fix: complete runtime and workspace workflows

Restore Ask workspace reads and output paging without granting write access,
and let programming Subagents reuse their parent's browser page. Allocate
browser resources only when used, keep Agent pages aligned with workbar tabs,
recover released bindings, and remove unused automatic screenshot work.

Preserve inherited Runtime selections during remote usage persistence and
manual compression. Keep viable channel model fallbacks, display the actual
scheduled-task mode and Harness model source, and avoid rediscovery delays
when calling healthy MCP tools. Fix directory/history refresh, case-only
renames, rename-aware commit diffs, and FastGPT probe success reporting.

Add regression coverage and align feature documentation. The Agent-side
connection timeout fix requires deploying an updated Agent; existing Ask
read-only and Execute authorization boundaries remain unchanged.

Final validation: 3839 tests passed, 66 skipped; typecheck, lint, development
build, and diff checks passed. Isolated Electron UI/MCP workflows passed with
a loopback model fixture. Current-source Linux x64 Host checks passed with
three successful real model requests. Chromium loadURL rejection was not
reproduced in the UI; released-binding recovery has automated coverage.

Release note: 修复 Ask 读取项目文件、浏览器页面显示与恢复、文件管理及模型配置跟随中的不一致,并减少无用截图和 MCP 等待。定时任务显示实际工作模式,远程会话可正常保存继承 Runtime 的用量;远端连接超时修复需更新 Agent 后生效。
This commit is contained in:
mesalogo
2026-09-10 12:48:30 +08:00
parent 020a9ad0dc
commit c037d7fb20
62 changed files with 2637 additions and 521 deletions
@@ -1,5 +1,5 @@
import { execFileSync } from 'node:child_process'
import { mkdtemp, readFile, rm, writeFile } from 'node:fs/promises'
import { link, lstat, mkdir, mkdtemp, readdir, readFile, rename, rm, writeFile } from 'node:fs/promises'
import { tmpdir } from 'node:os'
import { join } from 'node:path'
import { afterEach, expect, it } from 'vitest'
@@ -14,7 +14,7 @@ async function repository(): Promise<string> {
return root
}
function git(root: string, ...args: string[]): string {
return execFileSync('git', ['-C', root, '-c', 'user.name=Workspace Test', '-c', 'user.email=workspace@example.invalid', ...args], { encoding: 'utf8', windowsHide: true }).trim()
return execFileSync('git', ['-C', root, ...args], { encoding: 'utf8', windowsHide: true }).trim()
}
it('creates, moves, inspects and deletes without overwriting an existing file', async () => {
const root = await repository()
@@ -35,7 +35,7 @@ it('reads unborn history, initial commit files and literal paths, and switches w
git(root, 'add', '.')
git(root, 'commit', '-m', 'Initial subject')
const oid = git(root, 'rev-parse', 'HEAD')
await expect(manageWorkspace(root, { kind: 'history', offset: 0 })).resolves.toMatchObject({ head: oid, commits: [{ subject: 'Initial subject', author: 'Workspace Test' }] })
await expect(manageWorkspace(root, { kind: 'history', offset: 0 })).resolves.toMatchObject({ head: oid, commits: [{ subject: 'Initial subject', author: git(root, 'log', '-1', '--format=%an') }] })
await expect(manageWorkspace(root, { kind: 'commitFiles', oid })).resolves.toMatchObject({ files: [{ path: '[test].txt', status: 'A' }] })
await expect(manageWorkspace(root, { kind: 'commitDiff', oid, path: '[test].txt' })).resolves.toMatchObject({ patch: expect.stringContaining('+initial') })
await writeFile(join(root, '[test].txt'), 'dirty\n')
@@ -65,3 +65,88 @@ it('reports switch conflicts without discarding dirty content and pages stable h
const second = await manageWorkspace(root, { kind: 'history', offset: 50, head: first.head })
expect(second).toMatchObject({ hasMore: false, commits: [{ subject: 'page 0' }, { subject: 'initial' }] })
}, 30_000)
it.each([false, true])('preserves rename metadata and actual edits in a literal commit diff (edited: %s)', async (edited) => {
const root = await repository()
const previousPath = '[old];$(echo).txt'
const path = '[new];$(echo).txt'
const original = Array.from({ length: 20 }, (_, index) => `original line ${index}\n`).join('')
await writeFile(join(root, previousPath), original)
await writeFile(join(root, 'unrelated.txt'), 'unrelated before\n')
git(root, 'add', '.'); git(root, 'commit', '-m', 'before rename')
await rename(join(root, previousPath), join(root, path))
if (edited) await writeFile(join(root, path), original.replace('original line 10\n', 'edited line 10\n'))
await writeFile(join(root, 'unrelated.txt'), 'unrelated after\n')
git(root, 'add', '.'); git(root, 'commit', '-m', 'rename')
const oid = git(root, 'rev-parse', 'HEAD')
const files = await manageWorkspace(root, { kind: 'commitFiles', oid })
expect(files).toMatchObject({ files: expect.arrayContaining([{ path, previousPath, status: edited ? expect.stringMatching(/^R\d+$/) : 'R100' }]) })
const result = await manageWorkspace(root, { kind: 'commitDiff', oid, path })
expect(result.kind).toBe('commitDiff')
if (result.kind !== 'commitDiff') return
expect(result.truncated).toBe(false)
expect(result.patch).toContain(`rename from ${previousPath}`)
expect(result.patch).toContain(`rename to ${path}`)
expect(result.patch).not.toContain('new file mode')
expect(result.patch).not.toContain('unrelated')
if (edited) {
expect(result.patch).toContain('-original line 10')
expect(result.patch).toContain('+edited line 10')
expect(result.patch).not.toContain('+original line 0')
} else {
expect(result.patch).toContain('similarity index 100%')
expect(result.patch).not.toContain('@@')
}
})
it.each(['file', 'directory'] as const)('renames a %s by case only on the current filesystem', async (type) => {
const root = await repository()
const source = join(root, 'MixedCase')
if (type === 'directory') {
await mkdir(source)
await writeFile(join(source, 'keep.txt'), 'keep')
} else await writeFile(source, 'keep')
const alias = await lstat(join(root, 'mixedcase')).catch((error: unknown) => {
if ((error as NodeJS.ErrnoException).code !== 'ENOENT') throw error
return undefined
})
if (alias) expect(alias.ino).toBe((await lstat(source)).ino)
await expect(manageWorkspace(root, { kind: 'move', path: 'MixedCase', destination: 'mixedcase' })).resolves.toEqual({ kind: 'done' })
const names = await readdir(root)
expect(names).toContain('mixedcase')
expect(names).not.toContain('MixedCase')
expect(await readFile(join(root, 'mixedcase', ...(type === 'directory' ? ['keep.txt'] : [])), 'utf8')).toBe('keep')
})
it.each(['file', 'hardlink', 'directory'] as const)('rejects moving over a separate existing %s without changing either entry', async (type) => {
const root = await repository()
if (type === 'directory') {
await mkdir(join(root, 'source'))
await mkdir(join(root, 'destination'))
await writeFile(join(root, 'source/keep.txt'), 'source')
await writeFile(join(root, 'destination/keep.txt'), 'destination')
} else {
await writeFile(join(root, 'source'), 'source')
if (type === 'hardlink') await link(join(root, 'source'), join(root, 'destination'))
else await writeFile(join(root, 'destination'), 'destination')
}
await expect(manageWorkspace(root, { kind: 'move', path: 'source', destination: 'destination' })).rejects.toThrow('Destination already exists')
expect(await readFile(join(root, type === 'directory' ? 'source/keep.txt' : 'source'), 'utf8')).toBe('source')
expect(await readFile(join(root, type === 'directory' ? 'destination/keep.txt' : 'destination'), 'utf8')).toBe(type === 'hardlink' ? 'source' : 'destination')
})
it.for([false, true])('rejects a distinct case-differing destination on case-sensitive filesystems (hardlink: %s)', async (hardlink, context) => {
const root = await repository()
await writeFile(join(root, 'Case.txt'), 'source')
const alias = await lstat(join(root, 'case.txt')).catch((error: unknown) => {
if ((error as NodeJS.ErrnoException).code !== 'ENOENT') throw error
return undefined
})
if (alias) return context.skip()
if (hardlink) await link(join(root, 'Case.txt'), join(root, 'case.txt'))
else await writeFile(join(root, 'case.txt'), 'destination')
await expect(manageWorkspace(root, { kind: 'move', path: 'Case.txt', destination: 'case.txt' })).rejects.toThrow('Destination already exists')
expect(await readdir(root)).toEqual(expect.arrayContaining(['Case.txt', 'case.txt']))
expect(await readFile(join(root, 'Case.txt'), 'utf8')).toBe('source')
expect(await readFile(join(root, 'case.txt'), 'utf8')).toBe(hardlink ? 'source' : 'destination')
})
+35 -20
View File
@@ -1,6 +1,6 @@
import { execFile } from 'node:child_process'
import { lstat, mkdir, open, realpath, rename, rm } from 'node:fs/promises'
import { dirname, resolve } from 'node:path'
import { lstat, mkdir, open, readdir, realpath, rename, rm } from 'node:fs/promises'
import { basename, dirname, resolve } from 'node:path'
import { workspaceManagementActionSchema, type WorkspaceManagementAction, type WorkspaceManagementResult } from '../../shared/workspace-management-contracts'
import { getCanonicalWorkspace, isPathInside } from '../workspace-file-access'
@@ -18,6 +18,18 @@ export async function manageWorkspace(rootPath: string, input: WorkspaceManageme
else resolveOutput(stdout)
})
})
const commitFiles = async (oid: string): Promise<Extract<WorkspaceManagementResult, { kind: 'commitFiles' }>> => {
const output = await git(['diff-tree', '--root', '--no-commit-id', '--name-status', '-r', '-m', '--first-parent', '-M', '-z', oid])
const fields = output.split('\0')
const files: Extract<WorkspaceManagementResult, { kind: 'commitFiles' }>['files'] = []
for (let index = 0; index + 1 < fields.length;) {
const status = fields[index++]!
const first = fields[index++]!
const renamed = /^[RC]/.test(status)
files.push({ status, path: renamed ? fields[index++]! : first, ...(renamed ? { previousPath: first } : {}) })
}
return { kind: 'commitFiles', files }
}
if (['createFile', 'createDirectory', 'move', 'delete', 'properties'].includes(action.kind) && 'path' in action) {
const target = resolve(root, action.path)
const parent = await realpath(dirname(target))
@@ -36,13 +48,24 @@ export async function manageWorkspace(rootPath: string, input: WorkspaceManageme
const destination = resolve(root, action.destination)
const destinationParent = await realpath(dirname(destination))
if (!isPathInside(root, destinationParent) || !isPathInside(root, destination) || destination === root) throw new Error('Destination is outside the workspace')
try { await lstat(destination) } catch (error) {
const destinationMetadata = await lstat(destination).catch((error: unknown) => {
if ((error as NodeJS.ErrnoException).code !== 'ENOENT') throw error
signal?.throwIfAborted()
await rename(target, destination)
return { kind: 'done' }
return undefined
})
if (destinationMetadata) {
const sourceName = basename(target)
const destinationName = basename(destination)
// Equal inodes alone also match separate hard links. Permit only a
// differently cased alias of the same entry, not an existing name.
const caseAlias = parent === destinationParent && sourceName !== destinationName &&
sourceName.toLowerCase() === destinationName.toLowerCase() &&
metadata.dev === destinationMetadata.dev && metadata.ino === destinationMetadata.ino
const names = caseAlias ? await readdir(parent) : []
if (!caseAlias || !names.includes(sourceName) || names.includes(destinationName)) throw new Error('Destination already exists')
}
throw new Error('Destination already exists')
signal?.throwIfAborted()
await rename(target, destination)
return { kind: 'done' }
}
}
return { kind: 'done' }
@@ -86,20 +109,12 @@ export async function manageWorkspace(rootPath: string, input: WorkspaceManageme
for (let index = 0; index + 4 < fields.length; index += 5) commits.push({ oid: fields[index]!.trim(), subject: fields[index + 1]!, author: fields[index + 2]!, time: fields[index + 3]!, refs: fields[index + 4]! })
return { kind: 'history', head, commits: commits.slice(0, 50), hasMore: commits.length > 50 }
}
case 'commitFiles': {
const output = await git(['diff-tree', '--root', '--no-commit-id', '--name-status', '-r', '-m', '--first-parent', '-M', '-z', action.oid])
const fields = output.split('\0')
const files: Extract<WorkspaceManagementResult, { kind: 'commitFiles' }>['files'] = []
for (let index = 0; index + 1 < fields.length;) {
const status = fields[index++]!
const first = fields[index++]!
const renamed = /^[RC]/.test(status)
files.push({ status, path: renamed ? fields[index++]! : first, ...(renamed ? { previousPath: first } : {}) })
}
return { kind: 'commitFiles', files }
}
case 'commitFiles':
return commitFiles(action.oid)
case 'commitDiff': {
const patch = await git(['show', '--format=', '--first-parent', '--diff-merges=first-parent', '--no-ext-diff', '--no-textconv', action.oid, '--', action.path])
const file = (await commitFiles(action.oid)).files.find((file) => file.path === action.path)
const paths = file?.previousPath ? [file.previousPath, file.path] : [action.path]
const patch = await git(['show', '--format=', '--first-parent', '--diff-merges=first-parent', '-M', '--no-ext-diff', '--no-textconv', action.oid, '--', ...paths])
return { kind: 'commitDiff', patch: patch.slice(0, 256 * 1024), truncated: patch.length > 256 * 1024 }
}
default: throw new Error('Unsupported workspace action')