starforge-kernel 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.
starforge/mcp.py ADDED
@@ -0,0 +1,283 @@
1
+ """*Forge MCP server — agents author and run pipelines in the workspace.
2
+
3
+ Run over stdio: python -m starforge.mcp [workspace]
4
+
5
+ Tools wrap the same core engine the VS Code extension uses, file-based and
6
+ stateless: pipelines are the `.forge` JSON documents under
7
+ ``.forge/pipelines/``, blocks come from the static AST index, and runs
8
+ execute in the standard per-run worker subprocess. The `mcp` dependency is
9
+ optional — ``pip install starforge-kernel[mcp]``.
10
+
11
+ The plain functions below are the implementation; FastMCP registration is a
12
+ thin veneer so everything stays unit-testable without an MCP client.
13
+ """
14
+
15
+ from __future__ import annotations
16
+
17
+ import json
18
+ import os
19
+ from pathlib import Path
20
+ import subprocess
21
+ import sys
22
+ from typing import Any
23
+
24
+ from starforge.core.checkpoints import CheckpointStore
25
+ from starforge.core.provenance import compute_states, env_fingerprint
26
+ from starforge.core.spec import PipelineDoc
27
+ from starforge.index import scan_workspace
28
+ from starforge.kernel.server import BUILTIN_PALETTE
29
+
30
+
31
+ def _workspace(path: str) -> Path:
32
+ workspace = Path(path).resolve()
33
+ if not workspace.is_dir():
34
+ raise ValueError(f"workspace '{path}' is not a directory")
35
+ return workspace
36
+
37
+
38
+ def _pipeline_path(workspace: Path, name: str) -> Path:
39
+ candidate = (workspace / name).resolve()
40
+ if candidate.suffix != ".forge":
41
+ candidate = candidate.with_suffix(".forge")
42
+ if workspace not in candidate.parents and candidate != workspace:
43
+ raise ValueError("pipeline path escapes the workspace")
44
+ return candidate
45
+
46
+
47
+ def list_blocks(workspace: str = ".") -> list[dict[str, Any]]:
48
+ """Every block available in the workspace: builtins plus discovered
49
+ @block functions, with params/outputs/annotations/docstrings."""
50
+ ws = _workspace(workspace)
51
+ index, _ = scan_workspace(ws)
52
+ return list(BUILTIN_PALETTE) + [b.to_dict() for b in sorted(index.blocks.values(), key=lambda b: b.block_id)]
53
+
54
+
55
+ def list_pipelines(workspace: str = ".") -> list[str]:
56
+ """Workspace-relative paths of saved pipelines."""
57
+ ws = _workspace(workspace)
58
+ root = ws / ".forge" / "pipelines"
59
+ if not root.is_dir():
60
+ return []
61
+ return sorted(p.relative_to(ws).as_posix() for p in root.glob("*.forge"))
62
+
63
+
64
+ def read_pipeline(workspace: str, path: str) -> dict[str, Any]:
65
+ """The pipeline document (nodes, edges, comments) as JSON."""
66
+ ws = _workspace(workspace)
67
+ return PipelineDoc.load(_pipeline_path(ws, path)).to_dict()
68
+
69
+
70
+ def write_pipeline(workspace: str, path: str, doc: dict[str, Any]) -> dict[str, Any]:
71
+ """Validate and save a pipeline document; returns its node states so the
72
+ caller immediately sees problems and staleness."""
73
+ ws = _workspace(workspace)
74
+ pipeline = PipelineDoc.from_dict(doc) # validates the schema
75
+ target = _pipeline_path(ws, path)
76
+ target.parent.mkdir(parents=True, exist_ok=True)
77
+ pipeline.save(target)
78
+ return pipeline_state(workspace, path)
79
+
80
+
81
+ def pipeline_state(workspace: str, path: str) -> dict[str, Any]:
82
+ """Per-node history hash, staleness, and problems for a saved pipeline."""
83
+ ws = _workspace(workspace)
84
+ doc = PipelineDoc.load(_pipeline_path(ws, path))
85
+ index, _ = scan_workspace(ws)
86
+ store = CheckpointStore(ws)
87
+ states = compute_states(doc, index, env_fingerprint(ws), store.exists)
88
+ return {
89
+ "path": _pipeline_path(ws, path).relative_to(ws).as_posix(),
90
+ "nodes": {nid: state.to_dict() for nid, state in states.items()},
91
+ }
92
+
93
+
94
+ def run_pipeline(workspace: str, path: str, target: str | None = None, timeout_seconds: float = 600) -> dict[str, Any]:
95
+ """Run a saved pipeline (optionally only up to `target` node) in the
96
+ standard worker subprocess; blocks until done. Returns the event stream
97
+ summary and terminal status."""
98
+ ws = _workspace(workspace)
99
+ doc = PipelineDoc.load(_pipeline_path(ws, path))
100
+ index, _ = scan_workspace(ws)
101
+ store = CheckpointStore(ws)
102
+ store.ensure_layout()
103
+ states = compute_states(doc, index, env_fingerprint(ws), store.exists)
104
+ blocks = {
105
+ b.block_id: {
106
+ "module": b.module,
107
+ "qualname": b.qualname,
108
+ "label": b.label,
109
+ "outputs": b.outputs,
110
+ "source_hash": b.source_hash,
111
+ "optional_params": [p.name for p in b.params if p.optional and not p.has_default],
112
+ }
113
+ for b in index.blocks.values()
114
+ }
115
+ spec = {
116
+ "workspace": str(ws),
117
+ "doc": doc.to_dict(),
118
+ "blocks": blocks,
119
+ "states": {nid: s.to_dict() for nid, s in states.items()},
120
+ "pickle_enabled": False,
121
+ "target": target,
122
+ }
123
+ runs_dir = ws / ".forge" / "cache" / "runs"
124
+ runs_dir.mkdir(parents=True, exist_ok=True)
125
+ spec_path = runs_dir / "mcp_run.json"
126
+ spec_path.write_text(json.dumps(spec, default=repr), encoding="utf-8")
127
+
128
+ env = dict(os.environ)
129
+ package_root = str(Path(__file__).resolve().parent.parent)
130
+ env["PYTHONPATH"] = package_root + os.pathsep + env.get("PYTHONPATH", "")
131
+ try:
132
+ result = subprocess.run(
133
+ [sys.executable, "-m", "starforge.kernel.worker", str(spec_path)],
134
+ cwd=str(ws),
135
+ env=env,
136
+ stdin=subprocess.DEVNULL,
137
+ capture_output=True,
138
+ timeout=timeout_seconds,
139
+ )
140
+ finally:
141
+ spec_path.unlink(missing_ok=True)
142
+
143
+ events = []
144
+ for line in result.stdout.decode("utf-8", errors="replace").splitlines():
145
+ try:
146
+ events.append(json.loads(line))
147
+ except json.JSONDecodeError:
148
+ continue
149
+ status = next(
150
+ (e.get("status") for e in reversed(events) if e.get("event") == "run_finished"), "failed"
151
+ )
152
+ summary = {
153
+ "completed": [e["node"] for e in events if e.get("event") == "node_completed"],
154
+ "skipped": [e["node"] for e in events if e.get("event") == "node_skipped"],
155
+ "blocked": [e["node"] for e in events if e.get("event") == "node_blocked"],
156
+ "failed": {
157
+ e["node"]: e.get("traceback", "").strip().splitlines()[-1:]
158
+ for e in events
159
+ if e.get("event") == "node_failed"
160
+ },
161
+ }
162
+ return {"status": status, **summary, "states": pipeline_state(workspace, path)["nodes"]}
163
+
164
+
165
+ def inspect_node(workspace: str, path: str, node_id: str) -> dict[str, Any]:
166
+ """Checkpoint provenance for one node: outputs with previews, figures,
167
+ timing — or its staleness/problems when no checkpoint exists."""
168
+ ws = _workspace(workspace)
169
+ state = pipeline_state(workspace, path)["nodes"].get(node_id)
170
+ if state is None:
171
+ raise ValueError(f"node '{node_id}' not found in {path}")
172
+ store = CheckpointStore(ws)
173
+ history_hash = state.get("history_hash")
174
+ if not history_hash or not store.exists(history_hash):
175
+ return {"node": node_id, "state": state, "checkpoint": None}
176
+ return {"node": node_id, "state": state, "checkpoint": store.read_provenance(history_hash)}
177
+
178
+
179
+ TOOL_NAMES = (
180
+ "starforge_list_blocks",
181
+ "starforge_list_pipelines",
182
+ "starforge_read_pipeline",
183
+ "starforge_write_pipeline",
184
+ "starforge_pipeline_state",
185
+ "starforge_run_pipeline",
186
+ "starforge_inspect_node",
187
+ )
188
+
189
+
190
+ def main() -> None:
191
+ if any(arg in ("-h", "--help") for arg in sys.argv[1:]):
192
+ print(__doc__)
193
+ return
194
+ try:
195
+ from mcp.server.fastmcp import FastMCP
196
+ except ImportError as exc: # pragma: no cover
197
+ raise SystemExit(
198
+ "The *Forge MCP server needs the 'mcp' package: pip install starforge-kernel[mcp]"
199
+ ) from exc
200
+
201
+ default_workspace = sys.argv[1] if len(sys.argv) > 1 else "."
202
+
203
+ # stdout is the MCP protocol channel; everything human goes to stderr.
204
+ # Without this banner, running the server by hand looks like a hang.
205
+ print(
206
+ "\n".join(
207
+ [
208
+ "*Forge MCP server",
209
+ f" workspace : {Path(default_workspace).resolve()}",
210
+ " transport : stdio — waiting for an MCP client to connect on stdin.",
211
+ " (Silence is normal: agents launch this server themselves;",
212
+ " you rarely need to run it manually.)",
213
+ f" tools : {', '.join(TOOL_NAMES)}",
214
+ "",
215
+ "To register with agents, run \"*Forge: Set Up MCP for Agents\" in VS Code,",
216
+ "or add to your agent's MCP config:",
217
+ ' { "command": "python", "args": ["-m", "starforge.mcp"] } (cwd = your repo)',
218
+ "",
219
+ "Ctrl+C to exit.",
220
+ ]
221
+ ),
222
+ file=sys.stderr,
223
+ flush=True,
224
+ )
225
+ server = FastMCP(
226
+ "starforge",
227
+ instructions=(
228
+ "Author and run *Forge pipelines in this workspace. Blocks are @block-decorated "
229
+ "functions in the repo (see list_blocks for ids/params/outputs). Pipelines are "
230
+ ".forge JSON documents (read one for the schema). write_pipeline validates and "
231
+ "returns per-node staleness; run_pipeline executes only stale nodes and returns "
232
+ "results; inspect_node shows output previews from checkpoints. Edges target "
233
+ "parameter NAMES; params left unwired use literals from the doc or signature "
234
+ f"defaults. Default workspace: {Path(default_workspace).resolve()}"
235
+ ),
236
+ )
237
+
238
+ def _ws(workspace: str | None) -> str:
239
+ return workspace or default_workspace
240
+
241
+ @server.tool()
242
+ def starforge_list_blocks(workspace: str | None = None) -> list[dict[str, Any]]:
243
+ """List every available block with params, outputs, types, and docs."""
244
+ return list_blocks(_ws(workspace))
245
+
246
+ @server.tool()
247
+ def starforge_list_pipelines(workspace: str | None = None) -> list[str]:
248
+ """List saved .forge pipelines in the workspace."""
249
+ return list_pipelines(_ws(workspace))
250
+
251
+ @server.tool()
252
+ def starforge_read_pipeline(path: str, workspace: str | None = None) -> dict[str, Any]:
253
+ """Read a pipeline document (nodes, edges, comments)."""
254
+ return read_pipeline(_ws(workspace), path)
255
+
256
+ @server.tool()
257
+ def starforge_write_pipeline(path: str, doc: dict[str, Any], workspace: str | None = None) -> dict[str, Any]:
258
+ """Validate and save a pipeline document; returns per-node states (problems, staleness)."""
259
+ return write_pipeline(_ws(workspace), path, doc)
260
+
261
+ @server.tool()
262
+ def starforge_pipeline_state(path: str, workspace: str | None = None) -> dict[str, Any]:
263
+ """Per-node staleness and problems without running anything."""
264
+ return pipeline_state(_ws(workspace), path)
265
+
266
+ @server.tool()
267
+ def starforge_run_pipeline(
268
+ path: str, target: str | None = None, workspace: str | None = None
269
+ ) -> dict[str, Any]:
270
+ """Run a saved pipeline (stale nodes only; `target` limits to one node's
271
+ ancestor cone). Blocks until the run finishes."""
272
+ return run_pipeline(_ws(workspace), path, target=target)
273
+
274
+ @server.tool()
275
+ def starforge_inspect_node(path: str, node_id: str, workspace: str | None = None) -> dict[str, Any]:
276
+ """Checkpoint provenance for a node: output previews, figures, timing."""
277
+ return inspect_node(_ws(workspace), path, node_id)
278
+
279
+ server.run()
280
+
281
+
282
+ if __name__ == "__main__":
283
+ main()
@@ -0,0 +1,76 @@
1
+ Metadata-Version: 2.4
2
+ Name: starforge-kernel
3
+ Version: 0.1.0
4
+ Summary: *Forge — pipeline canvas, checkpointing, and stale/hydrate execution for the repo you already have open
5
+ Author: Jonathan Potter
6
+ License-Expression: Apache-2.0
7
+ Project-URL: Homepage, https://github.com/Jonpot/forge
8
+ Requires-Python: >=3.10
9
+ Description-Content-Type: text/markdown
10
+ Provides-Extra: dev
11
+ Requires-Dist: pytest>=8.0; extra == "dev"
12
+ Requires-Dist: pandas>=2.0; extra == "dev"
13
+ Requires-Dist: pyarrow>=15.0; extra == "dev"
14
+ Requires-Dist: numpy>=1.26; extra == "dev"
15
+ Provides-Extra: mcp
16
+ Requires-Dist: mcp>=1.8; extra == "mcp"
17
+
18
+ # *Forge (`starforge`)
19
+
20
+ Forge's canvas — checkpointing, provenance, stale/hydrate execution — as a VS Code
21
+ extension over the repo you already have open. Blocks are ordinary Python functions
22
+ tagged with `@block`. See [DESIGN.md](DESIGN.md) for the full design.
23
+
24
+ ## Try it (M0)
25
+
26
+ ```bash
27
+ # 1. Install the kernel + decorator into the venv your target repo uses
28
+ pip install -e <forge-repo>/starforge
29
+
30
+ # 2. Build the extension
31
+ cd <forge-repo>/starforge/vscode
32
+ npm install && npm run build
33
+
34
+ # 3. Open starforge/vscode in VS Code and press F5 (extension dev host).
35
+ # In the dev-host window, open any Python repo.
36
+ ```
37
+
38
+ In your repo:
39
+
40
+ ```python
41
+ # analysis/blocks.py
42
+ import matplotlib.pyplot as plt
43
+ from starforge import block
44
+
45
+ @block(category="IO")
46
+ def make_numbers(n: int = 5) -> dict:
47
+ return {"values": list(range(1, n + 1))}
48
+
49
+ @block
50
+ def scale(data: dict, factor: float = 2.0) -> dict:
51
+ return {"values": [v * factor for v in data["values"]]}
52
+
53
+ @block
54
+ def plot(data: dict) -> dict:
55
+ plt.plot(data["values"])
56
+ plt.show() # rendered inline on the canvas node
57
+ return data
58
+ ```
59
+
60
+ Save, run **“*Forge: New Pipeline”**, drag the blocks from the palette, wire
61
+ `output → data`, hit **▶ Run**. Run again — instant, everything reused. Edit
62
+ `scale`, watch it (and only it) go stale.
63
+
64
+ ## Layout
65
+
66
+ | Path | What |
67
+ |---|---|
68
+ | `src/starforge/__init__.py` | the `@block` decorator — zero-dep, the only thing user code touches |
69
+ | `src/starforge/index/` | static AST indexer (discovery, import graph, incremental cache) |
70
+ | `src/starforge/core/` | doc schema, history hashing, serializers, checkpoint store, runner |
71
+ | `src/starforge/kernel/` | stdio JSON-RPC kernel + per-run worker subprocess |
72
+ | `vscode/` | the extension (TS host + React Flow webview) |
73
+ | `tests/` | headless M0 proof — `python -m pytest starforge/tests` |
74
+
75
+ State lives in the target repo under `.forge/` — `pipelines/` is committable,
76
+ `checkpoints/` and `cache/` are auto-gitignored.
@@ -0,0 +1,20 @@
1
+ starforge/__init__.py,sha256=yRj7cwyn-dsRhmGX7J3I6WkfWvN1MeE6ei9LoGIsaag,2982
2
+ starforge/mcp.py,sha256=T55eHXZ5rLuBBsnDJ-VK90_mStKsIA78_Lr_O5GviFU,11297
3
+ starforge/core/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
4
+ starforge/core/checkpoints.py,sha256=ElIp21DkvK_VAdpP_ORhgatPKdMYDpMGv2mqQycARlk,7212
5
+ starforge/core/figures.py,sha256=EAreO6qG93LXBn2S1w6ALxRYmDaullhAOd92CAQmSD8,4156
6
+ starforge/core/previews.py,sha256=A7zud4-MiEx3ewdwsGQ_lI_8_5RJg9G0AwBXSaeBPhE,4096
7
+ starforge/core/provenance.py,sha256=DioES9Vd9663rRxQUv1r4FUFk3-4KSl2xJaW9J0q2TE,7362
8
+ starforge/core/runner.py,sha256=bWVRPN0tKbXA9BS58GGAz7fj6UiLYszvThfN88QVK8k,11322
9
+ starforge/core/serializers.py,sha256=f1e3rT6AQDAA7IKVxlxppxSW0IopuifOO7ElKOYD6cQ,4847
10
+ starforge/core/spec.py,sha256=8QAU7nIE3i05ut0iAuln9w_0NQDGHEWZ0x_dfDeSyTw,3873
11
+ starforge/index/__init__.py,sha256=4uZiK1x-5yO-wQBoCB_i1lurm605ungYPWv3Na6TY6U,214
12
+ starforge/index/scanner.py,sha256=2Hll4LmzVt3kgW3nfGb_PHbae7Qv9mkcVYxBpPWLvU8,18203
13
+ starforge/kernel/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
14
+ starforge/kernel/__main__.py,sha256=dKkjWoNHUSl5SvOEIwIdgP0oU40PPUfWrc4tWXNK5fc,49
15
+ starforge/kernel/server.py,sha256=3KKrnsTELGZFzdUCI--n1G4ZYD8Ksh8p7IVoaY42qiM,14276
16
+ starforge/kernel/worker.py,sha256=1uTyZET-h8hQVm7UN3H5eHpz3JcYFSlUv3TUtsfHm-8,2228
17
+ starforge_kernel-0.1.0.dist-info/METADATA,sha256=F3MWhjrvpGU_CpfY_UZPGwO0olftzGQdcRYTfV-alNc,2650
18
+ starforge_kernel-0.1.0.dist-info/WHEEL,sha256=aeYiig01lYGDzBgS8HxWXOg3uV61G9ijOsup-k9o1sk,91
19
+ starforge_kernel-0.1.0.dist-info/top_level.txt,sha256=lV8j4pGR6UxAx7zmzqeV7_WmUxjqcjHvZSkMhOiBC4A,10
20
+ starforge_kernel-0.1.0.dist-info/RECORD,,
@@ -0,0 +1,5 @@
1
+ Wheel-Version: 1.0
2
+ Generator: setuptools (82.0.1)
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
5
+
@@ -0,0 +1 @@
1
+ starforge