@dyx9/rosetta 0.0.0-stage → 0.0.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/.env.example +59 -0
- package/LICENSE +21 -0
- package/README.md +101 -2
- package/bin/rosetta.js +10 -0
- package/build/dist/assets/abnfDiagram-WEYOIA3C-CwZlxHkI.js +1 -0
- package/build/dist/assets/arc-B0x0q6O1.js +1 -0
- package/build/dist/assets/architecture-WOLXFQ4H-CkOpgBe5.js +1 -0
- package/build/dist/assets/architectureDiagram-ZHVYKT53-DT2OTBIB.js +36 -0
- package/build/dist/assets/array-BifhSqXX.js +1 -0
- package/build/dist/assets/bash-CLZ0U2zV.js +1 -0
- package/build/dist/assets/blockDiagram-RJAE7QA3-DXKyW4Qj.js +140 -0
- package/build/dist/assets/c4Diagram-XDPPPE54-CqHMZj3I.js +38 -0
- package/build/dist/assets/channel-BmjZSxDJ.js +1 -0
- package/build/dist/assets/chunk-2E7WHVMD-DTKlOCW9.js +88 -0
- package/build/dist/assets/chunk-2Q5K7J3B-C1jixKkw.js +1 -0
- package/build/dist/assets/chunk-4EMNKR2T-BsnKxZVc.js +1 -0
- package/build/dist/assets/chunk-55XCU76K-Ddul4yK8.js +1 -0
- package/build/dist/assets/chunk-5VM5RSS4-ZNzvKenW.js +15 -0
- package/build/dist/assets/chunk-7S6XSTFO-D73i4Kfs.js +2 -0
- package/build/dist/assets/chunk-AQDTSKZB-DJMpZsbr.js +72 -0
- package/build/dist/assets/chunk-DSNLSHJZ-KWPl82ns.js +129 -0
- package/build/dist/assets/chunk-DUW6YSOI-Dgx8z5s3.js +1 -0
- package/build/dist/assets/chunk-HAGFOTED-Co5xWe5K.js +62 -0
- package/build/dist/assets/chunk-HNE4Q36P-D9ufLjJL.js +1 -0
- package/build/dist/assets/chunk-J5ZVWO5B-oDCd-gWO.js +1 -0
- package/build/dist/assets/chunk-JWPE2WC7-DVXcaiue.js +1 -0
- package/build/dist/assets/chunk-NGNAAXSQ-C2Wy9Xmz.js +136 -0
- package/build/dist/assets/chunk-NQOKRVIF-DuVeWA2D.js +213 -0
- package/build/dist/assets/chunk-UHW7PFBP-DrzHcxiz.js +1 -0
- package/build/dist/assets/chunk-VQ5W62QI-tyK9wvZy.js +1 -0
- package/build/dist/assets/chunk-WQQL4CDK-BtfOu2ju.js +10 -0
- package/build/dist/assets/chunk-XXDRQBXY-BFig8xVb.js +1 -0
- package/build/dist/assets/chunk-Y2CYZVJY-DsF7k-Jl.js +1 -0
- package/build/dist/assets/chunk-YZO67HFG-Cw5eVjq1.js +1 -0
- package/build/dist/assets/chunk-ZAUX2LTX-CIpKyMEe.js +1 -0
- package/build/dist/assets/chunk-ZCHMQSOR-Djve8pwS.js +2 -0
- package/build/dist/assets/classDiagram-v2-4MYWR3UA-D4XAcUCd.js +217 -0
- package/build/dist/assets/cose-bilkent-JH36ORCC-DHRGyJqU.js +1 -0
- package/build/dist/assets/cynefin-EF2NZ3EQ-Cv-uxuqE.js +1 -0
- package/build/dist/assets/cynefinDiagram-WF24UDRP-MFe-UzcY.js +62 -0
- package/build/dist/assets/cytoscape.esm-BegBNzOV.js +321 -0
- package/build/dist/assets/dagre-NK4GIZVX-BsY0-Q-N.js +4 -0
- package/build/dist/assets/defaultLocale-BFoDCU3G.js +1 -0
- package/build/dist/assets/diagram-CBK3MWTC-9zfXZ0bX.js +24 -0
- package/build/dist/assets/diagram-I5XIIV2D-CfWjvEof.js +24 -0
- package/build/dist/assets/diagram-ODIL2UCW-BpJaYO2i.js +30 -0
- package/build/dist/assets/diagram-PFBEJUXE-CtpxT_FQ.js +3 -0
- package/build/dist/assets/diagram-RJA3I4VV-DuLQrOcT.js +41 -0
- package/build/dist/assets/diagram-TYUNFSNR-w6Mohdl5.js +200 -0
- package/build/dist/assets/dist-DI7WHQIh.js +1 -0
- package/build/dist/assets/ebnfDiagram-V4P3TTPT-DlwHfKyL.js +1 -0
- package/build/dist/assets/elk-QONTBCTQ-DEvtD9nx.js +27 -0
- package/build/dist/assets/erDiagram-KYQIGQAA-DQuhkec3.js +99 -0
- package/build/dist/assets/eventmodeling-K75KTNOO-BO57PAOs.js +1 -0
- package/build/dist/assets/flowDiagram-IUGLLWBU-B_Ib7Avh.js +1 -0
- package/build/dist/assets/ganttDiagram-R7GPNTJY-CDGbtnUY.js +292 -0
- package/build/dist/assets/geist-cyrillic-ext-wght-normal-DjL33-gN.woff2 +0 -0
- package/build/dist/assets/geist-cyrillic-wght-normal-BEAKL7Jp.woff2 +0 -0
- package/build/dist/assets/geist-latin-ext-wght-normal-DC-KSUi6.woff2 +0 -0
- package/build/dist/assets/geist-latin-wght-normal-BgDaEnEv.woff2 +0 -0
- package/build/dist/assets/geist-mono-cyrillic-ext-wght-normal-X_5orZeX.woff2 +0 -0
- package/build/dist/assets/geist-mono-cyrillic-wght-normal-DiZS0aHC.woff2 +0 -0
- package/build/dist/assets/geist-mono-latin-ext-wght-normal-Bwz-egvJ.woff2 +0 -0
- package/build/dist/assets/geist-mono-latin-wght-normal-XN7g48iV.woff2 +0 -0
- package/build/dist/assets/geist-mono-symbols2-wght-normal-CO5SzqOn.woff2 +0 -0
- package/build/dist/assets/geist-mono-vietnamese-wght-normal-DadHysG0.woff2 +0 -0
- package/build/dist/assets/geist-vietnamese-wght-normal-6IgcOCM7.woff2 +0 -0
- package/build/dist/assets/gitGraph-VSP46ZUC-D9ZZKP8z.js +1 -0
- package/build/dist/assets/gitGraphDiagram-PDYL7XHY-Da8Oypep.js +106 -0
- package/build/dist/assets/github-dark-C-LZuMrd.js +1 -0
- package/build/dist/assets/github-light-EUqPIrTm.js +1 -0
- package/build/dist/assets/graphlib-DS17s2tU.js +1 -0
- package/build/dist/assets/index-Cf0aBmwz.css +2 -0
- package/build/dist/assets/index-Ct3brajV.js +471 -0
- package/build/dist/assets/info-OHQRW6UA-BF836x6L.js +1 -0
- package/build/dist/assets/infoDiagram-MVDRKAX5-DpOoH-IR.js +2 -0
- package/build/dist/assets/init-C-OQMol4.js +1 -0
- package/build/dist/assets/ishikawaDiagram-HPEHV2GE-DT-Ft7vW.js +70 -0
- package/build/dist/assets/javascript-BgS3c2Ky.js +1 -0
- package/build/dist/assets/journeyDiagram-BOSHW7O7-BAF7tJ1H.js +139 -0
- package/build/dist/assets/json-qhed-kSA.js +1 -0
- package/build/dist/assets/kanban-definition-FNSA4HC5-B_w2r2eo.js +89 -0
- package/build/dist/assets/katex-B_JcIwqj.js +257 -0
- package/build/dist/assets/line-DMjgRylK.js +1 -0
- package/build/dist/assets/linear-BEdVl1PM.js +1 -0
- package/build/dist/assets/mermaid-parser.core-BDmgiU7m.js +7 -0
- package/build/dist/assets/mermaid.core-8Kj_dqOs.js +44 -0
- package/build/dist/assets/mindmap-definition-6OY57UG3-DOcaArfN.js +98 -0
- package/build/dist/assets/ordinal-BDEzSJ7C.js +1 -0
- package/build/dist/assets/packet-JDAUHWVQ-zWLPVpvX.js +1 -0
- package/build/dist/assets/path-fybaL0A-.js +1 -0
- package/build/dist/assets/pegDiagram-ECIUJNBX-sYwsFyAl.js +1 -0
- package/build/dist/assets/pie-XZMESJXO-B-h0LV9S.js +1 -0
- package/build/dist/assets/pieDiagram-GNMPY74T-FToCIvky.js +39 -0
- package/build/dist/assets/python-gzcpVVnB.js +1 -0
- package/build/dist/assets/quadrantDiagram-D57K4MC3-tiXtbFLP.js +7 -0
- package/build/dist/assets/radar-ABXABTNO--ew_ZFJl.js +1 -0
- package/build/dist/assets/railroad-I3PHUGI6-cxsf_ZsE.js +1 -0
- package/build/dist/assets/railroad-abnf-LNEFI6M7-BIK-9C0Z.js +1 -0
- package/build/dist/assets/railroad-ebnf-DWYD2UWJ-BHw1M8VQ.js +1 -0
- package/build/dist/assets/railroad-peg-JR7BVNR7-MHCdZHAZ.js +1 -0
- package/build/dist/assets/railroadDiagram-Y72NRBL7-wjsZpnJA.js +1 -0
- package/build/dist/assets/requirementDiagram-EQYSW5RA-CHkzAP3w.js +84 -0
- package/build/dist/assets/rough.esm-Dy-Kn_BL.js +1 -0
- package/build/dist/assets/sankeyDiagram-SY24JHO5-BBLq6ndO.js +40 -0
- package/build/dist/assets/sequenceDiagram-BLEHNHQU-DW_h9VZQ.js +169 -0
- package/build/dist/assets/sizeCapture-INFHLROL-3uHXrFqA.js +1 -0
- package/build/dist/assets/src-MbiMZlpk.js +1 -0
- package/build/dist/assets/stateDiagram-v2-A6IESKWZ-BgpDkVUs.js +291 -0
- package/build/dist/assets/swimlanes-B3ENRKFQ-DJHWFYmN.js +1 -0
- package/build/dist/assets/swimlanesDiagram-AYQRV4AK-Cq_MGOxD.js +8 -0
- package/build/dist/assets/timeline-definition-JXYKGLGB-DQ-XYDnd.js +120 -0
- package/build/dist/assets/treeView-D4JQ5SDB-ydIf6KAy.js +1 -0
- package/build/dist/assets/treemap-SAJKECNS-CB6tZuFJ.js +1 -0
- package/build/dist/assets/typescript-bsJCZSQ-.js +1 -0
- package/build/dist/assets/usecaseDiagram-WPOLBNNX-D_A5G5wv.js +328 -0
- package/build/dist/assets/vennDiagram-Q3Y3IDCW-j2yUit_4.js +34 -0
- package/build/dist/assets/wardley-7MLQ67FV-BztBXEva.js +1 -0
- package/build/dist/assets/wardleyDiagram-IVAIDKWA-CyrXADWS.js +78 -0
- package/build/dist/assets/xychartDiagram-4URNHDU4-BM2yVuEH.js +7 -0
- package/build/dist/favicon.svg +1 -0
- package/build/dist/icons.svg +24 -0
- package/build/dist/index.html +14 -0
- package/build/server/agent/conversation.js +244 -0
- package/build/server/agent/harness.js +435 -0
- package/build/server/agent/sessions.js +222 -0
- package/build/server/agent/turn.js +56 -0
- package/build/server/config.js +92 -0
- package/build/server/http/auth.js +31 -0
- package/build/server/http/chat.js +67 -0
- package/build/server/http/feedback.js +30 -0
- package/build/server/http/routes.js +231 -0
- package/build/server/http/stream.js +39 -0
- package/build/server/http/util.js +40 -0
- package/build/server/index.js +112 -0
- package/build/server/records/operations.js +53 -0
- package/build/server/records/recording.js +186 -0
- package/build/server/records/turn-recording.js +151 -0
- package/build/server/stone/file-boundary.js +57 -0
- package/build/server/stone/prompt.js +83 -0
- package/build/server/stone/stone.js +245 -0
- package/build/server/stone/verify.js +141 -0
- package/build/src/shared/protocol.js +25 -0
- package/build/stones/example/AGENTS.md +25 -0
- package/build/stones/example/checks/hotspot.py +74 -0
- package/build/stones/example/checks/syntax.py +13 -0
- package/build/stones/example/constraints/rig-limits.md +15 -0
- package/build/stones/example/docs/reference.md +26 -0
- package/build/stones/example/examples/good/crossfade.py +15 -0
- package/build/stones/example/examples/violations/hotspot.py +17 -0
- package/build/stones/example/rosetta.json +23 -0
- package/build/stones/example/skills/writing-cues/SKILL.md +21 -0
- package/package.json +74 -4
|
@@ -0,0 +1,231 @@
|
|
|
1
|
+
import { existsSync } from "node:fs";
|
|
2
|
+
import sirv from "sirv";
|
|
3
|
+
import { SESSION_NOT_FOUND } from "../agent/sessions.js";
|
|
4
|
+
import { validTurnId } from "../agent/turn.js";
|
|
5
|
+
import { isAdmin, isUser } from "./auth.js";
|
|
6
|
+
import { handleChat } from "./chat.js";
|
|
7
|
+
import { handleFeedback } from "./feedback.js";
|
|
8
|
+
import { bodyOr400, json } from "./util.js";
|
|
9
|
+
/** Every API route, by method and path. */
|
|
10
|
+
const ROUTES = new Map([
|
|
11
|
+
["GET /api/privacy", { access: "public", handle: privacy }],
|
|
12
|
+
["GET /api/health", { access: "public", handle: health }],
|
|
13
|
+
["GET /api/admin/status", { access: "admin", handle: adminStatus }],
|
|
14
|
+
["GET /api/admin/questions", { access: "admin", handle: adminQuestions }],
|
|
15
|
+
["POST /api/session", { access: "user", handle: createSession }],
|
|
16
|
+
["DELETE /api/session", { access: "user", handle: closeSession }],
|
|
17
|
+
["POST /api/heartbeat", { access: "user", handle: heartbeat }],
|
|
18
|
+
["POST /api/abort", { access: "user", handle: stopTurn }],
|
|
19
|
+
["POST /api/feedback", { access: "user", handle: feedback }],
|
|
20
|
+
["POST /api/chat", { access: "user", handle: chat }],
|
|
21
|
+
]);
|
|
22
|
+
export function createRequestHandler(context) {
|
|
23
|
+
const { tokens, operations } = context;
|
|
24
|
+
const assets = createAssetHandler(context.clientDir);
|
|
25
|
+
const handleRequest = async (req, res) => {
|
|
26
|
+
res.setHeader("Referrer-Policy", "no-referrer");
|
|
27
|
+
res.setHeader("X-Content-Type-Options", "nosniff");
|
|
28
|
+
res.setHeader("X-Frame-Options", "DENY");
|
|
29
|
+
// Only the path and query are used, so the client's Host header is not trusted to parse.
|
|
30
|
+
let url;
|
|
31
|
+
try {
|
|
32
|
+
url = new URL(req.url || "/", "http://localhost");
|
|
33
|
+
}
|
|
34
|
+
catch {
|
|
35
|
+
return json(res, 400, { error: "Bad request" });
|
|
36
|
+
}
|
|
37
|
+
if (!url.pathname.startsWith("/api/")) {
|
|
38
|
+
const notFound = () => json(res, 404, { error: "Not found" });
|
|
39
|
+
return assets ? assets(req, res, notFound) : notFound();
|
|
40
|
+
}
|
|
41
|
+
const route = ROUTES.get(`${req.method || "GET"} ${url.pathname}`);
|
|
42
|
+
// A path that is not a route is gated like the area it is in, so a caller
|
|
43
|
+
// without the code cannot find out which routes exist.
|
|
44
|
+
const access = route?.access ??
|
|
45
|
+
(url.pathname.startsWith("/api/admin/") ? "admin" : "user");
|
|
46
|
+
if (!mayCall(access, req, tokens))
|
|
47
|
+
return json(res, 401, { error: "Unauthorized" });
|
|
48
|
+
if (!route)
|
|
49
|
+
return json(res, 404, { error: "Not found" });
|
|
50
|
+
return route.handle(context, req, res, url);
|
|
51
|
+
};
|
|
52
|
+
/**
|
|
53
|
+
* Node does not catch a rejection from an async request handler: it becomes an
|
|
54
|
+
* unhandled rejection, which exits the process — and since every session runs in this
|
|
55
|
+
* one process, that takes every other session down too, along with conversations that
|
|
56
|
+
* exist nowhere else.
|
|
57
|
+
*
|
|
58
|
+
* Each route handles the failures it expects. This is the net under the ones it
|
|
59
|
+
* does not, so an unforeseen error costs one request rather than the server.
|
|
60
|
+
*/
|
|
61
|
+
return (req, res) => {
|
|
62
|
+
void handleRequest(req, res).catch((error) => {
|
|
63
|
+
// A stack is what an admin needs here: unlike a bad stone, this is a bug
|
|
64
|
+
// rather than something they can fix by editing a file.
|
|
65
|
+
operations.reportError("request", "Unexpected request failure. See the server log for details.");
|
|
66
|
+
console.error(`Unhandled error in ${req.method} ${req.url}:`, error);
|
|
67
|
+
// Once the body has started there is no status left to send. Destroying the
|
|
68
|
+
// socket surfaces it as a dropped connection, which the client already
|
|
69
|
+
// reports; ending cleanly would truncate the answer and say nothing.
|
|
70
|
+
if (res.headersSent)
|
|
71
|
+
res.destroy();
|
|
72
|
+
else
|
|
73
|
+
json(res, 500, {
|
|
74
|
+
error: "Rosetta hit an unexpected error. Check the server log.",
|
|
75
|
+
});
|
|
76
|
+
});
|
|
77
|
+
};
|
|
78
|
+
}
|
|
79
|
+
function mayCall(access, req, tokens) {
|
|
80
|
+
if (access === "public")
|
|
81
|
+
return true;
|
|
82
|
+
return access === "admin" ? isAdmin(req, tokens) : isUser(req, tokens);
|
|
83
|
+
}
|
|
84
|
+
function privacy({ config, recordings, harness }, _req, res) {
|
|
85
|
+
res.setHeader("Cache-Control", "no-store");
|
|
86
|
+
const body = {
|
|
87
|
+
...config.privacy,
|
|
88
|
+
recording: recordings.enabled,
|
|
89
|
+
retentionDays: config.recordings.retentionDays,
|
|
90
|
+
model: harness.model,
|
|
91
|
+
};
|
|
92
|
+
json(res, 200, body);
|
|
93
|
+
}
|
|
94
|
+
function health({ harness, sessions }, _req, res) {
|
|
95
|
+
json(res, 200, {
|
|
96
|
+
ok: true,
|
|
97
|
+
stone: harness.stone.languageName,
|
|
98
|
+
sessions: sessions.size,
|
|
99
|
+
});
|
|
100
|
+
}
|
|
101
|
+
function adminStatus({ config, harness, sessions, operations, recordings }, _req, res) {
|
|
102
|
+
// Sessions past their limit are ended first, so the page never lists one.
|
|
103
|
+
sessions.sweep();
|
|
104
|
+
const body = {
|
|
105
|
+
stone: summariseStone(harness),
|
|
106
|
+
model: harness.model,
|
|
107
|
+
tools: harness.tools,
|
|
108
|
+
sessions: sessions.size,
|
|
109
|
+
maxSessions: config.maxSessions,
|
|
110
|
+
operations: { ...operations.totals, ...sessions.summary },
|
|
111
|
+
questionLog: {
|
|
112
|
+
enabled: recordings.enabled,
|
|
113
|
+
path: recordings.path ?? null,
|
|
114
|
+
...recordings.health,
|
|
115
|
+
},
|
|
116
|
+
};
|
|
117
|
+
json(res, 200, body);
|
|
118
|
+
}
|
|
119
|
+
function adminQuestions({ recordings }, _req, res, url) {
|
|
120
|
+
const limit = Math.min(Number.parseInt(url.searchParams.get("limit") ?? "100", 10) || 100, 500);
|
|
121
|
+
const body = {
|
|
122
|
+
enabled: recordings.enabled,
|
|
123
|
+
path: recordings.path ?? null,
|
|
124
|
+
entries: recordings.recentQuestions(limit),
|
|
125
|
+
};
|
|
126
|
+
json(res, 200, body);
|
|
127
|
+
}
|
|
128
|
+
async function createSession({ config, harness, sessions, recordings }, _req, res) {
|
|
129
|
+
const session = await sessions.create();
|
|
130
|
+
if (!session) {
|
|
131
|
+
return json(res, 503, {
|
|
132
|
+
error: "Rosetta is at its session limit. Try again shortly.",
|
|
133
|
+
});
|
|
134
|
+
}
|
|
135
|
+
const { stone } = harness;
|
|
136
|
+
const info = {
|
|
137
|
+
sessionId: session.id,
|
|
138
|
+
title: stone.agentName,
|
|
139
|
+
welcome: stone.welcomeMessage,
|
|
140
|
+
suggestions: stone.suggestedQuestions,
|
|
141
|
+
language: stone.syntaxHighlighting,
|
|
142
|
+
recording: recordings.enabled,
|
|
143
|
+
turnTimeoutMs: config.turnLimits.timeoutMs,
|
|
144
|
+
abortGraceMs: config.turnLimits.abortGraceMs,
|
|
145
|
+
};
|
|
146
|
+
json(res, 201, info);
|
|
147
|
+
}
|
|
148
|
+
function closeSession({ sessions }, req, res) {
|
|
149
|
+
sessions.destroy(sessionIdOf(req));
|
|
150
|
+
json(res, 200, { ok: true });
|
|
151
|
+
}
|
|
152
|
+
function heartbeat({ sessions }, req, res) {
|
|
153
|
+
const session = sessions.find(sessionIdOf(req));
|
|
154
|
+
if (!session)
|
|
155
|
+
return json(res, 404, { error: SESSION_NOT_FOUND });
|
|
156
|
+
json(res, 200, {
|
|
157
|
+
ok: true,
|
|
158
|
+
inactivityRemainingMs: session.inactivityRemainingMs(),
|
|
159
|
+
});
|
|
160
|
+
}
|
|
161
|
+
async function stopTurn({ sessions }, req, res) {
|
|
162
|
+
const sessionId = sessionIdOf(req);
|
|
163
|
+
if (!sessions.find(sessionId))
|
|
164
|
+
return json(res, 404, { error: SESSION_NOT_FOUND });
|
|
165
|
+
const body = await bodyOr400(req, res);
|
|
166
|
+
if (!body)
|
|
167
|
+
return;
|
|
168
|
+
if (!validTurnId(body.turnId))
|
|
169
|
+
return json(res, 400, { error: "A valid turnId is required" });
|
|
170
|
+
json(res, 200, { ok: true, stopped: sessions.stop(sessionId, body.turnId) });
|
|
171
|
+
}
|
|
172
|
+
function feedback({ sessions, recordings }, req, res) {
|
|
173
|
+
return handleFeedback(req, res, sessionIdOf(req), sessions, recordings.enabled);
|
|
174
|
+
}
|
|
175
|
+
function chat({ conversations }, req, res) {
|
|
176
|
+
return handleChat(req, res, sessionIdOf(req), conversations);
|
|
177
|
+
}
|
|
178
|
+
/** The session a request is for. The header is absent on the first request of a conversation. */
|
|
179
|
+
function sessionIdOf(req) {
|
|
180
|
+
return req.headers["x-rosetta-session"] ?? "";
|
|
181
|
+
}
|
|
182
|
+
/**
|
|
183
|
+
* The stone as the admin page shows it, typed against the shared StoneSummary
|
|
184
|
+
* so a manifest rename is a compile error rather than a blank field on the page.
|
|
185
|
+
*/
|
|
186
|
+
function summariseStone(harness) {
|
|
187
|
+
const { stone } = harness;
|
|
188
|
+
return {
|
|
189
|
+
agentName: stone.agentName,
|
|
190
|
+
languageName: stone.languageName,
|
|
191
|
+
path: stone.path,
|
|
192
|
+
context: stone.context.map((file) => file.path),
|
|
193
|
+
skills: harness.skills,
|
|
194
|
+
checks: harness.checks,
|
|
195
|
+
warnings: stone.warnings,
|
|
196
|
+
};
|
|
197
|
+
}
|
|
198
|
+
/**
|
|
199
|
+
* Serves the built client. In development Vite serves it and proxies /api here,
|
|
200
|
+
* so dist/ may legitimately not exist yet.
|
|
201
|
+
*
|
|
202
|
+
* sirv rather than a hand-rolled reader: it gets content types, conditional
|
|
203
|
+
* requests and — the reason it matters — containment right. A prefix check like
|
|
204
|
+
* `join(dist, req).startsWith(dist)` passes for a sibling directory named
|
|
205
|
+
* `dist-backup`, which is a real escape.
|
|
206
|
+
*/
|
|
207
|
+
function createAssetHandler(clientDir) {
|
|
208
|
+
if (!existsSync(clientDir))
|
|
209
|
+
return undefined;
|
|
210
|
+
return sirv(clientDir, {
|
|
211
|
+
// /admin is a client-side route with no file behind it.
|
|
212
|
+
single: true,
|
|
213
|
+
// sirv precomputes its file index at startup unless told otherwise, so a
|
|
214
|
+
// rebuild while the server runs would 404 the new hashed assets. At this
|
|
215
|
+
// scale a stat per request costs nothing and removes the footgun.
|
|
216
|
+
dev: true,
|
|
217
|
+
etag: true,
|
|
218
|
+
// dev mode revalidates everything and ignores sirv's own cache options,
|
|
219
|
+
// so the two cases that matter are set here.
|
|
220
|
+
setHeaders(res, pathname) {
|
|
221
|
+
// The entry document must never be cached, or a redeploy is invisible.
|
|
222
|
+
if (pathname === "/" || pathname.endsWith("/index.html")) {
|
|
223
|
+
res.setHeader("Cache-Control", "no-store");
|
|
224
|
+
}
|
|
225
|
+
else if (/^\/assets\/.+\.\w+$/.test(pathname)) {
|
|
226
|
+
// Vite names these by content hash, so a given URL never changes.
|
|
227
|
+
res.setHeader("Cache-Control", "public,max-age=31536000,immutable");
|
|
228
|
+
}
|
|
229
|
+
},
|
|
230
|
+
});
|
|
231
|
+
}
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
/** Well under the idle limits of common proxies, such as nginx's 60-second default. */
|
|
2
|
+
const KEEP_ALIVE_MS = 15_000;
|
|
3
|
+
/**
|
|
4
|
+
* Writes RosettaEvents as NDJSON on an open response.
|
|
5
|
+
*/
|
|
6
|
+
export function openStream(res) {
|
|
7
|
+
res.writeHead(200, {
|
|
8
|
+
"Content-Type": "application/x-ndjson; charset=utf-8",
|
|
9
|
+
"Cache-Control": "no-store, no-transform",
|
|
10
|
+
// Stops reverse proxies buffering the stream into one delayed blob.
|
|
11
|
+
"X-Accel-Buffering": "no",
|
|
12
|
+
});
|
|
13
|
+
let closed = false;
|
|
14
|
+
// A reverse proxy may drop a response that stays silent while the model thinks or a
|
|
15
|
+
// checker runs. A blank line keeps it open; NDJSON readers skip blank lines.
|
|
16
|
+
const keepAlive = setInterval(() => {
|
|
17
|
+
if (res.destroyed)
|
|
18
|
+
clearInterval(keepAlive);
|
|
19
|
+
else
|
|
20
|
+
res.write("\n");
|
|
21
|
+
}, KEEP_ALIVE_MS);
|
|
22
|
+
keepAlive.unref();
|
|
23
|
+
return {
|
|
24
|
+
send(event) {
|
|
25
|
+
if (closed || res.destroyed)
|
|
26
|
+
return;
|
|
27
|
+
res.write(`${JSON.stringify(event)}\n`);
|
|
28
|
+
},
|
|
29
|
+
close(final) {
|
|
30
|
+
if (closed)
|
|
31
|
+
return;
|
|
32
|
+
clearInterval(keepAlive);
|
|
33
|
+
if (final && !res.destroyed)
|
|
34
|
+
res.write(`${JSON.stringify(final)}\n`);
|
|
35
|
+
closed = true;
|
|
36
|
+
res.end();
|
|
37
|
+
},
|
|
38
|
+
};
|
|
39
|
+
}
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Small HTTP helpers with no domain knowledge.
|
|
3
|
+
*
|
|
4
|
+
* Nothing here should know what a stone or a session is; anything that does
|
|
5
|
+
* belongs in the file named for it.
|
|
6
|
+
*/
|
|
7
|
+
/** A request body larger than this is refused rather than buffered. */
|
|
8
|
+
const MAX_BODY_BYTES = 64 * 1024;
|
|
9
|
+
export function json(res, status, body) {
|
|
10
|
+
res.writeHead(status, {
|
|
11
|
+
"Content-Type": "application/json; charset=utf-8",
|
|
12
|
+
"Cache-Control": "no-store",
|
|
13
|
+
});
|
|
14
|
+
res.end(JSON.stringify(body));
|
|
15
|
+
}
|
|
16
|
+
export async function readBody(req) {
|
|
17
|
+
const chunks = [];
|
|
18
|
+
let size = 0;
|
|
19
|
+
for await (const chunk of req) {
|
|
20
|
+
size += chunk.length;
|
|
21
|
+
if (size > MAX_BODY_BYTES)
|
|
22
|
+
throw new Error("Request body is too large");
|
|
23
|
+
chunks.push(chunk);
|
|
24
|
+
}
|
|
25
|
+
const text = Buffer.concat(chunks).toString("utf8");
|
|
26
|
+
const body = text ? JSON.parse(text) : {};
|
|
27
|
+
if (body === null || typeof body !== "object" || Array.isArray(body))
|
|
28
|
+
throw new Error("Request body must be a JSON object");
|
|
29
|
+
return body;
|
|
30
|
+
}
|
|
31
|
+
/** The JSON body, or undefined once a 400 has been sent for one that cannot be read. */
|
|
32
|
+
export async function bodyOr400(req, res) {
|
|
33
|
+
try {
|
|
34
|
+
return await readBody(req);
|
|
35
|
+
}
|
|
36
|
+
catch (error) {
|
|
37
|
+
json(res, 400, { error: error.message });
|
|
38
|
+
return undefined;
|
|
39
|
+
}
|
|
40
|
+
}
|
|
@@ -0,0 +1,112 @@
|
|
|
1
|
+
import { readFileSync } from "node:fs";
|
|
2
|
+
import { createServer as createHttpServer } from "node:http";
|
|
3
|
+
import { createServer as createHttpsServer } from "node:https";
|
|
4
|
+
import { networkInterfaces } from "node:os";
|
|
5
|
+
import { dirname, join, resolve } from "node:path";
|
|
6
|
+
import { fileURLToPath } from "node:url";
|
|
7
|
+
import { Conversations } from "./agent/conversation.js";
|
|
8
|
+
import { createHarness } from "./agent/harness.js";
|
|
9
|
+
import { SessionRegistry } from "./agent/sessions.js";
|
|
10
|
+
import { ConfigError, loadConfig } from "./config.js";
|
|
11
|
+
import { createTokens } from "./http/auth.js";
|
|
12
|
+
import { createRequestHandler } from "./http/routes.js";
|
|
13
|
+
import { Operations } from "./records/operations.js";
|
|
14
|
+
import { Recordings } from "./records/recording.js";
|
|
15
|
+
import { assertRecordingsOutsideStone } from "./stone/file-boundary.js";
|
|
16
|
+
import { loadStone, StoneError } from "./stone/stone.js";
|
|
17
|
+
const root = resolve(dirname(fileURLToPath(import.meta.url)), "..");
|
|
18
|
+
/** Configuration problems are the admin's to fix, so report them without a stack trace. */
|
|
19
|
+
async function boot() {
|
|
20
|
+
try {
|
|
21
|
+
const config = loadConfig(process.env, root, process.argv.slice(2));
|
|
22
|
+
const tokens = createTokens();
|
|
23
|
+
const tls = config.tls && {
|
|
24
|
+
cert: readFileSync(config.tls.certPath),
|
|
25
|
+
key: readFileSync(config.tls.keyPath),
|
|
26
|
+
};
|
|
27
|
+
const stone = await loadStone(config.stonePath);
|
|
28
|
+
await assertRecordingsOutsideStone(stone.path, config.recordings.directory);
|
|
29
|
+
const harness = await createHarness(stone, config.model);
|
|
30
|
+
return { config, tokens, tls, harness };
|
|
31
|
+
}
|
|
32
|
+
catch (error) {
|
|
33
|
+
const problems = error instanceof StoneError || error instanceof ConfigError
|
|
34
|
+
? error.problems
|
|
35
|
+
: [error.message];
|
|
36
|
+
console.error("\nRosetta could not start.\n");
|
|
37
|
+
for (const problem of problems)
|
|
38
|
+
console.error(` ${problem}`);
|
|
39
|
+
console.error("");
|
|
40
|
+
process.exit(1);
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
const { config, tokens, tls, harness } = await boot();
|
|
44
|
+
const operations = new Operations();
|
|
45
|
+
const recordings = new Recordings(config.recordings);
|
|
46
|
+
const sessions = new SessionRegistry(harness, {
|
|
47
|
+
maxSessions: config.maxSessions,
|
|
48
|
+
inactivityMs: config.inactivityMs,
|
|
49
|
+
reportError: (source, message) => operations.reportError(source, message),
|
|
50
|
+
}, recordings);
|
|
51
|
+
const conversations = new Conversations(sessions, recordings, operations, config.turnLimits);
|
|
52
|
+
const handler = createRequestHandler({
|
|
53
|
+
config,
|
|
54
|
+
tokens,
|
|
55
|
+
harness,
|
|
56
|
+
recordings,
|
|
57
|
+
sessions,
|
|
58
|
+
conversations,
|
|
59
|
+
operations,
|
|
60
|
+
clientDir: join(root, "dist"),
|
|
61
|
+
});
|
|
62
|
+
const server = tls
|
|
63
|
+
? createHttpsServer(tls, handler)
|
|
64
|
+
: createHttpServer(handler);
|
|
65
|
+
server.listen(config.port, config.host, printBanner);
|
|
66
|
+
/** What is running and how to reach it. The access codes are shown nowhere else. */
|
|
67
|
+
function printBanner() {
|
|
68
|
+
const urls = lanUrls(config.host, config.port, tls ? "https" : "http");
|
|
69
|
+
const current = harness.stone;
|
|
70
|
+
console.log(`\nRosetta — ${current.agentName}`);
|
|
71
|
+
console.log(`Stone: ${current.path}`);
|
|
72
|
+
console.log(`Context: ${current.context.map((file) => file.path).join(", ") || "(none)"}`);
|
|
73
|
+
console.log(`Skills: ${harness.skills.join(", ") || "(none)"}`);
|
|
74
|
+
console.log(`Checks: ${harness.checks.join(" → ") || "(none)"}`);
|
|
75
|
+
console.log(`Model: ${harness.model}`);
|
|
76
|
+
console.log(`Tools: ${harness.tools.join(", ")}`);
|
|
77
|
+
console.log(`Session recordings: ${recordings.enabled ? recordings.path : "disabled"}`);
|
|
78
|
+
for (const warning of current.warnings)
|
|
79
|
+
console.log(`Warning: ${warning}`);
|
|
80
|
+
console.log(`Open: ${urls.join(" or ")}`);
|
|
81
|
+
console.log(`Token: ${tokens.user ?? "not required (public access)"}`);
|
|
82
|
+
console.log(`Admin: ${tokens.admin}\n`);
|
|
83
|
+
}
|
|
84
|
+
/** Addresses this server can be reached on. */
|
|
85
|
+
function lanUrls(host, port, scheme = "http") {
|
|
86
|
+
if (host !== "0.0.0.0" && host !== "::")
|
|
87
|
+
return [`${scheme}://${host}:${port}`];
|
|
88
|
+
const urls = [];
|
|
89
|
+
for (const addresses of Object.values(networkInterfaces())) {
|
|
90
|
+
for (const address of addresses ?? []) {
|
|
91
|
+
if (address.family === "IPv4" && !address.internal) {
|
|
92
|
+
urls.push(`${scheme}://${address.address}:${port}`);
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
return urls.length ? urls : [`${scheme}://localhost:${port}`];
|
|
97
|
+
}
|
|
98
|
+
let shuttingDown = false;
|
|
99
|
+
async function shutdown() {
|
|
100
|
+
if (shuttingDown)
|
|
101
|
+
return;
|
|
102
|
+
shuttingDown = true;
|
|
103
|
+
server.close();
|
|
104
|
+
const forceClose = setTimeout(() => server.closeAllConnections(), config.turnLimits.abortGraceMs + 1000);
|
|
105
|
+
forceClose.unref();
|
|
106
|
+
await sessions.shutdown();
|
|
107
|
+
await recordings.close();
|
|
108
|
+
server.closeAllConnections();
|
|
109
|
+
clearTimeout(forceClose);
|
|
110
|
+
}
|
|
111
|
+
process.once("SIGINT", shutdown);
|
|
112
|
+
process.once("SIGTERM", shutdown);
|
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
import { addTokenUsage } from "./turn-recording.js";
|
|
2
|
+
/**
|
|
3
|
+
* Process-local outcome, usage, cost, and error totals for admin. Kept whether or
|
|
4
|
+
* not conversations are recorded; nothing here contains credentials or conversation text.
|
|
5
|
+
*/
|
|
6
|
+
export class Operations {
|
|
7
|
+
#startedAt = new Date().toISOString();
|
|
8
|
+
#started = performance.now();
|
|
9
|
+
#outcomes = { completed: 0, failed: 0, stopped: 0, incomplete: 0 };
|
|
10
|
+
#tokens = null;
|
|
11
|
+
#measuredTurns = 0;
|
|
12
|
+
#estimatedCostUsd = null;
|
|
13
|
+
#incompleteCostTurns = 0;
|
|
14
|
+
#incompleteUsageTurns = 0;
|
|
15
|
+
#errors = [];
|
|
16
|
+
reportError(source, message) {
|
|
17
|
+
this.#errors.unshift({
|
|
18
|
+
at: new Date().toISOString(),
|
|
19
|
+
source,
|
|
20
|
+
message: message.slice(0, 500),
|
|
21
|
+
});
|
|
22
|
+
this.#errors.length = Math.min(this.#errors.length, 30);
|
|
23
|
+
}
|
|
24
|
+
recordTurn(outcome, measurement, error) {
|
|
25
|
+
this.#outcomes[outcome]++;
|
|
26
|
+
if (measurement.tokens) {
|
|
27
|
+
this.#tokens = addTokenUsage(this.#tokens, measurement.tokens);
|
|
28
|
+
this.#measuredTurns++;
|
|
29
|
+
}
|
|
30
|
+
if (measurement.estimatedCostUsd !== null)
|
|
31
|
+
this.#estimatedCostUsd =
|
|
32
|
+
(this.#estimatedCostUsd ?? 0) + measurement.estimatedCostUsd;
|
|
33
|
+
if (!measurement.costComplete)
|
|
34
|
+
this.#incompleteCostTurns++;
|
|
35
|
+
if (!measurement.usageComplete)
|
|
36
|
+
this.#incompleteUsageTurns++;
|
|
37
|
+
if (outcome === "failed" || outcome === "incomplete")
|
|
38
|
+
this.reportError("turn", error ?? outcome);
|
|
39
|
+
}
|
|
40
|
+
get totals() {
|
|
41
|
+
return {
|
|
42
|
+
startedAt: this.#startedAt,
|
|
43
|
+
uptimeSeconds: Math.floor((performance.now() - this.#started) / 1000),
|
|
44
|
+
outcomes: { ...this.#outcomes },
|
|
45
|
+
tokens: this.#tokens ? { ...this.#tokens } : null,
|
|
46
|
+
estimatedCostUsd: this.#estimatedCostUsd,
|
|
47
|
+
incompleteCostTurns: this.#incompleteCostTurns,
|
|
48
|
+
measuredTurns: this.#measuredTurns,
|
|
49
|
+
incompleteUsageTurns: this.#incompleteUsageTurns,
|
|
50
|
+
errors: this.#errors.map((error) => ({ ...error })),
|
|
51
|
+
};
|
|
52
|
+
}
|
|
53
|
+
}
|
|
@@ -0,0 +1,186 @@
|
|
|
1
|
+
/** One readable conversation per file. API session credentials never enter the archive. */
|
|
2
|
+
import { mkdir, readdir, rename, stat, unlink, writeFile, } from "node:fs/promises";
|
|
3
|
+
import { join, resolve } from "node:path";
|
|
4
|
+
/**
|
|
5
|
+
* What the server keeps about conversations while recording is on: a transcript
|
|
6
|
+
* file per session, and a short in-memory list of recent questions for admin.
|
|
7
|
+
*/
|
|
8
|
+
export class Recordings {
|
|
9
|
+
path;
|
|
10
|
+
enabled;
|
|
11
|
+
#pending = new Set();
|
|
12
|
+
#active = new Set();
|
|
13
|
+
#recent = [];
|
|
14
|
+
#error = null;
|
|
15
|
+
#failedWrites = 0;
|
|
16
|
+
#retentionDays;
|
|
17
|
+
#ready;
|
|
18
|
+
#timer;
|
|
19
|
+
constructor(options = {}) {
|
|
20
|
+
this.path =
|
|
21
|
+
options.directory === false
|
|
22
|
+
? undefined
|
|
23
|
+
: resolve(options.directory ?? "data/sessions");
|
|
24
|
+
this.enabled = Boolean(this.path);
|
|
25
|
+
this.#retentionDays = options.retentionDays ?? 30;
|
|
26
|
+
if (!Number.isFinite(this.#retentionDays) || this.#retentionDays <= 0)
|
|
27
|
+
throw new Error("Invalid recording retentionDays");
|
|
28
|
+
this.#ready = this.path
|
|
29
|
+
? mkdir(this.path, { recursive: true, mode: 0o700 })
|
|
30
|
+
.then(() => this.prune())
|
|
31
|
+
.catch((error) => this.#fail(error))
|
|
32
|
+
: Promise.resolve();
|
|
33
|
+
if (this.enabled) {
|
|
34
|
+
this.#timer = setInterval(() => {
|
|
35
|
+
void this.prune().catch((error) => this.#fail(error));
|
|
36
|
+
}, 3_600_000);
|
|
37
|
+
this.#timer.unref();
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
get health() {
|
|
41
|
+
return { error: this.#error, failedWrites: this.#failedWrites };
|
|
42
|
+
}
|
|
43
|
+
#fail(error) {
|
|
44
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
45
|
+
if (message !== this.#error)
|
|
46
|
+
console.error(`Session recording: ${message}`);
|
|
47
|
+
this.#error = message;
|
|
48
|
+
this.#failedWrites++;
|
|
49
|
+
}
|
|
50
|
+
/** Starts a session's transcript. Every change is saved as a whole new snapshot of the file. */
|
|
51
|
+
startSession(metadata) {
|
|
52
|
+
const startedAt = new Date().toISOString();
|
|
53
|
+
const basename = startedAt.replace("T", "_").replaceAll(":", "-");
|
|
54
|
+
const conversation = {
|
|
55
|
+
startedAt,
|
|
56
|
+
endedAt: null,
|
|
57
|
+
endReason: null,
|
|
58
|
+
model: metadata.model ?? null,
|
|
59
|
+
stone: metadata.stone ?? null,
|
|
60
|
+
alwaysInContext: metadata.alwaysInContext ?? [],
|
|
61
|
+
messages: [],
|
|
62
|
+
};
|
|
63
|
+
/** Chosen by the first write. */
|
|
64
|
+
let filename;
|
|
65
|
+
/** The newest snapshot not yet written; one write runs at a time. */
|
|
66
|
+
let latest;
|
|
67
|
+
let writing = false;
|
|
68
|
+
let ended = false;
|
|
69
|
+
const save = () => {
|
|
70
|
+
if (!this.enabled)
|
|
71
|
+
return;
|
|
72
|
+
latest = JSON.stringify(conversation, null, 2) + "\n";
|
|
73
|
+
if (writing)
|
|
74
|
+
return; // Keep only the newest pending snapshot while disk catches up.
|
|
75
|
+
writing = true;
|
|
76
|
+
const pending = this.#ready
|
|
77
|
+
.then(async () => {
|
|
78
|
+
while (latest !== undefined) {
|
|
79
|
+
const snapshot = latest;
|
|
80
|
+
latest = undefined;
|
|
81
|
+
if (filename)
|
|
82
|
+
await this.#replaceFile(filename, snapshot);
|
|
83
|
+
else
|
|
84
|
+
filename = await this.#createFile(basename, snapshot);
|
|
85
|
+
}
|
|
86
|
+
})
|
|
87
|
+
.catch((error) => {
|
|
88
|
+
latest = undefined;
|
|
89
|
+
this.#fail(error);
|
|
90
|
+
})
|
|
91
|
+
.finally(() => {
|
|
92
|
+
writing = false;
|
|
93
|
+
this.#pending.delete(pending);
|
|
94
|
+
if (latest !== undefined)
|
|
95
|
+
save();
|
|
96
|
+
else if (ended && filename)
|
|
97
|
+
this.#active.delete(filename);
|
|
98
|
+
});
|
|
99
|
+
this.#pending.add(pending);
|
|
100
|
+
};
|
|
101
|
+
save();
|
|
102
|
+
return {
|
|
103
|
+
add(message) {
|
|
104
|
+
if (!ended) {
|
|
105
|
+
conversation.messages.push(message);
|
|
106
|
+
save();
|
|
107
|
+
}
|
|
108
|
+
},
|
|
109
|
+
save() {
|
|
110
|
+
if (!ended)
|
|
111
|
+
save();
|
|
112
|
+
},
|
|
113
|
+
end(reason) {
|
|
114
|
+
if (ended)
|
|
115
|
+
return;
|
|
116
|
+
conversation.endedAt = new Date().toISOString();
|
|
117
|
+
conversation.endReason = reason;
|
|
118
|
+
ended = true;
|
|
119
|
+
save();
|
|
120
|
+
},
|
|
121
|
+
};
|
|
122
|
+
}
|
|
123
|
+
/** Creates a session's file under a name no other session has, and returns that name. */
|
|
124
|
+
async #createFile(basename, snapshot) {
|
|
125
|
+
// Exclusive creation also handles same-millisecond starts across processes/restarts.
|
|
126
|
+
for (let suffix = 0;; suffix++) {
|
|
127
|
+
const candidate = `${basename}${suffix ? `-${suffix + 1}` : ""}.json`;
|
|
128
|
+
try {
|
|
129
|
+
await writeFile(join(this.path, candidate), snapshot, {
|
|
130
|
+
flag: "wx",
|
|
131
|
+
mode: 0o600,
|
|
132
|
+
});
|
|
133
|
+
this.#active.add(candidate);
|
|
134
|
+
return candidate;
|
|
135
|
+
}
|
|
136
|
+
catch (error) {
|
|
137
|
+
if (error.code !== "EEXIST")
|
|
138
|
+
throw error;
|
|
139
|
+
}
|
|
140
|
+
}
|
|
141
|
+
}
|
|
142
|
+
/** Replaces a session's file in one step, so a reader never sees half a snapshot. */
|
|
143
|
+
async #replaceFile(filename, snapshot) {
|
|
144
|
+
const target = join(this.path, filename);
|
|
145
|
+
await writeFile(`${target}.tmp`, snapshot, { mode: 0o600 });
|
|
146
|
+
await rename(`${target}.tmp`, target);
|
|
147
|
+
}
|
|
148
|
+
/**
|
|
149
|
+
* Adds a finished question to the recent list admin sees. The list stays in
|
|
150
|
+
* memory, holds the last 100, and is not kept at all while recording is off.
|
|
151
|
+
*/
|
|
152
|
+
logQuestion(entry) {
|
|
153
|
+
if (!this.enabled)
|
|
154
|
+
return;
|
|
155
|
+
this.#recent.push(entry);
|
|
156
|
+
if (this.#recent.length > 100)
|
|
157
|
+
this.#recent.shift();
|
|
158
|
+
}
|
|
159
|
+
/** The most recent questions, newest first. */
|
|
160
|
+
recentQuestions(limit) {
|
|
161
|
+
return this.#recent.slice(-Math.max(1, Math.min(100, limit))).reverse();
|
|
162
|
+
}
|
|
163
|
+
async prune() {
|
|
164
|
+
if (!this.path)
|
|
165
|
+
return;
|
|
166
|
+
const cutoff = Date.now() - this.#retentionDays * 86_400_000;
|
|
167
|
+
for (const name of await readdir(this.path)) {
|
|
168
|
+
if (!/^\d{4}-\d{2}-\d{2}_\d{2}-\d{2}-\d{2}\.\d{3}Z(?:-\d+)?\.json$/.test(name) ||
|
|
169
|
+
this.#active.has(name))
|
|
170
|
+
continue;
|
|
171
|
+
const file = join(this.path, name);
|
|
172
|
+
const info = await stat(file);
|
|
173
|
+
if (info.isFile() && info.mtimeMs < cutoff)
|
|
174
|
+
await unlink(file);
|
|
175
|
+
}
|
|
176
|
+
}
|
|
177
|
+
async flush() {
|
|
178
|
+
await this.#ready;
|
|
179
|
+
while (this.#pending.size)
|
|
180
|
+
await Promise.all(this.#pending);
|
|
181
|
+
}
|
|
182
|
+
async close() {
|
|
183
|
+
clearInterval(this.#timer);
|
|
184
|
+
await this.flush();
|
|
185
|
+
}
|
|
186
|
+
}
|