Skip to content
Merged
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
19 changes: 19 additions & 0 deletions .changeset/perstack-error-system.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
---
"@perstack/core": patch
"@perstack/installer": patch
"@perstack/log": patch
"@perstack/perstack-toml": patch
"@perstack/runtime": patch
"@perstack/tui": patch
"@perstack/tui-components": patch
"@perstack/anthropic-provider": patch
"@perstack/azure-openai-provider": patch
"@perstack/google-provider": patch
"@perstack/openai-provider": patch
"perstack": patch
"create-expert": patch
---

fix: add PerstackError class for user-facing error handling

Add `PerstackError` to distinguish user-facing errors from internal bugs. User-facing errors (missing config, invalid input, missing env vars) now show a clean message and exit(1), while unexpected errors crash with a stack trace for debugging.
10 changes: 9 additions & 1 deletion apps/create-expert/bin/cli.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
#!/usr/bin/env node

import { readFileSync } from "node:fs"
import { PerstackError } from "@perstack/core"
import { findLockfile, loadLockfile, parsePerstackConfig } from "@perstack/perstack-toml"
import { startHandler } from "@perstack/tui"
import { PROVIDER_ENV_MAP } from "@perstack/tui/provider-config"
Expand Down Expand Up @@ -31,4 +32,11 @@ new Command()
},
})
})
.parse()
.parseAsync()
.catch((error) => {
if (error instanceof PerstackError) {
console.error(error.message)
process.exit(1)
}
throw error
})
1 change: 1 addition & 0 deletions apps/create-expert/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,7 @@
"typecheck": "tsc --noEmit"
},
"dependencies": {
"@perstack/core": "workspace:*",
"@perstack/perstack-toml": "workspace:*",
"@perstack/runtime": "workspace:*",
"commander": "^14.0.3"
Expand Down
9 changes: 8 additions & 1 deletion apps/perstack/bin/cli.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
#!/usr/bin/env node

import { PerstackError } from "@perstack/core"
import { installHandler } from "@perstack/installer"
import { logHandler, parsePositiveInt } from "@perstack/log"
import {
Expand Down Expand Up @@ -154,4 +155,10 @@ program
await installHandler({ configPath, perstackConfig, envPath: options.envPath })
})

program.parse()
program.parseAsync().catch((error) => {
if (error instanceof PerstackError) {
console.error(error.message)
process.exit(1)
}
throw error
})
1 change: 1 addition & 0 deletions apps/perstack/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,7 @@
"commander": "^14.0.3"
},
"devDependencies": {
"@perstack/core": "workspace:*",
"@perstack/installer": "workspace:*",
"@perstack/log": "workspace:*",
"@perstack/perstack-toml": "workspace:*",
Expand Down
6 changes: 6 additions & 0 deletions packages/core/src/errors.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
export class PerstackError extends Error {
constructor(message: string) {
super(message)
this.name = "PerstackError"
}
}
1 change: 1 addition & 0 deletions packages/core/src/index.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
export * from "./adapters/index.js"
export * from "./constants/constants.js"
export * from "./errors.js"
export * from "./known-models/index.js"
export * from "./schemas/activity.js"
export * from "./schemas/checkpoint.js"
Expand Down
5 changes: 3 additions & 2 deletions packages/core/src/schemas/runtime.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ import {
defaultTimeout,
expertKeyRegex,
} from "../constants/constants.js"
import { PerstackError } from "../errors.js"
import type { Checkpoint } from "./checkpoint.js"
import { checkpointSchema } from "./checkpoint.js"
import type { Expert } from "./expert.js"
Expand Down Expand Up @@ -36,11 +37,11 @@ export function parseExpertKey(expertKey: string): {
} {
const match = expertKey.match(expertKeyRegex)
if (!match) {
throw new Error(`Invalid expert key format: ${expertKey}`)
throw new PerstackError(`Invalid expert key format: ${expertKey}`)
}
const [key, name, version, tag] = match
if (!name) {
throw new Error(`Invalid expert key format: ${expertKey}`)
throw new PerstackError(`Invalid expert key format: ${expertKey}`)
}
return { key, name, version, tag }
}
Expand Down
3 changes: 2 additions & 1 deletion packages/core/src/utils/event-filter.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import { PerstackError } from "../errors.js"
import type { PerstackEvent } from "../schemas/runtime.js"
import { isValidEventType, isValidRuntimeEventType } from "../schemas/runtime.js"

Expand All @@ -11,7 +12,7 @@ export function validateEventFilter(filter: string[]): string[] {
const invalid = filter.filter((type) => !isValidEventType(type) && !isValidRuntimeEventType(type))

if (invalid.length > 0) {
throw new Error(
throw new PerstackError(
`Invalid event type(s): ${invalid.join(", ")}. ` +
"Valid event types are: startRun, completeRun, stopRunByError, callTools, etc. " +
"See documentation for full list.",
Expand Down
3 changes: 2 additions & 1 deletion packages/core/src/utils/zod-error.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import type { ZodError, ZodSchema } from "zod"
import { PerstackError } from "../errors.js"

export function formatZodError(error: ZodError): string {
const issues = error.issues.map((issue) => {
Expand All @@ -18,5 +19,5 @@ export function parseWithFriendlyError<T>(
return result.data
}
const prefix = context ? `${context}: ` : ""
throw new Error(`${prefix}${formatZodError(result.error)}`)
throw new PerstackError(`${prefix}${formatZodError(result.error)}`)
}
11 changes: 7 additions & 4 deletions packages/installer/src/expert-resolver.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import {
type Expert,
expertSchema,
type PerstackConfig,
PerstackError,
type RuntimeVersion,
type Skill,
} from "@perstack/core"
Expand Down Expand Up @@ -103,7 +104,7 @@ export function toRuntimeExpert(key: string, expert: PublishedExpertData): Exper
},
]
default: {
throw new Error(`Unknown skill type: ${(skill as { type: string }).type}`)
throw new PerstackError(`Unknown skill type: ${(skill as { type: string }).type}`)
}
}
}),
Expand Down Expand Up @@ -161,7 +162,7 @@ export async function resolveAllExperts(
}
const apiKey = env.PERSTACK_API_KEY
if (!apiKey) {
throw new Error("PERSTACK_API_KEY is required to resolve remote delegates")
throw new PerstackError("PERSTACK_API_KEY is required to resolve remote delegates")
}
const client = createApiClient({
baseUrl: config.perstackApiBaseUrl ?? defaultPerstackApiBaseUrl,
Expand All @@ -174,11 +175,13 @@ export async function resolveAllExperts(
if (experts[delegateKey]) continue
const result = await client.experts.get(delegateKey)
if (!result.ok) {
throw new Error(`Failed to resolve delegate "${delegateKey}": ${result.error.message}`)
throw new PerstackError(
`Failed to resolve delegate "${delegateKey}": ${result.error.message}`,
)
}
const publishedExpert = result.data.data.definition.experts[delegateKey]
if (!publishedExpert) {
throw new Error(`Expert "${delegateKey}" not found in API response`)
throw new PerstackError(`Expert "${delegateKey}" not found in API response`)
}
experts[delegateKey] = toRuntimeExpert(delegateKey, publishedExpert)
for (const nestedDelegate of publishedExpert.delegates ?? []) {
Expand Down
67 changes: 29 additions & 38 deletions packages/installer/src/handler.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,43 +12,34 @@ export async function installHandler(options: {
perstackConfig: PerstackConfig
envPath?: string[]
}): Promise<void> {
try {
const configPath = options.configPath
const config = options.perstackConfig
const envPath =
options.envPath && options.envPath.length > 0
? options.envPath
: (config.envPath ?? [".env", ".env.local"])
const env = getEnv(envPath)
console.log("Resolving experts...")
const experts = await resolveAllExperts(config, env)
console.log(`Found ${Object.keys(experts).length} expert(s)`)
const lockfileExperts: Record<string, LockfileExpert> = {}
for (const [key, expert] of Object.entries(experts)) {
console.log(`Collecting tool definitions for ${key}...`)
const toolDefinitions = await collectToolDefinitionsForExpert(expert, {
env,
perstackBaseSkillCommand: config.perstackBaseSkillCommand,
})
console.log(` Found ${toolDefinitions.length} tool(s)`)
lockfileExperts[key] = expertToLockfileExpert(expert, toolDefinitions)
}
const lockfile: Lockfile = {
version: "1",
generatedAt: Date.now(),
configPath: path.basename(configPath),
experts: lockfileExperts,
}
const lockfilePath = path.join(path.dirname(configPath), "perstack.lock")
const lockfileContent = generateLockfileToml(lockfile)
await writeFile(lockfilePath, lockfileContent, "utf-8")
console.log(`Generated ${lockfilePath}`)
} catch (error) {
if (error instanceof Error) {
console.error(`Error: ${error.message}`)
} else {
console.error(error)
}
process.exit(1)
const configPath = options.configPath
const config = options.perstackConfig
const envPath =
options.envPath && options.envPath.length > 0
? options.envPath
: (config.envPath ?? [".env", ".env.local"])
const env = getEnv(envPath)
console.log("Resolving experts...")
const experts = await resolveAllExperts(config, env)
console.log(`Found ${Object.keys(experts).length} expert(s)`)
const lockfileExperts: Record<string, LockfileExpert> = {}
for (const [key, expert] of Object.entries(experts)) {
console.log(`Collecting tool definitions for ${key}...`)
const toolDefinitions = await collectToolDefinitionsForExpert(expert, {
env,
perstackBaseSkillCommand: config.perstackBaseSkillCommand,
})
console.log(` Found ${toolDefinitions.length} tool(s)`)
lockfileExperts[key] = expertToLockfileExpert(expert, toolDefinitions)
}
const lockfile: Lockfile = {
version: "1",
generatedAt: Date.now(),
configPath: path.basename(configPath),
experts: lockfileExperts,
}
const lockfilePath = path.join(path.dirname(configPath), "perstack.lock")
const lockfileContent = generateLockfileToml(lockfile)
await writeFile(lockfilePath, lockfileContent, "utf-8")
console.log(`Generated ${lockfilePath}`)
}
10 changes: 5 additions & 5 deletions packages/log/src/filter.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import type { RunEvent } from "@perstack/core"
import { PerstackError, type RunEvent } from "@perstack/core"
import type { FilterCondition, FilterOptions, StepFilter } from "./types.js"

export const ERROR_EVENT_TYPES = new Set(["stopRunByError", "retry"])
Expand All @@ -16,7 +16,7 @@ export function parseStepFilter(step: string): StepFilter {
const min = Number(rangeMatch[1])
const max = Number(rangeMatch[2])
if (min > max) {
throw new Error(`Invalid step range: ${step} (min ${min} > max ${max})`)
throw new PerstackError(`Invalid step range: ${step} (min ${min} > max ${max})`)
}
return { type: "range", min, max }
}
Expand All @@ -40,19 +40,19 @@ export function parseStepFilter(step: string): StepFilter {
if (exactMatch) {
return { type: "exact", value: Number(exactMatch[1]) }
}
throw new Error(`Invalid step filter: ${step}`)
throw new PerstackError(`Invalid step filter: ${step}`)
}

export function parseFilterExpression(expression: string): FilterCondition {
const trimmed = expression.trim()
const operatorMatch = trimmed.match(/^\.([a-zA-Z_][\w.[\]]*)\s*(==|!=|>=|<=|>|<)\s*(.+)$/)
if (!operatorMatch) {
throw new Error(`Invalid filter expression: ${expression}`)
throw new PerstackError(`Invalid filter expression: ${expression}`)
}
const [, fieldPath, operator, rawValue] = operatorMatch
const trimmedValue = rawValue.trim()
if (trimmedValue === "") {
throw new Error(`Missing value in filter expression: ${expression}`)
throw new PerstackError(`Missing value in filter expression: ${expression}`)
}
const field = parseFieldPath(fieldPath)
const value = parseValue(trimmedValue)
Expand Down
42 changes: 17 additions & 25 deletions packages/log/src/handler.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import { PerstackError } from "@perstack/core"
import {
applyFilters,
createLogDataFetcher,
Expand All @@ -19,38 +20,29 @@ const DEFAULT_OFFSET = 0
export function parsePositiveInt(val: string, optionName: string): number {
const parsed = parseInt(val, 10)
if (Number.isNaN(parsed)) {
throw new Error(`Invalid value for ${optionName}: "${val}" is not a valid number`)
throw new PerstackError(`Invalid value for ${optionName}: "${val}" is not a valid number`)
}
if (parsed < 0) {
throw new Error(`Invalid value for ${optionName}: "${val}" must be non-negative`)
throw new PerstackError(`Invalid value for ${optionName}: "${val}" must be non-negative`)
}
return parsed
}

export async function logHandler(options: LogCommandOptions): Promise<void> {
try {
const storagePath = process.env.PERSTACK_STORAGE_PATH ?? `${process.cwd()}/perstack`
const adapter = createStorageAdapter(storagePath)
const fetcher = createLogDataFetcher(adapter)
const filterOptions = buildFilterOptions(options)
const formatterOptions = buildFormatterOptions(options)
const output = await buildOutput(fetcher, options, filterOptions, storagePath)
if (!output) {
console.log("No data found")
return
}
const formatted = formatterOptions.json
? formatJson(output, formatterOptions)
: formatTerminal(output, formatterOptions)
console.log(formatted)
} catch (error) {
if (error instanceof Error) {
console.error(`Error: ${error.message}`)
} else {
console.error("An unexpected error occurred")
}
process.exit(1)
}
const storagePath = process.env.PERSTACK_STORAGE_PATH ?? `${process.cwd()}/perstack`
const adapter = createStorageAdapter(storagePath)
const fetcher = createLogDataFetcher(adapter)
const filterOptions = buildFilterOptions(options)
const formatterOptions = buildFormatterOptions(options)
const output = await buildOutput(fetcher, options, filterOptions, storagePath)
if (!output) {
console.log("No data found")
return
}
const formatted = formatterOptions.json
? formatJson(output, formatterOptions)
: formatTerminal(output, formatterOptions)
console.log(formatted)
}

function buildFilterOptions(options: LogCommandOptions): FilterOptions {
Expand Down
Loading