Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
21 changes: 18 additions & 3 deletions apps/runtime/bin/cli.ts
Original file line number Diff line number Diff line change
@@ -1,16 +1,20 @@
#!/usr/bin/env node

import { readFileSync } from "node:fs"
import path from "node:path"
import type { Checkpoint, RunEvent, RuntimeEvent } from "@perstack/core"
import {
createFilteredEventListener,
type Lockfile,
lockfileSchema,
parseWithFriendlyError,
runCommandInputSchema,
validateEventFilter,
} from "@perstack/core"
import { Command } from "commander"
import TOML from "smol-toml"
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))
Expand Down Expand Up @@ -93,8 +97,19 @@ program
model: input.options.model,
envPath: input.options.envPath,
})
const lockfilePath = findLockfile(input.options.config)
const lockfile = lockfilePath ? (loadLockfile(lockfilePath) ?? undefined) : undefined
let lockfile: Lockfile | undefined
try {
const lockfilePath = input.options.config
? path.join(
path.dirname(path.resolve(process.cwd(), input.options.config)),
"perstack.lock",
)
: path.resolve(process.cwd(), "perstack.lock")
const content = readFileSync(lockfilePath, "utf-8")
lockfile = parseWithFriendlyError(lockfileSchema, TOML.parse(content), "perstack.lock")
} catch {
// No lockfile found or invalid - continue without it
}
await run(
{
setting: {
Expand Down
6 changes: 1 addition & 5 deletions apps/runtime/src/helpers/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
270 changes: 55 additions & 215 deletions apps/runtime/src/helpers/lockfile.test.ts
Original file line number Diff line number Diff line change
@@ -1,8 +1,6 @@
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"
import { describe, expect, it } from "vitest"
import { getLockfileExpertToolDefinitions } from "./lockfile.js"

const createLockfileExpert = (
toolDefinitions: LockfileToolDefinition[],
Expand All @@ -19,64 +17,49 @@ const createLockfileExpert = (
...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")
})
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" })
it("returns empty object for expert with no tool definitions", () => {
const lockfileExpert = createLockfileExpert([], { key: "empty-expert", name: "Empty Expert" })

const result = getLockfileExpertToolDefinitions(lockfileExpert)
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(Object.keys(result)).toHaveLength(0)
})

expect(result["test-skill"][0]).toEqual({
it("preserves tool definition properties", () => {
const lockfileExpert = createLockfileExpert([
{
skillName: "test-skill",
name: "testTool",
description: "A test tool",
Expand All @@ -85,163 +68,20 @@ describe("lockfile", () => {
properties: { param: { type: "string" } },
required: ["param"],
},
})
})
})

describe("loadLockfile", () => {
const testDir = path.join(process.cwd(), ".test-lockfile-temp")
const testLockfilePath = path.join(testDir, "perstack.lock")

beforeEach(() => {
if (!fs.existsSync(testDir)) {
fs.mkdirSync(testDir, { recursive: true })
}
})

afterEach(() => {
vi.restoreAllMocks()
if (fs.existsSync(testDir)) {
fs.rmSync(testDir, { recursive: true, force: true })
}
})

it("returns parsed lockfile for valid TOML", () => {
const validToml = `
version = "1"
generatedAt = 1704067200000
configPath = "perstack.toml"

[experts.test-expert]
key = "test-expert"
name = "Test Expert"
version = "1.0.0"
instruction = "Test"
delegates = []
tags = []
toolDefinitions = []

[experts.test-expert.skills]
`
fs.writeFileSync(testLockfilePath, validToml)

const result = loadLockfile(testLockfilePath)

expect(result).not.toBeNull()
expect(result?.version).toBe("1")
expect(result?.configPath).toBe("perstack.toml")
expect(Object.keys(result?.experts ?? {})).toHaveLength(1)
})

it("returns null for invalid TOML", () => {
fs.writeFileSync(testLockfilePath, "invalid { toml content")

const result = loadLockfile(testLockfilePath)

expect(result).toBeNull()
})

it("returns null for non-existent file", () => {
const result = loadLockfile("/non/existent/path/perstack.lock")

expect(result).toBeNull()
})

it("returns null for TOML that doesn't match schema", () => {
fs.writeFileSync(testLockfilePath, "[invalid]\nkey = 'value'")

const result = loadLockfile(testLockfilePath)

expect(result).toBeNull()
})
})

describe("findLockfile", () => {
const originalCwd = process.cwd()
const testDir = path.join(originalCwd, ".test-find-lockfile-temp")
const nestedDir = path.join(testDir, "nested", "deep")

beforeEach(() => {
if (!fs.existsSync(nestedDir)) {
fs.mkdirSync(nestedDir, { recursive: true })
}
})

afterEach(() => {
vi.restoreAllMocks()
if (fs.existsSync(testDir)) {
fs.rmSync(testDir, { recursive: true, force: true })
}
})

it("returns lockfile path based on config path when provided", () => {
const configPath = path.join(testDir, "perstack.toml")

const result = findLockfile(configPath)

expect(result).toBe(path.join(testDir, "perstack.lock"))
})

it("returns lockfile path from config path even if file does not exist", () => {
// findLockfile with configPath always returns the path, doesn't check existence
const configPath = path.join("/some/nonexistent", "perstack.toml")

const result = findLockfile(configPath)

expect(result).toBe(path.join("/some/nonexistent", "perstack.lock"))
})

it("returns null for remote config URLs (https://)", () => {
const configPath = "https://raw.githubusercontent.com/org/repo/main/perstack.toml"

const result = findLockfile(configPath)

expect(result).toBeNull()
})

it("returns null for remote config URLs (http://)", () => {
const configPath = "http://example.com/perstack.toml"

const result = findLockfile(configPath)

expect(result).toBeNull()
})

it("returns null for remote config URLs with uppercase scheme (HTTPS://)", () => {
const configPath = "HTTPS://example.com/perstack.toml"

const result = findLockfile(configPath)

expect(result).toBeNull()
})

it("returns null for remote config URLs with mixed case scheme (HtTpS://)", () => {
const configPath = "HtTpS://raw.githubusercontent.com/org/repo/main/perstack.toml"

const result = findLockfile(configPath)

expect(result).toBeNull()
})

it("finds lockfile recursively from nested directory", () => {
// Create lockfile in parent directory
const lockfilePath = path.join(testDir, "perstack.lock")
fs.writeFileSync(lockfilePath, "")
vi.spyOn(process, "cwd").mockReturnValue(nestedDir)

const result = findLockfile()

expect(result).toBe(lockfilePath)
})

it("finds lockfile in current directory", () => {
const lockfilePath = path.join(testDir, "perstack.lock")
fs.writeFileSync(lockfilePath, "")
vi.spyOn(process, "cwd").mockReturnValue(testDir)

const result = findLockfile()

expect(result).toBe(lockfilePath)
},
])

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"],
},
})
})
})
Loading
Loading