Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
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
98 changes: 70 additions & 28 deletions .opencode/plugin/tensorlake/core/client.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import { RemoteAPIError, Sandbox, SandboxConnectionError, SandboxNotFoundError } from 'tensorlake'
import type { FileSystemMount } from 'tensorlake'
import { execFileSync } from 'child_process'
import { PROJECT_KEY_PREFIX } from './credentials.js'
import { logger } from './logger.js'

const MANAGEMENT_API = process.env.TENSORLAKE_API_URL ?? 'https://api.tensorlake.ai'
Expand Down Expand Up @@ -35,7 +35,15 @@ type HandleEntry = {
sandbox?: Sandbox
}

export class TensorLakeClient {
export type ProcessStatusInfo = {
pid: number
status: string
exitCode?: number
signal?: number
command: string
}

export class TensorlakeClient {
// Connected handles keyed by sandboxId, so repeated operations reuse the
// resolved proxy routing instead of re-resolving on every call. The pending
// connect promise is cached (not the resolved handle) so concurrent calls
Expand All @@ -58,31 +66,10 @@ export class TensorLakeClient {
return this.resolveKey() ?? ''
}

private warnedProjectKeyScopeOverride = false

// Ingress derives the organization/project scope from the API key itself
// (SDK >= 0.5.114); explicit scope options are no longer forwarded.
private clientOptions() {
const apiKey = this.getApiKey()
const organizationId = process.env.TENSORLAKE_ORGANIZATION_ID
const projectId = process.env.TENSORLAKE_PROJECT_ID
// A project API key carries its own org/project scope. Forwarding env IDs
// alongside it could point requests at a different project than the key
// authorizes, so the key's scope wins and the variables are ignored.
if (apiKey.startsWith(PROJECT_KEY_PREFIX) && (organizationId || projectId)) {
if (!this.warnedProjectKeyScopeOverride) {
this.warnedProjectKeyScopeOverride = true
logger.warn(
'TENSORLAKE_ORGANIZATION_ID/TENSORLAKE_PROJECT_ID are set, but the API key is a project key ' +
`(${PROJECT_KEY_PREFIX}...) that carries its own scope; ignoring the environment variables.`,
)
}
return { apiKey, apiUrl: MANAGEMENT_API }
}
return {
apiKey,
apiUrl: MANAGEMENT_API,
...(organizationId ? { organizationId } : {}),
...(projectId ? { projectId } : {}),
}
return { apiKey: this.getApiKey(), apiUrl: MANAGEMENT_API }
}

private connectSandbox(sandboxId: string): Promise<Sandbox> {
Expand Down Expand Up @@ -167,7 +154,7 @@ export class TensorLakeClient {
})
}

async createSandbox(opts: { image?: string; name?: string; timeoutSecs?: number } = {}): Promise<CreateSandboxResponse> {
async createSandbox(opts: { image?: string; name?: string; timeoutSecs?: number; fileSystems?: FileSystemMount[] } = {}): Promise<CreateSandboxResponse> {
const cpus = parseFloat(process.env.TENSORLAKE_CPUS ?? '2')
const memoryMb = parseInt(process.env.TENSORLAKE_MEMORY_MB ?? '4096', 10)
const ephemeralDiskMb = parseInt(process.env.TENSORLAKE_DISK_MB ?? '10240', 10)
Expand All @@ -181,12 +168,29 @@ export class TensorLakeClient {
diskMb: ephemeralDiskMb,
...(opts.name ? { name: opts.name } : {}),
...(opts.timeoutSecs ? { timeoutSecs: opts.timeoutSecs } : {}),
...(opts.fileSystems?.length ? { fileSystems: opts.fileSystems } : {}),
...this.clientOptions(),
})
this.handles.set(sandbox.sandboxId, { apiKey: this.getApiKey(), promise: Promise.resolve(sandbox), sandbox })
return { sandbox_id: sandbox.sandboxId, status: 'running' }
}

async listSandboxFileSystems(sandboxId: string): Promise<FileSystemMount[]> {
const info = await this.withSandbox(sandboxId, (sandbox) => sandbox.info())
return info.fileSystems ?? []
}

async attachFileSystem(sandboxId: string, fileSystemId: string, mountPath: string): Promise<void> {
// retry: false — a retried attach after a mid-flight failure could double-attach
await this.withSandbox(sandboxId, (sandbox) => sandbox.attachFileSystem(fileSystemId, mountPath), { retry: false })
}

async detachFileSystem(sandboxId: string, mountPath: string): Promise<void> {
// retry: false — a retry after a mid-flight success would fail on the
// already-detached path and mask the real outcome
await this.withSandbox(sandboxId, (sandbox) => sandbox.detachFileSystem(mountPath), { retry: false })
}

async getSandbox(sandboxId: string): Promise<SandboxInfo> {
const info = await this.withSandbox(sandboxId, (sandbox) => sandbox.info())
return { sandbox_id: info.sandboxId, status: info.status as unknown as string }
Expand Down Expand Up @@ -260,7 +264,10 @@ export class TensorLakeClient {
while (Date.now() < deadline) {
const info = await this.getSandbox(sandboxId)
if (info.status === 'running') return
if (info.status === 'terminated') throw new Error(`Sandbox ${sandboxId} was terminated`)
// 'timeout' is terminal like 'terminated' — fail fast instead of polling to the deadline
if (info.status === 'terminated' || info.status === 'timeout') {
throw new Error(`Sandbox ${sandboxId} is ${info.status}`)
}
await new Promise((r) => setTimeout(r, 500))
}
throw new Error(`Sandbox ${sandboxId} did not become running within ${timeoutMs}ms`)
Expand Down Expand Up @@ -289,6 +296,41 @@ export class TensorLakeClient {
}
}

// Processes are started unnamed (non-managed) on purpose: the daemon keeps
// tracking them after exit or kill, so status and output stay queryable by PID.
async startBackgroundProcess(sandboxId: string, command: string, workingDir: string): Promise<number> {
const info = await this.withSandbox(
sandboxId,
(sandbox) =>
sandbox.startProcess('sh', {
args: ['-c', command],
workingDir,
}),
{ retry: false },
)
return info.pid
}

async getProcessStatus(sandboxId: string, pid: number): Promise<ProcessStatusInfo> {
const info = await this.withSandbox(sandboxId, (sandbox) => sandbox.getProcess(pid))
return {
pid: info.pid,
status: info.status as unknown as string,
exitCode: info.exitCode,
signal: info.signal,
command: [info.command, ...(info.args ?? [])].join(' '),
}
}

async getProcessOutput(sandboxId: string, pid: number): Promise<string[]> {
const output = await this.withSandbox(sandboxId, (sandbox) => sandbox.getOutput(pid))
return output.lines
}

async killProcess(sandboxId: string, pid: number): Promise<void> {
await this.withSandbox(sandboxId, (sandbox) => sandbox.killProcess(pid))
}

async readFile(sandboxId: string, path: string): Promise<Buffer> {
const data = await this.withSandbox(sandboxId, (sandbox) => sandbox.readFile(path))
return Buffer.from(data)
Expand Down
7 changes: 4 additions & 3 deletions .opencode/plugin/tensorlake/core/credentials.ts
Original file line number Diff line number Diff line change
Expand Up @@ -61,15 +61,16 @@ export function resolveApiKey(): string | undefined {
/**
* OpenCode's built-in API-key prompt cannot validate the key at login (the
* plugin never sees the masked value), so keys are checked here on first use.
* A non-project key still works when its scope is supplied via env vars, so
* The SDK (>= 0.5.114) accepts only project API keys — ingress derives the
* project scope from the key, and Personal Access Tokens are not supported —
* but the key may still be valid in ways this prefix check cannot see, so
* this is a warning, not a hard failure.
*/
export function projectKeyWarning(apiKey: string): string | undefined {
if (apiKey.startsWith(PROJECT_KEY_PREFIX)) return undefined
if (process.env.TENSORLAKE_ORGANIZATION_ID && process.env.TENSORLAKE_PROJECT_ID) return undefined
return (
`The stored key is not a project API key (${PROJECT_KEY_PREFIX}...). ` +
'Sandbox calls may fail. Re-run `opencode auth login` with a project API key from ' +
'https://cloud.tensorlake.ai (Project → API Keys), or set TENSORLAKE_ORGANIZATION_ID and TENSORLAKE_PROJECT_ID.'
'https://cloud.tensorlake.ai (Project → API Keys).'
)
}
64 changes: 64 additions & 0 deletions .opencode/plugin/tensorlake/core/project-context.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,64 @@
import { createHash } from 'crypto'
import { existsSync, realpathSync } from 'fs'
import { resolve } from 'path'
import type { PluginInput } from '@opencode-ai/plugin'

/** OpenCode's project id for a directory that is not in a version-controlled repo. */
const GLOBAL_PROJECT_ID = 'global'

export type ProjectContext = {
/** Absolute path of the local project on this machine. */
worktree: string
/**
* Identity used for the sync resource name (hosted git repo or cloud volume)
* and the local session store.
*/
projectId: string
}

/**
* Resolve the local project from the plugin input.
*
* OpenCode only fills `project.worktree` for a repository. A plain folder gets
* the shared 'global' project, whose worktree is the literal '/' — which is not
* a project path and must never be synced. In that case the session directory
* (`ctx.directory`) is the project, and the id is derived from it so that two
* different plain folders never share a volume, a hosted repo, or a sandbox.
*/
export function resolveProjectContext(ctx: PluginInput): ProjectContext {
const reported = ctx.project?.worktree ?? ''
const projectId = ctx.project?.id ?? ''
if (isUsableWorktree(reported) && projectId && projectId !== GLOBAL_PROJECT_ID) {
return { worktree: canonical(reported), projectId }
}

const fallback = [ctx.worktree, ctx.directory].find(isUsableWorktree)
if (!fallback) return { worktree: '', projectId: projectId || GLOBAL_PROJECT_ID }

const worktree = canonical(fallback)
return { worktree, projectId: projectId === GLOBAL_PROJECT_ID ? folderProjectId(worktree) : projectId }
}

function isUsableWorktree(path: string | undefined): path is string {
if (!path || path === '/') return false
return existsSync(path)
}

/**
* The path as the filesystem itself spells it. On a case-insensitive volume
* (macOS, Windows) the same folder can be entered as .../gtm/... or .../GTM/...;
* without this each spelling would hash to a different id and get its own
* cloud volume and sandbox.
*/
function canonical(path: string): string {
try {
return realpathSync.native(path)
} catch {
return resolve(path)
}
}

/** Stable per-folder id for a plain (non-repository) directory. */
function folderProjectId(worktree: string): string {
return `folder-${createHash('sha1').update(worktree).digest('hex').slice(0, 16)}`
}
Loading