mirror of
https://github.com/stablyai/orca.git
synced 2026-10-23 08:03:39 +00:00
Merge origin/main into ssh restore-required fix
This commit is contained in:
@@ -270,7 +270,10 @@ jobs:
|
||||
src/main/providers/local-pty-shell-ready-zsh-zdotdir-normalization.test.ts \
|
||||
src/main/providers/__tests__/shell-ready-framework-example.test.ts \
|
||||
src/main/pty/omp-shell-wrapper.node-pty.test.ts \
|
||||
src/main/terminal-history-fish-session.node-pty.test.ts \
|
||||
src/main/zsh-scoped-histfile.live-shell.test.ts \
|
||||
src/renderer/src/components/terminal-pane/fish-color-scheme-child-stdin.node-pty.test.ts \
|
||||
src/shared/startup-shell-portability.live-shell.test.ts \
|
||||
src/shared/posix-command-path-lookup.test.ts
|
||||
|
||||
test:
|
||||
@@ -309,7 +312,10 @@ jobs:
|
||||
--exclude=src/main/providers/local-pty-shell-ready-zsh-zdotdir-normalization.test.ts \
|
||||
--exclude=src/main/providers/__tests__/shell-ready-framework-example.test.ts \
|
||||
--exclude=src/main/pty/omp-shell-wrapper.node-pty.test.ts \
|
||||
--exclude=src/main/terminal-history-fish-session.node-pty.test.ts \
|
||||
--exclude=src/main/zsh-scoped-histfile.live-shell.test.ts \
|
||||
--exclude=src/renderer/src/components/terminal-pane/fish-color-scheme-child-stdin.node-pty.test.ts \
|
||||
--exclude=src/shared/startup-shell-portability.live-shell.test.ts \
|
||||
--exclude=src/shared/posix-command-path-lookup.test.ts \
|
||||
--exclude=tests/e2e/cross-version-wire/** \
|
||||
--shard=${{ matrix.shard }}/${{ matrix.shard_total }}
|
||||
|
||||
@@ -65,7 +65,6 @@ inline src/main/runtime/orca-runtime-files.ts
|
||||
inline src/main/runtime/orca-runtime-git.ts
|
||||
inline src/main/runtime/orca-runtime.test.ts
|
||||
inline src/main/runtime/orca-runtime.ts
|
||||
inline src/main/runtime/orchestration/db.ts
|
||||
inline src/main/runtime/rpc/methods/orchestration.ts
|
||||
inline src/main/runtime/rpc/methods/terminal.ts
|
||||
inline src/main/runtime/runtime-rpc.ts
|
||||
|
||||
@@ -15,6 +15,7 @@ const shellContractFiles = [
|
||||
'src/main/providers/local-pty-shell-ready-zsh-zdotdir-discovery.test.ts',
|
||||
'src/main/providers/local-pty-shell-ready-zsh-zdotdir-normalization.test.ts',
|
||||
'src/main/providers/__tests__/shell-ready-framework-example.test.ts',
|
||||
'src/main/zsh-scoped-histfile.live-shell.test.ts',
|
||||
'src/shared/posix-command-path-lookup.test.ts'
|
||||
]
|
||||
const patchedNodePtyContractFiles = [
|
||||
|
||||
@@ -4944,7 +4944,9 @@ export default function SessionScreen() {
|
||||
ref={liveInputRef}
|
||||
style={styles.liveInputCapture}
|
||||
value={liveInputCapture}
|
||||
onChangeText={handleLiveInputChange}
|
||||
// Why onChange, not onChangeText: only the raw native event carries the
|
||||
// marked-text report that says whether this text is still preedit.
|
||||
onChange={handleLiveInputChange}
|
||||
onKeyPress={handleLiveInputKeyPress}
|
||||
onSubmitEditing={handleLiveInputSubmit}
|
||||
placeholder=""
|
||||
|
||||
@@ -85,6 +85,9 @@
|
||||
"pnpm": {
|
||||
"overrides": {
|
||||
"xcode>uuid": "11.1.1"
|
||||
},
|
||||
"patchedDependencies": {
|
||||
"react-native@0.83.9": "patches/react-native@0.83.9.patch"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,48 @@
|
||||
diff --git a/React/Fabric/Mounting/ComponentViews/TextInput/RCTTextInputComponentView.mm b/React/Fabric/Mounting/ComponentViews/TextInput/RCTTextInputComponentView.mm
|
||||
index d02e003a439e410ddab597b2c036256c80db0395..3caff21b0d00bfbc6dea3f8470e812b8343ac3a2 100644
|
||||
--- a/React/Fabric/Mounting/ComponentViews/TextInput/RCTTextInputComponentView.mm
|
||||
+++ b/React/Fabric/Mounting/ComponentViews/TextInput/RCTTextInputComponentView.mm
|
||||
@@ -703,6 +703,7 @@ static NSSet<NSNumber *> *returnKeyTypesSet;
|
||||
{
|
||||
return {
|
||||
.text = RCTStringFromNSString(_backedTextInputView.attributedText.string),
|
||||
+ .isComposing = _backedTextInputView.markedTextRange != nil,
|
||||
.selectionRange = [self _selectionRange],
|
||||
.eventCount = static_cast<int>(_mostRecentEventCount),
|
||||
.contentOffset = RCTPointFromCGPoint(_backedTextInputView.contentOffset),
|
||||
diff --git a/ReactCommon/react/renderer/components/textinput/TextInputEventEmitter.cpp b/ReactCommon/react/renderer/components/textinput/TextInputEventEmitter.cpp
|
||||
index a9bc219f8bf729111e907423ca61b991fd712b46..60e9c6f54edb295295d6e48d1d554499106b7e57 100644
|
||||
--- a/ReactCommon/react/renderer/components/textinput/TextInputEventEmitter.cpp
|
||||
+++ b/ReactCommon/react/renderer/components/textinput/TextInputEventEmitter.cpp
|
||||
@@ -24,6 +24,10 @@ static jsi::Value textInputMetricsPayload(
|
||||
|
||||
payload.setProperty(runtime, "eventCount", textInputMetrics.eventCount);
|
||||
|
||||
+ if (textInputMetrics.isComposing.has_value()) {
|
||||
+ payload.setProperty(runtime, "isComposing", *textInputMetrics.isComposing);
|
||||
+ }
|
||||
+
|
||||
if (includeSelectionState) {
|
||||
auto selection = jsi::Object(runtime);
|
||||
selection.setProperty(
|
||||
diff --git a/ReactCommon/react/renderer/components/textinput/TextInputEventEmitter.h b/ReactCommon/react/renderer/components/textinput/TextInputEventEmitter.h
|
||||
index a3f2e01c130c1a53aee0b39e72927d21f2a6973d..c9d34f8b42644800b5c85e5be45b6ebfbe94a54d 100644
|
||||
--- a/ReactCommon/react/renderer/components/textinput/TextInputEventEmitter.h
|
||||
+++ b/ReactCommon/react/renderer/components/textinput/TextInputEventEmitter.h
|
||||
@@ -7,6 +7,8 @@
|
||||
|
||||
#pragma once
|
||||
|
||||
+#include <optional>
|
||||
+
|
||||
#include <react/renderer/attributedstring/AttributedString.h>
|
||||
#include <react/renderer/components/view/ViewEventEmitter.h>
|
||||
|
||||
@@ -18,6 +20,7 @@ class TextInputEventEmitter : public ViewEventEmitter {
|
||||
|
||||
struct Metrics {
|
||||
std::string text;
|
||||
+ std::optional<bool> isComposing;
|
||||
AttributedString::Range selectionRange;
|
||||
// ScrollView-like metrics
|
||||
Size contentSize;
|
||||
Generated
+5
@@ -7,6 +7,11 @@ settings:
|
||||
overrides:
|
||||
xcode>uuid: 11.1.1
|
||||
|
||||
patchedDependencies:
|
||||
react-native@0.83.9:
|
||||
hash: a6502e7d63769bf1fd639cbbaab1cc0b8aebef31d22048e8f2186fb399d0c16e
|
||||
path: patches/react-native@0.83.9.patch
|
||||
|
||||
importers:
|
||||
|
||||
.:
|
||||
|
||||
@@ -12,6 +12,13 @@ describe('terminal iOS IME keyboard', () => {
|
||||
expect(sessionRouteSource).not.toContain('"ascii-capable"')
|
||||
})
|
||||
|
||||
it('subscribes live capture to onChange so the marked-text report survives', () => {
|
||||
// onChangeText hands over only a string, discarding the preedit report that
|
||||
// decides whether the text may reach the PTY at all.
|
||||
expect(sessionRouteSource).toContain('onChange={handleLiveInputChange}')
|
||||
expect(sessionRouteSource).not.toContain('onChangeText={handleLiveInputChange}')
|
||||
})
|
||||
|
||||
it('does not put terminal keyboard capture behind iOS textContentType semantics', () => {
|
||||
expect(sessionRouteSource).not.toContain('textContentType="none"')
|
||||
expect(sessionRouteSource).toContain('autoComplete="off"')
|
||||
|
||||
@@ -1,178 +0,0 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import {
|
||||
buildTerminalLiveMirrorPayload,
|
||||
computeTerminalLiveMirrorStep,
|
||||
isTerminalLiveHangulCodePoint,
|
||||
type TerminalLiveMirrorStep
|
||||
} from './terminal-live-hangul-mirror'
|
||||
|
||||
type MirrorRun = {
|
||||
readonly payloads: readonly string[]
|
||||
readonly sentText: string
|
||||
readonly heldText: string
|
||||
}
|
||||
|
||||
function runMirrorSequence(
|
||||
fieldStates: readonly string[],
|
||||
options: { readonly commitAtEnd: boolean } = { commitAtEnd: false }
|
||||
): MirrorRun {
|
||||
const payloads: string[] = []
|
||||
let sentText = ''
|
||||
let heldText = ''
|
||||
for (const fieldText of fieldStates) {
|
||||
const step = computeTerminalLiveMirrorStep(sentText, fieldText, { commitHeld: false })
|
||||
const payload = buildTerminalLiveMirrorPayload(step)
|
||||
if (payload.length > 0) {
|
||||
payloads.push(payload)
|
||||
}
|
||||
sentText = step.nextSentText
|
||||
heldText = step.heldText
|
||||
}
|
||||
if (options.commitAtEnd) {
|
||||
const lastField = sentText + heldText
|
||||
const step = computeTerminalLiveMirrorStep(sentText, lastField, { commitHeld: true })
|
||||
const payload = buildTerminalLiveMirrorPayload(step)
|
||||
if (payload.length > 0) {
|
||||
payloads.push(payload)
|
||||
}
|
||||
sentText = step.nextSentText
|
||||
heldText = step.heldText
|
||||
}
|
||||
return { payloads, sentText, heldText }
|
||||
}
|
||||
|
||||
describe('terminal live hangul mirror', () => {
|
||||
it('Given single-syllable composition When steps run Then leaks no jamo and commits only the final syllable', () => {
|
||||
// Given / When
|
||||
const run = runMirrorSequence(['ㅎ', '하', '한'], { commitAtEnd: true })
|
||||
|
||||
// Then
|
||||
expect(run.payloads).toEqual(['한'])
|
||||
expect(run.sentText).toBe('한')
|
||||
expect(run.heldText).toBe('')
|
||||
})
|
||||
|
||||
it('Given multi-syllable composition When a new syllable starts Then streams the stable prefix without erases', () => {
|
||||
// Given / When
|
||||
const run = runMirrorSequence(['ㅎ', '하', '한', '한ㄱ', '한그', '한글'], { commitAtEnd: true })
|
||||
|
||||
// Then
|
||||
expect(run.payloads).toEqual(['한', '글'])
|
||||
expect(run.sentText).toBe('한글')
|
||||
})
|
||||
|
||||
it('Given dubeolsik resplit 간→가나 When steps run Then never sends the intermediate syllable', () => {
|
||||
// Given / When
|
||||
const run = runMirrorSequence(['ㄱ', '가', '간', '가나'], { commitAtEnd: true })
|
||||
|
||||
// Then
|
||||
expect(run.payloads).toEqual(['가', '나'])
|
||||
expect(run.sentText).toBe('가나')
|
||||
})
|
||||
|
||||
it('Given a timer-committed syllable When composition continues Then erases and recommits via DEL correction', () => {
|
||||
// Given: '하' was committed by the settle timer
|
||||
const commit = computeTerminalLiveMirrorStep('', '하', { commitHeld: true })
|
||||
expect(buildTerminalLiveMirrorPayload(commit)).toBe('하')
|
||||
expect(commit.nextSentText).toBe('하')
|
||||
|
||||
// When: user keeps composing '하' → '한'
|
||||
const correction = computeTerminalLiveMirrorStep(commit.nextSentText, '한', {
|
||||
commitHeld: false
|
||||
})
|
||||
|
||||
// Then: one DEL erases the stale syllable; the new one is held again
|
||||
expect(buildTerminalLiveMirrorPayload(correction)).toBe('\x7f')
|
||||
expect(correction.nextSentText).toBe('')
|
||||
expect(correction.heldText).toBe('한')
|
||||
|
||||
const recommit = computeTerminalLiveMirrorStep('', '한', { commitHeld: true })
|
||||
expect(buildTerminalLiveMirrorPayload(recommit)).toBe('한')
|
||||
})
|
||||
|
||||
it('Given pure ASCII typing When steps run Then mirrors immediately with no held text', () => {
|
||||
// Given / When
|
||||
const run = runMirrorSequence(['a', 'ab', 'abc'])
|
||||
|
||||
// Then
|
||||
expect(run.payloads).toEqual(['a', 'b', 'c'])
|
||||
expect(run.heldText).toBe('')
|
||||
})
|
||||
|
||||
it('Given a trailing space after Hangul When the step runs Then the space commits the held syllable', () => {
|
||||
// Given: '한글' typed, '한' streamed, '글' held
|
||||
const beforeSpace = runMirrorSequence(['ㅎ', '하', '한', '한ㄱ', '한그', '한글'])
|
||||
expect(beforeSpace.sentText).toBe('한')
|
||||
expect(beforeSpace.heldText).toBe('글')
|
||||
|
||||
// When
|
||||
const step = computeTerminalLiveMirrorStep(beforeSpace.sentText, '한글 ', {
|
||||
commitHeld: false
|
||||
})
|
||||
|
||||
// Then
|
||||
expect(buildTerminalLiveMirrorPayload(step)).toBe('글 ')
|
||||
expect(step.heldText).toBe('')
|
||||
expect(step.nextSentText).toBe('한글 ')
|
||||
})
|
||||
|
||||
it('Given a trailing ASCII letter after Hangul When the step runs Then Hangul is committed with the letter', () => {
|
||||
// Given
|
||||
const held = computeTerminalLiveMirrorStep('', '한', { commitHeld: false })
|
||||
expect(held.heldText).toBe('한')
|
||||
|
||||
// When
|
||||
const step = computeTerminalLiveMirrorStep(held.nextSentText, '한a', { commitHeld: false })
|
||||
|
||||
// Then
|
||||
expect(buildTerminalLiveMirrorPayload(step)).toBe('한a')
|
||||
expect(step.heldText).toBe('')
|
||||
})
|
||||
|
||||
it('Given sent text When the user deletes everything Then erases with one DEL per code point', () => {
|
||||
// Given / When
|
||||
const step = computeTerminalLiveMirrorStep('한글a', '', { commitHeld: false })
|
||||
|
||||
// Then
|
||||
expect(step).toEqual<TerminalLiveMirrorStep>({
|
||||
eraseCount: 3,
|
||||
appendText: '',
|
||||
nextSentText: '',
|
||||
heldText: ''
|
||||
})
|
||||
expect(buildTerminalLiveMirrorPayload(step)).toBe('\x7f\x7f\x7f')
|
||||
})
|
||||
|
||||
it('Given empty field and empty sent text When committing Then produces a zero step', () => {
|
||||
// Given / When
|
||||
const step = computeTerminalLiveMirrorStep('', '', { commitHeld: true })
|
||||
|
||||
// Then
|
||||
expect(buildTerminalLiveMirrorPayload(step)).toBe('')
|
||||
expect(step).toEqual<TerminalLiveMirrorStep>({
|
||||
eraseCount: 0,
|
||||
appendText: '',
|
||||
nextSentText: '',
|
||||
heldText: ''
|
||||
})
|
||||
})
|
||||
|
||||
it('Given non-Hangul IME text When the step runs Then it mirrors immediately without holding', () => {
|
||||
// Given / When
|
||||
const chinese = computeTerminalLiveMirrorStep('', '你好', { commitHeld: false })
|
||||
const vietnamese = computeTerminalLiveMirrorStep('', 'tiếng', { commitHeld: false })
|
||||
|
||||
// Then
|
||||
expect(buildTerminalLiveMirrorPayload(chinese)).toBe('你好')
|
||||
expect(chinese.heldText).toBe('')
|
||||
expect(buildTerminalLiveMirrorPayload(vietnamese)).toBe('tiếng')
|
||||
expect(vietnamese.heldText).toBe('')
|
||||
})
|
||||
|
||||
it('Given Hangul code point ranges When checked Then jamo and syllables match and ASCII does not', () => {
|
||||
expect(isTerminalLiveHangulCodePoint('ㅎ'.codePointAt(0) ?? 0)).toBe(true)
|
||||
expect(isTerminalLiveHangulCodePoint('한'.codePointAt(0) ?? 0)).toBe(true)
|
||||
expect(isTerminalLiveHangulCodePoint('a'.codePointAt(0) ?? 0)).toBe(false)
|
||||
expect(isTerminalLiveHangulCodePoint('あ'.codePointAt(0) ?? 0)).toBe(false)
|
||||
})
|
||||
})
|
||||
@@ -1,61 +0,0 @@
|
||||
// Why: paused composition should still reach the PTY quickly; corrections make
|
||||
// a premature commit safe, so this can be short without leaking jamo forever.
|
||||
export const TERMINAL_LIVE_HELD_SYLLABLE_COMMIT_DELAY_MS = 300
|
||||
|
||||
const TERMINAL_DEL_BYTE = '\x7f'
|
||||
|
||||
export function isTerminalLiveHangulCodePoint(codePoint: number): boolean {
|
||||
return (
|
||||
(codePoint >= 0x1100 && codePoint <= 0x11ff) ||
|
||||
(codePoint >= 0x3130 && codePoint <= 0x318f) ||
|
||||
(codePoint >= 0xa960 && codePoint <= 0xa97f) ||
|
||||
(codePoint >= 0xac00 && codePoint <= 0xd7af)
|
||||
)
|
||||
}
|
||||
|
||||
export type TerminalLiveMirrorStep = {
|
||||
readonly eraseCount: number
|
||||
readonly appendText: string
|
||||
readonly nextSentText: string
|
||||
readonly heldText: string
|
||||
}
|
||||
|
||||
// Why: React Native exposes no composition events, but Hangul composition only
|
||||
// mutates the trailing syllable. Holding just that code point keeps the PTY
|
||||
// echo live while preedit jamo never leak; DEL corrections repair any commit
|
||||
// that later turns out to be premature.
|
||||
export function computeTerminalLiveMirrorStep(
|
||||
sentText: string,
|
||||
fieldText: string,
|
||||
options: { readonly commitHeld: boolean }
|
||||
): TerminalLiveMirrorStep {
|
||||
const fieldCodePoints = Array.from(fieldText)
|
||||
const lastCodePoint = fieldCodePoints.at(-1)
|
||||
const holdLast =
|
||||
!options.commitHeld &&
|
||||
lastCodePoint !== undefined &&
|
||||
isTerminalLiveHangulCodePoint(lastCodePoint.codePointAt(0) ?? 0)
|
||||
const heldText = holdLast && lastCodePoint !== undefined ? lastCodePoint : ''
|
||||
const targetCodePoints = holdLast ? fieldCodePoints.slice(0, -1) : fieldCodePoints
|
||||
const sentCodePoints = Array.from(sentText)
|
||||
|
||||
let commonPrefixLength = 0
|
||||
while (
|
||||
commonPrefixLength < sentCodePoints.length &&
|
||||
commonPrefixLength < targetCodePoints.length &&
|
||||
sentCodePoints[commonPrefixLength] === targetCodePoints[commonPrefixLength]
|
||||
) {
|
||||
commonPrefixLength += 1
|
||||
}
|
||||
|
||||
return {
|
||||
eraseCount: sentCodePoints.length - commonPrefixLength,
|
||||
appendText: targetCodePoints.slice(commonPrefixLength).join(''),
|
||||
nextSentText: targetCodePoints.join(''),
|
||||
heldText
|
||||
}
|
||||
}
|
||||
|
||||
export function buildTerminalLiveMirrorPayload(step: TerminalLiveMirrorStep): string {
|
||||
return TERMINAL_DEL_BYTE.repeat(step.eraseCount) + step.appendText
|
||||
}
|
||||
@@ -0,0 +1,304 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import {
|
||||
buildTerminalLiveMirrorPayload,
|
||||
computeTerminalLiveMirrorStep,
|
||||
type TerminalLiveMirrorStep
|
||||
} from './terminal-live-preedit-mirror'
|
||||
|
||||
type MirrorRun = {
|
||||
readonly payloads: readonly string[]
|
||||
readonly sentText: string
|
||||
readonly heldText: string
|
||||
}
|
||||
|
||||
type MirrorFrame = { readonly text: string; readonly composing?: boolean }
|
||||
|
||||
function runMirrorSequence(
|
||||
fieldStates: readonly (string | MirrorFrame)[],
|
||||
options: { readonly commitAtEnd: boolean } = { commitAtEnd: false }
|
||||
): MirrorRun {
|
||||
const payloads: string[] = []
|
||||
let sentText = ''
|
||||
let heldText = ''
|
||||
for (const frame of fieldStates) {
|
||||
const { text, composing } = typeof frame === 'string' ? { text: frame } : frame
|
||||
const step = computeTerminalLiveMirrorStep(sentText, text, { commitHeld: false, composing })
|
||||
const payload = buildTerminalLiveMirrorPayload(step)
|
||||
if (payload.length > 0) {
|
||||
payloads.push(payload)
|
||||
}
|
||||
sentText = step.nextSentText
|
||||
heldText = step.heldText
|
||||
}
|
||||
if (options.commitAtEnd) {
|
||||
const lastField = sentText + heldText
|
||||
const step = computeTerminalLiveMirrorStep(sentText, lastField, { commitHeld: true })
|
||||
const payload = buildTerminalLiveMirrorPayload(step)
|
||||
if (payload.length > 0) {
|
||||
payloads.push(payload)
|
||||
}
|
||||
sentText = step.nextSentText
|
||||
heldText = step.heldText
|
||||
}
|
||||
return { payloads, sentText, heldText }
|
||||
}
|
||||
|
||||
describe('terminal live preedit mirror with a reported marked-text range', () => {
|
||||
it('Given Japanese romaji composition When steps run Then no reading reaches the PTY and no DEL repairs it', () => {
|
||||
// Given / When: `sa` composes to さ, then `sakura` converts to 桜
|
||||
const run = runMirrorSequence(
|
||||
[
|
||||
{ text: 's', composing: true },
|
||||
{ text: 'さ', composing: true },
|
||||
{ text: 'さく', composing: true },
|
||||
{ text: 'さくら', composing: true },
|
||||
{ text: '桜', composing: false }
|
||||
],
|
||||
{ commitAtEnd: true }
|
||||
)
|
||||
|
||||
// Then
|
||||
expect(run.payloads).toEqual(['桜'])
|
||||
expect(run.sentText).toBe('桜')
|
||||
expect(run.heldText).toBe('')
|
||||
})
|
||||
|
||||
it('Given ASCII Chinese pinyin preedit When steps run Then the reading never leaks, which no code-point rule could achieve', () => {
|
||||
// Given / When
|
||||
const run = runMirrorSequence(
|
||||
[
|
||||
{ text: 'n', composing: true },
|
||||
{ text: 'ni', composing: true },
|
||||
{ text: 'niha', composing: true },
|
||||
{ text: 'nihao', composing: true },
|
||||
{ text: '你好', composing: false }
|
||||
],
|
||||
{ commitAtEnd: true }
|
||||
)
|
||||
|
||||
// Then
|
||||
expect(run.payloads).toEqual(['你好'])
|
||||
expect(run.sentText).toBe('你好')
|
||||
})
|
||||
|
||||
it('Given Hangul composition When the range is reported Then the syllable lands once with no DEL correction', () => {
|
||||
// Given / When
|
||||
const run = runMirrorSequence(
|
||||
[
|
||||
{ text: 'ㅎ', composing: true },
|
||||
{ text: '하', composing: true },
|
||||
{ text: '한', composing: true },
|
||||
{ text: '한', composing: false }
|
||||
],
|
||||
{ commitAtEnd: true }
|
||||
)
|
||||
|
||||
// Then
|
||||
expect(run.payloads).toEqual(['한'])
|
||||
expect(run.sentText).toBe('한')
|
||||
})
|
||||
|
||||
it('Given ASCII typing When the range is reported empty Then every character mirrors immediately', () => {
|
||||
// Given / When
|
||||
const run = runMirrorSequence([
|
||||
{ text: 'a', composing: false },
|
||||
{ text: 'ab', composing: false },
|
||||
{ text: 'abc', composing: false }
|
||||
])
|
||||
|
||||
// Then
|
||||
expect(run.payloads).toEqual(['a', 'b', 'c'])
|
||||
expect(run.heldText).toBe('')
|
||||
})
|
||||
|
||||
it('Given committed text When a new composition starts Then the commit stays and only the preedit is held', () => {
|
||||
// Given
|
||||
const committed = computeTerminalLiveMirrorStep('', 'ls ', {
|
||||
commitHeld: false,
|
||||
composing: false
|
||||
})
|
||||
expect(committed.nextSentText).toBe('ls ')
|
||||
|
||||
// When
|
||||
const step = computeTerminalLiveMirrorStep('ls ', 'ls さ', {
|
||||
commitHeld: false,
|
||||
composing: true
|
||||
})
|
||||
|
||||
// Then
|
||||
expect(buildTerminalLiveMirrorPayload(step)).toBe('')
|
||||
expect(step.nextSentText).toBe('ls ')
|
||||
expect(step.heldText).toBe('さ')
|
||||
})
|
||||
|
||||
it('Given a composition that rewrites already-sent text When the step runs Then DEL erases only the difference', () => {
|
||||
// Given / When: the input method reclaimed a code point the PTY already has
|
||||
const step = computeTerminalLiveMirrorStep('abc', 'abX', { commitHeld: false, composing: true })
|
||||
|
||||
// Then
|
||||
expect(buildTerminalLiveMirrorPayload(step)).toBe('\x7f')
|
||||
expect(step.nextSentText).toBe('ab')
|
||||
expect(step.heldText).toBe('X')
|
||||
})
|
||||
})
|
||||
|
||||
describe('terminal live preedit mirror with no marked-text report', () => {
|
||||
it('Given Japanese romaji When nothing is reported Then the whole multi-code-point reading is held as one unit', () => {
|
||||
// Given / When: きょう is three code points, so holding one trailing code point would leak きょ
|
||||
const run = runMirrorSequence(['k', 'ky', 'きょ', 'きょう'], { commitAtEnd: true })
|
||||
|
||||
// The ASCII part of the reading still leaks and is DEL-repaired — only a
|
||||
// marked-text range can see an ASCII preedit — but no partial kana reaches the
|
||||
// PTY, which a trailing-single-code-point hold could not achieve.
|
||||
expect(run.payloads).toEqual(['k', 'y', '\x7f\x7f', 'きょう'])
|
||||
expect(run.sentText).toBe('きょう')
|
||||
})
|
||||
|
||||
it('Given Hangul composition When nothing is reported Then no jamo leaks and only the final syllable commits', () => {
|
||||
// Given / When
|
||||
const run = runMirrorSequence(['ㅎ', '하', '한'], { commitAtEnd: true })
|
||||
|
||||
// Then
|
||||
expect(run.payloads).toEqual(['한'])
|
||||
expect(run.sentText).toBe('한')
|
||||
expect(run.heldText).toBe('')
|
||||
})
|
||||
|
||||
it('Given multi-syllable Hangul When nothing is reported Then the whole run stays held until commit', () => {
|
||||
// Given / When
|
||||
const run = runMirrorSequence(['ㅎ', '하', '한', '한ㄱ', '한그', '한글'], { commitAtEnd: true })
|
||||
|
||||
// Then
|
||||
expect(run.payloads).toEqual(['한글'])
|
||||
expect(run.sentText).toBe('한글')
|
||||
})
|
||||
|
||||
it('Given dubeolsik resplit 간→가나 When nothing is reported Then no intermediate syllable is ever sent', () => {
|
||||
// Given / When
|
||||
const run = runMirrorSequence(['ㄱ', '가', '간', '가나'], { commitAtEnd: true })
|
||||
|
||||
// Then
|
||||
expect(run.payloads).toEqual(['가나'])
|
||||
expect(run.sentText).toBe('가나')
|
||||
})
|
||||
|
||||
it('Given a timer-committed hold When composition continues Then DEL corrections repair the premature commit', () => {
|
||||
// Given: '하' was committed by the settle timer
|
||||
const commit = computeTerminalLiveMirrorStep('', '하', { commitHeld: true })
|
||||
expect(buildTerminalLiveMirrorPayload(commit)).toBe('하')
|
||||
|
||||
// When: user keeps composing '하' → '한'
|
||||
const correction = computeTerminalLiveMirrorStep(commit.nextSentText, '한', {
|
||||
commitHeld: false
|
||||
})
|
||||
|
||||
// Then
|
||||
expect(buildTerminalLiveMirrorPayload(correction)).toBe('\x7f')
|
||||
expect(correction.nextSentText).toBe('')
|
||||
expect(correction.heldText).toBe('한')
|
||||
})
|
||||
|
||||
it('Given pure ASCII typing When nothing is reported Then mirroring stays immediate with no held text', () => {
|
||||
// Given / When
|
||||
const run = runMirrorSequence(['a', 'ab', 'abc'])
|
||||
|
||||
// Then
|
||||
expect(run.payloads).toEqual(['a', 'b', 'c'])
|
||||
expect(run.heldText).toBe('')
|
||||
})
|
||||
|
||||
it('Given a trailing space after held text When the step runs Then the space commits the hold', () => {
|
||||
// Given
|
||||
const beforeSpace = runMirrorSequence(['ㅎ', '하', '한'])
|
||||
expect(beforeSpace.heldText).toBe('한')
|
||||
|
||||
// When
|
||||
const step = computeTerminalLiveMirrorStep(beforeSpace.sentText, '한 ', { commitHeld: false })
|
||||
|
||||
// Then
|
||||
expect(buildTerminalLiveMirrorPayload(step)).toBe('한 ')
|
||||
expect(step.heldText).toBe('')
|
||||
})
|
||||
|
||||
it('Given a trailing ASCII letter after held text When the step runs Then the hold commits with the letter', () => {
|
||||
// Given
|
||||
const held = computeTerminalLiveMirrorStep('', '한', { commitHeld: false })
|
||||
expect(held.heldText).toBe('한')
|
||||
|
||||
// When
|
||||
const step = computeTerminalLiveMirrorStep(held.nextSentText, '한a', { commitHeld: false })
|
||||
|
||||
// Then
|
||||
expect(buildTerminalLiveMirrorPayload(step)).toBe('한a')
|
||||
expect(step.heldText).toBe('')
|
||||
})
|
||||
|
||||
it('Given Vietnamese telex whose commit ends in ASCII When the step runs Then it mirrors immediately', () => {
|
||||
// Given / When
|
||||
const step = computeTerminalLiveMirrorStep('', 'tiếng', { commitHeld: false })
|
||||
|
||||
// Then
|
||||
expect(buildTerminalLiveMirrorPayload(step)).toBe('tiếng')
|
||||
expect(step.heldText).toBe('')
|
||||
})
|
||||
|
||||
it('Given sent text When the user deletes everything Then erases with one DEL per code point', () => {
|
||||
// Given / When
|
||||
const step = computeTerminalLiveMirrorStep('한글a', '', { commitHeld: false })
|
||||
|
||||
// Then
|
||||
expect(step).toEqual<TerminalLiveMirrorStep>({
|
||||
eraseCount: 3,
|
||||
appendText: '',
|
||||
nextSentText: '',
|
||||
heldText: ''
|
||||
})
|
||||
expect(buildTerminalLiveMirrorPayload(step)).toBe('\x7f\x7f\x7f')
|
||||
})
|
||||
|
||||
// A settle-timer commit promotes the held run to sent text. The next keystroke must not reach
|
||||
// back over it: the fallback has no composing signal, so without a bound it re-holds everything
|
||||
// already delivered and the caller erases it with DEL. Measured at 120 DELs for this word.
|
||||
it('Given a non-composing script on the fallback path When the settle timer commits between keystrokes Then nothing already sent is erased', () => {
|
||||
// Given
|
||||
const word = Array.from('приветмир')
|
||||
let sentText = ''
|
||||
let field = ''
|
||||
const payloads: string[] = []
|
||||
|
||||
// When — every keystroke is followed by a settle commit, the Android shape
|
||||
for (const codePoint of word) {
|
||||
field += codePoint
|
||||
const live = computeTerminalLiveMirrorStep(sentText, field, { commitHeld: false })
|
||||
const livePayload = buildTerminalLiveMirrorPayload(live)
|
||||
if (livePayload.length > 0) {
|
||||
payloads.push(livePayload)
|
||||
}
|
||||
const settled = computeTerminalLiveMirrorStep(live.nextSentText, field, { commitHeld: true })
|
||||
const settledPayload = buildTerminalLiveMirrorPayload(settled)
|
||||
if (settledPayload.length > 0) {
|
||||
payloads.push(settledPayload)
|
||||
}
|
||||
sentText = settled.nextSentText
|
||||
}
|
||||
|
||||
// Then
|
||||
const wire = payloads.join('')
|
||||
expect(wire, 'a DEL erases a character the pty already received').not.toContain('\x7f')
|
||||
expect(wire).toBe(word.join(''))
|
||||
})
|
||||
|
||||
it('Given empty field and empty sent text When committing Then produces a zero step', () => {
|
||||
// Given / When
|
||||
const step = computeTerminalLiveMirrorStep('', '', { commitHeld: true })
|
||||
|
||||
// Then
|
||||
expect(buildTerminalLiveMirrorPayload(step)).toBe('')
|
||||
expect(step).toEqual<TerminalLiveMirrorStep>({
|
||||
eraseCount: 0,
|
||||
appendText: '',
|
||||
nextSentText: '',
|
||||
heldText: ''
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,83 @@
|
||||
// Why: an unreported preedit that pauses should still reach the PTY quickly;
|
||||
// corrections make a premature commit safe. Only armed when the platform
|
||||
// reports nothing — a reported preedit is not text yet and must never time out.
|
||||
export const TERMINAL_LIVE_HELD_PREEDIT_COMMIT_DELAY_MS = 300
|
||||
|
||||
const TERMINAL_DEL_BYTE = '\x7f'
|
||||
const LAST_ASCII_CODE_POINT = 0x7f
|
||||
|
||||
export type TerminalLiveMirrorStep = {
|
||||
readonly eraseCount: number
|
||||
readonly appendText: string
|
||||
readonly nextSentText: string
|
||||
readonly heldText: string
|
||||
}
|
||||
|
||||
/**
|
||||
* How much of the field the text system can still rewrite, in code points.
|
||||
*
|
||||
* Preedit is a fact about the field, never about the script — Chinese pinyin
|
||||
* preedit is plain ASCII — so the marked-text range decides and no code point is
|
||||
* ever classified. That is what makes this hold for input methods nobody tested.
|
||||
*
|
||||
* `composing` is undefined only where the platform reports no range at all —
|
||||
* today React Native Android. The fallback holds the trailing non-ASCII run: a
|
||||
* conversion IME always leaves one and ASCII keeps its zero-latency echo. It
|
||||
* enumerates nothing, and it cannot see an ASCII preedit — only a report can.
|
||||
*/
|
||||
function heldPreeditLength(
|
||||
fieldCodePoints: readonly string[],
|
||||
stableLength: number,
|
||||
composing: boolean | undefined
|
||||
): number {
|
||||
if (composing !== undefined) {
|
||||
return composing ? fieldCodePoints.length - stableLength : 0
|
||||
}
|
||||
let held = 0
|
||||
while (
|
||||
held < fieldCodePoints.length &&
|
||||
(fieldCodePoints[fieldCodePoints.length - 1 - held]?.codePointAt(0) ?? 0) >
|
||||
LAST_ASCII_CODE_POINT
|
||||
) {
|
||||
held += 1
|
||||
}
|
||||
// Why the bound: the run can reach back over code points already delivered to the pty, and
|
||||
// holding those makes the caller erase them with DEL and retype them. The reported branch above
|
||||
// subtracts `stableLength` for the same reason; without it a settle-timer commit followed by
|
||||
// another keystroke costs a DEL per already-sent character.
|
||||
return Math.min(held, fieldCodePoints.length - stableLength)
|
||||
}
|
||||
|
||||
function commonPrefixLength(left: readonly string[], right: readonly string[]): number {
|
||||
let length = 0
|
||||
while (length < left.length && length < right.length && left[length] === right[length]) {
|
||||
length += 1
|
||||
}
|
||||
return length
|
||||
}
|
||||
|
||||
export function computeTerminalLiveMirrorStep(
|
||||
sentText: string,
|
||||
fieldText: string,
|
||||
options: { readonly commitHeld: boolean; readonly composing?: boolean }
|
||||
): TerminalLiveMirrorStep {
|
||||
const fieldCodePoints = Array.from(fieldText)
|
||||
const sentCodePoints = Array.from(sentText)
|
||||
const stableLength = commonPrefixLength(sentCodePoints, fieldCodePoints)
|
||||
const heldLength = options.commitHeld
|
||||
? 0
|
||||
: heldPreeditLength(fieldCodePoints, stableLength, options.composing)
|
||||
const targetCodePoints = fieldCodePoints.slice(0, fieldCodePoints.length - heldLength)
|
||||
const keptLength = Math.min(stableLength, targetCodePoints.length)
|
||||
|
||||
return {
|
||||
eraseCount: sentCodePoints.length - keptLength,
|
||||
appendText: targetCodePoints.slice(keptLength).join(''),
|
||||
nextSentText: targetCodePoints.join(''),
|
||||
heldText: fieldCodePoints.slice(fieldCodePoints.length - heldLength).join('')
|
||||
}
|
||||
}
|
||||
|
||||
export function buildTerminalLiveMirrorPayload(step: TerminalLiveMirrorStep): string {
|
||||
return TERMINAL_DEL_BYTE.repeat(step.eraseCount) + step.appendText
|
||||
}
|
||||
@@ -3,12 +3,23 @@ import { act, create, type ReactTestRenderer } from 'react-test-renderer'
|
||||
import type { TextInput } from 'react-native'
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest'
|
||||
import type { TerminalLiveInputSender } from './terminal-live-input-sender'
|
||||
import { TERMINAL_LIVE_HELD_SYLLABLE_COMMIT_DELAY_MS } from './terminal-live-hangul-mirror'
|
||||
import { TERMINAL_LIVE_HELD_PREEDIT_COMMIT_DELAY_MS } from './terminal-live-preedit-mirror'
|
||||
import { useTerminalLiveInputCommit } from './use-terminal-live-input-commit'
|
||||
|
||||
type TerminalLiveInputCommitHandlers = ReturnType<typeof useTerminalLiveInputCommit<string>>
|
||||
|
||||
/** `isComposing` omitted models a platform that reports no marked-text range. */
|
||||
function changeLiveInput(
|
||||
handlers: TerminalLiveInputCommitHandlers,
|
||||
text: string,
|
||||
isComposing?: boolean
|
||||
): void {
|
||||
handlers.handleLiveInputChange({ nativeEvent: { text, isComposing } })
|
||||
}
|
||||
|
||||
type TerminalLiveInputCommitHarness = {
|
||||
readonly captures: readonly string[]
|
||||
readonly handlers: ReturnType<typeof useTerminalLiveInputCommit<string>>
|
||||
readonly handlers: TerminalLiveInputCommitHandlers
|
||||
readonly sent: readonly string[]
|
||||
readonly setActiveSessionTabType: (next: string | undefined) => void
|
||||
readonly setConnected: (next: boolean) => void
|
||||
@@ -46,7 +57,7 @@ function createTerminalLiveInputCommitHarness({
|
||||
// Refs never re-render; only these variables re-run the hook's clear effects.
|
||||
let currentActiveSessionTabType: string | undefined = 'terminal'
|
||||
let currentConnected = true
|
||||
let handlers: ReturnType<typeof useTerminalLiveInputCommit<string>> | null = null
|
||||
let handlers: TerminalLiveInputCommitHandlers | null = null
|
||||
let renderer: ReactTestRenderer | null = null
|
||||
|
||||
function Harness(): null {
|
||||
@@ -105,29 +116,59 @@ describe('terminal live input commit hook', () => {
|
||||
vi.useRealTimers()
|
||||
})
|
||||
|
||||
it('Given Hangul composition When steps arrive Then streams the stable prefix and never leaks jamo', async () => {
|
||||
it('Given Hangul composition and no marked-text report When steps arrive Then no jamo leaks', async () => {
|
||||
// Given
|
||||
vi.useFakeTimers()
|
||||
const { handlers, sent } = createTerminalLiveInputCommitHarness()
|
||||
|
||||
// When: ㅎ→하→한→한ㄱ→한그→한글 (no settle pause between steps)
|
||||
for (const fieldText of ['ㅎ', '하', '한', '한ㄱ', '한그', '한글']) {
|
||||
handlers.handleLiveInputChange(fieldText)
|
||||
changeLiveInput(handlers, fieldText)
|
||||
await vi.advanceTimersByTimeAsync(50)
|
||||
}
|
||||
|
||||
// Then: only the stable prefix went out; the trailing syllable is held
|
||||
await vi.waitFor(() => expect(sent).toEqual(['한']))
|
||||
// Then: the settle timer commits the run intact; no jamo and no DEL repair
|
||||
await vi.waitFor(() => expect(sent).toEqual(['한글']))
|
||||
})
|
||||
|
||||
it('Given Japanese romaji When the marked-text range is reported Then no reading reaches the terminal', async () => {
|
||||
// Given
|
||||
vi.useFakeTimers()
|
||||
const { handlers, sent } = createTerminalLiveInputCommitHarness()
|
||||
|
||||
// When: `sa` reads as さ, converts to 桜, and only then commits
|
||||
for (const fieldText of ['s', 'さ', 'さく', 'さくら']) {
|
||||
changeLiveInput(handlers, fieldText, true)
|
||||
await vi.advanceTimersByTimeAsync(50)
|
||||
}
|
||||
await vi.advanceTimersByTimeAsync(TERMINAL_LIVE_HELD_PREEDIT_COMMIT_DELAY_MS * 4)
|
||||
changeLiveInput(handlers, '桜', false)
|
||||
|
||||
// Then: the reading never reached the PTY, so nothing had to be erased
|
||||
await vi.waitFor(() => expect(sent).toEqual(['桜']))
|
||||
})
|
||||
|
||||
it('Given a reported preedit that goes idle When the settle delay passes Then nothing is committed on a timer', async () => {
|
||||
// Given: preedit is not text yet, so no timer may promote it
|
||||
vi.useFakeTimers()
|
||||
const { handlers, sent } = createTerminalLiveInputCommitHarness()
|
||||
|
||||
// When
|
||||
changeLiveInput(handlers, 'nihao', true)
|
||||
await vi.advanceTimersByTimeAsync(TERMINAL_LIVE_HELD_PREEDIT_COMMIT_DELAY_MS * 10)
|
||||
|
||||
// Then
|
||||
expect(sent).toEqual([])
|
||||
})
|
||||
|
||||
it('Given a held syllable When the settle timer elapses Then commits it to the terminal', async () => {
|
||||
// Given
|
||||
vi.useFakeTimers()
|
||||
const { handlers, sent } = createTerminalLiveInputCommitHarness()
|
||||
handlers.handleLiveInputChange('한')
|
||||
changeLiveInput(handlers, '한')
|
||||
|
||||
// When
|
||||
await vi.advanceTimersByTimeAsync(TERMINAL_LIVE_HELD_SYLLABLE_COMMIT_DELAY_MS)
|
||||
await vi.advanceTimersByTimeAsync(TERMINAL_LIVE_HELD_PREEDIT_COMMIT_DELAY_MS)
|
||||
|
||||
// Then
|
||||
await vi.waitFor(() => expect(sent).toEqual(['한']))
|
||||
@@ -137,13 +178,13 @@ describe('terminal live input commit hook', () => {
|
||||
// Given
|
||||
vi.useFakeTimers()
|
||||
const { handlers, sent } = createTerminalLiveInputCommitHarness()
|
||||
handlers.handleLiveInputChange('하')
|
||||
await vi.advanceTimersByTimeAsync(TERMINAL_LIVE_HELD_SYLLABLE_COMMIT_DELAY_MS)
|
||||
changeLiveInput(handlers, '하')
|
||||
await vi.advanceTimersByTimeAsync(TERMINAL_LIVE_HELD_PREEDIT_COMMIT_DELAY_MS)
|
||||
await vi.waitFor(() => expect(sent).toEqual(['하']))
|
||||
|
||||
// When
|
||||
handlers.handleLiveInputChange('한')
|
||||
await vi.advanceTimersByTimeAsync(TERMINAL_LIVE_HELD_SYLLABLE_COMMIT_DELAY_MS)
|
||||
changeLiveInput(handlers, '한')
|
||||
await vi.advanceTimersByTimeAsync(TERMINAL_LIVE_HELD_PREEDIT_COMMIT_DELAY_MS)
|
||||
|
||||
// Then
|
||||
await vi.waitFor(() => expect(sent).toEqual(['하', '\x7f', '한']))
|
||||
@@ -152,7 +193,7 @@ describe('terminal live input commit hook', () => {
|
||||
it('Given Hangul pending text When submit is requested Then sends composed text before carriage return', async () => {
|
||||
// Given
|
||||
const { handlers, sent } = createTerminalLiveInputCommitHarness()
|
||||
handlers.handleLiveInputChange('한')
|
||||
changeLiveInput(handlers, '한')
|
||||
|
||||
// When
|
||||
handlers.handleLiveInputSubmit()
|
||||
@@ -175,7 +216,7 @@ describe('terminal live input commit hook', () => {
|
||||
it('Given a rejected held-text send When submit is requested Then suppresses the carriage return', async () => {
|
||||
// Given
|
||||
const { handlers, sent } = createTerminalLiveInputCommitHarness({ sendResult: false })
|
||||
handlers.handleLiveInputChange('한')
|
||||
changeLiveInput(handlers, '한')
|
||||
|
||||
// When
|
||||
handlers.handleLiveInputSubmit()
|
||||
@@ -191,8 +232,8 @@ describe('terminal live input commit hook', () => {
|
||||
const { handlers, sent } = createTerminalLiveInputCommitHarness()
|
||||
|
||||
// When
|
||||
handlers.handleLiveInputChange('a')
|
||||
handlers.handleLiveInputChange('ab')
|
||||
changeLiveInput(handlers, 'a')
|
||||
changeLiveInput(handlers, 'ab')
|
||||
|
||||
// Then
|
||||
await vi.waitFor(() => expect(sent).toEqual(['a', 'b']))
|
||||
@@ -203,7 +244,7 @@ describe('terminal live input commit hook', () => {
|
||||
const { captures, handlers, sent } = createTerminalLiveInputCommitHarness()
|
||||
|
||||
// When: iOS smart punctuation rewrote "--" into an en dash inside the field
|
||||
handlers.handleLiveInputChange('a–')
|
||||
changeLiveInput(handlers, 'a–')
|
||||
|
||||
// Then: writing "a--" back into the controlled value would kill an active
|
||||
// iOS dictation/IME session, so the capture must keep what iOS produced
|
||||
@@ -216,8 +257,8 @@ describe('terminal live input commit hook', () => {
|
||||
const { captures, handlers, sent } = createTerminalLiveInputCommitHarness()
|
||||
|
||||
// When: iOS dictation replaces its hypothesis as recognition refines
|
||||
handlers.handleLiveInputChange('high')
|
||||
handlers.handleLiveInputChange('hi there')
|
||||
changeLiveInput(handlers, 'high')
|
||||
changeLiveInput(handlers, 'hi there')
|
||||
|
||||
// Then: captures only echo the field; the mirror repairs the PTY with DELs
|
||||
expect(captures).toEqual(['high', 'hi there'])
|
||||
@@ -227,10 +268,10 @@ describe('terminal live input commit hook', () => {
|
||||
it('Given a trailing space after Hangul When the change arrives Then the space commits the held syllable', async () => {
|
||||
// Given
|
||||
const { handlers, sent } = createTerminalLiveInputCommitHarness()
|
||||
handlers.handleLiveInputChange('한')
|
||||
changeLiveInput(handlers, '한')
|
||||
|
||||
// When
|
||||
handlers.handleLiveInputChange('한 ')
|
||||
changeLiveInput(handlers, '한 ')
|
||||
|
||||
// Then
|
||||
await vi.waitFor(() => expect(sent).toEqual(['한 ']))
|
||||
@@ -239,7 +280,7 @@ describe('terminal live input commit hook', () => {
|
||||
it('Given Hangul pending text When an external terminal send is requested Then flushes composed text first', async () => {
|
||||
// Given
|
||||
const { handlers, sent } = createTerminalLiveInputCommitHarness()
|
||||
handlers.handleLiveInputChange('한')
|
||||
changeLiveInput(handlers, '한')
|
||||
|
||||
// When
|
||||
const flushed = await handlers.flushPendingLiveInputBeforeExternalSend('terminal-a')
|
||||
@@ -252,7 +293,7 @@ describe('terminal live input commit hook', () => {
|
||||
it('Given pending text cannot be sent When an external terminal send is requested Then reports failure', async () => {
|
||||
// Given
|
||||
const { handlers, sent } = createTerminalLiveInputCommitHarness({ sendResult: false })
|
||||
handlers.handleLiveInputChange('한')
|
||||
changeLiveInput(handlers, '한')
|
||||
|
||||
// When
|
||||
const flushed = await handlers.flushPendingLiveInputBeforeExternalSend('terminal-a')
|
||||
@@ -267,7 +308,7 @@ describe('terminal live input commit hook', () => {
|
||||
const { handlers, sent } = createTerminalLiveInputCommitHarness()
|
||||
|
||||
// When
|
||||
handlers.handleLiveInputChange('你好')
|
||||
changeLiveInput(handlers, '你好')
|
||||
|
||||
// Then
|
||||
await vi.waitFor(() => expect(sent).toEqual(['你好']))
|
||||
@@ -277,7 +318,7 @@ describe('terminal live input commit hook', () => {
|
||||
// Given
|
||||
vi.useFakeTimers()
|
||||
const { handlers, sent, unmount } = createTerminalLiveInputCommitHarness()
|
||||
handlers.handleLiveInputChange('한')
|
||||
changeLiveInput(handlers, '한')
|
||||
|
||||
// When
|
||||
unmount()
|
||||
@@ -290,7 +331,7 @@ describe('terminal live input commit hook', () => {
|
||||
it('Given Backspace with field text When the key arrives Then edits locally without terminal bytes', async () => {
|
||||
// Given
|
||||
const { handlers, sent } = createTerminalLiveInputCommitHarness()
|
||||
handlers.handleLiveInputChange('한')
|
||||
changeLiveInput(handlers, '한')
|
||||
|
||||
// When
|
||||
handlers.handleLiveInputKeyPress({ nativeEvent: { key: 'Backspace' } })
|
||||
@@ -302,7 +343,7 @@ describe('terminal live input commit hook', () => {
|
||||
it('Given Tab with a held syllable When the key arrives Then commits the syllable before the tab bytes', async () => {
|
||||
// Given
|
||||
const { handlers, sent } = createTerminalLiveInputCommitHarness()
|
||||
handlers.handleLiveInputChange('한')
|
||||
changeLiveInput(handlers, '한')
|
||||
|
||||
// When
|
||||
handlers.handleLiveInputKeyPress({ nativeEvent: { key: 'Tab' } })
|
||||
@@ -314,7 +355,7 @@ describe('terminal live input commit hook', () => {
|
||||
it('Given Hangul pending When the tab type lags to undefined Then keeps the composition state', async () => {
|
||||
// Given: '한' held while the active tab is still a terminal
|
||||
const { handlers, sent, setActiveSessionTabType } = createTerminalLiveInputCommitHarness()
|
||||
handlers.handleLiveInputChange('한')
|
||||
changeLiveInput(handlers, '한')
|
||||
|
||||
// When: the mobile tab list momentarily yields no active tab object
|
||||
setActiveSessionTabType(undefined)
|
||||
@@ -327,7 +368,7 @@ describe('terminal live input commit hook', () => {
|
||||
it('Given Hangul pending When the tab genuinely changes to non-terminal Then clears the composition state', async () => {
|
||||
// Given: '한' held while the active tab is still a terminal
|
||||
const { handlers, sent, setActiveSessionTabType } = createTerminalLiveInputCommitHarness()
|
||||
handlers.handleLiveInputChange('한')
|
||||
changeLiveInput(handlers, '한')
|
||||
|
||||
// When: the active tab actually becomes a non-terminal (chat) tab
|
||||
setActiveSessionTabType('chat')
|
||||
@@ -341,7 +382,7 @@ describe('terminal live input commit hook', () => {
|
||||
// Given: a stalled link — the mirror sends but the PTY never accepts (#6713 second defect)
|
||||
const { captures, handlers, sent, setConnected, setSendResult } =
|
||||
createTerminalLiveInputCommitHarness({ sendResult: false })
|
||||
handlers.handleLiveInputChange('XYZZY')
|
||||
changeLiveInput(handlers, 'XYZZY')
|
||||
await vi.waitFor(() => expect(sent).toEqual(['XYZZY']))
|
||||
|
||||
// When: the outage is finally detected, then the link recovers
|
||||
@@ -353,7 +394,7 @@ describe('terminal live input commit hook', () => {
|
||||
// 'XYZZY…' replayed and not DELs erasing PTY chars that never arrived
|
||||
expect(captures.at(-1)).toBe('')
|
||||
const sentBeforeRecovery = sent.length
|
||||
handlers.handleLiveInputChange('echo CLEANLINE')
|
||||
changeLiveInput(handlers, 'echo CLEANLINE')
|
||||
await vi.waitFor(() => expect(sent.slice(sentBeforeRecovery)).toEqual(['echo CLEANLINE']))
|
||||
})
|
||||
|
||||
@@ -363,11 +404,11 @@ describe('terminal live input commit hook', () => {
|
||||
const { handlers, sent, setConnected } = createTerminalLiveInputCommitHarness({
|
||||
sendResult: false
|
||||
})
|
||||
handlers.handleLiveInputChange('한')
|
||||
changeLiveInput(handlers, '한')
|
||||
|
||||
// When
|
||||
setConnected(false)
|
||||
await vi.advanceTimersByTimeAsync(TERMINAL_LIVE_HELD_SYLLABLE_COMMIT_DELAY_MS)
|
||||
await vi.advanceTimersByTimeAsync(TERMINAL_LIVE_HELD_PREEDIT_COMMIT_DELAY_MS)
|
||||
|
||||
// Then: the outage cleared the held text before the timer could send it
|
||||
expect(sent).toEqual([])
|
||||
|
||||
@@ -17,6 +17,16 @@ type TerminalLiveInputKeyPressEvent = {
|
||||
}
|
||||
}
|
||||
|
||||
/** `isComposing` is the text system's marked-text range, forwarded by the pinned
|
||||
* react-native patch on iOS; `onChangeText` would drop the payload entirely.
|
||||
* Absent means the platform reports no range — not "not composing". */
|
||||
type TerminalLiveInputChangeEvent = {
|
||||
readonly nativeEvent: {
|
||||
readonly text: string
|
||||
readonly isComposing?: boolean
|
||||
}
|
||||
}
|
||||
|
||||
type TerminalLiveInputCommitOptions<TTabType extends string> = {
|
||||
readonly activeHandle: string | null
|
||||
readonly activeHandleRef: RefObject<string | null>
|
||||
@@ -36,7 +46,7 @@ type TerminalLiveInputCommitHandlers = {
|
||||
readonly handleLiveInputAccessoryBytes: (
|
||||
input: TerminalLiveAccessoryInput
|
||||
) => Promise<TerminalLiveAccessoryInputCommitResult>
|
||||
readonly handleLiveInputChange: (text: string) => void
|
||||
readonly handleLiveInputChange: (event: TerminalLiveInputChangeEvent) => void
|
||||
readonly handleLiveInputKeyPress: (event: TerminalLiveInputKeyPressEvent) => void
|
||||
readonly handleLiveInputSubmit: () => void
|
||||
}
|
||||
@@ -113,7 +123,7 @@ export function useTerminalLiveInputCommit<TTabType extends string>({
|
||||
)
|
||||
|
||||
const handleLiveInputChange = useCallback(
|
||||
(text: string) => {
|
||||
({ nativeEvent }: TerminalLiveInputChangeEvent) => {
|
||||
if (!activeHandle || !liveInputTerminalHandles.has(activeHandle)) {
|
||||
clearPendingLiveInputCommit()
|
||||
return
|
||||
@@ -121,8 +131,12 @@ export function useTerminalLiveInputCommit<TTabType extends string>({
|
||||
// Why: iOS kills an active dictation/IME session when JS writes a value
|
||||
// that differs from the native field text, so the controlled capture must
|
||||
// echo the field verbatim; only the PTY mirror sees normalized text.
|
||||
setLiveInputCapture(text)
|
||||
applyLiveInputMirror(activeHandle, normalizeTerminalTextInput(text))
|
||||
setLiveInputCapture(nativeEvent.text)
|
||||
applyLiveInputMirror(
|
||||
activeHandle,
|
||||
normalizeTerminalTextInput(nativeEvent.text),
|
||||
nativeEvent.isComposing
|
||||
)
|
||||
},
|
||||
[
|
||||
activeHandle,
|
||||
|
||||
@@ -4,8 +4,8 @@ import type { TerminalLiveInputSender } from './terminal-live-input-sender'
|
||||
import {
|
||||
buildTerminalLiveMirrorPayload,
|
||||
computeTerminalLiveMirrorStep,
|
||||
TERMINAL_LIVE_HELD_SYLLABLE_COMMIT_DELAY_MS
|
||||
} from './terminal-live-hangul-mirror'
|
||||
TERMINAL_LIVE_HELD_PREEDIT_COMMIT_DELAY_MS
|
||||
} from './terminal-live-preedit-mirror'
|
||||
import {
|
||||
cancelTerminalLivePendingFlush,
|
||||
createTerminalLivePendingFlushState,
|
||||
@@ -22,8 +22,15 @@ type TerminalLivePendingInputFlushOptions<TTabType extends string> = {
|
||||
readonly setLiveInputCapture: (text: string) => void
|
||||
}
|
||||
|
||||
type RunTerminalLiveMirrorStep = (
|
||||
handle: string,
|
||||
fieldText: string,
|
||||
commitHeld: boolean,
|
||||
composing?: boolean
|
||||
) => Promise<boolean>
|
||||
|
||||
type TerminalLivePendingInputFlush = {
|
||||
readonly applyLiveInputMirror: (handle: string, fieldText: string) => void
|
||||
readonly applyLiveInputMirror: (handle: string, fieldText: string, composing?: boolean) => void
|
||||
readonly clearPendingLiveInputCommit: () => void
|
||||
readonly flushPendingLiveInputText: (expectedHandle: string | null) => Promise<boolean>
|
||||
readonly heldLiveInputTextRef: RefObject<string>
|
||||
@@ -45,9 +52,7 @@ export function useTerminalLivePendingInputFlush<TTabType extends string>({
|
||||
const heldLiveInputTextRef = useRef('')
|
||||
const sentLiveInputTextRef = useRef('')
|
||||
const pendingLiveInputHandleRef = useRef<string | null>(null)
|
||||
const runMirrorStepRef = useRef<
|
||||
(handle: string, fieldText: string, commitHeld: boolean) => Promise<boolean>
|
||||
>(async () => false)
|
||||
const runMirrorStepRef = useRef<RunTerminalLiveMirrorStep>(async () => false)
|
||||
|
||||
const clearHeldCommitTimer = useCallback(() => {
|
||||
if (heldCommitTimerRef.current) {
|
||||
@@ -80,8 +85,8 @@ export function useTerminalLivePendingInputFlush<TTabType extends string>({
|
||||
[sendLiveTerminalInputRef]
|
||||
)
|
||||
|
||||
const runMirrorStep = useCallback(
|
||||
async (handle: string, fieldText: string, commitHeld: boolean): Promise<boolean> => {
|
||||
const runMirrorStep = useCallback<RunTerminalLiveMirrorStep>(
|
||||
async (handle, fieldText, commitHeld, composing) => {
|
||||
if (
|
||||
handle !== activeHandleRef.current ||
|
||||
(activeSessionTabTypeRef.current != null &&
|
||||
@@ -96,7 +101,8 @@ export function useTerminalLivePendingInputFlush<TTabType extends string>({
|
||||
}
|
||||
|
||||
const step = computeTerminalLiveMirrorStep(sentLiveInputTextRef.current, fieldText, {
|
||||
commitHeld
|
||||
commitHeld,
|
||||
composing
|
||||
})
|
||||
sentLiveInputTextRef.current = step.nextSentText
|
||||
heldLiveInputTextRef.current = step.heldText
|
||||
@@ -104,12 +110,15 @@ export function useTerminalLivePendingInputFlush<TTabType extends string>({
|
||||
step.heldText.length > 0 || step.nextSentText.length > 0 ? handle : null
|
||||
|
||||
clearHeldCommitTimer()
|
||||
if (step.heldText.length > 0) {
|
||||
// Why: text the platform positively marked as preedit is not text yet, so
|
||||
// no idle timer may commit it. Only an unreported hold is a guess that has
|
||||
// to settle on its own.
|
||||
if (step.heldText.length > 0 && composing === undefined) {
|
||||
heldCommitTimerRef.current = setTimeout(() => {
|
||||
heldCommitTimerRef.current = null
|
||||
const heldField = sentLiveInputTextRef.current + heldLiveInputTextRef.current
|
||||
void runMirrorStepRef.current(handle, heldField, true)
|
||||
}, TERMINAL_LIVE_HELD_SYLLABLE_COMMIT_DELAY_MS)
|
||||
}, TERMINAL_LIVE_HELD_PREEDIT_COMMIT_DELAY_MS)
|
||||
}
|
||||
|
||||
const payload = buildTerminalLiveMirrorPayload(step)
|
||||
@@ -140,8 +149,8 @@ export function useTerminalLivePendingInputFlush<TTabType extends string>({
|
||||
}, [runMirrorStep])
|
||||
|
||||
const applyLiveInputMirror = useCallback(
|
||||
(handle: string, fieldText: string): void => {
|
||||
void runMirrorStep(handle, fieldText, false)
|
||||
(handle: string, fieldText: string, composing?: boolean): void => {
|
||||
void runMirrorStep(handle, fieldText, false, composing)
|
||||
},
|
||||
[runMirrorStep]
|
||||
)
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { formatTerminalFocus } from './terminal-format'
|
||||
import { formatTerminalClose, formatTerminalFocus } from './terminal-format'
|
||||
|
||||
describe('formatTerminalFocus', () => {
|
||||
it('distinguishes superseded navigation from a winning focus', () => {
|
||||
@@ -22,3 +22,40 @@ describe('formatTerminalFocus', () => {
|
||||
).toBe('Focused terminal term_winner (tab tab-winner).')
|
||||
})
|
||||
})
|
||||
|
||||
describe('formatTerminalClose', () => {
|
||||
it('prints "PTY killed." only for a confirmed kill', () => {
|
||||
expect(
|
||||
formatTerminalClose({ close: { handle: 'term_local', tabId: 'tab-1', ptyKilled: true } })
|
||||
).toBe('Closed terminal term_local. PTY killed.')
|
||||
})
|
||||
|
||||
it('says the remote process was not confirmed stopped instead of claiming a kill', () => {
|
||||
expect(
|
||||
formatTerminalClose({
|
||||
close: {
|
||||
handle: 'term_remote',
|
||||
tabId: 'tab-1',
|
||||
ptyKilled: false,
|
||||
ptyStopVerdict: 'unverifiable',
|
||||
ptyStopReason: 'its SSH provider is no longer registered'
|
||||
}
|
||||
})
|
||||
).toBe(
|
||||
'Closed terminal term_remote. The PTY was not confirmed stopped: its SSH provider is no longer registered.'
|
||||
)
|
||||
})
|
||||
|
||||
it('names a PTY known to be live', () => {
|
||||
expect(
|
||||
formatTerminalClose({
|
||||
close: {
|
||||
handle: 'term_live',
|
||||
tabId: 'tab-1',
|
||||
ptyKilled: false,
|
||||
ptyStopVerdict: 'live'
|
||||
}
|
||||
})
|
||||
).toBe('Closed terminal term_live. The PTY is live.')
|
||||
})
|
||||
})
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { PTY_LIVE_NOTE, describeUnconfirmedStop } from '../shared/pty-liveness-verdict'
|
||||
import type {
|
||||
RuntimeTerminalClose,
|
||||
RuntimeTerminalCreate,
|
||||
@@ -185,12 +186,25 @@ export function formatTerminalFocus(result: { focus: RuntimeTerminalFocus }): st
|
||||
return `Focused terminal ${result.focus.handle} (tab ${result.focus.tabId}).`
|
||||
}
|
||||
|
||||
/** "PTY killed." is a claim of observed death, so only a confirmed kill earns it. */
|
||||
function describePtyStop(close: RuntimeTerminalClose): string {
|
||||
if (close.ptyKilled) {
|
||||
return ' PTY killed.'
|
||||
}
|
||||
if (close.ptyStopVerdict === 'live') {
|
||||
return ` ${PTY_LIVE_NOTE}`
|
||||
}
|
||||
if (close.ptyStopVerdict === 'unverifiable') {
|
||||
return ` ${describeUnconfirmedStop(close.ptyStopReason ?? 'its host could not be reached')}`
|
||||
}
|
||||
return ''
|
||||
}
|
||||
|
||||
export function formatTerminalClose(result: { close: RuntimeTerminalClose }): string {
|
||||
if (result.close.closeMode === 'tab') {
|
||||
return `Closed terminal tab ${result.close.tabId} (${result.close.handle}).`
|
||||
}
|
||||
const ptyNote = result.close.ptyKilled ? ' PTY killed.' : ''
|
||||
return `Closed terminal ${result.close.handle}.${ptyNote}`
|
||||
return `Closed terminal ${result.close.handle}.${describePtyStop(result.close)}`
|
||||
}
|
||||
|
||||
export function formatTerminalWait(result: { wait: RuntimeTerminalWait }): string {
|
||||
|
||||
@@ -85,12 +85,7 @@ describe('recordProcessGoneCrash killed/1 ordering', () => {
|
||||
|
||||
recordProcessGoneCrash({ record } as never, gpuKill, dedupe)
|
||||
recordProcessGoneCrash({ record } as never, networkServiceKill, dedupe)
|
||||
recordProcessGoneCrash(
|
||||
{ record, attachDetails } as never,
|
||||
rendererKill,
|
||||
dedupe,
|
||||
noMinidump
|
||||
)
|
||||
recordProcessGoneCrash({ record, attachDetails } as never, rendererKill, dedupe, noMinidump)
|
||||
|
||||
expect(record).toHaveBeenCalledOnce()
|
||||
// Timing proximity is evidence, not authority to discard an ambiguous report.
|
||||
|
||||
@@ -439,7 +439,9 @@ describe('recordProcessGoneCrash', () => {
|
||||
// Why child kills: the decode gate is source-agnostic and reads
|
||||
// process.platform synchronously at record time, so the platform stub is
|
||||
// still in force when it runs.
|
||||
const nonRecoverableChildKill = (overrides: Partial<ProcessGoneCrashEvent>): ProcessGoneCrashEvent =>
|
||||
const nonRecoverableChildKill = (
|
||||
overrides: Partial<ProcessGoneCrashEvent>
|
||||
): ProcessGoneCrashEvent =>
|
||||
event({
|
||||
source: 'child',
|
||||
processType: 'Utility',
|
||||
|
||||
@@ -66,6 +66,9 @@ import { recognizeAgentProcessFromCommandLine } from '../../shared/agent-process
|
||||
import { shouldUseShellReadyStartupDelivery } from '../../shared/codex-startup-delivery'
|
||||
import type { PtyIncarnationId } from '../../shared/pty-incarnation'
|
||||
import { resolveSafePtyDefaultCwd } from '../providers/pty-default-cwd'
|
||||
import { resolveUnixShellPath } from '../providers/local-pty-utils'
|
||||
import { injectHistoryEnv, injectWslFishHistoryEnv, logHistoryInjection } from '../terminal-history'
|
||||
import { addWslEnvKeys } from '../wsl-env'
|
||||
import { PtyWriteUnavailableError } from '../providers/pty-write-unavailable-error'
|
||||
import { ColdRestorePayloadCache, type ColdRestorePayload } from './cold-restore-payload-cache'
|
||||
import { CheckpointSessionQueue } from './daemon-checkpoint-session-queue'
|
||||
@@ -425,7 +428,8 @@ export class DaemonPtyAdapter implements IPtyProvider {
|
||||
}
|
||||
|
||||
async spawn(opts: PtySpawnOptions): Promise<PtySpawnResult> {
|
||||
const sessionId = opts.sessionId ?? mintPtySessionId(opts.worktreeId)
|
||||
const spawnOpts = this.withHistoryIsolation(opts)
|
||||
const sessionId = spawnOpts.sessionId ?? mintPtySessionId(spawnOpts.worktreeId)
|
||||
const operation = {
|
||||
exitsBySessionId: new Map<string, { incarnationId?: string }[]>(),
|
||||
ignoredExitIncarnationIds: new Set<string>(),
|
||||
@@ -444,7 +448,9 @@ export class DaemonPtyAdapter implements IPtyProvider {
|
||||
}
|
||||
try {
|
||||
return await this.withHistorySpawnLock(sessionId, () =>
|
||||
this.withDaemonRetry(() => this.doSpawn({ ...opts, sessionId }, operation, historyRecovery))
|
||||
this.withDaemonRetry(() =>
|
||||
this.doSpawn({ ...spawnOpts, sessionId }, operation, historyRecovery)
|
||||
)
|
||||
)
|
||||
} finally {
|
||||
if (historyRecovery.freeze) {
|
||||
@@ -458,6 +464,44 @@ export class DaemonPtyAdapter implements IPtyProvider {
|
||||
}
|
||||
}
|
||||
|
||||
private withHistoryIsolation(opts: PtySpawnOptions): PtySpawnOptions {
|
||||
const wslContext = resolveWslSessionContext({
|
||||
cwd: opts.cwd,
|
||||
sessionId: opts.sessionId,
|
||||
shellOverride: opts.shellOverride,
|
||||
terminalWindowsWslDistro: opts.terminalWindowsWslDistro
|
||||
})
|
||||
if (
|
||||
opts.attachOnly === true ||
|
||||
(opts.sessionId !== undefined && opts.isNewSession !== true) ||
|
||||
!opts.worktreeId ||
|
||||
opts.historyIsolationEnabled !== true ||
|
||||
(process.platform === 'win32' && !wslContext)
|
||||
) {
|
||||
return opts
|
||||
}
|
||||
const env = { ...opts.env }
|
||||
const preferredShell = wslContext
|
||||
? 'bash'
|
||||
: opts.shellOverride || env.SHELL || process.env.SHELL || '/bin/zsh'
|
||||
const shellPath = resolveUnixShellPath(preferredShell)
|
||||
const historyArgs = [
|
||||
env,
|
||||
opts.worktreeId,
|
||||
shellPath,
|
||||
opts.cwd ?? resolveSafePtyDefaultCwd()
|
||||
] as const
|
||||
const result = wslContext
|
||||
? injectHistoryEnv(...historyArgs, { wslDistro: wslContext.distro })
|
||||
: injectHistoryEnv(...historyArgs)
|
||||
if (wslContext) {
|
||||
injectWslFishHistoryEnv(env, opts.worktreeId, wslContext.distro)
|
||||
addWslEnvKeys(env, ['HISTFILE', 'fish_history'])
|
||||
}
|
||||
logHistoryInjection(opts.worktreeId, result)
|
||||
return { ...opts, env }
|
||||
}
|
||||
|
||||
private async doSpawn(
|
||||
opts: PtySpawnOptions,
|
||||
operation: PendingDaemonSpawnOperation,
|
||||
|
||||
@@ -1,6 +1,10 @@
|
||||
import { getPosixOmpShellWrapper } from '../pty/omp-shell-wrapper'
|
||||
import { getPosixCodexShellLaunchPreflight } from '../pty/codex-shell-launch-preflight'
|
||||
import { getZshFinalZdotdirRestoreBlock, getZshStartupFileSourceBlock } from '../shell-templates'
|
||||
import {
|
||||
getZshFinalZdotdirRestoreBlock,
|
||||
getZshStartupFileSourceBlock,
|
||||
ZSH_HISTFILE_RESTORE_BLOCK
|
||||
} from '../shell-templates'
|
||||
|
||||
export function getDaemonZshShellReadyRcfileContent(): string {
|
||||
return `# Orca daemon zsh shell-ready wrapper
|
||||
@@ -23,6 +27,7 @@ if [[ ! -o login ]]; then
|
||||
[[ -n "\${ORCA_MIMOCODE_HOME:-}" ]] && export MIMOCODE_HOME="\${ORCA_MIMOCODE_HOME}"
|
||||
${getPosixOmpShellWrapper()}
|
||||
[[ -n "\${ORCA_CODEX_HOME:-}" ]] && export CODEX_HOME="\${ORCA_CODEX_HOME}"
|
||||
${ZSH_HISTFILE_RESTORE_BLOCK}
|
||||
${getPosixCodexShellLaunchPreflight()}
|
||||
fi
|
||||
__orca_osc133_precmd() {
|
||||
|
||||
@@ -16,7 +16,8 @@ import {
|
||||
getZshEnvTemplate,
|
||||
getZshFinalZdotdirRestoreBlock,
|
||||
getZshShellReadyMarkerRegistrationBlock,
|
||||
getZshStartupFileSourceBlock
|
||||
getZshStartupFileSourceBlock,
|
||||
ZSH_HISTFILE_RESTORE_BLOCK
|
||||
} from '../shell-templates'
|
||||
import { SHELL_READY_MARKER } from './daemon-shell-ready-marker'
|
||||
import { getDaemonBashShellReadyRcfileContent } from './daemon-bash-shell-ready-rcfile'
|
||||
@@ -121,6 +122,7 @@ __orca_restore_agent_teams_path
|
||||
[[ -n "\${ORCA_MIMOCODE_HOME:-}" ]] && export MIMOCODE_HOME="\${ORCA_MIMOCODE_HOME}"
|
||||
${getPosixOmpShellWrapper()}
|
||||
[[ -n "\${ORCA_CODEX_HOME:-}" ]] && export CODEX_HOME="\${ORCA_CODEX_HOME}"
|
||||
${ZSH_HISTFILE_RESTORE_BLOCK}
|
||||
${getPosixCodexShellLaunchPreflight()}
|
||||
${getZshShellReadyMarkerRegistrationBlock(SHELL_READY_MARKER)}
|
||||
${getZshFinalZdotdirRestoreBlock()}
|
||||
|
||||
@@ -0,0 +1,238 @@
|
||||
import { existsSync, mkdirSync, mkdtempSync, rmSync, symlinkSync, writeFileSync } from 'node:fs'
|
||||
import { tmpdir } from 'node:os'
|
||||
import { join } from 'node:path'
|
||||
import { afterEach, beforeEach, describe, expect, it } from 'vitest'
|
||||
import {
|
||||
deleteFishHistoryFile,
|
||||
fishHistorySessionName,
|
||||
isSafeFishHistorySession,
|
||||
relayFishHistorySessionName,
|
||||
resolveFishHistoryDir,
|
||||
sweepOrphanedFishHistoryFiles
|
||||
} from './fish-history-session'
|
||||
|
||||
const HASH_A = 'a1b2c3d4e5f60718'
|
||||
const HASH_B = '00112233445566ff'
|
||||
const SESSION_A = fishHistorySessionName(HASH_A)
|
||||
|
||||
describe('fish history session naming', () => {
|
||||
it('mints a session name that is a valid fish variable value', () => {
|
||||
// fish falls back to the shared default for anything that is not a valid
|
||||
// variable name, which would silently un-isolate the worktree.
|
||||
expect(SESSION_A).toBe('orca_a1b2c3d4e5f60718')
|
||||
expect(SESSION_A).toMatch(/^[A-Za-z_][A-Za-z0-9_]*$/)
|
||||
})
|
||||
|
||||
it.each([
|
||||
['orca_deadbeef', true],
|
||||
['orca_', false],
|
||||
['fish_history', false],
|
||||
['orca_../../etc/passwd', false],
|
||||
['orca_DEADBEEF', false],
|
||||
['', false],
|
||||
[undefined, false],
|
||||
[42, false]
|
||||
])('accepts %s as a safe session name: %s', (value, expected) => {
|
||||
expect(isSafeFishHistorySession(value)).toBe(expected)
|
||||
})
|
||||
})
|
||||
|
||||
describe('fish history directory resolution', () => {
|
||||
// Why join rather than literals: the separator is platform-specific, and on
|
||||
// Windows `isAbsolute('/data')` is also true, so a hardcoded '/data/fish'
|
||||
// fails there for a reason that has nothing to do with the behavior tested.
|
||||
it('follows XDG_DATA_HOME when it is absolute', () => {
|
||||
expect(resolveFishHistoryDir({ XDG_DATA_HOME: '/data', HOME: '/home/me' })).toBe(
|
||||
join('/data', 'fish')
|
||||
)
|
||||
})
|
||||
|
||||
it('ignores a relative XDG_DATA_HOME the way fish does', () => {
|
||||
expect(resolveFishHistoryDir({ XDG_DATA_HOME: 'relative', HOME: '/home/me' })).toBe(
|
||||
join('/home/me', '.local', 'share', 'fish')
|
||||
)
|
||||
})
|
||||
|
||||
it('falls back to HOME when XDG_DATA_HOME is unset', () => {
|
||||
expect(resolveFishHistoryDir({ HOME: '/home/me' })).toBe(
|
||||
join('/home/me', '.local', 'share', 'fish')
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
describe('fish history deletion', () => {
|
||||
let root: string
|
||||
let fishDir: string
|
||||
|
||||
beforeEach(() => {
|
||||
root = mkdtempSync(join(tmpdir(), 'orca-fish-history-'))
|
||||
fishDir = join(root, 'fish')
|
||||
mkdirSync(fishDir, { recursive: true })
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
rmSync(root, { recursive: true, force: true })
|
||||
})
|
||||
|
||||
const historyFile = (session: string): string => join(fishDir, `${session}_history`)
|
||||
|
||||
it('deletes the session file rather than truncating it', () => {
|
||||
writeFileSync(historyFile(SESSION_A), '- cmd: ls\n')
|
||||
|
||||
expect(deleteFishHistoryFile(SESSION_A, [fishDir])).toBe(true)
|
||||
expect(existsSync(historyFile(SESSION_A))).toBe(false)
|
||||
})
|
||||
|
||||
it('tries every candidate directory, since the PTY may not share this env', () => {
|
||||
const other = join(root, 'other-xdg', 'fish')
|
||||
mkdirSync(other, { recursive: true })
|
||||
writeFileSync(join(other, `${SESSION_A}_history`), '- cmd: ls\n')
|
||||
|
||||
expect(deleteFishHistoryFile(SESSION_A, [fishDir, other])).toBe(true)
|
||||
expect(existsSync(join(other, `${SESSION_A}_history`))).toBe(false)
|
||||
})
|
||||
|
||||
it('refuses a session name it did not mint', () => {
|
||||
const foreign = join(fishDir, 'fish_history')
|
||||
writeFileSync(foreign, 'the user’s real history\n')
|
||||
|
||||
expect(deleteFishHistoryFile('fish_history', [fishDir])).toBe(false)
|
||||
expect(existsSync(foreign)).toBe(true)
|
||||
})
|
||||
|
||||
// Why skipped on Windows: symlinkSync needs Developer Mode or elevation there.
|
||||
it.skipIf(process.platform === 'win32')(
|
||||
'refuses to follow a symlink out of the fish data dir',
|
||||
() => {
|
||||
const outside = join(root, 'precious.txt')
|
||||
writeFileSync(outside, 'keep me\n')
|
||||
symlinkSync(outside, historyFile(SESSION_A))
|
||||
|
||||
expect(deleteFishHistoryFile(SESSION_A, [fishDir])).toBe(false)
|
||||
expect(existsSync(outside)).toBe(true)
|
||||
}
|
||||
)
|
||||
|
||||
it('reports false when there is nothing to delete', () => {
|
||||
expect(deleteFishHistoryFile(SESSION_A, [fishDir])).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
describe('orphaned fish history sweep', () => {
|
||||
let root: string
|
||||
let fishDir: string
|
||||
|
||||
beforeEach(() => {
|
||||
root = mkdtempSync(join(tmpdir(), 'orca-fish-sweep-'))
|
||||
fishDir = join(root, 'fish')
|
||||
mkdirSync(fishDir, { recursive: true })
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
rmSync(root, { recursive: true, force: true })
|
||||
})
|
||||
|
||||
it('removes files for dead worktrees and keeps live ones', () => {
|
||||
writeFileSync(join(fishDir, `${fishHistorySessionName(HASH_A)}_history`), 'live\n')
|
||||
writeFileSync(join(fishDir, `${fishHistorySessionName(HASH_B)}_history`), 'dead\n')
|
||||
|
||||
expect(sweepOrphanedFishHistoryFiles(new Set([HASH_A]), [fishDir])).toBe(1)
|
||||
expect(existsSync(join(fishDir, `${fishHistorySessionName(HASH_A)}_history`))).toBe(true)
|
||||
expect(existsSync(join(fishDir, `${fishHistorySessionName(HASH_B)}_history`))).toBe(false)
|
||||
})
|
||||
|
||||
it("never touches the user's own history files", () => {
|
||||
// The whole safety of the sweep rests on the orca_<hex>_ prefix.
|
||||
const untouched = ['fish_history', 'work_history', 'orca_history', 'orca_nothex_history']
|
||||
for (const name of untouched) {
|
||||
writeFileSync(join(fishDir, name), 'mine\n')
|
||||
}
|
||||
|
||||
expect(sweepOrphanedFishHistoryFiles(new Set([HASH_A]), [fishDir])).toBe(0)
|
||||
for (const name of untouched) {
|
||||
expect(existsSync(join(fishDir, name))).toBe(true)
|
||||
}
|
||||
})
|
||||
|
||||
it('refuses to sweep on an empty live set, which cannot be told from a cold store', () => {
|
||||
writeFileSync(join(fishDir, `${fishHistorySessionName(HASH_B)}_history`), 'dead\n')
|
||||
|
||||
expect(sweepOrphanedFishHistoryFiles(new Set(), [fishDir])).toBe(0)
|
||||
expect(existsSync(join(fishDir, `${fishHistorySessionName(HASH_B)}_history`))).toBe(true)
|
||||
})
|
||||
|
||||
it('spares a file younger than the age guard, since the live set is a snapshot', () => {
|
||||
// The reachable race: a worktree created after the snapshot but before the
|
||||
// sweep looks dead while it is actively writing the history it owns.
|
||||
const fresh = join(fishDir, `${fishHistorySessionName(HASH_B)}_history`)
|
||||
writeFileSync(fresh, 'just created\n')
|
||||
|
||||
expect(sweepOrphanedFishHistoryFiles(new Set([HASH_A]), [fishDir], 5 * 60 * 1000)).toBe(0)
|
||||
expect(existsSync(fresh)).toBe(true)
|
||||
})
|
||||
|
||||
it('sweeps a file older than the age guard', () => {
|
||||
const stale = join(fishDir, `${fishHistorySessionName(HASH_B)}_history`)
|
||||
writeFileSync(stale, 'long dead\n')
|
||||
const wellPast = Date.now() + 60 * 60 * 1000
|
||||
|
||||
expect(
|
||||
sweepOrphanedFishHistoryFiles(new Set([HASH_A]), [fishDir], 5 * 60 * 1000, wellPast)
|
||||
).toBe(1)
|
||||
expect(existsSync(stale)).toBe(false)
|
||||
})
|
||||
|
||||
it('tolerates a directory that does not exist', () => {
|
||||
expect(sweepOrphanedFishHistoryFiles(new Set([HASH_A]), [join(root, 'absent')])).toBe(0)
|
||||
})
|
||||
|
||||
it('sweeps every candidate directory once', () => {
|
||||
const other = join(root, 'other', 'fish')
|
||||
mkdirSync(other, { recursive: true })
|
||||
for (const dir of [fishDir, other]) {
|
||||
writeFileSync(join(dir, `${fishHistorySessionName(HASH_B)}_history`), 'dead\n')
|
||||
}
|
||||
|
||||
expect(sweepOrphanedFishHistoryFiles(new Set([HASH_A]), [fishDir, other, fishDir])).toBe(2)
|
||||
})
|
||||
})
|
||||
|
||||
/**
|
||||
* A relay host keyed by its CLIENT's worktree ids shares one fish data dir with
|
||||
* any desktop Orca on the same machine, whose live set knows nothing of those
|
||||
* ids. The name is the only thing that keeps that sweep off remote history.
|
||||
*/
|
||||
describe('relay fish history naming', () => {
|
||||
let relayRoot: string
|
||||
|
||||
beforeEach(() => {
|
||||
relayRoot = mkdtempSync(join(tmpdir(), 'orca-fish-relay-'))
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
rmSync(relayRoot, { recursive: true, force: true })
|
||||
})
|
||||
|
||||
it('is namespaced apart from desktop session names', () => {
|
||||
expect(relayFishHistorySessionName(HASH_A)).not.toBe(fishHistorySessionName(HASH_A))
|
||||
})
|
||||
|
||||
it('is still a safe session name, so the relay can delete it by name', () => {
|
||||
expect(isSafeFishHistorySession(relayFishHistorySessionName(HASH_A))).toBe(true)
|
||||
})
|
||||
|
||||
it('survives a desktop sweep that knows none of the relay worktree ids', () => {
|
||||
const dir = relayRoot
|
||||
const relayFile = join(dir, `${relayFishHistorySessionName(HASH_A)}_history`)
|
||||
const desktopFile = join(dir, `${fishHistorySessionName(HASH_B)}_history`)
|
||||
writeFileSync(relayFile, 'relay')
|
||||
writeFileSync(desktopFile, 'desktop')
|
||||
|
||||
// A live set with neither hash in it: everything attributable is orphaned.
|
||||
const removed = sweepOrphanedFishHistoryFiles(new Set(['deadbeef']), [dir])
|
||||
|
||||
expect(removed).toBe(1)
|
||||
expect(existsSync(desktopFile)).toBe(false)
|
||||
expect(existsSync(relayFile)).toBe(true)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,139 @@
|
||||
import { lstatSync, readdirSync, rmSync } from 'node:fs'
|
||||
import { homedir } from 'node:os'
|
||||
import { isAbsolute, join } from 'node:path'
|
||||
|
||||
/**
|
||||
* Per-worktree fish history.
|
||||
*
|
||||
* fish ignores HISTFILE entirely — history lives at `<data dir>/<name>_history`
|
||||
* where `<name>` comes only from the `fish_history` variable, and there is no
|
||||
* custom-directory knob. So the session NAME is the only isolation lever, and
|
||||
* the file necessarily lands in the user's fish data dir rather than inside
|
||||
* Orca's history tree. That is why deletion needs its own path here instead of
|
||||
* riding the tree tombstone the way bash/zsh history does.
|
||||
*
|
||||
* `XDG_DATA_HOME` is deliberately not redirected: that would move every other
|
||||
* tool's data for the session, not just fish's history.
|
||||
*/
|
||||
const SESSION_PREFIX = 'orca_'
|
||||
const RELAY_SESSION_PREFIX = 'orca_relay_'
|
||||
const SAFE_SESSION_NAME = /^orca_(?:relay_)?[0-9a-f]{1,64}$/
|
||||
/** Deliberately does NOT match the relay prefix. A relay host keyed by its
|
||||
* CLIENT's worktree ids shares the one fish data dir with any desktop Orca on
|
||||
* the same machine, whose live set knows nothing of those ids — so an
|
||||
* attributable name is the only thing keeping that sweep off remote history.
|
||||
* Relay files are removed by exact name when their worktree goes away. */
|
||||
const ORCA_HISTORY_FILE = /^orca_([0-9a-f]{1,64})_history$/
|
||||
|
||||
export function isSafeFishHistorySession(session: unknown): session is string {
|
||||
return typeof session === 'string' && SAFE_SESSION_NAME.test(session)
|
||||
}
|
||||
|
||||
export function fishHistorySessionName(worktreeHash: string): string {
|
||||
return `${SESSION_PREFIX}${worktreeHash}`
|
||||
}
|
||||
|
||||
/** Relay-owned counterpart, namespaced out of the desktop sweep's reach. */
|
||||
export function relayFishHistorySessionName(worktreeHash: string): string {
|
||||
return `${RELAY_SESSION_PREFIX}${worktreeHash}`
|
||||
}
|
||||
|
||||
/** The directory fish will write history into, for a given environment.
|
||||
* Resolve this from the PTY's SPAWN env: Orca can be launched with a different
|
||||
* `XDG_DATA_HOME`/`HOME` than the shells it spawns, and fish follows its own. */
|
||||
export function resolveFishHistoryDir(env: NodeJS.ProcessEnv = process.env): string {
|
||||
const xdg = env.XDG_DATA_HOME?.trim()
|
||||
const dataHome =
|
||||
xdg && isAbsolute(xdg) ? xdg : join(env.HOME?.trim() || homedir(), '.local', 'share')
|
||||
return join(dataHome, 'fish')
|
||||
}
|
||||
|
||||
/** Removes one history file, refusing anything that is not a regular file.
|
||||
* Why lstat: a symlink here would take `rmSync` out of the fish data dir. */
|
||||
function removeHistoryFile(path: string): boolean {
|
||||
try {
|
||||
if (!lstatSync(path).isFile()) {
|
||||
return false
|
||||
}
|
||||
rmSync(path)
|
||||
return true
|
||||
} catch {
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
/** Delete one worktree's fish history file, trying every directory it could be in.
|
||||
* Callers pass the directory recorded at spawn time plus this process's own, since
|
||||
* the two disagree whenever Orca's environment differs from the PTY's. */
|
||||
export function deleteFishHistoryFile(session: string, dirs: Iterable<string>): boolean {
|
||||
if (!isSafeFishHistorySession(session)) {
|
||||
return false
|
||||
}
|
||||
let removed = false
|
||||
for (const dir of new Set(dirs)) {
|
||||
removed = removeHistoryFile(join(dir, `${session}_history`)) || removed
|
||||
}
|
||||
return removed
|
||||
}
|
||||
|
||||
/**
|
||||
* Delete every Orca-minted fish history file whose worktree is gone.
|
||||
*
|
||||
* Why a sweep and not just per-worktree deletion: the history file outlives the
|
||||
* directory that names it. A crash between tombstone and removal, a hand-deleted
|
||||
* history dir, or a file written by an older build all orphan one, and only a
|
||||
* scan of the data dir can find those. Matching on Orca's own `orca_<hex>_`
|
||||
* prefix means the user's real `fish_history` is never a candidate.
|
||||
*
|
||||
* `minAgeMs` mirrors the age guard the history-tree GC already applies: the
|
||||
* live-worktree set is a snapshot, so a worktree created between the snapshot
|
||||
* and this sweep would otherwise look dead and lose the history it is actively
|
||||
* writing. A live session's file keeps a recent mtime, so this doubles as a
|
||||
* second check on the set itself.
|
||||
*
|
||||
* Refuses to run at all on an empty live set: that cannot be told apart from a
|
||||
* store that failed to hydrate, and unlike the tree GC this deletes files
|
||||
* outside Orca's own directory.
|
||||
*/
|
||||
export function sweepOrphanedFishHistoryFiles(
|
||||
liveWorktreeHashes: ReadonlySet<string>,
|
||||
dirs: Iterable<string>,
|
||||
minAgeMs = 0,
|
||||
now = Date.now()
|
||||
): number {
|
||||
if (liveWorktreeHashes.size === 0) {
|
||||
return 0
|
||||
}
|
||||
let removed = 0
|
||||
for (const dir of new Set(dirs)) {
|
||||
let entries: string[]
|
||||
try {
|
||||
entries = readdirSync(dir)
|
||||
} catch {
|
||||
continue
|
||||
}
|
||||
for (const entry of entries) {
|
||||
const hash = ORCA_HISTORY_FILE.exec(entry)?.[1]
|
||||
if (!hash || liveWorktreeHashes.has(hash)) {
|
||||
continue
|
||||
}
|
||||
const path = join(dir, entry)
|
||||
if (minAgeMs > 0 && !isOlderThan(path, minAgeMs, now)) {
|
||||
continue
|
||||
}
|
||||
if (removeHistoryFile(path)) {
|
||||
removed += 1
|
||||
}
|
||||
}
|
||||
}
|
||||
return removed
|
||||
}
|
||||
|
||||
/** Treats an unreadable file as too young: never delete on a failed stat. */
|
||||
function isOlderThan(path: string, minAgeMs: number, now: number): boolean {
|
||||
try {
|
||||
return now - lstatSync(path).mtimeMs >= minAgeMs
|
||||
} catch {
|
||||
return false
|
||||
}
|
||||
}
|
||||
@@ -97,6 +97,15 @@ describe('remote URL probe', () => {
|
||||
await expect(assertRemoteUrlReadable({ repoPath: '/repo' })).resolves.toBeUndefined()
|
||||
})
|
||||
|
||||
it('keeps a failed SSH command unverifiable even without a transport error string', async () => {
|
||||
const failure = new Error('relay request failed')
|
||||
getSshGitProviderMock.mockReturnValue({ exec: vi.fn().mockRejectedValue(failure) })
|
||||
|
||||
await expect(
|
||||
assertRemoteUrlReadable({ repoPath: '/repo', connectionId: 'ssh-1' })
|
||||
).rejects.toBe(failure)
|
||||
})
|
||||
|
||||
it('does not turn a missing SSH provider into a false readable result', async () => {
|
||||
getSshGitProviderMock.mockReturnValue(null)
|
||||
|
||||
|
||||
@@ -3,6 +3,7 @@ import {
|
||||
SSH_GIT_PROVIDER_UNAVAILABLE_MESSAGE
|
||||
} from '../providers/ssh-git-dispatch'
|
||||
import { gitExecFileAsync } from './runner'
|
||||
import { isStableMissingGitRemoteError } from './stable-missing-git-remote-error'
|
||||
|
||||
/**
|
||||
* The `git remote get-url` probe every forge integration runs to decide whether
|
||||
@@ -91,10 +92,9 @@ export function isTransientGitProbeError(error: unknown): boolean {
|
||||
}
|
||||
|
||||
/**
|
||||
* Throws when the remote could not be read because the probe was wedged or its
|
||||
* transport was gone, and returns normally for every answer — including a repo
|
||||
* with no remote at all. Lets a caller that treats "nothing found" as a cacheable
|
||||
* answer tell that apart from a lookup that never got to ask.
|
||||
* Throws when the remote could not be read, and returns normally for every
|
||||
* answer — including a repo with no remote at all. Lets a caller that treats
|
||||
* "nothing found" as cacheable tell that apart from a lookup that never got to ask.
|
||||
*/
|
||||
export async function assertRemoteUrlReadable(
|
||||
context: RemoteUrlProbeContext,
|
||||
@@ -106,8 +106,9 @@ export async function assertRemoteUrlReadable(
|
||||
try {
|
||||
await readRemoteUrl(context, remoteName)
|
||||
} catch (error) {
|
||||
if (isTransientGitProbeError(error)) {
|
||||
throw error
|
||||
if (isStableMissingGitRemoteError(error)) {
|
||||
return
|
||||
}
|
||||
throw error
|
||||
}
|
||||
}
|
||||
|
||||
@@ -85,6 +85,7 @@ vi.mock('./github-enterprise-repository', () => ({
|
||||
}))
|
||||
|
||||
vi.mock('../providers/ssh-git-dispatch', () => ({
|
||||
getSshGitProviderGeneration: vi.fn(() => 0),
|
||||
getSshGitProvider: vi.fn()
|
||||
}))
|
||||
|
||||
|
||||
@@ -0,0 +1,266 @@
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import type * as GithubApiRepositoryModule from './github-api-repository'
|
||||
import type * as GitHubEnterpriseRepositoryModule from './github-enterprise-repository'
|
||||
|
||||
const { clientMocks, moduleMocks } = await vi.hoisted(async () => {
|
||||
const moduleMocks = await import('./client-test-mocks')
|
||||
return { clientMocks: moduleMocks.createGitHubClientMocks(), moduleMocks }
|
||||
})
|
||||
|
||||
vi.mock('./gh-utils', () => moduleMocks.ghUtilsModuleMock(clientMocks))
|
||||
vi.mock('../git/runner', () => moduleMocks.gitRunnerModuleMock(clientMocks))
|
||||
vi.mock('../providers/ssh-git-dispatch', () => moduleMocks.sshGitDispatchModuleMock(clientMocks))
|
||||
vi.mock('./local-git-config-signature', () =>
|
||||
moduleMocks.localGitConfigSignatureModuleMock(clientMocks)
|
||||
)
|
||||
vi.mock('./github-enterprise-repository', async (importOriginal) =>
|
||||
moduleMocks.githubEnterpriseRepositoryModuleMock(
|
||||
await importOriginal<typeof GitHubEnterpriseRepositoryModule>()
|
||||
)
|
||||
)
|
||||
vi.mock('./rate-limit', () => moduleMocks.rateLimitModuleMock(clientMocks))
|
||||
vi.mock('./github-api-repository', async (importOriginal) =>
|
||||
moduleMocks.githubApiRepositoryModuleMock(
|
||||
clientMocks,
|
||||
await importOriginal<typeof GithubApiRepositoryModule>()
|
||||
)
|
||||
)
|
||||
|
||||
import { getPRForBranch, getPRForBranchOutcome } from './client'
|
||||
import { resetPRForBranchMocks } from './client-test-harness'
|
||||
|
||||
const {
|
||||
ghExecFileAsyncMock,
|
||||
getOwnerRepoMock,
|
||||
gitExecFileAsyncMock,
|
||||
getSshGitProviderMock,
|
||||
resolvePRRepositoryCandidatesMock
|
||||
} = clientMocks
|
||||
|
||||
const MERGED_BRANCH = 'fix-tab-strip-layout-test'
|
||||
const MERGED_HEAD_OID = 'current-head-oid'
|
||||
|
||||
function mockMergedBranchPRLookup(): void {
|
||||
ghExecFileAsyncMock
|
||||
.mockResolvedValueOnce({
|
||||
stdout: JSON.stringify([
|
||||
{
|
||||
number: 5875,
|
||||
title: 'Merged current branch PR',
|
||||
state: 'closed',
|
||||
merged_at: '2026-06-20T04:53:05Z',
|
||||
html_url: 'https://github.com/acme/widgets/pull/5875',
|
||||
updated_at: '2026-06-20T04:53:05Z',
|
||||
draft: false,
|
||||
mergeable_state: 'clean',
|
||||
head: { ref: MERGED_BRANCH, sha: MERGED_HEAD_OID },
|
||||
base: { ref: 'main', sha: 'base-oid' }
|
||||
}
|
||||
])
|
||||
})
|
||||
.mockResolvedValueOnce({
|
||||
stdout: JSON.stringify({
|
||||
number: 5875,
|
||||
title: 'Merged current branch PR',
|
||||
state: 'MERGED',
|
||||
url: 'https://github.com/acme/widgets/pull/5875',
|
||||
statusCheckRollup: [],
|
||||
updatedAt: '2026-06-20T04:53:05Z',
|
||||
isDraft: false,
|
||||
mergeable: 'MERGEABLE',
|
||||
baseRefName: 'main',
|
||||
headRefName: MERGED_BRANCH,
|
||||
baseRefOid: 'base-oid',
|
||||
headRefOid: MERGED_HEAD_OID
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
function mockUpstreamOnlyPRLookup(): void {
|
||||
ghExecFileAsyncMock
|
||||
.mockResolvedValueOnce({ stdout: JSON.stringify([]) })
|
||||
.mockResolvedValueOnce({
|
||||
stdout: JSON.stringify([
|
||||
{
|
||||
number: 78,
|
||||
title: 'Upstream branch PR',
|
||||
state: 'open',
|
||||
html_url: 'https://github.com/acme/widgets/pull/78',
|
||||
updated_at: '2026-03-28T00:00:00Z',
|
||||
draft: false,
|
||||
mergeable: true,
|
||||
base: { ref: 'main', sha: 'base-oid' },
|
||||
head: { ref: 'contributor/original', sha: 'upstream-head-oid' }
|
||||
}
|
||||
])
|
||||
})
|
||||
.mockResolvedValueOnce({
|
||||
stdout: JSON.stringify({
|
||||
number: 78,
|
||||
title: 'Hydrated upstream branch PR',
|
||||
state: 'OPEN',
|
||||
url: 'https://github.com/acme/widgets/pull/78',
|
||||
statusCheckRollup: [],
|
||||
updatedAt: '2026-03-28T00:00:00Z',
|
||||
isDraft: false,
|
||||
mergeable: 'MERGEABLE',
|
||||
baseRefName: 'main',
|
||||
headRefName: 'contributor/original',
|
||||
baseRefOid: 'base-oid',
|
||||
headRefOid: 'upstream-head-oid'
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
// The strict execution boundary: for an SSH-hosted repoPath, an unregistered
|
||||
// provider means "we could not ask" — it must never degrade into a client-side
|
||||
// git run, which on a same-named local path answers for the wrong repository.
|
||||
describe('getPRForBranch SSH execution boundary', () => {
|
||||
beforeEach(() => {
|
||||
resetPRForBranchMocks(clientMocks)
|
||||
getOwnerRepoMock.mockResolvedValue({ owner: 'acme', repo: 'widgets' })
|
||||
})
|
||||
|
||||
it('does not rev-parse HEAD locally when the SSH provider is unregistered', async () => {
|
||||
getSshGitProviderMock.mockReturnValue(undefined)
|
||||
mockMergedBranchPRLookup()
|
||||
gitExecFileAsyncMock.mockResolvedValue({ stdout: `${MERGED_HEAD_OID}\n`, stderr: '' })
|
||||
|
||||
const outcome = await getPRForBranchOutcome('/remote/repo', MERGED_BRANCH, null, 'ssh-1')
|
||||
|
||||
expect(gitExecFileAsyncMock).not.toHaveBeenCalled()
|
||||
expect(outcome).toMatchObject({ kind: 'upstream-error', errorType: 'unknown' })
|
||||
})
|
||||
|
||||
it('reports an upstream error when SSH candidate discovery is unverifiable', async () => {
|
||||
resolvePRRepositoryCandidatesMock.mockRejectedValue(
|
||||
new Error('Remote repository identity is unverifiable.')
|
||||
)
|
||||
|
||||
const outcome = await getPRForBranchOutcome('/remote/repo', MERGED_BRANCH, null, 'ssh-1')
|
||||
|
||||
expect(gitExecFileAsyncMock).not.toHaveBeenCalled()
|
||||
expect(ghExecFileAsyncMock).not.toHaveBeenCalled()
|
||||
expect(outcome).toMatchObject({ kind: 'upstream-error', errorType: 'unknown' })
|
||||
})
|
||||
|
||||
it('keeps a verified empty SSH candidate set as no PR', async () => {
|
||||
resolvePRRepositoryCandidatesMock.mockResolvedValue({ candidates: [], headRepo: null })
|
||||
|
||||
const outcome = await getPRForBranchOutcome('/remote/repo', MERGED_BRANCH, null, 'ssh-1')
|
||||
|
||||
expect(gitExecFileAsyncMock).not.toHaveBeenCalled()
|
||||
expect(ghExecFileAsyncMock).not.toHaveBeenCalled()
|
||||
expect(outcome).toMatchObject({ kind: 'no-pr' })
|
||||
})
|
||||
|
||||
it('rev-parses HEAD through the SSH provider when it is registered', async () => {
|
||||
const sshGitProvider = {
|
||||
exec: vi.fn(async (args: string[]) =>
|
||||
args[0] === 'rev-parse' && args[1] === 'HEAD'
|
||||
? { stdout: `${MERGED_HEAD_OID}\n`, stderr: '' }
|
||||
: { stdout: '', stderr: '' }
|
||||
)
|
||||
}
|
||||
getSshGitProviderMock.mockReturnValue(sshGitProvider)
|
||||
mockMergedBranchPRLookup()
|
||||
|
||||
const pr = await getPRForBranch('/remote/repo', MERGED_BRANCH, null, 'ssh-1')
|
||||
|
||||
expect(sshGitProvider.exec).toHaveBeenCalledWith(['rev-parse', 'HEAD'], '/remote/repo')
|
||||
expect(gitExecFileAsyncMock).not.toHaveBeenCalled()
|
||||
expect(pr).toMatchObject({ number: 5875, state: 'merged', headSha: MERGED_HEAD_OID })
|
||||
})
|
||||
|
||||
it('rev-parses HEAD through the local runtime for a WSL repository', async () => {
|
||||
mockMergedBranchPRLookup()
|
||||
gitExecFileAsyncMock.mockResolvedValue({ stdout: `${MERGED_HEAD_OID}\n`, stderr: '' })
|
||||
|
||||
const pr = await getPRForBranch('/repo-root', MERGED_BRANCH, null, null, null, {
|
||||
localGitExecOptions: { wslDistro: 'Ubuntu' }
|
||||
})
|
||||
|
||||
expect(gitExecFileAsyncMock).toHaveBeenCalledWith(['rev-parse', 'HEAD'], {
|
||||
cwd: '/repo-root',
|
||||
wslDistro: 'Ubuntu'
|
||||
})
|
||||
expect(pr).toMatchObject({ number: 5875, state: 'merged', headSha: MERGED_HEAD_OID })
|
||||
})
|
||||
|
||||
it('does not read tracked upstreams locally when the SSH provider is unregistered', async () => {
|
||||
getSshGitProviderMock.mockReturnValue(undefined)
|
||||
mockUpstreamOnlyPRLookup()
|
||||
gitExecFileAsyncMock.mockResolvedValue({
|
||||
stdout: `${MERGED_BRANCH}\0origin/contributor/original\n`,
|
||||
stderr: ''
|
||||
})
|
||||
|
||||
const outcome = await getPRForBranchOutcome('/remote/repo', MERGED_BRANCH, null, 'ssh-1')
|
||||
|
||||
expect(gitExecFileAsyncMock).not.toHaveBeenCalled()
|
||||
expect(outcome).toMatchObject({ kind: 'upstream-error', errorType: 'unknown' })
|
||||
expect(ghExecFileAsyncMock).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
it('reports an upstream error when the SSH HEAD probe loses its provider mid-flight', async () => {
|
||||
getSshGitProviderMock.mockReturnValue({
|
||||
exec: vi.fn().mockRejectedValue(new Error('SSH transport closed'))
|
||||
})
|
||||
mockMergedBranchPRLookup()
|
||||
|
||||
const outcome = await getPRForBranchOutcome('/remote/repo', MERGED_BRANCH, null, 'ssh-1')
|
||||
|
||||
expect(gitExecFileAsyncMock).not.toHaveBeenCalled()
|
||||
expect(outcome).toMatchObject({ kind: 'upstream-error', errorType: 'unknown' })
|
||||
})
|
||||
|
||||
it('reports an upstream error when the SSH upstream probe loses its provider mid-flight', async () => {
|
||||
getSshGitProviderMock.mockReturnValue({
|
||||
exec: vi.fn().mockRejectedValue(new Error('SSH transport closed'))
|
||||
})
|
||||
mockUpstreamOnlyPRLookup()
|
||||
|
||||
const outcome = await getPRForBranchOutcome('/remote/repo', MERGED_BRANCH, null, 'ssh-1')
|
||||
|
||||
expect(gitExecFileAsyncMock).not.toHaveBeenCalled()
|
||||
expect(outcome).toMatchObject({ kind: 'upstream-error', errorType: 'unknown' })
|
||||
})
|
||||
|
||||
it('reads tracked upstreams through the SSH provider when it is registered', async () => {
|
||||
const forEachRefArgs = ['for-each-ref', '--format=%(refname)%00%(upstream)', 'refs/heads']
|
||||
const sshGitProvider = {
|
||||
exec: vi.fn(async (args: string[]) =>
|
||||
args[0] === 'for-each-ref'
|
||||
? { stdout: `${MERGED_BRANCH}\0origin/contributor/original\n`, stderr: '' }
|
||||
: { stdout: '', stderr: '' }
|
||||
)
|
||||
}
|
||||
getSshGitProviderMock.mockReturnValue(sshGitProvider)
|
||||
mockUpstreamOnlyPRLookup()
|
||||
|
||||
const pr = await getPRForBranch('/remote/repo', MERGED_BRANCH, null, 'ssh-1')
|
||||
|
||||
expect(sshGitProvider.exec).toHaveBeenCalledWith(forEachRefArgs, '/remote/repo')
|
||||
expect(gitExecFileAsyncMock).not.toHaveBeenCalled()
|
||||
expect(pr).toMatchObject({ number: 78, title: 'Hydrated upstream branch PR' })
|
||||
})
|
||||
|
||||
it('reads tracked upstreams through the local runtime for a WSL repository', async () => {
|
||||
const forEachRefArgs = ['for-each-ref', '--format=%(refname)%00%(upstream)', 'refs/heads']
|
||||
mockUpstreamOnlyPRLookup()
|
||||
gitExecFileAsyncMock.mockResolvedValue({
|
||||
stdout: `${MERGED_BRANCH}\0origin/contributor/original\n`,
|
||||
stderr: ''
|
||||
})
|
||||
|
||||
const pr = await getPRForBranch('/repo-root', MERGED_BRANCH, null, null, null, {
|
||||
localGitExecOptions: { wslDistro: 'Ubuntu' }
|
||||
})
|
||||
|
||||
expect(gitExecFileAsyncMock).toHaveBeenCalledWith(forEachRefArgs, {
|
||||
cwd: '/repo-root',
|
||||
wslDistro: 'Ubuntu'
|
||||
})
|
||||
expect(pr).toMatchObject({ number: 78, title: 'Hydrated upstream branch PR' })
|
||||
})
|
||||
})
|
||||
@@ -131,10 +131,18 @@ export function gitRunnerModuleMock(mocks: GitHubClientMocks): GitRunnerModuleMo
|
||||
}
|
||||
}
|
||||
|
||||
export type SshGitDispatchModuleMock = { getSshGitProvider: Mock }
|
||||
export type SshGitDispatchModuleMock = {
|
||||
getSshGitProviderGeneration: Mock
|
||||
getSshGitProvider: Mock
|
||||
SSH_GIT_PROVIDER_UNAVAILABLE_MESSAGE: string
|
||||
}
|
||||
|
||||
export function sshGitDispatchModuleMock(mocks: GitHubClientMocks): SshGitDispatchModuleMock {
|
||||
return { getSshGitProvider: mocks.getSshGitProviderMock }
|
||||
return {
|
||||
getSshGitProviderGeneration: vi.fn(() => 0),
|
||||
getSshGitProvider: mocks.getSshGitProviderMock,
|
||||
SSH_GIT_PROVIDER_UNAVAILABLE_MESSAGE: 'Remote connection dropped.'
|
||||
}
|
||||
}
|
||||
|
||||
export type LocalGitConfigSignatureModuleMock = { readLocalGitConfigSignature: Mock }
|
||||
|
||||
+31
-15
@@ -73,7 +73,10 @@ import {
|
||||
isCommitPartOfMergedPR,
|
||||
type MergedPRCommitMembership
|
||||
} from './merged-pr-commit-membership'
|
||||
import { getSshGitProvider } from '../providers/ssh-git-dispatch'
|
||||
import {
|
||||
getSshGitProvider,
|
||||
SSH_GIT_PROVIDER_UNAVAILABLE_MESSAGE
|
||||
} from '../providers/ssh-git-dispatch'
|
||||
import {
|
||||
hasHostedReviewLocalGitOptions,
|
||||
getHostedReviewLocalGitOptions,
|
||||
@@ -2315,14 +2318,19 @@ async function getCurrentHeadOid(
|
||||
connectionId?: string | null,
|
||||
localGitOptions: { wslDistro?: string } = {}
|
||||
): Promise<string | null> {
|
||||
const provider = connectionId ? getSshGitProvider(connectionId) : null
|
||||
if (connectionId && !provider) {
|
||||
throw new Error(SSH_GIT_PROVIDER_UNAVAILABLE_MESSAGE)
|
||||
}
|
||||
if (provider) {
|
||||
const result = await provider.exec(['rev-parse', 'HEAD'], repoPath)
|
||||
return result.stdout.trim() || null
|
||||
}
|
||||
try {
|
||||
const provider = connectionId ? getSshGitProvider(connectionId) : null
|
||||
const result = provider
|
||||
? await provider.exec(['rev-parse', 'HEAD'], repoPath)
|
||||
: await gitExecFileAsync(['rev-parse', 'HEAD'], {
|
||||
cwd: repoPath,
|
||||
...(localGitOptions.wslDistro ? { wslDistro: localGitOptions.wslDistro } : {})
|
||||
})
|
||||
const result = await gitExecFileAsync(['rev-parse', 'HEAD'], {
|
||||
cwd: repoPath,
|
||||
...(localGitOptions.wslDistro ? { wslDistro: localGitOptions.wslDistro } : {})
|
||||
})
|
||||
return result.stdout.trim() || null
|
||||
} catch {
|
||||
return null
|
||||
@@ -2814,14 +2822,22 @@ async function probeTrackedUpstreamBranches(
|
||||
upstreamsByBranchName: Map<string, TrackedUpstreamBranch | null>
|
||||
}> {
|
||||
const args = ['for-each-ref', '--format=%(refname)%00%(upstream)', 'refs/heads']
|
||||
const provider = connectionId ? getSshGitProvider(connectionId) : null
|
||||
if (connectionId && !provider) {
|
||||
throw new Error(SSH_GIT_PROVIDER_UNAVAILABLE_MESSAGE)
|
||||
}
|
||||
if (provider) {
|
||||
const result = await provider.exec(args, repoPath)
|
||||
return {
|
||||
probeFailed: false,
|
||||
upstreamsByBranchName: parseTrackedUpstreamBranches(result.stdout)
|
||||
}
|
||||
}
|
||||
try {
|
||||
const provider = connectionId ? getSshGitProvider(connectionId) : null
|
||||
const result = provider
|
||||
? await provider.exec(args, repoPath)
|
||||
: await gitExecFileAsync(args, {
|
||||
cwd: repoPath,
|
||||
...(localGitOptions.wslDistro ? { wslDistro: localGitOptions.wslDistro } : {})
|
||||
})
|
||||
const result = await gitExecFileAsync(args, {
|
||||
cwd: repoPath,
|
||||
...(localGitOptions.wslDistro ? { wslDistro: localGitOptions.wslDistro } : {})
|
||||
})
|
||||
return {
|
||||
probeFailed: false,
|
||||
upstreamsByBranchName: parseTrackedUpstreamBranches(result.stdout)
|
||||
|
||||
@@ -26,6 +26,7 @@ export {
|
||||
} from './github-repository-identity'
|
||||
export type {
|
||||
GitHubRemoteIdentity,
|
||||
GitHubRemoteIdentityProbeOptions,
|
||||
GitHubRepoContext,
|
||||
LocalGitExecOptions,
|
||||
OwnerRepo
|
||||
|
||||
@@ -0,0 +1,25 @@
|
||||
import type { LocalGitExecOptions } from './github-repository-identity'
|
||||
import { getSshGitProviderGeneration } from '../providers/ssh-git-dispatch'
|
||||
|
||||
export function githubApiRepositoryProbeCacheKey(
|
||||
repoPath: string,
|
||||
remoteName: string,
|
||||
connectionId: string | null | undefined,
|
||||
localGitOptions: LocalGitExecOptions,
|
||||
requireVerifiedSshProbe: boolean
|
||||
): string {
|
||||
const runtimeKey = connectionId
|
||||
? `ssh:${connectionId}:${getSshGitProviderGeneration(connectionId)}`
|
||||
: `local:${localGitOptions.wslDistro ?? 'host'}`
|
||||
return `${runtimeKey}\0${repoPath}\0${remoteName}\0${requireVerifiedSshProbe ? 'verified' : 'tolerant'}`
|
||||
}
|
||||
|
||||
export function resolveGitHubApiRepositoryProbe<T>(
|
||||
value: T | undefined,
|
||||
requireVerifiedSshProbe: boolean
|
||||
): T | null {
|
||||
if (value === undefined && requireVerifiedSshProbe) {
|
||||
throw new Error('GitHub repository identity is unverifiable.')
|
||||
}
|
||||
return value ?? null
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
import type { GitHubOwnerRepo } from '../../shared/github/pull-request-types'
|
||||
|
||||
export type GitHubApiRepositoryResolution =
|
||||
| GitHubOwnerRepo
|
||||
| null
|
||||
| undefined
|
||||
| (() => Promise<GitHubOwnerRepo | null>)
|
||||
|
||||
// Why: renderer/RPC overrides reach authenticated REST paths.
|
||||
const OWNER_SLUG_RE = /^[A-Za-z0-9][A-Za-z0-9-]*$/
|
||||
const REPOSITORY_SLUG_RE = /^[A-Za-z0-9._-]+$/
|
||||
|
||||
export function isValidGitHubApiRepository(repository: GitHubOwnerRepo): boolean {
|
||||
return (
|
||||
OWNER_SLUG_RE.test(repository.owner) &&
|
||||
REPOSITORY_SLUG_RE.test(repository.repo) &&
|
||||
repository.repo !== '.' &&
|
||||
repository.repo !== '..'
|
||||
)
|
||||
}
|
||||
@@ -1,19 +1,27 @@
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import type * as GitHubEnterpriseRepository from './github-enterprise-repository'
|
||||
import type * as GhUtils from './gh-utils'
|
||||
import type * as SshGitDispatch from '../providers/ssh-git-dispatch'
|
||||
|
||||
const {
|
||||
getEnterpriseGitHubRepoSlugMock,
|
||||
getOwnerRepoMock,
|
||||
getOwnerRepoForRemoteMock,
|
||||
getSshGitProviderGenerationMock,
|
||||
isGitHubHostAuthenticatedMock
|
||||
} = vi.hoisted(() => ({
|
||||
getEnterpriseGitHubRepoSlugMock: vi.fn(),
|
||||
getOwnerRepoMock: vi.fn(),
|
||||
getOwnerRepoForRemoteMock: vi.fn(),
|
||||
getSshGitProviderGenerationMock: vi.fn(() => 0),
|
||||
isGitHubHostAuthenticatedMock: vi.fn()
|
||||
}))
|
||||
|
||||
vi.mock('../providers/ssh-git-dispatch', async (importOriginal) => ({
|
||||
...(await importOriginal<typeof SshGitDispatch>()),
|
||||
getSshGitProviderGeneration: getSshGitProviderGenerationMock
|
||||
}))
|
||||
|
||||
vi.mock('./gh-utils', async (importOriginal) => ({
|
||||
...(await importOriginal<typeof GhUtils>()),
|
||||
getOwnerRepo: getOwnerRepoMock,
|
||||
@@ -29,6 +37,7 @@ vi.mock('./github-enterprise-repository', async (importOriginal) => ({
|
||||
|
||||
import {
|
||||
_resetOriginGitHubApiRepositoryCache,
|
||||
getGitHubApiRepositoryForRemote,
|
||||
getOriginGitHubApiRepository,
|
||||
githubHostExecOptions,
|
||||
resolveGitHubApiRepository,
|
||||
@@ -40,6 +49,7 @@ beforeEach(() => {
|
||||
getEnterpriseGitHubRepoSlugMock.mockReset().mockResolvedValue(null)
|
||||
getOwnerRepoMock.mockReset().mockResolvedValue(null)
|
||||
getOwnerRepoForRemoteMock.mockReset().mockResolvedValue(null)
|
||||
getSshGitProviderGenerationMock.mockReset().mockReturnValue(0)
|
||||
isGitHubHostAuthenticatedMock.mockReset().mockResolvedValue(false)
|
||||
})
|
||||
|
||||
@@ -182,6 +192,62 @@ describe('resolveGitHubRepoExecution', () => {
|
||||
})
|
||||
|
||||
describe('origin repository cache', () => {
|
||||
it('isolates Enterprise identity across SSH provider generations', async () => {
|
||||
const beforeReconnect = { owner: 'acme', repo: 'widgets', host: 'github.acme-corp.com' }
|
||||
const afterReconnect = { owner: 'acme', repo: 'other', host: 'github.acme-corp.com' }
|
||||
getEnterpriseGitHubRepoSlugMock
|
||||
.mockResolvedValueOnce(beforeReconnect)
|
||||
.mockResolvedValueOnce(afterReconnect)
|
||||
|
||||
await expect(getOriginGitHubApiRepository('/remote/repo', 'ssh-1')).resolves.toEqual(
|
||||
beforeReconnect
|
||||
)
|
||||
getSshGitProviderGenerationMock.mockReturnValue(1)
|
||||
await expect(getOriginGitHubApiRepository('/remote/repo', 'ssh-1')).resolves.toEqual(
|
||||
afterReconnect
|
||||
)
|
||||
expect(getEnterpriseGitHubRepoSlugMock).toHaveBeenCalledTimes(2)
|
||||
})
|
||||
|
||||
it('keeps an indeterminate auth inventory unverifiable during candidate discovery', async () => {
|
||||
getEnterpriseGitHubRepoSlugMock.mockResolvedValue(undefined)
|
||||
|
||||
await expect(
|
||||
getGitHubApiRepositoryForRemote(
|
||||
'/remote/repo',
|
||||
'origin',
|
||||
'ssh-1',
|
||||
{},
|
||||
{
|
||||
requireVerifiedSshProbe: true
|
||||
}
|
||||
)
|
||||
).rejects.toThrow('GitHub repository identity is unverifiable.')
|
||||
})
|
||||
|
||||
it('does not reuse a tolerant SSH miss for verified candidate discovery', async () => {
|
||||
const enterprise = {
|
||||
owner: 'acme',
|
||||
repo: 'widgets',
|
||||
host: 'github.acme-corp.com'
|
||||
}
|
||||
getEnterpriseGitHubRepoSlugMock.mockResolvedValueOnce(null).mockResolvedValueOnce(enterprise)
|
||||
|
||||
await expect(getOriginGitHubApiRepository('/remote/repo', 'ssh-1')).resolves.toBeNull()
|
||||
await expect(
|
||||
getGitHubApiRepositoryForRemote(
|
||||
'/remote/repo',
|
||||
'origin',
|
||||
'ssh-1',
|
||||
{},
|
||||
{
|
||||
requireVerifiedSshProbe: true
|
||||
}
|
||||
)
|
||||
).resolves.toEqual(enterprise)
|
||||
expect(getEnterpriseGitHubRepoSlugMock).toHaveBeenCalledTimes(2)
|
||||
})
|
||||
|
||||
it('does not cache an indeterminate Enterprise auth probe', async () => {
|
||||
const enterprise = {
|
||||
owner: 'acme',
|
||||
|
||||
@@ -8,6 +8,7 @@ import {
|
||||
getOwnerRepoForRemote,
|
||||
ghRepoExecOptions,
|
||||
githubRepoContext,
|
||||
type GitHubRemoteIdentityProbeOptions,
|
||||
type LocalGitExecOptions
|
||||
} from './gh-utils'
|
||||
import {
|
||||
@@ -15,7 +16,21 @@ import {
|
||||
getEnterpriseGitHubRepoSlugForRemote,
|
||||
isGitHubHostAuthenticated
|
||||
} from './github-enterprise-repository'
|
||||
import { githubHostExecOptions } from './github-repository-host'
|
||||
import {
|
||||
isValidGitHubApiRepository,
|
||||
type GitHubApiRepositoryResolution
|
||||
} from './github-api-repository-validation'
|
||||
import {
|
||||
githubApiRepositoryProbeCacheKey,
|
||||
resolveGitHubApiRepositoryProbe
|
||||
} from './github-api-repository-probe'
|
||||
|
||||
export {
|
||||
githubHostExecOptions,
|
||||
githubRepositorySlugArg,
|
||||
githubRepositoryWebHost
|
||||
} from './github-repository-host'
|
||||
export type GitHubApiRepository = GitHubOwnerRepo
|
||||
export type GitHubRepoExecOptions = ReturnType<typeof ghRepoExecOptions> & { host?: string }
|
||||
export type GitHubRepoExecution = {
|
||||
@@ -23,43 +38,12 @@ export type GitHubRepoExecution = {
|
||||
ghOptions: GitHubRepoExecOptions
|
||||
}
|
||||
|
||||
type GitHubApiRepositoryResolution =
|
||||
| GitHubApiRepository
|
||||
| null
|
||||
| undefined
|
||||
| (() => Promise<GitHubApiRepository | null>)
|
||||
|
||||
// Why: renderer/RPC repository overrides are interpolated into REST paths.
|
||||
// Reject path syntax before an authenticated gh process can target it.
|
||||
const GITHUB_OWNER_SLUG_RE = /^[A-Za-z0-9][A-Za-z0-9-]*$/
|
||||
const GITHUB_REPO_SLUG_RE = /^[A-Za-z0-9._-]+$/
|
||||
|
||||
function isValidGitHubApiRepository(repository: GitHubApiRepository): boolean {
|
||||
return (
|
||||
GITHUB_OWNER_SLUG_RE.test(repository.owner) &&
|
||||
GITHUB_REPO_SLUG_RE.test(repository.repo) &&
|
||||
repository.repo !== '.' &&
|
||||
repository.repo !== '..'
|
||||
)
|
||||
}
|
||||
|
||||
// Why: the enterprise branch spawns an uncached `git remote get-url` (an SSH
|
||||
// round trip on connection-backed repos) — hot paths like per-file contents
|
||||
// and viewed-state toggles resolve per call, so cache like ownerRepoCache does.
|
||||
// Why: cache the uncached Enterprise remote probe used by hot paths.
|
||||
const ORIGIN_REPO_CACHE_TTL_MS = 30_000
|
||||
const ORIGIN_REPO_CACHE_MAX_ENTRIES = 512
|
||||
const originRepoCache = new Map<string, { value: GitHubApiRepository | null; expiresAt: number }>()
|
||||
const originRepoInFlight = new Map<string, Promise<GitHubApiRepository | null>>()
|
||||
|
||||
function originRepoCacheKey(
|
||||
repoPath: string,
|
||||
remoteName: string,
|
||||
connectionId?: string | null,
|
||||
localGitOptions: LocalGitExecOptions = {}
|
||||
): string {
|
||||
return `${connectionId ?? 'local'}\0${localGitOptions.wslDistro ?? ''}\0${repoPath}\0${remoteName}`
|
||||
}
|
||||
|
||||
/** @internal - exposed for tests only */
|
||||
export function _resetOriginGitHubApiRepositoryCache(): void {
|
||||
originRepoCache.clear()
|
||||
@@ -90,15 +74,30 @@ export async function getGitHubApiRepositoryForRemote(
|
||||
repoPath: string,
|
||||
remoteName: string,
|
||||
connectionId?: string | null,
|
||||
localGitOptions: LocalGitExecOptions = {}
|
||||
localGitOptions: LocalGitExecOptions = {},
|
||||
probeOptions: GitHubRemoteIdentityProbeOptions = {}
|
||||
): Promise<GitHubApiRepository | null> {
|
||||
// Why: generic PR resolution prefers upstream, but this API represents the
|
||||
// caller-selected remote exactly (#7331).
|
||||
const ownerRepo = await getOwnerRepoForRemote(repoPath, remoteName, connectionId, localGitOptions)
|
||||
const requireVerifiedSshProbe = probeOptions.requireVerifiedSshProbe === true
|
||||
const verifiedIdentityArgs = requireVerifiedSshProbe ? ([probeOptions] as const) : []
|
||||
const ownerRepo = await getOwnerRepoForRemote(
|
||||
repoPath,
|
||||
remoteName,
|
||||
connectionId,
|
||||
localGitOptions,
|
||||
...verifiedIdentityArgs
|
||||
)
|
||||
if (ownerRepo) {
|
||||
return { ...ownerRepo, host: 'github.com' }
|
||||
}
|
||||
const cacheKey = originRepoCacheKey(repoPath, remoteName, connectionId, localGitOptions)
|
||||
const cacheKey = githubApiRepositoryProbeCacheKey(
|
||||
repoPath,
|
||||
remoteName,
|
||||
connectionId,
|
||||
localGitOptions,
|
||||
requireVerifiedSshProbe
|
||||
)
|
||||
const now = Date.now()
|
||||
pruneOriginRepoCache(now)
|
||||
const cached = originRepoCache.get(cacheKey)
|
||||
@@ -112,14 +111,21 @@ export async function getGitHubApiRepositoryForRemote(
|
||||
const probe = (async () => {
|
||||
const enterpriseOptions =
|
||||
Object.keys(localGitOptions).length > 0 ? { localGitExecOptions: localGitOptions } : {}
|
||||
const verifiedEnterpriseArgs = requireVerifiedSshProbe ? ([true] as const) : []
|
||||
const slug =
|
||||
remoteName === 'origin'
|
||||
? await getEnterpriseGitHubRepoSlug(repoPath, connectionId, enterpriseOptions)
|
||||
? await getEnterpriseGitHubRepoSlug(
|
||||
repoPath,
|
||||
connectionId,
|
||||
enterpriseOptions,
|
||||
...verifiedEnterpriseArgs
|
||||
)
|
||||
: await getEnterpriseGitHubRepoSlugForRemote(
|
||||
repoPath,
|
||||
remoteName,
|
||||
connectionId,
|
||||
enterpriseOptions
|
||||
enterpriseOptions,
|
||||
...verifiedEnterpriseArgs
|
||||
)
|
||||
// Why: undefined means the gh auth inventory could not be read. Caching it
|
||||
// as a negative would turn a transient spawn failure into a 30-second miss.
|
||||
@@ -130,7 +136,7 @@ export async function getGitHubApiRepositoryForRemote(
|
||||
})
|
||||
pruneOriginRepoCache(Date.now())
|
||||
}
|
||||
return slug ?? null
|
||||
return resolveGitHubApiRepositoryProbe(slug, requireVerifiedSshProbe)
|
||||
})()
|
||||
originRepoInFlight.set(cacheKey, probe)
|
||||
try {
|
||||
@@ -180,8 +186,12 @@ export async function resolveGitHubApiRepositoryCandidates(
|
||||
localGitOptions: LocalGitExecOptions = {}
|
||||
): Promise<GitHubApiRepositoryCandidates> {
|
||||
const [upstream, origin] = await Promise.all([
|
||||
getGitHubApiRepositoryForRemote(repoPath, 'upstream', connectionId, localGitOptions),
|
||||
getGitHubApiRepositoryForRemote(repoPath, 'origin', connectionId, localGitOptions)
|
||||
getGitHubApiRepositoryForRemote(repoPath, 'upstream', connectionId, localGitOptions, {
|
||||
requireVerifiedSshProbe: true
|
||||
}),
|
||||
getGitHubApiRepositoryForRemote(repoPath, 'origin', connectionId, localGitOptions, {
|
||||
requireVerifiedSshProbe: true
|
||||
})
|
||||
])
|
||||
const seen = new Set<string>()
|
||||
const candidates: GitHubApiRepository[] = []
|
||||
@@ -292,15 +302,6 @@ export async function resolveGitHubApiRepository(
|
||||
return null
|
||||
}
|
||||
|
||||
// Why: the gh runner host-qualifies argv from `options.host`, so every known
|
||||
// host must be carried through. Pinning github.com prevents a process-level
|
||||
// GH_HOST from silently redirecting an otherwise unambiguous API request.
|
||||
export function githubHostExecOptions(repository: GitHubApiRepository | null | undefined): {
|
||||
host?: string
|
||||
} {
|
||||
return repository?.host ? { host: repository.host } : {}
|
||||
}
|
||||
|
||||
export async function resolveGitHubRepoExecution(
|
||||
repoPath: string,
|
||||
repository?: GitHubApiRepositoryResolution,
|
||||
@@ -329,19 +330,3 @@ export async function resolveGitHubRepoExecution(
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export function githubRepositoryWebHost(repository: GitHubApiRepository): string {
|
||||
return repository.host ?? 'github.com'
|
||||
}
|
||||
|
||||
/**
|
||||
* Positional `HOST/OWNER/REPO` argv value (e.g. `gh repo view <slug>`).
|
||||
* Positional slugs bypass the runner's `--repo` qualifier, so they must be
|
||||
* qualified here whenever the host is known.
|
||||
*/
|
||||
export function githubRepositorySlugArg(repository: GitHubApiRepository): string {
|
||||
const slug = `${repository.owner}/${repository.repo}`
|
||||
// Why: github.com must be explicit too; otherwise process-level GH_HOST can
|
||||
// redirect positional `gh repo view OWNER/REPO` calls to an Enterprise host.
|
||||
return repository.host ? `${repository.host}/${slug}` : slug
|
||||
}
|
||||
|
||||
@@ -18,6 +18,11 @@ import {
|
||||
} from './github-remote-identity-parsing'
|
||||
import { resolveSshConfigHostname } from './github-ssh-host-alias-resolution'
|
||||
import { parseWslPath } from '../wsl'
|
||||
import {
|
||||
getSshGitProvider,
|
||||
SSH_GIT_PROVIDER_UNAVAILABLE_MESSAGE
|
||||
} from '../providers/ssh-git-dispatch'
|
||||
import { isStableMissingGitRemoteError } from '../git/stable-missing-git-remote-error'
|
||||
|
||||
export type GitHubEnterpriseRepoSlug = GitHubOwnerRepo & { host: string }
|
||||
|
||||
@@ -214,16 +219,26 @@ export async function getEnterpriseGitHubRepoSlugForRemote(
|
||||
repoPath: string,
|
||||
remoteName: string,
|
||||
connectionId?: string | null,
|
||||
options: HostedReviewExecutionOptions = {}
|
||||
options: HostedReviewExecutionOptions = {},
|
||||
requireVerifiedSshProbe = false
|
||||
): Promise<GitHubEnterpriseRepoSlug | null | undefined> {
|
||||
const localGitOptions = getHostedReviewLocalGitOptions(options)
|
||||
const context = githubRepoContext(repoPath, connectionId, localGitOptions)
|
||||
if (requireVerifiedSshProbe && connectionId && !getSshGitProvider(connectionId)) {
|
||||
throw new Error(SSH_GIT_PROVIDER_UNAVAILABLE_MESSAGE)
|
||||
}
|
||||
let remoteUrl: string | null
|
||||
try {
|
||||
remoteUrl = await getRemoteUrlForRepo(context, remoteName)
|
||||
} catch {
|
||||
} catch (error) {
|
||||
if (requireVerifiedSshProbe && connectionId && !isStableMissingGitRemoteError(error)) {
|
||||
throw error
|
||||
}
|
||||
return null
|
||||
}
|
||||
if (requireVerifiedSshProbe && connectionId && !remoteUrl && !getSshGitProvider(connectionId)) {
|
||||
throw new Error(SSH_GIT_PROVIDER_UNAVAILABLE_MESSAGE)
|
||||
}
|
||||
const identity = remoteUrl ? parseGitHubRemoteIdentity(remoteUrl) : null
|
||||
if (!identity) {
|
||||
return null
|
||||
@@ -234,6 +249,9 @@ export async function getEnterpriseGitHubRepoSlugForRemote(
|
||||
if (aliasHost) {
|
||||
const { hostname, resolved } = await resolveSshConfigHostname(aliasHost, context)
|
||||
if (!resolved || !hostname) {
|
||||
if (requireVerifiedSshProbe && connectionId) {
|
||||
throw new Error('Remote repository identity is unverifiable.')
|
||||
}
|
||||
const authenticatedLiteralHost = await resolveAuthenticatedGitHubHost(
|
||||
identity.host,
|
||||
repoPath,
|
||||
@@ -266,7 +284,14 @@ export async function getEnterpriseGitHubRepoSlugForRemote(
|
||||
export async function getEnterpriseGitHubRepoSlug(
|
||||
repoPath: string,
|
||||
connectionId?: string | null,
|
||||
options: HostedReviewExecutionOptions = {}
|
||||
options: HostedReviewExecutionOptions = {},
|
||||
requireVerifiedSshProbe = false
|
||||
): Promise<GitHubEnterpriseRepoSlug | null | undefined> {
|
||||
return getEnterpriseGitHubRepoSlugForRemote(repoPath, 'origin', connectionId, options)
|
||||
return getEnterpriseGitHubRepoSlugForRemote(
|
||||
repoPath,
|
||||
'origin',
|
||||
connectionId,
|
||||
options,
|
||||
requireVerifiedSshProbe
|
||||
)
|
||||
}
|
||||
|
||||
@@ -0,0 +1,18 @@
|
||||
import type { GitHubOwnerRepo } from '../../shared/github/pull-request-types'
|
||||
|
||||
export function githubHostExecOptions(repository: GitHubOwnerRepo | null | undefined): {
|
||||
host?: string
|
||||
} {
|
||||
return repository?.host ? { host: repository.host } : {}
|
||||
}
|
||||
|
||||
export function githubRepositoryWebHost(repository: GitHubOwnerRepo): string {
|
||||
return repository.host ?? 'github.com'
|
||||
}
|
||||
|
||||
/** Host-qualified positional slug for commands that bypass the runner's `--repo`. */
|
||||
export function githubRepositorySlugArg(repository: GitHubOwnerRepo): string {
|
||||
const slug = `${repository.owner}/${repository.repo}`
|
||||
// Why: pin dotcom too so process-level GH_HOST cannot redirect the request.
|
||||
return repository.host ? `${repository.host}/${slug}` : slug
|
||||
}
|
||||
@@ -25,7 +25,8 @@ vi.mock('../git/runner', async (importOriginal) => ({
|
||||
|
||||
vi.mock('../providers/ssh-git-dispatch', () => ({
|
||||
getSshGitProvider: getSshGitProviderMock,
|
||||
getSshGitProviderGeneration: getSshGitProviderGenerationMock
|
||||
getSshGitProviderGeneration: getSshGitProviderGenerationMock,
|
||||
SSH_GIT_PROVIDER_UNAVAILABLE_MESSAGE: 'Remote connection dropped.'
|
||||
}))
|
||||
|
||||
vi.mock('./local-git-config-signature', () => ({
|
||||
@@ -174,6 +175,70 @@ describe('#10284 SSH Host alias → github.com owner/repo', () => {
|
||||
expect(resolveWithSshGMock).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('does not read an SSH repoPath locally when its provider is missing', async () => {
|
||||
getSshGitProviderMock.mockReturnValue(undefined)
|
||||
|
||||
await expect(
|
||||
getOwnerRepoForRemote(
|
||||
'/remote/repo',
|
||||
'origin',
|
||||
'ssh-1',
|
||||
{},
|
||||
{
|
||||
requireVerifiedSshProbe: true
|
||||
}
|
||||
)
|
||||
).rejects.toThrow('Remote connection dropped.')
|
||||
expect(gitExecFileAsyncMock).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('preserves a one-shot SSH remote failure through candidate discovery', async () => {
|
||||
const failure = new Error('relay request failed')
|
||||
const provider = {
|
||||
exec: vi
|
||||
.fn()
|
||||
.mockRejectedValueOnce(failure)
|
||||
.mockResolvedValueOnce({ stdout: 'git@github.com:team/orca.git\n', stderr: '' })
|
||||
}
|
||||
getSshGitProviderMock.mockReturnValue(provider)
|
||||
|
||||
await expect(
|
||||
getOwnerRepoForRemote(
|
||||
'/remote/repo',
|
||||
'origin',
|
||||
'ssh-1',
|
||||
{},
|
||||
{
|
||||
requireVerifiedSshProbe: true
|
||||
}
|
||||
)
|
||||
).rejects.toBe(failure)
|
||||
await expect(getOwnerRepoForRemote('/remote/repo', 'origin', 'ssh-1')).resolves.toEqual({
|
||||
owner: 'team',
|
||||
repo: 'orca'
|
||||
})
|
||||
expect(provider.exec).toHaveBeenCalledTimes(2)
|
||||
expect(gitExecFileAsyncMock).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('keeps failed SSH alias classification unverifiable', async () => {
|
||||
const provider = sshProvider('')
|
||||
getSshGitProviderMock.mockReturnValue(provider)
|
||||
|
||||
await expect(
|
||||
getOwnerRepoForRemote(
|
||||
'/remote/repo',
|
||||
'origin',
|
||||
'ssh-1',
|
||||
{},
|
||||
{
|
||||
requireVerifiedSshProbe: true
|
||||
}
|
||||
)
|
||||
).rejects.toThrow('Remote repository identity is unverifiable.')
|
||||
expect(gitExecFileAsyncMock).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('isolates the same alias across native and WSL runtimes', async () => {
|
||||
resolveWithSshGMock.mockResolvedValueOnce(sshConfig('github.com'))
|
||||
commandExecFileAsyncMock.mockResolvedValueOnce({
|
||||
|
||||
@@ -1,7 +1,11 @@
|
||||
import { runCoalescedProbe, type CoalescedProbes } from '../git/coalesced-probe'
|
||||
import { readRemoteUrl } from '../git/remote-url-probe'
|
||||
import type { GitHubOwnerRepo } from '../../shared/github/pull-request-types'
|
||||
import { getSshGitProviderGeneration } from '../providers/ssh-git-dispatch'
|
||||
import {
|
||||
getSshGitProvider,
|
||||
getSshGitProviderGeneration,
|
||||
SSH_GIT_PROVIDER_UNAVAILABLE_MESSAGE
|
||||
} from '../providers/ssh-git-dispatch'
|
||||
import { readLocalGitConfigSignature } from './local-git-config-signature'
|
||||
import {
|
||||
parseGitHubOwnerRepo,
|
||||
@@ -26,6 +30,10 @@ export type LocalGitExecOptions = {
|
||||
wslDistro?: string
|
||||
}
|
||||
|
||||
export type GitHubRemoteIdentityProbeOptions = {
|
||||
requireVerifiedSshProbe?: boolean
|
||||
}
|
||||
|
||||
export function githubRepoContext(
|
||||
repoPath: string,
|
||||
connectionId?: string | null,
|
||||
@@ -108,9 +116,17 @@ export async function getOwnerRepoForRemote(
|
||||
repoPath: string,
|
||||
remoteName: string,
|
||||
connectionId?: string | null,
|
||||
localGitOptions: LocalGitExecOptions = {}
|
||||
localGitOptions: LocalGitExecOptions = {},
|
||||
probeOptions: GitHubRemoteIdentityProbeOptions = {}
|
||||
): Promise<OwnerRepo | null> {
|
||||
const context = githubRepoContext(repoPath, connectionId, localGitOptions)
|
||||
if (
|
||||
probeOptions.requireVerifiedSshProbe &&
|
||||
context.connectionId &&
|
||||
!getSshGitProvider(context.connectionId)
|
||||
) {
|
||||
throw new Error(SSH_GIT_PROVIDER_UNAVAILABLE_MESSAGE)
|
||||
}
|
||||
const runtimeKey = context.connectionId
|
||||
? `ssh:${context.connectionId}:${getSshGitProviderGeneration(context.connectionId)}`
|
||||
: `local:${context.wslDistro ?? 'host'}`
|
||||
@@ -145,8 +161,15 @@ export async function getOwnerRepoForRemote(
|
||||
// for the same repo concurrently. Coalesce missing-remote probes — but only
|
||||
// onto one young enough to still answer, so a wedged probe cannot pin the
|
||||
// repo's identity for the life of the process (P1-D).
|
||||
return runCoalescedProbe(ownerRepoInFlight, cacheKey, () =>
|
||||
resolveOwnerRepoForRemote(context, remoteName, cacheKey, nextConfigSignature)
|
||||
const inFlightKey = `${cacheKey}\0${probeOptions.requireVerifiedSshProbe ? 'verified' : 'tolerant'}`
|
||||
return runCoalescedProbe(ownerRepoInFlight, inFlightKey, () =>
|
||||
resolveOwnerRepoForRemote(
|
||||
context,
|
||||
remoteName,
|
||||
cacheKey,
|
||||
nextConfigSignature,
|
||||
probeOptions.requireVerifiedSshProbe === true
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
@@ -154,12 +177,20 @@ async function resolveOwnerRepoForRemote(
|
||||
context: GitHubRepoContext,
|
||||
remoteName: string,
|
||||
cacheKey: string,
|
||||
configSignature?: string
|
||||
configSignature: string | undefined,
|
||||
requireVerifiedSshProbe: boolean
|
||||
): Promise<OwnerRepo | null> {
|
||||
const now = Date.now()
|
||||
try {
|
||||
const remoteUrl = await getRemoteUrlForRepo(context, remoteName)
|
||||
if (!remoteUrl) {
|
||||
if (
|
||||
requireVerifiedSshProbe &&
|
||||
context.connectionId &&
|
||||
!getSshGitProvider(context.connectionId)
|
||||
) {
|
||||
throw new Error(SSH_GIT_PROVIDER_UNAVAILABLE_MESSAGE)
|
||||
}
|
||||
// Empty remote URL is stable until git config changes.
|
||||
ownerRepoCache.set(cacheKey, {
|
||||
value: null,
|
||||
@@ -181,6 +212,9 @@ async function resolveOwnerRepoForRemote(
|
||||
}
|
||||
if (classification.kind === 'indeterminate') {
|
||||
// Why: a failed ssh -G probe is not a stable "not GitHub" result.
|
||||
if (requireVerifiedSshProbe && context.connectionId) {
|
||||
throw new Error('Remote repository identity is unverifiable.')
|
||||
}
|
||||
return null
|
||||
}
|
||||
const stableConfigSignature = classification.cacheWithGitConfigSignature
|
||||
@@ -197,6 +231,9 @@ async function resolveOwnerRepoForRemote(
|
||||
// Why: only stable "no such remote" misses are safe to hold for minutes.
|
||||
// Transient git lock/IO failures must retry on the next lookup.
|
||||
if (!isStableMissingGitRemoteError(error)) {
|
||||
if (requireVerifiedSshProbe && context.connectionId) {
|
||||
throw error
|
||||
}
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
@@ -153,7 +153,7 @@ describe('registerPtyHandlers', () => {
|
||||
})
|
||||
expect(runtime.onPtyExit).not.toHaveBeenCalled()
|
||||
})
|
||||
it('does not accept an incarnation-less exit as proof that the current PTY stopped', async () => {
|
||||
it('uses inventory, not an incarnation-less exit, to confirm the current PTY stopped', async () => {
|
||||
const exitListeners = new Set<
|
||||
(payload: { id: string; code: number; incarnationId?: string }) => void
|
||||
>()
|
||||
@@ -203,7 +203,7 @@ describe('registerPtyHandlers', () => {
|
||||
await controller.spawn({ cols: 80, rows: 24 })
|
||||
await expect(controller.stopAndWait('local-incarnated')).resolves.toBe(true)
|
||||
|
||||
expect(runtime.onPtyExit).toHaveBeenCalledWith('local-incarnated', -1, 'incarnation-live')
|
||||
expect(runtime.onPtyExit).toHaveBeenCalledWith('local-incarnated', 0, 'incarnation-live')
|
||||
})
|
||||
it('runtime controller kill routes app-scoped SSH ids through the parsed provider when ownership is absent', async () => {
|
||||
const localShutdown = vi.fn()
|
||||
@@ -319,7 +319,9 @@ describe('registerPtyHandlers', () => {
|
||||
kill: (ptyId: string) => boolean
|
||||
}
|
||||
|
||||
expect(controller.kill('ssh:ssh-1@@relay-pty')).toBe(true)
|
||||
// The lease is tombstoned, but nothing observed the detached relay PTY
|
||||
// stop, so the stop itself is reported as unconfirmed.
|
||||
expect(controller.kill('ssh:ssh-1@@relay-pty')).toBe(false)
|
||||
|
||||
expect(localShutdown).not.toHaveBeenCalled()
|
||||
expect(store.markSshRemotePtyLease).toHaveBeenCalledWith('ssh-1', 'relay-pty', 'terminated')
|
||||
@@ -347,7 +349,7 @@ describe('registerPtyHandlers', () => {
|
||||
kill: (ptyId: string) => boolean
|
||||
}
|
||||
|
||||
expect(controller.kill('remote-pty')).toBe(true)
|
||||
expect(controller.kill('remote-pty')).toBe(false)
|
||||
|
||||
expect(store.markSshRemotePtyLease).toHaveBeenCalledWith(
|
||||
'ssh-1',
|
||||
@@ -356,14 +358,15 @@ describe('registerPtyHandlers', () => {
|
||||
)
|
||||
expect(runtime.onPtyExit).toHaveBeenCalledWith('remote-pty', -1, undefined)
|
||||
})
|
||||
it('retires a rejected SSH PTY after generic kill shutdown fails transiently', async () => {
|
||||
it('keeps a rejected SSH PTY unverifiable after kill shutdown fails transiently', async () => {
|
||||
const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {})
|
||||
const store = {
|
||||
markSshRemotePtyLease: vi.fn()
|
||||
}
|
||||
const runtime = {
|
||||
setPtyController: vi.fn(),
|
||||
onPtyExit: vi.fn()
|
||||
onPtyExit: vi.fn(),
|
||||
markPtyLivenessUnverifiable: vi.fn()
|
||||
}
|
||||
registerSshPtyProvider('ssh-1', {
|
||||
spawn: vi.fn(),
|
||||
@@ -399,7 +402,7 @@ describe('registerPtyHandlers', () => {
|
||||
)
|
||||
const controller = runtime.setPtyController.mock.calls[0]?.[0] as {
|
||||
kill: (ptyId: string) => boolean
|
||||
retireRejectedPty: (ptyId: string) => void
|
||||
retireRejectedPty: (ptyId: string, stopConfirmed: boolean) => void
|
||||
}
|
||||
|
||||
try {
|
||||
@@ -410,19 +413,23 @@ describe('registerPtyHandlers', () => {
|
||||
'remote-pty',
|
||||
'terminated'
|
||||
)
|
||||
controller.retireRejectedPty('remote-pty')
|
||||
controller.retireRejectedPty('remote-pty', false)
|
||||
} finally {
|
||||
warnSpy.mockRestore()
|
||||
deletePtyOwnership('remote-pty')
|
||||
}
|
||||
|
||||
expect(store.markSshRemotePtyLease).toHaveBeenCalledWith(
|
||||
expect(store.markSshRemotePtyLease).not.toHaveBeenCalledWith(
|
||||
'ssh-1',
|
||||
'remote-pty',
|
||||
'terminated'
|
||||
)
|
||||
expect(runtime.markPtyLivenessUnverifiable).toHaveBeenCalledWith(
|
||||
'remote-pty',
|
||||
'a follow-up stop was issued but its outcome could not be verified'
|
||||
)
|
||||
expect(runtime.onPtyExit).toHaveBeenCalledWith('remote-pty', -1, undefined)
|
||||
expect(runtime.onPtyExit).toHaveBeenCalledWith('remote-pty', 0, undefined)
|
||||
expect(runtime.onPtyExit).not.toHaveBeenCalledWith('remote-pty', 0, undefined)
|
||||
})
|
||||
it('strips ORCA_PANE_KEY/TAB_ID/WORKTREE_ID from SSH spawn env when remote agent hooks are disabled', async () => {
|
||||
const sshSpawn = vi.fn(async (_opts: { env: Record<string, string> }) => ({
|
||||
|
||||
@@ -615,7 +615,7 @@ describe('registerPtyHandlers', () => {
|
||||
'remote-pty',
|
||||
'terminated'
|
||||
)
|
||||
expect(runtime.onPtyExit).toHaveBeenCalledWith('remote-pty', -1, undefined)
|
||||
expect(runtime.onPtyExit).toHaveBeenCalledWith('remote-pty', 0, undefined)
|
||||
})
|
||||
it('splits the teardown budget so the liveness RPC gets only what shutdown left', async () => {
|
||||
// Why: sequential RPCs must share one absolute deadline; otherwise both get
|
||||
|
||||
@@ -2,6 +2,7 @@ import { describe, expect, it, vi } from 'vitest'
|
||||
import { spawnMock } from './pty-ipc-mock-registry'
|
||||
import { makeDeferred } from './pty-ipc-test-constants'
|
||||
import { setupPtyIpcSuite } from './pty-ipc-test-harness'
|
||||
import { LOCAL_EXECUTION_HOST_ID, toSshExecutionHostId } from '../../shared/execution-host'
|
||||
import {
|
||||
registerPtyHandlers,
|
||||
registerSshPtyProvider,
|
||||
@@ -102,11 +103,16 @@ describe('registerPtyHandlers', () => {
|
||||
})
|
||||
registerSshPtyProvider('ssh-a', { listProcesses: sshAList } as never)
|
||||
registerSshPtyProvider('ssh-b', { listProcesses: sshBList } as never)
|
||||
const runtime = { setPtyController: vi.fn() }
|
||||
setPtyOwnership('ssh-b-pty', 'ssh-b')
|
||||
const runtime = {
|
||||
setPtyController: vi.fn(),
|
||||
markPtyLivenessUnverifiable: vi.fn()
|
||||
}
|
||||
handlers.clear()
|
||||
registerPtyHandlers(mainWindow as never, runtime as never)
|
||||
const controller = runtime.setPtyController.mock.calls[0]?.[0] as {
|
||||
listProcesses(connectionId?: string | null): Promise<{ id: string }[]>
|
||||
listProcessesWithHostScope(): Promise<{ processes: { id: string }[]; hostIds: string[] }>
|
||||
}
|
||||
|
||||
await expect(controller.listProcesses(null)).resolves.toEqual([
|
||||
@@ -120,7 +126,22 @@ describe('registerPtyHandlers', () => {
|
||||
expect(sshAList).toHaveBeenCalledOnce()
|
||||
expect(sshBList).not.toHaveBeenCalled()
|
||||
|
||||
await expect(controller.listProcesses()).rejects.toThrow('ssh-b unavailable')
|
||||
await expect(controller.listProcesses()).resolves.toEqual([
|
||||
{ id: 'local-pty', title: 'Local', cwd: '/local' },
|
||||
{ id: 'ssh-a-pty' }
|
||||
])
|
||||
expect(sshBList).toHaveBeenCalledOnce()
|
||||
expect(runtime.markPtyLivenessUnverifiable).toHaveBeenCalledWith(
|
||||
'ssh-b-pty',
|
||||
'ssh-b unavailable'
|
||||
)
|
||||
|
||||
await expect(controller.listProcessesWithHostScope()).resolves.toEqual({
|
||||
processes: [{ id: 'local-pty', title: 'Local', cwd: '/local' }, { id: 'ssh-a-pty' }],
|
||||
hostIds: [LOCAL_EXECUTION_HOST_ID, toSshExecutionHostId('ssh-a')]
|
||||
})
|
||||
expect(sshBList).toHaveBeenCalledTimes(2)
|
||||
deletePtyOwnership('ssh-b-pty')
|
||||
})
|
||||
it('returns unavailable runtime confirmation for unsupported or missing providers', async () => {
|
||||
registerSshPtyProvider('ssh-1', {} as never)
|
||||
|
||||
@@ -0,0 +1,297 @@
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
import { SSH_PROVIDER_UNREGISTERED_REASON } from '../../shared/pty-liveness-verdict'
|
||||
import { setupPtyIpcSuite } from './pty-ipc-test-harness'
|
||||
import {
|
||||
registerPtyHandlers,
|
||||
deletePtyOwnership,
|
||||
setPtyOwnership,
|
||||
getLocalPtyProvider,
|
||||
registerSshPtyProvider,
|
||||
unregisterSshPtyProvider
|
||||
} from './pty'
|
||||
|
||||
vi.mock('electron', () => import('./pty-ipc-mock-registry').then((m) => m.electronModuleMock()))
|
||||
vi.mock('fs', () => import('./pty-ipc-mock-registry').then((m) => m.fsModuleMock()))
|
||||
vi.mock('node-pty', () => import('./pty-ipc-mock-registry').then((m) => m.nodePtyModuleMock()))
|
||||
vi.mock('node:child_process', async (importOriginal) =>
|
||||
(await import('./pty-ipc-mock-registry')).childProcessModuleMock(await importOriginal())
|
||||
)
|
||||
vi.mock('../opencode/hook-service', () =>
|
||||
import('./pty-ipc-mock-registry').then((m) => m.openCodeHookServiceModuleMock())
|
||||
)
|
||||
vi.mock('../mimo/hook-service', () =>
|
||||
import('./pty-ipc-mock-registry').then((m) => m.mimoHookServiceModuleMock())
|
||||
)
|
||||
vi.mock('../agent-hooks/server', () =>
|
||||
import('./pty-ipc-mock-registry').then((m) => m.agentHookServerModuleMock())
|
||||
)
|
||||
vi.mock('../pi/titlebar-extension-service', () =>
|
||||
import('./pty-ipc-mock-registry').then((m) => m.piTitlebarExtensionModuleMock())
|
||||
)
|
||||
vi.mock('../pwsh', () => import('./pty-ipc-mock-registry').then((m) => m.pwshModuleMock()))
|
||||
vi.mock('../wsl', async (importOriginal) =>
|
||||
(await import('./pty-ipc-mock-registry')).wslModuleMock(await importOriginal())
|
||||
)
|
||||
vi.mock('../telemetry/client', () =>
|
||||
import('./pty-ipc-mock-registry').then((m) => m.telemetryClientModuleMock())
|
||||
)
|
||||
vi.mock('../telemetry/classify-error', () =>
|
||||
import('./pty-ipc-mock-registry').then((m) => m.classifyErrorModuleMock())
|
||||
)
|
||||
vi.mock('../cli/linux-terminal-orca-cli-shim', () =>
|
||||
import('./pty-ipc-mock-registry').then((m) => m.linuxCliShimModuleMock())
|
||||
)
|
||||
vi.mock('../memory/pty-registry', () =>
|
||||
import('./pty-ipc-mock-registry').then((m) => m.ptyRegistryModuleMock())
|
||||
)
|
||||
vi.mock('../agent-hooks/migration-unsupported-pty-state', () =>
|
||||
import('./pty-ipc-mock-registry').then((m) => m.migrationUnsupportedPtyModuleMock())
|
||||
)
|
||||
vi.mock('../codex/codex-pane-account-registry', () =>
|
||||
import('./pty-ipc-mock-registry').then((m) => m.codexPaneAccountRegistryModuleMock())
|
||||
)
|
||||
vi.mock('../codex/codex-state-db-backfill-recovery', () =>
|
||||
import('./pty-ipc-mock-registry').then((m) => m.codexBackfillRecoveryModuleMock())
|
||||
)
|
||||
|
||||
// A detached relay PTY is designed to outlive the provider that addressed it, so
|
||||
// "the SSH provider is gone" is never evidence that the remote process stopped.
|
||||
describe('stopping a PTY whose SSH provider is unregistered', () => {
|
||||
const { handlers, mainWindow, installObservableDaemonTestProvider } = setupPtyIpcSuite()
|
||||
|
||||
function installController(): {
|
||||
controller: {
|
||||
kill: (ptyId: string) => boolean
|
||||
listProcesses: (connectionId?: string | null) => Promise<{ id: string }[]>
|
||||
retireRejectedPty: (ptyId: string, stopConfirmed: boolean) => void
|
||||
stopAndWait: (ptyId: string, opts?: { deadlineMs?: number }) => Promise<boolean>
|
||||
}
|
||||
runtime: {
|
||||
setPtyController: ReturnType<typeof vi.fn>
|
||||
onPtyExit: ReturnType<typeof vi.fn>
|
||||
markPtyLivenessUnverifiable: ReturnType<typeof vi.fn>
|
||||
markPtyLivenessLive: ReturnType<typeof vi.fn>
|
||||
}
|
||||
} {
|
||||
const runtime = {
|
||||
setPtyController: vi.fn(),
|
||||
onPtyExit: vi.fn(),
|
||||
markPtyLivenessUnverifiable: vi.fn(),
|
||||
markPtyLivenessLive: vi.fn()
|
||||
}
|
||||
handlers.clear()
|
||||
registerPtyHandlers(mainWindow as never, runtime as never)
|
||||
return {
|
||||
controller: runtime.setPtyController.mock.calls[0]?.[0] as never,
|
||||
runtime
|
||||
}
|
||||
}
|
||||
|
||||
it('reports an unconfirmed stop instead of a fabricated kill', () => {
|
||||
setPtyOwnership('ssh-detached-pty', 'ssh-dropped')
|
||||
const { controller, runtime } = installController()
|
||||
|
||||
expect(controller.kill('ssh-detached-pty')).toBe(false)
|
||||
// The local lease is still tombstoned so reconnect cannot revive the pane.
|
||||
expect(runtime.onPtyExit).toHaveBeenCalledWith('ssh-detached-pty', -1, undefined)
|
||||
expect(runtime.markPtyLivenessUnverifiable).toHaveBeenCalledWith(
|
||||
'ssh-detached-pty',
|
||||
expect.stringContaining('SSH')
|
||||
)
|
||||
deletePtyOwnership('ssh-detached-pty')
|
||||
})
|
||||
|
||||
it('reports an unconfirmed exact stop instead of a fabricated teardown', async () => {
|
||||
setPtyOwnership('ssh-detached-stop', 'ssh-dropped')
|
||||
const { controller, runtime } = installController()
|
||||
|
||||
await expect(controller.stopAndWait('ssh-detached-stop')).resolves.toBe(false)
|
||||
expect(runtime.onPtyExit).toHaveBeenCalledWith('ssh-detached-stop', -1, undefined)
|
||||
expect(runtime.markPtyLivenessUnverifiable).toHaveBeenCalledWith(
|
||||
'ssh-detached-stop',
|
||||
expect.stringContaining('SSH')
|
||||
)
|
||||
deletePtyOwnership('ssh-detached-stop')
|
||||
})
|
||||
|
||||
it('preserves lost-contact evidence for renderer IPC teardown', async () => {
|
||||
const ptyId = 'ssh-renderer-detached'
|
||||
setPtyOwnership(ptyId, 'ssh-dropped')
|
||||
const { runtime } = installController()
|
||||
try {
|
||||
await handlers.get('pty:kill')!(null, { id: ptyId })
|
||||
|
||||
expect(runtime.markPtyLivenessUnverifiable).toHaveBeenCalledWith(
|
||||
ptyId,
|
||||
SSH_PROVIDER_UNREGISTERED_REASON
|
||||
)
|
||||
expect(runtime.onPtyExit).toHaveBeenCalledWith(ptyId, -1, undefined)
|
||||
} finally {
|
||||
deletePtyOwnership(ptyId)
|
||||
}
|
||||
})
|
||||
|
||||
it('retires a rejected split without asserting an unconfirmed exit', () => {
|
||||
const ptyId = 'ssh-rejected-split'
|
||||
setPtyOwnership(ptyId, 'ssh-dropped')
|
||||
const { controller, runtime } = installController()
|
||||
try {
|
||||
controller.retireRejectedPty(ptyId, false)
|
||||
|
||||
expect(runtime.markPtyLivenessUnverifiable).toHaveBeenCalledWith(
|
||||
ptyId,
|
||||
'a follow-up stop was issued but its outcome could not be verified'
|
||||
)
|
||||
expect(runtime.onPtyExit).toHaveBeenCalledWith(ptyId, -1, undefined)
|
||||
expect(runtime.onPtyExit).not.toHaveBeenCalledWith(ptyId, 0, expect.anything())
|
||||
} finally {
|
||||
deletePtyOwnership(ptyId)
|
||||
}
|
||||
})
|
||||
|
||||
it('still confirms a stop the owning provider actually performed', async () => {
|
||||
const daemon = installObservableDaemonTestProvider()
|
||||
vi.spyOn(getLocalPtyProvider(), 'listProcesses').mockResolvedValue([])
|
||||
const { controller, runtime } = installController()
|
||||
|
||||
await expect(controller.stopAndWait('wt-1@@local-session')).resolves.toBe(true)
|
||||
expect(daemon.shutdown).toHaveBeenCalledWith(
|
||||
'wt-1@@local-session',
|
||||
expect.objectContaining({ immediate: true })
|
||||
)
|
||||
expect(runtime.markPtyLivenessUnverifiable).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('records provider-confirmed absence when the exit event was missed', async () => {
|
||||
const connectionId = 'ssh-confirmed-absent'
|
||||
const ptyId = 'ssh-confirmed-absent-pty'
|
||||
const provider = {
|
||||
onExit: vi.fn(() => () => {}),
|
||||
shutdown: vi.fn(async () => {}),
|
||||
listProcesses: vi.fn(async () => [])
|
||||
}
|
||||
registerSshPtyProvider(connectionId, provider as never)
|
||||
setPtyOwnership(ptyId, connectionId)
|
||||
try {
|
||||
const { controller, runtime } = installController()
|
||||
|
||||
await expect(controller.stopAndWait(ptyId)).resolves.toBe(true)
|
||||
expect(runtime.onPtyExit).toHaveBeenCalledWith(ptyId, 0, undefined)
|
||||
expect(runtime.markPtyLivenessUnverifiable).not.toHaveBeenCalled()
|
||||
} finally {
|
||||
deletePtyOwnership(ptyId)
|
||||
unregisterSshPtyProvider(connectionId)
|
||||
}
|
||||
})
|
||||
|
||||
it('reports lost contact when a registered provider drops during the stop', async () => {
|
||||
const connectionId = 'ssh-mid-stop-drop'
|
||||
const ptyId = 'ssh-mid-stop-pty'
|
||||
const provider = {
|
||||
onExit: vi.fn(() => () => {}),
|
||||
shutdown: vi.fn(async () => {
|
||||
throw new Error('relay disconnected during stop')
|
||||
})
|
||||
}
|
||||
registerSshPtyProvider(connectionId, provider as never)
|
||||
setPtyOwnership(ptyId, connectionId)
|
||||
try {
|
||||
const { controller, runtime } = installController()
|
||||
|
||||
await expect(controller.stopAndWait(ptyId)).resolves.toBe(false)
|
||||
expect(runtime.markPtyLivenessUnverifiable).toHaveBeenCalledWith(
|
||||
ptyId,
|
||||
'relay disconnected during stop'
|
||||
)
|
||||
} finally {
|
||||
deletePtyOwnership(ptyId)
|
||||
unregisterSshPtyProvider(connectionId)
|
||||
}
|
||||
})
|
||||
|
||||
it('preserves lost-contact evidence when fire-and-forget shutdown rejects', async () => {
|
||||
const connectionId = 'ssh-async-kill-drop'
|
||||
const ptyId = 'ssh-async-kill-pty'
|
||||
const provider = {
|
||||
onExit: vi.fn(() => () => {}),
|
||||
shutdown: vi.fn(async () => {
|
||||
throw new Error('relay disconnected during kill')
|
||||
})
|
||||
}
|
||||
registerSshPtyProvider(connectionId, provider as never)
|
||||
setPtyOwnership(ptyId, connectionId)
|
||||
try {
|
||||
const { controller, runtime } = installController()
|
||||
|
||||
expect(controller.kill(ptyId)).toBe(true)
|
||||
await vi.waitFor(() =>
|
||||
expect(runtime.markPtyLivenessUnverifiable).toHaveBeenCalledWith(
|
||||
ptyId,
|
||||
'relay disconnected during kill'
|
||||
)
|
||||
)
|
||||
expect(runtime.onPtyExit).toHaveBeenCalledWith(ptyId, -1, undefined)
|
||||
} finally {
|
||||
deletePtyOwnership(ptyId)
|
||||
unregisterSshPtyProvider(connectionId)
|
||||
}
|
||||
})
|
||||
|
||||
it('isolates a failed SSH inventory from healthy providers', async () => {
|
||||
const failedConnectionId = 'ssh-inventory-failed'
|
||||
const healthyConnectionId = 'ssh-inventory-healthy'
|
||||
const failedPtyId = 'ssh-inventory-failed-pty'
|
||||
const healthyPtyId = 'ssh-inventory-healthy-pty'
|
||||
registerSshPtyProvider(failedConnectionId, {
|
||||
listProcesses: vi.fn(async () => {
|
||||
throw new Error('inventory transport failed')
|
||||
})
|
||||
} as never)
|
||||
registerSshPtyProvider(healthyConnectionId, {
|
||||
listProcesses: vi.fn(async () => [{ id: healthyPtyId }])
|
||||
} as never)
|
||||
setPtyOwnership(failedPtyId, failedConnectionId)
|
||||
setPtyOwnership(healthyPtyId, healthyConnectionId)
|
||||
try {
|
||||
const { controller, runtime } = installController()
|
||||
|
||||
await expect(controller.listProcesses()).resolves.toEqual(
|
||||
expect.arrayContaining([{ id: healthyPtyId }])
|
||||
)
|
||||
expect(runtime.markPtyLivenessUnverifiable).toHaveBeenCalledWith(
|
||||
failedPtyId,
|
||||
'inventory transport failed'
|
||||
)
|
||||
expect(runtime.markPtyLivenessUnverifiable).not.toHaveBeenCalledWith(
|
||||
healthyPtyId,
|
||||
expect.anything()
|
||||
)
|
||||
} finally {
|
||||
deletePtyOwnership(failedPtyId)
|
||||
deletePtyOwnership(healthyPtyId)
|
||||
unregisterSshPtyProvider(failedConnectionId)
|
||||
unregisterSshPtyProvider(healthyConnectionId)
|
||||
}
|
||||
})
|
||||
|
||||
it('reports a provider-observed survivor as live', async () => {
|
||||
const connectionId = 'ssh-still-live'
|
||||
const ptyId = 'ssh-still-live-pty'
|
||||
const provider = {
|
||||
onExit: vi.fn(() => () => {}),
|
||||
shutdown: vi.fn(async () => {}),
|
||||
listProcesses: vi.fn(async () => [{ id: ptyId }])
|
||||
}
|
||||
registerSshPtyProvider(connectionId, provider as never)
|
||||
setPtyOwnership(ptyId, connectionId)
|
||||
try {
|
||||
const { controller, runtime } = installController()
|
||||
|
||||
await expect(controller.stopAndWait(ptyId)).resolves.toBe(false)
|
||||
expect(runtime.markPtyLivenessLive).toHaveBeenCalledWith(ptyId)
|
||||
} finally {
|
||||
deletePtyOwnership(ptyId)
|
||||
unregisterSshPtyProvider(connectionId)
|
||||
}
|
||||
})
|
||||
})
|
||||
+103
-22
@@ -15,6 +15,7 @@ export { getBashShellReadyRcfileContent } from '../providers/local-pty-shell-rea
|
||||
import type { OrcaRuntimeService } from '../runtime/orca-runtime'
|
||||
import type { PtyBindingSourceExpectation, Store } from '../persistence'
|
||||
import { retireTerminalSurfaceFromPersistence } from '../runtime/mobile-session-terminal-persistence-retirement'
|
||||
import { SSH_PROVIDER_UNREGISTERED_REASON } from '../../shared/pty-liveness-verdict'
|
||||
import type { GlobalSettings } from '../../shared/global-settings-types'
|
||||
import type { TuiAgent } from '../../shared/tui-agent'
|
||||
import {
|
||||
@@ -266,19 +267,36 @@ function registeredPtyProviders(): RegisteredPtyProvider[] {
|
||||
]
|
||||
}
|
||||
|
||||
async function listRegisteredPtyProcessesWithHostScope(): Promise<{
|
||||
async function listRegisteredPtyProcessesWithHostScope(
|
||||
onSshInventoryUnavailable?: (connectionId: string, error: unknown) => void
|
||||
): Promise<{
|
||||
processes: PtyProcessInfo[]
|
||||
hostIds: ExecutionHostId[]
|
||||
}> {
|
||||
const providers = registeredPtyProviders()
|
||||
const providerSessions = await Promise.all(
|
||||
providers.map(({ provider }) => provider.listProcesses())
|
||||
providers.map(async ({ provider, connectionId }) => {
|
||||
try {
|
||||
const hostId: ExecutionHostId = connectionId
|
||||
? toSshExecutionHostId(connectionId)
|
||||
: LOCAL_EXECUTION_HOST_ID
|
||||
return {
|
||||
processes: await provider.listProcesses(),
|
||||
hostId
|
||||
}
|
||||
} catch (error) {
|
||||
if (!connectionId) {
|
||||
throw error
|
||||
}
|
||||
onSshInventoryUnavailable?.(connectionId, error)
|
||||
return null
|
||||
}
|
||||
})
|
||||
)
|
||||
const respondingSessions = providerSessions.filter((session) => session !== null)
|
||||
return {
|
||||
processes: providerSessions.flat(),
|
||||
hostIds: providers.map(({ connectionId }) =>
|
||||
connectionId ? toSshExecutionHostId(connectionId) : LOCAL_EXECUTION_HOST_ID
|
||||
)
|
||||
processes: respondingSessions.flatMap((session) => session.processes),
|
||||
hostIds: respondingSessions.map((session) => session.hostId)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -3932,7 +3950,9 @@ export function registerPtyHandlers(
|
||||
return release
|
||||
},
|
||||
finalizeExit: (event) => {
|
||||
runtime?.onPtyExit(event.id, event.code, event.ptyIncarnation)
|
||||
runtime?.onPtyExit(event.id, event.code, event.ptyIncarnation, {
|
||||
hostExitConfirmed: true
|
||||
})
|
||||
finalizePtyExitForRenderer(event)
|
||||
},
|
||||
pauseProvider: (generation, id) => {
|
||||
@@ -4467,6 +4487,15 @@ export function registerPtyHandlers(
|
||||
}
|
||||
}
|
||||
|
||||
const markSshInventoryUnverifiable = (connectionId: string, error: unknown): void => {
|
||||
const reason = error instanceof Error ? error.message : String(error)
|
||||
for (const [ptyId, ownerConnectionId] of ptyOwnership) {
|
||||
if (ownerConnectionId === connectionId) {
|
||||
runtime?.markPtyLivenessUnverifiable?.(ptyId, reason)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Why: route through getProviderForPty() so CLI commands work for remote PTYs too; localProvider would silently fail for them.
|
||||
runtime?.setPtyController({
|
||||
claimStablePaneCreate: (args) => {
|
||||
@@ -4764,7 +4793,8 @@ export function registerPtyHandlers(
|
||||
rows: args.rows,
|
||||
cwd,
|
||||
env,
|
||||
...(isNewDaemonSession ? { isNewSession: true } : {})
|
||||
...(isNewDaemonSession ? { isNewSession: true } : {}),
|
||||
historyIsolationEnabled: getSettings?.()?.terminalScopeHistoryByWorktree ?? true
|
||||
}
|
||||
if (!args.connectionId && !isDaemonHostSpawn) {
|
||||
spawnOptions.codexHomePathOverride = { value: selectedCodexHomePath }
|
||||
@@ -5552,12 +5582,15 @@ export function registerPtyHandlers(
|
||||
if (connectionId) {
|
||||
// Why: runtime/CLI close can target a detached SSH PTY after its
|
||||
// provider was unregistered. Tombstone the lease so reconnect does
|
||||
// not revive a terminal the user explicitly closed.
|
||||
// not revive a terminal the user explicitly closed — but a detached
|
||||
// relay PTY outlives its provider, so report an unconfirmed stop
|
||||
// rather than a kill nobody performed.
|
||||
const incarnationId = finishPtyShutdown(ptyId, connectionId, store)
|
||||
runtime?.onPtyExit(ptyId, -1, incarnationId)
|
||||
rememberSyntheticKillExit(ptyId)
|
||||
sendPtyExitToRenderer({ id: ptyId, code: -1 })
|
||||
return true
|
||||
runtime?.markPtyLivenessUnverifiable?.(ptyId, SSH_PROVIDER_UNREGISTERED_REASON)
|
||||
return false
|
||||
}
|
||||
return false
|
||||
}
|
||||
@@ -5589,6 +5622,12 @@ export function registerPtyHandlers(
|
||||
// Why: close runtime tails without clearing provider ownership, so
|
||||
// a retry can still target a PTY that survived the failed shutdown.
|
||||
if (!retired) {
|
||||
if (connectionId) {
|
||||
runtime?.markPtyLivenessUnverifiable?.(
|
||||
ptyId,
|
||||
err instanceof Error ? err.message : String(err)
|
||||
)
|
||||
}
|
||||
runtime?.onPtyExit(ptyId, -1, ptyIncarnationById.get(ptyId))
|
||||
}
|
||||
})
|
||||
@@ -5602,6 +5641,12 @@ export function registerPtyHandlers(
|
||||
`[pty] Failed to stop PTY ${ptyId}: ${err instanceof Error ? err.message : String(err)}`
|
||||
)
|
||||
if (!retiredRejectedPtyIds.has(ptyId)) {
|
||||
if (connectionId) {
|
||||
runtime?.markPtyLivenessUnverifiable?.(
|
||||
ptyId,
|
||||
err instanceof Error ? err.message : String(err)
|
||||
)
|
||||
}
|
||||
runtime?.onPtyExit(ptyId, -1, ptyIncarnationById.get(ptyId))
|
||||
}
|
||||
})
|
||||
@@ -5609,11 +5654,23 @@ export function registerPtyHandlers(
|
||||
}
|
||||
return killWithCurrentProvider()
|
||||
},
|
||||
retireRejectedPty: (ptyId) => {
|
||||
retireRejectedPty: (ptyId, stopConfirmed) => {
|
||||
rememberRetiredRejectedPty(ptyId)
|
||||
if (!stopConfirmed) {
|
||||
runtime?.markPtyLivenessUnverifiable?.(
|
||||
ptyId,
|
||||
'a follow-up stop was issued but its outcome could not be verified'
|
||||
)
|
||||
if (!ptyOwnership.has(ptyId)) {
|
||||
return
|
||||
}
|
||||
runtime?.onPtyExit(ptyId, -1, ptyIncarnationById.get(ptyId))
|
||||
rememberSyntheticKillExit(ptyId)
|
||||
sendPtyExitToRenderer({ id: ptyId, code: -1 })
|
||||
return
|
||||
}
|
||||
// Why: a completed stop already cleared provider state, tombstoned the lease and told the
|
||||
// renderer; repeating that double-fires the exit IPC. The runtime still needs code 0 so an
|
||||
// SSH pane retires for good instead of staying preserved by the stop's negative exit.
|
||||
// renderer; repeating that double-fires the exit IPC.
|
||||
if (!ptyOwnership.has(ptyId)) {
|
||||
runtime?.onPtyExit(ptyId, 0, ptyIncarnationById.get(ptyId))
|
||||
return
|
||||
@@ -5682,13 +5739,14 @@ export function registerPtyHandlers(
|
||||
provider = connectionId ? getProvider(connectionId) : getProviderForPty(ptyId)
|
||||
} catch {
|
||||
if (connectionId) {
|
||||
// Why: an absent SSH provider means there is no live target left to
|
||||
// await, but the relay lease must still be tombstoned.
|
||||
// Why: the relay lease must still be tombstoned, but an absent SSH
|
||||
// provider is lost contact — the remote PTY is designed to survive it,
|
||||
// so nothing here observed an exit to report as a confirmed stop.
|
||||
const incarnationId = finishPtyShutdown(ptyId, connectionId, store)
|
||||
runtime?.onPtyExit(ptyId, -1, incarnationId)
|
||||
rememberSyntheticKillExit(ptyId)
|
||||
sendPtyExitToRenderer({ id: ptyId, code: -1 })
|
||||
return true
|
||||
runtime?.markPtyLivenessUnverifiable?.(ptyId, SSH_PROVIDER_UNREGISTERED_REASON)
|
||||
}
|
||||
return false
|
||||
}
|
||||
@@ -5701,6 +5759,12 @@ export function registerPtyHandlers(
|
||||
})
|
||||
} catch (err) {
|
||||
if (!isPtyAlreadyGoneError(err)) {
|
||||
if (connectionId) {
|
||||
runtime?.markPtyLivenessUnverifiable?.(
|
||||
ptyId,
|
||||
err instanceof Error ? err.message : String(err)
|
||||
)
|
||||
}
|
||||
console.warn(
|
||||
`[pty] Failed to stop PTY ${ptyId}: ${err instanceof Error ? err.message : String(err)}`
|
||||
)
|
||||
@@ -5709,9 +5773,16 @@ export function registerPtyHandlers(
|
||||
}
|
||||
try {
|
||||
if (!(await verifyPtyStopped(provider, ptyId, opts))) {
|
||||
runtime?.markPtyLivenessLive?.(ptyId)
|
||||
return false
|
||||
}
|
||||
} catch (err) {
|
||||
if (connectionId) {
|
||||
runtime?.markPtyLivenessUnverifiable?.(
|
||||
ptyId,
|
||||
err instanceof Error ? err.message : String(err)
|
||||
)
|
||||
}
|
||||
console.warn(
|
||||
`[pty] Failed to verify PTY ${ptyId} stopped: ${
|
||||
err instanceof Error ? err.message : String(err)
|
||||
@@ -5721,9 +5792,11 @@ export function registerPtyHandlers(
|
||||
}
|
||||
const incarnationId = finishPtyShutdown(ptyId, connectionId, store)
|
||||
if (!providerExitObserved) {
|
||||
runtime?.onPtyExit(ptyId, -1, incarnationId)
|
||||
// The owning provider's fresh inventory observed absence, so this is a
|
||||
// death certificate even when its exit event was missed.
|
||||
runtime?.onPtyExit(ptyId, 0, incarnationId)
|
||||
rememberSyntheticKillExit(ptyId)
|
||||
sendPtyExitToRenderer({ id: ptyId, code: -1 })
|
||||
sendPtyExitToRenderer({ id: ptyId, code: 0 })
|
||||
}
|
||||
return true
|
||||
},
|
||||
@@ -5780,11 +5853,17 @@ export function registerPtyHandlers(
|
||||
return localProvider.listProcesses()
|
||||
}
|
||||
if (connectionId !== undefined) {
|
||||
return getProvider(connectionId).listProcesses()
|
||||
try {
|
||||
return await getProvider(connectionId).listProcesses()
|
||||
} catch (error) {
|
||||
markSshInventoryUnverifiable(connectionId, error)
|
||||
throw error
|
||||
}
|
||||
}
|
||||
return (await listRegisteredPtyProcessesWithHostScope()).processes
|
||||
return (await listRegisteredPtyProcessesWithHostScope(markSshInventoryUnverifiable)).processes
|
||||
},
|
||||
listProcessesWithHostScope: listRegisteredPtyProcessesWithHostScope,
|
||||
listProcessesWithHostScope: () =>
|
||||
listRegisteredPtyProcessesWithHostScope(markSshInventoryUnverifiable),
|
||||
serializeBuffer: (ptyId, opts) => {
|
||||
// Why: mobile xterm must start from the desktop's exact screen state/dimensions before live TUI chunks render correctly.
|
||||
return requestSerializedBuffer(ptyId, opts)
|
||||
@@ -6493,7 +6572,8 @@ export function registerPtyHandlers(
|
||||
cwd,
|
||||
...(prevalidatedCwd && !isDaemonHostSpawn ? { prevalidatedCwd } : {}),
|
||||
env: spawnEnv,
|
||||
...(isMintedSessionId ? { isNewSession: true } : {})
|
||||
...(isMintedSessionId ? { isNewSession: true } : {}),
|
||||
historyIsolationEnabled: getSettings?.()?.terminalScopeHistoryByWorktree ?? true
|
||||
}
|
||||
if (!args.connectionId && !isDaemonHostSpawn) {
|
||||
spawnOptions.codexHomePathOverride = { value: selectedCodexHomePath }
|
||||
@@ -7628,6 +7708,7 @@ export function registerPtyHandlers(
|
||||
// provider is unregistered; hydrated app-scoped ids can also arrive
|
||||
// before ownership is rebuilt. Tombstone instead of falling back local.
|
||||
const incarnationId = finishPtyShutdown(args.id, connectionId, store)
|
||||
runtime?.markPtyLivenessUnverifiable?.(args.id, SSH_PROVIDER_UNREGISTERED_REASON)
|
||||
runtime?.onPtyExit(args.id, -1, incarnationId)
|
||||
rememberSyntheticKillExit(args.id)
|
||||
sendPtyExitToRenderer({ id: args.id, code: -1 })
|
||||
|
||||
@@ -265,6 +265,7 @@ import {
|
||||
removeStaleLocalWorktreeRegistrationAfterFilesystemRemoval,
|
||||
recoverLocalWindowsWorktreeRemoval
|
||||
} from '../local-worktree-removal-recovery'
|
||||
import { deleteRemoteWorktreeHistory } from '../remote-worktree-history-cleanup'
|
||||
|
||||
const NullableWorkspaceLinkedItemSchema = WorkspaceLinkedItemSchema.nullable()
|
||||
const NullableTaskSourceContextSchema = TaskSourceContextSchema.nullable()
|
||||
@@ -2493,15 +2494,15 @@ export function registerWorktreeHandlers(
|
||||
'Cannot delete the project root workspace. Remove the folder project instead.'
|
||||
)
|
||||
}
|
||||
const ownerHost = parseExecutionHostId(removalHostId)
|
||||
const sshPtyProvider =
|
||||
ownerHost?.kind === 'ssh' ? getSshPtyProvider(ownerHost.targetId) : undefined
|
||||
// Why: folder workspaces share one root, so there's no Git remove step to close shells; sweep PTYs before dropping metadata.
|
||||
await withWorktreeRemoveStageSpan('pty_sweep', 'folder', async () => {
|
||||
// Folder projects can be SSH-backed, so fence the sweep to the owning host exactly
|
||||
// like the git paths — the local inventory must never reach a remote workspace's id.
|
||||
// The resolved repo is authoritative here: path-derived metadata is shared by
|
||||
// same-id host copies and can describe a different owner's workspace.
|
||||
const ownerHost = parseExecutionHostId(removalHostId)
|
||||
const sshPtyProvider =
|
||||
ownerHost?.kind === 'ssh' ? getSshPtyProvider(ownerHost.targetId) : undefined
|
||||
const externalHost = ownerHost?.kind === 'ssh' || ownerHost?.kind === 'runtime'
|
||||
await killAllProcessesForWorktree(args.worktreeId, {
|
||||
runtime,
|
||||
@@ -2524,6 +2525,7 @@ export function registerWorktreeHandlers(
|
||||
})
|
||||
})
|
||||
await withWorktreeRemoveStageSpan('metadata_purge', 'folder', async () => {
|
||||
await deleteRemoteWorktreeHistory(sshPtyProvider, args.worktreeId)
|
||||
removeWorktreeMetadataAndTransientState(
|
||||
store,
|
||||
args.worktreeId,
|
||||
@@ -2612,6 +2614,14 @@ export function registerWorktreeHandlers(
|
||||
} finally {
|
||||
await removalGate.finish(removalCompleted)
|
||||
}
|
||||
// Why history first: the worktree is already gone from git and
|
||||
// disk by here, so a rejecting push-target cleanup must not be
|
||||
// able to skip history removal and leave the user's commands on
|
||||
// the remote host.
|
||||
await deleteRemoteWorktreeHistory(
|
||||
getSshPtyProvider(repo.connectionId),
|
||||
args.worktreeId
|
||||
)
|
||||
await cleanupUnusedWorktreePushTargetRemoteSsh(
|
||||
provider!,
|
||||
repo.path,
|
||||
@@ -2720,6 +2730,14 @@ export function registerWorktreeHandlers(
|
||||
}
|
||||
// Why: a manually deleted worktree is already gone; persisted metadata proves it was an Orca-known row, so no force is needed.
|
||||
if (repo.connectionId) {
|
||||
// Why history first: the worktree is already gone from git and
|
||||
// disk by here, so a rejecting push-target cleanup must not be
|
||||
// able to skip history removal and leave the user's commands on
|
||||
// the remote host.
|
||||
await deleteRemoteWorktreeHistory(
|
||||
getSshPtyProvider(repo.connectionId),
|
||||
args.worktreeId
|
||||
)
|
||||
await cleanupUnusedWorktreePushTargetRemoteSsh(
|
||||
provider!,
|
||||
repo.path,
|
||||
@@ -2898,6 +2916,10 @@ export function registerWorktreeHandlers(
|
||||
removedPushTarget,
|
||||
store
|
||||
)
|
||||
await deleteRemoteWorktreeHistory(
|
||||
getSshPtyProvider(remoteConnectionId),
|
||||
args.worktreeId
|
||||
)
|
||||
rememberPreservedBranchCleanupTarget(
|
||||
args.worktreeId,
|
||||
removalHostId,
|
||||
|
||||
@@ -19,8 +19,10 @@ import { splitWorktreeIdForFilesystem } from '../../shared/worktree/id'
|
||||
import { isBracketedPasteSafeShell } from '../../shared/startup-command-submission'
|
||||
import {
|
||||
injectHistoryEnv,
|
||||
updateHistFileForFallback,
|
||||
logHistoryInjection
|
||||
injectWslFishHistoryEnv,
|
||||
updateHistoryEnvForFallback,
|
||||
logHistoryInjection,
|
||||
type HistoryInjectionResult
|
||||
} from '../terminal-history'
|
||||
import type { IPtyProvider, PtyProcessInfo, PtySpawnOptions, PtySpawnResult } from './types'
|
||||
import {
|
||||
@@ -872,7 +874,15 @@ export class LocalPtyProvider implements IPtyProvider {
|
||||
historyResult = injectHistoryEnv(finalEnv, worktreeId, effectiveShellPath, cwd, {
|
||||
wslDistro: launchWslDistro
|
||||
})
|
||||
if (isWslTerminal && launchWslDistro) {
|
||||
injectWslFishHistoryEnv(finalEnv, worktreeId, launchWslDistro)
|
||||
addWslEnvKeys(finalEnv, ['HISTFILE', 'fish_history'])
|
||||
}
|
||||
logHistoryInjection(worktreeId, historyResult)
|
||||
} else {
|
||||
// Why: injectHistoryEnv is what normally clears it, so when history is off
|
||||
// an inherited ORCA_HISTFILE would still reach the wrapper. Credit: #11146.
|
||||
delete finalEnv.ORCA_HISTFILE
|
||||
}
|
||||
|
||||
await prepareLocalPtySpawn(id)
|
||||
@@ -895,8 +905,9 @@ export class LocalPtyProvider implements IPtyProvider {
|
||||
ptySpawn: pty.spawn,
|
||||
getShellReadyConfig: getFallbackShellReadyConfig,
|
||||
// Why: on zsh→bash fallback HISTFILE still points to zsh_history; update before spawn so the child inherits it (design doc §8).
|
||||
onBeforeFallbackSpawn: historyResult?.histFile
|
||||
? (env, fallbackShell) => updateHistFileForFallback(env, fallbackShell)
|
||||
onBeforeFallbackSpawn: historyResult?.historyDir
|
||||
? (env, fallbackShell) =>
|
||||
updateHistoryEnvForFallback(env, fallbackShell, historyResult as HistoryInjectionResult)
|
||||
: undefined,
|
||||
windowsFallbackAttempts
|
||||
})
|
||||
|
||||
@@ -11,7 +11,8 @@ import {
|
||||
getZshEnvTemplate,
|
||||
getZshFinalZdotdirRestoreBlock,
|
||||
getZshShellReadyMarkerRegistrationBlock,
|
||||
getZshStartupFileSourceBlock
|
||||
getZshStartupFileSourceBlock,
|
||||
ZSH_HISTFILE_RESTORE_BLOCK
|
||||
} from '../shell-templates'
|
||||
import { getBashShellReadyRcfileContent } from './local-pty-shell-ready-bash-rcfile'
|
||||
import {
|
||||
@@ -44,6 +45,7 @@ if [[ ! -o login ]]; then
|
||||
${getPosixOmpShellWrapper()}
|
||||
# Why: Codex must keep using Orca's runtime CODEX_HOME after rc files.
|
||||
[[ -n "\${ORCA_CODEX_HOME:-}" ]] && export CODEX_HOME="\${ORCA_CODEX_HOME}"
|
||||
${ZSH_HISTFILE_RESTORE_BLOCK}
|
||||
${getPosixCodexShellLaunchPreflight()}
|
||||
fi
|
||||
__orca_osc133_precmd() {
|
||||
@@ -96,6 +98,7 @@ __orca_restore_agent_teams_path
|
||||
[[ -n "\${ORCA_MIMOCODE_HOME:-}" ]] && export MIMOCODE_HOME="\${ORCA_MIMOCODE_HOME}"
|
||||
${getPosixOmpShellWrapper()}
|
||||
[[ -n "\${ORCA_CODEX_HOME:-}" ]] && export CODEX_HOME="\${ORCA_CODEX_HOME}"
|
||||
${ZSH_HISTFILE_RESTORE_BLOCK}
|
||||
${getPosixCodexShellLaunchPreflight()}
|
||||
${getZshShellReadyMarkerRegistrationBlock(SHELL_READY_MARKER_ESCAPED)}
|
||||
${getZshFinalZdotdirRestoreBlock()}
|
||||
|
||||
@@ -70,6 +70,8 @@ export type PtySpawnOptions = {
|
||||
* Existing-session attach paths must stay false so recovery checks do not
|
||||
* replace the daemon out from under a still-live PTY. */
|
||||
isNewSession?: boolean
|
||||
/** Host setting forwarded additively to the process owner; old owners ignore it. */
|
||||
historyIsolationEnabled?: boolean
|
||||
/** Attach the named session atomically or fail without creating a process. */
|
||||
attachOnly?: boolean
|
||||
/** Exact persisted owner expected by an attach-only routing decision. */
|
||||
@@ -119,6 +121,8 @@ export type IPtyProvider = {
|
||||
/** Re-probes a degraded durable host before main commits to fallback spawn semantics. */
|
||||
recoverFreshSpawnRouting?: () => Promise<boolean>
|
||||
spawn(opts: PtySpawnOptions): Promise<PtySpawnResult>
|
||||
/** Process-owner cleanup for history stored outside the workspace tree. */
|
||||
deleteWorktreeHistory?: (worktreeId: string) => Promise<void>
|
||||
/** Whether this spawn target can append the Git guard after its final env merge. */
|
||||
supportsGitCredentialGuardHost?: (sessionId?: string) => boolean
|
||||
/** Explicit false selects pre-claim legacy spawn for a preserved old daemon. */
|
||||
|
||||
@@ -370,14 +370,16 @@ describe('SshPtyProvider', () => {
|
||||
})
|
||||
})
|
||||
|
||||
it('forwards explicit shellOverride and terminalWindowsWslDistro to the relay mux', async () => {
|
||||
it('forwards shell, WSL, and scoped-history options to the relay mux', async () => {
|
||||
mux.request.mockResolvedValue({ id: 'pty-2' })
|
||||
|
||||
await provider.spawn({
|
||||
cols: 120,
|
||||
rows: 40,
|
||||
shellOverride: 'powershell.exe',
|
||||
terminalWindowsWslDistro: 'Ubuntu'
|
||||
terminalWindowsWslDistro: 'Ubuntu',
|
||||
worktreeId: 'repo-1::/remote/wt',
|
||||
historyIsolationEnabled: true
|
||||
})
|
||||
|
||||
expectRequest(mux.request, 'pty.spawn', {
|
||||
@@ -386,7 +388,9 @@ describe('SshPtyProvider', () => {
|
||||
cwd: undefined,
|
||||
env: { [POWERLEVEL10K_WIZARD_DISABLE_ENV]: 'true' },
|
||||
shellOverride: 'powershell.exe',
|
||||
terminalWindowsWslDistro: 'Ubuntu'
|
||||
terminalWindowsWslDistro: 'Ubuntu',
|
||||
worktreeId: 'repo-1::/remote/wt',
|
||||
historyIsolationEnabled: true
|
||||
})
|
||||
})
|
||||
|
||||
|
||||
@@ -27,6 +27,7 @@ import type { SshPtyLiveEvidence } from './ssh-pty-liveness-state'
|
||||
import { listSshPtyProcessesWithLiveEvidence } from './ssh-pty-process-list-liveness'
|
||||
import { attachSshPtyWithLiveEvidence } from './ssh-pty-attach-liveness'
|
||||
import { shutdownSshPty } from './ssh-pty-shutdown'
|
||||
import { requestSshPtyDefaultShell, requestSshPtyProfiles } from './ssh-pty-shell-metadata'
|
||||
|
||||
/** Remote PTY provider that proxies IPtyProvider operations through the relay. */
|
||||
export class SshPtyProvider implements IPtyProvider {
|
||||
@@ -136,6 +137,10 @@ export class SshPtyProvider implements IPtyProvider {
|
||||
})
|
||||
}
|
||||
|
||||
async deleteWorktreeHistory(worktreeId: string): Promise<void> {
|
||||
await this.mux.request('pty.deleteWorktreeHistory', { worktreeId })
|
||||
}
|
||||
|
||||
async supportsAgentSessionClaims(options: { signal?: AbortSignal } = {}): Promise<boolean> {
|
||||
return await this.agentSessionCapabilities.supportsClaims(options)
|
||||
}
|
||||
@@ -316,15 +321,9 @@ export class SshPtyProvider implements IPtyProvider {
|
||||
this.acceptExitedPtyLiveness(id)
|
||||
}
|
||||
|
||||
async getDefaultShell(): Promise<string> {
|
||||
const result = await this.mux.request('pty.getDefaultShell')
|
||||
return result as string
|
||||
}
|
||||
getDefaultShell = (): Promise<string> => requestSshPtyDefaultShell(this.mux)
|
||||
|
||||
async getProfiles(): Promise<{ name: string; path: string }[]> {
|
||||
const result = await this.mux.request('pty.getProfiles')
|
||||
return result as { name: string; path: string }[]
|
||||
}
|
||||
getProfiles = (): Promise<{ name: string; path: string }[]> => requestSshPtyProfiles(this.mux)
|
||||
|
||||
onData = (callback: SshPtyDataCallback): (() => void) => this.outputState.onData(callback)
|
||||
onRejectedData = (callback: SshPtyDataCallback): (() => void) =>
|
||||
|
||||
@@ -0,0 +1,11 @@
|
||||
import type { SshChannelMultiplexer } from '../ssh/ssh-channel-multiplexer'
|
||||
|
||||
export async function requestSshPtyDefaultShell(mux: SshChannelMultiplexer): Promise<string> {
|
||||
return (await mux.request('pty.getDefaultShell')) as string
|
||||
}
|
||||
|
||||
export async function requestSshPtyProfiles(
|
||||
mux: SshChannelMultiplexer
|
||||
): Promise<{ name: string; path: string }[]> {
|
||||
return (await mux.request('pty.getProfiles')) as { name: string; path: string }[]
|
||||
}
|
||||
@@ -22,6 +22,10 @@ export function buildSshPtySpawnRequest(args: {
|
||||
// Why: the relay needs launch identity for plugin env overlays and provider-side delivery.
|
||||
...(options.command ? { command: options.command } : {}),
|
||||
...(options.launchAgent ? { launchAgent: options.launchAgent } : {}),
|
||||
...(options.worktreeId ? { worktreeId: options.worktreeId } : {}),
|
||||
...(options.historyIsolationEnabled !== undefined
|
||||
? { historyIsolationEnabled: options.historyIsolationEnabled }
|
||||
: {}),
|
||||
...(options.shellOverride !== undefined ? { shellOverride: options.shellOverride } : {}),
|
||||
...(options.terminalWindowsWslDistro !== undefined
|
||||
? { terminalWindowsWslDistro: options.terminalWindowsWslDistro }
|
||||
|
||||
@@ -0,0 +1,30 @@
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
import { deleteRemoteWorktreeHistory } from './remote-worktree-history-cleanup'
|
||||
|
||||
describe('deleteRemoteWorktreeHistory', () => {
|
||||
it('repeats idempotent cleanup through the PTY owner', async () => {
|
||||
const deleteWorktreeHistory = vi.fn().mockResolvedValue(undefined)
|
||||
const provider = { deleteWorktreeHistory } as never
|
||||
|
||||
await deleteRemoteWorktreeHistory(provider, 'repo-1::/remote/wt')
|
||||
await deleteRemoteWorktreeHistory(provider, 'repo-1::/remote/wt')
|
||||
|
||||
expect(deleteWorktreeHistory).toHaveBeenCalledTimes(2)
|
||||
})
|
||||
|
||||
it('degrades safely when an old relay does not expose cleanup', async () => {
|
||||
const warn = vi.spyOn(console, 'warn').mockImplementation(() => {})
|
||||
const provider = {
|
||||
deleteWorktreeHistory: vi.fn().mockRejectedValue(new Error('unknown request method'))
|
||||
} as never
|
||||
|
||||
try {
|
||||
await expect(
|
||||
deleteRemoteWorktreeHistory(provider, 'repo-1::/remote/wt')
|
||||
).resolves.toBeUndefined()
|
||||
expect(warn).toHaveBeenCalledWith(expect.stringContaining('Remote cleanup unavailable'))
|
||||
} finally {
|
||||
warn.mockRestore()
|
||||
}
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,15 @@
|
||||
import type { IPtyProvider } from './providers/pty-provider-contract'
|
||||
|
||||
/** Best-effort cleanup through the PTY owner; older relays may not expose the method. */
|
||||
export async function deleteRemoteWorktreeHistory(
|
||||
provider: IPtyProvider | undefined,
|
||||
worktreeId: string
|
||||
): Promise<void> {
|
||||
try {
|
||||
await provider?.deleteWorktreeHistory?.(worktreeId)
|
||||
} catch (error) {
|
||||
console.warn(
|
||||
`[pty:history] Remote cleanup unavailable: ${error instanceof Error ? error.message : String(error)}`
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,683 @@
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest'
|
||||
import { AGENT_PROMPT_BRACKETED_PASTE_END } from '../../shared/agent-prompt-injection'
|
||||
import { OrcaRuntimeService } from './orca-runtime'
|
||||
import { makeStore } from './runtime-rpc-worktree-store-fixtures'
|
||||
|
||||
const WORKTREE_PATH = '/tmp/worktree-a'
|
||||
|
||||
vi.mock('../git/worktree', () => ({
|
||||
listWorktrees: vi.fn().mockResolvedValue([
|
||||
{
|
||||
path: '/tmp/worktree-a',
|
||||
head: 'abc',
|
||||
branch: 'feature/prompt-verification',
|
||||
isBare: false,
|
||||
isMainWorktree: false
|
||||
}
|
||||
]),
|
||||
listWorktreesStrict: vi.fn().mockResolvedValue([
|
||||
{
|
||||
path: '/tmp/worktree-a',
|
||||
head: 'abc',
|
||||
branch: 'feature/prompt-verification',
|
||||
isBare: false,
|
||||
isMainWorktree: false
|
||||
}
|
||||
])
|
||||
}))
|
||||
|
||||
async function createPromptRuntime(
|
||||
onWrite: (runtime: OrcaRuntimeService, data: string, writeIndex: number) => void
|
||||
): Promise<{ runtime: OrcaRuntimeService; handle: string; writes: string[] }> {
|
||||
const runtime = new OrcaRuntimeService(makeStore() as never)
|
||||
const writes: string[] = []
|
||||
runtime.setPtyController({
|
||||
spawn: vi.fn().mockResolvedValue({ id: 'pty-prompt' }),
|
||||
write: (_ptyId, data) => {
|
||||
writes.push(data)
|
||||
onWrite(runtime, data, writes.length)
|
||||
return true
|
||||
},
|
||||
kill: () => true,
|
||||
getForegroundProcess: async () => null
|
||||
})
|
||||
const terminal = await runtime.createTerminal(`path:${WORKTREE_PATH}`, {
|
||||
launchAgent: 'aider'
|
||||
})
|
||||
return { runtime, handle: terminal.handle, writes }
|
||||
}
|
||||
|
||||
describe('agent prompt submission runtime', () => {
|
||||
afterEach(() => vi.useRealTimers())
|
||||
|
||||
it('submits exactly once after an observed lifecycle transition', async () => {
|
||||
vi.useFakeTimers()
|
||||
const { runtime, handle, writes } = await createPromptRuntime((runtime, data) => {
|
||||
if (data === '\r') {
|
||||
runtime.onPtyData('pty-prompt', '\x1b]0;Codex working\x07', Date.now())
|
||||
}
|
||||
})
|
||||
|
||||
const submission = runtime.sendTerminalAgentPrompt(handle, 'review this')
|
||||
await vi.runAllTimersAsync()
|
||||
|
||||
await expect(submission).resolves.toMatchObject({ accepted: true })
|
||||
expect(writes.filter((data) => data === '\r')).toHaveLength(1)
|
||||
})
|
||||
|
||||
it('accepts a working-to-idle cycle completed before the first poll', async () => {
|
||||
vi.useFakeTimers()
|
||||
const { runtime, handle, writes } = await createPromptRuntime((runtime, data) => {
|
||||
if (data === '\r') {
|
||||
runtime.onPtyData('pty-prompt', '\x1b]0;Codex working\x07', Date.now())
|
||||
runtime.onPtyData('pty-prompt', '\x1b]0;Codex idle\x07', Date.now())
|
||||
}
|
||||
})
|
||||
|
||||
const submission = runtime.sendTerminalAgentPrompt(handle, 'review this')
|
||||
await vi.runAllTimersAsync()
|
||||
|
||||
await expect(submission).resolves.toMatchObject({ accepted: true })
|
||||
expect(writes.filter((data) => data === '\r')).toHaveLength(1)
|
||||
})
|
||||
|
||||
it('reports redraw-only activity as stalled without retrying Enter', async () => {
|
||||
vi.useFakeTimers()
|
||||
const { runtime, handle, writes } = await createPromptRuntime((runtime, data) => {
|
||||
if (data.includes(AGENT_PROMPT_BRACKETED_PASTE_END)) {
|
||||
runtime.onPtyData('pty-prompt', '\x1b[2J\x1b[H› review this', Date.now())
|
||||
} else if (data === '\r') {
|
||||
runtime.onPtyData('pty-prompt', '\x1b[2J\x1b[H› review this', Date.now())
|
||||
}
|
||||
})
|
||||
const submission = runtime.sendTerminalAgentPrompt(handle, 'review this')
|
||||
const rejected = expect(submission).rejects.toThrow('agent_prompt_stalled')
|
||||
|
||||
await vi.runAllTimersAsync()
|
||||
|
||||
await rejected
|
||||
expect(writes.filter((data) => data === '\r')).toHaveLength(1)
|
||||
})
|
||||
|
||||
it('reports a neutral title transition as stalled without retrying Enter', async () => {
|
||||
vi.useFakeTimers()
|
||||
const { runtime, handle, writes } = await createPromptRuntime((runtime, data) => {
|
||||
if (data === '\r') {
|
||||
runtime.onPtyData('pty-prompt', '\x1b]0;plain shell\x07', Date.now())
|
||||
runtime.onPtyData('pty-prompt', '\x1b]0;Codex idle\x07', Date.now())
|
||||
}
|
||||
})
|
||||
runtime.onPtyData('pty-prompt', '\x1b]0;Codex idle\x07', Date.now())
|
||||
const submission = runtime.sendTerminalAgentPrompt(handle, 'review this')
|
||||
const rejected = expect(submission).rejects.toThrow('agent_prompt_stalled')
|
||||
|
||||
await vi.runAllTimersAsync()
|
||||
|
||||
await rejected
|
||||
expect(writes.filter((data) => data === '\r')).toHaveLength(1)
|
||||
})
|
||||
|
||||
it('does not send Enter after a permission state appears', async () => {
|
||||
vi.useFakeTimers()
|
||||
const { runtime, handle, writes } = await createPromptRuntime((runtime, data) => {
|
||||
if (data.includes(AGENT_PROMPT_BRACKETED_PASTE_END)) {
|
||||
runtime.onPtyData('pty-prompt', '\x1b]0;Codex waiting for permission\x07', Date.now())
|
||||
}
|
||||
})
|
||||
const submission = runtime.sendTerminalAgentPrompt(handle, 'review this')
|
||||
const rejected = expect(submission).rejects.toThrow('agent_prompt_blocked')
|
||||
|
||||
await vi.runAllTimersAsync()
|
||||
|
||||
await rejected
|
||||
expect(writes).not.toContain('\r')
|
||||
})
|
||||
|
||||
it('does not paste into an existing permission prompt', async () => {
|
||||
const { runtime, handle, writes } = await createPromptRuntime(() => undefined)
|
||||
runtime.onPtyData('pty-prompt', '\x1b]0;Codex waiting for permission\x07', Date.now())
|
||||
|
||||
await expect(runtime.sendTerminalAgentPrompt(handle, 'review this')).rejects.toThrow(
|
||||
'agent_prompt_blocked'
|
||||
)
|
||||
expect(writes).toEqual([])
|
||||
})
|
||||
|
||||
it('does not paste into an output-only permission prompt', async () => {
|
||||
vi.useFakeTimers()
|
||||
vi.setSystemTime(1_000)
|
||||
const { runtime, handle, writes } = await createPromptRuntime(() => undefined)
|
||||
runtime.onPtyData('pty-prompt', '\x1b]0;Codex idle\x07', Date.now())
|
||||
vi.setSystemTime(2_000)
|
||||
runtime.onPtyData(
|
||||
'pty-prompt',
|
||||
'Permission required\nAllow once\nAllow always\nReject\n',
|
||||
Date.now()
|
||||
)
|
||||
|
||||
await expect(runtime.sendTerminalAgentPrompt(handle, 'review this')).rejects.toThrow(
|
||||
'agent_prompt_blocked'
|
||||
)
|
||||
expect(writes).toEqual([])
|
||||
})
|
||||
|
||||
it('does not paste into a coalesced live permission title', async () => {
|
||||
const { runtime, handle, writes } = await createPromptRuntime(() => undefined)
|
||||
runtime.onPtyData(
|
||||
'pty-prompt',
|
||||
'\x1b]9999;{"state":"working","agentType":"aider"}\x07' +
|
||||
'\x1b]0;Codex waiting for permission\x07',
|
||||
Date.now()
|
||||
)
|
||||
|
||||
await expect(runtime.sendTerminalAgentPrompt(handle, 'review this')).rejects.toThrow(
|
||||
'agent_prompt_blocked'
|
||||
)
|
||||
expect(writes).toEqual([])
|
||||
})
|
||||
|
||||
it('does not paste when split status stripping completes a permission title', async () => {
|
||||
vi.useFakeTimers()
|
||||
const { runtime, handle, writes } = await createPromptRuntime((runtime, data) => {
|
||||
if (data === '\r') {
|
||||
runtime.onPtyData('pty-prompt', '\x1b]0;Codex working\x07', Date.now())
|
||||
}
|
||||
})
|
||||
runtime.onPtyData(
|
||||
'pty-prompt',
|
||||
'\x1b]0;Codex waiting for permission\x1b]9999;{"state":"working","agentType":"aider"',
|
||||
Date.now()
|
||||
)
|
||||
runtime.onPtyData('pty-prompt', '}\x07\x07', Date.now())
|
||||
|
||||
const submission = runtime.sendTerminalAgentPrompt(handle, 'review this')
|
||||
const rejected = expect(submission).rejects.toThrow('agent_prompt_blocked')
|
||||
await vi.runAllTimersAsync()
|
||||
|
||||
await rejected
|
||||
expect(writes).toEqual([])
|
||||
})
|
||||
|
||||
it('preserves hook permission after an earlier live idle title', async () => {
|
||||
const { runtime, handle, writes } = await createPromptRuntime(() => undefined)
|
||||
runtime.onPtyData(
|
||||
'pty-prompt',
|
||||
'Permission required\nAllow once\nAllow always\nReject\n',
|
||||
Date.now()
|
||||
)
|
||||
runtime.onPtyData('pty-prompt', '\x1b]0;Codex idle\x07', Date.now())
|
||||
runtime.onPtyData(
|
||||
'pty-prompt',
|
||||
'\x1b]9999;{"state":"waiting","agentType":"aider"}\x07',
|
||||
Date.now()
|
||||
)
|
||||
|
||||
await expect(runtime.sendTerminalAgentPrompt(handle, 'review this')).rejects.toThrow(
|
||||
'agent_prompt_blocked'
|
||||
)
|
||||
expect(writes).toEqual([])
|
||||
})
|
||||
|
||||
it('does not block on permission text restored only as history', async () => {
|
||||
vi.useFakeTimers()
|
||||
const { runtime, handle, writes } = await createPromptRuntime((runtime, data) => {
|
||||
if (data === '\r') {
|
||||
runtime.onPtyData('pty-prompt', '\x1b]0;Codex working\x07', Date.now())
|
||||
}
|
||||
})
|
||||
runtime.seedTerminalRestoreTail('pty-prompt', {
|
||||
text: 'Permission required\r\nAllow once\r\nAllow always\r\nReject\r\n'
|
||||
})
|
||||
|
||||
const submission = runtime.sendTerminalAgentPrompt(handle, 'review this')
|
||||
await vi.runAllTimersAsync()
|
||||
|
||||
await expect(submission).resolves.toMatchObject({ accepted: true })
|
||||
expect(writes.filter((data) => data === '\r')).toHaveLength(1)
|
||||
})
|
||||
|
||||
it('does not send Enter after output-only permission appears during settlement', async () => {
|
||||
vi.useFakeTimers()
|
||||
vi.setSystemTime(1_000)
|
||||
const { runtime, handle, writes } = await createPromptRuntime((runtime, data) => {
|
||||
if (data.includes(AGENT_PROMPT_BRACKETED_PASTE_END)) {
|
||||
vi.setSystemTime(2_000)
|
||||
runtime.onPtyData(
|
||||
'pty-prompt',
|
||||
'Permission required\nAllow once\nAllow always\nReject\n',
|
||||
Date.now()
|
||||
)
|
||||
}
|
||||
})
|
||||
runtime.onPtyData('pty-prompt', '\x1b]0;Codex idle\x07', Date.now())
|
||||
const submission = runtime.sendTerminalAgentPrompt(handle, 'review this')
|
||||
const rejected = expect(submission).rejects.toThrow('agent_prompt_blocked')
|
||||
|
||||
await vi.runAllTimersAsync()
|
||||
|
||||
await rejected
|
||||
expect(writes).not.toContain('\r')
|
||||
})
|
||||
|
||||
it.each([
|
||||
'\x1b]0;Codex waiting for permission\x07\x1b]0;Codex idle\x07',
|
||||
'\x1b]9999;{"state":"working","agentType":"aider"}\x07' +
|
||||
'\x1b]0;Codex waiting for permission\x07',
|
||||
'\x1b]0;Codex waiting for permission\x07' +
|
||||
'\x1b]9999;{"state":"working","agentType":"aider"}\x07'
|
||||
])('does not send Enter after coalesced permission activity', async (output) => {
|
||||
vi.useFakeTimers()
|
||||
const { runtime, handle, writes } = await createPromptRuntime((runtime, data) => {
|
||||
if (data.includes(AGENT_PROMPT_BRACKETED_PASTE_END)) {
|
||||
runtime.onPtyData('pty-prompt', output, Date.now())
|
||||
}
|
||||
})
|
||||
const submission = runtime.sendTerminalAgentPrompt(handle, 'review this')
|
||||
const rejected = expect(submission).rejects.toThrow('agent_prompt_blocked')
|
||||
|
||||
await vi.runAllTimersAsync()
|
||||
|
||||
await rejected
|
||||
expect(writes).not.toContain('\r')
|
||||
})
|
||||
|
||||
it('stops a chunked paste when permission appears between chunks', async () => {
|
||||
const { runtime, handle, writes } = await createPromptRuntime(() => undefined)
|
||||
let writeChecks = 0
|
||||
|
||||
const submission = runtime.sendTerminalAgentPrompt(handle, 'x'.repeat(20_000), {
|
||||
beforeWrite: () => {
|
||||
writeChecks += 1
|
||||
if (writeChecks === 2) {
|
||||
runtime.onPtyData('pty-prompt', '\x1b]0;Codex waiting for permission\x07', Date.now())
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
await expect(submission).rejects.toThrow('agent_prompt_blocked')
|
||||
expect(writes).toHaveLength(2)
|
||||
expect(writes[1]).toBe(AGENT_PROMPT_BRACKETED_PASTE_END)
|
||||
expect(writes).not.toContain('\r')
|
||||
})
|
||||
|
||||
it('stops a chunked paste after transient output-only permission', async () => {
|
||||
const { runtime, handle, writes } = await createPromptRuntime(() => undefined)
|
||||
runtime.onPtyData('pty-prompt', 'initial output\n', Date.now())
|
||||
let writeChecks = 0
|
||||
|
||||
const submission = runtime.sendTerminalAgentPrompt(handle, 'x'.repeat(20_000), {
|
||||
beforeWrite: () => {
|
||||
writeChecks += 1
|
||||
if (writeChecks === 2) {
|
||||
runtime.onPtyData(
|
||||
'pty-prompt',
|
||||
'Permission required\nAllow once\nAllow always\nReject\n',
|
||||
Date.now()
|
||||
)
|
||||
runtime.onPtyData('pty-prompt', '\x1b]0;Codex working\x07', Date.now())
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
await expect(submission).rejects.toThrow('agent_prompt_blocked')
|
||||
expect(writes).toHaveLength(2)
|
||||
expect(writes[1]).toBe(AGENT_PROMPT_BRACKETED_PASTE_END)
|
||||
expect(writes).not.toContain('\r')
|
||||
})
|
||||
|
||||
it('prefers a later permission title over an earlier explicit idle status', async () => {
|
||||
const { runtime, handle, writes } = await createPromptRuntime(() => undefined)
|
||||
runtime.onPtyData(
|
||||
'pty-prompt',
|
||||
'\x1b]9999;{"state":"done","agentType":"aider"}\x07',
|
||||
Date.now()
|
||||
)
|
||||
runtime.onPtyData('pty-prompt', '\x1b]0;Codex waiting for permission\x07', Date.now())
|
||||
|
||||
await expect(runtime.sendTerminalAgentPrompt(handle, 'review this')).rejects.toThrow(
|
||||
'agent_prompt_blocked'
|
||||
)
|
||||
expect(writes).toEqual([])
|
||||
})
|
||||
|
||||
it('prefers later explicit idle evidence over stale permission output', async () => {
|
||||
vi.useFakeTimers()
|
||||
vi.setSystemTime(1_000)
|
||||
let handle = ''
|
||||
const writes: string[] = []
|
||||
const runtime = new OrcaRuntimeService(makeStore() as never, undefined, {
|
||||
getAgentStatusSnapshot: () => [
|
||||
{
|
||||
paneKey: 'prompt-pane',
|
||||
terminalHandle: handle,
|
||||
state: 'done',
|
||||
prompt: '',
|
||||
agentType: 'aider',
|
||||
connectionId: null,
|
||||
receivedAt: Date.now(),
|
||||
stateStartedAt: Date.now()
|
||||
}
|
||||
]
|
||||
})
|
||||
runtime.setPtyController({
|
||||
spawn: vi.fn().mockResolvedValue({ id: 'pty-prompt' }),
|
||||
write: (_ptyId, data) => {
|
||||
writes.push(data)
|
||||
if (data === '\r') {
|
||||
runtime.onPtyData('pty-prompt', '\x1b]0;Codex working\x07', Date.now())
|
||||
}
|
||||
return true
|
||||
},
|
||||
kill: () => true,
|
||||
getForegroundProcess: async () => null
|
||||
})
|
||||
handle = (await runtime.createTerminal(`path:${WORKTREE_PATH}`, { launchAgent: 'aider' }))
|
||||
.handle
|
||||
runtime.onPtyData(
|
||||
'pty-prompt',
|
||||
'Permission required\nAllow once\nAllow always\nReject\n' +
|
||||
'\x1b]0;Codex waiting for permission\x07',
|
||||
Date.now()
|
||||
)
|
||||
vi.setSystemTime(2_000)
|
||||
|
||||
const submission = runtime.sendTerminalAgentPrompt(handle, 'review this')
|
||||
await vi.runAllTimersAsync()
|
||||
|
||||
await expect(submission).resolves.toMatchObject({ accepted: true })
|
||||
expect(writes.filter((data) => data === '\r')).toHaveLength(1)
|
||||
})
|
||||
|
||||
it('prefers a later working title over an earlier explicit idle status', async () => {
|
||||
vi.useFakeTimers()
|
||||
const { runtime, handle, writes } = await createPromptRuntime((runtime, data) => {
|
||||
if (data === '\r') {
|
||||
runtime.onPtyData('pty-prompt', '\x1b]0;Codex working\x07', Date.now())
|
||||
}
|
||||
})
|
||||
runtime.onPtyData(
|
||||
'pty-prompt',
|
||||
'\x1b]9999;{"state":"done","agentType":"aider"}\x07',
|
||||
Date.now()
|
||||
)
|
||||
|
||||
const submission = runtime.sendTerminalAgentPrompt(handle, 'review this')
|
||||
await vi.runAllTimersAsync()
|
||||
|
||||
await expect(submission).resolves.toMatchObject({ accepted: true })
|
||||
expect(writes.filter((data) => data === '\r')).toHaveLength(1)
|
||||
})
|
||||
|
||||
it('does not treat an unchanged newer working status as submission evidence', async () => {
|
||||
vi.useFakeTimers()
|
||||
vi.setSystemTime(1_000)
|
||||
const { runtime, handle, writes } = await createPromptRuntime((runtime, data) => {
|
||||
if (data === '\r') {
|
||||
runtime.onPtyData(
|
||||
'pty-prompt',
|
||||
'\x1b]9999;{"state":"working","agentType":"aider"}\x07',
|
||||
Date.now()
|
||||
)
|
||||
}
|
||||
})
|
||||
runtime.onPtyData('pty-prompt', '\x1b]0;Codex waiting for permission\x07', Date.now())
|
||||
vi.setSystemTime(2_000)
|
||||
runtime.onPtyData(
|
||||
'pty-prompt',
|
||||
'\x1b]9999;{"state":"working","agentType":"aider"}\x07',
|
||||
Date.now()
|
||||
)
|
||||
|
||||
const submission = runtime.sendTerminalAgentPrompt(handle, 'review this')
|
||||
const rejected = expect(submission).rejects.toThrow('agent_prompt_stalled')
|
||||
await vi.runAllTimersAsync()
|
||||
|
||||
await rejected
|
||||
expect(writes.filter((data) => data === '\r')).toHaveLength(1)
|
||||
})
|
||||
|
||||
it('does not write Enter after the PTY generation changes during settlement', async () => {
|
||||
vi.useFakeTimers()
|
||||
const { runtime, handle, writes } = await createPromptRuntime(() => undefined)
|
||||
const submission = runtime.sendTerminalAgentPrompt(handle, 'review this')
|
||||
const rejected = expect(submission).rejects.toThrow('terminal_handle_stale')
|
||||
|
||||
await vi.advanceTimersByTimeAsync(0)
|
||||
expect(writes.some((data) => data.includes(AGENT_PROMPT_BRACKETED_PASTE_END))).toBe(true)
|
||||
runtime.synchronizePtyOutputSequenceFromProvider(
|
||||
'pty-prompt',
|
||||
{ value: 0, generation: 'reset' },
|
||||
runtime.getPtyOutputSequence('pty-prompt')
|
||||
)
|
||||
await vi.runAllTimersAsync()
|
||||
|
||||
await rejected
|
||||
expect(writes).not.toContain('\r')
|
||||
})
|
||||
|
||||
it('does not reuse explicit permission status across a provider generation reset', async () => {
|
||||
vi.useFakeTimers()
|
||||
const controller = new AbortController()
|
||||
const { runtime, handle, writes } = await createPromptRuntime(() => undefined)
|
||||
runtime.synchronizePtyOutputSequenceFromProvider(
|
||||
'pty-prompt',
|
||||
{ value: 0, generation: 'continued' },
|
||||
0
|
||||
)
|
||||
runtime.onPtyData(
|
||||
'pty-prompt',
|
||||
'\x1b]9999;{"state":"waiting","agentType":"aider"}\x07',
|
||||
Date.now()
|
||||
)
|
||||
runtime.synchronizePtyOutputSequenceFromProvider(
|
||||
'pty-prompt',
|
||||
{ value: 0, generation: 'reset' },
|
||||
0
|
||||
)
|
||||
|
||||
const submission = runtime.sendTerminalAgentPrompt(handle, 'review this', {
|
||||
signal: controller.signal
|
||||
})
|
||||
const rejected = expect(submission).rejects.toThrow('request_aborted')
|
||||
await vi.advanceTimersByTimeAsync(0)
|
||||
|
||||
expect(writes.some((data) => data.includes(AGENT_PROMPT_BRACKETED_PASTE_END))).toBe(true)
|
||||
controller.abort()
|
||||
await vi.runAllTimersAsync()
|
||||
await rejected
|
||||
})
|
||||
|
||||
it('does not reuse output-only permission across a provider generation reset', async () => {
|
||||
vi.useFakeTimers()
|
||||
const { runtime, handle, writes } = await createPromptRuntime((runtime, data) => {
|
||||
if (data === '\r') {
|
||||
runtime.onPtyData('pty-prompt', '\x1b]0;Codex working\x07', Date.now())
|
||||
}
|
||||
})
|
||||
runtime.synchronizePtyOutputSequenceFromProvider(
|
||||
'pty-prompt',
|
||||
{ value: 0, generation: 'continued' },
|
||||
0
|
||||
)
|
||||
runtime.onPtyData(
|
||||
'pty-prompt',
|
||||
'Permission required\nAllow once\nAllow always\nReject\n',
|
||||
Date.now()
|
||||
)
|
||||
const sequenceAtSpawnStart = runtime.getPtyOutputSequence('pty-prompt')
|
||||
runtime.synchronizePtyOutputSequenceFromProvider(
|
||||
'pty-prompt',
|
||||
{ value: 0, generation: 'reset' },
|
||||
sequenceAtSpawnStart
|
||||
)
|
||||
|
||||
const submission = runtime.sendTerminalAgentPrompt(handle, 'review this')
|
||||
await vi.runAllTimersAsync()
|
||||
|
||||
await expect(submission).resolves.toMatchObject({ accepted: true })
|
||||
expect(writes.filter((data) => data === '\r')).toHaveLength(1)
|
||||
})
|
||||
|
||||
it('fails closed when new bytes race a reset after old permission output', async () => {
|
||||
const { runtime, handle, writes } = await createPromptRuntime(() => undefined)
|
||||
runtime.synchronizePtyOutputSequenceFromProvider(
|
||||
'pty-prompt',
|
||||
{ value: 0, generation: 'continued' },
|
||||
0
|
||||
)
|
||||
runtime.onPtyData(
|
||||
'pty-prompt',
|
||||
'Permission required\nAllow once\nAllow always\nReject\n',
|
||||
Date.now()
|
||||
)
|
||||
const sequenceAtSpawnStart = runtime.getPtyOutputSequence('pty-prompt')
|
||||
runtime.onPtyData('pty-prompt', 'replacement startup output\n', Date.now())
|
||||
runtime.synchronizePtyOutputSequenceFromProvider(
|
||||
'pty-prompt',
|
||||
{ value: 0, generation: 'reset' },
|
||||
sequenceAtSpawnStart
|
||||
)
|
||||
|
||||
await expect(runtime.sendTerminalAgentPrompt(handle, 'review this')).rejects.toThrow(
|
||||
'agent_prompt_blocked'
|
||||
)
|
||||
expect(writes).toEqual([])
|
||||
})
|
||||
|
||||
it('reports permission reached after the first Enter as blocked', async () => {
|
||||
vi.useFakeTimers()
|
||||
const { runtime, handle, writes } = await createPromptRuntime((runtime, data) => {
|
||||
if (data === '\r') {
|
||||
runtime.onPtyData('pty-prompt', '\x1b]0;Codex waiting for permission\x07', Date.now())
|
||||
}
|
||||
})
|
||||
const submission = runtime.sendTerminalAgentPrompt(handle, 'review this')
|
||||
const rejected = expect(submission).rejects.toThrow('agent_prompt_blocked')
|
||||
|
||||
await vi.runAllTimersAsync()
|
||||
|
||||
await rejected
|
||||
expect(writes.filter((data) => data === '\r')).toHaveLength(1)
|
||||
})
|
||||
|
||||
it('serializes concurrent prompt submissions within one PTY generation', async () => {
|
||||
vi.useFakeTimers()
|
||||
let enterCount = 0
|
||||
const { runtime, handle, writes } = await createPromptRuntime((runtime, data) => {
|
||||
if (data === '\r') {
|
||||
enterCount += 1
|
||||
runtime.onPtyData('pty-prompt', '\x1b]0;Codex working\x07', Date.now())
|
||||
runtime.onPtyData('pty-prompt', '\x1b]0;Codex idle\x07', Date.now())
|
||||
}
|
||||
})
|
||||
|
||||
const first = runtime.sendTerminalAgentPrompt(handle, 'first prompt')
|
||||
const second = runtime.sendTerminalAgentPrompt(handle, 'second prompt')
|
||||
await vi.runAllTimersAsync()
|
||||
await Promise.all([first, second])
|
||||
|
||||
const firstPaste = writes.findIndex((data) => data.includes('first prompt'))
|
||||
const firstEnter = writes.indexOf('\r', firstPaste + 1)
|
||||
const secondPaste = writes.findIndex((data) => data.includes('second prompt'))
|
||||
const secondEnter = writes.indexOf('\r', secondPaste + 1)
|
||||
expect(firstPaste).toBeGreaterThanOrEqual(0)
|
||||
expect(firstEnter).toBeGreaterThan(firstPaste)
|
||||
expect(secondPaste).toBeGreaterThan(firstEnter)
|
||||
expect(secondEnter).toBeGreaterThan(secondPaste)
|
||||
expect(enterCount).toBe(2)
|
||||
})
|
||||
|
||||
it('does not queue a replacement generation behind an obsolete submission', async () => {
|
||||
vi.useFakeTimers()
|
||||
let releaseFirst!: () => void
|
||||
let firstWriteReached!: () => void
|
||||
const firstWrite = new Promise<void>((resolve) => {
|
||||
firstWriteReached = resolve
|
||||
})
|
||||
const firstGate = new Promise<void>((resolve) => {
|
||||
releaseFirst = resolve
|
||||
})
|
||||
const { runtime, handle, writes } = await createPromptRuntime((runtime, data) => {
|
||||
if (data === '\r') {
|
||||
runtime.onPtyData('pty-prompt', '\x1b]0;Codex working\x07', Date.now())
|
||||
}
|
||||
})
|
||||
|
||||
const first = runtime.sendTerminalAgentPrompt(handle, 'obsolete prompt', {
|
||||
beforeWrite: async () => {
|
||||
firstWriteReached()
|
||||
await firstGate
|
||||
}
|
||||
})
|
||||
await firstWrite
|
||||
runtime.synchronizePtyOutputSequenceFromProvider(
|
||||
'pty-prompt',
|
||||
{ value: 0, generation: 'reset' },
|
||||
0
|
||||
)
|
||||
|
||||
const replacement = runtime.sendTerminalAgentPrompt(handle, 'replacement prompt')
|
||||
await vi.runAllTimersAsync()
|
||||
await expect(replacement).resolves.toMatchObject({ accepted: true })
|
||||
expect(writes.some((data) => data.includes('replacement prompt'))).toBe(true)
|
||||
|
||||
releaseFirst()
|
||||
await expect(first).rejects.toThrow('terminal_handle_stale')
|
||||
})
|
||||
|
||||
it('does not close a partial paste after the PTY generation changes', async () => {
|
||||
const { runtime, handle, writes } = await createPromptRuntime(() => undefined)
|
||||
let writeChecks = 0
|
||||
|
||||
const submission = runtime.sendTerminalAgentPrompt(handle, 'x'.repeat(20_000), {
|
||||
beforeWrite: () => {
|
||||
writeChecks += 1
|
||||
if (writeChecks === 2) {
|
||||
runtime.synchronizePtyOutputSequenceFromProvider(
|
||||
'pty-prompt',
|
||||
{ value: 0, generation: 'reset' },
|
||||
runtime.getPtyOutputSequence('pty-prompt')
|
||||
)
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
await expect(submission).rejects.toThrow('terminal_handle_stale')
|
||||
expect(writes).toHaveLength(1)
|
||||
expect(writes[0]).not.toContain(AGENT_PROMPT_BRACKETED_PASTE_END)
|
||||
})
|
||||
|
||||
it('does not send delayed Enter after cancellation during settlement', async () => {
|
||||
vi.useFakeTimers()
|
||||
const controller = new AbortController()
|
||||
const { runtime, handle, writes } = await createPromptRuntime(() => undefined)
|
||||
const submission = runtime.sendTerminalAgentPrompt(handle, 'review this', {
|
||||
signal: controller.signal
|
||||
})
|
||||
const rejected = expect(submission).rejects.toThrow('request_aborted')
|
||||
|
||||
await vi.advanceTimersByTimeAsync(0)
|
||||
controller.abort()
|
||||
await vi.runAllTimersAsync()
|
||||
|
||||
await rejected
|
||||
expect(writes.filter((data) => data === '\r')).toHaveLength(0)
|
||||
})
|
||||
|
||||
it('does not send another Enter after cancellation during verification', async () => {
|
||||
vi.useFakeTimers()
|
||||
const controller = new AbortController()
|
||||
const { runtime, handle, writes } = await createPromptRuntime(() => undefined)
|
||||
const submission = runtime.sendTerminalAgentPrompt(handle, 'review this', {
|
||||
signal: controller.signal
|
||||
})
|
||||
const rejected = expect(submission).rejects.toThrow('request_aborted')
|
||||
|
||||
await vi.advanceTimersByTimeAsync(500)
|
||||
controller.abort()
|
||||
await vi.runAllTimersAsync()
|
||||
|
||||
await rejected
|
||||
expect(writes.filter((data) => data === '\r')).toHaveLength(1)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,155 @@
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest'
|
||||
import {
|
||||
AGENT_PROMPT_EFFECT_TIMEOUT_MS,
|
||||
type AgentPromptActivity,
|
||||
verifyAgentPromptSubmission
|
||||
} from './agent-prompt-submission-verification'
|
||||
|
||||
function activity(overrides: Partial<AgentPromptActivity> = {}): AgentPromptActivity {
|
||||
return {
|
||||
generation: 1,
|
||||
permissionSequence: 2,
|
||||
workingSequence: 4,
|
||||
status: 'idle',
|
||||
...overrides
|
||||
}
|
||||
}
|
||||
|
||||
describe('agent prompt submission verification', () => {
|
||||
afterEach(() => vi.useRealTimers())
|
||||
|
||||
it('accepts an observed working transition', async () => {
|
||||
vi.useFakeTimers()
|
||||
let current = activity()
|
||||
const verification = verifyAgentPromptSubmission({
|
||||
baseline: current,
|
||||
readActivity: () => current
|
||||
})
|
||||
|
||||
current = activity({ workingSequence: 5, status: 'working' })
|
||||
await vi.advanceTimersByTimeAsync(50)
|
||||
|
||||
await expect(verification).resolves.toBeUndefined()
|
||||
})
|
||||
|
||||
it('accepts a completed lifecycle transition between polls', async () => {
|
||||
vi.useFakeTimers()
|
||||
let current = activity()
|
||||
const verification = verifyAgentPromptSubmission({
|
||||
baseline: current,
|
||||
readActivity: () => current
|
||||
})
|
||||
|
||||
current = activity({ workingSequence: 5 })
|
||||
await vi.advanceTimersByTimeAsync(50)
|
||||
|
||||
await expect(verification).resolves.toBeUndefined()
|
||||
})
|
||||
|
||||
it('does not accept an unrelated transition to a neutral title', async () => {
|
||||
vi.useFakeTimers()
|
||||
let current = activity()
|
||||
const verification = verifyAgentPromptSubmission({
|
||||
baseline: current,
|
||||
readActivity: () => current
|
||||
})
|
||||
const rejected = expect(verification).rejects.toThrow('agent_prompt_stalled')
|
||||
|
||||
current = activity({ status: null })
|
||||
await vi.advanceTimersByTimeAsync(AGENT_PROMPT_EFFECT_TIMEOUT_MS)
|
||||
|
||||
await rejected
|
||||
})
|
||||
|
||||
it('reports stalled when no lifecycle transition occurs', async () => {
|
||||
vi.useFakeTimers()
|
||||
const current = activity()
|
||||
const verification = verifyAgentPromptSubmission({
|
||||
baseline: current,
|
||||
readActivity: () => current
|
||||
})
|
||||
const rejected = expect(verification).rejects.toThrow('agent_prompt_stalled')
|
||||
|
||||
await vi.advanceTimersByTimeAsync(AGENT_PROMPT_EFFECT_TIMEOUT_MS)
|
||||
|
||||
await rejected
|
||||
})
|
||||
|
||||
it('blocks when permission appears after submit', async () => {
|
||||
vi.useFakeTimers()
|
||||
let current = activity()
|
||||
const verification = verifyAgentPromptSubmission({
|
||||
baseline: current,
|
||||
readActivity: () => current
|
||||
})
|
||||
const rejected = expect(verification).rejects.toThrow('agent_prompt_blocked')
|
||||
|
||||
current = activity({ status: 'permission' })
|
||||
await vi.advanceTimersByTimeAsync(50)
|
||||
|
||||
await rejected
|
||||
})
|
||||
|
||||
it('blocks when permission appears and clears between polls', async () => {
|
||||
vi.useFakeTimers()
|
||||
let current = activity()
|
||||
const verification = verifyAgentPromptSubmission({
|
||||
baseline: current,
|
||||
readActivity: () => current
|
||||
})
|
||||
const rejected = expect(verification).rejects.toThrow('agent_prompt_blocked')
|
||||
|
||||
current = activity({ permissionSequence: 3 })
|
||||
await vi.advanceTimersByTimeAsync(50)
|
||||
|
||||
await rejected
|
||||
})
|
||||
|
||||
it('rejects an existing permission state', async () => {
|
||||
const current = activity({ status: 'permission' })
|
||||
|
||||
await expect(
|
||||
verifyAgentPromptSubmission({ baseline: current, readActivity: () => current })
|
||||
).rejects.toThrow('agent_prompt_blocked')
|
||||
})
|
||||
|
||||
it('does not accept an unchanged working baseline', async () => {
|
||||
vi.useFakeTimers()
|
||||
const current = activity({ status: 'working' })
|
||||
const verification = verifyAgentPromptSubmission({
|
||||
baseline: current,
|
||||
readActivity: () => current
|
||||
})
|
||||
const rejected = expect(verification).rejects.toThrow('agent_prompt_stalled')
|
||||
|
||||
await vi.advanceTimersByTimeAsync(AGENT_PROMPT_EFFECT_TIMEOUT_MS)
|
||||
|
||||
await rejected
|
||||
})
|
||||
|
||||
it('rejects a replaced terminal generation', async () => {
|
||||
const baseline = activity()
|
||||
|
||||
await expect(
|
||||
verifyAgentPromptSubmission({
|
||||
baseline,
|
||||
readActivity: () => activity({ generation: 2 })
|
||||
})
|
||||
).rejects.toThrow('terminal_handle_stale')
|
||||
})
|
||||
|
||||
it('cancels while waiting for activity', async () => {
|
||||
vi.useFakeTimers()
|
||||
const controller = new AbortController()
|
||||
const current = activity()
|
||||
const verification = verifyAgentPromptSubmission({
|
||||
baseline: current,
|
||||
readActivity: () => current,
|
||||
signal: controller.signal
|
||||
})
|
||||
|
||||
controller.abort()
|
||||
|
||||
await expect(verification).rejects.toThrow('request_aborted')
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,90 @@
|
||||
export const AGENT_PROMPT_EFFECT_TIMEOUT_MS = 5_000
|
||||
const AGENT_PROMPT_EFFECT_POLL_MS = 50
|
||||
|
||||
export type AgentPromptActivity = Readonly<{
|
||||
generation: number
|
||||
permissionSequence: number
|
||||
workingSequence: number
|
||||
status: 'working' | 'permission' | 'idle' | null
|
||||
}>
|
||||
|
||||
type AgentPromptVerificationOptions = {
|
||||
baseline: AgentPromptActivity
|
||||
readActivity: () => AgentPromptActivity
|
||||
signal?: AbortSignal
|
||||
}
|
||||
|
||||
export async function verifyAgentPromptSubmission(
|
||||
options: AgentPromptVerificationOptions
|
||||
): Promise<void> {
|
||||
throwIfAgentPromptAborted(options.signal)
|
||||
assertPromptNotBlocked(options.baseline, options.baseline)
|
||||
|
||||
const deadline = Date.now() + AGENT_PROMPT_EFFECT_TIMEOUT_MS
|
||||
while (Date.now() < deadline) {
|
||||
const current = options.readActivity()
|
||||
assertSamePromptGeneration(options.baseline, current)
|
||||
assertPromptNotBlocked(options.baseline, current)
|
||||
if (agentPromptLifecycleChanged(options.baseline, current)) {
|
||||
return
|
||||
}
|
||||
await waitForAgentPromptPoll(options.signal)
|
||||
}
|
||||
|
||||
const current = options.readActivity()
|
||||
assertSamePromptGeneration(options.baseline, current)
|
||||
assertPromptNotBlocked(options.baseline, current)
|
||||
if (agentPromptLifecycleChanged(options.baseline, current)) {
|
||||
return
|
||||
}
|
||||
throw new Error('agent_prompt_stalled')
|
||||
}
|
||||
|
||||
function agentPromptLifecycleChanged(
|
||||
baseline: AgentPromptActivity,
|
||||
current: AgentPromptActivity
|
||||
): boolean {
|
||||
return current.workingSequence > baseline.workingSequence
|
||||
}
|
||||
|
||||
function assertSamePromptGeneration(
|
||||
baseline: AgentPromptActivity,
|
||||
current: AgentPromptActivity
|
||||
): void {
|
||||
if (current.generation !== baseline.generation) {
|
||||
throw new Error('terminal_handle_stale')
|
||||
}
|
||||
}
|
||||
|
||||
function assertPromptNotBlocked(baseline: AgentPromptActivity, current: AgentPromptActivity): void {
|
||||
if (current.status === 'permission' || current.permissionSequence > baseline.permissionSequence) {
|
||||
throw new Error('agent_prompt_blocked')
|
||||
}
|
||||
}
|
||||
|
||||
function throwIfAgentPromptAborted(signal?: AbortSignal): void {
|
||||
if (signal?.aborted) {
|
||||
throw new Error('request_aborted')
|
||||
}
|
||||
}
|
||||
|
||||
async function waitForAgentPromptPoll(signal?: AbortSignal): Promise<void> {
|
||||
if (!signal) {
|
||||
await new Promise((resolve) => setTimeout(resolve, AGENT_PROMPT_EFFECT_POLL_MS))
|
||||
return
|
||||
}
|
||||
await new Promise<void>((resolve, reject) => {
|
||||
const onAbort = (): void => {
|
||||
clearTimeout(timer)
|
||||
reject(new Error('request_aborted'))
|
||||
}
|
||||
const timer = setTimeout(() => {
|
||||
signal.removeEventListener('abort', onAbort)
|
||||
resolve()
|
||||
}, AGENT_PROMPT_EFFECT_POLL_MS)
|
||||
signal.addEventListener('abort', onAbort, { once: true })
|
||||
if (signal.aborted) {
|
||||
onAbort()
|
||||
}
|
||||
})
|
||||
}
|
||||
@@ -189,7 +189,7 @@ describe('ClaudeAgentTeamsService', () => {
|
||||
expect(api.closeTerminal).toHaveBeenLastCalledWith('teammate-2')
|
||||
})
|
||||
|
||||
it('keeps the placeholder handle when the respawn split fails', async () => {
|
||||
it('removes the pane when replacement split fails after a confirmed placeholder stop', async () => {
|
||||
const { service, teamId, token, leaderPane, api } = createServiceWithLeader()
|
||||
const request = (argv: string[], envPane = leaderPane) =>
|
||||
service.handleTmuxCompat({ teamId, token, envPane, argv }, api)
|
||||
@@ -212,10 +212,59 @@ describe('ClaudeAgentTeamsService', () => {
|
||||
request(['respawn-pane', '-k', '-t', '%2', '--', 'claude --agent-id a'])
|
||||
).resolves.toMatchObject({ ok: false, exitCode: 1 })
|
||||
|
||||
// the placeholder terminal is left intact and the fake pane id still resolves.
|
||||
expect(api.closeTerminal).not.toHaveBeenCalled()
|
||||
await request(['kill-pane', '-t', '%2'])
|
||||
expect(api.closeTerminal).toHaveBeenCalledWith('teammate-1')
|
||||
await expect(
|
||||
request(['list-panes', '-t', 'orca:0', '-F', '#{pane_id}'])
|
||||
).resolves.toMatchObject({ stdout: '%1\n' })
|
||||
})
|
||||
|
||||
it('keeps a pane registered when its process stop is unconfirmed', async () => {
|
||||
const { service, teamId, token, leaderPane, api } = createServiceWithLeader()
|
||||
const request = (argv: string[], envPane = leaderPane) =>
|
||||
service.handleTmuxCompat({ teamId, token, envPane, argv }, api)
|
||||
|
||||
await request(['split-window', '-t', leaderPane, '-h', '-P', '-F', '#{pane_id}'])
|
||||
vi.mocked(api.closeTerminal).mockResolvedValueOnce({
|
||||
handle: 'teammate-1',
|
||||
tabId: 'tab-1',
|
||||
ptyKilled: false
|
||||
})
|
||||
|
||||
await expect(request(['kill-pane', '-t', '%2'])).resolves.toMatchObject({
|
||||
ok: false,
|
||||
exitCode: 1
|
||||
})
|
||||
await expect(
|
||||
request(['list-panes', '-t', 'orca:0', '-F', '#{pane_id}'])
|
||||
).resolves.toMatchObject({ stdout: '%1\n%2\n' })
|
||||
})
|
||||
|
||||
it('does not launch a replacement when the placeholder stop is unconfirmed', async () => {
|
||||
const { service, teamId, token, leaderPane, api, splitCalls } = createServiceWithLeader()
|
||||
const request = (argv: string[], envPane = leaderPane) =>
|
||||
service.handleTmuxCompat({ teamId, token, envPane, argv }, api)
|
||||
|
||||
await request(['split-window', '-t', leaderPane, '-h', '-P', '-F', '#{pane_id}', 'cat'])
|
||||
vi.mocked(api.closeTerminal).mockResolvedValueOnce({
|
||||
handle: 'teammate-1',
|
||||
tabId: 'tab-1',
|
||||
ptyKilled: false,
|
||||
ptyStopVerdict: 'live'
|
||||
})
|
||||
|
||||
await expect(
|
||||
request(['respawn-pane', '-k', '-t', '%2', '--', 'claude --agent-id a'])
|
||||
).resolves.toMatchObject({ ok: false, exitCode: 1 })
|
||||
expect(api.closeTerminal).toHaveBeenNthCalledWith(1, 'teammate-1')
|
||||
expect(splitCalls).toHaveLength(1)
|
||||
|
||||
await expect(
|
||||
request(['respawn-pane', '-k', '-t', '%2', '--', 'claude --agent-id a'])
|
||||
).resolves.toMatchObject({ ok: false, exitCode: 1 })
|
||||
expect(splitCalls).toHaveLength(1)
|
||||
|
||||
await request(['kill-pane', '-t', '%2'])
|
||||
expect(api.closeTerminal).toHaveBeenLastCalledWith('teammate-1')
|
||||
})
|
||||
|
||||
it('refuses to respawn the leader pane', async () => {
|
||||
|
||||
@@ -4,6 +4,7 @@ import {
|
||||
tmuxSendKeysText,
|
||||
tmuxValue
|
||||
} from '../../shared/claude-agent-teams-tmux-compat'
|
||||
import { describeUnconfirmedAgentStop } from '../../shared/pty-liveness-verdict'
|
||||
import {
|
||||
formatContext,
|
||||
paneEnv,
|
||||
@@ -157,27 +158,31 @@ export class ClaudeAgentTeamsTmuxDispatcher {
|
||||
if (!command) {
|
||||
return ''
|
||||
}
|
||||
if (pane.respawnBlockedReason) {
|
||||
throw new Error(pane.respawnBlockedReason)
|
||||
}
|
||||
const origin =
|
||||
(pane.splitFromPane ? team.panes.get(pane.splitFromPane) : undefined) ??
|
||||
team.panes.get(team.leaderPane)!
|
||||
// Why: create the replacement before destroying the placeholder so a failed
|
||||
// split leaves the fake pane id pointing at a still-live terminal; on cleanup
|
||||
// failure, discard the new split and keep the placeholder registered.
|
||||
const previousHandle = pane.handle
|
||||
const split = await api.splitTerminal(origin.handle, {
|
||||
direction: pane.splitDirection ?? 'horizontal',
|
||||
command,
|
||||
env: paneEnv(team, pane.fakePaneId),
|
||||
envToDelete: ['TERM_PROGRAM'],
|
||||
activate: false
|
||||
})
|
||||
const close = await api.closeTerminal(previousHandle)
|
||||
if (!close.ptyKilled) {
|
||||
pane.respawnBlockedReason = describeUnconfirmedAgentStop(close)
|
||||
throw new Error(pane.respawnBlockedReason)
|
||||
}
|
||||
try {
|
||||
await api.closeTerminal(previousHandle)
|
||||
const split = await api.splitTerminal(origin.handle, {
|
||||
direction: pane.splitDirection ?? 'horizontal',
|
||||
command,
|
||||
env: paneEnv(team, pane.fakePaneId),
|
||||
envToDelete: ['TERM_PROGRAM'],
|
||||
activate: false
|
||||
})
|
||||
pane.handle = split.handle
|
||||
} catch (error) {
|
||||
await api.closeTerminal(split.handle).catch(() => {})
|
||||
this.removePane(team, pane)
|
||||
throw error
|
||||
}
|
||||
pane.handle = split.handle
|
||||
return ''
|
||||
}
|
||||
|
||||
@@ -266,14 +271,21 @@ export class ClaudeAgentTeamsTmuxDispatcher {
|
||||
if (pane.fakePaneId === team.leaderPane) {
|
||||
throw new Error('refusing to kill leader pane')
|
||||
}
|
||||
await api.closeTerminal(pane.handle)
|
||||
const close = await api.closeTerminal(pane.handle)
|
||||
if (!close.ptyKilled) {
|
||||
throw new Error(describeUnconfirmedAgentStop(close))
|
||||
}
|
||||
this.removePane(team, pane)
|
||||
return ''
|
||||
}
|
||||
|
||||
private removePane(team: AgentTeam, pane: TeamPane): void {
|
||||
team.panes.delete(pane.fakePaneId)
|
||||
team.paneOrder = team.paneOrder.filter((id) => id !== pane.fakePaneId)
|
||||
if (team.mainVertical?.lastColumnPane === pane.fakePaneId) {
|
||||
team.mainVertical.lastColumnPane =
|
||||
[...team.paneOrder].toReversed().find((id) => id !== team.leaderPane) ?? null
|
||||
}
|
||||
return ''
|
||||
}
|
||||
|
||||
private async lastPane(
|
||||
|
||||
@@ -59,6 +59,7 @@ export type TeamPane = {
|
||||
// respawn can recreate it in the same slot while preserving its fake pane id.
|
||||
splitFromPane?: string
|
||||
splitDirection?: 'horizontal' | 'vertical'
|
||||
respawnBlockedReason?: string
|
||||
}
|
||||
|
||||
export type AgentTeam = {
|
||||
|
||||
@@ -35,12 +35,12 @@ describe('terminal process incarnation liveness', () => {
|
||||
).resolves.toBe('live')
|
||||
await expect(
|
||||
runtime.inspectTerminalProcessIncarnationLiveness('remote:ssh-1:pty-1:inc-old', SSH_SCOPE)
|
||||
).resolves.toBe('dead')
|
||||
).resolves.toBe('exited')
|
||||
expect(listProcesses).toHaveBeenNthCalledWith(1, 'ssh-1')
|
||||
expect(listProcesses).toHaveBeenNthCalledWith(2, 'ssh-1')
|
||||
})
|
||||
|
||||
it('keeps missing or malformed identity and unavailable inventory unknown', async () => {
|
||||
it('keeps missing or malformed identity and unavailable inventory unverifiable', async () => {
|
||||
const listProcesses = vi
|
||||
.fn()
|
||||
.mockResolvedValueOnce([{ id: 'remote:ssh-1:pty-1', cwd: '', title: 'worker' }])
|
||||
@@ -52,13 +52,13 @@ describe('terminal process incarnation liveness', () => {
|
||||
|
||||
await expect(
|
||||
runtime.inspectTerminalProcessIncarnationLiveness(PROCESS_INCARNATION, SSH_SCOPE)
|
||||
).resolves.toBe('unknown')
|
||||
).resolves.toBe('unverifiable')
|
||||
await expect(
|
||||
runtime.inspectTerminalProcessIncarnationLiveness(PROCESS_INCARNATION, SSH_SCOPE)
|
||||
).resolves.toBe('unknown')
|
||||
).resolves.toBe('unverifiable')
|
||||
await expect(
|
||||
runtime.inspectTerminalProcessIncarnationLiveness(PROCESS_INCARNATION, SSH_SCOPE)
|
||||
).resolves.toBe('unknown')
|
||||
).resolves.toBe('unverifiable')
|
||||
})
|
||||
|
||||
it('does not inspect an unproven host scope', async () => {
|
||||
@@ -67,10 +67,10 @@ describe('terminal process incarnation liveness', () => {
|
||||
|
||||
await expect(
|
||||
runtime.inspectTerminalProcessIncarnationLiveness(PROCESS_INCARNATION, null)
|
||||
).resolves.toBe('unknown')
|
||||
).resolves.toBe('unverifiable')
|
||||
await expect(
|
||||
runtime.inspectTerminalProcessIncarnationLiveness(PROCESS_INCARNATION, '{"kind":"ssh"}')
|
||||
).resolves.toBe('unknown')
|
||||
).resolves.toBe('unverifiable')
|
||||
expect(listProcesses).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
@@ -83,7 +83,7 @@ describe('terminal process incarnation liveness', () => {
|
||||
|
||||
await expect(
|
||||
runtime.inspectTerminalProcessIncarnationLiveness('local-pty:inc-1', JSON.stringify(scope))
|
||||
).resolves.toBe('dead')
|
||||
).resolves.toBe('exited')
|
||||
expect(listProcesses).toHaveBeenCalledWith(connectionId)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -311,7 +311,7 @@ describe('terminal close and handle incarnation continuity', () => {
|
||||
|
||||
harness.retirePersistedTab()
|
||||
harness.acknowledged.resolve()
|
||||
await expect(closing).resolves.toMatchObject({ handle, tabId: TAB_ID, ptyKilled: true })
|
||||
await expect(closing).resolves.toMatchObject({ handle, tabId: TAB_ID, ptyKilled: false })
|
||||
expect(harness.kill).toHaveBeenCalledWith(PTY_ID)
|
||||
expect(harness.closeTerminal).not.toHaveBeenCalled()
|
||||
expect(harness.getSession().tabsByWorktree[WORKTREE_ID]).toEqual([])
|
||||
@@ -328,7 +328,7 @@ describe('terminal close and handle incarnation continuity', () => {
|
||||
expect(harness.getSession().tabsByWorktree[WORKTREE_ID]).toHaveLength(1)
|
||||
})
|
||||
|
||||
it('kills every live tab PTY after retirement when the renderer graph is stale', async () => {
|
||||
it('requests a stop for every live tab PTY after retirement when the renderer graph is stale', async () => {
|
||||
const harness = createHarness()
|
||||
harness.syncSplitFixtureGraph()
|
||||
const terminal = (await harness.runtime.listTerminals(`id:${WORKTREE_ID}`)).terminals.find(
|
||||
@@ -342,7 +342,7 @@ describe('terminal close and handle incarnation continuity', () => {
|
||||
|
||||
harness.retirePersistedTab()
|
||||
harness.acknowledged.resolve()
|
||||
await expect(closing).resolves.toMatchObject({ ptyKilled: true })
|
||||
await expect(closing).resolves.toMatchObject({ ptyKilled: false })
|
||||
expect(harness.kill).toHaveBeenCalledWith(PTY_ID)
|
||||
expect(harness.kill).toHaveBeenCalledWith(SIBLING_PTY_ID)
|
||||
})
|
||||
@@ -365,7 +365,64 @@ describe('terminal close and handle incarnation continuity', () => {
|
||||
expect(harness.kill).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('falls back to kill when verified teardown rejects after retirement', async () => {
|
||||
it('reports an unconfirmed stop on the close receipt rather than a bare uncertain false', async () => {
|
||||
const harness = createHarness()
|
||||
const [{ handle }] = (await harness.runtime.listTerminals(`id:${WORKTREE_ID}`)).terminals
|
||||
// Mirrors pty.ts when the SSH provider is gone: the lease is tombstoned, the
|
||||
// stop reports false, and the PTY is marked as contact we lost — not a kill.
|
||||
harness.kill.mockReturnValue(false)
|
||||
harness.setVerifiedStopResult(false)
|
||||
harness.runtime.markPtyLivenessUnverifiable(PTY_ID, 'its SSH provider is no longer registered')
|
||||
|
||||
const closing = harness.runtime.closeTerminal(handle)
|
||||
await vi.waitFor(() => expect(harness.closeTerminalTab).toHaveBeenCalled())
|
||||
harness.retirePersistedTab()
|
||||
harness.acknowledged.resolve()
|
||||
|
||||
await expect(closing).resolves.toMatchObject({
|
||||
ptyKilled: false,
|
||||
ptyStopVerdict: 'unverifiable',
|
||||
ptyStopReason: 'its SSH provider is no longer registered'
|
||||
})
|
||||
expect(harness.kill).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('downgrades a live verdict after issuing an unverified follow-up stop', async () => {
|
||||
const harness = createHarness()
|
||||
const [{ handle }] = (await harness.runtime.listTerminals(`id:${WORKTREE_ID}`)).terminals
|
||||
harness.setVerifiedStopResult(false)
|
||||
harness.runtime.markPtyLivenessLive(PTY_ID)
|
||||
|
||||
const closing = harness.runtime.closeTerminal(handle)
|
||||
await vi.waitFor(() => expect(harness.closeTerminalTab).toHaveBeenCalled())
|
||||
harness.retirePersistedTab()
|
||||
harness.acknowledged.resolve()
|
||||
|
||||
await expect(closing).resolves.toMatchObject({
|
||||
ptyKilled: false,
|
||||
ptyStopVerdict: 'unverifiable',
|
||||
ptyStopReason: 'a follow-up stop was issued but its outcome could not be verified'
|
||||
})
|
||||
expect(harness.kill).toHaveBeenCalledWith(PTY_ID)
|
||||
})
|
||||
|
||||
it('leaves a confirmed kill receipt free of any stop verdict', async () => {
|
||||
const harness = createHarness()
|
||||
const [{ handle }] = (await harness.runtime.listTerminals(`id:${WORKTREE_ID}`)).terminals
|
||||
harness.setVerifiedStopResult(true)
|
||||
|
||||
const closing = harness.runtime.closeTerminal(handle)
|
||||
await vi.waitFor(() => expect(harness.closeTerminalTab).toHaveBeenCalled())
|
||||
harness.retirePersistedTab()
|
||||
harness.acknowledged.resolve()
|
||||
|
||||
const close = await closing
|
||||
expect(close.ptyKilled).toBe(true)
|
||||
expect(close.ptyStopVerdict).toBeUndefined()
|
||||
expect(close.ptyStopReason).toBeUndefined()
|
||||
})
|
||||
|
||||
it('reports an unconfirmed stop when verified teardown rejects after retirement', async () => {
|
||||
const harness = createHarness()
|
||||
const [{ handle }] = (await harness.runtime.listTerminals(`id:${WORKTREE_ID}`)).terminals
|
||||
harness.setVerifiedStopResult(new Error('provider_unavailable'))
|
||||
@@ -375,7 +432,11 @@ describe('terminal close and handle incarnation continuity', () => {
|
||||
harness.retirePersistedTab()
|
||||
harness.acknowledged.resolve()
|
||||
|
||||
await expect(closing).resolves.toMatchObject({ ptyKilled: true })
|
||||
await expect(closing).resolves.toMatchObject({
|
||||
ptyKilled: false,
|
||||
ptyStopVerdict: 'unverifiable',
|
||||
ptyStopReason: 'provider_unavailable'
|
||||
})
|
||||
expect(harness.stopAndWait).toHaveBeenCalledWith(PTY_ID, {
|
||||
deadlineMs: expect.any(Number)
|
||||
})
|
||||
@@ -398,7 +459,7 @@ describe('terminal close and handle incarnation continuity', () => {
|
||||
harness.makeSessionUnavailable()
|
||||
harness.acknowledged.resolve()
|
||||
|
||||
await expect(closing).resolves.toMatchObject({ handle, tabId: TAB_ID, ptyKilled: true })
|
||||
await expect(closing).resolves.toMatchObject({ handle, tabId: TAB_ID, ptyKilled: false })
|
||||
expect(harness.closeTerminal).toHaveBeenCalledWith(TAB_ID)
|
||||
expect(harness.stopAndWait).toHaveBeenCalledWith(RUNTIME_OWNED_PTY_ID, {
|
||||
deadlineMs: expect.any(Number)
|
||||
|
||||
@@ -348,7 +348,7 @@ describe('remote runtime terminal split authority', () => {
|
||||
expect.objectContaining({ deadlineMs: expect.any(Number) })
|
||||
)
|
||||
expect(harness.kill).not.toHaveBeenCalled()
|
||||
expect(harness.retireRejectedPty).toHaveBeenCalledWith(SPLIT_PTY_ID)
|
||||
expect(harness.retireRejectedPty).toHaveBeenCalledWith(SPLIT_PTY_ID, true)
|
||||
})
|
||||
|
||||
it('revalidates a projected paired-runtime source after renderer adoption', async () => {
|
||||
@@ -374,7 +374,7 @@ describe('remote runtime terminal split authority', () => {
|
||||
expect.objectContaining({ deadlineMs: expect.any(Number) })
|
||||
)
|
||||
expect(harness.kill).toHaveBeenCalledWith(SPLIT_PTY_ID)
|
||||
expect(harness.retireRejectedPty).toHaveBeenCalledWith(SPLIT_PTY_ID)
|
||||
expect(harness.retireRejectedPty).toHaveBeenCalledWith(SPLIT_PTY_ID, false)
|
||||
})
|
||||
|
||||
it('preserves the split error when kill and retirement throw', async () => {
|
||||
@@ -398,6 +398,6 @@ describe('remote runtime terminal split authority', () => {
|
||||
|
||||
await expect(split).rejects.toThrow('terminal_split_source_not_found')
|
||||
expect(harness.kill).toHaveBeenCalledWith(SPLIT_PTY_ID)
|
||||
expect(harness.retireRejectedPty).toHaveBeenCalledWith(SPLIT_PTY_ID)
|
||||
expect(harness.retireRejectedPty).toHaveBeenCalledWith(SPLIT_PTY_ID, false)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -166,6 +166,16 @@ function resetPlatform(): void {
|
||||
}
|
||||
}
|
||||
|
||||
function acknowledgeAgentPromptSubmit(
|
||||
runtime: OrcaRuntimeService,
|
||||
ptyId: string,
|
||||
data: string
|
||||
): void {
|
||||
if (data === '\r') {
|
||||
runtime.onPtyData(ptyId, '\x1b]0;Codex working\x07', Date.now())
|
||||
}
|
||||
}
|
||||
|
||||
const electronMocks = vi.hoisted(() => {
|
||||
type Listener = (...args: unknown[]) => void
|
||||
const listeners = new Map<string, Set<Listener>>()
|
||||
@@ -6455,7 +6465,8 @@ describe('OrcaRuntimeService', () => {
|
||||
worktreeId: `${TEST_REPO_ID}::/remote/feature`
|
||||
}
|
||||
]),
|
||||
shutdown: vi.fn().mockResolvedValue(undefined)
|
||||
shutdown: vi.fn().mockResolvedValue(undefined),
|
||||
deleteWorktreeHistory: vi.fn().mockResolvedValue(undefined)
|
||||
}
|
||||
const runtime = new OrcaRuntimeService(remoteStore as never, undefined, {
|
||||
getSshProvider: () => ptyProvider as never
|
||||
@@ -6472,6 +6483,9 @@ describe('OrcaRuntimeService', () => {
|
||||
'pty-remote',
|
||||
expect.objectContaining({ immediate: true })
|
||||
)
|
||||
expect(ptyProvider.deleteWorktreeHistory).toHaveBeenCalledWith(
|
||||
`${TEST_REPO_ID}::/remote/feature`
|
||||
)
|
||||
expect(ptyProvider.shutdown.mock.invocationCallOrder[0]).toBeLessThan(
|
||||
gitProvider.removeWorktree.mock.invocationCallOrder[0]
|
||||
)
|
||||
@@ -16762,6 +16776,7 @@ describe('OrcaRuntimeService', () => {
|
||||
spawn: vi.fn().mockResolvedValue({ id: 'pty-bg' }),
|
||||
write: (_ptyId, data) => {
|
||||
writes.push(data)
|
||||
acknowledgeAgentPromptSubmit(runtime, 'pty-bg', data)
|
||||
return true
|
||||
},
|
||||
kill: () => true,
|
||||
@@ -16828,6 +16843,7 @@ describe('OrcaRuntimeService', () => {
|
||||
} else {
|
||||
prematureEnters += 1
|
||||
}
|
||||
acknowledgeAgentPromptSubmit(runtime, 'pty-bg', data)
|
||||
}
|
||||
return true
|
||||
},
|
||||
@@ -16876,6 +16892,7 @@ describe('OrcaRuntimeService', () => {
|
||||
spawn: vi.fn().mockResolvedValue({ id: 'pty-bg' }),
|
||||
write: (_ptyId, data) => {
|
||||
writes.push(data)
|
||||
acknowledgeAgentPromptSubmit(runtime, 'pty-bg', data)
|
||||
return true
|
||||
},
|
||||
kill: () => true,
|
||||
@@ -16913,6 +16930,7 @@ describe('OrcaRuntimeService', () => {
|
||||
runtime.onPtyData('pty-bg', '\x1b[?25hcomposer rendered', Date.now())
|
||||
}, 1_200)
|
||||
}
|
||||
acknowledgeAgentPromptSubmit(runtime, 'pty-bg', data)
|
||||
return true
|
||||
},
|
||||
kill: () => true,
|
||||
@@ -16945,6 +16963,7 @@ describe('OrcaRuntimeService', () => {
|
||||
spawn: vi.fn().mockResolvedValue({ id: 'pty-bg' }),
|
||||
write: (_ptyId, data) => {
|
||||
writes.push(data)
|
||||
acknowledgeAgentPromptSubmit(runtime, 'pty-bg', data)
|
||||
return true
|
||||
},
|
||||
kill: () => true,
|
||||
@@ -16983,6 +17002,7 @@ describe('OrcaRuntimeService', () => {
|
||||
runtime.onPtyData('pty-bg', 'final slow composer frame', Date.now())
|
||||
}, 8_100)
|
||||
}
|
||||
acknowledgeAgentPromptSubmit(runtime, 'pty-bg', data)
|
||||
return true
|
||||
},
|
||||
kill: () => true,
|
||||
@@ -17024,6 +17044,7 @@ describe('OrcaRuntimeService', () => {
|
||||
)
|
||||
}
|
||||
}
|
||||
acknowledgeAgentPromptSubmit(runtime, 'pty-bg', data)
|
||||
return true
|
||||
},
|
||||
kill: () => true,
|
||||
@@ -17054,6 +17075,7 @@ describe('OrcaRuntimeService', () => {
|
||||
spawn: vi.fn().mockResolvedValue({ id: 'pty-bg' }),
|
||||
write: (_ptyId, data) => {
|
||||
writes.push(data)
|
||||
acknowledgeAgentPromptSubmit(runtime, 'pty-bg', data)
|
||||
return true
|
||||
},
|
||||
kill: () => true,
|
||||
@@ -33140,7 +33162,7 @@ describe('OrcaRuntimeService', () => {
|
||||
expect(spawn).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('uses POSIX quoting for mobile agent launch commands in WSL project runtimes', async () => {
|
||||
it('uses portable Unix quoting for mobile agent launch commands in WSL project runtimes', async () => {
|
||||
await withPlatform('win32', async () => {
|
||||
const spawn = vi.fn().mockResolvedValue({ id: 'pty-agent' })
|
||||
const runtime = new OrcaRuntimeService({
|
||||
@@ -33178,7 +33200,7 @@ describe('OrcaRuntimeService', () => {
|
||||
|
||||
expect(spawn).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
command: "command-code --profile mobile '--note' 'can'\\''t'",
|
||||
command: `command-code --profile mobile '--note' 'can'"'"'t'`,
|
||||
cwd: TEST_WORKTREE_PATH,
|
||||
worktreeId: TEST_WORKTREE_ID
|
||||
})
|
||||
@@ -48417,6 +48439,145 @@ describe('OrcaRuntimeService', () => {
|
||||
}
|
||||
})
|
||||
|
||||
it('routes already-missing SSH runtime history cleanup through the PTY owner', async () => {
|
||||
const repo = {
|
||||
id: 'repo-runtime-ssh',
|
||||
path: '/remote/repo',
|
||||
displayName: 'ssh',
|
||||
badgeColor: 'blue',
|
||||
addedAt: 1,
|
||||
connectionId: 'ssh-1'
|
||||
}
|
||||
const worktreeId = `${repo.id}::/remote/already-deleted`
|
||||
const metaById: Record<string, WorktreeMeta> = {
|
||||
[worktreeId]: makeWorktreeMeta({ hostId: 'ssh:ssh-1', orcaCreationSource: 'ssh' })
|
||||
}
|
||||
const removeWorktreeMeta = vi.fn((id: string) => {
|
||||
delete metaById[id]
|
||||
})
|
||||
const runtimeStore = {
|
||||
...store,
|
||||
getRepos: () => [repo],
|
||||
getRepo: (id: string) => (id === repo.id ? repo : undefined),
|
||||
getAllWorktreeMeta: () => metaById,
|
||||
getWorktreeMeta: (id: string) => metaById[id],
|
||||
removeWorktreeMeta
|
||||
}
|
||||
const gitProvider = {
|
||||
listWorktrees: vi.fn().mockResolvedValue([
|
||||
{
|
||||
path: repo.path,
|
||||
head: 'main',
|
||||
branch: 'refs/heads/main',
|
||||
isBare: false,
|
||||
isMainWorktree: true
|
||||
}
|
||||
])
|
||||
}
|
||||
const fsProvider = {
|
||||
stat: vi.fn().mockRejectedValue(Object.assign(new Error('missing'), { code: 'ENOENT' }))
|
||||
}
|
||||
const deleteWorktreeHistory = vi.fn().mockResolvedValue(undefined)
|
||||
const ptyProvider = { deleteWorktreeHistory } as never
|
||||
registerSshGitProvider(repo.connectionId, gitProvider as never)
|
||||
registerSshFilesystemProvider(repo.connectionId, fsProvider as never)
|
||||
const runtime = new OrcaRuntimeService(runtimeStore as never, undefined, {
|
||||
getSshProvider: () => ptyProvider
|
||||
})
|
||||
|
||||
try {
|
||||
await expect(runtime.removeManagedWorktree(`id:${worktreeId}`)).resolves.toEqual({})
|
||||
} finally {
|
||||
unregisterSshGitProvider(repo.connectionId)
|
||||
unregisterSshFilesystemProvider(repo.connectionId)
|
||||
}
|
||||
|
||||
expect(deleteWorktreeHistory).toHaveBeenCalledWith(worktreeId)
|
||||
expect(deleteWorktreeHistory.mock.invocationCallOrder[0]).toBeLessThan(
|
||||
removeWorktreeMeta.mock.invocationCallOrder[0]
|
||||
)
|
||||
expect(removeWorktreeMeta).toHaveBeenCalledWith(worktreeId, 'ssh:ssh-1')
|
||||
})
|
||||
|
||||
it('routes SSH runtime orphan-directory history cleanup through the PTY owner', async () => {
|
||||
const repo = {
|
||||
id: 'repo-runtime-ssh',
|
||||
path: '/remote/repo',
|
||||
displayName: 'ssh',
|
||||
badgeColor: 'blue',
|
||||
addedAt: 1,
|
||||
connectionId: 'ssh-1'
|
||||
}
|
||||
const worktreePath = '/remote/orphan'
|
||||
const worktreeId = `${repo.id}::${worktreePath}`
|
||||
const metaById: Record<string, WorktreeMeta> = {
|
||||
[worktreeId]: makeWorktreeMeta({
|
||||
hostId: 'ssh:ssh-1',
|
||||
orcaCreatedAt: Date.now(),
|
||||
orcaCreationSource: 'ssh'
|
||||
})
|
||||
}
|
||||
const removeWorktreeMeta = vi.fn((id: string) => {
|
||||
delete metaById[id]
|
||||
})
|
||||
const runtimeStore = {
|
||||
...store,
|
||||
getRepos: () => [repo],
|
||||
getRepo: (id: string) => (id === repo.id ? repo : undefined),
|
||||
getAllWorktreeMeta: () => metaById,
|
||||
getWorktreeMeta: (id: string) => metaById[id],
|
||||
removeWorktreeMeta
|
||||
}
|
||||
const gitProvider = {
|
||||
listWorktrees: vi.fn().mockResolvedValue([
|
||||
{
|
||||
path: repo.path,
|
||||
head: 'main',
|
||||
branch: 'refs/heads/main',
|
||||
isBare: false,
|
||||
isMainWorktree: true
|
||||
}
|
||||
])
|
||||
}
|
||||
const fsProvider = {
|
||||
lstat: vi.fn(async (path: string) => ({
|
||||
type: path === `${worktreePath}/.git` ? 'file' : 'directory'
|
||||
})),
|
||||
readFile: vi.fn(async (path: string) => ({
|
||||
isBinary: false,
|
||||
content:
|
||||
path === `${worktreePath}/.git`
|
||||
? `gitdir: ${repo.path}/.git/worktrees/orphan\n`
|
||||
: `${worktreePath}/.git\n`
|
||||
})),
|
||||
deletePath: vi.fn().mockResolvedValue(undefined)
|
||||
}
|
||||
const deleteWorktreeHistory = vi.fn().mockResolvedValue(undefined)
|
||||
const ptyProvider = {
|
||||
listProcesses: vi.fn().mockResolvedValue([]),
|
||||
shutdown: vi.fn().mockResolvedValue(undefined),
|
||||
deleteWorktreeHistory
|
||||
}
|
||||
registerSshGitProvider(repo.connectionId, gitProvider as never)
|
||||
registerSshFilesystemProvider(repo.connectionId, fsProvider as never)
|
||||
const runtime = new OrcaRuntimeService(runtimeStore as never, undefined, {
|
||||
getSshProvider: () => ptyProvider as never
|
||||
})
|
||||
|
||||
try {
|
||||
await expect(runtime.removeManagedWorktree(`id:${worktreeId}`, true)).resolves.toEqual({})
|
||||
} finally {
|
||||
unregisterSshGitProvider(repo.connectionId)
|
||||
unregisterSshFilesystemProvider(repo.connectionId)
|
||||
}
|
||||
|
||||
expect(fsProvider.deletePath).toHaveBeenCalledWith(worktreePath, true)
|
||||
expect(deleteWorktreeHistory).toHaveBeenCalledWith(worktreeId)
|
||||
expect(deleteWorktreeHistory.mock.invocationCallOrder[0]).toBeLessThan(
|
||||
removeWorktreeMeta.mock.invocationCallOrder[0]
|
||||
)
|
||||
})
|
||||
|
||||
it('force-removes a legacy Orca-created runtime orphaned worktree directory after Git tracking is gone', async () => {
|
||||
const parentDir = await mkdtemp(join(tmpdir(), 'orca-runtime-orphan-'))
|
||||
const repoPath = join(parentDir, 'repo')
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,121 @@
|
||||
import { attachCoordinatorRunStore } from './coordinator-runs/coordinator-run-store'
|
||||
import { attachDecisionGateStore } from './decision-gates/decision-gate-store'
|
||||
import { attachDispatchCapability } from './dispatch-context/dispatch-capability'
|
||||
import { attachDispatchCompletion } from './dispatch-context/dispatch-completion'
|
||||
import { attachDispatchContextStore } from './dispatch-context/dispatch-context-store'
|
||||
import { attachDispatchLookup } from './dispatch-context/dispatch-lookup'
|
||||
import { attachWorkerReportSettlement } from './dispatch-context/worker-report-settlement'
|
||||
import { attachFederatedDispatchStore } from './federation/federated-dispatch-store'
|
||||
import { attachFederationRelayAck } from './federation/federation-relay-ack'
|
||||
import { attachFederationRelayEnqueue } from './federation/federation-relay-enqueue'
|
||||
import { attachFederationRelayImport } from './federation/federation-relay-import'
|
||||
import { attachFederationRelayItem } from './federation/federation-relay-item'
|
||||
import { attachRemoteDispatchAttachmentAuthority } from './federation/remote-dispatch-attachment-authority'
|
||||
import { attachRemoteDispatchAttachmentCreate } from './federation/remote-dispatch-attachment-create'
|
||||
import { attachRemoteDispatchAttachmentStop } from './federation/remote-dispatch-attachment-stop'
|
||||
import { attachRemoteQuestionStore } from './federation/remote-question-store'
|
||||
import { attachLegacyAskOperation } from './legacy/legacy-ask-operation'
|
||||
import { attachLegacyCompatibilityCandidates } from './legacy/legacy-compatibility-candidates'
|
||||
import { attachLegacyCompatibilityPrincipals } from './legacy/legacy-compatibility-principals'
|
||||
import { attachLegacyLifecycleOperation } from './legacy/legacy-lifecycle-operation'
|
||||
import { attachLegacyMailAcknowledge } from './legacy/legacy-mail-acknowledge'
|
||||
import { attachLegacyQuestionAcknowledge } from './legacy/legacy-question-acknowledge'
|
||||
import { attachLegacyQuestionLookup } from './legacy/legacy-question-lookup'
|
||||
import { attachLegacyRecoveryCohort } from './legacy/legacy-recovery-cohort'
|
||||
import { attachLegacyReplyOperation } from './legacy/legacy-reply-operation'
|
||||
import { attachLegacyWorkerCompletion } from './legacy/legacy-worker-completion'
|
||||
import { attachDirectMailboxRouting } from './messages/direct-mailbox-routing'
|
||||
import { attachForeignDirectMailboxRouting } from './messages/foreign-direct-mailbox-routing'
|
||||
import { attachMessageInbox } from './messages/message-inbox'
|
||||
import { attachMessageInsert } from './messages/message-insert'
|
||||
import { attachMutationReceiptStore } from './mutation-receipts/mutation-receipt-store'
|
||||
import { attachQuestionThreads } from './questions/question-threads'
|
||||
import { attachOrchestrationReset } from './reset/orchestration-reset'
|
||||
import { attachRunBinding } from './runs/run-binding'
|
||||
import { attachRunCoordinatorMailRouting } from './runs/run-coordinator-mail-routing'
|
||||
import { attachRunCreate } from './runs/run-create'
|
||||
import { attachRunDelivery } from './runs/run-delivery'
|
||||
import { attachRunLookup } from './runs/run-lookup'
|
||||
import { attachLegacyCoordinatorMailTakeover } from './runs/legacy-coordinator-mail-takeover'
|
||||
import { attachAdoptLegacyRun } from './schema/adopt-legacy-run'
|
||||
import { attachBackfillLegacyQuestionThreads } from './schema/backfill-legacy-question-threads'
|
||||
import { attachCreateTables } from './schema/create-tables'
|
||||
import { attachMigrateLegacyContractStorage } from './schema/migrate-legacy-contract-storage'
|
||||
import { attachSchemaMigrate } from './schema/migrate'
|
||||
import { attachSchemaColumnProbes } from './schema/schema-column-probes'
|
||||
import { attachTaskStore } from './tasks/task-store'
|
||||
import { attachFederatedWorkerStartReconcile } from './worker-dispatch/federated-worker-start-reconcile'
|
||||
import { attachWorkerDispatchAbandon } from './worker-dispatch/worker-dispatch-abandon'
|
||||
import { attachWorkerDispatchAuthority } from './worker-dispatch/worker-dispatch-authority'
|
||||
import { attachWorkerDispatchOutcome } from './worker-dispatch/worker-dispatch-outcome'
|
||||
import { attachWorkerDispatchStage } from './worker-dispatch/worker-dispatch-stage'
|
||||
import { attachWorkerDispatchStart } from './worker-dispatch/worker-dispatch-start'
|
||||
import { attachWorkerDispatchStop } from './worker-dispatch/worker-dispatch-stop'
|
||||
import { attachWorkerTerminalRecovery } from './worker-dispatch/worker-terminal-recovery'
|
||||
import { attachWorkerTerminalArchive } from './worker-terminal/worker-terminal-archive'
|
||||
import { attachWorkerTerminalListing } from './worker-terminal/worker-terminal-listing'
|
||||
import { attachWorkerTerminalRelease } from './worker-terminal/worker-terminal-release'
|
||||
import { attachWorkerTerminalResourceStore } from './worker-terminal/worker-terminal-resource-store'
|
||||
import { attachWorkerTerminalTransfer } from './worker-terminal/worker-terminal-transfer'
|
||||
|
||||
export function attachOrchestrationDbMethods(ctor: { prototype: object }): void {
|
||||
attachCreateTables(ctor)
|
||||
attachSchemaMigrate(ctor)
|
||||
attachSchemaColumnProbes(ctor)
|
||||
attachMigrateLegacyContractStorage(ctor)
|
||||
attachBackfillLegacyQuestionThreads(ctor)
|
||||
attachAdoptLegacyRun(ctor)
|
||||
attachMutationReceiptStore(ctor)
|
||||
attachLegacyCompatibilityPrincipals(ctor)
|
||||
attachLegacyCompatibilityCandidates(ctor)
|
||||
attachLegacyWorkerCompletion(ctor)
|
||||
attachLegacyRecoveryCohort(ctor)
|
||||
attachLegacyMailAcknowledge(ctor)
|
||||
attachLegacyQuestionAcknowledge(ctor)
|
||||
attachLegacyLifecycleOperation(ctor)
|
||||
attachLegacyAskOperation(ctor)
|
||||
attachLegacyQuestionLookup(ctor)
|
||||
attachLegacyReplyOperation(ctor)
|
||||
attachRunCreate(ctor)
|
||||
attachRunBinding(ctor)
|
||||
attachRunLookup(ctor)
|
||||
attachRunCoordinatorMailRouting(ctor)
|
||||
attachLegacyCoordinatorMailTakeover(ctor)
|
||||
attachRunDelivery(ctor)
|
||||
attachMessageInsert(ctor)
|
||||
attachMessageInbox(ctor)
|
||||
attachDirectMailboxRouting(ctor)
|
||||
attachForeignDirectMailboxRouting(ctor)
|
||||
attachQuestionThreads(ctor)
|
||||
attachTaskStore(ctor)
|
||||
attachWorkerDispatchStart(ctor)
|
||||
attachWorkerDispatchStage(ctor)
|
||||
attachWorkerDispatchAuthority(ctor)
|
||||
attachWorkerDispatchOutcome(ctor)
|
||||
attachFederatedWorkerStartReconcile(ctor)
|
||||
attachWorkerTerminalRecovery(ctor)
|
||||
attachWorkerDispatchStop(ctor)
|
||||
attachWorkerDispatchAbandon(ctor)
|
||||
attachFederatedDispatchStore(ctor)
|
||||
attachRemoteDispatchAttachmentCreate(ctor)
|
||||
attachRemoteDispatchAttachmentAuthority(ctor)
|
||||
attachRemoteDispatchAttachmentStop(ctor)
|
||||
attachFederationRelayEnqueue(ctor)
|
||||
attachFederationRelayAck(ctor)
|
||||
attachFederationRelayImport(ctor)
|
||||
attachRemoteQuestionStore(ctor)
|
||||
attachFederationRelayItem(ctor)
|
||||
attachWorkerTerminalResourceStore(ctor)
|
||||
attachWorkerTerminalTransfer(ctor)
|
||||
attachWorkerTerminalRelease(ctor)
|
||||
attachWorkerTerminalArchive(ctor)
|
||||
attachWorkerTerminalListing(ctor)
|
||||
attachDispatchContextStore(ctor)
|
||||
attachDispatchCapability(ctor)
|
||||
attachDispatchLookup(ctor)
|
||||
attachDispatchCompletion(ctor)
|
||||
attachWorkerReportSettlement(ctor)
|
||||
attachDecisionGateStore(ctor)
|
||||
attachCoordinatorRunStore(ctor)
|
||||
attachOrchestrationReset(ctor)
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
import { ORCHESTRATION_LEGACY_RUN_ID } from '../../../../shared/orchestration-rpc-contract'
|
||||
import { ORCHESTRATION_CONTRACT_VERSION } from '../../../../shared/protocol-version'
|
||||
|
||||
export const LEGACY_RUN_ID = ORCHESTRATION_LEGACY_RUN_ID
|
||||
|
||||
export const LEGACY_CONTRACT_VERSION = 0
|
||||
export const CURRENT_CONTRACT_VERSION = ORCHESTRATION_CONTRACT_VERSION
|
||||
|
||||
// Schema versions: v2 'heartbeat'+last_heartbeat_at, v3 delivered_at, v4 task-creator terminal, v5 task_title/display_name, v6 pane identity, v7 lightweight Runs, v8 crash-safe Run deliveries, v9 durable question threads, v10 Dispatch capabilities, v11 durable mutation receipts, v12 composed worker state, v18 post-v6 version-skew repair, v19 adopted legacy Runs and compatibility receipts, v20 legacy question backfill, v21 legacy scheduler-loss provenance, v22 dispatch assignee lookup, v23 worker terminal resource ownership, v24 creator-incarnation authority, v25 active Dispatch handle lookup, v26 indexed mutation receipt capacity, v27 durable federation acknowledgments, v28 durable local mutation caller identity.
|
||||
export const SCHEMA_VERSION = 28
|
||||
@@ -0,0 +1,91 @@
|
||||
import type { CoordinatorStatus, CoordinatorRun } from '../../types'
|
||||
import { generateId } from '../generated-id'
|
||||
import type { OrchestrationDb } from '../orchestration-db'
|
||||
|
||||
// ── Coordinator Runs ──
|
||||
|
||||
export function createCoordinatorRun(
|
||||
this: OrchestrationDb,
|
||||
run: {
|
||||
spec: string
|
||||
coordinatorHandle: string
|
||||
pollIntervalMs?: number
|
||||
}
|
||||
): CoordinatorRun {
|
||||
const id = generateId('run')
|
||||
this.db
|
||||
.prepare(
|
||||
"INSERT INTO coordinator_runs (id, spec, status, coordinator_handle, poll_interval_ms) VALUES (?, ?, 'running', ?, ?)"
|
||||
)
|
||||
.run(id, run.spec, run.coordinatorHandle, run.pollIntervalMs ?? 2000)
|
||||
return this.db.prepare('SELECT * FROM coordinator_runs WHERE id = ?').get(id) as CoordinatorRun
|
||||
}
|
||||
|
||||
export function getCoordinatorRun(this: OrchestrationDb, id: string): CoordinatorRun | undefined {
|
||||
return this.db.prepare('SELECT * FROM coordinator_runs WHERE id = ?').get(id) as
|
||||
| CoordinatorRun
|
||||
| undefined
|
||||
}
|
||||
|
||||
export function updateCoordinatorRun(
|
||||
this: OrchestrationDb,
|
||||
id: string,
|
||||
status: CoordinatorStatus
|
||||
): CoordinatorRun | undefined {
|
||||
const completedAt =
|
||||
status === 'completed' || status === 'failed' ? new Date().toISOString() : null
|
||||
this.db
|
||||
.prepare(
|
||||
'UPDATE coordinator_runs SET status = ?, completed_at = COALESCE(?, completed_at) WHERE id = ?'
|
||||
)
|
||||
.run(status, completedAt, id)
|
||||
return this.getCoordinatorRun(id)
|
||||
}
|
||||
|
||||
export function getActiveCoordinatorRun(this: OrchestrationDb): CoordinatorRun | undefined {
|
||||
return this.db
|
||||
.prepare(
|
||||
"SELECT * FROM coordinator_runs WHERE status = 'running' ORDER BY created_at DESC LIMIT 1"
|
||||
)
|
||||
.get() as CoordinatorRun | undefined
|
||||
}
|
||||
|
||||
// ── Queries for Coordinator ──
|
||||
|
||||
export function getIdleTerminals(this: OrchestrationDb, excludeHandles: string[] = []): string[] {
|
||||
const active = this.db
|
||||
.prepare(
|
||||
"SELECT DISTINCT assignee_handle FROM dispatch_contexts WHERE status IN ('pending', 'dispatched')"
|
||||
)
|
||||
.all() as { assignee_handle: string }[]
|
||||
const busyHandles = new Set(active.map((r) => r.assignee_handle))
|
||||
for (const h of excludeHandles) {
|
||||
busyHandles.add(h)
|
||||
}
|
||||
// Return handles from message history that aren't busy
|
||||
const allHandles = this.db
|
||||
.prepare(
|
||||
// Why: alias the UNION column — otherwise the row shape depends on the first branch's column name.
|
||||
'SELECT DISTINCT to_handle AS handle FROM messages UNION SELECT DISTINCT from_handle FROM messages'
|
||||
)
|
||||
.all() as { handle: string }[]
|
||||
return [...new Set(allHandles.map((r) => r.handle))].filter((h) => !busyHandles.has(h))
|
||||
}
|
||||
|
||||
export type CoordinatorRunStoreMethods = {
|
||||
createCoordinatorRun: typeof createCoordinatorRun
|
||||
getCoordinatorRun: typeof getCoordinatorRun
|
||||
updateCoordinatorRun: typeof updateCoordinatorRun
|
||||
getActiveCoordinatorRun: typeof getActiveCoordinatorRun
|
||||
getIdleTerminals: typeof getIdleTerminals
|
||||
}
|
||||
|
||||
export function attachCoordinatorRunStore(ctor: { prototype: object }): void {
|
||||
Object.assign(ctor.prototype, {
|
||||
createCoordinatorRun,
|
||||
getCoordinatorRun,
|
||||
updateCoordinatorRun,
|
||||
getActiveCoordinatorRun,
|
||||
getIdleTerminals
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
import { chmodSync, existsSync } from 'node:fs'
|
||||
|
||||
export function hardenOrchestrationDatabaseFiles(dbPath: (string & {}) | ':memory:'): void {
|
||||
if (dbPath === ':memory:' || process.platform === 'win32') {
|
||||
// Why: Windows protects these files through Orca's current-user-only userData DACL; POSIX mode bits are inert there.
|
||||
return
|
||||
}
|
||||
for (const path of [dbPath, `${dbPath}-wal`, `${dbPath}-shm`]) {
|
||||
try {
|
||||
if (existsSync(path)) {
|
||||
chmodSync(path, 0o600)
|
||||
}
|
||||
} catch {
|
||||
// Why: best-effort — a mount that rejects chmod (SSHFS, some network shares) must not fail DB startup.
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,116 @@
|
||||
import type { GateStatus, DecisionGateRow } from '../../types'
|
||||
import { LEGACY_RUN_ID } from '../contract-constants'
|
||||
import { generateId } from '../generated-id'
|
||||
import type { OrchestrationDb } from '../orchestration-db'
|
||||
|
||||
// ── Decision Gates ──
|
||||
|
||||
export function createGate(
|
||||
this: OrchestrationDb,
|
||||
gate: { taskId: string; question: string; options?: string[] }
|
||||
): DecisionGateRow {
|
||||
const id = generateId('gate')
|
||||
const optionsJson = JSON.stringify(gate.options ?? [])
|
||||
this.db
|
||||
.prepare(
|
||||
'INSERT INTO decision_gates (id, run_id, task_id, question, options) VALUES (?, ?, ?, ?, ?)'
|
||||
)
|
||||
.run(
|
||||
id,
|
||||
this.getTask(gate.taskId)?.run_id ?? LEGACY_RUN_ID,
|
||||
gate.taskId,
|
||||
gate.question,
|
||||
optionsJson
|
||||
)
|
||||
|
||||
this.completeActiveDispatchForTask(gate.taskId)
|
||||
this.db.prepare("UPDATE tasks SET status = 'blocked' WHERE id = ?").run(gate.taskId)
|
||||
|
||||
return this.db.prepare('SELECT * FROM decision_gates WHERE id = ?').get(id) as DecisionGateRow
|
||||
}
|
||||
|
||||
export function resolveGate(
|
||||
this: OrchestrationDb,
|
||||
gateId: string,
|
||||
resolution: string
|
||||
): DecisionGateRow | undefined {
|
||||
const gate = this.db.prepare('SELECT * FROM decision_gates WHERE id = ?').get(gateId) as
|
||||
| DecisionGateRow
|
||||
| undefined
|
||||
if (!gate) {
|
||||
return undefined
|
||||
}
|
||||
|
||||
this.db
|
||||
.prepare(
|
||||
"UPDATE decision_gates SET status = 'resolved', resolution = ?, resolved_at = datetime('now') WHERE id = ?"
|
||||
)
|
||||
.run(resolution, gateId)
|
||||
|
||||
// Why: set to 'ready' (not the previous status) so the coordinator re-dispatches the worker with the resolution context.
|
||||
this.db.prepare("UPDATE tasks SET status = 'ready' WHERE id = ?").run(gate.task_id)
|
||||
|
||||
return this.db.prepare('SELECT * FROM decision_gates WHERE id = ?').get(gateId) as
|
||||
| DecisionGateRow
|
||||
| undefined
|
||||
}
|
||||
|
||||
export function timeoutGate(this: OrchestrationDb, gateId: string): DecisionGateRow | undefined {
|
||||
this.db
|
||||
.prepare(
|
||||
// Why: without the status guard a late timeout overwrites a gate the user already resolved.
|
||||
"UPDATE decision_gates SET status = 'timeout', resolved_at = datetime('now') WHERE id = ? AND status = 'pending'"
|
||||
)
|
||||
.run(gateId)
|
||||
return this.db.prepare('SELECT * FROM decision_gates WHERE id = ?').get(gateId) as
|
||||
| DecisionGateRow
|
||||
| undefined
|
||||
}
|
||||
|
||||
export function listGates(
|
||||
this: OrchestrationDb,
|
||||
filter?: { taskId?: string; status?: GateStatus }
|
||||
): DecisionGateRow[] {
|
||||
if (filter?.taskId && filter?.status) {
|
||||
return this.db
|
||||
.prepare('SELECT * FROM decision_gates WHERE task_id = ? AND status = ? ORDER BY created_at')
|
||||
.all(filter.taskId, filter.status) as DecisionGateRow[]
|
||||
}
|
||||
if (filter?.taskId) {
|
||||
return this.db
|
||||
.prepare('SELECT * FROM decision_gates WHERE task_id = ? ORDER BY created_at')
|
||||
.all(filter.taskId) as DecisionGateRow[]
|
||||
}
|
||||
if (filter?.status) {
|
||||
return this.db
|
||||
.prepare('SELECT * FROM decision_gates WHERE status = ? ORDER BY created_at')
|
||||
.all(filter.status) as DecisionGateRow[]
|
||||
}
|
||||
return this.db
|
||||
.prepare('SELECT * FROM decision_gates ORDER BY created_at')
|
||||
.all() as DecisionGateRow[]
|
||||
}
|
||||
|
||||
export function getGate(this: OrchestrationDb, id: string): DecisionGateRow | undefined {
|
||||
return this.db.prepare('SELECT * FROM decision_gates WHERE id = ?').get(id) as
|
||||
| DecisionGateRow
|
||||
| undefined
|
||||
}
|
||||
|
||||
export type DecisionGateStoreMethods = {
|
||||
createGate: typeof createGate
|
||||
resolveGate: typeof resolveGate
|
||||
timeoutGate: typeof timeoutGate
|
||||
listGates: typeof listGates
|
||||
getGate: typeof getGate
|
||||
}
|
||||
|
||||
export function attachDecisionGateStore(ctor: { prototype: object }): void {
|
||||
Object.assign(ctor.prototype, {
|
||||
createGate,
|
||||
resolveGate,
|
||||
timeoutGate,
|
||||
listGates,
|
||||
getGate
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
import { createHash } from 'node:crypto'
|
||||
|
||||
export function hashDispatchCapability(capability: string): string {
|
||||
return createHash('sha256').update(capability).digest('hex')
|
||||
}
|
||||
@@ -0,0 +1,105 @@
|
||||
import { randomBytes, timingSafeEqual } from 'node:crypto'
|
||||
import { OrchestrationError } from '../../orchestration-error'
|
||||
import { hashDispatchCapability } from '../dispatch-capability-hash'
|
||||
import { isEquivalentPaneKey } from '../pane-key-match'
|
||||
import type { OrchestrationDb } from '../orchestration-db'
|
||||
|
||||
export function mintDispatchCapability(
|
||||
this: OrchestrationDb,
|
||||
params: {
|
||||
dispatchId: string
|
||||
paneKey: string
|
||||
processIncarnation: string
|
||||
}
|
||||
): string {
|
||||
const dispatch = this.getDispatchContextById(params.dispatchId)
|
||||
if (!dispatch || (dispatch.status !== 'pending' && dispatch.status !== 'dispatched')) {
|
||||
throw new OrchestrationError(
|
||||
'dispatch_inactive',
|
||||
`Dispatch ${params.dispatchId} is not active.`
|
||||
)
|
||||
}
|
||||
const capability = `dcap_${randomBytes(32).toString('base64url')}`
|
||||
this.db
|
||||
.prepare(
|
||||
`UPDATE dispatch_contexts
|
||||
SET capability_hash = ?, assignee_pane_key = ?, process_incarnation = ?,
|
||||
capability_revoked_at = NULL
|
||||
WHERE id = ?`
|
||||
)
|
||||
.run(
|
||||
hashDispatchCapability(capability),
|
||||
params.paneKey,
|
||||
params.processIncarnation,
|
||||
params.dispatchId
|
||||
)
|
||||
return capability
|
||||
}
|
||||
|
||||
export function verifyDispatchCapability(
|
||||
this: OrchestrationDb,
|
||||
params: {
|
||||
dispatchId: string
|
||||
capability: string | undefined
|
||||
paneKey: string | undefined
|
||||
processIncarnation: string | undefined
|
||||
}
|
||||
): { valid: true } | { valid: false; reason: string } {
|
||||
const dispatch = this.getDispatchContextById(params.dispatchId)
|
||||
if (!dispatch) {
|
||||
return { valid: false, reason: `Dispatch ${params.dispatchId} was not found.` }
|
||||
}
|
||||
if (!dispatch.capability_hash) {
|
||||
return { valid: false, reason: `Dispatch ${params.dispatchId} has no lifecycle capability.` }
|
||||
}
|
||||
if (dispatch.capability_revoked_at) {
|
||||
return { valid: false, reason: `Dispatch ${params.dispatchId} capability is revoked.` }
|
||||
}
|
||||
if (!params.capability) {
|
||||
return { valid: false, reason: 'The Dispatch capability is missing.' }
|
||||
}
|
||||
const expected = Buffer.from(dispatch.capability_hash, 'hex')
|
||||
const observed = Buffer.from(hashDispatchCapability(params.capability), 'hex')
|
||||
if (expected.length !== observed.length || !timingSafeEqual(expected, observed)) {
|
||||
return { valid: false, reason: 'The Dispatch capability is invalid.' }
|
||||
}
|
||||
if (
|
||||
!dispatch.assignee_pane_key ||
|
||||
!params.paneKey ||
|
||||
!isEquivalentPaneKey(dispatch.assignee_pane_key, params.paneKey)
|
||||
) {
|
||||
return { valid: false, reason: 'The caller is not the Dispatch pane.' }
|
||||
}
|
||||
if (
|
||||
!dispatch.process_incarnation ||
|
||||
!params.processIncarnation ||
|
||||
dispatch.process_incarnation !== params.processIncarnation
|
||||
) {
|
||||
return { valid: false, reason: 'The Dispatch process incarnation changed.' }
|
||||
}
|
||||
return { valid: true }
|
||||
}
|
||||
|
||||
export function revokeDispatchCapability(this: OrchestrationDb, dispatchId: string): void {
|
||||
this.db
|
||||
.prepare(
|
||||
`UPDATE dispatch_contexts
|
||||
SET capability_revoked_at = COALESCE(capability_revoked_at, datetime('now'))
|
||||
WHERE id = ?`
|
||||
)
|
||||
.run(dispatchId)
|
||||
}
|
||||
|
||||
export type DispatchCapabilityMethods = {
|
||||
mintDispatchCapability: typeof mintDispatchCapability
|
||||
verifyDispatchCapability: typeof verifyDispatchCapability
|
||||
revokeDispatchCapability: typeof revokeDispatchCapability
|
||||
}
|
||||
|
||||
export function attachDispatchCapability(ctor: { prototype: object }): void {
|
||||
Object.assign(ctor.prototype, {
|
||||
mintDispatchCapability,
|
||||
verifyDispatchCapability,
|
||||
revokeDispatchCapability
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,2 @@
|
||||
// Why: shared so every dispatch failure path breaks the circuit at the same accumulated failure_count.
|
||||
export const DISPATCH_CIRCUIT_BREAK_FAILURES = 3
|
||||
@@ -0,0 +1,134 @@
|
||||
import type { TaskStatus, DispatchStatus, DispatchContextRow } from '../../types'
|
||||
import { DISPATCH_CIRCUIT_BREAK_FAILURES } from './dispatch-circuit-breaker'
|
||||
import type { OrchestrationDb } from '../orchestration-db'
|
||||
|
||||
const FAIL_DISPATCH_SAVEPOINT = 'fail_dispatch'
|
||||
|
||||
export function completeDispatch(this: OrchestrationDb, ctxId: string): void {
|
||||
this.db
|
||||
.prepare(
|
||||
// Why: the status guard keeps a late completion from reviving a dispatch already failed or circuit-broken.
|
||||
"UPDATE dispatch_contexts SET status = 'completed', completed_at = datetime('now'), capability_revoked_at = COALESCE(capability_revoked_at, datetime('now')) WHERE id = ? AND status IN ('pending', 'dispatched')"
|
||||
)
|
||||
.run(ctxId)
|
||||
}
|
||||
|
||||
export function completeActiveDispatchForTask(this: OrchestrationDb, taskId: string): void {
|
||||
const active = this.db
|
||||
.prepare(
|
||||
"SELECT * FROM dispatch_contexts WHERE task_id = ? AND status IN ('pending', 'dispatched') ORDER BY rowid DESC LIMIT 1"
|
||||
)
|
||||
.get(taskId) as DispatchContextRow | undefined
|
||||
if (active) {
|
||||
this.completeDispatch(active.id)
|
||||
}
|
||||
}
|
||||
|
||||
export function failActiveDispatchForTask(
|
||||
this: OrchestrationDb,
|
||||
taskId: string,
|
||||
error: string
|
||||
): DispatchContextRow | undefined {
|
||||
const active = this.db
|
||||
.prepare(
|
||||
"SELECT * FROM dispatch_contexts WHERE task_id = ? AND status IN ('pending', 'dispatched') ORDER BY rowid DESC LIMIT 1"
|
||||
)
|
||||
.get(taskId) as DispatchContextRow | undefined
|
||||
return active ? this.failDispatch(active.id, error) : undefined
|
||||
}
|
||||
|
||||
// Why: only bump status='dispatched' — a zombie heartbeat from a finished dispatch would mask a hung retry from the stale detector (§5.3.4).
|
||||
export function recordHeartbeat(this: OrchestrationDb, dispatchId: string, at: string): void {
|
||||
this.db
|
||||
.prepare(
|
||||
"UPDATE dispatch_contexts SET last_heartbeat_at = ? WHERE id = ? AND status = 'dispatched'"
|
||||
)
|
||||
.run(at, dispatchId)
|
||||
}
|
||||
|
||||
// Why: dispatched_at grace skips workers still within their first heartbeat interval; julianday() vs raw-TEXT compare avoids misflagging space-format timestamps as stale (#8452).
|
||||
export function getStaleDispatches(
|
||||
this: OrchestrationDb,
|
||||
thresholdIso: string
|
||||
): DispatchContextRow[] {
|
||||
return this.db
|
||||
.prepare(
|
||||
`SELECT * FROM dispatch_contexts
|
||||
WHERE status = 'dispatched'
|
||||
AND dispatched_at IS NOT NULL
|
||||
AND julianday(dispatched_at) < julianday(?)
|
||||
AND (last_heartbeat_at IS NULL OR julianday(last_heartbeat_at) < julianday(?))`
|
||||
)
|
||||
.all(thresholdIso, thresholdIso) as DispatchContextRow[]
|
||||
}
|
||||
|
||||
export function failDispatch(
|
||||
this: OrchestrationDb,
|
||||
ctxId: string,
|
||||
error: string
|
||||
): DispatchContextRow | undefined {
|
||||
this.db.exec(`SAVEPOINT ${FAIL_DISPATCH_SAVEPOINT}`)
|
||||
try {
|
||||
const ctx = this.db.prepare('SELECT * FROM dispatch_contexts WHERE id = ?').get(ctxId) as
|
||||
| DispatchContextRow
|
||||
| undefined
|
||||
if (!ctx || (ctx.status !== 'pending' && ctx.status !== 'dispatched')) {
|
||||
this.db.exec(`RELEASE ${FAIL_DISPATCH_SAVEPOINT}`)
|
||||
return ctx
|
||||
}
|
||||
|
||||
const newFailureCount = ctx.failure_count + 1
|
||||
const newStatus: DispatchStatus =
|
||||
newFailureCount >= DISPATCH_CIRCUIT_BREAK_FAILURES ? 'circuit_broken' : 'failed'
|
||||
const result = this.db
|
||||
.prepare(
|
||||
`UPDATE dispatch_contexts
|
||||
SET status = ?, failure_count = ?, last_failure = ?,
|
||||
completed_at = COALESCE(completed_at, datetime('now')),
|
||||
capability_revoked_at = COALESCE(capability_revoked_at, datetime('now'))
|
||||
WHERE id = ? AND status IN ('pending', 'dispatched')`
|
||||
)
|
||||
.run(newStatus, newFailureCount, error, ctxId)
|
||||
if (result.changes !== 1) {
|
||||
this.db.exec(`RELEASE ${FAIL_DISPATCH_SAVEPOINT}`)
|
||||
return this.db.prepare('SELECT * FROM dispatch_contexts WHERE id = ?').get(ctxId) as
|
||||
| DispatchContextRow
|
||||
| undefined
|
||||
}
|
||||
|
||||
// Why: back to 'ready' not 'pending' — 'pending' would strand it since promoteReadyTasks only runs when a dep completes.
|
||||
const taskStatus: TaskStatus = newStatus === 'circuit_broken' ? 'failed' : 'ready'
|
||||
// Why: the status guard keeps a late failure from reopening a task that already completed or was retried elsewhere.
|
||||
this.db
|
||||
.prepare("UPDATE tasks SET status = ? WHERE id = ? AND status IN ('dispatched', 'blocked')")
|
||||
.run(taskStatus, ctx.task_id)
|
||||
this.db.exec(`RELEASE ${FAIL_DISPATCH_SAVEPOINT}`)
|
||||
return this.db.prepare('SELECT * FROM dispatch_contexts WHERE id = ?').get(ctxId) as
|
||||
| DispatchContextRow
|
||||
| undefined
|
||||
} catch (cause) {
|
||||
this.db.exec(`ROLLBACK TO ${FAIL_DISPATCH_SAVEPOINT}`)
|
||||
this.db.exec(`RELEASE ${FAIL_DISPATCH_SAVEPOINT}`)
|
||||
throw cause
|
||||
}
|
||||
}
|
||||
|
||||
export type DispatchCompletionMethods = {
|
||||
completeDispatch: typeof completeDispatch
|
||||
completeActiveDispatchForTask: typeof completeActiveDispatchForTask
|
||||
failActiveDispatchForTask: typeof failActiveDispatchForTask
|
||||
recordHeartbeat: typeof recordHeartbeat
|
||||
getStaleDispatches: typeof getStaleDispatches
|
||||
failDispatch: typeof failDispatch
|
||||
}
|
||||
|
||||
export function attachDispatchCompletion(ctor: { prototype: object }): void {
|
||||
Object.assign(ctor.prototype, {
|
||||
completeDispatch,
|
||||
completeActiveDispatchForTask,
|
||||
failActiveDispatchForTask,
|
||||
recordHeartbeat,
|
||||
getStaleDispatches,
|
||||
failDispatch
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,131 @@
|
||||
import type { DispatchContextRow } from '../../types'
|
||||
import { OrchestrationError } from '../../orchestration-error'
|
||||
import { CURRENT_CONTRACT_VERSION } from '../contract-constants'
|
||||
import { generateId } from '../generated-id'
|
||||
import type { OrchestrationDb } from '../orchestration-db'
|
||||
|
||||
export function createDispatchContext(
|
||||
this: OrchestrationDb,
|
||||
taskId: string,
|
||||
assigneeHandle: string,
|
||||
// Why: pane key is the remint-stable identity behind the handle — lets worker_done ownership survive handle reissue.
|
||||
assigneePaneKey?: string,
|
||||
launchTokenHash?: string,
|
||||
processIncarnation?: string
|
||||
): DispatchContextRow {
|
||||
const task = this.getTask(taskId)
|
||||
if (!task) {
|
||||
throw new Error(`Task not found: ${taskId}`)
|
||||
}
|
||||
if (task.status !== 'ready') {
|
||||
throw new Error(`Task ${taskId} is ${task.status}; only ready tasks can be dispatched`)
|
||||
}
|
||||
|
||||
// Why: lock on pane identity too, so a reminted handle can't open a second concurrent dispatch on the same pane.
|
||||
const existing = this.findActiveDispatchForAssignee(assigneeHandle, assigneePaneKey)
|
||||
|
||||
if (existing) {
|
||||
throw new Error(
|
||||
`Terminal ${assigneeHandle} already has an active dispatch (${existing.id} for task ${existing.task_id})`
|
||||
)
|
||||
}
|
||||
|
||||
// Carry forward failure_count so the circuit breaker accumulates across retries for the same task.
|
||||
const prior = this.db
|
||||
.prepare('SELECT MAX(failure_count) as max_failures FROM dispatch_contexts WHERE task_id = ?')
|
||||
.get(taskId) as { max_failures: number | null } | undefined
|
||||
const priorFailures = prior?.max_failures ?? 0
|
||||
|
||||
const id = generateId('ctx')
|
||||
this.db
|
||||
.prepare(
|
||||
`INSERT INTO dispatch_contexts (
|
||||
id, run_id, task_id, contract_version, launch_token_hash,
|
||||
assignee_handle, assignee_pane_key, process_incarnation,
|
||||
status, failure_count, dispatched_at
|
||||
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, 'dispatched', ?, datetime('now'))`
|
||||
)
|
||||
.run(
|
||||
id,
|
||||
task.run_id,
|
||||
taskId,
|
||||
CURRENT_CONTRACT_VERSION,
|
||||
launchTokenHash ?? null,
|
||||
assigneeHandle,
|
||||
assigneePaneKey ?? null,
|
||||
processIncarnation ?? null,
|
||||
priorFailures
|
||||
)
|
||||
this.hasAnyDispatchContextsCache = true
|
||||
|
||||
this.db.prepare("UPDATE tasks SET status = 'dispatched' WHERE id = ?").run(taskId)
|
||||
|
||||
return this.db
|
||||
.prepare('SELECT * FROM dispatch_contexts WHERE id = ?')
|
||||
.get(id) as DispatchContextRow
|
||||
}
|
||||
|
||||
export function getDispatchContext(
|
||||
this: OrchestrationDb,
|
||||
taskId: string
|
||||
): DispatchContextRow | undefined {
|
||||
return this.db
|
||||
.prepare('SELECT * FROM dispatch_contexts WHERE task_id = ? ORDER BY rowid DESC LIMIT 1')
|
||||
.get(taskId) as DispatchContextRow | undefined
|
||||
}
|
||||
|
||||
export function getDispatchContextById(
|
||||
this: OrchestrationDb,
|
||||
dispatchId: string
|
||||
): DispatchContextRow | undefined {
|
||||
return this.db.prepare('SELECT * FROM dispatch_contexts WHERE id = ?').get(dispatchId) as
|
||||
| DispatchContextRow
|
||||
| undefined
|
||||
}
|
||||
|
||||
export function commitDispatchLaunchTokenHash(
|
||||
this: OrchestrationDb,
|
||||
dispatchId: string,
|
||||
launchTokenHash: string
|
||||
): DispatchContextRow {
|
||||
const dispatch = this.getDispatchContextById(dispatchId)
|
||||
if (!dispatch) {
|
||||
throw new OrchestrationError('dispatch_not_found', `Dispatch ${dispatchId} was not found.`)
|
||||
}
|
||||
if (dispatch.contract_version !== CURRENT_CONTRACT_VERSION) {
|
||||
throw new OrchestrationError(
|
||||
'request_mismatch',
|
||||
`Dispatch ${dispatchId} does not use the current contract.`
|
||||
)
|
||||
}
|
||||
if (dispatch.launch_token_hash && dispatch.launch_token_hash !== launchTokenHash) {
|
||||
throw new OrchestrationError(
|
||||
'request_mismatch',
|
||||
`Dispatch ${dispatchId} already has a different launch-token commitment.`
|
||||
)
|
||||
}
|
||||
this.db
|
||||
.prepare(
|
||||
`UPDATE dispatch_contexts
|
||||
SET launch_token_hash = COALESCE(launch_token_hash, ?)
|
||||
WHERE id = ?`
|
||||
)
|
||||
.run(launchTokenHash, dispatchId)
|
||||
return this.getDispatchContextById(dispatchId) as DispatchContextRow
|
||||
}
|
||||
|
||||
export type DispatchContextStoreMethods = {
|
||||
createDispatchContext: typeof createDispatchContext
|
||||
getDispatchContext: typeof getDispatchContext
|
||||
getDispatchContextById: typeof getDispatchContextById
|
||||
commitDispatchLaunchTokenHash: typeof commitDispatchLaunchTokenHash
|
||||
}
|
||||
|
||||
export function attachDispatchContextStore(ctor: { prototype: object }): void {
|
||||
Object.assign(ctor.prototype, {
|
||||
createDispatchContext,
|
||||
getDispatchContext,
|
||||
getDispatchContextById,
|
||||
commitDispatchLaunchTokenHash
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,108 @@
|
||||
import { parsePaneKey } from '../../../../../shared/stable-pane-id'
|
||||
import type { DispatchContextRow } from '../../types'
|
||||
import { DISPATCH_PANE_KEY_MATCH_SUFFIX_SQL, paneKeyMatchSuffix } from '../pane-key-match'
|
||||
import type { OrchestrationDb } from '../orchestration-db'
|
||||
|
||||
export function getActiveDispatchForTerminal(
|
||||
this: OrchestrationDb,
|
||||
handle: string
|
||||
): DispatchContextRow | undefined {
|
||||
return this.findActiveDispatchForAssignee(handle)
|
||||
}
|
||||
|
||||
/**
|
||||
* Cheap "are there any dispatch rows at all" probe. When false, no terminal
|
||||
* can have an active or recent-completed dispatch, so orchestration-context
|
||||
* builders can skip their per-terminal query fan-out entirely. Cached after
|
||||
* the first probe; createDispatchContext marks it true, resets clear it.
|
||||
*/
|
||||
|
||||
export function hasAnyDispatchContexts(this: OrchestrationDb): boolean {
|
||||
if (this.hasAnyDispatchContextsCache === undefined) {
|
||||
const row = this.db.prepare('SELECT 1 FROM dispatch_contexts LIMIT 1').get()
|
||||
this.hasAnyDispatchContextsCache = row !== undefined
|
||||
}
|
||||
return this.hasAnyDispatchContextsCache
|
||||
}
|
||||
|
||||
export function getActiveDispatchForIdentity(
|
||||
this: OrchestrationDb,
|
||||
handle: string,
|
||||
paneKey?: string
|
||||
): DispatchContextRow | undefined {
|
||||
return this.findActiveDispatchForAssignee(handle, paneKey)
|
||||
}
|
||||
|
||||
export function findActiveDispatchForAssignee(
|
||||
this: OrchestrationDb,
|
||||
assigneeHandle: string,
|
||||
assigneePaneKey?: string
|
||||
): DispatchContextRow | undefined {
|
||||
const byHandle = this.db
|
||||
.prepare(
|
||||
// Why: newest-first like the pane lookups below — an unordered LIMIT 1 could pin a stale row if a handle ever has two active dispatches.
|
||||
`SELECT * FROM dispatch_contexts
|
||||
WHERE assignee_handle = ? AND status IN ('pending', 'dispatched')
|
||||
ORDER BY rowid DESC LIMIT 1`
|
||||
)
|
||||
.get(assigneeHandle) as DispatchContextRow | undefined
|
||||
if (byHandle) {
|
||||
return byHandle
|
||||
}
|
||||
|
||||
if (!assigneePaneKey) {
|
||||
return undefined
|
||||
}
|
||||
|
||||
const exactPane = this.db
|
||||
.prepare(
|
||||
`SELECT * FROM dispatch_contexts
|
||||
WHERE assignee_pane_key = ? AND status IN ('pending', 'dispatched')
|
||||
ORDER BY rowid DESC LIMIT 1`
|
||||
)
|
||||
.get(assigneePaneKey) as DispatchContextRow | undefined
|
||||
if (exactPane) {
|
||||
return exactPane
|
||||
}
|
||||
if (!parsePaneKey(assigneePaneKey)) {
|
||||
return undefined
|
||||
}
|
||||
return this.db
|
||||
.prepare(
|
||||
`SELECT * FROM dispatch_contexts
|
||||
WHERE assignee_pane_key IS NOT NULL
|
||||
AND status IN ('pending', 'dispatched') AND instr(assignee_pane_key, ':') > 1
|
||||
AND ${DISPATCH_PANE_KEY_MATCH_SUFFIX_SQL} = ?
|
||||
ORDER BY rowid DESC LIMIT 1`
|
||||
)
|
||||
.get(paneKeyMatchSuffix(assigneePaneKey)) as DispatchContextRow | undefined
|
||||
}
|
||||
|
||||
export function getLatestDispatchForTerminal(
|
||||
this: OrchestrationDb,
|
||||
handle: string
|
||||
): DispatchContextRow | undefined {
|
||||
return this.db
|
||||
.prepare(
|
||||
'SELECT * FROM dispatch_contexts WHERE assignee_handle = ? ORDER BY rowid DESC LIMIT 1'
|
||||
)
|
||||
.get(handle) as DispatchContextRow | undefined
|
||||
}
|
||||
|
||||
export type DispatchLookupMethods = {
|
||||
getActiveDispatchForTerminal: typeof getActiveDispatchForTerminal
|
||||
hasAnyDispatchContexts: typeof hasAnyDispatchContexts
|
||||
getActiveDispatchForIdentity: typeof getActiveDispatchForIdentity
|
||||
findActiveDispatchForAssignee: typeof findActiveDispatchForAssignee
|
||||
getLatestDispatchForTerminal: typeof getLatestDispatchForTerminal
|
||||
}
|
||||
|
||||
export function attachDispatchLookup(ctor: { prototype: object }): void {
|
||||
Object.assign(ctor.prototype, {
|
||||
getActiveDispatchForTerminal,
|
||||
hasAnyDispatchContexts,
|
||||
getActiveDispatchForIdentity,
|
||||
findActiveDispatchForAssignee,
|
||||
getLatestDispatchForTerminal
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,125 @@
|
||||
import type { WorkerReportOutcome, WorkerReportSettlement } from '../../types'
|
||||
import type { OrchestrationDb } from '../orchestration-db'
|
||||
|
||||
export function settleWorkerReport(
|
||||
this: OrchestrationDb,
|
||||
params: {
|
||||
taskId: string
|
||||
dispatchId: string
|
||||
outcome: WorkerReportOutcome
|
||||
result: string
|
||||
}
|
||||
): WorkerReportSettlement {
|
||||
this.db.exec('BEGIN IMMEDIATE')
|
||||
try {
|
||||
const settlement = this.settleWorkerReportInTransaction(params)
|
||||
this.db.exec('COMMIT')
|
||||
return settlement
|
||||
} catch (error) {
|
||||
this.db.exec('ROLLBACK')
|
||||
throw error
|
||||
}
|
||||
}
|
||||
|
||||
export function settleWorkerReportInTransaction(
|
||||
this: OrchestrationDb,
|
||||
params: {
|
||||
taskId: string
|
||||
dispatchId: string
|
||||
outcome: WorkerReportOutcome
|
||||
result: string
|
||||
}
|
||||
): WorkerReportSettlement {
|
||||
const task = this.getTask(params.taskId)
|
||||
if (!task) {
|
||||
return { action: 'rejected', code: 'unknown_task', reason: `Unknown task ${params.taskId}.` }
|
||||
}
|
||||
const dispatch = this.getDispatchContextById(params.dispatchId)
|
||||
if (!dispatch) {
|
||||
return {
|
||||
action: 'rejected',
|
||||
code: 'unknown_dispatch',
|
||||
reason: `Unknown dispatch ${params.dispatchId}.`
|
||||
}
|
||||
}
|
||||
if (dispatch.task_id !== params.taskId) {
|
||||
return {
|
||||
action: 'rejected',
|
||||
code: 'task_dispatch_mismatch',
|
||||
reason: `Dispatch ${params.dispatchId} belongs to task ${dispatch.task_id}, not ${params.taskId}.`
|
||||
}
|
||||
}
|
||||
|
||||
const expectedDispatchStatus = params.outcome === 'succeeded' ? 'completed' : 'failed'
|
||||
const expectedTaskStatus = params.outcome === 'succeeded' ? 'completed' : 'failed'
|
||||
if (dispatch.status === expectedDispatchStatus && task.status === expectedTaskStatus) {
|
||||
return { action: 'settled', outcome: params.outcome, duplicate: true }
|
||||
}
|
||||
if (dispatch.status !== 'dispatched' || task.status !== 'dispatched') {
|
||||
return {
|
||||
action: 'rejected',
|
||||
code: 'inactive_dispatch',
|
||||
reason: `inactive dispatch ${params.dispatchId}: it or task ${params.taskId} is already settled.`
|
||||
}
|
||||
}
|
||||
const latest = this.getDispatchContext(params.taskId)
|
||||
if (latest?.id !== params.dispatchId) {
|
||||
return {
|
||||
action: 'rejected',
|
||||
code: 'stale_dispatch',
|
||||
reason: `Dispatch ${params.dispatchId} is not the current dispatch for task ${params.taskId}.`
|
||||
}
|
||||
}
|
||||
|
||||
this.db.exec('SAVEPOINT settle_worker_report')
|
||||
const dispatchUpdate = this.db
|
||||
.prepare(
|
||||
`UPDATE dispatch_contexts
|
||||
SET status = ?, completed_at = datetime('now'),
|
||||
last_failure = CASE WHEN ? = 'failed' THEN ? ELSE last_failure END,
|
||||
capability_revoked_at = COALESCE(capability_revoked_at, datetime('now'))
|
||||
WHERE id = ? AND status = 'dispatched'`
|
||||
)
|
||||
.run(expectedDispatchStatus, expectedDispatchStatus, params.result, params.dispatchId)
|
||||
const taskUpdate = this.db
|
||||
.prepare(
|
||||
`UPDATE tasks
|
||||
SET status = ?, result = ?, completed_at = datetime('now')
|
||||
WHERE id = ? AND status = 'dispatched'`
|
||||
)
|
||||
.run(expectedTaskStatus, params.result, params.taskId)
|
||||
if (dispatchUpdate.changes !== 1 || taskUpdate.changes !== 1) {
|
||||
this.db.exec('ROLLBACK TO settle_worker_report')
|
||||
this.db.exec('RELEASE settle_worker_report')
|
||||
return {
|
||||
action: 'rejected',
|
||||
code: 'inactive_dispatch',
|
||||
reason: `Dispatch ${params.dispatchId} changed while its worker report was settling.`
|
||||
}
|
||||
}
|
||||
this.db
|
||||
.prepare(
|
||||
`UPDATE worker_dispatches
|
||||
SET state = ?, stage = 'settled', updated_at = datetime('now')
|
||||
WHERE dispatch_id = ? AND state = 'ready'`
|
||||
)
|
||||
.run(params.outcome === 'succeeded' ? 'succeeded' : 'failed', params.dispatchId)
|
||||
this.closeQuestionsForDispatch(params.dispatchId)
|
||||
if (params.outcome === 'succeeded') {
|
||||
this.promoteReadyTasks(params.taskId)
|
||||
}
|
||||
this.db.exec('RELEASE settle_worker_report')
|
||||
return { action: 'settled', outcome: params.outcome, duplicate: false }
|
||||
}
|
||||
|
||||
export type WorkerReportSettlementMethods = {
|
||||
settleWorkerReport: typeof settleWorkerReport
|
||||
settleWorkerReportInTransaction: typeof settleWorkerReportInTransaction
|
||||
}
|
||||
|
||||
export function attachWorkerReportSettlement(ctor: { prototype: object }): void {
|
||||
Object.assign(ctor.prototype, {
|
||||
settleWorkerReport,
|
||||
settleWorkerReportInTransaction
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { parseFederatedWorkerReportOutcome } from './federated-worker-report-outcome'
|
||||
|
||||
describe('federated-worker-report-outcome', () => {
|
||||
it('reads succeeded or failed from a nested report payload', () => {
|
||||
const succeeded = JSON.stringify({ payload: JSON.stringify({ outcome: 'succeeded' }) })
|
||||
const failed = JSON.stringify({ payload: JSON.stringify({ outcome: 'failed' }) })
|
||||
expect(parseFederatedWorkerReportOutcome(succeeded)).toBe('succeeded')
|
||||
expect(parseFederatedWorkerReportOutcome(failed)).toBe('failed')
|
||||
})
|
||||
|
||||
it('returns undefined for malformed or non-terminal outcomes', () => {
|
||||
expect(parseFederatedWorkerReportOutcome('not-json')).toBeUndefined()
|
||||
expect(parseFederatedWorkerReportOutcome(JSON.stringify({ payload: {} }))).toBeUndefined()
|
||||
const other = JSON.stringify({ payload: JSON.stringify({ outcome: 'running' }) })
|
||||
expect(parseFederatedWorkerReportOutcome(other)).toBeUndefined()
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,18 @@
|
||||
import type { WorkerReportOutcome } from '../types'
|
||||
|
||||
export function parseFederatedWorkerReportOutcome(
|
||||
payload: string
|
||||
): WorkerReportOutcome | undefined {
|
||||
try {
|
||||
const message = JSON.parse(payload) as { payload?: unknown }
|
||||
if (typeof message.payload !== 'string') {
|
||||
return undefined
|
||||
}
|
||||
const report = JSON.parse(message.payload) as { outcome?: unknown }
|
||||
return report.outcome === 'succeeded' || report.outcome === 'failed'
|
||||
? report.outcome
|
||||
: undefined
|
||||
} catch {
|
||||
return undefined
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,114 @@
|
||||
import type { FederatedDispatchRow } from '../../types'
|
||||
import { OrchestrationError } from '../../orchestration-error'
|
||||
import type { OrchestrationDb } from '../orchestration-db'
|
||||
|
||||
export function getFederatedDispatch(
|
||||
this: OrchestrationDb,
|
||||
dispatchId: string
|
||||
): FederatedDispatchRow | undefined {
|
||||
return this.db
|
||||
.prepare('SELECT * FROM federated_dispatches WHERE dispatch_id = ?')
|
||||
.get(dispatchId) as FederatedDispatchRow | undefined
|
||||
}
|
||||
|
||||
export function listActiveFederatedDispatches(
|
||||
this: OrchestrationDb,
|
||||
runId?: string
|
||||
): FederatedDispatchRow[] {
|
||||
return this.db
|
||||
.prepare(
|
||||
`SELECT fd.*
|
||||
FROM federated_dispatches fd
|
||||
INNER JOIN dispatch_contexts dc ON dc.id = fd.dispatch_id
|
||||
INNER JOIN worker_dispatches wd ON wd.dispatch_id = fd.dispatch_id
|
||||
WHERE wd.state IN ('starting', 'ready', 'stopping', 'start_unknown', 'stop_unknown')
|
||||
AND (? IS NULL OR dc.run_id = ?)
|
||||
ORDER BY fd.rowid`
|
||||
)
|
||||
.all(runId ?? null, runId ?? null) as FederatedDispatchRow[]
|
||||
}
|
||||
|
||||
export function findNextTerminalFederatedDispatchPendingAcknowledgment(
|
||||
this: OrchestrationDb,
|
||||
afterRowId: number
|
||||
): { dispatchId: string; rowId: number } | undefined {
|
||||
return this.db
|
||||
.prepare(
|
||||
`SELECT fd.dispatch_id AS dispatchId, fd.rowid AS rowId
|
||||
FROM federated_dispatches fd
|
||||
INNER JOIN worker_dispatches wd ON wd.dispatch_id = fd.dispatch_id
|
||||
WHERE wd.state NOT IN ('starting', 'ready', 'stopping', 'start_unknown', 'stop_unknown')
|
||||
AND fd.to_home_acknowledged_sequence < fd.to_home_imported_sequence
|
||||
AND fd.rowid > ?
|
||||
ORDER BY fd.rowid
|
||||
LIMIT 1`
|
||||
)
|
||||
.get(afterRowId) as { dispatchId: string; rowId: number } | undefined
|
||||
}
|
||||
|
||||
export function isFederatedDispatchRelayEligible(
|
||||
this: OrchestrationDb,
|
||||
dispatchId: string
|
||||
): boolean {
|
||||
return Boolean(
|
||||
this.db
|
||||
.prepare(
|
||||
`SELECT 1
|
||||
FROM federated_dispatches fd
|
||||
INNER JOIN worker_dispatches wd ON wd.dispatch_id = fd.dispatch_id
|
||||
WHERE fd.dispatch_id = ?
|
||||
AND (
|
||||
wd.state IN ('starting', 'ready', 'stopping', 'start_unknown', 'stop_unknown')
|
||||
OR (
|
||||
fd.to_home_acknowledged_sequence < fd.to_home_imported_sequence
|
||||
)
|
||||
)`
|
||||
)
|
||||
.get(dispatchId)
|
||||
)
|
||||
}
|
||||
|
||||
export function updateFederatedDispatchResources(
|
||||
this: OrchestrationDb,
|
||||
params: {
|
||||
dispatchId: string
|
||||
remoteRuntimeEpoch: string
|
||||
worktreeId: string
|
||||
terminalHandle: string
|
||||
}
|
||||
): FederatedDispatchRow {
|
||||
this.db
|
||||
.prepare(
|
||||
`UPDATE federated_dispatches
|
||||
SET remote_runtime_epoch = ?, remote_worktree_id = ?, remote_terminal_handle = ?,
|
||||
updated_at = datetime('now')
|
||||
WHERE dispatch_id = ?`
|
||||
)
|
||||
.run(params.remoteRuntimeEpoch, params.worktreeId, params.terminalHandle, params.dispatchId)
|
||||
const row = this.getFederatedDispatch(params.dispatchId)
|
||||
if (!row) {
|
||||
throw new OrchestrationError(
|
||||
'dispatch_not_found',
|
||||
`Federated Dispatch ${params.dispatchId} was not found.`
|
||||
)
|
||||
}
|
||||
return row
|
||||
}
|
||||
|
||||
export type FederatedDispatchStoreMethods = {
|
||||
getFederatedDispatch: typeof getFederatedDispatch
|
||||
listActiveFederatedDispatches: typeof listActiveFederatedDispatches
|
||||
findNextTerminalFederatedDispatchPendingAcknowledgment: typeof findNextTerminalFederatedDispatchPendingAcknowledgment
|
||||
isFederatedDispatchRelayEligible: typeof isFederatedDispatchRelayEligible
|
||||
updateFederatedDispatchResources: typeof updateFederatedDispatchResources
|
||||
}
|
||||
|
||||
export function attachFederatedDispatchStore(ctor: { prototype: object }): void {
|
||||
Object.assign(ctor.prototype, {
|
||||
getFederatedDispatch,
|
||||
listActiveFederatedDispatches,
|
||||
findNextTerminalFederatedDispatchPendingAcknowledgment,
|
||||
isFederatedDispatchRelayEligible,
|
||||
updateFederatedDispatchResources
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,202 @@
|
||||
import type {
|
||||
WorkerReportOutcome,
|
||||
FederationRelayDirection,
|
||||
FederationRelayItemRow
|
||||
} from '../../types'
|
||||
import { OrchestrationError } from '../../orchestration-error'
|
||||
import { ORCHESTRATION_FEDERATION_LIFECYCLE_SETTLEMENT_PROTOCOL_VERSION } from '../../../../../shared/protocol-version'
|
||||
import { parseFederatedWorkerReportOutcome } from '../federated-worker-report-outcome'
|
||||
import type { OrchestrationDb } from '../orchestration-db'
|
||||
|
||||
export function listFederationRelay(
|
||||
this: OrchestrationDb,
|
||||
params: {
|
||||
dispatchId: string
|
||||
direction: FederationRelayDirection
|
||||
afterSequence: number
|
||||
limit?: number
|
||||
}
|
||||
): FederationRelayItemRow[] {
|
||||
return this.db
|
||||
.prepare(
|
||||
`SELECT * FROM federation_relay_items
|
||||
WHERE dispatch_id = ? AND direction = ? AND sequence > ?
|
||||
ORDER BY sequence LIMIT ?`
|
||||
)
|
||||
.all(
|
||||
params.dispatchId,
|
||||
params.direction,
|
||||
params.afterSequence,
|
||||
Math.min(Math.max(params.limit ?? 50, 1), 50)
|
||||
) as FederationRelayItemRow[]
|
||||
}
|
||||
|
||||
export function listPendingFederationRelay(
|
||||
this: OrchestrationDb,
|
||||
dispatchId: string,
|
||||
direction: FederationRelayDirection,
|
||||
limit = 50
|
||||
): FederationRelayItemRow[] {
|
||||
return this.db
|
||||
.prepare(
|
||||
`SELECT * FROM federation_relay_items
|
||||
WHERE dispatch_id = ? AND direction = ? AND acked_at IS NULL
|
||||
ORDER BY sequence LIMIT ?`
|
||||
)
|
||||
.all(dispatchId, direction, Math.min(Math.max(limit, 1), 50)) as FederationRelayItemRow[]
|
||||
}
|
||||
|
||||
export function acknowledgeFederationRelay(
|
||||
this: OrchestrationDb,
|
||||
params: {
|
||||
dispatchId: string
|
||||
direction: FederationRelayDirection
|
||||
throughSequence: number
|
||||
settleRemoteReports?: { sequence: number; outcome?: WorkerReportOutcome }[]
|
||||
}
|
||||
): void {
|
||||
this.db.exec('BEGIN IMMEDIATE')
|
||||
try {
|
||||
const settledReports = params.settleRemoteReports ?? []
|
||||
for (const settledReport of settledReports) {
|
||||
const report = this.getFederationRelayItem(
|
||||
params.dispatchId,
|
||||
params.direction,
|
||||
settledReport.sequence
|
||||
)
|
||||
if (
|
||||
params.direction !== 'to_home' ||
|
||||
settledReport.sequence > params.throughSequence ||
|
||||
report?.kind !== 'worker_done' ||
|
||||
(settledReport.outcome !== undefined &&
|
||||
parseFederatedWorkerReportOutcome(report.payload) !== settledReport.outcome)
|
||||
) {
|
||||
throw new OrchestrationError(
|
||||
'request_mismatch',
|
||||
`Federation acknowledgment for ${params.dispatchId} does not match its queued worker_done.`
|
||||
)
|
||||
}
|
||||
}
|
||||
const attachment = this.getRemoteDispatchAttachment(params.dispatchId)
|
||||
if (
|
||||
params.direction === 'to_home' &&
|
||||
attachment !== undefined &&
|
||||
attachment.protocol_version >= ORCHESTRATION_FEDERATION_LIFECYCLE_SETTLEMENT_PROTOCOL_VERSION
|
||||
) {
|
||||
const acknowledgedReports = this.db
|
||||
.prepare(
|
||||
`SELECT sequence FROM federation_relay_items
|
||||
WHERE dispatch_id = ? AND direction = 'to_home' AND kind = 'worker_done'
|
||||
AND acked_at IS NULL AND sequence <= ?`
|
||||
)
|
||||
.all(params.dispatchId, params.throughSequence) as { sequence: number }[]
|
||||
const settledSequences = new Set(settledReports.map((report) => report.sequence))
|
||||
if (acknowledgedReports.some((report) => !settledSequences.has(report.sequence))) {
|
||||
throw new OrchestrationError(
|
||||
'request_mismatch',
|
||||
`Federation acknowledgment for ${params.dispatchId} omits a worker_done settlement.`
|
||||
)
|
||||
}
|
||||
}
|
||||
const terminalOutcomes = new Set(
|
||||
settledReports.flatMap((report) => (report.outcome ? [report.outcome] : []))
|
||||
)
|
||||
if (terminalOutcomes.size > 1) {
|
||||
throw new OrchestrationError(
|
||||
'request_mismatch',
|
||||
`Federation acknowledgment for ${params.dispatchId} contains conflicting settlements.`
|
||||
)
|
||||
}
|
||||
const terminalOutcome = settledReports.find((report) => report.outcome)?.outcome
|
||||
if (terminalOutcome) {
|
||||
this.settleRemoteAttachmentInRelayTransaction(
|
||||
params.dispatchId,
|
||||
terminalOutcome,
|
||||
'worker_report_settled'
|
||||
)
|
||||
}
|
||||
this.db
|
||||
.prepare(
|
||||
`UPDATE federation_relay_items SET acked_at = COALESCE(acked_at, datetime('now'))
|
||||
WHERE dispatch_id = ? AND direction = ? AND sequence <= ?`
|
||||
)
|
||||
.run(params.dispatchId, params.direction, params.throughSequence)
|
||||
this.db.exec('COMMIT')
|
||||
} catch (error) {
|
||||
this.db.exec('ROLLBACK')
|
||||
throw error
|
||||
}
|
||||
}
|
||||
|
||||
export function setFederatedHomeImportSequence(
|
||||
this: OrchestrationDb,
|
||||
dispatchId: string,
|
||||
sequence: number
|
||||
): void {
|
||||
this.db
|
||||
.prepare(
|
||||
`UPDATE federated_dispatches
|
||||
SET to_home_imported_sequence = ?, updated_at = datetime('now')
|
||||
WHERE dispatch_id = ? AND to_home_imported_sequence < ?`
|
||||
)
|
||||
.run(sequence, dispatchId, sequence)
|
||||
}
|
||||
|
||||
export function recordFederatedHomeAcknowledgment(
|
||||
this: OrchestrationDb,
|
||||
params: {
|
||||
dispatchId: string
|
||||
remoteRuntimeEpoch: string
|
||||
sequence: number
|
||||
}
|
||||
): void {
|
||||
const federated = this.getFederatedDispatch(params.dispatchId)
|
||||
if (
|
||||
!federated ||
|
||||
!Number.isInteger(params.sequence) ||
|
||||
params.sequence < 0 ||
|
||||
params.sequence > federated.to_home_imported_sequence
|
||||
) {
|
||||
throw new OrchestrationError(
|
||||
'request_mismatch',
|
||||
`Federation acknowledgment for ${params.dispatchId} exceeds imported relay state.`
|
||||
)
|
||||
}
|
||||
this.db
|
||||
.prepare(
|
||||
`UPDATE federated_dispatches
|
||||
SET remote_runtime_epoch = ?,
|
||||
to_home_acknowledged_sequence = CASE
|
||||
WHEN remote_runtime_epoch = ?
|
||||
THEN MAX(to_home_acknowledged_sequence, ?)
|
||||
ELSE ?
|
||||
END,
|
||||
updated_at = datetime('now')
|
||||
WHERE dispatch_id = ?`
|
||||
)
|
||||
.run(
|
||||
params.remoteRuntimeEpoch,
|
||||
params.remoteRuntimeEpoch,
|
||||
params.sequence,
|
||||
params.sequence,
|
||||
params.dispatchId
|
||||
)
|
||||
}
|
||||
|
||||
export type FederationRelayAckMethods = {
|
||||
listFederationRelay: typeof listFederationRelay
|
||||
listPendingFederationRelay: typeof listPendingFederationRelay
|
||||
acknowledgeFederationRelay: typeof acknowledgeFederationRelay
|
||||
setFederatedHomeImportSequence: typeof setFederatedHomeImportSequence
|
||||
recordFederatedHomeAcknowledgment: typeof recordFederatedHomeAcknowledgment
|
||||
}
|
||||
|
||||
export function attachFederationRelayAck(ctor: { prototype: object }): void {
|
||||
Object.assign(ctor.prototype, {
|
||||
listFederationRelay,
|
||||
listPendingFederationRelay,
|
||||
acknowledgeFederationRelay,
|
||||
setFederatedHomeImportSequence,
|
||||
recordFederatedHomeAcknowledgment
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,185 @@
|
||||
import type {
|
||||
WorkerReportOutcome,
|
||||
FederationRelayDirection,
|
||||
FederationRelayItemRow
|
||||
} from '../../types'
|
||||
import { OrchestrationError } from '../../orchestration-error'
|
||||
import { generateId } from '../generated-id'
|
||||
import type { OrchestrationDb } from '../orchestration-db'
|
||||
|
||||
export function enqueueFederationRelay(
|
||||
this: OrchestrationDb,
|
||||
params: {
|
||||
dispatchId: string
|
||||
direction: FederationRelayDirection
|
||||
kind: string
|
||||
payload: string
|
||||
messageId?: string
|
||||
settleRemoteOutcome?: WorkerReportOutcome
|
||||
remoteQuestion?: true
|
||||
}
|
||||
): FederationRelayItemRow {
|
||||
const byteCount = Buffer.byteLength(params.payload, 'utf8')
|
||||
const messageId = params.messageId ?? generateId('relay')
|
||||
if (byteCount > 64 * 1024) {
|
||||
throw new OrchestrationError(
|
||||
'relay_quota_exceeded',
|
||||
'A federated orchestration message cannot exceed 64 KiB.'
|
||||
)
|
||||
}
|
||||
this.db.exec('BEGIN IMMEDIATE')
|
||||
try {
|
||||
if (params.settleRemoteOutcome) {
|
||||
const attachment = this.getRemoteDispatchAttachment(params.dispatchId)
|
||||
if (!attachment || attachment.state !== 'ready') {
|
||||
throw new OrchestrationError(
|
||||
'dispatch_inactive',
|
||||
`Remote Dispatch ${params.dispatchId} is not active.`
|
||||
)
|
||||
}
|
||||
}
|
||||
if (params.kind === 'heartbeat') {
|
||||
const heartbeat = this.db
|
||||
.prepare(
|
||||
`SELECT * FROM federation_relay_items
|
||||
WHERE dispatch_id = ? AND direction = ? AND kind = 'heartbeat'
|
||||
AND acked_at IS NULL
|
||||
ORDER BY sequence DESC LIMIT 1`
|
||||
)
|
||||
.get(params.dispatchId, params.direction) as FederationRelayItemRow | undefined
|
||||
if (heartbeat) {
|
||||
this.db
|
||||
.prepare(
|
||||
`UPDATE federation_relay_items
|
||||
SET payload = ?, byte_count = ?, created_at = datetime('now')
|
||||
WHERE dispatch_id = ? AND direction = ? AND sequence = ?`
|
||||
)
|
||||
.run(params.payload, byteCount, params.dispatchId, params.direction, heartbeat.sequence)
|
||||
this.db.exec('COMMIT')
|
||||
return this.getFederationRelayItem(
|
||||
params.dispatchId,
|
||||
params.direction,
|
||||
heartbeat.sequence
|
||||
) as FederationRelayItemRow
|
||||
}
|
||||
}
|
||||
if (params.kind === 'worker_done') {
|
||||
const identicalReport = this.db
|
||||
.prepare(
|
||||
`SELECT * FROM federation_relay_items
|
||||
WHERE dispatch_id = ? AND direction = ? AND kind = 'worker_done'
|
||||
AND payload = ? AND acked_at IS NULL
|
||||
ORDER BY sequence DESC LIMIT 1`
|
||||
)
|
||||
.get(params.dispatchId, params.direction, params.payload) as
|
||||
| FederationRelayItemRow
|
||||
| undefined
|
||||
if (identicalReport) {
|
||||
this.settleRemoteAttachmentInRelayTransaction(params.dispatchId, params.settleRemoteOutcome)
|
||||
this.db.exec('COMMIT')
|
||||
return identicalReport
|
||||
}
|
||||
}
|
||||
const quota = this.db
|
||||
.prepare(
|
||||
`SELECT COUNT(*) AS count, COALESCE(SUM(byte_count), 0) AS bytes
|
||||
FROM federation_relay_items
|
||||
WHERE dispatch_id = ? AND direction = ? AND acked_at IS NULL`
|
||||
)
|
||||
.get(params.dispatchId, params.direction) as { count: number; bytes: number }
|
||||
if (quota.count >= 256 || quota.bytes + byteCount > 1024 * 1024) {
|
||||
if (params.kind === 'worker_done') {
|
||||
const heartbeat = this.db
|
||||
.prepare(
|
||||
`SELECT * FROM federation_relay_items
|
||||
WHERE dispatch_id = ? AND direction = ? AND kind = 'heartbeat'
|
||||
AND acked_at IS NULL
|
||||
ORDER BY sequence LIMIT 1`
|
||||
)
|
||||
.get(params.dispatchId, params.direction) as FederationRelayItemRow | undefined
|
||||
if (heartbeat) {
|
||||
this.db
|
||||
.prepare(
|
||||
`UPDATE federation_relay_items
|
||||
SET message_id = ?, kind = ?, payload = ?, byte_count = ?,
|
||||
created_at = datetime('now')
|
||||
WHERE dispatch_id = ? AND direction = ? AND sequence = ?`
|
||||
)
|
||||
.run(
|
||||
messageId,
|
||||
params.kind,
|
||||
params.payload,
|
||||
byteCount,
|
||||
params.dispatchId,
|
||||
params.direction,
|
||||
heartbeat.sequence
|
||||
)
|
||||
this.settleRemoteAttachmentInRelayTransaction(
|
||||
params.dispatchId,
|
||||
params.settleRemoteOutcome
|
||||
)
|
||||
this.db.exec('COMMIT')
|
||||
return this.getFederationRelayItem(
|
||||
params.dispatchId,
|
||||
params.direction,
|
||||
heartbeat.sequence
|
||||
) as FederationRelayItemRow
|
||||
}
|
||||
}
|
||||
throw new OrchestrationError(
|
||||
'relay_quota_exceeded',
|
||||
`Federated Dispatch ${params.dispatchId} has no relay capacity.`
|
||||
)
|
||||
}
|
||||
const latest = this.db
|
||||
.prepare(
|
||||
`SELECT COALESCE(MAX(sequence), 0) AS sequence
|
||||
FROM federation_relay_items WHERE dispatch_id = ? AND direction = ?`
|
||||
)
|
||||
.get(params.dispatchId, params.direction) as { sequence: number }
|
||||
const sequence = latest.sequence + 1
|
||||
this.db
|
||||
.prepare(
|
||||
`INSERT INTO federation_relay_items (
|
||||
dispatch_id, direction, sequence, message_id, kind, payload, byte_count
|
||||
) VALUES (?, ?, ?, ?, ?, ?, ?)`
|
||||
)
|
||||
.run(
|
||||
params.dispatchId,
|
||||
params.direction,
|
||||
sequence,
|
||||
messageId,
|
||||
params.kind,
|
||||
params.payload,
|
||||
byteCount
|
||||
)
|
||||
if (params.remoteQuestion) {
|
||||
this.db
|
||||
.prepare(
|
||||
`INSERT INTO remote_questions (message_id, dispatch_id)
|
||||
VALUES (?, ?)`
|
||||
)
|
||||
.run(messageId, params.dispatchId)
|
||||
}
|
||||
this.settleRemoteAttachmentInRelayTransaction(params.dispatchId, params.settleRemoteOutcome)
|
||||
this.db.exec('COMMIT')
|
||||
return this.getFederationRelayItem(
|
||||
params.dispatchId,
|
||||
params.direction,
|
||||
sequence
|
||||
) as FederationRelayItemRow
|
||||
} catch (error) {
|
||||
this.db.exec('ROLLBACK')
|
||||
throw error
|
||||
}
|
||||
}
|
||||
|
||||
export type FederationRelayEnqueueMethods = {
|
||||
enqueueFederationRelay: typeof enqueueFederationRelay
|
||||
}
|
||||
|
||||
export function attachFederationRelayEnqueue(ctor: { prototype: object }): void {
|
||||
Object.assign(ctor.prototype, {
|
||||
enqueueFederationRelay
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,140 @@
|
||||
import type {
|
||||
MessageType,
|
||||
MessagePriority,
|
||||
MessageRow,
|
||||
WorkerReportOutcome,
|
||||
WorkerReportSettlement
|
||||
} from '../../types'
|
||||
import { OrchestrationError } from '../../orchestration-error'
|
||||
import type { OrchestrationDb } from '../orchestration-db'
|
||||
|
||||
export function importFederatedRelayItem(
|
||||
this: OrchestrationDb,
|
||||
params: {
|
||||
dispatchId: string
|
||||
sequence: number
|
||||
message: {
|
||||
id: string
|
||||
runId: string
|
||||
from: string
|
||||
to: string
|
||||
subject: string
|
||||
body: string
|
||||
type: MessageType
|
||||
priority: MessagePriority
|
||||
threadId?: string
|
||||
payload?: string
|
||||
}
|
||||
lifecycle:
|
||||
| { kind: 'none' }
|
||||
| { kind: 'heartbeat'; at: string }
|
||||
| {
|
||||
kind: 'worker_report'
|
||||
taskId: string
|
||||
outcome: WorkerReportOutcome
|
||||
result: string
|
||||
}
|
||||
| { kind: 'rejected'; code: string; reason: string }
|
||||
}
|
||||
): {
|
||||
message: MessageRow
|
||||
duplicate: boolean
|
||||
lifecycle?: WorkerReportSettlement | { action: 'rejected'; code: string; reason: string }
|
||||
} {
|
||||
this.db.exec('BEGIN IMMEDIATE')
|
||||
try {
|
||||
const federated = this.getFederatedDispatch(params.dispatchId)
|
||||
if (!federated) {
|
||||
throw new OrchestrationError(
|
||||
'dispatch_not_found',
|
||||
`Federated Dispatch ${params.dispatchId} was not found.`
|
||||
)
|
||||
}
|
||||
const duplicate = params.sequence <= federated.to_home_imported_sequence
|
||||
if (duplicate && !this.getMessageById(params.message.id)) {
|
||||
throw new OrchestrationError(
|
||||
'operation_unknown',
|
||||
`Federated relay sequence ${params.sequence} was committed without its message.`
|
||||
)
|
||||
}
|
||||
if (!duplicate && params.sequence !== federated.to_home_imported_sequence + 1) {
|
||||
throw new OrchestrationError(
|
||||
'operation_unknown',
|
||||
`Federated relay for ${params.dispatchId} is not contiguous after sequence ${federated.to_home_imported_sequence}.`
|
||||
)
|
||||
}
|
||||
|
||||
let message = this.getMessageById(params.message.id)
|
||||
if (!message) {
|
||||
message = this.insertMessage(params.message)
|
||||
} else if (
|
||||
message.run_id !== params.message.runId ||
|
||||
message.to_handle !== params.message.to ||
|
||||
message.type !== params.message.type
|
||||
) {
|
||||
throw new OrchestrationError(
|
||||
'request_mismatch',
|
||||
`Federated relay message ${params.message.id} conflicts with an existing message.`
|
||||
)
|
||||
}
|
||||
if (message.type === 'question') {
|
||||
this.registerFederatedQuestion({
|
||||
messageId: message.id,
|
||||
runId: params.message.runId,
|
||||
dispatchId: params.dispatchId
|
||||
})
|
||||
}
|
||||
let lifecycle:
|
||||
| WorkerReportSettlement
|
||||
| { action: 'rejected'; code: string; reason: string }
|
||||
| undefined
|
||||
if (params.lifecycle.kind === 'heartbeat' && !duplicate) {
|
||||
this.recordHeartbeat(params.dispatchId, params.lifecycle.at)
|
||||
} else if (params.lifecycle.kind === 'worker_report') {
|
||||
lifecycle = this.settleWorkerReportInTransaction({
|
||||
taskId: params.lifecycle.taskId,
|
||||
dispatchId: params.dispatchId,
|
||||
outcome: params.lifecycle.outcome,
|
||||
result: params.lifecycle.result
|
||||
})
|
||||
if (lifecycle.action === 'rejected' && !duplicate) {
|
||||
message = this.convertLifecycleMessageToRejection(
|
||||
message.id,
|
||||
lifecycle.code,
|
||||
lifecycle.reason
|
||||
) as MessageRow
|
||||
}
|
||||
} else if (params.lifecycle.kind === 'rejected') {
|
||||
lifecycle = {
|
||||
action: 'rejected',
|
||||
code: params.lifecycle.code,
|
||||
reason: params.lifecycle.reason
|
||||
}
|
||||
if (!duplicate) {
|
||||
message = this.convertLifecycleMessageToRejection(
|
||||
message.id,
|
||||
params.lifecycle.code,
|
||||
params.lifecycle.reason
|
||||
) as MessageRow
|
||||
}
|
||||
}
|
||||
if (!duplicate) {
|
||||
this.setFederatedHomeImportSequence(params.dispatchId, params.sequence)
|
||||
}
|
||||
this.db.exec('COMMIT')
|
||||
return { message, duplicate, ...(lifecycle ? { lifecycle } : {}) }
|
||||
} catch (error) {
|
||||
this.db.exec('ROLLBACK')
|
||||
throw error
|
||||
}
|
||||
}
|
||||
|
||||
export type FederationRelayImportMethods = {
|
||||
importFederatedRelayItem: typeof importFederatedRelayItem
|
||||
}
|
||||
|
||||
export function attachFederationRelayImport(ctor: { prototype: object }): void {
|
||||
Object.assign(ctor.prototype, {
|
||||
importFederatedRelayItem
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,69 @@
|
||||
import type {
|
||||
WorkerReportOutcome,
|
||||
FederationRelayDirection,
|
||||
FederationRelayItemRow
|
||||
} from '../../types'
|
||||
import { OrchestrationError } from '../../orchestration-error'
|
||||
import type { OrchestrationDb } from '../orchestration-db'
|
||||
|
||||
export function getFederationRelayItem(
|
||||
this: OrchestrationDb,
|
||||
dispatchId: string,
|
||||
direction: FederationRelayDirection,
|
||||
sequence: number
|
||||
): FederationRelayItemRow | undefined {
|
||||
return this.db
|
||||
.prepare(
|
||||
`SELECT * FROM federation_relay_items
|
||||
WHERE dispatch_id = ? AND direction = ? AND sequence = ?`
|
||||
)
|
||||
.get(dispatchId, direction, sequence) as FederationRelayItemRow | undefined
|
||||
}
|
||||
|
||||
export function settleRemoteAttachmentInRelayTransaction(
|
||||
this: OrchestrationDb,
|
||||
dispatchId: string,
|
||||
outcome: WorkerReportOutcome | undefined,
|
||||
stage = 'worker_report_queued'
|
||||
): void {
|
||||
if (!outcome) {
|
||||
return
|
||||
}
|
||||
const attachment = this.getRemoteDispatchAttachment(dispatchId)
|
||||
const state = outcome === 'succeeded' ? 'succeeded' : 'failed'
|
||||
if (!attachment) {
|
||||
throw new OrchestrationError(
|
||||
'dispatch_not_found',
|
||||
`Remote Dispatch ${dispatchId} was not found.`
|
||||
)
|
||||
}
|
||||
if (attachment.state === state) {
|
||||
return
|
||||
}
|
||||
if (attachment.state !== 'ready') {
|
||||
throw new OrchestrationError(
|
||||
'request_mismatch',
|
||||
`Remote Dispatch ${dispatchId} cannot settle as ${state} from ${attachment.state}.`
|
||||
)
|
||||
}
|
||||
this.db
|
||||
.prepare(
|
||||
`UPDATE remote_dispatch_attachments
|
||||
SET state = ?, stage = ?, capability_hash = NULL,
|
||||
updated_at = datetime('now')
|
||||
WHERE dispatch_id = ? AND state = 'ready'`
|
||||
)
|
||||
.run(state, stage, dispatchId)
|
||||
}
|
||||
|
||||
export type FederationRelayItemMethods = {
|
||||
getFederationRelayItem: typeof getFederationRelayItem
|
||||
settleRemoteAttachmentInRelayTransaction: typeof settleRemoteAttachmentInRelayTransaction
|
||||
}
|
||||
|
||||
export function attachFederationRelayItem(ctor: { prototype: object }): void {
|
||||
Object.assign(ctor.prototype, {
|
||||
getFederationRelayItem,
|
||||
settleRemoteAttachmentInRelayTransaction
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,173 @@
|
||||
import { randomBytes, timingSafeEqual } from 'node:crypto'
|
||||
import type { RemoteDispatchAttachmentRow } from '../../types'
|
||||
import { OrchestrationError } from '../../orchestration-error'
|
||||
import { hashDispatchCapability } from '../dispatch-capability-hash'
|
||||
import { isEquivalentPaneKey } from '../pane-key-match'
|
||||
import type { OrchestrationDb } from '../orchestration-db'
|
||||
|
||||
export function prepareRemoteAttachmentAuthority(
|
||||
this: OrchestrationDb,
|
||||
params: {
|
||||
dispatchId: string
|
||||
paneKey: string
|
||||
processIncarnation: string
|
||||
worktreeId: string
|
||||
terminalHandle: string
|
||||
setupState: string
|
||||
effects: unknown[]
|
||||
}
|
||||
): string {
|
||||
const attachment = this.getRemoteDispatchAttachment(params.dispatchId)
|
||||
if (!attachment || attachment.state !== 'starting') {
|
||||
throw new OrchestrationError(
|
||||
'dispatch_inactive',
|
||||
`Remote Dispatch ${params.dispatchId} is not starting.`
|
||||
)
|
||||
}
|
||||
const capability = `dcap_${randomBytes(32).toString('base64url')}`
|
||||
const result = this.db
|
||||
.prepare(
|
||||
`UPDATE remote_dispatch_attachments
|
||||
SET stage = 'authority_attached', capability_hash = ?, pane_key = ?,
|
||||
process_incarnation = ?, worktree_id = ?, terminal_handle = ?, setup_state = ?,
|
||||
effects = ?, residual_resources = ?, updated_at = datetime('now')
|
||||
WHERE dispatch_id = ? AND state = 'starting'`
|
||||
)
|
||||
.run(
|
||||
hashDispatchCapability(capability),
|
||||
params.paneKey,
|
||||
params.processIncarnation,
|
||||
params.worktreeId,
|
||||
params.terminalHandle,
|
||||
params.setupState,
|
||||
JSON.stringify(params.effects),
|
||||
JSON.stringify(
|
||||
params.effects.filter((effect) =>
|
||||
Boolean(
|
||||
effect &&
|
||||
typeof effect === 'object' &&
|
||||
((effect as { action?: string }).action?.startsWith('created') ||
|
||||
(effect as { action?: string }).action === 'reused_agent_terminal')
|
||||
)
|
||||
)
|
||||
),
|
||||
params.dispatchId
|
||||
)
|
||||
// Why: without this the caller keeps a capability whose hash was never stored, surfacing later as an authority mismatch.
|
||||
if (result.changes !== 1) {
|
||||
throw new OrchestrationError(
|
||||
'dispatch_inactive',
|
||||
`Remote Dispatch ${params.dispatchId} is not starting.`
|
||||
)
|
||||
}
|
||||
return capability
|
||||
}
|
||||
|
||||
export function markRemoteAttachmentReady(
|
||||
this: OrchestrationDb,
|
||||
dispatchId: string,
|
||||
effects?: unknown[]
|
||||
): RemoteDispatchAttachmentRow {
|
||||
const result = this.db
|
||||
.prepare(
|
||||
`UPDATE remote_dispatch_attachments
|
||||
SET state = 'ready', stage = 'input_accepted',
|
||||
effects = COALESCE(?, effects), updated_at = datetime('now')
|
||||
WHERE dispatch_id = ? AND state = 'starting'`
|
||||
)
|
||||
.run(effects ? JSON.stringify(effects) : null, dispatchId)
|
||||
if (result.changes !== 1) {
|
||||
throw new OrchestrationError(
|
||||
'dispatch_inactive',
|
||||
`Remote Dispatch ${dispatchId} is not starting.`
|
||||
)
|
||||
}
|
||||
return this.getRemoteDispatchAttachment(dispatchId) as RemoteDispatchAttachmentRow
|
||||
}
|
||||
|
||||
export function failRemoteAttachment(
|
||||
this: OrchestrationDb,
|
||||
dispatchId: string,
|
||||
stage: string,
|
||||
reason: string,
|
||||
unknown: boolean
|
||||
): RemoteDispatchAttachmentRow {
|
||||
const state = unknown ? 'start_unknown' : 'failed'
|
||||
const result = this.db
|
||||
.prepare(
|
||||
`UPDATE remote_dispatch_attachments
|
||||
SET state = ?, stage = ?, last_error = ?, capability_hash = NULL,
|
||||
updated_at = datetime('now')
|
||||
WHERE dispatch_id = ? AND state = 'starting'`
|
||||
)
|
||||
.run(state, stage, reason, dispatchId)
|
||||
if (result.changes !== 1) {
|
||||
throw new OrchestrationError(
|
||||
'dispatch_inactive',
|
||||
`Remote Dispatch ${dispatchId} is not starting.`
|
||||
)
|
||||
}
|
||||
return this.getRemoteDispatchAttachment(dispatchId) as RemoteDispatchAttachmentRow
|
||||
}
|
||||
|
||||
export function verifyRemoteAttachmentAuthority(
|
||||
this: OrchestrationDb,
|
||||
params: {
|
||||
dispatchId: string
|
||||
capability: string | undefined
|
||||
paneKey: string | null
|
||||
processIncarnation: string | null
|
||||
}
|
||||
): boolean {
|
||||
const attachment = this.getRemoteDispatchAttachment(params.dispatchId)
|
||||
if (
|
||||
!attachment?.capability_hash ||
|
||||
!params.capability ||
|
||||
!attachment.pane_key ||
|
||||
!params.paneKey ||
|
||||
!isEquivalentPaneKey(attachment.pane_key, params.paneKey) ||
|
||||
!attachment.process_incarnation ||
|
||||
attachment.process_incarnation !== params.processIncarnation
|
||||
) {
|
||||
return false
|
||||
}
|
||||
const expected = Buffer.from(attachment.capability_hash, 'hex')
|
||||
const observed = Buffer.from(hashDispatchCapability(params.capability), 'hex')
|
||||
return expected.length === observed.length && timingSafeEqual(expected, observed)
|
||||
}
|
||||
|
||||
export function isRemoteAttachmentProcessCurrent(
|
||||
this: OrchestrationDb,
|
||||
params: {
|
||||
dispatchId: string
|
||||
paneKey: string | null
|
||||
processIncarnation: string | null
|
||||
}
|
||||
): boolean {
|
||||
const attachment = this.getRemoteDispatchAttachment(params.dispatchId)
|
||||
return Boolean(
|
||||
attachment?.pane_key &&
|
||||
params.paneKey &&
|
||||
isEquivalentPaneKey(attachment.pane_key, params.paneKey) &&
|
||||
attachment.process_incarnation &&
|
||||
attachment.process_incarnation === params.processIncarnation
|
||||
)
|
||||
}
|
||||
|
||||
export type RemoteDispatchAttachmentAuthorityMethods = {
|
||||
prepareRemoteAttachmentAuthority: typeof prepareRemoteAttachmentAuthority
|
||||
markRemoteAttachmentReady: typeof markRemoteAttachmentReady
|
||||
failRemoteAttachment: typeof failRemoteAttachment
|
||||
verifyRemoteAttachmentAuthority: typeof verifyRemoteAttachmentAuthority
|
||||
isRemoteAttachmentProcessCurrent: typeof isRemoteAttachmentProcessCurrent
|
||||
}
|
||||
|
||||
export function attachRemoteDispatchAttachmentAuthority(ctor: { prototype: object }): void {
|
||||
Object.assign(ctor.prototype, {
|
||||
prepareRemoteAttachmentAuthority,
|
||||
markRemoteAttachmentReady,
|
||||
failRemoteAttachment,
|
||||
verifyRemoteAttachmentAuthority,
|
||||
isRemoteAttachmentProcessCurrent
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,177 @@
|
||||
import type { WorkerDispatchState, RemoteDispatchAttachmentRow } from '../../types'
|
||||
import { OrchestrationError } from '../../orchestration-error'
|
||||
import { ensureMutationReceiptCapacity } from '../../mutation-receipt-capacity'
|
||||
import type { OrchestrationDb } from '../orchestration-db'
|
||||
|
||||
export function createRemoteDispatchAttachment(
|
||||
this: OrchestrationDb,
|
||||
params: {
|
||||
dispatchId: string
|
||||
taskId: string
|
||||
homePeerFingerprint: string
|
||||
protocolVersion: number
|
||||
runtimeEpoch: string
|
||||
mutationReceipt: {
|
||||
callerFingerprint: string
|
||||
requestId: string
|
||||
method: string
|
||||
payloadHash: string
|
||||
}
|
||||
}
|
||||
): RemoteDispatchAttachmentRow {
|
||||
this.db.exec('BEGIN IMMEDIATE')
|
||||
try {
|
||||
if (params.homePeerFingerprint !== params.mutationReceipt.callerFingerprint) {
|
||||
throw new OrchestrationError(
|
||||
'resource_server_mismatch',
|
||||
'The authenticated Run-home peer does not match the attachment request.'
|
||||
)
|
||||
}
|
||||
const existingReceipt = this.getMutationReceipt(
|
||||
params.mutationReceipt.callerFingerprint,
|
||||
params.mutationReceipt.requestId
|
||||
)
|
||||
if (existingReceipt) {
|
||||
throw new OrchestrationError(
|
||||
existingReceipt.method === params.mutationReceipt.method &&
|
||||
existingReceipt.payload_hash === params.mutationReceipt.payloadHash
|
||||
? 'operation_unknown'
|
||||
: 'request_mismatch',
|
||||
`Remote attachment request ${params.mutationReceipt.requestId} already exists.`
|
||||
)
|
||||
}
|
||||
ensureMutationReceiptCapacity(this.db)
|
||||
this.db
|
||||
.prepare(
|
||||
`INSERT INTO mutation_receipts (
|
||||
caller_fingerprint, request_id, method, payload_hash, state, receipt
|
||||
) VALUES (?, ?, ?, ?, 'pending', ?)`
|
||||
)
|
||||
.run(
|
||||
params.mutationReceipt.callerFingerprint,
|
||||
params.mutationReceipt.requestId,
|
||||
params.mutationReceipt.method,
|
||||
params.mutationReceipt.payloadHash,
|
||||
JSON.stringify({ accepted: { dispatchId: params.dispatchId } })
|
||||
)
|
||||
this.db
|
||||
.prepare(
|
||||
`INSERT INTO remote_dispatch_attachments (
|
||||
dispatch_id, task_id, home_peer_fingerprint, protocol_version, runtime_epoch
|
||||
) VALUES (?, ?, ?, ?, ?)`
|
||||
)
|
||||
.run(
|
||||
params.dispatchId,
|
||||
params.taskId,
|
||||
params.homePeerFingerprint,
|
||||
params.protocolVersion,
|
||||
params.runtimeEpoch
|
||||
)
|
||||
this.db.exec('COMMIT')
|
||||
return this.getRemoteDispatchAttachment(params.dispatchId) as RemoteDispatchAttachmentRow
|
||||
} catch (error) {
|
||||
this.db.exec('ROLLBACK')
|
||||
throw error
|
||||
}
|
||||
}
|
||||
|
||||
export function getRemoteDispatchAttachment(
|
||||
this: OrchestrationDb,
|
||||
dispatchId: string
|
||||
): RemoteDispatchAttachmentRow | undefined {
|
||||
return this.db
|
||||
.prepare('SELECT * FROM remote_dispatch_attachments WHERE dispatch_id = ?')
|
||||
.get(dispatchId) as RemoteDispatchAttachmentRow | undefined
|
||||
}
|
||||
|
||||
export function recordRemoteAttachmentStage(
|
||||
this: OrchestrationDb,
|
||||
params: {
|
||||
dispatchId: string
|
||||
stage: string
|
||||
state?: WorkerDispatchState
|
||||
worktreeId?: string
|
||||
terminalHandle?: string
|
||||
setupState?: string
|
||||
effects?: unknown[]
|
||||
residualResources?: unknown[]
|
||||
lastError?: string
|
||||
}
|
||||
): RemoteDispatchAttachmentRow {
|
||||
const current = this.getRemoteDispatchAttachment(params.dispatchId)
|
||||
if (!current) {
|
||||
throw new OrchestrationError(
|
||||
'dispatch_not_found',
|
||||
`Remote Dispatch ${params.dispatchId} was not found.`
|
||||
)
|
||||
}
|
||||
this.db
|
||||
.prepare(
|
||||
`UPDATE remote_dispatch_attachments
|
||||
SET stage = ?, state = ?, worktree_id = ?, terminal_handle = ?, setup_state = ?,
|
||||
effects = ?, residual_resources = ?, last_error = ?, updated_at = datetime('now')
|
||||
WHERE dispatch_id = ?`
|
||||
)
|
||||
.run(
|
||||
params.stage,
|
||||
params.state ?? current.state,
|
||||
params.worktreeId ?? current.worktree_id,
|
||||
params.terminalHandle ?? current.terminal_handle,
|
||||
params.setupState ?? current.setup_state,
|
||||
params.effects ? JSON.stringify(params.effects) : current.effects,
|
||||
params.residualResources
|
||||
? JSON.stringify(params.residualResources)
|
||||
: current.residual_resources,
|
||||
params.lastError ?? current.last_error,
|
||||
params.dispatchId
|
||||
)
|
||||
return this.getRemoteDispatchAttachment(params.dispatchId) as RemoteDispatchAttachmentRow
|
||||
}
|
||||
|
||||
export function updateRemoteAttachmentSetupEvidence(
|
||||
this: OrchestrationDb,
|
||||
params: {
|
||||
dispatchId: string
|
||||
setupState: string
|
||||
effects: unknown[]
|
||||
}
|
||||
): { attachment: RemoteDispatchAttachmentRow; changed: boolean } {
|
||||
const current = this.getRemoteDispatchAttachment(params.dispatchId)
|
||||
if (!current) {
|
||||
throw new OrchestrationError(
|
||||
'dispatch_not_found',
|
||||
`Remote Dispatch ${params.dispatchId} was not found.`
|
||||
)
|
||||
}
|
||||
const effects = JSON.stringify(params.effects)
|
||||
if (current.setup_state === params.setupState && current.effects === effects) {
|
||||
return { attachment: current, changed: false }
|
||||
}
|
||||
this.db
|
||||
.prepare(
|
||||
`UPDATE remote_dispatch_attachments
|
||||
SET setup_state = ?, effects = ?, updated_at = datetime('now')
|
||||
WHERE dispatch_id = ?`
|
||||
)
|
||||
.run(params.setupState, effects, params.dispatchId)
|
||||
return {
|
||||
attachment: this.getRemoteDispatchAttachment(params.dispatchId) as RemoteDispatchAttachmentRow,
|
||||
changed: true
|
||||
}
|
||||
}
|
||||
|
||||
export type RemoteDispatchAttachmentCreateMethods = {
|
||||
createRemoteDispatchAttachment: typeof createRemoteDispatchAttachment
|
||||
getRemoteDispatchAttachment: typeof getRemoteDispatchAttachment
|
||||
recordRemoteAttachmentStage: typeof recordRemoteAttachmentStage
|
||||
updateRemoteAttachmentSetupEvidence: typeof updateRemoteAttachmentSetupEvidence
|
||||
}
|
||||
|
||||
export function attachRemoteDispatchAttachmentCreate(ctor: { prototype: object }): void {
|
||||
Object.assign(ctor.prototype, {
|
||||
createRemoteDispatchAttachment,
|
||||
getRemoteDispatchAttachment,
|
||||
recordRemoteAttachmentStage,
|
||||
updateRemoteAttachmentSetupEvidence
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,106 @@
|
||||
import { parsePaneKey } from '../../../../../shared/stable-pane-id'
|
||||
import type { RemoteDispatchAttachmentRow } from '../../types'
|
||||
import { OrchestrationError } from '../../orchestration-error'
|
||||
import { paneKeyMatchSuffix, REMOTE_ATTACHMENT_PANE_KEY_MATCH_SUFFIX_SQL } from '../pane-key-match'
|
||||
import type { OrchestrationDb } from '../orchestration-db'
|
||||
|
||||
export function beginRemoteAttachmentStop(
|
||||
this: OrchestrationDb,
|
||||
dispatchId: string
|
||||
): RemoteDispatchAttachmentRow {
|
||||
const attachment = this.getRemoteDispatchAttachment(dispatchId)
|
||||
if (!attachment) {
|
||||
throw new OrchestrationError(
|
||||
'dispatch_not_found',
|
||||
`Remote Dispatch ${dispatchId} was not found.`
|
||||
)
|
||||
}
|
||||
if (['succeeded', 'failed', 'stopped', 'abandoned'].includes(attachment.state)) {
|
||||
return attachment
|
||||
}
|
||||
if (!['ready', 'start_unknown'].includes(attachment.state)) {
|
||||
throw new OrchestrationError(
|
||||
'dispatch_inactive',
|
||||
`Remote Dispatch ${dispatchId} cannot stop from ${attachment.state}.`
|
||||
)
|
||||
}
|
||||
this.db
|
||||
.prepare(
|
||||
`UPDATE remote_dispatch_attachments
|
||||
SET state = 'stopping', stage = 'stop_requested', capability_hash = NULL,
|
||||
updated_at = datetime('now')
|
||||
WHERE dispatch_id = ? AND state IN ('ready', 'start_unknown')`
|
||||
)
|
||||
.run(dispatchId)
|
||||
return this.getRemoteDispatchAttachment(dispatchId) as RemoteDispatchAttachmentRow
|
||||
}
|
||||
|
||||
export function settleRemoteAttachmentStop(
|
||||
this: OrchestrationDb,
|
||||
dispatchId: string
|
||||
): RemoteDispatchAttachmentRow {
|
||||
this.db
|
||||
.prepare(
|
||||
`UPDATE remote_dispatch_attachments
|
||||
SET state = 'stopped', stage = 'process_stopped', updated_at = datetime('now')
|
||||
WHERE dispatch_id = ? AND state = 'stopping'`
|
||||
)
|
||||
.run(dispatchId)
|
||||
return this.getRemoteDispatchAttachment(dispatchId) as RemoteDispatchAttachmentRow
|
||||
}
|
||||
|
||||
export function markRemoteAttachmentStopUnknown(
|
||||
this: OrchestrationDb,
|
||||
dispatchId: string,
|
||||
reason: string
|
||||
): RemoteDispatchAttachmentRow {
|
||||
this.db
|
||||
.prepare(
|
||||
`UPDATE remote_dispatch_attachments
|
||||
SET state = 'stop_unknown', stage = 'stop_outcome_unknown', last_error = ?,
|
||||
updated_at = datetime('now')
|
||||
WHERE dispatch_id = ? AND state = 'stopping'`
|
||||
)
|
||||
.run(reason, dispatchId)
|
||||
return this.getRemoteDispatchAttachment(dispatchId) as RemoteDispatchAttachmentRow
|
||||
}
|
||||
|
||||
export function findActiveRemoteAttachmentForPane(
|
||||
this: OrchestrationDb,
|
||||
paneKey: string
|
||||
): RemoteDispatchAttachmentRow | undefined {
|
||||
if (!parsePaneKey(paneKey)) {
|
||||
return this.db
|
||||
.prepare(
|
||||
`SELECT * FROM remote_dispatch_attachments
|
||||
WHERE state IN ('starting', 'ready') AND pane_key = ?
|
||||
ORDER BY rowid DESC LIMIT 1`
|
||||
)
|
||||
.get(paneKey) as RemoteDispatchAttachmentRow | undefined
|
||||
}
|
||||
return this.db
|
||||
.prepare(
|
||||
`SELECT * FROM remote_dispatch_attachments
|
||||
WHERE state IN ('starting', 'ready') AND pane_key IS NOT NULL
|
||||
AND instr(pane_key, ':') > 1
|
||||
AND ${REMOTE_ATTACHMENT_PANE_KEY_MATCH_SUFFIX_SQL} = ?
|
||||
ORDER BY rowid DESC LIMIT 1`
|
||||
)
|
||||
.get(paneKeyMatchSuffix(paneKey)) as RemoteDispatchAttachmentRow | undefined
|
||||
}
|
||||
|
||||
export type RemoteDispatchAttachmentStopMethods = {
|
||||
beginRemoteAttachmentStop: typeof beginRemoteAttachmentStop
|
||||
settleRemoteAttachmentStop: typeof settleRemoteAttachmentStop
|
||||
markRemoteAttachmentStopUnknown: typeof markRemoteAttachmentStopUnknown
|
||||
findActiveRemoteAttachmentForPane: typeof findActiveRemoteAttachmentForPane
|
||||
}
|
||||
|
||||
export function attachRemoteDispatchAttachmentStop(ctor: { prototype: object }): void {
|
||||
Object.assign(ctor.prototype, {
|
||||
beginRemoteAttachmentStop,
|
||||
settleRemoteAttachmentStop,
|
||||
markRemoteAttachmentStopUnknown,
|
||||
findActiveRemoteAttachmentForPane
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,139 @@
|
||||
import { OrchestrationError } from '../../orchestration-error'
|
||||
import type { OrchestrationDb } from '../orchestration-db'
|
||||
|
||||
export function getRemoteQuestion(
|
||||
this: OrchestrationDb,
|
||||
messageId: string
|
||||
):
|
||||
| {
|
||||
message_id: string
|
||||
dispatch_id: string
|
||||
status: 'pending' | 'answered' | 'closed'
|
||||
answer_message_id: string | null
|
||||
answer_body: string | null
|
||||
}
|
||||
| undefined {
|
||||
return this.db.prepare('SELECT * FROM remote_questions WHERE message_id = ?').get(messageId) as
|
||||
| {
|
||||
message_id: string
|
||||
dispatch_id: string
|
||||
status: 'pending' | 'answered' | 'closed'
|
||||
answer_message_id: string | null
|
||||
answer_body: string | null
|
||||
}
|
||||
| undefined
|
||||
}
|
||||
|
||||
type RemoteAnswerParams = {
|
||||
messageId: string
|
||||
dispatchId: string
|
||||
answerMessageId: string
|
||||
body: string
|
||||
}
|
||||
|
||||
// Returns 'settled' when the stored row already carries this exact answer, 'writable' when the guarded UPDATE may apply.
|
||||
function classifyRemoteQuestion(
|
||||
this: OrchestrationDb,
|
||||
params: RemoteAnswerParams
|
||||
): 'settled' | 'writable' {
|
||||
const question = this.getRemoteQuestion(params.messageId)
|
||||
if (!question || question.dispatch_id !== params.dispatchId) {
|
||||
throw new OrchestrationError(
|
||||
'question_not_found',
|
||||
`Remote Question ${params.messageId} was not found.`
|
||||
)
|
||||
}
|
||||
if (question.status === 'answered') {
|
||||
if (
|
||||
question.answer_message_id !== params.answerMessageId ||
|
||||
question.answer_body !== params.body
|
||||
) {
|
||||
throw new OrchestrationError(
|
||||
'answer_conflict',
|
||||
`Remote Question ${params.messageId} already has a different answer.`
|
||||
)
|
||||
}
|
||||
return 'settled'
|
||||
}
|
||||
// Why: the guarded UPDATE is a silent no-op for a closed question, which the caller would read as stored.
|
||||
if (question.status === 'closed') {
|
||||
throw new OrchestrationError(
|
||||
'question_not_found',
|
||||
`Remote Question ${params.messageId} is closed.`
|
||||
)
|
||||
}
|
||||
return 'writable'
|
||||
}
|
||||
|
||||
export function answerRemoteQuestion(this: OrchestrationDb, params: RemoteAnswerParams): void {
|
||||
if (classifyRemoteQuestion.call(this, params) === 'settled') {
|
||||
return
|
||||
}
|
||||
const changes = this.db
|
||||
.prepare(
|
||||
`UPDATE remote_questions
|
||||
SET status = 'answered', answer_message_id = ?, answer_body = ?,
|
||||
answered_at = datetime('now')
|
||||
WHERE message_id = ? AND status = 'pending'`
|
||||
)
|
||||
.run(params.answerMessageId, params.body, params.messageId).changes
|
||||
if (changes > 0) {
|
||||
return
|
||||
}
|
||||
// Why: a concurrent answer or close won the guarded UPDATE after our read; re-classify so the caller
|
||||
// sees the stored outcome instead of a success that never happened.
|
||||
if (classifyRemoteQuestion.call(this, params) === 'settled') {
|
||||
return
|
||||
}
|
||||
throw new OrchestrationError(
|
||||
'answer_conflict',
|
||||
`Remote Question ${params.messageId} could not be answered.`
|
||||
)
|
||||
}
|
||||
|
||||
export function setRemoteWorkerImportSequence(
|
||||
this: OrchestrationDb,
|
||||
dispatchId: string,
|
||||
sequence: number
|
||||
): void {
|
||||
this.db
|
||||
.prepare(
|
||||
`UPDATE remote_dispatch_attachments
|
||||
SET to_worker_imported_sequence = ?, updated_at = datetime('now')
|
||||
WHERE dispatch_id = ? AND to_worker_imported_sequence < ?`
|
||||
)
|
||||
.run(sequence, dispatchId, sequence)
|
||||
}
|
||||
|
||||
export function registerFederatedQuestion(
|
||||
this: OrchestrationDb,
|
||||
params: {
|
||||
messageId: string
|
||||
runId: string
|
||||
dispatchId: string
|
||||
}
|
||||
): void {
|
||||
this.db
|
||||
.prepare(
|
||||
`INSERT OR IGNORE INTO question_threads (
|
||||
message_id, run_id, dispatch_id, asker_handle
|
||||
) VALUES (?, ?, ?, ?)`
|
||||
)
|
||||
.run(params.messageId, params.runId, params.dispatchId, `dispatch:${params.dispatchId}`)
|
||||
}
|
||||
|
||||
export type RemoteQuestionStoreMethods = {
|
||||
getRemoteQuestion: typeof getRemoteQuestion
|
||||
answerRemoteQuestion: typeof answerRemoteQuestion
|
||||
setRemoteWorkerImportSequence: typeof setRemoteWorkerImportSequence
|
||||
registerFederatedQuestion: typeof registerFederatedQuestion
|
||||
}
|
||||
|
||||
export function attachRemoteQuestionStore(ctor: { prototype: object }): void {
|
||||
Object.assign(ctor.prototype, {
|
||||
getRemoteQuestion,
|
||||
answerRemoteQuestion,
|
||||
setRemoteWorkerImportSequence,
|
||||
registerFederatedQuestion
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
import { randomBytes } from 'node:crypto'
|
||||
|
||||
export function generateId(prefix: string): string {
|
||||
return `${prefix}_${randomBytes(6).toString('hex')}`
|
||||
}
|
||||
@@ -0,0 +1,48 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import type { MessageRow } from '../types'
|
||||
import {
|
||||
legacyMessageMatchesQuestion,
|
||||
normalizeLegacyQuestionOptions,
|
||||
normalizeLegacyQuestionText
|
||||
} from './legacy-question-identity'
|
||||
|
||||
function message(overrides: Partial<MessageRow> = {}): MessageRow {
|
||||
return {
|
||||
id: 'msg_1',
|
||||
run_id: 'run_1',
|
||||
delivery_contract: 'legacy_direct',
|
||||
from_handle: 'from',
|
||||
to_handle: 'to',
|
||||
subject: 'q',
|
||||
body: 'Ask this?\r\n',
|
||||
type: 'question',
|
||||
priority: 'normal',
|
||||
thread_id: null,
|
||||
payload: '{"options":[" yes ","no"]}',
|
||||
read: 0,
|
||||
sequence: 1,
|
||||
created_at: '2026-01-01 00:00:00',
|
||||
delivered_at: null,
|
||||
sender_pane_key: null,
|
||||
...overrides
|
||||
}
|
||||
}
|
||||
|
||||
describe('legacy-question-identity', () => {
|
||||
it('normalizes question text and option arrays', () => {
|
||||
expect(normalizeLegacyQuestionText(' a\r\nb ')).toBe('a\nb')
|
||||
expect(normalizeLegacyQuestionOptions([' yes ', 'no'])).toBe('["yes","no"]')
|
||||
expect(normalizeLegacyQuestionOptions(['yes', 1])).toBe('[]')
|
||||
})
|
||||
|
||||
it('matches a question only when recipient, body, and options agree', () => {
|
||||
expect(legacyMessageMatchesQuestion(message(), 'Ask this?', ['yes', 'no'], ['to'])).toBe(true)
|
||||
expect(legacyMessageMatchesQuestion(message(), 'Ask this?', ['yes', 'no'], ['other'])).toBe(
|
||||
false
|
||||
)
|
||||
expect(legacyMessageMatchesQuestion(message(), 'Different', ['yes', 'no'], ['to'])).toBe(false)
|
||||
expect(
|
||||
legacyMessageMatchesQuestion(message({ payload: '{' }), 'Ask this?', ['yes'], ['to'])
|
||||
).toBe(false)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,34 @@
|
||||
import type { MessageRow } from '../types'
|
||||
|
||||
export function normalizeLegacyQuestionText(value: string): string {
|
||||
return value.replace(/\r\n/g, '\n').trim()
|
||||
}
|
||||
|
||||
export function normalizeLegacyQuestionOptions(options: unknown): string {
|
||||
if (!Array.isArray(options) || !options.every((option) => typeof option === 'string')) {
|
||||
return '[]'
|
||||
}
|
||||
return JSON.stringify(options.map((option) => option.trim()))
|
||||
}
|
||||
|
||||
export function legacyMessageMatchesQuestion(
|
||||
message: MessageRow,
|
||||
question: string,
|
||||
options: string[],
|
||||
recipientHandles: readonly string[]
|
||||
): boolean {
|
||||
if (
|
||||
!recipientHandles.includes(message.to_handle) ||
|
||||
normalizeLegacyQuestionText(message.body) !== normalizeLegacyQuestionText(question)
|
||||
) {
|
||||
return false
|
||||
}
|
||||
try {
|
||||
const payload = JSON.parse(message.payload ?? '{}') as { options?: unknown }
|
||||
return (
|
||||
normalizeLegacyQuestionOptions(payload.options) === normalizeLegacyQuestionOptions(options)
|
||||
)
|
||||
} catch {
|
||||
return false
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,148 @@
|
||||
import type { MessageRow, LegacyOperationReceiptRow, QuestionRow } from '../../types'
|
||||
import { OrchestrationError } from '../../orchestration-error'
|
||||
import { LEGACY_CONTRACT_VERSION } from '../contract-constants'
|
||||
import { legacyMessageMatchesQuestion } from '../legacy-question-identity'
|
||||
import type { OrchestrationDb } from '../orchestration-db'
|
||||
|
||||
export function commitLegacyAskOperation(
|
||||
this: OrchestrationDb,
|
||||
params: {
|
||||
principalId: string
|
||||
operationKey: string
|
||||
method: string
|
||||
payloadHash: string
|
||||
question: string
|
||||
options?: string[]
|
||||
recipientHandle: string
|
||||
existingQuestionId?: string
|
||||
}
|
||||
): {
|
||||
receipt: LegacyOperationReceiptRow
|
||||
question: QuestionRow
|
||||
message: MessageRow
|
||||
duplicate: boolean
|
||||
} {
|
||||
this.db.exec('BEGIN IMMEDIATE')
|
||||
try {
|
||||
const principal = this.requireCommittedLegacyPrincipal(params.principalId, 'worker')
|
||||
const receipt = this.requireMatchingLegacyOperationReceipt(params)
|
||||
if (receipt) {
|
||||
const response = JSON.parse(receipt.response_json) as { questionId: string }
|
||||
const question = this.getQuestion(response.questionId)
|
||||
const message = this.getMessageById(response.questionId)
|
||||
if (!question || !message) {
|
||||
throw new OrchestrationError(
|
||||
'operation_unknown',
|
||||
`Legacy ask ${params.operationKey} lost its durable question.`
|
||||
)
|
||||
}
|
||||
this.db.exec('COMMIT')
|
||||
return { receipt, question, message, duplicate: true }
|
||||
}
|
||||
|
||||
const dispatchId = principal.dispatch_id as string
|
||||
const dispatch = this.getDispatchContextById(dispatchId)
|
||||
if (
|
||||
!dispatch ||
|
||||
dispatch.run_id !== principal.run_id ||
|
||||
dispatch.contract_version !== LEGACY_CONTRACT_VERSION ||
|
||||
!['pending', 'dispatched'].includes(dispatch.status)
|
||||
) {
|
||||
throw new OrchestrationError(
|
||||
'dispatch_inactive',
|
||||
`Dispatch ${dispatchId} is not an active legacy attempt.`
|
||||
)
|
||||
}
|
||||
|
||||
const existingQuestionId =
|
||||
params.existingQuestionId &&
|
||||
!this.db
|
||||
.prepare(
|
||||
`SELECT 1 FROM legacy_operation_receipts
|
||||
WHERE principal_id = ? AND method = 'orchestration.ask' AND effect_id = ?
|
||||
LIMIT 1`
|
||||
)
|
||||
.get(principal.id, params.existingQuestionId)
|
||||
? params.existingQuestionId
|
||||
: undefined
|
||||
let question: QuestionRow
|
||||
let message: MessageRow
|
||||
const delivery = this.resolveLegacyWorkerCoordinatorDelivery(
|
||||
principal.run_id,
|
||||
params.recipientHandle
|
||||
)
|
||||
if (existingQuestionId) {
|
||||
const existingQuestion = this.getQuestion(existingQuestionId)
|
||||
const existingMessage = this.getMessageById(existingQuestionId)
|
||||
if (
|
||||
!existingQuestion ||
|
||||
!existingMessage ||
|
||||
existingQuestion.run_id !== principal.run_id ||
|
||||
existingQuestion.dispatch_id !== dispatchId ||
|
||||
existingQuestion.status !== 'pending' ||
|
||||
existingMessage.delivery_contract !== delivery.contract ||
|
||||
!legacyMessageMatchesQuestion(existingMessage, params.question, params.options ?? [], [
|
||||
delivery.to
|
||||
])
|
||||
) {
|
||||
throw new OrchestrationError(
|
||||
'request_mismatch',
|
||||
`Question ${params.existingQuestionId} is not a pending ask for this principal.`
|
||||
)
|
||||
}
|
||||
question = existingQuestion
|
||||
message = existingMessage
|
||||
} else {
|
||||
message = this.insertMessage({
|
||||
from: principal.terminal_handle,
|
||||
to: delivery.to,
|
||||
subject: 'Question',
|
||||
body: params.question,
|
||||
type: delivery.contract === 'legacy_direct' ? 'decision_gate' : 'question',
|
||||
payload: JSON.stringify({
|
||||
taskId: dispatch.task_id,
|
||||
dispatchId,
|
||||
question: params.question,
|
||||
options: params.options ?? []
|
||||
}),
|
||||
senderPaneKey: principal.pane_key,
|
||||
runId: principal.run_id,
|
||||
deliveryContract: delivery.contract
|
||||
})
|
||||
this.db.prepare('UPDATE messages SET thread_id = ? WHERE id = ?').run(message.id, message.id)
|
||||
this.db
|
||||
.prepare(
|
||||
`INSERT INTO question_threads (
|
||||
message_id, run_id, dispatch_id, asker_handle
|
||||
) VALUES (?, ?, ?, ?)`
|
||||
)
|
||||
.run(message.id, principal.run_id, dispatchId, principal.terminal_handle)
|
||||
question = this.getQuestion(message.id) as QuestionRow
|
||||
message = this.getMessageById(message.id) as MessageRow
|
||||
}
|
||||
|
||||
const committedReceipt = this.insertLegacyOperationReceipt({
|
||||
principalId: principal.id,
|
||||
operationKey: params.operationKey,
|
||||
method: params.method,
|
||||
payloadHash: params.payloadHash,
|
||||
effectId: question.message_id,
|
||||
responseJson: JSON.stringify({ questionId: question.message_id })
|
||||
})
|
||||
this.db.exec('COMMIT')
|
||||
return { receipt: committedReceipt, question, message, duplicate: false }
|
||||
} catch (error) {
|
||||
this.db.exec('ROLLBACK')
|
||||
throw error
|
||||
}
|
||||
}
|
||||
|
||||
export type LegacyAskOperationMethods = {
|
||||
commitLegacyAskOperation: typeof commitLegacyAskOperation
|
||||
}
|
||||
|
||||
export function attachLegacyAskOperation(ctor: { prototype: object }): void {
|
||||
Object.assign(ctor.prototype, {
|
||||
commitLegacyAskOperation
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,185 @@
|
||||
import type {
|
||||
DispatchContextRow,
|
||||
LegacyCompatibilityPrincipalRow,
|
||||
LegacyPrincipalRole
|
||||
} from '../../types'
|
||||
import { OrchestrationError } from '../../orchestration-error'
|
||||
import { LEGACY_CONTRACT_VERSION } from '../contract-constants'
|
||||
import { isEquivalentPaneKey } from '../pane-key-match'
|
||||
import type { OrchestrationDb } from '../orchestration-db'
|
||||
|
||||
export function resolveLegacyCompatibilityPrincipalByIdentity(
|
||||
this: OrchestrationDb,
|
||||
params: {
|
||||
runId: string
|
||||
role: LegacyPrincipalRole
|
||||
terminalHandle?: string
|
||||
paneKey?: string
|
||||
}
|
||||
): LegacyCompatibilityPrincipalRow | undefined {
|
||||
if (!params.terminalHandle && !params.paneKey) {
|
||||
return undefined
|
||||
}
|
||||
const rows = (
|
||||
this.db
|
||||
.prepare(
|
||||
`SELECT * FROM legacy_compatibility_principals
|
||||
WHERE run_id = ? AND role = ? AND status IN ('committed', 'settled')
|
||||
ORDER BY rowid`
|
||||
)
|
||||
.all(params.runId, params.role) as LegacyCompatibilityPrincipalRow[]
|
||||
).filter((principal) =>
|
||||
params.paneKey
|
||||
? isEquivalentPaneKey(principal.pane_key, params.paneKey)
|
||||
: principal.terminal_handle === params.terminalHandle
|
||||
)
|
||||
if (rows.length > 1) {
|
||||
throw new OrchestrationError(
|
||||
'operation_unknown',
|
||||
'Multiple legacy principals match this process identity.'
|
||||
)
|
||||
}
|
||||
return rows[0]
|
||||
}
|
||||
|
||||
export function resolveLegacyWorkerCandidate(
|
||||
this: OrchestrationDb,
|
||||
params: {
|
||||
runId?: string
|
||||
terminalHandle?: string
|
||||
paneKey?: string
|
||||
dispatchId?: string
|
||||
taskId?: string
|
||||
}
|
||||
): { dispatch: DispatchContextRow } | undefined {
|
||||
if (!params.runId || (!params.terminalHandle && !params.paneKey)) {
|
||||
return undefined
|
||||
}
|
||||
const rows = (
|
||||
params.dispatchId
|
||||
? [this.getDispatchContextById(params.dispatchId)].filter(
|
||||
(row): row is DispatchContextRow => row !== undefined
|
||||
)
|
||||
: (this.db
|
||||
.prepare(
|
||||
`SELECT * FROM dispatch_contexts
|
||||
WHERE run_id = ? AND contract_version = ?
|
||||
AND status IN ('pending', 'dispatched')
|
||||
ORDER BY rowid`
|
||||
)
|
||||
.all(params.runId, LEGACY_CONTRACT_VERSION) as DispatchContextRow[])
|
||||
).filter(
|
||||
(dispatch) =>
|
||||
dispatch.run_id === params.runId &&
|
||||
dispatch.contract_version === LEGACY_CONTRACT_VERSION &&
|
||||
(!params.taskId || dispatch.task_id === params.taskId) &&
|
||||
(params.paneKey
|
||||
? Boolean(
|
||||
dispatch.assignee_pane_key &&
|
||||
isEquivalentPaneKey(dispatch.assignee_pane_key, params.paneKey)
|
||||
)
|
||||
: dispatch.assignee_handle === params.terminalHandle)
|
||||
)
|
||||
if (rows.length > 1) {
|
||||
throw new OrchestrationError(
|
||||
'operation_unknown',
|
||||
'Multiple active legacy Dispatches match this process identity.'
|
||||
)
|
||||
}
|
||||
if (params.dispatchId && rows.length === 0) {
|
||||
const target = this.getDispatchContextById(params.dispatchId)
|
||||
if (target?.contract_version === LEGACY_CONTRACT_VERSION) {
|
||||
throw new OrchestrationError(
|
||||
'legacy_read_only',
|
||||
`Dispatch ${params.dispatchId} is retained but this process cannot prove ownership.`
|
||||
)
|
||||
}
|
||||
}
|
||||
return rows[0] ? { dispatch: rows[0] } : undefined
|
||||
}
|
||||
|
||||
export function resolveLegacyCoordinatorCandidate(
|
||||
this: OrchestrationDb,
|
||||
params: {
|
||||
runId: string
|
||||
terminalHandle?: string
|
||||
paneKey?: string
|
||||
}
|
||||
): { terminalHandle: string; paneKey: string } | undefined {
|
||||
if (!params.terminalHandle || !params.paneKey) {
|
||||
return undefined
|
||||
}
|
||||
const run = this.getRunRaw(params.runId)
|
||||
const principal = this.getLegacyCoordinatorPrincipal(params.runId)
|
||||
if (principal) {
|
||||
if (
|
||||
principal.status !== 'committed' ||
|
||||
principal.terminal_handle !== params.terminalHandle ||
|
||||
!isEquivalentPaneKey(principal.pane_key, params.paneKey) ||
|
||||
(run?.coordinator_pane_key !== null &&
|
||||
(run?.coordinator_handle !== principal.terminal_handle ||
|
||||
!isEquivalentPaneKey(run.coordinator_pane_key, principal.pane_key)))
|
||||
) {
|
||||
return undefined
|
||||
}
|
||||
return { terminalHandle: params.terminalHandle, paneKey: params.paneKey }
|
||||
}
|
||||
// Why: the first current binding durably fences uncommitted legacy processes.
|
||||
if (
|
||||
!run ||
|
||||
run.coordinator_pane_key !== null ||
|
||||
this.getUniqueLegacyCoordinatorHandle(params.runId) !== params.terminalHandle
|
||||
) {
|
||||
return undefined
|
||||
}
|
||||
return { terminalHandle: params.terminalHandle, paneKey: params.paneKey }
|
||||
}
|
||||
|
||||
// Why: caller-side fence jurisdiction. Widening this fences more callers, so keep it narrow.
|
||||
export function isLegacyCoordinatorHandle(
|
||||
this: OrchestrationDb,
|
||||
runId: string,
|
||||
terminalHandle: string
|
||||
): boolean {
|
||||
const principal = this.getLegacyCoordinatorPrincipal(runId)
|
||||
if (principal) {
|
||||
return principal.terminal_handle === terminalHandle
|
||||
}
|
||||
return this.getUniqueLegacyCoordinatorHandle(runId) === terminalHandle
|
||||
}
|
||||
|
||||
// Why: recipient-side permit for legacy lifecycle mail. After a takeover revokes the old principal
|
||||
// the replacement coordinator is only reachable through the Run binding.
|
||||
export function isLegacyCoordinatorDeliveryTarget(
|
||||
this: OrchestrationDb,
|
||||
runId: string,
|
||||
terminalHandle: string
|
||||
): boolean {
|
||||
if (this.isLegacyCoordinatorHandle(runId, terminalHandle)) {
|
||||
return true
|
||||
}
|
||||
if (this.getRunRaw(runId)?.coordinator_handle !== terminalHandle) {
|
||||
return false
|
||||
}
|
||||
// Why: must match resolveLegacyWorkerCoordinatorDelivery's takeover test. A still-committed
|
||||
// principal routes legacy_direct to this handle, and no reader can see that mailbox.
|
||||
return this.getLegacyCoordinatorPrincipal(runId)?.status !== 'committed'
|
||||
}
|
||||
|
||||
export type LegacyCompatibilityCandidatesMethods = {
|
||||
resolveLegacyCompatibilityPrincipalByIdentity: typeof resolveLegacyCompatibilityPrincipalByIdentity
|
||||
resolveLegacyWorkerCandidate: typeof resolveLegacyWorkerCandidate
|
||||
resolveLegacyCoordinatorCandidate: typeof resolveLegacyCoordinatorCandidate
|
||||
isLegacyCoordinatorHandle: typeof isLegacyCoordinatorHandle
|
||||
isLegacyCoordinatorDeliveryTarget: typeof isLegacyCoordinatorDeliveryTarget
|
||||
}
|
||||
|
||||
export function attachLegacyCompatibilityCandidates(ctor: { prototype: object }): void {
|
||||
Object.assign(ctor.prototype, {
|
||||
resolveLegacyCompatibilityPrincipalByIdentity,
|
||||
resolveLegacyWorkerCandidate,
|
||||
resolveLegacyCoordinatorCandidate,
|
||||
isLegacyCoordinatorHandle,
|
||||
isLegacyCoordinatorDeliveryTarget
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,188 @@
|
||||
import type {
|
||||
LegacyAdoptionRow,
|
||||
LegacyCompatibilityPrincipalRow,
|
||||
LegacyPrincipalRole
|
||||
} from '../../types'
|
||||
import { OrchestrationError } from '../../orchestration-error'
|
||||
import { LEGACY_RUN_ID, LEGACY_CONTRACT_VERSION } from '../contract-constants'
|
||||
import { generateId } from '../generated-id'
|
||||
import type { OrchestrationDb } from '../orchestration-db'
|
||||
|
||||
// ── Legacy adoption and compatibility principals ──
|
||||
|
||||
export function getLegacyAdoption(this: OrchestrationDb): LegacyAdoptionRow | undefined {
|
||||
return this.db
|
||||
.prepare('SELECT * FROM legacy_adoptions WHERE source_run_id = ?')
|
||||
.get(LEGACY_RUN_ID) as LegacyAdoptionRow | undefined
|
||||
}
|
||||
|
||||
export function commitLegacyCompatibilityPrincipal(
|
||||
this: OrchestrationDb,
|
||||
params: {
|
||||
runId: string
|
||||
dispatchId?: string
|
||||
role: LegacyPrincipalRole
|
||||
hostScope: string
|
||||
terminalHandle: string
|
||||
paneKey: string
|
||||
launchTokenHash: string
|
||||
processIncarnation?: string
|
||||
}
|
||||
): { principal: LegacyCompatibilityPrincipalRow; duplicate: boolean } {
|
||||
this.db.exec('BEGIN IMMEDIATE')
|
||||
try {
|
||||
const adoption = this.getLegacyAdoption()
|
||||
if (!adoption || adoption.adopted_run_id !== params.runId) {
|
||||
throw new OrchestrationError(
|
||||
'request_mismatch',
|
||||
`Run ${params.runId} is not the adopted legacy Run.`
|
||||
)
|
||||
}
|
||||
const dispatchId = params.role === 'worker' ? (params.dispatchId ?? null) : null
|
||||
let initialStatus: 'committed' | 'settled' = 'committed'
|
||||
if (params.role === 'worker') {
|
||||
const dispatch = dispatchId ? this.getDispatchContextById(dispatchId) : undefined
|
||||
if (
|
||||
!dispatch ||
|
||||
dispatch.run_id !== params.runId ||
|
||||
dispatch.contract_version !== LEGACY_CONTRACT_VERSION
|
||||
) {
|
||||
throw new OrchestrationError(
|
||||
'request_mismatch',
|
||||
`Dispatch ${dispatchId ?? '(missing)'} is not a legacy attempt in this Run.`
|
||||
)
|
||||
}
|
||||
initialStatus = ['pending', 'dispatched'].includes(dispatch.status) ? 'committed' : 'settled'
|
||||
} else if (params.dispatchId) {
|
||||
throw new OrchestrationError(
|
||||
'request_mismatch',
|
||||
'A coordinator compatibility principal cannot name a Dispatch.'
|
||||
)
|
||||
}
|
||||
|
||||
const existing = this.db
|
||||
.prepare(
|
||||
`SELECT * FROM legacy_compatibility_principals
|
||||
WHERE role = ? AND run_id = ? AND dispatch_id IS ?`
|
||||
)
|
||||
.get(params.role, params.runId, dispatchId) as LegacyCompatibilityPrincipalRow | undefined
|
||||
if (existing) {
|
||||
const same =
|
||||
existing.host_scope === params.hostScope &&
|
||||
existing.terminal_handle === params.terminalHandle &&
|
||||
existing.pane_key === params.paneKey &&
|
||||
existing.launch_token_hash === params.launchTokenHash &&
|
||||
existing.process_incarnation === (params.processIncarnation ?? null)
|
||||
if (!same) {
|
||||
throw new OrchestrationError(
|
||||
'request_mismatch',
|
||||
`The ${params.role} compatibility principal is already committed to different proof.`
|
||||
)
|
||||
}
|
||||
if (existing.status === 'revoked') {
|
||||
throw new OrchestrationError(
|
||||
'legacy_read_only',
|
||||
`The ${params.role} compatibility principal has been revoked. No effects were applied.`,
|
||||
{ effectsApplied: false }
|
||||
)
|
||||
}
|
||||
this.db.exec('COMMIT')
|
||||
return { principal: existing, duplicate: true }
|
||||
}
|
||||
if (
|
||||
params.role === 'coordinator' &&
|
||||
!this.resolveLegacyCoordinatorCandidate({
|
||||
runId: params.runId,
|
||||
terminalHandle: params.terminalHandle,
|
||||
paneKey: params.paneKey
|
||||
})
|
||||
) {
|
||||
throw new OrchestrationError(
|
||||
'legacy_read_only',
|
||||
'This retained legacy coordinator no longer has lifecycle authority. No effects were applied.',
|
||||
{ effectsApplied: false }
|
||||
)
|
||||
}
|
||||
|
||||
const id = generateId('legacy_principal')
|
||||
this.db
|
||||
.prepare(
|
||||
`INSERT INTO legacy_compatibility_principals (
|
||||
id, run_id, dispatch_id, role, host_scope, terminal_handle,
|
||||
pane_key, launch_token_hash, process_incarnation, status
|
||||
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`
|
||||
)
|
||||
.run(
|
||||
id,
|
||||
params.runId,
|
||||
dispatchId,
|
||||
params.role,
|
||||
params.hostScope,
|
||||
params.terminalHandle,
|
||||
params.paneKey,
|
||||
params.launchTokenHash,
|
||||
params.processIncarnation ?? null,
|
||||
initialStatus
|
||||
)
|
||||
const principal = this.getLegacyCompatibilityPrincipal(id) as LegacyCompatibilityPrincipalRow
|
||||
if (principal.status === 'committed') {
|
||||
this.initializeLegacyRecoveryCohort(principal)
|
||||
}
|
||||
this.db.exec('COMMIT')
|
||||
return { principal, duplicate: false }
|
||||
} catch (error) {
|
||||
this.db.exec('ROLLBACK')
|
||||
throw error
|
||||
}
|
||||
}
|
||||
|
||||
export function getLegacyCompatibilityPrincipal(
|
||||
this: OrchestrationDb,
|
||||
id: string
|
||||
): LegacyCompatibilityPrincipalRow | undefined {
|
||||
return this.db.prepare('SELECT * FROM legacy_compatibility_principals WHERE id = ?').get(id) as
|
||||
| LegacyCompatibilityPrincipalRow
|
||||
| undefined
|
||||
}
|
||||
|
||||
export function listLegacyCompatibilityPrincipals(
|
||||
this: OrchestrationDb,
|
||||
runId: string
|
||||
): LegacyCompatibilityPrincipalRow[] {
|
||||
return this.db
|
||||
.prepare(
|
||||
`SELECT * FROM legacy_compatibility_principals
|
||||
WHERE run_id = ? ORDER BY rowid`
|
||||
)
|
||||
.all(runId) as LegacyCompatibilityPrincipalRow[]
|
||||
}
|
||||
|
||||
export function getLegacyCoordinatorPrincipal(
|
||||
this: OrchestrationDb,
|
||||
runId: string
|
||||
): LegacyCompatibilityPrincipalRow | undefined {
|
||||
return this.db
|
||||
.prepare(
|
||||
`SELECT * FROM legacy_compatibility_principals
|
||||
WHERE run_id = ? AND role = 'coordinator'`
|
||||
)
|
||||
.get(runId) as LegacyCompatibilityPrincipalRow | undefined
|
||||
}
|
||||
|
||||
export type LegacyCompatibilityPrincipalsMethods = {
|
||||
getLegacyAdoption: typeof getLegacyAdoption
|
||||
commitLegacyCompatibilityPrincipal: typeof commitLegacyCompatibilityPrincipal
|
||||
getLegacyCompatibilityPrincipal: typeof getLegacyCompatibilityPrincipal
|
||||
listLegacyCompatibilityPrincipals: typeof listLegacyCompatibilityPrincipals
|
||||
getLegacyCoordinatorPrincipal: typeof getLegacyCoordinatorPrincipal
|
||||
}
|
||||
|
||||
export function attachLegacyCompatibilityPrincipals(ctor: { prototype: object }): void {
|
||||
Object.assign(ctor.prototype, {
|
||||
getLegacyAdoption,
|
||||
commitLegacyCompatibilityPrincipal,
|
||||
getLegacyCompatibilityPrincipal,
|
||||
listLegacyCompatibilityPrincipals,
|
||||
getLegacyCoordinatorPrincipal
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,196 @@
|
||||
import type {
|
||||
MessageType,
|
||||
MessagePriority,
|
||||
MessageRow,
|
||||
WorkerReportOutcome,
|
||||
WorkerReportSettlement,
|
||||
LegacyOperationReceiptRow
|
||||
} from '../../types'
|
||||
import { OrchestrationError } from '../../orchestration-error'
|
||||
import { LEGACY_CONTRACT_VERSION } from '../contract-constants'
|
||||
import type { OrchestrationDb } from '../orchestration-db'
|
||||
|
||||
export function commitLegacyLifecycleOperation(
|
||||
this: OrchestrationDb,
|
||||
params: {
|
||||
principalId: string
|
||||
operationKey: string
|
||||
method: string
|
||||
payloadHash: string
|
||||
message: {
|
||||
existingId?: string
|
||||
to: string
|
||||
subject: string
|
||||
body?: string
|
||||
type: MessageType
|
||||
priority?: MessagePriority
|
||||
payload?: string
|
||||
}
|
||||
lifecycle:
|
||||
| { kind: 'message_only' }
|
||||
| { kind: 'heartbeat'; at: string }
|
||||
| {
|
||||
kind: 'worker_report'
|
||||
taskId: string
|
||||
outcome: WorkerReportOutcome
|
||||
result: string
|
||||
}
|
||||
}
|
||||
): {
|
||||
receipt: LegacyOperationReceiptRow
|
||||
message: MessageRow
|
||||
settlement?: WorkerReportSettlement
|
||||
duplicate: boolean
|
||||
} {
|
||||
this.db.exec('BEGIN IMMEDIATE')
|
||||
try {
|
||||
const principal = this.getLegacyCompatibilityPrincipal(params.principalId)
|
||||
if (
|
||||
!principal ||
|
||||
principal.role !== 'worker' ||
|
||||
!['committed', 'settled'].includes(principal.status)
|
||||
) {
|
||||
throw new OrchestrationError(
|
||||
'request_mismatch',
|
||||
`Legacy compatibility principal ${params.principalId} cannot send lifecycle work.`
|
||||
)
|
||||
}
|
||||
const dispatchId = principal.dispatch_id as string
|
||||
const existingReceipt = this.requireMatchingLegacyOperationReceipt(params)
|
||||
if (existingReceipt) {
|
||||
const response = JSON.parse(existingReceipt.response_json) as {
|
||||
messageId: string
|
||||
settlement?: WorkerReportSettlement
|
||||
}
|
||||
const message = this.getMessageById(response.messageId)
|
||||
if (!message) {
|
||||
throw new OrchestrationError(
|
||||
'operation_unknown',
|
||||
`Legacy operation ${params.operationKey} lost its recorded message.`
|
||||
)
|
||||
}
|
||||
this.db.exec('COMMIT')
|
||||
return {
|
||||
receipt: existingReceipt,
|
||||
message,
|
||||
settlement: response.settlement,
|
||||
duplicate: true
|
||||
}
|
||||
}
|
||||
|
||||
const dispatch = this.getDispatchContextById(dispatchId)
|
||||
if (
|
||||
!dispatch ||
|
||||
dispatch.run_id !== principal.run_id ||
|
||||
dispatch.contract_version !== LEGACY_CONTRACT_VERSION
|
||||
) {
|
||||
throw new OrchestrationError(
|
||||
'dispatch_inactive',
|
||||
`Dispatch ${dispatchId} is not this principal's legacy attempt.`
|
||||
)
|
||||
}
|
||||
if (
|
||||
(principal.status === 'settled' || !['pending', 'dispatched'].includes(dispatch.status)) &&
|
||||
(!params.message.existingId || params.lifecycle.kind !== 'worker_report')
|
||||
) {
|
||||
throw new OrchestrationError(
|
||||
'dispatch_inactive',
|
||||
`Dispatch ${dispatchId} is settled and only matching completion reconstruction is allowed.`
|
||||
)
|
||||
}
|
||||
let message = params.message.existingId
|
||||
? this.getMessageById(params.message.existingId)
|
||||
: undefined
|
||||
const delivery = this.resolveLegacyWorkerCoordinatorDelivery(
|
||||
principal.run_id,
|
||||
params.message.to
|
||||
)
|
||||
if (params.message.existingId) {
|
||||
const matchesOriginalLegacyRoute =
|
||||
message?.delivery_contract === 'legacy_direct' && message.to_handle === params.message.to
|
||||
const matchesCurrentRoute =
|
||||
message?.delivery_contract === delivery.contract && message.to_handle === delivery.to
|
||||
if (
|
||||
!message ||
|
||||
message.run_id !== principal.run_id ||
|
||||
message.from_handle !== principal.terminal_handle ||
|
||||
(!matchesOriginalLegacyRoute && !matchesCurrentRoute)
|
||||
) {
|
||||
throw new OrchestrationError(
|
||||
'request_mismatch',
|
||||
`Existing legacy message ${params.message.existingId} does not match this principal.`
|
||||
)
|
||||
}
|
||||
} else {
|
||||
message = this.insertMessage({
|
||||
from: principal.terminal_handle,
|
||||
to: delivery.to,
|
||||
subject: params.message.subject,
|
||||
body: params.message.body,
|
||||
type: params.message.type,
|
||||
priority: params.message.priority,
|
||||
payload: params.message.payload,
|
||||
senderPaneKey: principal.pane_key,
|
||||
runId: principal.run_id,
|
||||
deliveryContract: delivery.contract
|
||||
})
|
||||
}
|
||||
|
||||
let settlement: WorkerReportSettlement | undefined
|
||||
if (params.lifecycle.kind === 'heartbeat') {
|
||||
this.recordHeartbeat(dispatchId, params.lifecycle.at)
|
||||
} else if (params.lifecycle.kind === 'worker_report') {
|
||||
const persistedOutcome =
|
||||
params.message.existingId &&
|
||||
dispatch.task_id === params.lifecycle.taskId &&
|
||||
dispatch.status === 'completed'
|
||||
? 'succeeded'
|
||||
: params.message.existingId &&
|
||||
dispatch.task_id === params.lifecycle.taskId &&
|
||||
dispatch.status === 'failed'
|
||||
? 'failed'
|
||||
: undefined
|
||||
settlement = persistedOutcome
|
||||
? { action: 'settled', outcome: persistedOutcome, duplicate: true }
|
||||
: this.settleWorkerReportInTransaction({
|
||||
taskId: params.lifecycle.taskId,
|
||||
dispatchId,
|
||||
outcome: params.lifecycle.outcome,
|
||||
result: params.lifecycle.result
|
||||
})
|
||||
if (settlement.action === 'rejected') {
|
||||
throw new OrchestrationError(settlement.code, settlement.reason)
|
||||
}
|
||||
this.db
|
||||
.prepare(
|
||||
`UPDATE legacy_compatibility_principals
|
||||
SET status = 'settled' WHERE id = ? AND status = 'committed'`
|
||||
)
|
||||
.run(principal.id)
|
||||
}
|
||||
const responseJson = JSON.stringify({ messageId: message.id, settlement })
|
||||
const receipt = this.insertLegacyOperationReceipt({
|
||||
principalId: principal.id,
|
||||
operationKey: params.operationKey,
|
||||
method: params.method,
|
||||
payloadHash: params.payloadHash,
|
||||
effectId: message.id,
|
||||
responseJson
|
||||
})
|
||||
this.db.exec('COMMIT')
|
||||
return { receipt, message, settlement, duplicate: false }
|
||||
} catch (error) {
|
||||
this.db.exec('ROLLBACK')
|
||||
throw error
|
||||
}
|
||||
}
|
||||
|
||||
export type LegacyLifecycleOperationMethods = {
|
||||
commitLegacyLifecycleOperation: typeof commitLegacyLifecycleOperation
|
||||
}
|
||||
|
||||
export function attachLegacyLifecycleOperation(ctor: { prototype: object }): void {
|
||||
Object.assign(ctor.prototype, {
|
||||
commitLegacyLifecycleOperation
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,113 @@
|
||||
import type { MessageType, MessageRow, LegacyMailReceiptRow } from '../../types'
|
||||
import { OrchestrationError } from '../../orchestration-error'
|
||||
import type { OrchestrationDb } from '../orchestration-db'
|
||||
|
||||
export function acknowledgeLegacyMail(
|
||||
this: OrchestrationDb,
|
||||
params: {
|
||||
principalId: string
|
||||
messageIds: string[]
|
||||
types?: MessageType[]
|
||||
}
|
||||
): {
|
||||
receipts: LegacyMailReceiptRow[]
|
||||
duplicate: boolean
|
||||
} {
|
||||
if (params.messageIds.length === 0) {
|
||||
return { receipts: [], duplicate: true }
|
||||
}
|
||||
this.db.exec('BEGIN IMMEDIATE')
|
||||
try {
|
||||
const principal = this.requireLegacyMailPrincipal(params.principalId)
|
||||
const uniqueIds = [...new Set(params.messageIds)]
|
||||
const placeholders = uniqueIds.map(() => '?').join(',')
|
||||
const prior = this.db
|
||||
.prepare(
|
||||
`SELECT COUNT(*) AS count FROM legacy_mail_receipts
|
||||
WHERE principal_id = ? AND message_id IN (${placeholders})
|
||||
AND acknowledged_at IS NOT NULL`
|
||||
)
|
||||
.get(params.principalId, ...uniqueIds) as { count: number }
|
||||
if (prior.count !== uniqueIds.length) {
|
||||
const actionable = this.getLegacyMailPage({
|
||||
principalId: params.principalId,
|
||||
limit: uniqueIds.length,
|
||||
types: params.types
|
||||
}).messages
|
||||
if (
|
||||
actionable.length !== uniqueIds.length ||
|
||||
actionable.some((message, index) => message.id !== uniqueIds[index])
|
||||
) {
|
||||
throw new OrchestrationError(
|
||||
'request_mismatch',
|
||||
'Legacy mail acknowledgment does not match the current replay page.'
|
||||
)
|
||||
}
|
||||
}
|
||||
const rows = this.db
|
||||
.prepare(
|
||||
`SELECT * FROM messages
|
||||
WHERE id IN (${placeholders}) AND run_id = ?
|
||||
AND delivery_contract = 'legacy_direct'`
|
||||
)
|
||||
.all(...uniqueIds, principal.run_id) as MessageRow[]
|
||||
const validIds = new Set(
|
||||
rows
|
||||
.filter(
|
||||
(message) =>
|
||||
message.to_handle === principal.terminal_handle ||
|
||||
(principal.role === 'worker' &&
|
||||
message.to_handle === `dispatch:${principal.dispatch_id}`)
|
||||
)
|
||||
.map((message) => message.id)
|
||||
)
|
||||
if (validIds.size !== uniqueIds.length || uniqueIds.some((id) => !validIds.has(id))) {
|
||||
throw new OrchestrationError(
|
||||
'request_mismatch',
|
||||
'Legacy mail acknowledgment contains a message outside this principal inbox.'
|
||||
)
|
||||
}
|
||||
|
||||
this.db
|
||||
.prepare(
|
||||
`UPDATE messages
|
||||
SET read = 1, delivered_at = COALESCE(delivered_at, datetime('now'))
|
||||
WHERE id IN (${placeholders})`
|
||||
)
|
||||
.run(...uniqueIds)
|
||||
const insert = this.db.prepare(
|
||||
`INSERT INTO legacy_mail_receipts (
|
||||
principal_id, message_id, acknowledged_at
|
||||
) VALUES (?, ?, datetime('now'))
|
||||
ON CONFLICT(principal_id, message_id)
|
||||
DO UPDATE SET acknowledged_at = COALESCE(
|
||||
legacy_mail_receipts.acknowledged_at, excluded.acknowledged_at
|
||||
)`
|
||||
)
|
||||
for (const messageId of uniqueIds) {
|
||||
insert.run(params.principalId, messageId)
|
||||
}
|
||||
const receipts = this.db
|
||||
.prepare(
|
||||
`SELECT * FROM legacy_mail_receipts
|
||||
WHERE principal_id = ? AND message_id IN (${placeholders})
|
||||
ORDER BY message_id`
|
||||
)
|
||||
.all(params.principalId, ...uniqueIds) as LegacyMailReceiptRow[]
|
||||
this.db.exec('COMMIT')
|
||||
return { receipts, duplicate: prior.count === uniqueIds.length }
|
||||
} catch (error) {
|
||||
this.db.exec('ROLLBACK')
|
||||
throw error
|
||||
}
|
||||
}
|
||||
|
||||
export type LegacyMailAcknowledgeMethods = {
|
||||
acknowledgeLegacyMail: typeof acknowledgeLegacyMail
|
||||
}
|
||||
|
||||
export function attachLegacyMailAcknowledge(ctor: { prototype: object }): void {
|
||||
Object.assign(ctor.prototype, {
|
||||
acknowledgeLegacyMail
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,100 @@
|
||||
import type { LegacyMailReceiptRow } from '../../types'
|
||||
import { OrchestrationError } from '../../orchestration-error'
|
||||
import { LEGACY_CONTRACT_VERSION } from '../contract-constants'
|
||||
import type { OrchestrationDb } from '../orchestration-db'
|
||||
|
||||
export function acknowledgeLegacyQuestionAnswer(
|
||||
this: OrchestrationDb,
|
||||
params: {
|
||||
principalId: string
|
||||
questionId: string
|
||||
answerMessageId: string
|
||||
}
|
||||
): { receipt: LegacyMailReceiptRow; duplicate: boolean } {
|
||||
this.db.exec('BEGIN IMMEDIATE')
|
||||
try {
|
||||
const principal = this.requireLegacyMailPrincipal(params.principalId, 'worker')
|
||||
const question = this.getQuestionRaw(params.questionId)
|
||||
const source = this.getMessageById(params.questionId)
|
||||
const answer = this.getMessageById(params.answerMessageId)
|
||||
const dispatch = principal.dispatch_id
|
||||
? this.getDispatchContextById(principal.dispatch_id)
|
||||
: undefined
|
||||
const exactLegacyAnswer =
|
||||
answer?.delivery_contract === 'legacy_direct' &&
|
||||
(answer.to_handle === principal.terminal_handle ||
|
||||
answer.to_handle === `dispatch:${principal.dispatch_id}`)
|
||||
const adoption = this.getLegacyAdoption()
|
||||
const exactTakenOverAnswer =
|
||||
adoption?.adopted_run_id === principal.run_id &&
|
||||
dispatch?.run_id === principal.run_id &&
|
||||
dispatch.contract_version === LEGACY_CONTRACT_VERSION &&
|
||||
source?.run_id === principal.run_id &&
|
||||
source.from_handle === principal.terminal_handle &&
|
||||
source.to_handle === `run:${principal.run_id}` &&
|
||||
source.delivery_contract === 'current_delivery' &&
|
||||
answer?.run_id === principal.run_id &&
|
||||
answer?.delivery_contract === 'current_delivery' &&
|
||||
answer.from_handle === `run:${principal.run_id}` &&
|
||||
answer.to_handle === `dispatch:${principal.dispatch_id}` &&
|
||||
answer.thread_id === question?.message_id
|
||||
if (
|
||||
!question ||
|
||||
!answer ||
|
||||
question.run_id !== principal.run_id ||
|
||||
question.dispatch_id !== principal.dispatch_id ||
|
||||
question.answer_message_id !== params.answerMessageId ||
|
||||
(!exactLegacyAnswer && !exactTakenOverAnswer)
|
||||
) {
|
||||
throw new OrchestrationError(
|
||||
'request_mismatch',
|
||||
'Legacy answer acknowledgment does not match this principal question.'
|
||||
)
|
||||
}
|
||||
const existing = this.db
|
||||
.prepare(
|
||||
`SELECT * FROM legacy_mail_receipts
|
||||
WHERE principal_id = ? AND message_id = ?`
|
||||
)
|
||||
.get(params.principalId, params.answerMessageId) as LegacyMailReceiptRow | undefined
|
||||
this.db
|
||||
.prepare(
|
||||
`UPDATE messages
|
||||
SET read = 1, delivered_at = COALESCE(delivered_at, datetime('now'))
|
||||
WHERE id = ?`
|
||||
)
|
||||
.run(params.answerMessageId)
|
||||
this.db
|
||||
.prepare(
|
||||
`INSERT INTO legacy_mail_receipts (
|
||||
principal_id, message_id, acknowledged_at
|
||||
) VALUES (?, ?, datetime('now'))
|
||||
ON CONFLICT(principal_id, message_id)
|
||||
DO UPDATE SET acknowledged_at = COALESCE(
|
||||
legacy_mail_receipts.acknowledged_at, excluded.acknowledged_at
|
||||
)`
|
||||
)
|
||||
.run(params.principalId, params.answerMessageId)
|
||||
const receipt = this.db
|
||||
.prepare(
|
||||
`SELECT * FROM legacy_mail_receipts
|
||||
WHERE principal_id = ? AND message_id = ?`
|
||||
)
|
||||
.get(params.principalId, params.answerMessageId) as LegacyMailReceiptRow
|
||||
this.db.exec('COMMIT')
|
||||
return { receipt, duplicate: Boolean(existing?.acknowledged_at) }
|
||||
} catch (error) {
|
||||
this.db.exec('ROLLBACK')
|
||||
throw error
|
||||
}
|
||||
}
|
||||
|
||||
export type LegacyQuestionAcknowledgeMethods = {
|
||||
acknowledgeLegacyQuestionAnswer: typeof acknowledgeLegacyQuestionAnswer
|
||||
}
|
||||
|
||||
export function attachLegacyQuestionAcknowledge(ctor: { prototype: object }): void {
|
||||
Object.assign(ctor.prototype, {
|
||||
acknowledgeLegacyQuestionAnswer
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,126 @@
|
||||
import type { MessageDeliveryContract, MessageRow, QuestionRow } from '../../types'
|
||||
import { OrchestrationError } from '../../orchestration-error'
|
||||
import { exposeQuestionTimestamps } from '../utc-timestamp'
|
||||
import { legacyMessageMatchesQuestion } from '../legacy-question-identity'
|
||||
import type { OrchestrationDb } from '../orchestration-db'
|
||||
|
||||
export function findPendingLegacyQuestions(
|
||||
this: OrchestrationDb,
|
||||
params: {
|
||||
principalId: string
|
||||
question: string
|
||||
options?: string[]
|
||||
recipientHandle: string
|
||||
}
|
||||
): { question: QuestionRow; message: MessageRow }[] {
|
||||
return this.findLegacyQuestionsBySemanticIdentity(params)
|
||||
.filter((row) => row.question.status === 'pending')
|
||||
.map(({ question, message }) => ({ question, message }))
|
||||
}
|
||||
|
||||
export function findLegacyQuestionsBySemanticIdentity(
|
||||
this: OrchestrationDb,
|
||||
params: {
|
||||
principalId: string
|
||||
question: string
|
||||
options?: string[]
|
||||
recipientHandle: string
|
||||
}
|
||||
): {
|
||||
question: QuestionRow
|
||||
message: MessageRow
|
||||
answerAcknowledged: boolean
|
||||
claimedByOperation: boolean
|
||||
}[] {
|
||||
const principal = this.requireCommittedLegacyPrincipal(params.principalId, 'worker')
|
||||
const runAddress = `run:${principal.run_id}`
|
||||
const rows = this.db
|
||||
.prepare(
|
||||
`SELECT q.*, m.id AS source_message_id,
|
||||
EXISTS(
|
||||
SELECT 1 FROM legacy_operation_receipts lor
|
||||
WHERE lor.principal_id = ? AND lor.method = 'orchestration.ask'
|
||||
AND lor.effect_id = q.message_id
|
||||
) AS claimed_by_operation
|
||||
FROM question_threads q
|
||||
INNER JOIN messages m ON m.id = q.message_id
|
||||
WHERE q.run_id = ? AND q.dispatch_id = ?
|
||||
AND (
|
||||
(m.delivery_contract = 'legacy_direct' AND m.to_handle = ?) OR
|
||||
(m.delivery_contract = 'current_delivery' AND m.to_handle = ?)
|
||||
)
|
||||
ORDER BY m.sequence
|
||||
LIMIT 501`
|
||||
)
|
||||
.all(
|
||||
principal.id,
|
||||
principal.run_id,
|
||||
principal.dispatch_id,
|
||||
params.recipientHandle,
|
||||
runAddress
|
||||
) as (QuestionRow & {
|
||||
source_message_id: string
|
||||
claimed_by_operation: number
|
||||
})[]
|
||||
if (rows.length > 500) {
|
||||
throw new OrchestrationError(
|
||||
'operation_unknown',
|
||||
'Legacy ask identity is too ambiguous to reconstruct safely.'
|
||||
)
|
||||
}
|
||||
return rows
|
||||
.filter((row) => {
|
||||
const message = this.getMessageById(row.source_message_id)
|
||||
return Boolean(
|
||||
message &&
|
||||
legacyMessageMatchesQuestion(message, params.question, params.options ?? [], [
|
||||
params.recipientHandle,
|
||||
runAddress
|
||||
])
|
||||
)
|
||||
})
|
||||
.map((row) => ({
|
||||
question: exposeQuestionTimestamps(row),
|
||||
message: this.getMessageById(row.message_id) as MessageRow,
|
||||
claimedByOperation: row.claimed_by_operation === 1,
|
||||
answerAcknowledged: row.answer_message_id
|
||||
? Boolean(
|
||||
this.db
|
||||
.prepare(
|
||||
`SELECT 1 FROM legacy_mail_receipts
|
||||
WHERE principal_id = ? AND message_id = ?
|
||||
AND acknowledged_at IS NOT NULL`
|
||||
)
|
||||
.get(principal.id, row.answer_message_id)
|
||||
)
|
||||
: false
|
||||
}))
|
||||
}
|
||||
|
||||
export function resolveLegacyWorkerCoordinatorDelivery(
|
||||
this: OrchestrationDb,
|
||||
runId: string,
|
||||
retainedCoordinatorHandle: string
|
||||
): { to: string; contract: MessageDeliveryContract } {
|
||||
const run = this.getRunRaw(runId)
|
||||
const principal = this.getLegacyCoordinatorPrincipal(runId)
|
||||
// Why: `!== null` alone reads an unknown run (undefined) as taken over and misroutes it to run: delivery.
|
||||
const takenOver = run?.coordinator_handle != null && principal?.status !== 'committed'
|
||||
return takenOver
|
||||
? { to: `run:${runId}`, contract: 'current_delivery' }
|
||||
: { to: retainedCoordinatorHandle, contract: 'legacy_direct' }
|
||||
}
|
||||
|
||||
export type LegacyQuestionLookupMethods = {
|
||||
findPendingLegacyQuestions: typeof findPendingLegacyQuestions
|
||||
findLegacyQuestionsBySemanticIdentity: typeof findLegacyQuestionsBySemanticIdentity
|
||||
resolveLegacyWorkerCoordinatorDelivery: typeof resolveLegacyWorkerCoordinatorDelivery
|
||||
}
|
||||
|
||||
export function attachLegacyQuestionLookup(ctor: { prototype: object }): void {
|
||||
Object.assign(ctor.prototype, {
|
||||
findPendingLegacyQuestions,
|
||||
findLegacyQuestionsBySemanticIdentity,
|
||||
resolveLegacyWorkerCoordinatorDelivery
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,163 @@
|
||||
import type { MessageType, MessageRow, LegacyCompatibilityPrincipalRow } from '../../types'
|
||||
import { LEGACY_CONTRACT_VERSION } from '../contract-constants'
|
||||
import { ORCHESTRATION_DELIVERY_BATCH_LIMIT } from '../messages/mailbox-routing-page'
|
||||
import { exposeMessageListTimestamps } from '../utc-timestamp'
|
||||
import type { OrchestrationDb } from '../orchestration-db'
|
||||
|
||||
export function initializeLegacyRecoveryCohort(
|
||||
this: OrchestrationDb,
|
||||
principal: LegacyCompatibilityPrincipalRow
|
||||
): void {
|
||||
if (principal.role === 'worker') {
|
||||
this.db
|
||||
.prepare(
|
||||
`INSERT OR IGNORE INTO legacy_mail_receipts (
|
||||
principal_id, message_id, acknowledged_at
|
||||
)
|
||||
SELECT ?, m.id, NULL
|
||||
FROM messages m
|
||||
INNER JOIN dispatch_contexts d ON d.id = ?
|
||||
WHERE m.run_id = ? AND m.delivery_contract = 'legacy_direct' AND m.read = 1
|
||||
AND d.status IN ('pending', 'dispatched')
|
||||
AND m.created_at >= d.created_at
|
||||
AND (m.to_handle = ? OR m.to_handle = ?)`
|
||||
)
|
||||
.run(
|
||||
principal.id,
|
||||
principal.dispatch_id,
|
||||
principal.run_id,
|
||||
principal.terminal_handle,
|
||||
`dispatch:${principal.dispatch_id}`
|
||||
)
|
||||
return
|
||||
}
|
||||
this.db
|
||||
.prepare(
|
||||
`INSERT OR IGNORE INTO legacy_mail_receipts (
|
||||
principal_id, message_id, acknowledged_at
|
||||
)
|
||||
SELECT ?, m.id, NULL
|
||||
FROM messages m
|
||||
WHERE m.run_id = ? AND m.delivery_contract = 'legacy_direct' AND m.read = 1
|
||||
AND m.to_handle = ?
|
||||
AND EXISTS(
|
||||
SELECT 1 FROM dispatch_contexts d
|
||||
WHERE d.run_id = m.run_id
|
||||
AND d.contract_version = ?
|
||||
AND d.status IN ('pending', 'dispatched')
|
||||
AND m.created_at >= d.created_at
|
||||
AND (m.from_handle = d.assignee_handle OR m.from_handle = 'dispatch:' || d.id)
|
||||
)`
|
||||
)
|
||||
.run(principal.id, principal.run_id, principal.terminal_handle, LEGACY_CONTRACT_VERSION)
|
||||
}
|
||||
|
||||
export function getLegacyMailPage(
|
||||
this: OrchestrationDb,
|
||||
params: { principalId: string; limit?: number; types?: MessageType[] }
|
||||
): {
|
||||
messages: MessageRow[]
|
||||
recovery: boolean
|
||||
} {
|
||||
const principal = this.requireLegacyMailPrincipal(params.principalId)
|
||||
const limit = Math.min(
|
||||
Math.max(params.limit ?? ORCHESTRATION_DELIVERY_BATCH_LIMIT, 1),
|
||||
ORCHESTRATION_DELIVERY_BATCH_LIMIT
|
||||
)
|
||||
const addressSql =
|
||||
principal.role === 'worker' ? '(m.to_handle = ? OR m.to_handle = ?)' : 'm.to_handle = ?'
|
||||
const addressParams =
|
||||
principal.role === 'worker'
|
||||
? [principal.terminal_handle, `dispatch:${principal.dispatch_id}`]
|
||||
: [principal.terminal_handle]
|
||||
const typeSql =
|
||||
params.types && params.types.length > 0
|
||||
? `AND m.type IN (${params.types.map(() => '?').join(',')})`
|
||||
: ''
|
||||
const typeParams = params.types ?? []
|
||||
const recovery = this.db
|
||||
.prepare(
|
||||
`SELECT m.*
|
||||
FROM legacy_mail_receipts r
|
||||
INNER JOIN messages m ON m.id = r.message_id
|
||||
WHERE r.principal_id = ? AND r.acknowledged_at IS NULL
|
||||
AND m.run_id = ? AND m.delivery_contract = 'legacy_direct'
|
||||
AND ${addressSql}
|
||||
${typeSql}
|
||||
ORDER BY m.sequence ASC LIMIT ?`
|
||||
)
|
||||
.all(
|
||||
params.principalId,
|
||||
principal.run_id,
|
||||
...addressParams,
|
||||
...typeParams,
|
||||
limit
|
||||
) as MessageRow[]
|
||||
if (recovery.length > 0) {
|
||||
return { messages: exposeMessageListTimestamps(recovery), recovery: true }
|
||||
}
|
||||
|
||||
const unread = this.db
|
||||
.prepare(
|
||||
`SELECT m.*
|
||||
FROM messages m
|
||||
LEFT JOIN legacy_mail_receipts r
|
||||
ON r.principal_id = ? AND r.message_id = m.id
|
||||
WHERE m.run_id = ? AND m.delivery_contract = 'legacy_direct'
|
||||
AND m.read = 0 AND r.message_id IS NULL AND ${addressSql}
|
||||
${typeSql}
|
||||
ORDER BY m.sequence ASC LIMIT ?`
|
||||
)
|
||||
.all(
|
||||
params.principalId,
|
||||
principal.run_id,
|
||||
...addressParams,
|
||||
...typeParams,
|
||||
limit
|
||||
) as MessageRow[]
|
||||
return { messages: exposeMessageListTimestamps(unread), recovery: false }
|
||||
}
|
||||
|
||||
export function getLegacyMailHistory(
|
||||
this: OrchestrationDb,
|
||||
params: { principalId: string; limit?: number; types?: MessageType[] }
|
||||
): {
|
||||
messages: MessageRow[]
|
||||
recovery: false
|
||||
} {
|
||||
const principal = this.requireLegacyMailPrincipal(params.principalId)
|
||||
const limit = Math.min(Math.max(params.limit ?? 100, 1), 100)
|
||||
const addressSql =
|
||||
principal.role === 'worker' ? '(to_handle = ? OR to_handle = ?)' : 'to_handle = ?'
|
||||
const addressParams =
|
||||
principal.role === 'worker'
|
||||
? [principal.terminal_handle, `dispatch:${principal.dispatch_id}`]
|
||||
: [principal.terminal_handle]
|
||||
const typeSql =
|
||||
params.types && params.types.length > 0
|
||||
? `AND type IN (${params.types.map(() => '?').join(',')})`
|
||||
: ''
|
||||
const messages = this.db
|
||||
.prepare(
|
||||
`SELECT * FROM messages
|
||||
WHERE run_id = ? AND delivery_contract = 'legacy_direct'
|
||||
AND ${addressSql} ${typeSql}
|
||||
ORDER BY sequence ASC LIMIT ?`
|
||||
)
|
||||
.all(principal.run_id, ...addressParams, ...(params.types ?? []), limit) as MessageRow[]
|
||||
return { messages: exposeMessageListTimestamps(messages), recovery: false }
|
||||
}
|
||||
|
||||
export type LegacyRecoveryCohortMethods = {
|
||||
initializeLegacyRecoveryCohort: typeof initializeLegacyRecoveryCohort
|
||||
getLegacyMailPage: typeof getLegacyMailPage
|
||||
getLegacyMailHistory: typeof getLegacyMailHistory
|
||||
}
|
||||
|
||||
export function attachLegacyRecoveryCohort(ctor: { prototype: object }): void {
|
||||
Object.assign(ctor.prototype, {
|
||||
initializeLegacyRecoveryCohort,
|
||||
getLegacyMailPage,
|
||||
getLegacyMailHistory
|
||||
})
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user