@yanglaofish/fishtools 0.5.0 → 0.5.1

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.
Files changed (33) hide show
  1. package/manual/docs/releases.md +12 -0
  2. package/manual/docs/superpowers/reviews/2026-10-13-0.5.0-followup-review.md +67 -0
  3. package/manual/docs/verification.md +14 -1
  4. package/manual/packages/core/src/application.js +12 -3
  5. package/manual/packages/core/src/version.js +1 -1
  6. package/manual/packages/fst-cli/README.md +6 -1
  7. package/manual/packages/fst-cli/package.json +1 -0
  8. package/manual/packages/fst-cli/src/terminal/run-feed.js +24 -9
  9. package/manual/packages/fst-logs/README.md +11 -0
  10. package/manual/packages/fst-logs/package.json +2 -0
  11. package/manual/packages/fst-logs/src/run-logs.js +8 -23
  12. package/manual/packages/fst-logs/src/run-preview.js +18 -0
  13. package/manual/packages/fst-logs/src/step-summaries.js +28 -0
  14. package/manual/packages/fst-platform/README.md +6 -0
  15. package/manual/packages/fst-platform/src/process.js +2 -2
  16. package/manual/packages/fst-plugin/src/host.js +7 -4
  17. package/manual/packages/fst-plugin/src/runtime.js +8 -5
  18. package/node_modules/@mytools/core/src/application.js +12 -3
  19. package/node_modules/@mytools/core/src/version.js +1 -1
  20. package/node_modules/@mytools/fst-cli/README.md +6 -1
  21. package/node_modules/@mytools/fst-cli/package.json +1 -0
  22. package/node_modules/@mytools/fst-cli/src/terminal/run-feed.js +24 -9
  23. package/node_modules/@mytools/fst-logs/README.md +11 -0
  24. package/node_modules/@mytools/fst-logs/package.json +2 -0
  25. package/node_modules/@mytools/fst-logs/src/run-logs.js +8 -23
  26. package/node_modules/@mytools/fst-logs/src/run-preview.js +18 -0
  27. package/node_modules/@mytools/fst-logs/src/step-summaries.js +28 -0
  28. package/node_modules/@mytools/fst-platform/README.md +6 -0
  29. package/node_modules/@mytools/fst-platform/src/process.js +2 -2
  30. package/node_modules/@mytools/fst-plugin/src/host.js +7 -4
  31. package/node_modules/@mytools/fst-plugin/src/runtime.js +8 -5
  32. package/node_modules/@mytools/web/public/THIRD_PARTY_NOTICES.txt +1416 -1415
  33. package/package.json +1 -1
@@ -1,12 +1,21 @@
1
1
  import {readableValue,readableError,readableTimeline} from './run-presentation.js';
2
2
  import {runStatuses} from './run-viewer.js';
3
+ import {createStepSummaries} from '@mytools/fst-logs/steps';
4
+ import {previewRun} from '@mytools/fst-logs/preview';
5
+
6
+ const feedRecord=(record,maxBytes)=>{
7
+ const {stepSummaries,stepSummaryBytes,stepsTruncated,...state}=record;
8
+ return previewRun({...state,events:[...(state.events??[])]},{maxValueBytes:Math.min(maxBytes,65536)});
9
+ };
3
10
 
4
11
  // 主面板按事件到达顺序追加;运行快照另存,配置菜单不会清掉执行记录。
5
12
  export class RunFeed{
6
13
  constructor(refresh,{maxEvents=1000,maxBytes=1048576,maxRuns=50}={}){Object.assign(this,{refresh,maxEvents,maxBytes,maxRuns});this.runs=new Map();this.entries=[];this.subscriptions=new Map();this.expanded=false;this.dropped=0;}
7
14
  track({app,runId,title,input}){
8
15
  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]))};
16
+ const raw=app.getRun(runId),record=feedRecord(raw,this.maxBytes),bounded=previewRun({input},{maxValueBytes:Math.min(this.maxBytes,65536)});
17
+ const item={app,record,title,input:bounded.input,inputTruncated:bounded.inputTruncated,count:0,ended:false,steps:createStepSummaries({maxEvents:this.maxEvents,maxMemoryBytes:this.maxBytes}),catalog:new Map((app.catalog?.()??[]).map(action=>[action.id,action]))};
18
+ for(const summary of raw.stepSummaries??[])item.steps.append(summary);
10
19
  this.runs.set(runId,item);
11
20
  this.entries.push({type:'start',item});
12
21
  if(!this.subscriptions.has(app))this.subscriptions.set(app,app.subscribe(event=>{
@@ -30,12 +39,13 @@ export class RunFeed{
30
39
  if(event?.type&&!['run.succeeded','run.failed','run.cancelled','run.stopped'].includes(event.type)){item.record.events.push(event);events=[event];item.count++;}
31
40
  else{
32
41
  const record=item.app.getRun(item.record.id);if(!record)return;
33
- item.record={...record,events:[...(record.events??[])]};
42
+ item.record=feedRecord(record,this.maxBytes);
43
+ for(const summary of record.stepSummaries??[])item.steps.append(summary);
34
44
  events=record.events.filter((e,index)=>e.seq!==undefined?e.seq>item.count:index>=item.count);
35
45
  item.count=record.eventCount??record.events.length;
36
46
  }
37
47
  const {record}=item;
38
- for(const event of events)if(event.type.startsWith('step.'))item.steps.set(event.callPath,{...item.steps.get(event.callPath),...event});
48
+ for(const event of events)item.steps.append(event);
39
49
  if(events.length){
40
50
  const last=this.entries.at(-1);
41
51
  if(last?.type==='events'&&last.item===item)last.events.push(...events);
@@ -59,7 +69,6 @@ export class RunFeed{
59
69
  this.entries=this.entries.filter(entry=>!entry.events||entry.events.length);
60
70
  for(const item of this.runs.values()){
61
71
  item.record.events=this.entries.filter(e=>e.item===item&&e.events).flatMap(e=>e.events);
62
- while(item.steps.size>this.maxEvents)item.steps.delete(item.steps.keys().next().value);
63
72
  }
64
73
  const ended=[...this.runs.values()].filter(item=>item.ended);
65
74
  for(const item of ended.slice(0,Math.max(0,ended.length-this.maxRuns))){this.runs.delete(item.record.id);this.entries=this.entries.filter(e=>e.item!==item);this.dropped++;}
@@ -75,10 +84,14 @@ export class RunFeed{
75
84
  for(const event of entry.events){
76
85
  const key=`${item.record.id}\0${event.callPath}`;
77
86
  if(event.type.startsWith('step.')&&!seen.has(key)){
78
- seen.add(key);const latest=item.steps.get(event.callPath),action=item.catalog.get(latest.action);
79
- const status=latest.type.slice(5),symbols={started:'●',succeeded:'✓',failed:'✗',cancelled:'×',skipped:'–',stopped:'■'};
80
- const name=action?.title??latest.action??latest.stepId??latest.callPath;
81
- rows.push(`${symbols[status]??'·'} ${name} · ${status==='started'?'执行中':runStatuses[status]??status}${latest.durationMs!==undefined?' · '+latest.durationMs+' ms':''}${action?.plugin?' · '+action.plugin:''} · ${latest.callPath}`);
87
+ seen.add(key);const latest=item.steps.get(event.callPath);
88
+ if(!latest)rows.push(`· ${event.action??event.stepId??event.callPath} · 摘要已省略 · ${event.callPath}`);
89
+ else{
90
+ const action=item.catalog.get(latest.action);
91
+ const status=latest.type.slice(5),symbols={started:'●',succeeded:'✓',failed:'✗',cancelled:'×',skipped:'–',stopped:'■'};
92
+ const name=action?.title??latest.action??latest.stepId??latest.callPath;
93
+ rows.push(`${symbols[status]??'·'} ${name} · ${status==='started'?'执行中':runStatuses[status]??status}${latest.durationMs!==undefined?' · '+latest.durationMs+' ms':''}${action?.plugin?' · '+action.plugin:''} · ${latest.callPath}`);
94
+ }
82
95
  }
83
96
  if(event.type==='log'&&event.audience==='user')rows.push(`[${event.callPath??item.record.workflowId}] ${event.message??''}${event.data!==undefined?'\n'+readableValue(event.data):''}`);
84
97
  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})}`);
@@ -86,18 +99,20 @@ export class RunFeed{
86
99
  }
87
100
  return rows.length?`${item.title??item.record.workflowId} · ${item.record.id.slice(0,8)}\n${rows.join('\n')}`:'';
88
101
  }
89
- text(){const seen=new Set();return (this.dropped?'主面板仅保留近期摘要;完整日志可在 /logs 中按 p 分页阅读。\n\n':'')+this.entries.map(entry=>{
102
+ text(){const seen=new Set();return (this.dropped?'主面板仅保留近期摘要;超出预算的事件和步骤摘要已省略。完整日志可在 /logs 中按 p 分页阅读。\n\n':'')+this.entries.map(entry=>{
90
103
  if(entry.type==='message')return entry.text;
91
104
  const {record,title,input}=entry.item,label=`${title??record.workflowId} · ${record.id.slice(0,8)}`;
92
105
  if(entry.type==='start')return [
93
106
  `› ${record.workflowId.startsWith('/')?record.workflowId:'/run '+record.workflowId} · ${label}`,
94
107
  `状态:${runStatuses[record.status]??record.status}`,
95
108
  this.expanded&&input&&Object.keys(input).length?'输入\n'+readableValue(input):'',
109
+ this.expanded&&entry.item.inputTruncated?'输入较大,主面板仅显示摘要。':'',
96
110
  ].filter(Boolean).join('\n');
97
111
  if(entry.type==='events')return this.expanded?`${label}\n${readableTimeline(entry.events,{values:true})}`:this.compactEvents(entry,seen);
98
112
  return [`结果 · ${label} · ${runStatuses[record.status]??record.status}`,
99
113
  ...(record.warnings??[]).map(warning=>`警告:${warning.message}`),
100
114
  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??'',
115
+ this.expanded&&record.outputTruncated?'结果较大,主面板仅显示摘要;完整结果请在 /logs 中按 p 分页阅读。':'',
101
116
  ].filter(Boolean).join('\n');
102
117
  }).filter(Boolean).join('\n\n');}
103
118
  close(){if(this.refreshTimer)clearTimeout(this.refreshTimer);this.refreshTimer=null;for(const unsubscribe of this.subscriptions.values())unsubscribe();this.subscriptions.clear();}
@@ -13,6 +13,8 @@
13
13
  | --- | --- |
14
14
  | `@mytools/fst-logs` | `./src/run-logs.js` |
15
15
  | `@mytools/fst-logs/markdown` | `./src/markdown-log.js` |
16
+ | `@mytools/fst-logs/steps` | `./src/step-summaries.js` |
17
+ | `@mytools/fst-logs/preview` | `./src/run-preview.js` |
16
18
  | `@mytools/fst-logs/package.json` | `./package.json` |
17
19
 
18
20
  仅依赖 exports 声明的入口,禁止消费者跨包读取 src/bin 私有文件。入口路径由本模块维护,修改协议时同步消费方回归。
@@ -35,6 +37,15 @@
35
37
 
36
38
  快照与历史元数据另有 `stepSummaries`、`stepSummaryBytes`、`stepsTruncated`。步骤摘要按 callPath(缺少时按 stepId)保留最新状态,仅包含类型、定位、动作、时间、序号、耗时及有界原因/错误,不缓存 input/output。步骤摘要使用独立的 maxEvents/maxMemoryBytes 预算,因此两份缓存合计最多为两倍 maxMemoryBytes;单条步骤摘要仍受 maxEventBytes 限制。普通日志淘汰不影响步骤状态;步骤本身超预算淘汰时 stepsTruncated 为 true,展示端应把无法确认的步骤显示为未知状态。
37
39
 
40
+ `createStepSummaries(policy)` 从 `/steps` 入口提供同一套摘要缓存,日志写入器与 TUI 共用。
41
+ `append(event)` 更新状态并刷新淘汰顺序;`get(callPath)`、`values()` 和 `snapshot()` 返回副本。
42
+ `snapshot()` 包含上述三个步骤摘要字段,`size` 为保留条数。
43
+
44
+ `previewRun(record,{maxValueBytes=65536})` 从 `/preview` 入口提供展示与历史元数据共用的结果投影。
45
+ input/output、error、reason 等字段转为有界 JSON 预览,过大时标记 `<字段>Truncated:true`;业务数据对象不变。
46
+ 定位字段与 events/stepSummaries 保留,后两者由各自缓存预算负责;展示端应裁去不使用的缓存副本。
47
+ maxValueBytes 至少为 2,连预览本身也容不下时省略字段值并保留截断标记。
48
+
38
49
  队列预算在追加时检查。未等待 append 的插件同样受到预算约束;超限返回 `LOG_QUEUE_LIMIT` 拒绝,并使 finish 失败。拒绝已安装内部处理,调用方仍可 await 观察失败,不会产生未处理拒绝。`LOG_DISK_LIMIT` 表示可选磁盘预算不足,拒绝完整追加而不静默截断。磁盘预算与队列预算都计算 JSONL 和 Markdown。建议插件等待 append,以便磁盘写入形成背压。
39
50
 
40
51
  `read`/`list` 仅读取受限的 result.json 元数据,运行中的 read 回退到归属元数据;不加载 JSONL 或 Markdown 正文。巨大旧 result.json 超过读取预算时,read 明确返回 `LOG_METADATA_LIMIT`,list 跳过该记录。旧版本缺少归属信息时仅在 `allowLegacy:true` 下可读。
@@ -6,6 +6,8 @@
6
6
  "main": "src/run-logs.js",
7
7
  "exports": {
8
8
  ".": "./src/run-logs.js",
9
+ "./steps": "./src/step-summaries.js",
10
+ "./preview": "./src/run-preview.js",
9
11
  "./markdown": "./src/markdown-log.js",
10
12
  "./package.json": "./package.json"
11
13
  },
@@ -2,7 +2,9 @@ import path from 'node:path';
2
2
  import {readdir,readFile,lstat,unlink,rmdir,mkdir,writeFile,appendFile,open} from 'node:fs/promises';
3
3
  import {FlowError} from '@mytools/sdk';
4
4
  import {markdownHeader,markdownEvent,markdownResult} from './markdown-log.js';
5
- import {logPolicy,jsonSize,preview,eventPreview} from './log-bounds.js';
5
+ import {logPolicy,jsonSize,eventPreview} from './log-bounds.js';
6
+ import {createStepSummaries} from './step-summaries.js';
7
+ import {previewRun} from './run-preview.js';
6
8
 
7
9
  const validId=id=>typeof id==='string'&&/^[a-f0-9-]{36}$/.test(id);
8
10
  const logFiles=['run.md','events.jsonl','result.json','run.meta.json'];
@@ -68,15 +70,15 @@ export function createRunLogs(directory,{projectDir,allowLegacy=false,...options
68
70
  return {
69
71
  list,read,owner,policy:Object.freeze({...policy}),
70
72
  async start(initial,{onEvent=()=>{}}={}){
71
- const directory=location(initial.id),events=[],sizes=[],steps=new Map();
73
+ const directory=location(initial.id),events=[],sizes=[],steps=createStepSummaries(policy);
72
74
  await mkdir(root,{recursive:true});
73
75
  const rootStat=await lstat(root);if(!rootStat.isDirectory()||rootStat.isSymbolicLink())throw new FlowError('日志根目录无效');
74
76
  await mkdir(directory,{recursive:true});
75
77
  await safeDirectory(initial.id);
76
78
  for(const name of logFiles){try{await lstat(path.join(directory,name));throw new FlowError('运行日志已存在,拒绝覆盖');}catch(error){if(error.code!=='ENOENT')throw error;}}
77
79
  const markdownFile=path.join(directory,'run.md');
78
- let writes=Promise.resolve(),failure=null,pendingBytes=0,diskBytes=0,resultBytes=0,eventCount=0,eventBytes=2,eventsTruncated=false,stepSummaryBytes=2,stepsTruncated=false,finished=false;
79
- const snapshot=state=>structuredClone({...initial,...state,logOwner:owner,events,eventCount,eventBytes,eventsTruncated,stepSummaries:[...steps.values()].map(entry=>entry.event),stepSummaryBytes,stepsTruncated});
80
+ let writes=Promise.resolve(),failure=null,pendingBytes=0,diskBytes=0,resultBytes=0,eventCount=0,eventBytes=2,eventsTruncated=false,finished=false;
81
+ const snapshot=state=>structuredClone({...initial,...state,logOwner:owner,events,eventCount,eventBytes,eventsTruncated,...steps.snapshot()});
80
82
  const reject=error=>{failure??=error;const promise=Promise.reject(failure);promise.catch(()=>{});return promise;};
81
83
  function diskCheck(extra){if(policy.maxDiskBytes!==null&&diskBytes+extra>policy.maxDiskBytes)throw new FlowError('日志磁盘预算已满',{code:'LOG_DISK_LIMIT',details:{maxDiskBytes:policy.maxDiskBytes}});}
82
84
  const queue=(bytes,operation)=>{
@@ -95,28 +97,11 @@ export function createRunLogs(directory,{projectDir,allowLegacy=false,...options
95
97
  while(events.length>policy.maxEvents||eventBytes>policy.maxMemoryBytes){
96
98
  const removed=sizes.shift();events.shift();eventBytes-=removed+(events.length?1:0);eventsTruncated=true;
97
99
  }
98
- if(typeof full.type==='string'&&full.type.startsWith('step.')&&(full.callPath!==undefined||full.stepId!==undefined)){
99
- const lightweight={};
100
- for(const key of ['type','callPath','stepId','action','time','seq','durationMs','reason','error'])if(full[key]!==undefined)lightweight[key]=full[key];
101
- const summary=eventPreview(lightweight,policy.maxEventBytes),key=summary.callPath??summary.stepId;
102
- if(key!==undefined&&summary.callPath===full.callPath&&summary.stepId===full.stepId){
103
- const previous=steps.get(key);
104
- if(previous){steps.delete(key);stepSummaryBytes-=previous.bytes+(steps.size?1:0);}
105
- const bytes=Buffer.byteLength(JSON.stringify(summary));steps.set(key,{event:summary,bytes});stepSummaryBytes+=bytes+(steps.size>1?1:0);
106
- while(steps.size>policy.maxEvents||stepSummaryBytes>policy.maxMemoryBytes){
107
- const oldest=steps.keys().next().value,removed=steps.get(oldest);steps.delete(oldest);stepSummaryBytes-=removed.bytes+(steps.size?1:0);stepsTruncated=true;
108
- }
109
- }else stepsTruncated=true;
110
- }
100
+ steps.append(full);
111
101
  onEvent(structuredClone(event));
112
102
  }
113
103
  function metadata(state){
114
- const run={...initial,...state,logOwner:owner,events,eventCount,eventBytes,eventsTruncated,stepSummaries:[...steps.values()].map(entry=>entry.event),stepSummaryBytes,stepsTruncated};
115
- for(const key of Object.keys(run)){
116
- if(['events','stepSummaries','id','workflowId','logOwner','status','startedAt','finishedAt'].includes(key))continue;
117
- if(run[key]===undefined)continue;
118
- const bounded=preview(run[key],policy.maxEventBytes);run[key]=bounded.value;if(bounded.truncated)run[`${key}Truncated`]=true;
119
- }
104
+ const run=previewRun({...initial,...state,logOwner:owner,events,eventCount,eventBytes,eventsTruncated,...steps.snapshot()},{maxValueBytes:policy.maxEventBytes});
120
105
  const text=JSON.stringify(run);
121
106
  if(Buffer.byteLength(text)>metadataLimit)throw new FlowError('运行记录元数据超过写入预算',{code:'LOG_METADATA_LIMIT'});
122
107
  return text;
@@ -0,0 +1,18 @@
1
+ import {FlowError} from '@mytools/sdk';
2
+ import {defaults,preview,jsonSize} from './log-bounds.js';
3
+
4
+ const retainedFields=new Set(['events','stepSummaries','id','workflowId','logOwner','status','startedAt','finishedAt']);
5
+
6
+ // 路由信息与已有独立预算的缓存保留;业务正文和诊断字段只投影有界预览。
7
+ export function previewRun(record,{maxValueBytes=defaults.maxEventBytes}={}){
8
+ if(!Number.isSafeInteger(maxValueBytes)||maxValueBytes<2)throw new FlowError('结果预览 maxValueBytes 必须为至少 2 的整数字节数');
9
+ const run={...record};
10
+ for(const key of Object.keys(run)){
11
+ if(retainedFields.has(key)||run[key]===undefined)continue;
12
+ const bounded=preview(run[key],maxValueBytes);
13
+ if(jsonSize(bounded.value,maxValueBytes)>maxValueBytes)delete run[key];
14
+ else run[key]=bounded.value;
15
+ if(bounded.truncated)run[`${key}Truncated`]=true;
16
+ }
17
+ return run;
18
+ }
@@ -0,0 +1,28 @@
1
+ import {logPolicy,eventPreview} from './log-bounds.js';
2
+
3
+ // 日志写入器与展示端共用轻量摘要,避免各端重新缓存业务 input/output。
4
+ export function createStepSummaries(options={}){
5
+ const policy=logPolicy(options),steps=new Map();
6
+ let bytes=2,truncated=false;
7
+ return {
8
+ append(full){
9
+ if(typeof full.type!=='string'||!full.type.startsWith('step.')||(full.callPath===undefined&&full.stepId===undefined))return;
10
+ const lightweight={};
11
+ for(const key of ['type','callPath','stepId','action','time','seq','durationMs','reason','error'])if(full[key]!==undefined)lightweight[key]=full[key];
12
+ const summary=eventPreview(lightweight,policy.maxEventBytes),key=summary.callPath??summary.stepId;
13
+ if(key===undefined||summary.callPath!==full.callPath||summary.stepId!==full.stepId){truncated=true;return;}
14
+ const previous=steps.get(key);
15
+ if(previous){steps.delete(key);bytes-=previous.bytes+(steps.size?1:0);}
16
+ const size=Buffer.byteLength(JSON.stringify(summary));
17
+ steps.set(key,{event:summary,bytes:size});bytes+=size+(steps.size>1?1:0);
18
+ while(steps.size>policy.maxEvents||bytes>policy.maxMemoryBytes){
19
+ const oldest=steps.keys().next().value,removed=steps.get(oldest);
20
+ steps.delete(oldest);bytes-=removed.bytes+(steps.size?1:0);truncated=true;
21
+ }
22
+ },
23
+ get size(){return steps.size;},
24
+ get(key){return structuredClone(steps.get(key)?.event);},
25
+ values(){return [...steps.values()].map(entry=>structuredClone(entry.event));},
26
+ snapshot(){return {stepSummaries:this.values(),stepSummaryBytes:bytes,stepsTruncated:truncated};},
27
+ };
28
+ }
@@ -16,6 +16,7 @@
16
16
  | `@mytools/fst-platform/bundle` | `./src/bundle.js` |
17
17
  | `@mytools/fst-platform/json` | `./src/json.js` |
18
18
  | `@mytools/fst-platform/npm` | `./src/npm.js` |
19
+ | `@mytools/fst-platform/network` | `./src/network.js` |
19
20
  | `@mytools/fst-platform/fs` | `./src/file-probe.js` |
20
21
  | `@mytools/fst-platform/package.json` | `./package.json` |
21
22
 
@@ -31,6 +32,11 @@
31
32
  node --test tests/modules.test.js tests/process.test.js tests/environments.test.js
32
33
  ```
33
34
 
35
+ ## 子进程环境
36
+
37
+ `runProcess(file,args,{env,extendEnv=true})` 默认把 `env` 覆盖到宿主环境上。
38
+ 当调用方已按动作代理规则构造完整环境时,传 `extendEnv:false`,防止已移除的代理变量被重新继承。
39
+
34
40
  ## 隔离文件探测
35
41
 
36
42
  `createFileProbe({timeout=2000,maxConcurrent=2,maxPending=16,allowRemote=false})` 创建只读探测器。
@@ -7,7 +7,7 @@ import path from 'node:path';
7
7
  const outputText=bytes=>bytes??new Uint8Array();
8
8
  const stripFinalNewline=text=>text.endsWith('\r\n')?text.slice(0,-2):text.endsWith('\n')?text.slice(0,-1):text;
9
9
 
10
- export async function runProcess(file,args=[],{cwd,env,signal,timeout=30000,maxBuffer=4*1024*1024,encoding='utf8',onOutput,outputMode='buffer',summaryBytes=65536}={}) {
10
+ export async function runProcess(file,args=[],{cwd,env,extendEnv=true,signal,timeout=30000,maxBuffer=4*1024*1024,encoding='utf8',onOutput,outputMode='buffer',summaryBytes=65536}={}) {
11
11
  if(!['buffer','stream'].includes(outputMode))throw new TypeError('outputMode 必须为 buffer 或 stream');
12
12
  const streaming=outputMode==='stream';
13
13
  if(streaming&&typeof onOutput!=='function')throw new TypeError('stream 模式必须提供 onOutput 消费输出');
@@ -34,7 +34,7 @@ export async function runProcess(file,args=[],{cwd,env,signal,timeout=30000,maxB
34
34
  }
35
35
  catch(cause){throw new FlowError(`未找到程序:${file}`,{code:'ENOENT',details:{executable:file},cause});}
36
36
  // Execa 收集原始字节;文本只在这里按同一编码解码,避免返回值被默认 UTF-8 解码。
37
- const child=execa(executable,args,{cwd,env,reject:false,windowsHide:true,maxBuffer,buffer:!streaming,timeout:0,shell:false,encoding:'buffer',stripFinalNewline:false});
37
+ const child=execa(executable,args,{cwd,env,extendEnv,reject:false,windowsHide:true,maxBuffer,buffer:!streaming,timeout:0,shell:false,encoding:'buffer',stripFinalNewline:false});
38
38
  let reason,killing,outputError,outputFailed=false,outputWrites=Promise.resolve();
39
39
  let markStopped;const stopped=new Promise(resolve=>{markStopped=resolve;});
40
40
  const outputBytes={stdout:0,stderr:0},tails={stdout:'',stderr:''};let outputTruncated=false;
@@ -57,7 +57,8 @@ export async function createHost({ plugins = [], config = {}, services = {}, pro
57
57
  const http=createHttpClient(resolved,{signal:context.signal});
58
58
  if(typeof bound.runProcess==='function'){
59
59
  const execute=bound.runProcess;
60
- bound.runProcess=(file,args,options={})=>execute(file,args,{...options,env:buildProcessEnv(resolved,{overrides:options.env})});
60
+ // 已构造完整环境;禁止进程层再次合并宿主代理,恢复被策略移除的变量。
61
+ bound.runProcess=(file,args,options={})=>execute(file,args,{...options,env:buildProcessEnv(resolved,{overrides:options.env}),extendEnv:false});
61
62
  }
62
63
  bound.http={fetch:(input,options)=>http.fetch(input,options)};
63
64
  return {capabilities:{...bound,projectDir,stateDir,environments},close:()=>http.close()};
@@ -100,7 +101,7 @@ export async function createHost({ plugins = [], config = {}, services = {}, pro
100
101
  const runtime=createRuntime(ctx,definitions,{enabled:runtimeEnabled});
101
102
  let unusable=false;
102
103
  const assertReadable=()=>{
103
- if(unusable)throw new FlowError('插件更新和恢复均失败,请重启应用',{code:'PLUGIN_RUNTIME_UNUSABLE'});
104
+ if(unusable)throw new FlowError('插件运行状态无法确认,请重启应用',{code:'PLUGIN_RUNTIME_UNUSABLE'});
104
105
  };
105
106
  const assertHealthy=()=>{runtime.assertOpen();assertReadable();};
106
107
  function registerPlugin(plugin,settings){
@@ -151,7 +152,8 @@ export async function createHost({ plugins = [], config = {}, services = {}, pro
151
152
  const oldDefinitions=new Map([...definitions].filter(([id])=>selected.has(id)));
152
153
  const oldSettings=new Map([...settingsById].filter(([id])=>selected.has(id)));
153
154
  const switchDefinitions=async(targets,settings)=>{
154
- await runtime.remove(selected);
155
+ try{await runtime.remove(selected);}
156
+ catch(error){unusable=true;throw error;}
155
157
  for(const id of selected){definitions.delete(id);settingsById.delete(id);}
156
158
  for(const [id,plugin] of targets)if(selected.has(id)){
157
159
  definitions.set(id,plugin);
@@ -165,8 +167,9 @@ export async function createHost({ plugins = [], config = {}, services = {}, pro
165
167
  for(const [id,plugin] of next)if(selected.has(id))nextSettings.set(id,validate(plugin.config,nextConfig[id]??{},`${id} 配置`));
166
168
  try{await switchDefinitions(next,nextSettings);}
167
169
  catch(error){
170
+ if(unusable)throw error;
168
171
  try{
169
- runtime.clearErrors();
172
+ runtime.clearActivationErrors();
170
173
  await switchDefinitions(oldDefinitions,oldSettings);
171
174
  }catch(recovery){unusable=true;throw new AggregateError([error,recovery],`插件更新失败且恢复失败:${error.message};${recovery.message}`);}
172
175
  throw error;
@@ -36,11 +36,11 @@ export function createRuntime(ctx,definitions,{enabled=true}={}){
36
36
  const reservedContext=new ctx.constructor();
37
37
  let graph=validateRuntimes(definitions,reservedContext);
38
38
  const forks=new Map(),scopes=new Map(),errors=[],pending=new Set();let closing;
39
- const record=(id,error)=>errors.push({plugin:id,message:error?.message??String(error)});
39
+ const record=(id,error,phase='activation')=>errors.push({plugin:id,phase,message:error?.message??String(error)});
40
40
  const track=(id,callback)=>{
41
41
  // 同步撤销注册,只有异步尾部进入等待集合,避免服务重激活时旧清理删掉新注册。
42
- let result;try{result=callback();}catch(error){record(id,error);return;}
43
- const task=Promise.resolve(result).catch(e=>record(id,e)).finally(()=>pending.delete(task));
42
+ let result;try{result=callback();}catch(error){record(id,error,'dispose');return;}
43
+ const task=Promise.resolve(result).catch(e=>record(id,e,'dispose')).finally(()=>pending.delete(task));
44
44
  pending.add(task);return task;
45
45
  };
46
46
  // 3.18.1 的 reset 会调用 dispose,但不等待 Promise;此适配只补齐等待和错误传播。
@@ -72,7 +72,7 @@ export function createRuntime(ctx,definitions,{enabled=true}={}){
72
72
  // 初始化可能仍在取得资源;先等其结束,再执行本次激活登记的清理。
73
73
  while(activation.tasks.size)await Promise.allSettled([...activation.tasks]);
74
74
  for(const dispose of activation.disposers.splice(0)){
75
- try{await dispose();}catch(error){record(plugin.id,error);}
75
+ try{await dispose();}catch(error){record(plugin.id,error,'dispose');}
76
76
  }
77
77
  })();
78
78
  });
@@ -100,7 +100,10 @@ export function createRuntime(ctx,definitions,{enabled=true}={}){
100
100
  await ctx.start();await runtime.ready();
101
101
  },
102
102
  reindex(){graph=validateRuntimes(definitions,reservedContext);},
103
- clearErrors(){errors.length=0;},
103
+ clearActivationErrors(){
104
+ // 启动失败可以在清理成功后恢复;清理失败必须保留,不能假定资源已释放。
105
+ for(let index=errors.length-1;index>=0;index--)if(errors[index].phase!=='dispose')errors.splice(index,1);
106
+ },
104
107
  async remove(ids){
105
108
  // 先撤销消费者再撤销提供者;旧作用域清理结束后才能注册新作用域。
106
109
  const selected=new Set(ids);