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
19 changes: 19 additions & 0 deletions plugins/kbagent/skills/kbagent/references/gotchas.md
Original file line number Diff line number Diff line change
Expand Up @@ -392,6 +392,25 @@ seamlessly. This is transparent -- no user action required.
- Skipped for: dev/editable installs, `update`/`version` commands
- Never crashes the CLI -- update failures are silently ignored

## `lineage build` and sync layouts

`lineage build` reads synced data from disk and supports both layouts produced by
`kbagent sync pull`:

- **Flat** (after `sync pull --project X`): `./.keboola/manifest.json` directly in CWD.
- **Nested** (after `sync pull --all-projects`): `./<alias>/.keboola/manifest.json`
for each project side by side.

Pass the matching directory to `--directory` / `-d`:

- Flat: `kbagent lineage build -d . -o lineage.json`
- Nested: `kbagent lineage build -d /path/to/parent -o lineage.json`

If the scan finds zero projects, the build still writes the cache file but
emits a warning (both in the human-readable output and as a `warnings` array
in `--json` mode) with a hint about the expected layouts. In JSON mode, inspect
`result["data"]["warnings"]` to detect this situation programmatically.

## Sync and dev branches

When an active branch is set (`branch use --branch ID`), sync commands automatically
Expand Down
13 changes: 13 additions & 0 deletions plugins/kbagent/skills/kbagent/references/lineage-deep-workflow.md
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,19 @@ kbagent sync pull --all-projects

The sync'd directory structure is the input for lineage analysis.

### Supported layouts

`lineage build` auto-detects both layouts produced by `sync pull`:

| Source command | Layout | Example manifest path |
|----------------|--------|------------------------|
| `sync pull --project X` | **Flat** -- CWD *is* the project | `./.keboola/manifest.json` |
| `sync pull --all-projects` | **Nested** -- one subdir per project | `./<alias>/.keboola/manifest.json` |

Pass the directory that *contains* the manifest (flat) or the parent of all
project subdirs (nested). When the build finds zero projects it emits a
warning with a hint rather than silently returning an empty graph.

## Build lineage

```bash
Expand Down
8 changes: 6 additions & 2 deletions src/keboola_agent_cli/commands/context.py
Original file line number Diff line number Diff line change
Expand Up @@ -252,8 +252,12 @@
kbagent lineage build --directory PATH --output PATH [--ai] [--refresh]
Build column-level lineage graph from sync'd data. Scans all sync'd projects,
detects dependencies via config mappings and SQL parsing, saves to cache file.
--refresh runs sync pull first. --ai generates .lineage_ai_tasks.json with
AI analysis tasks for an AI agent to process (2-step flow).
Auto-detects both sync layouts: flat (./.keboola/manifest.json from
sync pull --project X) and nested (./<alias>/.keboola/manifest.json from
sync pull --all-projects). Emits a warning in response data if no projects
are found. --refresh runs sync pull first. --ai generates
.lineage_ai_tasks.json with AI analysis tasks for an AI agent to process
(2-step flow).

kbagent lineage show --load PATH [--upstream NODE] [--downstream NODE]
[--column COL] [--columns] [--project ALIAS] [--depth N]
Expand Down
3 changes: 3 additions & 0 deletions src/keboola_agent_cli/commands/lineage.py
Original file line number Diff line number Diff line change
Expand Up @@ -158,6 +158,9 @@ def lineage_build(
formatter.console.print(
" Next: let your AI agent process the tasks, then re-run this command."
)
# Surface warnings (e.g. empty scan) so users notice layout issues.
for warning in result.get("warnings", []) or []:
formatter.console.print(f"\n[yellow]Warning:[/yellow] {warning}")
formatter.console.print(f"\n Saved to: {output}")


Expand Down
151 changes: 110 additions & 41 deletions src/keboola_agent_cli/services/deep_lineage_service.py
Original file line number Diff line number Diff line change
Expand Up @@ -375,18 +375,40 @@ def build_lineage(
if present. Use generate_ai_tasks=True to write a task file
for the AI agent to process.

Supports both sync layouts:
- Flat (``sync pull --project X``): ``root/.keboola/manifest.json``
- Nested (``sync pull --all-projects``): ``root/<alias>/.keboola/manifest.json``

Args:
root: Root directory containing sync'd project subdirectories.
root: Root directory containing sync'd project data. Can be either
the project directory itself (flat layout) or a parent directory
with one subdirectory per project (nested layout).
generate_ai_tasks: If True, write .lineage_ai_tasks.json for AI.

Returns:
Dict with lineage graph data, summary, and ai_status.
Dict with lineage graph data, summary, ai_status, and ``warnings``
(list of human-readable warnings emitted during the build, e.g. when
no sync'd projects were found).
"""
project_id_to_alias = self._build_project_map()
warnings: list[str] = []

# Phase 1: Scan
graph = self._scan_projects(root, project_id_to_alias)

# Empty-scan warning -- most often caused by a layout mismatch between
# ``sync pull --project X`` (flat) and ``sync pull --all-projects`` (nested).
if not graph.tables and not graph.configurations:
warning = (
f"No synced projects found in {root}. Expected either:\n"
f" - {root}/.keboola/manifest.json (single-project flat layout), or\n"
f" - {root}/<alias>/.keboola/manifest.json (multi-project nested layout)\n"
"Hint: run 'kbagent sync pull --all-projects' or pass --directory "
"pointing to the parent of your synced projects."
)
warnings.append(warning)
logger.warning(warning)

# Phase 2: Deterministic edges
self._build_deterministic_edges(graph, project_id_to_alias)

Expand All @@ -403,6 +425,7 @@ def build_lineage(

result = graph.to_dict()
result["ai_status"] = ai_status
result["warnings"] = warnings
return result

def build_and_cache(
Expand Down Expand Up @@ -480,53 +503,99 @@ def _build_project_map(self) -> dict[int, str]:
return mapping

def _scan_projects(self, root: Path, project_id_to_alias: dict[int, str]) -> LineageGraph:
"""Scan all sync'd projects and build initial graph."""
"""Scan all sync'd projects and build initial graph.

Supports two directory layouts (written by ``kbagent sync pull``):
- **Flat** (``sync pull --project X``): manifest lives directly at
``root/.keboola/manifest.json`` and ``root`` itself *is* the project.
- **Nested** (``sync pull --all-projects``): each project is a
subdirectory, i.e. ``root/<alias>/.keboola/manifest.json``.

Flat layout is detected first and, when present, returned exclusively
(nested iteration would be redundant -- in the flat case there are no
sibling project directories under ``root``).
"""
graph = LineageGraph()

for project_dir in sorted(root.iterdir()):
if not project_dir.is_dir() or project_dir.name.startswith("."):
continue
manifest_path = project_dir / ".keboola" / "manifest.json"
if not manifest_path.exists():
continue
flat_manifest = root / ".keboola" / "manifest.json"
if flat_manifest.exists():
project_alias = self._resolve_alias_from_manifest(flat_manifest, fallback=root.name)
self._scan_one_project(root, project_alias, project_id_to_alias, graph)
else:
for project_dir in sorted(root.iterdir()):
if not project_dir.is_dir() or project_dir.name.startswith("."):
continue
manifest_path = project_dir / ".keboola" / "manifest.json"
if not manifest_path.exists():
continue
self._scan_one_project(project_dir, project_dir.name, project_id_to_alias, graph)

# Store mapping for cross-project resolution
graph._project_id_to_alias = project_id_to_alias # type: ignore[attr-defined]
return graph

def _resolve_alias_from_manifest(self, manifest_path: Path, *, fallback: str) -> str:
"""Resolve a project alias for a flat-layout root.

In a flat layout the parent directory name is meaningless (it's the
user's CWD, not the project alias). We prefer the alias configured in
``ConfigStore`` for the project id recorded in the manifest; if no such
mapping exists, we fall back to the directory name so the graph stays
consistent but still disambiguated from other projects on disk.
"""
try:
with open(manifest_path) as f:
manifest = json.load(f)
project_id = int(manifest.get("project", {}).get("id", 0) or 0)
except (OSError, json.JSONDecodeError, ValueError):
return fallback

project_alias = project_dir.name
project_id = manifest.get("project", {}).get("id", 0)
project_id_to_alias[project_id] = project_alias

# Scan storage tables
storage_dir = project_dir / "storage" / "tables"
if storage_dir.exists():
for bucket_dir in sorted(storage_dir.iterdir()):
if not bucket_dir.is_dir():
continue
for table_file in sorted(bucket_dir.glob("*.json")):
with open(table_file) as f:
meta = json.load(f)
table = Table(
table_id=meta["id"],
project_alias=project_alias,
project_id=project_id,
bucket_id=meta["id"].rsplit(".", 1)[0],
name=meta["name"],
columns=meta.get("columns", []),
primary_key=meta.get("primary_key", []),
rows_count=meta.get("rows_count", 0),
)
graph.tables[table.fqn] = table
if project_id:
app_config = self._config_store.load()
for alias, project in app_config.projects.items():
if project.project_id == project_id:
return alias
return fallback

# Scan configurations
for config_entry in manifest.get("configurations", []):
self._scan_configuration(
project_dir, config_entry, project_alias, project_id, graph
)
def _scan_one_project(
self,
project_dir: Path,
project_alias: str,
project_id_to_alias: dict[int, str],
graph: LineageGraph,
) -> None:
"""Populate ``graph`` with tables and configs from a single project dir."""
manifest_path = project_dir / ".keboola" / "manifest.json"
with open(manifest_path) as f:
manifest = json.load(f)

project_id = manifest.get("project", {}).get("id", 0)
project_id_to_alias[project_id] = project_alias

# Scan storage tables
storage_dir = project_dir / "storage" / "tables"
if storage_dir.exists():
for bucket_dir in sorted(storage_dir.iterdir()):
if not bucket_dir.is_dir():
continue
for table_file in sorted(bucket_dir.glob("*.json")):
with open(table_file) as f:
meta = json.load(f)
table = Table(
table_id=meta["id"],
project_alias=project_alias,
project_id=project_id,
bucket_id=meta["id"].rsplit(".", 1)[0],
name=meta["name"],
columns=meta.get("columns", []),
primary_key=meta.get("primary_key", []),
rows_count=meta.get("rows_count", 0),
)
graph.tables[table.fqn] = table

# Store mapping for cross-project resolution
graph._project_id_to_alias = project_id_to_alias # type: ignore[attr-defined]
return graph
# Scan configurations
for config_entry in manifest.get("configurations", []):
self._scan_configuration(project_dir, config_entry, project_alias, project_id, graph)

def _scan_configuration(
self,
Expand Down
Loading