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
package/lib/core/owner-window.js
CHANGED
|
@@ -1,14 +1,16 @@
|
|
|
1
1
|
import { createHash, randomUUID } from 'node:crypto';
|
|
2
2
|
import { realpathSync, statSync } from 'node:fs';
|
|
3
3
|
import { isAbsolute, relative, sep } from 'node:path';
|
|
4
|
-
import { bindRole, rebindSession, setExecutionProfile } from './binding.js';
|
|
4
|
+
import { bindRole, bindSupervisorForTerritory, rebindSession, setExecutionProfile } from './binding.js';
|
|
5
5
|
import { createTerritory, setTerritorySupervisor, updateTerritory } from './territory.js';
|
|
6
6
|
import { setCapabilityCeiling } from '../capability/admin.js';
|
|
7
7
|
import { isOwnerControlCapability, issueOwnerOperationCapability, revokeOwnerOperationCapability, ownerEventPayload, ownerInputHash, } from './owner-control.js';
|
|
8
8
|
import { initializeKingdomFacts } from './kingdom.js';
|
|
9
9
|
import { normalizeBudgetPolicyParameters, readBudgetPolicy, setBudgetPolicy } from './budget.js';
|
|
10
10
|
import { adoptCollaborationPlan, listCollaborationPlans, readCollaborationPlan, normalizePlanAdoptionParameters } from './collaboration.js';
|
|
11
|
-
|
|
11
|
+
import { DELIVERY_ACK_EVENT_TYPE, CHANGE_EVIDENCE_LABEL, DELIVERY_QUESTION_NOTE, DELIVERY_QUESTION_EVENT_TYPE, DELIVERY_QUESTION_TEXT_LIMIT, DELIVERY_REPLY_STATES, boundedQuestionText, classifyAcceptedDelivery, deliveryAcknowledgementView, deliveryChangeItemId, deliveryIdFor, deliveryQuestionEventId, deliveryQuestionThreadForItem, deliveryReviewerBindingId, deriveDeliveryItems, listDeliveryQuestionHistory, readDeliveryAcknowledgements, readDeliveryItemVersion, readDeliveryQuestionThread, readLatestReviewEvent, recordDeliveryAcknowledgement, recordDeliveryQuestion, redactDeliveryText, } from './delivery-ack.js';
|
|
12
|
+
import { readAcceptedChangeEvidence, readChangeEntry, resolveChangeEvidenceForDelivery, } from './delivery-change.js';
|
|
13
|
+
export const OWNER_MUTATION_ACTIONS = ['init', 'territory.create', 'territory.update', 'territory.supervisor', 'role.bind', 'role.session', 'ceiling', 'execution-profile', 'budget.policy', 'plan.adopt', 'delivery.item.ack', 'delivery.item.question'];
|
|
12
14
|
export class OwnerOperationError extends Error {
|
|
13
15
|
code;
|
|
14
16
|
constructor(code, message) {
|
|
@@ -19,6 +21,37 @@ export class OwnerOperationError extends Error {
|
|
|
19
21
|
}
|
|
20
22
|
const MANAGED_ROLES = ['CHANCELLOR', 'SUPERVISOR', 'WORKER'];
|
|
21
23
|
const clone = (value) => JSON.parse(JSON.stringify(value));
|
|
24
|
+
/** 条目提问元数据:只计数与时间,不含任何正文。 */
|
|
25
|
+
function ownerDeliveryItemQuestions(thread) {
|
|
26
|
+
const replied = thread.questions.filter(question => question.reply !== null);
|
|
27
|
+
const asked = thread.questions.map(question => question.askedAt).sort();
|
|
28
|
+
const answered = replied.map(question => question.reply.repliedAt).sort();
|
|
29
|
+
return {
|
|
30
|
+
threadId: thread.deliveryId + ':' + thread.itemId,
|
|
31
|
+
totalCount: thread.questions.length,
|
|
32
|
+
pendingCount: thread.pendingCount,
|
|
33
|
+
answeredCount: thread.answeredCount,
|
|
34
|
+
historyCount: thread.historyCount,
|
|
35
|
+
lastAskedAt: asked.length ? asked[asked.length - 1] : null,
|
|
36
|
+
lastRepliedAt: answered.length ? answered[answered.length - 1] : null,
|
|
37
|
+
latestReplyState: thread.questions.length ? thread.questions[thread.questions.length - 1].replyState : null,
|
|
38
|
+
};
|
|
39
|
+
}
|
|
40
|
+
/** 固定的可达性说明;界面与报告共用,避免两处措辞漂移。 */
|
|
41
|
+
function classifyReplyStateNote(state) {
|
|
42
|
+
// `DELIVERY_REPLY_STATES` 是唯一状态清单:新增状态而忘记补说明时这里立刻失败,
|
|
43
|
+
// 而不是静默回退成一句含糊的通用文案。
|
|
44
|
+
if (!DELIVERY_REPLY_STATES.includes(state))
|
|
45
|
+
fail('DELIVERY_QUESTION_STATE_UNKNOWN', '未知的回复可达性状态。');
|
|
46
|
+
const notes = {
|
|
47
|
+
REPLY_ACCESSIBLE: '该问题仍由接受交付的主管负责:它仍是领地当前主理并持有 ACTIVE session,可读取并回复。',
|
|
48
|
+
REVIEWER_BINDING_MISSING: '接受该交付时记录的主管绑定已不存在或不再是主管,本条不可达;不会改投继任主管。',
|
|
49
|
+
REVIEWER_BINDING_RETIRED: '接受该交付的主管已退任,本条不可达;不会改投继任主管。',
|
|
50
|
+
REVIEWER_SESSION_CHANGED: '该主管当前没有可用的 ACTIVE session,本条暂时不可达。',
|
|
51
|
+
SUPERVISOR_REBOUND: '该领地已改由其他主管主理,本条仍归原接受主管,不可达;不会自动改投继任者。',
|
|
52
|
+
};
|
|
53
|
+
return notes[state];
|
|
54
|
+
}
|
|
22
55
|
function fail(code, message) { throw new OwnerOperationError(code, message); }
|
|
23
56
|
function object(value, allowed, required = allowed) {
|
|
24
57
|
if (!value || typeof value !== 'object' || Array.isArray(value)
|
|
@@ -88,10 +121,13 @@ function normalizeInput(raw) {
|
|
|
88
121
|
}
|
|
89
122
|
case 'territory.supervisor': {
|
|
90
123
|
const p = object(outer.parameters, ['territory_id', 'supervisor_binding_id']);
|
|
91
|
-
|
|
124
|
+
// v3.2.0(Owner 2026-09-29 裁定):管理窗口必须能表达"解除现任主理"。
|
|
125
|
+
// 显式 JSON null = 解除;缺失(undefined)仍然是输入错误,不会被当成解除。
|
|
126
|
+
const supervisorBindingId = p.supervisor_binding_id === null ? null : string(p.supervisor_binding_id, '主管绑定 ID');
|
|
127
|
+
return { action: 'territory.supervisor', parameters: { territory_id: string(p.territory_id, '领地 ID'), supervisor_binding_id: supervisorBindingId } };
|
|
92
128
|
}
|
|
93
129
|
case 'role.bind': {
|
|
94
|
-
const p = object(outer.parameters, ['role_type', 'role_name', 'session_id'], ['role_type', 'role_name']);
|
|
130
|
+
const p = object(outer.parameters, ['role_type', 'role_name', 'session_id', 'territory_id'], ['role_type', 'role_name']);
|
|
95
131
|
if (!MANAGED_ROLES.includes(p.role_type))
|
|
96
132
|
fail('INVALID_ROLE', '仅支持宰相、主管和执行者。');
|
|
97
133
|
const session = p.session_id === undefined || p.session_id === null ? null : string(p.session_id, 'Session ID');
|
|
@@ -99,7 +135,15 @@ function normalizeInput(raw) {
|
|
|
99
135
|
fail('WORKER_SESSION_UNSUPPORTED', '执行者 Session 由正常 governed 执行路径建立。');
|
|
100
136
|
if (p.role_type !== 'WORKER' && session === null)
|
|
101
137
|
fail('TARGET_SESSION_REQUIRED', '主管和宰相必须选择已有真实 Session。');
|
|
102
|
-
|
|
138
|
+
// v3.2.0(Owner 2026-09-28 口径):领地与主管完全绑定——任命主管必须同时选领地。
|
|
139
|
+
// 非主管角色不隶属领地,携带 territory_id 一律拒绝(不静默忽略)。
|
|
140
|
+
const territoryId = p.territory_id === undefined || p.territory_id === null ? null : string(p.territory_id, '领地 ID');
|
|
141
|
+
if (p.role_type === 'SUPERVISOR' && territoryId === null)
|
|
142
|
+
fail('TERRITORY_REQUIRED', '任命主管必须同时选择它要主理的领地;主管不能没有领地。');
|
|
143
|
+
if (p.role_type !== 'SUPERVISOR' && territoryId !== null)
|
|
144
|
+
fail('TERRITORY_UNSUPPORTED', '只有主管在任命时指定领地;其它角色不隶属领地。');
|
|
145
|
+
return { action: 'role.bind', parameters: { role_type: p.role_type, role_name: string(p.role_name, '角色名称', 120), session_id: session,
|
|
146
|
+
...(territoryId === null ? {} : { territory_id: territoryId }) } };
|
|
103
147
|
}
|
|
104
148
|
case 'role.session': {
|
|
105
149
|
const p = object(outer.parameters, ['binding_id', 'session_id']);
|
|
@@ -129,6 +173,39 @@ function normalizeInput(raw) {
|
|
|
129
173
|
}
|
|
130
174
|
return { action: 'execution-profile', parameters: { binding_id: string(p.binding_id, '绑定 ID'), profile } };
|
|
131
175
|
}
|
|
176
|
+
case 'delivery.item.ack': {
|
|
177
|
+
const p = object(outer.parameters, ['task_id', 'delivery_id', 'item_id', 'content_hash', 'attempt_no', 'result_id']);
|
|
178
|
+
const attemptNo = p.attempt_no;
|
|
179
|
+
if (!Number.isSafeInteger(attemptNo) || attemptNo < 1 || attemptNo > 1_000_000) {
|
|
180
|
+
fail('INVALID_INPUT', '交付尝试次数必须是正整数。');
|
|
181
|
+
}
|
|
182
|
+
const contentHash = string(p.content_hash, '条目内容版本', 128);
|
|
183
|
+
if (!/^[0-9a-f]{64}$/u.test(contentHash))
|
|
184
|
+
fail('INVALID_INPUT', '条目内容版本必须是小写十六进制摘要。');
|
|
185
|
+
return { action: 'delivery.item.ack', parameters: {
|
|
186
|
+
task_id: string(p.task_id, '任务 ID'), delivery_id: string(p.delivery_id, '交付 ID'),
|
|
187
|
+
item_id: string(p.item_id, '条目 ID'), content_hash: contentHash,
|
|
188
|
+
attempt_no: attemptNo, result_id: string(p.result_id, '结果 ID')
|
|
189
|
+
} };
|
|
190
|
+
}
|
|
191
|
+
case 'delivery.item.question': {
|
|
192
|
+
const p = object(outer.parameters, ['task_id', 'delivery_id', 'item_id', 'content_hash', 'attempt_no', 'result_id', 'question_text']);
|
|
193
|
+
const attemptNo = p.attempt_no;
|
|
194
|
+
if (!Number.isSafeInteger(attemptNo) || attemptNo < 1 || attemptNo > 1_000_000) {
|
|
195
|
+
fail('INVALID_INPUT', '交付尝试次数必须是正整数。');
|
|
196
|
+
}
|
|
197
|
+
const contentHash = string(p.content_hash, '条目内容版本', 128);
|
|
198
|
+
if (!/^[0-9a-f]{64}$/u.test(contentHash))
|
|
199
|
+
fail('INVALID_INPUT', '条目内容版本必须是小写十六进制摘要。');
|
|
200
|
+
// 与知悉同款的「非空、长度受限」文本校验;正文随后在 Core 里统一脱敏并限长,
|
|
201
|
+
// 因此事实里永远不保存未脱敏正文。
|
|
202
|
+
const questionText = string(p.question_text, '问题内容', DELIVERY_QUESTION_TEXT_LIMIT);
|
|
203
|
+
return { action: 'delivery.item.question', parameters: {
|
|
204
|
+
task_id: string(p.task_id, '任务 ID'), delivery_id: string(p.delivery_id, '交付 ID'),
|
|
205
|
+
item_id: string(p.item_id, '条目 ID'), content_hash: contentHash,
|
|
206
|
+
attempt_no: attemptNo, result_id: string(p.result_id, '结果 ID'), question_text: questionText
|
|
207
|
+
} };
|
|
208
|
+
}
|
|
132
209
|
default: return fail('INVALID_ACTION', '不支持该管理动作。');
|
|
133
210
|
}
|
|
134
211
|
}
|
|
@@ -222,7 +299,11 @@ export class OwnerDecisionController {
|
|
|
222
299
|
const kingdomId = record.view.kingdomId;
|
|
223
300
|
const bindings = kingdomId ? this.store.listBindings(kingdomId).filter(b => b.status === 'ACTIVE'
|
|
224
301
|
&& s.bindingIds.includes(b.binding_id) && s.roleTypes.includes(b.role_type)) : [];
|
|
225
|
-
|
|
302
|
+
// 目录与提交必须逐 action 同判据:只有**显式在授权范围内**的领地才列出。
|
|
303
|
+
// 不用 kingdomWide 放宽目录——否则会出现"清单里选得到、提交必被拒"
|
|
304
|
+
// (territory.update/delete/supervisor 的共享 territory() 只认 scope.territoryIds)。
|
|
305
|
+
return { territories: kingdomId ? this.store.listTerritories(kingdomId)
|
|
306
|
+
.filter(t => t.status !== 'DELETED' && s.territoryIds.includes(t.territory_id))
|
|
226
307
|
.map(t => ({ id: t.territory_id, name: t.name })) : [],
|
|
227
308
|
bindings: bindings.map(b => ({ id: b.binding_id, roleType: b.role_type, roleName: b.role_name })),
|
|
228
309
|
runtimeSessions: [...new Map(sessions.filter(item => s.targetSessionIds.includes(item.id))
|
|
@@ -230,27 +311,294 @@ export class OwnerDecisionController {
|
|
|
230
311
|
workspaceRoots: [...s.workspaceRoots],
|
|
231
312
|
plans: kingdomId && record.view.actions.includes('plan.adopt') ? listCollaborationPlans(this.store, kingdomId).filter(plan => {
|
|
232
313
|
try {
|
|
233
|
-
this.context(record, { action: 'plan.adopt', parameters: { plan_id: plan.planId, version: plan.version, digest: plan.digest } });
|
|
314
|
+
this.context(record, { action: 'plan.adopt', parameters: { plan_id: plan.planId, version: plan.version, digest: plan.digest } }, randomUUID());
|
|
234
315
|
return true;
|
|
235
316
|
}
|
|
236
317
|
catch {
|
|
237
318
|
return false;
|
|
238
319
|
}
|
|
239
|
-
}) : []
|
|
320
|
+
}) : [],
|
|
321
|
+
deliveryItems: kingdomId && (record.view.actions.includes('delivery.item.ack') || record.view.actions.includes('delivery.item.question'))
|
|
322
|
+
? this.deliveryItems(record) : [],
|
|
323
|
+
deliveryQuestionHistory: kingdomId && record.view.actions.includes('delivery.item.question')
|
|
324
|
+
? this.deliveryQuestionHistory(record) : [] };
|
|
325
|
+
}
|
|
326
|
+
/**
|
|
327
|
+
* 本次授权范围内的**已有提问记录**条目(只读历史入口)。
|
|
328
|
+
*
|
|
329
|
+
* 只读且不依赖有界交付目录:它按权威提问事实列出精确 task/item,再逐条用
|
|
330
|
+
* `deliveryScopeAllows` 过滤,因此不含跨领地条目;不在当前目录、当前无法重验的
|
|
331
|
+
* 旧问答也能在这里被发现。它不返回任何正文,也**不**进入新提问 prepare 的来源目录。
|
|
332
|
+
*/
|
|
333
|
+
deliveryQuestionHistory(record) {
|
|
334
|
+
const kingdomId = record.view.kingdomId;
|
|
335
|
+
if (!kingdomId)
|
|
336
|
+
return [];
|
|
337
|
+
const choices = [];
|
|
338
|
+
for (const target of listDeliveryQuestionHistory(this.store, kingdomId)) {
|
|
339
|
+
const task = this.store.getTask(target.taskId);
|
|
340
|
+
if (!task)
|
|
341
|
+
continue;
|
|
342
|
+
const territory = this.store.getTerritoryById(task.territory_id);
|
|
343
|
+
if (!territory || territory.kingdom_id !== kingdomId || territory.status === 'DELETED')
|
|
344
|
+
continue;
|
|
345
|
+
if (!this.deliveryScopeAllows(record, territory))
|
|
346
|
+
continue;
|
|
347
|
+
choices.push({ taskId: target.taskId, taskTitle: redactDeliveryText(task.title), itemId: target.itemId,
|
|
348
|
+
itemLabel: target.itemLabel, lastAskedAt: target.lastAskedAt, questionCount: target.questionCount,
|
|
349
|
+
pendingCount: target.pendingCount, answeredCount: target.answeredCount, historyCount: target.historyCount,
|
|
350
|
+
unverifiableCount: target.unverifiableCount, latestItemVersion: target.latestItemVersion,
|
|
351
|
+
currentContentHash: target.currentContentHash });
|
|
352
|
+
}
|
|
353
|
+
return choices;
|
|
354
|
+
}
|
|
355
|
+
/**
|
|
356
|
+
* 交付知悉的授权范围判据。
|
|
357
|
+
*
|
|
358
|
+
* catalog 与 prepare/commit 必须共用同一判据:否则会出现「清单可见、
|
|
359
|
+
* 提交被拒」或「清单不可见、提交却可行」。`kingdomWide` 是 Owner 的最高
|
|
360
|
+
* 授权级别;其余情况要求任务所在领地在 `territoryIds` 内,或该领地由
|
|
361
|
+
* 授权 `bindingIds` 中的主管主理且本次授权声明了 SUPERVISOR 角色。
|
|
362
|
+
*/
|
|
363
|
+
deliveryScopeAllows(record, territory) {
|
|
364
|
+
const scope = record.view.scope;
|
|
365
|
+
if (scope.kingdomWide)
|
|
366
|
+
return true;
|
|
367
|
+
if (!scope.territoryIds.length && !scope.bindingIds.length && !scope.roleTypes.length)
|
|
368
|
+
return false;
|
|
369
|
+
if (scope.territoryIds.includes(territory.territory_id))
|
|
370
|
+
return true;
|
|
371
|
+
return scope.roleTypes.includes('SUPERVISOR')
|
|
372
|
+
&& !!territory.supervisor_binding_id
|
|
373
|
+
&& scope.bindingIds.includes(territory.supervisor_binding_id);
|
|
374
|
+
}
|
|
375
|
+
/**
|
|
376
|
+
* 本次授权范围内的已确认交付条目。只有同一 Task/attempt 主管 ACCEPT 的交付才出现,
|
|
377
|
+
* Worker Claim 永远不会进入该清单。
|
|
378
|
+
*/
|
|
379
|
+
deliveryItems(record) {
|
|
380
|
+
const kingdomId = record.view.kingdomId;
|
|
381
|
+
if (!kingdomId)
|
|
382
|
+
return [];
|
|
383
|
+
const ownerId = record.view.ownerId ?? '';
|
|
384
|
+
const items = [];
|
|
385
|
+
/**
|
|
386
|
+
* 与工作台「最近交付」对齐:按交付最近更新/被接受的时间排序。
|
|
387
|
+
*
|
|
388
|
+
* 主管 ACCEPT 会经 `transitionTask` 更新 `tasks.updated_at`,因此「旧建但最近
|
|
389
|
+
* 接受的交付」会排到工作台顶部;若这里仍按 `created_at` 截断,那条交付就会
|
|
390
|
+
* 在工作台可见、却在 Owner 目录之外,既无法知悉也无法看差异。只改排序键,
|
|
391
|
+
* 不新增第二套任务索引,也不改变条目上限语义。
|
|
392
|
+
*/
|
|
393
|
+
const tasks = [...this.store.listTasks(kingdomId)].sort((a, b) => b.updated_at.localeCompare(a.updated_at) || b.task_id.localeCompare(a.task_id));
|
|
394
|
+
for (const task of tasks) {
|
|
395
|
+
if (items.length >= 200)
|
|
396
|
+
break;
|
|
397
|
+
if (task.status !== 'DONE')
|
|
398
|
+
continue;
|
|
399
|
+
const territory = this.store.getTerritoryById(task.territory_id);
|
|
400
|
+
if (!territory || territory.kingdom_id !== kingdomId || territory.status === 'DELETED')
|
|
401
|
+
continue;
|
|
402
|
+
if (!this.deliveryScopeAllows(record, territory))
|
|
403
|
+
continue;
|
|
404
|
+
const claim = this.store.latestWorkerResult(task.task_id);
|
|
405
|
+
if (!claim)
|
|
406
|
+
continue;
|
|
407
|
+
const review = readLatestReviewEvent(this.store, kingdomId, task.task_id);
|
|
408
|
+
const classification = classifyAcceptedDelivery(this.store, claim, review);
|
|
409
|
+
if (!classification)
|
|
410
|
+
continue;
|
|
411
|
+
const deliveryId = deliveryIdFor(task.task_id);
|
|
412
|
+
const acknowledgements = readDeliveryAcknowledgements(this.store, kingdomId, deliveryId);
|
|
413
|
+
const questions = readDeliveryQuestionThread(this.store, kingdomId, deliveryId);
|
|
414
|
+
for (const item of deriveDeliveryItems(task.task_id, claim, this.changeEvidenceFor(kingdomId, task.task_id))) {
|
|
415
|
+
const view = deliveryAcknowledgementView(acknowledgements, item, ownerId);
|
|
416
|
+
const thread = deliveryQuestionThreadForItem(questions, item.itemId, item.contentHash);
|
|
417
|
+
items.push({
|
|
418
|
+
taskId: task.task_id,
|
|
419
|
+
taskTitle: redactDeliveryText(task.title),
|
|
420
|
+
deliveryId,
|
|
421
|
+
attemptNo: claim.attempt_no,
|
|
422
|
+
resultId: claim.result_id,
|
|
423
|
+
itemId: item.itemId,
|
|
424
|
+
contentHash: item.contentHash,
|
|
425
|
+
layer: item.content.layer,
|
|
426
|
+
label: item.content.label,
|
|
427
|
+
detail: item.content.detail,
|
|
428
|
+
changeKind: item.content.change.kind,
|
|
429
|
+
changeNote: item.content.change.note,
|
|
430
|
+
acknowledgementState: view.state,
|
|
431
|
+
acknowledgedAt: view.acknowledgedAt,
|
|
432
|
+
acceptanceEvidenceKind: classification.kind,
|
|
433
|
+
acceptanceEvidenceExact: classification.evidence.exactResultBound,
|
|
434
|
+
acceptanceEvidenceNote: classification.legacyNote,
|
|
435
|
+
questions: thread ? ownerDeliveryItemQuestions(thread) : null,
|
|
436
|
+
});
|
|
437
|
+
}
|
|
438
|
+
}
|
|
439
|
+
return items;
|
|
440
|
+
}
|
|
441
|
+
/**
|
|
442
|
+
* 只读返回一条已确认交付条目的完整问答线程。
|
|
443
|
+
*
|
|
444
|
+
* 有效性要求与 `readDeliveryChange` 同款:窗口 ACTIVE、本次授权包含
|
|
445
|
+
* `delivery.item.question`、任务在授权范围内、该 task 确实存在写给它的问题记录。
|
|
446
|
+
* 版本关系按该精确 task/item **独立重验**(不受有界展示目录截断影响):命中当前
|
|
447
|
+
* 派生版本即已验证当前版,命中的是旧版本即历史版;条目已无法从当前 ACCEPT/证据
|
|
448
|
+
* 重验(例如 CHANGE 证据丢失)时仍可按精确 itemId 读回,但整条标为无法重验,
|
|
449
|
+
* 不冒充当前版、也不可回复。任一不成立都失败,且不写任何事实、不自动知悉。
|
|
450
|
+
*/
|
|
451
|
+
readDeliveryQuestions(handle, input) {
|
|
452
|
+
const record = this.active(handle);
|
|
453
|
+
const kingdomId = record.view.kingdomId;
|
|
454
|
+
if (!kingdomId)
|
|
455
|
+
fail('DELIVERY_NOT_CONFIRMED', '当前窗口没有可读取交付的王国。');
|
|
456
|
+
if (!record.view.actions.includes('delivery.item.question')) {
|
|
457
|
+
fail('ACTION_NOT_AUTHORIZED', '本次管理窗口未授权交付条目提问动作,不能读取问答正文。');
|
|
458
|
+
}
|
|
459
|
+
const task = this.store.getTask(string(input.taskId, '任务 ID'));
|
|
460
|
+
if (!task)
|
|
461
|
+
fail('SCOPE_DENIED', '任务不存在或不属于当前王国。');
|
|
462
|
+
const territory = this.store.getTerritoryById(task.territory_id);
|
|
463
|
+
if (!territory || territory.kingdom_id !== kingdomId || territory.status === 'DELETED')
|
|
464
|
+
fail('SCOPE_DENIED', '任务不属于当前王国。');
|
|
465
|
+
if (!this.deliveryScopeAllows(record, territory))
|
|
466
|
+
fail('SCOPE_DENIED', '该交付不在本次授权范围内。');
|
|
467
|
+
const itemId = string(input.itemId, '条目 ID');
|
|
468
|
+
// 版本真值必须逐 Task/条目精确重验,**不能**取自有界展示目录:展示目录会截断
|
|
469
|
+
// 较旧条目,若用它判断,真实仍是当前版的问题会被误标为无法重验。
|
|
470
|
+
const currentItem = readDeliveryItemVersion(this.store, kingdomId, task.task_id, itemId);
|
|
471
|
+
const thread = deliveryQuestionThreadForItem(readDeliveryQuestionThread(this.store, kingdomId, deliveryIdFor(task.task_id)), itemId, currentItem ? currentItem.contentHash : null);
|
|
472
|
+
// 条目无法重验、也没有任何提问记录时仍是「未知条目」,不能被读成一条空线程。
|
|
473
|
+
if (!thread && !currentItem)
|
|
474
|
+
fail('DELIVERY_ITEM_UNKNOWN', '该条目已无法从当前交付重验,也没有可读取的问答记录。');
|
|
475
|
+
if (!thread)
|
|
476
|
+
fail('DELIVERY_QUESTION_UNKNOWN', '该条目当前版本还没有提问记录。');
|
|
477
|
+
const review = readLatestReviewEvent(this.store, kingdomId, task.task_id);
|
|
478
|
+
const reviewerBindingId = deliveryReviewerBindingId(review);
|
|
479
|
+
return {
|
|
480
|
+
deliveryId: thread.deliveryId,
|
|
481
|
+
taskId: task.task_id,
|
|
482
|
+
taskTitle: redactDeliveryText(task.title),
|
|
483
|
+
itemId: thread.itemId,
|
|
484
|
+
itemLabel: currentItem ? currentItem.label : thread.itemLabel,
|
|
485
|
+
contentHash: currentItem ? currentItem.contentHash : null,
|
|
486
|
+
questions: thread.questions.map(question => ({
|
|
487
|
+
questionId: question.questionId,
|
|
488
|
+
questionText: question.questionText,
|
|
489
|
+
askedAt: question.askedAt,
|
|
490
|
+
ownerId: question.ownerId,
|
|
491
|
+
reviewerBindingId: question.reviewerBindingId,
|
|
492
|
+
replyState: question.replyState,
|
|
493
|
+
replyStateNote: classifyReplyStateNote(question.replyState),
|
|
494
|
+
itemVersion: question.itemVersion,
|
|
495
|
+
currentItemContentHash: question.currentItemContentHash,
|
|
496
|
+
reply: question.reply ? { replyText: question.reply.replyText, repliedAt: question.reply.repliedAt,
|
|
497
|
+
responderBindingId: question.reply.responderBindingId } : null,
|
|
498
|
+
})),
|
|
499
|
+
answeredCount: thread.answeredCount,
|
|
500
|
+
pendingCount: thread.pendingCount,
|
|
501
|
+
historyCount: thread.historyCount,
|
|
502
|
+
unverifiableCount: thread.unverifiableCount,
|
|
503
|
+
note: reviewerBindingId ? DELIVERY_QUESTION_NOTE
|
|
504
|
+
: `${DELIVERY_QUESTION_NOTE} 另:该交付的接受事件没有记录主管绑定,无法确定接收者。`,
|
|
505
|
+
};
|
|
506
|
+
}
|
|
507
|
+
/**
|
|
508
|
+
* 已由主管在 ACCEPT 中显式选择、且本地 hash 重验通过的改动证据。
|
|
509
|
+
* 缺失、漂移或未确认时返回 null:调用方显示「不可定位」,不退回执行者自述。
|
|
510
|
+
*/
|
|
511
|
+
changeEvidenceFor(kingdomId, taskId) {
|
|
512
|
+
const review = readLatestReviewEvent(this.store, kingdomId, taskId);
|
|
513
|
+
const ref = readAcceptedChangeEvidence(review);
|
|
514
|
+
return ref ? resolveChangeEvidenceForDelivery(undefined, ref) : null;
|
|
515
|
+
}
|
|
516
|
+
acceptedChangeRef(kingdomId, taskId) {
|
|
517
|
+
return readAcceptedChangeEvidence(readLatestReviewEvent(this.store, kingdomId, taskId));
|
|
518
|
+
}
|
|
519
|
+
/**
|
|
520
|
+
* 只读返回一条已确认改动条目的有界详情。
|
|
521
|
+
*
|
|
522
|
+
* 有效性要求:窗口 ACTIVE、**本次授权动作包含 `delivery.item.ack`**、任务在本次
|
|
523
|
+
* 授权范围内、该 task 确实存在一条主管 ACCEPT 绑定的改动证据、evidence id 与
|
|
524
|
+
* entry id 精确匹配、该条目仍需出现在当前有效交付目录中,且本地 hash 重验通过。
|
|
525
|
+
* 任一不成立都失败,且不写任何事实、不产生知悉。
|
|
526
|
+
*
|
|
527
|
+
* 只检查 ACTIVE + Territory scope 是不够的:同一领地的另一个管理窗口若没有该
|
|
528
|
+
* 动作授权,就不能凭 id 读取差异正文。这里与 `catalog` 使用同一动作判据,
|
|
529
|
+
* 不默认放行(也不新增只读动作)。
|
|
530
|
+
*/
|
|
531
|
+
readDeliveryChange(handle, input) {
|
|
532
|
+
const record = this.active(handle);
|
|
533
|
+
const kingdomId = record.view.kingdomId;
|
|
534
|
+
if (!kingdomId)
|
|
535
|
+
fail('DELIVERY_NOT_CONFIRMED', '当前窗口没有可读取交付的王国。');
|
|
536
|
+
if (!record.view.actions.includes('delivery.item.ack')) {
|
|
537
|
+
fail('ACTION_NOT_AUTHORIZED', '本次管理窗口未授权交付条目知悉动作,不能读取改动证据正文。');
|
|
538
|
+
}
|
|
539
|
+
const task = this.store.getTask(string(input.taskId, '任务 ID'));
|
|
540
|
+
if (!task)
|
|
541
|
+
fail('SCOPE_DENIED', '任务不存在或不属于当前王国。');
|
|
542
|
+
const territory = this.store.getTerritoryById(task.territory_id);
|
|
543
|
+
if (!territory || territory.kingdom_id !== kingdomId || territory.status === 'DELETED')
|
|
544
|
+
fail('SCOPE_DENIED', '任务不属于当前王国。');
|
|
545
|
+
if (!this.deliveryScopeAllows(record, territory))
|
|
546
|
+
fail('SCOPE_DENIED', '该交付不在本次授权范围内。');
|
|
547
|
+
const ref = this.acceptedChangeRef(kingdomId, task.task_id);
|
|
548
|
+
const evidenceId = string(input.evidenceId, '证据 ID', 128);
|
|
549
|
+
const entryId = string(input.entryId, '改动条目 ID', 128);
|
|
550
|
+
if (!ref || ref.evidenceId !== evidenceId || !ref.entryIds.includes(entryId)) {
|
|
551
|
+
fail('CHANGE_EVIDENCE_UNVERIFIED', '该改动引用不是本 Task 已由主管确认的证据,拒绝读取。');
|
|
552
|
+
}
|
|
553
|
+
// 必须仍属于当前有效交付目录:历史 ACCEPT 引用、已漂移证据或已不存在的条目
|
|
554
|
+
// 都不能单独开放正文。
|
|
555
|
+
const currentItem = this.deliveryItems(record).find(item => item.taskId === task.task_id
|
|
556
|
+
&& item.changeKind === 'REPO_RELATIVE_VERIFIED'
|
|
557
|
+
&& deliveryChangeItemId(item.deliveryId, entryId) === item.itemId);
|
|
558
|
+
if (!currentItem) {
|
|
559
|
+
fail('CHANGE_EVIDENCE_UNVERIFIED', '该改动条目不在当前有效交付目录内,拒绝读取。');
|
|
560
|
+
}
|
|
561
|
+
const opened = readChangeEntry(undefined, ref, entryId);
|
|
562
|
+
if (!opened.ok)
|
|
563
|
+
fail('CHANGE_EVIDENCE_UNVERIFIED', opened.reason);
|
|
564
|
+
const entry = opened.entry;
|
|
565
|
+
return {
|
|
566
|
+
kind: 'SUPERVISOR_CONFIRMED_BOUNDED_WINDOW',
|
|
567
|
+
label: CHANGE_EVIDENCE_LABEL,
|
|
568
|
+
evidenceId: ref.evidenceId,
|
|
569
|
+
entryId: entry.entryId,
|
|
570
|
+
taskId: task.task_id,
|
|
571
|
+
taskTitle: redactDeliveryText(task.title),
|
|
572
|
+
attemptNo: ref.attemptNo,
|
|
573
|
+
status: entry.status,
|
|
574
|
+
repoPath: entry.repoPath,
|
|
575
|
+
revision: `evidence:${ref.evidenceId}`,
|
|
576
|
+
repoVcs: opened.manifest.repo.vcs,
|
|
577
|
+
repoHead: opened.manifest.repo.head,
|
|
578
|
+
coverageComplete: opened.manifest.coverage.complete,
|
|
579
|
+
coverageReasons: opened.manifest.coverage.reasons.slice(0, 16),
|
|
580
|
+
labels: entry.labels.slice(0, 16),
|
|
581
|
+
note: entry.note,
|
|
582
|
+
hunks: entry.hunks.slice(0, 200).map(hunk => ({ kind: hunk.kind, beforeLine: hunk.beforeLine, afterLine: hunk.afterLine, text: hunk.text })),
|
|
583
|
+
};
|
|
240
584
|
}
|
|
241
585
|
async prepare(handle, raw, options = {}) {
|
|
242
586
|
const record = this.active(handle);
|
|
243
587
|
if (record.preparations.size >= 128)
|
|
244
588
|
fail('PREPARATION_LIMIT', '当前窗口准备次数已达上限,请重新激活。');
|
|
245
589
|
const input = normalizeInput(raw);
|
|
246
|
-
|
|
590
|
+
// 操作编号必须在预览与提交之间保持同一个:提问事实的身份以一次 Owner operation
|
|
591
|
+
// 为界(同一编号重放幂等、不同编号即使文本相同也是两条独立事实),因此它先于
|
|
592
|
+
// 预览计算生成,并同时用于指纹上下文与最终预览。
|
|
593
|
+
const operationId = randomUUID();
|
|
594
|
+
const context = this.context(record, input, operationId);
|
|
247
595
|
await this.validate(record, input, options.signal);
|
|
248
596
|
this.active(handle);
|
|
249
|
-
const checked = this.context(record, input);
|
|
597
|
+
const checked = this.context(record, input, operationId);
|
|
250
598
|
if (checked.fingerprint !== context.fingerprint)
|
|
251
599
|
fail('PREVIEW_STALE', '校验期间相关事实已改变,请重新预览。');
|
|
252
600
|
const createdAt = new Date(this.now()).toISOString();
|
|
253
|
-
const preview = { prepareId: randomUUID(), operationId
|
|
601
|
+
const preview = { prepareId: randomUUID(), operationId, decisionId: record.view.decisionId,
|
|
254
602
|
action: input.action, summary: checked.summary, changes: this.changes(input, checked),
|
|
255
603
|
affected: { territoryIds: checked.territoryIds, bindingIds: checked.bindingIds }, createdAt, expiresAt: record.view.expiresAt };
|
|
256
604
|
record.preparations.set(preview.prepareId, { input: clone(input), inputHash: ownerInputHash(input), fingerprint: checked.fingerprint, preview });
|
|
@@ -416,7 +764,7 @@ export class OwnerDecisionController {
|
|
|
416
764
|
if (!result || result.ok !== true)
|
|
417
765
|
fail(result?.ok === false ? result.code : 'VALIDATION_UNAVAILABLE', result?.ok === false ? result.message : '运行环境未提供有效校验结果。');
|
|
418
766
|
}
|
|
419
|
-
context(record, input) {
|
|
767
|
+
context(record, input, operationId) {
|
|
420
768
|
this.assertActive(record);
|
|
421
769
|
if (!record.view.actions.includes(input.action))
|
|
422
770
|
fail('SCOPE_DENIED', '该动作未获本次授权。');
|
|
@@ -474,6 +822,113 @@ export class OwnerDecisionController {
|
|
|
474
822
|
facts.push(related.map(t => t.territory_id).sort());
|
|
475
823
|
};
|
|
476
824
|
switch (input.action) {
|
|
825
|
+
case 'delivery.item.ack': {
|
|
826
|
+
const p = input.parameters;
|
|
827
|
+
const task = this.store.getTask(p.task_id);
|
|
828
|
+
if (!task)
|
|
829
|
+
fail('SCOPE_DENIED', '任务不存在或不属于当前王国。');
|
|
830
|
+
const taskTerritory = this.store.getTerritoryById(task.territory_id);
|
|
831
|
+
if (!taskTerritory || taskTerritory.kingdom_id !== kingdomId || taskTerritory.status === 'DELETED') {
|
|
832
|
+
fail('SCOPE_DENIED', '任务不属于当前王国。');
|
|
833
|
+
}
|
|
834
|
+
// 与 catalog 共用同一范围判据;知悉不改配置或责任,因此这里只登记领地事实,
|
|
835
|
+
// 不要求领地直接列在 territoryIds 中,也不参与 guardUnsettled。
|
|
836
|
+
if (!this.deliveryScopeAllows(record, taskTerritory))
|
|
837
|
+
fail('SCOPE_DENIED', '该交付不在本次授权范围内。');
|
|
838
|
+
territories.add(task.territory_id);
|
|
839
|
+
facts.push(taskTerritory);
|
|
840
|
+
const claim = this.store.latestWorkerResult(p.task_id);
|
|
841
|
+
const review = readLatestReviewEvent(this.store, kingdomId, p.task_id);
|
|
842
|
+
const classification = classifyAcceptedDelivery(this.store, claim, review);
|
|
843
|
+
if (!claim || !classification) {
|
|
844
|
+
fail('DELIVERY_NOT_CONFIRMED', '该交付尚未由同一 Task/attempt 的主管 ACCEPT 确认,不能记录知悉。');
|
|
845
|
+
}
|
|
846
|
+
if (claim.attempt_no !== p.attempt_no || claim.result_id !== p.result_id) {
|
|
847
|
+
fail('DELIVERY_VERSION_STALE', '交付的已接受版本已变化,请重新打开清单。');
|
|
848
|
+
}
|
|
849
|
+
// 接受证据强度进入 fingerprint:确认依据从强证据降为历史弱证据(或反之)时,
|
|
850
|
+
// 预览必须失效,Owner 不能在不知情的情况下按旧预览提交。
|
|
851
|
+
facts.push(claim, review, classification.kind, classification.evidence);
|
|
852
|
+
const deliveryId = deliveryIdFor(p.task_id);
|
|
853
|
+
if (deliveryId !== p.delivery_id)
|
|
854
|
+
fail('DELIVERY_VERSION_STALE', '交付标识与当前已接受版本不一致。');
|
|
855
|
+
const derived = deriveDeliveryItems(p.task_id, claim, this.changeEvidenceFor(kingdomId, p.task_id));
|
|
856
|
+
const item = derived.find(candidate => candidate.itemId === p.item_id);
|
|
857
|
+
if (!item)
|
|
858
|
+
fail('DELIVERY_ITEM_UNKNOWN', '该条目不在当前交付清单中。');
|
|
859
|
+
if (item.contentHash !== p.content_hash) {
|
|
860
|
+
fail('DELIVERY_ITEM_VERSION_STALE', '该条目内容版本已变化,请重新打开清单后再知悉。');
|
|
861
|
+
}
|
|
862
|
+
facts.push(item.contentHash);
|
|
863
|
+
const ownerId = this.store.getDefaultKingdom()?.owner_id ?? '';
|
|
864
|
+
const acknowledgements = readDeliveryAcknowledgements(this.store, kingdomId, deliveryId);
|
|
865
|
+
const view = deliveryAcknowledgementView(acknowledgements, item, ownerId);
|
|
866
|
+
if (view.acknowledged)
|
|
867
|
+
fail('DELIVERY_ITEM_ALREADY_ACKNOWLEDGED', '该条目当前版本已由 Owner 知悉;重复知悉不会新增记录。');
|
|
868
|
+
facts.push(acknowledgements);
|
|
869
|
+
before = view.acknowledgedAt;
|
|
870
|
+
after = JSON.stringify({ deliveryId, itemId: item.itemId, contentHash: item.contentHash, attemptNo: claim.attempt_no,
|
|
871
|
+
acceptanceEvidenceKind: classification.kind, acceptanceEvidenceExact: classification.evidence.exactResultBound });
|
|
872
|
+
summary = `记下已知悉「${item.content.label}」当前版本(不代表理解、质量认可、Task 完成或发布授权)`;
|
|
873
|
+
break;
|
|
874
|
+
}
|
|
875
|
+
case 'delivery.item.question': {
|
|
876
|
+
const p = input.parameters;
|
|
877
|
+
const task = this.store.getTask(p.task_id);
|
|
878
|
+
if (!task)
|
|
879
|
+
fail('SCOPE_DENIED', '任务不存在或不属于当前王国。');
|
|
880
|
+
const taskTerritory = this.store.getTerritoryById(task.territory_id);
|
|
881
|
+
if (!taskTerritory || taskTerritory.kingdom_id !== kingdomId || taskTerritory.status === 'DELETED') {
|
|
882
|
+
fail('SCOPE_DENIED', '任务不属于当前王国。');
|
|
883
|
+
}
|
|
884
|
+
if (!this.deliveryScopeAllows(record, taskTerritory))
|
|
885
|
+
fail('SCOPE_DENIED', '该交付不在本次授权范围内。');
|
|
886
|
+
territories.add(task.territory_id);
|
|
887
|
+
facts.push(taskTerritory);
|
|
888
|
+
const claim = this.store.latestWorkerResult(p.task_id);
|
|
889
|
+
const review = readLatestReviewEvent(this.store, kingdomId, p.task_id);
|
|
890
|
+
const classification = classifyAcceptedDelivery(this.store, claim, review);
|
|
891
|
+
if (!claim || !classification) {
|
|
892
|
+
fail('DELIVERY_NOT_CONFIRMED', '该交付尚未由同一 Task/attempt 的主管 ACCEPT 确认,不能提问。');
|
|
893
|
+
}
|
|
894
|
+
if (claim.attempt_no !== p.attempt_no || claim.result_id !== p.result_id) {
|
|
895
|
+
fail('DELIVERY_VERSION_STALE', '交付的已接受版本已变化,请重新打开清单。');
|
|
896
|
+
}
|
|
897
|
+
facts.push(claim, review, classification.kind, classification.evidence);
|
|
898
|
+
const deliveryId = deliveryIdFor(p.task_id);
|
|
899
|
+
if (deliveryId !== p.delivery_id)
|
|
900
|
+
fail('DELIVERY_VERSION_STALE', '交付标识与当前已接受版本不一致。');
|
|
901
|
+
// 接收者在写入前就固定:接受事件没有可核对的主管绑定时直接拒绝,而不是先写问题再猜接收者。
|
|
902
|
+
const reviewerBindingId = deliveryReviewerBindingId(review);
|
|
903
|
+
if (!reviewerBindingId) {
|
|
904
|
+
fail('DELIVERY_ACCEPT_REVIEWER_UNKNOWN', '接受事件没有记录主管绑定,无法确定本问题的接收者。');
|
|
905
|
+
}
|
|
906
|
+
facts.push(reviewerBindingId);
|
|
907
|
+
const derived = deriveDeliveryItems(p.task_id, claim, this.changeEvidenceFor(kingdomId, p.task_id));
|
|
908
|
+
const item = derived.find(candidate => candidate.itemId === p.item_id);
|
|
909
|
+
if (!item)
|
|
910
|
+
fail('DELIVERY_ITEM_UNKNOWN', '该条目不在当前交付清单中。');
|
|
911
|
+
if (item.contentHash !== p.content_hash) {
|
|
912
|
+
fail('DELIVERY_ITEM_VERSION_STALE', '该条目内容版本已变化,请重新打开清单后再提问。');
|
|
913
|
+
}
|
|
914
|
+
facts.push(item.contentHash);
|
|
915
|
+
const questionText = boundedQuestionText(p.question_text, DELIVERY_QUESTION_TEXT_LIMIT);
|
|
916
|
+
if (!questionText)
|
|
917
|
+
fail('INVALID_INPUT', '问题内容必须是非空、长度受限的文本。');
|
|
918
|
+
// 幂等只以**本次 Owner operation** 为界:同一 operationId 重放命中同一事件 ID,
|
|
919
|
+
// 才会被判为「不会新增第二条事实」;不同 operation 即使条目与文本都相同,也各自
|
|
920
|
+
// 形成独立问题。绝不按正文去重,否则新操作的提问会被历史同文问题吞掉。
|
|
921
|
+
const operationEventId = deliveryQuestionEventId({ kingdomId: kingdomId, deliveryId, itemId: item.itemId,
|
|
922
|
+
contentHash: item.contentHash, operationId });
|
|
923
|
+
const duplicate = this.store.getEventById(operationEventId) !== null;
|
|
924
|
+
const thread = deliveryQuestionThreadForItem(readDeliveryQuestionThread(this.store, kingdomId, deliveryId), item.itemId, item.contentHash);
|
|
925
|
+
facts.push(thread?.questions.map(question => [question.questionId, question.reply?.eventId ?? null]).sort() ?? null);
|
|
926
|
+
before = thread ? `${thread.questions.length} 条提问(${thread.pendingCount} 待回复 / ${thread.answeredCount} 已回复)` : null;
|
|
927
|
+
after = JSON.stringify({ deliveryId, itemId: item.itemId, contentHash: item.contentHash, attemptNo: claim.attempt_no,
|
|
928
|
+
reviewerBindingId, questionText, duplicate });
|
|
929
|
+
summary = `就「${item.content.label}」当前版本向接受该交付的主管提一个具体问题(记录为一条对话事实,不是知悉,也不改变任务状态)`;
|
|
930
|
+
break;
|
|
931
|
+
}
|
|
477
932
|
case 'plan.adopt': {
|
|
478
933
|
const p = input.parameters, plan = readCollaborationPlan(this.store, kingdomId, p.plan_id);
|
|
479
934
|
if (!plan || plan.version !== p.version || plan.digest !== p.digest || plan.state !== 'PROPOSED')
|
|
@@ -536,7 +991,25 @@ export class OwnerDecisionController {
|
|
|
536
991
|
break;
|
|
537
992
|
}
|
|
538
993
|
case 'territory.supervisor': {
|
|
539
|
-
const p = input.parameters, row = territory(p.territory_id)
|
|
994
|
+
const p = input.parameters, row = territory(p.territory_id);
|
|
995
|
+
if (p.supervisor_binding_id === null) {
|
|
996
|
+
// v3.2.0:解除现任主理。1:1 之下"把主理换到别处"必须先走这一步。
|
|
997
|
+
// 旧责任关系仍作为相关事实登记:它的 session 参与未结算工作守卫(不允许在在途工作时解除责任)。
|
|
998
|
+
if (row.supervisor_binding_id) {
|
|
999
|
+
const previous = this.store.getBindingById(row.supervisor_binding_id);
|
|
1000
|
+
if (previous && previous.kingdom_id !== kingdomId)
|
|
1001
|
+
fail('RELATED_BINDING_UNAVAILABLE', '旧主管引用不属于当前王国,无法安全修改责任。');
|
|
1002
|
+
facts.push({ previousSupervisor: previous, previousSupervisorId: row.supervisor_binding_id });
|
|
1003
|
+
bindings.add(row.supervisor_binding_id);
|
|
1004
|
+
if (previous?.session_id)
|
|
1005
|
+
sessions.add(previous.session_id);
|
|
1006
|
+
}
|
|
1007
|
+
before = row.supervisor_binding_id;
|
|
1008
|
+
after = null;
|
|
1009
|
+
summary = '解除领地现任主理(该领地随即 fail-closed,直到指派新的)';
|
|
1010
|
+
break;
|
|
1011
|
+
}
|
|
1012
|
+
const supervisor = binding(p.supervisor_binding_id);
|
|
540
1013
|
if (supervisor.role_type !== 'SUPERVISOR')
|
|
541
1014
|
fail('INVALID_ROLE', '目标必须是活跃主管。');
|
|
542
1015
|
if (row.supervisor_binding_id) {
|
|
@@ -550,6 +1023,22 @@ export class OwnerDecisionController {
|
|
|
550
1023
|
bindings.add(row.supervisor_binding_id);
|
|
551
1024
|
if (previous?.session_id)
|
|
552
1025
|
sessions.add(previous.session_id);
|
|
1026
|
+
// v3.2.0(Owner D2):在任主理不静默替换——先解除再指派。判据与 core 的
|
|
1027
|
+
// applyTerritorySupervisor 完全对齐:只有"当前指针仍指向**在任 ACTIVE** 席位"才算冲突;
|
|
1028
|
+
// 指向已退任席位的悬挂引用可直接改派(修复),不再出现 GUI 比 direct Slash 更严的分叉。
|
|
1029
|
+
if (previous && previous.status === 'ACTIVE' && previous.binding_id !== supervisor.binding_id) {
|
|
1030
|
+
fail('TERRITORY_ALREADY_SUPERVISED', `领地「${row.name}」已有在任主理(${previous.role_name});请先解除现有主理,再指派新的。`);
|
|
1031
|
+
}
|
|
1032
|
+
}
|
|
1033
|
+
// v3.2.0(Owner 1:1 裁定):一个主管席位只主理一个领地。与 core 同判据(排除本次目标领地、
|
|
1034
|
+
// 排除 DELETED 领地),在预览阶段就给出准确原因,而不是等到提交才回滚。
|
|
1035
|
+
const otherTerritory = this.store.listTerritories(kingdomId)
|
|
1036
|
+
.find(item => item.status !== 'DELETED' && item.territory_id !== row.territory_id
|
|
1037
|
+
&& item.supervisor_binding_id === supervisor.binding_id) ?? null;
|
|
1038
|
+
if (otherTerritory) {
|
|
1039
|
+
facts.push(otherTerritory);
|
|
1040
|
+
territories.add(otherTerritory.territory_id);
|
|
1041
|
+
fail('SUPERVISOR_ALREADY_ATTACHED', `主管 ${supervisor.role_name} 已隶属领地「${otherTerritory.name}」;一个主管席位只能主理一个领地,请先解除原领地的主理。`);
|
|
553
1042
|
}
|
|
554
1043
|
before = row.supervisor_binding_id;
|
|
555
1044
|
after = supervisor.binding_id;
|
|
@@ -566,10 +1055,39 @@ export class OwnerDecisionController {
|
|
|
566
1055
|
if (existing)
|
|
567
1056
|
fail('ROLE_ALREADY_BOUND', '宰相席位已有活跃绑定。');
|
|
568
1057
|
}
|
|
1058
|
+
if (p.role_type === 'SUPERVISOR') {
|
|
1059
|
+
// v3.2.0:主管必须同时落到一个领地上;预览阶段就证明「领地在授权范围内」且「当前无其他主理」,
|
|
1060
|
+
// 不让人类在提交后才发现冲突。
|
|
1061
|
+
if (!p.territory_id)
|
|
1062
|
+
fail('TERRITORY_REQUIRED', '任命主管必须指定领地。');
|
|
1063
|
+
// 与共享 territory() 助手、以及 catalog.territories 同一判据:领地必须显式在本次授权范围内。
|
|
1064
|
+
// 这里刻意**不**用 kingdomWide 放宽——否则目录可见性与提交判定会分叉(3.2.0 审计 r1 的 DV5 缺陷)。
|
|
1065
|
+
if (!scope.territoryIds.includes(p.territory_id))
|
|
1066
|
+
fail('SCOPE_DENIED', '领地不在授权范围内或不可用。');
|
|
1067
|
+
const row = this.store.getTerritoryById(p.territory_id);
|
|
1068
|
+
if (!row || row.kingdom_id !== kingdomId || row.status === 'DELETED')
|
|
1069
|
+
fail('SCOPE_DENIED', '领地不在授权范围内或不可用。');
|
|
1070
|
+
territories.add(row.territory_id);
|
|
1071
|
+
facts.push(row);
|
|
1072
|
+
if (row.supervisor_binding_id) {
|
|
1073
|
+
const current = this.store.getBindingById(row.supervisor_binding_id);
|
|
1074
|
+
facts.push({ previousSupervisor: current, previousSupervisorId: row.supervisor_binding_id });
|
|
1075
|
+
if (current?.session_id)
|
|
1076
|
+
sessions.add(current.session_id);
|
|
1077
|
+
// 与 core 的 applyTerritorySupervisor 同判据:只有在任 ACTIVE 主理才算冲突,
|
|
1078
|
+
// 指向已退任席位的悬挂引用属于修复(可直接改派),两条入口结论必须一致。
|
|
1079
|
+
if (current && current.status === 'ACTIVE') {
|
|
1080
|
+
fail('TERRITORY_ALREADY_SUPERVISED', `领地「${row.name}」已有在任主理(${current.role_name});请先解除现有主理(territory.supervisor 传 null),再指派新的。`);
|
|
1081
|
+
}
|
|
1082
|
+
}
|
|
1083
|
+
after = JSON.stringify({ roleType: p.role_type, territoryId: row.territory_id, territoryName: row.name });
|
|
1084
|
+
}
|
|
1085
|
+
else {
|
|
1086
|
+
after = JSON.stringify(p);
|
|
1087
|
+
}
|
|
569
1088
|
if (p.session_id)
|
|
570
1089
|
targetSession(p.session_id, null);
|
|
571
|
-
|
|
572
|
-
summary = '任命角色';
|
|
1090
|
+
summary = p.role_type === 'SUPERVISOR' ? '任命主管并同时指定其领地(席位与主理同一事务原子写入)' : '任命角色';
|
|
573
1091
|
break;
|
|
574
1092
|
}
|
|
575
1093
|
case 'role.session': {
|
|
@@ -604,11 +1122,16 @@ export class OwnerDecisionController {
|
|
|
604
1122
|
}
|
|
605
1123
|
const context = { territoryIds: [...territories].sort(), bindingIds: [...bindings].sort(), sessionIds: [...sessions].sort(),
|
|
606
1124
|
fingerprint: ownerInputHash(facts), before, after, summary };
|
|
607
|
-
|
|
1125
|
+
// `delivery.item.ack` 只追加 Owner 知悉事实,不改配置与责任,因此不受
|
|
1126
|
+
// 同领地不相关未结算执行/Lease/Dispatch 阻断。
|
|
1127
|
+
if (kingdomId && input.action !== 'territory.update' && input.action !== 'territory.create' && input.action !== 'budget.policy' && input.action !== 'plan.adopt' && input.action !== 'delivery.item.ack' && input.action !== 'delivery.item.question')
|
|
608
1128
|
this.guardUnsettled(kingdomId, input.action === 'ceiling', context);
|
|
609
1129
|
return context;
|
|
610
1130
|
}
|
|
611
1131
|
guardUnsettled(kingdomId, all, context) {
|
|
1132
|
+
// 3.2.0 审计 r1 更正:本守卫只覆盖 **Owner 管理窗口** 这条路径。
|
|
1133
|
+
// direct Slash / core 的 role.bind 与 territory.supervisor 没有等价守卫(3.1.0 既有语义),
|
|
1134
|
+
// 因此文档不得声称"旧主管的未结算执行一定会拦截改派"。
|
|
612
1135
|
const affected = (territoryId, bindingId, sessionId) => all
|
|
613
1136
|
|| !!territoryId && context.territoryIds.includes(territoryId)
|
|
614
1137
|
|| !!bindingId && context.bindingIds.includes(bindingId)
|
|
@@ -669,10 +1192,20 @@ export class OwnerDecisionController {
|
|
|
669
1192
|
}
|
|
670
1193
|
case 'territory.supervisor': {
|
|
671
1194
|
const describe = (id) => id ? `${this.store.getBindingById(id)?.role_name ?? '主管'}(${id})` : null;
|
|
672
|
-
return [change('主理主管', describe(context.before), describe(context.after))
|
|
1195
|
+
return [change('主理主管', describe(context.before), describe(context.after)),
|
|
1196
|
+
...(input.parameters.supervisor_binding_id === null
|
|
1197
|
+
? [change('解除后', null, '该领地无主理:任何 Supervisor 都不能治理该领地(fail-closed),直到指派新的。')]
|
|
1198
|
+
: [])];
|
|
1199
|
+
}
|
|
1200
|
+
case 'role.bind': {
|
|
1201
|
+
const after = context.after ? JSON.parse(context.after) : null;
|
|
1202
|
+
const scope = input.parameters.role_type === 'SUPERVISOR'
|
|
1203
|
+
? [change('主理领地', null, after?.territoryName && after.territoryId ? `${after.territoryName}(${after.territoryId})` : null),
|
|
1204
|
+
change('写入方式', null, '主管席位与领地主理在同一事务内原子写入;任一步不合法则整体回滚,不会留下没有领地的主管。')]
|
|
1205
|
+
: [];
|
|
1206
|
+
return [change('角色身份', null, { CHANCELLOR: '宰相', SUPERVISOR: '主管', WORKER: '执行者' }[input.parameters.role_type]),
|
|
1207
|
+
change('角色名称', null, input.parameters.role_name), change('目标 Session', null, input.parameters.session_id), ...scope];
|
|
673
1208
|
}
|
|
674
|
-
case 'role.bind': return [change('角色身份', null, { CHANCELLOR: '宰相', SUPERVISOR: '主管', WORKER: '执行者' }[input.parameters.role_type]),
|
|
675
|
-
change('角色名称', null, input.parameters.role_name), change('目标 Session', null, input.parameters.session_id)];
|
|
676
1209
|
case 'role.session': return [change('角色 Session', context.before, context.after)];
|
|
677
1210
|
case 'ceiling': {
|
|
678
1211
|
const before = context.before ? JSON.parse(context.before) : {};
|
|
@@ -688,11 +1221,51 @@ export class OwnerDecisionController {
|
|
|
688
1221
|
const after = input.parameters.profile;
|
|
689
1222
|
return [change('请求的 Provider', before?.provider, after?.provider), change('请求的模型', before?.model, after?.model)];
|
|
690
1223
|
}
|
|
1224
|
+
case 'delivery.item.ack': {
|
|
1225
|
+
const after = JSON.parse(context.after);
|
|
1226
|
+
const task = this.store.getTask(input.parameters.task_id);
|
|
1227
|
+
const item = deriveDeliveryItems(input.parameters.task_id, this.store.latestWorkerResult(input.parameters.task_id), this.changeEvidenceFor(this.store.getDefaultKingdom()?.kingdom_id ?? '', input.parameters.task_id))
|
|
1228
|
+
.find(candidate => candidate.itemId === after.itemId);
|
|
1229
|
+
return [change('任务', null, task?.title),
|
|
1230
|
+
change('已接受尝试', null, String(after.attemptNo)),
|
|
1231
|
+
change('条目', null, item ? `${item.content.label}` : after.itemId),
|
|
1232
|
+
change('条目内容', null, item?.content.detail),
|
|
1233
|
+
change('内容版本', null, after.contentHash),
|
|
1234
|
+
change('改动定位', null, item ? `${item.content.change.kind}${item.content.change.evidenceLabel ? ` · ${item.content.change.evidenceLabel}` : ''} · ${item.content.change.note}` : null),
|
|
1235
|
+
change('接受证据强度', null, after.acceptanceEvidenceExact
|
|
1236
|
+
? 'EXACT_RESULT_BOUND:TASK_ACCEPTED 锁定了本次结果 ID 与内容摘要,且两者都与当前呈报一致。'
|
|
1237
|
+
: '历史接受证据较弱(LEGACY_ATTEMPT_ONLY):该 Task/attempt 的 TASK_ACCEPTED 是 v1.0.0 旧格式,只有尝试编号,缺少被审查结果 ID 与内容摘要;本条按真实事件字段与同 Task/attempt 的唯一 WorkerResult 判定,不是 exact result-bound 证据。知悉仍只表示已知悉该条当前版本,不代表理解、质量认可、人类验收、Task DONE 或发布授权。'),
|
|
1238
|
+
change('Owner 知悉时间(本窗口提交后)', null, '提交时记录'),
|
|
1239
|
+
change('不改动的状态', null, '任务状态、主管审查、正式 Owner acceptance 与发布状态均不变')];
|
|
1240
|
+
}
|
|
1241
|
+
case 'delivery.item.question': {
|
|
1242
|
+
const after = JSON.parse(context.after);
|
|
1243
|
+
const task = this.store.getTask(input.parameters.task_id);
|
|
1244
|
+
const item = deriveDeliveryItems(input.parameters.task_id, this.store.latestWorkerResult(input.parameters.task_id), this.changeEvidenceFor(this.store.getDefaultKingdom()?.kingdom_id ?? '', input.parameters.task_id))
|
|
1245
|
+
.find(candidate => candidate.itemId === after.itemId);
|
|
1246
|
+
const reviewer = this.store.getBindingById(after.reviewerBindingId);
|
|
1247
|
+
const territory = this.store.getTerritoryById(task?.territory_id ?? '');
|
|
1248
|
+
return [change('任务', null, task?.title),
|
|
1249
|
+
change('已接受尝试', null, String(after.attemptNo)),
|
|
1250
|
+
change('条目', null, item ? item.content.label : after.itemId),
|
|
1251
|
+
change('条目内容', null, item?.content.detail),
|
|
1252
|
+
change('内容版本', null, after.contentHash),
|
|
1253
|
+
change('接收者(接受该交付的主管)', null, reviewer ? `${reviewer.role_name}(${reviewer.binding_id})` : after.reviewerBindingId),
|
|
1254
|
+
change('该领地当前主理主管', null, territory?.supervisor_binding_id ?? '未指派'),
|
|
1255
|
+
change('本次提交', null, after.duplicate
|
|
1256
|
+
? '该条目当前版本的同一问题已有记录:不会新增第二条事实,也不会改变任何状态。'
|
|
1257
|
+
: '新增一条 Owner 提问事实;接收者仍是接受该交付的主管,当前未被读取前只显示「待领取」。'),
|
|
1258
|
+
change('问题的准确保留范围', null, after.duplicate
|
|
1259
|
+
? '与既有记录一致,不重复写入。'
|
|
1260
|
+
: '只写入提问事实本身;不自动通知、不自动派发、不自动唤醒任何 Agent。'),
|
|
1261
|
+
change('不改动的状态', null, '任务状态、主管审查、知悉、正式 Owner acceptance 与发布状态均不变;提问不是知悉。'),
|
|
1262
|
+
change('回复', null, '由接受该交付的主管在其 session-bound Agent Tool 中回复;Owner 不代答,其他主管不能代答。')];
|
|
1263
|
+
}
|
|
691
1264
|
}
|
|
692
1265
|
}
|
|
693
1266
|
async apply(handle, record, preparation, signal) {
|
|
694
1267
|
this.active(handle);
|
|
695
|
-
const context = this.context(record, preparation.input);
|
|
1268
|
+
const context = this.context(record, preparation.input, preparation.preview.operationId);
|
|
696
1269
|
if (context.fingerprint !== preparation.fingerprint)
|
|
697
1270
|
fail('PREVIEW_STALE', '相关对象已变化,请重新预览。');
|
|
698
1271
|
await this.validate(record, preparation.input, signal);
|
|
@@ -701,7 +1274,7 @@ export class OwnerDecisionController {
|
|
|
701
1274
|
fail('REQUEST_ABORTED', '请求已取消。');
|
|
702
1275
|
const result = this.store.withImmediateTransaction(() => {
|
|
703
1276
|
this.active(handle);
|
|
704
|
-
const current = this.context(record, preparation.input);
|
|
1277
|
+
const current = this.context(record, preparation.input, preparation.preview.operationId);
|
|
705
1278
|
if (current.fingerprint !== preparation.fingerprint || ownerInputHash(preparation.input) !== preparation.inputHash)
|
|
706
1279
|
fail('PREVIEW_STALE', '相关事实或准备内容已变化,请重新预览。');
|
|
707
1280
|
const source = { source_channel: 'LOCAL_OWNER_GUI', authorization_source: 'LOCAL_DIRECT_SLASH',
|
|
@@ -753,6 +1326,8 @@ export class OwnerDecisionController {
|
|
|
753
1326
|
}
|
|
754
1327
|
};
|
|
755
1328
|
let message, eventType;
|
|
1329
|
+
/** 本次是否真的追加了一条新业务事实;幂等重试时没有新事件,不能按「事件数恰好 1」判定失败。 */
|
|
1330
|
+
let appendedBusinessFact = true;
|
|
756
1331
|
switch (input.action) {
|
|
757
1332
|
case 'plan.adopt':
|
|
758
1333
|
message = run({ kingdomId, ...input.parameters }, adoptCollaborationPlan);
|
|
@@ -782,7 +1357,10 @@ export class OwnerDecisionController {
|
|
|
782
1357
|
}
|
|
783
1358
|
case 'role.bind': {
|
|
784
1359
|
const p = input.parameters;
|
|
785
|
-
|
|
1360
|
+
// v3.2.0:主管走原子通道(建席位 + 设主理同一事务);其它角色保持原路径。
|
|
1361
|
+
message = p.role_type === 'SUPERVISOR'
|
|
1362
|
+
? run({ kingdomId, roleType: p.role_type, roleName: p.role_name, sessionId: p.session_id, territoryId: p.territory_id }, bindSupervisorForTerritory)
|
|
1363
|
+
: run({ kingdomId, roleType: p.role_type, roleName: p.role_name, sessionId: p.session_id }, bindRole);
|
|
786
1364
|
eventType = 'ROLE_BOUND';
|
|
787
1365
|
break;
|
|
788
1366
|
}
|
|
@@ -802,11 +1380,108 @@ export class OwnerDecisionController {
|
|
|
802
1380
|
message = '请求的执行配置已保存;这不代表模型已实际运行成功。';
|
|
803
1381
|
eventType = 'EXECUTION_PROFILE_UPDATED';
|
|
804
1382
|
break;
|
|
1383
|
+
case 'delivery.item.ack': {
|
|
1384
|
+
const claim = this.store.latestWorkerResult(input.parameters.task_id);
|
|
1385
|
+
if (!claim)
|
|
1386
|
+
fail('DELIVERY_NOT_CONFIRMED', '交付对应的结果不存在。');
|
|
1387
|
+
const review = readLatestReviewEvent(this.store, kingdomId, input.parameters.task_id);
|
|
1388
|
+
const existing = readDeliveryAcknowledgements(this.store, kingdomId, input.parameters.delivery_id);
|
|
1389
|
+
const already = existing.some(ack => ack.itemId === input.parameters.item_id && ack.contentHash === input.parameters.content_hash
|
|
1390
|
+
&& ack.ownerId === record.view.ownerId);
|
|
1391
|
+
if (!already) {
|
|
1392
|
+
const classification = classifyAcceptedDelivery(this.store, claim, review);
|
|
1393
|
+
if (!classification)
|
|
1394
|
+
fail('DELIVERY_NOT_CONFIRMED', '交付尚未由主管 ACCEPT 确认。');
|
|
1395
|
+
const changeEvidence = this.changeEvidenceFor(kingdomId, input.parameters.task_id);
|
|
1396
|
+
const item = deriveDeliveryItems(input.parameters.task_id, claim, changeEvidence).find(candidate => candidate.itemId === input.parameters.item_id);
|
|
1397
|
+
if (!item || item.contentHash !== input.parameters.content_hash)
|
|
1398
|
+
fail('DELIVERY_ITEM_VERSION_STALE', '条目内容版本已变化。');
|
|
1399
|
+
recordDeliveryAcknowledgement(this.store, {
|
|
1400
|
+
kingdomId,
|
|
1401
|
+
deliveryId: input.parameters.delivery_id,
|
|
1402
|
+
itemId: input.parameters.item_id,
|
|
1403
|
+
contentHash: input.parameters.content_hash,
|
|
1404
|
+
taskId: input.parameters.task_id,
|
|
1405
|
+
attemptNo: input.parameters.attempt_no,
|
|
1406
|
+
resultId: input.parameters.result_id,
|
|
1407
|
+
ownerId: record.view.ownerId,
|
|
1408
|
+
ownerBindingId: this.store.getBindingByRole(kingdomId, 'OWNER')?.binding_id ?? null,
|
|
1409
|
+
itemLabel: item.content.label,
|
|
1410
|
+
acknowledgedAt: new Date(this.now()).toISOString(),
|
|
1411
|
+
attribution: { ...source },
|
|
1412
|
+
changeEvidence,
|
|
1413
|
+
});
|
|
1414
|
+
}
|
|
1415
|
+
message = already
|
|
1416
|
+
? '该条目当前版本的 Owner 知悉记录已存在;未新增第二条事实。知悉不改变任务、审查与发布状态。'
|
|
1417
|
+
: '已记下 Owner 已知悉该条当前版本;这不代表理解、质量认可、Task DONE、正式验收或发布授权,也未改变任务、审查与发布状态。';
|
|
1418
|
+
eventType = DELIVERY_ACK_EVENT_TYPE;
|
|
1419
|
+
break;
|
|
1420
|
+
}
|
|
1421
|
+
case 'delivery.item.question': {
|
|
1422
|
+
const claim = this.store.latestWorkerResult(input.parameters.task_id);
|
|
1423
|
+
if (!claim)
|
|
1424
|
+
fail('DELIVERY_NOT_CONFIRMED', '交付对应的结果不存在。');
|
|
1425
|
+
const review = readLatestReviewEvent(this.store, kingdomId, input.parameters.task_id);
|
|
1426
|
+
// 责任归属必须在事务内重算:预览时的接受主管身份、条目版本或领地改绑都可能已变化。
|
|
1427
|
+
const reviewerBindingId = deliveryReviewerBindingId(review);
|
|
1428
|
+
if (!reviewerBindingId)
|
|
1429
|
+
fail('DELIVERY_ACCEPT_REVIEWER_UNKNOWN', '接受事件没有记录主管绑定,无法确定本问题的接收者。');
|
|
1430
|
+
// CHANGE 条目只由「主管在 ACCEPT 中显式选择、且本地 hash 重验通过」的改动证据派生:
|
|
1431
|
+
// 预览、事务内校验与写入端必须复用**同一份**已重验证据,写入端才可能重算出同一
|
|
1432
|
+
// itemId/contentHash,而不是必报 DELIVERY_ITEM_UNKNOWN。
|
|
1433
|
+
const changeEvidence = this.changeEvidenceFor(kingdomId, input.parameters.task_id);
|
|
1434
|
+
const item = deriveDeliveryItems(input.parameters.task_id, claim, changeEvidence)
|
|
1435
|
+
.find(candidate => candidate.itemId === input.parameters.item_id);
|
|
1436
|
+
if (!item || item.contentHash !== input.parameters.content_hash)
|
|
1437
|
+
fail('DELIVERY_ITEM_VERSION_STALE', '条目内容版本已变化。');
|
|
1438
|
+
const questionText = boundedQuestionText(input.parameters.question_text, DELIVERY_QUESTION_TEXT_LIMIT);
|
|
1439
|
+
if (!questionText)
|
|
1440
|
+
fail('INVALID_INPUT', '问题内容必须是非空、长度受限的文本。');
|
|
1441
|
+
const operationId = source.operation_id;
|
|
1442
|
+
if (!operationId)
|
|
1443
|
+
fail('OPERATION_MISMATCH', '本次管理操作没有操作编号,拒绝写入提问事实。');
|
|
1444
|
+
// 提问身份属于产生它的这次 Owner operation:同一编号重放由写入端的幂等分支核对
|
|
1445
|
+
// 并原样返回既有事实;不同编号即使文本相同也各自形成独立问题。这里不再按正文去重,
|
|
1446
|
+
// 「没有新事件」也不等于操作失败。
|
|
1447
|
+
const recorded = recordDeliveryQuestion(this.store, {
|
|
1448
|
+
kingdomId,
|
|
1449
|
+
deliveryId: input.parameters.delivery_id,
|
|
1450
|
+
taskId: input.parameters.task_id,
|
|
1451
|
+
itemId: input.parameters.item_id,
|
|
1452
|
+
itemLabel: item.content.label,
|
|
1453
|
+
contentHash: input.parameters.content_hash,
|
|
1454
|
+
attemptNo: input.parameters.attempt_no,
|
|
1455
|
+
resultId: input.parameters.result_id,
|
|
1456
|
+
ownerId: record.view.ownerId,
|
|
1457
|
+
ownerBindingId: this.store.getBindingByRole(kingdomId, 'OWNER')?.binding_id ?? null,
|
|
1458
|
+
operationId,
|
|
1459
|
+
questionText,
|
|
1460
|
+
askedAt: new Date(this.now()).toISOString(),
|
|
1461
|
+
changeEvidence,
|
|
1462
|
+
attribution: { ...source },
|
|
1463
|
+
});
|
|
1464
|
+
appendedBusinessFact = recorded.created;
|
|
1465
|
+
message = recorded.created
|
|
1466
|
+
? `已记录 Owner 就该条目当前版本提出的问题,接收者为接受该交付的主管。当前只显示「待领取」:在主管实际读取前不声称已通知或已阅读;这不代表知悉、验收或任务完成,也不改变任务、审查与发布状态。`
|
|
1467
|
+
: '同一 Owner 操作重放:既有提问记录与本次引用一致,未新增第二条事实,也未改变任何状态。';
|
|
1468
|
+
eventType = DELIVERY_QUESTION_EVENT_TYPE;
|
|
1469
|
+
break;
|
|
1470
|
+
}
|
|
805
1471
|
}
|
|
806
1472
|
const events = this.store.listEventsSince(kingdomId, beforeSeq, 20);
|
|
807
1473
|
const business = events.filter(event => event.event_type === eventType && event.actor_role === 'OWNER' && event.actor_id === record.view.ownerId
|
|
808
1474
|
&& JSON.parse(event.payload_json).operation_id === source.operation_id);
|
|
809
|
-
|
|
1475
|
+
// 幂等提问没有新增事件:此时只核对既有事实仍指向同一条交付,不要求事件数为 1。
|
|
1476
|
+
if (!appendedBusinessFact && input.action === 'delivery.item.question') {
|
|
1477
|
+
const existing = readDeliveryQuestionThread(this.store, kingdomId, input.parameters.delivery_id);
|
|
1478
|
+
const matches = existing?.questions.some(question => question.itemId === input.parameters.item_id
|
|
1479
|
+
&& question.contentHash === input.parameters.content_hash) ?? false;
|
|
1480
|
+
if (!matches)
|
|
1481
|
+
fail('MUTATION_NOT_APPLIED', '既有提问事实与本次引用不一致,事务已回滚。');
|
|
1482
|
+
return { target: { type: 'delivery', id: input.parameters.delivery_id }, message };
|
|
1483
|
+
}
|
|
1484
|
+
if (business.length !== 1 || !business[0].target_id || !['kingdom', 'territory', 'binding', 'collaboration-plan', 'delivery'].includes(business[0].target_type ?? '')) {
|
|
810
1485
|
fail('MUTATION_NOT_APPLIED', 'Core 未产生准确业务事实,事务已回滚。');
|
|
811
1486
|
}
|
|
812
1487
|
return { target: { type: business[0].target_type, id: business[0].target_id }, message };
|