diff --git a/DIVERGENCES.md b/DIVERGENCES.md index 17727c17e1..c83eb0f895 100644 --- a/DIVERGENCES.md +++ b/DIVERGENCES.md @@ -107,14 +107,14 @@ from the squash message first, or use a plain merge. | File | Change | Why | On conflict | | --- | --- | --- | --- | | `packages/core/src/global.ts`, `packages/core/test/global.test.ts`, `packages/opencode/test/cli/mcp-add.test.ts` | `const app = "opencode"` → `"codev"`; test expectations `tmpdir()/opencode` → `tmpdir()/codev` and `.config/opencode/` → `.config/codev/` | Config/data/cache/state dirs under `~/.config/codev` etc.; upstream tests assert the literal dir name. | Keep ours (the one constant + expectations); take upstream's surrounding changes. | -| `packages/opencode/src/index.ts`, `packages/opencode/test/cli/error.test.ts`, `packages/opencode/test/cli/help/help-snapshots.test.ts`, `packages/opencode/test/cli/help/__snapshots__/help-snapshots.test.ts.snap` | `scriptName("opencode")` → `"codev"`; `show()` prefix check `"opencode "` → `"codev "`; registered the fork-only `ConfigureCommand` (cli/cmd/configure.ts, new file), `CodegraphCommand` (cli/cmd/codegraph.ts, new file), `UploadCommand` (cli/cmd/upload.ts, new file — `codev upload`, the standalone `codevhub upload`: exports sessions and ships them to the analysis backend, `--force` to re-upload unchanged, `--logs` to print the last run's diagnostic trail; its inline SSO prompt now lives in cli/cmd/sso-prompt.ts, new file, shared with `codev skill push`), and `SkillHubCommand` (cli/cmd/skill.ts, new file — `codev skill search|pull|push|stack|office`, the standalone `codevhub skill`: searches/installs/publishes SkillHub skills, installs a Skill Stack — a public hub namespace's whole skill set — as one bundle, and fetches the CoDev Office offline bundle, backed by the fork-only `packages/codev-skillhub` workspace package with SSO via `@codev/gateway`) and added `configure` + `codegraph` + `upload` + `skill` to the help-snapshot TOP_LEVEL list (and the `skill` subcommands — `search`, `pull`, `push`, `stack`, `stack list`, `stack pull`, `office` — to SUBCOMMANDS); test expectation `` `opencode models` `` → `` `codev models` ``; help snapshots regenerated so usage lines read `codev …` | Help/usage text shows the real user-facing command; `codev configure` is the standalone gateway-setup entry point, and `codev codegraph` is the standalone CodeGraph MCP wiring for installs not managed by CoDev Hub. The `show()` check must always match the scriptName; upstream's error-format test and help snapshots assert the literal command. | Keep our strings, command registration + expectation; take upstream's surrounding changes. Regenerate the snapshot file with `bun test test/cli/help --update-snapshots` instead of hand-merging. | +| `packages/opencode/src/index.ts`, `packages/opencode/test/cli/error.test.ts`, `packages/opencode/test/cli/help/help-snapshots.test.ts`, `packages/opencode/test/cli/help/__snapshots__/help-snapshots.test.ts.snap` | `scriptName("opencode")` → `"codev"`; `show()` prefix check `"opencode "` → `"codev "`; registered the fork-only `ConfigureCommand` (cli/cmd/configure.ts, new file), `CodegraphCommand` (cli/cmd/codegraph.ts, new file), `UploadCommand` (cli/cmd/upload.ts, new file — `codev upload`: pushes session usage metrics to the analysis backend now (the same push every launch runs in the background); `--logs` switches to the standalone `codevhub upload` twin that exports sessions and ships the logs, the only path that uploads logs; `--force` to re-send unchanged sessions, `--diagnostics` to print the last run's diagnostic trail (with `--logs`: the last log upload); its inline SSO prompt now lives in cli/cmd/sso-prompt.ts, new file, shared with `codev skill push`), and `SkillHubCommand` (cli/cmd/skill.ts, new file — `codev skill search|pull|push|stack|office`, the standalone `codevhub skill`: searches/installs/publishes SkillHub skills, installs a Skill Stack — a public hub namespace's whole skill set — as one bundle, and fetches the CoDev Office offline bundle, backed by the fork-only `packages/codev-skillhub` workspace package with SSO via `@codev/gateway`) and added `configure` + `codegraph` + `upload` + `skill` to the help-snapshot TOP_LEVEL list (and the `skill` subcommands — `search`, `pull`, `push`, `stack`, `stack list`, `stack pull`, `office` — to SUBCOMMANDS); test expectation `` `opencode models` `` → `` `codev models` ``; help snapshots regenerated so usage lines read `codev …` | Help/usage text shows the real user-facing command; `codev configure` is the standalone gateway-setup entry point, and `codev codegraph` is the standalone CodeGraph MCP wiring for installs not managed by CoDev Hub. The `show()` check must always match the scriptName; upstream's error-format test and help snapshots assert the literal command. | Keep our strings, command registration + expectation; take upstream's surrounding changes. Regenerate the snapshot file with `bun test test/cli/help --update-snapshots` instead of hand-merging. | | `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 `{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; an "Upload Session Logs" menu item (desktop-menu.ts: the fork-only `upload.sessions` `DesktopMenuAction`, placed under "Get a new API Key..." in the macOS app menu and the Windows/Linux File menu; threaded through desktop-menu-actions.ts, menu.ts and ipc.ts's handler deps, generic renderer dispatch already covers it) runs the same sweep on demand with an "Uploading session logs" Notification as feedback; and `@codev/upload/nudge`'s `shouldNudgeSignIn()` is read once at startup into a one-shot `let` that `registerGatewaySsoIpcHandlers`' second argument hands to the first renderer that asks (see the gateway key recovery row), which raises the "Session upload is paused" toast — the desktop counterpart of the TUI toast, every launch until sign-in. Consumed rather than re-read per request so the answer cannot flip mid-session and so a second window does not repeat a toast the user is already looking at; it replaced an OS `Notification`, which carries no action button outside macOS and on Linux depends on a notification daemon that may drop it entirely. 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 + the startup nudge flag, and the devDependency placement; take upstream's surrounding changes. | +| `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:*"` (`codev upload` and the launch-time stats push — see the stats-push 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`. | +| Stats-push 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: `spawnStatsDaemon()` (from the fork-only `@codev/upload` package) fire-and-forgotten at handler start — spawns a detached, unref'd, stdio-ignored `codev debug stats` child. debug/index.ts: registered the fork-only `StatsCommand` (debug/stats.ts, new file), the entry that child runs. | Every agent launch pushes per-session usage statistics (real tokens, cost, tool calls, lines of code, hourly buckets) to the analysis backend in the background; the reports read those instead of re-deriving numbers from uploaded markdown. Logs are never uploaded from a launch — only `codev upload --logs` uploads them; plain `codev upload` runs the same stats push interactively. All gating (opt-out env `CODEV_DISABLE_METRICS`, signed-in check, the agent-only stats 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 + `spawnStatsDaemon()` line in both handlers and the debug registration; take upstream's surrounding changes. | +| Desktop stats push: `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 `runStartupStats()` (from the fork-only `@codev/upload`, statically imported so electron-vite bundles it), and handle a `{type: "stats", servers?}` sidecar command that re-runs it — runs are chained on an in-process promise (the stats 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 `stats(servers?)` (posts that command with structured-clone-safe `ServerSource` records; no-op once exited), and `createSidecarEnv` sets `CODEV_DESKTOP_VERSION` from `app.getVersion()` so the push can report the desktop build (the utility process has no `app`). index.ts: `sandboxStatsSources()` 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?.stats(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?.stats(sandboxStatsSources())` so a fresh sign-in sends the backlog (work sessions included) immediately; and `@codev/upload/nudge`'s `shouldNudgeSignIn()` is read once at startup into a one-shot `let` that `registerGatewaySsoIpcHandlers`' second argument hands to the first renderer that asks (see the gateway key recovery row), which raises the "Usage reporting is paused" toast — the desktop counterpart of the TUI toast, every launch until sign-in. Consumed rather than re-read per request so the answer cannot flip mid-session and so a second window does not repeat a toast the user is already looking at; it replaced an OS `Notification`, which carries no action button outside macOS and on Linux depends on a notification daemon that may drop it entirely. 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 never uploads session logs (there is no menu item or automatic trigger for it); sandbox work sessions contribute statistics only. | 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 stats` spawn cannot cover it; running the push 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 stats lockfile as the CLI. 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 `stats` command + startup trigger, the listener's `stats()`, the `CODEV_DESKTOP_VERSION` env line, the `onConfigured` call + the startup nudge flag, 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. | +| `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 usage-reporting sign-in toast and the post-configure stats-push 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. | | `packages/app/package.json` | Added dependency `"@codev/brand": "workspace:*"` | Every docs link the app opens in the browser takes its base from `DOCS_URL` in the fork-only `packages/codev-brand` (see the docs-site links row) instead of hardcoding the host. `codev-brand` is a zero-dependency leaf that uses only `atob`, so it is safe in client runtime code. | Keep our dependency line; take upstream's other fields. | | `packages/tui/src/feature-plugins/home/tips-view.tsx` | `NO_MODELS_TIP` points at `/configure` (CoDev gateway) instead of `/connect`; tip strings rebranded — built-CLI examples `opencode ` → `codev ` (`serve`, `upgrade`, `agent create`, `github install`, `debug config`) and product prose "OpenCode" → "CoDev Code". Dropped the `docker run … ghcr.io/anomalyco/opencode` tip and the `/connect` tip that advertised "OpenCode Zen" (a generic `/connect` tip already exists). GitHub-integration tips rebranded to the fork's trigger: the two `/opencode` tips and the `/oc` PR-review tip now say `/codev` (matches the rebranded `github.handler.ts` defaults). | The tips name the fork's CLI (`codev`), product, and `/codev` GitHub trigger; no fork docker image (see web-docs install row); Zen is upstream's hosted gateway. The `opencode.status` command id stays (internal identifier, not user-facing). | Keep our tip text and the docker-tip and Zen-`/connect`-tip deletions. Rebrand any new upstream `opencode `/"OpenCode"/`/opencode`-trigger tip; drop any re-added docker/brew/curl install tip. | | `packages/tui/src/feature-plugins/sidebar/footer.tsx` | Deleted the dismissable "Getting started" card (free-models pitch + `/connect` pointer + `dismissed_getting_started` KV flag); the sidebar footer keeps only the cwd path display | The card advertises upstream's hosted free Zen models — which the fork hides — and points at `/connect` while first-run setup routes through `/configure`; the fork's startup gate covers onboarding. | Keep the card deleted; take upstream's changes to the remaining path display. | @@ -157,7 +157,7 @@ from the squash message first, or use a plain merge. | `packages/tui/src/feature-plugins/sidebar/footer.tsx` | Added the editor-context label above the `cwd:branch` line: `useEditorLabel()` (fork-only `src/context/editor-label.tsx`) supplies the effective selection, an `editorFile` memo formats it via `editorContextFileLabel(...)` at a fixed width of 36 (the sidebar's 42 columns less 2 of padding a side and 2 for the bullet), and `` renders it — both from the fork-only `./editor-context-label.tsx`. | The open file + selected range belong with the sidebar's other ambient session state rather than in the prompt footer's crowded keybind-hint row; they sit next to the cwd for a single "where am I working" group. Moved out of the prompt footer, which no longer shows it at all. | Keep the `useEditorLabel` import, the `editorFile` memo, and the `` element as the box's first child; take upstream's changes to the path display below it. | | `packages/tui/src/feature-plugins/sidebar/lsp.tsx` | Whole LSP section wrapped in `` so it renders nothing when `config.lsp` is falsy; the "LSPs are disabled" placeholder text removed (empty state is only "LSPs will activate as files are read") | A disabled feature shouldn't occupy sidebar space with a permanent placeholder. | Keep our `Show` wrapper + removed placeholder; take upstream's surrounding changes. | | `packages/opencode/src/cli/cmd/*.ts` (tui, run, serve, attach, pr, upgrade, uninstall, web) | Help `describe` strings "opencode" → "codev" | `--help` is the first thing users read. | Keep our strings. | -| `packages/tui/src/app.tsx` | Terminal title "OpenCode"/"OC \|" → "CoDev Code"/"CC \|"; the configure-gate effect is a stateless forced-setup invariant — while `sync.status === "complete" && !useConnected() && dialog.stack.length === 0` it (re)opens the fork's `DialogConfigure` (component/dialog-configure.tsx, new file), so escaping any gate-launched flow or a credential-only save bounces back to the gate instead of an unconfigured chat (gates on `!useConnected()`, not `provider.length === 0` — hosted Zen free models always register); added the `codev.configure` command (`/configure`, opening `` — dismissable on a configured install, the gate otherwise) and a `CODEV_CONFIGURE=1` mount hook for `codev configure`; home screen skipped — plain startup seeds the `dummy` session route and auto-opens a new empty session (reusing a leftover default-titled empty one via `openNewSession`/`findEmptySession`), `session.new` and the current-session `session.deleted` handler open a new session instead of navigating home, and the `-c` resolver prefers non-default-titled sessions so it lands on the last real conversation; added the `app.exit.confirm` command + `EXIT_CONFIRM_WINDOW` const (double-press Ctrl+C to quit: first press shows a toast, second within 1.5 s exits) and gathered its `app_exit_confirm` binding alongside `app_exit`; an onMount "Session upload is paused" warning toast next to the raster-font one, shown when `@codev/upload/nudge`'s `shouldNudgeSignIn()` says the background upload stalled for lack of a CoDev SSO session (SSO-origin keys only; deliberately every launch until the sign-in happens — see the session-upload wiring row); a one-shot `createEffect` after `sync.status === "complete"` runs `@codev/gateway`'s `ensureGatewayKey()` (the startup gateway-key pre-flight shared with the desktop controller): a `refreshed` result merges the re-minted key into the auth registry for every id it names via `sdk.client.auth.set`, then `instance.dispose()` + `sync.bootstrap()`; a `needs-login` with reason `rejected` opens `` when no dialog is up (`no-key` is ignored — a non-gateway setup, or the gate's own case) | Tab title branding; first-run setup is a forced gate the user can't escape into an unconfigured chat (the invariant reopens it); `codev` starts directly in the session layout instead of the home screen; a stray Ctrl+C no longer quits on the first press. | Keep our strings, the invariant gate effect, dialog swap + command block, route seed, `openNewSession`/`findEmptySession` + call sites, the `-c` default-title filter, and the `app.exit.confirm` command + binding; take upstream's surrounding changes. | +| `packages/tui/src/app.tsx` | Terminal title "OpenCode"/"OC \|" → "CoDev Code"/"CC \|"; the configure-gate effect is a stateless forced-setup invariant — while `sync.status === "complete" && !useConnected() && dialog.stack.length === 0` it (re)opens the fork's `DialogConfigure` (component/dialog-configure.tsx, new file), so escaping any gate-launched flow or a credential-only save bounces back to the gate instead of an unconfigured chat (gates on `!useConnected()`, not `provider.length === 0` — hosted Zen free models always register); added the `codev.configure` command (`/configure`, opening `` — dismissable on a configured install, the gate otherwise) and a `CODEV_CONFIGURE=1` mount hook for `codev configure`; home screen skipped — plain startup seeds the `dummy` session route and auto-opens a new empty session (reusing a leftover default-titled empty one via `openNewSession`/`findEmptySession`), `session.new` and the current-session `session.deleted` handler open a new session instead of navigating home, and the `-c` resolver prefers non-default-titled sessions so it lands on the last real conversation; added the `app.exit.confirm` command + `EXIT_CONFIRM_WINDOW` const (double-press Ctrl+C to quit: first press shows a toast, second within 1.5 s exits) and gathered its `app_exit_confirm` binding alongside `app_exit`; an onMount "Usage reporting is paused" warning toast next to the raster-font one, shown when `@codev/upload/nudge`'s `shouldNudgeSignIn()` says the launch-time stats push stalled for lack of a CoDev SSO session (SSO-origin keys only; deliberately every launch until the sign-in happens — see the stats-push wiring row); a one-shot `createEffect` after `sync.status === "complete"` runs `@codev/gateway`'s `ensureGatewayKey()` (the startup gateway-key pre-flight shared with the desktop controller): a `refreshed` result merges the re-minted key into the auth registry for every id it names via `sdk.client.auth.set`, then `instance.dispose()` + `sync.bootstrap()`; a `needs-login` with reason `rejected` opens `` when no dialog is up (`no-key` is ignored — a non-gateway setup, or the gate's own case) | Tab title branding; first-run setup is a forced gate the user can't escape into an unconfigured chat (the invariant reopens it); `codev` starts directly in the session layout instead of the home screen; a stray Ctrl+C no longer quits on the first press. | Keep our strings, the invariant gate effect, dialog swap + command block, route seed, `openNewSession`/`findEmptySession` + call sites, the `-c` default-title filter, and the `app.exit.confirm` command + binding; take upstream's surrounding changes. | | `packages/tui/bunfig.toml` | `[test] preload` gains the fork-only `./test/preload.ts`, which sets `CODEV_TEST_HOME` to a fresh temp dir (unless already set) before any test runs | The app mount runs the gateway key pre-flight (`ensureGatewayKey`) and the upload sign-in nudge at startup; both read the shared auth file under `~/.codev`/`~/.codev-hub`, and the pre-flight can write a re-minted key back. View tests boot the real app, so without an isolated home a developer's test run would probe the real gateway with their real key. Same convention as core's `Global` and `packages/opencode/test/preload.ts`. | Keep our preload entry; take upstream's other bunfig changes. | | `packages/tui/src/routes/session/index.tsx` | Flow transcript: the scrollbox opens with a home-like banner (`Logo` + `home_bottom` tips slot, `placement="start"`) as its first entry for root sessions (subagent sessions keep a plain 1-row pad), its `maxHeight` is clamped to the content height (a `transcriptHeight` signal read off the content's yoga node in the renderer root's `layout-changed` handler, which then re-runs `calculateLayout` so the clamp lands in the same frame — `scrollHeight` is a frame stale there, and painting with a stale clamp flickers during streaming) so the `Prompt` rides directly below the last message, and a trailing flexGrow spacer absorbs the leftover space until content fills the screen (then the prompt is effectively bottom-anchored and the banner scrolls away with history); the session `Prompt` gets home's `placeholder` pool when the session has no messages so the input shows an `Ask anything... ""` placeholder like home | Startup lands in an empty session instead of the home screen; the transcript flows top-down like terminal scrollback — logo top-left, input following the conversation down — instead of a bottom-anchored prompt with a void above it. | Keep our banner block inside the scrollbox, the `transcriptHeight` clamp + `layout-changed` listener, the trailing spacer, and the `placeholders` prop; take upstream's surrounding changes. | | `packages/tui/src/routes/session/index.tsx` | Sidebar visibility is session-local: removed upstream's persisted `kv.signal<"auto" \| "hide">("sidebar", "auto")` (and the `sidebar() === "auto" && wide()` branch of `sidebarVisible`); visibility is driven only by the non-persisted `sidebarOpen` signal, which the `session.sidebar.toggle` command flips (supersedes the earlier `"auto"` → `"hide"` default change) | The right sidebar starts hidden in every session regardless of terminal width or what the user did last session; `ctrl+b`/`b` only affects the current session. A stale `sidebar` key in existing kv.json files is simply never read. | Keep the kv signal removed and `sidebarVisible` reading only `parentID` + `sidebarOpen`; take upstream's surrounding changes. | @@ -204,7 +204,7 @@ from the squash message first, or use a plain merge. | `packages/app/src/components/help-button.tsx` | Deleted (as of the v1.18.3 sync). The fork had removed the `TabsInfoPopup` component and its `introducing-tabs.mp4` import, keeping the rebranded `HelpButton`; upstream v1.18.3 deleted `HelpButton` itself, leaving only the popup — so nothing remains and the file is gone. | The popup is upstream's temporary, OpenCode-branded "Introducing Tabs" promo (upstream's own comment says it's removable after the rollout), unmounted per the row above; removing the definition lets the 13 MB video be deleted (row below). | Keep deleted (`git rm` the upstream side of modify/delete conflicts). If upstream mounts `TabsInfoPopup` somewhere new, drop that mount per the row above. When upstream deletes the popup itself, this row and the rows around it retire. | | `packages/app/src/assets/help/` (introducing-tabs.mp4, placeholder.png, home.png, tabs.png — whole directory) | Deleted | The 13 MB promo video's and the v1.18 home/tabs screenshots' only consumer was the removed `TabsInfoPopup` (row above) and they bloat every clone; `placeholder.png` is referenced by nothing. | Keep deleted (`git rm` the upstream side of modify/delete conflicts); delete new upstream assets under this directory whose only consumer is the removed popup. | | `packages/app/src/desktop-menu.ts`, `packages/app/src/components/windows-app-menu.tsx` | App-menu label "OpenCode" → "CoDev"; "OpenCode Documentation" item → "CoDev Documentation" (href `opencode.ai/docs` stays — real URL); trigger `aria-label` "OpenCode menu" → "CoDev menu" (×2). Plus a **"Check for Updates..." item added to the Help menu**, it and a preceding separator both `platforms: ["windows"]`; `desktop-menu.ts` gains an exported `desktopMenuItemDisabled(item, {updaterStatus, commandDisabled})` — sibling to the existing `desktopMenuVisible` — which `WindowsAppMenu` now calls to honour `enabled: "updater"`. | Upstream only ever exposes the update check from the macOS app menu (`platforms: ["macos"]`), and `createMenu` early-returns on non-darwin while `WindowsAppMenu` filters on `"windows"` — so on Windows and Linux there was no menu path to it at all, leaving Settings → General as the only manual entry point. `WindowsAppMenu` renders for both Windows and Linux (`titlebar.tsx` gates on `windows() \|\| linux()`) but filters with the `"windows"` key, so `platforms: ["windows"]` reaches both without widening `DesktopMenuPlatform`. The separator carries the same restriction or macOS's Help menu ends on a stray divider. `enabled: "updater"` was silently ignored in this component — only `command` entries could disable — so a dev build would have shown an item that errors on click; the updater's own `disabled` status is the renderer-side equivalent of the main process's `UPDATER_ENABLED`. | Keep our labels (they live in the dicts now — see the localized desktop menu row), the Help entry, and `entryDisabled`; take upstream's surrounding menu changes. Rebrand any new "OpenCode" label/aria-label. If upstream adds its own non-macOS update entry, drop ours for theirs. Covered by `packages/app/src/desktop-menu.test.ts` ("offers an update check on every platform exactly once"), which will fail on a duplicate rather than let both ship, plus the `item disabled state` block over `desktopMenuItemDisabled`. Keep the disabled logic in `desktop-menu.ts` rather than inlining it back into the component — it is unreachable from a test there, since the app package has no component-render harness. | -| Localized desktop menu (fork items): `packages/app/src/desktop-menu.ts`, `packages/app/src/desktop-menu.test.ts`, `packages/app/src/i18n/{en,es}.ts`, `packages/app/src/i18n/desktop-native.ts`, `packages/desktop/src/main/menu.ts` | Since the v1.18.19 sync the menu rides upstream's native-translations pipeline (`labelKey: DesktopNativeKey`, macOS resolves through `nativeT` from the renderer-pushed bundle; the fork's earlier `main/i18n.ts` translator, `refreshMenuLanguage`, and the `store-set` hook were deleted as superseded). The fork's residue: two menu items — Log in/Configure (`desktop.menu.login`, command `gateway.apiKey.new`) and Upload Session Logs (`desktop.menu.uploadSessionLogs`, action `upload.sessions`) — in the macOS app menu and as `platforms: ["windows"]` twins in File; their keys added to `DESKTOP_NATIVE_ENGLISH`; `main/menu.ts` carries `uploadSessions` in `Deps` and forwards it to `runDesktopMenuAction`; and `desktop-menu.test.ts` asserts every `labelKey` resolves in en/es/vi (the vi dict is fork-only). | The two items are fork features (see the gateway key recovery and desktop session upload rows); the parity test is the tripwire that keeps every menu label translated in all three locales. | Keep the two items (and their `platforms` twins — Windows/Linux render no app menu), their registry keys, the `uploadSessions` dep, and the three-locale parity test; take upstream's surrounding menu changes. An upstream item arriving with a key missing from a fork locale fails the parity test — add the translation, don't drop the test. | +| Localized desktop menu (fork items): `packages/app/src/desktop-menu.ts`, `packages/app/src/desktop-menu.test.ts`, `packages/app/src/i18n/{en,es}.ts`, `packages/app/src/i18n/desktop-native.ts`, `packages/desktop/src/main/menu.ts` | Since the v1.18.19 sync the menu rides upstream's native-translations pipeline (`labelKey: DesktopNativeKey`, macOS resolves through `nativeT` from the renderer-pushed bundle; the fork's earlier `main/i18n.ts` translator, `refreshMenuLanguage`, and the `store-set` hook were deleted as superseded). The fork's residue: one menu item — Log in/Configure (`desktop.menu.login`, command `gateway.apiKey.new`) — in the macOS app menu and as a `platforms: ["windows"]` twin in File; its key added to `DESKTOP_NATIVE_ENGLISH`; and `desktop-menu.test.ts` asserts every `labelKey` resolves in en/es/vi (the vi dict is fork-only). | The item is a fork feature (see the gateway key recovery row); the parity test is the tripwire that keeps every menu label translated in all three locales. | Keep the item (and its `platforms` twin — Windows/Linux render no app menu), its registry key, and the three-locale parity test; take upstream's surrounding menu changes. An upstream item arriving with a key missing from a fork locale fails the parity test — add the translation, don't drop the test. | | Hardcoded product-string rebrand in TUI + web app: `packages/tui/src/app.tsx`, `packages/tui/src/routes/session/permission.tsx`, `packages/tui/src/attention.ts` (+ `packages/opencode/test/cli/cmd/tui/attention.test.ts`), `packages/app/src/wsl/settings-model.ts` (+ `.test.ts`), `packages/ui/src/components/favicon.tsx` | User-visible "OpenCode" strings outside the i18n dicts → fork brand: update-complete toast "Successfully updated to OpenCode v…" → "CoDev Code"; permission dialog "…until OpenCode is restarted" → "CoDev Code" (the sibling patterns line was already rebranded); OS-notification fallback title `opencode` → `codev` and built-in sound-pack display name "OpenCode Default" → "CoDev Code Default" (pack id `opencode.default` stays — internal identifier; the three default-title expectations in `packages/opencode/test/cli/cmd/tui/attention.test.ts` follow, while its explicit-title pass-through fixture stays `opencode`); WSL settings actions "Install/Update OpenCode" → "…CoDev" (+ test expectations/titles; fixture paths `/usr/local/bin/opencode` stay); PWA `apple-mobile-web-app-title` "OpenCode" → "CoDev Code" (matches the rebranded `site.webmanifest`, favicon-assets row) | These render in surfaces the fork ships (TUI + embedded web UI); the i18n-dict strings remain a known gap (UI branding bullet below). | Keep our strings; take upstream's surrounding changes. Rebrand any new hardcoded user-visible "OpenCode" string in these surfaces; leave identifiers, URLs, and fixture paths. | | **Universal macOS builds**: `packages/desktop/electron.vite.config.ts`, `packages/desktop/electron-builder.config.ts`, `packages/desktop/package.json` | Three changes that together make `electron-builder --mac --universal` (and `--x64`) produce a working app. (1) The `codev:node-pty-narrower` plugin and its `nodePtyPkg` constant are gone; the main build externalises `"@lydell/node-pty"` itself. (2) `@lydell/node-pty` moves from `devDependencies` to `dependencies`, so the wrapper is actually packed. (3) `mac.x64ArchFiles` is set to `**/app.asar.unpacked/node_modules/**`. | (1) Upstream rewrote the import to `@lydell/node-pty-${process.platform}-${process.arch}` — the **build host's** arch, resolved once, while `electron-vite build` runs before `electron-builder` and emits one bundle for whatever is packaged after it. On Apple Silicon that baked `darwin-arm64` in, so an `--x64` or `--universal` build shipped an Electron shell that starts and then cannot load its terminal backend. The wrapper's own entry does the same platform/arch lookup at run time, so externalising it picks the right binary on the machine that runs the app. (2) The wrapper was a devDependency and therefore never shipped: the old narrowing worked only because it pointed at the *platform* packages, which are `optionalDependencies`. Verified against a packaged build — `asar list` showed `@lydell/node-pty-darwin-*` present and `@lydell/node-pty/index.js` absent — so `require("@lydell/node-pty")` could not resolve. The bundled server (`packages/opencode/dist/node/node.js`) already required the bare wrapper, so this was a live bug in packaged builds before the vite change, not one it introduced. (3) All six platform packages ship in both the x64 and arm64 halves, so eight Mach-O files (node-pty's `pty.node` and its extension-less `spawn-helper`, `@parcel/watcher`, `msgpackr-extract`) are byte-identical across them; `@electron/universal` reads that as a mistake and refuses to merge. They are prebuilt binaries chosen at run time, not per-arch build output. | Keep all three. On conflict take upstream's files and re-apply. Do not reintroduce host-arch narrowing to silence a bundler warning, and do not move `@lydell/node-pty` back to `devDependencies` — the two are load-bearing together. Verify a packaged build rather than trusting the build log: `grep -o "@lydell/node-pty[a-z0-9-]*" out/main/index.js` must print the bare wrapper with no platform suffix, `asar list` must contain `/node_modules/@lydell/node-pty/index.js`, and the app must load its `pty.node` from inside the bundle when run from **outside** this repo — inside it, Node's module walk-up finds the repo's `node_modules` and masks a missing wrapper. | | Desktop app text + identifier rebrand: `packages/desktop/**` (README, `package.json`, `src/renderer/index.html`, `src/renderer/i18n/{en,es}.ts`, `src/main/{index,windows,server,logging}.ts`, `src/main/wsl/{sidecar,startup,servers,servers.test}.ts`, `src/main/install-state.test.ts`, `scripts/copy-metainfo.ts`, `electron-builder.config.ts`) plus the WSL/deep-link surfaces it shares with `packages/app` (`src/wsl/{types,settings-model,settings-model.test,settings,dialog-add-server}.ts(x)`, `src/i18n/{en,es}.ts`, `src/pages/layout/{deep-links,helpers.test}.ts`, `src/app.tsx`, `src/index.ts`) and `packages/desktop/src/{preload,renderer}` | Every user-visible "OpenCode" the desktop app renders → "CoDev" (originally "CoDev Code"; the "Code" suffix was dropped in 2026-08 once the app carried a Work section beside Code): crash dialogs, window ``, updater/CLI-installed i18n (en+es, with the CLI-command hint `'opencode'`→`'codev'`), the unpackaged app-name fallback `"OpenCode Dev"`, the AppStream metainfo (product name, description, `developer` → `<name>CoDev</name>` with the upstream `id="ly.anoma"` dropped, the three URLs repointed to the fork repo, upstream's lander screenshot removed), the OS deep-link handler display name, README, and `package.json` (`homepage` → the fork repo, `author` → `CoDev <minhnh49@viettel.com.vn>`; both were briefly deleted outright, which broke `deb`/`rpm` packaging — electron-builder derives the Debian `Maintainer` field from `author.email` and refuses to build fpm targets without `homepage`, so the two fields have to carry fork values rather than be absent. `AppImage`/`nsis`/`dmg` do not use fpm and never noticed. Values match the AppStream metainfo `scripts/copy-metainfo.ts` already emits: `<developer><name>CoDev</name>` and `<url type="homepage">`). Codebase identifiers renamed `Opencode`→`Codev`/`opencode`→`codev` (`WslOpencodeCheck`, `resolveWslOpencode`, `installWslOpencode`, `expectOpencodeVersion`, `releaseOpencodeResolve`, `checkOpencode`/`refreshOpencodeCheck*`/`setOpencodeCheck`, `opencodeChecks`, `wslOpencodeAction`, …) together with the internal wire values whose producer and consumer both ship here (`virtual:opencode-server`, the `opencode:{node-pty,virtual-server,copy-server}` vite plugin names, the `wsl-servers-install-opencode` IPC channel, the `install-opencode` job kind, the `__OPENCODE__` deep-link bridge global, the `opencode:deep-link` renderer event, the `opencode-onboarding-` temp dir, the `wsl.onboarding.installOpencode*` i18n keys), and the server-log collector path `<xdgData>/opencode/log` → `/codev/log` (a real bug fix — the fork CLI writes to `<xdgData>/codev/log`, `packages/core/src/global.ts` `app = "codev"`, so debug-log export was silently collecting nothing). Two stale product-named locals renamed to what they hold (`opencode`→`binary` in wsl/sidecar.ts, `opencode`→`checked` in the servers test). | The fork ships the desktop app, so every surface it shows and every identifier a maintainer reads should say codev. | Keep our strings + identifiers; take upstream's surrounding changes. PRESERVED deliberately, do not rename: `@opencode-ai/*` package ids, the `opencode` data dir in install-state, the basic-auth username `"opencode"` (must match the server default in `packages/server/src/auth.ts`), and the `../opencode/dist` build paths. The persisted store names were preserved here originally and are now rebranded with a migration — see the persisted-store-name row below. | @@ -330,7 +330,7 @@ from the squash message first, or use a plain merge. | Dialogs close on a button, never on an outside click: `packages/ui/src/components/dialog.tsx`, `packages/ui/src/components/dialog.css`, `packages/ui/src/v2/components/dialog-v2.tsx`, `packages/ui/src/v2/components/dialog-v2.css`, `packages/ui/src/components/image-preview.tsx`, plus the `*.stories.tsx` beside the two dialogs | All three `Kobalte.Content`s take `onInteractOutside={(e) => e.preventDefault()}`, which is what actually blocks Kobalte's dismissable layer (the context row above only removes the explicit overlay click). Both `Dialog`s gained `closeLabel?`/`hideClose?` and now render the `Kobalte.CloseButton` themselves, as the last child of their content, absolutely positioned at the content's top-right — v1 at 16px/20px (12px/12px when `data-no-header`), v2 at 14px/14px, both at `z-index: 30` so they clear the settings tab header's sticky `z-index: 10`. The button was removed from v2's `DialogHeader` (which loses its `hideClose`/`closeLabel` props and its `data-hide-close` attribute) and from v1's header `Switch`, whose `action` branch used to *replace* it; `action` now simply renders. Headers reserve the corner in `padding-right` (v1 52px, v2 42px) and v1's title takes `flex: 1`; both containers take a `min-height` (v1 52px, v2 48px) so a short `fit` dialog cannot clip the button. `aria-label` defaults to `ui.common.close` through `useI18n` — the first v2 component to use the UI i18n context, which has an English fallback. | Every dialog needed a visible way out once the backdrop stopped being one, including the dozen whose call sites passed `hideClose`. Pinning the button to the content corner rather than laying it out in the header is what lets header-less dialogs (settings, release notes, the command palettes) have one at all; keeping it inside `Kobalte.Content` keeps it inside the focus trap and the modal's pointer-events layer. | Keep the corner button and the `onInteractOutside` guards. Upstream owns the header/body/footer layout — take its changes and re-apply the `padding-right` reserve and the close button on top. If upstream adds its own close button to `DialogHeader`, drop ours from `Dialog` rather than shipping two. | | Dialog close buttons at the call sites: `packages/app/src/sandbox/{dialog-add,dialog-add-default,dialog-delete,dialog-mounts}.tsx`, `packages/app/src/wsl/{dialog-add-server,dialog-delete}.tsx`, `packages/app/src/components/{dialog-manage-models,dialog-connect-provider,dialog-select-model-unpaid-v2,session-actions-menu,settings-v2/dialog-server-v2}.tsx`, `packages/app/src/components/gateway/dialog-configure.tsx`, `packages/app/src/components/dialog-select-file.tsx`, `packages/app/src/components/dialog-command-palette-v2.css` | Dropped every `hideClose`/`closeLabel` prop from `DialogHeader` (the button moved to `Dialog`, and hiding it is no longer wanted anywhere it was hidden). `dialog-configure` passes `hideClose` on its `Dialog` instead — it is the configure gate, which the dialog stack also marks `persistent`, so a close button there would be inert. The two search-first dialogs reserve the corner the button now occupies: `dialog-select-file` via `[&_[data-slot=list-search-wrapper]]:pr-10`, the v2 command palette via `padding-right` on `.command-palette-v2-search`. | Fork-only sandbox/WSL/gateway dialogs aside, these are upstream files whose only change is a removed prop. | Take upstream's version of these call sites and re-drop `hideClose`. The `hideClose` on `dialog-configure` is fork-only and must stay. | -| Gateway key recovery: `packages/app/src/desktop-menu.ts`, `packages/app/src/i18n/{en,es}.ts`, `packages/desktop/src/preload/{index,types}.ts` | A "Log in/Configure" item (`menu.login` in the dicts) dispatching the fork-only `gateway.apiKey.new` command — in the macOS app menu under Settings, and in the Windows/Linux **File** menu (`platforms: ["windows"]`) under its Settings, the same split Settings itself uses since those builds render no app menu. The label names the sign-in, not the key: re-authenticating is what the item is reached for, minting a key is the side effect, and the session-upload toast's **Log in** button dispatches the same command. The command id stays `gateway.apiKey.new` — it is the wire name the desktop menus dispatch. i18n gains `command.gateway.apiKey.new` and `toast.sessionUpload.paused.{title,description,action.login}`. Preload gains the `gateway-sso-ensure-key`, `gateway-sso-consume-upload-nudge`, `gateway-sso-get-account` and `gateway-sso-logout` channels on the existing `gatewaySso` namespace — the last two back the sidebar's account entry (see the persistent app sidebar row): `getAccount` reads the signed-in identity from the shared auth file, `logout` is the hub-parity sign-out (`@codev/gateway`'s `logout()`, the port of `codevhub logout`: drop the SSO block, keep the api-key/config blocks, leave the force-login sentinel, revoke best-effort, clear the hub admin cookie) (the nudge rides this namespace because signing back in is its entire remedy, and `GatewaySsoPlatform` in the fork-only `packages/app/src/gateway/types.ts` is where it can be typed without touching upstream's `platform.tsx`). The logic is fork-only: `packages/desktop/src/main/gateway-sso/controller.ts` gains `ensureKey()`, the desktop twin of codev-hub's `ensureFreshGatewayKey` — validate the stored key against the gateway's `/key/info` (`@codev/gateway`'s already-exported `validateApiKey`, previously with no production caller), bounded by a 2.5s race so a slow gateway cannot delay startup; a definite 401/403 tries `silentSso` + `fetchApiKey` and re-saves the record with every other field intact, returning the credential for the renderer to merge into the local server's auth registry (only the renderer writes to the server); anything else is left alone. `packages/app/src/components/gateway/gateway-configure-gate.tsx` (fork-only) registers the command behind its existing driver election and runs the pre-flight once per window after sync settles, opening `DialogConfigure` only on `needs-login`; on mount it also consumes the session-upload sign-in nudge (see the desktop session upload row) and raises a persistent toast whose **Log in** action is that same `openConfigure`. `DialogConfigure` gains a `dismissable` prop driving both `setPersistent` and `hideClose`: the gate's copy stays trapped (the provider-setup screen the dialog rework deliberately exempts), while the two new entry points — the menu command and the pre-flight — are escapable, since a provider is configured in both cases and the dialog otherwise has no close button, no backdrop dismissal and no Escape. | Gateway keys expire on a schedule CoDev does not control, and nothing in the desktop app noticed: `DialogConfigure` had exactly one caller, the gate, which fires only when no provider is connected — and a dead key is still a connected provider, so every message 401'd with the sign-in flow unreachable. The silent re-mint mirrors what the hub already does for CLI launches, so the common case costs the user nothing. | Keep the menu item on both platforms (dropping the `platforms: ["windows"]` twin loses it entirely on Windows/Linux, which render no app menu) and keep the pre-flight's "only a definite rejection acts" rule — treating an unreachable gateway as a dead key would lock people out on a flaky network. Take upstream's surrounding menu/i18n/preload changes. Keep the "Log in/Configure" label paired with the toast action label — they name one flow. | +| Gateway key recovery: `packages/app/src/desktop-menu.ts`, `packages/app/src/i18n/{en,es}.ts`, `packages/desktop/src/preload/{index,types}.ts` | A "Log in/Configure" item (`menu.login` in the dicts) dispatching the fork-only `gateway.apiKey.new` command — in the macOS app menu under Settings, and in the Windows/Linux **File** menu (`platforms: ["windows"]`) under its Settings, the same split Settings itself uses since those builds render no app menu. The label names the sign-in, not the key: re-authenticating is what the item is reached for, minting a key is the side effect, and the usage-reporting toast's **Log in** button dispatches the same command. The command id stays `gateway.apiKey.new` — it is the wire name the desktop menus dispatch. i18n gains `command.gateway.apiKey.new` and `toast.usageReporting.paused.{title,description,action.login}`. Preload gains the `gateway-sso-ensure-key`, `gateway-sso-consume-signin-nudge`, `gateway-sso-get-account` and `gateway-sso-logout` channels on the existing `gatewaySso` namespace — the last two back the sidebar's account entry (see the persistent app sidebar row): `getAccount` reads the signed-in identity from the shared auth file, `logout` is the hub-parity sign-out (`@codev/gateway`'s `logout()`, the port of `codevhub logout`: drop the SSO block, keep the api-key/config blocks, leave the force-login sentinel, revoke best-effort, clear the hub admin cookie) (the nudge rides this namespace because signing back in is its entire remedy, and `GatewaySsoPlatform` in the fork-only `packages/app/src/gateway/types.ts` is where it can be typed without touching upstream's `platform.tsx`). The logic is fork-only: `packages/desktop/src/main/gateway-sso/controller.ts` gains `ensureKey()`, the desktop twin of codev-hub's `ensureFreshGatewayKey` — validate the stored key against the gateway's `/key/info` (`@codev/gateway`'s already-exported `validateApiKey`, previously with no production caller), bounded by a 2.5s race so a slow gateway cannot delay startup; a definite 401/403 tries `silentSso` + `fetchApiKey` and re-saves the record with every other field intact, returning the credential for the renderer to merge into the local server's auth registry (only the renderer writes to the server); anything else is left alone. `packages/app/src/components/gateway/gateway-configure-gate.tsx` (fork-only) registers the command behind its existing driver election and runs the pre-flight once per window after sync settles, opening `DialogConfigure` only on `needs-login`; on mount it also consumes the stats-push sign-in nudge (see the desktop stats push row) and raises a persistent toast whose **Log in** action is that same `openConfigure`. `DialogConfigure` gains a `dismissable` prop driving both `setPersistent` and `hideClose`: the gate's copy stays trapped (the provider-setup screen the dialog rework deliberately exempts), while the two new entry points — the menu command and the pre-flight — are escapable, since a provider is configured in both cases and the dialog otherwise has no close button, no backdrop dismissal and no Escape. | Gateway keys expire on a schedule CoDev does not control, and nothing in the desktop app noticed: `DialogConfigure` had exactly one caller, the gate, which fires only when no provider is connected — and a dead key is still a connected provider, so every message 401'd with the sign-in flow unreachable. The silent re-mint mirrors what the hub already does for CLI launches, so the common case costs the user nothing. | Keep the menu item on both platforms (dropping the `platforms: ["windows"]` twin loses it entirely on Windows/Linux, which render no app menu) and keep the pre-flight's "only a definite rejection acts" rule — treating an unreachable gateway as a dead key would lock people out on a flaky network. Take upstream's surrounding menu/i18n/preload changes. Keep the "Log in/Configure" label paired with the toast action label — they name one flow. | | `packages/core/src/filesystem/search.ts`, `packages/app/e2e/utils/mock-server.ts` | `fffLayer`'s `find` drops the worktree-root entry: fff ranks the root itself into an empty-query result, where it leads every list as a bare `/` — a mention naming no file — while the ripgrep fallback never emits it. The e2e mock server gains an `agents` config field (defaulting to the previous single primary agent) so a spec can supply subagents; the `@` menu hides primary agents, so the default list left nothing to rank files against. | Empty-query `@` menus (see the `@` autocomplete rows) made the root visible for the first time, at the top of the list. Both search layers should answer the same way. | Keep the root filter and the `agents` default; take upstream's surrounding changes. | diff --git a/packages/app/src/components/gateway/gateway-configure-gate.tsx b/packages/app/src/components/gateway/gateway-configure-gate.tsx index 54d45fae04..4ece0670ee 100644 --- a/packages/app/src/components/gateway/gateway-configure-gate.tsx +++ b/packages/app/src/components/gateway/gateway-configure-gate.tsx @@ -94,8 +94,8 @@ export function GatewayConfigureGate() { ], ) - // The background session upload stalls when the CoDev SSO session dies, and - // nothing else notices — the gateway api key it runs on keeps working. Main + // The launch-time stats push stalls when the CoDev SSO session dies, and + // nothing else notices — the gateway api key the app runs on keeps working. Main // reports that stall once per launch (index.ts consumes the flag), so this // fires in exactly one window even though the gate mounts in each. Persistent // rather than timed: a toast that auto-dismisses while the user is reading @@ -115,14 +115,14 @@ export function GatewayConfigureGate() { nudgeToast = undefined }), ) - void platform.gatewaySso?.consumeUploadSignInNudge().then((stalled) => { + void platform.gatewaySso?.consumeSignInNudge().then((stalled) => { if (!stalled) return nudgeToast = showToast({ persistent: true, icon: "cloud-upload", - title: language.t("toast.sessionUpload.paused.title"), - description: language.t("toast.sessionUpload.paused.description"), - actions: [{ label: language.t("toast.sessionUpload.paused.action.login"), onClick: openConfigure }], + title: language.t("toast.usageReporting.paused.title"), + description: language.t("toast.usageReporting.paused.description"), + actions: [{ label: language.t("toast.usageReporting.paused.action.login"), onClick: openConfigure }], }) }) }) diff --git a/packages/app/src/desktop-menu.ts b/packages/app/src/desktop-menu.ts index 550ce09f2e..aff0d0f34c 100644 --- a/packages/app/src/desktop-menu.ts +++ b/packages/app/src/desktop-menu.ts @@ -24,7 +24,6 @@ export type DesktopMenuAction = | "window.close" | "window.minimize" | "window.toggleMaximize" - | "upload.sessions" export type DesktopMenuRole = | "about" @@ -89,7 +88,6 @@ export const DESKTOP_MENU: DesktopMenu[] = [ }, { type: "item", labelKey: "desktop.menu.settings", command: "settings.open", accelerator: { macos: "Cmd+," } }, { type: "item", labelKey: "desktop.menu.login", command: "gateway.apiKey.new" }, - { type: "item", labelKey: "desktop.menu.uploadSessionLogs", action: "upload.sessions" }, { type: "item", labelKey: "desktop.menu.reloadWebview", action: "view.reload" }, { type: "item", labelKey: "desktop.menu.restart", action: "app.relaunch" }, { type: "item", labelKey: "desktop.menu.exportLogs", command: "logs.export" }, @@ -132,13 +130,6 @@ export const DESKTOP_MENU: DesktopMenu[] = [ command: "gateway.apiKey.new", platforms: ["windows"], }, - // macOS carries this in the app menu below its sign-in sibling. - { - type: "item", - labelKey: "desktop.menu.uploadSessionLogs", - action: "upload.sessions", - platforms: ["windows"], - }, { type: "item", labelKey: "desktop.menu.newWindow", diff --git a/packages/app/src/gateway/types.ts b/packages/app/src/gateway/types.ts index 67fd4a1f1d..eb697434cd 100644 --- a/packages/app/src/gateway/types.ts +++ b/packages/app/src/gateway/types.ts @@ -91,11 +91,11 @@ export type GatewaySsoPlatform = { // Validates the stored key, refreshing it silently when it is rejected and // the SSO session still allows it. Never prompts. ensureKey(): Promise<GatewayKeyCheck> - // Whether the background session upload stalled because the CoDev SSO + // Whether the launch-time stats push stalled because the CoDev SSO // session died, and this launch should say so. Lives on the SSO namespace // because signing back in is the whole remedy. Consumed: the main process // hands the stall to the first asker, so a second window stays quiet. - consumeUploadSignInNudge(): Promise<boolean> + consumeSignInNudge(): Promise<boolean> // Reads the signed-in identity from the shared auth file; null when logged // out. Callers refetch on subscribe events — login and logout both emit. getAccount(): Promise<GatewayAccount | null> diff --git a/packages/app/src/i18n/desktop-native.ts b/packages/app/src/i18n/desktop-native.ts index 445827a3ad..d0a4885c61 100644 --- a/packages/app/src/i18n/desktop-native.ts +++ b/packages/app/src/i18n/desktop-native.ts @@ -50,7 +50,6 @@ export const DESKTOP_NATIVE_ENGLISH = { "desktop.menu.checkForUpdates": "Check for Updates...", "desktop.menu.settings": "Settings", "desktop.menu.login": "Log in/Configure", - "desktop.menu.uploadSessionLogs": "Upload Session Logs", "desktop.menu.reloadWebview": "Reload Webview", "desktop.menu.restart": "Restart", "desktop.menu.exportLogs": "Export Logs...", diff --git a/packages/app/src/i18n/en.ts b/packages/app/src/i18n/en.ts index d1568be2c0..85baa7c5e3 100644 --- a/packages/app/src/i18n/en.ts +++ b/packages/app/src/i18n/en.ts @@ -708,10 +708,10 @@ export const dict = { "toast.update.action.updateNow": "Update now", "toast.update.action.later": "Later", - "toast.sessionUpload.paused.title": "Session upload is paused", - "toast.sessionUpload.paused.description": - "Your CoDev sign-in expired, so sessions are no longer uploaded for analysis. Log in again to resume — pending sessions upload automatically afterwards.", - "toast.sessionUpload.paused.action.login": "Log in", + "toast.usageReporting.paused.title": "Usage reporting is paused", + "toast.usageReporting.paused.description": + "Your CoDev sign-in expired, so session statistics are no longer sent for analysis. Log in again to resume — pending statistics are sent automatically afterwards.", + "toast.usageReporting.paused.action.login": "Log in", // The updater dialogs, which the desktop main process builds itself — see // desktop/src/main/updater.ts. Every one names the running version. @@ -745,7 +745,6 @@ export const dict = { "desktop.menu.checkForUpdates": "Check for Updates...", "desktop.menu.settings": "Settings", "desktop.menu.login": "Log in/Configure", - "desktop.menu.uploadSessionLogs": "Upload Session Logs", "desktop.menu.reloadWebview": "Reload Webview", "desktop.menu.restart": "Restart", "desktop.menu.exportLogs": "Export Logs...", diff --git a/packages/app/src/i18n/es.ts b/packages/app/src/i18n/es.ts index 01c3307d69..bc2a47cee1 100644 --- a/packages/app/src/i18n/es.ts +++ b/packages/app/src/i18n/es.ts @@ -9,7 +9,6 @@ export const dict = { "desktop.menu.checkForUpdates": "Buscar actualizaciones...", "desktop.menu.settings": "Ajustes", "desktop.menu.login": "Iniciar sesión/Configurar", - "desktop.menu.uploadSessionLogs": "Subir registros de sesión", "desktop.menu.reloadWebview": "Volver a cargar la vista web", "desktop.menu.restart": "Reiniciar", "desktop.menu.exportLogs": "Exportar registros...", @@ -808,10 +807,10 @@ export const dict = { "toast.update.action.updateNow": "Actualizar ahora", "toast.update.action.later": "Más tarde", - "toast.sessionUpload.paused.title": "La subida de sesiones está pausada", - "toast.sessionUpload.paused.description": - "Tu sesión de CoDev caducó, así que las sesiones ya no se suben para su análisis. Vuelve a iniciar sesión para reanudarla: las sesiones pendientes se subirán automáticamente después.", - "toast.sessionUpload.paused.action.login": "Iniciar sesión", + "toast.usageReporting.paused.title": "El envío de estadísticas de uso está pausado", + "toast.usageReporting.paused.description": + "Tu sesión de CoDev caducó, así que las estadísticas de las sesiones ya no se envían para su análisis. Vuelve a iniciar sesión para reanudarlo: las estadísticas pendientes se enviarán automáticamente después.", + "toast.usageReporting.paused.action.login": "Iniciar sesión", // The updater dialogs, which the desktop main process builds itself — see // desktop/src/main/updater.ts. Every one names the running version. diff --git a/packages/app/src/i18n/vi.ts b/packages/app/src/i18n/vi.ts index ba338c2396..13bdc8b626 100644 --- a/packages/app/src/i18n/vi.ts +++ b/packages/app/src/i18n/vi.ts @@ -671,10 +671,10 @@ export const dict = { "toast.update.action.updateNow": "Cập nhật ngay", "toast.update.action.later": "Để sau", - "toast.sessionUpload.paused.title": "Việc tải phiên lên đang tạm dừng", - "toast.sessionUpload.paused.description": - "Phiên đăng nhập CoDev của bạn đã hết hạn nên các phiên làm việc không còn được tải lên để phân tích. Hãy đăng nhập lại để tiếp tục — các phiên đang chờ sẽ tự động được tải lên sau đó.", - "toast.sessionUpload.paused.action.login": "Đăng nhập", + "toast.usageReporting.paused.title": "Việc gửi thống kê sử dụng đang tạm dừng", + "toast.usageReporting.paused.description": + "Phiên đăng nhập CoDev của bạn đã hết hạn nên thống kê của các phiên làm việc không còn được gửi để phân tích. Hãy đăng nhập lại để tiếp tục — các thống kê đang chờ sẽ tự động được gửi sau đó.", + "toast.usageReporting.paused.action.login": "Đăng nhập", // The updater dialogs, which the desktop main process builds itself — see // desktop/src/main/updater.ts. Every one names the running version. @@ -708,7 +708,6 @@ export const dict = { "desktop.menu.checkForUpdates": "Kiểm tra cập nhật...", "desktop.menu.settings": "Cài đặt", "desktop.menu.login": "Đăng nhập/Cấu hình", - "desktop.menu.uploadSessionLogs": "Tải session logs lên", "desktop.menu.reloadWebview": "Tải lại webview", "desktop.menu.toggleDeveloperTools": "Bật/tắt Developer Tools", "desktop.menu.restart": "Khởi động lại", diff --git a/packages/codev-upload/src/backend.ts b/packages/codev-upload/src/backend.ts new file mode 100644 index 0000000000..825a6cdf3d --- /dev/null +++ b/packages/codev-upload/src/backend.ts @@ -0,0 +1,32 @@ +import { type CodevConfig, loadCodevConfig } from "@codev/gateway" + +// Analysis-backend coordinates and the refresh-retry trigger, shared by the +// log uploader (upload.ts) and the stats push (push.ts). Both talk to the same +// Supabase project with the same exchanged session. + +export interface AnalysisBackendConfig { + url: string + anonKey: string +} + +// The "Missing supabase_" prefix is the on-disk key name and is exactly what +// isRefreshableError matches — the hub's contract, kept verbatim. +export function getAnalysisBackendConfig(): AnalysisBackendConfig { + const config: CodevConfig | null = loadCodevConfig() + if (!config) { + throw new Error("Missing supabase_url/supabase_anon_key in the CoDev auth file") + } + return { url: config.supabaseUrl.replace(/\/+$/, ""), anonKey: config.supabaseAnonKey } +} + +// Narrow refresh trigger: empty cache, or a 401/403 from the analysis backend +// or CoDev backend. Anchored to the `failed (NNN)` shape every thrower here +// and in codev-gateway emits (hub parity). +export function isRefreshableError(err: unknown): boolean { + const msg = err instanceof Error ? err.message : String(err) + if (msg.includes("Missing supabase_")) return true + const match = msg.match(/failed \((\d{3})\)/) + if (!match?.[1]) return false + const stat = Number.parseInt(match[1], 10) + return stat === 401 || stat === 403 +} diff --git a/packages/codev-upload/src/daemon.ts b/packages/codev-upload/src/daemon.ts index 0d70cf4b02..4a14a663ef 100644 --- a/packages/codev-upload/src/daemon.ts +++ b/packages/codev-upload/src/daemon.ts @@ -1,18 +1,17 @@ import { closeSync, mkdirSync, openSync, readFileSync, unlinkSync, writeFileSync } from "node:fs" import { dirname } from "node:path" import { silentSso } from "@codev/gateway" -import { logInfo, logWarn } from "./log" -import { autoUploadDisabled, type UploadStatus } from "./nudge" -import { lastUploadStatusPath, uploadLockPath } from "./paths" +import { statsLog } from "./log" +import { metricsDisabled, type StatsStatus } from "./nudge" +import { statsLockPath, statsStatusPath } from "./paths" +import { type PushOptions, type PushSummary, pushSessionStats, type StatsClient } from "./push" import type { ServerSource } from "./server-source" -import { runUpload, type UploadSummary } from "./upload" -// Background-upload runner: `codev debug upload` executes runStartupUpload; -// the launch-side spawn lives in spawn.ts and the nudge gates/status shape in -// nudge.ts (both TUI-importable subpaths). Lock/status semantics are the hub -// daemon's, byte for byte — the two share the lockfile, so agent and hub can -// never upload concurrently. See TODO(hub-retirement) in paths.ts for the -// shared-file exit plan. +// Stats push runners. runStartupStats is the launch-time entry: `codev debug +// stats` executes it (spawned detached by spawn.ts at every CLI launch), and +// the desktop sidecar calls it directly. runLockedPush is the part it shares +// with the interactive `codev upload`. The launch-side spawn lives in spawn.ts +// and the nudge gates/status shape in nudge.ts (both TUI-importable subpaths). const STALE_LOCK_MS = 60 * 60 * 1000 @@ -30,8 +29,10 @@ function isPidAlive(pid: number): boolean { } } +// Two launches at once (a TUI plus the desktop) would read the same store and +// race on the sent-hash file; the loser skips — the winner sends everything. export function tryAcquireLock(): boolean { - const path = uploadLockPath() + const path = statsLockPath() mkdirSync(dirname(path), { recursive: true, mode: 0o700 }) for (let attempt = 0; attempt < 2; attempt++) { try { @@ -64,15 +65,15 @@ export function tryAcquireLock(): boolean { export function releaseLock(): void { try { - unlinkSync(uploadLockPath()) + unlinkSync(statsLockPath()) } catch { // Already gone. } } -function writeStatusFile(status: UploadStatus): void { +function writeStatus(status: StatsStatus): void { try { - const path = lastUploadStatusPath() + const path = statsStatusPath() mkdirSync(dirname(path), { recursive: true, mode: 0o700 }) writeFileSync(path, JSON.stringify(status, null, 2)) } catch { @@ -80,71 +81,64 @@ 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(options: { servers?: ServerSource[] } = {}): Promise<number> { - const startedAt = new Date().toISOString() - if (autoUploadDisabled()) { - logInfo("auto-upload skipped: CODEV_DISABLE_AUTO_UPLOAD is set", { extra: { reason: "disabled" } }) - return 0 - } - const auth = await silentSso() - if (!auth) { - logInfo("auto-upload skipped: not signed in", { extra: { reason: "not-logged-in" } }) - // Record the stall: the agent keeps working on the gateway api key alone, - // so without this marker nothing would ever tell the user their sessions - // stopped uploading. The TUI reads it via nudge.ts and shows a sign-in - // toast. The other skips deliberately write nothing — disabled is an - // explicit opt-out, and lock-held means another upload is running and - // will write the real outcome. - writeStatusFile({ - ok: false, - skipped: "not-logged-in", - startedAt, - finishedAt: new Date().toISOString(), - }) - return 0 - } +// One push under the stats lock, with its outcome recorded for the sign-in +// nudge — so a successful manual push clears a stall just like a launch push. +// Returns null when another push holds the lock. +export async function runLockedPush(options: PushOptions): Promise<PushSummary | null> { if (!tryAcquireLock()) { - logInfo("auto-upload skipped: another upload is in progress", { extra: { reason: "lock-held" } }) - return 0 + statsLog.info("stats push skipped: another push is in progress", { extra: { reason: "lock-held" } }) + return null } + const startedAt = new Date().toISOString() + statsLog.info("starting stats push", { extra: { app: options.client.app, force: options.force ?? false } }) try { - logInfo("starting auto-upload") - 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", - extra: { uploaded: summary.uploaded, skipped: summary.skipped, failed: summary.failed }, - }) - return summary.failed > 0 ? 1 : 0 + const summary = await pushSessionStats(options) + writeStatus({ ok: true, startedAt, finishedAt: new Date().toISOString(), summary }) + statsLog.info( + summary.unsupported + ? "stats push: backend has no upload-session-stats endpoint yet" + : `stats push done: sent=${summary.sent} unchanged=${summary.unchanged}`, + { outcome: "success", extra: { ...summary } }, + ) + return summary } catch (err) { - logWarn("auto-upload failed", { outcome: "failure", err }) - writeStatusFile({ + statsLog.warn("stats push failed", { outcome: "failure", err }) + writeStatus({ ok: false, startedAt, finishedAt: new Date().toISOString(), error: err instanceof Error ? err.message : String(err), }) - return 1 + throw err } finally { releaseLock() } } -function writeStatus(startedAt: string, summary: UploadSummary): void { - writeStatusFile({ - ok: summary.failed === 0, - startedAt, - finishedAt: new Date().toISOString(), - summary: { - outDir: summary.outDir, - found: summary.found, - uploaded: summary.uploaded, - skipped: summary.skipped, - failed: summary.failed, - }, - errors: summary.errors.length > 0 ? summary.errors : undefined, - }) +// Gates in order: opt-out env, signed-in (silent SSO — refreshes an expired +// session, never opens a browser), then the lock. Returns the process exit +// code; never throws. +export async function runStartupStats(options: { client: StatsClient; servers?: ServerSource[] }): Promise<number> { + const startedAt = new Date().toISOString() + if (metricsDisabled()) { + statsLog.info("stats push skipped: CODEV_DISABLE_METRICS is set", { extra: { reason: "disabled" } }) + return 0 + } + const auth = await silentSso() + if (!auth) { + statsLog.info("stats push skipped: not signed in", { extra: { reason: "not-logged-in" } }) + // Record the stall: the agent keeps working on the gateway api key alone, + // so without this marker nothing would ever tell the user their usage + // stopped reaching the backend. The TUI and desktop read it via nudge.ts + // and show a sign-in toast. The other skips deliberately write nothing — + // disabled is an explicit opt-out, and lock-held means another push is + // running and will write the real outcome. + writeStatus({ ok: false, skipped: "not-logged-in", startedAt, finishedAt: new Date().toISOString() }) + return 0 + } + // Lock-held returns null, which is fine: the other push writes the outcome. + return runLockedPush({ client: options.client, servers: options.servers, getAuth: async () => auth }).then( + () => 0, + () => 1, + ) } diff --git a/packages/codev-upload/src/db.ts b/packages/codev-upload/src/db.ts index 3530133101..c6603552a7 100644 --- a/packages/codev-upload/src/db.ts +++ b/packages/codev-upload/src/db.ts @@ -1,7 +1,8 @@ import { existsSync } from "node:fs" import { partToMarkdown, safeParse, unixToISO } from "./parts" import { openSessionDb, type SessionDb } from "#sqlite" -import { sessionDbPath } from "./paths" +import { projectFolderName, sessionDbPath } from "./paths" +import { buildSessionStats, type SessionStats, type StatsMessage } from "./stats" import type { Message, Session } from "./types" // Session-store reader: a port of the hub's OpenCode provider @@ -134,32 +135,24 @@ function buildSession(db: SessionDb, row: SessionRow, hasParentId: boolean, proj } // Every top-level session in the store, each tagged with the directory that -// decides its per-project export folder: the owning project's worktree, or the -// session's own directory for VCS-less ("global") sessions. +// decides its per-project export folder. export function readAllSessions(dbPath = sessionDbPath()): Session[] { if (!existsSync(dbPath)) return [] const db = openSessionDb(dbPath) try { - const worktrees = new Map<string, string>() - for (const project of db - .prepare<{ id: string; worktree: string | null }>("SELECT id, worktree FROM project") - .all()) { - if (project.worktree) worktrees.set(project.id, project.worktree) - } - + const worktrees = readWorktrees(db) const hasParentId = sessionHasParentId(db) - const cols = hasParentId - ? "id, project_id, title, directory, parent_id, time_created, time_updated" - : "id, project_id, title, directory, time_created, time_updated" // parent_id IS NULL drops normal-mode subagent sessions — they're folded // into their spawning session. Schemas without the column export everything // rather than throwing (hub parity). const parentFilter = hasParentId ? " WHERE parent_id IS NULL" : "" - const rows = db.prepare<SessionRow>(`SELECT ${cols} FROM session${parentFilter} ORDER BY time_created`).all() + const rows = db + .prepare<SessionRow>(`SELECT ${sessionColumns(hasParentId)} FROM session${parentFilter} ORDER BY time_created`) + .all() const sessions: Session[] = [] for (const row of rows) { - const projectDir = row.project_id === "global" ? row.directory : (worktrees.get(row.project_id) ?? row.directory) + const projectDir = sessionProjectDir(row, worktrees) if (!projectDir) continue try { const session = buildSession(db, row, hasParentId, projectDir) @@ -174,6 +167,91 @@ export function readAllSessions(dbPath = sessionDbPath()): Session[] { } } +// Statistics for every top-level session in the store, subagent descendants +// folded in. Same session selection and project keying as readAllSessions, so +// a session's stats and its markdown land under one project. +export function readAllSessionStats(dbPath = sessionDbPath()): SessionStats[] { + if (!existsSync(dbPath)) return [] + const db = openSessionDb(dbPath) + try { + const worktrees = readWorktrees(db) + const hasParentId = sessionHasParentId(db) + const rows = db + .prepare<SessionRow>(`SELECT ${sessionColumns(hasParentId)} FROM session ORDER BY time_created`) + .all() + const childrenOf = new Map<string, string[]>() + for (const row of rows) { + if (!row.parent_id) continue + childrenOf.set(row.parent_id, [...(childrenOf.get(row.parent_id) ?? []), row.id]) + } + const descendantsOf = (id: string): string[] => + (childrenOf.get(id) ?? []).flatMap((child) => [child, ...descendantsOf(child)]) + + return rows.flatMap((row) => { + if (row.parent_id) return [] + const projectDir = sessionProjectDir(row, worktrees) + if (!projectDir) return [] + try { + const stats = buildSessionStats({ + id: row.id, + origin: "local", + project: projectFolderName(projectDir), + messages: readStatsMessages(db, row.id), + descendants: descendantsOf(row.id).map((id) => readStatsMessages(db, id)), + }) + return stats ? [stats] : [] + } catch { + // Skip malformed sessions, like the markdown reader. + return [] + } + }) + } finally { + db.close() + } +} + +// One query per session for its parts, grouped onto their messages — the +// markdown reader's per-message queries would multiply across a whole store. +function readStatsMessages(db: SessionDb, sessionId: string): StatsMessage[] { + const parts = new Map<string, unknown[]>() + for (const row of db + .prepare<{ + message_id: string + data: string + }>("SELECT message_id, data FROM part WHERE session_id = ? ORDER BY time_created") + .all(sessionId)) { + const part = safeParse<unknown>(row.data) + if (part) parts.set(row.message_id, [...(parts.get(row.message_id) ?? []), part]) + } + return readMessages(db, sessionId).flatMap((msg) => { + const info = safeParse<StatsMessage["info"]>(msg.data) + if (!info) return [] + // Rows written before the message JSON carried time fall back to the row's. + return [{ info: { ...info, time: info.time ?? { created: msg.time_created } }, parts: parts.get(msg.id) ?? [] }] + }) +} + +function readWorktrees(db: SessionDb): Map<string, string> { + const worktrees = new Map<string, string>() + for (const project of db.prepare<{ id: string; worktree: string | null }>("SELECT id, worktree FROM project").all()) { + if (project.worktree) worktrees.set(project.id, project.worktree) + } + return worktrees +} + +function sessionColumns(hasParentId: boolean): string { + return hasParentId + ? "id, project_id, title, directory, parent_id, time_created, time_updated" + : "id, project_id, title, directory, time_created, time_updated" +} + +// The owning project's worktree, or the session's own directory for VCS-less +// ("global") sessions. +function sessionProjectDir(row: SessionRow, worktrees: Map<string, string>): string | null { + if (row.project_id === "global") return row.directory + return worktrees.get(row.project_id) ?? row.directory +} + // 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) diff --git a/packages/codev-upload/src/export.ts b/packages/codev-upload/src/export.ts index 8b4af677e0..169349b574 100644 --- a/packages/codev-upload/src/export.ts +++ b/packages/codev-upload/src/export.ts @@ -5,7 +5,6 @@ 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" @@ -15,29 +14,20 @@ 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 into the hub's per-project layout: // <agent-logs>/<project>/codev-code/<timestamp>-<slug>.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<ExportSummary> { +// statistics.json per project dir, from the local sqlite store. Unlike the hub +// (which exports only the launch cwd's project), the standalone uploader owns +// the whole store. Desktop sandbox (work) sessions are not exported: their +// stores are only reachable through the desktop, and logs upload solely via +// `codev upload --logs`. +export async function runExport(onStatus: StatusReporter = () => {}): Promise<ExportSummary> { const dbPath = sessionDbPath() - const summary: ExportSummary = { outDir: agentLogsDir(), exported: 0, projects: 0, dbPath, serverErrors: [] } + const summary: ExportSummary = { outDir: agentLogsDir(), exported: 0, projects: 0, dbPath } onStatus("Reading codev sessions...") const sessions: Session[] = [] @@ -45,24 +35,10 @@ export async function runExport( sessions.push(...readAllSessions(dbPath)) } catch (err) { // A store we can't read exports nothing from it this run; the next run - // retries. Sandbox sources below still export. + // retries. logWarn("session store read failed", { err, extra: { db_path: dbPath } }) } - 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 @@ -95,7 +71,7 @@ export async function runExport( logInfo(`exported ${summary.exported} session(s) across ${summary.projects} project(s)`, { outcome: "success", - extra: { exported: summary.exported, projects: summary.projects, server_errors: summary.serverErrors.length }, + extra: { exported: summary.exported, projects: summary.projects }, }) return summary } diff --git a/packages/codev-upload/src/index.ts b/packages/codev-upload/src/index.ts index 68674cb72b..8285752cce 100644 --- a/packages/codev-upload/src/index.ts +++ b/packages/codev-upload/src/index.ts @@ -1,7 +1,9 @@ -export { runStartupUpload } from "./daemon" +export { isRefreshableError } from "./backend" +export { runLockedPush, runStartupStats } 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" +export { metricsDisabled, readLastStatsStatus, shouldNudgeSignIn, type StatsStatus } from "./nudge" +export { pushSessionStats, type PushSummary, type StatsClient } from "./push" +export { type ServerSource } from "./server-source" +export { spawnStatsDaemon } from "./spawn" +export { runUpload, type UploadOptions, type UploadSummary } from "./upload" diff --git a/packages/codev-upload/src/log.ts b/packages/codev-upload/src/log.ts index 6375ed47d0..1c69142f78 100644 --- a/packages/codev-upload/src/log.ts +++ b/packages/codev-upload/src/log.ts @@ -3,8 +3,10 @@ import { appendFileSync, mkdirSync, readdirSync, rmSync } from "node:fs" import { join } from "node:path" import { uploadLogDir } from "./paths" -// The uploader's own NDJSON diagnostics — what `codev upload --logs` prints. -// Ground rules ported from the hub's lib/log.ts: (1) logging can never break +// The uploader's own NDJSON diagnostics — log uploads in upload-*.ndjson, +// stats pushes in stats-*.ndjson — what `codev upload [--logs] --diagnostics` +// prints. Kept apart so each mode's diagnostics show its own last run rather +// than whichever stats push ran at the most recent launch. Ground rules ported from the hub's lib/log.ts: (1) logging can never break // or block an upload — every disk touch is wrapped and degrades to a no-op; // (2) no secrets on disk — the serialized line is scrubbed before writing; // (3) nothing goes to stdout/stderr from here. @@ -39,21 +41,23 @@ export function redactSecrets(text: string): string { return out } +export type LogCommand = "upload" | "stats" + export interface UploadLogDoc { "@timestamp": string log: { level: string } message: string trace: { id: string } - codev: { command: "upload"; parent_trace_id?: string; [key: string]: unknown } + codev: { command: LogCommand; parent_trace_id?: string; [key: string]: unknown } event?: { outcome?: string } error?: { message?: string } } -function fileFor(date: Date): string { +function fileFor(command: LogCommand, date: Date): string { const pad = (n: number) => String(n).padStart(2, "0") return join( uploadLogDir(), - `upload-${date.getUTCFullYear()}${pad(date.getUTCMonth() + 1)}${pad(date.getUTCDate())}.ndjson`, + `${command}-${date.getUTCFullYear()}${pad(date.getUTCMonth() + 1)}${pad(date.getUTCDate())}.ndjson`, ) } @@ -69,12 +73,12 @@ function ensureInit(): void { } } -// Drop upload-*.ndjson files older than the retention window. Only this -// package's own file pattern is ever touched. +// Drop upload-*/stats-*.ndjson files older than the retention window. Only +// this package's own file patterns are ever touched. function prune(): void { const cutoff = Date.now() - MAX_AGE_DAYS * 24 * 60 * 60 * 1000 for (const name of readdirSync(uploadLogDir())) { - const m = name.match(/^upload-(\d{4})(\d{2})(\d{2})\.ndjson$/) + const m = name.match(/^(?:upload|stats)-(\d{4})(\d{2})(\d{2})\.ndjson$/) if (!m) continue const stamp = Date.UTC(Number(m[1]), Number(m[2]) - 1, Number(m[3])) if (stamp < cutoff) { @@ -88,6 +92,7 @@ function prune(): void { } function write( + command: LogCommand, level: string, message: string, fields?: { outcome?: string; err?: unknown; extra?: Record<string, unknown> }, @@ -99,27 +104,33 @@ function write( log: { level }, message, trace: { id: traceId }, - codev: { command: "upload", ...(parentTraceId ? { parent_trace_id: parentTraceId } : {}), ...fields?.extra }, + codev: { command, ...(parentTraceId ? { parent_trace_id: parentTraceId } : {}), ...fields?.extra }, ...(fields?.outcome ? { event: { outcome: fields.outcome } } : {}), ...(fields?.err !== undefined ? { error: { message: fields.err instanceof Error ? fields.err.message : String(fields.err) } } : {}), } - appendFileSync(fileFor(new Date()), `${redactSecrets(JSON.stringify(doc))}\n`) + appendFileSync(fileFor(command, new Date()), `${redactSecrets(JSON.stringify(doc))}\n`) } catch { // Logging must never break the upload. } } -export function logDebug(message: string, fields?: Parameters<typeof write>[2]): void { - write("debug", message, fields) -} -export function logInfo(message: string, fields?: Parameters<typeof write>[2]): void { - write("info", message, fields) -} -export function logWarn(message: string, fields?: Parameters<typeof write>[2]): void { - write("warn", message, fields) -} -export function logError(message: string, fields?: Parameters<typeof write>[2]): void { - write("error", message, fields) +type LogFields = Parameters<typeof write>[3] + +function createLogger(command: LogCommand) { + return { + debug: (message: string, fields?: LogFields) => write(command, "debug", message, fields), + info: (message: string, fields?: LogFields) => write(command, "info", message, fields), + warn: (message: string, fields?: LogFields) => write(command, "warn", message, fields), + error: (message: string, fields?: LogFields) => write(command, "error", message, fields), + } } + +const uploadLog = createLogger("upload") +export const logDebug = uploadLog.debug +export const logInfo = uploadLog.info +export const logWarn = uploadLog.warn +export const logError = uploadLog.error + +export const statsLog = createLogger("stats") diff --git a/packages/codev-upload/src/logs.ts b/packages/codev-upload/src/logs.ts index fc3c9d6f6e..86131d17d6 100644 --- a/packages/codev-upload/src/logs.ts +++ b/packages/codev-upload/src/logs.ts @@ -1,18 +1,19 @@ import { readdirSync, readFileSync } from "node:fs" import { join } from "node:path" -import { currentTraceId, type UploadLogDoc } from "./log" +import { currentTraceId, type LogCommand, type UploadLogDoc } from "./log" import { uploadLogDir } from "./paths" -// Reader side of the uploader's diagnostics (`codev upload --logs`), modeled +// Reader side of the uploader's diagnostics (`codev upload --diagnostics` for +// stats pushes, `codev upload --logs --diagnostics` for log uploads), modeled // on `codevhub logs`: print the most recent run, where a run is every document // sharing one trace id — plus any child runs pointing at it via -// parent_trace_id (the detached daemon). Plain text out; by the time someone +// parent_trace_id (the detached launch-time push). Plain text out; by the time someone // reaches for this something already went wrong, so no ceremony. -function listLogFiles(): string[] { +function listLogFiles(command: LogCommand): string[] { try { return readdirSync(uploadLogDir()) - .filter((name) => /^upload-\d{8}\.ndjson$/.test(name)) + .filter((name) => name.startsWith(`${command}-`) && /-\d{8}\.ndjson$/.test(name)) .sort() .map((name) => join(uploadLogDir(), name)) } catch { @@ -20,9 +21,9 @@ function listLogFiles(): string[] { } } -function readDocs(): UploadLogDoc[] { +function readDocs(command: LogCommand): UploadLogDoc[] { const docs: UploadLogDoc[] = [] - for (const file of listLogFiles()) { + for (const file of listLogFiles(command)) { let raw: string try { raw = readFileSync(file, "utf-8") @@ -49,11 +50,11 @@ function formatDoc(doc: UploadLogDoc): string { return `${ts} ${level} ${doc.message ?? ""}${outcome}${error}` } -// Renders the most recent upload run's log lines. Returns null when there is -// nothing to show. The current process's own trace is excluded — `codev -// upload --logs` must show the last real run, not itself. -export function renderLastRun(): string | null { - const docs = readDocs().filter((d) => d.trace?.id && d.trace.id !== currentTraceId()) +// Renders the most recent run's log lines for one kind of run. Returns null +// when there is nothing to show. The current process's own trace is excluded — +// `--diagnostics` must show the last real run, not itself. +export function renderLastRun(command: LogCommand): string | null { + const docs = readDocs(command).filter((d) => d.trace?.id && d.trace.id !== currentTraceId()) if (docs.length === 0) return null const lastTrace = docs[docs.length - 1].trace.id // The run is the last trace plus anything in its parent/child chain. diff --git a/packages/codev-upload/src/nudge.ts b/packages/codev-upload/src/nudge.ts index 49b4be941b..8761db8921 100644 --- a/packages/codev-upload/src/nudge.ts +++ b/packages/codev-upload/src/nudge.ts @@ -1,66 +1,57 @@ import { readFileSync } from "node:fs" import { GATEWAY_PROVIDER, LEGACY_GATEWAY_PROVIDER_IDS, loadApiKey } from "@codev/gateway" -import { lastUploadStatusPath } from "./paths" +import { statsStatusPath } from "./paths" +import type { PushSummary } from "./push" -// The sign-in nudge: the auto-upload daemon needs a live CoDev SSO session, -// but the agent itself only needs the gateway api key — so a machine whose -// session died keeps working while its uploads silently stop, forever. -// The daemon records that stall in last-upload.json; the TUI asks this module -// at startup whether to surface it. Kept free of bun:sqlite/child_process -// imports so the TUI can import it via the `@codev/upload/nudge` subpath -// without dragging the whole uploader into its module graph. +// The sign-in nudge: the launch-time stats push needs a live CoDev SSO +// session, but the agent itself only needs the gateway api key — so a machine +// whose session died keeps working while its statistics silently stop +// reaching the analysis backend, forever. The push records that stall in its +// status file; the TUI and desktop ask this module at startup whether to +// surface it. Kept free of bun:sqlite/child_process imports so the TUI can +// import it via the `@codev/upload/nudge` subpath without dragging the whole +// uploader into its module graph. -// Opt-out for the automatic startup upload. The interactive `codev upload` -// deliberately ignores it — typing the command is the opt-in. -export function autoUploadDisabled(env: NodeJS.ProcessEnv = process.env): boolean { - const value = env.CODEV_DISABLE_AUTO_UPLOAD +// Opt-out for the launch-time stats push. +export function metricsDisabled(env: NodeJS.ProcessEnv = process.env): boolean { + const value = env.CODEV_DISABLE_METRICS return value === "1" || value === "true" } -// Shape of ~/.codev-hub/last-upload.json. The summary/error fields are the -// hub's; `skipped` is this package's addition — the hub never writes it, and -// a hub-written file (no `skipped`) reads as "not stalled", which is right: -// the hub's daemon ran, so uploads flow. -export interface UploadStatus { +// Shape of the stats status file (paths.ts statsStatusPath). +export interface StatsStatus { ok: boolean startedAt: string finishedAt: string skipped?: "not-logged-in" - summary?: { - outDir: string - found: number - uploaded: number - skipped: number - failed: number - } - errors?: { file: string; message: string }[] + summary?: PushSummary error?: string } -export function readLastUploadStatus(): UploadStatus | null { +export function readLastStatsStatus(): StatsStatus | null { try { - return JSON.parse(readFileSync(lastUploadStatusPath(), "utf-8")) as UploadStatus + return JSON.parse(readFileSync(statsStatusPath(), "utf-8")) as StatsStatus } catch { return null } } -// Whether the TUI should show the "sign in to resume uploads" toast at this -// launch. Deliberately unthrottled — the toast repeats every startup until -// the user signs back in (or opts out): uploads are near-mandatory for the -// internal rollout, so steady pressure beats a daily reminder. Conditions, -// in order: -// - auto-upload not opted out (a user who disabled it asked for silence); +// Whether to show the "sign in to resume" toast at this launch. Deliberately +// unthrottled — the toast repeats every startup until the user signs back in +// (or opts out): usage reporting is near-mandatory for the internal rollout, +// so steady pressure beats a daily reminder. Conditions, in order: +// - the stats push is not opted out (a user who disabled it asked for +// silence); // - a gateway api key exists AND is SSO-origin (no provider id, or the // AIGW identity, current or pre-rename). A manually-named provider may // belong to someone with no CoDev SSO account at all — nagging them to // sign in to something they can't would be wrong; -// - the last daemon run recorded the not-logged-in stall. +// - the last push recorded the not-logged-in stall. export function shouldNudgeSignIn(env: NodeJS.ProcessEnv = process.env): boolean { - if (autoUploadDisabled(env)) return false + if (metricsDisabled(env)) return false const key = loadApiKey() if (!key?.apiKey) return false if (key.providerId && key.providerId !== GATEWAY_PROVIDER.id && !LEGACY_GATEWAY_PROVIDER_IDS.includes(key.providerId)) return false - return readLastUploadStatus()?.skipped === "not-logged-in" + return readLastStatsStatus()?.skipped === "not-logged-in" } diff --git a/packages/codev-upload/src/paths.ts b/packages/codev-upload/src/paths.ts index 1677ab34a6..7442c35e8b 100644 --- a/packages/codev-upload/src/paths.ts +++ b/packages/codev-upload/src/paths.ts @@ -11,8 +11,7 @@ import type { Session } from "./types" // old absolute paths, so the uploader must fall back to the legacy-path // lookup and send that row's id as previousVersionId once per file — // otherwise every user's history re-uploads as duplicates. -// 3. Move upload.lock and last-upload.json to ~/.codev/. -// 4. Make ~/.codev/auth.json primary in codev-gateway (fallback exists). +// 3. Make ~/.codev/auth.json primary in codev-gateway (fallback exists). // Blocked on: hub retired (or its codev-code provider dropped) — migrating // while the hub still uploads would re-diverge the paths. // Tracking: https://github.com/quickbeard/codev-code/issues/310 @@ -31,18 +30,6 @@ export function agentLogsDir(): string { return join(home(), ".codev-hub", "agent-logs") } -// Cross-process mutual exclusion with the hub's daemon: same file, same -// pid/staleness contract (hub src/lib/upload.ts). See TODO(hub-retirement). -export function uploadLockPath(): string { - return join(home(), ".codev-hub", "upload.lock") -} - -// Outcome snapshot of the most recent upload run, hub shape. See -// TODO(hub-retirement). -export function lastUploadStatusPath(): string { - return join(home(), ".codev-hub", "last-upload.json") -} - // The agent's own data home (hub provider parity: XDG_DATA_HOME or // ~/.local/share, app dir "codev"). Deliberately not core's Global.Path — // importing it would pull Effect and its top-level mkdirs into a package the @@ -60,13 +47,31 @@ export function sessionDbPath(): string { return join(dataDir(), "opencode.db") } -// The uploader's own NDJSON diagnostics (`codev upload --logs`). Lives in the +// The uploader's own NDJSON diagnostics (`codev upload --diagnostics`). Lives in the // AGENT's log home, not ~/.codev-hub/logs — that file set belongs to the hub's // writer, and sharing it would add another retirement dependency for nothing. export function uploadLogDir(): string { return join(dataDir(), "log") } +// The launch-time stats push's own state — deliberately not the hub-shared +// upload.lock/last-upload.json under ~/.codev-hub: the push is agent-only and +// never uploads logs, so it has no reason to coordinate with the hub. +export function statsLockPath(): string { + return join(dataDir(), "stats.lock") +} + +// Outcome of the most recent stats push — what the sign-in nudge reads. +export function statsStatusPath(): string { + return join(dataDir(), "stats-status.json") +} + +// session id -> sha256 of the record last accepted by the backend, so a push +// only sends sessions whose statistics changed. +export function statsSentPath(): string { + return join(dataDir(), "stats-sent.json") +} + // Maps a project directory to its per-project export subfolder. Byte-for-byte // port of the hub's projectFolderName (src/lib/paths.ts) — the folder name is // half of the absolute local_file_path the backend dedups on. diff --git a/packages/codev-upload/src/push.ts b/packages/codev-upload/src/push.ts new file mode 100644 index 0000000000..9485faaa32 --- /dev/null +++ b/packages/codev-upload/src/push.ts @@ -0,0 +1,142 @@ +import { createHash } from "node:crypto" +import { mkdirSync, readFileSync, writeFileSync } from "node:fs" +import { dirname } from "node:path" +import { type AuthData, fetchCodevConfig, saveCodevConfig, supabaseExchange } from "@codev/gateway" +import { getAnalysisBackendConfig, isRefreshableError } from "./backend" +import { readCodevCodeBaseUrl } from "./config" +import { readAllSessionStats } from "./db" +import { statsLog } from "./log" +import { sessionDbPath, statsSentPath } from "./paths" +import { readSessionStatsFromServer, type ServerSource } from "./server-source" +import { STATS_VERSION, type SessionStats } from "./stats" + +// The stats push: every launch of CoDev Code and CoDev Desktop, and a manual +// `codev upload`, sends the statistics of sessions that changed since the last +// accepted push to the analysis backend's upload-session-stats function, which +// upserts one row per session. Never uploads logs — that is +// `codev upload --logs` (upload.ts). + +export interface StatsClient { + app: "cli" | "desktop" + version: string +} + +export interface PushOptions { + client: StatsClient + // Resolves the SSO session; null aborts with a sign-in error. + getAuth: () => Promise<AuthData | null> + // Running sandbox servers to read work sessions from, in addition to the + // local store (desktop only — see server-source.ts). + servers?: ServerSource[] + // Resend every session even when its statistics are unchanged. Wired to + // `codev upload --force`; the backend's upsert makes it harmless. + force?: boolean + onStatus?: (message: string) => void + // Test seam (codev-gateway FetchOptions convention). + fetchImpl?: typeof fetch +} + +export interface PushSummary { + found: number + sent: number + unchanged: number + // The backend does not have the endpoint yet (404): nothing was sent and + // the next launch tries again. + unsupported?: boolean +} + +const BATCH_SIZE = 200 +const REQUEST_TIMEOUT_MS = 60_000 + +export async function pushSessionStats(options: PushOptions): Promise<PushSummary> { + const fetchImpl = options.fetchImpl ?? fetch + options.onStatus?.("Reading sessions...") + const records = await collectStats(options.servers ?? [], fetchImpl) + const sent = options.force ? {} : readSentHashes() + const pending = records.flatMap((record) => { + const hash = createHash("sha256").update(JSON.stringify(record)).digest("hex") + return sent[record.session_id] === hash ? [] : [{ record, hash }] + }) + const summary: PushSummary = { found: records.length, sent: 0, unchanged: records.length - pending.length } + if (pending.length === 0) return summary + + const auth = await options.getAuth() + if (!auth) throw new Error("Not signed in. Run `codev upload` interactively to sign in first.") + + // Same refresh contract as the log upload: a missing cached config or a + // 401/403 refreshes the CoDev config and retries exactly once. Batches the + // first attempt already landed are recorded in `sent` and not resent. + const send = async () => { + const config = getAnalysisBackendConfig() + options.onStatus?.("Exchanging SSO session for analysis backend session...") + const session = await supabaseExchange(auth.access_token, { fetchImpl }) + const unsent = pending.filter((item) => sent[item.record.session_id] !== item.hash) + for (let start = 0; start < unsent.length; start += BATCH_SIZE) { + const batch = unsent.slice(start, start + BATCH_SIZE) + options.onStatus?.(`Sending ${start + batch.length}/${unsent.length} session(s)...`) + const res = await fetchImpl(`${config.url}/functions/v1/upload-session-stats`, { + method: "POST", + headers: { Authorization: `Bearer ${session.access_token}`, "Content-Type": "application/json" }, + body: JSON.stringify({ + v: STATS_VERSION, + client: { ...options.client, base_url: readCodevCodeBaseUrl() }, + sessions: batch.map((item) => item.record), + }), + signal: AbortSignal.timeout(REQUEST_TIMEOUT_MS), + }) + if (res.status === 404) { + summary.unsupported = true + return + } + if (!res.ok) throw new Error(`upload-session-stats failed (${res.status}): ${await res.text()}`) + for (const item of batch) sent[item.record.session_id] = item.hash + writeSentHashes(sent) + summary.sent += batch.length + } + } + + try { + await send() + } catch (err) { + if (!isRefreshableError(err)) throw err + statsLog.warn("refreshable stats push error; refreshing config and retrying", { err }) + saveCodevConfig(await fetchCodevConfig(auth.access_token, { fetchImpl })) + await send() + } + return summary +} + +// Local store first, then each sandbox. A sandbox that cannot be read (went +// down mid-run) is skipped — its sessions push on the next launch. +async function collectStats(servers: ServerSource[], fetchImpl: typeof fetch): Promise<SessionStats[]> { + const records: SessionStats[] = [] + try { + records.push(...readAllSessionStats(sessionDbPath())) + } catch (err) { + statsLog.warn("session store read failed", { err, extra: { db_path: sessionDbPath() } }) + } + for (const server of servers) { + try { + records.push(...(await readSessionStatsFromServer(server, fetchImpl))) + } catch (err) { + statsLog.warn("sandbox server read failed", { err }) + } + } + return records +} + +function readSentHashes(): Record<string, string> { + try { + const parsed = JSON.parse(readFileSync(statsSentPath(), "utf-8")) as unknown + return parsed && typeof parsed === "object" ? (parsed as Record<string, string>) : {} + } catch { + // Missing or corrupt: everything counts as unsent, and the backend's + // upsert makes resending harmless. + return {} + } +} + +function writeSentHashes(sent: Record<string, string>) { + mkdirSync(dirname(statsSentPath()), { recursive: true }) + writeFileSync(statsSentPath(), JSON.stringify(sent)) +} diff --git a/packages/codev-upload/src/server-source.ts b/packages/codev-upload/src/server-source.ts index dd11acb657..1b80b680ea 100644 --- a/packages/codev-upload/src/server-source.ts +++ b/packages/codev-upload/src/server-source.ts @@ -1,25 +1,24 @@ -import { partToMarkdown, unixToISO } from "./parts" -import type { Message, Session } from "./types" +import { projectFolderName } from "./paths" +import { buildSessionStats, type SessionStats, type StatsMessage } from "./stats" -// 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. +// Second session source for the stats push: 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 push 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. +// sqlite `data` columns store, so one stats builder serves both sources. 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. + // Host directory that names the project of 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 } @@ -27,26 +26,18 @@ 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 + info?: StatsMessage["info"] 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 +// across a page boundary can be skipped — acceptable for a push that // runs again on every launch. const PAGE_LIMIT = 1000 const MAX_PAGES = 100 @@ -84,103 +75,52 @@ async function listAllSessions(source: ServerSource, fetchImpl: typeof fetch): P return all } -function readMessages(source: ServerSource, info: ApiSessionInfo, fetchImpl: typeof fetch): Promise<ApiMessage[]> { +async function readMessages( + source: ServerSource, + info: ApiSessionInfo, + fetchImpl: typeof fetch, +): Promise<StatsMessage[]> { // 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<ApiMessage[]>(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 } + const messages = await apiGet<ApiMessage[]>( + source, + `/session/${encodeURIComponent(info.id)}/message?${params}`, + fetchImpl, + ) + return messages.flatMap((message) => (message.info ? [{ info: message.info, parts: message.parts ?? [] }] : [])) } -export async function readSessionsFromServer( +export async function readSessionStatsFromServer( source: ServerSource, fetchImpl: typeof fetch = fetch, -): Promise<Session[]> { +): Promise<SessionStats[]> { const all = await listAllSessions(source, fetchImpl) const childrenOf = new Map<string, ApiSessionInfo[]>() 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 } + childrenOf.set(info.parentID, [...(childrenOf.get(info.parentID) ?? []), info]) } + const descendantsOf = (id: string): ApiSessionInfo[] => + (childrenOf.get(id) ?? []).flatMap((child) => [child, ...descendantsOf(child.id)]) - const sessions: Session[] = [] + const project = projectFolderName(source.projectDir) + const stats: SessionStats[] = [] 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({ + const built = buildSessionStats({ 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 } : {}), + origin: "sandbox", + project, + messages: await readMessages(source, info, fetchImpl), + descendants: await Promise.all(descendantsOf(info.id).map((child) => readMessages(source, child, fetchImpl))), }) + if (built) stats.push(built) } catch { - // Skip a session whose messages cannot be read — the rest still export + // Skip a session whose messages cannot be read — the rest still push // (sqlite-source parity: malformed sessions are skipped, not fatal). } } - return sessions + return stats } diff --git a/packages/codev-upload/src/spawn.ts b/packages/codev-upload/src/spawn.ts index 3382cf43a3..a0d4110029 100644 --- a/packages/codev-upload/src/spawn.ts +++ b/packages/codev-upload/src/spawn.ts @@ -1,14 +1,14 @@ import { spawn as nodeSpawn } from "node:child_process" import { existsSync } from "node:fs" import { authFilePath } from "@codev/gateway" -import { currentTraceId, logDebug, logWarn } from "./log" -import { autoUploadDisabled } from "./nudge" +import { currentTraceId, statsLog } from "./log" +import { metricsDisabled } from "./nudge" -// The launch-side half of the background upload: fire-and-forget a detached -// `codev debug upload` child. Split from daemon.ts so the TUI can import it -// (via the `@codev/upload/spawn` subpath — the configure dialog respawns the -// daemon after a fresh sign-in) without dragging bun:sqlite and the whole -// upload pipeline into its module graph. +// The launch-side half of the stats push: fire-and-forget a detached +// `codev debug stats` child. Split from daemon.ts so the TUI can import it +// (via the `@codev/upload/spawn` subpath — the configure dialog respawns it +// after a fresh sign-in) without dragging bun:sqlite and the whole push +// pipeline into its module graph. // Test seam (codev-gateway/hub convention). export const spawner = { @@ -26,23 +26,23 @@ function selfInvocation(): string[] { return [script] } -// Fire-and-forget `codev debug upload`, detached and unref'd so the agent -// starts immediately and the upload survives a quick exit. Never blocks or +// Fire-and-forget `codev debug stats`, detached and unref'd so the agent +// starts immediately and the push survives a quick exit. Never blocks or // fails the agent launch. -export function spawnUploadDaemon(): void { - if (autoUploadDisabled()) return +export function spawnStatsDaemon(): void { + if (metricsDisabled()) return // Cheap pre-gate: no auth file means no session and no refresh token — the // child would only start up to skip. The child re-checks properly. if (!existsSync(authFilePath())) return try { - const child = spawner.spawn(process.execPath, [...selfInvocation(), "debug", "upload"], { + const child = spawner.spawn(process.execPath, [...selfInvocation(), "debug", "stats"], { detached: true, stdio: ["ignore", "ignore", "ignore"], env: { ...process.env, CODEV_TRACE_PARENT: currentTraceId() }, }) child.unref() - logDebug("spawned upload daemon", { extra: { pid: child.pid ?? null } }) + statsLog.debug("spawned stats daemon", { extra: { pid: child.pid ?? null } }) } catch (err) { - logWarn("could not spawn upload daemon", { err }) + statsLog.warn("could not spawn stats daemon", { err }) } } diff --git a/packages/codev-upload/src/stats.ts b/packages/codev-upload/src/stats.ts new file mode 100644 index 0000000000..d3370c3b61 --- /dev/null +++ b/packages/codev-upload/src/stats.ts @@ -0,0 +1,269 @@ +import { diffFromEditInput, textValue } from "./diff" +import { unixToISO } from "./parts" + +// Per-session usage statistics — what the analysis backend reports from, +// instead of re-deriving numbers from the uploaded markdown. Built from the v1 +// message/part JSON that both session sources yield (the sqlite `data` +// columns and the server API payloads are the same shape). The record shape +// is a contract with the backend's upload-session-stats function: bump +// STATS_VERSION on any breaking change. + +export const STATS_VERSION = 1 + +// One v1 message with its parts, as stored/served. Only the fields read here +// are typed; everything is optional because old stores predate some of them. +export interface StatsMessage { + info: { + role?: string + modelID?: string + providerID?: string + cost?: number + tokens?: { + input?: number + output?: number + reasoning?: number + cache?: { read?: number; write?: number } + } + time?: { created?: number; completed?: number } + } + parts: unknown[] +} + +export interface StatsInput { + id: string + origin: "local" | "sandbox" + // Export folder name of the session's project — the same key the uploaded + // markdown lives under, so both can be joined per project. + project: string + messages: StatsMessage[] + // Messages of each descendant subagent session. They cost tokens, tools and + // edits of their own but are not sessions in the report's sense, so they + // fold into their top-level session. + descendants: StatsMessage[][] +} + +export interface Tokens { + input: number + output: number + reasoning: number + cache_read: number + cache_write: number +} + +export interface LinesOfCode { + added: number + deleted: number + // Edits the agent proposed that failed or were denied. + rejected_added: number + rejected_deleted: number +} + +export interface Usage { + // Assistant messages (provider turns), subagents included. + turns: number + tokens: Tokens + cost: number + tools: Record<string, { ok: number; error: number }> + loc: LinesOfCode +} + +export interface Bucket extends Usage { + // Start of the UTC hour the assistant messages began in. The backend + // re-buckets into its reporting timezone, so a session spanning days lands + // on every day it was active. + hour: string + model: string + provider: string +} + +export interface SessionStats { + session_id: string + origin: "local" | "sandbox" + project: string + started_at: string + ended_at: string + // Time the top-level agent spent generating (sum of its assistant messages' + // durations) — subagents run inside it, so they are not added again. + active_ms: number + // Prompts the user typed into the top-level session. + user_turns: number + subagents: number + files_changed: number + totals: Usage + buckets: Bucket[] +} + +export function buildSessionStats(input: StatsInput): SessionStats | null { + if (input.messages.length === 0) return null + const totals = emptyUsage() + const buckets = new Map<string, Bucket>() + const files = new Set<string>() + + for (const message of [...input.messages, ...input.descendants.flat()]) { + if (message.info.role !== "assistant") continue + const hour = hourOf(message.info.time?.created ?? 0) + const model = message.info.modelID ?? "" + const provider = message.info.providerID ?? "" + const key = `${hour}|${provider}|${model}` + const bucket = buckets.get(key) ?? { hour, model, provider, ...emptyUsage() } + buckets.set(key, bucket) + const usage = messageUsage(message, files) + addUsage(totals, usage) + addUsage(bucket, usage) + } + + // Missing timestamps (0) would drag the span to 1970, so they are skipped. + const starts = input.messages.map((message) => message.info.time?.created ?? 0).filter((time) => time > 0) + const ends = input.messages + .map((message) => message.info.time?.completed ?? message.info.time?.created ?? 0) + .filter((time) => time > 0) + return { + session_id: input.id, + origin: input.origin, + project: input.project, + started_at: starts.length > 0 ? unixToISO(starts.reduce((min, time) => Math.min(min, time))) : "", + ended_at: ends.length > 0 ? unixToISO(ends.reduce((max, time) => Math.max(max, time))) : "", + active_ms: input.messages + .filter((message) => message.info.role === "assistant" && message.info.time?.completed) + .reduce( + (sum, message) => sum + Math.max(0, (message.info.time?.completed ?? 0) - (message.info.time?.created ?? 0)), + 0, + ), + user_turns: input.messages.filter(isUserPrompt).length, + subagents: input.descendants.length, + files_changed: files.size, + totals, + buckets: [...buckets.values()].sort((a, b) => a.hour.localeCompare(b.hour) || a.model.localeCompare(b.model)), + } +} + +function emptyUsage(): Usage { + return { + turns: 0, + tokens: { input: 0, output: 0, reasoning: 0, cache_read: 0, cache_write: 0 }, + cost: 0, + tools: {}, + loc: { added: 0, deleted: 0, rejected_added: 0, rejected_deleted: 0 }, + } +} + +function addUsage(target: Usage, usage: Usage) { + target.turns += usage.turns + target.cost += usage.cost + for (const key of Object.keys(usage.tokens) as (keyof Tokens)[]) target.tokens[key] += usage.tokens[key] + for (const key of Object.keys(usage.loc) as (keyof LinesOfCode)[]) target.loc[key] += usage.loc[key] + for (const [tool, count] of Object.entries(usage.tools)) { + const existing = target.tools[tool] ?? { ok: 0, error: 0 } + target.tools[tool] = { ok: existing.ok + count.ok, error: existing.error + count.error } + } +} + +function messageUsage(message: StatsMessage, files: Set<string>): Usage { + const usage = emptyUsage() + const tokens = message.info.tokens + usage.turns = 1 + usage.cost = finite(message.info.cost) + usage.tokens = { + input: finite(tokens?.input), + output: finite(tokens?.output), + reasoning: finite(tokens?.reasoning), + cache_read: finite(tokens?.cache?.read), + cache_write: finite(tokens?.cache?.write), + } + for (const part of message.parts.filter(isFinishedToolPart)) { + const accepted = part.state.status === "completed" + const count = usage.tools[part.tool] ?? { ok: 0, error: 0 } + usage.tools[part.tool] = accepted ? { ...count, ok: count.ok + 1 } : { ...count, error: count.error + 1 } + const edit = toolEdit(part) + if (!edit) continue + if (!accepted) { + usage.loc.rejected_added += edit.added + usage.loc.rejected_deleted += edit.deleted + continue + } + usage.loc.added += edit.added + usage.loc.deleted += edit.deleted + for (const file of edit.files) files.add(file) + } + return usage +} + +interface FinishedToolPart { + type: "tool" + tool: string + state: { + status: "completed" | "error" + input?: Record<string, unknown> + metadata?: Record<string, unknown> + } +} + +// Pending/running parts are calls still in flight (or orphaned by a crash); +// only settled calls count. +function isFinishedToolPart(part: unknown): part is FinishedToolPart { + const candidate = part as Partial<FinishedToolPart> | null + if (!candidate || candidate.type !== "tool" || typeof candidate.tool !== "string") return false + return candidate.state?.status === "completed" || candidate.state?.status === "error" +} + +// Line counts of a file-editing tool call. Completed calls use the counts the +// tool itself recorded; failed or denied calls never produced metadata, so +// they are reconstructed from what the agent asked for. +function toolEdit(part: FinishedToolPart): { added: number; deleted: number; files: string[] } | undefined { + const input = part.state.input ?? {} + const metadata = part.state.metadata ?? {} + const completed = part.state.status === "completed" + if (part.tool === "edit") { + const files = [textValue(input.filePath)].filter(Boolean) + const filediff = metadata.filediff as { additions?: unknown; deletions?: unknown } | undefined + if (completed && typeof filediff?.additions === "number") + return { added: filediff.additions, deleted: finite(filediff.deletions), files } + return { ...countDiffLines(diffFromEditInput(input)), files } + } + if (part.tool === "write") { + const content = textValue(input.content) + const lines = content ? content.split("\n").length - (content.endsWith("\n") ? 1 : 0) : 0 + return { added: lines, deleted: 0, files: [textValue(input.filePath)].filter(Boolean) } + } + if (part.tool === "apply_patch") { + const changed = Array.isArray(metadata.files) + ? (metadata.files as { filePath?: unknown; additions?: unknown; deletions?: unknown }[]) + : [] + if (completed && changed.length > 0) + return { + added: changed.reduce((sum, file) => sum + finite(file.additions), 0), + deleted: changed.reduce((sum, file) => sum + finite(file.deletions), 0), + files: changed.map((file) => textValue(file.filePath)).filter(Boolean), + } + return { ...countDiffLines(textValue(input.patchText)), files: [] } + } + return undefined +} + +function countDiffLines(diff: string): { added: number; deleted: number } { + const lines = diff.split("\n") + return { + added: lines.filter((line) => line.startsWith("+")).length, + deleted: lines.filter((line) => line.startsWith("-")).length, + } +} + +// A user message the person typed: at least one text part that the agent did +// not synthesize (compaction continuations, injected reminders). +function isUserPrompt(message: StatsMessage): boolean { + if (message.info.role !== "user") return false + return message.parts.some((part) => { + const text = part as { type?: string; synthetic?: boolean; text?: unknown } | null + return text?.type === "text" && !text.synthetic && typeof text.text === "string" && text.text.trim() !== "" + }) +} + +function hourOf(ms: number): string { + const iso = unixToISO(ms) + if (!iso) return "" + return `${iso.slice(0, 13)}:00:00.000Z` +} + +function finite(value: unknown): number { + return typeof value === "number" && Number.isFinite(value) ? value : 0 +} diff --git a/packages/codev-upload/src/upload.ts b/packages/codev-upload/src/upload.ts index f1b925f819..6700f3765e 100644 --- a/packages/codev-upload/src/upload.ts +++ b/packages/codev-upload/src/upload.ts @@ -2,16 +2,9 @@ import { createHash } from "node:crypto" import { existsSync, readdirSync, readFileSync, realpathSync, type Stats, statSync } from "node:fs" import { basename, join } from "node:path" import { gzipSync } from "node:zlib" -import { - type AuthData, - type CodevConfig, - fetchCodevConfig, - loadCodevConfig, - saveCodevConfig, - supabaseExchange, -} from "@codev/gateway" +import { type AuthData, fetchCodevConfig, saveCodevConfig, supabaseExchange } from "@codev/gateway" +import { type AnalysisBackendConfig, getAnalysisBackendConfig, isRefreshableError } from "./backend" import { runExport, type StatusReporter } from "./export" -import type { ServerSource } from "./server-source" import { logDebug, logError, logInfo, logWarn } from "./log" import { agentLogsDir } from "./paths" @@ -25,16 +18,12 @@ import { agentLogsDir } from "./paths" export interface UploadOptions { // Re-upload every exported log even when its hash matches a prior upload. // The previous-version link is still sent, so a forced re-upload supersedes - // instead of duplicating. Wired to `codev upload --force`. + // instead of duplicating. Wired to `codev upload --logs --force`. force?: boolean onStatus?: StatusReporter - // Resolves the SSO session. The daemon passes a silent resolver and - // pre-gates on it; the interactive command runs a browser login. A resolver - // returning null aborts with a sign-in hint. + // Resolves the SSO session — `codev upload --logs` runs a browser login when + // signed out. A resolver returning null aborts with a sign-in hint. getAuth: () => Promise<AuthData | null> - // 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 } @@ -81,14 +70,14 @@ const MAX_PAGES = 100 export async function runUpload(options: UploadOptions): Promise<UploadSummary> { const fetchImpl = options.fetchImpl ?? fetch // Tee every status line into the diagnostic log so a failed run can be - // reconstructed from `codev upload --logs` alone. + // reconstructed from `codev upload --logs --diagnostics` alone. const status = (message: string) => { logDebug(message) options.onStatus?.(message) } status("Exporting codev sessions...") - const exportSummary = await runExport(status, options.servers ?? [], fetchImpl) + const exportSummary = await runExport(status) const outDir = agentLogsDir() const files = listMarkdownLogs(outDir) @@ -106,7 +95,7 @@ export async function runUpload(options: UploadOptions): Promise<UploadSummary> const auth = await options.getAuth() if (!auth) { - throw new Error("Not signed in. Run `codev configure` (or `codev upload` interactively) to sign in first.") + throw new Error("Not signed in. Run `codev configure` (or `codev upload --logs` interactively) to sign in first.") } // Try once; on a refreshable failure (missing cached config, or the backend @@ -187,33 +176,6 @@ async function runBackendUpload( return summary } -interface AnalysisBackendConfig { - url: string - anonKey: string -} - -// The "Missing supabase_" prefix is the on-disk key name and is exactly what -// isRefreshableError matches — the hub's contract, kept verbatim. -function getAnalysisBackendConfig(): AnalysisBackendConfig { - const config: CodevConfig | null = loadCodevConfig() - if (!config) { - throw new Error("Missing supabase_url/supabase_anon_key in the CoDev auth file") - } - return { url: config.supabaseUrl.replace(/\/+$/, ""), anonKey: config.supabaseAnonKey } -} - -// Narrow refresh trigger: empty cache, or a 401/403 from the analysis backend -// or CoDev backend. Anchored to the `failed (NNN)` shape every thrower here -// and in codev-gateway emits (hub parity). -export function isRefreshableError(err: unknown): boolean { - const msg = err instanceof Error ? err.message : String(err) - if (msg.includes("Missing supabase_")) return true - const match = msg.match(/failed \((\d{3})\)/) - if (!match?.[1]) return false - const stat = Number.parseInt(match[1], 10) - return stat === 401 || stat === 403 -} - // Every exported codev-code markdown across every project folder. The hub // walks one project and four agents; the standalone uploader walks every // project and exactly its own agent dir. @@ -284,8 +246,7 @@ async function fetchExistingUploads( Prefer: "count=none", Range: `${from}-${to}`, }, - // Per-page timeout: a hung endpoint must not hold the upload lock until - // the stale reclaim. + // Per-page timeout: a hung endpoint must not stall the run indefinitely. signal: AbortSignal.timeout(UPLOAD_TIMEOUT_MS), }) if (!res.ok) { diff --git a/packages/codev-upload/test/daemon.test.ts b/packages/codev-upload/test/daemon.test.ts index bb3b1fcff6..07066b78b5 100644 --- a/packages/codev-upload/test/daemon.test.ts +++ b/packages/codev-upload/test/daemon.test.ts @@ -1,25 +1,27 @@ import { afterEach, beforeEach, describe, expect, test } from "bun:test" -import { mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs" +import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from "node:fs" import { tmpdir } from "node:os" import { join } from "node:path" -import { releaseLock, runStartupUpload, tryAcquireLock } from "../src/daemon" -import { autoUploadDisabled, readLastUploadStatus } from "../src/nudge" -import { uploadLockPath } from "../src/paths" -import { spawner, spawnUploadDaemon } from "../src/spawn" +import { releaseLock, runLockedPush, runStartupStats, tryAcquireLock } from "../src/daemon" +import { metricsDisabled, readLastStatsStatus } from "../src/nudge" +import { statsLockPath } from "../src/paths" +import { spawner, spawnStatsDaemon } from "../src/spawn" let tempDir: string let originalTestHome: string | undefined let originalXdgData: string | undefined let originalDisable: string | undefined +const CLIENT = { app: "cli", version: "test" } as const + beforeEach(() => { tempDir = mkdtempSync(join(tmpdir(), "codev-upload-daemon-")) originalTestHome = process.env.CODEV_TEST_HOME originalXdgData = process.env.XDG_DATA_HOME - originalDisable = process.env.CODEV_DISABLE_AUTO_UPLOAD + originalDisable = process.env.CODEV_DISABLE_METRICS process.env.CODEV_TEST_HOME = tempDir process.env.XDG_DATA_HOME = join(tempDir, "share") - delete process.env.CODEV_DISABLE_AUTO_UPLOAD + delete process.env.CODEV_DISABLE_METRICS }) afterEach(() => { @@ -27,21 +29,27 @@ afterEach(() => { else process.env.CODEV_TEST_HOME = originalTestHome if (originalXdgData === undefined) delete process.env.XDG_DATA_HOME else process.env.XDG_DATA_HOME = originalXdgData - if (originalDisable === undefined) delete process.env.CODEV_DISABLE_AUTO_UPLOAD - else process.env.CODEV_DISABLE_AUTO_UPLOAD = originalDisable + if (originalDisable === undefined) delete process.env.CODEV_DISABLE_METRICS + else process.env.CODEV_DISABLE_METRICS = originalDisable rmSync(tempDir, { recursive: true, force: true }) }) -describe("autoUploadDisabled", () => { +describe("metricsDisabled", () => { test("recognizes 1 and true, nothing else", () => { - expect(autoUploadDisabled({ CODEV_DISABLE_AUTO_UPLOAD: "1" })).toBe(true) - expect(autoUploadDisabled({ CODEV_DISABLE_AUTO_UPLOAD: "true" })).toBe(true) - expect(autoUploadDisabled({ CODEV_DISABLE_AUTO_UPLOAD: "0" })).toBe(false) - expect(autoUploadDisabled({})).toBe(false) + expect(metricsDisabled({ CODEV_DISABLE_METRICS: "1" })).toBe(true) + expect(metricsDisabled({ CODEV_DISABLE_METRICS: "true" })).toBe(true) + expect(metricsDisabled({ CODEV_DISABLE_METRICS: "0" })).toBe(false) + // The retired log-upload opt-out no longer means anything. + expect(metricsDisabled({ CODEV_DISABLE_AUTO_UPLOAD: "1" })).toBe(false) + expect(metricsDisabled({})).toBe(false) }) }) -describe("upload lock", () => { +describe("stats lock", () => { + test("lives in the agent's data dir, not the hub-shared ~/.codev-hub", () => { + expect(statsLockPath()).toBe(join(tempDir, "share", "codev", "stats.lock")) + }) + test("second acquire fails while the first is held by a live process", () => { expect(tryAcquireLock()).toBe(true) expect(tryAcquireLock()).toBe(false) @@ -51,11 +59,11 @@ describe("upload lock", () => { }) test("a stale lock (dead pid) is reclaimed", () => { - mkdirSync(join(tempDir, ".codev-hub"), { recursive: true }) + mkdirSync(join(tempDir, "share", "codev"), { recursive: true }) // A pid that cannot be alive plus an ancient start time — both staleness // conditions the reclaim path checks. writeFileSync( - uploadLockPath(), + statsLockPath(), JSON.stringify({ pid: 999999999, startedAt: new Date(Date.now() - 2 * 60 * 60 * 1000).toISOString() }), ) expect(tryAcquireLock()).toBe(true) @@ -63,30 +71,31 @@ describe("upload lock", () => { }) test("a fresh lock held by this (live) process is respected", () => { - mkdirSync(join(tempDir, ".codev-hub"), { recursive: true }) - writeFileSync(uploadLockPath(), JSON.stringify({ pid: process.pid, startedAt: new Date().toISOString() })) + mkdirSync(join(tempDir, "share", "codev"), { recursive: true }) + writeFileSync(statsLockPath(), JSON.stringify({ pid: process.pid, startedAt: new Date().toISOString() })) expect(tryAcquireLock()).toBe(false) releaseLock() }) }) -describe("runStartupUpload gates", () => { +describe("runStartupStats gates", () => { test("opt-out env short-circuits to success", async () => { - process.env.CODEV_DISABLE_AUTO_UPLOAD = "1" - expect(await runStartupUpload()).toBe(0) + process.env.CODEV_DISABLE_METRICS = "1" + expect(await runStartupStats({ client: CLIENT })).toBe(0) + expect(readLastStatsStatus()).toBeNull() }) test("signed out (no auth file) skips, recording the stall for the nudge", async () => { - expect(await runStartupUpload()).toBe(0) - // The skip is durable — this is what the TUI's sign-in toast keys off. - expect(readLastUploadStatus()).toMatchObject({ ok: false, skipped: "not-logged-in" }) + expect(await runStartupStats({ client: CLIENT })).toBe(0) + // The skip is durable — this is what the sign-in toasts key off. + expect(readLastStatsStatus()).toMatchObject({ ok: false, skipped: "not-logged-in" }) // No lock left behind. expect(tryAcquireLock()).toBe(true) releaseLock() }) }) -describe("spawnUploadDaemon", () => { +describe("spawnStatsDaemon", () => { const realSpawn = spawner.spawn afterEach(() => { @@ -100,17 +109,17 @@ describe("spawnUploadDaemon", () => { return { unref() {}, pid: 1 } as never }) as typeof spawner.spawn - spawnUploadDaemon() + spawnStatsDaemon() expect(calls).toHaveLength(0) mkdirSync(join(tempDir, ".codev"), { recursive: true }) writeFileSync(join(tempDir, ".codev", "auth.json"), "{}") - process.env.CODEV_DISABLE_AUTO_UPLOAD = "1" - spawnUploadDaemon() + process.env.CODEV_DISABLE_METRICS = "1" + spawnStatsDaemon() expect(calls).toHaveLength(0) }) - test("spawns a detached `debug upload` child with a trace parent", () => { + test("spawns a detached `debug stats` child with a trace parent", () => { mkdirSync(join(tempDir, ".codev"), { recursive: true }) writeFileSync(join(tempDir, ".codev", "auth.json"), "{}") const calls: { args: string[]; options: Record<string, unknown> }[] = [] @@ -125,13 +134,33 @@ describe("spawnUploadDaemon", () => { } as never }) as unknown as typeof spawner.spawn - spawnUploadDaemon() + spawnStatsDaemon() expect(calls).toHaveLength(1) expect(calls[0].args[0]).toBe(process.execPath) - expect(calls[0].args.slice(-2)).toEqual(["debug", "upload"]) + expect(calls[0].args.slice(-2)).toEqual(["debug", "stats"]) expect(calls[0].options.detached).toBe(true) expect(calls[0].options.stdio).toEqual(["ignore", "ignore", "ignore"]) expect((calls[0].options.env as Record<string, string>).CODEV_TRACE_PARENT).toMatch(/^[0-9a-f]{16}$/) expect(unrefed).toBe(true) }) }) + +describe("runLockedPush", () => { + test("returns null while another push holds the lock", async () => { + expect(tryAcquireLock()).toBe(true) + expect(await runLockedPush({ client: CLIENT, getAuth: async () => null })).toBeNull() + releaseLock() + }) + + test("records a successful push, clearing a recorded sign-in stall", async () => { + expect(await runStartupStats({ client: CLIENT })).toBe(0) + expect(readLastStatsStatus()?.skipped).toBe("not-logged-in") + // An empty store has nothing to send, so no sign-in is needed. + expect(await runLockedPush({ client: CLIENT, getAuth: async () => null })).toEqual({ + found: 0, + sent: 0, + unchanged: 0, + }) + expect(readLastStatsStatus()).toMatchObject({ ok: true, summary: { found: 0 } }) + }) +}) diff --git a/packages/codev-upload/test/logs.test.ts b/packages/codev-upload/test/logs.test.ts index 8cd31b1234..52a8bc8eb9 100644 --- a/packages/codev-upload/test/logs.test.ts +++ b/packages/codev-upload/test/logs.test.ts @@ -1,8 +1,8 @@ import { afterEach, beforeEach, describe, expect, test } from "bun:test" -import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from "node:fs" +import { mkdirSync, mkdtempSync, readdirSync, rmSync, writeFileSync } from "node:fs" import { tmpdir } from "node:os" import { join } from "node:path" -import { redactSecrets } from "../src/log" +import { redactSecrets, statsLog } from "../src/log" import { renderLastRun } from "../src/logs" import { uploadLogDir } from "../src/paths" @@ -43,7 +43,7 @@ function doc(trace: string, message: string, extra: Record<string, unknown> = {} describe("renderLastRun", () => { test("returns null with no logs", () => { - expect(renderLastRun()).toBeNull() + expect(renderLastRun("upload")).toBeNull() }) test("prints only the most recent run, including its parent chain", () => { @@ -56,13 +56,23 @@ describe("renderLastRun", () => { doc("cccc000000000003", "starting auto-upload", { parent_trace_id: "bbbb000000000002" }), doc("cccc000000000003", "auto-upload done"), ]) - const rendered = renderLastRun() + const rendered = renderLastRun("upload") expect(rendered).toContain("starting auto-upload") expect(rendered).toContain("auto-upload done") expect(rendered).toContain("spawned upload daemon") expect(rendered).not.toContain("old run start") }) + test("ignores the launch-time stats push, which logs to its own files", () => { + // Dated today: the logger's first write prunes files past retention. + const today = new Date().toISOString().slice(0, 10).replaceAll("-", "") + writeDocs(`upload-${today}.ndjson`, [doc("eeee000000000005", "last real upload")]) + statsLog.info("stats push done") + expect(readdirSync(uploadLogDir()).some((name) => /^stats-\d{8}\.ndjson$/.test(name))).toBe(true) + expect(renderLastRun("upload")).toContain("last real upload") + expect(renderLastRun("upload")).not.toContain("stats push done") + }) + test("renders outcome and error fields", () => { writeDocs("upload-20260824.ndjson", [ { @@ -72,7 +82,9 @@ describe("renderLastRun", () => { error: { message: "presign-upload failed (500)" }, }, ]) - expect(renderLastRun()).toBe("2026-08-24T10:00:00.000Z WARN upload failed [failure] — presign-upload failed (500)") + expect(renderLastRun("upload")).toBe( + "2026-08-24T10:00:00.000Z WARN upload failed [failure] — presign-upload failed (500)", + ) }) }) diff --git a/packages/codev-upload/test/nudge.test.ts b/packages/codev-upload/test/nudge.test.ts index ea80960daf..39fc4db733 100644 --- a/packages/codev-upload/test/nudge.test.ts +++ b/packages/codev-upload/test/nudge.test.ts @@ -4,7 +4,7 @@ import { tmpdir } from "node:os" import { join } from "node:path" import { saveApiKey } from "@codev/gateway" import { shouldNudgeSignIn } from "../src/nudge" -import { lastUploadStatusPath } from "../src/paths" +import { statsStatusPath } from "../src/paths" let tempDir: string let originalTestHome: string | undefined @@ -15,10 +15,10 @@ beforeEach(() => { tempDir = mkdtempSync(join(tmpdir(), "codev-upload-nudge-")) originalTestHome = process.env.CODEV_TEST_HOME originalXdgData = process.env.XDG_DATA_HOME - originalDisable = process.env.CODEV_DISABLE_AUTO_UPLOAD + originalDisable = process.env.CODEV_DISABLE_METRICS process.env.CODEV_TEST_HOME = tempDir process.env.XDG_DATA_HOME = join(tempDir, "share") - delete process.env.CODEV_DISABLE_AUTO_UPLOAD + delete process.env.CODEV_DISABLE_METRICS }) afterEach(() => { @@ -26,15 +26,15 @@ afterEach(() => { else process.env.CODEV_TEST_HOME = originalTestHome if (originalXdgData === undefined) delete process.env.XDG_DATA_HOME else process.env.XDG_DATA_HOME = originalXdgData - if (originalDisable === undefined) delete process.env.CODEV_DISABLE_AUTO_UPLOAD - else process.env.CODEV_DISABLE_AUTO_UPLOAD = originalDisable + if (originalDisable === undefined) delete process.env.CODEV_DISABLE_METRICS + else process.env.CODEV_DISABLE_METRICS = originalDisable rmSync(tempDir, { recursive: true, force: true }) }) function recordStall(): void { - mkdirSync(join(tempDir, ".codev-hub"), { recursive: true }) + mkdirSync(join(tempDir, "share", "codev"), { recursive: true }) writeFileSync( - lastUploadStatusPath(), + statsStatusPath(), JSON.stringify({ ok: false, skipped: "not-logged-in", startedAt: "t", finishedAt: "t" }), ) } @@ -46,8 +46,8 @@ describe("shouldNudgeSignIn", () => { // Deliberately unthrottled: the toast repeats each launch while stalled. expect(shouldNudgeSignIn()).toBe(true) expect(shouldNudgeSignIn()).toBe(true) - // A healthy status (next successful upload) ends it. - writeFileSync(lastUploadStatusPath(), JSON.stringify({ ok: true, startedAt: "t", finishedAt: "t" })) + // A healthy status (next successful push) ends it. + writeFileSync(statsStatusPath(), JSON.stringify({ ok: true, startedAt: "t", finishedAt: "t" })) expect(shouldNudgeSignIn()).toBe(false) }) @@ -74,14 +74,14 @@ describe("shouldNudgeSignIn", () => { recordStall() expect(shouldNudgeSignIn()).toBe(false) - // Key but no stall recorded (or a healthy hub-written status): quiet. + // Key but no stall recorded (or a healthy status): quiet. saveApiKey({ apiKey: "sk-x" }) - writeFileSync(lastUploadStatusPath(), JSON.stringify({ ok: true, startedAt: "t", finishedAt: "t" })) + writeFileSync(statsStatusPath(), JSON.stringify({ ok: true, startedAt: "t", finishedAt: "t" })) expect(shouldNudgeSignIn()).toBe(false) // Opt-out wins over everything. recordStall() - process.env.CODEV_DISABLE_AUTO_UPLOAD = "1" + process.env.CODEV_DISABLE_METRICS = "1" expect(shouldNudgeSignIn()).toBe(false) }) }) diff --git a/packages/codev-upload/test/push.test.ts b/packages/codev-upload/test/push.test.ts new file mode 100644 index 0000000000..fa12b8d30f --- /dev/null +++ b/packages/codev-upload/test/push.test.ts @@ -0,0 +1,188 @@ +import { Database } from "bun:sqlite" +import { afterEach, beforeEach, describe, expect, test } from "bun:test" +import { mkdirSync, mkdtempSync, rmSync } from "node:fs" +import { tmpdir } from "node:os" +import { join } from "node:path" +import { saveCodevConfig, type AuthData } from "@codev/gateway" +import { sessionDbPath } from "../src/paths" +import { pushSessionStats } from "../src/push" + +let tempDir: string +let originalTestHome: string | undefined +let originalXdgData: string | undefined + +beforeEach(() => { + tempDir = mkdtempSync(join(tmpdir(), "codev-upload-push-")) + 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 AUTH: AuthData = { + access_token: "sso-token", + id_token: "id", + expires_at: Date.now() + 3_600_000, + user: { sub: "u1", email: "u@test", displayName: "U" }, +} +const CLIENT = { app: "cli", version: "1.2.3" } as const +const T0 = Date.UTC(2026, 7, 1, 10, 0, 0) + +function seedStore(sessions: { id: string; input: number }[]) { + const projectDir = join(tempDir, "work", "proj") + mkdirSync(projectDir, { recursive: true }) + mkdirSync(join(tempDir, "share", "codev"), { recursive: true }) + const db = new Database(sessionDbPath(), { create: true }) + db.run("CREATE TABLE IF NOT EXISTS project (id TEXT PRIMARY KEY, worktree TEXT)") + db.run( + "CREATE TABLE IF NOT EXISTS session (id TEXT PRIMARY KEY, project_id TEXT, title TEXT, directory TEXT, parent_id TEXT, time_created INTEGER, time_updated INTEGER)", + ) + db.run("CREATE TABLE IF NOT EXISTS message (id TEXT PRIMARY KEY, session_id TEXT, time_created INTEGER, data TEXT)") + db.run( + "CREATE TABLE IF NOT EXISTS part (id TEXT PRIMARY KEY, session_id TEXT, message_id TEXT, time_created INTEGER, data TEXT)", + ) + db.run("INSERT OR IGNORE INTO project (id, worktree) VALUES ('prj1', ?)", [projectDir]) + for (const session of sessions) { + db.run( + "INSERT OR REPLACE INTO session (id, project_id, title, directory, parent_id, time_created, time_updated) VALUES (?, 'prj1', 't', ?, NULL, ?, ?)", + [session.id, projectDir, T0, T0], + ) + db.run("INSERT OR REPLACE INTO message (id, session_id, time_created, data) VALUES (?, ?, ?, ?)", [ + `${session.id}-m`, + session.id, + T0, + JSON.stringify({ role: "assistant", modelID: "sonnet", tokens: { input: session.input }, time: { created: T0 } }), + ]) + } + db.close() +} + +interface Call { + url: string + body?: string + headers: Record<string, string> +} + +function mockFetch(state: { calls: Call[]; statsStatus?: number; statsStatusOnce?: number }): typeof fetch { + return (async (input: string | URL | Request, init?: RequestInit) => { + const url = String(input instanceof Request ? input.url : input) + state.calls.push({ url, body: init?.body?.toString(), headers: Object.fromEntries(new Headers(init?.headers)) }) + if (url.includes("/supabase/exchange")) { + return Response.json({ access_token: "sb-token", user: { id: "u1", email: "u@test" } }) + } + if (url.includes("/codev-backend/config")) { + return Response.json({ supabaseUrl: "https://sb.test", supabaseAnonKey: "anon", gatewayUrl: "https://gw.test" }) + } + if (url.includes("/functions/v1/upload-session-stats")) { + const status = state.statsStatusOnce ?? state.statsStatus ?? 200 + state.statsStatusOnce = undefined + return new Response(status === 200 ? "{}" : "nope", { status }) + } + throw new Error(`unexpected request: ${url}`) + }) as typeof fetch +} + +const statsCalls = (calls: Call[]) => calls.filter((call) => call.url.includes("/functions/v1/upload-session-stats")) + +describe("pushSessionStats", () => { + test("sends every session's stats with the exchanged token, then only what changed", async () => { + saveCodevConfig({ supabaseUrl: "https://sb.test", supabaseAnonKey: "anon", gatewayUrl: "https://gw.test" }) + seedStore([ + { id: "a", input: 1 }, + { id: "b", input: 2 }, + ]) + const state = { calls: [] as Call[] } + const first = await pushSessionStats({ client: CLIENT, getAuth: async () => AUTH, fetchImpl: mockFetch(state) }) + expect(first).toEqual({ found: 2, sent: 2, unchanged: 0 }) + const [call] = statsCalls(state.calls) + expect(call.url).toBe("https://sb.test/functions/v1/upload-session-stats") + expect(call.headers.authorization).toBe("Bearer sb-token") + const body = JSON.parse(call.body ?? "{}") + expect(body).toMatchObject({ v: 1, client: { app: "cli", version: "1.2.3" } }) + expect(body.sessions.map((session: { session_id: string }) => session.session_id)).toEqual(["a", "b"]) + + // Unchanged: nothing is sent and no sign-in is needed. + state.calls = [] + const second = await pushSessionStats({ client: CLIENT, getAuth: async () => null, fetchImpl: mockFetch(state) }) + expect(second).toEqual({ found: 2, sent: 0, unchanged: 2 }) + expect(state.calls).toHaveLength(0) + + // One session grows: only it goes out. + seedStore([{ id: "b", input: 5 }]) + const third = await pushSessionStats({ client: CLIENT, getAuth: async () => AUTH, fetchImpl: mockFetch(state) }) + expect(third).toEqual({ found: 2, sent: 1, unchanged: 1 }) + const resent = JSON.parse(statsCalls(state.calls)[0].body ?? "{}") + expect(resent.sessions.map((session: { session_id: string }) => session.session_id)).toEqual(["b"]) + }) + + test("force resends sessions whose stats are unchanged", async () => { + saveCodevConfig({ supabaseUrl: "https://sb.test", supabaseAnonKey: "anon", gatewayUrl: "https://gw.test" }) + seedStore([{ id: "a", input: 1 }]) + await pushSessionStats({ client: CLIENT, getAuth: async () => AUTH, fetchImpl: mockFetch({ calls: [] }) }) + const forced = await pushSessionStats({ + client: CLIENT, + force: true, + getAuth: async () => AUTH, + fetchImpl: mockFetch({ calls: [] }), + }) + expect(forced).toEqual({ found: 1, sent: 1, unchanged: 0 }) + }) + + test("a backend without the endpoint (404) records nothing as sent", async () => { + saveCodevConfig({ supabaseUrl: "https://sb.test", supabaseAnonKey: "anon", gatewayUrl: "https://gw.test" }) + seedStore([{ id: "a", input: 1 }]) + const state = { calls: [] as Call[], statsStatus: 404 } + const summary = await pushSessionStats({ client: CLIENT, getAuth: async () => AUTH, fetchImpl: mockFetch(state) }) + expect(summary).toEqual({ found: 1, sent: 0, unchanged: 0, unsupported: true }) + + const retry = await pushSessionStats({ + client: CLIENT, + getAuth: async () => AUTH, + fetchImpl: mockFetch({ calls: [] }), + }) + expect(retry.sent).toBe(1) + }) + + test("a 401 refreshes the CoDev config and retries once", async () => { + saveCodevConfig({ supabaseUrl: "https://stale.test", supabaseAnonKey: "anon", gatewayUrl: "https://gw.test" }) + seedStore([{ id: "a", input: 1 }]) + const state = { calls: [] as Call[], statsStatusOnce: 401 } + const summary = await pushSessionStats({ client: CLIENT, getAuth: async () => AUTH, fetchImpl: mockFetch(state) }) + expect(summary.sent).toBe(1) + expect(state.calls.some((call) => call.url.includes("/codev-backend/config"))).toBe(true) + expect(statsCalls(state.calls).map((call) => new URL(call.url).host)).toEqual(["stale.test", "sb.test"]) + }) + + test("other failures throw and leave the sessions unsent", async () => { + saveCodevConfig({ supabaseUrl: "https://sb.test", supabaseAnonKey: "anon", gatewayUrl: "https://gw.test" }) + seedStore([{ id: "a", input: 1 }]) + await expect( + pushSessionStats({ + client: CLIENT, + getAuth: async () => AUTH, + fetchImpl: mockFetch({ calls: [], statsStatus: 500 }), + }), + ).rejects.toThrow("upload-session-stats failed (500)") + const retry = await pushSessionStats({ + client: CLIENT, + getAuth: async () => AUTH, + fetchImpl: mockFetch({ calls: [] }), + }) + expect(retry.sent).toBe(1) + }) + + test("signed out with pending stats throws", async () => { + seedStore([{ id: "a", input: 1 }]) + await expect( + pushSessionStats({ client: CLIENT, getAuth: async () => null, fetchImpl: mockFetch({ calls: [] }) }), + ).rejects.toThrow("Not signed in") + }) +}) diff --git a/packages/codev-upload/test/server-source.test.ts b/packages/codev-upload/test/server-source.test.ts index 70af3a209d..218ce6c927 100644 --- a/packages/codev-upload/test/server-source.test.ts +++ b/packages/codev-upload/test/server-source.test.ts @@ -1,10 +1,9 @@ import { afterEach, beforeEach, describe, expect, test } from "bun:test" -import { mkdtempSync, readdirSync, readFileSync, rmSync } from "node:fs" +import { mkdtempSync, 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" +import { projectFolderName } from "../src/paths" +import { readSessionStatsFromServer, type ServerSource } from "../src/server-source" let tempDir: string let originalTestHome: string | undefined @@ -42,7 +41,14 @@ const MESSAGES: Record<string, object[]> = { parts: [{ type: "text", text: "run the tests" }], }, { - info: { role: "assistant", modelID: "claude-sonnet-4-5", time: { created: T0 + 30_000 } }, + info: { + role: "assistant", + modelID: "claude-sonnet-4-5", + providerID: "aigw", + cost: 0.02, + tokens: { input: 100, output: 20, reasoning: 0, cache: { read: 50, write: 0 } }, + time: { created: T0 + 30_000, completed: T0 + 40_000 }, + }, parts: [ { type: "text", text: "done" }, { @@ -53,7 +59,18 @@ const MESSAGES: Record<string, object[]> = { ], }, ], - "ws1-child": [{ info: { role: "assistant", modelID: "m" }, parts: [{ type: "text", text: "12345" }] }], + "ws1-child": [ + { + info: { + role: "assistant", + modelID: "m", + providerID: "aigw", + tokens: { input: 7, output: 3, reasoning: 0, cache: { read: 0, write: 0 } }, + time: { created: T0 + 35_000 }, + }, + parts: [{ type: "text", text: "12345" }], + }, + ], } function mockFetch(calls: string[]): typeof fetch { @@ -78,51 +95,36 @@ const source = (hostDir: string): ServerSource => ({ projectDir: hostDir, }) -describe("readSessionsFromServer", () => { - test("maps v1 API sessions to the exporter's shapes, folding subagents in", async () => { +describe("readSessionStatsFromServer", () => { + test("builds stats from v1 API sessions, folding subagents in under the host project", 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('<tool-use data-tool-type="shell" data-tool-name="bash">') - expect(session.subagentCharsOut).toBe(5) + const hostDir = join(tempDir, "work", "hostproj") + const stats = await readSessionStatsFromServer(source(hostDir), mockFetch(calls)) + expect(stats).toHaveLength(1) + expect(stats[0]).toMatchObject({ + session_id: "ws1", + origin: "sandbox", + project: projectFolderName(hostDir), + started_at: "2026-08-01T10:00:00.000Z", + ended_at: "2026-08-01T10:00:40.000Z", + active_ms: 10_000, + user_turns: 1, + subagents: 1, + }) + // Parent + child tokens, one bucket per model. + expect(stats[0].totals.tokens).toEqual({ input: 107, output: 23, reasoning: 0, cache_read: 50, cache_write: 0 }) + expect(stats[0].totals.tools).toEqual({ bash: { ok: 1, error: 0 } }) + expect(stats[0].buckets.map((bucket) => bucket.model)).toEqual(["claude-sonnet-4-5", "m"]) // 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("<!-- Generated by CoDev -->") - 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 () => { + test("an unreachable sandbox throws for the caller to skip", 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") + await expect(readSessionStatsFromServer(source(join(tempDir, "p")), failing)).rejects.toThrow("ECONNREFUSED") }) }) diff --git a/packages/codev-upload/test/stats.test.ts b/packages/codev-upload/test/stats.test.ts new file mode 100644 index 0000000000..91b09f0714 --- /dev/null +++ b/packages/codev-upload/test/stats.test.ts @@ -0,0 +1,221 @@ +import { Database } from "bun:sqlite" +import { afterEach, beforeEach, describe, expect, test } from "bun:test" +import { mkdirSync, mkdtempSync, rmSync } from "node:fs" +import { tmpdir } from "node:os" +import { join } from "node:path" +import { readAllSessionStats } from "../src/db" +import { projectFolderName, sessionDbPath } from "../src/paths" +import { buildSessionStats, type StatsMessage } from "../src/stats" + +const T0 = Date.UTC(2026, 7, 1, 10, 30, 0) +const HOUR = 60 * 60 * 1000 + +function assistant(created: number, fields: Partial<StatsMessage["info"]>, parts: unknown[] = []): StatsMessage { + return { + info: { + role: "assistant", + modelID: "sonnet", + providerID: "aigw", + cost: 0, + tokens: { input: 0, output: 0, reasoning: 0, cache: { read: 0, write: 0 } }, + time: { created }, + ...fields, + }, + parts, + } +} + +function user(created: number, text: string, synthetic = false): StatsMessage { + return { info: { role: "user", time: { created } }, parts: [{ type: "text", text, synthetic }] } +} + +function tool(name: string, status: string, state: Record<string, unknown> = {}) { + return { type: "tool", tool: name, state: { status, input: {}, ...state } } +} + +describe("buildSessionStats", () => { + test("sums real tokens and cost, bucketed by UTC hour and model", () => { + const stats = buildSessionStats({ + id: "ses1", + origin: "local", + project: "proj", + messages: [ + user(T0, "fix it"), + assistant(T0 + 1000, { + cost: 0.5, + tokens: { input: 100, output: 10, reasoning: 5, cache: { read: 1000, write: 20 } }, + time: { created: T0 + 1000, completed: T0 + 6000 }, + }), + user(T0 + HOUR, "continue", true), + user(T0 + HOUR, "and the tests"), + assistant(T0 + HOUR + 1000, { + modelID: "opus", + cost: 1, + tokens: { input: 200, output: 20, reasoning: 0, cache: { read: 0, write: 0 } }, + time: { created: T0 + HOUR + 1000, completed: T0 + HOUR + 3000 }, + }), + ], + descendants: [], + }) + expect(stats).toMatchObject({ + session_id: "ses1", + started_at: "2026-08-01T10:30:00.000Z", + ended_at: "2026-08-01T11:30:03.000Z", + active_ms: 7000, + // The synthetic continuation is not a prompt the user typed. + user_turns: 2, + subagents: 0, + }) + expect(stats?.totals).toMatchObject({ + turns: 2, + cost: 1.5, + tokens: { input: 300, output: 30, reasoning: 5, cache_read: 1000, cache_write: 20 }, + }) + expect(stats?.buckets.map((bucket) => [bucket.hour, bucket.model, bucket.tokens.input])).toEqual([ + ["2026-08-01T10:00:00.000Z", "sonnet", 100], + ["2026-08-01T11:00:00.000Z", "opus", 200], + ]) + }) + + test("folds subagent usage into totals and buckets, but not into the session's own time", () => { + const stats = buildSessionStats({ + id: "ses1", + origin: "local", + project: "proj", + messages: [user(T0, "go"), assistant(T0 + 1000, { time: { created: T0 + 1000, completed: T0 + 2000 } })], + descendants: [ + [ + assistant(T0 + 1500, { + tokens: { input: 40, output: 4 }, + time: { created: T0 + 1500, completed: T0 + 9000 }, + }), + ], + [assistant(T0 + 1600, { tokens: { input: 60, output: 6 } })], + ], + }) + expect(stats?.subagents).toBe(2) + expect(stats?.totals.turns).toBe(3) + expect(stats?.totals.tokens.input).toBe(100) + expect(stats?.active_ms).toBe(1000) + expect(stats?.ended_at).toBe("2026-08-01T10:30:02.000Z") + }) + + test("counts tool calls and accepted vs rejected lines of code", () => { + const stats = buildSessionStats({ + id: "ses1", + origin: "local", + project: "proj", + messages: [ + assistant(T0, {}, [ + tool("edit", "completed", { + input: { filePath: "/p/a.ts" }, + metadata: { filediff: { file: "/p/a.ts", additions: 3, deletions: 1 } }, + }), + // A denied edit carries no metadata — reconstructed from its input. + tool("edit", "error", { input: { filePath: "/p/b.ts", oldString: "x\ny", newString: "x\nz\nw" } }), + tool("write", "completed", { input: { filePath: "/p/c.ts", content: "1\n2\n3\n" } }), + tool("apply_patch", "completed", { + metadata: { files: [{ filePath: "/p/a.ts", additions: 2, deletions: 2 }] }, + }), + tool("bash", "completed"), + tool("bash", "error"), + // In flight: not counted. + tool("read", "running"), + ]), + ], + descendants: [], + }) + expect(stats?.totals.tools).toEqual({ + edit: { ok: 1, error: 1 }, + write: { ok: 1, error: 0 }, + apply_patch: { ok: 1, error: 0 }, + bash: { ok: 1, error: 1 }, + }) + expect(stats?.totals.loc).toEqual({ added: 8, deleted: 3, rejected_added: 2, rejected_deleted: 1 }) + expect(stats?.files_changed).toBe(2) + }) + + test("a session without messages yields nothing", () => { + expect(buildSessionStats({ id: "s", origin: "local", project: "p", messages: [], descendants: [] })).toBeNull() + }) +}) + +describe("readAllSessionStats", () => { + let tempDir: string + let originalTestHome: string | undefined + let originalXdgData: string | undefined + + beforeEach(() => { + tempDir = mkdtempSync(join(tmpdir(), "codev-upload-stats-")) + 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 }) + }) + + test("reads top-level sessions with their subagent descendants from the store", () => { + const projectDir = join(tempDir, "work", "myproj") + mkdirSync(projectDir, { recursive: true }) + mkdirSync(join(tempDir, "share", "codev"), { recursive: true }) + const db = new Database(sessionDbPath(), { create: true }) + db.run("CREATE TABLE project (id TEXT PRIMARY KEY, worktree TEXT)") + db.run( + "CREATE TABLE session (id TEXT PRIMARY KEY, project_id TEXT, title TEXT, directory TEXT, parent_id TEXT, time_created INTEGER, time_updated INTEGER)", + ) + db.run("CREATE TABLE message (id TEXT PRIMARY KEY, session_id TEXT, time_created INTEGER, data TEXT)") + db.run("CREATE TABLE part (id TEXT PRIMARY KEY, session_id TEXT, message_id TEXT, time_created INTEGER, data TEXT)") + db.run("INSERT INTO project (id, worktree) VALUES ('prj1', ?)", [projectDir]) + const session = (id: string, parent: string | null) => + db.run( + "INSERT INTO session (id, project_id, title, directory, parent_id, time_created, time_updated) VALUES (?, 'prj1', 't', ?, ?, ?, ?)", + [id, projectDir, parent, T0, T0], + ) + const message = (sessionId: string, id: string, data: object) => + db.run("INSERT INTO message (id, session_id, time_created, data) VALUES (?, ?, ?, ?)", [ + id, + sessionId, + T0, + JSON.stringify(data), + ]) + session("root", null) + session("child", "root") + session("grandchild", "child") + message("root", "m1", { role: "user", time: { created: T0 } }) + db.run("INSERT INTO part (id, session_id, message_id, time_created, data) VALUES ('p1', 'root', 'm1', ?, ?)", [ + T0, + JSON.stringify({ type: "text", text: "hi" }), + ]) + message("root", "m2", { role: "assistant", modelID: "sonnet", tokens: { input: 10 }, time: { created: T0 } }) + db.run("INSERT INTO part (id, session_id, message_id, time_created, data) VALUES ('p2', 'root', 'm2', ?, ?)", [ + T0, + JSON.stringify({ type: "tool", tool: "bash", state: { status: "completed", input: {} } }), + ]) + message("child", "m3", { role: "assistant", modelID: "sonnet", tokens: { input: 5 }, time: { created: T0 } }) + message("grandchild", "m4", { role: "assistant", modelID: "haiku", tokens: { input: 1 }, time: { created: T0 } }) + db.close() + + const stats = readAllSessionStats() + expect(stats).toHaveLength(1) + expect(stats[0]).toMatchObject({ + session_id: "root", + origin: "local", + project: projectFolderName(projectDir), + user_turns: 1, + subagents: 2, + }) + expect(stats[0].totals.tokens.input).toBe(16) + expect(stats[0].totals.tools).toEqual({ bash: { ok: 1, error: 0 } }) + }) + + test("a missing store yields nothing", () => { + expect(readAllSessionStats()).toEqual([]) + }) +}) diff --git a/packages/codev-upload/test/upload.test.ts b/packages/codev-upload/test/upload.test.ts index 78b3642f01..0fd6c1415e 100644 --- a/packages/codev-upload/test/upload.test.ts +++ b/packages/codev-upload/test/upload.test.ts @@ -4,7 +4,8 @@ import { tmpdir } from "node:os" import { join } from "node:path" import { saveCodevConfig, type AuthData } from "@codev/gateway" import { agentLogsDir } from "../src/paths" -import { fileSha256, isRefreshableError, listMarkdownLogs, runUpload } from "../src/upload" +import { isRefreshableError } from "../src/backend" +import { fileSha256, listMarkdownLogs, runUpload } from "../src/upload" let tempDir: string let originalTestHome: string | undefined diff --git a/packages/desktop/src/main/desktop-menu-actions.ts b/packages/desktop/src/main/desktop-menu-actions.ts index dc684893cf..aa15e05e27 100644 --- a/packages/desktop/src/main/desktop-menu-actions.ts +++ b/packages/desktop/src/main/desktop-menu-actions.ts @@ -5,7 +5,6 @@ import { createMainWindow, updateTitlebar } from "./windows" export type DesktopMenuActionHandlers = Partial<{ checkForUpdates: () => void relaunch: () => void - uploadSessions: () => void }> export function runDesktopMenuAction( @@ -20,9 +19,6 @@ export function runDesktopMenuAction( case "app.relaunch": handlers.relaunch?.() return - case "upload.sessions": - handlers.uploadSessions?.() - return case "window.new": createMainWindow() return diff --git a/packages/desktop/src/main/gateway-sso/ipc.ts b/packages/desktop/src/main/gateway-sso/ipc.ts index 825aec50e5..94031177f5 100644 --- a/packages/desktop/src/main/gateway-sso/ipc.ts +++ b/packages/desktop/src/main/gateway-sso/ipc.ts @@ -2,13 +2,10 @@ import { app, ipcMain } from "electron" import type { IpcMainInvokeEvent } from "electron" import type { GatewaySsoController } from "./controller" -// consumeUploadSignInNudge reports whether the background session upload -// stalled for lack of an SSO session, once per app launch (see index.ts) — the -// renderer turns it into the "Session upload is paused" toast. -export function registerGatewaySsoIpcHandlers( - controller: GatewaySsoController, - consumeUploadSignInNudge: () => boolean, -) { +// consumeSignInNudge reports whether the launch-time stats push stalled for +// lack of an SSO session, once per app launch (see index.ts) — the renderer +// turns it into the "Usage reporting is paused" toast. +export function registerGatewaySsoIpcHandlers(controller: GatewaySsoController, consumeSignInNudge: () => boolean) { const subscriptions = new Map<number, () => void>() const unsubscribe = (id: number) => { const off = subscriptions.get(id) @@ -50,7 +47,7 @@ export function registerGatewaySsoIpcHandlers( ) ipcMain.handle("gateway-sso-fetch-models", () => controller.fetchModels()) ipcMain.handle("gateway-sso-ensure-key", () => controller.ensureKey()) - ipcMain.handle("gateway-sso-consume-upload-nudge", () => consumeUploadSignInNudge()) + ipcMain.handle("gateway-sso-consume-signin-nudge", () => consumeSignInNudge()) ipcMain.handle("gateway-sso-get-account", () => controller.getAccount()) ipcMain.handle("gateway-sso-logout", () => controller.logout()) ipcMain.handle("gateway-sso-complete", (_event: IpcMainInvokeEvent, model: unknown) => diff --git a/packages/desktop/src/main/index.ts b/packages/desktop/src/main/index.ts index 3753cbda3b..cafe55444d 100644 --- a/packages/desktop/src/main/index.ts +++ b/packages/desktop/src/main/index.ts @@ -6,9 +6,9 @@ import { tmpdir } from "node:os" import { join } from "node:path" import { getCACertificates, setDefaultCACertificates } from "node:tls" import type { Event } from "electron" -import { app, BrowserWindow, net, Notification, session, shell } from "electron" +import { app, BrowserWindow, net, session, shell } from "electron" import { shouldNudgeSignIn } from "@codev/upload/nudge" -import type { ServerSource as UploadServerSource } from "@codev/upload" +import type { ServerSource as StatsServerSource } from "@codev/upload" import { Deferred, Effect, Fiber } from "effect" import contextMenu from "electron-context-menu" @@ -190,13 +190,13 @@ const main = Effect.gen(function* () { 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[] => + // broker-placeholder credentials — the guest cannot push their statistics, + // 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 stats push 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 sandboxStatsSources = (): StatsServerSource[] => sandboxes.getState().sandboxes.flatMap((item) => item.runtime.kind === "ready" ? [ @@ -212,7 +212,7 @@ const main = Effect.gen(function* () { // 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. + // sandboxes coming up just queues idempotent pushes. let readySandboxes = new Set<string>() sandboxes.subscribe((event) => { const ready = new Set( @@ -220,23 +220,11 @@ const main = Effect.gen(function* () { ) const newlyReady = [...ready].some((id) => !readySandboxes.has(id)) readySandboxes = ready - if (newlyReady) server?.upload(sandboxUploadSources()) + if (newlyReady) server?.stats(sandboxStatsSources()) }) - // Fork: the "Upload Session Logs" menu item — a user-triggered sweep of - // local and work sessions through the sidecar's uploader (same gates: - // opt-out env, sign-in, shared lock). The notification is the only - // feedback a background run can give from here; the sidecar posts no - // completion message back. - const uploadSessionLogs = () => { - server?.upload(sandboxUploadSources()) - new Notification({ - title: "Uploading session logs", - body: "New and changed sessions are being uploaded for analysis in the background.", - }).show() - } - // Fork: the background session upload needs a live CoDev SSO session, but - // the app itself runs on the gateway api key alone — so when the session - // dies, uploads stop silently while everything else keeps working. The + // Fork: the launch-time stats push needs a live CoDev SSO session, but the + // app itself runs on the gateway api key alone — so when the session dies, + // usage reporting stops silently while everything else keeps working. The // sidecar's runner records that stall; the renderer turns it into a toast // whose action opens the same sign-in dialog the Log in/Configure menu item // does (the desktop counterpart of the TUI's startup toast, and like it, @@ -245,7 +233,7 @@ const main = Effect.gen(function* () { // and handed to the first asker only — a second window would otherwise // repeat a toast the user is already looking at. shouldNudgeSignIn is // sqlite-free and cheap, so it is safe on the main process. - let uploadSignInNudge = shouldNudgeSignIn() + let signInNudge = shouldNudgeSignIn() const stopSidecars = async () => { await killSidecar() @@ -365,13 +353,11 @@ const main = Effect.gen(function* () { if (win) sendMenuCommand(win, id) }, checkForUpdates: () => void showUpdaterDialog(updater, true), - uploadSessions: uploadSessionLogs, relaunch, } registerIpcHandlers({ killSidecar: () => killSidecar(), relaunch, - uploadSessions: uploadSessionLogs, awaitInitialization: Effect.fnUntraced( function* () { logger.log("awaiting server ready") @@ -432,16 +418,15 @@ const main = Effect.gen(function* () { // those guests have no model to be working with in the first place. 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 (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()) + // Fork: a fresh sign-in un-stalls the stats push — send 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?.stats(sandboxStatsSources()) }, }), () => { - const stalled = uploadSignInNudge - uploadSignInNudge = false + const stalled = signInNudge + signInNudge = false return stalled }, ) @@ -547,8 +532,8 @@ const main = Effect.gen(function* () { .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) + const sources = sandboxStatsSources() + if (sources.length > 0) server?.stats(sources) }) .catch((error) => logger.error("sandbox initialization failed", error)) diff --git a/packages/desktop/src/main/ipc.ts b/packages/desktop/src/main/ipc.ts index 22f78273de..eb77bd2b8f 100644 --- a/packages/desktop/src/main/ipc.ts +++ b/packages/desktop/src/main/ipc.ts @@ -50,7 +50,6 @@ type Deps = { resolveAppPath: (appName: string) => Promise<string | null> updater: UpdaterController showUpdater: () => Promise<void> | void - uploadSessions: () => void setBackgroundColor: (color: string) => void exportDebugLogs: () => Promise<string> recordFatalRendererError: (error: FatalRendererError) => Promise<void> | void @@ -339,7 +338,6 @@ export function registerIpcHandlers(deps: Deps) { runDesktopMenuAction(BrowserWindow.fromWebContents(event.sender), action, { checkForUpdates: () => void deps.showUpdater(), relaunch: deps.relaunch, - uploadSessions: deps.uploadSessions, }) }) } diff --git a/packages/desktop/src/main/menu.ts b/packages/desktop/src/main/menu.ts index 7d7d347a6e..ac2da7e547 100644 --- a/packages/desktop/src/main/menu.ts +++ b/packages/desktop/src/main/menu.ts @@ -16,7 +16,6 @@ type Deps = { trigger: (id: string) => void checkForUpdates: () => void relaunch: () => void - uploadSessions: () => void } export function createMenu(deps: Deps) { @@ -54,7 +53,6 @@ function nativeItem(entry: DesktopMenuEntry, deps: Deps): MenuItemConstructorOpt item.click = () => runDesktopMenuAction(BrowserWindow.getFocusedWindow(), action, { checkForUpdates: deps.checkForUpdates, - uploadSessions: deps.uploadSessions, relaunch: deps.relaunch, }) } diff --git a/packages/desktop/src/main/server.ts b/packages/desktop/src/main/server.ts index 711f3d2d62..14b0c9b426 100644 --- a/packages/desktop/src/main/server.ts +++ b/packages/desktop/src/main/server.ts @@ -1,7 +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 { ServerSource as StatsServerSource } from "@codev/upload" import type { Details } from "electron" import { getLogger } from "./logging" import { getUserShell, loadShellEnv } from "./shell-env" @@ -15,7 +15,7 @@ type SidecarMessage = | { type: "stopped" } | { type: "error"; error: { message: string; stack?: string } } -export type SidecarListener = { stop: () => Promise<void>; upload: (servers?: UploadServerSource[]) => void } +export type SidecarListener = { stop: () => Promise<void>; stats: (servers?: StatsServerSource[]) => void } const SIDECAR_SERVICE_NAME = "codev server" const SIDECAR_START_STALL_TIMEOUT = 60_000 @@ -167,14 +167,15 @@ export async function spawnLocalServer( return { listener: { - // Fork: re-run the background session upload in the sidecar — used - // 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: (servers?: UploadServerSource[]) => { + // Fork: re-run the stats push in the sidecar — used when a sandbox + // becomes ready (its sessions are only readable then) and after a fresh + // gateway sign-in so the backlog goes out 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). + stats: (servers?: StatsServerSource[]) => { // servers must stay structured-clone-safe: plain url/credential/dir // records, nothing function-valued. - if (!exited) child.postMessage({ type: "upload", servers }) + if (!exited) child.postMessage({ type: "stats", servers }) }, stop: () => { if (stopping) return stopping @@ -225,6 +226,9 @@ function createSidecarEnv(): Record<string, string> { Object.entries(process.env).flatMap(([key, value]) => (value === undefined ? [] : [[key, String(value)]])), ) delete env.DEBUG + // Fork: the sidecar's stats push reports which desktop build sent it; the + // utility process has no Electron app object to ask. + env.CODEV_DESKTOP_VERSION = app.getVersion() if (process.platform === "linux") delete env.LD_PRELOAD return env } diff --git a/packages/desktop/src/main/sidecar.ts b/packages/desktop/src/main/sidecar.ts index fe0b162ae4..c6d6e9a6bf 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, type ServerSource } from "@codev/upload" +import { runStartupStats, type ServerSource } from "@codev/upload" type NodeHttpWithEnvProxy = typeof http & { setGlobalProxyFromEnv: () => void @@ -20,8 +20,8 @@ type StartCommand = { } type StopCommand = { type: "stop" } -type UploadCommand = { type: "upload"; servers?: ServerSource[] } -type SidecarCommand = StartCommand | StopCommand | UploadCommand +type StatsCommand = { type: "stats"; servers?: ServerSource[] } +type SidecarCommand = StartCommand | StopCommand | StatsCommand type SidecarMessage = | { type: "ready" } @@ -47,8 +47,8 @@ parentPort.on("message", (event) => { void stop() return } - if (command.type === "upload") { - void triggerUpload(command.servers) + if (command.type === "stats") { + void triggerStats(command.servers) return } void start(command) @@ -70,13 +70,13 @@ async function start(command: StartCommand) { cors: ["oc://renderer"], }) parentPort.postMessage({ type: "ready" }) - // Fork: ship prior sessions to the analysis backend in the background — - // the desktop's equivalent of the CLI's launch-time upload daemon. Runs - // in this utility process (not Electron main, which is UI-latency - // sensitive); all gating (opt-out env, signed-in via the shared auth - // file, the hub-shared lockfile) lives inside runStartupUpload, so - // desktop, CLI, and hub can never upload concurrently. - void triggerUpload() + // Fork: push session statistics to the analysis backend in the + // background — the desktop's equivalent of the CLI's launch-time stats + // daemon. Runs in this utility process (not Electron main, which is + // UI-latency sensitive); all gating (opt-out env, signed-in via the shared + // auth file, the stats lockfile) lives inside runStartupStats. Logs are + // never uploaded from the desktop — only `codev upload --logs` does. + void triggerStats() } catch (error) { parentPort.postMessage({ type: "error", error: serializeError(error) }) setImmediate(() => process.exit(1)) @@ -140,30 +140,33 @@ 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). 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<void> = Promise.resolve() -function triggerUpload(servers?: ServerSource[]) { - uploadChain = uploadChain.then(async () => { +// Fork: never let the stats push break or block the server — same contract +// as every other caller of runStartupStats (it returns codes rather than +// throwing; the catch is defensive). Runs are chained in-process: the stats +// 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 statsChain: Promise<void> = Promise.resolve() +function triggerStats(servers?: ServerSource[]) { + statsChain = statsChain.then(async () => { try { - const code = await runStartupUpload({ servers }) - console.log(`[upload] run finished code=${code} servers=${servers?.length ?? 0}`) + const code = await runStartupStats({ + client: { app: "desktop", version: process.env.CODEV_DESKTOP_VERSION ?? "unknown" }, + servers, + }) + console.log(`[stats] run finished code=${code} servers=${servers?.length ?? 0}`) } catch (error) { - console.warn(`[upload] run failed: ${serializeError(error).message}`) + console.warn(`[stats] run failed: ${serializeError(error).message}`) } }) - return uploadChain + return statsChain } function parseCommand(value: unknown): SidecarCommand | undefined { if (!value || typeof value !== "object") return const command = value as Partial<SidecarCommand> if (command.type === "stop") return { type: "stop" } - if (command.type === "upload") return { type: "upload", servers: (command as UploadCommand).servers } + if (command.type === "stats") return { type: "stats", servers: (command as StatsCommand).servers } if (command.type !== "start") return if (typeof command.hostname !== "string") return if (typeof command.port !== "number") return diff --git a/packages/desktop/src/preload/index.ts b/packages/desktop/src/preload/index.ts index 4e7c23fa7d..fdf6d364e5 100644 --- a/packages/desktop/src/preload/index.ts +++ b/packages/desktop/src/preload/index.ts @@ -93,7 +93,7 @@ const api: ElectronAPI = { setManualApiKey: (apiKey) => ipcRenderer.invoke("gateway-sso-set-manual-api-key", apiKey), fetchModels: () => ipcRenderer.invoke("gateway-sso-fetch-models"), ensureKey: () => ipcRenderer.invoke("gateway-sso-ensure-key"), - consumeUploadSignInNudge: () => ipcRenderer.invoke("gateway-sso-consume-upload-nudge"), + consumeSignInNudge: () => ipcRenderer.invoke("gateway-sso-consume-signin-nudge"), complete: (model) => ipcRenderer.invoke("gateway-sso-complete", model), getAccount: () => ipcRenderer.invoke("gateway-sso-get-account"), logout: () => ipcRenderer.invoke("gateway-sso-logout"), diff --git a/packages/opencode/src/cli/cmd/debug/index.ts b/packages/opencode/src/cli/cmd/debug/index.ts index 751e6b8691..404ee2e497 100644 --- a/packages/opencode/src/cli/cmd/debug/index.ts +++ b/packages/opencode/src/cli/cmd/debug/index.ts @@ -15,7 +15,7 @@ import { SkillCommand } from "./skill" import { SnapshotCommand } from "./snapshot" import { AgentCommand } from "./agent" import { StartupCommand } from "./startup" -import { UploadCommand } from "./upload" +import { StatsCommand } from "./stats" import { V2Command } from "./v2" export const DebugCommand = cmd({ @@ -32,7 +32,7 @@ export const DebugCommand = cmd({ .command(SkillCommand) .command(SnapshotCommand) .command(StartupCommand) - .command(UploadCommand) + .command(StatsCommand) .command(AgentCommand) .command(V2Command) .command(InfoCommand) diff --git a/packages/opencode/src/cli/cmd/debug/stats.ts b/packages/opencode/src/cli/cmd/debug/stats.ts new file mode 100644 index 0000000000..f24a6c87af --- /dev/null +++ b/packages/opencode/src/cli/cmd/debug/stats.ts @@ -0,0 +1,17 @@ +import { runStartupStats } from "@codev/upload" +import { InstallationVersion } from "@opencode-ai/core/installation/version" +import { cmd } from "../cmd" + +// Fork-only: the launch-time stats push that spawnStatsDaemon fire-and-forgets +// (detached) at every agent launch and after a fresh sign-in. Silent by +// design — its diagnostics land in the stats NDJSON log; gates (opt-out env, +// signed-in, lock) live in @codev/upload's runStartupStats. Never uploads +// logs: only `codev upload --logs` does. `codev upload` runs the same push +// interactively. +export const StatsCommand = cmd({ + command: "stats", + describe: "push session statistics (spawned automatically at launch)", + handler: async () => { + process.exitCode = await runStartupStats({ client: { app: "cli", version: InstallationVersion } }) + }, +}) diff --git a/packages/opencode/src/cli/cmd/debug/upload.ts b/packages/opencode/src/cli/cmd/debug/upload.ts deleted file mode 100644 index 6184abfd4c..0000000000 --- a/packages/opencode/src/cli/cmd/debug/upload.ts +++ /dev/null @@ -1,15 +0,0 @@ -import { runStartupUpload } from "@codev/upload" -import { cmd } from "../cmd" - -// Fork-only: the background-upload entry that spawnUploadDaemon -// fire-and-forgets (detached) at agent startup. Silent by design — its -// diagnostics land in the upload NDJSON log (`codev upload --logs`); gates -// (opt-out env, signed-in, shared lock) live in @codev/upload's -// runStartupUpload. -export const UploadCommand = cmd({ - command: "upload", - describe: "run the startup session upload (spawned automatically at launch)", - handler: async () => { - process.exitCode = await runStartupUpload() - }, -}) diff --git a/packages/opencode/src/cli/cmd/run.ts b/packages/opencode/src/cli/cmd/run.ts index 49681efb9a..11bb3606ab 100644 --- a/packages/opencode/src/cli/cmd/run.ts +++ b/packages/opencode/src/cli/cmd/run.ts @@ -1,6 +1,6 @@ import type { PermissionV1 } from "@opencode-ai/core/v1/permission" import { FSUtil } from "@opencode-ai/core/fs-util" -import { spawnUploadDaemon } from "@codev/upload" +import { spawnStatsDaemon } from "@codev/upload" // CLI entry point for `opencode run` and `opencode --mini`. // // Handles three modes: @@ -262,10 +262,10 @@ export const RunCommand = effectCmd({ describe: "enable direct interactive demo slash commands; pass one as the message to run it immediately", }), handler: Effect.fn("Cli.run")(function* (args) { - // Fork: ship prior sessions to the analysis backend in the background on - // every agent launch (hub-daemon parity). Detached + gated inside; never - // blocks or fails the run. - spawnUploadDaemon() + // Fork: push session statistics to the analysis backend in the background + // on every agent launch. Detached + gated inside; never blocks or fails + // the run. Logs are never uploaded from here — only `codev upload --logs` does. + spawnStatsDaemon() const { Agent } = yield* Effect.promise(() => import("@/agent/agent")) const { RuntimeFlags } = yield* Effect.promise(() => import("@/effect/runtime-flags")) const { InstanceRef } = yield* Effect.promise(() => import("@/effect/instance-ref")) diff --git a/packages/opencode/src/cli/cmd/tui.ts b/packages/opencode/src/cli/cmd/tui.ts index e94a1e38ef..f1bac62a2c 100644 --- a/packages/opencode/src/cli/cmd/tui.ts +++ b/packages/opencode/src/cli/cmd/tui.ts @@ -1,4 +1,4 @@ -import { spawnUploadDaemon } from "@codev/upload" +import { spawnStatsDaemon } from "@codev/upload" import { cmd } from "@/cli/cmd/cmd" import { Rpc } from "@/util/rpc" import { type rpc } from "../tui/worker" @@ -143,10 +143,10 @@ export const TuiThreadCommand = cmd({ hidden: true, }), handler: async (args) => { - // Fork: ship prior sessions to the analysis backend in the background on - // every agent launch (hub-daemon parity). Detached + gated inside; never - // blocks or fails the TUI. - spawnUploadDaemon() + // Fork: push session statistics to the analysis backend in the background + // on every agent launch. Detached + gated inside; never blocks or fails + // the TUI. Logs are never uploaded from here — only `codev upload --logs` does. + spawnStatsDaemon() if (args.replay === true) { UI.error("--replay is not supported; replay is enabled by default") process.exitCode = 1 diff --git a/packages/opencode/src/cli/cmd/upload.ts b/packages/opencode/src/cli/cmd/upload.ts index 5bce769db5..6192a4242a 100644 --- a/packages/opencode/src/cli/cmd/upload.ts +++ b/packages/opencode/src/cli/cmd/upload.ts @@ -1,74 +1,131 @@ import { fetchCodevConfig, saveCodevConfig } from "@codev/gateway" -import { renderLastRun, runUpload } from "@codev/upload" +import { renderLastRun, runLockedPush, runUpload } from "@codev/upload" +import { InstallationVersion } from "@opencode-ai/core/installation/version" import * as prompts from "@clack/prompts" import { EOL } from "os" import { UI } from "../ui" import { cmd } from "./cmd" import { interactiveAuth } from "./sso-prompt" -// Fork-only: `codev upload` — the standalone twin of `codevhub upload`. -// Exports every codev session to the shared agent-logs layout and ships -// new/changed ones to the analysis backend. Signed out, it runs the -// interactive SSO login inline (browser + loopback, with a paste-back -// fallback for machines whose browser can't reach back). +// Fork-only: `codev upload` — sends session usage metrics to the CoDev +// analysis backend now, the same push every launch runs in the background. +// `--logs` switches to the original log upload, the standalone twin of +// `codevhub upload`: exports every codev session to the shared agent-logs +// layout and ships new/changed ones — the only path that uploads logs. +// Signed out, either mode runs the interactive SSO login inline (browser + +// loopback, with a paste-back fallback for machines whose browser can't reach +// back). export const UploadCommand = cmd({ command: "upload", - describe: "upload codev sessions to the CoDev analysis backend", + describe: "upload session usage metrics (or session logs with --logs) to the CoDev analysis backend", builder: (yargs) => yargs + .option("logs", { + type: "boolean", + default: false, + describe: "upload session logs instead of usage metrics", + }) .option("force", { alias: ["f"], type: "boolean", default: false, - describe: "re-upload sessions even when they are unchanged", + describe: "re-send sessions even when they are unchanged", }) - .option("logs", { + .option("diagnostics", { type: "boolean", default: false, - describe: "show the most recent upload run's log instead of uploading", + describe: "show the last run's diagnostic log (with --logs: the last log upload)", }), handler: async (args) => { - if (args.logs) { - const rendered = renderLastRun() - process.stdout.write((rendered ?? "No upload runs logged yet.") + EOL) + if (args.diagnostics) { + const rendered = renderLastRun(args.logs ? "upload" : "stats") + process.stdout.write((rendered ?? `No ${args.logs ? "log upload" : "metrics upload"} runs logged yet.`) + EOL) return } - UI.empty() - prompts.intro("Upload sessions") - const spinner = prompts.spinner() - spinner.start("Preparing...") - try { - const summary = await runUpload({ - force: args.force, - onStatus: (message) => spinner.message(message), - // A fresh login also refreshes the cached CoDev config so the first - // upload attempt doesn't have to fail-and-retry just to learn the - // backend coordinates (hub ensureAuth parity). - getAuth: () => - interactiveAuth(spinner, { - afterLogin: async (auth) => saveCodevConfig(await fetchCodevConfig(auth.access_token)), - }), - }) - if (summary.found === 0) { - spinner.stop("No sessions found") - prompts.log.info(`Looked in ${summary.dbPath ?? "the codev session store"} — start a codev session first.`) - prompts.outro("Done") - return - } - spinner.stop( - `Uploaded ${summary.uploaded}/${summary.found} (${summary.skipped} unchanged, ${summary.failed} failed)`, - ) - for (const error of summary.errors) { - prompts.log.error(`${error.file}: ${error.message}`) - } - prompts.outro(summary.failed > 0 ? "Done, with errors — run `codev upload --logs` for details" : "Done") - if (summary.failed > 0) process.exitCode = 1 - } catch (err) { - spinner.stop("Upload failed", 1) - prompts.log.error(err instanceof Error ? err.message : String(err)) - prompts.outro("Run `codev upload --logs` for the full trail") - process.exitCode = 1 - } + if (args.logs) return uploadLogs(args.force) + return uploadMetrics(args.force) }, }) + +// A fresh login also refreshes the cached CoDev config so the first request +// doesn't have to fail-and-retry just to learn the backend coordinates (hub +// ensureAuth parity). +function signIn(spinner: ReturnType<typeof prompts.spinner>) { + return interactiveAuth(spinner, { + afterLogin: async (auth) => saveCodevConfig(await fetchCodevConfig(auth.access_token)), + }) +} + +// Deliberately ignores CODEV_DISABLE_METRICS: that opts out of the automatic +// launch push, and typing the command is the opt-in. +async function uploadMetrics(force: boolean) { + prompts.intro("Upload usage metrics") + const spinner = prompts.spinner() + spinner.start("Preparing...") + try { + const summary = await runLockedPush({ + client: { app: "cli", version: InstallationVersion }, + force, + onStatus: (message) => spinner.message(message), + getAuth: () => signIn(spinner), + }) + if (!summary) { + spinner.stop("A background metrics upload is already running") + prompts.outro("It sends the same data — run this again once it finishes if you need to") + return + } + if (summary.found === 0) { + spinner.stop("No sessions found") + prompts.outro("Start a codev session first") + return + } + if (summary.unsupported) { + spinner.stop("The analysis backend does not accept usage metrics yet", 1) + prompts.outro("Nothing was sent; it will be retried at the next launch") + process.exitCode = 1 + return + } + spinner.stop(`Sent metrics for ${summary.sent}/${summary.found} session(s) (${summary.unchanged} unchanged)`) + prompts.outro("Done") + } catch (err) { + spinner.stop("Upload failed", 1) + prompts.log.error(err instanceof Error ? err.message : String(err)) + prompts.outro("Run `codev upload --diagnostics` for the full trail") + process.exitCode = 1 + } +} + +async function uploadLogs(force: boolean) { + prompts.intro("Upload session logs") + const spinner = prompts.spinner() + spinner.start("Preparing...") + try { + const summary = await runUpload({ + force, + onStatus: (message) => spinner.message(message), + getAuth: () => signIn(spinner), + }) + if (summary.found === 0) { + spinner.stop("No sessions found") + prompts.log.info(`Looked in ${summary.dbPath ?? "the codev session store"} — start a codev session first.`) + prompts.outro("Done") + return + } + spinner.stop( + `Uploaded ${summary.uploaded}/${summary.found} (${summary.skipped} unchanged, ${summary.failed} failed)`, + ) + for (const error of summary.errors) { + prompts.log.error(`${error.file}: ${error.message}`) + } + prompts.outro( + summary.failed > 0 ? "Done, with errors — run `codev upload --logs --diagnostics` for details" : "Done", + ) + if (summary.failed > 0) process.exitCode = 1 + } catch (err) { + spinner.stop("Upload failed", 1) + prompts.log.error(err instanceof Error ? err.message : String(err)) + prompts.outro("Run `codev upload --logs --diagnostics` for the full trail") + process.exitCode = 1 + } +} diff --git a/packages/opencode/test/cli/help/__snapshots__/help-snapshots.test.ts.snap b/packages/opencode/test/cli/help/__snapshots__/help-snapshots.test.ts.snap index db6c0bc152..9ad98b3674 100644 --- a/packages/opencode/test/cli/help/__snapshots__/help-snapshots.test.ts.snap +++ b/packages/opencode/test/cli/help/__snapshots__/help-snapshots.test.ts.snap @@ -150,7 +150,7 @@ Commands: codev debug skill list all available skills codev debug snapshot snapshot debugging utilities codev debug startup print startup timing - codev debug upload run the startup session upload (spawned automatically at launch) + codev debug stats push session statistics (spawned automatically at launch) codev debug agent <name> show agent configuration details codev debug v2 debug v2 catalog and built-in plugins codev debug info show debug information @@ -436,16 +436,17 @@ Options: exports[`codev CLI help-text snapshots every documented command emits stable help text: codev upload --help 1`] = ` "codev upload -upload codev sessions to the CoDev analysis backend +upload session usage metrics (or session logs with --logs) to the CoDev analysis backend Options: - -h, --help show help [boolean] - -v, --version show version number [boolean] - --print-logs print logs to stderr [boolean] - --log-level log level [string] [choices: "DEBUG", "INFO", "WARN", "ERROR"] - --pure run without external plugins [boolean] - -f, --force re-upload sessions even when they are unchanged [boolean] [default: false] - --logs show the most recent upload run's log instead of uploading + -h, --help show help [boolean] + -v, --version show version number [boolean] + --print-logs print logs to stderr [boolean] + --log-level log level [string] [choices: "DEBUG", "INFO", "WARN", "ERROR"] + --pure run without external plugins [boolean] + --logs upload session logs instead of usage metrics [boolean] [default: false] + -f, --force re-send sessions even when they are unchanged [boolean] [default: false] + --diagnostics show the last run's diagnostic log (with --logs: the last log upload) [boolean] [default: false]" `; diff --git a/packages/tui/src/app.tsx b/packages/tui/src/app.tsx index 236d1c603d..7ce709945e 100644 --- a/packages/tui/src/app.tsx +++ b/packages/tui/src/app.tsx @@ -569,21 +569,21 @@ function App(props: { onSnapshot?: () => Promise<string[]>; pluginHost: TuiPlugi }) }) - // The background session upload needs a live CoDev SSO session, but the - // agent itself runs on the gateway api key alone — so when the session dies - // the agent keeps working while uploads silently stop. The daemon records - // that stall; shouldNudgeSignIn decides whether to surface it here — every - // launch until the sign-in happens. Signing in via /configure both restores - // uploads and, through the dialog's own respawn, ships the backlog - // immediately. + // The launch-time stats push needs a live CoDev SSO session, but the agent + // itself runs on the gateway api key alone — so when the session dies the + // agent keeps working while its usage silently stops reaching the analysis + // backend. The push records that stall; shouldNudgeSignIn decides whether to + // surface it here — every launch until the sign-in happens. Signing in via + // /configure both restores the push and, through the dialog's own respawn, + // sends the backlog immediately. onMount(() => { if (!shouldNudgeSignIn()) return toast.show({ variant: "warning", - title: "Session upload is paused", + title: "Usage reporting is paused", message: - "Your CoDev sign-in expired, so sessions are no longer uploaded for analysis. " + - "Run /configure (or `codev upload` in a terminal) to sign in again — pending sessions upload automatically afterwards.", + "Your CoDev sign-in expired, so session statistics are no longer sent for analysis. " + + "Run /configure (or `codev upload` in a terminal) to sign in again — pending statistics are sent automatically afterwards.", duration: 15000, }) }) diff --git a/packages/tui/src/component/dialog-configure.tsx b/packages/tui/src/component/dialog-configure.tsx index 456f17eaeb..3ac522786e 100644 --- a/packages/tui/src/component/dialog-configure.tsx +++ b/packages/tui/src/component/dialog-configure.tsx @@ -13,7 +13,7 @@ import { saveCodevConfig, silentSso, } from "@codev/gateway" -import { spawnUploadDaemon } from "@codev/upload/spawn" +import { spawnStatsDaemon } from "@codev/upload/spawn" import { TextareaRenderable } from "@opentui/core" import open from "open" import { createSignal, onMount, Show, type Accessor } from "solid-js" @@ -420,10 +420,10 @@ async function runModelFlow(ctx: FlowContext, creds: GatewayCreds) { if (ctx.sync.data.provider.some((item) => item.id === GATEWAY_PROVIDER.id && !!item.models[model])) ctx.local.model.set({ providerID: GATEWAY_PROVIDER.id, modelID: model }, { recent: true }) ctx.toast.show({ variant: "info", message: `CoDev Code configured — using ${model}` }) - // A fresh sign-in un-stalls the background session upload — ship any - // backlog now instead of waiting for the next launch. Detached and gated - // inside (opt-out env, signed-in, shared lock); never blocks the dialog. - spawnUploadDaemon() + // A fresh sign-in un-stalls the stats push — send any backlog now instead + // of waiting for the next launch. Detached and gated inside (opt-out env, + // signed-in, lock); never blocks the dialog. + spawnStatsDaemon() ctx.dialog.clear() } catch (err) { failSetup(ctx, err instanceof Error ? err.message : String(err))