From 4ea98ebcc6d2b60aea8659fbd0869fe7a2a2a72b Mon Sep 17 00:00:00 2001 From: ympang Date: Thu, 10 Sep 2026 13:12:40 +0800 Subject: [PATCH 1/2] feat: add JSONL upload and base path support Signed-off-by: ympang --- README.md | 24 +-- backend/package-lock.json | 20 +++ backend/package.json | 3 +- backend/src/app.js | 1 + backend/src/routes/uploads.js | 138 ++++++++++++++++++ backend/src/services/uploadNaming.js | 24 +++ frontend/index.html | 2 +- frontend/src/App.tsx | 5 + .../src/components/session/SessionList.tsx | 49 ++++++- frontend/src/components/ui/loading.tsx | 3 +- frontend/src/hooks/useIndexStatus.ts | 6 +- frontend/src/hooks/useMessages.ts | 3 +- frontend/src/lib/basePath.ts | 12 ++ frontend/src/services/api.ts | 66 +++++++-- frontend/vite.config.ts | 1 + 15 files changed, 321 insertions(+), 36 deletions(-) create mode 100644 backend/src/routes/uploads.js create mode 100644 backend/src/services/uploadNaming.js create mode 100644 frontend/src/lib/basePath.ts diff --git a/README.md b/README.md index 184e982..a577423 100644 --- a/README.md +++ b/README.md @@ -7,8 +7,8 @@

See what your agents actually did.
A self-hosted viewer for your Claude Code session transcripts — read, search, - replay, and audit every session you have ever run. Your transcripts stay on your - machine; nothing is uploaded anywhere. + replay, and audit every session you have ever run. Your transcripts stay within + your self-hosted deployment; nothing is sent to an external service.

@@ -205,6 +205,9 @@ The smaller things you notice after a day of use: - **Advanced search filters** — narrow a query by session-id prefix, model, or a date window; filter the session list by project or to pinned-only. +- **Upload a transcript** — use the Upload button to add a local `.jsonl` file to + the viewer; it is indexed and opened automatically, while previous uploads stay + available in the `uploads` project. - **Copy the resume command** — one button in the info panel yields the exact `claude --resume` invocation for the session, ready to paste into a terminal. - **Rich tool rendering** — TodoWrite checklists, AskUserQuestion choices, and Bash @@ -241,9 +244,9 @@ picture from the transcripts already on your disk: ## Non-goals -- **Not an editor.** It never rewrites transcript content. The only write path to your - transcripts is the explicit, guarded session delete described in - [Security and your data](#security-and-your-data). +- **Not an editor.** It never rewrites transcript content. Transcript changes are + limited to explicitly uploading a new file or using the guarded session delete + described in [Security and your data](#security-and-your-data). - **Not a cloud service.** No accounts, no telemetry, no external network calls. - **Not a Claude Code replacement.** It reads the transcripts Claude Code already writes to disk. @@ -389,6 +392,7 @@ Advanced overrides (for running from source or remote self-hosting): | `PUID` / `PGID` | *(auto)* | Linux only: the uid/gid the container runs as. Auto-detected from the mounted directory's owner; set explicitly only to override that (e.g. `PUID=0` under an unusual rootless setup). | | `RECENSA_ALLOWED_HOSTS` | *(none)* | Extra comma-separated hostnames added to the Host/Origin allowlist. Needed behind a reverse proxy or a custom hosts-file name (see [Security](#security-and-your-data)). | | `RECENSA_WATCH_MODE` | *(auto)* | Watch strategy: `events` (native fs events) or `tick` (polling). Auto-selects `tick` inside Docker (where bind-mount fs events are unreliable) or when `RECENSA_PROJECTS_DIR` is set. | +| `RECENSA_MAX_UPLOAD_BYTES` | `268435456` | Maximum size of a browser-uploaded `.jsonl` transcript (256 MiB). | ## Security and your data @@ -416,11 +420,11 @@ Your transcripts: - **Reading never writes.** The indexer, parser, and reconstruction paths only read your JSONL. -- **One write path exists: deleting a session.** `DELETE /api/session/:id` physically - removes the session's JSONL file and its subagents directory from disk. This is - irreversible. It is guarded: pinned sessions, sessions that belong to a fork/resume - chain, and individual subagents are all refused. Nothing else writes to your - transcripts. +- **Explicit transcript writes are limited.** Uploading a `.jsonl` file stores it under + the `uploads` project and indexes it. `DELETE /api/session/:id` physically removes + an uploaded or source session's JSONL file and its subagents directory from disk. + This is irreversible. Deletes are guarded: pinned sessions, sessions that belong to + a fork/resume chain, and individual subagents are all refused. - **Your curation is safe.** Pins, tags, notes, and titles live in a separate `flags.db` that index rebuilds never touch. The `viewer.db` index is a disposable cache. diff --git a/backend/package-lock.json b/backend/package-lock.json index 2ba14ce..eb39f11 100644 --- a/backend/package-lock.json +++ b/backend/package-lock.json @@ -10,6 +10,7 @@ "license": "MIT", "dependencies": { "@recensa/claude-session": "^0.1.2", + "busboy": "1.6.0", "chokidar": "^4.0.3", "express": "^4.21.2", "ws": "^8.18.0" @@ -371,6 +372,17 @@ "node": "18 || 20 || >=22" } }, + "node_modules/busboy": { + "version": "1.6.0", + "resolved": "https://registry.npmjs.org/busboy/-/busboy-1.6.0.tgz", + "integrity": "sha512-8SFQbg/0hQ9xy3UNTB0YEnsNBbWfhf7RtnzpL7TkBiTBRfrQ9Fxcnz7VJsleJpyp6rVLvXiuORqjlHi5q+PYuA==", + "dependencies": { + "streamsearch": "^1.1.0" + }, + "engines": { + "node": ">=10.16.0" + } + }, "node_modules/bytes": { "version": "3.1.2", "resolved": "https://registry.npmjs.org/bytes/-/bytes-3.1.2.tgz", @@ -1703,6 +1715,14 @@ "node": ">= 0.8" } }, + "node_modules/streamsearch": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/streamsearch/-/streamsearch-1.1.0.tgz", + "integrity": "sha512-Mcc5wHehp9aXz1ax6bZUyY5afg9u2rv5cqQI3mRrYkGC8rW2hM02jWuwjtL++LS5qinSyhj2QfLyNsuc+VsExg==", + "engines": { + "node": ">=10.0.0" + } + }, "node_modules/toidentifier": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/toidentifier/-/toidentifier-1.0.1.tgz", diff --git a/backend/package.json b/backend/package.json index 9a2759e..614b001 100644 --- a/backend/package.json +++ b/backend/package.json @@ -18,9 +18,10 @@ "check": "npm run type-check && npm run lint && npm test" }, "dependencies": { + "@recensa/claude-session": "^0.1.2", + "busboy": "1.6.0", "chokidar": "^4.0.3", "express": "^4.21.2", - "@recensa/claude-session": "^0.1.2", "ws": "^8.18.0" }, "devDependencies": { diff --git a/backend/src/app.js b/backend/src/app.js index 567b55c..54ab16f 100644 --- a/backend/src/app.js +++ b/backend/src/app.js @@ -63,6 +63,7 @@ function createApp() { app.use(require('./routes/sessions')); app.use(require('./routes/flags')); app.use(require('./routes/search')); + app.use(require('./routes/uploads')); // Serve the theme-injected index.html (must intercept '/' before the static handler) app.get(['/', '/index.html'], (_req, res, next) => { try { diff --git a/backend/src/routes/uploads.js b/backend/src/routes/uploads.js new file mode 100644 index 0000000..4cf6d8d --- /dev/null +++ b/backend/src/routes/uploads.js @@ -0,0 +1,138 @@ +'use strict'; + +const fs = require('node:fs'); +const fsp = require('node:fs/promises'); +const path = require('node:path'); +const { randomUUID } = require('node:crypto'); +const { pipeline } = require('node:stream/promises'); +const Busboy = require('busboy'); +const { PROJECTS_DIR } = require('../config'); +const indexService = require('../services/indexService'); +const repo = require('../repositories/sessionRepo'); +const { makeStoredFilename } = require('../services/uploadNaming'); +const log = require('../log').scope('uploads'); + +const UPLOAD_PROJECT = 'uploads'; +const UPLOAD_DIR = path.join(PROJECTS_DIR, UPLOAD_PROJECT); +const MAX_UPLOAD_BYTES = Number(process.env.RECENSA_MAX_UPLOAD_BYTES || 256 * 1024 * 1024); + +class UploadError extends Error { + constructor(status, message) { + super(message); + this.status = status; + } +} + +function waitForIndexed(sessionId, timeoutMs = 30_000) { + return new Promise((resolve) => { + const deadline = Date.now() + timeoutMs; + const check = () => { + try { + if (repo.getSession(sessionId)) return resolve(true); + } catch { + // The worker may be committing the row; keep polling until the deadline. + } + if (Date.now() >= deadline) return resolve(false); + setTimeout(check, 100); + }; + check(); + }); +} + +async function removeIfPresent(filePath) { + if (!filePath) return; + try { await fsp.rm(filePath, { force: true }); } catch { /* best-effort cleanup */ } +} + +function parseUpload(req) { + return new Promise((resolve, reject) => { + let parser; + try { + parser = Busboy({ + headers: req.headers, + limits: { files: 1, fileSize: MAX_UPLOAD_BYTES }, + }); + } catch { + reject(new UploadError(400, 'Expected a multipart file upload')); + return; + } + + let fileSeen = false; + let parseError = null; + let writeTask = null; + let result = null; + + const fail = (err) => { if (!parseError) parseError = err; }; + + parser.on('file', (fieldName, file, info) => { + if (fieldName !== 'file' || fileSeen) { + file.resume(); + fail(new UploadError(400, 'Upload exactly one file in the file field')); + return; + } + fileSeen = true; + + let storedFilename; + try { + storedFilename = makeStoredFilename(info.filename, randomUUID()); + } catch (err) { + file.resume(); + fail(new UploadError(400, err.message)); + return; + } + + const temporaryPath = path.join(UPLOAD_DIR, `.upload-${randomUUID()}.part`); + const destination = path.join(UPLOAD_DIR, storedFilename); + const output = fs.createWriteStream(temporaryPath, { flags: 'wx', mode: 0o600 }); + writeTask = pipeline(file, output).then(async () => { + if (file.truncated) throw new UploadError(413, 'File is too large (maximum 256 MB)'); + await fsp.rename(temporaryPath, destination); + result = { + destination, + sessionId: storedFilename.slice(0, -'.jsonl'.length), + originalFilename: path.basename(String(info.filename || storedFilename)), + }; + }).catch(async (err) => { + await removeIfPresent(temporaryPath); + throw err; + }); + }); + + parser.on('filesLimit', () => fail(new UploadError(400, 'Upload exactly one file'))); + parser.on('error', reject); + parser.on('finish', async () => { + try { + if (writeTask) await writeTask; + if (parseError) throw parseError; + if (!result) throw new UploadError(400, 'Choose a .jsonl file'); + resolve(result); + } catch (err) { + await removeIfPresent(result?.destination); + reject(err); + } + }); + + req.pipe(parser); + }); +} + +async function handleUpload(req, res) { + try { + await fsp.mkdir(UPLOAD_DIR, { recursive: true }); + const uploaded = await parseUpload(req); + indexService.ingestPath(uploaded.destination); + const indexed = await waitForIndexed(uploaded.sessionId); + res.status(indexed ? 201 : 202).json({ + sessionId: uploaded.sessionId, + filename: uploaded.originalFilename, + project: UPLOAD_PROJECT, + indexed, + }); + } catch (err) { + const status = err instanceof UploadError ? err.status : 500; + if (status >= 500) log.error(`upload failed: ${err?.message || err}`); + res.status(status).json({ error: status >= 500 ? 'Upload failed' : err.message }); + } +} + +module.exports = require('express').Router().post('/api/upload', handleUpload); diff --git a/backend/src/services/uploadNaming.js b/backend/src/services/uploadNaming.js new file mode 100644 index 0000000..f71d519 --- /dev/null +++ b/backend/src/services/uploadNaming.js @@ -0,0 +1,24 @@ +'use strict'; + +const path = require('node:path'); + +function normalizeUploadStem(originalName) { + const baseName = path.basename(String(originalName || '')); + if (path.extname(baseName).toLowerCase() !== '.jsonl') { + throw new Error('Only .jsonl files can be uploaded'); + } + const stem = baseName.slice(0, -'.jsonl'.length) + .normalize('NFKC') + .replace(/[^a-zA-Z0-9._-]+/g, '-') + .replace(/^-+|-+$/g, '') + .slice(0, 80); + return stem || 'session'; +} + +function makeStoredFilename(originalName, suffix) { + const token = String(suffix || '').replace(/[^a-zA-Z0-9_-]/g, ''); + if (!token) throw new Error('Upload id is required'); + return `${normalizeUploadStem(originalName)}-${token}.jsonl`; +} + +module.exports = { normalizeUploadStem, makeStoredFilename }; diff --git a/frontend/index.html b/frontend/index.html index 081b34d..3e5a9d5 100644 --- a/frontend/index.html +++ b/frontend/index.html @@ -4,7 +4,7 @@ Recensa - +