xuym-codex-collab 0.4.2 → 0.4.4
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +12 -3
- package/apps/onboarding/bundle.mjs +1 -0
- package/apps/onboarding/manager.mjs +83 -4
- package/apps/onboarding/server.mjs +1 -1
- package/apps/onboarding/web/app.js +22 -4
- package/apps/onboarding/web/index.html +1 -1
- package/apps/peer/desktop.mjs +2 -1
- package/apps/peer/mcp.mjs +4 -2
- package/apps/peer/wecom.mjs +6 -4
- package/docs/desktop-poll.md +20 -5
- package/docs/onboarding.md +16 -3
- package/docs/peer.md +5 -3
- package/docs/team-registry.md +3 -1
- package/package.json +1 -1
- package/packages/codex-kit/templates/.agents/skills/team-collab-desktop/SKILL.md +5 -4
- package/packages/peer/inbox.mjs +11 -4
- package/packages/peer/natural-review.mjs +105 -0
- package/packages/peer/registry.mjs +18 -5
- package/packages/peer/service.mjs +31 -9
- package/packages/peer/store.mjs +28 -3
- package/schemas/peer-inbox-event.schema.json +181 -3
- package/schemas/peer-inbox.schema.json +40 -1
- package/schemas/team-version.schema.json +20 -1
- package/scripts/peer/refresh-project.mjs +1 -1
- package/scripts/peer/setup-desktop-project.mjs +1 -1
package/packages/peer/inbox.mjs
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { digest, verifyEvent } from './config.mjs';
|
|
2
2
|
import { Denied } from './protocol.mjs';
|
|
3
|
+
import { validReviewContext, assertReviewIntent } from './natural-review.mjs';
|
|
3
4
|
|
|
4
5
|
export const inboxIdPattern = /^N-[A-F0-9]{32}$/;
|
|
5
6
|
export const inboxTaskId = (id) => `T-${id.slice(2)}`;
|
|
@@ -7,7 +8,8 @@ export const inboxMessageKey = (agentId, chatId, messageId) => `inbox-message:${
|
|
|
7
8
|
const text = (value, max) => typeof value === 'string' && !!value.trim() && value.length <= max;
|
|
8
9
|
const fields = (value, allowed) => value && typeof value === 'object' && !Array.isArray(value) && Object.keys(value).every((key) => allowed.includes(key));
|
|
9
10
|
export function validateInboxSpec(spec, team) {
|
|
10
|
-
if (!fields(spec, ['schema_version', 'id', 'repo', 'target_agent', 'chat_id', 'sender_id', 'content', 'message_key', 'admin_dispatch']) || spec.schema_version !== 1 || !inboxIdPattern.test(spec.id || '') || spec.repo !== team.repo || !text(spec.target_agent, 64) || !/^[a-zA-Z0-9_-]+$/.test(spec.target_agent) || !Object.hasOwn(team.agents, spec.target_agent) || !text(spec.chat_id, 200) || !text(spec.sender_id, 200) || !text(spec.content, 4000) || !text(spec.message_key, 300) || typeof spec.admin_dispatch !== 'boolean' || spec.id !== `N-${digest([spec.target_agent, spec.message_key]).slice(0, 32).toUpperCase()}`) throw new Error('invalid natural-language inbox specification');
|
|
11
|
+
if (!fields(spec, ['schema_version', 'id', 'repo', 'target_agent', 'chat_id', 'sender_id', 'content', 'message_key', 'admin_dispatch', 'review_context']) || spec.schema_version !== 1 || !inboxIdPattern.test(spec.id || '') || spec.repo !== team.repo || !text(spec.target_agent, 64) || !/^[a-zA-Z0-9_-]+$/.test(spec.target_agent) || !Object.hasOwn(team.agents, spec.target_agent) || !text(spec.chat_id, 200) || !text(spec.sender_id, 200) || !text(spec.content, 4000) || !text(spec.message_key, 300) || typeof spec.admin_dispatch !== 'boolean' || spec.id !== `N-${digest([spec.target_agent, spec.message_key]).slice(0, 32).toUpperCase()}`) throw new Error('invalid natural-language inbox specification');
|
|
12
|
+
if (spec.review_context !== undefined && (!validReviewContext(spec.review_context) || team.agents[spec.target_agent].wecom_userid !== spec.sender_id)) throw new Denied('invalid human review context');
|
|
11
13
|
const owner = team.agents[spec.target_agent].wecom_userid === spec.sender_id;
|
|
12
14
|
const senders = Object.values(team.agents).filter((agent) => agent.wecom_userid === spec.sender_id);
|
|
13
15
|
const member = senders.some((agent) => agent.role === 'member');
|
|
@@ -22,11 +24,11 @@ export function reduceInbox(spec, events, team) {
|
|
|
22
24
|
const keys = new Set(); const ids = new Set(); let previousTime = -Infinity;
|
|
23
25
|
for (const event of events) {
|
|
24
26
|
verifyEvent(team, event);
|
|
25
|
-
if (!fields(event, ['schema_version', 'request_id', 'seq', 'event_id', 'actor_id', 'occurred_at', 'idempotency_key', 'team_hash', 'signature', 'type', 'spec_hash', 'content', 'message_key', 'response', 'task_id', 'task_hash'])) throw new Error('unsupported inbox event fields');
|
|
27
|
+
if (!fields(event, ['schema_version', 'request_id', 'seq', 'event_id', 'actor_id', 'occurred_at', 'idempotency_key', 'team_hash', 'signature', 'type', 'spec_hash', 'content', 'message_key', 'response', 'task_id', 'task_hash', 'review_seq', 'decision', 'summary', 'task_event_hash'])) throw new Error('unsupported inbox event fields');
|
|
26
28
|
const time = Date.parse(event.occurred_at);
|
|
27
29
|
if (event.schema_version !== 1 || event.request_id !== spec.id || event.actor_id !== spec.target_agent || event.seq !== state.seq + 1 || !Number.isFinite(time) || !event.occurred_at.endsWith('Z') || time < previousTime || !/^[a-f0-9-]{36}$/.test(event.event_id || '') || !text(event.idempotency_key, 300) || keys.has(event.idempotency_key) || ids.has(event.event_id)) throw new Error('invalid inbox sequence, actor or duplicate event');
|
|
28
|
-
const allowed = { received: ['spec_hash'], clarified: ['response'], replied: ['content', 'message_key'], task_created: ['task_id', 'task_hash', 'response'], answered: ['response'] }[event.type];
|
|
29
|
-
if (!allowed || ['spec_hash', 'content', 'message_key', 'response', 'task_id', 'task_hash'].some((key) => event[key] !== undefined && !allowed.includes(key))) throw new Error('invalid inbox event payload');
|
|
30
|
+
const allowed = { received: ['spec_hash'], clarified: ['response'], replied: ['content', 'message_key'], task_created: ['task_id', 'task_hash', 'response'], answered: ['response'], reviewed: ['task_id', 'review_seq', 'decision', 'summary', 'task_event_hash', 'response'] }[event.type];
|
|
31
|
+
if (!allowed || ['spec_hash', 'content', 'message_key', 'response', 'task_id', 'task_hash', 'review_seq', 'decision', 'summary', 'task_event_hash'].some((key) => event[key] !== undefined && !allowed.includes(key))) throw new Error('invalid inbox event payload');
|
|
30
32
|
switch (event.type) {
|
|
31
33
|
case 'received':
|
|
32
34
|
if (state.seq || event.spec_hash !== digest(spec) || event.idempotency_key !== spec.message_key) throw new Error('invalid inbox reception');
|
|
@@ -38,6 +40,11 @@ export function reduceInbox(spec, events, team) {
|
|
|
38
40
|
case 'replied':
|
|
39
41
|
if (state.status !== 'clarification' || !text(event.content, 4000) || !text(event.message_key, 300) || event.idempotency_key !== event.message_key) throw new Error('invalid clarification reply');
|
|
40
42
|
state.status = 'pending'; break;
|
|
43
|
+
case 'reviewed': {
|
|
44
|
+
if (state.status !== 'pending' || !Number.isSafeInteger(event.review_seq) || event.review_seq < 2 || !text(event.response, 2000) || !/^[a-f0-9]{64}$/.test(event.task_event_hash || '')) throw new Error('invalid inbox human review');
|
|
45
|
+
assertReviewIntent({ spec, events: events.slice(0, event.seq - 1) }, { task_id: event.task_id, expected_seq: event.review_seq - 1, decision: event.decision, ...(event.summary !== undefined ? { summary: event.summary } : {}) }, spec.target_agent, team);
|
|
46
|
+
state.status = 'reviewed'; state.review_task_id = event.task_id; break;
|
|
47
|
+
}
|
|
41
48
|
case 'task_created':
|
|
42
49
|
if (state.status !== 'pending' || event.task_id !== inboxTaskId(spec.id) || !/^[a-f0-9]{64}$/.test(event.task_hash || '') || !text(event.response, 2000)) throw new Error('invalid inbox task resolution');
|
|
43
50
|
state.status = 'task_created'; state.task_id = event.task_id; break;
|
|
@@ -0,0 +1,105 @@
|
|
|
1
|
+
import { Conflict, Denied } from './protocol.mjs';
|
|
2
|
+
|
|
3
|
+
export const reviewTaskPattern = /^T-[A-Z0-9-]{1,62}$/;
|
|
4
|
+
const fields = (value, allowed) => value && typeof value === 'object' && !Array.isArray(value) && Object.keys(value).every((key) => allowed.includes(key));
|
|
5
|
+
|
|
6
|
+
// Freeze the human's review scope when their authenticated group message arrives.
|
|
7
|
+
// Model interpretation must never silently substitute a newer task revision.
|
|
8
|
+
export function reviewContext(tasks, agentId, senderId, chatId, team) {
|
|
9
|
+
if (team.agents[agentId]?.wecom_userid !== senderId) return [];
|
|
10
|
+
return tasks.filter((task) => task.spec.source?.channel === 'wecom' && task.spec.source.chat_id === chatId
|
|
11
|
+
&& ['stage_review', 'review'].includes(task.state.status) && task.state.assignee === agentId
|
|
12
|
+
&& (task.team || team).agents[agentId]?.wecom_userid === senderId)
|
|
13
|
+
.map((task) => ({ task_id: task.spec.id, expected_seq: task.state.seq, title: task.spec.title,
|
|
14
|
+
commit: task.state.checkpoint.commit, stage: task.state.stage }))
|
|
15
|
+
.sort((a, b) => a.task_id.localeCompare(b.task_id));
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
export function validReviewContext(value) {
|
|
19
|
+
return Array.isArray(value) && value.length <= 100 && new Set(value.map((item) => item?.task_id)).size === value.length
|
|
20
|
+
&& value.every((item) => fields(item, ['task_id', 'expected_seq', 'title', 'commit', 'stage'])
|
|
21
|
+
&& reviewTaskPattern.test(item.task_id || '') && Number.isSafeInteger(item.expected_seq) && item.expected_seq > 0
|
|
22
|
+
&& typeof item.title === 'string' && !!item.title.trim() && item.title.length <= 200
|
|
23
|
+
&& /^[a-f0-9]{40}$/.test(item.commit || '') && Number.isSafeInteger(item.stage) && item.stage >= 0);
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
export function assertReviewIntent(request, review, agentId, team) {
|
|
27
|
+
if (!fields(review, ['task_id', 'expected_seq', 'decision', 'summary']) || !reviewTaskPattern.test(review.task_id || '')
|
|
28
|
+
|| !Number.isSafeInteger(review.expected_seq) || review.expected_seq < 1
|
|
29
|
+
|| !['approved', 'changes_requested'].includes(review.decision)
|
|
30
|
+
|| review.summary !== undefined && (typeof review.summary !== 'string' || !review.summary.trim() || review.summary.length > 2000)
|
|
31
|
+
|| review.decision === 'changes_requested' && !review.summary) throw new Conflict('invalid human review interpretation; rejection requires feedback');
|
|
32
|
+
if (request.spec.sender_id !== team.agents[agentId]?.wecom_userid) throw new Denied('only the authenticated bot owner may review');
|
|
33
|
+
const candidates = request.spec.review_context || [];
|
|
34
|
+
const target = candidates.find((item) => item.task_id === review.task_id && item.expected_seq === review.expected_seq);
|
|
35
|
+
if (!target) throw new Conflict('review target/version was not pending when this human message arrived; ask for a new review message');
|
|
36
|
+
const humanMessages = [request.spec.content, ...request.events.filter((event) => event.type === 'replied').map((event) => event.content)];
|
|
37
|
+
// Keep a person's task selection across clarification turns. Never use the
|
|
38
|
+
// model's question/answer text as evidence of which task the person selected.
|
|
39
|
+
let selected = false; let revisionSelected = false;
|
|
40
|
+
for (let i = humanMessages.length - 1; i >= 0; i--) {
|
|
41
|
+
const selection = reviewSelection(humanMessages[i], candidates, i > 0);
|
|
42
|
+
if (!selected && selection.ids.length) {
|
|
43
|
+
if (selection.ids.length !== 1 || selection.ids[0] !== target.task_id) throw new Conflict('explicit task selection does not match the selected review; clarify conflicting choices');
|
|
44
|
+
selected = true;
|
|
45
|
+
}
|
|
46
|
+
if (!revisionSelected && selection.revisions.length) {
|
|
47
|
+
if (selection.revisions.length !== 1 || selection.revisions[0] !== target.expected_seq) throw new Conflict('explicit task or revision does not match the selected review');
|
|
48
|
+
revisionSelected = true;
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
if (candidates.length > 1 && !selected) throw new Conflict('multiple pending reviews; clarify the task first using its title, list number or ID');
|
|
52
|
+
const decisionText = (value) => {
|
|
53
|
+
for (const item of candidates.toSorted((a, b) => b.title.length - a.title.length)) value = value.replaceAll(item.title, '').replaceAll(item.task_id, '');
|
|
54
|
+
return value.replace(/\bN-[A-F0-9]{32}\b|\bR-[1-9][0-9]*\b/g, '').trim();
|
|
55
|
+
};
|
|
56
|
+
const selectionOnly = (value) => /^(?:(?:我选|选择|选|是)\s*)?(?:第\s*(?:[1-9][0-9]*|[一二三四五六七八九十百]+)\s*[个项条])?(?:这个|那个|任务|这条|那条)?[。!!\s]*$/.test(value);
|
|
57
|
+
let latestIntent = null;
|
|
58
|
+
for (const human of humanMessages.toReversed()) {
|
|
59
|
+
const value = decisionText(human);
|
|
60
|
+
if (selectionOnly(value)) continue;
|
|
61
|
+
latestIntent = explicitReviewDecision(value); break;
|
|
62
|
+
}
|
|
63
|
+
if (review.summary && !humanMessages.some((message) => message.includes(review.summary))) throw new Conflict('review feedback must quote actual human instructions');
|
|
64
|
+
if (review.decision === 'changes_requested') {
|
|
65
|
+
const feedback = review.summary.replace(/先别通过|不要通过|不通过|退回|需要修改|审核|通过|同意|批准/g, '').replace(/[\s,,。!!;;::]/g, '');
|
|
66
|
+
if (feedback.length < 2) throw new Conflict('rejection feedback must quote concrete human instructions; ask for missing feedback');
|
|
67
|
+
}
|
|
68
|
+
if (latestIntent !== review.decision) throw new Conflict('no matching explicit human review decision; clarify instead of guessing');
|
|
69
|
+
return target;
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
// A conservative consent check supplements model interpretation. Unclear or
|
|
73
|
+
// conditional prose becomes a clarification, never an inferred approval.
|
|
74
|
+
export function explicitReviewDecision(content) {
|
|
75
|
+
const value = content.trim().replace(/^@[^\s@]{1,40}\s+/, '');
|
|
76
|
+
if (/[??]/.test(value) || /(?:如果|假如|若是|等.+(?:通过|完成)|通过(?:以后|之后|后)再|不要退回|别退回|不退回)/.test(value)) return null;
|
|
77
|
+
if (/(?:退回|不通过|不批准|不同意|先别通过|不要通过|还不行|需要修改|再改|再补|补充|补一下|改成|改为)/.test(value)) return 'changes_requested';
|
|
78
|
+
if (/(?:先等等|先等一下|等一下|稍等|暂缓|暂不|先不|先别|别急|待确认)/.test(value)) return null;
|
|
79
|
+
if (/(?:^|[,,。!!;;\n])\s*(?:我(?:确认|同意)\s*)?(?:审核|验收)?(?:通过|同意|批准|没问题|可以了|可以继续|就这样|OK|ok)(?=$|[,,。!!;;\s]|了|吧)/.test(value)) return 'approved';
|
|
80
|
+
return null;
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
function reviewSelection(value, candidates, afterClarification) {
|
|
84
|
+
const ids = new Set(value.match(/\bT-[A-Z0-9-]{1,62}\b/g) || []);
|
|
85
|
+
const revisions = [...new Set((value.match(/\bR-[1-9][0-9]*\b/g) || []).map((item) => Number(item.slice(2))))];
|
|
86
|
+
// Match longer titles first so a title prefix isn't a second selection.
|
|
87
|
+
for (const candidate of candidates.toSorted((a, b) => b.title.length - a.title.length)) {
|
|
88
|
+
if (value.includes(candidate.title)) {
|
|
89
|
+
for (const sameTitle of candidates.filter((item) => item.title === candidate.title)) ids.add(sameTitle.task_id);
|
|
90
|
+
value = value.replaceAll(candidate.title, '');
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
for (const match of value.matchAll(/第\s*([1-9][0-9]*|[一二三四五六七八九十百]+)\s*(?:个|项|条)/g)) {
|
|
94
|
+
ids.add(afterClarification ? candidates[reviewIndex(match[1]) - 1]?.task_id || 'invalid-list-selection' : 'unconfirmed-list-selection');
|
|
95
|
+
}
|
|
96
|
+
return { ids: [...ids], revisions };
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
function reviewIndex(token) {
|
|
100
|
+
if (/^[0-9]+$/.test(token)) return Number(token);
|
|
101
|
+
if (token === '一百') return 100;
|
|
102
|
+
const digits = '零一二三四五六七八九';
|
|
103
|
+
if (token.includes('十')) { const [tens, ones] = token.split('十'); return (tens ? digits.indexOf(tens) : 1) * 10 + (ones ? digits.indexOf(ones) : 0); }
|
|
104
|
+
return digits.indexOf(token);
|
|
105
|
+
}
|
|
@@ -22,19 +22,19 @@ export function verifyRecord(record, publicKey) {
|
|
|
22
22
|
return body;
|
|
23
23
|
}
|
|
24
24
|
export function recordHash(record) { const { signature, ...body } = record; return digest(body); }
|
|
25
|
-
export function policyVersion(team, previous, actorId, privateKey, enrollment) {
|
|
25
|
+
export function policyVersion(team, previous, actorId, privateKey, enrollment, invitationReplacement) {
|
|
26
26
|
validateTeam(team);
|
|
27
27
|
const value = { schema_version: 1, version: previous ? previous.version + 1 : 1,
|
|
28
28
|
parent_hash: previous ? recordHash(previous) : null, team_hash: digest(team), team, actor_id: actorId,
|
|
29
|
-
...(enrollment ? { enrollment } : {}) };
|
|
29
|
+
...(enrollment ? { enrollment } : {}), ...(invitationReplacement ? { invitation_replacement: invitationReplacement } : {}) };
|
|
30
30
|
return signRecord(value, privateKey);
|
|
31
31
|
}
|
|
32
32
|
export function validateHistory(records, rootHash) {
|
|
33
33
|
if (!Array.isArray(records) || !records.length || !hashPattern.test(rootHash || '') || recordHash(records[0]) !== rootHash) fail();
|
|
34
|
-
const teams = new Map();
|
|
34
|
+
const teams = new Map(), revokedInvites = new Set(), replacementTargets = new Set(), consumedInvites = new Set();
|
|
35
35
|
let previous;
|
|
36
36
|
for (const record of records) {
|
|
37
|
-
fields(record, ['schema_version', 'version', 'parent_hash', 'team_hash', 'team', 'actor_id', 'enrollment', 'signature']);
|
|
37
|
+
fields(record, ['schema_version', 'version', 'parent_hash', 'team_hash', 'team', 'actor_id', 'enrollment', 'invitation_replacement', 'signature']);
|
|
38
38
|
if (record.schema_version !== 1 || record.version !== (previous?.version || 0) + 1 || record.parent_hash !== (previous ? recordHash(previous) : null)) fail();
|
|
39
39
|
const team = validateTeam(record.team);
|
|
40
40
|
const authority = previous?.team || team;
|
|
@@ -45,13 +45,25 @@ export function validateHistory(records, rootHash) {
|
|
|
45
45
|
if (previous) for (const [id, agent] of Object.entries(authority.agents)) {
|
|
46
46
|
if (!team.agents[id] || team.agents[id].public_key !== agent.public_key || team.agents[id].role !== agent.role || team.agents[id].wecom_userid !== agent.wecom_userid) fail();
|
|
47
47
|
}
|
|
48
|
+
if (record.invitation_replacement) {
|
|
49
|
+
const replacement = record.invitation_replacement;
|
|
50
|
+
fields(replacement, ['old_invite_id', 'new_invite_id']);
|
|
51
|
+
const { old_invite_id: oldId, new_invite_id: newId } = replacement;
|
|
52
|
+
if (!previous || record.enrollment || record.team_hash !== previous.team_hash ||
|
|
53
|
+
!uuidPattern.test(oldId || '') || !uuidPattern.test(newId || '') || oldId === newId ||
|
|
54
|
+
revokedInvites.has(oldId) || revokedInvites.has(newId) || replacementTargets.has(newId) ||
|
|
55
|
+
consumedInvites.has(oldId) || consumedInvites.has(newId)) fail();
|
|
56
|
+
revokedInvites.add(oldId); replacementTargets.add(newId);
|
|
57
|
+
}
|
|
48
58
|
if (record.enrollment) {
|
|
59
|
+
if (revokedInvites.has(record.enrollment.invite_id)) fail();
|
|
60
|
+
consumedInvites.add(record.enrollment.invite_id);
|
|
49
61
|
fields(record.enrollment, ['invite_id', 'request_id']);
|
|
50
62
|
if (!uuidPattern.test(record.enrollment.invite_id || '') || !uuidPattern.test(record.enrollment.request_id || '')) fail();
|
|
51
63
|
}
|
|
52
64
|
teams.set(record.team_hash, team); previous = record;
|
|
53
65
|
}
|
|
54
|
-
return { records, teams, latest: previous, root_hash: rootHash };
|
|
66
|
+
return { records, teams, latest: previous, root_hash: rootHash, revoked_invites: revokedInvites };
|
|
55
67
|
}
|
|
56
68
|
export async function readRegistry(checkout, rootHash) {
|
|
57
69
|
const dir = join(checkout, registryPath, 'versions');
|
|
@@ -76,6 +88,7 @@ export function invitationRecord(input, record, actorId, privateKey) {
|
|
|
76
88
|
return signed;
|
|
77
89
|
}
|
|
78
90
|
export function validateInvitation(invite, history) {
|
|
91
|
+
if (history.revoked_invites?.has(invite?.invite_id)) fail();
|
|
79
92
|
fields(invite, ['schema_version', 'invite_id', 'agent_id', 'capabilities', 'bot_id', 'chat_ids', 'commands_enabled', 'prefer', 'claim_public_key', 'expires_at', 'team_hash', 'actor_id', 'signature', 'owner_userid']);
|
|
80
93
|
const team = history.teams.get(invite.team_hash);
|
|
81
94
|
if (invite.schema_version !== 1 || !uuidPattern.test(invite.invite_id || '') || !idPattern.test(invite.agent_id || '') || !team || team.agents[invite.actor_id]?.role !== 'admin' ||
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { stageReviewRule } from './review.mjs';
|
|
2
|
+
import { reviewContext, assertReviewIntent } from './natural-review.mjs';
|
|
2
3
|
import { randomUUID } from 'node:crypto';
|
|
3
4
|
import { digest } from './config.mjs';
|
|
4
5
|
import { canClaimDesktop, Conflict, Denied, eligible, leaseToken, validRefs } from './protocol.mjs';
|
|
@@ -22,7 +23,7 @@ export class PeerService {
|
|
|
22
23
|
const { chatId, messageId, senderId, content, allowAdmin = false } = input || {};
|
|
23
24
|
if (!plainFields(input, ['chatId', 'messageId', 'senderId', 'content', 'allowAdmin']) || !validText(chatId, 200) || !validText(messageId, 200) || !validText(senderId, 200) || !validText(content, 4000) || typeof allowAdmin !== 'boolean') throw new Conflict('invalid group request');
|
|
24
25
|
const key = inboxMessageKey(this.agentId, chatId, messageId);
|
|
25
|
-
return this.store.transactInbox(key, (requests) => {
|
|
26
|
+
return this.store.transactInbox(key, async (requests) => {
|
|
26
27
|
const owner = this.team.agents[this.agentId]?.wecom_userid === senderId;
|
|
27
28
|
const senderAgents = Object.values(this.team.agents).filter((agent) => agent.wecom_userid === senderId);
|
|
28
29
|
const member = senderAgents.some((agent) => agent.role === 'member');
|
|
@@ -36,8 +37,9 @@ export class PeerService {
|
|
|
36
37
|
if (!referencedId && waiting.length > 1) throw Object.assign(new Conflict('multiple requests await clarification; include the N-request ID in your reply'), { code: 'AMBIGUOUS_INBOX_REPLY' });
|
|
37
38
|
if (parent) return { id: parent.spec.id, event: { type: 'replied', content, message_key: key } };
|
|
38
39
|
const id = `N-${digest([this.agentId, key]).slice(0, 32).toUpperCase()}`;
|
|
40
|
+
const context = reviewContext(await this.store.listLocal(), this.agentId, senderId, chatId, this.team);
|
|
39
41
|
return { id, spec: { schema_version: 1, id, repo: this.team.repo, target_agent: this.agentId, chat_id: chatId,
|
|
40
|
-
sender_id: senderId, content, message_key: key, admin_dispatch: !!admin }, event: { type: 'received' } };
|
|
42
|
+
sender_id: senderId, content, message_key: key, admin_dispatch: !!admin, ...(context.length ? { review_context: context } : {}) }, event: { type: 'received' } };
|
|
41
43
|
}, { replay: (request) => {
|
|
42
44
|
const event = request.events.find((item) => item.idempotency_key === key);
|
|
43
45
|
const original = event.type === 'received' ? request.spec.content : event.content;
|
|
@@ -50,18 +52,33 @@ export class PeerService {
|
|
|
50
52
|
} });
|
|
51
53
|
}
|
|
52
54
|
async resolveRequest(id, expectedSeq, resolution, { defaultWorkflow } = {}) {
|
|
53
|
-
const { action, response, task: input } = resolution || {};
|
|
54
|
-
if (
|
|
55
|
+
const { action, response, task: input, review } = resolution || {};
|
|
56
|
+
if (action === 'review' && response !== undefined) throw new Conflict('review confirmation is generated from the committed decision');
|
|
57
|
+
if (!plainFields(resolution, ['action', 'response', 'task', 'review']) || response !== undefined && !validText(response, 2000)) throw new Conflict('invalid request resolution');
|
|
55
58
|
if (!inboxIdPattern.test(id || '') || !Number.isSafeInteger(expectedSeq) || expectedSeq < 1) throw new Conflict('provide request ID and latest expected_seq');
|
|
56
|
-
if (!['create_task', 'clarify', 'answer'].includes(action)) throw new Conflict('unsupported request resolution');
|
|
59
|
+
if (!['create_task', 'clarify', 'answer', 'review'].includes(action)) throw new Conflict('unsupported request resolution');
|
|
60
|
+
if (action !== 'review' && review !== undefined) throw new Conflict('review payload requires review action');
|
|
61
|
+
if (action === 'review' && (input !== undefined || !review)) throw new Conflict('review requires a human interpretation without a new task');
|
|
57
62
|
if (action === 'create_task') {
|
|
58
63
|
if (!plainFields(input, ['title', 'description', 'acceptance', 'workflow']) || !validText(input.title, 200) || input.description !== undefined && (typeof input.description !== 'string' || input.description.length > 4000) || !validList(input.acceptance, 32, 1000) || input.workflow !== undefined && !validList(input.workflow, 16, 64)) throw new Conflict('invalid structured task');
|
|
59
|
-
} else if (input !== undefined || !validText(response, 2000)) throw new Conflict('answer and clarification require a response without a task');
|
|
64
|
+
} else if (action !== 'review' && (input !== undefined || !validText(response, 2000))) throw new Conflict('answer and clarification require a response without a task');
|
|
60
65
|
const key = `inbox-resolve:${id}:${expectedSeq}`;
|
|
61
|
-
const result = await this.store.transactInbox(key, (requests) => {
|
|
66
|
+
const result = await this.store.transactInbox(key, async (requests) => {
|
|
62
67
|
const request = requests.find((item) => item.spec.id === id);
|
|
63
68
|
if (!request || request.spec.target_agent !== this.agentId) throw new Denied('only the receiving bot identity may interpret this request');
|
|
64
69
|
if (request.state.status !== 'pending' || request.state.seq !== expectedSeq) throw new Conflict('request changed or is no longer pending');
|
|
70
|
+
if (action === 'review') {
|
|
71
|
+
assertReviewIntent(request, review, this.agentId, this.team);
|
|
72
|
+
const task = await this.store.readLocal(review.task_id);
|
|
73
|
+
const frozen = request.spec.review_context.find((item) => item.task_id === review.task_id);
|
|
74
|
+
if (task.state.seq !== review.expected_seq || task.state.checkpoint?.commit !== frozen.commit
|
|
75
|
+
|| !['stage_review', 'review'].includes(task.state.status) || task.state.assignee !== this.agentId
|
|
76
|
+
|| task.spec.source?.channel !== 'wecom' || task.spec.source.chat_id !== request.spec.chat_id) throw new Conflict('review changed; ask the human to review the latest result in a new message');
|
|
77
|
+
if ((task.team || this.team).agents[this.agentId]?.wecom_userid !== request.spec.sender_id) throw new Denied('sender is not the pinned human reviewer');
|
|
78
|
+
return { id, taskReview: review, event: { type: 'reviewed', task_id: review.task_id, review_seq: review.expected_seq + 1,
|
|
79
|
+
decision: review.decision, ...(review.summary ? { summary: review.summary } : {}),
|
|
80
|
+
response: `任务「${task.spec.title}」${review.decision === 'approved' ? '审核通过。' : `已退回修改:${review.summary}`}(${review.task_id})` } };
|
|
81
|
+
}
|
|
65
82
|
if (action !== 'create_task') return { id, event: { type: action === 'clarify' ? 'clarified' : 'answered', response } };
|
|
66
83
|
if (!input) throw new Conflict('a structured task is required');
|
|
67
84
|
const taskSpec = { schema_version: 2, id: inboxTaskId(id), repo: this.team.repo, title: input.title,
|
|
@@ -70,8 +87,12 @@ export class PeerService {
|
|
|
70
87
|
return { id, taskSpec, event: { type: 'task_created', task_id: taskSpec.id, task_hash: digest(taskSpec), response: response || `已创建任务 ${taskSpec.id}:${taskSpec.title}。分工:${taskSpec.workflow.join(' → ')};等待绑定对话认领。` } };
|
|
71
88
|
}, { replay: async (request) => {
|
|
72
89
|
const event = request.events.find((item) => item.idempotency_key === key);
|
|
73
|
-
const expectedType = { create_task: 'task_created', clarify: 'clarified', answer: 'answered' }[action];
|
|
90
|
+
const expectedType = { create_task: 'task_created', clarify: 'clarified', answer: 'answered', review: 'reviewed' }[action];
|
|
74
91
|
if (event.type !== expectedType || response !== undefined && event.response !== response) throw new Conflict('request already resolved with different content');
|
|
92
|
+
if (action === 'review') {
|
|
93
|
+
assertReviewIntent(request, review, this.agentId, this.team);
|
|
94
|
+
if (event.task_id !== review.task_id || event.review_seq !== review.expected_seq + 1 || event.decision !== review.decision || event.summary !== review.summary) throw new Conflict('request already resolved with a different review');
|
|
95
|
+
}
|
|
75
96
|
if (action === 'create_task') {
|
|
76
97
|
const accepted = await this.store.readLocal(event.task_id);
|
|
77
98
|
const taskSpec = { schema_version: 2, id: inboxTaskId(id), repo: this.team.repo, title: input.title, description: input.description ?? '', base_branch: 'main', acceptance: input.acceptance, workflow: input.workflow || accepted.spec.workflow, execution_mode: 'desktop_poll', source: { channel: 'wecom', chat_id: request.spec.chat_id } };
|
|
@@ -80,7 +101,8 @@ export class PeerService {
|
|
|
80
101
|
} });
|
|
81
102
|
if (result.state.task_id) await this.publish(await this.read(result.state.task_id), 'created');
|
|
82
103
|
else await this.publish(result, 'inbox_response');
|
|
83
|
-
|
|
104
|
+
const taskId = result.state.task_id || result.state.review_task_id;
|
|
105
|
+
return { request: result, ...(taskId ? { task: await this.read(taskId) } : {}) };
|
|
84
106
|
}
|
|
85
107
|
async create(input, idempotencyKey = randomUUID()) {
|
|
86
108
|
const id = input.id || `T-${digest(`${this.agentId}:${idempotencyKey}`).slice(0, 32).toUpperCase()}`;
|
package/packages/peer/store.mjs
CHANGED
|
@@ -132,6 +132,16 @@ export class PeerGitStore {
|
|
|
132
132
|
const task = await this.readLocal(state.task_id); const resolution = events.at(-1);
|
|
133
133
|
if (digest(task.spec) !== resolution.task_hash || task.events[0].actor_id !== spec.target_agent || task.spec.execution_mode !== 'desktop_poll' || task.spec.source?.chat_id !== spec.chat_id || task.events[0].idempotency_key !== `inbox-task:${id}`) throw new Error('inbox resolution does not match its signed task');
|
|
134
134
|
}
|
|
135
|
+
if (state.review_task_id) {
|
|
136
|
+
const resolution = events.at(-1); const task = await this.readLocal(state.review_task_id);
|
|
137
|
+
const reviewed = task.events[resolution.review_seq - 1];
|
|
138
|
+
const before = reducePeerTask(task.spec, task.events.slice(0, resolution.review_seq - 1), task.team || this.team);
|
|
139
|
+
const frozen = spec.review_context.find((item) => item.task_id === resolution.task_id);
|
|
140
|
+
if (!reviewed || reviewed.type !== 'reviewed' || reviewed.actor_id !== spec.target_agent || reviewed.decision !== resolution.decision
|
|
141
|
+
|| reviewed.summary !== resolution.summary || reviewed.idempotency_key !== `inbox-review:${id}:${resolution.seq - 1}`
|
|
142
|
+
|| digest(reviewed) !== resolution.task_event_hash || task.spec.source?.channel !== 'wecom' || task.spec.source.chat_id !== spec.chat_id
|
|
143
|
+
|| before.checkpoint?.commit !== frozen.commit || before.stage !== frozen.stage || task.spec.title !== frozen.title || (task.team || this.team).agents[spec.target_agent]?.wecom_userid !== spec.sender_id) throw new Error('inbox review does not match its signed task decision');
|
|
144
|
+
}
|
|
135
145
|
return { spec, events, state, team };
|
|
136
146
|
}
|
|
137
147
|
async listInboxLocal() {
|
|
@@ -142,8 +152,8 @@ export class PeerGitStore {
|
|
|
142
152
|
return Promise.all(entries.sort().map((id) => this.readInboxLocal(id)));
|
|
143
153
|
}
|
|
144
154
|
async listInbox() { return this.locked(async () => { await this.sync(); return this.listInboxLocal(); }); }
|
|
145
|
-
// One remote Git CAS publishes
|
|
146
|
-
// Retries cannot leave a task
|
|
155
|
+
// One remote Git CAS publishes the task creation/review and its inbox resolution.
|
|
156
|
+
// Retries cannot leave a task mutation with a still-pending request.
|
|
147
157
|
async transactInbox(idempotencyKey, build, { replay = () => {} } = {}) {
|
|
148
158
|
if (this.enrollmentOnly) throw new Error('enrollment client cannot write inbox requests');
|
|
149
159
|
if (typeof idempotencyKey !== 'string' || !idempotencyKey || idempotencyKey.length > 300) throw new Error('invalid inbox idempotency key');
|
|
@@ -162,12 +172,27 @@ export class PeerGitStore {
|
|
|
162
172
|
if (!spec || spec.id !== built.id || spec.target_agent !== this.agentId) throw new Error('inbox transaction identity mismatch');
|
|
163
173
|
const now = new Date(this.clock()).toISOString();
|
|
164
174
|
const team = current?.team || this.team;
|
|
175
|
+
let reviewedTask; let taskEvent;
|
|
176
|
+
if (built.taskReview) {
|
|
177
|
+
const review = built.taskReview; reviewedTask = await this.readLocal(review.task_id);
|
|
178
|
+
if (reviewedTask.state.seq !== review.expected_seq) throw new Error('review version changed');
|
|
179
|
+
taskEvent = makeSigner(reviewedTask.team || this.team, this.agentId, this.privateKey)({ schema_version: 2, task_id: review.task_id,
|
|
180
|
+
seq: review.expected_seq + 1, event_id: randomUUID(), actor_id: this.agentId, occurred_at: now,
|
|
181
|
+
idempotency_key: `inbox-review:${spec.id}:${current.state.seq}`, type: 'reviewed', decision: review.decision,
|
|
182
|
+
...(review.summary ? { summary: review.summary } : {}) });
|
|
183
|
+
reducePeerTask(reviewedTask.spec, [...reviewedTask.events, taskEvent], reviewedTask.team || this.team);
|
|
184
|
+
}
|
|
165
185
|
const event = makeSigner(team, this.agentId, this.privateKey)({ schema_version: 1, request_id: spec.id,
|
|
166
186
|
seq: (current?.state.seq || 0) + 1, event_id: randomUUID(), actor_id: this.agentId, occurred_at: now,
|
|
167
|
-
idempotency_key: idempotencyKey, ...built.event, ...(current ? {} : { spec_hash: digest(spec) }) });
|
|
187
|
+
idempotency_key: idempotencyKey, ...built.event, ...(taskEvent ? { task_event_hash: digest(taskEvent) } : {}), ...(current ? {} : { spec_hash: digest(spec) }) });
|
|
168
188
|
reduceInbox(spec, [...(current?.events || []), event], team);
|
|
169
189
|
if ((event.type === 'task_created') !== !!built.taskSpec) throw new Error('inbox task must be published with its resolution');
|
|
190
|
+
if ((event.type === 'reviewed') !== !!built.taskReview) throw new Error('inbox review must be published with its task decision');
|
|
170
191
|
const paths = [this.inboxRoot(spec.id)];
|
|
192
|
+
if (reviewedTask) {
|
|
193
|
+
const taskRoot = this.root(reviewedTask.spec.id); paths.push(taskRoot);
|
|
194
|
+
await writeFile(join(taskRoot, 'events', `${String(taskEvent.seq).padStart(6, '0')}-${taskEvent.event_id}.json`), JSON.stringify(taskEvent, null, 2) + '\n', { flag: 'wx' });
|
|
195
|
+
}
|
|
171
196
|
if (built.taskSpec) {
|
|
172
197
|
const taskSpec = built.taskSpec;
|
|
173
198
|
const taskEvent = this.sign({ schema_version: 2, task_id: taskSpec.id, seq: 1, event_id: randomUUID(), actor_id: this.agentId,
|
|
@@ -61,7 +61,8 @@
|
|
|
61
61
|
"clarified",
|
|
62
62
|
"replied",
|
|
63
63
|
"task_created",
|
|
64
|
-
"answered"
|
|
64
|
+
"answered",
|
|
65
|
+
"reviewed"
|
|
65
66
|
]
|
|
66
67
|
},
|
|
67
68
|
"spec_hash": {
|
|
@@ -88,11 +89,31 @@
|
|
|
88
89
|
},
|
|
89
90
|
"task_id": {
|
|
90
91
|
"type": "string",
|
|
91
|
-
"pattern": "^T-[A-
|
|
92
|
+
"pattern": "^T-[A-Z0-9-]{1,62}$"
|
|
92
93
|
},
|
|
93
94
|
"task_hash": {
|
|
94
95
|
"type": "string",
|
|
95
96
|
"pattern": "^[a-f0-9]{64}$"
|
|
97
|
+
},
|
|
98
|
+
"review_seq": {
|
|
99
|
+
"type": "integer",
|
|
100
|
+
"minimum": 2
|
|
101
|
+
},
|
|
102
|
+
"decision": {
|
|
103
|
+
"enum": [
|
|
104
|
+
"approved",
|
|
105
|
+
"changes_requested"
|
|
106
|
+
]
|
|
107
|
+
},
|
|
108
|
+
"summary": {
|
|
109
|
+
"type": "string",
|
|
110
|
+
"minLength": 1,
|
|
111
|
+
"maxLength": 2000,
|
|
112
|
+
"pattern": "\\S"
|
|
113
|
+
},
|
|
114
|
+
"task_event_hash": {
|
|
115
|
+
"type": "string",
|
|
116
|
+
"pattern": "^[a-f0-9]{64}$"
|
|
96
117
|
}
|
|
97
118
|
},
|
|
98
119
|
"oneOf": [
|
|
@@ -131,6 +152,26 @@
|
|
|
131
152
|
"required": [
|
|
132
153
|
"task_id"
|
|
133
154
|
]
|
|
155
|
+
},
|
|
156
|
+
{
|
|
157
|
+
"required": [
|
|
158
|
+
"review_seq"
|
|
159
|
+
]
|
|
160
|
+
},
|
|
161
|
+
{
|
|
162
|
+
"required": [
|
|
163
|
+
"decision"
|
|
164
|
+
]
|
|
165
|
+
},
|
|
166
|
+
{
|
|
167
|
+
"required": [
|
|
168
|
+
"summary"
|
|
169
|
+
]
|
|
170
|
+
},
|
|
171
|
+
{
|
|
172
|
+
"required": [
|
|
173
|
+
"task_event_hash"
|
|
174
|
+
]
|
|
134
175
|
}
|
|
135
176
|
]
|
|
136
177
|
}
|
|
@@ -170,6 +211,26 @@
|
|
|
170
211
|
"required": [
|
|
171
212
|
"task_id"
|
|
172
213
|
]
|
|
214
|
+
},
|
|
215
|
+
{
|
|
216
|
+
"required": [
|
|
217
|
+
"review_seq"
|
|
218
|
+
]
|
|
219
|
+
},
|
|
220
|
+
{
|
|
221
|
+
"required": [
|
|
222
|
+
"decision"
|
|
223
|
+
]
|
|
224
|
+
},
|
|
225
|
+
{
|
|
226
|
+
"required": [
|
|
227
|
+
"summary"
|
|
228
|
+
]
|
|
229
|
+
},
|
|
230
|
+
{
|
|
231
|
+
"required": [
|
|
232
|
+
"task_event_hash"
|
|
233
|
+
]
|
|
173
234
|
}
|
|
174
235
|
]
|
|
175
236
|
}
|
|
@@ -205,6 +266,26 @@
|
|
|
205
266
|
"required": [
|
|
206
267
|
"task_id"
|
|
207
268
|
]
|
|
269
|
+
},
|
|
270
|
+
{
|
|
271
|
+
"required": [
|
|
272
|
+
"review_seq"
|
|
273
|
+
]
|
|
274
|
+
},
|
|
275
|
+
{
|
|
276
|
+
"required": [
|
|
277
|
+
"decision"
|
|
278
|
+
]
|
|
279
|
+
},
|
|
280
|
+
{
|
|
281
|
+
"required": [
|
|
282
|
+
"summary"
|
|
283
|
+
]
|
|
284
|
+
},
|
|
285
|
+
{
|
|
286
|
+
"required": [
|
|
287
|
+
"task_event_hash"
|
|
288
|
+
]
|
|
208
289
|
}
|
|
209
290
|
]
|
|
210
291
|
}
|
|
@@ -213,6 +294,9 @@
|
|
|
213
294
|
"properties": {
|
|
214
295
|
"type": {
|
|
215
296
|
"const": "task_created"
|
|
297
|
+
},
|
|
298
|
+
"task_id": {
|
|
299
|
+
"pattern": "^T-[A-F0-9]{32}$"
|
|
216
300
|
}
|
|
217
301
|
},
|
|
218
302
|
"required": [
|
|
@@ -236,6 +320,26 @@
|
|
|
236
320
|
"required": [
|
|
237
321
|
"spec_hash"
|
|
238
322
|
]
|
|
323
|
+
},
|
|
324
|
+
{
|
|
325
|
+
"required": [
|
|
326
|
+
"review_seq"
|
|
327
|
+
]
|
|
328
|
+
},
|
|
329
|
+
{
|
|
330
|
+
"required": [
|
|
331
|
+
"decision"
|
|
332
|
+
]
|
|
333
|
+
},
|
|
334
|
+
{
|
|
335
|
+
"required": [
|
|
336
|
+
"summary"
|
|
337
|
+
]
|
|
338
|
+
},
|
|
339
|
+
{
|
|
340
|
+
"required": [
|
|
341
|
+
"task_event_hash"
|
|
342
|
+
]
|
|
239
343
|
}
|
|
240
344
|
]
|
|
241
345
|
}
|
|
@@ -275,10 +379,84 @@
|
|
|
275
379
|
"required": [
|
|
276
380
|
"task_id"
|
|
277
381
|
]
|
|
382
|
+
},
|
|
383
|
+
{
|
|
384
|
+
"required": [
|
|
385
|
+
"review_seq"
|
|
386
|
+
]
|
|
387
|
+
},
|
|
388
|
+
{
|
|
389
|
+
"required": [
|
|
390
|
+
"decision"
|
|
391
|
+
]
|
|
392
|
+
},
|
|
393
|
+
{
|
|
394
|
+
"required": [
|
|
395
|
+
"summary"
|
|
396
|
+
]
|
|
397
|
+
},
|
|
398
|
+
{
|
|
399
|
+
"required": [
|
|
400
|
+
"task_event_hash"
|
|
401
|
+
]
|
|
278
402
|
}
|
|
279
403
|
]
|
|
280
404
|
}
|
|
405
|
+
},
|
|
406
|
+
{
|
|
407
|
+
"properties": {
|
|
408
|
+
"type": {
|
|
409
|
+
"const": "reviewed"
|
|
410
|
+
}
|
|
411
|
+
},
|
|
412
|
+
"required": [
|
|
413
|
+
"task_id",
|
|
414
|
+
"review_seq",
|
|
415
|
+
"decision",
|
|
416
|
+
"task_event_hash",
|
|
417
|
+
"response"
|
|
418
|
+
],
|
|
419
|
+
"not": {
|
|
420
|
+
"anyOf": [
|
|
421
|
+
{
|
|
422
|
+
"required": [
|
|
423
|
+
"content"
|
|
424
|
+
]
|
|
425
|
+
},
|
|
426
|
+
{
|
|
427
|
+
"required": [
|
|
428
|
+
"message_key"
|
|
429
|
+
]
|
|
430
|
+
},
|
|
431
|
+
{
|
|
432
|
+
"required": [
|
|
433
|
+
"spec_hash"
|
|
434
|
+
]
|
|
435
|
+
},
|
|
436
|
+
{
|
|
437
|
+
"required": [
|
|
438
|
+
"task_hash"
|
|
439
|
+
]
|
|
440
|
+
}
|
|
441
|
+
]
|
|
442
|
+
},
|
|
443
|
+
"allOf": [
|
|
444
|
+
{
|
|
445
|
+
"if": {
|
|
446
|
+
"properties": {
|
|
447
|
+
"decision": {
|
|
448
|
+
"const": "changes_requested"
|
|
449
|
+
}
|
|
450
|
+
}
|
|
451
|
+
},
|
|
452
|
+
"then": {
|
|
453
|
+
"required": [
|
|
454
|
+
"summary"
|
|
455
|
+
]
|
|
456
|
+
}
|
|
457
|
+
}
|
|
458
|
+
]
|
|
281
459
|
}
|
|
282
460
|
],
|
|
283
|
-
"$comment": "Runtime also verifies Ed25519 signatures and historical team hash, receiving-agent authorship, exact sequence, unique IDs and idempotency keys, nondecreasing UTC timestamps, signed spec digest and allowed transitions. received opens pending; clarified waits for a reply; replied returns to pending; task_created and answered resolve pending. A task_created event and its desktop_poll task commit atomically; runtime validates the exact task digest, deterministic task ID, original group, creator and creation key. Replies are scoped to the same bot, group and sender."
|
|
461
|
+
"$comment": "Runtime also verifies Ed25519 signatures and historical team hash, receiving-agent authorship, exact sequence, unique IDs and idempotency keys, nondecreasing UTC timestamps, signed spec digest and allowed transitions. received opens pending; clarified waits for a reply; replied returns to pending; task_created and answered resolve pending. A task_created event and its desktop_poll task commit atomically; runtime validates the exact task digest, deterministic task ID, original group, creator and creation key. Replies are scoped to the same bot, group and sender. reviewed resolves a pending request and atomically appends the matching task reviewed event. Runtime verifies frozen context, explicit owner consent, pinned reviewer, unchanged code revision and the signed task-event digest; task_created keeps the deterministic inbox-derived task ID constraint."
|
|
284
462
|
}
|