checkpointflow 1.0.4__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,6 @@
1
+ from importlib.metadata import PackageNotFoundError, version
2
+
3
+ try:
4
+ __version__ = version("checkpointflow")
5
+ except PackageNotFoundError:
6
+ __version__ = "0.0.0"
@@ -0,0 +1,6 @@
1
+ from __future__ import annotations
2
+
3
+ from checkpointflow.cli import main
4
+
5
+ if __name__ == "__main__":
6
+ main()
checkpointflow/cli.py ADDED
@@ -0,0 +1,311 @@
1
+ from __future__ import annotations
2
+
3
+ from pathlib import Path
4
+ from typing import Annotated, NoReturn
5
+
6
+ import typer
7
+ import yaml
8
+
9
+ from checkpointflow import __version__
10
+ from checkpointflow.models.envelope import Envelope
11
+ from checkpointflow.models.errors import ErrorCode, ExitCode
12
+ from checkpointflow.schema import validate_workflow_document
13
+
14
+ app = typer.Typer(
15
+ name="cpf",
16
+ help="checkpointflow — deterministic, resumable agent workflows.",
17
+ no_args_is_help=True,
18
+ add_completion=False,
19
+ pretty_exceptions_enable=False,
20
+ )
21
+
22
+
23
+ def _emit(envelope: Envelope) -> NoReturn:
24
+ typer.echo(envelope.to_json())
25
+ raise typer.Exit(code=envelope.exit_code or 0)
26
+
27
+
28
+ def _version_callback(value: bool) -> None:
29
+ if value:
30
+ typer.echo(__version__)
31
+ raise typer.Exit()
32
+
33
+
34
+ @app.callback()
35
+ def callback(
36
+ version: Annotated[
37
+ bool,
38
+ typer.Option(
39
+ "--version",
40
+ help="Show the installed checkpointflow version.",
41
+ callback=_version_callback,
42
+ is_eager=True,
43
+ ),
44
+ ] = False,
45
+ ) -> None:
46
+ del version
47
+
48
+
49
+ @app.command()
50
+ def validate(
51
+ file: Annotated[
52
+ Path,
53
+ typer.Option("-f", "--file", help="Path to workflow YAML file."),
54
+ ],
55
+ ) -> None:
56
+ """Validate a workflow YAML file against the checkpointflow schema."""
57
+ try:
58
+ if not file.exists():
59
+ _emit(
60
+ Envelope.failure(
61
+ command="validate",
62
+ error_code=ErrorCode.ERR_FILE_NOT_FOUND,
63
+ message=f"Workflow file not found: {file}",
64
+ exit_code=ExitCode.VALIDATION_ERROR,
65
+ )
66
+ )
67
+
68
+ if file.is_dir():
69
+ _emit(
70
+ Envelope.failure(
71
+ command="validate",
72
+ error_code=ErrorCode.ERR_VALIDATION_WORKFLOW,
73
+ message=f"Expected a file, got a directory: {file}",
74
+ exit_code=ExitCode.VALIDATION_ERROR,
75
+ )
76
+ )
77
+
78
+ try:
79
+ with file.open() as f:
80
+ doc = yaml.safe_load(f)
81
+ except yaml.YAMLError as exc:
82
+ _emit(
83
+ Envelope.failure(
84
+ command="validate",
85
+ error_code=ErrorCode.ERR_YAML_PARSE,
86
+ message=f"YAML parse error: {exc}",
87
+ exit_code=ExitCode.VALIDATION_ERROR,
88
+ )
89
+ )
90
+
91
+ errors = validate_workflow_document(doc)
92
+ if errors:
93
+ _emit(
94
+ Envelope.failure(
95
+ command="validate",
96
+ error_code=ErrorCode.ERR_VALIDATION_WORKFLOW,
97
+ message=f"Workflow validation failed with {len(errors)} error(s).",
98
+ exit_code=ExitCode.VALIDATION_ERROR,
99
+ details=errors,
100
+ )
101
+ )
102
+
103
+ # Check for duplicate step IDs
104
+ workflow = doc["workflow"]
105
+ step_ids = [s["id"] for s in workflow.get("steps", [])]
106
+ seen: set[str] = set()
107
+ duplicates = []
108
+ for sid in step_ids:
109
+ if sid in seen:
110
+ duplicates.append(sid)
111
+ seen.add(sid)
112
+ if duplicates:
113
+ _emit(
114
+ Envelope.failure(
115
+ command="validate",
116
+ error_code=ErrorCode.ERR_DUPLICATE_STEP_ID,
117
+ message=f"Duplicate step ID(s): {', '.join(duplicates)}",
118
+ exit_code=ExitCode.VALIDATION_ERROR,
119
+ )
120
+ )
121
+
122
+ _emit(
123
+ Envelope.success(
124
+ command="validate",
125
+ workflow_id=workflow["id"],
126
+ workflow_version=workflow.get("version"),
127
+ )
128
+ )
129
+ except typer.Exit:
130
+ raise
131
+ except Exception as exc:
132
+ _emit(
133
+ Envelope.failure(
134
+ command="validate",
135
+ error_code=ErrorCode.ERR_INTERNAL,
136
+ message=f"Internal error: {exc}",
137
+ exit_code=ExitCode.INTERNAL_ERROR,
138
+ )
139
+ )
140
+
141
+
142
+ @app.command()
143
+ def run(
144
+ file: Annotated[
145
+ Path,
146
+ typer.Option("-f", "--file", help="Path to workflow YAML file."),
147
+ ],
148
+ input_data: Annotated[
149
+ str,
150
+ typer.Option("--input", help="Input JSON (inline or @file path)."),
151
+ ],
152
+ ) -> None:
153
+ """Run a workflow from start to completion or next wait point."""
154
+ from checkpointflow.engine.runner import run_workflow
155
+
156
+ envelope = run_workflow(file, input_data, base_dir=_get_base_dir())
157
+ _emit(envelope)
158
+
159
+
160
+ def _get_base_dir() -> Path | None:
161
+ import os
162
+
163
+ base_dir_str = os.environ.get("CHECKPOINTFLOW_BASE_DIR")
164
+ return Path(base_dir_str) if base_dir_str else None
165
+
166
+
167
+ @app.command()
168
+ def resume(
169
+ run_id: Annotated[
170
+ str,
171
+ typer.Option("--run-id", help="Run ID to resume."),
172
+ ],
173
+ event: Annotated[
174
+ str,
175
+ typer.Option("--event", help="Event name."),
176
+ ],
177
+ input_data: Annotated[
178
+ str,
179
+ typer.Option("--input", help="Event input JSON (inline or @file)."),
180
+ ],
181
+ ) -> None:
182
+ """Resume a waiting run with an event payload."""
183
+ from checkpointflow.engine.runner import resume_workflow
184
+
185
+ envelope = resume_workflow(run_id, event, input_data, base_dir=_get_base_dir())
186
+ _emit(envelope)
187
+
188
+
189
+ @app.command()
190
+ def status(
191
+ run_id: Annotated[
192
+ str,
193
+ typer.Option("--run-id", help="Run ID to query."),
194
+ ],
195
+ ) -> None:
196
+ """Query the current status of a run."""
197
+ from checkpointflow.engine.queries import query_status
198
+
199
+ envelope = query_status(run_id, base_dir=_get_base_dir())
200
+ _emit(envelope)
201
+
202
+
203
+ @app.command()
204
+ def inspect(
205
+ run_id: Annotated[
206
+ str,
207
+ typer.Option("--run-id", help="Run ID to inspect."),
208
+ ],
209
+ ) -> None:
210
+ """Inspect detailed execution history and state of a run."""
211
+ from checkpointflow.engine.queries import query_inspect
212
+
213
+ envelope = query_inspect(run_id, base_dir=_get_base_dir())
214
+ _emit(envelope)
215
+
216
+
217
+ _INIT_TEMPLATE = """\
218
+ schema_version: checkpointflow/v1
219
+ workflow:
220
+ id: my_workflow
221
+ name: My Workflow
222
+ version: 0.1.0
223
+
224
+ inputs:
225
+ type: object
226
+ required: [name]
227
+ properties:
228
+ name:
229
+ type: string
230
+
231
+ steps:
232
+ - id: greet
233
+ kind: cli
234
+ command: echo "Hello, ${inputs.name}"
235
+
236
+ - id: done
237
+ kind: end
238
+ result:
239
+ status: completed
240
+ """
241
+
242
+
243
+ @app.command()
244
+ def guide() -> None:
245
+ """Print the checkpointflow user guide."""
246
+ import importlib.resources
247
+
248
+ guide_file = importlib.resources.files("checkpointflow.docs").joinpath("guide.md")
249
+ typer.echo(guide_file.read_text(encoding="utf-8"))
250
+
251
+
252
+ @app.command()
253
+ def init(
254
+ file: Annotated[
255
+ Path,
256
+ typer.Option("--file", help="Output path for the workflow file."),
257
+ ] = Path("checkpointflow.yaml"),
258
+ force: Annotated[
259
+ bool,
260
+ typer.Option("--force", help="Overwrite existing file."),
261
+ ] = False,
262
+ ) -> None:
263
+ """Scaffold a new workflow YAML file."""
264
+ if file.exists() and not force:
265
+ _emit(
266
+ Envelope.failure(
267
+ command="init",
268
+ error_code=ErrorCode.ERR_FILE_EXISTS,
269
+ message=f"File already exists: {file}. Use --force to overwrite.",
270
+ exit_code=ExitCode.VALIDATION_ERROR,
271
+ )
272
+ )
273
+ try:
274
+ file.write_text(_INIT_TEMPLATE)
275
+ except OSError as exc:
276
+ _emit(
277
+ Envelope.failure(
278
+ command="init",
279
+ error_code=ErrorCode.ERR_PERSISTENCE,
280
+ message=f"Failed to write file: {exc}",
281
+ exit_code=ExitCode.PERSISTENCE_ERROR,
282
+ )
283
+ )
284
+ _emit(
285
+ Envelope.success(
286
+ command="init",
287
+ result={"file": str(file)},
288
+ )
289
+ )
290
+
291
+
292
+ @app.command()
293
+ def cancel(
294
+ run_id: Annotated[
295
+ str,
296
+ typer.Option("--run-id", help="Run ID to cancel."),
297
+ ],
298
+ reason: Annotated[
299
+ str,
300
+ typer.Option("--reason", help="Reason for cancellation."),
301
+ ],
302
+ ) -> None:
303
+ """Cancel a waiting or running run."""
304
+ from checkpointflow.engine.runner import cancel_run
305
+
306
+ envelope = cancel_run(run_id, reason, base_dir=_get_base_dir())
307
+ _emit(envelope)
308
+
309
+
310
+ def main() -> None:
311
+ app()
File without changes
@@ -0,0 +1,333 @@
1
+ # checkpointflow guide
2
+
3
+ ## What checkpointflow is
4
+
5
+ checkpointflow is a deterministic, resumable, agent-agnostic CLI toolchain for authoring and running workflows defined in YAML.
6
+
7
+ It is designed for workflows that mix:
8
+ - deterministic machine steps (CLI commands)
9
+ - explicit pause/resume for user or agent input
10
+ - durable execution across restarts and handoffs
11
+
12
+ Typical use cases:
13
+ - turn a conversation into a YAML workflow that stitches together actions and interaction points
14
+ - encode existing runbooks that mix CLIs, human review, and agent judgment
15
+ - pause and resume long-lived workflows across agents or operators without hidden context
16
+
17
+ ## Authoring model
18
+
19
+ A common loop is:
20
+
21
+ 1. a user and agent work through a task in conversation
22
+ 2. the agent extracts inputs, steps, interactions, and transitions
23
+ 3. the agent writes a `checkpointflow/v1` YAML file
24
+ 4. `cpf validate` checks the document against the schema
25
+ 5. `cpf run` executes it and emits structured envelopes
26
+ 6. `cpf resume` continues the run when external input arrives
27
+
28
+ Workflow files are location-independent. They can live anywhere on disk.
29
+
30
+ Runtime state is separate from workflow location. By default, checkpointflow stores run state under `~/.checkpointflow/`.
31
+
32
+ ## Workflow file
33
+
34
+ Top-level structure:
35
+
36
+ ```yaml
37
+ schema_version: checkpointflow/v1
38
+ workflow:
39
+ id: my_workflow
40
+ name: My workflow
41
+ version: 0.1.0
42
+ inputs: { ... }
43
+ steps: [ ... ]
44
+ outputs: { ... }
45
+ ```
46
+
47
+ Steps execute sequentially. Each step must have a unique `id`. If no `end` step is present, the workflow completes implicitly after the last step with no result.
48
+
49
+ ## Step kinds
50
+
51
+ ### cli (supported)
52
+
53
+ Runs a shell command. The command string supports `${inputs.x}` and `${steps.<id>.outputs.x}` interpolation.
54
+
55
+ Required fields: `id`, `kind: cli`, `command`
56
+
57
+ Optional fields:
58
+ - `outputs` — JSON Schema for expected stdout JSON. If defined, stdout must be valid JSON matching this schema or the step fails.
59
+ - `timeout_seconds` — kill the process after this many seconds
60
+ - `shell` — shell to use (default: system shell)
61
+ - `retry` — retry configuration with `max_attempts`, `backoff_seconds`, `strategy` (`fixed` or `exponential`). Note: retry is accepted by the schema but not yet enforced by the runtime.
62
+ - `if` — expression that must evaluate to true for the step to run (e.g., `inputs.mode == "full"`)
63
+
64
+ ```yaml
65
+ - id: plan
66
+ kind: cli
67
+ command: my-tool plan --id ${inputs.page_id} --format json
68
+ timeout_seconds: 300
69
+ outputs:
70
+ type: object
71
+ required: [plan_file]
72
+ properties:
73
+ plan_file: { type: string }
74
+ ```
75
+
76
+ ### await_event (supported)
77
+
78
+ Pauses execution and waits for explicit external input. The CLI exits with code 40 and returns a waiting envelope containing everything needed to resume.
79
+
80
+ Required fields: `id`, `kind: await_event`, `audience`, `event_name`, `input_schema`
81
+
82
+ Optional fields:
83
+ - `prompt` — human-readable description of what input is needed (included in the waiting envelope when present)
84
+ - `summary` — short summary for display
85
+ - `transitions` — list of `{when, next}` rules evaluated against the event data on resume. If transitions are defined, the first matching `when` condition determines which step to jump to. If no transitions are defined, execution continues to the next step in the array.
86
+
87
+ ```yaml
88
+ - id: approval
89
+ kind: await_event
90
+ audience: user
91
+ event_name: change_approval
92
+ prompt: Approve or reject the proposed change.
93
+ input_schema:
94
+ type: object
95
+ required: [decision]
96
+ properties:
97
+ decision:
98
+ type: string
99
+ enum: [approve, reject]
100
+ transitions:
101
+ - when: ${event.decision == "approve"}
102
+ next: apply
103
+ - when: ${event.decision == "reject"}
104
+ next: rejected
105
+ ```
106
+
107
+ ### end (supported)
108
+
109
+ Terminates the run with an explicit result. The result can be any JSON value.
110
+
111
+ Required fields: `id`, `kind: end`
112
+
113
+ Optional fields:
114
+ - `result` — the value to return in the completed envelope
115
+
116
+ ```yaml
117
+ - id: done
118
+ kind: end
119
+ result:
120
+ status: completed
121
+ page_url: ${steps.apply.outputs.page_url}
122
+ ```
123
+
124
+ ### Planned step kinds (not yet supported at runtime)
125
+
126
+ The following step kinds are defined in the schema but not yet implemented. They will return exit code 80 (`ERR_UNSUPPORTED_STEP`) if encountered during `cpf run`. The `api` and `workflow` kinds pass `cpf validate`; the others may require exact schema compliance.
127
+
128
+ - **api** — HTTP call. Required: `method`, `url`.
129
+ - **workflow** — subflow invocation. Required: `workflow_ref`.
130
+ - **switch** — conditional branching. Required: `cases` (array of `{when, next}`).
131
+ - **foreach** — iteration. Required: `items`, plus `body` or `workflow_ref`.
132
+ - **parallel** — concurrent branches. Required: `branches` (array of `{start_at}`).
133
+
134
+ ## Common step fields
135
+
136
+ All step kinds accept these optional fields:
137
+
138
+ - `name` — human-readable step name
139
+ - `description` — longer description
140
+ - `if` — conditional expression; step is skipped when false
141
+ - `timeout_seconds` — maximum execution time
142
+ - `risk_level` — `low`, `medium`, or `high`
143
+ - `retry` — `{max_attempts, backoff_seconds, strategy}`
144
+ - `outputs` — JSON Schema for step output validation
145
+ - `tags` — list of string tags
146
+
147
+ ## Expressions
148
+
149
+ Expressions appear in `command` strings, `if` conditions, and transition `when` clauses.
150
+
151
+ Syntax: `${<expression>}`
152
+
153
+ Path lookups:
154
+ - `inputs.<field>` — workflow input values
155
+ - `steps.<step_id>.outputs.<field>` — output from a previous step
156
+ - `event.<field>` — event data (only available in transition `when` clauses after resume)
157
+
158
+ Comparison operators: `==`, `!=`
159
+
160
+ Boolean operators: `and`, `or`
161
+
162
+ Examples:
163
+ - `${inputs.page_id}` — interpolated into a command string
164
+ - `inputs.mode == "full"` — used in an `if` condition (no `${}` wrapper needed)
165
+ - `${event.decision == "approve"}` — used in a transition `when` clause
166
+
167
+ ## CLI commands
168
+
169
+ Authoring and documentation:
170
+
171
+ ```bash
172
+ cpf init # scaffold a new workflow YAML file
173
+ cpf init --file path/to/workflow.yaml # scaffold at a specific path
174
+ cpf validate -f workflow.yaml # validate against the schema
175
+ cpf guide # print this guide
176
+ ```
177
+
178
+ Execution and inspection:
179
+
180
+ ```bash
181
+ cpf run -f workflow.yaml --input @input.json
182
+ cpf run -f workflow.yaml --input '{"key": "value"}'
183
+ cpf resume --run-id <run_id> --event <event_name> --input @event.json
184
+ cpf status --run-id <run_id>
185
+ cpf inspect --run-id <run_id>
186
+ cpf cancel --run-id <run_id> --reason "..."
187
+ ```
188
+
189
+ The `--input` flag accepts either inline JSON or `@path/to/file.json` (reads from file).
190
+
191
+ The `-f`/`--file` path may point anywhere. Workflow location does not affect where run state is persisted.
192
+
193
+ Note: `status` and `inspect` return the run's state exit code (e.g., 40 for waiting, 30 for failed), not the query's success code. A successful query of a failed run returns exit code 30.
194
+
195
+ ## JSON result envelope
196
+
197
+ All commands return a stable JSON envelope on stdout. The envelope always contains:
198
+ - `schema_version` — always `"checkpointflow-run/v1"`
199
+ - `ok` — `true` on success, `false` on failure
200
+ - `command` — the command that was run
201
+ - `status` — `completed`, `waiting`, `failed`, `cancelled`, etc.
202
+ - `exit_code` — numeric exit code
203
+
204
+ Success example:
205
+
206
+ ```json
207
+ {
208
+ "schema_version": "checkpointflow-run/v1",
209
+ "ok": true,
210
+ "command": "run",
211
+ "status": "completed",
212
+ "exit_code": 0,
213
+ "run_id": "a1b2c3...",
214
+ "workflow_id": "my_workflow",
215
+ "result": { "status": "done" }
216
+ }
217
+ ```
218
+
219
+ Error example:
220
+
221
+ ```json
222
+ {
223
+ "schema_version": "checkpointflow-run/v1",
224
+ "ok": false,
225
+ "command": "run",
226
+ "status": "failed",
227
+ "exit_code": 30,
228
+ "error": {
229
+ "code": "ERR_STEP_FAILED",
230
+ "message": "Step 'deploy' exited with code 1"
231
+ }
232
+ }
233
+ ```
234
+
235
+ ## Waiting behavior
236
+
237
+ When execution reaches an `await_event` step, the CLI:
238
+ - persists the run state to `~/.checkpointflow/`
239
+ - exits with code 40
240
+ - writes a waiting envelope to stdout
241
+
242
+ The waiting envelope includes a `wait` block with everything needed to resume:
243
+
244
+ ```json
245
+ {
246
+ "schema_version": "checkpointflow-run/v1",
247
+ "ok": true,
248
+ "command": "run",
249
+ "status": "waiting",
250
+ "exit_code": 40,
251
+ "run_id": "a1b2c3...",
252
+ "current_step_id": "approval",
253
+ "wait": {
254
+ "kind": "external_event",
255
+ "audience": "user",
256
+ "event_name": "approve_change",
257
+ "prompt": "Review the change and approve or reject it.",
258
+ "input_schema": {
259
+ "type": "object",
260
+ "required": ["decision"],
261
+ "properties": {
262
+ "decision": { "type": "string", "enum": ["approve", "reject"] }
263
+ }
264
+ },
265
+ "instructions": [
266
+ "Ask the intended audience for input using the prompt.",
267
+ "Collect JSON that matches input_schema.",
268
+ "Resume with the provided run_id and event_name."
269
+ ],
270
+ "resume": {
271
+ "command": "cpf resume --run-id a1b2c3... --event approve_change --input @response.json"
272
+ }
273
+ }
274
+ }
275
+ ```
276
+
277
+ Exit code 40 means "waiting for input" — it is not a failure.
278
+
279
+ ## Exit codes
280
+
281
+ | Code | Meaning |
282
+ |------|---------|
283
+ | 0 | Success |
284
+ | 10 | Validation error (workflow, input, or event) |
285
+ | 20 | Runtime error |
286
+ | 30 | Step failed |
287
+ | 40 | Waiting for external event (not a failure) |
288
+ | 50 | Cancelled |
289
+ | 60 | Persistence error |
290
+ | 80 | Unsupported feature (step kind not yet implemented) |
291
+ | 90 | Internal error |
292
+
293
+ ## Error codes
294
+
295
+ Machine-readable error codes in the `error.code` field:
296
+
297
+ | Code | Meaning |
298
+ |------|---------|
299
+ | `ERR_VALIDATION_WORKFLOW` | Workflow YAML does not match the schema |
300
+ | `ERR_VALIDATION_INPUT` | Input JSON does not match the workflow's input schema |
301
+ | `ERR_VALIDATION_EVENT_INPUT` | Resume event JSON does not match the expected schema |
302
+ | `ERR_FILE_NOT_FOUND` | Specified file does not exist |
303
+ | `ERR_FILE_EXISTS` | File already exists (e.g., `cpf init` without `--force`) |
304
+ | `ERR_YAML_PARSE` | YAML syntax error |
305
+ | `ERR_STEP_FAILED` | CLI step exited with a non-success code |
306
+ | `ERR_STEP_OUTPUT_INVALID` | Step stdout does not match the declared outputs schema |
307
+ | `ERR_TIMEOUT` | Step exceeded its timeout |
308
+ | `ERR_UNSUPPORTED_STEP` | Step kind is not supported in this version |
309
+ | `ERR_DUPLICATE_STEP_ID` | Two or more steps share the same ID |
310
+ | `ERR_PERSISTENCE` | Database or file write failed |
311
+ | `ERR_RESUME_EVENT_MISMATCH` | Resume event name does not match what the run expects |
312
+ | `ERR_RUN_NOT_WAITING` | Attempted to resume or cancel a run that is not in a waiting state |
313
+ | `ERR_RUN_NOT_FOUND` | No run exists with the given ID |
314
+ | `ERR_INTERNAL` | Unexpected internal error |
315
+
316
+ ## Example run loop
317
+
318
+ ```bash
319
+ # Start the workflow
320
+ cpf run -f publish.yaml --input @input.json
321
+
322
+ # If it pauses (exit code 40), gather the requested input and resume
323
+ cpf resume --run-id <run_id> --event approve_change --input @response.json
324
+
325
+ # Check status at any time
326
+ cpf status --run-id <run_id>
327
+
328
+ # View full execution history
329
+ cpf inspect --run-id <run_id>
330
+
331
+ # Cancel a waiting run
332
+ cpf cancel --run-id <run_id> --reason "No longer needed"
333
+ ```
@@ -0,0 +1 @@
1
+ from __future__ import annotations