diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index f0cb965..fcf1ba9 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -18,7 +18,7 @@ env: jobs: cli: - name: CLI and packages + name: CLI runs-on: ubuntu-latest timeout-minutes: 10 steps: @@ -43,8 +43,8 @@ jobs: - name: Bundle the sweep engine run: | bun build src/review-sweep.ts --target=bun --format=esm --outfile=/tmp/stupify-review-sweep.ts - if grep -E '@stupify/exe-(cli|host)' /tmp/stupify-review-sweep.ts; then - echo "::error::review-sweep bundle still imports a workspace package" + if grep -E "from ['\"]@(stupify|bevyl-ai)/" /tmp/stupify-review-sweep.ts; then + echo "::error::review-sweep bundle still imports an external package" exit 1 fi @@ -65,12 +65,10 @@ jobs: } ROOT_TGZ="$(pack_tgz . '^stupify-cli-[0-9].*\.tgz$')" - EXE_CLI_TGZ="$(pack_tgz packages/exe-cli '^stupify-exe-cli-[0-9].*\.tgz$')" - EXE_HOST_TGZ="$(pack_tgz packages/exe-host '^stupify-exe-host-[0-9].*\.tgz$')" TMP="$(mktemp -d)" - npm install --prefix "$TMP" "$EXE_CLI_TGZ" "$EXE_HOST_TGZ" "$ROOT_TGZ" + npm install --prefix "$TMP" "$ROOT_TGZ" npm exec --prefix "$TMP" -- stupify --help - rm -rf "$TMP" "$ROOT_TGZ" "$EXE_CLI_TGZ" "$EXE_HOST_TGZ" + rm -rf "$TMP" "$ROOT_TGZ" site: name: Site build diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 6543577..0b5731e 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -44,10 +44,9 @@ jobs: - name: Smoke the CLI run: bun src/cli.ts --help - # Bundle the workspace helpers into the CLI so we publish ONE self-contained package (the shape 0.4.x - # shipped before the exe-* split). npm OIDC can't create the never-published @stupify/exe-* packages, and - # a package must exist before it can have a trusted publisher — so we inline them here instead. Runs on the - # ephemeral checkout only; the repo keeps the workspace split for development. + # Bundle the CLI + engine so we publish ONE self-contained package: @bevyl-ai/agent-tools inlines at + # build time, so the published artifact has zero runtime deps beyond clack/picocolors. Runs on the + # ephemeral checkout only; the repo keeps the real dependency for development. - name: Bundle into a single self-contained package run: | set -euo pipefail @@ -55,10 +54,10 @@ jobs: bun build src/review-sweep.ts --target=bun --format=esm --outfile=src/review-sweep.bundled.ts mv src/cli.bundled.ts src/cli.ts mv src/review-sweep.bundled.ts src/review-sweep.ts - if grep -E '@stupify/exe-(cli|host)' src/cli.ts src/review-sweep.ts; then - echo "::error::bundle still imports a workspace package"; exit 1 + if grep -E "from ['\"]@(stupify|bevyl-ai)/" src/cli.ts src/review-sweep.ts; then + echo "::error::bundle still imports an external package"; exit 1 fi - node -e "const p=require('./package.json'); delete p.dependencies['@stupify/exe-cli']; delete p.dependencies['@stupify/exe-host']; delete p.workspaces; require('fs').writeFileSync('./package.json', JSON.stringify(p,null,2)+'\n')" + node -e "const p=require('./package.json'); delete p.dependencies['@bevyl-ai/agent-tools']; require('fs').writeFileSync('./package.json', JSON.stringify(p,null,2)+'\n')" - name: Check package contents run: | diff --git a/bun.lock b/bun.lock index ed8f68a..b243ead 100644 --- a/bun.lock +++ b/bun.lock @@ -5,10 +5,8 @@ "": { "name": "stupify", "dependencies": { - "@bevyl-ai/agent-tools": "^0.2.0", + "@bevyl-ai/agent-tools": "^0.3.0", "@clack/prompts": "^1.2.0", - "@stupify/exe-cli": "0.4.5", - "@stupify/exe-host": "0.4.5", "picocolors": "^1.1.1", }, "devDependencies": { @@ -17,26 +15,14 @@ "typescript": "^5.7.2", }, }, - "packages/exe-cli": { - "name": "@stupify/exe-cli", - "version": "0.4.5", - }, - "packages/exe-host": { - "name": "@stupify/exe-host", - "version": "0.4.5", - }, }, "packages": { - "@bevyl-ai/agent-tools": ["@bevyl-ai/agent-tools@0.2.0", "", {}, "sha512-dO9+jMUzZ/aIps4s3Xh+lt9FjHb2XBcSW/itmFg0L5c+2ssV7qXcSuos2uAIIe5dtxb3qb0W0cfAqAHjt2YnDQ=="], + "@bevyl-ai/agent-tools": ["@bevyl-ai/agent-tools@0.3.0", "", {}, "sha512-gx16OexHehbIhOHlnGD89GTiANXIV+ZoNYxr35zt8ADl9BNfdRIvyRoupC9yIRrPpCouKLQh7UuF61z/CZYP1Q=="], "@clack/core": ["@clack/core@1.4.1", "", { "dependencies": { "fast-wrap-ansi": "^0.2.0", "sisteransi": "^1.0.5" } }, "sha512-FILJa1gGKEFTGZAJE9RpVhrjKz3c3h4ar60dSv6cGuDqufQ84YEIS3GAGvZiN+H6yaLbbvTFNejjCC4tXpZEuw=="], "@clack/prompts": ["@clack/prompts@1.5.1", "", { "dependencies": { "@clack/core": "1.4.1", "fast-string-width": "^3.0.2", "fast-wrap-ansi": "^0.2.0", "sisteransi": "^1.0.5" } }, "sha512-zccHj2z2oCCO4yrDiRSlFOxWerGqRiysP7a5jPK6uoI9URKAquwY42Dd/iUP8JWHxEzdRe4TlbvZCo8z1/mhrw=="], - "@stupify/exe-cli": ["@stupify/exe-cli@workspace:packages/exe-cli"], - - "@stupify/exe-host": ["@stupify/exe-host@workspace:packages/exe-host"], - "@types/bun": ["@types/bun@1.3.14", "", { "dependencies": { "bun-types": "1.3.14" } }, "sha512-h1hFqFVcvAvD9j9K7ZW7vd82aSA+rTdznZa+5bwvCwqSB1jmmfLcbIWhOLx1/+boy/xmjgCs/OMUL8hRJSmnPw=="], "@types/node": ["@types/node@22.19.21", "", { "dependencies": { "undici-types": "~6.21.0" } }, "sha512-VMeFBSCKQKmm2swI2kW51SFusDqekC6q9trBCvJ/JliDchFSuoYYKN7yVNjPthP1HKZcx3U1gI/wTcEBjEFKTA=="], diff --git a/package.json b/package.json index 1e684bd..e9f89a5 100644 --- a/package.json +++ b/package.json @@ -3,9 +3,6 @@ "version": "0.4.5", "description": "A code reviewer that talks like an idiot and catches real bugs \u2014 corpus-grounded, anti-slop, runs on Codex.", "type": "module", - "workspaces": [ - "packages/*" - ], "bin": { "stupify": "bin/stupify.js" }, @@ -50,11 +47,9 @@ "cli": "bun src/cli.ts" }, "dependencies": { - "@stupify/exe-cli": "0.4.5", - "@stupify/exe-host": "0.4.5", "@clack/prompts": "^1.2.0", "picocolors": "^1.1.1", - "@bevyl-ai/agent-tools": "^0.2.0" + "@bevyl-ai/agent-tools": "^0.3.0" }, "devDependencies": { "@types/bun": "^1.3.14", diff --git a/packages/exe-cli/README.md b/packages/exe-cli/README.md deleted file mode 100644 index b5da4b0..0000000 --- a/packages/exe-cli/README.md +++ /dev/null @@ -1,6 +0,0 @@ -# @stupify/exe-cli - -Control-plane helpers for agents provisioned onto exe.dev. - -`@stupify/cli` consumes this for the laptop/control-plane side: stable Bun discovery, repo and host validation, -cron wiring, exe.dev integration lookup, VM naming, setup-script generation, and Codex gateway config. diff --git a/packages/exe-cli/package.json b/packages/exe-cli/package.json deleted file mode 100644 index de3486e..0000000 --- a/packages/exe-cli/package.json +++ /dev/null @@ -1,21 +0,0 @@ -{ - "name": "@stupify/exe-cli", - "version": "0.4.5", - "description": "Control-plane helpers for provisioning and wiring exe.dev agents.", - "type": "module", - "exports": { - ".": "./src/index.ts" - }, - "files": [ - "src", - "README.md" - ], - "license": "MIT", - "engines": { - "bun": ">=1.3" - }, - "publishConfig": { - "access": "public", - "provenance": true - } -} diff --git a/packages/exe-cli/src/index.ts b/packages/exe-cli/src/index.ts deleted file mode 100644 index e296514..0000000 --- a/packages/exe-cli/src/index.ts +++ /dev/null @@ -1,155 +0,0 @@ -import { spawnSync } from 'node:child_process' -import { existsSync, mkdirSync } from 'node:fs' -import { homedir } from 'node:os' -import { join } from 'node:path' - -export function stableBun(): string { - const running = Bun.which('bun') - if (running && !running.includes('/bun-node-') && !running.startsWith('/tmp/')) return running - for (const c of [join(homedir(), '.bun/bin/bun'), '/opt/homebrew/bin/bun', '/home/linuxbrew/.linuxbrew/bin/bun', '/usr/local/bin/bun', '/usr/bin/bun']) { - if (existsSync(c)) return c - } - return running ?? 'bun' -} - -export function validRepo(repo: string): boolean { - return /^[\w.-]+\/[\w.-]+$/.test(repo) -} - -export function normalizeRepo(input: string): string { - return input - .trim() - .replace(/^@/, '') - .replace(/^https?:\/\/(www\.)?github\.com\//i, '') - .replace(/^git@github\.com:/i, '') - .replace(/\/+$/, '') - .replace(/\.git$/i, '') -} - -export function validHost(host: string): boolean { - return /^[\w.-]+$/.test(host) -} - -export function detectRepo(cwd = process.cwd()): string | null { - const r = spawnSync('git', ['config', '--get', 'remote.origin.url'], { cwd, encoding: 'utf8' }) - if (r.status !== 0) return null - const slug = (r.stdout ?? '') - .trim() - .replace(/^[a-z]+:\/\/[^/]+\//, '') - .replace(/^git@[^:]+:/, '') - .replace(/\.git$/, '') - return validRepo(slug) ? slug : null -} - -export interface CronOptions { - stateDir: string - engineFile: string - ghHost?: string - cadence?: string - removeMarker?: string -} - -export function cronLine(opts: CronOptions): string { - const prefix = opts.ghHost ? `GH_HOST=${opts.ghHost} ` : '' - return `${opts.cadence ?? '*/1 * * * *'} ${prefix}${stableBun()} ${opts.engineFile} >> ${opts.stateDir}/cron.log 2>&1` -} - -export function installCron(opts: CronOptions): string { - mkdirSync(opts.stateDir, { recursive: true }) - const line = cronLine(opts) - const current = spawnSync('crontab', ['-l'], { encoding: 'utf8', timeout: 8_000 }).stdout ?? '' - const removeMarker = opts.removeMarker ?? opts.engineFile - const kept = current - .split('\n') - .filter((l) => l.trim() && !l.includes(removeMarker)) - const next = [...kept, line].join('\n') + '\n' - const wrote = spawnSync('crontab', ['-'], { input: next, encoding: 'utf8', timeout: 8_000 }) - if (wrote.status !== 0) { - const why = (wrote.stderr ?? '').trim() || wrote.error?.message || (wrote.signal ? `timed out (${wrote.signal})` : 'crontab exited non-zero') - throw new Error(`couldn't install the cron job (${why}). your config is saved. add the line yourself:\n ${line}`) - } - return line -} - -export interface ExeResult { - ok: boolean - out: string -} - -export function exe(args: string[], input = ''): ExeResult { - const r = spawnSync('ssh', ['-o', 'ConnectTimeout=25', 'exe.dev', ...args], { - input, - encoding: 'utf8', - maxBuffer: 8 * 1024 * 1024, - timeout: 180_000, - }) - return { ok: r.status === 0, out: (r.stdout ?? '') + (r.stderr ?? '') } -} - -export const vmNameFor = (agentName: string, repo: string): string => - `${agentName}-` + repo.toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/^-|-$/g, '') - -interface ExeIntegration { - name: string - type: string - config?: { - repositories?: string[] - providers?: { - openai?: { - enabled?: boolean - } - } - } -} - -function isObject(raw: unknown): raw is Record { - return typeof raw === 'object' && raw !== null && !Array.isArray(raw) -} - -function stringArray(raw: unknown): string[] | undefined { - return Array.isArray(raw) && raw.every((v) => typeof v === 'string') ? raw : undefined -} - -function isExeIntegration(raw: unknown): raw is ExeIntegration { - if (!isObject(raw) || typeof raw.name !== 'string' || typeof raw.type !== 'string') return false - if (raw.config === undefined) return true - if (!isObject(raw.config)) return false - if (raw.config.repositories !== undefined && stringArray(raw.config.repositories) === undefined) return false - if (raw.config.providers === undefined) return true - if (!isObject(raw.config.providers)) return false - if (raw.config.providers.openai === undefined) return true - if (!isObject(raw.config.providers.openai)) return false - const enabled = raw.config.providers.openai.enabled - return enabled === undefined || typeof enabled === 'boolean' -} - -function parseExeIntegrations(json: string): ExeIntegration[] { - let raw: unknown - try { - raw = JSON.parse(json) - } catch { - return [] - } - return Array.isArray(raw) ? raw.filter(isExeIntegration) : [] -} - -export function githubIntegrationFor(repo: string, runExe: (args: string[]) => ExeResult = exe): string | null { - const r = runExe(['int', 'list', '--json']) - if (!r.ok) return null - return parseExeIntegrations(r.out).find((i) => i.type === 'github' && (i.config?.repositories ?? []).includes(repo))?.name ?? null -} - -export function llmIntegrationFor(runExe: (args: string[]) => ExeResult = exe): string | null { - const r = runExe(['int', 'list', '--json']) - if (!r.ok) return null - return parseExeIntegrations(r.out).find((i) => i.type === 'llm' && i.config?.providers?.openai?.enabled === true)?.name ?? null -} - -export function exeSetupScript(command: string, codexHost?: string): string { - return [ - 'export PATH="$HOME/.bun/bin:/usr/local/bin:$PATH"', - 'command -v bun >/dev/null 2>&1 || curl -fsSL https://bun.sh/install | bash', - 'export PATH="$HOME/.bun/bin:$PATH"', - codexHost ? `${command} --codex-host ${codexHost}` : command, - ].join('\n') -} diff --git a/packages/exe-host/README.md b/packages/exe-host/README.md deleted file mode 100644 index 1c97429..0000000 --- a/packages/exe-host/README.md +++ /dev/null @@ -1,7 +0,0 @@ -# @stupify/exe-host - -Dependency-free runtime helpers for agents that run on an exe.dev box. - -`@stupify/cli` consumes this for the host-side sweep loop: env parsing, process execution, checkout refresh, -single-flight locks, local state files, daily caps, and rate-limit detection. The CLI bundles the sweep before -installing it to `~/.stupify`, so the cron runtime still runs as one dependency-free file on the box. diff --git a/packages/exe-host/package.json b/packages/exe-host/package.json deleted file mode 100644 index 654f7e2..0000000 --- a/packages/exe-host/package.json +++ /dev/null @@ -1,21 +0,0 @@ -{ - "name": "@stupify/exe-host", - "version": "0.4.5", - "description": "Dependency-free host runtime helpers for cron-driven exe.dev agents.", - "type": "module", - "exports": { - ".": "./src/index.ts" - }, - "files": [ - "src", - "README.md" - ], - "license": "MIT", - "engines": { - "bun": ">=1.3" - }, - "publishConfig": { - "access": "public", - "provenance": true - } -} diff --git a/packages/exe-host/src/index.ts b/packages/exe-host/src/index.ts deleted file mode 100644 index fbf92db..0000000 --- a/packages/exe-host/src/index.ts +++ /dev/null @@ -1,188 +0,0 @@ -import { spawnSync } from 'node:child_process' -import { existsSync, mkdirSync, readFileSync, rmSync, statSync, writeFileSync } from 'node:fs' -import { dirname, join } from 'node:path' - -export interface ProcResult { - ok: boolean - stdout: string - combined: string -} - -export interface ExecOptions { - cwd?: string - timeoutMs?: number - input?: string -} - -export function exec(cmd: string, args: string[], opts: ExecOptions = {}): ProcResult { - const r = spawnSync(cmd, args, { - cwd: opts.cwd, - input: opts.input ?? '', - timeout: opts.timeoutMs, - encoding: 'utf8', - maxBuffer: 64 * 1024 * 1024, - }) - const stdout = r.stdout ?? '' - let combined = stdout + (r.stderr ?? '') - if (r.signal) combined += `\n${cmd}: process killed by ${r.signal}${opts.timeoutMs ? ` (timeout ${opts.timeoutMs}ms)` : ''}` - if (r.error) combined += `\n${cmd}: ${r.error.message}` - return { ok: r.status === 0 && r.error === undefined, stdout, combined } -} - -export function parseEnvFile(path: string): Record { - if (!existsSync(path)) return {} - const out: Record = {} - for (const raw of readFileSync(path, 'utf8').split('\n')) { - const line = raw.trim() - if (!line || line.startsWith('#')) continue - const eq = line.indexOf('=') - if (eq < 0) continue - const key = line.slice(0, eq).trim() - const value = line.slice(eq + 1) - const comment = value.indexOf(' #') - let v = (comment < 0 ? value : value.slice(0, comment)).trim() - if (v.length >= 2 && (v[0] === "'" || v[0] === '"') && v.at(-1) === v[0]) v = v.slice(1, -1) - out[key] = v - } - return out -} - -export function pidAlive(pid: number): boolean { - if (!Number.isInteger(pid) || pid <= 0) return false - try { - process.kill(pid, 0) - return true - } catch (e) { - return (e as NodeJS.ErrnoException).code === 'EPERM' - } -} - -export function acquireLock(path: string, opts: { staleMs?: number } = {}): boolean { - const staleMs = opts.staleMs ?? 6 * 60 * 60_000 - try { - writeFileSync(path, String(process.pid), { flag: 'wx' }) - return true - } catch { - try { - const holder = Number(readFileSync(path, 'utf8').trim()) - if (!pidAlive(holder) || Date.now() - statSync(path).mtimeMs > staleMs) { - writeFileSync(path, String(process.pid)) - return true - } - } catch { - /* lock vanished or became unreadable; let the next tick retry */ - } - return false - } -} - -export function releaseLock(path: string): void { - try { - if (Number(readFileSync(path, 'utf8').trim()) === process.pid) rmSync(path, { force: true }) - } catch { - /* best-effort */ - } -} - -export function refreshCheckout(opts: { repoDir: string; slug: string; defaultBranch: string; log?: (message: string) => void }): boolean { - mkdirSync(dirname(opts.repoDir), { recursive: true }) - if (!existsSync(join(opts.repoDir, '.git'))) { - opts.log?.(`cloning ${opts.slug} -> ${opts.repoDir}`) - if (!exec('gh', ['repo', 'clone', opts.slug, opts.repoDir, '--', '-q']).ok) return false - } - const branch = opts.defaultBranch - return ( - exec('git', ['fetch', '-q', 'origin', branch], { cwd: opts.repoDir }).ok && - exec('git', ['checkout', '-q', branch], { cwd: opts.repoDir }).ok && - exec('git', ['reset', '-q', '--hard', `origin/${branch}`], { cwd: opts.repoDir }).ok - ) -} - -export interface HeadAttempt { - head: string - at: number -} - -export interface DailyCounter { - date: string - count: number -} - -const isObject = (raw: unknown): raw is Record => typeof raw === 'object' && raw !== null && !Array.isArray(raw) - -function readJson(path: string): unknown { - return existsSync(path) ? JSON.parse(readFileSync(path, 'utf8')) : undefined -} - -function writeJson(path: string, value: unknown): void { - mkdirSync(dirname(path), { recursive: true }) - writeFileSync(path, JSON.stringify(value)) -} - -export function loadHeadAttempts(path: string): Record { - try { - const raw = readJson(path) - if (!isObject(raw)) return {} - const out: Record = {} - for (const [key, value] of Object.entries(raw)) { - if (!isObject(value)) continue - if (typeof value.head === 'string' && typeof value.at === 'number') out[key] = { head: value.head, at: value.at } - } - return out - } catch { - return {} - } -} - -export function recordHeadAttempt(path: string, attempts: Record, key: string, head: string, at = Date.now()): void { - attempts[key] = { head, at } - try { - writeJson(path, attempts) - } catch { - /* best-effort */ - } -} - -export function loadReviewedHeads(path: string): Record { - try { - const raw = readJson(path) - if (!isObject(raw)) return {} - const out: Record = {} - for (const [key, value] of Object.entries(raw)) if (typeof value === 'string') out[key] = value - return out - } catch { - return {} - } -} - -export function recordReviewedHead(path: string, reviewed: Record, key: string, head: string): void { - reviewed[key] = head - try { - writeJson(path, reviewed) - } catch { - /* best-effort */ - } -} - -export function loadDailyCounter(path: string, now = new Date()): DailyCounter { - const today = now.toISOString().slice(0, 10) - try { - const raw = readJson(path) - if (!isObject(raw) || raw.date !== today || typeof raw.count !== 'number') return { date: today, count: 0 } - return { date: today, count: raw.count } - } catch { - return { date: today, count: 0 } - } -} - -export function bumpDailyCounter(path: string, daily: DailyCounter): void { - daily.count += 1 - try { - writeJson(path, daily) - } catch { - /* best-effort */ - } -} - -export const isRateLimited = (out: string): boolean => - /payment required|credits?\s+exhausted|insufficient\s+(?:credit|quota|balance)|usage limit|rate.?limit|too many requests|\b(?:402|429)\b|quota/i.test(out) diff --git a/src/cli.ts b/src/cli.ts index 89ae0b1..7d87d89 100644 --- a/src/cli.ts +++ b/src/cli.ts @@ -25,7 +25,7 @@ import { validHost, validRepo, vmNameFor as packageVmNameFor, -} from '@stupify/exe-cli' +} from '@bevyl-ai/agent-tools' import { writeCodexGatewayConfig } from '@bevyl-ai/agent-tools' import pc from 'picocolors' diff --git a/src/exe-packages.test.ts b/src/exe-packages.test.ts deleted file mode 100644 index 399f0f0..0000000 --- a/src/exe-packages.test.ts +++ /dev/null @@ -1,83 +0,0 @@ -import { expect, test } from 'bun:test' -import { mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs' -import { tmpdir } from 'node:os' -import { join } from 'node:path' -import { exeSetupScript, githubIntegrationFor, llmIntegrationFor, normalizeRepo, validHost, validRepo, vmNameFor } from '@stupify/exe-cli' -import { bumpDailyCounter, loadDailyCounter, loadHeadAttempts, loadReviewedHeads, parseEnvFile, recordHeadAttempt, recordReviewedHead } from '@stupify/exe-host' - -const tmp = (): string => mkdtempSync(join(tmpdir(), 'stupify-kit-')) -const clean = (dir: string): void => rmSync(dir, { recursive: true, force: true }) - -test('repo and host helpers keep shell-interpolated values tight', () => { - expect(normalizeRepo('https://github.com/Octember/stupify.git/')).toBe('Octember/stupify') - expect(normalizeRepo('git@github.com:Octember/stupify.git')).toBe('Octember/stupify') - expect(validRepo('Octember/stupify')).toBe(true) - expect(validRepo('Octember/stupify;curl bad')).toBe(false) - expect(validHost('llm.int.exe.xyz')).toBe(true) - expect(validHost('llm.int.exe.xyz && curl bad')).toBe(false) - expect(vmNameFor('stupify', 'Octember/stupify')).toBe('stupify-octember-stupify') -}) - -test('exeSetupScript preserves the stable bun PATH bootstrap and appends codex host last', () => { - expect(exeSetupScript('exec bunx @stupify/cli setup acme/widgets --yes', 'llm.int.exe.xyz')).toBe( - [ - 'export PATH="$HOME/.bun/bin:/usr/local/bin:$PATH"', - 'command -v bun >/dev/null 2>&1 || curl -fsSL https://bun.sh/install | bash', - 'export PATH="$HOME/.bun/bin:$PATH"', - 'exec bunx @stupify/cli setup acme/widgets --yes --codex-host llm.int.exe.xyz', - ].join('\n'), - ) -}) - -test('exe.dev integration discovery ignores malformed optional config fields', () => { - const runExe = (): { ok: boolean; out: string } => ({ - ok: true, - out: JSON.stringify([ - { name: 'bad-mixed', type: 'github', config: { repositories: 123, providers: { openai: { enabled: true } } } }, - { name: 'bad-llm', type: 'llm', config: { providers: { openai: { enabled: 'yes' } } } }, - { name: 'repo-ok', type: 'github', config: { repositories: ['acme/widgets'] } }, - { name: 'llm-ok', type: 'llm', config: { providers: { openai: { enabled: true } } } }, - ]), - }) - - expect(githubIntegrationFor('acme/widgets', runExe)).toBe('repo-ok') - expect(llmIntegrationFor(runExe)).toBe('llm-ok') -}) - -test('host env and state helpers parse defensively and persist compact JSON', () => { - const dir = tmp() - try { - const env = join(dir, 'config.env') - writeFileSync(env, ["REPO_SLUG='acme/widgets' # comment", 'DRY_RUN=true', 'BAD LINE'].join('\n')) - expect(parseEnvFile(env)).toEqual({ REPO_SLUG: 'acme/widgets', DRY_RUN: 'true' }) - - const failures = join(dir, 'failures.json') - recordHeadAttempt(failures, {}, '7', 'abc', 123) - expect(loadHeadAttempts(failures)).toEqual({ 7: { head: 'abc', at: 123 } }) - - const reviewed = join(dir, 'reviewed.json') - recordReviewedHead(reviewed, {}, '7', 'def') - expect(loadReviewedHeads(reviewed)).toEqual({ 7: 'def' }) - - const dailyPath = join(dir, 'daily.json') - const today = loadDailyCounter(dailyPath, new Date('2026-06-21T12:00:00Z')) - bumpDailyCounter(dailyPath, today) - expect(JSON.parse(readFileSync(dailyPath, 'utf8'))).toEqual({ date: '2026-06-21', count: 1 }) - } finally { - clean(dir) - } -}) - -test('state helpers ignore malformed persisted JSON instead of throwing mid-sweep', () => { - const dir = tmp() - try { - mkdirSync(dir, { recursive: true }) - const file = join(dir, 'bad.json') - writeFileSync(file, '{ nope') - expect(loadHeadAttempts(file)).toEqual({}) - expect(loadReviewedHeads(file)).toEqual({}) - expect(loadDailyCounter(file, new Date('2026-06-21T12:00:00Z'))).toEqual({ date: '2026-06-21', count: 0 }) - } finally { - clean(dir) - } -}) diff --git a/src/review-sweep.test.ts b/src/review-sweep.test.ts index 6d72ab6..1ff8ecd 100644 --- a/src/review-sweep.test.ts +++ b/src/review-sweep.test.ts @@ -298,3 +298,42 @@ test('the prefix is large enough to be cache-eligible (well past the ~1024-token expect(approxTokens).toBeGreaterThan(1024) }) + +// The per-VM sweep state stores (inlined from @stupify/exe-host when the kit absorbed it): parse +// defensively, persist compact JSON, and never throw mid-sweep on malformed files. +import { mkdtempSync, readFileSync as readF, rmSync, writeFileSync as writeF } from 'node:fs' +import { tmpdir } from 'node:os' +import { bumpDailyCounter, loadDailyCounter, loadHeadAttempts, loadReviewedHeads, recordHeadAttempt, recordReviewedHead } from './review-sweep' + +test('sweep state stores persist compact JSON and reload it', () => { + const dir = mkdtempSync(join(tmpdir(), 'stupify-state-')) + try { + const failures = join(dir, 'failures.json') + recordHeadAttempt(failures, {}, '7', 'abc', 123) + expect(loadHeadAttempts(failures)).toEqual({ 7: { head: 'abc', at: 123 } }) + + const reviewed = join(dir, 'reviewed.json') + recordReviewedHead(reviewed, {}, '7', 'def') + expect(loadReviewedHeads(reviewed)).toEqual({ 7: 'def' }) + + const dailyPath = join(dir, 'daily.json') + const today = loadDailyCounter(dailyPath, new Date('2026-06-21T12:00:00Z')) + bumpDailyCounter(dailyPath, today) + expect(JSON.parse(readF(dailyPath, 'utf8'))).toEqual({ date: '2026-06-21', count: 1 }) + } finally { + rmSync(dir, { recursive: true, force: true }) + } +}) + +test('sweep state stores ignore malformed persisted JSON instead of throwing mid-sweep', () => { + const dir = mkdtempSync(join(tmpdir(), 'stupify-state-')) + try { + const file = join(dir, 'bad.json') + writeF(file, '{ nope') + expect(loadHeadAttempts(file)).toEqual({}) + expect(loadReviewedHeads(file)).toEqual({}) + expect(loadDailyCounter(file, new Date('2026-06-21T12:00:00Z'))).toEqual({ date: '2026-06-21', count: 0 }) + } finally { + rmSync(dir, { recursive: true, force: true }) + } +}) diff --git a/src/review-sweep.ts b/src/review-sweep.ts index 9b250ba..ffd250a 100755 --- a/src/review-sweep.ts +++ b/src/review-sweep.ts @@ -21,23 +21,9 @@ import { appendFileSync, existsSync, mkdirSync, readFileSync, renameSync, rmSync, writeFileSync } from 'node:fs' import { dirname, join } from 'node:path' import { fileURLToPath } from 'node:url' -import { - acquireLock, - bumpDailyCounter, - exec, - isRateLimited, - loadDailyCounter, - loadHeadAttempts, - loadReviewedHeads, - parseEnvFile, - recordHeadAttempt, - recordReviewedHead, - refreshCheckout, - releaseLock, -} from '@stupify/exe-host' - -export { isRateLimited, pidAlive } from '@stupify/exe-host' -import { maybeRotateGateway } from '@bevyl-ai/agent-tools' +import { acquireLock, exec, isRateLimited, maybeRotateGateway, parseEnvFile, refreshCheckout, releaseLock } from '@bevyl-ai/agent-tools' + +export { isRateLimited, pidAlive } from '@bevyl-ai/agent-tools' const KIT_DIR = dirname(fileURLToPath(import.meta.url)) @@ -499,6 +485,96 @@ export const stripSignoff = (review: string): string => { return lines.join('\n').trimEnd() } + +// --- Per-VM sweep state: tiny best-effort JSON files (a parse error or fresh VM just re-attempts once). --- +// These lived in @stupify/exe-host, but they are review-sweep domain vocabulary (heads, reviews/day) with +// exactly one consumer, so they live here rather than in the shared kit. +export interface HeadAttempt { + head: string + at: number +} + +export interface DailyCounter { + date: string + count: number +} + +const isJsonObject = (raw: unknown): raw is Record => typeof raw === 'object' && raw !== null && !Array.isArray(raw) + +function readJson(path: string): unknown { + return existsSync(path) ? JSON.parse(readFileSync(path, 'utf8')) : undefined +} + +function writeJson(path: string, value: unknown): void { + mkdirSync(dirname(path), { recursive: true }) + writeFileSync(path, JSON.stringify(value)) +} + +export function loadHeadAttempts(path: string): Record { + try { + const raw = readJson(path) + if (!isJsonObject(raw)) return {} + const out: Record = {} + for (const [key, value] of Object.entries(raw)) { + if (!isJsonObject(value)) continue + if (typeof value.head === 'string' && typeof value.at === 'number') out[key] = { head: value.head, at: value.at } + } + return out + } catch { + return {} + } +} + +export function recordHeadAttempt(path: string, attempts: Record, key: string, head: string, at = Date.now()): void { + attempts[key] = { head, at } + try { + writeJson(path, attempts) + } catch { + /* best-effort */ + } +} + +export function loadReviewedHeads(path: string): Record { + try { + const raw = readJson(path) + if (!isJsonObject(raw)) return {} + const out: Record = {} + for (const [key, value] of Object.entries(raw)) if (typeof value === 'string') out[key] = value + return out + } catch { + return {} + } +} + +export function recordReviewedHead(path: string, reviewed: Record, key: string, head: string): void { + reviewed[key] = head + try { + writeJson(path, reviewed) + } catch { + /* best-effort */ + } +} + +export function loadDailyCounter(path: string, now = new Date()): DailyCounter { + const today = now.toISOString().slice(0, 10) + try { + const raw = readJson(path) + if (!isJsonObject(raw) || raw.date !== today || typeof raw.count !== 'number') return { date: today, count: 0 } + return { date: today, count: raw.count } + } catch { + return { date: today, count: 0 } + } +} + +export function bumpDailyCounter(path: string, daily: DailyCounter): void { + daily.count += 1 + try { + writeJson(path, daily) + } catch { + /* best-effort */ + } +} + const failuresPath = (cfg: Config): string => join(cfg.stateDir, 'failures.json') const reviewedPath = (cfg: Config): string => join(cfg.stateDir, 'reviewed.json') const dailyPath = (cfg: Config): string => join(cfg.stateDir, 'daily.json') diff --git a/tsconfig.json b/tsconfig.json index 26b0a66..d392091 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -19,5 +19,5 @@ "skipLibCheck": true, "noEmit": true }, - "include": ["src", "packages/*/src"] + "include": ["src"] }