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