chamba 0.0.1 → 0.2.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +66 -1
- package/dist/cli.js +1691 -0
- package/dist/server.js +1919 -0
- package/inject/annotate.js +49 -0
- package/package.json +24 -3
- package/skill/README.md +12 -0
- package/skill/SKILL.md +285 -0
- package/web/assets/abnfDiagram-VRR7QNED-C5V2hrtE.js +1 -0
- package/web/assets/arc-d7OTmxZy.js +1 -0
- package/web/assets/architectureDiagram-ZJ3FMSHR--CztaeAS.js +36 -0
- package/web/assets/blockDiagram-677ZJIJ3-DkA5Z_uK.js +132 -0
- package/web/assets/c4Diagram-LMCZKHZV-C78dI0l-.js +10 -0
- package/web/assets/channel-D9AxU8kk.js +1 -0
- package/web/assets/chunk-2Q5K7J3B-LBNP12XV.js +1 -0
- package/web/assets/chunk-32BRIVSS-DZoXrhCs.js +1 -0
- package/web/assets/chunk-5VM5RSS4-CPjoRxF_.js +15 -0
- package/web/assets/chunk-EX3LRPZG-DSlL8FE9.js +231 -0
- package/web/assets/chunk-JWPE2WC7-BjbBLYge.js +1 -0
- package/web/assets/chunk-MOJQB5TN-DCKqdaDr.js +88 -0
- package/web/assets/chunk-RYQCIY6F-D5yAURhE.js +1 -0
- package/web/assets/chunk-V7JOEXUC-DKY2sn06.js +206 -0
- package/web/assets/chunk-VR4S4FIN-DGiVMHh6.js +1 -0
- package/web/assets/chunk-XXDRQBXY-CUI1Tkrr.js +1 -0
- package/web/assets/classDiagram-OUVF2IWQ-D7gWuzIF.js +1 -0
- package/web/assets/classDiagram-v2-EOCWNBFH-D7gWuzIF.js +1 -0
- package/web/assets/cose-bilkent-JH36ORCC-Ao3LzalV.js +1 -0
- package/web/assets/cynefin-VYW2F7L2-7JQwpRwH.js +178 -0
- package/web/assets/cynefinDiagram-TSTJHNR4-BfuANy_V.js +62 -0
- package/web/assets/cytoscape.esm-DTSO7Bv0.js +331 -0
- package/web/assets/dagre-VKFMJZFB-DHVnVJCL.js +4 -0
- package/web/assets/defaultLocale-DX6XiGOO.js +1 -0
- package/web/assets/diagram-FQU43EPY-OYrtwA2f.js +3 -0
- package/web/assets/diagram-G47NLZAW-CbkLZxYp.js +24 -0
- package/web/assets/diagram-NH7WQ7WH-B0z_jTdi.js +24 -0
- package/web/assets/diagram-OA4YK3LP-BRu_2nwx.js +30 -0
- package/web/assets/diagram-WEI45ONY-B9cpw86D.js +41 -0
- package/web/assets/ebnfDiagram-CCIWWBDH-DaBQ7T4R.js +1 -0
- package/web/assets/erDiagram-Q63AITRT-D6PQiFj3.js +85 -0
- package/web/assets/flowDiagram-23GEKE2U-p_8Wj7It.js +156 -0
- package/web/assets/ganttDiagram-NO4QXBWP-BZBwuqu3.js +292 -0
- package/web/assets/gitGraphDiagram-IHSO6WYX-DyPcQ-91.js +106 -0
- package/web/assets/graph-C9eacEi8.js +1 -0
- package/web/assets/highlighted-body-OFNGDK62-Cq7RJZcn.js +1 -0
- package/web/assets/index-BN-QUr5b.js +350 -0
- package/web/assets/index-D6-LVQ5m.css +1 -0
- package/web/assets/infoDiagram-FWYZ7A6U-sAgBAeWC.js +2 -0
- package/web/assets/init-Gi6I4Gst.js +1 -0
- package/web/assets/ishikawaDiagram-FXEZZL3T-COEn9wCs.js +70 -0
- package/web/assets/journeyDiagram-5HDEW3XC-BPddPRXW.js +139 -0
- package/web/assets/kanban-definition-HUTT4EX6-B8TW97Oy.js +89 -0
- package/web/assets/katex-C5jXJg4s.js +257 -0
- package/web/assets/layout-DEXfKzaS.js +1 -0
- package/web/assets/linear-CnW5uQ_k.js +1 -0
- package/web/assets/map-Czzmt4hB.js +1 -0
- package/web/assets/mermaid.core-bOV5iNX_.js +314 -0
- package/web/assets/mindmap-definition-LN4V7U3C-Dtr4xW_-.js +96 -0
- package/web/assets/ordinal-Cboi1Yqb.js +1 -0
- package/web/assets/pegDiagram-2B236MQR-DqBITK5N.js +1 -0
- package/web/assets/pieDiagram-ENE6RG2P-DXjxZgy3.js +39 -0
- package/web/assets/quadrantDiagram-ABIIQ3AL-D4MXx3U7.js +7 -0
- package/web/assets/railroadDiagram-RFXS5EU6-LPfpHg4_.js +1 -0
- package/web/assets/requirementDiagram-TGXJPOKE-CiDswbjt.js +84 -0
- package/web/assets/sankeyDiagram-HTMAVEWB-VfN_qTEI.js +40 -0
- package/web/assets/sequenceDiagram-DBY2YBRQ-kJRIx4zm.js +162 -0
- package/web/assets/sizeCapture-X5ZJPWSS-D8vJbAt-.js +1 -0
- package/web/assets/stateDiagram-2N3HPSRC-9qku-qAj.js +1 -0
- package/web/assets/stateDiagram-v2-6OUMAXLB-DbOQICyv.js +1 -0
- package/web/assets/swimlanes-5IMT3BWC-Cdb5FDUM.js +2 -0
- package/web/assets/swimlanesDiagram-G3AALYLV-EDpLmqQv.js +8 -0
- package/web/assets/timeline-definition-FHXFAJF6-D0hyJCMm.js +120 -0
- package/web/assets/vennDiagram-L72KCM5P-KsCkGMtr.js +34 -0
- package/web/assets/wardleyDiagram-EHGQE667-B4jT_1ak.js +78 -0
- package/web/assets/xychartDiagram-FW5EYKEG-Bmy0rt8j.js +7 -0
- package/web/index.html +28 -0
package/dist/server.js
ADDED
|
@@ -0,0 +1,1919 @@
|
|
|
1
|
+
// packages/server/src/index.ts
|
|
2
|
+
import { serve as honoServe } from "@hono/node-server";
|
|
3
|
+
|
|
4
|
+
// packages/shared/src/annotations.ts
|
|
5
|
+
import { z as z3 } from "zod";
|
|
6
|
+
|
|
7
|
+
// packages/shared/src/messages.ts
|
|
8
|
+
import { z as z2 } from "zod";
|
|
9
|
+
|
|
10
|
+
// packages/shared/src/session.ts
|
|
11
|
+
import { z } from "zod";
|
|
12
|
+
var sessionKindSchema = z.enum(["everyday", "spec"]);
|
|
13
|
+
var sessionStatusSchema = z.enum(["active", "ended"]);
|
|
14
|
+
var sessionMetaSchema = z.object({
|
|
15
|
+
id: z.string().min(1),
|
|
16
|
+
kind: sessionKindSchema.optional(),
|
|
17
|
+
title: z.string().optional(),
|
|
18
|
+
status: sessionStatusSchema,
|
|
19
|
+
createdAt: z.string().datetime(),
|
|
20
|
+
updatedAt: z.string().datetime()
|
|
21
|
+
});
|
|
22
|
+
|
|
23
|
+
// packages/shared/src/messages.ts
|
|
24
|
+
var attachmentSchema = z2.object({
|
|
25
|
+
id: z2.string().min(1),
|
|
26
|
+
name: z2.string().min(1),
|
|
27
|
+
relPath: z2.string().min(1),
|
|
28
|
+
contentType: z2.string().min(1),
|
|
29
|
+
size: z2.number().int().nonnegative()
|
|
30
|
+
});
|
|
31
|
+
var messagePayloadSchema = z2.object({
|
|
32
|
+
text: z2.string().min(1).optional(),
|
|
33
|
+
attachments: z2.array(attachmentSchema).optional()
|
|
34
|
+
}).refine((p) => (p.text?.length ?? 0) > 0 || (p.attachments?.length ?? 0) > 0, {
|
|
35
|
+
message: "a message needs text or at least one attachment"
|
|
36
|
+
});
|
|
37
|
+
var systemReasonSchema = z2.enum([
|
|
38
|
+
"session-created",
|
|
39
|
+
"user-moved-away",
|
|
40
|
+
"session-ended",
|
|
41
|
+
"kind-chosen"
|
|
42
|
+
]);
|
|
43
|
+
var systemPayloadSchema = z2.object({
|
|
44
|
+
reason: systemReasonSchema,
|
|
45
|
+
to: z2.string().min(1).optional(),
|
|
46
|
+
kind: sessionKindSchema.optional()
|
|
47
|
+
});
|
|
48
|
+
|
|
49
|
+
// packages/shared/src/annotations.ts
|
|
50
|
+
var ANNOTATION_BATCH = "annotation-batch";
|
|
51
|
+
var annotationSourceSchema = z3.object({
|
|
52
|
+
file: z3.string().min(1),
|
|
53
|
+
line: z3.number().int().positive().optional(),
|
|
54
|
+
column: z3.number().int().positive().optional()
|
|
55
|
+
});
|
|
56
|
+
var annotationComponentSchema = z3.object({
|
|
57
|
+
name: z3.string().min(1),
|
|
58
|
+
source: annotationSourceSchema.optional()
|
|
59
|
+
});
|
|
60
|
+
var annotationTargetSchema = z3.object({
|
|
61
|
+
selector: z3.string().min(1),
|
|
62
|
+
pageUrl: z3.string().min(1),
|
|
63
|
+
viewport: z3.object({
|
|
64
|
+
width: z3.number().int().positive(),
|
|
65
|
+
height: z3.number().int().positive()
|
|
66
|
+
}),
|
|
67
|
+
label: z3.string().optional(),
|
|
68
|
+
component: annotationComponentSchema.optional()
|
|
69
|
+
});
|
|
70
|
+
var annotationItemSchema = z3.object({
|
|
71
|
+
note: z3.string().min(1).optional(),
|
|
72
|
+
target: annotationTargetSchema,
|
|
73
|
+
screenshot: attachmentSchema.optional()
|
|
74
|
+
});
|
|
75
|
+
var annotationBatchPayloadSchema = z3.object({
|
|
76
|
+
items: z3.array(annotationItemSchema).min(1)
|
|
77
|
+
});
|
|
78
|
+
|
|
79
|
+
// packages/shared/src/api.ts
|
|
80
|
+
import { z as z7 } from "zod";
|
|
81
|
+
|
|
82
|
+
// packages/shared/src/events.ts
|
|
83
|
+
import { z as z4 } from "zod";
|
|
84
|
+
var eventSourceSchema = z4.enum(["user", "agent", "system"]);
|
|
85
|
+
var eventEnvelopeSchema = z4.object({
|
|
86
|
+
id: z4.string().uuid(),
|
|
87
|
+
seq: z4.number().int().nonnegative(),
|
|
88
|
+
sessionId: z4.string().min(1),
|
|
89
|
+
ts: z4.string().datetime(),
|
|
90
|
+
type: z4.string().min(1),
|
|
91
|
+
source: eventSourceSchema,
|
|
92
|
+
payload: z4.unknown()
|
|
93
|
+
});
|
|
94
|
+
|
|
95
|
+
// packages/shared/src/presence.ts
|
|
96
|
+
import { z as z5 } from "zod";
|
|
97
|
+
var presenceSchema = z5.enum(["listening", "working", "away"]);
|
|
98
|
+
|
|
99
|
+
// packages/shared/src/spec.ts
|
|
100
|
+
import { z as z6 } from "zod";
|
|
101
|
+
var QUESTION_CARD = "question-card";
|
|
102
|
+
var DECISION = "decision";
|
|
103
|
+
var ARTIFACT = "artifact";
|
|
104
|
+
var questionOptionSchema = z6.object({
|
|
105
|
+
label: z6.string().min(1),
|
|
106
|
+
description: z6.string().min(1),
|
|
107
|
+
recommended: z6.boolean().optional()
|
|
108
|
+
});
|
|
109
|
+
var questionCardPayloadSchema = z6.object({
|
|
110
|
+
cardId: z6.string().uuid(),
|
|
111
|
+
question: z6.string().min(1),
|
|
112
|
+
options: z6.array(questionOptionSchema).min(2).max(6),
|
|
113
|
+
/** Optional custom wording for the stop-anytime affordance. */
|
|
114
|
+
stopHint: z6.string().min(1).optional()
|
|
115
|
+
});
|
|
116
|
+
var decisionStatusSchema = z6.enum(["confirmed", "assumed"]);
|
|
117
|
+
var decisionPayloadSchema = z6.object({
|
|
118
|
+
summary: z6.string().min(1),
|
|
119
|
+
status: decisionStatusSchema,
|
|
120
|
+
detail: z6.string().min(1).optional()
|
|
121
|
+
});
|
|
122
|
+
var artifactKindSchema = z6.enum(["draft", "mock", "diagram"]);
|
|
123
|
+
var artifactRefSchema = z6.object({
|
|
124
|
+
kind: artifactKindSchema,
|
|
125
|
+
name: z6.string().min(1),
|
|
126
|
+
relPath: z6.string().min(1),
|
|
127
|
+
contentType: z6.string().min(1),
|
|
128
|
+
size: z6.number().int().nonnegative()
|
|
129
|
+
});
|
|
130
|
+
var artifactPayloadSchema = z6.object({
|
|
131
|
+
artifact: artifactRefSchema
|
|
132
|
+
});
|
|
133
|
+
|
|
134
|
+
// packages/shared/src/api.ts
|
|
135
|
+
var cursorSchema = z7.number().int().nonnegative();
|
|
136
|
+
var createSessionReqSchema = z7.object({
|
|
137
|
+
kind: sessionKindSchema.optional(),
|
|
138
|
+
title: z7.string().min(1).optional()
|
|
139
|
+
});
|
|
140
|
+
var setSessionKindReqSchema = z7.object({
|
|
141
|
+
kind: sessionKindSchema
|
|
142
|
+
});
|
|
143
|
+
var messageBodySchema = z7.object({
|
|
144
|
+
text: z7.string().min(1).optional(),
|
|
145
|
+
attachmentIds: z7.array(z7.string().min(1)).optional()
|
|
146
|
+
}).refine((r) => (r.text?.length ?? 0) > 0 || (r.attachmentIds?.length ?? 0) > 0, {
|
|
147
|
+
message: "a message needs text or at least one attachment"
|
|
148
|
+
});
|
|
149
|
+
var postMessageReqSchema = messageBodySchema;
|
|
150
|
+
var replyReqSchema = messageBodySchema;
|
|
151
|
+
var annotationItemReqSchema = z7.object({
|
|
152
|
+
note: z7.string().min(1).optional(),
|
|
153
|
+
target: annotationTargetSchema,
|
|
154
|
+
screenshotAttachmentId: z7.string().min(1).optional()
|
|
155
|
+
});
|
|
156
|
+
var postAnnotationsReqSchema = z7.object({
|
|
157
|
+
items: z7.array(annotationItemReqSchema).min(1)
|
|
158
|
+
});
|
|
159
|
+
var moveAwayReqSchema = z7.object({
|
|
160
|
+
to: z7.string().min(1).optional()
|
|
161
|
+
});
|
|
162
|
+
var postQuestionCardReqSchema = questionCardPayloadSchema;
|
|
163
|
+
var postDecisionReqSchema = decisionPayloadSchema;
|
|
164
|
+
var postArtifactReqSchema = z7.object({
|
|
165
|
+
kind: artifactKindSchema,
|
|
166
|
+
name: z7.string().min(1),
|
|
167
|
+
content: z7.string().min(1)
|
|
168
|
+
});
|
|
169
|
+
var attachmentResSchema = z7.object({
|
|
170
|
+
attachment: attachmentSchema
|
|
171
|
+
});
|
|
172
|
+
var eventResSchema = z7.object({
|
|
173
|
+
event: eventEnvelopeSchema
|
|
174
|
+
});
|
|
175
|
+
var pollResSchema = z7.object({
|
|
176
|
+
events: z7.array(eventEnvelopeSchema),
|
|
177
|
+
cursor: cursorSchema,
|
|
178
|
+
ended: z7.boolean().optional()
|
|
179
|
+
});
|
|
180
|
+
var fetchMoreSchema = z7.object({
|
|
181
|
+
hasMore: z7.boolean(),
|
|
182
|
+
earliestLoadedSeq: cursorSchema,
|
|
183
|
+
hint: z7.string()
|
|
184
|
+
});
|
|
185
|
+
var attachDigestSchema = z7.object({
|
|
186
|
+
meta: sessionMetaSchema,
|
|
187
|
+
presence: presenceSchema,
|
|
188
|
+
tail: z7.array(eventEnvelopeSchema),
|
|
189
|
+
cursor: cursorSchema,
|
|
190
|
+
fetchMore: fetchMoreSchema
|
|
191
|
+
});
|
|
192
|
+
var sessionSummarySchema = z7.object({
|
|
193
|
+
meta: sessionMetaSchema,
|
|
194
|
+
presence: presenceSchema
|
|
195
|
+
});
|
|
196
|
+
var listResSchema = z7.object({
|
|
197
|
+
sessions: z7.array(sessionSummarySchema)
|
|
198
|
+
});
|
|
199
|
+
var sessionResSchema = z7.object({
|
|
200
|
+
session: sessionSummarySchema
|
|
201
|
+
});
|
|
202
|
+
var healthResSchema = z7.object({
|
|
203
|
+
ok: z7.boolean(),
|
|
204
|
+
sessions: cursorSchema,
|
|
205
|
+
home: z7.string().min(1).optional(),
|
|
206
|
+
port: z7.number().int().positive().optional(),
|
|
207
|
+
version: z7.string().min(1).optional()
|
|
208
|
+
});
|
|
209
|
+
|
|
210
|
+
// packages/shared/src/issues.ts
|
|
211
|
+
import { z as z8 } from "zod";
|
|
212
|
+
var issueTypeSchema = z8.enum(["bug", "feature"]);
|
|
213
|
+
var postIssueReqSchema = z8.object({
|
|
214
|
+
type: issueTypeSchema,
|
|
215
|
+
text: z8.string().min(1),
|
|
216
|
+
screenshotAttachmentIds: z8.array(z8.string().min(1)).optional()
|
|
217
|
+
});
|
|
218
|
+
var issueResSchema = z8.object({
|
|
219
|
+
id: z8.string().min(1),
|
|
220
|
+
path: z8.string().min(1)
|
|
221
|
+
});
|
|
222
|
+
|
|
223
|
+
// packages/shared/src/ws.ts
|
|
224
|
+
import { z as z9 } from "zod";
|
|
225
|
+
var wsServerMessageSchema = z9.discriminatedUnion("t", [
|
|
226
|
+
z9.object({
|
|
227
|
+
t: z9.literal("hello"),
|
|
228
|
+
meta: sessionMetaSchema,
|
|
229
|
+
presence: presenceSchema
|
|
230
|
+
}),
|
|
231
|
+
z9.object({
|
|
232
|
+
t: z9.literal("append"),
|
|
233
|
+
event: eventEnvelopeSchema
|
|
234
|
+
}),
|
|
235
|
+
z9.object({
|
|
236
|
+
t: z9.literal("presence"),
|
|
237
|
+
state: presenceSchema
|
|
238
|
+
}),
|
|
239
|
+
z9.object({
|
|
240
|
+
t: z9.literal("kind"),
|
|
241
|
+
meta: sessionMetaSchema
|
|
242
|
+
}),
|
|
243
|
+
z9.object({
|
|
244
|
+
t: z9.literal("ended"),
|
|
245
|
+
meta: sessionMetaSchema
|
|
246
|
+
})
|
|
247
|
+
]);
|
|
248
|
+
|
|
249
|
+
// packages/server/src/app.ts
|
|
250
|
+
import { createNodeWebSocket } from "@hono/node-ws";
|
|
251
|
+
import { Hono } from "hono";
|
|
252
|
+
|
|
253
|
+
// packages/server/src/inject.ts
|
|
254
|
+
import { existsSync } from "node:fs";
|
|
255
|
+
import { readFile } from "node:fs/promises";
|
|
256
|
+
import path from "node:path";
|
|
257
|
+
import { fileURLToPath } from "node:url";
|
|
258
|
+
function resolveInjectDist() {
|
|
259
|
+
const here = path.dirname(fileURLToPath(import.meta.url));
|
|
260
|
+
const dev = path.resolve(here, "../../inject/dist");
|
|
261
|
+
const packaged = path.resolve(here, "../inject");
|
|
262
|
+
return [dev, packaged].find((dir) => existsSync(path.join(dir, "annotate.js"))) ?? dev;
|
|
263
|
+
}
|
|
264
|
+
function createInjectHandler(injectDist) {
|
|
265
|
+
const file = path.join(injectDist, "annotate.js");
|
|
266
|
+
return async (c) => {
|
|
267
|
+
let bytes;
|
|
268
|
+
try {
|
|
269
|
+
const raw = await readFile(file);
|
|
270
|
+
bytes = new Uint8Array(raw.byteLength);
|
|
271
|
+
bytes.set(raw);
|
|
272
|
+
} catch {
|
|
273
|
+
return c.text("annotation client not built - run `pnpm --filter @chamba/inject build`", 404);
|
|
274
|
+
}
|
|
275
|
+
return c.body(bytes, 200, {
|
|
276
|
+
"content-type": "text/javascript; charset=utf-8",
|
|
277
|
+
"cache-control": "no-cache",
|
|
278
|
+
"access-control-allow-origin": "*"
|
|
279
|
+
});
|
|
280
|
+
};
|
|
281
|
+
}
|
|
282
|
+
|
|
283
|
+
// packages/server/src/render.ts
|
|
284
|
+
import { marked } from "marked";
|
|
285
|
+
var INJECT_TAG = '<script src="/inject/annotate.js"></script>';
|
|
286
|
+
async function renderArtifactHtml(file, content) {
|
|
287
|
+
const lower = file.toLowerCase();
|
|
288
|
+
if (lower.endsWith(".html") || lower.endsWith(".htm")) {
|
|
289
|
+
return injectInto(content);
|
|
290
|
+
}
|
|
291
|
+
if (lower.endsWith(".md") || lower.endsWith(".markdown")) {
|
|
292
|
+
const body = await marked.parse(content, { async: false });
|
|
293
|
+
return draftDocument(body);
|
|
294
|
+
}
|
|
295
|
+
return draftDocument(`<pre>${escapeHtml(content)}</pre>`);
|
|
296
|
+
}
|
|
297
|
+
function injectInto(html) {
|
|
298
|
+
const idx = html.toLowerCase().lastIndexOf("</body>");
|
|
299
|
+
if (idx === -1) return `${html}
|
|
300
|
+
${INJECT_TAG}`;
|
|
301
|
+
return `${html.slice(0, idx)}${INJECT_TAG}
|
|
302
|
+
${html.slice(idx)}`;
|
|
303
|
+
}
|
|
304
|
+
function draftDocument(body) {
|
|
305
|
+
return `<!doctype html>
|
|
306
|
+
<html lang="en">
|
|
307
|
+
<head>
|
|
308
|
+
<meta charset="utf-8" />
|
|
309
|
+
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
|
310
|
+
<style>
|
|
311
|
+
:root { color-scheme: light dark; }
|
|
312
|
+
body {
|
|
313
|
+
margin: 0 auto; max-width: 46rem; padding: 2.5rem 1.5rem;
|
|
314
|
+
font: 16px/1.65 -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Helvetica, Arial, sans-serif;
|
|
315
|
+
color: #1a1a1a; background: #fff;
|
|
316
|
+
}
|
|
317
|
+
@media (prefers-color-scheme: dark) { body { color: #e6e6e6; background: #0d0d0d; } }
|
|
318
|
+
h1, h2, h3 { line-height: 1.25; margin-top: 1.8em; }
|
|
319
|
+
h1 { font-size: 1.8rem; } h2 { font-size: 1.4rem; } h3 { font-size: 1.15rem; }
|
|
320
|
+
code { font-family: ui-monospace, SFMono-Regular, Menlo, monospace; font-size: 0.9em; }
|
|
321
|
+
pre { background: rgba(127,127,127,0.12); padding: 1rem; border-radius: 8px; overflow-x: auto; }
|
|
322
|
+
pre code { background: none; padding: 0; }
|
|
323
|
+
:not(pre) > code { background: rgba(127,127,127,0.14); padding: 0.15em 0.35em; border-radius: 4px; }
|
|
324
|
+
blockquote { margin: 1em 0; padding-left: 1em; border-left: 3px solid rgba(127,127,127,0.35); color: inherit; opacity: 0.85; }
|
|
325
|
+
table { border-collapse: collapse; } th, td { border: 1px solid rgba(127,127,127,0.3); padding: 0.4em 0.7em; }
|
|
326
|
+
a { color: #2563eb; } @media (prefers-color-scheme: dark) { a { color: #60a5fa; } }
|
|
327
|
+
</style>
|
|
328
|
+
</head>
|
|
329
|
+
<body>
|
|
330
|
+
${body}
|
|
331
|
+
${INJECT_TAG}
|
|
332
|
+
</body>
|
|
333
|
+
</html>`;
|
|
334
|
+
}
|
|
335
|
+
function escapeHtml(text) {
|
|
336
|
+
return text.replace(/&/g, "&").replace(/</g, "<").replace(/>/g, ">");
|
|
337
|
+
}
|
|
338
|
+
|
|
339
|
+
// packages/server/src/static.ts
|
|
340
|
+
import { existsSync as existsSync2 } from "node:fs";
|
|
341
|
+
import { readFile as readFile2 } from "node:fs/promises";
|
|
342
|
+
import path3 from "node:path";
|
|
343
|
+
import { fileURLToPath as fileURLToPath2 } from "node:url";
|
|
344
|
+
|
|
345
|
+
// packages/server/src/mime.ts
|
|
346
|
+
import path2 from "node:path";
|
|
347
|
+
var CONTENT_TYPES = {
|
|
348
|
+
".html": "text/html; charset=utf-8",
|
|
349
|
+
".js": "text/javascript; charset=utf-8",
|
|
350
|
+
".mjs": "text/javascript; charset=utf-8",
|
|
351
|
+
".css": "text/css; charset=utf-8",
|
|
352
|
+
".json": "application/json; charset=utf-8",
|
|
353
|
+
".svg": "image/svg+xml",
|
|
354
|
+
".png": "image/png",
|
|
355
|
+
".jpg": "image/jpeg",
|
|
356
|
+
".jpeg": "image/jpeg",
|
|
357
|
+
".gif": "image/gif",
|
|
358
|
+
".webp": "image/webp",
|
|
359
|
+
".ico": "image/x-icon",
|
|
360
|
+
".woff2": "font/woff2",
|
|
361
|
+
".map": "application/json; charset=utf-8",
|
|
362
|
+
".pdf": "application/pdf",
|
|
363
|
+
".txt": "text/plain; charset=utf-8",
|
|
364
|
+
".md": "text/markdown; charset=utf-8"
|
|
365
|
+
};
|
|
366
|
+
function contentTypeFor(filePath) {
|
|
367
|
+
return CONTENT_TYPES[path2.extname(filePath).toLowerCase()] ?? "application/octet-stream";
|
|
368
|
+
}
|
|
369
|
+
|
|
370
|
+
// packages/server/src/static.ts
|
|
371
|
+
function resolveWebDist() {
|
|
372
|
+
const here = path3.dirname(fileURLToPath2(import.meta.url));
|
|
373
|
+
const dev = path3.resolve(here, "../../web/dist");
|
|
374
|
+
const packaged = path3.resolve(here, "../web");
|
|
375
|
+
return [dev, packaged].find((dir) => existsSync2(path3.join(dir, "index.html"))) ?? dev;
|
|
376
|
+
}
|
|
377
|
+
function createStaticHandler(webDist) {
|
|
378
|
+
return async (c) => {
|
|
379
|
+
let pathname;
|
|
380
|
+
try {
|
|
381
|
+
pathname = decodeURIComponent(new URL(c.req.url).pathname);
|
|
382
|
+
} catch {
|
|
383
|
+
return c.json({ error: "not found" }, 404);
|
|
384
|
+
}
|
|
385
|
+
if (pathname.startsWith("/api") || pathname.startsWith("/ws")) {
|
|
386
|
+
return c.json({ error: "not found" }, 404);
|
|
387
|
+
}
|
|
388
|
+
const relative = pathname === "/" ? "index.html" : pathname.replace(/^\/+/, "");
|
|
389
|
+
const filePath = path3.resolve(webDist, relative);
|
|
390
|
+
if (filePath !== webDist && !filePath.startsWith(webDist + path3.sep)) {
|
|
391
|
+
return c.json({ error: "not found" }, 404);
|
|
392
|
+
}
|
|
393
|
+
const direct = await readFileOrNull(filePath);
|
|
394
|
+
if (direct) {
|
|
395
|
+
return c.body(direct, 200, { "content-type": contentTypeFor(filePath) });
|
|
396
|
+
}
|
|
397
|
+
const indexHtml = await readFileOrNull(path3.join(webDist, "index.html"));
|
|
398
|
+
if (indexHtml) {
|
|
399
|
+
return c.body(indexHtml, 200, { "content-type": "text/html; charset=utf-8" });
|
|
400
|
+
}
|
|
401
|
+
return c.text("web build not found - run `pnpm --filter @chamba/web build`", 404);
|
|
402
|
+
};
|
|
403
|
+
}
|
|
404
|
+
async function readFileOrNull(filePath) {
|
|
405
|
+
try {
|
|
406
|
+
const raw = await readFile2(filePath);
|
|
407
|
+
const bytes = new Uint8Array(raw.byteLength);
|
|
408
|
+
bytes.set(raw);
|
|
409
|
+
return bytes;
|
|
410
|
+
} catch {
|
|
411
|
+
return null;
|
|
412
|
+
}
|
|
413
|
+
}
|
|
414
|
+
|
|
415
|
+
// packages/server/src/app.ts
|
|
416
|
+
var INLINE_SAFE_TYPES = /* @__PURE__ */ new Set([
|
|
417
|
+
"image/png",
|
|
418
|
+
"image/jpeg",
|
|
419
|
+
"image/gif",
|
|
420
|
+
"image/webp",
|
|
421
|
+
"image/x-icon"
|
|
422
|
+
]);
|
|
423
|
+
var MAX_UPLOAD_BYTES = 25 * 1024 * 1024;
|
|
424
|
+
function createApp(ctx) {
|
|
425
|
+
const app = new Hono();
|
|
426
|
+
const { injectWebSocket, upgradeWebSocket } = createNodeWebSocket({ app });
|
|
427
|
+
ctx.presence.onChange((id, state) => ctx.hub.broadcast(id, { t: "presence", state }));
|
|
428
|
+
const summaryOf = (meta) => ({
|
|
429
|
+
meta,
|
|
430
|
+
presence: ctx.presence.get(meta.id)
|
|
431
|
+
});
|
|
432
|
+
app.get("/api/health", async (c) => {
|
|
433
|
+
const all = await ctx.sessions.list();
|
|
434
|
+
const active = all.filter((meta) => meta.status === "active").length;
|
|
435
|
+
return c.json({
|
|
436
|
+
ok: true,
|
|
437
|
+
sessions: active,
|
|
438
|
+
home: ctx.home,
|
|
439
|
+
port: ctx.port,
|
|
440
|
+
version: ctx.version
|
|
441
|
+
});
|
|
442
|
+
});
|
|
443
|
+
app.post("/api/sessions", async (c) => {
|
|
444
|
+
const parsed = createSessionReqSchema.safeParse(await readJson(c));
|
|
445
|
+
if (!parsed.success) return badRequest(c, parsed.error.issues);
|
|
446
|
+
const meta = await ctx.sessions.create(parsed.data);
|
|
447
|
+
return c.json({ session: summaryOf(meta) }, 201);
|
|
448
|
+
});
|
|
449
|
+
app.get("/api/sessions", async (c) => {
|
|
450
|
+
const all = await ctx.sessions.list();
|
|
451
|
+
return c.json({ sessions: all.map(summaryOf) });
|
|
452
|
+
});
|
|
453
|
+
app.get("/api/sessions/:id", async (c) => {
|
|
454
|
+
const meta = await ctx.sessions.getMeta(c.req.param("id"));
|
|
455
|
+
if (!meta) return notFound(c);
|
|
456
|
+
return c.json({ session: summaryOf(meta) });
|
|
457
|
+
});
|
|
458
|
+
app.post("/api/sessions/:id/messages", async (c) => {
|
|
459
|
+
const id = c.req.param("id");
|
|
460
|
+
const meta = await ctx.sessions.getMeta(id);
|
|
461
|
+
if (!meta) return notFound(c);
|
|
462
|
+
if (meta.status === "ended") return c.json({ error: "session ended" }, 409);
|
|
463
|
+
const parsed = postMessageReqSchema.safeParse(await readJson(c));
|
|
464
|
+
if (!parsed.success) return badRequest(c, parsed.error.issues);
|
|
465
|
+
const attachments = await resolveAttachments(ctx, id, parsed.data.attachmentIds);
|
|
466
|
+
if (attachments === null) return c.json({ error: "unknown attachment" }, 400);
|
|
467
|
+
const event = await ctx.sessions.postMessage(id, { text: parsed.data.text, attachments });
|
|
468
|
+
return c.json({ event }, 201);
|
|
469
|
+
});
|
|
470
|
+
app.post("/api/sessions/:id/reply", async (c) => {
|
|
471
|
+
const id = c.req.param("id");
|
|
472
|
+
const meta = await ctx.sessions.getMeta(id);
|
|
473
|
+
if (!meta) return notFound(c);
|
|
474
|
+
if (meta.status === "ended") return c.json({ error: "session ended" }, 409);
|
|
475
|
+
const parsed = replyReqSchema.safeParse(await readJson(c));
|
|
476
|
+
if (!parsed.success) return badRequest(c, parsed.error.issues);
|
|
477
|
+
const attachments = await resolveAttachments(ctx, id, parsed.data.attachmentIds);
|
|
478
|
+
if (attachments === null) return c.json({ error: "unknown attachment" }, 400);
|
|
479
|
+
const event = await ctx.sessions.reply(id, { text: parsed.data.text, attachments });
|
|
480
|
+
ctx.presence.beat(id);
|
|
481
|
+
return c.json({ event }, 201);
|
|
482
|
+
});
|
|
483
|
+
app.post("/api/sessions/:id/attachments", async (c) => {
|
|
484
|
+
const id = c.req.param("id");
|
|
485
|
+
const meta = await ctx.sessions.getMeta(id);
|
|
486
|
+
if (!meta) return notFound(c);
|
|
487
|
+
if (meta.status === "ended") return c.json({ error: "session ended" }, 409);
|
|
488
|
+
const declaredLength = Number(c.req.header("content-length"));
|
|
489
|
+
if (Number.isFinite(declaredLength) && declaredLength > MAX_UPLOAD_BYTES) {
|
|
490
|
+
return c.json({ error: "file too large" }, 413);
|
|
491
|
+
}
|
|
492
|
+
const body = await c.req.parseBody();
|
|
493
|
+
const file = body.file;
|
|
494
|
+
if (!(file instanceof File)) return c.json({ error: "expected a `file` field" }, 400);
|
|
495
|
+
if (file.size > MAX_UPLOAD_BYTES) return c.json({ error: "file too large" }, 413);
|
|
496
|
+
const bytes = new Uint8Array(await file.arrayBuffer());
|
|
497
|
+
if (bytes.byteLength === 0) return c.json({ error: "empty file" }, 400);
|
|
498
|
+
const attachment = await ctx.sessions.saveAttachment(id, file.name || "file", bytes);
|
|
499
|
+
return c.json({ attachment }, 201);
|
|
500
|
+
});
|
|
501
|
+
app.get("/api/sessions/:id/attachments/:attId", async (c) => {
|
|
502
|
+
const file = await ctx.sessions.readAttachment(c.req.param("id"), c.req.param("attId"));
|
|
503
|
+
if (!file) return notFound(c);
|
|
504
|
+
const headers = {
|
|
505
|
+
"content-type": file.contentType,
|
|
506
|
+
"x-content-type-options": "nosniff"
|
|
507
|
+
};
|
|
508
|
+
const baseType = file.contentType.split(";")[0]?.trim().toLowerCase() ?? "";
|
|
509
|
+
if (!INLINE_SAFE_TYPES.has(baseType)) headers["content-disposition"] = "attachment";
|
|
510
|
+
return c.body(file.bytes, 200, headers);
|
|
511
|
+
});
|
|
512
|
+
app.post("/api/sessions/:id/annotations", async (c) => {
|
|
513
|
+
const id = c.req.param("id");
|
|
514
|
+
const meta = await ctx.sessions.getMeta(id);
|
|
515
|
+
if (!meta) return notFound(c);
|
|
516
|
+
if (meta.status === "ended") return c.json({ error: "session ended" }, 409);
|
|
517
|
+
const parsed = postAnnotationsReqSchema.safeParse(await readJson(c));
|
|
518
|
+
if (!parsed.success) return badRequest(c, parsed.error.issues);
|
|
519
|
+
const items = await resolveAnnotationItems(ctx, id, parsed.data.items);
|
|
520
|
+
if (items === null) return c.json({ error: "unknown attachment" }, 400);
|
|
521
|
+
const event = await ctx.sessions.postAnnotations(id, items);
|
|
522
|
+
return c.json({ event }, 201);
|
|
523
|
+
});
|
|
524
|
+
app.post("/api/sessions/:id/cards", async (c) => {
|
|
525
|
+
const guard = await requireSpecActive(ctx, c);
|
|
526
|
+
if (guard.res) return guard.res;
|
|
527
|
+
const parsed = postQuestionCardReqSchema.safeParse(await readJson(c));
|
|
528
|
+
if (!parsed.success) return badRequest(c, parsed.error.issues);
|
|
529
|
+
const event = await ctx.sessions.postQuestionCard(guard.id, parsed.data);
|
|
530
|
+
ctx.presence.beat(guard.id);
|
|
531
|
+
return c.json({ event }, 201);
|
|
532
|
+
});
|
|
533
|
+
app.post("/api/sessions/:id/decisions", async (c) => {
|
|
534
|
+
const guard = await requireSpecActive(ctx, c);
|
|
535
|
+
if (guard.res) return guard.res;
|
|
536
|
+
const parsed = postDecisionReqSchema.safeParse(await readJson(c));
|
|
537
|
+
if (!parsed.success) return badRequest(c, parsed.error.issues);
|
|
538
|
+
const event = await ctx.sessions.postDecision(guard.id, parsed.data);
|
|
539
|
+
ctx.presence.beat(guard.id);
|
|
540
|
+
return c.json({ event }, 201);
|
|
541
|
+
});
|
|
542
|
+
app.post("/api/sessions/:id/artifacts", async (c) => {
|
|
543
|
+
const guard = await requireSpecActive(ctx, c);
|
|
544
|
+
if (guard.res) return guard.res;
|
|
545
|
+
const parsed = postArtifactReqSchema.safeParse(await readJson(c));
|
|
546
|
+
if (!parsed.success) return badRequest(c, parsed.error.issues);
|
|
547
|
+
const ref = await ctx.sessions.saveArtifact(
|
|
548
|
+
guard.id,
|
|
549
|
+
parsed.data.kind,
|
|
550
|
+
parsed.data.name,
|
|
551
|
+
parsed.data.content
|
|
552
|
+
);
|
|
553
|
+
const event = await ctx.sessions.postArtifact(guard.id, ref);
|
|
554
|
+
ctx.presence.beat(guard.id);
|
|
555
|
+
return c.json({ event }, 201);
|
|
556
|
+
});
|
|
557
|
+
app.post("/api/sessions/:id/issues", async (c) => {
|
|
558
|
+
const id = c.req.param("id");
|
|
559
|
+
const meta = await ctx.sessions.getMeta(id);
|
|
560
|
+
if (!meta) return notFound(c);
|
|
561
|
+
const parsed = postIssueReqSchema.safeParse(await readJson(c));
|
|
562
|
+
if (!parsed.success) return badRequest(c, parsed.error.issues);
|
|
563
|
+
const saved = await ctx.sessions.saveIssue(meta, parsed.data);
|
|
564
|
+
if (!saved) return c.json({ error: "unknown attachment" }, 400);
|
|
565
|
+
return c.json(saved, 201);
|
|
566
|
+
});
|
|
567
|
+
app.post("/api/sessions/:id/move-away", async (c) => {
|
|
568
|
+
const id = c.req.param("id");
|
|
569
|
+
const meta = await ctx.sessions.getMeta(id);
|
|
570
|
+
if (!meta) return notFound(c);
|
|
571
|
+
if (meta.status === "ended") return c.json({ error: "session ended" }, 409);
|
|
572
|
+
const parsed = moveAwayReqSchema.safeParse(await readJson(c));
|
|
573
|
+
if (!parsed.success) return badRequest(c, parsed.error.issues);
|
|
574
|
+
const event = await ctx.sessions.moveAway(id, parsed.data.to);
|
|
575
|
+
return c.json({ event }, 201);
|
|
576
|
+
});
|
|
577
|
+
app.get("/api/sessions/:id/poll", async (c) => {
|
|
578
|
+
const id = c.req.param("id");
|
|
579
|
+
const meta = await ctx.sessions.getMeta(id);
|
|
580
|
+
if (!meta) return notFound(c);
|
|
581
|
+
const since = parseNonNeg(c.req.query("since"), 0);
|
|
582
|
+
const wait = meta.status === "ended" ? 0 : clamp(parseNonNeg(c.req.query("wait"), 25e3), 0, 3e4);
|
|
583
|
+
ctx.presence.enter(id);
|
|
584
|
+
let delivered = false;
|
|
585
|
+
try {
|
|
586
|
+
const res = await ctx.sessions.poll(id, since, wait, c.req.raw.signal);
|
|
587
|
+
delivered = res.events.length > 0;
|
|
588
|
+
return c.json({ ...res, ended: meta.status === "ended" });
|
|
589
|
+
} finally {
|
|
590
|
+
ctx.presence.leave(id, c.req.raw.signal.aborted, delivered);
|
|
591
|
+
}
|
|
592
|
+
});
|
|
593
|
+
app.get("/api/sessions/:id/events", async (c) => {
|
|
594
|
+
const id = c.req.param("id");
|
|
595
|
+
const meta = await ctx.sessions.getMeta(id);
|
|
596
|
+
if (!meta) return notFound(c);
|
|
597
|
+
const since = parseNonNeg(c.req.query("since"), 0);
|
|
598
|
+
const limitRaw = c.req.query("limit");
|
|
599
|
+
const limit = limitRaw === void 0 ? void 0 : parseNonNeg(limitRaw, 0);
|
|
600
|
+
return c.json(await ctx.sessions.events(id, since, limit));
|
|
601
|
+
});
|
|
602
|
+
app.get("/api/sessions/:id/attach", async (c) => {
|
|
603
|
+
const id = c.req.param("id");
|
|
604
|
+
const tail = clamp(parseNonNeg(c.req.query("tail"), 20), 0, 500);
|
|
605
|
+
const digest = await ctx.sessions.attach(id, tail, ctx.presence.get(id));
|
|
606
|
+
if (!digest) return notFound(c);
|
|
607
|
+
return c.json(digest);
|
|
608
|
+
});
|
|
609
|
+
app.post("/api/sessions/:id/kind", async (c) => {
|
|
610
|
+
const id = c.req.param("id");
|
|
611
|
+
const parsed = setSessionKindReqSchema.safeParse(await readJson(c));
|
|
612
|
+
if (!parsed.success) return badRequest(c, parsed.error.issues);
|
|
613
|
+
const result = await ctx.sessions.setKind(id, parsed.data.kind);
|
|
614
|
+
if (!result.ok) {
|
|
615
|
+
if (result.reason === "unknown") return notFound(c);
|
|
616
|
+
const message = result.reason === "ended" ? "session ended" : "kind already set";
|
|
617
|
+
return c.json({ error: message }, 409);
|
|
618
|
+
}
|
|
619
|
+
ctx.hub.broadcast(id, { t: "kind", meta: result.meta });
|
|
620
|
+
return c.json({ session: summaryOf(result.meta) });
|
|
621
|
+
});
|
|
622
|
+
app.post("/api/sessions/:id/end", async (c) => {
|
|
623
|
+
const id = c.req.param("id");
|
|
624
|
+
const meta = await ctx.sessions.end(id);
|
|
625
|
+
if (!meta) return notFound(c);
|
|
626
|
+
ctx.hub.broadcast(id, { t: "ended", meta });
|
|
627
|
+
return c.json({ session: summaryOf(meta) });
|
|
628
|
+
});
|
|
629
|
+
app.get(
|
|
630
|
+
"/ws",
|
|
631
|
+
upgradeWebSocket((c) => {
|
|
632
|
+
const id = new URL(c.req.url).searchParams.get("s") ?? "";
|
|
633
|
+
return {
|
|
634
|
+
onOpen: (_evt, ws) => {
|
|
635
|
+
openSocket(ctx, id, ws).catch(() => ws.close(1011, "internal error"));
|
|
636
|
+
},
|
|
637
|
+
onClose: (_evt, ws) => {
|
|
638
|
+
ctx.hub.remove(id, ws);
|
|
639
|
+
}
|
|
640
|
+
};
|
|
641
|
+
})
|
|
642
|
+
);
|
|
643
|
+
app.get("/api/sessions/:id/artifacts/:name", async (c) => {
|
|
644
|
+
const id = c.req.param("id");
|
|
645
|
+
const meta = await ctx.sessions.getMeta(id);
|
|
646
|
+
if (!meta) return notFound(c);
|
|
647
|
+
const name = c.req.param("name");
|
|
648
|
+
const artifact = await ctx.sessions.readArtifact(id, name);
|
|
649
|
+
if (!artifact) return notFound(c);
|
|
650
|
+
const raw = c.req.query("raw");
|
|
651
|
+
if (raw === "1" || raw === "true") {
|
|
652
|
+
const type = name.toLowerCase().endsWith(".html") ? "text/html; charset=utf-8" : "text/plain; charset=utf-8";
|
|
653
|
+
return c.body(artifact.content, 200, { "content-type": type, "cache-control": "no-cache" });
|
|
654
|
+
}
|
|
655
|
+
const html = await renderArtifactHtml(artifact.file, artifact.content);
|
|
656
|
+
return c.body(html, 200, {
|
|
657
|
+
"content-type": "text/html; charset=utf-8",
|
|
658
|
+
"cache-control": "no-cache"
|
|
659
|
+
});
|
|
660
|
+
});
|
|
661
|
+
app.get("/inject/annotate.js", createInjectHandler(ctx.injectDist));
|
|
662
|
+
app.get("*", createStaticHandler(ctx.webDist));
|
|
663
|
+
return { app, injectWebSocket };
|
|
664
|
+
}
|
|
665
|
+
async function openSocket(ctx, id, ws) {
|
|
666
|
+
const meta = await ctx.sessions.getMeta(id);
|
|
667
|
+
if (!meta) {
|
|
668
|
+
ws.close(1008, "unknown session");
|
|
669
|
+
return;
|
|
670
|
+
}
|
|
671
|
+
const first = ctx.hub.add(id, ws);
|
|
672
|
+
if (first) {
|
|
673
|
+
const log = await ctx.sessions.logFor(id);
|
|
674
|
+
const unsubscribe = log.subscribe((event) => ctx.hub.broadcast(id, { t: "append", event }));
|
|
675
|
+
ctx.hub.setForwarder(id, unsubscribe);
|
|
676
|
+
}
|
|
677
|
+
const hello = { t: "hello", meta, presence: ctx.presence.get(id) };
|
|
678
|
+
ws.send(JSON.stringify(hello));
|
|
679
|
+
}
|
|
680
|
+
async function resolveAttachments(ctx, id, ids) {
|
|
681
|
+
if (!ids || ids.length === 0) return [];
|
|
682
|
+
const refs = [];
|
|
683
|
+
for (const attId of ids) {
|
|
684
|
+
const ref = await ctx.sessions.resolveAttachment(id, attId);
|
|
685
|
+
if (!ref) return null;
|
|
686
|
+
refs.push(ref);
|
|
687
|
+
}
|
|
688
|
+
return refs;
|
|
689
|
+
}
|
|
690
|
+
async function resolveAnnotationItems(ctx, id, items) {
|
|
691
|
+
const resolved = [];
|
|
692
|
+
for (const item of items) {
|
|
693
|
+
let screenshot;
|
|
694
|
+
if (item.screenshotAttachmentId) {
|
|
695
|
+
const ref = await ctx.sessions.resolveAttachment(id, item.screenshotAttachmentId);
|
|
696
|
+
if (!ref) return null;
|
|
697
|
+
screenshot = ref;
|
|
698
|
+
}
|
|
699
|
+
resolved.push({
|
|
700
|
+
...item.note ? { note: item.note } : {},
|
|
701
|
+
target: item.target,
|
|
702
|
+
...screenshot ? { screenshot } : {}
|
|
703
|
+
});
|
|
704
|
+
}
|
|
705
|
+
return resolved;
|
|
706
|
+
}
|
|
707
|
+
async function requireSpecActive(ctx, c) {
|
|
708
|
+
const id = c.req.param("id") ?? "";
|
|
709
|
+
const meta = await ctx.sessions.getMeta(id);
|
|
710
|
+
if (!meta) return { id, res: notFound(c) };
|
|
711
|
+
if (meta.status === "ended") return { id, res: c.json({ error: "session ended" }, 409) };
|
|
712
|
+
if (meta.kind !== "spec") return { id, res: c.json({ error: "not a spec session" }, 400) };
|
|
713
|
+
return { id, res: null };
|
|
714
|
+
}
|
|
715
|
+
async function readJson(c) {
|
|
716
|
+
try {
|
|
717
|
+
return await c.req.json();
|
|
718
|
+
} catch {
|
|
719
|
+
return {};
|
|
720
|
+
}
|
|
721
|
+
}
|
|
722
|
+
function notFound(c) {
|
|
723
|
+
return c.json({ error: "unknown session" }, 404);
|
|
724
|
+
}
|
|
725
|
+
function badRequest(c, issues) {
|
|
726
|
+
return c.json({ error: "invalid request", issues }, 400);
|
|
727
|
+
}
|
|
728
|
+
function parseNonNeg(value, fallback) {
|
|
729
|
+
if (value === void 0) return fallback;
|
|
730
|
+
const n = Number(value);
|
|
731
|
+
return Number.isFinite(n) && n >= 0 ? Math.floor(n) : fallback;
|
|
732
|
+
}
|
|
733
|
+
function clamp(n, min, max) {
|
|
734
|
+
return Math.min(max, Math.max(min, n));
|
|
735
|
+
}
|
|
736
|
+
|
|
737
|
+
// packages/server/src/lifecycle.ts
|
|
738
|
+
import { unlinkSync } from "node:fs";
|
|
739
|
+
import { open, readFile as readFile4, rm } from "node:fs/promises";
|
|
740
|
+
|
|
741
|
+
// packages/server/src/sessions/paths.ts
|
|
742
|
+
import { existsSync as existsSync3, realpathSync } from "node:fs";
|
|
743
|
+
import { appendFile, mkdir, readFile as readFile3 } from "node:fs/promises";
|
|
744
|
+
import path4 from "node:path";
|
|
745
|
+
function defaultContext() {
|
|
746
|
+
return { cwd: process.cwd(), env: process.env };
|
|
747
|
+
}
|
|
748
|
+
function resolveHome(ctx = defaultContext()) {
|
|
749
|
+
return findExistingHome(ctx) ?? plannedHome(ctx);
|
|
750
|
+
}
|
|
751
|
+
function findExistingHome(ctx = defaultContext()) {
|
|
752
|
+
const dir = ctx.env.CHAMBA_DIR;
|
|
753
|
+
if (dir && dir.length > 0) return canonicalize(dir);
|
|
754
|
+
const inside = enclosingChamba(ctx.cwd);
|
|
755
|
+
if (inside) return canonicalize(inside);
|
|
756
|
+
const root = gitRoot(ctx.cwd);
|
|
757
|
+
let cursor = path4.resolve(ctx.cwd);
|
|
758
|
+
while (true) {
|
|
759
|
+
if (existsSync3(path4.join(cursor, ".chamba"))) return canonicalize(path4.join(cursor, ".chamba"));
|
|
760
|
+
if (!root) break;
|
|
761
|
+
if (cursor === root) break;
|
|
762
|
+
const parent = path4.dirname(cursor);
|
|
763
|
+
if (parent === cursor) break;
|
|
764
|
+
cursor = parent;
|
|
765
|
+
}
|
|
766
|
+
return null;
|
|
767
|
+
}
|
|
768
|
+
function plannedHome(ctx = defaultContext()) {
|
|
769
|
+
const root = gitRoot(ctx.cwd) ?? path4.resolve(ctx.cwd);
|
|
770
|
+
return canonicalize(path4.join(root, ".chamba"));
|
|
771
|
+
}
|
|
772
|
+
function gitRoot(cwd) {
|
|
773
|
+
let cursor = path4.resolve(cwd);
|
|
774
|
+
while (true) {
|
|
775
|
+
if (existsSync3(path4.join(cursor, ".git"))) return cursor;
|
|
776
|
+
const parent = path4.dirname(cursor);
|
|
777
|
+
if (parent === cursor) return null;
|
|
778
|
+
cursor = parent;
|
|
779
|
+
}
|
|
780
|
+
}
|
|
781
|
+
function enclosingChamba(cwd) {
|
|
782
|
+
const parts = path4.resolve(cwd).split(path4.sep);
|
|
783
|
+
const index = parts.lastIndexOf(".chamba");
|
|
784
|
+
if (index < 0) return null;
|
|
785
|
+
return parts.slice(0, index + 1).join(path4.sep) || path4.sep;
|
|
786
|
+
}
|
|
787
|
+
function canonicalize(p) {
|
|
788
|
+
const resolved = path4.resolve(p);
|
|
789
|
+
let existing = resolved;
|
|
790
|
+
const tail = [];
|
|
791
|
+
while (!existsSync3(existing)) {
|
|
792
|
+
tail.unshift(path4.basename(existing));
|
|
793
|
+
const parent = path4.dirname(existing);
|
|
794
|
+
if (parent === existing) return resolved;
|
|
795
|
+
existing = parent;
|
|
796
|
+
}
|
|
797
|
+
return path4.join(realpathSync(existing), ...tail);
|
|
798
|
+
}
|
|
799
|
+
function sessionsDir(home) {
|
|
800
|
+
return path4.join(home, "sessions");
|
|
801
|
+
}
|
|
802
|
+
function sessionDir(home, id) {
|
|
803
|
+
return path4.join(sessionsDir(home), id);
|
|
804
|
+
}
|
|
805
|
+
function logPath(home, id) {
|
|
806
|
+
return path4.join(sessionDir(home, id), "log.ndjson");
|
|
807
|
+
}
|
|
808
|
+
function attachmentsDir(home, id) {
|
|
809
|
+
return path4.join(sessionDir(home, id), "attachments");
|
|
810
|
+
}
|
|
811
|
+
function artifactsDir(home, id) {
|
|
812
|
+
return path4.join(sessionDir(home, id), "artifacts");
|
|
813
|
+
}
|
|
814
|
+
function issuesDir(home) {
|
|
815
|
+
return path4.join(home, "issues");
|
|
816
|
+
}
|
|
817
|
+
function issueDir(home, id) {
|
|
818
|
+
return path4.join(issuesDir(home), id);
|
|
819
|
+
}
|
|
820
|
+
function metaPath(home, id) {
|
|
821
|
+
return path4.join(sessionDir(home, id), "meta.json");
|
|
822
|
+
}
|
|
823
|
+
function pidfilePath(home) {
|
|
824
|
+
return path4.join(home, "server.json");
|
|
825
|
+
}
|
|
826
|
+
async function ensureChambaHome(home) {
|
|
827
|
+
await mkdir(sessionsDir(home), { recursive: true });
|
|
828
|
+
await ensureGitignored(home);
|
|
829
|
+
}
|
|
830
|
+
async function ensureGitignored(home) {
|
|
831
|
+
if (path4.basename(home) !== ".chamba") return;
|
|
832
|
+
const gitignore = path4.join(path4.dirname(home), ".gitignore");
|
|
833
|
+
const entry = ".chamba/";
|
|
834
|
+
try {
|
|
835
|
+
let content = "";
|
|
836
|
+
try {
|
|
837
|
+
content = await readFile3(gitignore, "utf8");
|
|
838
|
+
} catch (err) {
|
|
839
|
+
if (err.code !== "ENOENT") return;
|
|
840
|
+
}
|
|
841
|
+
const already = content.split(/\r?\n/).some((line) => {
|
|
842
|
+
const trimmed = line.trim();
|
|
843
|
+
return trimmed === entry || trimmed === ".chamba";
|
|
844
|
+
});
|
|
845
|
+
if (already) return;
|
|
846
|
+
const prefix = content.length > 0 && !content.endsWith("\n") ? "\n" : "";
|
|
847
|
+
await appendFile(gitignore, `${prefix}${entry}
|
|
848
|
+
`, "utf8");
|
|
849
|
+
} catch {
|
|
850
|
+
}
|
|
851
|
+
}
|
|
852
|
+
|
|
853
|
+
// packages/server/src/lifecycle.ts
|
|
854
|
+
var ServerConflictError = class extends Error {
|
|
855
|
+
constructor(existing) {
|
|
856
|
+
super(`chamba server already running (pid ${existing.pid}, port ${existing.port})`);
|
|
857
|
+
this.existing = existing;
|
|
858
|
+
this.name = "ServerConflictError";
|
|
859
|
+
}
|
|
860
|
+
};
|
|
861
|
+
async function acquirePidfile(home, port) {
|
|
862
|
+
const file = pidfilePath(home);
|
|
863
|
+
const info = { pid: process.pid, port, startedAt: (/* @__PURE__ */ new Date()).toISOString() };
|
|
864
|
+
for (let attempt = 0; attempt < 3; attempt++) {
|
|
865
|
+
try {
|
|
866
|
+
const handle = await open(file, "wx");
|
|
867
|
+
await handle.writeFile(`${JSON.stringify(info, null, 2)}
|
|
868
|
+
`);
|
|
869
|
+
await handle.close();
|
|
870
|
+
return makeHandle(file, info.pid);
|
|
871
|
+
} catch (err) {
|
|
872
|
+
if (err.code !== "EEXIST") throw err;
|
|
873
|
+
const existing = await readPidfile(file);
|
|
874
|
+
if (existing && existing.pid !== process.pid && await isServerLive(existing)) {
|
|
875
|
+
throw new ServerConflictError(existing);
|
|
876
|
+
}
|
|
877
|
+
await rm(file, { force: true });
|
|
878
|
+
}
|
|
879
|
+
}
|
|
880
|
+
throw new Error("could not acquire chamba pidfile");
|
|
881
|
+
}
|
|
882
|
+
function makeHandle(file, ownerPid) {
|
|
883
|
+
let released = false;
|
|
884
|
+
const releaseSync = () => {
|
|
885
|
+
if (released) return;
|
|
886
|
+
released = true;
|
|
887
|
+
try {
|
|
888
|
+
unlinkSync(file);
|
|
889
|
+
} catch {
|
|
890
|
+
}
|
|
891
|
+
};
|
|
892
|
+
process.once("exit", releaseSync);
|
|
893
|
+
return {
|
|
894
|
+
release: async () => {
|
|
895
|
+
if (released) return;
|
|
896
|
+
released = true;
|
|
897
|
+
try {
|
|
898
|
+
const current = await readPidfile(file);
|
|
899
|
+
if (!current || current.pid === ownerPid) await rm(file, { force: true });
|
|
900
|
+
} catch {
|
|
901
|
+
}
|
|
902
|
+
}
|
|
903
|
+
};
|
|
904
|
+
}
|
|
905
|
+
async function readPidfile(file) {
|
|
906
|
+
try {
|
|
907
|
+
const raw = await readFile4(file, "utf8");
|
|
908
|
+
const parsed = JSON.parse(raw);
|
|
909
|
+
if (typeof parsed.pid === "number" && typeof parsed.port === "number") return parsed;
|
|
910
|
+
return null;
|
|
911
|
+
} catch {
|
|
912
|
+
return null;
|
|
913
|
+
}
|
|
914
|
+
}
|
|
915
|
+
function isPidAlive(pid) {
|
|
916
|
+
try {
|
|
917
|
+
process.kill(pid, 0);
|
|
918
|
+
return true;
|
|
919
|
+
} catch (err) {
|
|
920
|
+
return err.code === "EPERM";
|
|
921
|
+
}
|
|
922
|
+
}
|
|
923
|
+
async function isServerLive(info) {
|
|
924
|
+
if (!isPidAlive(info.pid)) return false;
|
|
925
|
+
try {
|
|
926
|
+
const controller = new AbortController();
|
|
927
|
+
const timer = setTimeout(() => controller.abort(), 500);
|
|
928
|
+
const res = await fetch(`http://127.0.0.1:${info.port}/api/health`, {
|
|
929
|
+
signal: controller.signal
|
|
930
|
+
});
|
|
931
|
+
clearTimeout(timer);
|
|
932
|
+
return res.ok;
|
|
933
|
+
} catch {
|
|
934
|
+
return false;
|
|
935
|
+
}
|
|
936
|
+
}
|
|
937
|
+
function startIdleWatchdog(opts) {
|
|
938
|
+
if (opts.idleMs <= 0) return () => void 0;
|
|
939
|
+
const interval = opts.intervalMs ?? Math.min(opts.idleMs, 5e3);
|
|
940
|
+
let idleSince = Date.now();
|
|
941
|
+
const timer = setInterval(() => {
|
|
942
|
+
if (opts.isBusy()) {
|
|
943
|
+
idleSince = Date.now();
|
|
944
|
+
return;
|
|
945
|
+
}
|
|
946
|
+
if (Date.now() - idleSince >= opts.idleMs) opts.onIdle();
|
|
947
|
+
}, interval);
|
|
948
|
+
timer.unref?.();
|
|
949
|
+
return () => clearInterval(timer);
|
|
950
|
+
}
|
|
951
|
+
|
|
952
|
+
// packages/server/src/port.ts
|
|
953
|
+
var DEFAULT_PORT = 4319;
|
|
954
|
+
var DEFAULT_BIND_HOST = "127.0.0.1";
|
|
955
|
+
function resolveBindHost(argv = process.argv.slice(2)) {
|
|
956
|
+
const flag = readFlag(argv, "--host");
|
|
957
|
+
if (flag !== void 0 && flag.length > 0) return flag;
|
|
958
|
+
const env = process.env.CHAMBA_HOST;
|
|
959
|
+
if (env !== void 0 && env.length > 0) return env;
|
|
960
|
+
return DEFAULT_BIND_HOST;
|
|
961
|
+
}
|
|
962
|
+
function resolvePort(argv = process.argv.slice(2), home) {
|
|
963
|
+
const flag = readFlag(argv, "--port");
|
|
964
|
+
if (flag !== void 0) return parsePort(flag, "--port");
|
|
965
|
+
const env = process.env.CHAMBA_PORT;
|
|
966
|
+
if (env !== void 0 && env.length > 0) return parsePort(env, "CHAMBA_PORT");
|
|
967
|
+
return home !== void 0 ? derivePort(home) : DEFAULT_PORT;
|
|
968
|
+
}
|
|
969
|
+
function derivePort(home) {
|
|
970
|
+
let hash = 2166136261;
|
|
971
|
+
for (let i = 0; i < home.length; i++) {
|
|
972
|
+
hash ^= home.charCodeAt(i);
|
|
973
|
+
hash = Math.imul(hash, 16777619);
|
|
974
|
+
}
|
|
975
|
+
return DEFAULT_PORT + Math.abs(hash) % 512;
|
|
976
|
+
}
|
|
977
|
+
function readFlag(argv, name) {
|
|
978
|
+
for (let i = 0; i < argv.length; i++) {
|
|
979
|
+
const arg = argv[i];
|
|
980
|
+
if (arg === name) return argv[i + 1];
|
|
981
|
+
if (arg?.startsWith(`${name}=`)) return arg.slice(name.length + 1);
|
|
982
|
+
}
|
|
983
|
+
return void 0;
|
|
984
|
+
}
|
|
985
|
+
function parsePort(value, source) {
|
|
986
|
+
const port = Number(value);
|
|
987
|
+
if (!Number.isInteger(port) || port < 1 || port > 65535) {
|
|
988
|
+
throw new Error(`invalid ${source}: ${value}`);
|
|
989
|
+
}
|
|
990
|
+
return port;
|
|
991
|
+
}
|
|
992
|
+
|
|
993
|
+
// packages/server/src/presence.ts
|
|
994
|
+
var PresenceTracker = class {
|
|
995
|
+
/**
|
|
996
|
+
* `idleAwayMs` is how long presence stays `listening` after a normal poll
|
|
997
|
+
* return - long enough to bridge real work gaps. `dropAwayMs` is the shorter
|
|
998
|
+
* window used when a poll's client disconnected, so a killed or crashed agent
|
|
999
|
+
* surfaces quickly. Both are unrelated to `ServeOptions.idleMs`, which is the
|
|
1000
|
+
* server's own self-shutdown window.
|
|
1001
|
+
*/
|
|
1002
|
+
constructor(idleAwayMs = 30 * 6e4, dropAwayMs = 1e4) {
|
|
1003
|
+
this.idleAwayMs = idleAwayMs;
|
|
1004
|
+
this.dropAwayMs = dropAwayMs;
|
|
1005
|
+
}
|
|
1006
|
+
active = /* @__PURE__ */ new Map();
|
|
1007
|
+
lastSeen = /* @__PURE__ */ new Map();
|
|
1008
|
+
state = /* @__PURE__ */ new Map();
|
|
1009
|
+
timers = /* @__PURE__ */ new Map();
|
|
1010
|
+
listeners = /* @__PURE__ */ new Set();
|
|
1011
|
+
get(id) {
|
|
1012
|
+
return this.state.get(id) ?? "away";
|
|
1013
|
+
}
|
|
1014
|
+
/** A poll has parked: the agent is definitely present. */
|
|
1015
|
+
enter(id) {
|
|
1016
|
+
this.active.set(id, (this.active.get(id) ?? 0) + 1);
|
|
1017
|
+
this.lastSeen.set(id, Date.now());
|
|
1018
|
+
this.clearTimer(id);
|
|
1019
|
+
this.set(id, "listening");
|
|
1020
|
+
}
|
|
1021
|
+
/**
|
|
1022
|
+
* A poll returned or timed out: still present through the away window. `dropped`
|
|
1023
|
+
* marks a client disconnect (the agent's process died or was killed mid-poll),
|
|
1024
|
+
* which uses the short window; a normal return uses the long one. `delivered`
|
|
1025
|
+
* marks a clean return that carried input - the agent took the human's message
|
|
1026
|
+
* and left to work - so with no poll left parked the state flips to `working`,
|
|
1027
|
+
* with the long window as the backstop if the agent never comes back.
|
|
1028
|
+
*/
|
|
1029
|
+
leave(id, dropped = false, delivered = false) {
|
|
1030
|
+
const remaining = Math.max(0, (this.active.get(id) ?? 1) - 1);
|
|
1031
|
+
this.active.set(id, remaining);
|
|
1032
|
+
this.lastSeen.set(id, Date.now());
|
|
1033
|
+
if (remaining > 0) return;
|
|
1034
|
+
if (delivered && !dropped) this.set(id, "working");
|
|
1035
|
+
this.scheduleAway(id, dropped ? this.dropAwayMs : this.idleAwayMs);
|
|
1036
|
+
}
|
|
1037
|
+
/** Any other sign of life (e.g. a reply) keeps the agent listening. */
|
|
1038
|
+
beat(id) {
|
|
1039
|
+
this.lastSeen.set(id, Date.now());
|
|
1040
|
+
this.set(id, "listening");
|
|
1041
|
+
if ((this.active.get(id) ?? 0) === 0) this.scheduleAway(id, this.idleAwayMs);
|
|
1042
|
+
}
|
|
1043
|
+
/** True while any session has a parked poll - used to hold off idle shutdown. */
|
|
1044
|
+
hasActivePolls() {
|
|
1045
|
+
for (const count of this.active.values()) {
|
|
1046
|
+
if (count > 0) return true;
|
|
1047
|
+
}
|
|
1048
|
+
return false;
|
|
1049
|
+
}
|
|
1050
|
+
onChange(listener) {
|
|
1051
|
+
this.listeners.add(listener);
|
|
1052
|
+
return () => this.listeners.delete(listener);
|
|
1053
|
+
}
|
|
1054
|
+
scheduleAway(id, delayMs) {
|
|
1055
|
+
this.clearTimer(id);
|
|
1056
|
+
const timer = setTimeout(() => {
|
|
1057
|
+
this.timers.delete(id);
|
|
1058
|
+
if ((this.active.get(id) ?? 0) === 0) this.set(id, "away");
|
|
1059
|
+
}, delayMs);
|
|
1060
|
+
timer.unref?.();
|
|
1061
|
+
this.timers.set(id, timer);
|
|
1062
|
+
}
|
|
1063
|
+
clearTimer(id) {
|
|
1064
|
+
const timer = this.timers.get(id);
|
|
1065
|
+
if (timer) {
|
|
1066
|
+
clearTimeout(timer);
|
|
1067
|
+
this.timers.delete(id);
|
|
1068
|
+
}
|
|
1069
|
+
}
|
|
1070
|
+
set(id, next) {
|
|
1071
|
+
if (this.state.get(id) === next) return;
|
|
1072
|
+
this.state.set(id, next);
|
|
1073
|
+
for (const listener of [...this.listeners]) listener(id, next);
|
|
1074
|
+
}
|
|
1075
|
+
};
|
|
1076
|
+
|
|
1077
|
+
// packages/server/src/sessions/index.ts
|
|
1078
|
+
import { randomBytes, randomUUID as randomUUID3 } from "node:crypto";
|
|
1079
|
+
import { mkdir as mkdir3, readdir as readdir2, readFile as readFile7, stat, writeFile as writeFile2 } from "node:fs/promises";
|
|
1080
|
+
import path7 from "node:path";
|
|
1081
|
+
|
|
1082
|
+
// packages/server/src/version.ts
|
|
1083
|
+
import { existsSync as existsSync4, readFileSync } from "node:fs";
|
|
1084
|
+
import path5 from "node:path";
|
|
1085
|
+
import { fileURLToPath as fileURLToPath3 } from "node:url";
|
|
1086
|
+
function chambaVersion() {
|
|
1087
|
+
let dir = path5.dirname(fileURLToPath3(import.meta.url));
|
|
1088
|
+
for (; ; ) {
|
|
1089
|
+
const manifest = path5.join(dir, "package.json");
|
|
1090
|
+
if (existsSync4(manifest)) {
|
|
1091
|
+
try {
|
|
1092
|
+
const { version } = JSON.parse(readFileSync(manifest, "utf8"));
|
|
1093
|
+
if (typeof version === "string" && version) return version;
|
|
1094
|
+
} catch {
|
|
1095
|
+
}
|
|
1096
|
+
}
|
|
1097
|
+
const parent = path5.dirname(dir);
|
|
1098
|
+
if (parent === dir) return "0.0.0-dev";
|
|
1099
|
+
dir = parent;
|
|
1100
|
+
}
|
|
1101
|
+
}
|
|
1102
|
+
|
|
1103
|
+
// packages/server/src/issues.ts
|
|
1104
|
+
function buildIssueRecord(input) {
|
|
1105
|
+
return {
|
|
1106
|
+
id: input.id,
|
|
1107
|
+
type: input.type,
|
|
1108
|
+
text: input.text,
|
|
1109
|
+
filedAt: input.filedAt,
|
|
1110
|
+
chambaVersion: chambaVersion(),
|
|
1111
|
+
session: {
|
|
1112
|
+
id: input.meta.id,
|
|
1113
|
+
kind: input.meta.kind ?? "unset",
|
|
1114
|
+
status: input.meta.status,
|
|
1115
|
+
...input.meta.title ? { title: input.meta.title } : {}
|
|
1116
|
+
},
|
|
1117
|
+
env: {
|
|
1118
|
+
node: process.version,
|
|
1119
|
+
platform: process.platform,
|
|
1120
|
+
arch: process.arch,
|
|
1121
|
+
cwd: process.cwd()
|
|
1122
|
+
},
|
|
1123
|
+
screenshots: input.screenshots,
|
|
1124
|
+
recentEvents: input.tail.map(summarizeEvent)
|
|
1125
|
+
};
|
|
1126
|
+
}
|
|
1127
|
+
function renderIssueMarkdown(record) {
|
|
1128
|
+
const lines = [
|
|
1129
|
+
`# ${record.type === "bug" ? "Bug" : "Feature request"}`,
|
|
1130
|
+
"",
|
|
1131
|
+
record.text,
|
|
1132
|
+
"",
|
|
1133
|
+
"## Context",
|
|
1134
|
+
"",
|
|
1135
|
+
`- chamba version: ${record.chambaVersion}`,
|
|
1136
|
+
`- session: ${record.session.id} (${record.session.kind}, ${record.session.status})`,
|
|
1137
|
+
...record.session.title ? [`- session title: ${record.session.title}`] : [],
|
|
1138
|
+
`- environment: node ${record.env.node} on ${record.env.platform}/${record.env.arch}`,
|
|
1139
|
+
`- cwd: ${record.env.cwd}`,
|
|
1140
|
+
`- filed: ${record.filedAt}`
|
|
1141
|
+
];
|
|
1142
|
+
if (record.screenshots.length > 0) {
|
|
1143
|
+
lines.push("", "### Screenshots", "");
|
|
1144
|
+
for (const name of record.screenshots) lines.push(`- ${name}`);
|
|
1145
|
+
}
|
|
1146
|
+
if (record.recentEvents.length > 0) {
|
|
1147
|
+
lines.push("", "### Recent session events", "");
|
|
1148
|
+
for (const event of record.recentEvents) {
|
|
1149
|
+
const text = event.text ? `: ${event.text}` : "";
|
|
1150
|
+
lines.push(`- [${event.seq}] ${event.ts} ${event.source}/${event.type}${text}`);
|
|
1151
|
+
}
|
|
1152
|
+
}
|
|
1153
|
+
return `${lines.join("\n")}
|
|
1154
|
+
`;
|
|
1155
|
+
}
|
|
1156
|
+
function summarizeEvent(event) {
|
|
1157
|
+
return {
|
|
1158
|
+
seq: event.seq,
|
|
1159
|
+
ts: event.ts,
|
|
1160
|
+
source: event.source,
|
|
1161
|
+
type: event.type,
|
|
1162
|
+
...eventText(event) ? { text: truncate(eventText(event), 200) } : {}
|
|
1163
|
+
};
|
|
1164
|
+
}
|
|
1165
|
+
function eventText(event) {
|
|
1166
|
+
const payload = event.payload;
|
|
1167
|
+
if (payload && typeof payload === "object") {
|
|
1168
|
+
const record = payload;
|
|
1169
|
+
if (typeof record.text === "string") return record.text;
|
|
1170
|
+
if (typeof record.reason === "string") return record.reason;
|
|
1171
|
+
}
|
|
1172
|
+
return "";
|
|
1173
|
+
}
|
|
1174
|
+
function truncate(text, max) {
|
|
1175
|
+
const oneLine = text.replace(/\s+/g, " ").trim();
|
|
1176
|
+
return oneLine.length > max ? `${oneLine.slice(0, max - 1)}\u2026` : oneLine;
|
|
1177
|
+
}
|
|
1178
|
+
|
|
1179
|
+
// packages/server/src/sessions/log.ts
|
|
1180
|
+
import { randomUUID } from "node:crypto";
|
|
1181
|
+
import { mkdir as mkdir2, open as open2, readFile as readFile5, truncate as truncate2 } from "node:fs/promises";
|
|
1182
|
+
import path6 from "node:path";
|
|
1183
|
+
var SessionLog = class {
|
|
1184
|
+
constructor(id, file) {
|
|
1185
|
+
this.id = id;
|
|
1186
|
+
this.file = file;
|
|
1187
|
+
}
|
|
1188
|
+
events = [];
|
|
1189
|
+
handle = null;
|
|
1190
|
+
loaded = false;
|
|
1191
|
+
loading = null;
|
|
1192
|
+
mutex = Promise.resolve();
|
|
1193
|
+
listeners = /* @__PURE__ */ new Set();
|
|
1194
|
+
/** Replay the ndjson file into memory and open the append handle. Idempotent. */
|
|
1195
|
+
async load() {
|
|
1196
|
+
if (this.loaded) return;
|
|
1197
|
+
if (this.loading) return this.loading;
|
|
1198
|
+
this.loading = this.replay();
|
|
1199
|
+
try {
|
|
1200
|
+
await this.loading;
|
|
1201
|
+
} finally {
|
|
1202
|
+
this.loading = null;
|
|
1203
|
+
}
|
|
1204
|
+
}
|
|
1205
|
+
async replay() {
|
|
1206
|
+
await mkdir2(path6.dirname(this.file), { recursive: true });
|
|
1207
|
+
this.events = [];
|
|
1208
|
+
let content = "";
|
|
1209
|
+
try {
|
|
1210
|
+
content = await readFile5(this.file, "utf8");
|
|
1211
|
+
} catch (err) {
|
|
1212
|
+
if (err.code !== "ENOENT") throw err;
|
|
1213
|
+
}
|
|
1214
|
+
const lines = content.split("\n");
|
|
1215
|
+
let validBytes = 0;
|
|
1216
|
+
let truncateTo = null;
|
|
1217
|
+
for (let i = 0; i < lines.length; i++) {
|
|
1218
|
+
const line = lines[i] ?? "";
|
|
1219
|
+
const hasSeparator = i < lines.length - 1;
|
|
1220
|
+
const byteLen = Buffer.byteLength(line, "utf8") + (hasSeparator ? 1 : 0);
|
|
1221
|
+
if (line.trim().length === 0) {
|
|
1222
|
+
validBytes += byteLen;
|
|
1223
|
+
continue;
|
|
1224
|
+
}
|
|
1225
|
+
try {
|
|
1226
|
+
this.events.push(eventEnvelopeSchema.parse(JSON.parse(line)));
|
|
1227
|
+
validBytes += byteLen;
|
|
1228
|
+
} catch (err) {
|
|
1229
|
+
const isLastContentLine = lines.slice(i + 1).every((rest) => rest.trim().length === 0);
|
|
1230
|
+
if (isLastContentLine && !hasSeparator) {
|
|
1231
|
+
truncateTo = validBytes;
|
|
1232
|
+
break;
|
|
1233
|
+
}
|
|
1234
|
+
throw err;
|
|
1235
|
+
}
|
|
1236
|
+
}
|
|
1237
|
+
if (truncateTo !== null) await truncate2(this.file, truncateTo);
|
|
1238
|
+
this.handle = await open2(this.file, "a");
|
|
1239
|
+
this.loaded = true;
|
|
1240
|
+
}
|
|
1241
|
+
/** Total events == next seq to be assigned == the "caught up" cursor. */
|
|
1242
|
+
get nextSeq() {
|
|
1243
|
+
return this.events.length;
|
|
1244
|
+
}
|
|
1245
|
+
/** Every event with `seq >= since`, in order. Pure read from memory. */
|
|
1246
|
+
readSince(since) {
|
|
1247
|
+
if (since <= 0) return this.events.slice();
|
|
1248
|
+
return this.events.slice(since);
|
|
1249
|
+
}
|
|
1250
|
+
/** The last `n` events (for the catch-up digest). */
|
|
1251
|
+
tail(n) {
|
|
1252
|
+
if (n <= 0) return [];
|
|
1253
|
+
return this.events.slice(Math.max(0, this.events.length - n));
|
|
1254
|
+
}
|
|
1255
|
+
/** True if a qualifying (non-agent) event exists at or beyond `since`. */
|
|
1256
|
+
hasIncoming(since) {
|
|
1257
|
+
for (let i = Math.max(0, since); i < this.events.length; i++) {
|
|
1258
|
+
const event = this.events[i];
|
|
1259
|
+
if (event && event.source !== "agent") return true;
|
|
1260
|
+
}
|
|
1261
|
+
return false;
|
|
1262
|
+
}
|
|
1263
|
+
/**
|
|
1264
|
+
* Append one event durably. Serialized so seq assignment and the write can
|
|
1265
|
+
* never interleave; resolves only after `fsync`. Listeners (WS broadcast,
|
|
1266
|
+
* presence, parked polls) fire synchronously right after the in-memory push,
|
|
1267
|
+
* with no `await` in between - so a poller that just read `events` and is about
|
|
1268
|
+
* to register cannot miss the wakeup.
|
|
1269
|
+
*/
|
|
1270
|
+
async append(input) {
|
|
1271
|
+
await this.load();
|
|
1272
|
+
const run = this.mutex.then(async () => {
|
|
1273
|
+
const handle = this.handle;
|
|
1274
|
+
if (!handle) throw new Error("session log not open");
|
|
1275
|
+
const event = {
|
|
1276
|
+
id: randomUUID(),
|
|
1277
|
+
seq: this.events.length,
|
|
1278
|
+
sessionId: this.id,
|
|
1279
|
+
ts: (/* @__PURE__ */ new Date()).toISOString(),
|
|
1280
|
+
type: input.type,
|
|
1281
|
+
source: input.source,
|
|
1282
|
+
payload: input.payload
|
|
1283
|
+
};
|
|
1284
|
+
await handle.write(`${JSON.stringify(event)}
|
|
1285
|
+
`);
|
|
1286
|
+
await handle.sync();
|
|
1287
|
+
this.events.push(event);
|
|
1288
|
+
this.emit(event);
|
|
1289
|
+
return event;
|
|
1290
|
+
});
|
|
1291
|
+
this.mutex = run.catch(() => void 0);
|
|
1292
|
+
return run;
|
|
1293
|
+
}
|
|
1294
|
+
/** Subscribe to every future append (WS/presence). Returns an unsubscribe. */
|
|
1295
|
+
subscribe(listener) {
|
|
1296
|
+
this.listeners.add(listener);
|
|
1297
|
+
return () => this.listeners.delete(listener);
|
|
1298
|
+
}
|
|
1299
|
+
/**
|
|
1300
|
+
* Resolve on the next append or when `timeoutMs`/`signal` fires. Registration
|
|
1301
|
+
* is synchronous (in the Promise executor), so callers can qualify-check then
|
|
1302
|
+
* await this with no yield between - no lost wakeup.
|
|
1303
|
+
*/
|
|
1304
|
+
waitForAppend(timeoutMs, signal) {
|
|
1305
|
+
return new Promise((resolve) => {
|
|
1306
|
+
const finish = () => {
|
|
1307
|
+
clearTimeout(timer);
|
|
1308
|
+
this.listeners.delete(onAppend);
|
|
1309
|
+
signal?.removeEventListener("abort", finish);
|
|
1310
|
+
resolve();
|
|
1311
|
+
};
|
|
1312
|
+
const onAppend = () => finish();
|
|
1313
|
+
const timer = setTimeout(finish, timeoutMs);
|
|
1314
|
+
this.listeners.add(onAppend);
|
|
1315
|
+
if (signal) {
|
|
1316
|
+
if (signal.aborted) {
|
|
1317
|
+
finish();
|
|
1318
|
+
return;
|
|
1319
|
+
}
|
|
1320
|
+
signal.addEventListener("abort", finish, { once: true });
|
|
1321
|
+
}
|
|
1322
|
+
});
|
|
1323
|
+
}
|
|
1324
|
+
emit(event) {
|
|
1325
|
+
for (const listener of [...this.listeners]) listener(event);
|
|
1326
|
+
}
|
|
1327
|
+
async close() {
|
|
1328
|
+
const handle = this.handle;
|
|
1329
|
+
this.handle = null;
|
|
1330
|
+
this.loaded = false;
|
|
1331
|
+
await handle?.close();
|
|
1332
|
+
}
|
|
1333
|
+
};
|
|
1334
|
+
|
|
1335
|
+
// packages/server/src/sessions/meta.ts
|
|
1336
|
+
import { randomUUID as randomUUID2 } from "node:crypto";
|
|
1337
|
+
import { readdir, readFile as readFile6, rename, rm as rm2, writeFile } from "node:fs/promises";
|
|
1338
|
+
async function readMeta(home, id) {
|
|
1339
|
+
try {
|
|
1340
|
+
const raw = await readFile6(metaPath(home, id), "utf8");
|
|
1341
|
+
return sessionMetaSchema.parse(JSON.parse(raw));
|
|
1342
|
+
} catch (err) {
|
|
1343
|
+
if (err.code === "ENOENT") return null;
|
|
1344
|
+
throw err;
|
|
1345
|
+
}
|
|
1346
|
+
}
|
|
1347
|
+
async function writeMeta(home, meta) {
|
|
1348
|
+
const target = metaPath(home, meta.id);
|
|
1349
|
+
const tmp = `${target}.${randomUUID2()}.tmp`;
|
|
1350
|
+
try {
|
|
1351
|
+
await writeFile(tmp, `${JSON.stringify(meta, null, 2)}
|
|
1352
|
+
`, "utf8");
|
|
1353
|
+
await rename(tmp, target);
|
|
1354
|
+
} catch (err) {
|
|
1355
|
+
await rm2(tmp, { force: true }).catch(() => void 0);
|
|
1356
|
+
throw err;
|
|
1357
|
+
}
|
|
1358
|
+
}
|
|
1359
|
+
async function listSessionIds(home) {
|
|
1360
|
+
try {
|
|
1361
|
+
const entries = await readdir(sessionsDir(home), { withFileTypes: true });
|
|
1362
|
+
return entries.filter((entry) => entry.isDirectory()).map((entry) => entry.name);
|
|
1363
|
+
} catch (err) {
|
|
1364
|
+
if (err.code === "ENOENT") return [];
|
|
1365
|
+
throw err;
|
|
1366
|
+
}
|
|
1367
|
+
}
|
|
1368
|
+
|
|
1369
|
+
// packages/server/src/sessions/index.ts
|
|
1370
|
+
var Sessions = class {
|
|
1371
|
+
constructor(home) {
|
|
1372
|
+
this.home = home;
|
|
1373
|
+
}
|
|
1374
|
+
logs = /* @__PURE__ */ new Map();
|
|
1375
|
+
metaCache = /* @__PURE__ */ new Map();
|
|
1376
|
+
metaLocks = /* @__PURE__ */ new Map();
|
|
1377
|
+
/**
|
|
1378
|
+
* Serialize a meta read-modify-write for one session. Without this, a message's
|
|
1379
|
+
* `touch` can read `active` from cache, then write it back on top of an `end`
|
|
1380
|
+
* that landed in between - leaving `meta.json` `active` while the log holds a
|
|
1381
|
+
* `session-ended` event. Chaining every read+write per session makes the last
|
|
1382
|
+
* writer see the previous one's result, so status can never regress.
|
|
1383
|
+
*/
|
|
1384
|
+
withMetaLock(id, fn) {
|
|
1385
|
+
const prev = this.metaLocks.get(id) ?? Promise.resolve();
|
|
1386
|
+
const run = prev.then(fn, fn);
|
|
1387
|
+
this.metaLocks.set(
|
|
1388
|
+
id,
|
|
1389
|
+
run.then(
|
|
1390
|
+
() => void 0,
|
|
1391
|
+
() => void 0
|
|
1392
|
+
)
|
|
1393
|
+
);
|
|
1394
|
+
return run;
|
|
1395
|
+
}
|
|
1396
|
+
async init() {
|
|
1397
|
+
await ensureChambaHome(this.home);
|
|
1398
|
+
}
|
|
1399
|
+
async create(req) {
|
|
1400
|
+
await ensureChambaHome(this.home);
|
|
1401
|
+
const id = await this.generateId();
|
|
1402
|
+
const now = (/* @__PURE__ */ new Date()).toISOString();
|
|
1403
|
+
const meta = {
|
|
1404
|
+
id,
|
|
1405
|
+
...req.kind ? { kind: req.kind } : {},
|
|
1406
|
+
...req.title ? { title: req.title } : {},
|
|
1407
|
+
status: "active",
|
|
1408
|
+
createdAt: now,
|
|
1409
|
+
updatedAt: now
|
|
1410
|
+
};
|
|
1411
|
+
await this.logFor(id);
|
|
1412
|
+
await writeMeta(this.home, meta);
|
|
1413
|
+
this.metaCache.set(id, meta);
|
|
1414
|
+
await this.append(id, {
|
|
1415
|
+
type: "system",
|
|
1416
|
+
source: "system",
|
|
1417
|
+
payload: { reason: "session-created" }
|
|
1418
|
+
});
|
|
1419
|
+
return await this.getMeta(id) ?? meta;
|
|
1420
|
+
}
|
|
1421
|
+
async getMeta(id) {
|
|
1422
|
+
const cached = this.metaCache.get(id);
|
|
1423
|
+
if (cached) return cached;
|
|
1424
|
+
const meta = await readMeta(this.home, id);
|
|
1425
|
+
if (meta) this.metaCache.set(id, meta);
|
|
1426
|
+
return meta;
|
|
1427
|
+
}
|
|
1428
|
+
async list() {
|
|
1429
|
+
const ids = await listSessionIds(this.home);
|
|
1430
|
+
const metas = await Promise.all(ids.map((id) => this.getMeta(id)));
|
|
1431
|
+
return metas.filter((meta) => meta !== null).sort((a, b) => b.updatedAt.localeCompare(a.updatedAt));
|
|
1432
|
+
}
|
|
1433
|
+
/** Get (or lazily create + replay) the in-memory log for a session. */
|
|
1434
|
+
async logFor(id) {
|
|
1435
|
+
let log = this.logs.get(id);
|
|
1436
|
+
if (!log) {
|
|
1437
|
+
log = new SessionLog(id, logPath(this.home, id));
|
|
1438
|
+
this.logs.set(id, log);
|
|
1439
|
+
}
|
|
1440
|
+
await log.load();
|
|
1441
|
+
return log;
|
|
1442
|
+
}
|
|
1443
|
+
/** Durable append; also bumps the session's `updatedAt`. */
|
|
1444
|
+
async append(id, input) {
|
|
1445
|
+
const log = await this.logFor(id);
|
|
1446
|
+
const event = await log.append(input);
|
|
1447
|
+
await this.touch(id);
|
|
1448
|
+
return event;
|
|
1449
|
+
}
|
|
1450
|
+
postMessage(id, body) {
|
|
1451
|
+
return this.append(id, { type: "message", source: "user", payload: messagePayload(body) });
|
|
1452
|
+
}
|
|
1453
|
+
reply(id, body) {
|
|
1454
|
+
return this.append(id, { type: "message", source: "agent", payload: messagePayload(body) });
|
|
1455
|
+
}
|
|
1456
|
+
/** Append a batch of element comments as one `annotation-batch` event (source
|
|
1457
|
+
* `user`, so the agent's poll delivers it like any incoming line). */
|
|
1458
|
+
postAnnotations(id, items) {
|
|
1459
|
+
return this.append(id, { type: ANNOTATION_BATCH, source: "user", payload: { items } });
|
|
1460
|
+
}
|
|
1461
|
+
/** A structured interview question, rendered as a card in the conversation. The
|
|
1462
|
+
* agent authors these, so the source is `agent` (like a reply); the user's
|
|
1463
|
+
* answer comes back as a normal `message`. */
|
|
1464
|
+
postQuestionCard(id, payload) {
|
|
1465
|
+
return this.append(id, { type: QUESTION_CARD, source: "agent", payload });
|
|
1466
|
+
}
|
|
1467
|
+
/** A decision-log entry (confirmed-by-user vs. assumed-by-agent). Agent-sourced. */
|
|
1468
|
+
postDecision(id, payload) {
|
|
1469
|
+
return this.append(id, { type: DECISION, source: "agent", payload });
|
|
1470
|
+
}
|
|
1471
|
+
/** Announce an authored/revised artifact. The bytes were written by
|
|
1472
|
+
* `saveArtifact`; this records the reference as an event so the surface tab and
|
|
1473
|
+
* the thread learn about the revision. Agent-sourced. */
|
|
1474
|
+
postArtifact(id, artifact) {
|
|
1475
|
+
return this.append(id, { type: ARTIFACT, source: "agent", payload: { artifact } });
|
|
1476
|
+
}
|
|
1477
|
+
/** Record that the user activated another session, so the agent's poll on this
|
|
1478
|
+
* one learns where to hand off (attach the target, digest, keep attending). */
|
|
1479
|
+
moveAway(id, to) {
|
|
1480
|
+
return this.append(id, {
|
|
1481
|
+
type: "system",
|
|
1482
|
+
source: "system",
|
|
1483
|
+
payload: { reason: "user-moved-away", ...to ? { to } : {} }
|
|
1484
|
+
});
|
|
1485
|
+
}
|
|
1486
|
+
/**
|
|
1487
|
+
* Persist an uploaded file into the session's attachments directory and return
|
|
1488
|
+
* its durable reference. The stored name embeds a generated id and the sanitized
|
|
1489
|
+
* original (`<attId>__<name>`), so the file is self-describing on disk and
|
|
1490
|
+
* resolvable later without a sidecar; the recorded path stays session-relative.
|
|
1491
|
+
*/
|
|
1492
|
+
async saveAttachment(id, name, bytes) {
|
|
1493
|
+
const dir = attachmentsDir(this.home, id);
|
|
1494
|
+
await mkdir3(dir, { recursive: true });
|
|
1495
|
+
const attId = randomUUID3();
|
|
1496
|
+
const safeName = sanitizeName(name);
|
|
1497
|
+
const stored = `${attId}__${safeName}`;
|
|
1498
|
+
await writeFile2(path7.join(dir, stored), bytes);
|
|
1499
|
+
return {
|
|
1500
|
+
id: attId,
|
|
1501
|
+
name: safeName,
|
|
1502
|
+
relPath: `attachments/${stored}`,
|
|
1503
|
+
contentType: contentTypeFor(safeName),
|
|
1504
|
+
size: bytes.byteLength
|
|
1505
|
+
};
|
|
1506
|
+
}
|
|
1507
|
+
/** Rebuild an attachment reference from disk by its id (null if absent). */
|
|
1508
|
+
async resolveAttachment(id, attId) {
|
|
1509
|
+
const stored = await this.storedAttachment(id, attId);
|
|
1510
|
+
if (!stored) return null;
|
|
1511
|
+
const info = await stat(path7.join(attachmentsDir(this.home, id), stored));
|
|
1512
|
+
return {
|
|
1513
|
+
id: attId,
|
|
1514
|
+
name: stored.slice(attId.length + 2),
|
|
1515
|
+
relPath: `attachments/${stored}`,
|
|
1516
|
+
contentType: contentTypeFor(stored),
|
|
1517
|
+
size: info.size
|
|
1518
|
+
};
|
|
1519
|
+
}
|
|
1520
|
+
/** Read an attachment's bytes and content type for serving (null if absent).
|
|
1521
|
+
* The bytes are copied into a plain `ArrayBuffer`-backed view so the response
|
|
1522
|
+
* layer can stream them directly (a `Buffer` widens to `ArrayBufferLike`). */
|
|
1523
|
+
async readAttachment(id, attId) {
|
|
1524
|
+
const stored = await this.storedAttachment(id, attId);
|
|
1525
|
+
if (!stored) return null;
|
|
1526
|
+
const raw = await readFile7(path7.join(attachmentsDir(this.home, id), stored));
|
|
1527
|
+
const bytes = new Uint8Array(new ArrayBuffer(raw.byteLength));
|
|
1528
|
+
bytes.set(raw);
|
|
1529
|
+
return { bytes, contentType: contentTypeFor(stored) };
|
|
1530
|
+
}
|
|
1531
|
+
/**
|
|
1532
|
+
* Persist an artifact's source text under the session's `artifacts/` directory
|
|
1533
|
+
* and return its durable reference. Re-posting the same (kind, name) overwrites
|
|
1534
|
+
* the file - the latest content is what the surface tab and export use, while
|
|
1535
|
+
* the event log preserves that a revision happened. The stored filename embeds
|
|
1536
|
+
* the kind and a kind-specific extension (`draft-*.md`, `mock-*.html`,
|
|
1537
|
+
* `diagram-*.mmd`), so a file is self-describing and the serve route can infer
|
|
1538
|
+
* how to render it without a sidecar.
|
|
1539
|
+
*/
|
|
1540
|
+
async saveArtifact(id, kind, name, content) {
|
|
1541
|
+
const dir = artifactsDir(this.home, id);
|
|
1542
|
+
await mkdir3(dir, { recursive: true });
|
|
1543
|
+
const ext = ARTIFACT_EXT[kind];
|
|
1544
|
+
const base = sanitizeName(name).replace(/\.(md|markdown|html?|mmd|mermaid)$/i, "");
|
|
1545
|
+
const stored = `${kind}-${base || "spec"}${ext}`;
|
|
1546
|
+
const bytes = Buffer.from(content, "utf8");
|
|
1547
|
+
await writeFile2(path7.join(dir, stored), bytes);
|
|
1548
|
+
return {
|
|
1549
|
+
kind,
|
|
1550
|
+
name,
|
|
1551
|
+
relPath: `artifacts/${stored}`,
|
|
1552
|
+
contentType: ARTIFACT_CONTENT_TYPE[kind],
|
|
1553
|
+
size: bytes.byteLength
|
|
1554
|
+
};
|
|
1555
|
+
}
|
|
1556
|
+
/** Read a stored artifact by its on-disk filename (basename of a ref's
|
|
1557
|
+
* `relPath`), or null. The session id and the name are both guarded to flat safe
|
|
1558
|
+
* tokens so the serve route cannot be walked out of the artifacts directory. */
|
|
1559
|
+
async readArtifact(id, file) {
|
|
1560
|
+
if (!isSafeId(id)) return null;
|
|
1561
|
+
if (!/^[A-Za-z0-9._-]+$/.test(file) || file.includes("..")) return null;
|
|
1562
|
+
try {
|
|
1563
|
+
const content = await readFile7(path7.join(artifactsDir(this.home, id), file), "utf8");
|
|
1564
|
+
return { content, file };
|
|
1565
|
+
} catch {
|
|
1566
|
+
return null;
|
|
1567
|
+
}
|
|
1568
|
+
}
|
|
1569
|
+
/**
|
|
1570
|
+
* File an offline issue report under `.chamba/issues/<id>/`. Referenced
|
|
1571
|
+
* screenshots are copied in as self-contained files, then `report.md` and
|
|
1572
|
+
* `context.json` capture the report plus auto-collected context (chamba
|
|
1573
|
+
* version, the session, a tail of recent events, basic env). Returns the new
|
|
1574
|
+
* id and the absolute directory, or `null` if a referenced screenshot id is
|
|
1575
|
+
* unknown for this session. Deliberately never appends to the log - an issue is
|
|
1576
|
+
* an on-disk artifact, not a conversation event.
|
|
1577
|
+
*/
|
|
1578
|
+
async saveIssue(meta, req) {
|
|
1579
|
+
const resolved = [];
|
|
1580
|
+
for (const attId of req.screenshotAttachmentIds ?? []) {
|
|
1581
|
+
const ref = await this.resolveAttachment(meta.id, attId);
|
|
1582
|
+
const file = await this.readAttachment(meta.id, attId);
|
|
1583
|
+
if (!ref || !file) return null;
|
|
1584
|
+
const ext = path7.extname(ref.name) || extFromContentType(file.contentType);
|
|
1585
|
+
resolved.push({ name: `screenshot-${resolved.length + 1}${ext}`, bytes: file.bytes });
|
|
1586
|
+
}
|
|
1587
|
+
const id = await this.generateId();
|
|
1588
|
+
const dir = issueDir(this.home, id);
|
|
1589
|
+
await mkdir3(dir, { recursive: true });
|
|
1590
|
+
for (const shot of resolved) await writeFile2(path7.join(dir, shot.name), shot.bytes);
|
|
1591
|
+
const tail = (await this.logFor(meta.id)).tail(ISSUE_TAIL);
|
|
1592
|
+
const record = buildIssueRecord({
|
|
1593
|
+
id,
|
|
1594
|
+
type: req.type,
|
|
1595
|
+
text: req.text,
|
|
1596
|
+
filedAt: (/* @__PURE__ */ new Date()).toISOString(),
|
|
1597
|
+
meta,
|
|
1598
|
+
tail,
|
|
1599
|
+
screenshots: resolved.map((shot) => shot.name)
|
|
1600
|
+
});
|
|
1601
|
+
await writeFile2(path7.join(dir, "report.md"), renderIssueMarkdown(record), "utf8");
|
|
1602
|
+
await writeFile2(path7.join(dir, "context.json"), `${JSON.stringify(record, null, 2)}
|
|
1603
|
+
`, "utf8");
|
|
1604
|
+
return { id, path: dir };
|
|
1605
|
+
}
|
|
1606
|
+
/** The on-disk filename for an attachment id, or null. The id is a uuid (no
|
|
1607
|
+
* `__`), so the `<attId>__` prefix uniquely identifies one stored file. The
|
|
1608
|
+
* session id is guarded too: the attachment-serve route reaches here without the
|
|
1609
|
+
* `getMeta` check the other routes use, so an unsafe id must fail closed here. */
|
|
1610
|
+
async storedAttachment(id, attId) {
|
|
1611
|
+
if (!isSafeId(id)) return null;
|
|
1612
|
+
if (!/^[0-9a-f-]+$/i.test(attId)) return null;
|
|
1613
|
+
let entries;
|
|
1614
|
+
try {
|
|
1615
|
+
entries = await readdir2(attachmentsDir(this.home, id));
|
|
1616
|
+
} catch {
|
|
1617
|
+
return null;
|
|
1618
|
+
}
|
|
1619
|
+
return entries.find((entry) => entry.startsWith(`${attId}__`)) ?? null;
|
|
1620
|
+
}
|
|
1621
|
+
/**
|
|
1622
|
+
* The agent long-poll: return every incoming (non-agent) event at or beyond
|
|
1623
|
+
* `since`, parking until one arrives or the wait elapses. The sync
|
|
1624
|
+
* qualify-check and waiter registration have no `await` between them, so an
|
|
1625
|
+
* append concurrent with a poll can never be missed.
|
|
1626
|
+
*/
|
|
1627
|
+
async poll(id, since, waitMs, signal) {
|
|
1628
|
+
const log = await this.logFor(id);
|
|
1629
|
+
const deadline = Date.now() + waitMs;
|
|
1630
|
+
while (true) {
|
|
1631
|
+
if (log.hasIncoming(since)) {
|
|
1632
|
+
const events = log.readSince(since).filter((event) => event.source !== "agent");
|
|
1633
|
+
return { events, cursor: log.nextSeq };
|
|
1634
|
+
}
|
|
1635
|
+
const remaining = deadline - Date.now();
|
|
1636
|
+
if (remaining <= 0 || signal?.aborted) return { events: [], cursor: log.nextSeq };
|
|
1637
|
+
await log.waitForAppend(remaining, signal);
|
|
1638
|
+
}
|
|
1639
|
+
}
|
|
1640
|
+
/** Non-blocking history read (all sources), optionally capped. */
|
|
1641
|
+
async events(id, since, limit) {
|
|
1642
|
+
const log = await this.logFor(id);
|
|
1643
|
+
const all = log.readSince(since);
|
|
1644
|
+
const events = limit && limit > 0 ? all.slice(0, limit) : all;
|
|
1645
|
+
return { events, cursor: log.nextSeq };
|
|
1646
|
+
}
|
|
1647
|
+
/** The catch-up digest: metadata, a recent tail, the poll cursor, how to get more. */
|
|
1648
|
+
async attach(id, tailN, presence) {
|
|
1649
|
+
const meta = await this.getMeta(id);
|
|
1650
|
+
if (!meta) return null;
|
|
1651
|
+
const log = await this.logFor(id);
|
|
1652
|
+
const tail = log.tail(tailN);
|
|
1653
|
+
const earliest = tail.length > 0 ? tail[0]?.seq ?? 0 : log.nextSeq;
|
|
1654
|
+
return {
|
|
1655
|
+
meta,
|
|
1656
|
+
presence,
|
|
1657
|
+
tail,
|
|
1658
|
+
cursor: log.nextSeq,
|
|
1659
|
+
fetchMore: {
|
|
1660
|
+
hasMore: earliest > 0,
|
|
1661
|
+
earliestLoadedSeq: earliest,
|
|
1662
|
+
hint: earliest > 0 ? `chamba events --since 0 (older history before seq ${earliest})` : "none"
|
|
1663
|
+
}
|
|
1664
|
+
};
|
|
1665
|
+
}
|
|
1666
|
+
/** End a session: append the lifecycle marker, flip status to `ended`. */
|
|
1667
|
+
async end(id) {
|
|
1668
|
+
const meta = await this.getMeta(id);
|
|
1669
|
+
if (!meta) return null;
|
|
1670
|
+
if (meta.status === "ended") return meta;
|
|
1671
|
+
await this.append(id, {
|
|
1672
|
+
type: "system",
|
|
1673
|
+
source: "system",
|
|
1674
|
+
payload: { reason: "session-ended" }
|
|
1675
|
+
});
|
|
1676
|
+
return this.withMetaLock(id, async () => {
|
|
1677
|
+
const current = await this.getMeta(id) ?? meta;
|
|
1678
|
+
const ended = {
|
|
1679
|
+
...current,
|
|
1680
|
+
status: "ended",
|
|
1681
|
+
updatedAt: (/* @__PURE__ */ new Date()).toISOString()
|
|
1682
|
+
};
|
|
1683
|
+
await writeMeta(this.home, ended);
|
|
1684
|
+
this.metaCache.set(id, ended);
|
|
1685
|
+
return ended;
|
|
1686
|
+
});
|
|
1687
|
+
}
|
|
1688
|
+
/**
|
|
1689
|
+
* Set the kind of an undecided session - the human's choice in the browser.
|
|
1690
|
+
* Allowed once: a session that is unknown, ended, or already has a kind is
|
|
1691
|
+
* refused. Appends a `kind-chosen` marker so the agent's poll learns the choice,
|
|
1692
|
+
* then locks the kind into meta. Keeps the property that a decided session's
|
|
1693
|
+
* kind never changes (an everyday session never grows spec events).
|
|
1694
|
+
*/
|
|
1695
|
+
async setKind(id, kind) {
|
|
1696
|
+
const meta = await this.getMeta(id);
|
|
1697
|
+
if (!meta) return { ok: false, reason: "unknown" };
|
|
1698
|
+
if (meta.status === "ended") return { ok: false, reason: "ended" };
|
|
1699
|
+
if (meta.kind) return { ok: false, reason: "already-set" };
|
|
1700
|
+
await this.append(id, {
|
|
1701
|
+
type: "system",
|
|
1702
|
+
source: "system",
|
|
1703
|
+
payload: { reason: "kind-chosen", kind }
|
|
1704
|
+
});
|
|
1705
|
+
return this.withMetaLock(id, async () => {
|
|
1706
|
+
const current = await this.getMeta(id) ?? meta;
|
|
1707
|
+
const decided = { ...current, kind, updatedAt: (/* @__PURE__ */ new Date()).toISOString() };
|
|
1708
|
+
await writeMeta(this.home, decided);
|
|
1709
|
+
this.metaCache.set(id, decided);
|
|
1710
|
+
return { ok: true, meta: decided };
|
|
1711
|
+
});
|
|
1712
|
+
}
|
|
1713
|
+
touch(id) {
|
|
1714
|
+
return this.withMetaLock(id, async () => {
|
|
1715
|
+
const meta = await this.getMeta(id);
|
|
1716
|
+
if (!meta) return;
|
|
1717
|
+
const updated = { ...meta, updatedAt: (/* @__PURE__ */ new Date()).toISOString() };
|
|
1718
|
+
await writeMeta(this.home, updated);
|
|
1719
|
+
this.metaCache.set(id, updated);
|
|
1720
|
+
});
|
|
1721
|
+
}
|
|
1722
|
+
async generateId() {
|
|
1723
|
+
for (let attempt = 0; attempt < 5; attempt++) {
|
|
1724
|
+
const id = `${Date.now().toString(36)}${randomBytes(3).toString("hex")}`;
|
|
1725
|
+
if (await readMeta(this.home, id) === null) return id;
|
|
1726
|
+
}
|
|
1727
|
+
return randomUUID3();
|
|
1728
|
+
}
|
|
1729
|
+
};
|
|
1730
|
+
function isSafeId(id) {
|
|
1731
|
+
return /^[A-Za-z0-9_-]+$/.test(id);
|
|
1732
|
+
}
|
|
1733
|
+
var ISSUE_TAIL = 20;
|
|
1734
|
+
function extFromContentType(contentType) {
|
|
1735
|
+
const type = contentType.split(";")[0]?.trim().toLowerCase();
|
|
1736
|
+
switch (type) {
|
|
1737
|
+
case "image/png":
|
|
1738
|
+
return ".png";
|
|
1739
|
+
case "image/jpeg":
|
|
1740
|
+
return ".jpg";
|
|
1741
|
+
case "image/gif":
|
|
1742
|
+
return ".gif";
|
|
1743
|
+
case "image/webp":
|
|
1744
|
+
return ".webp";
|
|
1745
|
+
case "image/svg+xml":
|
|
1746
|
+
return ".svg";
|
|
1747
|
+
default:
|
|
1748
|
+
return ".bin";
|
|
1749
|
+
}
|
|
1750
|
+
}
|
|
1751
|
+
var ARTIFACT_EXT = {
|
|
1752
|
+
draft: ".md",
|
|
1753
|
+
mock: ".html",
|
|
1754
|
+
diagram: ".mmd"
|
|
1755
|
+
};
|
|
1756
|
+
var ARTIFACT_CONTENT_TYPE = {
|
|
1757
|
+
draft: "text/markdown; charset=utf-8",
|
|
1758
|
+
mock: "text/html; charset=utf-8",
|
|
1759
|
+
diagram: "text/plain; charset=utf-8"
|
|
1760
|
+
};
|
|
1761
|
+
function messagePayload(body) {
|
|
1762
|
+
return {
|
|
1763
|
+
...body.text ? { text: body.text } : {},
|
|
1764
|
+
...body.attachments && body.attachments.length > 0 ? { attachments: body.attachments } : {}
|
|
1765
|
+
};
|
|
1766
|
+
}
|
|
1767
|
+
function sanitizeName(name) {
|
|
1768
|
+
const base = name.split(/[\\/]/).pop() ?? name;
|
|
1769
|
+
const cleaned = base.replace(/[^A-Za-z0-9._-]/g, "_").replace(/^\.+/, "");
|
|
1770
|
+
return cleaned.length > 0 ? cleaned.slice(0, 120) : "file";
|
|
1771
|
+
}
|
|
1772
|
+
|
|
1773
|
+
// packages/server/src/ws.ts
|
|
1774
|
+
var WsHub = class {
|
|
1775
|
+
rooms = /* @__PURE__ */ new Map();
|
|
1776
|
+
forwarders = /* @__PURE__ */ new Map();
|
|
1777
|
+
/** Add a socket to a room; returns true if it is the room's first socket. */
|
|
1778
|
+
add(id, ws) {
|
|
1779
|
+
let room = this.rooms.get(id);
|
|
1780
|
+
if (!room) {
|
|
1781
|
+
room = /* @__PURE__ */ new Set();
|
|
1782
|
+
this.rooms.set(id, room);
|
|
1783
|
+
}
|
|
1784
|
+
room.add(ws);
|
|
1785
|
+
return room.size === 1;
|
|
1786
|
+
}
|
|
1787
|
+
remove(id, ws) {
|
|
1788
|
+
const room = this.rooms.get(id);
|
|
1789
|
+
if (!room) return;
|
|
1790
|
+
room.delete(ws);
|
|
1791
|
+
if (room.size === 0) {
|
|
1792
|
+
this.rooms.delete(id);
|
|
1793
|
+
const unsubscribe = this.forwarders.get(id);
|
|
1794
|
+
if (unsubscribe) {
|
|
1795
|
+
unsubscribe();
|
|
1796
|
+
this.forwarders.delete(id);
|
|
1797
|
+
}
|
|
1798
|
+
}
|
|
1799
|
+
}
|
|
1800
|
+
setForwarder(id, unsubscribe) {
|
|
1801
|
+
this.forwarders.set(id, unsubscribe);
|
|
1802
|
+
}
|
|
1803
|
+
broadcast(id, message) {
|
|
1804
|
+
const room = this.rooms.get(id);
|
|
1805
|
+
if (!room) return;
|
|
1806
|
+
const data = JSON.stringify(message);
|
|
1807
|
+
for (const ws of [...room]) {
|
|
1808
|
+
try {
|
|
1809
|
+
ws.send(data);
|
|
1810
|
+
} catch {
|
|
1811
|
+
}
|
|
1812
|
+
}
|
|
1813
|
+
}
|
|
1814
|
+
hasClients() {
|
|
1815
|
+
return this.rooms.size > 0;
|
|
1816
|
+
}
|
|
1817
|
+
};
|
|
1818
|
+
|
|
1819
|
+
// packages/server/src/index.ts
|
|
1820
|
+
var DEFAULT_IDLE_MS = 5 * 6e4;
|
|
1821
|
+
async function serve(options = {}) {
|
|
1822
|
+
const home = options.home ?? resolveHome();
|
|
1823
|
+
const port = options.port ?? resolvePort(process.argv.slice(2), home);
|
|
1824
|
+
const host = options.host ?? resolveBindHost();
|
|
1825
|
+
const webDist = options.webDist ?? resolveWebDist();
|
|
1826
|
+
const injectDist = options.injectDist ?? resolveInjectDist();
|
|
1827
|
+
const sessions = new Sessions(home);
|
|
1828
|
+
await sessions.init();
|
|
1829
|
+
const presence = new PresenceTracker();
|
|
1830
|
+
const hub = new WsHub();
|
|
1831
|
+
const { app, injectWebSocket } = createApp({
|
|
1832
|
+
sessions,
|
|
1833
|
+
presence,
|
|
1834
|
+
hub,
|
|
1835
|
+
webDist,
|
|
1836
|
+
injectDist,
|
|
1837
|
+
home,
|
|
1838
|
+
port,
|
|
1839
|
+
version: chambaVersion()
|
|
1840
|
+
});
|
|
1841
|
+
const pid = await acquirePidfile(home, port);
|
|
1842
|
+
const server = honoServe({ fetch: app.fetch, hostname: host, port });
|
|
1843
|
+
injectWebSocket(server);
|
|
1844
|
+
try {
|
|
1845
|
+
await waitListening(server);
|
|
1846
|
+
} catch (err) {
|
|
1847
|
+
await pid.release();
|
|
1848
|
+
throw err;
|
|
1849
|
+
}
|
|
1850
|
+
let closing = false;
|
|
1851
|
+
let stopWatchdog = () => void 0;
|
|
1852
|
+
const close = async () => {
|
|
1853
|
+
if (closing) return;
|
|
1854
|
+
closing = true;
|
|
1855
|
+
stopWatchdog();
|
|
1856
|
+
await closeServer(server);
|
|
1857
|
+
await pid.release();
|
|
1858
|
+
};
|
|
1859
|
+
stopWatchdog = startIdleWatchdog({
|
|
1860
|
+
idleMs: options.idleMs ?? parseIdleMs(process.env.CHAMBA_IDLE_MS) ?? DEFAULT_IDLE_MS,
|
|
1861
|
+
isBusy: () => hub.hasClients() || presence.hasActivePolls(),
|
|
1862
|
+
// `close()` drains in-flight connections (a POST is neither a WS client nor a
|
|
1863
|
+
// parked poll, so `isBusy` does not count it) and releases the pidfile; the
|
|
1864
|
+
// explicit exit then guarantees the detached process actually goes away rather
|
|
1865
|
+
// than lingering on some stray handle.
|
|
1866
|
+
onIdle: () => void close().then(() => process.exit(0))
|
|
1867
|
+
});
|
|
1868
|
+
return { port, url: `http://localhost:${port}/`, home, sessions, close };
|
|
1869
|
+
}
|
|
1870
|
+
function parseIdleMs(value) {
|
|
1871
|
+
if (value === void 0 || value.length === 0) return null;
|
|
1872
|
+
const n = Number(value);
|
|
1873
|
+
return Number.isFinite(n) && n >= 0 ? Math.floor(n) : null;
|
|
1874
|
+
}
|
|
1875
|
+
function waitListening(server) {
|
|
1876
|
+
return new Promise((resolve, reject) => {
|
|
1877
|
+
const cleanup = () => {
|
|
1878
|
+
server.off("listening", onListening);
|
|
1879
|
+
server.off("error", onError);
|
|
1880
|
+
};
|
|
1881
|
+
const onListening = () => {
|
|
1882
|
+
cleanup();
|
|
1883
|
+
resolve();
|
|
1884
|
+
};
|
|
1885
|
+
const onError = (err) => {
|
|
1886
|
+
cleanup();
|
|
1887
|
+
reject(err);
|
|
1888
|
+
};
|
|
1889
|
+
server.once("listening", onListening);
|
|
1890
|
+
server.once("error", onError);
|
|
1891
|
+
});
|
|
1892
|
+
}
|
|
1893
|
+
function closeServer(server) {
|
|
1894
|
+
return new Promise((resolve) => server.close(() => resolve()));
|
|
1895
|
+
}
|
|
1896
|
+
|
|
1897
|
+
// packages/server/src/bin.ts
|
|
1898
|
+
async function main() {
|
|
1899
|
+
try {
|
|
1900
|
+
const running = await serve();
|
|
1901
|
+
process.stdout.write(`chamba server listening on ${running.url}
|
|
1902
|
+
`);
|
|
1903
|
+
const shutdown = () => {
|
|
1904
|
+
void running.close().then(() => process.exit(0));
|
|
1905
|
+
};
|
|
1906
|
+
process.once("SIGINT", shutdown);
|
|
1907
|
+
process.once("SIGTERM", shutdown);
|
|
1908
|
+
} catch (err) {
|
|
1909
|
+
if (err instanceof ServerConflictError) {
|
|
1910
|
+
process.stdout.write(`${err.message}
|
|
1911
|
+
`);
|
|
1912
|
+
process.exit(0);
|
|
1913
|
+
}
|
|
1914
|
+
process.stderr.write(`chamba server failed: ${err.message}
|
|
1915
|
+
`);
|
|
1916
|
+
process.exit(1);
|
|
1917
|
+
}
|
|
1918
|
+
}
|
|
1919
|
+
void main();
|