Skip to content

Commit d402db5

Browse files
committed
Refactor: Simplify checkpoint path and display Job-based history in TUI
- Simplify checkpoint storage path to jobs/{jobId}/checkpoints/{id}.json - Remove timestamp from checkpoint filename - Update --resume-from to strictly require --continue-job - Change TUI history from Run-based to Job-based display - Show jobId and checkpointId in TUI for easier CLI usage Closes #81 Closes #82
1 parent 9ff0833 commit d402db5

File tree

25 files changed

+257
-294
lines changed

25 files changed

+257
-294
lines changed

docs/content/references/cli.mdx

Lines changed: 2 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -67,25 +67,22 @@ Providers: `anthropic`, `google`, `openai`, `ollama`, `azure-openai`, `amazon-be
6767
| `--job-id <id>` | Custom Job ID for new Job (default: auto-generated) |
6868
| `--continue` | Continue latest Job with new Run |
6969
| `--continue-job <id>` | Continue specific Job with new Run |
70-
| `--resume-from <id>` | Resume from specific checkpoint (requires `--continue` or `--continue-job`) |
70+
| `--resume-from <id>` | Resume from specific checkpoint (requires `--continue-job`) |
7171

7272
**Combining options:**
7373

7474
```bash
7575
# Continue latest Job from its latest checkpoint
7676
--continue
7777

78-
# Continue latest Job from a specific checkpoint
79-
--continue --resume-from <checkpointId>
80-
8178
# Continue specific Job from its latest checkpoint
8279
--continue-job <jobId>
8380

8481
# Continue specific Job from a specific checkpoint
8582
--continue-job <jobId> --resume-from <checkpointId>
8683
```
8784

88-
**Note:** `--resume-from` must be combined with `--continue` or `--continue-job`. You can only resume from the Coordinator Expert's checkpoints.
85+
**Note:** `--resume-from` requires `--continue-job` (Job ID must be specified). You can only resume from the Coordinator Expert's checkpoints.
8986

9087
### Interactive
9188

docs/content/using-experts/state-management.mdx

Lines changed: 2 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -77,13 +77,9 @@ npx perstack run my-expert "Follow-up" --continue-job <jobId>
7777

7878
## Resuming from a checkpoint
7979

80-
Resume from a specific checkpoint to branch execution. Use `--resume-from` with `--continue` or `--continue-job`:
80+
Resume from a specific checkpoint to branch execution. Use `--resume-from` with `--continue-job`:
8181

8282
```bash
83-
# Resume from checkpoint in latest Job
84-
npx perstack run my-expert "try again" --continue --resume-from <checkpointId>
85-
86-
# Resume from checkpoint in specific Job
8783
npx perstack run my-expert "try again" --continue-job <jobId> --resume-from <checkpointId>
8884
```
8985

@@ -93,7 +89,7 @@ Useful for:
9389
- Debugging
9490

9591
**Important:**
96-
- `--resume-from` requires `--continue` or `--continue-job`
92+
- `--resume-from` requires `--continue-job` (Job ID must be specified)
9793
- You can only resume from the Coordinator Expert's checkpoints
9894

9995
## Interactive tool calls

e2e/run.test.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -31,6 +31,6 @@ describe("CLI run", () => {
3131
"checkpoint-123",
3232
])
3333
expect(result.exitCode).toBe(1)
34-
expect(result.stderr).toContain("--resume-from requires --continue or --continue-job")
34+
expect(result.stderr).toContain("--resume-from requires --continue-job")
3535
})
3636
})

packages/perstack/src/lib/context.ts

Lines changed: 5 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,7 @@ import type { Checkpoint, PerstackConfig, ProviderConfig, ProviderName } from "@
22
import { getEnv } from "./get-env.js"
33
import { getPerstackConfig } from "./perstack-toml.js"
44
import { getProviderConfig } from "./provider-config.js"
5-
import { getCheckpointById, getMostRecentCheckpoint, getMostRecentRunInJob } from "./run-manager.js"
5+
import { getCheckpointById, getMostRecentCheckpoint } from "./run-manager.js"
66

77
const defaultProvider: ProviderName = "anthropic"
88
const defaultModel = "claude-sonnet-4-5"
@@ -33,15 +33,12 @@ export async function resolveRunContext(input: ResolveRunContextInput): Promise<
3333
const perstackConfig = await getPerstackConfig(input.configPath)
3434
let checkpoint: Checkpoint | undefined
3535
if (input.resumeFrom) {
36-
if (!input.continue && !input.continueJob) {
37-
throw new Error("--resume-from requires --continue or --continue-job")
36+
if (!input.continueJob) {
37+
throw new Error("--resume-from requires --continue-job")
3838
}
39-
const jobId = input.continueJob ?? (await getMostRecentCheckpoint()).jobId
40-
const run = await getMostRecentRunInJob(jobId)
41-
checkpoint = await getCheckpointById(jobId, run.runId, input.resumeFrom)
39+
checkpoint = await getCheckpointById(input.continueJob, input.resumeFrom)
4240
} else if (input.continueJob) {
43-
const run = await getMostRecentRunInJob(input.continueJob)
44-
checkpoint = await getMostRecentCheckpoint(run.jobId, run.runId)
41+
checkpoint = await getMostRecentCheckpoint(input.continueJob)
4542
} else if (input.continue) {
4643
checkpoint = await getMostRecentCheckpoint()
4744
}

packages/perstack/src/lib/run-manager.ts

Lines changed: 69 additions & 92 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,43 @@
11
import { existsSync } from "node:fs"
22
import { readdir, readFile } from "node:fs/promises"
33
import path from "node:path"
4-
import { type Checkpoint, checkpointSchema, type RunEvent, type RunSetting } from "@perstack/core"
5-
import { getRunDir } from "@perstack/runtime"
4+
import {
5+
type Checkpoint,
6+
checkpointSchema,
7+
type Job,
8+
jobSchema,
9+
type RunEvent,
10+
type RunSetting,
11+
} from "@perstack/core"
12+
import { getCheckpointDir, getCheckpointPath, getRunDir } from "@perstack/runtime"
13+
14+
export async function getAllJobs(): Promise<Job[]> {
15+
const dataDir = path.resolve(process.cwd(), "perstack")
16+
if (!existsSync(dataDir)) {
17+
return []
18+
}
19+
const jobsDir = path.resolve(dataDir, "jobs")
20+
if (!existsSync(jobsDir)) {
21+
return []
22+
}
23+
const jobDirs = await readdir(jobsDir, { withFileTypes: true }).then((dirs) =>
24+
dirs.filter((dir) => dir.isDirectory()).map((dir) => dir.name),
25+
)
26+
if (jobDirs.length === 0) {
27+
return []
28+
}
29+
const jobs: Job[] = []
30+
for (const jobDir of jobDirs) {
31+
const jobPath = path.resolve(jobsDir, jobDir, "job.json")
32+
try {
33+
const jobContent = await readFile(jobPath, "utf-8")
34+
jobs.push(jobSchema.parse(JSON.parse(jobContent)))
35+
} catch {
36+
// Ignore invalid jobs
37+
}
38+
}
39+
return jobs.sort((a, b) => b.startedAt - a.startedAt)
40+
}
641

742
export async function getAllRuns(): Promise<RunSetting[]> {
843
const dataDir = path.resolve(process.cwd(), "perstack")
@@ -67,63 +102,30 @@ export async function getMostRecentRunInJob(jobId: string): Promise<RunSetting>
67102
return runs[0]
68103
}
69104

70-
export async function getCheckpoints(
71-
jobId: string,
72-
runId: string,
73-
): Promise<{ timestamp: string; stepNumber: string; id: string }[]> {
74-
const runDir = getRunDir(jobId, runId)
75-
if (!existsSync(runDir)) {
105+
export async function getCheckpointsByJobId(jobId: string): Promise<Checkpoint[]> {
106+
const checkpointDir = getCheckpointDir(jobId)
107+
if (!existsSync(checkpointDir)) {
76108
return []
77109
}
78-
return await readdir(runDir).then((files) =>
79-
files
80-
.filter((file) => file.startsWith("checkpoint-"))
81-
.map((file) => {
82-
const [_, timestamp, stepNumber, id] = file.split(".")[0].split("-")
83-
return {
84-
timestamp,
85-
stepNumber,
86-
id,
87-
}
88-
})
89-
.sort((a, b) => Number(a.stepNumber) - Number(b.stepNumber)),
90-
)
91-
}
92-
93-
export async function getCheckpoint(checkpointId: string): Promise<Checkpoint> {
94-
const run = await getMostRecentRun()
95-
const runDir = getRunDir(run.jobId, run.runId)
96-
const checkpointPath = path.resolve(runDir, `checkpoint-${checkpointId}.json`)
97-
const checkpoint = await readFile(checkpointPath, "utf-8")
98-
return checkpointSchema.parse(JSON.parse(checkpoint))
99-
}
100-
101-
export async function getMostRecentCheckpoint(jobId?: string, runId?: string): Promise<Checkpoint> {
102-
let runJobId: string
103-
let runIdForCheckpoint: string
104-
if (jobId && runId) {
105-
runJobId = jobId
106-
runIdForCheckpoint = runId
107-
} else {
108-
const run = await getMostRecentRun()
109-
runJobId = run.jobId
110-
runIdForCheckpoint = run.runId
111-
}
112-
const runDir = getRunDir(runJobId, runIdForCheckpoint)
113-
const checkpointFiles = await readdir(runDir, { withFileTypes: true }).then((files) =>
114-
files.filter((file) => file.isFile() && file.name.startsWith("checkpoint-")),
115-
)
116-
if (checkpointFiles.length === 0) {
117-
throw new Error(`No checkpoints found for run ${runIdForCheckpoint}`)
118-
}
110+
const files = await readdir(checkpointDir)
111+
const checkpointFiles = files.filter((file) => file.endsWith(".json"))
119112
const checkpoints = await Promise.all(
120113
checkpointFiles.map(async (file) => {
121-
const checkpointPath = path.resolve(runDir, file.name)
122-
const checkpoint = await readFile(checkpointPath, "utf-8")
123-
return checkpointSchema.parse(JSON.parse(checkpoint))
114+
const checkpointPath = path.resolve(checkpointDir, file)
115+
const content = await readFile(checkpointPath, "utf-8")
116+
return checkpointSchema.parse(JSON.parse(content))
124117
}),
125118
)
126-
return checkpoints.sort((a, b) => b.stepNumber - a.stepNumber)[0]
119+
return checkpoints.sort((a, b) => a.stepNumber - b.stepNumber)
120+
}
121+
122+
export async function getMostRecentCheckpoint(jobId?: string): Promise<Checkpoint> {
123+
const targetJobId = jobId ?? (await getMostRecentRun()).jobId
124+
const checkpoints = await getCheckpointsByJobId(targetJobId)
125+
if (checkpoints.length === 0) {
126+
throw new Error(`No checkpoints found for job ${targetJobId}`)
127+
}
128+
return checkpoints[checkpoints.length - 1]
127129
}
128130

129131
export async function getRecentExperts(
@@ -168,51 +170,26 @@ export async function getEvents(
168170
.sort((a, b) => Number(a.stepNumber) - Number(b.stepNumber)),
169171
)
170172
}
171-
export async function getCheckpointById(
172-
jobId: string,
173-
runId: string,
174-
checkpointId: string,
175-
): Promise<Checkpoint> {
176-
const runDir = getRunDir(jobId, runId)
177-
const files = await readdir(runDir)
178-
const checkpointFile = files.find(
179-
(file) => file.startsWith("checkpoint-") && file.includes(`-${checkpointId}.`),
180-
)
181-
if (!checkpointFile) {
182-
throw new Error(`Checkpoint ${checkpointId} not found in run ${runId}`)
173+
export async function getCheckpointById(jobId: string, checkpointId: string): Promise<Checkpoint> {
174+
const checkpointPath = getCheckpointPath(jobId, checkpointId)
175+
if (!existsSync(checkpointPath)) {
176+
throw new Error(`Checkpoint ${checkpointId} not found in job ${jobId}`)
183177
}
184-
const checkpointPath = path.resolve(runDir, checkpointFile)
185178
const checkpoint = await readFile(checkpointPath, "utf-8")
186179
return checkpointSchema.parse(JSON.parse(checkpoint))
187180
}
188181
export async function getCheckpointsWithDetails(
189182
jobId: string,
190-
runId: string,
191-
): Promise<
192-
{ id: string; runId: string; stepNumber: number; timestamp: number; contextWindowUsage: number }[]
193-
> {
194-
const runDir = getRunDir(jobId, runId)
195-
if (!existsSync(runDir)) {
196-
return []
197-
}
198-
const files = await readdir(runDir)
199-
const checkpointFiles = files.filter((file) => file.startsWith("checkpoint-"))
200-
const checkpoints = await Promise.all(
201-
checkpointFiles.map(async (file) => {
202-
const [_, timestamp, stepNumber, id] = file.split(".")[0].split("-")
203-
const checkpointPath = path.resolve(runDir, file)
204-
const content = await readFile(checkpointPath, "utf-8")
205-
const checkpoint = checkpointSchema.parse(JSON.parse(content))
206-
return {
207-
id,
208-
runId,
209-
stepNumber: Number(stepNumber),
210-
timestamp: Number(timestamp),
211-
contextWindowUsage: checkpoint.contextWindowUsage ?? 0,
212-
}
213-
}),
214-
)
215-
return checkpoints.sort((a, b) => b.stepNumber - a.stepNumber)
183+
): Promise<{ id: string; runId: string; stepNumber: number; contextWindowUsage: number }[]> {
184+
const checkpoints = await getCheckpointsByJobId(jobId)
185+
return checkpoints
186+
.map((cp) => ({
187+
id: cp.id,
188+
runId: cp.runId,
189+
stepNumber: cp.stepNumber,
190+
contextWindowUsage: cp.contextWindowUsage ?? 0,
191+
}))
192+
.sort((a, b) => b.stepNumber - a.stepNumber)
216193
}
217194
export async function getEventsWithDetails(
218195
jobId: string,

packages/perstack/src/start.ts

Lines changed: 18 additions & 23 deletions
Original file line numberDiff line numberDiff line change
@@ -6,13 +6,13 @@ import {
66
startCommandInputSchema,
77
} from "@perstack/core"
88
import { run, runtimeVersion } from "@perstack/runtime"
9-
import type { CheckpointHistoryItem, EventHistoryItem, RunHistoryItem } from "@perstack/tui"
9+
import type { CheckpointHistoryItem, EventHistoryItem, JobHistoryItem } from "@perstack/tui"
1010
import { renderStart } from "@perstack/tui"
1111
import { Command } from "commander"
1212
import { resolveRunContext } from "./lib/context.js"
1313
import { parseInteractiveToolCallResult } from "./lib/interactive.js"
1414
import {
15-
getAllRuns,
15+
getAllJobs,
1616
getCheckpointById,
1717
getCheckpointsWithDetails,
1818
getEventContents,
@@ -72,15 +72,14 @@ export const startCommand = new Command()
7272
name: key,
7373
}))
7474
const recentExperts = await getRecentExperts(10)
75-
const historyRuns: RunHistoryItem[] = showHistory
76-
? (await getAllRuns()).map((r) => ({
77-
jobId: r.jobId,
78-
runId: r.runId,
79-
expertKey: r.expertKey,
80-
model: r.model,
81-
inputText: r.input.text ?? "",
82-
startedAt: r.startedAt,
83-
updatedAt: r.updatedAt,
75+
const historyJobs: JobHistoryItem[] = showHistory
76+
? (await getAllJobs()).map((j) => ({
77+
jobId: j.id,
78+
status: j.status,
79+
expertKey: j.coordinatorExpertKey,
80+
totalSteps: j.totalSteps,
81+
startedAt: j.startedAt,
82+
finishedAt: j.finishedAt,
8483
}))
8584
: []
8685
const resumeState: { checkpoint: CheckpointHistoryItem | null } = { checkpoint: null }
@@ -106,7 +105,7 @@ export const startCommand = new Command()
106105
},
107106
configuredExperts,
108107
recentExperts,
109-
historyRuns,
108+
historyJobs,
110109
onContinue: (query: string) => {
111110
if (resolveContinueQuery) {
112111
resolveContinueQuery(query)
@@ -116,16 +115,16 @@ export const startCommand = new Command()
116115
onResumeFromCheckpoint: (cp: CheckpointHistoryItem) => {
117116
resumeState.checkpoint = cp
118117
},
119-
onLoadCheckpoints: async (r: RunHistoryItem): Promise<CheckpointHistoryItem[]> => {
120-
const checkpoints = await getCheckpointsWithDetails(r.jobId, r.runId)
121-
return checkpoints.map((cp) => ({ ...cp, jobId: r.jobId }))
118+
onLoadCheckpoints: async (j: JobHistoryItem): Promise<CheckpointHistoryItem[]> => {
119+
const checkpoints = await getCheckpointsWithDetails(j.jobId)
120+
return checkpoints.map((cp) => ({ ...cp, jobId: j.jobId }))
122121
},
123122
onLoadEvents: async (
124-
r: RunHistoryItem,
123+
j: JobHistoryItem,
125124
cp: CheckpointHistoryItem,
126125
): Promise<EventHistoryItem[]> => {
127-
const events = await getEventsWithDetails(r.jobId, r.runId, cp.stepNumber)
128-
return events.map((e) => ({ ...e, jobId: r.jobId }))
126+
const events = await getEventsWithDetails(j.jobId, cp.runId, cp.stepNumber)
127+
return events.map((e) => ({ ...e, jobId: j.jobId }))
129128
},
130129
onLoadHistoricalEvents: async (cp: CheckpointHistoryItem) => {
131130
return await getEventContents(cp.jobId, cp.runId, cp.stepNumber)
@@ -139,11 +138,7 @@ export const startCommand = new Command()
139138
}
140139
let currentCheckpoint =
141140
resumeState.checkpoint !== null
142-
? await getCheckpointById(
143-
resumeState.checkpoint.jobId,
144-
resumeState.checkpoint.runId,
145-
resumeState.checkpoint.id,
146-
)
141+
? await getCheckpointById(resumeState.checkpoint.jobId, resumeState.checkpoint.id)
147142
: checkpoint
148143
if (currentCheckpoint && currentCheckpoint.expert.key !== finalExpertKey) {
149144
console.error(

0 commit comments

Comments
 (0)