Files
orca/config/scripts/agent-state-rules-bundle.mjs
T
Jinwoo Hong 564f4d021a 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.
2026-10-02 14:59:24 -04:00

175 lines
6.2 KiB
JavaScript

// 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 <out> [--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 <out> [--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
}
}