@haikit/server 0.20.0 → 0.20.2
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/dist/routes.d.ts +2 -1
- package/dist/routes.d.ts.map +1 -1
- package/dist/routes.js +159 -74
- package/dist/routes.js.map +1 -1
- package/dist/runtime.d.ts +7 -0
- package/dist/runtime.d.ts.map +1 -1
- package/dist/runtime.js +8 -1
- package/dist/runtime.js.map +1 -1
- package/dist/store.d.ts.map +1 -1
- package/dist/store.js +24 -1
- package/dist/store.js.map +1 -1
- package/package.json +2 -2
- package/src/routes.ts +168 -75
- package/src/runtime.ts +8 -1
- package/src/store.ts +24 -1
package/src/routes.ts
CHANGED
|
@@ -1,11 +1,12 @@
|
|
|
1
1
|
import type { IncomingMessage, ServerResponse } from "node:http";
|
|
2
2
|
import { isConversationBusy, isStaleLease } from "@haikit/core";
|
|
3
3
|
import type { Emit, NoticeRecord, WireEvent } from "@haikit/core";
|
|
4
|
-
import type { Hai } from "./runtime.js";
|
|
4
|
+
import type { Hai, WakeOutcome } from "./runtime.js";
|
|
5
5
|
|
|
6
6
|
/**
|
|
7
7
|
* Node http handler for the three routes, and a fourth when the app sends
|
|
8
|
-
* notices. Returns true if it handled the request.
|
|
8
|
+
* notices. Returns true if it handled the request. It never rejects: a request
|
|
9
|
+
* that fails is answered, ended and logged (see `failed`).
|
|
9
10
|
*
|
|
10
11
|
* The three POST routes stream SSE: chat and interact because both can resume
|
|
11
12
|
* the agent loop, start because the init tool it runs can render surfaces.
|
|
@@ -18,92 +19,139 @@ import type { Hai } from "./runtime.js";
|
|
|
18
19
|
*/
|
|
19
20
|
export function nodeHandler(hai: Hai, basePath = "/hai") {
|
|
20
21
|
return async function handle(req: IncomingMessage, res: ServerResponse): Promise<boolean> {
|
|
21
|
-
|
|
22
|
+
let url: URL;
|
|
23
|
+
try {
|
|
24
|
+
url = new URL(req.url ?? "/", "http://localhost");
|
|
25
|
+
} catch {
|
|
26
|
+
// Node accepts targets a URL can't parse, such as `//[`. None is under
|
|
27
|
+
// a base path, which a URL always parses, so it's not ours: the app's.
|
|
28
|
+
return false;
|
|
29
|
+
}
|
|
22
30
|
if (!url.pathname.startsWith(basePath)) return false;
|
|
23
31
|
const route = url.pathname.slice(basePath.length);
|
|
24
32
|
|
|
25
33
|
if (req.method === "GET" && route === "/events" && hai.streamsEvents) {
|
|
26
|
-
|
|
34
|
+
try {
|
|
35
|
+
await streamNotices(hai, req, res, url);
|
|
36
|
+
} catch (err) {
|
|
37
|
+
failed(res, err);
|
|
38
|
+
}
|
|
27
39
|
return true;
|
|
28
40
|
}
|
|
29
41
|
|
|
30
42
|
if (req.method !== "POST") return false;
|
|
31
43
|
if (route !== "/chat" && route !== "/interact" && route !== "/start") return false;
|
|
32
44
|
|
|
33
|
-
const body = await readJson(req);
|
|
34
|
-
|
|
35
|
-
// Nothing to run before the first message: answer with an empty stream and
|
|
36
|
-
// create no conversation, so an app without init doesn't get one for every
|
|
37
|
-
// page view. Its first message starts the conversation, as it always has.
|
|
38
|
-
if (route === "/start" && !hai.config.init) {
|
|
39
|
-
openSSE(res);
|
|
40
|
-
res.end();
|
|
41
|
-
return true;
|
|
42
|
-
}
|
|
43
|
-
|
|
44
|
-
// Before the SSE stream opens, because a rejected load has no stream to
|
|
45
|
-
// report into — and an uncaught throw here would take down the request.
|
|
46
|
-
let conversation;
|
|
47
45
|
try {
|
|
48
|
-
|
|
46
|
+
await respond(hai, route, req, res);
|
|
49
47
|
} catch (err) {
|
|
50
|
-
|
|
51
|
-
res.writeHead(409, { "content-type": "application/json" });
|
|
52
|
-
res.end(JSON.stringify({ error: (err as Error).message }));
|
|
53
|
-
return true;
|
|
48
|
+
failed(res, err);
|
|
54
49
|
}
|
|
50
|
+
return true;
|
|
51
|
+
};
|
|
52
|
+
}
|
|
55
53
|
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
54
|
+
/**
|
|
55
|
+
* A request that failed in a way the route has no event for, such as a store
|
|
56
|
+
* error: the browser is told, the response ends, and the error is logged. It
|
|
57
|
+
* is never thrown on to the server, where an unhandled rejection would take
|
|
58
|
+
* the process down and leave the response open.
|
|
59
|
+
*/
|
|
60
|
+
function failed(res: ServerResponse, err: unknown) {
|
|
61
|
+
console.error("[haikit] request failed:", err);
|
|
62
|
+
const message = err instanceof Error ? err.message : String(err);
|
|
63
|
+
if (!res.headersSent) {
|
|
64
|
+
res.writeHead(500, { "content-type": "application/json" });
|
|
65
|
+
res.end(JSON.stringify({ error: message }));
|
|
66
|
+
} else if (!res.writableEnded) {
|
|
67
|
+
res.write(`data: ${JSON.stringify({ type: "error", message } satisfies WireEvent)}\n\n`);
|
|
68
|
+
res.end();
|
|
69
|
+
}
|
|
70
|
+
}
|
|
65
71
|
|
|
66
|
-
|
|
72
|
+
/** One of the three POST routes, once the request is known to be one. */
|
|
73
|
+
async function respond(hai: Hai, route: string, req: IncomingMessage, res: ServerResponse) {
|
|
74
|
+
let body: Record<string, any>;
|
|
75
|
+
try {
|
|
76
|
+
body = await readJson(req);
|
|
77
|
+
} catch {
|
|
78
|
+
res.writeHead(400, { "content-type": "application/json" });
|
|
79
|
+
res.end(JSON.stringify({ error: "the request body must be a JSON object" }));
|
|
80
|
+
return;
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
// Nothing to run before the first message: answer with an empty stream and
|
|
84
|
+
// create no conversation, so an app without init doesn't get one for every
|
|
85
|
+
// page view. Its first message starts the conversation, as it always has.
|
|
86
|
+
if (route === "/start" && !hai.config.init) {
|
|
87
|
+
openSSE(res);
|
|
88
|
+
res.end();
|
|
89
|
+
return;
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
// Before the SSE stream opens, because a rejected load has no stream to
|
|
93
|
+
// report into: busy is a 409, and anything else a 500 (see `failed`).
|
|
94
|
+
let conversation;
|
|
95
|
+
try {
|
|
96
|
+
conversation = await hai.config.store.loadConversation(body.conversationId);
|
|
97
|
+
} catch (err) {
|
|
98
|
+
if (!isConversationBusy(err)) throw err;
|
|
99
|
+
res.writeHead(409, { "content-type": "application/json" });
|
|
100
|
+
res.end(JSON.stringify({ error: (err as Error).message }));
|
|
101
|
+
return;
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
const emit = openSSE(res);
|
|
105
|
+
if (route === "/chat" || route === "/start") {
|
|
106
|
+
emit({
|
|
107
|
+
type: "hello",
|
|
108
|
+
conversationId: conversation.id,
|
|
109
|
+
model: hai.config.model.id,
|
|
110
|
+
...(hai.streamsEvents ? { events: true as const } : {}),
|
|
111
|
+
});
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
let superseded = false;
|
|
115
|
+
try {
|
|
116
|
+
if (route === "/chat") {
|
|
117
|
+
await hai.send(conversation, String(body.message ?? ""), emit);
|
|
118
|
+
} else if (route === "/start") {
|
|
119
|
+
await hai.start(conversation, emit);
|
|
120
|
+
} else {
|
|
121
|
+
await hai.interact(
|
|
122
|
+
conversation,
|
|
123
|
+
{ handle: body.handle, action: body.action, value: body.value },
|
|
124
|
+
emit,
|
|
125
|
+
);
|
|
126
|
+
}
|
|
127
|
+
} catch (err) {
|
|
128
|
+
if (isStaleLease(err)) superseded = true;
|
|
129
|
+
emit({ type: "error", message: (err as Error).message });
|
|
130
|
+
} finally {
|
|
131
|
+
// The lease lives exactly as long as the request. Releasing it any
|
|
132
|
+
// earlier — when a turn parks, say — lets the client's next interaction
|
|
133
|
+
// arrive before this request has finished writing, and the two turns
|
|
134
|
+
// interleave on one conversation.
|
|
135
|
+
//
|
|
136
|
+
// Only the expiry is cleared. The token has to survive so the save below
|
|
137
|
+
// can still prove this request is the rightful holder.
|
|
138
|
+
conversation.leaseUntil = null;
|
|
67
139
|
try {
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
await hai.start(conversation, emit);
|
|
72
|
-
} else {
|
|
73
|
-
await hai.interact(
|
|
74
|
-
conversation,
|
|
75
|
-
{ handle: body.handle, action: body.action, value: body.value },
|
|
76
|
-
emit,
|
|
77
|
-
);
|
|
78
|
-
}
|
|
140
|
+
// A turn that already hit a fence knows its save would be rejected too;
|
|
141
|
+
// attempting it only repeats the same error to the client.
|
|
142
|
+
if (!superseded) await hai.config.store.saveConversation(conversation);
|
|
79
143
|
} catch (err) {
|
|
80
|
-
|
|
144
|
+
// Overtaken while we were slow: another turn already wrote newer state
|
|
145
|
+
// under a fresh token. Dropping this write is the correct outcome, but
|
|
146
|
+
// the client asked for something it is not getting, so say so. Any
|
|
147
|
+
// other failure goes to `failed`, which says so too, ends the response
|
|
148
|
+
// and logs it: the conversation stays held until its lease lapses.
|
|
149
|
+
if (!isStaleLease(err)) throw err;
|
|
81
150
|
emit({ type: "error", message: (err as Error).message });
|
|
82
|
-
} finally {
|
|
83
|
-
// The lease lives exactly as long as the request. Releasing it any
|
|
84
|
-
// earlier — when a turn parks, say — lets the client's next interaction
|
|
85
|
-
// arrive before this request has finished writing, and the two turns
|
|
86
|
-
// interleave on one conversation.
|
|
87
|
-
//
|
|
88
|
-
// Only the expiry is cleared. The token has to survive so the save below
|
|
89
|
-
// can still prove this request is the rightful holder.
|
|
90
|
-
conversation.leaseUntil = null;
|
|
91
|
-
try {
|
|
92
|
-
// A turn that already hit a fence knows its save would be rejected too;
|
|
93
|
-
// attempting it only repeats the same error to the client.
|
|
94
|
-
if (!superseded) await hai.config.store.saveConversation(conversation);
|
|
95
|
-
} catch (err) {
|
|
96
|
-
// Overtaken while we were slow: another turn already wrote newer state
|
|
97
|
-
// under a fresh token. Dropping this write is the correct outcome, but
|
|
98
|
-
// the client asked for something it is not getting, so say so.
|
|
99
|
-
if (!isStaleLease(err)) throw err;
|
|
100
|
-
emit({ type: "error", message: (err as Error).message });
|
|
101
|
-
}
|
|
102
151
|
}
|
|
152
|
+
}
|
|
103
153
|
|
|
104
|
-
|
|
105
|
-
return true;
|
|
106
|
-
};
|
|
154
|
+
res.end();
|
|
107
155
|
}
|
|
108
156
|
|
|
109
157
|
/**
|
|
@@ -306,10 +354,18 @@ async function streamNotices(hai: Hai, req: IncomingMessage, res: ServerResponse
|
|
|
306
354
|
// turn, nor behind the retries while another request holds the
|
|
307
355
|
// conversation. One attempt at a time; a wake notice that lands meanwhile
|
|
308
356
|
// asks for another once it is done.
|
|
357
|
+
//
|
|
358
|
+
// An attempt takes the conversation's lease and saves it, even when it
|
|
359
|
+
// finds nothing to do, and a request that arrives meanwhile is turned away
|
|
360
|
+
// busy. So it is made only when there may be something new: a wake notice,
|
|
361
|
+
// read from the store by any stream here, past the last one an attempt here
|
|
362
|
+
// settled. A polling browser opens a stream several times a minute; each
|
|
363
|
+
// would otherwise take the lease.
|
|
309
364
|
let waking: Promise<void> | null = null;
|
|
310
365
|
let again = false;
|
|
311
366
|
const wake = () => {
|
|
312
367
|
if (!hai.wakes || signal.aborted) return;
|
|
368
|
+
if (watchers.wakeRead <= watchers.wakeSettled) return;
|
|
313
369
|
if (waking) {
|
|
314
370
|
again = true;
|
|
315
371
|
return;
|
|
@@ -317,12 +373,22 @@ async function streamNotices(hai: Hai, req: IncomingMessage, res: ServerResponse
|
|
|
317
373
|
waking = (async () => {
|
|
318
374
|
do {
|
|
319
375
|
again = false;
|
|
376
|
+
// every wake notice read here so far: what the attempt settles, unless it fails
|
|
377
|
+
const through = watchers.wakeRead;
|
|
320
378
|
let wait = WAKE_RETRY_MS;
|
|
321
|
-
|
|
379
|
+
let outcome: WakeOutcome = "busy";
|
|
380
|
+
while (!signal.aborted && (outcome = await hai.wake(conversationId, watchers.turn())) === "busy") {
|
|
322
381
|
await pause(wait, signal);
|
|
323
382
|
wait = Math.min(wait * 2, WAKE_RETRY_MAX_MS);
|
|
383
|
+
// Another stream here may have been the one holding it, with a turn
|
|
384
|
+
// that settled these notices: taking the lease again finds nothing.
|
|
385
|
+
if (through <= watchers.wakeSettled) break;
|
|
324
386
|
}
|
|
325
|
-
|
|
387
|
+
// A failed turn gives its notices back to wake again, so it settles
|
|
388
|
+
// nothing: the next stream to open tries once more.
|
|
389
|
+
if (outcome !== "busy" && outcome !== "failed") watchers.wakeSettled = Math.max(watchers.wakeSettled, through);
|
|
390
|
+
// asked again meanwhile: only if a notice came that this one didn't settle
|
|
391
|
+
} while (again && !signal.aborted && watchers.wakeRead > watchers.wakeSettled);
|
|
326
392
|
})()
|
|
327
393
|
.catch((err) => emit({ type: "error", message: (err as Error).message }))
|
|
328
394
|
.finally(() => {
|
|
@@ -374,7 +440,10 @@ async function streamNotices(hai: Hai, req: IncomingMessage, res: ServerResponse
|
|
|
374
440
|
await sendRevision(n.handle, n.replaces, n.seq);
|
|
375
441
|
after = n.seq;
|
|
376
442
|
markFresh();
|
|
377
|
-
|
|
443
|
+
if (n.kind === "wake") {
|
|
444
|
+
woken = true;
|
|
445
|
+
watchers.wakeRead = Math.max(watchers.wakeRead, n.seq);
|
|
446
|
+
}
|
|
378
447
|
continue;
|
|
379
448
|
}
|
|
380
449
|
const event: WireEvent = {
|
|
@@ -388,7 +457,10 @@ async function streamNotices(hai: Hai, req: IncomingMessage, res: ServerResponse
|
|
|
388
457
|
await write(`id: ${n.seq}\ndata: ${JSON.stringify(event)}\n\n`);
|
|
389
458
|
after = n.seq;
|
|
390
459
|
markFresh();
|
|
391
|
-
|
|
460
|
+
if (n.kind === "wake") {
|
|
461
|
+
woken = true;
|
|
462
|
+
watchers.wakeRead = Math.max(watchers.wakeRead, n.seq);
|
|
463
|
+
}
|
|
392
464
|
}
|
|
393
465
|
if (records.length < PAGE || signal.aborted) break;
|
|
394
466
|
page = null;
|
|
@@ -432,7 +504,8 @@ async function streamNotices(hai: Hai, req: IncomingMessage, res: ServerResponse
|
|
|
432
504
|
freshened.then(() => new Promise<void>((resolve) => setTimeout(resolve, POLL_GATHER_MS))),
|
|
433
505
|
new Promise<void>((resolve) => (hold = setTimeout(resolve, holdMs))),
|
|
434
506
|
]);
|
|
435
|
-
|
|
507
|
+
// a read that fails ends nothing early: the poll answers at its hold
|
|
508
|
+
void run().catch((err) => console.error("[haikit] events poll failed:", err));
|
|
436
509
|
await done;
|
|
437
510
|
clearTimeout(hold);
|
|
438
511
|
} finally {
|
|
@@ -486,6 +559,21 @@ interface Watchers {
|
|
|
486
559
|
leave: () => void;
|
|
487
560
|
/** Settles once this process is listening for other processes' turns. */
|
|
488
561
|
ready: Promise<void>;
|
|
562
|
+
/**
|
|
563
|
+
* The highest seq of a wake notice any stream here has read from the store,
|
|
564
|
+
* whether or not an attempt has settled it yet: a stream that resumes past
|
|
565
|
+
* it, reading nothing, still knows it's there. 0 until one is read.
|
|
566
|
+
*/
|
|
567
|
+
wakeRead: number;
|
|
568
|
+
/**
|
|
569
|
+
* The highest wake notice seq a wake attempt here has settled: it woke,
|
|
570
|
+
* found nothing to do, or was held back by `maxWakes`. A stream makes an
|
|
571
|
+
* attempt only while `wakeRead` is past it. -1 until the first, so the first
|
|
572
|
+
* stream this process opens for the conversation looks once, for an update
|
|
573
|
+
* notice a revision still owes. Both come from the store, never from what a
|
|
574
|
+
* browser says it has seen, since every stream here shares them.
|
|
575
|
+
*/
|
|
576
|
+
wakeSettled: number;
|
|
489
577
|
}
|
|
490
578
|
|
|
491
579
|
/** Fold a wake turn's frame into what it has shown so far. */
|
|
@@ -637,6 +725,8 @@ function watchersOf(hai: Hai, conversationId: string): Watchers {
|
|
|
637
725
|
lingering.unref?.();
|
|
638
726
|
},
|
|
639
727
|
ready: Promise.resolve(),
|
|
728
|
+
wakeRead: 0,
|
|
729
|
+
wakeSettled: -1,
|
|
640
730
|
// A wake turn running here: its frames to this process's streams, and,
|
|
641
731
|
// through the store, to every other process's. All but `context`, the
|
|
642
732
|
// whole history, which another tab gets with its own next request.
|
|
@@ -752,8 +842,11 @@ function openSSE(res: ServerResponse): Emit {
|
|
|
752
842
|
};
|
|
753
843
|
}
|
|
754
844
|
|
|
755
|
-
|
|
845
|
+
/** The request body, as a JSON object; anything else, or a body that fails to arrive, throws. */
|
|
846
|
+
async function readJson(req: IncomingMessage): Promise<Record<string, any>> {
|
|
756
847
|
const chunks: Buffer[] = [];
|
|
757
848
|
for await (const chunk of req) chunks.push(chunk as Buffer);
|
|
758
|
-
|
|
849
|
+
const body: unknown = JSON.parse(Buffer.concat(chunks).toString() || "{}");
|
|
850
|
+
if (!body || typeof body !== "object" || Array.isArray(body)) throw new TypeError("not a JSON object");
|
|
851
|
+
return body as Record<string, any>;
|
|
759
852
|
}
|
package/src/runtime.ts
CHANGED
|
@@ -201,6 +201,13 @@ export class Hai {
|
|
|
201
201
|
private readonly surfaceNames = new Map<string, Map<string, string>>();
|
|
202
202
|
/** What the model gets as its system prompt: the app's, then the scope. */
|
|
203
203
|
readonly system: string;
|
|
204
|
+
/**
|
|
205
|
+
* Block ids: this Hai's own random part, then a count. The browser matches
|
|
206
|
+
* blocks by id for a whole conversation, across POST responses and the
|
|
207
|
+
* events stream, which another server, or this one restarted, may have
|
|
208
|
+
* sent: a count alone would repeat there, and a new block overwrite an old.
|
|
209
|
+
*/
|
|
210
|
+
private readonly blockIds = globalThis.crypto.randomUUID().replaceAll("-", "");
|
|
204
211
|
private seq = 0;
|
|
205
212
|
|
|
206
213
|
constructor(config: HaiConfig) {
|
|
@@ -770,7 +777,7 @@ export class Hai {
|
|
|
770
777
|
}
|
|
771
778
|
|
|
772
779
|
private nid(prefix: string) {
|
|
773
|
-
return `${prefix}_${++this.seq}`;
|
|
780
|
+
return `${prefix}_${this.blockIds}_${++this.seq}`;
|
|
774
781
|
}
|
|
775
782
|
|
|
776
783
|
// ───────────────────────────────────────────────── derived tool
|
package/src/store.ts
CHANGED
|
@@ -9,6 +9,28 @@ import {
|
|
|
9
9
|
|
|
10
10
|
const LEASE_MS = 120_000;
|
|
11
11
|
|
|
12
|
+
/**
|
|
13
|
+
* Postgres refuses U+0000 anywhere in text or jsonb, and a lone UTF-16
|
|
14
|
+
* surrogate in jsonb, so pgStore makes them storable as it saves. This store
|
|
15
|
+
* does the same, so development sees what production would store. Each
|
|
16
|
+
* becomes U+FFFD, the replacement character, in strings and object keys
|
|
17
|
+
* alike.
|
|
18
|
+
*/
|
|
19
|
+
const UNSTORABLE = /\u0000|[\uD800-\uDBFF](?![\uDC00-\uDFFF])|(?<![\uD800-\uDBFF])[\uDC00-\uDFFF]/g;
|
|
20
|
+
const MAYBE_UNSTORABLE = /[\u0000\uD800-\uDFFF]/;
|
|
21
|
+
const storable = (s: string): string => (MAYBE_UNSTORABLE.test(s) ? s.replace(UNSTORABLE, "\uFFFD") : s);
|
|
22
|
+
/** A `JSON.stringify` replacer that makes every string and key storable. */
|
|
23
|
+
function storableJson(_key: string, value: unknown): unknown {
|
|
24
|
+
if (typeof value === "string") return storable(value);
|
|
25
|
+
if (value && typeof value === "object" && !Array.isArray(value)) {
|
|
26
|
+
const keys = Object.keys(value);
|
|
27
|
+
if (keys.some((k) => MAYBE_UNSTORABLE.test(k))) {
|
|
28
|
+
return Object.fromEntries(keys.map((k) => [storable(k), (value as Record<string, unknown>)[k]]));
|
|
29
|
+
}
|
|
30
|
+
}
|
|
31
|
+
return value;
|
|
32
|
+
}
|
|
33
|
+
|
|
12
34
|
export interface MemoryStoreOptions {
|
|
13
35
|
/**
|
|
14
36
|
* Turn lease TTL. A process that dies mid-turn strands the conversation for
|
|
@@ -60,7 +82,8 @@ export function memoryStore(options: MemoryStoreOptions = {}): StoreAdapter {
|
|
|
60
82
|
let convSeq = 0;
|
|
61
83
|
let handleSeq = 0;
|
|
62
84
|
|
|
63
|
-
|
|
85
|
+
// and anything Postgres can't store comes back as pgStore would return it
|
|
86
|
+
const copy = <T>(value: T): T => JSON.parse(JSON.stringify(value, storableJson)) as T;
|
|
64
87
|
const newToken = () => globalThis.crypto.randomUUID();
|
|
65
88
|
|
|
66
89
|
/** Every durable mutation presents the token it was issued. A holder that has
|