sentinelayer-cli 0.40.0 → 0.42.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 +22 -4
- package/package.json +4 -4
- package/src/auth/admission-scope.js +34 -0
- package/src/auth/gate.js +6 -0
- package/src/auth/http.js +4 -0
- package/src/auth/service.js +6 -0
- package/src/board/done-gate.js +155 -0
- package/src/board/evidence-resolvers.js +138 -0
- package/src/cli.js +24 -1
- package/src/commands/board.js +116 -0
- package/src/commands/session.js +1094 -279
- package/src/engram/build-state.js +183 -0
- package/src/engram/canonical.js +75 -0
- package/src/engram/document.js +190 -0
- package/src/engram/governance.js +67 -0
- package/src/engram/index.js +73 -0
- package/src/engram/ingest-document.js +107 -0
- package/src/engram/namespace.js +89 -0
- package/src/engram/observations.js +34 -0
- package/src/engram/sla.js +104 -0
- package/src/engram/store.js +120 -0
- package/src/engram/tools.js +152 -0
- package/src/engram/trust.js +105 -0
- package/src/events/schema.js +18 -0
- package/src/legacy-cli.js +6 -3
- package/src/mcp/registry.js +27 -10
- package/src/mcp/session-stdio-server.js +209 -18
- package/src/net/test-egress-guard.js +158 -0
- package/src/review/investor-dd-orchestrator.js +14 -2
- package/src/session/admission-auth.js +312 -0
- package/src/session/admission-preimage.js +90 -0
- package/src/session/admission.js +620 -0
- package/src/session/agent-registry.js +22 -63
- package/src/session/checkpoints.js +25 -1
- package/src/session/codebase-context.js +54 -0
- package/src/session/coordination-guidance.js +6 -6
- package/src/session/daemon-spawn.js +20 -2
- package/src/session/daemon.js +26 -125
- package/src/session/event-identity.js +9 -2
- package/src/session/file-lease-integrations.js +1300 -0
- package/src/session/file-locks.js +967 -464
- package/src/session/first-message.js +11 -3
- package/src/session/listener.js +246 -20
- package/src/session/listeners.js +71 -80
- package/src/session/live-source.js +47 -16
- package/src/session/message-edits.js +89 -0
- package/src/session/paths.js +2 -0
- package/src/session/recall/embedder.js +181 -0
- package/src/session/recall/entities.js +0 -0
- package/src/session/recall/index-build.js +38 -0
- package/src/session/recall/index-core.js +108 -0
- package/src/session/recall/index.js +131 -0
- package/src/session/recall/needle-eval.js +279 -0
- package/src/session/recall/observation-core.js +183 -0
- package/src/session/recall/observations.js +21 -0
- package/src/session/recall/provenance.js +153 -0
- package/src/session/recall/retrieve.js +374 -0
- package/src/session/recall/text.js +0 -0
- package/src/session/recall/token-stats.js +81 -0
- package/src/session/recap.js +54 -26
- package/src/session/remote-hydrate.js +12 -20
- package/src/session/store.js +13 -14
- package/src/session/stream.js +23 -3
- package/src/session/sync.js +789 -22
- package/src/session/tickets.js +316 -0
- package/src/session/title-sync.js +2 -1
- package/src/session/usage-report.js +41 -2
- package/src/session/wake/pump.js +8 -3
- package/src/session/wake/resolve-target.js +10 -5
package/src/session/sync.js
CHANGED
|
@@ -2,9 +2,18 @@ import fs from "node:fs";
|
|
|
2
2
|
import os from "node:os";
|
|
3
3
|
import path from "node:path";
|
|
4
4
|
import process from "node:process";
|
|
5
|
+
import { randomUUID } from "node:crypto";
|
|
5
6
|
|
|
6
7
|
import { resolveActiveAuthSession } from "../auth/service.js";
|
|
7
8
|
import { createAgentEvent } from "../events/schema.js";
|
|
9
|
+
import { isSessionControlEvent } from "./control-events.js";
|
|
10
|
+
import { createSessionMutationHeaders } from "./invitations.js";
|
|
11
|
+
import { messageRevision } from "./message-edits.js";
|
|
12
|
+
import { installTestEgressGuard } from "../net/test-egress-guard.js";
|
|
13
|
+
|
|
14
|
+
// No-op outside tests; inside a test process (or a child it spawned) every fetch
|
|
15
|
+
// to a non-loopback host is refused before a socket opens.
|
|
16
|
+
installTestEgressGuard();
|
|
8
17
|
|
|
9
18
|
const DEFAULT_API_BASE_URL = "https://api.sentinelayer.com";
|
|
10
19
|
const DEFAULT_SYNC_TIMEOUT_MS = 5_000;
|
|
@@ -17,6 +26,26 @@ const HUMAN_MESSAGE_FETCH_LIMIT = 50;
|
|
|
17
26
|
const SESSION_EVENT_FETCH_LIMIT = 200;
|
|
18
27
|
const SESSION_ACTION_FETCH_LIMIT = 500;
|
|
19
28
|
const SESSION_SEARCH_FETCH_LIMIT = 50;
|
|
29
|
+
const NON_SEMANTIC_TRANSCRIPT_EVENT_TYPES = new Set([
|
|
30
|
+
"agent_heartbeat",
|
|
31
|
+
"agent_identified",
|
|
32
|
+
"agent_identity",
|
|
33
|
+
"agent_join",
|
|
34
|
+
"agent_leave",
|
|
35
|
+
"agent_left",
|
|
36
|
+
"agent_status",
|
|
37
|
+
"context_briefing",
|
|
38
|
+
"session_recap",
|
|
39
|
+
"session_view",
|
|
40
|
+
"view",
|
|
41
|
+
]);
|
|
42
|
+
|
|
43
|
+
function isNonSemanticTranscriptEvent(event, eventType) {
|
|
44
|
+
return (
|
|
45
|
+
NON_SEMANTIC_TRANSCRIPT_EVENT_TYPES.has(eventType) ||
|
|
46
|
+
isSessionControlEvent(event)
|
|
47
|
+
);
|
|
48
|
+
}
|
|
20
49
|
|
|
21
50
|
// Audit §2.9: crash-recovery contract for in-memory circuit state.
|
|
22
51
|
// Persist outbound/inbound circuit state to disk so a process restart
|
|
@@ -305,6 +334,11 @@ function isApiHostAllowed(urlString) {
|
|
|
305
334
|
function resolveApiBaseUrl(session = {}) {
|
|
306
335
|
const apiUrl = normalizeString(session.apiUrl) || DEFAULT_API_BASE_URL;
|
|
307
336
|
const normalized = apiUrl.replace(/\/+$/, "");
|
|
337
|
+
if (!isApiHostAllowed(normalized) && session.source === "session_admission") {
|
|
338
|
+
// An admission credential is bound to the API that issued it. Redirecting it to
|
|
339
|
+
// the default host would hand it to an API it was never meant for: refuse.
|
|
340
|
+
throw new Error("Refusing to send an agent admission credential to a host other than the API that issued it.");
|
|
341
|
+
}
|
|
308
342
|
if (!isApiHostAllowed(normalized)) {
|
|
309
343
|
// Reject tampered session.apiUrl and fall back to the default.
|
|
310
344
|
// Caller will see API calls land on the canonical host, not an attacker's.
|
|
@@ -612,12 +646,12 @@ async function readResponseJsonSafely(response, timeoutMs = DEFAULT_SYNC_TIMEOUT
|
|
|
612
646
|
}
|
|
613
647
|
}
|
|
614
648
|
|
|
615
|
-
async function fetchJsonWithFullTimeout(
|
|
649
|
+
export async function fetchJsonWithFullTimeout(
|
|
616
650
|
url,
|
|
617
651
|
options,
|
|
618
652
|
timeoutMs,
|
|
619
653
|
fetchImpl = fetchWithTimeout,
|
|
620
|
-
{ readErrorBody = false } = {},
|
|
654
|
+
{ readErrorBody = false, readSuccessBody = true, errorBodyStatuses = [] } = {},
|
|
621
655
|
) {
|
|
622
656
|
const resolvedTimeoutMs = normalizePositiveInteger(timeoutMs, DEFAULT_SYNC_TIMEOUT_MS);
|
|
623
657
|
const controller = new AbortController();
|
|
@@ -638,13 +672,15 @@ async function fetchJsonWithFullTimeout(
|
|
|
638
672
|
);
|
|
639
673
|
|
|
640
674
|
let payload = {};
|
|
641
|
-
if (response?.ok || readErrorBody) {
|
|
675
|
+
if ((response?.ok && readSuccessBody) || readErrorBody || errorBodyStatuses.includes(response?.status)) {
|
|
642
676
|
const elapsedMs = Math.max(0, Date.now() - startedAtMs);
|
|
643
677
|
const remainingMs = Math.max(1, resolvedTimeoutMs - elapsedMs);
|
|
644
678
|
payload = await readResponseJsonWithTimeout(response, remainingMs, {
|
|
645
679
|
fallback: {},
|
|
646
680
|
onTimeout: () => abortRequestController(controller, "response_body_timeout"),
|
|
647
681
|
});
|
|
682
|
+
} else {
|
|
683
|
+
cancelResponseBody(response);
|
|
648
684
|
}
|
|
649
685
|
return { response, payload };
|
|
650
686
|
}
|
|
@@ -713,19 +749,29 @@ async function processSseBlock(block, handlers) {
|
|
|
713
749
|
if (!normalizedBlock) return;
|
|
714
750
|
|
|
715
751
|
const dataLines = [];
|
|
716
|
-
let
|
|
752
|
+
let eventId = "";
|
|
753
|
+
let hasComment = false;
|
|
717
754
|
for (const rawLine of String(block).split("\n")) {
|
|
718
755
|
const line = rawLine.trimEnd();
|
|
719
756
|
if (!line) continue;
|
|
720
|
-
if (line.startsWith(":"))
|
|
721
|
-
|
|
757
|
+
if (line.startsWith(":")) {
|
|
758
|
+
hasComment = true;
|
|
759
|
+
continue;
|
|
760
|
+
}
|
|
761
|
+
if (line.startsWith("id:")) {
|
|
762
|
+
eventId = line.slice(3).trimStart();
|
|
763
|
+
continue;
|
|
764
|
+
}
|
|
722
765
|
if (line.startsWith("data:")) {
|
|
723
766
|
dataLines.push(line.slice(5).trimStart());
|
|
724
767
|
}
|
|
725
768
|
}
|
|
726
769
|
|
|
727
770
|
if (dataLines.length === 0) {
|
|
728
|
-
if (
|
|
771
|
+
if (eventId && typeof handlers.onCursor === "function") {
|
|
772
|
+
await handlers.onCursor(eventId);
|
|
773
|
+
}
|
|
774
|
+
if (hasComment && typeof handlers.onHeartbeat === "function") {
|
|
729
775
|
await handlers.onHeartbeat();
|
|
730
776
|
}
|
|
731
777
|
return;
|
|
@@ -835,6 +881,18 @@ function buildHumanRelayEvent(sessionId, message = {}) {
|
|
|
835
881
|
agentId,
|
|
836
882
|
sessionId,
|
|
837
883
|
ts,
|
|
884
|
+
// The compatibility human-poll lane must preserve canonical revision and
|
|
885
|
+
// identity too; otherwise an edited body is mistaken for a fresh directive
|
|
886
|
+
// when its paired durable edit notification has not reached this poller.
|
|
887
|
+
id: message.eventId || message.event_id || undefined,
|
|
888
|
+
eventId: message.eventId || message.event_id || undefined,
|
|
889
|
+
cursor: message.cursor || undefined,
|
|
890
|
+
sequenceId: message.sequenceId ?? message.sequence_id,
|
|
891
|
+
messageRevision: messageRevision(message),
|
|
892
|
+
editedAt: message.editedAt,
|
|
893
|
+
editedBy: message.editedBy,
|
|
894
|
+
canEdit: message.canEdit,
|
|
895
|
+
revisionEvidence: message.revisionEvidence,
|
|
838
896
|
payload: {
|
|
839
897
|
message: sanitization.message,
|
|
840
898
|
channel: "session",
|
|
@@ -875,14 +933,22 @@ export async function syncSessionEventToApi(
|
|
|
875
933
|
if (!normalizedSessionId || !event || typeof event !== "object" || Array.isArray(event)) {
|
|
876
934
|
return { synced: false, reason: "invalid_input" };
|
|
877
935
|
}
|
|
936
|
+
const eventType = normalizeString(event.event || event.type).toLowerCase();
|
|
937
|
+
if (isNonSemanticTranscriptEvent(event, eventType)) {
|
|
938
|
+
return {
|
|
939
|
+
synced: false,
|
|
940
|
+
reason: "non_semantic_transcript_event_rejected",
|
|
941
|
+
eventType,
|
|
942
|
+
};
|
|
943
|
+
}
|
|
878
944
|
|
|
879
945
|
// Test-fixture leak guard. Tests in this repo (and downstream consumers)
|
|
880
946
|
// create + tear down sessions using a temp workspace; on a developer
|
|
881
947
|
// machine those calls inherit the user's stored auth and silently posted
|
|
882
948
|
// hundreds of orphan rooms to prod (Carter saw ~200 "<null>" sessions).
|
|
883
949
|
// Honoring SENTINELAYER_SKIP_REMOTE_SYNC=1 keeps everything local while
|
|
884
|
-
// still exercising the appendToStream
|
|
885
|
-
//
|
|
950
|
+
// still exercising the appendToStream paths the tests care about. Local
|
|
951
|
+
// NDJSON durability is unaffected.
|
|
886
952
|
if (String(process.env.SENTINELAYER_SKIP_REMOTE_SYNC || "").trim() === "1") {
|
|
887
953
|
return { synced: false, reason: "remote_sync_disabled_env" };
|
|
888
954
|
}
|
|
@@ -942,9 +1008,14 @@ export async function syncSessionEventToApi(
|
|
|
942
1008
|
body: requestBody,
|
|
943
1009
|
};
|
|
944
1010
|
const resolvedTimeoutMs = normalizePositiveInteger(timeoutMs, DEFAULT_SYNC_TIMEOUT_MS);
|
|
1011
|
+
const deadlineMs = Date.now() + resolvedTimeoutMs;
|
|
1012
|
+
const remainingMs = () => Math.max(1, deadlineMs - Date.now());
|
|
945
1013
|
|
|
946
1014
|
try {
|
|
947
|
-
const response = await
|
|
1015
|
+
const { response, payload: forbiddenBody } = await fetchJsonWithFullTimeout(
|
|
1016
|
+
endpoint, requestInit, remainingMs(), fetchImpl,
|
|
1017
|
+
{ readSuccessBody: false, errorBodyStatuses: [403] },
|
|
1018
|
+
);
|
|
948
1019
|
|
|
949
1020
|
if (response && response.ok) {
|
|
950
1021
|
recordCircuitSuccess(outboundCircuit);
|
|
@@ -960,7 +1031,7 @@ export async function syncSessionEventToApi(
|
|
|
960
1031
|
// their agents "talking" locally while the API has zero record. We attempt
|
|
961
1032
|
// the grant once per agentId per process, then retry the event POST once.
|
|
962
1033
|
if (response && response.status === 403) {
|
|
963
|
-
const body =
|
|
1034
|
+
const body = forbiddenBody;
|
|
964
1035
|
if (isIdentityForgeryBody(body)) {
|
|
965
1036
|
const agentId = normalizeString(event?.agent?.id);
|
|
966
1037
|
if (!agentId || isReservedAgentIdForGrant(agentId)) {
|
|
@@ -982,7 +1053,7 @@ export async function syncSessionEventToApi(
|
|
|
982
1053
|
const grantEndpoint = `${apiBaseUrl}/api/v1/sessions/agent-grants`;
|
|
983
1054
|
let grantResponse = null;
|
|
984
1055
|
try {
|
|
985
|
-
grantResponse = await
|
|
1056
|
+
({ response: grantResponse } = await fetchJsonWithFullTimeout(
|
|
986
1057
|
grantEndpoint,
|
|
987
1058
|
{
|
|
988
1059
|
method: "POST",
|
|
@@ -995,8 +1066,8 @@ export async function syncSessionEventToApi(
|
|
|
995
1066
|
role: grantRole,
|
|
996
1067
|
}),
|
|
997
1068
|
},
|
|
998
|
-
|
|
999
|
-
);
|
|
1069
|
+
remainingMs(), fetchImpl, { readSuccessBody: false },
|
|
1070
|
+
));
|
|
1000
1071
|
} catch (grantError) {
|
|
1001
1072
|
recordCircuitFailure(outboundCircuit, normalizedNowMs);
|
|
1002
1073
|
return {
|
|
@@ -1020,7 +1091,9 @@ export async function syncSessionEventToApi(
|
|
|
1020
1091
|
// Retry the original event POST exactly once.
|
|
1021
1092
|
let retryResponse;
|
|
1022
1093
|
try {
|
|
1023
|
-
retryResponse = await
|
|
1094
|
+
({ response: retryResponse } = await fetchJsonWithFullTimeout(
|
|
1095
|
+
endpoint, requestInit, remainingMs(), fetchImpl, { readSuccessBody: false },
|
|
1096
|
+
));
|
|
1024
1097
|
} catch (retryError) {
|
|
1025
1098
|
recordCircuitFailure(outboundCircuit, normalizedNowMs);
|
|
1026
1099
|
return {
|
|
@@ -1116,7 +1189,7 @@ async function syncSessionAuxPayload(
|
|
|
1116
1189
|
const apiBaseUrl = resolveApiBaseUrl(session);
|
|
1117
1190
|
const endpoint = `${apiBaseUrl}/api/v1/sessions/${encodeURIComponent(normalizedSessionId)}${pathSuffix}`;
|
|
1118
1191
|
try {
|
|
1119
|
-
const response = await
|
|
1192
|
+
const { response } = await fetchJsonWithFullTimeout(
|
|
1120
1193
|
endpoint,
|
|
1121
1194
|
{
|
|
1122
1195
|
method: "POST",
|
|
@@ -1129,7 +1202,9 @@ async function syncSessionAuxPayload(
|
|
|
1129
1202
|
source: "cli",
|
|
1130
1203
|
}),
|
|
1131
1204
|
},
|
|
1132
|
-
normalizePositiveInteger(timeoutMs, DEFAULT_SYNC_TIMEOUT_MS)
|
|
1205
|
+
normalizePositiveInteger(timeoutMs, DEFAULT_SYNC_TIMEOUT_MS),
|
|
1206
|
+
fetchImpl,
|
|
1207
|
+
{ readSuccessBody: false },
|
|
1133
1208
|
);
|
|
1134
1209
|
if (!response || !response.ok) {
|
|
1135
1210
|
recordCircuitFailure(outboundCircuit, normalizedNowMs);
|
|
@@ -1325,18 +1400,21 @@ export async function pollHumanMessages(
|
|
|
1325
1400
|
* / `agent_response` events. The result was codex and claude talking
|
|
1326
1401
|
* past each other ("Apologies — I missed your 5 updates").
|
|
1327
1402
|
*
|
|
1328
|
-
* Endpoint contract:
|
|
1403
|
+
* Endpoint contract:
|
|
1404
|
+
* `GET /api/v1/sessions/{id}/events?after=<cursor>&limit=N&listenerAgentId=<id>`.
|
|
1329
1405
|
* The API returns events in chronological order with cursor-based
|
|
1330
|
-
* pagination.
|
|
1331
|
-
* `
|
|
1406
|
+
* pagination. When `listenerAgentId` is present, Redis-backed listener
|
|
1407
|
+
* controls are returned separately in `listenerControls`; they never join
|
|
1408
|
+
* the durable event list or participate in its cursor.
|
|
1332
1409
|
*
|
|
1333
1410
|
* @param {string} sessionId
|
|
1334
1411
|
* @param {object} [options]
|
|
1335
1412
|
* @param {string} [options.targetPath]
|
|
1336
1413
|
* @param {string|null} [options.since] - cursor to start after; null = full history
|
|
1337
1414
|
* @param {number} [options.limit] - default 200 (max from API)
|
|
1415
|
+
* @param {string} [options.listenerAgentId] - optional listener control recipient
|
|
1338
1416
|
* @param {number} [options.timeoutMs] - per-request deadline
|
|
1339
|
-
* @returns {Promise<{ok: boolean, reason: string, events: Array<object>, cursor: string|null}>}
|
|
1417
|
+
* @returns {Promise<{ok: boolean, reason: string, events: Array<object>, listenerControls: Array<object>, cursor: string|null, scannedThroughSequence: number|null, scanExhausted: boolean|null}>}
|
|
1340
1418
|
*/
|
|
1341
1419
|
export async function pollSessionEvents(
|
|
1342
1420
|
sessionId,
|
|
@@ -1344,6 +1422,7 @@ export async function pollSessionEvents(
|
|
|
1344
1422
|
targetPath = process.cwd(),
|
|
1345
1423
|
since = null,
|
|
1346
1424
|
limit = 200,
|
|
1425
|
+
listenerAgentId = "",
|
|
1347
1426
|
timeoutMs = DEFAULT_SYNC_TIMEOUT_MS,
|
|
1348
1427
|
forceCircuitProbe = false,
|
|
1349
1428
|
resolveAuthSession = resolveActiveAuthSession,
|
|
@@ -1357,6 +1436,7 @@ export async function pollSessionEvents(
|
|
|
1357
1436
|
ok: false,
|
|
1358
1437
|
reason: "invalid_session_id",
|
|
1359
1438
|
events: [],
|
|
1439
|
+
listenerControls: [],
|
|
1360
1440
|
cursor: normalizeString(since) || null,
|
|
1361
1441
|
};
|
|
1362
1442
|
}
|
|
@@ -1367,6 +1447,7 @@ export async function pollSessionEvents(
|
|
|
1367
1447
|
ok: false,
|
|
1368
1448
|
reason: "circuit_breaker_open",
|
|
1369
1449
|
events: [],
|
|
1450
|
+
listenerControls: [],
|
|
1370
1451
|
cursor: normalizeString(since) || null,
|
|
1371
1452
|
};
|
|
1372
1453
|
}
|
|
@@ -1383,6 +1464,7 @@ export async function pollSessionEvents(
|
|
|
1383
1464
|
ok: false,
|
|
1384
1465
|
reason: "no_session",
|
|
1385
1466
|
events: [],
|
|
1467
|
+
listenerControls: [],
|
|
1386
1468
|
cursor: normalizeString(since) || null,
|
|
1387
1469
|
};
|
|
1388
1470
|
}
|
|
@@ -1391,6 +1473,7 @@ export async function pollSessionEvents(
|
|
|
1391
1473
|
ok: false,
|
|
1392
1474
|
reason: "not_authenticated",
|
|
1393
1475
|
events: [],
|
|
1476
|
+
listenerControls: [],
|
|
1394
1477
|
cursor: normalizeString(since) || null,
|
|
1395
1478
|
};
|
|
1396
1479
|
}
|
|
@@ -1401,6 +1484,10 @@ export async function pollSessionEvents(
|
|
|
1401
1484
|
if (normalizedSince) {
|
|
1402
1485
|
query.set("after", normalizedSince);
|
|
1403
1486
|
}
|
|
1487
|
+
const normalizedListenerAgentId = normalizeString(listenerAgentId);
|
|
1488
|
+
if (normalizedListenerAgentId) {
|
|
1489
|
+
query.set("listenerAgentId", normalizedListenerAgentId);
|
|
1490
|
+
}
|
|
1404
1491
|
query.set(
|
|
1405
1492
|
"limit",
|
|
1406
1493
|
String(Math.max(1, Math.min(SESSION_EVENT_FETCH_LIMIT, normalizePositiveInteger(limit, SESSION_EVENT_FETCH_LIMIT))))
|
|
@@ -1426,6 +1513,7 @@ export async function pollSessionEvents(
|
|
|
1426
1513
|
status: response?.status || 429,
|
|
1427
1514
|
retryAfterMs: retryAfterMsFromResponse(response, payload, normalizedNowMs),
|
|
1428
1515
|
events: [],
|
|
1516
|
+
listenerControls: [],
|
|
1429
1517
|
cursor: normalizedSince || null,
|
|
1430
1518
|
};
|
|
1431
1519
|
}
|
|
@@ -1434,12 +1522,23 @@ export async function pollSessionEvents(
|
|
|
1434
1522
|
ok: false,
|
|
1435
1523
|
reason: `api_${response ? response.status : "no_response"}`,
|
|
1436
1524
|
events: [],
|
|
1525
|
+
listenerControls: [],
|
|
1437
1526
|
cursor: normalizedSince || null,
|
|
1438
1527
|
};
|
|
1439
1528
|
}
|
|
1440
1529
|
recordCircuitSuccess(inboundCircuit);
|
|
1441
1530
|
|
|
1442
1531
|
const items = Array.isArray(payload?.events) ? payload.events : [];
|
|
1532
|
+
const listenerControlProjection =
|
|
1533
|
+
payload?.listenerControls ?? payload?.listener_controls;
|
|
1534
|
+
const listenerControlItems = Array.isArray(listenerControlProjection)
|
|
1535
|
+
? listenerControlProjection
|
|
1536
|
+
: Array.isArray(listenerControlProjection?.items)
|
|
1537
|
+
? listenerControlProjection.items
|
|
1538
|
+
: [];
|
|
1539
|
+
const listenerControls = listenerControlItems.filter(
|
|
1540
|
+
(control) => control && typeof control === "object" && !Array.isArray(control),
|
|
1541
|
+
);
|
|
1443
1542
|
const acceptedEvents = [];
|
|
1444
1543
|
let lastCursor = normalizedSince || null;
|
|
1445
1544
|
for (const item of items) {
|
|
@@ -1450,12 +1549,23 @@ export async function pollSessionEvents(
|
|
|
1450
1549
|
// envelope shape that appendToStream expects.
|
|
1451
1550
|
acceptedEvents.push(item);
|
|
1452
1551
|
}
|
|
1552
|
+
const responseCursor =
|
|
1553
|
+
normalizeString(payload?.nextCursor || payload?.next_cursor || payload?.cursor) ||
|
|
1554
|
+
lastCursor;
|
|
1555
|
+
const scannedThroughSequence = normalizePositiveInteger(
|
|
1556
|
+
payload?.scannedThroughSequence ?? payload?.scanned_through_sequence,
|
|
1557
|
+
null,
|
|
1558
|
+
);
|
|
1559
|
+
const rawScanExhausted = payload?.scanExhausted ?? payload?.scan_exhausted;
|
|
1453
1560
|
|
|
1454
1561
|
return {
|
|
1455
1562
|
ok: true,
|
|
1456
1563
|
reason: "",
|
|
1457
1564
|
events: acceptedEvents,
|
|
1458
|
-
|
|
1565
|
+
listenerControls,
|
|
1566
|
+
cursor: responseCursor,
|
|
1567
|
+
scannedThroughSequence,
|
|
1568
|
+
scanExhausted: typeof rawScanExhausted === "boolean" ? rawScanExhausted : null,
|
|
1459
1569
|
};
|
|
1460
1570
|
} catch (error) {
|
|
1461
1571
|
recordCircuitFailure(inboundCircuit, normalizedNowMs);
|
|
@@ -1463,11 +1573,546 @@ export async function pollSessionEvents(
|
|
|
1463
1573
|
ok: false,
|
|
1464
1574
|
reason: normalizeString(error?.message) || "poll_failed",
|
|
1465
1575
|
events: [],
|
|
1576
|
+
listenerControls: [],
|
|
1466
1577
|
cursor: normalizedSince || null,
|
|
1467
1578
|
};
|
|
1468
1579
|
}
|
|
1469
1580
|
}
|
|
1470
1581
|
|
|
1582
|
+
/**
|
|
1583
|
+
* Read the membership-gated, three-state ephemeral listener roster.
|
|
1584
|
+
*
|
|
1585
|
+
* Compatibility is deliberately fail-closed: an older server (404), a server
|
|
1586
|
+
* with presence disabled, or a degraded Redis dependency is reported as
|
|
1587
|
+
* unknown/unsupported. Callers must never reconstruct liveness by scanning
|
|
1588
|
+
* durable listener heartbeat events.
|
|
1589
|
+
*/
|
|
1590
|
+
export async function fetchSessionPresence(
|
|
1591
|
+
sessionId,
|
|
1592
|
+
{
|
|
1593
|
+
targetPath = process.cwd(),
|
|
1594
|
+
timeoutMs = DEFAULT_SYNC_TIMEOUT_MS,
|
|
1595
|
+
forceCircuitProbe = false,
|
|
1596
|
+
resolveAuthSession = resolveActiveAuthSession,
|
|
1597
|
+
fetchImpl = fetchWithTimeout,
|
|
1598
|
+
nowMs = Date.now,
|
|
1599
|
+
} = {},
|
|
1600
|
+
) {
|
|
1601
|
+
const normalizedSessionId = normalizeString(sessionId);
|
|
1602
|
+
if (!normalizedSessionId) {
|
|
1603
|
+
return {
|
|
1604
|
+
ok: false,
|
|
1605
|
+
reason: "invalid_session_id",
|
|
1606
|
+
status: "unsupported",
|
|
1607
|
+
enabled: false,
|
|
1608
|
+
present: [],
|
|
1609
|
+
};
|
|
1610
|
+
}
|
|
1611
|
+
|
|
1612
|
+
const normalizedNowMs = Number(nowMs()) || Date.now();
|
|
1613
|
+
if (!forceCircuitProbe && isCircuitOpen(inboundCircuit, normalizedNowMs)) {
|
|
1614
|
+
return {
|
|
1615
|
+
ok: false,
|
|
1616
|
+
reason: "circuit_breaker_open",
|
|
1617
|
+
status: "degraded",
|
|
1618
|
+
enabled: null,
|
|
1619
|
+
present: [],
|
|
1620
|
+
};
|
|
1621
|
+
}
|
|
1622
|
+
|
|
1623
|
+
let session = null;
|
|
1624
|
+
try {
|
|
1625
|
+
session = await resolveAuthSession({
|
|
1626
|
+
cwd: targetPath,
|
|
1627
|
+
env: process.env,
|
|
1628
|
+
autoRotate: false,
|
|
1629
|
+
});
|
|
1630
|
+
} catch {
|
|
1631
|
+
return {
|
|
1632
|
+
ok: false,
|
|
1633
|
+
reason: "no_session",
|
|
1634
|
+
status: "unsupported",
|
|
1635
|
+
enabled: null,
|
|
1636
|
+
present: [],
|
|
1637
|
+
};
|
|
1638
|
+
}
|
|
1639
|
+
if (!session?.token) {
|
|
1640
|
+
return {
|
|
1641
|
+
ok: false,
|
|
1642
|
+
reason: "not_authenticated",
|
|
1643
|
+
status: "unsupported",
|
|
1644
|
+
enabled: null,
|
|
1645
|
+
present: [],
|
|
1646
|
+
};
|
|
1647
|
+
}
|
|
1648
|
+
|
|
1649
|
+
const apiBaseUrl = resolveApiBaseUrl(session);
|
|
1650
|
+
const endpoint = `${apiBaseUrl}/api/v1/sessions/${encodeURIComponent(normalizedSessionId)}/presence`;
|
|
1651
|
+
try {
|
|
1652
|
+
const { response, payload } = await fetchJsonWithFullTimeout(
|
|
1653
|
+
endpoint,
|
|
1654
|
+
{
|
|
1655
|
+
method: "GET",
|
|
1656
|
+
headers: { Authorization: `Bearer ${session.token}` },
|
|
1657
|
+
},
|
|
1658
|
+
normalizePositiveInteger(timeoutMs, DEFAULT_SYNC_TIMEOUT_MS),
|
|
1659
|
+
fetchImpl,
|
|
1660
|
+
);
|
|
1661
|
+
|
|
1662
|
+
if (!response?.ok) {
|
|
1663
|
+
if (response?.status === 404) {
|
|
1664
|
+
return {
|
|
1665
|
+
ok: false,
|
|
1666
|
+
reason: "presence_unsupported",
|
|
1667
|
+
status: "unsupported",
|
|
1668
|
+
enabled: false,
|
|
1669
|
+
present: [],
|
|
1670
|
+
};
|
|
1671
|
+
}
|
|
1672
|
+
if (isRateLimitResponse(response, payload)) {
|
|
1673
|
+
return {
|
|
1674
|
+
ok: false,
|
|
1675
|
+
reason: "rate_limited",
|
|
1676
|
+
status: "degraded",
|
|
1677
|
+
enabled: null,
|
|
1678
|
+
retryAfterMs: retryAfterMsFromResponse(response, payload, normalizedNowMs),
|
|
1679
|
+
present: [],
|
|
1680
|
+
};
|
|
1681
|
+
}
|
|
1682
|
+
recordCircuitFailure(inboundCircuit, normalizedNowMs);
|
|
1683
|
+
return {
|
|
1684
|
+
ok: false,
|
|
1685
|
+
reason: `api_${response ? response.status : "no_response"}`,
|
|
1686
|
+
status: "degraded",
|
|
1687
|
+
enabled: null,
|
|
1688
|
+
retryAfterMs: retryAfterMsFromResponse(response, payload, normalizedNowMs),
|
|
1689
|
+
present: [],
|
|
1690
|
+
};
|
|
1691
|
+
}
|
|
1692
|
+
|
|
1693
|
+
recordCircuitSuccess(inboundCircuit);
|
|
1694
|
+
const enabled = payload?.enabled !== false;
|
|
1695
|
+
const status = enabled
|
|
1696
|
+
? normalizeString(payload?.status).toLowerCase() || "ok"
|
|
1697
|
+
: "unsupported";
|
|
1698
|
+
const present = enabled && Array.isArray(payload?.present)
|
|
1699
|
+
? payload.present.filter((entry) => entry && typeof entry === "object")
|
|
1700
|
+
: [];
|
|
1701
|
+
return {
|
|
1702
|
+
ok: enabled && status === "ok",
|
|
1703
|
+
reason: enabled
|
|
1704
|
+
? status === "ok"
|
|
1705
|
+
? ""
|
|
1706
|
+
: "presence_degraded"
|
|
1707
|
+
: "presence_disabled",
|
|
1708
|
+
status: status === "ok" ? "ok" : status === "unsupported" ? "unsupported" : "degraded",
|
|
1709
|
+
enabled,
|
|
1710
|
+
present,
|
|
1711
|
+
};
|
|
1712
|
+
} catch (error) {
|
|
1713
|
+
recordCircuitFailure(inboundCircuit, normalizedNowMs);
|
|
1714
|
+
return {
|
|
1715
|
+
ok: false,
|
|
1716
|
+
reason: normalizeString(error?.message) || "presence_read_failed",
|
|
1717
|
+
status: "degraded",
|
|
1718
|
+
enabled: null,
|
|
1719
|
+
present: [],
|
|
1720
|
+
};
|
|
1721
|
+
}
|
|
1722
|
+
}
|
|
1723
|
+
|
|
1724
|
+
/**
|
|
1725
|
+
* Renew one listener's ephemeral TTL. This endpoint is a hard protocol
|
|
1726
|
+
* boundary: it never falls back to POST /events on older or degraded servers.
|
|
1727
|
+
*/
|
|
1728
|
+
export async function renewSessionPresence(
|
|
1729
|
+
sessionId,
|
|
1730
|
+
{
|
|
1731
|
+
targetPath = process.cwd(),
|
|
1732
|
+
agentId,
|
|
1733
|
+
state = "idle",
|
|
1734
|
+
listenerId = "",
|
|
1735
|
+
model = "",
|
|
1736
|
+
displayName = "",
|
|
1737
|
+
provider = "",
|
|
1738
|
+
clientKind = "cli",
|
|
1739
|
+
observedAt = new Date().toISOString(),
|
|
1740
|
+
timeoutMs = DEFAULT_SYNC_TIMEOUT_MS,
|
|
1741
|
+
signal = undefined,
|
|
1742
|
+
resolveAuthSession = resolveActiveAuthSession,
|
|
1743
|
+
fetchImpl = fetchWithTimeout,
|
|
1744
|
+
nowMs = Date.now,
|
|
1745
|
+
} = {},
|
|
1746
|
+
) {
|
|
1747
|
+
const normalizedSessionId = normalizeString(sessionId);
|
|
1748
|
+
const normalizedAgentId = normalizeString(agentId);
|
|
1749
|
+
if (!normalizedSessionId || !normalizedAgentId) {
|
|
1750
|
+
return {
|
|
1751
|
+
ok: false,
|
|
1752
|
+
reason: "invalid_input",
|
|
1753
|
+
status: "unsupported",
|
|
1754
|
+
recorded: false,
|
|
1755
|
+
};
|
|
1756
|
+
}
|
|
1757
|
+
if (String(process.env.SENTINELAYER_SKIP_REMOTE_SYNC || "").trim() === "1") {
|
|
1758
|
+
return {
|
|
1759
|
+
ok: false,
|
|
1760
|
+
reason: "remote_sync_disabled_env",
|
|
1761
|
+
status: "unsupported",
|
|
1762
|
+
recorded: false,
|
|
1763
|
+
};
|
|
1764
|
+
}
|
|
1765
|
+
|
|
1766
|
+
const normalizedNowMs = Number(nowMs()) || Date.now();
|
|
1767
|
+
if (isCircuitOpen(outboundCircuit, normalizedNowMs)) {
|
|
1768
|
+
return {
|
|
1769
|
+
ok: false,
|
|
1770
|
+
reason: "circuit_breaker_open",
|
|
1771
|
+
status: "degraded",
|
|
1772
|
+
recorded: false,
|
|
1773
|
+
};
|
|
1774
|
+
}
|
|
1775
|
+
|
|
1776
|
+
let session = null;
|
|
1777
|
+
try {
|
|
1778
|
+
session = await resolveAuthSession({
|
|
1779
|
+
cwd: targetPath,
|
|
1780
|
+
env: process.env,
|
|
1781
|
+
autoRotate: false,
|
|
1782
|
+
});
|
|
1783
|
+
} catch {
|
|
1784
|
+
return {
|
|
1785
|
+
ok: false,
|
|
1786
|
+
reason: "no_session",
|
|
1787
|
+
status: "unsupported",
|
|
1788
|
+
recorded: false,
|
|
1789
|
+
};
|
|
1790
|
+
}
|
|
1791
|
+
if (!session?.token) {
|
|
1792
|
+
return {
|
|
1793
|
+
ok: false,
|
|
1794
|
+
reason: "not_authenticated",
|
|
1795
|
+
status: "unsupported",
|
|
1796
|
+
recorded: false,
|
|
1797
|
+
};
|
|
1798
|
+
}
|
|
1799
|
+
|
|
1800
|
+
const apiBaseUrl = resolveApiBaseUrl(session);
|
|
1801
|
+
const endpoint = `${apiBaseUrl}/api/v1/sessions/${encodeURIComponent(normalizedSessionId)}/presence`;
|
|
1802
|
+
const body = {
|
|
1803
|
+
agentId: normalizedAgentId,
|
|
1804
|
+
state: normalizeString(state).toLowerCase() || "idle",
|
|
1805
|
+
listenerId: normalizeString(listenerId) || undefined,
|
|
1806
|
+
model: normalizeString(model) || undefined,
|
|
1807
|
+
displayName: normalizeString(displayName) || undefined,
|
|
1808
|
+
provider: normalizeString(provider) || undefined,
|
|
1809
|
+
clientKind: normalizeString(clientKind) || "cli",
|
|
1810
|
+
observedAt: normalizeIsoTimestamp(observedAt),
|
|
1811
|
+
};
|
|
1812
|
+
|
|
1813
|
+
try {
|
|
1814
|
+
const { response, payload } = await fetchJsonWithFullTimeout(
|
|
1815
|
+
endpoint,
|
|
1816
|
+
{
|
|
1817
|
+
method: "PUT",
|
|
1818
|
+
headers: {
|
|
1819
|
+
"Content-Type": "application/json",
|
|
1820
|
+
Authorization: `Bearer ${session.token}`,
|
|
1821
|
+
},
|
|
1822
|
+
body: JSON.stringify(body),
|
|
1823
|
+
signal,
|
|
1824
|
+
},
|
|
1825
|
+
normalizePositiveInteger(timeoutMs, DEFAULT_SYNC_TIMEOUT_MS),
|
|
1826
|
+
fetchImpl,
|
|
1827
|
+
);
|
|
1828
|
+
if (!response?.ok) {
|
|
1829
|
+
if (response?.status === 404) {
|
|
1830
|
+
return {
|
|
1831
|
+
ok: false,
|
|
1832
|
+
reason: "presence_unsupported",
|
|
1833
|
+
status: "unsupported",
|
|
1834
|
+
recorded: false,
|
|
1835
|
+
};
|
|
1836
|
+
}
|
|
1837
|
+
if (isRateLimitResponse(response, payload)) {
|
|
1838
|
+
return {
|
|
1839
|
+
ok: false,
|
|
1840
|
+
reason: "rate_limited",
|
|
1841
|
+
status: "degraded",
|
|
1842
|
+
recorded: false,
|
|
1843
|
+
retryAfterMs: retryAfterMsFromResponse(response, payload, normalizedNowMs),
|
|
1844
|
+
};
|
|
1845
|
+
}
|
|
1846
|
+
recordCircuitFailure(outboundCircuit, normalizedNowMs);
|
|
1847
|
+
return {
|
|
1848
|
+
ok: false,
|
|
1849
|
+
reason: `api_${response ? response.status : "no_response"}`,
|
|
1850
|
+
status: "degraded",
|
|
1851
|
+
recorded: false,
|
|
1852
|
+
retryAfterMs: retryAfterMsFromResponse(response, payload, normalizedNowMs),
|
|
1853
|
+
};
|
|
1854
|
+
}
|
|
1855
|
+
recordCircuitSuccess(outboundCircuit);
|
|
1856
|
+
const status = normalizeString(payload?.status).toLowerCase() || "ok";
|
|
1857
|
+
return {
|
|
1858
|
+
ok: status === "ok" && Boolean(payload?.recorded ?? true),
|
|
1859
|
+
reason: status === "ok" ? "" : "presence_degraded",
|
|
1860
|
+
status: status === "ok" ? "ok" : "degraded",
|
|
1861
|
+
recorded: Boolean(payload?.recorded ?? status === "ok"),
|
|
1862
|
+
ttlSeconds: normalizePositiveInteger(payload?.ttlSeconds || payload?.ttl_seconds, null),
|
|
1863
|
+
};
|
|
1864
|
+
} catch (error) {
|
|
1865
|
+
recordCircuitFailure(outboundCircuit, normalizedNowMs);
|
|
1866
|
+
return {
|
|
1867
|
+
ok: false,
|
|
1868
|
+
reason: normalizeString(error?.message) || "presence_write_failed",
|
|
1869
|
+
status: "degraded",
|
|
1870
|
+
recorded: false,
|
|
1871
|
+
};
|
|
1872
|
+
}
|
|
1873
|
+
}
|
|
1874
|
+
|
|
1875
|
+
/**
|
|
1876
|
+
* Enqueue an ephemeral Redis-backed listener stop control.
|
|
1877
|
+
*
|
|
1878
|
+
* This endpoint is the only remote delivery path for stop requests. It never
|
|
1879
|
+
* falls back to the durable session event stream.
|
|
1880
|
+
*/
|
|
1881
|
+
export async function requestSessionListenerStop(
|
|
1882
|
+
sessionId,
|
|
1883
|
+
{
|
|
1884
|
+
targetPath = process.cwd(),
|
|
1885
|
+
targetAgentId = "",
|
|
1886
|
+
idempotencyKey = "",
|
|
1887
|
+
timeoutMs = DEFAULT_SYNC_TIMEOUT_MS,
|
|
1888
|
+
resolveAuthSession = resolveActiveAuthSession,
|
|
1889
|
+
fetchImpl = fetchWithTimeout,
|
|
1890
|
+
nowMs = Date.now,
|
|
1891
|
+
} = {},
|
|
1892
|
+
) {
|
|
1893
|
+
const normalizedSessionId = normalizeString(sessionId);
|
|
1894
|
+
if (!normalizedSessionId) {
|
|
1895
|
+
return { ok: false, reason: "invalid_session_id", recorded: false };
|
|
1896
|
+
}
|
|
1897
|
+
if (String(process.env.SENTINELAYER_SKIP_REMOTE_SYNC || "").trim() === "1") {
|
|
1898
|
+
return { ok: false, reason: "remote_sync_disabled_env", recorded: false };
|
|
1899
|
+
}
|
|
1900
|
+
|
|
1901
|
+
const normalizedNowMs = Number(nowMs()) || Date.now();
|
|
1902
|
+
if (isCircuitOpen(outboundCircuit, normalizedNowMs)) {
|
|
1903
|
+
return { ok: false, reason: "circuit_breaker_open", recorded: false };
|
|
1904
|
+
}
|
|
1905
|
+
|
|
1906
|
+
let session = null;
|
|
1907
|
+
try {
|
|
1908
|
+
session = await resolveAuthSession({
|
|
1909
|
+
cwd: targetPath,
|
|
1910
|
+
env: process.env,
|
|
1911
|
+
autoRotate: false,
|
|
1912
|
+
});
|
|
1913
|
+
} catch {
|
|
1914
|
+
return { ok: false, reason: "no_session", recorded: false };
|
|
1915
|
+
}
|
|
1916
|
+
if (!session?.token) {
|
|
1917
|
+
return { ok: false, reason: "not_authenticated", recorded: false };
|
|
1918
|
+
}
|
|
1919
|
+
|
|
1920
|
+
const normalizedTargetAgentId = normalizeString(targetAgentId);
|
|
1921
|
+
const normalizedIdempotencyKey =
|
|
1922
|
+
normalizeString(idempotencyKey) || `sl-listener-stop-${randomUUID()}`;
|
|
1923
|
+
const apiBaseUrl = resolveApiBaseUrl(session);
|
|
1924
|
+
const endpoint = `${apiBaseUrl}/api/v1/sessions/${encodeURIComponent(normalizedSessionId)}/listener-controls/stop`;
|
|
1925
|
+
const body = normalizedTargetAgentId
|
|
1926
|
+
? { targetAgentId: normalizedTargetAgentId }
|
|
1927
|
+
: { broadcast: true };
|
|
1928
|
+
|
|
1929
|
+
try {
|
|
1930
|
+
const { response, payload } = await fetchJsonWithFullTimeout(
|
|
1931
|
+
endpoint,
|
|
1932
|
+
{
|
|
1933
|
+
method: "POST",
|
|
1934
|
+
headers: {
|
|
1935
|
+
"Content-Type": "application/json",
|
|
1936
|
+
Authorization: `Bearer ${session.token}`,
|
|
1937
|
+
"Idempotency-Key": normalizedIdempotencyKey,
|
|
1938
|
+
},
|
|
1939
|
+
body: JSON.stringify(body),
|
|
1940
|
+
},
|
|
1941
|
+
normalizePositiveInteger(timeoutMs, DEFAULT_SYNC_TIMEOUT_MS),
|
|
1942
|
+
fetchImpl,
|
|
1943
|
+
{ readErrorBody: true },
|
|
1944
|
+
);
|
|
1945
|
+
if (!response?.ok) {
|
|
1946
|
+
if (response?.status === 404) {
|
|
1947
|
+
return {
|
|
1948
|
+
ok: false,
|
|
1949
|
+
reason: "listener_control_unsupported",
|
|
1950
|
+
recorded: false,
|
|
1951
|
+
};
|
|
1952
|
+
}
|
|
1953
|
+
if (isRateLimitResponse(response, payload)) {
|
|
1954
|
+
return {
|
|
1955
|
+
ok: false,
|
|
1956
|
+
reason: "rate_limited",
|
|
1957
|
+
recorded: false,
|
|
1958
|
+
retryAfterMs: retryAfterMsFromResponse(response, payload, normalizedNowMs),
|
|
1959
|
+
};
|
|
1960
|
+
}
|
|
1961
|
+
recordCircuitFailure(outboundCircuit, normalizedNowMs);
|
|
1962
|
+
return {
|
|
1963
|
+
ok: false,
|
|
1964
|
+
reason: `api_${response ? response.status : "no_response"}`,
|
|
1965
|
+
recorded: false,
|
|
1966
|
+
};
|
|
1967
|
+
}
|
|
1968
|
+
|
|
1969
|
+
recordCircuitSuccess(outboundCircuit);
|
|
1970
|
+
const control =
|
|
1971
|
+
payload?.control && typeof payload.control === "object" && !Array.isArray(payload.control)
|
|
1972
|
+
? payload.control
|
|
1973
|
+
: payload?.listenerControl &&
|
|
1974
|
+
typeof payload.listenerControl === "object" &&
|
|
1975
|
+
!Array.isArray(payload.listenerControl)
|
|
1976
|
+
? payload.listenerControl
|
|
1977
|
+
: null;
|
|
1978
|
+
return {
|
|
1979
|
+
ok: true,
|
|
1980
|
+
reason: "",
|
|
1981
|
+
recorded: Boolean(payload?.recorded ?? true),
|
|
1982
|
+
control,
|
|
1983
|
+
};
|
|
1984
|
+
} catch (error) {
|
|
1985
|
+
recordCircuitFailure(outboundCircuit, normalizedNowMs);
|
|
1986
|
+
return {
|
|
1987
|
+
ok: false,
|
|
1988
|
+
reason: normalizeString(error?.message) || "listener_control_write_failed",
|
|
1989
|
+
recorded: false,
|
|
1990
|
+
};
|
|
1991
|
+
}
|
|
1992
|
+
}
|
|
1993
|
+
|
|
1994
|
+
/**
|
|
1995
|
+
* Monotonically advance one authenticated actor's read cursor in place.
|
|
1996
|
+
*
|
|
1997
|
+
* No compatibility fallback is allowed: calling the legacy `view` action
|
|
1998
|
+
* could append one durable row per displayed event when the server flag is
|
|
1999
|
+
* disabled or races a rollout.
|
|
2000
|
+
*/
|
|
2001
|
+
export async function updateSessionReadCursor(
|
|
2002
|
+
sessionId,
|
|
2003
|
+
{
|
|
2004
|
+
targetPath = process.cwd(),
|
|
2005
|
+
targetSequenceId = null,
|
|
2006
|
+
targetCursor = "",
|
|
2007
|
+
agentId = "",
|
|
2008
|
+
timeoutMs = DEFAULT_SYNC_TIMEOUT_MS,
|
|
2009
|
+
signal = undefined,
|
|
2010
|
+
resolveAuthSession = resolveActiveAuthSession,
|
|
2011
|
+
fetchImpl = fetchWithTimeout,
|
|
2012
|
+
nowMs = Date.now,
|
|
2013
|
+
} = {},
|
|
2014
|
+
) {
|
|
2015
|
+
const normalizedSessionId = normalizeString(sessionId);
|
|
2016
|
+
const normalizedTargetSequence = Number(targetSequenceId);
|
|
2017
|
+
const normalizedTargetCursor = normalizeString(targetCursor);
|
|
2018
|
+
if (
|
|
2019
|
+
!normalizedSessionId ||
|
|
2020
|
+
((!Number.isFinite(normalizedTargetSequence) || normalizedTargetSequence <= 0) &&
|
|
2021
|
+
!normalizedTargetCursor)
|
|
2022
|
+
) {
|
|
2023
|
+
return { ok: false, reason: "invalid_input", updated: false };
|
|
2024
|
+
}
|
|
2025
|
+
if (String(process.env.SENTINELAYER_SKIP_REMOTE_SYNC || "").trim() === "1") {
|
|
2026
|
+
return { ok: false, reason: "remote_sync_disabled_env", updated: false };
|
|
2027
|
+
}
|
|
2028
|
+
|
|
2029
|
+
const normalizedNowMs = Number(nowMs()) || Date.now();
|
|
2030
|
+
if (isCircuitOpen(outboundCircuit, normalizedNowMs)) {
|
|
2031
|
+
return { ok: false, reason: "circuit_breaker_open", updated: false };
|
|
2032
|
+
}
|
|
2033
|
+
|
|
2034
|
+
let session = null;
|
|
2035
|
+
try {
|
|
2036
|
+
session = await resolveAuthSession({
|
|
2037
|
+
cwd: targetPath,
|
|
2038
|
+
env: process.env,
|
|
2039
|
+
autoRotate: false,
|
|
2040
|
+
});
|
|
2041
|
+
} catch {
|
|
2042
|
+
return { ok: false, reason: "no_session", updated: false };
|
|
2043
|
+
}
|
|
2044
|
+
if (!session?.token) {
|
|
2045
|
+
return { ok: false, reason: "not_authenticated", updated: false };
|
|
2046
|
+
}
|
|
2047
|
+
|
|
2048
|
+
const apiBaseUrl = resolveApiBaseUrl(session);
|
|
2049
|
+
const endpoint = `${apiBaseUrl}/api/v1/sessions/${encodeURIComponent(normalizedSessionId)}/read-cursor`;
|
|
2050
|
+
const body = {
|
|
2051
|
+
targetSequenceId:
|
|
2052
|
+
Number.isFinite(normalizedTargetSequence) && normalizedTargetSequence > 0
|
|
2053
|
+
? Math.floor(normalizedTargetSequence)
|
|
2054
|
+
: undefined,
|
|
2055
|
+
targetCursor: normalizedTargetCursor || undefined,
|
|
2056
|
+
agentId: normalizeString(agentId) || undefined,
|
|
2057
|
+
};
|
|
2058
|
+
try {
|
|
2059
|
+
const { response, payload } = await fetchJsonWithFullTimeout(
|
|
2060
|
+
endpoint,
|
|
2061
|
+
{
|
|
2062
|
+
method: "PUT",
|
|
2063
|
+
headers: {
|
|
2064
|
+
"Content-Type": "application/json",
|
|
2065
|
+
Authorization: `Bearer ${session.token}`,
|
|
2066
|
+
},
|
|
2067
|
+
body: JSON.stringify(body),
|
|
2068
|
+
signal,
|
|
2069
|
+
},
|
|
2070
|
+
normalizePositiveInteger(timeoutMs, DEFAULT_SYNC_TIMEOUT_MS),
|
|
2071
|
+
fetchImpl,
|
|
2072
|
+
);
|
|
2073
|
+
if (!response?.ok) {
|
|
2074
|
+
if (response?.status === 404) {
|
|
2075
|
+
return { ok: false, reason: "read_cursor_unsupported", updated: false };
|
|
2076
|
+
}
|
|
2077
|
+
if (isRateLimitResponse(response, payload)) {
|
|
2078
|
+
return {
|
|
2079
|
+
ok: false,
|
|
2080
|
+
reason: "rate_limited",
|
|
2081
|
+
status: response?.status || 429,
|
|
2082
|
+
retryAfterMs: retryAfterMsFromResponse(response, payload, normalizedNowMs),
|
|
2083
|
+
updated: false,
|
|
2084
|
+
};
|
|
2085
|
+
}
|
|
2086
|
+
recordCircuitFailure(outboundCircuit, normalizedNowMs);
|
|
2087
|
+
return {
|
|
2088
|
+
ok: false,
|
|
2089
|
+
reason: `api_${response ? response.status : "no_response"}`,
|
|
2090
|
+
retryAfterMs: retryAfterMsFromResponse(response, payload, normalizedNowMs),
|
|
2091
|
+
updated: false,
|
|
2092
|
+
};
|
|
2093
|
+
}
|
|
2094
|
+
recordCircuitSuccess(outboundCircuit);
|
|
2095
|
+
return {
|
|
2096
|
+
ok: Boolean(payload?.ok ?? true),
|
|
2097
|
+
reason: "",
|
|
2098
|
+
updated: Boolean(payload?.updated ?? true),
|
|
2099
|
+
lastReadSequenceId: normalizePositiveInteger(
|
|
2100
|
+
payload?.lastReadSequenceId || payload?.last_read_sequence_id,
|
|
2101
|
+
Number.isFinite(normalizedTargetSequence) && normalizedTargetSequence > 0
|
|
2102
|
+
? Math.floor(normalizedTargetSequence)
|
|
2103
|
+
: null,
|
|
2104
|
+
),
|
|
2105
|
+
};
|
|
2106
|
+
} catch (error) {
|
|
2107
|
+
recordCircuitFailure(outboundCircuit, normalizedNowMs);
|
|
2108
|
+
return {
|
|
2109
|
+
ok: false,
|
|
2110
|
+
reason: normalizeString(error?.message) || "read_cursor_write_failed",
|
|
2111
|
+
updated: false,
|
|
2112
|
+
};
|
|
2113
|
+
}
|
|
2114
|
+
}
|
|
2115
|
+
|
|
1471
2116
|
/**
|
|
1472
2117
|
* Consume the API's durable session SSE stream.
|
|
1473
2118
|
*
|
|
@@ -1483,6 +2128,7 @@ export async function pollSessionEvents(
|
|
|
1483
2128
|
* @param {(event: object) => Promise<void>|void} [options.onEvent]
|
|
1484
2129
|
* @param {(payload: object) => Promise<void>|void} [options.onError]
|
|
1485
2130
|
* @param {() => Promise<void>|void} [options.onHeartbeat]
|
|
2131
|
+
* @param {(cursor: string) => Promise<void>|void} [options.onCursor]
|
|
1486
2132
|
* @returns {Promise<{ok: boolean, reason: string, cursor: string|null, eventCount: number, errorCount: number, status?: number, aborted?: boolean}>}
|
|
1487
2133
|
*/
|
|
1488
2134
|
export async function streamSessionEvents(
|
|
@@ -1498,6 +2144,7 @@ export async function streamSessionEvents(
|
|
|
1498
2144
|
onEvent = async () => {},
|
|
1499
2145
|
onError = async () => {},
|
|
1500
2146
|
onHeartbeat = async () => {},
|
|
2147
|
+
onCursor = async () => {},
|
|
1501
2148
|
} = {}
|
|
1502
2149
|
) {
|
|
1503
2150
|
const normalizedSessionId = normalizeString(sessionId);
|
|
@@ -1585,6 +2232,7 @@ export async function streamSessionEvents(
|
|
|
1585
2232
|
headers: {
|
|
1586
2233
|
Accept: "text/event-stream",
|
|
1587
2234
|
Authorization: `Bearer ${session.token}`,
|
|
2235
|
+
...(normalizedSince ? { "Last-Event-ID": normalizedSince } : {}),
|
|
1588
2236
|
},
|
|
1589
2237
|
signal: controller.signal,
|
|
1590
2238
|
},
|
|
@@ -1616,6 +2264,12 @@ export async function streamSessionEvents(
|
|
|
1616
2264
|
|
|
1617
2265
|
const handlers = {
|
|
1618
2266
|
cursor: () => cursor,
|
|
2267
|
+
onCursor: async (candidate) => {
|
|
2268
|
+
const normalizedCandidate = normalizeString(candidate);
|
|
2269
|
+
if (!normalizedCandidate) return;
|
|
2270
|
+
cursor = normalizedCandidate;
|
|
2271
|
+
await onCursor(normalizedCandidate);
|
|
2272
|
+
},
|
|
1619
2273
|
onHeartbeat,
|
|
1620
2274
|
onError: async (payload) => {
|
|
1621
2275
|
errorCount += 1;
|
|
@@ -2136,6 +2790,88 @@ export async function fetchSessionPinnedMessages(
|
|
|
2136
2790
|
};
|
|
2137
2791
|
}
|
|
2138
2792
|
|
|
2793
|
+
// Native edits never fall back to POST /events or mutate an optimistic local row.
|
|
2794
|
+
export async function editSessionMessage(sessionId, {
|
|
2795
|
+
targetSequenceId = null,
|
|
2796
|
+
targetMessageId = "",
|
|
2797
|
+
targetActionId = "",
|
|
2798
|
+
text,
|
|
2799
|
+
expectedRevision = null,
|
|
2800
|
+
agentId = "",
|
|
2801
|
+
idempotencyKey = "",
|
|
2802
|
+
targetPath = process.cwd(),
|
|
2803
|
+
timeoutMs = DEFAULT_SYNC_TIMEOUT_MS,
|
|
2804
|
+
signal,
|
|
2805
|
+
resolveAuthSession = resolveActiveAuthSession,
|
|
2806
|
+
fetchImpl = fetchWithTimeout,
|
|
2807
|
+
nowMs = Date.now,
|
|
2808
|
+
} = {}) {
|
|
2809
|
+
const sid = normalizeString(sessionId);
|
|
2810
|
+
const sequence = Number(targetSequenceId);
|
|
2811
|
+
const replyId = normalizeString(targetActionId);
|
|
2812
|
+
let messageId = normalizeString(targetMessageId);
|
|
2813
|
+
let revision = expectedRevision === null || expectedRevision === undefined ? null : Number(expectedRevision);
|
|
2814
|
+
const validSequence = Number.isSafeInteger(sequence) && sequence > 0;
|
|
2815
|
+
const mutationKey = normalizeString(idempotencyKey) || `sl-message-edit-${randomUUID()}`;
|
|
2816
|
+
if (!sid || typeof text !== "string" || !text.trim() || (!replyId && !messageId && !validSequence) ||
|
|
2817
|
+
(revision !== null && (!Number.isSafeInteger(revision) || revision <= 0)) || mutationKey.length > 128) {
|
|
2818
|
+
return { ok: false, reason: "invalid_input" };
|
|
2819
|
+
}
|
|
2820
|
+
if (String(process.env.SENTINELAYER_SKIP_REMOTE_SYNC || "").trim() === "1") return { ok: false, reason: "remote_sync_disabled_env" };
|
|
2821
|
+
const timestamp = Number(nowMs()) || Date.now();
|
|
2822
|
+
if (isCircuitOpen(outboundCircuit, timestamp)) return { ok: false, reason: "circuit_breaker_open" };
|
|
2823
|
+
let auth;
|
|
2824
|
+
try { auth = await resolveAuthSession({ cwd: targetPath, env: process.env, autoRotate: false }); }
|
|
2825
|
+
catch { return { ok: false, reason: "no_session" }; }
|
|
2826
|
+
if (!auth?.token) return { ok: false, reason: "not_authenticated" };
|
|
2827
|
+
const base = `${resolveApiBaseUrl(auth)}/api/v1/sessions/${encodeURIComponent(sid)}`;
|
|
2828
|
+
const headers = { Authorization: `Bearer ${auth.token}` };
|
|
2829
|
+
const failure = (response, payload) => {
|
|
2830
|
+
const code = normalizeString(payload?.detail?.code || payload?.error?.code || payload?.code);
|
|
2831
|
+
// Client/schema/ownership conflicts are terminal and must not poison unrelated writes.
|
|
2832
|
+
if (!response || response.status >= 500) recordCircuitFailure(outboundCircuit, timestamp);
|
|
2833
|
+
return { ok: false, reason: code || `api_${response?.status || "no_response"}`, status: response?.status || null };
|
|
2834
|
+
};
|
|
2835
|
+
try {
|
|
2836
|
+
// Sequence resolution and omitted-revision lookup are one authenticated GET.
|
|
2837
|
+
if ((!replyId && !messageId) || revision === null) {
|
|
2838
|
+
const resource = replyId ? `replies/${encodeURIComponent(replyId)}` : messageId
|
|
2839
|
+
? `messages/${encodeURIComponent(messageId)}` : `messages/by-sequence/${sequence}`;
|
|
2840
|
+
const authorQuery = normalizeString(agentId) ? `?agentId=${encodeURIComponent(normalizeString(agentId))}` : "";
|
|
2841
|
+
const { response, payload } = await fetchJsonWithFullTimeout(`${base}/${resource}${authorQuery}`, { method: "GET", headers, signal }, timeoutMs, fetchImpl, { readErrorBody: true });
|
|
2842
|
+
if (!response?.ok || payload?.ok === false) return failure(response, payload);
|
|
2843
|
+
const current = replyId ? payload?.reply : payload?.event;
|
|
2844
|
+
const fetchedRevision = Number(current?.messageRevision ?? 1);
|
|
2845
|
+
if (!current?.id || (replyId && current.id !== replyId) || !Number.isSafeInteger(fetchedRevision) || fetchedRevision <= 0) return { ok: false, reason: "invalid_edit_target" };
|
|
2846
|
+
if (current.canEdit === false) return { ok: false, reason: "MESSAGE_EDIT_FORBIDDEN", status: 403 };
|
|
2847
|
+
if (!replyId) messageId = current.id;
|
|
2848
|
+
if (revision === null) revision = fetchedRevision;
|
|
2849
|
+
}
|
|
2850
|
+
const resource = replyId ? `replies/${encodeURIComponent(replyId)}` : `messages/${encodeURIComponent(messageId)}`;
|
|
2851
|
+
const body = { text, expectedRevision: revision, ...(normalizeString(agentId) ? { agentId: normalizeString(agentId) } : {}) };
|
|
2852
|
+
const { response, payload } = await fetchJsonWithFullTimeout(`${base}/${resource}`, {
|
|
2853
|
+
method: "PATCH",
|
|
2854
|
+
headers: {
|
|
2855
|
+
...createSessionMutationHeaders({ bearerToken: auth.token, sessionId: sid, routeId: replyId
|
|
2856
|
+
? "PATCH /api/v1/sessions/{session_id}/replies/{action_id}"
|
|
2857
|
+
: "PATCH /api/v1/sessions/{session_id}/messages/{message_id}", idempotencyKey: mutationKey }),
|
|
2858
|
+
"Content-Type": "application/json", "Idempotency-Key": mutationKey,
|
|
2859
|
+
},
|
|
2860
|
+
body: JSON.stringify(body),
|
|
2861
|
+
signal,
|
|
2862
|
+
}, timeoutMs, fetchImpl, { readErrorBody: true });
|
|
2863
|
+
if (!response?.ok || payload?.ok !== true) return failure(response, payload);
|
|
2864
|
+
const current = replyId ? payload.reply : payload.event;
|
|
2865
|
+
const currentRevision = Number(current?.messageRevision ?? 1);
|
|
2866
|
+
if (!current?.id || current.id !== (replyId || messageId) || !Number.isSafeInteger(currentRevision) || currentRevision <= 0) return { ok: false, reason: "invalid_edit_response" };
|
|
2867
|
+
recordCircuitSuccess(outboundCircuit);
|
|
2868
|
+
return { ok: true, changed: Boolean(payload.changed), replayed: Boolean(payload.replayed), event: payload.event || null, reply: payload.reply || null, notification: payload.notification || null };
|
|
2869
|
+
} catch (error) {
|
|
2870
|
+
recordCircuitFailure(outboundCircuit, timestamp);
|
|
2871
|
+
return { ok: false, reason: normalizeString(error?.message) || "message_edit_failed" };
|
|
2872
|
+
}
|
|
2873
|
+
}
|
|
2874
|
+
|
|
2139
2875
|
export async function createSessionMessageAction(
|
|
2140
2876
|
sessionId,
|
|
2141
2877
|
{
|
|
@@ -2159,6 +2895,37 @@ export async function createSessionMessageAction(
|
|
|
2159
2895
|
if (!normalizedSessionId || !normalizedActionType) {
|
|
2160
2896
|
return { ok: false, reason: "invalid_input", action: null };
|
|
2161
2897
|
}
|
|
2898
|
+
if (normalizedActionType === "view") {
|
|
2899
|
+
const actorAgentId = normalizeString(
|
|
2900
|
+
metadata?.agentId ||
|
|
2901
|
+
metadata?.agent_id ||
|
|
2902
|
+
metadata?.actorAgentId ||
|
|
2903
|
+
metadata?.actor_agent_id,
|
|
2904
|
+
);
|
|
2905
|
+
const readCursor = await updateSessionReadCursor(normalizedSessionId, {
|
|
2906
|
+
targetPath,
|
|
2907
|
+
targetSequenceId,
|
|
2908
|
+
targetCursor,
|
|
2909
|
+
agentId: actorAgentId,
|
|
2910
|
+
timeoutMs,
|
|
2911
|
+
signal,
|
|
2912
|
+
resolveAuthSession,
|
|
2913
|
+
fetchImpl,
|
|
2914
|
+
nowMs,
|
|
2915
|
+
});
|
|
2916
|
+
return {
|
|
2917
|
+
...readCursor,
|
|
2918
|
+
duplicate: false,
|
|
2919
|
+
readCursor: readCursor.ok,
|
|
2920
|
+
action: readCursor.ok
|
|
2921
|
+
? {
|
|
2922
|
+
actionType: "view",
|
|
2923
|
+
targetSequenceId: readCursor.lastReadSequenceId || targetSequenceId || null,
|
|
2924
|
+
actorId: actorAgentId || null,
|
|
2925
|
+
}
|
|
2926
|
+
: null,
|
|
2927
|
+
};
|
|
2928
|
+
}
|
|
2162
2929
|
if (String(process.env.SENTINELAYER_SKIP_REMOTE_SYNC || "").trim() === "1") {
|
|
2163
2930
|
return { ok: false, reason: "remote_sync_disabled_env", action: null };
|
|
2164
2931
|
}
|