runspool 0.1.0__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.
runspool/__init__.py ADDED
@@ -0,0 +1,14 @@
1
+ """Runspool: a local-first CLI workflow engine for reliable personal automation.
2
+
3
+ Runspool turns local scripts, files, and manual checklists into resumable,
4
+ observable workflows backed by SQLite. Tasks move through an ordered list of
5
+ steps; every transition is recorded, every step run is timed, and the whole
6
+ lifecycle (pause, resume, retry, terminate) is controllable from the CLI and
7
+ readable as JSON for scripts and AI agents.
8
+ """
9
+
10
+ from __future__ import annotations
11
+
12
+ __version__ = "0.1.0"
13
+
14
+ __all__ = ["__version__"]
runspool/app.py ADDED
@@ -0,0 +1,85 @@
1
+ """Application context: assembles config and persistence for the CLI and daemon."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from dataclasses import dataclass
6
+ from pathlib import Path
7
+
8
+ from runspool.config import AppConfig
9
+ from runspool.persistence.connection import Database
10
+ from runspool.persistence.event_log import EventLog
11
+ from runspool.persistence.repository import TaskRepository
12
+ from runspool.persistence.state_machine import StateMachine
13
+ from runspool.persistence.step_run_log import StepRunLog
14
+
15
+ DEFAULT_CONFIG_FILENAME = "config.yaml"
16
+
17
+
18
+ @dataclass
19
+ class AppContext:
20
+ config: AppConfig
21
+ db: Database
22
+ repo: TaskRepository
23
+ log: EventLog
24
+ step_runs: StepRunLog
25
+
26
+ def state_machine(self, workflow_name: str) -> StateMachine:
27
+ return StateMachine(self.repo, self.log, workflow=self.config.workflow(workflow_name))
28
+
29
+
30
+ def load_context(config_path: Path | str) -> AppContext:
31
+ config = AppConfig.load(Path(config_path))
32
+ db = Database(config.database_path)
33
+ db.init()
34
+ return AppContext(
35
+ config=config,
36
+ db=db,
37
+ repo=TaskRepository(db),
38
+ log=EventLog(db),
39
+ step_runs=StepRunLog(db),
40
+ )
41
+
42
+
43
+ def config_template(workspace_root: Path | str) -> str:
44
+ return f"""# Runspool configuration
45
+ # Local-first: all state lives under workspace_root. Nothing is sent anywhere.
46
+ workspace_root: {workspace_root}
47
+
48
+ scheduler:
49
+ poll_interval_seconds: 5 # how often the daemon looks for work
50
+ max_retries: 3 # default retry budget per task
51
+ retry_delay_seconds: 0 # 0 = retry immediately; >0 = backoff (driven by the daemon)
52
+
53
+ worker_pool:
54
+ size: 4 # concurrent step executions
55
+ heartbeat_timeout_seconds: 1800
56
+
57
+ # Per-step concurrency quota (defaults to 1). Raise it for cheap, parallelizable
58
+ # steps; keep it at 1 for steps that must not overlap.
59
+ concurrency: {{}}
60
+
61
+ # Workflows are ordered lists of step names. The built-in steps below need no
62
+ # network, API keys, or external tools.
63
+ workflows:
64
+ local_file:
65
+ steps: [ingest_file, classify_text, normalize_markdown, summarize_text, archive]
66
+
67
+ # Load custom steps from your own code. See docs/writing-steps.md.
68
+ # plugin_paths: [steps] # directories added to sys.path (relative to this file)
69
+ # steps:
70
+ # my_custom_step:
71
+ # import: "my_module:MyCustomStep"
72
+ """
73
+
74
+
75
+ def init_app(config_path: Path | str, *, workspace_root: Path | str) -> bool:
76
+ """Generate config (without overwriting an existing one) and initialise the
77
+ database. Returns whether a new config file was created."""
78
+ config_path = Path(config_path)
79
+ created = False
80
+ if not config_path.exists():
81
+ config_path.parent.mkdir(parents=True, exist_ok=True)
82
+ config_path.write_text(config_template(workspace_root), encoding="utf-8")
83
+ created = True
84
+ load_context(config_path) # initialises the database
85
+ return created
@@ -0,0 +1,41 @@
1
+ """Built-in steps: a small, dependency-free local-file pipeline.
2
+
3
+ These steps require no network, API keys, or external binaries. They exist to
4
+ make Runspool runnable the moment it is installed and to serve as readable
5
+ reference implementations of the step contract:
6
+
7
+ ingest_file -> classify_text -> normalize_markdown -> summarize_text -> archive
8
+ """
9
+
10
+ from __future__ import annotations
11
+
12
+ from runspool.builtin_steps.archive import ArchiveStep
13
+ from runspool.builtin_steps.file_intake import FileIntakeStep
14
+ from runspool.builtin_steps.markdown_normalize import MarkdownNormalizeStep
15
+ from runspool.builtin_steps.text_classify import TextClassifyStep
16
+ from runspool.builtin_steps.text_summarize import TextSummarizeStep
17
+ from runspool.engine.registry import StepRegistry
18
+
19
+ BUILTIN_STEP_CLASSES = (
20
+ FileIntakeStep,
21
+ TextClassifyStep,
22
+ MarkdownNormalizeStep,
23
+ TextSummarizeStep,
24
+ ArchiveStep,
25
+ )
26
+
27
+ __all__ = [
28
+ "FileIntakeStep",
29
+ "TextClassifyStep",
30
+ "MarkdownNormalizeStep",
31
+ "TextSummarizeStep",
32
+ "ArchiveStep",
33
+ "BUILTIN_STEP_CLASSES",
34
+ "register_builtins",
35
+ ]
36
+
37
+
38
+ def register_builtins(registry: StepRegistry) -> None:
39
+ """Register every built-in step into ``registry``."""
40
+ for cls in BUILTIN_STEP_CLASSES:
41
+ registry.register(cls())
@@ -0,0 +1,25 @@
1
+ """archive: move the task workspace into the ``ready/`` directory.
2
+
3
+ This bounds the local automation: once a task is archived, its artifacts live in
4
+ a stable, predictable location and the active workspace is freed.
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ import shutil
10
+
11
+ from runspool.builtin_steps.workspace import archive_dir, task_workspace
12
+ from runspool.engine.step import Step, StepContext, StepResult
13
+
14
+
15
+ class ArchiveStep(Step):
16
+ name = "archive"
17
+
18
+ def run(self, ctx: StepContext) -> StepResult:
19
+ ws = task_workspace(ctx.config, ctx.task)
20
+ ready = archive_dir(ctx.config, ctx.task)
21
+ ready.parent.mkdir(parents=True, exist_ok=True)
22
+ if ready.exists():
23
+ shutil.rmtree(ready)
24
+ shutil.move(str(ws), str(ready))
25
+ return StepResult(message=f"archived to {ready}")
@@ -0,0 +1,46 @@
1
+ """ingest_file: read the task input file into the workspace and record metadata."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import json
6
+ from pathlib import Path
7
+
8
+ from runspool.builtin_steps.workspace import task_workspace
9
+ from runspool.engine.step import Step, StepContext, StepResult
10
+
11
+
12
+ class FileIntakeStep(Step):
13
+ name = "ingest_file"
14
+
15
+ def run(self, ctx: StepContext) -> StepResult:
16
+ src = Path(ctx.task["input"]).expanduser()
17
+ if not src.exists():
18
+ raise FileNotFoundError(f"input file not found: {src}")
19
+ if not src.is_file():
20
+ raise ValueError(f"input is not a file: {src}")
21
+
22
+ ctx.heartbeat("reading input")
23
+ text = src.read_text(encoding="utf-8", errors="replace")
24
+ ws = task_workspace(ctx.config, ctx.task)
25
+ (ws / "source.txt").write_text(text, encoding="utf-8")
26
+
27
+ metadata = {
28
+ "original_path": str(src),
29
+ "original_name": src.name,
30
+ "suffix": src.suffix,
31
+ "size_bytes": src.stat().st_size,
32
+ "line_count": text.count("\n") + (1 if text and not text.endswith("\n") else 0),
33
+ "word_count": len(text.split()),
34
+ "char_count": len(text),
35
+ }
36
+ (ws / "metadata.json").write_text(
37
+ json.dumps(metadata, indent=2, ensure_ascii=False) + "\n", encoding="utf-8"
38
+ )
39
+
40
+ # Give the task a readable name if it does not have one yet.
41
+ updates = {}
42
+ if not ctx.task.get("name"):
43
+ updates["name"] = src.stem
44
+ return StepResult(
45
+ message=f"ingested {src.name} ({metadata['word_count']} words)", updates=updates
46
+ )
@@ -0,0 +1,35 @@
1
+ """normalize_markdown: tidy the source text into clean Markdown."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import re
6
+
7
+ from runspool.builtin_steps.workspace import read_source, task_workspace
8
+ from runspool.engine.step import Step, StepContext, StepResult
9
+
10
+ _BLANK_RUN = re.compile(r"\n{3,}")
11
+
12
+
13
+ def normalize(text: str, *, title: str | None = None) -> str:
14
+ # Strip trailing whitespace from every line.
15
+ lines = [line.rstrip() for line in text.splitlines()]
16
+ body = "\n".join(lines).strip("\n")
17
+ # Collapse runs of 3+ blank lines down to a single blank line.
18
+ body = _BLANK_RUN.sub("\n\n", body)
19
+ # Ensure the document opens with a level-1 heading.
20
+ has_heading = body.lstrip().startswith("#")
21
+ if not has_heading and title:
22
+ body = f"# {title}\n\n{body}"
23
+ return body.rstrip("\n") + "\n"
24
+
25
+
26
+ class MarkdownNormalizeStep(Step):
27
+ name = "normalize_markdown"
28
+
29
+ def run(self, ctx: StepContext) -> StepResult:
30
+ ws = task_workspace(ctx.config, ctx.task)
31
+ text = read_source(ws)
32
+ title = ctx.task.get("name") or "Document"
33
+ normalized = normalize(text, title=title)
34
+ (ws / "normalized.md").write_text(normalized, encoding="utf-8")
35
+ return StepResult(message=f"normalized to {len(normalized.splitlines())} lines")
@@ -0,0 +1,69 @@
1
+ """classify_text: assign a coarse category to the source text by keyword match.
2
+
3
+ Deterministic and offline: a tiny keyword model, not machine learning. It is
4
+ meant to show how a step turns input into a structured artifact that later steps
5
+ (or an operator) can branch on.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ import json
11
+
12
+ from runspool.builtin_steps.workspace import read_source, task_workspace
13
+ from runspool.engine.step import Step, StepContext, StepResult
14
+
15
+ # Ordered so the first category with the most matches wins ties predictably.
16
+ _CATEGORIES: dict[str, tuple[str, ...]] = {
17
+ "invoice": ("invoice", "amount due", "subtotal", "tax", "total", "bill to", "payment terms"),
18
+ "support_ticket": (
19
+ "ticket",
20
+ "issue",
21
+ "error",
22
+ "bug",
23
+ "cannot",
24
+ "can't",
25
+ "broken",
26
+ "support",
27
+ "reproduce",
28
+ ),
29
+ "meeting_notes": (
30
+ "meeting",
31
+ "agenda",
32
+ "attendees",
33
+ "action item",
34
+ "action items",
35
+ "next steps",
36
+ "minutes",
37
+ "discussed",
38
+ ),
39
+ }
40
+
41
+
42
+ def classify(text: str) -> dict:
43
+ lower = text.lower()
44
+ scores: dict[str, list[str]] = {}
45
+ for category, keywords in _CATEGORIES.items():
46
+ hits = [kw for kw in keywords if kw in lower]
47
+ if hits:
48
+ scores[category] = hits
49
+ if not scores:
50
+ return {"category": "general", "confidence": 0.0, "matched_keywords": []}
51
+ best = max(scores, key=lambda c: len(scores[c]))
52
+ hits = scores[best]
53
+ confidence = round(min(len(hits) / 3.0, 1.0), 2)
54
+ return {"category": best, "confidence": confidence, "matched_keywords": hits}
55
+
56
+
57
+ class TextClassifyStep(Step):
58
+ name = "classify_text"
59
+
60
+ def run(self, ctx: StepContext) -> StepResult:
61
+ ws = task_workspace(ctx.config, ctx.task)
62
+ text = read_source(ws)
63
+ result = classify(text)
64
+ (ws / "classification.json").write_text(
65
+ json.dumps(result, indent=2, ensure_ascii=False) + "\n", encoding="utf-8"
66
+ )
67
+ return StepResult(
68
+ message=f"classified as {result['category']} (confidence {result['confidence']})"
69
+ )
@@ -0,0 +1,81 @@
1
+ """summarize_text: produce a short extractive summary of the normalized document.
2
+
3
+ Offline and deterministic: counts, leading sentences, and top keywords by
4
+ frequency. No model calls. The point is to demonstrate a step that consumes a
5
+ prior step's artifact and emits a human-facing one.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ import json
11
+ import re
12
+ from collections import Counter
13
+
14
+ from runspool.builtin_steps.workspace import task_workspace
15
+ from runspool.engine.step import Step, StepContext, StepResult
16
+
17
+ _SENTENCE_SPLIT = re.compile(r"(?<=[.!?])\s+")
18
+ _WORD = re.compile(r"[A-Za-z][A-Za-z'-]+")
19
+
20
+ # Common words excluded from keyword ranking.
21
+ _STOPWORDS = frozenset(
22
+ """the a an and or but of to in on for with at by from as is are was were be been
23
+ being this that these those it its we you they he she them our your their i me my
24
+ will would can could should may might must shall not no yes do does did have has had
25
+ if then else when while which who whom whose what where why how all any each more
26
+ most some such than too very just about into over under again further once""".split()
27
+ )
28
+
29
+
30
+ def summarize(text: str, *, max_sentences: int = 3, top_keywords: int = 8) -> dict:
31
+ # Drop Markdown heading lines from sentence selection.
32
+ body_lines = [ln for ln in text.splitlines() if not ln.lstrip().startswith("#")]
33
+ body = " ".join(ln.strip() for ln in body_lines if ln.strip())
34
+ sentences = [s.strip() for s in _SENTENCE_SPLIT.split(body) if s.strip()]
35
+ lead = sentences[:max_sentences]
36
+
37
+ words = [w.lower() for w in _WORD.findall(text)]
38
+ meaningful = [w for w in words if w not in _STOPWORDS and len(w) > 2]
39
+ keywords = [w for w, _ in Counter(meaningful).most_common(top_keywords)]
40
+
41
+ return {
42
+ "word_count": len(words),
43
+ "sentence_count": len(sentences),
44
+ "summary_sentences": lead,
45
+ "keywords": keywords,
46
+ }
47
+
48
+
49
+ def render_markdown(summary: dict, *, title: str) -> str:
50
+ lines = [f"# Summary: {title}", ""]
51
+ lines.append(f"- Words: {summary['word_count']}")
52
+ lines.append(f"- Sentences: {summary['sentence_count']}")
53
+ if summary["keywords"]:
54
+ lines.append(f"- Keywords: {', '.join(summary['keywords'])}")
55
+ lines.append("")
56
+ lines.append("## Lead")
57
+ lines.append("")
58
+ if summary["summary_sentences"]:
59
+ for s in summary["summary_sentences"]:
60
+ lines.append(f"- {s}")
61
+ else:
62
+ lines.append("- (no extractable sentences)")
63
+ return "\n".join(lines) + "\n"
64
+
65
+
66
+ class TextSummarizeStep(Step):
67
+ name = "summarize_text"
68
+
69
+ def run(self, ctx: StepContext) -> StepResult:
70
+ ws = task_workspace(ctx.config, ctx.task)
71
+ normalized_path = ws / "normalized.md"
72
+ source = normalized_path if normalized_path.exists() else ws / "source.txt"
73
+ text = source.read_text(encoding="utf-8")
74
+ title = ctx.task.get("name") or "Document"
75
+
76
+ summary = summarize(text)
77
+ (ws / "summary.json").write_text(
78
+ json.dumps(summary, indent=2, ensure_ascii=False) + "\n", encoding="utf-8"
79
+ )
80
+ (ws / "summary.md").write_text(render_markdown(summary, title=title), encoding="utf-8")
81
+ return StepResult(message=f"summarized {summary['word_count']} words")
@@ -0,0 +1,44 @@
1
+ """Task workspace helpers.
2
+
3
+ The filesystem is the artifact store. Each task gets an isolated directory under
4
+ ``<workspace_root>/tasks/<id>/``; steps read and write files there.
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ from pathlib import Path
10
+ from typing import Any
11
+
12
+
13
+ def task_workspace(config: Any, task: dict[str, Any]) -> Path:
14
+ """Return (creating if needed) the working directory for a task."""
15
+ ws = Path(config.workspace_root) / "tasks" / str(task["id"])
16
+ ws.mkdir(parents=True, exist_ok=True)
17
+ return ws
18
+
19
+
20
+ def archive_dir(config: Any, task: dict[str, Any]) -> Path:
21
+ """Return the destination directory for an archived task."""
22
+ return Path(config.workspace_root) / "ready" / str(task["id"])
23
+
24
+
25
+ def read_source(ws: Path) -> str:
26
+ """Read the canonical source text written by the intake step."""
27
+ return (ws / "source.txt").read_text(encoding="utf-8")
28
+
29
+
30
+ def list_artifacts(config: Any, task: dict[str, Any]) -> list[str]:
31
+ """List artifact files for a task as paths relative to ``workspace_root``.
32
+
33
+ Looks in both the active task directory and the archived directory, so a
34
+ completed (archived) task still reports its outputs.
35
+ """
36
+ root = Path(config.workspace_root)
37
+ found: list[str] = []
38
+ for base in (root / "tasks" / str(task["id"]), root / "ready" / str(task["id"])):
39
+ if not base.exists():
40
+ continue
41
+ for path in sorted(base.rglob("*")):
42
+ if path.is_file():
43
+ found.append(str(path.relative_to(root)))
44
+ return found