mirror of
https://gh-proxy.org/https://github.com/klinsmaya/workdsh.git
synced 2026-11-09 18:07:02 +08:00
git-subtree-dir: workdsh-web git-subtree-mainline:d3a7b2143dgit-subtree-split:03b63ea143
18 lines
14 KiB
JavaScript
18 lines
14 KiB
JavaScript
import test from 'node:test'; import assert from 'node:assert/strict'; import { mkdtemp,rm } from 'node:fs/promises'; import { tmpdir } from 'node:os'; import { join } from 'node:path'; import { Context } from '@deepseek-ai/cordis'; import Storage from '@deepseek-ai/dsh-storage'; import * as JsonStorage from '@deepseek-ai/dsh-storage-json'; import * as StorageDomain from '@deepseek-ai/dsh-storage-domain'; import { ProjectManager } from '../dist/services/project-manager.js';
|
|
const actor={principalId:'alice',organizationId:'org',requestId:'test',resolvedBy:'test'};
|
|
async function boot(root){const ctx=new Context();await ctx.plugin(Storage);await ctx.plugin(JsonStorage,{root});await ctx.plugin(StorageDomain,{backend:'json'});await ctx.plugin(ProjectManager);return ctx;}
|
|
test('projects persist, search, isolate revisions and keep library references',async()=>{const root=await mkdtemp(join(tmpdir(),'workdsh-projects-'));let ctx;try{ctx=await boot(root);const first=await ctx.workdshProjects.create(actor,{name:'钣金厂智能运营平台',templateId:'delivery'});const second=await ctx.workdshProjects.create(actor,{name:'独立项目'});assert.equal((await ctx.workdshProjects.list(actor,'钣金')).length,1);await assert.rejects(ctx.workdshProjects.updateConfig(actor,first.project.id,{instruction:'长'.repeat(8001),capabilities:[]},first.config.id),/projects\/instruction-budget-exceeded/);assert.equal((await ctx.workdshProjects.get(actor,first.project.id)).config.id,first.config.id);const revised=await ctx.workdshProjects.updateConfig(actor,first.project.id,{instruction:'只引用已授权资料。',capabilities:[{kind:'skill',id:'organizer',revision:'skill-revision-1',label:'资料整理'}]},first.config.id);const secondRevised=await ctx.workdshProjects.updateConfig(actor,second.project.id,{instruction:'独立配置。',capabilities:[{kind:'skill',id:'organizer',revision:'skill-revision-1',label:'资料整理'}]},second.config.id);await assert.rejects(ctx.workdshProjects.updateConfig(actor,first.project.id,{instruction:'覆盖',capabilities:[]},first.config.id),/projects\/revision-conflict/);const item=await ctx.workdshProjects.addWorkItem(actor,first.project.id,'完成需求核对');await assert.rejects(ctx.workdshProjects.updateWorkItem(actor,first.project.id,{...item,title:'并发覆盖'},'bad'),/projects\/revision-conflict/);const ref=await ctx.workdshProjects.addAsset(actor,first.project.id,{nodeId:'node-1',assetId:'asset-1',revisionId:'revision-1',name:'规范.md',kind:'markdown'});assert.equal((await ctx.workdshProjects.addAsset(actor,first.project.id,{nodeId:'node-1',assetId:'asset-1',revisionId:'revision-1',name:'规范.md',kind:'markdown'})).id,ref.id);const references=[{kind:'work-item',id:item.id,revision:item.revision,label:item.title},{kind:'asset',id:ref.id,revision:ref.revisionId,label:ref.name},{kind:'skill',id:'organizer',revision:'skill-revision-1',label:'资料整理'}];assert.equal((await ctx.workdshProjects.validateInputRefs(actor,first.project.id,references)).length,3);await assert.rejects(ctx.workdshProjects.validateInputRefs(actor,first.project.id,[{...references[0],revision:'stale'}]),/projects\/reference-stale/);const task=await ctx.workdshProjects.linkTask(actor,first.project.id,'session-1','核对资料',item.id,references);assert.equal(task.configRevisionId,revised.id);assert.equal(task.references.length,3);const taskContext=await ctx.workdshProjects.taskContext(actor,'session-1');assert.equal(taskContext?.project.id,first.project.id);assert.equal(taskContext?.config.id,revised.id);assert.equal(taskContext?.task.references.length,3);const secondTask=await ctx.workdshProjects.linkTask(actor,first.project.id,'session-2','复核资料',item.id,references);assert.equal(secondTask.workItemId,item.id);const afterExecutions=await ctx.workdshProjects.get(actor,first.project.id);assert.equal(afterExecutions.tasks.filter(row=>row.workItemId===item.id).length,2);assert.equal(afterExecutions.workItems.find(row=>row.id===item.id)?.status,'todo');assert.equal((await ctx.workdshProjects.get(actor,second.project.id)).assets.length,0);const firstWithoutSkill=await ctx.workdshProjects.updateConfig(actor,first.project.id,{instruction:'只引用已授权资料。',capabilities:[]},revised.id);assert.equal(firstWithoutSkill.capabilities.length,0);assert.equal((await ctx.workdshProjects.get(actor,first.project.id)).tasks[0].configRevisionId,revised.id);const secondAfterRemoval=await ctx.workdshProjects.get(actor,second.project.id);assert.equal(secondAfterRemoval.config.capabilities.length,1);assert.equal(secondAfterRemoval.config.capabilities[0].revision,'skill-revision-1');const archived=await ctx.workdshProjects.archive(actor,second.project.id);assert.equal(archived.status,'archived');assert.equal((await ctx.workdshProjects.list(actor,'')).some(row=>row.id===second.project.id),false);assert.equal((await ctx.workdshProjects.listArchived(actor,'')).some(row=>row.id===second.project.id),true);await assert.rejects(ctx.workdshProjects.get(actor,second.project.id),/projects\/not-found/);const restoredProject=await ctx.workdshProjects.restore(actor,second.project.id);assert.equal(restoredProject.status,'active');assert.ok((await ctx.workdshProjects.get(actor,second.project.id)).activity.some(row=>row.text.includes('恢复了项目')));await ctx.fiber.dispose();ctx=await boot(root);assert.equal((await ctx.workdshProjects.list(actor,'独立项目')).length,1);const restoredEmpty=await ctx.workdshProjects.get(actor,second.project.id);assert.equal(restoredEmpty.project.description,'');const restored=await ctx.workdshProjects.get(actor,first.project.id);assert.equal(restored.assets.length,1);assert.equal(restored.assets[0].assetId,'asset-1');assert.equal(restored.assets[0].revisionId,'revision-1');assert.equal(restored.tasks[0].references.length,3);assert.equal(restored.config.id,firstWithoutSkill.id);assert.deepEqual(new Set(restored.activity.map(row=>row.kind)),new Set(['project','work-item','asset','task']));assert.ok(restored.activity.some(row=>row.text.includes('更新了项目配置')));assert.ok(restored.activity.some(row=>row.text.includes('新增计划')));assert.ok(restored.activity.some(row=>row.text.includes('关联资产')));assert.ok(restored.activity.some(row=>row.text.includes('创建任务')));await ctx.workdshProjects.removeAsset(actor,first.project.id,restored.assets[0].id);assert.equal((await ctx.workdshProjects.get(actor,first.project.id)).assets.length,0);await assert.rejects(ctx.workdshProjects.validateInputRefs(actor,first.project.id,[references[1]]),/projects\/reference-stale/);}finally{if(ctx)await ctx.fiber.dispose();await rm(root,{recursive:true,force:true});}});
|
|
|
|
test('capability revisions only upgrade after an explicit selection',async()=>{const {mergeCapabilitySelection}=await import('../dist/capability-selection.js');const existing=[{kind:'skill',id:'organizer',label:'资料整理',scope:'personal',revision:'revision-old'}],available=[{kind:'skill',id:'organizer',label:'资料整理(新版)',scope:'personal',revision:'revision-new'}],picked=new Set(['organizer']);assert.deepEqual(mergeCapabilitySelection(existing,available,picked,new Set()),existing);assert.deepEqual(mergeCapabilitySelection(existing,available,picked,new Set(['organizer'])),[{kind:'skill',id:'organizer',label:'资料整理(新版)',scope:'personal',revision:'revision-new'}]);assert.deepEqual(mergeCapabilitySelection(existing,available,new Set(),new Set()),[]);});
|
|
|
|
function attributionDeps(overrides={}){const calls={imports:[],assets:[],warns:[],gaps:[]};const base={identity:{resolve:async()=>actor},projects:{taskContext:async()=>({project:{id:'project-1'},config:{id:'config-1'},task:{id:'task-1'}}),addAsset:async(_row,projectId,asset)=>{calls.assets.push({projectId,asset});return asset},noteDeliveryGap:async(_row,_projectId,text)=>{calls.gaps.push(text)}},library:{importAsset:async(_row,input)=>{calls.imports.push(input);return{id:'node-1',name:input.name,asset:{id:'asset-1',kind:'markdown'},revision:{id:'revision-1'}}}},readBytes:async()=>new Uint8Array([1,2,3]),warn:message=>{calls.warns.push(message)}};return{deps:{...base,...overrides},calls};}
|
|
test('deliverable attribution skips sessions without a linked project task',async()=>{const {attributePresentedFiles}=await import('../dist/runtime/deliverable-attribution.js');const {deps,calls}=attributionDeps({projects:{taskContext:async()=>undefined,addAsset:async()=>{throw new Error('unexpected addAsset');},noteDeliveryGap:async()=>{throw new Error('unexpected noteDeliveryGap');}}});await attributePresentedFiles(deps,{sessionId:'session-1',cwd:'/tmp',files:[{path:'/tmp/报告.md'}]});assert.equal(calls.imports.length,0);assert.equal(calls.assets.length,0);});
|
|
test('deliverable attribution imports with task provenance and links exact revisions',async()=>{const {attributePresentedFiles}=await import('../dist/runtime/deliverable-attribution.js');const reads=[];const {deps,calls}=attributionDeps({readBytes:async(path,cwd)=>{reads.push([path,cwd]);return new Uint8Array([1]);}});await attributePresentedFiles(deps,{sessionId:'session-1',cwd:'/tmp',files:[{path:'/tmp/报告.md'}]});assert.deepEqual(reads,[['/tmp/报告.md','/tmp']]);assert.equal(calls.imports.length,1);assert.equal(calls.imports[0].name,'报告.md');assert.equal(calls.imports[0].source,'task');assert.equal(calls.imports[0].sourceTaskId,'session-1');assert.match(calls.imports[0].operationId,/^project-deliverable-session-1-[0-9a-f]{64}-1$/);assert.deepEqual(calls.assets,[{projectId:'project-1',asset:{nodeId:'node-1',assetId:'asset-1',revisionId:'revision-1',name:'报告.md',kind:'markdown'}}]);assert.equal(calls.warns.length,0);});
|
|
test('deliverable attribution retries name conflicts with numbered suffixes',async()=>{const {attributePresentedFiles}=await import('../dist/runtime/deliverable-attribution.js');const names=[];const {deps}=attributionDeps({library:{importAsset:async(_row,input)=>{names.push(input.name);if(names.length<3)throw new Error('library/name-conflict');return{id:'node-'+names.length,name:input.name,asset:{id:'asset-1',kind:'markdown'},revision:{id:'revision-1'}};}}});await attributePresentedFiles(deps,{sessionId:'s',cwd:'/tmp',files:[{path:'/tmp/报告.md'}]});assert.deepEqual(names,['报告.md','报告 (2).md','报告 (3).md']);});
|
|
test('deliverable attribution contains per-file failures and keeps importing the rest',async()=>{const {attributePresentedFiles}=await import('../dist/runtime/deliverable-attribution.js');const {deps,calls}=attributionDeps({library:{importAsset:async(_row,input)=>{calls.imports.push(input);if(input.name==='b.md')throw new Error('library/unsupported-format');return{id:'node-'+input.name,name:input.name,asset:{id:'asset-1',kind:'markdown'},revision:{id:'revision-1'}};}}});await attributePresentedFiles(deps,{sessionId:'s',cwd:'/tmp',files:[{path:'/tmp/a.md'},{path:'/tmp/b.md'},{path:'/tmp/c.md'}]});assert.deepEqual(calls.imports.map(row=>row.name),['a.md','b.md','c.md']);assert.deepEqual(calls.assets.map(row=>row.asset.name),['a.md','c.md']);assert.equal(calls.warns.length,1);assert.ok(calls.warns[0].includes('library/unsupported-format'));assert.equal(calls.gaps.length,1);assert.ok(calls.gaps[0].includes('library/unsupported-format'));});
|
|
test('deliverable attribution deduplicates repeated paths inside one event',async()=>{const {attributePresentedFiles}=await import('../dist/runtime/deliverable-attribution.js');const {deps,calls}=attributionDeps();await attributePresentedFiles(deps,{sessionId:'s',cwd:'/tmp',files:[{path:'/tmp/a.md'},{path:'/tmp/a.md'}]});assert.equal(calls.imports.length,1);});
|
|
test('deliverable attribution keeps stable operation ids across repeated deliveries',async()=>{const {attributePresentedFiles}=await import('../dist/runtime/deliverable-attribution.js');const {deps,calls}=attributionDeps();const input={sessionId:'s',cwd:'/tmp',files:[{path:'/tmp/a.md'},{path:'/tmp/b.md'}]};await attributePresentedFiles(deps,input);await attributePresentedFiles(deps,input);assert.equal(calls.imports.length,4);const [a1,b1,a2,b2]=calls.imports.map(row=>row.operationId);assert.equal(a1,a2);assert.equal(b1,b2);assert.notEqual(a1,b1);assert.match(a1,/^project-deliverable-s-[0-9a-f]{64}-1$/);});
|
|
test('deliverable attribution surfaces an exhausted name-conflict retry as a project gap',async()=>{const {attributePresentedFiles}=await import('../dist/runtime/deliverable-attribution.js');const {deps,calls}=attributionDeps({library:{importAsset:async(_row,input)=>{calls.imports.push(input);throw new Error('library/name-conflict');}}});await attributePresentedFiles(deps,{sessionId:'s',cwd:'/tmp',files:[{path:'/tmp/报告.md'}]});assert.equal(calls.imports.length,5);assert.deepEqual(calls.imports.map(row=>row.name),['报告.md','报告 (2).md','报告 (3).md','报告 (4).md','报告 (5).md']);assert.deepEqual(calls.imports.map(row=>row.operationId.slice(-2)),['-1','-2','-3','-4','-5']);assert.equal(calls.assets.length,0);assert.equal(calls.gaps.length,1);assert.ok(calls.gaps[0].includes('library/name-conflict'));});
|
|
|
|
test('task capability overrides remain isolated from defaults and other tasks',async()=>{const root=await mkdtemp(join(tmpdir(),'workdsh-task-selection-'));let ctx;try{ctx=await boot(root);const m=ctx.workdshProjects,p=await m.create(actor,{name:'选择隔离'}),cap={kind:'connector',id:'erp',label:'ERP',revision:'r1'};await m.updateConfig(actor,p.project.id,{instruction:'固定指令',capabilities:[cap]},p.config.id);await m.linkTask(actor,p.project.id,'empty','无连接器',undefined,[],undefined,[]);await m.linkTask(actor,p.project.id,'default','继承默认');assert.deepEqual((await m.taskContext(actor,'empty')).config.capabilities,[]);assert.deepEqual((await m.taskContext(actor,'default')).config.capabilities,[cap]);const snapshot=await m.get(actor,p.project.id);assert.deepEqual(snapshot.config.capabilities,[cap]);await assert.rejects(m.linkTask(actor,p.project.id,'bad','越界选择',undefined,[],undefined,[{...cap,id:'foreign'}]),/capability-not-bound/);await m.updateConfig(actor,p.project.id,{instruction:'新指令',capabilities:[]},snapshot.config.id);assert.equal((await m.taskContext(actor,'default')).config.instruction,'固定指令');assert.deepEqual((await m.taskContext(actor,'default')).config.capabilities,[cap]);assert.equal(await m.taskContext({...actor,principalId:'bob'},'default'),undefined)}finally{await rm(root,{recursive:true,force:true})}});
|