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).'
)
}
126 changes: 126 additions & 0 deletions .opencode/plugin/tensorlake/core/glob-match.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,126 @@
/**
* Compiles a glob pattern into a RegExp that matches a whole path, using the
* same semantics as OpenCode's built-in glob tool:
*
* * any run of characters except `/`
* ? one character except `/`
* ** any run of characters, `/` included
* [...] a character class; a leading `!` or `^` negates it
* {a,b} alternation, which may nest
*
* `/` separates path segments, so `*.ts` matches only the top level and
* `src/**\/*.ts` is what descends. A `find -name` search cannot express this:
* it matches the basename alone and treats `/` as an ordinary character.
*/
export function globToRegExp(pattern: string): RegExp {
let out = ''
let braces = 0
let i = 0
while (i < pattern.length) {
const c = pattern[i]
if (c === '\\' && i + 1 < pattern.length) {
out += escapeLiteral(pattern[i + 1])
i += 2
continue
}
if (c === '*') {
let j = i
while (pattern[j] === '*') j++
if (j - i >= 2) {
// `**/` also matches zero directories, so `src/**/*.ts` finds src/a.ts.
if (pattern[j] === '/') {
out += '(?:.*/)?'
j++
} else {
out += '.*'
}
} else {
out += '[^/]*'
}
i = j
continue
}
if (c === '?') {
out += '[^/]'
i++
continue
}
if (c === '[') {
const cls = readCharClass(pattern, i)
if (cls) {
out += cls.regex
i = cls.next
} else {
// Unterminated class: the bracket is just a bracket.
out += '\\['
i++
}
continue
}
if (c === '{') {
out += '(?:'
braces++
i++
continue
}
if (c === '}' && braces > 0) {
out += ')'
braces--
i++
continue
}
if (c === ',' && braces > 0) {
out += '|'
i++
continue
}
out += escapeLiteral(c)
i++
}
// Close any brace that was never closed, so the RegExp still compiles.
while (braces-- > 0) out += ')'
return new RegExp(`^${out}$`)
}

function readCharClass(pattern: string, start: number): { regex: string; next: number } | undefined {
let j = start + 1
let negated = false
if (pattern[j] === '!' || pattern[j] === '^') {
negated = true
j++
}
let body = ''
// A `]` in the first position is a literal, not the terminator.
if (pattern[j] === ']') {
body += '\\]'
j++
}
while (j < pattern.length && pattern[j] !== ']') {
const ch = pattern[j]
body += ch === '\\' || ch === '^' || ch === '[' ? `\\${ch}` : ch
j++
}
if (j >= pattern.length || body === '') return undefined
return { regex: `[${negated ? '^' : ''}${body}]`, next: j + 1 }
}

/**
* Splits off the leading path segments that contain no glob metacharacter.
* The search can then start at that subdirectory instead of walking the whole
* project: `src/**\/*.ts` only ever matches files under `src`.
*/
export function literalPrefix(pattern: string): string {
if (pattern.startsWith('/')) return ''
const segments = pattern.split('/')
const literal: string[] = []
// The last segment is the filename part, never a directory to descend into.
for (const segment of segments.slice(0, -1)) {
if (/[*?[\]{}\\]/.test(segment) || segment === '' || segment === '..') break
literal.push(segment)
}
return literal.join('/')
}

function escapeLiteral(ch: string): string {
return /[.*+?^${}()|[\]\\]/.test(ch) ? `\\${ch}` : ch
}
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