@yanglaofish/fishtools 0.4.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.
- package/README.md +33 -5
- package/bin/fst.js +1 -1
- package/manual/README.md +33 -5
- package/manual/docs/deployment.md +3 -3
- package/manual/docs/kernel-generation.md +97 -0
- package/manual/docs/modules.md +57 -0
- package/manual/docs/plugin-development.md +120 -10
- package/manual/docs/releases.md +24 -0
- package/manual/docs/research/2026-10-11-plugin-kernel-capabilities.md +6 -6
- package/manual/docs/roadmap.md +14 -3
- package/manual/docs/superpowers/plans/2026-10-09-workflow-mvp.md +5 -5
- package/manual/docs/superpowers/plans/2026-10-11-public-plugin-fixes.md +2 -2
- package/manual/docs/superpowers/plans/2026-10-12-kernel-generation.md +22 -0
- package/manual/docs/superpowers/plans/2026-10-12-modular-kernel.md +40 -0
- package/manual/docs/superpowers/reviews/2026-10-12-kernel-generation-review.md +27 -0
- package/manual/docs/superpowers/reviews/2026-10-12-modular-kernel-review.md +32 -0
- package/manual/docs/superpowers/reviews/2026-10-13-0.5.0-followup-review.md +67 -0
- package/manual/docs/superpowers/reviews/2026-10-13-kernel-runtime-release-review.md +19 -0
- package/manual/docs/superpowers/specs/2026-10-12-action-network-policy-design.md +52 -0
- package/manual/docs/superpowers/specs/2026-10-12-kernel-generation-design.md +38 -0
- package/manual/docs/superpowers/specs/2026-10-12-modular-kernel-design.md +70 -0
- package/manual/docs/superpowers/specs/2026-10-12-plugin-readiness-design.md +21 -0
- package/manual/docs/superpowers/specs/2026-10-12-runtime-maintenance-granularity-design.md +35 -0
- package/manual/docs/superpowers/specs/2026-10-12-workflow-variation-design.md +19 -0
- package/manual/docs/verification.md +55 -2
- package/manual/packages/core/README.md +44 -0
- package/manual/packages/core/package.json +38 -0
- package/manual/packages/core/src/application.js +251 -85
- package/manual/packages/core/src/bundle.js +2 -0
- package/manual/packages/core/src/development-watch-worker.js +69 -0
- package/manual/packages/core/src/development.js +146 -0
- package/manual/packages/core/src/environments.js +2 -0
- package/manual/packages/core/src/export-snapshot.js +42 -0
- package/manual/packages/core/src/plugin-update.js +108 -0
- package/manual/packages/core/src/process.js +2 -0
- package/manual/packages/core/src/resources.js +31 -0
- package/manual/packages/core/src/version.js +2 -0
- package/manual/packages/fst-cli/LICENSE +21 -0
- package/manual/packages/fst-cli/README.md +59 -0
- package/{node_modules/@mytools/core → manual/packages/fst-cli}/bin/fst.js +55 -23
- package/manual/packages/fst-cli/package.json +50 -0
- package/manual/packages/fst-cli/src/development.js +53 -0
- package/{node_modules/@mytools/core → manual/packages/fst-cli}/src/terminal/model.js +1 -1
- package/{node_modules/@mytools/core → manual/packages/fst-cli}/src/terminal/navigation.js +1 -1
- package/{node_modules/@mytools/core → manual/packages/fst-cli}/src/terminal/presentation.js +9 -1
- package/manual/packages/fst-cli/src/terminal/run-feed.js +119 -0
- package/{node_modules/@mytools/core → manual/packages/fst-cli}/src/terminal/run-viewer.js +9 -7
- package/{node_modules/@mytools/core → manual/packages/fst-cli}/src/terminal/schema.js +1 -1
- package/manual/packages/{core → fst-cli}/src/terminal/session.js +137 -9
- package/manual/packages/{core → fst-cli}/src/terminal/view.js +20 -3
- package/{node_modules/@mytools/core → manual/packages/fst-cli}/src/terminal/workflow-menu.js +17 -2
- package/manual/packages/fst-devtools/LICENSE +21 -0
- package/manual/packages/fst-devtools/README.md +28 -0
- package/manual/packages/fst-devtools/package.json +29 -0
- package/{node_modules/@mytools/core → manual/packages/fst-devtools}/src/documentation.js +3 -1
- package/manual/packages/fst-devtools/src/plugin-quality.js +136 -0
- package/{node_modules/@mytools/core → manual/packages/fst-devtools}/src/plugin-tools.js +24 -5
- package/manual/packages/fst-logs/LICENSE +21 -0
- package/manual/packages/fst-logs/README.md +67 -0
- package/manual/packages/fst-logs/package.json +26 -0
- package/manual/packages/fst-logs/src/log-bounds.js +91 -0
- package/manual/packages/fst-logs/src/run-logs.js +224 -0
- package/manual/packages/fst-logs/src/run-preview.js +18 -0
- package/manual/packages/fst-logs/src/step-summaries.js +28 -0
- package/manual/packages/fst-platform/LICENSE +21 -0
- package/manual/packages/fst-platform/README.md +73 -0
- package/manual/packages/fst-platform/package.json +33 -0
- package/manual/packages/fst-platform/src/bundle.js +17 -0
- package/manual/packages/fst-platform/src/environments.js +67 -0
- package/manual/packages/fst-platform/src/file-probe-engine.js +183 -0
- package/manual/packages/fst-platform/src/file-probe-worker.js +43 -0
- package/manual/packages/fst-platform/src/file-probe.js +6 -0
- package/manual/packages/fst-platform/src/json.js +5 -0
- package/manual/packages/fst-platform/src/network.js +199 -0
- package/manual/packages/fst-platform/src/npm.js +11 -0
- package/manual/packages/fst-platform/src/process.js +110 -0
- package/manual/packages/fst-plugin/LICENSE +21 -0
- package/manual/packages/fst-plugin/README.md +29 -0
- package/manual/packages/fst-plugin/package.json +29 -0
- package/{node_modules/@mytools/core → manual/packages/fst-plugin}/src/contracts.js +30 -3
- package/manual/packages/fst-plugin/src/host.js +235 -0
- package/manual/packages/fst-plugin/src/loader.js +21 -0
- package/manual/packages/fst-plugin/src/privileges.js +91 -0
- package/{node_modules/@mytools/core → manual/packages/fst-plugin}/src/runtime.js +27 -8
- package/manual/packages/fst-workflow/LICENSE +21 -0
- package/manual/packages/fst-workflow/README.md +28 -0
- package/manual/packages/fst-workflow/package.json +24 -0
- package/{node_modules/@mytools/core → manual/packages/fst-workflow}/src/control.js +3 -2
- package/manual/packages/{core → fst-workflow}/src/engine.js +2 -2
- package/manual/packages/fst-workspace/LICENSE +21 -0
- package/manual/packages/fst-workspace/README.md +32 -0
- package/manual/packages/fst-workspace/package.json +34 -0
- package/{node_modules/@mytools/core → manual/packages/fst-workspace}/src/bundle-workspace.js +1 -1
- package/manual/packages/fst-workspace/src/index.js +3 -0
- package/{node_modules/@mytools/core → manual/packages/fst-workspace}/src/plugins.js +6 -36
- package/{node_modules/@mytools/core → manual/packages/fst-workspace}/src/workflows.js +40 -7
- package/manual/packages/pack/LICENSE +21 -0
- package/manual/packages/pack/README.md +31 -0
- package/manual/packages/pack/archive.js +15 -0
- package/manual/packages/pack/dependencies.js +105 -0
- package/manual/packages/pack/environments.js +50 -0
- package/manual/packages/pack/index.js +103 -0
- package/manual/packages/pack/package.json +29 -0
- package/manual/packages/pack/runtime.js +61 -0
- package/manual/packages/sdk/README.md +31 -0
- package/manual/packages/sdk/conditions.js +1 -0
- package/manual/packages/sdk/package.json +23 -1
- package/manual/plugins/web/README.md +29 -0
- package/manual/tests/application.test.js +6 -4
- package/node_modules/@mytools/core/README.md +44 -0
- package/node_modules/@mytools/core/package.json +17 -17
- package/node_modules/@mytools/core/src/application.js +251 -85
- package/node_modules/@mytools/core/src/bundle.js +2 -17
- package/node_modules/@mytools/core/src/development-watch-worker.js +69 -0
- package/node_modules/@mytools/core/src/development.js +146 -0
- package/node_modules/@mytools/core/src/environments.js +2 -67
- package/node_modules/@mytools/core/src/export-snapshot.js +42 -0
- package/node_modules/@mytools/core/src/plugin-update.js +108 -0
- package/node_modules/@mytools/core/src/process.js +2 -80
- package/node_modules/@mytools/core/src/version.js +1 -1
- package/node_modules/@mytools/fst-cli/LICENSE +21 -0
- package/node_modules/@mytools/fst-cli/README.md +59 -0
- package/node_modules/@mytools/fst-cli/bin/fst.js +124 -0
- package/node_modules/@mytools/fst-cli/package.json +50 -0
- package/node_modules/@mytools/fst-cli/src/cli-run.js +12 -0
- package/node_modules/@mytools/fst-cli/src/development.js +53 -0
- package/node_modules/@mytools/fst-cli/src/terminal/conditions.js +32 -0
- package/node_modules/@mytools/fst-cli/src/terminal/log-viewer.js +45 -0
- package/node_modules/@mytools/fst-cli/src/terminal/model.js +104 -0
- package/node_modules/@mytools/fst-cli/src/terminal/navigation.js +17 -0
- package/node_modules/@mytools/fst-cli/src/terminal/presentation.js +50 -0
- package/node_modules/@mytools/fst-cli/src/terminal/run-feed.js +119 -0
- package/node_modules/@mytools/fst-cli/src/terminal/run-presentation.js +60 -0
- package/node_modules/@mytools/fst-cli/src/terminal/run-viewer.js +106 -0
- package/node_modules/@mytools/fst-cli/src/terminal/schema.js +52 -0
- package/node_modules/@mytools/{core → fst-cli}/src/terminal/session.js +137 -9
- package/node_modules/@mytools/fst-cli/src/terminal/theme.js +29 -0
- package/node_modules/@mytools/{core → fst-cli}/src/terminal/view.js +20 -3
- package/node_modules/@mytools/fst-cli/src/terminal/workflow-menu.js +65 -0
- package/node_modules/@mytools/fst-devtools/LICENSE +21 -0
- package/node_modules/@mytools/fst-devtools/README.md +28 -0
- package/node_modules/@mytools/fst-devtools/package.json +29 -0
- package/node_modules/@mytools/fst-devtools/src/documentation.js +67 -0
- package/node_modules/@mytools/fst-devtools/src/plugin-quality.js +136 -0
- package/node_modules/@mytools/fst-devtools/src/plugin-tools.js +152 -0
- package/node_modules/@mytools/fst-logs/LICENSE +21 -0
- package/node_modules/@mytools/fst-logs/README.md +67 -0
- package/node_modules/@mytools/fst-logs/package.json +26 -0
- package/node_modules/@mytools/fst-logs/src/log-bounds.js +91 -0
- package/node_modules/@mytools/fst-logs/src/markdown-log.js +55 -0
- package/node_modules/@mytools/fst-logs/src/run-logs.js +224 -0
- package/node_modules/@mytools/fst-logs/src/run-preview.js +18 -0
- package/node_modules/@mytools/fst-logs/src/step-summaries.js +28 -0
- package/node_modules/@mytools/fst-platform/LICENSE +21 -0
- package/node_modules/@mytools/fst-platform/README.md +73 -0
- package/node_modules/@mytools/fst-platform/package.json +33 -0
- package/node_modules/@mytools/fst-platform/src/bundle.js +17 -0
- package/node_modules/@mytools/fst-platform/src/environments.js +67 -0
- package/node_modules/@mytools/fst-platform/src/file-probe-engine.js +183 -0
- package/node_modules/@mytools/fst-platform/src/file-probe-worker.js +43 -0
- package/node_modules/@mytools/fst-platform/src/file-probe.js +6 -0
- package/node_modules/@mytools/fst-platform/src/json.js +5 -0
- package/node_modules/@mytools/fst-platform/src/network.js +199 -0
- package/node_modules/@mytools/fst-platform/src/npm.js +11 -0
- package/node_modules/@mytools/fst-platform/src/process.js +110 -0
- package/node_modules/@mytools/fst-plugin/LICENSE +21 -0
- package/node_modules/@mytools/fst-plugin/README.md +29 -0
- package/node_modules/@mytools/fst-plugin/package.json +29 -0
- package/node_modules/@mytools/fst-plugin/src/contracts.js +86 -0
- package/node_modules/@mytools/fst-plugin/src/host.js +235 -0
- package/node_modules/@mytools/fst-plugin/src/loader.js +21 -0
- package/node_modules/@mytools/fst-plugin/src/privileges.js +91 -0
- package/{manual/packages/core → node_modules/@mytools/fst-plugin}/src/runtime.js +27 -8
- package/node_modules/@mytools/fst-workflow/LICENSE +21 -0
- package/node_modules/@mytools/fst-workflow/README.md +28 -0
- package/node_modules/@mytools/fst-workflow/package.json +24 -0
- package/node_modules/@mytools/fst-workflow/src/control.js +64 -0
- package/node_modules/@mytools/{core → fst-workflow}/src/engine.js +2 -2
- package/node_modules/@mytools/fst-workspace/LICENSE +21 -0
- package/node_modules/@mytools/fst-workspace/README.md +32 -0
- package/node_modules/@mytools/fst-workspace/package.json +34 -0
- package/node_modules/@mytools/fst-workspace/src/bundle-workspace.js +39 -0
- package/node_modules/@mytools/fst-workspace/src/index.js +3 -0
- package/node_modules/@mytools/fst-workspace/src/plugin-installations.js +84 -0
- package/node_modules/@mytools/fst-workspace/src/plugins.js +35 -0
- package/node_modules/@mytools/fst-workspace/src/project.js +29 -0
- package/node_modules/@mytools/fst-workspace/src/workflows.js +150 -0
- package/node_modules/@mytools/fst-workspace/templates/control-demo.yaml +47 -0
- package/node_modules/@mytools/fst-workspace/templates/hello.yaml +35 -0
- package/node_modules/@mytools/fst-workspace/templates/python-check.yaml +26 -0
- package/node_modules/@mytools/fst-workspace/templates/system-check.yaml +11 -0
- package/node_modules/@mytools/pack/README.md +31 -0
- package/node_modules/@mytools/pack/dependencies.js +7 -2
- package/node_modules/@mytools/pack/environments.js +1 -1
- package/node_modules/@mytools/pack/index.js +33 -25
- package/node_modules/@mytools/pack/package.json +29 -1
- package/node_modules/@mytools/sdk/README.md +31 -0
- package/node_modules/@mytools/sdk/conditions.js +1 -0
- package/node_modules/@mytools/sdk/diagnostics.js +27 -0
- package/node_modules/@mytools/sdk/package.json +23 -1
- package/node_modules/@mytools/sdk/schema-path.js +31 -0
- package/node_modules/@mytools/sdk/validation.js +78 -0
- package/node_modules/@mytools/web/README.md +29 -0
- package/node_modules/@mytools/web/package.json +7 -0
- package/node_modules/@mytools/web/public/THIRD_PARTY_NOTICES.txt +1440 -1240
- package/node_modules/@mytools/web/public/assets/index-BaVzHycP.js +70 -0
- package/node_modules/@mytools/web/public/assets/index-DA8Ae92t.css +1 -0
- package/node_modules/@mytools/web/public/index.html +2 -2
- package/node_modules/@mytools/web/server.js +24 -17
- package/node_modules/@mytools/web/src/model.js +84 -0
- package/node_modules/acorn/CHANGELOG.md +1012 -0
- package/node_modules/acorn/LICENSE +21 -0
- package/node_modules/acorn/README.md +309 -0
- package/node_modules/acorn/bin/acorn +4 -0
- package/node_modules/acorn/dist/acorn.d.mts +897 -0
- package/node_modules/acorn/dist/acorn.d.ts +897 -0
- package/node_modules/acorn/dist/acorn.js +6370 -0
- package/node_modules/acorn/dist/acorn.mjs +6341 -0
- package/node_modules/acorn/dist/bin.js +90 -0
- package/node_modules/acorn/package.json +50 -0
- package/node_modules/{minipass-flush/node_modules/minipass → tar}/node_modules/yallist/README.md +8 -7
- package/node_modules/tar/node_modules/yallist/package.json +68 -0
- package/node_modules/yallist/README.md +7 -8
- package/node_modules/yallist/package.json +12 -51
- package/package.json +131 -115
- package/manual/packages/core/src/host.js +0 -139
- package/manual/packages/core/src/terminal/run-feed.js +0 -78
- package/node_modules/@mytools/core/src/host.js +0 -139
- package/node_modules/@mytools/core/src/run-logs.js +0 -63
- package/node_modules/@mytools/core/src/terminal/run-feed.js +0 -78
- package/node_modules/@mytools/web/public/assets/index-DT2B4tMV.js +0 -70
- package/node_modules/@mytools/web/public/assets/index-vuZvrUTT.css +0 -1
- package/node_modules/minipass-flush/node_modules/minipass/node_modules/yallist/package.json +0 -29
- /package/{node_modules/@mytools/core → manual/packages/fst-cli}/src/cli-run.js +0 -0
- /package/{node_modules/@mytools/core → manual/packages/fst-cli}/src/terminal/conditions.js +0 -0
- /package/{node_modules/@mytools/core → manual/packages/fst-cli}/src/terminal/log-viewer.js +0 -0
- /package/{node_modules/@mytools/core → manual/packages/fst-cli}/src/terminal/run-presentation.js +0 -0
- /package/{node_modules/@mytools/core → manual/packages/fst-cli}/src/terminal/theme.js +0 -0
- /package/{node_modules/@mytools/core → manual/packages/fst-logs}/src/markdown-log.js +0 -0
- /package/{node_modules/@mytools/core → manual/packages/fst-workspace}/src/plugin-installations.js +0 -0
- /package/{node_modules/@mytools/core → manual/packages/fst-workspace}/src/project.js +0 -0
- /package/{node_modules/@mytools/core → manual/packages/fst-workspace}/templates/control-demo.yaml +0 -0
- /package/{node_modules/@mytools/core → manual/packages/fst-workspace}/templates/hello.yaml +0 -0
- /package/{node_modules/@mytools/core → manual/packages/fst-workspace}/templates/python-check.yaml +0 -0
- /package/{node_modules/@mytools/core → manual/packages/fst-workspace}/templates/system-check.yaml +0 -0
- /package/{node_modules/@mytools/core/src → manual/packages/sdk}/diagnostics.js +0 -0
- /package/{node_modules/@mytools/core/src → manual/packages/sdk}/schema-path.js +0 -0
- /package/{node_modules/@mytools/core/src → manual/packages/sdk}/validation.js +0 -0
- /package/node_modules/{yallist → tar/node_modules/yallist}/LICENSE.md +0 -0
- /package/node_modules/{yallist → tar/node_modules/yallist}/dist/commonjs/index.d.ts +0 -0
- /package/node_modules/{yallist → tar/node_modules/yallist}/dist/commonjs/index.d.ts.map +0 -0
- /package/node_modules/{yallist → tar/node_modules/yallist}/dist/commonjs/index.js +0 -0
- /package/node_modules/{yallist → tar/node_modules/yallist}/dist/commonjs/index.js.map +0 -0
- /package/node_modules/{yallist → tar/node_modules/yallist}/dist/commonjs/package.json +0 -0
- /package/node_modules/{yallist → tar/node_modules/yallist}/dist/esm/index.d.ts +0 -0
- /package/node_modules/{yallist → tar/node_modules/yallist}/dist/esm/index.d.ts.map +0 -0
- /package/node_modules/{yallist → tar/node_modules/yallist}/dist/esm/index.js +0 -0
- /package/node_modules/{yallist → tar/node_modules/yallist}/dist/esm/index.js.map +0 -0
- /package/node_modules/{yallist → tar/node_modules/yallist}/dist/esm/package.json +0 -0
- /package/node_modules/{minipass-flush/node_modules/minipass/node_modules/yallist → yallist}/LICENSE +0 -0
- /package/node_modules/{minipass-flush/node_modules/minipass/node_modules/yallist → yallist}/iterator.js +0 -0
- /package/node_modules/{minipass-flush/node_modules/minipass/node_modules/yallist → yallist}/yallist.js +0 -0
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
const names={
|
|
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
|
+
};
|
|
6
|
+
const statuses={running:'运行中',succeeded:'成功',failed:'失败',cancelled:'已取消',stopped:'已停止'};
|
|
7
|
+
|
|
8
|
+
// 用长于正文的围栏包裹插件文本,避免错误消息中的 Markdown 改写日志结构。
|
|
9
|
+
function block(value,language='text'){
|
|
10
|
+
const text=typeof value==='string'?value:JSON.stringify(value,null,2)??String(value);
|
|
11
|
+
let longest=2;for(const match of text.matchAll(/`+/g))longest=Math.max(longest,match[0].length);
|
|
12
|
+
const fence='`'.repeat(longest+1);
|
|
13
|
+
return `${fence}${language}\n${text}\n${fence}\n\n`;
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
function errorText(error){
|
|
17
|
+
let output='',depth=0;
|
|
18
|
+
for(let current=error;current;current=current.cause){
|
|
19
|
+
const lines=[];
|
|
20
|
+
for(const [key,label] of [['plugin','插件'],['action','动作'],['command','命令'],['code','错误码'],['message','原因']]){
|
|
21
|
+
if(current[key]!==undefined)lines.push(`${label}:${current[key]}`);
|
|
22
|
+
}
|
|
23
|
+
output+=`#### ${depth++?'原始错误':'错误信息'}\n\n`+block(lines.join('\n'));
|
|
24
|
+
if(current.details!==undefined)output+='详情:\n\n'+block(current.details,'json');
|
|
25
|
+
if(current.stack)output+='调用栈:\n\n'+block(current.stack);
|
|
26
|
+
}
|
|
27
|
+
return output;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
export function markdownHeader(run){
|
|
31
|
+
return '# Fishtools 运行日志\n\n'+block(`运行 ID:${run.id}\n工作流或命令:${run.workflowId}\n开始时间:${run.startedAt}`)+'## 执行过程\n\n';
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
export function markdownEvent(event){
|
|
35
|
+
let output=`### ${event.type==='log'&&event.audience==='user'?'用户输出':names[event.type]??'事件'}\n\n`;
|
|
36
|
+
const lines=[`时间:${event.time}`];
|
|
37
|
+
for(const [key,label] of [['callPath','调用路径'],['action','动作'],['plugin','插件'],['command','命令'],['stream','输出流'],['durationMs','耗时(毫秒)']]){
|
|
38
|
+
if(event[key]!==undefined)lines.push(`${label}:${event[key]}`);
|
|
39
|
+
}
|
|
40
|
+
output+=block(lines.join('\n'));
|
|
41
|
+
if(event.message)output+=block(event.message);
|
|
42
|
+
if(event.reason!==undefined)output+='停止或跳过原因:\n\n'+block(event.reason);
|
|
43
|
+
if(event.data!==undefined)output+='附加信息:\n\n'+block(event.data,'json');
|
|
44
|
+
if(event.input!==undefined)output+='步骤输入:\n\n'+block(event.input,'json');
|
|
45
|
+
if(event.output!==undefined)output+='步骤输出:\n\n'+block(event.output,'json');
|
|
46
|
+
if(event.error)output+=errorText(event.error);
|
|
47
|
+
return output;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
export function markdownResult(run){
|
|
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);
|
|
53
|
+
if(run.output!==undefined)output+=block(run.output,'json');
|
|
54
|
+
return output;
|
|
55
|
+
}
|
|
@@ -0,0 +1,224 @@
|
|
|
1
|
+
import path from 'node:path';
|
|
2
|
+
import {readdir,readFile,lstat,unlink,rmdir,mkdir,writeFile,appendFile,open} from 'node:fs/promises';
|
|
3
|
+
import {FlowError} from '@mytools/sdk';
|
|
4
|
+
import {markdownHeader,markdownEvent,markdownResult} from './markdown-log.js';
|
|
5
|
+
import {logPolicy,jsonSize,eventPreview} from './log-bounds.js';
|
|
6
|
+
import {createStepSummaries} from './step-summaries.js';
|
|
7
|
+
import {previewRun} from './run-preview.js';
|
|
8
|
+
|
|
9
|
+
const validId=id=>typeof id==='string'&&/^[a-f0-9-]{36}$/.test(id);
|
|
10
|
+
const logFiles=['run.md','events.jsonl','result.json','run.meta.json'];
|
|
11
|
+
function pageBuffer(buffer,offset,limit,cursor=offset,totalBytes=buffer.length){
|
|
12
|
+
if(offset>buffer.length)throw new FlowError('日志 cursor 超过正文长度',{code:'LOG_CURSOR_INVALID'});
|
|
13
|
+
if(offset<buffer.length&&(buffer[offset]&0xc0)===0x80)throw new FlowError('日志 cursor 必须位于 UTF-8 字符边界',{code:'LOG_CURSOR_INVALID'});
|
|
14
|
+
let end=Math.min(offset+limit,buffer.length);
|
|
15
|
+
if(end<buffer.length)while(end>offset&&(buffer[end]&0xc0)===0x80)end--;
|
|
16
|
+
const text=buffer.subarray(offset,end).toString('utf8'),nextCursor=cursor+end-offset;
|
|
17
|
+
return {text,cursor,nextCursor,hasMore:nextCursor<totalBytes,totalBytes};
|
|
18
|
+
}
|
|
19
|
+
export function validateKeepRuns(value){
|
|
20
|
+
if(value!==null&&(!Number.isSafeInteger(value)||value<1))throw new FlowError('日志保留次数必须为正整数;null 表示不自动清理');
|
|
21
|
+
return value;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
export function createRunLogs(directory,{projectDir,allowLegacy=false,...options}){
|
|
25
|
+
const root=path.resolve(directory);let cleaning=Promise.resolve();
|
|
26
|
+
const policy=logPolicy(options),metadataLimit=Math.max(2*1024*1024,2*policy.maxMemoryBytes+8*policy.maxEventBytes+65536);
|
|
27
|
+
const owner=process.platform==='win32'?path.resolve(projectDir).toLowerCase():path.resolve(projectDir);
|
|
28
|
+
function location(id){if(!validId(id))throw new FlowError('运行 ID 无效');return path.join(root,id);}
|
|
29
|
+
async function regular(file){try{return (await lstat(file)).isFile();}catch(error){if(error.code==='ENOENT')return false;throw error;}}
|
|
30
|
+
async function safeDirectory(id){
|
|
31
|
+
const stat=await lstat(root);if(!stat.isDirectory()||stat.isSymbolicLink())throw new FlowError('日志根目录无效');
|
|
32
|
+
const dir=location(id),child=await lstat(dir);if(!child.isDirectory()||child.isSymbolicLink())throw new FlowError('日志目录无效');
|
|
33
|
+
return dir;
|
|
34
|
+
}
|
|
35
|
+
async function safeFile(file){
|
|
36
|
+
const before=await lstat(file);if(!before.isFile()||before.isSymbolicLink())throw new FlowError('日志文件无效');
|
|
37
|
+
const handle=await open(file,'r');
|
|
38
|
+
try{const after=await handle.stat();if(!after.isFile()||before.ino!==after.ino||before.dev!==after.dev)throw new FlowError('日志文件无效');return handle;}
|
|
39
|
+
catch(error){await handle.close();throw error;}
|
|
40
|
+
}
|
|
41
|
+
async function read(id){
|
|
42
|
+
const dir=await safeDirectory(id);
|
|
43
|
+
let file=path.join(dir,'result.json');
|
|
44
|
+
try{await lstat(file);}catch(error){if(error.code!=='ENOENT')throw error;file=path.join(dir,'run.meta.json');}
|
|
45
|
+
if(!await regular(file))throw new FlowError('运行记录不存在');
|
|
46
|
+
const handle=await safeFile(file);let result;
|
|
47
|
+
try{
|
|
48
|
+
const stat=await handle.stat();if(stat.size>metadataLimit)throw new FlowError('运行记录元数据超过读取预算',{code:'LOG_METADATA_LIMIT'});
|
|
49
|
+
// 读固定预算,文件在检查后增长也不会形成无限内存读取。
|
|
50
|
+
const buffer=Buffer.alloc(Math.min(stat.size+1,metadataLimit+1)),{bytesRead}=await handle.read(buffer,0,buffer.length,0);
|
|
51
|
+
if(bytesRead>metadataLimit)throw new FlowError('运行记录元数据超过读取预算',{code:'LOG_METADATA_LIMIT'});
|
|
52
|
+
result=JSON.parse(buffer.subarray(0,bytesRead).toString('utf8'));
|
|
53
|
+
}finally{await handle.close();}
|
|
54
|
+
if(result.id!==id)throw new FlowError('运行记录 ID 不匹配');
|
|
55
|
+
if(result.logOwner!==owner&&!(allowLegacy&&result.logOwner===undefined))throw new FlowError('运行记录不属于此项目');
|
|
56
|
+
return result;
|
|
57
|
+
}
|
|
58
|
+
async function list(){
|
|
59
|
+
let entries;try{const stat=await lstat(root);if(!stat.isDirectory()||stat.isSymbolicLink())throw new FlowError('日志根目录无效');entries=await readdir(root,{withFileTypes:true});}catch(error){if(error.code==='ENOENT')return [];throw error;}
|
|
60
|
+
const results=[];
|
|
61
|
+
for(const entry of entries){
|
|
62
|
+
if(!entry.isDirectory()||!validId(entry.name))continue;
|
|
63
|
+
try{
|
|
64
|
+
const record=await read(entry.name);
|
|
65
|
+
if(typeof record.workflowId==='string'&&['succeeded','failed','cancelled','stopped'].includes(record.status)&&Number.isFinite(Date.parse(record.finishedAt)))results.push(record);
|
|
66
|
+
}catch(error){if(error.code==='EACCES'||error.code==='EPERM')throw error;}
|
|
67
|
+
}
|
|
68
|
+
return results.sort((a,b)=>Date.parse(b.finishedAt)-Date.parse(a.finishedAt)||b.id.localeCompare(a.id));
|
|
69
|
+
}
|
|
70
|
+
return {
|
|
71
|
+
list,read,owner,policy:Object.freeze({...policy}),
|
|
72
|
+
async start(initial,{onEvent=()=>{}}={}){
|
|
73
|
+
const directory=location(initial.id),events=[],sizes=[],steps=createStepSummaries(policy);
|
|
74
|
+
await mkdir(root,{recursive:true});
|
|
75
|
+
const rootStat=await lstat(root);if(!rootStat.isDirectory()||rootStat.isSymbolicLink())throw new FlowError('日志根目录无效');
|
|
76
|
+
await mkdir(directory,{recursive:true});
|
|
77
|
+
await safeDirectory(initial.id);
|
|
78
|
+
for(const name of logFiles){try{await lstat(path.join(directory,name));throw new FlowError('运行日志已存在,拒绝覆盖');}catch(error){if(error.code!=='ENOENT')throw error;}}
|
|
79
|
+
const markdownFile=path.join(directory,'run.md');
|
|
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()});
|
|
82
|
+
const reject=error=>{failure??=error;const promise=Promise.reject(failure);promise.catch(()=>{});return promise;};
|
|
83
|
+
function diskCheck(extra){if(policy.maxDiskBytes!==null&&diskBytes+extra>policy.maxDiskBytes)throw new FlowError('日志磁盘预算已满',{code:'LOG_DISK_LIMIT',details:{maxDiskBytes:policy.maxDiskBytes}});}
|
|
84
|
+
const queue=(bytes,operation)=>{
|
|
85
|
+
if(failure)return reject(failure);
|
|
86
|
+
if(pendingBytes+bytes>policy.maxPendingBytes)return reject(new FlowError('日志待写队列超过字节预算',{code:'LOG_QUEUE_LIMIT',details:{maxPendingBytes:policy.maxPendingBytes}}));
|
|
87
|
+
pendingBytes+=bytes;
|
|
88
|
+
const job=writes.then(operation).catch(error=>{failure??=error;throw error;}).finally(()=>{pendingBytes-=bytes;});
|
|
89
|
+
writes=job;
|
|
90
|
+
// 调用方仍收到失败;同时防止未 await 的插件日志产生未处理拒绝。
|
|
91
|
+
writes.catch(()=>{});return writes;
|
|
92
|
+
};
|
|
93
|
+
function retain(full){
|
|
94
|
+
const event=eventPreview(full,policy.maxEventBytes),size=Buffer.byteLength(JSON.stringify(event));
|
|
95
|
+
eventCount++;eventsTruncated ||= event.eventTruncated===true;
|
|
96
|
+
events.push(event);sizes.push(size);eventBytes+=size+(events.length>1?1:0);
|
|
97
|
+
while(events.length>policy.maxEvents||eventBytes>policy.maxMemoryBytes){
|
|
98
|
+
const removed=sizes.shift();events.shift();eventBytes-=removed+(events.length?1:0);eventsTruncated=true;
|
|
99
|
+
}
|
|
100
|
+
steps.append(full);
|
|
101
|
+
onEvent(structuredClone(event));
|
|
102
|
+
}
|
|
103
|
+
function metadata(state){
|
|
104
|
+
const run=previewRun({...initial,...state,logOwner:owner,events,eventCount,eventBytes,eventsTruncated,...steps.snapshot()},{maxValueBytes:policy.maxEventBytes});
|
|
105
|
+
const text=JSON.stringify(run);
|
|
106
|
+
if(Buffer.byteLength(text)>metadataLimit)throw new FlowError('运行记录元数据超过写入预算',{code:'LOG_METADATA_LIMIT'});
|
|
107
|
+
return text;
|
|
108
|
+
}
|
|
109
|
+
async function saveText(text){
|
|
110
|
+
const bytes=Buffer.byteLength(text);diskCheck(bytes-resultBytes);
|
|
111
|
+
await rejectLink(path.join(directory,'result.json'));
|
|
112
|
+
await safeDirectory(initial.id);
|
|
113
|
+
await writeFile(path.join(directory,'result.json'),text);diskBytes+=bytes-resultBytes;resultBytes=bytes;
|
|
114
|
+
}
|
|
115
|
+
async function rejectLink(file){try{if((await lstat(file)).isSymbolicLink())throw new FlowError('日志文件无效');}catch(error){if(error.code!=='ENOENT')throw error;}}
|
|
116
|
+
async function appendText(json,markdown){
|
|
117
|
+
const bytes=Buffer.byteLength(json)+Buffer.byteLength(markdown);diskCheck(bytes);
|
|
118
|
+
await safeDirectory(initial.id);await rejectLink(path.join(directory,'events.jsonl'));await rejectLink(markdownFile);
|
|
119
|
+
await appendFile(path.join(directory,'events.jsonl'),json);await appendFile(markdownFile,markdown);diskBytes+=bytes;
|
|
120
|
+
}
|
|
121
|
+
const header=markdownHeader(initial);diskCheck(Buffer.byteLength(header));
|
|
122
|
+
// 独占创建,拒绝覆盖旧日志和文件链接。
|
|
123
|
+
await writeFile(markdownFile,header,{flag:'wx'});diskBytes+=Buffer.byteLength(header);
|
|
124
|
+
const ownership=JSON.stringify({id:initial.id,workflowId:initial.workflowId,startedAt:initial.startedAt,status:'running',logOwner:owner});
|
|
125
|
+
diskCheck(Buffer.byteLength(ownership));await writeFile(path.join(directory,'run.meta.json'),ownership,{flag:'wx'});diskBytes+=Buffer.byteLength(ownership);
|
|
126
|
+
return {
|
|
127
|
+
directory,snapshot,
|
|
128
|
+
markdown:state=>markdownHeader({...initial,...state})+events.map(markdownEvent).join(''),
|
|
129
|
+
append(event){
|
|
130
|
+
if(finished)return reject(new FlowError('日志已经完成',{code:'LOG_CLOSED'}));
|
|
131
|
+
if(failure)return reject(failure);
|
|
132
|
+
try{
|
|
133
|
+
const full={time:new Date().toISOString(),...event,runId:initial.id,seq:eventCount+1};
|
|
134
|
+
if(jsonSize(full,policy.maxPendingBytes)>policy.maxPendingBytes)throw new FlowError('单条日志超过待写字节预算',{code:'LOG_QUEUE_LIMIT'});
|
|
135
|
+
const json=JSON.stringify(full)+'\n',markdown=markdownEvent(full),bytes=Buffer.byteLength(json)+Buffer.byteLength(markdown);
|
|
136
|
+
if(pendingBytes+bytes>policy.maxPendingBytes)throw new FlowError('日志待写队列超过字节预算',{code:'LOG_QUEUE_LIMIT'});
|
|
137
|
+
retain(full);return queue(bytes,()=>appendText(json,markdown));
|
|
138
|
+
}catch(error){return reject(error);}
|
|
139
|
+
},
|
|
140
|
+
finish(state){
|
|
141
|
+
if(failure)return reject(failure);
|
|
142
|
+
if(finished)return reject(new FlowError('日志已经完成',{code:'LOG_CLOSED'}));
|
|
143
|
+
try{
|
|
144
|
+
const full={...state,time:new Date().toISOString(),runId:initial.id,type:'run.result',seq:eventCount+1};
|
|
145
|
+
if(jsonSize(full,policy.maxPendingBytes)>policy.maxPendingBytes)throw new FlowError('最终结果超过待写字节预算',{code:'LOG_QUEUE_LIMIT'});
|
|
146
|
+
const json=JSON.stringify(full)+'\n',markdown=markdownResult({...initial,...state}),bytes=Buffer.byteLength(json)+Buffer.byteLength(markdown);
|
|
147
|
+
if(pendingBytes+bytes>policy.maxPendingBytes)throw new FlowError('日志待写队列超过字节预算',{code:'LOG_QUEUE_LIMIT'});
|
|
148
|
+
// 最终业务结果已由快照提供;JSONL 补充完整记录,不重复广播到插件事件预览。
|
|
149
|
+
eventCount++;finished=true;
|
|
150
|
+
return queue(bytes,async()=>{const text=metadata(state);diskCheck(bytes+Buffer.byteLength(text)-resultBytes);await appendText(json,markdown);await saveText(text);});
|
|
151
|
+
}catch(error){return reject(error);}
|
|
152
|
+
},
|
|
153
|
+
save(state){try{const text=metadata(state);return queue(Buffer.byteLength(text),()=>saveText(text));}catch(error){return reject(error);}},
|
|
154
|
+
};
|
|
155
|
+
},
|
|
156
|
+
async usage(){
|
|
157
|
+
const report={directory:root,bytes:0,files:0,incomplete:false};
|
|
158
|
+
let entries;
|
|
159
|
+
try{
|
|
160
|
+
const stat=await lstat(root);
|
|
161
|
+
if(!stat.isDirectory()||stat.isSymbolicLink()){report.incomplete=true;return report;}
|
|
162
|
+
entries=await readdir(root,{withFileTypes:true});
|
|
163
|
+
}
|
|
164
|
+
catch(error){if(error.code!=='ENOENT')report.incomplete=true;return report;}
|
|
165
|
+
for(const entry of entries){
|
|
166
|
+
if(!entry.isDirectory()||!/^[a-f0-9]{8}(?:-[a-f0-9]{4}){3}-[a-f0-9]{12}$/.test(entry.name))continue;
|
|
167
|
+
const dir=location(entry.name);
|
|
168
|
+
try{
|
|
169
|
+
const stat=await lstat(dir);if(!stat.isDirectory()||stat.isSymbolicLink())continue;
|
|
170
|
+
// 只计约定的日志文件,不读内容、不递归遍历业务目录、不跟随文件链接。
|
|
171
|
+
for(const name of logFiles){
|
|
172
|
+
try{const file=await lstat(path.join(dir,name));if(file.isFile()){report.bytes+=file.size;report.files++;}}
|
|
173
|
+
catch(error){if(error.code!=='ENOENT')report.incomplete=true;}
|
|
174
|
+
}
|
|
175
|
+
}catch(error){if(error.code!=='ENOENT')report.incomplete=true;}
|
|
176
|
+
}
|
|
177
|
+
return report;
|
|
178
|
+
},
|
|
179
|
+
async markdown(id){
|
|
180
|
+
const dir=location(id);const stat=await lstat(dir);if(!stat.isDirectory()||stat.isSymbolicLink())throw new FlowError('日志目录无效');
|
|
181
|
+
const run=await read(id),file=path.join(dir,'run.md');if(await regular(file))return readFile(file,'utf8');
|
|
182
|
+
return markdownHeader(run)+(run.events??[]).map(markdownEvent).join('')+markdownResult(run);
|
|
183
|
+
},
|
|
184
|
+
async page(id,{cursor=0,limit=65536}={}){
|
|
185
|
+
if(!Number.isSafeInteger(cursor)||cursor<0)throw new FlowError('日志 cursor 必须为非负整数字节位置',{code:'LOG_CURSOR_INVALID'});
|
|
186
|
+
if(!Number.isSafeInteger(limit)||limit<4)throw new FlowError('日志 limit 必须为至少 4 的正整数');
|
|
187
|
+
limit=Math.min(limit,256*1024);
|
|
188
|
+
const dir=await safeDirectory(id),run=await read(id),file=path.join(dir,'run.md');
|
|
189
|
+
let handle;
|
|
190
|
+
try{handle=await safeFile(file);}catch(error){
|
|
191
|
+
if(error.code!=='ENOENT')throw error;
|
|
192
|
+
const buffer=Buffer.from(markdownHeader(run)+(run.events??[]).map(markdownEvent).join('')+markdownResult(run));
|
|
193
|
+
return pageBuffer(buffer,cursor,limit);
|
|
194
|
+
}
|
|
195
|
+
try{
|
|
196
|
+
const totalBytes=(await handle.stat()).size;
|
|
197
|
+
if(cursor>totalBytes)throw new FlowError('日志 cursor 超过正文长度',{code:'LOG_CURSOR_INVALID'});
|
|
198
|
+
const buffer=Buffer.alloc(Math.min(limit+1,totalBytes-cursor));
|
|
199
|
+
let offset=0;
|
|
200
|
+
while(offset<buffer.length){const {bytesRead}=await handle.read(buffer,offset,buffer.length-offset,cursor+offset);if(!bytesRead)break;offset+=bytesRead;}
|
|
201
|
+
return pageBuffer(buffer.subarray(0,offset),0,limit,cursor,totalBytes);
|
|
202
|
+
}finally{await handle.close();}
|
|
203
|
+
},
|
|
204
|
+
prune(keepRuns,protectedIds){
|
|
205
|
+
const job=cleaning.then(async()=>{
|
|
206
|
+
if(keepRuns===null)return [];
|
|
207
|
+
validateKeepRuns(keepRuns);
|
|
208
|
+
const ended=await list(),removed=[];
|
|
209
|
+
for(const run of ended.slice(keepRuns)){
|
|
210
|
+
if(protectedIds.has(run.id))continue;
|
|
211
|
+
const dir=location(run.id);
|
|
212
|
+
// 只删除本工具约定的四个日志文件;不递归删除目录或插件生成的业务结果。
|
|
213
|
+
for(const name of logFiles){
|
|
214
|
+
const file=path.join(dir,name);
|
|
215
|
+
if(await regular(file))try{await unlink(file);}catch(error){if(error.code!=='ENOENT')throw error;}
|
|
216
|
+
}
|
|
217
|
+
try{await rmdir(dir);}catch(error){if(!['ENOTEMPTY','EEXIST','ENOENT'].includes(error.code))throw error;}
|
|
218
|
+
removed.push(run.id);
|
|
219
|
+
}
|
|
220
|
+
return removed;
|
|
221
|
+
});cleaning=job.catch(()=>{});return job;
|
|
222
|
+
},
|
|
223
|
+
};
|
|
224
|
+
}
|
|
@@ -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
|
+
}
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 mytools contributors
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
# 平台模块
|
|
2
|
+
|
|
3
|
+
包名:`@mytools/fst-platform`。内部 workspace 包,随 `@yanglaofish/fishtools` 统一交付。
|
|
4
|
+
|
|
5
|
+
## 职责与边界
|
|
6
|
+
|
|
7
|
+
进程调用、环境解析、JSON 文件读取、npm 和产物完整性检查。日志留存策略由 logs 负责,项目状态由 workspace 负责。
|
|
8
|
+
|
|
9
|
+
内部依赖:`@mytools/sdk`。
|
|
10
|
+
## 公开入口
|
|
11
|
+
|
|
12
|
+
| 导入路径 | 实现位置 |
|
|
13
|
+
| --- | --- |
|
|
14
|
+
| `@mytools/fst-platform` | `./src/process.js` |
|
|
15
|
+
| `@mytools/fst-platform/environments` | `./src/environments.js` |
|
|
16
|
+
| `@mytools/fst-platform/bundle` | `./src/bundle.js` |
|
|
17
|
+
| `@mytools/fst-platform/json` | `./src/json.js` |
|
|
18
|
+
| `@mytools/fst-platform/npm` | `./src/npm.js` |
|
|
19
|
+
| `@mytools/fst-platform/network` | `./src/network.js` |
|
|
20
|
+
| `@mytools/fst-platform/fs` | `./src/file-probe.js` |
|
|
21
|
+
| `@mytools/fst-platform/package.json` | `./package.json` |
|
|
22
|
+
|
|
23
|
+
仅依赖 exports 声明的入口,禁止消费者跨包读取 src/bin 私有文件。入口路径由本模块维护,修改协议时同步消费方回归。
|
|
24
|
+
|
|
25
|
+
## 维护与验证
|
|
26
|
+
|
|
27
|
+
本模块负责上述职责的实现、文档与回归;跨模块接口调整需要同时检查生产者与消费者。具体维护人员尚未指定。
|
|
28
|
+
|
|
29
|
+
在仓库根目录执行:
|
|
30
|
+
|
|
31
|
+
```sh
|
|
32
|
+
node --test tests/modules.test.js tests/process.test.js tests/environments.test.js
|
|
33
|
+
```
|
|
34
|
+
|
|
35
|
+
## 子进程环境
|
|
36
|
+
|
|
37
|
+
`runProcess(file,args,{env,extendEnv=true})` 默认把 `env` 覆盖到宿主环境上。
|
|
38
|
+
当调用方已按动作代理规则构造完整环境时,传 `extendEnv:false`,防止已移除的代理变量被重新继承。
|
|
39
|
+
|
|
40
|
+
## 隔离文件探测
|
|
41
|
+
|
|
42
|
+
`createFileProbe({timeout=2000,maxConcurrent=2,maxPending=16,allowRemote=false})` 创建只读探测器。
|
|
43
|
+
`stat(path,{signal,timeout})` 和 `readDirectory(path,{signal,timeout,limit=1000})` 把实际 I/O 放入独立 Node 子进程。
|
|
44
|
+
超时从接收请求开始计时,包含排队;并发和排队预算满时返回 `error`,错误码为 `PROBE_BUSY`。
|
|
45
|
+
Windows 子进程使用 `windowsHide`,不会启动可见窗口。
|
|
46
|
+
|
|
47
|
+
每个结果都有 `status` 和 `incomplete`。状态为 `ok/missing/timedOut/cancelled/skipped/error`;非成功结果附 `reason`,错误结果还附 `{code,message}`。
|
|
48
|
+
`stat` 使用 `lstat`,不追踪符号链接;成功的 `value` 包含 `{type,size,mtimeMs,ctimeMs,birthtimeMs,mode}`,
|
|
49
|
+
其中 `type` 为 `file/directory/symlink/other`。目录成功的 `value` 为 `[{name,type}]`;顺序取决于文件系统。
|
|
50
|
+
目录只列一级、不读文件正文;达到条目上限且仍有条目时返回 `incomplete:true,reason:'entryLimit'`。
|
|
51
|
+
`limit` 必须为 1 到 10000 的整数,所有容量和超时参数必须是有限整数。
|
|
52
|
+
|
|
53
|
+
显式 UNC 和网络设备路径默认返回 `skipped`,原因 `remotePath`;Windows 扩展本地路径仍可探测。
|
|
54
|
+
普通挂载不根据路径字符串推测位置,由子进程超时约束。允许远端探测时设 `allowRemote:true`。
|
|
55
|
+
这提供响应时间和资源预算约束,底层内核 I/O 仍可能无法立即回收,不构成安全沙箱。
|
|
56
|
+
|
|
57
|
+
`status()` 返回 `{closed,active,pending,unreaped,maxConcurrent,maxPending}`;超时或取消的 worker 在确认退出前仍计入 `active` 和 `unreaped`。
|
|
58
|
+
`close()` 取消当前和排队请求,尝试终止进程,最多等待 250 ms;返回 `{closed:true,incomplete,unreaped}`。
|
|
59
|
+
关闭后拒绝新请求;关闭报告中的未回收数为关闭等待结束时的快照,后续实际状态可通过 `status()` 查询。
|
|
60
|
+
|
|
61
|
+
```js
|
|
62
|
+
import { createFileProbe } from '@mytools/fst-platform/fs';
|
|
63
|
+
const fs = createFileProbe();
|
|
64
|
+
try {
|
|
65
|
+
const result = await fs.readDirectory('.', { limit: 100 });
|
|
66
|
+
if (result.status === 'ok') console.log(result.value, result.incomplete);
|
|
67
|
+
} finally {
|
|
68
|
+
const closed = await fs.close();
|
|
69
|
+
if (closed.incomplete) console.error('仍有未回收探测进程', closed.unreaped);
|
|
70
|
+
}
|
|
71
|
+
```
|
|
72
|
+
|
|
73
|
+
定向验证:`node --test tests/file-probe.test.js`。阻塞 fixture 使用真实子进程同步等待,并验证终止失败后预算不会被释放。
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@mytools/fst-platform",
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"private": true,
|
|
5
|
+
"type": "module",
|
|
6
|
+
"main": "src/process.js",
|
|
7
|
+
"exports": {
|
|
8
|
+
".": "./src/process.js",
|
|
9
|
+
"./environments": "./src/environments.js",
|
|
10
|
+
"./bundle": "./src/bundle.js",
|
|
11
|
+
"./json": "./src/json.js",
|
|
12
|
+
"./npm": "./src/npm.js",
|
|
13
|
+
"./fs": "./src/file-probe.js",
|
|
14
|
+
"./network": "./src/network.js",
|
|
15
|
+
"./package.json": "./package.json"
|
|
16
|
+
},
|
|
17
|
+
"files": [
|
|
18
|
+
"src",
|
|
19
|
+
"README.md",
|
|
20
|
+
"LICENSE"
|
|
21
|
+
],
|
|
22
|
+
"license": "MIT",
|
|
23
|
+
"engines": {
|
|
24
|
+
"node": ">=24.15.0"
|
|
25
|
+
},
|
|
26
|
+
"dependencies": {
|
|
27
|
+
"@mytools/sdk": "0.1.0",
|
|
28
|
+
"execa": "10.1.0",
|
|
29
|
+
"tree-kill": "1.2.2",
|
|
30
|
+
"undici": "^8.4.1",
|
|
31
|
+
"which": "7.0.0"
|
|
32
|
+
}
|
|
33
|
+
}
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
import { createHash } from 'node:crypto';
|
|
2
|
+
import { readFile, realpath } from 'node:fs/promises';
|
|
3
|
+
import path from 'node:path';
|
|
4
|
+
|
|
5
|
+
export async function verifyBundle(root){
|
|
6
|
+
root=await realpath(root);
|
|
7
|
+
const manifest=JSON.parse(await readFile(path.join(root,'manifest.json'),'utf8'));
|
|
8
|
+
const errors=[];
|
|
9
|
+
for(const [relative,expected] of Object.entries(manifest.files??{})){
|
|
10
|
+
const file=path.resolve(root,relative);
|
|
11
|
+
try{
|
|
12
|
+
if(!file.startsWith(root+path.sep)||!(await realpath(file)).startsWith(root+path.sep))throw new Error('路径越界');
|
|
13
|
+
if(createHash('sha256').update(await readFile(file)).digest('hex')!==expected)errors.push(relative);
|
|
14
|
+
}catch{errors.push(relative);}
|
|
15
|
+
}
|
|
16
|
+
return {ok:errors.length===0,errors};
|
|
17
|
+
}
|
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
import path from 'node:path';
|
|
2
|
+
import {FlowError} from '@mytools/sdk';
|
|
3
|
+
import which from 'which';
|
|
4
|
+
|
|
5
|
+
export const currentTarget=()=>`${process.platform}-${process.arch}${process.platform==='linux'?'-glibc':''}`;
|
|
6
|
+
export function inside(root,relative){
|
|
7
|
+
if(typeof relative!=='string'||!relative||path.isAbsolute(relative)||/^[A-Za-z]:|\\/.test(relative))throw new FlowError(`资源路径必须是包内相对路径:${relative}`);
|
|
8
|
+
const absolute=path.resolve(root,relative),rel=path.relative(root,absolute);
|
|
9
|
+
if(rel==='..'||rel.startsWith('..'+path.sep)||path.isAbsolute(rel))throw new FlowError(`资源路径越界:${relative}`);
|
|
10
|
+
return absolute;
|
|
11
|
+
}
|
|
12
|
+
export function environmentDeclarations(plugin,action){return {...plugin.environments,...action?.environments};}
|
|
13
|
+
export function resolveEnvironments(plugin,action,overrides={},target=currentTarget()){
|
|
14
|
+
return Object.fromEntries(Object.entries(environmentDeclarations(plugin,action)).map(([id,declaration])=>{
|
|
15
|
+
const selected=overrides[id];
|
|
16
|
+
if(selected!==undefined&&(!selected||typeof selected!=='object'||Array.isArray(selected)||Object.keys(selected).some(k=>!['source','executable'].includes(k))||selected.source!==undefined&&typeof selected.source!=='string'||selected.executable!==undefined&&(typeof selected.executable!=='string'||!selected.executable.trim())))throw new FlowError(`环境来源覆盖无效:${plugin.id}/${id}`);
|
|
17
|
+
const source=selected?.source??declaration.source;
|
|
18
|
+
if(!(declaration.allowedSources??[declaration.source]).includes(source))throw new FlowError(`环境 ${plugin.id}/${id} 不允许来源 ${source}`);
|
|
19
|
+
const e={...declaration,source};delete e.artifacts;
|
|
20
|
+
if(selected?.executable!==undefined){
|
|
21
|
+
if(source!=='host')throw new FlowError(`只有 host 环境允许覆盖解释器:${plugin.id}/${id}`);
|
|
22
|
+
e.executable=selected.executable;
|
|
23
|
+
}
|
|
24
|
+
if(source==='tool'){
|
|
25
|
+
if(id!=='node')throw new FlowError(`工具尚未提供环境:${id}`);
|
|
26
|
+
e.executable=process.execPath;
|
|
27
|
+
}else if(source==='bundled'){
|
|
28
|
+
const artifact=declaration.artifacts?.[target];
|
|
29
|
+
if(!artifact)throw new FlowError(`环境 ${plugin.id}/${id} 未提供 ${target} 的随包资源`);
|
|
30
|
+
e.root=inside(plugin.root,artifact.directory);e.executable=inside(e.root,artifact.executable);
|
|
31
|
+
}
|
|
32
|
+
return [id,e];
|
|
33
|
+
}));
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
// 打包只解析声明;真正调用时才在当前工作区选择候选,避免将开发机路径写入跨平台分发包。
|
|
37
|
+
export async function prepareEnvironments(plugin,action,overrides={}, {cwd=process.cwd()}={}){
|
|
38
|
+
const resolved=resolveEnvironments(plugin,action,overrides);
|
|
39
|
+
for(const [id,environment] of Object.entries(resolved)){
|
|
40
|
+
if(environment.source!=='host'||!Array.isArray(environment.executable)&&overrides[id]?.executable===undefined)continue;
|
|
41
|
+
const candidates=Array.isArray(environment.executable)?environment.executable:[environment.executable];
|
|
42
|
+
const windows=process.platform==='win32',directory=path.resolve(cwd);
|
|
43
|
+
const searchDirectories=[...(windows?[directory]:[]),...(process.env.PATH??'').split(path.delimiter)]
|
|
44
|
+
.map(entry=>path.resolve(directory,entry.replace(/^"(.*)"$/,'$1')));
|
|
45
|
+
let found;
|
|
46
|
+
for(const candidate of candidates){
|
|
47
|
+
const direct=path.isAbsolute(candidate)||candidate.includes('/')||windows&&candidate.includes('\\');
|
|
48
|
+
// 使用绝对候选,防止 which 在 Windows 擅自优先搜索宿主启动目录。
|
|
49
|
+
const paths=direct?[path.resolve(directory,candidate)]:searchDirectories.map(base=>path.resolve(base,candidate));
|
|
50
|
+
for(const executable of paths){
|
|
51
|
+
found=await which(executable,{nothrow:true});
|
|
52
|
+
if(found)break;
|
|
53
|
+
}
|
|
54
|
+
if(found)break;
|
|
55
|
+
}
|
|
56
|
+
if(!found)throw new FlowError(`环境 ${plugin.id}/${id} 未找到程序:${candidates.join('、')}`,{code:'ENOENT',details:{plugin:plugin.id,environment:id,candidates}});
|
|
57
|
+
environment.executable=path.resolve(found);
|
|
58
|
+
}
|
|
59
|
+
return resolved;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
export function platformWarnings(plugin,platform=process.platform,architecture=process.arch){
|
|
63
|
+
const warnings=[];
|
|
64
|
+
if(plugin.platforms&&!plugin.platforms.includes(platform))warnings.push(`插件 ${plugin.id} 的平台声明不包含当前 ${platform},继续执行;请核实插件支持范围`);
|
|
65
|
+
if(plugin.architectures&&!plugin.architectures.includes(architecture))warnings.push(`插件 ${plugin.id} 的架构声明不包含当前 ${architecture},继续执行;请核实插件支持范围`);
|
|
66
|
+
return warnings;
|
|
67
|
+
}
|