Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
33 commits
Select commit Hold shift + click to select a range
e67ab35
feat(webview): durable per-view viewStates pipeline (N2)
easonLiangWorldedtech Oct 28, 2026
f28c486
Merge upstream/main into vps2/n2-viewstates-durability
easonLiangWorldedtech Oct 29, 2026
7906df6
test(webview): carry the identity and durable viewStates tests with N2
easonLiangWorldedtech Oct 29, 2026
f560872
chore: untrack generated husky hooks
easonLiangWorldedtech Oct 29, 2026
6340998
chore: restore main tracked husky hooks
easonLiangWorldedtech Oct 29, 2026
9849018
feat(webview): view-local buffer reads and setValue persistence (N3)
easonLiangWorldedtech Oct 29, 2026
7b610a1
feat(webview): setValues batch persistence and reset cleanup (N4)
easonLiangWorldedtech Oct 29, 2026
7de336e
chore: untrack generated husky hooks
easonLiangWorldedtech Oct 29, 2026
035f117
chore: strip BOM and fix lint imports
easonLiangWorldedtech Oct 29, 2026
c723553
fix(webview): address review findings on view-state durability
easonLiangWorldedtech Oct 29, 2026
faa3982
fix(config): keep a newer cached value when an older global-state wri…
easonLiangWorldedtech Oct 30, 2026
9b68e06
chore: tighten the shared review-fix comments
easonLiangWorldedtech Oct 30, 2026
ac73edb
fix(config): advance write generations on reset
easonLiangWorldedtech Oct 30, 2026
04350e2
chore: align the shared view-identity docs with N1
easonLiangWorldedtech Oct 30, 2026
d96c8a3
Merge remote-tracking branch 'upstream/main' into HEAD
easonLiangWorldedtech Oct 30, 2026
66184ce
fix(webview): invalidate in-flight loads on reset and refresh sibling…
easonLiangWorldedtech Oct 30, 2026
9a01dd8
chore: tighten the shared view-state docs
easonLiangWorldedtech Oct 30, 2026
e8307ef
fix(webview): address the N4 reset and profile-save findings
easonLiangWorldedtech Nov 1, 2026
5841fc1
fix(webview): address the N4 sibling-refresh and reset findings
easonLiangWorldedtech Nov 2, 2026
ff26ffc
fix(webview): restore a batch custom mode when createTask fails after…
easonLiangWorldedtech Nov 2, 2026
8145b37
merge upstream/main (a4f774b17) into vps2/n4-values-reset
easonLiangWorldedtech Nov 2, 2026
a95c43c
fix(webview): own failed-write rollbacks per key and undo registered …
easonLiangWorldedtech Nov 3, 2026
e3ca230
Merge upstream/main (e943530c8) into vps2/n4-values-reset
easonLiangWorldedtech Nov 5, 2026
877ea0a
test(webview): split the profile-save overlay case and drop its dupli…
easonLiangWorldedtech Nov 5, 2026
ec54bce
fix(webview): roll back custom-mode registrations that fail partway t…
easonLiangWorldedtech Nov 5, 2026
509f92c
test(webview): spy the profile and modes methods instead of suppressi…
easonLiangWorldedtech Nov 5, 2026
492cfb3
fix(config): refuse secret writes during a reset and clear the guard …
easonLiangWorldedtech Nov 5, 2026
610b6de
fix(config): single-flight the reset and settle every delete before t…
easonLiangWorldedtech Nov 5, 2026
41bd742
fix(webview): restore both source definitions of a custom mode on rol…
easonLiangWorldedtech Nov 5, 2026
677e5e3
test(config): pin that a reset settles every delete before it rejects
easonLiangWorldedtech Nov 7, 2026
96c9157
fix(config): tell an unreadable mode file from an absent one before r…
easonLiangWorldedtech Nov 7, 2026
6516269
Merge upstream/main into vps2/n4-values-reset (refresh, last commit).
easonLiangWorldedtech Nov 7, 2026
8f63933
test(config): probe the reset guard after the first delete rejects
easonLiangWorldedtech Nov 7, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
43 changes: 43 additions & 0 deletions packages/types/src/__tests__/global-settings.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -39,6 +39,49 @@ describe("alwaysDenyUnapprovedCommands global setting", () => {
})
})

describe("viewStates global setting", () => {
it("parses a valid per-view record", () => {
const parsed = globalSettingsSchema.parse({
viewStates: {
"stable-view-1": { mode: "code", currentApiConfigName: "profile-a", updatedAt: 1730000000000 },
"stable-view-2": {},
},
})
expect(parsed.viewStates?.["stable-view-1"]).toEqual({
mode: "code",
currentApiConfigName: "profile-a",
updatedAt: 1730000000000,
})
expect(parsed.viewStates?.["stable-view-2"]).toEqual({})
expect(GLOBAL_SETTINGS_KEYS).toContain("viewStates")
})

it("parses an entry that carries the host-minted claim token", () => {
// The claim binds the entry to the webview that wrote it; a legacy entry without one
// still parses, because refusing it is setViewStateId's job, not the schema's.
const parsed = globalSettingsSchema.parse({
viewStates: {
"stable-view-1": { mode: "code", updatedAt: 1730000000000, claimToken: "7f9c-host-minted" },
"stable-view-2": { mode: "code" },
},
})
expect(parsed.viewStates?.["stable-view-1"]?.claimToken).toBe("7f9c-host-minted")
expect(parsed.viewStates?.["stable-view-2"]).toEqual({ mode: "code" })
})

it("rejects invalid field values inside a view entry", () => {
expect(() => globalSettingsSchema.parse({ viewStates: { v: { mode: 42 } } })).toThrow()
expect(() => globalSettingsSchema.parse({ viewStates: { v: { currentApiConfigName: 42 } } })).toThrow()
expect(() => globalSettingsSchema.parse({ viewStates: { v: { updatedAt: "soon" } } })).toThrow()
expect(() => globalSettingsSchema.parse({ viewStates: { v: { claimToken: 42 } } })).toThrow()
})

it("rejects non-object viewStates values", () => {
expect(() => globalSettingsSchema.parse({ viewStates: "not-a-record" })).toThrow()
expect(() => globalSettingsSchema.parse({ viewStates: { v: "not-an-entry" } })).toThrow()
})
})

describe("collapseCommandOutputByDefault global setting", () => {
it.each([true, false, undefined])("round-trips %s through the export/import schema", (value) => {
const settings = globalSettingsSchema.parse({ collapseCommandOutputByDefault: value })
Expand Down
5 changes: 5 additions & 0 deletions packages/types/src/__tests__/index.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,10 @@
import { GLOBAL_STATE_KEYS } from "../index.js"

describe("GLOBAL_STATE_KEYS", () => {
it("should contain registered durable per-view state", () => {
expect(GLOBAL_STATE_KEYS).toContain("viewStates")
})

it("should contain provider settings keys", () => {
expect(GLOBAL_STATE_KEYS).toContain("autoApprovalEnabled")
})
Expand All @@ -13,6 +17,7 @@ describe("GLOBAL_STATE_KEYS", () => {

it("should not contain secret state keys", () => {
expect(GLOBAL_STATE_KEYS).not.toContain("openRouterApiKey")
expect(GLOBAL_STATE_KEYS).not.toContain("apiKey")
})

it("should contain OpenAI Compatible base URL setting", () => {
Expand Down
12 changes: 12 additions & 0 deletions packages/types/src/global-settings.ts
Original file line number Diff line number Diff line change
Expand Up @@ -114,6 +114,17 @@ export const MAX_CHECKPOINT_TIMEOUT_SECONDS = 60
*/
export const DEFAULT_CHECKPOINT_TIMEOUT_SECONDS = 15

/**
* Persisted non-secret selections for a stable webview instance.
*/
export const viewStateSchema = z.object({
mode: z.string().optional(),
currentApiConfigName: z.string().optional(),
updatedAt: z.number().optional(),
/** Host-minted token binding this entry to the webview that wrote it. */
claimToken: z.string().optional(),
})

/**
* GlobalSettings
*/
Expand All @@ -122,6 +133,7 @@ export const globalSettingsSchema = z.object({
currentApiConfigName: z.string().optional(),
listApiConfigMeta: z.array(providerSettingsEntrySchema).optional(),
pinnedApiConfigs: z.record(z.string(), z.boolean()).optional(),
viewStates: z.record(z.string(), viewStateSchema).optional(),

lastShownAnnouncementId: z.string().optional(),
customInstructions: z.string().optional(),
Expand Down
7 changes: 7 additions & 0 deletions packages/types/src/vscode-extension-host.ts
Original file line number Diff line number Diff line change
Expand Up @@ -113,7 +113,14 @@ export interface ExtensionMessage {
| "rooHistoryImportProgress"
| "themeFixtureProbeRequest"
| "diagnosticsRequest"
| "viewStateClaim"
text?: string
/**
* For `viewStateClaim`: the durable `viewStates` key the delivered claim governs.
*/
viewStateId?: string
/** For `viewStateClaim`: the host-minted token the webview stores and echoes on launch. */
viewStateClaim?: string
/** For fileContent: { path, content, error? } */
fileContent?: { path: string; content: string | null; error?: string }
/** For originalContent: the pre-edit file content of the requested tool message (null when unavailable) */
Expand Down
104 changes: 94 additions & 10 deletions src/core/config/ContextProxy.ts
Original file line number Diff line number Diff line change
Expand Up @@ -63,18 +63,35 @@ const withinReadBackDeadline = <T>(read: PromiseLike<T>, key: string): Promise<T

export const isPassThroughStateKey = (key: string) => PASS_THROUGH_STATE_KEYS.includes(key)

// A parsed payload can carry these as own keys. Writing one through a computed assignment
// re-points the target object's prototype instead of storing a setting, so they are never
// accepted as state keys.
const UNSAFE_STATE_KEYS = ["__proto__", "constructor", "prototype"]

export const isUnsafeStateKey = (key: string) => UNSAFE_STATE_KEYS.includes(key)

const globalSettingsExportSchema = globalSettingsSchema.omit({
taskHistory: true,
listApiConfigMeta: true,
currentApiConfigName: true,
// Per-view selection state is machine-local: it keeps flowing through the
// normal runtime and pruning paths but must not transfer between settings.
viewStates: true,
})

export class ContextProxy {
private readonly originalContext: vscode.ExtensionContext

private stateCache: GlobalState
private stateWriteGenerations = new Map<GlobalStateKey, number>()
private secretCache: SecretState
private _isInitialized = false
// Raised for the whole duration of a reset and cleared in a finally, so a reset that fails
// mid-way cannot leave the store refusing every later secret write; see resetAllState.
private resetting = false
// A reset that is already running. Overlapping callers join it instead of starting a second
// delete pass whose end could lower the guard while the first one is still writing.
private resetInFlight: Promise<void> | null = null

constructor(context: vscode.ExtensionContext) {
this.originalContext = context
Expand Down Expand Up @@ -401,6 +418,10 @@ export class ContextProxy {
private readonly secretWriteCount = new Map<string, number>()

updateGlobalState<K extends GlobalStateKey>(key: K, value: GlobalState[K]) {
if (isUnsafeStateKey(key)) {
return Promise.resolve()
}

if (isPassThroughStateKey(key)) {
return this.originalContext.globalState.update(key, value)
}
Expand All @@ -413,6 +434,8 @@ export class ContextProxy {
// writes that are in flight. An update that throws at once is turned into a rejection, so it is handled the same.
const owner = {}
this.stateWriteOwner.set(key, owner)
const generation = (this.stateWriteGenerations.get(key) ?? 0) + 1
this.stateWriteGenerations.set(key, generation)
this.stateCache[key] = value
const release = () => {
if (this.stateWriteOwner.get(key) === owner) this.stateWriteOwner.delete(key)
Expand All @@ -429,6 +452,32 @@ export class ContextProxy {
})
}

/**
* The write generation the next `setValue`/`setValues` of this key will produce, or
* `undefined` when the key's write path records no generation at all - a secret, or a
* pass-through key that bypasses the state cache. Capture it before awaiting the write.
*/
public beginStateWriteGeneration<K extends RooCodeSettingsKey>(key: K): number | undefined {
if (!this.recordsWriteGeneration(key)) {
return undefined
}

return (this.stateWriteGenerations.get(key as GlobalStateKey) ?? 0) + 1
}

/**
* Current write generation for a key, or `undefined` when its write path records none. A
* caller that may have to undo its own write compares it against the value captured from
* `beginStateWriteGeneration`: a newer write for the same key has already advanced it.
*/
public getStateWriteGeneration<K extends RooCodeSettingsKey>(key: K): number | undefined {
return this.recordsWriteGeneration(key) ? this.stateWriteGenerations.get(key as GlobalStateKey) : undefined
}

private recordsWriteGeneration<K extends RooCodeSettingsKey>(key: K): boolean {
return !isSecretStateKey(key) && !isPassThroughStateKey(key)
}

private getAllGlobalState(): GlobalState {
return Object.fromEntries(GLOBAL_STATE_KEYS.map((key) => [key, this.getGlobalState(key)]))
}
Expand All @@ -443,6 +492,13 @@ export class ContextProxy {
}

storeSecret(key: SecretStateKey, value?: string) {
// Refused rather than deferred: a deferred secret write would re-store exactly the value the
// user asked to erase, and a silently dropped one would lose the save.
if (this.resetting) {
return Promise.reject(
new SettingsWriteError([], [key], new Error("secret writes are refused while state is being reset")),
)
}
// Update cache.
const owner = {}
this.secretWriteOwner.set(key, owner)
Expand Down Expand Up @@ -715,18 +771,46 @@ export class ContextProxy {
* This clears all data from both the in-memory caches and the VSCode storage.
* @returns A promise that resolves when all reset operations are complete
*/
public async resetAllState() {
// Clear in-memory caches
this.stateCache = {}
this.secretCache = {}
public resetAllState(): Promise<void> {
// Two resets can overlap (a second webview, or the same command twice). A boolean guard lets the
// first pass to finish lower it while the second is still deleting, and a secret stored in that
// gap is erased by the pass that is still running - so overlapping callers join the running pass.
if (this.resetInFlight) {
return this.resetInFlight
}

await Promise.all([
...GLOBAL_STATE_KEYS.map((key) => this.originalContext.globalState.update(key, undefined)),
...SECRET_STATE_KEYS.map((key) => this.originalContext.secrets.delete(key)),
...GLOBAL_SECRET_KEYS.map((key) => this.originalContext.secrets.delete(key)),
])
this.resetting = true
Comment thread
coderabbitai[bot] marked this conversation as resolved.
Resolved
const pass = (async () => {
try {
this.stateCache = {}
this.secretCache = {}
// A write that started before this reset must not restore its pre-reset value when it
// rejects afterwards, so advance every generation the pending writes still hold.
for (const key of this.stateWriteGenerations.keys()) {
this.stateWriteGenerations.set(key, (this.stateWriteGenerations.get(key) ?? 0) + 1)
}

await this.initialize()
// Promise.all rejects on the first failure while the other updates and deletes are still
// pending, and the finally below would then admit a storeSecret that a still-running
// delete erases. Settle every delete first, then surface the first failure.
const deletions = await Promise.allSettled([
...GLOBAL_STATE_KEYS.map((key) => this.originalContext.globalState.update(key, undefined)),
...SECRET_STATE_KEYS.map((key) => this.originalContext.secrets.delete(key)),
...GLOBAL_SECRET_KEYS.map((key) => this.originalContext.secrets.delete(key)),
])
const deletionFailure = deletions.find((result) => result.status === "rejected")
if (deletionFailure) {
throw deletionFailure.reason
}

await this.initialize()
} finally {
this.resetting = false
this.resetInFlight = null
}
})()
this.resetInFlight = pass
return pass
}

private static _instance: ContextProxy | null = null
Expand Down
Loading
Loading