minifactory 0.0.1__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.
Files changed (66) hide show
  1. minifactory/__init__.py +1 -0
  2. minifactory/__main__.py +3 -0
  3. minifactory/catalog.py +130 -0
  4. minifactory/cli.py +380 -0
  5. minifactory/core/__init__.py +0 -0
  6. minifactory/core/context.py +43 -0
  7. minifactory/core/engine.py +167 -0
  8. minifactory/core/errors.py +18 -0
  9. minifactory/core/events.py +55 -0
  10. minifactory/core/expr.py +109 -0
  11. minifactory/core/models.py +76 -0
  12. minifactory/core/ports.py +48 -0
  13. minifactory/core/registry.py +47 -0
  14. minifactory/core/workflow.py +130 -0
  15. minifactory/demo.py +72 -0
  16. minifactory/plugins/__init__.py +0 -0
  17. minifactory/plugins/builtin/__init__.py +18 -0
  18. minifactory/plugins/builtin/fake_flutter.py +112 -0
  19. minifactory/plugins/builtin/flutter_tool.py +45 -0
  20. minifactory/plugins/builtin/intakes.py +118 -0
  21. minifactory/plugins/builtin/providers_bedrock.py +77 -0
  22. minifactory/plugins/builtin/providers_github.py +102 -0
  23. minifactory/plugins/builtin/providers_llm.py +106 -0
  24. minifactory/plugins/builtin/providers_misc.py +85 -0
  25. minifactory/plugins/builtin/providers_scm.py +125 -0
  26. minifactory/plugins/builtin/routers.py +102 -0
  27. minifactory/plugins/builtin/steps_agent.py +212 -0
  28. minifactory/plugins/builtin/steps_build.py +61 -0
  29. minifactory/plugins/builtin/steps_existing.py +128 -0
  30. minifactory/plugins/builtin/steps_flutter.py +123 -0
  31. minifactory/plugins/builtin/steps_scm.py +92 -0
  32. minifactory/plugins/builtin/steps_workflows.py +87 -0
  33. minifactory/plugins/builtin/util.py +191 -0
  34. minifactory/py.typed +0 -0
  35. minifactory/review.py +48 -0
  36. minifactory/router.py +85 -0
  37. minifactory/templates/create-workflow/workflow.yaml +72 -0
  38. minifactory/templates/fix-bug/assets/tasks/README.md +9 -0
  39. minifactory/templates/fix-bug/workflow.yaml +117 -0
  40. minifactory/templates/flutter-app-factory/assets/fixtures/flutter/bad_spec/plan.json +1 -0
  41. minifactory/templates/flutter-app-factory/assets/fixtures/flutter/counter_needs_repair/implement/counter.txt +42 -0
  42. minifactory/templates/flutter-app-factory/assets/fixtures/flutter/counter_needs_repair/plan.json +3 -0
  43. minifactory/templates/flutter-app-factory/assets/fixtures/flutter/counter_needs_repair/repair/1.txt +42 -0
  44. minifactory/templates/flutter-app-factory/assets/fixtures/flutter/notes_low_coverage/implement/screen.txt +25 -0
  45. minifactory/templates/flutter-app-factory/assets/fixtures/flutter/notes_low_coverage/implement/storage.txt +8 -0
  46. minifactory/templates/flutter-app-factory/assets/fixtures/flutter/notes_low_coverage/plan.json +4 -0
  47. minifactory/templates/flutter-app-factory/assets/fixtures/flutter/todo_app/implement/list_screen.txt +35 -0
  48. minifactory/templates/flutter-app-factory/assets/fixtures/flutter/todo_app/implement/model.txt +20 -0
  49. minifactory/templates/flutter-app-factory/assets/fixtures/flutter/todo_app/plan.json +4 -0
  50. minifactory/templates/flutter-app-factory/assets/specs/bad_spec.yaml +3 -0
  51. minifactory/templates/flutter-app-factory/assets/specs/counter_needs_repair.yaml +4 -0
  52. minifactory/templates/flutter-app-factory/assets/specs/notes_low_coverage.yaml +5 -0
  53. minifactory/templates/flutter-app-factory/assets/specs/todo_app.yaml +5 -0
  54. minifactory/templates/flutter-app-factory/workflow.yaml +80 -0
  55. minifactory/templates/flutter-app-factory-bedrock/assets/specs/todo_app.yaml +5 -0
  56. minifactory/templates/flutter-app-factory-bedrock/workflow.yaml +97 -0
  57. minifactory/templates/java-migration-demo/workflow.yaml +86 -0
  58. minifactory/templates.py +57 -0
  59. minifactory/ui.py +190 -0
  60. minifactory/workspace.py +107 -0
  61. minifactory-0.0.1.dist-info/METADATA +249 -0
  62. minifactory-0.0.1.dist-info/RECORD +66 -0
  63. minifactory-0.0.1.dist-info/WHEEL +5 -0
  64. minifactory-0.0.1.dist-info/entry_points.txt +2 -0
  65. minifactory-0.0.1.dist-info/licenses/LICENSE +21 -0
  66. minifactory-0.0.1.dist-info/top_level.txt +1 -0
@@ -0,0 +1 @@
1
+ __version__ = "0.0.1"
@@ -0,0 +1,3 @@
1
+ from minifactory.cli import main
2
+
3
+ raise SystemExit(main())
minifactory/catalog.py ADDED
@@ -0,0 +1,130 @@
1
+ """A description of what can be built with the registered plugins, generated from the registry
2
+ itself (so it is never stale): which steps exist, what parameters they take, which providers a
3
+ workflow can configure. It is what the workflow-drafting agent reads, and `plugins --schema` prints."""
4
+
5
+ from __future__ import annotations
6
+
7
+ import inspect
8
+ from typing import Any
9
+
10
+ from .core import registry
11
+
12
+ # Constructor arguments that are injected by the engine or exist only for tests: not for a workflow author.
13
+ _INTERNAL = {"self", "secrets", "client", "transport", "runner"}
14
+
15
+
16
+ def _first_paragraph(obj: Any) -> str:
17
+ """The class's OWN docstring (an inherited one would describe the base class, not the plugin)."""
18
+ doc = inspect.cleandoc(obj.__dict__.get("__doc__") or "")
19
+ return " ".join(doc.split("\n\n")[0].split())
20
+
21
+
22
+ def _type(schema: dict[str, Any]) -> str:
23
+ if "enum" in schema:
24
+ return "|".join(map(str, schema["enum"]))
25
+ if "anyOf" in schema:
26
+ return " | ".join(t for t in (_type(s) for s in schema["anyOf"]) if t != "null") or "null"
27
+ if schema.get("type") == "array":
28
+ return f"list[{_type(schema.get('items', {})) or 'any'}]"
29
+ if "$ref" in schema:
30
+ return "object"
31
+ return str(schema.get("type", "any"))
32
+
33
+
34
+ def _step_params(cls: Any) -> list[dict[str, Any]]:
35
+ schema = cls.Params.model_json_schema()
36
+ required = set(schema.get("required", []))
37
+ return [{"name": name, "type": _type(spec), "required": name in required,
38
+ **({"default": spec["default"]} if "default" in spec else {})}
39
+ for name, spec in schema.get("properties", {}).items()]
40
+
41
+
42
+ def _init_params(cls: Any) -> list[dict[str, Any]]:
43
+ out = []
44
+ for name, p in inspect.signature(cls.__init__).parameters.items():
45
+ if name in _INTERNAL or p.kind in (p.VAR_POSITIONAL, p.VAR_KEYWORD):
46
+ continue
47
+ out.append({"name": name, "required": p.default is p.empty,
48
+ **({} if p.default is p.empty else {"default": p.default})})
49
+ return out
50
+
51
+
52
+ def describe() -> dict[str, Any]:
53
+ """{'steps': {name: {doc, needs, params}}, '<kind>': {name: {doc, params}}} for every plugin."""
54
+ out: dict[str, Any] = {"steps": {}}
55
+ for name in registry.catalog()["step"]:
56
+ cls = registry.get("step", name)
57
+ out["steps"][name] = {"doc": _first_paragraph(cls), "needs": list(cls.needs), "params": _step_params(cls)}
58
+ for kind in ("scm", "llm", "secrets", "source", "executor", "router", "intake"):
59
+ out[kind] = {name: {"doc": _first_paragraph(registry.get(kind, name)),
60
+ "params": _init_params(registry.get(kind, name))}
61
+ for name in registry.catalog()[kind]}
62
+ return out
63
+
64
+
65
+ FORMAT_GUIDE = """\
66
+ # How a workflow is written
67
+
68
+ A workflow is one YAML file. Only the keys below exist; anything else is an error.
69
+
70
+ ```yaml
71
+ name: my-workflow # required, unique
72
+ route: # optional: makes `minifactory ask` able to choose it
73
+ description: one sentence on when to use this workflow
74
+ not_for: [things it must not be used for]
75
+ examples: ["a sample request"]
76
+ needs: what a request must contain for it to start
77
+ intake: app-spec # a registered intake that drafts the job input from the text
78
+ delivery: choose # or `fixed` when the workflow delivers to its own target
79
+ vars: {key: value} # constants, read as ${vars.key}
80
+ source: {use: <source>, with: {...}} # where the jobs come from
81
+ executor: {use: local} # optional
82
+ providers: # only the ones the steps need
83
+ scm: {use: <scm>, with: {...}}
84
+ llm: {use: <llm>, with: {...}}
85
+ steps:
86
+ - id: unique_id
87
+ use: <step name from the list below>
88
+ with: {param: value} # exactly the parameters listed for that step
89
+ when: "steps.build.status == 'failed'" # optional condition
90
+ on_fail: fail # fail | discard (stop, change nothing) | continue
91
+ blocking: true # false: a failure only becomes a note
92
+ ```
93
+
94
+ Rules the validator enforces:
95
+ - `use:` must name a registered step; `with:` keys must be that step's parameters; required ones must be present.
96
+ - A step that lists `needs: [scm]` or `[llm]` requires that provider under `providers:`.
97
+ - Step ids are unique, and `${steps.<id>...}` / `steps.<id>` may only refer to steps that run EARLIER.
98
+ - Templates: `${job.<attr>}`, `${steps.<id>.outputs.<key>}`, `${steps.<id>.status}`, `${vars.<k>}`, `${env.python}`.
99
+ - `when` is a tiny expression language: names, attribute access, literals, comparisons, `in`, `and`/`or`/`not`.
100
+ No function calls, no indexing, no arithmetic. Loops do not exist.
101
+ - Do not invent steps, providers or parameters. If the goal needs something that is not listed, say so.
102
+ """
103
+
104
+
105
+ def _requirement(p: dict[str, Any]) -> str:
106
+ if p["required"]:
107
+ return " (required)"
108
+ return f" (default {p['default']!r})" if "default" in p else " (optional)"
109
+
110
+
111
+ def _params_line(params: list[dict[str, Any]], with_types: bool) -> str:
112
+ labels = [(f"{p['name']}: {p['type']}" if with_types else p["name"]) + _requirement(p) for p in params]
113
+ return "; ".join(labels) or "none"
114
+
115
+
116
+ def render_markdown() -> str:
117
+ d = describe()
118
+ lines = [FORMAT_GUIDE, "# Steps (the only values allowed for `use:` in `steps`)", ""]
119
+ for name, info in d["steps"].items():
120
+ needs = f" [needs providers: {', '.join(info['needs'])}]" if info["needs"] else ""
121
+ lines += [f"## {name}{needs}", info["doc"] or "(no description)",
122
+ f"params: {_params_line(info['params'], True)}", ""]
123
+ lines += ["# Plugins for `source`, `executor` and `providers` (`use:` + `with:` = constructor arguments)", ""]
124
+ for kind in ("source", "executor", "scm", "llm", "secrets"):
125
+ for name, info in d[kind].items():
126
+ lines += [f"## {kind}: {name}", info["doc"] or "(no description)",
127
+ f"with: {_params_line(info['params'], False)}", ""]
128
+ lines += ["# Intakes (for `route.intake`)", ""]
129
+ lines += [f"- {name}: {info['doc']}" for name, info in d["intake"].items()]
130
+ return "\n".join(lines) + "\n"
minifactory/cli.py ADDED
@@ -0,0 +1,380 @@
1
+ from __future__ import annotations
2
+
3
+ import argparse
4
+ import os
5
+ import sys
6
+ from pathlib import Path
7
+
8
+ from minifactory import templates, workspace
9
+ from minifactory.core import registry
10
+ from minifactory.core.engine import Engine
11
+ from minifactory.core.errors import FactoryError, RoutingError, VagueRequest
12
+ from minifactory.core.events import ConsoleSink, EventBus
13
+ from minifactory.core.workflow import load_workflow
14
+
15
+
16
+ def _parser() -> argparse.ArgumentParser:
17
+ p = argparse.ArgumentParser(prog="minifactory", description="Tiny pluggable software factory")
18
+ sub = p.add_subparsers(dest="cmd", required=True)
19
+
20
+ run = sub.add_parser("run", help="run a workflow over all jobs from its source")
21
+ run.add_argument("workflow", help="a workflow name (from your workspace) or a path to a .yaml")
22
+ run.add_argument("--runs-dir", help="default: from minifactory.yaml")
23
+ run.add_argument("--resume", action="store_true", help="skip steps already completed (ok)")
24
+ run.add_argument("--only", action="append", default=[], help="job id to run (repeatable)")
25
+ run.add_argument("--limit", type=int, default=0)
26
+ run.add_argument("--plugin", action="append", default=[], help="extra plugin module to import")
27
+ run.add_argument("--quiet", action="store_true", help="hide per-step lines")
28
+ run.add_argument("--plain", action="store_true", help="no colours or live board")
29
+
30
+ ask = sub.add_parser("ask", help="describe what you need in plain language; picks and runs a workflow")
31
+ ask.add_argument("request")
32
+ ask.add_argument("--workflows-dir", help="default: the workspace workflows folders")
33
+ ask.add_argument("--router", help="router plugin (default: minifactory.yaml, then jev)")
34
+ ask.add_argument("--threshold", type=float, help="minimum router confidence (default: minifactory.yaml, then 0.7)")
35
+ ask.add_argument("--yes", action="store_true", help="don't ask; only acts when confident and actionable")
36
+ ask.add_argument("--dry-run", action="store_true", help="show the chosen workflow and drafted input, run nothing")
37
+ ask.add_argument("--output", choices=["local", "github"],
38
+ help="where the result goes (asked interactively if omitted)")
39
+ ask.add_argument("--output-dir", help="parent folder for --output local (default: minifactory.yaml, then output)")
40
+ ask.add_argument("--owner", help="GitHub user or org for --output github (default: your gh login)")
41
+ ask.add_argument("--public", action="store_true", help="make the GitHub repo public (default: private)")
42
+ ask.add_argument("--runs-dir", help="default: from minifactory.yaml")
43
+ ask.add_argument("--plain", action="store_true", help="no colours or live board")
44
+ ask.add_argument("--plugin", action="append", default=[])
45
+
46
+ val = sub.add_parser("validate", help="check a workflow without running it")
47
+ val.add_argument("workflow", help="a workflow name or a path to a .yaml")
48
+ val.add_argument("--plugin", action="append", default=[])
49
+
50
+ pl = sub.add_parser("plugins", help="list registered plugins")
51
+ pl.add_argument("--plugin", action="append", default=[])
52
+ pl.add_argument("--schema", action="store_true",
53
+ help="print what can be built: every step with its parameters, providers, the format rules")
54
+ pl.add_argument("--json", action="store_true", help="with --schema: machine-readable")
55
+
56
+ init = sub.add_parser("init", help="create a workspace (minifactory.yaml + workflows/) in a folder")
57
+ init.add_argument("folder", nargs="?", default=".")
58
+ init.add_argument("--template", help="also copy a template workflow (see `minifactory workflows list`)")
59
+
60
+ wf = sub.add_parser("workflows", help="list or copy workflows")
61
+ wf_sub = wf.add_subparsers(dest="wf_cmd", required=True)
62
+ wf_sub.add_parser("list", help="your workflows and the templates that ship with the package")
63
+ cp = wf_sub.add_parser("copy", help="copy a packaged template into your workspace")
64
+ cp.add_argument("name")
65
+ en = wf_sub.add_parser("enable", help="review a drafted workflow and make it active (routable by `ask`)")
66
+ en.add_argument("name")
67
+ en.add_argument("--yes", action="store_true", help="don't ask for confirmation")
68
+
69
+ demo = sub.add_parser("demo-init", help="create the demo sandbox (git remotes + catalog)")
70
+ demo.add_argument("--dir", default="demo")
71
+ return p
72
+
73
+
74
+ def _summary(results) -> None:
75
+ print("\n" + "─" * 78)
76
+ print(f"{'JOB':<22} {'OUTCOME':<10} DETAIL")
77
+ for r in results:
78
+ pr = r.artifacts[0].ref if r.artifacts else ""
79
+ detail = pr or r.reason
80
+ print(f"{r.job_id:<22} {r.outcome:<10} {detail}")
81
+ for note in r.notes:
82
+ print(f"{'':<33} note: {note}")
83
+ counts = {o: sum(r.outcome == o for r in results) for o in ("succeeded", "failed", "discarded")}
84
+ print("─" * 78)
85
+ print(" ".join(f"{k}: {v}" for k, v in counts.items()))
86
+
87
+
88
+ def _execute(engine: Engine, bus: EventBus, jobs, args) -> None:
89
+ """Run jobs with the live board on a terminal, plain lines otherwise."""
90
+
91
+ def run_one(job):
92
+ return engine.run_job(job, resume=getattr(args, "resume", False))
93
+
94
+ spec = engine.spec
95
+ if sys.stdout.isatty() and not args.plain:
96
+ from minifactory.ui import LiveBoard, banner
97
+
98
+ board = LiveBoard([j.id for j in jobs], [s.id for s in spec.steps])
99
+ banner(board.console)
100
+ board.console.print(f"[bold]{spec.name}[/] · {len(jobs)} job(s)\n")
101
+ bus.subscribe(board)
102
+ with board:
103
+ results = engine.executor.run(jobs, run_one)
104
+ board.summary(results)
105
+ return
106
+
107
+ if not getattr(args, "quiet", False):
108
+ bus.subscribe(ConsoleSink())
109
+ print(f"workflow '{spec.name}': {len(jobs)} job(s)")
110
+ results = engine.executor.run(jobs, run_one)
111
+ _summary(results)
112
+
113
+
114
+ def _pick_output(console, args) -> str | None:
115
+ console.print(f"Where should the result go?\n 1. local folder ({args.output_dir}/<name>)\n"
116
+ f" 2. a new {'PUBLIC' if args.public else 'private'} GitHub repository + pull request")
117
+ answer = input("Choose 1 or 2 (empty to abort): ").strip()
118
+ if answer not in ("1", "2"):
119
+ console.print("aborted")
120
+ return None
121
+ return "local" if answer == "1" else "github"
122
+
123
+
124
+ def _ask(args) -> int:
125
+ from rich.console import Console
126
+
127
+ from minifactory import router as R
128
+ from minifactory.ui import show_decision, show_spec
129
+
130
+ console = Console()
131
+ routables = R.discover(args.workflow_dirs)
132
+ router = registry.get("router", args.router)()
133
+ decision = router.choose(args.request, [r.candidate for r in routables.values()])
134
+ show_decision(console, decision, args.threshold)
135
+ if decision.usage:
136
+ console.print(f"[dim]router tokens: {decision.usage}[/]")
137
+
138
+ interactive = sys.stdin.isatty() and not args.yes
139
+ try:
140
+ R.check(decision, args.threshold)
141
+ choice = decision.choice
142
+ except VagueRequest as problem:
143
+ if not interactive:
144
+ raise
145
+ console.print(f"[yellow]{problem}[/] (you can still review the drafted input below)")
146
+ choice = decision.choice
147
+ except RoutingError as problem:
148
+ ranking = [n for n, _ in R.ranked(decision) if n in routables]
149
+ if not interactive or not ranking:
150
+ raise
151
+ console.print(f"[yellow]{problem}[/]")
152
+ for i, name in enumerate(ranking, 1):
153
+ console.print(f" {i}. {name}")
154
+ answer = input("Pick a workflow number (empty to abort): ").strip()
155
+ if not answer.isdigit() or not 1 <= int(answer) <= len(ranking):
156
+ console.print("aborted")
157
+ return 2
158
+ choice = ranking[int(answer) - 1]
159
+
160
+ chosen = routables[choice]
161
+ drafting = Engine(chosen.spec, args.runs_dir, EventBus()) # only for its llm provider
162
+ engine = drafting
163
+ if engine.llm is None:
164
+ raise RoutingError(f"workflow '{chosen.spec.name}' has no llm provider to draft its input")
165
+ intake = registry.get("intake", chosen.spec.route.intake)()
166
+ try:
167
+ job = intake.build(args.request, engine.llm)
168
+ except ValueError as e:
169
+ raise RoutingError(str(e)) from None
170
+ show_spec(console, chosen.spec.name, job)
171
+
172
+ output = args.output
173
+ if chosen.spec.route.delivery == "fixed":
174
+ if output:
175
+ raise RoutingError("this workflow delivers to the repository itself; --output does not apply")
176
+ bus = EventBus()
177
+ engine = Engine(chosen.spec, args.runs_dir, bus)
178
+ describe = getattr(engine.scm, "target", lambda repo: repo) # `target` is optional on providers
179
+ console.print(f"[bold]delivery →[/] {describe(job.name)}")
180
+ if args.dry_run:
181
+ return 0
182
+ if not args.yes and (not sys.stdin.isatty() or input("Run it? [y/N] ").strip().lower() != "y"):
183
+ console.print("not run (confirm interactively or pass --yes)")
184
+ return 2
185
+ _execute(engine, bus, [job], args)
186
+ return 0
187
+ if output is None and not args.dry_run:
188
+ if not interactive:
189
+ raise RoutingError("choose where the result goes: --output local|github")
190
+ output = _pick_output(console, args)
191
+ if output is None:
192
+ return 2
193
+ bus = EventBus()
194
+ if output:
195
+ spec = R.with_output(chosen.spec, output, output_dir=args.output_dir, owner=args.owner,
196
+ public=args.public)
197
+ engine = Engine(spec, args.runs_dir, bus)
198
+ console.print(f"[bold]output →[/] {engine.scm.target(job.name)}")
199
+ if output == "github" and args.public:
200
+ console.print("[bold red]this repository will be PUBLIC[/]")
201
+ if args.dry_run:
202
+ return 0
203
+ if not args.yes and (not sys.stdin.isatty() or input("Run it? [y/N] ").strip().lower() != "y"):
204
+ console.print("not run (confirm interactively or pass --yes)")
205
+ return 2
206
+ _execute(engine, bus, [job], args)
207
+ return 0
208
+
209
+
210
+ def _use_workspace(args: argparse.Namespace) -> workspace.Workspace | None:
211
+ """Find the workspace, fill unset options from its config and make it the working directory
212
+ (workflows reference specs/, output/... relative to the workspace root). Without a workspace
213
+ everything stays relative to the current directory, as before."""
214
+ ws = workspace.find()
215
+ cfg = ws.config if ws else workspace.FactoryConfig()
216
+ # Paths typed on the command line mean "relative to where I am": make them absolute first.
217
+ wf = getattr(args, "workflow", None)
218
+ if wf and Path(wf).is_file():
219
+ args.workflow = str(Path(wf).resolve())
220
+ explicit = getattr(args, "workflows_dir", None)
221
+ if explicit:
222
+ args.workflow_dirs = [Path(explicit).resolve()]
223
+ elif ws:
224
+ args.workflow_dirs = ws.workflow_dirs
225
+ else:
226
+ args.workflow_dirs = [Path.cwd() / cfg.workflows_dir]
227
+ if ws:
228
+ os.chdir(ws.root)
229
+ for attr, default in (("runs_dir", cfg.runs_dir), ("output_dir", cfg.output_dir),
230
+ ("router", cfg.router), ("threshold", cfg.threshold)):
231
+ if hasattr(args, attr) and getattr(args, attr) is None:
232
+ setattr(args, attr, default)
233
+ args.plugin = [*cfg.plugins, *getattr(args, "plugin", [])]
234
+ return ws
235
+
236
+
237
+ def _resolve_workflow(name: str, dirs: list[Path]) -> str:
238
+ """A path to a .yaml, or the name of a workflow in the workspace folders."""
239
+ if Path(name).is_file():
240
+ return name
241
+ for folder in dirs:
242
+ for candidate in (folder / name, folder / f"{name}.yaml"):
243
+ if candidate.is_file():
244
+ return str(candidate)
245
+ known = sorted(f.stem for folder in dirs for f in folder.glob("*.yaml"))
246
+ hint = f"your workflows: {', '.join(known)}" if known else "no workflows found"
247
+ ready = ", ".join(templates.available())
248
+ raise FactoryError(f"workflow '{name}' not found ({hint}). Templates you can copy: {ready}. "
249
+ "See `minifactory workflows list`")
250
+
251
+
252
+ def _enable(args: argparse.Namespace, ws: workspace.Workspace | None) -> int:
253
+ """Show what a drafted workflow would do, then move it into the active workflows folder."""
254
+ from minifactory.review import review
255
+
256
+ if ws is None:
257
+ raise FactoryError("no workspace here. Run `minifactory init` first")
258
+ folder = ws.root / ws.config.workflows_dir
259
+ draft, target = folder / "drafts" / f"{args.name}.yaml", folder / f"{args.name}.yaml"
260
+ if not draft.is_file():
261
+ raise FactoryError(f"no draft named '{args.name}' in {draft.parent}")
262
+ if target.exists():
263
+ raise FactoryError(f"{target} already exists; remove or rename one of them first")
264
+ spec = load_workflow(draft) # a draft written under other plugins may no longer validate
265
+ print(f"workflow '{spec.name}' ({len(spec.steps)} steps) would be enabled. Review:")
266
+ for finding in review(spec):
267
+ print(f" {finding}")
268
+ if not args.yes and (not sys.stdin.isatty() or input("Enable it? [y/N] ").strip().lower() != "y"):
269
+ print("not enabled (confirm interactively or pass --yes)")
270
+ return 2
271
+ draft.rename(target)
272
+ print(f"enabled: {target.relative_to(ws.root)}")
273
+ return 0
274
+
275
+
276
+ def _workflows(args: argparse.Namespace, ws: workspace.Workspace | None) -> int:
277
+ if args.wf_cmd == "copy":
278
+ if ws is None:
279
+ raise FactoryError("no workspace here. Run `minifactory init` first")
280
+ for path in templates.copy(args.name, ws.root, ws.config.workflows_dir):
281
+ print(f" + {path.relative_to(ws.root)}")
282
+ return 0
283
+ if args.wf_cmd == "enable":
284
+ return _enable(args, ws)
285
+ print("your workflows:")
286
+ mine = [f for folder in args.workflow_dirs for f in sorted(folder.glob("*.yaml"))]
287
+ for f in mine:
288
+ try:
289
+ spec = load_workflow(f)
290
+ print(f" {spec.name:<32} {'routable' if spec.route else 'run only'} {f}")
291
+ except FactoryError as e:
292
+ print(f" {f.stem:<32} INVALID: {str(e)[:80]}")
293
+ if not mine:
294
+ print(" (none)")
295
+ drafts = [f for folder in args.workflow_dirs for f in sorted((folder / "drafts").glob("*.yaml"))]
296
+ if drafts:
297
+ print("\ndrafts (not routable until `minifactory workflows enable NAME`):")
298
+ for f in drafts:
299
+ print(f" {f.stem:<32} {f}")
300
+ print("\ntemplates (copy with `minifactory workflows copy NAME`):")
301
+ for t in templates.available().values():
302
+ print(f" {t.name:<32} {t.description}")
303
+ return 0
304
+
305
+
306
+ def main(argv: list[str] | None = None) -> int:
307
+ args = _parser().parse_args(argv)
308
+ try:
309
+ if args.cmd == "init":
310
+ folder = Path(args.folder)
311
+ created = workspace.init(folder)
312
+ if args.template:
313
+ root = folder.resolve()
314
+ created += [str(p.relative_to(root)) for p in templates.copy(args.template, root)]
315
+ print(f"workspace ready in {folder.resolve()}" + "".join(f"\n + {c}" for c in created))
316
+ return 0
317
+
318
+ ws = _use_workspace(args)
319
+ registry.load_plugins(args.plugin)
320
+ if args.cmd == "workflows":
321
+ return _workflows(args, ws)
322
+
323
+ if args.cmd == "demo-init":
324
+ from minifactory.demo import demo_init
325
+
326
+ path = demo_init(args.dir)
327
+ print(f"demo sandbox ready in ./{path}")
328
+ return 0
329
+
330
+ if args.cmd == "plugins" and args.schema:
331
+ from minifactory import catalog as plugin_catalog
332
+
333
+ if args.json:
334
+ import json
335
+
336
+ print(json.dumps(plugin_catalog.describe(), indent=2, default=str))
337
+ else:
338
+ print(plugin_catalog.render_markdown())
339
+ return 0
340
+ if args.cmd == "plugins":
341
+ catalog = registry.catalog()
342
+ if sys.stdout.isatty():
343
+ from rich.console import Console
344
+ from rich.table import Table
345
+
346
+ table = Table(title="registered plugins", header_style="bold magenta")
347
+ table.add_column("kind", style="cyan")
348
+ table.add_column("names")
349
+ for kind, names in catalog.items():
350
+ table.add_row(kind, " ".join(names))
351
+ Console().print(table)
352
+ else:
353
+ for kind, names in catalog.items():
354
+ print(f"{kind:<9} {', '.join(names)}")
355
+ return 0
356
+
357
+ if args.cmd == "ask":
358
+ return _ask(args)
359
+
360
+ spec = load_workflow(_resolve_workflow(args.workflow, args.workflow_dirs))
361
+ if args.cmd == "validate":
362
+ print(f"workflow '{spec.name}' is valid ({len(spec.steps)} steps)")
363
+ return 0
364
+
365
+ bus = EventBus()
366
+ engine = Engine(spec, args.runs_dir, bus)
367
+ jobs = engine.source.jobs()
368
+ if args.only:
369
+ jobs = [j for j in jobs if j.id in args.only]
370
+ if args.limit:
371
+ jobs = jobs[: args.limit]
372
+ _execute(engine, bus, jobs, args)
373
+ return 0
374
+ except FactoryError as e:
375
+ print(f"error: {e}", file=sys.stderr)
376
+ return 2
377
+
378
+
379
+ if __name__ == "__main__":
380
+ sys.exit(main())
File without changes
@@ -0,0 +1,43 @@
1
+ """What a step receives (StepContext) and what a step is (Step)."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from dataclasses import dataclass, field
6
+ from pathlib import Path
7
+ from typing import Any, Callable, ClassVar
8
+
9
+ from pydantic import BaseModel, ConfigDict
10
+
11
+ from .models import Job, StepResult
12
+
13
+
14
+ class StrictParams(BaseModel):
15
+ """Base for step parameters: unknown keys are errors, so YAML typos fail fast."""
16
+
17
+ model_config = ConfigDict(extra="forbid")
18
+
19
+
20
+ @dataclass
21
+ class StepContext:
22
+ job: Job
23
+ work_dir: Path
24
+ scm: Any # SCMProvider | None
25
+ llm: Any # LLMProvider | None
26
+ secrets: Any # SecretsProvider
27
+ emit: Callable[..., None]
28
+ outputs: dict[str, dict[str, Any]] = field(default_factory=dict) # step id -> outputs
29
+ notes: list[str] = field(default_factory=list)
30
+
31
+ @property
32
+ def repo_dir(self) -> Path:
33
+ return self.work_dir / "repo"
34
+
35
+
36
+ class Step:
37
+ """Subclass, set Params, implement run(), and register with @register("step", "x.y")."""
38
+
39
+ Params: ClassVar[type[BaseModel]] = StrictParams
40
+ needs: ClassVar[tuple[str, ...]] = () # providers required: "scm", "llm"
41
+
42
+ def run(self, ctx: StepContext, params: Any) -> StepResult: # pragma: no cover
43
+ raise NotImplementedError