sentinelayer-cli 0.42.0 → 0.44.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +47 -1
- package/package.json +1 -1
- package/src/commands/session.js +435 -12
- package/src/mcp/cli-command-tools.js +3 -0
- package/src/mcp/registry.js +4 -4
- package/src/mcp/session-stdio-server.js +76 -6
- package/src/session/admission-access.js +284 -0
- package/src/session/admission-auth.js +8 -1
- package/src/session/admission.js +60 -4
- package/src/session/control-events.js +2 -0
- package/src/session/reactions.js +258 -0
- package/src/session/sync.js +41 -11
|
@@ -0,0 +1,258 @@
|
|
|
1
|
+
// reactions.js -- the ONE path every like / dislike / unlike / undislike takes, from
|
|
2
|
+
// the native CLI (`sl session react|action`) and the local MCP server
|
|
3
|
+
// (`session_react`, `session_action`) alike.
|
|
4
|
+
//
|
|
5
|
+
// Three guarantees. Separate copies of this logic once broke each of them:
|
|
6
|
+
//
|
|
7
|
+
// 1. Authority. The write runs inside withAgentAdmission for (session, agent): an
|
|
8
|
+
// admitted agent acts on ITS admission credential, a tombstoned (expired,
|
|
9
|
+
// malformed, mis-bound) admission is refused before any request, and nothing
|
|
10
|
+
// falls back to the human's broader token or legacy agent grants.
|
|
11
|
+
// 2. Intent. The API replays a reused idempotency key instead of acting on it
|
|
12
|
+
// (sentinelayer-api#914 reaction contract), so a key derived from
|
|
13
|
+
// (type, target, actor) makes like -> unlike -> like a silent no-op. Each NEW
|
|
14
|
+
// intent gets a fresh operation key. An explicit key is a retry of the intent
|
|
15
|
+
// that minted it, and is sent unchanged.
|
|
16
|
+
// 3. Truth. Every result names its outcome and carries its operation key, so an
|
|
17
|
+
// ambiguous failure (the request may have landed) can be retried as the SAME
|
|
18
|
+
// intent instead of becoming a new one that could retract a newer reaction.
|
|
19
|
+
|
|
20
|
+
import { randomBytes } from "node:crypto";
|
|
21
|
+
import process from "node:process";
|
|
22
|
+
|
|
23
|
+
import { assertCanonicalAgentId, canonicalAgentId, withAgentAdmission } from "./admission-auth.js";
|
|
24
|
+
import { createSessionMessageAction } from "./sync.js";
|
|
25
|
+
|
|
26
|
+
export const SESSION_REACTION_TYPES = Object.freeze(["like", "dislike", "unlike", "undislike"]);
|
|
27
|
+
const REACTION_TYPES = new Set(SESSION_REACTION_TYPES);
|
|
28
|
+
const UNDO_TARGETS = new Map([
|
|
29
|
+
["unlike", "like"],
|
|
30
|
+
["undislike", "dislike"],
|
|
31
|
+
]);
|
|
32
|
+
|
|
33
|
+
// createSessionMessageAction failures that happen before any request leaves this machine.
|
|
34
|
+
const NOT_SENT_REASONS = new Set([
|
|
35
|
+
"invalid_input",
|
|
36
|
+
"remote_sync_disabled_env",
|
|
37
|
+
"circuit_breaker_open",
|
|
38
|
+
"no_session",
|
|
39
|
+
"not_authenticated",
|
|
40
|
+
]);
|
|
41
|
+
|
|
42
|
+
/**
|
|
43
|
+
* Every outcome a reaction can have. `ok` is true only for the first three.
|
|
44
|
+
* applied -- this request recorded a new reaction row
|
|
45
|
+
* no_op -- an equal reaction was already active; nothing changed. With
|
|
46
|
+
* `collapsedActionId` the server retained this request as evidence.
|
|
47
|
+
* replayed -- an explicit (retry) key had already been recorded; the server
|
|
48
|
+
* returned that original result and did not re-check current state
|
|
49
|
+
* not_active -- an undo with nothing of this actor's active to retract (409)
|
|
50
|
+
* unsupported -- a server without reaction undo rejected the action value (422)
|
|
51
|
+
* refused -- any other definitive refusal; nothing was recorded
|
|
52
|
+
* not_sent -- the request never left this machine; nothing was recorded
|
|
53
|
+
* unknown -- the request may or may not have been recorded (timeout, lost
|
|
54
|
+
* response, 5xx): retry with the SAME operation key
|
|
55
|
+
*/
|
|
56
|
+
export const SESSION_REACTION_OUTCOMES = Object.freeze([
|
|
57
|
+
"applied",
|
|
58
|
+
"no_op",
|
|
59
|
+
"replayed",
|
|
60
|
+
"not_active",
|
|
61
|
+
"unsupported",
|
|
62
|
+
"refused",
|
|
63
|
+
"not_sent",
|
|
64
|
+
"unknown",
|
|
65
|
+
]);
|
|
66
|
+
|
|
67
|
+
function normalizeString(value) {
|
|
68
|
+
return String(value ?? "").trim();
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
export function isSessionReactionType(value) {
|
|
72
|
+
return REACTION_TYPES.has(normalizeString(value).toLowerCase());
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
/** The like/dislike an undo retracts, or "" for a non-undo. */
|
|
76
|
+
export function reactionUndoTarget(value) {
|
|
77
|
+
return UNDO_TARGETS.get(normalizeString(value).toLowerCase()) || "";
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
function targetKey({ targetSequenceId, targetCursor, targetActionId }) {
|
|
81
|
+
if (normalizeString(targetActionId)) return `action:${normalizeString(targetActionId)}`;
|
|
82
|
+
if (targetSequenceId) return `seq:${targetSequenceId}`;
|
|
83
|
+
return `cursor:${normalizeString(targetCursor)}`;
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
function targetLabel({ targetSequenceId, targetCursor, targetActionId }) {
|
|
87
|
+
if (normalizeString(targetActionId)) return `reply ${normalizeString(targetActionId)}`;
|
|
88
|
+
if (targetSequenceId) return `#${targetSequenceId}`;
|
|
89
|
+
return normalizeString(targetCursor) || "target";
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
/** A key for a NEW intent. Never derived from (type, target, actor) alone. */
|
|
93
|
+
export function newReactionOperationKey({ surface, reaction, agentId, ...target } = {}) {
|
|
94
|
+
return `${surface}:${reaction}:${targetKey(target)}:${agentId}:${randomBytes(8).toString("hex")}`;
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
/**
|
|
98
|
+
* The exact 422 a server without reaction undo (sentinelayer-api before #914) returns
|
|
99
|
+
* for this action: FastAPI's validation envelope around the route's action-type
|
|
100
|
+
* validator, a single entry echoing the value sent. Anything looser could relabel a
|
|
101
|
+
* different validation failure as "undo unsupported", so the match is exact.
|
|
102
|
+
*/
|
|
103
|
+
export function isUndoUnsupportedValidationError(result = {}, actionType = "") {
|
|
104
|
+
if (result.status !== 422) return false;
|
|
105
|
+
const detail = result.error?.detail;
|
|
106
|
+
if (!Array.isArray(detail) || detail.length !== 1) return false;
|
|
107
|
+
const [entry] = detail;
|
|
108
|
+
return (
|
|
109
|
+
entry?.type === "value_error" &&
|
|
110
|
+
Array.isArray(entry.loc) &&
|
|
111
|
+
entry.loc.length === 2 &&
|
|
112
|
+
entry.loc[0] === "body" &&
|
|
113
|
+
entry.loc[1] === "actionType" &&
|
|
114
|
+
entry.msg === "Value error, unsupported message action type" &&
|
|
115
|
+
entry.input === actionType
|
|
116
|
+
);
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
function classify(reaction, result, retry) {
|
|
120
|
+
const status = Number.isInteger(result?.status) ? result.status : null;
|
|
121
|
+
if (result?.ok && result.action) {
|
|
122
|
+
if (normalizeString(result.collapsedActionId)) return "no_op";
|
|
123
|
+
if (result.duplicate) return retry ? "replayed" : "no_op";
|
|
124
|
+
return "applied";
|
|
125
|
+
}
|
|
126
|
+
if (status === null) {
|
|
127
|
+
return NOT_SENT_REASONS.has(normalizeString(result?.reason)) ? "not_sent" : "unknown";
|
|
128
|
+
}
|
|
129
|
+
if (status >= 500) return "unknown";
|
|
130
|
+
if (isUndoUnsupportedValidationError(result, reaction)) return "unsupported";
|
|
131
|
+
if (status === 409 && reactionUndoTarget(reaction) && result.error?.error?.code === "REACTION_NOT_ACTIVE") {
|
|
132
|
+
return "not_active";
|
|
133
|
+
}
|
|
134
|
+
if (status >= 400) return "refused";
|
|
135
|
+
return "unknown";
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
function describe(outcome, { reaction, agentId, target, operationKey, reason, errorCode, collapsedActionId }) {
|
|
139
|
+
const where = targetLabel(target);
|
|
140
|
+
switch (outcome) {
|
|
141
|
+
case "applied":
|
|
142
|
+
return `${agentId} ${reaction} recorded on ${where}.`;
|
|
143
|
+
case "no_op":
|
|
144
|
+
return (
|
|
145
|
+
`No change: ${agentId} already has an active ${reaction} on ${where}.` +
|
|
146
|
+
(collapsedActionId ? ` This request is retained as evidence ${collapsedActionId}.` : "")
|
|
147
|
+
);
|
|
148
|
+
case "replayed":
|
|
149
|
+
return (
|
|
150
|
+
`Replayed: operation ${operationKey} was already recorded. The server returned its ` +
|
|
151
|
+
`original result and did not re-check the current state.`
|
|
152
|
+
);
|
|
153
|
+
case "not_active":
|
|
154
|
+
return `Nothing to undo: ${agentId} has no active ${reactionUndoTarget(reaction)} on ${where}.`;
|
|
155
|
+
case "unsupported":
|
|
156
|
+
return `This server doesn't support undo yet (no '${reaction}' action). Nothing was changed.`;
|
|
157
|
+
case "refused":
|
|
158
|
+
return `The server refused this ${reaction} (${errorCode || reason}). Nothing was recorded.`;
|
|
159
|
+
case "not_sent":
|
|
160
|
+
return `Not sent (${reason}). Nothing was recorded.`;
|
|
161
|
+
default:
|
|
162
|
+
return (
|
|
163
|
+
`Outcome unknown (${reason || "no response"}): the server may have recorded this ${reaction}. ` +
|
|
164
|
+
`To retry this same intent, resend it with idempotency key ${operationKey}; ` +
|
|
165
|
+
`without that key it is a new intent.`
|
|
166
|
+
);
|
|
167
|
+
}
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
/**
|
|
171
|
+
* Submit one reaction. Throws only for invalid input or a refused admission (before
|
|
172
|
+
* any request); every server answer, network failure and timeout is an outcome.
|
|
173
|
+
*/
|
|
174
|
+
export async function submitSessionReaction({
|
|
175
|
+
surface,
|
|
176
|
+
sessionId,
|
|
177
|
+
agentId,
|
|
178
|
+
reaction,
|
|
179
|
+
targetSequenceId = null,
|
|
180
|
+
targetCursor = "",
|
|
181
|
+
targetActionId = "",
|
|
182
|
+
note = "",
|
|
183
|
+
idempotencyKey = "",
|
|
184
|
+
targetPath = process.cwd(),
|
|
185
|
+
timeoutMs = 15_000,
|
|
186
|
+
dryRun = false,
|
|
187
|
+
createSessionMessageActionFn = createSessionMessageAction,
|
|
188
|
+
} = {}) {
|
|
189
|
+
const sid = normalizeString(sessionId);
|
|
190
|
+
const type = normalizeString(reaction).toLowerCase();
|
|
191
|
+
if (!sid) throw new Error("session id is required.");
|
|
192
|
+
if (!REACTION_TYPES.has(type)) {
|
|
193
|
+
throw new Error(`reaction must be one of: ${SESSION_REACTION_TYPES.join(", ")}.`);
|
|
194
|
+
}
|
|
195
|
+
// One canonical identity for authorisation and execution (see admission-auth.js).
|
|
196
|
+
assertCanonicalAgentId(agentId, "agentId");
|
|
197
|
+
const agent = canonicalAgentId(agentId);
|
|
198
|
+
if (!agent) throw new Error("agentId is required.");
|
|
199
|
+
const target = {
|
|
200
|
+
targetSequenceId: targetSequenceId || null,
|
|
201
|
+
targetCursor: normalizeString(targetCursor),
|
|
202
|
+
targetActionId: normalizeString(targetActionId),
|
|
203
|
+
};
|
|
204
|
+
if (!target.targetSequenceId && !target.targetCursor && !target.targetActionId) {
|
|
205
|
+
throw new Error("Provide a target sequence, cursor, or action id.");
|
|
206
|
+
}
|
|
207
|
+
const explicitKey = normalizeString(idempotencyKey);
|
|
208
|
+
const operationKey =
|
|
209
|
+
explicitKey || newReactionOperationKey({ surface, reaction: type, agentId: agent, ...target });
|
|
210
|
+
const base = {
|
|
211
|
+
sessionId: sid,
|
|
212
|
+
agentId: agent,
|
|
213
|
+
actionType: type,
|
|
214
|
+
...target,
|
|
215
|
+
note: normalizeString(note),
|
|
216
|
+
operationKey,
|
|
217
|
+
idempotencyKey: operationKey,
|
|
218
|
+
retry: Boolean(explicitKey),
|
|
219
|
+
};
|
|
220
|
+
if (dryRun) return { ok: true, dryRun: true, outcome: "dry_run", ...base };
|
|
221
|
+
|
|
222
|
+
const result = await withAgentAdmission(sid, agent, () =>
|
|
223
|
+
createSessionMessageActionFn(sid, {
|
|
224
|
+
actionType: type,
|
|
225
|
+
targetPath,
|
|
226
|
+
...target,
|
|
227
|
+
note: base.note,
|
|
228
|
+
metadata: { source: surface, agentId: agent },
|
|
229
|
+
idempotencyKey: operationKey,
|
|
230
|
+
timeoutMs,
|
|
231
|
+
}),
|
|
232
|
+
);
|
|
233
|
+
const outcome = classify(type, result, base.retry);
|
|
234
|
+
const collapsedActionId = normalizeString(result?.collapsedActionId) || null;
|
|
235
|
+
const status = Number.isInteger(result?.status) ? result.status : null;
|
|
236
|
+
const reason = normalizeString(result?.reason);
|
|
237
|
+
const errorCode = normalizeString(result?.error?.error?.code) || null;
|
|
238
|
+
return {
|
|
239
|
+
ok: outcome === "applied" || outcome === "no_op" || outcome === "replayed",
|
|
240
|
+
outcome,
|
|
241
|
+
...base,
|
|
242
|
+
duplicate: Boolean(result?.duplicate),
|
|
243
|
+
collapsedActionId,
|
|
244
|
+
status,
|
|
245
|
+
reason,
|
|
246
|
+
errorCode,
|
|
247
|
+
message: describe(outcome, {
|
|
248
|
+
reaction: type,
|
|
249
|
+
agentId: agent,
|
|
250
|
+
target,
|
|
251
|
+
operationKey,
|
|
252
|
+
reason,
|
|
253
|
+
errorCode,
|
|
254
|
+
collapsedActionId,
|
|
255
|
+
}),
|
|
256
|
+
action: result?.ok && result.action ? result.action : null,
|
|
257
|
+
};
|
|
258
|
+
}
|
package/src/session/sync.js
CHANGED
|
@@ -19,6 +19,8 @@ const DEFAULT_API_BASE_URL = "https://api.sentinelayer.com";
|
|
|
19
19
|
const DEFAULT_SYNC_TIMEOUT_MS = 5_000;
|
|
20
20
|
const MAX_CONSECUTIVE_FAILURES = 3;
|
|
21
21
|
const CIRCUIT_RESET_MS = 60_000;
|
|
22
|
+
// Message-action statuses whose body the caller needs to explain the refusal.
|
|
23
|
+
const MESSAGE_ACTION_REFUSAL_STATUSES = Object.freeze([409, 422]);
|
|
22
24
|
const SESSION_INGEST_LIMIT_PER_MINUTE = 500;
|
|
23
25
|
const HUMAN_MESSAGE_LIMIT_PER_MINUTE = 10;
|
|
24
26
|
const HUMAN_MESSAGE_MAX_LENGTH = 2_000;
|
|
@@ -374,6 +376,18 @@ function recordCircuitFailure(circuit, nowMs) {
|
|
|
374
376
|
persistCircuitState();
|
|
375
377
|
}
|
|
376
378
|
|
|
379
|
+
// 401 and 403 refuse one credential (an expired, revoked or narrower agent admission, or
|
|
380
|
+
// an identity the account has not granted). They say nothing about API health, so they
|
|
381
|
+
// never count toward the machine-wide outbound breaker; every other failure does.
|
|
382
|
+
function isCredentialRefusal(response) {
|
|
383
|
+
return Boolean(response) && (response.status === 401 || response.status === 403);
|
|
384
|
+
}
|
|
385
|
+
|
|
386
|
+
function recordOutboundFailure(response, nowMs) {
|
|
387
|
+
if (isCredentialRefusal(response)) return;
|
|
388
|
+
recordCircuitFailure(outboundCircuit, nowMs);
|
|
389
|
+
}
|
|
390
|
+
|
|
377
391
|
function recordCircuitSuccess(circuit) {
|
|
378
392
|
if (!circuit) {
|
|
379
393
|
return;
|
|
@@ -1033,18 +1047,23 @@ export async function syncSessionEventToApi(
|
|
|
1033
1047
|
if (response && response.status === 403) {
|
|
1034
1048
|
const body = forbiddenBody;
|
|
1035
1049
|
if (isIdentityForgeryBody(body)) {
|
|
1050
|
+
if (session.source === "session_admission") {
|
|
1051
|
+
// An admitted agent acts only on its admission: account-level agent grants
|
|
1052
|
+
// are neither created nor used for it.
|
|
1053
|
+
return { synced: false, reason: "api_403" };
|
|
1054
|
+
}
|
|
1036
1055
|
const agentId = normalizeString(event?.agent?.id);
|
|
1037
1056
|
if (!agentId || isReservedAgentIdForGrant(agentId)) {
|
|
1038
1057
|
// Reserved or empty — server enforcement is intentional, no grant
|
|
1039
1058
|
// attempt is meaningful. Treat as a normal 403 failure.
|
|
1040
|
-
|
|
1059
|
+
recordOutboundFailure(response, normalizedNowMs);
|
|
1041
1060
|
return { synced: false, reason: "api_403" };
|
|
1042
1061
|
}
|
|
1043
1062
|
if (autoGrantAttemptedAgentIds.has(agentId)) {
|
|
1044
1063
|
// We already tried to grant this identity in a prior event in this
|
|
1045
1064
|
// process and either it succeeded but the server still says no, or
|
|
1046
1065
|
// the grant failed. Either way, don't loop — surface the 403.
|
|
1047
|
-
|
|
1066
|
+
recordOutboundFailure(response, normalizedNowMs);
|
|
1048
1067
|
return { synced: false, reason: "api_403" };
|
|
1049
1068
|
}
|
|
1050
1069
|
rememberAutoGrantAttempt(agentId);
|
|
@@ -1081,7 +1100,7 @@ export async function syncSessionEventToApi(
|
|
|
1081
1100
|
// Treat 409 (already granted) as success — idempotent on the server.
|
|
1082
1101
|
const grantIdempotent = grantStatus === 409;
|
|
1083
1102
|
if (!grantOk && !grantIdempotent) {
|
|
1084
|
-
|
|
1103
|
+
recordOutboundFailure(grantResponse, normalizedNowMs);
|
|
1085
1104
|
return {
|
|
1086
1105
|
synced: false,
|
|
1087
1106
|
reason: `grant_failed_${grantStatus || "no_response"}`,
|
|
@@ -1109,7 +1128,7 @@ export async function syncSessionEventToApi(
|
|
|
1109
1128
|
autoGranted: true,
|
|
1110
1129
|
};
|
|
1111
1130
|
}
|
|
1112
|
-
|
|
1131
|
+
recordOutboundFailure(retryResponse, normalizedNowMs);
|
|
1113
1132
|
return {
|
|
1114
1133
|
synced: false,
|
|
1115
1134
|
reason: `api_${retryResponse ? retryResponse.status : "no_response"}`,
|
|
@@ -1117,7 +1136,7 @@ export async function syncSessionEventToApi(
|
|
|
1117
1136
|
}
|
|
1118
1137
|
}
|
|
1119
1138
|
|
|
1120
|
-
|
|
1139
|
+
recordOutboundFailure(response, normalizedNowMs);
|
|
1121
1140
|
return {
|
|
1122
1141
|
synced: false,
|
|
1123
1142
|
reason: `api_${response ? response.status : "no_response"}`,
|
|
@@ -1207,7 +1226,7 @@ async function syncSessionAuxPayload(
|
|
|
1207
1226
|
{ readSuccessBody: false },
|
|
1208
1227
|
);
|
|
1209
1228
|
if (!response || !response.ok) {
|
|
1210
|
-
|
|
1229
|
+
recordOutboundFailure(response, normalizedNowMs);
|
|
1211
1230
|
return {
|
|
1212
1231
|
synced: false,
|
|
1213
1232
|
reason: `api_${response ? response.status : "no_response"}`,
|
|
@@ -1843,7 +1862,7 @@ export async function renewSessionPresence(
|
|
|
1843
1862
|
retryAfterMs: retryAfterMsFromResponse(response, payload, normalizedNowMs),
|
|
1844
1863
|
};
|
|
1845
1864
|
}
|
|
1846
|
-
|
|
1865
|
+
recordOutboundFailure(response, normalizedNowMs);
|
|
1847
1866
|
return {
|
|
1848
1867
|
ok: false,
|
|
1849
1868
|
reason: `api_${response ? response.status : "no_response"}`,
|
|
@@ -1958,7 +1977,7 @@ export async function requestSessionListenerStop(
|
|
|
1958
1977
|
retryAfterMs: retryAfterMsFromResponse(response, payload, normalizedNowMs),
|
|
1959
1978
|
};
|
|
1960
1979
|
}
|
|
1961
|
-
|
|
1980
|
+
recordOutboundFailure(response, normalizedNowMs);
|
|
1962
1981
|
return {
|
|
1963
1982
|
ok: false,
|
|
1964
1983
|
reason: `api_${response ? response.status : "no_response"}`,
|
|
@@ -2083,7 +2102,7 @@ export async function updateSessionReadCursor(
|
|
|
2083
2102
|
updated: false,
|
|
2084
2103
|
};
|
|
2085
2104
|
}
|
|
2086
|
-
|
|
2105
|
+
recordOutboundFailure(response, normalizedNowMs);
|
|
2087
2106
|
return {
|
|
2088
2107
|
ok: false,
|
|
2089
2108
|
reason: `api_${response ? response.status : "no_response"}`,
|
|
@@ -2990,12 +3009,20 @@ export async function createSessionMessageAction(
|
|
|
2990
3009
|
},
|
|
2991
3010
|
normalizePositiveInteger(timeoutMs, DEFAULT_SYNC_TIMEOUT_MS),
|
|
2992
3011
|
fetchImpl,
|
|
3012
|
+
{ errorBodyStatuses: MESSAGE_ACTION_REFUSAL_STATUSES },
|
|
2993
3013
|
);
|
|
2994
3014
|
if (!response || !response.ok) {
|
|
2995
|
-
|
|
3015
|
+
const status = response ? response.status : null;
|
|
3016
|
+
const refused = MESSAGE_ACTION_REFUSAL_STATUSES.includes(status);
|
|
3017
|
+
// A refusal (409 nothing-to-undo, 422 a server that predates the action) is an
|
|
3018
|
+
// answer about this request, not API health: it must not open the persisted
|
|
3019
|
+
// breaker that guards every other outbound write.
|
|
3020
|
+
if (!refused) recordOutboundFailure(response, normalizedNowMs);
|
|
2996
3021
|
return {
|
|
2997
3022
|
ok: false,
|
|
2998
|
-
reason: `api_${
|
|
3023
|
+
reason: `api_${status ?? "no_response"}`,
|
|
3024
|
+
status,
|
|
3025
|
+
error: refused && payload && typeof payload === "object" ? payload : null,
|
|
2999
3026
|
action: null,
|
|
3000
3027
|
};
|
|
3001
3028
|
}
|
|
@@ -3004,6 +3031,9 @@ export async function createSessionMessageAction(
|
|
|
3004
3031
|
ok: Boolean(payload?.ok ?? true),
|
|
3005
3032
|
reason: "",
|
|
3006
3033
|
duplicate: Boolean(payload?.duplicate),
|
|
3034
|
+
// A repeat reaction is a no-op whose key the server retains as a collapsed row
|
|
3035
|
+
// (sentinelayer-api#914): the id of that evidence row.
|
|
3036
|
+
collapsedActionId: normalizeString(payload?.collapsedActionId) || null,
|
|
3007
3037
|
action: payload?.action && typeof payload.action === "object" ? payload.action : null,
|
|
3008
3038
|
};
|
|
3009
3039
|
} catch (error) {
|