mirror of
https://github.com/AgentSeal/codeburn.git
synced 2026-08-31 01:56:02 +00:00
merge origin/main into pr994
This commit is contained in:
commit
7fa7b36d00
11 changed files with 491 additions and 38 deletions
88
.github/workflows/build-windows-installer.yml
vendored
Normal file
88
.github/workflows/build-windows-installer.yml
vendored
Normal file
|
|
@ -0,0 +1,88 @@
|
|||
name: Build Windows installer
|
||||
|
||||
on:
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
release_tag:
|
||||
description: Existing desktop-v* release to verify after manual asset upload
|
||||
required: false
|
||||
type: string
|
||||
pull_request:
|
||||
paths:
|
||||
- .github/workflows/build-windows-installer.yml
|
||||
- app/**
|
||||
- src/**
|
||||
- scripts/**
|
||||
- package.json
|
||||
- package-lock.json
|
||||
push:
|
||||
tags:
|
||||
- 'desktop-v*'
|
||||
release:
|
||||
types: [published]
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
jobs:
|
||||
nsis:
|
||||
if: ${{ github.event_name != 'release' && !(github.event_name == 'workflow_dispatch' && inputs.release_tag != '') }}
|
||||
runs-on: windows-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v6
|
||||
|
||||
- uses: actions/setup-node@v6
|
||||
with:
|
||||
node-version: 22.13.0
|
||||
cache: npm
|
||||
cache-dependency-path: |
|
||||
package-lock.json
|
||||
app/package-lock.json
|
||||
|
||||
- name: Install CLI dependencies
|
||||
run: npm ci
|
||||
|
||||
- name: Install desktop dependencies
|
||||
run: npm ci --prefix app
|
||||
|
||||
- name: Test installer verifier
|
||||
run: npm --prefix app test -- scripts/verify-windows-installer.test.ts
|
||||
|
||||
- name: Build NSIS installer
|
||||
run: npm --prefix app run package:win
|
||||
|
||||
- name: Verify installer manifest
|
||||
shell: pwsh
|
||||
run: |
|
||||
if ($env:GITHUB_REF_TYPE -eq 'tag') {
|
||||
node app/scripts/verify-windows-installer.mjs --tag $env:GITHUB_REF_NAME
|
||||
} else {
|
||||
node app/scripts/verify-windows-installer.mjs
|
||||
}
|
||||
|
||||
- name: Upload installer artifact
|
||||
uses: actions/upload-artifact@v6
|
||||
with:
|
||||
name: CodeBurn-Windows-Installer
|
||||
path: |
|
||||
app/release/CodeBurn-Setup-*.exe
|
||||
app/release/CodeBurn-Setup-*.exe.blockmap
|
||||
if-no-files-found: error
|
||||
retention-days: 30
|
||||
|
||||
verify-release-assets:
|
||||
if: ${{ (github.event_name == 'release' && startsWith(github.event.release.tag_name, 'desktop-v')) || (github.event_name == 'workflow_dispatch' && inputs.release_tag != '') }}
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v6
|
||||
|
||||
- name: Verify live desktop release assets
|
||||
env:
|
||||
GH_TOKEN: ${{ github.token }}
|
||||
RELEASE_TAG: ${{ github.event.release.tag_name || inputs.release_tag }}
|
||||
run: |
|
||||
gh api "repos/${{ github.repository }}/releases/tags/$RELEASE_TAG" \
|
||||
--jq '[.assets[].name]' > "$RUNNER_TEMP/release-assets.json"
|
||||
node app/scripts/verify-windows-installer.mjs \
|
||||
--tag "$RELEASE_TAG" \
|
||||
--release-assets "$RUNNER_TEMP/release-assets.json"
|
||||
|
|
@ -28,6 +28,7 @@
|
|||
- **The resident `codeburn serve` child.** The first real panel request is also the cache warm-up, so startup never runs an artificial warm-up query beside a duplicate one-shot child; each served command carries its own read-only option allowlist, and anything outside it falls back to a normal spawn; the child exits when its stdin closes, so it can never outlive the app. Requests whose response exceeds the 16 MiB frame limit still replace the child, but that deliberate kill no longer spends the resident's unexpected-death budget. (#972)
|
||||
|
||||
### Fixed
|
||||
- **Old durable sources remain visible while they still exist.** The 90-day session-cache age-out now applies only after a durable source disappears from discovery, so an unchanged older Copilot source keeps reporting usage and reuses its persisted fingerprint instead of being reparsed and immediately discarded. (#987) On long-lived machines this makes previously dropped history reappear, so lifetime totals can jump once after upgrading.
|
||||
- **`optimize` no longer treats subagent transcripts as your sessions.** Claude Code writes each subagent's transcript to its own `subagents/agent-*.jsonl` file with `isSidechain: true` on every entry, and optimize counted each one as a user-started session. That inflated the session count in the header and fed the session-level detectors a population that fails their tests by construction: a sidechain is handed a large context and returns a short answer (context-heavy), and it never commits or opens a PR because its parent does (low-worth). Excluded from sidechains now: the header session count, the `low-worth-sessions`, `context-bloat`, `cost-outliers` and `capability-reliability` detectors, the coaching notes, the file-churn table, the median time-to-first-edit, the worst one-shot category, and the model-default recommendation - plus `duplicate-reads`, because a subagent starts on a fresh context and re-reading what its parent read is a necessary read, not a repeat. Everything else keeps the full population: `build-folder-reads` and `read-edit-ratio` still count calls made inside a sidechain, since reading `node_modules` or editing without reading is the same waste whoever does it and the `CLAUDE.md` rule they suggest binds subagents too, and so do the MCP, cache-bloat, ghost-command and configuration-overhead findings. Classification is sticky across the whole file, so calls that appear before the first marked entry are reclassified too, and `isSidechain` now survives the compact parser's 32 KB large-line path and warm-cache range rebuilds. Nothing is deleted from spend: sidechain tokens, calls and cost stay in every total and in `status`, and the optimize result cache keys on sidechain identity so a run cannot be served a pre-fix result. Absent markers still read as user-started, so no cache re-parse is needed. (#974)
|
||||
- **`optimize` no longer offers `claude mcp remove` for claude.ai connectors, and its MCP schema-cost estimate is per session.** A `claude_ai_*` namespace that no readable local MCP config claims is a claude.ai connector, managed through `/mcp` or claude.ai Settings rather than as a local MCP server (a local server that carries the prefix keeps its removal command and gains a same-name connector note); low-coverage findings now render them as a manual follow-up and build `--apply` plans only for exact local server names found in readable MCP config, so mixed findings remove only the local subset and the "apply-able" subtotal counts only that subset. The same change replaces the old global schema-cost cap with per-session, per-server proportional attribution — a more accurate model that lowers `mcp-low-coverage` estimates for everyone, connectors or not (on a large corpus roughly by half). (#975, #991)
|
||||
- **Bash command splitting was quadratic on long whitespace-heavy commands.** The separator regex retried its leading `\s*` from every offset; matching the separator alone and widening over whitespace by hand makes cold parse ~24% and warm ~40% faster on large corpora, output unchanged.
|
||||
|
|
|
|||
|
|
@ -1,8 +1,10 @@
|
|||
# Releasing CodeBurn
|
||||
|
||||
This document describes the actual steps a maintainer takes to cut a CLI or macOS menubar release. CLI releases are run by hand with `npm publish`; macOS menubar releases are automated by `.github/workflows/release-menubar.yml` when a `mac-v*` tag is pushed.
|
||||
This document describes the actual steps a maintainer takes to cut CLI, macOS menubar, and Electron desktop releases. CLI releases are run by hand with `npm publish`; macOS menubar releases are automated by `.github/workflows/release-menubar.yml` when a `mac-v*` tag is pushed.
|
||||
|
||||
The Electron desktop app (`app/`) has no CI automation yet, but it is released manually under `desktop-v<version>` tags: build the artifacts on a macOS host (see `app/DISTRIBUTION.md`) and `gh release upload desktop-v<version> … --clobber` them onto the release. See `app/DISTRIBUTION.md` for how to build and distribute it as an ad-hoc-signed, non-notarized macOS build (plus unsigned Windows and Linux builds).
|
||||
The Electron desktop app (`app/`) is released manually under `desktop-v<version>` tags. Build macOS and Linux artifacts as described in `app/DISTRIBUTION.md`; the tag also runs the read-only `Build Windows installer` workflow on `windows-latest`. Download its `CodeBurn-Windows-Installer` artifact and upload both the `.exe` and `.exe.blockmap` with the other platform assets. The workflow never publishes release assets.
|
||||
|
||||
Before announcing a desktop release, the release owner must confirm the live GitHub Release contains all four macOS `.dmg`/`.zip` files, the Linux `.AppImage`, `.deb`, and `.rpm`, and both Windows installer files. Publishing the Release runs the workflow's read-only live-asset verification job. If assets are uploaded after publication, rerun `Build Windows installer` with the `release_tag` input and require that verification job to pass. A failed or missing verification is a release blocker.
|
||||
|
||||
## Versioning
|
||||
|
||||
|
|
@ -197,4 +199,4 @@ For the menubar, tag a new mac-v0.9.9 and let the workflow build and upload it.
|
|||
|
||||
## Summary
|
||||
|
||||
The CLI release is manual: bump the version, update `CHANGELOG.md`, commit, run `npm publish`, then tag and create a GitHub Release. The macOS menubar release is automated: pushing a `mac-v*` tag fires `.github/workflows/release-menubar.yml`, which builds, signs, zips, and publishes the bundle. The homebrew-core formula is updated automatically or via `brew bump-formula-pr`.
|
||||
The CLI release is manual: bump the version, update `CHANGELOG.md`, commit, run `npm publish`, then tag and create a GitHub Release. The macOS menubar release is automated: pushing a `mac-v*` tag fires `.github/workflows/release-menubar.yml`, which builds, signs, zips, and publishes the bundle. The Electron desktop release is assembled manually under a `desktop-v*` tag, with the release-authoritative Windows NSIS installer built by the read-only `windows-latest` workflow. The homebrew-core formula is updated automatically or via `brew bump-formula-pr`.
|
||||
|
|
|
|||
|
|
@ -3,10 +3,10 @@
|
|||
This document describes how to produce distributable macOS, Windows, and Linux
|
||||
builds of the Electron desktop app. The macOS build is ad-hoc-signed and
|
||||
**not notarized** (no paid Apple Developer account); the Windows and Linux
|
||||
builds are **unsigned**. There is no CI automation for any of this yet (unlike
|
||||
the CLI and menubar release processes in `../RELEASING.md`) — packaging is run
|
||||
by hand on a maintainer's machine. All three targets are produced by
|
||||
`electron-builder` and can be cross-built from a single macOS host.
|
||||
builds are **unsigned**. Windows NSIS packages are built and checked by the
|
||||
`Build Windows installer` GitHub Actions workflow; the other desktop packages
|
||||
are still produced by hand. All three targets are produced by
|
||||
`electron-builder`.
|
||||
|
||||
## The bundled CLI (no install prerequisite)
|
||||
|
||||
|
|
@ -93,8 +93,9 @@ self-contained bundle into `app/build/cli`; see "The bundled CLI" above), then
|
|||
`vite`), then `electron-builder --mac` (whose `afterPack` hook copies the
|
||||
staged CLI into the app). `package:win` and `package:linux` mirror it exactly,
|
||||
swapping the final flag for `electron-builder --win` and `electron-builder
|
||||
--linux`. All three can run on the same macOS host — electron-builder downloads
|
||||
the NSIS and AppImage tooling on first use.
|
||||
--linux`. Developers can run all three locally on the same macOS host —
|
||||
electron-builder downloads the NSIS and AppImage tooling on first use. Release
|
||||
Windows installers are built by the `windows-latest` workflow described below.
|
||||
|
||||
### Artifacts
|
||||
|
||||
|
|
@ -154,19 +155,20 @@ separate `electron-builder.yml`):
|
|||
|
||||
## Windows and Linux builds
|
||||
|
||||
Both are cross-built from the same macOS host used for the mac build — no
|
||||
Windows or Linux machine, and no `wine`, is required. electron-builder 26
|
||||
embeds the Windows executable's icon/version resources natively and downloads
|
||||
the NSIS and AppImage tooling on first run.
|
||||
Developers can cross-build both locally from the same macOS host used for the
|
||||
mac build — no Windows or Linux machine, and no `wine`, is required.
|
||||
Release-authoritative Windows NSIS installers are instead built by the `Build
|
||||
Windows installer` workflow on `windows-latest`. electron-builder 26 embeds the
|
||||
Windows executable's icon/version resources natively and downloads the NSIS and
|
||||
AppImage tooling on first run.
|
||||
|
||||
### Windows (`package:win`)
|
||||
|
||||
`electron-builder --win` produces a single artifact in `app/release/`:
|
||||
`electron-builder --win` produces a single installer in `app/release/`:
|
||||
|
||||
- **`CodeBurn Setup 0.9.15.exe`** — the NSIS installer (the version number
|
||||
tracks `package.json`; note the spaces in the filename). A `.exe.blockmap`
|
||||
is written alongside it (differential-update metadata, unused — no
|
||||
auto-updater yet).
|
||||
- **`CodeBurn-Setup-0.9.15.exe`** — the NSIS installer (the version number
|
||||
tracks `package.json`). A `.exe.blockmap` is written alongside it
|
||||
(differential-update metadata, unused — no auto-updater yet).
|
||||
|
||||
Config (`build.win` + `build.nsis`):
|
||||
|
||||
|
|
@ -236,8 +238,7 @@ taskbar/dock; it does not affect packaging or launch.
|
|||
|
||||
## Releases
|
||||
|
||||
There is no release CI for the desktop app yet (see the note at the top). When
|
||||
a maintainer cuts a desktop release by hand, the GitHub tag convention is:
|
||||
When a maintainer cuts a desktop release, the GitHub tag convention is:
|
||||
|
||||
```
|
||||
desktop-v<version> # e.g. desktop-v0.9.15
|
||||
|
|
@ -245,14 +246,31 @@ desktop-v<version> # e.g. desktop-v0.9.15
|
|||
|
||||
This mirrors the menubar's `mac-v<version>` convention (see `../RELEASING.md`)
|
||||
and keeps the desktop app's tags in their own namespace, separate from the CLI
|
||||
(`v<version>`) and the menubar (`mac-v<version>`). Upload all of the artifacts
|
||||
above — the four macOS `.dmg`/`.zip` files, `CodeBurn-Setup-<version>.exe`,
|
||||
and `CodeBurn-<version>.AppImage` — to the GitHub Release created at that
|
||||
tag. The website's download links **pin that tag** in their URLs, so the
|
||||
release name and the artifact filenames must match exactly. (The Windows
|
||||
installer uses an explicit `nsis.artifactName` of
|
||||
`CodeBurn-Setup-${version}.${ext}` — electron-builder's default contains
|
||||
spaces, which make ugly percent-encoded URLs.)
|
||||
(`v<version>`) and the menubar (`mac-v<version>`).
|
||||
|
||||
Pushing a `desktop-v<version>` tag runs the `Build Windows installer` workflow
|
||||
on `windows-latest`. The workflow requires the tag version, root package
|
||||
version, and app package version to agree, and it fails unless the build emits
|
||||
exactly one `CodeBurn-Setup-<version>.exe` and one matching
|
||||
`.exe.blockmap` at the top level of `app/release/`. It uploads those exact
|
||||
top-level filenames as the `CodeBurn-Windows-Installer`
|
||||
Actions artifact. The workflow has read-only repository permissions and does
|
||||
**not** publish release assets automatically. Artifacts are retained for 30 days.
|
||||
|
||||
Before publishing the GitHub Release, the release owner must download that
|
||||
workflow artifact and manually upload both Windows files along with the four
|
||||
macOS `.dmg`/`.zip` files, `CodeBurn-<version>.AppImage`,
|
||||
`codeburn-desktop_<version>_amd64.deb`, and
|
||||
`codeburn-desktop-<version>.x86_64.rpm`. Confirm the live release contains
|
||||
every required platform asset before announcing it. The
|
||||
website's download links **pin that tag** in their URLs, so a release with a
|
||||
missing installer is broken even when another Windows distribution channel is
|
||||
available. The Windows installer uses an explicit `nsis.artifactName` of
|
||||
`CodeBurn-Setup-${version}.${ext}`.
|
||||
|
||||
Publishing the Release triggers a read-only live-asset check. If the files are
|
||||
uploaded afterward, rerun the workflow manually with `release_tag` set to the
|
||||
existing `desktop-v<version>` tag and require the verification job to pass.
|
||||
|
||||
## Verifying a build
|
||||
|
||||
|
|
|
|||
101
app/scripts/verify-windows-installer.mjs
Normal file
101
app/scripts/verify-windows-installer.mjs
Normal file
|
|
@ -0,0 +1,101 @@
|
|||
#!/usr/bin/env node
|
||||
|
||||
import { readFileSync, readdirSync } from 'node:fs'
|
||||
import { basename, join, resolve } from 'node:path'
|
||||
import { rootFromModuleUrl } from './windows-installer-paths.mjs'
|
||||
|
||||
function fail(message) {
|
||||
console.error(`Windows installer manifest invalid: ${message}`)
|
||||
process.exitCode = 1
|
||||
}
|
||||
|
||||
function option(name, fallback) {
|
||||
const index = process.argv.indexOf(name)
|
||||
if (index === -1) return fallback
|
||||
if (!process.argv[index + 1]) throw new Error(`${name} requires a value`)
|
||||
return process.argv[index + 1]
|
||||
}
|
||||
|
||||
function packageVersion(path) {
|
||||
return JSON.parse(readFileSync(path, 'utf8')).version
|
||||
}
|
||||
|
||||
function filesBelow(directory) {
|
||||
return readdirSync(directory, { withFileTypes: true })
|
||||
.filter(entry => entry.isFile())
|
||||
.map(entry => basename(entry.name))
|
||||
}
|
||||
|
||||
function releaseVersion(tag) {
|
||||
const match = /^desktop-v(.+)$/.exec(tag)
|
||||
if (!match) throw new Error(`${tag || '(missing tag)'} is not a desktop release tag`)
|
||||
return match[1]
|
||||
}
|
||||
|
||||
function verifyLiveRelease(tag, assetPath) {
|
||||
const version = releaseVersion(tag)
|
||||
const assets = JSON.parse(readFileSync(assetPath, 'utf8'))
|
||||
if (!Array.isArray(assets) || assets.some(asset => typeof asset !== 'string')) {
|
||||
throw new Error('release asset manifest must be a JSON array of names')
|
||||
}
|
||||
const required = [
|
||||
`CodeBurn-${version}-arm64.dmg`,
|
||||
`CodeBurn-${version}.dmg`,
|
||||
`CodeBurn-${version}-arm64-mac.zip`,
|
||||
`CodeBurn-${version}-mac.zip`,
|
||||
`CodeBurn-${version}.AppImage`,
|
||||
`codeburn-desktop_${version}_amd64.deb`,
|
||||
`codeburn-desktop-${version}.x86_64.rpm`,
|
||||
`CodeBurn-Setup-${version}.exe`,
|
||||
`CodeBurn-Setup-${version}.exe.blockmap`,
|
||||
]
|
||||
for (const expected of required) {
|
||||
const count = assets.filter(asset => asset === expected).length
|
||||
if (count === 0) fail(`live release is missing ${expected}`)
|
||||
if (count > 1) fail(`live release contains ${count} copies of ${expected}`)
|
||||
}
|
||||
if (!process.exitCode) console.log(`Live desktop release assets verified for ${version}`)
|
||||
}
|
||||
|
||||
try {
|
||||
const tag = option('--tag', '')
|
||||
const releaseAssets = option('--release-assets', '')
|
||||
if (releaseAssets) {
|
||||
verifyLiveRelease(tag, resolve(releaseAssets))
|
||||
} else {
|
||||
const root = resolve(option('--root', rootFromModuleUrl(import.meta.url)))
|
||||
const artifacts = resolve(option('--artifacts', join(root, 'app', 'release')))
|
||||
const rootVersion = packageVersion(join(root, 'package.json'))
|
||||
const appVersion = packageVersion(join(root, 'app', 'package.json'))
|
||||
|
||||
if (rootVersion !== appVersion) {
|
||||
fail(`root version ${rootVersion} does not match app version ${appVersion}`)
|
||||
}
|
||||
|
||||
if (tag && tag !== `desktop-v${appVersion}`) {
|
||||
fail(`${tag} does not match app version ${appVersion}`)
|
||||
}
|
||||
|
||||
const files = filesBelow(artifacts)
|
||||
const expectedArtifacts = [
|
||||
`CodeBurn-Setup-${appVersion}.exe`,
|
||||
`CodeBurn-Setup-${appVersion}.exe.blockmap`,
|
||||
]
|
||||
for (const expected of expectedArtifacts) {
|
||||
const count = files.filter(file => file === expected).length
|
||||
if (count !== 1) fail(`expected exactly one ${expected}, found ${count}`)
|
||||
}
|
||||
|
||||
const installerArtifacts = files.filter(file => /^CodeBurn-Setup-.*\.exe(?:\.blockmap)?$/.test(file))
|
||||
const unexpected = installerArtifacts.filter(file => !expectedArtifacts.includes(file))
|
||||
if (unexpected.length > 0) {
|
||||
fail(`unexpected Windows installer artifacts: ${unexpected.join(', ')}`)
|
||||
}
|
||||
|
||||
if (!process.exitCode) {
|
||||
console.log(`Windows installer manifest verified for ${appVersion}`)
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
fail(error instanceof Error ? error.message : String(error))
|
||||
}
|
||||
168
app/scripts/verify-windows-installer.test.ts
Normal file
168
app/scripts/verify-windows-installer.test.ts
Normal file
|
|
@ -0,0 +1,168 @@
|
|||
import { mkdtempSync, mkdirSync, writeFileSync } from 'node:fs'
|
||||
import { tmpdir } from 'node:os'
|
||||
import { join } from 'node:path'
|
||||
import { spawnSync } from 'node:child_process'
|
||||
import { fileURLToPath } from 'node:url'
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { rootFromModuleUrl } from './windows-installer-paths.mjs'
|
||||
|
||||
const verifier = new URL('./verify-windows-installer.mjs', import.meta.url)
|
||||
const verifierPath = fileURLToPath(verifier)
|
||||
|
||||
function fixture(options: {
|
||||
appVersion?: string
|
||||
rootVersion?: string
|
||||
files?: string[]
|
||||
tag?: string
|
||||
} = {}) {
|
||||
const root = mkdtempSync(join(tmpdir(), 'codeburn-windows-manifest-'))
|
||||
const appDir = join(root, 'app')
|
||||
const releaseDir = join(appDir, 'release')
|
||||
mkdirSync(releaseDir, { recursive: true })
|
||||
|
||||
const appVersion = options.appVersion ?? '1.2.3'
|
||||
writeFileSync(join(root, 'package.json'), JSON.stringify({ version: options.rootVersion ?? appVersion }))
|
||||
writeFileSync(join(appDir, 'package.json'), JSON.stringify({ version: appVersion }))
|
||||
for (const file of options.files ?? [
|
||||
`CodeBurn-Setup-${appVersion}.exe`,
|
||||
`CodeBurn-Setup-${appVersion}.exe.blockmap`,
|
||||
]) {
|
||||
const path = join(releaseDir, file)
|
||||
mkdirSync(join(path, '..'), { recursive: true })
|
||||
writeFileSync(path, 'fixture')
|
||||
}
|
||||
|
||||
const args = [verifierPath, '--root', root, '--artifacts', releaseDir]
|
||||
if (options.tag) args.push('--tag', options.tag)
|
||||
return spawnSync(process.execPath, args, { encoding: 'utf8' })
|
||||
}
|
||||
|
||||
function releaseFixture(files: string[]) {
|
||||
const root = mkdtempSync(join(tmpdir(), 'codeburn-windows-release-'))
|
||||
const assets = join(root, 'assets.json')
|
||||
writeFileSync(assets, JSON.stringify(files))
|
||||
return spawnSync(process.execPath, [
|
||||
verifierPath,
|
||||
'--tag',
|
||||
'desktop-v1.2.3',
|
||||
'--release-assets',
|
||||
assets,
|
||||
], { encoding: 'utf8' })
|
||||
}
|
||||
|
||||
describe('Windows installer release manifest verifier', () => {
|
||||
it('converts a Windows module URL into a valid drive-letter repository root', () => {
|
||||
expect(rootFromModuleUrl(
|
||||
'file:///D:/a/codeburn/codeburn/app/scripts/verify-windows-installer.mjs',
|
||||
true,
|
||||
)).toBe('D:\\a\\codeburn\\codeburn')
|
||||
})
|
||||
|
||||
it('accepts one exact installer and blockmap for matching package versions and tag', () => {
|
||||
const result = fixture({ tag: 'desktop-v1.2.3' })
|
||||
|
||||
expect(result.status).toBe(0)
|
||||
expect(result.stdout).toContain('Windows installer manifest verified for 1.2.3')
|
||||
})
|
||||
|
||||
it('rejects a desktop tag that does not match the app version', () => {
|
||||
const result = fixture({ tag: 'desktop-v1.2.4' })
|
||||
|
||||
expect(result.status).toBe(1)
|
||||
expect(result.stderr).toContain('desktop-v1.2.4 does not match app version 1.2.3')
|
||||
})
|
||||
|
||||
it('rejects divergent root and app versions', () => {
|
||||
const result = fixture({ rootVersion: '1.2.2' })
|
||||
|
||||
expect(result.status).toBe(1)
|
||||
expect(result.stderr).toContain('root version 1.2.2 does not match app version 1.2.3')
|
||||
})
|
||||
|
||||
it('rejects a missing installer blockmap', () => {
|
||||
const result = fixture({ files: ['CodeBurn-Setup-1.2.3.exe'] })
|
||||
|
||||
expect(result.status).toBe(1)
|
||||
expect(result.stderr).toContain('expected exactly one CodeBurn-Setup-1.2.3.exe.blockmap, found 0')
|
||||
})
|
||||
|
||||
it('requires installer artifacts at the documented top-level output', () => {
|
||||
const result = fixture({
|
||||
files: [
|
||||
'CodeBurn-Setup-1.2.3.exe.blockmap',
|
||||
'duplicate/CodeBurn-Setup-1.2.3.exe',
|
||||
],
|
||||
})
|
||||
|
||||
expect(result.status).toBe(1)
|
||||
expect(result.stderr).toContain('expected exactly one CodeBurn-Setup-1.2.3.exe, found 0')
|
||||
})
|
||||
|
||||
it('rejects stale installer artifacts from another version', () => {
|
||||
const result = fixture({
|
||||
files: [
|
||||
'CodeBurn-Setup-1.2.3.exe',
|
||||
'CodeBurn-Setup-1.2.3.exe.blockmap',
|
||||
'CodeBurn-Setup-1.2.2.exe',
|
||||
'CodeBurn-Setup-1.2.2.exe.blockmap',
|
||||
],
|
||||
})
|
||||
|
||||
expect(result.status).toBe(1)
|
||||
expect(result.stderr).toContain('unexpected Windows installer artifacts')
|
||||
})
|
||||
|
||||
it('accepts a complete live desktop release asset manifest', () => {
|
||||
const result = releaseFixture([
|
||||
'CodeBurn-1.2.3-arm64.dmg',
|
||||
'CodeBurn-1.2.3.dmg',
|
||||
'CodeBurn-1.2.3-arm64-mac.zip',
|
||||
'CodeBurn-1.2.3-mac.zip',
|
||||
'CodeBurn-1.2.3.AppImage',
|
||||
'codeburn-desktop_1.2.3_amd64.deb',
|
||||
'codeburn-desktop-1.2.3.x86_64.rpm',
|
||||
'CodeBurn-Setup-1.2.3.exe',
|
||||
'CodeBurn-Setup-1.2.3.exe.blockmap',
|
||||
])
|
||||
|
||||
expect(result.status).toBe(0)
|
||||
expect(result.stdout).toContain('Live desktop release assets verified for 1.2.3')
|
||||
})
|
||||
|
||||
it('rejects a live desktop release missing the Windows installer', () => {
|
||||
const result = releaseFixture([
|
||||
'CodeBurn-1.2.3-arm64.dmg',
|
||||
'CodeBurn-1.2.3.dmg',
|
||||
'CodeBurn-1.2.3-arm64-mac.zip',
|
||||
'CodeBurn-1.2.3-mac.zip',
|
||||
'CodeBurn-1.2.3.AppImage',
|
||||
'codeburn-desktop_1.2.3_amd64.deb',
|
||||
'codeburn-desktop-1.2.3.x86_64.rpm',
|
||||
'CodeBurn-Setup-1.2.3.exe.blockmap',
|
||||
])
|
||||
|
||||
expect(result.status).toBe(1)
|
||||
expect(result.stderr).toContain('live release is missing CodeBurn-Setup-1.2.3.exe')
|
||||
})
|
||||
|
||||
it.each([
|
||||
'codeburn-desktop_1.2.3_amd64.deb',
|
||||
'codeburn-desktop-1.2.3.x86_64.rpm',
|
||||
])('rejects a live desktop release missing %s', missing => {
|
||||
const required = [
|
||||
'CodeBurn-1.2.3-arm64.dmg',
|
||||
'CodeBurn-1.2.3.dmg',
|
||||
'CodeBurn-1.2.3-arm64-mac.zip',
|
||||
'CodeBurn-1.2.3-mac.zip',
|
||||
'CodeBurn-1.2.3.AppImage',
|
||||
'codeburn-desktop_1.2.3_amd64.deb',
|
||||
'codeburn-desktop-1.2.3.x86_64.rpm',
|
||||
'CodeBurn-Setup-1.2.3.exe',
|
||||
'CodeBurn-Setup-1.2.3.exe.blockmap',
|
||||
]
|
||||
const result = releaseFixture(required.filter(asset => asset !== missing))
|
||||
|
||||
expect(result.status).toBe(1)
|
||||
expect(result.stderr).toContain(`live release is missing ${missing}`)
|
||||
})
|
||||
})
|
||||
1
app/scripts/windows-installer-paths.d.mts
Normal file
1
app/scripts/windows-installer-paths.d.mts
Normal file
|
|
@ -0,0 +1 @@
|
|||
export function rootFromModuleUrl(moduleUrl: string | URL, windows?: boolean): string
|
||||
8
app/scripts/windows-installer-paths.mjs
Normal file
8
app/scripts/windows-installer-paths.mjs
Normal file
|
|
@ -0,0 +1,8 @@
|
|||
import { posix, win32 } from 'node:path'
|
||||
import { fileURLToPath } from 'node:url'
|
||||
|
||||
export function rootFromModuleUrl(moduleUrl, windows = process.platform === 'win32') {
|
||||
const path = windows ? win32 : posix
|
||||
const scriptPath = fileURLToPath(moduleUrl, { windows })
|
||||
return path.resolve(path.dirname(scriptPath), '..', '..')
|
||||
}
|
||||
|
|
@ -39,7 +39,8 @@ instead of trying to dedupe across stores.
|
|||
wrong-schema, OTel is skipped and the JSONL/transcript sources are used as a fallback.
|
||||
- **Durable cache (monotonic totals).** Copilot is marked `durableSources`: OTel-derived
|
||||
cache entries are never evicted when VS Code prunes old spans from the DB, so
|
||||
month-to-date totals do not drop as the DB rotates. Entries age out after 90 days.
|
||||
month-to-date totals do not drop as the DB rotates. Orphaned entries age out after
|
||||
90 days; sources still present in discovery remain cached regardless of call age.
|
||||
- **Upgrade note.** The first run after upgrading to the OTel version bumps the copilot
|
||||
parse version, which discards the prior copilot cache. Spans already pruned from the DB
|
||||
before the upgrade cannot be recovered, so monotonicity starts from the upgrade point,
|
||||
|
|
|
|||
|
|
@ -3275,8 +3275,9 @@ async function parseProviderSources(
|
|||
}
|
||||
}
|
||||
|
||||
// 90-day age-out for durable providers: remove entries whose newest call is
|
||||
// older than 90 days so the cache doesn't grow unboundedly over time.
|
||||
// 90-day age-out for durable providers: prune only orphaned entries whose
|
||||
// newest call is older than 90 days. Still-discovered sources remain live
|
||||
// regardless of age and keep their persisted fingerprint for reuse.
|
||||
if (!readOnly && provider.durableSources) {
|
||||
const cutoffMs = Date.now() - 90 * 24 * 60 * 60 * 1000
|
||||
for (const [cachedPath, cachedFile] of Object.entries(section.files)) {
|
||||
|
|
@ -3285,7 +3286,7 @@ async function parseProviderSources(
|
|||
.map(c => new Date(c.timestamp).getTime())
|
||||
.filter(ts => !isNaN(ts))
|
||||
.reduce((max, ts) => Math.max(max, ts), 0)
|
||||
if (newestTs > 0 && newestTs < cutoffMs) {
|
||||
if (!allDiscoveredFiles.has(cachedPath) && newestTs > 0 && newestTs < cutoffMs) {
|
||||
delete section.files[cachedPath]
|
||||
markCacheDirty(diskCache, providerName, cachedPath)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -24,6 +24,7 @@ import type { SessionSource, SessionParser, ParsedProviderCall } from '../src/pr
|
|||
let _synthSources: SessionSource[] = []
|
||||
let _synthDurable = false
|
||||
let _synthYields: ParsedProviderCall[] = []
|
||||
let _synthParseCalls = 0
|
||||
let _synthOnParse: (() => void | Promise<void>) | null = null
|
||||
|
||||
vi.mock('../src/providers/index.js', async (importOriginal) => {
|
||||
|
|
@ -54,6 +55,7 @@ vi.mock('../src/providers/index.js', async (importOriginal) => {
|
|||
createSessionParser(_s: SessionSource, _k: Set<string>): SessionParser {
|
||||
return {
|
||||
async *parse(): AsyncGenerator<ParsedProviderCall> {
|
||||
_synthParseCalls++
|
||||
await _synthOnParse?.()
|
||||
for (const call of _synthYields) {
|
||||
// Respect seenKeys so that when multiple sources share the same
|
||||
|
|
@ -193,6 +195,7 @@ beforeEach(async () => {
|
|||
_synthSources = []
|
||||
_synthDurable = false
|
||||
_synthYields = []
|
||||
_synthParseCalls = 0
|
||||
_synthOnParse = null
|
||||
})
|
||||
|
||||
|
|
@ -360,7 +363,7 @@ describe('(d) non-durable provider evicts deleted sources', () => {
|
|||
// (e) 90-day age-out: orphan ≥ 91d old is pruned; ≤ 89d is retained
|
||||
// ═══════════════════════════════════════════════════════════════════════════
|
||||
describe('(e) 90-day age-out for durable providers', () => {
|
||||
it('prunes an orphaned cache entry whose newest call is 91 days old', async () => {
|
||||
it('keeps a discovered 91-day source persisted until discovery removes it', async () => {
|
||||
const synthFile = join(tmpHome, 'synth-age.txt')
|
||||
await writeFile(synthFile, 'placeholder')
|
||||
|
||||
|
|
@ -380,15 +383,76 @@ describe('(e) 90-day age-out for durable providers', () => {
|
|||
userMessage: 'old', sessionId: 'synth-old',
|
||||
}]
|
||||
|
||||
// First parse: cached with 91d-old timestamp → immediately pruned by 90-day check
|
||||
// First refresh: a still-discovered durable source is live and persisted,
|
||||
// regardless of the age of its newest call.
|
||||
const proj1 = await parseAllSessions(undefined, 'test-synthetic')
|
||||
expect(totalOutput(proj1)).toBe(0) // pruned right away
|
||||
expect.soft(totalOutput(proj1)).toBe(8)
|
||||
expect.soft(_synthParseCalls).toBe(1)
|
||||
|
||||
// Confirm: entry is not in the persistent cache after first parse
|
||||
const cache1 = await loadCache()
|
||||
const persisted1 = cache1.providers['test-synthetic']?.files[synthFile]
|
||||
expect.soft(persisted1).toBeDefined()
|
||||
|
||||
// Second refresh: force the public seam through the persisted cache. The
|
||||
// unchanged fingerprint must serve the cached parse without invoking the
|
||||
// provider parser again.
|
||||
clearSessionCache()
|
||||
_synthSources = [] // no longer discovered
|
||||
const proj2 = await parseAllSessions(undefined, 'test-synthetic')
|
||||
expect(totalOutput(proj2)).toBe(0)
|
||||
expect.soft(totalOutput(proj2)).toBe(8)
|
||||
expect.soft(_synthParseCalls).toBe(1)
|
||||
|
||||
const cache2 = await loadCache()
|
||||
expect.soft(cache2.providers['test-synthetic']?.files[synthFile]?.fingerprint)
|
||||
.toEqual(persisted1?.fingerprint)
|
||||
|
||||
// Third refresh: once discovery removes the old source, it becomes an
|
||||
// orphan and the durable 90-day age-out prunes it from results and disk.
|
||||
clearSessionCache()
|
||||
_synthSources = []
|
||||
const proj3 = await parseAllSessions(undefined, 'test-synthetic')
|
||||
expect.soft(totalOutput(proj3)).toBe(0)
|
||||
|
||||
const cache3 = await loadCache()
|
||||
expect.soft(cache3.providers['test-synthetic']?.files[synthFile]).toBeUndefined()
|
||||
})
|
||||
|
||||
it('keeps a discovered 91-day source through a month-scoped refresh', async () => {
|
||||
const synthFile = join(tmpHome, 'synth-scoped.txt')
|
||||
await writeFile(synthFile, 'placeholder')
|
||||
|
||||
const ts91dAgo = new Date(Date.now() - 91 * 24 * 60 * 60 * 1000).toISOString()
|
||||
|
||||
_synthDurable = true
|
||||
_synthSources = [{ path: synthFile, project: 'test', provider: 'test-synthetic' }]
|
||||
_synthYields = [{
|
||||
provider: 'test-synthetic', model: 'gpt-4o',
|
||||
inputTokens: 10, outputTokens: 8,
|
||||
cacheCreationInputTokens: 0, cacheReadInputTokens: 0,
|
||||
cachedInputTokens: 0, reasoningTokens: 0, webSearchRequests: 0,
|
||||
costUSD: 0.002, tools: [], bashCommands: [],
|
||||
timestamp: ts91dAgo,
|
||||
speed: 'standard',
|
||||
deduplicationKey: 'synth-age-out-91d-scoped',
|
||||
userMessage: 'old', sessionId: 'synth-old-scoped',
|
||||
}]
|
||||
|
||||
expect.soft(totalOutput(await parseAllSessions(undefined, 'test-synthetic'))).toBe(8)
|
||||
|
||||
// A today-ranged refresh loads under a month scope that excludes the entry's
|
||||
// shard. Durable providers are never scoped, so the age-out still sees the
|
||||
// entry as discovered and the save must carry its month across intact.
|
||||
clearSessionCache()
|
||||
const today = new Date()
|
||||
const start = new Date(today); start.setHours(0, 0, 0, 0)
|
||||
const end = new Date(today); end.setHours(23, 59, 59, 999)
|
||||
expect.soft(totalOutput(await parseAllSessions({ start, end }, 'test-synthetic'))).toBe(0)
|
||||
|
||||
clearSessionCache()
|
||||
expect.soft(totalOutput(await parseAllSessions(undefined, 'test-synthetic'))).toBe(8)
|
||||
expect.soft(_synthParseCalls).toBe(1)
|
||||
|
||||
const cache = await loadCache()
|
||||
expect.soft(cache.providers['test-synthetic']?.files[synthFile]).toBeDefined()
|
||||
})
|
||||
|
||||
it('retains an orphaned cache entry whose newest call is 89 days old', async () => {
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue