@haikit/server 0.12.0 → 0.14.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 +103 -0
- package/dist/index.d.ts +1 -1
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js.map +1 -1
- package/dist/routes.d.ts +10 -6
- package/dist/routes.d.ts.map +1 -1
- package/dist/routes.js +304 -8
- package/dist/routes.js.map +1 -1
- package/dist/runtime.d.ts +65 -4
- package/dist/runtime.d.ts.map +1 -1
- package/dist/runtime.js +263 -10
- package/dist/runtime.js.map +1 -1
- package/dist/store.d.ts.map +1 -1
- package/dist/store.js +73 -1
- package/dist/store.js.map +1 -1
- package/package.json +2 -2
- package/src/index.ts +1 -1
- package/src/routes.ts +308 -10
- package/src/runtime.ts +297 -9
- package/src/store.ts +69 -1
package/dist/store.js.map
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"store.js","sourceRoot":"","sources":["../src/store.ts"],"names":[],"mappings":"AAAA,OAAO,EACL,gBAAgB,EAChB,UAAU,
|
|
1
|
+
{"version":3,"file":"store.js","sourceRoot":"","sources":["../src/store.ts"],"names":[],"mappings":"AAAA,OAAO,EACL,gBAAgB,EAChB,UAAU,GAKX,MAAM,cAAc,CAAC;AAEtB,MAAM,QAAQ,GAAG,OAAO,CAAC;AAczB;;;;;;;;;;;;;;;;;GAiBG;AACH,MAAM,UAAU,WAAW,CAAC,UAA8B,EAAE;IAC1D,MAAM,OAAO,GAAG,OAAO,CAAC,OAAO,IAAI,QAAQ,CAAC;IAC5C,6EAA6E;IAC7E,0EAA0E;IAC1E,2EAA2E;IAC3E,iEAAiE;IACjE,IAAI,CAAC,MAAM,CAAC,QAAQ,CAAC,OAAO,CAAC,IAAI,OAAO,IAAI,CAAC,EAAE,CAAC;QAC9C,MAAM,IAAI,UAAU,CAAC,kEAAkE,OAAO,GAAG,CAAC,CAAC;IACrG,CAAC;IACD,MAAM,aAAa,GAAG,IAAI,GAAG,EAAwB,CAAC;IACtD,MAAM,QAAQ,GAAG,IAAI,GAAG,EAAyB,CAAC;IAClD,4EAA4E;IAC5E,2EAA2E;IAC3E,+DAA+D;IAC/D,MAAM,OAAO,GAAG,IAAI,GAAG,EAA0B,CAAC;IAClD,MAAM,OAAO,GAAG,IAAI,GAAG,EAA2B,CAAC;IACnD,IAAI,OAAO,GAAG,CAAC,CAAC;IAChB,IAAI,SAAS,GAAG,CAAC,CAAC;IAElB,MAAM,IAAI,GAAG,CAAI,KAAQ,EAAK,EAAE,CAAC,IAAI,CAAC,KAAK,CAAC,IAAI,CAAC,SAAS,CAAC,KAAK,CAAC,CAAM,CAAC;IACxE,MAAM,QAAQ,GAAG,GAAG,EAAE,CAAC,UAAU,CAAC,MAAM,CAAC,UAAU,EAAE,CAAC;IAEtD;;;;;;;8CAO0C;IAC1C,MAAM,KAAK,GAAG,CAAC,cAAsB,EAAE,UAAyB,EAAE,EAAE;QAClE,MAAM,MAAM,GAAG,aAAa,CAAC,GAAG,CAAC,cAAc,CAAC,CAAC;QACjD,mEAAmE;QACnE,2EAA2E;QAC3E,yEAAyE;QACzE,uEAAuE;QACvE,4DAA4D;QAC5D,IAAI,CAAC,MAAM;YAAE,MAAM,IAAI,UAAU,CAAC,cAAc,EAAE,kDAAkD,CAAC,CAAC;QACtG,IAAI,MAAM,CAAC,UAAU,KAAK,UAAU;YAAE,MAAM,IAAI,UAAU,CAAC,cAAc,CAAC,CAAC;IAC7E,CAAC,CAAC;IAEF,OAAO;QACL,KAAK,CAAC,gBAAgB,CAAC,EAAE;YACvB,IAAI,EAAE,IAAI,aAAa,CAAC,GAAG,CAAC,EAAE,CAAC,EAAE,CAAC;gBAChC,MAAM,MAAM,GAAG,aAAa,CAAC,GAAG,CAAC,EAAE,CAAE,CAAC;gBACtC,IAAI,MAAM,CAAC,UAAU,KAAK,IAAI,IAAI,MAAM,CAAC,UAAU,GAAG,IAAI,CAAC,GAAG,EAAE,EAAE,CAAC;oBACjE,MAAM,IAAI,gBAAgB,CAAC,MAAM,CAAC,EAAE,CAAC,CAAC;gBACxC,CAAC;gBACD,iEAAiE;gBACjE,MAAM,CAAC,UAAU,GAAG,IAAI,CAAC,GAAG,EAAE,GAAG,OAAO,CAAC;gBACzC,MAAM,CAAC,UAAU,GAAG,QAAQ,EAAE,CAAC;gBAC/B,OAAO,IAAI,CAAC,MAAM,CAAC,CAAC;YACtB,CAAC;YACD,MAAM,YAAY,GAAiB;gBACjC,2EAA2E;gBAC3E,EAAE,EAAE,QAAQ,EAAE,OAAO,IAAI,QAAQ,EAAE,CAAC,UAAU,CAAC,GAAG,EAAE,EAAE,CAAC,CAAC,KAAK,CAAC,CAAC,EAAE,EAAE,CAAC,EAAE;gBACtE,MAAM,EAAE,MAAM;gBACd,QAAQ,EAAE,EAAE;gBACZ,OAAO,EAAE,EAAE;gBACX,MAAM,EAAE,EAAE;gBACV,OAAO,EAAE,IAAI;gBACb,UAAU,EAAE,IAAI,CAAC,GAAG,EAAE,GAAG,OAAO;gBAChC,UAAU,EAAE,QAAQ,EAAE;gBACtB,cAAc,EAAE,CAAC;gBACjB,KAAK,EAAE,EAAE;gBACT,WAAW,EAAE,CAAC;aACf,CAAC;YACF,aAAa,CAAC,GAAG,CAAC,YAAY,CAAC,EAAE,EAAE,YAAY,CAAC,CAAC;YACjD,OAAO,IAAI,CAAC,YAAY,CAAC,CAAC;QAC5B,CAAC;QAED,KAAK,CAAC,gBAAgB,CAAC,YAAY;YACjC,uEAAuE;YACvE,sEAAsE;YACtE,KAAK,CAAC,YAAY,CAAC,EAAE,EAAE,YAAY,CAAC,UAAU,CAAC,CAAC;YAChD,aAAa,CAAC,GAAG,CAAC,YAAY,CAAC,EAAE,EAAE,IAAI,CAAC,YAAY,CAAC,CAAC,CAAC;QACzD,CAAC;QAED,KAAK,CAAC,UAAU,CAAC,MAAM,EAAE,UAAU;YACjC,KAAK,CAAC,MAAM,CAAC,cAAc,EAAE,UAAU,CAAC,CAAC;YACzC,MAAM,MAAM,GAAG,MAAM,MAAM,CAAC,EAAE,SAAS,CAAC,CAAC,QAAQ,CAAC,CAAC,EAAE,GAAG,CAAC,EAAE,CAAC;YAC5D,QAAQ,CAAC,GAAG,CAAC,MAAM,EAAE,IAAI,CAAC,EAAE,GAAG,MAAM,EAAE,MAAM,EAAE,SAAS,EAAE,IAAI,CAAC,GAAG,EAAE,EAAE,YAAY,EAAE,MAAM,CAAC,YAAY,IAAI,IAAI,EAAE,CAAC,CAAC,CAAC;YACpH,OAAO,MAAM,CAAC;QAChB,CAAC;QAED,mEAAmE;QACnE,KAAK,CAAC,UAAU,CAAC,MAAM,EAAE,cAAc;YACrC,MAAM,MAAM,GAAG,QAAQ,CAAC,GAAG,CAAC,MAAM,CAAC,CAAC;YACpC,IAAI,CAAC,MAAM,IAAI,MAAM,CAAC,cAAc,KAAK,cAAc;gBAAE,OAAO,IAAI,CAAC;YACrE,OAAO,IAAI,CAAC,MAAM,CAAC,CAAC;QACtB,CAAC;QAED,KAAK,CAAC,WAAW,CAAC,OAAO,EAAE,cAAc;YACvC,MAAM,GAAG,GAAoB,EAAE,CAAC;YAChC,KAAK,MAAM,MAAM,IAAI,OAAO,EAAE,CAAC;gBAC7B,MAAM,MAAM,GAAG,QAAQ,CAAC,GAAG,CAAC,MAAM,CAAC,CAAC;gBACpC,IAAI,MAAM,IAAI,MAAM,CAAC,cAAc,KAAK,cAAc;oBAAE,GAAG,CAAC,IAAI,CAAC,IAAI,CAAC,MAAM,CAAC,CAAC,CAAC;YACjF,CAAC;YACD,OAAO,GAAG,CAAC;QACb,CAAC;QAED,6EAA6E;QAC7E,KAAK,CAAC,SAAS,CAAC,MAAM;YACpB,IAAI,CAAC,aAAa,CAAC,GAAG,CAAC,MAAM,CAAC,cAAc,CAAC,EAAE,CAAC;gBAC9C,MAAM,IAAI,KAAK,CAAC,gBAAgB,MAAM,CAAC,cAAc,iBAAiB,CAAC,CAAC;YAC1E,CAAC;YACD,MAAM,IAAI,GAAG,OAAO,CAAC,GAAG,CAAC,MAAM,CAAC,cAAc,CAAC,IAAI,EAAE,CAAC;YACtD,MAAM,MAAM,GAAiB,IAAI,CAAC,EAAE,GAAG,MAAM,EAAE,GAAG,EAAE,CAAC,IAAI,CAAC,EAAE,CAAC,CAAC,CAAC,CAAC,EAAE,GAAG,IAAI,CAAC,CAAC,GAAG,CAAC,EAAE,SAAS,EAAE,IAAI,CAAC,GAAG,EAAE,EAAE,CAAC,CAAC;YAC1G,IAAI,CAAC,IAAI,CAAC,MAAM,CAAC,CAAC;YAClB,OAAO,CAAC,GAAG,CAAC,MAAM,CAAC,cAAc,EAAE,IAAI,CAAC,CAAC;YACzC,KAAK,MAAM,IAAI,IAAI,OAAO,CAAC,GAAG,CAAC,MAAM,CAAC,cAAc,CAAC,IAAI,EAAE;gBAAE,IAAI,EAAE,CAAC;YACpE,OAAO,IAAI,CAAC,MAAM,CAAC,CAAC;QACtB,CAAC;QAED,KAAK,CAAC,UAAU,CAAC,cAAc,EAAE,KAAK,EAAE,KAAK,GAAG,QAAQ;YACtD,IAAI,CAAC,aAAa,CAAC,GAAG,CAAC,cAAc,CAAC;gBAAE,OAAO,IAAI,CAAC;YACpD,MAAM,IAAI,GAAG,OAAO,CAAC,GAAG,CAAC,cAAc,CAAC,IAAI,EAAE,CAAC;YAC/C,yEAAyE;YACzE,wEAAwE;YACxE,wCAAwC;YACxC,IAAI,EAAE,GAAG,CAAC,CAAC;YACX,IAAI,EAAE,GAAG,IAAI,CAAC,MAAM,CAAC;YACrB,OAAO,EAAE,GAAG,EAAE,EAAE,CAAC;gBACf,MAAM,GAAG,GAAG,CAAC,EAAE,GAAG,EAAE,CAAC,IAAI,CAAC,CAAC;gBAC3B,IAAI,IAAI,CAAC,GAAG,CAAC,CAAC,GAAG,GAAG,KAAK;oBAAE,EAAE,GAAG,GAAG,CAAC;;oBAC/B,EAAE,GAAG,GAAG,GAAG,CAAC,CAAC;YACpB,CAAC;YACD,OAAO,IAAI,CAAC,IAAI,CAAC,KAAK,CAAC,EAAE,EAAE,EAAE,GAAG,KAAK,CAAC,CAAC,CAAC;QAC1C,CAAC;QAED,KAAK,CAAC,CAAC,KAAK,CAAC,cAAc,EAAE,MAAM;YACjC,qEAAqE;YACrE,sEAAsE;YACtE,IAAI,MAAM,GAAG,KAAK,CAAC;YACnB,IAAI,IAAI,GAAwB,IAAI,CAAC;YACrC,MAAM,QAAQ,GAAG,GAAG,EAAE;gBACpB,MAAM,GAAG,IAAI,CAAC;gBACd,IAAI,EAAE,EAAE,CAAC;YACX,CAAC,CAAC;YACF,MAAM,OAAO,GAAG,GAAG,EAAE,CAAC,IAAI,EAAE,EAAE,CAAC;YAC/B,MAAM,GAAG,GAAG,OAAO,CAAC,GAAG,CAAC,cAAc,CAAC,IAAI,IAAI,GAAG,EAAE,CAAC;YACrD,GAAG,CAAC,GAAG,CAAC,QAAQ,CAAC,CAAC;YAClB,OAAO,CAAC,GAAG,CAAC,cAAc,EAAE,GAAG,CAAC,CAAC;YACjC,MAAM,CAAC,gBAAgB,CAAC,OAAO,EAAE,OAAO,CAAC,CAAC;YAC1C,IAAI,CAAC;gBACH,OAAO,CAAC,MAAM,CAAC,OAAO,EAAE,CAAC;oBACvB,IAAI,CAAC,MAAM;wBAAE,MAAM,IAAI,OAAO,CAAO,CAAC,CAAC,EAAE,EAAE,CAAC,CAAC,IAAI,GAAG,CAAC,CAAC,CAAC,CAAC;oBACxD,IAAI,GAAG,IAAI,CAAC;oBACZ,IAAI,MAAM,CAAC,OAAO;wBAAE,OAAO;oBAC3B,MAAM,GAAG,KAAK,CAAC;oBACf,KAAK,CAAC;gBACR,CAAC;YACH,CAAC;oBAAS,CAAC;gBACT,MAAM,CAAC,mBAAmB,CAAC,OAAO,EAAE,OAAO,CAAC,CAAC;gBAC7C,GAAG,CAAC,MAAM,CAAC,QAAQ,CAAC,CAAC;gBACrB,IAAI,CAAC,GAAG,CAAC,IAAI;oBAAE,OAAO,CAAC,MAAM,CAAC,cAAc,CAAC,CAAC;YAChD,CAAC;QACH,CAAC;KACF,CAAC;AACJ,CAAC"}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@haikit/server",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.14.0",
|
|
4
4
|
"description": "The haikit server runtime: agent loop, elicit state machine, derived query_ui, and the interaction security boundary.",
|
|
5
5
|
"keywords": [
|
|
6
6
|
"haikit",
|
|
@@ -36,7 +36,7 @@
|
|
|
36
36
|
"prepack": "npm --prefix ../.. run build"
|
|
37
37
|
},
|
|
38
38
|
"dependencies": {
|
|
39
|
-
"@haikit/core": "0.
|
|
39
|
+
"@haikit/core": "0.14.0",
|
|
40
40
|
"@types/node": ">=22"
|
|
41
41
|
},
|
|
42
42
|
"publishConfig": {
|
package/src/index.ts
CHANGED
package/src/routes.ts
CHANGED
|
@@ -1,23 +1,33 @@
|
|
|
1
1
|
import type { IncomingMessage, ServerResponse } from "node:http";
|
|
2
2
|
import { isConversationBusy, isStaleLease } from "@haikit/core";
|
|
3
|
-
import type { Emit, WireEvent } from "@haikit/core";
|
|
3
|
+
import type { Emit, NoticeRecord, WireEvent } from "@haikit/core";
|
|
4
4
|
import type { Hai } from "./runtime.js";
|
|
5
5
|
|
|
6
6
|
/**
|
|
7
|
-
* Node http handler for the three routes
|
|
8
|
-
* request.
|
|
7
|
+
* Node http handler for the three routes, and a fourth when the app sends
|
|
8
|
+
* notices. Returns true if it handled the request.
|
|
9
9
|
*
|
|
10
|
-
*
|
|
11
|
-
* loop, start because the init tool it runs can render surfaces.
|
|
12
|
-
* interact route accepts: {handle, action, value} and nothing
|
|
13
|
-
* cannot name a tool, a handler, or an action target.
|
|
10
|
+
* The three POST routes stream SSE: chat and interact because both can resume
|
|
11
|
+
* the agent loop, start because the init tool it runs can render surfaces.
|
|
12
|
+
* Note what the interact route accepts: {handle, action, value} and nothing
|
|
13
|
+
* else. The client cannot name a tool, a handler, or an action target.
|
|
14
|
+
*
|
|
15
|
+
* `GET /events` streams a conversation's notices, and the turns `wake`
|
|
16
|
+
* notices start. It accepts nothing from the browser beyond which
|
|
17
|
+
* conversation, and where to resume.
|
|
14
18
|
*/
|
|
15
19
|
export function nodeHandler(hai: Hai, basePath = "/hai") {
|
|
16
20
|
return async function handle(req: IncomingMessage, res: ServerResponse): Promise<boolean> {
|
|
17
21
|
const url = new URL(req.url ?? "/", "http://localhost");
|
|
18
|
-
if (
|
|
19
|
-
|
|
22
|
+
if (!url.pathname.startsWith(basePath)) return false;
|
|
20
23
|
const route = url.pathname.slice(basePath.length);
|
|
24
|
+
|
|
25
|
+
if (req.method === "GET" && route === "/events" && hai.sendsNotices) {
|
|
26
|
+
await streamNotices(hai, req, res, url);
|
|
27
|
+
return true;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
if (req.method !== "POST") return false;
|
|
21
31
|
if (route !== "/chat" && route !== "/interact" && route !== "/start") return false;
|
|
22
32
|
|
|
23
33
|
const body = await readJson(req);
|
|
@@ -45,7 +55,12 @@ export function nodeHandler(hai: Hai, basePath = "/hai") {
|
|
|
45
55
|
|
|
46
56
|
const emit = openSSE(res);
|
|
47
57
|
if (route === "/chat" || route === "/start") {
|
|
48
|
-
emit({
|
|
58
|
+
emit({
|
|
59
|
+
type: "hello",
|
|
60
|
+
conversationId: conversation.id,
|
|
61
|
+
model: hai.config.model.id,
|
|
62
|
+
...(hai.sendsNotices ? { events: true as const } : {}),
|
|
63
|
+
});
|
|
49
64
|
}
|
|
50
65
|
|
|
51
66
|
let superseded = false;
|
|
@@ -91,6 +106,289 @@ export function nodeHandler(hai: Hai, basePath = "/hai") {
|
|
|
91
106
|
};
|
|
92
107
|
}
|
|
93
108
|
|
|
109
|
+
/**
|
|
110
|
+
* The longest a notice waits to be read. A store's `watch` makes it sooner;
|
|
111
|
+
* this is what still finds a notice whose wake-up was lost.
|
|
112
|
+
*/
|
|
113
|
+
const POLL_MS = 2_000;
|
|
114
|
+
/** Proxies drop a stream that says nothing for long; a comment keeps it open. */
|
|
115
|
+
const HEARTBEAT_MS = 25_000;
|
|
116
|
+
|
|
117
|
+
/** Notices read from the store at a time, so a long backlog goes out page by page. */
|
|
118
|
+
const PAGE = 100;
|
|
119
|
+
|
|
120
|
+
/**
|
|
121
|
+
* Stream a conversation's notices: every one after where the browser has got
|
|
122
|
+
* to (`Last-Event-ID` on a reconnect, else `?after=`), then each as it lands.
|
|
123
|
+
* Takes no lease and writes nothing. Each notice goes out as its payload; its
|
|
124
|
+
* model text is not in it: that reaches the browser only as part of the
|
|
125
|
+
* history, in the `context` event, once a turn has taken it in.
|
|
126
|
+
*
|
|
127
|
+
* Bounded both ways. The store is read a page at a time, so a conversation
|
|
128
|
+
* with years of notices is never held in memory at once, and nothing more is
|
|
129
|
+
* written while the socket is still taking what was, so a reader that is slow
|
|
130
|
+
* cannot make the server buffer every notice for it.
|
|
131
|
+
*/
|
|
132
|
+
async function streamNotices(hai: Hai, req: IncomingMessage, res: ServerResponse, url: URL) {
|
|
133
|
+
const { store } = hai.config;
|
|
134
|
+
const conversationId = url.searchParams.get("conversationId") ?? "";
|
|
135
|
+
const resume = Number(req.headers["last-event-id"] ?? url.searchParams.get("after") ?? 0);
|
|
136
|
+
let after = Number.isSafeInteger(resume) && resume > 0 ? resume : 0;
|
|
137
|
+
|
|
138
|
+
const first = await store.getNotices(conversationId, after, PAGE);
|
|
139
|
+
if (first === null) {
|
|
140
|
+
res.writeHead(404, { "content-type": "application/json" });
|
|
141
|
+
res.end(JSON.stringify({ error: "unknown conversation" }));
|
|
142
|
+
return;
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
res.writeHead(200, {
|
|
146
|
+
"content-type": "text/event-stream",
|
|
147
|
+
"cache-control": "no-cache",
|
|
148
|
+
connection: "keep-alive",
|
|
149
|
+
});
|
|
150
|
+
// Sent now, not with the first frame: with nothing to catch up on, that is a
|
|
151
|
+
// heartbeat away, and the browser's request would hang open until then.
|
|
152
|
+
res.flushHeaders();
|
|
153
|
+
const aborter = new AbortController();
|
|
154
|
+
const { signal } = aborter;
|
|
155
|
+
res.on("close", () => aborter.abort());
|
|
156
|
+
|
|
157
|
+
// Resolves once the socket has taken what is queued, or the browser has gone.
|
|
158
|
+
const write = async (chunk: string) => {
|
|
159
|
+
if (res.write(chunk)) return;
|
|
160
|
+
await new Promise<void>((resolve) => {
|
|
161
|
+
const done = () => {
|
|
162
|
+
res.off("drain", done);
|
|
163
|
+
signal.removeEventListener("abort", done);
|
|
164
|
+
resolve();
|
|
165
|
+
};
|
|
166
|
+
res.on("drain", done);
|
|
167
|
+
signal.addEventListener("abort", done);
|
|
168
|
+
});
|
|
169
|
+
};
|
|
170
|
+
|
|
171
|
+
// A wake turn's events go out as they come, one frame each, and a stream
|
|
172
|
+
// the browser has left takes no more.
|
|
173
|
+
const emit: Emit = (event) => {
|
|
174
|
+
if (!signal.aborted && !res.writableEnded) res.write(`data: ${JSON.stringify(event)}\n\n`);
|
|
175
|
+
};
|
|
176
|
+
// Every stream this process has open for the conversation hears a wake
|
|
177
|
+
// turn, not only the one whose attempt won the lease: another tab shows the
|
|
178
|
+
// reply too, and holds its requests until the turn lets go.
|
|
179
|
+
const watchers = watchersOf(hai, conversationId);
|
|
180
|
+
watchers.streams.add(emit);
|
|
181
|
+
// Joining while a wake turn runs: what it has shown so far, starting with
|
|
182
|
+
// its `streaming` status, so the browser holds requests until it lets go,
|
|
183
|
+
// and the reply's later text has a block to land in.
|
|
184
|
+
if (watchers.live) for (const event of watchers.shown) emit(event);
|
|
185
|
+
|
|
186
|
+
// Wake turns run beside the notice loop, so a notice never waits behind a
|
|
187
|
+
// turn, nor behind the retries while another request holds the
|
|
188
|
+
// conversation. One attempt at a time; a wake notice that lands meanwhile
|
|
189
|
+
// asks for another once it is done.
|
|
190
|
+
let waking: Promise<void> | null = null;
|
|
191
|
+
let again = false;
|
|
192
|
+
const wake = () => {
|
|
193
|
+
if (!hai.wakes || signal.aborted) return;
|
|
194
|
+
if (waking) {
|
|
195
|
+
again = true;
|
|
196
|
+
return;
|
|
197
|
+
}
|
|
198
|
+
waking = (async () => {
|
|
199
|
+
do {
|
|
200
|
+
again = false;
|
|
201
|
+
let wait = WAKE_RETRY_MS;
|
|
202
|
+
while (!signal.aborted && (await hai.wake(conversationId, watchers.broadcast)) === "busy") {
|
|
203
|
+
await pause(wait, signal);
|
|
204
|
+
wait = Math.min(wait * 2, WAKE_RETRY_MAX_MS);
|
|
205
|
+
}
|
|
206
|
+
} while (again && !signal.aborted);
|
|
207
|
+
})()
|
|
208
|
+
.catch((err) => emit({ type: "error", message: (err as Error).message }))
|
|
209
|
+
.finally(() => {
|
|
210
|
+
waking = null;
|
|
211
|
+
// a notice that asked between the loop's last look and now
|
|
212
|
+
if (again) wake();
|
|
213
|
+
});
|
|
214
|
+
};
|
|
215
|
+
|
|
216
|
+
// Everything after `after`, a page at a time, until a page comes back short.
|
|
217
|
+
// A conversation deleted since the stream opened ends it: there is nothing
|
|
218
|
+
// more to wait for, and the browser's reconnect is then refused.
|
|
219
|
+
const catchUp = async (page: NoticeRecord[] | null = null) => {
|
|
220
|
+
let woken = false;
|
|
221
|
+
for (;;) {
|
|
222
|
+
const records = page ?? (await store.getNotices(conversationId, after, PAGE));
|
|
223
|
+
if (records === null) return aborter.abort();
|
|
224
|
+
for (const n of records) {
|
|
225
|
+
if (signal.aborted) return;
|
|
226
|
+
if (n.seq <= after) continue;
|
|
227
|
+
const event: WireEvent = {
|
|
228
|
+
type: "notice",
|
|
229
|
+
seq: n.seq,
|
|
230
|
+
name: n.name,
|
|
231
|
+
version: n.version,
|
|
232
|
+
payload: n.payload,
|
|
233
|
+
...(n.handle === undefined ? {} : { handle: n.handle }),
|
|
234
|
+
};
|
|
235
|
+
await write(`id: ${n.seq}\ndata: ${JSON.stringify(event)}\n\n`);
|
|
236
|
+
after = n.seq;
|
|
237
|
+
woken ||= n.kind === "wake";
|
|
238
|
+
}
|
|
239
|
+
if (records.length < PAGE || signal.aborted) break;
|
|
240
|
+
page = null;
|
|
241
|
+
}
|
|
242
|
+
if (woken) wake();
|
|
243
|
+
};
|
|
244
|
+
|
|
245
|
+
// a heartbeat is only for a stream gone quiet, never one already backed up
|
|
246
|
+
const heartbeat = setInterval(() => res.writableNeedDrain || res.write(": ping\n\n"), HEARTBEAT_MS);
|
|
247
|
+
const wakes = wakeups(store.watch?.(conversationId, signal), signal)[Symbol.asyncIterator]();
|
|
248
|
+
try {
|
|
249
|
+
await catchUp(first);
|
|
250
|
+
// A wake notice may be waiting from before this browser connected: one
|
|
251
|
+
// that arrived while nobody watched, or while the conversation was busy.
|
|
252
|
+
wake();
|
|
253
|
+
// Watching starts before the next read, so a notice that landed after the
|
|
254
|
+
// backlog was read is either in that read or wakes the loop.
|
|
255
|
+
let next = wakes.next();
|
|
256
|
+
await catchUp();
|
|
257
|
+
while (!signal.aborted) {
|
|
258
|
+
if ((await next).done) break;
|
|
259
|
+
next = wakes.next();
|
|
260
|
+
await catchUp();
|
|
261
|
+
}
|
|
262
|
+
} finally {
|
|
263
|
+
clearInterval(heartbeat);
|
|
264
|
+
aborter.abort();
|
|
265
|
+
watchers.streams.delete(emit);
|
|
266
|
+
if (!watchers.streams.size) forgetWatchers(hai, conversationId, watchers);
|
|
267
|
+
await wakes.return?.();
|
|
268
|
+
res.end();
|
|
269
|
+
}
|
|
270
|
+
}
|
|
271
|
+
|
|
272
|
+
/** The events streams one process has open for a conversation, and the wake turn running there, if any. */
|
|
273
|
+
interface Watchers {
|
|
274
|
+
streams: Set<Emit>;
|
|
275
|
+
live: boolean;
|
|
276
|
+
/**
|
|
277
|
+
* What the running wake turn has shown so far, as the frames a stream that
|
|
278
|
+
* joins now needs: each block once, with its text and status up to date,
|
|
279
|
+
* then everything else in order. A turn's worth, dropped when it ends.
|
|
280
|
+
*/
|
|
281
|
+
shown: WireEvent[];
|
|
282
|
+
broadcast: Emit;
|
|
283
|
+
}
|
|
284
|
+
|
|
285
|
+
/** Fold a wake turn's frame into what it has shown so far. */
|
|
286
|
+
function remember(shown: WireEvent[], event: WireEvent) {
|
|
287
|
+
const block = (id: string) =>
|
|
288
|
+
shown.find((e): e is Extract<WireEvent, { type: "block_start" }> => e.type === "block_start" && e.block.id === id)?.block;
|
|
289
|
+
if (event.type === "block_start") shown.push({ ...event, block: { ...event.block } });
|
|
290
|
+
else if (event.type === "text_delta") {
|
|
291
|
+
const b = block(event.id);
|
|
292
|
+
if (b && "text" in b) b.text += event.text;
|
|
293
|
+
} else if (event.type === "block_update") {
|
|
294
|
+
const b = block(event.id);
|
|
295
|
+
if (b) Object.assign(b, { status: event.status, ms: event.ms, result: event.result });
|
|
296
|
+
} else if (event.type === "progress") {
|
|
297
|
+
const { type, toolId, ...fields } = event;
|
|
298
|
+
const b = block(toolId);
|
|
299
|
+
if (b?.kind === "tool") b.progress = { ...b.progress, ...fields };
|
|
300
|
+
} else shown.push(event);
|
|
301
|
+
}
|
|
302
|
+
|
|
303
|
+
const watching = new WeakMap<Hai, Map<string, Watchers>>();
|
|
304
|
+
|
|
305
|
+
function watchersOf(hai: Hai, conversationId: string): Watchers {
|
|
306
|
+
let byConversation = watching.get(hai);
|
|
307
|
+
if (!byConversation) watching.set(hai, (byConversation = new Map()));
|
|
308
|
+
const map = byConversation;
|
|
309
|
+
let watchers = byConversation.get(conversationId);
|
|
310
|
+
if (!watchers) {
|
|
311
|
+
const fresh: Watchers = {
|
|
312
|
+
streams: new Set(),
|
|
313
|
+
live: false,
|
|
314
|
+
shown: [],
|
|
315
|
+
// A wake turn's frames, to every stream. `live` from its first
|
|
316
|
+
// `streaming` until `released`, with what it has shown kept meanwhile,
|
|
317
|
+
// for a stream that joins part way.
|
|
318
|
+
broadcast: (event) => {
|
|
319
|
+
if (event.type === "status" && event.status === "streaming" && !fresh.live) {
|
|
320
|
+
fresh.live = true;
|
|
321
|
+
fresh.shown = [];
|
|
322
|
+
}
|
|
323
|
+
if (fresh.live) remember(fresh.shown, event);
|
|
324
|
+
if (event.type === "released") {
|
|
325
|
+
fresh.live = false;
|
|
326
|
+
fresh.shown = [];
|
|
327
|
+
}
|
|
328
|
+
for (const send of fresh.streams) send(event);
|
|
329
|
+
// the turn outlived every stream that watched it: nothing left to keep
|
|
330
|
+
if (!fresh.live && !fresh.streams.size && map.get(conversationId) === fresh) map.delete(conversationId);
|
|
331
|
+
},
|
|
332
|
+
};
|
|
333
|
+
byConversation.set(conversationId, (watchers = fresh));
|
|
334
|
+
}
|
|
335
|
+
return watchers;
|
|
336
|
+
}
|
|
337
|
+
|
|
338
|
+
function forgetWatchers(hai: Hai, conversationId: string, watchers: Watchers) {
|
|
339
|
+
// a wake turn still running keeps its entry, so a stream that joins later still hears it
|
|
340
|
+
if (!watchers.live && watching.get(hai)?.get(conversationId) === watchers) watching.get(hai)!.delete(conversationId);
|
|
341
|
+
}
|
|
342
|
+
|
|
343
|
+
/** The first wait before trying a wake turn again on a busy conversation, and the longest. */
|
|
344
|
+
const WAKE_RETRY_MS = 250;
|
|
345
|
+
const WAKE_RETRY_MAX_MS = 5_000;
|
|
346
|
+
|
|
347
|
+
/** Wait `ms`, or until `signal` aborts. */
|
|
348
|
+
const pause = (ms: number, signal: AbortSignal) =>
|
|
349
|
+
new Promise<void>((resolve) => {
|
|
350
|
+
const done = () => {
|
|
351
|
+
clearTimeout(timer);
|
|
352
|
+
signal.removeEventListener("abort", done);
|
|
353
|
+
resolve();
|
|
354
|
+
};
|
|
355
|
+
const timer = setTimeout(done, ms);
|
|
356
|
+
signal.addEventListener("abort", done);
|
|
357
|
+
});
|
|
358
|
+
|
|
359
|
+
/**
|
|
360
|
+
* When the events route reads next: every POLL_MS, and sooner whenever the
|
|
361
|
+
* store's `watch` says a notice may have landed. `watch` alone is not enough.
|
|
362
|
+
* A wake-up can be lost, and a LISTEN connection can drop, and either would
|
|
363
|
+
* leave the stream open on heartbeats with a notice it never reads. A `watch`
|
|
364
|
+
* that ends or fails leaves the reads every POLL_MS to carry on.
|
|
365
|
+
*/
|
|
366
|
+
async function* wakeups(watch: AsyncIterable<void> | undefined, signal: AbortSignal): AsyncIterable<void> {
|
|
367
|
+
const steps = watch?.[Symbol.asyncIterator]();
|
|
368
|
+
// started now, so the watch is listening before the caller's next read
|
|
369
|
+
let step: Promise<unknown> | null = steps?.next().then((r) => !r.done, () => false) ?? null;
|
|
370
|
+
try {
|
|
371
|
+
while (!signal.aborted) {
|
|
372
|
+
let timer: ReturnType<typeof setTimeout> | undefined;
|
|
373
|
+
let onAbort: (() => void) | undefined;
|
|
374
|
+
const tick = new Promise<"tick">((resolve) => {
|
|
375
|
+
onAbort = () => resolve("tick");
|
|
376
|
+
timer = setTimeout(onAbort, POLL_MS);
|
|
377
|
+
signal.addEventListener("abort", onAbort);
|
|
378
|
+
});
|
|
379
|
+
const woke = await Promise.race(step ? [tick, step] : [tick]);
|
|
380
|
+
clearTimeout(timer);
|
|
381
|
+
signal.removeEventListener("abort", onAbort!);
|
|
382
|
+
if (signal.aborted) return;
|
|
383
|
+
// the watch stepped: listen for the next; it ended or failed: stop listening
|
|
384
|
+
if (woke !== "tick") step = woke ? steps!.next().then((r) => !r.done, () => false) : null;
|
|
385
|
+
yield;
|
|
386
|
+
}
|
|
387
|
+
} finally {
|
|
388
|
+
await steps?.return?.();
|
|
389
|
+
}
|
|
390
|
+
}
|
|
391
|
+
|
|
94
392
|
function openSSE(res: ServerResponse): Emit {
|
|
95
393
|
res.writeHead(200, {
|
|
96
394
|
"content-type": "text/event-stream",
|