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
13 changes: 13 additions & 0 deletions .changeset/job-concept-implementation.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
---
"@perstack/core": patch
"@perstack/runtime": patch
"@perstack/api-client": patch
"@perstack/tui": patch
"perstack": patch
---

Add Job concept as parent container for Runs

- Add Job schema and jobId to Checkpoint, RunSetting, and Event types
- Update storage structure to perstack/jobs/{jobId}/runs/{runId}/
- Update CLI options: --job-id, --continue-job (replacing --continue-run)
16 changes: 8 additions & 8 deletions e2e/continue-resume.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@ const CONFIG_PATH = "./e2e/experts/continue-resume.toml"
const TIMEOUT = 180000

describe("Continue and Resume From Checkpoint", () => {
it("should stop at interactive tool and get run ID", async () => {
it("should stop at interactive tool and get job ID", async () => {
const result = await runExpert("e2e-continue", "Test continue/resume functionality", {
configPath: CONFIG_PATH,
timeout: TIMEOUT,
Expand All @@ -19,18 +19,18 @@ describe("Continue and Resume From Checkpoint", () => {
"stopRunByInteractiveTool",
]).passed,
).toBe(true)
expect(result.runId).not.toBeNull()
expect(result.jobId).not.toBeNull()
}, 200000)

it("should continue run with --continue-run", async () => {
it("should continue job with --continue-job", async () => {
const initialResult = await runExpert("e2e-continue", "Test continue/resume functionality", {
configPath: CONFIG_PATH,
timeout: TIMEOUT,
})
expect(initialResult.runId).not.toBeNull()
expect(initialResult.jobId).not.toBeNull()
const continueResult = await runExpert("e2e-continue", "User confirmed the test", {
configPath: CONFIG_PATH,
continueRunId: initialResult.runId!,
continueJobId: initialResult.jobId!,
isInteractiveResult: true,
timeout: TIMEOUT,
})
Expand All @@ -50,10 +50,10 @@ describe("Continue and Resume From Checkpoint", () => {
configPath: CONFIG_PATH,
timeout: TIMEOUT,
})
expect(initialResult.runId).not.toBeNull()
expect(initialResult.jobId).not.toBeNull()
const continueResult = await runExpert("e2e-continue", "User confirmed the test", {
configPath: CONFIG_PATH,
continueRunId: initialResult.runId!,
continueJobId: initialResult.jobId!,
isInteractiveResult: true,
timeout: TIMEOUT,
})
Expand All @@ -68,6 +68,6 @@ describe("Continue and Resume From Checkpoint", () => {
const stopEvent = filterEventsByType(result.events, "stopRunByInteractiveTool")[0]
expect(stopEvent).toBeDefined()
expect((stopEvent as { checkpoint?: { id?: string } }).checkpoint?.id).toBeDefined()
expect(result.runId).not.toBeNull()
expect(result.jobId).not.toBeNull()
}, 200000)
})
9 changes: 6 additions & 3 deletions e2e/lib/runner.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ export type CommandResult = {

export type RunResult = CommandResult & {
events: ParsedEvent[]
jobId: string | null
runId: string | null
}

Expand Down Expand Up @@ -53,7 +54,7 @@ export async function runExpert(
options?: {
configPath?: string
timeout?: number
continueRunId?: string
continueJobId?: string
isInteractiveResult?: boolean
},
): Promise<RunResult> {
Expand All @@ -62,8 +63,8 @@ export async function runExpert(
if (options?.configPath) {
args.push("--config", options.configPath)
}
if (options?.continueRunId) {
args.push("--continue-run", options.continueRunId)
if (options?.continueJobId) {
args.push("--continue-job", options.continueJobId)
}
if (options?.isInteractiveResult) {
args.push("-i")
Expand Down Expand Up @@ -91,12 +92,14 @@ export async function runExpert(
clearTimeout(timer)
const events = parseEvents(stdout)
const startRunEvent = events.find((e) => e.type === "startRun")
const jobId = startRunEvent ? ((startRunEvent as { jobId?: string }).jobId ?? null) : null
const runId = startRunEvent ? ((startRunEvent as { runId?: string }).runId ?? null) : null
resolve({
stdout,
stderr,
events,
exitCode: code ?? 0,
jobId,
runId,
})
})
Expand Down
13 changes: 12 additions & 1 deletion e2e/run.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -21,5 +21,16 @@ describe("CLI run", () => {
const result = await runCli(["run", "expert", "query", "--config", "nonexistent.toml"])
expect(result.exitCode).toBe(1)
})
})

it("should fail when --resume-from is used without --continue or --continue-job", async () => {
const result = await runCli([
"run",
"test-expert",
"test query",
"--resume-from",
"checkpoint-123",
])
expect(result.exitCode).toBe(1)
expect(result.stderr).toContain("--resume-from requires --continue or --continue-job")
})
})
2 changes: 2 additions & 0 deletions packages/api-client/test/test-data.ts
Original file line number Diff line number Diff line change
Expand Up @@ -186,6 +186,7 @@ export const assertExpertJob = {

export const runtimeCheckpoint: z.input<typeof checkpointSchema> = {
id: "checkpoint123456789012345",
jobId: "job123456789012345",
runId: "run123456789012345",
status: "completed",
stepNumber: 3,
Expand Down Expand Up @@ -230,6 +231,7 @@ export const checkpoint: z.input<typeof apiCheckpointSchema> = {
todos: [],
},
expertJobId: expertJob.id,
jobId: "testjob123456789012345",
runId: "testrun123456789012345",
stepNumber: 1,
status: "completed",
Expand Down
1 change: 1 addition & 0 deletions packages/core/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ export * from "./constants/constants.js"
export * from "./known-models/index.js"
export * from "./schemas/checkpoint.js"
export * from "./schemas/expert.js"
export * from "./schemas/job.js"
export * from "./schemas/message.js"
export * from "./schemas/message-part.js"
export * from "./schemas/perstack-toml.js"
Expand Down
59 changes: 31 additions & 28 deletions packages/core/src/schemas/checkpoint.ts
Original file line number Diff line number Diff line change
Expand Up @@ -28,18 +28,32 @@ export const checkpointStatusSchema = z.enum([
"stoppedByError",
])

/** Information about a delegation target */
export interface DelegationTarget {
expert: {
key: string
name: string
version: string
}
toolCallId: string
toolName: string
query: string
}

/**
* A checkpoint represents a point-in-time snapshot of an Expert's execution state.
* Used for resuming, debugging, and observability.
*/
export interface Checkpoint {
/** Unique identifier for this checkpoint */
id: string
/** Job ID this checkpoint belongs to */
jobId: string
/** Run ID this checkpoint belongs to */
runId: string
/** Current execution status */
status: CheckpointStatus
/** Current step number */
/** Current step number within this Run */
stepNumber: number
/** All messages in the conversation so far */
messages: Message[]
Expand All @@ -52,21 +66,8 @@ export interface Checkpoint {
/** Expert version */
version: string
}
/** If delegating, information about the target Expert */
delegateTo?: {
/** The Expert being delegated to */
expert: {
key: string
name: string
version: string
}
/** Tool call ID that triggered delegation */
toolCallId: string
/** Name of the delegation tool */
toolName: string
/** Query passed to the delegate */
query: string
}
/** If delegating, information about the target Expert(s) - supports parallel delegation */
delegateTo?: DelegationTarget[]
/** If delegated, information about the parent Expert */
delegatedBy?: {
/** The parent Expert that delegated */
Expand Down Expand Up @@ -94,8 +95,21 @@ export interface Checkpoint {
partialToolResults?: ToolResult[]
}

export const delegationTargetSchema = z.object({
expert: z.object({
key: z.string(),
name: z.string(),
version: z.string(),
}),
toolCallId: z.string(),
toolName: z.string(),
query: z.string(),
})
delegationTargetSchema satisfies z.ZodType<DelegationTarget>

export const checkpointSchema = z.object({
id: z.string(),
jobId: z.string(),
runId: z.string(),
status: checkpointStatusSchema,
stepNumber: z.number(),
Expand All @@ -105,18 +119,7 @@ export const checkpointSchema = z.object({
name: z.string(),
version: z.string(),
}),
delegateTo: z
.object({
expert: z.object({
key: z.string(),
name: z.string(),
version: z.string(),
}),
toolCallId: z.string(),
toolName: z.string(),
query: z.string(),
})
.optional(),
delegateTo: z.array(delegationTargetSchema).optional(),
delegatedBy: z
.object({
expert: z.object({
Expand Down
41 changes: 41 additions & 0 deletions packages/core/src/schemas/job.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,41 @@
import { z } from "zod"
import type { Usage } from "./usage.js"
import { usageSchema } from "./usage.js"

export type JobStatus =
| "running"
| "completed"
| "stoppedByMaxSteps"
| "stoppedByInteractiveTool"
| "stoppedByError"

export const jobStatusSchema = z.enum([
"running",
"completed",
"stoppedByMaxSteps",
"stoppedByInteractiveTool",
"stoppedByError",
])

export interface Job {
id: string
status: JobStatus
coordinatorExpertKey: string
totalSteps: number
maxSteps?: number
usage: Usage
startedAt: number
finishedAt?: number
}

export const jobSchema = z.object({
id: z.string(),
status: jobStatusSchema,
coordinatorExpertKey: z.string(),
totalSteps: z.number(),
maxSteps: z.number().optional(),
usage: usageSchema,
startedAt: z.number(),
finishedAt: z.number().optional(),
})
jobSchema satisfies z.ZodType<Job>
46 changes: 46 additions & 0 deletions packages/core/src/schemas/run-command.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -121,3 +121,49 @@ describe("@perstack/core: startCommandInputSchema", () => {
expect(result.query).toBeUndefined()
})
})

describe("@perstack/core: commandOptionsSchema - Job options", () => {
it("parses jobId option", () => {
const result = runCommandInputSchema.parse({
expertKey: "test-expert",
query: "test",
options: { jobId: "job-123" },
})
expect(result.options.jobId).toBe("job-123")
})

it("parses continueJob option", () => {
const result = runCommandInputSchema.parse({
expertKey: "test-expert",
query: "test",
options: { continueJob: "job-456" },
})
expect(result.options.continueJob).toBe("job-456")
})

it("parses resumeFrom option", () => {
const result = runCommandInputSchema.parse({
expertKey: "test-expert",
query: "test",
options: { resumeFrom: "checkpoint-789" },
})
expect(result.options.resumeFrom).toBe("checkpoint-789")
})

it("parses all job-related options together", () => {
const result = runCommandInputSchema.parse({
expertKey: "test-expert",
query: "test",
options: {
jobId: "job-123",
continueJob: "job-456",
resumeFrom: "checkpoint-789",
continue: true,
},
})
expect(result.options.jobId).toBe("job-123")
expect(result.options.continueJob).toBe("job-456")
expect(result.options.resumeFrom).toBe("checkpoint-789")
expect(result.options.continue).toBe(true)
})
})
13 changes: 8 additions & 5 deletions packages/core/src/schemas/run-command.ts
Original file line number Diff line number Diff line change
Expand Up @@ -18,17 +18,19 @@ export interface CommandOptions {
maxRetries?: number
/** Timeout in milliseconds */
timeout?: number
/** Custom job ID */
jobId?: string
/** Custom run ID */
runId?: string
/** Paths to .env files */
envPath?: string[]
/** Enable verbose logging */
verbose?: boolean
/** Continue most recent run */
/** Continue most recent job */
continue?: boolean
/** Continue specific run by ID */
continueRun?: string
/** Resume from specific checkpoint */
/** Continue specific job by ID */
continueJob?: string
/** Resume from specific checkpoint (requires --continue or --continue-job) */
resumeFrom?: string
/** Query is interactive tool call result */
interactiveToolCallResult?: boolean
Expand Down Expand Up @@ -74,11 +76,12 @@ const commandOptionsSchema = z.object({
if (Number.isNaN(parsedValue)) return undefined
return parsedValue
}),
jobId: z.string().optional(),
runId: z.string().optional(),
envPath: z.array(z.string()).optional(),
verbose: z.boolean().optional(),
continue: z.boolean().optional(),
continueRun: z.string().optional(),
continueJob: z.string().optional(),
resumeFrom: z.string().optional(),
interactiveToolCallResult: z.boolean().optional(),
})
Expand Down
Loading