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 +14 -0
- runspool/app.py +85 -0
- runspool/builtin_steps/__init__.py +41 -0
- runspool/builtin_steps/archive.py +25 -0
- runspool/builtin_steps/file_intake.py +46 -0
- runspool/builtin_steps/markdown_normalize.py +35 -0
- runspool/builtin_steps/text_classify.py +69 -0
- runspool/builtin_steps/text_summarize.py +81 -0
- runspool/builtin_steps/workspace.py +44 -0
- runspool/cli.py +337 -0
- runspool/clock.py +42 -0
- runspool/commands.py +96 -0
- runspool/config.py +127 -0
- runspool/daemon.py +74 -0
- runspool/display.py +154 -0
- runspool/doctor.py +79 -0
- runspool/engine/__init__.py +5 -0
- runspool/engine/coordinator.py +93 -0
- runspool/engine/registry.py +29 -0
- runspool/engine/runner.py +149 -0
- runspool/engine/step.py +68 -0
- runspool/engine/worker_pool.py +34 -0
- runspool/models.py +76 -0
- runspool/persistence/__init__.py +1 -0
- runspool/persistence/connection.py +41 -0
- runspool/persistence/event_log.py +40 -0
- runspool/persistence/repository.py +125 -0
- runspool/persistence/schema.py +51 -0
- runspool/persistence/state_machine.py +361 -0
- runspool/persistence/step_run_log.py +37 -0
- runspool/registry_builder.py +71 -0
- runspool/runtime.py +97 -0
- runspool/views.py +138 -0
- runspool-0.1.0.dist-info/METADATA +284 -0
- runspool-0.1.0.dist-info/RECORD +38 -0
- runspool-0.1.0.dist-info/WHEEL +4 -0
- runspool-0.1.0.dist-info/entry_points.txt +2 -0
- runspool-0.1.0.dist-info/licenses/LICENSE +21 -0
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
|