feat: complete WorkBuddy parity and Windows release workflow
This commit is contained in:
1 parent
143516b3a8
commit
d5512df549
434 files changed
+54977
-1854
No files matched your search
@@ -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
|
||||
})
|
||||
Reference in new issue
Block a user