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.
- minifactory/__init__.py +1 -0
- minifactory/__main__.py +3 -0
- minifactory/catalog.py +130 -0
- minifactory/cli.py +380 -0
- minifactory/core/__init__.py +0 -0
- minifactory/core/context.py +43 -0
- minifactory/core/engine.py +167 -0
- minifactory/core/errors.py +18 -0
- minifactory/core/events.py +55 -0
- minifactory/core/expr.py +109 -0
- minifactory/core/models.py +76 -0
- minifactory/core/ports.py +48 -0
- minifactory/core/registry.py +47 -0
- minifactory/core/workflow.py +130 -0
- minifactory/demo.py +72 -0
- minifactory/plugins/__init__.py +0 -0
- minifactory/plugins/builtin/__init__.py +18 -0
- minifactory/plugins/builtin/fake_flutter.py +112 -0
- minifactory/plugins/builtin/flutter_tool.py +45 -0
- minifactory/plugins/builtin/intakes.py +118 -0
- minifactory/plugins/builtin/providers_bedrock.py +77 -0
- minifactory/plugins/builtin/providers_github.py +102 -0
- minifactory/plugins/builtin/providers_llm.py +106 -0
- minifactory/plugins/builtin/providers_misc.py +85 -0
- minifactory/plugins/builtin/providers_scm.py +125 -0
- minifactory/plugins/builtin/routers.py +102 -0
- minifactory/plugins/builtin/steps_agent.py +212 -0
- minifactory/plugins/builtin/steps_build.py +61 -0
- minifactory/plugins/builtin/steps_existing.py +128 -0
- minifactory/plugins/builtin/steps_flutter.py +123 -0
- minifactory/plugins/builtin/steps_scm.py +92 -0
- minifactory/plugins/builtin/steps_workflows.py +87 -0
- minifactory/plugins/builtin/util.py +191 -0
- minifactory/py.typed +0 -0
- minifactory/review.py +48 -0
- minifactory/router.py +85 -0
- minifactory/templates/create-workflow/workflow.yaml +72 -0
- minifactory/templates/fix-bug/assets/tasks/README.md +9 -0
- minifactory/templates/fix-bug/workflow.yaml +117 -0
- minifactory/templates/flutter-app-factory/assets/fixtures/flutter/bad_spec/plan.json +1 -0
- minifactory/templates/flutter-app-factory/assets/fixtures/flutter/counter_needs_repair/implement/counter.txt +42 -0
- minifactory/templates/flutter-app-factory/assets/fixtures/flutter/counter_needs_repair/plan.json +3 -0
- minifactory/templates/flutter-app-factory/assets/fixtures/flutter/counter_needs_repair/repair/1.txt +42 -0
- minifactory/templates/flutter-app-factory/assets/fixtures/flutter/notes_low_coverage/implement/screen.txt +25 -0
- minifactory/templates/flutter-app-factory/assets/fixtures/flutter/notes_low_coverage/implement/storage.txt +8 -0
- minifactory/templates/flutter-app-factory/assets/fixtures/flutter/notes_low_coverage/plan.json +4 -0
- minifactory/templates/flutter-app-factory/assets/fixtures/flutter/todo_app/implement/list_screen.txt +35 -0
- minifactory/templates/flutter-app-factory/assets/fixtures/flutter/todo_app/implement/model.txt +20 -0
- minifactory/templates/flutter-app-factory/assets/fixtures/flutter/todo_app/plan.json +4 -0
- minifactory/templates/flutter-app-factory/assets/specs/bad_spec.yaml +3 -0
- minifactory/templates/flutter-app-factory/assets/specs/counter_needs_repair.yaml +4 -0
- minifactory/templates/flutter-app-factory/assets/specs/notes_low_coverage.yaml +5 -0
- minifactory/templates/flutter-app-factory/assets/specs/todo_app.yaml +5 -0
- minifactory/templates/flutter-app-factory/workflow.yaml +80 -0
- minifactory/templates/flutter-app-factory-bedrock/assets/specs/todo_app.yaml +5 -0
- minifactory/templates/flutter-app-factory-bedrock/workflow.yaml +97 -0
- minifactory/templates/java-migration-demo/workflow.yaml +86 -0
- minifactory/templates.py +57 -0
- minifactory/ui.py +190 -0
- minifactory/workspace.py +107 -0
- minifactory-0.0.1.dist-info/METADATA +249 -0
- minifactory-0.0.1.dist-info/RECORD +66 -0
- minifactory-0.0.1.dist-info/WHEEL +5 -0
- minifactory-0.0.1.dist-info/entry_points.txt +2 -0
- minifactory-0.0.1.dist-info/licenses/LICENSE +21 -0
- minifactory-0.0.1.dist-info/top_level.txt +1 -0
minifactory/__init__.py
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
__version__ = "0.0.1"
|
minifactory/__main__.py
ADDED
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
|