diff --git a/.changeset/remove-runtime-cli.md b/.changeset/remove-runtime-cli.md new file mode 100644 index 00000000..5c4e02a2 --- /dev/null +++ b/.changeset/remove-runtime-cli.md @@ -0,0 +1,6 @@ +--- +"@perstack/runtime": patch +"@perstack/tui": patch +--- + +Remove runtime CLI, move runtime to packages/, separate lockfile I/O diff --git a/apps/runtime/README.md b/apps/runtime/README.md deleted file mode 100644 index 716bd077..00000000 --- a/apps/runtime/README.md +++ /dev/null @@ -1,308 +0,0 @@ -# @perstack/runtime - -The **Execution Engine** for Perstack agents. - -This package serves as the engine of Perstack. It orchestrates the lifecycle of an agent's execution, manages state, bridges the gap between LLMs and tools, and handles multi-agent coordination. - -## Installation - -```bash -npm install @perstack/runtime -``` - -## CLI Usage - -The runtime can be executed as a standalone CLI: - -```bash -perstack-runtime run [options] -``` - -### Options - -| Option | Description | -| ----------------------- | ----------------------- | -| `--config ` | Path to perstack.toml | -| `--provider ` | LLM provider | -| `--model ` | Model name | -| `--max-steps ` | Maximum steps | -| `--max-retries ` | Maximum retries | -| `--timeout ` | Timeout in milliseconds | -| `--job-id ` | Job ID | -| `--run-id ` | Run ID | -| `--env-path ` | Environment file paths | - -### Example - -```bash -perstack-runtime run my-expert "What is the weather?" --config ./perstack.toml -``` - -Output is JSON events (one per line) to stdout. - -## Programmatic Usage - -The primary entry point is the `run` function. It takes a `RunSetting` object and an optional `RunOptions` object. - -```typescript -import { run } from "@perstack/runtime" -import { type RunSetting } from "@perstack/core" - -// Configure the run -const setting: RunSetting = { - model: "claude-sonnet-4-20250514", - providerConfig: { providerName: "anthropic", apiKey: "..." }, - jobId: "job-123", - runId: "run-123", - expertKey: "researcher", - input: { text: "Research quantum computing" }, - experts: { /* ... */ }, - // ... other configuration -} - -// Execute the job -const finalCheckpoint = await run({ setting }, { - eventListener: (event) => { - console.log(`[${event.type}]`, event) - } -}) -``` - -### Event Object - -The `eventListener` callback receives a `RunEvent` object, which provides granular details about the execution. - -```typescript -type RunEvent = { - type: EventType // e.g., "startRun", "callTools" - id: string // Unique event ID - timestamp: number // Unix timestamp - jobId: string // ID of the Job - runId: string // ID of the current Run - stepNumber: number // Current step number within this Run - // ... plus payload specific to the event type -} -``` - -You can narrow down the event type to access specific properties: - -```typescript -eventListener: (event) => { - if (event.type === "callTools") { - // event is now narrowed to the callTools event type - console.log(`Executing ${event.toolCalls.length} tools`) - } -} -``` - -## Package Responsibilities - -1. **Expert Realization**: The engine that brings declaratively defined Experts to life, realizing the desired state described by the developer. -2. **Lifecycle**: Drives the main execution loop of the agent (Reasoning -> Act -> Observe, repeat). -3. **State Management**: Maintains the canonical state of an execution in the form of **Checkpoints**, enabling pause/resume and time-travel. -4. **Skill Provider**: Provides the client side of the **Model Context Protocol (MCP)** to securely execute tools. -5. **Expert Delegation**: Implements the protocol for **Expert-to-Expert delegation**, allowing agents to call each other. - -## Skill Manager - -The runtime manages skills through specialized Skill Managers. Each skill type has its own manager class: - -| Type | Manager | Purpose | -| --------------- | ------------------------- | ----------------------------------- | -| MCP (stdio/SSE) | `McpSkillManager` | External tools via MCP protocol | -| Interactive | `InteractiveSkillManager` | User input tools (Coordinator only) | -| Delegate | `DelegateSkillManager` | Expert-to-Expert calls | - -All managers extend `BaseSkillManager` which provides: -- `init()` — Initialize the skill (connect MCP servers, parse definitions) -- `close()` — Clean up resources (disconnect MCP servers) -- `getToolDefinitions()` — Get available tools -- `callTool()` — Execute a tool call - -**Note:** Interactive skills are only available to the Coordinator Expert. See [Experts documentation](https://github.com/perstack-ai/perstack/blob/main/docs/understanding-perstack/experts.md#why-no-interactive-tools-for-delegates) for details. - -### Initialization Flow - -``` -getSkillManagers(expert, experts, setting) - │ - ├─► Initialize MCP skills (parallel) - │ └─► McpSkillManager × N - │ - ├─► Initialize Interactive skills (Coordinator only) - │ └─► InteractiveSkillManager × N - │ - └─► Initialize Delegate skills (parallel) - └─► DelegateSkillManager × N - -Result: Record -``` - -If any skill fails to initialize, all previously initialized skills are cleaned up before throwing. - -## Architecture - -The runtime orchestrates the interaction between the user's definition of an Expert and the actual execution environment. - -```mermaid -graph TD - Author((Author)) -->|Defines| Def[Expert Definition] - User((User)) -->|Provides| Input[Input / Query] - - subgraph Runtime [Runtime Engine] - subgraph Job [Job] - subgraph Run1 [Run: Coordinator] - State[State Machine] - Context[Execution Context] - - subgraph Skills [Skill Layer] - SM[Skill Manager] - MCP[MCP Client] - MCPServer[MCP Server] - end - end - - Run2["Run: Delegate A"] - Run3["Run: Delegate B"] - end - end - - subgraph External [External World] - LLM[LLM Provider] - Workspace[Workspace / FS] - end - - Def -->|Instantiates| Run1 - Input -->|Starts| Run1 - - State -->|Reasoning| LLM - State -->|Act| SM - SM -->|Execute| MCP - MCP -->|Connect| MCPServer - MCPServer -->|Access| Workspace - - SM -.->|Delegate| Run2 - SM -.->|Delegate| Run3 -``` - -## Core Concepts - -### Execution Hierarchy - -``` -Job (jobId) - ├── Run 1 (Coordinator Expert) - │ └── Checkpoints... - ├── Run 2 (Delegated Expert A) - │ └── Checkpoints... - └── Run 3 (Delegated Expert B) - └── Checkpoints... -``` - -| Concept | Description | -| -------------- | -------------------------------------------- | -| **Job** | Top-level execution unit. Contains all Runs. | -| **Run** | Single Expert execution. | -| **Checkpoint** | Snapshot at step end. Enables pause/resume. | - -For details on step counting, Coordinator vs. Delegated Expert differences, and the full execution model, see [Runtime](https://github.com/perstack-ai/perstack/blob/main/docs/understanding-perstack/runtime.md). - -### Events, Steps, Checkpoints - -The runtime's execution model can be visualized as a timeline where **Events** are points, **Steps** are the lines connecting them, and **Checkpoints** are the anchors. - -```mermaid -graph LR - subgraph Step1 [Step 1: The Process] - direction LR - E1(Event: Start) --> E2(Event: Reasoning) --> E3(Event: Act) --> CP1((Checkpoint 1)) - end - - subgraph Step2 [Step 2: The Process] - direction LR - CP1 --> E4(Event: Start) --> E5(Event: Reasoning) --> CP2((Checkpoint 2)) - end - - style CP1 fill:#f96,stroke:#333,stroke-width:4px - style CP2 fill:#f96,stroke:#333,stroke-width:4px -``` - -#### 1. Events -**Events** are granular moments in time that occur *during* execution. They represent specific actions or observations, such as "started reasoning", "called tool", or "finished tool". - -#### 2. Step -A **Step** is the continuous process that connects these events. It represents one atomic cycle of the agent's loop (Reasoning -> Act -> Observe, repeat). - -#### 3. Checkpoint -A **Checkpoint** is the immutable result at the end of a Step. It serves as the anchor point that: -- Finalizes the previous Step. -- Becomes the starting point for the next Step. -- Allows the execution to be paused, resumed, or forked from that exact moment. - -## Internal State Machine - -The runtime ensures deterministic execution through a strictly defined state machine. - -```mermaid -stateDiagram-v2 - [*] --> Init - Init --> PreparingForStep: startRun - Init --> ResumingFromStop: resumeFromStop - - PreparingForStep --> GeneratingToolCall: startGeneration - - ResumingFromStop --> CallingInteractiveTools: proceedToInteractiveTools - ResumingFromStop --> ResolvingToolResult: resolveToolResults - - GeneratingToolCall --> CallingMcpTools: callTools - GeneratingToolCall --> FinishingStep: retry - GeneratingToolCall --> Stopped: stopRunByError - GeneratingToolCall --> Stopped: completeRun - - CallingMcpTools --> ResolvingToolResult: resolveToolResults - CallingMcpTools --> GeneratingRunResult: attemptCompletion - CallingMcpTools --> CallingDelegates: finishMcpTools - CallingMcpTools --> Stopped: completeRun - - CallingDelegates --> Stopped: stopRunByDelegate - CallingDelegates --> CallingInteractiveTools: skipDelegates - - CallingInteractiveTools --> Stopped: stopRunByInteractiveTool - CallingInteractiveTools --> ResolvingToolResult: resolveToolResults - - ResolvingToolResult --> FinishingStep: finishToolCall - - GeneratingRunResult --> Stopped: completeRun - GeneratingRunResult --> FinishingStep: retry - GeneratingRunResult --> Stopped: stopRunByError - - FinishingStep --> PreparingForStep: continueToNextStep - FinishingStep --> Stopped: stopRunByExceededMaxSteps -``` - -### Events -Events trigger state transitions. They are emitted by the runtime logic or external inputs. - -- **Lifecycle**: `startRun`, `resumeFromStop`, `startGeneration`, `continueToNextStep`, `completeRun` -- **Tool Execution**: `callTools`, `resolveToolResults`, `finishToolCall`, `finishMcpTools`, `attemptCompletion` -- **Delegation**: `skipDelegates` (internal state transition) -- **Interactive**: `proceedToInteractiveTools` (for resuming to interactive tools) -- **Interruption**: `stopRunByInteractiveTool`, `stopRunByDelegate`, `stopRunByExceededMaxSteps`, `stopRunByError` -- **Error Handling**: `retry` - -## Checkpoint Status - -The `status` field in a Checkpoint indicates the current state: - -- `init`, `proceeding` — Run lifecycle -- `completed` — Task finished successfully -- `stoppedByInteractiveTool`, `stoppedByDelegate` — Waiting for external input -- `stoppedByExceededMaxSteps`, `stoppedByError`, `stoppedByCancellation` — Run stopped - -For stop reasons and error handling, see [Error Handling](https://github.com/perstack-ai/perstack/blob/main/docs/using-experts/error-handling.md). - -## Related Documentation - -- [Runtime](https://github.com/perstack-ai/perstack/blob/main/docs/understanding-perstack/runtime.md) — Full execution model -- [State Management](https://github.com/perstack-ai/perstack/blob/main/docs/using-experts/state-management.md) — Jobs, Runs, and Checkpoints -- [Running Experts](https://github.com/perstack-ai/perstack/blob/main/docs/using-experts/running-experts.md) — CLI usage diff --git a/apps/runtime/bin/cli.ts b/apps/runtime/bin/cli.ts deleted file mode 100755 index 8c88330f..00000000 --- a/apps/runtime/bin/cli.ts +++ /dev/null @@ -1,132 +0,0 @@ -#!/usr/bin/env node - -import type { Checkpoint, RunEvent, RuntimeEvent } from "@perstack/core" -import { - createFilteredEventListener, - parseWithFriendlyError, - runCommandInputSchema, - validateEventFilter, -} from "@perstack/core" -import { Command } from "commander" -import pkg from "../package.json" with { type: "json" } -import { resolveRunContext } from "../src/cli/context.js" -import { findLockfile, loadLockfile } from "../src/helpers/lockfile.js" -import { run } from "../src/run.js" - -const defaultEventListener = (event: RunEvent | RuntimeEvent) => console.log(JSON.stringify(event)) - -const checkpointStore = new Map() -const storeCheckpoint = async (checkpoint: Checkpoint) => { - checkpointStore.set(checkpoint.id, checkpoint) -} -const retrieveCheckpoint = async (_jobId: string, checkpointId: string) => { - const checkpoint = checkpointStore.get(checkpointId) - if (!checkpoint) { - throw new Error(`Checkpoint not found: ${checkpointId}`) - } - return checkpoint -} - -const program = new Command() - .name("perstack-runtime") - .description("Perstack Runtime CLI - Execute Experts directly") - .version(pkg.version) - -program - .command("run") - .description("Run an Expert with JSON event output") - .argument("", "Expert key to run") - .argument("", "Query to run") - .option("--config ", "Path to perstack.toml config file") - .option("--provider ", "Provider to use") - .option("--model ", "Model to use") - .option( - "--reasoning-budget ", - "Reasoning budget for native LLM reasoning (minimal, low, medium, high, or token count)", - ) - .option( - "--max-steps ", - "Maximum number of steps to run, default is undefined (no limit)", - ) - .option("--max-retries ", "Maximum number of generation retries, default is 5") - .option( - "--timeout ", - "Timeout for each generation in milliseconds, default is 60000 (1 minute)", - ) - .option("--job-id ", "Job ID for identifying the job") - .option("--run-id ", "Run ID for identifying the run") - .option( - "--env-path ", - "Path to the environment file (can be specified multiple times), default is .env and .env.local", - (value: string, previous: string[]) => previous.concat(value), - [] as string[], - ) - .option("--verbose", "Enable verbose logging") - .option( - "--filter ", - "Filter events by type (comma-separated, e.g., completeRun,stopRunByError)", - ) - .action(async (expertKey, query, options) => { - const input = parseWithFriendlyError(runCommandInputSchema, { expertKey, query, options }) - - // Validate and apply event filter if specified - let eventListener = defaultEventListener - if (input.options.filter && input.options.filter.length > 0) { - try { - const validatedTypes = validateEventFilter(input.options.filter) - const allowedTypes = new Set(validatedTypes) - eventListener = createFilteredEventListener(defaultEventListener, allowedTypes) - } catch (error) { - if (error instanceof Error) { - console.error(error.message) - } else { - console.error(error) - } - process.exit(1) - } - } - - try { - const { perstackConfig, env, providerConfig, model, experts } = await resolveRunContext({ - configPath: input.options.config, - provider: input.options.provider, - model: input.options.model, - envPath: input.options.envPath, - }) - const lockfilePath = findLockfile(input.options.config) - const lockfile = lockfilePath ? (loadLockfile(lockfilePath) ?? undefined) : undefined - await run( - { - setting: { - jobId: input.options.jobId, - runId: input.options.runId, - expertKey: input.expertKey, - input: { text: input.query }, - experts, - model, - providerConfig, - reasoningBudget: input.options.reasoningBudget ?? perstackConfig.reasoningBudget, - maxSteps: input.options.maxSteps ?? perstackConfig.maxSteps, - maxRetries: input.options.maxRetries ?? perstackConfig.maxRetries, - timeout: input.options.timeout ?? perstackConfig.timeout, - perstackApiBaseUrl: perstackConfig.perstackApiBaseUrl, - perstackApiKey: env.PERSTACK_API_KEY, - perstackBaseSkillCommand: perstackConfig.perstackBaseSkillCommand, - env, - proxyUrl: process.env.PERSTACK_PROXY_URL, - verbose: input.options.verbose, - }, - }, - { eventListener, storeCheckpoint, retrieveCheckpoint, lockfile }, - ) - } catch (error) { - if (error instanceof Error) { - console.error(error.message) - } else { - console.error(error) - } - process.exit(1) - } - }) - -program.parse() diff --git a/apps/runtime/src/cli/context.ts b/apps/runtime/src/cli/context.ts deleted file mode 100644 index db9b348a..00000000 --- a/apps/runtime/src/cli/context.ts +++ /dev/null @@ -1,67 +0,0 @@ -import type { PerstackConfig, ProviderConfig, ProviderName } from "@perstack/core" -import { getEnv } from "./get-env.js" -import { getPerstackConfig } from "./perstack-toml.js" -import { getProviderConfig } from "./provider-config.js" - -const defaultProvider: ProviderName = "anthropic" -const defaultModel = "claude-sonnet-4-5" - -export type ExpertConfig = NonNullable[string] - -export type RunContext = { - perstackConfig: PerstackConfig - env: Record - providerConfig: ProviderConfig - model: string - experts: Record -} - -export type ResolveRunContextInput = { - configPath?: string - provider?: string - model?: string - envPath?: string[] -} - -export async function resolveRunContext(input: ResolveRunContextInput): Promise { - const perstackConfig = await getPerstackConfig(input.configPath) - const envPath = - input.envPath && input.envPath.length > 0 - ? input.envPath - : (perstackConfig.envPath ?? [".env", ".env.local"]) - const env = getEnv(envPath) - const provider = (input.provider ?? - perstackConfig.provider?.providerName ?? - defaultProvider) as ProviderName - const model = input.model ?? perstackConfig.model ?? defaultModel - const providerConfig = getProviderConfig(provider, env, perstackConfig.provider) - const experts = Object.fromEntries( - Object.entries(perstackConfig.experts ?? {}).map(([name, expert]) => { - return [ - name, - { - key: name, - name, - version: expert.version ?? "1.0.0", - description: expert.description, - instruction: expert.instruction, - // Don't default to {} - let expertSchema's default apply @perstack/base - skills: expert.skills, - delegates: expert.delegates ?? [], - tags: expert.tags ?? [], - minRuntimeVersion: expert.minRuntimeVersion, - }, - ] - }), - ) - return { - perstackConfig, - env, - providerConfig, - model, - experts: experts as Record< - string, - ExpertConfig & { key: string; name: string; version: string } - >, - } -} diff --git a/apps/runtime/src/cli/get-env.ts b/apps/runtime/src/cli/get-env.ts deleted file mode 100644 index 215d61a1..00000000 --- a/apps/runtime/src/cli/get-env.ts +++ /dev/null @@ -1,11 +0,0 @@ -import dotenv from "dotenv" - -export function getEnv(envPath: string[]): Record { - const env: Record = Object.fromEntries( - Object.entries(process.env) - .filter(([_, value]) => !!value) - .map(([key, value]) => [key, value as string]), - ) - dotenv.config({ path: envPath, processEnv: env, quiet: true }) - return env -} diff --git a/apps/runtime/src/cli/perstack-toml.test.ts b/apps/runtime/src/cli/perstack-toml.test.ts deleted file mode 100644 index 85073db9..00000000 --- a/apps/runtime/src/cli/perstack-toml.test.ts +++ /dev/null @@ -1,144 +0,0 @@ -import { vol } from "memfs" -import { afterEach, beforeEach, describe, expect, it, vi } from "vitest" -import { getPerstackConfig } from "./perstack-toml.js" - -vi.mock("node:fs/promises", async () => { - const memfs = await import("memfs") - return memfs.fs.promises -}) - -describe("@perstack/runtime: getPerstackConfig", () => { - beforeEach(() => { - vol.reset() - vi.spyOn(process, "cwd").mockReturnValue("/test") - }) - - afterEach(() => { - vi.restoreAllMocks() - }) - - it("parses valid perstack.toml from specified path", async () => { - const tomlContent = ` -[experts.test] -description = "Test expert" -instruction = "Do testing" -` - vol.fromJSON({ - "/test/custom.toml": tomlContent, - }) - - const config = await getPerstackConfig("custom.toml") - - expect(config.experts?.test).toBeDefined() - expect(config.experts?.test?.description).toBe("Test expert") - expect(config.experts?.test?.instruction).toBe("Do testing") - }) - - it("finds perstack.toml in current directory", async () => { - const tomlContent = ` -[experts.main] -description = "Main expert" -instruction = "Main instruction" -` - vol.fromJSON({ - "/test/perstack.toml": tomlContent, - }) - - const config = await getPerstackConfig() - - expect(config.experts?.main).toBeDefined() - }) - - it("throws when config path not found", async () => { - vol.fromJSON({}) - - await expect(getPerstackConfig("nonexistent.toml")).rejects.toThrow( - 'Given config path "nonexistent.toml" is not found', - ) - }) - - it("throws when perstack.toml not found anywhere", async () => { - vol.fromJSON({}) - vi.spyOn(process, "cwd").mockReturnValue("/") - - await expect(getPerstackConfig()).rejects.toThrow( - "perstack.toml not found. Create one or specify --config path.", - ) - }) - - it("parses skills configuration", async () => { - const tomlContent = ` -[experts.test] -description = "Test" -instruction = "Test" - -[experts.test.skills.base] -type = "mcpStdioSkill" -command = "npx" -packageName = "@perstack/base" -` - vol.fromJSON({ - "/test/perstack.toml": tomlContent, - }) - - const config = await getPerstackConfig() - - expect(config.experts?.test?.skills?.base).toBeDefined() - expect(config.experts?.test?.skills?.base?.type).toBe("mcpStdioSkill") - }) - - it("parses envPath configuration", async () => { - const tomlContent = ` -envPath = [".env.custom"] - -[experts.test] -description = "Test" -instruction = "Test" -` - vol.fromJSON({ - "/test/perstack.toml": tomlContent, - }) - - const config = await getPerstackConfig() - - expect(config.envPath).toEqual([".env.custom"]) - }) - - it("parses provider configuration", async () => { - const tomlContent = ` -[provider] -providerName = "openai" - -[provider.setting] -baseUrl = "https://custom.openai.com" - -[experts.test] -description = "Test" -instruction = "Test" -` - vol.fromJSON({ - "/test/perstack.toml": tomlContent, - }) - - const config = await getPerstackConfig() - - expect(config.provider?.providerName).toBe("openai") - expect((config.provider?.setting as { baseUrl?: string })?.baseUrl).toBe( - "https://custom.openai.com", - ) - }) - - describe("remote config", () => { - it("rejects non-HTTPS URLs", async () => { - await expect(getPerstackConfig("http://example.com/config.toml")).rejects.toThrow( - "Remote config requires HTTPS", - ) - }) - - it("rejects disallowed hosts", async () => { - await expect(getPerstackConfig("https://example.com/config.toml")).rejects.toThrow( - "Remote config only allowed from:", - ) - }) - }) -}) diff --git a/apps/runtime/src/cli/perstack-toml.ts b/apps/runtime/src/cli/perstack-toml.ts deleted file mode 100644 index 0278bb30..00000000 --- a/apps/runtime/src/cli/perstack-toml.ts +++ /dev/null @@ -1,76 +0,0 @@ -import { readFile } from "node:fs/promises" -import path from "node:path" -import { type PerstackConfig, parseWithFriendlyError, perstackConfigSchema } from "@perstack/core" -import TOML from "smol-toml" - -const ALLOWED_CONFIG_HOSTS = ["raw.githubusercontent.com"] - -export async function getPerstackConfig(configPath?: string): Promise { - const configString = await findPerstackConfigString(configPath) - if (configString === null) { - throw new Error("perstack.toml not found. Create one or specify --config path.") - } - return await parsePerstackConfig(configString) -} - -function isRemoteUrl(configPath: string): boolean { - const lower = configPath.toLowerCase() - return lower.startsWith("https://") || lower.startsWith("http://") -} - -async function fetchRemoteConfig(url: string): Promise { - let parsed: URL - try { - parsed = new URL(url) - } catch { - throw new Error(`Invalid remote config URL: ${url}`) - } - if (parsed.protocol !== "https:") { - throw new Error("Remote config requires HTTPS") - } - if (!ALLOWED_CONFIG_HOSTS.includes(parsed.hostname)) { - throw new Error(`Remote config only allowed from: ${ALLOWED_CONFIG_HOSTS.join(", ")}`) - } - try { - const response = await fetch(url, { redirect: "error" }) - if (!response.ok) { - throw new Error(`${response.status} ${response.statusText}`) - } - return await response.text() - } catch (error) { - const message = error instanceof Error ? error.message : String(error) - throw new Error(`Failed to fetch remote config: ${message}`) - } -} - -async function findPerstackConfigString(configPath?: string): Promise { - if (configPath) { - if (isRemoteUrl(configPath)) { - return await fetchRemoteConfig(configPath) - } - try { - const tomlString = await readFile(path.resolve(process.cwd(), configPath), "utf-8") - return tomlString - } catch { - throw new Error(`Given config path "${configPath}" is not found`) - } - } - return await findPerstackConfigStringRecursively(path.resolve(process.cwd())) -} - -async function findPerstackConfigStringRecursively(cwd: string): Promise { - try { - const tomlString = await readFile(path.resolve(cwd, "perstack.toml"), "utf-8") - return tomlString - } catch { - if (cwd === path.parse(cwd).root) { - return null - } - return await findPerstackConfigStringRecursively(path.dirname(cwd)) - } -} - -async function parsePerstackConfig(config: string): Promise { - const toml = TOML.parse(config ?? "") - return parseWithFriendlyError(perstackConfigSchema, toml, "perstack.toml") -} diff --git a/apps/runtime/src/cli/provider-config.ts b/apps/runtime/src/cli/provider-config.ts deleted file mode 100644 index b5e06937..00000000 --- a/apps/runtime/src/cli/provider-config.ts +++ /dev/null @@ -1,104 +0,0 @@ -import type { ProviderConfig, ProviderName, ProviderTable } from "@perstack/core" - -type SettingRecord = Record - -export function getProviderConfig( - provider: ProviderName, - env: Record, - providerTable?: ProviderTable, -): ProviderConfig { - const setting = (providerTable?.setting ?? {}) as SettingRecord - switch (provider) { - case "anthropic": { - const apiKey = env.ANTHROPIC_API_KEY - if (!apiKey) throw new Error("ANTHROPIC_API_KEY is not set") - return { - providerName: "anthropic", - apiKey, - baseUrl: (setting.baseUrl as string | undefined) ?? env.ANTHROPIC_BASE_URL, - headers: setting.headers as Record | undefined, - } - } - case "google": { - const apiKey = env.GOOGLE_GENERATIVE_AI_API_KEY - if (!apiKey) throw new Error("GOOGLE_GENERATIVE_AI_API_KEY is not set") - return { - providerName: "google", - apiKey, - baseUrl: (setting.baseUrl as string | undefined) ?? env.GOOGLE_GENERATIVE_AI_BASE_URL, - headers: setting.headers as Record | undefined, - } - } - case "openai": { - const apiKey = env.OPENAI_API_KEY - if (!apiKey) throw new Error("OPENAI_API_KEY is not set") - return { - providerName: "openai", - apiKey, - baseUrl: (setting.baseUrl as string | undefined) ?? env.OPENAI_BASE_URL, - organization: (setting.organization as string | undefined) ?? env.OPENAI_ORGANIZATION, - project: (setting.project as string | undefined) ?? env.OPENAI_PROJECT, - name: setting.name as string | undefined, - headers: setting.headers as Record | undefined, - } - } - case "ollama": { - return { - providerName: "ollama", - baseUrl: (setting.baseUrl as string | undefined) ?? env.OLLAMA_BASE_URL, - headers: setting.headers as Record | undefined, - } - } - case "azure-openai": { - const apiKey = env.AZURE_API_KEY - if (!apiKey) throw new Error("AZURE_API_KEY is not set") - const resourceName = (setting.resourceName as string | undefined) ?? env.AZURE_RESOURCE_NAME - const baseUrl = (setting.baseUrl as string | undefined) ?? env.AZURE_BASE_URL - if (!resourceName && !baseUrl) throw new Error("AZURE_RESOURCE_NAME or baseUrl is not set") - return { - providerName: "azure-openai", - apiKey, - resourceName, - apiVersion: (setting.apiVersion as string | undefined) ?? env.AZURE_API_VERSION, - baseUrl, - headers: setting.headers as Record | undefined, - useDeploymentBasedUrls: setting.useDeploymentBasedUrls as boolean | undefined, - } - } - case "amazon-bedrock": { - const accessKeyId = env.AWS_ACCESS_KEY_ID - const secretAccessKey = env.AWS_SECRET_ACCESS_KEY - const sessionToken = env.AWS_SESSION_TOKEN - if (!accessKeyId) throw new Error("AWS_ACCESS_KEY_ID is not set") - if (!secretAccessKey) throw new Error("AWS_SECRET_ACCESS_KEY is not set") - const region = (setting.region as string | undefined) ?? env.AWS_REGION - if (!region) throw new Error("AWS_REGION is not set") - return { - providerName: "amazon-bedrock", - accessKeyId, - secretAccessKey, - region, - sessionToken, - } - } - case "google-vertex": { - return { - providerName: "google-vertex", - project: (setting.project as string | undefined) ?? env.GOOGLE_VERTEX_PROJECT, - location: (setting.location as string | undefined) ?? env.GOOGLE_VERTEX_LOCATION, - baseUrl: (setting.baseUrl as string | undefined) ?? env.GOOGLE_VERTEX_BASE_URL, - headers: setting.headers as Record | undefined, - } - } - case "deepseek": { - const apiKey = env.DEEPSEEK_API_KEY - if (!apiKey) throw new Error("DEEPSEEK_API_KEY is not set") - return { - providerName: "deepseek", - apiKey, - baseUrl: (setting.baseUrl as string | undefined) ?? env.DEEPSEEK_BASE_URL, - headers: setting.headers as Record | undefined, - } - } - } -} diff --git a/benchmarks/README.md b/benchmarks/README.md index 38720011..4b6677a8 100644 --- a/benchmarks/README.md +++ b/benchmarks/README.md @@ -115,7 +115,7 @@ npx tsx benchmarks/production-perf/run-benchmark.ts **Usage** (as a filter): ```bash -npx tsx ./apps/runtime/bin/cli.ts run --config benchmarks/mcp-startup/perstack.toml mcp-startup-benchmark "Hello" | npx tsx benchmarks/mcp-startup/filter.ts +npx tsx ./apps/perstack/bin/cli.ts run --config benchmarks/mcp-startup/perstack.toml mcp-startup-benchmark "Hello" | npx tsx benchmarks/mcp-startup/filter.ts ``` ### Output Format diff --git a/benchmarks/base-transport/run-benchmark.ts b/benchmarks/base-transport/run-benchmark.ts index ae0fd0ab..538dab84 100644 --- a/benchmarks/base-transport/run-benchmark.ts +++ b/benchmarks/base-transport/run-benchmark.ts @@ -43,7 +43,7 @@ async function runBenchmark( return new Promise((resolve) => { const proc = spawn( "npx", - ["tsx", "./apps/runtime/bin/cli.ts", "run", "--config", configPath, expertName, "Ready"], + ["tsx", "./apps/perstack/bin/cli.ts", "run", "--config", configPath, expertName, "Ready"], { cwd: process.cwd(), env: { ...process.env }, diff --git a/benchmarks/production-perf/run-benchmark.ts b/benchmarks/production-perf/run-benchmark.ts index e94e55fd..06b36004 100644 --- a/benchmarks/production-perf/run-benchmark.ts +++ b/benchmarks/production-perf/run-benchmark.ts @@ -103,7 +103,7 @@ async function runBenchmark( "npx", [ "tsx", - "./apps/runtime/bin/cli.ts", + "./apps/perstack/bin/cli.ts", "run", "--config", CONFIG_PATH, diff --git a/docs/understanding-perstack/experts.md b/docs/understanding-perstack/experts.md index 6adb6a51..e35347ff 100644 --- a/docs/understanding-perstack/experts.md +++ b/docs/understanding-perstack/experts.md @@ -92,7 +92,7 @@ packageName = "@eslint/mcp" When you run an Expert: 1. The runtime creates a **Job** and starts the first **Run** with your Expert (the Coordinator) -2. The instruction becomes the system prompt (with [runtime meta-instructions](https://github.com/perstack-ai/perstack/blob/main/apps/runtime/src/messages/instruction-message.ts)) +2. The instruction becomes the system prompt (with [runtime meta-instructions](https://github.com/perstack-ai/perstack/blob/main/packages/runtime/src/messages/instruction-message.ts)) 3. Your query becomes the user message 4. The LLM reasons and calls tools (skills) as needed 5. Each step produces a checkpoint — a complete snapshot of the Run's state diff --git a/e2e/README.md b/e2e/README.md index 9154099f..51cfdb13 100644 --- a/e2e/README.md +++ b/e2e/README.md @@ -1,6 +1,6 @@ # E2E Tests -End-to-end tests for Perstack CLI and runtime. This document serves as the authoritative audit trail for security and functional verification. +End-to-end tests for the Perstack CLI. This document serves as the authoritative audit trail for security and functional verification. ## Prerequisites @@ -26,33 +26,31 @@ pnpm test:e2e -- --testNamePattern "delegate" ``` e2e/ ├── perstack-cli/ # perstack CLI tests +│ ├── bundled-base.test.ts # Bundled base skill │ ├── continue.test.ts # Continue job, resume from checkpoint │ ├── delegate.test.ts # Delegate to expert -│ ├── interactive.test.ts # Interactive input (with delegation) -│ ├── log.test.ts # Log command -│ ├── published-expert.test.ts # Published expert resolution -│ └── validation.test.ts # CLI validation -├── perstack-runtime/ # perstack-runtime CLI tests -│ ├── bundled-base.test.ts # Bundled base skill │ ├── error-handling.test.ts # Error handling -│ ├── interactive.test.ts # Interactive input -│ ├── lazy-init.test.ts # Lazy initialization +│ ├── interactive.test.ts # Interactive input (with delegation) +│ ├── lazy-init.test.ts # Lazy initialization │ ├── limits.test.ts # Execution limits │ ├── lockfile.test.ts # Lockfile functionality +│ ├── log.test.ts # Log command │ ├── options.test.ts # CLI options -│ ├── providers.test.ts # Provider tests +│ ├── providers.test.ts # Provider tests +│ ├── published-expert.test.ts # Published expert resolution │ ├── reasoning-budget.test.ts # Reasoning budget │ ├── run.test.ts # Run expert +│ ├── runtime-interactive.test.ts # Interactive input (basic) │ ├── runtime-version.test.ts # Runtime version │ ├── skills.test.ts # Skill configuration -│ ├── streaming.test.ts # Streaming events +│ ├── streaming.test.ts # Streaming events │ ├── validation.test.ts # CLI validation │ └── versioned-base.test.ts # Versioned base skill ├── lib/ # Test utilities │ ├── assertions.ts # Custom assertions │ ├── event-parser.ts # Runtime event parsing │ ├── prerequisites.ts # Environment checks -│ └── runner.ts # CLI and Expert execution +│ └── runner.ts # CLI execution ├── experts/ # Expert definitions for tests └── fixtures/ # Test fixtures ``` @@ -105,6 +103,9 @@ e2e/ | Test | Purpose | | --------------------------------------------------------------- | ------------------------------------- | +| `should show version` | Verify --version flag | +| `should show help` | Verify --help flag | +| `should show run command help` | Verify run --help | | `should fail without arguments` | Verify run command requires arguments | | `should fail with only expert key` | Verify run command requires query | | `should fail for nonexistent expert` | Verify expert existence validation | @@ -112,8 +113,6 @@ e2e/ | `should fail when --resume-from is used without --continue-job` | Verify resume flag dependency | | `should fail with clear message for nonexistent delegate` | Verify delegate existence validation | -### perstack-runtime/ - #### Run Expert (`run.test.ts`) | Test | Purpose | @@ -158,7 +157,7 @@ e2e/ | `should not have access to omitted tools` | Verify omit tool filtering | | `should have access to tools from multiple skills` | Verify multi-skill tool access | -#### Interactive Input (`interactive.test.ts`) +#### Interactive Input - Basic (`runtime-interactive.test.ts`) | Test | Purpose | | ----------------------------------------------------- | ------------------------------------- | @@ -172,59 +171,11 @@ e2e/ | `should fail gracefully when MCP skill command is invalid` | Verify MCP error handling | | `should fail with invalid provider name` | Verify provider validation | -#### CLI Validation (`validation.test.ts`) - -| Test | Purpose | -| ------------------------------------------ | ----------------------------- | -| `should show version` | Verify --version flag | -| `should show help` | Verify --help flag | -| `should show run command help` | Verify run --help | -| `should fail without arguments` | Verify run requires arguments | -| `should fail with only expert key` | Verify run requires query | -| `should fail for nonexistent expert` | Verify expert validation | -| `should fail with nonexistent config file` | Verify config validation | - ---- - -## Architecture Notes - -### Two CLIs - -| CLI | Package | Use Case | -| ------------------ | --------------- | ---------------------------- | -| `perstack` | `apps/perstack` | Primary user-facing CLI | -| `perstack-runtime` | `apps/runtime` | Standalone runtime execution | - -### Key Differences - -- **perstack CLI**: User-facing CLI with job management features -- **perstack-runtime CLI**: Lightweight wrapper that executes experts and outputs JSON events - --- ## Test Execution Notes - Tests run sequentially with `fileParallelism: false` to reduce CPU load - `--bail=1` stops on first failure for faster feedback -- Runtime tests require API keys (set in `.env.local`) +- Tests require API keys (set in `.env.local`) - TUI-based commands (`start`) are excluded from E2E tests - ---- - -## Summary Statistics - -| Category | Test Count | -| --------------------------- | ---------- | -| Continue Job | 5 | -| Delegate to Expert | 6 | -| Interactive Input (CLI) | 4 | -| Published Expert | 3 | -| CLI Validation (perstack) | 6 | -| Run Expert | 9 | -| CLI Options | 10 | -| Execution Limits | 2 | -| Skills | 4 | -| Interactive Input (runtime) | 1 | -| Error Handling | 3 | -| CLI Validation (runtime) | 7 | -| **Total** | **60** | diff --git a/e2e/lib/runner.ts b/e2e/lib/runner.ts index b1c5a354..00e45092 100644 --- a/e2e/lib/runner.ts +++ b/e2e/lib/runner.ts @@ -78,37 +78,3 @@ export async function runCli(args: string[], options?: RunOptions): Promise { - const timeout = options?.timeout ?? 30000 - const cwd = options?.cwd ?? process.cwd() - const env = options?.env ?? { ...process.env } - const finalArgs = buildFinalArgs(args, options) - return new Promise((resolve, reject) => { - let stdout = "" - let stderr = "" - const proc = spawn("npx", ["tsx", "./apps/runtime/bin/cli.ts", ...finalArgs], { - cwd, - env, - stdio: ["pipe", "pipe", "pipe"], - }) - const timer = setTimeout(() => { - proc.kill("SIGTERM") - reject(new Error(`Timeout after ${timeout}ms`)) - }, timeout) - proc.stdout.on("data", (data) => { - stdout += data.toString() - }) - proc.stderr.on("data", (data) => { - stderr += data.toString() - }) - proc.on("close", (code) => { - clearTimeout(timer) - resolve({ stdout, stderr, exitCode: code ?? 0 }) - }) - proc.on("error", (err) => { - clearTimeout(timer) - reject(err) - }) - }) -} diff --git a/e2e/perstack-runtime/bundled-base.test.ts b/e2e/perstack-cli/bundled-base.test.ts similarity index 93% rename from e2e/perstack-runtime/bundled-base.test.ts rename to e2e/perstack-cli/bundled-base.test.ts index 69e4c836..8bb3911d 100644 --- a/e2e/perstack-runtime/bundled-base.test.ts +++ b/e2e/perstack-cli/bundled-base.test.ts @@ -1,5 +1,5 @@ /** - * Bundled Base Skill E2E Tests (Runtime) + * Bundled Base Skill E2E Tests * * Tests that the bundled @perstack/base skill uses InMemoryTransport * for near-zero initialization latency. @@ -9,7 +9,7 @@ import { describe, expect, it } from "vitest" import { assertEventSequenceContains } from "../lib/assertions.js" import { filterEventsByType } from "../lib/event-parser.js" -import { runRuntimeCli, withEventParsing } from "../lib/runner.js" +import { runCli, withEventParsing } from "../lib/runner.js" const BUNDLED_BASE_CONFIG = "./e2e/experts/bundled-base.toml" // LLM API calls require extended timeout @@ -20,7 +20,7 @@ describe.concurrent("Bundled Base Skill", () => { it( "should use InMemoryTransport for bundled base (spawnDurationMs = 0)", async () => { - const cmdResult = await runRuntimeCli( + const cmdResult = await runCli( ["run", "--config", BUNDLED_BASE_CONFIG, "e2e-bundled-base", "Run health check"], { timeout: LLM_TIMEOUT }, ) @@ -56,7 +56,7 @@ describe.concurrent("Bundled Base Skill", () => { it( "should have all base skill tools available", async () => { - const cmdResult = await runRuntimeCli( + const cmdResult = await runCli( ["run", "--config", BUNDLED_BASE_CONFIG, "e2e-bundled-base", "Run health check"], { timeout: LLM_TIMEOUT }, ) diff --git a/e2e/perstack-runtime/error-handling.test.ts b/e2e/perstack-cli/error-handling.test.ts similarity index 86% rename from e2e/perstack-runtime/error-handling.test.ts rename to e2e/perstack-cli/error-handling.test.ts index 2aafb32f..774eaea1 100644 --- a/e2e/perstack-runtime/error-handling.test.ts +++ b/e2e/perstack-cli/error-handling.test.ts @@ -1,7 +1,7 @@ /** - * Error Handling E2E Tests (Runtime) + * Error Handling E2E Tests * - * Tests graceful error handling in perstack-runtime: + * Tests graceful error handling in perstack: * - Tool error recovery (file not found) * - Invalid MCP skill command * - Invalid provider name @@ -11,7 +11,7 @@ import { describe, expect, it } from "vitest" import { assertEventSequenceContains } from "../lib/assertions.js" import { filterEventsByType } from "../lib/event-parser.js" -import { runRuntimeCli, withEventParsing } from "../lib/runner.js" +import { runCli, withEventParsing } from "../lib/runner.js" const ERROR_HANDLING_CONFIG = "./e2e/experts/error-handling.toml" const ERRORS_CONFIG = "./e2e/experts/errors.toml" @@ -24,7 +24,7 @@ describe.concurrent("Error Handling", () => { it( "should recover from file not found error and complete successfully", async () => { - const cmdResult = await runRuntimeCli( + const cmdResult = await runCli( [ "run", "--config", @@ -53,20 +53,14 @@ describe.concurrent("Error Handling", () => { /** Verifies graceful failure for broken MCP skill. */ it("should fail gracefully when MCP skill command is invalid", async () => { - const result = await runRuntimeCli([ - "run", - "--config", - ERRORS_CONFIG, - "e2e-mcp-error", - "Say hello", - ]) + const result = await runCli(["run", "--config", ERRORS_CONFIG, "e2e-mcp-error", "Say hello"]) expect(result.exitCode).toBe(1) expect(result.stderr).toMatch(/has no packageName or args/i) }) /** Verifies rejection of invalid provider name. */ it("should fail with invalid provider name", async () => { - const result = await runRuntimeCli([ + const result = await runCli([ "run", "--config", GLOBAL_RUNTIME_CONFIG, diff --git a/e2e/perstack-runtime/lazy-init.test.ts b/e2e/perstack-cli/lazy-init.test.ts similarity index 95% rename from e2e/perstack-runtime/lazy-init.test.ts rename to e2e/perstack-cli/lazy-init.test.ts index bbccc1ae..7f32ff93 100644 --- a/e2e/perstack-runtime/lazy-init.test.ts +++ b/e2e/perstack-cli/lazy-init.test.ts @@ -1,5 +1,5 @@ /** - * Lazy Init E2E Tests (Runtime) + * Lazy Init E2E Tests * * Tests skill initialization behavior based on lazyInit setting: * - lazyInit = false (default): All skills must be fully connected BEFORE startRun @@ -8,7 +8,7 @@ * TOML: e2e/experts/lazy-init.toml */ import { describe, expect, it } from "vitest" -import { runRuntimeCli, withEventParsing } from "../lib/runner.js" +import { runCli, withEventParsing } from "../lib/runner.js" const LAZY_INIT_CONFIG = "./e2e/experts/lazy-init.toml" // LLM API calls require extended timeout @@ -43,7 +43,7 @@ describe.concurrent("Lazy Init", () => { it( "all lazyInit=false: all skills should be connected before startRun", async () => { - const cmdResult = await runRuntimeCli( + const cmdResult = await runCli( ["run", "--config", LAZY_INIT_CONFIG, "e2e-lazy-init-all-false", "Complete the task"], { timeout: LLM_TIMEOUT }, ) @@ -87,7 +87,7 @@ describe.concurrent("Lazy Init", () => { it( "mixed: lazyInit=false blocks startRun, lazyInit=true does not block", async () => { - const cmdResult = await runRuntimeCli( + const cmdResult = await runCli( ["run", "--config", LAZY_INIT_CONFIG, "e2e-lazy-init-mixed", "Complete the task"], { timeout: LLM_TIMEOUT }, ) diff --git a/e2e/perstack-runtime/limits.test.ts b/e2e/perstack-cli/limits.test.ts similarity index 85% rename from e2e/perstack-runtime/limits.test.ts rename to e2e/perstack-cli/limits.test.ts index 4fad18e4..30d37c9b 100644 --- a/e2e/perstack-runtime/limits.test.ts +++ b/e2e/perstack-cli/limits.test.ts @@ -1,7 +1,7 @@ /** - * Execution Limits E2E Tests (Runtime) + * Execution Limits E2E Tests * - * Tests execution limit options in perstack-runtime: + * Tests execution limit options in perstack: * - --max-steps: Maximum generation steps * - --max-retries: Maximum retry attempts * @@ -9,7 +9,7 @@ */ import { describe, expect, it } from "vitest" import { assertEventSequenceContains } from "../lib/assertions.js" -import { runRuntimeCli, withEventParsing } from "../lib/runner.js" +import { runCli, withEventParsing } from "../lib/runner.js" const GLOBAL_RUNTIME_CONFIG = "./e2e/experts/global-runtime.toml" // LLM API calls require extended timeout @@ -20,7 +20,7 @@ describe.concurrent("Execution Limits", () => { it( "should accept --max-steps option and complete within limit", async () => { - const cmdResult = await runRuntimeCli( + const cmdResult = await runCli( [ "run", "--config", @@ -45,7 +45,7 @@ describe.concurrent("Execution Limits", () => { it( "should accept --max-retries option", async () => { - const cmdResult = await runRuntimeCli( + const cmdResult = await runCli( [ "run", "--config", diff --git a/e2e/perstack-runtime/lockfile.test.ts b/e2e/perstack-cli/lockfile.test.ts similarity index 95% rename from e2e/perstack-runtime/lockfile.test.ts rename to e2e/perstack-cli/lockfile.test.ts index 0f497130..f8b68fe9 100644 --- a/e2e/perstack-runtime/lockfile.test.ts +++ b/e2e/perstack-cli/lockfile.test.ts @@ -10,7 +10,7 @@ import { existsSync, readFileSync, unlinkSync } from "node:fs" import { afterEach, beforeEach, describe, expect, it } from "vitest" import { assertEventSequenceContains } from "../lib/assertions.js" -import { runCli, runRuntimeCli, withEventParsing } from "../lib/runner.js" +import { runCli, withEventParsing } from "../lib/runner.js" const LOCKFILE_CONFIG = "./e2e/experts/lockfile.toml" const LOCKFILE_PATH = "./e2e/experts/perstack.lock" @@ -59,7 +59,7 @@ describe("Lockfile", () => { }) expect(installResult.exitCode).toBe(0) expect(existsSync(LOCKFILE_PATH)).toBe(true) - const runResult = await runRuntimeCli( + const runResult = await runCli( ["run", "--config", LOCKFILE_CONFIG, "e2e-lockfile", "Test with lockfile"], { timeout: LLM_TIMEOUT }, ) @@ -76,7 +76,7 @@ describe("Lockfile", () => { "should run without lockfile (fallback)", async () => { expect(existsSync(LOCKFILE_PATH)).toBe(false) - const runResult = await runRuntimeCli( + const runResult = await runCli( ["run", "--config", LOCKFILE_CONFIG, "e2e-lockfile", "Test without lockfile"], { timeout: LLM_TIMEOUT }, ) diff --git a/e2e/perstack-runtime/options.test.ts b/e2e/perstack-cli/options.test.ts similarity index 81% rename from e2e/perstack-runtime/options.test.ts rename to e2e/perstack-cli/options.test.ts index d4f71a6a..dee28190 100644 --- a/e2e/perstack-runtime/options.test.ts +++ b/e2e/perstack-cli/options.test.ts @@ -1,16 +1,16 @@ /** - * CLI Options E2E Tests (Runtime) + * CLI Options E2E Tests * - * Tests CLI option handling in perstack-runtime: + * Tests CLI option handling in perstack: * - --provider, --model * - --max-steps, --max-retries, --timeout - * - --job-id, --run-id, --env-path, --verbose + * - --job-id, --env-path, --verbose * * TOML: e2e/experts/global-runtime.toml */ import { describe, expect, it } from "vitest" import { assertEventSequenceContains } from "../lib/assertions.js" -import { runRuntimeCli, withEventParsing } from "../lib/runner.js" +import { runCli, withEventParsing } from "../lib/runner.js" const GLOBAL_RUNTIME_CONFIG = "./e2e/experts/global-runtime.toml" // LLM API calls require extended timeout @@ -21,7 +21,7 @@ describe.concurrent("CLI Options", () => { it( "should accept --provider option", async () => { - const result = await runRuntimeCli( + const result = await runCli( [ "run", "--config", @@ -42,7 +42,7 @@ describe.concurrent("CLI Options", () => { it( "should accept --model option", async () => { - const result = await runRuntimeCli( + const result = await runCli( [ "run", "--config", @@ -63,7 +63,7 @@ describe.concurrent("CLI Options", () => { it( "should accept --max-steps option", async () => { - const result = await runRuntimeCli( + const result = await runCli( [ "run", "--config", @@ -84,7 +84,7 @@ describe.concurrent("CLI Options", () => { it( "should accept --max-retries option", async () => { - const result = await runRuntimeCli( + const result = await runCli( [ "run", "--config", @@ -105,7 +105,7 @@ describe.concurrent("CLI Options", () => { it( "should accept --timeout option", async () => { - const result = await runRuntimeCli( + const result = await runCli( [ "run", "--config", @@ -126,7 +126,7 @@ describe.concurrent("CLI Options", () => { it( "should accept --job-id option", async () => { - const cmdResult = await runRuntimeCli( + const cmdResult = await runCli( [ "run", "--config", @@ -146,36 +146,11 @@ describe.concurrent("CLI Options", () => { LLM_TIMEOUT, ) - /** Verifies --run-id option is accepted. */ - it( - "should accept --run-id option", - async () => { - const cmdResult = await runRuntimeCli( - [ - "run", - "--config", - GLOBAL_RUNTIME_CONFIG, - "--run-id", - "test-run-456", - "e2e-global-runtime", - "Say hello", - ], - { timeout: LLM_TIMEOUT }, - ) - const result = withEventParsing(cmdResult) - expect(result.exitCode).toBe(0) - expect(assertEventSequenceContains(result.events, ["startRun", "completeRun"]).passed).toBe( - true, - ) - }, - LLM_TIMEOUT, - ) - /** Verifies --env-path option is accepted. */ it( "should accept --env-path option", async () => { - const cmdResult = await runRuntimeCli( + const cmdResult = await runCli( [ "run", "--config", @@ -197,7 +172,7 @@ describe.concurrent("CLI Options", () => { it( "should accept --verbose option", async () => { - const result = await runRuntimeCli( + const result = await runCli( ["run", "--config", GLOBAL_RUNTIME_CONFIG, "--verbose", "e2e-global-runtime", "Say hello"], { timeout: LLM_TIMEOUT }, ) @@ -212,7 +187,7 @@ describe.concurrent("CLI Options - Filter", () => { it( "should filter events to only completeRun", async () => { - const cmdResult = await runRuntimeCli( + const cmdResult = await runCli( [ "run", "--config", @@ -239,7 +214,7 @@ describe.concurrent("CLI Options - Filter", () => { it( "should filter events to completeRun and initializeRuntime", async () => { - const cmdResult = await runRuntimeCli( + const cmdResult = await runCli( [ "run", "--config", @@ -266,7 +241,7 @@ describe.concurrent("CLI Options - Filter", () => { it( "should reject invalid filter type", async () => { - const result = await runRuntimeCli( + const result = await runCli( [ "run", "--config", diff --git a/e2e/perstack-runtime/providers.test.ts b/e2e/perstack-cli/providers.test.ts similarity index 87% rename from e2e/perstack-runtime/providers.test.ts rename to e2e/perstack-cli/providers.test.ts index bbb0b65d..680da3fc 100644 --- a/e2e/perstack-runtime/providers.test.ts +++ b/e2e/perstack-cli/providers.test.ts @@ -13,7 +13,7 @@ import { describe, expect, it } from "vitest" import { assertEventSequenceContains } from "../lib/assertions.js" import { hasAnthropicKey, hasGoogleKey, hasOpenAIKey } from "../lib/prerequisites.js" -import { runRuntimeCli, withEventParsing } from "../lib/runner.js" +import { runCli, withEventParsing } from "../lib/runner.js" const CONFIG = "./e2e/experts/providers.toml" const LLM_TIMEOUT = 120000 @@ -32,10 +32,11 @@ describe.concurrent("LLM Providers", () => { console.log(`Skipping ${provider} test: API key not available`) return } - const cmdResult = await runRuntimeCli( - ["run", "--config", CONFIG, "e2e-providers", "Say hello"], - { timeout: LLM_TIMEOUT, provider, model }, - ) + const cmdResult = await runCli(["run", "--config", CONFIG, "e2e-providers", "Say hello"], { + timeout: LLM_TIMEOUT, + provider, + model, + }) const result = withEventParsing(cmdResult) expect(result.exitCode).toBe(0) expect(assertEventSequenceContains(result.events, ["startRun", "completeRun"]).passed).toBe( diff --git a/e2e/perstack-runtime/reasoning-budget.test.ts b/e2e/perstack-cli/reasoning-budget.test.ts similarity index 98% rename from e2e/perstack-runtime/reasoning-budget.test.ts rename to e2e/perstack-cli/reasoning-budget.test.ts index 91abe1e6..fc67e590 100644 --- a/e2e/perstack-runtime/reasoning-budget.test.ts +++ b/e2e/perstack-cli/reasoning-budget.test.ts @@ -1,5 +1,5 @@ /** - * Reasoning Budget E2E Tests (Runtime) + * Reasoning Budget E2E Tests * * Tests that different reasoning budget levels produce different reasoning token counts. * This validates that the reasoningBudget configuration is correctly passed to providers. @@ -8,7 +8,7 @@ */ import { describe, expect, it } from "vitest" import { filterEventsByType } from "../lib/event-parser.js" -import { runRuntimeCli, withEventParsing } from "../lib/runner.js" +import { runCli, withEventParsing } from "../lib/runner.js" const REASONING_BUDGET_CONFIG = "./e2e/experts/reasoning-budget.toml" // Extended thinking requires longer timeout @@ -30,7 +30,7 @@ async function runReasoningTest( model: string, ): Promise { const expertKey = `e2e-reasoning-${provider}-${budget}` - const cmdResult = await runRuntimeCli( + const cmdResult = await runCli( [ "run", "--config", @@ -120,7 +120,7 @@ describe("Reasoning Budget", () => { "should emit streaming reasoning events", async () => { const expertKey = "e2e-reasoning-anthropic-medium" - const cmdResult = await runRuntimeCli( + const cmdResult = await runCli( [ "run", "--config", diff --git a/e2e/perstack-runtime/run.test.ts b/e2e/perstack-cli/run.test.ts similarity index 94% rename from e2e/perstack-runtime/run.test.ts rename to e2e/perstack-cli/run.test.ts index 84cbaabc..8530513a 100644 --- a/e2e/perstack-runtime/run.test.ts +++ b/e2e/perstack-cli/run.test.ts @@ -1,7 +1,7 @@ /** - * Run Expert E2E Tests (Runtime) + * Run Expert E2E Tests * - * Tests core expert execution in perstack-runtime: + * Tests core expert execution in perstack: * - Simple question answering * - Multi-tool parallel execution * - PDF reading and summarization @@ -12,7 +12,7 @@ import { describe, expect, it } from "vitest" import { assertEventSequenceContains, assertToolCallCount } from "../lib/assertions.js" import { filterEventsByType } from "../lib/event-parser.js" -import { runRuntimeCli, withEventParsing } from "../lib/runner.js" +import { runCli, withEventParsing } from "../lib/runner.js" const GLOBAL_RUNTIME_CONFIG = "./e2e/experts/global-runtime.toml" const SPECIAL_TOOLS_CONFIG = "./e2e/experts/special-tools.toml" @@ -26,7 +26,7 @@ describe.concurrent("Run Expert", () => { it( "should answer a simple question and complete", async () => { - const cmdResult = await runRuntimeCli( + const cmdResult = await runCli( ["run", "--config", GLOBAL_RUNTIME_CONFIG, "e2e-global-runtime", "Say hello"], { timeout: LLM_TIMEOUT }, ) @@ -46,7 +46,7 @@ describe.concurrent("Run Expert", () => { it( "should execute multiple tools in parallel and complete", async () => { - const cmdResult = await runRuntimeCli( + const cmdResult = await runCli( ["run", "--config", SPECIAL_TOOLS_CONFIG, "e2e-special-tools", "echo test"], { timeout: LLM_EXTENDED_TIMEOUT }, ) @@ -82,7 +82,7 @@ describe.concurrent("Run Expert", () => { it( "should read and summarize PDF content", async () => { - const cmdResult = await runRuntimeCli( + const cmdResult = await runCli( [ "run", "--config", @@ -123,7 +123,7 @@ describe.concurrent("Run Expert", () => { it( "should read and describe image content", async () => { - const cmdResult = await runRuntimeCli( + const cmdResult = await runCli( [ "run", "--config", diff --git a/e2e/perstack-runtime/interactive.test.ts b/e2e/perstack-cli/runtime-interactive.test.ts similarity index 88% rename from e2e/perstack-runtime/interactive.test.ts rename to e2e/perstack-cli/runtime-interactive.test.ts index 5dac5406..ef14bad9 100644 --- a/e2e/perstack-runtime/interactive.test.ts +++ b/e2e/perstack-cli/runtime-interactive.test.ts @@ -1,7 +1,7 @@ /** * Interactive Input E2E Tests (Runtime) * - * Tests interactive tool handling in perstack-runtime: + * Tests interactive tool handling: * - Stop at interactive tool (askUser) * - Checkpoint emission for resume * @@ -9,7 +9,7 @@ */ import { describe, expect, it } from "vitest" import { assertEventSequenceContains } from "../lib/assertions.js" -import { runRuntimeCli, withEventParsing } from "../lib/runner.js" +import { runCli, withEventParsing } from "../lib/runner.js" const CONTINUE_CONFIG = "./e2e/experts/continue-resume.toml" // LLM API calls require extended timeout @@ -20,7 +20,7 @@ describe.concurrent("Interactive Input", () => { it( "should stop at interactive tool and emit checkpoint", async () => { - const cmdResult = await runRuntimeCli( + const cmdResult = await runCli( ["run", "--config", CONTINUE_CONFIG, "e2e-continue", "Test continue/resume functionality"], { timeout: LLM_TIMEOUT }, ) diff --git a/e2e/perstack-runtime/runtime-version.test.ts b/e2e/perstack-cli/runtime-version.test.ts similarity index 89% rename from e2e/perstack-runtime/runtime-version.test.ts rename to e2e/perstack-cli/runtime-version.test.ts index 7a01f100..36859f42 100644 --- a/e2e/perstack-runtime/runtime-version.test.ts +++ b/e2e/perstack-cli/runtime-version.test.ts @@ -1,7 +1,7 @@ /** * Runtime Version E2E Tests * - * Tests runtime version validation in perstack-runtime: + * Tests runtime version validation in perstack: * - v1.0 minRuntimeVersion with 0.x.y runtime (special case) * - No minRuntimeVersion (default) * - Future version requirement (validation failure) @@ -13,7 +13,7 @@ import { describe, expect, it } from "vitest" import { assertEventSequenceContains } from "../lib/assertions.js" import { filterEventsByType } from "../lib/event-parser.js" -import { runRuntimeCli, withEventParsing } from "../lib/runner.js" +import { runCli, withEventParsing } from "../lib/runner.js" const RUNTIME_VERSION_CONFIG = "./e2e/experts/runtime-version.toml" const RUNTIME_VERSION_FUTURE_CONFIG = "./e2e/experts/runtime-version-future.toml" @@ -24,7 +24,7 @@ describe.concurrent("Runtime Version Validation", () => { it( "should succeed with v1.0 minRuntimeVersion on 0.x.y runtime", async () => { - const cmdResult = await runRuntimeCli( + const cmdResult = await runCli( ["run", "--config", RUNTIME_VERSION_CONFIG, "e2e-runtime-v1", "test"], { timeout: LLM_TIMEOUT }, ) @@ -40,7 +40,7 @@ describe.concurrent("Runtime Version Validation", () => { it( "should succeed with no minRuntimeVersion (default)", async () => { - const cmdResult = await runRuntimeCli( + const cmdResult = await runCli( ["run", "--config", RUNTIME_VERSION_CONFIG, "e2e-runtime-default", "test"], { timeout: LLM_TIMEOUT }, ) @@ -56,7 +56,7 @@ describe.concurrent("Runtime Version Validation", () => { it( "should fail when expert requires future version", async () => { - const cmdResult = await runRuntimeCli( + const cmdResult = await runCli( ["run", "--config", RUNTIME_VERSION_FUTURE_CONFIG, "e2e-runtime-future", "test"], { timeout: LLM_TIMEOUT }, ) @@ -69,7 +69,7 @@ describe.concurrent("Runtime Version Validation", () => { it( "should succeed with 3-level delegation chain all requiring v1.0", async () => { - const cmdResult = await runRuntimeCli( + const cmdResult = await runCli( ["run", "--config", RUNTIME_VERSION_CONFIG, "e2e-runtime-chain-ok", "test"], { timeout: LLM_EXTENDED_TIMEOUT }, ) @@ -84,7 +84,7 @@ describe.concurrent("Runtime Version Validation", () => { it( "should fail when nested delegate requires future version", async () => { - const cmdResult = await runRuntimeCli( + const cmdResult = await runCli( ["run", "--config", RUNTIME_VERSION_FUTURE_CONFIG, "e2e-runtime-chain-future", "test"], { timeout: LLM_TIMEOUT }, ) diff --git a/e2e/perstack-runtime/skills.test.ts b/e2e/perstack-cli/skills.test.ts similarity index 91% rename from e2e/perstack-runtime/skills.test.ts rename to e2e/perstack-cli/skills.test.ts index 67b84845..ef9343dc 100644 --- a/e2e/perstack-runtime/skills.test.ts +++ b/e2e/perstack-cli/skills.test.ts @@ -1,7 +1,7 @@ /** - * Skills E2E Tests (Runtime) + * Skills E2E Tests * - * Tests skill configuration in perstack-runtime: + * Tests skill configuration in perstack: * - pick: Only allow specific tools * - omit: Exclude specific tools * - Multi-skill: Combine tools from multiple skills @@ -11,7 +11,7 @@ import { describe, expect, it } from "vitest" import { assertEventSequenceContains } from "../lib/assertions.js" import { filterEventsByType } from "../lib/event-parser.js" -import { runRuntimeCli, withEventParsing } from "../lib/runner.js" +import { runCli, withEventParsing } from "../lib/runner.js" const SKILLS_CONFIG = "./e2e/experts/skills.toml" // LLM API calls require extended timeout @@ -22,7 +22,7 @@ describe.concurrent("Skills", () => { it( "should only have access to picked tools", async () => { - const cmdResult = await runRuntimeCli( + const cmdResult = await runCli( [ "run", "--config", @@ -51,7 +51,7 @@ describe.concurrent("Skills", () => { it( "should be able to use picked tools", async () => { - const cmdResult = await runRuntimeCli( + const cmdResult = await runCli( ["run", "--config", SKILLS_CONFIG, "e2e-pick-tools", "Track a task and complete"], { timeout: LLM_TIMEOUT }, ) @@ -75,7 +75,7 @@ describe.concurrent("Skills", () => { it( "should not have access to omitted tools", async () => { - const cmdResult = await runRuntimeCli( + const cmdResult = await runCli( ["run", "--config", SKILLS_CONFIG, "e2e-omit-tools", "Say hello"], { timeout: LLM_TIMEOUT }, ) @@ -95,7 +95,7 @@ describe.concurrent("Skills", () => { it( "should have access to tools from multiple skills", async () => { - const cmdResult = await runRuntimeCli( + const cmdResult = await runCli( ["run", "--config", SKILLS_CONFIG, "e2e-multi-skill", "Track a task and complete"], { timeout: LLM_TIMEOUT }, ) diff --git a/e2e/perstack-runtime/streaming.test.ts b/e2e/perstack-cli/streaming.test.ts similarity index 96% rename from e2e/perstack-runtime/streaming.test.ts rename to e2e/perstack-cli/streaming.test.ts index f0aa101a..4d0e490e 100644 --- a/e2e/perstack-runtime/streaming.test.ts +++ b/e2e/perstack-cli/streaming.test.ts @@ -1,5 +1,5 @@ /** - * Streaming Events E2E Tests (Runtime) + * Streaming Events E2E Tests * * Tests that streaming events are emitted in the correct sequence: * - startReasoning → streamReasoning... → completeReasoning @@ -9,7 +9,7 @@ */ import { describe, expect, it } from "vitest" import type { ParsedEvent } from "../lib/event-parser.js" -import { runRuntimeCli, withEventParsing } from "../lib/runner.js" +import { runCli, withEventParsing } from "../lib/runner.js" const STREAMING_CONFIG = "./e2e/experts/reasoning-budget.toml" // Streaming tests need enough time for LLM response @@ -37,7 +37,7 @@ describe("Streaming Events", () => { it( "emits reasoning events in correct order (start → stream... → complete)", async () => { - const cmdResult = await runRuntimeCli( + const cmdResult = await runCli( [ "run", "--config", @@ -90,7 +90,7 @@ describe("Streaming Events", () => { it( "emits result events in correct order (start → stream... → complete)", async () => { - const cmdResult = await runRuntimeCli( + const cmdResult = await runCli( [ "run", "--config", @@ -143,7 +143,7 @@ describe("Streaming Events", () => { it( "reasoning phase completes before result phase", async () => { - const cmdResult = await runRuntimeCli( + const cmdResult = await runCli( [ "run", "--config", @@ -187,7 +187,7 @@ describe("Streaming Events", () => { it( "skips reasoning events when budget is none", async () => { - const cmdResult = await runRuntimeCli( + const cmdResult = await runCli( [ "run", "--config", @@ -227,7 +227,7 @@ describe("Streaming Events", () => { it( "streamReasoning events contain non-empty deltas", async () => { - const cmdResult = await runRuntimeCli( + const cmdResult = await runCli( [ "run", "--config", @@ -270,7 +270,7 @@ describe("Streaming Events", () => { it( "streamRunResult events contain non-empty deltas", async () => { - const cmdResult = await runRuntimeCli( + const cmdResult = await runCli( [ "run", "--config", diff --git a/e2e/perstack-cli/validation.test.ts b/e2e/perstack-cli/validation.test.ts index ade58ad8..015e77c4 100644 --- a/e2e/perstack-cli/validation.test.ts +++ b/e2e/perstack-cli/validation.test.ts @@ -2,6 +2,7 @@ * CLI Validation E2E Tests * * Tests CLI argument validation and error handling: + * - --version, --help output * - Missing required arguments * - Nonexistent config files * - Invalid option combinations (e.g., --resume-from without --continue-job) @@ -12,6 +13,32 @@ import { describe, expect, it } from "vitest" import { runCli } from "../lib/runner.js" describe.concurrent("CLI Validation", () => { + // ───────────────────────────────────────────────────────────────────────── + // Help and Version + // ───────────────────────────────────────────────────────────────────────── + + /** Verifies --version outputs semver. */ + it("should show version", async () => { + const result = await runCli(["--version"]) + expect(result.exitCode).toBe(0) + expect(result.stdout).toMatch(/^\d+\.\d+\.\d+/) + }) + + /** Verifies --help outputs usage info. */ + it("should show help", async () => { + const result = await runCli(["--help"]) + expect(result.exitCode).toBe(0) + expect(result.stdout).toContain("perstack") + }) + + /** Verifies run --help shows expertKey and query. */ + it("should show run command help", async () => { + const result = await runCli(["run", "--help"]) + expect(result.exitCode).toBe(0) + expect(result.stdout).toContain("expertKey") + expect(result.stdout).toContain("query") + }) + // ───────────────────────────────────────────────────────────────────────── // Missing Arguments // ───────────────────────────────────────────────────────────────────────── diff --git a/e2e/perstack-runtime/versioned-base.test.ts b/e2e/perstack-cli/versioned-base.test.ts similarity index 92% rename from e2e/perstack-runtime/versioned-base.test.ts rename to e2e/perstack-cli/versioned-base.test.ts index b9f22c99..b2e204a5 100644 --- a/e2e/perstack-runtime/versioned-base.test.ts +++ b/e2e/perstack-cli/versioned-base.test.ts @@ -1,5 +1,5 @@ /** - * Versioned Base Skill E2E Tests (Runtime) + * Versioned Base Skill E2E Tests * * Tests that pinning an explicit version for @perstack/base * falls back to StdioTransport (npx). @@ -9,7 +9,7 @@ import { describe, expect, it } from "vitest" import { assertEventSequenceContains } from "../lib/assertions.js" import { filterEventsByType } from "../lib/event-parser.js" -import { runRuntimeCli, withEventParsing } from "../lib/runner.js" +import { runCli, withEventParsing } from "../lib/runner.js" const VERSIONED_BASE_CONFIG = "./e2e/experts/versioned-base.toml" // LLM API calls + npx download require extended timeout @@ -20,7 +20,7 @@ describe.concurrent("Versioned Base Skill (StdioTransport Fallback)", () => { it( "should use StdioTransport for versioned base (spawnDurationMs > 0)", async () => { - const cmdResult = await runRuntimeCli( + const cmdResult = await runCli( ["run", "--config", VERSIONED_BASE_CONFIG, "e2e-versioned-base", "Run health check"], { timeout: LLM_TIMEOUT }, ) @@ -56,7 +56,7 @@ describe.concurrent("Versioned Base Skill (StdioTransport Fallback)", () => { it( "should have picked tools available", async () => { - const cmdResult = await runRuntimeCli( + const cmdResult = await runCli( ["run", "--config", VERSIONED_BASE_CONFIG, "e2e-versioned-base", "Run health check"], { timeout: LLM_TIMEOUT }, ) diff --git a/e2e/perstack-runtime/validation.test.ts b/e2e/perstack-runtime/validation.test.ts deleted file mode 100644 index f636be4b..00000000 --- a/e2e/perstack-runtime/validation.test.ts +++ /dev/null @@ -1,73 +0,0 @@ -/** - * CLI Validation E2E Tests (Runtime) - * - * Tests CLI argument validation in perstack-runtime: - * - --version, --help output - * - Missing required arguments - * - Nonexistent config files - * - * These tests do NOT invoke LLM APIs. - */ -import { describe, expect, it } from "vitest" -import { runRuntimeCli } from "../lib/runner.js" - -describe.concurrent("CLI Validation", () => { - // ───────────────────────────────────────────────────────────────────────── - // Help and Version - // ───────────────────────────────────────────────────────────────────────── - - /** Verifies --version outputs semver. */ - it("should show version", async () => { - const result = await runRuntimeCli(["--version"]) - expect(result.exitCode).toBe(0) - expect(result.stdout).toMatch(/^\d+\.\d+\.\d+/) - }) - - /** Verifies --help outputs usage info. */ - it("should show help", async () => { - const result = await runRuntimeCli(["--help"]) - expect(result.exitCode).toBe(0) - expect(result.stdout).toContain("perstack-runtime") - }) - - /** Verifies run --help shows expertKey and query. */ - it("should show run command help", async () => { - const result = await runRuntimeCli(["run", "--help"]) - expect(result.exitCode).toBe(0) - expect(result.stdout).toContain("expertKey") - expect(result.stdout).toContain("query") - }) - - // ───────────────────────────────────────────────────────────────────────── - // Missing Arguments - // ───────────────────────────────────────────────────────────────────────── - - /** Verifies run requires expert and query. */ - it("should fail without arguments", async () => { - const result = await runRuntimeCli(["run"]) - expect(result.exitCode).toBe(1) - }) - - /** Verifies run requires query after expert key. */ - it("should fail with only expert key", async () => { - const result = await runRuntimeCli(["run", "expertOnly"]) - expect(result.exitCode).toBe(1) - }) - - // ───────────────────────────────────────────────────────────────────────── - // Nonexistent Resources - // ───────────────────────────────────────────────────────────────────────── - - /** Verifies error for nonexistent expert. */ - it("should fail for nonexistent expert", async () => { - const result = await runRuntimeCli(["run", "nonexistent-expert", "test query"]) - expect(result.exitCode).toBe(1) - }) - - /** Verifies error for nonexistent config file. */ - it("should fail with nonexistent config file", async () => { - const result = await runRuntimeCli(["run", "expert", "query", "--config", "nonexistent.toml"]) - expect(result.exitCode).toBe(1) - expect(result.stderr).toContain("nonexistent.toml") - }) -}) diff --git a/e2e/tsconfig.json b/e2e/tsconfig.json index 84bcdadb..82fa9be4 100644 --- a/e2e/tsconfig.json +++ b/e2e/tsconfig.json @@ -4,7 +4,7 @@ "resolveJsonModule": true, "paths": { "@perstack/core": ["../packages/core/src/index.ts"], - "@perstack/runtime": ["../apps/runtime/src/index.ts"] + "@perstack/runtime": ["../packages/runtime/src/index.ts"] } }, "include": ["**/*.ts"], diff --git a/knip.json b/knip.json index 0d166568..4a492f11 100644 --- a/knip.json +++ b/knip.json @@ -15,7 +15,7 @@ ], "workspaces": { "apps/perstack": { - "entry": ["bin/cli.ts", "src/**/*.ts"], + "entry": ["bin/cli.ts"], "ignoreDependencies": ["dotenv", "ink", "react"] }, "apps/create-expert": { diff --git a/apps/runtime/CHANGELOG.md b/packages/runtime/CHANGELOG.md similarity index 100% rename from apps/runtime/CHANGELOG.md rename to packages/runtime/CHANGELOG.md diff --git a/packages/runtime/README.md b/packages/runtime/README.md new file mode 100644 index 00000000..9102c3b9 --- /dev/null +++ b/packages/runtime/README.md @@ -0,0 +1,104 @@ +# @perstack/runtime + +The execution engine library for Perstack agents. This is a **pure library** — it has no CLI. CLI functionality is provided by the [`perstack`](https://www.npmjs.com/package/perstack) package. + +## Installation + +```bash +npm install @perstack/runtime +``` + +## Usage + +The primary entry point is the `run` function: + +```typescript +import { run } from "@perstack/runtime" + +const checkpoint = await run( + { + setting: { + model: "claude-sonnet-4-5", + providerConfig: { providerName: "anthropic", apiKey: "..." }, + jobId: "job-123", + runId: "run-123", + expertKey: "researcher", + input: { text: "Research quantum computing" }, + experts: { /* expert definitions */ }, + }, + }, + { + eventListener: (event) => console.log(`[${event.type}]`, event), + }, +) +``` + +### Public API + +| Export | Description | +| --- | --- | +| `run(input, options)` | Execute an expert run. Returns a `Checkpoint`. | +| `runtimeVersion` | Current runtime semver string. | +| `collectToolDefinitionsForExpert()` | Collect tool definitions from an expert's skills. | +| `getLockfileExpertToolDefinitions()` | Extract tool definitions from a lockfile expert entry. | +| `runtimeStateMachine` | XState machine definition (for advanced use). | + +### Events + +The `eventListener` callback receives `RunEvent | RuntimeEvent` objects with granular execution details: + +```typescript +eventListener: (event) => { + if (event.type === "callTools") { + console.log(`Executing ${event.toolCalls.length} tools`) + } +} +``` + +## Architecture + +``` +Job (jobId) + ├── Run 1 (Coordinator Expert) + │ └── Checkpoints... + ├── Run 2 (Delegated Expert A) + │ └── Checkpoints... + └── Run 3 (Delegated Expert B) + └── Checkpoints... +``` + +| Concept | Description | +| --- | --- | +| **Job** | Top-level execution unit. Contains all Runs. | +| **Run** | Single Expert execution within a Job. | +| **Checkpoint** | Immutable snapshot at each step boundary. Enables pause/resume. | + +The runtime drives the agent loop (Reason → Act → Observe), manages checkpoints for state persistence, provides MCP-based tool execution, and handles expert-to-expert delegation. + +### Skill Managers + +| Type | Manager | Purpose | +| --- | --- | --- | +| MCP (stdio/SSE) | `McpSkillManager` | External tools via MCP protocol | +| Interactive | `InteractiveSkillManager` | User input tools (Coordinator only) | +| Delegate | `DelegateSkillManager` | Expert-to-Expert calls | + +### State Machine + +The runtime uses an XState state machine for deterministic execution flow: + +``` +Init → PreparingForStep → GeneratingToolCall → CallingMcpTools → ... + ↓ ↓ +ResumingFromStop CallingDelegates → CallingInteractiveTools + ↓ + ResolvingToolResult → FinishingStep → (loop) +``` + +Terminal states: `completed`, `stoppedByError`, `stoppedByExceededMaxSteps`, `stoppedByInteractiveTool`, `stoppedByDelegate`, `stoppedByCancellation` + +## Related Documentation + +- [Runtime](https://github.com/perstack-ai/perstack/blob/main/docs/understanding-perstack/runtime.md) — Full execution model +- [State Management](https://github.com/perstack-ai/perstack/blob/main/docs/using-experts/state-management.md) — Jobs, Runs, and Checkpoints +- [Running Experts](https://github.com/perstack-ai/perstack/blob/main/docs/using-experts/running-experts.md) — CLI usage via `perstack` package diff --git a/apps/runtime/package.json b/packages/runtime/package.json similarity index 89% rename from apps/runtime/package.json rename to packages/runtime/package.json index c2ce5880..d607781d 100644 --- a/apps/runtime/package.json +++ b/packages/runtime/package.json @@ -5,17 +5,11 @@ "author": "Wintermute Technologies, Inc.", "license": "Apache-2.0", "type": "module", - "bin": { - "perstack-runtime": "./bin/cli.ts" - }, "exports": { ".": "./src/index.ts" }, "publishConfig": { "access": "public", - "bin": { - "perstack-runtime": "dist/bin/cli.js" - }, "exports": { ".": "./dist/src/index.js" }, @@ -45,10 +39,7 @@ "@perstack/base": "workspace:*", "@perstack/core": "workspace:*", "ai": "^6.0.86", - "commander": "^14.0.3", - "dotenv": "^17.3.1", "ollama-ai-provider-v2": "^3.3.0", - "smol-toml": "^1.6.0", "ts-dedent": "^2.2.0", "undici": "^7.22.0", "xstate": "^5.28.0" @@ -65,7 +56,6 @@ "@perstack/vertex-provider": "workspace:*", "@tsconfig/node22": "^22.0.5", "@types/node": "^25.2.3", - "memfs": "^4.56.10", "tsup": "^8.5.1", "typescript": "^5.9.3", "vitest": "^4.0.18" diff --git a/apps/runtime/src/events/event-emitter.test.ts b/packages/runtime/src/events/event-emitter.test.ts similarity index 100% rename from apps/runtime/src/events/event-emitter.test.ts rename to packages/runtime/src/events/event-emitter.test.ts diff --git a/apps/runtime/src/events/event-emitter.ts b/packages/runtime/src/events/event-emitter.ts similarity index 100% rename from apps/runtime/src/events/event-emitter.ts rename to packages/runtime/src/events/event-emitter.ts diff --git a/apps/runtime/src/helpers/checkpoint.test.ts b/packages/runtime/src/helpers/checkpoint.test.ts similarity index 100% rename from apps/runtime/src/helpers/checkpoint.test.ts rename to packages/runtime/src/helpers/checkpoint.test.ts diff --git a/apps/runtime/src/helpers/checkpoint.ts b/packages/runtime/src/helpers/checkpoint.ts similarity index 100% rename from apps/runtime/src/helpers/checkpoint.ts rename to packages/runtime/src/helpers/checkpoint.ts diff --git a/apps/runtime/src/helpers/index.ts b/packages/runtime/src/helpers/index.ts similarity index 85% rename from apps/runtime/src/helpers/index.ts rename to packages/runtime/src/helpers/index.ts index 91d1f847..09b13ca8 100644 --- a/apps/runtime/src/helpers/index.ts +++ b/packages/runtime/src/helpers/index.ts @@ -4,11 +4,7 @@ export { createNextStepCheckpoint, type DelegationStateResult, } from "./checkpoint.js" -export { - findLockfile, - getLockfileExpertToolDefinitions, - loadLockfile, -} from "./lockfile.js" +export { getLockfileExpertToolDefinitions } from "./lockfile.js" export { calculateContextWindowUsage, getContextWindow } from "./model.js" export { getCurrentRuntimeVersion, diff --git a/packages/runtime/src/helpers/lockfile.test.ts b/packages/runtime/src/helpers/lockfile.test.ts new file mode 100644 index 00000000..1ef3bd3d --- /dev/null +++ b/packages/runtime/src/helpers/lockfile.test.ts @@ -0,0 +1,89 @@ +import type { LockfileExpert, LockfileToolDefinition } from "@perstack/core" +import { describe, expect, it } from "vitest" +import { getLockfileExpertToolDefinitions } from "./lockfile.js" + +const createLockfileExpert = ( + toolDefinitions: LockfileToolDefinition[], + overrides: Partial = {}, +): LockfileExpert => ({ + key: "test-expert", + name: "Test Expert", + version: "1.0.0", + instruction: "Test instruction", + skills: {}, + delegates: [], + tags: [], + toolDefinitions, + ...overrides, +}) + +describe("lockfile", () => { + describe("getLockfileExpertToolDefinitions", () => { + it("groups tool definitions by skill name", () => { + const lockfileExpert = createLockfileExpert([ + { + skillName: "@perstack/base", + name: "readFile", + description: "Read a file", + inputSchema: { type: "object", properties: { path: { type: "string" } } }, + }, + { + skillName: "@perstack/base", + name: "writeFile", + description: "Write a file", + inputSchema: { type: "object", properties: { path: { type: "string" } } }, + }, + { + skillName: "other-skill", + name: "otherTool", + description: "Other tool", + inputSchema: { type: "object" }, + }, + ]) + + const result = getLockfileExpertToolDefinitions(lockfileExpert) + + expect(result["@perstack/base"]).toHaveLength(2) + expect(result["other-skill"]).toHaveLength(1) + expect(result["@perstack/base"][0].name).toBe("readFile") + expect(result["@perstack/base"][1].name).toBe("writeFile") + expect(result["other-skill"][0].name).toBe("otherTool") + }) + + it("returns empty object for expert with no tool definitions", () => { + const lockfileExpert = createLockfileExpert([], { key: "empty-expert", name: "Empty Expert" }) + + const result = getLockfileExpertToolDefinitions(lockfileExpert) + + expect(Object.keys(result)).toHaveLength(0) + }) + + it("preserves tool definition properties", () => { + const lockfileExpert = createLockfileExpert([ + { + skillName: "test-skill", + name: "testTool", + description: "A test tool", + inputSchema: { + type: "object", + properties: { param: { type: "string" } }, + required: ["param"], + }, + }, + ]) + + const result = getLockfileExpertToolDefinitions(lockfileExpert) + + expect(result["test-skill"][0]).toEqual({ + skillName: "test-skill", + name: "testTool", + description: "A test tool", + inputSchema: { + type: "object", + properties: { param: { type: "string" } }, + required: ["param"], + }, + }) + }) + }) +}) diff --git a/packages/runtime/src/helpers/lockfile.ts b/packages/runtime/src/helpers/lockfile.ts new file mode 100644 index 00000000..a638f34b --- /dev/null +++ b/packages/runtime/src/helpers/lockfile.ts @@ -0,0 +1,30 @@ +import type { LockfileExpert } from "@perstack/core" + +export function getLockfileExpertToolDefinitions( + lockfileExpert: LockfileExpert, +): Record< + string, + { skillName: string; name: string; description?: string; inputSchema: Record }[] +> { + const result: Record< + string, + { + skillName: string + name: string + description?: string + inputSchema: Record + }[] + > = {} + for (const toolDef of lockfileExpert.toolDefinitions) { + if (!result[toolDef.skillName]) { + result[toolDef.skillName] = [] + } + result[toolDef.skillName].push({ + skillName: toolDef.skillName, + name: toolDef.name, + description: toolDef.description, + inputSchema: toolDef.inputSchema, + }) + } + return result +} diff --git a/apps/runtime/src/helpers/model.test.ts b/packages/runtime/src/helpers/model.test.ts similarity index 100% rename from apps/runtime/src/helpers/model.test.ts rename to packages/runtime/src/helpers/model.test.ts diff --git a/apps/runtime/src/helpers/model.ts b/packages/runtime/src/helpers/model.ts similarity index 100% rename from apps/runtime/src/helpers/model.ts rename to packages/runtime/src/helpers/model.ts diff --git a/apps/runtime/src/helpers/provider-adapter-factory.test.ts b/packages/runtime/src/helpers/provider-adapter-factory.test.ts similarity index 100% rename from apps/runtime/src/helpers/provider-adapter-factory.test.ts rename to packages/runtime/src/helpers/provider-adapter-factory.test.ts diff --git a/apps/runtime/src/helpers/provider-adapter-factory.ts b/packages/runtime/src/helpers/provider-adapter-factory.ts similarity index 100% rename from apps/runtime/src/helpers/provider-adapter-factory.ts rename to packages/runtime/src/helpers/provider-adapter-factory.ts diff --git a/apps/runtime/src/helpers/register-providers.ts b/packages/runtime/src/helpers/register-providers.ts similarity index 100% rename from apps/runtime/src/helpers/register-providers.ts rename to packages/runtime/src/helpers/register-providers.ts diff --git a/apps/runtime/src/helpers/resolve-expert.test.ts b/packages/runtime/src/helpers/resolve-expert.test.ts similarity index 100% rename from apps/runtime/src/helpers/resolve-expert.test.ts rename to packages/runtime/src/helpers/resolve-expert.test.ts diff --git a/apps/runtime/src/helpers/resolve-expert.ts b/packages/runtime/src/helpers/resolve-expert.ts similarity index 100% rename from apps/runtime/src/helpers/resolve-expert.ts rename to packages/runtime/src/helpers/resolve-expert.ts diff --git a/apps/runtime/src/helpers/runtime-version.test.ts b/packages/runtime/src/helpers/runtime-version.test.ts similarity index 100% rename from apps/runtime/src/helpers/runtime-version.test.ts rename to packages/runtime/src/helpers/runtime-version.test.ts diff --git a/apps/runtime/src/helpers/runtime-version.ts b/packages/runtime/src/helpers/runtime-version.ts similarity index 100% rename from apps/runtime/src/helpers/runtime-version.ts rename to packages/runtime/src/helpers/runtime-version.ts diff --git a/apps/runtime/src/helpers/setup-experts.test.ts b/packages/runtime/src/helpers/setup-experts.test.ts similarity index 100% rename from apps/runtime/src/helpers/setup-experts.test.ts rename to packages/runtime/src/helpers/setup-experts.test.ts diff --git a/apps/runtime/src/helpers/setup-experts.ts b/packages/runtime/src/helpers/setup-experts.ts similarity index 100% rename from apps/runtime/src/helpers/setup-experts.ts rename to packages/runtime/src/helpers/setup-experts.ts diff --git a/apps/runtime/src/helpers/thinking.test.ts b/packages/runtime/src/helpers/thinking.test.ts similarity index 100% rename from apps/runtime/src/helpers/thinking.test.ts rename to packages/runtime/src/helpers/thinking.test.ts diff --git a/apps/runtime/src/helpers/thinking.ts b/packages/runtime/src/helpers/thinking.ts similarity index 100% rename from apps/runtime/src/helpers/thinking.ts rename to packages/runtime/src/helpers/thinking.ts diff --git a/apps/runtime/src/helpers/usage.test.ts b/packages/runtime/src/helpers/usage.test.ts similarity index 100% rename from apps/runtime/src/helpers/usage.test.ts rename to packages/runtime/src/helpers/usage.test.ts diff --git a/apps/runtime/src/helpers/usage.ts b/packages/runtime/src/helpers/usage.ts similarity index 100% rename from apps/runtime/src/helpers/usage.ts rename to packages/runtime/src/helpers/usage.ts diff --git a/apps/runtime/src/index.ts b/packages/runtime/src/index.ts similarity index 79% rename from apps/runtime/src/index.ts rename to packages/runtime/src/index.ts index 2d8ca7c4..d35c4861 100755 --- a/apps/runtime/src/index.ts +++ b/packages/runtime/src/index.ts @@ -1,6 +1,6 @@ import pkg from "../package.json" with { type: "json" } -export { findLockfile, getLockfileExpertToolDefinitions, loadLockfile } from "./helpers/index.js" +export { getLockfileExpertToolDefinitions } from "./helpers/index.js" export { type RunOptions, run } from "./run.js" export { type CollectedToolDefinition, diff --git a/apps/runtime/src/llm/executor.test.ts b/packages/runtime/src/llm/executor.test.ts similarity index 100% rename from apps/runtime/src/llm/executor.test.ts rename to packages/runtime/src/llm/executor.test.ts diff --git a/apps/runtime/src/llm/executor.ts b/packages/runtime/src/llm/executor.ts similarity index 100% rename from apps/runtime/src/llm/executor.ts rename to packages/runtime/src/llm/executor.ts diff --git a/apps/runtime/src/llm/index.ts b/packages/runtime/src/llm/index.ts similarity index 100% rename from apps/runtime/src/llm/index.ts rename to packages/runtime/src/llm/index.ts diff --git a/apps/runtime/src/llm/mock-executor.test.ts b/packages/runtime/src/llm/mock-executor.test.ts similarity index 100% rename from apps/runtime/src/llm/mock-executor.test.ts rename to packages/runtime/src/llm/mock-executor.test.ts diff --git a/apps/runtime/src/llm/mock-executor.ts b/packages/runtime/src/llm/mock-executor.ts similarity index 100% rename from apps/runtime/src/llm/mock-executor.ts rename to packages/runtime/src/llm/mock-executor.ts diff --git a/apps/runtime/src/llm/types.ts b/packages/runtime/src/llm/types.ts similarity index 100% rename from apps/runtime/src/llm/types.ts rename to packages/runtime/src/llm/types.ts diff --git a/apps/runtime/src/messages/instruction-message.ts b/packages/runtime/src/messages/instruction-message.ts similarity index 100% rename from apps/runtime/src/messages/instruction-message.ts rename to packages/runtime/src/messages/instruction-message.ts diff --git a/apps/runtime/src/messages/message.test.ts b/packages/runtime/src/messages/message.test.ts similarity index 100% rename from apps/runtime/src/messages/message.test.ts rename to packages/runtime/src/messages/message.test.ts diff --git a/apps/runtime/src/messages/message.ts b/packages/runtime/src/messages/message.ts similarity index 100% rename from apps/runtime/src/messages/message.ts rename to packages/runtime/src/messages/message.ts diff --git a/apps/runtime/src/orchestration/delegation-strategy.test.ts b/packages/runtime/src/orchestration/delegation-strategy.test.ts similarity index 100% rename from apps/runtime/src/orchestration/delegation-strategy.test.ts rename to packages/runtime/src/orchestration/delegation-strategy.test.ts diff --git a/apps/runtime/src/orchestration/delegation-strategy.ts b/packages/runtime/src/orchestration/delegation-strategy.ts similarity index 100% rename from apps/runtime/src/orchestration/delegation-strategy.ts rename to packages/runtime/src/orchestration/delegation-strategy.ts diff --git a/apps/runtime/src/orchestration/index.ts b/packages/runtime/src/orchestration/index.ts similarity index 100% rename from apps/runtime/src/orchestration/index.ts rename to packages/runtime/src/orchestration/index.ts diff --git a/apps/runtime/src/orchestration/single-run-executor.test.ts b/packages/runtime/src/orchestration/single-run-executor.test.ts similarity index 100% rename from apps/runtime/src/orchestration/single-run-executor.test.ts rename to packages/runtime/src/orchestration/single-run-executor.test.ts diff --git a/apps/runtime/src/orchestration/single-run-executor.ts b/packages/runtime/src/orchestration/single-run-executor.ts similarity index 100% rename from apps/runtime/src/orchestration/single-run-executor.ts rename to packages/runtime/src/orchestration/single-run-executor.ts diff --git a/apps/runtime/src/run.test.ts b/packages/runtime/src/run.test.ts similarity index 100% rename from apps/runtime/src/run.test.ts rename to packages/runtime/src/run.test.ts diff --git a/apps/runtime/src/run.ts b/packages/runtime/src/run.ts similarity index 100% rename from apps/runtime/src/run.ts rename to packages/runtime/src/run.ts diff --git a/apps/runtime/src/skill-manager/base.ts b/packages/runtime/src/skill-manager/base.ts similarity index 100% rename from apps/runtime/src/skill-manager/base.ts rename to packages/runtime/src/skill-manager/base.ts diff --git a/apps/runtime/src/skill-manager/command-args.test.ts b/packages/runtime/src/skill-manager/command-args.test.ts similarity index 100% rename from apps/runtime/src/skill-manager/command-args.test.ts rename to packages/runtime/src/skill-manager/command-args.test.ts diff --git a/apps/runtime/src/skill-manager/command-args.ts b/packages/runtime/src/skill-manager/command-args.ts similarity index 100% rename from apps/runtime/src/skill-manager/command-args.ts rename to packages/runtime/src/skill-manager/command-args.ts diff --git a/apps/runtime/src/skill-manager/delegate.test.ts b/packages/runtime/src/skill-manager/delegate.test.ts similarity index 100% rename from apps/runtime/src/skill-manager/delegate.test.ts rename to packages/runtime/src/skill-manager/delegate.test.ts diff --git a/apps/runtime/src/skill-manager/delegate.ts b/packages/runtime/src/skill-manager/delegate.ts similarity index 100% rename from apps/runtime/src/skill-manager/delegate.ts rename to packages/runtime/src/skill-manager/delegate.ts diff --git a/apps/runtime/src/skill-manager/helpers.test.ts b/packages/runtime/src/skill-manager/helpers.test.ts similarity index 100% rename from apps/runtime/src/skill-manager/helpers.test.ts rename to packages/runtime/src/skill-manager/helpers.test.ts diff --git a/apps/runtime/src/skill-manager/helpers.ts b/packages/runtime/src/skill-manager/helpers.ts similarity index 100% rename from apps/runtime/src/skill-manager/helpers.ts rename to packages/runtime/src/skill-manager/helpers.ts diff --git a/apps/runtime/src/skill-manager/in-memory-base.test.ts b/packages/runtime/src/skill-manager/in-memory-base.test.ts similarity index 100% rename from apps/runtime/src/skill-manager/in-memory-base.test.ts rename to packages/runtime/src/skill-manager/in-memory-base.test.ts diff --git a/apps/runtime/src/skill-manager/in-memory-base.ts b/packages/runtime/src/skill-manager/in-memory-base.ts similarity index 100% rename from apps/runtime/src/skill-manager/in-memory-base.ts rename to packages/runtime/src/skill-manager/in-memory-base.ts diff --git a/apps/runtime/src/skill-manager/index.ts b/packages/runtime/src/skill-manager/index.ts similarity index 100% rename from apps/runtime/src/skill-manager/index.ts rename to packages/runtime/src/skill-manager/index.ts diff --git a/apps/runtime/src/skill-manager/interactive.test.ts b/packages/runtime/src/skill-manager/interactive.test.ts similarity index 100% rename from apps/runtime/src/skill-manager/interactive.test.ts rename to packages/runtime/src/skill-manager/interactive.test.ts diff --git a/apps/runtime/src/skill-manager/interactive.ts b/packages/runtime/src/skill-manager/interactive.ts similarity index 100% rename from apps/runtime/src/skill-manager/interactive.ts rename to packages/runtime/src/skill-manager/interactive.ts diff --git a/apps/runtime/src/skill-manager/ip-validator.test.ts b/packages/runtime/src/skill-manager/ip-validator.test.ts similarity index 100% rename from apps/runtime/src/skill-manager/ip-validator.test.ts rename to packages/runtime/src/skill-manager/ip-validator.test.ts diff --git a/apps/runtime/src/skill-manager/ip-validator.ts b/packages/runtime/src/skill-manager/ip-validator.ts similarity index 100% rename from apps/runtime/src/skill-manager/ip-validator.ts rename to packages/runtime/src/skill-manager/ip-validator.ts diff --git a/apps/runtime/src/skill-manager/lockfile-skill-manager.test.ts b/packages/runtime/src/skill-manager/lockfile-skill-manager.test.ts similarity index 100% rename from apps/runtime/src/skill-manager/lockfile-skill-manager.test.ts rename to packages/runtime/src/skill-manager/lockfile-skill-manager.test.ts diff --git a/apps/runtime/src/skill-manager/lockfile-skill-manager.ts b/packages/runtime/src/skill-manager/lockfile-skill-manager.ts similarity index 100% rename from apps/runtime/src/skill-manager/lockfile-skill-manager.ts rename to packages/runtime/src/skill-manager/lockfile-skill-manager.ts diff --git a/apps/runtime/src/skill-manager/mcp-converters.test.ts b/packages/runtime/src/skill-manager/mcp-converters.test.ts similarity index 100% rename from apps/runtime/src/skill-manager/mcp-converters.test.ts rename to packages/runtime/src/skill-manager/mcp-converters.test.ts diff --git a/apps/runtime/src/skill-manager/mcp-converters.ts b/packages/runtime/src/skill-manager/mcp-converters.ts similarity index 100% rename from apps/runtime/src/skill-manager/mcp-converters.ts rename to packages/runtime/src/skill-manager/mcp-converters.ts diff --git a/apps/runtime/src/skill-manager/mcp.test.ts b/packages/runtime/src/skill-manager/mcp.test.ts similarity index 100% rename from apps/runtime/src/skill-manager/mcp.test.ts rename to packages/runtime/src/skill-manager/mcp.test.ts diff --git a/apps/runtime/src/skill-manager/mcp.ts b/packages/runtime/src/skill-manager/mcp.ts similarity index 100% rename from apps/runtime/src/skill-manager/mcp.ts rename to packages/runtime/src/skill-manager/mcp.ts diff --git a/apps/runtime/src/skill-manager/skill-manager-factory.test.ts b/packages/runtime/src/skill-manager/skill-manager-factory.test.ts similarity index 100% rename from apps/runtime/src/skill-manager/skill-manager-factory.test.ts rename to packages/runtime/src/skill-manager/skill-manager-factory.test.ts diff --git a/apps/runtime/src/skill-manager/skill-manager-factory.ts b/packages/runtime/src/skill-manager/skill-manager-factory.ts similarity index 100% rename from apps/runtime/src/skill-manager/skill-manager-factory.ts rename to packages/runtime/src/skill-manager/skill-manager-factory.ts diff --git a/apps/runtime/src/skill-manager/transport-factory.test.ts b/packages/runtime/src/skill-manager/transport-factory.test.ts similarity index 100% rename from apps/runtime/src/skill-manager/transport-factory.test.ts rename to packages/runtime/src/skill-manager/transport-factory.test.ts diff --git a/apps/runtime/src/skill-manager/transport-factory.ts b/packages/runtime/src/skill-manager/transport-factory.ts similarity index 100% rename from apps/runtime/src/skill-manager/transport-factory.ts rename to packages/runtime/src/skill-manager/transport-factory.ts diff --git a/apps/runtime/src/state-machine/actor-factory.test.ts b/packages/runtime/src/state-machine/actor-factory.test.ts similarity index 100% rename from apps/runtime/src/state-machine/actor-factory.test.ts rename to packages/runtime/src/state-machine/actor-factory.test.ts diff --git a/apps/runtime/src/state-machine/actor-factory.ts b/packages/runtime/src/state-machine/actor-factory.ts similarity index 100% rename from apps/runtime/src/state-machine/actor-factory.ts rename to packages/runtime/src/state-machine/actor-factory.ts diff --git a/apps/runtime/src/state-machine/coordinator.test.ts b/packages/runtime/src/state-machine/coordinator.test.ts similarity index 100% rename from apps/runtime/src/state-machine/coordinator.test.ts rename to packages/runtime/src/state-machine/coordinator.test.ts diff --git a/apps/runtime/src/state-machine/coordinator.ts b/packages/runtime/src/state-machine/coordinator.ts similarity index 100% rename from apps/runtime/src/state-machine/coordinator.ts rename to packages/runtime/src/state-machine/coordinator.ts diff --git a/apps/runtime/src/state-machine/executor.test.ts b/packages/runtime/src/state-machine/executor.test.ts similarity index 100% rename from apps/runtime/src/state-machine/executor.test.ts rename to packages/runtime/src/state-machine/executor.test.ts diff --git a/apps/runtime/src/state-machine/executor.ts b/packages/runtime/src/state-machine/executor.ts similarity index 100% rename from apps/runtime/src/state-machine/executor.ts rename to packages/runtime/src/state-machine/executor.ts diff --git a/apps/runtime/src/state-machine/index.ts b/packages/runtime/src/state-machine/index.ts similarity index 100% rename from apps/runtime/src/state-machine/index.ts rename to packages/runtime/src/state-machine/index.ts diff --git a/apps/runtime/src/state-machine/machine.ts b/packages/runtime/src/state-machine/machine.ts similarity index 100% rename from apps/runtime/src/state-machine/machine.ts rename to packages/runtime/src/state-machine/machine.ts diff --git a/apps/runtime/src/state-machine/states/calling-delegates.test.ts b/packages/runtime/src/state-machine/states/calling-delegates.test.ts similarity index 100% rename from apps/runtime/src/state-machine/states/calling-delegates.test.ts rename to packages/runtime/src/state-machine/states/calling-delegates.test.ts diff --git a/apps/runtime/src/state-machine/states/calling-delegates.ts b/packages/runtime/src/state-machine/states/calling-delegates.ts similarity index 100% rename from apps/runtime/src/state-machine/states/calling-delegates.ts rename to packages/runtime/src/state-machine/states/calling-delegates.ts diff --git a/apps/runtime/src/state-machine/states/calling-interactive-tools.test.ts b/packages/runtime/src/state-machine/states/calling-interactive-tools.test.ts similarity index 100% rename from apps/runtime/src/state-machine/states/calling-interactive-tools.test.ts rename to packages/runtime/src/state-machine/states/calling-interactive-tools.test.ts diff --git a/apps/runtime/src/state-machine/states/calling-interactive-tools.ts b/packages/runtime/src/state-machine/states/calling-interactive-tools.ts similarity index 100% rename from apps/runtime/src/state-machine/states/calling-interactive-tools.ts rename to packages/runtime/src/state-machine/states/calling-interactive-tools.ts diff --git a/apps/runtime/src/state-machine/states/calling-mcp-tools.test.ts b/packages/runtime/src/state-machine/states/calling-mcp-tools.test.ts similarity index 100% rename from apps/runtime/src/state-machine/states/calling-mcp-tools.test.ts rename to packages/runtime/src/state-machine/states/calling-mcp-tools.test.ts diff --git a/apps/runtime/src/state-machine/states/calling-mcp-tools.ts b/packages/runtime/src/state-machine/states/calling-mcp-tools.ts similarity index 100% rename from apps/runtime/src/state-machine/states/calling-mcp-tools.ts rename to packages/runtime/src/state-machine/states/calling-mcp-tools.ts diff --git a/apps/runtime/src/state-machine/states/finishing-step.test.ts b/packages/runtime/src/state-machine/states/finishing-step.test.ts similarity index 100% rename from apps/runtime/src/state-machine/states/finishing-step.test.ts rename to packages/runtime/src/state-machine/states/finishing-step.test.ts diff --git a/apps/runtime/src/state-machine/states/finishing-step.ts b/packages/runtime/src/state-machine/states/finishing-step.ts similarity index 100% rename from apps/runtime/src/state-machine/states/finishing-step.ts rename to packages/runtime/src/state-machine/states/finishing-step.ts diff --git a/apps/runtime/src/state-machine/states/generating-run-result.test.ts b/packages/runtime/src/state-machine/states/generating-run-result.test.ts similarity index 100% rename from apps/runtime/src/state-machine/states/generating-run-result.test.ts rename to packages/runtime/src/state-machine/states/generating-run-result.test.ts diff --git a/apps/runtime/src/state-machine/states/generating-run-result.ts b/packages/runtime/src/state-machine/states/generating-run-result.ts similarity index 100% rename from apps/runtime/src/state-machine/states/generating-run-result.ts rename to packages/runtime/src/state-machine/states/generating-run-result.ts diff --git a/apps/runtime/src/state-machine/states/generating-tool-call.test.ts b/packages/runtime/src/state-machine/states/generating-tool-call.test.ts similarity index 100% rename from apps/runtime/src/state-machine/states/generating-tool-call.test.ts rename to packages/runtime/src/state-machine/states/generating-tool-call.test.ts diff --git a/apps/runtime/src/state-machine/states/generating-tool-call.ts b/packages/runtime/src/state-machine/states/generating-tool-call.ts similarity index 100% rename from apps/runtime/src/state-machine/states/generating-tool-call.ts rename to packages/runtime/src/state-machine/states/generating-tool-call.ts diff --git a/apps/runtime/src/state-machine/states/init.test.ts b/packages/runtime/src/state-machine/states/init.test.ts similarity index 100% rename from apps/runtime/src/state-machine/states/init.test.ts rename to packages/runtime/src/state-machine/states/init.test.ts diff --git a/apps/runtime/src/state-machine/states/init.ts b/packages/runtime/src/state-machine/states/init.ts similarity index 100% rename from apps/runtime/src/state-machine/states/init.ts rename to packages/runtime/src/state-machine/states/init.ts diff --git a/apps/runtime/src/state-machine/states/preparing-for-step.test.ts b/packages/runtime/src/state-machine/states/preparing-for-step.test.ts similarity index 100% rename from apps/runtime/src/state-machine/states/preparing-for-step.test.ts rename to packages/runtime/src/state-machine/states/preparing-for-step.test.ts diff --git a/apps/runtime/src/state-machine/states/preparing-for-step.ts b/packages/runtime/src/state-machine/states/preparing-for-step.ts similarity index 100% rename from apps/runtime/src/state-machine/states/preparing-for-step.ts rename to packages/runtime/src/state-machine/states/preparing-for-step.ts diff --git a/apps/runtime/src/state-machine/states/resolving-tool-result.test.ts b/packages/runtime/src/state-machine/states/resolving-tool-result.test.ts similarity index 100% rename from apps/runtime/src/state-machine/states/resolving-tool-result.test.ts rename to packages/runtime/src/state-machine/states/resolving-tool-result.test.ts diff --git a/apps/runtime/src/state-machine/states/resolving-tool-result.ts b/packages/runtime/src/state-machine/states/resolving-tool-result.ts similarity index 100% rename from apps/runtime/src/state-machine/states/resolving-tool-result.ts rename to packages/runtime/src/state-machine/states/resolving-tool-result.ts diff --git a/apps/runtime/src/state-machine/states/resuming-from-stop.test.ts b/packages/runtime/src/state-machine/states/resuming-from-stop.test.ts similarity index 100% rename from apps/runtime/src/state-machine/states/resuming-from-stop.test.ts rename to packages/runtime/src/state-machine/states/resuming-from-stop.test.ts diff --git a/apps/runtime/src/state-machine/states/resuming-from-stop.ts b/packages/runtime/src/state-machine/states/resuming-from-stop.ts similarity index 100% rename from apps/runtime/src/state-machine/states/resuming-from-stop.ts rename to packages/runtime/src/state-machine/states/resuming-from-stop.ts diff --git a/apps/runtime/src/tool-execution/executor-factory.test.ts b/packages/runtime/src/tool-execution/executor-factory.test.ts similarity index 100% rename from apps/runtime/src/tool-execution/executor-factory.test.ts rename to packages/runtime/src/tool-execution/executor-factory.test.ts diff --git a/apps/runtime/src/tool-execution/executor-factory.ts b/packages/runtime/src/tool-execution/executor-factory.ts similarity index 100% rename from apps/runtime/src/tool-execution/executor-factory.ts rename to packages/runtime/src/tool-execution/executor-factory.ts diff --git a/apps/runtime/src/tool-execution/index.ts b/packages/runtime/src/tool-execution/index.ts similarity index 100% rename from apps/runtime/src/tool-execution/index.ts rename to packages/runtime/src/tool-execution/index.ts diff --git a/apps/runtime/src/tool-execution/mcp-executor.test.ts b/packages/runtime/src/tool-execution/mcp-executor.test.ts similarity index 100% rename from apps/runtime/src/tool-execution/mcp-executor.test.ts rename to packages/runtime/src/tool-execution/mcp-executor.test.ts diff --git a/apps/runtime/src/tool-execution/mcp-executor.ts b/packages/runtime/src/tool-execution/mcp-executor.ts similarity index 100% rename from apps/runtime/src/tool-execution/mcp-executor.ts rename to packages/runtime/src/tool-execution/mcp-executor.ts diff --git a/apps/runtime/src/tool-execution/tool-classifier.test.ts b/packages/runtime/src/tool-execution/tool-classifier.test.ts similarity index 100% rename from apps/runtime/src/tool-execution/tool-classifier.test.ts rename to packages/runtime/src/tool-execution/tool-classifier.test.ts diff --git a/apps/runtime/src/tool-execution/tool-classifier.ts b/packages/runtime/src/tool-execution/tool-classifier.ts similarity index 100% rename from apps/runtime/src/tool-execution/tool-classifier.ts rename to packages/runtime/src/tool-execution/tool-classifier.ts diff --git a/apps/runtime/src/tool-execution/tool-executor.ts b/packages/runtime/src/tool-execution/tool-executor.ts similarity index 100% rename from apps/runtime/src/tool-execution/tool-executor.ts rename to packages/runtime/src/tool-execution/tool-executor.ts diff --git a/apps/runtime/test/run-params.ts b/packages/runtime/test/run-params.ts similarity index 100% rename from apps/runtime/test/run-params.ts rename to packages/runtime/test/run-params.ts diff --git a/apps/runtime/tsconfig.json b/packages/runtime/tsconfig.json similarity index 100% rename from apps/runtime/tsconfig.json rename to packages/runtime/tsconfig.json diff --git a/apps/runtime/tsup.config.ts b/packages/runtime/tsup.config.ts similarity index 89% rename from apps/runtime/tsup.config.ts rename to packages/runtime/tsup.config.ts index 16f966a4..61aa6555 100644 --- a/apps/runtime/tsup.config.ts +++ b/packages/runtime/tsup.config.ts @@ -4,7 +4,6 @@ import { baseConfig } from "../../tsup.config.js" export const runtimeConfig: Options = { ...baseConfig, entry: { - "bin/cli": "bin/cli.ts", "src/index": "src/index.ts", }, } diff --git a/packages/tui/package.json b/packages/tui/package.json index 043d7bd8..5499f5b6 100644 --- a/packages/tui/package.json +++ b/packages/tui/package.json @@ -29,6 +29,7 @@ "@perstack/runtime": "workspace:*", "@tsconfig/node22": "^22.0.5", "@types/node": "^25.2.3", + "memfs": "^4.56.10", "tsup": "^8.5.1", "typescript": "^5.9.3", "vitest": "^4.0.18" diff --git a/apps/runtime/src/cli/context.test.ts b/packages/tui/src/lib/context.test.ts similarity index 90% rename from apps/runtime/src/cli/context.test.ts rename to packages/tui/src/lib/context.test.ts index 41b9ec8c..923f16d4 100644 --- a/apps/runtime/src/cli/context.test.ts +++ b/packages/tui/src/lib/context.test.ts @@ -7,7 +7,12 @@ vi.mock("node:fs/promises", async () => { return memfs.fs.promises }) -describe("@perstack/runtime: resolveRunContext", () => { +vi.mock("./run-manager.js", () => ({ + getCheckpointById: vi.fn(), + getMostRecentCheckpoint: vi.fn(), +})) + +describe("resolveRunContext", () => { const originalEnv = { ...process.env } beforeEach(() => { @@ -149,7 +154,6 @@ version = "2.0.0" const context = await resolveRunContext({}) expect(context.experts["my-expert"]).toBeDefined() - expect(context.experts["my-expert"].key).toBe("my-expert") expect(context.experts["my-expert"].name).toBe("my-expert") expect(context.experts["my-expert"].version).toBe("2.0.0") }) @@ -207,4 +211,19 @@ instruction = "Test" // This allows expertSchema's default value (@perstack/base) to be applied expect(context.experts.test.skills).toBeUndefined() }) + + it("throws when --resume-from is used without --continue-job", async () => { + const tomlContent = ` +[experts.test] +description = "Test" +instruction = "Test" +` + vol.fromJSON({ + "/test/perstack.toml": tomlContent, + }) + + await expect(resolveRunContext({ resumeFrom: "checkpoint-123" })).rejects.toThrow( + "--resume-from requires --continue-job", + ) + }) }) diff --git a/packages/tui/src/lib/context.ts b/packages/tui/src/lib/context.ts index 8fcc86bb..6a248850 100644 --- a/packages/tui/src/lib/context.ts +++ b/packages/tui/src/lib/context.ts @@ -71,6 +71,7 @@ export async function resolveRunContext(input: ResolveRunContextInput): Promise< { name, version: expert.version ?? "1.0.0", + minRuntimeVersion: expert.minRuntimeVersion, description: expert.description, instruction: expert.instruction, skills: expert.skills, diff --git a/apps/runtime/src/cli/get-env.test.ts b/packages/tui/src/lib/get-env.test.ts similarity index 97% rename from apps/runtime/src/cli/get-env.test.ts rename to packages/tui/src/lib/get-env.test.ts index c2179ac7..6dbd1229 100644 --- a/apps/runtime/src/cli/get-env.test.ts +++ b/packages/tui/src/lib/get-env.test.ts @@ -1,7 +1,7 @@ import { afterEach, beforeEach, describe, expect, it } from "vitest" import { getEnv } from "./get-env.js" -describe("@perstack/runtime: getEnv", () => { +describe("getEnv", () => { const originalEnv = { ...process.env } beforeEach(() => { diff --git a/packages/tui/src/lib/perstack-toml.test.ts b/packages/tui/src/lib/perstack-toml.test.ts index 450a52f8..ba2c3f65 100644 --- a/packages/tui/src/lib/perstack-toml.test.ts +++ b/packages/tui/src/lib/perstack-toml.test.ts @@ -1,66 +1,199 @@ -import { afterEach, describe, expect, it, vi } from "vitest" +import { vol } from "memfs" +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest" import { getPerstackConfig } from "./perstack-toml.js" -describe("getPerstackConfig with remote URL", () => { +vi.mock("node:fs/promises", async () => { + const memfs = await import("memfs") + return memfs.fs.promises +}) + +describe("getPerstackConfig", () => { + beforeEach(() => { + vol.reset() + vi.spyOn(process, "cwd").mockReturnValue("/test") + }) + afterEach(() => { vi.restoreAllMocks() }) - it("should fetch config from raw.githubusercontent.com with redirect disabled", async () => { - const mockToml = ` + + describe("local config", () => { + it("parses valid perstack.toml from specified path", async () => { + const tomlContent = ` +[experts.test] +description = "Test expert" +instruction = "Do testing" +` + vol.fromJSON({ + "/test/custom.toml": tomlContent, + }) + + const config = await getPerstackConfig("custom.toml") + + expect(config.experts?.test).toBeDefined() + expect(config.experts?.test?.description).toBe("Test expert") + expect(config.experts?.test?.instruction).toBe("Do testing") + }) + + it("finds perstack.toml in current directory", async () => { + const tomlContent = ` +[experts.main] +description = "Main expert" +instruction = "Main instruction" +` + vol.fromJSON({ + "/test/perstack.toml": tomlContent, + }) + + const config = await getPerstackConfig() + + expect(config.experts?.main).toBeDefined() + }) + + it("throws when config path not found", async () => { + vol.fromJSON({}) + + await expect(getPerstackConfig("nonexistent.toml")).rejects.toThrow( + 'Given config path "nonexistent.toml" is not found', + ) + }) + + it("throws when perstack.toml not found anywhere", async () => { + vol.fromJSON({}) + vi.spyOn(process, "cwd").mockReturnValue("/") + + await expect(getPerstackConfig()).rejects.toThrow( + "perstack.toml not found. Create one or specify --config path.", + ) + }) + + it("parses skills configuration", async () => { + const tomlContent = ` +[experts.test] +description = "Test" +instruction = "Test" + +[experts.test.skills.base] +type = "mcpStdioSkill" +command = "npx" +packageName = "@perstack/base" +` + vol.fromJSON({ + "/test/perstack.toml": tomlContent, + }) + + const config = await getPerstackConfig() + + expect(config.experts?.test?.skills?.base).toBeDefined() + expect(config.experts?.test?.skills?.base?.type).toBe("mcpStdioSkill") + }) + + it("parses envPath configuration", async () => { + const tomlContent = ` +envPath = [".env.custom"] + +[experts.test] +description = "Test" +instruction = "Test" +` + vol.fromJSON({ + "/test/perstack.toml": tomlContent, + }) + + const config = await getPerstackConfig() + + expect(config.envPath).toEqual([".env.custom"]) + }) + + it("parses provider configuration", async () => { + const tomlContent = ` +[provider] +providerName = "openai" + +[provider.setting] +baseUrl = "https://custom.openai.com" + +[experts.test] +description = "Test" +instruction = "Test" +` + vol.fromJSON({ + "/test/perstack.toml": tomlContent, + }) + + const config = await getPerstackConfig() + + expect(config.provider?.providerName).toBe("openai") + expect((config.provider?.setting as { baseUrl?: string })?.baseUrl).toBe( + "https://custom.openai.com", + ) + }) + }) + + describe("remote config", () => { + it("should fetch config from raw.githubusercontent.com with redirect disabled", async () => { + const mockToml = ` [experts."test-expert"] instruction = "Test instruction" ` - const mockFetch = vi.fn().mockResolvedValue({ - ok: true, - text: () => Promise.resolve(mockToml), + const mockFetch = vi.fn().mockResolvedValue({ + ok: true, + text: () => Promise.resolve(mockToml), + }) + vi.stubGlobal("fetch", mockFetch) + const config = await getPerstackConfig( + "https://raw.githubusercontent.com/owner/repo/main/perstack.toml", + ) + expect(mockFetch).toHaveBeenCalledWith( + "https://raw.githubusercontent.com/owner/repo/main/perstack.toml", + { redirect: "error" }, + ) + expect(config.experts?.["test-expert"]).toBeDefined() + expect(config.experts?.["test-expert"]?.instruction).toBe("Test instruction") }) - vi.stubGlobal("fetch", mockFetch) - const config = await getPerstackConfig( - "https://raw.githubusercontent.com/owner/repo/main/perstack.toml", - ) - expect(mockFetch).toHaveBeenCalledWith( - "https://raw.githubusercontent.com/owner/repo/main/perstack.toml", - { redirect: "error" }, - ) - expect(config.experts?.["test-expert"]).toBeDefined() - expect(config.experts?.["test-expert"]?.instruction).toBe("Test instruction") - }) - it("should reject URLs from disallowed domains", async () => { - await expect(getPerstackConfig("https://example.com/perstack.toml")).rejects.toThrow( - "Remote config only allowed from: raw.githubusercontent.com", - ) - }) - it("should reject http URLs with clear error message", async () => { - await expect(getPerstackConfig("http://example.com/perstack.toml")).rejects.toThrow( - "Remote config requires HTTPS", - ) - }) - it("should throw error when fetch fails", async () => { - const mockFetch = vi.fn().mockResolvedValue({ - ok: false, - status: 404, - statusText: "Not Found", + + it("rejects non-HTTPS URLs", async () => { + await expect(getPerstackConfig("http://example.com/config.toml")).rejects.toThrow( + "Remote config requires HTTPS", + ) + }) + + it("rejects disallowed hosts", async () => { + await expect(getPerstackConfig("https://example.com/config.toml")).rejects.toThrow( + "Remote config only allowed from:", + ) + }) + + it("should throw error when fetch fails", async () => { + const mockFetch = vi.fn().mockResolvedValue({ + ok: false, + status: 404, + statusText: "Not Found", + }) + vi.stubGlobal("fetch", mockFetch) + await expect( + getPerstackConfig("https://raw.githubusercontent.com/owner/repo/main/perstack.toml"), + ).rejects.toThrow("Failed to fetch remote config: 404 Not Found") + }) + + it("should throw friendly error for network failures", async () => { + const mockFetch = vi.fn().mockRejectedValue(new Error("Network error")) + vi.stubGlobal("fetch", mockFetch) + await expect( + getPerstackConfig("https://raw.githubusercontent.com/owner/repo/main/perstack.toml"), + ).rejects.toThrow("Failed to fetch remote config: Network error") + }) + + it("should handle uppercase URL schemes (case-insensitive)", async () => { + await expect(getPerstackConfig("HTTPS://example.com/perstack.toml")).rejects.toThrow( + "Remote config only allowed from: raw.githubusercontent.com", + ) + }) + + it("should reject uppercase HTTP URLs", async () => { + await expect(getPerstackConfig("HTTP://example.com/perstack.toml")).rejects.toThrow( + "Remote config requires HTTPS", + ) }) - vi.stubGlobal("fetch", mockFetch) - await expect( - getPerstackConfig("https://raw.githubusercontent.com/owner/repo/main/perstack.toml"), - ).rejects.toThrow("Failed to fetch remote config: 404 Not Found") - }) - it("should throw friendly error for network failures", async () => { - const mockFetch = vi.fn().mockRejectedValue(new Error("Network error")) - vi.stubGlobal("fetch", mockFetch) - await expect( - getPerstackConfig("https://raw.githubusercontent.com/owner/repo/main/perstack.toml"), - ).rejects.toThrow("Failed to fetch remote config: Network error") - }) - it("should handle uppercase URL schemes (case-insensitive)", async () => { - await expect(getPerstackConfig("HTTPS://example.com/perstack.toml")).rejects.toThrow( - "Remote config only allowed from: raw.githubusercontent.com", - ) - }) - it("should reject uppercase HTTP URLs", async () => { - await expect(getPerstackConfig("HTTP://example.com/perstack.toml")).rejects.toThrow( - "Remote config requires HTTPS", - ) }) }) diff --git a/apps/runtime/src/cli/provider-config.test.ts b/packages/tui/src/lib/provider-config.test.ts similarity index 99% rename from apps/runtime/src/cli/provider-config.test.ts rename to packages/tui/src/lib/provider-config.test.ts index d6fddddb..07e858d1 100644 --- a/apps/runtime/src/cli/provider-config.test.ts +++ b/packages/tui/src/lib/provider-config.test.ts @@ -5,7 +5,7 @@ import { getProviderConfig } from "./provider-config.js" type ConfigWithApiKey = { apiKey: string } type ConfigWithBaseUrl = { baseUrl?: string } -describe("@perstack/runtime: getProviderConfig", () => { +describe("getProviderConfig", () => { describe("anthropic", () => { it("returns anthropic config with API key", () => { const env = { ANTHROPIC_API_KEY: "test-key" } diff --git a/apps/runtime/src/helpers/lockfile.test.ts b/packages/tui/src/lockfile.test.ts similarity index 62% rename from apps/runtime/src/helpers/lockfile.test.ts rename to packages/tui/src/lockfile.test.ts index a4b7f113..2e9a8d82 100644 --- a/apps/runtime/src/helpers/lockfile.test.ts +++ b/packages/tui/src/lockfile.test.ts @@ -1,94 +1,9 @@ import fs from "node:fs" import path from "node:path" -import type { LockfileExpert, LockfileToolDefinition } from "@perstack/core" import { afterEach, beforeEach, describe, expect, it, vi } from "vitest" -import { findLockfile, getLockfileExpertToolDefinitions, loadLockfile } from "./lockfile.js" - -const createLockfileExpert = ( - toolDefinitions: LockfileToolDefinition[], - overrides: Partial = {}, -): LockfileExpert => ({ - key: "test-expert", - name: "Test Expert", - version: "1.0.0", - instruction: "Test instruction", - skills: {}, - delegates: [], - tags: [], - toolDefinitions, - ...overrides, -}) +import { findLockfile, loadLockfile } from "./lockfile.js" describe("lockfile", () => { - describe("getLockfileExpertToolDefinitions", () => { - it("groups tool definitions by skill name", () => { - const lockfileExpert = createLockfileExpert([ - { - skillName: "@perstack/base", - name: "readFile", - description: "Read a file", - inputSchema: { type: "object", properties: { path: { type: "string" } } }, - }, - { - skillName: "@perstack/base", - name: "writeFile", - description: "Write a file", - inputSchema: { type: "object", properties: { path: { type: "string" } } }, - }, - { - skillName: "other-skill", - name: "otherTool", - description: "Other tool", - inputSchema: { type: "object" }, - }, - ]) - - const result = getLockfileExpertToolDefinitions(lockfileExpert) - - expect(result["@perstack/base"]).toHaveLength(2) - expect(result["other-skill"]).toHaveLength(1) - expect(result["@perstack/base"][0].name).toBe("readFile") - expect(result["@perstack/base"][1].name).toBe("writeFile") - expect(result["other-skill"][0].name).toBe("otherTool") - }) - - it("returns empty object for expert with no tool definitions", () => { - const lockfileExpert = createLockfileExpert([], { key: "empty-expert", name: "Empty Expert" }) - - const result = getLockfileExpertToolDefinitions(lockfileExpert) - - expect(Object.keys(result)).toHaveLength(0) - }) - - it("preserves tool definition properties", () => { - const lockfileExpert = createLockfileExpert([ - { - skillName: "test-skill", - name: "testTool", - description: "A test tool", - inputSchema: { - type: "object", - properties: { param: { type: "string" } }, - required: ["param"], - }, - }, - ]) - - const result = getLockfileExpertToolDefinitions(lockfileExpert) - - expect(result["test-skill"][0]).toEqual({ - skillName: "test-skill", - name: "testTool", - description: "A test tool", - inputSchema: { - type: "object", - properties: { param: { type: "string" } }, - required: ["param"], - }, - }) - }) - }) - describe("loadLockfile", () => { const testDir = path.join(process.cwd(), ".test-lockfile-temp") const testLockfilePath = path.join(testDir, "perstack.lock") diff --git a/apps/runtime/src/helpers/lockfile.ts b/packages/tui/src/lockfile.ts similarity index 59% rename from apps/runtime/src/helpers/lockfile.ts rename to packages/tui/src/lockfile.ts index 755701b4..3960a956 100644 --- a/apps/runtime/src/helpers/lockfile.ts +++ b/packages/tui/src/lockfile.ts @@ -1,11 +1,6 @@ import { readFileSync } from "node:fs" import path from "node:path" -import { - type Lockfile, - type LockfileExpert, - lockfileSchema, - parseWithFriendlyError, -} from "@perstack/core" +import { type Lockfile, lockfileSchema, parseWithFriendlyError } from "@perstack/core" import TOML from "smol-toml" export function loadLockfile(lockfilePath: string): Lockfile | null { @@ -47,32 +42,3 @@ function findLockfileRecursively(cwd: string): string | null { return findLockfileRecursively(path.dirname(cwd)) } } - -export function getLockfileExpertToolDefinitions( - lockfileExpert: LockfileExpert, -): Record< - string, - { skillName: string; name: string; description?: string; inputSchema: Record }[] -> { - const result: Record< - string, - { - skillName: string - name: string - description?: string - inputSchema: Record - }[] - > = {} - for (const toolDef of lockfileExpert.toolDefinitions) { - if (!result[toolDef.skillName]) { - result[toolDef.skillName] = [] - } - result[toolDef.skillName].push({ - skillName: toolDef.skillName, - name: toolDef.name, - description: toolDef.description, - inputSchema: toolDef.inputSchema, - }) - } - return result -} diff --git a/packages/tui/src/run-handler.ts b/packages/tui/src/run-handler.ts index 2ee58375..8a1f407a 100644 --- a/packages/tui/src/run-handler.ts +++ b/packages/tui/src/run-handler.ts @@ -14,12 +14,13 @@ import { retrieveJob, storeJob, } from "@perstack/filesystem-storage" -import { findLockfile, loadLockfile, run as perstackRun } from "@perstack/runtime" +import { run as perstackRun } from "@perstack/runtime" import { resolveRunContext } from "./lib/context.js" import { parseInteractiveToolCallResult, parseInteractiveToolCallResultJson, } from "./lib/interactive.js" +import { findLockfile, loadLockfile } from "./lockfile.js" const defaultEventListener = (event: RunEvent | RuntimeEvent) => console.log(JSON.stringify(event)) diff --git a/packages/tui/src/start-handler.ts b/packages/tui/src/start-handler.ts index b4c756b7..9ce50091 100644 --- a/packages/tui/src/start-handler.ts +++ b/packages/tui/src/start-handler.ts @@ -14,7 +14,7 @@ import { retrieveJob, storeJob, } from "@perstack/filesystem-storage" -import { findLockfile, loadLockfile, run as perstackRun, runtimeVersion } from "@perstack/runtime" +import { run as perstackRun, runtimeVersion } from "@perstack/runtime" import { type CheckpointHistoryItem, type JobHistoryItem, @@ -31,6 +31,7 @@ import { type getEventContents, getRecentExperts, } from "./lib/run-manager.js" +import { findLockfile, loadLockfile } from "./lockfile.js" const CONTINUE_TIMEOUT_MS = 60_000 diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index b3ef8157..c28a8212 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -101,7 +101,7 @@ importers: version: link:../../packages/core '@perstack/runtime': specifier: workspace:* - version: link:../runtime + version: link:../../packages/runtime commander: specifier: ^14.0.3 version: 14.0.3 @@ -208,115 +208,6 @@ importers: specifier: ^4.0.18 version: 4.0.18(@opentelemetry/api@1.9.0)(@types/node@25.2.3)(jiti@2.6.1)(jsdom@28.0.0(@noble/hashes@2.0.1))(lightningcss@1.30.2)(msw@2.12.4(@types/node@25.2.3)(typescript@5.9.3))(tsx@4.21.0)(yaml@2.8.2) - apps/runtime: - dependencies: - '@ai-sdk/amazon-bedrock': - specifier: ^4.0.60 - version: 4.0.60(zod@4.3.6) - '@ai-sdk/anthropic': - specifier: ^3.0.44 - version: 3.0.44(zod@4.3.6) - '@ai-sdk/azure': - specifier: ^3.0.30 - version: 3.0.30(zod@4.3.6) - '@ai-sdk/deepseek': - specifier: ^2.0.20 - version: 2.0.20(zod@4.3.6) - '@ai-sdk/google': - specifier: ^3.0.29 - version: 3.0.29(zod@4.3.6) - '@ai-sdk/google-vertex': - specifier: ^4.0.58 - version: 4.0.58(zod@4.3.6) - '@ai-sdk/openai': - specifier: ^3.0.29 - version: 3.0.29(zod@4.3.6) - '@modelcontextprotocol/sdk': - specifier: ^1.26.0 - version: 1.26.0(zod@4.3.6) - '@paralleldrive/cuid2': - specifier: ^3.3.0 - version: 3.3.0 - '@perstack/api-client': - specifier: ^0.0.55 - version: 0.0.55(@perstack/core@packages+core)(zod@4.3.6) - '@perstack/base': - specifier: workspace:* - version: link:../base - '@perstack/core': - specifier: workspace:* - version: link:../../packages/core - ai: - specifier: ^6.0.86 - version: 6.0.86(zod@4.3.6) - commander: - specifier: ^14.0.3 - version: 14.0.3 - dotenv: - specifier: ^17.3.1 - version: 17.3.1 - ollama-ai-provider-v2: - specifier: ^3.3.0 - version: 3.3.0(ai@6.0.86(zod@4.3.6))(zod@4.3.6) - smol-toml: - specifier: ^1.6.0 - version: 1.6.0 - ts-dedent: - specifier: ^2.2.0 - version: 2.2.0 - undici: - specifier: ^7.22.0 - version: 7.22.0 - xstate: - specifier: ^5.28.0 - version: 5.28.0 - devDependencies: - '@perstack/anthropic-provider': - specifier: workspace:* - version: link:../../packages/providers/anthropic - '@perstack/azure-openai-provider': - specifier: workspace:* - version: link:../../packages/providers/azure-openai - '@perstack/bedrock-provider': - specifier: workspace:* - version: link:../../packages/providers/bedrock - '@perstack/deepseek-provider': - specifier: workspace:* - version: link:../../packages/providers/deepseek - '@perstack/google-provider': - specifier: workspace:* - version: link:../../packages/providers/google - '@perstack/ollama-provider': - specifier: workspace:* - version: link:../../packages/providers/ollama - '@perstack/openai-provider': - specifier: workspace:* - version: link:../../packages/providers/openai - '@perstack/provider-core': - specifier: workspace:* - version: link:../../packages/providers/core - '@perstack/vertex-provider': - specifier: workspace:* - version: link:../../packages/providers/vertex - '@tsconfig/node22': - specifier: ^22.0.5 - version: 22.0.5 - '@types/node': - specifier: ^25.2.3 - version: 25.2.3 - memfs: - specifier: ^4.56.10 - version: 4.56.10(tslib@2.8.1) - tsup: - specifier: ^8.5.1 - version: 8.5.1(jiti@2.6.1)(postcss@8.5.6)(tsx@4.21.0)(typescript@5.9.3)(yaml@2.8.2) - typescript: - specifier: ^5.9.3 - version: 5.9.3 - vitest: - specifier: ^4.0.18 - version: 4.0.18(@opentelemetry/api@1.9.0)(@types/node@25.2.3)(jiti@2.6.1)(jsdom@28.0.0(@noble/hashes@2.0.1))(lightningcss@1.30.2)(msw@2.12.4(@types/node@25.2.3)(typescript@5.9.3))(tsx@4.21.0)(yaml@2.8.2) - packages/core: dependencies: '@paralleldrive/cuid2': @@ -377,7 +268,7 @@ importers: version: link:../core '@perstack/runtime': specifier: workspace:* - version: link:../../apps/runtime + version: link:../runtime smol-toml: specifier: ^1.6.0 version: 1.6.0 @@ -739,6 +630,103 @@ importers: specifier: ^4.0.18 version: 4.0.18(@opentelemetry/api@1.9.0)(@types/node@25.2.3)(jiti@2.6.1)(jsdom@28.0.0(@noble/hashes@2.0.1))(lightningcss@1.30.2)(msw@2.12.4(@types/node@25.2.3)(typescript@5.9.3))(tsx@4.21.0)(yaml@2.8.2) + packages/runtime: + dependencies: + '@ai-sdk/amazon-bedrock': + specifier: ^4.0.60 + version: 4.0.60(zod@4.3.6) + '@ai-sdk/anthropic': + specifier: ^3.0.44 + version: 3.0.44(zod@4.3.6) + '@ai-sdk/azure': + specifier: ^3.0.30 + version: 3.0.30(zod@4.3.6) + '@ai-sdk/deepseek': + specifier: ^2.0.20 + version: 2.0.20(zod@4.3.6) + '@ai-sdk/google': + specifier: ^3.0.29 + version: 3.0.29(zod@4.3.6) + '@ai-sdk/google-vertex': + specifier: ^4.0.58 + version: 4.0.58(zod@4.3.6) + '@ai-sdk/openai': + specifier: ^3.0.29 + version: 3.0.29(zod@4.3.6) + '@modelcontextprotocol/sdk': + specifier: ^1.26.0 + version: 1.26.0(zod@4.3.6) + '@paralleldrive/cuid2': + specifier: ^3.3.0 + version: 3.3.0 + '@perstack/api-client': + specifier: ^0.0.55 + version: 0.0.55(@perstack/core@packages+core)(zod@4.3.6) + '@perstack/base': + specifier: workspace:* + version: link:../../apps/base + '@perstack/core': + specifier: workspace:* + version: link:../core + ai: + specifier: ^6.0.86 + version: 6.0.86(zod@4.3.6) + ollama-ai-provider-v2: + specifier: ^3.3.0 + version: 3.3.0(ai@6.0.86(zod@4.3.6))(zod@4.3.6) + ts-dedent: + specifier: ^2.2.0 + version: 2.2.0 + undici: + specifier: ^7.22.0 + version: 7.22.0 + xstate: + specifier: ^5.28.0 + version: 5.28.0 + devDependencies: + '@perstack/anthropic-provider': + specifier: workspace:* + version: link:../providers/anthropic + '@perstack/azure-openai-provider': + specifier: workspace:* + version: link:../providers/azure-openai + '@perstack/bedrock-provider': + specifier: workspace:* + version: link:../providers/bedrock + '@perstack/deepseek-provider': + specifier: workspace:* + version: link:../providers/deepseek + '@perstack/google-provider': + specifier: workspace:* + version: link:../providers/google + '@perstack/ollama-provider': + specifier: workspace:* + version: link:../providers/ollama + '@perstack/openai-provider': + specifier: workspace:* + version: link:../providers/openai + '@perstack/provider-core': + specifier: workspace:* + version: link:../providers/core + '@perstack/vertex-provider': + specifier: workspace:* + version: link:../providers/vertex + '@tsconfig/node22': + specifier: ^22.0.5 + version: 22.0.5 + '@types/node': + specifier: ^25.2.3 + version: 25.2.3 + tsup: + specifier: ^8.5.1 + version: 8.5.1(jiti@2.6.1)(postcss@8.5.6)(tsx@4.21.0)(typescript@5.9.3)(yaml@2.8.2) + typescript: + specifier: ^5.9.3 + version: 5.9.3 + vitest: + specifier: ^4.0.18 + version: 4.0.18(@opentelemetry/api@1.9.0)(@types/node@25.2.3)(jiti@2.6.1)(jsdom@28.0.0(@noble/hashes@2.0.1))(lightningcss@1.30.2)(msw@2.12.4(@types/node@25.2.3)(typescript@5.9.3))(tsx@4.21.0)(yaml@2.8.2) + packages/tui: dependencies: '@paralleldrive/cuid2': @@ -762,13 +750,16 @@ importers: version: link:../filesystem '@perstack/runtime': specifier: workspace:* - version: link:../../apps/runtime + version: link:../runtime '@tsconfig/node22': specifier: ^22.0.5 version: 22.0.5 '@types/node': specifier: ^25.2.3 version: 25.2.3 + memfs: + specifier: ^4.56.10 + version: 4.56.10(tslib@2.8.1) tsup: specifier: ^8.5.1 version: 8.5.1(jiti@2.6.1)(postcss@8.5.6)(tsx@4.21.0)(typescript@5.9.3)(yaml@2.8.2) diff --git a/vitest.config.ts b/vitest.config.ts index e64bfc7b..1fb1800d 100644 --- a/vitest.config.ts +++ b/vitest.config.ts @@ -50,7 +50,7 @@ export default defineConfig({ "scripts/**", "demo/**", "docs/**", - "apps/runtime/src/state-machine/machine.ts", + "packages/runtime/src/state-machine/machine.ts", ], }, },