@jjh32456/dsh-longlive 1.6.7 → 1.8.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +70 -1508
- package/LICENSE +1 -1
- package/QUICKSTART.md +34 -13
- package/README.en.md +18 -7
- package/README.md +34 -9
- package/SPEC.md +5 -5
- package/cordis.patch.yml +23 -9
- package/docs/ANSWER-GUARD.md +92 -0
- package/docs/ARCHITECTURE.md +4 -3
- package/docs/AUTO-RECALL-WIRING.md +1 -1
- package/docs/AUTO-RECALL.md +15 -9
- package/docs/CONFIG.md +168 -35
- package/docs/ERRORS.md +17 -5
- package/docs/FAQ.md +7 -7
- package/docs/FEATURES.md +96 -28
- package/docs/MIGRATION-1.6.0.md +2 -2
- package/docs/OPERATIONS.md +6 -6
- package/docs/PANEL-INTEGRATION.md +6 -0
- package/docs/PRIVACY.md +88 -0
- package/docs/QUICKSTART.md +5 -5
- package/docs/README.en.md +4 -2
- package/docs/RESIDENT-WATCHER.md +1 -1
- package/docs/SESSION-WATCH.md +6 -6
- package/docs/TROUBLESHOOTING.md +4 -4
- package/package.json +47 -117
- package/runbook.md +2 -2
- package/scripts/backfill-auto-cards.mjs +98 -0
- package/scripts/install-surgery-task.ps1 +5 -2
- package/scripts/lib/session-watch-fixture.mjs +1 -1
- package/scripts/longlive-doctor.mjs +6 -0
- package/scripts/migrate-180.mjs +163 -0
- package/scripts/recall-index-build.mjs +128 -0
- package/scripts/session-watch-drill.mjs +1 -1
- package/scripts/surgery-run.cmd +1 -1
- package/scripts/unattended-surgery.ps1 +5 -2
- package/scripts/verify-card-anchors.mjs +138 -0
- package/src/actions/audit.js +125 -0
- package/src/actions/evidence.js +122 -0
- package/src/actions/fs.js +162 -0
- package/src/activity/index.js +760 -0
- package/src/activity/model.js +351 -0
- package/src/activity/store.js +644 -0
- package/src/activity/thinking.js +176 -0
- package/src/adapters/constants.js +11 -0
- package/src/adapters/engram/data/aliases.json +51 -0
- package/src/adapters/engram/data/policy.json +8 -0
- package/src/adapters/engram/data/routing-rules.json +546 -0
- package/src/adapters/engram/index.js +235 -0
- package/src/adapters/registry.js +315 -0
- package/src/answer/audit.js +193 -0
- package/src/answer/evidence.js +30 -0
- package/src/answer/guard.js +1370 -0
- package/src/answer/index.js +1067 -0
- package/src/answer/query.js +111 -0
- package/src/answer/second-hop.mjs +101 -0
- package/src/auto-truncate.js +1 -1
- package/src/config.js +171 -47
- package/src/deps.js +6 -4
- package/src/features.js +182 -6
- package/src/heartbeat.js +156 -23
- package/src/identity/handover.js +52 -1
- package/src/identity/index.js +13 -5
- package/src/identity/inject-position.js +2 -2
- package/src/index.js +97 -7
- package/src/lifecycle/gap-clocks.js +328 -0
- package/src/lifecycle/index.js +269 -0
- package/src/live.js +22 -2
- package/src/memory/activity-capture.js +229 -0
- package/src/memory/anchors.js +194 -0
- package/src/memory/audit-records.js +222 -0
- package/src/memory/auto-cards.js +401 -0
- package/src/memory/capture.js +442 -0
- package/src/memory/card-anchors.js +203 -0
- package/src/memory/conflicts.js +106 -0
- package/src/memory/decay.js +311 -0
- package/src/memory/distill.js +471 -0
- package/src/memory/event-index.js +917 -0
- package/src/memory/evidence-chain.js +295 -0
- package/src/memory/evidence.js +145 -0
- package/src/memory/filter-runner.js +1 -1
- package/src/memory/filter.js +5 -1
- package/src/memory/forget-v2.js +410 -0
- package/src/memory/forget.js +71 -0
- package/src/memory/frames-activation.js +247 -0
- package/src/memory/fts-query.js +174 -0
- package/src/memory/governance.js +604 -0
- package/src/memory/index.js +1 -0
- package/src/memory/l2-gate.js +169 -0
- package/src/memory/lock.js +121 -0
- package/src/memory/migrate.js +139 -0
- package/src/memory/recall-v2.js +223 -0
- package/src/memory/recall.js +65 -2
- package/src/memory/records.js +425 -0
- package/src/memory/retention.js +128 -0
- package/src/memory/review.js +124 -0
- package/src/memory/trust.js +88 -0
- package/src/memory/version-chain.js +264 -0
- package/src/memory/wal.js +137 -0
- package/src/memory/working-frame.js +516 -0
- package/src/metrics/classify.mjs +5 -3
- package/src/metrics/index.mjs +21 -1
- package/src/metrics/prestep-latency.mjs +300 -0
- package/src/metrics/store.mjs +25 -0
- package/src/policy-profile.js +18 -3
- package/src/recall/anaphora.mjs +87 -0
- package/src/recall/cards.mjs +7 -0
- package/src/recall/embedding.mjs +8 -8
- package/src/recall/entities.mjs +112 -0
- package/src/recall/index.js +235 -19
- package/src/recall/inject-order.mjs +10 -8
- package/src/recall/privacy.mjs +105 -16
- package/src/recall/recall-core.mjs +116 -10
- package/src/recall/shared-visibility.js +302 -0
- package/src/recall/time-normalizer.mjs +196 -0
- package/src/role/activation.js +191 -0
- package/src/role/attention.js +318 -0
- package/src/role/base.js +36 -0
- package/src/role/channels.js +423 -0
- package/src/role/index.js +425 -0
- package/src/role/interfaces.js +115 -0
- package/src/role/personality.js +370 -0
- package/src/role/readers.js +151 -0
- package/src/role/relationships.js +477 -0
- package/src/role/store.js +202 -0
- package/src/role/writeprotect.js +373 -0
- package/src/router/aliases.json +176 -25
- package/src/router/background/agent-runner.mjs +1 -1
- package/src/router/background/anchor.mjs +1 -1
- package/src/router/background/audit.mjs +2 -2
- package/src/router/background/budget-ledger.mjs +2 -2
- package/src/router/background/host-sim.mjs +1 -1
- package/src/router/background/memory.mjs +4 -4
- package/src/router/background/queue.mjs +1 -1
- package/src/router/background/retry.mjs +1 -1
- package/src/router/background/router.mjs +1 -1
- package/src/router/background/stream.mjs +1 -1
- package/src/router/background/tool-policy.mjs +1 -1
- package/src/router/design-headings.json +5 -5
- package/src/router/face/config.mjs +1 -1
- package/src/router/face/identity.mjs +3 -3
- package/src/router/judge.mjs +8 -7
- package/src/router/policies/generic.json +21 -4
- package/src/router/route.js +109 -22
- package/src/router/router.mjs +10 -6
- package/src/router/routing-rules.json +260 -445
- package/src/router/run-sim.mjs +8 -8
- package/src/router/runtime.js +23 -12
- package/src/router/service/board.mjs +95 -15
- package/src/router/service/config.mjs +5 -5
- package/src/router/service/context.mjs +1 -1
- package/src/router/service/hint.mjs +14 -0
- package/src/router/service/queue.mjs +1 -1
- package/src/router/service/service.mjs +27 -11
- package/src/router/shadow.mjs +4 -3
- package/src/surgery-recovery.js +2 -2
- package/src/tool-output.js +2 -2
- package/src/tools.js +39 -7
- package/src/truncate-visible.js +2 -2
- package/src/ui/actions.js +1312 -0
- package/src/ui/panel-api.js +367 -0
- package/src/ui/panel-auth.js +251 -0
- package/src/ui/panel-integrity.js +243 -0
- package/src/ui/routes-readonly.js +695 -0
- package/src/ui/routes.js +3 -0
- package/docs/DEV.md +0 -279
- package/docs/PRESENCE-BUS-WIRING.md +0 -491
- package/docs/PRESENCE-BUS.md +0 -694
- package/docs/SURGERY.md +0 -186
- package/scripts/mark-core-cards.mjs +0 -289
- package/scripts/pack.mjs +0 -392
- package/scripts/router-proposals.mjs +0 -386
- package/src/router/fixtures/M1.json +0 -16
- package/src/router/fixtures/M2.json +0 -16
- package/src/router/fixtures/M3.json +0 -20
- package/src/router/fixtures/M4.json +0 -20
- package/src/router/fixtures/M5.json +0 -45
- package/src/router/fixtures/M6.json +0 -53
- package/src/router/fixtures/M7.json +0 -46
- package/src/router/fixtures/S1.json +0 -20
- package/src/router/fixtures/S2.json +0 -23
- package/src/router/fixtures/S3.json +0 -20
- package/src/router/fixtures/S4.json +0 -34
- package/src/router/fixtures/S5.json +0 -43
- package/src/router/fixtures/S6.json +0 -47
- package/src/router/fixtures/T1.json +0 -23
- package/src/router/fixtures/T2.json +0 -16
- package/src/router/fixtures/T3.json +0 -19
- package/src/router/fixtures/T4.json +0 -16
- package/src/router/fixtures/X1.json +0 -17
- package/src/router/fixtures/X2.json +0 -20
- package/src/router/gen-design-headings.mjs +0 -107
- package/src/router/policies/owner-rulings-2026-11-03.json +0 -62
- package/test/auto-recall.test.mjs +0 -474
- package/test/auto-truncate.test.mjs +0 -931
- package/test/autobind.test.mjs +0 -385
- package/test/bus.test.mjs +0 -1572
- package/test/cli-guard.test.mjs +0 -198
- package/test/conclusion.test.mjs +0 -408
- package/test/config-schema.test.mjs +0 -204
- package/test/d6-output.test.mjs +0 -194
- package/test/d6.test.mjs +0 -395
- package/test/deps.test.mjs +0 -342
- package/test/doctor-setup.test.mjs +0 -297
- package/test/embedding-recall.test.mjs +0 -347
- package/test/feature-flags.test.mjs +0 -568
- package/test/filter-shadow.test.mjs +0 -170
- package/test/fixtures/baseline-0.8.0-registration.json +0 -556
- package/test/fixtures/d6-header-tools.json +0 -1
- package/test/fixtures/lock-child.mjs +0 -67
- package/test/fixtures/pre-step-container.mjs +0 -100
- package/test/fixtures/privacy-memory/topics/pii-unmarked.md +0 -9
- package/test/fixtures/privacy-memory/topics/secret-local-only.md +0 -11
- package/test/fixtures/profile-session-longlive-config.json +0 -20
- package/test/fixtures/proposal-cas-child.mjs +0 -66
- package/test/host-engine-status.test.mjs +0 -307
- package/test/hot-truncate.test.mjs +0 -1349
- package/test/identity-card-marker.test.mjs +0 -307
- package/test/identity-router/run.mjs +0 -721
- package/test/integration-pre-step.test.mjs +0 -553
- package/test/live.test.mjs +0 -1020
- package/test/maintenance-schedule.test.mjs +0 -538
- package/test/memory.test.mjs +0 -2828
- package/test/metrics.test.mjs +0 -478
- package/test/orchestrator.test.mjs +0 -266
- package/test/p1_probe.mjs +0 -97
- package/test/packaging.test.mjs +0 -182
- package/test/portability.test.mjs +0 -343
- package/test/rc4-regressions.test.mjs +0 -562
- package/test/rc5-regressions.test.mjs +0 -197
- package/test/rc6-chinese.test.mjs +0 -394
- package/test/router-1100/apply-off.test.mjs +0 -175
- package/test/router-1100/budget-reconcile.test.mjs +0 -215
- package/test/router-1100/config-context.test.mjs +0 -211
- package/test/router-1100/helpers.mjs +0 -129
- package/test/router-1100/hint-envelope.test.mjs +0 -150
- package/test/router-1100/lock-crossproc.test.mjs +0 -206
- package/test/router-1100/proposal-cas-crossproc.test.mjs +0 -184
- package/test/router-1100/router-apply.test.mjs +0 -372
- package/test/router-1100/router-fixb.test.mjs +0 -602
- package/test/router-1100/router-proposals.test.mjs +0 -405
- package/test/router-1100/router-queue-lease-wal.test.mjs +0 -355
- package/test/router-1100/router-security-cr.test.mjs +0 -333
- package/test/router-1100/service-lifecycle.test.mjs +0 -220
- package/test/router-1100/tools-contract.test.mjs +0 -164
- package/test/router-1100/turn-reflect-tick.test.mjs +0 -89
- package/test/router-1100/v6-contract.test.mjs +0 -158
- package/test/router-1100/v6b-contract.test.mjs +0 -293
- package/test/router-1100/v6c-contract.test.mjs +0 -249
- package/test/router-1100/wal-rotation.test.mjs +0 -375
- package/test/router-1100/wp1-config.test.mjs +0 -170
- package/test/router-1100/wp1-default-off.test.mjs +0 -77
- package/test/router-1100/wp1-model-status.test.mjs +0 -90
- package/test/router-1100/wp1-status.test.mjs +0 -220
- package/test/router-1100/wp1-tools.test.mjs +0 -146
- package/test/router-1100/wp5-cli.test.mjs +0 -240
- package/test/router-1100/wp5-model-auto.test.mjs +0 -418
- package/test/router-1100/wp6-merge.test.mjs +0 -386
- package/test/router-1100/wp6-wake.test.mjs +0 -502
- package/test/router-background/audit.test.mjs +0 -37
- package/test/router-background/budget-ledger.test.mjs +0 -330
- package/test/router-background/cross-process.test.mjs +0 -222
- package/test/router-background/fixtures/cross-child.mjs +0 -85
- package/test/router-background/fixtures/real-shapes.mjs +0 -239
- package/test/router-background/router.test.mjs +0 -1044
- package/test/router-background/stream-retry.test.mjs +0 -358
- package/test/router-background/tool-agent.test.mjs +0 -384
- package/test/router-face/bridge.test.mjs +0 -169
- package/test/router-face/face.test.mjs +0 -316
- package/test/router-failclosed.test.mjs +0 -466
- package/test/router-identity.test.mjs +0 -384
- package/test/router-l1-l2.contract.test.mjs +0 -1075
- package/test/router-memory-tools.test.mjs +0 -262
- package/test/router-policy.test.mjs +0 -225
- package/test/router-runtime.test.mjs +0 -438
- package/test/router-shadow.test.mjs +0 -287
- package/test/router.test.mjs +0 -903
- package/test/run-rc.mjs +0 -299
- package/test/run.mjs +0 -2469
- package/test/safety-defaults.test.mjs +0 -179
- package/test/seeded-load.test.mjs +0 -436
- package/test/session-original.test.mjs +0 -338
- package/test/surgery-recovery.test.mjs +0 -936
- package/test/t7_drive.mjs +0 -151
- package/test/tool-output.test.mjs +0 -393
- package/test/tool-schema.test.mjs +0 -177
- package/test/tools_contract.test.mjs +0 -716
- package/test/truncate-visible.test.mjs +0 -1150
- package/test/ui-flags.test.mjs +0 -477
- package/test/v4-hardening.test.mjs +0 -427
|
@@ -0,0 +1,695 @@
|
|
|
1
|
+
// src/ui/routes-readonly.js — M2-F 面板只读数据面(16 条 /api/longlive/*)
|
|
2
|
+
//
|
|
3
|
+
// 契约(MEMORY-RECALL-V2-PROPOSAL v2.6.3 §12.3 / §12.7-G2/G3/G4 / §12.11-R9):
|
|
4
|
+
// 16 条只读路由,全部过同一道能力令牌门(panel-auth),面板只走 HTTP、
|
|
5
|
+
// 不直连 SQLite、不读 `.longlive/` 之外的路径。
|
|
6
|
+
// #16 GET /api/longlive/integrity 返回 R8 manifest 校验状态;ok=false 时
|
|
7
|
+
// **只读展示保留**,动作面由 actions.js 判 401 E_PANEL_INTEGRITY。
|
|
8
|
+
//
|
|
9
|
+
// 本模块可在无宿主环境直接构造(契约测试用 createReadonlyRouter + handle);
|
|
10
|
+
// 宿主接线见 registerReadonlyRoutes(复用 src/ui/routes.js 的 ctx.inject 模式)。
|
|
11
|
+
// 数据文件缺失一律给 `present:false` 空态,绝不抛错、绝不新增文件。
|
|
12
|
+
|
|
13
|
+
import fs from 'node:fs';
|
|
14
|
+
import path from 'node:path';
|
|
15
|
+
|
|
16
|
+
import { listRecords } from '../memory/records.js';
|
|
17
|
+
import { unifiedConflicts } from '../memory/conflicts.js';
|
|
18
|
+
import { readEvidenceChain } from '../memory/evidence-chain.js';
|
|
19
|
+
import { listBanks, readClaims } from '../memory/governance.js';
|
|
20
|
+
import { appendAuditRecord, normalizeAuditRecordRow } from '../memory/audit-records.js';
|
|
21
|
+
import { readPrestepSummary } from '../metrics/store.mjs';
|
|
22
|
+
import { readChannels } from '../role/channels.js';
|
|
23
|
+
import { readAttentionView, readPersonalityView, readRelationshipsView } from '../role/readers.js';
|
|
24
|
+
|
|
25
|
+
import { REVIEW_SCHEMA, normalizeReviewRow } from './actions.js';
|
|
26
|
+
|
|
27
|
+
export const READONLY_SCHEMA_VERSION = 1;
|
|
28
|
+
export const READONLY_BODY_LIMIT = 16 * 1024;
|
|
29
|
+
export const EVENTS_LIMIT_MAX = 200;
|
|
30
|
+
export const LOG_LIMIT_MAX = 200;
|
|
31
|
+
|
|
32
|
+
/** Producer module that will fill each file in later M2 slices (Gate-1 honesty). */
|
|
33
|
+
/** R4 P1-6:27 条只读路由的显式 id 预留表(先冻结名字与 id,实现按表注册,不改号段)。 */
|
|
34
|
+
export const ROUTE_ID_BY_NAME = Object.freeze({
|
|
35
|
+
flags: 1,
|
|
36
|
+
board: 2,
|
|
37
|
+
status: 3,
|
|
38
|
+
live: 4,
|
|
39
|
+
activity: 5,
|
|
40
|
+
'evidence/latest': 6,
|
|
41
|
+
gap: 7,
|
|
42
|
+
'recall-log': 8,
|
|
43
|
+
'write-health': 9,
|
|
44
|
+
review: 10,
|
|
45
|
+
events: 11,
|
|
46
|
+
distill: 12,
|
|
47
|
+
archive: 13,
|
|
48
|
+
protection: 14,
|
|
49
|
+
'anchor/:anchorId': 15,
|
|
50
|
+
integrity: 16,
|
|
51
|
+
records: 17,
|
|
52
|
+
// 18–23 activity-writer(治理/连续性):claims / evidence/:claimId / banks / conflicts / audit/records / frames
|
|
53
|
+
claims: 18,
|
|
54
|
+
'evidence/:claimId': 19,
|
|
55
|
+
banks: 20,
|
|
56
|
+
conflicts: 21,
|
|
57
|
+
'audit/records': 22,
|
|
58
|
+
frames: 23,
|
|
59
|
+
// 24–26 state-truth(角色内核):attention / relationships / personality
|
|
60
|
+
attention: 24,
|
|
61
|
+
relationships: 25,
|
|
62
|
+
personality: 26,
|
|
63
|
+
// 27 release-check:channels
|
|
64
|
+
channels: 27,
|
|
65
|
+
})
|
|
66
|
+
|
|
67
|
+
export const READONLY_ROUTES = Object.freeze([
|
|
68
|
+
{ id: 1, path: '/api/longlive/flags', schema: 'flags/v1', producer: 'src/ui/flags.js + host configEditor', dynamic: false },
|
|
69
|
+
{ id: 2, path: '/api/longlive/board', schema: 'board/v1', producer: 'src/activity/index.js', dynamic: false },
|
|
70
|
+
{ id: 3, path: '/api/longlive/status', schema: 'status/v1', producer: 'src/engine-status.js + src/heartbeat.js', dynamic: false },
|
|
71
|
+
{ id: 4, path: '/api/longlive/live', schema: 'live/v1', producer: 'src/live.js + heartbeat', dynamic: false },
|
|
72
|
+
{ id: 5, path: '/api/longlive/activity', schema: 'activity/v1', producer: 'src/activity/store.js', dynamic: false },
|
|
73
|
+
{ id: 6, path: '/api/longlive/evidence/latest', schema: 'evidence-package/v1', producer: 'M2-C pre-step recall pipeline', dynamic: false },
|
|
74
|
+
{ id: 7, path: '/api/longlive/gap', schema: 'gap/v1', producer: 'M2-B lifecycle/gap-clocks.js', dynamic: false },
|
|
75
|
+
{ id: 8, path: '/api/longlive/recall-log', schema: 'recall-log/v1', producer: 'M2-C recall pipeline', dynamic: false },
|
|
76
|
+
{ id: 9, path: '/api/longlive/write-health', schema: 'write-health/v1', producer: 'M2-B capture writer', dynamic: false },
|
|
77
|
+
{ id: 10, path: '/api/longlive/review', schema: 'review/v1', producer: 'M2-D maintenance/review queue', dynamic: false },
|
|
78
|
+
{ id: 11, path: '/api/longlive/events', schema: 'events/v1', producer: 'M2-A event index', dynamic: false },
|
|
79
|
+
{ id: 12, path: '/api/longlive/distill', schema: 'distill/v1', producer: 'M2-D distiller', dynamic: false },
|
|
80
|
+
{ id: 13, path: '/api/longlive/archive', schema: 'archive/v1', producer: 'M2-D forget/archive flow', dynamic: false },
|
|
81
|
+
{ id: 14, path: '/api/longlive/protection', schema: 'protection/v1', producer: 'M2-B protection predicate writer', dynamic: false },
|
|
82
|
+
{ id: 15, path: '/api/longlive/anchor/:anchorId', schema: 'anchor/v1', producer: 'M2-A/M2-C anchor liveness', dynamic: true },
|
|
83
|
+
{ id: 16, path: '/api/longlive/integrity', schema: 'integrity/v1', producer: 'host package manager + loader verifier', dynamic: false },
|
|
84
|
+
{ id: 17, path: '/api/longlive/records', schema: 'records/v1', producer: 'M2-B records writer(evidence[]/sourceTier/conflict)', dynamic: false },
|
|
85
|
+
{ id: 18, path: '/api/longlive/claims', schema: 'claims/v1', producer: 'M2-B governance.js readClaims', dynamic: false },
|
|
86
|
+
{ id: 19, path: '/api/longlive/evidence/:claimId', schema: 'evidence-chain/v1', producer: 'M2-B evidence-chain.js', dynamic: true },
|
|
87
|
+
{ id: 20, path: '/api/longlive/banks', schema: 'banks/v1', producer: 'M2-B governance.js listBanks', dynamic: false },
|
|
88
|
+
{ id: 21, path: '/api/longlive/conflicts', schema: 'conflicts/v1', producer: 'M2-B conflicts.js unifiedConflicts', dynamic: false },
|
|
89
|
+
{ id: 22, path: '/api/longlive/audit/records', schema: 'audit-records/v1', producer: 'M2-B read/write audit(.longlive/audit/records.jsonl)', dynamic: false },
|
|
90
|
+
{ id: 23, path: '/api/longlive/frames', schema: 'frames/v1', producer: 'M2-B working-frame frame stack', dynamic: false },
|
|
91
|
+
// #24–#26 角色内核(v2.8 §16.3;state-truth 生产者:src/role/*)。
|
|
92
|
+
{ id: 24, path: '/api/longlive/attention', schema: 'attention/v1', producer: 'src/role/attention.js(state-truth)', dynamic: false },
|
|
93
|
+
{ id: 25, path: '/api/longlive/relationships', schema: 'relationships/v1', producer: 'src/role/relationships.js(state-truth)', dynamic: false },
|
|
94
|
+
{ id: 26, path: '/api/longlive/personality', schema: 'personality/v1', producer: 'src/role/personality.js(state-truth)', dynamic: false },
|
|
95
|
+
// #27 channels:release-check 注册(state-truth 只提供 src/role/channels.js#readChannels 读取器)。
|
|
96
|
+
{ id: 27, path: '/api/longlive/channels', schema: 'channels/v1', producer: 'src/role/channels.js#readChannels(state-truth 读取器)+ release-check 注册 #27', dynamic: false },
|
|
97
|
+
]);
|
|
98
|
+
|
|
99
|
+
/** G3 detail route lives under #12 (one logical route, exact list + `/<id>`). */
|
|
100
|
+
export const DISTILL_DETAIL_PREFIX = '/api/longlive/distill/';
|
|
101
|
+
export const ANCHOR_DETAIL_PREFIX = '/api/longlive/anchor/';
|
|
102
|
+
export const EVIDENCE_DETAIL_PREFIX = '/api/longlive/evidence/';
|
|
103
|
+
|
|
104
|
+
export class ReadonlyRouteError extends Error {
|
|
105
|
+
constructor(code, message, status = 400) {
|
|
106
|
+
super(message);
|
|
107
|
+
this.name = 'ReadonlyRouteError';
|
|
108
|
+
this.code = code;
|
|
109
|
+
this.status = status;
|
|
110
|
+
}
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
function readText(file) {
|
|
114
|
+
try {
|
|
115
|
+
return fs.readFileSync(file, 'utf8');
|
|
116
|
+
} catch {
|
|
117
|
+
return null;
|
|
118
|
+
}
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
function readJson(file) {
|
|
122
|
+
const text = readText(file);
|
|
123
|
+
if (text === null) return { ok: false, code: 'ENOENT' };
|
|
124
|
+
try {
|
|
125
|
+
return { ok: true, value: JSON.parse(text) };
|
|
126
|
+
} catch {
|
|
127
|
+
return { ok: false, code: 'E_PARSE' };
|
|
128
|
+
}
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
function readJsonlLimited(file, limit) {
|
|
132
|
+
const text = readText(file);
|
|
133
|
+
if (text === null) return { present: false, rows: [], bad: 0 };
|
|
134
|
+
const rows = [];
|
|
135
|
+
let bad = 0;
|
|
136
|
+
const lines = text.split(/\r?\n/);
|
|
137
|
+
for (const line of lines) {
|
|
138
|
+
if (line.trim() === '') continue;
|
|
139
|
+
try {
|
|
140
|
+
rows.push(JSON.parse(line));
|
|
141
|
+
} catch {
|
|
142
|
+
bad += 1;
|
|
143
|
+
}
|
|
144
|
+
}
|
|
145
|
+
const max = Number.isInteger(limit) && limit > 0 ? limit : LOG_LIMIT_MAX;
|
|
146
|
+
return { present: true, rows: rows.slice(-max), bad };
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
function readJsonDir(dir) {
|
|
150
|
+
let names = [];
|
|
151
|
+
try {
|
|
152
|
+
names = fs.readdirSync(dir);
|
|
153
|
+
} catch {
|
|
154
|
+
return { present: false, files: [], rows: [] };
|
|
155
|
+
}
|
|
156
|
+
const files = names.filter((name) => name.endsWith('.json')).sort();
|
|
157
|
+
const rows = [];
|
|
158
|
+
for (const name of files) {
|
|
159
|
+
const read = readJson(path.join(dir, name));
|
|
160
|
+
if (read.ok && read.value && typeof read.value === 'object') rows.push(read.value);
|
|
161
|
+
}
|
|
162
|
+
return { present: files.length > 0, files, rows };
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
function listLiveRows(memoryRoot) {
|
|
166
|
+
return readJsonDir(path.join(String(memoryRoot ?? ''), '.longlive', 'live')).rows;
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
function boardRows(memoryRoot, limit = 500) {
|
|
170
|
+
const read = readJsonlLimited(path.join(String(memoryRoot ?? ''), '.longlive', 'board', 'activity.jsonl'), limit);
|
|
171
|
+
const rows = read.rows.filter((row) => row && Number.isInteger(row.seq) && typeof row.sessionId === 'string');
|
|
172
|
+
rows.sort((a, b) => a.seq - b.seq);
|
|
173
|
+
return { ...read, rows };
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
function createDefaultReaders(options) {
|
|
177
|
+
const memoryRoot = options.memoryRoot;
|
|
178
|
+
// §16.8 A:角色层 HMAC 侧车可能由 panel auditKey 或 host key file 任一写成;
|
|
179
|
+
// 路由读透传宿主 auditKey(缺省走 key file),校验失败时 fail-safe 降级。
|
|
180
|
+
const auditKey = options.auditKey ?? null;
|
|
181
|
+
const now = options.now;
|
|
182
|
+
const base = path.join(String(memoryRoot ?? ''), '.longlive');
|
|
183
|
+
return {
|
|
184
|
+
flags: () => (typeof options.flagsSnapshot === 'function'
|
|
185
|
+
? options.flagsSnapshot()
|
|
186
|
+
: { schema: 'flags/v1', flags: {}, writable: false, note: 'host configEditor 未装配(测试/无宿主形态)' }),
|
|
187
|
+
board: () => {
|
|
188
|
+
const read = boardRows(memoryRoot);
|
|
189
|
+
return {
|
|
190
|
+
schema: 'board/v1',
|
|
191
|
+
present: read.present,
|
|
192
|
+
rows: read.rows,
|
|
193
|
+
badLines: read.bad,
|
|
194
|
+
maxSeq: read.rows.reduce((max, row) => (row.seq > max ? row.seq : max), 0),
|
|
195
|
+
};
|
|
196
|
+
},
|
|
197
|
+
status: () => {
|
|
198
|
+
const rows = listLiveRows(memoryRoot);
|
|
199
|
+
return { schema: 'status/v1', present: rows.length > 0, sessions: rows, counts: { sessions: rows.length } };
|
|
200
|
+
},
|
|
201
|
+
live: () => {
|
|
202
|
+
const rows = listLiveRows(memoryRoot);
|
|
203
|
+
return { schema: 'live/v1', present: rows.length > 0, rows };
|
|
204
|
+
},
|
|
205
|
+
activity: () => {
|
|
206
|
+
const board = boardRows(memoryRoot);
|
|
207
|
+
const live = listLiveRows(memoryRoot);
|
|
208
|
+
const bySession = new Map();
|
|
209
|
+
for (const row of live) {
|
|
210
|
+
const id = typeof row?.sessionId === 'string' ? row.sessionId : null;
|
|
211
|
+
if (id === null) continue;
|
|
212
|
+
bySession.set(id, { sessionId: id, goal: row.goal ?? null, state: row.state ?? null, lastAt: row.updatedAt ?? null, items: [] });
|
|
213
|
+
}
|
|
214
|
+
for (const row of board.rows.filter((item) => item.archived !== true).slice(-5)) {
|
|
215
|
+
const id = row.sessionId;
|
|
216
|
+
const entry = bySession.get(id) ?? { sessionId: id, goal: null, state: null, lastAt: null, items: [] };
|
|
217
|
+
entry.items.push({ seq: row.seq, kind: row.kind, text: row.text, at: row.last_at ?? row.at });
|
|
218
|
+
bySession.set(id, entry);
|
|
219
|
+
}
|
|
220
|
+
return { schema: 'activity/v1', present: bySession.size > 0, sessions: [...bySession.values()] };
|
|
221
|
+
},
|
|
222
|
+
evidenceLatest: () => {
|
|
223
|
+
const read = readJson(path.join(base, 'evidence', 'latest.json'));
|
|
224
|
+
if (!read.ok) return { schema: 'evidence-package/v1', present: false, code: read.code };
|
|
225
|
+
const value = read.value ?? {};
|
|
226
|
+
return {
|
|
227
|
+
schema: 'evidence-package/v1',
|
|
228
|
+
present: true,
|
|
229
|
+
package: value.package ?? null,
|
|
230
|
+
packageSha256: value.packageSha256 ?? null,
|
|
231
|
+
writtenAt: value.writtenAt ?? null,
|
|
232
|
+
};
|
|
233
|
+
},
|
|
234
|
+
gap: () => {
|
|
235
|
+
const read = readJson(path.join(base, 'gap', 'latest.json'));
|
|
236
|
+
if (!read.ok) return { schema: 'gap/v1', present: false, code: read.code };
|
|
237
|
+
return { schema: 'gap/v1', present: true, ...read.value };
|
|
238
|
+
},
|
|
239
|
+
recallLog: (query) => {
|
|
240
|
+
const limit = Number.isInteger(query.limit) ? Math.min(Math.max(query.limit, 1), LOG_LIMIT_MAX) : LOG_LIMIT_MAX;
|
|
241
|
+
const read = readJsonlLimited(path.join(base, 'recall', 'log.jsonl'), limit);
|
|
242
|
+
return { schema: 'recall-log/v1', present: read.present, entries: read.rows, badLines: read.bad };
|
|
243
|
+
},
|
|
244
|
+
writeHealth: () => {
|
|
245
|
+
// B3: pre-step latency attribution lives next to write-health (read-only summary).
|
|
246
|
+
const prestep = readPrestepSummary({ memoryRoot });
|
|
247
|
+
const read = readJson(path.join(base, 'metrics', 'write-health.json'));
|
|
248
|
+
if (!read.ok) return { schema: 'write-health/v1', present: false, code: read.code, prestep };
|
|
249
|
+
return { schema: 'write-health/v1', present: true, ...read.value, prestep };
|
|
250
|
+
},
|
|
251
|
+
review: () => {
|
|
252
|
+
const read = readJsonlLimited(path.join(base, 'review', 'queue.jsonl'), 500);
|
|
253
|
+
const items = read.rows.map((row) => normalizeReviewRow(row));
|
|
254
|
+
return { schema: REVIEW_SCHEMA, present: read.present, items, badLines: read.bad };
|
|
255
|
+
},
|
|
256
|
+
events: (query) => {
|
|
257
|
+
if (typeof options.eventsReader === 'function') {
|
|
258
|
+
const out = options.eventsReader(query);
|
|
259
|
+
return { schema: 'events/v1', present: true, ...(out ?? {}) };
|
|
260
|
+
}
|
|
261
|
+
return {
|
|
262
|
+
schema: 'events/v1',
|
|
263
|
+
present: false,
|
|
264
|
+
events: [],
|
|
265
|
+
reason: 'E_EVENTS_INDEX_NOT_BUILT',
|
|
266
|
+
note: 'M2-A 事件索引未建(Gate-1 只交付契约层,不直连 SQLite)。',
|
|
267
|
+
};
|
|
268
|
+
},
|
|
269
|
+
distill: (query) => {
|
|
270
|
+
const read = readJsonDir(path.join(base, 'distill'));
|
|
271
|
+
const id = typeof query.id === 'string' ? query.id : '';
|
|
272
|
+
if (id !== '') {
|
|
273
|
+
const hit = read.rows.find((row) => row?.id === id);
|
|
274
|
+
if (!hit) return { schema: 'distill/v1', present: false, code: 'E_DISTILL_NOT_FOUND' };
|
|
275
|
+
return { schema: 'distill/v1', present: true, distill: hit };
|
|
276
|
+
}
|
|
277
|
+
return { schema: 'distill/v1', present: read.present, items: read.rows };
|
|
278
|
+
},
|
|
279
|
+
archive: () => {
|
|
280
|
+
const dirRead = readJsonDir(path.join(base, 'archive', 'tombstones'));
|
|
281
|
+
const tombstones = readText(path.join(String(memoryRoot ?? ''), 'tombstones.md'));
|
|
282
|
+
const entries = dirRead.rows;
|
|
283
|
+
return {
|
|
284
|
+
schema: 'archive/v1',
|
|
285
|
+
present: entries.length > 0 || (tombstones !== null && tombstones.trim() !== ''),
|
|
286
|
+
entries,
|
|
287
|
+
tombstonesPresent: tombstones !== null,
|
|
288
|
+
};
|
|
289
|
+
},
|
|
290
|
+
protection: () => {
|
|
291
|
+
const read = readJson(path.join(base, 'protected.json'));
|
|
292
|
+
if (!read.ok) return { schema: 'protection/v1', present: false, default: 'protect', entries: [], version: 1 };
|
|
293
|
+
const value = read.value ?? {};
|
|
294
|
+
return {
|
|
295
|
+
schema: 'protection/v1',
|
|
296
|
+
present: true,
|
|
297
|
+
default: value.default === 'protect' ? 'protect' : (value.default ?? 'protect'),
|
|
298
|
+
version: Number.isInteger(value.version) ? value.version : 1,
|
|
299
|
+
entries: Array.isArray(value.entries) ? value.entries : [],
|
|
300
|
+
};
|
|
301
|
+
},
|
|
302
|
+
anchor: (params) => {
|
|
303
|
+
const anchorId = typeof params.anchorId === 'string' ? params.anchorId : '';
|
|
304
|
+
if (typeof options.anchorResolver === 'function') {
|
|
305
|
+
const hit = options.anchorResolver(anchorId);
|
|
306
|
+
if (!hit) return { schema: 'anchor/v1', present: false, code: 'E_ANCHOR_NOT_FOUND' };
|
|
307
|
+
return { schema: 'anchor/v1', present: true, ...hit };
|
|
308
|
+
}
|
|
309
|
+
const read = readJsonlLimited(path.join(base, 'index', 'anchors.jsonl'), 1000);
|
|
310
|
+
const hit = read.rows.find((row) => row?.anchorId === anchorId);
|
|
311
|
+
if (!hit) return { schema: 'anchor/v1', present: false, code: 'E_ANCHOR_NOT_FOUND' };
|
|
312
|
+
return { schema: 'anchor/v1', present: true, anchor: hit };
|
|
313
|
+
},
|
|
314
|
+
records: (query) => {
|
|
315
|
+
const limit = Number.isInteger(query.limit) ? Math.min(Math.max(query.limit, 1), 500) : 200;
|
|
316
|
+
const listed = listRecords({ memoryRoot, limit });
|
|
317
|
+
// EA-15: L5 forgotten/archived rows stay visible as audit metadata but never
|
|
318
|
+
// expose their body text through the panel records view.
|
|
319
|
+
const items = listed.items.map((row) => {
|
|
320
|
+
const state = row?.state;
|
|
321
|
+
if (state === 'forgotten' || state === 'archived') {
|
|
322
|
+
return { ...row, content: null, excerpt: null, body: null, redactedByState: state };
|
|
323
|
+
}
|
|
324
|
+
return row;
|
|
325
|
+
});
|
|
326
|
+
// EA-16/A5: panel record reads are audited (single source: audit/records.jsonl),
|
|
327
|
+
// but only when real rows exist (fresh roots stay side-effect free).
|
|
328
|
+
if (items.length > 0) {
|
|
329
|
+
try {
|
|
330
|
+
appendAuditRecord({ memoryRoot, action: 'read', recordId: null, readerTier: 'host', questionId: 'unknown', expectedTurns: null, answerText: null, extra: { route: '#17 records', total: listed.total } });
|
|
331
|
+
} catch { /* read auditing must never break the panel */ }
|
|
332
|
+
}
|
|
333
|
+
return {
|
|
334
|
+
schema: 'records/v1',
|
|
335
|
+
present: listed.present,
|
|
336
|
+
items,
|
|
337
|
+
total: listed.total,
|
|
338
|
+
conflicts: listed.conflicts,
|
|
339
|
+
conflictsCount: listed.conflicts.length,
|
|
340
|
+
unverified: listed.unverified,
|
|
341
|
+
badLines: listed.badLines,
|
|
342
|
+
...(listed.present ? {} : { code: 'E_RECORDS_MISSING' }),
|
|
343
|
+
};
|
|
344
|
+
},
|
|
345
|
+
// #18–#23 治理/连续性只读(v2.8 §15.1/§15.2;缺数据一律空态,不 500)。
|
|
346
|
+
claims: (query = {}) => {
|
|
347
|
+
const limit = Number.isInteger(query.limit) ? Math.min(Math.max(query.limit, 1), 500) : 200;
|
|
348
|
+
const listed = readClaims({ memoryRoot, actor: { kind: 'host' } });
|
|
349
|
+
const items = listed.items.slice(-limit);
|
|
350
|
+
return { schema: 'claims/v1', present: listed.present, items, total: listed.items.length, badLines: listed.badLines, ...(listed.present ? {} : { code: 'E_CLAIMS_MISSING' }) };
|
|
351
|
+
},
|
|
352
|
+
evidenceChain: (params = {}) => {
|
|
353
|
+
const claimId = typeof params.claimId === 'string' ? params.claimId : '';
|
|
354
|
+
if (claimId === '') return { schema: 'evidence-chain/v1', present: false, code: 'E_CLAIM_ID', lines: [] };
|
|
355
|
+
const chain = readEvidenceChain({ memoryRoot, claimId });
|
|
356
|
+
return { schema: 'evidence-chain/v1', present: chain.present, claimId: chain.claimId, version: chain.version, count: chain.count, head: chain.head, integrityOk: chain.integrityOk, lines: chain.lines, ...(chain.present ? {} : { code: 'E_EVIDENCE_CHAIN_MISSING' }) };
|
|
357
|
+
},
|
|
358
|
+
banks: () => {
|
|
359
|
+
const banks = listBanks({ memoryRoot });
|
|
360
|
+
const custom = banks.filter((bank) => bank.builtin !== true);
|
|
361
|
+
return { schema: 'banks/v1', present: custom.length > 0, items: custom, builtin: banks.filter((bank) => bank.builtin === true), total: banks.length, ...(custom.length > 0 ? {} : { code: 'E_BANKS_EMPTY' }) };
|
|
362
|
+
},
|
|
363
|
+
conflicts: (query = {}) => {
|
|
364
|
+
const limit = Number.isInteger(query.limit) ? Math.min(Math.max(query.limit, 1), 500) : 200;
|
|
365
|
+
const unified = unifiedConflicts({ memoryRoot });
|
|
366
|
+
const conflicts = unified.conflicts.slice(0, limit);
|
|
367
|
+
return { schema: 'conflicts/v1', present: unified.entries.length > 0, items: conflicts, entries: unified.entries.slice(0, limit), total: unified.conflicts.length, source: unified.source, ...(unified.entries.length > 0 ? {} : { code: 'E_CONFLICTS_EMPTY' }) };
|
|
368
|
+
},
|
|
369
|
+
auditRecords: (query = {}) => {
|
|
370
|
+
const limit = Number.isInteger(query.limit) ? Math.min(Math.max(query.limit, 1), 1000) : 200;
|
|
371
|
+
const read = readJsonlLimited(path.join(base, 'audit', 'records.jsonl'), limit);
|
|
372
|
+
return { schema: 'audit-records/v1', present: read.present, items: read.rows.map((row) => normalizeAuditRecordRow(row)), badLines: read.bad, ...(read.present ? {} : { code: 'E_AUDIT_RECORDS_MISSING' }) };
|
|
373
|
+
},
|
|
374
|
+
frames: (query = {}) => {
|
|
375
|
+
const limit = Number.isInteger(query.limit) ? Math.min(Math.max(query.limit, 1), 200) : 50;
|
|
376
|
+
const dir = path.join(base, 'working');
|
|
377
|
+
const items = [];
|
|
378
|
+
try {
|
|
379
|
+
for (const name of fs.readdirSync(dir)) {
|
|
380
|
+
if (!name.endsWith('.json') || name.startsWith('.')) continue;
|
|
381
|
+
const value = readJson(path.join(dir, name));
|
|
382
|
+
if (!value.ok || !value.value || typeof value.value !== 'object') continue;
|
|
383
|
+
const frame = value.value;
|
|
384
|
+
items.push({
|
|
385
|
+
sessionId: frame.sessionId ?? name.replace(/\.json$/, ''),
|
|
386
|
+
frameId: frame.frameId ?? null,
|
|
387
|
+
frameNo: Number.isInteger(frame.frameNo) ? frame.frameNo : null,
|
|
388
|
+
turnRange: Array.isArray(frame.turnRange) ? frame.turnRange : null,
|
|
389
|
+
focus: frame.focus ?? null,
|
|
390
|
+
ttlMs: Number.isInteger(frame.ttlMs) ? frame.ttlMs : null,
|
|
391
|
+
lastUsedAt: frame.lastUsedAt ?? null,
|
|
392
|
+
state: frame.state ?? null,
|
|
393
|
+
});
|
|
394
|
+
}
|
|
395
|
+
} catch {
|
|
396
|
+
/* missing working dir => honest empty state */
|
|
397
|
+
}
|
|
398
|
+
items.sort((a, b) => String(b.lastUsedAt ?? '').localeCompare(String(a.lastUsedAt ?? '')));
|
|
399
|
+
return { schema: 'frames/v1', present: items.length > 0, items: items.slice(0, limit), total: items.length, ...(items.length > 0 ? {} : { code: 'E_FRAMES_EMPTY' }) };
|
|
400
|
+
},
|
|
401
|
+
// #24–#26 角色内核只读(v2.8 §16.3):attention / relationships / personality。
|
|
402
|
+
attention: (query = {}) => {
|
|
403
|
+
const limit = Number.isInteger(query.limit) ? Math.min(Math.max(query.limit, 1), 200) : 50;
|
|
404
|
+
return readAttentionView({ memoryRoot, now, limit, auditKey });
|
|
405
|
+
},
|
|
406
|
+
relationships: () => readRelationshipsView({ memoryRoot, now, auditKey }),
|
|
407
|
+
personality: () => readPersonalityView({ memoryRoot, now, auditKey }),
|
|
408
|
+
// #27 channels:只读状态(1.8.0 只做宿主内注册与只读,不接真实 QQ 协议,§16.7)。
|
|
409
|
+
channels: () => readChannels({ memoryRoot, now, auditKey }),
|
|
410
|
+
integrity: () => {
|
|
411
|
+
if (options.integrity && typeof options.integrity.status === 'function') {
|
|
412
|
+
return { schema: 'integrity/v1', ...options.integrity.status() };
|
|
413
|
+
}
|
|
414
|
+
return {
|
|
415
|
+
schema: 'integrity/v1',
|
|
416
|
+
ok: false,
|
|
417
|
+
manifestVersion: null,
|
|
418
|
+
checkedAt: now().toISOString(),
|
|
419
|
+
files: [],
|
|
420
|
+
lastError: 'E_INTEGRITY_NOT_WIRED',
|
|
421
|
+
};
|
|
422
|
+
},
|
|
423
|
+
};
|
|
424
|
+
}
|
|
425
|
+
|
|
426
|
+
/**
|
|
427
|
+
* Create the read-only router (pure, no host wiring).
|
|
428
|
+
*
|
|
429
|
+
* @param {object} options
|
|
430
|
+
* @param {string} options.memoryRoot
|
|
431
|
+
* @param {ReturnType<import('./panel-auth.js').createPanelAuth>} options.auth
|
|
432
|
+
* @param {ReturnType<import('./panel-integrity.js').createIntegrityGuard>} [options.integrity]
|
|
433
|
+
* @param {(query:object)=>object} [options.eventsReader]
|
|
434
|
+
* @param {(anchorId:string)=>object|null} [options.anchorResolver]
|
|
435
|
+
* @param {Buffer|string} [options.auditKey] host auditKey for role-layer HMAC sidecars
|
|
436
|
+
* @param {() => object} [options.flagsSnapshot]
|
|
437
|
+
* @param {() => Date} [options.now]
|
|
438
|
+
*/
|
|
439
|
+
export function createReadonlyRouter(options = {}) {
|
|
440
|
+
const auth = options.auth;
|
|
441
|
+
const now = typeof options.now === 'function' ? options.now : () => new Date();
|
|
442
|
+
const readers = createDefaultReaders({ ...options, now });
|
|
443
|
+
|
|
444
|
+
function gate(request) {
|
|
445
|
+
if (!auth || typeof auth.actionGate !== 'function') {
|
|
446
|
+
throw new ReadonlyRouteError('E_NO_CAP', '宿主权限门未装配。', 401);
|
|
447
|
+
}
|
|
448
|
+
try {
|
|
449
|
+
return auth.actionGate({
|
|
450
|
+
source: request?.source ?? 'http',
|
|
451
|
+
headers: request?.headers ?? {},
|
|
452
|
+
actorKind: request?.actorKind ?? 'host',
|
|
453
|
+
});
|
|
454
|
+
} catch (error) {
|
|
455
|
+
throw new ReadonlyRouteError(error.code ?? 'E_NO_CAP', error.message ?? '权限校验失败。', error.status ?? 401);
|
|
456
|
+
}
|
|
457
|
+
}
|
|
458
|
+
|
|
459
|
+
function match(urlPath) {
|
|
460
|
+
for (const route of READONLY_ROUTES) {
|
|
461
|
+
if (!route.dynamic && route.path === urlPath) return { route, params: {}, query: {} };
|
|
462
|
+
}
|
|
463
|
+
const anchorPrefix = ANCHOR_DETAIL_PREFIX;
|
|
464
|
+
if (urlPath.startsWith(anchorPrefix)) {
|
|
465
|
+
const anchorId = decodeURIComponent(urlPath.slice(anchorPrefix.length));
|
|
466
|
+
if (anchorId !== '') {
|
|
467
|
+
return { route: READONLY_ROUTES.find((route) => route.id === 15), params: { anchorId } };
|
|
468
|
+
}
|
|
469
|
+
}
|
|
470
|
+
// G3 distill detail: `/api/longlive/distill/<id>` belongs to logical route
|
|
471
|
+
// #12 (it must not widen the documented route count: 16 -> 17 with #17 records).
|
|
472
|
+
if (urlPath.startsWith(DISTILL_DETAIL_PREFIX) && urlPath.length > DISTILL_DETAIL_PREFIX.length) {
|
|
473
|
+
const distillId = decodeURIComponent(urlPath.slice(DISTILL_DETAIL_PREFIX.length));
|
|
474
|
+
if (distillId !== '' && !distillId.includes('/')) {
|
|
475
|
+
return { route: READONLY_ROUTES.find((route) => route.id === 12), params: { id: distillId }, query: { id: distillId } };
|
|
476
|
+
}
|
|
477
|
+
}
|
|
478
|
+
// #19 evidence chain detail: /api/longlive/evidence/<claimId> (exact /evidence/latest matched first).
|
|
479
|
+
if (urlPath.startsWith(EVIDENCE_DETAIL_PREFIX) && urlPath.length > EVIDENCE_DETAIL_PREFIX.length) {
|
|
480
|
+
const claimId = decodeURIComponent(urlPath.slice(EVIDENCE_DETAIL_PREFIX.length));
|
|
481
|
+
if (claimId !== '' && !claimId.includes('/') && claimId !== 'latest') {
|
|
482
|
+
return { route: READONLY_ROUTES.find((route) => route.id === 19), params: { claimId }, query: {} };
|
|
483
|
+
}
|
|
484
|
+
}
|
|
485
|
+
return null;
|
|
486
|
+
}
|
|
487
|
+
|
|
488
|
+
/**
|
|
489
|
+
* Handle one in-process read-only request.
|
|
490
|
+
* @param {{method?:string, path:string, headers?:object, source?:string, actorKind?:string}} request
|
|
491
|
+
* @returns {{status:number, body:object}}
|
|
492
|
+
*/
|
|
493
|
+
function handle(request = {}) {
|
|
494
|
+
const method = String(request.method ?? 'GET').toUpperCase();
|
|
495
|
+
const rawPath = String(request.path ?? '');
|
|
496
|
+
let url;
|
|
497
|
+
try {
|
|
498
|
+
url = new URL(rawPath, 'http://longlive.local');
|
|
499
|
+
} catch {
|
|
500
|
+
return { status: 400, body: { ok: false, error: { code: 'E_PATH', message: '非法请求路径。' } } };
|
|
501
|
+
}
|
|
502
|
+
const detail = match(url.pathname);
|
|
503
|
+
const query = { ...(detail?.query ?? {}) };
|
|
504
|
+
for (const [key, value] of url.searchParams.entries()) {
|
|
505
|
+
query[key] = /^\d+$/.test(value) ? Number.parseInt(value, 10) : value;
|
|
506
|
+
}
|
|
507
|
+
if (method !== 'GET' && method !== 'HEAD') {
|
|
508
|
+
return {
|
|
509
|
+
status: 405,
|
|
510
|
+
body: { ok: false, error: { code: 'E_METHOD', message: `只读路由只支持 GET/HEAD(收到 ${method})。` } },
|
|
511
|
+
headers: { allow: 'GET, HEAD' },
|
|
512
|
+
};
|
|
513
|
+
}
|
|
514
|
+
try {
|
|
515
|
+
gate(request);
|
|
516
|
+
} catch (error) {
|
|
517
|
+
if (error instanceof ReadonlyRouteError) {
|
|
518
|
+
return { status: error.status, body: { ok: false, error: { code: error.code, message: error.message } } };
|
|
519
|
+
}
|
|
520
|
+
return { status: 401, body: { ok: false, error: { code: 'E_NO_CAP', message: '权限校验失败。' } } };
|
|
521
|
+
}
|
|
522
|
+
if (detail === null) {
|
|
523
|
+
return { status: 404, body: { ok: false, error: { code: 'E_ROUTE_UNKNOWN', message: `未知只读路由:${url.pathname}` } } };
|
|
524
|
+
}
|
|
525
|
+
if (method === 'HEAD') {
|
|
526
|
+
// HEAD passes the same capability gate; no body (same-origin probes).
|
|
527
|
+
return { status: 200, body: null, headers: { 'content-type': 'application/json; charset=utf-8' } };
|
|
528
|
+
}
|
|
529
|
+
const { route, params } = detail;
|
|
530
|
+
try {
|
|
531
|
+
const data = readRoute(route, params, query);
|
|
532
|
+
return {
|
|
533
|
+
status: 200,
|
|
534
|
+
body: {
|
|
535
|
+
ok: true,
|
|
536
|
+
present: data?.present !== false,
|
|
537
|
+
generatedAt: now().toISOString(),
|
|
538
|
+
...data,
|
|
539
|
+
},
|
|
540
|
+
headers: { 'content-type': 'application/json; charset=utf-8', 'cache-control': 'no-store' },
|
|
541
|
+
};
|
|
542
|
+
} catch (error) {
|
|
543
|
+
if (error instanceof ReadonlyRouteError) {
|
|
544
|
+
return { status: error.status, body: { ok: false, error: { code: error.code, message: error.message } } };
|
|
545
|
+
}
|
|
546
|
+
return { status: 500, body: { ok: false, error: { code: 'E_READONLY_FAILED', message: error?.message ?? String(error) } } };
|
|
547
|
+
}
|
|
548
|
+
}
|
|
549
|
+
|
|
550
|
+
/**
|
|
551
|
+
* §16.8 / R4 P1-6:固定 id → handler 显式映射(不依赖数组顺序)。
|
|
552
|
+
* 每条 route id 必须在表里有 handler;缺表 = 启动即抛,避免数组顺序漂移造成串路。
|
|
553
|
+
*/
|
|
554
|
+
const HANDLERS_BY_ID = new Map([
|
|
555
|
+
[1, (params, query) => readers.flags()],
|
|
556
|
+
[2, (params, query) => readers.board()],
|
|
557
|
+
[3, (params, query) => readers.status()],
|
|
558
|
+
[4, (params, query) => readers.live()],
|
|
559
|
+
[5, (params, query) => readers.activity()],
|
|
560
|
+
[6, (params, query) => readers.evidenceLatest()],
|
|
561
|
+
[7, (params, query) => readers.gap()],
|
|
562
|
+
[8, (params, query) => readers.recallLog(query)],
|
|
563
|
+
[9, (params, query) => readers.writeHealth()],
|
|
564
|
+
[10, (params, query) => readers.review()],
|
|
565
|
+
[11, (params, query) => readers.events(query)],
|
|
566
|
+
[12, (params, query) => readers.distill(query)],
|
|
567
|
+
[13, (params, query) => readers.archive()],
|
|
568
|
+
[14, (params, query) => readers.protection()],
|
|
569
|
+
[15, (params, query) => readers.anchor(params)],
|
|
570
|
+
[16, (params, query) => readers.integrity()],
|
|
571
|
+
[17, (params, query) => readers.records(query)],
|
|
572
|
+
[18, (params, query) => readers.claims(query)],
|
|
573
|
+
[19, (params) => readers.evidenceChain(params)],
|
|
574
|
+
[20, (params, query) => readers.banks(query)],
|
|
575
|
+
[21, (params, query) => readers.conflicts(query)],
|
|
576
|
+
[22, (params, query) => readers.auditRecords(query)],
|
|
577
|
+
[23, (params, query) => readers.frames(query)],
|
|
578
|
+
[24, (params, query) => readers.attention(query)],
|
|
579
|
+
[25, () => readers.relationships()],
|
|
580
|
+
[26, () => readers.personality()],
|
|
581
|
+
[27, () => readers.channels()],
|
|
582
|
+
]);
|
|
583
|
+
for (const route of READONLY_ROUTES) {
|
|
584
|
+
if (!HANDLERS_BY_ID.has(route.id)) {
|
|
585
|
+
throw new ReadonlyRouteError('E_ROUTE_HANDLER_MISSING', `只读路由 #${route.id} 没有显式 handler(id 表与 READONLY_ROUTES 不一致)。`, 500);
|
|
586
|
+
}
|
|
587
|
+
}
|
|
588
|
+
|
|
589
|
+
function readRoute(route, params, query) {
|
|
590
|
+
const handler = HANDLERS_BY_ID.get(route.id);
|
|
591
|
+
if (typeof handler !== 'function') {
|
|
592
|
+
throw new ReadonlyRouteError('E_ROUTE_UNKNOWN', `未知只读路由 #${route.id}`, 404);
|
|
593
|
+
}
|
|
594
|
+
return handler(params, query);
|
|
595
|
+
}
|
|
596
|
+
|
|
597
|
+
return { handle, routes: READONLY_ROUTES, readers };
|
|
598
|
+
}
|
|
599
|
+
|
|
600
|
+
const warned = new Set();
|
|
601
|
+
function warnOnce(key, error) {
|
|
602
|
+
if (warned.has(key)) return;
|
|
603
|
+
warned.add(key);
|
|
604
|
+
try {
|
|
605
|
+
console.warn(`[longlive] ${key}: ${error?.stack ?? error?.message ?? String(error)}`);
|
|
606
|
+
} catch {
|
|
607
|
+
/* logging must never break activation */
|
|
608
|
+
}
|
|
609
|
+
}
|
|
610
|
+
|
|
611
|
+
/**
|
|
612
|
+
* Register the 16 read-only routes on the host Web server.
|
|
613
|
+
* A profile without a Web server simply gets no route; activation never depends on it.
|
|
614
|
+
*/
|
|
615
|
+
export function registerReadonlyRoutes(ctx, options = {}) {
|
|
616
|
+
if (typeof ctx?.inject !== 'function') return false;
|
|
617
|
+
try {
|
|
618
|
+
ctx.inject(['webServer'], (webCtx) => {
|
|
619
|
+
const webServer = webCtx?.webServer;
|
|
620
|
+
if (webServer === undefined || typeof webServer.register !== 'function') return;
|
|
621
|
+
const router = createReadonlyRouter(options);
|
|
622
|
+
const handleRequest = (req, res) => {
|
|
623
|
+
const method = typeof req?.method === 'string' ? req.method.toUpperCase() : 'GET';
|
|
624
|
+
const host = String(req?.headers?.host ?? 'longlive.local');
|
|
625
|
+
const urlPath = String(req?.url ?? '/');
|
|
626
|
+
const verdict = router.handle({
|
|
627
|
+
method,
|
|
628
|
+
path: `http://${host}${urlPath}`,
|
|
629
|
+
headers: req?.headers ?? {},
|
|
630
|
+
source: 'http',
|
|
631
|
+
});
|
|
632
|
+
if (verdict.body === null) {
|
|
633
|
+
try {
|
|
634
|
+
res.writeHead(verdict.status, verdict.headers ?? {});
|
|
635
|
+
res.end();
|
|
636
|
+
} catch {
|
|
637
|
+
/* peer gone */
|
|
638
|
+
}
|
|
639
|
+
return;
|
|
640
|
+
}
|
|
641
|
+
const payload = JSON.stringify(verdict.body);
|
|
642
|
+
try {
|
|
643
|
+
res.writeHead(verdict.status, {
|
|
644
|
+
'content-type': 'application/json; charset=utf-8',
|
|
645
|
+
'cache-control': 'no-store',
|
|
646
|
+
'content-length': Buffer.byteLength(payload),
|
|
647
|
+
...(verdict.headers ?? {}),
|
|
648
|
+
});
|
|
649
|
+
res.end(payload);
|
|
650
|
+
} catch {
|
|
651
|
+
try {
|
|
652
|
+
res.destroy?.();
|
|
653
|
+
} catch {
|
|
654
|
+
/* peer gone */
|
|
655
|
+
}
|
|
656
|
+
}
|
|
657
|
+
};
|
|
658
|
+
for (const route of READONLY_ROUTES) {
|
|
659
|
+
const registration = route.dynamic
|
|
660
|
+
? { kind: 'prefix', path: route.path.replace(/\/:anchorId$/, '/'), handler: handleRequest }
|
|
661
|
+
: { kind: 'exact', path: route.path, handler: handleRequest };
|
|
662
|
+
try {
|
|
663
|
+
const dispose = webServer.register(registration);
|
|
664
|
+
if (typeof dispose === 'function' && typeof webCtx.effect === 'function') {
|
|
665
|
+
try {
|
|
666
|
+
webCtx.effect(() => () => dispose(), `longlive: readonly route ${route.id}`);
|
|
667
|
+
} catch {
|
|
668
|
+
/* fake context without an effect scope */
|
|
669
|
+
}
|
|
670
|
+
}
|
|
671
|
+
} catch (error) {
|
|
672
|
+
warnOnce(`readonly route ${route.id} registration failed`, error);
|
|
673
|
+
}
|
|
674
|
+
}
|
|
675
|
+
// G3 detail route (`/api/longlive/distill/<id>`) is served by a prefix
|
|
676
|
+
// route so it does not widen the documented 16-route count.
|
|
677
|
+
try {
|
|
678
|
+
const dispose = webServer.register({ kind: 'prefix', path: DISTILL_DETAIL_PREFIX, handler: handleRequest });
|
|
679
|
+
if (typeof dispose === 'function' && typeof webCtx.effect === 'function') {
|
|
680
|
+
try {
|
|
681
|
+
webCtx.effect(() => () => dispose(), 'longlive: distill detail route');
|
|
682
|
+
} catch {
|
|
683
|
+
/* fake context */
|
|
684
|
+
}
|
|
685
|
+
}
|
|
686
|
+
} catch (error) {
|
|
687
|
+
warnOnce('distill detail route registration failed', error);
|
|
688
|
+
}
|
|
689
|
+
});
|
|
690
|
+
return true;
|
|
691
|
+
} catch (error) {
|
|
692
|
+
warnOnce('readonly route inject failed', error);
|
|
693
|
+
return false;
|
|
694
|
+
}
|
|
695
|
+
}
|