mirror of
https://github.com/stablyai/orca.git
synced 2026-11-03 10:37:35 +00:00
* Add Orca's own bundled-skill planner and installer (not wired in yet) One planner decides what installing Orca's bundled skills does to each target folder; execute re-plans under per-path locks and runs only a plan whose digest still matches; a shared summary derives badge, wording and exit code. A copy counts as Orca's untouched one only when its SKILL.md bytes match a version some build shipped (resources/skills/released-digests.json, generated from git history and checked add-only). Nothing calls this yet; the npx path is unchanged. * Report left-alone folders as warnings and keep backups in the home A leave is no longer a failure: the outcome line says what happened plus "N left alone: <reason>", and the exit code is 1 only for a failed or changed op. Backups for every scope go to <home>/.agents/.orca-skill-backups on the executing host, so a project Replace never writes into the repo; moving there falls back to copy, verify, then remove across filesystems. * Update Orca's skills with Orca's own installer instead of npx skills The app's Update skills dialog, badge, nudge and status pills now read the bundled-skill plan and summary, and Update executes the reviewed plan's digest. `orca skills install|update` runs the same planner in-process with --dry-run, --force, --json and per-summary exit codes. Removes both npx spawn sites, the freshness inventory modules and the npx round-trip CI. * Update only where the skill already is; one provider-root rule for app and CLI installedOnly now drops every location that holds nothing (keeping the shared slot only when an existing agent link points at it), so Update never adds a skill for another agent. The app and CLI resolve the Claude skills folder through the same rule in localBundledSkillInput; a test asserts both plan identically. * Compact Update skills dialog: one row per skill, locations named by agent summarizeBundledSkillPlan now returns each skill's locations, labelled by agent (or "Shared folder") with a ~-collapsed path; the dialog shows one row per skill that needs something, collapses up-to-date skills into one line, and lists every location behind "Show all locations". The result reads "Updated N skills · M left alone". Under installedOnly a Replace with no shared copy replaces in place instead of adding one. * fix(skills): keep location paths readable by putting the state under the path * fix(skills): hold the last plan in state, not a ref mutated during render Split the update dialog's rows, headline and footer into components, move the Update label next to the other plan copy, and use z.strictObject for the request schemas. * Escape non-ASCII characters in bundled-skill regex and wording The orcad bundle rejects a literal non-ASCII character in a regex. * fix(cli): escape non-ASCII apostrophes in the skills install help text * test(orcad): check the bundled CLI plans a skill install without npx Drop the deleted roundtrip workflow's group from the closed-PR cancellation fixture. * Make bundled-skill test fixtures link folders the way the product does on Windows Directory links in fixtures are junctions on Windows, which need no Developer Mode; the one file-link fixture is skipped there. * Resolve legacy skill agent keys canonically * Reject stale skill replacements when auxiliary content changes * Keep ordinary bundled skill classification shallow * Leave unreadable skill replacements alone without aborting plans * Pass force-all through shallow planning and describe unreadable copies * Clarify skill update statuses and folder details * Keep selected skill replacements in place with one status * Report completed skill replacements with their backup confirmation * Clarify available skill updates and skipped copies * Keep skill update results in preview order * Show each skill skip reason once * Count skipped skills instead of skipped folders * Make installer input and link tests work on Windows * Bound bundled skill classification reads and parse names linearly * Keep workspace skill roots inside the resolved workspace * Compare workspace skill roots with native path semantics * Restrict bundled skill backups to their owner on POSIX * Keep recovered skills needing attention until they are updated * Count completed skill updates in the status tooltip * Sync the runtime localization catalog
371 lines
13 KiB
TypeScript
371 lines
13 KiB
TypeScript
import { spawn } from 'node:child_process'
|
|
import { isBuiltin } from 'node:module'
|
|
import { join } from 'node:path'
|
|
import type { Plugin, Rollup } from 'vite'
|
|
|
|
type NormalizedInputOptions = Rollup.NormalizedInputOptions
|
|
type NormalizedOutputOptions = Rollup.NormalizedOutputOptions
|
|
type OutputBundle = Rollup.OutputBundle
|
|
type OutputChunk = Rollup.OutputChunk
|
|
|
|
// Why: v1.4.129-rc.1 shipped a dead terminal daemon because a shared main
|
|
// chunk gained `require("electron")` (an import edge added in #7642), and the
|
|
// daemon is forked as a plain-Node process where electron cannot be required.
|
|
// Nothing in CI executes the built daemon-entry under plain Node, so the leak
|
|
// stayed invisible until an adopted old daemon died. This guard fails the
|
|
// build when any chunk reachable from a plain-Node fork entry requires
|
|
// electron, and smoke-loads daemon-entry under plain Node to prove its module
|
|
// graph still resolves.
|
|
|
|
// The CLI loads these paths after electron-vite replaces out/main.
|
|
export const CLI_MAIN_ENTRY_NAMES = [
|
|
'agent-hooks/managed-agent-hook-controls',
|
|
'gitlab/project-ref-parser',
|
|
'orca-profiles/profile-index-store',
|
|
'claude-accounts/keychain',
|
|
'skills/bundled-skill-install/cli-entry',
|
|
'macos-tree-clone',
|
|
...[
|
|
'access',
|
|
'active-location',
|
|
'storage-classification',
|
|
'offline-settings',
|
|
'backup-path',
|
|
'database-recovery',
|
|
'domain-reader',
|
|
'recovery-command'
|
|
].map((module) => `persistence/profile-state/profile-state-${module}`),
|
|
'persistence/profile-state/legacy-json/profile-state-export-path',
|
|
'persistence/profile-state/legacy-json/profile-state-recovery',
|
|
'startup/http1-compatibility-marker'
|
|
] as const
|
|
|
|
// Plain-Node processes and CLI modules cannot load Electron's API.
|
|
const PLAIN_NODE_ENTRY_NAMES = [
|
|
'daemon-entry',
|
|
'parcel-watcher-process-entry',
|
|
'session-scanner-service-entry',
|
|
'computer-sidecar',
|
|
'wsl-transcript-fs-process-entry',
|
|
'orcad/orcad-local-serve-selection-entry',
|
|
...CLI_MAIN_ENTRY_NAMES
|
|
] as const
|
|
|
|
// Entries executed as worker threads of the main process. Electron's module is
|
|
// not registered on worker threads, so require("electron") throws
|
|
// "Cannot find module 'electron'" there too (verified on Electron 43) and kills
|
|
// the worker at startup. These carry hand-written "must stay electron-free"
|
|
// comments, which is convention, not enforcement — and the port-scan worker in
|
|
// particular sits one import away from a client module that deliberately does
|
|
// require electron.
|
|
const WORKER_THREAD_ENTRY_NAMES = [
|
|
'stt-worker',
|
|
'warp-theme-parser-worker',
|
|
'foreign-sqlite-reader-entry',
|
|
'main-thread-hang-watchdog-entry',
|
|
'port-scan-command-worker-entry',
|
|
'worker-thread-child-process-entry',
|
|
'usage-scan-worker-entry',
|
|
'workspace-space-scan-worker-entry',
|
|
'claude-profile-setup-worker-entry',
|
|
'profile-state-backup-worker-entry',
|
|
'profile-state-writer-worker-entry'
|
|
] as const
|
|
|
|
export const GUARDED_ENTRY_NAMES = [
|
|
...PLAIN_NODE_ENTRY_NAMES,
|
|
...WORKER_THREAD_ENTRY_NAMES
|
|
] as const
|
|
|
|
type EntryRuntime = 'plain-Node process' | 'worker thread'
|
|
|
|
// Subpaths (electron/main) are as unloadable as the bare module under plain Node.
|
|
const ELECTRON_REQUIRE_RE = /require\(\s*["'`]electron(?:\/[^"'`]+)?["'`]\s*\)/
|
|
|
|
// Why: writeBundle skips any name missing from the bundle, so a renamed or
|
|
// removed rollup input would silently drop that entry from the guard and let the
|
|
// regression back in. Pin the lists to the input keys at build start instead.
|
|
function assertEntryNamesAreRollupInputs(input: NormalizedInputOptions['input']): void {
|
|
if (typeof input === 'string' || Array.isArray(input)) {
|
|
return
|
|
}
|
|
const inputNames = new Set(Object.keys(input))
|
|
const missing = GUARDED_ENTRY_NAMES.filter((name) => !inputNames.has(name))
|
|
if (missing.length > 0) {
|
|
throw new Error(
|
|
`[plain-node-entry-guard] guarded ${missing.map((name) => `"${name}"`).join(', ')} ` +
|
|
`${missing.length === 1 ? 'is not a rollup input' : 'are not rollup inputs'} anymore. ` +
|
|
`Update PLAIN_NODE_ENTRY_NAMES/WORKER_THREAD_ENTRY_NAMES in plain-node-entry-guard.ts to ` +
|
|
`the current entry names — a stale name silently stops guarding that entry.`
|
|
)
|
|
}
|
|
}
|
|
|
|
function collectReachableChunks(
|
|
entry: OutputChunk,
|
|
byFileName: Map<string, OutputChunk>
|
|
): OutputChunk[] {
|
|
const seen = new Set<string>()
|
|
const reachable: OutputChunk[] = []
|
|
const stack = [entry.fileName]
|
|
while (stack.length > 0) {
|
|
const fileName = stack.pop() as string
|
|
if (seen.has(fileName)) {
|
|
continue
|
|
}
|
|
seen.add(fileName)
|
|
const chunk = byFileName.get(fileName)
|
|
if (!chunk) {
|
|
continue
|
|
}
|
|
reachable.push(chunk)
|
|
for (const imported of [...chunk.imports, ...chunk.dynamicImports]) {
|
|
stack.push(imported)
|
|
}
|
|
}
|
|
return reachable
|
|
}
|
|
|
|
function assertNoElectronRequire(
|
|
entryName: string,
|
|
entry: OutputChunk,
|
|
byFileName: Map<string, OutputChunk>,
|
|
electronFreeChunkCode: Map<OutputChunk, string>,
|
|
runtime: EntryRuntime = 'plain-Node process'
|
|
): void {
|
|
for (const chunk of collectReachableChunks(entry, byFileName)) {
|
|
const code = chunk.code
|
|
if (electronFreeChunkCode.get(chunk) === code) {
|
|
continue
|
|
}
|
|
if (ELECTRON_REQUIRE_RE.test(code)) {
|
|
throw new Error(
|
|
`[plain-node-entry-guard] "${entryName}" reaches chunk "${chunk.fileName}" that ` +
|
|
`requires electron. "${entryName}" runs as a ${runtime}, where ` +
|
|
`require("electron") throws MODULE_NOT_FOUND and kills it at startup (the ` +
|
|
`v1.4.129-rc.1 daemon outage). Keep electron imports out of its module graph.`
|
|
)
|
|
}
|
|
electronFreeChunkCode.set(chunk, code)
|
|
}
|
|
}
|
|
|
|
// Why: on packaged Windows the daemon runs from a relocated daemon-host copy that mirrors only
|
|
// these packages (src/main/daemon/daemon-host-manifest.ts). Any other bare require in its graph
|
|
// resolves in the install dir but throws MODULE_NOT_FOUND in the copy, and the desktop silently
|
|
// falls back to an unrelocated daemon that the next update kills.
|
|
const DAEMON_HOST_MIRRORED_PACKAGES = [
|
|
'@orca/process-host',
|
|
'node-pty',
|
|
'@vscode/windows-process-tree'
|
|
]
|
|
const BARE_REQUIRE_RE = /require\(\s*["'`]([^"'`./][^"'`]*)["'`]\s*\)/g
|
|
|
|
function packageOfSpecifier(specifier: string): string {
|
|
const parts = specifier.split('/')
|
|
return specifier.startsWith('@') ? parts.slice(0, 2).join('/') : parts[0]!
|
|
}
|
|
|
|
function assertDaemonExternalsAreMirrored(
|
|
entry: OutputChunk,
|
|
byFileName: Map<string, OutputChunk>
|
|
): void {
|
|
for (const chunk of collectReachableChunks(entry, byFileName)) {
|
|
for (const match of chunk.code.matchAll(BARE_REQUIRE_RE)) {
|
|
const specifier = match[1]!
|
|
if (specifier.startsWith('node:') || isBuiltin(specifier)) {
|
|
continue
|
|
}
|
|
if (!DAEMON_HOST_MIRRORED_PACKAGES.includes(packageOfSpecifier(specifier))) {
|
|
throw new Error(
|
|
`[plain-node-entry-guard] "daemon-entry" reaches chunk "${chunk.fileName}" that ` +
|
|
`requires "${specifier}", which the relocated Windows daemon-host does not mirror ` +
|
|
`(daemon-host-manifest.ts). Keep it out of the daemon graph, or mirror it there.`
|
|
)
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
// Owned by the argv parser in src/main/daemon/daemon-entry.ts — keep in sync.
|
|
const DAEMON_USAGE_PREFIX = 'Usage: daemon-entry'
|
|
|
|
export type SmokeTimings = {
|
|
timeoutMs: number
|
|
// daemon-entry traps SIGTERM and awaits a native shutdown, so the deadline
|
|
// needs an uncatchable follow-up to stay a deadline.
|
|
killGraceMs: number
|
|
}
|
|
|
|
const DEFAULT_SMOKE_TIMINGS: SmokeTimings = { timeoutMs: 15_000, killGraceMs: 2_000 }
|
|
|
|
// Bound the wait for stderr to flush after exit; a grandchild inheriting stdio
|
|
// can hold the pipes open long after the child is gone.
|
|
const SMOKE_STDERR_DRAIN_MS = 250
|
|
|
|
type SmokeResult = {
|
|
status: number | null
|
|
signal: NodeJS.Signals | null
|
|
stderr: string
|
|
error?: Error
|
|
timedOut: boolean
|
|
}
|
|
|
|
// Why not spawnSync({ timeout }): its timeout only sends killSignal and then
|
|
// keeps blocking until the child exits, so a child that traps SIGTERM hangs the
|
|
// build forever. Escalate to SIGKILL instead.
|
|
function runDaemonEntry(entryPath: string, timings: SmokeTimings): Promise<SmokeResult> {
|
|
return new Promise((resolve) => {
|
|
const child = spawn(process.execPath, [entryPath], { stdio: ['ignore', 'ignore', 'pipe'] })
|
|
let stderr = ''
|
|
let timedOut = false
|
|
let settled = false
|
|
let forceKillTimer: NodeJS.Timeout | undefined
|
|
let drainTimer: NodeJS.Timeout | undefined
|
|
|
|
child.stderr.setEncoding('utf8')
|
|
child.stderr.on('data', (chunk: string) => {
|
|
stderr += chunk
|
|
})
|
|
|
|
const deadlineTimer = setTimeout(() => {
|
|
timedOut = true
|
|
child.kill('SIGTERM')
|
|
forceKillTimer = setTimeout(() => child.kill('SIGKILL'), timings.killGraceMs)
|
|
}, timings.timeoutMs)
|
|
|
|
const finish = (status: number | null, signal: NodeJS.Signals | null, error?: Error): void => {
|
|
if (settled) {
|
|
return
|
|
}
|
|
settled = true
|
|
clearTimeout(deadlineTimer)
|
|
clearTimeout(forceKillTimer)
|
|
clearTimeout(drainTimer)
|
|
resolve({ status, signal, stderr, error, timedOut })
|
|
}
|
|
|
|
child.on('error', (error: Error) => finish(null, null, error))
|
|
// 'close' gives the full stderr; 'exit' is the fallback so a held-open pipe
|
|
// cannot outlast the process itself.
|
|
child.on('close', (status, signal) => finish(status, signal))
|
|
child.on('exit', (status, signal) => {
|
|
drainTimer = setTimeout(() => finish(status, signal), SMOKE_STDERR_DRAIN_MS)
|
|
})
|
|
})
|
|
}
|
|
|
|
// Why: proves the whole daemon-entry graph resolves under plain Node (no
|
|
// unresolved requires). require("electron") does not throw in a dev tree with
|
|
// node_modules present, so the static scan above — not this smoke — is the
|
|
// electron regression guard; this only catches gross load failures.
|
|
async function smokeLoadDaemonEntry(outputDir: string, timings: SmokeTimings): Promise<void> {
|
|
const entryPath = join(outputDir, 'daemon-entry.js')
|
|
const result = await runDaemonEntry(entryPath, timings)
|
|
if (result.error) {
|
|
throw new Error(
|
|
`[plain-node-entry-guard] could not smoke-load daemon-entry.js under plain Node: ` +
|
|
`${result.error.message}`
|
|
)
|
|
}
|
|
// Almost always means the daemon stopped rejecting an empty argv and started
|
|
// listening instead.
|
|
if (result.timedOut) {
|
|
throw new Error(
|
|
`[plain-node-entry-guard] daemon-entry.js did not exit within ${timings.timeoutMs}ms on an ` +
|
|
`empty argv under plain Node, so the smoke killed it.`
|
|
)
|
|
}
|
|
if (result.signal) {
|
|
throw new Error(
|
|
`[plain-node-entry-guard] daemon-entry.js was killed by ${result.signal} under plain Node.`
|
|
)
|
|
}
|
|
const stderr = result.stderr
|
|
if (/Cannot find module|MODULE_NOT_FOUND/.test(stderr)) {
|
|
throw new Error(
|
|
`[plain-node-entry-guard] daemon-entry.js failed to load under plain Node:\n${stderr}`
|
|
)
|
|
}
|
|
if (result.status === 0 || !stderr.includes(DAEMON_USAGE_PREFIX)) {
|
|
throw new Error(
|
|
`[plain-node-entry-guard] daemon-entry.js did not reject an empty argv under plain Node ` +
|
|
`(expected a non-zero exit and the "${DAEMON_USAGE_PREFIX}" error, got exit ` +
|
|
`${result.status}). stderr:\n${stderr}`
|
|
)
|
|
}
|
|
}
|
|
|
|
export function createPlainNodeEntryGuardPlugin(
|
|
smokeTimings: SmokeTimings = DEFAULT_SMOKE_TIMINGS
|
|
): Plugin {
|
|
let daemonOutputDir: string | undefined
|
|
|
|
return {
|
|
name: 'orca-plain-node-entry-guard',
|
|
buildStart(options: NormalizedInputOptions) {
|
|
assertEntryNamesAreRollupInputs(options.input)
|
|
},
|
|
writeBundle(options: NormalizedOutputOptions, bundle: OutputBundle) {
|
|
// Why: skip in `electron-vite dev` watch mode — the smoke would respawn on
|
|
// every rebuild, and the guard only needs to gate produced builds.
|
|
if (this.meta.watchMode) {
|
|
return
|
|
}
|
|
const chunks = Object.values(bundle).filter(
|
|
(item): item is OutputChunk => item.type === 'chunk'
|
|
)
|
|
const byFileName = new Map(chunks.map((chunk) => [chunk.fileName, chunk]))
|
|
const entryByName = new Map<string, OutputChunk>()
|
|
for (const chunk of chunks) {
|
|
if (chunk.isEntry && chunk.name) {
|
|
entryByName.set(chunk.name, chunk)
|
|
}
|
|
}
|
|
|
|
const electronFreeChunkCode = new Map<OutputChunk, string>()
|
|
for (const entryName of PLAIN_NODE_ENTRY_NAMES) {
|
|
const entry = entryByName.get(entryName)
|
|
if (entry) {
|
|
assertNoElectronRequire(
|
|
entryName,
|
|
entry,
|
|
byFileName,
|
|
electronFreeChunkCode,
|
|
'plain-Node process'
|
|
)
|
|
}
|
|
}
|
|
|
|
for (const entryName of WORKER_THREAD_ENTRY_NAMES) {
|
|
const entry = entryByName.get(entryName)
|
|
if (entry) {
|
|
assertNoElectronRequire(
|
|
entryName,
|
|
entry,
|
|
byFileName,
|
|
electronFreeChunkCode,
|
|
'worker thread'
|
|
)
|
|
}
|
|
}
|
|
|
|
const daemonEntry = entryByName.get('daemon-entry')
|
|
if (daemonEntry) {
|
|
assertDaemonExternalsAreMirrored(daemonEntry, byFileName)
|
|
}
|
|
|
|
if (entryByName.has('daemon-entry') && options.dir) {
|
|
daemonOutputDir = options.dir
|
|
}
|
|
},
|
|
async closeBundle() {
|
|
if (daemonOutputDir) {
|
|
const outputDir = daemonOutputDir
|
|
daemonOutputDir = undefined
|
|
await smokeLoadDaemonEntry(outputDir, smokeTimings)
|
|
}
|
|
}
|
|
}
|
|
}
|