ngksbuildcore 0.1.4__tar.gz
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.
- ngksbuildcore-0.1.4/PKG-INFO +52 -0
- ngksbuildcore-0.1.4/README.md +43 -0
- ngksbuildcore-0.1.4/ngksbuildcore/__init__.py +3 -0
- ngksbuildcore-0.1.4/ngksbuildcore/__main__.py +5 -0
- ngksbuildcore-0.1.4/ngksbuildcore/adapters/__init__.py +4 -0
- ngksbuildcore-0.1.4/ngksbuildcore/adapters/devfabric_adapter.py +41 -0
- ngksbuildcore-0.1.4/ngksbuildcore/adapters/graph_adapter.py +10 -0
- ngksbuildcore-0.1.4/ngksbuildcore/cli.py +159 -0
- ngksbuildcore-0.1.4/ngksbuildcore/hashing.py +24 -0
- ngksbuildcore-0.1.4/ngksbuildcore/loggingx.py +46 -0
- ngksbuildcore-0.1.4/ngksbuildcore/plan.py +107 -0
- ngksbuildcore-0.1.4/ngksbuildcore/runner.py +393 -0
- ngksbuildcore-0.1.4/ngksbuildcore/scheduler.py +43 -0
- ngksbuildcore-0.1.4/ngksbuildcore/store.py +86 -0
- ngksbuildcore-0.1.4/ngksbuildcore.egg-info/PKG-INFO +52 -0
- ngksbuildcore-0.1.4/ngksbuildcore.egg-info/SOURCES.txt +20 -0
- ngksbuildcore-0.1.4/ngksbuildcore.egg-info/dependency_links.txt +1 -0
- ngksbuildcore-0.1.4/ngksbuildcore.egg-info/top_level.txt +1 -0
- ngksbuildcore-0.1.4/pyproject.toml +17 -0
- ngksbuildcore-0.1.4/setup.cfg +4 -0
- ngksbuildcore-0.1.4/tests/test_plan_ingest.py +95 -0
- ngksbuildcore-0.1.4/tests/test_runner_actionkey.py +290 -0
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: ngksbuildcore
|
|
3
|
+
Version: 0.1.4
|
|
4
|
+
Summary: NGKsBuildCore
|
|
5
|
+
Author: NGKsSystems
|
|
6
|
+
License: Proprietary
|
|
7
|
+
Requires-Python: >=3.10
|
|
8
|
+
Description-Content-Type: text/markdown
|
|
9
|
+
|
|
10
|
+
# NGKsBuildCore
|
|
11
|
+
|
|
12
|
+
NGKsBuildCore is a Python MVP build runner that executes a DAG build plan with deterministic scheduling, parallel workers, and auditable proof logs.
|
|
13
|
+
|
|
14
|
+
## Commands
|
|
15
|
+
|
|
16
|
+
- `python -m ngksbuildcore --help`
|
|
17
|
+
- `python -m ngksbuildcore doctor`
|
|
18
|
+
- `python -m ngksbuildcore run --plan examples\hello_plan.json -j 8`
|
|
19
|
+
- `python -m ngksbuildcore explain --plan examples\hello_plan.json`
|
|
20
|
+
|
|
21
|
+
## Plan Contract
|
|
22
|
+
|
|
23
|
+
Plan JSON fields:
|
|
24
|
+
|
|
25
|
+
- `base_dir` optional, defaults to plan file directory
|
|
26
|
+
- `nodes` array of objects with:
|
|
27
|
+
- `id` (string)
|
|
28
|
+
- `desc` (string, optional)
|
|
29
|
+
- `cwd` (string, optional)
|
|
30
|
+
- `cmd` (string or string array)
|
|
31
|
+
- `deps` (string array)
|
|
32
|
+
- `inputs` (path array)
|
|
33
|
+
- `outputs` (path array)
|
|
34
|
+
- `env` (dict, optional)
|
|
35
|
+
|
|
36
|
+
## Proof Output
|
|
37
|
+
|
|
38
|
+
Each run writes to timestamped proof directory under `_proof` (or `NGKS_PROOF_ROOT`):
|
|
39
|
+
|
|
40
|
+
- `events.jsonl`
|
|
41
|
+
- `commands.jsonl`
|
|
42
|
+
- `summary.json`
|
|
43
|
+
- `summary.txt`
|
|
44
|
+
- `environment.txt`
|
|
45
|
+
- `tool_versions.txt`
|
|
46
|
+
- `git_status.txt`
|
|
47
|
+
- `git_head.txt`
|
|
48
|
+
|
|
49
|
+
## Integration Adapters
|
|
50
|
+
|
|
51
|
+
- `ngksbuildcore.adapters.graph_adapter.run_graph_plan`
|
|
52
|
+
- `ngksbuildcore.adapters.devfabric_adapter.build_from_manifest`
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
# NGKsBuildCore
|
|
2
|
+
|
|
3
|
+
NGKsBuildCore is a Python MVP build runner that executes a DAG build plan with deterministic scheduling, parallel workers, and auditable proof logs.
|
|
4
|
+
|
|
5
|
+
## Commands
|
|
6
|
+
|
|
7
|
+
- `python -m ngksbuildcore --help`
|
|
8
|
+
- `python -m ngksbuildcore doctor`
|
|
9
|
+
- `python -m ngksbuildcore run --plan examples\hello_plan.json -j 8`
|
|
10
|
+
- `python -m ngksbuildcore explain --plan examples\hello_plan.json`
|
|
11
|
+
|
|
12
|
+
## Plan Contract
|
|
13
|
+
|
|
14
|
+
Plan JSON fields:
|
|
15
|
+
|
|
16
|
+
- `base_dir` optional, defaults to plan file directory
|
|
17
|
+
- `nodes` array of objects with:
|
|
18
|
+
- `id` (string)
|
|
19
|
+
- `desc` (string, optional)
|
|
20
|
+
- `cwd` (string, optional)
|
|
21
|
+
- `cmd` (string or string array)
|
|
22
|
+
- `deps` (string array)
|
|
23
|
+
- `inputs` (path array)
|
|
24
|
+
- `outputs` (path array)
|
|
25
|
+
- `env` (dict, optional)
|
|
26
|
+
|
|
27
|
+
## Proof Output
|
|
28
|
+
|
|
29
|
+
Each run writes to timestamped proof directory under `_proof` (or `NGKS_PROOF_ROOT`):
|
|
30
|
+
|
|
31
|
+
- `events.jsonl`
|
|
32
|
+
- `commands.jsonl`
|
|
33
|
+
- `summary.json`
|
|
34
|
+
- `summary.txt`
|
|
35
|
+
- `environment.txt`
|
|
36
|
+
- `tool_versions.txt`
|
|
37
|
+
- `git_status.txt`
|
|
38
|
+
- `git_head.txt`
|
|
39
|
+
|
|
40
|
+
## Integration Adapters
|
|
41
|
+
|
|
42
|
+
- `ngksbuildcore.adapters.graph_adapter.run_graph_plan`
|
|
43
|
+
- `ngksbuildcore.adapters.devfabric_adapter.build_from_manifest`
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import json
|
|
4
|
+
import os
|
|
5
|
+
import subprocess
|
|
6
|
+
from pathlib import Path
|
|
7
|
+
|
|
8
|
+
from ..loggingx import EventLogger
|
|
9
|
+
from ..runner import make_proof_dir
|
|
10
|
+
from .graph_adapter import run_graph_plan
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
def build_from_manifest(manifest_path: str, jobs: int = 1, proof_dir: str | None = None) -> int:
|
|
14
|
+
manifest_file = Path(manifest_path).resolve()
|
|
15
|
+
payload = json.loads(manifest_file.read_text(encoding="utf-8"))
|
|
16
|
+
|
|
17
|
+
proof = make_proof_dir(proof_dir)
|
|
18
|
+
logger = EventLogger(proof)
|
|
19
|
+
try:
|
|
20
|
+
logger.emit("DEVFABRIC_MANIFEST_LOAD_OK", manifest=str(manifest_file))
|
|
21
|
+
plan_path = payload.get("plan_path")
|
|
22
|
+
if plan_path:
|
|
23
|
+
return run_graph_plan(plan_path=plan_path, jobs=jobs, proof_dir=proof_dir, extra_env={"NGKS_DEVFABRIC_MODE": "1"})
|
|
24
|
+
|
|
25
|
+
graph_exe = os.environ.get("NGKS_GRAPH_EXE")
|
|
26
|
+
if not graph_exe:
|
|
27
|
+
logger.emit("GRAPH_NOT_FOUND", reason="NGKS_GRAPH_EXE not set and manifest has no plan_path")
|
|
28
|
+
return 2
|
|
29
|
+
|
|
30
|
+
graph_args = payload.get("graph_args", [])
|
|
31
|
+
out_plan = payload.get("plan_out", str((manifest_file.parent / "generated_plan.json").resolve()))
|
|
32
|
+
cmd = [graph_exe, *graph_args, "--out", out_plan]
|
|
33
|
+
logger.emit("GRAPH_GENERATE_START", cmd=cmd)
|
|
34
|
+
proc = subprocess.run(cmd, text=True, capture_output=True, encoding="utf-8", errors="replace")
|
|
35
|
+
logger.emit("GRAPH_GENERATE_END", exit_code=proc.returncode, stdout=proc.stdout, stderr=proc.stderr)
|
|
36
|
+
if proc.returncode != 0:
|
|
37
|
+
return proc.returncode
|
|
38
|
+
|
|
39
|
+
return run_graph_plan(plan_path=out_plan, jobs=jobs, proof_dir=proof_dir, extra_env={"NGKS_DEVFABRIC_MODE": "1"})
|
|
40
|
+
finally:
|
|
41
|
+
logger.close()
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from typing import Mapping
|
|
4
|
+
|
|
5
|
+
from ..runner import run_build
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
def run_graph_plan(plan_path: str, jobs: int = 1, proof_dir: str | None = None, extra_env: Mapping[str, str] | None = None) -> int:
|
|
9
|
+
env = dict(extra_env or {})
|
|
10
|
+
return run_build(plan_path=plan_path, jobs=jobs, proof=proof_dir, extra_env=env)
|
|
@@ -0,0 +1,159 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import argparse
|
|
4
|
+
import json
|
|
5
|
+
import os
|
|
6
|
+
import subprocess
|
|
7
|
+
import sys
|
|
8
|
+
from pathlib import Path
|
|
9
|
+
|
|
10
|
+
from .plan import load_plan
|
|
11
|
+
from .runner import make_proof_dir, run_build
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
def _default_jobs(value: int | None) -> int:
|
|
15
|
+
if value is not None:
|
|
16
|
+
return value
|
|
17
|
+
env_value = os.environ.get("NGKS_BUILD_JOBS")
|
|
18
|
+
if env_value and env_value.isdigit():
|
|
19
|
+
return max(1, int(env_value))
|
|
20
|
+
return 1
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
def _doctor(proof: str | None) -> int:
|
|
24
|
+
proof_dir = make_proof_dir(proof)
|
|
25
|
+
checks: list[tuple[str, bool, str]] = []
|
|
26
|
+
|
|
27
|
+
py_ok = sys.version_info >= (3, 9)
|
|
28
|
+
checks.append(("python_version", py_ok, sys.version.split()[0]))
|
|
29
|
+
|
|
30
|
+
state_dir = Path(".ngksbuildcore")
|
|
31
|
+
proof_root = Path("_proof")
|
|
32
|
+
try:
|
|
33
|
+
state_dir.mkdir(parents=True, exist_ok=True)
|
|
34
|
+
test_file = state_dir / "_write_test.tmp"
|
|
35
|
+
test_file.write_text("ok\n", encoding="utf-8")
|
|
36
|
+
test_file.unlink()
|
|
37
|
+
state_ok = True
|
|
38
|
+
state_msg = "writable"
|
|
39
|
+
except Exception as exc:
|
|
40
|
+
state_ok = False
|
|
41
|
+
state_msg = str(exc)
|
|
42
|
+
checks.append(("state_dir_write", state_ok, state_msg))
|
|
43
|
+
|
|
44
|
+
try:
|
|
45
|
+
proof_root.mkdir(parents=True, exist_ok=True)
|
|
46
|
+
test_file = proof_root / "_write_test.tmp"
|
|
47
|
+
test_file.write_text("ok\n", encoding="utf-8")
|
|
48
|
+
test_file.unlink()
|
|
49
|
+
proof_ok = True
|
|
50
|
+
proof_msg = "writable"
|
|
51
|
+
except Exception as exc:
|
|
52
|
+
proof_ok = False
|
|
53
|
+
proof_msg = str(exc)
|
|
54
|
+
checks.append(("proof_root_write", proof_ok, proof_msg))
|
|
55
|
+
|
|
56
|
+
try:
|
|
57
|
+
proc = subprocess.run(
|
|
58
|
+
[sys.executable, "-c", "print('SPAWN_OK')"],
|
|
59
|
+
check=True,
|
|
60
|
+
capture_output=True,
|
|
61
|
+
text=True,
|
|
62
|
+
encoding="utf-8",
|
|
63
|
+
errors="replace",
|
|
64
|
+
)
|
|
65
|
+
spawn_ok = "SPAWN_OK" in proc.stdout
|
|
66
|
+
spawn_msg = proc.stdout.strip()
|
|
67
|
+
except Exception as exc:
|
|
68
|
+
spawn_ok = False
|
|
69
|
+
spawn_msg = str(exc)
|
|
70
|
+
checks.append(("process_spawn", spawn_ok, spawn_msg))
|
|
71
|
+
|
|
72
|
+
passed = all(item[1] for item in checks)
|
|
73
|
+
result = {
|
|
74
|
+
"status": "PASS" if passed else "FAIL",
|
|
75
|
+
"proof_dir": str(proof_dir),
|
|
76
|
+
"checks": [{"name": n, "ok": ok, "detail": d} for n, ok, d in checks],
|
|
77
|
+
}
|
|
78
|
+
(proof_dir / "doctor.json").write_text(json.dumps(result, indent=2, ensure_ascii=True), encoding="utf-8")
|
|
79
|
+
report_lines = [f"doctor: {result['status']}"] + [f"- {n}: {'PASS' if ok else 'FAIL'} ({d})" for n, ok, d in checks]
|
|
80
|
+
text = "\n".join(report_lines) + "\n"
|
|
81
|
+
(proof_dir / "doctor.txt").write_text(text, encoding="utf-8")
|
|
82
|
+
print(text, end="")
|
|
83
|
+
return 0 if passed else 1
|
|
84
|
+
|
|
85
|
+
|
|
86
|
+
def _explain(plan_path: str, node_id: str | None) -> int:
|
|
87
|
+
plan = load_plan(plan_path)
|
|
88
|
+
if node_id is None:
|
|
89
|
+
print(f"plan: {plan.plan_path}")
|
|
90
|
+
print(f"base_dir: {plan.base_dir}")
|
|
91
|
+
print(f"nodes: {len(plan.nodes)}")
|
|
92
|
+
for n in sorted(plan.nodes, key=lambda x: x.id):
|
|
93
|
+
print(f"- {n.id}: deps={n.deps} inputs={len(n.inputs)} outputs={len(n.outputs)}")
|
|
94
|
+
return 0
|
|
95
|
+
|
|
96
|
+
found = None
|
|
97
|
+
for n in plan.nodes:
|
|
98
|
+
if n.id == node_id:
|
|
99
|
+
found = n
|
|
100
|
+
break
|
|
101
|
+
if not found:
|
|
102
|
+
print(f"node not found: {node_id}")
|
|
103
|
+
return 1
|
|
104
|
+
|
|
105
|
+
print(json.dumps(
|
|
106
|
+
{
|
|
107
|
+
"id": found.id,
|
|
108
|
+
"desc": found.desc,
|
|
109
|
+
"cwd": found.cwd,
|
|
110
|
+
"cmd": found.cmd,
|
|
111
|
+
"deps": found.deps,
|
|
112
|
+
"inputs": found.inputs,
|
|
113
|
+
"outputs": found.outputs,
|
|
114
|
+
"env": found.env,
|
|
115
|
+
},
|
|
116
|
+
indent=2,
|
|
117
|
+
ensure_ascii=True,
|
|
118
|
+
))
|
|
119
|
+
return 0
|
|
120
|
+
|
|
121
|
+
|
|
122
|
+
def build_parser() -> argparse.ArgumentParser:
|
|
123
|
+
parser = argparse.ArgumentParser(prog="ngksbuildcore", description="NGKsBuildCore MVP build runner")
|
|
124
|
+
sub = parser.add_subparsers(dest="command", required=True)
|
|
125
|
+
|
|
126
|
+
run_cmd = sub.add_parser("run", help="Run a build plan DAG")
|
|
127
|
+
run_cmd.add_argument("--plan", required=True, help="Path to plan json")
|
|
128
|
+
run_cmd.add_argument("--env-lock", default=None, help="Path to env_capsule.lock.json")
|
|
129
|
+
run_cmd.add_argument("-j", "--jobs", type=int, default=None, help="Parallel jobs")
|
|
130
|
+
run_cmd.add_argument("--proof", default=None, help="Proof root directory override")
|
|
131
|
+
run_cmd.add_argument("--pf", default=None, help="Proof root directory override (alias of --proof)")
|
|
132
|
+
|
|
133
|
+
doctor_cmd = sub.add_parser("doctor", help="Environment diagnostics")
|
|
134
|
+
doctor_cmd.add_argument("--proof", default=None, help="Proof root directory override")
|
|
135
|
+
doctor_cmd.add_argument("--pf", default=None, help="Proof root directory override (alias of --proof)")
|
|
136
|
+
|
|
137
|
+
explain_cmd = sub.add_parser("explain", help="Explain plan or node")
|
|
138
|
+
explain_cmd.add_argument("--plan", required=True, help="Path to plan json")
|
|
139
|
+
explain_cmd.add_argument("--node", default=None, help="Optional node id")
|
|
140
|
+
|
|
141
|
+
return parser
|
|
142
|
+
|
|
143
|
+
|
|
144
|
+
def main(argv: list[str] | None = None) -> int:
|
|
145
|
+
parser = build_parser()
|
|
146
|
+
args = parser.parse_args(argv)
|
|
147
|
+
|
|
148
|
+
if args.command == "run":
|
|
149
|
+
jobs = _default_jobs(args.jobs)
|
|
150
|
+
proof_root = args.pf or args.proof
|
|
151
|
+
return run_build(plan_path=args.plan, jobs=jobs, proof=proof_root, env_lock=args.env_lock)
|
|
152
|
+
if args.command == "doctor":
|
|
153
|
+
proof_root = args.pf or args.proof
|
|
154
|
+
return _doctor(proof_root)
|
|
155
|
+
if args.command == "explain":
|
|
156
|
+
return _explain(args.plan, args.node)
|
|
157
|
+
|
|
158
|
+
parser.print_help()
|
|
159
|
+
return 1
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import hashlib
|
|
4
|
+
from pathlib import Path
|
|
5
|
+
|
|
6
|
+
|
|
7
|
+
def normalize_path(path: str | Path, base_dir: Path) -> Path:
|
|
8
|
+
p = Path(path)
|
|
9
|
+
if not p.is_absolute():
|
|
10
|
+
p = (base_dir / p).resolve()
|
|
11
|
+
else:
|
|
12
|
+
p = p.resolve()
|
|
13
|
+
return p
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
def file_fingerprint(path: Path) -> str:
|
|
17
|
+
stat = path.stat()
|
|
18
|
+
raw = f"{path}:{stat.st_mtime_ns}:{stat.st_size}".encode("utf-8", errors="replace")
|
|
19
|
+
return hashlib.sha256(raw).hexdigest()
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
def input_signature(paths: list[Path]) -> str:
|
|
23
|
+
joined = "|".join(file_fingerprint(p) for p in sorted(paths))
|
|
24
|
+
return hashlib.sha256(joined.encode("utf-8", errors="replace")).hexdigest()
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import json
|
|
4
|
+
import threading
|
|
5
|
+
from datetime import datetime, timezone
|
|
6
|
+
from pathlib import Path
|
|
7
|
+
from typing import Any
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
def utc_now_iso() -> str:
|
|
11
|
+
return datetime.now(timezone.utc).isoformat()
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
class EventLogger:
|
|
15
|
+
def __init__(self, proof_dir: Path, console_verbose: bool = True) -> None:
|
|
16
|
+
self.proof_dir = proof_dir
|
|
17
|
+
self.proof_dir.mkdir(parents=True, exist_ok=True)
|
|
18
|
+
self.events_path = self.proof_dir / "events.jsonl"
|
|
19
|
+
self.commands_path = self.proof_dir / "commands.jsonl"
|
|
20
|
+
self._events_handle = self.events_path.open("a", encoding="utf-8", newline="\n")
|
|
21
|
+
self._commands_handle = self.commands_path.open("a", encoding="utf-8", newline="\n")
|
|
22
|
+
self._lock = threading.Lock()
|
|
23
|
+
self.console_verbose = console_verbose
|
|
24
|
+
|
|
25
|
+
def emit(self, event_type: str, **payload: Any) -> None:
|
|
26
|
+
evt = {"ts": utc_now_iso(), "event": event_type, **payload}
|
|
27
|
+
line = json.dumps(evt, ensure_ascii=True)
|
|
28
|
+
with self._lock:
|
|
29
|
+
self._events_handle.write(line + "\n")
|
|
30
|
+
self._events_handle.flush()
|
|
31
|
+
|
|
32
|
+
def command(self, **payload: Any) -> None:
|
|
33
|
+
row = {"ts": utc_now_iso(), **payload}
|
|
34
|
+
line = json.dumps(row, ensure_ascii=True)
|
|
35
|
+
with self._lock:
|
|
36
|
+
self._commands_handle.write(line + "\n")
|
|
37
|
+
self._commands_handle.flush()
|
|
38
|
+
|
|
39
|
+
def print(self, message: str) -> None:
|
|
40
|
+
if self.console_verbose:
|
|
41
|
+
print(message, flush=True)
|
|
42
|
+
|
|
43
|
+
def close(self) -> None:
|
|
44
|
+
with self._lock:
|
|
45
|
+
self._events_handle.close()
|
|
46
|
+
self._commands_handle.close()
|
|
@@ -0,0 +1,107 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import json
|
|
4
|
+
from dataclasses import dataclass, field
|
|
5
|
+
from pathlib import Path
|
|
6
|
+
|
|
7
|
+
from .hashing import normalize_path
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
@dataclass(slots=True)
|
|
11
|
+
class PlanNode:
|
|
12
|
+
id: str
|
|
13
|
+
desc: str = ""
|
|
14
|
+
cwd: str | None = None
|
|
15
|
+
cmd: str | list[str] = ""
|
|
16
|
+
deps: list[str] = field(default_factory=list)
|
|
17
|
+
inputs: list[str] = field(default_factory=list)
|
|
18
|
+
outputs: list[str] = field(default_factory=list)
|
|
19
|
+
env: dict[str, str] = field(default_factory=dict)
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
@dataclass(slots=True)
|
|
23
|
+
class BuildPlan:
|
|
24
|
+
plan_path: Path
|
|
25
|
+
base_dir: Path
|
|
26
|
+
nodes: list[PlanNode]
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
def _as_str_list(value: object) -> list[str]:
|
|
30
|
+
if value is None:
|
|
31
|
+
return []
|
|
32
|
+
if not isinstance(value, list):
|
|
33
|
+
raise ValueError("expected a list")
|
|
34
|
+
return [str(x) for x in value]
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
def _legacy_node(raw: dict) -> PlanNode:
|
|
38
|
+
return PlanNode(
|
|
39
|
+
id=str(raw["id"]),
|
|
40
|
+
desc=str(raw.get("desc", "")),
|
|
41
|
+
cwd=raw.get("cwd"),
|
|
42
|
+
cmd=raw["cmd"],
|
|
43
|
+
deps=_as_str_list(raw.get("deps", [])),
|
|
44
|
+
inputs=_as_str_list(raw.get("inputs", [])),
|
|
45
|
+
outputs=_as_str_list(raw.get("outputs", [])),
|
|
46
|
+
env={str(k): str(v) for k, v in (raw.get("env") or {}).items()},
|
|
47
|
+
)
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
def _graph_action_node(raw: dict) -> PlanNode:
|
|
51
|
+
argv = raw.get("argv", [])
|
|
52
|
+
if isinstance(argv, list):
|
|
53
|
+
cmd: str | list[str] = [str(x) for x in argv]
|
|
54
|
+
else:
|
|
55
|
+
cmd = str(argv)
|
|
56
|
+
return PlanNode(
|
|
57
|
+
id=str(raw["id"]),
|
|
58
|
+
desc=str(raw.get("desc", "")),
|
|
59
|
+
cwd=raw.get("cwd"),
|
|
60
|
+
cmd=cmd,
|
|
61
|
+
deps=_as_str_list(raw.get("deps", [])),
|
|
62
|
+
inputs=_as_str_list(raw.get("inputs", [])),
|
|
63
|
+
outputs=_as_str_list(raw.get("outputs", [])),
|
|
64
|
+
env={str(k): str(v) for k, v in (raw.get("env") or {}).items()},
|
|
65
|
+
)
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
def load_plan(plan_path: str | Path) -> BuildPlan:
|
|
69
|
+
plan_file = Path(plan_path).resolve()
|
|
70
|
+
with plan_file.open("r", encoding="utf-8") as f:
|
|
71
|
+
payload = json.load(f)
|
|
72
|
+
|
|
73
|
+
if isinstance(payload.get("actions"), list):
|
|
74
|
+
raw_nodes = payload["actions"]
|
|
75
|
+
node_factory = _graph_action_node
|
|
76
|
+
elif isinstance(payload.get("nodes"), list):
|
|
77
|
+
raw_nodes = payload["nodes"]
|
|
78
|
+
node_factory = _legacy_node
|
|
79
|
+
else:
|
|
80
|
+
if isinstance(payload, dict) and isinstance(payload.get("targets"), list):
|
|
81
|
+
raise ValueError(
|
|
82
|
+
"plan json appears graph-native (targets/steps). Use `ngksgraph buildplan` for NGKsBuildCore-compatible output."
|
|
83
|
+
)
|
|
84
|
+
raise ValueError("plan json must include either an 'actions' array or a 'nodes' array")
|
|
85
|
+
|
|
86
|
+
base_dir_raw = payload.get("base_dir")
|
|
87
|
+
if base_dir_raw:
|
|
88
|
+
base_dir = normalize_path(base_dir_raw, plan_file.parent)
|
|
89
|
+
else:
|
|
90
|
+
base_dir = plan_file.parent.resolve()
|
|
91
|
+
|
|
92
|
+
nodes: list[PlanNode] = []
|
|
93
|
+
seen_ids: set[str] = set()
|
|
94
|
+
for raw in raw_nodes:
|
|
95
|
+
node = node_factory(raw)
|
|
96
|
+
if node.id in seen_ids:
|
|
97
|
+
raise ValueError(f"duplicate node id: {node.id}")
|
|
98
|
+
seen_ids.add(node.id)
|
|
99
|
+
nodes.append(node)
|
|
100
|
+
|
|
101
|
+
id_set = {n.id for n in nodes}
|
|
102
|
+
for node in nodes:
|
|
103
|
+
for dep in node.deps:
|
|
104
|
+
if dep not in id_set:
|
|
105
|
+
raise ValueError(f"node '{node.id}' depends on unknown node '{dep}'")
|
|
106
|
+
|
|
107
|
+
return BuildPlan(plan_path=plan_file, base_dir=base_dir, nodes=nodes)
|