@yanglaofish/fishtools 0.2.0 → 0.3.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 +43 -17
- package/manual/README.md +43 -17
- package/manual/docs/examples/pipeline-reader/LICENSE +21 -0
- package/manual/docs/examples/pipeline-reader/index.js +14 -0
- package/manual/docs/examples/pipeline-reader/package.json +7 -0
- package/manual/docs/examples/pipeline-writer/LICENSE +21 -0
- package/manual/docs/examples/pipeline-writer/index.js +17 -0
- package/manual/docs/examples/pipeline-writer/package.json +7 -0
- package/manual/docs/plugin-development.md +26 -6
- package/manual/docs/releases.md +19 -1
- package/manual/docs/research/2026-10-11-plugin-kernel-capabilities.md +167 -0
- package/manual/docs/roadmap.md +5 -0
- package/manual/docs/superpowers/plans/2026-10-11-control-observation.md +1 -1
- package/manual/docs/superpowers/plans/2026-10-11-public-plugin-fixes.md +40 -0
- package/manual/docs/superpowers/plans/2026-10-11-terminal-hierarchy.md +40 -0
- package/manual/docs/superpowers/specs/2026-10-11-collapsed-plugin-output.md +23 -0
- package/manual/docs/verification.md +83 -0
- package/manual/packages/core/src/application.js +211 -0
- package/manual/packages/core/src/engine.js +93 -0
- package/manual/packages/core/src/host.js +2 -0
- package/manual/packages/core/src/runtime.js +135 -0
- package/manual/packages/core/src/terminal/run-feed.js +78 -0
- package/manual/packages/core/src/terminal/session.js +438 -0
- package/manual/packages/core/src/terminal/view.js +283 -0
- package/manual/tests/application.test.js +30 -0
- package/node_modules/@mytools/core/src/application.js +2 -1
- package/node_modules/@mytools/core/src/contracts.js +1 -1
- package/node_modules/@mytools/core/src/documentation.js +1 -1
- package/node_modules/@mytools/core/src/engine.js +1 -1
- package/node_modules/@mytools/core/src/host.js +2 -0
- package/node_modules/@mytools/core/src/markdown-log.js +1 -1
- package/node_modules/@mytools/core/src/process.js +14 -1
- package/node_modules/@mytools/core/src/terminal/model.js +1 -1
- package/node_modules/@mytools/core/src/terminal/navigation.js +17 -0
- package/node_modules/@mytools/core/src/terminal/presentation.js +3 -3
- package/node_modules/@mytools/core/src/terminal/run-feed.js +78 -0
- package/node_modules/@mytools/core/src/terminal/run-presentation.js +60 -0
- package/node_modules/@mytools/core/src/terminal/run-viewer.js +30 -12
- package/node_modules/@mytools/core/src/terminal/session.js +118 -50
- package/node_modules/@mytools/core/src/terminal/theme.js +1 -0
- package/node_modules/@mytools/core/src/terminal/view.js +125 -28
- package/node_modules/@mytools/core/src/terminal/workflow-menu.js +50 -0
- package/node_modules/@mytools/core/src/validation.js +3 -1
- package/node_modules/@mytools/core/src/version.js +1 -1
- package/node_modules/@mytools/core/src/workflows.js +11 -0
- package/node_modules/@mytools/pack/index.js +4 -2
- package/node_modules/@mytools/web/public/THIRD_PARTY_NOTICES.txt +766 -765
- package/package.json +1 -1
|
@@ -0,0 +1,211 @@
|
|
|
1
|
+
import { mkdir, appendFile, writeFile, rename, readFile } from 'node:fs/promises';
|
|
2
|
+
import path from 'node:path';
|
|
3
|
+
import { randomUUID } from 'node:crypto';
|
|
4
|
+
import { EventEmitter } from 'node:events';
|
|
5
|
+
import { createHost } from './host.js';
|
|
6
|
+
import { WorkflowStore } from './workflows.js';
|
|
7
|
+
import { runWorkflow } from './engine.js';
|
|
8
|
+
import { runProcess } from './process.js';
|
|
9
|
+
import { readJson, loadPlugin, installGeneration } from './plugins.js';
|
|
10
|
+
import { FlowError } from '@mytools/sdk';
|
|
11
|
+
import {prepareBundleWorkspace} from './bundle-workspace.js';
|
|
12
|
+
import {validate} from './validation.js';
|
|
13
|
+
import {createResources} from './resources.js';
|
|
14
|
+
import {errorRecord} from './diagnostics.js';
|
|
15
|
+
import {markdownHeader,markdownEvent,markdownResult} from './markdown-log.js';
|
|
16
|
+
import {createRunLogs,validateKeepRuns} from './run-logs.js';
|
|
17
|
+
|
|
18
|
+
export async function createApplication({projectDir=process.cwd(),workspace,outputDir,plugins,config={},maintenance=false}={}) {
|
|
19
|
+
projectDir=await prepareBundleWorkspace(path.resolve(projectDir),workspace);
|
|
20
|
+
const project=await readJson(path.join(projectDir,'flow.project.json'),{plugins:[]});
|
|
21
|
+
const output=path.resolve(outputDir??process.env.FLOW_OUTPUT_DIR??(project.readOnly?path.join(process.cwd(),'flow-results'):path.join(projectDir,'.flow','runs')));
|
|
22
|
+
const store=new WorkflowStore(path.join(projectDir,'workflows'));
|
|
23
|
+
const emitter=new EventEmitter(), runs=new Map();
|
|
24
|
+
const resources=createResources(),operations=new Set();let closing,launching=0;
|
|
25
|
+
const runLogs=createRunLogs(output,{projectDir,allowLegacy:output===path.join(projectDir,'.flow','runs')}),writingRuns=new Set();
|
|
26
|
+
validateKeepRuns(project.logs?.keepRuns??null);
|
|
27
|
+
let host,state=await readJson(path.join(projectDir,'.flow','plugins.json'),{}),busy=false;
|
|
28
|
+
const app={
|
|
29
|
+
projectDir,project,outputDir:output,store,
|
|
30
|
+
get host(){return host;},
|
|
31
|
+
get maintenance(){return maintenance;},
|
|
32
|
+
catalog:()=>host.catalog(),
|
|
33
|
+
listPlugins:()=>[...host.definitions.values()].map(p=>({id:p.id,version:p.version,title:p.title,description:p.description,platforms:p.platforms,architectures:p.architectures,environments:p.environments,config:p.config,runtime:host.runtimeStatus().find(r=>r.id===p.id),removable:!!state.dependencies?.[p.id]})),
|
|
34
|
+
listCommands:()=>host.commands(),
|
|
35
|
+
listWorkflows:()=>store.list(),
|
|
36
|
+
async saveWorkflow(w){if(project.readOnly)throw new FlowError('此分发包仅用于运行;请在开发项目中编辑');return store.save(w);},
|
|
37
|
+
async removeWorkflow(id){if(project.readOnly)throw new FlowError('只读项目不能删除工作流');return store.remove(id);},
|
|
38
|
+
checkWorkflow:id=>store.resolve([id],host),
|
|
39
|
+
subscribe(fn){emitter.on('event',fn);return ()=>emitter.off('event',fn);},
|
|
40
|
+
async startRun(id,input={}) {
|
|
41
|
+
if(busy) throw new FlowError('插件更新中,请稍后运行');
|
|
42
|
+
const closure=await store.resolve([id],host);
|
|
43
|
+
const snapshotStore={resolve:async()=>structuredClone(closure),childId:ref=>store.childId(ref)};
|
|
44
|
+
return app.executeTracked(id,(activeHost,context,event)=>runWorkflow({store:snapshotStore,host:activeHost,id,input,signal:context.signal,onEvent:event,runId:context.runId,context}));
|
|
45
|
+
},
|
|
46
|
+
async startCommand(name,input={},context={}){
|
|
47
|
+
if(busy)throw new FlowError('插件更新中,请稍后调用');
|
|
48
|
+
return app.executeTracked(`/${name}`,(activeHost,runContext)=>activeHost.invokeCommand(name,input,{...context,...runContext}));
|
|
49
|
+
},
|
|
50
|
+
async executeTracked(id,execute){
|
|
51
|
+
const runId=randomUUID(),dir=path.join(output,runId),controller=new AbortController();
|
|
52
|
+
await mkdir(dir,{recursive:true});
|
|
53
|
+
const run={id:runId,workflowId:id,logOwner:runLogs.owner,status:'running',startedAt:new Date().toISOString(),events:[],controller};
|
|
54
|
+
runs.set(runId,run);
|
|
55
|
+
writingRuns.add(runId);
|
|
56
|
+
const markdownFile=path.join(dir,'run.md');
|
|
57
|
+
let writes=writeFile(markdownFile,markdownHeader(run));
|
|
58
|
+
const event=e=>{
|
|
59
|
+
const full={time:new Date().toISOString(),runId,...e};run.events.push(full);
|
|
60
|
+
emitter.emit('event',full);
|
|
61
|
+
const {output:ignored,...record}=full;
|
|
62
|
+
writes=writes.then(async()=>{
|
|
63
|
+
await appendFile(path.join(dir,'events.jsonl'),JSON.stringify(record)+'\n');
|
|
64
|
+
await appendFile(markdownFile,markdownEvent(full));
|
|
65
|
+
});
|
|
66
|
+
return writes;
|
|
67
|
+
};
|
|
68
|
+
const activeHost=host;
|
|
69
|
+
run.done=(async()=>{
|
|
70
|
+
try {
|
|
71
|
+
await event({type:'run.started'});
|
|
72
|
+
run.output=await execute(activeHost,{outputDir:dir,signal:controller.signal,runId,log:(message,data,meta={})=>event({...meta,type:'log',message,data})},event);
|
|
73
|
+
run.status='succeeded';await event({type:'run.succeeded'});
|
|
74
|
+
} catch(error) {
|
|
75
|
+
if(error?.code==='WORKFLOW_STOPPED'){
|
|
76
|
+
run.status='stopped';run.reason=error.details?.reason??error.message;
|
|
77
|
+
await event({type:'run.stopped',reason:run.reason});
|
|
78
|
+
}else{
|
|
79
|
+
run.status=error?.code==='EXECUTION_FAILED'?'failed':controller.signal.aborted||['PROMPT_CANCELLED','CANCELLED'].includes(error?.code)?'cancelled':'failed';
|
|
80
|
+
run.error=errorRecord(error);
|
|
81
|
+
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});
|
|
82
|
+
}
|
|
83
|
+
} finally {
|
|
84
|
+
run.finishedAt=new Date().toISOString();
|
|
85
|
+
await writes;
|
|
86
|
+
await appendFile(markdownFile,markdownResult(app.getRun(runId)));
|
|
87
|
+
await writeFile(path.join(dir,'result.json'),JSON.stringify(snapshotRun(run),null,2));
|
|
88
|
+
writingRuns.delete(runId);
|
|
89
|
+
try{await pruneLogs();}catch(error){
|
|
90
|
+
await event({type:'log',message:`日志自动清理失败:${error.message}`});
|
|
91
|
+
await writeFile(path.join(dir,'result.json'),JSON.stringify(snapshotRun(run),null,2));
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
return snapshotRun(run);
|
|
95
|
+
})();
|
|
96
|
+
return run;
|
|
97
|
+
},
|
|
98
|
+
getRun(id){const r=runs.get(id);return r?snapshotRun(r):null;},
|
|
99
|
+
async readRun(id){if(runs.has(id))return app.getRun(id);if(!/^[a-f0-9-]{36}$/.test(id))throw new FlowError('运行 ID 无效');return JSON.parse(await readFile(path.join(output,id,'result.json'),'utf8'));},
|
|
100
|
+
listRuns:()=>[...runs.keys()].map(id=>{const {events,...r}=app.getRun(id);return r;}),
|
|
101
|
+
logSettings:()=>({keepRuns:project.logs?.keepRuns??null}),
|
|
102
|
+
async listRunHistory(){
|
|
103
|
+
const records=new Map((await runLogs.list()).map(r=>[r.id,r]));
|
|
104
|
+
for(const id of runs.keys())records.set(id,app.getRun(id));
|
|
105
|
+
return [...records.values()].sort((a,b)=>Date.parse(b.finishedAt??b.startedAt)-Date.parse(a.finishedAt??a.startedAt)||b.id.localeCompare(a.id)).map(({events,output,...r})=>r);
|
|
106
|
+
},
|
|
107
|
+
async readLog(id){
|
|
108
|
+
const run=runs.get(id);
|
|
109
|
+
if(run&&writingRuns.has(id))return markdownHeader(run)+run.events.map(markdownEvent).join('');
|
|
110
|
+
return runLogs.markdown(id);
|
|
111
|
+
},
|
|
112
|
+
async configureLogs(keepRuns){
|
|
113
|
+
validateKeepRuns(keepRuns);if(project.readOnly)throw new FlowError('只读运行包不能保存日志设置');
|
|
114
|
+
if(busy)throw new FlowError('项目正在更新,请稍后修改日志设置');
|
|
115
|
+
busy=true;
|
|
116
|
+
try{
|
|
117
|
+
const logs={...project.logs,keepRuns},file=path.join(projectDir,'flow.project.json'),temp=`${file}.${randomUUID()}.tmp`;
|
|
118
|
+
await mkdir(projectDir,{recursive:true});await writeFile(temp,JSON.stringify({...project,logs},null,2));await rename(temp,file);project.logs=logs;
|
|
119
|
+
await pruneLogs();
|
|
120
|
+
}finally{busy=false;}
|
|
121
|
+
},
|
|
122
|
+
cancelRun(id){const r=runs.get(id);if(r?.status==='running')r.controller.abort();return !!r;},
|
|
123
|
+
async installPlugin(source){return change({add:source});},
|
|
124
|
+
async removePlugin(name){if(resources.active(name))throw new FlowError('请先停止此插件提供的 Web');if(!state.dependencies?.[name])throw new FlowError('此插件来自发行配置,不能在此移除');return change({remove:name});},
|
|
125
|
+
async pack(options){const {packApplication}=await import('@mytools/pack');return packApplication(app,options);},
|
|
126
|
+
async packPlan(options){const {planApplication}=await import('@mytools/pack');return planApplication(app,options);},
|
|
127
|
+
async configurePlugin(id,value){
|
|
128
|
+
if(project.readOnly)throw new FlowError('只读项目不能保存配置');
|
|
129
|
+
assertMutable();
|
|
130
|
+
if(resources.active(id))throw new FlowError('请先停止此插件提供的 Web,再修改配置');
|
|
131
|
+
if(busy||launching||app.listRuns().some(r=>r.status==='running'))throw new FlowError('请等待运行或安装结束');
|
|
132
|
+
if(!host.definitions.has(id))throw new FlowError(`插件不存在:${id}`);
|
|
133
|
+
value=validate(host.definitions.get(id).config,value,`${id} 配置`);
|
|
134
|
+
busy=true;let nextHost;
|
|
135
|
+
try{
|
|
136
|
+
const nextConfig={...project.config,[id]:value};
|
|
137
|
+
nextHost=await createHost({plugins:[...host.definitions.values()].map(p=>({plugin:p,root:p.root})),config:{...nextConfig,...config},services,environmentOverrides:project.environments,runtimeEnabled:!maintenance});
|
|
138
|
+
const nextProject={...project,config:nextConfig},file=path.join(projectDir,'flow.project.json'),temp=`${file}.${randomUUID()}.tmp`;
|
|
139
|
+
await writeFile(temp,JSON.stringify(nextProject,null,2));await rename(temp,file);
|
|
140
|
+
const old=host;host=nextHost;nextHost=null;project.config=nextConfig;await old.close();
|
|
141
|
+
}finally{try{if(nextHost)await nextHost.close();}finally{busy=false;}}
|
|
142
|
+
},
|
|
143
|
+
async setMaintenance(enabled){
|
|
144
|
+
if(typeof enabled!=='boolean')throw new FlowError('维护模式必须为布尔值');
|
|
145
|
+
if(enabled===maintenance)return;
|
|
146
|
+
if(busy||launching||app.listRuns().some(r=>r.status==='running'))throw new FlowError('请等待运行或安装结束');
|
|
147
|
+
if([...host.definitions.values()].some(p=>p.runtime&&resources.active(p.id)))throw new FlowError('请先停止运行时插件提供的 Web');
|
|
148
|
+
busy=true;
|
|
149
|
+
try{
|
|
150
|
+
const entries=[...host.definitions.values()].map(p=>({plugin:p,root:p.root}));
|
|
151
|
+
if(enabled){maintenance=true;await host.close();}
|
|
152
|
+
const next=await createHost({plugins:entries,config:{...project.config,...config},services,environmentOverrides:project.environments,runtimeEnabled:!enabled});
|
|
153
|
+
const old=host;host=next;maintenance=enabled;if(!enabled)await old.close();
|
|
154
|
+
}finally{busy=false;}
|
|
155
|
+
},
|
|
156
|
+
startWeb(options){
|
|
157
|
+
if(busy)throw new FlowError('插件更新中,请稍后启动 Web');
|
|
158
|
+
const owner=[...host.definitions.values()].find(p=>p.services?.web)?.id;
|
|
159
|
+
return resources.own(()=>host.service('web',options),owner);
|
|
160
|
+
},
|
|
161
|
+
close(){
|
|
162
|
+
closing??=Promise.resolve().then(async()=>{
|
|
163
|
+
for(const r of runs.values())if(r.status==='running')r.controller.abort();
|
|
164
|
+
await Promise.allSettled([...operations]);
|
|
165
|
+
for(const r of runs.values())if(r.status==='running')r.controller.abort();
|
|
166
|
+
await Promise.allSettled([...runs.values()].map(r=>r.done));
|
|
167
|
+
const errors=[];
|
|
168
|
+
try{await resources.close();}catch(error){errors.push(error);}
|
|
169
|
+
try{await host.close();}catch(error){errors.push(error);}
|
|
170
|
+
if(errors.length)throw new AggregateError(errors,`应用关闭失败:${errors.map(e=>e.message).join(';')}`);
|
|
171
|
+
});return closing;
|
|
172
|
+
},
|
|
173
|
+
};
|
|
174
|
+
const initial=plugins??await Promise.all((project.plugins??[]).map(spec=>loadPlugin(spec,project.baseBundle?.root??projectDir)));
|
|
175
|
+
const services={application:app,runProcess};
|
|
176
|
+
const build=extra=>createHost({plugins:[...initial,...extra],config:{...project.config,...config},services,environmentOverrides:project.environments,runtimeEnabled:!maintenance});
|
|
177
|
+
const external=[];
|
|
178
|
+
for(const name of Object.keys(state.dependencies??{}))external.push(await loadPlugin(name,state.root));
|
|
179
|
+
host=await build(external);
|
|
180
|
+
for(const name of ['startRun','startCommand','startWeb','saveWorkflow','removeWorkflow','installPlugin','removePlugin','configurePlugin','configureLogs','setMaintenance','pack']){
|
|
181
|
+
const original=app[name],isRun=['startRun','startCommand'].includes(name);
|
|
182
|
+
app[name]=async(...args)=>{
|
|
183
|
+
if(closing)throw new FlowError('应用已关闭');
|
|
184
|
+
if(isRun)launching++;
|
|
185
|
+
const task=Promise.resolve().then(()=>original.apply(app,args));operations.add(task);
|
|
186
|
+
try{return await task;}finally{operations.delete(task);if(isRun)launching--;}
|
|
187
|
+
};
|
|
188
|
+
}
|
|
189
|
+
function assertMutable(){if(!maintenance&&host.hasRuntime)throw new FlowError('运行时插件已启动,请先进入维护模式再修改插件或配置');}
|
|
190
|
+
function snapshotRun(run){const {done,controller,...result}=run;return structuredClone(result);}
|
|
191
|
+
async function pruneLogs(){for(const id of await runLogs.prune(project.logs?.keepRuns??null,writingRuns))runs.delete(id);}
|
|
192
|
+
async function change(operation){
|
|
193
|
+
if(project.readOnly)throw new FlowError('运行包不支持安装或移除插件');
|
|
194
|
+
assertMutable();
|
|
195
|
+
if(busy || launching || [...runs.values()].some(r=>r.status==='running'))throw new FlowError('请等待当前运行或安装结束');
|
|
196
|
+
busy=true;let nextHost;
|
|
197
|
+
try {
|
|
198
|
+
const next=await installGeneration(projectDir,{...operation,state});
|
|
199
|
+
for(const p of host.definitions.values())if(resources.active(p.id)){
|
|
200
|
+
const candidate=[...initial,...next.loaded].find(e=>(e.plugin??e).id===p.id);
|
|
201
|
+
if(!candidate||candidate.root!==p.root||(candidate.plugin??candidate).version!==p.version)throw new FlowError('请先停止活动 Web,再替换其提供者');
|
|
202
|
+
}
|
|
203
|
+
nextHost=await build(next.loaded);
|
|
204
|
+
const file=path.join(projectDir,'.flow','plugins.json'),temp=`${file}.tmp`;
|
|
205
|
+
await writeFile(temp,JSON.stringify(next.state,null,2));await rename(temp,file);
|
|
206
|
+
const old=host;host=nextHost;nextHost=null;state=next.state;await old.close();
|
|
207
|
+
return app.listPlugins();
|
|
208
|
+
} finally {try{if(nextHost)await nextHost.close();}finally{busy=false;}}
|
|
209
|
+
}
|
|
210
|
+
return app;
|
|
211
|
+
}
|
|
@@ -0,0 +1,93 @@
|
|
|
1
|
+
import { randomUUID } from 'node:crypto';
|
|
2
|
+
import { FlowError } from '@mytools/sdk';
|
|
3
|
+
import { bind, validate, emptyObject } from './validation.js';
|
|
4
|
+
import {errorRecord} from './diagnostics.js';
|
|
5
|
+
import {evaluateCondition,childLists} from './control.js';
|
|
6
|
+
|
|
7
|
+
export async function runWorkflow({store,host,id,input={},signal,onEvent=()=>{},runId=randomUUID(),context={}}) {
|
|
8
|
+
const closure=await store.resolve([id],host);
|
|
9
|
+
const snapshot=new Map(closure.workflows.map(w=>[w.id,w]));
|
|
10
|
+
const emit=event=>onEvent({time:new Date().toISOString(),runId,...event});
|
|
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);}
|
|
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,...(meta.audience==='user'?{audience:'user'}:{}),...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;
|
|
80
|
+
}
|
|
81
|
+
};
|
|
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);
|
|
93
|
+
}
|
|
@@ -16,6 +16,8 @@ export async function createHost({ plugins = [], config = {}, services = {}, env
|
|
|
16
16
|
const commandNames=new Set(),commands=new Map();
|
|
17
17
|
function bindContext(context){
|
|
18
18
|
const bound={...services,...context},execute=bound.runProcess;
|
|
19
|
+
// 面向用户的输出与诊断日志共用持久化通道,只区分默认可见性。
|
|
20
|
+
bound.print=(message,data)=>Promise.resolve(bound.log?.(message,data,{audience:'user'}));
|
|
19
21
|
if(typeof execute==='function')bound.runProcess=(file,args,options={})=>{
|
|
20
22
|
const signal=context.signal&&options.signal?AbortSignal.any([context.signal,options.signal]):context.signal??options.signal;
|
|
21
23
|
return execute(file,args,{...options,signal,onOutput:async({stream,message})=>{
|
|
@@ -0,0 +1,135 @@
|
|
|
1
|
+
import {FlowError} from '@mytools/sdk';
|
|
2
|
+
|
|
3
|
+
// 静态声明用于诊断与打包,运行时激活仍由 Cordis inject 驱动。
|
|
4
|
+
export function validateRuntimes(definitions,ctx){
|
|
5
|
+
const providers=new Map(),ordered=[],visiting=new Set(),visited=new Set();
|
|
6
|
+
for(const p of definitions.values()){
|
|
7
|
+
const r=p.runtime;if(r===undefined)continue;
|
|
8
|
+
if(!r||typeof r.apply!=='function')throw new FlowError(`${p.id} runtime 必须提供 apply`);
|
|
9
|
+
for(const key of ['provides','inject']){
|
|
10
|
+
const names=r[key]??[];
|
|
11
|
+
if(!Array.isArray(names)||names.some(n=>typeof n!=='string'||!/^\w[\w.-]*$/.test(n)||n in ctx)||new Set(names).size!==names.length)throw new FlowError(`${p.id} runtime.${key} 服务名称无效或重复`);
|
|
12
|
+
}
|
|
13
|
+
for(const name of r.provides??[]){
|
|
14
|
+
if(providers.has(name))throw new FlowError(`服务重复:${name}(${providers.get(name)}、${p.id})`);
|
|
15
|
+
providers.set(name,p.id);
|
|
16
|
+
}
|
|
17
|
+
}
|
|
18
|
+
function visit(id){
|
|
19
|
+
if(visiting.has(id))throw new FlowError(`服务循环依赖:${[...visiting,id].join(' → ')}`);
|
|
20
|
+
if(visited.has(id))return;
|
|
21
|
+
visiting.add(id);const p=definitions.get(id);
|
|
22
|
+
for(const name of p.runtime?.inject??[]){
|
|
23
|
+
const owner=providers.get(name);
|
|
24
|
+
if(!owner)throw new FlowError(`${id} 缺少服务提供者:${name}`);
|
|
25
|
+
if(owner!==id&&!Object.hasOwn(p.dependencies??{},owner))throw new FlowError(`${id} 的 dependencies 必须声明服务 ${name} 的提供者 ${owner}`);
|
|
26
|
+
visit(owner);
|
|
27
|
+
}
|
|
28
|
+
visiting.delete(id);visited.add(id);ordered.push(id);
|
|
29
|
+
}
|
|
30
|
+
for(const id of definitions.keys())visit(id);
|
|
31
|
+
return {providers,ordered};
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
export function createRuntime(ctx,definitions,{enabled=true}={}){
|
|
35
|
+
const {providers,ordered}=validateRuntimes(definitions,ctx);
|
|
36
|
+
const forks=new Map(),scopes=new Map(),errors=[],pending=new Set();let closing;
|
|
37
|
+
const record=(id,error)=>errors.push({plugin:id,message:error?.message??String(error)});
|
|
38
|
+
const track=(id,callback)=>{
|
|
39
|
+
// 同步撤销注册,只有异步尾部进入等待集合,避免服务重激活时旧清理删掉新注册。
|
|
40
|
+
let result;try{result=callback();}catch(error){record(id,error);return;}
|
|
41
|
+
const task=Promise.resolve(result).catch(e=>record(id,e)).finally(()=>pending.delete(task));
|
|
42
|
+
pending.add(task);return task;
|
|
43
|
+
};
|
|
44
|
+
// 3.18.1 的 reset 会调用 dispose,但不等待 Promise;此适配只补齐等待和错误传播。
|
|
45
|
+
ctx.on('internal/listener',function(name,listener,options){
|
|
46
|
+
if(name!=='dispose')return;
|
|
47
|
+
const wrapped=()=>track(this.runtime.name??'host',listener),list=this.scope.disposables;
|
|
48
|
+
list[options.prepend?'unshift':'push'](wrapped);
|
|
49
|
+
return ()=>{const i=list.indexOf(wrapped);if(i>=0)list.splice(i,1);};
|
|
50
|
+
},{prepend:true,global:true});
|
|
51
|
+
ctx.on('internal/error',function(error){record(this?.runtime?.name??'host',error);},{global:true});
|
|
52
|
+
const drain=async()=>{while(pending.size)await Promise.all([...pending]);};
|
|
53
|
+
const failure=()=>new FlowError(`插件生命周期失败:${errors.map(e=>`${e.plugin}: ${e.message}`).join(';')}`,{code:'PLUGIN_RUNTIME',details:{errors:structuredClone(errors)}});
|
|
54
|
+
const runtime={
|
|
55
|
+
hasRuntime:[...definitions.values()].some(p=>p.runtime),
|
|
56
|
+
register(plugin,settings,register){
|
|
57
|
+
if(!enabled&&plugin.runtime)return;
|
|
58
|
+
const fork=ctx.plugin({name:plugin.id,inject:plugin.runtime?.inject??[],apply(scope){
|
|
59
|
+
const activation={scope,active:true,tasks:new Set(),disposers:[]};
|
|
60
|
+
scopes.set(plugin.id,activation);
|
|
61
|
+
const initialize=callback=>{
|
|
62
|
+
const task=Promise.resolve().then(callback);
|
|
63
|
+
activation.tasks.add(task);
|
|
64
|
+
return task.finally(()=>activation.tasks.delete(task));
|
|
65
|
+
};
|
|
66
|
+
scope.on('dispose',()=>{
|
|
67
|
+
activation.active=false;
|
|
68
|
+
if(scopes.get(plugin.id)===activation)scopes.delete(plugin.id);
|
|
69
|
+
return (async()=>{
|
|
70
|
+
// 初始化可能仍在取得资源;先等其结束,再执行本次激活登记的清理。
|
|
71
|
+
while(activation.tasks.size)await Promise.allSettled([...activation.tasks]);
|
|
72
|
+
for(const dispose of activation.disposers.splice(0)){
|
|
73
|
+
try{await dispose();}catch(error){record(plugin.id,error);}
|
|
74
|
+
}
|
|
75
|
+
})();
|
|
76
|
+
});
|
|
77
|
+
register(scope);
|
|
78
|
+
const managed=new Proxy(scope,{get(target,name){
|
|
79
|
+
if(name==='on')return(event,listener,options={})=>{
|
|
80
|
+
if(event==='dispose'){
|
|
81
|
+
activation.disposers[options.prepend?'unshift':'push'](listener);
|
|
82
|
+
return()=>{const i=activation.disposers.indexOf(listener);if(i>=0)activation.disposers.splice(i,1);};
|
|
83
|
+
}
|
|
84
|
+
if(event==='ready')return scope.on(event,()=>initialize(listener),options);
|
|
85
|
+
return scope.on(event,listener,options);
|
|
86
|
+
};
|
|
87
|
+
if(name==='set')return(...args)=>{
|
|
88
|
+
if(!activation.active||closing)throw new FlowError(`${plugin.id} 激活作用域已失效`);
|
|
89
|
+
return scope.set(...args);
|
|
90
|
+
};
|
|
91
|
+
return Reflect.get(target,name);
|
|
92
|
+
}});
|
|
93
|
+
if(plugin.runtime)scope.on('ready',()=>initialize(()=>plugin.runtime.apply(managed,settings)));
|
|
94
|
+
}});
|
|
95
|
+
forks.set(plugin.id,fork);
|
|
96
|
+
},
|
|
97
|
+
async start(){
|
|
98
|
+
await ctx.start();await drain();
|
|
99
|
+
if(errors.length)throw failure();
|
|
100
|
+
if(!enabled)return;
|
|
101
|
+
for(const [name,id] of providers)if(ctx.get(name)==null)throw new FlowError(`${id} 未提供声明的服务:${name}`,{code:'PLUGIN_RUNTIME'});
|
|
102
|
+
for(const p of runtime.status())if(p.status!=='ready')throw new FlowError(`${p.id} 服务尚未就绪:${p.missing.join(', ')}`,{code:'PLUGIN_RUNTIME'});
|
|
103
|
+
},
|
|
104
|
+
status(){return [...definitions.values()].map(p=>{
|
|
105
|
+
const state=forks.get(p.id)?.runtime.status,missing=(p.runtime?.inject??[]).filter(n=>ctx.get(n)==null);
|
|
106
|
+
const error=errors.findLast(e=>e.plugin===p.id);
|
|
107
|
+
return {id:p.id,status:closing||!enabled&&p.runtime?'stopped':state===3?'failed':state===2&&forks.get(p.id)?.runtime.isActive&&!missing.length?'ready':'pending',missing,...(error?{error:error.message}:{})};
|
|
108
|
+
});},
|
|
109
|
+
assertActive(id){
|
|
110
|
+
if(closing)throw new FlowError('插件宿主已停止');
|
|
111
|
+
if(runtime.status().find(p=>p.id===id)?.status!=='ready'||!scopes.has(id))throw new FlowError(`插件 ${id} 当前不可用`,{code:'PLUGIN_RUNTIME',details:runtime.status().find(p=>p.id===id)});
|
|
112
|
+
},
|
|
113
|
+
bindServices(id){
|
|
114
|
+
runtime.assertActive(id);const activation=scopes.get(id);
|
|
115
|
+
return name=>runtime.getService(id,name,activation);
|
|
116
|
+
},
|
|
117
|
+
getService(id,name,activation){
|
|
118
|
+
if(!activation?.active||scopes.get(id)!==activation)throw new FlowError(`${id} 激活作用域已失效`);
|
|
119
|
+
runtime.assertActive(id);
|
|
120
|
+
const p=definitions.get(id);
|
|
121
|
+
if(![...(p.runtime?.inject??[]),...(p.runtime?.provides??[])].includes(name))throw new FlowError(`${id} 未声明服务:${name}`);
|
|
122
|
+
const value=activation.scope.get(name);
|
|
123
|
+
if(value==null)throw new FlowError(`${id} 服务不可用:${name}`);
|
|
124
|
+
return value;
|
|
125
|
+
},
|
|
126
|
+
assertOpen(){if(closing)throw new FlowError('插件宿主已停止');},
|
|
127
|
+
close(){
|
|
128
|
+
closing??=Promise.resolve().then(async()=>{
|
|
129
|
+
// 消费者先关闭,等待其使用完服务;注册撤销和依赖停用由 Cordis 执行。
|
|
130
|
+
for(const id of [...ordered].reverse()){forks.get(id)?.dispose();await drain();}
|
|
131
|
+
await ctx.stop();await drain();if(errors.length)throw failure();
|
|
132
|
+
});return closing;
|
|
133
|
+
},
|
|
134
|
+
};return runtime;
|
|
135
|
+
}
|
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
import {readableValue,readableError,readableTimeline} from './run-presentation.js';
|
|
2
|
+
import {runStatuses} from './run-viewer.js';
|
|
3
|
+
|
|
4
|
+
// 主面板按事件到达顺序追加;运行快照另存,配置菜单不会清掉执行记录。
|
|
5
|
+
export class RunFeed{
|
|
6
|
+
constructor(refresh){this.refresh=refresh;this.runs=new Map();this.entries=[];this.subscriptions=new Map();this.expanded=false;}
|
|
7
|
+
track({app,runId,title,input}){
|
|
8
|
+
if(this.runs.has(runId))return;
|
|
9
|
+
const record=app.getRun(runId),item={app,record,title,input,count:0,ended:false,steps:new Map(),catalog:new Map((app.catalog?.()??[]).map(action=>[action.id,action]))};
|
|
10
|
+
this.runs.set(runId,item);
|
|
11
|
+
this.entries.push({type:'start',item});
|
|
12
|
+
if(!this.subscriptions.has(app))this.subscriptions.set(app,app.subscribe(event=>{
|
|
13
|
+
const item=this.runs.get(event.runId);if(!item||item.app!==app)return;
|
|
14
|
+
this.update(item,event);this.release(app);
|
|
15
|
+
if(event.type==='log'&&!item.ended)this.scheduleRefresh();
|
|
16
|
+
else this.flushRefresh();
|
|
17
|
+
}));
|
|
18
|
+
this.update(item);this.release(app);this.refresh();
|
|
19
|
+
}
|
|
20
|
+
scheduleRefresh(){
|
|
21
|
+
if(this.refreshTimer)return;
|
|
22
|
+
this.refreshTimer=setTimeout(()=>{this.refreshTimer=null;this.refresh();},100);
|
|
23
|
+
}
|
|
24
|
+
flushRefresh(){
|
|
25
|
+
if(this.refreshTimer){clearTimeout(this.refreshTimer);this.refreshTimer=null;}
|
|
26
|
+
this.refresh();
|
|
27
|
+
}
|
|
28
|
+
update(item,event){
|
|
29
|
+
if(event?.type&&!['run.succeeded','run.failed','run.cancelled','run.stopped'].includes(event.type))item.record.events.push(event);
|
|
30
|
+
else{
|
|
31
|
+
const record=item.app.getRun(item.record.id);if(!record)return;
|
|
32
|
+
item.record={...record,events:[...(record.events??[])]};
|
|
33
|
+
}
|
|
34
|
+
const {record}=item,events=record.events.slice(item.count);item.count=record.events.length;
|
|
35
|
+
for(const event of events)if(event.type.startsWith('step.'))item.steps.set(event.callPath,{...item.steps.get(event.callPath),...event});
|
|
36
|
+
if(events.length){
|
|
37
|
+
const last=this.entries.at(-1);
|
|
38
|
+
if(last?.type==='events'&&last.item===item)last.events.push(...events);
|
|
39
|
+
else this.entries.push({type:'events',item,events});
|
|
40
|
+
}
|
|
41
|
+
if(record.status!=='running'&&!item.ended){item.ended=true;this.entries.push({type:'result',item});}
|
|
42
|
+
}
|
|
43
|
+
release(app){
|
|
44
|
+
if([...this.runs.values()].some(item=>item.app===app&&!item.ended))return;
|
|
45
|
+
this.subscriptions.get(app)?.();this.subscriptions.delete(app);
|
|
46
|
+
}
|
|
47
|
+
message(text){this.entries.push({type:'message',text});this.refresh();}
|
|
48
|
+
compactEvents(entry,seen){
|
|
49
|
+
const {item}=entry,rows=[];
|
|
50
|
+
for(const event of entry.events){
|
|
51
|
+
const key=`${item.record.id}\0${event.callPath}`;
|
|
52
|
+
if(event.type.startsWith('step.')&&!seen.has(key)){
|
|
53
|
+
seen.add(key);const latest=item.steps.get(event.callPath),action=item.catalog.get(latest.action);
|
|
54
|
+
const status=latest.type.slice(5),symbols={started:'●',succeeded:'✓',failed:'✗',cancelled:'×',skipped:'–',stopped:'■'};
|
|
55
|
+
const name=action?.title??latest.action??latest.stepId??latest.callPath;
|
|
56
|
+
rows.push(`${symbols[status]??'·'} ${name} · ${status==='started'?'执行中':runStatuses[status]??status}${latest.durationMs!==undefined?' · '+latest.durationMs+' ms':''}${action?.plugin?' · '+action.plugin:''} · ${latest.callPath}`);
|
|
57
|
+
}
|
|
58
|
+
if(event.type==='log'&&event.audience==='user')rows.push(`[${event.callPath??item.record.workflowId}] ${event.message??''}${event.data!==undefined?'\n'+readableValue(event.data):''}`);
|
|
59
|
+
if(event.type==='step.failed'&&![...item.steps.values()].some(step=>step.type==='step.failed'&&step.callPath?.startsWith(event.callPath+'/')))rows.push(`✗ ${event.callPath}\n${readableError(event.error??{message:event.message})}`);
|
|
60
|
+
if(event.type==='step.stopped'||event.type==='step.skipped')rows.push(`${event.callPath}:${event.reason??event.message??''}`);
|
|
61
|
+
}
|
|
62
|
+
return rows.length?`${item.title??item.record.workflowId} · ${item.record.id.slice(0,8)}\n${rows.join('\n')}`:'';
|
|
63
|
+
}
|
|
64
|
+
text(){const seen=new Set();return this.entries.map(entry=>{
|
|
65
|
+
if(entry.type==='message')return entry.text;
|
|
66
|
+
const {record,title,input}=entry.item,label=`${title??record.workflowId} · ${record.id.slice(0,8)}`;
|
|
67
|
+
if(entry.type==='start')return [
|
|
68
|
+
`› ${record.workflowId.startsWith('/')?record.workflowId:'/run '+record.workflowId} · ${label}`,
|
|
69
|
+
`状态:${runStatuses[record.status]??record.status}`,
|
|
70
|
+
this.expanded&&input&&Object.keys(input).length?'输入\n'+readableValue(input):'',
|
|
71
|
+
].filter(Boolean).join('\n');
|
|
72
|
+
if(entry.type==='events')return this.expanded?`${label}\n${readableTimeline(entry.events,{values:true})}`:this.compactEvents(entry,seen);
|
|
73
|
+
return [`结果 · ${label} · ${runStatuses[record.status]??record.status}`,
|
|
74
|
+
this.expanded&&record.output!==undefined?readableValue(record.output):'',record.error&&(this.expanded||![...entry.item.steps.values()].some(step=>step.type==='step.failed'))?readableError(record.error,{details:this.expanded}):'',record.reason??'',
|
|
75
|
+
].filter(Boolean).join('\n');
|
|
76
|
+
}).filter(Boolean).join('\n\n');}
|
|
77
|
+
close(){if(this.refreshTimer)clearTimeout(this.refreshTimer);this.refreshTimer=null;for(const unsubscribe of this.subscriptions.values())unsubscribe();this.subscriptions.clear();}
|
|
78
|
+
}
|