@vincemakes/kiso-server 0.41.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 +21 -0
- package/README.md +70 -0
- package/dist/errors.d.ts +25 -0
- package/dist/errors.js +38 -0
- package/dist/execution.d.ts +12 -0
- package/dist/execution.js +19 -0
- package/dist/http.d.ts +48 -0
- package/dist/http.js +249 -0
- package/dist/index.d.ts +16 -0
- package/dist/index.js +13 -0
- package/dist/service.d.ts +150 -0
- package/dist/service.js +263 -0
- package/dist/tail.d.ts +18 -0
- package/dist/tail.js +47 -0
- package/dist/wire.d.ts +29 -0
- package/dist/wire.js +40 -0
- package/package.json +47 -0
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 kiso contributors
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/README.md
ADDED
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
# @vincemakes/kiso-server
|
|
2
|
+
|
|
3
|
+
The hosted-session service: many durable kiso sessions in one long-lived
|
|
4
|
+
process, each with observers, one run at a time. It is the layer a product
|
|
5
|
+
backend puts between its HTTP (or socket, or stdio) handlers and the
|
|
6
|
+
runtime — the layer two products wrote independently before this package
|
|
7
|
+
existed.
|
|
8
|
+
|
|
9
|
+
It is not a transport, and it is not a second agent. Every session here is
|
|
10
|
+
`runtime.createAgent(...).session({ id })`; every run is that session's
|
|
11
|
+
`run()` / `resume()`. The product supplies the agent factory; the service
|
|
12
|
+
never learns what the agent is for.
|
|
13
|
+
|
|
14
|
+
```ts
|
|
15
|
+
import { createSessionService } from "@vincemakes/kiso-server";
|
|
16
|
+
import { createAgent, SessionStore } from "@vincemakes/kiso-runtime";
|
|
17
|
+
|
|
18
|
+
const store = new SessionStore("./sessions");
|
|
19
|
+
const service = createSessionService({
|
|
20
|
+
store,
|
|
21
|
+
open: async (sessionId) => createAgent({ /* the product's definition */ store, model, tools, extensions }),
|
|
22
|
+
});
|
|
23
|
+
|
|
24
|
+
const { runId, done } = await service.run("s1", "hello"); // InFlightError | OpenRunError | DrainingError
|
|
25
|
+
const off = await service.subscribe("s1", lastSeqSeen, (ev) => send(ev)); // replay, then live, exactly once
|
|
26
|
+
await service.approve("s1", decisionId, true); // → { needsResume }
|
|
27
|
+
await service.abort("s1"); // → idle | parked (who is waited for) | stopped
|
|
28
|
+
await service.drain(20_000); // → { waitedFor, parked, interrupted, timedOut }
|
|
29
|
+
await service.close(5_000); // abort what is live, wait up to the grace
|
|
30
|
+
```
|
|
31
|
+
|
|
32
|
+
## What it decides, and what it leaves to the host
|
|
33
|
+
|
|
34
|
+
- **One run per session.** A second `run()` while one is live refuses
|
|
35
|
+
with `InFlightError`.
|
|
36
|
+
- **An open run in the log** (a previous process died inside it) refuses
|
|
37
|
+
with `OpenRunError`; `run(input, { resumeFirst: true })` resumes it to its
|
|
38
|
+
terminal first. Silent resume is the one option not offered.
|
|
39
|
+
- **Abort while parked.** A run waiting for a person — a kernel approval,
|
|
40
|
+
or whatever `hooks.parkedBy` names (the ask extension's questions) —
|
|
41
|
+
is parked, not running. `abort()` returns `{ kind: "parked", ... }` and
|
|
42
|
+
does nothing; `{ force: true }` aborts anyway. "stopped" carries a
|
|
43
|
+
`settled` promise: the kernel does not kill a tool, so a tool that
|
|
44
|
+
ignores the abort keeps its run open until it returns.
|
|
45
|
+
- **After the last uncertain verdict** the service does not resume;
|
|
46
|
+
`resolveUncertain` returns `{ remaining }` and the host resumes at zero.
|
|
47
|
+
- **Executing tools** are counted from `tool_execution_started` and the
|
|
48
|
+
three ending events (succeeded, failed, resolved) — `EXECUTION_ENDED`,
|
|
49
|
+
stated once.
|
|
50
|
+
- **Product frames on the stream** (billing, estimates, couriers) are the
|
|
51
|
+
transport's business; the service delivers the durable events only.
|
|
52
|
+
|
|
53
|
+
## What a host still writes
|
|
54
|
+
|
|
55
|
+
The transport (routes, SSE framing, `Last-Event-ID`, keepalive, status
|
|
56
|
+
codes), the agent factory, and any per-session product state it feeds the
|
|
57
|
+
factory. The service is the part that was the same in every host.
|
|
58
|
+
|
|
59
|
+
## The HTTP + SSE transport — `@vincemakes/kiso-server/http`
|
|
60
|
+
|
|
61
|
+
`createHttpHandler(service, { authorize, augment?, prepareInput?, projection?, keepaliveMs? })`
|
|
62
|
+
returns a `handle(req, res)` a host mounts in front of its own routes; it
|
|
63
|
+
answers the agent routes under `prefix` (default `/v1/sessions`) and
|
|
64
|
+
returns false for everything else. Status codes and framing are decided
|
|
65
|
+
here once: `id: <seq>` / `event: <type>` / `data: <WireEvent>`, an `: open`
|
|
66
|
+
preamble, `Last-Event-ID` and `?after`, a keepalive; `in_flight` and
|
|
67
|
+
`open_run` are 409, `draining` 503, `forbidden` 403. Wire events are the
|
|
68
|
+
projection in `@vincemakes/kiso-protocol` — never the durable event.
|
|
69
|
+
`authorize` is required: there is no default that allows.
|
|
70
|
+
|
package/dist/errors.d.ts
ADDED
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The service's typed refusals. A transport maps each to its own status
|
|
3
|
+
* (409 / 409 / 503 in the two products that wrote these by hand); the
|
|
4
|
+
* service itself knows no status code.
|
|
5
|
+
*/
|
|
6
|
+
/** A run is already in flight for this session — one run per session. */
|
|
7
|
+
export declare class InFlightError extends Error {
|
|
8
|
+
readonly sessionId: string;
|
|
9
|
+
readonly runId: string;
|
|
10
|
+
constructor(sessionId: string, runId: string);
|
|
11
|
+
}
|
|
12
|
+
/** The log holds a run that never reached its terminal — a previous
|
|
13
|
+
* process died inside it. It must be resumed (recovery drives ONE run to
|
|
14
|
+
* its terminal) before a new turn starts; `run(..., { resumeFirst: true })`
|
|
15
|
+
* does that in one call. The one thing the service never does is resume
|
|
16
|
+
* silently. */
|
|
17
|
+
export declare class OpenRunError extends Error {
|
|
18
|
+
readonly sessionId: string;
|
|
19
|
+
readonly runId: string;
|
|
20
|
+
constructor(sessionId: string, runId: string);
|
|
21
|
+
}
|
|
22
|
+
/** drain() was called: no new run or resume starts in this process. */
|
|
23
|
+
export declare class DrainingError extends Error {
|
|
24
|
+
constructor();
|
|
25
|
+
}
|
package/dist/errors.js
ADDED
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The service's typed refusals. A transport maps each to its own status
|
|
3
|
+
* (409 / 409 / 503 in the two products that wrote these by hand); the
|
|
4
|
+
* service itself knows no status code.
|
|
5
|
+
*/
|
|
6
|
+
/** A run is already in flight for this session — one run per session. */
|
|
7
|
+
export class InFlightError extends Error {
|
|
8
|
+
sessionId;
|
|
9
|
+
runId;
|
|
10
|
+
constructor(sessionId, runId) {
|
|
11
|
+
super(`session ${sessionId} already has a run in flight (${runId})`);
|
|
12
|
+
this.name = "InFlightError";
|
|
13
|
+
this.sessionId = sessionId;
|
|
14
|
+
this.runId = runId;
|
|
15
|
+
}
|
|
16
|
+
}
|
|
17
|
+
/** The log holds a run that never reached its terminal — a previous
|
|
18
|
+
* process died inside it. It must be resumed (recovery drives ONE run to
|
|
19
|
+
* its terminal) before a new turn starts; `run(..., { resumeFirst: true })`
|
|
20
|
+
* does that in one call. The one thing the service never does is resume
|
|
21
|
+
* silently. */
|
|
22
|
+
export class OpenRunError extends Error {
|
|
23
|
+
sessionId;
|
|
24
|
+
runId;
|
|
25
|
+
constructor(sessionId, runId) {
|
|
26
|
+
super(`session ${sessionId} still has an open run (${runId}) — resume it before starting a new turn`);
|
|
27
|
+
this.name = "OpenRunError";
|
|
28
|
+
this.sessionId = sessionId;
|
|
29
|
+
this.runId = runId;
|
|
30
|
+
}
|
|
31
|
+
}
|
|
32
|
+
/** drain() was called: no new run or resume starts in this process. */
|
|
33
|
+
export class DrainingError extends Error {
|
|
34
|
+
constructor() {
|
|
35
|
+
super("the service is draining — no new run starts in this process");
|
|
36
|
+
this.name = "DrainingError";
|
|
37
|
+
}
|
|
38
|
+
}
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
import type { Event } from "@vincemakes/kiso-core";
|
|
2
|
+
/**
|
|
3
|
+
* The events that end a tool execution, stated ONCE.
|
|
4
|
+
*
|
|
5
|
+
* The two products that counted executing tools by hand disagreed on this
|
|
6
|
+
* set: one stopped at succeeded / failed, the other also counted resolved.
|
|
7
|
+
* Resolved belongs here — an uncertain execution that received its verdict
|
|
8
|
+
* is no longer executing, whichever verdict it got.
|
|
9
|
+
*/
|
|
10
|
+
export declare const EXECUTION_ENDED: ReadonlySet<Event["type"]>;
|
|
11
|
+
/** +1 when a tool starts executing, −1 when one ends, 0 otherwise. */
|
|
12
|
+
export declare function executionDelta(event: Event): -1 | 0 | 1;
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The events that end a tool execution, stated ONCE.
|
|
3
|
+
*
|
|
4
|
+
* The two products that counted executing tools by hand disagreed on this
|
|
5
|
+
* set: one stopped at succeeded / failed, the other also counted resolved.
|
|
6
|
+
* Resolved belongs here — an uncertain execution that received its verdict
|
|
7
|
+
* is no longer executing, whichever verdict it got.
|
|
8
|
+
*/
|
|
9
|
+
export const EXECUTION_ENDED = new Set([
|
|
10
|
+
"tool_execution_succeeded",
|
|
11
|
+
"tool_execution_failed",
|
|
12
|
+
"tool_execution_resolved",
|
|
13
|
+
]);
|
|
14
|
+
/** +1 when a tool starts executing, −1 when one ends, 0 otherwise. */
|
|
15
|
+
export function executionDelta(event) {
|
|
16
|
+
if (event.type === "tool_execution_started")
|
|
17
|
+
return 1;
|
|
18
|
+
return EXECUTION_ENDED.has(event.type) ? -1 : 0;
|
|
19
|
+
}
|
package/dist/http.d.ts
ADDED
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
import type { IncomingMessage, ServerResponse } from "node:http";
|
|
2
|
+
import type { Event } from "@vincemakes/kiso-core";
|
|
3
|
+
import type { WireFrame, WireInput } from "@vincemakes/kiso-protocol";
|
|
4
|
+
import type { SessionService } from "./service.js";
|
|
5
|
+
import { type ProjectionOptions } from "./wire.js";
|
|
6
|
+
/**
|
|
7
|
+
* The HTTP + SSE transport over the hosted-session service.
|
|
8
|
+
*
|
|
9
|
+
* A host mounts `handle` in front of its own routes: it answers the agent
|
|
10
|
+
* routes under `prefix` and returns false for everything else. Status codes
|
|
11
|
+
* and framing are decided here, once — the two hosting products had each
|
|
12
|
+
* chosen their own (409 for in-flight in one, an exception in the other;
|
|
13
|
+
* `event:` on every frame in one, none in the other; a keepalive in one).
|
|
14
|
+
*
|
|
15
|
+
* GET {prefix}/:id/events the stream, from ?after or Last-Event-ID
|
|
16
|
+
* GET {prefix}/:id the session snapshot (also /state)
|
|
17
|
+
* GET {prefix}/:id/replay every wire event on the log + the snapshot
|
|
18
|
+
* POST {prefix}/:id/run 202 { runId } — or the run's own stream with ?stream=1
|
|
19
|
+
* POST {prefix}/:id/resume 202 { runId } — or the stream with ?stream=1
|
|
20
|
+
* POST {prefix}/:id/abort 200 idle | stopped — 409 parked (who is waited for)
|
|
21
|
+
* POST {prefix}/:id/approve 200 { needsResume }
|
|
22
|
+
* POST {prefix}/:id/uncertain 200 { remaining }
|
|
23
|
+
*
|
|
24
|
+
* What the host supplies: `authorize` (REQUIRED — session ownership; there
|
|
25
|
+
* is no default that allows), and optionally `augment` (frames beside a
|
|
26
|
+
* wire event: billing, estimates), `prepareInput` (the product's turn
|
|
27
|
+
* preparation), the projection options and the keepalive period.
|
|
28
|
+
*/
|
|
29
|
+
export interface HttpHandlerOptions {
|
|
30
|
+
/** The mount point; the session id and the action follow it. Default `/v1/sessions`. */
|
|
31
|
+
readonly prefix?: string;
|
|
32
|
+
/** Session ownership, per request. False → 403, and nothing is opened. */
|
|
33
|
+
readonly authorize: (req: IncomingMessage, sessionId: string) => Promise<boolean> | boolean;
|
|
34
|
+
/** Frames a product adds beside a wire event on the same connection. */
|
|
35
|
+
readonly augment?: (event: Event, sessionId: string) => readonly WireFrame[] | Promise<readonly WireFrame[]>;
|
|
36
|
+
/** The product's turn preparation; default: `body.input` as given. */
|
|
37
|
+
readonly prepareInput?: (body: Readonly<Record<string, unknown>>, req: IncomingMessage, sessionId: string) => WireInput | Promise<WireInput>;
|
|
38
|
+
readonly projection?: ProjectionOptions;
|
|
39
|
+
/** `: keepalive` comments on an idle stream. Default 15 s; 0 disables. */
|
|
40
|
+
readonly keepaliveMs?: number;
|
|
41
|
+
/** Request-body cap in bytes. Default 1 MiB. */
|
|
42
|
+
readonly bodyLimit?: number;
|
|
43
|
+
}
|
|
44
|
+
export interface HttpHandler {
|
|
45
|
+
/** True when the request was an agent route and has been answered. */
|
|
46
|
+
readonly handle: (req: IncomingMessage, res: ServerResponse) => Promise<boolean>;
|
|
47
|
+
}
|
|
48
|
+
export declare function createHttpHandler(service: SessionService, options: HttpHandlerOptions): HttpHandler;
|
package/dist/http.js
ADDED
|
@@ -0,0 +1,249 @@
|
|
|
1
|
+
import { DrainingError, InFlightError, OpenRunError } from "./errors.js";
|
|
2
|
+
import { toWireEvent } from "./wire.js";
|
|
3
|
+
const SESSION_ID = /^[A-Za-z0-9][A-Za-z0-9._-]{0,127}$/;
|
|
4
|
+
const ACTIONS = new Set(["", "state", "events", "replay", "run", "resume", "abort", "approve", "uncertain"]);
|
|
5
|
+
export function createHttpHandler(service, options) {
|
|
6
|
+
const prefix = (options.prefix ?? "/v1/sessions").replace(/\/+$/, "");
|
|
7
|
+
const keepaliveMs = options.keepaliveMs ?? 15_000;
|
|
8
|
+
const bodyLimit = options.bodyLimit ?? 1024 * 1024;
|
|
9
|
+
const projection = options.projection ?? {};
|
|
10
|
+
const json = (res, status, body) => {
|
|
11
|
+
res.writeHead(status, { "content-type": "application/json; charset=utf-8" });
|
|
12
|
+
res.end(JSON.stringify(body));
|
|
13
|
+
};
|
|
14
|
+
const fail = (res, status, code, message, runId) => {
|
|
15
|
+
const error = { code, message, ...(runId !== undefined ? { runId } : {}) };
|
|
16
|
+
json(res, status, error);
|
|
17
|
+
};
|
|
18
|
+
/** The service's refusals → the wire's codes. */
|
|
19
|
+
const refuse = (res, err) => {
|
|
20
|
+
if (err instanceof InFlightError)
|
|
21
|
+
return fail(res, 409, "in_flight", err.message, err.runId);
|
|
22
|
+
if (err instanceof OpenRunError)
|
|
23
|
+
return fail(res, 409, "open_run", err.message, err.runId);
|
|
24
|
+
if (err instanceof DrainingError)
|
|
25
|
+
return fail(res, 503, "draining", err.message);
|
|
26
|
+
if (err instanceof BadRequest)
|
|
27
|
+
return fail(res, 400, "bad_request", err.message);
|
|
28
|
+
fail(res, 500, "internal", err instanceof Error ? err.message : String(err));
|
|
29
|
+
};
|
|
30
|
+
const readBody = async (req) => {
|
|
31
|
+
const chunks = [];
|
|
32
|
+
let size = 0;
|
|
33
|
+
for await (const chunk of req) {
|
|
34
|
+
const buf = chunk;
|
|
35
|
+
size += buf.length;
|
|
36
|
+
if (size > bodyLimit)
|
|
37
|
+
throw new BadRequest(`body exceeds ${bodyLimit} bytes`);
|
|
38
|
+
chunks.push(buf);
|
|
39
|
+
}
|
|
40
|
+
if (chunks.length === 0)
|
|
41
|
+
return {};
|
|
42
|
+
let parsed;
|
|
43
|
+
try {
|
|
44
|
+
parsed = JSON.parse(Buffer.concat(chunks).toString("utf8"));
|
|
45
|
+
}
|
|
46
|
+
catch {
|
|
47
|
+
throw new BadRequest("body is not JSON");
|
|
48
|
+
}
|
|
49
|
+
if (typeof parsed !== "object" || parsed === null || Array.isArray(parsed))
|
|
50
|
+
throw new BadRequest("body must be a JSON object");
|
|
51
|
+
return parsed;
|
|
52
|
+
};
|
|
53
|
+
const inputOf = async (body, req, sessionId) => {
|
|
54
|
+
if (options.prepareInput !== undefined)
|
|
55
|
+
return options.prepareInput(body, req, sessionId);
|
|
56
|
+
const input = body["input"];
|
|
57
|
+
if (typeof input === "string") {
|
|
58
|
+
if (input.trim() === "")
|
|
59
|
+
throw new BadRequest("input must be a non-empty string");
|
|
60
|
+
return input;
|
|
61
|
+
}
|
|
62
|
+
if (Array.isArray(input) && input.length > 0)
|
|
63
|
+
return input;
|
|
64
|
+
throw new BadRequest("input must be a non-empty string or a non-empty array of content blocks");
|
|
65
|
+
};
|
|
66
|
+
const state = async (sessionId) => {
|
|
67
|
+
const events = service.events(sessionId);
|
|
68
|
+
return {
|
|
69
|
+
sessionId,
|
|
70
|
+
running: service.isRunning(sessionId),
|
|
71
|
+
highWater: events.at(-1)?.seq ?? -1,
|
|
72
|
+
openRun: service.openRun(sessionId),
|
|
73
|
+
pendingApprovals: await service.pendingApprovals(sessionId),
|
|
74
|
+
uncertain: (await service.uncertainExecutions(sessionId)).map((u) => ({ executionId: u.executionId, callId: u.callId, name: u.name })),
|
|
75
|
+
};
|
|
76
|
+
};
|
|
77
|
+
/** One durable event → its frames: the wire event (if any) under its seq, then the product's. */
|
|
78
|
+
const framesOf = async (event, sessionId) => {
|
|
79
|
+
let out = "";
|
|
80
|
+
const wire = toWireEvent(event, projection);
|
|
81
|
+
if (wire !== null)
|
|
82
|
+
out += `id: ${wire.seq}\nevent: ${wire.type}\ndata: ${JSON.stringify(wire)}\n\n`;
|
|
83
|
+
if (options.augment !== undefined) {
|
|
84
|
+
for (const frame of await options.augment(event, sessionId))
|
|
85
|
+
out += `event: ${frame.event}\ndata: ${JSON.stringify(frame.data)}\n\n`;
|
|
86
|
+
}
|
|
87
|
+
return out;
|
|
88
|
+
};
|
|
89
|
+
/** Open an SSE response and pump the session's events after `after`
|
|
90
|
+
* into it until the client leaves or `until` settles. */
|
|
91
|
+
const stream = async (res, req, sessionId, after, until) => {
|
|
92
|
+
res.writeHead(200, {
|
|
93
|
+
"content-type": "text/event-stream; charset=utf-8",
|
|
94
|
+
"cache-control": "no-cache, no-transform",
|
|
95
|
+
connection: "keep-alive",
|
|
96
|
+
"x-accel-buffering": "no",
|
|
97
|
+
});
|
|
98
|
+
res.write(": open\n\n");
|
|
99
|
+
// frames are written in event order even though augment is async:
|
|
100
|
+
// a chain serialises them
|
|
101
|
+
let chain = Promise.resolve();
|
|
102
|
+
let closed = false;
|
|
103
|
+
const unsubscribe = await service.subscribe(sessionId, after, (event) => {
|
|
104
|
+
chain = chain.then(async () => {
|
|
105
|
+
if (closed)
|
|
106
|
+
return;
|
|
107
|
+
const text = await framesOf(event, sessionId);
|
|
108
|
+
if (!closed && text !== "")
|
|
109
|
+
res.write(text);
|
|
110
|
+
});
|
|
111
|
+
});
|
|
112
|
+
const keepalive = keepaliveMs > 0 ? setInterval(() => res.write(": keepalive\n\n"), keepaliveMs) : undefined;
|
|
113
|
+
keepalive?.unref();
|
|
114
|
+
const end = () => {
|
|
115
|
+
if (closed)
|
|
116
|
+
return;
|
|
117
|
+
closed = true;
|
|
118
|
+
if (keepalive !== undefined)
|
|
119
|
+
clearInterval(keepalive);
|
|
120
|
+
unsubscribe();
|
|
121
|
+
};
|
|
122
|
+
req.on("close", end);
|
|
123
|
+
res.on("close", end);
|
|
124
|
+
if (until !== undefined) {
|
|
125
|
+
await until.catch(() => { });
|
|
126
|
+
await chain; // every frame of the run has been written
|
|
127
|
+
end();
|
|
128
|
+
res.end();
|
|
129
|
+
}
|
|
130
|
+
};
|
|
131
|
+
const afterOf = (req, url, body) => {
|
|
132
|
+
const header = req.headers["last-event-id"];
|
|
133
|
+
const fromHeader = typeof header === "string" ? Number(header) : NaN;
|
|
134
|
+
if (Number.isFinite(fromHeader))
|
|
135
|
+
return fromHeader;
|
|
136
|
+
const fromBody = body !== undefined && typeof body["after"] === "number" ? body["after"] : NaN;
|
|
137
|
+
if (Number.isFinite(fromBody))
|
|
138
|
+
return fromBody;
|
|
139
|
+
const fromQuery = Number(url.searchParams.get("after") ?? NaN);
|
|
140
|
+
return Number.isFinite(fromQuery) ? fromQuery : -1;
|
|
141
|
+
};
|
|
142
|
+
const handle = async (req, res) => {
|
|
143
|
+
const url = new URL(req.url ?? "/", "http://localhost");
|
|
144
|
+
if (url.pathname !== prefix && !url.pathname.startsWith(`${prefix}/`))
|
|
145
|
+
return false;
|
|
146
|
+
const rest = url.pathname.slice(prefix.length + 1);
|
|
147
|
+
const slash = rest.indexOf("/");
|
|
148
|
+
const rawId = slash === -1 ? rest : rest.slice(0, slash);
|
|
149
|
+
const action = slash === -1 ? "" : rest.slice(slash + 1);
|
|
150
|
+
let sessionId;
|
|
151
|
+
try {
|
|
152
|
+
sessionId = decodeURIComponent(rawId);
|
|
153
|
+
}
|
|
154
|
+
catch {
|
|
155
|
+
fail(res, 400, "bad_request", "malformed session id");
|
|
156
|
+
return true;
|
|
157
|
+
}
|
|
158
|
+
if (!SESSION_ID.test(sessionId) || !ACTIONS.has(action)) {
|
|
159
|
+
fail(res, 404, "not_found", "no such route");
|
|
160
|
+
return true;
|
|
161
|
+
}
|
|
162
|
+
try {
|
|
163
|
+
if (!(await options.authorize(req, sessionId))) {
|
|
164
|
+
fail(res, 403, "forbidden", "this session is not yours");
|
|
165
|
+
return true;
|
|
166
|
+
}
|
|
167
|
+
const method = req.method ?? "GET";
|
|
168
|
+
if (method === "GET" && action === "events") {
|
|
169
|
+
await stream(res, req, sessionId, afterOf(req, url));
|
|
170
|
+
return true;
|
|
171
|
+
}
|
|
172
|
+
if (method === "GET" && (action === "" || action === "state")) {
|
|
173
|
+
json(res, 200, await state(sessionId));
|
|
174
|
+
return true;
|
|
175
|
+
}
|
|
176
|
+
if (method === "GET" && action === "replay") {
|
|
177
|
+
const events = service.events(sessionId).map((e) => toWireEvent(e, projection)).filter((w) => w !== null);
|
|
178
|
+
json(res, 200, { sessionId, events, state: await state(sessionId) });
|
|
179
|
+
return true;
|
|
180
|
+
}
|
|
181
|
+
if (method !== "POST") {
|
|
182
|
+
fail(res, 405, "bad_request", `${method} is not allowed on ${action || "the session"}`);
|
|
183
|
+
return true;
|
|
184
|
+
}
|
|
185
|
+
const body = await readBody(req);
|
|
186
|
+
const wantsStream = url.searchParams.get("stream") === "1";
|
|
187
|
+
if (action === "run" || action === "resume") {
|
|
188
|
+
let handle;
|
|
189
|
+
if (action === "run") {
|
|
190
|
+
const input = await inputOf(body, req, sessionId);
|
|
191
|
+
const source = body["source"];
|
|
192
|
+
handle = await service.run(sessionId, input, {
|
|
193
|
+
...(source === "user" || source === "suggestion" || source === "tool_result" ? { source: source } : {}),
|
|
194
|
+
...(body["resumeFirst"] === true ? { resumeFirst: true } : {}),
|
|
195
|
+
});
|
|
196
|
+
}
|
|
197
|
+
else {
|
|
198
|
+
handle = await service.resume(sessionId);
|
|
199
|
+
}
|
|
200
|
+
if (wantsStream)
|
|
201
|
+
await stream(res, req, sessionId, afterOf(req, url, body), handle.done);
|
|
202
|
+
else
|
|
203
|
+
json(res, 202, { runId: handle.runId });
|
|
204
|
+
return true;
|
|
205
|
+
}
|
|
206
|
+
if (action === "abort") {
|
|
207
|
+
const outcome = await service.abort(sessionId, body["force"] === true ? { force: true } : {});
|
|
208
|
+
const reply = outcome.kind === "stopped" ? { kind: "stopped", runId: outcome.runId } : outcome;
|
|
209
|
+
json(res, outcome.kind === "parked" ? 409 : 200, reply);
|
|
210
|
+
return true;
|
|
211
|
+
}
|
|
212
|
+
if (action === "approve") {
|
|
213
|
+
const decisionId = body["decisionId"];
|
|
214
|
+
const allow = body["allow"];
|
|
215
|
+
if (typeof decisionId !== "string" || decisionId === "" || typeof allow !== "boolean")
|
|
216
|
+
throw new BadRequest("decisionId (string) and allow (boolean) are required");
|
|
217
|
+
const reason = body["reason"];
|
|
218
|
+
json(res, 200, await service.approve(sessionId, decisionId, allow, typeof reason === "string" ? reason : undefined));
|
|
219
|
+
return true;
|
|
220
|
+
}
|
|
221
|
+
if (action === "uncertain") {
|
|
222
|
+
const executionId = body["executionId"];
|
|
223
|
+
const resolution = body["resolution"];
|
|
224
|
+
if (typeof executionId !== "string" || executionId === "" || (resolution !== "rerun" && resolution !== "abandoned")) {
|
|
225
|
+
throw new BadRequest('executionId (string) and resolution ("rerun" | "abandoned") are required');
|
|
226
|
+
}
|
|
227
|
+
json(res, 200, await service.resolveUncertain(sessionId, executionId, resolution));
|
|
228
|
+
return true;
|
|
229
|
+
}
|
|
230
|
+
fail(res, 404, "not_found", "no such route");
|
|
231
|
+
return true;
|
|
232
|
+
}
|
|
233
|
+
catch (err) {
|
|
234
|
+
if (res.headersSent) {
|
|
235
|
+
res.end();
|
|
236
|
+
return true;
|
|
237
|
+
}
|
|
238
|
+
refuse(res, err);
|
|
239
|
+
return true;
|
|
240
|
+
}
|
|
241
|
+
};
|
|
242
|
+
return { handle };
|
|
243
|
+
}
|
|
244
|
+
class BadRequest extends Error {
|
|
245
|
+
constructor(message) {
|
|
246
|
+
super(message);
|
|
247
|
+
this.name = "BadRequest";
|
|
248
|
+
}
|
|
249
|
+
}
|
package/dist/index.d.ts
ADDED
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @vincemakes/kiso-server — the hosted-session service.
|
|
3
|
+
*
|
|
4
|
+
* Many durable sessions in one process; observers with exact replay from
|
|
5
|
+
* a sequence number; approve / abort / resume routed to the right run;
|
|
6
|
+
* drain and close. No transport, no product noun: the product supplies
|
|
7
|
+
* the agent factory, and every run is the runtime's own `Session.run()`.
|
|
8
|
+
*/
|
|
9
|
+
export { createSessionService, SessionService } from "./service.js";
|
|
10
|
+
export type { AbortOutcome, DrainReport, OpenRun, RunHandle, RunOptions, SessionServiceOptions } from "./service.js";
|
|
11
|
+
export { DrainingError, InFlightError, OpenRunError } from "./errors.js";
|
|
12
|
+
export { EXECUTION_ENDED, executionDelta } from "./execution.js";
|
|
13
|
+
export { tail } from "./tail.js";
|
|
14
|
+
export { MAX_ARG_VALUE_CHARS, STRIPPED_ARG_KEYS, sanitizeToolArgs, toWireEvent } from "./wire.js";
|
|
15
|
+
export type { ProjectionOptions } from "./wire.js";
|
|
16
|
+
export type { Listener } from "./tail.js";
|
package/dist/index.js
ADDED
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @vincemakes/kiso-server — the hosted-session service.
|
|
3
|
+
*
|
|
4
|
+
* Many durable sessions in one process; observers with exact replay from
|
|
5
|
+
* a sequence number; approve / abort / resume routed to the right run;
|
|
6
|
+
* drain and close. No transport, no product noun: the product supplies
|
|
7
|
+
* the agent factory, and every run is the runtime's own `Session.run()`.
|
|
8
|
+
*/
|
|
9
|
+
export { createSessionService, SessionService } from "./service.js";
|
|
10
|
+
export { DrainingError, InFlightError, OpenRunError } from "./errors.js";
|
|
11
|
+
export { EXECUTION_ENDED, executionDelta } from "./execution.js";
|
|
12
|
+
export { tail } from "./tail.js";
|
|
13
|
+
export { MAX_ARG_VALUE_CHARS, STRIPPED_ARG_KEYS, sanitizeToolArgs, toWireEvent } from "./wire.js";
|
|
@@ -0,0 +1,150 @@
|
|
|
1
|
+
import type { ContentBlock, Event, MessageSource } from "@vincemakes/kiso-core";
|
|
2
|
+
import type { Agent, ApprovalRequest, Session, SessionStore } from "@vincemakes/kiso-runtime";
|
|
3
|
+
import { type Listener } from "./tail.js";
|
|
4
|
+
/**
|
|
5
|
+
* The hosted-session service — many durable sessions in one long-lived
|
|
6
|
+
* process, each with observers, one run at a time.
|
|
7
|
+
*
|
|
8
|
+
* What it is: the layer two products wrote independently on top of the
|
|
9
|
+
* runtime (a session registry, the one-run rule, a pump that fans a run's
|
|
10
|
+
* events out to listeners and counts executing tools, replay-from-seq for
|
|
11
|
+
* reconnecting observers, approve / abort / resume routed to the right
|
|
12
|
+
* run, drain before exit). It names no product noun: the product hands
|
|
13
|
+
* in an agent FACTORY (control inversion) and the service never learns
|
|
14
|
+
* what the agent is for.
|
|
15
|
+
*
|
|
16
|
+
* What it is not: a transport. HTTP, SSE, sockets and their status codes
|
|
17
|
+
* live above it. It is also not a second Agent — every session here is
|
|
18
|
+
* `runtime.createAgent(...).session({ id })`, and every run is that
|
|
19
|
+
* session's `run()` / `resume()`; the service adds bookkeeping, never a
|
|
20
|
+
* loop of its own.
|
|
21
|
+
*/
|
|
22
|
+
export interface SessionServiceOptions {
|
|
23
|
+
/** The store every session in this process lives on — the same one the
|
|
24
|
+
* factory's agents write to. The service reads it for replay and for
|
|
25
|
+
* the open-run check. */
|
|
26
|
+
readonly store: SessionStore;
|
|
27
|
+
/** The product's agent factory, called once per session id (and again by
|
|
28
|
+
* `reopen`). Everything product-specific — prompt, tools, extensions,
|
|
29
|
+
* permission policy — is decided in here. */
|
|
30
|
+
readonly open: (sessionId: string) => Promise<Agent>;
|
|
31
|
+
readonly hooks?: {
|
|
32
|
+
/** Product-level parking beyond the kernel's own approvals — the ask
|
|
33
|
+
* extension's pending questions are the known case. A non-empty
|
|
34
|
+
* list means the run is waiting for a person, so `abort()` refuses
|
|
35
|
+
* (a voided draft is not what "stop" means when someone is being
|
|
36
|
+
* asked) unless forced. */
|
|
37
|
+
readonly parkedBy?: (sessionId: string) => readonly string[];
|
|
38
|
+
/** After `abort()` has aborted a run: the product's cleanup, e.g.
|
|
39
|
+
* finishing paid jobs the run left in flight. */
|
|
40
|
+
readonly onAbort?: (sessionId: string, runId: string) => Promise<void> | void;
|
|
41
|
+
};
|
|
42
|
+
}
|
|
43
|
+
export interface RunHandle {
|
|
44
|
+
readonly runId: string;
|
|
45
|
+
/** Settles when the run has reached its terminal and the last event has
|
|
46
|
+
* been delivered to every listener. Rejects only if the runtime threw
|
|
47
|
+
* (a poisoned session); a run that fails still SETTLES — its failure is
|
|
48
|
+
* a terminal event, not an exception. */
|
|
49
|
+
readonly done: Promise<void>;
|
|
50
|
+
}
|
|
51
|
+
export interface RunOptions {
|
|
52
|
+
readonly source?: MessageSource;
|
|
53
|
+
/** The log holds an open run from a previous process: resume it to its
|
|
54
|
+
* terminal first, then start this turn. Without this the call refuses
|
|
55
|
+
* with `OpenRunError` — the service never resumes silently. */
|
|
56
|
+
readonly resumeFirst?: boolean;
|
|
57
|
+
}
|
|
58
|
+
export type AbortOutcome = {
|
|
59
|
+
readonly kind: "idle";
|
|
60
|
+
} | {
|
|
61
|
+
readonly kind: "parked";
|
|
62
|
+
readonly runId: string;
|
|
63
|
+
readonly approvals: readonly ApprovalRequest[];
|
|
64
|
+
readonly reasons: readonly string[];
|
|
65
|
+
} | {
|
|
66
|
+
readonly kind: "stopped";
|
|
67
|
+
readonly runId: string;
|
|
68
|
+
readonly settled: Promise<void>;
|
|
69
|
+
};
|
|
70
|
+
export interface DrainReport {
|
|
71
|
+
/** Sessions with a tool executing when drain began; waited for, up to the grace. */
|
|
72
|
+
readonly waitedFor: readonly string[];
|
|
73
|
+
/** Sessions whose run is waiting for a person — untouched, resumable. */
|
|
74
|
+
readonly parked: readonly string[];
|
|
75
|
+
/** Sessions streaming (no tool executing) — cut when the process exits, resumable. */
|
|
76
|
+
readonly interrupted: readonly string[];
|
|
77
|
+
/** The subset of `waitedFor` still executing when the grace ran out. */
|
|
78
|
+
readonly timedOut: readonly string[];
|
|
79
|
+
}
|
|
80
|
+
export interface OpenRun {
|
|
81
|
+
readonly sessionId: string;
|
|
82
|
+
readonly runId: string;
|
|
83
|
+
}
|
|
84
|
+
export declare class SessionService {
|
|
85
|
+
#private;
|
|
86
|
+
constructor(options: SessionServiceOptions);
|
|
87
|
+
/** Rebuild an IDLE session from the factory — the product's definition
|
|
88
|
+
* changed (its tool set, its prompt) and the next run should see it.
|
|
89
|
+
* Observers are carried over. Refuses while a run is in flight. */
|
|
90
|
+
reopen(sessionId: string): Promise<void>;
|
|
91
|
+
/** Start a turn. Refuses with `InFlightError` (one run per session),
|
|
92
|
+
* `OpenRunError` (a previous process died inside a run — see
|
|
93
|
+
* `resumeFirst`) or `DrainingError`. */
|
|
94
|
+
run(sessionId: string, input: string | readonly ContentBlock[], options?: RunOptions): Promise<RunHandle>;
|
|
95
|
+
/** Drive the open run (one a previous process left, or one parked at an
|
|
96
|
+
* approval that has since been answered) to its terminal. */
|
|
97
|
+
resume(sessionId: string): Promise<RunHandle>;
|
|
98
|
+
/** Stop the run in flight. A run waiting for a person (a kernel approval,
|
|
99
|
+
* or whatever `hooks.parkedBy` names) is PARKED, not running: aborting
|
|
100
|
+
* it would void the draft the answer is meant to continue, so the
|
|
101
|
+
* service refuses and says who is being waited for — `force` overrides.
|
|
102
|
+
* "stopped" means the abort was delivered; `settled` resolves when the
|
|
103
|
+
* run's terminal is written (a tool that ignores the abort signal keeps
|
|
104
|
+
* the run open until it returns — the kernel does not kill tools). The
|
|
105
|
+
* `onAbort` hook runs on the abort, not on the settle. */
|
|
106
|
+
abort(sessionId: string, options?: {
|
|
107
|
+
readonly force?: boolean;
|
|
108
|
+
}): Promise<AbortOutcome>;
|
|
109
|
+
/** Answer a kernel approval. `needsResume` is true when no run was live
|
|
110
|
+
* to consume the answer (the process that asked is gone): the decision
|
|
111
|
+
* is durable, and `resume()` is what makes the run continue. */
|
|
112
|
+
approve(sessionId: string, decisionId: string, allow: boolean, reason?: string): Promise<{
|
|
113
|
+
readonly needsResume: boolean;
|
|
114
|
+
}>;
|
|
115
|
+
pendingApprovals(sessionId: string): Promise<readonly ApprovalRequest[]>;
|
|
116
|
+
uncertainExecutions(sessionId: string): Promise<ReturnType<Session["uncertainExecutions"]>>;
|
|
117
|
+
/** Record a verdict on an uncertain execution. `remaining` is how many
|
|
118
|
+
* still await one; the host resumes when it reaches zero — the service
|
|
119
|
+
* does not start runs on its own. */
|
|
120
|
+
resolveUncertain(sessionId: string, executionId: string, resolution: "rerun" | "abandoned"): Promise<{
|
|
121
|
+
readonly remaining: number;
|
|
122
|
+
}>;
|
|
123
|
+
/** Every event after `after` — the ones on disk now, then the live ones
|
|
124
|
+
* as they land — exactly once each, in order. Returns the unsubscribe. */
|
|
125
|
+
subscribe(sessionId: string, after: number, listener: Listener): Promise<() => void>;
|
|
126
|
+
/** The durable log after `after` — a plain read, no session opened. */
|
|
127
|
+
events(sessionId: string, after?: number): Event[];
|
|
128
|
+
isRunning(sessionId: string): boolean;
|
|
129
|
+
/** The highest seq this process has delivered for the session, −1 before any. */
|
|
130
|
+
highWater(sessionId: string): number;
|
|
131
|
+
executingCount(): number;
|
|
132
|
+
get draining(): boolean;
|
|
133
|
+
/** The run a previous process left without a terminal on this session,
|
|
134
|
+
* or null. The plain read the transport's snapshot needs. */
|
|
135
|
+
openRun(sessionId: string): string | null;
|
|
136
|
+
/** Boot recovery: every session on the store whose last run has no
|
|
137
|
+
* terminal. The host decides what to do with them (resume, or leave
|
|
138
|
+
* for the next request to find). */
|
|
139
|
+
openRuns(): OpenRun[];
|
|
140
|
+
/** Stop accepting runs; wait up to `graceMs` for executing tools; report.
|
|
141
|
+
* Nothing is aborted here — a run cut by the process exiting is
|
|
142
|
+
* resumable, and the report says which sessions those are. */
|
|
143
|
+
drain(graceMs: number): Promise<DrainReport>;
|
|
144
|
+
/** Abort every live run, wait up to `graceMs` for each to settle (a
|
|
145
|
+
* tool that ignores the abort keeps its run open until it returns),
|
|
146
|
+
* then forget every session. Whatever did not settle is resumable by
|
|
147
|
+
* the next process — persist-first means nothing delivered is lost. */
|
|
148
|
+
close(graceMs?: number): Promise<void>;
|
|
149
|
+
}
|
|
150
|
+
export declare function createSessionService(options: SessionServiceOptions): SessionService;
|
package/dist/service.js
ADDED
|
@@ -0,0 +1,263 @@
|
|
|
1
|
+
import { openRunId } from "@vincemakes/kiso-runtime/internal";
|
|
2
|
+
import { DrainingError, InFlightError, OpenRunError } from "./errors.js";
|
|
3
|
+
import { executionDelta } from "./execution.js";
|
|
4
|
+
import { tail } from "./tail.js";
|
|
5
|
+
export class SessionService {
|
|
6
|
+
#store;
|
|
7
|
+
#open;
|
|
8
|
+
#hooks;
|
|
9
|
+
/** id → the opening (memoized, so two concurrent callers share one open). */
|
|
10
|
+
#opening = new Map();
|
|
11
|
+
/** id → the held session, once open. */
|
|
12
|
+
#held = new Map();
|
|
13
|
+
#draining = false;
|
|
14
|
+
constructor(options) {
|
|
15
|
+
this.#store = options.store;
|
|
16
|
+
this.#open = options.open;
|
|
17
|
+
this.#hooks = options.hooks ?? {};
|
|
18
|
+
}
|
|
19
|
+
// ---- the registry -------------------------------------------------------
|
|
20
|
+
async #hold(sessionId) {
|
|
21
|
+
const existing = this.#opening.get(sessionId);
|
|
22
|
+
if (existing !== undefined)
|
|
23
|
+
return existing;
|
|
24
|
+
const opening = (async () => {
|
|
25
|
+
const agent = await this.#open(sessionId);
|
|
26
|
+
const session = await agent.session({ id: sessionId });
|
|
27
|
+
const held = { id: sessionId, agent, session, listeners: new Set(), live: null, pumping: null, executing: 0, highWater: -1 };
|
|
28
|
+
this.#held.set(sessionId, held);
|
|
29
|
+
return held;
|
|
30
|
+
})();
|
|
31
|
+
this.#opening.set(sessionId, opening);
|
|
32
|
+
try {
|
|
33
|
+
return await opening;
|
|
34
|
+
}
|
|
35
|
+
catch (err) {
|
|
36
|
+
this.#opening.delete(sessionId);
|
|
37
|
+
throw err;
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
/** Rebuild an IDLE session from the factory — the product's definition
|
|
41
|
+
* changed (its tool set, its prompt) and the next run should see it.
|
|
42
|
+
* Observers are carried over. Refuses while a run is in flight. */
|
|
43
|
+
async reopen(sessionId) {
|
|
44
|
+
const held = await this.#hold(sessionId);
|
|
45
|
+
if (held.live !== null)
|
|
46
|
+
throw new InFlightError(sessionId, held.live.runId);
|
|
47
|
+
const agent = await this.#open(sessionId);
|
|
48
|
+
held.agent = agent;
|
|
49
|
+
held.session = await agent.session({ id: sessionId });
|
|
50
|
+
}
|
|
51
|
+
// ---- runs -----------------------------------------------------------------
|
|
52
|
+
#pump(held, run) {
|
|
53
|
+
held.live = run;
|
|
54
|
+
const pumping = (async () => {
|
|
55
|
+
try {
|
|
56
|
+
for await (const event of run) {
|
|
57
|
+
held.executing = Math.max(0, held.executing + executionDelta(event));
|
|
58
|
+
held.highWater = Math.max(held.highWater, event.seq);
|
|
59
|
+
for (const listener of [...held.listeners]) {
|
|
60
|
+
try {
|
|
61
|
+
listener(event);
|
|
62
|
+
}
|
|
63
|
+
catch {
|
|
64
|
+
held.listeners.delete(listener); // a throwing observer leaves; the run does not
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
finally {
|
|
70
|
+
if (held.live === run) {
|
|
71
|
+
held.live = null;
|
|
72
|
+
held.pumping = null;
|
|
73
|
+
held.executing = 0;
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
})();
|
|
77
|
+
held.pumping = pumping;
|
|
78
|
+
void pumping.catch(() => { }); // a host that does not await `done` gets no unhandled rejection
|
|
79
|
+
return { runId: run.runId, done: pumping };
|
|
80
|
+
}
|
|
81
|
+
/** Start a turn. Refuses with `InFlightError` (one run per session),
|
|
82
|
+
* `OpenRunError` (a previous process died inside a run — see
|
|
83
|
+
* `resumeFirst`) or `DrainingError`. */
|
|
84
|
+
async run(sessionId, input, options = {}) {
|
|
85
|
+
if (this.#draining)
|
|
86
|
+
throw new DrainingError();
|
|
87
|
+
const held = await this.#hold(sessionId);
|
|
88
|
+
if (held.live !== null)
|
|
89
|
+
throw new InFlightError(sessionId, held.live.runId);
|
|
90
|
+
const open = openRunId(this.#store.load(sessionId));
|
|
91
|
+
if (open !== undefined) {
|
|
92
|
+
if (options.resumeFirst !== true)
|
|
93
|
+
throw new OpenRunError(sessionId, open);
|
|
94
|
+
await this.#pump(held, held.session.resume()).done;
|
|
95
|
+
}
|
|
96
|
+
const run = held.session.run(input, options.source !== undefined ? { source: options.source } : {});
|
|
97
|
+
return this.#pump(held, run);
|
|
98
|
+
}
|
|
99
|
+
/** Drive the open run (one a previous process left, or one parked at an
|
|
100
|
+
* approval that has since been answered) to its terminal. */
|
|
101
|
+
async resume(sessionId) {
|
|
102
|
+
if (this.#draining)
|
|
103
|
+
throw new DrainingError();
|
|
104
|
+
const held = await this.#hold(sessionId);
|
|
105
|
+
if (held.live !== null)
|
|
106
|
+
throw new InFlightError(sessionId, held.live.runId);
|
|
107
|
+
return this.#pump(held, held.session.resume());
|
|
108
|
+
}
|
|
109
|
+
/** Stop the run in flight. A run waiting for a person (a kernel approval,
|
|
110
|
+
* or whatever `hooks.parkedBy` names) is PARKED, not running: aborting
|
|
111
|
+
* it would void the draft the answer is meant to continue, so the
|
|
112
|
+
* service refuses and says who is being waited for — `force` overrides.
|
|
113
|
+
* "stopped" means the abort was delivered; `settled` resolves when the
|
|
114
|
+
* run's terminal is written (a tool that ignores the abort signal keeps
|
|
115
|
+
* the run open until it returns — the kernel does not kill tools). The
|
|
116
|
+
* `onAbort` hook runs on the abort, not on the settle. */
|
|
117
|
+
async abort(sessionId, options = {}) {
|
|
118
|
+
const held = await this.#hold(sessionId);
|
|
119
|
+
const run = held.live;
|
|
120
|
+
if (run === null)
|
|
121
|
+
return { kind: "idle" };
|
|
122
|
+
const approvals = held.session.pendingApprovals();
|
|
123
|
+
const reasons = this.#hooks.parkedBy?.(sessionId) ?? [];
|
|
124
|
+
if ((approvals.length > 0 || reasons.length > 0) && options.force !== true) {
|
|
125
|
+
return { kind: "parked", runId: run.runId, approvals, reasons };
|
|
126
|
+
}
|
|
127
|
+
run.abort();
|
|
128
|
+
const settled = held.pumping?.catch(() => { }) ?? Promise.resolve();
|
|
129
|
+
await this.#hooks.onAbort?.(sessionId, run.runId);
|
|
130
|
+
return { kind: "stopped", runId: run.runId, settled };
|
|
131
|
+
}
|
|
132
|
+
// ---- decisions ------------------------------------------------------------
|
|
133
|
+
/** Answer a kernel approval. `needsResume` is true when no run was live
|
|
134
|
+
* to consume the answer (the process that asked is gone): the decision
|
|
135
|
+
* is durable, and `resume()` is what makes the run continue. */
|
|
136
|
+
async approve(sessionId, decisionId, allow, reason) {
|
|
137
|
+
const held = await this.#hold(sessionId);
|
|
138
|
+
const wasLive = held.live !== null;
|
|
139
|
+
await held.session.approve(decisionId, allow, reason);
|
|
140
|
+
return { needsResume: !wasLive };
|
|
141
|
+
}
|
|
142
|
+
async pendingApprovals(sessionId) {
|
|
143
|
+
return (await this.#hold(sessionId)).session.pendingApprovals();
|
|
144
|
+
}
|
|
145
|
+
async uncertainExecutions(sessionId) {
|
|
146
|
+
return (await this.#hold(sessionId)).session.uncertainExecutions();
|
|
147
|
+
}
|
|
148
|
+
/** Record a verdict on an uncertain execution. `remaining` is how many
|
|
149
|
+
* still await one; the host resumes when it reaches zero — the service
|
|
150
|
+
* does not start runs on its own. */
|
|
151
|
+
async resolveUncertain(sessionId, executionId, resolution) {
|
|
152
|
+
const held = await this.#hold(sessionId);
|
|
153
|
+
await held.session.resolveUncertain(executionId, resolution);
|
|
154
|
+
return { remaining: held.session.uncertainExecutions().length };
|
|
155
|
+
}
|
|
156
|
+
// ---- observation ----------------------------------------------------------
|
|
157
|
+
/** Every event after `after` — the ones on disk now, then the live ones
|
|
158
|
+
* as they land — exactly once each, in order. Returns the unsubscribe. */
|
|
159
|
+
async subscribe(sessionId, after, listener) {
|
|
160
|
+
const held = await this.#hold(sessionId);
|
|
161
|
+
return tail(() => this.events(sessionId, after), held.listeners, after, listener);
|
|
162
|
+
}
|
|
163
|
+
/** The durable log after `after` — a plain read, no session opened. */
|
|
164
|
+
events(sessionId, after = -1) {
|
|
165
|
+
return this.#store
|
|
166
|
+
.load(sessionId)
|
|
167
|
+
.map((r) => r.event)
|
|
168
|
+
.filter((e) => e.seq > after);
|
|
169
|
+
}
|
|
170
|
+
isRunning(sessionId) {
|
|
171
|
+
return this.#held.get(sessionId)?.live !== null && this.#held.get(sessionId)?.live !== undefined;
|
|
172
|
+
}
|
|
173
|
+
/** The highest seq this process has delivered for the session, −1 before any. */
|
|
174
|
+
highWater(sessionId) {
|
|
175
|
+
return this.#held.get(sessionId)?.highWater ?? -1;
|
|
176
|
+
}
|
|
177
|
+
executingCount() {
|
|
178
|
+
let n = 0;
|
|
179
|
+
for (const held of this.#held.values())
|
|
180
|
+
n += held.executing;
|
|
181
|
+
return n;
|
|
182
|
+
}
|
|
183
|
+
get draining() {
|
|
184
|
+
return this.#draining;
|
|
185
|
+
}
|
|
186
|
+
/** The run a previous process left without a terminal on this session,
|
|
187
|
+
* or null. The plain read the transport's snapshot needs. */
|
|
188
|
+
openRun(sessionId) {
|
|
189
|
+
return openRunId(this.#store.load(sessionId)) ?? null;
|
|
190
|
+
}
|
|
191
|
+
/** Boot recovery: every session on the store whose last run has no
|
|
192
|
+
* terminal. The host decides what to do with them (resume, or leave
|
|
193
|
+
* for the next request to find). */
|
|
194
|
+
openRuns() {
|
|
195
|
+
const out = [];
|
|
196
|
+
for (const sessionId of this.#store.ids()) {
|
|
197
|
+
const runId = openRunId(this.#store.load(sessionId));
|
|
198
|
+
if (runId !== undefined)
|
|
199
|
+
out.push({ sessionId, runId });
|
|
200
|
+
}
|
|
201
|
+
return out;
|
|
202
|
+
}
|
|
203
|
+
// ---- process lifecycle -------------------------------------------------
|
|
204
|
+
/** Stop accepting runs; wait up to `graceMs` for executing tools; report.
|
|
205
|
+
* Nothing is aborted here — a run cut by the process exiting is
|
|
206
|
+
* resumable, and the report says which sessions those are. */
|
|
207
|
+
async drain(graceMs) {
|
|
208
|
+
this.#draining = true;
|
|
209
|
+
const waitedFor = [];
|
|
210
|
+
const parked = [];
|
|
211
|
+
const interrupted = [];
|
|
212
|
+
const waits = [];
|
|
213
|
+
for (const held of this.#held.values()) {
|
|
214
|
+
if (held.live === null)
|
|
215
|
+
continue;
|
|
216
|
+
if (held.executing > 0) {
|
|
217
|
+
waitedFor.push(held.id);
|
|
218
|
+
if (held.pumping !== null)
|
|
219
|
+
waits.push(held.pumping.catch(() => { }));
|
|
220
|
+
continue;
|
|
221
|
+
}
|
|
222
|
+
const askedFor = held.session.pendingApprovals().length > 0 || (this.#hooks.parkedBy?.(held.id) ?? []).length > 0;
|
|
223
|
+
(askedFor ? parked : interrupted).push(held.id);
|
|
224
|
+
}
|
|
225
|
+
if (waits.length === 0)
|
|
226
|
+
return { waitedFor, parked, interrupted, timedOut: [] };
|
|
227
|
+
let timer;
|
|
228
|
+
const deadline = new Promise((resolve) => {
|
|
229
|
+
timer = setTimeout(() => resolve("timeout"), graceMs);
|
|
230
|
+
});
|
|
231
|
+
const outcome = await Promise.race([Promise.allSettled(waits).then(() => "done"), deadline]);
|
|
232
|
+
if (timer !== undefined)
|
|
233
|
+
clearTimeout(timer);
|
|
234
|
+
const timedOut = outcome === "timeout" ? waitedFor.filter((id) => (this.#held.get(id)?.executing ?? 0) > 0) : [];
|
|
235
|
+
return { waitedFor, parked, interrupted, timedOut };
|
|
236
|
+
}
|
|
237
|
+
/** Abort every live run, wait up to `graceMs` for each to settle (a
|
|
238
|
+
* tool that ignores the abort keeps its run open until it returns),
|
|
239
|
+
* then forget every session. Whatever did not settle is resumable by
|
|
240
|
+
* the next process — persist-first means nothing delivered is lost. */
|
|
241
|
+
async close(graceMs = 0) {
|
|
242
|
+
this.#draining = true;
|
|
243
|
+
const settling = [];
|
|
244
|
+
for (const held of this.#held.values()) {
|
|
245
|
+
held.live?.abort();
|
|
246
|
+
if (held.pumping !== null)
|
|
247
|
+
settling.push(held.pumping.catch(() => { }));
|
|
248
|
+
}
|
|
249
|
+
if (settling.length > 0 && graceMs > 0) {
|
|
250
|
+
let timer;
|
|
251
|
+
await Promise.race([Promise.all(settling), new Promise((resolve) => {
|
|
252
|
+
timer = setTimeout(resolve, graceMs);
|
|
253
|
+
})]);
|
|
254
|
+
if (timer !== undefined)
|
|
255
|
+
clearTimeout(timer);
|
|
256
|
+
}
|
|
257
|
+
this.#held.clear();
|
|
258
|
+
this.#opening.clear();
|
|
259
|
+
}
|
|
260
|
+
}
|
|
261
|
+
export function createSessionService(options) {
|
|
262
|
+
return new SessionService(options);
|
|
263
|
+
}
|
package/dist/tail.d.ts
ADDED
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
import type { Event } from "@vincemakes/kiso-core";
|
|
2
|
+
export type Listener = (event: Event) => void;
|
|
3
|
+
/**
|
|
4
|
+
* Replay-then-live, exact by sequence number.
|
|
5
|
+
*
|
|
6
|
+
* The reconnect guarantee: a subscriber that names the last seq it saw
|
|
7
|
+
* receives every event after it exactly once — the ones already on disk
|
|
8
|
+
* (persist-first: every event is written before a run yields it) and the
|
|
9
|
+
* ones the live run emits from now on, in order, with no duplicate at the
|
|
10
|
+
* seam. The seam is the only hard part: an event the run emits WHILE the
|
|
11
|
+
* replay is being read must neither be lost nor sent twice. It is
|
|
12
|
+
* buffered until the replay ends, then sent only if its seq is beyond
|
|
13
|
+
* what the replay covered.
|
|
14
|
+
*
|
|
15
|
+
* Both hosting products wrote this function independently, flag names
|
|
16
|
+
* included. It is one function now.
|
|
17
|
+
*/
|
|
18
|
+
export declare function tail(replay: () => Iterable<Event>, listeners: Set<Listener>, after: number, send: Listener): () => void;
|
package/dist/tail.js
ADDED
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Replay-then-live, exact by sequence number.
|
|
3
|
+
*
|
|
4
|
+
* The reconnect guarantee: a subscriber that names the last seq it saw
|
|
5
|
+
* receives every event after it exactly once — the ones already on disk
|
|
6
|
+
* (persist-first: every event is written before a run yields it) and the
|
|
7
|
+
* ones the live run emits from now on, in order, with no duplicate at the
|
|
8
|
+
* seam. The seam is the only hard part: an event the run emits WHILE the
|
|
9
|
+
* replay is being read must neither be lost nor sent twice. It is
|
|
10
|
+
* buffered until the replay ends, then sent only if its seq is beyond
|
|
11
|
+
* what the replay covered.
|
|
12
|
+
*
|
|
13
|
+
* Both hosting products wrote this function independently, flag names
|
|
14
|
+
* included. It is one function now.
|
|
15
|
+
*/
|
|
16
|
+
export function tail(replay, listeners, after, send) {
|
|
17
|
+
let sentUpTo = after;
|
|
18
|
+
const buffered = [];
|
|
19
|
+
let replaying = true;
|
|
20
|
+
const listener = (event) => {
|
|
21
|
+
if (replaying) {
|
|
22
|
+
buffered.push(event);
|
|
23
|
+
return;
|
|
24
|
+
}
|
|
25
|
+
if (event.seq <= sentUpTo)
|
|
26
|
+
return;
|
|
27
|
+
sentUpTo = event.seq;
|
|
28
|
+
send(event);
|
|
29
|
+
};
|
|
30
|
+
listeners.add(listener);
|
|
31
|
+
for (const event of replay()) {
|
|
32
|
+
if (event.seq <= sentUpTo)
|
|
33
|
+
continue;
|
|
34
|
+
sentUpTo = event.seq;
|
|
35
|
+
send(event);
|
|
36
|
+
}
|
|
37
|
+
replaying = false;
|
|
38
|
+
for (const event of buffered) {
|
|
39
|
+
if (event.seq <= sentUpTo)
|
|
40
|
+
continue;
|
|
41
|
+
sentUpTo = event.seq;
|
|
42
|
+
send(event);
|
|
43
|
+
}
|
|
44
|
+
return () => {
|
|
45
|
+
listeners.delete(listener);
|
|
46
|
+
};
|
|
47
|
+
}
|
package/dist/wire.d.ts
ADDED
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
import type { Event } from "@vincemakes/kiso-core";
|
|
2
|
+
import { type WireEvent } from "@vincemakes/kiso-protocol";
|
|
3
|
+
/**
|
|
4
|
+
* The projection: durable Event → WireEvent | null.
|
|
5
|
+
*
|
|
6
|
+
* Pure, total over the union: a type not in DURABLE_TO_WIRE yields null; a
|
|
7
|
+
* type on the wire yields exactly the allowlisted fields that are present
|
|
8
|
+
* (never undefined), with tool arguments passed through `sanitize`. The
|
|
9
|
+
* allowlist is data in the protocol package; nothing here can add a field
|
|
10
|
+
* the protocol did not name.
|
|
11
|
+
*/
|
|
12
|
+
export interface ProjectionOptions {
|
|
13
|
+
/** Reasoning text on the wire — OFF unless a product asks (every hosting
|
|
14
|
+
* product shipped a "thinking leaks" fix once). */
|
|
15
|
+
readonly exposeThinking?: boolean;
|
|
16
|
+
/** Replaces the default tool-argument sanitizer. */
|
|
17
|
+
readonly sanitize?: (input: unknown) => unknown;
|
|
18
|
+
}
|
|
19
|
+
/** Argument keys that carry prose the client has no business seeing in a
|
|
20
|
+
* tool card (the model's own prompt to a generator, a document body).
|
|
21
|
+
* uooki's list, verbatim. */
|
|
22
|
+
export declare const STRIPPED_ARG_KEYS: ReadonlySet<string>;
|
|
23
|
+
/** Longer string values are cut here; the card shows a prefix. */
|
|
24
|
+
export declare const MAX_ARG_VALUE_CHARS = 200;
|
|
25
|
+
/** The default sanitizer: drops the prose keys, truncates long strings,
|
|
26
|
+
* leaves non-objects alone. Shallow — an argument's nested object is
|
|
27
|
+
* passed through as is. */
|
|
28
|
+
export declare function sanitizeToolArgs(input: unknown): unknown;
|
|
29
|
+
export declare function toWireEvent(event: Event, options?: ProjectionOptions): WireEvent | null;
|
package/dist/wire.js
ADDED
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
import { DURABLE_TO_WIRE, SANITIZED_FIELDS, WIRE_FIELDS } from "@vincemakes/kiso-protocol";
|
|
2
|
+
/** Argument keys that carry prose the client has no business seeing in a
|
|
3
|
+
* tool card (the model's own prompt to a generator, a document body).
|
|
4
|
+
* uooki's list, verbatim. */
|
|
5
|
+
export const STRIPPED_ARG_KEYS = new Set(["prompt", "description", "text", "body", "content", "instructions", "system_prompt", "input_text"]);
|
|
6
|
+
/** Longer string values are cut here; the card shows a prefix. */
|
|
7
|
+
export const MAX_ARG_VALUE_CHARS = 200;
|
|
8
|
+
/** The default sanitizer: drops the prose keys, truncates long strings,
|
|
9
|
+
* leaves non-objects alone. Shallow — an argument's nested object is
|
|
10
|
+
* passed through as is. */
|
|
11
|
+
export function sanitizeToolArgs(input) {
|
|
12
|
+
if (input === null || typeof input !== "object" || Array.isArray(input))
|
|
13
|
+
return input;
|
|
14
|
+
const out = {};
|
|
15
|
+
for (const [key, value] of Object.entries(input)) {
|
|
16
|
+
if (STRIPPED_ARG_KEYS.has(key))
|
|
17
|
+
continue;
|
|
18
|
+
out[key] = typeof value === "string" && value.length > MAX_ARG_VALUE_CHARS ? `${value.slice(0, MAX_ARG_VALUE_CHARS)}…` : value;
|
|
19
|
+
}
|
|
20
|
+
return out;
|
|
21
|
+
}
|
|
22
|
+
export function toWireEvent(event, options = {}) {
|
|
23
|
+
const wireType = DURABLE_TO_WIRE[event.type];
|
|
24
|
+
if (wireType === undefined)
|
|
25
|
+
return null;
|
|
26
|
+
if (wireType === "thinking" && options.exposeThinking !== true)
|
|
27
|
+
return null;
|
|
28
|
+
const sanitize = options.sanitize ?? sanitizeToolArgs;
|
|
29
|
+
const source = event;
|
|
30
|
+
const projected = { seq: event.seq, type: wireType };
|
|
31
|
+
for (const field of WIRE_FIELDS[wireType]) {
|
|
32
|
+
if (!(field in source))
|
|
33
|
+
continue;
|
|
34
|
+
const value = source[field];
|
|
35
|
+
if (value === undefined)
|
|
36
|
+
continue;
|
|
37
|
+
projected[field] = SANITIZED_FIELDS.has(field) ? sanitize(value) : value;
|
|
38
|
+
}
|
|
39
|
+
return projected;
|
|
40
|
+
}
|
package/package.json
ADDED
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@vincemakes/kiso-server",
|
|
3
|
+
"version": "0.41.0",
|
|
4
|
+
"description": "kiso server — the hosted-session service: many durable sessions in one process, observers with exact replay from a sequence number, approve / abort / resume routed to the right run, drain and close. No transport; a product supplies the agent factory.",
|
|
5
|
+
"type": "module",
|
|
6
|
+
"license": "MIT",
|
|
7
|
+
"exports": {
|
|
8
|
+
".": {
|
|
9
|
+
"types": "./dist/index.d.ts",
|
|
10
|
+
"default": "./dist/index.js"
|
|
11
|
+
},
|
|
12
|
+
"./http": {
|
|
13
|
+
"types": "./dist/http.d.ts",
|
|
14
|
+
"default": "./dist/http.js"
|
|
15
|
+
}
|
|
16
|
+
},
|
|
17
|
+
"files": [
|
|
18
|
+
"dist",
|
|
19
|
+
"README.md",
|
|
20
|
+
"LICENSE"
|
|
21
|
+
],
|
|
22
|
+
"scripts": {
|
|
23
|
+
"build": "tsc -p tsconfig.build.json",
|
|
24
|
+
"typecheck": "tsc -p tsconfig.json",
|
|
25
|
+
"test": "vitest run"
|
|
26
|
+
},
|
|
27
|
+
"dependencies": {
|
|
28
|
+
"@vincemakes/kiso-core": "0.41.0",
|
|
29
|
+
"@vincemakes/kiso-protocol": "0.41.0",
|
|
30
|
+
"@vincemakes/kiso-runtime": "0.41.0"
|
|
31
|
+
},
|
|
32
|
+
"devDependencies": {
|
|
33
|
+
"@vincemakes/kiso-evals": "0.41.0",
|
|
34
|
+
"@types/node": "^26.1.2",
|
|
35
|
+
"typescript": "^5.7.2",
|
|
36
|
+
"vitest": "^3.0.0"
|
|
37
|
+
},
|
|
38
|
+
"repository": {
|
|
39
|
+
"type": "git",
|
|
40
|
+
"url": "https://github.com/vincemakes/kiso.git",
|
|
41
|
+
"directory": "packages/server"
|
|
42
|
+
},
|
|
43
|
+
"bugs": {
|
|
44
|
+
"url": "https://github.com/vincemakes/kiso/issues"
|
|
45
|
+
},
|
|
46
|
+
"homepage": "https://github.com/vincemakes/kiso/tree/main/packages/server#readme"
|
|
47
|
+
}
|