diff --git a/.claude/skills/release/SKILL.md b/.claude/skills/release/SKILL.md index 4649987076..4b0e197282 100644 --- a/.claude/skills/release/SKILL.md +++ b/.claude/skills/release/SKILL.md @@ -186,12 +186,22 @@ git pull origin main git push origin dev ``` +> **Important**: This sync ensures dev has the merge commit from main. Without it, +> dev and main diverge. The CI `update-homebrew` job only pushes the formula +> commit to dev — it does not bring the PR merge commit onto dev. This manual +> `git pull origin main` is what ensures dev has the merge commit. + The GitHub Release is distinct from the git tag — without it, the release won't appear on the repository's Releases page. Always create it. If the user merges the PR themselves and comes back, still offer to tag, release, and sync. ### Step 10: Wait for Release Workflow and Update Homebrew Formula +> **Note**: The `update-homebrew` CI job in `.github/workflows/release.yml` runs automatically +> after the release job and handles the formula update + push to dev (part of Step 10). +> Step 11 (tap sync to `coleam00/homebrew-archon`) is always manual. Check the Actions tab +> before running Step 10 manually. + After the tag is pushed, `.github/workflows/release.yml` builds platform binaries and uploads them to the GitHub release. This takes 5-10 minutes. The Homebrew formula SHA256 values cannot be known until these binaries exist. **Wait for all assets to appear on the release:** @@ -200,16 +210,16 @@ After the tag is pushed, `.github/workflows/release.yml` builds platform binarie echo "Waiting for release workflow to finish uploading binaries..." for i in {1..30}; do ASSET_COUNT=$(gh release view "vx.y.z" --repo coleam00/Archon --json assets --jq '.assets | length') - # Expect 6 assets: 5 binaries (darwin-arm64, darwin-x64, linux-arm64, linux-x64, windows-x64.exe) + checksums.txt - if [ "$ASSET_COUNT" -ge 6 ]; then + # Expect 7 assets: 5 binaries (darwin-arm64, darwin-x64, linux-arm64, linux-x64, windows-x64.exe) + archon-web.tar.gz + checksums.txt + if [ "$ASSET_COUNT" -ge 7 ]; then echo "All $ASSET_COUNT assets uploaded" break fi - echo " Assets so far: $ASSET_COUNT/6 — waiting 30s (attempt $i/30)..." + echo " Assets so far: $ASSET_COUNT/7 — waiting 30s (attempt $i/30)..." sleep 30 done -if [ "$ASSET_COUNT" -lt 6 ]; then +if [ "$ASSET_COUNT" -lt 7 ]; then echo "ERROR: Release workflow did not finish uploading assets after 15 minutes" echo "Check https://github.com/coleam00/Archon/actions for the release workflow run" exit 1 diff --git a/CLAUDE.md b/CLAUDE.md index caa254b740..c28260d351 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -536,6 +536,7 @@ curl http://localhost:3637/api/conversations//messages │ │ └── uploads/{convId}/ # Web UI file uploads (ephemeral) │ └── logs/ # Workflow execution logs ├── web-dist// # Cached web UI dist (archon serve, binary only) +├── update-check.json # Update check cache (binary builds, 24h TTL) ├── archon.db # SQLite database (when DATABASE_URL not set) └── config.yaml # Global configuration (non-secrets) ``` @@ -766,6 +767,9 @@ Pattern: Use `classifyIsolationError()` (from `@archon/isolation`) to map git er **Command Listing:** - `GET /api/commands` - List available command names (bundled + project-defined); optional `?cwd=`; returns `{ commands: [{ name, source: 'bundled' | 'project' }] }` +**System:** +- `GET /api/update-check` - Check for available updates; returns `{ updateAvailable, currentVersion, latestVersion, releaseUrl }`; skips GitHub API call for non-binary builds + **OpenAPI Spec:** - `GET /api/openapi.json` - Generated OpenAPI 3.0 spec for all Zod-validated routes diff --git a/packages/cli/src/cli.ts b/packages/cli/src/cli.ts index 363b516b9c..c32863d271 100755 --- a/packages/cli/src/cli.ts +++ b/packages/cli/src/cli.ts @@ -80,7 +80,13 @@ import { setupCommand } from './commands/setup'; import { validateWorkflowsCommand, validateCommandsCommand } from './commands/validate'; import { serveCommand } from './commands/serve'; import { closeDatabase } from '@archon/core'; -import { setLogLevel, createLogger } from '@archon/paths'; +import { + setLogLevel, + createLogger, + checkForUpdate, + BUNDLED_IS_BINARY, + BUNDLED_VERSION, +} from '@archon/paths'; import * as git from '@archon/git'; /** Lazy-initialized logger (deferred so test mocks can intercept createLogger) */ @@ -159,6 +165,20 @@ async function closeDb(): Promise { } } +async function printUpdateNotice(quiet: boolean | undefined): Promise { + if (quiet || !BUNDLED_IS_BINARY) return; + try { + const result = await checkForUpdate(BUNDLED_VERSION); + if (result?.updateAvailable) { + process.stderr.write( + `Update available: v${result.currentVersion} → v${result.latestVersion} — ${result.releaseUrl}\n` + ); + } + } catch (err) { + getLog().debug({ err }, 'update_check.notice_failed'); + } +} + /** * Main CLI entry point * Returns exit code (0 = success, non-zero = failure) @@ -556,6 +576,7 @@ async function main(): Promise { printUsage(); return 1; } + await printUpdateNotice(values.quiet as boolean | undefined); return 0; } catch (error) { const err = error as Error; diff --git a/packages/docs-web/src/content/docs/reference/api.md b/packages/docs-web/src/content/docs/reference/api.md index 0df9cbbfd0..0e2fa8aa37 100644 --- a/packages/docs-web/src/content/docs/reference/api.md +++ b/packages/docs-web/src/content/docs/reference/api.md @@ -352,6 +352,16 @@ curl -X PATCH http://localhost:3090/api/config/assistants \ --- +## System + +| Method | Path | Description | +|--------|------|-------------| +| GET | `/api/update-check` | Check for available updates (binary builds only) | + +Returns `{ updateAvailable, currentVersion, latestVersion, releaseUrl }`. For non-binary (source) builds, always returns `updateAvailable: false` without making external requests. + +--- + ## SSE Streaming | Path | Description | diff --git a/packages/docs-web/src/content/docs/reference/archon-directories.md b/packages/docs-web/src/content/docs/reference/archon-directories.md index 9b04245041..a718824c3a 100644 --- a/packages/docs-web/src/content/docs/reference/archon-directories.md +++ b/packages/docs-web/src/content/docs/reference/archon-directories.md @@ -32,6 +32,7 @@ Archon provides a unified directory and configuration system with: │ └── worktrees/ # Git worktrees for this project ├── worktrees/ # Legacy global worktrees (for repos not in workspaces/) ├── web-dist// # Cached web UI dist (archon serve, binary only) +├── update-check.json # Update check cache (binary builds only, 24h TTL) └── config.yaml # Global user configuration ``` diff --git a/packages/paths/src/index.ts b/packages/paths/src/index.ts index 3f1790b0dd..99a254f4ca 100644 --- a/packages/paths/src/index.ts +++ b/packages/paths/src/index.ts @@ -34,3 +34,12 @@ export type { Logger } from './logger'; // Build-time constants (rewritten by scripts/build-binaries.sh) export { BUNDLED_IS_BINARY, BUNDLED_VERSION, BUNDLED_GIT_COMMIT } from './bundled-build'; + +// Update check +export { + checkForUpdate, + getCachedUpdateCheck, + isNewerVersion, + parseLatestRelease, +} from './update-check'; +export type { UpdateCheckResult } from './update-check'; diff --git a/packages/paths/src/update-check.test.ts b/packages/paths/src/update-check.test.ts new file mode 100644 index 0000000000..cdfd4b7a30 --- /dev/null +++ b/packages/paths/src/update-check.test.ts @@ -0,0 +1,269 @@ +import { describe, test, expect, spyOn, beforeEach, afterEach } from 'bun:test'; +import { join } from 'path'; +import { mkdirSync, writeFileSync, readFileSync, existsSync, rmSync } from 'fs'; +import { tmpdir } from 'os'; +import { + isNewerVersion, + parseLatestRelease, + checkForUpdate, + getCachedUpdateCheck, +} from './update-check'; + +// ─── isNewerVersion ────────────────────────────────────────────────── + +describe('isNewerVersion', () => { + test('returns true when latest minor is higher', () => { + expect(isNewerVersion('0.3.2', '0.4.0')).toBe(true); + }); + + test('returns true when latest patch is higher', () => { + expect(isNewerVersion('0.3.2', '0.3.3')).toBe(true); + }); + + test('returns false when current is higher', () => { + expect(isNewerVersion('0.4.0', '0.3.9')).toBe(false); + }); + + test('returns false when versions are equal', () => { + expect(isNewerVersion('0.3.2', '0.3.2')).toBe(false); + }); + + test('handles major version differences', () => { + expect(isNewerVersion('0.99.99', '1.0.0')).toBe(true); + }); + + test('handles double-digit segments correctly (not string comparison)', () => { + expect(isNewerVersion('0.9.0', '0.10.0')).toBe(true); + }); +}); + +// ─── parseLatestRelease ────────────────────────────────────────────── + +describe('parseLatestRelease', () => { + test('parses valid response with v prefix', () => { + const result = parseLatestRelease({ + tag_name: 'v0.4.0', + html_url: 'https://github.com/coleam00/Archon/releases/tag/v0.4.0', + }); + expect(result).toEqual({ + version: '0.4.0', + url: 'https://github.com/coleam00/Archon/releases/tag/v0.4.0', + }); + }); + + test('parses tag_name without v prefix', () => { + const result = parseLatestRelease({ + tag_name: '0.4.0', + html_url: 'https://example.com', + }); + expect(result.version).toBe('0.4.0'); + }); + + test('throws on missing tag_name', () => { + expect(() => parseLatestRelease({})).toThrow('Missing tag_name'); + }); + + test('returns empty url when html_url is missing', () => { + const result = parseLatestRelease({ tag_name: 'v1.0.0' }); + expect(result.url).toBe(''); + }); +}); + +// ─── checkForUpdate (with mocked fetch) ────────────────────────────── + +describe('checkForUpdate', () => { + const testDir = join(tmpdir(), `archon-update-check-test-${Date.now()}`); + let originalArchonHome: string | undefined; + + beforeEach(() => { + originalArchonHome = process.env.ARCHON_HOME; + process.env.ARCHON_HOME = testDir; + mkdirSync(testDir, { recursive: true }); + }); + + afterEach(() => { + if (originalArchonHome !== undefined) { + process.env.ARCHON_HOME = originalArchonHome; + } else { + delete process.env.ARCHON_HOME; + } + try { + rmSync(testDir, { recursive: true, force: true }); + } catch { + // ignore cleanup errors + } + }); + + test('returns result from fresh cache without fetching', async () => { + const cache = { + latestVersion: '0.5.0', + releaseUrl: 'https://github.com/coleam00/Archon/releases/tag/v0.5.0', + checkedAt: Date.now(), + }; + writeFileSync(join(testDir, 'update-check.json'), JSON.stringify(cache)); + + const fetchSpy = spyOn(globalThis, 'fetch'); + const result = await checkForUpdate('0.4.0'); + + expect(result).toEqual({ + updateAvailable: true, + currentVersion: '0.4.0', + latestVersion: '0.5.0', + releaseUrl: 'https://github.com/coleam00/Archon/releases/tag/v0.5.0', + }); + expect(fetchSpy).not.toHaveBeenCalled(); + fetchSpy.mockRestore(); + }); + + test('fetches from GitHub when no cache exists', async () => { + const fetchSpy = spyOn(globalThis, 'fetch').mockResolvedValue( + new Response( + JSON.stringify({ + tag_name: 'v0.5.0', + html_url: 'https://github.com/coleam00/Archon/releases/tag/v0.5.0', + }), + { status: 200 } + ) + ); + + const result = await checkForUpdate('0.4.0'); + + expect(result).toEqual({ + updateAvailable: true, + currentVersion: '0.4.0', + latestVersion: '0.5.0', + releaseUrl: 'https://github.com/coleam00/Archon/releases/tag/v0.5.0', + }); + expect(fetchSpy).toHaveBeenCalledTimes(1); + + // Verify cache was written with correct content + const cacheRaw = JSON.parse(readFileSync(join(testDir, 'update-check.json'), 'utf-8')); + expect(cacheRaw.latestVersion).toBe('0.5.0'); + expect(cacheRaw.releaseUrl).toBe('https://github.com/coleam00/Archon/releases/tag/v0.5.0'); + expect(typeof cacheRaw.checkedAt).toBe('number'); + fetchSpy.mockRestore(); + }); + + test('returns null on network error', async () => { + const fetchSpy = spyOn(globalThis, 'fetch').mockRejectedValue(new Error('Network error')); + + const result = await checkForUpdate('0.4.0'); + + expect(result).toBeNull(); + fetchSpy.mockRestore(); + }); + + test('returns null on non-200 HTTP response', async () => { + const fetchSpy = spyOn(globalThis, 'fetch').mockResolvedValue( + new Response('{"message":"rate limit exceeded"}', { status: 403 }) + ); + + const result = await checkForUpdate('0.4.0'); + + expect(result).toBeNull(); + expect(fetchSpy).toHaveBeenCalledTimes(1); + fetchSpy.mockRestore(); + }); + + test('returns updateAvailable: false when current matches latest', async () => { + const cache = { + latestVersion: '0.4.0', + releaseUrl: 'https://github.com/coleam00/Archon/releases/tag/v0.4.0', + checkedAt: Date.now(), + }; + writeFileSync(join(testDir, 'update-check.json'), JSON.stringify(cache)); + + const result = await checkForUpdate('0.4.0'); + + expect(result?.updateAvailable).toBe(false); + }); + + test('fetches when cache is stale', async () => { + const staleCache = { + latestVersion: '0.4.0', + releaseUrl: 'https://example.com', + checkedAt: Date.now() - 25 * 60 * 60 * 1000, // 25 hours ago + }; + writeFileSync(join(testDir, 'update-check.json'), JSON.stringify(staleCache)); + + const fetchSpy = spyOn(globalThis, 'fetch').mockResolvedValue( + new Response( + JSON.stringify({ + tag_name: 'v0.5.0', + html_url: 'https://github.com/coleam00/Archon/releases/tag/v0.5.0', + }), + { status: 200 } + ) + ); + + const result = await checkForUpdate('0.4.0'); + + expect(result?.latestVersion).toBe('0.5.0'); + expect(fetchSpy).toHaveBeenCalledTimes(1); + fetchSpy.mockRestore(); + }); +}); + +// ─── getCachedUpdateCheck ──────────────────────────────────────────── + +describe('getCachedUpdateCheck', () => { + const testDir = join(tmpdir(), `archon-cached-check-test-${Date.now()}`); + let originalArchonHome: string | undefined; + + beforeEach(() => { + originalArchonHome = process.env.ARCHON_HOME; + process.env.ARCHON_HOME = testDir; + mkdirSync(testDir, { recursive: true }); + }); + + afterEach(() => { + if (originalArchonHome !== undefined) { + process.env.ARCHON_HOME = originalArchonHome; + } else { + delete process.env.ARCHON_HOME; + } + try { + rmSync(testDir, { recursive: true, force: true }); + } catch { + // ignore cleanup errors + } + }); + + test('returns null when no cache file', () => { + expect(getCachedUpdateCheck('0.4.0')).toBeNull(); + }); + + test('returns result from cache file', () => { + const cache = { + latestVersion: '0.5.0', + releaseUrl: 'https://example.com', + checkedAt: Date.now(), + }; + writeFileSync(join(testDir, 'update-check.json'), JSON.stringify(cache)); + + const result = getCachedUpdateCheck('0.4.0'); + expect(result?.updateAvailable).toBe(true); + expect(result?.latestVersion).toBe('0.5.0'); + }); + + test('returns null for corrupt cache file', () => { + writeFileSync(join(testDir, 'update-check.json'), 'not json'); + expect(getCachedUpdateCheck('0.4.0')).toBeNull(); + }); + + test('returns null for stale cache', () => { + const staleCache = { + latestVersion: '0.5.0', + releaseUrl: 'https://example.com', + checkedAt: Date.now() - 25 * 60 * 60 * 1000, // 25 hours ago + }; + writeFileSync(join(testDir, 'update-check.json'), JSON.stringify(staleCache)); + expect(getCachedUpdateCheck('0.4.0')).toBeNull(); + }); + + test('returns null when checkedAt is missing', () => { + const cache = { latestVersion: '0.5.0', releaseUrl: 'https://example.com' }; + writeFileSync(join(testDir, 'update-check.json'), JSON.stringify(cache)); + expect(getCachedUpdateCheck('0.4.0')).toBeNull(); + }); +}); diff --git a/packages/paths/src/update-check.ts b/packages/paths/src/update-check.ts new file mode 100644 index 0000000000..26156ae605 --- /dev/null +++ b/packages/paths/src/update-check.ts @@ -0,0 +1,153 @@ +import { join } from 'path'; +import { existsSync, readFileSync, writeFileSync, mkdirSync } from 'fs'; +import { getArchonHome } from './archon-paths'; +import { createLogger } from './logger'; + +const log = createLogger('update-check'); + +interface UpdateCheckCache { + latestVersion: string; + releaseUrl: string; + checkedAt: number; // Date.now() ms +} + +export interface UpdateCheckResult { + updateAvailable: boolean; + currentVersion: string; + latestVersion: string; + releaseUrl: string; +} + +const CACHE_FILE = 'update-check.json'; +const STALENESS_MS = 24 * 60 * 60 * 1000; // 24 hours +const FETCH_TIMEOUT_MS = 3000; // 3 seconds +const GITHUB_API_URL = 'https://api.github.com/repos/coleam00/Archon/releases/latest'; + +function getCachePath(): string { + return join(getArchonHome(), CACHE_FILE); +} + +function readCache(): UpdateCheckCache | null { + const cachePath = getCachePath(); + try { + if (!existsSync(cachePath)) return null; + const raw = readFileSync(cachePath, 'utf-8'); + const data = JSON.parse(raw) as UpdateCheckCache; + if (!data.latestVersion || !data.releaseUrl || typeof data.checkedAt !== 'number') { + return null; + } + if (Date.now() - data.checkedAt > STALENESS_MS) { + return null; + } + return data; + } catch (err) { + log.debug({ err, cachePath }, 'update_check.cache_read_failed'); + return null; + } +} + +function writeCache(cache: UpdateCheckCache): void { + try { + const home = getArchonHome(); + mkdirSync(home, { recursive: true }); + writeFileSync(getCachePath(), JSON.stringify(cache), 'utf-8'); + } catch (err) { + log.debug({ err }, 'update_check.cache_write_failed'); + } +} + +/** + * Compare semver strings: returns true if latest > current. + * Expects plain MAJOR.MINOR.PATCH (no `v` prefix). + */ +export function isNewerVersion(current: string, latest: string): boolean { + const c = current.split('.').map(Number); + const l = latest.split('.').map(Number); + for (let i = 0; i < 3; i++) { + const cv = c[i] ?? 0; + const lv = l[i] ?? 0; + if (lv > cv) return true; + if (lv < cv) return false; + } + return false; +} + +/** + * Parse tag_name and html_url from GitHub API /releases/latest response. + * Strips `v` prefix from tag_name. + */ +export function parseLatestRelease(json: unknown): { version: string; url: string } { + const obj = json as Record; + const tagName = obj.tag_name; + if (typeof tagName !== 'string' || !tagName) { + throw new Error('Missing tag_name in GitHub release response'); + } + const version = tagName.startsWith('v') ? tagName.slice(1) : tagName; + const url = typeof obj.html_url === 'string' ? obj.html_url : ''; + return { version, url }; +} + +/** + * Full update check: read cache → fetch if stale → write cache → return result. + * Network errors are swallowed (returns null). + * Only call when BUNDLED_IS_BINARY is true. + */ +export async function checkForUpdate(currentVersion: string): Promise { + try { + // Try cache first + const cached = readCache(); + if (cached) { + return { + updateAvailable: isNewerVersion(currentVersion, cached.latestVersion), + currentVersion, + latestVersion: cached.latestVersion, + releaseUrl: cached.releaseUrl, + }; + } + + // Fetch from GitHub with timeout + const controller = new AbortController(); + const timeout = setTimeout(() => { + controller.abort(); + }, FETCH_TIMEOUT_MS); + try { + const res = await fetch(GITHUB_API_URL, { + signal: controller.signal, + headers: { 'User-Agent': 'archon-update-check' }, + }); + if (!res.ok) return null; + const json: unknown = await res.json(); + const { version, url } = parseLatestRelease(json); + + // Write cache + writeCache({ latestVersion: version, releaseUrl: url, checkedAt: Date.now() }); + + return { + updateAvailable: isNewerVersion(currentVersion, version), + currentVersion, + latestVersion: version, + releaseUrl: url, + }; + } finally { + clearTimeout(timeout); + } + } catch (err) { + log.debug({ err }, 'update_check.fetch_failed'); + return null; + } +} + +/** + * Sync-only: read cache, compare, return result. No fetch. + * Returns null for stale or corrupt cache entries. + */ +export function getCachedUpdateCheck(currentVersion: string): UpdateCheckResult | null { + const cached = readCache(); + if (!cached) return null; + return { + updateAvailable: isNewerVersion(currentVersion, cached.latestVersion), + currentVersion, + latestVersion: cached.latestVersion, + releaseUrl: cached.releaseUrl, + }; +} diff --git a/packages/server/src/routes/api.ts b/packages/server/src/routes/api.ts index 81afc6db3d..cfade2c012 100644 --- a/packages/server/src/routes/api.ts +++ b/packages/server/src/routes/api.ts @@ -41,6 +41,8 @@ import { getRunArtifactsPath, getArchonHome, isDocker, + checkForUpdate, + BUNDLED_IS_BINARY, } from '@archon/paths'; import { discoverWorkflowsWithConfig } from '@archon/workflows/workflow-discovery'; import { parseWorkflow } from '@archon/workflows/loader'; @@ -67,6 +69,7 @@ import * as workflowDb from '@archon/core/db/workflows'; import * as workflowEventDb from '@archon/core/db/workflow-events'; import * as messageDb from '@archon/core/db/messages'; import { errorSchema } from './schemas/common.schemas'; +import { updateCheckResponseSchema } from './schemas/system.schemas'; import { workflowListResponseSchema, validateWorkflowBodySchema, @@ -831,6 +834,23 @@ const getHealthRoute = createRoute({ }, }); +const getUpdateCheckRoute = createRoute({ + method: 'get', + path: '/api/update-check', + tags: ['System'], + summary: 'Check for available updates', + responses: { + 200: { + content: { + 'application/json': { + schema: updateCheckResponseSchema, + }, + }, + description: 'Update check result', + }, + }, +}); + /** * Register all /api/* routes on the Hono app. */ @@ -2589,4 +2609,16 @@ export function registerApiRoutes( is_docker: isDocker(), }); }); + + registerOpenApiRoute(getUpdateCheckRoute, async c => { + const noUpdate = { + updateAvailable: false, + currentVersion: appVersion, + latestVersion: appVersion, + releaseUrl: '', + }; + if (!BUNDLED_IS_BINARY) return c.json(noUpdate); + const result = await checkForUpdate(appVersion); + return c.json(result ?? noUpdate); + }); } diff --git a/packages/server/src/routes/schemas/system.schemas.ts b/packages/server/src/routes/schemas/system.schemas.ts new file mode 100644 index 0000000000..7ee20ae361 --- /dev/null +++ b/packages/server/src/routes/schemas/system.schemas.ts @@ -0,0 +1,10 @@ +import { z } from '@hono/zod-openapi'; + +export const updateCheckResponseSchema = z + .object({ + updateAvailable: z.boolean(), + currentVersion: z.string(), + latestVersion: z.string(), + releaseUrl: z.string(), + }) + .openapi('UpdateCheckResponse'); diff --git a/packages/web/src/components/layout/TopNav.tsx b/packages/web/src/components/layout/TopNav.tsx index 4f0a100b5b..45924f5004 100644 --- a/packages/web/src/components/layout/TopNav.tsx +++ b/packages/web/src/components/layout/TopNav.tsx @@ -1,7 +1,7 @@ import { NavLink, Link } from 'react-router'; import { useQuery } from '@tanstack/react-query'; import { LayoutDashboard, MessageSquare, Workflow, Settings } from 'lucide-react'; -import { listWorkflowRuns } from '@/lib/api'; +import { listWorkflowRuns, getUpdateCheck } from '@/lib/api'; import { cn } from '@/lib/utils'; const tabs = [ @@ -19,6 +19,14 @@ export function TopNav(): React.ReactElement { }); const hasRunning = (runningRuns?.length ?? 0) > 0; + const { data: updateCheck } = useQuery({ + queryKey: ['update-check'], + queryFn: getUpdateCheck, + staleTime: 60 * 60 * 1000, + refetchInterval: 60 * 60 * 1000, + retry: false, + }); + return ( ); diff --git a/packages/web/src/lib/api.generated.d.ts b/packages/web/src/lib/api.generated.d.ts index afaf22a9ef..193c619588 100644 --- a/packages/web/src/lib/api.generated.d.ts +++ b/packages/web/src/lib/api.generated.d.ts @@ -310,7 +310,10 @@ export interface paths { }; get?: never; put?: never; - /** Send a message to a conversation */ + /** + * Send a message (JSON or multipart with file uploads) + * @description Accepts `application/json` with `{ message: string }` or `multipart/form-data` with a `message` field and optional file attachments (max 5 files, 10 MB each). + */ post: { parameters: { query?: never; @@ -320,11 +323,7 @@ export interface paths { }; cookie?: never; }; - requestBody: { - content: { - 'application/json': components['schemas']['SendMessageBody']; - }; - }; + requestBody?: never; responses: { /** @description Accepted */ 200: { @@ -476,13 +475,528 @@ export interface paths { }; requestBody?: never; responses: { - /** @description Codebase */ + /** @description Codebase */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + 'application/json': components['schemas']['Codebase']; + }; + }; + /** @description Not found */ + 404: { + headers: { + [name: string]: unknown; + }; + content: { + 'application/json': components['schemas']['Error']; + }; + }; + /** @description Server error */ + 500: { + headers: { + [name: string]: unknown; + }; + content: { + 'application/json': components['schemas']['Error']; + }; + }; + }; + }; + put?: never; + post?: never; + /** Delete a codebase and clean up associated resources */ + delete: { + parameters: { + query?: never; + header?: never; + path: { + id: string; + }; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description Deleted */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + 'application/json': components['schemas']['DeleteCodebaseResponse']; + }; + }; + /** @description Not found */ + 404: { + headers: { + [name: string]: unknown; + }; + content: { + 'application/json': components['schemas']['Error']; + }; + }; + /** @description Server error */ + 500: { + headers: { + [name: string]: unknown; + }; + content: { + 'application/json': components['schemas']['Error']; + }; + }; + }; + }; + options?: never; + head?: never; + /** Update codebase consent flags (e.g. allow_env_keys) */ + patch: { + parameters: { + query?: never; + header?: never; + path: { + id: string; + }; + cookie?: never; + }; + requestBody: { + content: { + 'application/json': components['schemas']['UpdateCodebaseBody']; + }; + }; + responses: { + /** @description Updated codebase */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + 'application/json': components['schemas']['Codebase']; + }; + }; + /** @description Not found */ + 404: { + headers: { + [name: string]: unknown; + }; + content: { + 'application/json': components['schemas']['Error']; + }; + }; + /** @description Server error */ + 500: { + headers: { + [name: string]: unknown; + }; + content: { + 'application/json': components['schemas']['Error']; + }; + }; + }; + }; + trace?: never; + }; + '/api/codebases/{id}/env': { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + /** List env vars for a codebase */ + get: { + parameters: { + query?: never; + header?: never; + path: { + id: string; + }; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description Env vars for codebase */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + 'application/json': components['schemas']['CodebaseEnvVarsResponse']; + }; + }; + /** @description Codebase not found */ + 404: { + headers: { + [name: string]: unknown; + }; + content: { + 'application/json': components['schemas']['Error']; + }; + }; + }; + }; + /** Set (upsert) an env var for a codebase */ + put: { + parameters: { + query?: never; + header?: never; + path: { + id: string; + }; + cookie?: never; + }; + requestBody?: { + content: { + 'application/json': components['schemas']['SetEnvVarBody']; + }; + }; + responses: { + /** @description Env var set */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + 'application/json': components['schemas']['EnvVarMutationResponse']; + }; + }; + /** @description Codebase not found */ + 404: { + headers: { + [name: string]: unknown; + }; + content: { + 'application/json': components['schemas']['Error']; + }; + }; + }; + }; + post?: never; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + '/api/codebases/{id}/env/{key}': { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get?: never; + put?: never; + post?: never; + /** Delete an env var from a codebase */ + delete: { + parameters: { + query?: never; + header?: never; + path: { + id: string; + key: string; + }; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description Env var deleted */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + 'application/json': components['schemas']['EnvVarMutationResponse']; + }; + }; + /** @description Codebase not found */ + 404: { + headers: { + [name: string]: unknown; + }; + content: { + 'application/json': components['schemas']['Error']; + }; + }; + }; + }; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + '/api/workflows': { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + /** List available workflows */ + get: { + parameters: { + query?: { + cwd?: string; + }; + header?: never; + path?: never; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description OK */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + 'application/json': components['schemas']['WorkflowListResponse']; + }; + }; + /** @description Bad request */ + 400: { + headers: { + [name: string]: unknown; + }; + content: { + 'application/json': components['schemas']['Error']; + }; + }; + /** @description Server error */ + 500: { + headers: { + [name: string]: unknown; + }; + content: { + 'application/json': components['schemas']['Error']; + }; + }; + }; + }; + put?: never; + post?: never; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + '/api/workflows/{name}/run': { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get?: never; + put?: never; + /** Run a workflow via the orchestrator */ + post: { + parameters: { + query?: never; + header?: never; + path: { + name: string; + }; + cookie?: never; + }; + requestBody: { + content: { + 'application/json': components['schemas']['RunWorkflowBody']; + }; + }; + responses: { + /** @description Accepted */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + 'application/json': components['schemas']['DispatchResponse']; + }; + }; + /** @description Bad request */ + 400: { + headers: { + [name: string]: unknown; + }; + content: { + 'application/json': components['schemas']['Error']; + }; + }; + /** @description Server error */ + 500: { + headers: { + [name: string]: unknown; + }; + content: { + 'application/json': components['schemas']['Error']; + }; + }; + }; + }; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + '/api/dashboard/runs': { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + /** List enriched workflow runs for the Command Center dashboard */ + get: { + parameters: { + query?: { + status?: string; + codebaseId?: string; + search?: string; + after?: string; + before?: string; + limit?: string; + offset?: string; + }; + header?: never; + path?: never; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description OK */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + 'application/json': components['schemas']['DashboardRunsResponse']; + }; + }; + /** @description Server error */ + 500: { + headers: { + [name: string]: unknown; + }; + content: { + 'application/json': components['schemas']['Error']; + }; + }; + }; + }; + put?: never; + post?: never; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + '/api/workflows/runs/{runId}/cancel': { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get?: never; + put?: never; + /** Cancel a workflow run */ + post: { + parameters: { + query?: never; + header?: never; + path: { + runId: string; + }; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description Cancelled */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + 'application/json': components['schemas']['CancelWorkflowRunResponse']; + }; + }; + /** @description Bad request */ + 400: { + headers: { + [name: string]: unknown; + }; + content: { + 'application/json': components['schemas']['Error']; + }; + }; + /** @description Not found */ + 404: { + headers: { + [name: string]: unknown; + }; + content: { + 'application/json': components['schemas']['Error']; + }; + }; + /** @description Server error */ + 500: { + headers: { + [name: string]: unknown; + }; + content: { + 'application/json': components['schemas']['Error']; + }; + }; + }; + }; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + '/api/workflows/runs/{runId}/resume': { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get?: never; + put?: never; + /** Resume a failed workflow run (re-run auto-resumes from completed nodes) */ + post: { + parameters: { + query?: never; + header?: never; + path: { + runId: string; + }; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description Resumed */ 200: { headers: { [name: string]: unknown; }; content: { - 'application/json': components['schemas']['Codebase']; + 'application/json': components['schemas']['WorkflowRunActionResponse']; + }; + }; + /** @description Bad request */ + 400: { + headers: { + [name: string]: unknown; + }; + content: { + 'application/json': components['schemas']['Error']; }; }; /** @description Not found */ @@ -505,27 +1019,49 @@ export interface paths { }; }; }; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + '/api/workflows/runs/{runId}/abandon': { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get?: never; put?: never; - post?: never; - /** Delete a codebase and clean up associated resources */ - delete: { + /** Abandon a workflow run (mark as failed) */ + post: { parameters: { query?: never; header?: never; path: { - id: string; + runId: string; }; cookie?: never; }; requestBody?: never; responses: { - /** @description Deleted */ + /** @description Abandoned */ 200: { headers: { [name: string]: unknown; }; content: { - 'application/json': components['schemas']['DeleteCodebaseResponse']; + 'application/json': components['schemas']['WorkflowRunActionResponse']; + }; + }; + /** @description Bad request */ + 400: { + headers: { + [name: string]: unknown; + }; + content: { + 'application/json': components['schemas']['Error']; }; }; /** @description Not found */ @@ -548,37 +1084,44 @@ export interface paths { }; }; }; + delete?: never; options?: never; head?: never; patch?: never; trace?: never; }; - '/api/workflows': { + '/api/workflows/runs/{runId}/approve': { parameters: { query?: never; header?: never; path?: never; cookie?: never; }; - /** List available workflows */ - get: { + get?: never; + put?: never; + /** Approve a paused workflow run */ + post: { parameters: { - query?: { - cwd?: string; - }; + query?: never; header?: never; - path?: never; + path: { + runId: string; + }; cookie?: never; }; - requestBody?: never; + requestBody?: { + content: { + 'application/json': components['schemas']['ApproveWorkflowRunBody']; + }; + }; responses: { - /** @description OK */ + /** @description Approved */ 200: { headers: { [name: string]: unknown; }; content: { - 'application/json': components['schemas']['WorkflowListResponse']; + 'application/json': components['schemas']['WorkflowRunActionResponse']; }; }; /** @description Bad request */ @@ -590,6 +1133,15 @@ export interface paths { 'application/json': components['schemas']['Error']; }; }; + /** @description Not found */ + 404: { + headers: { + [name: string]: unknown; + }; + content: { + 'application/json': components['schemas']['Error']; + }; + }; /** @description Server error */ 500: { headers: { @@ -601,15 +1153,13 @@ export interface paths { }; }; }; - put?: never; - post?: never; delete?: never; options?: never; head?: never; patch?: never; trace?: never; }; - '/api/workflows/{name}/run': { + '/api/workflows/runs/{runId}/reject': { parameters: { query?: never; header?: never; @@ -618,29 +1168,29 @@ export interface paths { }; get?: never; put?: never; - /** Run a workflow via the orchestrator */ + /** Reject a paused workflow run */ post: { parameters: { query?: never; header?: never; path: { - name: string; + runId: string; }; cookie?: never; }; - requestBody: { + requestBody?: { content: { - 'application/json': components['schemas']['RunWorkflowBody']; + 'application/json': components['schemas']['RejectWorkflowRunBody']; }; }; responses: { - /** @description Accepted */ + /** @description Rejected */ 200: { headers: { [name: string]: unknown; }; content: { - 'application/json': components['schemas']['DispatchResponse']; + 'application/json': components['schemas']['WorkflowRunActionResponse']; }; }; /** @description Bad request */ @@ -652,6 +1202,15 @@ export interface paths { 'application/json': components['schemas']['Error']; }; }; + /** @description Not found */ + 404: { + headers: { + [name: string]: unknown; + }; + content: { + 'application/json': components['schemas']['Error']; + }; + }; /** @description Server error */ 500: { headers: { @@ -669,38 +1228,41 @@ export interface paths { patch?: never; trace?: never; }; - '/api/dashboard/runs': { + '/api/workflows/runs/{runId}': { parameters: { query?: never; header?: never; path?: never; cookie?: never; }; - /** List enriched workflow runs for the Command Center dashboard */ + /** Get workflow run details with events */ get: { parameters: { - query?: { - status?: string; - codebaseId?: string; - search?: string; - after?: string; - before?: string; - limit?: string; - offset?: string; - }; + query?: never; header?: never; - path?: never; + path: { + runId: string; + }; cookie?: never; }; requestBody?: never; responses: { - /** @description OK */ + /** @description Workflow run detail */ 200: { headers: { [name: string]: unknown; }; content: { - 'application/json': components['schemas']['DashboardRunsResponse']; + 'application/json': components['schemas']['WorkflowRunDetail']; + }; + }; + /** @description Not found */ + 404: { + headers: { + [name: string]: unknown; + }; + content: { + 'application/json': components['schemas']['Error']; }; }; /** @description Server error */ @@ -716,23 +1278,8 @@ export interface paths { }; put?: never; post?: never; - delete?: never; - options?: never; - head?: never; - patch?: never; - trace?: never; - }; - '/api/workflows/runs/{runId}/cancel': { - parameters: { - query?: never; - header?: never; - path?: never; - cookie?: never; - }; - get?: never; - put?: never; - /** Cancel a workflow run */ - post: { + /** Delete a workflow run and its events */ + delete: { parameters: { query?: never; header?: never; @@ -743,13 +1290,13 @@ export interface paths { }; requestBody?: never; responses: { - /** @description Cancelled */ + /** @description Deleted */ 200: { headers: { [name: string]: unknown; }; content: { - 'application/json': components['schemas']['CancelWorkflowRunResponse']; + 'application/json': components['schemas']['WorkflowRunActionResponse']; }; }; /** @description Bad request */ @@ -781,7 +1328,6 @@ export interface paths { }; }; }; - delete?: never; options?: never; head?: never; patch?: never; @@ -893,62 +1439,6 @@ export interface paths { patch?: never; trace?: never; }; - '/api/workflows/runs/{runId}': { - parameters: { - query?: never; - header?: never; - path?: never; - cookie?: never; - }; - /** Get workflow run details with events */ - get: { - parameters: { - query?: never; - header?: never; - path: { - runId: string; - }; - cookie?: never; - }; - requestBody?: never; - responses: { - /** @description Workflow run detail */ - 200: { - headers: { - [name: string]: unknown; - }; - content: { - 'application/json': components['schemas']['WorkflowRunDetail']; - }; - }; - /** @description Not found */ - 404: { - headers: { - [name: string]: unknown; - }; - content: { - 'application/json': components['schemas']['Error']; - }; - }; - /** @description Server error */ - 500: { - headers: { - [name: string]: unknown; - }; - content: { - 'application/json': components['schemas']['Error']; - }; - }; - }; - }; - put?: never; - post?: never; - delete?: never; - options?: never; - head?: never; - patch?: never; - trace?: never; - }; '/api/workflows/validate': { parameters: { query?: never; @@ -1422,6 +1912,42 @@ export interface paths { patch?: never; trace?: never; }; + '/api/update-check': { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + /** Check for available updates */ + get: { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description Update check result */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + 'application/json': components['schemas']['UpdateCheckResponse']; + }; + }; + }; + }; + put?: never; + post?: never; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; } export type webhooks = Record; export interface components { @@ -1474,9 +2000,6 @@ export interface components { accepted: boolean; status: string; }; - SendMessageBody: { - message: string; - }; CodebaseCommand: { path: string; description: string; @@ -1487,6 +2010,7 @@ export interface components { repository_url: string | null; default_cwd: string; ai_assistant_type: string; + allow_env_keys: boolean; commands: { [key: string]: components['schemas']['CodebaseCommand']; }; @@ -1497,10 +2021,24 @@ export interface components { AddCodebaseBody: { url?: string; path?: string; + allowEnvKeys?: boolean; + }; + UpdateCodebaseBody: { + allowEnvKeys: boolean; }; DeleteCodebaseResponse: { success: boolean; }; + CodebaseEnvVarsResponse: { + keys: string[]; + }; + EnvVarMutationResponse: { + success: boolean; + }; + SetEnvVarBody: { + key: string; + value: string; + }; DagNode: { id: string; depends_on?: string[]; @@ -1675,6 +2213,55 @@ export interface components { }; mcp?: string; skills?: string[]; + /** @enum {string} */ + effort?: 'low' | 'medium' | 'high' | 'max'; + thinking?: + | { + /** @enum {string} */ + type: 'adaptive'; + } + | { + /** @enum {string} */ + type: 'enabled'; + budgetTokens?: number; + } + | { + /** @enum {string} */ + type: 'disabled'; + }; + maxBudgetUsd?: number; + systemPrompt?: string; + fallbackModel?: string; + betas?: string[]; + sandbox?: { + enabled?: boolean; + autoAllowBashIfSandboxed?: boolean; + allowUnsandboxedCommands?: boolean; + network?: { + allowedDomains?: string[]; + allowManagedDomainsOnly?: boolean; + allowUnixSockets?: string[]; + allowAllUnixSockets?: boolean; + allowLocalBinding?: boolean; + httpProxyPort?: number; + socksProxyPort?: number; + }; + filesystem?: { + allowWrite?: string[]; + denyWrite?: string[]; + denyRead?: string[]; + }; + ignoreViolations?: { + [key: string]: string[]; + }; + enableWeakerNestedSandbox?: boolean; + enableWeakerNetworkIsolation?: boolean; + excludedCommands?: string[]; + ripgrep?: { + command: string; + args?: string[]; + }; + }; command?: string; prompt?: string; bash?: string; @@ -1685,7 +2272,22 @@ export interface components { /** @default false */ fresh_context: boolean; until_bash?: string; + interactive?: boolean; + gate_message?: string; }; + approval?: { + message: string; + capture_response?: boolean; + on_reject?: { + prompt: string; + max_attempts?: number; + }; + }; + cancel?: string; + script?: string; + /** @enum {string} */ + runtime?: 'bun' | 'uv'; + deps?: string[]; timeout?: number; }; WorkflowDefinition: { @@ -1699,6 +2301,54 @@ export interface components { /** @enum {string} */ webSearchMode?: 'disabled' | 'cached' | 'live'; additionalDirectories?: string[]; + interactive?: boolean; + /** @enum {string} */ + effort?: 'low' | 'medium' | 'high' | 'max'; + thinking?: + | { + /** @enum {string} */ + type: 'adaptive'; + } + | { + /** @enum {string} */ + type: 'enabled'; + budgetTokens?: number; + } + | { + /** @enum {string} */ + type: 'disabled'; + }; + fallbackModel?: string; + betas?: string[]; + sandbox?: { + enabled?: boolean; + autoAllowBashIfSandboxed?: boolean; + allowUnsandboxedCommands?: boolean; + network?: { + allowedDomains?: string[]; + allowManagedDomainsOnly?: boolean; + allowUnixSockets?: string[]; + allowAllUnixSockets?: boolean; + allowLocalBinding?: boolean; + httpProxyPort?: number; + socksProxyPort?: number; + }; + filesystem?: { + allowWrite?: string[]; + denyWrite?: string[]; + denyRead?: string[]; + }; + ignoreViolations?: { + [key: string]: string[]; + }; + enableWeakerNestedSandbox?: boolean; + enableWeakerNetworkIsolation?: boolean; + excludedCommands?: string[]; + ripgrep?: { + command: string; + args?: string[]; + }; + }; nodes: components['schemas']['DagNode'][]; }; /** @enum {string} */ @@ -1762,12 +2412,23 @@ export interface components { failed: number; cancelled: number; pending: number; + paused: number; }; }; CancelWorkflowRunResponse: { success: boolean; message: string; }; + WorkflowRunActionResponse: { + success: boolean; + message: string; + }; + ApproveWorkflowRunBody: { + comment?: string; + }; + RejectWorkflowRunBody: { + reason?: string; + }; WorkflowRunListResponse: { runs: components['schemas']['WorkflowRun'][]; }; @@ -1846,8 +2507,6 @@ export interface components { discord: 'stream' | 'batch'; /** @enum {string} */ slack: 'stream' | 'batch'; - /** @enum {string} */ - github: 'stream' | 'batch'; }; concurrency: { maxConversations: number; @@ -1896,6 +2555,14 @@ export interface components { [key: string]: unknown; }; runningWorkflows: number; + version?: string; + is_docker: boolean; + }; + UpdateCheckResponse: { + updateAvailable: boolean; + currentVersion: string; + latestVersion: string; + releaseUrl: string; }; }; responses: never; diff --git a/packages/web/src/lib/api.ts b/packages/web/src/lib/api.ts index f13034f274..6c81aa66b1 100644 --- a/packages/web/src/lib/api.ts +++ b/packages/web/src/lib/api.ts @@ -495,3 +495,9 @@ export async function deleteCodebaseEnvVar( export async function getHealth(): Promise { return fetchJSON('/api/health'); } + +export type UpdateCheckResult = components['schemas']['UpdateCheckResponse']; + +export async function getUpdateCheck(): Promise { + return fetchJSON('/api/update-check'); +}