mirror of
https://github.com/esengine/DeepSeek-Reasonix.git
synced 2026-11-11 10:01:27 -10:00
Compare commits
12
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
3763768436 | ||
|
|
7c15d9c619 | ||
|
|
1ea3f097f4 | ||
|
|
f4b815ecda | ||
|
|
7f4c9dc589 | ||
|
|
8aa4ea7a87 | ||
|
|
a0af95ffab | ||
|
|
2cc5ad25f4 | ||
|
|
07e6a61344 | ||
|
|
3807e411d7 | ||
|
|
21f37476dc | ||
|
|
5cd38fde8f |
No files matched your search
@@ -77,5 +77,10 @@
|
||||
"typescript": "^7.0.2",
|
||||
"vite": "^8.3.6",
|
||||
"vitest": "^5.1.0"
|
||||
},
|
||||
"pnpm": {
|
||||
"overrides": {
|
||||
"redos-detector": "file:./vendor/redos-detector-6.1.4.tgz"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -87,7 +87,7 @@ class BenchPort extends MockPort {
|
||||
// ?peerset=1 starts with session messages on and two projects already allowed through.
|
||||
if (query.has("peerset")) {
|
||||
this.peerSettings = {
|
||||
enabled: true, dispatch: true, max_dispatch_depth: 1, max_dispatched_sessions: 8, applies: "next_turn", pending: query.has("pending"), available: true,
|
||||
enabled: true, dispatch: true, max_dispatch_depth: 1, max_dispatched_sessions: 8, open_nested: false, applies: "next_turn", pending: query.has("pending"), available: true,
|
||||
path: "/Users/you/.reasonix/config.toml",
|
||||
release_projects: [{ path: "/Users/you/work/site", name: "site" }, { path: "/Users/you/work/api-gateway", name: "api-gateway" }],
|
||||
};
|
||||
|
||||
Generated
+8
-4
@@ -4,6 +4,9 @@ settings:
|
||||
autoInstallPeers: true
|
||||
excludeLinksFromLockfile: false
|
||||
|
||||
overrides:
|
||||
redos-detector: file:./vendor/redos-detector-6.1.4.tgz
|
||||
|
||||
importers:
|
||||
|
||||
.:
|
||||
@@ -2475,8 +2478,9 @@ packages:
|
||||
react-dom: ^16.0.0 || ^17.0.0 || ^18.0.0 || ^19.0.0
|
||||
react-is: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0
|
||||
|
||||
[email protected]:
|
||||
resolution: {integrity: sha512-lPlka1rEH6kK42gtgokvvxMmpAvyc28DcRjQbGxeP5RJsJWgAduBOdGedVCDPdSyCr4ay/JDxq3nGYsJZyt8tA==}
|
||||
redos-detector@file:vendor/redos-detector-6.1.4.tgz:
|
||||
resolution: {integrity: sha512-0a7pybN7BYeVkQnDiL0GuELrvrvRYd4cDqV0T7G0+UsRAdg7SfMy60Vf0V4SPeftu6hD60FiDi5VANJsP9+ISg==, tarball: file:vendor/redos-detector-6.1.4.tgz}
|
||||
version: 6.1.4
|
||||
engines: {node: '>=16.0.0'}
|
||||
hasBin: true
|
||||
|
||||
@@ -3049,7 +3053,7 @@ snapshots:
|
||||
'@lit/context': 1.1.6
|
||||
'@preact/signals-core': 1.14.4
|
||||
lit: 3.3.3
|
||||
redos-detector: 6.1.4
|
||||
redos-detector: file:vendor/redos-detector-6.1.4.tgz
|
||||
validate-color: 2.2.4
|
||||
zod: 3.25.76
|
||||
zod-to-json-schema: 3.25.2([email protected])
|
||||
@@ -5599,7 +5603,7 @@ snapshots:
|
||||
- '@types/react'
|
||||
- redux
|
||||
|
||||
[email protected]:
|
||||
redos-detector@file:vendor/redos-detector-6.1.4.tgz:
|
||||
dependencies:
|
||||
regjsparser: 0.13.0
|
||||
|
||||
|
||||
@@ -682,6 +682,7 @@ export const ACTIONS: UIAction[] = [
|
||||
{ id: "reply.run-detail", kind: "view", target: "none", proof: "interaction" },
|
||||
{ id: "deck.agents", kind: "view", target: "none", proof: "interaction" },
|
||||
{ id: "deck.jobs", kind: "view", target: "none", proof: "interaction" },
|
||||
{ id: "deck.processes", kind: "view", target: "none", proof: "interaction" },
|
||||
{ id: "plan.fold", kind: "view", target: "none", proof: "interaction" },
|
||||
{ id: "plan.collapse", kind: "view", target: "none", proof: "interaction" },
|
||||
{ id: "plan.jump", kind: "navigation", target: "none", proof: "interaction" },
|
||||
@@ -744,6 +745,7 @@ export const ACTIONS: UIAction[] = [
|
||||
{ id: "peers.approve-project", kind: "kernel-mutation", target: "none", proof: "authority-effect" },
|
||||
{ id: "peers.enable", kind: "kernel-mutation", target: "none", proof: "authority-effect" },
|
||||
{ id: "peers.dispatch", kind: "kernel-mutation", target: "none", proof: "authority-effect" },
|
||||
{ id: "peers.open-nested", kind: "kernel-mutation", target: "none", proof: "authority-effect" },
|
||||
{ id: "peers.release-revoke", kind: "kernel-mutation", target: "entity", proof: "authority-effect" },
|
||||
{ id: "peers.release-add", kind: "kernel-mutation", target: "none", proof: "authority-effect" },
|
||||
{ id: "peers.release-pick", kind: "view", target: "none", proof: "interaction" },
|
||||
|
||||
@@ -120,6 +120,7 @@ const SAID: Record<string, string> = {
|
||||
"terminal.not_found": "kernel.that_terminal_is_not_open",
|
||||
"terminal.shell_missing": "kernel.that_shell_is_not_on",
|
||||
"terminal.start_failed": "kernel.the_shell_could_not_start",
|
||||
"terminal.unsupported": "kernel.this_windows_has_no_built_in_terminal",
|
||||
"terminal.exited": "kernel.the_shell_has_ended",
|
||||
"terminal.limit": "kernel.too_many_terminals_are_open",
|
||||
"workspace.file_missing": "kernel.the_file_was_not_found",
|
||||
@@ -518,6 +519,7 @@ const SAID: Record<string, string> = {
|
||||
"subagent.not_found": "kernel.no_sub_agent_run_was",
|
||||
"subagent.not_owned": "kernel.that_sub_agent_belongs_to",
|
||||
"subagent.read_failed": "kernel.could_not_read_the_sub",
|
||||
"process.read_failed": "kernel.could_not_read_the_processes",
|
||||
"subagent.not_running": "kernel.that_sub_agent_is_no",
|
||||
"subagent.steer_empty": "kernel.guidance_cannot_be_empty",
|
||||
"subagent.steer_too_large": "kernel.that_guidance_is_longer_than",
|
||||
|
||||
@@ -111,6 +111,7 @@ export const KERNEL: Messages = {
|
||||
"kernel.could_not_reach_host_detail": { zh: "连接 {host} 失败:{detail}", en: "Could not reach {host}: {detail}" },
|
||||
"kernel.could_not_reach_the_account": { zh: "连不上账号服务,请检查网络后重试", en: "Could not reach the account service; check the network and try again" },
|
||||
"kernel.could_not_read_the_file": { zh: "文件预览读取失败,请重试", en: "Could not read the file preview. Try again." },
|
||||
"kernel.could_not_read_the_processes": { zh: "读取进程列表失败:{detail}", en: "Could not read the process list: {detail}" },
|
||||
"kernel.could_not_read_the_sub": { zh: "读取子代理记录失败:{detail}", en: "Could not read the sub-agent record: {detail}" },
|
||||
"kernel.could_not_read_this_commit": { zh: "无法读取这个提交", en: "Could not read this commit" },
|
||||
"kernel.could_not_read_this_file": { zh: "无法读取这个文件:{detail}。它可能被其他程序占用、已被移动,或在云盘里尚未下载。", en: "Could not read this file: {detail}. It may be open in another program, moved, or not yet downloaded from cloud storage." },
|
||||
@@ -596,6 +597,7 @@ export const KERNEL: Messages = {
|
||||
"kernel.the_session_workspace_or_runtime": { zh: "会话、工作区或运行时正在切换,这条消息没能立刻发出;它已排入队列,切换结束后会自动发送,不需要重发", en: "The session, workspace or runtime is switching, so this message could not be sent right away; it is queued and will be sent automatically when the switch ends - no need to send it again" },
|
||||
"kernel.the_settings_were_written_but": { zh: "设置已写入,但运行时未能按新设置重建:{detail}", en: "The settings were written, but the runtime could not be rebuilt on them: {detail}" },
|
||||
"kernel.the_sharing_listener_is_not": { zh: "共享入口配置不完整,无法接受连接。", en: "The sharing listener is not fully configured and cannot accept connections." },
|
||||
"kernel.this_windows_has_no_built_in_terminal": { zh: "这个 Windows 版本不支持内置终端(需要 Windows 10 1809 或更高版本)", en: "The built-in terminal needs Windows 10 version 1809 or later" },
|
||||
"kernel.the_shell_could_not_start": { zh: "终端没能启动", en: "The terminal could not start" },
|
||||
"kernel.the_shell_has_ended": { zh: "终端里的程序已经结束,重新启动后再输入", en: "The shell has ended; restart it before typing" },
|
||||
"kernel.the_shell_settings_were_not": { zh: "shell 设置未能保存:{detail}", en: "The shell settings were not saved: {detail}" },
|
||||
|
||||
@@ -79,6 +79,8 @@ export const REMOTE: Messages = {
|
||||
"remote.last_used_at": { zh: "最近使用 {at}", en: "Last used {at}" },
|
||||
"remote.leave_empty_to_let_the": { zh: "留空则每次开启时由系统随机选择,范围 1024–65535。更改端口后,手机需要重新扫码配对。", en: "Leave empty to let the system pick a port each time, or choose one from 1024 to 65535. After changing the port, phones need to scan a new code to pair again." },
|
||||
"remote.left_blank_it_tries_in": { zh: "留空时按这个顺序尝试:先 ssh-agent 持有的密钥,再 ~/.ssh 下的默认密钥;只有填了下面的环境变量或该机器要求交互时,才会走密码。", en: "Left blank, it tries in this order: keys held by ssh-agent, then the default keys under ~/.ssh. A password is only used when one of the variables below is set, or the machine asks interactively." },
|
||||
"remote.open_nested_folders_without_asking": { zh: "不询问直接打开本项目内的文件夹", en: "Open folders inside this project without asking" },
|
||||
"remote.open_nested_folders_without_asking_ds": { zh: "模型用 open_projects 把本项目目录下的子文件夹加为项目时,直接添加并标为可信,不再弹确认卡;项目之外的文件夹仍然会询问。被信任的文件夹里的配置可以运行程序,请只在你信任这个仓库时开启。", en: "When the model adds a subfolder of this project as a project with open_projects, it is added and trusted without a card; folders outside the project still ask. A trusted folder's configuration can run programs, so turn this on only for a repository you trust." },
|
||||
"remote.let_the_model_hand_a": { zh: "允许模型把任务交给别的会话", en: "Let the model hand a task to another session" },
|
||||
"remote.local_pairing_valid_until_time": { zh: "局域网配对 · {time} 前有效", en: "Local pairing · valid until {time}" },
|
||||
"remote.lost_the_connection_to_machine": { zh: "与 {machine} 的连接已中断", en: "Lost the connection to {machine}" },
|
||||
|
||||
@@ -10,6 +10,26 @@ export const SUBAGENT: Messages = {
|
||||
"subagent.deck.hint": { zh: "点一行在工作台打开", en: "Select a row to open it in the workbench" },
|
||||
"subagent.deck.open_full": { zh: "查看完整记录:{name}", en: "Open full transcript: {name}" },
|
||||
"subagent.deck.title": { zh: "本会话的子代理", en: "This session's subagents" },
|
||||
"subagent.process.cause.adopted": { zh: "沿用已有结果", en: "Reused an earlier result" },
|
||||
"subagent.process.cause.budget": { zh: "预算用尽", en: "Out of budget" },
|
||||
"subagent.process.cause.claim": { zh: "等被占用的写入路径", en: "Waiting for a write path someone holds" },
|
||||
"subagent.process.cause.held": { zh: "被搁置,尚未开始", en: "Held back, not started" },
|
||||
"subagent.process.cause.parked": { zh: "停在无人回答的提问上", en: "Stopped on a question nobody could answer" },
|
||||
"subagent.process.cause.slots": { zh: "等并发名额", en: "Waiting for a free slot" },
|
||||
"subagent.process.cause.writers": { zh: "等写入名额", en: "Waiting for a writer slot" },
|
||||
"subagent.process.chip": { zh: "进程", en: "Processes" },
|
||||
"subagent.process.empty": { zh: "现在没有在运行的东西", en: "Nothing is running" },
|
||||
"subagent.process.kind.job": { zh: "后台作业", en: "Background job" },
|
||||
"subagent.process.kind.subagent": { zh: "子代理", en: "Sub-agent" },
|
||||
"subagent.process.state.completed": { zh: "已完成", en: "Completed" },
|
||||
"subagent.process.state.failed": { zh: "失败", en: "Failed" },
|
||||
"subagent.process.state.interrupted": { zh: "已中断", en: "Interrupted" },
|
||||
"subagent.process.state.pending": { zh: "等待上游", en: "Waiting on upstream" },
|
||||
"subagent.process.state.queued": { zh: "排队中", en: "Queued" },
|
||||
"subagent.process.state.running": { zh: "运行中", en: "Running" },
|
||||
"subagent.process.state.skipped": { zh: "已跳过", en: "Skipped" },
|
||||
"subagent.process.state.stopped": { zh: "已停止", en: "Stopped" },
|
||||
"subagent.process.state.waiting": { zh: "等待中", en: "Waiting" },
|
||||
"subagent.doing": { zh: "正在{what}", en: "Now: {what}" },
|
||||
"subagent.full_transcript_of_subagent_name": { zh: "子代理完整记录:{name}", en: "Full transcript of subagent: {name}" },
|
||||
"subagent.group.all_delivered": { zh: "{n} 个都已交付", en: "All {n} delivered" },
|
||||
|
||||
@@ -183,6 +183,7 @@ export const WORKBENCH: Messages = {
|
||||
"workbench.changes_page_hint": { zh: "勾选=暂存;悬停文件头出「打开文件」「丢弃」;点差异里的行号打开这个文件。", en: "Tick to stage; hover a file header for Open file and Discard; click a line number to open the file." },
|
||||
"workbench.diff_cut_short": { zh: "差异太长,只显示了前一部分。", en: "The diff is long; only its first part is shown." },
|
||||
"workbench.expand_section": { zh: "展开{name}", en: "Expand {name}" },
|
||||
"workbench.resize_the_file_tree": { zh: "调整文件树宽度", en: "Resize the file tree" },
|
||||
"workbench.collapse_section": { zh: "收起{name}", en: "Collapse {name}" },
|
||||
"workbench.name_is_not_saved": { zh: "{name} 还没保存", en: "{name} is not saved" },
|
||||
"workbench.dont_save": { zh: "不保存", en: "Don't save" },
|
||||
|
||||
@@ -204,6 +204,9 @@ export interface PeersSettings {
|
||||
// Ceilings on chained and simultaneous dispatches; 0 removes a ceiling.
|
||||
max_dispatch_depth: number;
|
||||
max_dispatched_sessions: number;
|
||||
// The user's standing yes for open_projects to add folders inside the session's
|
||||
// own workspace without a card; it applies at the next call.
|
||||
open_nested: boolean;
|
||||
path: string;
|
||||
applies: "next_turn";
|
||||
pending: boolean;
|
||||
@@ -218,6 +221,7 @@ export interface PeersSettingsEdit {
|
||||
release_projects?: string[];
|
||||
max_dispatch_depth?: number;
|
||||
max_dispatched_sessions?: number;
|
||||
open_nested?: boolean;
|
||||
}
|
||||
|
||||
export type PeerRemember = "project" | "none";
|
||||
|
||||
@@ -31,8 +31,8 @@ export type DeliveryFault = "kernel_busy" | "unreachable" | "ui_stalled";
|
||||
// may not have landed, so the identity is the fault: the kernel was slow, could
|
||||
// not be reached, or this window's own event loop was too starved to say which.
|
||||
export class DeliveryError extends Error {
|
||||
constructor(readonly fault: DeliveryFault) {
|
||||
super("request unconfirmed: " + fault);
|
||||
constructor(readonly fault: DeliveryFault, readonly path?: string) {
|
||||
super("request unconfirmed: " + fault + (path ? " " + path : ""));
|
||||
this.name = "DeliveryError";
|
||||
}
|
||||
}
|
||||
@@ -324,7 +324,7 @@ export class MockBoundary extends MockShell {
|
||||
return mockMods(this.modConfig.enabled);
|
||||
}
|
||||
|
||||
protected peerSettings: PeersSettings = { enabled: false, dispatch: false, release_projects: [], max_dispatch_depth: 1, max_dispatched_sessions: 8, path: "/Users/you/.reasonix/config.toml", applies: "next_turn", pending: false, available: true };
|
||||
protected peerSettings: PeersSettings = { enabled: false, dispatch: false, release_projects: [], max_dispatch_depth: 1, max_dispatched_sessions: 8, open_nested: false, path: "/Users/you/.reasonix/config.toml", applies: "next_turn", pending: false, available: true };
|
||||
|
||||
async peersSettings(): Promise<PeersSettings> {
|
||||
return { ...this.peerSettings, release_projects: [...this.peerSettings.release_projects] };
|
||||
@@ -338,6 +338,7 @@ export class MockBoundary extends MockShell {
|
||||
dispatch: edit.dispatch ?? this.peerSettings.dispatch,
|
||||
max_dispatch_depth: edit.max_dispatch_depth ?? this.peerSettings.max_dispatch_depth,
|
||||
max_dispatched_sessions: edit.max_dispatched_sessions ?? this.peerSettings.max_dispatched_sessions,
|
||||
open_nested: edit.open_nested ?? this.peerSettings.open_nested,
|
||||
release_projects: keep ? keep.map((path) => this.peerSettings.release_projects.find((p) => p.path === path) ?? { path, name: path.split(/[\\/]/).filter(Boolean).at(-1) ?? path }) : this.peerSettings.release_projects,
|
||||
pending: true,
|
||||
};
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { MockMcp } from "./mock_mcp";
|
||||
import { HttpError, type HookCatalog, type HookDryRun, type HookEntry } from "./port";
|
||||
import type { SubagentRun, SubagentTranscript } from "./subagent";
|
||||
import type { ProcessEntry } from "./process";
|
||||
|
||||
// Hooks, and what a dry run of one reports back.
|
||||
export class MockHook extends MockMcp {
|
||||
@@ -17,6 +18,10 @@ export class MockHook extends MockMcp {
|
||||
return [];
|
||||
}
|
||||
|
||||
async processes(): Promise<ProcessEntry[]> {
|
||||
return [];
|
||||
}
|
||||
|
||||
async subagentTranscript(_ref: string): Promise<SubagentTranscript> {
|
||||
throw new HttpError(404, "no sub-agent run answers to that reference", { code: "subagent.not_found" });
|
||||
}
|
||||
|
||||
@@ -28,7 +28,7 @@ import type { NetworkProbe, NetworkSettings } from "./network";
|
||||
import type { ApprovalDefault, ApprovalMode, WorkspaceTrust, ApprovalVerdict, BrowserTab, Checkpoint, HistoryMessage, HostTodo, JobEntry, JobOutput, Preset, RewindPlan, ToolOutputPage, RewindResult, RewindUndo, RewindScope, SessionEntry, SessionStatus, SessionUsage, SourceUsage, WalletLine, WalletReading, PlanAction } from "./session";
|
||||
import type { ContextBreakdown, ShellOption, ShellSettings } from "./shell";
|
||||
import type { SkillCatalog, SkillEntry, SkillUpdateChange, SkillUpdatePlan } from "./skill";
|
||||
import type { SubagentRun, SubagentTranscript } from "./subagent";
|
||||
import type { RunningWorkPort } from "./running";
|
||||
import type { StudioCrash, StudioLogs, UpdateMode, UpdateProgress, VersionEntry, VersionHub, VersionNotes } from "./version";
|
||||
import type { ChangeDiff, CommitFile, CommitProposal, CommitRequest, CommitResult, DiscardRequest, GitCommitFiles, GitRepos, PushRequest, PushResult, PushPlan, StageRequest, WorkspaceBranch, WorkspaceBranches, WorkspaceChange, WorkspaceChanges, WorkspaceEntry, WorkspaceFile, WorkspaceFiles, WorkspaceGit, WorkspaceInfo, WorkspacePreviewPort } from "./workspace";
|
||||
import type { SessionTailPort } from "./session_tail";
|
||||
@@ -181,7 +181,7 @@ export interface TrayPrefs {
|
||||
closeToTray: boolean;
|
||||
}
|
||||
|
||||
export interface AgentPort extends MemoryHoldPort, WorkspacePreviewPort, SessionTailPort, UploadUsagePort, TerminalPort {
|
||||
export interface AgentPort extends RunningWorkPort, MemoryHoldPort, WorkspacePreviewPort, SessionTailPort, UploadUsagePort, TerminalPort {
|
||||
providerSetup(): Promise<ProviderSetup | null>;
|
||||
saveProviderKey(apiKey: string): Promise<void>;
|
||||
// The models that answer the job asked about; "chat" when none is named.
|
||||
@@ -708,11 +708,6 @@ export interface AgentPort extends MemoryHoldPort, WorkspacePreviewPort, Session
|
||||
// Ends one running tool call and lets the turn go on; a call that already
|
||||
// finished is refused (tool_call.not_running).
|
||||
stopToolCall(callId: string): Promise<void>;
|
||||
// The sub-agent runs this conversation spawned, running or finished, newest
|
||||
// first, and one run's conversation a page at a time: a running run is read
|
||||
// live. A run of another conversation is refused (subagent.not_owned).
|
||||
subagents(): Promise<SubagentRun[]>;
|
||||
subagentTranscript(ref: string, after?: number, limit?: number): Promise<SubagentTranscript>;
|
||||
// The person's guidance to a sub-agent that is still running, read at its next
|
||||
// tool call. Refused with a code when it is not running (subagent.not_running),
|
||||
// is empty, over the size or rate limit, or is another conversation's.
|
||||
|
||||
@@ -0,0 +1,19 @@
|
||||
/** Where a unit of work stands, in terms every kind of work can be mapped onto. */
|
||||
export type ProcessState = "pending" | "queued" | "running" | "waiting" | "completed" | "failed" | "stopped" | "interrupted" | "skipped";
|
||||
|
||||
/** What kind of work a process is: the owner that produced it. */
|
||||
export type ProcessKind = "subagent" | "job";
|
||||
|
||||
/** One unit of work this session has running now: GET /processes. */
|
||||
export interface ProcessEntry {
|
||||
/** "<kind>:<owner id>". */
|
||||
id: string;
|
||||
kind: ProcessKind;
|
||||
/** The process that started this one; absent for a root. */
|
||||
parent?: string;
|
||||
name: string;
|
||||
state: ProcessState;
|
||||
/** Why it is in this state, when the owner records one. */
|
||||
cause?: string;
|
||||
startedAt: number;
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
import type { ProcessEntry } from "./process";
|
||||
import type { SubagentRun, SubagentTranscript } from "./subagent";
|
||||
|
||||
/** What this conversation has handed off or has running. */
|
||||
export interface RunningWorkPort {
|
||||
// The sub-agent runs this conversation spawned, running or finished, newest
|
||||
// first, and one run's conversation a page at a time: a running run is read
|
||||
// live. A run of another conversation is refused (subagent.not_owned).
|
||||
subagents(): Promise<SubagentRun[]>;
|
||||
subagentTranscript(ref: string, after?: number, limit?: number): Promise<SubagentTranscript>;
|
||||
// What this session has running now across owners, read when asked and never
|
||||
// pushed: a window that does not open the list pays nothing for it.
|
||||
processes(): Promise<ProcessEntry[]>;
|
||||
}
|
||||
@@ -89,9 +89,19 @@ describe("idempotent reads", () => {
|
||||
});
|
||||
vi.stubGlobal("fetch", fetchMock);
|
||||
const probe = new Probe();
|
||||
await expect(probe.read("/status")).rejects.toBeInstanceOf(TypeError);
|
||||
await expect(probe.read("/status")).rejects.toBeInstanceOf(DeliveryError);
|
||||
expect(await probe.read("/status")).toEqual({ n: 2 });
|
||||
});
|
||||
|
||||
it("names an unreachable kernel and the read, not the browser's bare fetch failure", async () => {
|
||||
vi.stubGlobal("fetch", async () => {
|
||||
throw new TypeError("Failed to fetch");
|
||||
});
|
||||
const err = (await new Probe().read("/status").catch((e) => e)) as DeliveryError;
|
||||
expect(err).toBeInstanceOf(DeliveryError);
|
||||
expect(err.fault).toBe("unreachable");
|
||||
expect(err.path).toBe("/status");
|
||||
});
|
||||
});
|
||||
|
||||
describe("a decision submit", () => {
|
||||
|
||||
@@ -142,11 +142,22 @@ export class SseHttp {
|
||||
// A read the caller can abandon: it is not shared with another caller's, so
|
||||
// abandoning it frees its connection instead of leaving it to run out.
|
||||
protected async getUntil<T>(path: string, signal: AbortSignal): Promise<T> {
|
||||
const res = await fetch(this.base + path, { credentials: "same-origin", signal });
|
||||
const res = await SseHttp.reach(path, () => fetch(this.base + path, { credentials: "same-origin", signal }), signal);
|
||||
if (!res.ok) await SseHttp.fail(path, res);
|
||||
return (await res.json()) as T;
|
||||
}
|
||||
|
||||
// A transport failure on a read is the kernel being unreachable, and the
|
||||
// browser's own "Failed to fetch" names neither that nor which read it was.
|
||||
private static async reach(path: string, send: () => Promise<Response>, signal?: AbortSignal): Promise<Response> {
|
||||
try {
|
||||
return await send();
|
||||
} catch (e) {
|
||||
if (e instanceof TypeError && !signal?.aborted) throw new DeliveryError("unreachable", path);
|
||||
throw e;
|
||||
}
|
||||
}
|
||||
|
||||
private startRead<T>(path: string): Promise<T> {
|
||||
const flight = this.fetchRead<T>(path);
|
||||
const slot: ReadSlot = { flight };
|
||||
@@ -159,7 +170,7 @@ export class SseHttp {
|
||||
}
|
||||
|
||||
private async fetchRead<T>(path: string): Promise<T> {
|
||||
const res = await fetch(this.base + path, { credentials: "same-origin" });
|
||||
const res = await SseHttp.reach(path, () => fetch(this.base + path, { credentials: "same-origin" }));
|
||||
if (!res.ok) await SseHttp.fail(path, res);
|
||||
return (await res.json()) as T;
|
||||
}
|
||||
|
||||
@@ -2,6 +2,7 @@ import type { ChangeDiff, StageRequest, WorkspaceBranches, WorkspaceChanges, Wor
|
||||
import type { DiscardRequest, GitCommitFiles, GitHistory, GitRepos, PushPlan, PushRequest, PushResult } from "./workspace";
|
||||
import type { JobOutput, ToolOutputPage } from "./session";
|
||||
import type { SubagentRun, SubagentTranscript } from "./subagent";
|
||||
import type { ProcessEntry } from "./process";
|
||||
import { SseFeedback } from "./sse_feedback";
|
||||
import { host } from "./host";
|
||||
import { HttpError } from "./http_error";
|
||||
@@ -82,6 +83,9 @@ export class SseWorkspace extends SseFeedback {
|
||||
subagents() {
|
||||
return this.get<SubagentRun[]>("/subagents");
|
||||
}
|
||||
processes() {
|
||||
return this.get<ProcessEntry[]>("/processes");
|
||||
}
|
||||
subagentTranscript(ref: string, after = 0, limit = 0) {
|
||||
const query = [after ? `after=${after}` : "", limit ? `limit=${limit}` : ""].filter(Boolean).join("&");
|
||||
return this.get<SubagentTranscript>("/subagents/" + encodeURIComponent(ref) + "/transcript" + (query ? "?" + query : ""));
|
||||
|
||||
@@ -1750,3 +1750,13 @@ A sub-agent row, by the list's own width:
|
||||
- Hover waits briefly before opening and closing so the pointer can cross to
|
||||
the card. Click and keyboard activation also work; Escape restores focus.
|
||||
The portaled card follows its anchor and stays inside the viewport.
|
||||
|
||||
## Display math
|
||||
|
||||
- A display formula scrolls sideways when it is wider than the column, which
|
||||
makes its vertical overflow hidden as well. KaTeX draws raised exponents, tall
|
||||
delimiters and big-operator limits past the line box, so the block carries
|
||||
`.6em` of padding above and below and only `.2em` of margin: the clip edge
|
||||
sits outside everything a formula draws, and the gap between blocks is
|
||||
unchanged to the eye. Padding on one side only leaves the other flush with
|
||||
the clip.
|
||||
@@ -3550,7 +3550,7 @@
|
||||
}
|
||||
|
||||
.md .katex { font-size: 1.02em }
|
||||
.md .katex-display { margin: .6em 0; overflow-x: auto; overflow-y: hidden; padding-bottom: 2px }
|
||||
.md .katex-display { margin: .2em 0; overflow-x: auto; overflow-y: hidden; padding: .6em 0 }
|
||||
|
||||
.md-tw { margin: 0 0 .7em; overflow-x: auto }
|
||||
|
||||
|
||||
@@ -0,0 +1,3 @@
|
||||
.workbench-tree-resize { position: absolute; top: 0; bottom: 0; right: -4px; width: 8px; z-index: 3; cursor: col-resize; touch-action: none; }
|
||||
.workbench-tree-resize:hover, .workbench-tree-resize[data-on] { background: linear-gradient(to right, transparent 3px, var(--accent) 3px, var(--accent) 5px, transparent 5px); }
|
||||
.workbench-tree-resize:focus-visible { outline: 2px solid var(--focus); outline-offset: -2px; }
|
||||
@@ -0,0 +1,28 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import app from "./app.css?raw";
|
||||
|
||||
const rule = (selector: string): string => {
|
||||
const at = app.indexOf(selector + " {");
|
||||
return at < 0 ? "" : app.slice(at, app.indexOf("}", at));
|
||||
};
|
||||
|
||||
const em = (value: string): number => (value.endsWith("em") ? parseFloat(value) : NaN);
|
||||
|
||||
describe("a display formula in a message", () => {
|
||||
const block = rule(".md .katex-display");
|
||||
|
||||
it("scrolls sideways when it is too wide", () => {
|
||||
expect(block).toMatch(/overflow-x:\s*auto/);
|
||||
});
|
||||
|
||||
it("leaves room above and below for what a raised exponent or a tall delimiter draws past the line", () => {
|
||||
expect(block).toMatch(/overflow-y:\s*hidden/);
|
||||
const pad = block.match(/padding:\s*([\d.]+em)\s+0/);
|
||||
expect(pad, "vertical padding in em on both sides").not.toBeNull();
|
||||
expect(em(pad![1])).toBeGreaterThanOrEqual(0.55);
|
||||
});
|
||||
|
||||
it("is not given a bottom-only allowance that leaves the top flush with the clip edge", () => {
|
||||
expect(block).not.toMatch(/padding-bottom/);
|
||||
});
|
||||
});
|
||||
@@ -42,6 +42,7 @@
|
||||
@import "./sessionpin.css" layer(legacy);
|
||||
@import "./treerows.css" layer(legacy);
|
||||
@import "./workbench-changes.css" layer(legacy);
|
||||
@import "./explorer-resize.css" layer(legacy);
|
||||
@import "./agentrun.css" layer(legacy);
|
||||
@import "./gitgraph.css" layer(legacy);
|
||||
@import "./modelmenu.css" layer(legacy);
|
||||
|
||||
@@ -1188,11 +1188,11 @@ body { background: var(--page) !important; }
|
||||
.workbench-tab:hover .workbench-tab-close, .workbench-tab[aria-selected="true"] .workbench-tab-close { opacity: 1; }
|
||||
.workbench-tab-close:hover { color: var(--text); }
|
||||
.workbench-tabs svg { width: 13px; height: 13px; flex: none; }
|
||||
.workbench-body { flex: 1; min-height: 0; min-width: 0; display: grid; grid-template-columns: clamp(190px, 36%, 260px) minmax(0, 1fr); grid-template-rows: minmax(0, 1fr); }
|
||||
.workbench-body { flex: 1; min-height: 0; min-width: 0; display: grid; grid-template-columns: min(var(--wb-tree-w, clamp(190px, 36%, 260px)), 70%) minmax(0, 1fr); grid-template-rows: minmax(0, 1fr); }
|
||||
.workbench-canvas { grid-column: 2; grid-row: 1; min-width: 0; min-height: 0; display: flex; flex-direction: column; }
|
||||
.workbench-content > .studio-browser, .workbench-content > .bpanel { width: 100%; height: 100%; min-width: 0; }
|
||||
.workbench-canvas .bbar > .vtabs { display: none; }
|
||||
.workbench-explorer { grid-column: 1; grid-row: 1; min-width: 0; display: flex; flex-direction: column; border-right: 1px solid var(--hair); background: var(--page); }
|
||||
.workbench-explorer { position: relative; grid-column: 1; grid-row: 1; min-width: 0; display: flex; flex-direction: column; border-right: 1px solid var(--hair); background: var(--page); }
|
||||
.workbench-explorer-head { height: 36px; padding: 0 12px; flex: none; display: flex; align-items: center; justify-content: space-between; border-bottom: 1px solid var(--hair); color: var(--muted); font: var(--w-med) var(--fs-meta)/1 var(--ui); text-transform: uppercase; }
|
||||
.workbench-explorer-head > span { min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
||||
.workbench-explorer-head small { color: var(--faint); font-size: var(--fs-micro); }
|
||||
|
||||
@@ -5,8 +5,7 @@ import { keepSame } from "./stable";
|
||||
import { usePaneCallbacks } from "./usePaneCallbacks";
|
||||
import { watchTreeWake } from "./treewake";
|
||||
import { usePinnedSessions } from "./usePinnedSessions";
|
||||
import { AutoArchiveNotice } from "./AutoArchiveNotice";
|
||||
import { LevelUpNotice } from "./LevelUpNotice";
|
||||
import { useWindowNotices } from "./useWindowNotices";
|
||||
import { lazy, Suspense, useCallback, useEffect, useMemo, useRef, useState, useSyncExternalStore, type CSSProperties } from "react";
|
||||
import { reason } from "../i18n/kernel";
|
||||
import { t } from "../i18n";
|
||||
@@ -20,7 +19,6 @@ import { Nav } from "./Nav";
|
||||
import { useTreeWindow, type TreePaging } from "./treewindow";
|
||||
import { useNavRail } from "./navrail";
|
||||
import { feedbackEntryTab } from "./feedbackentry";
|
||||
import { ErrorBar } from "./ErrorBar";
|
||||
import { noteFault } from "./fault";
|
||||
import { ToastStack } from "./ToastStack";
|
||||
import { useLinkRouting } from "./links";
|
||||
@@ -481,11 +479,8 @@ export function App({ hub }: { hub: HubPort }) {
|
||||
// A failed request floats above the composer of the pane in front, where the
|
||||
// eye already is; with no pane on screen it takes the main area's corner.
|
||||
const activePaneShown = !!activeRuntime && panePorts.has(activeRuntime.id);
|
||||
const errorBar = useMemo(() => (error ? <ErrorBar text={error} onDismiss={() => setError("")} onReport={() => setFeedback("send")} /> : null), [error]);
|
||||
const bar = useMemo(
|
||||
() => errorBar ?? <LevelUpNotice port={machineRt ? machinePort : null} tick={feedback === null ? 0 : 1} fallback={<AutoArchiveNotice tree={tree} />} />,
|
||||
[errorBar, tree, machineRt, machinePort, feedback],
|
||||
);
|
||||
const reportError = useCallback(() => setFeedback("send"), []);
|
||||
const { errorBar, note, bar } = useWindowNotices({ error, setError, onReport: reportError, tree, port: machineRt ? machinePort : null, feedbackOpen: feedback !== null });
|
||||
const activeWorkspace = tree.find((ws) => !activeRuntime?.host && workspaceContains(ws, activeRuntime?.root)) ?? tree[0];
|
||||
const newSessionRoot = activeWorkspace?.root;
|
||||
const startConversation = useNewConversation(active, runtimes, newSessionRoot, openPane, openRemotePane);
|
||||
@@ -704,7 +699,8 @@ export function App({ hub }: { hub: HubPort }) {
|
||||
terminalPulse={terminalRequests[rt.id] ?? 0}
|
||||
viewRequest={viewRequests[rt.id]}
|
||||
sessionRef={sessionRefs[rt.id]}
|
||||
alert={rt.id === active ? bar : undefined}
|
||||
alert={rt.id === active && !errorBar ? note : undefined}
|
||||
failure={rt.id === active ? errorBar : undefined}
|
||||
connect={first.connect}
|
||||
needsProject={needsProject}
|
||||
onOpenProject={openProject}
|
||||
|
||||
@@ -1,25 +1,31 @@
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
||||
import type { JobEntry, JobOutput } from "../port/port";
|
||||
import type { ProcessEntry } from "../port/process";
|
||||
import { t } from "../i18n";
|
||||
import { reason } from "../i18n/kernel";
|
||||
import { StudioIcon } from "./StudioIcon";
|
||||
import { agentsIn } from "./panels/derive";
|
||||
import { AgentDeck } from "./AgentDeck";
|
||||
import { AgentTranscript } from "./panels/AgentTranscript";
|
||||
import { JobDeck } from "./JobDeck";
|
||||
import { ProcessDeck } from "./ProcessDeck";
|
||||
import { JobOutputView } from "./panels/JobOutputView";
|
||||
import type { Task } from "./panels/Agents";
|
||||
import { useDismiss } from "./dismiss";
|
||||
|
||||
export type Deck = "" | "agents" | "jobs";
|
||||
export type Deck = "" | "agents" | "jobs" | "processes";
|
||||
|
||||
/** What is running on this session's behalf but is not in the transcript: work
|
||||
* handed to a sub-agent, and processes left running in the background. The
|
||||
* readings sit at the right end of the run rail, beside the other numbers
|
||||
* about this turn, and each opens on hover the way the context reading does —
|
||||
* one gesture for every summary on this row. */
|
||||
export function DeckChips({ tasks, jobs, open, onOpen, onCancelJob, onReadJob, onOpenRun, onStopCall, onClearJobs, onOpenJob }: { tasks: Task[]; jobs: JobEntry[]; open: Deck; onOpen: (next: (was: Deck) => Deck) => void; onCancelJob?: (id: string) => Promise<void>; onReadJob?: (id: string) => Promise<JobOutput>; onOpenRun?: (call: string) => Promise<boolean>; onStopCall?: (callId: string) => void; onClearJobs?: (ids: string[]) => void; onOpenJob?: (job: JobEntry) => void }) {
|
||||
export function DeckChips({ tasks, jobs, open, onOpen, onCancelJob, onReadJob, onOpenRun, onStopCall, onClearJobs, onOpenJob, onReadProcesses }: { tasks: Task[]; jobs: JobEntry[]; open: Deck; onOpen: (next: (was: Deck) => Deck) => void; onCancelJob?: (id: string) => Promise<void>; onReadJob?: (id: string) => Promise<JobOutput>; onOpenRun?: (call: string) => Promise<boolean>; onStopCall?: (callId: string) => void; onClearJobs?: (ids: string[]) => void; onOpenJob?: (job: JobEntry) => void; onReadProcesses?: () => Promise<ProcessEntry[]> }) {
|
||||
const agentsBox = useRef<HTMLDivElement>(null);
|
||||
const jobsBox = useRef<HTMLDivElement>(null);
|
||||
const processBox = useRef<HTMLDivElement>(null);
|
||||
const [processes, setProcesses] = useState<ProcessEntry[]>([]);
|
||||
const [processError, setProcessError] = useState("");
|
||||
const [viewing, setViewing] = useState("");
|
||||
const [viewingJob, setViewingJob] = useState("");
|
||||
const viewed = viewing ? tasks.find((x) => x.id === viewing) : undefined;
|
||||
@@ -38,6 +44,16 @@ export function DeckChips({ tasks, jobs, open, onOpen, onCancelJob, onReadJob, o
|
||||
};
|
||||
useDismiss(open === "agents", agentsBox, shut);
|
||||
useDismiss(open === "jobs", jobsBox, shut);
|
||||
useDismiss(open === "processes", processBox, shut);
|
||||
useEffect(() => {
|
||||
if (open !== "processes" || !onReadProcesses) return;
|
||||
let current = true;
|
||||
onReadProcesses().then(
|
||||
(rows) => { if (current) { setProcesses(rows); setProcessError(""); } },
|
||||
(e: unknown) => { if (current) { setProcesses([]); setProcessError(reason(e)); } },
|
||||
);
|
||||
return () => { current = false; };
|
||||
}, [open, onReadProcesses, tasks, jobs]);
|
||||
const liveAgents = useMemo(() => agentsIn(tasks.filter((x) => x.running)), [tasks]);
|
||||
const liveJobs = useMemo(() => jobs.filter((j) => j.status === "running").length, [jobs]);
|
||||
// A failure stays loud until the list has been opened on it.
|
||||
@@ -79,6 +95,27 @@ export function DeckChips({ tasks, jobs, open, onOpen, onCancelJob, onReadJob, o
|
||||
{viewingJob && onReadJob && jobs.some((j) => j.id === viewingJob) && (
|
||||
<JobOutputView job={jobs.find((j) => j.id === viewingJob)!} read={onReadJob} onClose={() => setViewingJob("")} onCancel={onCancelJob} />
|
||||
)}
|
||||
{onReadProcesses && (
|
||||
<div ref={processBox} className="studio-deck-anchor" data-open={open === "processes" ? "" : undefined}>
|
||||
<button
|
||||
type="button"
|
||||
className="studio-deckchip"
|
||||
data-action="deck.processes"
|
||||
aria-label={t("subagent.process.chip")}
|
||||
title={t("subagent.process.chip")}
|
||||
data-live={liveAgents || liveJobs ? "" : undefined}
|
||||
aria-expanded={open === "processes"}
|
||||
aria-haspopup="dialog"
|
||||
onClick={() => onOpen((was) => (was === "processes" ? "" : "processes"))}
|
||||
>
|
||||
<StudioIcon name="play" />
|
||||
<span>{t("subagent.process.chip")}</span>
|
||||
</button>
|
||||
<div className="studio-deck-pop" role="dialog" aria-label={t("subagent.process.chip")}>
|
||||
<ProcessDeck rows={processes} error={processError} />
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
{jobs.length > 0 && (
|
||||
<div ref={jobsBox} className="studio-deck-anchor" data-open={open === "jobs" ? "" : undefined}>
|
||||
<button
|
||||
|
||||
@@ -0,0 +1,110 @@
|
||||
import { useLayoutEffect, useRef } from "react";
|
||||
import type { KeyboardEvent as ReactKeyboardEvent, PointerEvent as ReactPointerEvent } from "react";
|
||||
import { t } from "../i18n";
|
||||
|
||||
const KEY = "rx-wb-tree-w";
|
||||
const MIN = 150;
|
||||
const MAX = 560;
|
||||
const VAR = "--wb-tree-w";
|
||||
|
||||
const clamp = (v: number) => Math.min(MAX, Math.max(MIN, Math.round(v)));
|
||||
|
||||
function saved(): number | null {
|
||||
try {
|
||||
const n = Number(localStorage.getItem(KEY));
|
||||
return Number.isFinite(n) && n > 0 ? clamp(n) : null;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
function keep(w: number | null) {
|
||||
try {
|
||||
if (w === null) localStorage.removeItem(KEY);
|
||||
else localStorage.setItem(KEY, String(w));
|
||||
} catch {
|
||||
/* the width only fails to be remembered */
|
||||
}
|
||||
}
|
||||
|
||||
/** The explorer's width is the reader's call about their own screen, so it is
|
||||
* kept in this browser and absent until they drag; without it the stylesheet's
|
||||
* clamp decides. */
|
||||
export function ExplorerResize() {
|
||||
const bar = useRef<HTMLDivElement>(null);
|
||||
const host = () => bar.current?.closest<HTMLElement>(".workbench-body") ?? null;
|
||||
const explorer = () => bar.current?.closest<HTMLElement>(".workbench-explorer") ?? null;
|
||||
|
||||
const apply = (w: number | null) => {
|
||||
const body = host();
|
||||
if (!body) return;
|
||||
if (w === null) body.style.removeProperty(VAR);
|
||||
else body.style.setProperty(VAR, `${w}px`);
|
||||
bar.current?.setAttribute("aria-valuenow", String(Math.round(explorer()?.getBoundingClientRect().width ?? 0)));
|
||||
};
|
||||
|
||||
useLayoutEffect(() => {
|
||||
const w = saved();
|
||||
if (w !== null) apply(w);
|
||||
}, []);
|
||||
|
||||
const drag = (e: ReactPointerEvent<HTMLDivElement>) => {
|
||||
if (e.button !== 0) return;
|
||||
e.preventDefault();
|
||||
const el = e.currentTarget;
|
||||
const from = explorer()?.getBoundingClientRect().width ?? 0;
|
||||
const x0 = e.clientX;
|
||||
let now = from;
|
||||
el.setPointerCapture(e.pointerId);
|
||||
el.dataset.on = "";
|
||||
const move = (ev: PointerEvent) => {
|
||||
now = clamp(from + ev.clientX - x0);
|
||||
apply(now);
|
||||
};
|
||||
const up = () => {
|
||||
el.removeEventListener("pointermove", move);
|
||||
el.removeEventListener("pointerup", up);
|
||||
el.removeEventListener("pointercancel", up);
|
||||
delete el.dataset.on;
|
||||
keep(clamp(now));
|
||||
};
|
||||
el.addEventListener("pointermove", move);
|
||||
el.addEventListener("pointerup", up);
|
||||
el.addEventListener("pointercancel", up);
|
||||
};
|
||||
|
||||
const reset = () => {
|
||||
apply(null);
|
||||
keep(null);
|
||||
};
|
||||
|
||||
const key = (e: ReactKeyboardEvent<HTMLDivElement>) => {
|
||||
if (e.key === "Home") {
|
||||
e.preventDefault();
|
||||
reset();
|
||||
return;
|
||||
}
|
||||
const dir = e.key === "ArrowLeft" ? -1 : e.key === "ArrowRight" ? 1 : 0;
|
||||
if (!dir) return;
|
||||
e.preventDefault();
|
||||
const next = clamp((explorer()?.getBoundingClientRect().width ?? 0) + dir * (e.shiftKey ? 48 : 12));
|
||||
apply(next);
|
||||
keep(next);
|
||||
};
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={bar}
|
||||
className="workbench-tree-resize"
|
||||
role="separator"
|
||||
aria-orientation="vertical"
|
||||
aria-label={t("workbench.resize_the_file_tree")}
|
||||
aria-valuemin={MIN}
|
||||
aria-valuemax={MAX}
|
||||
tabIndex={0}
|
||||
onPointerDown={drag}
|
||||
onDoubleClick={reset}
|
||||
onKeyDown={key}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -3,6 +3,7 @@ import { t } from "../i18n";
|
||||
import { money } from "../i18n/format";
|
||||
import type { ContextBreakdown, JobEntry, JobOutput, McpEntry, SessionUsage } from "../port/port";
|
||||
import type { CostQuote } from "../port/wire";
|
||||
import type { ProcessEntry } from "../port/process";
|
||||
import { cacheRate as rateOf } from "../state/overview";
|
||||
import { SessionOverviewCard } from "./SessionOverviewCard";
|
||||
import type { CurrencyAmount, Metrics } from "../state/session_types";
|
||||
@@ -20,7 +21,7 @@ import { MASK, type Wallet } from "./wallet";
|
||||
import type { SpeedSummary } from "./speed";
|
||||
|
||||
/** The numbers about this turn, in one row under the composer. */
|
||||
export function MeterRail({ tps, trail, running, speed, metrics, ctx, mcp, compact, cost, turnCost = [], wallet, hideAmounts, tasks, jobs, onSettings, onCancelJob, onReadJob, onOpenRun, onStopCall, onClearJobs, onOpenJob, usage, quote, tariff, onTariffFlip }: {
|
||||
export function MeterRail({ tps, trail, running, speed, metrics, ctx, mcp, compact, cost, turnCost = [], wallet, hideAmounts, tasks, jobs, onSettings, onCancelJob, onReadJob, onReadProcesses, onOpenRun, onStopCall, onClearJobs, onOpenJob, usage, quote, tariff, onTariffFlip }: {
|
||||
tps: number;
|
||||
trail: number[];
|
||||
running: boolean;
|
||||
@@ -40,6 +41,7 @@ export function MeterRail({ tps, trail, running, speed, metrics, ctx, mcp, compa
|
||||
onSettings: (section?: string) => void;
|
||||
onCancelJob: (id: string) => Promise<void>;
|
||||
onReadJob?: (id: string) => Promise<JobOutput>;
|
||||
onReadProcesses?: () => Promise<ProcessEntry[]>;
|
||||
onOpenRun?: (call: string) => Promise<boolean>;
|
||||
onStopCall?: (callId: string) => void;
|
||||
onClearJobs?: (ids: string[]) => void;
|
||||
@@ -115,7 +117,7 @@ export function MeterRail({ tps, trail, running, speed, metrics, ctx, mcp, compa
|
||||
{cost.length > 0 && turnCost.some((m) => m.amount > 0) && <span className="studio-meter-cost"><span>{t("composer.this_turn")}</span>{hideAmounts ? <b>{MASK}</b> : turnCost.map((m) => <b key={m.currency}>{money(m.amount, m.currency)}</b>)}</span>}
|
||||
<button className="studio-meter-mask" type="button" data-action="metrics.hide-amounts" aria-pressed={hideAmounts} aria-label={hideAmounts ? t("composer.show_amounts") : t("composer.hide_amounts")} title={hideAmounts ? t("composer.show_amounts") : t("composer.hide_amounts")} onClick={() => setHidesAmounts(!hideAmounts)}><StudioIcon name={hideAmounts ? "eyeoff" : "eye"} /></button>
|
||||
{wallet.kind === "read" && <button className="studio-meter-wallet" data-short={wallet.reading.available ? undefined : "true"} title={wallet.reading.available ? undefined : t("common.insufficient_balance")} data-action="settings.section" data-value="usage" aria-label={wallet.reading.available ? t("composer.view_wallet_balance") : `${t("composer.view_wallet_balance")}, ${t("common.insufficient_balance")}`} onClick={() => onSettings("spend")}><StudioIcon name="wallet" /><b>{hideAmounts ? MASK : wallet.reading.display}</b></button>}
|
||||
<DeckChips tasks={tasks} jobs={jobs} open={deck} onOpen={setDeck} onCancelJob={onCancelJob} onReadJob={onReadJob} onOpenRun={onOpenRun} onStopCall={onStopCall} onClearJobs={onClearJobs} onOpenJob={onOpenJob} />
|
||||
<DeckChips tasks={tasks} jobs={jobs} open={deck} onOpen={setDeck} onCancelJob={onCancelJob} onReadJob={onReadJob} onReadProcesses={onReadProcesses} onOpenRun={onOpenRun} onStopCall={onStopCall} onClearJobs={onClearJobs} onOpenJob={onOpenJob} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -103,7 +103,7 @@ const totalsOf = (st: SessionStatus) => ({
|
||||
incompleteReason: st.sessionCostQuote?.incompleteReason,
|
||||
});
|
||||
|
||||
function PaneView({ port, hub, rt, title, active, visible, sideHost, side, onFocus, onReport, onSessionChanged, onTurnDone, pulse, findPulse, findTogglePulse = 0, filesPulse = 0, terminalPulse = 0, viewRequest, sessionRef, onSettings, onFeedback, onOpenSession, needsProject, onOpenProject, onKeepHere, connect, theme, dockW, dockMax, onDockW, manualBrowser = false, onManualBrowser, alert }: PaneProps) {
|
||||
function PaneView({ port, hub, rt, title, active, visible, sideHost, side, onFocus, onReport, onSessionChanged, onTurnDone, pulse, findPulse, findTogglePulse = 0, filesPulse = 0, terminalPulse = 0, viewRequest, sessionRef, onSettings, onFeedback, onOpenSession, needsProject, onOpenProject, onKeepHere, connect, theme, dockW, dockMax, onDockW, manualBrowser = false, onManualBrowser, alert, failure }: PaneProps) {
|
||||
const [s, dispatch] = useReducer(reduce, initialState);
|
||||
const [traj, trajDispatch] = useReducer(reduceTraj, initialTraj);
|
||||
const [sessionRead, setSessionRead] = useState<SessionRead>({ kind: "pending" });
|
||||
@@ -200,6 +200,7 @@ function PaneView({ port, hub, rt, title, active, visible, sideHost, side, onFoc
|
||||
}, [pacer]);
|
||||
|
||||
const readJob = useCallback((id: string) => port.jobOutput(id), [port]);
|
||||
const readProcesses = useCallback(() => port.processes(), [port]);
|
||||
const refreshStatus = useStatusRefresh(port, applyStatus, statusRead);
|
||||
|
||||
const [wallet, refreshWallet] = useWallet(port, accountOf(status?.modelRef));
|
||||
@@ -672,6 +673,7 @@ function PaneView({ port, hub, rt, title, active, visible, sideHost, side, onFoc
|
||||
wallet={wallet} hideAmounts={hideAmounts} tasks={rail.tasks} jobs={jobs} onSettings={onSettings}
|
||||
onCancelJob={(id) => cancelJob(port, id, notify, refreshStatus).then(() => {})}
|
||||
onReadJob={readJob}
|
||||
onReadProcesses={readProcesses}
|
||||
onOpenRun={openRun} onStopCall={(id) => void port.stopToolCall(id).catch(notify)}
|
||||
onClearJobs={(ids) => setClearedJobs((was) => new Set([...was, ...ids]))}
|
||||
onOpenJob={jobsLive.open}
|
||||
@@ -682,6 +684,7 @@ function PaneView({ port, hub, rt, title, active, visible, sideHost, side, onFoc
|
||||
both are dismissed one at a time, so nothing else bounds how many can
|
||||
be on screen at once. */}
|
||||
<div className="composenotes">
|
||||
{failure}
|
||||
{s.error && (
|
||||
<ErrorBar text={s.error} onDismiss={() => dispatch({ kind: "__error", text: "" } as never)} onReport={onFeedback} />
|
||||
)}
|
||||
|
||||
@@ -47,6 +47,13 @@ export function PeerSettings({ port, settings, onSaved }: { port: AgentPort; set
|
||||
</span>
|
||||
<Switch data-action="peers.dispatch" on={state.dispatch} busy={off || !state.enabled} label={t("remote.let_the_model_hand_a")} onClick={() => void save({ dispatch: !state.dispatch })} />
|
||||
</div>
|
||||
<div className="lrow">
|
||||
<span className="tx">
|
||||
<span className="lb">{t("remote.open_nested_folders_without_asking")}</span>
|
||||
<span className="ds">{t("remote.open_nested_folders_without_asking_ds")}</span>
|
||||
</span>
|
||||
<Switch data-action="peers.open-nested" on={state.open_nested} busy={off || !state.enabled || !state.dispatch} label={t("remote.open_nested_folders_without_asking")} onClick={() => void save({ open_nested: !state.open_nested })} />
|
||||
</div>
|
||||
{!state.available && <div className="peernote" role="status">{t("remote.not_supported_in_this_session")}</div>}
|
||||
{state.pending && <div className="peernote" role="status">{t("remote.saved_takes_effect_from_the")}</div>}
|
||||
<div className="peernote">{t("remote.these_switches_are_kept_in")}<code>{state.path}</code></div>
|
||||
|
||||
@@ -0,0 +1,60 @@
|
||||
import { t } from "../i18n";
|
||||
import type { ProcessEntry } from "../port/process";
|
||||
import { jobElapsed } from "./panels/jobElapsed";
|
||||
|
||||
function stateLabel(state: string): string {
|
||||
switch (state) {
|
||||
case "pending": return t("subagent.process.state.pending");
|
||||
case "queued": return t("subagent.process.state.queued");
|
||||
case "running": return t("subagent.process.state.running");
|
||||
case "waiting": return t("subagent.process.state.waiting");
|
||||
case "completed": return t("subagent.process.state.completed");
|
||||
case "failed": return t("subagent.process.state.failed");
|
||||
case "stopped": return t("subagent.process.state.stopped");
|
||||
case "interrupted": return t("subagent.process.state.interrupted");
|
||||
case "skipped": return t("subagent.process.state.skipped");
|
||||
default: return state;
|
||||
}
|
||||
}
|
||||
|
||||
function causeLabel(cause: string): string {
|
||||
switch (cause) {
|
||||
case "slots": return t("subagent.process.cause.slots");
|
||||
case "writers": return t("subagent.process.cause.writers");
|
||||
case "claim": return t("subagent.process.cause.claim");
|
||||
case "held": return t("subagent.process.cause.held");
|
||||
case "adopted": return t("subagent.process.cause.adopted");
|
||||
case "budget": return t("subagent.process.cause.budget");
|
||||
case "parked": return t("subagent.process.cause.parked");
|
||||
default: return cause;
|
||||
}
|
||||
}
|
||||
|
||||
function kindLabel(kind: string): string {
|
||||
switch (kind) {
|
||||
case "subagent": return t("subagent.process.kind.subagent");
|
||||
case "job": return t("subagent.process.kind.job");
|
||||
default: return kind;
|
||||
}
|
||||
}
|
||||
|
||||
/** What this session has running now, one row per process. State and cause are
|
||||
* the kernel's words for it; a value this page has no wording for is shown as
|
||||
* it came rather than guessed at. */
|
||||
export function ProcessDeck({ rows, error }: { rows: ProcessEntry[]; error: string }) {
|
||||
if (error) return <div className="empty" role="alert">{error}</div>;
|
||||
if (rows.length === 0) return <div className="empty">{t("subagent.process.empty")}</div>;
|
||||
return (
|
||||
<div className="job-deck">
|
||||
{rows.map((p) => (
|
||||
<div className="job-deck-row" key={p.id} data-status={p.state}>
|
||||
<div className="job-deck-line">
|
||||
<b className="job-deck-name">{p.name}</b>
|
||||
<span className="job-deck-time">{[kindLabel(p.kind), stateLabel(p.state), jobElapsed(Date.now() - p.startedAt)].join(" · ")}</span>
|
||||
</div>
|
||||
{p.cause && <span className="job-deck-tail">{causeLabel(p.cause)}</span>}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -55,6 +55,7 @@ const DocumentPreview = lazy(() => import("./document/DocumentPreview"));
|
||||
const CodeEditor = lazy(() => import("./CodeEditor"));
|
||||
|
||||
import { treeRows } from "./workbench_tree";
|
||||
import { ExplorerResize } from "./ExplorerResize";
|
||||
import { AgentRunView } from "./AgentRunView";
|
||||
import { JobRunView } from "./JobRunView";
|
||||
import { useAgentTabs, type AgentRequest } from "./useAgentTabs";
|
||||
@@ -713,6 +714,7 @@ export function WorkbenchPanel({
|
||||
</div>
|
||||
</main>
|
||||
<aside className="workbench-explorer">
|
||||
<ExplorerResize />
|
||||
<WorkbenchExplorerHead
|
||||
port={port}
|
||||
count={files.length + directories.length}
|
||||
|
||||
@@ -0,0 +1,71 @@
|
||||
// @vitest-environment jsdom
|
||||
import { cleanup, fireEvent, render, screen } from "@testing-library/react";
|
||||
import { afterEach, describe, expect, it } from "vitest";
|
||||
import { ExplorerResize } from "./ExplorerResize";
|
||||
|
||||
afterEach(() => { cleanup(); localStorage.clear(); });
|
||||
|
||||
function mount() {
|
||||
const view = render(
|
||||
<div className="workbench-body">
|
||||
<aside className="workbench-explorer">
|
||||
<ExplorerResize />
|
||||
</aside>
|
||||
</div>,
|
||||
);
|
||||
const body = view.container.querySelector<HTMLElement>(".workbench-body")!;
|
||||
const aside = view.container.querySelector<HTMLElement>(".workbench-explorer")!;
|
||||
aside.getBoundingClientRect = () => ({ width: Number.parseFloat(body.style.getPropertyValue("--wb-tree-w")) || 220 }) as DOMRect;
|
||||
return { body, bar: screen.getByRole("separator", { name: "调整文件树宽度" }) };
|
||||
}
|
||||
|
||||
describe("the file tree's width handle", () => {
|
||||
it("leaves the stylesheet's width alone until it is used", () => {
|
||||
const { body } = mount();
|
||||
expect(body.style.getPropertyValue("--wb-tree-w")).toBe("");
|
||||
});
|
||||
|
||||
it("widens with the arrow key, remembers it, and clamps at the ends", () => {
|
||||
const { body, bar } = mount();
|
||||
fireEvent.keyDown(bar, { key: "ArrowRight", shiftKey: true });
|
||||
expect(body.style.getPropertyValue("--wb-tree-w")).toBe("268px");
|
||||
expect(localStorage.getItem("rx-wb-tree-w")).toBe("268");
|
||||
for (let i = 0; i < 40; i++) fireEvent.keyDown(bar, { key: "ArrowRight", shiftKey: true });
|
||||
expect(body.style.getPropertyValue("--wb-tree-w")).toBe("560px");
|
||||
for (let i = 0; i < 40; i++) fireEvent.keyDown(bar, { key: "ArrowLeft", shiftKey: true });
|
||||
expect(body.style.getPropertyValue("--wb-tree-w")).toBe("150px");
|
||||
});
|
||||
|
||||
it("restores the remembered width on the next mount", () => {
|
||||
localStorage.setItem("rx-wb-tree-w", "333");
|
||||
expect(mount().body.style.getPropertyValue("--wb-tree-w")).toBe("333px");
|
||||
});
|
||||
|
||||
it("returns to the default on Home and on a double click", () => {
|
||||
const { body, bar } = mount();
|
||||
fireEvent.keyDown(bar, { key: "ArrowRight" });
|
||||
fireEvent.keyDown(bar, { key: "Home" });
|
||||
expect(body.style.getPropertyValue("--wb-tree-w")).toBe("");
|
||||
expect(localStorage.getItem("rx-wb-tree-w")).toBeNull();
|
||||
fireEvent.keyDown(bar, { key: "ArrowRight" });
|
||||
fireEvent.doubleClick(bar);
|
||||
expect(body.style.getPropertyValue("--wb-tree-w")).toBe("");
|
||||
});
|
||||
|
||||
it("follows a pointer drag and keeps the width where it was released", () => {
|
||||
const { body, bar } = mount();
|
||||
bar.setPointerCapture = () => {};
|
||||
fireEvent.pointerDown(bar, { button: 0, pointerId: 1, clientX: 220 });
|
||||
fireEvent.pointerMove(bar, { pointerId: 1, clientX: 300 });
|
||||
expect(body.style.getPropertyValue("--wb-tree-w")).toBe("300px");
|
||||
fireEvent.pointerUp(bar, { pointerId: 1, clientX: 300 });
|
||||
expect(localStorage.getItem("rx-wb-tree-w")).toBe("300");
|
||||
fireEvent.pointerMove(bar, { pointerId: 1, clientX: 500 });
|
||||
expect(body.style.getPropertyValue("--wb-tree-w")).toBe("300px");
|
||||
});
|
||||
|
||||
it("ignores a stored width that is not a number", () => {
|
||||
localStorage.setItem("rx-wb-tree-w", "wide");
|
||||
expect(mount().body.style.getPropertyValue("--wb-tree-w")).toBe("");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,32 @@
|
||||
// @vitest-environment jsdom
|
||||
import { afterEach, describe, expect, it } from "vitest";
|
||||
import { cleanup, render } from "@testing-library/react";
|
||||
import "./testkit";
|
||||
import { Pane } from "./Pane";
|
||||
import { MockPort } from "../port/mock";
|
||||
import type { RuntimeView } from "../port/hub";
|
||||
|
||||
afterEach(cleanup);
|
||||
|
||||
function mount(extra: Record<string, unknown>) {
|
||||
return render(<Pane port={new MockPort()} rt={{ id: "p1", root: "/w", name: "w" } as RuntimeView}
|
||||
title="w" active visible sideHost={null} side={false} onFocus={() => {}} onReport={() => {}}
|
||||
onSessionChanged={() => {}} pulse={0} findPulse={0} onSettings={() => {}} needsProject={false}
|
||||
onOpenProject={() => {}} onKeepHere={() => {}} theme="dark" dockW={560} dockMax={880} onDockW={() => {}} {...extra} />);
|
||||
}
|
||||
|
||||
describe("where a refused action's reason sits in the pane", () => {
|
||||
it("is in the stack above the composer, not in the corner layer the composer paints over", async () => {
|
||||
const view = mount({ failure: <div className="errbar" role="alert" data-testid="refusal">这个文件夹里有正在运行的对话</div> });
|
||||
const refusal = await view.findByTestId("refusal");
|
||||
expect(refusal.parentElement?.classList.contains("composenotes")).toBe(true);
|
||||
expect(refusal.closest(".cmpalert")).toBeNull();
|
||||
});
|
||||
|
||||
it("leaves a note, which has no business over the composer, in the corner layer", async () => {
|
||||
const view = mount({ alert: <div className="errbar" data-kind="note" role="status" data-testid="note">升级了</div> });
|
||||
const note = await view.findByTestId("note");
|
||||
expect(note.closest(".cmpalert")).not.toBeNull();
|
||||
expect(note.closest(".composenotes")).toBeNull();
|
||||
});
|
||||
});
|
||||
@@ -78,6 +78,9 @@ export interface PaneProps {
|
||||
onDockW: (w: number) => void;
|
||||
manualBrowser?: boolean;
|
||||
onManualBrowser?: (on: boolean) => void;
|
||||
// The window's failed-request notice, handed only to the pane in front.
|
||||
// The window's passing notes, handed only to the pane in front.
|
||||
alert?: ReactNode;
|
||||
// The window's refused-action reason, handed only to the pane in front; it joins
|
||||
// the stack above the composer rather than a corner the composer paints over.
|
||||
failure?: ReactNode;
|
||||
}
|
||||
@@ -25,7 +25,7 @@ function Projects({ port, hub }: { port: AgentPort; hub: Parameters<typeof PeerP
|
||||
}
|
||||
|
||||
const settings = (over: Partial<PeersSettings> = {}): PeersSettings => ({
|
||||
enabled: true, dispatch: false, release_projects: [], max_dispatch_depth: 1, max_dispatched_sessions: 8, path: "/home/me/.reasonix/config.toml", applies: "next_turn", pending: false, available: true, ...over,
|
||||
enabled: true, dispatch: false, release_projects: [], max_dispatch_depth: 1, max_dispatched_sessions: 8, open_nested: false, path: "/home/me/.reasonix/config.toml", applies: "next_turn", pending: false, available: true, ...over,
|
||||
});
|
||||
const ws = (root: string, name: string, remembered = true): TreeWorkspace => ({ root, name, remembered, sessions: [] });
|
||||
|
||||
|
||||
@@ -18,7 +18,7 @@ afterEach(() => {
|
||||
});
|
||||
|
||||
const base: PeersSettings = {
|
||||
enabled: false, dispatch: false, release_projects: [{ path: "/work/site", name: "site" }], max_dispatch_depth: 1, max_dispatched_sessions: 8,
|
||||
enabled: false, dispatch: false, release_projects: [{ path: "/work/site", name: "site" }], max_dispatch_depth: 1, max_dispatched_sessions: 8, open_nested: false,
|
||||
path: "/home/me/.reasonix/config.toml", applies: "next_turn", pending: false, available: true,
|
||||
};
|
||||
|
||||
|
||||
@@ -25,7 +25,7 @@ afterEach(() => {
|
||||
});
|
||||
|
||||
const settings = (over: Partial<PeersSettings> = {}): PeersSettings => ({
|
||||
enabled: false, dispatch: false, release_projects: [], max_dispatch_depth: 1, max_dispatched_sessions: 8, path: "/home/me/.reasonix/config.toml", applies: "next_turn", pending: false, available: true, ...over,
|
||||
enabled: false, dispatch: false, release_projects: [], max_dispatch_depth: 1, max_dispatched_sessions: 8, open_nested: false, path: "/home/me/.reasonix/config.toml", applies: "next_turn", pending: false, available: true, ...over,
|
||||
});
|
||||
|
||||
function portWith(initial: PeersSettings) {
|
||||
@@ -37,6 +37,7 @@ function portWith(initial: PeersSettings) {
|
||||
...current,
|
||||
enabled: edit.enabled ?? current.enabled,
|
||||
dispatch: edit.dispatch ?? current.dispatch,
|
||||
open_nested: edit.open_nested ?? current.open_nested,
|
||||
release_projects: edit.release_projects ? current.release_projects.filter((p) => edit.release_projects!.includes(p.path)) : current.release_projects,
|
||||
pending: true,
|
||||
};
|
||||
@@ -82,6 +83,27 @@ describe("the session messages switches", () => {
|
||||
expect(save).toHaveBeenCalledExactlyOnceWith({ dispatch: true });
|
||||
});
|
||||
|
||||
it("offers opening folders inside the project without asking, off until the user turns it on", async () => {
|
||||
const { port, save } = portWith(settings({ enabled: true, dispatch: true }));
|
||||
render(<Messages port={port} />);
|
||||
const name = "不询问直接打开本项目内的文件夹";
|
||||
const sw = await screen.findByRole("switch", { name });
|
||||
expect(sw.getAttribute("aria-checked")).toBe("false");
|
||||
expect(screen.getByText(/项目之外的文件夹仍然会询问/)).toBeTruthy();
|
||||
await userEvent.click(sw);
|
||||
expect(save).toHaveBeenCalledExactlyOnceWith({ open_nested: true });
|
||||
await waitFor(() => expect(screen.getByRole("switch", { name }).getAttribute("aria-checked")).toBe("true"));
|
||||
});
|
||||
|
||||
it("holds the open-folders switch inert until the model may hand off tasks", async () => {
|
||||
const { port, save } = portWith(settings({ enabled: true, dispatch: false }));
|
||||
render(<Messages port={port} />);
|
||||
const sw = await screen.findByRole("switch", { name: "不询问直接打开本项目内的文件夹" });
|
||||
expect((sw as HTMLButtonElement).disabled).toBe(true);
|
||||
await userEvent.click(sw);
|
||||
expect(save).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("names the file the switches live in, and shows a refusal beside them", async () => {
|
||||
const { port } = portWith(settings({ enabled: true }));
|
||||
vi.spyOn(port, "savePeersSettings").mockRejectedValue(new HttpError(500, "x", { code: "peer.settings_unwritable", params: { detail: "/home/me/.reasonix/config.toml" } }));
|
||||
|
||||
@@ -0,0 +1,76 @@
|
||||
// @vitest-environment jsdom
|
||||
import { afterEach, expect, it, vi } from "vitest";
|
||||
import { act, cleanup, render, screen, waitFor } from "@testing-library/react";
|
||||
import userEvent from "@testing-library/user-event";
|
||||
import { useState } from "react";
|
||||
import "./testkit";
|
||||
import { DeckChips, type Deck } from "./DeckChips";
|
||||
import type { JobEntry } from "../port/port";
|
||||
import type { ProcessEntry } from "../port/process";
|
||||
import { HttpError } from "../port/http_error";
|
||||
|
||||
afterEach(cleanup);
|
||||
|
||||
const JOBS: JobEntry[] = [{ id: "j1", kind: "bash", label: "npm run build", status: "running", startedAt: Date.now() - 5000 }];
|
||||
|
||||
const ROWS: ProcessEntry[] = [
|
||||
{ id: "job:j1", kind: "job", name: "npm run build", state: "running", startedAt: Date.now() - 5000 },
|
||||
{ id: "subagent:sa_1", kind: "subagent", parent: "turn:t3", name: "audit", state: "queued", cause: "writers", startedAt: Date.now() - 2000 },
|
||||
];
|
||||
|
||||
function Host({ read, jobs = JOBS }: { read?: () => Promise<ProcessEntry[]>; jobs?: JobEntry[] }) {
|
||||
const [open, setOpen] = useState<Deck>("");
|
||||
return <DeckChips tasks={[]} jobs={jobs} open={open} onOpen={(next) => setOpen(next)} onReadProcesses={read} />;
|
||||
}
|
||||
|
||||
const chip = () => document.querySelector<HTMLButtonElement>('[data-action="deck.processes"]')!;
|
||||
|
||||
it("reads nothing until the list is opened", async () => {
|
||||
const read = vi.fn().mockResolvedValue(ROWS);
|
||||
render(<Host read={read} />);
|
||||
await act(async () => { await Promise.resolve(); });
|
||||
expect(read).not.toHaveBeenCalled();
|
||||
await userEvent.click(chip());
|
||||
await waitFor(() => expect(read).toHaveBeenCalledTimes(1));
|
||||
});
|
||||
|
||||
it("shows each process with its state and the cause the kernel gave", async () => {
|
||||
render(<Host read={vi.fn().mockResolvedValue(ROWS)} />);
|
||||
await userEvent.click(chip());
|
||||
const dialog = await screen.findByRole("dialog", { name: "进程" });
|
||||
expect(dialog.textContent).toContain("npm run build");
|
||||
expect(dialog.textContent).toContain("运行中");
|
||||
expect(dialog.textContent).toContain("audit");
|
||||
expect(dialog.textContent).toContain("排队中");
|
||||
expect(dialog.textContent).toContain("等写入名额");
|
||||
});
|
||||
|
||||
it("does not poll while open, and reads again when the jobs it was built from change", async () => {
|
||||
vi.useFakeTimers({ shouldAdvanceTime: true });
|
||||
try {
|
||||
const read = vi.fn().mockResolvedValue(ROWS);
|
||||
const { rerender } = render(<Host read={read} />);
|
||||
await userEvent.click(chip());
|
||||
await waitFor(() => expect(read).toHaveBeenCalledTimes(1));
|
||||
await act(async () => { vi.advanceTimersByTime(120_000); });
|
||||
expect(read).toHaveBeenCalledTimes(1);
|
||||
rerender(<Host read={read} jobs={[...JOBS, { id: "j2", kind: "bash", label: "go test", status: "running", startedAt: Date.now() }]} />);
|
||||
await waitFor(() => expect(read).toHaveBeenCalledTimes(2));
|
||||
} finally { vi.useRealTimers(); }
|
||||
});
|
||||
|
||||
it("says so when the read fails and keeps no stale rows", async () => {
|
||||
const read = vi.fn().mockResolvedValueOnce(ROWS).mockRejectedValueOnce(new HttpError(500, "disk gone", { code: "process.read_failed", params: { detail: "disk gone" } }));
|
||||
render(<Host read={read} />);
|
||||
await userEvent.click(chip());
|
||||
await screen.findByText("audit");
|
||||
await userEvent.click(chip());
|
||||
await userEvent.click(chip());
|
||||
await waitFor(() => expect(screen.queryByText("audit")).toBeNull());
|
||||
expect(screen.getByRole("alert").textContent).toContain("读取进程列表失败:disk gone");
|
||||
});
|
||||
|
||||
it("offers no process chip where the window cannot read processes", () => {
|
||||
render(<Host />);
|
||||
expect(chip()).toBeNull();
|
||||
});
|
||||
@@ -9,6 +9,7 @@ export const SETTING_SCREEN: Record<string, string> = {
|
||||
"agent.persona.include_runtime_context": "persona:persona",
|
||||
"peers.enabled": "peers:peers",
|
||||
"peers.dispatch": "peers:peers",
|
||||
"peers.open_nested": "peers:peers",
|
||||
"peers.release_projects": "peers:peer-projects",
|
||||
"desktop.default_tool_approval_mode": "tools:approval",
|
||||
"agent.plan_approval": "session:plan-approval",
|
||||
|
||||
@@ -0,0 +1,24 @@
|
||||
import { useMemo, type Dispatch, type ReactNode, type SetStateAction } from "react";
|
||||
import type { AgentPort } from "../port/port";
|
||||
import type { TreeWorkspace } from "../port/hub";
|
||||
import { AutoArchiveNotice } from "./AutoArchiveNotice";
|
||||
import { ErrorBar } from "./ErrorBar";
|
||||
import { LevelUpNotice } from "./LevelUpNotice";
|
||||
|
||||
/** What the window says about itself. A refused action's reason is the error bar
|
||||
* and takes the place of the passing notes, which fill it when there is none. */
|
||||
export function useWindowNotices({ error, setError, onReport, tree, port, feedbackOpen }: {
|
||||
error: string;
|
||||
setError: Dispatch<SetStateAction<string>>;
|
||||
onReport: () => void;
|
||||
tree: TreeWorkspace[];
|
||||
port: AgentPort | null;
|
||||
feedbackOpen: boolean;
|
||||
}): { errorBar: ReactNode; note: ReactNode; bar: ReactNode } {
|
||||
const errorBar = useMemo(() => (error ? <ErrorBar text={error} onDismiss={() => setError("")} onReport={onReport} /> : null), [error, setError, onReport]);
|
||||
const note = useMemo(
|
||||
() => <LevelUpNotice port={port} tick={feedbackOpen ? 1 : 0} fallback={<AutoArchiveNotice tree={tree} />} />,
|
||||
[tree, port, feedbackOpen],
|
||||
);
|
||||
return { errorBar, note, bar: errorBar ?? note };
|
||||
}
|
||||
@@ -0,0 +1,42 @@
|
||||
import { readFileSync } from "node:fs";
|
||||
import { gunzipSync } from "node:zlib";
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
const ILLEGAL = /[<>:"|?*\u0000-\u001f]/;
|
||||
|
||||
function entryNames(tgz: Buffer): string[] {
|
||||
const tar = gunzipSync(tgz);
|
||||
const names: string[] = [];
|
||||
for (let at = 0; at + 512 <= tar.length; ) {
|
||||
const header = tar.subarray(at, at + 512);
|
||||
if (header.every((b) => b === 0)) break;
|
||||
const name = header.subarray(0, 100).toString("utf8").replace(/\0.*$/, "");
|
||||
const prefix = header.subarray(345, 500).toString("utf8").replace(/\0.*$/, "");
|
||||
const size = parseInt(header.subarray(124, 136).toString("utf8").replace(/\0.*$/, "").trim() || "0", 8);
|
||||
names.push(prefix ? `${prefix}/${name}` : name);
|
||||
at += 512 + Math.ceil(size / 512) * 512;
|
||||
}
|
||||
return names;
|
||||
}
|
||||
|
||||
describe("a package vendored for installs on Windows", () => {
|
||||
const dir = new URL("../vendor/", import.meta.url);
|
||||
|
||||
it("holds no file name Windows cannot create", () => {
|
||||
const names = entryNames(readFileSync(new URL("redos-detector-6.1.4.tgz", dir)));
|
||||
expect(names.length).toBeGreaterThan(10);
|
||||
expect(names.filter((n) => ILLEGAL.test(n))).toEqual([]);
|
||||
});
|
||||
|
||||
it("is the package the override says it is", () => {
|
||||
const names = entryNames(readFileSync(new URL("redos-detector-6.1.4.tgz", dir)));
|
||||
expect(names).toContain("package/package.json");
|
||||
expect(names).toContain("package/LICENSE");
|
||||
expect(names).toContain("package/dist/redos-detector.js");
|
||||
});
|
||||
|
||||
it("is the one package.json points the override at", () => {
|
||||
const pkg = JSON.parse(readFileSync(new URL("../package.json", import.meta.url), "utf8"));
|
||||
expect(pkg.pnpm.overrides["redos-detector"]).toBe("file:./vendor/redos-detector-6.1.4.tgz");
|
||||
});
|
||||
});
|
||||
+31
@@ -0,0 +1,31 @@
|
||||
---
|
||||
owner: @esengine
|
||||
backup: @SivanCola
|
||||
status: active
|
||||
reviewed: 2026-11-10
|
||||
---
|
||||
|
||||
# Vendored packages
|
||||
|
||||
`pnpm.overrides` in `package.json` points `redos-detector` at the tarball here, so that `pnpm install` can finish on Windows.
|
||||
|
||||
## Why
|
||||
|
||||
`@a2ui/web_core` depends on `redos-detector`, and its `isSafeRegex` uses it at run time to refuse regular expressions that can backtrack catastrophically. The check must stay.
|
||||
|
||||
Every published `redos-detector` 6.1.x tarball contains `dist/__mocks__/package-json:version.d.ts`. A colon is not a legal character in a Windows file name, so a package manager cannot write that path and the install stops with `os error 123` (issue #15067). Nothing reads the file: it is a type stub for a build-time module.
|
||||
|
||||
## What is changed
|
||||
|
||||
- `redos-detector-6.1.4.tgz` is the upstream 6.1.4 tarball with that one entry removed. The other 44 entries are byte-identical.
|
||||
- The upstream tarball has the integrity `sha512-lPlka1rEH6kK42gtgokvvxMmpAvyc28DcRjQbGxeP5RJsJWgAduBOdGedVCDPdSyCr4ay/JDxq3nGYsJZyt8tA==`, the one the lockfile held before the override.
|
||||
- The package is MIT licensed and carries its own `LICENSE`.
|
||||
|
||||
## Checking it
|
||||
|
||||
- `src/vendor-windows-names.test.ts` fails if the tarball holds a name Windows cannot create, so re-vendoring the unmodified upstream file is caught.
|
||||
- To redo it: `npm pack [email protected]`, check the integrity above, drop the colon-named entry, write the archive back, then run `pnpm install --lockfile-only`.
|
||||
|
||||
## When to remove it
|
||||
|
||||
Delete the tarball, the `pnpm.overrides` entry and the test once `@a2ui/web_core` depends on a `redos-detector` that no longer ships a colon-named file, then run `pnpm install --lockfile-only`.
|
||||
Binary file not shown.
@@ -16,7 +16,7 @@ const ROUTES = [
|
||||
"/appearance",
|
||||
"/agent-presets", "/agent-composition",
|
||||
"/events", "/history", "/status", "/balance", "/submit", "/cancel", "/approve", "/answer",
|
||||
"/adjudications", "/execution-graph", "/jobs", "/tool-calls", "/subagents",
|
||||
"/adjudications", "/execution-graph", "/jobs", "/tool-calls", "/subagents", "/processes",
|
||||
"/plan", "/plan-decision", "/goal", "/resume", "/models", "/tool-approval-mode", "/workspace-trust", "/preset",
|
||||
"/model", "/default-model", "/default-mode", "/effort", "/new", "/sessions", "/session/tail", "/delete-session", "/provider-setup",
|
||||
"/tools", "/present-files",
|
||||
|
||||
+9
-1
@@ -150,9 +150,10 @@ allow = ["Bash(go test:*)"] # never prompted
|
||||
|
||||
[peers]
|
||||
# enabled = true # your sessions on this machine can message each other; unpaired messages wait for the receiving session's user to approve them; also a switch in Settings, applied at the next turn (user config only)
|
||||
# dispatch = true # the model may hand a task to a session in another of your projects, via dispatch_to_project (the session it opens sends its final answer back on its own), and ask you to open folders as projects with open_projects (one card lists every path; nothing is added or trusted until you approve it, and no mode answers it for you); needs enabled; turning it on is your consent for those sessions' tasks and replies, no card, and a project in your sidebar you have not decided about is trusted by being named; the opened session keeps the sender's model, preset, approval mode and planning, so a YOLO sender opens a YOLO session that answers the target project's ask rules too (deny rules still hold) (user config only)
|
||||
# dispatch = true # the model may hand a task to a session in another of your projects, via dispatch_to_project (the session it opens sends its final answer back on its own), and ask you to open folders as projects with open_projects (one card lists every path; nothing is added or trusted until you approve it, and no mode answers it for you, except the open_nested switch below); needs enabled; turning it on is your consent for those sessions' tasks and replies, no card, and a project in your sidebar you have not decided about is trusted by being named; the opened session keeps the sender's model, preset, approval mode and planning, so a YOLO sender opens a YOLO session that answers the target project's ask rules too (deny rules still hold) (user config only)
|
||||
# max_dispatch_depth = 1 # how many sessions deep a dispatch may chain; 0 removes the ceiling (user config only)
|
||||
# max_dispatched_sessions = 8 # sessions one session may keep open from its dispatches; 0 removes the ceiling (user config only)
|
||||
# open_nested = false # open_projects adds folders inside the session's own workspace without a card; also a switch in Settings (user config only)
|
||||
# release_projects = [] # projects that take messages from your other sessions without the card; their own approvals and sandbox still apply; they start over a pause the host set itself (session switch, unreadable @ reference, reopen) but never over one you pressed (user config only)
|
||||
|
||||
[mods]
|
||||
@@ -2660,6 +2661,13 @@ For a folder not yet in your project list, add it yourself, or let the model
|
||||
request `open_projects`; its card lists the paths and requires your explicit
|
||||
approval even in YOLO mode before adding or trusting them.
|
||||
|
||||
One setting relaxes this:
|
||||
|
||||
- With the user-only `peers.open_nested = true` (also a switch in Settings), a call whose folders are all strictly inside the asking session's own workspace, after links are resolved, is added and trusted without the card, and a notice lists the paths.
|
||||
- Any folder outside the workspace brings the card for the whole call.
|
||||
- It is off by default, and a project's `reasonix.toml` cannot turn it on.
|
||||
- A trusted folder's configuration can run programs, so turn it on only for a repository you trust.
|
||||
|
||||
`open_projects` is unavailable in plan mode until the plan is approved.
|
||||
|
||||
Dispatch refuses unknown or ambiguous projects and the current project itself
|
||||
|
||||
+9
-1
@@ -159,9 +159,10 @@ allow = ["Bash(go test:*)"] # 从不询问
|
||||
|
||||
[peers]
|
||||
# enabled = true # 本机你的各个会话可以互发消息;未配对会话的消息要对方会话的用户批准;设置里也有开关,下个回合起生效(只认用户级配置)
|
||||
# dispatch = true # 模型可用 dispatch_to_project 把任务交给你另一个项目里的会话(对方做完会自己把最终答复送回来),也可用 open_projects 请你把文件夹开成项目(一张卡逐条列出路径,你批准前不会加入也不会被信任,任何模式都不会代你回答);需要 enabled;打开就是你对这些会话的任务和回信的总同意,不再出审批卡;新开的会话沿用发起方的模型、预设、审批档位和计划模式,所以 YOLO 的发起方开出的也是 YOLO 会话,会连目标项目的“要问我”规则一起答掉(拒绝规则仍然生效)(只认用户级配置)
|
||||
# dispatch = true # 模型可用 dispatch_to_project 把任务交给你另一个项目里的会话(对方做完会自己把最终答复送回来),也可用 open_projects 请你把文件夹开成项目(一张卡逐条列出路径,你批准前不会加入也不会被信任,任何模式都不会代你回答,下面的 open_nested 开关除外);需要 enabled;打开就是你对这些会话的任务和回信的总同意,不再出审批卡;新开的会话沿用发起方的模型、预设、审批档位和计划模式,所以 YOLO 的发起方开出的也是 YOLO 会话,会连目标项目的“要问我”规则一起答掉(拒绝规则仍然生效)(只认用户级配置)
|
||||
# max_dispatch_depth = 1 # 派发最多能连着开几层会话;0 表示不限(只认用户级配置)
|
||||
# max_dispatched_sessions = 8 # 一个会话通过派发最多同时开着几个会话;0 表示不限(只认用户级配置)
|
||||
# open_nested = false # open_projects 打开会话自己工作区之内的文件夹时不弹审批卡;设置里也有开关(只认用户级配置)
|
||||
# release_projects = [] # 这些项目收到你其它会话的消息时不弹批准卡;它们自己的审批与沙盒照旧;宿主自己设的暂停(切换会话、@ 引用读不了、重新打开)不拦它们,你主动按下的暂停会拦住,消息留在队列里(只认用户级配置)
|
||||
|
||||
[mods]
|
||||
@@ -1950,6 +1951,13 @@ dispatch = true
|
||||
或让模型通过 `open_projects` 请求;其审批卡会列出路径,即使在 YOLO 模式下也必须
|
||||
由你明确批准,才会添加或信任。
|
||||
|
||||
有一个设置可以放宽这一条:
|
||||
|
||||
- 用户级配置 `peers.open_nested = true`(设置里也有开关)开启后,一次调用里的文件夹只要全部严格位于发起会话自己的工作区之内(解析链接之后),就直接添加并信任,不弹卡,并用通知列出这些路径。
|
||||
- 只要有一个文件夹在工作区之外,整次调用仍然弹卡。
|
||||
- 默认关闭,项目里的 `reasonix.toml` 无法开启它。
|
||||
- 被信任的文件夹里的配置可以运行程序,请只在你信任这个仓库时开启。
|
||||
|
||||
计划模式下不能使用 `open_projects`,需要先批准计划。
|
||||
|
||||
不存在或重名不明确的项目、当前项目本身都会被拒绝(当前项目应在本地完成或用
|
||||
|
||||
@@ -758,6 +758,10 @@ type Messages struct {
|
||||
// usage / help
|
||||
UsageBody string // full multi-line help text
|
||||
StandaloneConsoleHint string
|
||||
// MinttyConsoleHint is shown instead of the usage text when stdin or stdout is a Cygwin or MSYS terminal, which a Windows program cannot drive as a console.
|
||||
MinttyConsoleHint string
|
||||
// OpenedNestedWithoutAskingFmt is the notice after open_projects added folders inside the workspace on the user's standing setting; the arguments are a count and the paths.
|
||||
OpenedNestedWithoutAskingFmt string
|
||||
|
||||
// Feedback is the /feedback list standing and refusal wording.
|
||||
Feedback FeedbackText
|
||||
|
||||
@@ -743,7 +743,9 @@ var English = Messages{
|
||||
CLITelemetryConsentSaveFailedFmt: "CLI telemetry remains disabled because the preference could not be saved: %v",
|
||||
CLITelemetryConsentCleanupFailedFmt: "CLI telemetry is disabled, but pending statistics could not be deleted: %v",
|
||||
|
||||
StandaloneConsoleHint: "This is the Reasonix command-line tool; run it from a terminal such as PowerShell.\nFor the desktop app, install Reasonix Studio (ReasonixStudio-windows-amd64-installer.exe):\n https://github.com/esengine/DeepSeek-Reasonix/releases?q=studio-v&expanded=true\n\nPress Enter to close this window.\n",
|
||||
MinttyConsoleHint: "The Reasonix terminal UI needs a Windows console, and this terminal (Git Bash / mintty) is not one.\nRun it from cmd, PowerShell or Windows Terminal. `reasonix run \"task\"` still works from here.\n",
|
||||
OpenedNestedWithoutAskingFmt: "Opened %d folder(s) inside this project without asking, as the setting allows (Settings > Agent behavior > Messages between sessions): %s",
|
||||
StandaloneConsoleHint: "This is the Reasonix command-line tool; run it from a terminal such as PowerShell.\nFor the desktop app, install Reasonix Studio (ReasonixStudio-windows-amd64-installer.exe):\n https://github.com/esengine/DeepSeek-Reasonix/releases?q=studio-v&expanded=true\n\nPress Enter to close this window.\n",
|
||||
UsageBody: `reasonix — a config- and plugin-driven coding agent (multi-model)
|
||||
|
||||
Usage:
|
||||
|
||||
@@ -743,7 +743,9 @@ var Chinese = Messages{
|
||||
CLITelemetryConsentSaveFailedFmt: "由于无法保存偏好设置,CLI 使用统计仍保持关闭:%v",
|
||||
CLITelemetryConsentCleanupFailedFmt: "CLI 使用统计已关闭,但无法删除待发送统计:%v",
|
||||
|
||||
StandaloneConsoleHint: "这是 Reasonix 命令行工具,需要在 PowerShell 等终端里运行。\n要用桌面应用,请安装 Reasonix Studio(ReasonixStudio-windows-amd64-installer.exe):\n https://github.com/esengine/DeepSeek-Reasonix/releases?q=studio-v&expanded=true\n\n按回车关闭此窗口。\n",
|
||||
MinttyConsoleHint: "Reasonix 的终端界面需要 Windows 控制台,而当前终端(Git Bash / mintty)不是。\n请在 cmd、PowerShell 或 Windows Terminal 里运行;在这里仍可用 `reasonix run \"任务\"` 执行单个任务。\n",
|
||||
OpenedNestedWithoutAskingFmt: "按设置(设置 > 智能体行为 > 会话间消息)的允许,已在不询问的情况下打开本项目内的 %d 个文件夹:%s",
|
||||
StandaloneConsoleHint: "这是 Reasonix 命令行工具,需要在 PowerShell 等终端里运行。\n要用桌面应用,请安装 Reasonix Studio(ReasonixStudio-windows-amd64-installer.exe):\n https://github.com/esengine/DeepSeek-Reasonix/releases?q=studio-v&expanded=true\n\n按回车关闭此窗口。\n",
|
||||
UsageBody: `reasonix — 由配置和插件驱动的 coding agent(多模型)
|
||||
|
||||
用法:
|
||||
|
||||
@@ -666,7 +666,9 @@ var ChineseTraditional = Messages{
|
||||
CLITelemetryConsentSaveFailedFmt: "由於無法儲存偏好設定,CLI 使用統計仍維持關閉:%v",
|
||||
CLITelemetryConsentCleanupFailedFmt: "CLI 使用統計已關閉,但無法刪除待傳送統計:%v",
|
||||
|
||||
StandaloneConsoleHint: "這是 Reasonix 命令列工具,需要在 PowerShell 等終端機裡執行。\n要使用桌面應用程式,請安裝 Reasonix Studio(ReasonixStudio-windows-amd64-installer.exe):\n https://github.com/esengine/DeepSeek-Reasonix/releases?q=studio-v&expanded=true\n\n按 Enter 關閉此視窗。\n",
|
||||
MinttyConsoleHint: "Reasonix 的終端介面需要 Windows 主控台,而目前的終端機(Git Bash / mintty)不是。\n請在 cmd、PowerShell 或 Windows Terminal 裡執行;在這裡仍可用 `reasonix run \"任務\"` 執行單一任務。\n",
|
||||
OpenedNestedWithoutAskingFmt: "依設定(設定 > 智慧代理行為 > 會話間訊息)的允許,已在不詢問的情況下開啟本專案內的 %d 個資料夾:%s",
|
||||
StandaloneConsoleHint: "這是 Reasonix 命令列工具,需要在 PowerShell 等終端機裡執行。\n要使用桌面應用程式,請安裝 Reasonix Studio(ReasonixStudio-windows-amd64-installer.exe):\n https://github.com/esengine/DeepSeek-Reasonix/releases?q=studio-v&expanded=true\n\n按 Enter 關閉此視窗。\n",
|
||||
UsageBody: `reasonix — 由設定和插件驅動的 coding agent(多模型)
|
||||
|
||||
用法:
|
||||
|
||||
@@ -0,0 +1,39 @@
|
||||
package agentgraph
|
||||
|
||||
import "reasonix/internal/contract/process"
|
||||
|
||||
// ProcessClass projects the node state onto the shared process vocabulary. A
|
||||
// queued node carries the cause the scheduler held it for.
|
||||
func (s NodeState) ProcessClass(w WaitCause) (process.Class, bool) {
|
||||
switch s {
|
||||
case StatePending:
|
||||
return process.Class{State: process.Pending}, true
|
||||
case StateQueued:
|
||||
return process.Class{State: process.Queued, Cause: waitCause(w)}, true
|
||||
case StateRunning:
|
||||
return process.Class{State: process.Running}, true
|
||||
case StateCompleted:
|
||||
return process.Class{State: process.Completed}, true
|
||||
case StateAdopted:
|
||||
return process.Class{State: process.Completed, Cause: process.CauseAdopted}, true
|
||||
case StateFailed:
|
||||
return process.Class{State: process.Failed}, true
|
||||
case StateCancelled:
|
||||
return process.Class{State: process.Stopped}, true
|
||||
case StateSkipped:
|
||||
return process.Class{State: process.Skipped}, true
|
||||
}
|
||||
return process.Class{}, false
|
||||
}
|
||||
|
||||
func waitCause(w WaitCause) process.Cause {
|
||||
switch w {
|
||||
case WaitSlots:
|
||||
return process.CauseSlots
|
||||
case WaitWriters:
|
||||
return process.CauseWriters
|
||||
case WaitClaim:
|
||||
return process.CauseClaim
|
||||
}
|
||||
return process.CauseNone
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
package agentgraph
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"reasonix/internal/contract/process"
|
||||
"reasonix/internal/contract/process/proctest"
|
||||
)
|
||||
|
||||
func TestNodeStateMapsOntoProcessStates(t *testing.T) {
|
||||
proctest.Total(t, ".", "NodeState", func(v string) (process.Class, bool) { return NodeState(v).ProcessClass("") })
|
||||
}
|
||||
|
||||
func TestQueuedNodeCarriesItsWaitCause(t *testing.T) {
|
||||
for w, want := range map[WaitCause]process.Cause{WaitSlots: process.CauseSlots, WaitWriters: process.CauseWriters, WaitClaim: process.CauseClaim} {
|
||||
got, _ := StateQueued.ProcessClass(w)
|
||||
if got.State != process.Queued || got.Cause != want {
|
||||
t.Errorf("queued for %q: got %+v", w, got)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -27,6 +27,10 @@ type PeersConfig struct {
|
||||
// dispatches at once, 0 for no ceiling. Each is a kernel and a lease, so it
|
||||
// bounds a loop that keeps opening panes. User file only.
|
||||
MaxDispatchedSessions int `toml:"max_dispatched_sessions"`
|
||||
// OpenNested lets open_projects add folders inside the asking session's own
|
||||
// workspace without the approval card. A folder outside it still asks. Off
|
||||
// unless the user turns it on; user file only.
|
||||
OpenNested bool `toml:"open_nested"`
|
||||
}
|
||||
|
||||
func defaultPeers() PeersConfig {
|
||||
@@ -46,6 +50,10 @@ 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 }
|
||||
|
||||
// PeersOpenNested reports whether open_projects may add folders inside the
|
||||
// asking session's workspace without a card.
|
||||
func (c *Config) PeersOpenNested() bool { return c != nil && c.Peers.OpenNested }
|
||||
|
||||
// PeersMaxDispatchDepth is the user's depth ceiling for chained dispatches, 0 for none.
|
||||
func (c *Config) PeersMaxDispatchDepth() int {
|
||||
if c == nil {
|
||||
@@ -91,7 +99,7 @@ func renderServeAndPeers(b *strings.Builder, c *Config) {
|
||||
func renderPeersSection(b *strings.Builder, c *Config) {
|
||||
p := c.Peers
|
||||
customCeilings := p.MaxDispatchDepth != DefaultMaxDispatchDepth || p.MaxDispatchedSessions != DefaultMaxDispatchedSessions
|
||||
if p.Enabled && p.Dispatch && len(p.ReleaseProjects) == 0 && !customCeilings {
|
||||
if p.Enabled && p.Dispatch && len(p.ReleaseProjects) == 0 && !customCeilings && !p.OpenNested {
|
||||
return
|
||||
}
|
||||
b.WriteString("[peers]\n")
|
||||
@@ -100,6 +108,9 @@ func renderPeersSection(b *strings.Builder, c *Config) {
|
||||
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))
|
||||
}
|
||||
if p.OpenNested {
|
||||
b.WriteString("open_nested = true # open_projects adds folders inside the asking session's workspace without a card; user file only\n")
|
||||
}
|
||||
if customCeilings {
|
||||
fmt.Fprintf(b, "max_dispatch_depth = %d # how many sessions deep a dispatch may chain; 0 for no ceiling\n", p.MaxDispatchDepth)
|
||||
fmt.Fprintf(b, "max_dispatched_sessions = %d # sessions one session may keep open from its dispatches; 0 for no ceiling\n", p.MaxDispatchedSessions)
|
||||
|
||||
@@ -4,6 +4,7 @@ import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"slices"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"reasonix/internal/base/testenv"
|
||||
@@ -170,3 +171,53 @@ func TestCeilingsTheUserSetSurviveAnUnrelatedSave(t *testing.T) {
|
||||
t.Fatalf("ceilings after a save = %d, %d; want 0 and 3", got.PeersMaxDispatchDepth(), got.PeersMaxDispatchedSessions())
|
||||
}
|
||||
}
|
||||
|
||||
func TestOpeningNestedFoldersWithoutAskingIsOffUnlessTheUserTurnsItOn(t *testing.T) {
|
||||
if Default().PeersOpenNested() {
|
||||
t.Fatal("opening folders without a card is on by default")
|
||||
}
|
||||
cfg, _ := loadScoped(t, "[peers]\nopen_nested = true\n", "")
|
||||
if !cfg.PeersOpenNested() {
|
||||
t.Fatal("the user's own open_nested was not read")
|
||||
}
|
||||
}
|
||||
|
||||
// The setting skips a card that decides whether a folder is trusted, so a cloned
|
||||
// repository must neither switch it on nor undo the user's choice.
|
||||
func TestAProjectFileCannotTurnOpeningFoldersWithoutAskingOn(t *testing.T) {
|
||||
cfg, _ := loadScoped(t, "", "[peers]\nopen_nested = true\n")
|
||||
if cfg.PeersOpenNested() {
|
||||
t.Fatal("a cloned repo's reasonix.toml let folders open without a card")
|
||||
}
|
||||
if !slices.Contains(ignoredKeys(cfg), "peers.open_nested") {
|
||||
t.Fatalf("ignored = %v, want peers.open_nested reported", ignoredKeys(cfg))
|
||||
}
|
||||
if kept, _ := loadScoped(t, "[peers]\nopen_nested = true\n", "[peers]\nopen_nested = false\n"); !kept.PeersOpenNested() {
|
||||
t.Fatal("the project file overrode the user's own choice")
|
||||
}
|
||||
}
|
||||
|
||||
func TestSavingTheUserFileKeepsOpenNestedAndWritesNothingWhenItIsOff(t *testing.T) {
|
||||
t.Setenv("REASONIX_HOME", testenv.TempDir(t))
|
||||
path := UserConfigPath()
|
||||
cfg := LoadForEdit(path)
|
||||
cfg.Peers.OpenNested = true
|
||||
if err := cfg.SaveTo(path); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if !LoadForEdit(path).Peers.OpenNested {
|
||||
t.Fatal("open_nested did not survive a save")
|
||||
}
|
||||
off := LoadForEdit(path)
|
||||
off.Peers.OpenNested = false
|
||||
if err := off.SaveTo(path); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
raw, err := os.ReadFile(path)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if strings.Contains(string(raw), "open_nested") {
|
||||
t.Fatalf("an off setting was written out:\n%s", raw)
|
||||
}
|
||||
}
|
||||
@@ -29,5 +29,8 @@ func (h heldScope) narrowPeers(c *Config) {
|
||||
if c.Peers.MaxDispatchedSessions != h.peers.MaxDispatchedSessions {
|
||||
c.ignoreProject("peers.max_dispatched_sessions", fmt.Sprint(c.Peers.MaxDispatchedSessions), ProjectUserOnly)
|
||||
}
|
||||
if c.Peers.OpenNested != h.peers.OpenNested {
|
||||
c.ignoreProject("peers.open_nested", fmt.Sprintf("%t", c.Peers.OpenNested), ProjectUserOnly)
|
||||
}
|
||||
c.Peers = clonePeers(h.peers)
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
// Package process is the shared vocabulary for work the kernel is running: a
|
||||
// closed set of states and the typed causes that explain them.
|
||||
//
|
||||
// It records nothing and owns no lifecycle. Each owner keeps its own states and
|
||||
// transitions and maps them onto this set; a layer above may project a class,
|
||||
// never re-derive one. A cause is left empty when the owner does not know it.
|
||||
package process
|
||||
@@ -0,0 +1,81 @@
|
||||
package process
|
||||
|
||||
import (
|
||||
"slices"
|
||||
"time"
|
||||
)
|
||||
|
||||
// State is where a unit of work stands, in terms every owner can be mapped onto.
|
||||
type State string
|
||||
|
||||
const (
|
||||
Pending State = "pending"
|
||||
Queued State = "queued"
|
||||
Running State = "running"
|
||||
Waiting State = "waiting"
|
||||
Completed State = "completed"
|
||||
Failed State = "failed"
|
||||
Stopped State = "stopped"
|
||||
Interrupted State = "interrupted"
|
||||
Skipped State = "skipped"
|
||||
)
|
||||
|
||||
// Cause says why a unit is in its State. Empty means the owner does not record
|
||||
// one, which is not the same as there being none.
|
||||
type Cause string
|
||||
|
||||
const (
|
||||
CauseNone Cause = ""
|
||||
CauseSlots Cause = "slots"
|
||||
CauseWriters Cause = "writers"
|
||||
CauseClaim Cause = "claim"
|
||||
CauseHeld Cause = "held"
|
||||
CauseAdopted Cause = "adopted"
|
||||
CauseBudget Cause = "budget"
|
||||
CauseParked Cause = "parked"
|
||||
)
|
||||
|
||||
// Class is one owner state projected onto the shared vocabulary.
|
||||
type Class struct {
|
||||
State State
|
||||
Cause Cause
|
||||
}
|
||||
|
||||
// States lists every State.
|
||||
var States = []State{Pending, Queued, Running, Waiting, Completed, Failed, Stopped, Interrupted, Skipped}
|
||||
|
||||
// Terminal reports whether a unit in this state will not change state again.
|
||||
func (s State) Terminal() bool {
|
||||
switch s {
|
||||
case Completed, Failed, Stopped, Interrupted, Skipped:
|
||||
return true
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// Valid reports whether s is one of States.
|
||||
func (s State) Valid() bool { return slices.Contains(States, s) }
|
||||
|
||||
// Kind is what sort of work a Process is. It is the owner that produced it.
|
||||
type Kind string
|
||||
|
||||
const (
|
||||
KindSubagent Kind = "subagent"
|
||||
KindJob Kind = "job"
|
||||
)
|
||||
|
||||
// Process is one running unit as a reader sees it.
|
||||
type Process struct {
|
||||
// ID is "<kind>:<owner id>", stable for as long as the owner keeps the unit.
|
||||
ID string
|
||||
Kind Kind
|
||||
// Parent is the ID of the process that started this one; empty for a root.
|
||||
Parent string
|
||||
Name string
|
||||
State State
|
||||
Cause Cause
|
||||
Started time.Time
|
||||
}
|
||||
|
||||
// ID builds the process id for an owner's own id.
|
||||
func ID(k Kind, owner string) string { return string(k) + ":" + owner }
|
||||
@@ -0,0 +1,34 @@
|
||||
package process
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestTerminalStatesAreExactlyThoseThatEnd(t *testing.T) {
|
||||
for _, s := range States {
|
||||
live := s == Pending || s == Queued || s == Running || s == Waiting
|
||||
if s.Terminal() == live {
|
||||
t.Errorf("%s: terminal=%v", s, s.Terminal())
|
||||
}
|
||||
if !s.Valid() || strings.TrimSpace(string(s)) != string(s) {
|
||||
t.Errorf("%q is not a clean valid state", s)
|
||||
}
|
||||
}
|
||||
if State("paused").Valid() {
|
||||
t.Error("an unknown state must not be valid")
|
||||
}
|
||||
}
|
||||
|
||||
func BenchmarkStateTerminal(b *testing.B) {
|
||||
b.ReportAllocs()
|
||||
for i := range b.N {
|
||||
_ = States[i%len(States)].Terminal()
|
||||
}
|
||||
}
|
||||
|
||||
func TestIDNamesTheKindAndTheOwnersOwnID(t *testing.T) {
|
||||
if got := ID(KindJob, "j1"); got != "job:j1" {
|
||||
t.Fatalf("ID = %q, want job:j1", got)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,71 @@
|
||||
// Package proctest holds the check that keeps an owner's mapping onto the
|
||||
// process vocabulary total.
|
||||
package proctest
|
||||
|
||||
import (
|
||||
"go/ast"
|
||||
"go/parser"
|
||||
"go/token"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strconv"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"reasonix/internal/contract/process"
|
||||
)
|
||||
|
||||
// Declared returns the string values of every constant of typeName declared in
|
||||
// the non-test files of dir.
|
||||
func Declared(t testing.TB, dir, typeName string) []string {
|
||||
t.Helper()
|
||||
entries, err := os.ReadDir(dir)
|
||||
if err != nil {
|
||||
t.Fatalf("read %s: %v", dir, err)
|
||||
}
|
||||
fset := token.NewFileSet()
|
||||
var out []string
|
||||
for _, e := range entries {
|
||||
name := e.Name()
|
||||
if e.IsDir() || !strings.HasSuffix(name, ".go") || strings.HasSuffix(name, "_test.go") {
|
||||
continue
|
||||
}
|
||||
f, err := parser.ParseFile(fset, filepath.Join(dir, name), nil, 0)
|
||||
if err != nil {
|
||||
t.Fatalf("parse %s: %v", name, err)
|
||||
}
|
||||
for _, d := range f.Decls {
|
||||
gd, ok := d.(*ast.GenDecl)
|
||||
if !ok || gd.Tok != token.CONST {
|
||||
continue
|
||||
}
|
||||
for _, sp := range gd.Specs {
|
||||
vs := sp.(*ast.ValueSpec)
|
||||
id, ok := vs.Type.(*ast.Ident)
|
||||
if !ok || id.Name != typeName || len(vs.Values) != 1 {
|
||||
continue
|
||||
}
|
||||
if lit, ok := vs.Values[0].(*ast.BasicLit); ok && lit.Kind == token.STRING {
|
||||
v, _ := strconv.Unquote(lit.Value)
|
||||
out = append(out, v)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// Total fails unless every declared value of typeName maps to a valid state.
|
||||
func Total(t testing.TB, dir, typeName string, class func(string) (process.Class, bool)) {
|
||||
t.Helper()
|
||||
vals := Declared(t, dir, typeName)
|
||||
if len(vals) == 0 {
|
||||
t.Fatalf("no constants of %s found in %s", typeName, dir)
|
||||
}
|
||||
for _, v := range vals {
|
||||
c, ok := class(v)
|
||||
if !ok || !c.State.Valid() {
|
||||
t.Errorf("%s %q has no mapping onto the process vocabulary", typeName, v)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -261,5 +261,6 @@ var table = []Entry{
|
||||
{ID: "peers.release_projects", Class: Guarded, Effect: NextTurn},
|
||||
{ID: "peers.max_dispatch_depth", Class: Guarded, Effect: NextTurn},
|
||||
{ID: "peers.max_dispatched_sessions", Class: Guarded, Effect: NextTurn},
|
||||
{ID: "peers.open_nested", Class: Guarded, Effect: Immediate},
|
||||
{ID: "storage", Class: Guarded, Effect: Restart},
|
||||
}
|
||||
@@ -281,3 +281,13 @@ func TestTheDesktopScreenTableNamesOnlyDeclaredSettings(t *testing.T) {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestSkippingTheOpenFoldersCardIsNeverTheModelsToWrite(t *testing.T) {
|
||||
e, ok := Lookup("peers.open_nested")
|
||||
if !ok {
|
||||
t.Fatal("peers.open_nested is not declared")
|
||||
}
|
||||
if e.Class != Guarded {
|
||||
t.Fatalf("class = %v, want Guarded: a setting that removes a trust card must not be written on the model's say-so", e.Class)
|
||||
}
|
||||
}
|
||||
@@ -24,3 +24,14 @@ func bareUsage(bare bool) int {
|
||||
}
|
||||
return 2
|
||||
}
|
||||
|
||||
// bareOutsideConsole answers a bare invocation that has no console: a Cygwin
|
||||
// or MSYS terminal is told why the UI cannot run there, anything else gets the
|
||||
// usage text.
|
||||
func bareOutsideConsole(bare bool) int {
|
||||
if hint := minttyHint(os.Stdin, os.Stdout, onCygwinPty); hint != "" {
|
||||
fmt.Fprint(os.Stderr, hint)
|
||||
return 2
|
||||
}
|
||||
return bareUsage(bare)
|
||||
}
|
||||
@@ -83,7 +83,7 @@ func RunWithBuildInfo(args []string, info BuildInfo) int {
|
||||
if term.IsTerminal(int(os.Stdin.Fd())) && term.IsTerminal(int(os.Stdout.Fd())) {
|
||||
return runTUI(args, version)
|
||||
}
|
||||
return bareUsage(len(args) == 0)
|
||||
return bareOutsideConsole(len(args) == 0)
|
||||
}
|
||||
|
||||
rest := args[1:]
|
||||
|
||||
@@ -0,0 +1,25 @@
|
||||
package cli
|
||||
|
||||
import (
|
||||
"os"
|
||||
"regexp"
|
||||
|
||||
"reasonix/internal/base/i18n"
|
||||
)
|
||||
|
||||
// A Cygwin or MSYS terminal, Git Bash's mintty among them, hands a Windows
|
||||
// program named pipes instead of a console, so no console call works on it.
|
||||
// The runtime names them this way; MSYS2 adds "-nat" to those it opens for
|
||||
// native programs.
|
||||
var cygwinPty = regexp.MustCompile(`^\\(?:cygwin|msys)-[0-9a-f]{16}-pty[0-9]+-(?:from|to)-master(?:-nat)?$`)
|
||||
|
||||
func cygwinPtyName(name string) bool { return cygwinPty.MatchString(name) }
|
||||
|
||||
// minttyHint is what to say instead of the usage text when either end of the
|
||||
// session is a Cygwin or MSYS terminal, and "" when neither is.
|
||||
func minttyHint(in, out *os.File, on func(*os.File) bool) string {
|
||||
if on(in) || on(out) {
|
||||
return i18n.M.MinttyConsoleHint
|
||||
}
|
||||
return ""
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
//go:build !windows
|
||||
|
||||
package cli
|
||||
|
||||
import "os"
|
||||
|
||||
func onCygwinPty(*os.File) bool { return false }
|
||||
@@ -0,0 +1,63 @@
|
||||
package cli
|
||||
|
||||
import (
|
||||
"os"
|
||||
"testing"
|
||||
|
||||
"reasonix/internal/base/i18n"
|
||||
"reasonix/internal/base/testenv"
|
||||
)
|
||||
|
||||
func TestCygwinPtyNamesAreTheOnesMSYSAndCygwinGiveTheirTerminals(t *testing.T) {
|
||||
for name, want := range map[string]bool{
|
||||
`\msys-1888ae32e00d56aa-pty0-from-master`: true,
|
||||
`\msys-1888ae32e00d56aa-pty0-to-master`: true,
|
||||
`\cygwin-e022582115c10879-pty4-to-master`: true,
|
||||
`\cygwin-e022582115c10879-pty4-from-master`: true,
|
||||
`\msys-1888ae32e00d56aa-pty2-from-master-nat`: true,
|
||||
`\msys-1888ae32e00d56aa-pty2-to-master-nat`: true,
|
||||
`\msys-1888ae32e00d56aa-pty2-to-master-nat2`: false,
|
||||
`\msys-1888ae32e00d56aa-pty0-master`: false,
|
||||
`\msys-1888ae32e00d56a-pty0-to-master`: false,
|
||||
`\Device\NamedPipe\mojo.1234`: false,
|
||||
`\pipe\something`: false,
|
||||
``: false,
|
||||
} {
|
||||
if got := cygwinPtyName(name); got != want {
|
||||
t.Errorf("cygwinPtyName(%q) = %v, want %v", name, got, want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestAMinttyEndReplacesTheUsageTextWithTheHintAndAConsoleDoesNot(t *testing.T) {
|
||||
in, out := os.Stdin, os.Stdout
|
||||
if got := minttyHint(in, out, func(f *os.File) bool { return f == out }); got != i18n.M.MinttyConsoleHint || got == "" {
|
||||
t.Fatalf("hint = %q when stdout is a pty", got)
|
||||
}
|
||||
if got := minttyHint(in, out, func(f *os.File) bool { return f == in }); got == "" {
|
||||
t.Fatal("no hint when stdin is a pty")
|
||||
}
|
||||
if got := minttyHint(in, out, func(*os.File) bool { return false }); got != "" {
|
||||
t.Fatalf("hint %q for a real console or a pipe", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAskingAboutAFileOrAPipeIsAnAnswerNotAFault(t *testing.T) {
|
||||
f, err := os.CreateTemp(testenv.TempDir(t), "x")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer f.Close()
|
||||
if onCygwinPty(f) {
|
||||
t.Fatal("a regular file reported as a Cygwin terminal")
|
||||
}
|
||||
r, w, err := os.Pipe()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer r.Close()
|
||||
defer w.Close()
|
||||
if onCygwinPty(r) || onCygwinPty(w) {
|
||||
t.Fatal("an anonymous pipe reported as a Cygwin terminal")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,31 @@
|
||||
//go:build windows
|
||||
|
||||
package cli
|
||||
|
||||
import (
|
||||
"os"
|
||||
"unicode/utf16"
|
||||
"unsafe"
|
||||
|
||||
"golang.org/x/sys/windows"
|
||||
)
|
||||
|
||||
const fileNameInfo = 2
|
||||
|
||||
// onCygwinPty reports whether f is one end of a Cygwin or MSYS terminal.
|
||||
func onCygwinPty(f *os.File) bool {
|
||||
h := windows.Handle(f.Fd())
|
||||
if t, err := windows.GetFileType(h); err != nil || t != windows.FILE_TYPE_PIPE {
|
||||
return false
|
||||
}
|
||||
var buf [4 + 2*windows.MAX_PATH]byte
|
||||
if windows.GetFileInformationByHandleEx(h, fileNameInfo, &buf[0], uint32(len(buf))) != nil {
|
||||
return false
|
||||
}
|
||||
n := *(*uint32)(unsafe.Pointer(&buf[0])) / 2
|
||||
if n == 0 || int(n) > windows.MAX_PATH {
|
||||
return false
|
||||
}
|
||||
name := unsafe.Slice((*uint16)(unsafe.Pointer(&buf[4])), n)
|
||||
return cygwinPtyName(string(utf16.Decode(name)))
|
||||
}
|
||||
@@ -36,6 +36,10 @@ func runTUI(args []string, version string) int {
|
||||
return code
|
||||
}
|
||||
if !term.IsTerminal(int(os.Stdin.Fd())) || !term.IsTerminal(int(os.Stdout.Fd())) {
|
||||
if hint := minttyHint(os.Stdin, os.Stdout, onCygwinPty); hint != "" {
|
||||
fmt.Fprint(os.Stderr, hint)
|
||||
return 2
|
||||
}
|
||||
fmt.Fprintln(os.Stderr, i18n.M.ErrorPrefix, "reasonix tui needs an interactive terminal; use `reasonix run` for scripts")
|
||||
return 2
|
||||
}
|
||||
|
||||
@@ -51,13 +51,14 @@ func (s *Server) savePeersSettings(w http.ResponseWriter, r *http.Request) {
|
||||
ReleaseProjects *[]string `json:"release_projects"`
|
||||
MaxDepth *int `json:"max_dispatch_depth"`
|
||||
MaxSessions *int `json:"max_dispatched_sessions"`
|
||||
OpenNested *bool `json:"open_nested"`
|
||||
}
|
||||
if err := json.NewDecoder(http.MaxBytesReader(w, r.Body, 64<<10)).Decode(&body); err != nil ||
|
||||
(body.Enabled == nil && body.Dispatch == nil && body.ReleaseProjects == nil && body.MaxDepth == nil && body.MaxSessions == nil) {
|
||||
(body.Enabled == nil && body.Dispatch == nil && body.ReleaseProjects == nil && body.MaxDepth == nil && body.MaxSessions == nil && body.OpenNested == nil) {
|
||||
badBody(w)
|
||||
return
|
||||
}
|
||||
got, err := s.ctl().SavePeersSettings(control.PeersPatch{Enabled: body.Enabled, Dispatch: body.Dispatch, ReleaseProjects: body.ReleaseProjects, MaxDispatchDepth: body.MaxDepth, MaxDispatchedSessions: body.MaxSessions})
|
||||
got, err := s.ctl().SavePeersSettings(control.PeersPatch{Enabled: body.Enabled, Dispatch: body.Dispatch, ReleaseProjects: body.ReleaseProjects, MaxDispatchDepth: body.MaxDepth, MaxDispatchedSessions: body.MaxSessions, OpenNested: body.OpenNested})
|
||||
if err != nil {
|
||||
refusePeer(w, err)
|
||||
return
|
||||
|
||||
@@ -109,3 +109,23 @@ func TestANetworkedClientCannotAllowAProjectThrough(t *testing.T) {
|
||||
t.Fatalf("the refused save still wrote the user file: %v", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestOpeningFoldersWithoutAskingRoundTripsAndANetworkedClientCannotTurnItOn(t *testing.T) {
|
||||
srv := standbyServer(t, true)
|
||||
if getSettings(t, srv.URL).OpenNested {
|
||||
t.Fatal("on by default")
|
||||
}
|
||||
if status, raw := postSide(t, srv.URL+"/peers/settings", map[string]any{"open_nested": true}); status != http.StatusOK {
|
||||
t.Fatalf("save = %d %s", status, raw)
|
||||
}
|
||||
if !getSettings(t, srv.URL).OpenNested || !config.UserPeers().OpenNested {
|
||||
t.Fatal("open_nested was not saved")
|
||||
}
|
||||
locked := standbyServer(t, false)
|
||||
if status, raw := postSide(t, locked.URL+"/peers/settings", map[string]any{"open_nested": true}); status != http.StatusForbidden {
|
||||
t.Fatalf("save without the grant = %d %s", status, raw)
|
||||
}
|
||||
if config.UserPeers().OpenNested {
|
||||
t.Fatal("the refused save still wrote the user file")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,44 @@
|
||||
package serve
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
|
||||
"reasonix/internal/contract/process"
|
||||
)
|
||||
|
||||
// registerRunningWorkRoutes serves what the session has handed off or has
|
||||
// running: its sub-agent runs and the process list across owners.
|
||||
func (s *Server) registerRunningWorkRoutes(mux *http.ServeMux) {
|
||||
s.registerSubagentRoutes(mux)
|
||||
mux.HandleFunc("GET /processes", s.processes)
|
||||
}
|
||||
|
||||
type processView struct {
|
||||
ID string `json:"id"`
|
||||
Kind string `json:"kind"`
|
||||
Parent string `json:"parent,omitempty"`
|
||||
Name string `json:"name"`
|
||||
State string `json:"state"`
|
||||
Cause string `json:"cause,omitempty"`
|
||||
StartedAt int64 `json:"startedAt"` // unix milliseconds
|
||||
}
|
||||
|
||||
func processOf(p process.Process) processView {
|
||||
return processView{
|
||||
ID: p.ID, Kind: string(p.Kind), Parent: p.Parent, Name: p.Name,
|
||||
State: string(p.State), Cause: string(p.Cause), StartedAt: p.Started.UnixMilli(),
|
||||
}
|
||||
}
|
||||
|
||||
func (s *Server) processes(w http.ResponseWriter, _ *http.Request) {
|
||||
procs, err := s.ctl().Processes()
|
||||
if err != nil {
|
||||
refuse(w, http.StatusInternalServerError, "process.read_failed", err.Error(), map[string]any{"detail": err.Error()})
|
||||
return
|
||||
}
|
||||
out := make([]processView, 0, len(procs))
|
||||
for _, p := range procs {
|
||||
out = append(out, processOf(p))
|
||||
}
|
||||
writeJSON(w, out)
|
||||
}
|
||||
@@ -0,0 +1,124 @@
|
||||
package serve
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"io"
|
||||
"net/http"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"reasonix/internal/contract/event"
|
||||
"reasonix/internal/contract/process"
|
||||
"reasonix/internal/session/control"
|
||||
"reasonix/internal/tools/jobs"
|
||||
)
|
||||
|
||||
type processSource struct {
|
||||
control.SessionAPI
|
||||
procs []process.Process
|
||||
err error
|
||||
calls int
|
||||
}
|
||||
|
||||
func (c *processSource) Processes() ([]process.Process, error) {
|
||||
c.calls++
|
||||
return c.procs, c.err
|
||||
}
|
||||
|
||||
func processMux(src *processSource) *http.ServeMux {
|
||||
mux := http.NewServeMux()
|
||||
(&Server{ctrl: src}).registerRunningWorkRoutes(mux)
|
||||
return mux
|
||||
}
|
||||
|
||||
func TestProcessesAreListedWithStateAndCause(t *testing.T) {
|
||||
started := time.UnixMilli(1_760_000_000_000)
|
||||
src := &processSource{procs: []process.Process{
|
||||
{ID: "subagent:sa_1", Kind: process.KindSubagent, Parent: "turn:t3", Name: "audit", State: process.Queued, Cause: process.CauseWriters, Started: started},
|
||||
{ID: "job:bash-1", Kind: process.KindJob, Name: "sleep 60", State: process.Running, Started: started},
|
||||
}}
|
||||
rec := get(processMux(src), "/processes")
|
||||
var got []processView
|
||||
if err := json.Unmarshal(rec.Body.Bytes(), &got); err != nil || rec.Code != 200 {
|
||||
t.Fatalf("status %d body %s err %v", rec.Code, rec.Body.String(), err)
|
||||
}
|
||||
if len(got) != 2 || got[0].ID != "subagent:sa_1" || got[0].State != "queued" || got[0].Cause != "writers" || got[0].Parent != "turn:t3" || got[0].StartedAt != 1_760_000_000_000 {
|
||||
t.Fatalf("processes = %+v", got)
|
||||
}
|
||||
if got[1].Cause != "" || got[1].Parent != "" || got[1].Kind != "job" {
|
||||
t.Fatalf("a job has no parent and no cause, got %+v", got[1])
|
||||
}
|
||||
}
|
||||
|
||||
func TestNoProcessesIsAnEmptyListAndNothingIsReadUnasked(t *testing.T) {
|
||||
src := &processSource{}
|
||||
mux := processMux(src)
|
||||
if src.calls != 0 {
|
||||
t.Fatalf("registering the route read the process table %d times", src.calls)
|
||||
}
|
||||
if rec := get(mux, "/processes"); rec.Body.String() != "[]\n" {
|
||||
t.Fatalf("no processes must be an empty list, got %q", rec.Body.String())
|
||||
}
|
||||
if src.calls != 1 {
|
||||
t.Fatalf("one request read the table %d times, want 1", src.calls)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAFailedReadIsACodedRefusalNotAnEmptyList(t *testing.T) {
|
||||
rec := get(processMux(&processSource{err: errors.New("disk gone")}), "/processes")
|
||||
var reason Reason
|
||||
if err := json.Unmarshal(rec.Body.Bytes(), &reason); err != nil || rec.Code != http.StatusInternalServerError || reason.Code != "process.read_failed" {
|
||||
t.Fatalf("status %d body %s", rec.Code, rec.Body.String())
|
||||
}
|
||||
}
|
||||
|
||||
func TestARealControllerReportsItsRunningJobOverTheRoute(t *testing.T) {
|
||||
m := jobs.NewManager(event.Discard)
|
||||
t.Cleanup(m.Close)
|
||||
job := m.Start("bash", "sleep 60", func(ctx context.Context, _ io.Writer) (string, error) {
|
||||
<-ctx.Done()
|
||||
return "", ctx.Err()
|
||||
})
|
||||
ctrl := control.New(control.Options{Sink: event.Discard, Jobs: m})
|
||||
mux := http.NewServeMux()
|
||||
(&Server{ctrl: ctrl}).registerRunningWorkRoutes(mux)
|
||||
|
||||
var got []processView
|
||||
rec := get(mux, "/processes")
|
||||
if err := json.Unmarshal(rec.Body.Bytes(), &got); err != nil || rec.Code != 200 {
|
||||
t.Fatalf("status %d body %s err %v", rec.Code, rec.Body.String(), err)
|
||||
}
|
||||
if len(got) != 1 || got[0].ID != "job:"+job.ID || got[0].State != "running" || got[0].Name != "sleep 60" || got[0].StartedAt == 0 {
|
||||
t.Fatalf("processes = %+v, want the running job", got)
|
||||
}
|
||||
m.Kill(job.ID)
|
||||
deadline := time.Now().Add(5 * time.Second)
|
||||
for time.Now().Before(deadline) {
|
||||
if rec := get(mux, "/processes"); rec.Body.String() == "[]\n" {
|
||||
return
|
||||
}
|
||||
time.Sleep(10 * time.Millisecond)
|
||||
}
|
||||
t.Fatal("a killed job is still listed as running")
|
||||
}
|
||||
|
||||
func BenchmarkProcessesRoute(b *testing.B) {
|
||||
m := jobs.NewManager(event.Discard)
|
||||
b.Cleanup(m.Close)
|
||||
for range 20 {
|
||||
m.Start("bash", "sleep 60", func(ctx context.Context, _ io.Writer) (string, error) {
|
||||
<-ctx.Done()
|
||||
return "", ctx.Err()
|
||||
})
|
||||
}
|
||||
mux := http.NewServeMux()
|
||||
(&Server{ctrl: control.New(control.Options{Sink: event.Discard, Jobs: m})}).registerRunningWorkRoutes(mux)
|
||||
b.ReportAllocs()
|
||||
for range b.N {
|
||||
if rec := get(mux, "/processes"); rec.Code != 200 {
|
||||
b.Fatalf("status %d", rec.Code)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -38,7 +38,7 @@ func (s *Server) routes() http.Handler {
|
||||
s.registerGitRoutes(mux)
|
||||
s.registerJobRoutes(mux)
|
||||
s.registerToolCallRoutes(mux)
|
||||
s.registerSubagentRoutes(mux)
|
||||
s.registerRunningWorkRoutes(mux)
|
||||
s.registerProviderRoutes(mux)
|
||||
s.registerRoleRoutes(mux)
|
||||
s.registerWelcomeRoutes(mux)
|
||||
|
||||
@@ -13,12 +13,13 @@ import (
|
||||
)
|
||||
|
||||
const (
|
||||
codeTerminalNotFound = "terminal.not_found"
|
||||
codeTerminalShell = "terminal.shell_missing"
|
||||
codeTerminalStart = "terminal.start_failed"
|
||||
codeTerminalExited = "terminal.exited"
|
||||
codeTerminalLimit = "terminal.limit"
|
||||
terminalInputLimit = 1 << 16
|
||||
codeTerminalNotFound = "terminal.not_found"
|
||||
codeTerminalShell = "terminal.shell_missing"
|
||||
codeTerminalStart = "terminal.start_failed"
|
||||
codeTerminalUnsupported = "terminal.unsupported"
|
||||
codeTerminalExited = "terminal.exited"
|
||||
codeTerminalLimit = "terminal.limit"
|
||||
terminalInputLimit = 1 << 16
|
||||
)
|
||||
|
||||
// registerWorkbenchRoutes is what the panes beside the conversation call on.
|
||||
@@ -49,6 +50,8 @@ func refuseTerminal(w http.ResponseWriter, err error) {
|
||||
refuse(w, http.StatusConflict, codeTerminalExited, err.Error(), nil)
|
||||
case terminal.CodeLimit:
|
||||
refuse(w, http.StatusConflict, codeTerminalLimit, err.Error(), nil)
|
||||
case terminal.CodeUnsupported:
|
||||
refuse(w, http.StatusNotImplemented, codeTerminalUnsupported, err.Error(), nil)
|
||||
default:
|
||||
refuse(w, http.StatusInternalServerError, codeTerminalStart, err.Error(), nil)
|
||||
}
|
||||
|
||||
@@ -0,0 +1,19 @@
|
||||
package serve
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"testing"
|
||||
|
||||
"reasonix/internal/platform/terminal"
|
||||
)
|
||||
|
||||
func TestAnUnsupportedTerminalIsRefusedWithItsOwnCode(t *testing.T) {
|
||||
rec := httptest.NewRecorder()
|
||||
refuseTerminal(rec, &terminal.Error{Code: terminal.CodeUnsupported, Detail: "no pseudoconsole"})
|
||||
var reason Reason
|
||||
if err := json.Unmarshal(rec.Body.Bytes(), &reason); err != nil || rec.Code != http.StatusNotImplemented || reason.Code != "terminal.unsupported" {
|
||||
t.Fatalf("status %d body %s", rec.Code, rec.Body.String())
|
||||
}
|
||||
}
|
||||
@@ -10,6 +10,7 @@ const (
|
||||
CodeNotFound = "terminal.not_found"
|
||||
CodeShellMissing = "terminal.shell_missing"
|
||||
CodeStartFailed = "terminal.start_failed"
|
||||
CodeUnsupported = "terminal.unsupported"
|
||||
CodeExited = "terminal.exited"
|
||||
CodeLimit = "terminal.limit"
|
||||
)
|
||||
|
||||
@@ -4,6 +4,7 @@ package terminal
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"strings"
|
||||
"sync"
|
||||
"syscall"
|
||||
@@ -31,11 +32,21 @@ func startPty(o Options) (pty, error) {
|
||||
}
|
||||
c, err := conpty.Start(strings.Join(parts, " "), opts...)
|
||||
if err != nil {
|
||||
return nil, fail(CodeStartFailed, "%v", err)
|
||||
return nil, ptyStartError(err)
|
||||
}
|
||||
return &winPty{c: c}, nil
|
||||
}
|
||||
|
||||
// ptyStartError separates a Windows that has no pseudoconsole (before 1809)
|
||||
// from a shell that failed to start: the first can never succeed, so a person
|
||||
// is told the terminal is unsupported rather than to try again.
|
||||
func ptyStartError(err error) *Error {
|
||||
if errors.Is(err, conpty.ErrConPtyUnsupported) {
|
||||
return fail(CodeUnsupported, "%v", err)
|
||||
}
|
||||
return fail(CodeStartFailed, "%v", err)
|
||||
}
|
||||
|
||||
func (p *winPty) Read(b []byte) (int, error) { return p.c.Read(b) }
|
||||
func (p *winPty) Write(b []byte) (int, error) { return p.c.Write(b) }
|
||||
func (p *winPty) Close() error {
|
||||
|
||||
@@ -0,0 +1,24 @@
|
||||
//go:build windows
|
||||
|
||||
package terminal
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"testing"
|
||||
|
||||
"github.com/UserExistsError/conpty"
|
||||
)
|
||||
|
||||
func TestAWindowsWithoutAPseudoConsoleIsAnUnsupportedTerminalNotAFailedStart(t *testing.T) {
|
||||
err := ptyStartError(fmt.Errorf("start: %w", conpty.ErrConPtyUnsupported))
|
||||
if CodeOf(err) != CodeUnsupported {
|
||||
t.Fatalf("code = %q, want %q", CodeOf(err), CodeUnsupported)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAnyOtherStartFailureStaysAFailedStart(t *testing.T) {
|
||||
if got := CodeOf(ptyStartError(errors.New("the file cannot be found"))); got != CodeStartFailed {
|
||||
t.Fatalf("code = %q, want %q", got, CodeStartFailed)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,151 @@
|
||||
package delegation
|
||||
|
||||
import (
|
||||
"context"
|
||||
"testing"
|
||||
|
||||
"reasonix/internal/base/testenv"
|
||||
"reasonix/internal/contract/tool"
|
||||
)
|
||||
|
||||
func TestTurnIdentityRidesTheContext(t *testing.T) {
|
||||
if got := TurnIdentity(context.Background()); got != "" {
|
||||
t.Fatalf("bare context carries turn %q", got)
|
||||
}
|
||||
if got := TurnIdentity(WithTurnIdentity(context.Background(), "turn-7")); got != "turn-7" {
|
||||
t.Fatalf("TurnIdentity = %q, want turn-7", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRunRecordNamesTheTurnItWasSpawnedIn(t *testing.T) {
|
||||
store := NewSubagentStore(testenv.TempDir(t))
|
||||
spec := testSubagentSpec(t, "audit")
|
||||
spec.ParentTurn = "turn-7"
|
||||
run, err := store.PrepareFresh(spec)
|
||||
if err != nil {
|
||||
t.Fatalf("PrepareFresh: %v", err)
|
||||
}
|
||||
defer run.Release()
|
||||
if err := store.MarkRunning(run); err != nil {
|
||||
t.Fatalf("MarkRunning: %v", err)
|
||||
}
|
||||
meta, err := store.LoadMeta(run.Ref)
|
||||
if err != nil || meta.ParentTurn != "turn-7" {
|
||||
t.Fatalf("stored parent turn = %q err=%v, want turn-7", meta.ParentTurn, err)
|
||||
}
|
||||
got, err := store.Transcript(run.Ref, spec.ParentSession, spec.WorkspaceRoot, 0, 0)
|
||||
if err != nil || got.Info.ParentTurn != "turn-7" {
|
||||
t.Fatalf("listed parent turn = %q err=%v, want turn-7", got.Info.ParentTurn, err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestParentTurnDoesNotChangeTheCapsuleIdentity(t *testing.T) {
|
||||
store := NewSubagentStore(testenv.TempDir(t))
|
||||
plain := testSubagentSpec(t, "audit")
|
||||
turned := plain
|
||||
turned.ParentTurn = "turn-9"
|
||||
a, err := store.PrepareFresh(plain)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer a.Release()
|
||||
b, err := store.PrepareFresh(turned)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer b.Release()
|
||||
if a.Meta.CapsuleHash != b.Meta.CapsuleHash {
|
||||
t.Fatal("a parent turn changed the capsule hash: two runs of the same work would differ on every turn")
|
||||
}
|
||||
}
|
||||
|
||||
func BenchmarkPrepareFresh(b *testing.B) {
|
||||
reg := tool.NewRegistry()
|
||||
reg.Add(fakeTool{name: "read_file", readOnly: true})
|
||||
for _, turn := range []string{"", "turn-7"} {
|
||||
b.Run("turn="+turn, func(b *testing.B) {
|
||||
store := NewSubagentStore(testenv.TempDir(b))
|
||||
spec := SubagentSpec{ExecutionID: "e", Kind: "task", Name: "x", WorkspaceRoot: testenv.TempDir(b), ParentSession: "p", SystemPrompt: "s", Registry: reg, ParentTurn: turn}
|
||||
b.ReportAllocs()
|
||||
for range b.N {
|
||||
run, err := store.PrepareFresh(spec)
|
||||
if err != nil {
|
||||
b.Fatal(err)
|
||||
}
|
||||
run.Release()
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestLiveRunsListsOnlyWhatThisProcessIsExecuting(t *testing.T) {
|
||||
store, spec := viewFixture(t)
|
||||
spec.ParentTurn = "turn-3"
|
||||
running := startedRun(t, store, spec)
|
||||
done := startedRun(t, store, spec)
|
||||
if err := store.SaveCompleted(done); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
done.Release()
|
||||
|
||||
got, err := store.LiveRuns(spec.ParentSession, spec.WorkspaceRoot)
|
||||
if err != nil {
|
||||
t.Fatalf("LiveRuns: %v", err)
|
||||
}
|
||||
if len(got) != 1 || got[0].Ref != running.Ref || got[0].State != RunRunning || got[0].ParentTurn != "turn-3" {
|
||||
t.Fatalf("live runs = %+v, want only %s running under turn-3", got, running.Ref)
|
||||
}
|
||||
if other, _ := store.LiveRuns("another-session", spec.WorkspaceRoot); len(other) != 0 {
|
||||
t.Fatalf("another conversation sees %d live runs, want none", len(other))
|
||||
}
|
||||
running.Release()
|
||||
if after, _ := store.LiveRuns(spec.ParentSession, spec.WorkspaceRoot); len(after) != 0 {
|
||||
t.Fatalf("a released run is still live: %+v", after)
|
||||
}
|
||||
}
|
||||
|
||||
func BenchmarkLiveRunsAgainstARunsScan(b *testing.B) {
|
||||
store := NewSubagentStore(testenv.TempDir(b) + "/subagents")
|
||||
reg := tool.NewRegistry()
|
||||
reg.Add(fakeTool{name: "read_file", readOnly: true})
|
||||
spec := SubagentSpec{ExecutionID: "e", Kind: "task", Name: "x", WorkspaceRoot: testenv.TempDir(b), ParentSession: "p", SystemPrompt: "s", Registry: reg}
|
||||
for range 300 {
|
||||
run, err := store.PrepareFresh(spec)
|
||||
if err != nil {
|
||||
b.Fatal(err)
|
||||
}
|
||||
if err := store.MarkRunning(run); err != nil {
|
||||
b.Fatal(err)
|
||||
}
|
||||
if err := store.SaveCompleted(run); err != nil {
|
||||
b.Fatal(err)
|
||||
}
|
||||
run.Release()
|
||||
}
|
||||
for range 3 {
|
||||
run, err := store.PrepareFresh(spec)
|
||||
if err != nil {
|
||||
b.Fatal(err)
|
||||
}
|
||||
if err := store.MarkRunning(run); err != nil {
|
||||
b.Fatal(err)
|
||||
}
|
||||
defer run.Release()
|
||||
}
|
||||
b.Run("LiveRuns", func(b *testing.B) {
|
||||
b.ReportAllocs()
|
||||
for range b.N {
|
||||
if _, err := store.LiveRuns("p", spec.WorkspaceRoot); err != nil {
|
||||
b.Fatal(err)
|
||||
}
|
||||
}
|
||||
})
|
||||
b.Run("Runs", func(b *testing.B) {
|
||||
b.ReportAllocs()
|
||||
for range b.N {
|
||||
if _, err := store.Runs("p", spec.WorkspaceRoot); err != nil {
|
||||
b.Fatal(err)
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
package delegation
|
||||
|
||||
import "reasonix/internal/contract/process"
|
||||
|
||||
// ProcessClass projects the run state onto the shared process vocabulary.
|
||||
func (s RunState) ProcessClass() (process.Class, bool) {
|
||||
switch s {
|
||||
case RunRunning:
|
||||
return process.Class{State: process.Running}, true
|
||||
case RunCompleted:
|
||||
return process.Class{State: process.Completed}, true
|
||||
case RunFailed:
|
||||
return process.Class{State: process.Failed}, true
|
||||
case RunCancelled:
|
||||
return process.Class{State: process.Stopped}, true
|
||||
case RunInterrupted:
|
||||
return process.Class{State: process.Interrupted}, true
|
||||
}
|
||||
return process.Class{}, false
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
package delegation
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"reasonix/internal/contract/process"
|
||||
"reasonix/internal/contract/process/proctest"
|
||||
)
|
||||
|
||||
func TestRunStateMapsOntoProcessStates(t *testing.T) {
|
||||
proctest.Total(t, ".", "RunState", func(v string) (process.Class, bool) { return RunState(v).ProcessClass() })
|
||||
}
|
||||
@@ -164,6 +164,7 @@ func (r *Runner) prepare(ctx context.Context, spec RunSpec, system string, subRe
|
||||
WorkspaceRoot: r.WorkspaceRoot,
|
||||
ParentSession: parentSession,
|
||||
ParentToolCallID: parentID,
|
||||
ParentTurn: TurnIdentity(ctx),
|
||||
SystemPrompt: system,
|
||||
ExecutionID: executionFor(parentID, spec.Index),
|
||||
Registry: subReg,
|
||||
|
||||
@@ -37,6 +37,7 @@ func metaFromSpec(ref string, status sessionstore.SubagentStatus, created, updat
|
||||
WorkspaceRoot: capsule.WorkspaceRoot,
|
||||
ParentSession: capsule.ParentSession,
|
||||
ParentToolCallID: capsule.ParentToolCallID,
|
||||
ParentTurn: strings.TrimSpace(spec.ParentTurn),
|
||||
SystemPromptHash: capsule.SystemPromptHash,
|
||||
ToolScope: capsule.ToolScope,
|
||||
ToolSchemaHash: capsule.ToolSchemaHash,
|
||||
|
||||
@@ -30,7 +30,9 @@ type SubagentSpec struct {
|
||||
WorkspaceRoot string
|
||||
ParentSession string
|
||||
ParentToolCallID string
|
||||
SystemPrompt string
|
||||
// ParentTurn is the in-flight marker id of the turn that spawned the run.
|
||||
ParentTurn string
|
||||
SystemPrompt string
|
||||
// ExecutionID is the execution this run belongs to, allocated before the
|
||||
// work was opened. A store ref cannot stand in for it: work the scheduler
|
||||
// is holding back has an execution and no transcript yet.
|
||||
|
||||
@@ -50,6 +50,7 @@ type RunInfo struct {
|
||||
// ParentToolCallID is the call in the parent's transcript that spawned the
|
||||
// run; it is how a reader pairs a task card with its run.
|
||||
ParentToolCallID string
|
||||
ParentTurn string
|
||||
StartedAt time.Time
|
||||
UpdatedAt time.Time
|
||||
}
|
||||
@@ -114,7 +115,7 @@ func (s *SubagentStore) infoOf(meta sessionstore.SubagentMeta) RunInfo {
|
||||
}
|
||||
return RunInfo{
|
||||
Ref: meta.Ref, Name: meta.Name, Kind: meta.Kind, Model: meta.Model, Effort: meta.Effort,
|
||||
State: state, ParentSession: meta.ParentSession, ParentToolCallID: meta.ParentToolCallID,
|
||||
State: state, ParentSession: meta.ParentSession, ParentToolCallID: meta.ParentToolCallID, ParentTurn: meta.ParentTurn,
|
||||
StartedAt: meta.CreatedAt, UpdatedAt: meta.UpdatedAt,
|
||||
}
|
||||
}
|
||||
@@ -141,6 +142,40 @@ func (s *SubagentStore) Runs(parentSession, workspaceRoot string) ([]RunInfo, er
|
||||
return out, nil
|
||||
}
|
||||
|
||||
// LiveRuns lists the runs of parentSession that this process is executing now.
|
||||
// It reads the in-memory registry and one small record per live run, never the
|
||||
// store's directory, so asking often costs what is running and not what ran.
|
||||
func (s *SubagentStore) LiveRuns(parentSession, workspaceRoot string) ([]RunInfo, error) {
|
||||
parentSession = strings.TrimSpace(parentSession)
|
||||
if s == nil || parentSession == "" {
|
||||
return nil, nil
|
||||
}
|
||||
s.mu.Lock()
|
||||
refs := make([]string, 0, len(s.live))
|
||||
for ref := range s.live {
|
||||
refs = append(refs, ref)
|
||||
}
|
||||
s.mu.Unlock()
|
||||
want := strings.TrimSpace(workspaceRoot)
|
||||
out := make([]RunInfo, 0, len(refs))
|
||||
for _, ref := range refs {
|
||||
meta, err := s.LoadMeta(ref)
|
||||
if err != nil {
|
||||
if errors.Is(err, os.ErrNotExist) {
|
||||
continue
|
||||
}
|
||||
return nil, err
|
||||
}
|
||||
if strings.TrimSpace(meta.ParentSession) != parentSession || (want != "" && strings.TrimSpace(meta.WorkspaceRoot) != want) {
|
||||
continue
|
||||
}
|
||||
meta.Ref = ref
|
||||
out = append(out, s.infoOf(meta))
|
||||
}
|
||||
slices.SortFunc(out, func(a, b RunInfo) int { return b.StartedAt.Compare(a.StartedAt) })
|
||||
return out, nil
|
||||
}
|
||||
|
||||
// Transcript reads a run's conversation from index after, at most limit
|
||||
// messages (a page when zero). A run this process is executing is read from
|
||||
// memory; any other from its stored transcript.
|
||||
|
||||
@@ -0,0 +1,18 @@
|
||||
package delegation
|
||||
|
||||
import "context"
|
||||
|
||||
type turnIdentityContextKey struct{}
|
||||
|
||||
// WithTurnIdentity carries the running turn's in-flight marker id through the
|
||||
// tool calls it makes; the transcript has no position for a turn that has not
|
||||
// ended, which is when a delegation is opened.
|
||||
func WithTurnIdentity(ctx context.Context, id string) context.Context {
|
||||
return context.WithValue(ctx, turnIdentityContextKey{}, id)
|
||||
}
|
||||
|
||||
// TurnIdentity returns the turn a context was opened under, or empty.
|
||||
func TurnIdentity(ctx context.Context) string {
|
||||
id, _ := ctx.Value(turnIdentityContextKey{}).(string)
|
||||
return id
|
||||
}
|
||||
@@ -0,0 +1,24 @@
|
||||
package taskmonitor
|
||||
|
||||
import "reasonix/internal/contract/process"
|
||||
|
||||
// ProcessClass projects the task state onto the shared process vocabulary.
|
||||
func (s TaskState) ProcessClass() (process.Class, bool) {
|
||||
switch s {
|
||||
case TaskStateQueued:
|
||||
return process.Class{State: process.Queued}, true
|
||||
case TaskStateRunning:
|
||||
return process.Class{State: process.Running}, true
|
||||
case TaskStateWaiting:
|
||||
return process.Class{State: process.Waiting}, true
|
||||
case TaskStateSucceeded:
|
||||
return process.Class{State: process.Completed}, true
|
||||
case TaskStateFailed:
|
||||
return process.Class{State: process.Failed}, true
|
||||
case TaskStateCancelled:
|
||||
return process.Class{State: process.Stopped}, true
|
||||
case TaskStateStale:
|
||||
return process.Class{State: process.Interrupted}, true
|
||||
}
|
||||
return process.Class{}, false
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
package taskmonitor
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"reasonix/internal/contract/process"
|
||||
"reasonix/internal/contract/process/proctest"
|
||||
)
|
||||
|
||||
func TestTaskStateMapsOntoProcessStates(t *testing.T) {
|
||||
proctest.Total(t, ".", "TaskState", func(v string) (process.Class, bool) { return TaskState(v).ProcessClass() })
|
||||
}
|
||||
@@ -32,6 +32,7 @@ import (
|
||||
"reasonix/internal/ext/skill"
|
||||
"reasonix/internal/runtime/agent"
|
||||
"reasonix/internal/runtime/coordinator"
|
||||
"reasonix/internal/runtime/delegation"
|
||||
"reasonix/internal/runtime/guardian"
|
||||
"reasonix/internal/runtime/usecap"
|
||||
"reasonix/internal/safety/permission"
|
||||
@@ -5244,3 +5245,23 @@ func TestCacheColdAfterFailureFallsBackTo24h(t *testing.T) {
|
||||
t.Fatalf("ResolveModel failure must fall back to 24h, got %v", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestBeginTurnHandsTheMarkerIdToTheToolContext(t *testing.T) {
|
||||
dir := testenv.TempDir(t)
|
||||
path := filepath.Join(dir, "session.jsonl")
|
||||
sess := sessionstore.NewSession("sys")
|
||||
exec := agent.New(nil, nil, sess, agent.Options{}, event.Discard)
|
||||
c := New(Options{Executor: exec, SessionDir: dir, SessionPath: path, Label: "test"})
|
||||
|
||||
ctx, marker := c.beginTurn(context.Background(), sess.Len(), true)
|
||||
if marker.ID == "" {
|
||||
t.Fatal("in-flight marker was not created")
|
||||
}
|
||||
if got := delegation.TurnIdentity(ctx); got != marker.ID {
|
||||
t.Fatalf("tool context turn = %q, want the persisted marker %q", got, marker.ID)
|
||||
}
|
||||
meta, ok, err := sessionstore.LoadBranchMeta(path)
|
||||
if err != nil || !ok || meta.InFlightTurn == nil || meta.InFlightTurn.ID != delegation.TurnIdentity(ctx) {
|
||||
t.Fatalf("persisted marker = %+v ok=%v err=%v, want the id the context carries", meta.InFlightTurn, ok, err)
|
||||
}
|
||||
}
|
||||
@@ -5,6 +5,7 @@ import (
|
||||
"reasonix/internal/state/sessionstore"
|
||||
"strings"
|
||||
|
||||
"reasonix/internal/runtime/delegation"
|
||||
"reasonix/internal/state/execjournal"
|
||||
)
|
||||
|
||||
@@ -15,6 +16,9 @@ func (c *Controller) beginTurn(ctx context.Context, startMessageIndex int, prese
|
||||
c.applyPeerSwitches()
|
||||
c.settings.proposals.Store(0)
|
||||
marker := c.markInFlightTurn(startMessageIndex, preserveUser)
|
||||
if marker.ID != "" {
|
||||
ctx = delegation.WithTurnIdentity(ctx, marker.ID)
|
||||
}
|
||||
return ctx, marker
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,30 @@
|
||||
package control
|
||||
|
||||
import (
|
||||
"path/filepath"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// insideWorkspace reports whether every path is a folder strictly inside root,
|
||||
// after both are resolved through links. The root itself is not inside itself,
|
||||
// and a path that cannot be resolved is not inside anything: the card decides.
|
||||
func insideWorkspace(root string, paths []string) bool {
|
||||
if strings.TrimSpace(root) == "" || len(paths) == 0 {
|
||||
return false
|
||||
}
|
||||
base, err := filepath.EvalSymlinks(filepath.Clean(root))
|
||||
if err != nil {
|
||||
return false
|
||||
}
|
||||
for _, p := range paths {
|
||||
resolved, err := filepath.EvalSymlinks(p)
|
||||
if err != nil {
|
||||
return false
|
||||
}
|
||||
rel, err := filepath.Rel(base, resolved)
|
||||
if err != nil || rel == "." || rel == ".." || strings.HasPrefix(rel, ".."+string(filepath.Separator)) || filepath.IsAbs(rel) {
|
||||
return false
|
||||
}
|
||||
}
|
||||
return true
|
||||
}
|
||||
@@ -8,6 +8,7 @@ import (
|
||||
"path/filepath"
|
||||
"strings"
|
||||
|
||||
"reasonix/internal/base/i18n"
|
||||
"reasonix/internal/contract/config"
|
||||
"reasonix/internal/contract/tool"
|
||||
)
|
||||
@@ -46,9 +47,12 @@ func (c *Controller) SetProjectOpener(o ProjectOpener) {
|
||||
|
||||
// OpenProjects asks the user whether to add the folders to their project list,
|
||||
// and on a yes has the host add and trust exactly the ones the card named. The
|
||||
// card is fresh: no mode, grant or hook answers it for the user.
|
||||
// card is fresh: no mode, grant or hook answers it for the user. The one
|
||||
// exception is the user's own open_nested setting, which skips it for folders
|
||||
// inside this session's workspace and says so in a notice.
|
||||
func (c *Controller) OpenProjects(ctx context.Context, paths []string) ([]tool.OpenedProject, error) {
|
||||
if !config.LoadForEdit(config.UserConfigPath()).PeersDispatch() {
|
||||
user := config.LoadForEdit(config.UserConfigPath())
|
||||
if !user.PeersDispatch() {
|
||||
return nil, ErrDispatchDisabled
|
||||
}
|
||||
if c.PlanMode() {
|
||||
@@ -62,6 +66,10 @@ func (c *Controller) OpenProjects(ctx context.Context, paths []string) ([]tool.O
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if user.PeersOpenNested() && insideWorkspace(c.WorkspaceRoot(), clean) {
|
||||
c.notice(fmt.Sprintf(i18n.M.OpenedNestedWithoutAskingFmt, len(clean), strings.Join(clean, ", ")))
|
||||
return (*hook)(ctx, c, clean)
|
||||
}
|
||||
args, _ := json.Marshal(map[string]any{"paths": clean})
|
||||
subject := clean[0]
|
||||
if len(clean) > 1 {
|
||||
|
||||
@@ -3,11 +3,14 @@ package control
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"reasonix/internal/base/testenv"
|
||||
"reasonix/internal/contract/config"
|
||||
"reasonix/internal/contract/event"
|
||||
"reasonix/internal/contract/tool"
|
||||
)
|
||||
|
||||
@@ -115,3 +118,120 @@ func TestOpenProjectsNeedsTheDispatchSwitchAndAHost(t *testing.T) {
|
||||
t.Fatalf("code = %q", got)
|
||||
}
|
||||
}
|
||||
|
||||
func openNestedController(t *testing.T, root string, on bool) (*Controller, chan event.Approval, *[][]string) {
|
||||
t.Helper()
|
||||
setDispatchSwitch(t, true, true)
|
||||
cfg := config.LoadForEdit(config.UserConfigPath())
|
||||
cfg.Peers.OpenNested = on
|
||||
if err := cfg.SaveTo(config.UserConfigPath()); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
requests := make(chan event.Approval, 8)
|
||||
c := New(Options{WorkspaceRoot: root, Sink: event.FuncSink(func(e event.Event) {
|
||||
if e.Kind == event.ApprovalRequest {
|
||||
requests <- e.Approval
|
||||
}
|
||||
})})
|
||||
var seen [][]string
|
||||
c.SetProjectOpener(func(_ context.Context, _ *Controller, paths []string) ([]tool.OpenedProject, error) {
|
||||
seen = append(seen, paths)
|
||||
out := make([]tool.OpenedProject, len(paths))
|
||||
for i, p := range paths {
|
||||
out[i] = tool.OpenedProject{Path: p, Added: true}
|
||||
}
|
||||
return out, nil
|
||||
})
|
||||
return c, requests, &seen
|
||||
}
|
||||
|
||||
func TestFoldersInsideTheWorkspaceOpenWithoutACardWhenTheUserAllowedIt(t *testing.T) {
|
||||
root := testenv.TempDir(t)
|
||||
inner := filepath.Join(root, "src", "systems")
|
||||
other := filepath.Join(root, ".agent")
|
||||
for _, d := range []string{inner, other} {
|
||||
if err := os.MkdirAll(d, 0o755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
c, requests, seen := openNestedController(t, root, true)
|
||||
done := openProjectsAsync(c, inner, other)
|
||||
select {
|
||||
case a := <-requests:
|
||||
t.Fatalf("a card was shown for folders inside the workspace: %+v", a)
|
||||
case got := <-done:
|
||||
if got.err != nil || len(*seen) != 1 || len((*seen)[0]) != 2 || len(got.got) != 2 {
|
||||
t.Fatalf("got %+v err=%v host saw %v, want both folders opened by the host", got.got, got.err, *seen)
|
||||
}
|
||||
case <-time.After(10 * time.Second):
|
||||
t.Fatal("OpenProjects neither opened the folders nor asked")
|
||||
}
|
||||
}
|
||||
|
||||
func TestALinkInsideTheWorkspaceThatLeavesItStillAsks(t *testing.T) {
|
||||
root, outside := testenv.TempDir(t), testenv.TempDir(t)
|
||||
link := filepath.Join(root, "escape")
|
||||
if err := os.Symlink(outside, link); err != nil {
|
||||
t.Skipf("cannot make a symlink here: %v", err)
|
||||
}
|
||||
c, requests, seen := openNestedController(t, root, true)
|
||||
done := openProjectsAsync(c, link)
|
||||
a := nextApproval(t, requests)
|
||||
if a.Tool != OpenProjectsApprovalTool || len(*seen) != 0 {
|
||||
t.Fatalf("approval = %+v seen=%v, want a card and no host call", a, *seen)
|
||||
}
|
||||
c.Approve(a.ID, false, false, false)
|
||||
<-done
|
||||
}
|
||||
|
||||
func TestAFolderOutsideTheWorkspaceStillAsksEvenWhenTheOthersAreInside(t *testing.T) {
|
||||
root, outside := testenv.TempDir(t), testenv.TempDir(t)
|
||||
inner := filepath.Join(root, "pkg")
|
||||
if err := os.MkdirAll(inner, 0o755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
for name, paths := range map[string][]string{
|
||||
"one outside": {inner, outside},
|
||||
"parent traversal": {filepath.Join(root, "..", filepath.Base(outside))},
|
||||
"the root's parent": {filepath.Dir(root)},
|
||||
} {
|
||||
t.Run(name, func(t *testing.T) {
|
||||
c, requests, seen := openNestedController(t, root, true)
|
||||
done := openProjectsAsync(c, paths...)
|
||||
a := nextApproval(t, requests)
|
||||
if a.Tool != OpenProjectsApprovalTool || !a.Fresh {
|
||||
t.Fatalf("approval = %+v, want the fresh open_projects card", a)
|
||||
}
|
||||
if len(*seen) != 0 {
|
||||
t.Fatal("the host was asked before the click")
|
||||
}
|
||||
c.Approve(a.ID, false, false, false)
|
||||
<-done
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestInsideTheWorkspaceStillAsksWhenTheUserHasNotAllowedIt(t *testing.T) {
|
||||
root := testenv.TempDir(t)
|
||||
inner := filepath.Join(root, "pkg")
|
||||
if err := os.MkdirAll(inner, 0o755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
c, requests, seen := openNestedController(t, root, false)
|
||||
done := openProjectsAsync(c, inner)
|
||||
a := nextApproval(t, requests)
|
||||
if a.Tool != OpenProjectsApprovalTool || len(*seen) != 0 {
|
||||
t.Fatalf("approval = %+v seen=%v, want a card and no host call", a, *seen)
|
||||
}
|
||||
c.Approve(a.ID, false, false, false)
|
||||
<-done
|
||||
}
|
||||
|
||||
func TestAFolderThatDoesNotExistIsNotInsideAnything(t *testing.T) {
|
||||
root := testenv.TempDir(t)
|
||||
c, requests, _ := openNestedController(t, root, true)
|
||||
done := openProjectsAsync(c, filepath.Join(root, "missing"))
|
||||
a := nextApproval(t, requests)
|
||||
c.Approve(a.ID, false, false, false)
|
||||
<-done
|
||||
}
|
||||
@@ -44,12 +44,15 @@ type PeersSettings struct {
|
||||
ReleaseProjects []PeerReleased `json:"release_projects"`
|
||||
// MaxDispatchDepth and MaxDispatchedSessions are the user's ceilings on chained
|
||||
// and simultaneous dispatches; 0 removes a ceiling.
|
||||
MaxDispatchDepth int `json:"max_dispatch_depth"`
|
||||
MaxDispatchedSessions int `json:"max_dispatched_sessions"`
|
||||
Path string `json:"path"`
|
||||
Applies string `json:"applies"`
|
||||
Pending bool `json:"pending"`
|
||||
Available bool `json:"available"`
|
||||
MaxDispatchDepth int `json:"max_dispatch_depth"`
|
||||
MaxDispatchedSessions int `json:"max_dispatched_sessions"`
|
||||
// OpenNested is the user's standing yes for open_projects to add folders inside
|
||||
// the session's own workspace without a card; it takes effect at the next call.
|
||||
OpenNested bool `json:"open_nested"`
|
||||
Path string `json:"path"`
|
||||
Applies string `json:"applies"`
|
||||
Pending bool `json:"pending"`
|
||||
Available bool `json:"available"`
|
||||
}
|
||||
|
||||
// PeersPatch is a partial update: a nil field is left as it is, and a non-nil
|
||||
@@ -61,6 +64,7 @@ type PeersPatch struct {
|
||||
// Negative values are refused; 0 removes the ceiling.
|
||||
MaxDispatchDepth *int
|
||||
MaxDispatchedSessions *int
|
||||
OpenNested *bool
|
||||
}
|
||||
|
||||
// PeerRemember says what a decision on a held message also writes down.
|
||||
@@ -81,7 +85,7 @@ func (c *Controller) peersSettingsFrom(cfg config.PeersConfig) PeersSettings {
|
||||
p := c.peers
|
||||
out := PeersSettings{
|
||||
Enabled: cfg.Enabled, Dispatch: cfg.Enabled && cfg.Dispatch, Path: config.UserConfigPath(),
|
||||
MaxDispatchDepth: max(cfg.MaxDispatchDepth, 0), MaxDispatchedSessions: max(cfg.MaxDispatchedSessions, 0),
|
||||
MaxDispatchDepth: max(cfg.MaxDispatchDepth, 0), MaxDispatchedSessions: max(cfg.MaxDispatchedSessions, 0), OpenNested: cfg.OpenNested,
|
||||
Applies: "next_turn", Available: p != nil && p.root != "", ReleaseProjects: []PeerReleased{},
|
||||
}
|
||||
for _, path := range cfg.ReleaseProjects {
|
||||
@@ -123,6 +127,9 @@ func (c *Controller) SavePeersSettings(patch PeersPatch) (PeersSettings, error)
|
||||
if patch.MaxDispatchedSessions != nil {
|
||||
p.MaxDispatchedSessions = *patch.MaxDispatchedSessions
|
||||
}
|
||||
if patch.OpenNested != nil {
|
||||
p.OpenNested = *patch.OpenNested
|
||||
}
|
||||
return nil
|
||||
})
|
||||
if err != nil {
|
||||
|
||||
@@ -260,3 +260,25 @@ func TestTheDispatchCeilingsAreSettingsTheUserCanRaiseRemoveAndNotMakeNegative(t
|
||||
t.Fatal("a refused ceiling changed the file")
|
||||
}
|
||||
}
|
||||
|
||||
func TestOpeningNestedFoldersWithoutAskingIsOffByDefaultAndTheUsersToTurnOn(t *testing.T) {
|
||||
f := newStandbyFixture(t)
|
||||
if f.c.PeersSettings().OpenNested {
|
||||
t.Fatal("opening folders without a card is on by default")
|
||||
}
|
||||
on := true
|
||||
got, err := f.c.SavePeersSettings(PeersPatch{OpenNested: &on})
|
||||
if err != nil || !got.OpenNested {
|
||||
t.Fatalf("saved = %+v, %v; want open_nested on", got, err)
|
||||
}
|
||||
if again := f.c.PeersSettings(); !again.OpenNested {
|
||||
t.Fatal("open_nested did not survive a read-back")
|
||||
}
|
||||
if _, err := f.c.SavePeersSettings(PeersPatch{}); err != nil || !f.c.PeersSettings().OpenNested {
|
||||
t.Fatalf("an empty patch changed open_nested (err %v)", err)
|
||||
}
|
||||
off := false
|
||||
if got, err := f.c.SavePeersSettings(PeersPatch{OpenNested: &off}); err != nil || got.OpenNested {
|
||||
t.Fatalf("turned off = %+v, %v", got, err)
|
||||
}
|
||||
}
|
||||
@@ -9,6 +9,7 @@ import (
|
||||
"reasonix/internal/contract/config"
|
||||
"reasonix/internal/contract/event"
|
||||
"reasonix/internal/contract/pricing"
|
||||
"reasonix/internal/contract/process"
|
||||
"reasonix/internal/contract/provider"
|
||||
"reasonix/internal/ext/command"
|
||||
"reasonix/internal/ext/hook"
|
||||
@@ -450,6 +451,11 @@ type SubagentView interface {
|
||||
SubagentTranscript(ref string, after, limit int) (delegation.RunTranscript, error)
|
||||
}
|
||||
|
||||
// ProcessView reads what the session has running now, across owners.
|
||||
type ProcessView interface {
|
||||
Processes() ([]process.Process, error)
|
||||
}
|
||||
|
||||
// Mods is Claude Code mods: which are installed and what became of each, and the
|
||||
// user's switch and budget for running them.
|
||||
type Mods interface {
|
||||
@@ -562,6 +568,7 @@ type SurfaceHistory interface {
|
||||
|
||||
type SessionAPI interface {
|
||||
AgentCompositions
|
||||
ProcessView
|
||||
AgentPresetManagement
|
||||
SurfaceActions
|
||||
SurfaceHistory
|
||||
|
||||
@@ -0,0 +1,58 @@
|
||||
package control
|
||||
|
||||
import (
|
||||
"time"
|
||||
|
||||
"reasonix/internal/contract/process"
|
||||
)
|
||||
|
||||
// Processes lists the work this session has running now, across owners. It
|
||||
// reads in-memory state only and costs what is running, not what ran.
|
||||
func (c *Controller) Processes() ([]process.Process, error) {
|
||||
var out []process.Process
|
||||
for _, j := range c.Jobs() {
|
||||
out = append(out, process.Process{
|
||||
ID: process.ID(process.KindJob, j.ID),
|
||||
Kind: process.KindJob,
|
||||
Name: firstNonBlank(j.Description, j.Label),
|
||||
State: process.Running,
|
||||
Started: time.UnixMilli(j.StartedAt),
|
||||
})
|
||||
}
|
||||
parent := c.parentSessionID()
|
||||
if c.subagents == nil || parent == "" {
|
||||
return out, nil
|
||||
}
|
||||
runs, err := c.subagents.LiveRuns(parent, c.WorkspaceRoot())
|
||||
if err != nil {
|
||||
return out, err
|
||||
}
|
||||
for _, r := range runs {
|
||||
class, ok := r.State.ProcessClass()
|
||||
if !ok || class.State.Terminal() {
|
||||
continue
|
||||
}
|
||||
p := process.Process{
|
||||
ID: process.ID(process.KindSubagent, r.Ref),
|
||||
Kind: process.KindSubagent,
|
||||
Name: r.Name,
|
||||
State: class.State,
|
||||
Cause: class.Cause,
|
||||
Started: r.StartedAt,
|
||||
}
|
||||
if r.ParentTurn != "" {
|
||||
p.Parent = "turn:" + r.ParentTurn
|
||||
}
|
||||
out = append(out, p)
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
func firstNonBlank(vals ...string) string {
|
||||
for _, v := range vals {
|
||||
if v != "" {
|
||||
return v
|
||||
}
|
||||
}
|
||||
return ""
|
||||
}
|
||||
@@ -0,0 +1,62 @@
|
||||
package control
|
||||
|
||||
import (
|
||||
"context"
|
||||
"io"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
|
||||
"reasonix/internal/base/testenv"
|
||||
"reasonix/internal/contract/event"
|
||||
"reasonix/internal/contract/process"
|
||||
"reasonix/internal/runtime/delegation"
|
||||
"reasonix/internal/tools/jobs"
|
||||
)
|
||||
|
||||
type liveSubagents struct {
|
||||
SubagentReader
|
||||
runs []delegation.RunInfo
|
||||
}
|
||||
|
||||
func (l liveSubagents) LiveRuns(parent, workspace string) ([]delegation.RunInfo, error) {
|
||||
return l.runs, nil
|
||||
}
|
||||
|
||||
func TestProcessesListsWhatIsRunningAcrossOwners(t *testing.T) {
|
||||
m := jobs.NewManager(event.Discard)
|
||||
t.Cleanup(m.Close)
|
||||
job := m.Start("bash", "sleep 60", func(ctx context.Context, _ io.Writer) (string, error) {
|
||||
<-ctx.Done()
|
||||
return "", ctx.Err()
|
||||
})
|
||||
dir := testenv.TempDir(t)
|
||||
c := New(Options{Sink: event.Discard, SessionDir: dir, SessionPath: filepath.Join(dir, "session.jsonl"), Jobs: m, Subagents: liveSubagents{runs: []delegation.RunInfo{
|
||||
{Ref: "sa_1", Name: "audit", State: delegation.RunRunning, ParentTurn: "turn-3"},
|
||||
{Ref: "sa_2", Name: "late", State: delegation.RunCompleted},
|
||||
}}})
|
||||
|
||||
got, err := c.Processes()
|
||||
if err != nil {
|
||||
t.Fatalf("Processes: %v", err)
|
||||
}
|
||||
byID := map[string]process.Process{}
|
||||
for _, p := range got {
|
||||
byID[p.ID] = p
|
||||
}
|
||||
if len(got) != 2 {
|
||||
t.Fatalf("got %d processes %+v, want the running job and the running sub-agent only", len(got), got)
|
||||
}
|
||||
if p := byID[process.ID(process.KindJob, job.ID)]; p.Kind != process.KindJob || p.State != process.Running || p.Name != "sleep 60" {
|
||||
t.Fatalf("job process = %+v", p)
|
||||
}
|
||||
if p := byID["subagent:sa_1"]; p.State != process.Running || p.Parent != "turn:turn-3" || p.Name != "audit" {
|
||||
t.Fatalf("sub-agent process = %+v", p)
|
||||
}
|
||||
}
|
||||
|
||||
func TestProcessesOfABareControllerIsEmpty(t *testing.T) {
|
||||
got, err := New(Options{Sink: event.Discard}).Processes()
|
||||
if err != nil || len(got) != 0 {
|
||||
t.Fatalf("bare controller: %+v err=%v", got, err)
|
||||
}
|
||||
}
|
||||
Loaded 100 of 114 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user