mirror of
https://github.com/getpaseo/paseo.git
synced 2026-11-02 22:52:58 +00:00
Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
138d9d63ab |
+11
-1
@@ -174,7 +174,7 @@ REACT_NATIVE_PACKAGER_HOSTNAME=localhost \
|
|||||||
|
|
||||||
This is the Android counterpart of the iOS local-simulator flow in [development.md](development.md): on iOS the simulator shares the Mac's loopback so `localhost:<port>` works directly; on Android you need `10.0.2.2` or `adb reverse`.
|
This is the Android counterpart of the iOS local-simulator flow in [development.md](development.md): on iOS the simulator shares the Mac's loopback so `localhost:<port>` works directly; on Android you need `10.0.2.2` or `adb reverse`.
|
||||||
|
|
||||||
## Inverted timeline selection
|
## Inverted timeline scrolling
|
||||||
|
|
||||||
Android focus and selection visibility requests must not reposition inverted timelines. The
|
Android focus and selection visibility requests must not reposition inverted timelines. The
|
||||||
`modules/paseo-scroll` package keeps React Native's scroll manager interface and returns zero for
|
`modules/paseo-scroll` package keeps React Native's scroll manager interface and returns zero for
|
||||||
@@ -183,6 +183,16 @@ Native's Android list uses `scale: -1`, which decomposes into a 180-degree rotat
|
|||||||
`scaleY`; checking the scale property alone misses it. Dragging and explicit scroll
|
`scaleY`; checking the scale property alone misses it. Dragging and explicit scroll
|
||||||
commands still work; non-inverted scroll views keep Android's default behavior.
|
commands still work; non-inverted scroll views keep Android's default behavior.
|
||||||
|
|
||||||
|
Nested panels also keep their edge overshoot and unconsumed flings inside the panel instead of
|
||||||
|
moving the inverted timeline in the opposite direction. Nested-scroll registration and scrolling
|
||||||
|
inside the panel are preserved. Run the native selection and nested-scroll regressions after prebuild:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd packages/app/android
|
||||||
|
./gradlew :paseo-scroll:connectedDebugAndroidTest \
|
||||||
|
--no-daemon --max-workers=2 -Dorg.gradle.parallel=false
|
||||||
|
```
|
||||||
|
|
||||||
Register this package before React Native's core package through its Expo config plugin. Normal
|
Register this package before React Native's core package through its Expo config plugin. Normal
|
||||||
Android builds use the prebuilt `react-android` library, so patching Java under `node_modules` does
|
Android builds use the prebuilt `react-android` library, so patching Java under `node_modules` does
|
||||||
not change the shipped scroll view. Keep this behavior in the app's compiled native module.
|
not change the shipped scroll view. Keep this behavior in the app's compiled native module.
|
||||||
|
|||||||
+3
-3
@@ -374,10 +374,10 @@ changes both `usePaseo()` and `useRpc()` to that host's installation. An offline
|
|||||||
installation. A server handler owns an IPC-backed daemon session for the life of its subprocess.
|
installation. A server handler owns an IPC-backed daemon session for the life of its subprocess.
|
||||||
Use plugin RPC for plugin-specific backend behavior that is not a normal Paseo operation.
|
Use plugin RPC for plugin-specific backend behavior that is not a normal Paseo operation.
|
||||||
|
|
||||||
Host handles and host-targeted clients are owned by `packages/app/src/plugins/hosts`. Its `projectHost` is the secret boundary; host target input uses the shared `parseHostTarget` grammar. Per-installation
|
Host-targeted clients and discovery are owned by `packages/app/src/plugins/hosts`, with per-installation
|
||||||
bindings supplied by the bundle loader. Bind the imperative getter to that installation; do not
|
bindings supplied by the bundle loader. Bind the imperative getter to that installation; do not
|
||||||
resolve ownership through a mutable current-plugin global. Keep observation ownership in this module
|
resolve ownership through a mutable current-plugin global. Keep observation ownership in this module
|
||||||
and transport ownership in the app host runtime. See the [public host API contract](../public-docs/plugins/reference.md#hosts).
|
and transport ownership in the app host runtime. See the [public host API contract](../public-docs/plugins/reference.md#discover-hosts-and-target-another-host).
|
||||||
|
|
||||||
Each subprocess gets an exclusively owned `plugin:<id>` session. That identity is reserved from
|
Each subprocess gets an exclusively owned `plugin:<id>` session. That identity is reserved from
|
||||||
normal clients, never resumes another session, and is cleaned immediately on exit without reconnect
|
normal clients, never resumes another session, and is cleaned immediately on exit without reconnect
|
||||||
@@ -396,7 +396,7 @@ in-memory bundles usable, and a later catalog retries persistence.
|
|||||||
|
|
||||||
When the same plugin contribution exists on multiple hosts, Paseo shows it once in the sidebar and
|
When the same plugin contribution exists on multiple hosts, Paseo shows it once in the sidebar and
|
||||||
adds a host picker to the screen header. The selected host supplies the bundle, RPC transport, and
|
adds a host picker to the screen header. The selected host supplies the bundle, RPC transport, and
|
||||||
query cache. Explicit SDK targets follow the [host API contract](../public-docs/plugins/reference.md#hosts).
|
query cache. Explicit SDK targets follow the [host API contract](../public-docs/plugins/reference.md#discover-hosts-and-target-another-host).
|
||||||
|
|
||||||
Workspace panels, Command Center items, and client slash commands are client contributions. The
|
Workspace panels, Command Center items, and client slash commands are client contributions. The
|
||||||
daemon transports their compiled bundle without interpreting placement or callbacks. Panel props
|
daemon transports their compiled bundle without interpreting placement or callbacks. Panel props
|
||||||
|
|||||||
@@ -3,7 +3,7 @@ import {
|
|||||||
composerLocator,
|
composerLocator,
|
||||||
expectComposerDraft,
|
expectComposerDraft,
|
||||||
expectComposerVisible,
|
expectComposerVisible,
|
||||||
fillComposerDraft,
|
submitMessage,
|
||||||
typeIntoFocusedComposer,
|
typeIntoFocusedComposer,
|
||||||
} from "../support/helpers/composer";
|
} from "../support/helpers/composer";
|
||||||
import { openAgentRoute, seedMockAgentWorkspace } from "../support/helpers/mock-agent";
|
import { openAgentRoute, seedMockAgentWorkspace } from "../support/helpers/mock-agent";
|
||||||
@@ -12,14 +12,8 @@ function userMessages(page: Page) {
|
|||||||
return page.getByTestId("user-message");
|
return page.getByTestId("user-message");
|
||||||
}
|
}
|
||||||
|
|
||||||
async function sendDraftWhenReady(page: Page) {
|
|
||||||
await expect(page.getByRole("button", { name: "Send message", exact: true })).toBeEnabled();
|
|
||||||
await pressInComposer(page, "Enter");
|
|
||||||
}
|
|
||||||
|
|
||||||
async function sendAndWaitForMessage(page: Page, text: string, expectedCount: number) {
|
async function sendAndWaitForMessage(page: Page, text: string, expectedCount: number) {
|
||||||
await fillComposerDraft(page, text);
|
await submitMessage(page, text);
|
||||||
await sendDraftWhenReady(page);
|
|
||||||
await expect(userMessages(page)).toHaveCount(expectedCount, { timeout: 30_000 });
|
await expect(userMessages(page)).toHaveCount(expectedCount, { timeout: 30_000 });
|
||||||
await expectComposerDraft(page, "");
|
await expectComposerDraft(page, "");
|
||||||
}
|
}
|
||||||
@@ -55,7 +49,7 @@ test.describe("Composer recall", () => {
|
|||||||
await pressInComposer(page, "ArrowUp");
|
await pressInComposer(page, "ArrowUp");
|
||||||
await expectComposerDraft(page, "add tests for login");
|
await expectComposerDraft(page, "add tests for login");
|
||||||
|
|
||||||
await sendDraftWhenReady(page);
|
await pressInComposer(page, "Enter");
|
||||||
await expect(userMessages(page)).toHaveCount(3, { timeout: 30_000 });
|
await expect(userMessages(page)).toHaveCount(3, { timeout: 30_000 });
|
||||||
await expect(userMessages(page).last()).toContainText("add tests for login");
|
await expect(userMessages(page).last()).toContainText("add tests for login");
|
||||||
} finally {
|
} finally {
|
||||||
|
|||||||
@@ -0,0 +1,5 @@
|
|||||||
|
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
|
||||||
|
<application>
|
||||||
|
<activity android:name="sh.paseo.scroll.NestedScrollActivity" android:theme="@android:style/Theme.Material.Light.NoActionBar" android:exported="false" />
|
||||||
|
</application>
|
||||||
|
</manifest>
|
||||||
+134
@@ -0,0 +1,134 @@
|
|||||||
|
package sh.paseo.scroll
|
||||||
|
|
||||||
|
import android.app.Activity
|
||||||
|
import android.content.Intent
|
||||||
|
import android.os.SystemClock
|
||||||
|
import android.view.View
|
||||||
|
import android.view.ViewGroup
|
||||||
|
import android.widget.FrameLayout
|
||||||
|
import androidx.test.ext.junit.runners.AndroidJUnit4
|
||||||
|
import androidx.test.platform.app.InstrumentationRegistry
|
||||||
|
import com.facebook.react.bridge.JavaOnlyArray
|
||||||
|
import com.facebook.react.bridge.JavaOnlyMap
|
||||||
|
import com.facebook.react.soloader.OpenSourceMergedSoMapping
|
||||||
|
import com.facebook.react.uimanager.DisplayMetricsHolder
|
||||||
|
import com.facebook.react.uimanager.ReactStylesDiffMap
|
||||||
|
import com.facebook.react.uimanager.ThemedReactContext
|
||||||
|
import com.facebook.react.views.scroll.ReactScrollView
|
||||||
|
import com.facebook.react.views.scroll.ReactScrollViewManager
|
||||||
|
import com.facebook.soloader.SoLoader
|
||||||
|
import org.junit.Assert.*
|
||||||
|
import org.junit.Test
|
||||||
|
import org.junit.runner.RunWith
|
||||||
|
|
||||||
|
class NestedScrollActivity : Activity()
|
||||||
|
|
||||||
|
@RunWith(AndroidJUnit4::class)
|
||||||
|
class NestedScrollTest {
|
||||||
|
@Test fun invertedTimelineDoesNotConsumePanelOvershoot() = withNestedScroll(inverted = true) { parent, child ->
|
||||||
|
val before = parent.scrollY
|
||||||
|
assertTrue(child.startNestedScroll(View.SCROLL_AXIS_VERTICAL))
|
||||||
|
child.scrollTo(0, 600)
|
||||||
|
assertEquals(600, child.scrollY)
|
||||||
|
assertTrue(child.dispatchNestedScroll(0, 0, 0, 100, null))
|
||||||
|
assertEquals("Panel overshoot must not move the inverted timeline", before, parent.scrollY)
|
||||||
|
child.scrollTo(0, 0)
|
||||||
|
assertTrue(child.dispatchNestedScroll(0, 0, 0, -100, null))
|
||||||
|
assertEquals("Overshoot at either edge must stay inside the panel", before, parent.scrollY)
|
||||||
|
child.stopNestedScroll()
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test fun ordinaryParentStillConsumesPanelOvershoot() = withNestedScroll(inverted = false) { parent, child ->
|
||||||
|
val before = parent.scrollY
|
||||||
|
assertTrue(child.startNestedScroll(View.SCROLL_AXIS_VERTICAL))
|
||||||
|
child.scrollTo(0, 600)
|
||||||
|
assertEquals(600, child.scrollY)
|
||||||
|
assertTrue(child.dispatchNestedScroll(0, 0, 0, 100, null))
|
||||||
|
assertEquals(before + 100, parent.scrollY)
|
||||||
|
child.stopNestedScroll()
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test fun panelCanStillScrollInsideInvertedTimeline() = withNestedScroll(inverted = true) { parent, child ->
|
||||||
|
val before = parent.scrollY
|
||||||
|
assertTrue(child.startNestedScroll(View.SCROLL_AXIS_VERTICAL))
|
||||||
|
child.scrollTo(0, 100)
|
||||||
|
assertTrue(child.dispatchNestedScroll(0, 100, 0, 0, null))
|
||||||
|
assertEquals(100, child.scrollY)
|
||||||
|
assertEquals(before, parent.scrollY)
|
||||||
|
child.stopNestedScroll()
|
||||||
|
parent.scrollTo(0, before + 100)
|
||||||
|
assertEquals(before + 100, parent.scrollY)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test fun invertedTimelineAbsorbsUnconsumedPanelFling() = checkFling(inverted = true)
|
||||||
|
|
||||||
|
@Test fun ordinaryParentStillAcceptsUnconsumedPanelFling() = checkFling(inverted = false)
|
||||||
|
|
||||||
|
private fun checkFling(inverted: Boolean) = withNestedScroll(
|
||||||
|
inverted = inverted,
|
||||||
|
afterFrames = { parent ->
|
||||||
|
if (inverted) assertEquals("Panel fling must not move the inverted timeline", 400, parent.scrollY)
|
||||||
|
else assertTrue("Control fling must move an ordinary parent", parent.scrollY > 400)
|
||||||
|
}
|
||||||
|
) { parent, child ->
|
||||||
|
parent.scrollTo(0, 400)
|
||||||
|
assertTrue(child.startNestedScroll(View.SCROLL_AXIS_VERTICAL))
|
||||||
|
child.scrollTo(0, 600)
|
||||||
|
assertEquals(600, child.scrollY)
|
||||||
|
assertTrue(child.dispatchNestedFling(0f, 1500f, false))
|
||||||
|
child.stopNestedScroll()
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun withNestedScroll(
|
||||||
|
inverted: Boolean,
|
||||||
|
afterFrames: ((ReactScrollView) -> Unit)? = null,
|
||||||
|
check: (ReactScrollView, ReactScrollView) -> Unit
|
||||||
|
) {
|
||||||
|
val instrumentation = InstrumentationRegistry.getInstrumentation()
|
||||||
|
val activity = instrumentation.startActivitySync(
|
||||||
|
Intent(instrumentation.context, NestedScrollActivity::class.java).addFlags(Intent.FLAG_ACTIVITY_NEW_TASK)
|
||||||
|
)
|
||||||
|
lateinit var parent: ReactScrollView
|
||||||
|
try {
|
||||||
|
instrumentation.runOnMainSync {
|
||||||
|
val appContext = instrumentation.targetContext
|
||||||
|
DisplayMetricsHolder.initDisplayMetricsIfNotInitialized(appContext)
|
||||||
|
SoLoader.init(appContext, OpenSourceMergedSoMapping)
|
||||||
|
val reactContext = ScrollTestContext(appContext)
|
||||||
|
val context = ThemedReactContext(reactContext, appContext)
|
||||||
|
val manager = PaseoScrollPackage().createViewManager(reactContext, ReactScrollViewManager.REACT_CLASS) as ReactScrollViewManager
|
||||||
|
parent = manager.createViewInstance(context)
|
||||||
|
val child = manager.createViewInstance(context).apply { isNestedScrollingEnabled = true }
|
||||||
|
val panelContent = View(context)
|
||||||
|
child.addView(panelContent, FrameLayout.LayoutParams(300, 800))
|
||||||
|
val content = FrameLayout(context)
|
||||||
|
content.addView(child, FrameLayout.LayoutParams(300, 200).apply { topMargin = 500 })
|
||||||
|
parent.addView(content, FrameLayout.LayoutParams(300, 2000))
|
||||||
|
panelContent.measure(exact(300), exact(800))
|
||||||
|
panelContent.layout(0, 0, 300, 800)
|
||||||
|
child.measure(exact(300), exact(200))
|
||||||
|
child.layout(0, 500, 300, 700)
|
||||||
|
content.measure(exact(300), exact(2000))
|
||||||
|
content.layout(0, 0, 300, 2000)
|
||||||
|
parent.measure(exact(300), exact(400))
|
||||||
|
parent.layout(0, 0, 300, 400)
|
||||||
|
// The same transform React Native applies to an inverted FlatList on Android.
|
||||||
|
manager.updateProperties(parent, ReactStylesDiffMap(JavaOnlyMap.of(
|
||||||
|
"transform", JavaOnlyArray.of(JavaOnlyMap.of("scale", if (inverted) -1.0 else 1.0))
|
||||||
|
)))
|
||||||
|
activity.setContentView(parent, ViewGroup.LayoutParams(300, 400))
|
||||||
|
parent.scrollTo(0, 400)
|
||||||
|
check(parent, child)
|
||||||
|
}
|
||||||
|
if (afterFrames != null) {
|
||||||
|
// Let the framework's real fling animation advance, with an ordinary-parent control.
|
||||||
|
SystemClock.sleep(500)
|
||||||
|
instrumentation.runOnMainSync { afterFrames(parent) }
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
instrumentation.runOnMainSync { activity.finish() }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun exact(size: Int) = View.MeasureSpec.makeMeasureSpec(size, View.MeasureSpec.EXACTLY)
|
||||||
|
}
|
||||||
+1
-2
@@ -8,7 +8,6 @@ import android.widget.FrameLayout
|
|||||||
import android.widget.TextView
|
import android.widget.TextView
|
||||||
import androidx.test.ext.junit.runners.AndroidJUnit4
|
import androidx.test.ext.junit.runners.AndroidJUnit4
|
||||||
import androidx.test.platform.app.InstrumentationRegistry
|
import androidx.test.platform.app.InstrumentationRegistry
|
||||||
import com.facebook.react.bridge.BridgeReactContext
|
|
||||||
import com.facebook.react.bridge.JavaOnlyArray
|
import com.facebook.react.bridge.JavaOnlyArray
|
||||||
import com.facebook.react.bridge.JavaOnlyMap
|
import com.facebook.react.bridge.JavaOnlyMap
|
||||||
import com.facebook.react.soloader.OpenSourceMergedSoMapping
|
import com.facebook.react.soloader.OpenSourceMergedSoMapping
|
||||||
@@ -70,7 +69,7 @@ class ScrollSelectionTest {
|
|||||||
val appContext = instrumentation.targetContext
|
val appContext = instrumentation.targetContext
|
||||||
DisplayMetricsHolder.initDisplayMetricsIfNotInitialized(appContext)
|
DisplayMetricsHolder.initDisplayMetricsIfNotInitialized(appContext)
|
||||||
SoLoader.init(appContext, OpenSourceMergedSoMapping)
|
SoLoader.init(appContext, OpenSourceMergedSoMapping)
|
||||||
val reactContext = BridgeReactContext(appContext)
|
val reactContext = ScrollTestContext(appContext)
|
||||||
val context = ThemedReactContext(reactContext, appContext)
|
val context = ThemedReactContext(reactContext, appContext)
|
||||||
val manager = PaseoScrollPackage().createViewManager(reactContext, ReactScrollViewManager.REACT_CLASS) as ReactScrollViewManager
|
val manager = PaseoScrollPackage().createViewManager(reactContext, ReactScrollViewManager.REACT_CLASS) as ReactScrollViewManager
|
||||||
val scroll = manager.createViewInstance(context)
|
val scroll = manager.createViewInstance(context)
|
||||||
|
|||||||
+19
@@ -0,0 +1,19 @@
|
|||||||
|
package sh.paseo.scroll;
|
||||||
|
|
||||||
|
import android.content.Context;
|
||||||
|
import com.facebook.react.bridge.BridgeReactContext;
|
||||||
|
import com.facebook.react.uimanager.events.BlackHoleEventDispatcher;
|
||||||
|
import com.facebook.react.uimanager.events.EventDispatcher;
|
||||||
|
import com.facebook.react.uimanager.events.EventDispatcherProvider;
|
||||||
|
|
||||||
|
/** Native-only tests use RN's dispatcher for a context without a JS instance. */
|
||||||
|
final class ScrollTestContext extends BridgeReactContext implements EventDispatcherProvider {
|
||||||
|
ScrollTestContext(Context context) {
|
||||||
|
super(context);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public EventDispatcher getEventDispatcher() {
|
||||||
|
return BlackHoleEventDispatcher.INSTANCE;
|
||||||
|
}
|
||||||
|
}
|
||||||
+21
-3
@@ -2,6 +2,7 @@ package sh.paseo.scroll
|
|||||||
|
|
||||||
import android.graphics.Matrix
|
import android.graphics.Matrix
|
||||||
import android.graphics.Rect
|
import android.graphics.Rect
|
||||||
|
import android.view.View
|
||||||
import com.facebook.react.ReactPackage
|
import com.facebook.react.ReactPackage
|
||||||
import com.facebook.react.ViewManagerOnDemandReactPackage
|
import com.facebook.react.ViewManagerOnDemandReactPackage
|
||||||
import com.facebook.react.bridge.NativeModule
|
import com.facebook.react.bridge.NativeModule
|
||||||
@@ -30,13 +31,30 @@ private class PaseoScrollViewManager : ReactScrollViewManager() {
|
|||||||
private class PaseoScrollView(context: ThemedReactContext) : ReactScrollView(context) {
|
private class PaseoScrollView(context: ThemedReactContext) : ReactScrollView(context) {
|
||||||
private val transformValues = FloatArray(9)
|
private val transformValues = FloatArray(9)
|
||||||
|
|
||||||
|
private val isInverted: Boolean
|
||||||
|
get() {
|
||||||
|
// RN's Android scale: -1 becomes a 180-degree rotation with positive scaleY.
|
||||||
|
matrix.getValues(transformValues)
|
||||||
|
return transformValues[Matrix.MSCALE_Y] < 0
|
||||||
|
}
|
||||||
|
|
||||||
// Both RN focus handling and Android selection visibility requests use this calculation.
|
// Both RN focus handling and Android selection visibility requests use this calculation.
|
||||||
// Its coordinates ignore inversion. Keep focus/selection bookkeeping, but leave timeline
|
// Its coordinates ignore inversion. Keep focus/selection bookkeeping, but leave timeline
|
||||||
// movement to user gestures and explicit scroll commands.
|
// movement to user gestures and explicit scroll commands.
|
||||||
override fun computeScrollDeltaToGetChildRectOnScreen(rect: Rect): Int {
|
override fun computeScrollDeltaToGetChildRectOnScreen(rect: Rect): Int {
|
||||||
// RN's Android scale: -1 becomes a 180-degree rotation with positive scaleY.
|
if (isInverted) return 0
|
||||||
matrix.getValues(transformValues)
|
|
||||||
if (transformValues[Matrix.MSCALE_Y] < 0) return 0
|
|
||||||
return super.computeScrollDeltaToGetChildRectOnScreen(rect)
|
return super.computeScrollDeltaToGetChildRectOnScreen(rect)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// A nested panel's remaining delta uses its own visual direction. Consuming it in
|
||||||
|
// an inverted timeline moves the timeline the opposite way when the panel hits an edge.
|
||||||
|
override fun onNestedScroll(target: View, dxConsumed: Int, dyConsumed: Int, dxUnconsumed: Int, dyUnconsumed: Int) {
|
||||||
|
if (isInverted) return
|
||||||
|
super.onNestedScroll(target, dxConsumed, dyConsumed, dxUnconsumed, dyUnconsumed)
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onNestedFling(target: View, velocityX: Float, velocityY: Float, consumed: Boolean): Boolean {
|
||||||
|
if (isInverted && !consumed) return true
|
||||||
|
return super.onNestedFling(target, velocityX, velocityY, consumed)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,42 +0,0 @@
|
|||||||
import React, { act } from "react";
|
|
||||||
import { createRoot } from "react-dom/client";
|
|
||||||
import { expect, it } from "vitest";
|
|
||||||
import { HostPickerOption } from "./host-picker";
|
|
||||||
|
|
||||||
const actions: string[] = [];
|
|
||||||
function select() {
|
|
||||||
actions.push("select");
|
|
||||||
}
|
|
||||||
function openSettings() {
|
|
||||||
actions.push("settings");
|
|
||||||
}
|
|
||||||
|
|
||||||
it("keeps host selection and settings as separate accessible buttons", () => {
|
|
||||||
const container = document.createElement("div");
|
|
||||||
document.body.appendChild(container);
|
|
||||||
const root = createRoot(container);
|
|
||||||
actions.length = 0;
|
|
||||||
act(() =>
|
|
||||||
root.render(
|
|
||||||
<HostPickerOption
|
|
||||||
serverId="host-a"
|
|
||||||
label="Host A"
|
|
||||||
showActiveConnection={false}
|
|
||||||
active={false}
|
|
||||||
onPress={select}
|
|
||||||
onOpenHostSettings={openSettings}
|
|
||||||
/>,
|
|
||||||
),
|
|
||||||
);
|
|
||||||
const host = container.querySelector('button[aria-label="Host A"]');
|
|
||||||
const settings = container.querySelector('button[aria-label="Open Host A settings"]');
|
|
||||||
expect(host).not.toBeNull();
|
|
||||||
expect(settings).not.toBeNull();
|
|
||||||
expect(host?.contains(settings!)).toBe(false);
|
|
||||||
act(() => (settings as HTMLElement).click());
|
|
||||||
expect(actions).toEqual(["settings"]);
|
|
||||||
act(() => (host as HTMLElement).click());
|
|
||||||
expect(actions).toEqual(["settings", "select"]);
|
|
||||||
act(() => root.unmount());
|
|
||||||
container.remove();
|
|
||||||
});
|
|
||||||
@@ -8,7 +8,6 @@ import { Combobox, ComboboxItem, type ComboboxProps } from "@/components/ui/comb
|
|||||||
import { useLocalDaemonServerId } from "@/hooks/use-is-local-daemon";
|
import { useLocalDaemonServerId } from "@/hooks/use-is-local-daemon";
|
||||||
import { useHostRuntimeSnapshot, type ActiveConnection } from "@/runtime/host-runtime";
|
import { useHostRuntimeSnapshot, type ActiveConnection } from "@/runtime/host-runtime";
|
||||||
import { orderHostsLocalFirst } from "@/types/host-connection";
|
import { orderHostsLocalFirst } from "@/types/host-connection";
|
||||||
import { isWeb } from "@/constants/platform";
|
|
||||||
import {
|
import {
|
||||||
ADD_HOST_OPTION_ID,
|
ADD_HOST_OPTION_ID,
|
||||||
ALL_HOSTS_OPTION_ID,
|
ALL_HOSTS_OPTION_ID,
|
||||||
@@ -90,33 +89,35 @@ export function HostPickerOption({
|
|||||||
);
|
);
|
||||||
const trailingSlot = useMemo(() => {
|
const trailingSlot = useMemo(() => {
|
||||||
if (!onOpenHostSettings) return undefined;
|
if (!onOpenHostSettings) return undefined;
|
||||||
return <View style={styles.settingsSlot} />;
|
return (
|
||||||
}, [onOpenHostSettings]);
|
<Pressable
|
||||||
|
onPress={handleSettingsPress}
|
||||||
|
hitSlop={8}
|
||||||
|
accessibilityRole="button"
|
||||||
|
accessibilityLabel={`Open ${label} settings`}
|
||||||
|
>
|
||||||
|
<Settings size={theme.iconSize.sm} color={theme.colors.foregroundMuted} />
|
||||||
|
</Pressable>
|
||||||
|
);
|
||||||
|
}, [
|
||||||
|
handleSettingsPress,
|
||||||
|
label,
|
||||||
|
onOpenHostSettings,
|
||||||
|
theme.colors.foregroundMuted,
|
||||||
|
theme.iconSize.sm,
|
||||||
|
]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<View style={styles.optionContainer}>
|
<ComboboxItem
|
||||||
<ComboboxItem
|
label={label}
|
||||||
label={label}
|
description={connectionLabel}
|
||||||
description={connectionLabel}
|
leadingSlot={leadingSlot}
|
||||||
leadingSlot={leadingSlot}
|
trailingSlot={trailingSlot}
|
||||||
trailingSlot={trailingSlot}
|
selected={selected}
|
||||||
selected={selected}
|
active={active}
|
||||||
active={active}
|
onPress={onPress}
|
||||||
onPress={onPress}
|
testID={testID}
|
||||||
testID={testID}
|
/>
|
||||||
/>
|
|
||||||
{onOpenHostSettings ? (
|
|
||||||
<Pressable
|
|
||||||
style={styles.settingsAction}
|
|
||||||
onPress={handleSettingsPress}
|
|
||||||
hitSlop={8}
|
|
||||||
accessibilityRole="button"
|
|
||||||
accessibilityLabel={`Open ${label} settings`}
|
|
||||||
>
|
|
||||||
<Settings size={theme.iconSize.sm} color={theme.colors.foregroundMuted} />
|
|
||||||
</Pressable>
|
|
||||||
) : null}
|
|
||||||
</View>
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -318,15 +319,6 @@ export function HostPicker({
|
|||||||
}
|
}
|
||||||
|
|
||||||
const styles = StyleSheet.create((theme) => ({
|
const styles = StyleSheet.create((theme) => ({
|
||||||
optionContainer: { position: "relative" },
|
|
||||||
settingsSlot: { width: theme.iconSize.sm, height: theme.iconSize.sm },
|
|
||||||
settingsAction: {
|
|
||||||
position: "absolute",
|
|
||||||
right: theme.spacing[3] + (isWeb ? 0 : theme.spacing[1]),
|
|
||||||
top: 0,
|
|
||||||
bottom: 0,
|
|
||||||
justifyContent: "center",
|
|
||||||
},
|
|
||||||
statusDotSlot: {
|
statusDotSlot: {
|
||||||
width: theme.iconSize.sm,
|
width: theme.iconSize.sm,
|
||||||
height: theme.iconSize.sm,
|
height: theme.iconSize.sm,
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import { describe, expect, it } from "vitest";
|
import { describe, expect, it } from "vitest";
|
||||||
import { executeDaemonManagementToggle } from "./daemon-management-toggle";
|
import { executeDaemonManagementToggle } from "./daemon-management-toggle";
|
||||||
import { resolveDesktopDaemonServerId, type DesktopDaemonStatus } from "./desktop-daemon";
|
import type { DesktopDaemonStatus } from "./desktop-daemon";
|
||||||
|
|
||||||
const runningManagedStatus: DesktopDaemonStatus = {
|
const runningManagedStatus: DesktopDaemonStatus = {
|
||||||
serverId: "desktop",
|
serverId: "desktop",
|
||||||
@@ -54,29 +54,6 @@ function makeDeps(overrides?: {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
it("retains the desktop identity when the daemon is stopped or status fails", async () => {
|
|
||||||
expect(
|
|
||||||
await resolveDesktopDaemonServerId({
|
|
||||||
getStatus: async () => ({ ...stoppedStatus, serverId: "" }),
|
|
||||||
getSavedId: async () => "desktop",
|
|
||||||
}),
|
|
||||||
).toBe("desktop");
|
|
||||||
expect(
|
|
||||||
await resolveDesktopDaemonServerId({
|
|
||||||
getStatus: async () => {
|
|
||||||
throw new Error("offline");
|
|
||||||
},
|
|
||||||
getSavedId: async () => "desktop",
|
|
||||||
}),
|
|
||||||
).toBe("desktop");
|
|
||||||
expect(
|
|
||||||
await resolveDesktopDaemonServerId({
|
|
||||||
getStatus: async () => runningManagedStatus,
|
|
||||||
getSavedId: async () => "old",
|
|
||||||
}),
|
|
||||||
).toBe("desktop");
|
|
||||||
});
|
|
||||||
|
|
||||||
describe("executeDaemonManagementToggle", () => {
|
describe("executeDaemonManagementToggle", () => {
|
||||||
describe("enable path (currentlyManaging: false)", () => {
|
describe("enable path (currentlyManaging: false)", () => {
|
||||||
it("persists the new setting then starts the daemon", async () => {
|
it("persists the new setting then starts the daemon", async () => {
|
||||||
|
|||||||
@@ -186,28 +186,6 @@ export async function getDesktopDaemonStatus(): Promise<DesktopDaemonStatus> {
|
|||||||
return parseDesktopDaemonStatus(await invokeDesktopCommand("desktop_daemon_status"));
|
return parseDesktopDaemonStatus(await invokeDesktopCommand("desktop_daemon_status"));
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Resolve the identity even when the daemon has stopped. */
|
|
||||||
export async function resolveDesktopDaemonServerId(
|
|
||||||
deps: {
|
|
||||||
getStatus(): Promise<DesktopDaemonStatus>;
|
|
||||||
getSavedId(): Promise<string | null>;
|
|
||||||
} = {
|
|
||||||
getStatus: getDesktopDaemonStatus,
|
|
||||||
getSavedId: () => invokeDesktopCommand<string | null>("desktop_daemon_identity"),
|
|
||||||
},
|
|
||||||
): Promise<string | null> {
|
|
||||||
try {
|
|
||||||
const id = (await deps.getStatus()).serverId.trim();
|
|
||||||
if (id) return id;
|
|
||||||
} catch {
|
|
||||||
/* The persisted identity remains available if the status request fails. */
|
|
||||||
}
|
|
||||||
const id = await deps.getSavedId();
|
|
||||||
if (id === null) return null;
|
|
||||||
if (typeof id !== "string") throw new Error("Invalid desktop daemon identity");
|
|
||||||
return id.trim() || null;
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function startDesktopDaemon(): Promise<DesktopDaemonStatus> {
|
export async function startDesktopDaemon(): Promise<DesktopDaemonStatus> {
|
||||||
return parseDesktopDaemonStatus(await invokeDesktopCommand("start_desktop_daemon"));
|
return parseDesktopDaemonStatus(await invokeDesktopCommand("start_desktop_daemon"));
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -132,19 +132,12 @@ function harness() {
|
|||||||
return {
|
return {
|
||||||
hosts: createPluginHosts(
|
hosts: createPluginHosts(
|
||||||
{
|
{
|
||||||
addHost: async () => ({ status: "cancelled" }),
|
|
||||||
getHosts: () => [],
|
getHosts: () => [],
|
||||||
getSnapshot: () => null,
|
getSnapshot: () => null,
|
||||||
subscribeAll: () => () => {},
|
subscribeAll: () => () => {},
|
||||||
subscribeHostList: () => () => {},
|
subscribeHostList: () => () => {},
|
||||||
renameHost: async () => {},
|
|
||||||
setHostColor: async () => {},
|
|
||||||
setHostBadgeDisplay: async () => {},
|
|
||||||
removeHost: async () => {},
|
|
||||||
removeConnection: async () => {},
|
|
||||||
},
|
},
|
||||||
installation.lifetime.signal,
|
installation.lifetime.signal,
|
||||||
{ openSettings() {}, isManagedLocalHost: async () => false },
|
|
||||||
),
|
),
|
||||||
paseo: installation.paseo,
|
paseo: installation.paseo,
|
||||||
i18n: installation.i18n,
|
i18n: installation.i18n,
|
||||||
|
|||||||
@@ -1,9 +1,5 @@
|
|||||||
import { createPlayAudio, type AudioEngine } from "@/audio";
|
import { createPlayAudio, type AudioEngine } from "@/audio";
|
||||||
import { getHostRuntimeStore } from "@/runtime/host-runtime";
|
import { getHostRuntimeStore } from "@/runtime/host-runtime";
|
||||||
import {
|
|
||||||
resolveDesktopDaemonServerId,
|
|
||||||
shouldUseDesktopDaemon,
|
|
||||||
} from "@/desktop/daemon/desktop-daemon";
|
|
||||||
import { createPluginHosts } from "./hosts";
|
import { createPluginHosts } from "./hosts";
|
||||||
import type { PluginClientOpenPanelOptions } from "@getpaseo/plugin/client";
|
import type { PluginClientOpenPanelOptions } from "@getpaseo/plugin/client";
|
||||||
import {
|
import {
|
||||||
@@ -30,14 +26,7 @@ export function createPluginClientRuntime(
|
|||||||
...capabilities,
|
...capabilities,
|
||||||
i18n: installation.i18n,
|
i18n: installation.i18n,
|
||||||
playAudio: createPlayAudio(audio, installation.lifetime.signal),
|
playAudio: createPlayAudio(audio, installation.lifetime.signal),
|
||||||
hosts: createPluginHosts(getHostRuntimeStore(), installation.lifetime.signal, {
|
hosts: createPluginHosts(getHostRuntimeStore(), installation.lifetime.signal),
|
||||||
openSettings(serverId) {
|
|
||||||
createPluginNavigation({ serverId, workspaceId: null }).openHostSettings(serverId);
|
|
||||||
},
|
|
||||||
async isManagedLocalHost(serverId) {
|
|
||||||
return shouldUseDesktopDaemon() && (await resolveDesktopDaemonServerId()) === serverId;
|
|
||||||
},
|
|
||||||
}),
|
|
||||||
addComposerPill(contribution) {
|
addComposerPill(contribution) {
|
||||||
return pluginButtonStore.addComposerPill(installation, contribution);
|
return pluginButtonStore.addComposerPill(installation, contribution);
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -819,14 +819,6 @@ it("binds imported getters to each originating installation across delayed callb
|
|||||||
hosts: {
|
hosts: {
|
||||||
getSnapshot: () => [],
|
getSnapshot: () => [],
|
||||||
subscribe: () => () => {},
|
subscribe: () => () => {},
|
||||||
observeHosts: () => {
|
|
||||||
calls.push(`${installation}/observe`);
|
|
||||||
return () => {};
|
|
||||||
},
|
|
||||||
addHost: async (input) => {
|
|
||||||
calls.push(`${installation}/add:${input.connection}`);
|
|
||||||
return { status: "cancelled" };
|
|
||||||
},
|
|
||||||
getPaseoClient(serverId) {
|
getPaseoClient(serverId) {
|
||||||
calls.push(`${installation}/${serverId}`);
|
calls.push(`${installation}/${serverId}`);
|
||||||
return runtime.paseo;
|
return runtime.paseo;
|
||||||
@@ -834,12 +826,11 @@ it("binds imported getters to each originating installation across delayed callb
|
|||||||
},
|
},
|
||||||
});
|
});
|
||||||
const source = bundle(`
|
const source = bundle(`
|
||||||
const { getPaseoClient, observeHosts, addHost } = require("@getpaseo/plugin/client");
|
const { getPaseoClient } = require("@getpaseo/plugin/client");
|
||||||
getPaseoClient("entry-host");
|
getPaseoClient("entry-host");
|
||||||
observeHosts(() => {});
|
|
||||||
plugin.addCommandCenterItem({
|
plugin.addCommandCenterItem({
|
||||||
id: "read", title: "Read", icon: "Server", context: "global",
|
id: "read", title: "Read", icon: "Server", context: "global",
|
||||||
onSelect: async () => { await Promise.resolve(); getPaseoClient("target-host"); await addHost({ connection: "example:6767" }); },
|
onSelect: async () => { await Promise.resolve(); getPaseoClient("target-host"); },
|
||||||
});
|
});
|
||||||
`);
|
`);
|
||||||
const first = runPluginClientBundle("same-id", source, hostRuntime("first"));
|
const first = runPluginClientBundle("same-id", source, hostRuntime("first"));
|
||||||
@@ -849,13 +840,9 @@ it("binds imported getters to each originating installation across delayed callb
|
|||||||
await second.commandCenterItems[0].onSelect({} as never);
|
await second.commandCenterItems[0].onSelect({} as never);
|
||||||
expect(calls).toEqual([
|
expect(calls).toEqual([
|
||||||
"first/entry-host",
|
"first/entry-host",
|
||||||
"first/observe",
|
|
||||||
"second/entry-host",
|
"second/entry-host",
|
||||||
"second/observe",
|
|
||||||
"first/target-host",
|
"first/target-host",
|
||||||
"first/add:example:6767",
|
|
||||||
"second/target-host",
|
"second/target-host",
|
||||||
"second/add:example:6767",
|
|
||||||
]);
|
]);
|
||||||
await first.cleanup();
|
await first.cleanup();
|
||||||
await second.cleanup();
|
await second.cleanup();
|
||||||
|
|||||||
@@ -520,10 +520,6 @@ export function runPluginClientBundle(
|
|||||||
useSettings,
|
useSettings,
|
||||||
openExternalUrl,
|
openExternalUrl,
|
||||||
getPaseoClient: (serverId: string) => runtime.hosts.getPaseoClient(serverId),
|
getPaseoClient: (serverId: string) => runtime.hosts.getPaseoClient(serverId),
|
||||||
addHost: (input: Parameters<typeof runtime.hosts.addHost>[0]) =>
|
|
||||||
runtime.hosts.addHost(input),
|
|
||||||
observeHosts: (listener: Parameters<typeof runtime.hosts.observeHosts>[0]) =>
|
|
||||||
runtime.hosts.observeHosts(listener),
|
|
||||||
useHosts: () =>
|
useHosts: () =>
|
||||||
React.useSyncExternalStore(
|
React.useSyncExternalStore(
|
||||||
runtime.hosts.subscribe,
|
runtime.hosts.subscribe,
|
||||||
|
|||||||
@@ -1,66 +1,17 @@
|
|||||||
import { describe, expect, it } from "vitest";
|
import { describe, expect, it } from "vitest";
|
||||||
import { DaemonClient } from "@getpaseo/client/internal/daemon-client";
|
import { DaemonClient } from "@getpaseo/client/internal/daemon-client";
|
||||||
import { createPluginHosts, type PluginHostsSource } from "./index";
|
import { createPluginHosts, type PluginHostsSource } from "./index";
|
||||||
import { resolveDesktopDaemonServerId } from "@/desktop/daemon/desktop-daemon";
|
|
||||||
|
|
||||||
function registry() {
|
function registry() {
|
||||||
const hosts = [
|
const hosts = [
|
||||||
{
|
{ serverId: "a", label: "Alpha", password: "secret" },
|
||||||
serverId: "a",
|
{ serverId: "b", label: "Beta", password: "secret" },
|
||||||
label: "Alpha",
|
|
||||||
password: "secret",
|
|
||||||
appearance: { color: "none" as const, badgeDisplay: null },
|
|
||||||
connections: [
|
|
||||||
{ id: "one", type: "directTcp" as const, endpoint: "localhost:6767", useTls: false },
|
|
||||||
{ id: "two", type: "directSocket" as const, path: "/tmp/test" },
|
|
||||||
],
|
|
||||||
lifecycle: {},
|
|
||||||
preferredConnectionId: null,
|
|
||||||
createdAt: "today",
|
|
||||||
updatedAt: "today",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
serverId: "b",
|
|
||||||
label: "Beta",
|
|
||||||
password: "secret",
|
|
||||||
appearance: { color: "none" as const, badgeDisplay: null },
|
|
||||||
connections: [],
|
|
||||||
lifecycle: {},
|
|
||||||
preferredConnectionId: null,
|
|
||||||
createdAt: "today",
|
|
||||||
updatedAt: "today",
|
|
||||||
},
|
|
||||||
];
|
];
|
||||||
const snapshots = new Map<
|
const snapshots = new Map<string, NonNullable<ReturnType<PluginHostsSource["getSnapshot"]>>>();
|
||||||
string,
|
|
||||||
{ connectionStatus: "online" | "offline" | "connecting"; client: DaemonClient | null }
|
|
||||||
>();
|
|
||||||
const listeners = new Set<() => void>();
|
const listeners = new Set<() => void>();
|
||||||
const source: PluginHostsSource = {
|
const source: PluginHostsSource = {
|
||||||
addHost: async () => ({ status: "cancelled" }),
|
|
||||||
getHosts: () => hosts,
|
getHosts: () => hosts,
|
||||||
getSnapshot: (id) => {
|
getSnapshot: (id) => snapshots.get(id) ?? null,
|
||||||
const value = snapshots.get(id);
|
|
||||||
return value
|
|
||||||
? { ...value, activeConnectionId: null, lastError: null, lastOnlineAt: null }
|
|
||||||
: null;
|
|
||||||
},
|
|
||||||
renameHost: async (id, label) => {
|
|
||||||
hosts.find((candidate) => candidate.serverId === id)!.label = label;
|
|
||||||
},
|
|
||||||
setHostColor: async () => {},
|
|
||||||
setHostBadgeDisplay: async () => {},
|
|
||||||
removeHost: async (id) => {
|
|
||||||
const index = hosts.findIndex((host) => host.serverId === id);
|
|
||||||
if (index >= 0) hosts.splice(index, 1);
|
|
||||||
for (const l of listeners) l();
|
|
||||||
},
|
|
||||||
removeConnection: async (id, connectionId) => {
|
|
||||||
const host = hosts.find((candidate) => candidate.serverId === id)!;
|
|
||||||
host.connections = host.connections.filter((c) => c.id !== connectionId);
|
|
||||||
if (!host.connections.length) await source.removeHost(id);
|
|
||||||
else for (const l of listeners) l();
|
|
||||||
},
|
|
||||||
subscribeAll(listener) {
|
subscribeAll(listener) {
|
||||||
listeners.add(listener);
|
listeners.add(listener);
|
||||||
return () => {
|
return () => {
|
||||||
@@ -75,10 +26,7 @@ function registry() {
|
|||||||
},
|
},
|
||||||
};
|
};
|
||||||
const lifetime = new AbortController();
|
const lifetime = new AbortController();
|
||||||
const runtime = createPluginHosts(source, lifetime.signal, {
|
const runtime = createPluginHosts(source, lifetime.signal);
|
||||||
openSettings() {},
|
|
||||||
isManagedLocalHost: async () => false,
|
|
||||||
});
|
|
||||||
return {
|
return {
|
||||||
hosts,
|
hosts,
|
||||||
snapshots,
|
snapshots,
|
||||||
@@ -100,7 +48,7 @@ function connection(id: string) {
|
|||||||
describe("plugin host access", () => {
|
describe("plugin host access", () => {
|
||||||
it("publishes credential-free configured hosts and only changes snapshots when summaries change", () => {
|
it("publishes credential-free configured hosts and only changes snapshots when summaries change", () => {
|
||||||
const h = registry();
|
const h = registry();
|
||||||
expect(h.runtime.getSnapshot()).toMatchObject([
|
expect(h.runtime.getSnapshot()).toEqual([
|
||||||
{ serverId: "a", label: "Alpha", status: "offline" },
|
{ serverId: "a", label: "Alpha", status: "offline" },
|
||||||
{ serverId: "b", label: "Beta", status: "offline" },
|
{ serverId: "b", label: "Beta", status: "offline" },
|
||||||
]);
|
]);
|
||||||
@@ -120,171 +68,6 @@ describe("plugin host access", () => {
|
|||||||
expect(h.listeners.size).toBe(0);
|
expect(h.listeners.size).toBe(0);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("keeps handles stable, protects credentials, and stops observations on unload", async () => {
|
|
||||||
const h = registry();
|
|
||||||
const seen: unknown[] = [];
|
|
||||||
const stop = h.runtime.observeHosts((hosts) => seen.push(hosts));
|
|
||||||
const first = h.runtime.getSnapshot()[0];
|
|
||||||
function walk(value: unknown): void {
|
|
||||||
if (typeof value === "string") expect(value).not.toContain("secret");
|
|
||||||
if (!value || typeof value !== "object") return;
|
|
||||||
for (const [key, child] of Object.entries(value)) {
|
|
||||||
expect(key.toLowerCase()).not.toBe("password");
|
|
||||||
walk(child);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
walk(h.runtime.getSnapshot());
|
|
||||||
h.hosts[0].label = "New name";
|
|
||||||
h.publish();
|
|
||||||
expect(h.runtime.getSnapshot()[0]).toBe(first);
|
|
||||||
expect(first.label).toBe("New name");
|
|
||||||
expect(seen).toHaveLength(2);
|
|
||||||
h.lifetime.abort();
|
|
||||||
h.publish();
|
|
||||||
expect(seen).toHaveLength(2);
|
|
||||||
await expect(first.remove()).rejects.toThrow("Plugin has stopped");
|
|
||||||
stop();
|
|
||||||
});
|
|
||||||
|
|
||||||
it("redacts saved passwords from mutation errors", async () => {
|
|
||||||
const h = registry();
|
|
||||||
h.source.renameHost = async () => {
|
|
||||||
throw new Error("Failed for secret");
|
|
||||||
};
|
|
||||||
await expect(h.runtime.getSnapshot()[0].update({ label: "New" })).rejects.not.toThrow("secret");
|
|
||||||
h.lifetime.abort();
|
|
||||||
});
|
|
||||||
|
|
||||||
it("isolates a throwing observer from other observers and registry mutations", async () => {
|
|
||||||
const h = registry();
|
|
||||||
let calls = 0;
|
|
||||||
h.runtime.observeHosts(() => {
|
|
||||||
if (calls++ > 0) throw new Error("plugin listener failed");
|
|
||||||
});
|
|
||||||
let delivered = 0;
|
|
||||||
h.runtime.observeHosts(() => {
|
|
||||||
delivered++;
|
|
||||||
});
|
|
||||||
await expect(h.runtime.getSnapshot()[0].removeConnection("one")).resolves.toBeUndefined();
|
|
||||||
expect(delivered).toBe(2);
|
|
||||||
expect(h.runtime.getSnapshot()[0].connections).toHaveLength(1);
|
|
||||||
h.lifetime.abort();
|
|
||||||
});
|
|
||||||
|
|
||||||
it("refuses to remove the last connection of the desktop daemon", async () => {
|
|
||||||
const h = registry();
|
|
||||||
const lifetime = new AbortController();
|
|
||||||
const runtime = createPluginHosts(h.source, lifetime.signal, {
|
|
||||||
openSettings() {},
|
|
||||||
isManagedLocalHost: async (id) => id === "a",
|
|
||||||
});
|
|
||||||
const host = runtime.getSnapshot()[0];
|
|
||||||
await host.removeConnection("one");
|
|
||||||
await expect(host.removeConnection("two")).rejects.toThrow("desktop daemon");
|
|
||||||
expect(host.connections).toHaveLength(1);
|
|
||||||
lifetime.abort();
|
|
||||||
h.lifetime.abort();
|
|
||||||
});
|
|
||||||
|
|
||||||
it("returns the projected handle after adding a host", async () => {
|
|
||||||
const h = registry();
|
|
||||||
h.source.addHost = async () => ({ status: "saved", serverId: "a" });
|
|
||||||
const result = await h.runtime.addHost({ connection: "tcp://example:6767?password=secret" });
|
|
||||||
expect(result.status).toBe("saved");
|
|
||||||
if (result.status !== "saved") throw new Error("expected saved host");
|
|
||||||
expect(result.host).toBe(h.runtime.getSnapshot()[0]);
|
|
||||||
expect(JSON.stringify(result)).not.toContain("secret");
|
|
||||||
h.lifetime.abort();
|
|
||||||
await expect(h.runtime.addHost({ connection: "example:6767" })).rejects.toThrow(
|
|
||||||
"Plugin has stopped",
|
|
||||||
);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("passes cancellation and authentication results without exposing credentials", async () => {
|
|
||||||
const h = registry();
|
|
||||||
for (const result of [
|
|
||||||
{ status: "cancelled" as const },
|
|
||||||
{ status: "password_required" as const, reason: "incorrect_password" as const },
|
|
||||||
]) {
|
|
||||||
h.source.addHost = async () => result;
|
|
||||||
const returned = await h.runtime.addHost({
|
|
||||||
connection: "tcp://example:6767?password=secret",
|
|
||||||
});
|
|
||||||
expect(returned).toEqual(result);
|
|
||||||
for (const [key, value] of Object.entries(returned)) {
|
|
||||||
expect(key).not.toBe("password");
|
|
||||||
expect(value).not.toBe("secret");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
h.lifetime.abort();
|
|
||||||
});
|
|
||||||
|
|
||||||
it("redacts encoded saved passwords in mutation errors", async () => {
|
|
||||||
const h = registry();
|
|
||||||
h.hosts[0].password = "two words";
|
|
||||||
h.source.renameHost = async () => {
|
|
||||||
throw new Error("failed for two%20words");
|
|
||||||
};
|
|
||||||
await expect(h.runtime.getSnapshot()[0].update({ label: "New" })).rejects.not.toThrow(
|
|
||||||
"two%20words",
|
|
||||||
);
|
|
||||||
h.lifetime.abort();
|
|
||||||
});
|
|
||||||
|
|
||||||
it("never exposes supplied passwords in add errors", async () => {
|
|
||||||
const h = registry();
|
|
||||||
h.source.addHost = async () => {
|
|
||||||
throw new Error("secret was rejected");
|
|
||||||
};
|
|
||||||
await expect(
|
|
||||||
h.runtime.addHost({ connection: "tcp://example:6767?password=secret" }),
|
|
||||||
).rejects.not.toThrow("secret");
|
|
||||||
h.lifetime.abort();
|
|
||||||
});
|
|
||||||
|
|
||||||
it("removes a connection and then the last host connection", async () => {
|
|
||||||
const h = registry();
|
|
||||||
const host = h.runtime.getSnapshot()[0];
|
|
||||||
await host.removeConnection("one");
|
|
||||||
expect(host.connections.map((item) => item.id)).toEqual(["two"]);
|
|
||||||
await host.removeConnection("two");
|
|
||||||
expect(h.runtime.getSnapshot().map((entry) => entry.serverId)).toEqual(["b"]);
|
|
||||||
h.lifetime.abort();
|
|
||||||
});
|
|
||||||
|
|
||||||
it("rejects removal of the managed desktop daemon", async () => {
|
|
||||||
const h = registry();
|
|
||||||
const lifetime = new AbortController();
|
|
||||||
const runtime = createPluginHosts(h.source, lifetime.signal, {
|
|
||||||
openSettings() {},
|
|
||||||
isManagedLocalHost: async (id) => id === "a",
|
|
||||||
});
|
|
||||||
await expect(runtime.getSnapshot()[0].remove()).rejects.toThrow("desktop daemon");
|
|
||||||
expect(runtime.getSnapshot()).toHaveLength(2);
|
|
||||||
lifetime.abort();
|
|
||||||
h.lifetime.abort();
|
|
||||||
});
|
|
||||||
|
|
||||||
it("protects the desktop host after its daemon has stopped", async () => {
|
|
||||||
const h = registry();
|
|
||||||
const lifetime = new AbortController();
|
|
||||||
const runtime = createPluginHosts(h.source, lifetime.signal, {
|
|
||||||
openSettings() {},
|
|
||||||
isManagedLocalHost: async (id) =>
|
|
||||||
id ===
|
|
||||||
(await resolveDesktopDaemonServerId({
|
|
||||||
getStatus: async () => {
|
|
||||||
throw new Error("daemon stopped");
|
|
||||||
},
|
|
||||||
getSavedId: async () => "a",
|
|
||||||
})),
|
|
||||||
});
|
|
||||||
await expect(runtime.getSnapshot()[0].remove()).rejects.toThrow("desktop daemon");
|
|
||||||
expect(runtime.getSnapshot()).toHaveLength(2);
|
|
||||||
lifetime.abort();
|
|
||||||
h.lifetime.abort();
|
|
||||||
});
|
|
||||||
|
|
||||||
it("rejects unknown and disconnected targets without falling through to another host", () => {
|
it("rejects unknown and disconnected targets without falling through to another host", () => {
|
||||||
const h = registry();
|
const h = registry();
|
||||||
h.snapshots.set("a", { connectionStatus: "online", client: connection("a") });
|
h.snapshots.set("a", { connectionStatus: "online", client: connection("a") });
|
||||||
@@ -299,10 +82,7 @@ describe("plugin host access", () => {
|
|||||||
h.snapshots.set("b", { connectionStatus: "online", client });
|
h.snapshots.set("b", { connectionStatus: "online", client });
|
||||||
h.publish();
|
h.publish();
|
||||||
const otherLifetime = new AbortController();
|
const otherLifetime = new AbortController();
|
||||||
const other = createPluginHosts(h.source, otherLifetime.signal, {
|
const other = createPluginHosts(h.source, otherLifetime.signal);
|
||||||
openSettings() {},
|
|
||||||
isManagedLocalHost: async () => false,
|
|
||||||
});
|
|
||||||
const api = h.runtime.getPaseoClient("b");
|
const api = h.runtime.getPaseoClient("b");
|
||||||
expect(api).toBe(h.runtime.getPaseoClient("b"));
|
expect(api).toBe(h.runtime.getPaseoClient("b"));
|
||||||
expect(api).not.toBe(other.getPaseoClient("b"));
|
expect(api).not.toBe(other.getPaseoClient("b"));
|
||||||
|
|||||||
@@ -1,288 +1,31 @@
|
|||||||
import { createPaseoApi, type PaseoApi } from "@getpaseo/client";
|
import { createPaseoApi, type PaseoApi } from "@getpaseo/client";
|
||||||
import type { DaemonClient } from "@getpaseo/client/internal/daemon-client";
|
import type { DaemonClient } from "@getpaseo/client/internal/daemon-client";
|
||||||
import type {
|
import type { PluginHostSummary } from "@getpaseo/plugin/client";
|
||||||
PluginHost,
|
|
||||||
PluginHostConnection,
|
|
||||||
PluginHostPatch,
|
|
||||||
PluginHostAddInput,
|
|
||||||
PluginHostAddResult,
|
|
||||||
} from "@getpaseo/plugin/client";
|
|
||||||
import type { HostProfile } from "@/types/host-connection";
|
|
||||||
import type { HostRuntimeSnapshot } from "@/runtime/host-runtime";
|
|
||||||
import {
|
|
||||||
parseHostPort,
|
|
||||||
serializeConnectionUri,
|
|
||||||
serializeRelayConnectionUri,
|
|
||||||
shouldUseTlsForDefaultHostedRelay,
|
|
||||||
} from "@getpaseo/protocol/daemon-endpoints";
|
|
||||||
import { parseHostTarget, serializeSocketHostTarget } from "@getpaseo/protocol/host-target";
|
|
||||||
import {
|
|
||||||
DEFAULT_SSH_DAEMON_PORT,
|
|
||||||
serializeSshTransportUri,
|
|
||||||
} from "@getpaseo/protocol/ssh-transport";
|
|
||||||
|
|
||||||
type HostSnapshot = Pick<
|
|
||||||
HostRuntimeSnapshot,
|
|
||||||
| "connectionStatus"
|
|
||||||
| "client"
|
|
||||||
| "activeConnectionId"
|
|
||||||
| "lastError"
|
|
||||||
| "authFailureReason"
|
|
||||||
| "lastOnlineAt"
|
|
||||||
>;
|
|
||||||
export interface PluginHostsSource {
|
export interface PluginHostsSource {
|
||||||
addHost(
|
getHosts(): readonly { serverId: string; label: string }[];
|
||||||
input: PluginHostAddInput,
|
getSnapshot(serverId: string): {
|
||||||
signal?: AbortSignal,
|
connectionStatus: PluginHostSummary["status"];
|
||||||
): Promise<
|
client: DaemonClient | null;
|
||||||
| { status: "saved"; serverId: string }
|
} | null;
|
||||||
| { status: "password_required"; reason: "password_required" | "incorrect_password" }
|
|
||||||
| { status: "cancelled" }
|
|
||||||
>;
|
|
||||||
getHosts(): readonly HostProfile[];
|
|
||||||
getSnapshot(serverId: string): HostSnapshot | null;
|
|
||||||
subscribeAll(listener: () => void): () => void;
|
subscribeAll(listener: () => void): () => void;
|
||||||
subscribeHostList(listener: () => void): () => void;
|
subscribeHostList(listener: () => void): () => void;
|
||||||
renameHost(serverId: string, label: string): Promise<void>;
|
|
||||||
setHostColor(serverId: string, color: PluginHost["appearance"]["color"]): Promise<void>;
|
|
||||||
setHostBadgeDisplay(
|
|
||||||
serverId: string,
|
|
||||||
badge: PluginHost["appearance"]["badgeDisplay"],
|
|
||||||
): Promise<void>;
|
|
||||||
removeHost(serverId: string): Promise<void>;
|
|
||||||
removeConnection(serverId: string, connectionId: string): Promise<void>;
|
|
||||||
}
|
|
||||||
function redactSecret(message: string, secret: string | undefined): string {
|
|
||||||
if (!secret) return message;
|
|
||||||
for (const value of [
|
|
||||||
secret,
|
|
||||||
encodeURIComponent(secret),
|
|
||||||
new URLSearchParams({ password: secret }).toString().slice(9),
|
|
||||||
]) {
|
|
||||||
message = message.split(value).join("[redacted]");
|
|
||||||
}
|
|
||||||
return message;
|
|
||||||
}
|
|
||||||
type ProjectedHost = Omit<PluginHost, "update" | "remove" | "removeConnection" | "openSettings">;
|
|
||||||
function projectHost(profile: HostProfile, snapshot: HostSnapshot | null): ProjectedHost {
|
|
||||||
const connections: PluginHostConnection[] = profile.connections.map((connection) => {
|
|
||||||
switch (connection.type) {
|
|
||||||
case "directTcp":
|
|
||||||
return {
|
|
||||||
id: connection.id,
|
|
||||||
type: connection.type,
|
|
||||||
endpoint: connection.endpoint,
|
|
||||||
useTls: connection.useTls ?? false,
|
|
||||||
uri: serializeConnectionUri({
|
|
||||||
...parseHostPort(connection.endpoint),
|
|
||||||
useTls: connection.useTls ?? false,
|
|
||||||
}),
|
|
||||||
};
|
|
||||||
case "directSocket":
|
|
||||||
return {
|
|
||||||
id: connection.id,
|
|
||||||
type: connection.type,
|
|
||||||
path: connection.path,
|
|
||||||
uri: serializeSocketHostTarget("unix", connection.path),
|
|
||||||
};
|
|
||||||
case "directPipe":
|
|
||||||
return {
|
|
||||||
id: connection.id,
|
|
||||||
type: connection.type,
|
|
||||||
path: connection.path,
|
|
||||||
uri: serializeSocketHostTarget("pipe", connection.path),
|
|
||||||
};
|
|
||||||
case "remoteSsh":
|
|
||||||
return {
|
|
||||||
id: connection.id,
|
|
||||||
type: connection.type,
|
|
||||||
host: connection.host,
|
|
||||||
...(connection.sshPort === undefined ? {} : { sshPort: connection.sshPort }),
|
|
||||||
...(connection.daemonPort === undefined ? {} : { daemonPort: connection.daemonPort }),
|
|
||||||
uri: serializeSshTransportUri({
|
|
||||||
host: connection.host,
|
|
||||||
sshPort: connection.sshPort,
|
|
||||||
daemonPort: connection.daemonPort ?? DEFAULT_SSH_DAEMON_PORT,
|
|
||||||
}),
|
|
||||||
};
|
|
||||||
case "relay": {
|
|
||||||
const useTls =
|
|
||||||
connection.useTls ?? shouldUseTlsForDefaultHostedRelay(connection.relayEndpoint);
|
|
||||||
return {
|
|
||||||
id: connection.id,
|
|
||||||
type: connection.type,
|
|
||||||
relayEndpoint: connection.relayEndpoint,
|
|
||||||
useTls,
|
|
||||||
daemonPublicKeyB64: connection.daemonPublicKeyB64,
|
|
||||||
...(connection.minimumSecurityMode
|
|
||||||
? { minimumSecurityMode: connection.minimumSecurityMode }
|
|
||||||
: {}),
|
|
||||||
uri: serializeRelayConnectionUri({
|
|
||||||
offer: {
|
|
||||||
v: 2,
|
|
||||||
serverId: profile.serverId,
|
|
||||||
daemonPublicKeyB64: connection.daemonPublicKeyB64,
|
|
||||||
relay: { endpoint: connection.relayEndpoint, useTls },
|
|
||||||
...(connection.minimumSecurityMode
|
|
||||||
? { minimumSecurityMode: connection.minimumSecurityMode }
|
|
||||||
: {}),
|
|
||||||
},
|
|
||||||
}),
|
|
||||||
};
|
|
||||||
}
|
|
||||||
case "routed":
|
|
||||||
return {
|
|
||||||
id: connection.id,
|
|
||||||
type: connection.type,
|
|
||||||
originServerId: connection.originServerId,
|
|
||||||
uri: null,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
});
|
|
||||||
const error = snapshot?.lastError ?? null;
|
|
||||||
return {
|
|
||||||
serverId: profile.serverId,
|
|
||||||
label: profile.label,
|
|
||||||
status: snapshot?.connectionStatus ?? "offline",
|
|
||||||
appearance: { color: profile.appearance.color, badgeDisplay: profile.appearance.badgeDisplay },
|
|
||||||
connections,
|
|
||||||
activeConnectionId: snapshot?.activeConnectionId ?? null,
|
|
||||||
error: error ? redactSecret(error, profile.password) : null,
|
|
||||||
authFailureReason: snapshot?.authFailureReason ?? null,
|
|
||||||
lastOnlineAt: snapshot?.lastOnlineAt ?? null,
|
|
||||||
createdAt: profile.createdAt,
|
|
||||||
updatedAt: profile.updatedAt,
|
|
||||||
};
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Each evaluated installation owns its borrowed APIs and registry subscriptions. */
|
/** Each evaluated installation owns its borrowed APIs and registry subscriptions. */
|
||||||
export function createPluginHosts(
|
export function createPluginHosts(source: PluginHostsSource, signal: AbortSignal) {
|
||||||
source: PluginHostsSource,
|
|
||||||
signal: AbortSignal,
|
|
||||||
options: {
|
|
||||||
openSettings(serverId: string): void;
|
|
||||||
isManagedLocalHost(serverId: string): Promise<boolean>;
|
|
||||||
},
|
|
||||||
) {
|
|
||||||
const clients = new Map<
|
const clients = new Map<
|
||||||
string,
|
string,
|
||||||
{ client: DaemonClient; api: PaseoApi; lifetime: AbortController }
|
{ client: DaemonClient; api: PaseoApi; lifetime: AbortController }
|
||||||
>();
|
>();
|
||||||
const listeners = new Set<() => void>();
|
const listeners = new Set<() => void>();
|
||||||
let snapshot: readonly PluginHost[] = [];
|
let snapshot: readonly PluginHostSummary[] = [];
|
||||||
let serialized = "";
|
function readHosts(): readonly PluginHostSummary[] {
|
||||||
const entries = new Map<string, { data: ProjectedHost; handle: PluginHost }>();
|
return source.getHosts().map(({ serverId, label }) => ({
|
||||||
function assertHost(id: string) {
|
serverId,
|
||||||
if (signal.aborted) throw new Error("Plugin has stopped");
|
label,
|
||||||
if (!source.getHosts().some((host) => host.serverId === id))
|
status: source.getSnapshot(serverId)?.connectionStatus ?? "offline",
|
||||||
throw new Error(`Unknown Paseo host: ${id}`);
|
}));
|
||||||
}
|
|
||||||
async function perform(id: string, action: () => Promise<void>): Promise<void> {
|
|
||||||
const secret = source.getHosts().find((host) => host.serverId === id)?.password;
|
|
||||||
try {
|
|
||||||
await action();
|
|
||||||
} catch (error) {
|
|
||||||
const message = error instanceof Error ? error.message : String(error);
|
|
||||||
// A cause would expose the original error, including any password.
|
|
||||||
// oxlint-disable-next-line preserve-caught-error
|
|
||||||
throw new Error(redactSecret(message, secret));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
function readHosts(): { hosts: PluginHost[]; key: string } {
|
|
||||||
const projected = source
|
|
||||||
.getHosts()
|
|
||||||
.map((profile) => projectHost(profile, source.getSnapshot(profile.serverId)));
|
|
||||||
const ids = new Set(projected.map((host) => host.serverId));
|
|
||||||
for (const id of entries.keys()) if (!ids.has(id)) entries.delete(id);
|
|
||||||
const hosts = projected.map((data) => {
|
|
||||||
let entry = entries.get(data.serverId);
|
|
||||||
if (!entry) {
|
|
||||||
const id = data.serverId;
|
|
||||||
const read = () => entry!.data;
|
|
||||||
const handle: PluginHost = {
|
|
||||||
get serverId() {
|
|
||||||
return read().serverId;
|
|
||||||
},
|
|
||||||
get label() {
|
|
||||||
return read().label;
|
|
||||||
},
|
|
||||||
get status() {
|
|
||||||
return read().status;
|
|
||||||
},
|
|
||||||
get appearance() {
|
|
||||||
return read().appearance;
|
|
||||||
},
|
|
||||||
get connections() {
|
|
||||||
return read().connections;
|
|
||||||
},
|
|
||||||
get activeConnectionId() {
|
|
||||||
return read().activeConnectionId;
|
|
||||||
},
|
|
||||||
get error() {
|
|
||||||
return read().error;
|
|
||||||
},
|
|
||||||
get authFailureReason() {
|
|
||||||
return read().authFailureReason;
|
|
||||||
},
|
|
||||||
get lastOnlineAt() {
|
|
||||||
return read().lastOnlineAt;
|
|
||||||
},
|
|
||||||
get createdAt() {
|
|
||||||
return read().createdAt;
|
|
||||||
},
|
|
||||||
get updatedAt() {
|
|
||||||
return read().updatedAt;
|
|
||||||
},
|
|
||||||
async update(patch: PluginHostPatch) {
|
|
||||||
await perform(id, async () => {
|
|
||||||
assertHost(id);
|
|
||||||
if (patch.label !== undefined) await source.renameHost(id, patch.label);
|
|
||||||
if (patch.appearance?.color !== undefined) {
|
|
||||||
assertHost(id);
|
|
||||||
await source.setHostColor(id, patch.appearance.color);
|
|
||||||
}
|
|
||||||
if (patch.appearance?.badgeDisplay !== undefined) {
|
|
||||||
assertHost(id);
|
|
||||||
await source.setHostBadgeDisplay(id, patch.appearance.badgeDisplay);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
},
|
|
||||||
async remove() {
|
|
||||||
await perform(id, async () => {
|
|
||||||
assertHost(id);
|
|
||||||
if (await options.isManagedLocalHost(id))
|
|
||||||
throw new Error("Cannot remove the desktop daemon host");
|
|
||||||
assertHost(id);
|
|
||||||
await source.removeHost(id);
|
|
||||||
});
|
|
||||||
},
|
|
||||||
async removeConnection(connectionId) {
|
|
||||||
await perform(id, async () => {
|
|
||||||
assertHost(id);
|
|
||||||
const isManaged = await options.isManagedLocalHost(id);
|
|
||||||
assertHost(id);
|
|
||||||
const host = source.getHosts().find((candidate) => candidate.serverId === id)!;
|
|
||||||
if (
|
|
||||||
isManaged &&
|
|
||||||
host.connections.length === 1 &&
|
|
||||||
host.connections[0].id === connectionId
|
|
||||||
) {
|
|
||||||
throw new Error("Cannot remove the desktop daemon host");
|
|
||||||
}
|
|
||||||
await source.removeConnection(id, connectionId);
|
|
||||||
});
|
|
||||||
},
|
|
||||||
openSettings() {
|
|
||||||
assertHost(id);
|
|
||||||
options.openSettings(id);
|
|
||||||
},
|
|
||||||
};
|
|
||||||
entry = { data, handle };
|
|
||||||
entries.set(id, entry);
|
|
||||||
} else entry.data = data;
|
|
||||||
return entry.handle;
|
|
||||||
});
|
|
||||||
return { hosts, key: JSON.stringify(projected) };
|
|
||||||
}
|
}
|
||||||
function resolve(serverId: string): DaemonClient {
|
function resolve(serverId: string): DaemonClient {
|
||||||
if (signal.aborted) throw new Error("Plugin has stopped");
|
if (signal.aborted) throw new Error("Plugin has stopped");
|
||||||
@@ -306,16 +49,11 @@ export function createPluginHosts(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
const next = readHosts();
|
const next = readHosts();
|
||||||
if (serialized === next.key) return;
|
if (JSON.stringify(snapshot) === JSON.stringify(next)) return;
|
||||||
serialized = next.key;
|
snapshot = next;
|
||||||
snapshot = next.hosts;
|
|
||||||
for (const listener of listeners) listener();
|
for (const listener of listeners) listener();
|
||||||
}
|
}
|
||||||
{
|
snapshot = readHosts();
|
||||||
const initial = readHosts();
|
|
||||||
snapshot = initial.hosts;
|
|
||||||
serialized = initial.key;
|
|
||||||
}
|
|
||||||
const unsubscribeRuntime = source.subscribeAll(refresh);
|
const unsubscribeRuntime = source.subscribeAll(refresh);
|
||||||
const unsubscribeHosts = source.subscribeHostList(refresh);
|
const unsubscribeHosts = source.subscribeHostList(refresh);
|
||||||
function stop() {
|
function stop() {
|
||||||
@@ -329,46 +67,6 @@ export function createPluginHosts(
|
|||||||
else signal.addEventListener("abort", stop, { once: true });
|
else signal.addEventListener("abort", stop, { once: true });
|
||||||
return {
|
return {
|
||||||
getSnapshot: () => snapshot,
|
getSnapshot: () => snapshot,
|
||||||
async addHost(input: PluginHostAddInput): Promise<PluginHostAddResult> {
|
|
||||||
if (signal.aborted) throw new Error("Plugin has stopped");
|
|
||||||
let result: Awaited<ReturnType<PluginHostsSource["addHost"]>>;
|
|
||||||
try {
|
|
||||||
result = await source.addHost(input, signal);
|
|
||||||
} catch (error) {
|
|
||||||
let secret: string | undefined;
|
|
||||||
try {
|
|
||||||
secret = parseHostTarget(input.connection).password;
|
|
||||||
} catch {
|
|
||||||
/* Invalid input has no parsed password. */
|
|
||||||
}
|
|
||||||
const message = redactSecret(
|
|
||||||
error instanceof Error ? error.message : String(error),
|
|
||||||
secret,
|
|
||||||
);
|
|
||||||
// The original error can contain the supplied credential.
|
|
||||||
// oxlint-disable-next-line preserve-caught-error
|
|
||||||
throw new Error(message);
|
|
||||||
}
|
|
||||||
if (result.status !== "saved") return result;
|
|
||||||
const host = snapshot.find((item) => item.serverId === result.serverId);
|
|
||||||
if (!host) throw new Error(`Unknown Paseo host: ${result.serverId}`);
|
|
||||||
return { status: "saved", host };
|
|
||||||
},
|
|
||||||
observeHosts(listener: (hosts: readonly PluginHost[]) => void) {
|
|
||||||
if (signal.aborted) throw new Error("Plugin has stopped");
|
|
||||||
const notify = () => {
|
|
||||||
try {
|
|
||||||
listener(snapshot);
|
|
||||||
} catch (error) {
|
|
||||||
console.error("[Plugins] Host observer failed", error);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
listeners.add(notify);
|
|
||||||
notify();
|
|
||||||
return () => {
|
|
||||||
listeners.delete(notify);
|
|
||||||
};
|
|
||||||
},
|
|
||||||
subscribe(listener: () => void) {
|
subscribe(listener: () => void) {
|
||||||
if (signal.aborted) return () => {};
|
if (signal.aborted) return () => {};
|
||||||
listeners.add(listener);
|
listeners.add(listener);
|
||||||
|
|||||||
@@ -1,4 +1,3 @@
|
|||||||
import { buildSettingsHostRoute } from "@/utils/host-routes";
|
|
||||||
import { buildPluginSettingsRoute } from "./settings/routes";
|
import { buildPluginSettingsRoute } from "./settings/routes";
|
||||||
import { router } from "expo-router";
|
import { router } from "expo-router";
|
||||||
import type { PluginPanelLocation } from "@getpaseo/plugin/client";
|
import type { PluginPanelLocation } from "@getpaseo/plugin/client";
|
||||||
@@ -10,7 +9,7 @@ import type { PluginNavigation } from "./actions";
|
|||||||
export function createPluginNavigation(input: {
|
export function createPluginNavigation(input: {
|
||||||
serverId: string;
|
serverId: string;
|
||||||
workspaceId: string | null;
|
workspaceId: string | null;
|
||||||
}): PluginNavigation & { openHostSettings(serverId: string): void } {
|
}): PluginNavigation {
|
||||||
const { serverId, workspaceId } = input;
|
const { serverId, workspaceId } = input;
|
||||||
function placement(location: PluginPanelLocation) {
|
function placement(location: PluginPanelLocation) {
|
||||||
if (location !== "explorer") return undefined;
|
if (location !== "explorer") return undefined;
|
||||||
@@ -21,9 +20,6 @@ export function createPluginNavigation(input: {
|
|||||||
return { mode: "pane" as const, paneId };
|
return { mode: "pane" as const, paneId };
|
||||||
}
|
}
|
||||||
return {
|
return {
|
||||||
openHostSettings(hostId) {
|
|
||||||
router.push(buildSettingsHostRoute(hostId));
|
|
||||||
},
|
|
||||||
openSettings(pluginId, screenId) {
|
openSettings(pluginId, screenId) {
|
||||||
router.push(buildPluginSettingsRoute(serverId, pluginId, screenId));
|
router.push(buildPluginSettingsRoute(serverId, pluginId, screenId));
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -43,19 +43,12 @@ function registry(
|
|||||||
return {
|
return {
|
||||||
hosts: createPluginHosts(
|
hosts: createPluginHosts(
|
||||||
{
|
{
|
||||||
addHost: async () => ({ status: "cancelled" }),
|
|
||||||
getHosts: () => [],
|
getHosts: () => [],
|
||||||
getSnapshot: () => null,
|
getSnapshot: () => null,
|
||||||
subscribeAll: () => () => {},
|
subscribeAll: () => () => {},
|
||||||
subscribeHostList: () => () => {},
|
subscribeHostList: () => () => {},
|
||||||
renameHost: async () => {},
|
|
||||||
setHostColor: async () => {},
|
|
||||||
setHostBadgeDisplay: async () => {},
|
|
||||||
removeHost: async () => {},
|
|
||||||
removeConnection: async () => {},
|
|
||||||
},
|
},
|
||||||
installation.lifetime.signal,
|
installation.lifetime.signal,
|
||||||
{ openSettings() {}, isManagedLocalHost: async () => false },
|
|
||||||
),
|
),
|
||||||
paseo: installation.paseo,
|
paseo: installation.paseo,
|
||||||
i18n: installation.i18n,
|
i18n: installation.i18n,
|
||||||
|
|||||||
@@ -18,19 +18,12 @@ function registry(version: string) {
|
|||||||
return {
|
return {
|
||||||
hosts: createPluginHosts(
|
hosts: createPluginHosts(
|
||||||
{
|
{
|
||||||
addHost: async () => ({ status: "cancelled" }),
|
|
||||||
getHosts: () => [],
|
getHosts: () => [],
|
||||||
getSnapshot: () => null,
|
getSnapshot: () => null,
|
||||||
subscribeAll: () => () => {},
|
subscribeAll: () => () => {},
|
||||||
subscribeHostList: () => () => {},
|
subscribeHostList: () => () => {},
|
||||||
renameHost: async () => {},
|
|
||||||
setHostColor: async () => {},
|
|
||||||
setHostBadgeDisplay: async () => {},
|
|
||||||
removeHost: async () => {},
|
|
||||||
removeConnection: async () => {},
|
|
||||||
},
|
},
|
||||||
installation.lifetime.signal,
|
installation.lifetime.signal,
|
||||||
{ openSettings() {}, isManagedLocalHost: async () => false },
|
|
||||||
),
|
),
|
||||||
paseo: installation.paseo,
|
paseo: installation.paseo,
|
||||||
i18n: installation.i18n,
|
i18n: installation.i18n,
|
||||||
|
|||||||
@@ -55,38 +55,24 @@ export class HostConfirmations {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/** Resolves true without asking when the link matches a saved host exactly. */
|
/** Resolves true without asking when the link matches a saved host exactly. */
|
||||||
confirmLink(
|
confirmLink(offer: ConnectionOffer, savedHosts: HostProfile[]): Promise<boolean> {
|
||||||
offer: ConnectionOffer,
|
|
||||||
savedHosts: HostProfile[],
|
|
||||||
signal?: AbortSignal,
|
|
||||||
): Promise<boolean> {
|
|
||||||
const connection = relayConnectionFromOffer(offer);
|
const connection = relayConnectionFromOffer(offer);
|
||||||
const savedHost = savedHosts.find((host) => host.serverId === offer.serverId);
|
const savedHost = savedHosts.find((host) => host.serverId === offer.serverId);
|
||||||
if (savedHost && hostHasConnection(savedHost, connection)) return Promise.resolve(true);
|
if (savedHost && hostHasConnection(savedHost, connection)) return Promise.resolve(true);
|
||||||
if (signal?.aborted) return Promise.resolve(false);
|
return this.ask({
|
||||||
return this.ask(
|
id: this.nextRequestId++,
|
||||||
{
|
serverId: offer.serverId,
|
||||||
id: this.nextRequestId++,
|
keyFingerprint: formatDaemonKeyFingerprint(connection.daemonPublicKeyB64),
|
||||||
serverId: offer.serverId,
|
relayEndpoint: connection.relayEndpoint,
|
||||||
keyFingerprint: formatDaemonKeyFingerprint(connection.daemonPublicKeyB64),
|
kind: savedHost ? "changedConnection" : "newHost",
|
||||||
relayEndpoint: connection.relayEndpoint,
|
});
|
||||||
kind: savedHost ? "changedConnection" : "newHost",
|
|
||||||
},
|
|
||||||
signal,
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private ask(request: HostConfirmationRequest, signal?: AbortSignal): Promise<boolean> {
|
private ask(request: HostConfirmationRequest): Promise<boolean> {
|
||||||
// One question at a time: a newer one cancels the unanswered one.
|
// One question at a time: a newer one cancels the unanswered one.
|
||||||
this.pending?.resolve(false);
|
this.pending?.resolve(false);
|
||||||
return new Promise<boolean>((resolve) => {
|
return new Promise<boolean>((resolve) => {
|
||||||
const cancel = () => this.answer(request.id, false);
|
this.pending = { request, resolve };
|
||||||
const finish = (approved: boolean) => {
|
|
||||||
signal?.removeEventListener("abort", cancel);
|
|
||||||
resolve(approved);
|
|
||||||
};
|
|
||||||
this.pending = { request, resolve: finish };
|
|
||||||
signal?.addEventListener("abort", cancel, { once: true });
|
|
||||||
this.emit();
|
this.emit();
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -16,7 +16,7 @@ import type { ConnectionOffer } from "@getpaseo/protocol/connection-offer";
|
|||||||
import type { SessionOutboundMessage } from "@getpaseo/protocol/messages";
|
import type { SessionOutboundMessage } from "@getpaseo/protocol/messages";
|
||||||
import type { AgentPermissionRequest } from "@getpaseo/protocol/agent-types";
|
import type { AgentPermissionRequest } from "@getpaseo/protocol/agent-types";
|
||||||
import type { HostConnection, HostProfile } from "@/types/host-connection";
|
import type { HostConnection, HostProfile } from "@/types/host-connection";
|
||||||
import { defaultHostAppearance, selectHostBadges } from "@/hosts/appearance";
|
import { defaultHostAppearance } from "@/hosts/appearance";
|
||||||
import { useSessionStore, type Agent } from "@/stores/session-store";
|
import { useSessionStore, type Agent } from "@/stores/session-store";
|
||||||
import { normalizeAgentSnapshot } from "@/utils/agent-snapshots";
|
import { normalizeAgentSnapshot } from "@/utils/agent-snapshots";
|
||||||
import { isAgentArchiving, setAgentArchiving } from "@/hooks/use-archive-agent";
|
import { isAgentArchiving, setAgentArchiving } from "@/hooks/use-archive-agent";
|
||||||
@@ -33,7 +33,6 @@ import type { ReplicaRow, ReplicaRowStore } from "./replica-cache/row-store";
|
|||||||
import { subscriptionFixture } from "./subscription-fixture";
|
import { subscriptionFixture } from "./subscription-fixture";
|
||||||
import { readDesktopManagedLocalCredential } from "@/desktop/daemon/local-credential";
|
import { readDesktopManagedLocalCredential } from "@/desktop/daemon/local-credential";
|
||||||
import type { HostConfirmationRequest } from "./host-confirmation";
|
import type { HostConfirmationRequest } from "./host-confirmation";
|
||||||
import { createPluginHosts } from "@/plugins/hosts";
|
|
||||||
|
|
||||||
it("requests the managed connection credential through desktop main without a web hint", async () => {
|
it("requests the managed connection credential through desktop main without a web hint", async () => {
|
||||||
const requests: string[] = [];
|
const requests: string[] = [];
|
||||||
@@ -4779,295 +4778,3 @@ describe("HostRuntimeStore initial connection hint bootstrap", () => {
|
|||||||
expect(store.getHosts()).toHaveLength(0);
|
expect(store.getHosts()).toHaveLength(0);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe("adding hosts from plugin connection targets", () => {
|
|
||||||
it("does not save an unauthenticated target and retries with the password", async () => {
|
|
||||||
const store = createPairingStore({ password: "secret", serverIdForHost: () => "srv_direct" });
|
|
||||||
const requests = answerHostConfirmations(store, true);
|
|
||||||
expect(await store.addHost({ connection: "example.com:6767" })).toEqual({
|
|
||||||
status: "password_required",
|
|
||||||
reason: "password_required",
|
|
||||||
});
|
|
||||||
expect(store.getHosts()).toHaveLength(0);
|
|
||||||
expect(
|
|
||||||
await store.addHost({ connection: "tcp://example.com:6767?password=secret", label: "Build" }),
|
|
||||||
).toEqual({ status: "saved", serverId: "srv_direct" });
|
|
||||||
expect(store.getHosts()[0]).toMatchObject({
|
|
||||||
label: "Build",
|
|
||||||
password: "secret",
|
|
||||||
connections: [{ type: "directTcp", endpoint: "example.com:6767" }],
|
|
||||||
});
|
|
||||||
expect(requests).toHaveLength(0);
|
|
||||||
await waitForHostOnline(store, "srv_direct");
|
|
||||||
store.syncHosts([]);
|
|
||||||
});
|
|
||||||
it("persists the supplied password and reconnects after the app restarts", async () => {
|
|
||||||
const storage = createMemoryHostRuntimeStorage({ "@paseo:e2e": "1" });
|
|
||||||
const first = createPairingStore({
|
|
||||||
storage,
|
|
||||||
password: "secret",
|
|
||||||
serverIdForHost: () => "srv_direct",
|
|
||||||
});
|
|
||||||
const lifetime = new AbortController();
|
|
||||||
const plugin = createPluginHosts(first, lifetime.signal, {
|
|
||||||
openSettings() {},
|
|
||||||
isManagedLocalHost: async () => false,
|
|
||||||
});
|
|
||||||
const result = await plugin.addHost({ connection: "tcp://example.com:6767?password=secret" });
|
|
||||||
expect(result.status).toBe("saved");
|
|
||||||
expect(JSON.stringify(result)).not.toContain("secret");
|
|
||||||
const persisted = JSON.parse((await storage.getItem("@paseo:daemon-registry")) ?? "[]");
|
|
||||||
expect(persisted[0].password).toBe("secret");
|
|
||||||
lifetime.abort();
|
|
||||||
first.syncHosts([]);
|
|
||||||
|
|
||||||
const attempts: string[] = [];
|
|
||||||
const restarted = createPairingStore({
|
|
||||||
storage,
|
|
||||||
password: "secret",
|
|
||||||
connectAttempts: attempts,
|
|
||||||
serverIdForHost: () => "srv_direct",
|
|
||||||
});
|
|
||||||
await restarted.boot();
|
|
||||||
await waitForHostOnline(restarted, "srv_direct");
|
|
||||||
expect(attempts).toContain("direct:example.com:6767");
|
|
||||||
expect(restarted.getHosts()[0].password).toBe("secret");
|
|
||||||
const restartedLifetime = new AbortController();
|
|
||||||
const restartedPlugin = createPluginHosts(restarted, restartedLifetime.signal, {
|
|
||||||
openSettings() {},
|
|
||||||
isManagedLocalHost: async () => false,
|
|
||||||
});
|
|
||||||
expect(JSON.stringify(restartedPlugin.getSnapshot())).not.toContain("secret");
|
|
||||||
restartedLifetime.abort();
|
|
||||||
restarted.syncHosts([]);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("asks before saving a relay target and respects rejection", async () => {
|
|
||||||
const uri = "relay://relay.paseo.sh:443/srv_offer?key=pk_test_offer&ssl=true";
|
|
||||||
const rejected = createPairingStore();
|
|
||||||
const requests = answerHostConfirmations(rejected, false);
|
|
||||||
expect(await rejected.addHost({ connection: uri })).toEqual({ status: "cancelled" });
|
|
||||||
expect(requests).toHaveLength(1);
|
|
||||||
expect(rejected.getHosts()).toHaveLength(0);
|
|
||||||
const accepted = createPairingStore();
|
|
||||||
const acceptedRequests = answerHostConfirmations(accepted, true);
|
|
||||||
expect(await accepted.addHost({ connection: uri, label: "Relay" })).toEqual({
|
|
||||||
status: "saved",
|
|
||||||
serverId: "srv_offer",
|
|
||||||
});
|
|
||||||
expect(acceptedRequests).toHaveLength(1);
|
|
||||||
expect(accepted.getHosts()[0]).toMatchObject({
|
|
||||||
label: "Relay",
|
|
||||||
connections: [{ type: "relay" }],
|
|
||||||
});
|
|
||||||
accepted.syncHosts([]);
|
|
||||||
});
|
|
||||||
it.each([
|
|
||||||
["ssh://deploy@build:2222?daemonPort=7777", "remoteSsh"],
|
|
||||||
["unix:///tmp/paseo.sock", "directSocket"],
|
|
||||||
["pipe://mypipe", "directPipe"],
|
|
||||||
["wss://example.com/ws", "directTcp"],
|
|
||||||
])("probes and saves %s", async (connection, type) => {
|
|
||||||
const store = createPairingStore({ serverIdForHost: () => "srv_target" });
|
|
||||||
expect(await store.addHost({ connection })).toEqual({
|
|
||||||
status: "saved",
|
|
||||||
serverId: "srv_target",
|
|
||||||
});
|
|
||||||
expect(store.getHosts()[0].connections[0].type).toBe(type);
|
|
||||||
store.syncHosts([]);
|
|
||||||
});
|
|
||||||
it("shares host changes between plugin handles, host settings, and sidebar badges", async () => {
|
|
||||||
const store = createPairingStore({
|
|
||||||
serverIdForHost: (host) => {
|
|
||||||
const connection = host.connections[0];
|
|
||||||
return connection?.type === "directTcp" ? connection.endpoint : "";
|
|
||||||
},
|
|
||||||
});
|
|
||||||
await store.addHost({ connection: "first:6767", label: "First" });
|
|
||||||
await store.addHost({ connection: "second:6767", label: "Second" });
|
|
||||||
await waitForHostOnline(store, "first:6767");
|
|
||||||
await waitForHostOnline(store, "second:6767");
|
|
||||||
const lifetime = new AbortController();
|
|
||||||
const plugin = createPluginHosts(store, lifetime.signal, {
|
|
||||||
openSettings() {},
|
|
||||||
isManagedLocalHost: async () => false,
|
|
||||||
});
|
|
||||||
expect(
|
|
||||||
plugin.getSnapshot().map((host) => [host.label, host.status, host.connections[0].uri]),
|
|
||||||
).toEqual([
|
|
||||||
["First", "online", "tcp://first:6767"],
|
|
||||||
["Second", "online", "tcp://second:6767"],
|
|
||||||
]);
|
|
||||||
await plugin.getSnapshot()[0].update({ label: "Build", appearance: { color: "sky" } });
|
|
||||||
expect(store.getHosts()[0]).toMatchObject({ label: "Build", appearance: { color: "sky" } });
|
|
||||||
expect(
|
|
||||||
selectHostBadges({ hosts: store.getHosts(), localServerId: null, enabled: true }).get(
|
|
||||||
"first:6767",
|
|
||||||
),
|
|
||||||
).toMatchObject({ label: "Build", color: "sky" });
|
|
||||||
lifetime.abort();
|
|
||||||
store.syncHosts([]);
|
|
||||||
});
|
|
||||||
|
|
||||||
it.each([
|
|
||||||
"tcp://example.com:6767?ssl=true",
|
|
||||||
"ssh://deploy@build:2222?daemonPort=7777",
|
|
||||||
"unix:///tmp/paseo.sock",
|
|
||||||
"relay://relay.paseo.sh:443/srv_offer?key=pk_test_offer&ssl=true",
|
|
||||||
])("recreates the projected connection on another device: %s", async (connection) => {
|
|
||||||
const first = createPairingStore({ serverIdForHost: (host) => host.serverId || "srv_target" });
|
|
||||||
answerHostConfirmations(first, true);
|
|
||||||
const result = await first.addHost({ connection });
|
|
||||||
if (result.status !== "saved") throw new Error("expected saved host");
|
|
||||||
const lifetime = new AbortController();
|
|
||||||
const plugin = createPluginHosts(first, lifetime.signal, {
|
|
||||||
openSettings() {},
|
|
||||||
isManagedLocalHost: async () => false,
|
|
||||||
});
|
|
||||||
const uri = plugin.getSnapshot().find((host) => host.serverId === result.serverId)
|
|
||||||
?.connections[0].uri;
|
|
||||||
if (!uri) throw new Error("expected connection URI");
|
|
||||||
const second = createPairingStore({ serverIdForHost: (host) => host.serverId || "srv_target" });
|
|
||||||
answerHostConfirmations(second, true);
|
|
||||||
expect(await second.addHost({ connection: uri })).toEqual({
|
|
||||||
status: "saved",
|
|
||||||
serverId: result.serverId,
|
|
||||||
});
|
|
||||||
expect(second.getHosts()[0].connections).toEqual(first.getHosts()[0].connections);
|
|
||||||
lifetime.abort();
|
|
||||||
first.syncHosts([]);
|
|
||||||
second.syncHosts([]);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("does not save an in-flight addition after its plugin stops", async () => {
|
|
||||||
const started = createDeferred<void>();
|
|
||||||
const probe = createDeferred<{
|
|
||||||
client: DaemonClient;
|
|
||||||
serverId: string;
|
|
||||||
hostname: string | null;
|
|
||||||
}>();
|
|
||||||
const store = new HostRuntimeStore({
|
|
||||||
storage: createMemoryHostRuntimeStorage(),
|
|
||||||
deps: {
|
|
||||||
createClient: () => new FakeDaemonClient() as unknown as DaemonClient,
|
|
||||||
connectToDaemon: async () => {
|
|
||||||
started.resolve(undefined);
|
|
||||||
return probe.promise;
|
|
||||||
},
|
|
||||||
getClientId: async () => "test",
|
|
||||||
readInitialConnectionHint: () => null,
|
|
||||||
},
|
|
||||||
});
|
|
||||||
const lifetime = new AbortController();
|
|
||||||
const plugin = createPluginHosts(store, lifetime.signal, {
|
|
||||||
openSettings() {},
|
|
||||||
isManagedLocalHost: async () => false,
|
|
||||||
});
|
|
||||||
const adding = plugin.addHost({ connection: "example.com:6767" });
|
|
||||||
await started.promise;
|
|
||||||
lifetime.abort();
|
|
||||||
probe.resolve({
|
|
||||||
client: makeConnectedProbeClient(5) as unknown as DaemonClient,
|
|
||||||
serverId: "srv",
|
|
||||||
hostname: null,
|
|
||||||
});
|
|
||||||
await expect(adding).rejects.toThrow("Plugin has stopped");
|
|
||||||
expect(store.getHosts()).toHaveLength(0);
|
|
||||||
store.syncHosts([]);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("reports a stopped plugin when an aborted probe rejects authentication", async () => {
|
|
||||||
const started = createDeferred<void>();
|
|
||||||
const probe = createDeferred<{
|
|
||||||
client: DaemonClient;
|
|
||||||
serverId: string;
|
|
||||||
hostname: string | null;
|
|
||||||
}>();
|
|
||||||
const store = new HostRuntimeStore({
|
|
||||||
storage: createMemoryHostRuntimeStorage(),
|
|
||||||
deps: {
|
|
||||||
createClient: () => new FakeDaemonClient() as unknown as DaemonClient,
|
|
||||||
connectToDaemon: async () => {
|
|
||||||
started.resolve(undefined);
|
|
||||||
return probe.promise;
|
|
||||||
},
|
|
||||||
getClientId: async () => "test",
|
|
||||||
readInitialConnectionHint: () => null,
|
|
||||||
},
|
|
||||||
});
|
|
||||||
const lifetime = new AbortController();
|
|
||||||
const plugin = createPluginHosts(store, lifetime.signal, {
|
|
||||||
openSettings() {},
|
|
||||||
isManagedLocalHost: async () => false,
|
|
||||||
});
|
|
||||||
const adding = plugin.addHost({ connection: "example.com:6767" });
|
|
||||||
await started.promise;
|
|
||||||
lifetime.abort();
|
|
||||||
probe.reject(new DaemonAuthenticationError("password_required"));
|
|
||||||
await expect(adding).rejects.toThrow("Plugin has stopped");
|
|
||||||
expect(store.getHosts()).toHaveLength(0);
|
|
||||||
store.syncHosts([]);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("dismisses an unanswered confirmation when the plugin stops", async () => {
|
|
||||||
const store = createPairingStore();
|
|
||||||
const lifetime = new AbortController();
|
|
||||||
const plugin = createPluginHosts(store, lifetime.signal, {
|
|
||||||
openSettings() {},
|
|
||||||
isManagedLocalHost: async () => false,
|
|
||||||
});
|
|
||||||
const pending = createDeferred<void>();
|
|
||||||
const unsubscribe = store.subscribeHostConfirmation(() => {
|
|
||||||
if (store.getPendingHostConfirmation()) pending.resolve(undefined);
|
|
||||||
});
|
|
||||||
const adding = plugin.addHost({
|
|
||||||
connection: "relay://relay.paseo.sh:443/srv_offer?key=pk_test_offer&ssl=true",
|
|
||||||
});
|
|
||||||
await pending.promise;
|
|
||||||
lifetime.abort();
|
|
||||||
await expect(adding).rejects.toThrow("Plugin has stopped");
|
|
||||||
expect(store.getPendingHostConfirmation()).toBeNull();
|
|
||||||
expect(store.getHosts()).toHaveLength(0);
|
|
||||||
unsubscribe();
|
|
||||||
store.syncHosts([]);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("returns incorrect_password without saving and redacts failed probe errors", async () => {
|
|
||||||
let failure: "auth" | "network" = "auth";
|
|
||||||
const store = new HostRuntimeStore({
|
|
||||||
storage: createMemoryHostRuntimeStorage(),
|
|
||||||
deps: {
|
|
||||||
createClient: () => new FakeDaemonClient() as unknown as DaemonClient,
|
|
||||||
connectToDaemon: async () => {
|
|
||||||
if (failure === "auth") throw new DaemonAuthenticationError("incorrect_password");
|
|
||||||
throw new Error("connection failed for secret");
|
|
||||||
},
|
|
||||||
getClientId: async () => "test",
|
|
||||||
readInitialConnectionHint: () => null,
|
|
||||||
},
|
|
||||||
});
|
|
||||||
expect(await store.addHost({ connection: "tcp://example.com:6767?password=secret" })).toEqual({
|
|
||||||
status: "password_required",
|
|
||||||
reason: "incorrect_password",
|
|
||||||
});
|
|
||||||
expect(store.getHosts()).toHaveLength(0);
|
|
||||||
failure = "network";
|
|
||||||
await expect(
|
|
||||||
store.addHost({ connection: "tcp://example.com:6767?password=secret" }),
|
|
||||||
).rejects.not.toThrow("secret");
|
|
||||||
expect(store.getHosts()).toHaveLength(0);
|
|
||||||
store.syncHosts([]);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("rejects websocket paths and query options the app cannot represent", async () => {
|
|
||||||
const store = createPairingStore();
|
|
||||||
await expect(store.addHost({ connection: "wss://example.com/custom" })).rejects.toThrow(
|
|
||||||
"unsupported",
|
|
||||||
);
|
|
||||||
await expect(
|
|
||||||
store.addHost({ connection: "wss://example.com/ws?token=secret" }),
|
|
||||||
).rejects.toThrow("unsupported");
|
|
||||||
expect(store.getHosts()).toHaveLength(0);
|
|
||||||
store.syncHosts([]);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|||||||
@@ -3,7 +3,6 @@ import { watchdog } from "@/watchdog";
|
|||||||
import { createHostChannelTransport } from "@getpaseo/client/internal/hosts";
|
import { createHostChannelTransport } from "@getpaseo/client/internal/hosts";
|
||||||
import type { OwnedSubscription } from "@getpaseo/client";
|
import type { OwnedSubscription } from "@getpaseo/client";
|
||||||
import type { HostDirectory } from "@getpaseo/protocol/hosts";
|
import type { HostDirectory } from "@getpaseo/protocol/hosts";
|
||||||
import { parseHostTarget, type HostTarget } from "@getpaseo/protocol/host-target";
|
|
||||||
import { useSyncExternalStore, useMemo } from "react";
|
import { useSyncExternalStore, useMemo } from "react";
|
||||||
import AsyncStorage from "@react-native-async-storage/async-storage";
|
import AsyncStorage from "@react-native-async-storage/async-storage";
|
||||||
import equal from "fast-deep-equal/es6";
|
import equal from "fast-deep-equal/es6";
|
||||||
@@ -1613,48 +1612,6 @@ interface AgentDirectoryRefreshInput {
|
|||||||
page?: FetchAgentsOptions["page"];
|
page?: FetchAgentsOptions["page"];
|
||||||
}
|
}
|
||||||
|
|
||||||
function directHostTarget(target: HostTarget, input: string): HostConnection {
|
|
||||||
let connection: HostConnection;
|
|
||||||
if (target.kind === "ssh") {
|
|
||||||
connection = createRemoteSshHostConnection(target.ssh);
|
|
||||||
} else {
|
|
||||||
if (/^wss?:\/\//.test(input.trim())) {
|
|
||||||
const url = new URL(target.url);
|
|
||||||
if (url.pathname !== "/ws" || url.search)
|
|
||||||
throw new Error("WebSocket path or query is unsupported by the app");
|
|
||||||
}
|
|
||||||
switch (target.connection.type) {
|
|
||||||
case "directTcp": {
|
|
||||||
const endpoint = normalizeHostPort(target.connection.endpoint);
|
|
||||||
connection = {
|
|
||||||
id: `direct:${endpoint}`,
|
|
||||||
type: "directTcp",
|
|
||||||
endpoint,
|
|
||||||
useTls: target.connection.useTls,
|
|
||||||
};
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
case "directSocket":
|
|
||||||
connection = {
|
|
||||||
id: `socket:${target.connection.path}`,
|
|
||||||
type: "directSocket",
|
|
||||||
path: target.connection.path,
|
|
||||||
};
|
|
||||||
break;
|
|
||||||
case "directPipe":
|
|
||||||
connection = {
|
|
||||||
id: `pipe:${target.connection.path}`,
|
|
||||||
type: "directPipe",
|
|
||||||
path: target.connection.path,
|
|
||||||
};
|
|
||||||
break;
|
|
||||||
case "relay":
|
|
||||||
throw new Error("Unexpected relay target");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return connection;
|
|
||||||
}
|
|
||||||
|
|
||||||
export class HostRuntimeStore {
|
export class HostRuntimeStore {
|
||||||
private controllers = new Map<string, HostRuntimeController>();
|
private controllers = new Map<string, HostRuntimeController>();
|
||||||
private hostObservations = new Map<
|
private hostObservations = new Map<
|
||||||
@@ -2007,66 +1964,6 @@ export class HostRuntimeStore {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Add a saved host using the shared connection grammar. */
|
|
||||||
async addHost(
|
|
||||||
input: {
|
|
||||||
connection: string;
|
|
||||||
label?: string;
|
|
||||||
},
|
|
||||||
signal?: AbortSignal,
|
|
||||||
): Promise<
|
|
||||||
| { status: "saved"; serverId: string }
|
|
||||||
| { status: "password_required"; reason: "password_required" | "incorrect_password" }
|
|
||||||
| { status: "cancelled" }
|
|
||||||
> {
|
|
||||||
const target = parseHostTarget(input.connection);
|
|
||||||
await this.loadRegistry();
|
|
||||||
try {
|
|
||||||
if (signal?.aborted) throw new Error("Plugin has stopped");
|
|
||||||
if (target.kind === "socket" && target.connection.type === "relay") {
|
|
||||||
const approved = await this.confirmLink(target.connection.offer, signal);
|
|
||||||
if (signal?.aborted) throw new Error("Plugin has stopped");
|
|
||||||
if (!approved) return { status: "cancelled" };
|
|
||||||
const result = await this.probeAndUpsertOffer(
|
|
||||||
target.connection.offer,
|
|
||||||
target.password,
|
|
||||||
input.label,
|
|
||||||
signal,
|
|
||||||
);
|
|
||||||
return { status: "saved", serverId: result.serverId };
|
|
||||||
}
|
|
||||||
const connection = directHostTarget(target, input.connection);
|
|
||||||
const result = await this.probeAndUpsertConnection({
|
|
||||||
connection,
|
|
||||||
password: target.password,
|
|
||||||
label: input.label,
|
|
||||||
signal,
|
|
||||||
});
|
|
||||||
return { status: "saved", serverId: result.serverId };
|
|
||||||
} catch (error) {
|
|
||||||
if (signal?.aborted) {
|
|
||||||
// The failed probe can include a credential; do not attach it as a cause.
|
|
||||||
// oxlint-disable-next-line preserve-caught-error
|
|
||||||
throw new Error("Plugin has stopped");
|
|
||||||
}
|
|
||||||
const reason = getConnectionAuthFailureReason(error);
|
|
||||||
if (reason) return { status: "password_required", reason };
|
|
||||||
let message = error instanceof Error ? error.message : String(error);
|
|
||||||
if (target.password) {
|
|
||||||
for (const value of [
|
|
||||||
target.password,
|
|
||||||
encodeURIComponent(target.password),
|
|
||||||
new URLSearchParams({ password: target.password }).toString().slice(9),
|
|
||||||
]) {
|
|
||||||
if (value) message = message.split(value).join("[redacted]");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
// The original error can contain a credential; do not expose it as a cause.
|
|
||||||
// oxlint-disable-next-line preserve-caught-error
|
|
||||||
throw new Error(message);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async upsertDirectConnection(input: {
|
async upsertDirectConnection(input: {
|
||||||
serverId: string;
|
serverId: string;
|
||||||
endpoint: string;
|
endpoint: string;
|
||||||
@@ -2096,7 +1993,6 @@ export class HostRuntimeStore {
|
|||||||
password?: string;
|
password?: string;
|
||||||
label?: string;
|
label?: string;
|
||||||
timeoutMs?: number;
|
timeoutMs?: number;
|
||||||
signal?: AbortSignal;
|
|
||||||
}): Promise<{ profile: HostProfile; serverId: string; hostname: string | null }> {
|
}): Promise<{ profile: HostProfile; serverId: string; hostname: string | null }> {
|
||||||
if (input.connection.type === "relay") {
|
if (input.connection.type === "relay") {
|
||||||
throw new Error("Cannot probe a relay connection without a server id.");
|
throw new Error("Cannot probe a relay connection without a server id.");
|
||||||
@@ -2117,10 +2013,6 @@ export class HostRuntimeStore {
|
|||||||
connection: input.connection,
|
connection: input.connection,
|
||||||
...(input.timeoutMs !== undefined ? { timeoutMs: input.timeoutMs } : {}),
|
...(input.timeoutMs !== undefined ? { timeoutMs: input.timeoutMs } : {}),
|
||||||
});
|
});
|
||||||
if (input.signal?.aborted) {
|
|
||||||
await client.close().catch(() => undefined);
|
|
||||||
throw new Error("Plugin has stopped");
|
|
||||||
}
|
|
||||||
const profile = await this.upsertHostConnection({
|
const profile = await this.upsertHostConnection({
|
||||||
serverId,
|
serverId,
|
||||||
label: input.label ?? hostname ?? undefined,
|
label: input.label ?? hostname ?? undefined,
|
||||||
@@ -2170,9 +2062,9 @@ export class HostRuntimeStore {
|
|||||||
// A pairing link (a URL, a QR code, or a pasted link) asks the user to
|
// A pairing link (a URL, a QR code, or a pasted link) asks the user to
|
||||||
// confirm when it adds a new host or changes a saved host's key, relay, or
|
// confirm when it adds a new host or changes a saved host's key, relay, or
|
||||||
// TLS setting. The answer needs the saved hosts, so it waits for them to load.
|
// TLS setting. The answer needs the saved hosts, so it waits for them to load.
|
||||||
private async confirmLink(offer: ConnectionOffer, signal?: AbortSignal): Promise<boolean> {
|
private async confirmLink(offer: ConnectionOffer): Promise<boolean> {
|
||||||
await this.loadRegistry();
|
await this.loadRegistry();
|
||||||
return this.hostConfirmations.confirmLink(offer, this.hosts, signal);
|
return this.hostConfirmations.confirmLink(offer, this.hosts);
|
||||||
}
|
}
|
||||||
|
|
||||||
getPendingHostConfirmation(): HostConfirmationRequest | null {
|
getPendingHostConfirmation(): HostConfirmationRequest | null {
|
||||||
@@ -2252,8 +2144,6 @@ export class HostRuntimeStore {
|
|||||||
private async probeAndUpsertOffer(
|
private async probeAndUpsertOffer(
|
||||||
offer: ConnectionOffer,
|
offer: ConnectionOffer,
|
||||||
password: string | undefined,
|
password: string | undefined,
|
||||||
label?: string,
|
|
||||||
signal?: AbortSignal,
|
|
||||||
): Promise<{ profile: HostProfile; serverId: string; hostname: string | null }> {
|
): Promise<{ profile: HostProfile; serverId: string; hostname: string | null }> {
|
||||||
const connection = relayConnectionFromOffer(offer);
|
const connection = relayConnectionFromOffer(offer);
|
||||||
const probeHost: HostProfile = {
|
const probeHost: HostProfile = {
|
||||||
@@ -2269,12 +2159,7 @@ export class HostRuntimeStore {
|
|||||||
};
|
};
|
||||||
const { client, hostname } = await this.deps.connectToDaemon({ host: probeHost, connection });
|
const { client, hostname } = await this.deps.connectToDaemon({ host: probeHost, connection });
|
||||||
await client.close().catch(() => undefined);
|
await client.close().catch(() => undefined);
|
||||||
if (signal?.aborted) throw new Error("Plugin has stopped");
|
const profile = await this.upsertConnectionFromOffer(offer, hostname ?? undefined, password);
|
||||||
const profile = await this.upsertConnectionFromOffer(
|
|
||||||
offer,
|
|
||||||
label ?? hostname ?? undefined,
|
|
||||||
password,
|
|
||||||
);
|
|
||||||
return { profile, serverId: offer.serverId, hostname };
|
return { profile, serverId: offer.serverId, hostname };
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -2336,10 +2221,7 @@ export class HostRuntimeStore {
|
|||||||
}));
|
}));
|
||||||
}
|
}
|
||||||
|
|
||||||
async setHostBadgeDisplay(
|
async setHostBadgeDisplay(serverId: string, badgeDisplay: HostBadgeDisplay): Promise<void> {
|
||||||
serverId: string,
|
|
||||||
badgeDisplay: HostBadgeDisplay | null,
|
|
||||||
): Promise<void> {
|
|
||||||
await this.updateHostAppearance(serverId, (host) => ({
|
await this.updateHostAppearance(serverId, (host) => ({
|
||||||
...host,
|
...host,
|
||||||
appearance: { ...host.appearance, badgeDisplay },
|
appearance: { ...host.appearance, badgeDisplay },
|
||||||
@@ -3152,7 +3034,7 @@ export interface HostMutations {
|
|||||||
beginLinkPairing: () => LinkPairing;
|
beginLinkPairing: () => LinkPairing;
|
||||||
renameHost: (serverId: string, label: string) => Promise<void>;
|
renameHost: (serverId: string, label: string) => Promise<void>;
|
||||||
setHostColor: (serverId: string, color: HostColor) => Promise<void>;
|
setHostColor: (serverId: string, color: HostColor) => Promise<void>;
|
||||||
setHostBadgeDisplay: (serverId: string, badgeDisplay: HostBadgeDisplay | null) => Promise<void>;
|
setHostBadgeDisplay: (serverId: string, badgeDisplay: HostBadgeDisplay) => Promise<void>;
|
||||||
removeHost: (serverId: string) => Promise<void>;
|
removeHost: (serverId: string) => Promise<void>;
|
||||||
removeConnection: (serverId: string, connectionId: string) => Promise<void>;
|
removeConnection: (serverId: string, connectionId: string) => Promise<void>;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -152,12 +152,6 @@ describe("daemon-manager commands", () => {
|
|||||||
expect(mocks.runExternalCliJsonCommand).not.toHaveBeenCalled();
|
expect(mocks.runExternalCliJsonCommand).not.toHaveBeenCalled();
|
||||||
});
|
});
|
||||||
|
|
||||||
it("reads the desktop daemon identity after it has stopped", async () => {
|
|
||||||
mkdirSync(mocks.paseoHome);
|
|
||||||
writeFileSync(path.join(mocks.paseoHome, "server-id"), "srv_existing\n");
|
|
||||||
expect(await createDaemonCommandHandlers().desktop_daemon_identity()).toBe("srv_existing");
|
|
||||||
});
|
|
||||||
|
|
||||||
it("reports an errored daemon when the local daemon state cannot be read", async () => {
|
it("reports an errored daemon when the local daemon state cannot be read", async () => {
|
||||||
mkdirSync(mocks.paseoHome);
|
mkdirSync(mocks.paseoHome);
|
||||||
writeFileSync(path.join(mocks.paseoHome, "paseo.pid"), "garbage");
|
writeFileSync(path.join(mocks.paseoHome, "paseo.pid"), "garbage");
|
||||||
|
|||||||
@@ -476,14 +476,6 @@ export function createDaemonCommandHandlers(): Record<string, DesktopCommandHand
|
|||||||
runningUnderARM64Translation: isRunningUnderARM64Translation(),
|
runningUnderARM64Translation: isRunningUnderARM64Translation(),
|
||||||
}),
|
}),
|
||||||
desktop_daemon_status: () => resolveDesktopDaemonStatus(),
|
desktop_daemon_status: () => resolveDesktopDaemonStatus(),
|
||||||
desktop_daemon_identity: () => {
|
|
||||||
try {
|
|
||||||
return readFileSync(path.join(getPaseoHome(), "server-id"), "utf8").trim() || null;
|
|
||||||
} catch (error) {
|
|
||||||
if ((error as NodeJS.ErrnoException).code === "ENOENT") return null;
|
|
||||||
throw error;
|
|
||||||
}
|
|
||||||
},
|
|
||||||
desktop_local_credential: async (args) => {
|
desktop_local_credential: async (args) => {
|
||||||
const instance = await readDaemonInstance(getPaseoHome());
|
const instance = await readDaemonInstance(getPaseoHome());
|
||||||
if (!instance?.desktopManaged || typeof args?.listen !== "string") return null;
|
if (!instance?.desktopManaged || typeof args?.listen !== "string") return null;
|
||||||
|
|||||||
@@ -92,18 +92,6 @@ function typeErrors(code: string): string[] {
|
|||||||
.map((diagnostic) => ts.flattenDiagnosticMessageText(diagnostic.messageText, "\n"));
|
.map((diagnostic) => ts.flattenDiagnosticMessageText(diagnostic.messageText, "\n"));
|
||||||
}
|
}
|
||||||
describe("plugin SDK import boundaries", () => {
|
describe("plugin SDK import boundaries", () => {
|
||||||
it("exposes host handles and object-shaped additions from the client entry", () => {
|
|
||||||
expect(
|
|
||||||
typeErrors(`
|
|
||||||
import { addHost, observeHosts, useHosts, type PluginHost } from "@getpaseo/plugin/client";
|
|
||||||
const hosts: readonly PluginHost[] = useHosts();
|
|
||||||
const stop = observeHosts((value) => { void value[0]?.openSettings(); });
|
|
||||||
void addHost({ connection: "localhost:6767", label: "Local" });
|
|
||||||
void hosts; stop();
|
|
||||||
`),
|
|
||||||
).toEqual([]);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("publishes process capabilities without their Windows implementation helpers", () => {
|
it("publishes process capabilities without their Windows implementation helpers", () => {
|
||||||
expect(Object.keys(server).sort()).toEqual([
|
expect(Object.keys(server).sort()).toEqual([
|
||||||
"defineAgentTool",
|
"defineAgentTool",
|
||||||
|
|||||||
@@ -1,98 +0,0 @@
|
|||||||
import type { PluginHostSummary } from "./index.js";
|
|
||||||
|
|
||||||
export type PluginHostStatus = PluginHostSummary["status"];
|
|
||||||
export type PluginHostColor =
|
|
||||||
| "none"
|
|
||||||
| "violet"
|
|
||||||
| "sky"
|
|
||||||
| "emerald"
|
|
||||||
| "orange"
|
|
||||||
| "pink"
|
|
||||||
| "indigo"
|
|
||||||
| "teal"
|
|
||||||
| "red"
|
|
||||||
| "amber"
|
|
||||||
| "blue";
|
|
||||||
export type PluginHostBadgeDisplay = "name" | "icon" | "hidden";
|
|
||||||
export interface PluginHostAppearance {
|
|
||||||
readonly color: PluginHostColor;
|
|
||||||
/** Null uses the automatic local or remote default. */
|
|
||||||
readonly badgeDisplay: PluginHostBadgeDisplay | null;
|
|
||||||
}
|
|
||||||
/** Saved connection settings without credentials. Routed connections have no URI. */
|
|
||||||
export type PluginHostConnection =
|
|
||||||
| {
|
|
||||||
readonly id: string;
|
|
||||||
readonly type: "directTcp";
|
|
||||||
readonly uri: string;
|
|
||||||
readonly endpoint: string;
|
|
||||||
readonly useTls: boolean;
|
|
||||||
}
|
|
||||||
| {
|
|
||||||
readonly id: string;
|
|
||||||
readonly type: "directSocket";
|
|
||||||
readonly uri: string;
|
|
||||||
readonly path: string;
|
|
||||||
}
|
|
||||||
| {
|
|
||||||
readonly id: string;
|
|
||||||
readonly type: "directPipe";
|
|
||||||
readonly uri: string;
|
|
||||||
readonly path: string;
|
|
||||||
}
|
|
||||||
| {
|
|
||||||
readonly id: string;
|
|
||||||
readonly type: "remoteSsh";
|
|
||||||
readonly uri: string;
|
|
||||||
readonly host: string;
|
|
||||||
readonly sshPort?: number;
|
|
||||||
readonly daemonPort?: number;
|
|
||||||
}
|
|
||||||
| {
|
|
||||||
readonly id: string;
|
|
||||||
readonly type: "relay";
|
|
||||||
readonly uri: string;
|
|
||||||
readonly relayEndpoint: string;
|
|
||||||
readonly useTls: boolean;
|
|
||||||
readonly daemonPublicKeyB64: string;
|
|
||||||
readonly minimumSecurityMode?: "protected";
|
|
||||||
}
|
|
||||||
| {
|
|
||||||
readonly id: string;
|
|
||||||
readonly type: "routed";
|
|
||||||
readonly uri: null;
|
|
||||||
readonly originServerId: string;
|
|
||||||
};
|
|
||||||
export interface PluginHostPatch {
|
|
||||||
label?: string;
|
|
||||||
appearance?: Partial<PluginHostAppearance>;
|
|
||||||
}
|
|
||||||
export interface PluginHost extends PluginHostSummary {
|
|
||||||
readonly appearance: PluginHostAppearance;
|
|
||||||
readonly connections: readonly PluginHostConnection[];
|
|
||||||
readonly activeConnectionId: string | null;
|
|
||||||
readonly error: string | null;
|
|
||||||
readonly authFailureReason: "password_required" | "incorrect_password" | null;
|
|
||||||
readonly lastOnlineAt: string | null;
|
|
||||||
readonly createdAt: string;
|
|
||||||
readonly updatedAt: string;
|
|
||||||
update(patch: PluginHostPatch): Promise<void>;
|
|
||||||
/** Removes the host; throws for the desktop daemon host. */
|
|
||||||
remove(): Promise<void>;
|
|
||||||
/** Removing the last connection removes the host. */
|
|
||||||
removeConnection(connectionId: string): Promise<void>;
|
|
||||||
openSettings(): void;
|
|
||||||
}
|
|
||||||
export interface PluginHostAddInput {
|
|
||||||
/** Shared host connection grammar; may contain a password for the probe. */
|
|
||||||
connection: string;
|
|
||||||
label?: string;
|
|
||||||
}
|
|
||||||
export type PluginHostAddResult =
|
|
||||||
| { status: "saved"; host: PluginHost }
|
|
||||||
| { status: "password_required"; reason: "password_required" | "incorrect_password" }
|
|
||||||
| { status: "cancelled" };
|
|
||||||
export declare function useHosts(): readonly PluginHost[];
|
|
||||||
export declare function observeHosts(listener: (hosts: readonly PluginHost[]) => void): () => void;
|
|
||||||
|
|
||||||
export declare function addHost(input: PluginHostAddInput): Promise<PluginHostAddResult>;
|
|
||||||
@@ -86,20 +86,7 @@ export interface PluginHostSummary {
|
|||||||
readonly status: "idle" | "connecting" | "online" | "offline" | "error";
|
readonly status: "idle" | "connecting" | "online" | "offline" | "error";
|
||||||
}
|
}
|
||||||
/** Live configured hosts. Supplied by the app's client bundle loader. */
|
/** Live configured hosts. Supplied by the app's client bundle loader. */
|
||||||
export declare const useHosts: typeof import("./hosts.js").useHosts;
|
export declare function useHosts(): readonly PluginHostSummary[];
|
||||||
export declare const observeHosts: typeof import("./hosts.js").observeHosts;
|
|
||||||
export declare const addHost: typeof import("./hosts.js").addHost;
|
|
||||||
export type {
|
|
||||||
PluginHost,
|
|
||||||
PluginHostStatus,
|
|
||||||
PluginHostColor,
|
|
||||||
PluginHostBadgeDisplay,
|
|
||||||
PluginHostAppearance,
|
|
||||||
PluginHostConnection,
|
|
||||||
PluginHostPatch,
|
|
||||||
PluginHostAddInput,
|
|
||||||
PluginHostAddResult,
|
|
||||||
} from "./hosts.js";
|
|
||||||
/** Borrow an online host's API under this installation's lifetime. */
|
/** Borrow an online host's API under this installation's lifetime. */
|
||||||
export declare function getPaseoClient(serverId: string): import("@getpaseo/client").PaseoApi;
|
export declare function getPaseoClient(serverId: string): import("@getpaseo/client").PaseoApi;
|
||||||
/** Open an absolute HTTP(S) URL using the client platform’s external opener. */
|
/** Open an absolute HTTP(S) URL using the client platform’s external opener. */
|
||||||
|
|||||||
@@ -1,55 +1,5 @@
|
|||||||
import { expect, test } from "vitest";
|
import { expect, test } from "vitest";
|
||||||
import { parseHostTarget } from "./host-target.js";
|
import { parseHostTarget } from "./host-target.js";
|
||||||
import { serializeSshTransportUri } from "./ssh-transport.js";
|
|
||||||
import { serializeSocketHostTarget } from "./host-target.js";
|
|
||||||
import {
|
|
||||||
parseConnectionUri,
|
|
||||||
serializeConnectionUri,
|
|
||||||
serializeRelayConnectionUri,
|
|
||||||
} from "./daemon-endpoints.js";
|
|
||||||
|
|
||||||
test("SSH and local socket targets round trip through their serializers", () => {
|
|
||||||
const ssh = parseHostTarget("ssh://deploy@build-box:2222?daemonPort=7777");
|
|
||||||
if (ssh.kind !== "ssh") throw new Error("expected SSH");
|
|
||||||
expect(parseHostTarget(serializeSshTransportUri(ssh.ssh))).toEqual(ssh);
|
|
||||||
for (const [type, path] of [
|
|
||||||
["unix", "/tmp/paseo.sock"],
|
|
||||||
["pipe", "\\\\.\\pipe\\paseo"],
|
|
||||||
] as const) {
|
|
||||||
const target = parseHostTarget(`${type}://${path}`);
|
|
||||||
expect(parseHostTarget(serializeSocketHostTarget(type, path))).toEqual(target);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
test("TCP and relay targets round trip through credential-free serializers", () => {
|
|
||||||
const tcp = parseConnectionUri("tcp://example.com:6767?ssl=true&password=secret");
|
|
||||||
const tcpUri = serializeConnectionUri(tcp);
|
|
||||||
expect(tcpUri).not.toContain("secret");
|
|
||||||
expect(parseHostTarget(tcpUri)).toMatchObject({
|
|
||||||
connection: { type: "directTcp", endpoint: "example.com:6767", useTls: true },
|
|
||||||
});
|
|
||||||
const relay = parseHostTarget(
|
|
||||||
"relay://relay.example:443/server?key=public&ssl=true&password=secret",
|
|
||||||
);
|
|
||||||
if (relay.kind !== "socket" || relay.connection.type !== "relay")
|
|
||||||
throw new Error("expected relay");
|
|
||||||
const relayUri = serializeRelayConnectionUri({ offer: relay.connection.offer });
|
|
||||||
expect(relayUri).not.toContain("secret");
|
|
||||||
expect(parseHostTarget(relayUri)).toMatchObject({ connection: relay.connection });
|
|
||||||
});
|
|
||||||
|
|
||||||
test.each([
|
|
||||||
["example.com:6767", { type: "directTcp", endpoint: "example.com:6767", useTls: false }],
|
|
||||||
[
|
|
||||||
"tcp://example.com:6767?ssl=true",
|
|
||||||
{ type: "directTcp", endpoint: "example.com:6767", useTls: true },
|
|
||||||
],
|
|
||||||
["wss://example.com/ws", { type: "directTcp", endpoint: "example.com:443", useTls: true }],
|
|
||||||
["unix:///tmp/paseo.sock", { type: "directSocket", path: "/tmp/paseo.sock" }],
|
|
||||||
["pipe://mypipe", { type: "directPipe", path: "mypipe" }],
|
|
||||||
])("%s exposes its structured connection", (input, connection) => {
|
|
||||||
expect(parseHostTarget(input)).toMatchObject({ kind: "socket", connection });
|
|
||||||
});
|
|
||||||
|
|
||||||
test.each([
|
test.each([
|
||||||
["127.0.0.1:6767", "tcp://127.0.0.1:6767"],
|
["127.0.0.1:6767", "tcp://127.0.0.1:6767"],
|
||||||
|
|||||||
@@ -14,23 +14,12 @@ export type HostTarget =
|
|||||||
| {
|
| {
|
||||||
kind: "socket";
|
kind: "socket";
|
||||||
url: string;
|
url: string;
|
||||||
connection:
|
|
||||||
| { type: "directTcp"; endpoint: string; useTls: boolean }
|
|
||||||
| { type: "directSocket"; path: string }
|
|
||||||
| { type: "directPipe"; path: string }
|
|
||||||
| { type: "relay"; offer: ConnectionOffer };
|
|
||||||
socketPath?: string;
|
socketPath?: string;
|
||||||
password?: string;
|
password?: string;
|
||||||
offer?: ConnectionOffer;
|
offer?: ConnectionOffer;
|
||||||
description: string;
|
description: string;
|
||||||
};
|
};
|
||||||
|
|
||||||
/** Serialize a local socket target in the shared host grammar. */
|
|
||||||
export function serializeSocketHostTarget(type: "unix" | "pipe", path: string): string {
|
|
||||||
if (!path || path === "~") throw new Error("Invalid socket path");
|
|
||||||
return `${type}://${path}`;
|
|
||||||
}
|
|
||||||
|
|
||||||
/** The shared saved-host and CLI grammar. Errors never echo credential-bearing input. */
|
/** The shared saved-host and CLI grammar. Errors never echo credential-bearing input. */
|
||||||
export function parseHostTarget(input: string): HostTarget {
|
export function parseHostTarget(input: string): HostTarget {
|
||||||
try {
|
try {
|
||||||
@@ -68,9 +57,6 @@ function parse(input: string): HostTarget {
|
|||||||
const pipe = input.startsWith("pipe://") || input.startsWith("\\\\.\\pipe\\");
|
const pipe = input.startsWith("pipe://") || input.startsWith("\\\\.\\pipe\\");
|
||||||
return {
|
return {
|
||||||
kind: "socket",
|
kind: "socket",
|
||||||
connection: pipe
|
|
||||||
? { type: "directPipe", path: socketPath }
|
|
||||||
: { type: "directSocket", path: socketPath },
|
|
||||||
socketPath,
|
socketPath,
|
||||||
url: pipe ? "ws://localhost/ws" : `ws+unix://${socketPath}:/ws`,
|
url: pipe ? "ws://localhost/ws" : `ws+unix://${socketPath}:/ws`,
|
||||||
description: `${pipe ? "pipe" : "unix"}://${socketPath}`,
|
description: `${pipe ? "pipe" : "unix"}://${socketPath}`,
|
||||||
@@ -81,7 +67,6 @@ function parse(input: string): HostTarget {
|
|||||||
const endpoint = validatedEndpoint(/^\d+$/.test(input) ? `127.0.0.1:${input}` : input);
|
const endpoint = validatedEndpoint(/^\d+$/.test(input) ? `127.0.0.1:${input}` : input);
|
||||||
return {
|
return {
|
||||||
kind: "socket",
|
kind: "socket",
|
||||||
connection: { type: "directTcp", endpoint, useTls: false },
|
|
||||||
url: buildDaemonWebSocketUrl(endpoint, { useTls: false }),
|
url: buildDaemonWebSocketUrl(endpoint, { useTls: false }),
|
||||||
description: `tcp://${endpoint}`,
|
description: `tcp://${endpoint}`,
|
||||||
};
|
};
|
||||||
@@ -90,7 +75,6 @@ function relayTarget(offer: ConnectionOffer, password?: string): HostTarget {
|
|||||||
const endpoint = validatedEndpoint(offer.relay.endpoint);
|
const endpoint = validatedEndpoint(offer.relay.endpoint);
|
||||||
return {
|
return {
|
||||||
kind: "socket",
|
kind: "socket",
|
||||||
connection: { type: "relay", offer },
|
|
||||||
url: buildRelayWebSocketUrl({
|
url: buildRelayWebSocketUrl({
|
||||||
endpoint,
|
endpoint,
|
||||||
serverId: offer.serverId,
|
serverId: offer.serverId,
|
||||||
@@ -113,7 +97,6 @@ function tcpTarget(input: string): HostTarget {
|
|||||||
);
|
);
|
||||||
return {
|
return {
|
||||||
kind: "socket",
|
kind: "socket",
|
||||||
connection: { type: "directTcp", endpoint, useTls: parts.useTls },
|
|
||||||
url: buildDaemonWebSocketUrl(endpoint, { useTls: parts.useTls }),
|
url: buildDaemonWebSocketUrl(endpoint, { useTls: parts.useTls }),
|
||||||
password: parts.password,
|
password: parts.password,
|
||||||
description: `tcp://${endpoint}`,
|
description: `tcp://${endpoint}`,
|
||||||
@@ -126,15 +109,7 @@ function websocketTarget(input: string): HostTarget {
|
|||||||
// Unknown query parameters may themselves contain credentials; discovery shows only the origin.
|
// Unknown query parameters may themselves contain credentials; discovery shows only the origin.
|
||||||
if (url.pathname === "/") url.pathname = "/ws";
|
if (url.pathname === "/") url.pathname = "/ws";
|
||||||
url.searchParams.delete("password");
|
url.searchParams.delete("password");
|
||||||
const useTls = url.protocol === "wss:";
|
return { kind: "socket", url: url.toString(), password, description: url.origin };
|
||||||
const endpoint = `${url.hostname}:${url.port || (useTls ? "443" : "80")}`;
|
|
||||||
return {
|
|
||||||
kind: "socket",
|
|
||||||
connection: { type: "directTcp", endpoint, useTls },
|
|
||||||
url: url.toString(),
|
|
||||||
password,
|
|
||||||
description: url.origin,
|
|
||||||
};
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function sshTarget(input: string): HostTarget {
|
function sshTarget(input: string): HostTarget {
|
||||||
|
|||||||
@@ -60,19 +60,6 @@ export function parseSshTransportUri(value: string): SshTransportTarget {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Serialize an SSH target without daemon credentials. */
|
|
||||||
export function serializeSshTransportUri(target: SshTransportTarget): string {
|
|
||||||
const host = validateSshHost(target.host);
|
|
||||||
const separator = host.lastIndexOf("@");
|
|
||||||
const username = separator < 0 ? "" : `${encodeURIComponent(host.slice(0, separator))}@`;
|
|
||||||
const hostname = host.slice(separator + 1);
|
|
||||||
const authority = hostname.includes(":") ? `[${hostname}]` : hostname;
|
|
||||||
const port = target.sshPort === undefined ? "" : `:${validatePort(target.sshPort, "SSH port")}`;
|
|
||||||
const daemonPort = validatePort(target.daemonPort, "Daemon port");
|
|
||||||
const query = daemonPort === DEFAULT_SSH_DAEMON_PORT ? "" : `?daemonPort=${daemonPort}`;
|
|
||||||
return `ssh://${username}${authority}${port}${query}`;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function buildSshTunnelArgs(target: SshTransportTarget): string[] {
|
export function buildSshTunnelArgs(target: SshTransportTarget): string[] {
|
||||||
const host = validateSshHost(target.host);
|
const host = validateSshHost(target.host);
|
||||||
const daemonPort = validatePort(target.daemonPort, "Daemon port");
|
const daemonPort = validatePort(target.daemonPort, "Daemon port");
|
||||||
|
|||||||
@@ -89,7 +89,7 @@
|
|||||||
"speech:transcribe:local": "tsx scripts/transcribe-local-wav.ts",
|
"speech:transcribe:local": "tsx scripts/transcribe-local-wav.ts",
|
||||||
"test": "npm run test:unit && npm run test:integration",
|
"test": "npm run test:unit && npm run test:integration",
|
||||||
"test:unit": "vitest run --fileParallelism --exclude \"**/*.e2e.test.ts\"",
|
"test:unit": "vitest run --fileParallelism --exclude \"**/*.e2e.test.ts\"",
|
||||||
"test:integration": "vitest run --maxWorkers=1 src/server/git-filename-diffs.e2e.test.ts src/server/daemon-e2e/checkout-diff-content.e2e.test.ts src/server/daemon-e2e/create-agent-mode.e2e.test.ts src/server/daemon-e2e/filesystem.e2e.test.ts src/server/daemon-e2e/file-download.e2e.test.ts src/server/daemon-e2e/cli-logs-follow.e2e.test.ts src/server/creation/creation.e2e.test.ts src/server/daemon-e2e/creation-status.e2e.test.ts src/server/daemon-e2e/terminal-workspace-sdk.e2e.test.ts src/server/daemon-e2e/terminal-worker-lifecycle.e2e.test.ts src/server/workspace-create-worktree-source.e2e.test.ts src/server/daemon-e2e/models.e2e.test.ts src/server/daemon-e2e/live-preferences.e2e.test.ts src/server/daemon-e2e/oversized-message.e2e.test.ts src/server/agent/model-catalog.e2e.test.ts src/server/plugins/plugin-paseo-api.e2e.test.ts src/server/plugins/usage-sources.e2e.test.ts src/server/plugins/lifecycle.e2e.test.ts src/server/connected-hosts/hosts.e2e.test.ts src/server/owned-subscriptions.e2e.test.ts && vitest run --maxWorkers=1 src/server/agent/providers/omp/history.e2e.test.ts src/server/daemon-e2e/git-operations.e2e.test.ts src/server/session.create-agent-worktree-autoarchive.e2e.test.ts src/server/agent/providers/opencode-bridge.local.e2e.test.ts src/server/daemon-e2e/relay-transport.e2e.test.ts -t \"merges into|creates a worktree and auto-archives both|versioned runtime|oversized attachments|connected hosts|password-protected daemon refuses|replays selected OMP history\"",
|
"test:integration": "vitest run --maxWorkers=1 src/server/git-filename-diffs.e2e.test.ts src/server/daemon-e2e/checkout-diff-content.e2e.test.ts src/server/daemon-e2e/filesystem.e2e.test.ts src/server/daemon-e2e/file-download.e2e.test.ts src/server/daemon-e2e/cli-logs-follow.e2e.test.ts src/server/creation/creation.e2e.test.ts src/server/daemon-e2e/creation-status.e2e.test.ts src/server/daemon-e2e/terminal-workspace-sdk.e2e.test.ts src/server/daemon-e2e/terminal-worker-lifecycle.e2e.test.ts src/server/workspace-create-worktree-source.e2e.test.ts src/server/daemon-e2e/models.e2e.test.ts src/server/daemon-e2e/live-preferences.e2e.test.ts src/server/daemon-e2e/oversized-message.e2e.test.ts src/server/agent/model-catalog.e2e.test.ts src/server/plugins/plugin-paseo-api.e2e.test.ts src/server/plugins/usage-sources.e2e.test.ts src/server/plugins/lifecycle.e2e.test.ts src/server/connected-hosts/hosts.e2e.test.ts src/server/owned-subscriptions.e2e.test.ts && vitest run --maxWorkers=1 src/server/agent/providers/omp/history.e2e.test.ts src/server/daemon-e2e/git-operations.e2e.test.ts src/server/session.create-agent-worktree-autoarchive.e2e.test.ts src/server/agent/providers/opencode-bridge.local.e2e.test.ts src/server/daemon-e2e/relay-transport.e2e.test.ts -t \"merges into|creates a worktree and auto-archives both|versioned runtime|oversized attachments|connected hosts|password-protected daemon refuses|replays selected OMP history\"",
|
||||||
"test:hub-cli-contract": "vitest run --maxWorkers=1 src/server/hub/hub-cli-contract.test.ts",
|
"test:hub-cli-contract": "vitest run --maxWorkers=1 src/server/hub/hub-cli-contract.test.ts",
|
||||||
"test:integration:all": "npm run test:e2e",
|
"test:integration:all": "npm run test:e2e",
|
||||||
"test:integration:real": "vitest run real.e2e.test.ts",
|
"test:integration:real": "vitest run real.e2e.test.ts",
|
||||||
|
|||||||
@@ -78,7 +78,7 @@ describe("resolveAndValidateCreateAgentMode", () => {
|
|||||||
availableModes: OPENCODE_MODES,
|
availableModes: OPENCODE_MODES,
|
||||||
}),
|
}),
|
||||||
).toThrow(
|
).toThrow(
|
||||||
"cannot inherit mode 'bypassPermissions' from caller (provider 'claude') for new agent (provider 'opencode'). Pass an explicit mode in settings.modeId. Available modes for 'opencode': build, plan",
|
"cannot inherit mode 'bypassPermissions' from caller (provider 'claude') for new agent (provider 'opencode'). Pass an explicit mode. Available modes for 'opencode': build, plan",
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -92,7 +92,7 @@ describe("resolveAndValidateCreateAgentMode", () => {
|
|||||||
availableModes: CODEX_MODES,
|
availableModes: CODEX_MODES,
|
||||||
}),
|
}),
|
||||||
).toThrow(
|
).toThrow(
|
||||||
"cannot inherit mode '<none>' from caller (provider 'opencode') for new agent (provider 'codex'). Pass an explicit mode in settings.modeId. Available modes for 'codex': auto, full-access",
|
"cannot inherit mode '<none>' from caller (provider 'opencode') for new agent (provider 'codex'). Pass an explicit mode. Available modes for 'codex': auto, full-access",
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -180,7 +180,7 @@ describe("resolveAndValidateCreateAgentMode", () => {
|
|||||||
targetUnattendedMode: "full-access",
|
targetUnattendedMode: "full-access",
|
||||||
}),
|
}),
|
||||||
).toThrow(
|
).toThrow(
|
||||||
"cannot inherit mode 'default' from caller (provider 'claude') for new agent (provider 'codex'). Pass an explicit mode in settings.modeId. Available modes for 'codex': auto, full-access",
|
"cannot inherit mode 'default' from caller (provider 'claude') for new agent (provider 'codex'). Pass an explicit mode. Available modes for 'codex': auto, full-access",
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -195,7 +195,7 @@ describe("resolveAndValidateCreateAgentMode", () => {
|
|||||||
targetUnattendedMode: undefined,
|
targetUnattendedMode: undefined,
|
||||||
}),
|
}),
|
||||||
).toThrow(
|
).toThrow(
|
||||||
"cannot inherit mode 'bypassPermissions' from caller (provider 'claude') for new agent (provider 'zai-custom'). Pass an explicit mode in settings.modeId. Available modes for 'zai-custom': unknown",
|
"cannot inherit mode 'bypassPermissions' from caller (provider 'claude') for new agent (provider 'zai-custom'). Pass an explicit mode. Available modes for 'zai-custom': unknown",
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -77,7 +77,7 @@ export function resolveAndValidateCreateAgentMode(
|
|||||||
}
|
}
|
||||||
|
|
||||||
throw new Error(
|
throw new Error(
|
||||||
`cannot inherit mode '${formatCreateConfigParentMode(parent)}' from ${formatCreateConfigParentSource(parent)} for new agent (provider '${targetProvider}'). Pass an explicit mode in settings.modeId. Available modes for '${targetProvider}': ${listModes(availableModes)}`,
|
`cannot inherit mode '${formatCreateConfigParentMode(parent)}' from ${formatCreateConfigParentSource(parent)} for new agent (provider '${targetProvider}'). Pass an explicit mode. Available modes for '${targetProvider}': ${listModes(availableModes)}`,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,190 +0,0 @@
|
|||||||
import { expect, test } from "vitest";
|
|
||||||
import { createTestLogger } from "../../../../test-utils/test-logger.js";
|
|
||||||
import { collectSessionTurnEvents } from "../test-utils/session-stream-adapter.js";
|
|
||||||
import { ClaudeAgentClient } from "./agent.js";
|
|
||||||
import type { ClaudeQueryFactory } from "./query.js";
|
|
||||||
|
|
||||||
const LIMIT = "You've hit your session limit · resets 5:20pm (Asia/Singapore)";
|
|
||||||
|
|
||||||
interface ScriptedTurn {
|
|
||||||
// An API error reply as Claude Code writes it: a synthetic assistant message with `error`.
|
|
||||||
errorReply?: string;
|
|
||||||
// Assistant text the model wrote before or after it.
|
|
||||||
before?: string;
|
|
||||||
after?: string;
|
|
||||||
isError: boolean;
|
|
||||||
usage?: {
|
|
||||||
inputTokens: number;
|
|
||||||
cachedInputTokens: number;
|
|
||||||
outputTokens: number;
|
|
||||||
totalCostUsd: number;
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
// A query dependency that speaks Claude Code's stream interface, one scripted turn per prompt.
|
|
||||||
function scriptedQuery(turns: ScriptedTurn[]): ClaudeQueryFactory {
|
|
||||||
return ({ prompt }) => {
|
|
||||||
let index = 0;
|
|
||||||
const assistant = (id: string, text: string, error?: string) => ({
|
|
||||||
type: "assistant",
|
|
||||||
session_id: "error-session",
|
|
||||||
parent_tool_use_id: null,
|
|
||||||
uuid: id,
|
|
||||||
message: {
|
|
||||||
id,
|
|
||||||
role: "assistant",
|
|
||||||
model: error ? "<synthetic>" : "claude-opus-5-5",
|
|
||||||
content: [{ type: "text", text }],
|
|
||||||
stop_reason: "stop_sequence",
|
|
||||||
usage: { input_tokens: 0, output_tokens: 0 },
|
|
||||||
},
|
|
||||||
...(error ? { error } : {}),
|
|
||||||
});
|
|
||||||
const messages = (async function* () {
|
|
||||||
if (typeof prompt === "string") throw new Error("Expected streaming input");
|
|
||||||
for await (const _message of prompt) {
|
|
||||||
const turn = turns[index];
|
|
||||||
index += 1;
|
|
||||||
yield {
|
|
||||||
type: "system",
|
|
||||||
subtype: "init",
|
|
||||||
session_id: "error-session",
|
|
||||||
permissionMode: "default",
|
|
||||||
model: "claude-opus-5-5",
|
|
||||||
};
|
|
||||||
if (turn.before) yield assistant(`before-${index}`, turn.before);
|
|
||||||
if (turn.errorReply) yield assistant(`error-${index}`, turn.errorReply, "rate_limit");
|
|
||||||
if (turn.after) yield assistant(`after-${index}`, turn.after);
|
|
||||||
yield {
|
|
||||||
type: "result",
|
|
||||||
subtype: "success",
|
|
||||||
session_id: "error-session",
|
|
||||||
duration_ms: 1,
|
|
||||||
duration_api_ms: 0,
|
|
||||||
is_error: turn.isError,
|
|
||||||
num_turns: 1,
|
|
||||||
result: turn.isError ? (turn.errorReply ?? "") : (turn.after ?? turn.errorReply ?? ""),
|
|
||||||
stop_reason: "stop_sequence",
|
|
||||||
total_cost_usd: turn.usage?.totalCostUsd ?? 0,
|
|
||||||
usage: {
|
|
||||||
input_tokens: turn.usage?.inputTokens ?? 0,
|
|
||||||
output_tokens: turn.usage?.outputTokens ?? 0,
|
|
||||||
cache_read_input_tokens: turn.usage?.cachedInputTokens ?? 0,
|
|
||||||
},
|
|
||||||
modelUsage: {},
|
|
||||||
permission_denials: [],
|
|
||||||
uuid: `result-${index}`,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
})();
|
|
||||||
return Object.assign(messages, {
|
|
||||||
close: () => {
|
|
||||||
void messages.return();
|
|
||||||
},
|
|
||||||
interrupt: async () => undefined,
|
|
||||||
setModel: async () => undefined,
|
|
||||||
setPermissionMode: async () => undefined,
|
|
||||||
supportedModels: async () => [],
|
|
||||||
supportedCommands: async () => [],
|
|
||||||
}) as ReturnType<ClaudeQueryFactory>;
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
async function runTurns(turns: ScriptedTurn[]) {
|
|
||||||
const client = new ClaudeAgentClient({
|
|
||||||
logger: createTestLogger(),
|
|
||||||
queryFactory: scriptedQuery(turns),
|
|
||||||
resolveBinary: async () => process.execPath,
|
|
||||||
});
|
|
||||||
const session = await client.createSession({
|
|
||||||
provider: "claude",
|
|
||||||
cwd: process.cwd(),
|
|
||||||
});
|
|
||||||
try {
|
|
||||||
const results = [];
|
|
||||||
for (const [index] of turns.entries()) {
|
|
||||||
results.push(await collectSessionTurnEvents(session, `turn ${index + 1}`));
|
|
||||||
}
|
|
||||||
return results;
|
|
||||||
} finally {
|
|
||||||
await session.close();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function assistantTexts(events: Awaited<ReturnType<typeof runTurns>>[number]): string[] {
|
|
||||||
return events.flatMap((event) =>
|
|
||||||
event.type === "timeline" && event.item.type === "assistant_message" ? [event.item.text] : [],
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
function terminal(events: Awaited<ReturnType<typeof runTurns>>[number]) {
|
|
||||||
return events.find(
|
|
||||||
(event) =>
|
|
||||||
event.type === "turn_completed" ||
|
|
||||||
event.type === "turn_failed" ||
|
|
||||||
event.type === "turn_canceled",
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
test("a turn Claude Code ends on an API error fails with the error, shown once", async () => {
|
|
||||||
const [limited, overloaded, next] = await runTurns([
|
|
||||||
{ errorReply: LIMIT, isError: true },
|
|
||||||
{
|
|
||||||
before: "Checking the deploy doc.",
|
|
||||||
errorReply: "API Error: 529 Overloaded",
|
|
||||||
isError: true,
|
|
||||||
},
|
|
||||||
{ after: "Done.", isError: false },
|
|
||||||
]);
|
|
||||||
|
|
||||||
expect(terminal(limited)).toMatchObject({
|
|
||||||
type: "turn_failed",
|
|
||||||
error: LIMIT,
|
|
||||||
});
|
|
||||||
expect(assistantTexts(limited)).toEqual([]);
|
|
||||||
|
|
||||||
expect(terminal(overloaded)).toMatchObject({
|
|
||||||
type: "turn_failed",
|
|
||||||
error: "API Error: 529 Overloaded",
|
|
||||||
});
|
|
||||||
expect(assistantTexts(overloaded)).toEqual(["Checking the deploy doc."]);
|
|
||||||
|
|
||||||
// The session keeps working after a failed turn.
|
|
||||||
expect(terminal(next)).toMatchObject({ type: "turn_completed" });
|
|
||||||
expect(assistantTexts(next)).toEqual(["Done."]);
|
|
||||||
});
|
|
||||||
|
|
||||||
test("a failed turn retains its final token counts and cost", async () => {
|
|
||||||
const usage = {
|
|
||||||
inputTokens: 120,
|
|
||||||
cachedInputTokens: 80,
|
|
||||||
outputTokens: 30,
|
|
||||||
totalCostUsd: 0.012,
|
|
||||||
};
|
|
||||||
const [failed] = await runTurns([
|
|
||||||
{ before: "Checking the deploy doc.", errorReply: LIMIT, isError: true, usage },
|
|
||||||
]);
|
|
||||||
|
|
||||||
expect(failed).toContainEqual(
|
|
||||||
expect.objectContaining({ type: "usage_updated", usage: expect.objectContaining(usage) }),
|
|
||||||
);
|
|
||||||
expect(terminal(failed)).toMatchObject({ type: "turn_failed", error: LIMIT });
|
|
||||||
expect(assistantTexts(failed)).toEqual(["Checking the deploy doc."]);
|
|
||||||
});
|
|
||||||
|
|
||||||
test("an error reply the turn moves past is still shown", async () => {
|
|
||||||
const [recovered, completed] = await runTurns([
|
|
||||||
{
|
|
||||||
errorReply: "API Error: 529 Overloaded",
|
|
||||||
after: "Recovered.",
|
|
||||||
isError: false,
|
|
||||||
},
|
|
||||||
{ errorReply: LIMIT, isError: false },
|
|
||||||
]);
|
|
||||||
|
|
||||||
expect(terminal(recovered)).toMatchObject({ type: "turn_completed" });
|
|
||||||
expect(assistantTexts(recovered)).toEqual(["API Error: 529 Overloaded", "Recovered."]);
|
|
||||||
|
|
||||||
expect(terminal(completed)).toMatchObject({ type: "turn_completed" });
|
|
||||||
expect(assistantTexts(completed)).toEqual([LIMIT]);
|
|
||||||
});
|
|
||||||
@@ -1,4 +1,3 @@
|
|||||||
import { constants as bufferConstants } from "node:buffer";
|
|
||||||
import * as fs from "node:fs/promises";
|
import * as fs from "node:fs/promises";
|
||||||
import * as os from "node:os";
|
import * as os from "node:os";
|
||||||
import * as path from "node:path";
|
import * as path from "node:path";
|
||||||
@@ -237,64 +236,4 @@ describe("importing a renamed Claude session", () => {
|
|||||||
|
|
||||||
await expect(importSession(sessionId)).rejects.toThrow();
|
await expect(importSession(sessionId)).rejects.toThrow();
|
||||||
});
|
});
|
||||||
|
|
||||||
test("reads the rename from a transcript too large to hold as one string", async () => {
|
|
||||||
const sessionId = "oversized-transcript";
|
|
||||||
const projectDir = claudeProjectDirSync(SESSION_CWD, { configDir });
|
|
||||||
await fs.mkdir(projectDir, { recursive: true });
|
|
||||||
const transcript = path.join(projectDir, `${sessionId}.jsonl`);
|
|
||||||
// A hole stands in for the long session this used to fail on: the file only
|
|
||||||
// has to be longer than a string can be, and the rename sits at its end.
|
|
||||||
await fs.writeFile(transcript, "");
|
|
||||||
await fs.truncate(transcript, bufferConstants.MAX_STRING_LENGTH + 1);
|
|
||||||
await fs.appendFile(
|
|
||||||
transcript,
|
|
||||||
`\n${[
|
|
||||||
userRecord({ sessionId, content: "Trace the slow query" }),
|
|
||||||
{ type: "custom-title", customTitle: "Slow query hunt", sessionId },
|
|
||||||
]
|
|
||||||
.map((record) => JSON.stringify(record))
|
|
||||||
.join("\n")}\n`,
|
|
||||||
);
|
|
||||||
|
|
||||||
const imported = await importSession(sessionId);
|
|
||||||
|
|
||||||
expect(imported.config.title).toBe("Slow query hunt");
|
|
||||||
});
|
|
||||||
|
|
||||||
test("reads the rename from behind more records than one read covers", async () => {
|
|
||||||
const sessionId = "late-records-rename";
|
|
||||||
await writeSession({
|
|
||||||
sessionId,
|
|
||||||
records: [
|
|
||||||
userRecord({ sessionId, content: "Review this project" }),
|
|
||||||
{ type: "custom-title", customTitle: "Scrollback review", sessionId },
|
|
||||||
// One record longer than a single read, then enough records to leave the
|
|
||||||
// rename several reads back from the end of the file.
|
|
||||||
userRecord({ sessionId, content: "x".repeat(128 * 1024) }),
|
|
||||||
...Array.from({ length: 64 }, (_, index) =>
|
|
||||||
userRecord({ sessionId, content: `${"y".repeat(4 * 1024)} ${index}` }),
|
|
||||||
),
|
|
||||||
],
|
|
||||||
});
|
|
||||||
|
|
||||||
const imported = await importSession(sessionId);
|
|
||||||
|
|
||||||
expect(imported.config.title).toBe("Scrollback review");
|
|
||||||
});
|
|
||||||
|
|
||||||
test("imports a large record after the last rename without repeatedly copying it", async () => {
|
|
||||||
const sessionId = "large-record-after-rename";
|
|
||||||
await writeSession({
|
|
||||||
sessionId,
|
|
||||||
records: [
|
|
||||||
{ type: "custom-title", customTitle: "Large attachment review", sessionId },
|
|
||||||
userRecord({ sessionId, content: "x".repeat(96 * 1024 * 1024) }),
|
|
||||||
],
|
|
||||||
});
|
|
||||||
|
|
||||||
const imported = await importSession(sessionId);
|
|
||||||
|
|
||||||
expect(imported.config.title).toBe("Large attachment review");
|
|
||||||
});
|
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1,48 +0,0 @@
|
|||||||
import pino from "pino";
|
|
||||||
import { describe, expect, test } from "vitest";
|
|
||||||
|
|
||||||
import { ClaudeAgentClient } from "./agent.js";
|
|
||||||
|
|
||||||
describe("Claude runtime info before a turn", () => {
|
|
||||||
const client = new ClaudeAgentClient({ logger: pino({ level: "silent" }) });
|
|
||||||
|
|
||||||
test("reports the explicitly configured model before the first turn", async () => {
|
|
||||||
const session = await client.createSession({
|
|
||||||
provider: "claude",
|
|
||||||
cwd: process.cwd(),
|
|
||||||
model: "claude-haiku-5-5",
|
|
||||||
});
|
|
||||||
try {
|
|
||||||
expect(await session.getRuntimeInfo()).toMatchObject({ model: "claude-haiku-5-5" });
|
|
||||||
} finally {
|
|
||||||
await session.close();
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
test("reports the persisted configured model after rebuilding an idle session", async () => {
|
|
||||||
const session = await client.resumeSession({
|
|
||||||
provider: "claude",
|
|
||||||
sessionId: "runtime-model-before-turn",
|
|
||||||
metadata: { cwd: process.cwd(), model: "claude-haiku-5-5" },
|
|
||||||
});
|
|
||||||
try {
|
|
||||||
expect(await session.getRuntimeInfo()).toMatchObject({
|
|
||||||
sessionId: "runtime-model-before-turn",
|
|
||||||
model: "claude-haiku-5-5",
|
|
||||||
});
|
|
||||||
expect((await session.getRuntimeInfo()).model).toBe("claude-haiku-5-5");
|
|
||||||
expect((await session.getRuntimeInfo()).extra?.runtimeModel).toBeUndefined();
|
|
||||||
} finally {
|
|
||||||
await session.close();
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
test("leaves the model unknown when Claude will choose its default", async () => {
|
|
||||||
const session = await client.createSession({ provider: "claude", cwd: process.cwd() });
|
|
||||||
try {
|
|
||||||
expect((await session.getRuntimeInfo()).model).toBeNull();
|
|
||||||
} finally {
|
|
||||||
await session.close();
|
|
||||||
}
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@@ -2389,12 +2389,6 @@ class ClaudeAgentSession implements AgentSession {
|
|||||||
private mainTurnInFlight = false;
|
private mainTurnInFlight = false;
|
||||||
private foregroundHasVisibleActivity = false;
|
private foregroundHasVisibleActivity = false;
|
||||||
private activeTurnHasAssistantText = false;
|
private activeTurnHasAssistantText = false;
|
||||||
/**
|
|
||||||
* The reply Claude Code writes when a turn stops on an API error ("You've hit your session
|
|
||||||
* limit", "API Error: 529 ..."), held back until the result says whether the turn failed: a
|
|
||||||
* failed turn shows the same text as its error, so showing both would repeat it.
|
|
||||||
*/
|
|
||||||
private withheldErrorReply: AgentTimelineItem[] = [];
|
|
||||||
private readonly contextUsage: ClaudeContextUsageState;
|
private readonly contextUsage: ClaudeContextUsageState;
|
||||||
private userMessageIds: string[] = [];
|
private userMessageIds: string[] = [];
|
||||||
private readonly emittedUserMessageIds = new Set<string>();
|
private readonly emittedUserMessageIds = new Set<string>();
|
||||||
@@ -2466,7 +2460,7 @@ class ClaudeAgentSession implements AgentSession {
|
|||||||
const info: AgentRuntimeInfo = {
|
const info: AgentRuntimeInfo = {
|
||||||
provider: "claude",
|
provider: "claude",
|
||||||
sessionId: this.claudeSessionId,
|
sessionId: this.claudeSessionId,
|
||||||
model: this.lastOptionsModel ?? this.config.model ?? null,
|
model: this.lastOptionsModel,
|
||||||
modeId: this.currentMode ?? null,
|
modeId: this.currentMode ?? null,
|
||||||
...(this.lastRuntimeModel
|
...(this.lastRuntimeModel
|
||||||
? {
|
? {
|
||||||
@@ -2537,7 +2531,6 @@ class ClaudeAgentSession implements AgentSession {
|
|||||||
this.activeForegroundTurnId = turnId;
|
this.activeForegroundTurnId = turnId;
|
||||||
this.foregroundHasVisibleActivity = false;
|
this.foregroundHasVisibleActivity = false;
|
||||||
this.activeTurnHasAssistantText = false;
|
this.activeTurnHasAssistantText = false;
|
||||||
this.withheldErrorReply = [];
|
|
||||||
this.contextUsage.beginTurn();
|
this.contextUsage.beginTurn();
|
||||||
this.transitionTurnState("foreground", "foreground turn started");
|
this.transitionTurnState("foreground", "foreground turn started");
|
||||||
this.clearRecentStderr();
|
this.clearRecentStderr();
|
||||||
@@ -4426,21 +4419,6 @@ class ClaudeAgentSession implements AgentSession {
|
|||||||
}) satisfies AgentStreamEvent,
|
}) satisfies AgentStreamEvent,
|
||||||
);
|
);
|
||||||
|
|
||||||
if (!readClaudeParentToolUseId(message)) {
|
|
||||||
if (message.type === "assistant" && message.error) {
|
|
||||||
this.withheldErrorReply.push(...assistantTimelineEvents.map((event) => event.item));
|
|
||||||
return messageEvents;
|
|
||||||
}
|
|
||||||
if (
|
|
||||||
message.type === "assistant" ||
|
|
||||||
message.type === "user" ||
|
|
||||||
message.type === "stream_event"
|
|
||||||
) {
|
|
||||||
const released: AgentStreamEvent[] = [];
|
|
||||||
this.releaseWithheldErrorReply(released);
|
|
||||||
return [...released, ...messageEvents, ...assistantTimelineEvents];
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return [...messageEvents, ...assistantTimelineEvents];
|
return [...messageEvents, ...assistantTimelineEvents];
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -4988,23 +4966,6 @@ class ClaudeAgentSession implements AgentSession {
|
|||||||
events: AgentStreamEvent[],
|
events: AgentStreamEvent[],
|
||||||
): void {
|
): void {
|
||||||
const usage = this.convertUsage(message);
|
const usage = this.convertUsage(message);
|
||||||
// Claude Code reports a turn that stopped on an API error (a usage limit, an overloaded or
|
|
||||||
// rejected request) as `success` with `is_error`, carrying the error text in `result`.
|
|
||||||
if (message.subtype === "success" && message.is_error) {
|
|
||||||
const withheld = this.withheldErrorReply;
|
|
||||||
this.withheldErrorReply = [];
|
|
||||||
const resultText = typeof message.result === "string" ? message.result.trim() : "";
|
|
||||||
const withheldText = withheld
|
|
||||||
.map((item) => (item.type === "assistant_message" ? item.text : ""))
|
|
||||||
.join("\n")
|
|
||||||
.trim();
|
|
||||||
if (usage) events.push({ type: "usage_updated", provider: "claude", usage });
|
|
||||||
events.push(...this.sidechainTracker.finishAll("failed"));
|
|
||||||
events.push(this.buildTurnFailedEvent(resultText || withheldText || "Claude run failed"));
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
const releasedErrorReply = this.withheldErrorReply.length > 0;
|
|
||||||
this.releaseWithheldErrorReply(events);
|
|
||||||
if (message.subtype === "success") {
|
if (message.subtype === "success") {
|
||||||
events.push(...this.sidechainTracker.finishAll("completed"));
|
events.push(...this.sidechainTracker.finishAll("completed"));
|
||||||
// Built-in slash commands (e.g. /voice, /usage, "Unknown command: …")
|
// Built-in slash commands (e.g. /voice, /usage, "Unknown command: …")
|
||||||
@@ -5014,12 +4975,7 @@ class ClaudeAgentSession implements AgentSession {
|
|||||||
// accounting from provider gateways does not duplicate streamed output.
|
// accounting from provider gateways does not duplicate streamed output.
|
||||||
const resultText = typeof message.result === "string" ? message.result.trim() : "";
|
const resultText = typeof message.result === "string" ? message.result.trim() : "";
|
||||||
const outputTokens = message.usage?.output_tokens;
|
const outputTokens = message.usage?.output_tokens;
|
||||||
if (
|
if (resultText.length > 0 && outputTokens === 0 && !this.activeTurnHasAssistantText) {
|
||||||
resultText.length > 0 &&
|
|
||||||
outputTokens === 0 &&
|
|
||||||
!this.activeTurnHasAssistantText &&
|
|
||||||
!releasedErrorReply
|
|
||||||
) {
|
|
||||||
events.push({
|
events.push({
|
||||||
type: "timeline",
|
type: "timeline",
|
||||||
provider: "claude",
|
provider: "claude",
|
||||||
@@ -5041,14 +4997,6 @@ class ClaudeAgentSession implements AgentSession {
|
|||||||
events.push(this.buildTurnFailedEvent(errorMessage));
|
events.push(this.buildTurnFailedEvent(errorMessage));
|
||||||
}
|
}
|
||||||
|
|
||||||
private releaseWithheldErrorReply(events: AgentStreamEvent[]): void {
|
|
||||||
if (this.withheldErrorReply.length === 0) return;
|
|
||||||
for (const item of this.withheldErrorReply) {
|
|
||||||
events.push({ type: "timeline", item, provider: "claude" });
|
|
||||||
}
|
|
||||||
this.withheldErrorReply = [];
|
|
||||||
}
|
|
||||||
|
|
||||||
private createClaudeSessionChangedNotice(
|
private createClaudeSessionChangedNotice(
|
||||||
oldSessionId: string,
|
oldSessionId: string,
|
||||||
newSessionId: string,
|
newSessionId: string,
|
||||||
@@ -6905,79 +6853,40 @@ function normalizeClaudeSessionTitle(title: string | null): string | null {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const CLAUDE_CUSTOM_TITLE_TYPE = "custom-title";
|
const CLAUDE_CUSTOM_TITLE_TYPE = "custom-title";
|
||||||
const CLAUDE_CUSTOM_TITLE_NEEDLE = Buffer.from(`"${CLAUDE_CUSTOM_TITLE_TYPE}"`);
|
|
||||||
const CLAUDE_TITLE_SCAN_CHUNK_BYTES = 64 * 1024;
|
|
||||||
|
|
||||||
function readClaudeCustomTitleFromRecord(record: Buffer): string | null {
|
|
||||||
// The needle also matches a prompt that quotes it, so a record still has to
|
|
||||||
// parse as a `custom-title` entry carrying a usable name. Records without the
|
|
||||||
// needle are never decoded, so a long one costs a byte search and nothing more.
|
|
||||||
if (!record.includes(CLAUDE_CUSTOM_TITLE_NEEDLE)) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
let parsed: unknown;
|
|
||||||
try {
|
|
||||||
parsed = JSON.parse(record.toString("utf8").trim());
|
|
||||||
} catch {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
const entry = toObjectRecord(parsed);
|
|
||||||
if (!entry || entry.isSidechain || entry.type !== CLAUDE_CUSTOM_TITLE_TYPE) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
return normalizeClaudeSessionTitle(
|
|
||||||
typeof entry.customTitle === "string" ? entry.customTitle : null,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* The `custom-title` record is re-emitted every time the session is saved, so
|
* The `custom-title` record is re-emitted every time the session is saved, so
|
||||||
* the current name is the last usable one in the file. Reading backwards a chunk
|
* the current name is the last usable one in the file. Searching backwards finds it without
|
||||||
* at a time finds it without walking the transcript, which matters because
|
* walking the transcript, which matters because sessions reach tens of
|
||||||
* sessions reach tens of megabytes, and never holds the file as one string, so a
|
* megabytes.
|
||||||
* transcript past V8's maximum string length still imports.
|
|
||||||
*/
|
*/
|
||||||
async function readLastClaudeCustomTitle(filePath: string): Promise<string | null> {
|
function readLastClaudeCustomTitle(content: string): string | null {
|
||||||
const handle = await fsPromises.open(filePath, "r");
|
const needle = `"${CLAUDE_CUSTOM_TITLE_TYPE}"`;
|
||||||
try {
|
let at = content.lastIndexOf(needle);
|
||||||
const stats = await handle.stat();
|
while (at >= 0) {
|
||||||
if (!stats.isFile()) {
|
const lineStart = content.lastIndexOf("\n", at) + 1;
|
||||||
// Opening a directory succeeds on Windows even though reading it does not,
|
const lineEnd = content.indexOf("\n", at);
|
||||||
// so reject the way reading the transcript as a file would.
|
const line = lineEnd === -1 ? content.slice(lineStart) : content.slice(lineStart, lineEnd);
|
||||||
throw Object.assign(
|
let parsed: unknown;
|
||||||
new Error(`EISDIR: illegal operation on a directory, read '${filePath}'`),
|
try {
|
||||||
{ code: "EISDIR", path: filePath },
|
parsed = JSON.parse(line.trim());
|
||||||
|
} catch {
|
||||||
|
parsed = undefined;
|
||||||
|
}
|
||||||
|
const entry = toObjectRecord(parsed);
|
||||||
|
if (entry && !entry.isSidechain && entry.type === CLAUDE_CUSTOM_TITLE_TYPE) {
|
||||||
|
const title = normalizeClaudeSessionTitle(
|
||||||
|
typeof entry.customTitle === "string" ? entry.customTitle : null,
|
||||||
);
|
);
|
||||||
}
|
if (title) {
|
||||||
let end = stats.size;
|
return title;
|
||||||
// Retain unfinished record pieces in reverse file order. Assemble each
|
|
||||||
// complete record once, so a large tool/image record costs linear work.
|
|
||||||
let pieces: Buffer[] = [];
|
|
||||||
while (end > 0) {
|
|
||||||
const length = Math.min(CLAUDE_TITLE_SCAN_CHUNK_BYTES, end);
|
|
||||||
const chunk = Buffer.alloc(length);
|
|
||||||
await handle.read(chunk, 0, length, end - length);
|
|
||||||
let recordEnd = chunk.length;
|
|
||||||
for (
|
|
||||||
let at = chunk.lastIndexOf(10);
|
|
||||||
at >= 0;
|
|
||||||
at = at === 0 ? -1 : chunk.lastIndexOf(10, at - 1)
|
|
||||||
) {
|
|
||||||
pieces.push(chunk.subarray(at + 1, recordEnd));
|
|
||||||
const record = Buffer.concat(pieces.toReversed());
|
|
||||||
const title = readClaudeCustomTitleFromRecord(record);
|
|
||||||
if (title) return title;
|
|
||||||
pieces = [];
|
|
||||||
recordEnd = at;
|
|
||||||
}
|
}
|
||||||
if (recordEnd > 0) pieces.push(chunk.subarray(0, recordEnd));
|
|
||||||
end -= length;
|
|
||||||
}
|
}
|
||||||
// The first record in the file has no newline in front of it.
|
// The needle also matches a prompt that quotes it, and a record can carry no
|
||||||
return readClaudeCustomTitleFromRecord(Buffer.concat(pieces.toReversed()));
|
// usable name, so keep looking backwards for one that does.
|
||||||
} finally {
|
at = at <= 0 ? -1 : content.lastIndexOf(needle, at - 1);
|
||||||
await handle.close();
|
|
||||||
}
|
}
|
||||||
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
function isErrnoException(error: unknown): error is NodeJS.ErrnoException {
|
function isErrnoException(error: unknown): error is NodeJS.ErrnoException {
|
||||||
@@ -6989,8 +6898,9 @@ async function readClaudeRenamedSessionTitle(input: {
|
|||||||
sessionId: string;
|
sessionId: string;
|
||||||
configDir: string;
|
configDir: string;
|
||||||
}): Promise<string | null> {
|
}): Promise<string | null> {
|
||||||
|
let content: string;
|
||||||
try {
|
try {
|
||||||
return await readLastClaudeCustomTitle(await claudeTranscriptPath(input));
|
content = await fsPromises.readFile(await claudeTranscriptPath(input), "utf8");
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
// A session with no transcript on disk has no rename to carry over. Every
|
// A session with no transcript on disk has no rename to carry over. Every
|
||||||
// other read failure is a real fault and stays explicit.
|
// other read failure is a real fault and stays explicit.
|
||||||
@@ -6999,6 +6909,7 @@ async function readClaudeRenamedSessionTitle(input: {
|
|||||||
}
|
}
|
||||||
throw error;
|
throw error;
|
||||||
}
|
}
|
||||||
|
return readLastClaudeCustomTitle(content);
|
||||||
}
|
}
|
||||||
|
|
||||||
function normalizeImportablePromptPreview(text: string): string | null {
|
function normalizeImportablePromptPreview(text: string): string | null {
|
||||||
|
|||||||
@@ -150,15 +150,6 @@ function isCodexAlreadyUnarchivedError(error: unknown, threadId: string): boolea
|
|||||||
|
|
||||||
const TURN_START_TIMEOUT_MS = 90 * 1000;
|
const TURN_START_TIMEOUT_MS = 90 * 1000;
|
||||||
const INTERRUPT_TIMEOUT_MS = 2_000;
|
const INTERRUPT_TIMEOUT_MS = 2_000;
|
||||||
// `turn/interrupt` ends a turn but leaves the shell commands it started running.
|
|
||||||
// Each terminate is bounded by the first constant. A turn start waits for the
|
|
||||||
// interrupted turn's commands to end, at most the interrupt's own bound plus one
|
|
||||||
// terminate's and a margin, so it gives up only when a request outlives its timeout.
|
|
||||||
const INTERRUPTED_TURN_TERMINATE_TIMEOUT_MS = 2_000;
|
|
||||||
const INTERRUPTED_TURN_CLEANUP_WAIT_MS =
|
|
||||||
INTERRUPT_TIMEOUT_MS + INTERRUPTED_TURN_TERMINATE_TIMEOUT_MS + 500;
|
|
||||||
// Turns whose commands are remembered; older turns are forgotten first.
|
|
||||||
const INTERRUPTED_TURN_TRACKED_TURNS = 16;
|
|
||||||
const CODEX_PROVIDER = "codex" as const;
|
const CODEX_PROVIDER = "codex" as const;
|
||||||
// These native provider identities belong to Codex, not to an older custom
|
// These native provider identities belong to Codex, not to an older custom
|
||||||
// Paseo provider. Never include them in a custom provider's legacy import scan.
|
// Paseo provider. Never include them in a custom provider's legacy import scan.
|
||||||
@@ -3485,14 +3476,6 @@ export class CodexAppServerAgentSession implements AgentSession {
|
|||||||
private pendingFileChangeOutputDeltas = new Map<string, string[]>();
|
private pendingFileChangeOutputDeltas = new Map<string, string[]>();
|
||||||
private pendingAssistantMessageBoundary = false;
|
private pendingAssistantMessageBoundary = false;
|
||||||
private terminalCommandByProcessId = new Map<string, string>();
|
private terminalCommandByProcessId = new Map<string, string>();
|
||||||
// The root thread's still-running command process ids, keyed by the native turn that
|
|
||||||
// started them. Codex's `thread/backgroundTerminals/list` carries no turn, so
|
|
||||||
// `item/started` is the only record of which turn owns a process.
|
|
||||||
private terminalProcessIdsByTurnId = new Map<
|
|
||||||
string,
|
|
||||||
{ processIds: Set<string>; terminatedProcessIds: Set<string>; interruptAccepted: boolean }
|
|
||||||
>();
|
|
||||||
private interruptedTurnCleanup: Promise<void> | null = null;
|
|
||||||
private pendingUnlabeledTerminalInteractions = new Map<
|
private pendingUnlabeledTerminalInteractions = new Map<
|
||||||
string,
|
string,
|
||||||
Array<{ callId: string; stdin: string | null }>
|
Array<{ callId: string; stdin: string | null }>
|
||||||
@@ -4448,9 +4431,6 @@ export class CodexAppServerAgentSession implements AgentSession {
|
|||||||
hasDeveloperInstructions: turnStart.hasDeveloperInstructions,
|
hasDeveloperInstructions: turnStart.hasDeveloperInstructions,
|
||||||
hasCodexConfig: turnStart.hasCodexConfig,
|
hasCodexConfig: turnStart.hasCodexConfig,
|
||||||
});
|
});
|
||||||
// The interrupted turn's commands end before this turn begins, so none of them
|
|
||||||
// outlives it into the new turn.
|
|
||||||
await this.waitForInterruptedTurnCleanup();
|
|
||||||
if (pendingStart.cancelRequested) {
|
if (pendingStart.cancelRequested) {
|
||||||
throw new Error("Codex turn start was interrupted before reaching Codex");
|
throw new Error("Codex turn start was interrupted before reaching Codex");
|
||||||
}
|
}
|
||||||
@@ -5056,27 +5036,15 @@ export class CodexAppServerAgentSession implements AgentSession {
|
|||||||
if (!turnId || (foregroundTurnId && this.activeForegroundTurnId !== foregroundTurnId)) {
|
if (!turnId || (foregroundTurnId && this.activeForegroundTurnId !== foregroundTurnId)) {
|
||||||
throw new Error("Cannot interrupt Codex before turn/started identifies the active turn");
|
throw new Error("Cannot interrupt Codex before turn/started identifies the active turn");
|
||||||
}
|
}
|
||||||
const client = this.client;
|
|
||||||
const threadId = this.currentThreadId;
|
|
||||||
const interruptRequest = client.request(
|
|
||||||
"turn/interrupt",
|
|
||||||
{
|
|
||||||
threadId,
|
|
||||||
turnId,
|
|
||||||
},
|
|
||||||
INTERRUPT_TIMEOUT_MS,
|
|
||||||
);
|
|
||||||
// Once Codex accepts the interrupt, end the commands this turn started. Registered
|
|
||||||
// before the await, so a turn start racing this interrupt waits for it. An already-idle
|
|
||||||
// turn was not interrupted, and what it left running stays.
|
|
||||||
this.trackInterruptedTurnCleanup(
|
|
||||||
interruptRequest.then(
|
|
||||||
() => this.endInterruptedTurnProcesses(client, threadId, turnId),
|
|
||||||
() => undefined,
|
|
||||||
),
|
|
||||||
);
|
|
||||||
try {
|
try {
|
||||||
await interruptRequest;
|
await this.client.request(
|
||||||
|
"turn/interrupt",
|
||||||
|
{
|
||||||
|
threadId: this.currentThreadId,
|
||||||
|
turnId,
|
||||||
|
},
|
||||||
|
INTERRUPT_TIMEOUT_MS,
|
||||||
|
);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
if (!isCodexAlreadyIdleInterrupt(error)) {
|
if (!isCodexAlreadyIdleInterrupt(error)) {
|
||||||
throw error;
|
throw error;
|
||||||
@@ -5117,137 +5085,6 @@ export class CodexAppServerAgentSession implements AgentSession {
|
|||||||
return !this.client.hasPendingRequests();
|
return !this.client.hasPendingRequests();
|
||||||
}
|
}
|
||||||
|
|
||||||
// Remember which turn started a command, so an interrupt ends that turn's commands and
|
|
||||||
// no other turn's. Only the root thread's: a subagent's commands live on its own thread.
|
|
||||||
// A command that starts after its turn's interrupt was accepted is ended as soon as it
|
|
||||||
// is seen.
|
|
||||||
private recordTurnTerminalProcess(
|
|
||||||
parsed: Extract<ParsedCodexNotification, { kind: "item_started" | "item_completed" }>,
|
|
||||||
): void {
|
|
||||||
const processId = parsed.item.processId;
|
|
||||||
if (
|
|
||||||
parsed.source !== "item" ||
|
|
||||||
!parsed.turnId ||
|
|
||||||
!parsed.threadId ||
|
|
||||||
parsed.threadId !== this.currentThreadId ||
|
|
||||||
typeof processId !== "string" ||
|
|
||||||
!processId ||
|
|
||||||
normalizeCodexThreadItemType(
|
|
||||||
typeof parsed.item.type === "string" ? parsed.item.type : undefined,
|
|
||||||
) !== "commandExecution"
|
|
||||||
) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
const record = this.terminalProcessIdsByTurnId.get(parsed.turnId);
|
|
||||||
if (parsed.kind === "item_completed") {
|
|
||||||
// Only a completed command has exited. A failed one can still be running:
|
|
||||||
// Codex reports an interrupted turn's live command as failed.
|
|
||||||
if (parsed.item.status === "completed") record?.processIds.delete(processId);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
const current = record ?? this.getTurnTerminalRecord(parsed.turnId);
|
|
||||||
current.processIds.add(processId);
|
|
||||||
if (current.interruptAccepted && this.client) {
|
|
||||||
this.trackInterruptedTurnCleanup(
|
|
||||||
this.endInterruptedTurnProcesses(this.client, parsed.threadId, parsed.turnId),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// An accepted interrupt needs a record even before any of its turn's commands is seen:
|
|
||||||
// an `item/started` can still be queued behind an earlier notification's conversion.
|
|
||||||
private getTurnTerminalRecord(turnId: string) {
|
|
||||||
const existing = this.terminalProcessIdsByTurnId.get(turnId);
|
|
||||||
if (existing) return existing;
|
|
||||||
const record = {
|
|
||||||
processIds: new Set<string>(),
|
|
||||||
terminatedProcessIds: new Set<string>(),
|
|
||||||
interruptAccepted: false,
|
|
||||||
};
|
|
||||||
this.terminalProcessIdsByTurnId.set(turnId, record);
|
|
||||||
for (const oldestTurnId of this.terminalProcessIdsByTurnId.keys()) {
|
|
||||||
if (this.terminalProcessIdsByTurnId.size <= INTERRUPTED_TURN_TRACKED_TURNS) break;
|
|
||||||
this.terminalProcessIdsByTurnId.delete(oldestTurnId);
|
|
||||||
}
|
|
||||||
return record;
|
|
||||||
}
|
|
||||||
|
|
||||||
// A later sweep joins the pending one, so a turn start waits for both.
|
|
||||||
private trackInterruptedTurnCleanup(cleanup: Promise<void>): void {
|
|
||||||
const tracked = Promise.all([this.interruptedTurnCleanup, cleanup])
|
|
||||||
.then(() => undefined)
|
|
||||||
.finally(() => {
|
|
||||||
if (this.interruptedTurnCleanup === tracked) this.interruptedTurnCleanup = null;
|
|
||||||
});
|
|
||||||
this.interruptedTurnCleanup = tracked;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Terminate each process the interrupted turn started, by its own id, once each.
|
|
||||||
// `thread/backgroundTerminals/clean` is never used: it ends every terminal on the thread,
|
|
||||||
// an earlier turn's included. A process already gone answers `terminated: false`. Never
|
|
||||||
// rejects; failures are logged.
|
|
||||||
private async endInterruptedTurnProcesses(
|
|
||||||
client: CodexAppServerClient,
|
|
||||||
threadId: string | null,
|
|
||||||
turnId: string,
|
|
||||||
): Promise<void> {
|
|
||||||
if (!threadId) return;
|
|
||||||
const record = this.getTurnTerminalRecord(turnId);
|
|
||||||
record.interruptAccepted = true;
|
|
||||||
const processIds = [...record.processIds].filter(
|
|
||||||
(processId) => !record.terminatedProcessIds.has(processId),
|
|
||||||
);
|
|
||||||
for (const processId of processIds) record.terminatedProcessIds.add(processId);
|
|
||||||
const logContext = { agentId: this.agentId, provider: CODEX_PROVIDER, threadId, turnId };
|
|
||||||
await Promise.all(
|
|
||||||
processIds.map(async (processId) => {
|
|
||||||
try {
|
|
||||||
const response = await client.request(
|
|
||||||
"thread/backgroundTerminals/terminate",
|
|
||||||
{ threadId, processId },
|
|
||||||
INTERRUPTED_TURN_TERMINATE_TIMEOUT_MS,
|
|
||||||
);
|
|
||||||
this.logger.info(
|
|
||||||
{ ...logContext, processId, terminated: toObjectRecord(response)?.terminated ?? null },
|
|
||||||
"provider.codex.interrupt.terminal_terminated",
|
|
||||||
);
|
|
||||||
} catch (error) {
|
|
||||||
this.logger.warn(
|
|
||||||
{ ...logContext, processId, err: error },
|
|
||||||
"provider.codex.interrupt.terminal_terminate_failed",
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
// A new turn starts only after every pending sweep of interrupted turns' commands has
|
|
||||||
// ended, or after the bound, whichever is first.
|
|
||||||
private async waitForInterruptedTurnCleanup(): Promise<void> {
|
|
||||||
if (!this.interruptedTurnCleanup) return;
|
|
||||||
let timer: ReturnType<typeof setTimeout> | undefined;
|
|
||||||
const timedOut = await Promise.race([
|
|
||||||
(async () => {
|
|
||||||
while (this.interruptedTurnCleanup) await this.interruptedTurnCleanup;
|
|
||||||
return false;
|
|
||||||
})(),
|
|
||||||
new Promise<boolean>((resolve) => {
|
|
||||||
timer = setTimeout(() => resolve(true), INTERRUPTED_TURN_CLEANUP_WAIT_MS);
|
|
||||||
}),
|
|
||||||
]);
|
|
||||||
clearTimeout(timer);
|
|
||||||
if (timedOut) {
|
|
||||||
this.logger.warn(
|
|
||||||
{
|
|
||||||
agentId: this.agentId,
|
|
||||||
provider: CODEX_PROVIDER,
|
|
||||||
waitMs: INTERRUPTED_TURN_CLEANUP_WAIT_MS,
|
|
||||||
},
|
|
||||||
"provider.codex.interrupt.terminal_cleanup_timed_out",
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async close(): Promise<void> {
|
async close(): Promise<void> {
|
||||||
this.closed = true;
|
this.closed = true;
|
||||||
this.clearPendingPermissions();
|
this.clearPendingPermissions();
|
||||||
@@ -6372,11 +6209,6 @@ export class CodexAppServerAgentSession implements AgentSession {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
this.completePendingRootCompactions();
|
this.completePendingRootCompactions();
|
||||||
// What a turn that was not interrupted leaves running, it left on purpose; no later
|
|
||||||
// interrupt may end it.
|
|
||||||
if (parsed.status !== "interrupted" && this.currentTurnId) {
|
|
||||||
this.terminalProcessIdsByTurnId.delete(this.currentTurnId);
|
|
||||||
}
|
|
||||||
if (parsed.status === "failed") {
|
if (parsed.status === "failed") {
|
||||||
this.emitEvent({
|
this.emitEvent({
|
||||||
type: "turn_failed",
|
type: "turn_failed",
|
||||||
@@ -6781,7 +6613,6 @@ export class CodexAppServerAgentSession implements AgentSession {
|
|||||||
private async handleItemCompletedNotification(
|
private async handleItemCompletedNotification(
|
||||||
parsed: Extract<ParsedCodexNotification, { kind: "item_completed" }>,
|
parsed: Extract<ParsedCodexNotification, { kind: "item_completed" }>,
|
||||||
): Promise<void> {
|
): Promise<void> {
|
||||||
this.recordTurnTerminalProcess(parsed);
|
|
||||||
// Codex emits mirrored lifecycle notifications via both `codex/event/item_*`
|
// Codex emits mirrored lifecycle notifications via both `codex/event/item_*`
|
||||||
// and canonical `item/*`. Render ordinary items only from the canonical
|
// and canonical `item/*`. Render ordinary items only from the canonical
|
||||||
// channel, but accept a legacy-only child announcement so it can establish
|
// channel, but accept a legacy-only child announcement so it can establish
|
||||||
@@ -6944,7 +6775,6 @@ export class CodexAppServerAgentSession implements AgentSession {
|
|||||||
private async handleItemStartedNotification(
|
private async handleItemStartedNotification(
|
||||||
parsed: Extract<ParsedCodexNotification, { kind: "item_started" }>,
|
parsed: Extract<ParsedCodexNotification, { kind: "item_started" }>,
|
||||||
): Promise<void> {
|
): Promise<void> {
|
||||||
this.recordTurnTerminalProcess(parsed);
|
|
||||||
if (shouldIgnoreMirroredLifecycleItem(parsed.source, parsed.item)) {
|
if (shouldIgnoreMirroredLifecycleItem(parsed.source, parsed.item)) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,374 +0,0 @@
|
|||||||
import { afterEach, describe, expect, test, vi } from "vitest";
|
|
||||||
|
|
||||||
import type { AgentSession } from "../agent-sdk-types.js";
|
|
||||||
import { createTestLogger } from "../../../test-utils/test-logger.js";
|
|
||||||
import { CodexAppServerAgentSession } from "./codex-app-server-agent.js";
|
|
||||||
import {
|
|
||||||
createFakeCodexAppServer,
|
|
||||||
type FakeCodexAppServer,
|
|
||||||
waitForNextEvent,
|
|
||||||
waitForTimelineToolCall,
|
|
||||||
} from "./codex/test-utils/fake-app-server.js";
|
|
||||||
|
|
||||||
const THREAD = "thread-1";
|
|
||||||
const TERMINATE = "thread/backgroundTerminals/terminate";
|
|
||||||
|
|
||||||
type Handler = (params: unknown) => unknown;
|
|
||||||
|
|
||||||
function deferred<T = void>() {
|
|
||||||
let resolve!: (value: T) => void;
|
|
||||||
const promise = new Promise<T>((resolvePromise) => {
|
|
||||||
resolve = resolvePromise;
|
|
||||||
});
|
|
||||||
return { promise, resolve };
|
|
||||||
}
|
|
||||||
|
|
||||||
function paramsOf(value: unknown): Record<string, unknown> {
|
|
||||||
return value && typeof value === "object" ? (value as Record<string, unknown>) : {};
|
|
||||||
}
|
|
||||||
|
|
||||||
let sessions: AgentSession[] = [];
|
|
||||||
|
|
||||||
afterEach(async () => {
|
|
||||||
for (const session of sessions) await session.close().catch(() => undefined);
|
|
||||||
sessions = [];
|
|
||||||
});
|
|
||||||
|
|
||||||
/**
|
|
||||||
* A Codex app-server whose interrupt ends the turn the way Codex does: an `interrupted`
|
|
||||||
* turn completion, then the response. `terminate` answers per process.
|
|
||||||
*/
|
|
||||||
function createHarness(
|
|
||||||
options: {
|
|
||||||
terminate?: (processId: string) => unknown;
|
|
||||||
interruptResponse?: (completeTurn: () => void) => unknown;
|
|
||||||
} = {},
|
|
||||||
) {
|
|
||||||
const handlers: Record<string, Handler> = {
|
|
||||||
"turn/interrupt": () => {
|
|
||||||
const completeTurn = () =>
|
|
||||||
appServer.completeTurn({ threadId: THREAD, status: "interrupted" });
|
|
||||||
if (options.interruptResponse) return options.interruptResponse(completeTurn);
|
|
||||||
completeTurn();
|
|
||||||
return {};
|
|
||||||
},
|
|
||||||
[TERMINATE]: (params) =>
|
|
||||||
options.terminate?.(String(paramsOf(params).processId)) ?? { terminated: true },
|
|
||||||
};
|
|
||||||
const appServer: FakeCodexAppServer = createFakeCodexAppServer(handlers);
|
|
||||||
const session = new CodexAppServerAgentSession(
|
|
||||||
{
|
|
||||||
provider: "codex",
|
|
||||||
cwd: "/workspace/project",
|
|
||||||
modeId: "auto",
|
|
||||||
model: "gpt-5.4",
|
|
||||||
},
|
|
||||||
null,
|
|
||||||
createTestLogger(),
|
|
||||||
async () => appServer.child,
|
|
||||||
);
|
|
||||||
sessions.push(session);
|
|
||||||
|
|
||||||
function requests(method: string): Record<string, unknown>[] {
|
|
||||||
return appServer
|
|
||||||
.requests()
|
|
||||||
.filter((message) => message.method === method && "id" in message)
|
|
||||||
.map((message) => paramsOf(message.params));
|
|
||||||
}
|
|
||||||
|
|
||||||
function terminated(): string[] {
|
|
||||||
return requests(TERMINATE)
|
|
||||||
.map((params) => String(params.processId))
|
|
||||||
.sort();
|
|
||||||
}
|
|
||||||
|
|
||||||
// Starts a turn and lets Codex identify it as `nativeTurnId`.
|
|
||||||
async function startTurn(prompt: string, nativeTurnId: string): Promise<void> {
|
|
||||||
const before = requests("turn/start").length;
|
|
||||||
const started = session.startTurn(prompt);
|
|
||||||
await vi.waitFor(() => expect(requests("turn/start")).toHaveLength(before + 1), {
|
|
||||||
timeout: 5_000,
|
|
||||||
});
|
|
||||||
const turnStarted = waitForNextEvent(session, "turn_started");
|
|
||||||
appServer.startsTurn({ threadId: THREAD, turnId: nativeTurnId });
|
|
||||||
await started;
|
|
||||||
await turnStarted;
|
|
||||||
}
|
|
||||||
|
|
||||||
async function runsCommand(params: { turnId: string; processId: string }): Promise<void> {
|
|
||||||
const itemId = `exec-${params.processId}`;
|
|
||||||
const shown = waitForTimelineToolCall(session, itemId);
|
|
||||||
appServer.startsTerminalCommand({
|
|
||||||
threadId: THREAD,
|
|
||||||
turnId: params.turnId,
|
|
||||||
itemId,
|
|
||||||
processId: params.processId,
|
|
||||||
command: `sleep ${params.processId}`,
|
|
||||||
});
|
|
||||||
await shown;
|
|
||||||
}
|
|
||||||
|
|
||||||
async function completesTurn(): Promise<void> {
|
|
||||||
const completed = waitForNextEvent(session, "turn_completed");
|
|
||||||
appServer.completeTurn({ threadId: THREAD });
|
|
||||||
await completed;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Interrupts the running turn and waits for the session to report it canceled, as a
|
|
||||||
// caller does before it starts the next turn: Codex notifications are handled in order
|
|
||||||
// but asynchronously, so the interrupt's answer can arrive before the turn has ended.
|
|
||||||
async function interrupts(): Promise<void> {
|
|
||||||
const canceled = waitForNextEvent(session, "turn_canceled");
|
|
||||||
await session.interrupt();
|
|
||||||
await canceled;
|
|
||||||
}
|
|
||||||
|
|
||||||
return {
|
|
||||||
appServer,
|
|
||||||
session,
|
|
||||||
requests,
|
|
||||||
terminated,
|
|
||||||
startTurn,
|
|
||||||
runsCommand,
|
|
||||||
completesTurn,
|
|
||||||
interrupts,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
// Turn A leaves a server running on purpose and completes. Turn B starts two
|
|
||||||
// commands, and a subagent on its own thread starts a third, then B is interrupted.
|
|
||||||
async function interruptTurnB(harness: ReturnType<typeof createHarness>): Promise<void> {
|
|
||||||
await harness.startTurn("start a server", "native-A");
|
|
||||||
await harness.runsCommand({ turnId: "native-A", processId: "101" });
|
|
||||||
await harness.completesTurn();
|
|
||||||
|
|
||||||
await harness.startTurn("run two commands", "native-B");
|
|
||||||
await harness.runsCommand({ turnId: "native-B", processId: "201" });
|
|
||||||
// A subagent thread's command shows no row here; the next command's row shows it
|
|
||||||
// was read, because notifications are read in order.
|
|
||||||
harness.appServer.startsTerminalCommand({
|
|
||||||
threadId: "child-thread",
|
|
||||||
turnId: "native-B",
|
|
||||||
itemId: "exec-209",
|
|
||||||
processId: "209",
|
|
||||||
command: "sleep 209",
|
|
||||||
});
|
|
||||||
await harness.runsCommand({ turnId: "native-B", processId: "202" });
|
|
||||||
await harness.interrupts();
|
|
||||||
}
|
|
||||||
|
|
||||||
describe("an interrupted Codex turn's commands end before the next turn", () => {
|
|
||||||
test("terminates exactly the processes the interrupted turn started, by id, and never cleans", async () => {
|
|
||||||
const harness = createHarness();
|
|
||||||
await interruptTurnB(harness);
|
|
||||||
await harness.startTurn("correction", "native-C");
|
|
||||||
|
|
||||||
expect(harness.terminated()).toEqual(["201", "202"]);
|
|
||||||
expect(harness.requests(TERMINATE).every((params) => params.threadId === THREAD)).toBe(true);
|
|
||||||
// `clean` would end every terminal on the thread, an earlier turn's included.
|
|
||||||
expect(
|
|
||||||
harness.appServer
|
|
||||||
.requests()
|
|
||||||
.filter((message) => String(message.method).startsWith("thread/backgroundTerminals/"))
|
|
||||||
.map((message) => message.method),
|
|
||||||
).toEqual([TERMINATE, TERMINATE]);
|
|
||||||
harness.appServer.assertNoErrors();
|
|
||||||
});
|
|
||||||
|
|
||||||
test("leaves the next turn's commands running when Codex starts that turn before acknowledging the interrupt", async () => {
|
|
||||||
const acknowledge = deferred();
|
|
||||||
const harness = createHarness({
|
|
||||||
// Codex ends B, starts a turn of its own (a goal continuation), and only then
|
|
||||||
// answers the interrupt.
|
|
||||||
interruptResponse: (completeTurn) => {
|
|
||||||
completeTurn();
|
|
||||||
return acknowledge.promise.then(() => ({}));
|
|
||||||
},
|
|
||||||
});
|
|
||||||
await harness.startTurn("run two commands", "native-B");
|
|
||||||
await harness.runsCommand({ turnId: "native-B", processId: "201" });
|
|
||||||
await harness.runsCommand({ turnId: "native-B", processId: "202" });
|
|
||||||
const interrupting = harness.session.interrupt();
|
|
||||||
await vi.waitFor(() => expect(harness.requests("turn/interrupt")).toHaveLength(1));
|
|
||||||
const autonomousTurn = waitForNextEvent(harness.session, "turn_started");
|
|
||||||
harness.appServer.startsTurn({ threadId: THREAD, turnId: "native-C" });
|
|
||||||
await autonomousTurn;
|
|
||||||
await harness.runsCommand({ turnId: "native-C", processId: "301" });
|
|
||||||
|
|
||||||
acknowledge.resolve();
|
|
||||||
await interrupting;
|
|
||||||
await vi.waitFor(() => expect(harness.terminated()).toEqual(["201", "202"]));
|
|
||||||
await new Promise((resolve) => setTimeout(resolve, 50));
|
|
||||||
expect(harness.terminated()).toEqual(["201", "202"]);
|
|
||||||
});
|
|
||||||
|
|
||||||
test("sends the next turn/start only after the terminates are answered", async () => {
|
|
||||||
const answer = deferred();
|
|
||||||
const harness = createHarness({
|
|
||||||
terminate: () => answer.promise.then(() => ({ terminated: true })),
|
|
||||||
});
|
|
||||||
await interruptTurnB(harness);
|
|
||||||
const next = harness.startTurn("correction", "native-C");
|
|
||||||
await vi.waitFor(() => expect(harness.terminated()).toEqual(["201", "202"]));
|
|
||||||
await new Promise((resolve) => setTimeout(resolve, 200));
|
|
||||||
expect(harness.requests("turn/start")).toHaveLength(2);
|
|
||||||
|
|
||||||
answer.resolve();
|
|
||||||
await next;
|
|
||||||
expect(harness.requests("turn/start")).toHaveLength(3);
|
|
||||||
});
|
|
||||||
|
|
||||||
test.each(["initial", "late"] as const)(
|
|
||||||
"tracks both cleanup sweeps when the %s answer arrives first",
|
|
||||||
async (answeredFirst) => {
|
|
||||||
const first = deferred();
|
|
||||||
const second = deferred();
|
|
||||||
const harness = createHarness({
|
|
||||||
terminate: (processId) =>
|
|
||||||
(processId === "201" ? first : second).promise.then(() => ({ terminated: true })),
|
|
||||||
});
|
|
||||||
await harness.startTurn("run commands", "native-B");
|
|
||||||
await harness.runsCommand({ turnId: "native-B", processId: "201" });
|
|
||||||
await harness.interrupts();
|
|
||||||
await vi.waitFor(() => expect(harness.terminated()).toEqual(["201"]));
|
|
||||||
const next = harness.startTurn("correction", "native-C");
|
|
||||||
await new Promise((resolve) => setTimeout(resolve, 100));
|
|
||||||
expect(harness.requests("turn/start")).toHaveLength(1);
|
|
||||||
// The next turn is already waiting when another process is announced.
|
|
||||||
harness.appServer.startsTerminalCommand({
|
|
||||||
threadId: THREAD,
|
|
||||||
turnId: "native-B",
|
|
||||||
itemId: "late-202",
|
|
||||||
processId: "202",
|
|
||||||
command: "sleep 202",
|
|
||||||
});
|
|
||||||
harness.appServer.startsTerminalCommand({
|
|
||||||
threadId: THREAD,
|
|
||||||
turnId: "native-B",
|
|
||||||
itemId: "duplicate-202",
|
|
||||||
processId: "202",
|
|
||||||
command: "sleep 202",
|
|
||||||
});
|
|
||||||
await vi.waitFor(() => expect(harness.terminated()).toEqual(["201", "202"]));
|
|
||||||
(answeredFirst === "initial" ? first : second).resolve();
|
|
||||||
await new Promise((resolve) => setTimeout(resolve, 100));
|
|
||||||
expect(harness.requests("turn/start")).toHaveLength(1);
|
|
||||||
(answeredFirst === "initial" ? second : first).resolve();
|
|
||||||
await next;
|
|
||||||
expect(harness.terminated()).toEqual(["201", "202"]);
|
|
||||||
// Accepted state survives the completed sweep and a newer foreground turn.
|
|
||||||
harness.appServer.startsTerminalCommand({
|
|
||||||
threadId: THREAD,
|
|
||||||
turnId: "native-B",
|
|
||||||
itemId: "late-203",
|
|
||||||
processId: "203",
|
|
||||||
command: "sleep 203",
|
|
||||||
});
|
|
||||||
await vi.waitFor(() => expect(harness.terminated()).toEqual(["201", "202", "203"]));
|
|
||||||
harness.appServer.assertNoErrors();
|
|
||||||
},
|
|
||||||
);
|
|
||||||
|
|
||||||
test("keeps the first sweep pending when a late sweep answers before the next start", async () => {
|
|
||||||
const first = deferred();
|
|
||||||
const harness = createHarness({
|
|
||||||
terminate: (processId) =>
|
|
||||||
processId === "201"
|
|
||||||
? first.promise.then(() => ({ terminated: true }))
|
|
||||||
: { terminated: true },
|
|
||||||
});
|
|
||||||
await harness.startTurn("run commands", "native-B");
|
|
||||||
await harness.runsCommand({ turnId: "native-B", processId: "201" });
|
|
||||||
await harness.interrupts();
|
|
||||||
harness.appServer.startsTerminalCommand({
|
|
||||||
threadId: THREAD,
|
|
||||||
turnId: "native-B",
|
|
||||||
itemId: "late-202",
|
|
||||||
processId: "202",
|
|
||||||
command: "sleep 202",
|
|
||||||
});
|
|
||||||
await vi.waitFor(() => expect(harness.terminated()).toEqual(["201", "202"]));
|
|
||||||
const next = harness.startTurn("correction", "native-C");
|
|
||||||
await new Promise((resolve) => setTimeout(resolve, 100));
|
|
||||||
expect(harness.requests("turn/start")).toHaveLength(1);
|
|
||||||
first.resolve();
|
|
||||||
await next;
|
|
||||||
harness.appServer.assertNoErrors();
|
|
||||||
});
|
|
||||||
|
|
||||||
test("forgets completed commands but retains failed commands at interrupt", async () => {
|
|
||||||
const harness = createHarness();
|
|
||||||
await harness.startTurn("run commands", "native-B");
|
|
||||||
for (const processId of ["201", "202"]) {
|
|
||||||
await harness.runsCommand({ turnId: "native-B", processId });
|
|
||||||
harness.appServer.child.stdout.write(
|
|
||||||
JSON.stringify({
|
|
||||||
method: "item/completed",
|
|
||||||
params: {
|
|
||||||
threadId: THREAD,
|
|
||||||
turnId: "native-B",
|
|
||||||
item: {
|
|
||||||
id: `exec-${processId}`,
|
|
||||||
type: "commandExecution",
|
|
||||||
processId,
|
|
||||||
command: `sleep ${processId}`,
|
|
||||||
status: processId === "201" ? "completed" : "failed",
|
|
||||||
exitCode: processId === "201" ? 0 : null,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
}) + "\n",
|
|
||||||
);
|
|
||||||
}
|
|
||||||
// A following command's row is a barrier for both completion handlers.
|
|
||||||
await harness.runsCommand({ turnId: "native-B", processId: "203" });
|
|
||||||
await harness.interrupts();
|
|
||||||
await harness.startTurn("correction", "native-C");
|
|
||||||
expect(harness.terminated()).toEqual(["202", "203"]);
|
|
||||||
harness.appServer.assertNoErrors();
|
|
||||||
});
|
|
||||||
|
|
||||||
test("retains accepted interrupts for only the latest sixteen turns", async () => {
|
|
||||||
const harness = createHarness();
|
|
||||||
for (let index = 0; index < 17; index++) {
|
|
||||||
await harness.startTurn("run a command", `native-${index}`);
|
|
||||||
await harness.interrupts();
|
|
||||||
}
|
|
||||||
await harness.startTurn("correction", "native-current");
|
|
||||||
harness.appServer.startsTerminalCommand({
|
|
||||||
threadId: THREAD,
|
|
||||||
turnId: "native-0",
|
|
||||||
itemId: "evicted-201",
|
|
||||||
processId: "201",
|
|
||||||
command: "sleep 201",
|
|
||||||
});
|
|
||||||
harness.appServer.startsTerminalCommand({
|
|
||||||
threadId: THREAD,
|
|
||||||
turnId: "native-16",
|
|
||||||
itemId: "retained-202",
|
|
||||||
processId: "202",
|
|
||||||
command: "sleep 202",
|
|
||||||
});
|
|
||||||
await harness.runsCommand({ turnId: "native-current", processId: "301" });
|
|
||||||
expect(harness.terminated()).toEqual(["202"]);
|
|
||||||
harness.appServer.assertNoErrors();
|
|
||||||
});
|
|
||||||
|
|
||||||
test("a failing or unanswered terminate does not block or fail the next turn", async () => {
|
|
||||||
const harness = createHarness({
|
|
||||||
// 201 is refused while the next turn waits; 202 is never answered.
|
|
||||||
terminate: (processId) =>
|
|
||||||
processId === "201"
|
|
||||||
? new Promise((resolve) => setTimeout(resolve, 300)).then(() => ({
|
|
||||||
__jsonRpcError: { code: -32600, message: "invalid background terminal process id" },
|
|
||||||
}))
|
|
||||||
: new Promise(() => {}),
|
|
||||||
});
|
|
||||||
await interruptTurnB(harness);
|
|
||||||
const startedAt = Date.now();
|
|
||||||
await harness.startTurn("correction", "native-C");
|
|
||||||
|
|
||||||
expect(Date.now() - startedAt).toBeLessThan(5_000);
|
|
||||||
expect(harness.requests("turn/start")).toHaveLength(3);
|
|
||||||
expect(harness.terminated()).toEqual(["201", "202"]);
|
|
||||||
}, 15_000);
|
|
||||||
});
|
|
||||||
@@ -4,102 +4,6 @@ import { createTestLogger } from "../../../../../test-utils/test-logger.js";
|
|||||||
import type { V2Api } from "./api.js";
|
import type { V2Api } from "./api.js";
|
||||||
import { OpenCodeV2AgentClient } from "./agent.js";
|
import { OpenCodeV2AgentClient } from "./agent.js";
|
||||||
import { V2Harness } from "../test-utils/v2-harness.js";
|
import { V2Harness } from "../test-utils/v2-harness.js";
|
||||||
import type { AgentPermissionResponse } from "../../../agent-sdk-types.js";
|
|
||||||
|
|
||||||
describe("OpenCode v2 denial reasons", () => {
|
|
||||||
test.each<{
|
|
||||||
response: AgentPermissionResponse;
|
|
||||||
decision: "reject" | "once" | "always";
|
|
||||||
message?: string;
|
|
||||||
}>([
|
|
||||||
{
|
|
||||||
response: { behavior: "deny", message: "Use the existing file instead." },
|
|
||||||
decision: "reject",
|
|
||||||
message: "Use the existing file instead.",
|
|
||||||
},
|
|
||||||
{ response: { behavior: "deny" }, decision: "reject" },
|
|
||||||
{ response: { behavior: "allow" }, decision: "once" },
|
|
||||||
{ response: { behavior: "allow", selectedActionId: "always" }, decision: "always" },
|
|
||||||
])("preserves a permission response: $response", async ({ response, decision, message }) => {
|
|
||||||
const harness = new V2Harness();
|
|
||||||
let pending = true;
|
|
||||||
harness.api.permission.list = async () =>
|
|
||||||
pending
|
|
||||||
? [{ id: "approval", sessionID: "session", action: "shell", resources: ["pwd"] }]
|
|
||||||
: [];
|
|
||||||
const replies: Parameters<V2Api["permission"]["reply"]>[0][] = [];
|
|
||||||
harness.api.permission.reply = async (input) => {
|
|
||||||
replies.push(input);
|
|
||||||
pending = false;
|
|
||||||
};
|
|
||||||
const client = new OpenCodeV2AgentClient({
|
|
||||||
logger: createTestLogger(),
|
|
||||||
runtime: harness.runtime,
|
|
||||||
});
|
|
||||||
const session = await client.createSession({ provider: "opencode", cwd: "/tmp/project" });
|
|
||||||
try {
|
|
||||||
await session.respondToPermission("approval", response);
|
|
||||||
expect(replies).toEqual([
|
|
||||||
{
|
|
||||||
sessionID: "session",
|
|
||||||
requestID: "approval",
|
|
||||||
decision,
|
|
||||||
...(message === undefined ? {} : { message }),
|
|
||||||
},
|
|
||||||
]);
|
|
||||||
expect(session.getPendingPermissions()).toEqual([]);
|
|
||||||
} finally {
|
|
||||||
await session.close();
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
test.each([true, false])(
|
|
||||||
"explains cards superseded by a steer when clearing is %s",
|
|
||||||
async (clearPendingPermissions) => {
|
|
||||||
const harness = new V2Harness();
|
|
||||||
harness.autoComplete = false;
|
|
||||||
let pending = true;
|
|
||||||
harness.api.permission.list = async () =>
|
|
||||||
pending
|
|
||||||
? [{ id: "approval", sessionID: "session", action: "shell", resources: ["pwd"] }]
|
|
||||||
: [];
|
|
||||||
const replies: Parameters<V2Api["permission"]["reply"]>[0][] = [];
|
|
||||||
harness.api.permission.reply = async (input) => {
|
|
||||||
replies.push(input);
|
|
||||||
pending = false;
|
|
||||||
};
|
|
||||||
const client = new OpenCodeV2AgentClient({
|
|
||||||
logger: createTestLogger(),
|
|
||||||
runtime: harness.runtime,
|
|
||||||
});
|
|
||||||
const session = await client.createSession({ provider: "opencode", cwd: "/tmp/project" });
|
|
||||||
try {
|
|
||||||
const { turnId } = await session.startTurn("Run pwd");
|
|
||||||
await expect.poll(() => harness.prompts).toEqual(["Run pwd"]);
|
|
||||||
await expect(
|
|
||||||
session.steerActiveTurn!("go ahead", { expectedTurnId: turnId, clearPendingPermissions }),
|
|
||||||
).resolves.toEqual({ status: "accepted" });
|
|
||||||
expect(harness.prompts).toEqual(["Run pwd", "go ahead"]);
|
|
||||||
expect(replies).toEqual(
|
|
||||||
clearPendingPermissions
|
|
||||||
? [
|
|
||||||
{
|
|
||||||
sessionID: "session",
|
|
||||||
requestID: "approval",
|
|
||||||
decision: "reject",
|
|
||||||
message:
|
|
||||||
"The user answered with a message instead of approving. Their message follows.",
|
|
||||||
},
|
|
||||||
]
|
|
||||||
: [],
|
|
||||||
);
|
|
||||||
expect(session.getPendingPermissions()).toHaveLength(clearPendingPermissions ? 0 : 1);
|
|
||||||
} finally {
|
|
||||||
await session.close();
|
|
||||||
}
|
|
||||||
},
|
|
||||||
);
|
|
||||||
});
|
|
||||||
|
|
||||||
describe("OpenCode v2 questions", () => {
|
describe("OpenCode v2 questions", () => {
|
||||||
test("maps selected labels to native values and parses numeric and boolean answers", async () => {
|
test("maps selected labels to native values and parses numeric and boolean answers", async () => {
|
||||||
|
|||||||
@@ -68,9 +68,6 @@ export class SessionPermissions {
|
|||||||
sessionID: this.permissionOwners.get(requestId) ?? this.id,
|
sessionID: this.permissionOwners.get(requestId) ?? this.id,
|
||||||
requestID: requestId,
|
requestID: requestId,
|
||||||
decision: permissionReply(response),
|
decision: permissionReply(response),
|
||||||
...(response.behavior === "deny" && response.message !== undefined
|
|
||||||
? { message: response.message }
|
|
||||||
: {}),
|
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
this.resolvePending(requestId, response);
|
this.resolvePending(requestId, response);
|
||||||
|
|||||||
@@ -100,11 +100,7 @@ export class OpenCodeV2Session implements AgentSession {
|
|||||||
),
|
),
|
||||||
clearPermissions: async () => {
|
clearPermissions: async () => {
|
||||||
for (const request of this.permissions.list())
|
for (const request of this.permissions.list())
|
||||||
await this.permissions.respondToPermission(request.id, {
|
await this.permissions.respondToPermission(request.id, { behavior: "deny" });
|
||||||
behavior: "deny",
|
|
||||||
message:
|
|
||||||
"The user answered with a message instead of approving. Their message follows.",
|
|
||||||
});
|
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
this.usage = new SessionUsage({
|
this.usage = new SessionUsage({
|
||||||
|
|||||||
@@ -1590,7 +1590,7 @@ function createToolCatalog(
|
|||||||
{
|
{
|
||||||
title: "Create agent",
|
title: "Create agent",
|
||||||
description:
|
description:
|
||||||
"Create an agent. Agent-scoped creation defaults to your workspace and creates your subagent. Top-level creation without workspaceId creates a new local workspace. Requires provider/model (for example codex/gpt-5.4) and an initial prompt. For a different provider, set settings.modeId from inspect_provider; omission is accepted for targets with no modes and for unattended execution supported by the target. Do not guess; call list_providers and list_models first if uncertain.",
|
"Create an agent. Agent-scoped creation defaults to your workspace and creates your subagent. Top-level creation without workspaceId creates a new local workspace. Requires provider/model (for example codex/gpt-5.4) and an initial prompt. Do not guess; call list_providers and list_models first if uncertain.",
|
||||||
inputSchema: createAgentInputSchema,
|
inputSchema: createAgentInputSchema,
|
||||||
outputSchema: {
|
outputSchema: {
|
||||||
agentId: z.string(),
|
agentId: z.string(),
|
||||||
|
|||||||
@@ -183,7 +183,7 @@ test("ordinary routed APIs isolate scopes and enforce one-hop, disable and origi
|
|||||||
await paseo.dispose();
|
await paseo.dispose();
|
||||||
});
|
});
|
||||||
|
|
||||||
test("routed authority isolates simultaneous sessions and preserves ordinary permission RPC errors", async () => {
|
test("routed authority is bounded by the caller, with isolated simultaneous sessions", async () => {
|
||||||
const b = await daemon();
|
const b = await daemon();
|
||||||
const a = await daemon();
|
const a = await daemon();
|
||||||
const owner = await api(a);
|
const owner = await api(a);
|
||||||
@@ -210,16 +210,33 @@ test("routed authority isolates simultaneous sessions and preserves ordinary per
|
|||||||
code: "permission_denied",
|
code: "permission_denied",
|
||||||
});
|
});
|
||||||
expect((await owner.paseo.hosts.list()).hosts).toHaveLength(1);
|
expect((await owner.paseo.hosts.list()).hosts).toHaveLength(1);
|
||||||
const directClient = createHostClient(`127.0.0.1:${b.port}`, {
|
await remote.dispose();
|
||||||
clientId: crypto.randomUUID(),
|
});
|
||||||
sessionPermissions: ["daemon.read", "workspace.read"],
|
|
||||||
reconnect: { enabled: false },
|
test("SDK host clients preserve ordinary permission RPC errors", async () => {
|
||||||
});
|
const b = await daemon();
|
||||||
await directClient.connect();
|
const a = await daemon();
|
||||||
const direct = createPaseoApi(directClient);
|
const owner = await api(a);
|
||||||
|
await owner.paseo.hosts.add({ connection: `127.0.0.1:${b.port}` });
|
||||||
|
const serverId = (await online(owner.paseo)).hosts[0]!.serverId!;
|
||||||
|
const clients = [a, b].map((host) =>
|
||||||
|
createHostClient(`127.0.0.1:${host.port}`, {
|
||||||
|
clientId: crypto.randomUUID(),
|
||||||
|
sessionPermissions: ["daemon.read", "workspace.read"],
|
||||||
|
reconnect: { enabled: false },
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
for (const client of clients) {
|
||||||
|
cleanup.push(() => client.close());
|
||||||
|
await client.connect();
|
||||||
|
}
|
||||||
|
const origin = createPaseoApi(clients[0]!);
|
||||||
|
const direct = createPaseoApi(clients[1]!);
|
||||||
|
const remote = await origin.hosts.client(serverId);
|
||||||
cleanup.push(async () => {
|
cleanup.push(async () => {
|
||||||
|
await remote.dispose();
|
||||||
await direct.dispose();
|
await direct.dispose();
|
||||||
await directClient.close();
|
await origin.dispose();
|
||||||
});
|
});
|
||||||
const input = { source: { kind: "directory" as const, path: b.staticDir } };
|
const input = { source: { kind: "directory" as const, path: b.staticDir } };
|
||||||
const directError = (await direct.workspaces.create(input).catch((error: unknown) => error)) as {
|
const directError = (await direct.workspaces.create(input).catch((error: unknown) => error)) as {
|
||||||
@@ -230,7 +247,6 @@ test("routed authority isolates simultaneous sessions and preserves ordinary per
|
|||||||
};
|
};
|
||||||
expect(directError.code).toBe("access_denied");
|
expect(directError.code).toBe("access_denied");
|
||||||
expect(remoteError.code).toBe(directError.code);
|
expect(remoteError.code).toBe(directError.code);
|
||||||
await remote.dispose();
|
|
||||||
});
|
});
|
||||||
|
|
||||||
test("distinct origin sessions cannot collide through a caller-selected route scope, including terminal binary output", async () => {
|
test("distinct origin sessions cannot collide through a caller-selected route scope, including terminal binary output", async () => {
|
||||||
|
|||||||
@@ -1,67 +0,0 @@
|
|||||||
import { mkdir, mkdtemp, rm } from "node:fs/promises";
|
|
||||||
import path from "node:path";
|
|
||||||
import { Client } from "@modelcontextprotocol/sdk/client/index.js";
|
|
||||||
import { StreamableHTTPClientTransport } from "@modelcontextprotocol/sdk/client/streamableHttp.js";
|
|
||||||
import { expect, test } from "vitest";
|
|
||||||
import { createDaemonTestContext } from "../test-utils/index.js";
|
|
||||||
|
|
||||||
test("an attended MCP caller can discover and supply a cross-provider child mode", async () => {
|
|
||||||
const artifacts = path.join(process.cwd(), ".artifacts");
|
|
||||||
await mkdir(artifacts, { recursive: true });
|
|
||||||
const root = await mkdtemp(path.join(artifacts, "create-agent-mode-"));
|
|
||||||
const ctx = await createDaemonTestContext({ paseoHomeRoot: root, mcpEnabled: true });
|
|
||||||
const mcp = new Client({ name: "mode-contract-test", version: "1" });
|
|
||||||
try {
|
|
||||||
const parent = await ctx.client.createAgent({
|
|
||||||
provider: "claude",
|
|
||||||
cwd: root,
|
|
||||||
modeId: "default",
|
|
||||||
title: "Attended parent",
|
|
||||||
});
|
|
||||||
await mcp.connect(
|
|
||||||
new StreamableHTTPClientTransport(
|
|
||||||
new URL(`http://127.0.0.1:${ctx.daemon.port}/mcp/agents?callerAgentId=${parent.id}`),
|
|
||||||
),
|
|
||||||
);
|
|
||||||
const { tools } = await mcp.listTools();
|
|
||||||
const create = tools.find((tool) => tool.name === "create_agent");
|
|
||||||
expect.soft(create?.description).toContain("settings.modeId");
|
|
||||||
|
|
||||||
const args = {
|
|
||||||
provider: "codex/gpt-5.4-mini",
|
|
||||||
title: "Cross-provider child",
|
|
||||||
initialPrompt: "Reply with OK",
|
|
||||||
};
|
|
||||||
const refused = await mcp.callTool({ name: "create_agent", arguments: args });
|
|
||||||
expect(refused.isError).toBe(true);
|
|
||||||
expect.soft(refused.content).toEqual([
|
|
||||||
expect.objectContaining({
|
|
||||||
type: "text",
|
|
||||||
text: expect.stringContaining("Pass an explicit mode in settings.modeId"),
|
|
||||||
}),
|
|
||||||
]);
|
|
||||||
expect((await ctx.client.fetchAgents({ subscribe: {} })).entries).toHaveLength(1);
|
|
||||||
|
|
||||||
const inspected = await mcp.callTool({
|
|
||||||
name: "inspect_provider",
|
|
||||||
arguments: { provider: "codex" },
|
|
||||||
});
|
|
||||||
expect(inspected.isError).not.toBe(true);
|
|
||||||
expect(inspected.structuredContent).toMatchObject({
|
|
||||||
modes: expect.arrayContaining([expect.objectContaining({ id: "auto" })]),
|
|
||||||
});
|
|
||||||
const created = await mcp.callTool({
|
|
||||||
name: "create_agent",
|
|
||||||
arguments: { ...args, settings: { modeId: "auto" } },
|
|
||||||
});
|
|
||||||
expect(created.isError).not.toBe(true);
|
|
||||||
expect(created.structuredContent).toMatchObject({ type: "codex", currentModeId: "auto" });
|
|
||||||
const childId = (created.structuredContent as { agentId: string }).agentId;
|
|
||||||
expect((await ctx.client.fetchAgent(childId))?.agent.currentModeId).toBe("auto");
|
|
||||||
expect((await ctx.client.waitForFinish(childId, 5000)).status).toBe("idle");
|
|
||||||
} finally {
|
|
||||||
await mcp.close();
|
|
||||||
await ctx.cleanup();
|
|
||||||
await rm(root, { recursive: true, force: true });
|
|
||||||
}
|
|
||||||
}, 30_000);
|
|
||||||
@@ -102,52 +102,53 @@ describe("daemon E2E", () => {
|
|||||||
},
|
},
|
||||||
);
|
);
|
||||||
|
|
||||||
test("downloads ASCII and Unicode filenames through one workspace", async () => {
|
test.each([
|
||||||
const cwd = tmpCwd();
|
{ fileName: "download.txt", disposition: 'attachment; filename="download.txt"' },
|
||||||
try {
|
{
|
||||||
const agent = await ctx.client.createAgent({
|
fileName: "café.txt",
|
||||||
provider: "codex",
|
disposition: "attachment; filename=\"caf?.txt\"; filename*=UTF-8''caf%C3%A9.txt",
|
||||||
model: CODEX_TEST_MODEL,
|
},
|
||||||
thinkingOptionId: CODEX_TEST_THINKING_OPTION_ID,
|
{
|
||||||
cwd,
|
fileName: "中文报告 (最终版).txt",
|
||||||
title: "Download Token Test Agent",
|
disposition:
|
||||||
});
|
"attachment; filename=\"???? (???).txt\"; filename*=UTF-8''%E4%B8%AD%E6%96%87%E6%8A%A5%E5%91%8A%20%28%E6%9C%80%E7%BB%88%E7%89%88%29.txt",
|
||||||
|
},
|
||||||
expect(agent.id).toMatch(
|
{
|
||||||
/^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/,
|
fileName: "報告書.md",
|
||||||
);
|
disposition:
|
||||||
|
"attachment; filename=\"???.md\"; filename*=UTF-8''%E5%A0%B1%E5%91%8A%E6%9B%B8.md",
|
||||||
for (const { fileName, disposition } of [
|
},
|
||||||
{ fileName: "download.txt", disposition: 'attachment; filename="download.txt"' },
|
{
|
||||||
{
|
fileName: "보고서.txt",
|
||||||
fileName: "café.txt",
|
disposition:
|
||||||
disposition: "attachment; filename=\"caf?.txt\"; filename*=UTF-8''caf%C3%A9.txt",
|
"attachment; filename=\"???.txt\"; filename*=UTF-8''%EB%B3%B4%EA%B3%A0%EC%84%9C.txt",
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
fileName: "中文报告 (最终版).txt",
|
fileName: "report-📄.txt",
|
||||||
disposition:
|
disposition:
|
||||||
"attachment; filename=\"???? (???).txt\"; filename*=UTF-8''%E4%B8%AD%E6%96%87%E6%8A%A5%E5%91%8A%20%28%E6%9C%80%E7%BB%88%E7%89%88%29.txt",
|
"attachment; filename=\"report-??.txt\"; filename*=UTF-8''report-%F0%9F%93%84.txt",
|
||||||
},
|
},
|
||||||
{
|
])(
|
||||||
fileName: "報告書.md",
|
"issues token over WS and downloads $fileName via HTTP",
|
||||||
disposition:
|
async ({ fileName, disposition }) => {
|
||||||
"attachment; filename=\"???.md\"; filename*=UTF-8''%E5%A0%B1%E5%91%8A%E6%9B%B8.md",
|
const cwd = tmpCwd();
|
||||||
},
|
try {
|
||||||
{
|
|
||||||
fileName: "보고서.txt",
|
|
||||||
disposition:
|
|
||||||
"attachment; filename=\"???.txt\"; filename*=UTF-8''%EB%B3%B4%EA%B3%A0%EC%84%9C.txt",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
fileName: "report-📄.txt",
|
|
||||||
disposition:
|
|
||||||
"attachment; filename=\"report-??.txt\"; filename*=UTF-8''report-%F0%9F%93%84.txt",
|
|
||||||
},
|
|
||||||
]) {
|
|
||||||
const filePath = path.join(cwd, fileName);
|
const filePath = path.join(cwd, fileName);
|
||||||
const fileContents = `download test payload: ${fileName}`;
|
const fileContents = "download test payload";
|
||||||
writeFileSync(filePath, fileContents, "utf-8");
|
writeFileSync(filePath, fileContents, "utf-8");
|
||||||
|
|
||||||
|
const agent = await ctx.client.createAgent({
|
||||||
|
provider: "codex",
|
||||||
|
model: CODEX_TEST_MODEL,
|
||||||
|
thinkingOptionId: CODEX_TEST_THINKING_OPTION_ID,
|
||||||
|
cwd,
|
||||||
|
title: "Download Token Test Agent",
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(agent.id).toMatch(
|
||||||
|
/^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/,
|
||||||
|
);
|
||||||
|
|
||||||
const tokenResponse = await ctx.client.requestDownloadToken(cwd, fileName);
|
const tokenResponse = await ctx.client.requestDownloadToken(cwd, fileName);
|
||||||
|
|
||||||
expect(tokenResponse.error).toBeNull();
|
expect(tokenResponse.error).toBeNull();
|
||||||
@@ -166,11 +167,12 @@ describe("daemon E2E", () => {
|
|||||||
|
|
||||||
const body = await response.text();
|
const body = await response.text();
|
||||||
expect(body).toBe(fileContents);
|
expect(body).toBe(fileContents);
|
||||||
|
} finally {
|
||||||
|
rmSync(cwd, { recursive: true, force: true });
|
||||||
}
|
}
|
||||||
} finally {
|
},
|
||||||
rmSync(cwd, { recursive: true, force: true });
|
60000,
|
||||||
}
|
);
|
||||||
}, 60000);
|
|
||||||
|
|
||||||
test("rejects invalid token", async () => {
|
test("rejects invalid token", async () => {
|
||||||
const response = await fetch(
|
const response = await fetch(
|
||||||
|
|||||||
@@ -1,129 +0,0 @@
|
|||||||
import { mkdir, mkdtemp, rm } from "node:fs/promises";
|
|
||||||
import path from "node:path";
|
|
||||||
import type { FormInfo } from "@opencode/client";
|
|
||||||
import { expect, test } from "vitest";
|
|
||||||
|
|
||||||
import { createTestLogger } from "../../test-utils/test-logger.js";
|
|
||||||
import { OpenCodeV2AgentClient } from "../agent/providers/opencode/v2/agent.js";
|
|
||||||
import { V2Harness } from "../agent/providers/opencode/test-utils/v2-harness.js";
|
|
||||||
import { createTestAgentClients } from "../test-utils/fake-agent-client.js";
|
|
||||||
import { createTestPaseoDaemon } from "../test-utils/paseo-daemon.js";
|
|
||||||
import { DaemonClient } from "../test-utils/daemon-client.js";
|
|
||||||
|
|
||||||
async function createQuestionHarness() {
|
|
||||||
const artifacts = path.resolve(".artifacts");
|
|
||||||
await mkdir(artifacts, { recursive: true });
|
|
||||||
const root = await mkdtemp(path.join(artifacts, "peer-permissions-"));
|
|
||||||
const runtime = new V2Harness();
|
|
||||||
runtime.autoComplete = false;
|
|
||||||
runtime.api.model.list = async () => ({ location: runtime.info.location, data: [] });
|
|
||||||
runtime.api.provider.list = async () => ({
|
|
||||||
location: runtime.info.location,
|
|
||||||
data: [{ id: "test", name: "Test", activation: "enabled", package: "test" }],
|
|
||||||
});
|
|
||||||
let form: FormInfo | null = null;
|
|
||||||
const answers: Array<Record<string, unknown>> = [];
|
|
||||||
runtime.api.session.form.list = async () => (form ? [form] : []);
|
|
||||||
runtime.api.session.form.cancel = async () => {
|
|
||||||
form = null;
|
|
||||||
runtime.finishExecution("interrupted");
|
|
||||||
};
|
|
||||||
runtime.api.session.form.reply = async ({ answer }) => {
|
|
||||||
answers.push(answer);
|
|
||||||
form = null;
|
|
||||||
runtime.finishExecution();
|
|
||||||
};
|
|
||||||
// A steer is admitted to the existing execution, rather than starting another one.
|
|
||||||
runtime.prompt = async (input) => {
|
|
||||||
runtime.prompts.push(input.text);
|
|
||||||
if (runtime.prompts.length === 1) runtime.startExecution();
|
|
||||||
};
|
|
||||||
const provider = new OpenCodeV2AgentClient({
|
|
||||||
logger: createTestLogger(),
|
|
||||||
runtime: runtime.runtime,
|
|
||||||
});
|
|
||||||
const daemon = await createTestPaseoDaemon({
|
|
||||||
agentClients: { ...createTestAgentClients(), opencode: provider },
|
|
||||||
paseoHomeRoot: path.join(root, "daemon"),
|
|
||||||
staticDir: path.join(root, "static"),
|
|
||||||
cleanup: false,
|
|
||||||
});
|
|
||||||
const client = new DaemonClient({ url: `ws://127.0.0.1:${daemon.port}/ws` });
|
|
||||||
try {
|
|
||||||
await client.connect();
|
|
||||||
await client.fetchAgents({ subscribe: {} });
|
|
||||||
const recipient = await client.createAgent({
|
|
||||||
provider: "opencode",
|
|
||||||
cwd: root,
|
|
||||||
title: "Waiting for a human answer",
|
|
||||||
});
|
|
||||||
const sender = await client.createAgent({ provider: "claude", cwd: root });
|
|
||||||
await client.sendMessage(recipient.id, "Ask which color to use");
|
|
||||||
await expect.poll(() => runtime.prompts).toHaveLength(1);
|
|
||||||
form = {
|
|
||||||
id: "color-question",
|
|
||||||
sessionID: "session",
|
|
||||||
title: "Color",
|
|
||||||
fields: [{ key: "color", title: "Color", type: "string" }],
|
|
||||||
};
|
|
||||||
runtime.push({ id: "question-created", created: 2, type: "form.created", data: { form } });
|
|
||||||
await client.waitForAgentUpsert(recipient.id, (agent) =>
|
|
||||||
(agent.pendingPermissions ?? []).some((request) => request.id === "color-question"),
|
|
||||||
);
|
|
||||||
return {
|
|
||||||
client,
|
|
||||||
recipient,
|
|
||||||
sender,
|
|
||||||
answers,
|
|
||||||
async close() {
|
|
||||||
await client.close();
|
|
||||||
await daemon.close();
|
|
||||||
await rm(root, { recursive: true, force: true });
|
|
||||||
},
|
|
||||||
};
|
|
||||||
} catch (error) {
|
|
||||||
await client.close();
|
|
||||||
await daemon.close();
|
|
||||||
await rm(root, { recursive: true, force: true });
|
|
||||||
throw error;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
test("an agent-sourced steer leaves the question pending and answerable", async () => {
|
|
||||||
const { client, recipient, sender, answers, close } = await createQuestionHarness();
|
|
||||||
try {
|
|
||||||
await client.sendAgentMessage(recipient.id, "Peer status check", {
|
|
||||||
sourceAgentId: sender.id,
|
|
||||||
});
|
|
||||||
// The public wait includes admitted message delivery before reporting permission status.
|
|
||||||
const waiting = await client.waitForFinish(recipient.id);
|
|
||||||
expect(waiting.status).toBe("permission");
|
|
||||||
expect(waiting.final?.pendingPermissions).toMatchObject([
|
|
||||||
{ id: "color-question", kind: "question" },
|
|
||||||
]);
|
|
||||||
await client.respondToPermission(recipient.id, "color-question", {
|
|
||||||
behavior: "allow",
|
|
||||||
updatedInput: { answers: { Color: "Blue" } },
|
|
||||||
});
|
|
||||||
await client.waitForAgentUpsert(
|
|
||||||
recipient.id,
|
|
||||||
(agent) => (agent.pendingPermissions ?? []).length === 0 && agent.status === "idle",
|
|
||||||
);
|
|
||||||
expect(answers).toEqual([{ color: "Blue" }]);
|
|
||||||
} finally {
|
|
||||||
await close();
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
test("a human steer still dismisses the question", async () => {
|
|
||||||
const { client, recipient, answers, close } = await createQuestionHarness();
|
|
||||||
try {
|
|
||||||
await client.sendAgentMessage(recipient.id, "Use red instead", { activeTurnBehavior: "steer" });
|
|
||||||
const finished = await client.waitForFinish(recipient.id);
|
|
||||||
expect(finished.status).toBe("idle");
|
|
||||||
expect(finished.final?.pendingPermissions ?? []).toEqual([]);
|
|
||||||
expect(answers).toEqual([]);
|
|
||||||
} finally {
|
|
||||||
await close();
|
|
||||||
}
|
|
||||||
});
|
|
||||||
@@ -8318,7 +8318,7 @@ export class Session {
|
|||||||
},
|
},
|
||||||
activeTurnBehavior: msg.activeTurnBehavior ?? "steer",
|
activeTurnBehavior: msg.activeTurnBehavior ?? "steer",
|
||||||
sessionMode: msg.sessionMode,
|
sessionMode: msg.sessionMode,
|
||||||
clearPendingPermissions: false,
|
clearPendingPermissions: true,
|
||||||
});
|
});
|
||||||
deliveryStatus = "queued";
|
deliveryStatus = "queued";
|
||||||
queued();
|
queued();
|
||||||
|
|||||||
@@ -1,4 +1,3 @@
|
|||||||
import { flushGitCommandTrace } from "../utils/git-command-trace.js";
|
|
||||||
import { createWorkspaceRuntimeStore } from "./workspace-runtime-store/index.js";
|
import { createWorkspaceRuntimeStore } from "./workspace-runtime-store/index.js";
|
||||||
import { mkdirSync, mkdtempSync, readFileSync, realpathSync, rmSync, writeFileSync } from "node:fs";
|
import { mkdirSync, mkdtempSync, readFileSync, realpathSync, rmSync, writeFileSync } from "node:fs";
|
||||||
import { execFileSync } from "node:child_process";
|
import { execFileSync } from "node:child_process";
|
||||||
@@ -731,103 +730,3 @@ test("Git event identity retains a symlink's literal target before canonicalizin
|
|||||||
rmSync(root, { recursive: true, force: true });
|
rmSync(root, { recursive: true, force: true });
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
test.skipIf(process.platform !== "linux")(
|
|
||||||
"missing folders stop degraded Git polling and explicit refreshes, then recover",
|
|
||||||
async () => {
|
|
||||||
const artifacts = path.resolve(".artifacts");
|
|
||||||
mkdirSync(artifacts, { recursive: true });
|
|
||||||
const root = mkdtempSync(path.join(artifacts, "workspace-git-missing-"));
|
|
||||||
const cwd = path.join(root, "repo");
|
|
||||||
const initializeRepo = (branch: string) => {
|
|
||||||
mkdirSync(cwd);
|
|
||||||
execFileSync("git", ["init", "-b", branch], { cwd, stdio: "pipe" });
|
|
||||||
writeFileSync(path.join(cwd, "README.md"), "fixture\n");
|
|
||||||
execFileSync("git", ["add", "."], { cwd, stdio: "pipe" });
|
|
||||||
execFileSync(
|
|
||||||
"git",
|
|
||||||
[
|
|
||||||
"-c",
|
|
||||||
"user.name=Test",
|
|
||||||
"-c",
|
|
||||||
"user.email=test@example.invalid",
|
|
||||||
"-c",
|
|
||||||
"commit.gpgsign=false",
|
|
||||||
"commit",
|
|
||||||
"-m",
|
|
||||||
"fixture",
|
|
||||||
],
|
|
||||||
{ cwd, stdio: "pipe" },
|
|
||||||
);
|
|
||||||
};
|
|
||||||
initializeRepo("main");
|
|
||||||
// Exceed the real Linux observer's directory limit to enter degraded polling.
|
|
||||||
// No watcher errors or Git responses are injected.
|
|
||||||
for (let index = 0; index < 5100; index++) {
|
|
||||||
mkdirSync(path.join(cwd, `generated-${index}`));
|
|
||||||
}
|
|
||||||
const trace = path.join(root, "git.jsonl");
|
|
||||||
writeFileSync(trace, "");
|
|
||||||
const previousTrace = process.env.PASEO_GIT_TRACE_FILE;
|
|
||||||
process.env.PASEO_GIT_TRACE_FILE = trace;
|
|
||||||
let degraded = false;
|
|
||||||
let suspended = false;
|
|
||||||
const logger = pino(
|
|
||||||
{ level: "warn" },
|
|
||||||
{
|
|
||||||
write(line) {
|
|
||||||
const message = JSON.parse(line).msg;
|
|
||||||
if (message === "Failed to start working tree watcher; using degraded polling")
|
|
||||||
degraded = true;
|
|
||||||
if (
|
|
||||||
message ===
|
|
||||||
"Workspace folder no longer exists; suspended git observation until it is back"
|
|
||||||
)
|
|
||||||
suspended = true;
|
|
||||||
},
|
|
||||||
},
|
|
||||||
);
|
|
||||||
const service = new WorkspaceGitServiceImpl({ logger, paseoHome: path.join(root, "home") });
|
|
||||||
const subscription = service.registerWorkspace({ cwd }, () => {});
|
|
||||||
const commandCount = async () => {
|
|
||||||
await flushGitCommandTrace();
|
|
||||||
return readFileSync(trace, "utf8").trim().split("\n").filter(Boolean).length;
|
|
||||||
};
|
|
||||||
try {
|
|
||||||
await expect
|
|
||||||
.poll(() => degraded && service.getMetrics().workspaceRefreshInFlightCount === 0, {
|
|
||||||
timeout: 15_000,
|
|
||||||
})
|
|
||||||
.toBe(true);
|
|
||||||
rmSync(cwd, { recursive: true, force: true });
|
|
||||||
await expect.poll(() => suspended, { timeout: 15_000 }).toBe(true);
|
|
||||||
await expect.poll(() => service.getMetrics().workspaceRefreshInFlightCount).toBe(0);
|
|
||||||
const commandsWhileMissing = await commandCount();
|
|
||||||
await service.getSnapshot(cwd, {
|
|
||||||
force: true,
|
|
||||||
includeForge: false,
|
|
||||||
reason: "refresh-button",
|
|
||||||
});
|
|
||||||
await service.refresh(cwd);
|
|
||||||
expect(await commandCount()).toBe(commandsWhileMissing);
|
|
||||||
await new Promise((resolve) => setTimeout(resolve, 16_000));
|
|
||||||
expect(await commandCount()).toBe(commandsWhileMissing);
|
|
||||||
|
|
||||||
initializeRepo("restored");
|
|
||||||
const snapshot = await service.getSnapshot(cwd, {
|
|
||||||
force: true,
|
|
||||||
includeForge: false,
|
|
||||||
reason: "refresh-button",
|
|
||||||
});
|
|
||||||
expect(snapshot.git).toMatchObject({ isGit: true, currentBranch: "restored" });
|
|
||||||
await expect.poll(() => service.getMetrics().repositoryWorkspaceLinkCount).toBe(1);
|
|
||||||
} finally {
|
|
||||||
subscription.unsubscribe();
|
|
||||||
await service.dispose();
|
|
||||||
if (previousTrace === undefined) delete process.env.PASEO_GIT_TRACE_FILE;
|
|
||||||
else process.env.PASEO_GIT_TRACE_FILE = previousTrace;
|
|
||||||
rmSync(root, { recursive: true, force: true });
|
|
||||||
}
|
|
||||||
},
|
|
||||||
60_000,
|
|
||||||
);
|
|
||||||
|
|||||||
@@ -244,7 +244,6 @@ function createService(
|
|||||||
filterEvents: (events) => events,
|
filterEvents: (events) => events,
|
||||||
verify: vi.fn(async () => {}),
|
verify: vi.fn(async () => {}),
|
||||||
})),
|
})),
|
||||||
workspaceFolderExists: vi.fn(async () => true),
|
|
||||||
...overrides,
|
...overrides,
|
||||||
} as never,
|
} as never,
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1,7 +1,5 @@
|
|||||||
import { createWorkspaceRuntimeStore } from "./workspace-runtime-store/index.js";
|
import { createWorkspaceRuntimeStore } from "./workspace-runtime-store/index.js";
|
||||||
import { afterEach, beforeEach, describe, expect, test, vi } from "vitest";
|
import { afterEach, beforeEach, describe, expect, test, vi } from "vitest";
|
||||||
import { existsSync } from "node:fs";
|
|
||||||
import { mkdir, mkdtemp, rm, writeFile } from "node:fs/promises";
|
|
||||||
import os from "node:os";
|
import os from "node:os";
|
||||||
import path, { join } from "node:path";
|
import path, { join } from "node:path";
|
||||||
import type pino from "pino";
|
import type pino from "pino";
|
||||||
@@ -12,14 +10,11 @@ import type {
|
|||||||
PullRequestStatusResult,
|
PullRequestStatusResult,
|
||||||
} from "../utils/checkout-git.js";
|
} from "../utils/checkout-git.js";
|
||||||
import {
|
import {
|
||||||
BACKGROUND_GIT_FETCH_INTERVAL_MS,
|
|
||||||
WORKSPACE_GIT_OBSERVATION_REENSURE_INTERVAL_MS,
|
|
||||||
WORKSPACE_GIT_OBSERVATION_SETUP_CONCURRENCY,
|
WORKSPACE_GIT_OBSERVATION_SETUP_CONCURRENCY,
|
||||||
WORKSPACE_GIT_REFRESH_CONCURRENCY,
|
WORKSPACE_GIT_REFRESH_CONCURRENCY,
|
||||||
WORKSPACE_GIT_WATCHER_SUBSCRIBE_TIMEOUT_MS,
|
WORKSPACE_GIT_WATCHER_SUBSCRIBE_TIMEOUT_MS,
|
||||||
WorkspaceGitServiceImpl,
|
WorkspaceGitServiceImpl,
|
||||||
type WorkspaceGitRuntimeSnapshot,
|
type WorkspaceGitRuntimeSnapshot,
|
||||||
workspaceFolderExists,
|
|
||||||
} from "./workspace-git-service.js";
|
} from "./workspace-git-service.js";
|
||||||
|
|
||||||
const REPO_CWD = path.resolve("/tmp/repo");
|
const REPO_CWD = path.resolve("/tmp/repo");
|
||||||
@@ -28,7 +23,6 @@ function createLogger() {
|
|||||||
const logger = {
|
const logger = {
|
||||||
child: () => logger,
|
child: () => logger,
|
||||||
debug: vi.fn(),
|
debug: vi.fn(),
|
||||||
info: vi.fn(),
|
|
||||||
warn: vi.fn(),
|
warn: vi.fn(),
|
||||||
};
|
};
|
||||||
return logger;
|
return logger;
|
||||||
@@ -257,7 +251,7 @@ function createGitHubServiceStub(): ForgeService {
|
|||||||
interface CreateServiceTestOptions {
|
interface CreateServiceTestOptions {
|
||||||
subscribe?: ReturnType<typeof vi.fn>;
|
subscribe?: ReturnType<typeof vi.fn>;
|
||||||
getCheckoutStatus?: ReturnType<typeof vi.fn>;
|
getCheckoutStatus?: ReturnType<typeof vi.fn>;
|
||||||
getCheckoutSnapshotFacts?: (cwd: string) => Promise<CheckoutSnapshotFacts>;
|
getCheckoutSnapshotFacts?: ReturnType<typeof vi.fn>;
|
||||||
getCheckoutShortstat?: ReturnType<typeof vi.fn>;
|
getCheckoutShortstat?: ReturnType<typeof vi.fn>;
|
||||||
getCheckoutWorktreeState?: ReturnType<typeof vi.fn>;
|
getCheckoutWorktreeState?: ReturnType<typeof vi.fn>;
|
||||||
getPullRequestStatus?: ReturnType<typeof vi.fn>;
|
getPullRequestStatus?: ReturnType<typeof vi.fn>;
|
||||||
@@ -267,7 +261,6 @@ interface CreateServiceTestOptions {
|
|||||||
runGitFetch?: ReturnType<typeof vi.fn>;
|
runGitFetch?: ReturnType<typeof vi.fn>;
|
||||||
runGitCommand?: ReturnType<typeof vi.fn>;
|
runGitCommand?: ReturnType<typeof vi.fn>;
|
||||||
getWorkspaceGitSelfHealPhaseMs?: (cwd: string) => number;
|
getWorkspaceGitSelfHealPhaseMs?: (cwd: string) => number;
|
||||||
workspaceFolderExists?: (cwd: string) => Promise<boolean>;
|
|
||||||
now?: () => Date;
|
now?: () => Date;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -301,7 +294,6 @@ function buildDefaultTestServiceDeps() {
|
|||||||
signal: null,
|
signal: null,
|
||||||
})),
|
})),
|
||||||
getWorkspaceGitSelfHealPhaseMs: vi.fn(() => 30_000),
|
getWorkspaceGitSelfHealPhaseMs: vi.fn(() => 30_000),
|
||||||
workspaceFolderExists: vi.fn(async () => true),
|
|
||||||
createWatcherLivenessCanary: vi.fn(() => ({
|
createWatcherLivenessCanary: vi.fn(() => ({
|
||||||
path: "",
|
path: "",
|
||||||
filterEvents: (events) => events,
|
filterEvents: (events) => events,
|
||||||
@@ -332,145 +324,6 @@ function createService(options?: CreateServiceTestOptions) {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
type WatchCallback = (error: Error | null, events: Array<{ path: string; type: "update" }>) => void;
|
|
||||||
|
|
||||||
interface FolderBackedGitOptions {
|
|
||||||
facts?: (cwd: string) => CheckoutSnapshotFacts;
|
|
||||||
unwatchableDirectories?: string[];
|
|
||||||
heldWatch?: { directory: string; release: Promise<void> };
|
|
||||||
}
|
|
||||||
|
|
||||||
function createFolderBackedGit(options?: FolderBackedGitOptions) {
|
|
||||||
const watchCallbacks = new Map<string, WatchCallback>();
|
|
||||||
const subscribe = vi.fn(async (directory: string, callback: WatchCallback) => {
|
|
||||||
if (!existsSync(directory) || options?.unwatchableDirectories?.includes(directory)) {
|
|
||||||
throw new Error(`Cannot watch ${directory}`);
|
|
||||||
}
|
|
||||||
if (options?.heldWatch?.directory === directory) {
|
|
||||||
await options.heldWatch.release;
|
|
||||||
}
|
|
||||||
watchCallbacks.set(directory, callback);
|
|
||||||
return createAsyncSubscription();
|
|
||||||
});
|
|
||||||
const getCheckoutSnapshotFacts = vi.fn(
|
|
||||||
async (cwd: string): Promise<CheckoutSnapshotFacts> =>
|
|
||||||
existsSync(cwd) ? (options?.facts ?? createCheckoutSnapshotFacts)(cwd) : { isGit: false },
|
|
||||||
);
|
|
||||||
const getCheckoutStatus = vi.fn(async (cwd: string) =>
|
|
||||||
existsSync(cwd) ? createCheckoutStatus(cwd) : ({ isGit: false } as const),
|
|
||||||
);
|
|
||||||
const getCheckoutShortstat = vi.fn(async (_cwd: string) => ({ additions: 1, deletions: 0 }));
|
|
||||||
const getCheckoutWorktreeState = vi.fn(async (_cwd: string) => ({
|
|
||||||
isDirty: false,
|
|
||||||
diffStat: { additions: 1, deletions: 0 },
|
|
||||||
}));
|
|
||||||
const runGitCommand = vi.fn(async (args: string[], runOptions: { cwd: string }) => ({
|
|
||||||
stdout: args[0] === "rev-parse" ? `${runOptions.cwd}\n` : "",
|
|
||||||
stderr: "",
|
|
||||||
truncated: false,
|
|
||||||
exitCode: 0,
|
|
||||||
signal: null,
|
|
||||||
}));
|
|
||||||
const runGitFetch = vi.fn(async (_cwd: string) => ({ changes: [], error: null }));
|
|
||||||
const callsIn = (fake: ReturnType<typeof vi.fn>, cwd: string) =>
|
|
||||||
fake.mock.calls.filter(([calledCwd]) => calledCwd === cwd).length;
|
|
||||||
|
|
||||||
return {
|
|
||||||
deps: {
|
|
||||||
subscribe,
|
|
||||||
getCheckoutSnapshotFacts,
|
|
||||||
getCheckoutStatus,
|
|
||||||
getCheckoutShortstat,
|
|
||||||
getCheckoutWorktreeState,
|
|
||||||
runGitCommand,
|
|
||||||
runGitFetch,
|
|
||||||
workspaceFolderExists: async (cwd: string) => existsSync(cwd),
|
|
||||||
now: () => new Date(),
|
|
||||||
},
|
|
||||||
watchCallbackFor(directory: string): WatchCallback {
|
|
||||||
const callback = watchCallbacks.get(directory);
|
|
||||||
if (!callback) {
|
|
||||||
throw new Error(`No watcher was started for ${directory}`);
|
|
||||||
}
|
|
||||||
return callback;
|
|
||||||
},
|
|
||||||
watchAttemptsFor: (directory: string) => callsIn(subscribe, directory),
|
|
||||||
pollsOf: (cwd: string) => callsIn(getCheckoutShortstat, cwd),
|
|
||||||
fetchesIn: (cwd: string) => callsIn(runGitFetch, cwd),
|
|
||||||
gitCommandsIn(cwd: string): number {
|
|
||||||
const cwdCommands = [
|
|
||||||
getCheckoutSnapshotFacts,
|
|
||||||
getCheckoutStatus,
|
|
||||||
getCheckoutShortstat,
|
|
||||||
getCheckoutWorktreeState,
|
|
||||||
runGitFetch,
|
|
||||||
].reduce((count, fake) => count + callsIn(fake, cwd), 0);
|
|
||||||
const commandsRunInCwd = runGitCommand.mock.calls.filter(
|
|
||||||
([, runOptions]) => runOptions.cwd === cwd,
|
|
||||||
).length;
|
|
||||||
return cwdCommands + commandsRunInCwd;
|
|
||||||
},
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
function linkedWorktreeFacts(gitCommonDir: string) {
|
|
||||||
return (cwd: string): CheckoutSnapshotFacts => ({
|
|
||||||
...createCheckoutSnapshotFacts(cwd),
|
|
||||||
absoluteGitDir: join(gitCommonDir, "worktrees", path.basename(cwd)),
|
|
||||||
gitCommonDir,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
const SETTLE_ATTEMPTS = 1_000;
|
|
||||||
|
|
||||||
async function settle(assertion: () => void): Promise<void> {
|
|
||||||
let lastFailure: unknown = null;
|
|
||||||
for (let attempt = 0; attempt < SETTLE_ATTEMPTS; attempt += 1) {
|
|
||||||
try {
|
|
||||||
assertion();
|
|
||||||
return;
|
|
||||||
} catch (failure) {
|
|
||||||
lastFailure = failure;
|
|
||||||
}
|
|
||||||
await vi.advanceTimersByTimeAsync(0);
|
|
||||||
}
|
|
||||||
throw lastFailure;
|
|
||||||
}
|
|
||||||
|
|
||||||
async function waitForRefreshesToSettle(service: WorkspaceGitServiceImpl): Promise<void> {
|
|
||||||
await settle(() => {
|
|
||||||
expect(service.getMetrics().workspaceRefreshInFlightCount).toBe(0);
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
async function waitForObservationToSettle(
|
|
||||||
service: WorkspaceGitServiceImpl,
|
|
||||||
repositoryWorkspaceLinkCount: number,
|
|
||||||
): Promise<void> {
|
|
||||||
await settle(() => {
|
|
||||||
const metrics = service.getMetrics();
|
|
||||||
expect(metrics.repositoryWorkspaceLinkCount).toBe(repositoryWorkspaceLinkCount);
|
|
||||||
expect(metrics.workspaceObservationSetupInFlightCount).toBe(0);
|
|
||||||
expect(metrics.workspaceRefreshInFlightCount).toBe(0);
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
async function advanceThroughDegradedPolls(service: WorkspaceGitServiceImpl): Promise<void> {
|
|
||||||
for (const delayMs of [5_000, 10_000, 20_000, 40_000, 60_000, 60_000]) {
|
|
||||||
await vi.advanceTimersByTimeAsync(delayMs);
|
|
||||||
await waitForRefreshesToSettle(service);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async function advanceThroughWorkingTreeRecoveryAttempts(
|
|
||||||
service: WorkspaceGitServiceImpl,
|
|
||||||
): Promise<void> {
|
|
||||||
for (const delayMs of [30_000, 60_000, 120_000]) {
|
|
||||||
await vi.advanceTimersByTimeAsync(delayMs);
|
|
||||||
await waitForRefreshesToSettle(service);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
describe("WorkspaceGitServiceImpl", () => {
|
describe("WorkspaceGitServiceImpl", () => {
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
vi.useFakeTimers();
|
vi.useFakeTimers();
|
||||||
@@ -1700,460 +1553,3 @@ describe("WorkspaceGitServiceImpl", () => {
|
|||||||
service.dispose();
|
service.dispose();
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe("WorkspaceGitServiceImpl with workspace folders that no longer exist", () => {
|
|
||||||
const temporaryFolders: string[] = [];
|
|
||||||
|
|
||||||
async function createTemporaryFolder(): Promise<string> {
|
|
||||||
const folder = await mkdtemp(join(os.tmpdir(), "paseo-workspace-git-"));
|
|
||||||
temporaryFolders.push(folder);
|
|
||||||
return folder;
|
|
||||||
}
|
|
||||||
|
|
||||||
beforeEach(() => {
|
|
||||||
vi.useFakeTimers();
|
|
||||||
});
|
|
||||||
|
|
||||||
afterEach(async () => {
|
|
||||||
vi.useRealTimers();
|
|
||||||
await Promise.all(
|
|
||||||
temporaryFolders.splice(0).map((folder) => rm(folder, { recursive: true, force: true })),
|
|
||||||
);
|
|
||||||
});
|
|
||||||
|
|
||||||
test("polling stops for a workspace whose folder no longer exists", async () => {
|
|
||||||
const workspace = await createTemporaryFolder();
|
|
||||||
const git = createFolderBackedGit();
|
|
||||||
const service = createService(git.deps);
|
|
||||||
const subscription = service.registerWorkspace({ cwd: workspace }, vi.fn());
|
|
||||||
await waitForObservationToSettle(service, 1);
|
|
||||||
|
|
||||||
await rm(workspace, { recursive: true, force: true });
|
|
||||||
const gitCommandsAtDeletion = git.gitCommandsIn(workspace);
|
|
||||||
await advanceThroughDegradedPolls(service);
|
|
||||||
|
|
||||||
expect(git.gitCommandsIn(workspace)).toBe(gitCommandsAtDeletion);
|
|
||||||
expect(service.getMetrics()).toMatchObject({
|
|
||||||
repositoryTargetCount: 0,
|
|
||||||
workingTreeWatchTargetCount: 0,
|
|
||||||
});
|
|
||||||
expect(vi.getTimerCount()).toBe(1);
|
|
||||||
|
|
||||||
subscription.unsubscribe();
|
|
||||||
await service.dispose();
|
|
||||||
});
|
|
||||||
|
|
||||||
test("a deleted worktree leaves its shared repository polling and fetch to the surviving worktree", async () => {
|
|
||||||
const gitCommonDir = join(await createTemporaryFolder(), ".git");
|
|
||||||
const deletedWorktree = await createTemporaryFolder();
|
|
||||||
const survivingWorktree = await createTemporaryFolder();
|
|
||||||
const git = createFolderBackedGit({ facts: linkedWorktreeFacts(gitCommonDir) });
|
|
||||||
let survivingWorktreeEdits = 0;
|
|
||||||
git.deps.getCheckoutShortstat.mockImplementation(async (cwd: string) => ({
|
|
||||||
additions: cwd === survivingWorktree ? ++survivingWorktreeEdits : 1,
|
|
||||||
deletions: 0,
|
|
||||||
}));
|
|
||||||
const service = createService(git.deps);
|
|
||||||
const deletedSubscription = service.registerWorkspace({ cwd: deletedWorktree }, vi.fn());
|
|
||||||
await waitForObservationToSettle(service, 1);
|
|
||||||
const survivingSubscription = service.registerWorkspace({ cwd: survivingWorktree }, vi.fn());
|
|
||||||
await waitForObservationToSettle(service, 2);
|
|
||||||
|
|
||||||
await rm(deletedWorktree, { recursive: true, force: true });
|
|
||||||
await vi.advanceTimersByTimeAsync(60_000);
|
|
||||||
await waitForRefreshesToSettle(service);
|
|
||||||
const deletedWorktreeCommands = git.gitCommandsIn(deletedWorktree);
|
|
||||||
|
|
||||||
for (let tick = 0; tick < 3; tick += 1) {
|
|
||||||
const survivingWorktreePolls = git.pollsOf(survivingWorktree);
|
|
||||||
await vi.advanceTimersByTimeAsync(5_000);
|
|
||||||
await settle(() => {
|
|
||||||
expect(git.pollsOf(survivingWorktree)).toBe(survivingWorktreePolls + 1);
|
|
||||||
});
|
|
||||||
}
|
|
||||||
expect(git.gitCommandsIn(deletedWorktree)).toBe(deletedWorktreeCommands);
|
|
||||||
|
|
||||||
const fetchesBefore = git.deps.runGitFetch.mock.calls.length;
|
|
||||||
await vi.advanceTimersByTimeAsync(2 * BACKGROUND_GIT_FETCH_INTERVAL_MS);
|
|
||||||
expect(git.deps.runGitFetch.mock.calls.slice(fetchesBefore).map(([cwd]) => cwd)).toEqual([
|
|
||||||
survivingWorktree,
|
|
||||||
survivingWorktree,
|
|
||||||
]);
|
|
||||||
expect(git.gitCommandsIn(deletedWorktree)).toBe(deletedWorktreeCommands);
|
|
||||||
|
|
||||||
deletedSubscription.unsubscribe();
|
|
||||||
survivingSubscription.unsubscribe();
|
|
||||||
await service.dispose();
|
|
||||||
});
|
|
||||||
|
|
||||||
test("deleting every worktree of a repository stops its polling and background fetch", async () => {
|
|
||||||
const gitCommonDir = join(await createTemporaryFolder(), ".git");
|
|
||||||
const firstWorktree = await createTemporaryFolder();
|
|
||||||
const secondWorktree = await createTemporaryFolder();
|
|
||||||
const git = createFolderBackedGit({ facts: linkedWorktreeFacts(gitCommonDir) });
|
|
||||||
const service = createService(git.deps);
|
|
||||||
const firstSubscription = service.registerWorkspace({ cwd: firstWorktree }, vi.fn());
|
|
||||||
await waitForObservationToSettle(service, 1);
|
|
||||||
const secondSubscription = service.registerWorkspace({ cwd: secondWorktree }, vi.fn());
|
|
||||||
await waitForObservationToSettle(service, 2);
|
|
||||||
|
|
||||||
await rm(firstWorktree, { recursive: true, force: true });
|
|
||||||
await rm(secondWorktree, { recursive: true, force: true });
|
|
||||||
const commandsAtDeletion = [
|
|
||||||
git.gitCommandsIn(firstWorktree),
|
|
||||||
git.gitCommandsIn(secondWorktree),
|
|
||||||
];
|
|
||||||
await advanceThroughDegradedPolls(service);
|
|
||||||
|
|
||||||
expect([git.gitCommandsIn(firstWorktree), git.gitCommandsIn(secondWorktree)]).toEqual(
|
|
||||||
commandsAtDeletion,
|
|
||||||
);
|
|
||||||
expect(service.getMetrics()).toMatchObject({
|
|
||||||
repositoryTargetCount: 0,
|
|
||||||
workingTreeWatchTargetCount: 0,
|
|
||||||
});
|
|
||||||
expect(vi.getTimerCount()).toBe(2);
|
|
||||||
|
|
||||||
firstSubscription.unsubscribe();
|
|
||||||
secondSubscription.unsubscribe();
|
|
||||||
await service.dispose();
|
|
||||||
});
|
|
||||||
|
|
||||||
test("working tree fallback polling and watcher recovery stop for a workspace whose folder no longer exists", async () => {
|
|
||||||
const workspace = await createTemporaryFolder();
|
|
||||||
await mkdir(join(workspace, ".git"));
|
|
||||||
const git = createFolderBackedGit();
|
|
||||||
const service = createService(git.deps);
|
|
||||||
const subscription = service.registerWorkspace({ cwd: workspace }, vi.fn());
|
|
||||||
await waitForObservationToSettle(service, 1);
|
|
||||||
|
|
||||||
await rm(workspace, { recursive: true, force: true });
|
|
||||||
git.watchCallbackFor(workspace)(new Error("watched directory was deleted"), []);
|
|
||||||
await vi.advanceTimersByTimeAsync(1_000);
|
|
||||||
await waitForRefreshesToSettle(service);
|
|
||||||
const commandsAfterWatcherError = git.gitCommandsIn(workspace);
|
|
||||||
const watchAttemptsAfterWatcherError = git.watchAttemptsFor(workspace);
|
|
||||||
await advanceThroughDegradedPolls(service);
|
|
||||||
|
|
||||||
expect(git.gitCommandsIn(workspace)).toBe(commandsAfterWatcherError);
|
|
||||||
expect(git.watchAttemptsFor(workspace)).toBe(watchAttemptsAfterWatcherError);
|
|
||||||
expect(vi.getTimerCount()).toBe(1);
|
|
||||||
|
|
||||||
subscription.unsubscribe();
|
|
||||||
await service.dispose();
|
|
||||||
});
|
|
||||||
|
|
||||||
test("a workspace whose folder comes back resumes polling on its next subscription", async () => {
|
|
||||||
const workspace = await createTemporaryFolder();
|
|
||||||
const git = createFolderBackedGit();
|
|
||||||
const service = createService(git.deps);
|
|
||||||
const firstSubscription = service.registerWorkspace({ cwd: workspace }, vi.fn());
|
|
||||||
await waitForObservationToSettle(service, 1);
|
|
||||||
await rm(workspace, { recursive: true, force: true });
|
|
||||||
const commandsWhileMissing = git.gitCommandsIn(workspace);
|
|
||||||
await advanceThroughDegradedPolls(service);
|
|
||||||
expect(git.gitCommandsIn(workspace)).toBe(commandsWhileMissing);
|
|
||||||
|
|
||||||
await mkdir(workspace);
|
|
||||||
const secondSubscription = service.registerWorkspace({ cwd: workspace }, vi.fn());
|
|
||||||
await waitForObservationToSettle(service, 1);
|
|
||||||
const pollsAfterResume = git.pollsOf(workspace);
|
|
||||||
await vi.advanceTimersByTimeAsync(5_000);
|
|
||||||
await waitForRefreshesToSettle(service);
|
|
||||||
|
|
||||||
expect(git.pollsOf(workspace)).toBe(pollsAfterResume + 1);
|
|
||||||
|
|
||||||
firstSubscription.unsubscribe();
|
|
||||||
secondSubscription.unsubscribe();
|
|
||||||
await service.dispose();
|
|
||||||
});
|
|
||||||
|
|
||||||
test("a suspended workspace costs no git while its folder is missing, even when a refresh is requested", async () => {
|
|
||||||
const workspace = await createTemporaryFolder();
|
|
||||||
const git = createFolderBackedGit();
|
|
||||||
const service = createService(git.deps);
|
|
||||||
const subscription = service.registerWorkspace({ cwd: workspace }, vi.fn());
|
|
||||||
await waitForObservationToSettle(service, 1);
|
|
||||||
await rm(workspace, { recursive: true, force: true });
|
|
||||||
await advanceThroughDegradedPolls(service);
|
|
||||||
const commandsWhileMissing = git.gitCommandsIn(workspace);
|
|
||||||
|
|
||||||
for (let probe = 0; probe < 10; probe += 1) {
|
|
||||||
await vi.advanceTimersByTimeAsync(WORKSPACE_GIT_OBSERVATION_REENSURE_INTERVAL_MS);
|
|
||||||
await waitForRefreshesToSettle(service);
|
|
||||||
}
|
|
||||||
service.scheduleRefreshForCwd(workspace);
|
|
||||||
service.onWorkspaceStateMayHaveChanged(workspace);
|
|
||||||
await vi.advanceTimersByTimeAsync(5_000);
|
|
||||||
await waitForRefreshesToSettle(service);
|
|
||||||
|
|
||||||
expect(git.gitCommandsIn(workspace)).toBe(commandsWhileMissing);
|
|
||||||
expect(vi.getTimerCount()).toBe(1);
|
|
||||||
|
|
||||||
subscription.unsubscribe();
|
|
||||||
await service.dispose();
|
|
||||||
});
|
|
||||||
|
|
||||||
test("a suspended worktree resumes polling and its repository fetch once its folder is back, without a new subscription", async () => {
|
|
||||||
const gitCommonDir = join(await createTemporaryFolder(), ".git");
|
|
||||||
const worktree = await createTemporaryFolder();
|
|
||||||
const git = createFolderBackedGit({ facts: linkedWorktreeFacts(gitCommonDir) });
|
|
||||||
const service = createService(git.deps);
|
|
||||||
const subscription = service.registerWorkspace({ cwd: worktree }, vi.fn());
|
|
||||||
await waitForObservationToSettle(service, 1);
|
|
||||||
await rm(worktree, { recursive: true, force: true });
|
|
||||||
await advanceThroughDegradedPolls(service);
|
|
||||||
await waitForObservationToSettle(service, 0);
|
|
||||||
|
|
||||||
await mkdir(worktree);
|
|
||||||
const fetchesBeforeRestore = git.fetchesIn(worktree);
|
|
||||||
await vi.advanceTimersByTimeAsync(WORKSPACE_GIT_OBSERVATION_REENSURE_INTERVAL_MS);
|
|
||||||
await waitForObservationToSettle(service, 1);
|
|
||||||
expect(git.fetchesIn(worktree)).toBe(fetchesBeforeRestore + 1);
|
|
||||||
|
|
||||||
const pollsAfterRestore = git.pollsOf(worktree);
|
|
||||||
await vi.advanceTimersByTimeAsync(5_000);
|
|
||||||
await waitForRefreshesToSettle(service);
|
|
||||||
expect(git.pollsOf(worktree)).toBe(pollsAfterRestore + 1);
|
|
||||||
|
|
||||||
subscription.unsubscribe();
|
|
||||||
await service.dispose();
|
|
||||||
});
|
|
||||||
|
|
||||||
test("a forced snapshot resumes a suspended workspace whose folder is back", async () => {
|
|
||||||
const workspace = await createTemporaryFolder();
|
|
||||||
const git = createFolderBackedGit();
|
|
||||||
const service = createService(git.deps);
|
|
||||||
const subscription = service.registerWorkspace({ cwd: workspace }, vi.fn());
|
|
||||||
await waitForObservationToSettle(service, 1);
|
|
||||||
await rm(workspace, { recursive: true, force: true });
|
|
||||||
await advanceThroughDegradedPolls(service);
|
|
||||||
await waitForObservationToSettle(service, 0);
|
|
||||||
|
|
||||||
await mkdir(workspace);
|
|
||||||
const snapshot = await service.getSnapshot(workspace, {
|
|
||||||
force: true,
|
|
||||||
reason: "refresh-button",
|
|
||||||
});
|
|
||||||
await waitForObservationToSettle(service, 1);
|
|
||||||
|
|
||||||
expect(snapshot.git.isGit).toBe(true);
|
|
||||||
|
|
||||||
subscription.unsubscribe();
|
|
||||||
await service.dispose();
|
|
||||||
});
|
|
||||||
|
|
||||||
test("a resumed workspace rebuilds observation from fresh facts, not the non-git facts cached while its folder was gone", async () => {
|
|
||||||
const workspace = await createTemporaryFolder();
|
|
||||||
const git = createFolderBackedGit();
|
|
||||||
const service = createService(git.deps);
|
|
||||||
const firstSubscription = service.registerWorkspace({ cwd: workspace }, vi.fn());
|
|
||||||
await waitForObservationToSettle(service, 1);
|
|
||||||
await rm(workspace, { recursive: true, force: true });
|
|
||||||
service.onWorkspaceStateMayHaveChanged(workspace);
|
|
||||||
await vi.advanceTimersByTimeAsync(1_000);
|
|
||||||
await waitForRefreshesToSettle(service);
|
|
||||||
expect((await service.getSnapshot(workspace)).git.isGit).toBe(false);
|
|
||||||
await advanceThroughDegradedPolls(service);
|
|
||||||
await waitForObservationToSettle(service, 0);
|
|
||||||
|
|
||||||
await mkdir(workspace);
|
|
||||||
const secondSubscription = service.registerWorkspace({ cwd: workspace }, vi.fn());
|
|
||||||
await waitForObservationToSettle(service, 1);
|
|
||||||
|
|
||||||
expect((await service.getSnapshot(workspace)).git.isGit).toBe(true);
|
|
||||||
|
|
||||||
firstSubscription.unsubscribe();
|
|
||||||
secondSubscription.unsubscribe();
|
|
||||||
await service.dispose();
|
|
||||||
});
|
|
||||||
|
|
||||||
test("resuming waits for an observation setup that was still running when the folder vanished", async () => {
|
|
||||||
const workspace = await createTemporaryFolder();
|
|
||||||
const gitDir = join(workspace, ".git");
|
|
||||||
await mkdir(gitDir);
|
|
||||||
const metadataWatchRelease = createDeferred<void>();
|
|
||||||
const git = createFolderBackedGit({
|
|
||||||
unwatchableDirectories: [workspace],
|
|
||||||
heldWatch: { directory: gitDir, release: metadataWatchRelease.promise },
|
|
||||||
});
|
|
||||||
const service = createService(git.deps);
|
|
||||||
const firstSubscription = service.registerWorkspace({ cwd: workspace }, vi.fn());
|
|
||||||
await settle(() => {
|
|
||||||
expect(git.watchAttemptsFor(gitDir)).toBe(1);
|
|
||||||
});
|
|
||||||
|
|
||||||
await rm(workspace, { recursive: true, force: true });
|
|
||||||
await vi.advanceTimersByTimeAsync(5_000);
|
|
||||||
await settle(() => {
|
|
||||||
expect(service.getMetrics()).toMatchObject({
|
|
||||||
repositoryTargetCount: 0,
|
|
||||||
workingTreeWatchTargetCount: 0,
|
|
||||||
});
|
|
||||||
});
|
|
||||||
await mkdir(workspace);
|
|
||||||
const secondSubscription = service.registerWorkspace({ cwd: workspace }, vi.fn());
|
|
||||||
metadataWatchRelease.resolve();
|
|
||||||
await waitForObservationToSettle(service, 1);
|
|
||||||
|
|
||||||
expect(service.getMetrics().workingTreeWatchTargetCount).toBe(1);
|
|
||||||
|
|
||||||
firstSubscription.unsubscribe();
|
|
||||||
secondSubscription.unsubscribe();
|
|
||||||
await service.dispose();
|
|
||||||
});
|
|
||||||
|
|
||||||
test("an open working tree watch neither notifies nor recovers while its folder is missing, and picks up once it is back", async () => {
|
|
||||||
const workspace = await createTemporaryFolder();
|
|
||||||
await mkdir(join(workspace, ".git"));
|
|
||||||
const git = createFolderBackedGit();
|
|
||||||
const service = createService(git.deps);
|
|
||||||
const workspaceSubscription = service.registerWorkspace({ cwd: workspace }, vi.fn());
|
|
||||||
await waitForObservationToSettle(service, 1);
|
|
||||||
const diffListener = vi.fn();
|
|
||||||
const diffWatch = await service.requestWorkingTreeWatch(workspace, diffListener);
|
|
||||||
|
|
||||||
await rm(workspace, { recursive: true, force: true });
|
|
||||||
git.watchCallbackFor(workspace)(new Error("watched directory was deleted"), []);
|
|
||||||
await vi.advanceTimersByTimeAsync(1_000);
|
|
||||||
await waitForRefreshesToSettle(service);
|
|
||||||
const commandsAfterWatcherError = git.gitCommandsIn(workspace);
|
|
||||||
const notificationsAfterWatcherError = diffListener.mock.calls.length;
|
|
||||||
const watchAttemptsAfterWatcherError = git.watchAttemptsFor(workspace);
|
|
||||||
await advanceThroughWorkingTreeRecoveryAttempts(service);
|
|
||||||
|
|
||||||
expect(git.gitCommandsIn(workspace)).toBe(commandsAfterWatcherError);
|
|
||||||
expect(diffListener.mock.calls.length).toBe(notificationsAfterWatcherError);
|
|
||||||
expect(git.watchAttemptsFor(workspace)).toBe(watchAttemptsAfterWatcherError);
|
|
||||||
expect(service.getMetrics().workingTreeWatchTargetCount).toBe(1);
|
|
||||||
|
|
||||||
await mkdir(join(workspace, ".git"), { recursive: true });
|
|
||||||
await vi.advanceTimersByTimeAsync(60_000);
|
|
||||||
await waitForRefreshesToSettle(service);
|
|
||||||
expect(diffListener.mock.calls.length).toBe(notificationsAfterWatcherError + 1);
|
|
||||||
|
|
||||||
await vi.advanceTimersByTimeAsync(240_000);
|
|
||||||
await waitForRefreshesToSettle(service);
|
|
||||||
expect(git.watchAttemptsFor(workspace)).toBe(watchAttemptsAfterWatcherError + 1);
|
|
||||||
const notificationsAfterResubscribe = diffListener.mock.calls.length;
|
|
||||||
await vi.advanceTimersByTimeAsync(120_000);
|
|
||||||
await waitForRefreshesToSettle(service);
|
|
||||||
expect(diffListener.mock.calls.length).toBe(notificationsAfterResubscribe);
|
|
||||||
|
|
||||||
diffWatch.unsubscribe();
|
|
||||||
workspaceSubscription.unsubscribe();
|
|
||||||
await service.dispose();
|
|
||||||
});
|
|
||||||
|
|
||||||
test("a refresh crossing suspension preserves the cached snapshot and drops queued work", async () => {
|
|
||||||
const workspace = await createTemporaryFolder();
|
|
||||||
const git = createFolderBackedGit();
|
|
||||||
const started = createDeferred<void>();
|
|
||||||
const release = createDeferred<void>();
|
|
||||||
let holdNextRead = false;
|
|
||||||
let factsReads = 0;
|
|
||||||
const service = createService({
|
|
||||||
...git.deps,
|
|
||||||
getCheckoutSnapshotFacts: async (cwd: string) => {
|
|
||||||
factsReads++;
|
|
||||||
if (holdNextRead) {
|
|
||||||
holdNextRead = false;
|
|
||||||
started.resolve();
|
|
||||||
await release.promise;
|
|
||||||
}
|
|
||||||
return git.deps.getCheckoutSnapshotFacts(cwd);
|
|
||||||
},
|
|
||||||
});
|
|
||||||
const updates: WorkspaceGitRuntimeSnapshot[] = [];
|
|
||||||
const subscription = service.registerWorkspace({ cwd: workspace }, (snapshot) =>
|
|
||||||
updates.push(snapshot),
|
|
||||||
);
|
|
||||||
await waitForObservationToSettle(service, 1);
|
|
||||||
const cached = await service.getSnapshot(workspace);
|
|
||||||
holdNextRead = true;
|
|
||||||
const active = service.getSnapshot(workspace, {
|
|
||||||
force: true,
|
|
||||||
includeForge: false,
|
|
||||||
reason: "overlapping-refresh",
|
|
||||||
});
|
|
||||||
await started.promise;
|
|
||||||
const queued = service.getSnapshot(workspace, {
|
|
||||||
force: true,
|
|
||||||
includeForge: true,
|
|
||||||
reason: "queued-refresh",
|
|
||||||
});
|
|
||||||
await rm(workspace, { recursive: true, force: true });
|
|
||||||
await vi.advanceTimersByTimeAsync(5_000);
|
|
||||||
await settle(() => expect(service.getMetrics().repositoryWorkspaceLinkCount).toBe(0));
|
|
||||||
const readsAtSuspension = factsReads;
|
|
||||||
const updatesAtSuspension = updates.length;
|
|
||||||
release.resolve();
|
|
||||||
expect(await active).toEqual(cached);
|
|
||||||
expect(await queued).toEqual(cached);
|
|
||||||
expect(await service.getSnapshot(workspace)).toEqual(cached);
|
|
||||||
expect(updates).toHaveLength(updatesAtSuspension);
|
|
||||||
expect(factsReads).toBe(readsAtSuspension);
|
|
||||||
subscription.unsubscribe();
|
|
||||||
await service.dispose();
|
|
||||||
});
|
|
||||||
|
|
||||||
test("restoration settles a suspended refresh before reloading facts", async () => {
|
|
||||||
const workspace = await createTemporaryFolder();
|
|
||||||
const git = createFolderBackedGit();
|
|
||||||
const staleFacts = createDeferred<CheckoutSnapshotFacts>();
|
|
||||||
const started = createDeferred<void>();
|
|
||||||
let holdNextRead = false;
|
|
||||||
const service = createService({
|
|
||||||
...git.deps,
|
|
||||||
getCheckoutSnapshotFacts: async (cwd: string) => {
|
|
||||||
if (holdNextRead) {
|
|
||||||
holdNextRead = false;
|
|
||||||
started.resolve();
|
|
||||||
return staleFacts.promise;
|
|
||||||
}
|
|
||||||
return git.deps.getCheckoutSnapshotFacts(cwd);
|
|
||||||
},
|
|
||||||
});
|
|
||||||
const updates: WorkspaceGitRuntimeSnapshot[] = [];
|
|
||||||
const subscription = service.registerWorkspace({ cwd: workspace }, (snapshot) =>
|
|
||||||
updates.push(snapshot),
|
|
||||||
);
|
|
||||||
await waitForObservationToSettle(service, 1);
|
|
||||||
const cached = await service.getSnapshot(workspace);
|
|
||||||
holdNextRead = true;
|
|
||||||
const active = service.getSnapshot(workspace, {
|
|
||||||
force: true,
|
|
||||||
includeForge: false,
|
|
||||||
reason: "overlapping-refresh",
|
|
||||||
});
|
|
||||||
await started.promise;
|
|
||||||
await rm(workspace, { recursive: true, force: true });
|
|
||||||
await vi.advanceTimersByTimeAsync(5_000);
|
|
||||||
await settle(() => expect(service.getMetrics().repositoryWorkspaceLinkCount).toBe(0));
|
|
||||||
const updatesAtSuspension = updates.length;
|
|
||||||
await mkdir(workspace);
|
|
||||||
const restored = service.getSnapshot(workspace, {
|
|
||||||
force: true,
|
|
||||||
includeForge: false,
|
|
||||||
reason: "folder-returned",
|
|
||||||
});
|
|
||||||
await vi.advanceTimersByTimeAsync(0);
|
|
||||||
staleFacts.resolve({ isGit: false });
|
|
||||||
expect(await active).toEqual(cached);
|
|
||||||
expect((await restored).git.isGit).toBe(true);
|
|
||||||
expect(updates.slice(updatesAtSuspension).every((snapshot) => snapshot.git.isGit)).toBe(true);
|
|
||||||
await waitForObservationToSettle(service, 1);
|
|
||||||
subscription.unsubscribe();
|
|
||||||
await service.dispose();
|
|
||||||
});
|
|
||||||
|
|
||||||
test("workspaceFolderExists reports a missing folder and a path under a file as missing", async () => {
|
|
||||||
const folder = await createTemporaryFolder();
|
|
||||||
const file = join(folder, "file.txt");
|
|
||||||
await writeFile(file, "");
|
|
||||||
|
|
||||||
expect(await workspaceFolderExists(folder)).toBe(true);
|
|
||||||
expect(await workspaceFolderExists(join(folder, "missing"))).toBe(false);
|
|
||||||
expect(await workspaceFolderExists(join(file, "nested"))).toBe(false);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { createHash } from "node:crypto";
|
import { createHash } from "node:crypto";
|
||||||
import { readFile, stat } from "node:fs/promises";
|
import { readFile } from "node:fs/promises";
|
||||||
import { basename, dirname, join, resolve } from "node:path";
|
import { basename, dirname, join, resolve } from "node:path";
|
||||||
import { LRUCache } from "lru-cache";
|
import { LRUCache } from "lru-cache";
|
||||||
import { CheckoutDiffCache } from "./checkout-diff-cache.js";
|
import { CheckoutDiffCache } from "./checkout-diff-cache.js";
|
||||||
@@ -74,7 +74,7 @@ import { checkoutLiteFromGitSnapshot } from "./workspace-registry-model.js";
|
|||||||
import { createWatcherLivenessCanary } from "./watcher-liveness-canary.js";
|
import { createWatcherLivenessCanary } from "./watcher-liveness-canary.js";
|
||||||
|
|
||||||
const WORKSPACE_GIT_WATCH_DEBOUNCE_MS = 1_000;
|
const WORKSPACE_GIT_WATCH_DEBOUNCE_MS = 1_000;
|
||||||
export const BACKGROUND_GIT_FETCH_INTERVAL_MS = 180_000;
|
const BACKGROUND_GIT_FETCH_INTERVAL_MS = 180_000;
|
||||||
const FETCH_METADATA_ECHO_TTL_MS = 5_000;
|
const FETCH_METADATA_ECHO_TTL_MS = 5_000;
|
||||||
export const WORKSPACE_GIT_OBSERVATION_REENSURE_INTERVAL_MS = 60_000;
|
export const WORKSPACE_GIT_OBSERVATION_REENSURE_INTERVAL_MS = 60_000;
|
||||||
const FORGE_PR_STATUS_POLL_FAST_INTERVAL_MS = 20_000;
|
const FORGE_PR_STATUS_POLL_FAST_INTERVAL_MS = 20_000;
|
||||||
@@ -384,7 +384,6 @@ interface WorkspaceGitServiceDependencies {
|
|||||||
runGitCommand: typeof runGitCommand;
|
runGitCommand: typeof runGitCommand;
|
||||||
getWorkspaceGitSelfHealPhaseMs: typeof getWorkspaceGitObservationReensurePhaseMs;
|
getWorkspaceGitSelfHealPhaseMs: typeof getWorkspaceGitObservationReensurePhaseMs;
|
||||||
createWatcherLivenessCanary: typeof createWatcherLivenessCanary;
|
createWatcherLivenessCanary: typeof createWatcherLivenessCanary;
|
||||||
workspaceFolderExists: (cwd: string) => Promise<boolean>;
|
|
||||||
now: () => Date;
|
now: () => Date;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -436,8 +435,6 @@ interface WorkspaceGitTarget {
|
|||||||
repoGitRoot: string | null;
|
repoGitRoot: string | null;
|
||||||
observationSetupPromise: Promise<void> | null;
|
observationSetupPromise: Promise<void> | null;
|
||||||
observationSetupComplete: boolean;
|
observationSetupComplete: boolean;
|
||||||
folderMissing: boolean;
|
|
||||||
folderRestorePromise: Promise<void> | null;
|
|
||||||
closed: boolean;
|
closed: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -574,21 +571,10 @@ function buildDefaultWorkspaceGitServiceDeps(
|
|||||||
runGitCommand,
|
runGitCommand,
|
||||||
getWorkspaceGitSelfHealPhaseMs: getWorkspaceGitObservationReensurePhaseMs,
|
getWorkspaceGitSelfHealPhaseMs: getWorkspaceGitObservationReensurePhaseMs,
|
||||||
createWatcherLivenessCanary,
|
createWatcherLivenessCanary,
|
||||||
workspaceFolderExists,
|
|
||||||
now: () => new Date(),
|
now: () => new Date(),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function workspaceFolderExists(cwd: string): Promise<boolean> {
|
|
||||||
try {
|
|
||||||
await stat(cwd);
|
|
||||||
return true;
|
|
||||||
} catch (error) {
|
|
||||||
const code = (error as NodeJS.ErrnoException).code;
|
|
||||||
return code !== "ENOENT" && code !== "ENOTDIR";
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function resolveWorkspaceGitServiceDeps(
|
function resolveWorkspaceGitServiceDeps(
|
||||||
subscribe: SubscribeToFileChanges,
|
subscribe: SubscribeToFileChanges,
|
||||||
deps: Partial<WorkspaceGitServiceDependencies> | undefined,
|
deps: Partial<WorkspaceGitServiceDependencies> | undefined,
|
||||||
@@ -674,9 +660,6 @@ export class WorkspaceGitServiceImpl implements WorkspaceGitService {
|
|||||||
const cwd = resolve(params.cwd);
|
const cwd = resolve(params.cwd);
|
||||||
const target = this.ensureWorkspaceTarget(cwd);
|
const target = this.ensureWorkspaceTarget(cwd);
|
||||||
target.listeners.add(listener);
|
target.listeners.add(listener);
|
||||||
if (target.folderMissing) {
|
|
||||||
void this.resumeWorkspaceIfFolderRestored(target);
|
|
||||||
}
|
|
||||||
if (target.listeners.size === 1) {
|
if (target.listeners.size === 1) {
|
||||||
this.startWorkspaceSubscriptionTimers(target);
|
this.startWorkspaceSubscriptionTimers(target);
|
||||||
}
|
}
|
||||||
@@ -768,9 +751,6 @@ export class WorkspaceGitServiceImpl implements WorkspaceGitService {
|
|||||||
if (!request.force && target.latestSnapshot) {
|
if (!request.force && target.latestSnapshot) {
|
||||||
return target.latestSnapshot;
|
return target.latestSnapshot;
|
||||||
}
|
}
|
||||||
if (target.folderMissing) {
|
|
||||||
await this.resumeWorkspaceIfFolderRestored(target);
|
|
||||||
}
|
|
||||||
|
|
||||||
return this.requestWorkspaceSnapshot(target, request);
|
return this.requestWorkspaceSnapshot(target, request);
|
||||||
}
|
}
|
||||||
@@ -992,9 +972,6 @@ export class WorkspaceGitServiceImpl implements WorkspaceGitService {
|
|||||||
this.assertNotDisposed();
|
this.assertNotDisposed();
|
||||||
cwd = resolve(cwd);
|
cwd = resolve(cwd);
|
||||||
const target = this.ensureWorkspaceTarget(cwd);
|
const target = this.ensureWorkspaceTarget(cwd);
|
||||||
if (target.folderMissing) {
|
|
||||||
await this.resumeWorkspaceIfFolderRestored(target);
|
|
||||||
}
|
|
||||||
await this.refreshWorkspaceTarget(target, {
|
await this.refreshWorkspaceTarget(target, {
|
||||||
force: false,
|
force: false,
|
||||||
refreshStructure: true,
|
refreshStructure: true,
|
||||||
@@ -1028,14 +1005,9 @@ export class WorkspaceGitServiceImpl implements WorkspaceGitService {
|
|||||||
this.assertNotDisposed();
|
this.assertNotDisposed();
|
||||||
cwd = resolve(cwd);
|
cwd = resolve(cwd);
|
||||||
const target = this.workspaceTargets.get(cwd);
|
const target = this.workspaceTargets.get(cwd);
|
||||||
if (!target) {
|
if (target) {
|
||||||
return;
|
this.scheduleWorkspaceRefresh(target, { queueIfBusy: false });
|
||||||
}
|
}
|
||||||
if (target.folderMissing) {
|
|
||||||
void this.resumeWorkspaceIfFolderRestored(target);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
this.scheduleWorkspaceRefresh(target, { queueIfBusy: false });
|
|
||||||
}
|
}
|
||||||
|
|
||||||
onWorkspaceStateMayHaveChanged(cwd: string): void {
|
onWorkspaceStateMayHaveChanged(cwd: string): void {
|
||||||
@@ -1046,10 +1018,6 @@ export class WorkspaceGitServiceImpl implements WorkspaceGitService {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
this.invalidateForge(normalizedCwd);
|
this.invalidateForge(normalizedCwd);
|
||||||
if (target.folderMissing) {
|
|
||||||
void this.resumeWorkspaceIfFolderRestored(target);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
this.scheduleWorkspaceRefresh(target, {
|
this.scheduleWorkspaceRefresh(target, {
|
||||||
force: true,
|
force: true,
|
||||||
includeForge: true,
|
includeForge: true,
|
||||||
@@ -1259,8 +1227,6 @@ export class WorkspaceGitServiceImpl implements WorkspaceGitService {
|
|||||||
repoGitRoot: null,
|
repoGitRoot: null,
|
||||||
observationSetupPromise: null,
|
observationSetupPromise: null,
|
||||||
observationSetupComplete: false,
|
observationSetupComplete: false,
|
||||||
folderMissing: false,
|
|
||||||
folderRestorePromise: null,
|
|
||||||
closed: false,
|
closed: false,
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -1392,7 +1358,6 @@ export class WorkspaceGitServiceImpl implements WorkspaceGitService {
|
|||||||
private isActiveObservedWorkspaceTarget(target: WorkspaceGitTarget): boolean {
|
private isActiveObservedWorkspaceTarget(target: WorkspaceGitTarget): boolean {
|
||||||
return (
|
return (
|
||||||
!target.closed &&
|
!target.closed &&
|
||||||
!target.folderMissing &&
|
|
||||||
target.listeners.size > 0 &&
|
target.listeners.size > 0 &&
|
||||||
this.workspaceTargets.get(target.cwd) === target
|
this.workspaceTargets.get(target.cwd) === target
|
||||||
);
|
);
|
||||||
@@ -1647,110 +1612,11 @@ export class WorkspaceGitServiceImpl implements WorkspaceGitService {
|
|||||||
workspaceTarget: WorkspaceGitTarget,
|
workspaceTarget: WorkspaceGitTarget,
|
||||||
request: WorkspaceGitRefreshRequest,
|
request: WorkspaceGitRefreshRequest,
|
||||||
): Promise<boolean> {
|
): Promise<boolean> {
|
||||||
if (await this.suspendWorkspaceIfFolderMissing(workspaceTarget)) {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
const fingerprintBefore = workspaceTarget.latestFingerprint;
|
const fingerprintBefore = workspaceTarget.latestFingerprint;
|
||||||
await this.refreshWorkspaceTarget(workspaceTarget, request);
|
await this.refreshWorkspaceTarget(workspaceTarget, request);
|
||||||
return workspaceTarget.latestFingerprint !== fingerprintBefore;
|
return workspaceTarget.latestFingerprint !== fingerprintBefore;
|
||||||
}
|
}
|
||||||
|
|
||||||
private async suspendWorkspaceIfFolderMissing(target: WorkspaceGitTarget): Promise<boolean> {
|
|
||||||
if (target.folderMissing) {
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
if (await this.deps.workspaceFolderExists(target.cwd)) {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
this.suspendWorkspaceWithMissingFolder(target);
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
|
|
||||||
private suspendWorkspaceWithMissingFolder(target: WorkspaceGitTarget): void {
|
|
||||||
if (target.folderMissing || target.closed || this.workspaceTargets.get(target.cwd) !== target) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
target.folderMissing = true;
|
|
||||||
if (target.refreshState.status === "in-flight") {
|
|
||||||
target.refreshState.queued = null;
|
|
||||||
}
|
|
||||||
this.detachWorkspaceFromRepoTarget(target);
|
|
||||||
target.repoGitRoot = null;
|
|
||||||
this.stopWorkspaceObservation(target);
|
|
||||||
target.observationSetupComplete = false;
|
|
||||||
this.scheduleMissingFolderProbe(target);
|
|
||||||
this.logger.warn(
|
|
||||||
{ cwd: target.cwd },
|
|
||||||
"Workspace folder no longer exists; suspended git observation until it is back",
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
private isSuspendedWorkspaceTarget(target: WorkspaceGitTarget): boolean {
|
|
||||||
return (
|
|
||||||
target.folderMissing && !target.closed && this.workspaceTargets.get(target.cwd) === target
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
private scheduleMissingFolderProbe(target: WorkspaceGitTarget): void {
|
|
||||||
if (
|
|
||||||
!this.isSuspendedWorkspaceTarget(target) ||
|
|
||||||
target.observationReensureTimer ||
|
|
||||||
target.folderRestorePromise
|
|
||||||
) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
target.observationReensureTimer = setTimeout(() => {
|
|
||||||
target.observationReensureTimer = null;
|
|
||||||
void this.resumeWorkspaceIfFolderRestored(target);
|
|
||||||
}, WORKSPACE_GIT_OBSERVATION_REENSURE_INTERVAL_MS);
|
|
||||||
}
|
|
||||||
|
|
||||||
private resumeWorkspaceIfFolderRestored(target: WorkspaceGitTarget): Promise<void> {
|
|
||||||
if (!target.folderMissing) {
|
|
||||||
return Promise.resolve();
|
|
||||||
}
|
|
||||||
target.folderRestorePromise ??= this.resumeWorkspaceWithRestoredFolder(target).finally(() => {
|
|
||||||
target.folderRestorePromise = null;
|
|
||||||
this.scheduleMissingFolderProbe(target);
|
|
||||||
});
|
|
||||||
return target.folderRestorePromise;
|
|
||||||
}
|
|
||||||
|
|
||||||
private async resumeWorkspaceWithRestoredFolder(target: WorkspaceGitTarget): Promise<void> {
|
|
||||||
if (!(await this.deps.workspaceFolderExists(target.cwd))) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
await target.observationSetupPromise;
|
|
||||||
if (target.refreshState.status === "in-flight") {
|
|
||||||
await target.refreshState.promise.catch(() => undefined);
|
|
||||||
}
|
|
||||||
if (!this.isSuspendedWorkspaceTarget(target)) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
target.latestFacts = null;
|
|
||||||
target.folderMissing = false;
|
|
||||||
await this.refreshWorkspaceTarget(target, {
|
|
||||||
force: false,
|
|
||||||
refreshStructure: true,
|
|
||||||
refreshWorktree: true,
|
|
||||||
includeForge: false,
|
|
||||||
reason: "workspace-folder-restored",
|
|
||||||
queueIfBusy: true,
|
|
||||||
movedRemoteRefs: new Set(),
|
|
||||||
});
|
|
||||||
if (target.closed || this.workspaceTargets.get(target.cwd) !== target) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
target.observationSetupComplete = false;
|
|
||||||
if (target.observationReensureTimer) {
|
|
||||||
clearTimeout(target.observationReensureTimer);
|
|
||||||
target.observationReensureTimer = null;
|
|
||||||
}
|
|
||||||
this.logger.info({ cwd: target.cwd }, "Workspace folder is back; resumed git observation");
|
|
||||||
this.startWorkspaceSubscriptionTimers(target);
|
|
||||||
this.scheduleWorkspaceObservationSetup(target);
|
|
||||||
}
|
|
||||||
|
|
||||||
private startWorkingTreeWatchFallback(
|
private startWorkingTreeWatchFallback(
|
||||||
target: WorkingTreeWatchTarget,
|
target: WorkingTreeWatchTarget,
|
||||||
reason: WorkingTreeWatchFallbackReason,
|
reason: WorkingTreeWatchFallbackReason,
|
||||||
@@ -1788,9 +1654,7 @@ export class WorkspaceGitServiceImpl implements WorkspaceGitService {
|
|||||||
return changed;
|
return changed;
|
||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
if (await this.deps.workspaceFolderExists(target.watchPath)) {
|
this.notifyWorkingTreeConsumers(target);
|
||||||
this.notifyWorkingTreeConsumers(target);
|
|
||||||
}
|
|
||||||
if (!target.closed && (target.subscription === null || target.repoRoot === null)) {
|
if (!target.closed && (target.subscription === null || target.repoRoot === null)) {
|
||||||
target.fallbackPollQuietTickCount = changes.some(Boolean)
|
target.fallbackPollQuietTickCount = changes.some(Boolean)
|
||||||
? 0
|
? 0
|
||||||
@@ -1867,10 +1731,6 @@ export class WorkspaceGitServiceImpl implements WorkspaceGitService {
|
|||||||
if (target.closed || target.subscription) {
|
if (target.closed || target.subscription) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (!(await this.deps.workspaceFolderExists(target.watchPath))) {
|
|
||||||
this.scheduleWorkingTreeWatchRecovery(target);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
await this.refreshWorkingTreeIgnoredDirectories(target);
|
await this.refreshWorkingTreeIgnoredDirectories(target);
|
||||||
if (target.closed || target.subscription) {
|
if (target.closed || target.subscription) {
|
||||||
return;
|
return;
|
||||||
@@ -2926,7 +2786,7 @@ export class WorkspaceGitServiceImpl implements WorkspaceGitService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private updateForgePrStatusPollForTarget(target: WorkspaceGitTarget): void {
|
private updateForgePrStatusPollForTarget(target: WorkspaceGitTarget): void {
|
||||||
if (!this.isActiveObservedWorkspaceTarget(target)) {
|
if (target.listeners.size === 0) {
|
||||||
this.stopForgePrStatusPollForTarget(target);
|
this.stopForgePrStatusPollForTarget(target);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -3189,9 +3049,6 @@ export class WorkspaceGitServiceImpl implements WorkspaceGitService {
|
|||||||
target: WorkspaceGitTarget,
|
target: WorkspaceGitTarget,
|
||||||
request: WorkspaceGitRefreshRequest,
|
request: WorkspaceGitRefreshRequest,
|
||||||
): Promise<WorkspaceGitRuntimeSnapshot> {
|
): Promise<WorkspaceGitRuntimeSnapshot> {
|
||||||
if (target.folderMissing) {
|
|
||||||
return Promise.resolve(target.latestSnapshot ?? buildNotGitSnapshot(target.cwd));
|
|
||||||
}
|
|
||||||
if (target.refreshState.status === "in-flight") {
|
if (target.refreshState.status === "in-flight") {
|
||||||
const active = target.refreshState.request;
|
const active = target.refreshState.request;
|
||||||
const addsWork =
|
const addsWork =
|
||||||
@@ -3334,19 +3191,11 @@ export class WorkspaceGitServiceImpl implements WorkspaceGitService {
|
|||||||
try {
|
try {
|
||||||
const runRefreshGitCommand = createRunGitCommand(`workspace-refresh:${request.reason}`);
|
const runRefreshGitCommand = createRunGitCommand(`workspace-refresh:${request.reason}`);
|
||||||
const admittedSnapshot = await this.workspaceRefreshLimit(() => {
|
const admittedSnapshot = await this.workspaceRefreshLimit(() => {
|
||||||
if (
|
if (target.closed || this.workspaceTargets.get(target.cwd) !== target) {
|
||||||
target.closed ||
|
|
||||||
target.folderMissing ||
|
|
||||||
this.workspaceTargets.get(target.cwd) !== target
|
|
||||||
) {
|
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
return this.refreshSnapshot(target, request, runRefreshGitCommand);
|
return this.refreshSnapshot(target, request, runRefreshGitCommand);
|
||||||
});
|
});
|
||||||
if (target.folderMissing) {
|
|
||||||
failure = null;
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
if (!admittedSnapshot) {
|
if (!admittedSnapshot) {
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
@@ -3360,10 +3209,6 @@ export class WorkspaceGitServiceImpl implements WorkspaceGitService {
|
|||||||
failure = { error };
|
failure = { error };
|
||||||
}
|
}
|
||||||
|
|
||||||
if (target.folderMissing) {
|
|
||||||
failure = null;
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
if (this.disposed || target.closed || this.workspaceTargets.get(target.cwd) !== target) {
|
if (this.disposed || target.closed || this.workspaceTargets.get(target.cwd) !== target) {
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
@@ -3659,7 +3504,7 @@ export class WorkspaceGitServiceImpl implements WorkspaceGitService {
|
|||||||
snapshot: WorkspaceGitRuntimeSnapshot,
|
snapshot: WorkspaceGitRuntimeSnapshot,
|
||||||
options?: { forceEmit?: boolean },
|
options?: { forceEmit?: boolean },
|
||||||
): void {
|
): void {
|
||||||
if (target.closed || target.folderMissing || this.workspaceTargets.get(target.cwd) !== target) {
|
if (target.closed || this.workspaceTargets.get(target.cwd) !== target) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
target.latestSnapshot = snapshot;
|
target.latestSnapshot = snapshot;
|
||||||
@@ -3815,25 +3660,21 @@ export class WorkspaceGitServiceImpl implements WorkspaceGitService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private removeWorkspaceTarget(target: WorkspaceGitTarget): void {
|
private removeWorkspaceTarget(target: WorkspaceGitTarget): void {
|
||||||
this.detachWorkspaceFromRepoTarget(target);
|
if (target.repoGitRoot) {
|
||||||
|
const repoTarget = this.repoTargets.get(target.repoGitRoot);
|
||||||
|
repoTarget?.workspaceKeys.delete(target.cwd);
|
||||||
|
if (repoTarget && repoTarget.workspaceKeys.size === 0) {
|
||||||
|
this.closeRepoTarget(repoTarget);
|
||||||
|
this.repoTargets.delete(target.repoGitRoot);
|
||||||
|
} else if (repoTarget?.cwd === target.cwd) {
|
||||||
|
repoTarget.cwd = repoTarget.workspaceKeys.values().next().value ?? repoTarget.cwd;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
this.closeWorkspaceTarget(target);
|
this.closeWorkspaceTarget(target);
|
||||||
this.workspaceTargets.delete(target.cwd);
|
this.workspaceTargets.delete(target.cwd);
|
||||||
}
|
}
|
||||||
|
|
||||||
private detachWorkspaceFromRepoTarget(target: WorkspaceGitTarget): void {
|
|
||||||
if (!target.repoGitRoot) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
const repoTarget = this.repoTargets.get(target.repoGitRoot);
|
|
||||||
repoTarget?.workspaceKeys.delete(target.cwd);
|
|
||||||
if (repoTarget && repoTarget.workspaceKeys.size === 0) {
|
|
||||||
this.closeRepoTarget(repoTarget);
|
|
||||||
this.repoTargets.delete(target.repoGitRoot);
|
|
||||||
} else if (repoTarget?.cwd === target.cwd) {
|
|
||||||
repoTarget.cwd = repoTarget.workspaceKeys.values().next().value ?? repoTarget.cwd;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private removeWorkingTreeWatchListener(cwd: string, listener: () => void): void {
|
private removeWorkingTreeWatchListener(cwd: string, listener: () => void): void {
|
||||||
const target = this.workingTreeWatchTargets.get(cwd);
|
const target = this.workingTreeWatchTargets.get(cwd);
|
||||||
if (!target) {
|
if (!target) {
|
||||||
@@ -3878,11 +3719,6 @@ export class WorkspaceGitServiceImpl implements WorkspaceGitService {
|
|||||||
|
|
||||||
private closeWorkspaceTarget(target: WorkspaceGitTarget): void {
|
private closeWorkspaceTarget(target: WorkspaceGitTarget): void {
|
||||||
target.closed = true;
|
target.closed = true;
|
||||||
this.stopWorkspaceObservation(target);
|
|
||||||
target.listeners.clear();
|
|
||||||
}
|
|
||||||
|
|
||||||
private stopWorkspaceObservation(target: WorkspaceGitTarget): void {
|
|
||||||
if (target.workingTreeWatchTarget) {
|
if (target.workingTreeWatchTarget) {
|
||||||
this.removeWorkspaceWorkingTreeLink(target.workingTreeWatchTarget, target.cwd);
|
this.removeWorkspaceWorkingTreeLink(target.workingTreeWatchTarget, target.cwd);
|
||||||
target.workingTreeWatchTarget = null;
|
target.workingTreeWatchTarget = null;
|
||||||
@@ -3891,12 +3727,12 @@ export class WorkspaceGitServiceImpl implements WorkspaceGitService {
|
|||||||
clearTimeout(target.debounceTimer);
|
clearTimeout(target.debounceTimer);
|
||||||
target.debounceTimer = null;
|
target.debounceTimer = null;
|
||||||
}
|
}
|
||||||
target.pendingDebounceRequest = null;
|
|
||||||
if (target.observationReensureTimer) {
|
if (target.observationReensureTimer) {
|
||||||
clearTimeout(target.observationReensureTimer);
|
clearTimeout(target.observationReensureTimer);
|
||||||
target.observationReensureTimer = null;
|
target.observationReensureTimer = null;
|
||||||
}
|
}
|
||||||
this.stopForgePrStatusPollForTarget(target);
|
this.stopForgePrStatusPollForTarget(target);
|
||||||
|
target.listeners.clear();
|
||||||
}
|
}
|
||||||
|
|
||||||
private closeWorkingTreeWatchTarget(target: WorkingTreeWatchTarget): void {
|
private closeWorkingTreeWatchTarget(target: WorkingTreeWatchTarget): void {
|
||||||
|
|||||||
@@ -1,6 +1,5 @@
|
|||||||
import { afterEach, describe, expect, it } from "vitest";
|
import { afterEach, describe, expect, it } from "vitest";
|
||||||
import {
|
import {
|
||||||
chmodSync,
|
|
||||||
existsSync,
|
existsSync,
|
||||||
mkdirSync,
|
mkdirSync,
|
||||||
mkdtempSync,
|
mkdtempSync,
|
||||||
@@ -55,7 +54,6 @@ function makeSources(bundleSource = "export {};\n"): {
|
|||||||
mkdirSync(native, { recursive: true });
|
mkdirSync(native, { recursive: true });
|
||||||
writeFileSync(path.join(native, "pty.node"), "native");
|
writeFileSync(path.join(native, "pty.node"), "native");
|
||||||
writeFileSync(path.join(native, "spawn-helper"), "#!/bin/sh\n", { mode: 0o755 });
|
writeFileSync(path.join(native, "spawn-helper"), "#!/bin/sh\n", { mode: 0o755 });
|
||||||
chmodSync(path.join(native, "spawn-helper"), 0o755);
|
|
||||||
// Another platform's build is not copied.
|
// Another platform's build is not copied.
|
||||||
mkdirSync(path.join(nodePtyDirectory, "prebuilds", "other-platform"));
|
mkdirSync(path.join(nodePtyDirectory, "prebuilds", "other-platform"));
|
||||||
return { bundleDirectory, nodePtyDirectory };
|
return { bundleDirectory, nodePtyDirectory };
|
||||||
|
|||||||
@@ -1,32 +1,16 @@
|
|||||||
import {
|
import {
|
||||||
addHost,
|
|
||||||
getPaseoClient,
|
getPaseoClient,
|
||||||
useHosts,
|
useHosts,
|
||||||
usePaseo,
|
usePaseo,
|
||||||
type PluginSurfaceProps,
|
type PluginSurfaceProps,
|
||||||
type PluginHost,
|
|
||||||
} from "@getpaseo/plugin/client";
|
} from "@getpaseo/plugin/client";
|
||||||
import { useCallback, useMemo, useState } from "react";
|
import { useCallback, useMemo, useState } from "react";
|
||||||
import { Pressable, Text, TextInput, View } from "react-native";
|
import { Pressable, Text, View } from "react-native";
|
||||||
|
|
||||||
export function Hosts({ theme }: PluginSurfaceProps) {
|
export function Hosts({ theme }: PluginSurfaceProps) {
|
||||||
const hosts = useHosts();
|
const hosts = useHosts();
|
||||||
const selected = usePaseo();
|
const selected = usePaseo();
|
||||||
const [result, setResult] = useState("");
|
const [result, setResult] = useState("");
|
||||||
const [connection, setConnection] = useState("");
|
|
||||||
const add = useCallback(async () => {
|
|
||||||
try {
|
|
||||||
const outcome = await addHost({ connection });
|
|
||||||
if (outcome.status === "saved") setResult(`Added ${outcome.host.label}`);
|
|
||||||
else if (outcome.status === "cancelled") setResult("Host addition cancelled");
|
|
||||||
else setResult("Add ?password= and retry");
|
|
||||||
} catch (error) {
|
|
||||||
setResult(error instanceof Error ? error.message : String(error));
|
|
||||||
}
|
|
||||||
}, [connection]);
|
|
||||||
const addPressed = useCallback(() => {
|
|
||||||
void add();
|
|
||||||
}, [add]);
|
|
||||||
const list = useCallback(
|
const list = useCallback(
|
||||||
async (serverId?: string) => {
|
async (serverId?: string) => {
|
||||||
try {
|
try {
|
||||||
@@ -49,63 +33,27 @@ export function Hosts({ theme }: PluginSurfaceProps) {
|
|||||||
const listSelected = useCallback(() => {
|
const listSelected = useCallback(() => {
|
||||||
void list();
|
void list();
|
||||||
}, [list]);
|
}, [list]);
|
||||||
|
const rows = hosts.map((host) => ({
|
||||||
|
serverId: host.serverId,
|
||||||
|
label: host.label,
|
||||||
|
status: host.status,
|
||||||
|
onPress() {
|
||||||
|
void list(host.serverId);
|
||||||
|
},
|
||||||
|
}));
|
||||||
return (
|
return (
|
||||||
<View style={styles.root}>
|
<View style={styles.root}>
|
||||||
<TextInput
|
|
||||||
value={connection}
|
|
||||||
onChangeText={setConnection}
|
|
||||||
placeholder="host:port, ssh://user@host, or a pairing link"
|
|
||||||
/>
|
|
||||||
<Pressable accessibilityRole="button" onPress={addPressed}>
|
|
||||||
<Text style={styles.text}>Add host</Text>
|
|
||||||
</Pressable>
|
|
||||||
<Pressable accessibilityRole="button" onPress={listSelected}>
|
<Pressable accessibilityRole="button" onPress={listSelected}>
|
||||||
<Text style={styles.text}>List selected host agents</Text>
|
<Text style={styles.text}>List selected host agents</Text>
|
||||||
</Pressable>
|
</Pressable>
|
||||||
{hosts.map((host) => (
|
{rows.map((host) => (
|
||||||
<HostRow key={host.serverId} host={host} color={theme.colors.foreground} list={list} />
|
<Pressable key={host.serverId} accessibilityRole="button" onPress={host.onPress}>
|
||||||
|
<Text style={styles.text}>
|
||||||
|
{host.label}: {host.status}
|
||||||
|
</Text>
|
||||||
|
</Pressable>
|
||||||
))}
|
))}
|
||||||
<Text style={styles.text}>{result}</Text>
|
<Text style={styles.text}>{result}</Text>
|
||||||
</View>
|
</View>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function HostRow({
|
|
||||||
host,
|
|
||||||
color,
|
|
||||||
list,
|
|
||||||
}: {
|
|
||||||
host: PluginHost;
|
|
||||||
color: string;
|
|
||||||
list: (serverId: string) => Promise<void>;
|
|
||||||
}) {
|
|
||||||
const style = useMemo(() => ({ color }), [color]);
|
|
||||||
const rowStyle = useMemo(() => ({ gap: 4 }), []);
|
|
||||||
const listAgents = useCallback(() => void list(host.serverId), [host, list]);
|
|
||||||
const update = useCallback(
|
|
||||||
() => void host.update({ label: `${host.label} (build)`, appearance: { color: "sky" } }),
|
|
||||||
[host],
|
|
||||||
);
|
|
||||||
const open = useCallback(() => host.openSettings(), [host]);
|
|
||||||
const remove = useCallback(() => void host.remove(), [host]);
|
|
||||||
return (
|
|
||||||
<View style={rowStyle}>
|
|
||||||
<Text style={style}>
|
|
||||||
{host.label}: {host.status} ·{" "}
|
|
||||||
{host.connections.map((connection) => connection.uri ?? "via another host").join(", ")}
|
|
||||||
</Text>
|
|
||||||
<Pressable accessibilityRole="button" onPress={listAgents}>
|
|
||||||
<Text style={style}>List agents</Text>
|
|
||||||
</Pressable>
|
|
||||||
<Pressable accessibilityRole="button" onPress={update}>
|
|
||||||
<Text style={style}>Rename and color</Text>
|
|
||||||
</Pressable>
|
|
||||||
<Pressable accessibilityRole="button" onPress={open}>
|
|
||||||
<Text style={style}>Host settings</Text>
|
|
||||||
</Pressable>
|
|
||||||
<Pressable accessibilityRole="button" onPress={remove}>
|
|
||||||
<Text style={style}>Remove host</Text>
|
|
||||||
</Pressable>
|
|
||||||
</View>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -1,6 +1,5 @@
|
|||||||
import { useCallback } from "react";
|
import { useCallback } from "react";
|
||||||
import type { PluginClientContext, PluginSidebarItemProps } from "@getpaseo/plugin/client";
|
import type { PluginClientContext, PluginSidebarItemProps } from "@getpaseo/plugin/client";
|
||||||
import { observeHosts } from "@getpaseo/plugin/client";
|
|
||||||
import { SidebarRow } from "@getpaseo/plugin/client/ui";
|
import { SidebarRow } from "@getpaseo/plugin/client/ui";
|
||||||
import { Hosts } from "./client/hosts";
|
import { Hosts } from "./client/hosts";
|
||||||
|
|
||||||
@@ -12,8 +11,5 @@ function HostsItem({ currentScreen, openScreen }: PluginSidebarItemProps) {
|
|||||||
export default function contribute(client: PluginClientContext) {
|
export default function contribute(client: PluginClientContext) {
|
||||||
client.addScreen({ id: "main", title: "Host agents", Component: Hosts });
|
client.addScreen({ id: "main", title: "Host agents", Component: Hosts });
|
||||||
client.addSidebarHeaderItem({ id: "hosts", title: "Host agents", Component: HostsItem });
|
client.addSidebarHeaderItem({ id: "hosts", title: "Host agents", Component: HostsItem });
|
||||||
return observeHosts((hosts) => {
|
return () => {};
|
||||||
for (const host of hosts)
|
|
||||||
if (host.authFailureReason) console.log(`${host.label} requires attention`);
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1 +1 @@
|
|||||||
{ "id": "host-agents", "requirements": { "paseo": ">=0.15.0" } }
|
{ "id": "host-agents", "requirements": { "paseo": ">=0.8.0" } }
|
||||||
|
|||||||
@@ -2078,7 +2078,7 @@ function PullRequestAction({ theme }: PluginSurfaceProps) {
|
|||||||
|
|
||||||
The returned API covers projects, workspaces, agents, terminals, providers, and daemon config. See the [SDK API reference](/docs/sdk/reference) for its methods. Connection lifecycle methods are intentionally absent because Paseo owns the connection.
|
The returned API covers projects, workspaces, agents, terminals, providers, and daemon config. See the [SDK API reference](/docs/sdk/reference) for its methods. Connection lifecycle methods are intentionally absent because Paseo owns the connection.
|
||||||
|
|
||||||
### Hosts
|
### Discover hosts and target another host
|
||||||
|
|
||||||
Use `useHosts()` to display configured hosts and `getPaseoClient(serverId)` in an action callback
|
Use `useHosts()` to display configured hosts and `getPaseoClient(serverId)` in an action callback
|
||||||
to run SDK operations on one of them:
|
to run SDK operations on one of them:
|
||||||
@@ -2124,36 +2124,14 @@ export function HostAgents({ theme }: Pick<PluginSurfaceProps, "theme">): ReactE
|
|||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
`useHosts(): readonly PluginHost[]` includes offline hosts and updates when projected fields change.
|
`useHosts(): readonly PluginHostSummary[]` includes offline hosts and updates when hosts,
|
||||||
`observeHosts(listener)` immediately delivers the same list and returns an unsubscribe function.
|
labels, or statuses change.
|
||||||
Set `requirements.paseo` in the manifest to the release that ships these host capabilities.
|
|
||||||
|
|
||||||
| Host field | Meaning |
|
| Summary field | Type or values | Meaning |
|
||||||
| ---------------------------------------- | -------------------------------------------------------------------------------------------------------------------------- |
|
| ------------- | ------------------------------------------------------------ | ------------------------------------------------------------ |
|
||||||
| `serverId` | ID to pass to `getPaseoClient`. |
|
| `serverId` | `string` | ID to pass to `getPaseoClient`. |
|
||||||
| `label` | Display name. |
|
| `label` | `string` | Host's display name. |
|
||||||
| `status` | `idle`, `connecting`, `online`, `offline`, or `error`. SDK calls require `online`. |
|
| `status` | `"idle"`, `"connecting"`, `"online"`, `"offline"`, `"error"` | Current app connection status. SDK calls require `"online"`. |
|
||||||
| `appearance` | `color` and `badgeDisplay`; `null` badge display uses the local or remote default. |
|
|
||||||
| `connections` | Saved connections with an ID, type, credential-free `uri`, and type-specific settings. Routed connections have a null URI. |
|
|
||||||
| `activeConnectionId` | ID of the active connection, or null. |
|
|
||||||
| `error` | Current connection error, or null. |
|
|
||||||
| `authFailureReason` | `password_required`, `incorrect_password`, or null. |
|
|
||||||
| `lastOnlineAt`, `createdAt`, `updatedAt` | ISO timestamps; `lastOnlineAt` can be null. |
|
|
||||||
|
|
||||||
Call `host.update({ label, appearance })`, `host.removeConnection(id)`, `host.remove()`,
|
|
||||||
or `host.openSettings()` to manage a host. Updating `appearance.badgeDisplay` to null restores
|
|
||||||
the automatic default. Removing the last connection removes the host. The desktop daemon host
|
|
||||||
cannot be removed by a plugin.
|
|
||||||
|
|
||||||
Use `addHost({ connection, label? })` with the [shared connection grammar](../cli.md#connected-hosts).
|
|
||||||
A connection may include `?password=`. The password is used for the probe and saved locally; plugins
|
|
||||||
cannot read it back. Relay targets and pairing links ask the user to confirm.
|
|
||||||
|
|
||||||
| Result | Meaning |
|
|
||||||
| ----------------------------------------- | ---------------------------------------------------------------------------------------------------------- |
|
|
||||||
| `{ status: "saved", host }` | The host or connection was saved. |
|
|
||||||
| `{ status: "password_required", reason }` | Nothing was saved; supply a password and retry. The reason is `password_required` or `incorrect_password`. |
|
|
||||||
| `{ status: "cancelled" }` | The user declined the confirmation. |
|
|
||||||
|
|
||||||
`getPaseoClient(serverId: string): PaseoApi` borrows the host's authenticated app connection.
|
`getPaseoClient(serverId: string): PaseoApi` borrows the host's authenticated app connection.
|
||||||
Call it in client entry code or callbacks; it opens no socket and does not require the plugin
|
Call it in client entry code or callbacks; it opens no socket and does not require the plugin
|
||||||
@@ -2167,13 +2145,13 @@ on the target daemon. Acquire the API when performing an action to use the curre
|
|||||||
| Connection settings change or the app switches connections, including automatic failover | The old API is released. Call `getPaseoClient(serverId)` again and recreate subscriptions. |
|
| Connection settings change or the app switches connections, including automatic failover | The old API is released. Call `getPaseoClient(serverId)` again and recreate subscriptions. |
|
||||||
| Host is removed | Its API is released; the removed ID is unknown. |
|
| Host is removed | Its API is released; the removed ID is unknown. |
|
||||||
| `client.dispose()` | Releases that API and its observations. A later getter call returns a fresh API over the app connection. Disposing the old API again leaves the new API usable. |
|
| `client.dispose()` | Releases that API and its observations. A later getter call returns a fresh API over the app connection. Disposing the old API again leaves the new API usable. |
|
||||||
| Originating plugin unloads | Borrowed APIs and host observations are released. Later host handle calls throw `Plugin has stopped`. |
|
| Originating plugin unloads | All its borrowed APIs and observations are released, including those targeting other hosts. Retained handles cannot outlive the installation. |
|
||||||
| Surface host selection changes | `usePaseo()` follows the selected host. An explicitly acquired API keeps its original target. |
|
| Surface host selection changes | `usePaseo()` follows the selected host. An explicitly acquired API keeps its original target. |
|
||||||
|
|
||||||
You can also release individual subscriptions through the normal SDK API.
|
You can also release individual subscriptions through the normal SDK API.
|
||||||
|
|
||||||
Plugins see connection targets and daemon identities but cannot read saved passwords. Borrowed APIs
|
Plugins are trusted app code; cross-host access is intentional. Summaries contain no connection
|
||||||
provide no connection lifecycle controls. See the
|
URLs or credentials, and borrowed APIs provide no connection lifecycle controls. See the
|
||||||
[host agents example](https://github.com/getpaseo/paseo/tree/main/plugin-examples/hosts).
|
[host agents example](https://github.com/getpaseo/paseo/tree/main/plugin-examples/hosts).
|
||||||
|
|
||||||
## Add plugin-specific backend behavior
|
## Add plugin-specific backend behavior
|
||||||
|
|||||||
@@ -1779,7 +1779,7 @@ function PullRequestAction({ theme }: PluginSurfaceProps) {
|
|||||||
|
|
||||||
返回的 API 涵盖项目、工作区、Agent、终端、Provider 和 daemon 配置。其方法请参阅 [SDK API 参考](/docs/sdk/reference)。这里刻意没有连接生命周期方法,因为连接由 Paseo 负责。
|
返回的 API 涵盖项目、工作区、Agent、终端、Provider 和 daemon 配置。其方法请参阅 [SDK API 参考](/docs/sdk/reference)。这里刻意没有连接生命周期方法,因为连接由 Paseo 负责。
|
||||||
|
|
||||||
### 主机 {#hosts}
|
### 发现主机并指定另一台主机 {#discover-hosts-and-target-another-host}
|
||||||
|
|
||||||
使用 `useHosts()` 显示已配置的主机,并在操作回调中使用 `getPaseoClient(serverId)` 对其中一台主机
|
使用 `useHosts()` 显示已配置的主机,并在操作回调中使用 `getPaseoClient(serverId)` 对其中一台主机
|
||||||
执行 SDK 操作:
|
执行 SDK 操作:
|
||||||
@@ -1825,33 +1825,13 @@ export function HostAgents({ theme }: Pick<PluginSurfaceProps, "theme">): ReactE
|
|||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
`useHosts(): readonly PluginHost[]` 包括离线主机,并在可见字段变化时更新。`observeHosts(listener)`
|
`useHosts(): readonly PluginHostSummary[]` 包括离线的主机,并在主机、标签或状态变化时更新。
|
||||||
立即提供相同的列表,并返回取消订阅函数。请将清单中的 `requirements.paseo` 设为提供这些主机能力的发布版本。
|
|
||||||
|
|
||||||
| 主机字段 | 含义 |
|
| 摘要字段 | 类型或取值 | 含义 |
|
||||||
| ---------------------------------------- | -------------------------------------------------------------------------------------- |
|
| ---------- | ------------------------------------------------------------ | --------------------------------------------- |
|
||||||
| `serverId` | 传给 `getPaseoClient` 的 ID。 |
|
| `serverId` | `string` | 传给 `getPaseoClient` 的 ID。 |
|
||||||
| `label` | 显示名称。 |
|
| `label` | `string` | 主机的显示名称。 |
|
||||||
| `status` | `idle`、`connecting`、`online`、`offline` 或 `error`。SDK 调用需要 `online`。 |
|
| `status` | `"idle"`、`"connecting"`、`"online"`、`"offline"`、`"error"` | 当前应用的连接状态。SDK 调用需要 `"online"`。 |
|
||||||
| `appearance` | `color` 和 `badgeDisplay`;`null` 使用本地主机或远程主机的默认显示方式。 |
|
|
||||||
| `connections` | 已保存的连接,包括 ID、类型、不含凭据的 `uri` 和类型专属设置。路由连接的 URI 为 null。 |
|
|
||||||
| `activeConnectionId` | 活动连接的 ID,或 null。 |
|
|
||||||
| `error` | 当前连接错误,或 null。 |
|
|
||||||
| `authFailureReason` | `password_required`、`incorrect_password` 或 null。 |
|
|
||||||
| `lastOnlineAt`、`createdAt`、`updatedAt` | ISO 时间戳;`lastOnlineAt` 可以为 null。 |
|
|
||||||
|
|
||||||
调用 `host.update({ label, appearance })`、`host.removeConnection(id)`、`host.remove()` 或
|
|
||||||
`host.openSettings()` 管理主机。将 `appearance.badgeDisplay` 更新为 null 可恢复自动默认值。
|
|
||||||
移除最后一个连接会移除主机。插件不能移除桌面 daemon 主机。
|
|
||||||
|
|
||||||
使用 `addHost({ connection, label? })` 和[共享连接语法](../cli.md#connected-hosts)。连接字符串可以包含
|
|
||||||
`?password=`。密码用于探测并保存在本地,插件无法读回密码。中继目标和配对链接需要用户确认。
|
|
||||||
|
|
||||||
| 结果 | 含义 |
|
|
||||||
| ----------------------------------------- | ------------------------------------------------------------------------------------ |
|
|
||||||
| `{ status: "saved", host }` | 主机或连接已保存。 |
|
|
||||||
| `{ status: "password_required", reason }` | 未保存任何内容;提供密码后重试。原因是 `password_required` 或 `incorrect_password`。 |
|
|
||||||
| `{ status: "cancelled" }` | 用户拒绝了确认。 |
|
|
||||||
|
|
||||||
`getPaseoClient(serverId: string): PaseoApi` 借用该主机已认证的应用连接。请在客户端入口代码或回调中
|
`getPaseoClient(serverId: string): PaseoApi` 借用该主机已认证的应用连接。请在客户端入口代码或回调中
|
||||||
调用它;它不会打开 socket,也不要求目标 daemon 上安装了该插件。在执行操作时再获取 API,以便使用当前的
|
调用它;它不会打开 socket,也不要求目标 daemon 上安装了该插件。在执行操作时再获取 API,以便使用当前的
|
||||||
@@ -1869,7 +1849,8 @@ export function HostAgents({ theme }: Pick<PluginSurfaceProps, "theme">): ReactE
|
|||||||
|
|
||||||
你也可以通过普通的 SDK API 释放单个订阅。
|
你也可以通过普通的 SDK API 释放单个订阅。
|
||||||
|
|
||||||
插件可以查看连接目标和 daemon 身份,但不能读取已保存的密码。借用的 API 不提供连接生命周期控制。请参阅[主机 Agent 示例](https://github.com/getpaseo/paseo/tree/main/plugin-examples/hosts)。
|
插件是受信任的应用代码;跨主机访问是有意设计的。摘要中不包含连接 URL 或凭据,借用的 API 也不提供连接
|
||||||
|
生命周期控制。请参阅[主机 Agent 示例](https://github.com/getpaseo/paseo/tree/main/plugin-examples/hosts)。
|
||||||
|
|
||||||
## 添加插件特有的后端行为 {#add-plugin-specific-backend-behavior}
|
## 添加插件特有的后端行为 {#add-plugin-specific-backend-behavior}
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user