From 564f4d021af1f79bf966ac52575d6702591bbb3e Mon Sep 17 00:00:00 2001 From: Jinwoo Hong <73622457+Jinwoo-H@users.noreply.github.com> Date: Fri, 2 Oct 2026 14:59:24 -0400 Subject: [PATCH] feat: live updates for agent state rules (#24387) Orca downloads a newer agent-state-rules.json from a fixed GitHub release (stable or next channel), validates it like the bundled rules, and applies it without a restart; a local override wins over the download, which wins over the bundled rules. A hand-started workflow from main is the only publisher; merging publishes nothing. --- .../scripts/render-readme-downloads-badge.mjs | 41 +- .../workflows/agent-state-rules-publish.yml | 94 ++++ .github/workflows/release-policy.yml | 4 +- config/scripts/agent-state-rules-bundle.mjs | 174 ++++++++ .../scripts/agent-state-rules-bundle.test.mjs | 179 ++++++++ .../scripts/readme-downloads-badge.test.mjs | 19 + config/scripts/release-policy.mjs | 16 +- config/scripts/release-policy.test.mjs | 37 ++ .../release-tag-pattern-census.test.mjs | 184 ++++++++ config/scripts/release-tag-patterns.mjs | 30 ++ src/main/orcad/orcad-entry.ts | 7 + src/main/orcad/orcad-push-startup.test.ts | 4 + ...roxy-guarded-fetch-call-site-audit.test.ts | 4 +- .../active-agent-state-rules.ts | 55 +++ .../agent-state-rules-bundle.test.ts | 68 +++ .../agent-state-rules-bundle.ts | 69 +++ .../agent-state-rules-engine.ts | 25 +- .../agent-state-rules-live-update.test.ts | 406 ++++++++++++++++++ .../agent-state-rules-live-update.ts | 259 +++++++++++ .../agent-state-rules-release.json | 14 + .../agent-state-rules-schema.ts | 2 +- .../agent-state-text-anchors.test.ts | 4 +- .../agent-state-text-anchors.ts | 16 +- .../agent-state-title-anchors.ts | 6 +- .../agent-state-rules/blocked-text-layer.ts | 19 +- .../terminal-tail-sentinel-index.test.ts | 12 +- .../runtime/terminal-tail-sentinel-index.ts | 27 +- src/main/runtime/terminal-wait-detection.ts | 15 +- src/main/runtime/terminal-wait-tail-state.ts | 4 +- .../startup/main-process-runtime-service.ts | 6 + .../updater-agent-state-rules-release.test.ts | 119 +++++ src/shared/global-settings-types.ts | 6 + 32 files changed, 1850 insertions(+), 75 deletions(-) create mode 100644 .github/workflows/agent-state-rules-publish.yml create mode 100644 config/scripts/agent-state-rules-bundle.mjs create mode 100644 config/scripts/agent-state-rules-bundle.test.mjs create mode 100644 config/scripts/readme-downloads-badge.test.mjs create mode 100644 config/scripts/release-tag-pattern-census.test.mjs create mode 100644 config/scripts/release-tag-patterns.mjs create mode 100644 src/main/runtime/agent-state-rules/active-agent-state-rules.ts create mode 100644 src/main/runtime/agent-state-rules/agent-state-rules-bundle.test.ts create mode 100644 src/main/runtime/agent-state-rules/agent-state-rules-bundle.ts create mode 100644 src/main/runtime/agent-state-rules/agent-state-rules-live-update.test.ts create mode 100644 src/main/runtime/agent-state-rules/agent-state-rules-live-update.ts create mode 100644 src/main/runtime/agent-state-rules/agent-state-rules-release.json create mode 100644 src/main/updater-agent-state-rules-release.test.ts diff --git a/.github/scripts/render-readme-downloads-badge.mjs b/.github/scripts/render-readme-downloads-badge.mjs index efe0d670cf5..9c98bf9229e 100644 --- a/.github/scripts/render-readme-downloads-badge.mjs +++ b/.github/scripts/render-readme-downloads-badge.mjs @@ -1,5 +1,7 @@ import { mkdir, writeFile } from 'node:fs/promises' import { dirname } from 'node:path' +import { pathToFileURL } from 'node:url' +import { isAgentStateRulesTag } from '../../config/scripts/release-tag-patterns.mjs' const repository = process.env.GITHUB_REPOSITORY ?? 'stablyai/orca' const token = process.env.GITHUB_TOKEN @@ -24,6 +26,22 @@ async function fetchJson(url) { return response.json() } +// Why rules releases are excluded: every running app fetches them every few hours, so they +// count fetches, not installs. +export function countReleaseDownloads(releases) { + let total = 0 + for (const release of releases) { + if (release.draft || isAgentStateRulesTag(release.tag_name ?? '')) { + continue + } + + for (const asset of release.assets ?? []) { + total += asset.download_count ?? 0 + } + } + return total +} + async function getTotalReleaseDownloads() { let page = 1 let total = 0 @@ -37,16 +55,7 @@ async function getTotalReleaseDownloads() { return total } - for (const release of releases) { - if (release.draft) { - continue - } - - for (const asset of release.assets ?? []) { - total += asset.download_count ?? 0 - } - } - + total += countReleaseDownloads(releases) page += 1 } } @@ -104,9 +113,11 @@ function renderBadge(value) { ` } -const total = await getTotalReleaseDownloads() -const badge = renderBadge(formatDownloads(total)) +if (import.meta.url === pathToFileURL(process.argv[1] ?? '').href) { + const total = await getTotalReleaseDownloads() + const badge = renderBadge(formatDownloads(total)) -await mkdir(dirname(outputPath), { recursive: true }) -await writeFile(outputPath, badge) -console.log(`Rendered ${outputPath} from ${total} downloads.`) + await mkdir(dirname(outputPath), { recursive: true }) + await writeFile(outputPath, badge) + console.log(`Rendered ${outputPath} from ${total} downloads.`) +} diff --git a/.github/workflows/agent-state-rules-publish.yml b/.github/workflows/agent-state-rules-publish.yml new file mode 100644 index 00000000000..8a24adbc73f --- /dev/null +++ b/.github/workflows/agent-state-rules-publish.yml @@ -0,0 +1,94 @@ +name: Publish agent state rules + +# The only publisher of agent state rules releases. Merging never publishes: a maintainer runs +# this from main, first to `next` (RC and dev builds), then promotes the identical file to +# `stable` after a soak. Every job runs on the dispatched commit, so the publish job runs exactly +# the script and rules the gate tested. +# +# Why GITHUB_TOKEN: release-policy.yml deletes an agent-state-rules-* release any other author +# publishes. Its releases start no workflow, so release-policy.yml sees them only when someone +# edits one by hand, and then accepts them as bot-authored prereleases. +on: + workflow_dispatch: + inputs: + action: + description: 'publish-next builds from main and publishes to next; promote-stable copies the next file to stable' + required: true + type: choice + options: + - publish-next + - promote-stable + bundled_only: + description: 'publish-next: tell apps to fall back to the rules they shipped with; promote-stable then carries it to stable' + required: false + type: boolean + default: false + +permissions: + contents: read + +concurrency: + group: agent-state-rules-publish + cancel-in-progress: false + +jobs: + gate: + if: github.ref == 'refs/heads/main' && inputs.action == 'publish-next' + runs-on: ubuntu-latest + timeout-minutes: 30 + steps: + - uses: actions/checkout@v6 + with: + persist-credentials: false + - uses: ./.github/actions/install-node-dependencies + with: + native-runtime: node + - name: Run the rules gate (schema, regex safety, the bundle, every transcript replay) + shell: bash + run: | + set -euo pipefail + mapfile -t gate_files < <(git ls-files \ + ':(glob)src/main/runtime/agent-state-rules/**/*.test.ts' \ + ':(glob)src/main/runtime/readiness-census*.test.ts' \ + ':(glob)src/main/runtime/*transcript.test.ts' \ + ':(glob)src/main/runtime/*transcripts.test.ts' \ + config/scripts/agent-state-rules-bundle.test.mjs) + pnpm exec vitest run --config config/vitest.config.ts "${gate_files[@]}" + + publish-next: + needs: gate + runs-on: ubuntu-latest + timeout-minutes: 10 + environment: agent-state-rules + permissions: + contents: write + steps: + - uses: actions/checkout@v6 + with: + persist-credentials: false + - name: Publish to next + env: + GH_TOKEN: ${{ github.token }} + BUNDLED_ONLY: ${{ inputs.bundled_only }} + shell: bash + run: | + set -euo pipefail + flags=() + [[ "$BUNDLED_ONLY" == "true" ]] && flags+=(--bundled-only) + node config/scripts/agent-state-rules-bundle.mjs publish-next "${flags[@]}" + + promote-stable: + if: github.ref == 'refs/heads/main' && inputs.action == 'promote-stable' + runs-on: ubuntu-latest + timeout-minutes: 10 + environment: agent-state-rules + permissions: + contents: write + steps: + - uses: actions/checkout@v6 + with: + persist-credentials: false + - name: Copy the next file to stable + env: + GH_TOKEN: ${{ github.token }} + run: node config/scripts/agent-state-rules-bundle.mjs promote-stable diff --git a/.github/workflows/release-policy.yml b/.github/workflows/release-policy.yml index 53decc5e7e3..512a5fb8ea8 100644 --- a/.github/workflows/release-policy.yml +++ b/.github/workflows/release-policy.yml @@ -22,7 +22,9 @@ jobs: # Why: release events run this file from the tagged commit, so load the module from the same commit. - uses: actions/checkout@v6 with: - sparse-checkout: config/scripts/release-policy.mjs + sparse-checkout: | + config/scripts/release-policy.mjs + config/scripts/release-tag-patterns.mjs sparse-checkout-cone-mode: false persist-credentials: false - name: Enforce release policy diff --git a/config/scripts/agent-state-rules-bundle.mjs b/config/scripts/agent-state-rules-bundle.mjs new file mode 100644 index 00000000000..170d6b4c122 --- /dev/null +++ b/config/scripts/agent-state-rules-bundle.mjs @@ -0,0 +1,174 @@ +// Builds and publishes the agent state rules bundle (agent-state-rules.json). The app's loader +// validates the same file (src/main/runtime/agent-state-rules/agent-state-rules-bundle.ts), and +// agent-state-rules-bundle.test.mjs proves the build passes it; this script only assembles the +// file and enforces the publishing rules a single file cannot express. +// +// node config/scripts/agent-state-rules-bundle.mjs build [--bundled-only] +// node config/scripts/agent-state-rules-bundle.mjs publish-next [--bundled-only] +// node config/scripts/agent-state-rules-bundle.mjs promote-stable + +import { spawnSync } from 'node:child_process' +import { mkdtempSync, readFileSync, writeFileSync } from 'node:fs' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { pathToFileURL } from 'node:url' +import { agentStateRulesTag } from './release-tag-patterns.mjs' + +const RULES_DIR = join(import.meta.dirname, '..', '..', 'src/main/runtime/agent-state-rules') +export const AGENT_STATE_RULES_ASSET = 'agent-state-rules.json' + +function readJson(path) { + return JSON.parse(readFileSync(path, 'utf8')) +} + +/** The live-updatable agents' files, in release order, under the release's version. */ +export function buildAgentStateRulesBundle({ bundledOnly = false } = {}) { + const release = readJson(join(RULES_DIR, 'agent-state-rules-release.json')) + const files = release.liveUpdatable.map((id) => readJson(join(RULES_DIR, `${id}.json`))) + const bundle = { + version: release.version, + engineVersion: files[0].engineVersion, + ...(bundledOnly ? { bundledOnly: true } : {}), + files + } + return `${JSON.stringify(bundle, null, 2)}\n` +} + +function runGh(args) { + const result = spawnSync('gh', args, { encoding: 'utf8' }) + return { status: result.status ?? 1, stdout: result.stdout ?? '', stderr: result.stderr ?? '' } +} + +function ghOrThrow(gh, args) { + const result = gh(args) + if (result.status !== 0) { + throw new Error(`gh ${args.join(' ')} failed: ${result.stderr.trim()}`) + } + return result.stdout +} + +/** The file on `tag`'s release, or null when there is no release yet. */ +function downloadPublished(gh, repo, tag) { + const args = [ + 'release', + 'download', + tag, + '--repo', + repo, + '-p', + AGENT_STATE_RULES_ASSET, + '-O', + '-' + ] + const result = gh(args) + if (result.status === 0) { + return result.stdout + } + // Why throw on anything else, a missing asset included: that means an earlier upload broke. + if (/release not found/i.test(result.stderr)) { + return null + } + throw new Error(`gh ${args.join(' ')} failed: ${result.stderr.trim()}`) +} + +/** + * Puts `text` on the channel's release for its engine, as a prerelease that never becomes Latest + * (the app updater follows Latest). `gh` is injectable so the sequence is testable. + */ +export function publishAgentStateRules({ repo, channel, text, target, gh = runGh }) { + const candidate = JSON.parse(text) + const tag = agentStateRulesTag(candidate.engineVersion, channel) + const publishedText = downloadPublished(gh, repo, tag) + // Why strictly higher: apps refuse a version they already have, so republishing one would reach + // nobody, and a lower one would reach only apps that never took the higher. + const published = publishedText === null ? null : JSON.parse(publishedText).version + if (published !== null && candidate.version <= published) { + throw new Error( + `version ${candidate.version} is not higher than the published ${published}; bump agent-state-rules-release.json` + ) + } + const file = join(mkdtempSync(join(tmpdir(), 'agent-state-rules-')), AGENT_STATE_RULES_ASSET) + writeFileSync(file, text) + if (published === null) { + ghOrThrow(gh, [ + 'release', + 'create', + tag, + file, + '--repo', + repo, + '--target', + target, + '--prerelease', + '--latest=false', + '--title', + `Agent state rules (${channel})`, + '--notes', + 'Agent state rules for Orca. Running apps download this file; it is not an app release.' + ]) + } else { + // Why --clobber on the same tag: the app's fixed URL stays valid, and a fetch that lands + // mid-upload gets a 404 and keeps its last good copy. + ghOrThrow(gh, ['release', 'upload', tag, file, '--repo', repo, '--clobber']) + } + return { tag, version: candidate.version } +} + +/** + * Why no rebuild and no re-gate: stable gets exactly the bytes RC and dev builds soaked on next, + * which only the gated publish-next job writes. A bundledOnly next is promoted too: that is how + * stable rolls back to the rules it shipped. + */ +export function promoteAgentStateRules({ repo, engineVersion, target, gh = runGh }) { + const nextTag = agentStateRulesTag(engineVersion, 'next') + const text = downloadPublished(gh, repo, nextTag) + if (text === null) { + throw new Error(`${nextTag} has not been published`) + } + return publishAgentStateRules({ repo, channel: 'stable', text, target, gh }) +} + +function requireEnv(name) { + const value = process.env[name] + if (!value) { + throw new Error(`${name} is not set`) + } + return value +} + +function main([command, ...args]) { + const bundledOnly = args.includes('--bundled-only') + switch (command) { + case 'build': { + const out = args.find((arg) => !arg.startsWith('--')) + if (!out) { + throw new Error('usage: build [--bundled-only]') + } + writeFileSync(out, buildAgentStateRulesBundle({ bundledOnly })) + return + } + case 'publish-next': + case 'promote-stable': { + const repo = requireEnv('GITHUB_REPOSITORY') + const target = requireEnv('GITHUB_SHA') + const text = buildAgentStateRulesBundle({ bundledOnly }) + const { tag, version } = + command === 'publish-next' + ? publishAgentStateRules({ repo, channel: 'next', text, target }) + : promoteAgentStateRules({ repo, engineVersion: JSON.parse(text).engineVersion, target }) + console.log(`Published version ${version} to ${tag}.`) + return + } + default: + throw new Error(`unknown command ${command ?? '(none)'}`) + } +} + +if (import.meta.url === pathToFileURL(process.argv[1] ?? '').href) { + try { + main(process.argv.slice(2)) + } catch (error) { + console.error(`::error::${error instanceof Error ? error.message : String(error)}`) + process.exitCode = 1 + } +} diff --git a/config/scripts/agent-state-rules-bundle.test.mjs b/config/scripts/agent-state-rules-bundle.test.mjs new file mode 100644 index 00000000000..38fd0de6633 --- /dev/null +++ b/config/scripts/agent-state-rules-bundle.test.mjs @@ -0,0 +1,179 @@ +// The rules-release gate: the bundle a release would publish validates in the app's own loader, +// carries only agents whose transcripts the census replays, and only the protected workflow can +// publish it. +import { readdirSync, readFileSync } from 'node:fs' +import { describe, expect, it } from 'vitest' +import { parse } from 'yaml' +import { + BUNDLED_AGENT_STATE_RULES_VERSION, + LIVE_UPDATABLE_AGENT_STATE_RULE_IDS, + parseAgentStateRulesBundle +} from '../../src/main/runtime/agent-state-rules/agent-state-rules-bundle.ts' +import { BUNDLED_AGENT_STATE_RULE_FILES } from '../../src/main/runtime/agent-state-rules/agent-state-rules-catalog.ts' +import { agentStateRulesDownloadUrl } from '../../src/main/runtime/agent-state-rules/agent-state-rules-live-update.ts' +import { + AGENT_STATE_RULES_ENGINE_VERSION, + UNKNOWN_PANE_RULES_ID +} from '../../src/main/runtime/agent-state-rules/agent-state-rules-schema.ts' +import { CENSUS_TRANSCRIPTS } from '../../src/main/runtime/readiness-census-transcript-catalog.ts' +import { + AGENT_STATE_RULES_ASSET, + buildAgentStateRulesBundle, + promoteAgentStateRules, + publishAgentStateRules +} from './agent-state-rules-bundle.mjs' +import { agentStateRulesTag } from './release-tag-patterns.mjs' + +const REPO = 'stablyai/orca' +const NEXT = agentStateRulesTag(1, 'next') +const STABLE = agentStateRulesTag(1, 'stable') + +describe('agent state rules bundle build', () => { + it('builds a bundle the app accepts, under the bundled version and engine', () => { + const text = buildAgentStateRulesBundle() + const parsed = parseAgentStateRulesBundle(text, 'live-updatable') + expect(parsed.ok).toBe(true) + const bundle = JSON.parse(text) + expect(bundle.engineVersion).toBe(AGENT_STATE_RULES_ENGINE_VERSION) + expect(bundle.version).toBe(BUNDLED_AGENT_STATE_RULES_VERSION) + expect(bundle.files).toEqual( + BUNDLED_AGENT_STATE_RULE_FILES.filter((file) => + LIVE_UPDATABLE_AGENT_STATE_RULE_IDS.has(file.id) + ) + ) + expect(bundle.bundledOnly).toBeUndefined() + expect(JSON.parse(buildAgentStateRulesBundle({ bundledOnly: true })).bundledOnly).toBe(true) + }) + + it('lets a rules release change only agents the readiness census replays', () => { + const replayed = new Set(CENSUS_TRANSCRIPTS.flatMap((transcript) => transcript.agent ?? [])) + // Why unknown-pane: the census replays every recording on an agent-unknown pane too. + replayed.add(UNKNOWN_PANE_RULES_ID) + expect([...LIVE_UPDATABLE_AGENT_STATE_RULE_IDS].filter((id) => !replayed.has(id))).toEqual([]) + }) + + it('publishes to the exact URL the app fetches', () => { + for (const channel of ['next', 'stable']) { + expect(agentStateRulesDownloadUrl(channel)).toBe( + `https://github.com/${REPO}/releases/download/${agentStateRulesTag(AGENT_STATE_RULES_ENGINE_VERSION, channel)}/${AGENT_STATE_RULES_ASSET}` + ) + } + }) +}) + +/** A `gh` stand-in over an in-memory set of releases, recording each call. */ +function fakeGh(releases = {}) { + const calls = [] + const gh = (args) => { + calls.push(args) + const [, verb, tag] = args + if (verb === 'download') { + return tag in releases + ? { status: 0, stdout: releases[tag], stderr: '' } + : { status: 1, stdout: '', stderr: 'release not found' } + } + if (verb === 'upload' || verb === 'create') { + expect(args[3].endsWith(`/${AGENT_STATE_RULES_ASSET}`)).toBe(true) + releases[tag] = readFileSync(args[3], 'utf8') + } + return { status: 0, stdout: '', stderr: '' } + } + return { gh, calls, releases } +} + +const at = (version, engineVersion = 1) => + `${JSON.stringify({ version, engineVersion, files: [] })}\n` + +describe('publishAgentStateRules', () => { + it("creates the engine's channel release as a prerelease that can never be Latest", () => { + const fake = fakeGh() + expect( + publishAgentStateRules({ + repo: REPO, + channel: 'next', + text: at(2), + target: 'abc', + gh: fake.gh + }) + ).toEqual({ tag: NEXT, version: 2 }) + expect(fake.calls.find((args) => args[1] === 'create')).toEqual( + expect.arrayContaining([NEXT, '--prerelease', '--latest=false', '--target', 'abc']) + ) + expect(fake.releases[NEXT]).toBe(at(2)) + }) + + it('replaces the asset in place on an existing release, keeping the tag', () => { + const fake = fakeGh({ [NEXT]: at(1) }) + publishAgentStateRules({ repo: REPO, channel: 'next', text: at(2), target: 'abc', gh: fake.gh }) + expect(fake.calls.map((args) => args[1])).toEqual(['download', 'upload']) + expect(fake.calls[1]).toContain('--clobber') + expect(fake.releases[NEXT]).toBe(at(2)) + }) + + it.each([1, 2])('refuses version %s over a published 2, uploading nothing', (version) => { + const fake = fakeGh({ [NEXT]: at(2) }) + expect(() => + publishAgentStateRules({ + repo: REPO, + channel: 'next', + text: at(version), + target: 'abc', + gh: fake.gh + }) + ).toThrow('bump agent-state-rules-release.json') + expect(fake.calls.map((args) => args[1])).toEqual(['download']) + }) + + it('fails when the release exists but its download fails', () => { + const gh = () => ({ status: 1, stdout: '', stderr: 'no assets match the file pattern' }) + expect(() => + publishAgentStateRules({ repo: REPO, channel: 'next', text: at(2), target: 'abc', gh }) + ).toThrow('no assets match') + }) + + it('promotes the identical next bytes to stable', () => { + const nextText = JSON.stringify({ version: 3, engineVersion: 1, files: [] }, null, 2) + const fake = fakeGh({ [NEXT]: nextText, [STABLE]: at(2) }) + promoteAgentStateRules({ repo: REPO, engineVersion: 1, target: 'abc', gh: fake.gh }) + expect(fake.releases[STABLE]).toBe(nextText) + }) + + it('refuses to promote before next exists', () => { + const fake = fakeGh() + expect(() => + promoteAgentStateRules({ repo: REPO, engineVersion: 1, target: 'abc', gh: fake.gh }) + ).toThrow('has not been published') + }) +}) + +describe('agent state rules workflows', () => { + const read = (name) => parse(readFileSync(`.github/workflows/${name}`, 'utf8')) + const publish = read('agent-state-rules-publish.yml') + + it('publishes only on manual dispatch from main, in the protected environment', () => { + expect(Object.keys(publish.on)).toEqual(['workflow_dispatch']) + for (const name of ['publish-next', 'promote-stable']) { + const job = publish.jobs[name] + expect(job.environment).toBe('agent-state-rules') + expect(job.permissions).toEqual({ contents: 'write' }) + } + expect(publish.permissions).toEqual({ contents: 'read' }) + expect(publish.jobs.gate.if).toContain("github.ref == 'refs/heads/main'") + expect(publish.jobs['promote-stable'].if).toContain("github.ref == 'refs/heads/main'") + expect(publish.jobs['publish-next'].needs).toBe('gate') + // Why: every job must check out the dispatched commit, so publish runs what the gate tested. + const checkouts = Object.values(publish.jobs).flatMap((job) => + job.steps.filter((step) => step.uses?.startsWith('actions/checkout')) + ) + expect(checkouts.map((step) => step.with?.ref)).toEqual([undefined, undefined, undefined]) + }) + + it('is the only workflow that publishes rules releases', () => { + const publishers = readdirSync('.github/workflows').filter((name) => + /agent-state-rules-bundle\.mjs (?:publish|promote)|release create agent-state-rules/.test( + readFileSync(`.github/workflows/${name}`, 'utf8') + ) + ) + expect(publishers).toEqual(['agent-state-rules-publish.yml']) + }) +}) diff --git a/config/scripts/readme-downloads-badge.test.mjs b/config/scripts/readme-downloads-badge.test.mjs new file mode 100644 index 00000000000..736ccbbd9b2 --- /dev/null +++ b/config/scripts/readme-downloads-badge.test.mjs @@ -0,0 +1,19 @@ +import { describe, expect, it } from 'vitest' +import { countReleaseDownloads } from '../../.github/scripts/render-readme-downloads-badge.mjs' + +const asset = (downloads) => ({ download_count: downloads }) + +describe('README downloads badge', () => { + it('counts published app releases and skips drafts and agent state rules releases', () => { + expect( + countReleaseDownloads([ + { tag_name: 'v1.4.1', draft: false, assets: [asset(10), asset(5)] }, + { tag_name: 'v1.4.2-rc.0', draft: false, assets: [asset(3)] }, + { tag_name: 'mobile-v0.0.1', draft: false, assets: [asset(2)] }, + { tag_name: 'v1.4.3', draft: true, assets: [asset(100)] }, + { tag_name: 'agent-state-rules-engine-1-next', draft: false, assets: [asset(9000)] }, + { tag_name: 'agent-state-rules-engine-1-stable', draft: false, assets: [asset(9000)] } + ]) + ).toBe(20) + }) +}) diff --git a/config/scripts/release-policy.mjs b/config/scripts/release-policy.mjs index 4733e79f2b4..e9ef5d8264e 100644 --- a/config/scripts/release-policy.mjs +++ b/config/scripts/release-policy.mjs @@ -1,15 +1,19 @@ // Enforced by .github/workflows/release-policy.yml on release events. +import { + DESKTOP_RC_TAG as RC_TAG, + DESKTOP_STABLE_TAG as STABLE_TAG, + MOBILE_TAG, + isAgentStateRulesTag +} from './release-tag-patterns.mjs' + const BOT_LOGIN = 'github-actions[bot]' const BOT_EMAIL = '41898282+github-actions[bot]@users.noreply.github.com' -const NUMBER = '(?:0|[1-9][0-9]*)' -const VERSION = `${NUMBER}\\.${NUMBER}\\.${NUMBER}` -const STABLE_TAG = new RegExp(`^v${VERSION}$`) -const RC_TAG = new RegExp(`^v${VERSION}-rc\\.${NUMBER}(?:\\.[0-9A-Za-z]+)?$`) -const MOBILE_TAG = new RegExp(`^mobile(?:-android)?-v${VERSION}$`) +// Why agent state rules here: their publish workflow is a bot author, and as a prerelease the +// release can never become Latest, which the app updater follows. export function isPrereleaseTag(tag) { - return RC_TAG.test(tag) || MOBILE_TAG.test(tag) + return RC_TAG.test(tag) || MOBILE_TAG.test(tag) || isAgentStateRulesTag(tag) } export function compareStableTags(left, right) { diff --git a/config/scripts/release-policy.test.mjs b/config/scripts/release-policy.test.mjs index 6478f6bb0c4..6d36717d785 100644 --- a/config/scripts/release-policy.test.mjs +++ b/config/scripts/release-policy.test.mjs @@ -147,4 +147,41 @@ describe('release policy', () => { await expect(restoreLatestStable(github, repoRef)).resolves.toBe('v1.4.100') }) + + it('keeps a bot-published agent state rules release as a prerelease, off Latest', async () => { + const rules = release('agent-state-rules-engine-1-next', 'github-actions[bot]') + const stable = release('v1.4.214', 'github-actions[bot]') + const github = createGithub({ + releases: [rules, stable], + tags: { 'v1.4.214': {} } + }) + + await run(github, rules, 'published') + + expect(github.rest.repos.deleteRelease).not.toHaveBeenCalled() + expect(github.rest.git.deleteRef).not.toHaveBeenCalled() + expect(github.rest.repos.updateRelease).toHaveBeenCalledWith({ + ...repoRef, + release_id: rules.id, + prerelease: true, + make_latest: 'false' + }) + expect(github.rest.repos.updateRelease).toHaveBeenLastCalledWith({ + ...repoRef, + release_id: stable.id, + make_latest: 'true' + }) + }) + + it('deletes an agent state rules release a person published', async () => { + const rules = release('agent-state-rules-engine-1-stable', 'someone', { prerelease: true }) + const github = createGithub({ releases: [rules] }) + + await run(github, rules, 'published') + + expect(github.rest.repos.deleteRelease).toHaveBeenCalledWith({ + ...repoRef, + release_id: rules.id + }) + }) }) diff --git a/config/scripts/release-tag-pattern-census.test.mjs b/config/scripts/release-tag-pattern-census.test.mjs new file mode 100644 index 00000000000..b42a424a36f --- /dev/null +++ b/config/scripts/release-tag-pattern-census.test.mjs @@ -0,0 +1,184 @@ +// Census: every release-triggered workflow and every script that lists this repo's releases +// classifies tags through release-tag-patterns.mjs, so a new tag family such as the agent state +// rules cannot reach a desktop-only path through one that forgot it. +import { readdirSync, readFileSync } from 'node:fs' +import { join } from 'node:path' +import { afterEach, describe, expect, it, vi } from 'vitest' +import { parse } from 'yaml' +import { + DESKTOP_RC_TAG, + DESKTOP_STABLE_TAG, + DESKTOP_STABLE_TAG_SHELL_PATTERN, + agentStateRulesTag, + isAgentStateRulesTag +} from './release-tag-patterns.mjs' + +const WORKFLOWS_DIR = '.github/workflows' +const SCRIPT_DIRS = ['config/scripts', '.github/scripts'] +const SHARED_IMPORT = /from '[^']*release-tag-patterns\.mjs'/ +const LISTS_RELEASES = /\/releases\?|listReleases|releases\.atom|'release',\s*'list'/ + +const RULES_TAGS = [agentStateRulesTag(1, 'next'), agentStateRulesTag(1, 'stable')] +const rulesRelease = (tag, extra = {}) => ({ + tag_name: tag, + name: tag, + draft: false, + prerelease: true, + author: { login: 'github-actions[bot]' }, + assets: [{ name: 'agent-state-rules.json', download_count: 5 }], + ...extra +}) + +function read(path) { + return readFileSync(path, 'utf8') +} + +function importsSharedPatterns(path) { + return SHARED_IMPORT.test(read(path)) +} + +function isReleaseTriggered(workflow) { + const on = workflow.on + if (typeof on === 'string') { + return on === 'release' + } + if (Array.isArray(on)) { + return on.includes('release') + } + return typeof on === 'object' && on !== null && 'release' in on +} + +function releaseTriggeredWorkflows() { + return readdirSync(WORKFLOWS_DIR) + .filter((name) => /\.ya?ml$/.test(name)) + .map((name) => join(WORKFLOWS_DIR, name)) + .filter((path) => isReleaseTriggered(parse(read(path)))) +} + +function scriptsNamedIn(text) { + return [...text.matchAll(/((?:config|\.github)\/scripts\/[\w.-]+\.mjs)/g)].map( + (match) => match[1] + ) +} + +function releaseListingScripts() { + return SCRIPT_DIRS.flatMap((dir) => + readdirSync(dir) + .filter((name) => name.endsWith('.mjs') && !name.includes('.test.')) + .map((name) => join(dir, name)) + ).filter((path) => LISTS_RELEASES.test(read(path))) +} + +/** + * Scripts that list releases yet admit only desktop tags by their own shape. Each entry proves, + * by calling the script, that a rules release never passes; a new listing script must import the + * shared patterns or add a proof here. + */ +const DESKTOP_ONLY_PROOFS = { + 'config/scripts/create-draft-release.mjs': async () => { + const { latestPreviousPublishedDesktopReleaseTag } = await import('./create-draft-release.mjs') + const releases = [ + ...RULES_TAGS.map((tag) => rulesRelease(tag)), + { tag_name: 'v1.4.1', draft: false } + ] + expect(latestPreviousPublishedDesktopReleaseTag(releases, 'v1.4.2')).toBe('v1.4.1') + }, + 'config/scripts/publish-complete-draft-releases.mjs': async () => { + const { isReleaseCutDraft } = await import('./publish-complete-draft-releases.mjs') + for (const tag of RULES_TAGS) { + expect(isReleaseCutDraft(rulesRelease(tag, { draft: true }))).toBe(false) + } + }, + 'config/scripts/latest-stable-release.mjs': async () => { + const { latestStableDesktopReleaseTag } = await import('./latest-stable-release.mjs') + const releases = [ + ...RULES_TAGS.map((tag) => rulesRelease(tag, { prerelease: false })), + { tag_name: 'v1.4.1' } + ] + expect(latestStableDesktopReleaseTag(releases)).toBe('v1.4.1') + }, + 'config/scripts/assert-github-release-is-draft.mjs': async () => { + const { matchingDesktopReleases } = await import('./assert-github-release-is-draft.mjs') + expect( + matchingDesktopReleases( + RULES_TAGS.map((tag) => rulesRelease(tag)), + 'v1.4.1' + ) + ).toEqual([]) + }, + 'config/scripts/verify-release-required-assets.mjs': async () => { + const { verifyRequiredReleaseAssets } = await import('./verify-release-required-assets.mjs') + vi.stubGlobal( + 'fetch', + vi.fn(async () => new Response(JSON.stringify(RULES_TAGS.map((tag) => rulesRelease(tag))))) + ) + await expect( + verifyRequiredReleaseAssets({ repo: 'stablyai/orca', tag: 'v1.4.1', token: '' }) + ).rejects.toThrow('was not found') + } +} + +afterEach(() => { + vi.unstubAllGlobals() +}) + +describe('release tag pattern census', () => { + it('finds the release-triggered workflows and release-listing scripts it guards', () => { + expect(releaseTriggeredWorkflows().length).toBeGreaterThan(0) + expect(releaseListingScripts().length).toBeGreaterThan(0) + }) + + it.each(releaseTriggeredWorkflows())('%s classifies tags through the shared patterns', (path) => { + const text = read(path) + const viaScript = scriptsNamedIn(text).some(importsSharedPatterns) + const viaShellPattern = text.includes(DESKTOP_STABLE_TAG_SHELL_PATTERN) + expect( + viaScript || viaShellPattern, + `${path} names no script importing release-tag-patterns.mjs and embeds no shared pattern` + ).toBe(true) + }) + + it.each(releaseTriggeredWorkflows())( + '%s checks out the shared patterns beside its script', + (path) => { + const workflow = parse(read(path)) + const sparse = Object.values(workflow.jobs) + .flatMap((job) => job.steps ?? []) + .map((step) => step.with?.['sparse-checkout']) + .filter((value) => typeof value === 'string') + for (const paths of sparse) { + if (scriptsNamedIn(paths).some(importsSharedPatterns)) { + expect(paths).toContain('config/scripts/release-tag-patterns.mjs') + } + } + } + ) + + it.each(releaseListingScripts())( + '%s imports the shared patterns or proves it admits only desktop tags', + async (path) => { + if (importsSharedPatterns(path)) { + return + } + const proof = DESKTOP_ONLY_PROOFS[path] + expect( + proof, + `${path} lists releases: import release-tag-patterns.mjs or add a proof` + ).toBeDefined() + await proof() + } + ) + + it('keeps no proof for a script that no longer lists releases', () => { + const listing = new Set(releaseListingScripts()) + expect(Object.keys(DESKTOP_ONLY_PROOFS).filter((path) => !listing.has(path))).toEqual([]) + }) + + it('never classifies an agent state rules tag as a desktop release', () => { + for (const tag of RULES_TAGS) { + expect(isAgentStateRulesTag(tag)).toBe(true) + expect(DESKTOP_STABLE_TAG.test(tag) || DESKTOP_RC_TAG.test(tag)).toBe(false) + } + expect(() => agentStateRulesTag(1, 'beta')).toThrow() + }) +}) diff --git a/config/scripts/release-tag-patterns.mjs b/config/scripts/release-tag-patterns.mjs new file mode 100644 index 00000000000..43bae852b5f --- /dev/null +++ b/config/scripts/release-tag-patterns.mjs @@ -0,0 +1,30 @@ +// The tag families published as GitHub releases of this repo. Every release-triggered workflow and +// every script that lists releases classifies tags through this file, so a new family (like the +// agent state rules) is excluded or admitted in one place; release-tag-pattern-census.test.mjs +// enforces that. + +const NUMBER = '(?:0|[1-9][0-9]*)' +const VERSION = `${NUMBER}\\.${NUMBER}\\.${NUMBER}` + +/** The stable desktop tag as a bash `[[ =~ ]]` pattern, for workflows that gate inline. */ +export const DESKTOP_STABLE_TAG_SHELL_PATTERN = + '^v(0|[1-9][0-9]*)\\.(0|[1-9][0-9]*)\\.(0|[1-9][0-9]*)$' + +export const DESKTOP_STABLE_TAG = new RegExp(DESKTOP_STABLE_TAG_SHELL_PATTERN) +export const DESKTOP_RC_TAG = new RegExp(`^v${VERSION}-rc\\.${NUMBER}(?:\\.[0-9A-Za-z]+)?$`) +export const MOBILE_TAG = new RegExp(`^mobile(?:-android)?-v${VERSION}$`) + +/** The agent state rules bundle, one release per rules engine and channel, updated in place. */ +const AGENT_STATE_RULES_TAG = /^agent-state-rules-engine-[1-9][0-9]*-(?:next|stable)$/ + +export function isAgentStateRulesTag(tag) { + return AGENT_STATE_RULES_TAG.test(tag) +} + +export function agentStateRulesTag(engineVersion, channel) { + const tag = `agent-state-rules-engine-${engineVersion}-${channel}` + if (!isAgentStateRulesTag(tag)) { + throw new Error(`not an agent state rules tag: ${tag}`) + } + return tag +} diff --git a/src/main/orcad/orcad-entry.ts b/src/main/orcad/orcad-entry.ts index 854b4dfb723..ad9f51e7ad6 100644 --- a/src/main/orcad/orcad-entry.ts +++ b/src/main/orcad/orcad-entry.ts @@ -266,6 +266,13 @@ async function startOrcadRuntime( }) getAppEnvironment().onWillQuit(() => sessionSearch?.dispose()) + // Why: this host evaluates its own panes, so it keeps its own rules current. + const { startAgentStateRulesLiveUpdates } = + await import('../runtime/agent-state-rules/agent-state-rules-live-update') + startAgentStateRulesLiveUpdates(profileStore, (rules) => + console.info(`[orcad] agent state rules ${rules.version} (${rules.source})`) + ) + // Why here too and not only on the desktop: nothing else republishes `session.tabs` when a // pane's status row changes, and orcad's whole job is serving paired clients. uninstallHookStatusRepublish = installHookStatusSessionTabsRepublish( diff --git a/src/main/orcad/orcad-push-startup.test.ts b/src/main/orcad/orcad-push-startup.test.ts index 21ad507c016..530d06f4ee8 100644 --- a/src/main/orcad/orcad-push-startup.test.ts +++ b/src/main/orcad/orcad-push-startup.test.ts @@ -30,6 +30,10 @@ vi.mock('./orcad-daemon-supervision', () => ({ stopOrcadDaemon: async () => {} })) vi.mock('./orcad-health', () => ({ collectOrcadHealth: async () => ({}) })) +// Why: the real updater would fetch rules from GitHub inside a unit test. +vi.mock('../runtime/agent-state-rules/agent-state-rules-live-update', () => ({ + startAgentStateRulesLiveUpdates: () => {} +})) vi.mock('../daemon/daemon-init', () => ({ daemonOwnsFreshPersistentPtys: () => false })) vi.mock('../ipc/pty', () => ({ registerHeadlessPtyRuntime: async () => {}, diff --git a/src/main/proxy-guarded-fetch-call-site-audit.test.ts b/src/main/proxy-guarded-fetch-call-site-audit.test.ts index 4427b11f55d..b92b266f427 100644 --- a/src/main/proxy-guarded-fetch-call-site-audit.test.ts +++ b/src/main/proxy-guarded-fetch-call-site-audit.test.ts @@ -20,7 +20,9 @@ const AUDITED_NON_NET_FETCH_CALLS = new Map([ ['main/rate-limits/minimax/minimax-request-context.ts', 2], // Injected HttpClient, not a session: resolves to net.fetch on defaultSession // (main/host/electron-http-client.ts) or to the global-fetch-audited Node fallback. - ['main/jira/authenticated-request.ts', 1] + ['main/jira/authenticated-request.ts', 1], + // The same injected HttpClient, and the updater's deps.fetch that it is passed as. + ['main/runtime/agent-state-rules/agent-state-rules-live-update.ts', 2] ]) // `globalThis.fetch` / `global.fetch` belong to global-fetch-call-site-audit.test.ts. diff --git a/src/main/runtime/agent-state-rules/active-agent-state-rules.ts b/src/main/runtime/agent-state-rules/active-agent-state-rules.ts new file mode 100644 index 00000000000..bc6c0e1eff8 --- /dev/null +++ b/src/main/runtime/agent-state-rules/active-agent-state-rules.ts @@ -0,0 +1,55 @@ +import { BUNDLED_AGENT_STATE_RULE_FILES } from './agent-state-rules-catalog' +import { BUNDLED_AGENT_STATE_RULES_VERSION } from './agent-state-rules-bundle' +import type { AgentStateRulesFile } from './agent-state-rules-schema' + +/** Where the rules in use came from: precedence is override, then downloaded, then bundled. */ +export type AgentStateRulesSource = 'bundled' | 'downloaded' | 'override' + +export type ActiveAgentStateRules = { + files: readonly AgentStateRulesFile[] + version: number + source: AgentStateRulesSource +} + +export const BUNDLED_AGENT_STATE_RULES: ActiveAgentStateRules = { + files: BUNDLED_AGENT_STATE_RULE_FILES, + version: BUNDLED_AGENT_STATE_RULES_VERSION, + source: 'bundled' +} + +let active = BUNDLED_AGENT_STATE_RULES + +/** Replaces the bundled files of the same id; an agent the bundle does not name keeps its own. */ +export function overlayOnBundledAgentStateRules( + files: readonly AgentStateRulesFile[] +): readonly AgentStateRulesFile[] { + const byId = new Map(files.map((file) => [file.id, file])) + const merged = BUNDLED_AGENT_STATE_RULES.files.map((file) => byId.get(file.id) ?? file) + const bundledIds = new Set(BUNDLED_AGENT_STATE_RULES.files.map((file) => file.id)) + return [...merged, ...files.filter((file) => !bundledIds.has(file.id))] +} + +export function getActiveAgentStateRules(): ActiveAgentStateRules { + return active +} + +/** Hot reload: every compiled view below recompiles from `next` on its next read. */ +export function activateAgentStateRules(next: ActiveAgentStateRules): void { + active = next +} + +/** + * A view compiled from the active files, rebuilt only when they change. Why keyed on the array: + * readers run on every poll, and one identity check is all a stable rule set costs them. + */ +export function compiledFromActiveAgentStateRules( + compile: (files: readonly AgentStateRulesFile[]) => T +): () => T { + let compiled: { files: readonly AgentStateRulesFile[]; value: T } | null = null + return () => { + if (compiled?.files !== active.files) { + compiled = { files: active.files, value: compile(active.files) } + } + return compiled.value + } +} diff --git a/src/main/runtime/agent-state-rules/agent-state-rules-bundle.test.ts b/src/main/runtime/agent-state-rules/agent-state-rules-bundle.test.ts new file mode 100644 index 00000000000..679c6f7a3d4 --- /dev/null +++ b/src/main/runtime/agent-state-rules/agent-state-rules-bundle.test.ts @@ -0,0 +1,68 @@ +import { describe, expect, it } from 'vitest' +import { + AGENT_STATE_RULES_BUNDLE_MAX_BYTES, + LIVE_UPDATABLE_AGENT_STATE_RULE_IDS, + parseAgentStateRulesBundle +} from './agent-state-rules-bundle' +import { BUNDLED_AGENT_STATE_RULE_FILES } from './agent-state-rules-catalog' + +function file(id: string): Record { + const found = BUNDLED_AGENT_STATE_RULE_FILES.find((candidate) => candidate.id === id) + return structuredClone({ ...found }) +} + +function bundle(overrides: Record = {}): string { + return JSON.stringify({ + version: 2, + engineVersion: 1, + files: [file('claude')], + ...overrides + }) +} + +function rejection(text: string, scope: 'live-updatable' | 'any-agent' = 'live-updatable') { + const result = parseAgentStateRulesBundle(text, scope) + return result.ok ? null : result.error +} + +describe('agent state rules bundle', () => { + it('accepts a live-updatable file and carries the bundledOnly flag', () => { + const result = parseAgentStateRulesBundle(bundle({ bundledOnly: true }), 'live-updatable') + expect(result).toMatchObject({ + ok: true, + bundle: { version: 2, bundledOnly: true } + }) + }) + + it.each([ + ['an unknown top-level field', bundle({ signature: 'x' }), 'Unrecognized key'], + ['a version that is not a positive integer', bundle({ version: '2026.10.02' }), 'version'], + ['version zero', bundle({ version: 0 }), 'version'], + ['a missing version', bundle({ version: undefined }), 'version'], + ['another engine', bundle({ engineVersion: 2 }), 'engineVersion'], + [ + 'a file with a misspelled field', + bundle({ files: [{ ...file('claude'), rule: [] }] }), + 'agent state rules file 0' + ], + ['two files for one agent', bundle({ files: [file('claude'), file('claude')] }), 'two files'], + ['an agent with no transcript suite', bundle({ files: [file('gemini')] }), 'gemini'] + ])('rejects %s', (_label, text, error) => { + expect(rejection(text)).toContain(error) + }) + + it('rejects text over the size cap before parsing it', () => { + expect(rejection(`${bundle()}${' '.repeat(AGENT_STATE_RULES_BUNDLE_MAX_BYTES)}`)).toBe( + `larger than ${AGENT_STATE_RULES_BUNDLE_MAX_BYTES} bytes` + ) + }) + + it('lets a local override carry any agent', () => { + expect(rejection(bundle({ files: [file('gemini')] }), 'any-agent')).toBeNull() + }) + + it('names only agents that have a bundled rule file', () => { + const bundled = new Set(BUNDLED_AGENT_STATE_RULE_FILES.map((rules) => rules.id)) + expect([...LIVE_UPDATABLE_AGENT_STATE_RULE_IDS].filter((id) => !bundled.has(id))).toEqual([]) + }) +}) diff --git a/src/main/runtime/agent-state-rules/agent-state-rules-bundle.ts b/src/main/runtime/agent-state-rules/agent-state-rules-bundle.ts new file mode 100644 index 00000000000..b4b77cd1ffb --- /dev/null +++ b/src/main/runtime/agent-state-rules/agent-state-rules-bundle.ts @@ -0,0 +1,69 @@ +import { z } from 'zod' +import release from './agent-state-rules-release.json' +import { parseAgentStateRuleFiles } from './agent-state-rules-catalog' +import { + AGENT_STATE_RULES_ENGINE_VERSION, + type AgentStateRulesFile +} from './agent-state-rules-schema' + +/** + * The published bundle, `agent-state-rules.json`: the live-updatable agents' rule files, built by + * config/scripts/agent-state-rules-bundle.mjs from the per-agent files and + * agent-state-rules-release.json. `bundledOnly` tells apps to fall back to the rules they shipped. + */ + +// Why a cap: the file comes off the network and is parsed on the main thread. +export const AGENT_STATE_RULES_BUNDLE_MAX_BYTES = 256 * 1024 + +/** The version of the rules this build ships; a download must be higher to replace them. */ +export const BUNDLED_AGENT_STATE_RULES_VERSION: number = release.version + +/** The rule files a rules release may carry: those the publish gate can replay transcripts for. */ +export const LIVE_UPDATABLE_AGENT_STATE_RULE_IDS: ReadonlySet = new Set( + release.liveUpdatable +) + +// Why the engine is checked here, before the files: a newer engine's files fail the file schema +// for a reason that is not theirs. +const BundleEnvelopeSchema = z + .object({ + version: z.number().int().positive(), + engineVersion: z.literal(AGENT_STATE_RULES_ENGINE_VERSION), + bundledOnly: z.boolean().optional(), + files: z.array(z.unknown()) + }) + .strict() + +export type AgentStateRulesBundle = { + version: number + bundledOnly: boolean + files: readonly AgentStateRulesFile[] +} + +export type AgentStateRulesBundleParse = + | { ok: true; bundle: AgentStateRulesBundle } + | { ok: false; error: string } + +/** + * Validates a bundle whole: a file that fails any check is rejected, never partly applied. + * `any-agent` is for the local override, which the user chose, so it may carry any agent. + */ +export function parseAgentStateRulesBundle( + text: string, + scope: 'live-updatable' | 'any-agent' +): AgentStateRulesBundleParse { + if (Buffer.byteLength(text, 'utf8') > AGENT_STATE_RULES_BUNDLE_MAX_BYTES) { + return { ok: false, error: `larger than ${AGENT_STATE_RULES_BUNDLE_MAX_BYTES} bytes` } + } + try { + const { version, bundledOnly, files } = BundleEnvelopeSchema.parse(JSON.parse(text)) + const parsed = parseAgentStateRuleFiles(files) + const outside = parsed.find((file) => !LIVE_UPDATABLE_AGENT_STATE_RULE_IDS.has(file.id)) + if (scope === 'live-updatable' && outside) { + return { ok: false, error: `carries ${outside.id}, which has no transcript suite to gate it` } + } + return { ok: true, bundle: { version, bundledOnly: bundledOnly ?? false, files: parsed } } + } catch (error) { + return { ok: false, error: error instanceof Error ? error.message : String(error) } + } +} diff --git a/src/main/runtime/agent-state-rules/agent-state-rules-engine.ts b/src/main/runtime/agent-state-rules/agent-state-rules-engine.ts index 5ba0b853b60..afae652ead9 100644 --- a/src/main/runtime/agent-state-rules/agent-state-rules-engine.ts +++ b/src/main/runtime/agent-state-rules/agent-state-rules-engine.ts @@ -1,7 +1,7 @@ import type { AgentStatus } from '../../../shared/agent-detection' import type { TuiAgent } from '../../../shared/tui-agent' import { compileScreenCondition } from './agent-state-rule-matchers' -import { BUNDLED_AGENT_STATE_RULE_FILES } from './agent-state-rules-catalog' +import { compiledFromActiveAgentStateRules } from './active-agent-state-rules' import { UNKNOWN_PANE_RULES_ID, type AgentStateRuleAnswer, @@ -105,20 +105,23 @@ type RulesKey = TuiAgent | typeof UNKNOWN_PANE_RULES_ID type CompiledFile = { rules: CompiledRule[]; readsTrustedScreen: boolean; hooks: HookAuthority } -const FILES_BY_KEY: ReadonlyMap = new Map( - BUNDLED_AGENT_STATE_RULE_FILES.map((file) => [ - file.id, - { - rules: compileAgentRules(file), - readsTrustedScreen: file.profile?.screenSource === 'trusted', - hooks: file.profile?.hooks ?? 'identity-only' - } - ]) +const filesByKey = compiledFromActiveAgentStateRules( + (files): ReadonlyMap => + new Map( + files.map((file) => [ + file.id, + { + rules: compileAgentRules(file), + readsTrustedScreen: file.profile?.screenSource === 'trusted', + hooks: file.profile?.hooks ?? 'identity-only' + } + ]) + ) ) // Why the unknown-pane file for a null agent: an adopted pane can still run a known agent. function compiledFileFor(agent: TuiAgent | null | undefined): CompiledFile | undefined { - return FILES_BY_KEY.get(agent ?? UNKNOWN_PANE_RULES_ID) + return filesByKey().get(agent ?? UNKNOWN_PANE_RULES_ID) } /** diff --git a/src/main/runtime/agent-state-rules/agent-state-rules-live-update.test.ts b/src/main/runtime/agent-state-rules/agent-state-rules-live-update.test.ts new file mode 100644 index 00000000000..8400f38d9fd --- /dev/null +++ b/src/main/runtime/agent-state-rules/agent-state-rules-live-update.test.ts @@ -0,0 +1,406 @@ +import { mkdtempSync, readdirSync, readFileSync, rmSync, writeFileSync } from 'node:fs' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { getTerminalTailSentinelMatches } from '../terminal-tail-sentinel-index' +import { + activateAgentStateRules, + BUNDLED_AGENT_STATE_RULES, + getActiveAgentStateRules +} from './active-agent-state-rules' +import { BUNDLED_AGENT_STATE_RULES_VERSION } from './agent-state-rules-bundle' +import { BUNDLED_AGENT_STATE_RULE_FILES } from './agent-state-rules-catalog' +import { idleTitleRequiresQuiet } from './agent-state-rules-engine' +import { + AgentStateRulesLiveUpdater, + agentStateRulesCacheFileName, + agentStateRulesChannelForAppVersion, + agentStateRulesDownloadUrl, + type AgentStateRulesLiveUpdateDeps +} from './agent-state-rules-live-update' +import { detectExplicitIdleStatusFromTitle } from '../terminal-wait-detection' +import { showsIdleTitleAnchor } from './agent-state-title-anchors' + +const NEWER = 9999 +const NEWEST = 10000 + +function bundledFile(id: string): Record { + const file = BUNDLED_AGENT_STATE_RULE_FILES.find((candidate) => candidate.id === id) + if (!file) { + throw new Error(`no bundled ${id}`) + } + return structuredClone(file) +} + +// A Claude file whose title rule settles without quiet: visible through idleTitleRequiresQuiet. +function claudeWithoutQuiet(): Record { + const claude = bundledFile('claude') + return { + ...claude, + rules: [ + { + id: 'idle_title', + why: 'test', + priority: 100, + when: { region: 'title', status: 'idle' }, + answer: { state: 'idle', strength: 'weak', requiresQuiet: false } + } + ] + } +} + +function bundleText( + version: number, + files: unknown[] = [claudeWithoutQuiet()], + extra: Record = {} +): string { + return JSON.stringify({ version, engineVersion: 1, ...extra, files }) +} + +type Harness = { + updater: AgentStateRulesLiveUpdater + userData: string + settings: { agentStateRulesPath?: string | null; agentStateRulesLiveUpdates?: boolean } + fetch: ReturnType + onActivated: ReturnType +} + +let userData: string +let warn: ReturnType + +function status(): { version: number; source: string } { + const { version, source } = getActiveAgentStateRules() + return { version, source } +} + +function warnings(): string[] { + return warn.mock.calls.map(([message]) => String(message)) +} + +function harness(overrides: Partial = {}): Harness { + const settings: Harness['settings'] = {} + const fetch = vi.fn(async () => new Response('Not Found', { status: 404 })) + const onActivated = vi.fn() + const updater = new AgentStateRulesLiveUpdater({ + userDataPath: userData, + appVersion: '1.4.0', + isPackaged: true, + fetch, + readSettings: () => settings, + onActivated, + ...overrides + }) + return { updater, userData, settings, fetch, onActivated } +} + +function serve(h: Harness, text: string): void { + h.fetch.mockImplementation(async () => new Response(text, { status: 200 })) +} + +function cachePath(channel: 'next' | 'stable' = 'stable'): string { + return join(userData, agentStateRulesCacheFileName(channel)) +} + +function writeCache(text: string, channel: 'next' | 'stable' = 'stable'): void { + writeFileSync(cachePath(channel), text) +} + +beforeEach(() => { + userData = mkdtempSync(join(tmpdir(), 'agent-state-rules-live-update-')) + warn = vi.spyOn(console, 'warn').mockImplementation(() => {}) +}) + +afterEach(() => { + activateAgentStateRules(BUNDLED_AGENT_STATE_RULES) + warn.mockRestore() + rmSync(userData, { recursive: true, force: true }) +}) + +describe('agent state rules channel and URL', () => { + it.each([ + ['1.4.0', 'stable'], + ['1.4.1-rc.2', 'next'], + ['1.4.1-rc.2.perf', 'next'], + ['not-a-version', null] + ])('maps app version %s to %s', (version, channel) => { + expect(agentStateRulesChannelForAppVersion(version)).toBe(channel) + }) + + it('fetches the fixed release-download URL for the engine and channel', () => { + expect(agentStateRulesDownloadUrl('next')).toBe( + 'https://github.com/stablyai/orca/releases/download/agent-state-rules-engine-1-next/agent-state-rules.json' + ) + }) +}) + +describe('agent state rules live updates', () => { + it('starts on the bundled rules and fetches the stable tag for a stable app', async () => { + const h = harness() + await h.updater.start() + expect(h.fetch).toHaveBeenCalledWith(agentStateRulesDownloadUrl('stable'), expect.anything()) + expect(status()).toEqual({ version: BUNDLED_AGENT_STATE_RULES_VERSION, source: 'bundled' }) + expect(warnings()).toEqual(['[agent-state-rules] download failed: HTTP 404']) + h.updater.stop() + }) + + it('accepts a newer download, writes it atomically, and hot-reloads the engine', async () => { + const h = harness({ appVersion: '1.4.1-rc.0' }) + expect(idleTitleRequiresQuiet('claude')).toBe(true) + const text = bundleText(NEWER) + serve(h, text) + await h.updater.start() + expect(h.fetch).toHaveBeenCalledWith(agentStateRulesDownloadUrl('next'), expect.anything()) + expect(status()).toEqual({ version: NEWER, source: 'downloaded' }) + expect(warnings()).toEqual([]) + expect(idleTitleRequiresQuiet('claude')).toBe(false) + expect(readFileSync(cachePath('next'), 'utf8')).toBe(text) + expect(readdirSync(userData)).toEqual([agentStateRulesCacheFileName('next')]) + expect(h.onActivated).toHaveBeenCalledWith({ version: NEWER, source: 'downloaded' }) + h.updater.stop() + }) + + it('keeps every agent the download does not carry on its bundled file', async () => { + const h = harness() + serve(h, bundleText(NEWER)) + await h.updater.start() + const active = getActiveAgentStateRules() + expect(active.files.map((file) => file.id)).toEqual( + BUNDLED_AGENT_STATE_RULE_FILES.map((file) => file.id) + ) + expect(active.files.find((file) => file.id === 'gemini')).toBe( + BUNDLED_AGENT_STATE_RULE_FILES.find((file) => file.id === 'gemini') + ) + h.updater.stop() + }) + + it.each([ + ['not newer than the bundled copy', bundleText(BUNDLED_AGENT_STATE_RULES_VERSION), null], + [ + 'built for another engine', + JSON.stringify({ version: NEWER, engineVersion: 2, files: [] }), + 'download rejected: ' + ], + [ + 'carrying an agent with no transcript suite', + bundleText(NEWER, [bundledFile('gemini')]), + 'download rejected: carries gemini, which has no transcript suite to gate it' + ], + [ + 'an unsafe pattern', + bundleText(NEWER, [ + { + ...bundledFile('claude'), + anchors: [ + { + id: 'bad', + why: 'test', + when: { region: 'title', status: 'idle', match: { regex: '(a+)+$' } }, + answer: { state: 'idle' } + } + ] + } + ]), + 'repeats a group' + ], + ['not JSON', '{', 'download rejected: '], + [ + 'over the size cap', + ' '.repeat(256 * 1024 + 1), + 'download failed: Response body exceeds 262144 byte limit' + ] + ])('refuses a download %s and keeps the bundled rules', async (_label, text, error) => { + const h = harness() + serve(h, text) + await h.updater.start() + expect(status()).toEqual({ version: BUNDLED_AGENT_STATE_RULES_VERSION, source: 'bundled' }) + if (error === null) { + expect(warnings()).toEqual([]) + } else { + expect(warnings()).toEqual([expect.stringContaining(error)]) + } + expect(readdirSync(userData)).toEqual([]) + h.updater.stop() + }) + + it('keeps the last good copy through a 404, a network error and an older download', async () => { + const h = harness() + serve(h, bundleText(NEWEST)) + await h.updater.start() + h.fetch.mockImplementation(async () => new Response('Not Found', { status: 404 })) + await h.updater.refresh() + expect(status()).toMatchObject({ version: NEWEST, source: 'downloaded' }) + h.fetch.mockImplementation(async () => { + throw new Error('offline') + }) + await h.updater.refresh() + expect(status()).toEqual({ version: NEWEST, source: 'downloaded' }) + expect(warnings().at(-1)).toBe('[agent-state-rules] download failed: offline') + serve(h, bundleText(NEWER)) + await h.updater.refresh() + expect(status()).toEqual({ version: NEWEST, source: 'downloaded' }) + expect(JSON.parse(readFileSync(cachePath(), 'utf8'))).toMatchObject({ version: NEWEST }) + h.updater.stop() + }) + + it('activates a cached download newer than the bundled rules before any fetch lands', async () => { + writeCache(bundleText(NEWER)) + const h = harness() + h.fetch.mockImplementation(() => new Promise(() => {})) + void h.updater.start() + await vi.waitFor(() => expect(status().source).toBe('downloaded')) + expect(status().version).toBe(NEWER) + h.updater.stop() + }) + + it('ignores a cached download that is not newer than the bundled rules', async () => { + writeCache(bundleText(BUNDLED_AGENT_STATE_RULES_VERSION)) + const h = harness() + await h.updater.start() + expect(status().source).toBe('bundled') + h.updater.stop() + }) + + it("never reads the other channel's cache", async () => { + // Why: an RC build left next rules in the userData a stable build now shares. + writeCache(bundleText(NEWEST), 'next') + const h = harness() + serve(h, bundleText(NEWER)) + await h.updater.start() + expect(status()).toMatchObject({ version: NEWER, source: 'downloaded' }) + expect(JSON.parse(readFileSync(cachePath('next'), 'utf8'))).toMatchObject({ version: NEWEST }) + h.updater.stop() + }) + + it('lets no superseded start or fetch change the rules after a restart', async () => { + const overridePath = join(userData, 'override.json') + writeFileSync(overridePath, bundleText(1, [bundledFile('gemini')])) + const h = harness() + let land: (response: Response) => void = () => {} + h.fetch.mockImplementation(() => new Promise((resolve) => (land = resolve))) + h.settings.agentStateRulesPath = overridePath + const superseded = h.updater.start() + await vi.waitFor(() => expect(h.fetch).toHaveBeenCalledTimes(1)) + h.settings.agentStateRulesPath = null + h.settings.agentStateRulesLiveUpdates = false + await h.updater.start() + land(new Response(bundleText(NEWER), { status: 200 })) + await superseded + expect(status()).toEqual({ version: BUNDLED_AGENT_STATE_RULES_VERSION, source: 'bundled' }) + expect(readdirSync(userData)).toEqual(['override.json']) + }) + + it('refuses a download no newer than the cached one', async () => { + writeCache(bundleText(NEWEST)) + const h = harness() + serve(h, bundleText(NEWER, [bundledFile('claude')])) + await h.updater.start() + expect(status()).toMatchObject({ version: NEWEST, source: 'downloaded' }) + expect(idleTitleRequiresQuiet('claude')).toBe(false) + h.updater.stop() + }) + + it('falls back to the bundled rules when a published bundle says bundledOnly', async () => { + const h = harness() + serve(h, bundleText(NEWER)) + await h.updater.start() + serve(h, bundleText(NEWEST, [claudeWithoutQuiet()], { bundledOnly: true })) + await h.updater.refresh() + expect(status()).toMatchObject({ source: 'bundled' }) + expect(idleTitleRequiresQuiet('claude')).toBe(true) + h.updater.stop() + }) + + it('uses only the bundled rules when the setting turns live updates off', async () => { + writeCache(bundleText(NEWER)) + const h = harness() + h.settings.agentStateRulesLiveUpdates = false + await h.updater.start() + expect(h.fetch).not.toHaveBeenCalled() + expect(status().source).toBe('bundled') + h.updater.stop() + }) + + it('never fetches from an unpackaged build', async () => { + const h = harness({ isPackaged: false }) + await h.updater.start() + expect(h.fetch).not.toHaveBeenCalled() + h.updater.stop() + }) + + it('ranks a local override over a download over the bundled rules', async () => { + const overridePath = join(userData, 'override.json') + // Why gemini: the override may carry an agent a rules release cannot. + writeFileSync(overridePath, bundleText(1, [bundledFile('gemini')])) + writeCache(bundleText(NEWER)) + const h = harness() + h.settings.agentStateRulesPath = overridePath + await h.updater.start() + expect(status()).toMatchObject({ version: 1, source: 'override' }) + + h.settings.agentStateRulesPath = null + await h.updater.start() + expect(status()).toMatchObject({ version: NEWER, source: 'downloaded' }) + h.updater.stop() + }) + + it('reports a rejected override and keeps the next source in line', async () => { + const overridePath = join(userData, 'override.json') + writeFileSync(overridePath, '{"version":1,"engineVersion":1,"files":[{"id":"claude"}]}') + const h = harness() + h.settings.agentStateRulesPath = overridePath + await h.updater.start() + expect(status().source).toBe('bundled') + expect(warnings()[0]).toContain(`override ${overridePath} rejected`) + expect(h.onActivated).not.toHaveBeenCalled() + h.updater.stop() + }) + + it('still activates an accepted download when the cache cannot be written', async () => { + const h = harness({ userDataPath: join(userData, 'missing', 'dir') }) + serve(h, bundleText(NEWER)) + await h.updater.start() + expect(status().source).toBe('downloaded') + expect(warnings()).toEqual([expect.stringContaining('downloaded rules not cached')]) + h.updater.stop() + }) +}) + +describe('agent state rules hot reload', () => { + it('recompiles the title anchors every pane reads, past the title memo', () => { + expect(showsIdleTitleAnchor('✳ Claude Code')).toBe(true) + expect(detectExplicitIdleStatusFromTitle('✳ Claude Code')).toBe('idle') + activateAgentStateRules({ + ...BUNDLED_AGENT_STATE_RULES, + files: BUNDLED_AGENT_STATE_RULES.files.map((file) => + file.id === 'claude' ? { ...file, anchors: [] } : file + ) + }) + expect(showsIdleTitleAnchor('✳ Claude Code')).toBe(false) + expect(detectExplicitIdleStatusFromTitle('✳ Claude Code')).toBeNull() + }) + + it('rescans a tail the sentinel index already covered when a blocked anchor arrives', () => { + const lines = ['zebra crossing prompt'] + expect(getTerminalTailSentinelMatches(lines)).toEqual([]) + activateAgentStateRules({ + ...BUNDLED_AGENT_STATE_RULES, + files: BUNDLED_AGENT_STATE_RULES.files.map((file) => + file.id === 'claude' + ? { + ...file, + anchors: [ + ...file.anchors, + { + id: 'zebra', + why: 'test', + when: { region: 'text', find: { lastOf: 'zebra crossing' } }, + answer: { state: 'blocked', reason: 'agent-approval-prompt' } + } + ] + } + : file + ) + }) + expect(getTerminalTailSentinelMatches(lines)).toEqual([0]) + }) +}) diff --git a/src/main/runtime/agent-state-rules/agent-state-rules-live-update.ts b/src/main/runtime/agent-state-rules/agent-state-rules-live-update.ts new file mode 100644 index 00000000000..ee4e7440967 --- /dev/null +++ b/src/main/runtime/agent-state-rules/agent-state-rules-live-update.ts @@ -0,0 +1,259 @@ +import { readFile } from 'node:fs/promises' +import { join } from 'node:path' +import { getAppEnvironment } from '../../../shared/app-environment' +import { readFetchResponseTextWithinLimit } from '../../../shared/fetch-response-body' +import type { GlobalSettings } from '../../../shared/global-settings-types' +import { getVersionChannel, MAIN_RELEASE_REPO } from '../../../shared/release-channel' +import { getMainHttpClient } from '../../network/http-client' +import { writePluginFileAtomically } from '../../plugins/plugin-atomic-file-write' +import { + activateAgentStateRules, + BUNDLED_AGENT_STATE_RULES, + getActiveAgentStateRules, + overlayOnBundledAgentStateRules, + type ActiveAgentStateRules, + type AgentStateRulesSource +} from './active-agent-state-rules' +import { + AGENT_STATE_RULES_BUNDLE_MAX_BYTES, + BUNDLED_AGENT_STATE_RULES_VERSION, + parseAgentStateRulesBundle, + type AgentStateRulesBundle +} from './agent-state-rules-bundle' +import { AGENT_STATE_RULES_ENGINE_VERSION } from './agent-state-rules-schema' + +const REFRESH_INTERVAL_MS = 4 * 60 * 60 * 1000 +const FETCH_TIMEOUT_MS = 30_000 + +export type AgentStateRulesChannel = 'next' | 'stable' + +/** Stable apps read the stable tag; RC, hourly, daily and adhoc builds soak the next one first. */ +export function agentStateRulesChannelForAppVersion( + appVersion: string +): AgentStateRulesChannel | null { + const channel = getVersionChannel(appVersion) + if (!channel) { + return null + } + return channel === 'stable' ? 'stable' : 'next' +} + +// Why a fixed release-download URL: no API call or rate limit, and no "latest" lookup to steer. +export function agentStateRulesDownloadUrl(channel: AgentStateRulesChannel): string { + const tag = `agent-state-rules-engine-${AGENT_STATE_RULES_ENGINE_VERSION}-${channel}` + return `https://github.com/${MAIN_RELEASE_REPO}/releases/download/${tag}/agent-state-rules.json` +} + +// Why per channel: stable and RC builds share userData, and a stable app must not run, or be +// held below, rules that only next has published. +export function agentStateRulesCacheFileName(channel: AgentStateRulesChannel): string { + return `agent-state-rules-${channel}.json` +} + +type LiveUpdateSettings = Pick + +export type AgentStateRulesLiveUpdateDeps = { + userDataPath: string + appVersion: string + /** Unpackaged dev and test runs never download. */ + isPackaged: boolean + fetch: (url: string, init?: RequestInit) => Promise + readSettings: () => LiveUpdateSettings + /** Called when the active version or source changes, for diagnostics and crash reports. */ + onActivated: (rules: { version: number; source: AgentStateRulesSource }) => void +} + +function warn(message: string): void { + console.warn(`[agent-state-rules] ${message}`) +} + +function describeError(error: unknown): string { + return error instanceof Error ? error.message : String(error) +} + +function parseOrWarn( + label: string, + text: string, + scope: 'live-updatable' | 'any-agent' +): AgentStateRulesBundle | null { + const parsed = parseAgentStateRulesBundle(text, scope) + if (!parsed.ok) { + warn(`${label} rejected: ${parsed.error}`) + return null + } + return parsed.bundle +} + +async function readBundleFile( + label: string, + path: string, + scope: 'live-updatable' | 'any-agent', + warnIfMissing: boolean +): Promise { + try { + return parseOrWarn(label, await readFile(path, 'utf8'), scope) + } catch (error) { + const missing = error instanceof Error && 'code' in error && error.code === 'ENOENT' + if (!missing || warnIfMissing) { + warn(`${label} unreadable: ${describeError(error)}`) + } + return null + } +} + +/** + * Keeps the active agent state rules current: a local override, else a downloaded copy newer than + * the bundled one, else the bundled rules. Any failure keeps the last good copy active. + */ +export class AgentStateRulesLiveUpdater { + private override: AgentStateRulesBundle | null = null + /** Always newer than the bundled rules, so it is also the floor a download must clear. */ + private downloaded: AgentStateRulesBundle | null = null + // Why an identity per start: a settings change restarts while an earlier read or fetch may + // still be pending, and a superseded one must not change the active rules. + private run: { channel: AgentStateRulesChannel | null } | null = null + private timer: ReturnType | null = null + + constructor(private readonly deps: AgentStateRulesLiveUpdateDeps) {} + + /** Loads the override and the cached download, then fetches now and on an interval. Re-run it + * when the settings it reads change. */ + async start(): Promise { + this.stop() + const settings = this.deps.readSettings() + const live = this.deps.isPackaged && settings.agentStateRulesLiveUpdates !== false + const run = { channel: live ? agentStateRulesChannelForAppVersion(this.deps.appVersion) : null } + this.run = run + const overridePath = settings.agentStateRulesPath + const [override, cached] = await Promise.all([ + // Why any agent: the user chose this file, so the transcript gate on releases does not apply. + overridePath + ? readBundleFile(`override ${overridePath}`, overridePath, 'any-agent', true) + : null, + run.channel + ? readBundleFile('cached rules', this.cachePath(run.channel), 'live-updatable', false) + : null + ]) + if (this.run !== run) { + return + } + this.override = override + // Why re-check the version: an app update may have bundled rules newer than the cache. + this.downloaded = cached && cached.version > BUNDLED_AGENT_STATE_RULES_VERSION ? cached : null + this.activate() + if (run.channel) { + this.timer = setInterval(() => void this.refresh(), REFRESH_INTERVAL_MS) + this.timer.unref?.() + await this.refresh() + } + } + + stop(): void { + this.run = null + if (this.timer) { + clearInterval(this.timer) + this.timer = null + } + } + + /** Fetches the channel's file now; does nothing unless started with live updates on. */ + async refresh(): Promise { + const run = this.run + if (!run?.channel) { + return + } + const text = await this.fetchText(run.channel) + if (this.run !== run || text === null) { + return + } + const bundle = parseOrWarn('download', text, 'live-updatable') + // Why higher than both: an app whose bundled rules already hold a fix must not be shadowed by + // an older download, and a cached copy must never be replaced by an older one. + const floor = this.downloaded?.version ?? BUNDLED_AGENT_STATE_RULES_VERSION + if (!bundle || bundle.version <= floor) { + return + } + this.downloaded = bundle + this.activate() + try { + await writePluginFileAtomically(this.cachePath(run.channel), text) + } catch (error) { + warn(`downloaded rules not cached: ${describeError(error)}`) + } + } + + private cachePath(channel: AgentStateRulesChannel): string { + return join(this.deps.userDataPath, agentStateRulesCacheFileName(channel)) + } + + /** The published file's text, or null after warning why there is none. */ + private async fetchText(channel: AgentStateRulesChannel): Promise { + try { + const response = await this.deps.fetch(agentStateRulesDownloadUrl(channel), { + signal: AbortSignal.timeout(FETCH_TIMEOUT_MS) + }) + if (!response.ok) { + await response.body?.cancel().catch(() => undefined) + // Why not fatal: a 404 is also what a re-upload in progress looks like. + warn(`download failed: HTTP ${response.status}`) + return null + } + return await readFetchResponseTextWithinLimit(response, AGENT_STATE_RULES_BUNDLE_MAX_BYTES) + } catch (error) { + warn(`download failed: ${describeError(error)}`) + return null + } + } + + private resolveActive(): ActiveAgentStateRules { + const chosen = this.override ?? (this.downloaded?.bundledOnly ? null : this.downloaded) + if (!chosen) { + return BUNDLED_AGENT_STATE_RULES + } + return { + files: overlayOnBundledAgentStateRules(chosen.files), + version: chosen.version, + source: chosen === this.override ? 'override' : 'downloaded' + } + } + + private activate(): void { + const previous = getActiveAgentStateRules() + const next = this.resolveActive() + activateAgentStateRules(next) + if (previous.version !== next.version || previous.source !== next.source) { + this.deps.onActivated({ version: next.version, source: next.source }) + } + } +} + +type SettingsStore = { + getSettings: () => LiveUpdateSettings + onSettingsChanged: (listener: (updates: Partial) => void) => unknown +} + +/** + * Starts live updates on this host: the desktop, `orca serve` and orcad each fetch their own copy, + * so a paired client never supplies the rules a host evaluates with. + */ +export function startAgentStateRulesLiveUpdates( + store: SettingsStore, + onActivated: AgentStateRulesLiveUpdateDeps['onActivated'] +): void { + const environment = getAppEnvironment() + const updater = new AgentStateRulesLiveUpdater({ + userDataPath: environment.getPath('userData'), + appVersion: environment.getVersion(), + isPackaged: environment.isPackaged(), + fetch: (url, init) => getMainHttpClient().fetch(url, init), + readSettings: () => store.getSettings(), + onActivated + }) + void updater.start() + store.onSettingsChanged((updates) => { + if ('agentStateRulesPath' in updates || 'agentStateRulesLiveUpdates' in updates) { + void updater.start() + } + }) + environment.onWillQuit(() => updater.stop()) +} diff --git a/src/main/runtime/agent-state-rules/agent-state-rules-release.json b/src/main/runtime/agent-state-rules/agent-state-rules-release.json new file mode 100644 index 00000000000..6876a5a2170 --- /dev/null +++ b/src/main/runtime/agent-state-rules/agent-state-rules-release.json @@ -0,0 +1,14 @@ +{ + "version": 1, + "liveUpdatable": [ + "antigravity", + "claude", + "cline", + "codex", + "cursor", + "omp", + "opencode", + "prime-agent", + "unknown-pane" + ] +} diff --git a/src/main/runtime/agent-state-rules/agent-state-rules-schema.ts b/src/main/runtime/agent-state-rules/agent-state-rules-schema.ts index f5aafc2db3a..c3735d2ef9e 100644 --- a/src/main/runtime/agent-state-rules/agent-state-rules-schema.ts +++ b/src/main/runtime/agent-state-rules/agent-state-rules-schema.ts @@ -11,7 +11,7 @@ import { findUnsafePatternReason } from './agent-state-rule-pattern-safety' * show) plus `answer`. Once a version ships, adding a region, predicate or answer bumps * `engineVersion`; until then version 1 is still being defined. */ -const AGENT_STATE_RULES_ENGINE_VERSION = 1 +export const AGENT_STATE_RULES_ENGINE_VERSION = 1 const MAX_PATTERN_LENGTH = 200 const MAX_RULES = 32 diff --git a/src/main/runtime/agent-state-rules/agent-state-text-anchors.test.ts b/src/main/runtime/agent-state-rules/agent-state-text-anchors.test.ts index 97fda216dc6..2dbdea7c3b1 100644 --- a/src/main/runtime/agent-state-rules/agent-state-text-anchors.test.ts +++ b/src/main/runtime/agent-state-rules/agent-state-text-anchors.test.ts @@ -1,7 +1,7 @@ import { describe, expect, it } from 'vitest' import { parseAgentStateRuleFiles } from './agent-state-rules-catalog' import { compileTextAnchors, findPromptAnchorIndexes } from './agent-state-text-anchors' -import { TERMINAL_WAIT_BLOCKED_SENTINEL_RE } from './blocked-text-layer' +import { terminalWaitBlockedSentinelRe } from './blocked-text-layer' import { detectTerminalWaitBlockedReason } from '../terminal-wait-detection' function anchorsOf(anchors: unknown[]) { @@ -79,6 +79,6 @@ describe('the bundled Cursor approval menu', () => { describe('the blocked layer prefilter', () => { it('includes every bundled blocked anchor', () => { - expect(TERMINAL_WAIT_BLOCKED_SENTINEL_RE.test('Run this command?')).toBe(true) + expect(terminalWaitBlockedSentinelRe().test('Run this command?')).toBe(true) }) }) diff --git a/src/main/runtime/agent-state-rules/agent-state-text-anchors.ts b/src/main/runtime/agent-state-rules/agent-state-text-anchors.ts index 5de562de05a..4c59b5a39e5 100644 --- a/src/main/runtime/agent-state-rules/agent-state-text-anchors.ts +++ b/src/main/runtime/agent-state-rules/agent-state-text-anchors.ts @@ -1,7 +1,7 @@ import type { RuntimeTerminalWaitBlockedReason } from '../../../shared/runtime-types' import { startOfLastLines } from '../terminal-wait-tail-window' import { compileTextTest, type TextMatcher } from './agent-state-rule-matchers' -import { BUNDLED_AGENT_STATE_RULE_FILES } from './agent-state-rules-catalog' +import { compiledFromActiveAgentStateRules } from './active-agent-state-rules' import type { Anchor, AgentStateRulesFile, @@ -96,14 +96,16 @@ export function compileTextAnchors(files: readonly AgentStateRulesFile[]): { } } -const TEXT_ANCHORS = compileTextAnchors(BUNDLED_AGENT_STATE_RULE_FILES) +const textAnchors = compiledFromActiveAgentStateRules(compileTextAnchors) /** The literal every blocked anchor needs, for the blocked layer's one-pass prefilter. */ -export const BLOCKED_ANCHOR_LITERALS: readonly string[] = TEXT_ANCHORS.blockedLiterals +export function blockedAnchorLiterals(): readonly string[] { + return textAnchors().blockedLiterals +} /** Every rule file's blocked anchor found in the blocked layer's live window. */ export function findBlockedAnchorSignals(window: string): BlockedTextSignal[] { - return TEXT_ANCHORS.blocked.flatMap((find) => { + return textAnchors().blocked.flatMap((find) => { const hit = find(window) return hit?.answer.state === 'blocked' ? [{ reason: hit.answer.reason, index: hit.index }] : [] }) @@ -119,7 +121,7 @@ export function findPromptAnchorIndexes(normalized: string): { } { let live: number | null = null let ready: number | null = null - for (const find of TEXT_ANCHORS.prompts) { + for (const find of textAnchors().prompts) { const hit = find(normalized) if (hit === null) { continue @@ -134,10 +136,10 @@ export function findPromptAnchorIndexes(normalized: string): { /** Whether any rule file's hold anchor shows: an agent is up but not yet taking input. */ export function showsHoldAnchor(normalized: string): boolean { - return TEXT_ANCHORS.holds.some((find) => find(normalized) !== null) + return textAnchors().holds.some((find) => find(normalized) !== null) } export function showsScreenProbeBanner(text: string): boolean { const normalized = text.toLowerCase() - return TEXT_ANCHORS.screenProbeBanners.some((banner) => normalized.includes(banner)) + return textAnchors().screenProbeBanners.some((banner) => normalized.includes(banner)) } diff --git a/src/main/runtime/agent-state-rules/agent-state-title-anchors.ts b/src/main/runtime/agent-state-rules/agent-state-title-anchors.ts index 67fc6063408..0f41f5e7d00 100644 --- a/src/main/runtime/agent-state-rules/agent-state-title-anchors.ts +++ b/src/main/runtime/agent-state-rules/agent-state-title-anchors.ts @@ -1,6 +1,6 @@ import { isOpenCodeNativeTitle } from '../../../shared/opencode-terminal-title' import { compileTextTest } from './agent-state-rule-matchers' -import { BUNDLED_AGENT_STATE_RULE_FILES } from './agent-state-rules-catalog' +import { compiledFromActiveAgentStateRules } from './active-agent-state-rules' import type { AgentStateRulesFile, NamedTitlePredicate, @@ -26,9 +26,9 @@ function compileTitleAnchors(files: readonly AgentStateRulesFile[]): TitleAnchor ) } -const TITLE_ANCHORS = compileTitleAnchors(BUNDLED_AGENT_STATE_RULE_FILES) +const titleAnchors = compiledFromActiveAgentStateRules(compileTitleAnchors) /** Whether any rule file's title anchor marks an idle-classified `title` as an agent's own rest title. */ export function showsIdleTitleAnchor(title: string): boolean { - return TITLE_ANCHORS.some((matches) => matches(title)) + return titleAnchors().some((matches) => matches(title)) } diff --git a/src/main/runtime/agent-state-rules/blocked-text-layer.ts b/src/main/runtime/agent-state-rules/blocked-text-layer.ts index 9e8d486bb35..9787f6966f9 100644 --- a/src/main/runtime/agent-state-rules/blocked-text-layer.ts +++ b/src/main/runtime/agent-state-rules/blocked-text-layer.ts @@ -1,8 +1,9 @@ import { escapeRegex } from '../../../shared/string-utils' import { findStartupDialogBlockedSignals } from '../startup-dialog-blocked-signals' import { startOfLastNonBlankLines } from '../terminal-wait-tail-window' +import { compiledFromActiveAgentStateRules } from './active-agent-state-rules' import { - BLOCKED_ANCHOR_LITERALS, + blockedAnchorLiterals, findBlockedAnchorSignals, showsHoldAnchor, type BlockedTextSignal @@ -17,10 +18,16 @@ import { const BUILT_IN_SENTINEL_RE = /update available|choose working directory to|codex just got an upgrade|available\s*·|esc\s*skip|enter\s*confirm\s*·|enter\/esc\s*(?:continue|confirm)|hooks need review|do you trust|trust this|trusted workspace|press enter to (?:confirm|continue|view|insert)|press t to trust|permission required|requires permission|allow once|allow always/ -/** Matches any line that may carry a blocker; a cheap negative test before the full scan. */ -export const TERMINAL_WAIT_BLOCKED_SENTINEL_RE = new RegExp( - [BUILT_IN_SENTINEL_RE.source, ...BLOCKED_ANCHOR_LITERALS.map(escapeRegex)].join('|'), - 'i' +/** + * Matches any line that may carry a blocker; a cheap negative test before the full scan. The same + * object until the active rules change, so a caller may key cached matches on it. + */ +export const terminalWaitBlockedSentinelRe = compiledFromActiveAgentStateRules( + () => + new RegExp( + [BUILT_IN_SENTINEL_RE.source, ...blockedAnchorLiterals().map(escapeRegex)].join('|'), + 'i' + ) ) // Why bounded: answered dialogs and quoted prompt wording (agents grep this file and its specs) stay in the @@ -32,7 +39,7 @@ export function findTerminalWaitBlockedSignal(fullTail: string): BlockedTextSign const windowStart = startOfLastNonBlankLines(fullTail, LIVE_PROMPT_TAIL_LINES) const normalized = windowStart === 0 ? fullTail : fullTail.slice(windowStart) // Why: one combined negative scan avoids a dozen searches when no prompt can match. - if (!TERMINAL_WAIT_BLOCKED_SENTINEL_RE.test(normalized)) { + if (!terminalWaitBlockedSentinelRe().test(normalized)) { return null } const signal = findBlockedSignalInLiveWindow(normalized) diff --git a/src/main/runtime/terminal-tail-sentinel-index.test.ts b/src/main/runtime/terminal-tail-sentinel-index.test.ts index b4c1c888012..273feb94311 100644 --- a/src/main/runtime/terminal-tail-sentinel-index.test.ts +++ b/src/main/runtime/terminal-tail-sentinel-index.test.ts @@ -8,22 +8,22 @@ import { tailMayContainBlockedSignal } from './terminal-tail-sentinel-index' import { computeTerminalTailWaitState } from './terminal-wait-tail-state' -import { TERMINAL_WAIT_BLOCKED_SENTINEL_RE } from './agent-state-rules/blocked-text-layer' +import { terminalWaitBlockedSentinelRe } from './agent-state-rules/blocked-text-layer' import type { RetainedTailRedrawCursor } from './terminal-tail-redraw-buffer' // The definition the incremental index must reproduce: does ANY retained line (or the // partial line) match the sentinel? Written out independently of the implementation. function referenceMayContainBlockedSignal(lines: string[], partialLine: string): boolean { for (const line of lines) { - if (TERMINAL_WAIT_BLOCKED_SENTINEL_RE.test(line)) { + if (terminalWaitBlockedSentinelRe().test(line)) { return true } } - return TERMINAL_WAIT_BLOCKED_SENTINEL_RE.test(partialLine) + return terminalWaitBlockedSentinelRe().test(partialLine) } function indexedMayContainBlockedSignal(lines: string[], partialLine: string): boolean { - return tailMayContainBlockedSignal(lines) || TERMINAL_WAIT_BLOCKED_SENTINEL_RE.test(partialLine) + return tailMayContainBlockedSignal(lines) || terminalWaitBlockedSentinelRe().test(partialLine) } type TailSim = { @@ -66,7 +66,7 @@ const ESC = String.fromCharCode(27) function referenceSentinelMatches(lines: readonly string[]): number[] { const matches: number[] = [] for (let index = 0; index < lines.length; index += 1) { - if (TERMINAL_WAIT_BLOCKED_SENTINEL_RE.test(lines[index]!)) { + if (terminalWaitBlockedSentinelRe().test(lines[index]!)) { matches.push(index) } } @@ -88,7 +88,7 @@ function assertIndexedPositionsAreExact(lines: readonly string[]): void { } function countSentinelTests(run: () => void): number { - const spy = vi.spyOn(TERMINAL_WAIT_BLOCKED_SENTINEL_RE, 'test') + const spy = vi.spyOn(terminalWaitBlockedSentinelRe(), 'test') try { run() return spy.mock.calls.length diff --git a/src/main/runtime/terminal-tail-sentinel-index.ts b/src/main/runtime/terminal-tail-sentinel-index.ts index 799bf759696..df9b358a847 100644 --- a/src/main/runtime/terminal-tail-sentinel-index.ts +++ b/src/main/runtime/terminal-tail-sentinel-index.ts @@ -1,4 +1,4 @@ -import { TERMINAL_WAIT_BLOCKED_SENTINEL_RE } from './agent-state-rules/blocked-text-layer' +import { terminalWaitBlockedSentinelRe } from './agent-state-rules/blocked-text-layer' /** * Which retained tail lines match the wait-blocked sentinel, memoized per @@ -9,17 +9,22 @@ import { TERMINAL_WAIT_BLOCKED_SENTINEL_RE } from './agent-state-rules/blocked-t * per streaming PTY) even though only ~20 lines were new. Keyed weakly by the * array so an entry dies with the tail it describes; the tail array is replaced * on every append and never mutated in place, so at most one entry per PTY - * stays live. + * stays live. Each entry keeps the sentinel it was scanned with: a rules hot reload swaps it, and + * matches found by the old one would hide a blocker only the new one knows. */ -const sentinelMatchesByTailLines = new WeakMap() +const sentinelMatchesByTailLines = new WeakMap< + readonly string[], + { sentinel: RegExp; matches: number[] } +>() function collectSentinelMatches( + sentinel: RegExp, lines: readonly string[], startIndex: number, into: number[] ): void { for (let index = startIndex; index < lines.length; index += 1) { - if (TERMINAL_WAIT_BLOCKED_SENTINEL_RE.test(lines[index]!)) { + if (sentinel.test(lines[index]!)) { into.push(index) } } @@ -40,14 +45,15 @@ export function getTerminalTailSentinelFullScanCount(): number { /** Ascending indices of sentinel-matching lines; full-scans an unseen array. */ export function getTerminalTailSentinelMatches(lines: readonly string[]): readonly number[] { + const sentinel = terminalWaitBlockedSentinelRe() const cached = sentinelMatchesByTailLines.get(lines) - if (cached) { - return cached + if (cached?.sentinel === sentinel) { + return cached.matches } sentinelFullScanCount += 1 const matches: number[] = [] - collectSentinelMatches(lines, 0, matches) - sentinelMatchesByTailLines.set(lines, matches) + collectSentinelMatches(sentinel, lines, 0, matches) + sentinelMatchesByTailLines.set(lines, { sentinel, matches }) return matches } @@ -89,6 +95,7 @@ export function carryTerminalTailSentinelMatches( } } } - collectSentinelMatches(nextLines, carriedCount, matches) - sentinelMatchesByTailLines.set(nextLines, matches) + const sentinel = terminalWaitBlockedSentinelRe() + collectSentinelMatches(sentinel, nextLines, carriedCount, matches) + sentinelMatchesByTailLines.set(nextLines, { sentinel, matches }) } diff --git a/src/main/runtime/terminal-wait-detection.ts b/src/main/runtime/terminal-wait-detection.ts index fb24d21c124..a2e6ee3f073 100644 --- a/src/main/runtime/terminal-wait-detection.ts +++ b/src/main/runtime/terminal-wait-detection.ts @@ -11,6 +11,7 @@ import { } from './agent-state-rules/agent-state-rules-engine' import { findPromptAnchorIndexes } from './agent-state-rules/agent-state-text-anchors' import { showsIdleTitleAnchor } from './agent-state-rules/agent-state-title-anchors' +import { compiledFromActiveAgentStateRules } from './agent-state-rules/active-agent-state-rules' import { findTerminalWaitBlockedSignal, isSettledAfter, @@ -29,12 +30,18 @@ function computeExplicitIdleStatusFromTitle(title: string): AgentStatus | null { } /** - * Pure in `title`, so it is memoized on the title string like the status classifier it - * wraps: the wait path re-asks for the same unchanged title on every poll tick and every - * repaint frame, and the marker scan below is a regex sweep each time (~72ns vs ~7ns). + * Pure in `title` for one rule set, so it is memoized on the title string like the status + * classifier it wraps: the wait path re-asks for the same unchanged title on every poll tick and + * every repaint frame, and the marker scan below is a regex sweep each time (~72ns vs ~7ns). Why a + * fresh memo per rule set: a rules reload can change which titles read as idle. */ -export const detectExplicitIdleStatusFromTitle: (title: string) => AgentStatus | null = +const explicitIdleTitleMemo = compiledFromActiveAgentStateRules(() => memoizeTitleClassification(computeExplicitIdleStatusFromTitle) +) + +export function detectExplicitIdleStatusFromTitle(title: string): AgentStatus | null { + return explicitIdleTitleMemo()(title) +} export function isKnownReadyPromptPreview(preview: string): boolean { const normalized = preview.toLowerCase() diff --git a/src/main/runtime/terminal-wait-tail-state.ts b/src/main/runtime/terminal-wait-tail-state.ts index 485451abf0c..28bb094b8e3 100644 --- a/src/main/runtime/terminal-wait-tail-state.ts +++ b/src/main/runtime/terminal-wait-tail-state.ts @@ -2,7 +2,7 @@ import type { RuntimeTerminalWaitBlockedReason } from '../../shared/runtime-type import { buildTailLines } from './terminal-tail-state' import { tailMayContainBlockedSignal } from './terminal-tail-sentinel-index' import { findActionableTerminalWaitBlockedSignal } from './terminal-wait-detection' -import { TERMINAL_WAIT_BLOCKED_SENTINEL_RE } from './agent-state-rules/blocked-text-layer' +import { terminalWaitBlockedSentinelRe } from './agent-state-rules/blocked-text-layer' export function buildTerminalWaitText( lines: string[], @@ -64,7 +64,7 @@ function inspectTerminalWaitTail( // Why the index: proving a signal is ABSENT can't early-exit, so a full re-test of the // 2000-line tail ran per scan; the index tests only the lines each append produced. mayContainBlockedSignal: - tailMayContainBlockedSignal(lines) || TERMINAL_WAIT_BLOCKED_SENTINEL_RE.test(partialLine) + tailMayContainBlockedSignal(lines) || terminalWaitBlockedSentinelRe().test(partialLine) } } diff --git a/src/main/startup/main-process-runtime-service.ts b/src/main/startup/main-process-runtime-service.ts index cfd0fa29402..9da3d7212ef 100644 --- a/src/main/startup/main-process-runtime-service.ts +++ b/src/main/startup/main-process-runtime-service.ts @@ -32,6 +32,8 @@ import { AgentStatusObservedPaneIdentities, recordObservedAgentStatusPaneIdentity } from '../runtime/agent-status-observed-pane-identity' +import { startAgentStateRulesLiveUpdates } from '../runtime/agent-state-rules/agent-state-rules-live-update' +import { recordDurableCrashBreadcrumb } from '../crash-reporting/durable-crash-breadcrumb' export function getDesktopWindowStatus(): RuntimeDesktopWindowStatus { const activation = state.desktopActivationGate @@ -162,6 +164,10 @@ export function initializeMainProcessRuntime(): OrcaRuntimeService { getScopeCatalog: () => sessionSearchScopeCatalogFromStore(store, LOCAL_EXECUTION_HOST_ID) }) app.once('will-quit', () => sessionSearch?.dispose()) + // Why here: this runs for the desktop and headless `orca serve`, and each evaluates its own panes. + startAgentStateRulesLiveUpdates(store, (rules) => + recordDurableCrashBreadcrumb('agent_state_rules_active', rules) + ) state.runtime = runtime agentHookServer.subscribeEnrichedStatus((enriched) => recordObservedAgentStatusPaneIdentity(observedPaneIdentities, enriched.paneKey, runtime) diff --git a/src/main/updater-agent-state-rules-release.test.ts b/src/main/updater-agent-state-rules-release.test.ts new file mode 100644 index 00000000000..880f46c1087 --- /dev/null +++ b/src/main/updater-agent-state-rules-release.test.ts @@ -0,0 +1,119 @@ +// Regression: agent state rules releases live in the app's own release feed, and must never change +// what the updater resolves on any path: the stable Latest feed, the RC prerelease feed, or the +// build picker. +import { beforeEach, describe, expect, it, vi } from 'vitest' +import { getVersionChannel } from '../shared/release-channel' +import { installNetRequestFetchAdapter } from './updater-net-request.fixture' + +const { netFetchMock, netRequestMock } = vi.hoisted(() => ({ + netFetchMock: vi.fn(), + netRequestMock: vi.fn() +})) + +vi.mock('electron', () => ({ net: { fetch: netFetchMock, request: netRequestMock } })) +vi.mock('./updater-release-api-token', () => ({ + resolveReleaseApiToken: async () => null, + rejectReleaseApiToken: () => {} +})) +vi.mock('./git/gh-rate-limit-breaker', () => ({ + getGhRateLimitBlockedUntilMs: () => null, + recordGhPrimaryRateLimit: () => {} +})) + +const { fetchNewerReleaseTag } = await import('./updater-prerelease-feed') +const { listReleaseBuilds, resolveTargetBuild } = await import('./updater-release-builds') + +const RULES_TAGS = ['agent-state-rules-engine-1-next', 'agent-state-rules-engine-1-stable'] +const APP_TAGS = ['v1.4.3-rc.1', 'v1.4.2', 'v1.4.2-rc.4', 'v1.4.1'] + +function atomFeed(tags: readonly string[]): string { + const entries = tags + .map( + (tag) => + `${tag}` + ) + .join('') + return `${entries}` +} + +function serveFeed(tags: readonly string[]): void { + netFetchMock.mockImplementation((url: string) => { + if (url === 'https://github.com/stablyai/orca/releases.atom') { + return Promise.resolve({ ok: true, text: () => Promise.resolve(atomFeed(tags)) }) + } + const manifest = url.match(/\/releases\/download\/v([^/]+)\/latest(?:-[a-z]+)?\.yml$/) + if (manifest) { + const version = manifest[1] + return Promise.resolve({ + ok: true, + status: 200, + text: () => + Promise.resolve( + `version: ${version}\npath: Orca-${version}.zip\nfiles:\n - url: Orca-${version}.zip\n` + ) + }) + } + return Promise.resolve({ ok: true, status: 200, text: () => Promise.resolve('') }) + }) +} + +function apiRelease(tag: string, assets: readonly string[]) { + return { + tag_name: tag, + draft: false, + published_at: '2026-10-01T00:00:00Z', + html_url: `https://github.com/stablyai/orca/releases/tag/${tag}`, + assets: assets.map((name) => ({ name })) + } +} + +const APP_ASSETS = [ + 'latest-mac.yml', + 'orca-macos-arm64.dmg', + 'latest.yml', + 'orca-windows-setup.exe' +] + +beforeEach(() => { + netFetchMock.mockReset() + netRequestMock.mockReset() + installNetRequestFetchAdapter(netRequestMock, netFetchMock) +}) + +describe('agent state rules releases and the app updater', () => { + it('leave the RC prerelease feed resolving to the same app tag', async () => { + serveFeed(APP_TAGS) + const withoutRules = await fetchNewerReleaseTag('1.4.2-rc.4') + serveFeed([...RULES_TAGS, ...APP_TAGS]) + expect(await fetchNewerReleaseTag('1.4.2-rc.4')).toBe(withoutRules) + expect(withoutRules).toBe('v1.4.3-rc.1') + }) + + it('leave the stable check resolving to the same app tag', async () => { + serveFeed([...RULES_TAGS, ...APP_TAGS]) + expect(await fetchNewerReleaseTag('1.4.1', { includePrerelease: false })).toBe('v1.4.2') + }) + + it.each(['stable', 'rc'] as const)('never appear in the %s build picker', async (channel) => { + netFetchMock.mockResolvedValue({ + ok: true, + status: 200, + headers: new Headers(), + json: () => + Promise.resolve([ + ...RULES_TAGS.map((tag) => apiRelease(tag, ['agent-state-rules.json'])), + ...APP_TAGS.map((tag) => apiRelease(tag, APP_ASSETS)) + ]) + }) + const builds = await listReleaseBuilds(channel, 'darwin') + expect(builds.map((build) => build.tag).filter((tag) => RULES_TAGS.includes(tag))).toEqual([]) + expect(builds.length).toBeGreaterThan(0) + }) + + it('cannot be pinned as a build or read as any app channel', () => { + for (const tag of RULES_TAGS) { + expect(getVersionChannel(tag)).toBeNull() + expect(() => resolveTargetBuild('stable', tag)).toThrow('not a valid release tag') + } + }) +}) diff --git a/src/shared/global-settings-types.ts b/src/shared/global-settings-types.ts index c1a9b7d2df1..2a3fac5d545 100644 --- a/src/shared/global-settings-types.ts +++ b/src/shared/global-settings-types.ts @@ -420,6 +420,12 @@ export type GlobalSettings = { agentYoloDefaultsMigrated?: boolean /** Why: disabling must persist so startup doesn't reinstall global agent hook entries the user just removed. */ agentStatusHooksEnabled: boolean + /** A local agent-state-rules.json that replaces downloaded and bundled rules, for testing a rule + * change. */ + agentStateRulesPath?: string | null + /** Off: rules are never downloaded and a cached download is ignored, so the bundled rules (or a + * local override) apply. Absent reads as on. */ + agentStateRulesLiveUpdates?: boolean /** Pre-trust the worktree or folder Orca starts an agent in, so its "trust this folder?" prompt is skipped. Defaults on. */ agentWorkspaceTrustEnabled: boolean /** Why: Codex's shared server runs every tab's hooks with the first tab's env; off opts new terminals back into it. Absent reads as on. */