feat: complete WorkBuddy parity and Windows release workflow

This commit is contained in:
xiaohan.yuan committed 2026-10-18 12:45:21 +08:00
1 parent 143516b3a8
commit d5512df549
434 files changed
+54977 -1854

No files matched your search

+370
View File
@@ -0,0 +1,370 @@
import assert from 'node:assert/strict'
import { promises as fs } from 'node:fs'
import { tmpdir } from 'node:os'
import { dirname, join } from 'node:path'
import {
artifactPathKey,
classifyArtifact,
extractArtifactCandidates,
indexArtifactSources,
type ArtifactSource
} from '../src/shared/artifact-index'
import type { LibraryArtifactEntry } from '../src/shared/library'
import type { ChatMessage, SessionMeta, ToolCallRecord } from '../src/shared/types'
import {
filterLibraryEntries,
formatLibraryBytes,
groupLibraryEntries,
listLibraryWorkspaces,
sortLibraryEntries
} from '../src/renderer/src/library/catalog'
import {
FileArtifactIndexRepository,
probeArtifactPath
} from '../src/main/library/artifact-repository'
import { ArtifactIndexService } from '../src/main/library/artifact-service'
import { LibraryService } from '../src/main/library/service'
import { JsonLibraryUserStateRepository } from '../src/main/library/user-state'
import { collectWorkbenchArtifacts } from '../src/renderer/src/workbuddy/workbench/workbench-state'
function tool(
id: string,
name: string,
args: Record<string, unknown>,
status: ToolCallRecord['status'] = 'ok'
): ToolCallRecord {
return { id, name, args, argsRaw: JSON.stringify(args), status }
}
function message(
id: string,
createdAt: number,
input: Partial<Pick<ChatMessage, 'role' | 'parts' | 'tools' | 'attachments' | 'resources'>> = {}
): ChatMessage {
return {
id,
role: 'assistant',
content: '',
createdAt,
...input
}
}
async function writeFixture(path: string, content: string): Promise<void> {
await fs.mkdir(dirname(path), { recursive: true })
await fs.writeFile(path, content, 'utf8')
}
async function main(): Promise<void> {
const root = await fs.mkdtemp(join(tmpdir(), 'ycode-library-regression-'))
let outsideRoot = ''
try {
outsideRoot = await fs.mkdtemp(join(tmpdir(), 'ycode-library-outside-'))
const reportPath = join(root, 'report.md')
const tablePath = join(root, 'charts', 'table.csv')
const resultPath = join(root, 'notes', 'result.md')
const nativeWritePath = join(root, 'native', 'native-write.md')
const missingPath = join(root, 'missing.md')
await writeFixture(reportPath, '# Updated report\n')
await writeFixture(tablePath, 'name,value\na,1\n')
await writeFixture(resultPath, 'written result')
await writeFixture(nativeWritePath, 'native OMP write')
const messagesA: ChatMessage[] = [
message('user-presentation', 500, {
role: 'user',
parts: [{
type: 'artifact',
artifact: { id: 'user-artifact', name: '用户展示块', path: 'user-visible.md' }
}]
}),
message('m1', 1_000, {
parts: [{
type: 'artifact',
artifact: { id: 'report-artifact', name: '旧报告', path: 'report.md' }
}]
}),
message('m2', 2_000, {
tools: [
tool('present-ok', 'present_files', {
files: [{ path: 'charts/table.csv' }, { path: 'missing.md' }]
}),
tool('write-ok', 'write_to_file', {
file_path: 'notes/result.md',
content: 'written result'
}),
tool('read-ok', 'read_file', { path: 'notes/result.md' }),
tool('write-failed', 'write_to_file', {
file_path: 'failed.md',
content: 'must not index'
}, 'error')
],
attachments: [{
id: 'attachment',
name: 'attachment.md',
path: 'attachment.md',
mimeType: 'text/markdown',
size: 12
}],
resources: ['attachment.md', 'notes/result.md']
}),
message('m3', 3_000, {
tools: [
tool('write-newer', 'write_to_file', {
file_path: 'report.md',
content: '# Updated report\n'
})
]
})
]
const messagesB: ChatMessage[] = [
message('m4', 2_500, {
parts: [{
type: 'artifact',
artifact: { id: 'report-b', name: '任务 B 报告', path: 'report.md' }
}]
})
]
const sessionA: ArtifactSource = {
id: 'session-a',
title: '任务 A',
createdAt: 100,
updatedAt: 3_000,
workspaceId: 'workspace-a',
projectId: 'project-a',
cwd: root,
messages: messagesA
}
const sessionB: ArtifactSource = {
id: 'session-b',
title: '任务 B',
createdAt: 200,
updatedAt: 2_500,
workspaceId: 'workspace-a',
cwd: root,
messages: messagesB
}
const candidatesA = extractArtifactCandidates(messagesA, {
sessionId: sessionA.id,
cwd: root,
workspaceId: sessionA.workspaceId,
projectId: sessionA.projectId,
sourceSessionTitle: sessionA.title
})
assert.equal(candidatesA.length, 4, '只有显式 artifact、成功 present_files 和成功 write 应被提取')
const candidateReport = candidatesA.find((item) => artifactPathKey(item.path) === artifactPathKey(reportPath))
assert.equal(candidateReport?.sourceKind, 'write', '同任务同路径采用最新来源')
assert.equal(candidateReport?.messageId, 'm3')
assert.equal(candidateReport?.workspaceId, 'workspace-a')
assert.equal(candidateReport?.projectId, 'project-a')
assert.equal(candidatesA.some((item) => item.path.endsWith('failed.md')), false)
assert.equal(candidatesA.some((item) => item.path.endsWith('attachment.md')), false)
assert.equal(candidatesA.some((item) => item.path.endsWith('user-visible.md')), false)
const workbench = collectWorkbenchArtifacts(messagesA, root)
assert.equal(workbench.artifacts.length, 2, '工作台非改动产物应复用同一白名单')
assert.equal(workbench.changes.length, 2, '工作台写编辑产物应复用同一白名单')
const entries = await indexArtifactSources([sessionA, sessionB], probeArtifactPath)
assert.equal(entries.length, 5, '任务 A 四个产物与任务 B 同路径产物必须分开保留')
const reportEntries = entries.filter((entry) => artifactPathKey(entry.path) === artifactPathKey(reportPath))
assert.equal(reportEntries.length, 2, '跨任务同路径不能合并')
assert.deepEqual(new Set(reportEntries.map((entry) => entry.sessionId)), new Set(['session-a', 'session-b']))
assert.notEqual(reportEntries[0].id, reportEntries[1].id)
const reportA = entries.find((entry) => entry.sessionId === 'session-a'
&& artifactPathKey(entry.path) === artifactPathKey(reportPath))
assert.equal(reportA?.availability, 'available')
assert.equal(reportA?.size, Buffer.byteLength('# Updated report\n'))
assert.equal(reportA?.sourceSessionTitle, '任务 A')
assert.equal(reportA?.workspaceRoot, root)
const missing = entries.find((entry) => artifactPathKey(entry.path) === artifactPathKey(missingPath))
assert.equal(missing?.availability, 'unavailable', '无法访问文件必须保留索引并标记不可用')
assert.equal(classifyArtifact('table.csv'), 'spreadsheet')
assert.equal(classifyArtifact('README.md'), 'document')
assert.equal(classifyArtifact('app.ts'), 'code')
const nativeWriteMessages = [message('native-write-message', 3_500, {
tools: [tool('native-write', 'write', {
path: 'native/native-write.md',
content: 'native OMP write'
})]
})]
const nativeCandidates = extractArtifactCandidates(nativeWriteMessages, {
sessionId: 'native-write-session',
cwd: root,
sourceSessionTitle: '原生写入任务'
})
assert.equal(nativeCandidates.length, 1, '小写 OMP write 应经 Write 别名规范化后提取')
assert.equal(nativeCandidates[0].sourceKind, 'write')
assert.equal(nativeCandidates[0].sourceToolCallId, 'native-write')
assert.equal(artifactPathKey(nativeCandidates[0].path), artifactPathKey(nativeWritePath))
const nativeEntries = await indexArtifactSources([{
id: 'native-write-session',
title: '原生写入任务',
createdAt: 350,
updatedAt: 3_500,
cwd: root,
messages: nativeWriteMessages
}], probeArtifactPath)
assert.equal(nativeEntries.length, 1)
assert.equal(nativeEntries[0].availability, 'available')
const directOutsidePath = join(outsideRoot, 'direct.md')
const linkedOutsidePath = join(outsideRoot, 'linked.md')
const insideDirectory = join(root, 'safe-inside')
const insidePath = join(insideDirectory, 'inside.md')
await writeFixture(directOutsidePath, 'outside')
await writeFixture(linkedOutsidePath, 'must stay unavailable')
await writeFixture(insidePath, 'safe')
const escapeLink = join(root, 'escape-link')
const safeLink = join(root, 'safe-link')
await fs.symlink(outsideRoot, escapeLink, process.platform === 'win32' ? 'junction' : 'dir')
await fs.symlink(insideDirectory, safeLink, process.platform === 'win32' ? 'junction' : 'dir')
const unreferencedDirectory = join(root, 'large-unreferenced-tree')
await fs.mkdir(unreferencedDirectory, { recursive: true })
await Promise.all(Array.from({ length: 64 }, (_, index) =>
fs.writeFile(join(unreferencedDirectory, `source-${index}.ts`), `export const value = ${index}\n`, 'utf8')
))
const securitySession: ArtifactSource = {
id: 'security-session',
title: '边界检查任务',
createdAt: 400,
updatedAt: 4_000,
workspaceId: 'workspace-a',
cwd: root,
messages: [message('security-message', 4_000, {
tools: [tool('security-present', 'present_files', {
files: [
{ path: directOutsidePath },
{ path: join(escapeLink, 'linked.md') },
{ path: join(safeLink, 'inside.md') }
]
})]
})]
}
let securityProbeCalls = 0
const securityEntries = await indexArtifactSources([securitySession], async (artifactPath, context) => {
securityProbeCalls += 1
return probeArtifactPath(artifactPath, context)
})
assert.equal(securityEntries.length, 3, '大目录中未引用文件不能被扫描为产物')
assert.equal(securityProbeCalls, 3, '每个索引条目只探测自身路径')
assert.equal(
securityEntries.find((entry) => entry.path === directOutsidePath)?.availability,
'unavailable',
'来源 cwd 之外的显式路径必须不可用'
)
assert.equal(
securityEntries.find((entry) => entry.path === join(escapeLink, 'linked.md'))?.availability,
'unavailable',
'指向 cwd 之外的目录链接必须不可用'
)
assert.equal(
securityEntries.find((entry) => entry.path === join(safeLink, 'inside.md'))?.availability,
'available',
'仍位于来源 cwd realpath 内的链接应可用'
)
const userStatePath = join(root, 'state', 'library-state.json')
const userState = new JsonLibraryUserStateRepository(userStatePath)
const repository = new FileArtifactIndexRepository(probeArtifactPath)
const artifactIndex = new ArtifactIndexService({
repository,
listSourceSessions: async () => [sessionA, sessionB] satisfies SessionMeta[],
getMessages: async (sessionId) => sessionId === sessionA.id ? messagesA : messagesB
})
const library = new LibraryService({ artifactIndex, userState, now: () => 9_000 })
const snapshot = await library.getSnapshot()
assert.equal(snapshot.generatedAt, 9_000)
const snapshotReportA = snapshot.artifacts.find((entry) => entry.id === reportA?.id)
assert.ok(snapshotReportA)
await library.setFavorite(snapshotReportA.id, true)
await library.recordRecent(snapshotReportA.id, 9_100)
await assert.rejects(() => library.setFavorite('missing-artifact', true), /产物不存在/)
const updated = await library.getSnapshot()
const updatedReportA = updated.artifacts.find((entry) => entry.id === snapshotReportA.id)
assert.equal(updatedReportA?.favorite, true)
assert.equal(updatedReportA?.recentAt, 9_100)
const reloadedState = new JsonLibraryUserStateRepository(userStatePath)
const persisted = await reloadedState.get()
assert.deepEqual(persisted.favoriteArtifactIds, [snapshotReportA.id])
assert.deepEqual(persisted.recent, [{ artifactId: snapshotReportA.id, openedAt: 9_100 }])
assert.equal(
(await fs.readdir(dirname(userStatePath))).some((name) => name.endsWith('.tmp')),
false,
'原子写入不得遗留临时文件'
)
const catalogEntries: LibraryArtifactEntry[] = updated.artifacts.map((entry) => ({
...entry,
favorite: entry.id === snapshotReportA.id,
...(entry.id === snapshotReportA.id ? { recentAt: 9_100 } : {})
}))
const allFilters = { query: '', kind: 'all' as const, workspaceKey: 'all', favoritesOnly: false }
assert.equal(filterLibraryEntries(catalogEntries, 'recent', allFilters).length, 1)
assert.equal(filterLibraryEntries(catalogEntries, 'local', {
...allFilters,
query: '任务 B'
}).length, 1)
assert.equal(filterLibraryEntries(catalogEntries, 'local', {
...allFilters,
kind: 'spreadsheet'
}).length, 1)
assert.equal(filterLibraryEntries(catalogEntries, 'local', {
...allFilters,
favoritesOnly: true
}).length, 1)
const workspaceOptions = listLibraryWorkspaces(catalogEntries)
assert.equal(workspaceOptions.length, 1)
assert.equal(filterLibraryEntries(catalogEntries, 'local', {
...allFilters,
workspaceKey: workspaceOptions[0].key
}).length, 5)
const groups = groupLibraryEntries(catalogEntries)
assert.equal(groups.length, 1)
assert.equal(groups[0].tasks.length, 2, '同一空间内必须按任务分组')
assert.equal(groups[0].label, root.split(/[\\/]/).pop())
const sortedByName = sortLibraryEntries(catalogEntries, { key: 'name', direction: 'asc' }, 'local')
assert.ok(sortedByName[0].name.localeCompare(sortedByName.at(-1)!.name, 'zh-CN') <= 0)
assert.equal(formatLibraryBytes(1536), '1.5 KB')
assert.equal(formatLibraryBytes(1024), '1 KB')
assert.equal(formatLibraryBytes(undefined), '—')
const corruptPath = join(root, 'state', 'corrupt.json')
await fs.writeFile(corruptPath, '{not-json', 'utf8')
const corrupt = new JsonLibraryUserStateRepository(corruptPath)
await assert.rejects(() => corrupt.get(), /资料库收藏数据损坏|JSON|Unexpected|property name/i)
console.log('library-regression: ok')
console.log(JSON.stringify({
indexedEntries: entries.length,
crossTaskSamePathEntries: reportEntries.length,
unavailableEntries: entries.filter((entry) => entry.availability === 'unavailable').length,
workspaceGroups: groups.length,
taskGroups: groups[0].tasks.length,
persistedRecent: persisted.recent.length,
persistedFavorites: persisted.favoriteArtifactIds.length,
nativeWriteEntries: nativeEntries.length,
nativeWriteAvailability: nativeEntries[0]?.availability,
securityEntries: securityEntries.length,
securityProbeCalls,
crossRootUnavailable: securityEntries.find((entry) => entry.path === directOutsidePath)?.availability,
symlinkEscapeUnavailable: securityEntries.find((entry) => entry.path === join(escapeLink, 'linked.md'))?.availability,
safeSymlinkAvailable: securityEntries.find((entry) => entry.path === join(safeLink, 'inside.md'))?.availability
}, null, 2))
} finally {
await Promise.all([
fs.rm(root, { recursive: true, force: true }),
outsideRoot ? fs.rm(outsideRoot, { recursive: true, force: true }) : Promise.resolve()
])
}
}
main().catch((error) => {
console.error(error)
process.exitCode = 1
})