@yanglaofish/fishtools 0.1.4 → 0.2.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +46 -18
- package/manual/LICENSE +21 -0
- package/manual/README.md +173 -0
- package/manual/THIRD_PARTY_NOTICES.md +32 -0
- package/manual/docs/examples/action-plugin/LICENSE +21 -0
- package/manual/docs/examples/action-plugin/README.md +38 -0
- package/manual/docs/examples/action-plugin/index.js +18 -0
- package/manual/docs/examples/action-plugin/input.json +1 -0
- package/manual/docs/examples/action-plugin/package.json +9 -0
- package/manual/docs/plans/2026-10-10-workbench-ui.md +27 -0
- package/{docs → manual/docs}/plugin-development.md +151 -19
- package/{docs → manual/docs}/releases.md +13 -1
- package/manual/docs/roadmap.md +85 -0
- package/manual/docs/superpowers/plans/2026-10-09-workflow-mvp.md +115 -0
- package/manual/docs/superpowers/plans/2026-10-10-cordis-services.md +34 -0
- package/manual/docs/superpowers/plans/2026-10-10-fst-terminal.md +33 -0
- package/manual/docs/superpowers/plans/2026-10-10-portable-plugin-contract.md +35 -0
- package/manual/docs/superpowers/plans/2026-10-11-control-observation.md +77 -0
- package/manual/docs/superpowers/specs/2026-10-09-workflow-cli-design.md +377 -0
- package/manual/docs/superpowers/specs/2026-10-10-cordis-services-design.md +88 -0
- package/manual/docs/superpowers/specs/2026-10-10-portable-plugin-contract.md +31 -0
- package/manual/docs/superpowers/specs/2026-10-10-run-logs.md +28 -0
- package/manual/docs/superpowers/specs/2026-10-11-control-observation.md +37 -0
- package/{docs → manual/docs}/verification.md +14 -1
- package/manual/examples/service-plugins/README.md +20 -0
- package/manual/examples/service-plugins/consumer/LICENSE +21 -0
- package/manual/examples/service-plugins/consumer/index.js +14 -0
- package/manual/examples/service-plugins/consumer/package.json +1 -0
- package/manual/examples/service-plugins/provider/LICENSE +21 -0
- package/manual/examples/service-plugins/provider/index.js +19 -0
- package/manual/examples/service-plugins/provider/package.json +1 -0
- package/manual/packages/core/LICENSE +21 -0
- package/manual/packages/core/src/host.js +122 -0
- package/manual/packages/sdk/LICENSE +21 -0
- package/manual/packages/sdk/index.js +14 -0
- package/manual/packages/sdk/package.json +1 -0
- package/manual/plugins/environment/LICENSE +21 -0
- package/manual/plugins/environment/index.js +92 -0
- package/manual/plugins/environment/package.json +1 -0
- package/manual/plugins/examples/LICENSE +21 -0
- package/manual/plugins/examples/index.js +23 -0
- package/manual/plugins/examples/package.json +1 -0
- package/manual/plugins/examples/scripts/echo.py +5 -0
- package/manual/tests/application.test.js +45 -0
- package/node_modules/@mytools/core/bin/fst.js +12 -4
- package/node_modules/@mytools/core/src/application.js +9 -4
- package/node_modules/@mytools/core/src/cli-run.js +6 -0
- package/node_modules/@mytools/core/src/contracts.js +15 -2
- package/node_modules/@mytools/core/src/control.js +63 -0
- package/node_modules/@mytools/core/src/documentation.js +65 -0
- package/node_modules/@mytools/core/src/engine.js +80 -26
- package/node_modules/@mytools/core/src/host.js +13 -2
- package/node_modules/@mytools/core/src/markdown-log.js +8 -5
- package/node_modules/@mytools/core/src/process.js +35 -14
- package/node_modules/@mytools/core/src/project.js +1 -1
- package/node_modules/@mytools/core/src/run-logs.js +1 -1
- package/node_modules/@mytools/core/src/terminal/conditions.js +32 -0
- package/node_modules/@mytools/core/src/terminal/log-viewer.js +7 -8
- package/node_modules/@mytools/core/src/terminal/model.js +32 -3
- package/node_modules/@mytools/core/src/terminal/presentation.js +7 -9
- package/node_modules/@mytools/core/src/terminal/run-viewer.js +86 -0
- package/node_modules/@mytools/core/src/terminal/session.js +109 -22
- package/node_modules/@mytools/core/src/terminal/theme.js +28 -0
- package/node_modules/@mytools/core/src/terminal/view.js +74 -19
- package/node_modules/@mytools/core/src/validation.js +15 -4
- package/node_modules/@mytools/core/src/version.js +1 -1
- package/node_modules/@mytools/core/src/workflows.js +30 -19
- package/node_modules/@mytools/core/templates/control-demo.yaml +47 -0
- package/node_modules/@mytools/environment/index.js +14 -2
- package/node_modules/@mytools/pack/index.js +2 -0
- package/node_modules/@mytools/web/public/THIRD_PARTY_NOTICES.txt +215 -216
- package/node_modules/@mytools/web/public/assets/index-DT2B4tMV.js +70 -0
- package/node_modules/@mytools/web/public/assets/{index-BeV6IqTW.css → index-vuZvrUTT.css} +1 -1
- package/node_modules/@mytools/web/public/index.html +2 -2
- package/package.json +2 -2
- package/node_modules/@mytools/web/public/assets/index-TgsBnZgG.js +0 -70
- /package/{docs → manual/docs}/deployment.md +0 -0
|
@@ -5,8 +5,9 @@ import path from 'node:path';
|
|
|
5
5
|
import { createApplication } from '../src/application.js';
|
|
6
6
|
import { verifyBundle } from '../src/bundle.js';
|
|
7
7
|
import { resolveProject, initializeProject } from '../src/project.js';
|
|
8
|
-
import {waitForCliRun} from '../src/cli-run.js';
|
|
8
|
+
import {waitForCliRun,cliRunResult} from '../src/cli-run.js';
|
|
9
9
|
import {VERSION} from '../src/version.js';
|
|
10
|
+
import {listDocumentation,readDocumentation,findDocumentationRoot,exportDocumentation} from '../src/documentation.js';
|
|
10
11
|
|
|
11
12
|
const program=new Command().name('fst').description('Fishtools · 插件式工作流工作台').version(VERSION);
|
|
12
13
|
program.option('--maintenance','维护模式:不启动有状态服务插件');
|
|
@@ -14,13 +15,20 @@ program.option('--project <directory>','项目目录(默认当前项目或用
|
|
|
14
15
|
async function app(){const o=program.opts();return createApplication({projectDir:await resolveProject(o.project,o.workspace),outputDir:o.output,maintenance:!!o.maintenance,config:o.config?JSON.parse(await readFile(o.config,'utf8')):{}});}
|
|
15
16
|
async function use(fn){const a=await app();try{return await fn(a);}finally{await a.close();}}
|
|
16
17
|
program.command('init [directory]').description('创建项目,保留已有配置与工作流').action(async directory=>console.log(`Fishtools 项目:${await initializeProject(path.resolve(directory??process.cwd()))}`));
|
|
18
|
+
program.command('docs [topic]').description('阅读或导出随安装提供的离线文档').option('--path','只打印文档路径').option('--export <directory>','导出完整资料到新目录').action(async(topic,options)=>{
|
|
19
|
+
if(options.export){if(topic||options.path)throw new Error('--export 不可与文档主题或 --path 同用');console.log(await exportDocumentation(options.export));return;}
|
|
20
|
+
if(topic){const document=await readDocumentation(topic);console.log(options.path?document.path:document.text);return;}
|
|
21
|
+
if(options.path){console.log(await findDocumentationRoot());return;}
|
|
22
|
+
console.log((await listDocumentation()).map(document=>`${document.id}\t${document.title}`).join('\n'));
|
|
23
|
+
console.log('\nfst docs <主题> 阅读正文;fst docs <主题> --path 查看路径;fst docs --export <新目录> 导出全部资料');
|
|
24
|
+
});
|
|
17
25
|
const workflow=program.command('workflow').description('查看和检查工作流');
|
|
18
26
|
workflow.command('list').action(()=>use(async a=>console.log(JSON.stringify(await a.listWorkflows(),null,2))));
|
|
19
27
|
workflow.command('check <id>').action(id=>use(async a=>{await a.checkWorkflow(id);console.log('工作流校验通过');}));
|
|
20
28
|
program.command('run <id>').option('--input <file>','JSON 输入文件').action((id,o)=>use(async a=>{
|
|
21
29
|
const run=await a.startRun(id,o.input?JSON.parse(await readFile(o.input,'utf8')):{});
|
|
22
|
-
const
|
|
23
|
-
process.exitCode=
|
|
30
|
+
const result=cliRunResult(await waitForCliRun(a,run));console.log(JSON.stringify(result.value,null,2));
|
|
31
|
+
process.exitCode=result.exitCode;
|
|
24
32
|
}));
|
|
25
33
|
const plugin=program.command('plugin').description('管理插件');
|
|
26
34
|
plugin.command('list').action(()=>use(a=>console.log(JSON.stringify(a.listPlugins(),null,2))));
|
|
@@ -36,7 +44,7 @@ packOptions(program.command('portable').description('导出完整便携工具、
|
|
|
36
44
|
program.command('command [name]').description('列出插件命令或执行动作命令').option('--input <file>','JSON 输入').action((name,o)=>use(async a=>{
|
|
37
45
|
if(!name){console.log(JSON.stringify(a.listCommands(),null,2));return;}
|
|
38
46
|
const run=await a.startCommand(name,o.input?JSON.parse(await readFile(o.input,'utf8')):{});
|
|
39
|
-
const result=await waitForCliRun(a,run);console.log(JSON.stringify(result.
|
|
47
|
+
const result=cliRunResult(await waitForCliRun(a,run));console.log(JSON.stringify(result.value,null,2));process.exitCode=result.exitCode;
|
|
40
48
|
}));
|
|
41
49
|
program.command('configure <plugin> <file>').description('从 JSON 保存插件配置').action((id,file)=>use(async a=>{await a.configurePlugin(id,JSON.parse(await readFile(file,'utf8')));console.log('插件配置已保存');}));
|
|
42
50
|
async function web(o){
|
|
@@ -68,12 +68,17 @@ export async function createApplication({projectDir=process.cwd(),workspace,outp
|
|
|
68
68
|
run.done=(async()=>{
|
|
69
69
|
try {
|
|
70
70
|
await event({type:'run.started'});
|
|
71
|
-
run.output=await execute(activeHost,{outputDir:dir,signal:controller.signal,runId,log:(message,data)=>event({type:'log',message,data})},event);
|
|
71
|
+
run.output=await execute(activeHost,{outputDir:dir,signal:controller.signal,runId,log:(message,data,meta={})=>event({...meta,type:'log',message,data})},event);
|
|
72
72
|
run.status='succeeded';await event({type:'run.succeeded'});
|
|
73
73
|
} catch(error) {
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
74
|
+
if(error?.code==='WORKFLOW_STOPPED'){
|
|
75
|
+
run.status='stopped';run.reason=error.details?.reason??error.message;
|
|
76
|
+
await event({type:'run.stopped',reason:run.reason});
|
|
77
|
+
}else{
|
|
78
|
+
run.status=error?.code==='EXECUTION_FAILED'?'failed':controller.signal.aborted||['PROMPT_CANCELLED','CANCELLED'].includes(error?.code)?'cancelled':'failed';
|
|
79
|
+
run.error=errorRecord(error);
|
|
80
|
+
await event({type:`run.${run.status}`,message:run.error.message,code:run.error.code,plugin:run.error.plugin,action:run.error.action,command:run.error.command,details:run.error.details,error:run.error});
|
|
81
|
+
}
|
|
77
82
|
} finally {
|
|
78
83
|
run.finishedAt=new Date().toISOString();
|
|
79
84
|
await writes;
|
|
@@ -4,3 +4,9 @@ export async function waitForCliRun(app,run,signals=process){
|
|
|
4
4
|
try{return await run.done??app.getRun(run.id);}
|
|
5
5
|
finally{signals.removeListener('SIGINT',cancel);}
|
|
6
6
|
}
|
|
7
|
+
|
|
8
|
+
export function cliRunResult(run){
|
|
9
|
+
const value=run.status==='stopped'?{status:'stopped',reason:run.reason}:run.error??run.output;
|
|
10
|
+
const exitCode=['succeeded','stopped'].includes(run.status)?0:run.status==='cancelled'?130:1;
|
|
11
|
+
return {value,exitCode};
|
|
12
|
+
}
|
|
@@ -1,13 +1,26 @@
|
|
|
1
1
|
import {FlowError} from '@mytools/sdk';
|
|
2
|
+
import {conditionOperators} from './control.js';
|
|
2
3
|
|
|
3
4
|
export const builtinCommands=[
|
|
4
5
|
['workflows','选择已有工作流'],['new','新建工作流'],['add','添加插件能力或子工作流'],
|
|
5
6
|
['steps','设置步骤参数、排序或删除'],['outputs','设置最终输出'],['save','保存工作流'],
|
|
6
|
-
['run','运行工作流,可附带 @参数.json'],['logs','查看运行日志与保留设置'],['plugins','管理插件'],['config','设置插件配置'],
|
|
7
|
-
['pack','选择工作流打包'],['web','启动 Web 工作台'],['help','查看操作说明'],['exit','退出终端工作台'],
|
|
7
|
+
['run','运行工作流,可附带 @参数.json'],['runs','观察运行中的步骤、输入输出与实时日志'],['logs','查看运行日志与保留设置'],['plugins','管理插件'],['config','设置插件配置'],
|
|
8
|
+
['pack','选择工作流打包'],['web','启动 Web 工作台'],['docs','阅读离线开发与使用文档'],['help','查看操作说明'],['exit','退出终端工作台'],
|
|
8
9
|
].map(([name,description])=>({name,description}));
|
|
9
10
|
|
|
10
11
|
export function validateDeclarations(plugin){
|
|
12
|
+
for(const [name,action] of Object.entries(plugin.actions??{})){
|
|
13
|
+
if(action.conditions!==undefined&&(!action.conditions||typeof action.conditions!=='object'||Array.isArray(action.conditions)))throw new FlowError(`动作 ${name} 的 conditions 必须为对象`,{code:'VALIDATION'});
|
|
14
|
+
for(const [key,preset] of Object.entries(action.conditions??{})){
|
|
15
|
+
if(!preset||typeof preset.title!=='string'||!preset.title.trim()||typeof preset.path!=='string'||!preset.path||preset.path.split('.').some(p=>!p||['__proto__','constructor','prototype'].includes(p))||!conditionOperators.includes(preset.op)||(preset.op!=='exists'&&!Object.hasOwn(preset,'value')))throw new FlowError(`动作 ${name} 的判定预设 ${key} 无效`,{code:'VALIDATION'});
|
|
16
|
+
let schema=action.output;
|
|
17
|
+
for(const part of preset.path.split('.')){
|
|
18
|
+
if(schema?.properties&&Object.hasOwn(schema.properties,part))schema=schema.properties[part];
|
|
19
|
+
else if(schema?.type==='array'&&/^\d+$/.test(part))schema=schema.items;
|
|
20
|
+
else throw new FlowError(`判定预设 ${key} 引用了未声明的输出字段 ${preset.path}`,{code:'VALIDATION'});
|
|
21
|
+
}
|
|
22
|
+
}
|
|
23
|
+
}
|
|
11
24
|
for(const key of ['platforms','architectures'])if(plugin[key]!==undefined&&(!Array.isArray(plugin[key])||!plugin[key].every(x=>typeof x==='string'&&x)))throw new FlowError(`${plugin.id} 的 ${key} 必须是字符串数组`);
|
|
12
25
|
for(const declarations of [plugin.environments,...Object.values(plugin.actions??{}).map(a=>a.environments)]){
|
|
13
26
|
for(const [id,e] of Object.entries(declarations??{})){
|
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
import {isDeepStrictEqual} from 'node:util';
|
|
2
|
+
import {FlowError} from '@mytools/sdk';
|
|
3
|
+
import {bind,references} from './validation.js';
|
|
4
|
+
|
|
5
|
+
export const conditionOperators=['eq','ne','gt','gte','lt','lte','contains','exists'];
|
|
6
|
+
const invalid=message=>{throw new FlowError(message,{code:'VALIDATION'});};
|
|
7
|
+
export function validateCondition(condition,visit=()=>{},depth=0){
|
|
8
|
+
if(depth>32||!condition||typeof condition!=='object'||Array.isArray(condition))invalid('条件必须是有限深度的结构化对象');
|
|
9
|
+
const groups=['all','any','not'].filter(key=>Object.hasOwn(condition,key));
|
|
10
|
+
if(groups.length){
|
|
11
|
+
if(groups.length!==1||Object.keys(condition).length!==1)invalid('组合条件只能包含 all、any 或 not 中的一种');
|
|
12
|
+
const key=groups[0],children=key==='not'?[condition.not]:condition[key];
|
|
13
|
+
if(!Array.isArray(children)||!children.length)invalid('组合条件不能为空');
|
|
14
|
+
children.forEach(child=>validateCondition(child,visit,depth+1));return;
|
|
15
|
+
}
|
|
16
|
+
if(!conditionOperators.includes(condition.op)||!Object.hasOwn(condition,'left')||(condition.op!=='exists'&&!Object.hasOwn(condition,'right'))||Object.keys(condition).some(k=>!['left','op','right'].includes(k)))invalid('条件比较格式错误');
|
|
17
|
+
references(condition.left,visit);if(condition.op!=='exists')references(condition.right,visit);
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
export function evaluateCondition(condition,state){
|
|
21
|
+
if(condition.all)return condition.all.every(c=>evaluateCondition(c,state));
|
|
22
|
+
if(condition.any)return condition.any.some(c=>evaluateCondition(c,state));
|
|
23
|
+
if(condition.not)return !evaluateCondition(condition.not,state);
|
|
24
|
+
let left,right;
|
|
25
|
+
try{left=bind(condition.left,state);if(condition.op!=='exists')right=bind(condition.right,state);}
|
|
26
|
+
catch(error){if(error.code==='MISSING_REFERENCE')return false;throw error;}
|
|
27
|
+
switch(condition.op){
|
|
28
|
+
case 'exists':return left!==undefined;
|
|
29
|
+
case 'eq':return isDeepStrictEqual(left,right);
|
|
30
|
+
case 'ne':return !isDeepStrictEqual(left,right);
|
|
31
|
+
case 'contains':return typeof left==='string'&&typeof right==='string'?left.includes(right):Array.isArray(left)&&left.some(v=>isDeepStrictEqual(v,right));
|
|
32
|
+
default:
|
|
33
|
+
if(typeof left!=='number'||typeof right!=='number'||!Number.isFinite(left)||!Number.isFinite(right))return false;
|
|
34
|
+
return {gt:()=>left>right,gte:()=>left>=right,lt:()=>left<right,lte:()=>left<=right}[condition.op]();
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
export function childLists(step){
|
|
39
|
+
if(step.branch)return [step.branch.then??[],step.branch.else??[]];
|
|
40
|
+
if(step.parallel)return [step.parallel.steps];
|
|
41
|
+
return [];
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
export function validateStepList(steps,assertId,ids=new Set(),depth=0){
|
|
45
|
+
if(depth>32||!Array.isArray(steps))invalid('步骤列表必须是数组,嵌套深度不得超过 32');
|
|
46
|
+
for(const step of steps){
|
|
47
|
+
assertId(step?.id);if(ids.has(step.id))invalid(`步骤 ID 重复:${step.id}`);ids.add(step.id);
|
|
48
|
+
if(['constructor','prototype'].includes(step.id))invalid(`步骤 ID 不能使用保留名称:${step.id}`);
|
|
49
|
+
const kinds=['action','workflow','branch','parallel','stop'].filter(k=>Object.hasOwn(step,k));
|
|
50
|
+
if(kinds.length!==1)invalid(`${step.id} 必须选择一个动作、子工作流或控制节点`);
|
|
51
|
+
if(step.when!==undefined)validateCondition(step.when);
|
|
52
|
+
if(kinds[0]==='action'||kinds[0]==='workflow'){
|
|
53
|
+
if(typeof step[kinds[0]]!=='string'||!step[kinds[0]])invalid(`${step.id} 的目标必须为字符串`);
|
|
54
|
+
}else{
|
|
55
|
+
const value=step[kinds[0]];
|
|
56
|
+
if(!value||typeof value!=='object'||Array.isArray(value))invalid(`${step.id} 控制节点格式错误`);
|
|
57
|
+
if(step.branch){validateCondition(value.condition);if(!Array.isArray(value.then)||value.else!==undefined&&!Array.isArray(value.else))invalid('分支需要 then/else 步骤数组');}
|
|
58
|
+
if(step.parallel){const n=value.concurrency??4;if(!Number.isInteger(n)||n<1||n>32)invalid('并发数必须是 1 到 32 的整数');}
|
|
59
|
+
if(step.stop&&(typeof value.reason!=='string'||!value.reason.trim()))invalid('提前结束必须填写原因');
|
|
60
|
+
}
|
|
61
|
+
for(const list of childLists(step))validateStepList(list,assertId,ids,depth+1);
|
|
62
|
+
}
|
|
63
|
+
}
|
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
import path from 'node:path';
|
|
2
|
+
import {fileURLToPath} from 'node:url';
|
|
3
|
+
import {access,readFile,readdir,lstat,mkdir,copyFile,realpath} from 'node:fs/promises';
|
|
4
|
+
|
|
5
|
+
const topics=[
|
|
6
|
+
['plugins','插件开发规范','docs/plugin-development.md'],
|
|
7
|
+
['deployment','安装与部署','docs/deployment.md'],
|
|
8
|
+
['control','控制流与运行观察规范','docs/superpowers/specs/2026-10-11-control-observation.md'],
|
|
9
|
+
['services','服务插件示例','examples/service-plugins/README.md'],
|
|
10
|
+
['example','可安装的零依赖动作插件','docs/examples/action-plugin/README.md'],
|
|
11
|
+
['roadmap','路线图','docs/roadmap.md'],
|
|
12
|
+
['readme','Fishtools 使用说明','README.md'],
|
|
13
|
+
];
|
|
14
|
+
// 保留文档链接所需的目录布局;参考代码仅供阅读,不是第二份运行时。
|
|
15
|
+
const assets=['README.md','LICENSE','THIRD_PARTY_NOTICES.md','docs','examples/service-plugins','plugins/examples','plugins/environment','packages/sdk','packages/core/src/host.js','packages/core/LICENSE','tests/application.test.js'];
|
|
16
|
+
const excluded=new Set(['node_modules','dist','.git','.cache','__pycache__','.pytest_cache','coverage','public']);
|
|
17
|
+
const inside=(parent,child)=>{const relative=path.relative(parent,child);return relative===''||(!relative.startsWith(`..${path.sep}`)&&relative!=='..'&&!path.isAbsolute(relative));};
|
|
18
|
+
|
|
19
|
+
export async function findDocumentationRoot(){
|
|
20
|
+
let directory=path.dirname(fileURLToPath(import.meta.url));
|
|
21
|
+
for(;;){
|
|
22
|
+
for(const candidate of [directory,path.join(directory,'manual')]){
|
|
23
|
+
try{await access(path.join(candidate,'docs/plugin-development.md'));await access(path.join(candidate,'LICENSE'));return candidate;}
|
|
24
|
+
catch(error){if(error.code!=='ENOENT'&&error.code!=='ENOTDIR')throw error;}
|
|
25
|
+
}
|
|
26
|
+
const parent=path.dirname(directory);if(parent===directory)break;directory=parent;
|
|
27
|
+
}
|
|
28
|
+
throw new Error('当前安装缺少离线文档,请重新安装完整的 Fishtools 发行包');
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
export async function listDocumentation(){
|
|
32
|
+
const root=await findDocumentationRoot();
|
|
33
|
+
return topics.map(([id,title,file])=>({id,title,path:path.join(root,file)}));
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
export async function readDocumentation(topic='plugins'){
|
|
37
|
+
const entry=(await listDocumentation()).find(item=>item.id===topic);
|
|
38
|
+
if(!entry)throw new Error(`未知文档主题:${topic}。运行 fst docs 查看可用主题`);
|
|
39
|
+
return {...entry,text:await readFile(entry.path,'utf8')};
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
export async function copyDocumentation(destination,{sourceRoot}={}){
|
|
43
|
+
const source=await realpath(sourceRoot??await findDocumentationRoot());
|
|
44
|
+
const target=path.resolve(destination);
|
|
45
|
+
try{await lstat(target);throw new Error(`文档导出目标已存在:${target}`);}catch(error){if(error.code!=='ENOENT')throw error;}
|
|
46
|
+
// 目标可位于仓库 dist 下,但不能嵌入即将复制的资料目录。
|
|
47
|
+
await mkdir(path.dirname(target),{recursive:true});
|
|
48
|
+
const canonicalTarget=path.join(await realpath(path.dirname(target)),path.basename(target));
|
|
49
|
+
for(const asset of assets)if(inside(path.join(source,asset),canonicalTarget))throw new Error('文档导出目标不能位于原资料目录内');
|
|
50
|
+
await mkdir(target);
|
|
51
|
+
const copy=async(from,to)=>{
|
|
52
|
+
const info=await lstat(from);
|
|
53
|
+
if(info.isSymbolicLink())throw new Error(`离线文档不允许符号链接:${from}`);
|
|
54
|
+
if(info.isDirectory()){
|
|
55
|
+
await mkdir(to,{recursive:true});
|
|
56
|
+
for(const entry of await readdir(from))if(!excluded.has(entry))await copy(path.join(from,entry),path.join(to,entry));
|
|
57
|
+
}else if(info.isFile()){
|
|
58
|
+
await mkdir(path.dirname(to),{recursive:true});await copyFile(from,to);
|
|
59
|
+
}else throw new Error(`离线文档包含不支持的文件:${from}`);
|
|
60
|
+
};
|
|
61
|
+
for(const asset of assets)await copy(path.join(source,asset),path.join(target,asset));
|
|
62
|
+
return target;
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
export async function exportDocumentation(destination){return copyDocumentation(destination);}
|
|
@@ -2,38 +2,92 @@ import { randomUUID } from 'node:crypto';
|
|
|
2
2
|
import { FlowError } from '@mytools/sdk';
|
|
3
3
|
import { bind, validate, emptyObject } from './validation.js';
|
|
4
4
|
import {errorRecord} from './diagnostics.js';
|
|
5
|
+
import {evaluateCondition,childLists} from './control.js';
|
|
5
6
|
|
|
6
7
|
export async function runWorkflow({store,host,id,input={},signal,onEvent=()=>{},runId=randomUUID(),context={}}) {
|
|
7
8
|
const closure=await store.resolve([id],host);
|
|
8
9
|
const snapshot=new Map(closure.workflows.map(w=>[w.id,w]));
|
|
9
10
|
const emit=event=>onEvent({time:new Date().toISOString(),runId,...event});
|
|
10
|
-
const
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
const
|
|
14
|
-
await emit({type:'
|
|
15
|
-
for(const step of w
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
await
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
11
|
+
const failures=new WeakMap();
|
|
12
|
+
const checkCancelled=currentSignal=>{if(currentSignal?.aborted)throw new FlowError('运行已取消',{code:'CANCELLED'});};
|
|
13
|
+
const skip=async(step,w,path,message)=>{
|
|
14
|
+
const callPath=`${path}/${step.id}`;
|
|
15
|
+
await emit({type:'step.skipped',workflowId:w.id,stepId:step.id,callPath,action:step.action,message});
|
|
16
|
+
for(const list of childLists(step))for(const child of list)await skip(child,w,callPath,message);
|
|
17
|
+
if(step.workflow){const child=snapshot.get(store.childId(step.workflow));for(const node of child.steps)await skip(node,child,callPath,message);}
|
|
18
|
+
};
|
|
19
|
+
const sequence=async(steps,w,base,path,currentSignal,reportFailure)=>{
|
|
20
|
+
const state={inputs:base.inputs,steps:{...base.steps}},outputs={};
|
|
21
|
+
for(let i=0;i<steps.length;i++){
|
|
22
|
+
try{
|
|
23
|
+
const result=await executeStep(steps[i],w,state,path,currentSignal,reportFailure);
|
|
24
|
+
state.steps[steps[i].id]=result;
|
|
25
|
+
if(Object.hasOwn(result,'output'))outputs[steps[i].id]=result.output;
|
|
26
|
+
}catch(error){
|
|
27
|
+
for(const pending of steps.slice(i+1))await skip(pending,w,path,error.code==='WORKFLOW_STOPPED'?'工作流已提前结束':'前序步骤失败或运行已取消');
|
|
28
|
+
throw error;
|
|
29
|
+
}
|
|
30
|
+
}
|
|
31
|
+
checkCancelled(currentSignal);return {state,outputs};
|
|
32
|
+
};
|
|
33
|
+
const parallel=async(group,w,state,path,currentSignal,reportFailure)=>{
|
|
34
|
+
const controller=new AbortController(),combined=currentSignal?AbortSignal.any([currentSignal,controller.signal]):controller.signal;
|
|
35
|
+
const results=new Array(group.steps.length);let next=0,firstError;
|
|
36
|
+
// 叶子先锁定首因,再写事件;父控制节点的异步收尾不能改变失败顺序。
|
|
37
|
+
const fail=error=>{if(!firstError){firstError=error;reportFailure?.(error);controller.abort(error);}};
|
|
38
|
+
const worker=async()=>{
|
|
39
|
+
while(!firstError&&next<group.steps.length){
|
|
40
|
+
const index=next++,step=group.steps[index];
|
|
41
|
+
try{results[index]=await executeStep(step,w,{inputs:state.inputs,steps:{...state.steps}},path,combined,fail);}
|
|
42
|
+
catch(error){fail(error);}
|
|
32
43
|
}
|
|
44
|
+
};
|
|
45
|
+
// 等待所有已启动任务清理,避免 Promise.all 首次拒绝后仍有后台动作执行。
|
|
46
|
+
await Promise.all(Array.from({length:Math.min(group.concurrency??4,group.steps.length)},worker));
|
|
47
|
+
if(firstError){for(const step of group.steps.slice(next))await skip(step,w,path,'并行组已停止调度');throw firstError;}
|
|
48
|
+
return Object.fromEntries(group.steps.flatMap((step,index)=>Object.hasOwn(results[index]??{},'output')?[[step.id,results[index].output]]:[]));
|
|
49
|
+
};
|
|
50
|
+
const executeStep=async(step,w,state,parentPath,currentSignal,reportFailure)=>{
|
|
51
|
+
const callPath=`${parentPath}/${step.id}`,start=Date.now(),base={workflowId:w.id,stepId:step.id,callPath,action:step.action};
|
|
52
|
+
try{
|
|
53
|
+
checkCancelled(currentSignal);
|
|
54
|
+
if(step.when&&!evaluateCondition(step.when,state)){await skip(step,w,parentPath,'执行条件不满足');return {status:'skipped'};}
|
|
55
|
+
const args=step.action||step.workflow?bind(step.with??{},state):{};
|
|
56
|
+
await emit({type:'step.started',...base,input:args});
|
|
57
|
+
let output;
|
|
58
|
+
if(step.action)output=await host.invoke(step.action,args,{
|
|
59
|
+
...context,signal:currentSignal,runId,stepId:step.id,callPath,workflow:w,
|
|
60
|
+
log:(message,data,meta={})=>emit({type:'log',stream:meta.stream,...base,message,data}),
|
|
61
|
+
});
|
|
62
|
+
else if(step.workflow)output=await execute(store.childId(step.workflow),args,callPath,currentSignal,reportFailure);
|
|
63
|
+
else if(step.branch){
|
|
64
|
+
const matched=evaluateCondition(step.branch.condition,state);
|
|
65
|
+
for(const unused of matched?(step.branch.else??[]):step.branch.then)await skip(unused,w,callPath,'未选择此分支');
|
|
66
|
+
output=(await sequence(matched?step.branch.then:(step.branch.else??[]),w,state,callPath,currentSignal,reportFailure)).outputs;
|
|
67
|
+
}else if(step.parallel)output=await parallel(step.parallel,w,state,callPath,currentSignal,reportFailure);
|
|
68
|
+
else throw new FlowError(step.stop.reason,{code:'WORKFLOW_STOPPED',details:{reason:step.stop.reason}});
|
|
69
|
+
checkCancelled(currentSignal);
|
|
70
|
+
await emit({type:'step.succeeded',...base,durationMs:Date.now()-start,output});
|
|
71
|
+
return {status:'succeeded',output};
|
|
72
|
+
}catch(cause){
|
|
73
|
+
const origin=failures.get(cause),propagated=origin&&(origin===callPath||origin.startsWith(callPath+'/'));
|
|
74
|
+
const cancelled=propagated?cause.code==='CANCELLED':!!currentSignal?.aborted,stopped=!cancelled&&cause.code==='WORKFLOW_STOPPED',error=errorRecord(cause);
|
|
75
|
+
const failure=propagated||stopped?cause:Object.assign(new FlowError(`${callPath}: ${error.message}`,{code:cancelled?'CANCELLED':'EXECUTION_FAILED',cause,details:error.details}),{plugin:error.plugin,action:error.action});
|
|
76
|
+
if(!failures.has(failure))failures.set(failure,callPath);
|
|
77
|
+
reportFailure?.(failure);
|
|
78
|
+
await emit({type:cancelled?'step.cancelled':stopped?'step.stopped':'step.failed',...base,plugin:error.plugin,message:error.message,code:error.code,details:error.details,error:stopped?undefined:error,durationMs:Date.now()-start});
|
|
79
|
+
throw failure;
|
|
33
80
|
}
|
|
34
|
-
const output=bind(w.outputs??{},state);
|
|
35
|
-
await emit({type:'workflow.succeeded',workflowId,callPath});
|
|
36
|
-
return output;
|
|
37
81
|
};
|
|
38
|
-
|
|
82
|
+
const execute=async(workflowId,values,callPath,currentSignal,reportFailure)=>{
|
|
83
|
+
checkCancelled(currentSignal);
|
|
84
|
+
const w=snapshot.get(workflowId),state={inputs:validate(w.inputs??emptyObject,values,`${workflowId} 输入`),steps:{}};
|
|
85
|
+
await emit({type:'workflow.started',workflowId,callPath});
|
|
86
|
+
try{
|
|
87
|
+
const result=await sequence(w.steps,w,state,callPath,currentSignal,reportFailure),output=bind(w.outputs??{},result.state);
|
|
88
|
+
checkCancelled(currentSignal);
|
|
89
|
+
await emit({type:'workflow.succeeded',workflowId,callPath});return output;
|
|
90
|
+
}catch(error){if(error.code==='WORKFLOW_STOPPED')await emit({type:'workflow.stopped',workflowId,callPath,message:error.details.reason});throw error;}
|
|
91
|
+
};
|
|
92
|
+
return execute(id,input,id,signal);
|
|
39
93
|
}
|
|
@@ -14,6 +14,17 @@ export async function createHost({ plugins = [], config = {}, services = {}, env
|
|
|
14
14
|
const extensions = new Map();
|
|
15
15
|
const serviceNames = new Set();
|
|
16
16
|
const commandNames=new Set(),commands=new Map();
|
|
17
|
+
function bindContext(context){
|
|
18
|
+
const bound={...services,...context},execute=bound.runProcess;
|
|
19
|
+
if(typeof execute==='function')bound.runProcess=(file,args,options={})=>{
|
|
20
|
+
const signal=context.signal&&options.signal?AbortSignal.any([context.signal,options.signal]):context.signal??options.signal;
|
|
21
|
+
return execute(file,args,{...options,signal,onOutput:async({stream,message})=>{
|
|
22
|
+
await bound.log?.(message,undefined,{stream});
|
|
23
|
+
await options.onOutput?.({stream,message});
|
|
24
|
+
}});
|
|
25
|
+
};
|
|
26
|
+
return bound;
|
|
27
|
+
}
|
|
17
28
|
for (const entry of plugins) {
|
|
18
29
|
const plugin = entry.plugin ?? entry;
|
|
19
30
|
validateDeclarations(plugin);validateCommands(plugin,commandNames);
|
|
@@ -79,7 +90,7 @@ export async function createHost({ plugins = [], config = {}, services = {}, env
|
|
|
79
90
|
try{
|
|
80
91
|
const getService=runtime.bindServices(plugin.id);
|
|
81
92
|
context.signal?.throwIfAborted();
|
|
82
|
-
const result=await command.run(validate(command.input??emptyObject,input,`/${name} 输入`),{...
|
|
93
|
+
const result=await command.run(validate(command.input??emptyObject,input,`/${name} 输入`),{...bindContext(context),getService,config:command.config,resourceDir:plugin.root,environments:resolveEnvironments(plugin,undefined,environmentOverrides[plugin.id])});
|
|
83
94
|
context.signal?.throwIfAborted();
|
|
84
95
|
return validate(command.output,JSON.parse(JSON.stringify(result??{})),`/${name} 输出`);
|
|
85
96
|
}catch(cause){throw pluginError(cause,{plugin:plugin.id,command:`/${name}`});}
|
|
@@ -92,7 +103,7 @@ export async function createHost({ plugins = [], config = {}, services = {}, env
|
|
|
92
103
|
try{
|
|
93
104
|
const getService=runtime.bindServices(action.plugin);
|
|
94
105
|
context.signal?.throwIfAborted();
|
|
95
|
-
const result = await action.run(validate(action.input ?? emptyObject,input,`${id} 输入`), {...
|
|
106
|
+
const result = await action.run(validate(action.input ?? emptyObject,input,`${id} 输入`), {...bindContext(context),getService, config:action.config, resourceDir:action.root,environments:resolveEnvironments(definitions.get(action.plugin),action,environmentOverrides[action.plugin])});
|
|
96
107
|
context.signal?.throwIfAborted();
|
|
97
108
|
// 通过 JSON 边界验证返回值,避免函数、循环引用等进入工作流状态。
|
|
98
109
|
const encoded = JSON.stringify(result);
|
|
@@ -1,9 +1,9 @@
|
|
|
1
1
|
const names={
|
|
2
|
-
'run.started':'运行开始','run.succeeded':'运行成功','run.failed':'运行失败','run.cancelled':'运行取消',
|
|
3
|
-
'workflow.started':'工作流开始','workflow.succeeded':'工作流完成',
|
|
4
|
-
'step.started':'步骤开始','step.succeeded':'步骤成功','step.failed':'步骤失败','step.cancelled':'步骤取消',log:'插件日志',
|
|
2
|
+
'run.started':'运行开始','run.succeeded':'运行成功','run.failed':'运行失败','run.cancelled':'运行取消','run.stopped':'运行停止',
|
|
3
|
+
'workflow.started':'工作流开始','workflow.succeeded':'工作流完成','workflow.stopped':'工作流停止',
|
|
4
|
+
'step.started':'步骤开始','step.succeeded':'步骤成功','step.failed':'步骤失败','step.cancelled':'步骤取消','step.skipped':'步骤跳过','step.stopped':'步骤停止',log:'插件日志',
|
|
5
5
|
};
|
|
6
|
-
const statuses={running:'运行中',succeeded:'成功',failed:'失败',cancelled:'已取消'};
|
|
6
|
+
const statuses={running:'运行中',succeeded:'成功',failed:'失败',cancelled:'已取消',stopped:'已停止'};
|
|
7
7
|
|
|
8
8
|
// 用长于正文的围栏包裹插件文本,避免错误消息中的 Markdown 改写日志结构。
|
|
9
9
|
function block(value,language='text'){
|
|
@@ -34,12 +34,14 @@ export function markdownHeader(run){
|
|
|
34
34
|
export function markdownEvent(event){
|
|
35
35
|
let output=`### ${names[event.type]??'事件'}\n\n`;
|
|
36
36
|
const lines=[`时间:${event.time}`];
|
|
37
|
-
for(const [key,label] of [['callPath','调用路径'],['action','动作'],['plugin','插件'],['command','命令'],['durationMs','耗时(毫秒)']]){
|
|
37
|
+
for(const [key,label] of [['callPath','调用路径'],['action','动作'],['plugin','插件'],['command','命令'],['stream','输出流'],['durationMs','耗时(毫秒)']]){
|
|
38
38
|
if(event[key]!==undefined)lines.push(`${label}:${event[key]}`);
|
|
39
39
|
}
|
|
40
40
|
output+=block(lines.join('\n'));
|
|
41
41
|
if(event.message)output+=block(event.message);
|
|
42
|
+
if(event.reason!==undefined)output+='停止或跳过原因:\n\n'+block(event.reason);
|
|
42
43
|
if(event.data!==undefined)output+='附加信息:\n\n'+block(event.data,'json');
|
|
44
|
+
if(event.input!==undefined)output+='步骤输入:\n\n'+block(event.input,'json');
|
|
43
45
|
if(event.output!==undefined)output+='步骤输出:\n\n'+block(event.output,'json');
|
|
44
46
|
if(event.error)output+=errorText(event.error);
|
|
45
47
|
return output;
|
|
@@ -47,6 +49,7 @@ export function markdownEvent(event){
|
|
|
47
49
|
|
|
48
50
|
export function markdownResult(run){
|
|
49
51
|
let output='## 最终结果\n\n'+block(`状态:${statuses[run.status]??run.status}\n结束时间:${run.finishedAt}`);
|
|
52
|
+
if(run.reason!==undefined)output+='停止原因:\n\n'+block(run.reason);
|
|
50
53
|
if(run.output!==undefined)output+=block(run.output,'json');
|
|
51
54
|
return output;
|
|
52
55
|
}
|
|
@@ -2,14 +2,15 @@ import { execa } from 'execa';
|
|
|
2
2
|
import treeKill from 'tree-kill';
|
|
3
3
|
import which from 'which';
|
|
4
4
|
import { FlowError } from '@mytools/sdk';
|
|
5
|
+
import { StringDecoder } from 'node:string_decoder';
|
|
5
6
|
|
|
6
|
-
export async function runProcess(file,args=[],{cwd,env,signal,timeout=30000,maxBuffer=4*1024*1024}={}) {
|
|
7
|
+
export async function runProcess(file,args=[],{cwd,env,signal,timeout=30000,maxBuffer=4*1024*1024,onOutput}={}) {
|
|
7
8
|
if(signal?.aborted) throw new FlowError('操作已取消',{code:'CANCELLED'});
|
|
8
9
|
let executable;
|
|
9
10
|
try {executable=await which(file,{path:env?.PATH??process.env.PATH});}
|
|
10
11
|
catch(cause){throw new FlowError(`未找到程序:${file}`,{code:'ENOENT',details:{executable:file},cause});}
|
|
11
12
|
const child=execa(executable,args,{cwd,env,reject:false,windowsHide:true,maxBuffer,timeout:0,shell:false});
|
|
12
|
-
let reason,
|
|
13
|
+
let reason,killing,outputError,outputFailed=false,outputWrites=Promise.resolve();
|
|
13
14
|
const stop=kind=>{
|
|
14
15
|
if(reason) return; reason=kind;
|
|
15
16
|
killing=new Promise(resolve=>child.pid ? treeKill(child.pid,'SIGKILL',()=>resolve()) : resolve());
|
|
@@ -17,21 +18,41 @@ export async function runProcess(file,args=[],{cwd,env,signal,timeout=30000,maxB
|
|
|
17
18
|
const cancel=()=>stop('cancelled');
|
|
18
19
|
signal?.addEventListener('abort',cancel,{once:true});
|
|
19
20
|
if(signal?.aborted)cancel();
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
21
|
+
const emit=(stream,message)=>{
|
|
22
|
+
if(!onOutput||!message)return;
|
|
23
|
+
// 立即挂接拒绝处理,不能让事件回调中的异步落盘变成未处理拒绝。
|
|
24
|
+
outputWrites=outputWrites.then(async()=>{if(!outputFailed)await onOutput({stream,message});}).catch(error=>{
|
|
25
|
+
if(!outputFailed){outputFailed=true;outputError=error;stop('output-callback');}
|
|
26
|
+
});
|
|
27
|
+
};
|
|
28
|
+
const flush=[];
|
|
29
|
+
for(const [name,stream] of [['stdout',child.stdout],['stderr',child.stderr]]){
|
|
30
|
+
let bytes=0,ended=false;
|
|
31
|
+
const decoder=new StringDecoder('utf8');
|
|
32
|
+
const finish=()=>{if(!ended){ended=true;emit(name,decoder.end());}};
|
|
33
|
+
flush.push(finish);stream?.once('end',finish);
|
|
34
|
+
stream?.on('data',chunk=>{
|
|
35
|
+
bytes+=Buffer.byteLength(chunk);
|
|
36
|
+
// 流量超限时立即回收整棵进程树,不继续扩张待落盘的日志队列。
|
|
37
|
+
if(bytes>maxBuffer){stop('output-limit');return;}
|
|
38
|
+
if(onOutput)emit(name,decoder.write(Buffer.isBuffer(chunk)?chunk:Buffer.from(chunk)));
|
|
39
|
+
});
|
|
24
40
|
}
|
|
25
41
|
const timer=timeout>0 ? setTimeout(()=>stop('timeout'),timeout) : null;
|
|
42
|
+
let result;
|
|
26
43
|
try {
|
|
27
|
-
|
|
28
|
-
if(killing) await killing;
|
|
29
|
-
if(reason==='output-limit')throw new FlowError('程序输出超过上限',{code:'OUTPUT_LIMIT'});
|
|
30
|
-
if(reason) throw new FlowError(reason==='cancelled'?'操作已取消':'程序运行超时',{code:reason==='cancelled'?'CANCELLED':'TIMEOUT'});
|
|
31
|
-
if(result.isMaxBuffer) throw new FlowError('程序输出超过上限',{code:'OUTPUT_LIMIT'});
|
|
32
|
-
if(result.exitCode===undefined && result.failed) throw new FlowError(`无法启动程序 ${file}:${result.shortMessage??result.message}`,{code:result.code??'SPAWN_FAILED'});
|
|
33
|
-
return {exitCode:result.exitCode,stdout:result.stdout??'',stderr:result.stderr??''};
|
|
44
|
+
result=await child;
|
|
34
45
|
} finally {
|
|
35
|
-
clearTimeout(timer);
|
|
46
|
+
clearTimeout(timer);
|
|
47
|
+
for(const finish of flush)finish();
|
|
48
|
+
await outputWrites;
|
|
49
|
+
if(killing)await killing;
|
|
50
|
+
signal?.removeEventListener('abort',cancel);
|
|
36
51
|
}
|
|
52
|
+
if(reason==='output-callback')throw outputError;
|
|
53
|
+
if(reason==='output-limit')throw new FlowError('程序输出超过上限',{code:'OUTPUT_LIMIT'});
|
|
54
|
+
if(reason)throw new FlowError(reason==='cancelled'?'操作已取消':'程序运行超时',{code:reason==='cancelled'?'CANCELLED':'TIMEOUT'});
|
|
55
|
+
if(result.isMaxBuffer)throw new FlowError('程序输出超过上限',{code:'OUTPUT_LIMIT'});
|
|
56
|
+
if(result.exitCode===undefined&&result.failed)throw new FlowError(`无法启动程序 ${file}:${result.shortMessage??result.message}`,{code:result.code??'SPAWN_FAILED'});
|
|
57
|
+
return {exitCode:result.exitCode,stdout:result.stdout??'',stderr:result.stderr??''};
|
|
37
58
|
}
|
|
@@ -12,7 +12,7 @@ export async function initializeProject(directory){
|
|
|
12
12
|
directory=path.resolve(directory);
|
|
13
13
|
if(await exists(path.join(directory,'flow.project.json')))return directory;
|
|
14
14
|
await mkdir(path.join(directory,'workflows'),{recursive:true});
|
|
15
|
-
for(const name of ['hello','system-check','python-check']){
|
|
15
|
+
for(const name of ['hello','system-check','python-check','control-demo']){
|
|
16
16
|
try{await copyFile(path.join(templates,`${name}.yaml`),path.join(directory,'workflows',`${name}.yaml`),constants.COPYFILE_EXCL);}
|
|
17
17
|
catch(error){if(error.code!=='EEXIST')throw error;}
|
|
18
18
|
}
|
|
@@ -28,7 +28,7 @@ export function createRunLogs(directory,{projectDir,allowLegacy=false}){
|
|
|
28
28
|
if(!entry.isDirectory()||!validId(entry.name))continue;
|
|
29
29
|
try{
|
|
30
30
|
const record=await read(entry.name);
|
|
31
|
-
if(typeof record.workflowId==='string'&&['succeeded','failed','cancelled'].includes(record.status)&&Number.isFinite(Date.parse(record.finishedAt)))results.push(record);
|
|
31
|
+
if(typeof record.workflowId==='string'&&['succeeded','failed','cancelled','stopped'].includes(record.status)&&Number.isFinite(Date.parse(record.finishedAt)))results.push(record);
|
|
32
32
|
}catch(error){if(error.code==='EACCES'||error.code==='EPERM')throw error;}
|
|
33
33
|
}
|
|
34
34
|
return results.sort((a,b)=>Date.parse(b.finishedAt)-Date.parse(a.finishedAt)||b.id.localeCompare(a.id));
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
import { outputSources, precedingSteps, stepMetadata } from './model.js';
|
|
2
|
+
import { editValue } from './schema.js';
|
|
3
|
+
|
|
4
|
+
export function conditionSources(flow,index,catalog,workflows){
|
|
5
|
+
const flatten=(schema,prefix)=>Object.entries(schema?.properties??{}).flatMap(([key,value])=>[{value:`${prefix}.${key}`,label:value.title??`${prefix}.${key}`,schema:value},...flatten(value,`${prefix}.${key}`)]);
|
|
6
|
+
return [...flatten(flow.inputs,'inputs'),...outputSources(flow,index,catalog,workflows)];
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
// 条件使用结构化表单构造;用户不需要了解底层 JSON 协议。
|
|
10
|
+
export async function editCondition(ui,flow,index,catalog,workflows,{baseDir,current}={}){
|
|
11
|
+
const presets=precedingSteps(flow,index).flatMap(step=>Object.entries(stepMetadata(step,catalog,workflows)?.conditions??{}).map(([key,preset])=>({value:`preset:steps.${step.id}.output:${key}`,label:`${step.title??step.id} · ${preset.title??key}`,condition:{left:{$ref:`steps.${step.id}.output.${preset.path}`},op:preset.op,...(preset.op==='exists'?{}:{right:preset.value})}})));
|
|
12
|
+
const mode=await ui.select('设置判断条件',[
|
|
13
|
+
...presets,{value:'compare',label:'选择字段与比较方式'},{value:'all',label:'全部条件成立(且)'},{value:'any',label:'任一条件成立(或)'},{value:'not',label:'条件取反'},...(current?[{value:'keep',label:'保留当前条件'}]:[]),
|
|
14
|
+
]);
|
|
15
|
+
if(mode==='keep')return current;
|
|
16
|
+
const preset=presets.find(p=>p.value===mode);if(preset)return structuredClone(preset.condition);
|
|
17
|
+
if(mode==='not')return {not:await editCondition(ui,flow,index,catalog,workflows,{baseDir})};
|
|
18
|
+
if(mode==='all'||mode==='any'){
|
|
19
|
+
const children=[];
|
|
20
|
+
do{children.push(await editCondition(ui,flow,index,catalog,workflows,{baseDir}));}while(await ui.confirm('继续添加判断条件?'));
|
|
21
|
+
return {[mode]:children};
|
|
22
|
+
}
|
|
23
|
+
const sources=conditionSources(flow,index,catalog,workflows);
|
|
24
|
+
if(!sources.length)throw new Error('请先添加运行参数或产生输出的前置步骤');
|
|
25
|
+
const source=await ui.select('选择要判断的字段',sources),schema=sources.find(s=>s.value===source)?.schema??{};
|
|
26
|
+
const op=await ui.select('比较方式',[['eq','等于'],['ne','不等于'],['gt','大于'],['gte','大于等于'],['lt','小于'],['lte','小于等于'],['contains','包含'],['exists','存在']].map(([value,label])=>({value,label})));
|
|
27
|
+
if(op==='exists')return {left:{$ref:source},op};
|
|
28
|
+
const type=op==='contains'&&schema.type==='array'?schema.items?.type:schema.type;
|
|
29
|
+
const chosen=['string','number','integer','boolean'].includes(type)?type:await ui.select('比较值类型',[{value:'string',label:'文本'},{value:'number',label:'数字'},{value:'boolean',label:'是 / 否'}]);
|
|
30
|
+
const right=await editValue(ui,{type:chosen},current?.right,{baseDir,required:true,label:'比较值'});
|
|
31
|
+
return {left:{$ref:source},op,right};
|
|
32
|
+
}
|
|
@@ -1,10 +1,8 @@
|
|
|
1
1
|
import { Markdown, matchesKey, truncateToWidth } from '@mariozechner/pi-tui';
|
|
2
|
+
import { mouseWheel } from './run-viewer.js';
|
|
3
|
+
import {palette,markdownTheme} from './theme.js';
|
|
2
4
|
|
|
3
|
-
const
|
|
4
|
-
const cyan=text=>`\x1b[36m${text}\x1b[0m`;
|
|
5
|
-
const dim=text=>`\x1b[2m${text}\x1b[0m`;
|
|
6
|
-
const theme={heading:cyan,link:cyan,linkUrl:dim,code:cyan,codeBlock:plain,codeBlockBorder:dim,quote:plain,quoteBorder:dim,hr:dim,listBullet:cyan,
|
|
7
|
-
bold:text=>`\x1b[1m${text}\x1b[22m`,italic:text=>`\x1b[3m${text}\x1b[23m`,strikethrough:text=>`\x1b[9m${text}\x1b[29m`,underline:text=>`\x1b[4m${text}\x1b[24m`};
|
|
5
|
+
const theme=markdownTheme();
|
|
8
6
|
|
|
9
7
|
// 文本已由 TerminalView 的 safe 清理;每次只向 Overlay 提交当前窗口内的行。
|
|
10
8
|
export class MarkdownLogViewer{
|
|
@@ -27,14 +25,15 @@ export class MarkdownLogViewer{
|
|
|
27
25
|
const body=lines.slice(this.offset,this.offset+pageSize);
|
|
28
26
|
while(body.length<pageSize)body.push('');
|
|
29
27
|
const start=lines.length?this.offset+1:0,end=Math.min(this.offset+pageSize,lines.length);
|
|
30
|
-
const footer=
|
|
31
|
-
const screen=height===1?body:height===2?[...body,footer]:[
|
|
28
|
+
const footer=palette.muted(`Esc/q 返回 · ↑↓/滚轮 · PgUp/PgDn · Home/End · ${start}–${end}/${lines.length}`);
|
|
29
|
+
const screen=height===1?body:height===2?[...body,footer]:[palette.brand(this.title),...body,footer];
|
|
32
30
|
return screen.map(line=>truncateToWidth(line,width));
|
|
33
31
|
}
|
|
34
32
|
handleInput(data){
|
|
35
33
|
if(['escape','enter','q'].some(key=>matchesKey(data,key))){this.onClose();return;}
|
|
36
34
|
const {pageSize,maxOffset}=this.layout(this.terminal.columns);
|
|
37
|
-
if(
|
|
35
|
+
if(mouseWheel(data))this.offset+=mouseWheel(data)*3;
|
|
36
|
+
else if(matchesKey(data,'up'))this.offset--;
|
|
38
37
|
else if(matchesKey(data,'down'))this.offset++;
|
|
39
38
|
else if(matchesKey(data,'pageUp'))this.offset-=pageSize;
|
|
40
39
|
else if(matchesKey(data,'pageDown'))this.offset+=pageSize;
|