@modusensus/dsh-mneme 0.9.0 → 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 +78 -16
- package/bin/cli.mjs +8 -2
- package/docs/CONFIGURATION.md +25 -0
- package/docs/SEMANTIC.md +3 -3
- package/lib/api-standalone.js +78 -12
- package/lib/api.js +282 -57
- package/lib/candidates.js +454 -454
- package/lib/client.js +598 -248
- package/lib/config.js +33 -2
- package/lib/embedding.js +116 -15
- package/lib/index.js +32 -0
- package/lib/inject.js +34 -3
- 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 +128 -7
- package/lib/semantic.js +66 -10
- package/lib/settings.js +132 -1
- package/lib/store.js +77 -3
- package/lib/summarize.js +2 -3
- package/package.json +3 -3
- package/scripts/changelog-prep.mjs +62 -7
- package/scripts/check-sync.js +8 -6
- 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 +282 -57
- package/src/candidates.js +454 -454
- package/src/config.js +33 -2
- package/src/embedding.js +116 -15
- package/src/index.js +32 -0
- package/src/inject.js +34 -3
- 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 +128 -7
- package/src/semantic.js +66 -10
- package/src/settings.js +132 -1
- package/src/store.js +77 -3
- package/src/summarize.js +2 -3
- package/test/api-hardening.test.js +173 -0
- package/test/api.test.js +98 -1
- package/test/changelog-prep.test.js +74 -8
- package/test/cli-enums.test.js +37 -0
- package/test/client.test.js +152 -3
- package/test/docs-config-keys.test.js +65 -0
- package/test/helpers/write-admission-samples.js +6 -2
- package/test/lib-smoke.test.js +20 -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 +137 -6
- 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 +16 -0
- package/test/route-policy.test.js +113 -0
- package/test/security-doc.test.js +139 -0
- package/test/sensitive-scan.test.js +44 -0
- package/test/workflow-guards.test.js +156 -0
package/lib/api.js
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { URL } from "node:url";
|
|
2
2
|
import { isAbsolute } from "node:path";
|
|
3
|
+
import { existsSync, readdirSync } from "node:fs";
|
|
3
4
|
import { timingSafeEqual, randomBytes } from "node:crypto";
|
|
4
5
|
import { FEATURE_FLAG_SPEC } from "./settings.js";
|
|
5
6
|
import { TYPES } from "./store.js";
|
|
@@ -12,7 +13,10 @@ import { describeLocalRuntime, publicRuntimeStatus } from "./runtime/loader.js";
|
|
|
12
13
|
import { hostModulesDir, provisionRuntime } from "./runtime/provision.js";
|
|
13
14
|
import { classify, fetchLatestVersion, PACKAGE_VERSION } from "./version-check.js";
|
|
14
15
|
import { parseReclaimQuery, parseReclaimJson, readReclaimFields, reclaimReply, RECLAIM_UNAVAILABLE } from "./reclaim-http.js";
|
|
15
|
-
import { readPipelineDoc } from "./pipeline.js";
|
|
16
|
+
import { readPipelineDoc, loadPipelines, projectPipelineDir } from "./pipeline.js";
|
|
17
|
+
import { loadRunsById, runsDir, RUNS_SUFFIX } from "./pipeline-runs.js";
|
|
18
|
+
import { observationDir, loadObservationsById } from "./pipeline-observations.js";
|
|
19
|
+
import { projectKey } from "./candidates.js";
|
|
16
20
|
import { readCandidateDoc } from "./candidates.js";
|
|
17
21
|
|
|
18
22
|
// headers:少数端点(/export 附件下载)需要追加 Content-Disposition 等响应头。
|
|
@@ -37,6 +41,16 @@ function sendAttachment(res, status, contentType, filename, body) {
|
|
|
37
41
|
// defaults in config.js (externalApiPort / externalApiHost).
|
|
38
42
|
const EXTERNAL_API_DEFAULTS = { enabled: false, port: 8790, host: "127.0.0.1" };
|
|
39
43
|
|
|
44
|
+
// 请求体上限(防御,不是功能):readBody 只累加字符串,不设上限时一个超大 body
|
|
45
|
+
// 就能把进程内存拉到 Content-Length 那个量级。4 MiB 远高于任何真实写入(记忆
|
|
46
|
+
// 正文、导入的 markdown、面板 patch 都远小于它)。
|
|
47
|
+
const MAX_REQUEST_BODY_BYTES = 4 * 1024 * 1024;
|
|
48
|
+
// 分页上界(防御):list/search 的参数直接进 SQL LIMIT,无上界时
|
|
49
|
+
// ?limit=100000 会让一次免鉴权 GET 把全库序列化出来(2026-10 审计:免鉴权读面
|
|
50
|
+
// + 浏览器简单请求 = 廉价 DoS)。1000/200 远高于面板与 CLI 的任何真实用法。
|
|
51
|
+
const MAX_PAGE_LIMIT = 1000;
|
|
52
|
+
const MAX_SEARCH_TOP_K = 200;
|
|
53
|
+
|
|
40
54
|
/** Merge persisted external-api kv over the defaults for client display. */
|
|
41
55
|
function fullExternalConfig(kv = {}) {
|
|
42
56
|
return {
|
|
@@ -70,22 +84,82 @@ function isAuthorized(req, apiToken) {
|
|
|
70
84
|
}
|
|
71
85
|
|
|
72
86
|
/**
|
|
73
|
-
*
|
|
74
|
-
*
|
|
87
|
+
* 路由鉴权策略(2026-10 审计的结构性修法)。三条:
|
|
88
|
+
* open 永不鉴权——「公开读面」,必须写进 SECURITY.md 的安全模型表
|
|
89
|
+
* token 配置了 apiToken 就整条要带(写路由 + 敏感读)
|
|
90
|
+
* token-write 只有非 GET/HEAD 才要 token(面板要读、只有写才有副作用)
|
|
91
|
+
*
|
|
92
|
+
* 少声明不是「默认开放」:register 直接抛错,插件起不来。这是本方案的要点——
|
|
93
|
+
* 「新增路由忘了加鉴权」从运行期静默漏洞变成装配期硬失败,不依赖谁记得写、也
|
|
94
|
+
* 不依赖测试有没有覆盖到(/export 就是这么漏的,见 2026-10 审计 P0)。
|
|
75
95
|
*/
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
96
|
+
export const ROUTE_POLICIES = Object.freeze(["open", "token", "token-write"]);
|
|
97
|
+
|
|
98
|
+
/** True when this request must present the apiToken under the route's policy. */
|
|
99
|
+
function needsToken(req, policy, apiToken) {
|
|
100
|
+
if (policy === "open") return false;
|
|
101
|
+
const method = String(req.method || "GET").toUpperCase();
|
|
102
|
+
if (policy === "token-write" && (method === "GET" || method === "HEAD")) return false;
|
|
103
|
+
return !isAuthorized(req, apiToken);
|
|
80
104
|
}
|
|
81
105
|
|
|
82
|
-
/**
|
|
83
|
-
|
|
106
|
+
/**
|
|
107
|
+
* 唯一的登记入口:校验 policy、拒绝重复路径、把 handler 包上鉴权。
|
|
108
|
+
* 返回的 count() 供 createApi 汇报真实路由数(此前的字面量 36 已经和现实脱节)。
|
|
109
|
+
*/
|
|
110
|
+
export function createRouteRegistrar(webServer, apiToken, disposers) {
|
|
111
|
+
const seen = new Set();
|
|
112
|
+
const register = (route) => {
|
|
113
|
+
if (!ROUTE_POLICIES.includes(route.policy)) {
|
|
114
|
+
throw new Error(
|
|
115
|
+
`[dsh-mneme] 路由 ${route.path} 必须声明 policy(${ROUTE_POLICIES.join(" / ")}),` +
|
|
116
|
+
`实际是 ${JSON.stringify(route.policy)}。先想清楚它属于哪一类,再同步 SECURITY.md 的安全模型表。`
|
|
117
|
+
);
|
|
118
|
+
}
|
|
119
|
+
const key = `${route.kind}:${route.path}`;
|
|
120
|
+
if (seen.has(key)) throw new Error(`[dsh-mneme] 路由 ${key} 被重复登记`);
|
|
121
|
+
seen.add(key);
|
|
122
|
+
const inner = route.handler;
|
|
123
|
+
const handler = (req, res) => {
|
|
124
|
+
if (!needsToken(req, route.policy, apiToken)) return inner(req, res);
|
|
125
|
+
sendJson(res, 401, { error: "unauthorized" });
|
|
126
|
+
return undefined;
|
|
127
|
+
};
|
|
128
|
+
disposers.push(webServer.register({ ...route, handler }));
|
|
129
|
+
};
|
|
130
|
+
return { register, count: () => seen.size };
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
|
|
134
|
+
/**
|
|
135
|
+
* Collect the request body as text, or null when it exceeds the cap.
|
|
136
|
+
*
|
|
137
|
+
* 超限时**必须回 null 而不是空串**:空串经 parseBody 变成 {},而 /profile、
|
|
138
|
+
* /rules、/persona 的 PUT 会把「字段缺失」当成「用户要清空」——一个 5 MiB 的
|
|
139
|
+
* 请求就能把画像/规则/人格静默清掉。返回 null 让调用点在碰任何 store 之前
|
|
140
|
+
* 直接 413(2026-10 评审指出的数据损坏路径)。
|
|
141
|
+
* 连接不 destroy:调用方要能收到明确的 413,而不是被掐断。
|
|
142
|
+
*/
|
|
143
|
+
function readBody(req, res) {
|
|
84
144
|
return new Promise((resolve) => {
|
|
85
145
|
let body = "";
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
req.on("
|
|
146
|
+
let bytes = 0;
|
|
147
|
+
let overflow = false;
|
|
148
|
+
req.on("data", (chunk) => {
|
|
149
|
+
if (overflow) return;
|
|
150
|
+
bytes += chunk.length;
|
|
151
|
+
if (bytes > MAX_REQUEST_BODY_BYTES) {
|
|
152
|
+
overflow = true;
|
|
153
|
+
body = "";
|
|
154
|
+
sendJson(res, 413, { error: "payload-too-large", limit: MAX_REQUEST_BODY_BYTES });
|
|
155
|
+
return;
|
|
156
|
+
}
|
|
157
|
+
body += chunk;
|
|
158
|
+
});
|
|
159
|
+
req.on("end", () => resolve(overflow ? null : body));
|
|
160
|
+
// 超限后再来 transport error 也必须保留超限状态:否则调用点会拿着空串
|
|
161
|
+
// 继续往 store 写(PUT /persona 把 "" 解析成 {},覆盖既有设置)。
|
|
162
|
+
req.on("error", () => resolve(overflow ? null : ""));
|
|
89
163
|
});
|
|
90
164
|
}
|
|
91
165
|
|
|
@@ -187,14 +261,14 @@ export function createApi(ctx, service, settings, commands, embedder, semantic =
|
|
|
187
261
|
service.setEmbedder(embedder);
|
|
188
262
|
}
|
|
189
263
|
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
};
|
|
264
|
+
// 所有路由都经这一处登记:policy 必填、鉴权在这里统一判定(见 createRouteRegistrar)。
|
|
265
|
+
const { register, count: countRoutes } = createRouteRegistrar(webServer, apiToken, disposers);
|
|
193
266
|
|
|
194
267
|
// /api/dsh-mneme prefix fallback → 404 JSON for unknown sub-paths
|
|
195
268
|
register({
|
|
196
269
|
kind: "prefix",
|
|
197
270
|
path: "/api/dsh-mneme",
|
|
271
|
+
policy: "open",
|
|
198
272
|
handler(req, res) {
|
|
199
273
|
sendJson(res, 404, { error: "not-found" });
|
|
200
274
|
}
|
|
@@ -205,6 +279,7 @@ export function createApi(ctx, service, settings, commands, embedder, semantic =
|
|
|
205
279
|
register({
|
|
206
280
|
kind: "exact",
|
|
207
281
|
path: "/api/dsh-mneme/info",
|
|
282
|
+
policy: "open",
|
|
208
283
|
handler(req, res) {
|
|
209
284
|
sendJson(res, 200, { version: PACKAGE_VERSION });
|
|
210
285
|
}
|
|
@@ -217,6 +292,7 @@ export function createApi(ctx, service, settings, commands, embedder, semantic =
|
|
|
217
292
|
register({
|
|
218
293
|
kind: "exact",
|
|
219
294
|
path: "/api/dsh-mneme/version-check",
|
|
295
|
+
policy: "open",
|
|
220
296
|
handler(req, res) {
|
|
221
297
|
if (req.method !== "GET") {
|
|
222
298
|
sendJson(res, 404, { error: "not-found" });
|
|
@@ -236,11 +312,13 @@ export function createApi(ctx, service, settings, commands, embedder, semantic =
|
|
|
236
312
|
register({
|
|
237
313
|
kind: "exact",
|
|
238
314
|
path: "/api/dsh-mneme/list",
|
|
315
|
+
policy: "open",
|
|
239
316
|
handler(req, res) {
|
|
240
317
|
try {
|
|
241
318
|
const url = new URL(req.url, "http://localhost");
|
|
242
319
|
const type = url.searchParams.get("type") ?? undefined;
|
|
243
|
-
|
|
320
|
+
// 上界是防御:参数直接进 SQL LIMIT(见 MAX_PAGE_LIMIT 注释)。
|
|
321
|
+
const limit = Math.min(Number(url.searchParams.get("limit") ?? 50), MAX_PAGE_LIMIT);
|
|
244
322
|
const offset = Number(url.searchParams.get("offset") ?? 0);
|
|
245
323
|
// order=chrono: pure newest-first for paged browsing; default keeps
|
|
246
324
|
// the importance-ranked order other callers rely on.
|
|
@@ -292,6 +370,7 @@ export function createApi(ctx, service, settings, commands, embedder, semantic =
|
|
|
292
370
|
register({
|
|
293
371
|
kind: "exact",
|
|
294
372
|
path: "/api/dsh-mneme/recall-stats",
|
|
373
|
+
policy: "open",
|
|
295
374
|
handler(req, res) {
|
|
296
375
|
if (req.method !== "GET") {
|
|
297
376
|
sendJson(res, 404, { error: "not-found" });
|
|
@@ -311,11 +390,13 @@ export function createApi(ctx, service, settings, commands, embedder, semantic =
|
|
|
311
390
|
register({
|
|
312
391
|
kind: "exact",
|
|
313
392
|
path: "/api/dsh-mneme/search",
|
|
393
|
+
policy: "open",
|
|
314
394
|
handler(req, res) {
|
|
315
395
|
try {
|
|
316
396
|
const url = new URL(req.url, "http://localhost");
|
|
317
397
|
const q = url.searchParams.get("q") ?? "";
|
|
318
|
-
|
|
398
|
+
// 上界是防御:topK 直接进召回(见 MAX_SEARCH_TOP_K 注释)。
|
|
399
|
+
const limit = Math.min(Number(url.searchParams.get("topK") ?? url.searchParams.get("limit") ?? 20), MAX_SEARCH_TOP_K);
|
|
319
400
|
// mode selects the recall strategy (defaults to auto):
|
|
320
401
|
// auto (default) keyword first, vector fills remaining slots
|
|
321
402
|
// hybrid vector first, keyword fills remaining slots; scores of
|
|
@@ -355,11 +436,12 @@ export function createApi(ctx, service, settings, commands, embedder, semantic =
|
|
|
355
436
|
register({
|
|
356
437
|
kind: "exact",
|
|
357
438
|
path: "/api/dsh-mneme/profile",
|
|
439
|
+
policy: "token-write",
|
|
358
440
|
handler(req, res) {
|
|
359
441
|
try {
|
|
360
442
|
if (req.method === "PUT" || req.method === "POST") {
|
|
361
|
-
|
|
362
|
-
|
|
443
|
+
return readBody(req, res).then((text) => {
|
|
444
|
+
if (text === null) return;
|
|
363
445
|
const body = parseBody(text);
|
|
364
446
|
settings.setProfile(typeof body.profile === "string" ? body.profile : "");
|
|
365
447
|
sendJson(res, 200, { profile: settings.getProfile() });
|
|
@@ -372,6 +454,44 @@ export function createApi(ctx, service, settings, commands, embedder, semantic =
|
|
|
372
454
|
}
|
|
373
455
|
});
|
|
374
456
|
|
|
457
|
+
// --- agent persona(人格定义页,beta) -------------------------------------
|
|
458
|
+
// 形状照抄 /profile 路由(设计稿②):写方法走 requireAuth + readBody + parseBody。
|
|
459
|
+
// v1.2 多卡:GET 回全量 { default, byAgent };PUT 双形——单对象形(旧面板)只更新
|
|
460
|
+
// default 且 byAgent 保留(合并语义,settings.setPersona 内判形状),多卡形全量
|
|
461
|
+
// 替换。坏体守卫:jsonOk=false 时保值回显,不清空库内现值。
|
|
462
|
+
register({
|
|
463
|
+
kind: "exact",
|
|
464
|
+
path: "/api/dsh-mneme/persona",
|
|
465
|
+
policy: "token-write",
|
|
466
|
+
handler(req, res) {
|
|
467
|
+
try {
|
|
468
|
+
if (req.method === "PUT" || req.method === "POST") {
|
|
469
|
+
return readBody(req, res).then((text) => {
|
|
470
|
+
if (text === null) return;
|
|
471
|
+
// parseBody 对坏 JSON 回 {}(容错不抛),所以这里的守卫必须把
|
|
472
|
+
// 「坏体解析成的 {}」与「真的传了 {persona:{}}」区分开:文本解析
|
|
473
|
+
// 失败的体不该把人格清空。jsonOk=false 时保持库内现值原样回显。
|
|
474
|
+
let body;
|
|
475
|
+
let jsonOk = true;
|
|
476
|
+
try {
|
|
477
|
+
body = JSON.parse(text || "{}");
|
|
478
|
+
} catch {
|
|
479
|
+
body = {};
|
|
480
|
+
jsonOk = false;
|
|
481
|
+
}
|
|
482
|
+
if (jsonOk && body && typeof body === "object") {
|
|
483
|
+
settings.setPersona(body.persona && typeof body.persona === "object" ? body.persona : body);
|
|
484
|
+
}
|
|
485
|
+
sendJson(res, 200, { persona: settings.getPersona() });
|
|
486
|
+
});
|
|
487
|
+
}
|
|
488
|
+
sendJson(res, 200, { persona: settings.getPersona() });
|
|
489
|
+
} catch {
|
|
490
|
+
sendJson(res, 500, { error: "internal" });
|
|
491
|
+
}
|
|
492
|
+
}
|
|
493
|
+
});
|
|
494
|
+
|
|
375
495
|
// --- dream/sleep 模型连通性 + 模型发现(settings 面板)----------------------
|
|
376
496
|
// Connectivity probe for a dream/sleep model route: runs one minimal LLM call
|
|
377
497
|
// through the same harness path the consolidation uses and reports success /
|
|
@@ -471,6 +591,7 @@ export function createApi(ctx, service, settings, commands, embedder, semantic =
|
|
|
471
591
|
register({
|
|
472
592
|
kind: "exact",
|
|
473
593
|
path: "/api/dsh-mneme/llm-providers",
|
|
594
|
+
policy: "open",
|
|
474
595
|
handler(req, res) {
|
|
475
596
|
try {
|
|
476
597
|
const llm = ctx.llm;
|
|
@@ -499,11 +620,12 @@ export function createApi(ctx, service, settings, commands, embedder, semantic =
|
|
|
499
620
|
register({
|
|
500
621
|
kind: "exact",
|
|
501
622
|
path: "/api/dsh-mneme/test-model",
|
|
623
|
+
policy: "token",
|
|
502
624
|
handler(req, res) {
|
|
503
625
|
try {
|
|
504
626
|
if (req.method !== "POST") return sendJson(res, 404, { error: "not-found" });
|
|
505
|
-
|
|
506
|
-
|
|
627
|
+
return readBody(req, res).then(async (text) => {
|
|
628
|
+
if (text === null) return;
|
|
507
629
|
const body = parseBody(text);
|
|
508
630
|
let provider = typeof body.provider === "string" ? body.provider.trim() : "";
|
|
509
631
|
let model = typeof body.model === "string" ? body.model.trim() : "";
|
|
@@ -534,14 +656,15 @@ export function createApi(ctx, service, settings, commands, embedder, semantic =
|
|
|
534
656
|
register({
|
|
535
657
|
kind: "exact",
|
|
536
658
|
path: "/api/dsh-mneme/delete",
|
|
659
|
+
policy: "token",
|
|
537
660
|
handler(req, res) {
|
|
538
661
|
try {
|
|
539
662
|
if (req.method !== "POST") {
|
|
540
663
|
sendJson(res, 404, { error: "not-found" });
|
|
541
664
|
return;
|
|
542
665
|
}
|
|
543
|
-
|
|
544
|
-
|
|
666
|
+
return readBody(req, res).then((text) => {
|
|
667
|
+
if (text === null) return;
|
|
545
668
|
const body = parseBody(text);
|
|
546
669
|
const id = typeof body.id === "string" ? body.id.trim() : "";
|
|
547
670
|
if (!id) {
|
|
@@ -571,14 +694,15 @@ export function createApi(ctx, service, settings, commands, embedder, semantic =
|
|
|
571
694
|
register({
|
|
572
695
|
kind: "exact",
|
|
573
696
|
path: "/api/dsh-mneme/update",
|
|
697
|
+
policy: "token",
|
|
574
698
|
handler(req, res) {
|
|
575
699
|
try {
|
|
576
700
|
if (req.method !== "POST") {
|
|
577
701
|
sendJson(res, 404, { error: "not-found" });
|
|
578
702
|
return;
|
|
579
703
|
}
|
|
580
|
-
|
|
581
|
-
|
|
704
|
+
return readBody(req, res).then((text) => {
|
|
705
|
+
if (text === null) return;
|
|
582
706
|
const body = parseBody(text);
|
|
583
707
|
const id = typeof body.id === "string" ? body.id.trim() : "";
|
|
584
708
|
if (!id) {
|
|
@@ -677,14 +801,15 @@ export function createApi(ctx, service, settings, commands, embedder, semantic =
|
|
|
677
801
|
register({
|
|
678
802
|
kind: "exact",
|
|
679
803
|
path: "/api/dsh-mneme/create",
|
|
804
|
+
policy: "token",
|
|
680
805
|
handler(req, res) {
|
|
681
806
|
try {
|
|
682
807
|
if (req.method !== "POST") {
|
|
683
808
|
sendJson(res, 404, { error: "not-found" });
|
|
684
809
|
return;
|
|
685
810
|
}
|
|
686
|
-
|
|
687
|
-
|
|
811
|
+
return readBody(req, res).then((text) => {
|
|
812
|
+
if (text === null) return;
|
|
688
813
|
const body = parseBody(text);
|
|
689
814
|
if (!TYPES.has(body.type)) {
|
|
690
815
|
sendJson(res, 400, { error: "invalid-type" });
|
|
@@ -759,10 +884,10 @@ export function createApi(ctx, service, settings, commands, embedder, semantic =
|
|
|
759
884
|
register({
|
|
760
885
|
kind: "exact",
|
|
761
886
|
path: "/api/dsh-mneme/maintenance/reclaim",
|
|
887
|
+
policy: "token",
|
|
762
888
|
handler(req, res) {
|
|
763
889
|
try {
|
|
764
890
|
if (req.method === "GET") {
|
|
765
|
-
if (!requireAuth(req, res, apiToken)) return;
|
|
766
891
|
if (!maintenance) {
|
|
767
892
|
sendJson(res, 503, { error: RECLAIM_UNAVAILABLE });
|
|
768
893
|
return;
|
|
@@ -785,8 +910,8 @@ export function createApi(ctx, service, settings, commands, embedder, semantic =
|
|
|
785
910
|
sendJson(res, 404, { error: "not-found" });
|
|
786
911
|
return;
|
|
787
912
|
}
|
|
788
|
-
|
|
789
|
-
|
|
913
|
+
return readBody(req, res).then((text) => {
|
|
914
|
+
if (text === null) return;
|
|
790
915
|
const parsed = parseReclaimJson(text);
|
|
791
916
|
if (!parsed.ok) {
|
|
792
917
|
sendJson(res, 400, { error: parsed.error });
|
|
@@ -821,11 +946,12 @@ export function createApi(ctx, service, settings, commands, embedder, semantic =
|
|
|
821
946
|
register({
|
|
822
947
|
kind: "exact",
|
|
823
948
|
path: "/api/dsh-mneme/rules",
|
|
949
|
+
policy: "token-write",
|
|
824
950
|
handler(req, res) {
|
|
825
951
|
try {
|
|
826
952
|
if (req.method === "PUT" || req.method === "POST") {
|
|
827
|
-
|
|
828
|
-
|
|
953
|
+
return readBody(req, res).then((text) => {
|
|
954
|
+
if (text === null) return;
|
|
829
955
|
const body = parseBody(text);
|
|
830
956
|
settings.setRules(Array.isArray(body.rules) ? body.rules : []);
|
|
831
957
|
sendJson(res, 200, { rules: settings.getRules() });
|
|
@@ -842,12 +968,13 @@ export function createApi(ctx, service, settings, commands, embedder, semantic =
|
|
|
842
968
|
register({
|
|
843
969
|
kind: "exact",
|
|
844
970
|
path: "/api/dsh-mneme/vector-config",
|
|
971
|
+
policy: "token",
|
|
845
972
|
handler(req, res) {
|
|
846
973
|
try {
|
|
847
974
|
// Secret-bearing endpoint: fully protected when apiToken is set.
|
|
848
|
-
if (!requireAuth(req, res, apiToken)) return;
|
|
849
975
|
if (req.method === "PUT" || req.method === "POST") {
|
|
850
|
-
return readBody(req).then((text) => {
|
|
976
|
+
return readBody(req, res).then((text) => {
|
|
977
|
+
if (text === null) return;
|
|
851
978
|
const body = parseBody(text);
|
|
852
979
|
// An empty apiKey, or one that already looks masked (round-trips
|
|
853
980
|
// through maskApiKey unchanged), means "keep the existing key".
|
|
@@ -879,9 +1006,9 @@ export function createApi(ctx, service, settings, commands, embedder, semantic =
|
|
|
879
1006
|
register({
|
|
880
1007
|
kind: "exact",
|
|
881
1008
|
path: "/api/dsh-mneme/vector-reindex",
|
|
1009
|
+
policy: "token",
|
|
882
1010
|
handler(req, res) {
|
|
883
1011
|
try {
|
|
884
|
-
if (!requireAuth(req, res, apiToken)) return;
|
|
885
1012
|
if (!embedder) {
|
|
886
1013
|
sendJson(res, 200, { indexed: 0, skipped: 0, error: "vector-unavailable" });
|
|
887
1014
|
return;
|
|
@@ -911,6 +1038,7 @@ export function createApi(ctx, service, settings, commands, embedder, semantic =
|
|
|
911
1038
|
register({
|
|
912
1039
|
kind: "exact",
|
|
913
1040
|
path: "/api/dsh-mneme/semantic",
|
|
1041
|
+
policy: "open",
|
|
914
1042
|
handler(req, res) {
|
|
915
1043
|
try {
|
|
916
1044
|
const stats = semantic?.vectorIndex?.getStats?.() ?? null;
|
|
@@ -971,14 +1099,15 @@ export function createApi(ctx, service, settings, commands, embedder, semantic =
|
|
|
971
1099
|
register({
|
|
972
1100
|
kind: "exact",
|
|
973
1101
|
path: "/api/dsh-mneme/runtime/provision",
|
|
1102
|
+
policy: "token",
|
|
974
1103
|
handler(req, res) {
|
|
975
1104
|
try {
|
|
976
1105
|
if (req.method !== "POST") {
|
|
977
1106
|
sendJson(res, 405, { error: "method-not-allowed" });
|
|
978
1107
|
return;
|
|
979
1108
|
}
|
|
980
|
-
|
|
981
|
-
|
|
1109
|
+
return readBody(req, res).then(async (text) => {
|
|
1110
|
+
if (text === null) return;
|
|
982
1111
|
const body = parseBody(text);
|
|
983
1112
|
const runtimeDir = config?.runtimeDir ?? "";
|
|
984
1113
|
// 三档来源依次尝试(收编本机 node_modules → 本地 .tgz 目录 → registry)。
|
|
@@ -1008,6 +1137,7 @@ export function createApi(ctx, service, settings, commands, embedder, semantic =
|
|
|
1008
1137
|
register({
|
|
1009
1138
|
kind: "exact",
|
|
1010
1139
|
path: "/api/dsh-mneme/semantic/llm-audit",
|
|
1140
|
+
policy: "open",
|
|
1011
1141
|
handler(req, res) {
|
|
1012
1142
|
try {
|
|
1013
1143
|
const url = new URL(req.url, "http://localhost");
|
|
@@ -1030,6 +1160,7 @@ export function createApi(ctx, service, settings, commands, embedder, semantic =
|
|
|
1030
1160
|
register({
|
|
1031
1161
|
kind: "exact",
|
|
1032
1162
|
path: "/api/dsh-mneme/semantic/llm-audit/stats",
|
|
1163
|
+
policy: "open",
|
|
1033
1164
|
handler(req, res) {
|
|
1034
1165
|
try {
|
|
1035
1166
|
const url = new URL(req.url, "http://localhost");
|
|
@@ -1053,6 +1184,7 @@ export function createApi(ctx, service, settings, commands, embedder, semantic =
|
|
|
1053
1184
|
register({
|
|
1054
1185
|
kind: "exact",
|
|
1055
1186
|
path: "/api/dsh-mneme/semantic/graph/ego",
|
|
1187
|
+
policy: "open",
|
|
1056
1188
|
handler(req, res) {
|
|
1057
1189
|
try {
|
|
1058
1190
|
const url = new URL(req.url, "http://localhost");
|
|
@@ -1130,6 +1262,7 @@ export function createApi(ctx, service, settings, commands, embedder, semantic =
|
|
|
1130
1262
|
register({
|
|
1131
1263
|
kind: "exact",
|
|
1132
1264
|
path: "/api/dsh-mneme/semantic/graph/entity-attrs",
|
|
1265
|
+
policy: "open",
|
|
1133
1266
|
handler(req, res) {
|
|
1134
1267
|
try {
|
|
1135
1268
|
const url = new URL(req.url, "http://localhost");
|
|
@@ -1168,6 +1301,7 @@ export function createApi(ctx, service, settings, commands, embedder, semantic =
|
|
|
1168
1301
|
register({
|
|
1169
1302
|
kind: "exact",
|
|
1170
1303
|
path: "/api/dsh-mneme/entities",
|
|
1304
|
+
policy: "open",
|
|
1171
1305
|
handler(req, res) {
|
|
1172
1306
|
try {
|
|
1173
1307
|
const url = new URL(req.url, "http://localhost");
|
|
@@ -1187,6 +1321,7 @@ export function createApi(ctx, service, settings, commands, embedder, semantic =
|
|
|
1187
1321
|
register({
|
|
1188
1322
|
kind: "exact",
|
|
1189
1323
|
path: "/api/dsh-mneme/memories/entities",
|
|
1324
|
+
policy: "open",
|
|
1190
1325
|
handler(req, res) {
|
|
1191
1326
|
try {
|
|
1192
1327
|
const url = new URL(req.url, "http://localhost");
|
|
@@ -1217,8 +1352,12 @@ export function createApi(ctx, service, settings, commands, embedder, semantic =
|
|
|
1217
1352
|
register({
|
|
1218
1353
|
kind: "exact",
|
|
1219
1354
|
path: "/api/dsh-mneme/export",
|
|
1355
|
+
policy: "token",
|
|
1220
1356
|
handler(req, res) {
|
|
1221
1357
|
try {
|
|
1358
|
+
// 导出是「全库 + 含 archived/forgotten 的全字段」的一次读,敏感度不低于
|
|
1359
|
+
// 写面:它必须与写路由同门。历史漏项——这条路由长期没有鉴权分支,配了
|
|
1360
|
+
// apiToken 也照旧对任何调用方开放(2026-10 审计 P0)。
|
|
1222
1361
|
const url = new URL(req.url, "http://localhost");
|
|
1223
1362
|
const format = url.searchParams.get("format") ?? "json";
|
|
1224
1363
|
if (format !== "json" && format !== "markdown") {
|
|
@@ -1267,14 +1406,15 @@ export function createApi(ctx, service, settings, commands, embedder, semantic =
|
|
|
1267
1406
|
register({
|
|
1268
1407
|
kind: "exact",
|
|
1269
1408
|
path: "/api/dsh-mneme/import",
|
|
1409
|
+
policy: "token",
|
|
1270
1410
|
handler(req, res) {
|
|
1271
1411
|
try {
|
|
1272
1412
|
if (req.method !== "POST") {
|
|
1273
1413
|
sendJson(res, 404, { error: "not-found" });
|
|
1274
1414
|
return;
|
|
1275
1415
|
}
|
|
1276
|
-
|
|
1277
|
-
|
|
1416
|
+
return readBody(req, res).then((text) => {
|
|
1417
|
+
if (text === null) return;
|
|
1278
1418
|
const body = parseBody(text);
|
|
1279
1419
|
if (typeof body.type !== "string" || !Object.hasOwn(TYPE_FILE, body.type)) {
|
|
1280
1420
|
sendJson(res, 400, { error: "invalid-type" });
|
|
@@ -1308,6 +1448,7 @@ export function createApi(ctx, service, settings, commands, embedder, semantic =
|
|
|
1308
1448
|
register({
|
|
1309
1449
|
kind: "exact",
|
|
1310
1450
|
path: "/api/dsh-mneme/dream-status",
|
|
1451
|
+
policy: "open",
|
|
1311
1452
|
handler(req, res) {
|
|
1312
1453
|
try {
|
|
1313
1454
|
const runs = (service.listDreamRuns?.({ limit: 5 }) ?? []).map((r) => ({
|
|
@@ -1352,13 +1493,13 @@ export function createApi(ctx, service, settings, commands, embedder, semantic =
|
|
|
1352
1493
|
register({
|
|
1353
1494
|
kind: "exact",
|
|
1354
1495
|
path: "/api/dsh-mneme/conflicts",
|
|
1496
|
+
policy: "token",
|
|
1355
1497
|
handler(req, res) {
|
|
1356
1498
|
try {
|
|
1357
1499
|
if (req.method !== "GET") {
|
|
1358
1500
|
sendJson(res, 404, { error: "not-found" });
|
|
1359
1501
|
return;
|
|
1360
1502
|
}
|
|
1361
|
-
if (!requireAuth(req, res, apiToken)) return;
|
|
1362
1503
|
const url = new URL(req.url, "http://localhost");
|
|
1363
1504
|
const limit = Math.min(200, Math.max(1, Number(url.searchParams.get("limit") ?? 50) || 50));
|
|
1364
1505
|
const offset = Math.max(0, Number(url.searchParams.get("offset") ?? 0) || 0);
|
|
@@ -1373,14 +1514,15 @@ export function createApi(ctx, service, settings, commands, embedder, semantic =
|
|
|
1373
1514
|
register({
|
|
1374
1515
|
kind: "exact",
|
|
1375
1516
|
path: "/api/dsh-mneme/conflicts/resolve",
|
|
1517
|
+
policy: "token",
|
|
1376
1518
|
handler(req, res) {
|
|
1377
1519
|
try {
|
|
1378
1520
|
if (req.method !== "POST") {
|
|
1379
1521
|
sendJson(res, 404, { error: "not-found" });
|
|
1380
1522
|
return;
|
|
1381
1523
|
}
|
|
1382
|
-
|
|
1383
|
-
|
|
1524
|
+
return readBody(req, res).then((text) => {
|
|
1525
|
+
if (text === null) return;
|
|
1384
1526
|
let body = {};
|
|
1385
1527
|
try { body = JSON.parse(text || "{}"); } catch { /* 坏 JSON → 走缺参分支 */ }
|
|
1386
1528
|
const id = typeof body.id === "string" ? body.id.trim() : "";
|
|
@@ -1410,8 +1552,8 @@ export function createApi(ctx, service, settings, commands, embedder, semantic =
|
|
|
1410
1552
|
register({
|
|
1411
1553
|
kind: "exact",
|
|
1412
1554
|
path: "/api/dsh-mneme/health",
|
|
1555
|
+
policy: "token",
|
|
1413
1556
|
handler(req, res) {
|
|
1414
|
-
if (!requireAuth(req, res, apiToken)) return;
|
|
1415
1557
|
let state = null;
|
|
1416
1558
|
try {
|
|
1417
1559
|
state = service.getMirrorHealth?.() ?? null;
|
|
@@ -1459,11 +1601,12 @@ export function createApi(ctx, service, settings, commands, embedder, semantic =
|
|
|
1459
1601
|
register({
|
|
1460
1602
|
kind: "exact",
|
|
1461
1603
|
path: "/api/dsh-mneme/mode",
|
|
1604
|
+
policy: "token-write",
|
|
1462
1605
|
handler(req, res) {
|
|
1463
1606
|
try {
|
|
1464
1607
|
if (req.method === "PUT" || req.method === "POST") {
|
|
1465
|
-
|
|
1466
|
-
|
|
1608
|
+
return readBody(req, res).then((text) => {
|
|
1609
|
+
if (text === null) return;
|
|
1467
1610
|
const body = parseBody(text);
|
|
1468
1611
|
if (body.mode !== "light" && body.mode !== "standard") {
|
|
1469
1612
|
sendJson(res, 400, { error: "invalid-mode" });
|
|
@@ -1484,11 +1627,12 @@ export function createApi(ctx, service, settings, commands, embedder, semantic =
|
|
|
1484
1627
|
register({
|
|
1485
1628
|
kind: "exact",
|
|
1486
1629
|
path: "/api/dsh-mneme/external-api",
|
|
1630
|
+
policy: "token",
|
|
1487
1631
|
handler(req, res) {
|
|
1488
1632
|
try {
|
|
1489
1633
|
if (req.method === "PUT" || req.method === "POST") {
|
|
1490
|
-
|
|
1491
|
-
|
|
1634
|
+
return readBody(req, res).then((text) => {
|
|
1635
|
+
if (text === null) return;
|
|
1492
1636
|
const body = parseBody(text);
|
|
1493
1637
|
const patch = {};
|
|
1494
1638
|
if (body.enabled !== undefined) patch.enabled = body.enabled === true;
|
|
@@ -1511,9 +1655,13 @@ export function createApi(ctx, service, settings, commands, embedder, semantic =
|
|
|
1511
1655
|
sendJson(res, 200, { config: fullExternalConfig(settings.setExternalApi(patch)) });
|
|
1512
1656
|
});
|
|
1513
1657
|
}
|
|
1514
|
-
// GET
|
|
1515
|
-
//
|
|
1516
|
-
//
|
|
1658
|
+
// GET 回的是能解锁整个 8790 数据面的 Bearer,敏感度等同写面:必须与
|
|
1659
|
+
// PUT 同门。未配置 apiToken 时 isAuthorized 恒真(既有的 fail-open 模型,
|
|
1660
|
+
// 见 SECURITY.md「安全模型」一节),所以默认配置下面板仍旧即装即用。
|
|
1661
|
+
// GET: hand back a token — the standalone server generates its own on
|
|
1662
|
+
// first enabled boot, but the panel displays it before that, so
|
|
1663
|
+
// materialize and persist one here. 只有通过上面那道门才会走到这里,
|
|
1664
|
+
// 否则「未鉴权的 GET 顺手写库」就是一条不需要凭证的写路径。
|
|
1517
1665
|
const kv = settings.getExternalApi() ?? {};
|
|
1518
1666
|
if (!kv.token) {
|
|
1519
1667
|
kv.token = randomBytes(24).toString("base64url");
|
|
@@ -1535,11 +1683,12 @@ export function createApi(ctx, service, settings, commands, embedder, semantic =
|
|
|
1535
1683
|
register({
|
|
1536
1684
|
kind: "exact",
|
|
1537
1685
|
path: "/api/dsh-mneme/features",
|
|
1686
|
+
policy: "token-write",
|
|
1538
1687
|
handler(req, res) {
|
|
1539
1688
|
try {
|
|
1540
1689
|
if (req.method === "PUT" || req.method === "POST") {
|
|
1541
|
-
|
|
1542
|
-
|
|
1690
|
+
return readBody(req, res).then((text) => {
|
|
1691
|
+
if (text === null) return;
|
|
1543
1692
|
const body = parseBody(text);
|
|
1544
1693
|
if (typeof body !== "object" || body === null || Array.isArray(body) || Object.keys(body).length === 0) {
|
|
1545
1694
|
sendJson(res, 400, { error: "invalid-patch" });
|
|
@@ -1569,6 +1718,7 @@ export function createApi(ctx, service, settings, commands, embedder, semantic =
|
|
|
1569
1718
|
register({
|
|
1570
1719
|
kind: "exact",
|
|
1571
1720
|
path: "/api/dsh-mneme/inject-status",
|
|
1721
|
+
policy: "open",
|
|
1572
1722
|
handler(req, res) {
|
|
1573
1723
|
try {
|
|
1574
1724
|
if (req.method !== "GET") {
|
|
@@ -1593,6 +1743,7 @@ export function createApi(ctx, service, settings, commands, embedder, semantic =
|
|
|
1593
1743
|
register({
|
|
1594
1744
|
kind: "exact",
|
|
1595
1745
|
path: "/api/dsh-mneme/inject-preview",
|
|
1746
|
+
policy: "open",
|
|
1596
1747
|
handler(req, res) {
|
|
1597
1748
|
try {
|
|
1598
1749
|
if (req.method !== "GET") {
|
|
@@ -1610,11 +1761,12 @@ export function createApi(ctx, service, settings, commands, embedder, semantic =
|
|
|
1610
1761
|
register({
|
|
1611
1762
|
kind: "exact",
|
|
1612
1763
|
path: "/api/dsh-mneme/commands",
|
|
1764
|
+
policy: "token-write",
|
|
1613
1765
|
handler(req, res) {
|
|
1614
1766
|
try {
|
|
1615
1767
|
if (req.method === "POST") {
|
|
1616
|
-
|
|
1617
|
-
|
|
1768
|
+
return readBody(req, res).then((text) => {
|
|
1769
|
+
if (text === null) return;
|
|
1618
1770
|
const body = parseBody(text);
|
|
1619
1771
|
try {
|
|
1620
1772
|
const command = commands.add({
|
|
@@ -1629,7 +1781,6 @@ export function createApi(ctx, service, settings, commands, embedder, semantic =
|
|
|
1629
1781
|
});
|
|
1630
1782
|
}
|
|
1631
1783
|
if (req.method === "DELETE") {
|
|
1632
|
-
if (!requireAuth(req, res, apiToken)) return;
|
|
1633
1784
|
const url = new URL(req.url, "http://localhost");
|
|
1634
1785
|
const id = url.searchParams.get("id");
|
|
1635
1786
|
const removed = id ? commands.remove(id) : false;
|
|
@@ -1647,11 +1798,15 @@ export function createApi(ctx, service, settings, commands, embedder, semantic =
|
|
|
1647
1798
|
// 只读端点,读的是**项目里**的 `<项目>/.mneme/pipelines/*.json`(文件头在
|
|
1648
1799
|
// src/pipeline.js),项目层优先、插件包里那份降级成全局层;投影成
|
|
1649
1800
|
// mneme.pipeline/v1 交给面板与 agent——两边读同一份,画面只是它的投影。
|
|
1801
|
+
// 运行账(`<id>.runs.jsonl`,文件头在 src/pipeline-runs.js)也在这里读:**没有账,
|
|
1802
|
+
// 读侧算不出任何状态**(`tallyRuns` 拿空数组时全体节点恒为 candidate),所以喂不喂
|
|
1803
|
+
// runs 决定的是「面板上有没有一句实话」,不是多一个可选装饰。
|
|
1650
1804
|
// 刻意不做旗标闸门:旗标管的是**客户端座位**(关=一个座位都不注册),不是数据面;
|
|
1651
1805
|
// 多一个只读端点不会让任何人看见它,而给它加闸门只会让「为什么面板打不开」多一种成因。
|
|
1652
1806
|
register({
|
|
1653
1807
|
kind: "exact",
|
|
1654
1808
|
path: "/api/dsh-mneme/pipeline",
|
|
1809
|
+
policy: "open",
|
|
1655
1810
|
handler(req, res) {
|
|
1656
1811
|
try {
|
|
1657
1812
|
const url = new URL(req.url, "http://localhost");
|
|
@@ -1665,7 +1820,76 @@ export function createApi(ctx, service, settings, commands, embedder, semantic =
|
|
|
1665
1820
|
sendJson(res, 400, { error: "project-not-absolute", detail: project });
|
|
1666
1821
|
return;
|
|
1667
1822
|
}
|
|
1668
|
-
|
|
1823
|
+
|
|
1824
|
+
// 只读**账本目录**来定「哪些 id 有账」:最新一条 run 可能属于一条刚被删掉或
|
|
1825
|
+
// 还没写完的 .json,这时工作流目录里问不到它的 id,而账本还在盘上。
|
|
1826
|
+
// runIds 必须在 handler 级声明:下面观察旁挂块(同函数的另一个 if 分支)也按
|
|
1827
|
+
// 这份 id 列表取观察——之前声明在账本分支的块级作用域里,旁挂块一引用就
|
|
1828
|
+
// ReferenceError,被 catch 静默吞成「没有观察」(CodeRabbit #458 F1,
|
|
1829
|
+
// 复盘注释见下方旁挂块)。
|
|
1830
|
+
let runIds = [];
|
|
1831
|
+
let runsResult = { runsById: {}, errors: [], statesById: {} };
|
|
1832
|
+
if (project !== null) {
|
|
1833
|
+
const where = runsDir(project);
|
|
1834
|
+
runIds = existsSync(where)
|
|
1835
|
+
? readdirSync(where)
|
|
1836
|
+
.filter((f) => f.endsWith(RUNS_SUFFIX))
|
|
1837
|
+
.sort()
|
|
1838
|
+
.map((f) => f.slice(0, -RUNS_SUFFIX.length))
|
|
1839
|
+
: [];
|
|
1840
|
+
if (runIds.length) {
|
|
1841
|
+
// step id 闸门:账本里出现「图上没有的 step id」时,credit 会落在一个没有节点的
|
|
1842
|
+
// 地方——**记了、数得出来、面板上永远看不见**,症状与「从没跑过」同形。
|
|
1843
|
+
// 这里把每条工作流的步骤表读出来当白名单,让那种行变成看得见的噪音。
|
|
1844
|
+
let stepIdsById = null;
|
|
1845
|
+
try {
|
|
1846
|
+
stepIdsById = Object.create(null);
|
|
1847
|
+
for (const p of loadPipelines(projectPipelineDir(project), { scope: "project" })) {
|
|
1848
|
+
stepIdsById[p.id] = p.steps.map((s) => s.id);
|
|
1849
|
+
}
|
|
1850
|
+
} catch {
|
|
1851
|
+
// 项目层有坏 .json 时 readPipelineDoc 会把它记进 `unreadable`;这里不重复报,
|
|
1852
|
+
// 也绝不因此让整个面板打不开——闸门退化成「没有白名单」,账本照读。
|
|
1853
|
+
stepIdsById = null;
|
|
1854
|
+
}
|
|
1855
|
+
runsResult = loadRunsById(project, runIds, { expectedStepIdsById: stepIdsById });
|
|
1856
|
+
}
|
|
1857
|
+
}
|
|
1858
|
+
const { runsById, errors: runsErrors, statesById } = runsResult;
|
|
1859
|
+
|
|
1860
|
+
// P3 投影的燃料:观察旁挂(与 /candidates 的 memoryDir 同源同默认)。
|
|
1861
|
+
// 失败不连累图——旁挂是可再生的辅助数据,读不到就当没有,提议少一类而已。
|
|
1862
|
+
let observationsById = {};
|
|
1863
|
+
if (project !== null) {
|
|
1864
|
+
try {
|
|
1865
|
+
const memoryDir = (config && typeof config.memoryDir === "string" && config.memoryDir.trim() !== "")
|
|
1866
|
+
? config.memoryDir
|
|
1867
|
+
: "~/.dsh/memory";
|
|
1868
|
+
const odir = observationDir({ memoryDir, projectKey: projectKey(project) });
|
|
1869
|
+
if (odir) {
|
|
1870
|
+
observationsById = loadObservationsById(odir, runIds.filter(Boolean));
|
|
1871
|
+
}
|
|
1872
|
+
} catch {
|
|
1873
|
+
// 读旁挂失败不算账本问题:如实降级成「没有观察」,fix-step-tool 少发而已
|
|
1874
|
+
}
|
|
1875
|
+
}
|
|
1876
|
+
|
|
1877
|
+
const doc = readPipelineDoc({ projectRoot: project, id, runsById, observationsById });
|
|
1878
|
+
|
|
1879
|
+
// 「读不出来」与「还没跑过」在节点状态上完全同形(都是全体 candidate),而且
|
|
1880
|
+
// **不带 id 的默认视图没有兜底**——那正是用户打开时看到的那一屏。所以状态按
|
|
1881
|
+
// pipeline 逐条显式带出去(清单行的 `runsState` 与选中那条的顶层 `runsState`),
|
|
1882
|
+
// 客户端不必靠顶层噪音猜。也刻意**不再**对坏账本回 500:那会让客户端把它当成
|
|
1883
|
+
// 「离线」,还顺手吞掉本来读得出来的那张图(账本坏了,工作流本身没坏)。
|
|
1884
|
+
// 没有项目根时账本层无从读起,如实给 null,而不是谎称「没有账本文件」。
|
|
1885
|
+
const chosenId = doc.pipeline ? doc.pipeline.id : null;
|
|
1886
|
+
const runsState = project !== null && chosenId !== null ? statesById[chosenId] || "absent" : null;
|
|
1887
|
+
const available =
|
|
1888
|
+
project === null
|
|
1889
|
+
? doc.available
|
|
1890
|
+
: doc.available.map((row) => ({ ...row, runsState: statesById[row.id] || "absent" }));
|
|
1891
|
+
|
|
1892
|
+
sendJson(res, 200, { ...doc, available, runsState, runsErrors });
|
|
1669
1893
|
} catch (error) {
|
|
1670
1894
|
// 工作流是人手写的 JSON,报错本身就是给人看的:说清哪一条、坏在哪,
|
|
1671
1895
|
// 而不是回一句 internal 让人去翻日志。
|
|
@@ -1690,6 +1914,7 @@ export function createApi(ctx, service, settings, commands, embedder, semantic =
|
|
|
1690
1914
|
register({
|
|
1691
1915
|
kind: "exact",
|
|
1692
1916
|
path: "/api/dsh-mneme/candidates",
|
|
1917
|
+
policy: "open",
|
|
1693
1918
|
handler(req, res) {
|
|
1694
1919
|
try {
|
|
1695
1920
|
const url = new URL(req.url, "http://localhost");
|
|
@@ -1713,7 +1938,7 @@ export function createApi(ctx, service, settings, commands, embedder, semantic =
|
|
|
1713
1938
|
});
|
|
1714
1939
|
|
|
1715
1940
|
return {
|
|
1716
|
-
routes:
|
|
1941
|
+
routes: countRoutes(),
|
|
1717
1942
|
dispose: () => {
|
|
1718
1943
|
for (const dispose of disposers) dispose();
|
|
1719
1944
|
}
|