Compare commits

..
Author SHA1 Message Date
paseo-bot[bot]andRyan Swift 7ebc7fff8d fix(usage): show Copilot premium request quota
The usage source discarded quota snapshots, leaving the card with only a
plan and reset date. Publish finite premium usage through the existing
window interface and retain its remaining request count.

Co-authored-by: Ryan Swift <ryan@mlh.io>
2026-11-01 19:31:51 +00:00
paseo-bot[bot] 04fe4be40f fix(claude): retain configured model metadata before a turn (#8465)
Rebuilding an idle session cleared its observed model and published null despite retaining an explicit model in its configuration. Fall back to that configuration until the next turn supplies an observed model.

Co-authored-by: paseo-bot[bot] <266920839+paseo-bot[bot]@users.noreply.github.com>
2026-11-01 18:59:04 +00:00
paseo-bot[bot] 2d75cc80b2 test(server): cover routed permissions in one host journey (#8486)
Co-authored-by: paseo-bot[bot] <266920839+paseo-bot[bot]@users.noreply.github.com>
2026-11-01 18:34:57 +00:00
paseo-bot[bot]andDominic Anaeto bfac132920 Stop Git polling while workspace folders are missing (#8462)
* fix(server): stop polling git for a workspace whose folder is gone

When a workspace's folder disappears together with its project (the
repository deleted, a volume unmounted), reconciliation leaves the
workspace registered, and the daemon kept polling it: a failed git
command and a warning about once a minute, and a working-tree watcher
retry every minute or two, for as long as the daemon ran.

The degraded poll now checks that the workspace folder exists before
refreshing. A workspace whose folder is gone is suspended once: it is
detached from its repository and working-tree targets, its timers stop,
and one warning is logged. A worktree that shared a repository target
hands the repository's polling and background fetch to a surviving
worktree. Nothing is archived. A working-tree watch that an open diff
still holds skips its consumer notifications and watcher recovery while
its folder is missing.

While suspended, the workspace runs no git. A stat every 60 seconds, a
new subscription, a forced snapshot or a refresh request checks whether
the folder is back; when it is, the workspace reloads its git facts,
refreshes, and rebuilds its observation.

Fixes #7856

* fix(server): keep missing workspaces suspended during refresh

Admit Git refreshes only after the folder has returned. Cover missing-folder polling, explicit refreshes, and restoration with real Git and the Linux observer.

Co-authored-by: Dominic Anaeto <dominic.anaeto@gmail.com>

* fix(server): preserve workspace snapshots through suspension

Drop queued refreshes and withhold publication when a folder disappears during an active read. Wait for that read before restoring observation so its stale result cannot overwrite the recovered snapshot.

---------

Co-authored-by: Dominic Anaeto <dominic.anaeto@gmail.com>
Co-authored-by: paseo-bot[bot] <266920839+paseo-bot[bot]@users.noreply.github.com>
2026-11-01 18:02:26 +00:00
paseo-bot[bot]andDominic Anaeto 34f996b32a Preserve pending questions and approvals after peer messages (#8453)
* fix(server): keep a recipient's pending permission when another agent messages it

A message from another agent (send_agent_message with sourceAgentId) was
enqueued with clearPendingPermissions: true, so it superseded the
recipient's pending permission request and the request was denied before
a human saw it. Agent-sourced messages now leave pending permissions
alone. Typed messages and sourceless `paseo send` still clear them.

Fixes #7602

* test(server): verify peer messages preserve answerable questions

Exercise message delivery through the daemon and provider interfaces so the regression checks that the question survives and its answer reaches the provider. Retain the human-steer cancellation behavior.

Co-authored-by: Dominic Anaeto <dominic.anaeto@gmail.com>

* test(terminal): make runtime copy fixture independent of umask

Set the source helper permissions after creation so the copy test measures preservation instead of inheriting the shell file-creation mask. The existing test fails under umask 077 without this setup correction.

Fixes #8175

---------

Co-authored-by: Dominic Anaeto <dominic.anaeto@gmail.com>
Co-authored-by: paseo-bot[bot] <266920839+paseo-bot[bot]@users.noreply.github.com>
2026-11-01 17:11:42 +00:00
3e83f433da Import large Claude sessions without exceeding the string limit (#8457)
* fix(claude): read the session rename from the end of a large transcript

Importing a Claude session read the whole transcript into one UTF-8 string to
look for its `custom-title` record, so a transcript past V8's maximum string
length failed the import with `RangeError: Invalid string length` before the
incremental history reader ever ran. The scan now reads the file backwards a
chunk at a time, which keeps the original intent of not walking the transcript
and never holds it as one string. Records without the needle are not decoded, so
a transcript with no rename costs a byte search per chunk.

Closes #8451

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* test(app): wait for send readiness in composer recall

The previous prompt can appear and clear its draft before the send acknowledgement arrives. Wait for Send to be enabled before pressing Enter so recall setup does not silently leave the next prompt unsent.

Co-authored-by: qwist1233-cpu <qwist1233@gmail.com>

* fix(claude): scan large transcript records once

Retain unfinished record chunks and assemble each record once instead of copying and rescanning a growing buffer on every backward read. Cover import of a large record after the last explicit rename.

---------

Co-authored-by: qwist1233-cpu <qwist1233@gmail.com>
Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
Co-authored-by: paseo-bot[bot] <266920839+paseo-bot[bot]@users.noreply.github.com>
2026-11-01 15:56:42 +00:00
paseo-bot[bot] 674530152e test(server): cover filename downloads in one workspace journey (#8475)
Co-authored-by: paseo-bot[bot] <266920839+paseo-bot[bot]@users.noreply.github.com>
2026-11-01 15:31:08 +00:00
21 changed files with 1397 additions and 323 deletions
+1 -11
View File
@@ -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`.
## Inverted timeline scrolling
## Inverted timeline selection
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
@@ -183,16 +183,6 @@ 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
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
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.
@@ -3,7 +3,7 @@ import {
composerLocator,
expectComposerDraft,
expectComposerVisible,
submitMessage,
fillComposerDraft,
typeIntoFocusedComposer,
} from "../support/helpers/composer";
import { openAgentRoute, seedMockAgentWorkspace } from "../support/helpers/mock-agent";
@@ -12,8 +12,14 @@ function userMessages(page: Page) {
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) {
await submitMessage(page, text);
await fillComposerDraft(page, text);
await sendDraftWhenReady(page);
await expect(userMessages(page)).toHaveCount(expectedCount, { timeout: 30_000 });
await expectComposerDraft(page, "");
}
@@ -49,7 +55,7 @@ test.describe("Composer recall", () => {
await pressInComposer(page, "ArrowUp");
await expectComposerDraft(page, "add tests for login");
await pressInComposer(page, "Enter");
await sendDraftWhenReady(page);
await expect(userMessages(page)).toHaveCount(3, { timeout: 30_000 });
await expect(userMessages(page).last()).toContainText("add tests for login");
} finally {
@@ -1,5 +0,0 @@
<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>
@@ -1,134 +0,0 @@
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)
}
@@ -8,6 +8,7 @@ import android.widget.FrameLayout
import android.widget.TextView
import androidx.test.ext.junit.runners.AndroidJUnit4
import androidx.test.platform.app.InstrumentationRegistry
import com.facebook.react.bridge.BridgeReactContext
import com.facebook.react.bridge.JavaOnlyArray
import com.facebook.react.bridge.JavaOnlyMap
import com.facebook.react.soloader.OpenSourceMergedSoMapping
@@ -69,7 +70,7 @@ class ScrollSelectionTest {
val appContext = instrumentation.targetContext
DisplayMetricsHolder.initDisplayMetricsIfNotInitialized(appContext)
SoLoader.init(appContext, OpenSourceMergedSoMapping)
val reactContext = ScrollTestContext(appContext)
val reactContext = BridgeReactContext(appContext)
val context = ThemedReactContext(reactContext, appContext)
val manager = PaseoScrollPackage().createViewManager(reactContext, ReactScrollViewManager.REACT_CLASS) as ReactScrollViewManager
val scroll = manager.createViewInstance(context)
@@ -1,19 +0,0 @@
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;
}
}
@@ -2,7 +2,6 @@ package sh.paseo.scroll
import android.graphics.Matrix
import android.graphics.Rect
import android.view.View
import com.facebook.react.ReactPackage
import com.facebook.react.ViewManagerOnDemandReactPackage
import com.facebook.react.bridge.NativeModule
@@ -31,30 +30,13 @@ private class PaseoScrollViewManager : ReactScrollViewManager() {
private class PaseoScrollView(context: ThemedReactContext) : ReactScrollView(context) {
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.
// Its coordinates ignore inversion. Keep focus/selection bookkeeping, but leave timeline
// movement to user gestures and explicit scroll commands.
override fun computeScrollDeltaToGetChildRectOnScreen(rect: Rect): Int {
if (isInverted) return 0
// RN's Android scale: -1 becomes a 180-degree rotation with positive scaleY.
matrix.getValues(transformValues)
if (transformValues[Matrix.MSCALE_Y] < 0) return 0
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,3 +1,4 @@
import { constants as bufferConstants } from "node:buffer";
import * as fs from "node:fs/promises";
import * as os from "node:os";
import * as path from "node:path";
@@ -236,4 +237,64 @@ describe("importing a renamed Claude session", () => {
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");
});
});
@@ -0,0 +1,48 @@
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();
}
});
});
@@ -2460,7 +2460,7 @@ class ClaudeAgentSession implements AgentSession {
const info: AgentRuntimeInfo = {
provider: "claude",
sessionId: this.claudeSessionId,
model: this.lastOptionsModel,
model: this.lastOptionsModel ?? this.config.model ?? null,
modeId: this.currentMode ?? null,
...(this.lastRuntimeModel
? {
@@ -6853,40 +6853,79 @@ function normalizeClaudeSessionTitle(title: string | null): string | null {
}
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 current name is the last usable one in the file. Searching backwards finds it without
* walking the transcript, which matters because sessions reach tens of
* megabytes.
* the current name is the last usable one in the file. Reading backwards a chunk
* at a time finds it without walking the transcript, which matters because
* sessions reach tens of megabytes, and never holds the file as one string, so a
* transcript past V8's maximum string length still imports.
*/
function readLastClaudeCustomTitle(content: string): string | null {
const needle = `"${CLAUDE_CUSTOM_TITLE_TYPE}"`;
let at = content.lastIndexOf(needle);
while (at >= 0) {
const lineStart = content.lastIndexOf("\n", at) + 1;
const lineEnd = content.indexOf("\n", at);
const line = lineEnd === -1 ? content.slice(lineStart) : content.slice(lineStart, lineEnd);
let parsed: unknown;
try {
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,
async function readLastClaudeCustomTitle(filePath: string): Promise<string | null> {
const handle = await fsPromises.open(filePath, "r");
try {
const stats = await handle.stat();
if (!stats.isFile()) {
// Opening a directory succeeds on Windows even though reading it does not,
// so reject the way reading the transcript as a file would.
throw Object.assign(
new Error(`EISDIR: illegal operation on a directory, read '${filePath}'`),
{ code: "EISDIR", path: filePath },
);
if (title) {
return title;
}
}
// The needle also matches a prompt that quotes it, and a record can carry no
// usable name, so keep looking backwards for one that does.
at = at <= 0 ? -1 : content.lastIndexOf(needle, at - 1);
let end = stats.size;
// 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.
return readClaudeCustomTitleFromRecord(Buffer.concat(pieces.toReversed()));
} finally {
await handle.close();
}
return null;
}
function isErrnoException(error: unknown): error is NodeJS.ErrnoException {
@@ -6898,9 +6937,8 @@ async function readClaudeRenamedSessionTitle(input: {
sessionId: string;
configDir: string;
}): Promise<string | null> {
let content: string;
try {
content = await fsPromises.readFile(await claudeTranscriptPath(input), "utf8");
return await readLastClaudeCustomTitle(await claudeTranscriptPath(input));
} catch (error) {
// A session with no transcript on disk has no rename to carry over. Every
// other read failure is a real fault and stays explicit.
@@ -6909,7 +6947,6 @@ async function readClaudeRenamedSessionTitle(input: {
}
throw error;
}
return readLastClaudeCustomTitle(content);
}
function normalizeImportablePromptPreview(text: string): string | null {
@@ -183,7 +183,7 @@ test("ordinary routed APIs isolate scopes and enforce one-hop, disable and origi
await paseo.dispose();
});
test("routed authority is bounded by the caller, with isolated simultaneous sessions", async () => {
test("routed authority isolates simultaneous sessions and preserves ordinary permission RPC errors", async () => {
const b = await daemon();
const a = await daemon();
const owner = await api(a);
@@ -210,33 +210,16 @@ test("routed authority is bounded by the caller, with isolated simultaneous sess
code: "permission_denied",
});
expect((await owner.paseo.hosts.list()).hosts).toHaveLength(1);
await remote.dispose();
});
test("SDK host clients preserve ordinary permission RPC errors", async () => {
const b = await daemon();
const a = await daemon();
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);
const directClient = createHostClient(`127.0.0.1:${b.port}`, {
clientId: crypto.randomUUID(),
sessionPermissions: ["daemon.read", "workspace.read"],
reconnect: { enabled: false },
});
await directClient.connect();
const direct = createPaseoApi(directClient);
cleanup.push(async () => {
await remote.dispose();
await direct.dispose();
await origin.dispose();
await directClient.close();
});
const input = { source: { kind: "directory" as const, path: b.staticDir } };
const directError = (await direct.workspaces.create(input).catch((error: unknown) => error)) as {
@@ -247,6 +230,7 @@ test("SDK host clients preserve ordinary permission RPC errors", async () => {
};
expect(directError.code).toBe("access_denied");
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 () => {
@@ -102,53 +102,52 @@ describe("daemon E2E", () => {
},
);
test.each([
{ fileName: "download.txt", disposition: 'attachment; filename="download.txt"' },
{
fileName: "café.txt",
disposition: "attachment; filename=\"caf?.txt\"; filename*=UTF-8''caf%C3%A9.txt",
},
{
fileName: "中文报告 (最终版).txt",
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",
},
{
fileName: "報告書.md",
disposition:
"attachment; filename=\"???.md\"; filename*=UTF-8''%E5%A0%B1%E5%91%8A%E6%9B%B8.md",
},
{
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",
},
])(
"issues token over WS and downloads $fileName via HTTP",
async ({ fileName, disposition }) => {
const cwd = tmpCwd();
try {
test("downloads ASCII and Unicode filenames through one workspace", async () => {
const cwd = tmpCwd();
try {
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}$/,
);
for (const { fileName, disposition } of [
{ fileName: "download.txt", disposition: 'attachment; filename="download.txt"' },
{
fileName: "café.txt",
disposition: "attachment; filename=\"caf?.txt\"; filename*=UTF-8''caf%C3%A9.txt",
},
{
fileName: "中文报告 (最终版).txt",
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",
},
{
fileName: "報告書.md",
disposition:
"attachment; filename=\"???.md\"; filename*=UTF-8''%E5%A0%B1%E5%91%8A%E6%9B%B8.md",
},
{
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 fileContents = "download test payload";
const fileContents = `download test payload: ${fileName}`;
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);
expect(tokenResponse.error).toBeNull();
@@ -167,12 +166,11 @@ describe("daemon E2E", () => {
const body = await response.text();
expect(body).toBe(fileContents);
} finally {
rmSync(cwd, { recursive: true, force: true });
}
},
60000,
);
} finally {
rmSync(cwd, { recursive: true, force: true });
}
}, 60000);
test("rejects invalid token", async () => {
const response = await fetch(
@@ -0,0 +1,129 @@
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();
}
});
+1 -1
View File
@@ -8318,7 +8318,7 @@ export class Session {
},
activeTurnBehavior: msg.activeTurnBehavior ?? "steer",
sessionMode: msg.sessionMode,
clearPendingPermissions: true,
clearPendingPermissions: false,
});
deliveryStatus = "queued";
queued();
@@ -1,3 +1,4 @@
import { flushGitCommandTrace } from "../utils/git-command-trace.js";
import { createWorkspaceRuntimeStore } from "./workspace-runtime-store/index.js";
import { mkdirSync, mkdtempSync, readFileSync, realpathSync, rmSync, writeFileSync } from "node:fs";
import { execFileSync } from "node:child_process";
@@ -730,3 +731,103 @@ test("Git event identity retains a symlink's literal target before canonicalizin
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,6 +244,7 @@ function createService(
filterEvents: (events) => events,
verify: vi.fn(async () => {}),
})),
workspaceFolderExists: vi.fn(async () => true),
...overrides,
} as never,
});
@@ -1,5 +1,7 @@
import { createWorkspaceRuntimeStore } from "./workspace-runtime-store/index.js";
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 path, { join } from "node:path";
import type pino from "pino";
@@ -10,11 +12,14 @@ import type {
PullRequestStatusResult,
} from "../utils/checkout-git.js";
import {
BACKGROUND_GIT_FETCH_INTERVAL_MS,
WORKSPACE_GIT_OBSERVATION_REENSURE_INTERVAL_MS,
WORKSPACE_GIT_OBSERVATION_SETUP_CONCURRENCY,
WORKSPACE_GIT_REFRESH_CONCURRENCY,
WORKSPACE_GIT_WATCHER_SUBSCRIBE_TIMEOUT_MS,
WorkspaceGitServiceImpl,
type WorkspaceGitRuntimeSnapshot,
workspaceFolderExists,
} from "./workspace-git-service.js";
const REPO_CWD = path.resolve("/tmp/repo");
@@ -23,6 +28,7 @@ function createLogger() {
const logger = {
child: () => logger,
debug: vi.fn(),
info: vi.fn(),
warn: vi.fn(),
};
return logger;
@@ -251,7 +257,7 @@ function createGitHubServiceStub(): ForgeService {
interface CreateServiceTestOptions {
subscribe?: ReturnType<typeof vi.fn>;
getCheckoutStatus?: ReturnType<typeof vi.fn>;
getCheckoutSnapshotFacts?: ReturnType<typeof vi.fn>;
getCheckoutSnapshotFacts?: (cwd: string) => Promise<CheckoutSnapshotFacts>;
getCheckoutShortstat?: ReturnType<typeof vi.fn>;
getCheckoutWorktreeState?: ReturnType<typeof vi.fn>;
getPullRequestStatus?: ReturnType<typeof vi.fn>;
@@ -261,6 +267,7 @@ interface CreateServiceTestOptions {
runGitFetch?: ReturnType<typeof vi.fn>;
runGitCommand?: ReturnType<typeof vi.fn>;
getWorkspaceGitSelfHealPhaseMs?: (cwd: string) => number;
workspaceFolderExists?: (cwd: string) => Promise<boolean>;
now?: () => Date;
}
@@ -294,6 +301,7 @@ function buildDefaultTestServiceDeps() {
signal: null,
})),
getWorkspaceGitSelfHealPhaseMs: vi.fn(() => 30_000),
workspaceFolderExists: vi.fn(async () => true),
createWatcherLivenessCanary: vi.fn(() => ({
path: "",
filterEvents: (events) => events,
@@ -324,6 +332,145 @@ 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", () => {
beforeEach(() => {
vi.useFakeTimers();
@@ -1553,3 +1700,460 @@ describe("WorkspaceGitServiceImpl", () => {
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 { readFile } from "node:fs/promises";
import { readFile, stat } from "node:fs/promises";
import { basename, dirname, join, resolve } from "node:path";
import { LRUCache } from "lru-cache";
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";
const WORKSPACE_GIT_WATCH_DEBOUNCE_MS = 1_000;
const BACKGROUND_GIT_FETCH_INTERVAL_MS = 180_000;
export const BACKGROUND_GIT_FETCH_INTERVAL_MS = 180_000;
const FETCH_METADATA_ECHO_TTL_MS = 5_000;
export const WORKSPACE_GIT_OBSERVATION_REENSURE_INTERVAL_MS = 60_000;
const FORGE_PR_STATUS_POLL_FAST_INTERVAL_MS = 20_000;
@@ -384,6 +384,7 @@ interface WorkspaceGitServiceDependencies {
runGitCommand: typeof runGitCommand;
getWorkspaceGitSelfHealPhaseMs: typeof getWorkspaceGitObservationReensurePhaseMs;
createWatcherLivenessCanary: typeof createWatcherLivenessCanary;
workspaceFolderExists: (cwd: string) => Promise<boolean>;
now: () => Date;
}
@@ -435,6 +436,8 @@ interface WorkspaceGitTarget {
repoGitRoot: string | null;
observationSetupPromise: Promise<void> | null;
observationSetupComplete: boolean;
folderMissing: boolean;
folderRestorePromise: Promise<void> | null;
closed: boolean;
}
@@ -571,10 +574,21 @@ function buildDefaultWorkspaceGitServiceDeps(
runGitCommand,
getWorkspaceGitSelfHealPhaseMs: getWorkspaceGitObservationReensurePhaseMs,
createWatcherLivenessCanary,
workspaceFolderExists,
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(
subscribe: SubscribeToFileChanges,
deps: Partial<WorkspaceGitServiceDependencies> | undefined,
@@ -660,6 +674,9 @@ export class WorkspaceGitServiceImpl implements WorkspaceGitService {
const cwd = resolve(params.cwd);
const target = this.ensureWorkspaceTarget(cwd);
target.listeners.add(listener);
if (target.folderMissing) {
void this.resumeWorkspaceIfFolderRestored(target);
}
if (target.listeners.size === 1) {
this.startWorkspaceSubscriptionTimers(target);
}
@@ -751,6 +768,9 @@ export class WorkspaceGitServiceImpl implements WorkspaceGitService {
if (!request.force && target.latestSnapshot) {
return target.latestSnapshot;
}
if (target.folderMissing) {
await this.resumeWorkspaceIfFolderRestored(target);
}
return this.requestWorkspaceSnapshot(target, request);
}
@@ -972,6 +992,9 @@ export class WorkspaceGitServiceImpl implements WorkspaceGitService {
this.assertNotDisposed();
cwd = resolve(cwd);
const target = this.ensureWorkspaceTarget(cwd);
if (target.folderMissing) {
await this.resumeWorkspaceIfFolderRestored(target);
}
await this.refreshWorkspaceTarget(target, {
force: false,
refreshStructure: true,
@@ -1005,9 +1028,14 @@ export class WorkspaceGitServiceImpl implements WorkspaceGitService {
this.assertNotDisposed();
cwd = resolve(cwd);
const target = this.workspaceTargets.get(cwd);
if (target) {
this.scheduleWorkspaceRefresh(target, { queueIfBusy: false });
if (!target) {
return;
}
if (target.folderMissing) {
void this.resumeWorkspaceIfFolderRestored(target);
return;
}
this.scheduleWorkspaceRefresh(target, { queueIfBusy: false });
}
onWorkspaceStateMayHaveChanged(cwd: string): void {
@@ -1018,6 +1046,10 @@ export class WorkspaceGitServiceImpl implements WorkspaceGitService {
return;
}
this.invalidateForge(normalizedCwd);
if (target.folderMissing) {
void this.resumeWorkspaceIfFolderRestored(target);
return;
}
this.scheduleWorkspaceRefresh(target, {
force: true,
includeForge: true,
@@ -1227,6 +1259,8 @@ export class WorkspaceGitServiceImpl implements WorkspaceGitService {
repoGitRoot: null,
observationSetupPromise: null,
observationSetupComplete: false,
folderMissing: false,
folderRestorePromise: null,
closed: false,
};
@@ -1358,6 +1392,7 @@ export class WorkspaceGitServiceImpl implements WorkspaceGitService {
private isActiveObservedWorkspaceTarget(target: WorkspaceGitTarget): boolean {
return (
!target.closed &&
!target.folderMissing &&
target.listeners.size > 0 &&
this.workspaceTargets.get(target.cwd) === target
);
@@ -1612,11 +1647,110 @@ export class WorkspaceGitServiceImpl implements WorkspaceGitService {
workspaceTarget: WorkspaceGitTarget,
request: WorkspaceGitRefreshRequest,
): Promise<boolean> {
if (await this.suspendWorkspaceIfFolderMissing(workspaceTarget)) {
return false;
}
const fingerprintBefore = workspaceTarget.latestFingerprint;
await this.refreshWorkspaceTarget(workspaceTarget, request);
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(
target: WorkingTreeWatchTarget,
reason: WorkingTreeWatchFallbackReason,
@@ -1654,7 +1788,9 @@ export class WorkspaceGitServiceImpl implements WorkspaceGitService {
return changed;
}),
);
this.notifyWorkingTreeConsumers(target);
if (await this.deps.workspaceFolderExists(target.watchPath)) {
this.notifyWorkingTreeConsumers(target);
}
if (!target.closed && (target.subscription === null || target.repoRoot === null)) {
target.fallbackPollQuietTickCount = changes.some(Boolean)
? 0
@@ -1731,6 +1867,10 @@ export class WorkspaceGitServiceImpl implements WorkspaceGitService {
if (target.closed || target.subscription) {
return;
}
if (!(await this.deps.workspaceFolderExists(target.watchPath))) {
this.scheduleWorkingTreeWatchRecovery(target);
return;
}
await this.refreshWorkingTreeIgnoredDirectories(target);
if (target.closed || target.subscription) {
return;
@@ -2786,7 +2926,7 @@ export class WorkspaceGitServiceImpl implements WorkspaceGitService {
}
private updateForgePrStatusPollForTarget(target: WorkspaceGitTarget): void {
if (target.listeners.size === 0) {
if (!this.isActiveObservedWorkspaceTarget(target)) {
this.stopForgePrStatusPollForTarget(target);
return;
}
@@ -3049,6 +3189,9 @@ export class WorkspaceGitServiceImpl implements WorkspaceGitService {
target: WorkspaceGitTarget,
request: WorkspaceGitRefreshRequest,
): Promise<WorkspaceGitRuntimeSnapshot> {
if (target.folderMissing) {
return Promise.resolve(target.latestSnapshot ?? buildNotGitSnapshot(target.cwd));
}
if (target.refreshState.status === "in-flight") {
const active = target.refreshState.request;
const addsWork =
@@ -3191,11 +3334,19 @@ export class WorkspaceGitServiceImpl implements WorkspaceGitService {
try {
const runRefreshGitCommand = createRunGitCommand(`workspace-refresh:${request.reason}`);
const admittedSnapshot = await this.workspaceRefreshLimit(() => {
if (target.closed || this.workspaceTargets.get(target.cwd) !== target) {
if (
target.closed ||
target.folderMissing ||
this.workspaceTargets.get(target.cwd) !== target
) {
return null;
}
return this.refreshSnapshot(target, request, runRefreshGitCommand);
});
if (target.folderMissing) {
failure = null;
break;
}
if (!admittedSnapshot) {
break;
}
@@ -3209,6 +3360,10 @@ export class WorkspaceGitServiceImpl implements WorkspaceGitService {
failure = { error };
}
if (target.folderMissing) {
failure = null;
break;
}
if (this.disposed || target.closed || this.workspaceTargets.get(target.cwd) !== target) {
break;
}
@@ -3504,7 +3659,7 @@ export class WorkspaceGitServiceImpl implements WorkspaceGitService {
snapshot: WorkspaceGitRuntimeSnapshot,
options?: { forceEmit?: boolean },
): void {
if (target.closed || this.workspaceTargets.get(target.cwd) !== target) {
if (target.closed || target.folderMissing || this.workspaceTargets.get(target.cwd) !== target) {
return;
}
target.latestSnapshot = snapshot;
@@ -3660,21 +3815,25 @@ export class WorkspaceGitServiceImpl implements WorkspaceGitService {
}
private removeWorkspaceTarget(target: WorkspaceGitTarget): void {
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.detachWorkspaceFromRepoTarget(target);
this.closeWorkspaceTarget(target);
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 {
const target = this.workingTreeWatchTargets.get(cwd);
if (!target) {
@@ -3719,6 +3878,11 @@ export class WorkspaceGitServiceImpl implements WorkspaceGitService {
private closeWorkspaceTarget(target: WorkspaceGitTarget): void {
target.closed = true;
this.stopWorkspaceObservation(target);
target.listeners.clear();
}
private stopWorkspaceObservation(target: WorkspaceGitTarget): void {
if (target.workingTreeWatchTarget) {
this.removeWorkspaceWorkingTreeLink(target.workingTreeWatchTarget, target.cwd);
target.workingTreeWatchTarget = null;
@@ -3727,12 +3891,12 @@ export class WorkspaceGitServiceImpl implements WorkspaceGitService {
clearTimeout(target.debounceTimer);
target.debounceTimer = null;
}
target.pendingDebounceRequest = null;
if (target.observationReensureTimer) {
clearTimeout(target.observationReensureTimer);
target.observationReensureTimer = null;
}
this.stopForgePrStatusPollForTarget(target);
target.listeners.clear();
}
private closeWorkingTreeWatchTarget(target: WorkingTreeWatchTarget): void {
@@ -1,5 +1,6 @@
import { afterEach, describe, expect, it } from "vitest";
import {
chmodSync,
existsSync,
mkdirSync,
mkdtempSync,
@@ -54,6 +55,7 @@ function makeSources(bundleSource = "export {};\n"): {
mkdirSync(native, { recursive: true });
writeFileSync(path.join(native, "pty.node"), "native");
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.
mkdirSync(path.join(nodePtyDirectory, "prebuilds", "other-platform"));
return { bundleDirectory, nodePtyDirectory };
@@ -171,3 +171,90 @@ it.each([401, 403])("reports an existing login rejected with HTTP %i", async (st
else process.env["COPILOT_TOKEN"] = previous;
}
});
it.each([
{ remaining: 1500, percentRemaining: 100, usedPct: 0 },
{ remaining: 1125, percentRemaining: 75, usedPct: 25 },
{ remaining: 0, percentRemaining: 0, usedPct: 100 },
])(
"reports premium quota with $remaining requests remaining",
async ({ remaining, percentRemaining, usedPct }) => {
const report = await fetchPremiumQuota({
entitlement: 1500,
remaining,
percent_remaining: percentRemaining,
unlimited: false,
});
expect(report).toMatchObject({
status: "available",
planLabel: "individual",
windows: [
{
id: "premium_interactions",
label: "Premium requests",
usedPct,
remainingPct: percentRemaining,
resetsAt: "2026-12-01T00:00:00.000Z",
},
],
details: expect.arrayContaining([
{
id: "premium_remaining",
label: "Premium requests remaining",
value: `${remaining} / 1500`,
},
]),
});
},
);
it.each([
undefined,
null,
{ unlimited: true, percent_remaining: 100, remaining: 1500, entitlement: 1500 },
])(
"does not invent a finite quota for absent or unlimited premium snapshots (%j)",
async (premium) => {
const report = await fetchPremiumQuota(premium);
expect(report).toMatchObject({
status: "available",
windows: [],
details: [{ id: "reset", label: "Quota reset", value: "2026-12-01" }],
});
},
);
it("reports a percentage without inventing missing counts and falls back to the legacy reset", async () => {
const report = await fetchPremiumQuota({ percent_remaining: 60, unlimited: false }, null);
expect(report).toMatchObject({
status: "available",
windows: [{ usedPct: 40, remainingPct: 60, resetsAt: "2026-12-01" }],
details: [{ id: "reset", label: "Quota reset", value: "2026-12-01" }],
});
});
async function fetchPremiumQuota(
premium: unknown,
utcReset: string | null = "2026-12-01T00:00:00.000Z",
) {
const { writeFile } = await import("node:fs/promises");
const directory = mkdtempSync(join(tmpdir(), "copilot-premium-"));
const locator = join(directory, "hosts.yml");
try {
await writeFile(locator, "github.com:\n oauth_token: fixture-copilot\n");
return await fetchUsage({ store: "file", locator }, async () =>
jsonResponse({
copilot_plan: "individual",
quota_reset_date: "2026-12-01",
quota_reset_date_utc: utcReset,
quota_snapshots: {
premium_interactions: premium,
chat: { unlimited: true, percent_remaining: 100 },
completions: { unlimited: true, percent_remaining: 100 },
},
}),
);
} finally {
rmSync(directory, { recursive: true, force: true });
}
}
+38 -1
View File
@@ -5,6 +5,8 @@ import { join } from "node:path";
import { z } from "zod";
import {
unavailable,
windowFromUsedPct,
type UsageWindow,
type UsageAccount,
type UsageReport,
type UsageDetail,
@@ -18,6 +20,19 @@ const ApiOptionalStringSchema = z.preprocess(
const CopilotUsageResponseSchema = z.object({
copilot_plan: ApiOptionalStringSchema,
quota_reset_date: ApiOptionalStringSchema,
quota_reset_date_utc: ApiOptionalStringSchema,
quota_snapshots: z
.object({
premium_interactions: z
.object({
entitlement: z.number().nonnegative().nullish(),
remaining: z.number().nonnegative().nullish(),
percent_remaining: z.number().min(0).max(100).nullish(),
unlimited: z.boolean().optional(),
})
.nullish(),
})
.nullish(),
});
async function readToken(input: UsageInput): Promise<string | undefined> {
@@ -72,10 +87,32 @@ export async function fetchUsage(
? [{ id: "reset", label: "Quota reset", value: resp.quota_reset_date }]
: [];
const windows: UsageWindow[] = [];
const premium = resp.quota_snapshots?.premium_interactions;
if (premium && !premium.unlimited) {
if (premium.percent_remaining != null) {
windows.push(
windowFromUsedPct({
id: "premium_interactions",
label: "Premium requests",
utilizationPct: 100 - premium.percent_remaining,
resetsAt: resp.quota_reset_date_utc || resp.quota_reset_date,
}),
);
}
if (premium.remaining != null && premium.entitlement != null) {
details.push({
id: "premium_remaining",
label: "Premium requests remaining",
value: `${premium.remaining} / ${premium.entitlement}`,
});
}
}
return {
status: "available",
planLabel: resp.copilot_plan || undefined,
windows: [],
windows,
balances: [],
details,
};