Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -404,7 +404,7 @@ When contributing changes to the AI plugins:
2. Verify **ai-core**, **ai-agent-local**, **ai-agent-gemini** and **ai-assistant** work together
3. Ensure **llama.cpp submodule** updates are documented
4. Run ProGuard/R8 release build to catch reflection issues
5. Update this BUILDING.md with any new dependencies or steps
5. Update this guide with any new dependencies or steps

---

Expand Down
2 changes: 1 addition & 1 deletion plugins/AI-Agent-Local/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -34,7 +34,7 @@ checked out (`subprojects/llama.cpp/CMakeLists.txt` exists) — which is exactly
the setup a CI checkout does *not* have, so CI builds against the prebuilt AAR.

To regenerate the AAR after bumping the llama.cpp fork, see
**[BUILDING.md](BUILDING.md)** and `scripts/rebuild-llama-aar.sh` (requires the
**[NATIVE-BUILD.md](NATIVE-BUILD.md)** and `scripts/rebuild-llama-aar.sh` (requires the
submodule + NDK/CMake). Notes on the fork live in
[SUBMODULE_NOTES.md](SUBMODULE_NOTES.md).

Expand Down
83 changes: 77 additions & 6 deletions tools/addons/src/addons/tarball.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@
import subprocess
import tarfile
from pathlib import Path
from typing import NamedTuple

from addons import model

Expand All @@ -19,7 +20,7 @@
- Author: {author}
- Comes from: {source}/tree/main/{directory}
- License: {license}. The full text is in LICENSE beside this file.

{prerequisites}
This folder is the project root. Open it in Code on the Go, or build it from a
desktop:

Expand Down Expand Up @@ -54,6 +55,16 @@
./gradlew downloadAssets
"""

# Stated before the build instructions (design §9.4, R46): a submodule is not
# copied, so a reader who needs it has to fetch the exact commit themselves.
SUBMODULE_NOTE = """
`{path}` is a git submodule and is not in this archive. If you
need it, clone it into place at the commit this source was published with:

git clone {url} {path}
git -C {path} checkout {commit}
"""

# Every shared jar is referenced as "../libs/x.jar" (or "../../libs/x.jar")
# from the addon's place in the repository. Flattening moves the addon to the
# archive root, so those references have to lose their parent hops.
Expand All @@ -64,6 +75,9 @@
# and stay as they are.
REWRITTEN = ("build.gradle.kts", "settings.gradle.kts")

# The index mode git gives a submodule: a commit pointer, not a file.
GITLINK = "160000"


def jars_for(addon: Path) -> list[str]:
text = ""
Expand All @@ -74,12 +88,62 @@ def jars_for(addon: Path) -> list[str]:
return sorted(set(re.findall(r"\.\./libs/([A-Za-z0-9._-]+\.jar)", text)))


def tracked_files(root: Path, addon: Path) -> list[str]:
class IndexEntry(NamedTuple):
mode: str
object_id: str
path: str

@property
def is_submodule(self) -> bool:
return self.mode == GITLINK


def _index(root: Path, addon: Path) -> list[IndexEntry]:
"""Every index entry under the addon, submodules included."""
relative = addon.relative_to(root).as_posix()
result = subprocess.run(
["git", "-C", str(root), "ls-files", "--", relative],
["git", "-C", str(root), "ls-files", "--stage", "-z", "--", relative],
capture_output=True, text=True, check=True)
return [line for line in result.stdout.splitlines() if line]
entries = []
for entry in result.stdout.split("\0"):
if entry:
info, path = entry.split("\t", 1)
mode, object_id, _ = info.split(" ")
entries.append(IndexEntry(mode, object_id, path))
return entries


def submodule_urls(root: Path) -> dict[str, str]:
"""Submodule path -> URL, from .gitmodules (keyed by path, not name)."""
result = subprocess.run(
["git", "-C", str(root), "config", "-f", ".gitmodules",
"--get-regexp", r"^submodule\..*\.(path|url)$"],
capture_output=True, text=True)
# Exit 1 only means no .gitmodules or no match; anything else is a real error
if result.returncode not in (0, 1):
raise RuntimeError(f"reading .gitmodules failed: {result.stderr.strip()}")
fields: dict[str, dict[str, str]] = {}
for line in result.stdout.splitlines():
key, value = line.split(" ", 1)
name, field = key.removeprefix("submodule.").rsplit(".", 1)
fields.setdefault(name, {})[field] = value
return {f["path"]: f["url"] for f in fields.values() if "path" in f and "url" in f}


def _submodule_notes(root: Path, addon: Path, submodules: list[IndexEntry]) -> str:
"""The clone instructions for each submodule, or "" when there are none."""
if not submodules:
return ""
inside = addon.relative_to(root).as_posix()
urls = submodule_urls(root)
notes = ""
for entry in submodules:
if entry.path not in urls:
raise RuntimeError(
f"{addon.name}: submodule {entry.path} has no url in .gitmodules")
notes += SUBMODULE_NOTE.format(path=entry.path[len(inside) + 1:],
url=urls[entry.path], commit=entry.object_id)
return notes


def flatten(text: str) -> str:
Expand Down Expand Up @@ -107,7 +171,11 @@ def _stage(root: Path, addon: Path, out: Path, meta: dict) -> Path:
top.mkdir(parents=True)

inside = addon.relative_to(root).as_posix()
files = tracked_files(root, addon)
entries = _index(root, addon)
# A submodule is one entry pointing at a commit, not a file, so there is
# nothing to copy. AI-Agent-Local's llama.cpp is the case in point: its
# build only needs the committed AAR (see its settings.gradle.kts).
files = [entry.path for entry in entries if not entry.is_submodule]
if not files:
raise RuntimeError(f"{addon.name}: git tracks no file in this directory")
# The archive root IS the project root: Code on the Go reads a folder as a
Expand Down Expand Up @@ -152,9 +220,12 @@ def mentions(relative: str, text: str) -> bool:
if mentions("build.gradle.kts", "downloadAssets"):
caveats += DOWNLOAD_NOTE

prerequisites = _submodule_notes(
root, addon, [entry for entry in entries if entry.is_submodule])

author = meta.get("author") or {}
_claim(top, "BUILDING.md", addon.name).write_text(NOTES.format(
caveats=caveats,
caveats=caveats, prerequisites=prerequisites,
name=model.display_name(addon.name), directory=inside,
origin="Community contribution" if meta.get("origin") == "community"
else "App Dev for All",
Expand Down
62 changes: 62 additions & 0 deletions tools/addons/tests/test_tarball.py
Original file line number Diff line number Diff line change
Expand Up @@ -184,6 +184,68 @@ def test_verify_rejects_a_surviving_parent_path(tmp_path):
tarball.verify(top, "plugins/Keystore-Generator", ["plugin-api.jar"])


LLAMA_URL = "https://github.com/appdevforall/llama.cpp.git"


def add_submodule(root: Path, addon: Path, url: str | None = LLAMA_URL) -> str:
"""Track a nested repo under the addon as a gitlink; returns its commit."""
nested = addon / "subprojects" / "llama.cpp"
subprocess.run(["git", "init", "-q", str(nested)], check=True)
(nested / "CMakeLists.txt").write_text("project(llama)\n")
commit(nested)
if url:
(root / ".gitmodules").write_text(
'[submodule "llama"]\n'
f"\tpath = {nested.relative_to(root).as_posix()}\n"
f"\turl = {url}\n")
commit(root)
return subprocess.run(["git", "-C", str(nested), "rev-parse", "HEAD"],
capture_output=True, text=True).stdout.strip()


def test_a_submodule_is_left_out_of_the_archive(tmp_path):
"""AI-Agent-Local's llama.cpp: copying the gitlink raised IsADirectoryError."""
addon = make_repo(tmp_path)
pinned = add_submodule(tmp_path, addon)
archive = build(tmp_path, addon, META)
top = "keystore-generator-src"
names = names_in(archive)
assert f"{top}/build.gradle.kts" in names
assert not any(n.startswith(f"{top}/subprojects") for n in names)
notes = text_in(archive, f"{top}/BUILDING.md")
assert f"git clone {LLAMA_URL} subprojects/llama.cpp" in notes
assert f"checkout {pinned}" in notes
# R46: the prerequisite comes before the build instructions
assert notes.index("git clone") < notes.index("./gradlew assemblePlugin")
# Blank lines keep the note out of the License bullet and end the code block
assert "this file.\n\n`subprojects/llama.cpp`" in notes
assert f"checkout {pinned}\n\nThis folder" in notes


def test_an_addon_without_submodules_gets_no_submodule_note(tmp_path):
archive = build(tmp_path, make_repo(tmp_path), META)
notes = text_in(archive, "keystore-generator-src/BUILDING.md")
assert "submodule" not in notes
assert "this file.\n\nThis folder" in notes


def test_a_submodule_missing_from_gitmodules_stops_the_build(tmp_path):
"""No clone instructions at all beats ones with a placeholder URL."""
addon = make_repo(tmp_path)
add_submodule(tmp_path, addon, url=None)
with pytest.raises(RuntimeError, match="has no url in .gitmodules"):
build(tmp_path, addon, META)


def test_an_addon_shipping_its_own_building_md_stops_the_build(tmp_path):
"""AI-Agent-Local's guide is NATIVE-BUILD.md for this reason (ADFA-6566)."""
addon = make_repo(tmp_path)
(addon / "BUILDING.md").write_text("# The addon's own guide\n")
commit(tmp_path)
with pytest.raises(RuntimeError, match="BUILDING.md is claimed twice"):
build(tmp_path, addon, META)


def test_a_sibling_directory_is_not_inside_the_archive(tmp_path):
# "/out/foo-src-evil" starts with "/out/foo-src" but is not inside it
root = tmp_path / "foo-src"
Expand Down
Loading