-
Notifications
You must be signed in to change notification settings - Fork 12
Expand file tree
/
Copy pathtypes.ts
More file actions
530 lines (484 loc) · 20 KB
/
Copy pathtypes.ts
File metadata and controls
530 lines (484 loc) · 20 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
// Shared data model — no runtime imports, safe for any platform.
export interface Session {
id: string;
agent: string;
title: string | null;
cwd: string | null;
createdAt: string;
lastActiveAt: string;
// Highest comment seq already delivered to the agent — lets responses to
// agent writes piggyback comments the agent has not seen yet.
agentSeq: number;
}
// A post is an ordered list of surfaces. Each surface declares its own kind;
// the post itself is kind-agnostic. An `html` surface is arbitrary agent
// markup (rendered sandboxed in an iframe); `diff`, `image`, `trace`,
// `markdown`, `terminal`, and `mermaid` surfaces are structured data rendered by
// the trusted viewer. A snippet is just a post with one html surface; a
// diagram-with-its-diff is `[html, diff]`.
// The canonical, ordered list of every surface kind — the single source of
// truth. `SurfaceKind` derives from it, and the MCP tool schemas (mcpSpec.ts)
// build their `kind` enums from it, so a kind can't be added to the model
// without the MCP tier advertising it too (the gap that left `json`/`code`
// publishable over CLI/REST but invisible to MCP). The per-kind FIELD schemas
// in postSurfaces.ts and mcpSpec.ts are still hand-written; test/mcpSpec.test.ts
// guards that every kind here round-trips through both the MCP schema and the
// validator with its fields, so neither half can silently fall behind.
export const SURFACE_KINDS = [
"html",
"diff",
"image",
"trace",
"markdown",
"terminal",
"mermaid",
"json",
"code",
] as const;
export type SurfaceKind = (typeof SURFACE_KINDS)[number];
export interface HtmlSurface {
kind: "html";
html: string;
// Opt-in style/behavior bundles (see kits.ts). The sandbox doc gets each
// listed kit's CSS/JS injected after the base kit; omit for plain html.
kits?: string[];
}
// A markdown surface is prose the trusted viewer renders — explanations, plans,
// tradeoff write-ups. Unlike an html surface it is NOT sandboxed: the viewer
// renders it to HTML in its own origin, so raw HTML embedded in the source is
// escaped, not executed (see MarkdownPart.tsx). Agents wanting live markup use
// an html surface instead.
export interface MarkdownSurface {
kind: "markdown";
markdown: string;
}
// A mermaid surface is diagram source (flowchart, sequence, ERD, gantt, …) the
// trusted viewer renders to SVG with the mermaid library. Like markdown it is
// NOT sandboxed: mermaid renders in the viewer's own origin with
// securityLevel 'strict', sanitizing the SVG and disabling scripts/HTML labels
// (see MermaidPart.tsx). Agents wanting hand-drawn vector art use an html surface
// with inline <svg> instead.
export interface MermaidSurface {
kind: "mermaid";
mermaid: string;
}
export interface DiffFile {
filename: string;
before: string;
after: string;
// Shiki language id; inferred from the filename when omitted.
language?: string;
}
export interface DiffSurface {
kind: "diff";
// A unified/git patch (may span multiple files) and/or explicit before/after
// file pairs. At least one must be present; the viewer prefers `patch`.
patch?: string;
files?: DiffFile[];
layout?: "unified" | "split";
}
// An image surface references an uploaded asset by id; the trusted viewer renders
// it as a plain <img> in its own chrome (no iframe). Agents can also embed the
// asset's URL inside an html surface instead — both paths resolve to /a/:id.
export interface ImageSurface {
kind: "image";
assetId: string;
alt?: string;
caption?: string;
}
// One step in an agent trace. `label` is the one-line summary; `detail` is the
// expandable body (tool output, args, reasoning). Everything else is optional.
export interface TraceStep {
label: string;
kind?: string;
detail?: string;
ts?: string;
}
// A trace surface renders a step timeline the viewer shows beside the post.
// `steps` travel inline (small, structured); `assetId` points at a larger
// uploaded trace file (JSON/JSONL), offered for download and rendered when it
// parses. At least one of the two is present.
export interface TraceSurface {
kind: "trace";
steps?: TraceStep[];
assetId?: string;
title?: string;
}
// A terminal surface renders monospace terminal output the viewer styles as a
// terminal window. `text` travels inline (like html) — raw output that may
// carry ANSI SGR escapes (colors/bold/italic); the viewer converts those to
// styled spans and HTML-escapes everything else. `cols` is an optional render
// width hint; `title` labels the window chrome. The renderer is intentionally
// SGR-only for now (cursor-addressing TUIs aren't resolved) — the wire shape
// is renderer-agnostic so a full VT emulator can replace it later.
export interface TerminalSurface {
kind: "terminal";
text: string;
cols?: number;
title?: string;
}
// A json surface is a pre-parsed JSON value the trusted viewer renders as a
// collapsible tree (objects/arrays expand and collapse; primitives show inline).
// Like image/trace it is DATA, not markup: the viewer renders it with Solid
// text nodes, which escape by construction — so agent-authored JSON can never
// execute in the trusted viewer origin, and no sandboxed iframe is needed.
// `data` is `unknown` (any JSON value, including null); the wire body already
// parsed it, so the viewer never needs to JSON.parse.
export interface JsonSurface {
kind: "json";
data: unknown;
}
// A code surface is source code the trusted viewer highlights with shiki (the
// same highlighter MarkdownPart uses for fenced code blocks) and renders in a
// sandboxed iframe. Like markdown/mermaid it is DATA, not markup: the viewer
// produces the HTML string via shiki, then SandboxedPart parses it inside an
// opaque-origin iframe. `language` is a shiki lang id (ts, js, python, rust,
// go, ...); omit or use "text" for plain monospace. `title` is an optional
// label (e.g. a filename) shown above the code.
export interface CodeSurface {
kind: "code";
code: string;
language?: string;
title?: string;
// 1-based line number the displayed code starts at (e.g. 80 for "lines
// 80-150 of x.ts"). The viewer renders line numbers starting here instead
// of 1, so an agent can show an excerpt with its original line numbers.
lineStart?: number;
}
// Every surface optionally carries a server-assigned id — a short, stable
// identifier for per-surface targeting (append/edit/remove/reorder). The id
// is assigned by normalizeSurfaceIds on create/update and preserved across
// per-surface mutations; full-replace updates assign fresh ids.
export type Surface =
| (HtmlSurface & { id?: string })
| (DiffSurface & { id?: string })
| (ImageSurface & { id?: string })
| (TraceSurface & { id?: string })
| (MarkdownSurface & { id?: string })
| (TerminalSurface & { id?: string })
| (MermaidSurface & { id?: string })
| (JsonSurface & { id?: string })
| (CodeSurface & { id?: string });
export interface PostVersion {
version: number;
title: string;
surfaces: Surface[];
at: string;
}
export interface Post {
id: string;
sessionId: string;
title: string;
surfaces: Surface[];
createdAt: string;
updatedAt: string;
version: number;
history: PostVersion[];
}
export type CommentAnchor =
| {
kind: "point";
surfaceIndex: number;
surfaceId?: string;
surfaceKind?: SurfaceKind;
postVersion: number;
x: number;
y: number;
}
| {
kind: "rect";
surfaceIndex: number;
surfaceId?: string;
surfaceKind?: SurfaceKind;
postVersion: number;
x: number;
y: number;
w: number;
h: number;
}
| {
kind: "lineRange";
surfaceIndex: number;
surfaceId?: string;
surfaceKind?: SurfaceKind;
postVersion: number;
startLine: number;
endLine: number;
file?: string;
};
export interface Comment {
id: string;
seq: number;
sessionId: string;
postId: string | null;
postTitle: string | null;
author: string;
text: string;
createdAt: string;
// Optional host-authored anchor for comments on a specific rendered surface
// area/line. It is data only: render with text/positioned elements in the
// trusted viewer, never as HTML.
anchor?: CommentAnchor;
}
// An uploaded blob (image, trace file, arbitrary file) the agent pushes once and
// references by id. Stored apart from surfaces so binary never bloats the surfaces
// JSON or the 2 MB post limit. `data` is raw bytes — base64 is an edge-only
// encoding (HTTP/MCP request bodies, JsonFileStore's on-disk JSON).
export type AssetKind = "image" | "trace" | "file";
export interface Asset {
id: string;
sessionId: string;
kind: AssetKind;
contentType: string;
byteLength: number;
filename: string | null;
data: Uint8Array;
createdAt: string;
// Bumped on each serve; drives the reference-aware LRU eviction below.
lastAccessedAt: string;
}
export interface CreateAssetInput {
sessionId: string;
kind: AssetKind;
contentType: string;
filename?: string;
data: Uint8Array;
}
export interface CreateSessionInput {
agent: string;
title?: string;
cwd?: string;
}
export interface CreatePostInput {
sessionId: string;
title?: string;
surfaces: Surface[];
}
export interface UpdatePostInput {
title?: string;
surfaces?: Surface[];
}
export interface CreateCommentInput {
sessionId: string;
postId?: string;
author: string;
text: string;
anchor?: CommentAnchor;
}
export interface CommentQuery {
sessionId?: string;
postId?: string;
afterSeq?: number;
}
// Storage interface — implementations: JsonFileStore (local Node),
// SqlStore (Cloudflare Durable Object SQLite).
export interface Store {
listSessions(): Promise<Session[]>;
getSession(id: string): Promise<Session | null>;
createSession(input: CreateSessionInput): Promise<Session>;
renameSession(id: string, title: string): Promise<Session | null>;
removeSession(id: string): Promise<boolean>;
// Advance the delivered-to-agent comment cursor (never moves backwards).
markAgentSeen(sessionId: string, seq: number): Promise<void>;
// Workspace-level key/value settings (e.g. the selected theme id). Returns null
// for an unset key.
getSetting(key: string): Promise<string | null>;
setSetting(key: string, value: string): Promise<void>;
listPosts(sessionId?: string): Promise<Post[]>;
getPost(id: string): Promise<Post | null>;
createPost(input: CreatePostInput): Promise<Post | null>;
updatePost(id: string, patch: UpdatePostInput): Promise<Post | null>;
removePost(id: string): Promise<boolean>;
listComments(query: CommentQuery): Promise<Comment[]>;
createComment(input: CreateCommentInput): Promise<Comment | null>;
removeComment(id: string): Promise<Comment | null>;
// Session-scoped agent trace: the steps that produced a session's surfaces,
// synced from the transcript. setTrace replaces the whole list (windowed
// syncs re-send the full slice); removeSession cascades it.
listTrace(sessionId: string): Promise<TraceStep[]>;
setTrace(sessionId: string, steps: TraceStep[]): Promise<void>;
// Assets. putAsset evicts to stay under MAX_WORKSPACE_ASSET_BYTES (see
// selectEvictions) and returns null only if the session is missing.
putAsset(input: CreateAssetInput): Promise<Asset | null>;
getAsset(id: string): Promise<Asset | null>;
// Bump lastAccessedAt (called when bytes are served), keeping live assets warm.
touchAsset(id: string): Promise<void>;
listAssets(sessionId: string): Promise<Asset[]>;
removeAsset(id: string): Promise<boolean>;
// Whether any live surface (current or historical version) references this
// asset id. Drives the optimistic-read wait and reference-aware deletion.
isAssetReferenced(id: string): Promise<boolean>;
}
// The slice of a Durable Object's `SqlStorage` that SqlStore actually uses.
// Declared here as a plain interface (rather than leaning on the ambient
// Cloudflare global) so the SAME SqlStore runs on the DO and on Node's
// node:sqlite via a thin adapter, and both the node and workers typecheck
// programs resolve it the same way. A real DO `SqlStorage` is structurally
// assignable to this narrower shape.
export type SqlStorageValue = ArrayBuffer | string | number | null;
export interface SqlStorageCursor {
toArray(): Record<string, SqlStorageValue>[];
one(): Record<string, SqlStorageValue>;
}
export interface SqlStorage {
exec(query: string, ...bindings: SqlStorageValue[]): SqlStorageCursor;
}
// A whole workspace's contents, used to migrate one backend's data into another
// (JSON file → SQLite). Carries every field verbatim — ids, versions, history,
// comment `seq`, `agentSeq`, asset bytes — so identity and the feedback cursor
// survive the copy.
export interface WorkspaceSnapshot {
sessions: Session[];
surfaces: Post[];
comments: Comment[];
traces: { sessionId: string; steps: TraceStep[] }[];
assets: Asset[];
settings: { key: string; value: string }[];
}
export const HISTORY_LIMIT = 20;
// SQLite terminates a TEXT value at the first embedded NUL byte, while the JSON
// store preserves it — so the two stores would diverge on a NUL. A NUL has no
// place in a title/comment/label anyway, so both stores strip it from stored
// text (removing the byte, not truncating), keeping them in lockstep. Returns
// the input untouched when there's nothing to strip, so the common path is free.
const NUL_CHAR = String.fromCharCode(0);
export function stripNul<T extends string | null | undefined>(s: T): T {
// replaceAll with a string (not a RegExp literal) keeps the control char out
// of the source; the includes guard keeps the common no-NUL path free.
return (typeof s === "string" && s.includes(NUL_CHAR) ? s.replaceAll(NUL_CHAR, "") : s) as T;
}
// stripNul applied to a trace step, rebuilding it so absent optional keys stay
// absent (a `{kind: undefined}` key would itself diverge: the JSON store keeps
// it, SqlStore drops it).
export function stripNulStep(s: TraceStep): TraceStep {
const out: TraceStep = { label: stripNul(s.label) };
if (s.kind !== undefined) out.kind = stripNul(s.kind);
if (s.detail !== undefined) out.detail = stripNul(s.detail);
if (s.ts !== undefined) out.ts = s.ts;
return out;
}
// Per-asset upload cap (enforced at the HTTP/MCP edge → 413) and the workspace-wide
// budget the store evicts down to. One Durable Object holds the whole workspace, so
// the budget sits well under its ~10 GB SQLite ceiling.
export const MAX_ASSET_BYTES = 5 * 1024 * 1024;
export const MAX_WORKSPACE_ASSET_BYTES = 2 * 1024 * 1024 * 1024;
// Short, unguessable id: 8 random bytes (64 bits) as 11 url-safe base64 chars —
// YouTube-video-id sized. These double as bearer capabilities: in publicRead
// mode `/s/:id` and `/api/{sessions,surfaces}/:id` are reachable without the
// workspace token, so the id IS the share secret and must resist enumeration. 64
// bits (~1.8e19) is far past sweepable; the old `randomUUID().split("-")[0]`
// kept only the first 32-bit segment (~4e9), brute-forceable in about an hour.
// (Assets use a separate content-hash id, not this.) btoa is a global in both
// Node and Workers, same as the atob the asset path already relies on.
export const newId = () => {
let id = btoa(String.fromCharCode(...crypto.getRandomValues(new Uint8Array(8))))
.replace(/\+/g, "-")
.replace(/\//g, "_")
.replace(/=+$/, "");
// Ids are used as CLI positional args and path segments. A leading "-" or
// "_" makes node:util parseArgs treat them as options ("Unknown option '-6'"
// for an id like "-6K4AJsKD4M"), so swap a leading separator for an
// alphanumeric. Collision risk is negligible (the remaining ~10 chars hold
// ~8e17 possibilities).
if (id[0] === "-" || id[0] === "_") id = "0" + id.slice(1);
return id;
};
// Content-addressed asset id: the lowercase hex SHA-256 of the bytes. Because
// it depends only on the content, an agent can derive `/a/:id` from the bytes
// alone — no upload round-trip — and write the URL into a surface before (or
// while) the upload lands. Identical uploads collapse to one stored blob.
// Uses Web Crypto (a global on Node ≥20 and Workers) to stay runtime-agnostic.
export async function hashAssetId(data: Uint8Array): Promise<string> {
// Copy into a fresh ArrayBuffer-backed view: digest wants a definite
// ArrayBuffer, and this also avoids the SharedArrayBuffer-backed lib type.
const digest = await crypto.subtle.digest("SHA-256", new Uint8Array(data));
return [...new Uint8Array(digest)].map((b) => b.toString(16).padStart(2, "0")).join("");
}
// Assign a stable id to every surface that lacks one, preserving existing ids.
// Called by the stores on create/update so all persisted surfaces are
// addressable. Per-surface flow functions call this after mutating a single
// surface so untouched surfaces keep their ids.
export function normalizeSurfaceIds(surfaces: Surface[]): Surface[] {
return surfaces.map((s) => (s.id ? s : { ...s, id: newId() }));
}
// A snippet is sugar for a single html surface; this bridges the legacy
// `{ html }` shape (CLI `publish`, `POST /api/snippets`) to the surfaces model.
// An optional `kits` list opts the surface into style/behavior bundles (kits.ts).
export const htmlSurface = (html: string, kits?: unknown): HtmlSurface => ({
kind: "html",
html,
...(Array.isArray(kits) && kits.length > 0
? { kits: kits.filter((k) => typeof k === "string") }
: {}),
});
// The combined byte weight of a post's surfaces, for size limits. image/trace
// surfaces are tiny (refs + inline steps) — the asset bytes they point at are
// bounded separately by MAX_ASSET_BYTES, not this post cap.
export function surfacesByteLength(surfaces: Surface[]): number {
let n = 0;
for (const p of surfaces) {
if (p.kind === "html") n += p.html.length;
else if (p.kind === "diff") {
n += p.patch?.length ?? 0;
for (const f of p.files ?? []) n += f.before.length + f.after.length;
} else if (p.kind === "image") {
n += p.assetId.length + (p.alt?.length ?? 0) + (p.caption?.length ?? 0);
} else if (p.kind === "markdown") {
n += p.markdown.length;
} else if (p.kind === "terminal") {
n += p.text.length + (p.title?.length ?? 0);
} else if (p.kind === "mermaid") {
n += p.mermaid.length;
} else if (p.kind === "json") {
n += JSON.stringify(p.data).length;
} else if (p.kind === "code") {
n +=
p.code.length + (p.language?.length ?? 0) + (p.title?.length ?? 0) + (p.lineStart ? 4 : 0);
} else {
n += (p.assetId?.length ?? 0) + (p.title?.length ?? 0);
for (const s of p.steps ?? []) {
n += s.label.length + (s.kind?.length ?? 0) + (s.detail?.length ?? 0);
}
}
}
return n;
}
// Collect the asset ids an ordered surfaces list references (image/trace surfaces).
// Used to keep referenced assets out of eviction's first wave. Note: assets
// embedded by raw URL inside html markup are invisible here — touch-on-serve
// keeps those warm instead.
export function collectAssetIds(surfaces: Surface[], out: Set<string>): void {
for (const p of surfaces) {
if (p.kind === "image") out.add(p.assetId);
else if (p.kind === "trace" && p.assetId) out.add(p.assetId);
}
}
export interface EvictionCandidate {
id: string;
byteLength: number;
lastAccessedAt: string;
referenced: boolean;
}
// Pick the assets to evict so `incomingBytes` fits under `budget`. Oldest
// (lastAccessedAt) first, but unreferenced assets go before referenced ones —
// a live embed is only evicted as a last resort, once unreferenced candidates
// are exhausted. Returns the ids to remove (possibly empty).
export function selectEvictions(
candidates: EvictionCandidate[],
incomingBytes: number,
budget: number,
): string[] {
let total = candidates.reduce((sum, c) => sum + c.byteLength, 0);
if (total + incomingBytes <= budget) return [];
const order = [...candidates].sort((a, b) => {
if (a.referenced !== b.referenced) return a.referenced ? 1 : -1;
return a.lastAccessedAt.localeCompare(b.lastAccessedAt);
});
const evict: string[] = [];
for (const c of order) {
if (total + incomingBytes <= budget) break;
evict.push(c.id);
total -= c.byteLength;
}
return evict;
}