Skip to content
Merged
Show file tree
Hide file tree
Changes from 2 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
14 changes: 14 additions & 0 deletions packages/opencode/src/altimate/telemetry/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -446,6 +446,20 @@ export namespace Telemetry {
duration_ms: number
}
// altimate_change end
// altimate_change start — pre-execution SQL validation telemetry
| {
type: "sql_pre_validation"
timestamp: number
session_id: string
/** skipped = no cache or stale, passed = valid SQL, blocked = invalid SQL caught, error = validation itself failed */
outcome: "skipped" | "passed" | "blocked" | "error"
/** why: no_cache, stale_cache, empty_cache, valid, non_structural, structural_error, validation_exception */
reason: string
schema_columns: number
duration_ms: number
error_message?: string
}
// altimate_change end

// altimate_change start — expanded error classification patterns for better triage
// Order matters: earlier patterns take priority. Use specific phrases, not
Expand Down
51 changes: 49 additions & 2 deletions packages/opencode/src/altimate/tools/altimate-core-validate.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,9 @@ import z from "zod"
import { Tool } from "../../tool/tool"
import { Dispatcher } from "../native"
import type { Telemetry } from "../telemetry"
// altimate_change start — auto-pull schema from cache when not provided
import { getCache } from "../native/schema/cache"
// altimate_change end

export const AltimateCoreValidateTool = Tool.define("altimate_core_validate", {
description:
Expand All @@ -12,11 +15,20 @@ export const AltimateCoreValidateTool = Tool.define("altimate_core_validate", {
schema_context: z.record(z.string(), z.any()).optional().describe("Inline schema definition"),
}),
async execute(args, ctx) {
const hasSchema = !!(args.schema_path || (args.schema_context && Object.keys(args.schema_context).length > 0))
let hasSchema = !!(args.schema_path || (args.schema_context && Object.keys(args.schema_context).length > 0))
// altimate_change start — auto-pull schema from cache when not provided
if (!hasSchema) {
const cachedSchema = await tryGetSchemaFromCache()
if (cachedSchema) {
args = { ...args, schema_context: cachedSchema }
hasSchema = true
}
}
// altimate_change end
const noSchema = !hasSchema
if (noSchema) {
const error =
"No schema provided. Provide schema_context or schema_path so table/column references can be resolved."
"No schema provided. Provide schema_context or schema_path so table/column references can be resolved. Tip: run schema_index first to cache your warehouse schema."
return {
title: "Validate: NO SCHEMA",
metadata: { success: false, valid: false, has_schema: false, error },
Expand Down Expand Up @@ -77,6 +89,41 @@ function classifyValidationError(message: string): string {
return "validation_error"
}

// altimate_change start — auto-pull schema from cache when not provided
const CACHE_TTL_MS = 24 * 60 * 60 * 1000

async function tryGetSchemaFromCache(): Promise<Record<string, any> | null> {
try {
const cache = await getCache()
const status = cache.cacheStatus()
const warehouse = status.warehouses[0]
if (!warehouse?.last_indexed) return null

const cacheAge = Date.now() - new Date(warehouse.last_indexed).getTime()
if (cacheAge > CACHE_TTL_MS) return null

const columns = cache.listColumns(warehouse.name, 10_000)
if (columns.length === 0) return null

const schemaContext: Record<string, any> = {}
for (const col of columns) {
const tableName = col.schema_name ? `${col.schema_name}.${col.table}` : col.table
if (!schemaContext[tableName]) {
schemaContext[tableName] = []
}
schemaContext[tableName].push({
name: col.name,
type: col.data_type || "VARCHAR",
nullable: col.nullable,
})
}
return schemaContext
} catch {
return null
}
}
// altimate_change end

function formatValidate(data: Record<string, any>): string {
if (data.error) return `Error: ${data.error}`
if (data.valid) return "SQL is valid."
Expand Down
144 changes: 144 additions & 0 deletions packages/opencode/src/altimate/tools/sql-execute.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,10 @@ import { classifyAndCheck } from "./sql-classify"
// altimate_change start — progressive disclosure suggestions
import { PostConnectSuggestions } from "./post-connect-suggestions"
// altimate_change end
// altimate_change start — pre-execution SQL validation via cached schema
import { getCache } from "../native/schema/cache"
import { Telemetry } from "../../telemetry"
// altimate_change end

export const SqlExecuteTool = Tool.define("sql_execute", {
description: "Execute SQL against a connected data warehouse. Returns results as a formatted table.",
Expand All @@ -33,6 +37,17 @@ export const SqlExecuteTool = Tool.define("sql_execute", {
}
// altimate_change end

// altimate_change start — pre-execution SQL validation via cached schema
const preValidation = await preValidateSql(args.query, args.warehouse)
if (preValidation.blocked) {
return {
title: "SQL: VALIDATION ERROR",
metadata: { rowCount: 0, truncated: false, error: preValidation.error },
output: preValidation.error!,
}
}
// altimate_change end

try {
const result = await Dispatcher.call("sql.execute", {
sql: args.query,
Expand Down Expand Up @@ -68,6 +83,135 @@ export const SqlExecuteTool = Tool.define("sql_execute", {
},
})

// altimate_change start — pre-execution SQL validation via cached schema
const CACHE_TTL_MS = 24 * 60 * 60 * 1000 // 24 hours

interface PreValidationResult {
blocked: boolean
error?: string
}

async function preValidateSql(sql: string, warehouse?: string): Promise<PreValidationResult> {
const startTime = Date.now()
try {
const cache = await getCache()
const status = cache.cacheStatus()

// Find the target warehouse in cache
const warehouseName = warehouse || status.warehouses[0]?.name
if (!warehouseName) {
trackPreValidation("skipped", "no_cache", 0, Date.now() - startTime)
return { blocked: false }
}

const warehouseStatus = status.warehouses.find((w) => w.name === warehouseName)
if (!warehouseStatus?.last_indexed) {
trackPreValidation("skipped", "no_cache", 0, Date.now() - startTime)
return { blocked: false }
}

// Check cache freshness
const cacheAge = Date.now() - new Date(warehouseStatus.last_indexed).getTime()
if (cacheAge > CACHE_TTL_MS) {
trackPreValidation("skipped", "stale_cache", 0, Date.now() - startTime)
return { blocked: false }
}

// Build schema context from cached columns
const columns = cache.listColumns(warehouseName, 10_000)
if (columns.length === 0) {
trackPreValidation("skipped", "empty_cache", 0, Date.now() - startTime)
return { blocked: false }
}

const schemaContext: Record<string, any> = {}
for (const col of columns) {
const tableName = col.schema_name ? `${col.schema_name}.${col.table}` : col.table
if (!schemaContext[tableName]) {
schemaContext[tableName] = []
}
schemaContext[tableName].push({
name: col.name,
type: col.data_type || "VARCHAR",
nullable: col.nullable,
})
}

// Validate SQL against cached schema
const validationResult = await Dispatcher.call("altimate_core.validate", {
sql,
schema_path: "",
schema_context: schemaContext,
})

const data = (validationResult.data ?? {}) as Record<string, any>
const errors = Array.isArray(data.errors) ? data.errors : []
const isValid = data.valid !== false && errors.length === 0

if (isValid) {
trackPreValidation("passed", "valid", columns.length, Date.now() - startTime)
return { blocked: false }
}

// Only block on high-confidence structural errors
const structuralErrors = errors.filter((e: any) => {
const msg = (e.message ?? "").toLowerCase()
return msg.includes("column") || msg.includes("table") || msg.includes("not found") || msg.includes("does not exist")
})

if (structuralErrors.length === 0) {
// Non-structural errors (ambiguous cases) — let them through
trackPreValidation("passed", "non_structural", columns.length, Date.now() - startTime)
return { blocked: false }
}

// Build helpful error with available columns
const errorMsgs = structuralErrors.map((e: any) => e.message).join("\n")
const referencedTables = Object.keys(schemaContext).slice(0, 10)
const availableColumns = referencedTables
.map((t) => `${t}: ${schemaContext[t].map((c: any) => c.name).join(", ")}`)
.join("\n")

const errorOutput = [
`Pre-execution validation failed (validated against cached schema):`,
``,
errorMsgs,
``,
`Available tables and columns:`,
availableColumns,
``,
`Fix the query and retry. If the schema cache is outdated, run schema_index to refresh it.`,
].join("\n")

trackPreValidation("blocked", "structural_error", columns.length, Date.now() - startTime, errorMsgs)
return { blocked: true, error: errorOutput }
} catch {
// Validation failure should never block execution
trackPreValidation("error", "validation_exception", 0, Date.now() - startTime)
return { blocked: false }
}
}

function trackPreValidation(
outcome: "skipped" | "passed" | "blocked" | "error",
reason: string,
schema_columns: number,
duration_ms: number,
error_message?: string,
) {
Telemetry.track({
type: "sql_pre_validation",
timestamp: Date.now(),
session_id: Telemetry.getContext().sessionId,
outcome,
reason,
schema_columns,
duration_ms,
...(error_message && { error_message: error_message.slice(0, 500) }),
})
}
// altimate_change end

function formatResult(result: SqlExecuteResult): string {
if (result.row_count === 0) return "(0 rows)"

Expand Down
39 changes: 38 additions & 1 deletion packages/opencode/src/altimate/tools/warehouse-test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -20,10 +20,14 @@ export const WarehouseTestTool = Tool.define("warehouse_test", {
}
}

// altimate_change start — actionable error guidance for common auth failures
const errorDetail = result.error ?? "Unknown error"
const guidance = getConnectionGuidance(errorDetail)
// altimate_change end
return {
title: `Connection '${args.name}': FAILED`,
metadata: { connected: false },
output: `Failed to connect to warehouse '${args.name}'.\nError: ${result.error ?? "Unknown error"}`,
output: `Failed to connect to warehouse '${args.name}'.\nError: ${errorDetail}${guidance}`,
}
} catch (e) {
const msg = e instanceof Error ? e.message : String(e)
Expand All @@ -35,3 +39,36 @@ export const WarehouseTestTool = Tool.define("warehouse_test", {
}
},
})

// altimate_change start — actionable error guidance for common auth failures
function getConnectionGuidance(error: string): string {
const lower = error.toLowerCase()

if (lower.includes("password") && (lower.includes("incorrect") || lower.includes("authentication failed"))) {
return "\n\nHow to fix: Check the password in your connection config. Verify the username has access from your current IP address. Use `warehouse_remove` then `warehouse_add` to re-enter credentials."
}
if (lower.includes("password must be a string") || lower.includes("scram")) {
return "\n\nHow to fix: The password field is missing or not a string. Check your connection config — the password may be empty or set to a non-string value. Use `warehouse_remove` then `warehouse_add` to re-configure."
}
if (lower.includes("private key") || lower.includes("decrypt")) {
return "\n\nHow to fix: Key pair authentication failed. Verify: (1) the key file is PEM/PKCS#8 format, (2) the passphrase is correct, (3) the key has not expired, (4) the public key is registered in your warehouse."
}
if (lower.includes("missing") && lower.includes("password")) {
return "\n\nHow to fix: No password was provided. Use `warehouse_remove` then `warehouse_add` to configure credentials. For Snowflake, you can also use key pair or SSO authentication."
}
if (lower.includes("browser") && lower.includes("timed out")) {
return "\n\nHow to fix: SSO browser authentication timed out. Ensure your default browser opened the auth page. If running in a headless environment, switch to password or key pair authentication instead."
}
if (lower.includes("not installed") || lower.includes("cannot find module")) {
return "\n\nHow to fix: The database driver is not installed. Run `npm install` with the appropriate driver package for your database type."
}
if (lower.includes("econnrefused") || lower.includes("enotfound")) {
return "\n\nHow to fix: Cannot reach the database server. Check: (1) the hostname and port are correct, (2) the server is running, (3) any firewalls or VPNs are configured to allow the connection."
}
if (lower.includes("schema") && (lower.includes("does not exist") || lower.includes("not authorized"))) {
return "\n\nHow to fix: The specified schema does not exist or your user lacks access. Check: (1) the schema name is spelled correctly, (2) your user/role has USAGE privilege on the schema."
}

return ""
}
// altimate_change end
2 changes: 1 addition & 1 deletion packages/opencode/src/cli/cmd/pr.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@ import { git } from "@/util/git"

export const PrCommand = cmd({
command: "pr <number>",
describe: "fetch and checkout a GitHub PR branch, then run opencode",
describe: "fetch and checkout a GitHub PR branch, then run altimate-code",
builder: (yargs) =>
yargs.positional("number", {
type: "number",
Expand Down
4 changes: 2 additions & 2 deletions packages/opencode/src/cli/cmd/tui/thread.ts
Original file line number Diff line number Diff line change
Expand Up @@ -64,12 +64,12 @@ async function input(value?: string) {

export const TuiThreadCommand = cmd({
command: "$0 [project]",
describe: "start opencode tui",
describe: "start altimate-code tui",
builder: (yargs) =>
withNetworkOptions(yargs)
.positional("project", {
type: "string",
describe: "path to start opencode in",
describe: "path to start altimate-code in",
})
.option("model", {
type: "string",
Expand Down
2 changes: 1 addition & 1 deletion packages/opencode/src/cli/cmd/uninstall.ts
Original file line number Diff line number Diff line change
Expand Up @@ -24,7 +24,7 @@ interface RemovalTargets {

export const UninstallCommand = {
command: "uninstall",
describe: "uninstall opencode and remove all related files",
describe: "uninstall altimate-code and remove all related files",
builder: (yargs: Argv) =>
yargs
.option("keep-config", {
Expand Down
2 changes: 1 addition & 1 deletion packages/opencode/src/config/config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1070,7 +1070,7 @@ export namespace Config {
.object({
$schema: z.string().optional().describe("JSON schema reference for configuration validation"),
logLevel: Log.Level.optional().describe("Log level"),
server: Server.optional().describe("Server configuration for opencode serve and web commands"),
server: Server.optional().describe("Server configuration for altimate-code serve and web commands"),
command: z
.record(z.string(), Command)
.optional()
Expand Down
8 changes: 4 additions & 4 deletions packages/opencode/src/server/server.ts
Original file line number Diff line number Diff line change
Expand Up @@ -223,9 +223,9 @@ export namespace Server {
openAPIRouteHandler(app, {
documentation: {
info: {
title: "opencode",
title: "altimate-code",
version: "0.0.3",
description: "opencode api",
description: "altimate-code api",
},
openapi: "3.1.1",
},
Expand Down Expand Up @@ -583,9 +583,9 @@ export namespace Server {
const result = await generateSpecs(Default(), {
documentation: {
info: {
title: "opencode",
title: "altimate-code",
version: "1.0.0",
description: "opencode api",
description: "altimate-code api",
},
openapi: "3.1.1",
},
Expand Down
Loading
Loading