mirror of
https://github.com/stablyai/orca.git
synced 2026-09-27 16:02:35 +00:00
* fix(runtime): recover stale session owners and await retirement * fix(runtime): preserve session hydration and smoke compatibility * test(runtime): cover empty and unindexed session owners * feat(cli): make terminal close the canonical workspace teardown * fix(preload): align ssh termination result type * test(runtime): assert folder hydration owner * fix(runtime): fence legacy terminal stop by worktree host * fix(preload): reconcile ssh result import with main * fix(runtime): keep same-id sibling hosts out of workspace close The stale-owner fallback in the session controller re-routed any worktree whose catalog partition had no tabs to whichever other partition held tabs. Only `runtime:` environment ids rotate across relay restarts; `repoId::path` legitimately repeats across hosts, so an SSH workspace close could retire the local copy's tabs and resume records, or flip owners mid-close and strand the SSH PTY. Restrict the fallback to runtime hosts, and pin the session partition once per workspace close so record clearing targets the partition that owned the tabs. * test(runtime): give the cross-host close fixture a real resume record * fix(preload): take main's ssh-bridge import order so the merge stays duplicate-free
125 lines
4.4 KiB
TypeScript
125 lines
4.4 KiB
TypeScript
import { specPaths, type CommandSpec } from './command-spec'
|
|
import { levenshtein } from '../shared/edit-distance'
|
|
|
|
export { levenshtein } from '../shared/edit-distance'
|
|
|
|
// Why: rank the live registry so typo recovery cannot drift from accepted paths.
|
|
|
|
const SUGGESTION_THRESHOLD = 3
|
|
const MAX_SUGGESTIONS = 3
|
|
|
|
// Why: a close typo of a destructive verb (`remov`→`remove`) still signals that
|
|
// intent, but `move` (distance 2 from `remove`) does not — keep this at 1 so
|
|
// genuine recovery works while unrelated verbs stay locked out. #6303
|
|
const DESTRUCTIVE_INTENT_THRESHOLD = 1
|
|
|
|
function finalToken(path: string[]): string {
|
|
return path.at(-1) ?? ''
|
|
}
|
|
|
|
// Why: destructiveness is declared on the spec (single source of truth); the
|
|
// intent verbs are the final tokens of every destructive path/alias so the guard
|
|
// tracks the registry instead of a hand-maintained list.
|
|
function destructiveVerbs(specs: CommandSpec[]): Set<string> {
|
|
const verbs = new Set<string>()
|
|
for (const spec of specs) {
|
|
if (spec.destructive) {
|
|
for (const path of specPaths(spec)) {
|
|
verbs.add(finalToken(path))
|
|
}
|
|
}
|
|
}
|
|
return verbs
|
|
}
|
|
|
|
// Why: deletion is irreversible and suggestions flow into agents' recovery
|
|
// channel (--json nextSteps), so only unlock destructive candidates when the
|
|
// input token is itself a near-miss of a destructive verb. #6303
|
|
function intendsDestruction(inputToken: string, verbs: Set<string>): boolean {
|
|
for (const verb of verbs) {
|
|
if (levenshtein(inputToken, verb) <= DESTRUCTIVE_INTENT_THRESHOLD) {
|
|
return true
|
|
}
|
|
}
|
|
return false
|
|
}
|
|
|
|
export type CommandErrorData = {
|
|
suggestions: string[]
|
|
nextSteps: string[]
|
|
}
|
|
|
|
// Why: one bounded near-match ranking keeps command and flag recovery consistent.
|
|
function rankByDistance(scored: { label: string; distance: number }[]): string[] {
|
|
return scored
|
|
.filter((entry) => entry.distance > 0 && entry.distance <= SUGGESTION_THRESHOLD)
|
|
.sort((a, b) => a.distance - b.distance || a.label.localeCompare(b.label))
|
|
.slice(0, MAX_SUGGESTIONS)
|
|
.map((entry) => entry.label)
|
|
}
|
|
|
|
// Why: same-depth matching avoids suggesting parent groups or unrelated commands.
|
|
export function suggestCommands(specs: CommandSpec[], commandPath: string[]): string[] {
|
|
const input = commandPath.join(' ')
|
|
// Why: only surface destructive commands when the user actually reached for one;
|
|
// otherwise a benign typo could recover into an irreversible action. #6303
|
|
const allowDestructive = intendsDestruction(finalToken(commandPath), destructiveVerbs(specs))
|
|
const seen = new Set<string>()
|
|
const scored: { label: string; distance: number }[] = []
|
|
for (const spec of specs) {
|
|
if (spec.hidden) {
|
|
continue
|
|
}
|
|
if (spec.destructive && !allowDestructive) {
|
|
continue
|
|
}
|
|
const candidates = specPaths(spec).map((path) =>
|
|
commandPath.length === 1 ? path.slice(0, 1) : path
|
|
)
|
|
for (const candidate of candidates) {
|
|
if (candidate.length !== commandPath.length) {
|
|
continue
|
|
}
|
|
const joined = candidate.join(' ')
|
|
if (seen.has(joined)) {
|
|
continue
|
|
}
|
|
seen.add(joined)
|
|
scored.push({ label: joined, distance: levenshtein(input, joined) })
|
|
}
|
|
}
|
|
return rankByDistance(scored)
|
|
}
|
|
|
|
export function unknownCommandData(specs: CommandSpec[], commandPath: string[]): CommandErrorData {
|
|
const suggestions = suggestCommands(specs, commandPath)
|
|
const nextSteps = suggestions.length
|
|
? [`Did you mean: ${suggestions.map((path) => `orca ${path}`).join(', ')}`]
|
|
: []
|
|
return { suggestions, nextSteps }
|
|
}
|
|
|
|
export type FlagErrorData = {
|
|
validFlags: string[]
|
|
suggestions: string[]
|
|
nextSteps: string[]
|
|
}
|
|
|
|
function suggestFlags(flag: string, validFlags: string[]): string[] {
|
|
return rankByDistance(
|
|
validFlags.map((candidate) => ({ label: candidate, distance: levenshtein(flag, candidate) }))
|
|
)
|
|
}
|
|
|
|
// Why: include the accepted set so agents can recover without another help call.
|
|
export function unknownFlagData(flag: string, validFlags: string[]): FlagErrorData {
|
|
const sortedValid = [...validFlags].sort((a, b) => a.localeCompare(b))
|
|
const suggestions = suggestFlags(flag, sortedValid)
|
|
const nextSteps: string[] = []
|
|
if (suggestions.length > 0) {
|
|
nextSteps.push(`Did you mean: ${suggestions.map((name) => `--${name}`).join(', ')}`)
|
|
}
|
|
nextSteps.push(`Valid flags: ${sortedValid.map((name) => `--${name}`).join(', ')}`)
|
|
return { validFlags: sortedValid, suggestions, nextSteps }
|
|
}
|