Skip to content

Commit 76de5a9

Browse files
committed
fix(workspace): drop hard dependency on git remote + defer post-scan prompt
Two user-flagged issues on the Workspaces post-scan prompt landed in 7c7e17f: 1. Post-scan dialog raced the LLM's onboarding-menu streaming — the dialog painted while text was still generating, and Enter didn't register until streaming finished. Fix: arm a one-shot `session.idle` listener via `EventV2Bridge` from `onboarding-telemetry.ts` and publish `TuiEvent.CommandExecute` only after the session settles. Costs a few seconds of latency; kills the race. 2. `resolveProjectRemote` returned undefined for projects without a git remote (materialized sample dbt scaffolds, fresh scratch dirs), so the post-scan prompt and `altimate-code link` both bailed silently. Fix: new `resolveProjectIdentifier` in `workspace/detect.ts` always returns a `{repoRemote?, projectPath}` pair (path is symlink-resolved `realpath`). `ProjectIdentifier` type threads through `WorkspaceApi`, the TuiPlugin dialogs, and the `link` subcommand — remote is preferred when available (stronger identity, survives directory moves); path is the fallback the backend indexes symmetrically. Also: `projectNameFromPath` fallback for auto-naming (derives from directory basename when no remote); Skip-latch key hashes remote-or-path so path-only projects also get the 7-day suppression; `runFlow` and `runOnDemandPicker` reworked to use `WorkspaceApi.getBindingForProject` (tries remote first, then path); `CachedBinding` in state.ts extended with `projectPath: string | null`. Tests updated + one new latch test covers the path-only case. `bun test test/altimate/plugin/workspace.test.ts` → 18/18.
1 parent 7c7e17f commit 76de5a9

7 files changed

Lines changed: 583 additions & 278 deletions

File tree

‎packages/opencode/src/altimate/plugin/onboarding-telemetry.ts‎

Lines changed: 46 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -16,11 +16,53 @@ import * as OnboardingTelemetry from "../telemetry/onboarding"
1616
// EventV2 bridge the server/routes/tui.ts uses to publish TuiEvent.CommandExecute
1717
// so the workspace TuiPlugin (packages/opencode/src/plugin/tui/altimate/workspace.tsx)
1818
// runs its post-scan flow. Feature-flagged via Flag.ALTIMATE_WORKSPACE.
19+
import { Effect } from "effect"
1920
import { Flag } from "@opencode-ai/core/flag/flag"
2021
import { AltimateApi } from "@/altimate/api/client"
2122
import { AppRuntime } from "@/effect/app-runtime"
2223
import { EventV2Bridge } from "@/event-v2-bridge"
2324
import { TuiEvent } from "@/server/tui-event"
25+
import { Event as SessionEvent } from "@/session/status"
26+
27+
/**
28+
* Publish the workspace-postScan command AFTER the session goes idle, not on
29+
* `project_scan`'s tool.execute.after. Rationale: project_scan tool RETURNS while
30+
* the LLM is still generating the activation-menu text; the dialog paints in
31+
* that window but user interactions queue behind the streaming. Waiting for
32+
* session.idle costs a few seconds of latency but sidesteps the race entirely —
33+
* the dialog appears once things are quiet.
34+
*
35+
* One-shot per sessionID: the listener unregisters as soon as the target
36+
* session emits idle. A pending arm is dropped if a second project_scan fires
37+
* in the same session (unlikely but handled).
38+
*/
39+
const pendingWorkspacePromptSessions = new Set<string>()
40+
let workspacePromptListenerArmed = false
41+
42+
async function armWorkspacePromptOnSessionIdle(sessionID: string): Promise<void> {
43+
pendingWorkspacePromptSessions.add(sessionID)
44+
if (workspacePromptListenerArmed) return
45+
workspacePromptListenerArmed = true
46+
await AppRuntime.runPromise(
47+
EventV2Bridge.Service.use((events) =>
48+
events.listen((event) =>
49+
Effect.gen(function* () {
50+
if (event.type !== SessionEvent.Idle.type) return
51+
const sid = (event.data as { sessionID?: string } | undefined)?.sessionID
52+
if (!sid || !pendingWorkspacePromptSessions.has(sid)) return
53+
pendingWorkspacePromptSessions.delete(sid)
54+
yield* events.publish(TuiEvent.CommandExecute, {
55+
command: "altimate.workspace.postScan",
56+
})
57+
}),
58+
),
59+
),
60+
).catch((err) => {
61+
// eslint-disable-next-line no-console
62+
console.error("[altimate-workspace] session-idle listener install failed:", err)
63+
workspacePromptListenerArmed = false
64+
})
65+
}
2466
// altimate_change end
2567

2668
const ONBOARD_CONNECT = "onboard-connect"
@@ -145,25 +187,11 @@ export async function OnboardingTelemetryPlugin(_input: PluginInput): Promise<Ho
145187
input.sessionID,
146188
)
147189
// altimate_change start — AI-8398 workspaces post-scan prompt trigger.
148-
// Fires the TUI plugin's altimate.workspace.postScan command once the scan
149-
// completes AND the CLI has real Altimate credentials (BYOK users skip this
150-
// path entirely — no place to send them). All the state (Skip latch, cache,
151-
// server pre-check) is plugin-side so the trigger stays a fire-and-forget
152-
// signal here; a retry would just re-enter the plugin's idempotent runFlow.
190+
// ARM (don't publish yet) — the dialog fires when the session goes idle,
191+
// not the moment project_scan returns. See armWorkspacePromptOnSessionIdle
192+
// above for why the immediate publish raced the LLM's ongoing streaming.
153193
if (Flag.ALTIMATE_WORKSPACE && (await AltimateApi.isConfigured().catch(() => false))) {
154-
void AppRuntime.runPromise(
155-
EventV2Bridge.Service.use((events) =>
156-
events.publish(TuiEvent.CommandExecute, {
157-
command: "altimate.workspace.postScan",
158-
}),
159-
),
160-
).catch((err) => {
161-
// Never block onboarding on a publish failure — worst case the
162-
// user picks up the workspace on their next launch or via the
163-
// /altimate.workspace.link palette command.
164-
// eslint-disable-next-line no-console
165-
console.error("[altimate-workspace] postScan trigger failed:", err)
166-
})
194+
void armWorkspacePromptOnSessionIdle(input.sessionID)
167195
}
168196
// altimate_change end
169197
return

‎packages/opencode/src/altimate/workspace/api-client.ts‎

Lines changed: 68 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -23,10 +23,20 @@ export interface Binding {
2323
id: number
2424
datamate_id: number
2525
datamate_name: string
26-
repo_remote: string
26+
/** Either ``repo_remote`` OR ``project_path`` is populated (at least one). */
27+
repo_remote: string | null
28+
project_path: string | null
2729
created_at?: string
2830
}
2931

32+
/** Project identifier passed to create/bind endpoints. At least one field is
33+
* required by the backend's CHECK constraint; the CLI's resolveProjectIdentifier
34+
* always populates ``projectPath`` and populates ``repoRemote`` when available. */
35+
export interface ProjectIdentifier {
36+
repoRemote?: string
37+
projectPath?: string
38+
}
39+
3040
export interface CreateAndBindResponse {
3141
datamate: DatamateRef
3242
binding: Binding
@@ -47,6 +57,7 @@ export interface ConflictDetail {
4757
existing_datamate_id?: number
4858
existing_datamate_name?: string | null
4959
repo_remote?: string
60+
project_path?: string
5061
}
5162

5263
export interface PreconditionDetail {
@@ -170,7 +181,7 @@ async function req<T>(
170181
}
171182

172183
export namespace WorkspaceApi {
173-
/** Server-authoritative pre-check: is this remote already bound in this tenant? */
184+
/** Server-authoritative pre-check by git remote. Returns null on 404. */
174185
export async function getBindingForRemote(remote: string): Promise<GetBindingResponse | null> {
175186
try {
176187
return await req<GetBindingResponse>("GET", "/by-remote", { query: { repo_remote: remote } })
@@ -180,23 +191,56 @@ export namespace WorkspaceApi {
180191
}
181192
}
182193

194+
/** Symmetric pre-check by absolute project directory path (for projects
195+
* without a git remote). Returns null on 404. */
196+
export async function getBindingForPath(projectPath: string): Promise<GetBindingResponse | null> {
197+
try {
198+
return await req<GetBindingResponse>("GET", "/by-path", { query: { project_path: projectPath } })
199+
} catch (err) {
200+
if (err instanceof NotFoundError) return null
201+
throw err
202+
}
203+
}
204+
205+
/** Tries remote first (stronger identity), then path. Returns the first hit
206+
* or null. Both fields on the identifier are optional but at least one must
207+
* be present. */
208+
export async function getBindingForProject(id: ProjectIdentifier): Promise<GetBindingResponse | null> {
209+
if (id.repoRemote) {
210+
const hit = await getBindingForRemote(id.repoRemote)
211+
if (hit) return hit
212+
}
213+
if (id.projectPath) {
214+
return await getBindingForPath(id.projectPath)
215+
}
216+
return null
217+
}
218+
183219
export async function createAndBind(input: {
184220
name: string
185-
repoRemote: string
221+
identifier: ProjectIdentifier
186222
description?: string
187223
}): Promise<CreateAndBindResponse> {
188224
return req<CreateAndBindResponse>("POST", "/", {
189225
body: {
190226
name: input.name,
191-
repo_remote: input.repoRemote,
227+
repo_remote: input.identifier.repoRemote ?? null,
228+
project_path: input.identifier.projectPath ?? null,
192229
description: input.description ?? null,
193230
},
194231
})
195232
}
196233

197-
export async function bindExisting(datamateId: number, remote: string): Promise<BindingResponse> {
234+
export async function bindExisting(
235+
datamateId: number,
236+
identifier: ProjectIdentifier,
237+
): Promise<BindingResponse> {
198238
return req<BindingResponse>("POST", "/bind", {
199-
body: { datamate_id: datamateId, repo_remote: remote },
239+
body: {
240+
datamate_id: datamateId,
241+
repo_remote: identifier.repoRemote ?? null,
242+
project_path: identifier.projectPath ?? null,
243+
},
200244
})
201245
}
202246

@@ -216,6 +260,24 @@ export namespace WorkspaceApi {
216260
})
217261
}
218262

263+
/** Path-identified rebind — symmetric to ``rebindByRemote`` for projects
264+
* without a git remote. */
265+
export async function rebindByPath(input: {
266+
projectPath: string
267+
targetDatamateId: number
268+
expectedCurrentDatamateId?: number
269+
}): Promise<BindingResponse> {
270+
return req<BindingResponse>("PUT", "/by-path", {
271+
body: {
272+
project_path: input.projectPath,
273+
target_datamate_id: input.targetDatamateId,
274+
...(input.expectedCurrentDatamateId !== undefined
275+
? { expected_current_datamate_id: input.expectedCurrentDatamateId }
276+
: {}),
277+
},
278+
})
279+
}
280+
219281
/** Populates the "link to existing workspace" picker. Reuses the existing
220282
* /datamates/ list endpoint on the datamates_router. */
221283
export async function listDatamates(): Promise<DatamateRef[]> {

‎packages/opencode/src/altimate/workspace/detect.ts‎

Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,8 @@
88
// basic-auth (e.g. `https://x-access-token:ghp_xxx@github.com/...`) never
99
// reaches the server or the local cache in clear.
1010
import { spawnSync } from "node:child_process"
11+
import { realpathSync } from "node:fs"
12+
import path from "node:path"
1113
import { stripGitRemoteCredentials } from "../tools/project-scan"
1214

1315
export function detectProjectRemote(directory: string): string | undefined {
@@ -24,6 +26,30 @@ export function detectProjectRemote(directory: string): string | undefined {
2426
}
2527
}
2628

29+
/** Project identity for the binding system. Prefers ``repo_remote`` when the
30+
* project has a git remote (stronger identity — survives directory moves); falls
31+
* back to ``project_path`` (absolute, symlink-resolved directory path) for
32+
* projects without one (materialized sample scaffolds, fresh scratch dirs).
33+
*
34+
* Both fields can be populated simultaneously; callers pick which to use for
35+
* lookup or send both to create/bind (server uses whichever it needs for its
36+
* partial unique index). At least one field is always populated — falling back
37+
* to the raw ``directory`` argument keeps the "no remote AND unresolvable path"
38+
* degenerate case from returning empty. */
39+
export function resolveProjectIdentifier(directory: string): {
40+
repoRemote?: string
41+
projectPath: string
42+
} {
43+
const repoRemote = detectProjectRemote(directory)
44+
let projectPath: string
45+
try {
46+
projectPath = realpathSync(path.resolve(directory))
47+
} catch {
48+
projectPath = path.resolve(directory)
49+
}
50+
return repoRemote ? { repoRemote, projectPath } : { projectPath }
51+
}
52+
2753
/** Sensible default workspace name from a remote URL — best-effort. Used to
2854
* prefill the workspace-name prompt in the CreateDialog and `altimate link`.
2955
* ``github.com/foo/bar.git`` → ``bar`` ; ``git@github.com:foo/bar.git`` → ``bar``. */
@@ -32,3 +58,10 @@ export function projectNameFromRemote(remote: string): string {
3258
const parts = trimmed.split(/[/:]/)
3359
return parts[parts.length - 1] || "workspace"
3460
}
61+
62+
/** Fallback name source when the project has no git remote — uses the directory's
63+
* basename (e.g. ``/Users/x/sample-dbt-project`` → ``sample-dbt-project``). */
64+
export function projectNameFromPath(projectPath: string): string {
65+
const base = path.basename(projectPath.replace(/\/$/, ""))
66+
return base || "workspace"
67+
}

‎packages/opencode/src/altimate/workspace/state.ts‎

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -24,7 +24,11 @@ const log = Log.create({ service: "altimate-workspace-state" })
2424
export interface CachedBinding {
2525
datamateId: number
2626
datamateName: string
27-
repoRemote: string
27+
/** Either ``repoRemote`` or ``projectPath`` is populated (at least one).
28+
* Mirrors the server-side binding row, which is identified by whichever
29+
* fields it has. */
30+
repoRemote: string | null
31+
projectPath: string | null
2832
linkedAt: number
2933
}
3034

0 commit comments

Comments
 (0)