From eefe1627ddc0df7cfbd66d9d08c07a863d004b42 Mon Sep 17 00:00:00 2001 From: minhn4 Date: Mon, 24 Aug 2026 14:57:18 +0700 Subject: [PATCH] feat(desktop): upload work (sandbox) sessions MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Work sessions run a full server inside each sandbox guest (VZ VM / WSL / bwrap) against a guest-private opencode.db: the host cannot read the disk (opaque VM image on macOS), and the guest holds only broker-placeholder credentials, so it cannot upload itself. The authenticated server API the desktop already uses to render those sessions is the one uniform way in. - @codev/upload gains a second session source (server-source.ts): the v1 HTTP API — GET /experimental/session (paged, archived included, children listed for the subagent rollup) + GET /session/:id/message (SessionV1.WithParts) — whose payloads are the same objects the sqlite data columns store, rendered through the shared part renderer extracted into parts.ts, so both sources produce one format. Sessions are grouped under the sandbox's HOST project dir. Per-source failures are recorded and non-fatal; runExport is now async and takes servers. - Sidecar: the upload command carries ServerSource[] and runs are chained on an in-process promise — the hub-shared lockfile makes a concurrent run skip, not queue, so an unchained sandbox run posted right after startup's local run would silently drop its work sessions. - Desktop main: sandboxUploadSources() maps every ready sandbox (runtime url/credentials + config.projectDir); a sandboxes.subscribe diff uploads whenever a sandbox newly reaches ready (its whole history becomes readable then — covers app start and every later start), with a post-initialize catch-up; onConfigured now ships work sessions too. A stopped sandbox's sessions are unreachable until it next starts — delayed, never lost: the sweep at every ready reads the guest's full history. Co-Authored-By: Claude Fable 5 --- CHANGELOG.md | 8 +- DIVERGENCES.md | 2 +- packages/codev-upload/src/daemon.ts | 5 +- packages/codev-upload/src/db.ts | 179 +---------------- packages/codev-upload/src/export.ts | 53 +++-- packages/codev-upload/src/index.ts | 1 + packages/codev-upload/src/parts.ts | 178 +++++++++++++++++ packages/codev-upload/src/server-source.ts | 186 ++++++++++++++++++ packages/codev-upload/src/upload.ts | 6 +- packages/codev-upload/test/export.test.ts | 20 +- .../codev-upload/test/server-source.test.ts | 128 ++++++++++++ packages/desktop/src/main/index.ts | 51 ++++- packages/desktop/src/main/server.ts | 9 +- packages/desktop/src/main/sidecar.ts | 31 +-- 14 files changed, 639 insertions(+), 218 deletions(-) create mode 100644 packages/codev-upload/src/parts.ts create mode 100644 packages/codev-upload/src/server-source.ts create mode 100644 packages/codev-upload/test/server-source.test.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index fa21e0f4e0..2e60fe6aff 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -179,8 +179,12 @@ file is the human-readable summary of what changed and why. immediately, and when uploads stall because the CoDev sign-in expired, the desktop shows a system notification at startup — its version of the terminal app's "Session upload is paused" warning. Work (sandbox) sessions - are not covered yet; they live in each sandbox's own database and get their - own follow-up. + are covered too: each sandbox keeps its sessions in its own guest database + that the host cannot read as a file, so whenever a sandbox comes up, the + desktop reads its sessions over the sandbox's own authenticated API and + uploads them like any others — filed under the project folder the sandbox + belongs to, alongside that project's local sessions. A sandbox that is off + simply uploads on its next start; nothing is lost in between. ([#313](https://github.com/quickbeard/codev-code/pull/313)) - **A dropped model connection retries instead of silently stopping the agent** — when the connection to the model died mid-response (corporate proxies are fond diff --git a/DIVERGENCES.md b/DIVERGENCES.md index 981bba7be2..088d8cbfbc 100644 --- a/DIVERGENCES.md +++ b/DIVERGENCES.md @@ -95,7 +95,7 @@ from the squash message first, or use a plain merge. | `package.json` (root) | `name` `opencode` → `codev-code`; `repository.url` → `github.com/quickbeard/codev-code`; added `release` script delegating to packages/opencode; added `bump` script pointing at the fork-only `script/bump-version.ts`; removed the `sso`, `dev:stats`, and `random` scripts; added the `lint-staged` devDependency and a `lint-staged` config block (`"*": "prettier --write --ignore-unknown"`) driving the fork-only `.husky/pre-commit` | The root manifest carries the fork's name (nothing reads it — root is private and unpublished; `packages/script` reads only `packageManager` — so this is cosmetic and also removes the name collision with the `packages/opencode` workspace member, whose name MUST stay `opencode`: build.ts derives bun compile targets and dist dir names from it). `bun run release` and `bun run bump` work from the repo root. `sso` logs into upstream's AWS org (`--sso-session=opencode`) and `dev:stats` runs inside an sst shell against upstream's production stage — neither works without anomalyco credentials; `random` is inert cruft (`echo 'Random script'`). `lint-staged` keeps staged files at Prettier's fixed point on commit, so the formatting drift PR #261 had to clean up cannot silently reaccumulate; `--ignore-unknown` skips files Prettier has no parser for (`bun.lock`, `.husky/*`, binaries) and `.prettierignore` still exempts `DIVERGENCES.md` and the docs merge base. | Keep our name + release/bump lines and the removals (drop any new upstream-infra-only scripts too); take upstream's other fields. | | `packages/opencode/package.json` | bin key `opencode` → `codev` (shim path `./bin/opencode` unchanged); added `release` script; added dependencies `"@codev/brand": "workspace:*"`, `"@codev/gateway": "workspace:*"` (the latter for the gateway-key migration's shared-auth-file read, see the config.ts migration row), and `"@codev/upload": "workspace:*"` (the session uploader — see the session-upload wiring row) | Installed command is `codev` — matches the CLI's `scriptName` and help text (the hub's command is `codevhub`; it relinquished the `codev` bin in codev-ai ≥ 0.4.0). `bun run release` publishes the version in this file (upstream-synced; script/release.ts is fork-only). | Keep our bin key, release line, and dependency lines; take upstream's other fields including `version`. | | Session-upload wiring: `packages/opencode/src/cli/cmd/tui.ts`, `packages/opencode/src/cli/cmd/run.ts`, `packages/opencode/src/cli/cmd/debug/index.ts` | tui.ts + run.ts: `spawnUploadDaemon()` (from the fork-only `@codev/upload` package) fire-and-forgotten at handler start — spawns a detached, unref'd, stdio-ignored `codev debug upload` child. debug/index.ts: registered the fork-only `UploadCommand` (debug/upload.ts, new file), the entry that child runs. | Standalone parity with the hub's launch-time upload daemon: every agent launch ships prior codev sessions to the analysis backend in the background. All gating (opt-out env `CODEV_DISABLE_AUTO_UPLOAD`, signed-in check, the hub-shared lockfile) lives inside `@codev/upload` — the wiring is one guarded call per entry point that can never block or fail the launch. | Keep the import + `spawnUploadDaemon()` line in both handlers and the debug registration; take upstream's surrounding changes. | -| Desktop session upload: `packages/desktop/src/main/sidecar.ts`, `packages/desktop/src/main/server.ts`, `packages/desktop/src/main/index.ts`, `packages/desktop/package.json` | sidecar.ts: after `Server.listen` succeeds, fire `runStartupUpload()` (from the fork-only `@codev/upload`, statically imported so electron-vite bundles it), and handle a new `{type: "upload"}` sidecar command that re-runs it. server.ts: `SidecarListener` gains `upload()` (posts that command to the child; no-op once exited). index.ts: the gateway-sso `onConfigured` hook also calls `server?.upload()` so a fresh sign-in ships the backlog immediately, and startup shows an OS `Notification` ("Session upload is paused") when `@codev/upload/nudge`'s `shouldNudgeSignIn()` reports the stall — the desktop counterpart of the TUI toast, every launch until sign-in. package.json: `"@codev/upload": "workspace:*"` under devDependencies — deliberately, like `@codev/gateway`: electron-vite externalizes `dependencies` into the (unshipped) runtime node_modules, and only devDependency workspace packages get bundled. | The desktop runs the server in-process under Electron's Node with no `codev` CLI in the bundle, so the CLI's detached `codev debug upload` spawn cannot cover it; running the uploader inside the sidecar utility process (never Electron main, which is UI-latency sensitive) reuses the whole pipeline — same store (the sidecar only overrides `XDG_STATE_HOME`), same shared auth file, same hub-shared lockfile, so desktop, CLI, and hub can never upload concurrently. The `#sqlite` bun/node conditional import inside `@codev/upload` (same pattern as the server core's `#db`) is what lets the identical package run on `bun:sqlite` in the CLI binary and `node:sqlite` in the sidecar. | Keep the sidecar `upload` command + startup trigger, the listener's `upload()`, the `onConfigured` call + startup Notification, and the devDependency placement; take upstream's surrounding changes. | +| Desktop session upload: `packages/desktop/src/main/sidecar.ts`, `packages/desktop/src/main/server.ts`, `packages/desktop/src/main/index.ts`, `packages/desktop/package.json` | sidecar.ts: after `Server.listen` succeeds, fire `runStartupUpload()` (from the fork-only `@codev/upload`, statically imported so electron-vite bundles it), and handle a `{type: "upload", servers?}` sidecar command that re-runs it — runs are chained on an in-process promise (the hub-shared lockfile makes a concurrent run skip, not queue, so an unchained sandbox run posted right after startup's local run would silently drop its work sessions). server.ts: `SidecarListener` gains `upload(servers?)` (posts that command with structured-clone-safe `ServerSource` records; no-op once exited). index.ts: `sandboxUploadSources()` maps every `ready` sandbox to a source (`runtime.url`/`username`/`password` + `config.projectDir`, the HOST dir, so a project's local and sandboxed sessions land in one analysis bucket); a `sandboxes.subscribe` diff fires `server?.upload(sources)` whenever a sandbox newly reaches ready (its whole history becomes readable at that moment), with a post-`initialize` catch-up for sandboxes ready before the sidecar listened; the gateway-sso `onConfigured` hook also calls `server?.upload(sandboxUploadSources())` so a fresh sign-in ships the backlog (work sessions included) immediately, and startup shows an OS `Notification` ("Session upload is paused") when `@codev/upload/nudge`'s `shouldNudgeSignIn()` reports the stall — the desktop counterpart of the TUI toast, every launch until sign-in. package.json: `"@codev/upload": "workspace:*"` under devDependencies — deliberately, like `@codev/gateway`: electron-vite externalizes `dependencies` into the (unshipped) runtime node_modules, and only devDependency workspace packages get bundled. | The desktop runs the server in-process under Electron's Node with no `codev` CLI in the bundle, so the CLI's detached `codev debug upload` spawn cannot cover it; running the uploader inside the sidecar utility process (never Electron main, which is UI-latency sensitive) reuses the whole pipeline — same store (the sidecar only overrides `XDG_STATE_HOME`), same shared auth file, same hub-shared lockfile, so desktop, CLI, and hub can never upload concurrently. The `#sqlite` bun/node conditional import inside `@codev/upload` (same pattern as the server core's `#db`) is what lets the identical package run on `bun:sqlite` in the CLI binary and `node:sqlite` in the sidecar. | Keep the sidecar `upload` command + startup trigger, the listener's `upload()`, the `onConfigured` call + startup Notification, and the devDependency placement; take upstream's surrounding changes. | | `bun.lock` | Mirrors the bin key rename above, the fork-only workspace packages/deps (`@codev/gateway`, `@codev/brand`, `@codev/upload`), and the fork-only `lint-staged` devDependency | Lockfile records workspace bin mappings and workspace deps. Known quirk: the root workspace stanza still says `"name": "opencode"` — bun never syncs the root manifest's name into an existing lock (verified: neither `bun install` nor `--lockfile-only` rewrites it); it self-corrects only on a from-scratch regeneration and is harmless meanwhile. Don't hand-edit it. | Regenerate with `bun install`; never hand-merge. | | `.prettierignore` | Appended `DIVERGENCES.md` and `packages/web/src/content/docs/` (each with a comment saying why) | Prettier realigns every row of this file's changed-files table, so a one-row edit churns all ~120 rows — noise in the one file that records upstream-conflict guidance, and a guaranteed conflict on the next sync. Nothing in the repo formatted it before (the pre-push hook only runs `bun typecheck`), so this pins the status quo rather than changing it. The docs directory is the upstream merge base (see `packages/web/FORK-DOCS.md`): nothing builds it, its whole job is to surface upstream doc diffs during a sync, and formatting it only adds conflict surface — plus Prettier's MDX printer mangles `index.mdx`, collapsing the fenced code blocks inside `` into inline text. | Keep our lines; take upstream's other entries. | | `packages/tui/package.json` | Added dependencies `"@codev/gateway": "workspace:*"`, `"@codev/brand": "workspace:*"`, and `"@codev/upload": "workspace:*"` (the latter's `/nudge` + `/spawn` subpaths only — sqlite-free modules for the upload sign-in toast and the post-configure daemon respawn) | The fork's gateway configure dialog imports SSO/backend/config-block logic from the fork-only `packages/codev-gateway`; the "Open docs" command takes its URL from `DOCS_URL` in the fork-only `packages/codev-brand` instead of hardcoding the host. | Keep our dependency lines; take upstream's other fields. | diff --git a/packages/codev-upload/src/daemon.ts b/packages/codev-upload/src/daemon.ts index b1b2de4e27..0d70cf4b02 100644 --- a/packages/codev-upload/src/daemon.ts +++ b/packages/codev-upload/src/daemon.ts @@ -4,6 +4,7 @@ import { silentSso } from "@codev/gateway" import { logInfo, logWarn } from "./log" import { autoUploadDisabled, type UploadStatus } from "./nudge" import { lastUploadStatusPath, uploadLockPath } from "./paths" +import type { ServerSource } from "./server-source" import { runUpload, type UploadSummary } from "./upload" // Background-upload runner: `codev debug upload` executes runStartupUpload; @@ -82,7 +83,7 @@ function writeStatusFile(status: UploadStatus): void { // The daemon entry (`codev debug upload`). Gates in order: opt-out env, // signed-in (silent SSO — refreshes an expired session, never opens a // browser), then the shared lock. Returns the process exit code. -export async function runStartupUpload(): Promise { +export async function runStartupUpload(options: { servers?: ServerSource[] } = {}): Promise { const startedAt = new Date().toISOString() if (autoUploadDisabled()) { logInfo("auto-upload skipped: CODEV_DISABLE_AUTO_UPLOAD is set", { extra: { reason: "disabled" } }) @@ -111,7 +112,7 @@ export async function runStartupUpload(): Promise { } try { logInfo("starting auto-upload") - const summary = await runUpload({ getAuth: async () => auth }) + const summary = await runUpload({ getAuth: async () => auth, servers: options.servers }) writeStatus(startedAt, summary) logInfo(`auto-upload done: uploaded=${summary.uploaded} skipped=${summary.skipped} failed=${summary.failed}`, { outcome: summary.failed > 0 ? "failure" : "success", diff --git a/packages/codev-upload/src/db.ts b/packages/codev-upload/src/db.ts index 3aa9d67889..3530133101 100644 --- a/packages/codev-upload/src/db.ts +++ b/packages/codev-upload/src/db.ts @@ -1,8 +1,7 @@ import { existsSync } from "node:fs" +import { partToMarkdown, safeParse, unixToISO } from "./parts" import { openSessionDb, type SessionDb } from "#sqlite" -import { diffFromEditInput, diffFromWriteContent } from "./diff" import { sessionDbPath } from "./paths" -import { codeFence } from "./render" import type { Message, Session } from "./types" // Session-store reader: a port of the hub's OpenCode provider @@ -31,175 +30,6 @@ interface PartRow { data: string } -function safeParse(raw: string): T | null { - try { - return JSON.parse(raw) as T - } catch { - return null - } -} - -function unixToISO(ms: number): string { - if (!Number.isFinite(ms) || ms <= 0) return "" - // Timestamps may be seconds or milliseconds; treat large values as ms. - const epochMs = ms > 1e12 ? ms : ms * 1000 - return new Date(epochMs).toISOString() -} - -function formatDuration(ms: number): string { - if (ms >= 1000) { - return `${(ms / 1000).toFixed(1).replace(/\.0$/, "")}s` - } - return `${ms}ms` -} - -interface ReasoningPart { - type: "reasoning" - text: string - time?: { start: number; end: number } -} - -interface ToolPart { - type: "tool" - tool: string - state: { - status: "completed" | "error" - input: Record - output?: string - error?: string - metadata?: { - diff?: string - filediff?: { patch: string } - } - } -} - -interface TextPart { - type: "text" - text: string -} - -function parsePart(raw: string): string | null { - const obj = safeParse<{ type?: string }>(raw) - if (!obj) return null - - if (obj.type === "text") { - const textPart = obj as TextPart - return typeof textPart.text === "string" ? textPart.text : null - } - - if (obj.type === "reasoning") { - const reasonPart = obj as ReasoningPart - if (typeof reasonPart.text !== "string" || !reasonPart.text.trim()) return null - - let durationStr = "" - if (reasonPart.time && typeof reasonPart.time.start === "number" && typeof reasonPart.time.end === "number") { - const diff = reasonPart.time.end - reasonPart.time.start - if (diff >= 0) { - durationStr = ` for ${formatDuration(diff)}` - } - } - return `
Thought${durationStr}\n\n${reasonPart.text.trim()}\n
\n` - } - - if (obj.type === "tool") { - const toolPart = obj as ToolPart - const toolName = toolPart.tool - const state = toolPart.state || {} - const isCompleted = state.status === "completed" - const input = state.input || {} - const output = state.output || "" - const error = state.error || "" - - if (toolName === "bash") { - const command = typeof input.command === "string" ? input.command : "" - const desc = typeof input.description === "string" ? input.description : "" - const summary = `Run command: ${command}` - const descSection = desc ? `Description: ${desc}\n\n` : "" - const body = `${descSection}${codeFence( - `$ ${command}\n${isCompleted ? output : `Error: ${error || "Tool execution aborted"}`}`, - "bash", - )}` - return `\n
\n${summary}\n\n${body}\n
\n
\n` - } - - if (toolName === "read") { - const path = typeof input.filePath === "string" ? input.filePath : "" - const summary = `Read file: ${path}` - const body = isCompleted ? output : `Error: ${error || "Tool execution aborted"}` - return `\n
\n${summary}\n\n${body}\n
\n
\n` - } - - if (toolName === "glob") { - const pattern = typeof input.pattern === "string" ? input.pattern : "" - const summary = `Glob files: ${pattern}` - const body = codeFence( - `Pattern: ${pattern}\nMatches:\n${isCompleted ? output : `Error: ${error || "Tool execution aborted"}`}`, - ) - return `\n
\n${summary}\n\n${body}\n
\n
\n` - } - - if (toolName === "grep") { - const pattern = typeof input.pattern === "string" ? input.pattern : "" - const path = typeof input.path === "string" ? input.path : "" - const summary = `Grep search: ${pattern}` - const body = codeFence( - `Pattern: ${pattern}\nPath: ${path}\nMatches:\n${isCompleted ? output : `Error: ${error || "Tool execution aborted"}`}`, - ) - return `\n
\n${summary}\n\n${body}\n
\n
\n` - } - - if (toolName === "write") { - const path = typeof input.filePath === "string" ? input.filePath : "" - const content = typeof input.content === "string" ? input.content : "" - const summary = `Edit file: ${path}` - // All-additions diff so the LOC enricher counts every line (hub parity). - const diff = diffFromWriteContent(content) - let body: string - if (diff) { - body = `${codeFence(diff, "diff")}${isCompleted ? "" : `\n\nError: ${error || "Tool execution aborted"}`}` - } else if (isCompleted) { - body = codeFence(content) - } else { - body = `Error: ${error || "Tool execution aborted"}` - } - return `\n
\n${summary}\n\n${body}\n
\n
\n` - } - - if (toolName === "edit") { - const path = typeof input.filePath === "string" ? input.filePath : "" - const summary = `Edit file: ${path}` - // Reconstruct the diff when no server-rendered metadata was attached — - // otherwise completed edits underreport accepted LOC (hub parity). - const diff = state.metadata?.diff || state.metadata?.filediff?.patch || diffFromEditInput(input) - let body: string - if (diff) { - body = `${codeFence(diff, "diff")}${isCompleted ? "" : `\n\nError: ${error || "Tool execution aborted"}`}` - } else if (isCompleted) { - body = "Edit applied successfully." - } else { - body = `Error: ${error || "Tool execution aborted"}` - } - return `\n
\n${summary}\n\n${body}\n
\n
\n` - } - - if (toolName === "task") { - const desc = typeof input.description === "string" ? input.description : "" - const prompt = typeof input.prompt === "string" ? input.prompt : "" - const subagent = typeof input.subagent_type === "string" ? input.subagent_type : "" - const summary = `Spawn subagent: ${desc || subagent}` - const body = `**Subagent Type**: ${subagent}\n**Prompt**:\n${prompt}\n\n**Result**:\n${isCompleted ? output : `Error: ${error || "Subagent execution aborted"}`}` - return `\n
\n${summary}\n\n${body}\n
\n
\n` - } - - const summary = `Call tool: ${toolName}` - const body = codeFence(JSON.stringify(state, null, 2), "json") - return `\n
\n${summary}\n\n${body}\n
\n
\n` - } - - return null -} - interface MessageMeta { role?: string modelID?: string @@ -343,3 +173,10 @@ export function readAllSessions(dbPath = sessionDbPath()): Session[] { db.close() } } + +// Rows store the part JSON as text; parse then render via the shared renderer. +function parsePart(raw: string): string | null { + const obj = safeParse<{ type?: string }>(raw) + if (!obj) return null + return partToMarkdown(obj) +} diff --git a/packages/codev-upload/src/export.ts b/packages/codev-upload/src/export.ts index 7644587767..8b4af677e0 100644 --- a/packages/codev-upload/src/export.ts +++ b/packages/codev-upload/src/export.ts @@ -5,7 +5,9 @@ import { readAllSessions } from "./db" import { logDebug, logInfo, logWarn } from "./log" import { agentLogsDir, buildFilename, projectLogsDir, sessionDbPath } from "./paths" import { renderMarkdown } from "./render" +import { readSessionsFromServer, type ServerSource } from "./server-source" import { computeSessionStatistics, StatisticsCollector } from "./statistics" +import type { Session } from "./types" export interface ExportSummary { outDir: string @@ -13,34 +15,61 @@ export interface ExportSummary { projects: number // Where the exporter looked, for the "found nothing" explanation. dbPath: string + // One entry per server source that could not be read (a sandbox that went + // down mid-run) — its sessions ship on the next run; the rest of the export + // proceeded. + serverErrors: { url: string; message: string }[] } export type StatusReporter = (message: string) => void -// Exports every top-level session in the store into the hub's per-project -// layout: //codev-code/-.md, plus a -// statistics.json per project dir. Unlike the hub (which exports only the -// launch cwd's project), the standalone uploader owns the whole store. -export function runExport(onStatus: StatusReporter = () => {}): ExportSummary { +// Exports every top-level session into the hub's per-project layout: +// //codev-code/-.md, plus a +// statistics.json per project dir. Two sources feed one write path: the local +// sqlite store (always), and any running sandbox servers the caller passes +// (the desktop's work sessions — see server-source.ts). Unlike the hub (which +// exports only the launch cwd's project), the standalone uploader owns the +// whole store. +export async function runExport( + onStatus: StatusReporter = () => {}, + servers: ServerSource[] = [], + // Test seam for the sandbox reads (codev-gateway FetchOptions convention). + fetchImpl: typeof fetch = fetch, +): Promise { const dbPath = sessionDbPath() - const summary: ExportSummary = { outDir: agentLogsDir(), exported: 0, projects: 0, dbPath } + const summary: ExportSummary = { outDir: agentLogsDir(), exported: 0, projects: 0, dbPath, serverErrors: [] } onStatus("Reading codev sessions...") - let sessions: ReturnType + const sessions: Session[] = [] try { - sessions = readAllSessions(dbPath) + sessions.push(...readAllSessions(dbPath)) } catch (err) { - // A store we can't read exports nothing this run; the next run retries. + // A store we can't read exports nothing from it this run; the next run + // retries. Sandbox sources below still export. logWarn("session store read failed", { err, extra: { db_path: dbPath } }) - return summary } + + for (const server of servers) { + onStatus("Reading work sessions...") + try { + const found = await readSessionsFromServer(server, fetchImpl) + sessions.push(...found) + logDebug(`read ${found.length} session(s) from sandbox server`, { extra: { count: found.length } }) + } catch (err) { + // Best-effort per sandbox: one unreachable guest must not cost the + // local sessions (or the other sandboxes) their upload. + summary.serverErrors.push({ url: server.url, message: err instanceof Error ? err.message : String(err) }) + logWarn("sandbox server read failed", { err }) + } + } + if (sessions.length === 0) { logDebug("no sessions to export", { extra: { db_path: dbPath } }) return summary } const baseUrl = readCodevCodeBaseUrl() - const byProject = new Map() + const byProject = new Map() for (const session of sessions) { const dir = projectLogsDir(session.projectDir) const group = byProject.get(dir) @@ -66,7 +95,7 @@ export function runExport(onStatus: StatusReporter = () => {}): ExportSummary { logInfo(`exported ${summary.exported} session(s) across ${summary.projects} project(s)`, { outcome: "success", - extra: { exported: summary.exported, projects: summary.projects }, + extra: { exported: summary.exported, projects: summary.projects, server_errors: summary.serverErrors.length }, }) return summary } diff --git a/packages/codev-upload/src/index.ts b/packages/codev-upload/src/index.ts index fa8a9620b9..68674cb72b 100644 --- a/packages/codev-upload/src/index.ts +++ b/packages/codev-upload/src/index.ts @@ -2,5 +2,6 @@ export { runStartupUpload } from "./daemon" export { runExport, type ExportSummary } from "./export" export { renderLastRun } from "./logs" export { autoUploadDisabled, readLastUploadStatus, shouldNudgeSignIn, type UploadStatus } from "./nudge" +export { readSessionsFromServer, type ServerSource } from "./server-source" export { spawnUploadDaemon } from "./spawn" export { isRefreshableError, runUpload, type UploadOptions, type UploadSummary } from "./upload" diff --git a/packages/codev-upload/src/parts.ts b/packages/codev-upload/src/parts.ts new file mode 100644 index 0000000000..532b5c3e54 --- /dev/null +++ b/packages/codev-upload/src/parts.ts @@ -0,0 +1,178 @@ +import { diffFromEditInput, diffFromWriteContent } from "./diff" +import { codeFence } from "./render" + +// Part-to-markdown rendering shared by the two session sources: the sqlite +// reader (db.ts) and the sandbox server-API reader (server-source.ts). The +// sqlite `part.data` column stores exactly the v1 API part objects, so one +// renderer keeps both sources' output in one format. Ported from the hub's +// OpenCode provider — the tool-use markup and diff fences are what the +// backend's LOC enricher parses; change nothing here casually. + +export function safeParse(raw: string): T | null { + try { + return JSON.parse(raw) as T + } catch { + return null + } +} + +export function unixToISO(ms: number): string { + if (!Number.isFinite(ms) || ms <= 0) return "" + // Timestamps may be seconds or milliseconds; treat large values as ms. + const epochMs = ms > 1e12 ? ms : ms * 1000 + return new Date(epochMs).toISOString() +} + +function formatDuration(ms: number): string { + if (ms >= 1000) { + return `${(ms / 1000).toFixed(1).replace(/\.0$/, "")}s` + } + return `${ms}ms` +} + +interface ReasoningPart { + type: "reasoning" + text: string + time?: { start: number; end: number } +} + +interface ToolPart { + type: "tool" + tool: string + state: { + status: "completed" | "error" + input: Record + output?: string + error?: string + metadata?: { + diff?: string + filediff?: { patch: string } + } + } +} + +interface TextPart { + type: "text" + text: string +} + +export function partToMarkdown(part: unknown): string | null { + const obj = part as { type?: string } | null + if (!obj || typeof obj !== "object") return null + + if (obj.type === "text") { + const textPart = obj as TextPart + return typeof textPart.text === "string" ? textPart.text : null + } + + if (obj.type === "reasoning") { + const reasonPart = obj as ReasoningPart + if (typeof reasonPart.text !== "string" || !reasonPart.text.trim()) return null + + let durationStr = "" + if (reasonPart.time && typeof reasonPart.time.start === "number" && typeof reasonPart.time.end === "number") { + const diff = reasonPart.time.end - reasonPart.time.start + if (diff >= 0) { + durationStr = ` for ${formatDuration(diff)}` + } + } + return `
Thought${durationStr}\n\n${reasonPart.text.trim()}\n
\n` + } + + if (obj.type === "tool") { + const toolPart = obj as ToolPart + const toolName = toolPart.tool + const state = toolPart.state || {} + const isCompleted = state.status === "completed" + const input = state.input || {} + const output = state.output || "" + const error = state.error || "" + + if (toolName === "bash") { + const command = typeof input.command === "string" ? input.command : "" + const desc = typeof input.description === "string" ? input.description : "" + const summary = `Run command: ${command}` + const descSection = desc ? `Description: ${desc}\n\n` : "" + const body = `${descSection}${codeFence( + `$ ${command}\n${isCompleted ? output : `Error: ${error || "Tool execution aborted"}`}`, + "bash", + )}` + return `\n
\n${summary}\n\n${body}\n
\n
\n` + } + + if (toolName === "read") { + const path = typeof input.filePath === "string" ? input.filePath : "" + const summary = `Read file: ${path}` + const body = isCompleted ? output : `Error: ${error || "Tool execution aborted"}` + return `\n
\n${summary}\n\n${body}\n
\n
\n` + } + + if (toolName === "glob") { + const pattern = typeof input.pattern === "string" ? input.pattern : "" + const summary = `Glob files: ${pattern}` + const body = codeFence( + `Pattern: ${pattern}\nMatches:\n${isCompleted ? output : `Error: ${error || "Tool execution aborted"}`}`, + ) + return `\n
\n${summary}\n\n${body}\n
\n
\n` + } + + if (toolName === "grep") { + const pattern = typeof input.pattern === "string" ? input.pattern : "" + const path = typeof input.path === "string" ? input.path : "" + const summary = `Grep search: ${pattern}` + const body = codeFence( + `Pattern: ${pattern}\nPath: ${path}\nMatches:\n${isCompleted ? output : `Error: ${error || "Tool execution aborted"}`}`, + ) + return `\n
\n${summary}\n\n${body}\n
\n
\n` + } + + if (toolName === "write") { + const path = typeof input.filePath === "string" ? input.filePath : "" + const content = typeof input.content === "string" ? input.content : "" + const summary = `Edit file: ${path}` + // All-additions diff so the LOC enricher counts every line (hub parity). + const diff = diffFromWriteContent(content) + let body: string + if (diff) { + body = `${codeFence(diff, "diff")}${isCompleted ? "" : `\n\nError: ${error || "Tool execution aborted"}`}` + } else if (isCompleted) { + body = codeFence(content) + } else { + body = `Error: ${error || "Tool execution aborted"}` + } + return `\n
\n${summary}\n\n${body}\n
\n
\n` + } + + if (toolName === "edit") { + const path = typeof input.filePath === "string" ? input.filePath : "" + const summary = `Edit file: ${path}` + // Reconstruct the diff when no server-rendered metadata was attached — + // otherwise completed edits underreport accepted LOC (hub parity). + const diff = state.metadata?.diff || state.metadata?.filediff?.patch || diffFromEditInput(input) + let body: string + if (diff) { + body = `${codeFence(diff, "diff")}${isCompleted ? "" : `\n\nError: ${error || "Tool execution aborted"}`}` + } else if (isCompleted) { + body = "Edit applied successfully." + } else { + body = `Error: ${error || "Tool execution aborted"}` + } + return `\n
\n${summary}\n\n${body}\n
\n
\n` + } + + if (toolName === "task") { + const desc = typeof input.description === "string" ? input.description : "" + const prompt = typeof input.prompt === "string" ? input.prompt : "" + const subagent = typeof input.subagent_type === "string" ? input.subagent_type : "" + const summary = `Spawn subagent: ${desc || subagent}` + const body = `**Subagent Type**: ${subagent}\n**Prompt**:\n${prompt}\n\n**Result**:\n${isCompleted ? output : `Error: ${error || "Subagent execution aborted"}`}` + return `\n
\n${summary}\n\n${body}\n
\n
\n` + } + + const summary = `Call tool: ${toolName}` + const body = codeFence(JSON.stringify(state, null, 2), "json") + return `\n
\n${summary}\n\n${body}\n
\n
\n` + } + + return null +} diff --git a/packages/codev-upload/src/server-source.ts b/packages/codev-upload/src/server-source.ts new file mode 100644 index 0000000000..dd11acb657 --- /dev/null +++ b/packages/codev-upload/src/server-source.ts @@ -0,0 +1,186 @@ +import { partToMarkdown, unixToISO } from "./parts" +import type { Message, Session } from "./types" + +// Second session source: a running codev server, read over its v1 HTTP API. +// Exists for the desktop's work (sandbox) sessions — each sandbox runs a full +// server against its own guest-private database, holds only broker-placeholder +// credentials (so it cannot upload itself), and its disk is opaque to the host +// (a VZ image on macOS) — the authenticated HTTP surface the desktop already +// uses to render those sessions is the one uniform way in. +// +// The v1 routes are used deliberately: their payloads are the same objects the +// sqlite `data` columns store, so the shared part renderer (parts.ts) keeps +// API-sourced markdown in the exact format the sqlite source produces. + +export interface ServerSource { + url: string + username?: string | null + password?: string | null + // Host directory that names the per-project export folder for every session + // from this server. Guest paths (/mnt/..., /workspace) are meaningless on + // the host, and keying on the host project dir puts a project's local and + // sandboxed sessions in one analysis bucket. + projectDir: string +} + +interface ApiSessionInfo { + id: string + parentID?: string + directory: string + title?: string + time?: { created?: number; updated?: number } +} + +interface ApiMessageInfo { + role?: string + modelID?: string + model?: { modelID?: string } + time?: { created?: number } +} + +interface ApiMessage { + info?: ApiMessageInfo + parts?: unknown[] +} + +const REQUEST_TIMEOUT_MS = 60_000 +// The global listing defaults to 100 rows; page explicitly. The cursor is +// strictly-less-than on time_updated, so rows sharing one updated timestamp +// across a page boundary can be skipped — acceptable for an exporter that +// runs again on every launch. +const PAGE_LIMIT = 1000 +const MAX_PAGES = 100 + +async function apiGet(source: ServerSource, path: string, fetchImpl: typeof fetch): Promise { + const headers = new Headers({ accept: "application/json" }) + if (source.username && source.password) { + headers.set("authorization", `Basic ${btoa(`${source.username}:${source.password}`)}`) + } + const res = await fetchImpl(`${source.url.replace(/\/+$/, "")}${path}`, { + headers, + signal: AbortSignal.timeout(REQUEST_TIMEOUT_MS), + }) + if (!res.ok) { + throw new Error(`sandbox server GET ${path} failed (${res.status}): ${await res.text()}`) + } + return (await res.json()) as T +} + +// All sessions in the server's store, children included — parentID on the +// listing rows is what lets the subagent rollup work from one sweep. +// archived=true matches the sqlite source, which never filters archived rows. +async function listAllSessions(source: ServerSource, fetchImpl: typeof fetch): Promise { + const all: ApiSessionInfo[] = [] + let cursor: number | undefined + for (let page = 0; page < MAX_PAGES; page++) { + const params = new URLSearchParams({ limit: String(PAGE_LIMIT), archived: "true" }) + if (cursor !== undefined) params.set("cursor", String(cursor)) + const rows = await apiGet(source, `/experimental/session?${params}`, fetchImpl) + all.push(...rows) + if (rows.length < PAGE_LIMIT) break + cursor = rows[rows.length - 1]?.time?.updated + if (cursor === undefined) break + } + return all +} + +function readMessages(source: ServerSource, info: ApiSessionInfo, fetchImpl: typeof fetch): Promise { + // No limit param: the v1 handler returns the full timeline when unpaged. + // directory routes the request to the session's workspace instance. + const params = new URLSearchParams({ directory: info.directory }) + return apiGet(source, `/session/${encodeURIComponent(info.id)}/message?${params}`, fetchImpl) +} + +interface BuiltMessages { + messages: Message[] + firstUserMessage: string + charsIn: number + charsOut: number +} + +// Mirror of the sqlite source's buildSession message walk (db.ts), fed by API +// message objects instead of rows — the shapes are the same JSON. +function buildMessages(apiMessages: ApiMessage[]): BuiltMessages { + const messages: Message[] = [] + let firstUserMessage = "" + let charsIn = 0 + let charsOut = 0 + for (const msg of apiMessages) { + const role = msg.info?.role === "assistant" ? "assistant" : "user" + const textParts: string[] = [] + for (const part of msg.parts ?? []) { + const text = partToMarkdown(part) + if (text) textParts.push(text) + } + const content = textParts.join("\n").trim() + if (!content) continue + if (role === "user") charsIn += content.length + else charsOut += content.length + const timestamp = unixToISO(msg.info?.time?.created ?? 0) + const modelId = msg.info?.modelID || msg.info?.model?.modelID || undefined + messages.push({ + role, + content, + timestamp: timestamp || undefined, + model: role === "assistant" ? modelId : undefined, + }) + if (role === "user" && !firstUserMessage) firstUserMessage = content + } + return { messages, firstUserMessage, charsIn, charsOut } +} + +export async function readSessionsFromServer( + source: ServerSource, + fetchImpl: typeof fetch = fetch, +): Promise { + const all = await listAllSessions(source, fetchImpl) + const childrenOf = new Map() + for (const info of all) { + if (!info.parentID) continue + const group = childrenOf.get(info.parentID) + if (group) group.push(info) + else childrenOf.set(info.parentID, [info]) + } + + // Descendant subagent char rollup, same contract as the sqlite source: the + // children are folded into their parent rather than exported standalone. + const descendantChars = async (id: string): Promise<{ charsIn: number; charsOut: number }> => { + let charsIn = 0 + let charsOut = 0 + for (const child of childrenOf.get(id) ?? []) { + const built = buildMessages(await readMessages(source, child, fetchImpl)) + charsIn += built.charsIn + charsOut += built.charsOut + const nested = await descendantChars(child.id) + charsIn += nested.charsIn + charsOut += nested.charsOut + } + return { charsIn, charsOut } + } + + const sessions: Session[] = [] + for (const info of all) { + if (info.parentID) continue + try { + const built = buildMessages(await readMessages(source, info, fetchImpl)) + if (built.messages.length === 0) continue + const createdMs = unixToISO(info.time?.created ?? 0) + const updatedMs = unixToISO(info.time?.updated ?? 0) + const { charsIn: subagentCharsIn, charsOut: subagentCharsOut } = await descendantChars(info.id) + sessions.push({ + id: info.id, + createdAt: createdMs ? new Date(createdMs) : new Date(), + updatedAt: updatedMs ? new Date(updatedMs) : undefined, + title: info.title || undefined, + firstUserMessage: built.firstUserMessage, + messages: built.messages, + projectDir: source.projectDir, + ...(subagentCharsIn > 0 || subagentCharsOut > 0 ? { subagentCharsIn, subagentCharsOut } : {}), + }) + } catch { + // Skip a session whose messages cannot be read — the rest still export + // (sqlite-source parity: malformed sessions are skipped, not fatal). + } + } + return sessions +} diff --git a/packages/codev-upload/src/upload.ts b/packages/codev-upload/src/upload.ts index 19ca850720..f1b925f819 100644 --- a/packages/codev-upload/src/upload.ts +++ b/packages/codev-upload/src/upload.ts @@ -11,6 +11,7 @@ import { supabaseExchange, } from "@codev/gateway" import { runExport, type StatusReporter } from "./export" +import type { ServerSource } from "./server-source" import { logDebug, logError, logInfo, logWarn } from "./log" import { agentLogsDir } from "./paths" @@ -31,6 +32,9 @@ export interface UploadOptions { // pre-gates on it; the interactive command runs a browser login. A resolver // returning null aborts with a sign-in hint. getAuth: () => Promise + // Running sandbox servers to export work sessions from, in addition to the + // local store (desktop only — see server-source.ts). + servers?: ServerSource[] // Test seam (codev-gateway FetchOptions convention). fetchImpl?: typeof fetch } @@ -84,7 +88,7 @@ export async function runUpload(options: UploadOptions): Promise } status("Exporting codev sessions...") - const exportSummary = runExport(status) + const exportSummary = await runExport(status, options.servers ?? [], fetchImpl) const outDir = agentLogsDir() const files = listMarkdownLogs(outDir) diff --git a/packages/codev-upload/test/export.test.ts b/packages/codev-upload/test/export.test.ts index 961586d5fe..ec5d086ccb 100644 --- a/packages/codev-upload/test/export.test.ts +++ b/packages/codev-upload/test/export.test.ts @@ -63,7 +63,7 @@ function insertPart(db: Database, sessionId: string, messageId: string, id: stri } describe("runExport", () => { - test("exports sessions into the hub layout with hub-format markdown", () => { + test("exports sessions into the hub layout with hub-format markdown", async () => { const projectDir = join(tempDir, "work", "myproj") mkdirSync(projectDir, { recursive: true }) seedDb((db) => { @@ -83,7 +83,7 @@ describe("runExport", () => { }) }) - const summary = runExport() + const summary = await await runExport() expect(summary.exported).toBe(1) expect(summary.projects).toBe(1) @@ -131,7 +131,7 @@ describe("runExport", () => { expect(stats.sessions.ses1.provider).toBe("codev-code") }) - test("skips subagent sessions but rolls their chars into the parent", () => { + test("skips subagent sessions but rolls their chars into the parent", async () => { const projectDir = join(tempDir, "work", "proj2") mkdirSync(projectDir, { recursive: true }) seedDb((db) => { @@ -150,14 +150,14 @@ describe("runExport", () => { insertPart(db, "child", "cm1", "cp1", T0, { type: "text", text: "12345" }) }) - const summary = runExport() + const summary = await await runExport() expect(summary.exported).toBe(1) const agentDir = join(agentLogsDir(), projectFolderName(projectDir), "codev-code") const md = readFileSync(join(agentDir, readdirSync(agentDir)[0]), "utf-8") expect(md).toContain("subagent_chars_out=5") }) - test("global (VCS-less) sessions use their own directory; sessions without one are skipped", () => { + test("global (VCS-less) sessions use their own directory; sessions without one are skipped", async () => { const looseDir = join(tempDir, "loose") mkdirSync(looseDir, { recursive: true }) seedDb((db) => { @@ -175,12 +175,12 @@ describe("runExport", () => { insertPart(db, "g2", "m2", "p2", T0, { type: "text", text: "orphan" }) }) - const summary = runExport() + const summary = await await runExport() expect(summary.exported).toBe(1) expect(readdirSync(join(agentLogsDir(), projectFolderName(looseDir), "codev-code"))).toHaveLength(1) }) - test("stamps base_url from the CoDev-managed codev.json", () => { + test("stamps base_url from the CoDev-managed codev.json", async () => { const projectDir = join(tempDir, "work", "proj3") mkdirSync(projectDir, { recursive: true }) const configDir = join(tempDir, ".config", "codev") @@ -199,14 +199,14 @@ describe("runExport", () => { insertPart(db, "ses1", "m1", "p1", T0, { type: "text", text: "hi" }) }) - runExport() + await runExport() const agentDir = join(agentLogsDir(), projectFolderName(projectDir), "codev-code") const md = readFileSync(join(agentDir, readdirSync(agentDir)[0]), "utf-8") expect(md).toContain("base_url=https://gw.test/v1") }) - test("a missing store exports nothing and reports where it looked", () => { - const summary = runExport() + test("a missing store exports nothing and reports where it looked", async () => { + const summary = await await runExport() expect(summary.exported).toBe(0) expect(summary.dbPath).toBe(sessionDbPath()) }) diff --git a/packages/codev-upload/test/server-source.test.ts b/packages/codev-upload/test/server-source.test.ts new file mode 100644 index 0000000000..70af3a209d --- /dev/null +++ b/packages/codev-upload/test/server-source.test.ts @@ -0,0 +1,128 @@ +import { afterEach, beforeEach, describe, expect, test } from "bun:test" +import { mkdtempSync, readdirSync, readFileSync, rmSync } from "node:fs" +import { tmpdir } from "node:os" +import { join } from "node:path" +import { runExport } from "../src/export" +import { readSessionsFromServer, type ServerSource } from "../src/server-source" +import { agentLogsDir, projectFolderName } from "../src/paths" + +let tempDir: string +let originalTestHome: string | undefined +let originalXdgData: string | undefined + +beforeEach(() => { + tempDir = mkdtempSync(join(tmpdir(), "codev-upload-srv-")) + originalTestHome = process.env.CODEV_TEST_HOME + originalXdgData = process.env.XDG_DATA_HOME + process.env.CODEV_TEST_HOME = tempDir + process.env.XDG_DATA_HOME = join(tempDir, "share") +}) + +afterEach(() => { + if (originalTestHome === undefined) delete process.env.CODEV_TEST_HOME + else process.env.CODEV_TEST_HOME = originalTestHome + if (originalXdgData === undefined) delete process.env.XDG_DATA_HOME + else process.env.XDG_DATA_HOME = originalXdgData + rmSync(tempDir, { recursive: true, force: true }) +}) + +const T0 = Date.UTC(2026, 7, 1, 10, 0, 0) + +// Fixture guest: one root work session with a subagent child, guest-side +// directories the host has never heard of. +const SESSIONS = [ + { id: "ws1", directory: "/mnt/proj", title: "Fix in sandbox", time: { created: T0, updated: T0 + 60_000 } }, + { id: "ws1-child", parentID: "ws1", directory: "/mnt/proj", title: "", time: { created: T0, updated: T0 } }, +] + +const MESSAGES: Record = { + ws1: [ + { + info: { role: "user", time: { created: T0 } }, + parts: [{ type: "text", text: "run the tests" }], + }, + { + info: { role: "assistant", modelID: "claude-sonnet-4-5", time: { created: T0 + 30_000 } }, + parts: [ + { type: "text", text: "done" }, + { + type: "tool", + tool: "bash", + state: { status: "completed", input: { command: "bun test" }, output: "1 pass" }, + }, + ], + }, + ], + "ws1-child": [{ info: { role: "assistant", modelID: "m" }, parts: [{ type: "text", text: "12345" }] }], +} + +function mockFetch(calls: string[]): typeof fetch { + return (async (input: string | URL | Request) => { + const url = new URL(String(input instanceof Request ? input.url : input)) + calls.push(url.pathname + url.search) + if (url.pathname === "/experimental/session") { + return Response.json(SESSIONS) + } + const message = url.pathname.match(/^\/session\/([^/]+)\/message$/) + if (message) { + return Response.json(MESSAGES[decodeURIComponent(message[1])] ?? []) + } + throw new Error(`unexpected request: ${url}`) + }) as typeof fetch +} + +const source = (hostDir: string): ServerSource => ({ + url: "http://127.0.0.1:14100", + username: "codev", + password: "secret", + projectDir: hostDir, +}) + +describe("readSessionsFromServer", () => { + test("maps v1 API sessions to the exporter's shapes, folding subagents in", async () => { + const calls: string[] = [] + const sessions = await readSessionsFromServer(source("/tmp/host-proj"), mockFetch(calls)) + expect(sessions).toHaveLength(1) + const session = sessions[0] + expect(session.id).toBe("ws1") + expect(session.title).toBe("Fix in sandbox") + expect(session.projectDir).toBe("/tmp/host-proj") + expect(session.createdAt.toISOString()).toBe("2026-08-01T10:00:00.000Z") + expect(session.messages).toHaveLength(2) + expect(session.messages[1].model).toBe("claude-sonnet-4-5") + expect(session.messages[1].content).toContain('') + expect(session.subagentCharsOut).toBe(5) + // Requests include archived sessions and route messages by the session's + // own (guest) directory. + expect(calls[0]).toContain("archived=true") + expect(calls.some((c) => c.includes("/session/ws1/message") && c.includes("directory=%2Fmnt%2Fproj"))).toBe(true) + }) +}) + +describe("runExport with server sources", () => { + test("work sessions land in the HOST project's folder, in the standard format", async () => { + const hostDir = join(tempDir, "work", "hostproj") + const summary = await runExport(() => {}, [source(hostDir)], mockFetch([])) + expect(summary.exported).toBe(1) + expect(summary.serverErrors).toHaveLength(0) + + const agentDir = join(agentLogsDir(), projectFolderName(hostDir), "codev-code") + const files = readdirSync(agentDir) + expect(files).toEqual(["2026-08-01_10-00-00Z-run-the-tests.md"]) + const md = readFileSync(join(agentDir, files[0]), "utf-8") + expect(md).toContain("") + expect(md).toContain("# Fix in sandbox") + expect(md).toContain("subagent_chars_out=5") + expect(md).toContain("## Assistant (claude-sonnet-4-5)") + }) + + test("an unreachable sandbox is recorded and non-fatal", async () => { + const failing = (async () => { + throw new Error("connect ECONNREFUSED") + }) as unknown as typeof fetch + const summary = await runExport(() => {}, [source(join(tempDir, "p"))], failing) + expect(summary.exported).toBe(0) + expect(summary.serverErrors).toHaveLength(1) + expect(summary.serverErrors[0].message).toContain("ECONNREFUSED") + }) +}) diff --git a/packages/desktop/src/main/index.ts b/packages/desktop/src/main/index.ts index 3c6db9ec2e..b77c1943b3 100644 --- a/packages/desktop/src/main/index.ts +++ b/packages/desktop/src/main/index.ts @@ -8,6 +8,7 @@ import { getCACertificates, setDefaultCACertificates } from "node:tls" import type { Event } from "electron" import { app, net, Notification, session, shell } from "electron" import { shouldNudgeSignIn } from "@codev/upload/nudge" +import type { ServerSource as UploadServerSource } from "@codev/upload" import { Deferred, Effect, Fiber } from "effect" import contextMenu from "electron-context-menu" @@ -181,6 +182,39 @@ const main = Effect.gen(function* () { log: (message, meta) => logger.log(message, meta), error: (message, meta) => logger.error(message, meta), }) + // Fork: work (sandbox) sessions live in each guest's own database behind + // broker-placeholder credentials — the guest cannot upload them, and the + // host cannot read its disk. The authenticated server API the app already + // uses is the way in: every ready sandbox becomes a source the sidecar's + // uploader reads over HTTP (see @codev/upload's server-source.ts). Grouped + // under the sandbox's HOST project dir so a project's local and sandboxed + // sessions land in one analysis bucket. + const sandboxUploadSources = (): UploadServerSource[] => + sandboxes.getState().sandboxes.flatMap((item) => + item.runtime.kind === "ready" + ? [ + { + url: item.runtime.url, + username: item.runtime.username, + password: item.runtime.password, + projectDir: item.config.projectDir, + }, + ] + : [], + ) + // A sandbox transitioning to ready is the moment its whole history becomes + // readable (covers app start — sandboxes come ready during initialize — and + // every later manual start/restart). The sidecar chains runs, so a burst of + // sandboxes coming up just queues idempotent sweeps. + let readySandboxes = new Set() + sandboxes.subscribe((event) => { + const ready = new Set( + event.state.sandboxes.flatMap((item) => (item.runtime.kind === "ready" ? [item.config.id] : [])), + ) + const newlyReady = [...ready].some((id) => !readySandboxes.has(id)) + readySandboxes = ready + if (newlyReady) server?.upload(sandboxUploadSources()) + }) const stopSidecars = async () => { await killSidecar() wslServers.stopAll() @@ -351,9 +385,10 @@ const main = Effect.gen(function* () { onConfigured: () => { void sandboxes.restartRunning().catch((error) => logger.error("sandbox egress refresh failed", error)) // Fork: a fresh sign-in un-stalls the background session upload — - // ship any backlog now instead of at the next app launch. Gated - // inside the sidecar's runner; never blocks the configure flow. - server?.upload() + // ship any backlog (work sessions included) now instead of at the + // next app launch. Gated inside the sidecar's runner; never blocks + // the configure flow. + server?.upload(sandboxUploadSources()) }, }), ) @@ -450,7 +485,15 @@ const main = Effect.gen(function* () { if (process.platform === "win32") { void wslServers.initialize().catch((error) => logger.error("wsl server initialization failed", error)) } - void sandboxes.initialize().catch((error) => logger.error("sandbox initialization failed", error)) + void sandboxes + .initialize() + .then(() => { + // Catch-up for sandboxes that reached ready before the sidecar was + // listening — the subscribe diff above only sees transitions. + const sources = sandboxUploadSources() + if (sources.length > 0) server?.upload(sources) + }) + .catch((error) => logger.error("sandbox initialization failed", error)) yield* Effect.promise(() => health.wait).pipe( Effect.timeout("30 seconds"), diff --git a/packages/desktop/src/main/server.ts b/packages/desktop/src/main/server.ts index d80249434d..79768b1f2e 100644 --- a/packages/desktop/src/main/server.ts +++ b/packages/desktop/src/main/server.ts @@ -1,6 +1,7 @@ import { dirname, join } from "node:path" import { fileURLToPath } from "node:url" import { app, utilityProcess } from "electron" +import type { ServerSource as UploadServerSource } from "@codev/upload" import type { Details } from "electron" import { getLogger } from "./logging" import { getUserShell, loadShellEnv } from "./shell-env" @@ -14,7 +15,7 @@ type SidecarMessage = | { type: "stopped" } | { type: "error"; error: { message: string; stack?: string } } -export type SidecarListener = { stop: () => Promise; upload: () => void } +export type SidecarListener = { stop: () => Promise; upload: (servers?: UploadServerSource[]) => void } const SIDECAR_SERVICE_NAME = "codev server" const SIDECAR_START_STALL_TIMEOUT = 60_000 @@ -168,8 +169,10 @@ export async function spawnLocalServer( // after a fresh gateway sign-in so the backlog ships immediately // instead of at the next app launch. Fire-and-forget; a dead sidecar // just drops it (the next start's own run covers it). - upload: () => { - if (!exited) child.postMessage({ type: "upload" }) + upload: (servers?: UploadServerSource[]) => { + // servers must stay structured-clone-safe: plain url/credential/dir + // records, nothing function-valued. + if (!exited) child.postMessage({ type: "upload", servers }) }, stop: () => { if (stopping) return stopping diff --git a/packages/desktop/src/main/sidecar.ts b/packages/desktop/src/main/sidecar.ts index 8a67308d0a..fe0b162ae4 100644 --- a/packages/desktop/src/main/sidecar.ts +++ b/packages/desktop/src/main/sidecar.ts @@ -1,6 +1,6 @@ import * as http from "node:http" import * as tls from "node:tls" -import { runStartupUpload } from "@codev/upload" +import { runStartupUpload, type ServerSource } from "@codev/upload" type NodeHttpWithEnvProxy = typeof http & { setGlobalProxyFromEnv: () => void @@ -20,7 +20,7 @@ type StartCommand = { } type StopCommand = { type: "stop" } -type UploadCommand = { type: "upload" } +type UploadCommand = { type: "upload"; servers?: ServerSource[] } type SidecarCommand = StartCommand | StopCommand | UploadCommand type SidecarMessage = @@ -48,7 +48,7 @@ parentPort.on("message", (event) => { return } if (command.type === "upload") { - void triggerUpload() + void triggerUpload(command.servers) return } void start(command) @@ -142,21 +142,28 @@ function useEnvProxy() { // Fork: never let the upload break or block the server — same contract as // every other caller of runStartupUpload (it returns codes rather than -// throwing; the catch is defensive). -async function triggerUpload() { - try { - const code = await runStartupUpload() - console.log(`[upload] startup run finished code=${code}`) - } catch (error) { - console.warn(`[upload] startup run failed: ${serializeError(error).message}`) - } +// throwing; the catch is defensive). Runs are chained in-process: the +// hub-shared lockfile makes a concurrent run SKIP (not queue), so without +// the chain a sandbox-inclusive run posted right after startup's local run +// would silently drop its work sessions. +let uploadChain: Promise = Promise.resolve() +function triggerUpload(servers?: ServerSource[]) { + uploadChain = uploadChain.then(async () => { + try { + const code = await runStartupUpload({ servers }) + console.log(`[upload] run finished code=${code} servers=${servers?.length ?? 0}`) + } catch (error) { + console.warn(`[upload] run failed: ${serializeError(error).message}`) + } + }) + return uploadChain } function parseCommand(value: unknown): SidecarCommand | undefined { if (!value || typeof value !== "object") return const command = value as Partial if (command.type === "stop") return { type: "stop" } - if (command.type === "upload") return { type: "upload" } + if (command.type === "upload") return { type: "upload", servers: (command as UploadCommand).servers } if (command.type !== "start") return if (typeof command.hostname !== "string") return if (typeof command.port !== "number") return