@sumeru/server 0.1.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/LICENSE +18 -0
- package/dist/.build-fingerprint +1 -0
- package/dist/config.d.ts +14 -0
- package/dist/config.d.ts.map +1 -0
- package/dist/config.js +142 -0
- package/dist/config.js.map +1 -0
- package/dist/envelope.d.ts +28 -0
- package/dist/envelope.d.ts.map +1 -0
- package/dist/envelope.js +43 -0
- package/dist/envelope.js.map +1 -0
- package/dist/export/bundle.d.ts +28 -0
- package/dist/export/bundle.d.ts.map +1 -0
- package/dist/export/bundle.js +78 -0
- package/dist/export/bundle.js.map +1 -0
- package/dist/export/handler.d.ts +24 -0
- package/dist/export/handler.d.ts.map +1 -0
- package/dist/export/handler.js +102 -0
- package/dist/export/handler.js.map +1 -0
- package/dist/export/index.d.ts +3 -0
- package/dist/export/index.d.ts.map +1 -0
- package/dist/export/index.js +3 -0
- package/dist/export/index.js.map +1 -0
- package/dist/handler.d.ts +24 -0
- package/dist/handler.d.ts.map +1 -0
- package/dist/handler.js +622 -0
- package/dist/handler.js.map +1 -0
- package/dist/index.d.ts +12 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +10 -0
- package/dist/index.js.map +1 -0
- package/dist/ocas/index.d.ts +3 -0
- package/dist/ocas/index.d.ts.map +1 -0
- package/dist/ocas/index.js +3 -0
- package/dist/ocas/index.js.map +1 -0
- package/dist/ocas/schemas.d.ts +41 -0
- package/dist/ocas/schemas.d.ts.map +1 -0
- package/dist/ocas/schemas.js +108 -0
- package/dist/ocas/schemas.js.map +1 -0
- package/dist/ocas/store.d.ts +58 -0
- package/dist/ocas/store.d.ts.map +1 -0
- package/dist/ocas/store.js +139 -0
- package/dist/ocas/store.js.map +1 -0
- package/dist/search/handler.d.ts +54 -0
- package/dist/search/handler.d.ts.map +1 -0
- package/dist/search/handler.js +178 -0
- package/dist/search/handler.js.map +1 -0
- package/dist/search/index.d.ts +4 -0
- package/dist/search/index.d.ts.map +1 -0
- package/dist/search/index.js +3 -0
- package/dist/search/index.js.map +1 -0
- package/dist/search/sqlite-index.d.ts +49 -0
- package/dist/search/sqlite-index.d.ts.map +1 -0
- package/dist/search/sqlite-index.js +508 -0
- package/dist/search/sqlite-index.js.map +1 -0
- package/dist/search/types.d.ts +143 -0
- package/dist/search/types.d.ts.map +1 -0
- package/dist/search/types.js +10 -0
- package/dist/search/types.js.map +1 -0
- package/dist/session/cwd.d.ts +31 -0
- package/dist/session/cwd.d.ts.map +1 -0
- package/dist/session/cwd.js +54 -0
- package/dist/session/cwd.js.map +1 -0
- package/dist/session/id.d.ts +12 -0
- package/dist/session/id.d.ts.map +1 -0
- package/dist/session/id.js +76 -0
- package/dist/session/id.js.map +1 -0
- package/dist/session/index.d.ts +5 -0
- package/dist/session/index.d.ts.map +1 -0
- package/dist/session/index.js +4 -0
- package/dist/session/index.js.map +1 -0
- package/dist/session/store.d.ts +89 -0
- package/dist/session/store.d.ts.map +1 -0
- package/dist/session/store.js +258 -0
- package/dist/session/store.js.map +1 -0
- package/dist/sse/buffer.d.ts +53 -0
- package/dist/sse/buffer.d.ts.map +1 -0
- package/dist/sse/buffer.js +119 -0
- package/dist/sse/buffer.js.map +1 -0
- package/dist/sse/index.d.ts +3 -0
- package/dist/sse/index.d.ts.map +1 -0
- package/dist/sse/index.js +3 -0
- package/dist/sse/index.js.map +1 -0
- package/dist/sse/messages.d.ts +30 -0
- package/dist/sse/messages.d.ts.map +1 -0
- package/dist/sse/messages.js +489 -0
- package/dist/sse/messages.js.map +1 -0
- package/dist/start.d.ts +22 -0
- package/dist/start.d.ts.map +1 -0
- package/dist/start.js +86 -0
- package/dist/start.js.map +1 -0
- package/dist/types.d.ts +252 -0
- package/dist/types.d.ts.map +1 -0
- package/dist/types.js +10 -0
- package/dist/types.js.map +1 -0
- package/package.json +31 -0
|
@@ -0,0 +1,119 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Per-send in-memory ring buffer for SSE resume support.
|
|
3
|
+
*
|
|
4
|
+
* Each `POST /gateways/:name/sessions/:id/messages` call creates a fresh
|
|
5
|
+
* buffer keyed by `(gateway, sessionId, sendNonce)`. Events are appended in
|
|
6
|
+
* id order; the most-recent N events are retained (N = `sseBufferSize`,
|
|
7
|
+
* default 1024). After `event: done` the buffer is held for `retentionMs`
|
|
8
|
+
* (default 30_000) to allow late clients to resume.
|
|
9
|
+
*
|
|
10
|
+
* Resume is a pure replay — `adapter.send` is NOT called twice. The buffer
|
|
11
|
+
* is the canonical record of what the original send produced.
|
|
12
|
+
*/
|
|
13
|
+
let nonceCounter = 0;
|
|
14
|
+
function nextNonce() {
|
|
15
|
+
nonceCounter += 1;
|
|
16
|
+
return `n${Date.now().toString(36)}_${nonceCounter}`;
|
|
17
|
+
}
|
|
18
|
+
export function createSseBufferStore(options) {
|
|
19
|
+
// One buffer per (gateway, sessionId, nonce). We retain the most recent
|
|
20
|
+
// completed buffer per session so an empty-body Last-Event-ID resume can
|
|
21
|
+
// find it for a short window.
|
|
22
|
+
const all = new Map();
|
|
23
|
+
const latestBySession = new Map();
|
|
24
|
+
// Ghost set: tracks recently-expired session keys so resume can
|
|
25
|
+
// distinguish "expired" (410) from "never existed" (404). Entries are
|
|
26
|
+
// pruned when older than retentionMs past their expiry time.
|
|
27
|
+
const recentlyExpired = new Map();
|
|
28
|
+
function key(buf) {
|
|
29
|
+
return `${buf.gateway}\u0000${buf.sessionId}\u0000${buf.nonce}`;
|
|
30
|
+
}
|
|
31
|
+
function sessionKey(gateway, sessionId) {
|
|
32
|
+
return `${gateway}\u0000${sessionId}`;
|
|
33
|
+
}
|
|
34
|
+
function create(gateway, sessionId) {
|
|
35
|
+
const buf = {
|
|
36
|
+
gateway,
|
|
37
|
+
sessionId,
|
|
38
|
+
nonce: nextNonce(),
|
|
39
|
+
events: [],
|
|
40
|
+
maxId: 0,
|
|
41
|
+
doneAt: null,
|
|
42
|
+
maxSize: options.maxSize,
|
|
43
|
+
finished: false,
|
|
44
|
+
};
|
|
45
|
+
all.set(key(buf), buf);
|
|
46
|
+
latestBySession.set(sessionKey(gateway, sessionId), key(buf));
|
|
47
|
+
return buf;
|
|
48
|
+
}
|
|
49
|
+
function getLatestForSession(gateway, sessionId) {
|
|
50
|
+
const k = latestBySession.get(sessionKey(gateway, sessionId));
|
|
51
|
+
if (k === undefined)
|
|
52
|
+
return null;
|
|
53
|
+
return all.get(k) ?? null;
|
|
54
|
+
}
|
|
55
|
+
function finish(buf) {
|
|
56
|
+
buf.finished = true;
|
|
57
|
+
buf.doneAt = Date.now();
|
|
58
|
+
}
|
|
59
|
+
function purgeExpired(now) {
|
|
60
|
+
// First, prune ghost entries older than retentionMs past their expiry.
|
|
61
|
+
for (const [skey, expiredAt] of recentlyExpired) {
|
|
62
|
+
if (now - expiredAt > options.retentionMs) {
|
|
63
|
+
recentlyExpired.delete(skey);
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
// Then, move expired live buffers to the ghost set before deleting.
|
|
67
|
+
for (const [k, buf] of all) {
|
|
68
|
+
if (buf.doneAt !== null && now - buf.doneAt > options.retentionMs) {
|
|
69
|
+
const skey = sessionKey(buf.gateway, buf.sessionId);
|
|
70
|
+
recentlyExpired.set(skey, now);
|
|
71
|
+
all.delete(k);
|
|
72
|
+
if (latestBySession.get(skey) === k) {
|
|
73
|
+
latestBySession.delete(skey);
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
function wasRecentlyExpired(gateway, sessionId) {
|
|
79
|
+
return recentlyExpired.has(sessionKey(gateway, sessionId));
|
|
80
|
+
}
|
|
81
|
+
return {
|
|
82
|
+
create,
|
|
83
|
+
getLatestForSession,
|
|
84
|
+
finish,
|
|
85
|
+
purgeExpired,
|
|
86
|
+
wasRecentlyExpired,
|
|
87
|
+
};
|
|
88
|
+
}
|
|
89
|
+
/** Append an event to a buffer, maintaining `maxSize` ring semantics. */
|
|
90
|
+
export function appendEvent(buf, event, data) {
|
|
91
|
+
const id = buf.maxId + 1;
|
|
92
|
+
buf.maxId = id;
|
|
93
|
+
const evt = { id, event, data };
|
|
94
|
+
buf.events.push(evt);
|
|
95
|
+
if (buf.events.length > buf.maxSize) {
|
|
96
|
+
buf.events.shift();
|
|
97
|
+
}
|
|
98
|
+
return evt;
|
|
99
|
+
}
|
|
100
|
+
/** Wire-format an event: `id: ...\nevent: ...\ndata: ...\n\n`. */
|
|
101
|
+
export function formatEvent(evt) {
|
|
102
|
+
return `id: ${evt.id}\nevent: ${evt.event}\ndata: ${evt.data}\n\n`;
|
|
103
|
+
}
|
|
104
|
+
/** Range-replay events whose id is strictly greater than `since`. */
|
|
105
|
+
export function eventsAfter(buf, since) {
|
|
106
|
+
if (since < 0)
|
|
107
|
+
return buf.events.slice();
|
|
108
|
+
return buf.events.filter((e) => e.id > since);
|
|
109
|
+
}
|
|
110
|
+
/**
|
|
111
|
+
* Lowest still-buffered event id, or 0 when empty. Useful when computing
|
|
112
|
+
* whether a Last-Event-ID predates the ring's current window.
|
|
113
|
+
*/
|
|
114
|
+
export function lowestId(buf) {
|
|
115
|
+
if (buf.events.length === 0)
|
|
116
|
+
return 0;
|
|
117
|
+
return buf.events[0]?.id ?? 0;
|
|
118
|
+
}
|
|
119
|
+
//# sourceMappingURL=buffer.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"buffer.js","sourceRoot":"","sources":["../../src/sse/buffer.ts"],"names":[],"mappings":"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"}
|
|
@@ -0,0 +1,3 @@
|
|
|
1
|
+
export { appendEvent, createSseBufferStore, eventsAfter, formatEvent, lowestId, type SseBuffer, type SseBufferOptions, type SseBufferStore, type SseEvent, } from "./buffer.js";
|
|
2
|
+
export { handleMessageEndpoint, type MessageEndpointDeps, makeMessageBufferStore, } from "./messages.js";
|
|
3
|
+
//# sourceMappingURL=index.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../../src/sse/index.ts"],"names":[],"mappings":"AAAA,OAAO,EACN,WAAW,EACX,oBAAoB,EACpB,WAAW,EACX,WAAW,EACX,QAAQ,EACR,KAAK,SAAS,EACd,KAAK,gBAAgB,EACrB,KAAK,cAAc,EACnB,KAAK,QAAQ,GACb,MAAM,aAAa,CAAC;AACrB,OAAO,EACN,qBAAqB,EACrB,KAAK,mBAAmB,EACxB,sBAAsB,GACtB,MAAM,eAAe,CAAC"}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"index.js","sourceRoot":"","sources":["../../src/sse/index.ts"],"names":[],"mappings":"AAAA,OAAO,EACN,WAAW,EACX,oBAAoB,EACpB,WAAW,EACX,WAAW,EACX,QAAQ,GAKR,MAAM,aAAa,CAAC;AACrB,OAAO,EACN,qBAAqB,EAErB,sBAAsB,GACtB,MAAM,eAAe,CAAC"}
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* SSE message endpoint — `POST /gateways/:name/sessions/:id/messages`.
|
|
3
|
+
*
|
|
4
|
+
* Streams turn / heartbeat / done / error events generated by the gateway's
|
|
5
|
+
* adapter. Supports `Last-Event-ID` header for resume from in-memory buffer.
|
|
6
|
+
*
|
|
7
|
+
* Phase 4: each user/assistant turn is also written to ocas before its SSE
|
|
8
|
+
* event is emitted; the resulting hash is stamped onto the SSE payload's
|
|
9
|
+
* `value.hash` and appended to `Session.turnHashes` so the history endpoint
|
|
10
|
+
* can replay it.
|
|
11
|
+
*/
|
|
12
|
+
import type { IncomingMessage, ServerResponse } from "node:http";
|
|
13
|
+
import type { Adapter } from "@sumeru/core";
|
|
14
|
+
import type { SessionStore } from "../session/index.js";
|
|
15
|
+
import type { ServerConfig } from "../types.js";
|
|
16
|
+
import { type SseBufferStore } from "./buffer.js";
|
|
17
|
+
export type MessageEndpointDeps = {
|
|
18
|
+
sessions: SessionStore;
|
|
19
|
+
adapters: Record<string, Adapter>;
|
|
20
|
+
config: ServerConfig;
|
|
21
|
+
bufferStore: SseBufferStore;
|
|
22
|
+
};
|
|
23
|
+
/** Build an isolated buffer store per server instance. */
|
|
24
|
+
export declare function makeMessageBufferStore(config: ServerConfig): SseBufferStore;
|
|
25
|
+
/**
|
|
26
|
+
* Handle the SSE message endpoint. Performs body validation, status flips,
|
|
27
|
+
* adapter invocation, and event emission.
|
|
28
|
+
*/
|
|
29
|
+
export declare function handleMessageEndpoint(req: IncomingMessage, res: ServerResponse, gatewayName: string, sessionId: string, deps: MessageEndpointDeps): Promise<void>;
|
|
30
|
+
//# sourceMappingURL=messages.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"messages.d.ts","sourceRoot":"","sources":["../../src/sse/messages.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;GAUG;AAEH,OAAO,KAAK,EAAE,eAAe,EAAE,cAAc,EAAE,MAAM,WAAW,CAAC;AAEjE,OAAO,KAAK,EAAE,OAAO,EAAQ,MAAM,cAAc,CAAC;AAGlD,OAAO,KAAK,EAAE,YAAY,EAAE,MAAM,qBAAqB,CAAC;AACxD,OAAO,KAAK,EAAE,YAAY,EAAE,MAAM,aAAa,CAAC;AAChD,OAAO,EAON,KAAK,cAAc,EACnB,MAAM,aAAa,CAAC;AAErB,MAAM,MAAM,mBAAmB,GAAG;IACjC,QAAQ,EAAE,YAAY,CAAC;IACvB,QAAQ,EAAE,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,CAAC;IAClC,MAAM,EAAE,YAAY,CAAC;IACrB,WAAW,EAAE,cAAc,CAAC;CAC5B,CAAC;AAEF,0DAA0D;AAC1D,wBAAgB,sBAAsB,CAAC,MAAM,EAAE,YAAY,GAAG,cAAc,CAK3E;AAmCD;;;GAGG;AACH,wBAAsB,qBAAqB,CAC1C,GAAG,EAAE,eAAe,EACpB,GAAG,EAAE,cAAc,EACnB,WAAW,EAAE,MAAM,EACnB,SAAS,EAAE,MAAM,EACjB,IAAI,EAAE,mBAAmB,GACvB,OAAO,CAAC,IAAI,CAAC,CAsZf"}
|
|
@@ -0,0 +1,489 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* SSE message endpoint — `POST /gateways/:name/sessions/:id/messages`.
|
|
3
|
+
*
|
|
4
|
+
* Streams turn / heartbeat / done / error events generated by the gateway's
|
|
5
|
+
* adapter. Supports `Last-Event-ID` header for resume from in-memory buffer.
|
|
6
|
+
*
|
|
7
|
+
* Phase 4: each user/assistant turn is also written to ocas before its SSE
|
|
8
|
+
* event is emitted; the resulting hash is stamped onto the SSE payload's
|
|
9
|
+
* `value.hash` and appended to `Session.turnHashes` so the history endpoint
|
|
10
|
+
* can replay it.
|
|
11
|
+
*/
|
|
12
|
+
import { SchemaValidationError } from "@ocas/core";
|
|
13
|
+
import { errorEnvelope } from "../envelope.js";
|
|
14
|
+
import { recordPayload } from "../ocas/index.js";
|
|
15
|
+
import { appendEvent, createSseBufferStore, eventsAfter, formatEvent, lowestId, } from "./buffer.js";
|
|
16
|
+
/** Build an isolated buffer store per server instance. */
|
|
17
|
+
export function makeMessageBufferStore(config) {
|
|
18
|
+
return createSseBufferStore({
|
|
19
|
+
maxSize: config.sseBufferSize,
|
|
20
|
+
retentionMs: config.sseRetentionMs,
|
|
21
|
+
});
|
|
22
|
+
}
|
|
23
|
+
function parseLastEventId(headerValue) {
|
|
24
|
+
if (headerValue === undefined)
|
|
25
|
+
return { ok: true, value: null };
|
|
26
|
+
const trimmed = headerValue.trim();
|
|
27
|
+
if (trimmed === "") {
|
|
28
|
+
return {
|
|
29
|
+
ok: false,
|
|
30
|
+
message: "Last-Event-ID header is present but empty",
|
|
31
|
+
};
|
|
32
|
+
}
|
|
33
|
+
if (!/^[0-9]+$/.test(trimmed)) {
|
|
34
|
+
return {
|
|
35
|
+
ok: false,
|
|
36
|
+
message: `Last-Event-ID must be a non-negative integer (got '${headerValue}')`,
|
|
37
|
+
};
|
|
38
|
+
}
|
|
39
|
+
const n = Number.parseInt(trimmed, 10);
|
|
40
|
+
if (!Number.isFinite(n) || n < 0) {
|
|
41
|
+
return {
|
|
42
|
+
ok: false,
|
|
43
|
+
message: `Last-Event-ID must be a non-negative integer (got '${headerValue}')`,
|
|
44
|
+
};
|
|
45
|
+
}
|
|
46
|
+
return { ok: true, value: n };
|
|
47
|
+
}
|
|
48
|
+
/**
|
|
49
|
+
* Handle the SSE message endpoint. Performs body validation, status flips,
|
|
50
|
+
* adapter invocation, and event emission.
|
|
51
|
+
*/
|
|
52
|
+
export async function handleMessageEndpoint(req, res, gatewayName, sessionId, deps) {
|
|
53
|
+
const { sessions, adapters, config, bufferStore } = deps;
|
|
54
|
+
bufferStore.purgeExpired(Date.now());
|
|
55
|
+
const session = sessions.get(gatewayName, sessionId);
|
|
56
|
+
if (session === null) {
|
|
57
|
+
writeJson(res, 404, errorEnvelope("session_not_found", `Session ${sessionId} not found on gateway ${gatewayName}`));
|
|
58
|
+
return;
|
|
59
|
+
}
|
|
60
|
+
if (session.status === "closed") {
|
|
61
|
+
writeJson(res, 404, errorEnvelope("session_not_found", `Session ${sessionId} not found on gateway ${gatewayName}`));
|
|
62
|
+
return;
|
|
63
|
+
}
|
|
64
|
+
const lastEventIdRaw = readHeader(req, "last-event-id");
|
|
65
|
+
const lastEventId = parseLastEventId(lastEventIdRaw);
|
|
66
|
+
if (!lastEventId.ok) {
|
|
67
|
+
writeJson(res, 400, errorEnvelope("invalid_last_event_id", lastEventId.message));
|
|
68
|
+
return;
|
|
69
|
+
}
|
|
70
|
+
const body = await readJsonBody(req);
|
|
71
|
+
if (!body.ok) {
|
|
72
|
+
writeJson(res, 400, errorEnvelope(body.error, body.message));
|
|
73
|
+
return;
|
|
74
|
+
}
|
|
75
|
+
const isResumeOnly = lastEventId.value !== null && body.value.contentMissing === true;
|
|
76
|
+
const isResumeWithBody = lastEventId.value !== null && body.value.contentMissing !== true;
|
|
77
|
+
const lastEventIdValue = lastEventId.value;
|
|
78
|
+
// Empty-body resume: pure replay
|
|
79
|
+
if (isResumeOnly && lastEventIdValue !== null) {
|
|
80
|
+
await handleResumeOnly(res, gatewayName, sessionId, lastEventIdValue, bufferStore, config);
|
|
81
|
+
return;
|
|
82
|
+
}
|
|
83
|
+
// Validate content: required for new send AND resume-with-body
|
|
84
|
+
if (body.value.contentMissing === true) {
|
|
85
|
+
writeJson(res, 400, errorEnvelope("invalid_request", "Missing required field 'content'"));
|
|
86
|
+
return;
|
|
87
|
+
}
|
|
88
|
+
if (body.value.content === "") {
|
|
89
|
+
writeJson(res, 400, errorEnvelope("invalid_request", "Field 'content' must be a non-empty string"));
|
|
90
|
+
return;
|
|
91
|
+
}
|
|
92
|
+
// Adapter is keyed by adapter NAME (from gateway config), not gateway name.
|
|
93
|
+
const gatewayCfg = config.gateways[session.gateway];
|
|
94
|
+
const adapterByName = gatewayCfg !== undefined ? adapters[gatewayCfg.adapter] : undefined;
|
|
95
|
+
if (adapterByName === undefined) {
|
|
96
|
+
writeJson(res, 503, errorEnvelope("adapter_unavailable", `Adapter '${gatewayCfg?.adapter ?? "(unknown)"}' is not registered for gateway '${session.gateway}'`));
|
|
97
|
+
return;
|
|
98
|
+
}
|
|
99
|
+
// Resume-with-body: continue an existing buffer if one is live, else 404.
|
|
100
|
+
if (isResumeWithBody && lastEventIdValue !== null) {
|
|
101
|
+
const buf = bufferStore.getLatestForSession(gatewayName, sessionId);
|
|
102
|
+
if (buf === null) {
|
|
103
|
+
if (bufferStore.wasRecentlyExpired(gatewayName, sessionId)) {
|
|
104
|
+
writeJson(res, 410, errorEnvelope("stream_expired", `SSE stream for session ${sessionId} has expired (retained ${Math.round(config.sseRetentionMs / 1000)}s after completion)`));
|
|
105
|
+
return;
|
|
106
|
+
}
|
|
107
|
+
writeJson(res, 404, errorEnvelope("no_event_buffer", `No buffered SSE stream to resume on session ${sessionId}`));
|
|
108
|
+
return;
|
|
109
|
+
}
|
|
110
|
+
await streamFromBufferThenLive(res, buf, lastEventIdValue, config);
|
|
111
|
+
return;
|
|
112
|
+
}
|
|
113
|
+
// Concurrency check (idle → active). Resume cases handled above.
|
|
114
|
+
const transition = sessions.tryActivate(gatewayName, sessionId);
|
|
115
|
+
if (!transition.ok) {
|
|
116
|
+
const reason = transition.reason;
|
|
117
|
+
if (reason === "busy") {
|
|
118
|
+
writeJson(res, 409, errorEnvelope("session_busy", `Session ${sessionId} is currently active`));
|
|
119
|
+
return;
|
|
120
|
+
}
|
|
121
|
+
if (reason === "closed") {
|
|
122
|
+
writeJson(res, 404, errorEnvelope("session_not_found", `Session ${sessionId} not found on gateway ${gatewayName}`));
|
|
123
|
+
return;
|
|
124
|
+
}
|
|
125
|
+
writeJson(res, 404, errorEnvelope("session_not_found", `Session ${sessionId} not found on gateway ${gatewayName}`));
|
|
126
|
+
return;
|
|
127
|
+
}
|
|
128
|
+
const nativeRef = sessions.getNativeRef(gatewayName, sessionId);
|
|
129
|
+
if (nativeRef === null) {
|
|
130
|
+
sessions.markIdle(gatewayName, sessionId);
|
|
131
|
+
writeJson(res, 503, errorEnvelope("adapter_unavailable", `Session ${sessionId} has no native ref recorded — adapter likely failed at create time`));
|
|
132
|
+
return;
|
|
133
|
+
}
|
|
134
|
+
// Write the user turn to ocas BEFORE invoking adapter.send. The session has
|
|
135
|
+
// been activated; if the write fails we restore idle and return 500.
|
|
136
|
+
const userTurnIndex = session.turnHashes.length;
|
|
137
|
+
const userTurn = {
|
|
138
|
+
index: userTurnIndex,
|
|
139
|
+
role: "user",
|
|
140
|
+
content: body.value.content,
|
|
141
|
+
timestamp: new Date().toISOString(),
|
|
142
|
+
toolCalls: null,
|
|
143
|
+
tokens: null,
|
|
144
|
+
hash: null,
|
|
145
|
+
};
|
|
146
|
+
const userPayload = turnPayload(userTurn);
|
|
147
|
+
let userHash;
|
|
148
|
+
try {
|
|
149
|
+
userHash = recordPayload(config.ocas.store, config.ocas.turnSchemaHash, userPayload);
|
|
150
|
+
}
|
|
151
|
+
catch (err) {
|
|
152
|
+
sessions.markIdle(gatewayName, sessionId);
|
|
153
|
+
const cause = err instanceof Error ? err.message : String(err);
|
|
154
|
+
const code = err instanceof SchemaValidationError
|
|
155
|
+
? "ocas_validation_failed"
|
|
156
|
+
: "ocas_write_failed";
|
|
157
|
+
writeJson(res, 500, errorEnvelope(code, `Failed to record user turn: ${truncate(cause, 500)}`));
|
|
158
|
+
return;
|
|
159
|
+
}
|
|
160
|
+
// Phase 5: index the user turn for FTS5 search BEFORE appending the hash
|
|
161
|
+
// or starting the SSE stream. If indexing fails the ocas write stays —
|
|
162
|
+
// the index can be rebuilt — but the request fails fast.
|
|
163
|
+
try {
|
|
164
|
+
config.ocas.searchIndex.indexTurn({
|
|
165
|
+
turnHash: userHash,
|
|
166
|
+
sessionId,
|
|
167
|
+
gateway: gatewayName,
|
|
168
|
+
turnIndex: userTurn.index,
|
|
169
|
+
role: "user",
|
|
170
|
+
content: userTurn.content,
|
|
171
|
+
createdAt: userTurn.timestamp,
|
|
172
|
+
});
|
|
173
|
+
}
|
|
174
|
+
catch (err) {
|
|
175
|
+
sessions.markIdle(gatewayName, sessionId);
|
|
176
|
+
const cause = err instanceof Error ? err.message : String(err);
|
|
177
|
+
writeJson(res, 500, errorEnvelope("search_index_failed", `Failed to update search index: ${truncate(cause, 500)}`));
|
|
178
|
+
return;
|
|
179
|
+
}
|
|
180
|
+
// Phase 6 (Refs #399): persist the turn-list pointer. A failure here must
|
|
181
|
+
// not silently diverge memory from disk — fail fast with a clean 500 before
|
|
182
|
+
// the SSE stream starts (headers are written just below).
|
|
183
|
+
try {
|
|
184
|
+
sessions.appendTurnHash(gatewayName, sessionId, userHash);
|
|
185
|
+
}
|
|
186
|
+
catch (err) {
|
|
187
|
+
sessions.markIdle(gatewayName, sessionId);
|
|
188
|
+
const cause = err instanceof Error ? err.message : String(err);
|
|
189
|
+
writeJson(res, 500, errorEnvelope("turn_persist_failed", `Failed to persist turn list: ${truncate(cause, 500)}`));
|
|
190
|
+
return;
|
|
191
|
+
}
|
|
192
|
+
const buf = bufferStore.create(gatewayName, sessionId);
|
|
193
|
+
writeSseHeaders(res);
|
|
194
|
+
const startedAt = Date.now();
|
|
195
|
+
const heartbeatTimer = startHeartbeats(res, buf, startedAt, config);
|
|
196
|
+
let turnCount = 0;
|
|
197
|
+
try {
|
|
198
|
+
for await (const event of adapterByName.send(nativeRef, body.value.content)) {
|
|
199
|
+
if (event.type === "turn") {
|
|
200
|
+
const turn = event.turn;
|
|
201
|
+
const payload = turnPayload(turn);
|
|
202
|
+
let hash;
|
|
203
|
+
try {
|
|
204
|
+
hash = recordPayload(config.ocas.store, config.ocas.turnSchemaHash, payload);
|
|
205
|
+
}
|
|
206
|
+
catch (err) {
|
|
207
|
+
const cause = err instanceof Error ? err.message : String(err);
|
|
208
|
+
const reason = err instanceof SchemaValidationError
|
|
209
|
+
? "adapter_returned_invalid_turn"
|
|
210
|
+
: "ocas_write_failed";
|
|
211
|
+
const errEvt = appendEvent(buf, "error", JSON.stringify({
|
|
212
|
+
type: "@sumeru/error",
|
|
213
|
+
value: { error: reason, message: truncate(cause, 500) },
|
|
214
|
+
}));
|
|
215
|
+
res.write(formatEvent(errEvt));
|
|
216
|
+
break;
|
|
217
|
+
}
|
|
218
|
+
try {
|
|
219
|
+
config.ocas.searchIndex.indexTurn({
|
|
220
|
+
turnHash: hash,
|
|
221
|
+
sessionId,
|
|
222
|
+
gateway: gatewayName,
|
|
223
|
+
turnIndex: turn.index,
|
|
224
|
+
role: turn.role,
|
|
225
|
+
content: turn.content,
|
|
226
|
+
createdAt: turn.timestamp,
|
|
227
|
+
});
|
|
228
|
+
}
|
|
229
|
+
catch (err) {
|
|
230
|
+
const cause = err instanceof Error ? err.message : String(err);
|
|
231
|
+
const errEvt = appendEvent(buf, "error", JSON.stringify({
|
|
232
|
+
type: "@sumeru/error",
|
|
233
|
+
value: {
|
|
234
|
+
error: "search_index_failed",
|
|
235
|
+
message: `Failed to update search index: ${truncate(cause, 500)}`,
|
|
236
|
+
},
|
|
237
|
+
}));
|
|
238
|
+
res.write(formatEvent(errEvt));
|
|
239
|
+
break;
|
|
240
|
+
}
|
|
241
|
+
try {
|
|
242
|
+
sessions.appendTurnHash(gatewayName, sessionId, hash);
|
|
243
|
+
}
|
|
244
|
+
catch (err) {
|
|
245
|
+
const cause = err instanceof Error ? err.message : String(err);
|
|
246
|
+
const errEvt = appendEvent(buf, "error", JSON.stringify({
|
|
247
|
+
type: "@sumeru/error",
|
|
248
|
+
value: {
|
|
249
|
+
error: "turn_persist_failed",
|
|
250
|
+
message: `Failed to persist turn list: ${truncate(cause, 500)}`,
|
|
251
|
+
},
|
|
252
|
+
}));
|
|
253
|
+
res.write(formatEvent(errEvt));
|
|
254
|
+
break;
|
|
255
|
+
}
|
|
256
|
+
const wireTurn = { ...turn, hash };
|
|
257
|
+
const evt = appendEvent(buf, "turn", JSON.stringify({ type: "@sumeru/turn", value: wireTurn }));
|
|
258
|
+
res.write(formatEvent(evt));
|
|
259
|
+
turnCount += 1;
|
|
260
|
+
}
|
|
261
|
+
else if (event.type === "done") {
|
|
262
|
+
const summary = {
|
|
263
|
+
turnCount,
|
|
264
|
+
tokens: event.tokens === null
|
|
265
|
+
? null
|
|
266
|
+
: { in: event.tokens.input, out: event.tokens.output },
|
|
267
|
+
durationMs: event.durationMs,
|
|
268
|
+
};
|
|
269
|
+
const doneEvt = appendEvent(buf, "done", JSON.stringify({ type: "@sumeru/summary", value: summary }));
|
|
270
|
+
res.write(formatEvent(doneEvt));
|
|
271
|
+
}
|
|
272
|
+
else if (event.type === "error") {
|
|
273
|
+
const errEvt = appendEvent(buf, "error", JSON.stringify({
|
|
274
|
+
type: "@sumeru/error",
|
|
275
|
+
value: {
|
|
276
|
+
error: "adapter_error",
|
|
277
|
+
message: truncate(event.error.message, 500),
|
|
278
|
+
},
|
|
279
|
+
}));
|
|
280
|
+
res.write(formatEvent(errEvt));
|
|
281
|
+
}
|
|
282
|
+
}
|
|
283
|
+
}
|
|
284
|
+
catch (err) {
|
|
285
|
+
const adapterError = err instanceof Error ? err : new Error(String(err));
|
|
286
|
+
const errEvt = appendEvent(buf, "error", JSON.stringify({
|
|
287
|
+
type: "@sumeru/error",
|
|
288
|
+
value: {
|
|
289
|
+
error: "adapter_error",
|
|
290
|
+
message: truncate(adapterError.message, 500),
|
|
291
|
+
},
|
|
292
|
+
}));
|
|
293
|
+
res.write(formatEvent(errEvt));
|
|
294
|
+
}
|
|
295
|
+
finally {
|
|
296
|
+
clearInterval(heartbeatTimer);
|
|
297
|
+
sessions.markIdle(gatewayName, sessionId);
|
|
298
|
+
}
|
|
299
|
+
bufferStore.finish(buf);
|
|
300
|
+
res.end();
|
|
301
|
+
}
|
|
302
|
+
/**
|
|
303
|
+
* Strip server-only / nullable fields from a Turn before recording so the
|
|
304
|
+
* payload conforms to `@sumeru/turn`. The schema rejects extra fields
|
|
305
|
+
* (`additionalProperties: false`); also drops `tokens` when null/absent and
|
|
306
|
+
* `hash` (always server-injected, never persisted INSIDE the payload).
|
|
307
|
+
*/
|
|
308
|
+
function turnPayload(turn) {
|
|
309
|
+
const out = {
|
|
310
|
+
index: turn.index,
|
|
311
|
+
role: turn.role,
|
|
312
|
+
content: turn.content,
|
|
313
|
+
timestamp: turn.timestamp,
|
|
314
|
+
toolCalls: turn.toolCalls,
|
|
315
|
+
};
|
|
316
|
+
if (turn.tokens !== undefined && turn.tokens !== null) {
|
|
317
|
+
out.tokens = { input: turn.tokens.input, output: turn.tokens.output };
|
|
318
|
+
}
|
|
319
|
+
return out;
|
|
320
|
+
}
|
|
321
|
+
async function handleResumeOnly(res, gatewayName, sessionId, since, bufferStore, config) {
|
|
322
|
+
const buf = bufferStore.getLatestForSession(gatewayName, sessionId);
|
|
323
|
+
if (buf === null) {
|
|
324
|
+
if (bufferStore.wasRecentlyExpired(gatewayName, sessionId)) {
|
|
325
|
+
writeJson(res, 410, errorEnvelope("stream_expired", `SSE stream for session ${sessionId} has expired (retained ${Math.round(config.sseRetentionMs / 1000)}s after completion)`));
|
|
326
|
+
return;
|
|
327
|
+
}
|
|
328
|
+
writeJson(res, 404, errorEnvelope("no_event_buffer", `No buffered SSE stream to resume on session ${sessionId}`));
|
|
329
|
+
return;
|
|
330
|
+
}
|
|
331
|
+
if (since > buf.maxId) {
|
|
332
|
+
writeJson(res, 400, errorEnvelope("invalid_last_event_id", `Last-Event-ID ${since} is greater than the highest known event id ${buf.maxId}`));
|
|
333
|
+
return;
|
|
334
|
+
}
|
|
335
|
+
if (buf.doneAt !== null && Date.now() - buf.doneAt > buf.maxSize * 0) {
|
|
336
|
+
// retention check is handled by purgeExpired; if buf still resolves we can replay.
|
|
337
|
+
}
|
|
338
|
+
const lowest = lowestId(buf);
|
|
339
|
+
if (lowest > 0 && since < lowest - 1) {
|
|
340
|
+
writeJson(res, 410, errorEnvelope("events_evicted", `Last-Event-ID ${since} is older than the lowest still-buffered event ${lowest}`));
|
|
341
|
+
return;
|
|
342
|
+
}
|
|
343
|
+
writeSseHeaders(res);
|
|
344
|
+
const remaining = eventsAfter(buf, since);
|
|
345
|
+
for (const evt of remaining) {
|
|
346
|
+
res.write(formatEvent(evt));
|
|
347
|
+
}
|
|
348
|
+
res.end();
|
|
349
|
+
}
|
|
350
|
+
async function streamFromBufferThenLive(res, buf, since, _config) {
|
|
351
|
+
if (since > buf.maxId) {
|
|
352
|
+
writeJson(res, 400, errorEnvelope("invalid_last_event_id", `Last-Event-ID ${since} is greater than the highest known event id ${buf.maxId}`));
|
|
353
|
+
return;
|
|
354
|
+
}
|
|
355
|
+
const lowest = lowestId(buf);
|
|
356
|
+
if (lowest > 0 && since < lowest - 1) {
|
|
357
|
+
writeJson(res, 410, errorEnvelope("events_evicted", `Last-Event-ID ${since} is older than the lowest still-buffered event ${lowest}`));
|
|
358
|
+
return;
|
|
359
|
+
}
|
|
360
|
+
writeSseHeaders(res);
|
|
361
|
+
let cursor = since;
|
|
362
|
+
const flushPending = () => {
|
|
363
|
+
const events = eventsAfter(buf, cursor);
|
|
364
|
+
for (const evt of events) {
|
|
365
|
+
res.write(formatEvent(evt));
|
|
366
|
+
cursor = Math.max(cursor, evt.id);
|
|
367
|
+
}
|
|
368
|
+
return buf.finished;
|
|
369
|
+
};
|
|
370
|
+
if (flushPending()) {
|
|
371
|
+
res.end();
|
|
372
|
+
return;
|
|
373
|
+
}
|
|
374
|
+
// Poll the buffer until finished. The original send writes events to this
|
|
375
|
+
// same buffer; resume just mirrors them out on the new connection.
|
|
376
|
+
await new Promise((resolve) => {
|
|
377
|
+
const poll = setInterval(() => {
|
|
378
|
+
const finished = flushPending();
|
|
379
|
+
if (finished) {
|
|
380
|
+
clearInterval(poll);
|
|
381
|
+
res.end();
|
|
382
|
+
resolve();
|
|
383
|
+
}
|
|
384
|
+
}, 25);
|
|
385
|
+
res.once("close", () => {
|
|
386
|
+
clearInterval(poll);
|
|
387
|
+
resolve();
|
|
388
|
+
});
|
|
389
|
+
});
|
|
390
|
+
}
|
|
391
|
+
function writeSseHeaders(res) {
|
|
392
|
+
res.statusCode = 200;
|
|
393
|
+
res.setHeader("Content-Type", "text/event-stream; charset=utf-8");
|
|
394
|
+
res.setHeader("Cache-Control", "no-cache, no-transform");
|
|
395
|
+
res.setHeader("Connection", "keep-alive");
|
|
396
|
+
res.setHeader("X-Accel-Buffering", "no");
|
|
397
|
+
// Disable Nagle's algorithm so each res.write() flushes immediately.
|
|
398
|
+
// Without this, SSE events (heartbeats, turns, done) are buffered by
|
|
399
|
+
// the TCP stack and never reach the client — the broker's consumeSse
|
|
400
|
+
// blocks on reader.read() forever. Fixes #30.
|
|
401
|
+
res.socket?.setNoDelay(true);
|
|
402
|
+
res.flushHeaders?.();
|
|
403
|
+
}
|
|
404
|
+
function startHeartbeats(res, buf, startedAt, config) {
|
|
405
|
+
const timer = setInterval(() => {
|
|
406
|
+
const elapsed = Date.now() - startedAt;
|
|
407
|
+
const evt = appendEvent(buf, "heartbeat", JSON.stringify({
|
|
408
|
+
type: "@sumeru/heartbeat",
|
|
409
|
+
value: { elapsed },
|
|
410
|
+
}));
|
|
411
|
+
res.write(formatEvent(evt));
|
|
412
|
+
}, Math.max(50, config.sseHeartbeatMs));
|
|
413
|
+
timer.unref();
|
|
414
|
+
return timer;
|
|
415
|
+
}
|
|
416
|
+
function truncate(s, max) {
|
|
417
|
+
if (s.length <= max)
|
|
418
|
+
return s;
|
|
419
|
+
return `${s.slice(0, max - 1)}…`;
|
|
420
|
+
}
|
|
421
|
+
function readHeader(req, name) {
|
|
422
|
+
const value = req.headers[name];
|
|
423
|
+
if (value === undefined)
|
|
424
|
+
return undefined;
|
|
425
|
+
if (Array.isArray(value))
|
|
426
|
+
return value[0];
|
|
427
|
+
return value;
|
|
428
|
+
}
|
|
429
|
+
async function readJsonBody(req) {
|
|
430
|
+
const chunks = [];
|
|
431
|
+
let total = 0;
|
|
432
|
+
const MAX = 1024 * 1024;
|
|
433
|
+
for await (const chunk of req) {
|
|
434
|
+
const buf = chunk instanceof Buffer ? chunk : Buffer.from(chunk);
|
|
435
|
+
total += buf.length;
|
|
436
|
+
if (total > MAX) {
|
|
437
|
+
return {
|
|
438
|
+
ok: false,
|
|
439
|
+
error: "invalid_request",
|
|
440
|
+
message: "Request body too large",
|
|
441
|
+
};
|
|
442
|
+
}
|
|
443
|
+
chunks.push(buf);
|
|
444
|
+
}
|
|
445
|
+
const raw = Buffer.concat(chunks).toString("utf-8").trim();
|
|
446
|
+
if (raw.length === 0) {
|
|
447
|
+
return { ok: true, value: { content: "", contentMissing: true } };
|
|
448
|
+
}
|
|
449
|
+
let parsed;
|
|
450
|
+
try {
|
|
451
|
+
parsed = JSON.parse(raw);
|
|
452
|
+
}
|
|
453
|
+
catch (err) {
|
|
454
|
+
const detail = err instanceof Error ? err.message : String(err);
|
|
455
|
+
return {
|
|
456
|
+
ok: false,
|
|
457
|
+
error: "invalid_json",
|
|
458
|
+
message: `Request body is not valid JSON: ${detail}`,
|
|
459
|
+
};
|
|
460
|
+
}
|
|
461
|
+
if (parsed === null || typeof parsed !== "object" || Array.isArray(parsed)) {
|
|
462
|
+
return {
|
|
463
|
+
ok: false,
|
|
464
|
+
error: "invalid_request",
|
|
465
|
+
message: "Request body must be a JSON object",
|
|
466
|
+
};
|
|
467
|
+
}
|
|
468
|
+
const obj = parsed;
|
|
469
|
+
if (!("content" in obj)) {
|
|
470
|
+
return { ok: true, value: { content: "", contentMissing: true } };
|
|
471
|
+
}
|
|
472
|
+
const content = obj.content;
|
|
473
|
+
if (typeof content !== "string") {
|
|
474
|
+
return {
|
|
475
|
+
ok: false,
|
|
476
|
+
error: "invalid_request",
|
|
477
|
+
message: "Field 'content' must be a string",
|
|
478
|
+
};
|
|
479
|
+
}
|
|
480
|
+
return { ok: true, value: { content, contentMissing: false } };
|
|
481
|
+
}
|
|
482
|
+
function writeJson(res, status, body) {
|
|
483
|
+
const payload = JSON.stringify(body);
|
|
484
|
+
res.statusCode = status;
|
|
485
|
+
res.setHeader("Content-Type", "application/json; charset=utf-8");
|
|
486
|
+
res.setHeader("Content-Length", Buffer.byteLength(payload).toString());
|
|
487
|
+
res.end(payload);
|
|
488
|
+
}
|
|
489
|
+
//# sourceMappingURL=messages.js.map
|