Files
headquarter/openspec/designs/tool-definition-manifest.md
T
Alex Blank 5deee8c65c feat: tool definition manifest system (PR 1)
- Add ToolDefinitionManifest model with base image versioning
- Add manifest compiler: Dockerfile + Compose generation from JSON manifests
- Add permission fixer: post-start chown/chmod for mount policies
- Add tool definition CRUD API with live compile preview endpoint
- Integrate manifest-based startup flow in start_instance
- Add Alembic migration with data conversion for pi-agent
- Add 48 unit tests for manifest compiler, permission fixer, docker service
- Keep backward compatibility with legacy dockerfile_template/compose_template

Migration: applied successfully. Pi-agent converted to manifest.
Quality gates: pytest (146 passed, 4 pre-existing unrelated failures)
2026-05-28 13:37:34 +02:00

752 lines
30 KiB
Markdown

# Design: Tool Definition Manifest System
## Status
**Phase:** design
**Date:** 2026-05-28
**Owner:** el Gentleman
**Based on:** Spec `tool-definition-manifest`
---
## Architecture Overview
```
┌─────────────────────────────────────────────────────────────────┐
│ Tool Workshop (Frontend) │
│ ┌─────────────┐ ┌─────────────┐ ┌─────────────────────────┐ │
│ │ Base Image │ │ Packages │ │ Mount Schema Designer │ │
│ │ Selector │ │ Editors │ │ (target, owner, mode) │ │
│ └─────────────┘ └─────────────┘ └─────────────────────────┘ │
│ ┌─────────────────────────────────────────────────────────────┐│
│ │ Live Preview: Dockerfile + Compose ││
│ └─────────────────────────────────────────────────────────────┘│
└─────────────────────────────────────────────────────────────────┘
▼ POST /tool-definitions
┌─────────────────────────────────────────────────────────────────┐
│ API Backend │
│ ┌─────────────────┐ ┌──────────────────┐ ┌────────────┐ │
│ │ ManifestSchema │───▶│ ManifestCompiler │───▶│ LiveBuild │ │
│ │ (validation) │ │ (Dockerfile + │ │ (optional) │ │
│ │ │ │ Compose gen) │ │ │ │
│ └─────────────────┘ └──────────────────┘ └────────────┘ │
│ │
│ ▼ save to DB
│ ┌─────────────────────────────────────────────────────────────┐│
│ │ ToolDefinitionManifest (JSONB in PostgreSQL) ││
│ └─────────────────────────────────────────────────────────────┘│
└─────────────────────────────────────────────────────────────────┘
▼ POST /instances/{id}/start
┌─────────────────────────────────────────────────────────────────┐
│ Instance Startup Flow │
│ ┌─────────────┐ ┌──────────────┐ ┌──────────────────────┐ │
│ │ Resolve │──▶│ Compile │──▶│ Build Image │ │
│ │ Manifest │ │ to Dockerfile│ │ (docker build) │ │
│ │ (base merge)│ │ + Compose │ │ │ │
│ └─────────────┘ └──────────────┘ └──────────────────────┘ │
│ │
│ ▼
│ ┌─────────────┐ ┌──────────────┐ ┌──────────────────────┐ │
│ │ Permission │◀──│ docker comp. │◀──│ Generate Compose │ │
│ │ Fixer │ │ up │ │ (mount resolution) │ │
│ │ (post-start)│ │ │ │ │ │
│ └─────────────┘ └──────────────┘ └──────────────────────┘ │
└─────────────────────────────────────────────────────────────────┘
```
---
## Manifest JSON Schema
```json
{
"$schema": "http://json-schema.org/draft-07/schema#",
"type": "object",
"required": ["name", "interface_type"],
"oneOf": [
{"required": ["base_image"]},
{"required": ["base_definition_id"]}
],
"properties": {
"name": {"type": "string", "pattern": "^[a-z0-9-]+$", "maxLength": 64},
"display_name": {"type": "string", "maxLength": 128},
"description": {"type": "string"},
"category": {"type": "string", "maxLength": 64},
"interface_type": {"type": "string", "enum": ["web", "terminal"]},
"base_image": {"type": "string", "maxLength": 256},
"base_definition_id": {"type": "string", "format": "uuid"},
"base_version": {"type": "string", "maxLength": 32, "default": "latest"},
"packages": {
"type": "object",
"properties": {
"apt": {"type": "array", "items": {"type": "string"}},
"node": {
"type": "object",
"properties": {
"version": {"type": "string", "pattern": "^\\d+$"}
},
"required": ["version"]
},
"npm_global": {"type": "array", "items": {"type": "string"}},
"pip": {"type": "array", "items": {"type": "string"}}
}
},
"user": {
"type": "object",
"properties": {
"name": {"type": "string", "maxLength": 32},
"uid": {"type": "integer", "minimum": 1, "maximum": 65535},
"gid": {"type": "integer", "minimum": 1, "maximum": 65535},
"create_home": {"type": "boolean", "default": true},
"shell": {"type": "string", "maxLength": 64, "default": "/bin/bash"}
},
"required": ["name", "uid", "gid"]
},
"env": {"type": "object", "additionalProperties": {"type": "string"}},
"scripts": {
"type": "object",
"properties": {
"build": {"type": "array", "items": {"type": "string"}},
"startup": {"type": "array", "items": {"type": "string"}}
}
},
"mounts": {
"type": "array",
"items": {
"type": "object",
"required": ["name", "target", "source_type"],
"properties": {
"name": {"type": "string", "maxLength": 64},
"target": {"type": "string", "maxLength": 256},
"source_type": {"type": "string", "enum": ["repo", "ssh_key", "instance", "git_mount", "host_path"]},
"writable": {"type": "boolean", "default": true},
"owner": {"type": "string", "maxLength": 32},
"mode": {"type": "string", "pattern": "^[0-7]{3,4}$"},
"file_mode": {"type": "string", "pattern": "^[0-7]{3,4}$"},
"readonly": {"type": "boolean", "default": false},
"git_mount_ref": {"type": "string", "maxLength": 64}
}
}
},
"runtime": {
"type": "object",
"properties": {
"command": {"type": "array", "items": {"type": "string"}},
"stdin_open": {"type": "boolean", "default": false},
"tty": {"type": "boolean", "default": false},
"working_dir": {"type": "string", "maxLength": 256}
}
}
}
}
```
---
## Manifest Compiler Algorithm
### Step 1: Resolve Base
```python
def resolve_base(manifest: dict) -> dict:
"""Merge base definition into the manifest."""
if manifest.get("base_definition_id"):
base = load_base_definition(manifest["base_definition_id"],
manifest.get("base_version", "latest"))
# Deep merge: base first, then tool-specific overrides
merged = deep_merge(base, manifest)
# Remove base fields from the merged result
merged.pop("base_definition_id", None)
merged.pop("base_version", None)
return merged
return manifest
```
Merge rules:
- `packages`: Union arrays (base apt + tool apt = combined apt)
- `env`: Tool overrides base (dict merge, tool wins on key conflict)
- `scripts.build`: Concatenate arrays (base scripts first, then tool)
- `scripts.startup`: Concatenate arrays
- `user`: Tool overrides base entirely
- `mounts`: Concatenate arrays
- `runtime`: Tool overrides base (dict merge)
### Step 2: Generate Dockerfile
```python
def compile_dockerfile(manifest: dict) -> str:
"""Compile a resolved manifest to a Dockerfile string."""
lines = []
# FROM
lines.append(f"FROM {manifest['base_image']}")
lines.append("")
# ENV (build-time)
for key, value in manifest.get("env", {}).items():
lines.append(f"ENV {key}={shlex.quote(value)}")
if manifest.get("env"):
lines.append("")
# System packages (apt)
apt_packages = manifest.get("packages", {}).get("apt", [])
if apt_packages:
lines.append("RUN apt-get update && apt-get install -y \\\\")
for pkg in apt_packages:
lines.append(f" {pkg} \\\\")
lines.append(" && rm -rf /var/lib/apt/lists/*")
lines.append("")
# Node.js
node = manifest.get("packages", {}).get("node")
if node:
lines.append(
f"RUN curl -fsSL https://deb.nodesource.com/setup_{node['version']}.x | bash - && \\\\")
lines.append(" apt-get install -y nodejs && \\\\")
lines.append(" rm -rf /var/lib/apt/lists/*")
lines.append("")
# NPM global
npm_packages = manifest.get("packages", {}).get("npm_global", [])
if npm_packages:
pkg_list = " ".join(shlex.quote(p) for p in npm_packages)
lines.append(f"RUN npm install -g {pkg_list}")
lines.append("")
# Pip
pip_packages = manifest.get("packages", {}).get("pip", [])
if pip_packages:
pkg_list = " ".join(shlex.quote(p) for p in pip_packages)
lines.append(f"RUN pip install {pkg_list}")
lines.append("")
# User creation
user = manifest.get("user")
if user:
lines.append(
f"RUN groupadd -g {user['gid']} {user['name']} && \\\\")
lines.append(
f" useradd -u {user['uid']} -g {user['gid']} "
f"{'-m ' if user.get('create_home', True) else ''}"
f"-s {user['shell']} {user['name']}")
lines.append("")
# Build scripts
build_scripts = manifest.get("scripts", {}).get("build", [])
for script in build_scripts:
# Each script block becomes one RUN command
# Normalize multi-line scripts
normalized = " && ".join(line.strip() for line in script.strip().split("\n") if line.strip())
lines.append(f"RUN {normalized}")
if build_scripts:
lines.append("")
# Create mount target directories and pre-set ownership
mounts = manifest.get("mounts", [])
if mounts:
dirs = []
for mount in mounts:
dirs.append(mount["target"])
if dirs:
dir_str = " ".join(dirs)
lines.append(f"RUN mkdir -p {dir_str}")
if user:
lines.append(f"RUN chown -R {user['name']}:{user['name']} {dir_str}")
lines.append("")
# Entrypoint for startup scripts
startup_scripts = manifest.get("scripts", {}).get("startup", [])
if startup_scripts:
lines.append("COPY .headquarter/entrypoint.sh /usr/local/bin/headquarter-entrypoint")
lines.append("RUN chmod +x /usr/local/bin/headquarter-entrypoint")
lines.append("")
# Switch to user
if user:
lines.append(f"USER {user['name']}")
lines.append(f"WORKDIR /home/{user['name']}")
lines.append("")
# Entrypoint and CMD
runtime = manifest.get("runtime", {})
if startup_scripts:
lines.append('ENTRYPOINT ["/usr/local/bin/headquarter-entrypoint"]')
command = runtime.get("command", ["/bin/bash"])
cmd_json = json.dumps(command)
lines.append(f"CMD {cmd_json}")
return "\n".join(lines)
```
### Step 3: Generate Entrypoint Script
```python
def compile_entrypoint(manifest: dict) -> str:
"""Generate the startup entrypoint script."""
lines = ["#!/bin/bash", "set -e", ""]
startup_scripts = manifest.get("scripts", {}).get("startup", [])
for script in startup_scripts:
lines.append(script)
lines.append("")
lines.append('exec "$@"')
return "\n".join(lines)
```
### Step 4: Generate Compose
```python
def compile_compose(manifest: dict, variables: dict) -> str:
"""Compile a resolved manifest to a Docker Compose string."""
runtime = manifest.get("runtime", {})
user = manifest.get("user")
interface_type = manifest["interface_type"]
service = {
"image": variables["IMAGE_TAG"],
"container_name": variables["INSTANCE_NAME"],
"restart": "unless-stopped",
}
# Terminal-specific fields
if runtime.get("stdin_open", False):
service["stdin_open"] = True
if runtime.get("tty", False):
service["tty"] = True
if runtime.get("working_dir"):
service["working_dir"] = runtime["working_dir"]
# User override in compose (helps with permission consistency)
if user:
service["user"] = f"{user['uid']}:{user['gid']}"
# Ports for web tools
if interface_type == "web" and manifest.get("default_port"):
service["ports"] = [f"{variables['TOOL_PORT']}:{manifest['default_port']}"]
# Environment
env = manifest.get("env", {})
if env:
service["environment"] = env
# Volumes from mounts
volumes = []
for mount in manifest.get("mounts", []):
source = resolve_mount_source(mount, variables)
target = mount["target"]
readonly = ":ro" if mount.get("readonly", False) else ""
volumes.append(f"{source}:{target}{readonly}")
# Append extra volumes from tool config / config profile
for vol in variables.get("EXTRA_VOLUMES", []):
vol_str = f"{vol['source']}:{vol['target']}"
if vol.get("readonly"):
vol_str += ":ro"
volumes.append(vol_str)
if volumes:
service["volumes"] = volumes
compose = {
"services": {"app": service}
}
return yaml.dump(compose, default_flow_style=False)
```
### Step 5: Image Tag Hash
```python
import hashlib
import json
def compute_image_tag(tool_name: str, manifest: dict) -> str:
"""Deterministic image tag from manifest content."""
# Normalize: sort keys, stable JSON
canonical = json.dumps(manifest, sort_keys=True, separators=(',', ':'))
hash_suffix = hashlib.sha256(canonical.encode()).hexdigest()[:8]
safe_name = tool_name.lower().replace(" ", "-").replace("_", "-")
return f"headquarter/{safe_name}-{hash_suffix}:latest"
```
---
## Mount Resolution
Each `source_type` resolves differently at instance creation time:
| source_type | Resolution | Example |
|-------------|------------|---------|
| `repo` | `{repo_path}` (mount or clone) | `/data/repos/headquarter` |
| `ssh_key` | `{instance_dir}/.ssh` | `/data/instances/.../.ssh` |
| `instance` | `{instance_dir}/{name}` | `/data/instances/.../mounts/tmp_.pi_agents` |
| `git_mount` | Resolved from ConfigProfile git_mounts | `/data/instances/.../git-mounts/...` |
| `host_path` | Literal host path | `/var/run/docker.sock` |
```python
def resolve_mount_source(mount: dict, variables: dict) -> str:
source_type = mount["source_type"]
if source_type == "repo":
return variables["REPO_PATH"]
elif source_type == "ssh_key":
return variables["SSH_PATH"]
elif source_type == "instance":
instance_dir = variables["INSTANCE_DIR"]
mount_name = mount["name"]
return f"{instance_dir}/mounts/{mount_name}"
elif source_type == "git_mount":
ref = mount.get("git_mount_ref", "default")
return variables.get(f"GIT_MOUNT_{ref}", "")
elif source_type == "host_path":
return mount.get("source", "")
else:
raise ValueError(f"Unknown source_type: {source_type}")
```
---
## Permission Fixer (Post-Start)
```python
def apply_mount_permissions(
container_id: str,
mounts: list[dict],
timeout: int = 10
) -> list[dict]:
"""Apply permission policies to mounted directories in a running container.
Returns a list of results: [{mount_name, success, error}]
"""
results = []
for mount in mounts:
name = mount["name"]
target = mount["target"]
owner = mount.get("owner")
mode = mount.get("mode")
file_mode = mount.get("file_mode")
result = {"mount_name": name, "success": True, "error": None}
try:
if owner:
proc = subprocess.run(
["docker", "exec", container_id, "chown", "-R",
f"{owner}:{owner}", target],
capture_output=True, text=True, timeout=timeout
)
if proc.returncode != 0:
result["success"] = False
result["error"] = f"chown failed: {proc.stderr}"
if mode and result["success"]:
proc = subprocess.run(
["docker", "exec", container_id, "chmod", mode, target],
capture_output=True, text=True, timeout=timeout
)
if proc.returncode != 0:
result["success"] = False
result["error"] = f"chmod failed: {proc.stderr}"
if file_mode and result["success"]:
proc = subprocess.run(
["docker", "exec", container_id, "sh", "-c",
f"find {target} -type f -exec chmod {file_mode} {{}} +"],
capture_output=True, text=True, timeout=timeout
)
if proc.returncode != 0:
result["success"] = False
result["error"] = f"file_mode chmod failed: {proc.stderr}"
except subprocess.TimeoutExpired:
result["success"] = False
result["error"] = "Permission fix timed out"
results.append(result)
return results
```
**Important:** The permission fixer checks if `root` exists in the container before running. If the container has no `root` user (e.g., distroless images), it logs a warning and skips.
---
## Modified Startup Flow
The `start_instance` function in `tool_instances.py` is modified as follows:
```python
async def start_instance(...):
# ... existing validation ...
tool_type = await session.get(ToolType, instance.tool_type_id)
if tool_type.definition_type == "manifest" and tool_type.manifest_id:
# NEW: Manifest-based startup flow
manifest = await load_manifest(session, tool_type.manifest_id)
# Merge base definition
resolved = resolve_base(manifest)
# Merge tool configs and config profile
resolved = apply_tool_configs(resolved, configs)
resolved = apply_config_profile(resolved, profile)
# Compile
dockerfile = compile_dockerfile(resolved)
entrypoint = compile_entrypoint(resolved)
image_tag = compute_image_tag(tool_type.name, resolved)
# Build image
build_context = {
"Dockerfile": dockerfile,
".headquarter/entrypoint.sh": entrypoint,
}
returncode, stdout, stderr = build_image(
instance_dir=instance_dir,
dockerfile=dockerfile, # The Dockerfile references entrypoint.sh
tag=image_tag,
build_context=build_context,
)
# Generate compose with resolved variables
variables = {
"IMAGE_TAG": image_tag,
"INSTANCE_NAME": instance.name.lower(),
"REPO_PATH": repo_path,
"SSH_PATH": ssh_dir,
"INSTANCE_DIR": instance_dir,
# ... git mount resolutions from config profile ...
}
compose_content = compile_compose(resolved, variables)
write_compose_file(instance_dir, compose_content)
# Store image tag for reuse
instance.image_tag = image_tag
else:
# LEGACY: Existing dockerfile_template / compose_template flow
...
# ... docker compose up ...
# ... wait for running ...
# NEW: Apply mount permissions post-start
if tool_type.definition_type == "manifest":
manifest = await load_manifest(session, tool_type.manifest_id)
resolved = resolve_base(manifest)
permission_results = apply_mount_permissions(
instance.container_id,
resolved.get("mounts", [])
)
for result in permission_results:
if not result["success"]:
logger.warning(
"Permission fix failed for mount %s: %s",
result["mount_name"], result["error"]
)
# ... readiness probe ...
# ... tunnel creation ...
```
---
## File Layout
```
apps/api/src/
├── models/
│ ├── tool_definition_manifest.py # NEW: SQLAlchemy model
│ └── tool_type.py # MOD: add manifest_id, definition_type
├── services/
│ ├── manifest_compiler.py # NEW: compile_dockerfile, compile_compose, resolve_base
│ ├── permission_fixer.py # NEW: apply_mount_permissions
│ └── docker_build.py # MOD: support build_context files
├── api/
│ ├── tool_definitions.py # NEW: CRUD + compile endpoints
│ └── tool_instances.py # MOD: manifest-based startup flow
├── schemas/
│ └── manifest_schema.py # NEW: JSON Schema + Pydantic validators
└── alembic/versions/
└── 20260528_add_tool_definition_manifests.py # NEW: migration
```
---
## Testing Strategy
### Unit Tests
| Module | Tests | Coverage |
|--------|-------|----------|
| `manifest_compiler.py` | Dockerfile generation for all package managers, base merging, entrypoint generation | All branches |
| `permission_fixer.py` | chown/chmod success, failure, timeout, missing root user | All branches |
| `manifest_schema.py` | Valid manifest acceptance, invalid manifest rejection (all error paths) | All validation rules |
### Integration Tests
| Scenario | Test |
|----------|------|
| Manifest → Dockerfile → Build | Create manifest, compile, build image, verify it runs |
| ConfigProfile merge | Start instance with profile, verify mounts merged correctly |
| Permission fix | Start non-root container, verify workspace is writable |
| SSH key mount | Start clone-mode instance, verify SSH keys accessible and have correct permissions |
| Legacy compatibility | Start instance from old dockerfile_template tool type, verify it still works |
| Base versioning | Create tool with base v1, update base to v2, verify tool still uses v1 |
### E2E Tests
| Scenario | Test |
|----------|------|
| Tool Workshop CRUD | Create, edit, preview, delete a tool definition via UI |
| Instance lifecycle | Create instance from manifest tool, start, terminal connect, stop, delete |
---
## Migration Plan
### Step 1: Schema Migration
```python
# alembic migration
from alembic import op
import sqlalchemy as sa
def upgrade():
op.create_table(
"tool_definition_manifests",
sa.Column("id", sa.UUID(), nullable=False),
sa.Column("name", sa.String(64), nullable=False),
sa.Column("display_name", sa.String(128), nullable=False),
sa.Column("description", sa.Text(), nullable=True),
sa.Column("category", sa.String(64), nullable=True),
sa.Column("interface_type", sa.String(16), nullable=False),
sa.Column("base_image", sa.String(256), nullable=True),
sa.Column("base_definition_id", sa.UUID(), nullable=True),
sa.Column("base_version", sa.String(32), nullable=False, server_default="latest"),
sa.Column("manifest", sa.JSON(), nullable=False),
sa.Column("dockerfile_cache", sa.Text(), nullable=True),
sa.Column("compose_cache", sa.Text(), nullable=True),
sa.Column("version", sa.String(32), nullable=False, server_default="v1"),
sa.Column("is_base", sa.Boolean(), nullable=False, server_default="false"),
sa.Column("created_by_id", sa.UUID(), nullable=True),
sa.Column("created_at", sa.TIMESTAMP(timezone=True), server_default=sa.func.now()),
sa.Column("updated_at", sa.TIMESTAMP(timezone=True), server_default=sa.func.now()),
sa.PrimaryKeyConstraint("id"),
sa.UniqueConstraint("name"),
sa.ForeignKeyConstraint(["base_definition_id"], ["tool_definition_manifests.id"]),
sa.ForeignKeyConstraint(["created_by_id"], ["users.id"]),
sa.CheckConstraint(
"(base_image IS NOT NULL) OR (base_definition_id IS NOT NULL)",
name="ck_tool_definition_manifests_base_required"
),
)
op.add_column("tool_types", sa.Column("manifest_id", sa.UUID(), nullable=True))
op.add_column("tool_types", sa.Column("definition_type", sa.String(16), nullable=False, server_default="legacy"))
op.create_foreign_key(
"fk_tool_types_manifest_id",
"tool_types", "tool_definition_manifests",
["manifest_id"], ["id"]
)
op.add_column("tool_instances", sa.Column("manifest_compiled_at", sa.TIMESTAMP(timezone=True), nullable=True))
op.add_column("tool_instances", sa.Column("image_tag", sa.String(256), nullable=True))
```
### Step 2: Data Migration
Convert the existing pi-agent tool type from `dockerfile_template` to manifest:
```python
def upgrade_data():
conn = op.get_bind()
# Create the base definition for ubuntu-24.04-dev
base_id = uuid.uuid4()
conn.execute(sa.text("""
INSERT INTO tool_definition_manifests
(id, name, display_name, description, interface_type, base_image, manifest, is_base, version)
VALUES (:id, 'ubuntu-24.04-dev', 'Ubuntu 24.04 Dev Base', 'Base development environment',
'terminal', 'ubuntu:24.04', :manifest, true, 'v1')
"""), {
"id": base_id,
"manifest": json.dumps({
"name": "ubuntu-24.04-dev",
"base_image": "ubuntu:24.04",
"packages": {"apt": ["curl", "wget", "git", "build-essential", "ca-certificates"]},
"user": {"name": "user", "uid": 1000, "gid": 1000},
})
})
# Create pi-agent manifest referencing the base
pi_agent_id = uuid.UUID("d07b8376-2151-4119-8c1d-27f792aae9a3")
conn.execute(sa.text("""
INSERT INTO tool_definition_manifests
(id, name, display_name, description, category, interface_type,
base_definition_id, base_version, manifest, version)
VALUES (:id, 'pi-agent', 'Pi Agent', 'Terminal-based coding harness',
'development', 'terminal', :base_id, 'v1', :manifest, 'v1')
"""), {
"id": pi_agent_id,
"base_id": base_id,
"manifest": json.dumps({
"name": "pi-agent",
"display_name": "Pi Agent",
"interface_type": "terminal",
"packages": {
"apt": ["neovim", "ranger", "tmux", "htop", "tree", "jq", "python3", "python3-pip"],
"node": {"version": "20"},
"npm_global": ["@earendil-works/pi-coding-agent"]
},
"user": {"name": "user", "uid": 1001, "gid": 1001, "create_home": True, "shell": "/bin/bash"},
"env": {"DEBIAN_FRONTEND": "noninteractive"},
"scripts": {
"build": [
"git config --global init.defaultBranch main && git config --global user.email 'dev@headquarter.local' && git config --global user.name 'Developer'",
"mkdir -p /home/user/.config/ranger && echo 'set preview_files true' > /home/user/.config/ranger/rc.conf"
],
"startup": [
"if [ -d /workspace ]; then sudo chown -R user:user /workspace 2>/dev/null || true; fi"
]
},
"mounts": [
{"name": "workspace", "target": "/workspace", "source_type": "repo", "writable": True, "owner": "user"},
{"name": "ssh_keys", "target": "/home/user/.ssh", "source_type": "ssh_key", "mode": "0700", "file_mode": "0600", "readonly": True},
{"name": "pi_state", "target": "/tmp/.pi/agents", "source_type": "instance", "writable": True},
{"name": "pi_config", "target": "/home/user/.pi", "source_type": "git_mount", "git_mount_ref": "dotfiles", "writable": True, "owner": "user"}
],
"runtime": {
"command": ["/bin/bash"],
"stdin_open": True,
"tty": True,
"working_dir": "/workspace"
}
})
})
# Update the existing tool_types row
conn.execute(sa.text("""
UPDATE tool_types
SET manifest_id = :manifest_id,
definition_type = 'manifest',
dockerfile_template = NULL,
compose_template = NULL
WHERE name = 'pi-agent'
"""), {"manifest_id": pi_agent_id})
```
---
## Review Workload Forecast
| PR | Scope | Est. Lines | Review Risk |
|----|-------|-----------|-------------|
| PR 1: Backend | Compiler, fixer, API, tests, migration | ~1800 | Medium — core algorithm changes |
| PR 2: Frontend | Tool Workshop UI | ~1200 | Medium — new feature, self-contained |
| PR 3: Migration | Data migration, legacy fallback | ~300 | Low — additive only |
All PRs are under the 400-line budget individually. Chained PRs recommended for sequential review.