dsh-kingdom 2.0.0 → 3.2.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/ASSETS_NOTICE.md +22 -22
- package/README.md +246 -243
- package/THIRD_PARTY_NOTICES.md +92 -92
- package/docs/DSH-KINGDOM-3.0-ANNOUNCEMENT.md +13 -0
- package/docs/V2.0-RELEASE-NOTES.md +30 -30
- package/docs/V2.0-USER-GUIDE.md +74 -74
- package/docs/V3.0-RELEASE-NOTES.md +21 -0
- package/docs/V3.0-USER-GUIDE.md +33 -0
- package/docs/V3.1-RELEASE-NOTES.md +76 -0
- package/docs/V3.2-RELEASE-NOTES.md +103 -0
- package/lib/core/binding.js +167 -60
- package/lib/core/binding.js.map +1 -1
- package/lib/core/db.js +582 -546
- package/lib/core/db.js.map +1 -1
- package/lib/core/delivery-ack.js +1356 -0
- package/lib/core/delivery-ack.js.map +1 -0
- package/lib/core/delivery-change.js +1237 -0
- package/lib/core/delivery-change.js.map +1 -0
- package/lib/core/owner-binding-intent.js +14 -25
- package/lib/core/owner-binding-intent.js.map +1 -1
- package/lib/core/owner-window.js +698 -23
- package/lib/core/owner-window.js.map +1 -1
- package/lib/core/task-service.js +144 -5
- package/lib/core/task-service.js.map +1 -1
- package/lib/core/territory.js +43 -9
- package/lib/core/territory.js.map +1 -1
- package/lib/gui/assets/characters/chancellor-idle.svg +67 -67
- package/lib/gui/assets/characters/chancellor-sleeping.svg +67 -67
- package/lib/gui/assets/characters/chancellor-thinking.svg +67 -67
- package/lib/gui/assets/characters/chancellor-working.svg +67 -67
- package/lib/gui/assets/characters/knight-redraw-r1-idle.svg +67 -67
- package/lib/gui/assets/characters/knight-redraw-r1-sleeping.svg +67 -67
- package/lib/gui/assets/characters/knight-redraw-r1-thinking.svg +67 -67
- package/lib/gui/assets/characters/knight-redraw-r1-working.svg +67 -67
- package/lib/gui/assets/characters/supervisor-idle.svg +67 -67
- package/lib/gui/assets/characters/supervisor-sleeping.svg +67 -67
- package/lib/gui/assets/characters/supervisor-thinking.svg +67 -67
- package/lib/gui/assets/characters/supervisor-working.svg +67 -67
- package/lib/gui/console-app.js +7 -1
- package/lib/gui/console-app.js.map +1 -1
- package/lib/gui/contract.js +6 -0
- package/lib/gui/contract.js.map +1 -1
- package/lib/gui/control-contract.js +3 -2
- package/lib/gui/control-contract.js.map +1 -1
- package/lib/gui/owner-control.js +114 -4
- package/lib/gui/owner-control.js.map +1 -1
- package/lib/gui/owner-ui.js +645 -183
- package/lib/gui/owner-ui.js.map +1 -1
- package/lib/gui/server.js +109 -109
- package/lib/gui/snapshot.js +127 -4
- package/lib/gui/snapshot.js.map +1 -1
- package/lib/gui/visual-assets.js +37 -21
- package/lib/gui/visual-assets.js.map +1 -1
- package/lib/gui/visual-system.js +269 -269
- package/lib/gui/workbench-ui.js +549 -136
- package/lib/gui/workbench-ui.js.map +1 -1
- package/lib/gui/workbench.js +133 -0
- package/lib/gui/workbench.js.map +1 -1
- package/lib/index.js +392 -40
- package/lib/index.js.map +1 -1
- package/lib/types/core/binding.d.ts +29 -0
- package/lib/types/core/db.d.ts +14 -0
- package/lib/types/core/delivery-ack.d.ts +688 -0
- package/lib/types/core/delivery-change.d.ts +381 -0
- package/lib/types/core/owner-binding-intent.d.ts +9 -1
- package/lib/types/core/owner-window.d.ts +237 -3
- package/lib/types/core/task-service.d.ts +22 -0
- package/lib/types/core/territory.d.ts +18 -3
- package/lib/types/gui/contract.d.ts +162 -1
- package/lib/types/gui/owner-control.d.ts +61 -1
- package/lib/types/gui/snapshot.d.ts +8 -0
- package/lib/types/gui/visual-assets.d.ts +16 -0
- package/lib/types/gui/workbench.d.ts +47 -1
- package/lib/types/index.d.ts +60 -1
- package/lib/types/worker/governed-executor.d.ts +15 -0
- package/lib/worker/governed-executor.js +21 -0
- package/lib/worker/governed-executor.js.map +1 -1
- package/package.json +80 -75
|
@@ -0,0 +1,1356 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Owner delivery-item acknowledgement (Owner 逐条知悉).
|
|
3
|
+
*
|
|
4
|
+
* 本模块是「交付知悉」这一治理事实的**唯一** Canonical 定义处:交付条目的稳定
|
|
5
|
+
* 身份、内容版本、层级结构,以及知悉回执的持久化形状。
|
|
6
|
+
*
|
|
7
|
+
* ## 事实分类
|
|
8
|
+
*
|
|
9
|
+
* - `OWNER_DELIVERY_ITEM_ACKNOWLEDGED` 事件 = Kingdom Core Fact:Owner principal
|
|
10
|
+
* 对某条交付的**某个内容版本**的一次知悉。它写在既有 events 账本里,不新增表、
|
|
11
|
+
* 不做 schema 迁移。
|
|
12
|
+
* - 交付条目本身 = **确定性派生**:由「本 Task/attempt 的主管 ACCEPT + 该次 Claim」
|
|
13
|
+
* 推导,不落库。因此不存在需要迁移的第二套任务状态机。
|
|
14
|
+
*
|
|
15
|
+
* ## 边界(不得越界)
|
|
16
|
+
*
|
|
17
|
+
* - 知悉只记录「已知悉该条当前版本」,不表示理解、质量认可、Task DONE、
|
|
18
|
+
* 正式 Owner acceptance 或发布授权。它不修改 Task、主管审查、execution 或发布状态。
|
|
19
|
+
* - Claim 的 `artifacts`/`risks` 是**执行者自述文本**,永远不能升级为仓库路径或安全链接。
|
|
20
|
+
* 它们只作为可读证据文本渲染。
|
|
21
|
+
* - 外层摘要知悉不覆盖子条:每条各自有独立 ID 与内容版本。
|
|
22
|
+
*/
|
|
23
|
+
import { createHash } from 'node:crypto';
|
|
24
|
+
import { ownerInputHash } from './owner-control.js';
|
|
25
|
+
import { readAcceptedChangeEvidence, resolveChangeEvidenceForDelivery } from './delivery-change.js';
|
|
26
|
+
export const DELIVERY_ACK_EVENT_TYPE = 'OWNER_DELIVERY_ITEM_ACKNOWLEDGED';
|
|
27
|
+
/** 知悉回执载荷版本;形状变化时必须递增,旧记录按旧版本读取。 */
|
|
28
|
+
export const DELIVERY_ACK_PAYLOAD_VERSION = 'KingdomDeliveryItemAck/v1';
|
|
29
|
+
/**
|
|
30
|
+
* 交付条目提问与回复:**两条独立**的治理事实,写在既有 events 账本里。
|
|
31
|
+
*
|
|
32
|
+
* 它们与「知悉」无关:提问不是知悉、不是 ACCEPT、不是 Task DONE,也不改变任何
|
|
33
|
+
* Task/Claim/审查/发布状态;回复同理。二者都不触发任何自动派发、唤醒或后续动作。
|
|
34
|
+
*/
|
|
35
|
+
export const DELIVERY_QUESTION_EVENT_TYPE = 'OWNER_DELIVERY_ITEM_QUESTIONED';
|
|
36
|
+
export const DELIVERY_REPLY_EVENT_TYPE = 'OWNER_DELIVERY_ITEM_ANSWERED';
|
|
37
|
+
export const DELIVERY_QUESTION_PAYLOAD_VERSION = 'KingdomDeliveryItemQuestion/v1';
|
|
38
|
+
export const DELIVERY_REPLY_PAYLOAD_VERSION = 'KingdomDeliveryItemReply/v1';
|
|
39
|
+
/** 需要最小化投影的事件类型;通用事件投影只保留元数据,绝不带问答正文。 */
|
|
40
|
+
export const DELIVERY_QUESTION_EVENT_TYPES = [DELIVERY_QUESTION_EVENT_TYPE, DELIVERY_REPLY_EVENT_TYPE];
|
|
41
|
+
/** 载荷中永远不进入公开投影的字段:正文只经受权 Owner 窗口或当前责任主管 Tool 返回。 */
|
|
42
|
+
const QUESTION_BODY_KEYS = ['questionText', 'replyText'];
|
|
43
|
+
/** 交付页的三层结构:成果摘要 → 模块/事项 → 证据/改动。 */
|
|
44
|
+
export const DELIVERY_ITEM_LAYERS = ['SUMMARY', 'MODULE', 'EVIDENCE'];
|
|
45
|
+
/**
|
|
46
|
+
* 条目槽位:与所属层一起构成身份的命名空间。
|
|
47
|
+
*
|
|
48
|
+
* `artifacts`(产物引用)与 `risks`(执行者报告的风险)同属 EVIDENCE 层。
|
|
49
|
+
* 若身份只用「层 + 位置」,追加一个 artifact 会把后面所有 risk 的位置整体
|
|
50
|
+
* 后移,使旧的 risk 条目 ID 被另一个 artifact 占用——既有知悉会错误地指向
|
|
51
|
+
* 另一条内容。因此身份必须带槽位,各类条目在各自槽位内独立编号。
|
|
52
|
+
*
|
|
53
|
+
* `CHANGE` 槽位承载「主管确认的改动证据」条目;它的身份来自稳定的 entry id,
|
|
54
|
+
* 而不是位置,避免主管换选改动时旧条目 ID 被挪用。
|
|
55
|
+
*/
|
|
56
|
+
export const DELIVERY_ITEM_SLOTS = ['SUMMARY', 'ARTIFACT', 'RISK', 'CHANGE'];
|
|
57
|
+
/**
|
|
58
|
+
* 「查看改动」可信度分类。
|
|
59
|
+
*
|
|
60
|
+
* - `REPO_RELATIVE_VERIFIED`:可验证的仓库相对路径 + 固定源码版本/差异。只有
|
|
61
|
+
* 主管在 ACCEPT 中显式选择、且本地内容寻址证据 hash 重验通过时才成立。
|
|
62
|
+
* - `NOT_LOCATABLE`:无可信来源。GUI 必须明确显示「不可定位」而不是给出链接。
|
|
63
|
+
*/
|
|
64
|
+
export const DELIVERY_CHANGE_REF_KINDS = ['REPO_RELATIVE_VERIFIED', 'NOT_LOCATABLE'];
|
|
65
|
+
/** 界面与报告共用的固定标注:这是主管确认的改动证据,不是作者证据。 */
|
|
66
|
+
export const CHANGE_EVIDENCE_LABEL = '主管确认的改动证据';
|
|
67
|
+
export const CHANGE_EVIDENCE_NOTE = '本差异来自本次 Task/attempt 窗口前后的有界本地快照,并由该领地当前 ACTIVE 主管在同一事务的 ACCEPT 中显式选择确认。它只证明这些仓库相对路径在窗口内发生变化;不证明 Git 作者身份,也不证明由哪个执行者写入。';
|
|
68
|
+
/**
|
|
69
|
+
* 主管 ACCEPT 确认交付时**实际可核对到**的证据强度。
|
|
70
|
+
*
|
|
71
|
+
* - `EXACT_RESULT_BOUND`:`TASK_ACCEPTED` 同时锁定了本次 Claim 的 `reviewed_result_id`
|
|
72
|
+
* 与 `reviewed_result_digest`,且两者都与当前 Claim 一致。这是新格式的强证据。
|
|
73
|
+
* - `LEGACY_ATTEMPT_ONLY`:immutable v1.0.0 的合法 `TASK_ACCEPTED` 只有
|
|
74
|
+
* `reviewed_attempt_no`(以及 decision/reviewer/claimed_outcome 等真实字段),
|
|
75
|
+
* 既没有 `reviewed_result_id` 也没有 `reviewed_result_digest`。Owner 2026-09-27
|
|
76
|
+
* 裁决允许知悉这类历史交付,但必须标注「历史接受证据较弱」:它只按真实事件字段
|
|
77
|
+
* 与**同 Task/attempt 的唯一 WorkerResult** 判定,不冒充 exact result-bound。
|
|
78
|
+
* - 新格式两字段只出现其一、digest 不一致、或 JSON 形状与真实 v1.0.0 事件不符时
|
|
79
|
+
* 一律拒绝(`invalid`),不会退回弱判据,也不会静默隐藏历史交付。
|
|
80
|
+
*/
|
|
81
|
+
export const DELIVERY_ACCEPTANCE_EVIDENCE_KINDS = ['EXACT_RESULT_BOUND', 'LEGACY_ATTEMPT_ONLY'];
|
|
82
|
+
/** 历史(v1.0.0)接受证据的固定标注文案;工作台与 Owner 窗口共用同一措辞。 */
|
|
83
|
+
export const LEGACY_ACCEPTANCE_EVIDENCE_NOTE = '历史接受证据较弱:该 Task/attempt 的 TASK_ACCEPTED 是 v1.0.0 旧格式,只有尝试编号,缺少被审查结果 ID 与内容摘要;本条按真实事件字段与同 Task/attempt 的唯一 WorkerResult 判定,不构成 exact result-bound 证据。';
|
|
84
|
+
// ── 身份与版本(纯函数;不依赖 store,便于 GUI 侧复用同一身份)────────
|
|
85
|
+
const hash = (value) => createHash('sha256').update(value).digest('hex');
|
|
86
|
+
export function digestText(value) {
|
|
87
|
+
return hash(value);
|
|
88
|
+
}
|
|
89
|
+
/**
|
|
90
|
+
* 交付身份 = 一个 Task 的一条交付时间线。
|
|
91
|
+
*
|
|
92
|
+
* 刻意**不含** attempt/result:同一 Task 的后续已接受尝试是同一交付的新版本,
|
|
93
|
+
* 因此条目 ID 保持稳定,旧知悉随内容版本变化自动降为历史。若是每次尝试换一个
|
|
94
|
+
* 交付身份,旧知悉就无法作为「同一条目的历史」被识别。
|
|
95
|
+
*/
|
|
96
|
+
export function deliveryIdFor(taskId) {
|
|
97
|
+
return `delivery:${taskId}`;
|
|
98
|
+
}
|
|
99
|
+
/**
|
|
100
|
+
* 条目 ID 来自条目在交付结构中的**位置**(层 + 槽位 + 槽位内位置),
|
|
101
|
+
* 不来自可变化的正文,因此同一逻辑条目改版后仍是同一条。新增条目会追加
|
|
102
|
+
* 新 ID,旧条目 ID 不变;不同槽位(产物引用 / 风险)之间也不会互相挪用。
|
|
103
|
+
*/
|
|
104
|
+
export function deliveryItemId(deliveryId, layer, slot, position) {
|
|
105
|
+
return `item:${digestText(`${deliveryId}\u0000${layer}\u0000${slot}\u0000${position}`).slice(0, 32)}`;
|
|
106
|
+
}
|
|
107
|
+
/**
|
|
108
|
+
* 改动证据条目的身份来自稳定 entry id(= 仓库相对路径的摘要),不来自位置:
|
|
109
|
+
* 主管换选或增删改动时,旧条目 ID 不会被另一条改动占用。
|
|
110
|
+
*/
|
|
111
|
+
export function deliveryChangeItemId(deliveryId, entryId) {
|
|
112
|
+
return `item:${digestText(`${deliveryId}\u0000EVIDENCE\u0000CHANGE\u0000${entryId}`).slice(0, 32)}`;
|
|
113
|
+
}
|
|
114
|
+
/**
|
|
115
|
+
* 内容版本 = 结构、标签、正文、证据文本与改动引用状态(含可见 note、已确认
|
|
116
|
+
* 证据 id 与条目 id)的规范化摘要。
|
|
117
|
+
*
|
|
118
|
+
* 只在字段真实存在时把 evidenceId/entryId 纳入摘要,因此未带改动证据的既有
|
|
119
|
+
* 条目版本保持不变,历史知悉不会被本次扩展误判为改版。
|
|
120
|
+
*/
|
|
121
|
+
export function deliveryContentHash(content) {
|
|
122
|
+
return digestText(JSON.stringify({
|
|
123
|
+
v: 1,
|
|
124
|
+
layer: content.layer,
|
|
125
|
+
label: content.label,
|
|
126
|
+
detail: content.detail,
|
|
127
|
+
sourceRef: content.sourceRef ? { entityType: content.sourceRef.entityType, entityId: content.sourceRef.entityId } : null,
|
|
128
|
+
change: { kind: content.change.kind, repoPath: content.change.repoPath, revision: content.change.revision, reasonCode: content.change.reasonCode, note: content.change.note,
|
|
129
|
+
...(content.change.evidenceId ? { evidenceId: content.change.evidenceId } : {}),
|
|
130
|
+
...(content.change.entryId ? { entryId: content.change.entryId } : {}) },
|
|
131
|
+
details: content.details.map(detail => ({
|
|
132
|
+
label: detail.label,
|
|
133
|
+
detail: detail.detail,
|
|
134
|
+
sourceRef: detail.sourceRef ? { entityType: detail.sourceRef.entityType, entityId: detail.sourceRef.entityId } : null,
|
|
135
|
+
})),
|
|
136
|
+
}));
|
|
137
|
+
}
|
|
138
|
+
/**
|
|
139
|
+
* 严格校验仓库相对路径。
|
|
140
|
+
*
|
|
141
|
+
* 拒绝:绝对路径、Windows 盘符、UNC/网络共享、`..` 逃逸、`.git` 内部路径、控制字符、
|
|
142
|
+
* 反斜杠(仓库相对路径一律使用 `/`)。任何不通过的值都不得成为链接。
|
|
143
|
+
*/
|
|
144
|
+
export function validateRepoRelativePath(value) {
|
|
145
|
+
if (typeof value !== 'string')
|
|
146
|
+
return null;
|
|
147
|
+
const path = value.trim();
|
|
148
|
+
if (!path || path.length > 512)
|
|
149
|
+
return null;
|
|
150
|
+
if (/[\u0000-\u001f\u007f]/u.test(path))
|
|
151
|
+
return null;
|
|
152
|
+
if (path.includes('\\'))
|
|
153
|
+
return null;
|
|
154
|
+
if (path.startsWith('/') || /^[A-Za-z]:/u.test(path) || path.startsWith('//'))
|
|
155
|
+
return null;
|
|
156
|
+
if (path.startsWith('~'))
|
|
157
|
+
return null;
|
|
158
|
+
const segments = path.split('/');
|
|
159
|
+
if (segments.some(segment => segment === '' || segment === '.' || segment === '..'))
|
|
160
|
+
return null;
|
|
161
|
+
if (segments.some(segment => segment.toLowerCase() === '.git'))
|
|
162
|
+
return null;
|
|
163
|
+
return path;
|
|
164
|
+
}
|
|
165
|
+
/** 固定源码版本/差异标识:有界、无空白的 opaque 标识(含 git 的 `~`/`^` 相对写法)。 */
|
|
166
|
+
export function validateSourceRevision(value) {
|
|
167
|
+
if (typeof value !== 'string')
|
|
168
|
+
return null;
|
|
169
|
+
const revision = value.trim();
|
|
170
|
+
if (!revision || revision.length > 200)
|
|
171
|
+
return null;
|
|
172
|
+
// 连字符放在字符类末尾,避免 `/+-` 被解析成范围而意外放行 `~` 等 ASCII 字符。
|
|
173
|
+
if (!/^[A-Za-z0-9._:@+~^/-]+$/u.test(revision))
|
|
174
|
+
return null;
|
|
175
|
+
return revision;
|
|
176
|
+
}
|
|
177
|
+
/**
|
|
178
|
+
* 只有「通过严格校验的仓库相对路径 + 固定版本」才构成可信改动引用。
|
|
179
|
+
* 其余一切(任意 Claim 字符串、本机绝对路径、现有 `SourceRef`)都返回 null,
|
|
180
|
+
* 由调用方明确渲染为不可定位。
|
|
181
|
+
*/
|
|
182
|
+
export function trustedChangeRef(repoPath, revision) {
|
|
183
|
+
const safePath = validateRepoRelativePath(repoPath);
|
|
184
|
+
const safeRevision = validateSourceRevision(revision);
|
|
185
|
+
return safePath && safeRevision ? { repoPath: safePath, revision: safeRevision } : null;
|
|
186
|
+
}
|
|
187
|
+
function notLocatable(reasonCode) {
|
|
188
|
+
return { kind: 'NOT_LOCATABLE', repoPath: null, revision: null, reasonCode,
|
|
189
|
+
note: '本条目没有可验证的仓库相对路径与固定源码版本;不提供改动链接。执行者自述、本机绝对路径与现有 SourceRef 都不能当作安全链接。' };
|
|
190
|
+
}
|
|
191
|
+
/**
|
|
192
|
+
* 只有**同一 Task/attempt 的主管 ACCEPT** 才确认交付。
|
|
193
|
+
*
|
|
194
|
+
* `TASK_ACCEPTED` 事件必须同时锁定本次 Claim 的 attempt、`result_id` 与内容
|
|
195
|
+
* 摘要(`reviewed_result_digest`,与主管审查写入端使用同一 `ownerInputHash`)。
|
|
196
|
+
* 只比对 attempt 会让一条针对同一 attempt 但另一份/已改内容的 ACCEPT 冒充确认,
|
|
197
|
+
* 因此三者缺一不可。
|
|
198
|
+
*
|
|
199
|
+
* 这是严格的**布尔**判据:新格式必须两字段齐备并一致,两字段只出现其一即拒绝。
|
|
200
|
+
* 需要区分「历史接受证据较弱」的调用方请看 {@link classifyAcceptedDelivery}。
|
|
201
|
+
*/
|
|
202
|
+
export function acceptedDelivery(store, claim, latestReview) {
|
|
203
|
+
return classifyAcceptedDelivery(store, claim, latestReview) !== null;
|
|
204
|
+
}
|
|
205
|
+
/**
|
|
206
|
+
* immutable v1.0.0 `TASK_ACCEPTED` 的真实字段集合(除两个新格式字段外)。
|
|
207
|
+
*
|
|
208
|
+
* 这里刻意是**白名单**而不是「忽略未知字段」:旧格式判定要证明事件确实来自
|
|
209
|
+
* v1.0.0 的既有写入端,而不是某条带了其它字段的其它形状事件。
|
|
210
|
+
*/
|
|
211
|
+
const V1_TASK_ACCEPTED_PAYLOAD_KEYS = ['decision', 'reviewed_attempt_no', 'reason', 'reviewer_binding_id', 'claimed_outcome'];
|
|
212
|
+
/**
|
|
213
|
+
* v1.0.0 旧格式事件形状必须**恰好**是那五个真实字段:五个全部存在,且没有额外字段。
|
|
214
|
+
*
|
|
215
|
+
* 「缺字段」与「多字段」都说明这不是 v1.0.0 写入端的原始事件(该写入端恒定发出
|
|
216
|
+
* 全部五个字段,见 `task-service.ts` 的 ACCEPT 分支),因此一律拒绝,不退回弱判据。
|
|
217
|
+
*/
|
|
218
|
+
function v1LegacyAcceptanceShape(payload) {
|
|
219
|
+
const keys = Object.keys(payload);
|
|
220
|
+
if (keys.some(key => !V1_TASK_ACCEPTED_PAYLOAD_KEYS.includes(key)))
|
|
221
|
+
return false;
|
|
222
|
+
if (keys.length !== V1_TASK_ACCEPTED_PAYLOAD_KEYS.length)
|
|
223
|
+
return false;
|
|
224
|
+
if (payload.decision !== 'ACCEPT')
|
|
225
|
+
return false;
|
|
226
|
+
if (!Number.isSafeInteger(payload.reviewed_attempt_no) || payload.reviewed_attempt_no < 1)
|
|
227
|
+
return false;
|
|
228
|
+
if (payload.reason !== undefined && payload.reason !== null && typeof payload.reason !== 'string')
|
|
229
|
+
return false;
|
|
230
|
+
if (payload.reviewer_binding_id !== undefined && payload.reviewer_binding_id !== null && typeof payload.reviewer_binding_id !== 'string')
|
|
231
|
+
return false;
|
|
232
|
+
if (payload.claimed_outcome !== undefined && payload.claimed_outcome !== null && typeof payload.claimed_outcome !== 'string')
|
|
233
|
+
return false;
|
|
234
|
+
return true;
|
|
235
|
+
}
|
|
236
|
+
/**
|
|
237
|
+
* 把一次主管 ACCEPT 判定为「exact result-bound」或「历史接受证据较弱」,或 null(拒绝)。
|
|
238
|
+
*
|
|
239
|
+
* 判据(任一条不成立即返回 null,不返回较弱证据):
|
|
240
|
+
* - Task 处于 `DONE`,最近一次主管裁定是 `TASK_ACCEPTED`(`readLatestReviewEvent`
|
|
241
|
+
* 已限定 `actor_role = SUPERVISOR`)且 `decision = 'ACCEPT'`;
|
|
242
|
+
* - `reviewed_attempt_no` 必须等于本次 Claim 的 attempt;
|
|
243
|
+
* - **新格式**:`reviewed_result_id` 与 `reviewed_result_digest` 必须同时存在、均为
|
|
244
|
+
* 字符串,并分别等于本次 Claim 的 `result_id` 与 `ownerInputHash(claim)`;
|
|
245
|
+
* - **旧格式(v1.0.0)**:两个新格式字段必须**同时缺失**,payload 必须**恰好**由
|
|
246
|
+
* v1.0.0 的五个真实字段(decision / reviewed_attempt_no / reason /
|
|
247
|
+
* reviewer_binding_id / claimed_outcome)组成——缺一个或多一个都不是旧格式,
|
|
248
|
+
* 且该 Task/attempt 恰好只有一条 WorkerResult 且就是本次 Claim。缺少这一唯一性
|
|
249
|
+
* 核对时无法排除「同一 attempt 有多份结果」,因此拒绝。
|
|
250
|
+
*
|
|
251
|
+
* 旧格式只做上述真实字段核对,不虚构 result-bound 证据:它证明的是「该尝试的唯一
|
|
252
|
+
* 已记录结果被接受」,而不是「该事件的摘要与这份内容一致」。
|
|
253
|
+
*/
|
|
254
|
+
export function classifyAcceptedDelivery(store, claim, latestReview) {
|
|
255
|
+
const task = store.getTask(claim?.task_id ?? '');
|
|
256
|
+
if (!task || !claim)
|
|
257
|
+
return null;
|
|
258
|
+
if (latestReview?.event_type !== 'TASK_ACCEPTED')
|
|
259
|
+
return null;
|
|
260
|
+
if (task.status !== 'DONE')
|
|
261
|
+
return null;
|
|
262
|
+
const payload = parseJson(latestReview.payload_json);
|
|
263
|
+
if (payload.decision !== 'ACCEPT')
|
|
264
|
+
return null;
|
|
265
|
+
if (payload.reviewed_attempt_no !== claim.attempt_no)
|
|
266
|
+
return null;
|
|
267
|
+
if ((latestReview.target_type === null || latestReview.target_type === 'task') && latestReview.target_id !== claim.task_id)
|
|
268
|
+
return null;
|
|
269
|
+
const reviewedResultId = payload.reviewed_result_id;
|
|
270
|
+
const reviewedResultDigest = payload.reviewed_result_digest;
|
|
271
|
+
const hasResultId = typeof reviewedResultId === 'string' && reviewedResultId !== '';
|
|
272
|
+
const hasResultDigest = typeof reviewedResultDigest === 'string' && reviewedResultDigest !== '';
|
|
273
|
+
if (hasResultId || hasResultDigest) {
|
|
274
|
+
if (!hasResultId || !hasResultDigest)
|
|
275
|
+
return null;
|
|
276
|
+
if (reviewedResultId !== claim.result_id)
|
|
277
|
+
return null;
|
|
278
|
+
if (reviewedResultDigest !== ownerInputHash(claim))
|
|
279
|
+
return null;
|
|
280
|
+
return {
|
|
281
|
+
kind: 'EXACT_RESULT_BOUND',
|
|
282
|
+
evidence: { attemptNo: claim.attempt_no, resultId: claim.result_id, exactResultBound: true, matchingResultCount: 1 },
|
|
283
|
+
legacyNote: null,
|
|
284
|
+
};
|
|
285
|
+
}
|
|
286
|
+
if (!v1LegacyAcceptanceShape(payload))
|
|
287
|
+
return null;
|
|
288
|
+
const matchingResults = store.db
|
|
289
|
+
.prepare('SELECT result_id FROM worker_results WHERE task_id = ? AND attempt_no = ? ORDER BY result_id ASC')
|
|
290
|
+
.all(claim.task_id, claim.attempt_no);
|
|
291
|
+
if (matchingResults.length !== 1 || matchingResults[0].result_id !== claim.result_id)
|
|
292
|
+
return null;
|
|
293
|
+
return {
|
|
294
|
+
kind: 'LEGACY_ATTEMPT_ONLY',
|
|
295
|
+
evidence: { attemptNo: claim.attempt_no, resultId: claim.result_id, exactResultBound: false, matchingResultCount: matchingResults.length },
|
|
296
|
+
legacyNote: LEGACY_ACCEPTANCE_EVIDENCE_NOTE,
|
|
297
|
+
};
|
|
298
|
+
}
|
|
299
|
+
/** 读取某 Task 最近一次主管裁定事件(与投影窗口无关的精确读取)。 */
|
|
300
|
+
export function readLatestReviewEvent(store, kingdomId, taskId) {
|
|
301
|
+
return store.db.prepare("SELECT * FROM events WHERE kingdom_id = ? AND target_type = 'task' AND target_id = ? AND actor_role = 'SUPERVISOR' AND event_type IN ('TASK_ACCEPTED', 'TASK_REWORK_REQUESTED', 'TASK_FAILED', 'TASK_HANDED_OFF') ORDER BY seq DESC LIMIT 1")
|
|
302
|
+
.all(kingdomId, taskId)[0] ?? null;
|
|
303
|
+
}
|
|
304
|
+
// ── 条目派生 ─────────────────────────────────────────────────────────
|
|
305
|
+
const MAX_ITEM_TEXT = 4000;
|
|
306
|
+
const REDACTED_PATH = '[redacted-path]';
|
|
307
|
+
const REDACTED_SECRET = '[REDACTED]';
|
|
308
|
+
/**
|
|
309
|
+
* 公开呈现共用的凭据脱敏(工作台投影与 Owner 窗口都调用这里,避免两份正则漂移)。
|
|
310
|
+
*
|
|
311
|
+
* 这是**针对性**修复,不是通用秘密检测:只把「凭据关键字后跟一个值」整体替换为
|
|
312
|
+
* 占位符,关键字与值之间允许 `=`/`:` 引号形式和空白。
|
|
313
|
+
*
|
|
314
|
+
* `Bearer` 之类的 scheme 后接真实令牌时必须连值一起遮住:
|
|
315
|
+
* `Authorization: Bearer sk-secret` 里 `Bearer` 本身不是秘密,只遮 `Bearer`
|
|
316
|
+
* 而留下 `sk-secret` 等于没有脱敏。因此先匹配「关键字 + scheme 字词 + 值」,
|
|
317
|
+
* 再退回「关键字 + 值」。所有分支都以「值是行内非空白文本」收尾,不会吞掉
|
|
318
|
+
* 后面的正文;scheme 只列出已知认证 scheme,不做「任意大写词」泛化匹配。
|
|
319
|
+
*
|
|
320
|
+
* 关键字与值之间没有 `=`/`:` 时只保留两种情况:裸 `Bearer` 后跟任意令牌,或
|
|
321
|
+
* 关键字后跟一个**不是自然语言词**的值(含数字或 `_ - + / = @` 中的符号,如
|
|
322
|
+
* `token opaqueFreeTextSecret99`)。`token is ready`、`secret was rotated` 这类
|
|
323
|
+
* 普通交付正文因此不再被误遮;判据只看值的字符构成,不猜测它是不是秘密。
|
|
324
|
+
*
|
|
325
|
+
* 已知边界(刻意不修):无关键字前缀的裸令牌、纯字母令牌(如 `token deadbeef`)、
|
|
326
|
+
* 跨行值、被引号包裹的多词值都不在本模式识别范围内;本函数不声称能检测任意秘密。
|
|
327
|
+
*/
|
|
328
|
+
const CREDENTIAL_NAME = '(?:authorization|bearer|session(?:[_ .-]?id)?|principal(?:[_ .-]?id)?|private(?:[_ .-]?config)?|token|cookie|credential|secret|password|api[_ .-]?key|access[_ .-]?key|client[_ .-]?secret|connection[_ .-]?string|csrf)';
|
|
329
|
+
const CREDENTIAL_ASSIGNMENT = '(?:\\b\\s*["\']?\\s*(?:=|:)\\s*["\']?\\s*)';
|
|
330
|
+
const CREDENTIAL_VALUE = '[^\\s,;&}]+';
|
|
331
|
+
/** 无 `=`/`:` 时的值必须是数字或凭据常见符号构成的令牌;纯字母词按自然语言放过。 */
|
|
332
|
+
const CREDENTIAL_OPAQUE_VALUE = '(?=[^\\s,;&}]*[0-9_\\-/+@=])' + CREDENTIAL_VALUE;
|
|
333
|
+
const CREDENTIAL_SCHEME = '(?:Bearer|Basic|Token|Digest|DPoP)';
|
|
334
|
+
const CREDENTIAL_BODY = `(?:${CREDENTIAL_NAME}${CREDENTIAL_ASSIGNMENT}${CREDENTIAL_SCHEME}\\s+${CREDENTIAL_VALUE}|${CREDENTIAL_NAME}${CREDENTIAL_ASSIGNMENT}${CREDENTIAL_VALUE}|bearer\\s+${CREDENTIAL_VALUE}|${CREDENTIAL_NAME}\\s+${CREDENTIAL_OPAQUE_VALUE})`;
|
|
335
|
+
// 词边界必须放在捕获组之外:替换整段匹配时,组内的 `\b` 会让首个字符漏在占位符外。
|
|
336
|
+
export const CREDENTIAL_REDACTION_PATTERN = new RegExp(`\\b(?:${CREDENTIAL_BODY})`, 'giu');
|
|
337
|
+
/** 只做凭据关键字 + 完整值(含 Bearer 等 scheme)替换;调用方决定是否再做路径脱敏。 */
|
|
338
|
+
export function redactCredentialText(value) {
|
|
339
|
+
return value.replace(CREDENTIAL_REDACTION_PATTERN, '[REDACTED]');
|
|
340
|
+
}
|
|
341
|
+
/**
|
|
342
|
+
* 交付文本的公开呈现脱敏。
|
|
343
|
+
*
|
|
344
|
+
* 沿用工作台投影既有的公开内容规则(`src/gui/snapshot.ts` 的
|
|
345
|
+
* `sanitizePublicJsonString` public-content 分支):本机绝对路径、UNC 路径与
|
|
346
|
+
* 内联凭据替换为占位符。刻意只做这一件事——不做递归 JSON 遍历,也不是
|
|
347
|
+
* 新的通用脱敏框架;核心派生一次脱敏,所有呈现面(Owner 窗口与工作台投影)
|
|
348
|
+
* 都不会再看到原始 Claim 文本。
|
|
349
|
+
*/
|
|
350
|
+
export function redactDeliveryText(value) {
|
|
351
|
+
return redactCredentialText(value)
|
|
352
|
+
.replace(/[A-Za-z]:[\\/][^\s<>"']*/gu, REDACTED_PATH)
|
|
353
|
+
.replace(/\\\\[^\s<>"']*/gu, REDACTED_PATH)
|
|
354
|
+
.replace(/(^|[\s("'=])\/[^\s<>"']*/gu, `$1${REDACTED_PATH}`)
|
|
355
|
+
.replace(/\bprivate[-_][^\s<>"']+/giu, REDACTED_SECRET);
|
|
356
|
+
}
|
|
357
|
+
function bounded(value) {
|
|
358
|
+
const collapsed = redactDeliveryText(value).replace(/[\r\n\t]+/gu, ' ').trim();
|
|
359
|
+
return collapsed.length > MAX_ITEM_TEXT ? `${collapsed.slice(0, MAX_ITEM_TEXT)}…` : collapsed;
|
|
360
|
+
}
|
|
361
|
+
function summaryDetail(claim) {
|
|
362
|
+
const payload = parseJson(claim.result_json);
|
|
363
|
+
const raw = typeof payload.summary === 'string' ? payload.summary : '';
|
|
364
|
+
return bounded(raw) || '执行者未提供摘要。';
|
|
365
|
+
}
|
|
366
|
+
function stringArray(value, limit) {
|
|
367
|
+
return Array.isArray(value) ? value.filter((item) => typeof item === 'string' && item.trim() !== '').slice(0, limit) : [];
|
|
368
|
+
}
|
|
369
|
+
/**
|
|
370
|
+
* 从已确认的交付派生三层条目。
|
|
371
|
+
*
|
|
372
|
+
* 摘要层恒有 1 条(内容为执行者自述摘要);每条 `artifacts` 与 `risks` 各派生一条
|
|
373
|
+
* 证据层条目。所有条目都带独立 ID 与内容版本,外层知悉不会覆盖子条。
|
|
374
|
+
*
|
|
375
|
+
* 位置在每个槽位内独立编号,因此追加 artifact 不会移动既有 risk 的身份。
|
|
376
|
+
*
|
|
377
|
+
* `changeEvidence` 只在主管在 ACCEPT 中显式选择了改动引用、且本地内容寻址证据
|
|
378
|
+
* hash 重验通过时传入。它派生的条目**不是**执行者自述,而是有界窗口观测 +
|
|
379
|
+
* 主管确认;标签固定为「主管确认的改动证据」,且绝不声称 Git 证明作者身份。
|
|
380
|
+
*/
|
|
381
|
+
export function deriveDeliveryItems(taskId, claim, changeEvidence) {
|
|
382
|
+
const deliveryId = deliveryIdFor(taskId);
|
|
383
|
+
const resultRef = { entityType: 'worker_results', entityId: claim.result_id };
|
|
384
|
+
const payload = parseJson(claim.result_json);
|
|
385
|
+
const artifacts = stringArray(payload.artifacts, 8);
|
|
386
|
+
const risks = stringArray(payload.risks, 8);
|
|
387
|
+
const slots = [
|
|
388
|
+
{
|
|
389
|
+
slot: 'SUMMARY',
|
|
390
|
+
position: 0,
|
|
391
|
+
content: {
|
|
392
|
+
layer: 'SUMMARY',
|
|
393
|
+
label: '成果摘要',
|
|
394
|
+
detail: summaryDetail(claim),
|
|
395
|
+
sourceRef: resultRef,
|
|
396
|
+
change: notLocatable('DELIVERY_ITEM_IS_SUMMARY'),
|
|
397
|
+
details: [],
|
|
398
|
+
},
|
|
399
|
+
},
|
|
400
|
+
...artifacts.map((artifact, index) => ({
|
|
401
|
+
slot: 'ARTIFACT',
|
|
402
|
+
position: index,
|
|
403
|
+
content: {
|
|
404
|
+
layer: 'EVIDENCE',
|
|
405
|
+
label: `产物引用 ${index + 1}`,
|
|
406
|
+
detail: bounded(artifact),
|
|
407
|
+
sourceRef: resultRef,
|
|
408
|
+
change: notLocatable('DELIVERY_EVIDENCE_TEXT_IS_WORKER_CLAIM'),
|
|
409
|
+
details: [],
|
|
410
|
+
},
|
|
411
|
+
})),
|
|
412
|
+
...risks.map((risk, index) => ({
|
|
413
|
+
slot: 'RISK',
|
|
414
|
+
position: index,
|
|
415
|
+
content: {
|
|
416
|
+
layer: 'EVIDENCE',
|
|
417
|
+
label: `执行者报告的风险 ${index + 1}`,
|
|
418
|
+
detail: bounded(risk),
|
|
419
|
+
sourceRef: resultRef,
|
|
420
|
+
change: notLocatable('DELIVERY_RISK_TEXT_IS_WORKER_CLAIM'),
|
|
421
|
+
details: [],
|
|
422
|
+
},
|
|
423
|
+
})),
|
|
424
|
+
];
|
|
425
|
+
const items = slots.map(({ content, slot, position }) => ({
|
|
426
|
+
itemId: deliveryItemId(deliveryId, content.layer, slot, position),
|
|
427
|
+
contentHash: deliveryContentHash(content),
|
|
428
|
+
content,
|
|
429
|
+
}));
|
|
430
|
+
for (const entry of changeEvidence?.entries ?? []) {
|
|
431
|
+
const content = changeEvidenceItemContent(resultRef, changeEvidence, entry);
|
|
432
|
+
items.push({ itemId: deliveryChangeItemId(deliveryId, entry.entryId), contentHash: deliveryContentHash(content), content });
|
|
433
|
+
}
|
|
434
|
+
return items;
|
|
435
|
+
}
|
|
436
|
+
/** 单条「主管确认的改动证据」条目的公开内容。 */
|
|
437
|
+
function changeEvidenceItemContent(resultRef, evidence, entry) {
|
|
438
|
+
const coverageNote = evidence.coverageComplete
|
|
439
|
+
? null
|
|
440
|
+
: `部分覆盖:${evidence.coverageReasons.length ? evidence.coverageReasons.join('、') : '存在未采集或未判定项'};该快照未完整覆盖全部文件。`;
|
|
441
|
+
const note = [CHANGE_EVIDENCE_NOTE, coverageNote].filter((value) => Boolean(value)).join(' ');
|
|
442
|
+
return {
|
|
443
|
+
layer: 'EVIDENCE',
|
|
444
|
+
label: `${CHANGE_EVIDENCE_LABEL} · ${bounded(entry.repoPath)}`,
|
|
445
|
+
detail: bounded(`${entry.status} · ${entry.note}`),
|
|
446
|
+
sourceRef: resultRef,
|
|
447
|
+
change: {
|
|
448
|
+
kind: 'REPO_RELATIVE_VERIFIED',
|
|
449
|
+
repoPath: entry.repoPath,
|
|
450
|
+
revision: `evidence:${evidence.evidenceId}`,
|
|
451
|
+
reasonCode: 'SUPERVISOR_CONFIRMED_BOUNDED_WINDOW',
|
|
452
|
+
note,
|
|
453
|
+
evidenceId: evidence.evidenceId,
|
|
454
|
+
entryId: entry.entryId,
|
|
455
|
+
evidenceLabel: CHANGE_EVIDENCE_LABEL,
|
|
456
|
+
coverageNote,
|
|
457
|
+
},
|
|
458
|
+
details: [],
|
|
459
|
+
};
|
|
460
|
+
}
|
|
461
|
+
function readAck(row) {
|
|
462
|
+
if (row.event_type !== DELIVERY_ACK_EVENT_TYPE)
|
|
463
|
+
return null;
|
|
464
|
+
if (row.actor_role !== 'OWNER' || typeof row.actor_id !== 'string' || row.actor_id === '')
|
|
465
|
+
return null;
|
|
466
|
+
const payload = parseJson(row.payload_json);
|
|
467
|
+
if (payload.version !== DELIVERY_ACK_PAYLOAD_VERSION)
|
|
468
|
+
return null;
|
|
469
|
+
if (typeof payload.deliveryId !== 'string' || typeof payload.itemId !== 'string' || typeof payload.contentHash !== 'string')
|
|
470
|
+
return null;
|
|
471
|
+
return {
|
|
472
|
+
deliveryId: payload.deliveryId,
|
|
473
|
+
itemId: payload.itemId,
|
|
474
|
+
contentHash: payload.contentHash,
|
|
475
|
+
ownerId: row.actor_id,
|
|
476
|
+
acknowledgedAt: row.created_at,
|
|
477
|
+
eventSeq: row.seq,
|
|
478
|
+
eventId: row.event_id,
|
|
479
|
+
// 旧回执没有这两个字段:如实读成 UNKNOWN,不猜测、也不据此拒绝历史事实。
|
|
480
|
+
acceptanceEvidenceKind: DELIVERY_ACCEPTANCE_EVIDENCE_KINDS.includes(payload.acceptanceEvidenceKind)
|
|
481
|
+
? payload.acceptanceEvidenceKind
|
|
482
|
+
: 'UNKNOWN',
|
|
483
|
+
acceptanceEvidenceExact: payload.acceptanceEvidenceExact === true,
|
|
484
|
+
};
|
|
485
|
+
}
|
|
486
|
+
/** 按 delivery + item 读取全部历史知悉(升序),包括旧版本。 */
|
|
487
|
+
export function readDeliveryAcknowledgements(store, kingdomId, deliveryId) {
|
|
488
|
+
const rows = store.db.prepare('SELECT * FROM events WHERE kingdom_id = ? AND event_type = ? AND target_type = ? AND target_id = ? ORDER BY seq ASC')
|
|
489
|
+
.all(kingdomId, DELIVERY_ACK_EVENT_TYPE, 'delivery', deliveryId);
|
|
490
|
+
return rows.map(readAck).filter((ack) => ack !== null);
|
|
491
|
+
}
|
|
492
|
+
/**
|
|
493
|
+
* 汇总某交付内每条条目的当前知悉状态。
|
|
494
|
+
*
|
|
495
|
+
* - 当前 `contentHash` 已有知悉 → `ACKNOWLEDGED`
|
|
496
|
+
* - 只有旧版本知悉 → `PENDING_REVISION`(旧知悉仅留历史)
|
|
497
|
+
* - 从无知悉 → `PENDING`
|
|
498
|
+
*
|
|
499
|
+
* `ownerId` 是当前王国 Owner principal:知悉回执必须归属于该 principal,
|
|
500
|
+
* 其他 actor 的历史事件(即使事件类型相同)都不构成 Owner 知悉。
|
|
501
|
+
*/
|
|
502
|
+
export function deliveryAcknowledgementView(acknowledgements, item, ownerId) {
|
|
503
|
+
const owned = acknowledgements.filter(ack => ack.ownerId === ownerId);
|
|
504
|
+
const forItem = owned.filter(ack => ack.itemId === item.itemId);
|
|
505
|
+
const current = forItem.find(ack => ack.contentHash === item.contentHash) ?? null;
|
|
506
|
+
const historical = forItem.filter(ack => ack.contentHash !== item.contentHash);
|
|
507
|
+
return {
|
|
508
|
+
state: current ? 'ACKNOWLEDGED' : historical.length > 0 ? 'PENDING_REVISION' : 'PENDING',
|
|
509
|
+
acknowledged: current !== null,
|
|
510
|
+
historicalCount: historical.length,
|
|
511
|
+
acknowledgedAt: current?.acknowledgedAt ?? null,
|
|
512
|
+
acknowledgedByOwnerId: current?.ownerId ?? null,
|
|
513
|
+
acknowledgementEventSeq: current?.eventSeq ?? null,
|
|
514
|
+
acknowledgedAcceptanceEvidenceKind: current?.acceptanceEvidenceKind ?? 'UNKNOWN',
|
|
515
|
+
acknowledgedAcceptanceEvidenceExact: current?.acceptanceEvidenceExact ?? false,
|
|
516
|
+
};
|
|
517
|
+
}
|
|
518
|
+
// ── 知悉写入(仅供 Core Owner Control Plane 调用)────────────────────
|
|
519
|
+
export class DeliveryAcknowledgementError extends Error {
|
|
520
|
+
code;
|
|
521
|
+
constructor(code, message) {
|
|
522
|
+
super(message);
|
|
523
|
+
this.code = code;
|
|
524
|
+
this.name = 'DeliveryAcknowledgementError';
|
|
525
|
+
}
|
|
526
|
+
}
|
|
527
|
+
function ackFail(code, message) {
|
|
528
|
+
throw new DeliveryAcknowledgementError(code, message);
|
|
529
|
+
}
|
|
530
|
+
/** 幂等事件 ID:同一交付条目版本的再次知悉不会写出第二条事实。 */
|
|
531
|
+
export function deliveryAckEventId(kingdomId, deliveryId, itemId, contentHash) {
|
|
532
|
+
return `owner-delivery-ack:${hash(`${kingdomId}\u0000${deliveryId}\u0000${itemId}\u0000${contentHash}`)}`;
|
|
533
|
+
}
|
|
534
|
+
/**
|
|
535
|
+
* 追加一条 Owner 知悉事实。
|
|
536
|
+
*
|
|
537
|
+
* 前置校验(任一失败即零写入):
|
|
538
|
+
* - Task 当前确实处于「主管 ACCEPT 确认交付」状态;
|
|
539
|
+
* - 传入的 `deliveryId`/`itemId`/`contentHash` 与当前派生结果完全一致
|
|
540
|
+
* (防止对旧版本或伪造条目写入知悉)。
|
|
541
|
+
*
|
|
542
|
+
* 幂等:同一 (delivery, item, contentHash) 已有记录时原样返回,不追加事件。
|
|
543
|
+
*/
|
|
544
|
+
export function recordDeliveryAcknowledgement(store, input) {
|
|
545
|
+
if (!input.ownerId)
|
|
546
|
+
ackFail('OWNER_CONTROL_REQUIRED', '知悉必须记录 Owner principal。');
|
|
547
|
+
const eventId = deliveryAckEventId(input.kingdomId, input.deliveryId, input.itemId, input.contentHash);
|
|
548
|
+
const existing = store.getEventById(eventId);
|
|
549
|
+
if (existing) {
|
|
550
|
+
const ack = readAck(existing);
|
|
551
|
+
if (!ack)
|
|
552
|
+
ackFail('ACK_EVENT_CONFLICT', '既有知悉记录与本次引用不一致,请重新核对。');
|
|
553
|
+
return ack;
|
|
554
|
+
}
|
|
555
|
+
const task = store.getTask(input.taskId);
|
|
556
|
+
if (!task)
|
|
557
|
+
ackFail('TASK_NOT_FOUND', '交付对应的任务不存在。');
|
|
558
|
+
const claim = store.latestWorkerResult(input.taskId);
|
|
559
|
+
const review = readLatestReviewEvent(store, input.kingdomId, input.taskId);
|
|
560
|
+
const classification = classifyAcceptedDelivery(store, claim, review);
|
|
561
|
+
if (!claim || !classification) {
|
|
562
|
+
ackFail('DELIVERY_NOT_CONFIRMED', '该交付尚未由同一 Task/attempt 的主管 ACCEPT 确认,不能记录知悉。');
|
|
563
|
+
}
|
|
564
|
+
if (claim.attempt_no !== input.attemptNo || claim.result_id !== input.resultId) {
|
|
565
|
+
ackFail('DELIVERY_VERSION_STALE', '交付的已接受版本已变化,请重新读取清单后再知悉。');
|
|
566
|
+
}
|
|
567
|
+
const derived = deliveryIdFor(input.taskId);
|
|
568
|
+
if (derived !== input.deliveryId)
|
|
569
|
+
ackFail('DELIVERY_VERSION_STALE', '交付标识与当前已接受版本不一致。');
|
|
570
|
+
const item = deriveDeliveryItems(input.taskId, claim, input.changeEvidence).find(candidate => candidate.itemId === input.itemId);
|
|
571
|
+
if (!item)
|
|
572
|
+
ackFail('DELIVERY_ITEM_UNKNOWN', '该条目不在当前交付清单中,不能记录知悉。');
|
|
573
|
+
if (item.contentHash !== input.contentHash)
|
|
574
|
+
ackFail('DELIVERY_ITEM_VERSION_STALE', '该条目内容版本已变化,请重新读取后再知悉。');
|
|
575
|
+
const row = store.appendEvent({
|
|
576
|
+
event_id: eventId,
|
|
577
|
+
kingdom_id: input.kingdomId,
|
|
578
|
+
event_type: DELIVERY_ACK_EVENT_TYPE,
|
|
579
|
+
actor_role: 'OWNER',
|
|
580
|
+
actor_id: input.ownerId,
|
|
581
|
+
target_type: 'delivery',
|
|
582
|
+
target_id: input.deliveryId,
|
|
583
|
+
payload_json: JSON.stringify({
|
|
584
|
+
version: DELIVERY_ACK_PAYLOAD_VERSION,
|
|
585
|
+
deliveryId: input.deliveryId,
|
|
586
|
+
taskId: input.taskId,
|
|
587
|
+
attemptNo: input.attemptNo,
|
|
588
|
+
resultId: input.resultId,
|
|
589
|
+
itemId: input.itemId,
|
|
590
|
+
itemLabel: bounded(input.itemLabel),
|
|
591
|
+
contentHash: input.contentHash,
|
|
592
|
+
ownerId: input.ownerId,
|
|
593
|
+
ownerBindingId: input.ownerBindingId,
|
|
594
|
+
acknowledgedAt: input.acknowledgedAt,
|
|
595
|
+
// 知悉时实际依据的接受证据强度随事实一起落账:旧格式交付的知悉也能被
|
|
596
|
+
// 回溯为「历史接受证据较弱」,而不是事后与强证据混淆。
|
|
597
|
+
acceptanceEvidenceKind: classification.kind,
|
|
598
|
+
acceptanceEvidenceExact: classification.evidence.exactResultBound,
|
|
599
|
+
meaning: 'OWNER_ACKNOWLEDGED_CURRENT_VERSION_ONLY',
|
|
600
|
+
...(input.attribution ?? {}),
|
|
601
|
+
}),
|
|
602
|
+
created_at: input.acknowledgedAt,
|
|
603
|
+
});
|
|
604
|
+
return { deliveryId: input.deliveryId, itemId: input.itemId, contentHash: input.contentHash,
|
|
605
|
+
ownerId: input.ownerId, acknowledgedAt: input.acknowledgedAt, eventSeq: row.seq, eventId: row.event_id,
|
|
606
|
+
acceptanceEvidenceKind: classification.kind, acceptanceEvidenceExact: classification.evidence.exactResultBound };
|
|
607
|
+
}
|
|
608
|
+
function parseJson(value) {
|
|
609
|
+
try {
|
|
610
|
+
const parsed = JSON.parse(value);
|
|
611
|
+
return parsed && typeof parsed === 'object' && !Array.isArray(parsed) ? parsed : {};
|
|
612
|
+
}
|
|
613
|
+
catch {
|
|
614
|
+
return {};
|
|
615
|
+
}
|
|
616
|
+
}
|
|
617
|
+
// ── 交付条目提问与回复(事实、读取与责任)────────────────────────────
|
|
618
|
+
//
|
|
619
|
+
// 这三个概念必须分开:
|
|
620
|
+
// - **问题**:Owner 在 canonical 管理窗口里就某个精确条目版本提交的一条提问。
|
|
621
|
+
// - **回复**:当时接受该交付的主管 binding 针对该 problem 作出的一条回复。
|
|
622
|
+
// - **可达性**:该主管此刻是否仍能回复——绑定是否仍 ACTIVE、session 是否仍是
|
|
623
|
+
// 当前、领地是否仍由它主理。不可达时明确说明,且**绝不**改投继任主管。
|
|
624
|
+
//
|
|
625
|
+
// 公开投影只保留元数据(见 {@link toDeliveryEventView}):正文只经有效 Owner
|
|
626
|
+
// 窗口或当前责任主管的 session-bound Tool 返回。
|
|
627
|
+
/** 界面与报告共用的固定措辞:回复只说明主管回答了这个问题。 */
|
|
628
|
+
export const DELIVERY_QUESTION_NOTE = '提问与回复只是对话记录:它们不代表 Owner 知悉、质量认可、人类验收、Task DONE,也不改变任何任务、审查或发布状态。';
|
|
629
|
+
/** 未被主管实际读取时只能说「待领取」,不声称已通知或已阅读。 */
|
|
630
|
+
export const DELIVERY_QUESTION_UNCLAIMED = '待领取(尚未确认主管已读取)';
|
|
631
|
+
/**
|
|
632
|
+
* 回复可达性。`REPLY_ACCESSIBLE` 之外的状态都不得写入回复:
|
|
633
|
+
* 旧主管退任、session 更换或领地改绑时,问题保持可见但明确不可达,不会自动转给继任者。
|
|
634
|
+
*/
|
|
635
|
+
export const DELIVERY_REPLY_STATES = ['REPLY_ACCESSIBLE', 'REVIEWER_BINDING_MISSING', 'REVIEWER_BINDING_RETIRED', 'REVIEWER_SESSION_CHANGED', 'SUPERVISOR_REBOUND'];
|
|
636
|
+
/**
|
|
637
|
+
* 一条问题相对**当前交付目录**的版本关系。
|
|
638
|
+
*
|
|
639
|
+
* - `CURRENT`:该条目此刻重新派生出的版本与问题冻结版本一致(已验证当前)。
|
|
640
|
+
* - `HISTORICAL`:该条目仍可派生,但版本已变化(可确认的旧版本)。
|
|
641
|
+
* - `UNVERIFIABLE`:该条目此刻无法从当前 ACCEPT/证据重新派生(例如 CHANGE 证据
|
|
642
|
+
* 丢失或被替换);既不能当成当前版,也不能断言它是旧版。后两者都不计当前待办、
|
|
643
|
+
* 不可回复,但仍可读。
|
|
644
|
+
*/
|
|
645
|
+
export const DELIVERY_ITEM_VERSION_STATES = ['CURRENT', 'HISTORICAL', 'UNVERIFIABLE'];
|
|
646
|
+
/** 版本关系判据:只有精确命中当前派生版本才算当前;派生失败一律 fail-as-unknown。 */
|
|
647
|
+
export function classifyDeliveryItemVersion(currentItemContentHash, questionContentHash) {
|
|
648
|
+
if (currentItemContentHash === null)
|
|
649
|
+
return 'UNVERIFIABLE';
|
|
650
|
+
return currentItemContentHash === questionContentHash ? 'CURRENT' : 'HISTORICAL';
|
|
651
|
+
}
|
|
652
|
+
function readQuestionRow(row) {
|
|
653
|
+
if (row.event_type !== DELIVERY_QUESTION_EVENT_TYPE || row.actor_role !== 'OWNER')
|
|
654
|
+
return null;
|
|
655
|
+
const payload = parseJson(row.payload_json);
|
|
656
|
+
if (payload.version !== DELIVERY_QUESTION_PAYLOAD_VERSION)
|
|
657
|
+
return null;
|
|
658
|
+
const questionId = payload.questionId;
|
|
659
|
+
if (typeof questionId !== 'string' || !DELIVERY_QUESTION_ID.test(questionId))
|
|
660
|
+
return null;
|
|
661
|
+
const fields = { deliveryId: payload.deliveryId, taskId: payload.taskId, itemId: payload.itemId, contentHash: payload.contentHash,
|
|
662
|
+
resultId: payload.resultId, reviewerBindingId: payload.reviewerBindingId };
|
|
663
|
+
for (const value of Object.values(fields))
|
|
664
|
+
if (typeof value !== 'string' || value === '')
|
|
665
|
+
return null;
|
|
666
|
+
if (typeof payload.questionText !== 'string' || !payload.questionText)
|
|
667
|
+
return null;
|
|
668
|
+
if (typeof payload.askedAt !== 'string' || payload.askedAt === '')
|
|
669
|
+
return null;
|
|
670
|
+
return {
|
|
671
|
+
questionId,
|
|
672
|
+
deliveryId: payload.deliveryId,
|
|
673
|
+
taskId: payload.taskId,
|
|
674
|
+
itemId: payload.itemId,
|
|
675
|
+
itemLabel: typeof payload.itemLabel === 'string' ? payload.itemLabel : '',
|
|
676
|
+
contentHash: payload.contentHash,
|
|
677
|
+
attemptNo: Number.isSafeInteger(payload.attemptNo) ? payload.attemptNo : 0,
|
|
678
|
+
resultId: payload.resultId,
|
|
679
|
+
reviewerBindingId: payload.reviewerBindingId,
|
|
680
|
+
acceptanceEvidenceKind: DELIVERY_ACCEPTANCE_EVIDENCE_KINDS.includes(payload.acceptanceEvidenceKind)
|
|
681
|
+
? payload.acceptanceEvidenceKind : 'UNKNOWN',
|
|
682
|
+
acceptanceEvidenceExact: payload.acceptanceEvidenceExact === true,
|
|
683
|
+
ownerId: typeof payload.ownerId === 'string' ? payload.ownerId : row.actor_id ?? '',
|
|
684
|
+
ownerBindingId: typeof payload.ownerBindingId === 'string' ? payload.ownerBindingId : null,
|
|
685
|
+
operationId: typeof payload.operationId === 'string' && payload.operationId ? payload.operationId : null,
|
|
686
|
+
questionText: payload.questionText,
|
|
687
|
+
askedAt: payload.askedAt,
|
|
688
|
+
eventId: row.event_id,
|
|
689
|
+
eventSeq: row.seq,
|
|
690
|
+
};
|
|
691
|
+
}
|
|
692
|
+
function readReplyRow(row) {
|
|
693
|
+
if (row.event_type !== DELIVERY_REPLY_EVENT_TYPE || row.actor_role !== 'SUPERVISOR')
|
|
694
|
+
return null;
|
|
695
|
+
const payload = parseJson(row.payload_json);
|
|
696
|
+
if (payload.version !== DELIVERY_REPLY_PAYLOAD_VERSION)
|
|
697
|
+
return null;
|
|
698
|
+
const questionId = payload.questionId;
|
|
699
|
+
if (typeof questionId !== 'string' || !DELIVERY_QUESTION_ID.test(questionId))
|
|
700
|
+
return null;
|
|
701
|
+
if (typeof payload.responderBindingId !== 'string' || !payload.responderBindingId)
|
|
702
|
+
return null;
|
|
703
|
+
if (typeof payload.replyText !== 'string' || !payload.replyText)
|
|
704
|
+
return null;
|
|
705
|
+
if (typeof payload.repliedAt !== 'string' || !payload.repliedAt)
|
|
706
|
+
return null;
|
|
707
|
+
return { questionId, responderBindingId: payload.responderBindingId, replyText: payload.replyText,
|
|
708
|
+
repliedAt: payload.repliedAt, eventId: row.event_id, eventSeq: row.seq };
|
|
709
|
+
}
|
|
710
|
+
/**
|
|
711
|
+
* 问题事实 ID:**以一次 prepared Owner operation 为界**,不以正文去重。
|
|
712
|
+
*
|
|
713
|
+
* 同一 prepared operation 重试(含 submit 重放)由调用方传入同一 operationId,
|
|
714
|
+
* 因此得到同一事件 ID,只写一条事实;两个**不同** operation 即使针对同一条目、
|
|
715
|
+
* 同一内容版本、输入完全相同的文字,也各自形成独立问题——提问身份属于一次 Owner
|
|
716
|
+
* 操作,不属于那段文本。
|
|
717
|
+
*
|
|
718
|
+
* 旧记录(冻结时尚未携带 operationId)用写入时冻结的 questionId/contentHash/正文
|
|
719
|
+
* 派生,保证同一历史问题在任何重放中仍是同一身份。
|
|
720
|
+
*/
|
|
721
|
+
export function deliveryQuestionEventId(input) {
|
|
722
|
+
const scope = `${input.kingdomId}\u0000${input.deliveryId}\u0000${input.itemId}\u0000${input.contentHash}`;
|
|
723
|
+
const identity = input.operationId
|
|
724
|
+
? `operation\u0000${input.operationId}`
|
|
725
|
+
: `question\u0000${input.questionId ?? ''}\u0000${input.questionText ?? ''}`;
|
|
726
|
+
return `owner-delivery-question:${hash(`${scope}\u0000${identity}`)}`;
|
|
727
|
+
}
|
|
728
|
+
/**
|
|
729
|
+
* 幂等回复 ID:**只由 question ID 决定**,与正文无关。
|
|
730
|
+
*
|
|
731
|
+
* 一个问题因此至多存在一条回复事实行;同一文本重试回原事实,不同文本在同一
|
|
732
|
+
* `BEGIN IMMEDIATE` 事务里看到既有回复行并明确冲突。事件 ID 不含正文,两个并发
|
|
733
|
+
* 连接不可能各自写出「不同 ID 的双事实」。
|
|
734
|
+
*/
|
|
735
|
+
export function deliveryReplyEventId(kingdomId, questionId) {
|
|
736
|
+
return `owner-delivery-reply:${hash(`${kingdomId}\u0000${questionId}`)}`;
|
|
737
|
+
}
|
|
738
|
+
/**
|
|
739
|
+
* 读取一条已确认交付的**全部**提问与回复。**只按精确引用的既有事件行读取**,不依赖
|
|
740
|
+
* 任何有界的最近事件投影:账本里没有记录的问题就不存在。
|
|
741
|
+
*
|
|
742
|
+
* 返回的线程按**问题自身**的 item/contentHash 归类,不用首问代表整条交付:
|
|
743
|
+
* `itemId`/`itemLabel`/`contentHash` 只描述最早那条问题所属的条目,调用方必须用
|
|
744
|
+
* {@link deliveryQuestionThreadForItem} 按目标条目过滤。
|
|
745
|
+
*/
|
|
746
|
+
export function readDeliveryQuestionThread(store, kingdomId, deliveryId) {
|
|
747
|
+
const questions = store.db.prepare('SELECT * FROM events WHERE kingdom_id = ? AND event_type = ? AND target_type = ? AND target_id = ? ORDER BY seq ASC')
|
|
748
|
+
.all(kingdomId, DELIVERY_QUESTION_EVENT_TYPE, 'delivery', deliveryId);
|
|
749
|
+
const replies = store.db.prepare('SELECT * FROM events WHERE kingdom_id = ? AND event_type = ? AND target_type = ? AND target_id = ? ORDER BY seq ASC')
|
|
750
|
+
.all(kingdomId, DELIVERY_REPLY_EVENT_TYPE, 'delivery', deliveryId);
|
|
751
|
+
const replyByQuestion = new Map();
|
|
752
|
+
for (const row of replies) {
|
|
753
|
+
const reply = readReplyRow(row);
|
|
754
|
+
if (reply && !replyByQuestion.has(reply.questionId))
|
|
755
|
+
replyByQuestion.set(reply.questionId, reply);
|
|
756
|
+
}
|
|
757
|
+
const parsed = questions.map(readQuestionRow).filter((question) => question !== null);
|
|
758
|
+
if (!parsed.length)
|
|
759
|
+
return null;
|
|
760
|
+
const taskId = parsed[0].taskId;
|
|
761
|
+
const territory = store.getTerritoryById(store.getTask(taskId)?.territory_id ?? '');
|
|
762
|
+
const currentSupervisorBindingId = territory?.supervisor_binding_id ?? null;
|
|
763
|
+
// 当前内容版本逐 Task 重算一次,而不是用「最早那条问题」代表整条交付:同一条目的
|
|
764
|
+
// 每条问题据此标明当前版/历史版,旧版仍可读但不再冒充当前可回复的待办。
|
|
765
|
+
const currentVersions = currentItemContentHashes(store, kingdomId, [...new Set(parsed.map(question => question.taskId))]);
|
|
766
|
+
const threadQuestions = parsed.map(question => {
|
|
767
|
+
const reply = replyByQuestion.get(question.questionId) ?? null;
|
|
768
|
+
const currentItemContentHash = currentVersions.get(question.taskId)?.get(question.itemId) ?? null;
|
|
769
|
+
return { ...question, reply, currentSupervisorBindingId,
|
|
770
|
+
itemVersion: classifyDeliveryItemVersion(currentItemContentHash, question.contentHash),
|
|
771
|
+
currentItemContentHash,
|
|
772
|
+
replyState: classifyDeliveryReplyAccess(store, kingdomId, question.reviewerBindingId, currentSupervisorBindingId).state };
|
|
773
|
+
});
|
|
774
|
+
const latestCurrent = [...threadQuestions].reverse().find(question => question.itemVersion === 'CURRENT') ?? null;
|
|
775
|
+
return {
|
|
776
|
+
deliveryId,
|
|
777
|
+
taskId,
|
|
778
|
+
itemId: threadQuestions[0].itemId,
|
|
779
|
+
itemLabel: threadQuestions[0].itemLabel,
|
|
780
|
+
contentHash: threadQuestions[0].contentHash,
|
|
781
|
+
questions: threadQuestions,
|
|
782
|
+
answeredCount: threadQuestions.filter(question => question.reply !== null).length,
|
|
783
|
+
// 待领取决不把旧版或无法重验的未答问题算成当前待办:只有「已验证当前版本且尚无回复」才计入。
|
|
784
|
+
pendingCount: threadQuestions.filter(question => question.reply === null && question.itemVersion === 'CURRENT').length,
|
|
785
|
+
historyCount: threadQuestions.filter(question => question.itemVersion === 'HISTORICAL').length,
|
|
786
|
+
unverifiableCount: threadQuestions.filter(question => question.itemVersion === 'UNVERIFIABLE').length,
|
|
787
|
+
currentQuestionId: latestCurrent?.questionId ?? null,
|
|
788
|
+
currentContentHash: latestCurrent?.contentHash ?? null,
|
|
789
|
+
};
|
|
790
|
+
}
|
|
791
|
+
/**
|
|
792
|
+
* 列出本王国**已有提问记录**的精确条目索引(按最近提问时间倒序)。
|
|
793
|
+
*
|
|
794
|
+
* 只查既有提问事件账本,不依赖任何有界的最近事件投影或当前交付目录:这正是
|
|
795
|
+
* 「旧问答仍可发现」的最小入口。正文与回复正文不在这里返回,调用方仍须按窗口
|
|
796
|
+
* scope 过滤后展示,且这些条目**不能**被当作新提问目标(prepare 另有精确重验)。
|
|
797
|
+
*/
|
|
798
|
+
export function listDeliveryQuestionHistory(store, kingdomId) {
|
|
799
|
+
const rows = store.db.prepare('SELECT DISTINCT target_id FROM events WHERE kingdom_id = ? AND event_type = ? AND target_type = ?')
|
|
800
|
+
.all(kingdomId, DELIVERY_QUESTION_EVENT_TYPE, 'delivery');
|
|
801
|
+
const targets = [];
|
|
802
|
+
for (const row of rows) {
|
|
803
|
+
const thread = readDeliveryQuestionThread(store, kingdomId, row.target_id);
|
|
804
|
+
if (!thread)
|
|
805
|
+
continue;
|
|
806
|
+
const byItem = new Map();
|
|
807
|
+
for (const question of thread.questions) {
|
|
808
|
+
const bucket = byItem.get(question.itemId);
|
|
809
|
+
if (bucket)
|
|
810
|
+
bucket.push(question);
|
|
811
|
+
else
|
|
812
|
+
byItem.set(question.itemId, [question]);
|
|
813
|
+
}
|
|
814
|
+
for (const [itemId, questions] of byItem) {
|
|
815
|
+
const latest = questions[questions.length - 1];
|
|
816
|
+
targets.push({
|
|
817
|
+
deliveryId: thread.deliveryId, taskId: latest.taskId, itemId, itemLabel: latest.itemLabel,
|
|
818
|
+
questionCount: questions.length,
|
|
819
|
+
pendingCount: questions.filter(question => question.reply === null && question.itemVersion === 'CURRENT').length,
|
|
820
|
+
answeredCount: questions.filter(question => question.reply !== null).length,
|
|
821
|
+
historyCount: questions.filter(question => question.itemVersion === 'HISTORICAL').length,
|
|
822
|
+
unverifiableCount: questions.filter(question => question.itemVersion === 'UNVERIFIABLE').length,
|
|
823
|
+
lastAskedAt: latest.askedAt,
|
|
824
|
+
latestItemVersion: latest.itemVersion,
|
|
825
|
+
currentContentHash: latest.currentItemContentHash,
|
|
826
|
+
});
|
|
827
|
+
}
|
|
828
|
+
}
|
|
829
|
+
targets.sort((a, b) => b.lastAskedAt.localeCompare(a.lastAskedAt)
|
|
830
|
+
|| a.taskId.localeCompare(b.taskId) || a.itemId.localeCompare(b.itemId));
|
|
831
|
+
return targets;
|
|
832
|
+
}
|
|
833
|
+
/**
|
|
834
|
+
* 一个 Task 此刻重新派生出的交付条目。
|
|
835
|
+
*
|
|
836
|
+
* 优先用「同一 Task/attempt 的主管 ACCEPT 确认交付 + 该 ACCEPT 绑定的改动证据」;
|
|
837
|
+
* 尚未确认的新尝试(例如刚写入、还在 REVIEW 的 WorkerResult)也按其自述重新派生,
|
|
838
|
+
* 这样已经改版却尚未被接受的旧问题不会继续冒充当前版本。无法派生(没有
|
|
839
|
+
* WorkerResult)时返回 null,调用方据此不声称任何版本关系(fail-as-unknown,
|
|
840
|
+
* 而不是把旧版当当前版)。
|
|
841
|
+
*/
|
|
842
|
+
function derivedDeliveryItemsForTask(store, kingdomId, taskId) {
|
|
843
|
+
const claim = store.latestWorkerResult(taskId);
|
|
844
|
+
if (!claim)
|
|
845
|
+
return null;
|
|
846
|
+
const review = readLatestReviewEvent(store, kingdomId, taskId);
|
|
847
|
+
const accepted = classifyAcceptedDelivery(store, claim, review) !== null;
|
|
848
|
+
const ref = accepted ? readAcceptedChangeEvidence(review) : null;
|
|
849
|
+
const evidence = ref ? resolveChangeEvidenceForDelivery(undefined, ref) : null;
|
|
850
|
+
return deriveDeliveryItems(taskId, claim, evidence);
|
|
851
|
+
}
|
|
852
|
+
/**
|
|
853
|
+
* 一个**精确条目**此刻重新派生出的版本(itemId、contentHash 与当前标签)。
|
|
854
|
+
*
|
|
855
|
+
* Owner 的精确 task/item 回读必须用它做版本真值,**不能**用有界展示目录:展示目录
|
|
856
|
+
* 为了界面规模会截断较旧条目,但那些条目依然是可以精确重验的当前版本。返回 null
|
|
857
|
+
* 表示该条目此刻无法从当前 ACCEPT/证据重新派生。
|
|
858
|
+
*/
|
|
859
|
+
export function readDeliveryItemVersion(store, kingdomId, taskId, itemId) {
|
|
860
|
+
const item = derivedDeliveryItemsForTask(store, kingdomId, taskId)?.find(candidate => candidate.itemId === itemId);
|
|
861
|
+
return item ? { itemId: item.itemId, contentHash: item.contentHash, label: item.content.label } : null;
|
|
862
|
+
}
|
|
863
|
+
/**
|
|
864
|
+
* 每个 Task 各条目**当前重新派生**出的内容版本(itemId → contentHash)。
|
|
865
|
+
*
|
|
866
|
+
* 逐 Task 从真实 Claim/ACCEPT/证据重算,不受任何目录上限影响;无法派生的 Task
|
|
867
|
+
* 整体缺席,调用方据此不声称任何版本关系。
|
|
868
|
+
*/
|
|
869
|
+
function currentItemContentHashes(store, kingdomId, taskIds) {
|
|
870
|
+
const result = new Map();
|
|
871
|
+
for (const taskId of taskIds) {
|
|
872
|
+
const items = derivedDeliveryItemsForTask(store, kingdomId, taskId);
|
|
873
|
+
if (!items)
|
|
874
|
+
continue;
|
|
875
|
+
result.set(taskId, new Map(items.map(item => [item.itemId, item.contentHash])));
|
|
876
|
+
}
|
|
877
|
+
return result;
|
|
878
|
+
}
|
|
879
|
+
/** 在调用方给定的精确内容版本下重新标注已验证当前/历史版/无法重验与计数。 */
|
|
880
|
+
function markThreadVersions(thread, questions, currentContentHash, itemLabel) {
|
|
881
|
+
const entries = questions.map(question => ({ ...question,
|
|
882
|
+
itemVersion: classifyDeliveryItemVersion(currentContentHash, question.contentHash),
|
|
883
|
+
currentItemContentHash: currentContentHash }));
|
|
884
|
+
const latestCurrent = [...entries].reverse().find(question => question.itemVersion === 'CURRENT') ?? null;
|
|
885
|
+
return { ...thread,
|
|
886
|
+
questions: entries,
|
|
887
|
+
answeredCount: entries.filter(question => question.reply !== null).length,
|
|
888
|
+
pendingCount: entries.filter(question => question.reply === null && question.itemVersion === 'CURRENT').length,
|
|
889
|
+
historyCount: entries.filter(question => question.itemVersion === 'HISTORICAL').length,
|
|
890
|
+
unverifiableCount: entries.filter(question => question.itemVersion === 'UNVERIFIABLE').length,
|
|
891
|
+
currentQuestionId: latestCurrent?.questionId ?? null };
|
|
892
|
+
}
|
|
893
|
+
/**
|
|
894
|
+
* 过滤出**某一交付条目**的问答线程。
|
|
895
|
+
*
|
|
896
|
+
* 按每条问题自己的 `itemId` 归类,再按调用方给出的精确 `contentHash` 区分已验证当前、
|
|
897
|
+
* 历史版与无法重验;不以整条交付的首问代表其他条目。`contentHash` 为 null 表示当前
|
|
898
|
+
* 交付目录无法重验该条目,此时所有问题都标为无法重验而不是当前版。条目下没有任何
|
|
899
|
+
* 提问时返回 null,调用方据此说明「该条还没有提问记录」,而不是给出空线程或另一条的线程。
|
|
900
|
+
*/
|
|
901
|
+
export function deliveryQuestionThreadForItem(thread, itemId, contentHash) {
|
|
902
|
+
if (!thread)
|
|
903
|
+
return null;
|
|
904
|
+
const questions = thread.questions.filter(question => question.itemId === itemId);
|
|
905
|
+
if (!questions.length)
|
|
906
|
+
return null;
|
|
907
|
+
const latest = questions[questions.length - 1];
|
|
908
|
+
const marked = markThreadVersions(thread, questions, contentHash, latest.itemLabel);
|
|
909
|
+
return { ...marked, itemId, itemLabel: latest.itemLabel, contentHash };
|
|
910
|
+
}
|
|
911
|
+
/**
|
|
912
|
+
* 回复可达性判据。
|
|
913
|
+
*
|
|
914
|
+
* 首版接收者是 `TASK_ACCEPTED` 所记的主管 binding(问题写入时冻结)。这里先看该
|
|
915
|
+
* binding 自身:不存在 → `REVIEWER_BINDING_MISSING`;已退任 → `REVIEWER_BINDING_RETIRED`;
|
|
916
|
+
* 领地当前主理已不是它 → `SUPERVISOR_REBOUND`;没有可用于回复的 session →
|
|
917
|
+
* `REVIEWER_SESSION_CHANGED`。只有全部成立才是 `REPLY_ACCESSIBLE`,否则 fail-closed。
|
|
918
|
+
*
|
|
919
|
+
* `claimedSessionId` 是调用方**实际用来证明身份**的 session:它与 binding 当前
|
|
920
|
+
* session 不一致(即主管已换 session)时同样不可达,避免旧 session 的调用者被误判为
|
|
921
|
+
* 当前责任主管。
|
|
922
|
+
*/
|
|
923
|
+
export function classifyDeliveryReplyAccess(store, kingdomId, reviewerBindingId, currentSupervisorBindingId, claimedSessionId) {
|
|
924
|
+
const binding = store.getBindingById(reviewerBindingId);
|
|
925
|
+
if (!binding || binding.kingdom_id !== kingdomId || binding.role_type !== 'SUPERVISOR') {
|
|
926
|
+
return { state: 'REVIEWER_BINDING_MISSING', detail: '接受该交付时记录的主管绑定已不存在或不是主管,本条提问不可达;不会改投继任主管。' };
|
|
927
|
+
}
|
|
928
|
+
if (binding.status !== 'ACTIVE') {
|
|
929
|
+
return { state: 'REVIEWER_BINDING_RETIRED', detail: '接受该交付的主管已退任,本条提问不可达;不会改投继任主管。' };
|
|
930
|
+
}
|
|
931
|
+
if (!currentSupervisorBindingId || currentSupervisorBindingId !== reviewerBindingId) {
|
|
932
|
+
return { state: 'SUPERVISOR_REBOUND', detail: '该领地已改由其他主管主理,本条提问仍归原接受主管,不可达;不会自动改投继任者。' };
|
|
933
|
+
}
|
|
934
|
+
if (!binding.session_id || (claimedSessionId !== undefined && claimedSessionId !== binding.session_id)) {
|
|
935
|
+
return { state: 'REVIEWER_SESSION_CHANGED', detail: '该主管当前没有可用于回复的 ACTIVE session(或其 session 已更换),本条提问暂不可达。' };
|
|
936
|
+
}
|
|
937
|
+
return { state: 'REPLY_ACCESSIBLE', detail: '该主管仍是本领地主理且持有 ACTIVE session,可读取并回复本条提问。' };
|
|
938
|
+
}
|
|
939
|
+
/** 公开事件投影的最大字段集:问答正文永不出现。 */
|
|
940
|
+
export function toDeliveryQuestionEventPayload(row) {
|
|
941
|
+
const payload = parseJson(row.payload_json);
|
|
942
|
+
const result = {};
|
|
943
|
+
for (const [key, value] of Object.entries(payload))
|
|
944
|
+
result[key] = QUESTION_BODY_KEYS.includes(key) ? '[redacted]' : value;
|
|
945
|
+
return result;
|
|
946
|
+
}
|
|
947
|
+
// ── 提问写入(仅供 Core Owner Control Plane 调用)────────────────────
|
|
948
|
+
export class DeliveryQuestionError extends Error {
|
|
949
|
+
code;
|
|
950
|
+
constructor(code, message) {
|
|
951
|
+
super(message);
|
|
952
|
+
this.code = code;
|
|
953
|
+
this.name = 'DeliveryQuestionError';
|
|
954
|
+
}
|
|
955
|
+
}
|
|
956
|
+
function questionFail(code, message) {
|
|
957
|
+
throw new DeliveryQuestionError(code, message);
|
|
958
|
+
}
|
|
959
|
+
const DELIVERY_QUESTION_ID = /^question:[0-9a-f]{32}$/u;
|
|
960
|
+
export const DELIVERY_QUESTION_TEXT_LIMIT = 2000;
|
|
961
|
+
export const DELIVERY_REPLY_TEXT_LIMIT = 4000;
|
|
962
|
+
/**
|
|
963
|
+
* 提问/回复正文的写入形状:先做与公开呈现同一套凭据与路径脱敏,再折叠空白并限长。
|
|
964
|
+
*
|
|
965
|
+
* 事实里因此永远不保存未脱敏正文;投影端即使错误地透传字段,也不会带出凭据或本机路径。
|
|
966
|
+
*/
|
|
967
|
+
export function boundedQuestionText(value, limit) {
|
|
968
|
+
const collapsed = redactDeliveryText(value).replace(/[\s]+/gu, ' ').trim();
|
|
969
|
+
return collapsed.length > limit ? collapsed.slice(0, limit) : collapsed;
|
|
970
|
+
}
|
|
971
|
+
/**
|
|
972
|
+
* 追加一条 Owner 提问事实。
|
|
973
|
+
*
|
|
974
|
+
* 前置校验(任一失败即零写入):
|
|
975
|
+
* - Task 当前确实处于「同一 Task/attempt 的主管 ACCEPT 确认交付」状态;
|
|
976
|
+
* - 传入的 delivery/item/contentHash/attempt/result 与当前派生结果完全一致;
|
|
977
|
+
* - `itemId` 必须正好指向一条提问槽位:入口每次只提交一条精确条目。
|
|
978
|
+
*
|
|
979
|
+
* 幂等:同一 `operationId`(即同一次 prepared operation 的重试与重放)已有记录时
|
|
980
|
+
* 原样返回;**不同 operation 即使问题文字相同,也各自是一条独立问题事实**。
|
|
981
|
+
*/
|
|
982
|
+
export function recordDeliveryQuestion(store, input) {
|
|
983
|
+
if (!input.ownerId)
|
|
984
|
+
questionFail('OWNER_CONTROL_REQUIRED', '提问必须记录 Owner principal。');
|
|
985
|
+
const operationId = boundedQuestionText(input.operationId, 200);
|
|
986
|
+
if (!operationId)
|
|
987
|
+
questionFail('INVALID_INPUT', '提问必须记录产生它的 Owner 操作编号。');
|
|
988
|
+
const text = boundedQuestionText(input.questionText, DELIVERY_QUESTION_TEXT_LIMIT);
|
|
989
|
+
if (!text)
|
|
990
|
+
questionFail('INVALID_INPUT', '问题文本必须是非空、长度受限的文本。');
|
|
991
|
+
const task = store.getTask(input.taskId);
|
|
992
|
+
if (!task)
|
|
993
|
+
questionFail('TASK_NOT_FOUND', '提问对应的任务不存在。');
|
|
994
|
+
const claim = store.latestWorkerResult(input.taskId);
|
|
995
|
+
const review = readLatestReviewEvent(store, input.kingdomId, input.taskId);
|
|
996
|
+
const classification = classifyAcceptedDelivery(store, claim, review);
|
|
997
|
+
if (!claim || !classification)
|
|
998
|
+
questionFail('DELIVERY_NOT_CONFIRMED', '该交付尚未由同一 Task/attempt 的主管 ACCEPT 确认,不能提问。');
|
|
999
|
+
if (claim.attempt_no !== input.attemptNo || claim.result_id !== input.resultId) {
|
|
1000
|
+
questionFail('DELIVERY_VERSION_STALE', '交付的已接受版本已变化,请重新读取清单后再提问。');
|
|
1001
|
+
}
|
|
1002
|
+
const deliveryId = deliveryIdFor(input.taskId);
|
|
1003
|
+
if (deliveryId !== input.deliveryId)
|
|
1004
|
+
questionFail('DELIVERY_VERSION_STALE', '交付标识与当前已接受版本不一致。');
|
|
1005
|
+
// CHANGE 条目只由「主管在 ACCEPT 中显式选择、且本地 hash 重验通过」的改动证据派生;
|
|
1006
|
+
// 与 Owner 目录、预览和知悉使用同一份证据,重建出的 itemId/contentHash 才可能一致。
|
|
1007
|
+
const item = deriveDeliveryItems(input.taskId, claim, input.changeEvidence).find(candidate => candidate.itemId === input.itemId);
|
|
1008
|
+
if (!item)
|
|
1009
|
+
questionFail('DELIVERY_ITEM_UNKNOWN', '该条目不在当前交付清单中,不能提问。');
|
|
1010
|
+
if (item.contentHash !== input.contentHash)
|
|
1011
|
+
questionFail('DELIVERY_ITEM_VERSION_STALE', '该条目内容版本已变化,请重新读取后再提问。');
|
|
1012
|
+
const reviewerBindingId = deliveryReviewerBindingId(review);
|
|
1013
|
+
if (!reviewerBindingId)
|
|
1014
|
+
questionFail('DELIVERY_ACCEPT_REVIEWER_UNKNOWN', '接受事件没有记录主管绑定,无法确定本问题的接收者。');
|
|
1015
|
+
const eventId = deliveryQuestionEventId({ kingdomId: input.kingdomId, deliveryId: input.deliveryId, itemId: input.itemId,
|
|
1016
|
+
contentHash: input.contentHash, operationId });
|
|
1017
|
+
const existing = store.getEventById(eventId);
|
|
1018
|
+
if (existing) {
|
|
1019
|
+
const question = readQuestionRow(existing);
|
|
1020
|
+
if (!question)
|
|
1021
|
+
questionFail('QUESTION_EVENT_CONFLICT', '既有提问记录与本次引用不一致,请重新核对。');
|
|
1022
|
+
// 同一 operation 只对应一条事实:内容必须与本次提交完全一致,否则是重放冲突。
|
|
1023
|
+
if (question.itemId !== input.itemId || question.contentHash !== input.contentHash || question.questionText !== text) {
|
|
1024
|
+
questionFail('QUESTION_EVENT_CONFLICT', '该操作编号已记录了另一条提问,拒绝改写既有事实。');
|
|
1025
|
+
}
|
|
1026
|
+
return { questionId: question.questionId, askedAt: question.askedAt, eventId: existing.event_id, eventSeq: existing.seq, created: false };
|
|
1027
|
+
}
|
|
1028
|
+
const questionId = `question:${hash(`${eventId}\u0000${reviewerBindingId}`).slice(0, 32)}`;
|
|
1029
|
+
const row = store.appendEvent({
|
|
1030
|
+
event_id: eventId,
|
|
1031
|
+
kingdom_id: input.kingdomId,
|
|
1032
|
+
event_type: DELIVERY_QUESTION_EVENT_TYPE,
|
|
1033
|
+
actor_role: 'OWNER',
|
|
1034
|
+
actor_id: input.ownerId,
|
|
1035
|
+
target_type: 'delivery',
|
|
1036
|
+
target_id: input.deliveryId,
|
|
1037
|
+
payload_json: JSON.stringify({
|
|
1038
|
+
version: DELIVERY_QUESTION_PAYLOAD_VERSION,
|
|
1039
|
+
questionId,
|
|
1040
|
+
deliveryId: input.deliveryId,
|
|
1041
|
+
taskId: input.taskId,
|
|
1042
|
+
itemId: input.itemId,
|
|
1043
|
+
itemLabel: bounded(input.itemLabel),
|
|
1044
|
+
contentHash: input.contentHash,
|
|
1045
|
+
attemptNo: input.attemptNo,
|
|
1046
|
+
resultId: input.resultId,
|
|
1047
|
+
// 首版接收者固定为 ACCEPT 事件所记主管:事实里冻结,事后改绑也无法改投。
|
|
1048
|
+
reviewerBindingId,
|
|
1049
|
+
acceptanceEvidenceKind: classification.kind,
|
|
1050
|
+
acceptanceEvidenceExact: classification.evidence.exactResultBound,
|
|
1051
|
+
ownerId: input.ownerId,
|
|
1052
|
+
ownerBindingId: input.ownerBindingId,
|
|
1053
|
+
// 提问身份属于产生它的那次 Owner 操作,而不是那段文本。
|
|
1054
|
+
operationId,
|
|
1055
|
+
questionText: text,
|
|
1056
|
+
askedAt: input.askedAt,
|
|
1057
|
+
meaning: 'OWNER_QUESTION_FOR_ACCEPTING_SUPERVISOR_ONLY',
|
|
1058
|
+
...(input.attribution ?? {}),
|
|
1059
|
+
}),
|
|
1060
|
+
created_at: input.askedAt,
|
|
1061
|
+
});
|
|
1062
|
+
return { questionId, askedAt: input.askedAt, eventId: row.event_id, eventSeq: row.seq, created: true };
|
|
1063
|
+
}
|
|
1064
|
+
/**
|
|
1065
|
+
* 追加一条主管回复事实。
|
|
1066
|
+
*
|
|
1067
|
+
* 写入门槛(任一失败即零写入):
|
|
1068
|
+
* - 问题必须存在于账本里,且属于本王国、本条交付;
|
|
1069
|
+
* - 传入 item/contentHash/attempt/result 必须与问题记录完全一致,同时仍等于当前
|
|
1070
|
+
* 已确认交付的派生结果(条目改版后不得回复到旧版本);
|
|
1071
|
+
* - 回复者必须**正好**是问题记录的主管 binding,且该 binding 此刻仍 ACTIVE、仍是
|
|
1072
|
+
* 领地当前主理、其 session 仍是调用者用来证明身份的那个 session;
|
|
1073
|
+
* - CHANGE 条目必须按**当前 ACCEPT 引用**在同一写锁内重新读取并重验证据:证据漂移、
|
|
1074
|
+
* 被替换或条目改版时不再派生该条目,回复 fail-closed。
|
|
1075
|
+
*
|
|
1076
|
+
* 锁前入口校验(`replyToDeliveryQuestion`)只用于尽早拒绝与给出可读原因,**不是**
|
|
1077
|
+
* 写入授权:上面这些可变授权事实全部在 `BEGIN IMMEDIATE` 取得写锁之后重新读取。
|
|
1078
|
+
* 否则「A 先通过入口校验 → B 退任/改绑并提交 → A 再取锁写回复」会写出已失效的回复。
|
|
1079
|
+
* 幂等重试仍在锁内最先判定:既有回复行 + 同一文本原样返回,不因随后改绑而报错,
|
|
1080
|
+
* 也不会新增第二条事实。
|
|
1081
|
+
*
|
|
1082
|
+
* 一问最多一个当前回复,且**只由 question ID 决定回复事件 ID**:同一问题在任何
|
|
1083
|
+
* 并发连接上都只可能写出一条回复事实行。同一文本重试幂等;不同文本明确拒绝。
|
|
1084
|
+
* 「重验 + 单次写入」整体在 `store.withImmediateTransaction` 的 `BEGIN IMMEDIATE`
|
|
1085
|
+
* 事务内完成:该包装在取得写锁之前不执行回调,BEGIN 因 BUSY/LOCKED/I-O/MISUSE
|
|
1086
|
+
* 失败时原样抛出且**零回复写入**,绝不把 BEGIN 失败当作「已有外层事务」继续。
|
|
1087
|
+
* 两个并发连接因此被 SQLite 串行化,不会各自看到空回复而写出双事实。
|
|
1088
|
+
*/
|
|
1089
|
+
export function recordDeliveryReply(store, input) {
|
|
1090
|
+
return store.withImmediateTransaction(() => {
|
|
1091
|
+
const question = readQuestionById(store, input.kingdomId, input.questionId);
|
|
1092
|
+
if (!question)
|
|
1093
|
+
questionFail('QUESTION_NOT_FOUND', '该问题不存在或不属于本王国,不能回复。');
|
|
1094
|
+
if (question.reviewerBindingId !== input.responderBindingId) {
|
|
1095
|
+
questionFail('QUESTION_NOT_ADDRESSED_TO_CALLER', '该问题只由接受交付的主管负责回复;其他主管不能代答。');
|
|
1096
|
+
}
|
|
1097
|
+
const text = boundedQuestionText(input.replyText, DELIVERY_REPLY_TEXT_LIMIT);
|
|
1098
|
+
if (!text)
|
|
1099
|
+
questionFail('INVALID_INPUT', '回复文本必须是非空、长度受限的文本。');
|
|
1100
|
+
// 锁内重新核对绑定:入口校验之后退任的 binding 不再放行。
|
|
1101
|
+
const binding = store.getBindingById(input.responderBindingId);
|
|
1102
|
+
if (!binding || binding.kingdom_id !== input.kingdomId || binding.role_type !== 'SUPERVISOR' || binding.status !== 'ACTIVE') {
|
|
1103
|
+
questionFail('QUESTION_REVIEWER_UNREACHABLE', '接受该交付的主管绑定此刻不是本王国 ACTIVE 主管,不能回复。');
|
|
1104
|
+
}
|
|
1105
|
+
const task = store.getTask(question.taskId);
|
|
1106
|
+
const territory = store.getTerritoryById(task?.territory_id ?? '');
|
|
1107
|
+
if (!task || !territory || territory.kingdom_id !== input.kingdomId || territory.status === 'DELETED') {
|
|
1108
|
+
questionFail('TASK_NOT_IN_KINGDOM', '该问题对应的任务此刻不在本王国,不能回复。');
|
|
1109
|
+
}
|
|
1110
|
+
// 领地当前主理必须仍是这个 binding:入口校验之后发生的改绑同样在锁内被拒绝,
|
|
1111
|
+
// 且不会把问题改投继任主管。
|
|
1112
|
+
if (!territory.supervisor_binding_id || territory.supervisor_binding_id !== input.responderBindingId) {
|
|
1113
|
+
questionFail('SUPERVISOR_REBOUND', '该领地此刻已改由其他主管主理,本条提问不可达;不会自动改投继任者。');
|
|
1114
|
+
}
|
|
1115
|
+
// session 也必须在锁内重新比对:主管换 session 后,旧 session 不再能写回复。
|
|
1116
|
+
if (!binding.session_id || binding.session_id !== input.callerSessionId) {
|
|
1117
|
+
questionFail('QUESTION_REVIEWER_SESSION_CHANGED', '该主管此刻的 session 已更换,旧 session 不能写入回复。');
|
|
1118
|
+
}
|
|
1119
|
+
// 回复事件 ID 只由问题 ID 决定:先按精确 ID 查既有事实行,一问至多一条回复。
|
|
1120
|
+
const eventId = deliveryReplyEventId(input.kingdomId, question.questionId);
|
|
1121
|
+
const recorded = store.getEventById(eventId);
|
|
1122
|
+
if (recorded) {
|
|
1123
|
+
const reply = readReplyRow(recorded);
|
|
1124
|
+
if (!reply)
|
|
1125
|
+
questionFail('REPLY_EVENT_CONFLICT', '既有回复记录与本次引用不一致,请重新核对。');
|
|
1126
|
+
if (reply.replyText === text)
|
|
1127
|
+
return { questionId: question.questionId, reply, created: false };
|
|
1128
|
+
questionFail('REPLY_ALREADY_RECORDED', '该问题已有一条回复;同一问题最多一个当前回复,冲突回复被拒绝。');
|
|
1129
|
+
}
|
|
1130
|
+
const claim = store.latestWorkerResult(question.taskId);
|
|
1131
|
+
if (!claim)
|
|
1132
|
+
questionFail('DELIVERY_NOT_CONFIRMED', '该交付对应的结果不存在,不能回复。');
|
|
1133
|
+
if (claim.attempt_no !== question.attemptNo || claim.result_id !== question.resultId) {
|
|
1134
|
+
questionFail('DELIVERY_VERSION_STALE', '交付的已接受版本已变化,不能对该版本的问题回复。');
|
|
1135
|
+
}
|
|
1136
|
+
const review = readLatestReviewEvent(store, input.kingdomId, question.taskId);
|
|
1137
|
+
if (!classifyAcceptedDelivery(store, claim, review)) {
|
|
1138
|
+
questionFail('DELIVERY_NOT_CONFIRMED', '该交付已不再处于主管 ACCEPT 确认状态,不能回复。');
|
|
1139
|
+
}
|
|
1140
|
+
// 版本核对按问题**冻结时**的内容版本:问题只对它被提问的那个版本有效。
|
|
1141
|
+
// CHANGE 条目由主管确认的改动证据派生;证据在同一个写锁内**按当前 ACCEPT 引用**
|
|
1142
|
+
// 重新读取并重验,而不是沿用锁外解析出的旧对象。证据漂移/被替换/条目改版都会
|
|
1143
|
+
// fail-closed,因此回复不会把正文挪用到另一个条目或另一个内容版本上。
|
|
1144
|
+
const currentRef = readAcceptedChangeEvidence(review);
|
|
1145
|
+
const currentEvidence = currentRef ? resolveChangeEvidenceForDelivery(undefined, currentRef) : null;
|
|
1146
|
+
const currentItems = deriveDeliveryItems(question.taskId, claim, currentEvidence);
|
|
1147
|
+
if (!currentItems.some(item => item.itemId === question.itemId && item.contentHash === question.contentHash)) {
|
|
1148
|
+
questionFail('DELIVERY_ITEM_VERSION_STALE', '该条目当前版本已变化或改动证据不可重验,不能对该版本的问题回复。');
|
|
1149
|
+
}
|
|
1150
|
+
const row = store.appendEvent({
|
|
1151
|
+
event_id: eventId,
|
|
1152
|
+
kingdom_id: input.kingdomId,
|
|
1153
|
+
event_type: DELIVERY_REPLY_EVENT_TYPE,
|
|
1154
|
+
actor_role: 'SUPERVISOR',
|
|
1155
|
+
actor_id: input.responderBindingId,
|
|
1156
|
+
target_type: 'delivery',
|
|
1157
|
+
target_id: question.deliveryId,
|
|
1158
|
+
payload_json: JSON.stringify({
|
|
1159
|
+
version: DELIVERY_REPLY_PAYLOAD_VERSION,
|
|
1160
|
+
questionId: question.questionId,
|
|
1161
|
+
deliveryId: question.deliveryId,
|
|
1162
|
+
taskId: question.taskId,
|
|
1163
|
+
itemId: question.itemId,
|
|
1164
|
+
contentHash: question.contentHash,
|
|
1165
|
+
attemptNo: question.attemptNo,
|
|
1166
|
+
resultId: question.resultId,
|
|
1167
|
+
responderBindingId: input.responderBindingId,
|
|
1168
|
+
replyText: text,
|
|
1169
|
+
repliedAt: input.repliedAt,
|
|
1170
|
+
meaning: 'SUPERVISOR_REPLY_TO_OWNER_QUESTION_ONLY',
|
|
1171
|
+
...(input.attribution ?? {}),
|
|
1172
|
+
}),
|
|
1173
|
+
created_at: input.repliedAt,
|
|
1174
|
+
});
|
|
1175
|
+
return { questionId: question.questionId, created: true,
|
|
1176
|
+
reply: { questionId: question.questionId, responderBindingId: input.responderBindingId, replyText: text,
|
|
1177
|
+
repliedAt: input.repliedAt, eventId: row.event_id, eventSeq: row.seq } };
|
|
1178
|
+
});
|
|
1179
|
+
}
|
|
1180
|
+
/**
|
|
1181
|
+
* 按精确 questionId 从权威账本读取一条问题;形状不可核对或不属于该王国时为 null。
|
|
1182
|
+
*/
|
|
1183
|
+
export function readQuestionById(store, kingdomId, questionId) {
|
|
1184
|
+
if (!DELIVERY_QUESTION_ID.test(questionId))
|
|
1185
|
+
return null;
|
|
1186
|
+
const row = store.db.prepare('SELECT * FROM events WHERE kingdom_id = ? AND event_type = ? ORDER BY seq ASC')
|
|
1187
|
+
.all(kingdomId, DELIVERY_QUESTION_EVENT_TYPE)
|
|
1188
|
+
.map(readQuestionRow).find(candidate => candidate?.questionId === questionId);
|
|
1189
|
+
if (!row)
|
|
1190
|
+
return null;
|
|
1191
|
+
const task = store.getTask(row.taskId);
|
|
1192
|
+
const territory = store.getTerritoryById(task?.territory_id ?? '');
|
|
1193
|
+
const currentSupervisorBindingId = territory?.supervisor_binding_id ?? null;
|
|
1194
|
+
const currentItemContentHash = currentItemContentHashes(store, kingdomId, [row.taskId]).get(row.taskId)?.get(row.itemId) ?? null;
|
|
1195
|
+
return { ...row, reply: null, currentSupervisorBindingId, currentItemContentHash,
|
|
1196
|
+
itemVersion: classifyDeliveryItemVersion(currentItemContentHash, row.contentHash),
|
|
1197
|
+
replyState: classifyDeliveryReplyAccess(store, kingdomId, row.reviewerBindingId, currentSupervisorBindingId).state };
|
|
1198
|
+
}
|
|
1199
|
+
/** `TASK_ACCEPTED` 事件所记的主管 binding;形状不可核对时为 null(fail-closed)。 */
|
|
1200
|
+
export function deliveryReviewerBindingId(review) {
|
|
1201
|
+
if (review?.event_type !== 'TASK_ACCEPTED' || review.actor_role !== 'SUPERVISOR')
|
|
1202
|
+
return null;
|
|
1203
|
+
const payload = parseJson(review.payload_json);
|
|
1204
|
+
const reviewBinding = typeof payload.reviewer_binding_id === 'string' && payload.reviewer_binding_id ? payload.reviewer_binding_id : null;
|
|
1205
|
+
const actor = typeof review.actor_id === 'string' && review.actor_id ? review.actor_id : null;
|
|
1206
|
+
// 两个真实字段必须**同时非空且相等**:只出现其一时不猜测接收者。
|
|
1207
|
+
// 否则任一单字段(伪造的 payload 或 actor_id)都能把问题定向到任意主管。
|
|
1208
|
+
if (!reviewBinding || !actor || reviewBinding !== actor)
|
|
1209
|
+
return null;
|
|
1210
|
+
return reviewBinding;
|
|
1211
|
+
}
|
|
1212
|
+
// ── 主管收件箱与回复(session-bound Tool 专用)────────────────────────
|
|
1213
|
+
/**
|
|
1214
|
+
* 主管回复一个提问。
|
|
1215
|
+
*
|
|
1216
|
+
* 与收件箱共用同一套 session-bound 门槛:调用者必须已由 Core 证明为**真实、当前**的
|
|
1217
|
+
* 同领地 ACTIVE 主管 session;问题必须正好写给该 binding,且该 binding 仍是领地当前
|
|
1218
|
+
* 主理(退任、换 session、领地改绑一律拒绝,不改投继任者)。失败零写入。
|
|
1219
|
+
*
|
|
1220
|
+
* 这里的每一条都只是**早拒**:`recordDeliveryReply` 会在同一个写锁事务内用真实
|
|
1221
|
+
* caller session、当前 binding 状态、当前领地主理与当前 ACCEPT 证据重新核对一遍,
|
|
1222
|
+
* 因此锁前状态在等待写锁期间发生变化也不会写出失效回复。
|
|
1223
|
+
*
|
|
1224
|
+
* A question is answered at most once: the same reply text retried is idempotent, a
|
|
1225
|
+
* different reply for the same question is rejected. A reply writes only this one
|
|
1226
|
+
* conversation fact — never an acknowledgement, Task/Claim, supervisor ACCEPT, Owner
|
|
1227
|
+
* acceptance or release fact, and never a dispatch/follow-up/wakeup.
|
|
1228
|
+
*/
|
|
1229
|
+
export function replyToDeliveryQuestion(store, ctx, input) {
|
|
1230
|
+
if (ctx.auth.mode !== 'session-bound') {
|
|
1231
|
+
return { ok: false, code: 'DELIVERY_QUESTION_SESSION_REQUIRED',
|
|
1232
|
+
message: '错误:交付问答只能由真实 session-bound 主管读取与回复;declarative 低信任模式不放行。' };
|
|
1233
|
+
}
|
|
1234
|
+
const caller = ctx.principal?.sessionId ?? null;
|
|
1235
|
+
if (!caller) {
|
|
1236
|
+
return { ok: false, code: 'UNAUTHORIZED_PRINCIPAL',
|
|
1237
|
+
message: '错误:无法从 DSH Runtime 证明当前调用方的真实 session,拒绝回复。' };
|
|
1238
|
+
}
|
|
1239
|
+
const bindings = store.getBindingsByRole(ctx.kingdomId, 'SUPERVISOR').filter(candidate => candidate.session_id === caller);
|
|
1240
|
+
if (!bindings.length) {
|
|
1241
|
+
return { ok: false, code: 'UNAUTHORIZED_PRINCIPAL',
|
|
1242
|
+
message: '错误:当前调用者没有与该 session 匹配的 ACTIVE SUPERVISOR binding,拒绝回复。' };
|
|
1243
|
+
}
|
|
1244
|
+
const questionId = typeof input.questionId === 'string' ? input.questionId.trim() : '';
|
|
1245
|
+
if (!/^question:[0-9a-f]{32}$/u.test(questionId)) {
|
|
1246
|
+
return { ok: false, code: 'QUESTION_NOT_FOUND', message: '错误:questionId 形状不合法,未写入任何回复。' };
|
|
1247
|
+
}
|
|
1248
|
+
const replyText = typeof input.replyText === 'string' ? input.replyText : '';
|
|
1249
|
+
if (!boundedQuestionText(replyText, DELIVERY_REPLY_TEXT_LIMIT)) {
|
|
1250
|
+
return { ok: false, code: 'INVALID_INPUT', message: '错误:回复内容必须是非空、长度受限的文本。' };
|
|
1251
|
+
}
|
|
1252
|
+
const found = readQuestionById(store, ctx.kingdomId, questionId);
|
|
1253
|
+
if (!found)
|
|
1254
|
+
return { ok: false, code: 'QUESTION_NOT_FOUND', message: '错误:该问题不存在或不属于当前王国,未写入任何回复。' };
|
|
1255
|
+
const task = store.getTask(found.taskId);
|
|
1256
|
+
if (!task)
|
|
1257
|
+
return { ok: false, code: 'QUESTION_NOT_FOUND', message: '错误:该问题对应的任务不存在。' };
|
|
1258
|
+
const territory = store.getTerritoryById(task.territory_id);
|
|
1259
|
+
if (!territory || territory.kingdom_id !== ctx.kingdomId || territory.status === 'DELETED') {
|
|
1260
|
+
return { ok: false, code: 'TASK_NOT_IN_KINGDOM', message: '错误:该问题对应的任务不属于当前王国。' };
|
|
1261
|
+
}
|
|
1262
|
+
if (!bindings.some(binding => binding.binding_id === territory.supervisor_binding_id)) {
|
|
1263
|
+
return { ok: false, code: 'TASK_OUT_OF_SCOPE',
|
|
1264
|
+
message: `错误:任务「${task.title}」属于领地「${territory.name}」,超出当前主管的治理范围。` };
|
|
1265
|
+
}
|
|
1266
|
+
// 只由接受该交付的主管回复:身份还必须是它本人,而不是任何一位在任主管。
|
|
1267
|
+
const responder = bindings.find(binding => binding.binding_id === found.reviewerBindingId);
|
|
1268
|
+
if (!responder) {
|
|
1269
|
+
const access = classifyDeliveryReplyAccess(store, ctx.kingdomId, found.reviewerBindingId, territory.supervisor_binding_id, caller);
|
|
1270
|
+
return { ok: false, code: 'QUESTION_NOT_ADDRESSED_TO_CALLER',
|
|
1271
|
+
message: `错误:该问题只由接受该交付的主管负责回复,其他主管不能代答。${access.detail}` };
|
|
1272
|
+
}
|
|
1273
|
+
try {
|
|
1274
|
+
const result = recordDeliveryReply(store, {
|
|
1275
|
+
kingdomId: ctx.kingdomId,
|
|
1276
|
+
questionId,
|
|
1277
|
+
responderBindingId: responder.binding_id,
|
|
1278
|
+
callerSessionId: caller,
|
|
1279
|
+
replyText,
|
|
1280
|
+
repliedAt: new Date().toISOString(),
|
|
1281
|
+
attribution: { source_channel: 'SUPERVISOR_TOOL', authorization_source: 'SESSION_BOUND' },
|
|
1282
|
+
});
|
|
1283
|
+
return { ok: true, text: result.created
|
|
1284
|
+
? `已记录回复(问题 ${questionId})。这是该问题的唯一当前回复;它不写知悉、Task/Claim、主管 ACCEPT、Owner acceptance 或发布事实,也不自动派发或唤醒任何后续动作。\n${result.reply.replyText}`
|
|
1285
|
+
: `该问题已有同一内容的回复记录,未新增第二条事实。\n${result.reply.replyText}` };
|
|
1286
|
+
}
|
|
1287
|
+
catch (error) {
|
|
1288
|
+
if (error instanceof DeliveryQuestionError)
|
|
1289
|
+
return { ok: false, code: error.code, message: `错误:${error.message}` };
|
|
1290
|
+
throw error;
|
|
1291
|
+
}
|
|
1292
|
+
}
|
|
1293
|
+
/**
|
|
1294
|
+
* 按 session 读取**全部**可证明绑定的收件箱。
|
|
1295
|
+
*
|
|
1296
|
+
* 一个 session 可以合法持有多个 ACTIVE SUPERVISOR 绑定;只取第一个会让后续绑定的问题
|
|
1297
|
+
* 静默消失。这里逐 binding 精确读取再合并:每条问题只有一个接收主管,因此不会重复。
|
|
1298
|
+
* 领地界仍是这些 binding 当前主理领地的并集,授权仍由调用方用真实 session 判定。
|
|
1299
|
+
* 找不到任何 binding 时返回空列表,由调用方 fail-closed。
|
|
1300
|
+
*/
|
|
1301
|
+
export function readDeliveryQuestionInboxForSession(store, kingdomId, sessionId) {
|
|
1302
|
+
const bindings = sessionId ? store.getBindingsByRole(kingdomId, 'SUPERVISOR').filter(binding => binding.session_id === sessionId) : [];
|
|
1303
|
+
const bindingIds = new Set(bindings.map(binding => binding.binding_id));
|
|
1304
|
+
const territoryIds = store.listTerritories(kingdomId)
|
|
1305
|
+
.filter(territory => !!territory.supervisor_binding_id && bindingIds.has(territory.supervisor_binding_id))
|
|
1306
|
+
.map(territory => territory.territory_id);
|
|
1307
|
+
return {
|
|
1308
|
+
bindings,
|
|
1309
|
+
entries: bindings.flatMap(binding => readDeliveryQuestionInbox(store, kingdomId, { reviewerBindingId: binding.binding_id, territoryIds })),
|
|
1310
|
+
};
|
|
1311
|
+
}
|
|
1312
|
+
/**
|
|
1313
|
+
* 按权威账本精确读取**只属于该主管 binding**的提问。
|
|
1314
|
+
*
|
|
1315
|
+
* 刻意不是「最近事件投影」:逐条从 events 表按 delivery 精确查询,因此不会被
|
|
1316
|
+
* 有界的最近事件窗口截断,也不会看到其他主管或其他领地的问题。
|
|
1317
|
+
*/
|
|
1318
|
+
export function readDeliveryQuestionInbox(store, kingdomId, scope) {
|
|
1319
|
+
const entries = [];
|
|
1320
|
+
const allowed = scope.territoryIds ? new Set(scope.territoryIds) : null;
|
|
1321
|
+
const rows = store.db.prepare('SELECT * FROM events WHERE kingdom_id = ? AND event_type = ? ORDER BY seq ASC')
|
|
1322
|
+
.all(kingdomId, DELIVERY_QUESTION_EVENT_TYPE);
|
|
1323
|
+
const repliesByDelivery = new Map();
|
|
1324
|
+
const deliveries = new Set();
|
|
1325
|
+
const parsed = rows.map(readQuestionRow).filter((question) => question !== null)
|
|
1326
|
+
.filter(question => question.reviewerBindingId === scope.reviewerBindingId);
|
|
1327
|
+
for (const question of parsed) {
|
|
1328
|
+
if (deliveries.has(question.deliveryId))
|
|
1329
|
+
continue;
|
|
1330
|
+
deliveries.add(question.deliveryId);
|
|
1331
|
+
const thread = readDeliveryQuestionThread(store, kingdomId, question.deliveryId);
|
|
1332
|
+
repliesByDelivery.set(question.deliveryId, new Map((thread?.questions ?? []).filter(item => item.reply).map(item => [item.questionId, item.reply])));
|
|
1333
|
+
}
|
|
1334
|
+
// 当前内容版本逐 Task 重算一次:收件箱是主管决定「现在要不要回复」的地方,这里
|
|
1335
|
+
// 绝不能把旧版本未答问题算成当前待答复的提问。
|
|
1336
|
+
const currentVersions = currentItemContentHashes(store, kingdomId, [...new Set(parsed.map(question => question.taskId))]);
|
|
1337
|
+
for (const question of parsed) {
|
|
1338
|
+
const task = store.getTask(question.taskId);
|
|
1339
|
+
if (!task)
|
|
1340
|
+
continue;
|
|
1341
|
+
const territory = store.getTerritoryById(task.territory_id);
|
|
1342
|
+
if (!territory || territory.kingdom_id !== kingdomId || territory.status === 'DELETED')
|
|
1343
|
+
continue;
|
|
1344
|
+
if (allowed && !allowed.has(territory.territory_id))
|
|
1345
|
+
continue;
|
|
1346
|
+
const reply = repliesByDelivery.get(question.deliveryId)?.get(question.questionId) ?? null;
|
|
1347
|
+
const currentSupervisorBindingId = territory.supervisor_binding_id ?? null;
|
|
1348
|
+
const currentItemContentHash = currentVersions.get(question.taskId)?.get(question.itemId) ?? null;
|
|
1349
|
+
entries.push({ question: { ...question, reply, currentSupervisorBindingId, currentItemContentHash,
|
|
1350
|
+
itemVersion: classifyDeliveryItemVersion(currentItemContentHash, question.contentHash),
|
|
1351
|
+
replyState: classifyDeliveryReplyAccess(store, kingdomId, question.reviewerBindingId, currentSupervisorBindingId).state },
|
|
1352
|
+
taskTitle: bounded(task.title), territoryName: bounded(territory.name) });
|
|
1353
|
+
}
|
|
1354
|
+
return entries;
|
|
1355
|
+
}
|
|
1356
|
+
//# sourceMappingURL=delivery-ack.js.map
|