@brightbeamai/chap-coordinator 0.2.10 → 0.2.13
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 +14 -4
- package/dist/{api-Cvkhcalw.d.ts → api-BOGEBNmX.d.ts} +2 -0
- package/dist/{api-CcT_8H5j.d.cts → api-DiiL0sil.d.cts} +2 -0
- package/dist/api.d.cts +1 -1
- package/dist/api.d.ts +1 -1
- package/dist/index.cjs +112 -22
- package/dist/index.cjs.map +1 -1
- package/dist/index.d.cts +2 -2
- package/dist/index.d.ts +2 -2
- package/dist/index.js +112 -22
- package/dist/index.js.map +1 -1
- package/dist/patch.cjs +8 -7
- package/dist/patch.cjs.map +1 -1
- package/dist/patch.js +8 -7
- package/dist/patch.js.map +1 -1
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -13,8 +13,9 @@ surface, so the two interoperate on the same JSON-RPC 2.0 wire.
|
|
|
13
13
|
npm install @brightbeamai/chap-coordinator
|
|
14
14
|
```
|
|
15
15
|
|
|
16
|
-
Node 20+ required.
|
|
17
|
-
|
|
16
|
+
Node 20+ required. No required runtime dependencies; uses Node's built-in
|
|
17
|
+
`crypto` for Ed25519 and JCS. `better-sqlite3` is an optional dependency,
|
|
18
|
+
needed only for `SqliteStore`.
|
|
18
19
|
|
|
19
20
|
## Companion packages
|
|
20
21
|
|
|
@@ -85,7 +86,7 @@ console.log(resp.result); // { task_id: "tsk_...", state: "created" }
|
|
|
85
86
|
|
|
86
87
|
| Profile | Methods (per profile spec) |
|
|
87
88
|
|-----------------------|-------------------------------------------------------------------------------------------|
|
|
88
|
-
| `core/1.0` | `workspace.create`, `workspace.describe`, `workspace.set_profiles`, `participant.join`, `participant.leave`, `task.create`, `task.update`, `task.complete`, `audit.read` |
|
|
89
|
+
| `core/1.0` | `workspace.create`, `workspace.describe`, `workspace.set_profiles`, `participant.join`, `participant.leave`, `task.create`, `task.update`, `task.complete`, `audit.read`; a task created with `review_required` completes only on a reviewer decision |
|
|
89
90
|
| `review/1.0` | `review.request`, `decide.approve`, `decide.reject`, `decide.override`, `abstain.declare`, `escalate.raise` |
|
|
90
91
|
| `whisper/1.0` | `whisper.ask`, `whisper.answer`; lapse hook via `coord.checkWhisperLapses(workspaceId, now?)` |
|
|
91
92
|
| `deliberation/1.0` | `deliberate.open`, `deliberate.comment`, `deliberate.vote`, `deliberate.close` |
|
|
@@ -100,6 +101,15 @@ console.log(resp.result); // { task_id: "tsk_...", state: "created" }
|
|
|
100
101
|
|
|
101
102
|
**39 method handlers in total**, matching the Python reference exactly.
|
|
102
103
|
|
|
104
|
+
**The review gate.** A task carrying `review_required` does not complete on
|
|
105
|
+
`task.complete`. The call opens a review, holding the submitted output as the
|
|
106
|
+
artefact under review, and only a `decide.*` reaches `completed`. `task.update`
|
|
107
|
+
cannot complete such a task either, and answers `-32602` naming the route
|
|
108
|
+
through. Where no reviewer was named, the set is the human members other than
|
|
109
|
+
the completer and the assignee; a workspace with no eligible human refuses the
|
|
110
|
+
completion rather than opening a review nobody can decide. Under `modes/1.0` a
|
|
111
|
+
trial-mode task has `review_required` set for it.
|
|
112
|
+
|
|
103
113
|
## Architecture
|
|
104
114
|
|
|
105
115
|
The coordinator is transport-agnostic, persistence-agnostic, and
|
|
@@ -171,7 +181,7 @@ Key lifecycle:
|
|
|
171
181
|
- `participant.revoke_key` is an admin operation; envelopes signed
|
|
172
182
|
with the revoked key after revocation are rejected.
|
|
173
183
|
|
|
174
|
-
Helpers exported from `@brightbeamai/chap-coordinator
|
|
184
|
+
Helpers exported from `@brightbeamai/chap-coordinator`:
|
|
175
185
|
|
|
176
186
|
```typescript
|
|
177
187
|
import {
|
|
@@ -75,6 +75,8 @@ interface CoordinatorOptions {
|
|
|
75
75
|
requireSignatures?: boolean;
|
|
76
76
|
enforceStepUp?: boolean;
|
|
77
77
|
requireReadMembership?: boolean;
|
|
78
|
+
/** Largest envelope accepted (default 1 MiB), published in the descriptor. */
|
|
79
|
+
maxEnvelopeBytes?: number;
|
|
78
80
|
onAudit?: AuditListener;
|
|
79
81
|
onAutoEscalate?: (task: Task, to: ParticipantUri) => void;
|
|
80
82
|
verifyOidcToken?: TokenVerifier;
|
|
@@ -75,6 +75,8 @@ interface CoordinatorOptions {
|
|
|
75
75
|
requireSignatures?: boolean;
|
|
76
76
|
enforceStepUp?: boolean;
|
|
77
77
|
requireReadMembership?: boolean;
|
|
78
|
+
/** Largest envelope accepted (default 1 MiB), published in the descriptor. */
|
|
79
|
+
maxEnvelopeBytes?: number;
|
|
78
80
|
onAudit?: AuditListener;
|
|
79
81
|
onAutoEscalate?: (task: Task, to: ParticipantUri) => void;
|
|
80
82
|
verifyOidcToken?: TokenVerifier;
|
package/dist/api.d.cts
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
1
|
import './methods.cjs';
|
|
2
|
-
export { b as CoordinatorApi, c as CoordinatorError, g as call, m as makeApi } from './api-
|
|
2
|
+
export { b as CoordinatorApi, c as CoordinatorError, g as call, m as makeApi } from './api-DiiL0sil.cjs';
|
|
3
3
|
import './types.cjs';
|
|
4
4
|
import './storage/store.cjs';
|
package/dist/api.d.ts
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
1
|
import './methods.js';
|
|
2
|
-
export { b as CoordinatorApi, c as CoordinatorError, g as call, m as makeApi } from './api-
|
|
2
|
+
export { b as CoordinatorApi, c as CoordinatorError, g as call, m as makeApi } from './api-BOGEBNmX.js';
|
|
3
3
|
import './types.js';
|
|
4
4
|
import './storage/store.js';
|
package/dist/index.cjs
CHANGED
|
@@ -323,6 +323,11 @@ function isValidEnvelope(env) {
|
|
|
323
323
|
var PatchError = class extends Error {
|
|
324
324
|
};
|
|
325
325
|
var DANGEROUS_KEYS = /* @__PURE__ */ new Set(["__proto__", "constructor", "prototype"]);
|
|
326
|
+
var ARRAY_INDEX_RE = /^(0|[1-9][0-9]*)$/;
|
|
327
|
+
function arrayIndex(seg) {
|
|
328
|
+
if (!ARRAY_INDEX_RE.test(seg)) throw new PatchError(`Array index expected at ${JSON.stringify(seg)}`);
|
|
329
|
+
return Number(seg);
|
|
330
|
+
}
|
|
326
331
|
function unescape(token) {
|
|
327
332
|
return token.replace(/~1/g, "/").replace(/~0/g, "~");
|
|
328
333
|
}
|
|
@@ -347,8 +352,8 @@ function navigate(doc, parts) {
|
|
|
347
352
|
for (let i = 0; i < parts.length - 1; i++) {
|
|
348
353
|
const part = parts[i];
|
|
349
354
|
if (Array.isArray(parent)) {
|
|
350
|
-
const idx =
|
|
351
|
-
if (
|
|
355
|
+
const idx = arrayIndex(part);
|
|
356
|
+
if (idx >= parent.length) {
|
|
352
357
|
throw new PatchError(`Index out of range at /${parts.slice(0, i + 1).join("/")}`);
|
|
353
358
|
}
|
|
354
359
|
parent = parent[idx];
|
|
@@ -364,11 +369,7 @@ function navigate(doc, parts) {
|
|
|
364
369
|
const last = parts[parts.length - 1];
|
|
365
370
|
if (Array.isArray(parent)) {
|
|
366
371
|
if (last === "-") return [parent, "-"];
|
|
367
|
-
|
|
368
|
-
if (!Number.isInteger(idx)) {
|
|
369
|
-
throw new PatchError(`Array index expected at ${JSON.stringify(last)}`);
|
|
370
|
-
}
|
|
371
|
-
return [parent, idx];
|
|
372
|
+
return [parent, arrayIndex(last)];
|
|
372
373
|
}
|
|
373
374
|
return [parent, last];
|
|
374
375
|
}
|
|
@@ -819,7 +820,7 @@ function registerControl(coord) {
|
|
|
819
820
|
if (scope === "task") {
|
|
820
821
|
const task = ws.tasks.get(p.task_id);
|
|
821
822
|
if (!task) return { error: rpcError(E.PARAMS, "Unknown task") };
|
|
822
|
-
if (task.state === "completed" || task.state === "declined" || task.state === "cancelled") {
|
|
823
|
+
if (task.state === "completed" || task.state === "declined" || task.state === "cancelled" || task.state === "superseded") {
|
|
823
824
|
return { error: rpcError(E.CONTROL_NOT_AUTHORISED, `Cannot pause ${task.state} task`) };
|
|
824
825
|
}
|
|
825
826
|
task.paused = true;
|
|
@@ -876,7 +877,7 @@ function registerControl(coord) {
|
|
|
876
877
|
if (!ws) return { error: rpcError(E.PARAMS, "Unknown workspace") };
|
|
877
878
|
const task = ws.tasks.get(p.task_id);
|
|
878
879
|
if (!task) return { error: rpcError(E.PARAMS, "Unknown task") };
|
|
879
|
-
if (task.state === "completed" || task.state === "declined" || task.state === "cancelled") {
|
|
880
|
+
if (task.state === "completed" || task.state === "declined" || task.state === "cancelled" || task.state === "superseded") {
|
|
880
881
|
return { error: rpcError(E.CONTROL_NOT_AUTHORISED, `Cannot cancel ${task.state} task`) };
|
|
881
882
|
}
|
|
882
883
|
task.state = "cancelled";
|
|
@@ -898,7 +899,7 @@ function registerControl(coord) {
|
|
|
898
899
|
}));
|
|
899
900
|
}
|
|
900
901
|
if (include.includes("open_tasks")) {
|
|
901
|
-
state.open_tasks = Array.from(ws.tasks.values()).filter((t) => t.state !== "completed" && t.state !== "declined" && t.state !== "cancelled").map((t) => ({ id: t.id, kind: t.kind, state: t.state, assignee: t.assignee }));
|
|
902
|
+
state.open_tasks = Array.from(ws.tasks.values()).filter((t) => t.state !== "completed" && t.state !== "declined" && t.state !== "cancelled" && t.state !== "superseded").map((t) => ({ id: t.id, kind: t.kind, state: t.state, assignee: t.assignee }));
|
|
902
903
|
}
|
|
903
904
|
if (include.includes("mode_ceiling")) state.mode_ceiling = ws.mode_ceiling;
|
|
904
905
|
if (include.includes("policy")) state.routing_policy_uri = ws.routing_policy_uri;
|
|
@@ -966,6 +967,13 @@ function registerControl(coord) {
|
|
|
966
967
|
if (!ws.members.has(assignee)) {
|
|
967
968
|
return { error: rpcError(E.PARAMS, "successor assignee not in workspace") };
|
|
968
969
|
}
|
|
970
|
+
const requestedMode = newSpec.mode ?? old.mode;
|
|
971
|
+
if (!modeLE(requestedMode, ws.mode_ceiling)) {
|
|
972
|
+
return { error: rpcError(
|
|
973
|
+
E.MODE_CEILING_EXCEEDED,
|
|
974
|
+
`Requested mode ${requestedMode} exceeds ceiling ${ws.mode_ceiling}`
|
|
975
|
+
) };
|
|
976
|
+
}
|
|
969
977
|
const now = coord.now();
|
|
970
978
|
const newId = coord.ids.taskId();
|
|
971
979
|
const newTask = {
|
|
@@ -977,7 +985,7 @@ function registerControl(coord) {
|
|
|
977
985
|
input: newSpec.input ?? {},
|
|
978
986
|
created_at: now,
|
|
979
987
|
updated_at: now,
|
|
980
|
-
mode:
|
|
988
|
+
mode: requestedMode,
|
|
981
989
|
supersedes: old.id,
|
|
982
990
|
history: [{
|
|
983
991
|
ts: now,
|
|
@@ -987,6 +995,8 @@ function registerControl(coord) {
|
|
|
987
995
|
}],
|
|
988
996
|
paused: false
|
|
989
997
|
};
|
|
998
|
+
if (ws.profiles.some((pr) => pr.startsWith("modes/")) && newTask.mode === "trial") newTask.review_required = true;
|
|
999
|
+
else if ("review_required" in newSpec) newTask.review_required = !!newSpec.review_required;
|
|
990
1000
|
ws.tasks.set(newId, newTask);
|
|
991
1001
|
old.state = "superseded";
|
|
992
1002
|
old.superseded_by = newId;
|
|
@@ -1740,6 +1750,8 @@ var READ_ONLY_METHODS = /* @__PURE__ */ new Set([
|
|
|
1740
1750
|
"audit.verify_chain",
|
|
1741
1751
|
"audit.verify_receipt"
|
|
1742
1752
|
]);
|
|
1753
|
+
var MAX_IDEMPOTENCY_KEYS = 1e4;
|
|
1754
|
+
var DEFAULT_MAX_ENVELOPE_BYTES = 1048576;
|
|
1743
1755
|
function nowIso(clockMs) {
|
|
1744
1756
|
const d = clockMs === void 0 ? /* @__PURE__ */ new Date() : new Date(clockMs);
|
|
1745
1757
|
return d.toISOString().replace(/\.(\d{3})Z$/, ".$1Z");
|
|
@@ -1758,12 +1770,32 @@ function tagsError(p) {
|
|
|
1758
1770
|
function linkHash(envelope, prev) {
|
|
1759
1771
|
return sha256Hex(Buffer.concat([canonicalize(envelope), Buffer.from(prev, "utf-8")]));
|
|
1760
1772
|
}
|
|
1773
|
+
function reviewRuleSupported(rule) {
|
|
1774
|
+
if (rule === "any_one_approves" || rule === "all_approve") return true;
|
|
1775
|
+
if (rule.startsWith("quorum:")) {
|
|
1776
|
+
const arg = rule.slice("quorum:".length);
|
|
1777
|
+
return /^[0-9]+$/.test(arg) && parseInt(arg, 10) >= 1;
|
|
1778
|
+
}
|
|
1779
|
+
return false;
|
|
1780
|
+
}
|
|
1781
|
+
function reviewSatisfied(review) {
|
|
1782
|
+
const approvers = new Set(review.decisions.filter((d) => d.kind === "approve").map((d) => d.reviewer));
|
|
1783
|
+
const rule = review.rule || "any_one_approves";
|
|
1784
|
+
if (rule === "all_approve") {
|
|
1785
|
+
const named = review.requested_to.filter((r) => !(r.startsWith("workspace:") || r.startsWith("group:")));
|
|
1786
|
+
if (named.length === 0) return approvers.size >= 1;
|
|
1787
|
+
return named.every((r) => approvers.has(r));
|
|
1788
|
+
}
|
|
1789
|
+
if (rule.startsWith("quorum:")) return approvers.size >= parseInt(rule.slice("quorum:".length), 10);
|
|
1790
|
+
return approvers.size >= 1;
|
|
1791
|
+
}
|
|
1761
1792
|
function reply(env, body) {
|
|
1762
1793
|
const out = { jsonrpc: "2.0", id: env.id };
|
|
1763
1794
|
if (body.error !== void 0) out.error = body.error;
|
|
1764
1795
|
else out.result = body.result;
|
|
1765
1796
|
return out;
|
|
1766
1797
|
}
|
|
1798
|
+
var REVIEW_REQUEST_STOPPED = ["cancelled", "superseded", "paused"];
|
|
1767
1799
|
var Coordinator = class {
|
|
1768
1800
|
workspaces = /* @__PURE__ */ new Map();
|
|
1769
1801
|
options;
|
|
@@ -1983,6 +2015,18 @@ var Coordinator = class {
|
|
|
1983
2015
|
if (!isValidEnvelope(envelope) || !envelope.method) {
|
|
1984
2016
|
return reply(envelope, { error: rpcError(E.REQUEST, "Invalid JSON-RPC 2.0 request") });
|
|
1985
2017
|
}
|
|
2018
|
+
const maxBytes = this.options.maxEnvelopeBytes ?? DEFAULT_MAX_ENVELOPE_BYTES;
|
|
2019
|
+
let size = 0;
|
|
2020
|
+
try {
|
|
2021
|
+
size = canonicalize(envelope).length;
|
|
2022
|
+
} catch {
|
|
2023
|
+
}
|
|
2024
|
+
if (size > maxBytes) {
|
|
2025
|
+
return reply(envelope, { error: rpcError(
|
|
2026
|
+
E.REQUEST,
|
|
2027
|
+
`Envelope exceeds max_envelope_bytes (${size} > ${maxBytes})`
|
|
2028
|
+
) });
|
|
2029
|
+
}
|
|
1986
2030
|
const method = envelope.method;
|
|
1987
2031
|
const rawParams = envelope.params;
|
|
1988
2032
|
if (rawParams !== void 0 && rawParams !== null && (typeof rawParams !== "object" || Array.isArray(rawParams))) {
|
|
@@ -2007,7 +2051,7 @@ var Coordinator = class {
|
|
|
2007
2051
|
const stale = this.checkStepUp(params);
|
|
2008
2052
|
if (stale) return reply(envelope, { error: stale });
|
|
2009
2053
|
}
|
|
2010
|
-
if (method.startsWith("control.") || method.startsWith("deliberate.") || method.startsWith("handoff.")) {
|
|
2054
|
+
if (method.startsWith("control.") || method.startsWith("deliberate.") || method.startsWith("handoff.") || method.startsWith("whisper.")) {
|
|
2011
2055
|
const wsId2 = params.workspace;
|
|
2012
2056
|
const ws = typeof wsId2 === "string" ? this.workspaces.get(wsId2) : void 0;
|
|
2013
2057
|
if (ws) {
|
|
@@ -2116,6 +2160,9 @@ var Coordinator = class {
|
|
|
2116
2160
|
if (member.keys.some((k) => k.kid === kid && k.revoked_at !== void 0 && now >= k.revoked_at)) {
|
|
2117
2161
|
return rpcError(E.SIG_KEY_REVOKED, `Key ${kid} is revoked`);
|
|
2118
2162
|
}
|
|
2163
|
+
if (member.keys.some((k) => k.kid === kid && k.valid_until !== void 0 && now >= k.valid_until)) {
|
|
2164
|
+
return rpcError(E.SIG_KEY_NOT_FOUND, `Key ${kid} is no longer valid`);
|
|
2165
|
+
}
|
|
2119
2166
|
const key = this.lookupKey(member, kid, ts);
|
|
2120
2167
|
if (!key) {
|
|
2121
2168
|
const revoked = member.keys.find((k) => k.kid === kid && k.revoked_at);
|
|
@@ -2131,9 +2178,8 @@ var Coordinator = class {
|
|
|
2131
2178
|
return rpcError(E.SIG_VERIFY_FAILED, "Signature failed verification");
|
|
2132
2179
|
}
|
|
2133
2180
|
return null;
|
|
2134
|
-
} catch
|
|
2135
|
-
|
|
2136
|
-
return rpcError(E.INTERNAL, `Signature check error: ${msg}`);
|
|
2181
|
+
} catch {
|
|
2182
|
+
return rpcError(E.SIG_VERIFY_FAILED, "Signature failed verification");
|
|
2137
2183
|
}
|
|
2138
2184
|
}
|
|
2139
2185
|
/** Lookup the key valid for a given participant at a given time. */
|
|
@@ -2257,6 +2303,7 @@ var Coordinator = class {
|
|
|
2257
2303
|
state: ws.state,
|
|
2258
2304
|
mode: ws.mode,
|
|
2259
2305
|
mode_ceiling: ws.mode_ceiling,
|
|
2306
|
+
max_envelope_bytes: this.options.maxEnvelopeBytes ?? DEFAULT_MAX_ENVELOPE_BYTES,
|
|
2260
2307
|
step_up_window_sec: ws.step_up_window_sec,
|
|
2261
2308
|
profiles: ws.profiles,
|
|
2262
2309
|
members: Array.from(ws.members.values()).map(memberToDict),
|
|
@@ -2397,10 +2444,14 @@ var Coordinator = class {
|
|
|
2397
2444
|
history: [{ ts: now, from: p.from, state: "created" }],
|
|
2398
2445
|
paused: false
|
|
2399
2446
|
};
|
|
2400
|
-
if (task.mode === "trial") task.review_required = true;
|
|
2447
|
+
if (ws.profiles.some((pr) => pr.startsWith("modes/")) && task.mode === "trial") task.review_required = true;
|
|
2401
2448
|
else if ("review_required" in p) task.review_required = !!p.review_required;
|
|
2402
2449
|
ws.tasks.set(taskId, task);
|
|
2403
|
-
if (typeof p.idempotency_key === "string")
|
|
2450
|
+
if (typeof p.idempotency_key === "string") {
|
|
2451
|
+
ws.idempotency_keys[p.idempotency_key] = taskId;
|
|
2452
|
+
const keys = Object.keys(ws.idempotency_keys);
|
|
2453
|
+
if (keys.length > MAX_IDEMPOTENCY_KEYS) delete ws.idempotency_keys[keys[0]];
|
|
2454
|
+
}
|
|
2404
2455
|
return { result: { task_id: taskId, state: "created" } };
|
|
2405
2456
|
}
|
|
2406
2457
|
opTaskUpdate(p) {
|
|
@@ -2414,12 +2465,18 @@ var Coordinator = class {
|
|
|
2414
2465
|
const legal = {
|
|
2415
2466
|
created: ["in_progress", "declined", "paused"],
|
|
2416
2467
|
in_progress: ["in_progress", "completed", "declined", "review_requested", "paused"],
|
|
2417
|
-
review_requested: ["in_progress"
|
|
2468
|
+
review_requested: ["in_progress"],
|
|
2418
2469
|
paused: ["in_progress", "cancelled"]
|
|
2419
2470
|
};
|
|
2420
2471
|
if (!legal[task.state]?.includes(newState)) {
|
|
2421
2472
|
return { error: rpcError(E.PARAMS, `Illegal transition ${task.state} -> ${newState}`) };
|
|
2422
2473
|
}
|
|
2474
|
+
if (newState === "completed" && task.review_required) {
|
|
2475
|
+
return { error: rpcError(
|
|
2476
|
+
E.PARAMS,
|
|
2477
|
+
"This task requires review. Submit the output with task.complete, which opens the review; a reviewer decision completes it."
|
|
2478
|
+
) };
|
|
2479
|
+
}
|
|
2423
2480
|
task.state = newState;
|
|
2424
2481
|
task.updated_at = this.now();
|
|
2425
2482
|
task.history.push({
|
|
@@ -2440,6 +2497,30 @@ var Coordinator = class {
|
|
|
2440
2497
|
if (task.state !== "created" && task.state !== "in_progress") {
|
|
2441
2498
|
return { error: rpcError(E.PARAMS, `Cannot complete task in state: ${task.state}`) };
|
|
2442
2499
|
}
|
|
2500
|
+
if (task.review_required) {
|
|
2501
|
+
const now = this.now();
|
|
2502
|
+
task.pending_artefact = p.output;
|
|
2503
|
+
if (!task.review) {
|
|
2504
|
+
const producer = p.from;
|
|
2505
|
+
const eligible = [...ws.members.entries()].filter(([uri, member]) => uri !== producer && uri !== task.assignee && member.type === "human").map(([uri]) => uri);
|
|
2506
|
+
if (eligible.length === 0) {
|
|
2507
|
+
return { error: rpcError(
|
|
2508
|
+
E.NOT_AUTHORISED,
|
|
2509
|
+
"Task requires review but has no eligible human reviewer (needs a human member other than its assignee and completer)"
|
|
2510
|
+
) };
|
|
2511
|
+
}
|
|
2512
|
+
task.review = { requested_at: now, requested_to: eligible, rule: "any_one_approves", decisions: [] };
|
|
2513
|
+
}
|
|
2514
|
+
task.state = "review_requested";
|
|
2515
|
+
task.updated_at = now;
|
|
2516
|
+
task.history.push({
|
|
2517
|
+
ts: now,
|
|
2518
|
+
from: p.from,
|
|
2519
|
+
state: "review_requested",
|
|
2520
|
+
note: "review required; opened on task.complete"
|
|
2521
|
+
});
|
|
2522
|
+
return { result: { state: "review_requested", review_id: task.id } };
|
|
2523
|
+
}
|
|
2443
2524
|
task.output = p.output;
|
|
2444
2525
|
if (typeof p.confidence === "number" || typeof p.confidence === "string") task.confidence = p.confidence;
|
|
2445
2526
|
task.state = "completed";
|
|
@@ -2486,11 +2567,18 @@ var Coordinator = class {
|
|
|
2486
2567
|
if (notMember) return notMember;
|
|
2487
2568
|
const task = ws.tasks.get(p.task_id);
|
|
2488
2569
|
if (!task) return { error: rpcError(E.PARAMS, "Unknown task") };
|
|
2570
|
+
if (REVIEW_REQUEST_STOPPED.includes(task.state)) {
|
|
2571
|
+
return { error: rpcError(E.NOT_REVIEWABLE, `Cannot request review for task in state: ${task.state}`) };
|
|
2572
|
+
}
|
|
2489
2573
|
const to = p.to;
|
|
2490
2574
|
const reviewers = Array.isArray(to) ? to : typeof to === "string" ? [to] : [];
|
|
2491
2575
|
if (!reviewers.length) return { error: rpcError(E.PARAMS, "review.request needs 'to'") };
|
|
2492
2576
|
const now = this.now();
|
|
2493
|
-
const
|
|
2577
|
+
const declaredRule = p.rule;
|
|
2578
|
+
const rule = declaredRule || "any_one_approves";
|
|
2579
|
+
if (!reviewRuleSupported(rule)) {
|
|
2580
|
+
return { error: rpcError(E.PARAMS, `Unsupported review rule ${JSON.stringify(rule)}: review/1.0 supports any_one_approves, all_approve, quorum:<n>`) };
|
|
2581
|
+
}
|
|
2494
2582
|
if (task.state === "review_requested" && task.review) {
|
|
2495
2583
|
if (contentHash(task.pending_artefact) !== contentHash(p.artefact)) {
|
|
2496
2584
|
return { error: rpcError(
|
|
@@ -2498,7 +2586,7 @@ var Coordinator = class {
|
|
|
2498
2586
|
"A review is already open on this task with different content. Decide, abstain, escalate or cancel it before requesting review of a new artefact."
|
|
2499
2587
|
) };
|
|
2500
2588
|
}
|
|
2501
|
-
if (
|
|
2589
|
+
if (declaredRule !== void 0 && declaredRule !== task.review.rule) {
|
|
2502
2590
|
return { error: rpcError(
|
|
2503
2591
|
E.REVIEW_ALREADY_OPEN,
|
|
2504
2592
|
`Cannot change the decision rule of an open review (currently ${task.review.rule})`
|
|
@@ -2587,8 +2675,10 @@ var Coordinator = class {
|
|
|
2587
2675
|
tags: p.tags
|
|
2588
2676
|
});
|
|
2589
2677
|
if (kind === "approve") {
|
|
2590
|
-
task.
|
|
2591
|
-
|
|
2678
|
+
if (reviewSatisfied(task.review)) {
|
|
2679
|
+
task.output = task.pending_artefact;
|
|
2680
|
+
task.state = "completed";
|
|
2681
|
+
}
|
|
2592
2682
|
} else {
|
|
2593
2683
|
task.state = p.request_revision ? "in_progress" : "declined";
|
|
2594
2684
|
}
|