pi2web 0.2.1 → 0.2.6
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 +44 -5
- package/dist/activity.js +178 -0
- package/dist/agent-feed.js +133 -0
- package/dist/agent-templates.js +144 -0
- package/dist/agents.js +518 -141
- package/dist/auth.js +3 -1
- package/dist/collab/baseline.js +84 -0
- package/dist/collab/dispatcher.js +45 -10
- package/dist/collab/hub.js +1392 -384
- package/dist/collab/pi-extension.js +322 -65
- package/dist/collab/review-flow.js +197 -48
- package/dist/collab/routes.js +94 -23
- package/dist/collab/schemas.js +69 -13
- package/dist/collab/scoring-flow.js +145 -39
- package/dist/collab/similarity.js +15 -0
- package/dist/collab/store.js +467 -112
- package/dist/collab/types.js +94 -10
- package/dist/collab/validate.js +62 -25
- package/dist/drafts.js +26 -6
- package/dist/http/agent-routes.js +196 -0
- package/dist/http/login-limiter.js +53 -0
- package/dist/http/mail-settings-routes.js +53 -0
- package/dist/http/system-prompt-routes.js +52 -0
- package/dist/http/utils.js +43 -0
- package/dist/http/web-assets.js +63 -0
- package/dist/http/workspace-routes.js +58 -0
- package/dist/mail-notifier.js +63 -17
- package/dist/main.js +61 -16
- package/dist/mcp.js +24 -5
- package/dist/metadata-store.js +190 -56
- package/dist/paths.js +10 -0
- package/dist/sdk-backend.js +221 -45
- package/dist/server.js +444 -620
- package/dist/session-namer.js +66 -17
- package/dist/terminals.js +94 -27
- package/dist/timeline.js +185 -0
- package/dist/voice.js +153 -35
- package/dist/workspaces.js +91 -26
- package/dist/ws/agent-socket.js +256 -0
- package/dist/ws/terminal-socket.js +46 -0
- package/npm-shrinkwrap.json +3685 -0
- package/package.json +11 -1
- package/packages/protocol/openapi.yaml +6 -2
- package/packages/protocol/websocket.schema.json +58 -15
- package/web/activity-view.js +121 -0
- package/web/app.js +3098 -743
- package/web/backend-state.js +59 -27
- package/web/collab.js +668 -255
- package/web/hid-keyboard.js +173 -140
- package/web/index.html +4 -1
- package/web/styles.css +14 -0
|
@@ -12,7 +12,9 @@ export function currentIssueVotes(snapshot) {
|
|
|
12
12
|
if (vote.round !== snapshot.round)
|
|
13
13
|
continue;
|
|
14
14
|
const key = `${vote.issueId}:${vote.participantId}`, previous = latest.get(key);
|
|
15
|
-
if (!previous ||
|
|
15
|
+
if (!previous ||
|
|
16
|
+
vote.consensusRound > previous.consensusRound ||
|
|
17
|
+
(vote.consensusRound === previous.consensusRound && vote.createdAt > previous.createdAt))
|
|
16
18
|
latest.set(key, vote);
|
|
17
19
|
}
|
|
18
20
|
return [...latest.values()];
|
|
@@ -26,16 +28,36 @@ export function issueConsensus(snapshot, options = {}) {
|
|
|
26
28
|
const latest = new Map();
|
|
27
29
|
for (const vote of issueVotes) {
|
|
28
30
|
const previous = latest.get(vote.participantId);
|
|
29
|
-
if (!previous ||
|
|
31
|
+
if (!previous ||
|
|
32
|
+
vote.consensusRound > previous.consensusRound ||
|
|
33
|
+
(vote.consensusRound === previous.consensusRound && vote.createdAt > previous.createdAt))
|
|
30
34
|
latest.set(vote.participantId, vote);
|
|
31
35
|
}
|
|
32
36
|
const positions = panel.map(reviewer => reviewer.participantId === issue.reporterId
|
|
33
37
|
? { participantId: reviewer.participantId, stance: 'approve', implicit: true }
|
|
34
|
-
: {
|
|
35
|
-
|
|
38
|
+
: {
|
|
39
|
+
participantId: reviewer.participantId,
|
|
40
|
+
stance: latest.get(reviewer.participantId)?.stance,
|
|
41
|
+
rationale: latest.get(reviewer.participantId)?.rationale,
|
|
42
|
+
implicit: false
|
|
43
|
+
});
|
|
44
|
+
return {
|
|
45
|
+
issueId: issue.issueId,
|
|
46
|
+
positions,
|
|
47
|
+
missing: positions.filter(position => !position.stance).map(position => position.participantId),
|
|
48
|
+
rejecters: positions
|
|
49
|
+
.filter(position => position.stance === 'reject')
|
|
50
|
+
.map(position => position.participantId),
|
|
51
|
+
supporters: positions
|
|
52
|
+
.filter(position => position.stance === 'approve')
|
|
53
|
+
.map(position => position.participantId),
|
|
54
|
+
unanimous: positions.length > 0 && positions.every(position => position.stance === 'approve')
|
|
55
|
+
};
|
|
36
56
|
});
|
|
37
57
|
}
|
|
38
|
-
export const contestedIssueIds = (snapshot) => issueConsensus(snapshot)
|
|
58
|
+
export const contestedIssueIds = (snapshot) => issueConsensus(snapshot)
|
|
59
|
+
.filter(entry => entry.rejecters.length > 0)
|
|
60
|
+
.map(entry => entry.issueId);
|
|
39
61
|
/** Latest ballot per reviewer for one issue as it stood at the end of consensus round `consensusRound`. */
|
|
40
62
|
function stancesAt(snapshot, issueId, consensusRound) {
|
|
41
63
|
const latest = new Map();
|
|
@@ -43,12 +65,19 @@ function stancesAt(snapshot, issueId, consensusRound) {
|
|
|
43
65
|
if (vote.issueId !== issueId || vote.round !== snapshot.round || vote.consensusRound > consensusRound)
|
|
44
66
|
continue;
|
|
45
67
|
const previous = latest.get(vote.participantId);
|
|
46
|
-
if (!previous ||
|
|
68
|
+
if (!previous ||
|
|
69
|
+
vote.consensusRound > previous.consensusRound ||
|
|
70
|
+
(vote.consensusRound === previous.consensusRound && vote.createdAt > previous.createdAt))
|
|
47
71
|
latest.set(vote.participantId, vote);
|
|
48
72
|
}
|
|
49
|
-
return [...latest.entries()]
|
|
73
|
+
return [...latest.entries()]
|
|
74
|
+
.map(([participantId, vote]) => `${participantId}:${vote.stance}`)
|
|
75
|
+
.sort()
|
|
76
|
+
.join('|');
|
|
50
77
|
}
|
|
51
|
-
const lastVotedRound = (snapshot, issueId) => Math.max(-1, ...(snapshot.issueVotes ?? [])
|
|
78
|
+
const lastVotedRound = (snapshot, issueId) => Math.max(-1, ...(snapshot.issueVotes ?? [])
|
|
79
|
+
.filter(vote => vote.issueId === issueId && vote.round === snapshot.round)
|
|
80
|
+
.map(vote => vote.consensusRound));
|
|
52
81
|
/**
|
|
53
82
|
* Discussion rounds are only worth their tokens while they still move somebody. An issue whose ballot has been
|
|
54
83
|
* identical for two consecutive reconsiderations is settled in fact, so the panel stops re-arguing it instead of
|
|
@@ -62,7 +91,8 @@ export function staleContestedIssueIds(snapshot) {
|
|
|
62
91
|
if (voted < 2)
|
|
63
92
|
return false;
|
|
64
93
|
const current = stancesAt(snapshot, issueId, voted);
|
|
65
|
-
return current === stancesAt(snapshot, issueId, voted - 1) &&
|
|
94
|
+
return (current === stancesAt(snapshot, issueId, voted - 1) &&
|
|
95
|
+
current === stancesAt(snapshot, issueId, voted - 2));
|
|
66
96
|
});
|
|
67
97
|
}
|
|
68
98
|
/** Contested findings the panel is still allowed to spend a round on. */
|
|
@@ -81,26 +111,49 @@ const MAJOR_SEVERITIES = ['blocker', 'critical', 'major'];
|
|
|
81
111
|
*/
|
|
82
112
|
export function resolveContestedIssues(snapshot) {
|
|
83
113
|
const panel = reviewers(snapshot);
|
|
84
|
-
return issueConsensus(snapshot)
|
|
114
|
+
return issueConsensus(snapshot)
|
|
115
|
+
.filter(entry => entry.rejecters.length > 0)
|
|
116
|
+
.map(entry => {
|
|
85
117
|
const issue = snapshot.issues.find(candidate => candidate.issueId === entry.issueId);
|
|
86
118
|
const severity = issue?.severity ?? 'major';
|
|
87
|
-
const voters = panel
|
|
88
|
-
|
|
119
|
+
const voters = panel
|
|
120
|
+
.filter(reviewer => reviewer.participantId !== issue?.reporterId)
|
|
121
|
+
.map(reviewer => reviewer.participantId);
|
|
122
|
+
const base = {
|
|
123
|
+
issueId: entry.issueId,
|
|
124
|
+
severity,
|
|
125
|
+
supporters: entry.supporters,
|
|
126
|
+
rejecters: entry.rejecters
|
|
127
|
+
};
|
|
89
128
|
// The reporter is the only supporter left: the panel says this is not a defect, and it had every discussion
|
|
90
129
|
// round to withdraw it or convince somebody. Only a claimed blocker still buys a human's attention. Two
|
|
91
130
|
// dissenters minimum: on a two-seat panel "everyone else" is one reviewer, which is a tie, not a verdict.
|
|
92
131
|
if (voters.length > 1 && voters.every(participantId => entry.rejecters.includes(participantId)))
|
|
93
|
-
return {
|
|
132
|
+
return {
|
|
133
|
+
...base,
|
|
134
|
+
disposition: HUMAN_REVIEW_SEVERITIES.includes(severity)
|
|
135
|
+
? 'escalate'
|
|
136
|
+
: 'panel_rejected',
|
|
137
|
+
reason: 'panel_unanimously_rejected'
|
|
138
|
+
};
|
|
94
139
|
if (MAJOR_SEVERITIES.includes(severity))
|
|
95
140
|
return { ...base, disposition: 'escalate', reason: 'split_panel_on_a_major_finding' };
|
|
96
141
|
if (entry.rejecters.length > entry.supporters.length)
|
|
97
142
|
return { ...base, disposition: 'majority_rejected', reason: 'panel_majority_rejected' };
|
|
98
143
|
// A tie keeps the finding: reporting it costs a line in the report, dropping a real defect costs a release.
|
|
99
|
-
return {
|
|
144
|
+
return {
|
|
145
|
+
...base,
|
|
146
|
+
disposition: 'majority_confirmed',
|
|
147
|
+
reason: entry.supporters.length === entry.rejecters.length
|
|
148
|
+
? 'tie_kept_as_a_recorded_dispute'
|
|
149
|
+
: 'panel_majority_confirmed'
|
|
150
|
+
};
|
|
100
151
|
});
|
|
101
152
|
}
|
|
102
153
|
const currentMergeProposals = (snapshot) => (snapshot.mergeProposals ?? []).filter(proposal => proposal.round === snapshot.round);
|
|
103
|
-
const collectedThisRound = (snapshot) => new Set(snapshot.completions
|
|
154
|
+
const collectedThisRound = (snapshot) => new Set(snapshot.completions
|
|
155
|
+
.filter(entry => entry.phase === 'collecting' && entry.round === snapshot.round)
|
|
156
|
+
.map(entry => entry.participantId));
|
|
104
157
|
/** Reviewers who already closed their own blind review but still owe votes on other reviewers' already-filed issues. */
|
|
105
158
|
export function pendingCrossVoters(snapshot) {
|
|
106
159
|
if (snapshot.phase !== 'collecting' || !snapshot.policy.consensusReview)
|
|
@@ -108,9 +161,17 @@ export function pendingCrossVoters(snapshot) {
|
|
|
108
161
|
const collected = collectedThisRound(snapshot);
|
|
109
162
|
return unique(issueConsensus(snapshot).flatMap(entry => entry.missing)).filter(participantId => collected.has(participantId));
|
|
110
163
|
}
|
|
111
|
-
export const owedIssueVoteIds = (snapshot, participantId) => issueConsensus(snapshot)
|
|
164
|
+
export const owedIssueVoteIds = (snapshot, participantId) => issueConsensus(snapshot)
|
|
165
|
+
.filter(entry => entry.missing.includes(participantId))
|
|
166
|
+
.map(entry => entry.issueId);
|
|
112
167
|
/** Statuses nobody may move again without a human: rulings are final. */
|
|
113
|
-
export const FINAL_ISSUE_STATUSES = [
|
|
168
|
+
export const FINAL_ISSUE_STATUSES = [
|
|
169
|
+
'human_ruled',
|
|
170
|
+
'wontfix',
|
|
171
|
+
'closed',
|
|
172
|
+
'duplicate',
|
|
173
|
+
'withdrawn'
|
|
174
|
+
];
|
|
114
175
|
export const isOpenIssue = (issue) => OPEN_ISSUE_STATUSES.includes(issue.status);
|
|
115
176
|
export function assertCapability(participant, capability) {
|
|
116
177
|
if (participant.state === 'budget_exhausted')
|
|
@@ -134,29 +195,55 @@ export function assertPhase(snapshot, phases, action) {
|
|
|
134
195
|
export function requiredActors(snapshot) {
|
|
135
196
|
const participants = active(snapshot);
|
|
136
197
|
switch (snapshot.phase) {
|
|
137
|
-
case 'implementing':
|
|
138
|
-
|
|
198
|
+
case 'implementing':
|
|
199
|
+
return participants
|
|
200
|
+
.filter(participant => participant.role === 'implementer')
|
|
201
|
+
.map(participant => participant.participantId);
|
|
202
|
+
case 'collecting':
|
|
203
|
+
return participants
|
|
204
|
+
.filter(participant => participant.role === 'reviewer')
|
|
205
|
+
.map(participant => participant.participantId);
|
|
139
206
|
// A reviewer that owes no ballot is already done. There is no empty submission for it to make, so requiring
|
|
140
207
|
// one would park the phase on a seat that can never satisfy it.
|
|
141
|
-
case 'validating':
|
|
142
|
-
|
|
208
|
+
case 'validating':
|
|
209
|
+
return reviewers(snapshot)
|
|
210
|
+
.filter(participant => owedIssueVoteIds(snapshot, participant.participantId).length)
|
|
211
|
+
.map(participant => participant.participantId);
|
|
212
|
+
case 'merge_voting':
|
|
213
|
+
return reviewers(snapshot)
|
|
214
|
+
.filter(participant => currentMergeProposals(snapshot).some(proposal => !proposal.votes.some(vote => vote.participantId === participant.participantId)))
|
|
215
|
+
.map(participant => participant.participantId);
|
|
143
216
|
case 'issue_discussing': {
|
|
144
217
|
const contested = new Set(activeContestedIssueIds(snapshot));
|
|
145
|
-
return unique(issueConsensus(snapshot)
|
|
218
|
+
return unique(issueConsensus(snapshot)
|
|
219
|
+
.filter(entry => contested.has(entry.issueId))
|
|
220
|
+
.flatMap(entry => entry.supporters));
|
|
146
221
|
}
|
|
147
222
|
case 'issue_reconsidering': {
|
|
148
223
|
const contested = new Set(activeContestedIssueIds(snapshot));
|
|
149
|
-
return unique(issueConsensus(snapshot)
|
|
224
|
+
return unique(issueConsensus(snapshot)
|
|
225
|
+
.filter(entry => contested.has(entry.issueId))
|
|
226
|
+
.flatMap(entry => entry.rejecters));
|
|
150
227
|
}
|
|
151
228
|
// responding/adjudicating are legacy persisted phases. New reviews finish after panel consensus.
|
|
152
|
-
default:
|
|
229
|
+
default:
|
|
230
|
+
return [];
|
|
153
231
|
}
|
|
154
232
|
}
|
|
155
233
|
/** Required actors that have not finished yet. An exhausted or departed participant still blocks: a human must intervene. */
|
|
156
234
|
export function waitingOn(snapshot) {
|
|
157
235
|
const required = requiredActors(snapshot);
|
|
158
|
-
if ([
|
|
159
|
-
|
|
236
|
+
if ([
|
|
237
|
+
'collecting',
|
|
238
|
+
'implementing',
|
|
239
|
+
'validating',
|
|
240
|
+
'merge_voting',
|
|
241
|
+
'issue_discussing',
|
|
242
|
+
'issue_reconsidering'
|
|
243
|
+
].includes(snapshot.phase)) {
|
|
244
|
+
const done = new Set(snapshot.completions
|
|
245
|
+
.filter(entry => entry.phase === snapshot.phase && entry.round === snapshot.round)
|
|
246
|
+
.map(entry => entry.participantId));
|
|
160
247
|
return required.filter(participantId => !done.has(participantId));
|
|
161
248
|
}
|
|
162
249
|
return required;
|
|
@@ -191,14 +278,23 @@ export function nextPhase(snapshot, options = {}) {
|
|
|
191
278
|
// A participant may explicitly ask for a human during any consensus step. Finish the current batched step,
|
|
192
279
|
// then stop before another debate round instead of silently debating past an open escalation.
|
|
193
280
|
if (snapshot.pendingEscalations > 0 && snapshot.phase !== 'awaiting_human')
|
|
194
|
-
return {
|
|
281
|
+
return {
|
|
282
|
+
phase: 'awaiting_human',
|
|
283
|
+
round: snapshot.round,
|
|
284
|
+
debateRound: snapshot.debateRound,
|
|
285
|
+
reason: 'participant_escalated',
|
|
286
|
+
...skipped
|
|
287
|
+
};
|
|
195
288
|
switch (snapshot.phase) {
|
|
196
|
-
case 'draft':
|
|
197
|
-
|
|
198
|
-
|
|
289
|
+
case 'draft':
|
|
290
|
+
return snapshot.policy.implementationFirst
|
|
291
|
+
? { phase: 'implementing', round: snapshot.round, reason: 'implementation_started', ...skipped }
|
|
292
|
+
: { phase: 'collecting', round: snapshot.round, reason: 'round_opened', ...skipped };
|
|
199
293
|
// The implementer declares itself done (or a managed agent goes idle); only then are the reviewers called.
|
|
200
|
-
case 'implementing':
|
|
201
|
-
|
|
294
|
+
case 'implementing':
|
|
295
|
+
return { phase: 'collecting', round: snapshot.round, reason: 'implementation_ready', ...skipped };
|
|
296
|
+
case 'collecting':
|
|
297
|
+
return { phase: 'consolidating', round: snapshot.round, reason: 'findings_complete', ...skipped };
|
|
202
298
|
case 'consolidating': {
|
|
203
299
|
if (!snapshot.policy.consensusReview)
|
|
204
300
|
return { phase: 'finished', round: snapshot.round, reason: 'review_complete', ...skipped };
|
|
@@ -206,41 +302,88 @@ export function nextPhase(snapshot, options = {}) {
|
|
|
206
302
|
if (waitingOn({ ...snapshot, phase: 'validating' }).length)
|
|
207
303
|
return { phase: 'validating', round: snapshot.round, reason: 'issue_validation_started', ...skipped };
|
|
208
304
|
const proposals = currentMergeProposals(snapshot);
|
|
209
|
-
if (proposals.length &&
|
|
305
|
+
if (proposals.length &&
|
|
306
|
+
!snapshot.completions.some(entry => entry.phase === 'merge_voting' &&
|
|
307
|
+
entry.round === snapshot.round &&
|
|
308
|
+
entry.participantId === 'system'))
|
|
210
309
|
return { phase: 'merge_voting', round: snapshot.round, reason: 'merge_voting_started', ...skipped };
|
|
211
310
|
return contestedIssueIds(snapshot).length
|
|
212
|
-
? {
|
|
311
|
+
? {
|
|
312
|
+
phase: 'issue_discussing',
|
|
313
|
+
round: snapshot.round,
|
|
314
|
+
debateRound: Math.max(1, snapshot.debateRound ?? 0),
|
|
315
|
+
reason: 'issue_votes_contested',
|
|
316
|
+
...skipped
|
|
317
|
+
}
|
|
213
318
|
: { phase: 'finished', round: snapshot.round, reason: 'review_consensus_complete', ...skipped };
|
|
214
319
|
}
|
|
215
|
-
case 'validating':
|
|
216
|
-
|
|
217
|
-
case '
|
|
320
|
+
case 'validating':
|
|
321
|
+
return { phase: 'consolidating', round: snapshot.round, reason: 'issue_validation_complete', ...skipped };
|
|
322
|
+
case 'merge_voting':
|
|
323
|
+
return { phase: 'consolidating', round: snapshot.round, reason: 'merge_voting_complete', ...skipped };
|
|
324
|
+
case 'issue_discussing':
|
|
325
|
+
return {
|
|
326
|
+
phase: 'issue_reconsidering',
|
|
327
|
+
round: snapshot.round,
|
|
328
|
+
debateRound: snapshot.debateRound ?? 1,
|
|
329
|
+
reason: 'supporter_arguments_complete',
|
|
330
|
+
...skipped
|
|
331
|
+
};
|
|
218
332
|
case 'issue_reconsidering': {
|
|
219
333
|
const contested = contestedIssueIds(snapshot);
|
|
220
334
|
if (!contested.length)
|
|
221
|
-
return {
|
|
335
|
+
return {
|
|
336
|
+
phase: 'finished',
|
|
337
|
+
round: snapshot.round,
|
|
338
|
+
debateRound: snapshot.debateRound ?? 1,
|
|
339
|
+
reason: 'review_consensus_complete',
|
|
340
|
+
...skipped
|
|
341
|
+
};
|
|
222
342
|
const exhausted = (snapshot.debateRound ?? 1) >= snapshot.policy.maxConsensusRounds;
|
|
223
343
|
if (!exhausted && activeContestedIssueIds(snapshot).length)
|
|
224
|
-
return {
|
|
344
|
+
return {
|
|
345
|
+
phase: 'issue_discussing',
|
|
346
|
+
round: snapshot.round,
|
|
347
|
+
debateRound: (snapshot.debateRound ?? 1) + 1,
|
|
348
|
+
reason: 'issue_consensus_next_round',
|
|
349
|
+
...skipped
|
|
350
|
+
};
|
|
225
351
|
// The panel is done arguing, either out of rounds or out of movement. Dispose of every contested finding
|
|
226
352
|
// here so only the ones a human must actually rule on reach the escalation queue.
|
|
227
353
|
const resolutions = resolveContestedIssues(snapshot), escalated = resolutions.filter(entry => entry.disposition === 'escalate');
|
|
228
|
-
return {
|
|
229
|
-
|
|
230
|
-
|
|
354
|
+
return {
|
|
355
|
+
phase: escalated.length ? 'awaiting_human' : 'finished',
|
|
356
|
+
round: snapshot.round,
|
|
357
|
+
debateRound: snapshot.debateRound ?? 1,
|
|
358
|
+
reason: escalated.length
|
|
359
|
+
? exhausted
|
|
360
|
+
? 'issue_consensus_exhausted'
|
|
361
|
+
: 'issue_consensus_settled_with_disputes'
|
|
362
|
+
: 'issue_consensus_resolved_by_panel',
|
|
363
|
+
resolutions,
|
|
364
|
+
escalateConsensus: escalated.length > 0,
|
|
365
|
+
...skipped
|
|
366
|
+
};
|
|
231
367
|
}
|
|
232
|
-
case 'awaiting_human':
|
|
368
|
+
case 'awaiting_human':
|
|
369
|
+
return { phase: 'consolidating', round: snapshot.round, reason: 'consensus_human_ruled', ...skipped };
|
|
233
370
|
// Compatibility recovery for sessions persisted by the old mandatory response loop: do not ask Agents for
|
|
234
371
|
// another response; close the review and let a human start an explicit fix/recheck cycle if desired.
|
|
235
372
|
case 'responding':
|
|
236
|
-
case 'adjudicating':
|
|
237
|
-
|
|
373
|
+
case 'adjudicating':
|
|
374
|
+
return { phase: 'finished', round: snapshot.round, reason: 'legacy_response_loop_removed', ...skipped };
|
|
375
|
+
default:
|
|
376
|
+
throw flowError(COLLAB_ERRORS.wrongPhase, `Phase ${snapshot.phase} cannot advance`);
|
|
238
377
|
}
|
|
239
378
|
}
|
|
240
379
|
/** A stall is informational only: it never changes the phase (see COLLAB_PLAN.md decision 3). */
|
|
241
380
|
export function stallCheck(snapshot, lastProgressAt, nowMs) {
|
|
242
381
|
const pending = waitingOn(snapshot), overdueBySec = Math.floor((nowMs - lastProgressAt) / 1000);
|
|
243
|
-
return {
|
|
382
|
+
return {
|
|
383
|
+
stalled: pending.length > 0 && overdueBySec >= snapshot.policy.overdueWarningSec,
|
|
384
|
+
waitingOn: pending,
|
|
385
|
+
overdueBySec
|
|
386
|
+
};
|
|
244
387
|
}
|
|
245
388
|
export function assertCanFileFinding(snapshot, participantId) {
|
|
246
389
|
const participant = findParticipant(snapshot, participantId);
|
|
@@ -285,7 +428,12 @@ export function approvalSummary(snapshot) {
|
|
|
285
428
|
const approvals = reviewers.map(reviewer => {
|
|
286
429
|
const filed = snapshot.issues.filter(issue => issue.reporterId === reviewer.participantId);
|
|
287
430
|
const unresolved = filed.filter(isOpenIssue);
|
|
288
|
-
return {
|
|
431
|
+
return {
|
|
432
|
+
participantId: reviewer.participantId,
|
|
433
|
+
filed: filed.length,
|
|
434
|
+
unresolved: unresolved.length,
|
|
435
|
+
approved: unresolved.length === 0
|
|
436
|
+
};
|
|
289
437
|
});
|
|
290
438
|
return {
|
|
291
439
|
reviewers: approvals,
|
|
@@ -299,7 +447,8 @@ export function sessionProgress(snapshot) {
|
|
|
299
447
|
for (const issue of snapshot.issues)
|
|
300
448
|
counts.set(issue.status, (counts.get(issue.status) ?? 0) + 1);
|
|
301
449
|
return {
|
|
302
|
-
phase: snapshot.phase,
|
|
450
|
+
phase: snapshot.phase,
|
|
451
|
+
round: snapshot.round,
|
|
303
452
|
totalIssues: snapshot.issues.length,
|
|
304
453
|
openIssues: snapshot.issues.filter(isOpenIssue).length,
|
|
305
454
|
byStatus: Object.fromEntries(counts),
|
package/dist/collab/routes.js
CHANGED
|
@@ -1,7 +1,10 @@
|
|
|
1
1
|
import { ValidationError } from './validate.js';
|
|
2
2
|
import { COLLAB_ERRORS } from './types.js';
|
|
3
3
|
const ok = (payload, status = 200) => ({ status, payload: { data: payload } });
|
|
4
|
-
const forbidden = (message) => ({
|
|
4
|
+
const forbidden = (message) => ({
|
|
5
|
+
status: 403,
|
|
6
|
+
payload: { error: { code: COLLAB_ERRORS.forbidden, message } }
|
|
7
|
+
});
|
|
5
8
|
/**
|
|
6
9
|
* HTTP surface for the collaboration hub. Two credentials are accepted:
|
|
7
10
|
* the pairing code (a human, full control) and a participant token (one agent, its own session only).
|
|
@@ -25,7 +28,13 @@ export class CollabRouter {
|
|
|
25
28
|
participant = this.hub.authenticate(token);
|
|
26
29
|
}
|
|
27
30
|
catch {
|
|
28
|
-
return {
|
|
31
|
+
return {
|
|
32
|
+
status: 401,
|
|
33
|
+
payload: {
|
|
34
|
+
error: { code: 'UNAUTHORIZED', message: 'A pairing code or participant token is required' }
|
|
35
|
+
},
|
|
36
|
+
authFailed: true
|
|
37
|
+
};
|
|
29
38
|
}
|
|
30
39
|
}
|
|
31
40
|
try {
|
|
@@ -37,20 +46,45 @@ export class CollabRouter {
|
|
|
37
46
|
}
|
|
38
47
|
async route(ctx) {
|
|
39
48
|
const { method, url, parts, isHuman, participant } = ctx;
|
|
40
|
-
const human = (action) => {
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
+
const human = (action) => {
|
|
50
|
+
if (!isHuman)
|
|
51
|
+
throw Object.assign(new Error(`${action} is reserved for humans`), {
|
|
52
|
+
code: COLLAB_ERRORS.humanOnly,
|
|
53
|
+
httpStatus: 403
|
|
54
|
+
});
|
|
55
|
+
};
|
|
56
|
+
const asParticipant = () => {
|
|
57
|
+
if (!participant)
|
|
58
|
+
throw Object.assign(new Error('This endpoint requires a participant token'), {
|
|
59
|
+
code: COLLAB_ERRORS.forbidden,
|
|
60
|
+
httpStatus: 403
|
|
61
|
+
});
|
|
62
|
+
return participant;
|
|
63
|
+
};
|
|
64
|
+
const scoped = (sessionId) => {
|
|
65
|
+
if (participant && participant.sessionId !== sessionId)
|
|
66
|
+
throw Object.assign(new Error('Collaboration session not found'), {
|
|
67
|
+
code: COLLAB_ERRORS.sessionNotFound,
|
|
68
|
+
httpStatus: 404
|
|
69
|
+
});
|
|
70
|
+
};
|
|
71
|
+
const collabAgent = (agentId) => {
|
|
72
|
+
if (typeof agentId !== 'string' || !this.deps.isCollabAgent(agentId))
|
|
73
|
+
throw Object.assign(new Error('A collaboration seat requires an existing, dedicated collaboration Agent (profile=collab). Create one from the collaboration board, then register or rebind this seat to it.'), { code: COLLAB_ERRORS.forbidden, httpStatus: 403 });
|
|
74
|
+
};
|
|
75
|
+
const number = (name, fallback) => {
|
|
76
|
+
const raw = url.searchParams.get(name);
|
|
77
|
+
const value = raw === null ? fallback : Number(raw);
|
|
78
|
+
return Number.isFinite(value) ? value : fallback;
|
|
79
|
+
};
|
|
49
80
|
// /api/v1/collab/escalations[/{id}/resolve]
|
|
50
81
|
if (parts[3] === 'escalations') {
|
|
51
82
|
if (method === 'GET' && parts.length === 4) {
|
|
52
83
|
human('Listing escalations');
|
|
53
|
-
return ok(this.hub.listEscalations({
|
|
84
|
+
return ok(this.hub.listEscalations({
|
|
85
|
+
status: url.searchParams.get('status') ?? undefined,
|
|
86
|
+
sessionId: url.searchParams.get('sessionId') ?? undefined
|
|
87
|
+
}));
|
|
54
88
|
}
|
|
55
89
|
if (method === 'GET' && parts.length === 5) {
|
|
56
90
|
human('Reading an escalation');
|
|
@@ -70,7 +104,12 @@ export class CollabRouter {
|
|
|
70
104
|
}
|
|
71
105
|
if (method === 'GET' && parts.length === 4) {
|
|
72
106
|
if (isHuman)
|
|
73
|
-
return ok(this.hub.listSessions({
|
|
107
|
+
return ok(this.hub.listSessions({
|
|
108
|
+
status: url.searchParams.get('status') ?? undefined,
|
|
109
|
+
kind: url.searchParams.get('kind') ?? undefined,
|
|
110
|
+
limit: number('limit', 50),
|
|
111
|
+
offset: number('offset', 0)
|
|
112
|
+
}));
|
|
74
113
|
return ok([this.hub.getSession(asParticipant().sessionId)]);
|
|
75
114
|
}
|
|
76
115
|
const sessionId = parts[4];
|
|
@@ -83,7 +122,13 @@ export class CollabRouter {
|
|
|
83
122
|
if (sub && !['participants', 'issues', 'debates'].includes(tail ?? ''))
|
|
84
123
|
return undefined;
|
|
85
124
|
if (method === 'GET' && !tail)
|
|
86
|
-
return ok({
|
|
125
|
+
return ok({
|
|
126
|
+
...this.hub.getSession(sessionId),
|
|
127
|
+
progress: this.hub.progress(sessionId),
|
|
128
|
+
participants: isHuman
|
|
129
|
+
? this.hub.participantsForHuman(sessionId)
|
|
130
|
+
: this.hub.store.listParticipants(sessionId)
|
|
131
|
+
});
|
|
87
132
|
if (tail === 'participants') {
|
|
88
133
|
// Sub-resources first: an unguarded register branch would swallow .../participants/{id}/binding and /budget
|
|
89
134
|
// and answer them with "role is required", which is how both repair paths became unreachable over HTTP.
|
|
@@ -114,7 +159,9 @@ export class CollabRouter {
|
|
|
114
159
|
return ok({ participant: created, participantToken: token, briefing: this.hub.digest(created) }, 201);
|
|
115
160
|
}
|
|
116
161
|
if (method === 'GET')
|
|
117
|
-
return ok(isHuman
|
|
162
|
+
return ok(isHuman
|
|
163
|
+
? this.hub.participantsForHuman(sessionId)
|
|
164
|
+
: this.hub.store.listParticipants(sessionId).map(entry => ({ ...entry })));
|
|
118
165
|
}
|
|
119
166
|
if (method === 'POST' && tail === 'advance') {
|
|
120
167
|
human('Advancing a phase');
|
|
@@ -193,22 +240,46 @@ export class CollabRouter {
|
|
|
193
240
|
if (method === 'GET' && tail === 'report') {
|
|
194
241
|
human('Reading the session report');
|
|
195
242
|
const reportSession = this.hub.getSession(sessionId);
|
|
196
|
-
return ok({
|
|
243
|
+
return ok({
|
|
244
|
+
session: reportSession,
|
|
245
|
+
progress: this.hub.progress(sessionId),
|
|
246
|
+
issues: this.hub.store.listIssues(sessionId),
|
|
247
|
+
reviewSummary: reportSession.kind === 'review' ? this.hub.reviewSummary(sessionId) : undefined,
|
|
248
|
+
escalations: this.hub.listEscalations({ sessionId })
|
|
249
|
+
});
|
|
197
250
|
}
|
|
198
251
|
return undefined;
|
|
199
252
|
}
|
|
200
253
|
/** Every failure carries a stable code so an agent can branch on it instead of parsing prose. */
|
|
201
254
|
errorResult(error) {
|
|
202
255
|
if (error instanceof ValidationError)
|
|
203
|
-
return {
|
|
256
|
+
return {
|
|
257
|
+
status: 422,
|
|
258
|
+
payload: { error: { code: error.code, message: error.message, fieldErrors: error.fieldErrors } }
|
|
259
|
+
};
|
|
204
260
|
const value = error, code = value?.code;
|
|
205
|
-
const status = Number.isInteger(value?.httpStatus)
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
261
|
+
const status = Number.isInteger(value?.httpStatus)
|
|
262
|
+
? value.httpStatus
|
|
263
|
+
: code === COLLAB_ERRORS.conflict ||
|
|
264
|
+
code === COLLAB_ERRORS.staleBaseline ||
|
|
265
|
+
code === COLLAB_ERRORS.wrongPhase ||
|
|
266
|
+
code === COLLAB_ERRORS.humanRulingFinal
|
|
267
|
+
? 409
|
|
268
|
+
: code === COLLAB_ERRORS.budgetExhausted
|
|
269
|
+
? 429
|
|
270
|
+
: code?.endsWith('NOT_FOUND')
|
|
271
|
+
? 404
|
|
272
|
+
: 400;
|
|
273
|
+
const extra = code === COLLAB_ERRORS.staleBaseline && value.currentBaseline
|
|
274
|
+
? { currentBaseline: value.currentBaseline }
|
|
275
|
+
: {};
|
|
210
276
|
if (status === 403 && !code)
|
|
211
277
|
return forbidden(String(value?.message ?? 'Forbidden'));
|
|
212
|
-
return {
|
|
278
|
+
return {
|
|
279
|
+
status,
|
|
280
|
+
payload: {
|
|
281
|
+
error: { code: code ?? 'BAD_REQUEST', message: String(value?.message ?? 'Request failed'), ...extra }
|
|
282
|
+
}
|
|
283
|
+
};
|
|
213
284
|
}
|
|
214
285
|
}
|