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.
@@ -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
+ }
@@ -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
- recordCircuitFailure(outboundCircuit, normalizedNowMs);
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
- recordCircuitFailure(outboundCircuit, normalizedNowMs);
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
- recordCircuitFailure(outboundCircuit, normalizedNowMs);
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
- recordCircuitFailure(outboundCircuit, normalizedNowMs);
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
- recordCircuitFailure(outboundCircuit, normalizedNowMs);
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
- recordCircuitFailure(outboundCircuit, normalizedNowMs);
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
- recordCircuitFailure(outboundCircuit, normalizedNowMs);
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
- recordCircuitFailure(outboundCircuit, normalizedNowMs);
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
- recordCircuitFailure(outboundCircuit, normalizedNowMs);
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
- recordCircuitFailure(outboundCircuit, normalizedNowMs);
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_${response ? response.status : "no_response"}`,
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) {