Skip to content

Commit 102fd5e

Browse files
fix(workspace): pair the attach snapshot with the scope its binding was resolved under
The sidebar and boot box read the snapshot under the scope captured around the binding resolve; the boot box clears another account's lines. Status ignores a partial attach-time selection and lists each unreported tool once. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_0172qrhMa5TQgETASi5hxMqD
1 parent 3f54e1f commit 102fd5e

10 files changed

Lines changed: 179 additions & 50 deletions

File tree

‎packages/opencode/src/altimate/workspace/attach-snapshot.ts‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -158,6 +158,7 @@ function isSnapshot(v: unknown): v is AttachSnapshot {
158158
if (s.declared !== null) {
159159
const d = s.declared as Partial<Declared> | undefined
160160
if (!d || !isStringArray(d.keys) || !isStringArray(d.extensionKeys)) return false
161+
if (d.partial !== undefined && d.partial !== true) return false
161162
// The optional groupings too: the status view iterates them.
162163
if (d.integrations !== undefined) {
163164
if (!Array.isArray(d.integrations)) return false

‎packages/opencode/src/altimate/workspace/engine-probes.ts‎

Lines changed: 11 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -153,7 +153,17 @@ export async function declared(workspaceId: string): Promise<Declared | null> {
153153
extensionKeys.push(...toolKeys)
154154
if (toolKeys.length > 0) extensions.push({ id: integration.id, name, keys: toolKeys })
155155
}
156-
return { keys, extensionKeys, ...(extensions.length > 0 ? { extensions } : {}), integrations }
156+
// The schema lets the list, or an integration's tools, be absent; neither is
157+
// an empty selection.
158+
const list = workspace.integrations
159+
const partial = !Array.isArray(list) || list.some((i) => !Array.isArray(i.tools))
160+
return {
161+
keys,
162+
extensionKeys,
163+
...(extensions.length > 0 ? { extensions } : {}),
164+
integrations,
165+
...(partial ? { partial: true as const } : {}),
166+
}
157167
} catch (err) {
158168
log.warn("could not read the declared workspace integrations", { workspaceId, err: String(err) })
159169
return null

‎packages/opencode/src/altimate/workspace/engine-types.ts‎

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -86,6 +86,10 @@ export type Declared = {
8686
* attach was measured against even after the selection changes. Optional, like
8787
* `extensions`. */
8888
integrations?: DeclaredIntegration[]
89+
/** The lookup's response left out the integration list or an integration's
90+
* tools, so `integrations` is not a known selection: nothing can tell whether
91+
* the selection changed since. */
92+
partial?: true
8993
}
9094

9195
export type DeclaredExtension = { id: string; name: string; keys: string[] }

‎packages/opencode/src/altimate/workspace/status-view.ts‎

Lines changed: 29 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -111,9 +111,16 @@ export function buildStatusView(
111111
})
112112
const gaps = toGaps(reported.get(integration.id) ?? [])
113113
// An extension's absent keys are expected without its IDE bridge, not dropped.
114+
// One per catalog entry, like `served`.
115+
const missing = new Set<string>()
114116
const unreported = integration.extension
115117
? []
116-
: integration.keys.filter((k) => !present.has(sanitize(k)) && !reportedKeys.has(k))
118+
: integration.keys.filter((k) => {
119+
const entry = sanitize(k)
120+
if (present.has(entry) || reportedKeys.has(k) || missing.has(entry)) return false
121+
missing.add(entry)
122+
return true
123+
})
117124
rows.push({
118125
id: integration.id,
119126
name: names.get(integration.id) ?? integration.name ?? `Integration ${integration.id}`,
@@ -153,8 +160,12 @@ export function buildStatusView(
153160
...counts,
154161
rows,
155162
extras,
163+
// Only when both sides are known selections: the attach's read can be partial too.
156164
selectionChanged:
157-
!!live?.selection && !!snapshot.declared?.integrations && !sameSelection(declaredIntegrations, live.selection),
165+
!!live?.selection &&
166+
!!snapshot.declared?.integrations &&
167+
!snapshot.declared.partial &&
168+
!sameSelection(declaredIntegrations, live.selection),
158169
}
159170
}
160171

@@ -220,16 +231,25 @@ export function sidebarAttachLine(snapshot: AttachSnapshot, now = Date.now()): s
220231
return `${statusHeadline(snapshotCounts(snapshot))} · last session ${describeAge(snapshot.at, now)}`
221232
}
222233

223-
/** The last attach for `binding`, matched under the account scope the binding
224-
* cache and the overlay use (tenant, URL and credential digest). Undefined when
225-
* no session has attached to it under these credentials. */
226-
export async function boundAttachSnapshot(
234+
/** The last attach for `binding`, matched under `scope`, the account scope the
235+
* binding was resolved under (`accountScope`, read around that resolve by the
236+
* caller): never a scope sampled on its own, which could pair one account's
237+
* binding with another's snapshot. Undefined when no session has attached to it
238+
* under that scope. */
239+
export function boundAttachSnapshot(
227240
directory: string,
228241
binding: { datamateId: number | string } | null,
229-
): Promise<AttachSnapshot | undefined> {
230-
if (!binding) return undefined
242+
scope: string | null,
243+
): AttachSnapshot | undefined {
244+
if (!binding || scope === null) return undefined
245+
return currentAttachSnapshot(directory, { scope, datamateId: binding.datamateId })
246+
}
247+
248+
/** The account scope a binding read now would run under, as the binding cache
249+
* writes it; null when credentials do not resolve this instant. */
250+
export async function accountScope(): Promise<string | null> {
231251
const key = await currentScope().catch(() => null)
232-
return currentAttachSnapshot(directory, { scope: key ? scopeStringOf(key) : null, datamateId: binding.datamateId })
252+
return key ? scopeStringOf(key) : null
233253
}
234254

235255
/** The live selection, or null when the read could not say what it is: a

‎packages/opencode/src/altimate/workspace/welcome-lines.ts‎

Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -89,3 +89,34 @@ export function welcomeLinesFor(
8989
if (outcome.status === "unknown") return null
9090
return welcomeLines({ binding: outcome.status === "bound" ? outcome.binding : null, snapshot, now })
9191
}
92+
93+
/** What the box shows, and the account scope it describes. */
94+
export interface WelcomeState {
95+
lines: WelcomeLines | null
96+
scope: string | null
97+
}
98+
99+
/** One refresh of the box. `scopeBefore` and `scopeAfter` bracket the binding
100+
* resolve and the snapshot read. Lines that belong to another account are
101+
* cleared as soon as a different one reads; a pass whose account moved
102+
* underneath it is dropped, so one account's binding never shows with
103+
* another's numbers; an unknown answer leaves the box as it is. A null scope is
104+
* "could not read the credentials this instant", not a different account. */
105+
export function nextWelcomeState(
106+
prev: WelcomeState,
107+
read: {
108+
scopeBefore: string | null
109+
outcome: BindingOutcome
110+
snapshot: AttachSnapshot | undefined
111+
scopeAfter: string | null
112+
now?: number
113+
},
114+
): WelcomeState {
115+
const base =
116+
prev.scope !== null && read.scopeBefore !== null && read.scopeBefore !== prev.scope
117+
? { lines: null, scope: null }
118+
: prev
119+
if (read.scopeAfter !== read.scopeBefore) return base
120+
const lines = welcomeLinesFor(read.outcome, read.snapshot, read.now)
121+
return lines ? { lines, scope: read.scopeBefore } : base
122+
}

‎packages/opencode/src/plugin/tui/altimate/workspace-sidebar.tsx‎

Lines changed: 13 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -17,7 +17,7 @@ import { buildManageUrl, resolveWorkspaceWebUrl } from "@/altimate/workspace/bro
1717
import { getResolvedWorkspaceId } from "@/altimate/workspace/session-context"
1818
// altimate_change start - counts from the last attach under the workspace name
1919
import { describeAge } from "@/altimate/workspace/attach-snapshot"
20-
import { boundAttachSnapshot, sidebarAttachLine } from "@/altimate/workspace/status-view"
20+
import { accountScope, boundAttachSnapshot, sidebarAttachLine } from "@/altimate/workspace/status-view"
2121
// altimate_change end
2222
import { AltimateApi } from "@/altimate/api/client"
2323
import { openManageUrl } from "./workspace"
@@ -71,29 +71,25 @@ function View(props: { api: TuiPluginApi }) {
7171
// altimate_change end
7272
// altimate_change start - what the last session got, in numbers
7373
const [attachLine, setAttachLine] = createSignal<string | null>(null)
74-
const readAttachLine = async (bound: CachedBinding | null) => {
75-
// Only for the workspace this project is bound to now, under these
76-
// credentials; anything else would describe the wrong workspace by this
77-
// name. Matched under the full account scope the overlay writes it with,
78-
// not the tile's `tenant|apiUrl`.
79-
const snapshot = await boundAttachSnapshot(props.api.state.path.directory, bound)
74+
const readAttachLine = (bound: CachedBinding | null) => {
75+
// Only for the workspace this project is bound to now, matched under the
76+
// account scope that binding was resolved under (`boundScope`), the one the
77+
// overlay writes the snapshot with; anything else would describe the wrong
78+
// workspace by this name.
79+
const snapshot = boundAttachSnapshot(props.api.state.path.directory, bound, boundScope)
8080
setAttachLine(snapshot ? sidebarAttachLine(snapshot) : null)
8181
}
8282
// altimate_change end
8383

8484
let refreshInFlight = false
8585
let refreshQueued = false
8686
let disposed = false
87-
/** `tenant|apiUrl` the current binding was resolved under. */
87+
// altimate_change start - the full account scope, so a key switch on the same tenant is an account change too
88+
/** The account scope (tenant, URL and credential digest) the current binding
89+
* was resolved under: workspaces and their attach snapshots belong to it. */
8890
let boundScope: string | null = null
89-
const currentScope = async (): Promise<string | null> => {
90-
try {
91-
const creds = await AltimateApi.getCredentials()
92-
return `${creds.altimateInstanceName}|${creds.altimateUrl}`
93-
} catch {
94-
return null
95-
}
96-
}
91+
const currentScope = (): Promise<string | null> => accountScope()
92+
// altimate_change end
9793
const refresh = async (why: "poll" | "notify" = "poll") => {
9894
// A notification that lands mid-refresh is queued, not dropped: that pass
9995
// may already have read the old binding, and returning early would leave
@@ -190,7 +186,7 @@ function View(props: { api: TuiPluginApi }) {
190186
}
191187
const b = binding()
192188
// altimate_change start - what the last session got, in numbers
193-
await readAttachLine(b ?? null)
189+
readAttachLine(b ?? null)
194190
// altimate_change end
195191
// No clear here: every path that reaches this with no binding has already
196192
// cleared the manage URL, or never set one.

‎packages/opencode/src/plugin/tui/altimate/workspace-welcome.tsx‎

Lines changed: 11 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -9,8 +9,8 @@ import type { TuiPlugin, TuiPluginApi } from "@opencode-ai/plugin/tui"
99
import type { BuiltinTuiPlugin } from "@opencode-ai/tui/builtins"
1010
import { createSignal, onCleanup, onMount } from "solid-js"
1111
import { resolveBindingOutcome } from "@/altimate/workspace/state"
12-
import { boundAttachSnapshot } from "@/altimate/workspace/status-view"
13-
import { welcomeLinesFor, type WelcomeLines } from "@/altimate/workspace/welcome-lines"
12+
import { accountScope, boundAttachSnapshot } from "@/altimate/workspace/status-view"
13+
import { nextWelcomeState, type WelcomeState } from "@/altimate/workspace/welcome-lines"
1414

1515
const id = "altimate:welcome-workspace"
1616

@@ -23,7 +23,7 @@ const POLL_MS = 5_000
2323

2424
function View(props: { api: TuiPluginApi }) {
2525
const theme = () => props.api.theme.current
26-
const [lines, setLines] = createSignal<WelcomeLines | null>(null)
26+
const [state, setState] = createSignal<WelcomeState>({ lines: null, scope: null })
2727
let inFlight = false
2828
const refresh = async () => {
2929
if (inFlight) return
@@ -34,10 +34,14 @@ function View(props: { api: TuiPluginApi }) {
3434
// would read as unlinked and the box would tell the user to run
3535
// `altimate-code link`. An unknown answer (the server unreachable) leaves
3636
// the box as it was rather than asserting either way.
37+
// Bracketed by two account reads: after an account switch the box must
38+
// not keep the previous account's workspace, nor pair one account's
39+
// binding with another's numbers.
40+
const scopeBefore = await accountScope()
3741
const outcome = await resolveBindingOutcome(dir).catch(() => ({ status: "unknown" }) as const)
38-
const snapshot = await boundAttachSnapshot(dir, outcome.status === "bound" ? outcome.binding : null)
39-
const next = welcomeLinesFor(outcome, snapshot)
40-
if (next) setLines(next)
42+
const snapshot = boundAttachSnapshot(dir, outcome.status === "bound" ? outcome.binding : null, scopeBefore)
43+
const scopeAfter = await accountScope()
44+
setState((prev) => nextWelcomeState(prev, { scopeBefore, outcome, snapshot, scopeAfter }))
4145
} finally {
4246
inFlight = false
4347
}
@@ -47,7 +51,7 @@ function View(props: { api: TuiPluginApi }) {
4751
const timer = setInterval(() => void refresh(), POLL_MS)
4852
onCleanup(() => clearInterval(timer))
4953
})
50-
const current = () => lines()
54+
const current = () => state().lines
5155
return (
5256
<box gap={0} paddingTop={1}>
5357
<text fg={theme().accent}>

‎packages/opencode/test/altimate/workspace/engine-probes.test.ts‎

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -63,6 +63,18 @@ describe("declared", () => {
6363
})
6464
})
6565

66+
test("a response missing the list or an integration's tools is marked partial, not an empty selection", async () => {
67+
// The schema allows both; neither says the selection is empty. (codex)
68+
api.isConfigured = async () => true
69+
api.listIntegrations = async () => [{ id: "snowflake", type: "tool", tools: [] }]
70+
for (const integrations of [undefined, null, [{ id: "snowflake" }]]) {
71+
api.getDatamate = async () => ({ id: "42", name: "analytics", integrations })
72+
expect((await declared("42"))?.partial).toBe(true)
73+
}
74+
api.getDatamate = async () => ({ id: "42", name: "analytics", integrations: [{ id: "snowflake", tools: [] }] })
75+
expect((await declared("42"))?.partial).toBeUndefined()
76+
})
77+
6678
test("a workspace with no extension-type integration reports the flat lists only", async () => {
6779
api.isConfigured = async () => true
6880
api.getDatamate = async () => ({

‎packages/opencode/test/altimate/workspace/status-view.test.ts‎

Lines changed: 37 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,7 @@ afterAll(() => {
1717
})
1818

1919
const {
20+
accountScope,
2021
boundAttachSnapshot,
2122
buildStatusView,
2223
loadStatusView,
@@ -106,6 +107,14 @@ describe("buildStatusView", () => {
106107
expect(statusHeadline(view)).toBe("2 of 5 integration tools available · 3 need attention")
107108
})
108109

110+
test("a selection the attach could only read in part is never reported as changed", () => {
111+
// The attach-time read omitted the list or an integration's tools. (codex)
112+
const partial = snapshot()
113+
partial.declared = { ...partial.declared!, partial: true }
114+
const view = buildStatusView(partial, { selection: [{ id: "slack", tools: [{ key: "slack_post" }] }], catalog })
115+
expect(view.selectionChanged).toBe(false)
116+
})
117+
109118
test("the same selection in another order is not a change", () => {
110119
const view = buildStatusView(snapshot(), { selection: [...selection].reverse(), catalog })
111120
expect(view.selectionChanged).toBe(false)
@@ -153,6 +162,16 @@ describe("buildStatusView", () => {
153162
)
154163
expect(view.rows[0].served).toHaveLength(1)
155164
expect(rowLine(view.rows[0])).toStartWith("1 of 2")
165+
// Nor are they two dropped tools when neither is served. (coderabbit)
166+
const dropped = buildStatusView(
167+
snapshot({
168+
declared: { keys: ["jira.search", "jira_search"], extensionKeys: [], integrations: declared },
169+
present: [],
170+
unfulfilled: [],
171+
}),
172+
null,
173+
)
174+
expect(dropped.rows[0].unreported).toEqual(["jira.search"])
156175
})
157176

158177
test("a row shows every distinct error its gaps carry", () => {
@@ -292,7 +311,7 @@ describe("loadStatusView", () => {
292311
})
293312
})
294313

295-
describe("boundAttachSnapshot", () => {
314+
describe("boundAttachSnapshot and accountScope", () => {
296315
const DIR = "/proj/bound"
297316
const creds = { altimateUrl: "https://api.example.com", altimateInstanceName: "acme", altimateApiKey: "key-1" }
298317
let configured: ReturnType<typeof spyOn>
@@ -307,29 +326,32 @@ describe("boundAttachSnapshot", () => {
307326
credentials.mockRestore()
308327
})
309328
// The scope the overlay writes a snapshot under: the binding cache's, with the credential digest.
310-
const accountScope = (apiKey: string) =>
329+
const scopeFor = (apiKey: string) =>
311330
scopeStringOf({
312331
tenant: creds.altimateInstanceName,
313332
apiUrl: creds.altimateUrl,
314333
account: credentialDigest(creds.altimateUrl, creds.altimateInstanceName, apiKey),
315334
})
316-
317-
test("finds the attach the overlay wrote under the full account scope", async () => {
318-
// The sidebar matched it under `tenant|apiUrl` and never found it. (kilo)
335+
const written = (apiKey: string) =>
319336
writeAttachSnapshot(
320337
DIR,
321-
snapshot({ workspace: { id: "6", name: "e2e-demo-live", key: workspaceIdentity(accountScope("key-1"), 6) } }),
338+
snapshot({ workspace: { id: "6", name: "e2e-demo-live", key: workspaceIdentity(scopeFor(apiKey), 6) } }),
322339
)
323-
expect((await boundAttachSnapshot(DIR, { datamateId: 6 }))?.workspace.id).toBe("6")
340+
341+
test("accountScope is the full scope the overlay writes the snapshot under", async () => {
342+
// The sidebar matched under `tenant|apiUrl` and never found it. (kilo)
343+
written("key-1")
344+
const scope = await accountScope()
345+
expect(scope).toBe(scopeFor("key-1"))
346+
expect(boundAttachSnapshot(DIR, { datamateId: 6 }, scope)?.workspace.id).toBe("6")
324347
})
325348

326-
test("finds nothing for another key on the same tenant, or with no binding", async () => {
327-
writeAttachSnapshot(
328-
DIR,
329-
snapshot({ workspace: { id: "6", name: "e2e-demo-live", key: workspaceIdentity(accountScope("key-2"), 6) } }),
330-
)
331-
expect(await boundAttachSnapshot(DIR, { datamateId: 6 })).toBeUndefined()
332-
expect(await boundAttachSnapshot(DIR, null)).toBeUndefined()
349+
test("a snapshot matches only under the scope the binding was resolved under", () => {
350+
// Another key on the same tenant is another account; no binding or no scope finds nothing. (codex)
351+
written("key-2")
352+
expect(boundAttachSnapshot(DIR, { datamateId: 6 }, scopeFor("key-1"))).toBeUndefined()
353+
expect(boundAttachSnapshot(DIR, { datamateId: 6 }, scopeFor("key-2"))?.workspace.id).toBe("6")
354+
expect(boundAttachSnapshot(DIR, null, scopeFor("key-2"))).toBeUndefined()
355+
expect(boundAttachSnapshot(DIR, { datamateId: 6 }, null)).toBeUndefined()
333356
})
334357
})
335-

‎packages/opencode/test/altimate/workspace/welcome-lines.test.ts‎

Lines changed: 30 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
import { describe, expect, test } from "bun:test"
22
import { workspaceIdentity, type AttachSnapshot } from "../../../src/altimate/workspace/attach-snapshot"
3-
import { WELCOME_LINE_MAX_CHARS, welcomeLines, welcomeLinesFor, WORKSPACE_COMMANDS } from "../../../src/altimate/workspace/welcome-lines"
3+
import { nextWelcomeState, WELCOME_LINE_MAX_CHARS, welcomeLines, welcomeLinesFor, WORKSPACE_COMMANDS } from "../../../src/altimate/workspace/welcome-lines"
44

55
const binding = {
66
datamateId: 6,
@@ -98,3 +98,32 @@ describe("welcomeLines", () => {
9898
})
9999
})
100100

101+
describe("nextWelcomeState: one refresh of the box", () => {
102+
// After an account switch the box must not keep the previous account's
103+
// workspace, nor pair one account's binding with another's numbers. (coderabbit)
104+
const A = "acme|https://api.example.com|a"
105+
const B = "acme|https://api.example.com|b"
106+
const shown = { lines: welcomeLines({ binding, snapshot: undefined }), scope: A }
107+
const bound = { status: "bound", binding } as const
108+
const unknown = { status: "unknown" } as const
109+
type Case = [string, typeof shown | { lines: null; scope: null }, string | null, typeof bound | typeof unknown, string | null, "kept" | "cleared" | "new"]
110+
test.each<Case>([
111+
["same account, resolved: the new lines", shown, A, bound, A, "new"],
112+
["same account, unknown: left as it is", shown, A, unknown, A, "kept"],
113+
["another account, unknown: the old account's lines go", shown, B, unknown, B, "cleared"],
114+
["another account, resolved: the new account's lines", shown, B, bound, B, "new"],
115+
["account moved during the pass: dropped, nothing paired", shown, A, bound, B, "kept"],
116+
["moved to another account during the pass: cleared, nothing committed", shown, B, bound, A, "cleared"],
117+
["credentials unreadable this instant: not another account", shown, null, unknown, null, "kept"],
118+
["first pass, resolved", { lines: null, scope: null }, A, bound, A, "new"],
119+
])("%s", (_label, prev, scopeBefore, outcome, scopeAfter, expected) => {
120+
const next = nextWelcomeState(prev, { scopeBefore, outcome, snapshot: undefined, scopeAfter })
121+
if (expected === "kept") expect(next).toBe(prev)
122+
if (expected === "cleared") expect(next).toEqual({ lines: null, scope: null })
123+
if (expected === "new") {
124+
expect(next.lines?.mode).toStartWith("Workspace mode · linked to")
125+
expect(next.scope).toBe(scopeBefore)
126+
}
127+
})
128+
})
129+

0 commit comments

Comments
 (0)