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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
37 changes: 37 additions & 0 deletions .changeset/runtime-version.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,37 @@
---
"@perstack/core": patch
"@perstack/runtime": patch
"@perstack/base": patch
"@perstack/filesystem-storage": patch
"@perstack/s3-compatible-storage": patch
"@perstack/s3-storage": patch
"@perstack/r2-storage": patch
"@perstack/runner": patch
"@perstack/mock": patch
"@perstack/react": patch
"@perstack/anthropic-provider": patch
"@perstack/azure-openai-provider": patch
"@perstack/bedrock-provider": patch
"@perstack/deepseek-provider": patch
"@perstack/google-provider": patch
"@perstack/ollama-provider": patch
"@perstack/openai-provider": patch
"@perstack/vertex-provider": patch
"@perstack/provider-core": patch
"@perstack/adapter-base": patch
"@perstack/claude-code": patch
"@perstack/cursor": patch
"@perstack/docker": patch
"@perstack/gemini": patch
"@perstack/tui-components": patch
"create-expert": patch
"perstack": patch
---

Add runtime version tracking to Job schema and validation

- Add `runtimeVersion` field to Job schema to track which runtime version executed the job
- Add `minRuntimeVersion` field to Expert schema for compatibility requirements
- Runtime version 0.x.y is treated as v1.0 for compatibility
- Validate entire delegation chain before execution (fail fast, no LLM calls if incompatible)
- Recursive delegate resolution ensures all experts in chain are checked
3 changes: 3 additions & 0 deletions apps/perstack/src/install.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ import {
type Lockfile,
type LockfileExpert,
type PerstackConfig,
type RuntimeVersion,
type Skill,
} from "@perstack/core"
import { collectToolDefinitionsForExpert } from "@perstack/runtime"
Expand Down Expand Up @@ -41,6 +42,7 @@ type PublishedExpertData = {
version: string
description?: string
instruction: string
minRuntimeVersion?: RuntimeVersion
skills?: Record<
string,
| {
Expand Down Expand Up @@ -139,6 +141,7 @@ function toRuntimeExpert(key: string, expert: PublishedExpertData): Expert {
key,
name: expert.name,
version: expert.version,
minRuntimeVersion: expert.minRuntimeVersion ?? "v1.0",
description: expert.description ?? "",
instruction: expert.instruction,
skills,
Expand Down
1 change: 1 addition & 0 deletions apps/perstack/src/lib/log/data-fetcher.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ const mockJob: Job = {
id: "job-1",
status: "completed",
coordinatorExpertKey: "test-expert@1.0.0",
runtimeVersion: "v1.0",
totalSteps: 5,
usage: {
inputTokens: 1000,
Expand Down
1 change: 1 addition & 0 deletions apps/perstack/src/lib/log/data-fetcher.ts
Original file line number Diff line number Diff line change
Expand Up @@ -37,6 +37,7 @@ export function createLogDataFetcher(storage: StorageAdapter): LogDataFetcher {
return {
id: jobId,
coordinatorExpertKey: firstCheckpoint.expert.key,
runtimeVersion: "v1.0",
totalSteps: lastCheckpoint.stepNumber,
usage: lastCheckpoint.usage,
startedAt: getJobDirMtime(storage.getBasePath(), jobId),
Expand Down
1 change: 1 addition & 0 deletions apps/perstack/src/lib/log/formatter.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ const mockJob: Job = {
id: "job-1",
status: "completed",
coordinatorExpertKey: "test-expert@1.0.0",
runtimeVersion: "v1.0",
totalSteps: 5,
usage: {
inputTokens: 1000,
Expand Down
1 change: 1 addition & 0 deletions apps/runtime/src/cli/context.ts
Original file line number Diff line number Diff line change
Expand Up @@ -49,6 +49,7 @@ export async function resolveRunContext(input: ResolveRunContextInput): Promise<
skills: expert.skills,
delegates: expert.delegates ?? [],
tags: expert.tags ?? [],
minRuntimeVersion: expert.minRuntimeVersion,
},
]
}),
Expand Down
9 changes: 9 additions & 0 deletions apps/runtime/src/helpers/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,15 @@ export {
loadLockfile,
} from "./lockfile.js"
export { calculateContextWindowUsage, getContextWindow, getModel } from "./model.js"
export {
compareRuntimeVersions,
determineJobRuntimeVersion,
getCurrentRuntimeVersion,
getMaxMinRuntimeVersion,
parseRuntimeVersion,
toRuntimeVersion,
validateRuntimeVersion,
} from "./runtime-version.js"
export {
type ResolveExpertToRunFn,
type SetupExpertsResult,
Expand Down
1 change: 1 addition & 0 deletions apps/runtime/src/helpers/resolve-expert.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -70,6 +70,7 @@ function createTestExpert(overrides: Partial<Expert> = {}): Expert {
},
delegates: [],
tags: [],
minRuntimeVersion: "v1.0",
...overrides,
}
}
Expand Down
4 changes: 3 additions & 1 deletion apps/runtime/src/helpers/resolve-expert.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import { createApiClient } from "@perstack/api-client"
import type { Expert, Skill } from "@perstack/core"
import type { Expert, RuntimeVersion, Skill } from "@perstack/core"

export async function resolveExpertToRun(
expertKey: string,
Expand Down Expand Up @@ -35,6 +35,7 @@ function toRuntimeExpert(
expert: {
name: string
version: string
minRuntimeVersion?: RuntimeVersion
description?: string
instruction: string
skills?: Record<
Expand Down Expand Up @@ -134,6 +135,7 @@ function toRuntimeExpert(
key,
name: expert.name,
version: expert.version,
minRuntimeVersion: expert.minRuntimeVersion ?? "v1.0",
description: expert.description ?? "",
instruction: expert.instruction,
skills,
Expand Down
198 changes: 198 additions & 0 deletions apps/runtime/src/helpers/runtime-version.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,198 @@
import type { Expert, RuntimeVersion } from "@perstack/core"
import { describe, expect, it, vi } from "vitest"

vi.mock("../../package.json", () => ({
default: { version: "0.0.87" },
}))

import {
compareRuntimeVersions,
determineJobRuntimeVersion,
getCurrentRuntimeVersion,
getMaxMinRuntimeVersion,
parseRuntimeVersion,
toRuntimeVersion,
validateRuntimeVersion,
} from "./runtime-version.js"

function createMockExpert(
overrides: Partial<Omit<Expert, "minRuntimeVersion">> & {
minRuntimeVersion?: RuntimeVersion | string
} = {},
): Expert {
return {
key: "test-expert",
name: "Test Expert",
version: "1.0.0",
instruction: "Test instruction",
skills: {},
delegates: [],
tags: [],
...overrides,
} as Expert
}

describe("@perstack/runtime: runtime-version", () => {
describe("parseRuntimeVersion()", () => {
it("parses v1.0 format", () => {
const result = parseRuntimeVersion("v1.0")
expect(result).toEqual({ major: 1, minor: 0 })
})

it("parses 1.0 format without v prefix", () => {
const result = parseRuntimeVersion("1.0")
expect(result).toEqual({ major: 1, minor: 0 })
})

it("parses v1.10 with two-digit minor", () => {
const result = parseRuntimeVersion("v1.10")
expect(result).toEqual({ major: 1, minor: 10 })
})

it("returns null for undefined", () => {
const result = parseRuntimeVersion(undefined)
expect(result).toBeNull()
})

it("returns null for semver format 1.0.0", () => {
const result = parseRuntimeVersion("1.0.0")
expect(result).toBeNull()
})

it("returns null for invalid format", () => {
const result = parseRuntimeVersion("invalid")
expect(result).toBeNull()
})
})

describe("compareRuntimeVersions()", () => {
it("returns positive when a > b (major)", () => {
expect(compareRuntimeVersions("v2.0", "v1.0")).toBeGreaterThan(0)
})

it("returns negative when a < b (major)", () => {
expect(compareRuntimeVersions("v1.0", "v2.0")).toBeLessThan(0)
})

it("returns positive when a > b (minor)", () => {
expect(compareRuntimeVersions("v1.5", "v1.2")).toBeGreaterThan(0)
})

it("returns zero when a equals b", () => {
expect(compareRuntimeVersions("v1.0", "v1.0")).toBe(0)
})

it("returns zero when either version is invalid", () => {
expect(compareRuntimeVersions("invalid", "v1.0")).toBe(0)
expect(compareRuntimeVersions("v1.0", "invalid")).toBe(0)
})
})

describe("toRuntimeVersion()", () => {
it("converts semver to runtime version", () => {
expect(toRuntimeVersion("1.2.3")).toBe("v1.2")
})

it("converts 0.x.y to v1.0 (special case)", () => {
expect(toRuntimeVersion("0.0.87")).toBe("v1.0")
})

it("converts 0.5.10 to v1.0", () => {
expect(toRuntimeVersion("0.5.10")).toBe("v1.0")
})
})

describe("getCurrentRuntimeVersion()", () => {
it("returns v1.0 for mocked 0.0.87 version", () => {
expect(getCurrentRuntimeVersion()).toBe("v1.0")
})
})

describe("getMaxMinRuntimeVersion()", () => {
it("returns undefined for empty experts", () => {
const result = getMaxMinRuntimeVersion({})
expect(result).toBeUndefined()
})

it("returns undefined when no experts have minRuntimeVersion", () => {
const experts: Record<string, Expert> = {
"expert-1": createMockExpert({ key: "expert-1" }),
"expert-2": createMockExpert({ key: "expert-2" }),
}
const result = getMaxMinRuntimeVersion(experts)
expect(result).toBeUndefined()
})

it("returns the single minRuntimeVersion", () => {
const experts: Record<string, Expert> = {
"expert-1": createMockExpert({ key: "expert-1", minRuntimeVersion: "v1.0" }),
}
const result = getMaxMinRuntimeVersion(experts)
expect(result).toBe("v1.0")
})

it("returns max minRuntimeVersion among multiple experts", () => {
const experts: Record<string, Expert> = {
"expert-1": createMockExpert({ key: "expert-1", minRuntimeVersion: "v1.0" }),
"expert-2": createMockExpert({ key: "expert-2", minRuntimeVersion: "v1.5" }),
"expert-3": createMockExpert({ key: "expert-3", minRuntimeVersion: "v1.2" }),
}
const result = getMaxMinRuntimeVersion(experts)
expect(result).toBe("v1.5")
})

it("handles mixed undefined and defined minRuntimeVersion", () => {
const experts: Record<string, Expert> = {
"expert-1": createMockExpert({ key: "expert-1" }),
"expert-2": createMockExpert({ key: "expert-2", minRuntimeVersion: "v1.0" }),
}
const result = getMaxMinRuntimeVersion(experts)
expect(result).toBe("v1.0")
})
})

describe("validateRuntimeVersion()", () => {
it("does not throw when no minRuntimeVersion is set", () => {
const experts: Record<string, Expert> = {
"expert-1": createMockExpert({ key: "expert-1" }),
}
expect(() => validateRuntimeVersion(experts)).not.toThrow()
})

it("does not throw when minRuntimeVersion <= current (v1.0)", () => {
const experts: Record<string, Expert> = {
"expert-1": createMockExpert({ key: "expert-1", minRuntimeVersion: "v1.0" }),
}
expect(() => validateRuntimeVersion(experts)).not.toThrow()
})

it("throws when minRuntimeVersion > current", () => {
const experts: Record<string, Expert> = {
"expert-1": createMockExpert({ key: "expert-1", minRuntimeVersion: "v99.0" }),
}
expect(() => validateRuntimeVersion(experts)).toThrow("v99.0")
})
})

describe("determineJobRuntimeVersion()", () => {
it("returns v1.0 for 0.x.y runtime version", () => {
const experts: Record<string, Expert> = {
"expert-1": createMockExpert({ key: "expert-1", minRuntimeVersion: "v1.0" }),
}
const result = determineJobRuntimeVersion(experts)
expect(result).toBe("v1.0")
})

it("throws on validation failure", () => {
const experts: Record<string, Expert> = {
"expert-1": createMockExpert({ key: "expert-1", minRuntimeVersion: "v99.0" }),
}
expect(() => determineJobRuntimeVersion(experts)).toThrow("v99.0")
})

it("returns current version when no experts provided", () => {
const result = determineJobRuntimeVersion({})
expect(result).toBe("v1.0")
})
})
})
56 changes: 56 additions & 0 deletions apps/runtime/src/helpers/runtime-version.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,56 @@
import type { Expert, RuntimeVersion } from "@perstack/core"
import pkg from "../../package.json" with { type: "json" }

export function parseRuntimeVersion(
version: string | undefined,
): { major: number; minor: number } | null {
if (!version) return null
const match = version.match(/^v?(\d+)\.(\d+)$/)
if (!match) return null
return { major: parseInt(match[1], 10), minor: parseInt(match[2], 10) }
}

export function compareRuntimeVersions(a: string, b: string): number {
const parsedA = parseRuntimeVersion(a)
const parsedB = parseRuntimeVersion(b)
if (!parsedA || !parsedB) return 0
if (parsedA.major !== parsedB.major) return parsedA.major - parsedB.major
return parsedA.minor - parsedB.minor
}

export function toRuntimeVersion(semver: string): RuntimeVersion {
const parts = semver.split(".")
const major = parseInt(parts[0], 10)
if (major === 0) return "v1.0"
return `v${parts[0]}.${parts[1]}` as RuntimeVersion
}

export function getCurrentRuntimeVersion(): RuntimeVersion {
return toRuntimeVersion(pkg.version)
}

export function getMaxMinRuntimeVersion(
experts: Record<string, Expert>,
): RuntimeVersion | undefined {
const versions = Object.values(experts)
.map((e) => e.minRuntimeVersion)
.filter((v): v is RuntimeVersion => v !== undefined && parseRuntimeVersion(v) !== null)
if (versions.length === 0) return undefined
return versions.reduce((max, v) => (compareRuntimeVersions(v, max) > 0 ? v : max), versions[0])
}

export function validateRuntimeVersion(experts: Record<string, Expert>): void {
const currentVersion = getCurrentRuntimeVersion()
const maxMinVersion = getMaxMinRuntimeVersion(experts)
if (!maxMinVersion) return
if (compareRuntimeVersions(maxMinVersion, currentVersion) > 0) {
throw new Error(
`Runtime version ${currentVersion} does not meet minimum requirement ${maxMinVersion}`,
)
}
}

export function determineJobRuntimeVersion(experts: Record<string, Expert>): RuntimeVersion {
validateRuntimeVersion(experts)
return getCurrentRuntimeVersion()
}
Loading