sentinelayer-cli 0.41.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 +18 -0
- package/package.json +3 -2
- 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 +483 -47
- 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/index.js +11 -2
- package/src/engram/ingest-document.js +107 -0
- package/src/engram/store.js +6 -1
- package/src/engram/tools.js +6 -0
- package/src/events/schema.js +18 -0
- package/src/legacy-cli.js +2 -2
- 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/checkpoints.js +25 -1
- package/src/session/codebase-context.js +54 -0
- package/src/session/coordination-guidance.js +1 -1
- package/src/session/daemon-spawn.js +20 -2
- package/src/session/daemon.js +7 -1
- package/src/session/event-identity.js +9 -2
- package/src/session/first-message.js +11 -3
- package/src/session/listener.js +3 -0
- package/src/session/live-source.js +47 -16
- package/src/session/message-edits.js +89 -0
- package/src/session/recap.js +26 -4
- package/src/session/remote-hydrate.js +7 -19
- package/src/session/store.js +13 -14
- package/src/session/stream.js +23 -3
- package/src/session/sync.js +128 -11
- 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/resolve-target.js +10 -5
package/src/session/stream.js
CHANGED
|
@@ -8,6 +8,7 @@ import {
|
|
|
8
8
|
sessionEventIdentityKeys,
|
|
9
9
|
} from "./event-identity.js";
|
|
10
10
|
import { enrichEventWithMentions } from "./mentions.js";
|
|
11
|
+
import { messageRevision, projectSessionMessageEdits } from "./message-edits.js";
|
|
11
12
|
import { resolveSessionPaths } from "./paths.js";
|
|
12
13
|
import { redactEventPayload } from "./redact.js";
|
|
13
14
|
import { fetchSessionFromApi, listSessionsFromApi, syncSessionEventToApi } from "./sync.js";
|
|
@@ -287,6 +288,23 @@ async function releaseLock(lockPath) {
|
|
|
287
288
|
await fsp.rm(lockPath, { recursive: true, force: true }).catch(() => {});
|
|
288
289
|
}
|
|
289
290
|
|
|
291
|
+
// Creation and append must coordinate on the SAME lock. In particular, two
|
|
292
|
+
// billing writers may both observe a missing session before either creates it.
|
|
293
|
+
// The callback must not append (append acquires this lock independently).
|
|
294
|
+
export async function withSessionStreamLock(sessionId, operation, {
|
|
295
|
+
targetPath = process.cwd(),
|
|
296
|
+
timeoutMs = DEFAULT_LOCK_TIMEOUT_MS,
|
|
297
|
+
} = {}) {
|
|
298
|
+
const paths = resolveSessionPaths(sessionId, { targetPath });
|
|
299
|
+
await fsp.mkdir(paths.sessionDir, { recursive: true });
|
|
300
|
+
await acquireLock(paths.lockPath, { timeoutMs });
|
|
301
|
+
try {
|
|
302
|
+
return await operation();
|
|
303
|
+
} finally {
|
|
304
|
+
await releaseLock(paths.lockPath);
|
|
305
|
+
}
|
|
306
|
+
}
|
|
307
|
+
|
|
290
308
|
async function readEventsFromFile(filePath) {
|
|
291
309
|
try {
|
|
292
310
|
const raw = await fsp.readFile(filePath, "utf-8");
|
|
@@ -359,7 +377,8 @@ function eventsShareIdentity(left = {}, right = {}) {
|
|
|
359
377
|
}
|
|
360
378
|
|
|
361
379
|
async function appendOrMergeCurrentStreamEvent(paths, canonicalEvent, { mergeExisting = false } = {}) {
|
|
362
|
-
|
|
380
|
+
// Keep prior revisions in the raw audit log. Readers project the latest body.
|
|
381
|
+
if (!mergeExisting || messageRevision(canonicalEvent) > 1) {
|
|
363
382
|
await fsp.appendFile(paths.streamPath, `${JSON.stringify(canonicalEvent)}\n`, "utf-8");
|
|
364
383
|
return;
|
|
365
384
|
}
|
|
@@ -427,6 +446,7 @@ export async function appendToStream(
|
|
|
427
446
|
maxEvents = DEFAULT_MAX_STREAM_EVENTS,
|
|
428
447
|
syncRemote = true,
|
|
429
448
|
awaitRemoteSync = false,
|
|
449
|
+
lockTimeoutMs = DEFAULT_LOCK_TIMEOUT_MS,
|
|
430
450
|
mergeExisting = false,
|
|
431
451
|
} = {}
|
|
432
452
|
) {
|
|
@@ -457,7 +477,7 @@ export async function appendToStream(
|
|
|
457
477
|
const nowIso = new Date().toISOString();
|
|
458
478
|
const normalizedMaxEvents = normalizePositiveInteger(maxEvents, DEFAULT_MAX_STREAM_EVENTS);
|
|
459
479
|
|
|
460
|
-
await acquireLock(paths.lockPath);
|
|
480
|
+
await acquireLock(paths.lockPath, { timeoutMs: lockTimeoutMs });
|
|
461
481
|
try {
|
|
462
482
|
await fsp.mkdir(paths.sessionDir, { recursive: true });
|
|
463
483
|
await appendOrMergeCurrentStreamEvent(paths, canonicalEvent, { mergeExisting });
|
|
@@ -490,7 +510,7 @@ export async function readStream(
|
|
|
490
510
|
{ tail = 20, since = null, targetPath = process.cwd() } = {}
|
|
491
511
|
) {
|
|
492
512
|
const paths = resolveSessionPaths(sessionId, { targetPath });
|
|
493
|
-
const events = await readAllEvents(paths);
|
|
513
|
+
const events = projectSessionMessageEdits(await readAllEvents(paths));
|
|
494
514
|
const filtered = filterBySince(events, since);
|
|
495
515
|
const normalizedTail = Number(tail);
|
|
496
516
|
if (!Number.isFinite(normalizedTail) || normalizedTail <= 0) {
|
package/src/session/sync.js
CHANGED
|
@@ -7,6 +7,13 @@ import { randomUUID } from "node:crypto";
|
|
|
7
7
|
import { resolveActiveAuthSession } from "../auth/service.js";
|
|
8
8
|
import { createAgentEvent } from "../events/schema.js";
|
|
9
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();
|
|
10
17
|
|
|
11
18
|
const DEFAULT_API_BASE_URL = "https://api.sentinelayer.com";
|
|
12
19
|
const DEFAULT_SYNC_TIMEOUT_MS = 5_000;
|
|
@@ -327,6 +334,11 @@ function isApiHostAllowed(urlString) {
|
|
|
327
334
|
function resolveApiBaseUrl(session = {}) {
|
|
328
335
|
const apiUrl = normalizeString(session.apiUrl) || DEFAULT_API_BASE_URL;
|
|
329
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
|
+
}
|
|
330
342
|
if (!isApiHostAllowed(normalized)) {
|
|
331
343
|
// Reject tampered session.apiUrl and fall back to the default.
|
|
332
344
|
// Caller will see API calls land on the canonical host, not an attacker's.
|
|
@@ -634,12 +646,12 @@ async function readResponseJsonSafely(response, timeoutMs = DEFAULT_SYNC_TIMEOUT
|
|
|
634
646
|
}
|
|
635
647
|
}
|
|
636
648
|
|
|
637
|
-
async function fetchJsonWithFullTimeout(
|
|
649
|
+
export async function fetchJsonWithFullTimeout(
|
|
638
650
|
url,
|
|
639
651
|
options,
|
|
640
652
|
timeoutMs,
|
|
641
653
|
fetchImpl = fetchWithTimeout,
|
|
642
|
-
{ readErrorBody = false } = {},
|
|
654
|
+
{ readErrorBody = false, readSuccessBody = true, errorBodyStatuses = [] } = {},
|
|
643
655
|
) {
|
|
644
656
|
const resolvedTimeoutMs = normalizePositiveInteger(timeoutMs, DEFAULT_SYNC_TIMEOUT_MS);
|
|
645
657
|
const controller = new AbortController();
|
|
@@ -660,13 +672,15 @@ async function fetchJsonWithFullTimeout(
|
|
|
660
672
|
);
|
|
661
673
|
|
|
662
674
|
let payload = {};
|
|
663
|
-
if (response?.ok || readErrorBody) {
|
|
675
|
+
if ((response?.ok && readSuccessBody) || readErrorBody || errorBodyStatuses.includes(response?.status)) {
|
|
664
676
|
const elapsedMs = Math.max(0, Date.now() - startedAtMs);
|
|
665
677
|
const remainingMs = Math.max(1, resolvedTimeoutMs - elapsedMs);
|
|
666
678
|
payload = await readResponseJsonWithTimeout(response, remainingMs, {
|
|
667
679
|
fallback: {},
|
|
668
680
|
onTimeout: () => abortRequestController(controller, "response_body_timeout"),
|
|
669
681
|
});
|
|
682
|
+
} else {
|
|
683
|
+
cancelResponseBody(response);
|
|
670
684
|
}
|
|
671
685
|
return { response, payload };
|
|
672
686
|
}
|
|
@@ -867,6 +881,18 @@ function buildHumanRelayEvent(sessionId, message = {}) {
|
|
|
867
881
|
agentId,
|
|
868
882
|
sessionId,
|
|
869
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,
|
|
870
896
|
payload: {
|
|
871
897
|
message: sanitization.message,
|
|
872
898
|
channel: "session",
|
|
@@ -982,9 +1008,14 @@ export async function syncSessionEventToApi(
|
|
|
982
1008
|
body: requestBody,
|
|
983
1009
|
};
|
|
984
1010
|
const resolvedTimeoutMs = normalizePositiveInteger(timeoutMs, DEFAULT_SYNC_TIMEOUT_MS);
|
|
1011
|
+
const deadlineMs = Date.now() + resolvedTimeoutMs;
|
|
1012
|
+
const remainingMs = () => Math.max(1, deadlineMs - Date.now());
|
|
985
1013
|
|
|
986
1014
|
try {
|
|
987
|
-
const response = await
|
|
1015
|
+
const { response, payload: forbiddenBody } = await fetchJsonWithFullTimeout(
|
|
1016
|
+
endpoint, requestInit, remainingMs(), fetchImpl,
|
|
1017
|
+
{ readSuccessBody: false, errorBodyStatuses: [403] },
|
|
1018
|
+
);
|
|
988
1019
|
|
|
989
1020
|
if (response && response.ok) {
|
|
990
1021
|
recordCircuitSuccess(outboundCircuit);
|
|
@@ -1000,7 +1031,7 @@ export async function syncSessionEventToApi(
|
|
|
1000
1031
|
// their agents "talking" locally while the API has zero record. We attempt
|
|
1001
1032
|
// the grant once per agentId per process, then retry the event POST once.
|
|
1002
1033
|
if (response && response.status === 403) {
|
|
1003
|
-
const body =
|
|
1034
|
+
const body = forbiddenBody;
|
|
1004
1035
|
if (isIdentityForgeryBody(body)) {
|
|
1005
1036
|
const agentId = normalizeString(event?.agent?.id);
|
|
1006
1037
|
if (!agentId || isReservedAgentIdForGrant(agentId)) {
|
|
@@ -1022,7 +1053,7 @@ export async function syncSessionEventToApi(
|
|
|
1022
1053
|
const grantEndpoint = `${apiBaseUrl}/api/v1/sessions/agent-grants`;
|
|
1023
1054
|
let grantResponse = null;
|
|
1024
1055
|
try {
|
|
1025
|
-
grantResponse = await
|
|
1056
|
+
({ response: grantResponse } = await fetchJsonWithFullTimeout(
|
|
1026
1057
|
grantEndpoint,
|
|
1027
1058
|
{
|
|
1028
1059
|
method: "POST",
|
|
@@ -1035,8 +1066,8 @@ export async function syncSessionEventToApi(
|
|
|
1035
1066
|
role: grantRole,
|
|
1036
1067
|
}),
|
|
1037
1068
|
},
|
|
1038
|
-
|
|
1039
|
-
);
|
|
1069
|
+
remainingMs(), fetchImpl, { readSuccessBody: false },
|
|
1070
|
+
));
|
|
1040
1071
|
} catch (grantError) {
|
|
1041
1072
|
recordCircuitFailure(outboundCircuit, normalizedNowMs);
|
|
1042
1073
|
return {
|
|
@@ -1060,7 +1091,9 @@ export async function syncSessionEventToApi(
|
|
|
1060
1091
|
// Retry the original event POST exactly once.
|
|
1061
1092
|
let retryResponse;
|
|
1062
1093
|
try {
|
|
1063
|
-
retryResponse = await
|
|
1094
|
+
({ response: retryResponse } = await fetchJsonWithFullTimeout(
|
|
1095
|
+
endpoint, requestInit, remainingMs(), fetchImpl, { readSuccessBody: false },
|
|
1096
|
+
));
|
|
1064
1097
|
} catch (retryError) {
|
|
1065
1098
|
recordCircuitFailure(outboundCircuit, normalizedNowMs);
|
|
1066
1099
|
return {
|
|
@@ -1156,7 +1189,7 @@ async function syncSessionAuxPayload(
|
|
|
1156
1189
|
const apiBaseUrl = resolveApiBaseUrl(session);
|
|
1157
1190
|
const endpoint = `${apiBaseUrl}/api/v1/sessions/${encodeURIComponent(normalizedSessionId)}${pathSuffix}`;
|
|
1158
1191
|
try {
|
|
1159
|
-
const response = await
|
|
1192
|
+
const { response } = await fetchJsonWithFullTimeout(
|
|
1160
1193
|
endpoint,
|
|
1161
1194
|
{
|
|
1162
1195
|
method: "POST",
|
|
@@ -1169,7 +1202,9 @@ async function syncSessionAuxPayload(
|
|
|
1169
1202
|
source: "cli",
|
|
1170
1203
|
}),
|
|
1171
1204
|
},
|
|
1172
|
-
normalizePositiveInteger(timeoutMs, DEFAULT_SYNC_TIMEOUT_MS)
|
|
1205
|
+
normalizePositiveInteger(timeoutMs, DEFAULT_SYNC_TIMEOUT_MS),
|
|
1206
|
+
fetchImpl,
|
|
1207
|
+
{ readSuccessBody: false },
|
|
1173
1208
|
);
|
|
1174
1209
|
if (!response || !response.ok) {
|
|
1175
1210
|
recordCircuitFailure(outboundCircuit, normalizedNowMs);
|
|
@@ -2755,6 +2790,88 @@ export async function fetchSessionPinnedMessages(
|
|
|
2755
2790
|
};
|
|
2756
2791
|
}
|
|
2757
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
|
+
|
|
2758
2875
|
export async function createSessionMessageAction(
|
|
2759
2876
|
sessionId,
|
|
2760
2877
|
{
|
|
@@ -0,0 +1,316 @@
|
|
|
1
|
+
// tickets.js — the room's shared backlog from the CLI: list, claim, renew, release,
|
|
2
|
+
// submit, and the ticket event feed (API "Ticket Lease Contract (T2)").
|
|
3
|
+
//
|
|
4
|
+
// Credentials: every call resolves auth with resolveActiveAuthSession. Inside an
|
|
5
|
+
// admitted agent's command (the runCli choke point), that is the agent's admission
|
|
6
|
+
// credential and never the human token, so a ticket is claimed AS the agent.
|
|
7
|
+
//
|
|
8
|
+
// Lease state: a claim's { leaseId, fence, version, expiresAt } is kept per
|
|
9
|
+
// (session, ticket, identity) in a local file. Only a MISSING file means "no lease
|
|
10
|
+
// held here"; an unreadable one refuses, never reads as absent.
|
|
11
|
+
//
|
|
12
|
+
// Lost responses: a write's Idempotency-Key is stored BEFORE the request is sent. If
|
|
13
|
+
// the response is lost, re-running the same command reuses that key, and the server
|
|
14
|
+
// replays the original result instead of performing the operation again.
|
|
15
|
+
|
|
16
|
+
import { randomUUID } from "node:crypto";
|
|
17
|
+
import fsp from "node:fs/promises";
|
|
18
|
+
import os from "node:os";
|
|
19
|
+
import path from "node:path";
|
|
20
|
+
import process from "node:process";
|
|
21
|
+
|
|
22
|
+
import { requestJson, requestJsonMutation } from "../auth/http.js";
|
|
23
|
+
import { resolveActiveAuthSession } from "../auth/service.js";
|
|
24
|
+
|
|
25
|
+
function normalizeString(value) {
|
|
26
|
+
return String(value ?? "").trim();
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
function segment(value, label) {
|
|
30
|
+
const text = normalizeString(value).toLowerCase();
|
|
31
|
+
if (!/^[a-z0-9][a-z0-9._:-]{0,63}$/.test(text)) throw new Error(`${label} is not a valid identifier.`);
|
|
32
|
+
return text.replace(/:/g, "%3a");
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
export function ticketLeasePath(sessionId, ticketId, identity, { homeDir } = {}) {
|
|
36
|
+
return path.join(
|
|
37
|
+
homeDir || os.homedir(),
|
|
38
|
+
".sentinelayer",
|
|
39
|
+
"agents",
|
|
40
|
+
segment(identity, "identity"),
|
|
41
|
+
"tickets",
|
|
42
|
+
segment(sessionId, "sessionId"),
|
|
43
|
+
`${segment(ticketId, "ticketId")}.json`
|
|
44
|
+
);
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
async function readState(filePath) {
|
|
48
|
+
let raw;
|
|
49
|
+
try {
|
|
50
|
+
raw = await fsp.readFile(filePath, "utf8");
|
|
51
|
+
} catch (error) {
|
|
52
|
+
if (error && error.code === "ENOENT") return null;
|
|
53
|
+
throw new Error(`The local ticket lease state at ${filePath} is unreadable; refusing to guess.`);
|
|
54
|
+
}
|
|
55
|
+
try {
|
|
56
|
+
const parsed = JSON.parse(raw);
|
|
57
|
+
if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) throw new Error("not an object");
|
|
58
|
+
return parsed;
|
|
59
|
+
} catch {
|
|
60
|
+
throw new Error(`The local ticket lease state at ${filePath} is unreadable; refusing to guess.`);
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
async function writeState(filePath, state) {
|
|
65
|
+
await fsp.mkdir(path.dirname(filePath), { recursive: true, mode: 0o700 });
|
|
66
|
+
const tmp = `${filePath}.${randomUUID()}.tmp`;
|
|
67
|
+
await fsp.writeFile(tmp, `${JSON.stringify(state, null, 2)}\n`, { mode: 0o600, flag: "wx" });
|
|
68
|
+
await fsp.rename(tmp, filePath);
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
async function auth(targetPath, resolveAuthSession) {
|
|
72
|
+
const session = await resolveAuthSession({ cwd: targetPath, env: process.env, autoRotate: false });
|
|
73
|
+
if (!session?.token || !session?.apiUrl) throw new Error("Not authenticated for this session.");
|
|
74
|
+
return { token: session.token, apiUrl: normalizeString(session.apiUrl).replace(/\/+$/, "") };
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
function ticketsUrl(apiUrl, sessionId, suffix = "") {
|
|
78
|
+
return `${apiUrl}/api/v1/sessions/${encodeURIComponent(sessionId)}/tickets${suffix}`;
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
/** The Idempotency-Key for this logical write: reused until its response is stored. */
|
|
82
|
+
async function operationKey(statePath, operation, fingerprint) {
|
|
83
|
+
const state = (await readState(statePath)) || {};
|
|
84
|
+
const pending = state.pending;
|
|
85
|
+
if (pending && pending.operation === operation && pending.fingerprint === fingerprint && pending.key) {
|
|
86
|
+
return { key: pending.key, state };
|
|
87
|
+
}
|
|
88
|
+
const key = `${operation}-${randomUUID()}`;
|
|
89
|
+
const next = { ...state, pending: { operation, fingerprint, key } };
|
|
90
|
+
await writeState(statePath, next);
|
|
91
|
+
return { key, state: next };
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
function leaseState(response) {
|
|
95
|
+
return {
|
|
96
|
+
version: 1,
|
|
97
|
+
leaseId: response.lease.leaseId,
|
|
98
|
+
fence: response.lease.fence,
|
|
99
|
+
ticketVersion: response.version,
|
|
100
|
+
expiresAt: response.lease.expiresAt,
|
|
101
|
+
};
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
const SNAPSHOT_PAGE = 200;
|
|
105
|
+
const SNAPSHOT_MAX_PAGES = 50;
|
|
106
|
+
|
|
107
|
+
function malformedSnapshot(reason) {
|
|
108
|
+
return new Error(`The ticket snapshot is malformed (${reason}); refusing a partial list.`);
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
/**
|
|
112
|
+
* One snapshot page, validated before anything in it is believed. The snapshot is keyset by
|
|
113
|
+
* ticket id: ids on a page are STRICTLY ascending and above the previous continuation (so
|
|
114
|
+
* none repeats), and the continuation is exactly the LAST id returned -- a token that jumps
|
|
115
|
+
* ahead would silently skip every ticket in between. Anything else is not a complete list.
|
|
116
|
+
*/
|
|
117
|
+
function validatedSnapshotPage(body, { first, afterId }) {
|
|
118
|
+
if (!body || typeof body !== "object" || Array.isArray(body)) throw malformedSnapshot("a page is not an object");
|
|
119
|
+
if (!Array.isArray(body.items)) throw malformedSnapshot("a page has no items list");
|
|
120
|
+
if (typeof body.hasMore !== "boolean") throw malformedSnapshot("a page does not say whether there is more");
|
|
121
|
+
if (first && !(Number.isSafeInteger(body.cursor) && body.cursor >= 0)) {
|
|
122
|
+
throw malformedSnapshot("the first page has no room cursor to follow events from");
|
|
123
|
+
}
|
|
124
|
+
let previous = afterId;
|
|
125
|
+
for (const item of body.items) {
|
|
126
|
+
const id = item && typeof item === "object" ? item.id : null;
|
|
127
|
+
if (typeof id !== "string" || !id) throw malformedSnapshot("a ticket has no id");
|
|
128
|
+
if (previous && !(id > previous)) throw malformedSnapshot("ticket ids are not in ascending order");
|
|
129
|
+
previous = id;
|
|
130
|
+
}
|
|
131
|
+
if (body.hasMore) {
|
|
132
|
+
if (!body.items.length) throw malformedSnapshot("an empty page claims there is more");
|
|
133
|
+
const next = body.nextAfterId;
|
|
134
|
+
if (typeof next !== "string" || !next) throw malformedSnapshot("there is more but no way to continue");
|
|
135
|
+
if (next !== previous) throw malformedSnapshot("the continuation is not the last ticket returned");
|
|
136
|
+
}
|
|
137
|
+
return body;
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
/**
|
|
141
|
+
* The room's COMPLETE backlog, from GET /tickets/snapshot paged to the end. The board read
|
|
142
|
+
* (GET /tickets) is a bounded, newest-first page: filtering it for "available" could miss
|
|
143
|
+
* claimable work beyond the page. `cursor` is the FIRST page's room cursor, taken with that
|
|
144
|
+
* page; `sl session ticket events --after <cursor>` continues from exactly there. A snapshot
|
|
145
|
+
* that cannot be completed is refused, never returned partial.
|
|
146
|
+
*/
|
|
147
|
+
export async function listTickets(
|
|
148
|
+
sessionId,
|
|
149
|
+
{
|
|
150
|
+
targetPath = process.cwd(),
|
|
151
|
+
available = false,
|
|
152
|
+
resolveAuthSession = resolveActiveAuthSession,
|
|
153
|
+
requestRead = requestJson,
|
|
154
|
+
pageSize = SNAPSHOT_PAGE,
|
|
155
|
+
maxPages = SNAPSHOT_MAX_PAGES,
|
|
156
|
+
} = {}
|
|
157
|
+
) {
|
|
158
|
+
const { token, apiUrl } = await auth(targetPath, resolveAuthSession);
|
|
159
|
+
const items = [];
|
|
160
|
+
let cursor = null;
|
|
161
|
+
let afterId = null;
|
|
162
|
+
let complete = false;
|
|
163
|
+
for (let page = 0; page < maxPages && !complete; page += 1) {
|
|
164
|
+
const query = new URLSearchParams({ limit: String(pageSize) });
|
|
165
|
+
if (afterId) query.set("afterId", afterId);
|
|
166
|
+
const body = validatedSnapshotPage(
|
|
167
|
+
await requestRead(`${ticketsUrl(apiUrl, sessionId, "/snapshot")}?${query}`, {
|
|
168
|
+
method: "GET",
|
|
169
|
+
headers: { Authorization: `Bearer ${token}` },
|
|
170
|
+
}),
|
|
171
|
+
{ first: page === 0, afterId },
|
|
172
|
+
);
|
|
173
|
+
if (page === 0) cursor = body.cursor; // ONLY the first page's cursor is the baseline
|
|
174
|
+
items.push(...body.items);
|
|
175
|
+
if (!body.hasMore) complete = true;
|
|
176
|
+
else afterId = body.nextAfterId;
|
|
177
|
+
}
|
|
178
|
+
if (!complete) {
|
|
179
|
+
throw new Error(`The room has more than ${maxPages * pageSize} tickets; refusing a partial list.`);
|
|
180
|
+
}
|
|
181
|
+
// Newest first for people; the snapshot itself is in id order.
|
|
182
|
+
items.sort((a, b) => String(b.createdAt || "").localeCompare(String(a.createdAt || "")) || String(a.id).localeCompare(String(b.id)));
|
|
183
|
+
// Available = claimable by someone now: open, or working with an expired lease.
|
|
184
|
+
// Never blocked work: the MVP has no dependencies, and blocked is never available.
|
|
185
|
+
const filtered = available
|
|
186
|
+
? items.filter((t) => t.status === "open" || (t.status === "working" && t.lease && !t.lease.live))
|
|
187
|
+
: items;
|
|
188
|
+
return { items: filtered, cursor, count: filtered.length, complete: true };
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
export async function ticketEvents(
|
|
192
|
+
sessionId,
|
|
193
|
+
{ after = 0, targetPath = process.cwd(), resolveAuthSession = resolveActiveAuthSession, requestRead = requestJson } = {}
|
|
194
|
+
) {
|
|
195
|
+
const { token, apiUrl } = await auth(targetPath, resolveAuthSession);
|
|
196
|
+
return requestRead(`${ticketsUrl(apiUrl, sessionId, "/events")}?after=${encodeURIComponent(String(after))}`, {
|
|
197
|
+
method: "GET",
|
|
198
|
+
headers: { Authorization: `Bearer ${token}` },
|
|
199
|
+
});
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
async function mutate(url, { token, key, operation, body, requestMutation }) {
|
|
203
|
+
return requestMutation(url, {
|
|
204
|
+
method: "POST",
|
|
205
|
+
operationName: `session.ticket_${operation}`,
|
|
206
|
+
idempotencyKey: key,
|
|
207
|
+
headers: { Authorization: `Bearer ${token}`, "Idempotency-Key": key },
|
|
208
|
+
body,
|
|
209
|
+
});
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
export async function claimTicket(
|
|
213
|
+
sessionId,
|
|
214
|
+
ticketId,
|
|
215
|
+
{
|
|
216
|
+
identity,
|
|
217
|
+
expectedVersion = null,
|
|
218
|
+
targetPath = process.cwd(),
|
|
219
|
+
homeDir,
|
|
220
|
+
resolveAuthSession = resolveActiveAuthSession,
|
|
221
|
+
requestMutation = requestJsonMutation,
|
|
222
|
+
} = {}
|
|
223
|
+
) {
|
|
224
|
+
const statePath = ticketLeasePath(sessionId, ticketId, identity, { homeDir });
|
|
225
|
+
const { token, apiUrl } = await auth(targetPath, resolveAuthSession);
|
|
226
|
+
const { key } = await operationKey(statePath, "claim", String(expectedVersion ?? "any"));
|
|
227
|
+
const response = await mutate(ticketsUrl(apiUrl, sessionId, `/${encodeURIComponent(ticketId)}/claim`), {
|
|
228
|
+
token, key, operation: "claim", requestMutation,
|
|
229
|
+
body: expectedVersion == null ? {} : { expectedVersion: Number(expectedVersion) },
|
|
230
|
+
});
|
|
231
|
+
// The lease is recorded here only if the server says it is held by the identity that
|
|
232
|
+
// asked: an agent by its own id, a person as a human. Anything else is not this lease.
|
|
233
|
+
const holder = response?.ticket?.lease;
|
|
234
|
+
const heldByIdentity =
|
|
235
|
+
identity === "human" ? holder?.holderKind === "human" : holder?.holderKind === "agent" && holder?.holder === identity;
|
|
236
|
+
if (!heldByIdentity) {
|
|
237
|
+
throw new Error(
|
|
238
|
+
`The server recorded this lease for ${holder?.holderKind || "nobody"} "${holder?.holder || ""}", not for "${identity}". ` +
|
|
239
|
+
"Nothing was recorded locally; release it from the web if it is not yours."
|
|
240
|
+
);
|
|
241
|
+
}
|
|
242
|
+
await writeState(statePath, leaseState(response));
|
|
243
|
+
return response;
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
async function heldLease(statePath) {
|
|
247
|
+
const state = await readState(statePath);
|
|
248
|
+
if (!state || !state.leaseId) {
|
|
249
|
+
throw new Error("This identity holds no lease on that ticket here. Claim it first.");
|
|
250
|
+
}
|
|
251
|
+
return state;
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
export async function renewTicketLease(
|
|
255
|
+
sessionId,
|
|
256
|
+
ticketId,
|
|
257
|
+
{ identity, targetPath = process.cwd(), homeDir, resolveAuthSession = resolveActiveAuthSession, requestMutation = requestJsonMutation } = {}
|
|
258
|
+
) {
|
|
259
|
+
const statePath = ticketLeasePath(sessionId, ticketId, identity, { homeDir });
|
|
260
|
+
const held = await heldLease(statePath);
|
|
261
|
+
const { token, apiUrl } = await auth(targetPath, resolveAuthSession);
|
|
262
|
+
const { key } = await operationKey(statePath, "renew", `${held.leaseId}:${held.fence}:${held.ticketVersion}`);
|
|
263
|
+
const response = await mutate(ticketsUrl(apiUrl, sessionId, `/${encodeURIComponent(ticketId)}/lease/renew`), {
|
|
264
|
+
token, key, operation: "renew", requestMutation, body: { leaseId: held.leaseId, fence: held.fence },
|
|
265
|
+
});
|
|
266
|
+
await writeState(statePath, leaseState(response));
|
|
267
|
+
return response;
|
|
268
|
+
}
|
|
269
|
+
|
|
270
|
+
export async function releaseTicketLease(
|
|
271
|
+
sessionId,
|
|
272
|
+
ticketId,
|
|
273
|
+
{ identity, reason = null, targetPath = process.cwd(), homeDir, resolveAuthSession = resolveActiveAuthSession, requestMutation = requestJsonMutation } = {}
|
|
274
|
+
) {
|
|
275
|
+
const statePath = ticketLeasePath(sessionId, ticketId, identity, { homeDir });
|
|
276
|
+
const held = await heldLease(statePath);
|
|
277
|
+
const { token, apiUrl } = await auth(targetPath, resolveAuthSession);
|
|
278
|
+
const { key } = await operationKey(statePath, "release", `${held.leaseId}:${held.fence}:${held.ticketVersion}`);
|
|
279
|
+
const response = await mutate(ticketsUrl(apiUrl, sessionId, `/${encodeURIComponent(ticketId)}/lease/release`), {
|
|
280
|
+
token, key, operation: "release", requestMutation,
|
|
281
|
+
body: { leaseId: held.leaseId, fence: held.fence, expectedVersion: held.ticketVersion, ...(reason ? { reason } : {}) },
|
|
282
|
+
});
|
|
283
|
+
await fsp.rm(statePath, { force: true });
|
|
284
|
+
return response;
|
|
285
|
+
}
|
|
286
|
+
|
|
287
|
+
export async function submitTicket(
|
|
288
|
+
sessionId,
|
|
289
|
+
ticketId,
|
|
290
|
+
{
|
|
291
|
+
identity,
|
|
292
|
+
implementationSha = null,
|
|
293
|
+
evidence = [],
|
|
294
|
+
targetPath = process.cwd(),
|
|
295
|
+
homeDir,
|
|
296
|
+
resolveAuthSession = resolveActiveAuthSession,
|
|
297
|
+
requestMutation = requestJsonMutation,
|
|
298
|
+
} = {}
|
|
299
|
+
) {
|
|
300
|
+
const statePath = ticketLeasePath(sessionId, ticketId, identity, { homeDir });
|
|
301
|
+
const held = await heldLease(statePath);
|
|
302
|
+
const { token, apiUrl } = await auth(targetPath, resolveAuthSession);
|
|
303
|
+
const { key } = await operationKey(statePath, "submit", `${held.leaseId}:${held.fence}:${held.ticketVersion}`);
|
|
304
|
+
const response = await mutate(ticketsUrl(apiUrl, sessionId, `/${encodeURIComponent(ticketId)}/submit`), {
|
|
305
|
+
token, key, operation: "submit", requestMutation,
|
|
306
|
+
body: {
|
|
307
|
+
leaseId: held.leaseId,
|
|
308
|
+
fence: held.fence,
|
|
309
|
+
expectedVersion: held.ticketVersion,
|
|
310
|
+
...(implementationSha ? { implementationSha } : {}),
|
|
311
|
+
evidence,
|
|
312
|
+
},
|
|
313
|
+
});
|
|
314
|
+
await fsp.rm(statePath, { force: true });
|
|
315
|
+
return response;
|
|
316
|
+
}
|
|
@@ -38,6 +38,7 @@ export async function pushSessionTitleToApi(
|
|
|
38
38
|
timeoutMs = DEFAULT_TITLE_SYNC_TIMEOUT_MS,
|
|
39
39
|
maxRetries = 1,
|
|
40
40
|
retryDelayMs = DEFAULT_TITLE_SYNC_RETRY_DELAY_MS,
|
|
41
|
+
autoRotate = true,
|
|
41
42
|
resolveAuthSession = resolveActiveAuthSession,
|
|
42
43
|
requestMutation = requestJsonMutation,
|
|
43
44
|
recordRemoteTitleSync = recordSessionRemoteTitleSync,
|
|
@@ -63,7 +64,7 @@ export async function pushSessionTitleToApi(
|
|
|
63
64
|
const session = await resolveAuthSession({
|
|
64
65
|
cwd: targetPath,
|
|
65
66
|
env,
|
|
66
|
-
autoRotate
|
|
67
|
+
autoRotate,
|
|
67
68
|
});
|
|
68
69
|
if (!session?.token || !session?.apiUrl) {
|
|
69
70
|
await recordRemoteTitleSync(normalizedSessionId, {
|