@modusensus/dsh-mneme 0.8.16 → 0.9.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 +91 -16
- package/assets/icon.svg +2 -2
- package/bin/cli.mjs +8 -2
- package/docs/CONFIGURATION.md +27 -1
- package/docs/CONTINUITY.md +18 -3
- package/docs/SEMANTIC.md +3 -3
- package/docs/WORKFLOWS.md +299 -0
- package/lib/api-standalone.js +78 -12
- package/lib/api.js +403 -62
- package/lib/candidates.js +454 -0
- package/lib/capabilities.js +819 -0
- package/lib/client.js +2294 -423
- package/lib/config.js +63 -6
- package/lib/continuity.js +35 -6
- package/lib/dream/decisions.js +17 -1
- package/lib/dream/sleep.js +10 -2
- package/lib/dream.js +5 -1
- package/lib/embedding.js +116 -15
- package/lib/entities/extractor.js +4 -0
- package/lib/index.js +81 -12
- package/lib/inject.js +40 -4
- package/lib/lang.js +124 -0
- package/lib/pipeline-activities.js +178 -0
- package/lib/pipeline-attribution.js +165 -0
- package/lib/pipeline-command.js +279 -0
- package/lib/pipeline-confirm.js +142 -0
- package/lib/pipeline-exec.js +174 -0
- package/lib/pipeline-nudge.js +1281 -0
- package/lib/pipeline-observations.js +181 -0
- package/lib/pipeline-observe.js +133 -0
- package/lib/pipeline-runner.js +346 -0
- package/lib/pipeline-runs.js +442 -0
- package/lib/pipeline.js +655 -0
- package/lib/scope.js +16 -4
- package/lib/semantic.js +66 -10
- package/lib/serve.js +1 -1
- package/lib/service.js +59 -18
- package/lib/session-usage.js +350 -0
- package/lib/settings.js +138 -1
- package/lib/store.js +132 -6
- package/lib/summarize.js +30 -0
- package/lib/tools.js +11 -2
- package/package.json +3 -3
- package/scripts/changelog-prep.mjs +62 -7
- package/scripts/check-sync.js +8 -6
- package/scripts/publish-gate.mjs +197 -0
- package/scripts/release-docs.mjs +204 -0
- package/scripts/release-notes.mjs +162 -0
- package/src/api-standalone.js +78 -12
- package/src/api.js +403 -62
- package/src/candidates.js +454 -0
- package/src/capabilities.js +819 -0
- package/src/config.js +63 -6
- package/src/continuity.js +35 -6
- package/src/dream/decisions.js +17 -1
- package/src/dream/sleep.js +10 -2
- package/src/dream.js +5 -1
- package/src/embedding.js +116 -15
- package/src/entities/extractor.js +4 -0
- package/src/index.js +81 -12
- package/src/inject.js +40 -4
- package/src/lang.js +124 -0
- package/src/pipeline-activities.js +178 -0
- package/src/pipeline-attribution.js +165 -0
- package/src/pipeline-command.js +279 -0
- package/src/pipeline-confirm.js +142 -0
- package/src/pipeline-exec.js +174 -0
- package/src/pipeline-nudge.js +1281 -0
- package/src/pipeline-observations.js +181 -0
- package/src/pipeline-observe.js +133 -0
- package/src/pipeline-runner.js +346 -0
- package/src/pipeline-runs.js +442 -0
- package/src/pipeline.js +655 -0
- package/src/scope.js +16 -4
- package/src/semantic.js +66 -10
- package/src/serve.js +1 -1
- package/src/service.js +59 -18
- package/src/session-usage.js +350 -0
- package/src/settings.js +138 -1
- package/src/store.js +132 -6
- package/src/summarize.js +30 -0
- package/src/tools.js +11 -2
- package/test/api-hardening.test.js +173 -0
- package/test/api.test.js +244 -7
- package/test/candidates.test.js +345 -0
- package/test/capabilities.test.js +577 -0
- package/test/changelog-prep.test.js +74 -8
- package/test/cli-enums.test.js +37 -0
- package/test/client.test.js +510 -2
- package/test/continuity.test.js +84 -0
- package/test/docs-config-keys.test.js +65 -0
- package/test/embed-ready-poll.test.js +94 -0
- package/test/entities.test.js +13 -2
- package/test/failure-query.test.js +147 -0
- package/test/failure-retention.test.js +105 -0
- package/test/graph-api.test.js +36 -2
- package/test/helpers/write-admission-samples.js +6 -2
- package/test/inject-recall.test.js +32 -4
- package/test/lib-smoke.test.js +20 -0
- package/test/llm-audit.test.js +92 -1
- package/test/mint-source.test.js +202 -0
- package/test/persona-api.test.js +172 -0
- package/test/persona-flag.test.js +36 -0
- package/test/persona-inject.test.js +145 -0
- package/test/persona-settings.test.js +145 -0
- package/test/pipeline-activities.test.js +155 -0
- package/test/pipeline-attribution.test.js +175 -0
- package/test/pipeline-command.test.js +243 -0
- package/test/pipeline-confirm.test.js +148 -0
- package/test/pipeline-exec.test.js +172 -0
- package/test/pipeline-nudge.test.js +1036 -0
- package/test/pipeline-observations.test.js +157 -0
- package/test/pipeline-observe.test.js +127 -0
- package/test/pipeline-r1r2.test.js +149 -0
- package/test/pipeline-runner.test.js +442 -0
- package/test/pipeline-runs.test.js +767 -0
- package/test/pipeline.test.js +636 -0
- package/test/placement.test.js +1 -1
- package/test/publish-gate.test.js +272 -0
- package/test/recall-retention.test.js +128 -0
- package/test/reindex-backfill.test.js +83 -4
- package/test/release-docs.test.js +130 -0
- package/test/release-notes.test.js +240 -0
- package/test/release-workflow.test.js +93 -0
- package/test/route-policy.test.js +113 -0
- package/test/scope-distill-stamp.test.js +135 -0
- package/test/security-doc.test.js +139 -0
- package/test/sensitive-scan.test.js +44 -0
- package/test/serve.test.js +1 -1
- package/test/session-usage.test.js +308 -0
- package/test/store-write-path-columns.test.js +191 -0
- package/test/summarize.test.js +176 -0
- package/test/supersede-state-tracking.test.js +4 -4
- package/test/tools.test.js +16 -1
- package/test/workflow-guards.test.js +156 -0
package/lib/pipeline.js
ADDED
|
@@ -0,0 +1,655 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* M.O.(Modus Operandi · 手法)工作流的**读侧**。
|
|
3
|
+
*
|
|
4
|
+
* 本体的落点是仓库(`<项目>/.mneme/pipelines/*.json`),不是记忆库:
|
|
5
|
+
* 工作流是要跑的东西,必须能提交、能 PR、能 review、能被 CI 读、能整版回退;
|
|
6
|
+
* 记忆库只存「为什么有这条」(证据)与「跑得怎么样」(结算)。
|
|
7
|
+
* 三句话的分工:记忆是「为什么」,仓库是「怎么跑」,图是「看起来怎么样」。
|
|
8
|
+
*
|
|
9
|
+
* 这里只做三件事:读盘 → 校验(宁可读不出来,也不要读出一个半成品)→ 投影成
|
|
10
|
+
* `mneme.pipeline/v1`(前端与 agent 共用的那一份结构化数据)。
|
|
11
|
+
*
|
|
12
|
+
* 为什么不用 YAML:本包运行时依赖只有 `morphicons` 一个,为一个配置文件引 YAML
|
|
13
|
+
* 解析器不划算;手写子集解析器则是给未来埋一个「读起来对、跑起来错」的坑。
|
|
14
|
+
* 人读的形态由面板渲染负责,不靠文件格式。
|
|
15
|
+
*
|
|
16
|
+
* **两层来源**(2026-10-15 定):项目层在前、全局层在后,顺序即优先级。
|
|
17
|
+
* 一条工作流默认属于某个项目,跟着项目走;装一次插件就在所有项目里看到同一套
|
|
18
|
+
* 「全局手法」,正是设计备忘要避免的局面——本机活跃记忆里 95.7% 的
|
|
19
|
+
* `workspace_scope` 为空,「全局」于是变成谁都不看的杂物堆。全局层留着的唯一
|
|
20
|
+
* 理由是接那些**跨项目复用**的手法,而跨项目复用是一个要留痕的显式动作。
|
|
21
|
+
*/
|
|
22
|
+
import { readFileSync, readdirSync, existsSync } from "node:fs";
|
|
23
|
+
import { dirname, join, posix as pathPosix, win32 as pathWin32 } from "node:path";
|
|
24
|
+
import { fileURLToPath } from "node:url";
|
|
25
|
+
|
|
26
|
+
// 两种路径语义各自指名一次,别让调用点再自己判断平台。
|
|
27
|
+
const basenamePosix = pathPosix.basename;
|
|
28
|
+
const basenameWin32 = pathWin32.basename;
|
|
29
|
+
|
|
30
|
+
/**
|
|
31
|
+
* **全局层**的家:插件包自带的 `pipelines/`。算法是「本模块所在目录的上一级」——
|
|
32
|
+
* src/ 与 lib/ 都在包根下,所以这个式子对源码树和发布包同时成立。
|
|
33
|
+
*
|
|
34
|
+
* 它只是**兜底层**,不是默认住处:默认住处是 `projectPipelineDir()`。发布包里今天
|
|
35
|
+
* 没有 `pipelines/`(目录为空即不进包),所以全局层通常是空的——**这正是想要的**:
|
|
36
|
+
* 一条手法该不该跨项目用,得是一个人说出来的动作,而不是装插件时顺手塞进去的默认。
|
|
37
|
+
*/
|
|
38
|
+
export function defaultPipelineDir() {
|
|
39
|
+
return join(dirname(fileURLToPath(import.meta.url)), "..", "pipelines");
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
/** **项目层**的家:`<项目>/.mneme/pipelines/*.json`。点开头不碍眼,但仍随仓库提交。 */
|
|
43
|
+
export const PROJECT_PIPELINE_SUBDIR = ".mneme";
|
|
44
|
+
|
|
45
|
+
export function projectPipelineDir(projectRoot) {
|
|
46
|
+
return join(projectRoot, PROJECT_PIPELINE_SUBDIR, "pipelines");
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
/**
|
|
50
|
+
* 项目根 → 面板上那个作用域标签。**只取目录名,不把整条绝对路径画出去**:
|
|
51
|
+
* 绝对路径里有本机用户名,而面板是会被截图、被贴进 issue 的那一面。
|
|
52
|
+
* 机器要用的完整路径另在 `sources[].dir`,那是数据,不是给人读的。
|
|
53
|
+
*/
|
|
54
|
+
export function projectLabel(projectRoot) {
|
|
55
|
+
const root = String(projectRoot ?? "");
|
|
56
|
+
// **语义显式指名,不借 path.basename()**:它跟着运行平台走——posix 上 `\` 不是
|
|
57
|
+
// 分隔符,一条 Windows 路径会被整条当成标签,而面板正是会被截图、被贴进 issue
|
|
58
|
+
// 的那一面,标签里于是带着本机用户名,恰好是本函数要挡住的东西。
|
|
59
|
+
// 含反斜杠按 Windows 语义解析,否则按 posix——两个平台的正确结果就此一致。
|
|
60
|
+
// Windows 腿对旧写法**结构性看不见**(win32.basename 两种分隔符都认,旧实现在
|
|
61
|
+
// 本机算出来就是对的),所以这条的杀伤力只在 ubuntu 矩阵腿上。
|
|
62
|
+
const base = root.includes("\\") ? basenameWin32(root) : basenamePosix(root);
|
|
63
|
+
return base === "" ? root : base;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
/**
|
|
67
|
+
* 读取来源,**项目层在前、全局层在后**——顺序就是优先级:同 id 时项目层赢。
|
|
68
|
+
*
|
|
69
|
+
* 没有项目根(会话工作目录那一刻不可得)时只给全局层,并如实标 `project: null`;
|
|
70
|
+
* **绝不回退到进程自己所在的目录**:那会把「读不到」变成「读到别的地方」,
|
|
71
|
+
* 而后者在界面上看不出来,是最难查的一类错。
|
|
72
|
+
*/
|
|
73
|
+
export function pipelineSources({ projectRoot = null, pluginDir = defaultPipelineDir() } = {}) {
|
|
74
|
+
const sources = [];
|
|
75
|
+
const root = typeof projectRoot === "string" ? projectRoot.trim() : "";
|
|
76
|
+
if (root !== "") sources.push({ scope: "project", label: projectLabel(root), dir: projectPipelineDir(root) });
|
|
77
|
+
sources.push({ scope: "global", label: null, dir: pluginDir });
|
|
78
|
+
return sources;
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
/** 风险四档(R0–R3)。跨档按最高档算;子 agent 只准接 R0 / R1。 */
|
|
82
|
+
export const RISK_LEVELS = ["R0", "R1", "R2", "R3"];
|
|
83
|
+
|
|
84
|
+
/** 这一步谁做。`human` 与 `ci` 是闸门与流水线自己的位置,不是 agent。 */
|
|
85
|
+
export const ACTORS = ["main", "subagent", "human", "ci"];
|
|
86
|
+
|
|
87
|
+
/** 工作流状态:草稿 / 生效 / 退役。退役不删文件——留着才知道它曾经存在过。 */
|
|
88
|
+
export const STATUSES = ["draft", "active", "retired"];
|
|
89
|
+
|
|
90
|
+
/** 节点状态四值;`refuted` 是全篇核心信号。 */
|
|
91
|
+
export const NODE_STATES = ["candidate", "trusted", "stale", "refuted"];
|
|
92
|
+
|
|
93
|
+
/** 单层最多读多少条。目录被人塞满时宁可报错,也不要把一次面板刷新变成磁盘扫描。 */
|
|
94
|
+
const MAX_PIPELINES = 200;
|
|
95
|
+
|
|
96
|
+
/**
|
|
97
|
+
* 提议的阈值:跳过/绕开多少次就该提一句。
|
|
98
|
+
* 3 这个数来自标本 E2/E3——「某步跳过 ≥3 次 → 提议删掉;某动作你手动补过 ≥2 次 → 提议升成正式步骤」。
|
|
99
|
+
*/
|
|
100
|
+
export const RETIRE_AFTER_SKIPS = 3;
|
|
101
|
+
export const RETIRE_AFTER_DETOURS = 3;
|
|
102
|
+
|
|
103
|
+
/**
|
|
104
|
+
* 从运行记录里算出「该提议什么」。这是**图的自我精简**——不是人定期巡检,
|
|
105
|
+
* 而是计数到了就冒出来:
|
|
106
|
+
* 跳过 ≥3 次 → 提议删掉**这一步**(这一步对这条流程没用了)
|
|
107
|
+
* 绕开 ≥3 次 → 提议这条**路径**过时了(从这里岔出去成了常态,图该重画)
|
|
108
|
+
*
|
|
109
|
+
* 两者的区别是这一版的核心:**跳过 = 这次不用;绕开 = 这条路整个不对。**
|
|
110
|
+
* 只有绕开才动「路径」这一层。
|
|
111
|
+
*/
|
|
112
|
+
export function suggestRetirements(pipeline, { runs = [], now = Date.now(), observed = null } = {}) {
|
|
113
|
+
const credit = tallyRuns(runs);
|
|
114
|
+
const out = [];
|
|
115
|
+
// 前置违规(R1):跨整本账本查一次——某步被执行过,而它的某个 needs 前置
|
|
116
|
+
// 在**同一条 run 里**既没执行也没被跳过。查账本级而不是 run 级,
|
|
117
|
+
// 是因为 tallyRuns 已经把 run 边界抹掉了;这里要保住边界,所以单独扫 runs。
|
|
118
|
+
const violationSeen = new Set();
|
|
119
|
+
for (const run of runs || []) {
|
|
120
|
+
const doneIds = new Set((run.steps || []).filter((s) => s && !s.skipped && !s.detoured).map((s) => s.id));
|
|
121
|
+
for (const step of pipeline.steps) {
|
|
122
|
+
if (!doneIds.has(step.id) || !Array.isArray(step.needs)) continue;
|
|
123
|
+
for (const need of step.needs) {
|
|
124
|
+
if (doneIds.has(need)) continue;
|
|
125
|
+
const had = (run.steps || []).some((s) => s && s.id === need);
|
|
126
|
+
if (had) continue; // 前置做过(哪怕是 skipped)就不算违规
|
|
127
|
+
const vk = `${run.at ?? ""}|${step.id}|${need}`;
|
|
128
|
+
if (violationSeen.has(vk)) continue;
|
|
129
|
+
violationSeen.add(vk);
|
|
130
|
+
// 同一步对不同前置各报一条:修图的人需要知道**断在哪条边**上
|
|
131
|
+
out.push({
|
|
132
|
+
kind: "violation-precedence",
|
|
133
|
+
stepId: step.id,
|
|
134
|
+
title: step.title,
|
|
135
|
+
because: `执行过 ${step.id} 的那次(${run.at ?? "无时间"})里,前置 ${need} 既没做也没标跳过`,
|
|
136
|
+
effect: `提议重看 ${need} → ${step.id} 这条依赖——要么顺序写错了,要么前置根本不必要`
|
|
137
|
+
});
|
|
138
|
+
}
|
|
139
|
+
}
|
|
140
|
+
}
|
|
141
|
+
for (const step of pipeline.steps) {
|
|
142
|
+
const c = credit.get(step.id);
|
|
143
|
+
if (!c) continue;
|
|
144
|
+
// R2:跳过的性质由步骤自己的 optional 声明决定,不再靠次数猜意图。
|
|
145
|
+
// 可选步骤跳过 = 设计如此,**永远不提议删**;
|
|
146
|
+
// 必经步骤被跳 = 违规信号,rework-path 与 drop-step **并存**——
|
|
147
|
+
// 同一事实「必经的一步总被跳」既可能是「这步多余」(删)也可能是「流程写错」
|
|
148
|
+
// (修图),判要靠人。这里绝不能用 if/else 吞并:07 号文那条教训在本仓重演过。
|
|
149
|
+
if (step.optional !== true && (c.skipped || 0) >= RETIRE_AFTER_SKIPS && (c.uses || 0) === 0) {
|
|
150
|
+
// 必经、从未真正执行、且总被跳:最像「写进清单但实际从不执行」(08 号文实测
|
|
151
|
+
// gh pr merge --rebase 出现 0 次)。删步与修图两种读法都成立,同时给人。
|
|
152
|
+
out.push({
|
|
153
|
+
kind: "drop-step",
|
|
154
|
+
stepId: step.id,
|
|
155
|
+
title: step.title,
|
|
156
|
+
because: `从未被执行过、且被跳过 ${c.skipped} 次`,
|
|
157
|
+
effect: "提议删掉这一步——写进清单但实际从不执行的步骤,最可能是清单过时"
|
|
158
|
+
});
|
|
159
|
+
}
|
|
160
|
+
if (step.optional === true) {
|
|
161
|
+
if ((c.skipped || 0) >= RETIRE_AFTER_SKIPS) {
|
|
162
|
+
out.push({
|
|
163
|
+
kind: "optional-skip-ok",
|
|
164
|
+
stepId: step.id,
|
|
165
|
+
title: step.title,
|
|
166
|
+
because: `可选步骤,被跳过 ${c.skipped} 次`,
|
|
167
|
+
effect: "不提议改动——这是 optional 声明的正常行为(信息项,供人留意)"
|
|
168
|
+
});
|
|
169
|
+
}
|
|
170
|
+
} else {
|
|
171
|
+
if ((c.skipped || 0) >= RETIRE_AFTER_SKIPS) {
|
|
172
|
+
out.push({
|
|
173
|
+
kind: "rework-path",
|
|
174
|
+
// cause 区分同 kind 的两种成因:nudge 侧按它选封闭问法——「必经的一步总
|
|
175
|
+
// 被跳」问的是删步/改 optional,「被绕开成常态」问的是重画路径,共用一句
|
|
176
|
+
// 问法会答非所问(CodeRabbit #458 F3)。
|
|
177
|
+
cause: "skipped",
|
|
178
|
+
stepId: step.id,
|
|
179
|
+
title: step.title,
|
|
180
|
+
because: `必经步骤被跳过 ${c.skipped} 次`,
|
|
181
|
+
effect: "提议重看这条路径——必经的一步总被跳,要么它是多余的(该删/改 optional),要么流程写错了"
|
|
182
|
+
});
|
|
183
|
+
}
|
|
184
|
+
}
|
|
185
|
+
if ((c.detoured || 0) >= RETIRE_AFTER_DETOURS) {
|
|
186
|
+
out.push({
|
|
187
|
+
kind: "rework-path",
|
|
188
|
+
cause: "detoured",
|
|
189
|
+
stepId: step.id,
|
|
190
|
+
title: step.title,
|
|
191
|
+
because: `被绕开 ${c.detoured} 次`,
|
|
192
|
+
effect: "提议重看这条路径——从这一步岔出去已经成了常态,图该重画"
|
|
193
|
+
});
|
|
194
|
+
}
|
|
195
|
+
}
|
|
196
|
+
// P3 投影(fix-step-tool):人确认做过、但声明的 tool 在攒下的观察里从没出现。
|
|
197
|
+
// 观察是旁挂文件里的聚合({key,count},无命令原文),不泄露路径。
|
|
198
|
+
// **不做累计**:一次矛盾就报——人可以判「声明过时/本来不必敲/我记错了」。
|
|
199
|
+
// 纯人判步骤(无 tool 声明)与 optional 步骤不进判据:前者无从矛盾,
|
|
200
|
+
// 后者「这次没做」是正常,谈不上「做了但没看到」。
|
|
201
|
+
if (observed && typeof observed === "object" && observed.latest?.activities?.length) {
|
|
202
|
+
const seen = new Set(observed.latest.activities.map((a) => String(a?.key ?? "").toLowerCase()).filter(Boolean));
|
|
203
|
+
for (const step of pipeline.steps) {
|
|
204
|
+
if (step.optional === true) continue;
|
|
205
|
+
const declared = typeof step.tool === "string" ? step.tool.trim() : "";
|
|
206
|
+
if (declared === "") continue;
|
|
207
|
+
const anchors = declared.split("/").map((t) => t.trim().toLowerCase()).filter(Boolean);
|
|
208
|
+
if (anchors.some((a) => seen.has(a))) continue;
|
|
209
|
+
const c = credit.get(step.id);
|
|
210
|
+
if (!c || (c.uses || 0) === 0) continue; // 没被确认做过 → 无矛盾可言
|
|
211
|
+
out.push({
|
|
212
|
+
kind: "fix-step-tool",
|
|
213
|
+
stepId: step.id,
|
|
214
|
+
title: step.title,
|
|
215
|
+
because: `确认做过 ${c.uses} 次,但声明的 ${declared} 在最近的观察里一次都没出现`,
|
|
216
|
+
effect: "提议核对这个声明——是 tool 写过时了,还是这一步本来就不必敲那条命令",
|
|
217
|
+
observedTop: observed.latest.activities.slice(0, 3).map((a) => `${a.key} ×${a.count}`)
|
|
218
|
+
});
|
|
219
|
+
}
|
|
220
|
+
}
|
|
221
|
+
return out;
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
const ID_RE = /^[a-z0-9][a-z0-9._-]*$/;
|
|
225
|
+
const STEP_ID_RE = /^[A-Za-z0-9][A-Za-z0-9._-]*$/;
|
|
226
|
+
|
|
227
|
+
/** 校验失败的统一出口:一次把话说完,别让人一轮修一条。 */
|
|
228
|
+
class PipelineError extends Error {
|
|
229
|
+
constructor(file, problems) {
|
|
230
|
+
super(`${file}: ${problems.join("; ")}`);
|
|
231
|
+
this.name = "PipelineError";
|
|
232
|
+
this.problems = problems;
|
|
233
|
+
}
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
function isPlainObject(value) {
|
|
237
|
+
return !!value && typeof value === "object" && !Array.isArray(value);
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
function isNonEmptyString(value) {
|
|
241
|
+
return typeof value === "string" && value.trim() !== "";
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
/**
|
|
245
|
+
* 校验一条工作流。返回归一化后的对象;有任何问题就抛,不返回「部分可用」的东西
|
|
246
|
+
* ——半个工作流比没有工作流更危险,因为它会被当成完整的跑。
|
|
247
|
+
*/
|
|
248
|
+
export function validatePipeline(raw, file = "<pipeline>") {
|
|
249
|
+
const problems = [];
|
|
250
|
+
const bad = (msg) => problems.push(msg);
|
|
251
|
+
|
|
252
|
+
if (!isPlainObject(raw)) {
|
|
253
|
+
throw new PipelineError(file, ["顶层必须是一个对象"]);
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
if (!isNonEmptyString(raw.id) || !ID_RE.test(raw.id)) {
|
|
257
|
+
bad("id 必须是小写 kebab(如 dsh-mneme-maintenance)");
|
|
258
|
+
}
|
|
259
|
+
if (!isNonEmptyString(raw.name)) bad("name 必须是字符串");
|
|
260
|
+
if (!isNonEmptyString(raw.project)) bad("project 必须是字符串(如 slow-stack/mneme)");
|
|
261
|
+
if (!Number.isInteger(raw.version) || raw.version < 0) bad("version 必须是从 0 起的整数");
|
|
262
|
+
if (!STATUSES.includes(raw.status)) bad(`status 必须是 ${STATUSES.join(" / ")}`);
|
|
263
|
+
if (!isNonEmptyString(raw.done)) bad("done 必须写清「整条怎么算完成」");
|
|
264
|
+
|
|
265
|
+
if (!Array.isArray(raw.trigger) || raw.trigger.length === 0) {
|
|
266
|
+
bad("trigger 至少要有一条");
|
|
267
|
+
} else {
|
|
268
|
+
raw.trigger.forEach((t, i) => {
|
|
269
|
+
if (!isPlainObject(t) || !isNonEmptyString(t.value)) {
|
|
270
|
+
bad(`trigger[${i}] 必须写成 { kind, value }`);
|
|
271
|
+
}
|
|
272
|
+
});
|
|
273
|
+
}
|
|
274
|
+
|
|
275
|
+
if (!Array.isArray(raw.steps) || raw.steps.length === 0) {
|
|
276
|
+
bad("steps 至少要有一级");
|
|
277
|
+
throw new PipelineError(file, problems);
|
|
278
|
+
}
|
|
279
|
+
|
|
280
|
+
const ids = new Set();
|
|
281
|
+
for (const [i, step] of raw.steps.entries()) {
|
|
282
|
+
const at = isPlainObject(step) && isNonEmptyString(step.id) ? step.id : `steps[${i}]`;
|
|
283
|
+
if (!isPlainObject(step)) {
|
|
284
|
+
bad(`${at} 必须是对象`);
|
|
285
|
+
continue;
|
|
286
|
+
}
|
|
287
|
+
if (!isNonEmptyString(step.id) || !STEP_ID_RE.test(step.id)) {
|
|
288
|
+
bad(`${at}.id 非法(如 A1 / C2)`);
|
|
289
|
+
} else if (ids.has(step.id)) {
|
|
290
|
+
bad(`${at}.id 重复`);
|
|
291
|
+
} else {
|
|
292
|
+
ids.add(step.id);
|
|
293
|
+
}
|
|
294
|
+
for (const key of ["phase", "title", "when", "do", "done", "why"]) {
|
|
295
|
+
if (!isNonEmptyString(step[key])) bad(`${at}.${key} 必须是非空字符串`);
|
|
296
|
+
}
|
|
297
|
+
if (!RISK_LEVELS.includes(step.risk)) bad(`${at}.risk 必须是 ${RISK_LEVELS.join(" / ")}`);
|
|
298
|
+
if (!ACTORS.includes(step.who)) bad(`${at}.who 必须是 ${ACTORS.join(" / ")}`);
|
|
299
|
+
if (!Array.isArray(step.needs)) bad(`${at}.needs 必须是数组(可空,表示这是一段的入口)`);
|
|
300
|
+
if (step.gate !== undefined && typeof step.gate !== "boolean") bad(`${at}.gate 只能是布尔`);
|
|
301
|
+
// 可选/必经声明(R2):缺省 = 必经。有了它,「被跳过」才第一次有性质——
|
|
302
|
+
// 可选步骤跳过是正常,必经步骤被跳是违规。见 07 号文的语义对照与 15 号文拍板。
|
|
303
|
+
if (step.optional !== undefined && typeof step.optional !== "boolean") bad(`${at}.optional 只能是布尔`);
|
|
304
|
+
if (step.evidence !== undefined && !Array.isArray(step.evidence)) {
|
|
305
|
+
bad(`${at}.evidence 必须是字符串数组`);
|
|
306
|
+
}
|
|
307
|
+
// 断言(可断言节点):一条一条写「这一步怎么算做完」,且要求**机器能判**。
|
|
308
|
+
// 没有它就等于判断节点——完成与否只能由人认。这不是缺失,是两类节点之一。
|
|
309
|
+
if (step.assert !== undefined) {
|
|
310
|
+
if (!Array.isArray(step.assert)) {
|
|
311
|
+
bad(`${at}.assert 必须是字符串数组`);
|
|
312
|
+
} else {
|
|
313
|
+
step.assert.forEach((entry, i) => {
|
|
314
|
+
if (!isNonEmptyString(entry)) bad(`${at}.assert[${i}] 必须是非空字符串`);
|
|
315
|
+
});
|
|
316
|
+
}
|
|
317
|
+
}
|
|
318
|
+
}
|
|
319
|
+
|
|
320
|
+
// 先把 id 收集齐再校验引用,否则会按声明顺序报一堆假错。
|
|
321
|
+
for (const step of raw.steps) {
|
|
322
|
+
if (!Array.isArray(step.needs)) continue;
|
|
323
|
+
for (const need of step.needs) {
|
|
324
|
+
if (!ids.has(need)) bad(`${step.id}.needs 指向了不存在的步骤 ${need}`);
|
|
325
|
+
}
|
|
326
|
+
}
|
|
327
|
+
|
|
328
|
+
const cycles = findCycles(raw.steps, ids);
|
|
329
|
+
if (cycles.length) bad(`图里有环:${cycles.join(" / ")}(回写是新版本,不是回到自己)`);
|
|
330
|
+
|
|
331
|
+
if (problems.length) throw new PipelineError(file, problems);
|
|
332
|
+
|
|
333
|
+
return {
|
|
334
|
+
id: raw.id,
|
|
335
|
+
name: raw.name,
|
|
336
|
+
project: raw.project,
|
|
337
|
+
version: raw.version,
|
|
338
|
+
status: raw.status,
|
|
339
|
+
done: raw.done,
|
|
340
|
+
trigger: raw.trigger.map((t) => ({ kind: t.kind || "phrase", value: t.value, note: t.note || "" })),
|
|
341
|
+
inputs: Array.isArray(raw.inputs) ? raw.inputs.slice() : [],
|
|
342
|
+
steps: raw.steps.map((s) => ({
|
|
343
|
+
id: s.id,
|
|
344
|
+
phase: s.phase,
|
|
345
|
+
title: s.title,
|
|
346
|
+
when: s.when,
|
|
347
|
+
do: s.do,
|
|
348
|
+
who: s.who,
|
|
349
|
+
tool: isNonEmptyString(s.tool) ? s.tool : "",
|
|
350
|
+
done: s.done,
|
|
351
|
+
risk: s.risk,
|
|
352
|
+
needs: s.needs.slice(),
|
|
353
|
+
why: s.why,
|
|
354
|
+
evidence: Array.isArray(s.evidence) ? s.evidence.slice() : [],
|
|
355
|
+
// 缺省是空数组而不是 undefined:两类节点的区别用「有没有断言」表达,
|
|
356
|
+
// 用缺键会让下游每处都得先判在不在。
|
|
357
|
+
assert: Array.isArray(s.assert) ? s.assert.slice() : [],
|
|
358
|
+
gate: s.gate === true,
|
|
359
|
+
// 缺省 false(= 必经):显式落键而非缺省缺键——「这步可不可跳」是判据要读的
|
|
360
|
+
// 语义,不是可有可无的装饰。读侧拿到的一律是布尔,不用到处判 undefined。
|
|
361
|
+
optional: s.optional === true
|
|
362
|
+
}))
|
|
363
|
+
};
|
|
364
|
+
}
|
|
365
|
+
|
|
366
|
+
/** 三色 DFS 找环,把环上的步骤 id 拼出来(报错要能直接指到人看的那一行)。 */
|
|
367
|
+
function findCycles(steps, ids) {
|
|
368
|
+
const needsOf = new Map();
|
|
369
|
+
for (const step of steps) {
|
|
370
|
+
if (isNonEmptyString(step.id) && Array.isArray(step.needs)) {
|
|
371
|
+
needsOf.set(step.id, step.needs.filter((n) => ids.has(n)));
|
|
372
|
+
}
|
|
373
|
+
}
|
|
374
|
+
const state = new Map(); // 0 未访问 / 1 在栈上 / 2 已定
|
|
375
|
+
const cycles = [];
|
|
376
|
+
const walk = (id, trail) => {
|
|
377
|
+
if (state.get(id) === 2) return;
|
|
378
|
+
if (state.get(id) === 1) {
|
|
379
|
+
cycles.push([...trail.slice(trail.indexOf(id)), id].join(" → "));
|
|
380
|
+
return;
|
|
381
|
+
}
|
|
382
|
+
state.set(id, 1);
|
|
383
|
+
for (const next of needsOf.get(id) || []) walk(next, [...trail, id]);
|
|
384
|
+
state.set(id, 2);
|
|
385
|
+
};
|
|
386
|
+
for (const id of needsOf.keys()) walk(id, []);
|
|
387
|
+
return cycles;
|
|
388
|
+
}
|
|
389
|
+
|
|
390
|
+
/**
|
|
391
|
+
* 读一个目录下的全部工作流;坏文件直接抛,不做「跳过继续」——静默少一条最难查。
|
|
392
|
+
* 每条挂上 `origin`(哪一层来的、家在哪个目录、哪个文件),供上层判优先级与显示。
|
|
393
|
+
*/
|
|
394
|
+
export function loadPipelines(dir, { scope = "global", label = null } = {}) {
|
|
395
|
+
if (!existsSync(dir)) return [];
|
|
396
|
+
const files = readdirSync(dir).filter((f) => f.endsWith(".json")).sort();
|
|
397
|
+
if (files.length > MAX_PIPELINES) {
|
|
398
|
+
throw new PipelineError(dir, [`目录里有 ${files.length} 个 .json,超过上限 ${MAX_PIPELINES}——这里应该是工作流目录,不是数据堆`]);
|
|
399
|
+
}
|
|
400
|
+
const out = [];
|
|
401
|
+
const seen = new Map();
|
|
402
|
+
for (const file of files) {
|
|
403
|
+
const full = join(dir, file);
|
|
404
|
+
let raw;
|
|
405
|
+
try {
|
|
406
|
+
raw = JSON.parse(readFileSync(full, "utf8"));
|
|
407
|
+
} catch (error) {
|
|
408
|
+
throw new PipelineError(file, [`JSON 解析失败:${error.message}`]);
|
|
409
|
+
}
|
|
410
|
+
const pipeline = validatePipeline(raw, file);
|
|
411
|
+
if (seen.has(pipeline.id)) {
|
|
412
|
+
throw new PipelineError(file, [`id "${pipeline.id}" 与 ${seen.get(pipeline.id)} 重复`]);
|
|
413
|
+
}
|
|
414
|
+
seen.set(pipeline.id, file);
|
|
415
|
+
out.push({ ...pipeline, origin: { scope, label, dir, file } });
|
|
416
|
+
}
|
|
417
|
+
return out;
|
|
418
|
+
}
|
|
419
|
+
|
|
420
|
+
/**
|
|
421
|
+
* 从一条运行记录里数出「这次走过哪几步」——结算用。
|
|
422
|
+
*
|
|
423
|
+
* 每一步有三种处置,**计数必须分开**,因为它们驱动的判断完全不同:
|
|
424
|
+
* uses 照图走了(或走了但被推翻)
|
|
425
|
+
* skipped 这一步这次不用(图上还在)→ 跳过 ≥3 次就提议删掉这一步
|
|
426
|
+
* detoured 从这里岔出去走了图上没有的路 → 被绕开 ≥3 次说明这条路径过时了
|
|
427
|
+
*
|
|
428
|
+
* 「跳过」与「绕开」不是一回事:**跳过 = 这次不用这一步;绕开 = 这条路整个不对**。
|
|
429
|
+
* 混在一起数,两种提议就都发不出来了。
|
|
430
|
+
*/
|
|
431
|
+
function tallyRuns(runs) {
|
|
432
|
+
const perStep = new Map();
|
|
433
|
+
for (const run of runs || []) {
|
|
434
|
+
for (const step of run.steps || []) {
|
|
435
|
+
const cur = perStep.get(step.id) || { uses: 0, refuted: 0, lastUsedAt: null };
|
|
436
|
+
perStep.set(step.id, cur);
|
|
437
|
+
// 三种处置**各自独立累加**,谁也不吃谁:
|
|
438
|
+
// 一次绕行很可能同时是「这一步这次没用」+「从这里岔出去了」——
|
|
439
|
+
// 这两件事驱动的提议不同(删这一步 / 重画这条路径),吞掉一个就少一条提议。
|
|
440
|
+
// 第一版写成 if/else if,于是绕行把跳过吃掉了。
|
|
441
|
+
const detoured = step.detoured === true || step.kind === "detour";
|
|
442
|
+
const skipped = step.skipped === true;
|
|
443
|
+
if (detoured) cur.detoured = (cur.detoured || 0) + 1;
|
|
444
|
+
if (skipped) cur.skipped = (cur.skipped || 0) + 1;
|
|
445
|
+
// 只有「既没跳过也没绕行」才算真走了这一步——两种偏离都不是使用。
|
|
446
|
+
if (!detoured && !skipped) cur.uses += 1;
|
|
447
|
+
if (step.refuted) cur.refuted += 1;
|
|
448
|
+
if (step.at && (!cur.lastUsedAt || step.at > cur.lastUsedAt)) cur.lastUsedAt = step.at;
|
|
449
|
+
}
|
|
450
|
+
}
|
|
451
|
+
return perStep;
|
|
452
|
+
}
|
|
453
|
+
|
|
454
|
+
/**
|
|
455
|
+
* 节点状态由**对照数据**推出来,不由作者写死:
|
|
456
|
+
* 被推翻过 → refuted;用过没用砸 → trusted;有记录但久未使用 → stale;没有记录 → candidate。
|
|
457
|
+
* 没有运行记录时全体是 candidate —— 这是实话,也正是面板上那句 beta 免责必须摆在脸上的原因。
|
|
458
|
+
*
|
|
459
|
+
* 绕行不改变状态、只累加计数:**被绕开不等于这一步坏了**(可能只是这次不需要),
|
|
460
|
+
* 它推动的是「这条路径该重看了」这种**提议**,不是把节点染红。
|
|
461
|
+
* 染色要留给真正被推翻过的(refuted)——那是唯一有硬判据的信号。
|
|
462
|
+
*/
|
|
463
|
+
function deriveState(credit, now, staleAfterDays = 90) {
|
|
464
|
+
if (!credit || credit.uses === 0) return credit && credit.skipped ? "stale" : "candidate";
|
|
465
|
+
if (credit.refuted > 0) return "refuted";
|
|
466
|
+
if (credit.lastUsedAt) {
|
|
467
|
+
const age = (now - Date.parse(credit.lastUsedAt)) / 86400000;
|
|
468
|
+
if (Number.isFinite(age) && age > staleAfterDays) return "stale";
|
|
469
|
+
}
|
|
470
|
+
return "trusted";
|
|
471
|
+
}
|
|
472
|
+
|
|
473
|
+
/**
|
|
474
|
+
* 清单页要的那点信息:够渲染「名字 + 一句话 + 状态 + 作用域」,不必把整张图读出来。
|
|
475
|
+
* 面板第一屏是清单、点进去才是图,所以这一份是**首屏的全部输入**。
|
|
476
|
+
*/
|
|
477
|
+
function summarizePipeline(pipeline, runs, now) {
|
|
478
|
+
const credit = tallyRuns(runs);
|
|
479
|
+
const states = { candidate: 0, trusted: 0, stale: 0, refuted: 0 };
|
|
480
|
+
let gates = 0;
|
|
481
|
+
let contrast = 0;
|
|
482
|
+
let assertable = 0;
|
|
483
|
+
for (const step of pipeline.steps) {
|
|
484
|
+
states[deriveState(credit.get(step.id) || null, now)] += 1;
|
|
485
|
+
if (step.gate) gates += 1;
|
|
486
|
+
if (step.evidence.length) contrast += 1;
|
|
487
|
+
// 可断言节点数:与 contrast 并列的首屏指标。两者含义不同——contrast 数的是
|
|
488
|
+
// 「写了没有」(内容靠人判),assertable 数的是「机器能不能判」。
|
|
489
|
+
if (step.assert.length) assertable += 1;
|
|
490
|
+
}
|
|
491
|
+
return {
|
|
492
|
+
id: pipeline.id,
|
|
493
|
+
title: pipeline.name,
|
|
494
|
+
scope: pipeline.origin.scope,
|
|
495
|
+
scopeLabel: pipeline.origin.label,
|
|
496
|
+
project: pipeline.project,
|
|
497
|
+
status: pipeline.status,
|
|
498
|
+
version: pipeline.version,
|
|
499
|
+
done: pipeline.done,
|
|
500
|
+
trigger: pipeline.trigger,
|
|
501
|
+
file: pipeline.origin.file,
|
|
502
|
+
steps: pipeline.steps.length,
|
|
503
|
+
gates,
|
|
504
|
+
// 反事实对照覆盖率:19/19 与 6/19 是两条完全不同的工作流,清单上就该看得出来。
|
|
505
|
+
contrast,
|
|
506
|
+
// 可断言节点数:机器能自动验完的有几步。「0/19」说明这条流程全靠人判——
|
|
507
|
+
// 那是实话,不是缺陷,但清单上必须看得见。
|
|
508
|
+
assertable,
|
|
509
|
+
states
|
|
510
|
+
};
|
|
511
|
+
}
|
|
512
|
+
|
|
513
|
+
/**
|
|
514
|
+
* 投影成 `mneme.pipeline/v1`:前端渲染与 agent 读取用的是同一份数据,
|
|
515
|
+
* 画面只是它的投影——只画不存,agent 就什么也读不到。
|
|
516
|
+
*/
|
|
517
|
+
export function toPipelineV1(pipeline, { runs = [], now = Date.now(), memoryLinks = {} } = {}) {
|
|
518
|
+
const credit = tallyRuns(runs);
|
|
519
|
+
// 没经过 loadPipelines 的对象(手工 validate 出来的、别处传来的)没有来源。
|
|
520
|
+
// 这时 scope 落 null 而不是 "project":默认成项目级会让一条其实来路不明的工作流
|
|
521
|
+
// 看起来「就是本项目的」,而面板上没人能分辨。
|
|
522
|
+
const origin = pipeline.origin || { scope: null, label: null, dir: "", file: "" };
|
|
523
|
+
|
|
524
|
+
const nodes = pipeline.steps.map((step) => {
|
|
525
|
+
const c = credit.get(step.id) || null;
|
|
526
|
+
const link = memoryLinks[step.id] || null;
|
|
527
|
+
return {
|
|
528
|
+
id: step.id,
|
|
529
|
+
// gate / branch 是画法,不是第二套数据:闸门要能一眼看出来「这里会停下」。
|
|
530
|
+
kind: step.gate ? "gate" : step.needs.length > 1 ? "branch" : "step",
|
|
531
|
+
title: step.title,
|
|
532
|
+
phase: step.phase,
|
|
533
|
+
situation: step.when,
|
|
534
|
+
action: step.do,
|
|
535
|
+
why: step.why,
|
|
536
|
+
who: step.who,
|
|
537
|
+
tool: step.tool,
|
|
538
|
+
done: step.done,
|
|
539
|
+
risk: step.risk,
|
|
540
|
+
state: deriveState(c, now),
|
|
541
|
+
credit: c ? { uses: c.uses, refuted: c.refuted, skipped: c.skipped || 0, detoured: c.detoured || 0, lastUsedAt: c.lastUsedAt } : null,
|
|
542
|
+
// 可断言节点 vs 判断节点:这是两类节点的**唯一**区分点,也是一张图里
|
|
543
|
+
// 「哪些步骤能被机器盯着」的全部依据。**必须投影成 null 而不是空数组**:
|
|
544
|
+
// 空数组会让「作者没写断言」与「写了但一条都没通过」长得一样,而这两件事
|
|
545
|
+
// 在验收时完全不同(前者是判断节点,后者是失败)。
|
|
546
|
+
assert: step.assert.length ? { checks: step.assert.slice(), count: step.assert.length } : null,
|
|
547
|
+
// 反事实对照是**准入判据**的载体(「没有它这次会不会失败」),不是备注:
|
|
548
|
+
// 答不出来的步骤在图上要显得比答得出来的虚。
|
|
549
|
+
counterfactual: step.evidence.length
|
|
550
|
+
? { contrast: step.evidence.join(" / "), evidence: step.evidence.length }
|
|
551
|
+
: null,
|
|
552
|
+
// 记忆 id 是本机私有数据,不进仓库文件;有本地对照表才填。
|
|
553
|
+
source: link ? { memoryIds: link.memoryIds || [], originSession: link.originSession || null } : null
|
|
554
|
+
};
|
|
555
|
+
});
|
|
556
|
+
|
|
557
|
+
const edges = [];
|
|
558
|
+
for (const step of pipeline.steps) {
|
|
559
|
+
for (const need of step.needs) {
|
|
560
|
+
edges.push({ id: `${need}->${step.id}`, from: need, to: step.id, kind: "control", label: "" });
|
|
561
|
+
}
|
|
562
|
+
}
|
|
563
|
+
|
|
564
|
+
return {
|
|
565
|
+
contract: "mneme.pipeline/v1",
|
|
566
|
+
pipeline: {
|
|
567
|
+
id: pipeline.id,
|
|
568
|
+
title: pipeline.name,
|
|
569
|
+
// scope 是**从哪儿读出来的**,不是作者写的:同一条文件放进项目层就变项目级。
|
|
570
|
+
scope: origin.scope,
|
|
571
|
+
scopeLabel: origin.label,
|
|
572
|
+
project: pipeline.project,
|
|
573
|
+
file: origin.file,
|
|
574
|
+
status: pipeline.status,
|
|
575
|
+
version: pipeline.version,
|
|
576
|
+
done: pipeline.done,
|
|
577
|
+
trigger: pipeline.trigger,
|
|
578
|
+
inputs: pipeline.inputs,
|
|
579
|
+
updatedAt: null
|
|
580
|
+
},
|
|
581
|
+
nodes,
|
|
582
|
+
edges,
|
|
583
|
+
runs
|
|
584
|
+
};
|
|
585
|
+
}
|
|
586
|
+
|
|
587
|
+
/**
|
|
588
|
+
* 面板与 agent 的公共入口:按来源顺序读全两层,投影出第一条(或指定 id 的那条),
|
|
589
|
+
* 并**总是**给出清单(`available`)——首屏是清单,图是点进去才要的东西。
|
|
590
|
+
*
|
|
591
|
+
* `projectRoot` 是会话工作目录。给不出来时只读全局层,并在 `sources` 里如实标
|
|
592
|
+
* `project: null`、`available` 为空:界面据此说「这个会话的工作目录拿不到」,
|
|
593
|
+
* 而不是悄悄显示一份不属于任何项目的清单。
|
|
594
|
+
*/
|
|
595
|
+
export function readPipelineDoc({
|
|
596
|
+
projectRoot = null,
|
|
597
|
+
pluginDir = defaultPipelineDir(),
|
|
598
|
+
id = null,
|
|
599
|
+
runsById = {},
|
|
600
|
+
now = Date.now(),
|
|
601
|
+
memoryLinks = {},
|
|
602
|
+
// P3 投影的燃料:观察旁挂(调用方用 loadObservations 读好后传进来)。
|
|
603
|
+
// 形状:{ [pipelineId]: { latest: { activities: [{key,count,flags?}] } } }。
|
|
604
|
+
// 设计成调用方预读而不是传 memoryDir——pipeline.js 保持零文件系统职责(只读
|
|
605
|
+
// 自己的 pipelines 目录),旁挂的路径规则归 observations 模块管。
|
|
606
|
+
observationsById = {}
|
|
607
|
+
} = {}) {
|
|
608
|
+
const sources = pipelineSources({ projectRoot, pluginDir });
|
|
609
|
+
const ordered = [];
|
|
610
|
+
const byId = new Map();
|
|
611
|
+
const shadowed = [];
|
|
612
|
+
const unreadable = [];
|
|
613
|
+
|
|
614
|
+
for (const source of sources) {
|
|
615
|
+
let list;
|
|
616
|
+
try {
|
|
617
|
+
list = loadPipelines(source.dir, { scope: source.scope, label: source.label });
|
|
618
|
+
} catch (error) {
|
|
619
|
+
// 项目层里有个坏文件,不该把全局层一起拖死:如实记下来继续读下一层。
|
|
620
|
+
// 全局层是自家发出去的包,坏了就是坏了——照抛。
|
|
621
|
+
if (source.scope === "global") throw error;
|
|
622
|
+
unreadable.push({ scope: source.scope, dir: source.dir, detail: error.message });
|
|
623
|
+
continue;
|
|
624
|
+
}
|
|
625
|
+
for (const pipeline of list) {
|
|
626
|
+
const already = byId.get(pipeline.id);
|
|
627
|
+
if (already) {
|
|
628
|
+
// 项目层赢是**安排好的**,不是巧合:记下来,免得以后有人以为丢了文件。
|
|
629
|
+
shadowed.push({ id: pipeline.id, hidden: source.scope, by: already.origin.scope });
|
|
630
|
+
continue;
|
|
631
|
+
}
|
|
632
|
+
byId.set(pipeline.id, pipeline);
|
|
633
|
+
ordered.push(pipeline);
|
|
634
|
+
}
|
|
635
|
+
}
|
|
636
|
+
|
|
637
|
+
const available = ordered.map((p) => summarizePipeline(p, runsById[p.id] || [], now));
|
|
638
|
+
const chosen = (id ? byId.get(id) : ordered[0]) || null;
|
|
639
|
+
|
|
640
|
+
const base = {
|
|
641
|
+
contract: "mneme.pipeline/v1",
|
|
642
|
+
sources: sources.map((s) => ({ scope: s.scope, label: s.label, dir: s.dir, present: existsSync(s.dir) })),
|
|
643
|
+
available,
|
|
644
|
+
shadowed,
|
|
645
|
+
unreadable
|
|
646
|
+
};
|
|
647
|
+
|
|
648
|
+
if (!chosen) return { ...base, pipeline: null, nodes: [], edges: [], runs: [], suggestions: [] };
|
|
649
|
+
return {
|
|
650
|
+
...toPipelineV1(chosen, { runs: runsById[chosen.id] || [], now, memoryLinks }),
|
|
651
|
+
...base,
|
|
652
|
+
// 图的自我精简:计数到了就把提议冒出来,不用人等巡检。
|
|
653
|
+
suggestions: suggestRetirements(chosen, { runs: runsById[chosen.id] || [], now, observed: observationsById[chosen.id] ?? null })
|
|
654
|
+
};
|
|
655
|
+
}
|