@aistrike-dev/ui-shell-dev 2.0.3 → 2.1.1
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 +41 -17
- package/dist/args.d.ts +9 -1
- package/dist/args.js +51 -5
- package/dist/cell.d.ts +29 -0
- package/dist/cell.js +194 -0
- package/dist/cli.js +25 -1
- package/dist/devfile.d.ts +25 -9
- package/dist/devfile.js +75 -30
- package/dist/hosts.d.ts +17 -0
- package/dist/hosts.js +51 -0
- package/dist/inject.js +0 -3
- package/dist/platform/host.d.ts +5 -7
- package/dist/platform/host.js +4 -4
- package/dist/platform/list.d.ts +2 -2
- package/dist/platform/list.js +6 -5
- package/dist/platform/server.d.ts +3 -2
- package/dist/platform/server.js +21 -10
- package/dist/ports.d.ts +12 -0
- package/dist/ports.js +14 -0
- package/dist/preamble.d.ts +15 -0
- package/dist/preamble.js +44 -0
- package/dist/serve.d.ts +28 -0
- package/dist/serve.js +161 -0
- package/dist/token.d.ts +6 -1
- package/dist/token.js +2 -2
- package/dist/vite/plugin.d.ts +12 -3
- package/dist/vite/plugin.js +126 -27
- package/dist/vite/targets.d.ts +27 -0
- package/dist/vite/targets.js +35 -0
- package/package.json +2 -2
- package/platform/assistant/mock.js +1809 -222
- package/platform/assistant/ui/chunks/ui-CnxV0v5I.js +280 -0
- package/platform/assistant/ui/entry.js +2 -2
- package/platform/home/ui/chunks/ui-BJZEfOZI.js +174 -0
- package/platform/home/ui/entry.js +1 -1
- package/platform/settings/ui/chunks/ui-BRCsapmB.js +271 -0
- package/platform/settings/ui/entry.js +2 -2
- package/shell/assets/LabHost-D3o-OJsx.js +2 -0
- package/shell/assets/index-DW0Z5YeB.js +185 -0
- package/shell/index.html +1 -1
- package/platform/assistant/ui/chunks/ui-C9pWqM_D.js +0 -182
- package/platform/home/ui/chunks/ui-SNPmLvIE.js +0 -174
- package/platform/settings/ui/chunks/ui-C21hUUVD.js +0 -271
- package/shell/assets/LabHost-BiiVtiYJ.js +0 -2
- package/shell/assets/index-CqB5Mswt.js +0 -184
|
@@ -1,33 +1,351 @@
|
|
|
1
1
|
import { createHash, randomUUID } from "node:crypto";
|
|
2
|
-
//#region src/mock/
|
|
3
|
-
/**
|
|
4
|
-
var
|
|
5
|
-
/** The
|
|
6
|
-
function
|
|
7
|
-
const
|
|
2
|
+
//#region src/mock/http.ts
|
|
3
|
+
/** The sessions API's media type (session pane design 9); `application/json` alone gets the latest version. */
|
|
4
|
+
var MEDIA_TYPE = "application/vnd.aistrike.sessions+json; version=1.0";
|
|
5
|
+
/** The body's bytes, or `'too_large'` once they pass `cap`; the rest is read and dropped so the client can finish sending. */
|
|
6
|
+
async function readRaw(req, cap) {
|
|
7
|
+
const declared = Number(req.headers["content-length"]);
|
|
8
|
+
let over = Number.isFinite(declared) && declared > cap;
|
|
9
|
+
const chunks = [];
|
|
10
|
+
let size = 0;
|
|
11
|
+
for await (const chunk of req) {
|
|
12
|
+
if (over) continue;
|
|
13
|
+
const buffer = typeof chunk === "string" ? Buffer.from(chunk) : chunk;
|
|
14
|
+
size += buffer.length;
|
|
15
|
+
if (size > cap) {
|
|
16
|
+
over = true;
|
|
17
|
+
chunks.length = 0;
|
|
18
|
+
} else chunks.push(buffer);
|
|
19
|
+
}
|
|
20
|
+
return over ? "too_large" : Buffer.concat(chunks);
|
|
21
|
+
}
|
|
22
|
+
/** A JSON body; `undefined` when it does not parse. */
|
|
23
|
+
async function readJson(req, cap = 1048576) {
|
|
24
|
+
const raw = await readRaw(req, cap);
|
|
25
|
+
if (raw === "too_large") return;
|
|
26
|
+
try {
|
|
27
|
+
return JSON.parse(raw.toString("utf8"));
|
|
28
|
+
} catch {
|
|
29
|
+
return;
|
|
30
|
+
}
|
|
31
|
+
}
|
|
32
|
+
/** The client may read these on a cross-origin response: the dev servers answer CORS but expose nothing by default. */
|
|
33
|
+
var EXPOSED = "ETag, Location, Content-Disposition";
|
|
34
|
+
function sendJson(res, status, body, contentType = "application/json") {
|
|
35
|
+
res.statusCode = status;
|
|
36
|
+
res.setHeader("Content-Type", contentType);
|
|
37
|
+
res.setHeader("Access-Control-Expose-Headers", EXPOSED);
|
|
38
|
+
res.end(JSON.stringify(body));
|
|
39
|
+
}
|
|
40
|
+
/** An error in the API's envelope: `errors: [{ id, message, referenceId }]` and no `data`. */
|
|
41
|
+
function refuse(res, status, id, message) {
|
|
42
|
+
sendJson(res, status, { errors: [{
|
|
43
|
+
id,
|
|
44
|
+
message,
|
|
45
|
+
referenceId: randomUUID()
|
|
46
|
+
}] });
|
|
47
|
+
}
|
|
48
|
+
/** The vendor type when the request asked for it, else plain JSON. */
|
|
49
|
+
function contentTypeFor(req) {
|
|
50
|
+
return /application\/vnd\.aistrike\.sessions\+json/i.test(req.headers.accept ?? "") ? MEDIA_TYPE : "application/json";
|
|
51
|
+
}
|
|
52
|
+
/** A JSON answer with an `ETag` over its bytes; `304` when the client already holds it. */
|
|
53
|
+
function sendTagged(req, res, status, body) {
|
|
54
|
+
const text = JSON.stringify(body);
|
|
55
|
+
const etag = `"${createHash("sha256").update(text).digest("hex").slice(0, 32)}"`;
|
|
56
|
+
res.setHeader("ETag", etag);
|
|
57
|
+
res.setHeader("Access-Control-Expose-Headers", EXPOSED);
|
|
58
|
+
if (status === 200 && req.headers["if-none-match"] === etag) {
|
|
59
|
+
res.statusCode = 304;
|
|
60
|
+
res.end();
|
|
61
|
+
return;
|
|
62
|
+
}
|
|
63
|
+
res.statusCode = status;
|
|
64
|
+
res.setHeader("Content-Type", contentTypeFor(req));
|
|
65
|
+
res.end(text);
|
|
66
|
+
}
|
|
67
|
+
/** Bytes as a download: their type, `content-disposition: attachment`, and no sniffing. */
|
|
68
|
+
function sendBytes(res, bytes, type, disposition) {
|
|
69
|
+
res.statusCode = 200;
|
|
70
|
+
res.setHeader("Content-Type", type);
|
|
71
|
+
res.setHeader("Content-Length", String(bytes.length));
|
|
72
|
+
res.setHeader("Content-Disposition", disposition);
|
|
73
|
+
res.setHeader("X-Content-Type-Options", "nosniff");
|
|
74
|
+
res.setHeader("Access-Control-Expose-Headers", EXPOSED);
|
|
75
|
+
res.end(bytes);
|
|
76
|
+
}
|
|
77
|
+
var pause = async (ms) => new Promise((resolve) => {
|
|
78
|
+
setTimeout(resolve, ms);
|
|
79
|
+
});
|
|
80
|
+
//#endregion
|
|
81
|
+
//#region src/mock/access.ts
|
|
82
|
+
var isObject = (value) => typeof value === "object" && value !== null && !Array.isArray(value);
|
|
83
|
+
var MODES = /* @__PURE__ */ new Set([
|
|
84
|
+
"auto",
|
|
85
|
+
"ask",
|
|
86
|
+
"plan",
|
|
87
|
+
"agent"
|
|
88
|
+
]);
|
|
89
|
+
var isSkillRef = (value) => value === void 0 || isObject(value) && typeof value.id === "string";
|
|
90
|
+
var isIdList = (value) => value === void 0 || Array.isArray(value) && value.every((id) => typeof id === "string");
|
|
91
|
+
function isInput(value) {
|
|
92
|
+
if (!isObject(value)) return false;
|
|
93
|
+
switch (value.kind) {
|
|
94
|
+
case "message": return typeof value.text === "string" && Array.isArray(value.refs) && isSkillRef(value.skill) && isIdList(value.attachments);
|
|
95
|
+
case "page_tool_results": return Array.isArray(value.results) && value.results.every((result) => isObject(result) && typeof result.id === "string");
|
|
96
|
+
case "approval": return typeof value.id === "string" && typeof value.approve === "boolean";
|
|
97
|
+
default: return false;
|
|
98
|
+
}
|
|
99
|
+
}
|
|
100
|
+
/** Enough of the context's shape for the responder to read it without throwing. */
|
|
101
|
+
function isContext(value) {
|
|
102
|
+
if (!isObject(value)) return false;
|
|
103
|
+
const { route, title, screen, domains, pageTools, suggestions } = value;
|
|
104
|
+
const screenOk = screen === null || isObject(screen) && Array.isArray(screen.entities) && Array.isArray(screen.selection);
|
|
105
|
+
return isObject(route) && typeof title === "string" && screenOk && [
|
|
106
|
+
domains,
|
|
107
|
+
pageTools,
|
|
108
|
+
suggestions
|
|
109
|
+
].every((list) => Array.isArray(list));
|
|
110
|
+
}
|
|
111
|
+
/** `{ tenantIds: [...] }` with at least one id, or `{ all: true }`, and nothing else (authorization standard). */
|
|
112
|
+
function isTenantScope(value) {
|
|
113
|
+
if (!isObject(value)) return false;
|
|
114
|
+
const keys = Object.keys(value);
|
|
115
|
+
if (keys.length === 1 && value.all === true) return true;
|
|
116
|
+
const ids = value.tenantIds;
|
|
117
|
+
return keys.length === 1 && Array.isArray(ids) && ids.length > 0 && ids.every((id) => typeof id === "string" && id !== "") && new Set(ids).size === ids.length;
|
|
118
|
+
}
|
|
119
|
+
var isScope = (value) => isObject(value) && typeof value.orgId === "string" && value.orgId !== "" && isTenantScope(value.tenantScope);
|
|
120
|
+
var tenantsKey = (scope) => "all" in scope ? "*" : [...scope.tenantIds].sort().join("\n");
|
|
121
|
+
/** The same org and the same tenants, in any order. */
|
|
122
|
+
function sameScope(a, b) {
|
|
123
|
+
return a.orgId === b.orgId && tenantsKey(a.tenantScope) === tenantsKey(b.tenantScope);
|
|
124
|
+
}
|
|
125
|
+
var isRequest = (value) => isScope(value) && isObject(value) && MODES.has(value.mode) && isContext(value.context) && isInput(value.input);
|
|
126
|
+
/**
|
|
127
|
+
* The scope a request names in its query: `orgId` and one or more `tenantId`, or `orgId` and `all=true` (agent
|
|
128
|
+
* conversations API 5.3, open question 2). `null` when it names none; `'invalid'` when it names one badly.
|
|
129
|
+
*/
|
|
130
|
+
function queryScope(url) {
|
|
131
|
+
const orgId = url.searchParams.get("orgId");
|
|
132
|
+
const tenants = url.searchParams.getAll("tenantId");
|
|
133
|
+
const all = url.searchParams.get("all");
|
|
134
|
+
if (orgId === null && tenants.length === 0 && all === null) return null;
|
|
135
|
+
const scope = {
|
|
136
|
+
orgId,
|
|
137
|
+
tenantScope: all === "true" ? { all: true } : { tenantIds: tenants }
|
|
138
|
+
};
|
|
139
|
+
return (all === null || all === "true" && tenants.length === 0) && isScope(scope) ? scope : "invalid";
|
|
140
|
+
}
|
|
141
|
+
var SCOPE_RULE = "The scope is orgId with tenantId, or orgId with all=true";
|
|
142
|
+
/** Unknown, deleted, or in another scope: the same answer (agent conversations API 4). */
|
|
143
|
+
var notFound = (res) => refuse(res, 404, "session_not_found", "No such session");
|
|
144
|
+
/**
|
|
145
|
+
* A session the request may see: it exists, and the scope the query names, if it names one, is its own. Otherwise the
|
|
146
|
+
* answer is sent and the result is `null`.
|
|
147
|
+
*/
|
|
148
|
+
function lookup(url, id, res, sessions) {
|
|
149
|
+
const scope = queryScope(url);
|
|
150
|
+
if (scope === "invalid") {
|
|
151
|
+
refuse(res, 400, "invalid_request", SCOPE_RULE);
|
|
152
|
+
return null;
|
|
153
|
+
}
|
|
154
|
+
const session = sessions.get(id);
|
|
155
|
+
if (session === void 0 || scope !== null && !sameScope(session.scope, scope)) {
|
|
156
|
+
notFound(res);
|
|
157
|
+
return null;
|
|
158
|
+
}
|
|
159
|
+
return session;
|
|
160
|
+
}
|
|
161
|
+
//#endregion
|
|
162
|
+
//#region src/mock/multipart.ts
|
|
163
|
+
/** The boundary of a `multipart/form-data` content type, or `null` when it is not one or has none. */
|
|
164
|
+
function boundaryOf(contentType) {
|
|
165
|
+
if (contentType === void 0 || !/^multipart\/form-data\s*(;|$)/i.test(contentType.trim())) return null;
|
|
166
|
+
const match = /;\s*boundary=(?:"([^"]+)"|([^;\s]+))/i.exec(contentType);
|
|
167
|
+
const boundary = match?.[1] ?? match?.[2] ?? null;
|
|
168
|
+
return boundary !== null && boundary.length >= 1 && boundary.length <= 70 ? boundary : null;
|
|
169
|
+
}
|
|
170
|
+
/** A header parameter, quoted or not; `filename*` (RFC 5987, `UTF-8''…`) wins over `filename`. */
|
|
171
|
+
function parameter(header, name) {
|
|
172
|
+
const extended = new RegExp(`;\\s*${name}\\*\\s*=\\s*([^']*)'[^']*'([^;]*)`, "i").exec(header);
|
|
173
|
+
if (extended !== null && /^utf-8$/i.test(extended[1])) try {
|
|
174
|
+
return decodeURIComponent(extended[2].trim());
|
|
175
|
+
} catch {
|
|
176
|
+
return null;
|
|
177
|
+
}
|
|
178
|
+
const plain = new RegExp(`;\\s*${name}\\s*=\\s*(?:"((?:[^"\\\\]|\\\\.)*)"|([^;]*))`, "i").exec(header);
|
|
179
|
+
if (plain === null) return null;
|
|
180
|
+
return plain[1] === void 0 ? plain[2].trim() : plain[1].replace(/\\(.)/g, "$1");
|
|
181
|
+
}
|
|
182
|
+
function headersOf(block) {
|
|
183
|
+
const headers = {};
|
|
184
|
+
for (const line of block.split("\r\n")) {
|
|
185
|
+
const colon = line.indexOf(":");
|
|
186
|
+
if (colon > 0) headers[line.slice(0, colon).trim().toLowerCase()] = line.slice(colon + 1).trim();
|
|
187
|
+
}
|
|
188
|
+
return headers;
|
|
189
|
+
}
|
|
190
|
+
var CRLF = Buffer.of(13, 10);
|
|
191
|
+
var HEADERS_END = Buffer.concat([CRLF, CRLF]);
|
|
192
|
+
/** The part whose headers start at `at` (just past its delimiter's CRLF), and where it ends; `null` if malformed. */
|
|
193
|
+
function partAt(body, at, next) {
|
|
194
|
+
const headersEnd = body.indexOf(HEADERS_END, at);
|
|
195
|
+
if (headersEnd === -1) return null;
|
|
196
|
+
const headers = headersOf(body.subarray(at, headersEnd).toString("utf8"));
|
|
197
|
+
const dataStart = headersEnd + HEADERS_END.length;
|
|
198
|
+
const dataEnd = body.indexOf(next, dataStart);
|
|
199
|
+
const disposition = headers["content-disposition"] ?? "";
|
|
200
|
+
const name = /^form-data\b/i.test(disposition) ? parameter(disposition, "name") : null;
|
|
201
|
+
if (dataEnd === -1 || name === null) return null;
|
|
8
202
|
return {
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
203
|
+
part: {
|
|
204
|
+
name,
|
|
205
|
+
filename: parameter(disposition, "filename"),
|
|
206
|
+
contentType: headers["content-type"] ?? null,
|
|
207
|
+
data: body.subarray(dataStart, dataEnd)
|
|
208
|
+
},
|
|
209
|
+
end: dataEnd + next.length
|
|
210
|
+
};
|
|
211
|
+
}
|
|
212
|
+
/** Where the first part starts: past the first delimiter, which starts the body or follows a preamble's CRLF; -1 if none. */
|
|
213
|
+
function firstPart(body, delimiter, next) {
|
|
214
|
+
if (body.subarray(0, delimiter.length).equals(delimiter)) return delimiter.length;
|
|
215
|
+
const found = body.indexOf(next);
|
|
216
|
+
return found === -1 ? -1 : found + next.length;
|
|
217
|
+
}
|
|
218
|
+
/** The parts of a body, or `null` when it is not well-formed multipart with this boundary. */
|
|
219
|
+
function parseMultipart(body, boundary) {
|
|
220
|
+
const delimiter = Buffer.from(`--${boundary}`);
|
|
221
|
+
const next = Buffer.concat([CRLF, delimiter]);
|
|
222
|
+
let at = firstPart(body, delimiter, next);
|
|
223
|
+
const parts = [];
|
|
224
|
+
while (at !== -1) {
|
|
225
|
+
if (body.subarray(at, at + 2).toString("latin1") === "--") return parts;
|
|
226
|
+
while (body[at] === 32 || body[at] === 9) at += 1;
|
|
227
|
+
const found = body.subarray(at, at + 2).equals(CRLF) ? partAt(body, at + 2, next) : null;
|
|
228
|
+
if (found === null) return null;
|
|
229
|
+
parts.push(found.part);
|
|
230
|
+
at = found.end;
|
|
231
|
+
}
|
|
232
|
+
return null;
|
|
233
|
+
}
|
|
234
|
+
/**
|
|
235
|
+
* A file name fit to show and to put in a header: its last path segment, without control characters, at most 255
|
|
236
|
+
* characters; `file` when nothing is left. It is shown as text and never interpreted.
|
|
237
|
+
*/
|
|
238
|
+
function safeFileName(raw) {
|
|
239
|
+
const cleaned = ((raw ?? "").split(/[\\/]/).at(-1) ?? "").replace(/[\u0000-\u001f\u007f]/g, "").trim().slice(0, 255);
|
|
240
|
+
return cleaned === "" || cleaned === "." || cleaned === ".." ? "file" : cleaned;
|
|
241
|
+
}
|
|
242
|
+
/** `content-disposition: attachment` for a name: an ASCII fallback and the exact name in `filename*` (RFC 6266). */
|
|
243
|
+
function attachmentDisposition(name) {
|
|
244
|
+
return `attachment; filename="${name.replace(/[^\x20-\x7e]/g, "_").replace(/["\\]/g, "_")}"; filename*=UTF-8''${encodeURIComponent(name)}`;
|
|
245
|
+
}
|
|
246
|
+
//#endregion
|
|
247
|
+
//#region src/mock/files.ts
|
|
248
|
+
/** Limits (session pane design 9.4, 14). */
|
|
249
|
+
var MAX_FILE = 20971520;
|
|
250
|
+
var MAX_ATTACHMENTS = 100;
|
|
251
|
+
/** Refused to exercise the UI's `415`. */
|
|
252
|
+
var REFUSED_TYPES = /* @__PURE__ */ new Set(["application/x-msdownload"]);
|
|
253
|
+
var ONE_FILE = "The body is multipart/form-data with one file part";
|
|
254
|
+
var TOO_LARGE = "A file is at most 20 MiB";
|
|
255
|
+
/** The one `file` part of an upload, `'too_large'`, or `null` when the body is not one file part. */
|
|
256
|
+
async function fileOf(req, boundary) {
|
|
257
|
+
const raw = await readRaw(req, 21037056);
|
|
258
|
+
if (raw === "too_large") return raw;
|
|
259
|
+
const files = parseMultipart(raw, boundary)?.filter((part) => part.name === "file") ?? [];
|
|
260
|
+
if (files.length !== 1) return null;
|
|
261
|
+
return files[0].data.length > MAX_FILE ? "too_large" : files[0];
|
|
262
|
+
}
|
|
263
|
+
/** The part's type, as `type/subtype`; `application/octet-stream` when it has none or a malformed one. */
|
|
264
|
+
function mimeOf(part) {
|
|
265
|
+
const declared = (part.contentType ?? "").split(";")[0].trim().toLowerCase();
|
|
266
|
+
return /^[a-z0-9][\w.+-]*\/[\w.+-]+$/.test(declared) ? declared : "application/octet-stream";
|
|
267
|
+
}
|
|
268
|
+
/** `POST /api/sessions/{id}/attachments`: one `file` part, kept in memory only (9.8). */
|
|
269
|
+
async function upload(req, res, session) {
|
|
270
|
+
if (session.attachments.size >= MAX_ATTACHMENTS) {
|
|
271
|
+
refuse(res, 409, "too_many_attachments", `A session holds at most ${MAX_ATTACHMENTS} attachments`);
|
|
272
|
+
return;
|
|
273
|
+
}
|
|
274
|
+
const boundary = boundaryOf(req.headers["content-type"]);
|
|
275
|
+
const file = boundary === null ? null : await fileOf(req, boundary);
|
|
276
|
+
if (file === null || file === "too_large") {
|
|
277
|
+
refuse(res, file === null ? 400 : 413, file === null ? "invalid_request" : "request_too_large", file === null ? ONE_FILE : TOO_LARGE);
|
|
278
|
+
return;
|
|
279
|
+
}
|
|
280
|
+
const mime = mimeOf(file);
|
|
281
|
+
if (REFUSED_TYPES.has(mime)) {
|
|
282
|
+
refuse(res, 415, "unsupported_media_type", `${mime} is not accepted`);
|
|
283
|
+
return;
|
|
284
|
+
}
|
|
285
|
+
if (session.deleted) {
|
|
286
|
+
notFound(res);
|
|
287
|
+
return;
|
|
288
|
+
}
|
|
289
|
+
const id = `att_${randomUUID().replace(/-/g, "")}`;
|
|
290
|
+
const attachment = {
|
|
291
|
+
id,
|
|
292
|
+
name: safeFileName(file.filename),
|
|
293
|
+
mime,
|
|
294
|
+
size: file.data.length,
|
|
295
|
+
createdAt: (/* @__PURE__ */ new Date()).toISOString(),
|
|
296
|
+
url: `sessions/${session.id}/attachments/${id}`
|
|
30
297
|
};
|
|
298
|
+
session.attachments.set(id, {
|
|
299
|
+
attachment,
|
|
300
|
+
bytes: Buffer.from(file.data),
|
|
301
|
+
usedBy: null
|
|
302
|
+
});
|
|
303
|
+
res.setHeader("Location", `/api/${attachment.url}`);
|
|
304
|
+
sendJson(res, 201, { data: attachment }, contentTypeFor(req));
|
|
305
|
+
}
|
|
306
|
+
/** `GET` (the bytes, as a download) and `DELETE` (until a turn sends it) of one attachment. */
|
|
307
|
+
function attachmentRoute(req, res, session, attachmentId) {
|
|
308
|
+
const stored = session.attachments.get(attachmentId);
|
|
309
|
+
if (stored === void 0) {
|
|
310
|
+
refuse(res, 404, "not_found", "No such attachment");
|
|
311
|
+
return;
|
|
312
|
+
}
|
|
313
|
+
if (req.method === "GET") {
|
|
314
|
+
sendBytes(res, stored.bytes, stored.attachment.mime, attachmentDisposition(stored.attachment.name));
|
|
315
|
+
return;
|
|
316
|
+
}
|
|
317
|
+
if (stored.usedBy !== null) {
|
|
318
|
+
refuse(res, 409, "attachment_in_use", "A turn has sent this attachment");
|
|
319
|
+
return;
|
|
320
|
+
}
|
|
321
|
+
session.attachments.delete(attachmentId);
|
|
322
|
+
sendJson(res, 200, { data: { id: attachmentId } }, contentTypeFor(req));
|
|
323
|
+
}
|
|
324
|
+
/** One CSV cell (RFC 4180); text a spreadsheet would read as a formula is prefixed with `'`. */
|
|
325
|
+
function cell(value) {
|
|
326
|
+
if (value === null || value === void 0) return "";
|
|
327
|
+
let text = typeof value === "string" ? value : JSON.stringify(value);
|
|
328
|
+
if (typeof value === "string" && /^[=+\-@\t\r]/.test(text)) text = `'${text}`;
|
|
329
|
+
return /[",\r\n]/.test(text) ? `"${text.replace(/"/g, "\"\"")}"` : text;
|
|
330
|
+
}
|
|
331
|
+
/** `GET /api/sessions/{id}/artifacts/{artifactId}` (9.5): a table as CSV, an image's or a file's bytes. */
|
|
332
|
+
function artifactRoute(res, session, artifactId) {
|
|
333
|
+
const found = session.artifacts.find((candidate) => candidate.id === artifactId);
|
|
334
|
+
if (found === void 0) {
|
|
335
|
+
refuse(res, 404, "not_found", "No such artifact");
|
|
336
|
+
return;
|
|
337
|
+
}
|
|
338
|
+
if (found.kind === "table") {
|
|
339
|
+
const lines = [found.columns.map((column) => cell(column.label)).join(",")].concat(found.rows.map((row) => found.columns.map((column) => cell(row[column.id])).join(",")));
|
|
340
|
+
sendBytes(res, Buffer.from(`${lines.join("\r\n")}\r\n`), "text/csv; charset=utf-8", attachmentDisposition(`${safeFileName(found.title)}.csv`));
|
|
341
|
+
return;
|
|
342
|
+
}
|
|
343
|
+
const bytes = session.artifactBytes.get(artifactId);
|
|
344
|
+
if ((found.kind === "image" || found.kind === "file") && bytes !== void 0) {
|
|
345
|
+
sendBytes(res, bytes, found.mime, attachmentDisposition(safeFileName(found.title)));
|
|
346
|
+
return;
|
|
347
|
+
}
|
|
348
|
+
refuse(res, 400, "invalid_request", "Only table, image and file artifacts have content to fetch");
|
|
31
349
|
}
|
|
32
350
|
//#endregion
|
|
33
351
|
//#region ../../packages/sdk/dist/assistant/refs.js
|
|
@@ -201,7 +519,46 @@ var SEED_PROMPTS = [{
|
|
|
201
519
|
tool: "search_alerts",
|
|
202
520
|
input: { query: "credential dumping" }
|
|
203
521
|
}]
|
|
522
|
+
}, {
|
|
523
|
+
domainId: "lab",
|
|
524
|
+
id: "alerts-by-host",
|
|
525
|
+
text: "Alerts by host",
|
|
526
|
+
label: "Alerts by host",
|
|
527
|
+
expects: []
|
|
204
528
|
}];
|
|
529
|
+
/** Alert counts per host over a day, for the chart and the table the mock answers "Alerts by host" with. Synthetic. */
|
|
530
|
+
var HOSTS = [
|
|
531
|
+
{
|
|
532
|
+
host: "web-01",
|
|
533
|
+
alerts: 42,
|
|
534
|
+
critical: 3
|
|
535
|
+
},
|
|
536
|
+
{
|
|
537
|
+
host: "db-02",
|
|
538
|
+
alerts: 27,
|
|
539
|
+
critical: 1
|
|
540
|
+
},
|
|
541
|
+
{
|
|
542
|
+
host: "build-07",
|
|
543
|
+
alerts: 19,
|
|
544
|
+
critical: 0
|
|
545
|
+
},
|
|
546
|
+
{
|
|
547
|
+
host: "vpn-gw-1",
|
|
548
|
+
alerts: 14,
|
|
549
|
+
critical: 2
|
|
550
|
+
},
|
|
551
|
+
{
|
|
552
|
+
host: "mail-03",
|
|
553
|
+
alerts: 9,
|
|
554
|
+
critical: 0
|
|
555
|
+
},
|
|
556
|
+
{
|
|
557
|
+
host: "hr-laptop-12",
|
|
558
|
+
alerts: 5,
|
|
559
|
+
critical: 1
|
|
560
|
+
}
|
|
561
|
+
];
|
|
205
562
|
var SEED_SKILLS = [{
|
|
206
563
|
id: "triage-alert",
|
|
207
564
|
title: "Triage an alert",
|
|
@@ -400,7 +757,7 @@ var CHANGE = /\b(suppress|select|highlight|close|assign|delete|disable)\b/i;
|
|
|
400
757
|
* Runs the calls: its own at once, reported as steps, then what they found; the page's asked of the UI, whose results
|
|
401
758
|
* close the turn.
|
|
402
759
|
*/
|
|
403
|
-
function run(calls, refs,
|
|
760
|
+
function run(calls, refs, session) {
|
|
404
761
|
const out = [];
|
|
405
762
|
const lines = [];
|
|
406
763
|
const asked = [];
|
|
@@ -436,12 +793,13 @@ function run(calls, refs, conversation) {
|
|
|
436
793
|
type: "done",
|
|
437
794
|
stop: "end"
|
|
438
795
|
}];
|
|
439
|
-
|
|
796
|
+
session.pending = {
|
|
440
797
|
kind: "page_tool",
|
|
441
798
|
calls: calls.filter((c) => c.tool.kind === "page").map((c) => {
|
|
442
799
|
return {
|
|
443
800
|
id: c.id,
|
|
444
|
-
tool: qualified(c.tool)
|
|
801
|
+
tool: qualified(c.tool),
|
|
802
|
+
input: c.input
|
|
445
803
|
};
|
|
446
804
|
})
|
|
447
805
|
};
|
|
@@ -451,7 +809,7 @@ function run(calls, refs, conversation) {
|
|
|
451
809
|
}];
|
|
452
810
|
}
|
|
453
811
|
/** Holds the calls for the person's approval: each as a step that waits, then the approval itself. */
|
|
454
|
-
function propose(calls,
|
|
812
|
+
function propose(calls, session) {
|
|
455
813
|
const id = nextId();
|
|
456
814
|
const steps = calls.map((c) => {
|
|
457
815
|
return {
|
|
@@ -462,7 +820,7 @@ function propose(calls, conversation) {
|
|
|
462
820
|
state: "awaiting-approval"
|
|
463
821
|
};
|
|
464
822
|
});
|
|
465
|
-
|
|
823
|
+
session.pending = {
|
|
466
824
|
kind: "approval",
|
|
467
825
|
id,
|
|
468
826
|
calls: calls.map((c) => {
|
|
@@ -499,13 +857,13 @@ function rules(user, context, tools) {
|
|
|
499
857
|
* The mode's say on what the rules decided (plan decision 2): Ask refuses a change in text, Plan holds a write for
|
|
500
858
|
* approval, Agent runs it, and Auto runs one change and plans more than one.
|
|
501
859
|
*/
|
|
502
|
-
function actOn(decided, mode, user,
|
|
860
|
+
function actOn(decided, mode, user, session) {
|
|
503
861
|
if ("text" in decided) return [modeEvent(mode, actingFor(mode, decided.acting, 0), decided.reason), ...say(decided.text)];
|
|
504
862
|
const changes = decided.calls.filter((c) => c.tool.effect !== "read").length;
|
|
505
863
|
const acts = actingFor(mode, decided.acting, changes);
|
|
506
864
|
const head = modeEvent(mode, acts, acts === decided.acting ? decided.reason : "more than one change, so a plan first");
|
|
507
|
-
if (changes === 0 || acts === "agent") return [head, ...run(decided.calls, user.refs,
|
|
508
|
-
return acts === "plan" ? [head, ...propose(decided.calls,
|
|
865
|
+
if (changes === 0 || acts === "agent") return [head, ...run(decided.calls, user.refs, session)];
|
|
866
|
+
return acts === "plan" ? [head, ...propose(decided.calls, session)] : [head, ...say(ASK_REFUSAL)];
|
|
509
867
|
}
|
|
510
868
|
/** A skill chosen by id: the scripted reply names it and echoes the argument, or names it alone for empty text. */
|
|
511
869
|
function useSkill(skill, user, mode) {
|
|
@@ -514,27 +872,161 @@ function useSkill(skill, user, mode) {
|
|
|
514
872
|
const reply = argument === "" ? `${title}. Nothing more was given, so I used the page as it is.` : `${title}: ${argument}…`;
|
|
515
873
|
return [modeEvent(mode, actingFor(mode, "ask", 0), QUESTION), ...say(`${reply} This is the mock's scripted reply; it runs no skill.`)];
|
|
516
874
|
}
|
|
875
|
+
/** The chart and the table of alerts by host, as the mock's answer and its seeded session both hold them. */
|
|
876
|
+
function hostChart(id) {
|
|
877
|
+
return {
|
|
878
|
+
id,
|
|
879
|
+
kind: "chart",
|
|
880
|
+
title: "Alerts by host",
|
|
881
|
+
config: {
|
|
882
|
+
title: "Alerts by host",
|
|
883
|
+
subtitle: "Last 24 hours",
|
|
884
|
+
chartType: "bar",
|
|
885
|
+
legend: {
|
|
886
|
+
show: true,
|
|
887
|
+
position: "bottom"
|
|
888
|
+
},
|
|
889
|
+
xAxes: [{
|
|
890
|
+
id: "host",
|
|
891
|
+
type: "category",
|
|
892
|
+
name: "Host"
|
|
893
|
+
}],
|
|
894
|
+
yAxes: [{
|
|
895
|
+
id: "alerts",
|
|
896
|
+
type: "value",
|
|
897
|
+
name: "Alerts",
|
|
898
|
+
min: 0
|
|
899
|
+
}],
|
|
900
|
+
series: [{
|
|
901
|
+
id: "alerts",
|
|
902
|
+
name: "Alerts",
|
|
903
|
+
fields: {
|
|
904
|
+
x: "host",
|
|
905
|
+
y: "alerts"
|
|
906
|
+
}
|
|
907
|
+
}, {
|
|
908
|
+
id: "critical",
|
|
909
|
+
name: "Critical",
|
|
910
|
+
fields: {
|
|
911
|
+
x: "host",
|
|
912
|
+
y: "critical"
|
|
913
|
+
}
|
|
914
|
+
}]
|
|
915
|
+
},
|
|
916
|
+
rows: HOSTS.map((row) => {
|
|
917
|
+
return { ...row };
|
|
918
|
+
})
|
|
919
|
+
};
|
|
920
|
+
}
|
|
921
|
+
function hostTable(sessionId, id) {
|
|
922
|
+
return {
|
|
923
|
+
id,
|
|
924
|
+
kind: "table",
|
|
925
|
+
title: "Alerts by host",
|
|
926
|
+
columns: [
|
|
927
|
+
{
|
|
928
|
+
id: "host",
|
|
929
|
+
label: "Host"
|
|
930
|
+
},
|
|
931
|
+
{
|
|
932
|
+
id: "alerts",
|
|
933
|
+
label: "Alerts"
|
|
934
|
+
},
|
|
935
|
+
{
|
|
936
|
+
id: "critical",
|
|
937
|
+
label: "Critical"
|
|
938
|
+
}
|
|
939
|
+
],
|
|
940
|
+
rows: HOSTS.map((row) => {
|
|
941
|
+
return { ...row };
|
|
942
|
+
}),
|
|
943
|
+
downloadUrl: `sessions/${sessionId}/artifacts/${id}`
|
|
944
|
+
};
|
|
945
|
+
}
|
|
946
|
+
var ALERTS_BY_HOST = /\balerts by host\b/i;
|
|
947
|
+
var CRITICAL_ALERTS = /\bshow critical alerts\b/i;
|
|
948
|
+
var MY_APPEARANCE = /\bmy appearance\b/i;
|
|
949
|
+
/** "Alerts by host": a chart and a table of the same counts (session pane design 10). */
|
|
950
|
+
function chartingHosts(mode, session) {
|
|
951
|
+
const [top] = [...HOSTS].sort((a, b) => b.alerts - a.alerts);
|
|
952
|
+
return [
|
|
953
|
+
modeEvent(mode, actingFor(mode, "ask", 0), QUESTION),
|
|
954
|
+
...speak(`${top.host} raised the most alerts in the last 24 hours (${top.alerts}). The chart and the table hold the same counts.`),
|
|
955
|
+
{
|
|
956
|
+
type: "artifact",
|
|
957
|
+
...hostChart(nextId())
|
|
958
|
+
},
|
|
959
|
+
{
|
|
960
|
+
type: "artifact",
|
|
961
|
+
...hostTable(session.id, nextId())
|
|
962
|
+
},
|
|
963
|
+
{
|
|
964
|
+
type: "done",
|
|
965
|
+
stop: "end"
|
|
966
|
+
}
|
|
967
|
+
];
|
|
968
|
+
}
|
|
969
|
+
/** "Show critical alerts": a view the person can open in the UI. */
|
|
970
|
+
function showingCritical(mode) {
|
|
971
|
+
return [
|
|
972
|
+
modeEvent(mode, actingFor(mode, "ask", 0), QUESTION),
|
|
973
|
+
...speak("3 alerts are critical. Open them in Alerts to triage them."),
|
|
974
|
+
{
|
|
975
|
+
type: "view",
|
|
976
|
+
id: nextId(),
|
|
977
|
+
route: "/lab/alerts?severity=critical",
|
|
978
|
+
label: "Open Alerts filtered to critical",
|
|
979
|
+
reason: "The critical alerts you asked for"
|
|
980
|
+
},
|
|
981
|
+
{
|
|
982
|
+
type: "done",
|
|
983
|
+
stop: "end"
|
|
984
|
+
}
|
|
985
|
+
];
|
|
986
|
+
}
|
|
987
|
+
/** "Open my appearance": a view of a page every dev host serves, so View in UI can succeed there. */
|
|
988
|
+
function showingAppearance(mode) {
|
|
989
|
+
return [
|
|
990
|
+
modeEvent(mode, actingFor(mode, "ask", 0), QUESTION),
|
|
991
|
+
...speak("Your appearance settings hold your theme and interaction mode. Open them to review."),
|
|
992
|
+
{
|
|
993
|
+
type: "view",
|
|
994
|
+
id: nextId(),
|
|
995
|
+
route: "/settings/appearance",
|
|
996
|
+
label: "Open your appearance settings",
|
|
997
|
+
reason: "The page you asked for"
|
|
998
|
+
},
|
|
999
|
+
{
|
|
1000
|
+
type: "done",
|
|
1001
|
+
stop: "end"
|
|
1002
|
+
}
|
|
1003
|
+
];
|
|
1004
|
+
}
|
|
517
1005
|
/** The first answer to a message: the rules, in order, then the mode's say on what they decided. */
|
|
518
|
-
function answer(request, user,
|
|
1006
|
+
function answer(request, user, session) {
|
|
519
1007
|
const { mode, context } = request;
|
|
520
1008
|
if (user.skill !== void 0) return useSkill(user.skill, user, mode);
|
|
1009
|
+
const asked = stripMarkup(user.text);
|
|
1010
|
+
if (ALERTS_BY_HOST.test(asked)) return chartingHosts(mode, session);
|
|
1011
|
+
if (CRITICAL_ALERTS.test(asked)) return showingCritical(mode);
|
|
1012
|
+
if (MY_APPEARANCE.test(asked)) return showingAppearance(mode);
|
|
521
1013
|
const tools = toolsOf(context);
|
|
522
1014
|
const suggested = following(user, context, tools);
|
|
523
1015
|
if (suggested === null && mode === "ask" && CHANGE.test(user.text)) return [modeEvent(mode, "ask", QUESTION), ...say(ASK_REFUSAL)];
|
|
524
|
-
return actOn(suggested ?? rules(user, context, tools), mode, user,
|
|
1016
|
+
return actOn(suggested ?? rules(user, context, tools), mode, user, session);
|
|
525
1017
|
}
|
|
526
|
-
function afterPageTools(results,
|
|
527
|
-
const pending =
|
|
1018
|
+
function afterPageTools(results, session) {
|
|
1019
|
+
const pending = session.pending;
|
|
528
1020
|
if (pending?.kind !== "page_tool") return {
|
|
529
1021
|
status: 409,
|
|
530
|
-
message: "No page tool is waiting in this
|
|
1022
|
+
message: "No page tool is waiting in this session"
|
|
531
1023
|
};
|
|
532
|
-
|
|
1024
|
+
session.pending = null;
|
|
533
1025
|
const tools = new Map(pending.calls.map((c) => [c.id, c.tool]));
|
|
534
1026
|
return say(results.map((r) => sentence(tools.get(r.id) ?? "tool", r.ok, r.result, r.error)).join("\n\n"));
|
|
535
1027
|
}
|
|
536
1028
|
/** Runs the approved calls with the tools offered now; one the page no longer offers fails as the shell would say. */
|
|
537
|
-
function approved(planned, context,
|
|
1029
|
+
function approved(planned, context, session) {
|
|
538
1030
|
const tools = toolsOf(context);
|
|
539
1031
|
const calls = [];
|
|
540
1032
|
const missing = [];
|
|
@@ -555,17 +1047,17 @@ function approved(planned, context, conversation) {
|
|
|
555
1047
|
input: input ?? {}
|
|
556
1048
|
});
|
|
557
1049
|
}
|
|
558
|
-
return [...missing, ...run(calls,
|
|
1050
|
+
return [...missing, ...run(calls, session.user?.refs ?? [], session)];
|
|
559
1051
|
}
|
|
560
|
-
function afterApproval(request, input,
|
|
561
|
-
const pending =
|
|
1052
|
+
function afterApproval(request, input, session) {
|
|
1053
|
+
const pending = session.pending;
|
|
562
1054
|
if (pending?.kind !== "approval" || pending.id !== input.id) return {
|
|
563
1055
|
status: 409,
|
|
564
|
-
message: `No approval ${input.id} is waiting in this
|
|
1056
|
+
message: `No approval ${input.id} is waiting in this session`
|
|
565
1057
|
};
|
|
566
|
-
|
|
567
|
-
|
|
568
|
-
if (input.approve) return approved(pending.calls, request.context,
|
|
1058
|
+
session.pending = null;
|
|
1059
|
+
session.context = request.context;
|
|
1060
|
+
if (input.approve) return approved(pending.calls, request.context, session);
|
|
569
1061
|
return [...pending.calls.map((c) => {
|
|
570
1062
|
return {
|
|
571
1063
|
type: "step",
|
|
@@ -577,18 +1069,84 @@ function afterApproval(request, input, conversation) {
|
|
|
577
1069
|
}), ...say("Understood, I left it alone.")];
|
|
578
1070
|
}
|
|
579
1071
|
/**
|
|
1072
|
+
* What the harness used for a turn: one of each kind, each running and then done, so the column fills in a demo. Memory
|
|
1073
|
+
* and a connector it pulled data through are sources; an agent and a workflow are actions.
|
|
1074
|
+
*/
|
|
1075
|
+
var RESOURCES = [
|
|
1076
|
+
{
|
|
1077
|
+
kind: "memory",
|
|
1078
|
+
use: "source",
|
|
1079
|
+
label: "Triage preferences",
|
|
1080
|
+
detail: "Read 3 notes"
|
|
1081
|
+
},
|
|
1082
|
+
{
|
|
1083
|
+
kind: "connector",
|
|
1084
|
+
use: "source",
|
|
1085
|
+
label: "Lab API",
|
|
1086
|
+
detail: "Searched alerts"
|
|
1087
|
+
},
|
|
1088
|
+
{
|
|
1089
|
+
kind: "agent",
|
|
1090
|
+
use: "action",
|
|
1091
|
+
label: "Alert triage",
|
|
1092
|
+
detail: "Ranked the alerts in view"
|
|
1093
|
+
},
|
|
1094
|
+
{
|
|
1095
|
+
kind: "workflow",
|
|
1096
|
+
use: "action",
|
|
1097
|
+
label: "Suppress duplicates",
|
|
1098
|
+
detail: "Dry run: nothing changed"
|
|
1099
|
+
}
|
|
1100
|
+
];
|
|
1101
|
+
function resources() {
|
|
1102
|
+
return RESOURCES.flatMap((resource) => {
|
|
1103
|
+
const id = nextId();
|
|
1104
|
+
return [{
|
|
1105
|
+
type: "resource",
|
|
1106
|
+
id,
|
|
1107
|
+
...resource,
|
|
1108
|
+
state: "running"
|
|
1109
|
+
}, {
|
|
1110
|
+
type: "resource",
|
|
1111
|
+
id,
|
|
1112
|
+
...resource,
|
|
1113
|
+
state: "done"
|
|
1114
|
+
}];
|
|
1115
|
+
});
|
|
1116
|
+
}
|
|
1117
|
+
/** A size as a person reads it: bytes, KB or MB, at 1024. */
|
|
1118
|
+
function sizeOf(bytes) {
|
|
1119
|
+
if (bytes < 1024) return `${bytes} B`;
|
|
1120
|
+
return bytes < 1048576 ? `${(bytes / 1024).toFixed(1)} KB` : `${(bytes / 1048576).toFixed(1)} MB`;
|
|
1121
|
+
}
|
|
1122
|
+
/** A message's attachments, named with their sizes, ahead of the rest of the answer. */
|
|
1123
|
+
function attached(input, session) {
|
|
1124
|
+
const files = (input.attachments ?? []).flatMap((id) => {
|
|
1125
|
+
const stored = session.attachments.get(id);
|
|
1126
|
+
return stored === void 0 ? [] : [`${stored.attachment.name} (${sizeOf(stored.attachment.size)})`];
|
|
1127
|
+
});
|
|
1128
|
+
if (files.length === 0) return [];
|
|
1129
|
+
return speak(`You attached ${files.length} ${files.length === 1 ? "file" : "files"}: ${files.join(", ")}.\n\n`);
|
|
1130
|
+
}
|
|
1131
|
+
/**
|
|
580
1132
|
* One request of a turn: a message starts the turn afresh, discarding anything it waited on; page tool results and an
|
|
581
|
-
* approval answer what the
|
|
1133
|
+
* approval answer what the session waits on, and anything else is a 409.
|
|
582
1134
|
*/
|
|
583
|
-
function respond(request,
|
|
1135
|
+
function respond(request, session) {
|
|
584
1136
|
const { input } = request;
|
|
585
1137
|
if (input.kind === "message") {
|
|
586
|
-
|
|
587
|
-
|
|
588
|
-
|
|
589
|
-
|
|
1138
|
+
session.user = input;
|
|
1139
|
+
session.context = request.context;
|
|
1140
|
+
session.pending = null;
|
|
1141
|
+
const [head, ...rest] = answer(request, input, session);
|
|
1142
|
+
return [
|
|
1143
|
+
head,
|
|
1144
|
+
...resources(),
|
|
1145
|
+
...attached(input, session),
|
|
1146
|
+
...rest
|
|
1147
|
+
];
|
|
590
1148
|
}
|
|
591
|
-
return input.kind === "page_tool_results" ? afterPageTools(input.results,
|
|
1149
|
+
return input.kind === "page_tool_results" ? afterPageTools(input.results, session) : afterApproval(request, input, session);
|
|
592
1150
|
}
|
|
593
1151
|
/**
|
|
594
1152
|
* `GET /api/prompts`: a domain's seeded prompts, without what they expect, which stays on the backend. In Ask, a
|
|
@@ -604,87 +1162,135 @@ function promptsFor(domainId, mode, prompts = SEED_PROMPTS) {
|
|
|
604
1162
|
};
|
|
605
1163
|
});
|
|
606
1164
|
}
|
|
607
|
-
|
|
608
|
-
|
|
609
|
-
|
|
610
|
-
|
|
611
|
-
|
|
612
|
-
|
|
613
|
-
|
|
614
|
-
|
|
615
|
-
|
|
616
|
-
|
|
1165
|
+
var createChannel = (retain = 500, lastEventId = 0) => {
|
|
1166
|
+
return {
|
|
1167
|
+
lastEventId,
|
|
1168
|
+
retained: [],
|
|
1169
|
+
subscribers: /* @__PURE__ */ new Set(),
|
|
1170
|
+
retain
|
|
1171
|
+
};
|
|
1172
|
+
};
|
|
1173
|
+
/** Publishes one event: the next id, kept in the ring, sent to every subscriber. */
|
|
1174
|
+
function publish(channel, type, data) {
|
|
1175
|
+
channel.lastEventId += 1;
|
|
1176
|
+
const event = {
|
|
1177
|
+
id: channel.lastEventId,
|
|
1178
|
+
type,
|
|
1179
|
+
data
|
|
1180
|
+
};
|
|
1181
|
+
channel.retained.push(event);
|
|
1182
|
+
if (channel.retained.length > channel.retain) channel.retained.splice(0, channel.retained.length - channel.retain);
|
|
1183
|
+
for (const subscriber of [...channel.subscribers]) subscriber.send(event);
|
|
1184
|
+
return event;
|
|
617
1185
|
}
|
|
618
|
-
|
|
619
|
-
|
|
620
|
-
|
|
621
|
-
|
|
622
|
-
|
|
623
|
-
"
|
|
624
|
-
]
|
|
625
|
-
|
|
626
|
-
|
|
627
|
-
|
|
628
|
-
switch (value.kind) {
|
|
629
|
-
case "message": return typeof value.text === "string" && Array.isArray(value.refs) && isSkillRef(value.skill);
|
|
630
|
-
case "page_tool_results": return Array.isArray(value.results) && value.results.every((result) => isObject(result) && typeof result.id === "string");
|
|
631
|
-
case "approval": return typeof value.id === "string" && typeof value.approve === "boolean";
|
|
632
|
-
default: return false;
|
|
633
|
-
}
|
|
1186
|
+
/**
|
|
1187
|
+
* The retained events after `after`, or `'expired'` when some of them are no longer held: `after` is older than the
|
|
1188
|
+
* ring reaches back, or newer than anything published (a mock restarted since the client last read it).
|
|
1189
|
+
*/
|
|
1190
|
+
function replay(channel, after) {
|
|
1191
|
+
if (after > channel.lastEventId) return "expired";
|
|
1192
|
+
if (after === channel.lastEventId) return [];
|
|
1193
|
+
const oldest = channel.retained[0]?.id;
|
|
1194
|
+
if (oldest === void 0 || after < oldest - 1) return "expired";
|
|
1195
|
+
return channel.retained.filter((event) => event.id > after);
|
|
634
1196
|
}
|
|
635
|
-
|
|
636
|
-
|
|
637
|
-
|
|
638
|
-
|
|
639
|
-
|
|
640
|
-
return isObject(route) && typeof title === "string" && screenOk && [
|
|
641
|
-
domains,
|
|
642
|
-
pageTools,
|
|
643
|
-
suggestions
|
|
644
|
-
].every((list) => Array.isArray(list));
|
|
1197
|
+
function subscribe(channel, subscriber) {
|
|
1198
|
+
channel.subscribers.add(subscriber);
|
|
1199
|
+
return () => {
|
|
1200
|
+
channel.subscribers.delete(subscriber);
|
|
1201
|
+
};
|
|
645
1202
|
}
|
|
646
|
-
/**
|
|
647
|
-
function
|
|
648
|
-
|
|
649
|
-
const
|
|
650
|
-
|
|
651
|
-
const ids = value.tenantIds;
|
|
652
|
-
return keys.length === 1 && Array.isArray(ids) && ids.length > 0 && ids.every((id) => typeof id === "string" && id !== "") && new Set(ids).size === ids.length;
|
|
1203
|
+
/** Publishes a last event, then ends every stream: the session was deleted or left the store. */
|
|
1204
|
+
function close(channel, type, data) {
|
|
1205
|
+
publish(channel, type, data);
|
|
1206
|
+
for (const subscriber of [...channel.subscribers]) subscriber.end();
|
|
1207
|
+
channel.subscribers.clear();
|
|
653
1208
|
}
|
|
654
|
-
|
|
655
|
-
|
|
656
|
-
|
|
657
|
-
const tenants = (scope) => "all" in scope ? "*" : [...scope.tenantIds].sort().join("\n");
|
|
658
|
-
return a.orgId === b.orgId && tenants(a.tenantScope) === tenants(b.tenantScope);
|
|
1209
|
+
/** One event as `text/event-stream` text; with no id, no `id:` line. */
|
|
1210
|
+
function frame(type, data, id) {
|
|
1211
|
+
return `${id === void 0 ? "" : `id: ${id}\n`}event: ${type}\ndata: ${JSON.stringify(data)}\n\n`;
|
|
659
1212
|
}
|
|
660
|
-
|
|
661
|
-
|
|
662
|
-
|
|
663
|
-
}
|
|
664
|
-
|
|
665
|
-
|
|
666
|
-
|
|
667
|
-
|
|
1213
|
+
//#endregion
|
|
1214
|
+
//#region src/mock/sessions.ts
|
|
1215
|
+
/** `ses_` and a UUID's 32 hex digits (the API's ids). */
|
|
1216
|
+
var newSessionId = () => `ses_${randomUUID().replace(/-/g, "")}`;
|
|
1217
|
+
var DELETED_REMEMBERED = 1e3;
|
|
1218
|
+
/** The mock's sessions, by id, in memory: at most `limit` (default 100). */
|
|
1219
|
+
function createSessions(limit = 100, newId = newSessionId) {
|
|
1220
|
+
const byId = /* @__PURE__ */ new Map();
|
|
1221
|
+
const deleted = /* @__PURE__ */ new Set();
|
|
1222
|
+
const forget = (session) => {
|
|
1223
|
+
session.deleted = true;
|
|
1224
|
+
session.busy = false;
|
|
1225
|
+
byId.delete(session.id);
|
|
1226
|
+
deleted.add(session.id);
|
|
1227
|
+
if (deleted.size > DELETED_REMEMBERED) {
|
|
1228
|
+
const first = deleted.values().next().value;
|
|
1229
|
+
if (first !== void 0) deleted.delete(first);
|
|
1230
|
+
}
|
|
1231
|
+
close(session.channel, "session.deleted", { id: session.id });
|
|
1232
|
+
};
|
|
1233
|
+
const evict = () => {
|
|
1234
|
+
while (byId.size > limit) {
|
|
1235
|
+
const oldest = [...byId.values()].filter((session) => !session.busy).reduce((found, session) => found === void 0 || session.updatedAt < found.updatedAt ? session : found, void 0);
|
|
1236
|
+
if (oldest === void 0) break;
|
|
1237
|
+
forget(oldest);
|
|
1238
|
+
}
|
|
1239
|
+
};
|
|
1240
|
+
return {
|
|
1241
|
+
get: (id) => byId.get(id),
|
|
1242
|
+
wasDeleted: (id) => deleted.has(id),
|
|
1243
|
+
all: () => [...byId.values()],
|
|
1244
|
+
create(scope, at = {}) {
|
|
1245
|
+
const now = (/* @__PURE__ */ new Date()).toISOString();
|
|
1246
|
+
const createdAt = at.createdAt ?? now;
|
|
1247
|
+
const session = {
|
|
1248
|
+
id: at.id ?? newId(),
|
|
1249
|
+
scope,
|
|
1250
|
+
title: null,
|
|
1251
|
+
createdAt,
|
|
1252
|
+
updatedAt: at.updatedAt ?? createdAt,
|
|
1253
|
+
archivedAt: null,
|
|
1254
|
+
busy: false,
|
|
1255
|
+
deleted: false,
|
|
1256
|
+
messages: [],
|
|
1257
|
+
artifacts: [],
|
|
1258
|
+
resources: [],
|
|
1259
|
+
views: [],
|
|
1260
|
+
attachments: /* @__PURE__ */ new Map(),
|
|
1261
|
+
artifactBytes: /* @__PURE__ */ new Map(),
|
|
1262
|
+
channel: createChannel(),
|
|
1263
|
+
turn: null,
|
|
1264
|
+
user: null,
|
|
1265
|
+
context: null,
|
|
1266
|
+
pending: null
|
|
1267
|
+
};
|
|
1268
|
+
byId.set(session.id, session);
|
|
1269
|
+
evict();
|
|
1270
|
+
return session;
|
|
1271
|
+
},
|
|
1272
|
+
remove(id) {
|
|
1273
|
+
const session = byId.get(id);
|
|
1274
|
+
if (session !== void 0) forget(session);
|
|
1275
|
+
return session;
|
|
1276
|
+
}
|
|
1277
|
+
};
|
|
668
1278
|
}
|
|
669
|
-
/**
|
|
670
|
-
function
|
|
671
|
-
|
|
672
|
-
|
|
673
|
-
|
|
674
|
-
referenceId: randomUUID()
|
|
675
|
-
}] });
|
|
1279
|
+
/** Marks a change: `updatedAt` moves to now, and always forward, so a precondition read before it never matches after. */
|
|
1280
|
+
function touch(session) {
|
|
1281
|
+
const next = Math.max(Date.now(), Date.parse(session.updatedAt) + 1);
|
|
1282
|
+
session.updatedAt = new Date(next).toISOString();
|
|
1283
|
+
return session.updatedAt;
|
|
676
1284
|
}
|
|
677
|
-
|
|
678
|
-
|
|
679
|
-
|
|
680
|
-
|
|
681
|
-
let waiting = null;
|
|
682
|
-
if (pending?.kind === "approval") waiting = {
|
|
1285
|
+
/** What the session waits on from the UI, as the API states it. */
|
|
1286
|
+
function waitingOf(session) {
|
|
1287
|
+
const { pending } = session;
|
|
1288
|
+
if (pending?.kind === "approval") return {
|
|
683
1289
|
kind: "approval",
|
|
684
1290
|
id: pending.id,
|
|
685
1291
|
steps: pending.calls.map((call) => call.id)
|
|
686
1292
|
};
|
|
687
|
-
|
|
1293
|
+
if (pending?.kind === "page_tool") return {
|
|
688
1294
|
kind: "page_tool",
|
|
689
1295
|
calls: pending.calls.map((call) => {
|
|
690
1296
|
const [domainId, name] = call.tool.split("__");
|
|
@@ -692,89 +1298,681 @@ function resourceOf(conversation) {
|
|
|
692
1298
|
id: call.id,
|
|
693
1299
|
domainId,
|
|
694
1300
|
name,
|
|
695
|
-
input:
|
|
1301
|
+
input: call.input
|
|
696
1302
|
};
|
|
697
1303
|
})
|
|
698
1304
|
};
|
|
1305
|
+
return null;
|
|
1306
|
+
}
|
|
1307
|
+
function summaryOf(session) {
|
|
1308
|
+
return {
|
|
1309
|
+
id: session.id,
|
|
1310
|
+
title: session.title,
|
|
1311
|
+
createdAt: session.createdAt,
|
|
1312
|
+
updatedAt: session.updatedAt,
|
|
1313
|
+
archivedAt: session.archivedAt,
|
|
1314
|
+
orgId: session.scope.orgId,
|
|
1315
|
+
tenantScope: session.scope.tenantScope,
|
|
1316
|
+
lastEventId: session.channel.lastEventId
|
|
1317
|
+
};
|
|
1318
|
+
}
|
|
1319
|
+
var resourceOf = (session) => {
|
|
699
1320
|
return {
|
|
700
|
-
|
|
701
|
-
|
|
702
|
-
|
|
703
|
-
|
|
704
|
-
|
|
705
|
-
|
|
706
|
-
|
|
707
|
-
|
|
708
|
-
|
|
709
|
-
|
|
710
|
-
|
|
711
|
-
|
|
1321
|
+
...summaryOf(session),
|
|
1322
|
+
waiting: waitingOf(session)
|
|
1323
|
+
};
|
|
1324
|
+
};
|
|
1325
|
+
/** `GET sessions/{id}`: the session with its transcript and everything collected in it. */
|
|
1326
|
+
function transcriptOf(session) {
|
|
1327
|
+
const truncated = session.messages.length > 500;
|
|
1328
|
+
return {
|
|
1329
|
+
...resourceOf(session),
|
|
1330
|
+
messages: truncated ? session.messages.slice(-500) : session.messages,
|
|
1331
|
+
artifacts: session.artifacts,
|
|
1332
|
+
resources: session.resources,
|
|
1333
|
+
views: session.views,
|
|
1334
|
+
...truncated ? { truncated: true } : {}
|
|
1335
|
+
};
|
|
1336
|
+
}
|
|
1337
|
+
//#endregion
|
|
1338
|
+
//#region src/mock/sessionRoutes.ts
|
|
1339
|
+
var LIST_LIMIT = {
|
|
1340
|
+
default: 20,
|
|
1341
|
+
max: 100
|
|
1342
|
+
};
|
|
1343
|
+
var MAX_IDS = 8;
|
|
1344
|
+
var SORTS = ["updatedAt:desc", "createdAt:desc"];
|
|
1345
|
+
var ARCHIVED = [
|
|
1346
|
+
"false",
|
|
1347
|
+
"true",
|
|
1348
|
+
"any"
|
|
1349
|
+
];
|
|
1350
|
+
/** `POST /api/sessions`: a new session in the body's scope. */
|
|
1351
|
+
async function create(req, res, sessions) {
|
|
1352
|
+
const body = await readJson(req);
|
|
712
1353
|
if (!isScope(body)) {
|
|
713
1354
|
refuse(res, 400, "invalid_request", "The body must be { orgId, tenantScope }");
|
|
714
1355
|
return;
|
|
715
1356
|
}
|
|
716
|
-
const
|
|
1357
|
+
const session = sessions.create({
|
|
717
1358
|
orgId: body.orgId,
|
|
718
1359
|
tenantScope: body.tenantScope
|
|
719
1360
|
});
|
|
720
|
-
res.setHeader("Location", `/api/
|
|
721
|
-
|
|
1361
|
+
res.setHeader("Location", `/api/sessions/${session.id}`);
|
|
1362
|
+
sendTagged(req, res, 201, { data: resourceOf(session) });
|
|
1363
|
+
}
|
|
1364
|
+
/** A keyset cursor (REST standard): the sort and filters it was issued under, then the last item's sort key and id. */
|
|
1365
|
+
var cursorOf$1 = (query, key, id) => Buffer.from(JSON.stringify([
|
|
1366
|
+
query.sort,
|
|
1367
|
+
query.archived,
|
|
1368
|
+
`${query.scope.orgId}|${tenantsKey(query.scope.tenantScope)}`,
|
|
1369
|
+
key,
|
|
1370
|
+
id
|
|
1371
|
+
])).toString("base64url");
|
|
1372
|
+
/** The keyset in a cursor this API issued for the same sort, filter and scope; `null` for any other. */
|
|
1373
|
+
function keysetOf(cursor, query) {
|
|
1374
|
+
try {
|
|
1375
|
+
const parsed = JSON.parse(Buffer.from(cursor, "base64url").toString("utf8"));
|
|
1376
|
+
if (!Array.isArray(parsed) || parsed.length !== 5 || !parsed.every((part) => typeof part === "string")) return null;
|
|
1377
|
+
const [, , , key, id] = parsed;
|
|
1378
|
+
return cursorOf$1(query, key, id) === cursor ? {
|
|
1379
|
+
key,
|
|
1380
|
+
id
|
|
1381
|
+
} : null;
|
|
1382
|
+
} catch {
|
|
1383
|
+
return null;
|
|
1384
|
+
}
|
|
1385
|
+
}
|
|
1386
|
+
var oneOf = (allowed, value) => allowed.includes(value);
|
|
1387
|
+
/** The list's query, or `null` when any part of it is not one the API takes. */
|
|
1388
|
+
/** The page size: a whole number from 1 to the maximum, the default if none; 0 for anything else. */
|
|
1389
|
+
function limitOf$1(url) {
|
|
1390
|
+
const raw = url.searchParams.get("limit") ?? String(LIST_LIMIT.default);
|
|
1391
|
+
const limit = /^\d+$/.test(raw) ? Number(raw) : 0;
|
|
1392
|
+
return limit >= 1 && limit <= LIST_LIMIT.max ? limit : 0;
|
|
1393
|
+
}
|
|
1394
|
+
function listQuery(url, scope) {
|
|
1395
|
+
const limit = limitOf$1(url);
|
|
1396
|
+
const archived = url.searchParams.get("archived") ?? "false";
|
|
1397
|
+
const sort = url.searchParams.get("sort") ?? "updatedAt:desc";
|
|
1398
|
+
if (limit === 0 || !oneOf(ARCHIVED, archived) || !oneOf(SORTS, sort)) return null;
|
|
1399
|
+
const cursor = url.searchParams.get("cursor");
|
|
1400
|
+
const after = cursor === null ? null : keysetOf(cursor, {
|
|
1401
|
+
scope,
|
|
1402
|
+
sort,
|
|
1403
|
+
archived
|
|
1404
|
+
});
|
|
1405
|
+
return cursor !== null && after === null ? null : {
|
|
1406
|
+
scope,
|
|
1407
|
+
limit,
|
|
1408
|
+
sort,
|
|
1409
|
+
archived,
|
|
1410
|
+
after
|
|
1411
|
+
};
|
|
1412
|
+
}
|
|
1413
|
+
/** One page of the sessions the query selects, newest first, the id breaking ties. */
|
|
1414
|
+
function pageOf(sessions, query) {
|
|
1415
|
+
const { scope, sort, archived, after, limit } = query;
|
|
1416
|
+
const keyOf = (session) => sort === "createdAt:desc" ? session.createdAt : session.updatedAt;
|
|
1417
|
+
const kept = (session) => archived === "any" || archived === "true" === (session.archivedAt !== null);
|
|
1418
|
+
const beyond = (session) => after === null || keyOf(session) < after.key || keyOf(session) === after.key && session.id < after.id;
|
|
1419
|
+
const matches = sessions.all().filter((session) => sameScope(session.scope, scope) && kept(session)).sort((a, b) => keyOf(b).localeCompare(keyOf(a)) || b.id.localeCompare(a.id)).filter(beyond);
|
|
1420
|
+
const page = matches.slice(0, limit);
|
|
1421
|
+
const hasMore = matches.length > limit;
|
|
1422
|
+
const last = page.at(-1);
|
|
1423
|
+
const nextCursor = hasMore && last !== void 0 ? cursorOf$1(query, keyOf(last), last.id) : null;
|
|
1424
|
+
return {
|
|
1425
|
+
data: page.map(summaryOf),
|
|
1426
|
+
nextCursor,
|
|
1427
|
+
hasMore
|
|
1428
|
+
};
|
|
1429
|
+
}
|
|
1430
|
+
/** With `ids`: exactly those sessions' summaries (at most 8, in the order asked, whatever their archive state). */
|
|
1431
|
+
function byIds(res, ids, scope, sessions) {
|
|
1432
|
+
const wanted = ids.split(",").map((id) => id.trim());
|
|
1433
|
+
if (wanted.length > MAX_IDS || wanted.some((id) => id === "")) {
|
|
1434
|
+
refuse(res, 400, "invalid_request", `ids is 1 to ${MAX_IDS} comma-separated session ids`);
|
|
1435
|
+
return null;
|
|
1436
|
+
}
|
|
1437
|
+
return {
|
|
1438
|
+
data: [...new Set(wanted)].map((id) => sessions.get(id)).filter((session) => session !== void 0 && sameScope(session.scope, scope)).map(summaryOf),
|
|
1439
|
+
nextCursor: null,
|
|
1440
|
+
hasMore: false
|
|
1441
|
+
};
|
|
1442
|
+
}
|
|
1443
|
+
/** `GET /api/sessions`: the caller's sessions in the scope the query names, paged by a keyset cursor. */
|
|
1444
|
+
function list(req, res, url, sessions) {
|
|
1445
|
+
const scope = queryScope(url);
|
|
1446
|
+
if (scope === null || scope === "invalid") {
|
|
1447
|
+
refuse(res, 400, "invalid_request", SCOPE_RULE);
|
|
1448
|
+
return;
|
|
1449
|
+
}
|
|
1450
|
+
const ids = url.searchParams.get("ids");
|
|
1451
|
+
if (ids !== null) {
|
|
1452
|
+
const answer = byIds(res, ids, scope, sessions);
|
|
1453
|
+
if (answer !== null) sendTagged(req, res, 200, answer);
|
|
1454
|
+
return;
|
|
1455
|
+
}
|
|
1456
|
+
const query = listQuery(url, scope);
|
|
1457
|
+
if (query === null) {
|
|
1458
|
+
refuse(res, 400, "invalid_request", `${`limit is 1 to ${LIST_LIMIT.max}; archived is ${ARCHIVED.join(", ")}; sort is ${SORTS.join(" or ")}`}; cursor is one this API issued for the same query`);
|
|
1459
|
+
return;
|
|
1460
|
+
}
|
|
1461
|
+
sendTagged(req, res, 200, pageOf(sessions, query));
|
|
1462
|
+
}
|
|
1463
|
+
/** `GET /api/sessions/{id}`: the session, its transcript and what it collected. */
|
|
1464
|
+
function read(req, res, session) {
|
|
1465
|
+
sendTagged(req, res, 200, { data: transcriptOf(session) });
|
|
722
1466
|
}
|
|
723
|
-
|
|
1467
|
+
var PATCHABLE = /* @__PURE__ */ new Set(["title", "archived"]);
|
|
1468
|
+
/** A title of 1 to 80 characters once trimmed; `null` for anything else. */
|
|
1469
|
+
function titleOf$1(value) {
|
|
1470
|
+
const title = typeof value === "string" ? value.trim() : "";
|
|
1471
|
+
return title.length >= 1 && title.length <= 80 ? title : null;
|
|
1472
|
+
}
|
|
1473
|
+
/** A `PATCH` body: a title of 1 to 80 characters once trimmed, `archived`, or both; `null` for anything else. */
|
|
1474
|
+
function patchOf(body) {
|
|
1475
|
+
if (!isObject(body) || Object.keys(body).length === 0 || !Object.keys(body).every((key) => PATCHABLE.has(key))) return null;
|
|
1476
|
+
const patch = {};
|
|
1477
|
+
if ("title" in body) {
|
|
1478
|
+
const title = titleOf$1(body.title);
|
|
1479
|
+
if (title === null) return null;
|
|
1480
|
+
patch.title = title;
|
|
1481
|
+
}
|
|
1482
|
+
if ("archived" in body) {
|
|
1483
|
+
if (typeof body.archived !== "boolean") return null;
|
|
1484
|
+
patch.archived = body.archived;
|
|
1485
|
+
}
|
|
1486
|
+
return patch;
|
|
1487
|
+
}
|
|
1488
|
+
/** `PATCH /api/sessions/{id}`: rename or (un)archive, against the `updatedAt` the caller read (REST standard). */
|
|
1489
|
+
async function change(req, res, session) {
|
|
1490
|
+
const since = req.headers["if-unmodified-since"];
|
|
1491
|
+
if (since === void 0) {
|
|
1492
|
+
refuse(res, 428, "precondition_required", "Send if-unmodified-since with the updatedAt you read");
|
|
1493
|
+
return;
|
|
1494
|
+
}
|
|
1495
|
+
const patch = patchOf(await readJson(req));
|
|
1496
|
+
if (patch === null) {
|
|
1497
|
+
refuse(res, 400, "invalid_request", "The body is { title?: 1 to 80 characters, archived?: boolean }, with at least one");
|
|
1498
|
+
return;
|
|
1499
|
+
}
|
|
1500
|
+
if (session.deleted) {
|
|
1501
|
+
notFound(res);
|
|
1502
|
+
return;
|
|
1503
|
+
}
|
|
1504
|
+
const readAt = Date.parse(since);
|
|
1505
|
+
if (Number.isNaN(readAt) || readAt !== Date.parse(session.updatedAt)) {
|
|
1506
|
+
refuse(res, 412, "precondition_failed", "The session changed since you read it");
|
|
1507
|
+
return;
|
|
1508
|
+
}
|
|
1509
|
+
const updatedAt = touch(session);
|
|
1510
|
+
const changed = {};
|
|
1511
|
+
if (patch.title !== void 0) {
|
|
1512
|
+
session.title = patch.title;
|
|
1513
|
+
changed.title = patch.title;
|
|
1514
|
+
}
|
|
1515
|
+
if (patch.archived !== void 0) {
|
|
1516
|
+
session.archivedAt = patch.archived ? session.archivedAt ?? updatedAt : null;
|
|
1517
|
+
changed.archivedAt = session.archivedAt;
|
|
1518
|
+
}
|
|
1519
|
+
publish(session.channel, "session", {
|
|
1520
|
+
...changed,
|
|
1521
|
+
updatedAt
|
|
1522
|
+
});
|
|
1523
|
+
sendTagged(req, res, 200, { data: resourceOf(session) });
|
|
1524
|
+
}
|
|
1525
|
+
/** `DELETE /api/sessions/{id}`: gone from now on; a turn in progress stops, and every events stream ends. */
|
|
1526
|
+
function remove(req, res, session, sessions) {
|
|
1527
|
+
sessions.remove(session.id);
|
|
1528
|
+
sendJson(res, 200, { data: { id: session.id } }, contentTypeFor(req));
|
|
1529
|
+
}
|
|
1530
|
+
//#endregion
|
|
1531
|
+
//#region src/mock/stream.ts
|
|
1532
|
+
/** Where a stream starts: `Last-Event-ID` (a reconnect), else `after`; `null` for neither, `'invalid'` for a bad one. */
|
|
1533
|
+
function startOf(req, url) {
|
|
1534
|
+
const header = req.headers["last-event-id"];
|
|
1535
|
+
const raw = (typeof header === "string" ? header : void 0) ?? url.searchParams.get("after");
|
|
1536
|
+
if (raw === null) return null;
|
|
1537
|
+
return /^\d+$/.test(raw.trim()) ? Number(raw.trim()) : "invalid";
|
|
1538
|
+
}
|
|
1539
|
+
/**
|
|
1540
|
+
* `GET /api/sessions/{id}/events` (9.6): the session's events, long-lived. `after` (or `Last-Event-ID`, which wins)
|
|
1541
|
+
* replays what the ring still holds after it, then live events follow; with neither, live events only. `: ping` every
|
|
1542
|
+
* `pingMs`. A deleted session answers 410; an `after` the ring no longer reaches, 409 `events_expired`.
|
|
1543
|
+
*/
|
|
1544
|
+
function events(req, res, url, id, context) {
|
|
1545
|
+
if (context.sessions.wasDeleted(id)) {
|
|
1546
|
+
refuse(res, 410, "gone", "This session was deleted");
|
|
1547
|
+
return;
|
|
1548
|
+
}
|
|
1549
|
+
const session = lookup(url, id, res, context.sessions);
|
|
1550
|
+
if (session === null) return;
|
|
1551
|
+
const start = startOf(req, url);
|
|
1552
|
+
if (start === "invalid") {
|
|
1553
|
+
refuse(res, 400, "invalid_request", "after is an event id");
|
|
1554
|
+
return;
|
|
1555
|
+
}
|
|
1556
|
+
const replayed = replay(session.channel, start ?? session.channel.lastEventId);
|
|
1557
|
+
if (replayed === "expired") {
|
|
1558
|
+
refuse(res, 409, "events_expired", "Events after that id are no longer held; read the session again");
|
|
1559
|
+
return;
|
|
1560
|
+
}
|
|
1561
|
+
res.statusCode = 200;
|
|
1562
|
+
res.setHeader("Content-Type", "text/event-stream");
|
|
1563
|
+
res.setHeader("Cache-Control", "no-cache");
|
|
1564
|
+
res.flushHeaders();
|
|
1565
|
+
for (const event of replayed) res.write(frame(event.type, event.data, event.id));
|
|
1566
|
+
let open = true;
|
|
1567
|
+
const ping = setInterval(() => {
|
|
1568
|
+
res.write(": ping\n\n");
|
|
1569
|
+
}, context.pingMs);
|
|
1570
|
+
let unsubscribe = () => void 0;
|
|
1571
|
+
const cleanup = () => {
|
|
1572
|
+
if (open) {
|
|
1573
|
+
open = false;
|
|
1574
|
+
clearInterval(ping);
|
|
1575
|
+
unsubscribe();
|
|
1576
|
+
}
|
|
1577
|
+
};
|
|
1578
|
+
unsubscribe = subscribe(session.channel, {
|
|
1579
|
+
send(event) {
|
|
1580
|
+
res.write(frame(event.type, event.data, event.id));
|
|
1581
|
+
},
|
|
1582
|
+
end() {
|
|
1583
|
+
cleanup();
|
|
1584
|
+
res.end();
|
|
1585
|
+
}
|
|
1586
|
+
});
|
|
1587
|
+
res.on("close", cleanup);
|
|
1588
|
+
}
|
|
1589
|
+
//#endregion
|
|
1590
|
+
//#region src/mock/transcript.ts
|
|
1591
|
+
/** The turn's answer in the transcript, made on its first event: one assistant message per turn, across its requests. */
|
|
1592
|
+
function answerOf(session, turn) {
|
|
1593
|
+
const found = session.messages.find((message) => message.role === "assistant" && message.id === turn.answerId);
|
|
1594
|
+
if (found !== void 0) return found;
|
|
1595
|
+
const answer = {
|
|
1596
|
+
role: "assistant",
|
|
1597
|
+
id: `${turn.messageId}_a`,
|
|
1598
|
+
turnId: turn.id,
|
|
1599
|
+
createdAt: (/* @__PURE__ */ new Date()).toISOString(),
|
|
1600
|
+
blocks: []
|
|
1601
|
+
};
|
|
1602
|
+
turn.answerId = answer.id;
|
|
1603
|
+
session.messages.push(answer);
|
|
1604
|
+
return answer;
|
|
1605
|
+
}
|
|
1606
|
+
function upsertStep(answer, step) {
|
|
1607
|
+
const block = {
|
|
1608
|
+
kind: "step",
|
|
1609
|
+
...step
|
|
1610
|
+
};
|
|
1611
|
+
const index = answer.blocks.findIndex((candidate) => candidate.kind === "step" && candidate.id === step.id);
|
|
1612
|
+
if (index === -1) answer.blocks.push(block);
|
|
1613
|
+
else answer.blocks[index] = block;
|
|
1614
|
+
}
|
|
1615
|
+
function upsert(list, item) {
|
|
1616
|
+
const index = list.findIndex((candidate) => candidate.id === item.id);
|
|
1617
|
+
if (index === -1) list.push(item);
|
|
1618
|
+
else list[index] = item;
|
|
1619
|
+
}
|
|
1620
|
+
/** An event's data: everything but its type. */
|
|
1621
|
+
function bodyOf$1(event) {
|
|
1622
|
+
return Object.fromEntries(Object.entries(event).filter(([key]) => key !== "type"));
|
|
1623
|
+
}
|
|
1624
|
+
function appendText(answer, delta) {
|
|
1625
|
+
const last = answer.blocks.at(-1);
|
|
1626
|
+
if (last?.kind === "text") last.text += delta;
|
|
1627
|
+
else answer.blocks.push({
|
|
1628
|
+
kind: "text",
|
|
1629
|
+
text: delta
|
|
1630
|
+
});
|
|
1631
|
+
}
|
|
1632
|
+
function setMode(answer, event) {
|
|
1633
|
+
answer.acting = event.acting;
|
|
1634
|
+
if (event.reason === void 0) delete answer.reason;
|
|
1635
|
+
else answer.reason = event.reason;
|
|
1636
|
+
}
|
|
1637
|
+
/** A block that refers to an artifact or a view, once. */
|
|
1638
|
+
function refer(answer, kind, id) {
|
|
1639
|
+
if (!answer.blocks.some((block) => block.kind === kind && block.id === id)) answer.blocks.push({
|
|
1640
|
+
kind,
|
|
1641
|
+
id
|
|
1642
|
+
});
|
|
1643
|
+
}
|
|
1644
|
+
/** How an event that draws something changes the turn's answer, and the session's collections. */
|
|
1645
|
+
function draw(session, turn, answer, event) {
|
|
1646
|
+
switch (event.type) {
|
|
1647
|
+
case "text":
|
|
1648
|
+
appendText(answer, event.delta);
|
|
1649
|
+
return;
|
|
1650
|
+
case "mode":
|
|
1651
|
+
setMode(answer, event);
|
|
1652
|
+
return;
|
|
1653
|
+
case "step":
|
|
1654
|
+
upsertStep(answer, bodyOf$1(event));
|
|
1655
|
+
return;
|
|
1656
|
+
case "page_tool":
|
|
1657
|
+
upsertStep(answer, {
|
|
1658
|
+
id: event.id,
|
|
1659
|
+
tool: `${event.domainId}__${event.name}`,
|
|
1660
|
+
input: event.input,
|
|
1661
|
+
state: "running"
|
|
1662
|
+
});
|
|
1663
|
+
return;
|
|
1664
|
+
case "approval":
|
|
1665
|
+
answer.approval = {
|
|
1666
|
+
id: event.id,
|
|
1667
|
+
state: "proposed"
|
|
1668
|
+
};
|
|
1669
|
+
return;
|
|
1670
|
+
case "artifact":
|
|
1671
|
+
upsert(session.artifacts, {
|
|
1672
|
+
...bodyOf$1(event),
|
|
1673
|
+
turnId: turn.id
|
|
1674
|
+
});
|
|
1675
|
+
refer(answer, "artifact", event.id);
|
|
1676
|
+
return;
|
|
1677
|
+
default:
|
|
1678
|
+
upsert(session.views, {
|
|
1679
|
+
...bodyOf$1(event),
|
|
1680
|
+
turnId: turn.id
|
|
1681
|
+
});
|
|
1682
|
+
refer(answer, "view", event.id);
|
|
1683
|
+
}
|
|
1684
|
+
}
|
|
1685
|
+
/**
|
|
1686
|
+
* Applies one of a turn's events to the session's transcript and collections, so a session read back (9.1) holds what
|
|
1687
|
+
* the person saw: text, steps (a page tool the UI ran shows as a step), the approval, artifacts, views and resources.
|
|
1688
|
+
*/
|
|
1689
|
+
function record(session, turn, event) {
|
|
1690
|
+
if (event.type === "done") return;
|
|
1691
|
+
if (event.type === "resource") {
|
|
1692
|
+
upsert(session.resources, {
|
|
1693
|
+
...bodyOf$1(event),
|
|
1694
|
+
turnId: turn.id
|
|
1695
|
+
});
|
|
1696
|
+
return;
|
|
1697
|
+
}
|
|
1698
|
+
draw(session, turn, answerOf(session, turn), event);
|
|
1699
|
+
}
|
|
1700
|
+
/** The person's decision on the turn's plan, as the transcript keeps it. */
|
|
1701
|
+
function decide(session, turn, approve) {
|
|
1702
|
+
const answer = answerOf(session, turn);
|
|
1703
|
+
if (answer.approval !== void 0) answer.approval = {
|
|
1704
|
+
...answer.approval,
|
|
1705
|
+
state: approve ? "approved" : "declined"
|
|
1706
|
+
};
|
|
1707
|
+
}
|
|
1708
|
+
//#endregion
|
|
1709
|
+
//#region src/mock/turns.ts
|
|
1710
|
+
var MAX_MESSAGE_ATTACHMENTS = 10;
|
|
1711
|
+
var MAX_TURN_BODY = 1048576;
|
|
1712
|
+
var hexId = (prefix) => `${prefix}_${randomUUID().replace(/-/g, "")}`;
|
|
1713
|
+
/** Why a message's skill is refused, or `null`: it must be offered, and comes alone. */
|
|
724
1714
|
function skillRefusal(input) {
|
|
725
1715
|
if (input.kind !== "message" || input.skill === void 0) return null;
|
|
726
1716
|
if (input.suggestion !== void 0 || input.markup !== void 0) return "A skill does not come with a suggestion or markup";
|
|
727
1717
|
const { id } = input.skill;
|
|
728
1718
|
return SEED_SKILLS.some((skill) => skill.id === id) ? null : `Unknown skill ${id}`;
|
|
729
1719
|
}
|
|
730
|
-
/**
|
|
731
|
-
|
|
732
|
-
|
|
733
|
-
|
|
734
|
-
|
|
735
|
-
|
|
1720
|
+
/** Why a message's attachments are refused, or `null`: at most 10, each this session's (9.2). */
|
|
1721
|
+
function attachmentRefusal(input, session) {
|
|
1722
|
+
if (input.kind !== "message" || input.attachments === void 0) return null;
|
|
1723
|
+
if (input.attachments.length > MAX_MESSAGE_ATTACHMENTS) return `At most ${MAX_MESSAGE_ATTACHMENTS} attachments per message`;
|
|
1724
|
+
const unknown = input.attachments.find((id) => !session.attachments.has(id));
|
|
1725
|
+
return unknown === void 0 ? null : `No attachment ${unknown} in this session`;
|
|
1726
|
+
}
|
|
1727
|
+
async function bodyOf(req) {
|
|
1728
|
+
const raw = await readRaw(req, MAX_TURN_BODY);
|
|
1729
|
+
if (raw === "too_large") return raw;
|
|
1730
|
+
try {
|
|
1731
|
+
return JSON.parse(raw.toString("utf8"));
|
|
1732
|
+
} catch {
|
|
1733
|
+
return;
|
|
1734
|
+
}
|
|
1735
|
+
}
|
|
1736
|
+
/** The request and its session when the turn may run; otherwise the refusal is sent and the result is `null`. */
|
|
1737
|
+
async function admit(req, res, id, context) {
|
|
1738
|
+
const body = await bodyOf(req);
|
|
1739
|
+
if (body === "too_large") {
|
|
1740
|
+
refuse(res, 413, "request_too_large", "The turn is over 1 MiB");
|
|
1741
|
+
return null;
|
|
1742
|
+
}
|
|
736
1743
|
if (!isRequest(body)) {
|
|
737
1744
|
refuse(res, 400, "invalid_request", "The body is not a turn request");
|
|
738
|
-
return;
|
|
1745
|
+
return null;
|
|
739
1746
|
}
|
|
740
|
-
const
|
|
741
|
-
if (
|
|
1747
|
+
const session = context.sessions.get(id);
|
|
1748
|
+
if (session === void 0 || !sameScope(session.scope, body)) {
|
|
742
1749
|
notFound(res);
|
|
743
|
-
return;
|
|
1750
|
+
return null;
|
|
744
1751
|
}
|
|
745
|
-
const refusal = skillRefusal(body.input);
|
|
1752
|
+
const refusal = skillRefusal(body.input) ?? attachmentRefusal(body.input, session);
|
|
746
1753
|
if (refusal !== null) {
|
|
747
1754
|
refuse(res, 400, "invalid_request", refusal);
|
|
748
|
-
return;
|
|
1755
|
+
return null;
|
|
749
1756
|
}
|
|
750
|
-
if (
|
|
751
|
-
refuse(res, 409, "turn_in_progress", "A turn is in progress on this
|
|
752
|
-
return;
|
|
1757
|
+
if (session.busy) {
|
|
1758
|
+
refuse(res, 409, "turn_in_progress", "A turn is in progress on this session");
|
|
1759
|
+
return null;
|
|
753
1760
|
}
|
|
754
|
-
|
|
755
|
-
|
|
756
|
-
|
|
757
|
-
|
|
758
|
-
|
|
759
|
-
|
|
760
|
-
|
|
1761
|
+
return {
|
|
1762
|
+
session,
|
|
1763
|
+
body
|
|
1764
|
+
};
|
|
1765
|
+
}
|
|
1766
|
+
/** A title from the first message: its text as a person reads it, else its skill or its files; at most 80 characters. */
|
|
1767
|
+
function titleOf(input, session) {
|
|
1768
|
+
const skill = input.skill === void 0 ? void 0 : SEED_SKILLS.find((seeded) => seeded.id === input.skill?.id)?.title;
|
|
1769
|
+
const text = stripMarkup(input.text).replace(/\s+/g, " ").trim();
|
|
1770
|
+
const files = (input.attachments ?? []).flatMap((id) => {
|
|
1771
|
+
const name = session.attachments.get(id)?.attachment.name;
|
|
1772
|
+
return name === void 0 ? [] : [name];
|
|
1773
|
+
});
|
|
1774
|
+
const title = [skill ?? "", text].filter((part) => part !== "").join(": ") || files.join(", ");
|
|
1775
|
+
if (title === "") return null;
|
|
1776
|
+
return title.length <= 80 ? title : `${title.slice(0, 79).trimEnd()}…`;
|
|
1777
|
+
}
|
|
1778
|
+
/** A message starts a turn: its user message joins the transcript, and the attachments it sends are in use from now. */
|
|
1779
|
+
function startTurn(session, input) {
|
|
1780
|
+
const turn = {
|
|
1781
|
+
id: hexId("turn"),
|
|
1782
|
+
messageId: hexId("msg"),
|
|
1783
|
+
seq: 0,
|
|
1784
|
+
answerId: null
|
|
1785
|
+
};
|
|
1786
|
+
const attachments = (input.attachments ?? []).flatMap((id) => {
|
|
1787
|
+
const stored = session.attachments.get(id);
|
|
1788
|
+
if (stored === void 0) return [];
|
|
1789
|
+
stored.usedBy = turn.id;
|
|
1790
|
+
return [stored.attachment];
|
|
1791
|
+
});
|
|
1792
|
+
const skill = input.skill === void 0 ? void 0 : SEED_SKILLS.find((seeded) => seeded.id === input.skill?.id);
|
|
1793
|
+
const message = {
|
|
1794
|
+
role: "user",
|
|
1795
|
+
id: turn.messageId,
|
|
1796
|
+
turnId: turn.id,
|
|
1797
|
+
createdAt: (/* @__PURE__ */ new Date()).toISOString(),
|
|
1798
|
+
text: input.text,
|
|
1799
|
+
refs: input.refs,
|
|
1800
|
+
...input.markup === void 0 ? {} : { markup: input.markup },
|
|
1801
|
+
...input.suggestion === void 0 ? {} : { suggestion: input.suggestion },
|
|
1802
|
+
...skill === void 0 ? {} : { skill: {
|
|
1803
|
+
id: skill.id,
|
|
1804
|
+
title: skill.title
|
|
1805
|
+
} },
|
|
1806
|
+
attachments
|
|
1807
|
+
};
|
|
1808
|
+
session.messages.push(message);
|
|
1809
|
+
session.turn = turn;
|
|
1810
|
+
return {
|
|
1811
|
+
turn,
|
|
1812
|
+
message
|
|
1813
|
+
};
|
|
1814
|
+
}
|
|
1815
|
+
/** The page tools the UI ran, as steps every device can draw: the results close the steps the `page_tool` events opened. */
|
|
1816
|
+
function settledSteps(input, before) {
|
|
1817
|
+
if (input.kind !== "page_tool_results" || before?.kind !== "page_tool") return [];
|
|
1818
|
+
return input.results.flatMap((result) => {
|
|
1819
|
+
const call = before.calls.find((candidate) => candidate.id === result.id);
|
|
1820
|
+
if (call === void 0) return [];
|
|
1821
|
+
const step = {
|
|
1822
|
+
type: "step",
|
|
1823
|
+
id: call.id,
|
|
1824
|
+
tool: call.tool,
|
|
1825
|
+
input: call.input
|
|
1826
|
+
};
|
|
1827
|
+
return [result.ok ? {
|
|
1828
|
+
...step,
|
|
1829
|
+
state: "done",
|
|
1830
|
+
result: result.result
|
|
1831
|
+
} : {
|
|
1832
|
+
...step,
|
|
1833
|
+
state: "error",
|
|
1834
|
+
error: result.error ?? "Failed"
|
|
1835
|
+
}];
|
|
1836
|
+
});
|
|
1837
|
+
}
|
|
1838
|
+
function emitterOf(res, session, turn) {
|
|
1839
|
+
let ended = false;
|
|
1840
|
+
let gone = false;
|
|
761
1841
|
res.on("close", () => {
|
|
762
|
-
|
|
1842
|
+
if (!ended) gone = true;
|
|
763
1843
|
});
|
|
764
1844
|
res.statusCode = 200;
|
|
765
1845
|
res.setHeader("Content-Type", "text/event-stream");
|
|
766
1846
|
res.setHeader("Cache-Control", "no-cache");
|
|
767
|
-
|
|
768
|
-
|
|
769
|
-
|
|
770
|
-
|
|
771
|
-
|
|
772
|
-
|
|
1847
|
+
return {
|
|
1848
|
+
emit(type, data, seq, hub = {
|
|
1849
|
+
type,
|
|
1850
|
+
data
|
|
1851
|
+
}) {
|
|
1852
|
+
const published = publish(session.channel, hub.type, {
|
|
1853
|
+
...hub.data,
|
|
1854
|
+
turnId: turn.id,
|
|
1855
|
+
seq
|
|
1856
|
+
});
|
|
1857
|
+
if (!gone) res.write(frame(type, {
|
|
1858
|
+
...data,
|
|
1859
|
+
turnId: turn.id,
|
|
1860
|
+
seq
|
|
1861
|
+
}, published.id));
|
|
1862
|
+
},
|
|
1863
|
+
gone: () => gone,
|
|
1864
|
+
end() {
|
|
1865
|
+
ended = true;
|
|
1866
|
+
res.end();
|
|
1867
|
+
}
|
|
1868
|
+
};
|
|
1869
|
+
}
|
|
1870
|
+
/** Sends one of the responder's events: recorded, stamped, streamed; a page tool goes to the hub as a running step. */
|
|
1871
|
+
function send(emitter, session, turn, event) {
|
|
1872
|
+
record(session, turn, event);
|
|
1873
|
+
turn.seq += 1;
|
|
1874
|
+
const { type, ...data } = event;
|
|
1875
|
+
if (event.type === "page_tool") {
|
|
1876
|
+
const step = {
|
|
1877
|
+
id: event.id,
|
|
1878
|
+
tool: `${event.domainId}__${event.name}`,
|
|
1879
|
+
input: event.input,
|
|
1880
|
+
state: "running"
|
|
1881
|
+
};
|
|
1882
|
+
emitter.emit(type, data, turn.seq, {
|
|
1883
|
+
type: "step",
|
|
1884
|
+
data: step
|
|
1885
|
+
});
|
|
1886
|
+
} else emitter.emit(type, data, turn.seq);
|
|
1887
|
+
}
|
|
1888
|
+
/** Streams the answer with a little pacing; `true` when it reached `done`, `false` when stopped. */
|
|
1889
|
+
async function play(emitter, session, turn, events, title, delayMs) {
|
|
1890
|
+
for (const event of events) {
|
|
1891
|
+
if (emitter.gone() || session.deleted) return false;
|
|
1892
|
+
if (event.type === "done" && title !== null) {
|
|
1893
|
+
session.title = title;
|
|
1894
|
+
turn.seq += 1;
|
|
1895
|
+
emitter.emit("title", { title }, turn.seq);
|
|
1896
|
+
}
|
|
1897
|
+
send(emitter, session, turn, event);
|
|
773
1898
|
await pause(delayMs);
|
|
774
1899
|
}
|
|
775
|
-
|
|
776
|
-
|
|
1900
|
+
return true;
|
|
1901
|
+
}
|
|
1902
|
+
/** The turn a request belongs to: a message starts one; page tools' results and an approval carry on the session's. */
|
|
1903
|
+
function begin(session, input) {
|
|
1904
|
+
if (input.kind === "message") return startTurn(session, input);
|
|
1905
|
+
const turn = session.turn ?? {
|
|
1906
|
+
id: hexId("turn"),
|
|
1907
|
+
messageId: hexId("msg"),
|
|
1908
|
+
seq: 0,
|
|
1909
|
+
answerId: null
|
|
1910
|
+
};
|
|
1911
|
+
session.turn = turn;
|
|
1912
|
+
if (input.kind === "approval") decide(session, turn, input.approve);
|
|
1913
|
+
return {
|
|
1914
|
+
turn,
|
|
1915
|
+
message: null
|
|
1916
|
+
};
|
|
1917
|
+
}
|
|
1918
|
+
/** Closes the turn's response: a deleted session says so; a stopped turn leaves the session idle, for every device. */
|
|
1919
|
+
function end(emitter, res, session, turn, finished) {
|
|
1920
|
+
if (session.deleted) {
|
|
1921
|
+
if (!emitter.gone()) res.write(frame("session.deleted", { id: session.id }));
|
|
1922
|
+
} else if (!finished) {
|
|
1923
|
+
session.pending = null;
|
|
1924
|
+
turn.seq += 1;
|
|
1925
|
+
publish(session.channel, "done", {
|
|
1926
|
+
stop: "end",
|
|
1927
|
+
turnId: turn.id,
|
|
1928
|
+
seq: turn.seq
|
|
1929
|
+
});
|
|
1930
|
+
}
|
|
1931
|
+
session.busy = false;
|
|
1932
|
+
emitter.end();
|
|
777
1933
|
}
|
|
1934
|
+
/**
|
|
1935
|
+
* `POST /api/sessions/{id}/turns`: one request of a turn. A message starts a turn (`turn`, `seq` 0); a page tool's
|
|
1936
|
+
* results or an approval continue it: the same `turnId`, `seq` going on from where it stopped, no second `turn`. Every
|
|
1937
|
+
* event goes to this response and to the session's hub (so to every events stream), stamped with `turnId` and `seq`
|
|
1938
|
+
* and with the hub's event id as `id:`; a `page_tool` goes only to this response, and the hub gets a running `step` for
|
|
1939
|
+
* the same call under the same `seq` and id, so neither stream has a gap.
|
|
1940
|
+
*/
|
|
1941
|
+
async function turnRequest(req, res, id, context) {
|
|
1942
|
+
const admitted = await admit(req, res, id, context);
|
|
1943
|
+
if (admitted === null) return;
|
|
1944
|
+
const { session, body: { input }, body } = admitted;
|
|
1945
|
+
const before = session.pending;
|
|
1946
|
+
const answer = respond(body, session);
|
|
1947
|
+
if (!Array.isArray(answer)) {
|
|
1948
|
+
refuse(res, answer.status, "not_waiting", answer.message);
|
|
1949
|
+
return;
|
|
1950
|
+
}
|
|
1951
|
+
const { turn, message } = begin(session, input);
|
|
1952
|
+
session.busy = true;
|
|
1953
|
+
const updatedAt = touch(session);
|
|
1954
|
+
const emitter = emitterOf(res, session, turn);
|
|
1955
|
+
if (message !== null) emitter.emit("turn", { messageId: turn.messageId }, 0, {
|
|
1956
|
+
type: "turn",
|
|
1957
|
+
data: {
|
|
1958
|
+
messageId: turn.messageId,
|
|
1959
|
+
message
|
|
1960
|
+
}
|
|
1961
|
+
});
|
|
1962
|
+
if (session.archivedAt !== null) {
|
|
1963
|
+
session.archivedAt = null;
|
|
1964
|
+
turn.seq += 1;
|
|
1965
|
+
emitter.emit("session", {
|
|
1966
|
+
archivedAt: null,
|
|
1967
|
+
updatedAt
|
|
1968
|
+
}, turn.seq);
|
|
1969
|
+
}
|
|
1970
|
+
const title = input.kind !== "message" || session.title !== null ? null : titleOf(input, session);
|
|
1971
|
+
end(emitter, res, session, turn, await play(emitter, session, turn, [...settledSteps(input, before), ...answer], title, context.delayMs));
|
|
1972
|
+
}
|
|
1973
|
+
//#endregion
|
|
1974
|
+
//#region src/mock/chatHandler.ts
|
|
1975
|
+
var SKILLS_LIMIT = 50;
|
|
778
1976
|
/** `GET /api/prompts?domain=&mode=`: the domain's seeded prompts in the mode. */
|
|
779
1977
|
function prompts(url, res) {
|
|
780
1978
|
const domain = url.searchParams.get("domain");
|
|
@@ -785,7 +1983,6 @@ function prompts(url, res) {
|
|
|
785
1983
|
}
|
|
786
1984
|
sendJson(res, 200, { data: { prompts: promptsFor(domain, mode) } });
|
|
787
1985
|
}
|
|
788
|
-
var SKILLS_LIMIT = 50;
|
|
789
1986
|
/** A page position as an opaque cursor, and back; `null` when the cursor is not one this handler issued. */
|
|
790
1987
|
var cursorOf = (index) => Buffer.from(String(index)).toString("base64url");
|
|
791
1988
|
function indexOf(cursor) {
|
|
@@ -809,72 +2006,462 @@ function skills(url, req, res) {
|
|
|
809
2006
|
}
|
|
810
2007
|
const end = start + limit;
|
|
811
2008
|
const hasMore = end < SEED_SKILLS.length;
|
|
812
|
-
|
|
813
|
-
|
|
814
|
-
|
|
815
|
-
|
|
816
|
-
|
|
817
|
-
|
|
818
|
-
|
|
819
|
-
|
|
820
|
-
data,
|
|
2009
|
+
sendTagged(req, res, 200, {
|
|
2010
|
+
data: SEED_SKILLS.slice(start, end).map(({ id, title, description }) => {
|
|
2011
|
+
return {
|
|
2012
|
+
id,
|
|
2013
|
+
title,
|
|
2014
|
+
description
|
|
2015
|
+
};
|
|
2016
|
+
}),
|
|
821
2017
|
nextCursor: hasMore ? cursorOf(end) : null,
|
|
822
2018
|
hasMore
|
|
823
2019
|
});
|
|
824
|
-
|
|
825
|
-
|
|
826
|
-
|
|
827
|
-
|
|
828
|
-
|
|
829
|
-
|
|
2020
|
+
}
|
|
2021
|
+
/**
|
|
2022
|
+
* Runs the method's action, or answers 405 naming the methods the path takes. A synchronous action answers before
|
|
2023
|
+
* this returns (an events stream is subscribed at once); a failure is a 500 in the envelope.
|
|
2024
|
+
*/
|
|
2025
|
+
function route(req, res, actions) {
|
|
2026
|
+
const failed = () => {
|
|
2027
|
+
if (res.headersSent) res.end();
|
|
2028
|
+
else refuse(res, 500, "internal_error", "The mock failed");
|
|
2029
|
+
};
|
|
2030
|
+
const action = actions[(req.method ?? "").toLowerCase()];
|
|
2031
|
+
if (action === void 0) {
|
|
2032
|
+
const allowed = Object.keys(actions).map((method) => method.toUpperCase()).join(", ");
|
|
2033
|
+
res.setHeader("Allow", allowed);
|
|
2034
|
+
refuse(res, 405, "method_not_allowed", `Only ${allowed}`);
|
|
2035
|
+
return true;
|
|
830
2036
|
}
|
|
831
|
-
|
|
832
|
-
|
|
833
|
-
|
|
2037
|
+
try {
|
|
2038
|
+
const pending = action();
|
|
2039
|
+
if (pending instanceof Promise) pending.catch(failed);
|
|
2040
|
+
} catch {
|
|
2041
|
+
failed();
|
|
2042
|
+
}
|
|
2043
|
+
return true;
|
|
2044
|
+
}
|
|
2045
|
+
/** A path segment decoded; a malformed one stays as it is, and so names no session. */
|
|
2046
|
+
function decode(segment) {
|
|
2047
|
+
try {
|
|
2048
|
+
return decodeURIComponent(segment);
|
|
2049
|
+
} catch {
|
|
2050
|
+
return segment;
|
|
2051
|
+
}
|
|
2052
|
+
}
|
|
2053
|
+
var SESSION = /^\/api\/sessions\/([^/]+)$/;
|
|
2054
|
+
var SUBRESOURCE = /^\/api\/sessions\/([^/]+)\/(turns|events|attachments|artifacts)(?:\/([^/]+))?$/;
|
|
2055
|
+
/** Runs `then` on the session the path names, when the request may see it. */
|
|
2056
|
+
var withSession = ({ res, url, context }, id, then) => async () => {
|
|
2057
|
+
const session = lookup(url, id, res, context.sessions);
|
|
2058
|
+
if (session !== null) await then(session);
|
|
2059
|
+
};
|
|
2060
|
+
/** `/api/sessions/{id}`: read, change, delete. */
|
|
2061
|
+
function sessionRoute(request, id) {
|
|
2062
|
+
const { req, res, context } = request;
|
|
2063
|
+
return route(req, res, {
|
|
2064
|
+
get: withSession(request, id, (session) => read(req, res, session)),
|
|
2065
|
+
patch: withSession(request, id, async (session) => change(req, res, session)),
|
|
2066
|
+
delete: withSession(request, id, (session) => remove(req, res, session, context.sessions))
|
|
2067
|
+
});
|
|
2068
|
+
}
|
|
2069
|
+
/** `/api/sessions/{id}/turns`, `…/events`, `…/attachments[/{attachmentId}]`, `…/artifacts/{artifactId}`. */
|
|
2070
|
+
function subresourceRoute(request, id, kind, item) {
|
|
2071
|
+
const { req, res, url, context } = request;
|
|
2072
|
+
if (item === void 0) switch (kind) {
|
|
2073
|
+
case "turns": return route(req, res, { post: async () => turnRequest(req, res, id, context) });
|
|
2074
|
+
case "events": return route(req, res, { get: () => events(req, res, url, id, context) });
|
|
2075
|
+
case "attachments": return route(req, res, { post: withSession(request, id, async (session) => upload(req, res, session)) });
|
|
2076
|
+
default: return false;
|
|
2077
|
+
}
|
|
2078
|
+
if (kind === "attachments") {
|
|
2079
|
+
const action = withSession(request, id, (session) => attachmentRoute(req, res, session, item));
|
|
2080
|
+
return route(req, res, {
|
|
2081
|
+
get: action,
|
|
2082
|
+
delete: action
|
|
2083
|
+
});
|
|
2084
|
+
}
|
|
2085
|
+
return kind === "artifacts" && route(req, res, { get: withSession(request, id, (session) => artifactRoute(res, session, item)) });
|
|
834
2086
|
}
|
|
835
|
-
var TURNS = /^\/api\/conversations\/([^/]+)\/turns$/;
|
|
836
2087
|
/**
|
|
837
|
-
* The dev-only agent backend (assistant endpoints design, decision 23), speaking the agent
|
|
838
|
-
*
|
|
839
|
-
*
|
|
2088
|
+
* The dev-only agent backend (assistant endpoints design, decision 23), speaking the agent sessions API (session pane
|
|
2089
|
+
* design 9): sessions with their transcripts, turns, events, attachments and artifacts, and `GET /api/prompts` and `GET
|
|
2090
|
+
* /api/skills`. Each handler keeps its own sessions, in memory.
|
|
840
2091
|
*/
|
|
841
2092
|
function createChatHandler(options = {}) {
|
|
842
|
-
const
|
|
843
|
-
|
|
844
|
-
|
|
845
|
-
|
|
846
|
-
else res.end();
|
|
2093
|
+
const context = {
|
|
2094
|
+
sessions: options.sessions ?? createSessions(),
|
|
2095
|
+
delayMs: options.delayMs ?? 40,
|
|
2096
|
+
pingMs: options.pingMs ?? 25e3
|
|
847
2097
|
};
|
|
848
2098
|
return (req, res) => {
|
|
849
2099
|
const url = new URL(req.url ?? "/", "http://mock.invalid");
|
|
850
|
-
const
|
|
851
|
-
|
|
852
|
-
|
|
853
|
-
|
|
854
|
-
|
|
855
|
-
}
|
|
856
|
-
return true;
|
|
2100
|
+
const request = {
|
|
2101
|
+
req,
|
|
2102
|
+
res,
|
|
2103
|
+
url,
|
|
2104
|
+
context
|
|
857
2105
|
};
|
|
858
|
-
|
|
859
|
-
|
|
860
|
-
|
|
861
|
-
|
|
862
|
-
|
|
863
|
-
|
|
864
|
-
|
|
865
|
-
|
|
866
|
-
|
|
867
|
-
|
|
2106
|
+
switch (url.pathname) {
|
|
2107
|
+
case "/api/prompts": return route(req, res, { get: () => prompts(url, res) });
|
|
2108
|
+
case "/api/skills": return route(req, res, { get: () => skills(url, req, res) });
|
|
2109
|
+
case "/api/sessions": return route(req, res, {
|
|
2110
|
+
get: () => list(req, res, url, context.sessions),
|
|
2111
|
+
post: async () => create(req, res, context.sessions)
|
|
2112
|
+
});
|
|
2113
|
+
}
|
|
2114
|
+
const one = SESSION.exec(url.pathname);
|
|
2115
|
+
if (one !== null) return sessionRoute(request, decode(one[1]));
|
|
2116
|
+
const sub = SUBRESOURCE.exec(url.pathname);
|
|
2117
|
+
if (sub === null) return false;
|
|
2118
|
+
return subresourceRoute(request, decode(sub[1]), sub[2], sub[3] === void 0 ? void 0 : decode(sub[3]));
|
|
868
2119
|
};
|
|
869
2120
|
}
|
|
870
2121
|
//#endregion
|
|
2122
|
+
//#region src/mock/seedSessions.ts
|
|
2123
|
+
/** The dev token's scope: its org and default tenant, as the assistant names it (`runtime.ts`, `scopeOf`). */
|
|
2124
|
+
var DEV_SCOPE = {
|
|
2125
|
+
orgId: "org_dev",
|
|
2126
|
+
tenantScope: { tenantIds: ["acme"] }
|
|
2127
|
+
};
|
|
2128
|
+
var MINUTE = 6e4;
|
|
2129
|
+
var HOUR = 60 * MINUTE;
|
|
2130
|
+
var DAY = 24 * HOUR;
|
|
2131
|
+
var minutes = (count) => count * MINUTE;
|
|
2132
|
+
var days = (count, hours = 0) => count * DAY + hours * HOUR;
|
|
2133
|
+
/** A stable id per seed, so a tab left open across a dev-server restart still finds its session. */
|
|
2134
|
+
var stableId = (prefix, seed) => `${prefix}_${createHash("sha256").update(seed).digest("hex").slice(0, 32)}`;
|
|
2135
|
+
/** Plausible, synthetic sessions: 5 today, 8 in the previous 7 days, 12 older (assistant recent conversations design 7). */
|
|
2136
|
+
var SEEDS = [
|
|
2137
|
+
{
|
|
2138
|
+
title: "Alerts by host",
|
|
2139
|
+
ago: minutes(4),
|
|
2140
|
+
question: "Alerts by host",
|
|
2141
|
+
reply: "web-01 raised the most alerts in the last 24 hours (42). The chart and the table hold the same counts."
|
|
2142
|
+
},
|
|
2143
|
+
{
|
|
2144
|
+
title: "Suppress duplicate beaconing alerts",
|
|
2145
|
+
ago: minutes(12),
|
|
2146
|
+
question: "Suppress a4 and a5, they are duplicates of a2",
|
|
2147
|
+
reply: ""
|
|
2148
|
+
},
|
|
2149
|
+
{
|
|
2150
|
+
title: "Triage credential dumping on web-01",
|
|
2151
|
+
ago: minutes(26),
|
|
2152
|
+
question: "What happened on web-01 around 09:00?",
|
|
2153
|
+
reply: "Two alerts fired within four minutes: credential dumping, then a new scheduled task. Both point at the same service account."
|
|
2154
|
+
},
|
|
2155
|
+
{
|
|
2156
|
+
title: "Impossible travel for j.doe",
|
|
2157
|
+
ago: minutes(41),
|
|
2158
|
+
question: "Is the impossible travel alert for j.doe real?",
|
|
2159
|
+
reply: "The second sign-in came from a known VPN exit. It is likely benign; the VPN log confirms the session."
|
|
2160
|
+
},
|
|
2161
|
+
{
|
|
2162
|
+
title: "Noisy rules this week",
|
|
2163
|
+
ago: minutes(55),
|
|
2164
|
+
question: "Which rules were noisiest this week?",
|
|
2165
|
+
reply: "Three rules account for 61 % of alerts: DNS beaconing, failed MFA bursts and new admin share access."
|
|
2166
|
+
},
|
|
2167
|
+
{
|
|
2168
|
+
title: "Phishing report from finance",
|
|
2169
|
+
ago: days(1, 2),
|
|
2170
|
+
question: "Summarise the phishing report from finance",
|
|
2171
|
+
reply: "One user clicked; no credentials were entered. The sender domain was registered two days ago."
|
|
2172
|
+
},
|
|
2173
|
+
{
|
|
2174
|
+
title: "VPN gateway brute force",
|
|
2175
|
+
ago: days(1, 7),
|
|
2176
|
+
question: "Is vpn-gw-1 under brute force?",
|
|
2177
|
+
reply: "1,840 failed logins from 12 addresses in an hour. Lockout held; no successful sign-in."
|
|
2178
|
+
},
|
|
2179
|
+
{
|
|
2180
|
+
title: "Open investigations by owner",
|
|
2181
|
+
ago: days(2),
|
|
2182
|
+
question: "Who owns the open investigations?",
|
|
2183
|
+
reply: "Seven are open: four with the tier-2 rota, two with you, one unassigned."
|
|
2184
|
+
},
|
|
2185
|
+
{
|
|
2186
|
+
title: "Malware sample on build-07",
|
|
2187
|
+
ago: days(3),
|
|
2188
|
+
question: "What did the sandbox say about the sample on build-07?",
|
|
2189
|
+
reply: "The sample is a known loader. It reached out to one address, which is now blocked."
|
|
2190
|
+
},
|
|
2191
|
+
{
|
|
2192
|
+
title: "Weekend alert summary",
|
|
2193
|
+
ago: days(3, 5),
|
|
2194
|
+
question: "Summarise the weekend's alerts",
|
|
2195
|
+
reply: "212 alerts, 9 critical, all triaged. Nothing is waiting on you."
|
|
2196
|
+
},
|
|
2197
|
+
{
|
|
2198
|
+
title: "Service account review",
|
|
2199
|
+
ago: days(4),
|
|
2200
|
+
question: "Which service accounts logged in interactively?",
|
|
2201
|
+
reply: "Two did: svc-backup and svc-report. Both from jump hosts, both within change windows."
|
|
2202
|
+
},
|
|
2203
|
+
{
|
|
2204
|
+
title: "Mail rule forwarding outside",
|
|
2205
|
+
ago: days(5),
|
|
2206
|
+
question: "Find mailboxes forwarding to outside domains",
|
|
2207
|
+
reply: "Three mailboxes forward externally. One rule was created yesterday from an unfamiliar address."
|
|
2208
|
+
},
|
|
2209
|
+
{
|
|
2210
|
+
title: "Data egress from db-02",
|
|
2211
|
+
ago: days(6),
|
|
2212
|
+
question: "Did db-02 send unusual volumes out?",
|
|
2213
|
+
reply: "Egress peaked at 3.1 GB at 02:00, matching the nightly backup to the usual target."
|
|
2214
|
+
},
|
|
2215
|
+
{
|
|
2216
|
+
title: "New admin accounts in October",
|
|
2217
|
+
ago: days(9),
|
|
2218
|
+
question: "List admin accounts created in October",
|
|
2219
|
+
reply: "Four were created, all through the access request flow, each with an approver."
|
|
2220
|
+
},
|
|
2221
|
+
{
|
|
2222
|
+
title: "Endpoint isolation checklist",
|
|
2223
|
+
ago: days(12),
|
|
2224
|
+
question: "What do I check before isolating a laptop?",
|
|
2225
|
+
reply: "Confirm the owner, that a ticket exists, and that the device is not a server; then isolate and note why."
|
|
2226
|
+
},
|
|
2227
|
+
{
|
|
2228
|
+
title: "Lateral movement from hr-laptop-12",
|
|
2229
|
+
ago: days(15),
|
|
2230
|
+
question: "Did hr-laptop-12 move laterally?",
|
|
2231
|
+
reply: "It reached two file shares it uses daily and nothing else. No sign of lateral movement."
|
|
2232
|
+
},
|
|
2233
|
+
{
|
|
2234
|
+
title: "Firewall change review",
|
|
2235
|
+
ago: days(18),
|
|
2236
|
+
question: "Review last week's firewall changes",
|
|
2237
|
+
reply: "Eleven changes, all ticketed. One opened a port wider than its ticket asked for."
|
|
2238
|
+
},
|
|
2239
|
+
{
|
|
2240
|
+
title: "DNS tunnelling false positives",
|
|
2241
|
+
ago: days(21),
|
|
2242
|
+
question: "Why does the DNS tunnelling rule fire on the CDN?",
|
|
2243
|
+
reply: "Long subdomains from the CDN look like encoded data. An exception for its zone would quiet it."
|
|
2244
|
+
},
|
|
2245
|
+
{
|
|
2246
|
+
title: "Quarterly access review prep",
|
|
2247
|
+
ago: days(25),
|
|
2248
|
+
question: "Prepare the quarterly access review",
|
|
2249
|
+
reply: "Exported 312 grants across 41 groups; 17 have not been used in 90 days."
|
|
2250
|
+
},
|
|
2251
|
+
{
|
|
2252
|
+
title: "Ransomware tabletop notes",
|
|
2253
|
+
ago: days(30),
|
|
2254
|
+
question: "Summarise the ransomware tabletop",
|
|
2255
|
+
reply: "Detection was quick; isolation took 40 minutes because the runbook named an old tool."
|
|
2256
|
+
},
|
|
2257
|
+
{
|
|
2258
|
+
title: "Okta MFA fatigue alerts",
|
|
2259
|
+
ago: days(34),
|
|
2260
|
+
question: "Explain the MFA fatigue alerts",
|
|
2261
|
+
reply: "One user got 23 pushes in ten minutes and approved none. The password was reset."
|
|
2262
|
+
},
|
|
2263
|
+
{
|
|
2264
|
+
title: "Cloud storage made public",
|
|
2265
|
+
ago: days(40),
|
|
2266
|
+
question: "Which buckets were made public?",
|
|
2267
|
+
reply: "One bucket, for 6 minutes, by a deploy script. It is private again."
|
|
2268
|
+
},
|
|
2269
|
+
{
|
|
2270
|
+
title: "Old: printer beaconing",
|
|
2271
|
+
ago: days(47),
|
|
2272
|
+
archived: true,
|
|
2273
|
+
question: "Why is the printer beaconing?",
|
|
2274
|
+
reply: "Its firmware checks for updates every five minutes. Expected behaviour."
|
|
2275
|
+
},
|
|
2276
|
+
{
|
|
2277
|
+
title: "Old: test alert cleanup",
|
|
2278
|
+
ago: days(53),
|
|
2279
|
+
archived: true,
|
|
2280
|
+
question: "Clean up the test alerts",
|
|
2281
|
+
reply: "There were 48 test alerts; all are closed."
|
|
2282
|
+
},
|
|
2283
|
+
{
|
|
2284
|
+
title: "Old: onboarding questions",
|
|
2285
|
+
ago: days(60),
|
|
2286
|
+
archived: true,
|
|
2287
|
+
question: "How do I read an alert's timeline?",
|
|
2288
|
+
reply: "Newest at the top; each entry names its source, and a click opens the raw event."
|
|
2289
|
+
}
|
|
2290
|
+
];
|
|
2291
|
+
var iso = (ms) => new Date(ms).toISOString();
|
|
2292
|
+
function seedTurn(session, index, seed, at) {
|
|
2293
|
+
const turnId = stableId("turn", `seed-${index}`);
|
|
2294
|
+
const messageId = stableId("msg", `seed-${index}`);
|
|
2295
|
+
const user = {
|
|
2296
|
+
role: "user",
|
|
2297
|
+
id: messageId,
|
|
2298
|
+
turnId,
|
|
2299
|
+
createdAt: iso(at - MINUTE),
|
|
2300
|
+
text: seed.question,
|
|
2301
|
+
refs: [],
|
|
2302
|
+
attachments: []
|
|
2303
|
+
};
|
|
2304
|
+
const answer = {
|
|
2305
|
+
role: "assistant",
|
|
2306
|
+
id: `${messageId}_a`,
|
|
2307
|
+
turnId,
|
|
2308
|
+
createdAt: iso(at),
|
|
2309
|
+
acting: "ask",
|
|
2310
|
+
blocks: seed.reply === "" ? [] : [{
|
|
2311
|
+
kind: "text",
|
|
2312
|
+
text: seed.reply
|
|
2313
|
+
}]
|
|
2314
|
+
};
|
|
2315
|
+
session.messages.push(user, answer);
|
|
2316
|
+
return {
|
|
2317
|
+
turnId,
|
|
2318
|
+
messageId
|
|
2319
|
+
};
|
|
2320
|
+
}
|
|
2321
|
+
/** The session waiting on a plan: two suppressions proposed, and the mock ready to run or decline them. */
|
|
2322
|
+
function waitOnApproval(session, turnId, messageId) {
|
|
2323
|
+
const answer = session.messages.at(-1);
|
|
2324
|
+
if (answer?.role !== "assistant") return;
|
|
2325
|
+
const approvalId = stableId("apr", session.id);
|
|
2326
|
+
const calls = ["a4", "a5"].map((id) => {
|
|
2327
|
+
return {
|
|
2328
|
+
id: stableId("step", `${session.id}-${id}`),
|
|
2329
|
+
tool: "lab__suppress_alert",
|
|
2330
|
+
input: { id }
|
|
2331
|
+
};
|
|
2332
|
+
});
|
|
2333
|
+
answer.acting = "plan";
|
|
2334
|
+
answer.reason = "more than one change, so a plan first";
|
|
2335
|
+
answer.blocks = calls.map((call) => {
|
|
2336
|
+
return {
|
|
2337
|
+
kind: "step",
|
|
2338
|
+
id: call.id,
|
|
2339
|
+
tool: call.tool,
|
|
2340
|
+
input: call.input,
|
|
2341
|
+
state: "awaiting-approval"
|
|
2342
|
+
};
|
|
2343
|
+
});
|
|
2344
|
+
answer.approval = {
|
|
2345
|
+
id: approvalId,
|
|
2346
|
+
state: "proposed"
|
|
2347
|
+
};
|
|
2348
|
+
session.pending = {
|
|
2349
|
+
kind: "approval",
|
|
2350
|
+
id: approvalId,
|
|
2351
|
+
calls
|
|
2352
|
+
};
|
|
2353
|
+
session.turn = {
|
|
2354
|
+
id: turnId,
|
|
2355
|
+
messageId,
|
|
2356
|
+
seq: 3 + calls.length,
|
|
2357
|
+
answerId: answer.id
|
|
2358
|
+
};
|
|
2359
|
+
session.user = {
|
|
2360
|
+
kind: "message",
|
|
2361
|
+
text: "Suppress a4 and a5, they are duplicates of a2",
|
|
2362
|
+
refs: []
|
|
2363
|
+
};
|
|
2364
|
+
}
|
|
2365
|
+
/**
|
|
2366
|
+
* The session holding a chart and a table, the four resources the harness reports, and a connector it acted through:
|
|
2367
|
+
* sources and actions both, so the column's two sections fill in dev.
|
|
2368
|
+
*/
|
|
2369
|
+
function collect(session, turnId) {
|
|
2370
|
+
const answer = session.messages.at(-1);
|
|
2371
|
+
if (answer?.role !== "assistant") return;
|
|
2372
|
+
const chart = {
|
|
2373
|
+
...hostChart(stableId("art", `${session.id}-chart`)),
|
|
2374
|
+
turnId
|
|
2375
|
+
};
|
|
2376
|
+
const table = {
|
|
2377
|
+
...hostTable(session.id, stableId("art", `${session.id}-table`)),
|
|
2378
|
+
turnId
|
|
2379
|
+
};
|
|
2380
|
+
session.artifacts.push(chart, table);
|
|
2381
|
+
answer.blocks.push({
|
|
2382
|
+
kind: "artifact",
|
|
2383
|
+
id: chart.id
|
|
2384
|
+
}, {
|
|
2385
|
+
kind: "artifact",
|
|
2386
|
+
id: table.id
|
|
2387
|
+
});
|
|
2388
|
+
session.resources.push(...[
|
|
2389
|
+
{
|
|
2390
|
+
kind: "memory",
|
|
2391
|
+
use: "source",
|
|
2392
|
+
label: "Triage preferences",
|
|
2393
|
+
detail: "Read 3 notes",
|
|
2394
|
+
state: "done"
|
|
2395
|
+
},
|
|
2396
|
+
{
|
|
2397
|
+
kind: "connector",
|
|
2398
|
+
use: "source",
|
|
2399
|
+
label: "Lab API",
|
|
2400
|
+
detail: "Searched alerts",
|
|
2401
|
+
state: "done"
|
|
2402
|
+
},
|
|
2403
|
+
{
|
|
2404
|
+
kind: "agent",
|
|
2405
|
+
use: "action",
|
|
2406
|
+
label: "Alert triage",
|
|
2407
|
+
detail: "Ranked the alerts in view",
|
|
2408
|
+
state: "done"
|
|
2409
|
+
},
|
|
2410
|
+
{
|
|
2411
|
+
kind: "workflow",
|
|
2412
|
+
use: "action",
|
|
2413
|
+
label: "Suppress duplicates",
|
|
2414
|
+
detail: "Dry run: nothing changed",
|
|
2415
|
+
state: "done"
|
|
2416
|
+
},
|
|
2417
|
+
{
|
|
2418
|
+
kind: "connector",
|
|
2419
|
+
use: "action",
|
|
2420
|
+
label: "Ticketing",
|
|
2421
|
+
detail: "Drafted a ticket; not filed",
|
|
2422
|
+
state: "done"
|
|
2423
|
+
}
|
|
2424
|
+
].map((resource) => {
|
|
2425
|
+
return {
|
|
2426
|
+
...resource,
|
|
2427
|
+
id: stableId("res", `${session.id}-${resource.kind}-${resource.use}`),
|
|
2428
|
+
turnId
|
|
2429
|
+
};
|
|
2430
|
+
}));
|
|
2431
|
+
}
|
|
2432
|
+
/**
|
|
2433
|
+
* Seeds 25 sessions in the dev token's scope: 3 archived, 1 waiting on an approval, 1 holding a chart, a table and
|
|
2434
|
+
* five resources, updated today, in the previous 7 days and earlier. Every word in them is made up.
|
|
2435
|
+
*/
|
|
2436
|
+
function seedSessions(sessions, scope = DEV_SCOPE, now = Date.now()) {
|
|
2437
|
+
for (const [index, seed] of [...SEEDS.entries()].reverse()) {
|
|
2438
|
+
const at = now - seed.ago;
|
|
2439
|
+
const session = sessions.create(scope, {
|
|
2440
|
+
id: stableId("ses", `seed-${index}`),
|
|
2441
|
+
createdAt: iso(at - 2 * MINUTE),
|
|
2442
|
+
updatedAt: iso(at)
|
|
2443
|
+
});
|
|
2444
|
+
session.title = seed.title;
|
|
2445
|
+
const { turnId, messageId } = seedTurn(session, index, seed, at);
|
|
2446
|
+
if (seed.archived === true) {
|
|
2447
|
+
session.archivedAt = iso(at + MINUTE);
|
|
2448
|
+
session.updatedAt = session.archivedAt;
|
|
2449
|
+
}
|
|
2450
|
+
if (index === 0) collect(session, turnId);
|
|
2451
|
+
if (index === 1) waitOnApproval(session, turnId, messageId);
|
|
2452
|
+
}
|
|
2453
|
+
}
|
|
2454
|
+
//#endregion
|
|
871
2455
|
//#region src/mock/index.ts
|
|
872
2456
|
/**
|
|
873
|
-
* The assistant's dev API, fresh state per call
|
|
874
|
-
* (`@aistrike-dev/ui-shell-dev`, which loads the build of
|
|
2457
|
+
* The assistant's dev API, fresh state per call: the agent sessions API with 25 seeded sessions in the dev token's
|
|
2458
|
+
* scope. Its own dev server and the dev host's bundled copy (`@aistrike-dev/ui-shell-dev`, which loads the build of
|
|
2459
|
+
* this file) both run it. Never part of the MFE's build.
|
|
875
2460
|
*/
|
|
876
2461
|
function createMockHandlers() {
|
|
877
|
-
|
|
2462
|
+
const sessions = createSessions();
|
|
2463
|
+
seedSessions(sessions);
|
|
2464
|
+
return [createChatHandler({ sessions })];
|
|
878
2465
|
}
|
|
879
2466
|
//#endregion
|
|
880
2467
|
export { createMockHandlers };
|