Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
33 commits
Select commit Hold shift + click to select a range
6e9cbda
fix(tools): sanitize tool output with abnegate-secret
abnegate Oct 5, 2026
8b3bb0a
refactor(chat): box the route a chat turn carries
abnegate Oct 5, 2026
a3a861d
chore(deps): bump rmcp to 3.4.0 and dirs to 7.0.0
abnegate Oct 5, 2026
7721a18
fix(comfy): time out training at the deadline when ComfyUI is slow
abnegate Oct 5, 2026
df3416b
test(chat): renew the catalog test's lease like a generation does
abnegate Oct 5, 2026
5935179
refactor(chat): box the route a chat turn carries
abnegate Oct 5, 2026
f5c09e6
test(core): serialise every environment change behind one lock
abnegate Oct 5, 2026
beb11be
refactor(search)!: use abnegate-search for chat web search
abnegate Oct 5, 2026
d42c0d4
fix(compose): pass SEARCH_TIMEOUT_SECONDS to the manager
abnegate Oct 5, 2026
40f1da1
docs(search): document SEARCH_TIMEOUT_SECONDS
abnegate Oct 5, 2026
48c2705
refactor(chat): box the route a chat turn carries
abnegate Oct 5, 2026
12a0aed
fix(mail): deliver account mail over STARTTLS through abnegate-notify
abnegate Oct 5, 2026
72af74d
docs(notify): point the docs at services::mail and abnegate-notify
abnegate Oct 5, 2026
53c723b
refactor(chat): box the route a chat turn carries
abnegate Oct 5, 2026
8a07a6d
fix(scripts): create the trainer venvs without pip when pip is skipped
abnegate Oct 5, 2026
69cbeba
fix(comfy): load every zone_lora node, not only the face node
abnegate Oct 5, 2026
76f0b7e
refactor(state): name the mail constructor without the account prefix
abnegate Oct 5, 2026
ec8eb58
perf(build): keep only line tables in dev builds
abnegate Oct 6, 2026
4d373ef
fix(chat): box the chat route so routing passes clippy
abnegate Oct 6, 2026
00cfbd5
test(server): point unreachable services at a port nobody can take
abnegate Oct 6, 2026
31dbceb
fix(login): read a pending codex sign-in before the logins it may record
abnegate Oct 6, 2026
2a75cdd
Merge branch 'main' of github.com:abnegate/zone into dazzling-spence-…
abnegate Oct 6, 2026
0e3b2f1
fix(chat): drop the second unboxing of the chat route
abnegate Oct 6, 2026
3ebfe92
Merge remote-tracking branch 'origin/migrate/sanitize' into combined/…
abnegate Oct 6, 2026
d54c141
Merge remote-tracking branch 'origin/migrate/rmcp' into combined/open…
abnegate Oct 6, 2026
9fec17c
Merge remote-tracking branch 'origin/migrate/test-timing' into combin…
abnegate Oct 6, 2026
f4c8ae0
Merge remote-tracking branch 'origin/migrate/search' into combined/op…
abnegate Oct 6, 2026
3e5b5ad
Merge remote-tracking branch 'origin/migrate/notify-email' into combi…
abnegate Oct 6, 2026
0286a86
Merge remote-tracking branch 'origin/migrate/comfyui-nodes' into comb…
abnegate Oct 6, 2026
ec4a6cd
test(e2e): follow the console's chunked train uploads and used/total …
abnegate Oct 7, 2026
ac3d3f6
test(e2e): stage the busy Qwen screenshot's images through the upload…
abnegate Oct 7, 2026
b253426
test(server): hold the owner backfill to the workspace a test seeded
abnegate Oct 7, 2026
a1fb132
fix(login): drop a finished codex sign-in's attempt only once it is s…
abnegate Oct 7, 2026
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
8 changes: 6 additions & 2 deletions .env.example
Original file line number Diff line number Diff line change
Expand Up @@ -177,6 +177,7 @@ TRAIN_UPLOAD_LIMIT_MB=4096

SEARCH_ENABLE_WEB_SEARCH=true
SEARCH_RESULT_COUNT=5
SEARCH_TIMEOUT_SECONDS=15
SEARCH_SEARXNG_QUERY_URL="http://gluetun:8080/search?q=<query>&format=json"
SEARCH_SEARXNG_SERVER_BASE_URL="http://localhost:8080"
SEARCH_SEARXNG_INSTANCE_NAME="Zone Search"
Expand Down Expand Up @@ -282,8 +283,11 @@ ZONE_MCP_AUTO_MAGENTS=true
# ZONE_NOTIFY_DISCORD_WEBHOOK=https://discord.com/api/webhooks/...
# ZONE_NOTIFY_EMAIL_TO=alerts@example.com,ops@example.com
# ZONE_NOTIFY_TIMEOUT_SECONDS=10
# SMTP_* - The relay ZONE_NOTIFY_EMAIL_TO, sign-up verification, password
# resets and invitations send through. ALERT_SMTP_* below is Grafana's own.
# SMTP_* - The relay ZONE_NOTIFY_EMAIL_TO, sign-up verification and password
# resets send through. Port 465 connects with implicit TLS; any other port,
# 587 included, upgrades with STARTTLS before signing in. A blank value other
# than SMTP_PASSWORD counts as unset, and an unreadable SMTP_PORT falls back
# to 587. ALERT_SMTP_* below is Grafana's own.
# SMTP_HOST=smtp.example.com
# SMTP_PORT=587
# SMTP_USER=
Expand Down
2 changes: 0 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -433,8 +433,6 @@ zone/
│ │ ├── src/video.rs # Training frames pulled out of a submitted clip
│ │ ├── src/subject.rs # Subject-aware framing for training crops
│ │ └── src/caption.rs # Vision captioning for training sets
│ ├── zone_email/ # Transactional email over SMTP
│ ├── zone_search/ # SearXNG web search client
│ ├── zone_vcs/ # Local git operations and GitHub pull requests
│ ├── zone_vision/ # Subject detection and subject-aware cropping
│ ├── zone_server/ # HTTP/WS server
Expand Down
9 changes: 3 additions & 6 deletions comfyui/custom_nodes/zone_lora/__init__.py
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
from comfy_api.latest import ComfyExtension
from comfy_api.latest import ComfyExtension, io

from .cleanup_node import ZoneCleanupTrainingRun
from .dataset_node import ZoneLoadTrainDataset
Expand All @@ -11,16 +11,13 @@

install_all()

NODE_CLASS_MAPPINGS = {
'ZoneIPAdapterFace': ZoneIPAdapterFace,
}


class ZoneLoraExtension(ComfyExtension):
async def get_node_list(self):
async def get_node_list(self) -> list[type[io.ComfyNode]]:
return [
ZoneLoadTrainDataset,
ZoneCleanupTrainingRun,
ZoneIPAdapterFace,
ZoneLoadTrainFolder,
ZoneProbeGradient,
ZoneProbeLoss,
Expand Down
49 changes: 24 additions & 25 deletions comfyui/custom_nodes/zone_lora/face_node.py
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@
from comfy.ldm.modules.attention import optimized_attention
import comfy.model_management as model_management
import comfy.utils
from comfy_api.latest import io

PLUS_FACE = 'ip-adapter-plus-face_sdxl_vit-h.safetensors'

Expand Down Expand Up @@ -267,32 +268,30 @@ def patch_sdxl(model, ipa, cond, uncond, weight: float):
number += 1


class ZoneIPAdapterFace:
class ZoneIPAdapterFace(io.ComfyNode):
@classmethod
def INPUT_TYPES(cls):
names = folder_paths.get_filename_list('ipadapter')
if not names:
names = [PLUS_FACE]
return {
'required': {
'model': ('MODEL',),
'clip_vision': ('CLIP_VISION',),
'image': ('IMAGE',),
'ipadapter_name': (names,),
'strength': (
'FLOAT',
{'default': 0.4, 'min': 0.0, 'max': 2.0, 'step': 0.01},
),
}
}

RETURN_TYPES = ('MODEL',)
FUNCTION = 'apply'
CATEGORY = 'zone'

def apply(self, model, clip_vision, image, ipadapter_name, strength):
def define_schema(cls) -> io.Schema:
names = folder_paths.get_filename_list('ipadapter') or [PLUS_FACE]
return io.Schema(
node_id='ZoneIPAdapterFace',
display_name='Zone IP-Adapter Face',
category='zone',
inputs=[
io.Model.Input('model'),
io.ClipVision.Input('clip_vision'),
io.Image.Input('image'),
io.Combo.Input('ipadapter_name', options=names),
io.Float.Input('strength', default=0.4, min=0.0, max=2.0, step=0.01),
],
outputs=[io.Model.Output()],
)

@classmethod
def execute(
cls, model, clip_vision, image, ipadapter_name: str, strength: float
) -> io.NodeOutput:
if strength == 0:
return (model,)
return io.NodeOutput(model)
path = folder_paths.get_full_path_or_raise('ipadapter', ipadapter_name)
loaded = load_plus_face(path)
if 'latents' not in loaded['image_proj']:
Expand All @@ -314,4 +313,4 @@ def apply(self, model, clip_vision, image, ipadapter_name, strength):
uncond = uncond.to(device, dtype=dtype)
work = model.clone()
patch_sdxl(work, ipa, cond, uncond, float(strength))
return (work,)
return io.NodeOutput(work)
95 changes: 95 additions & 0 deletions comfyui/tests/test_graph_contract.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@
import asyncio
import json
import os
import re
import subprocess
import sys
import tempfile
Expand All @@ -19,6 +20,7 @@
ZONE_NODES = COMFYUI / 'custom_nodes' / 'zone_lora'
TRAIN_SERVER = ROOT / 'runner/zone_comfy/src/train.rs'
QUALITY_SERVER = ROOT / 'runner/zone_comfy/src/quality.rs'
PEOPLE_SERVER = ROOT / 'runner/zone_comfy/src/people.rs'

if not INSTALL.is_dir():
raise RuntimeError('COMFYUI_INSTALL_DIR must point to the pinned ComfyUI checkout')
Expand Down Expand Up @@ -165,6 +167,53 @@ def rust_graphs() -> dict[str, dict]:
}


def resolve(value: object, constants: dict[str, str]) -> object:
if isinstance(value, Expression):
return constants.get(value.text, value)
if isinstance(value, list):
return [resolve(item, constants) for item in value]
if isinstance(value, dict):
return {name: resolve(item, constants) for name, item in value.items()}
return value


def people_strings() -> dict[str, str]:
return dict(re.findall(r'const (\w+): &str = "([^"]*)";', PEOPLE_SERVER.read_text()))


def people_numbers() -> dict[str, float]:
return {
name: float(value)
for name, value in re.findall(r'const (\w+): f64 = ([0-9.]+);', PEOPLE_SERVER.read_text())
}


def rust_face_graph() -> dict:
source = PEOPLE_SERVER.read_text()
constants = people_strings()
start = source.find('fn inject_ipadapter(')
if start < 0:
raise AssertionError(f'{PEOPLE_SERVER}: inject_ipadapter is missing')
end = source.find('\n}\n', start)
if end <= start:
raise AssertionError(f'{PEOPLE_SERVER}: inject_ipadapter has no closing brace')
graph = {
'4': {
'class_type': 'CheckpointLoaderSimple',
'inputs': {'ckpt_name': 'model.safetensors'},
}
}
for insert in re.finditer(r'object\.insert\(\s*(\w+)\.into\(\),\s*json!\(', source[start:end]):
key = insert.group(1)
if key not in constants:
raise AssertionError(f'{PEOPLE_SERVER}: node key {key} is not a &str constant')
node = RustLiteral(source, start + insert.end(), PEOPLE_SERVER).object()
graph[constants[key]] = resolve(node, constants)
face = graph[constants['IPADAPTER_NODE']]
face['inputs']['model'] = ['4', 0]
return graph


def flux() -> train_lora.TrainingModel:
return train_lora.TrainingModel(architecture='flux', checkpoint='flux.safetensors')

Expand Down Expand Up @@ -371,13 +420,59 @@ def test_repository_extension_is_part_of_the_loaded_registry(self) -> None:
'ZoneCleanupTrainingRun',
'ZoneIPAdapterFace',
'ZoneLoadTrainDataset',
'ZoneLoadTrainFolder',
'ZoneProbeGradient',
'ZoneProbeLoss',
'ZoneStageTrainingArtifact',
'ZoneTrainLoRA',
}
self.assertLessEqual(expected, set(self.nodes.NODE_CLASS_MAPPINGS))

def test_rust_face_graph_matches_the_pinned_registry(self) -> None:
graph = rust_face_graph()
self.assertEqual(
sorted(node['class_type'] for node in graph.values()),
['CLIPVisionLoader', 'CheckpointLoaderSimple', 'LoadImage', 'ZoneIPAdapterFace'],
)
self.validate_graph('Rust face', graph)

def test_rust_face_constants_fit_the_face_node(self) -> None:
node_class = self.nodes.NODE_CLASS_MAPPINGS['ZoneIPAdapterFace']
inputs, _, _ = schema(node_class)
bounds = inputs['strength'][1]
strength = people_numbers()['IPADAPTER_STRENGTH']
self.assertGreaterEqual(strength, bounds['min'], f'{PEOPLE_SERVER}: IPADAPTER_STRENGTH')
self.assertLessEqual(strength, bounds['max'], f'{PEOPLE_SERVER}: IPADAPTER_STRENGTH')
self.assertEqual(
people_strings()['IPADAPTER_FILE'],
sys.modules[node_class.__module__].PLUS_FACE,
f'{PEOPLE_SERVER}: IPADAPTER_FILE',
)

def test_face_node_at_zero_strength_returns_the_model_untouched(self) -> None:
from execution import _async_map_node_over_list

node_class = self.nodes.NODE_CLASS_MAPPINGS['ZoneIPAdapterFace']
model = object()
results = asyncio.run(
_async_map_node_over_list(
'contract',
'face',
node_class,
{
'model': [model],
'clip_vision': [None],
'image': [None],
'ipadapter_name': ['missing.safetensors'],
'strength': [0.0],
},
node_class.FUNCTION,
v3_data={},
)
)
self.assertEqual(len(results), 1)
self.assertIs(results[0].result[0], model)

def test_sdxl_people_graphs_match_the_pinned_registry(self) -> None:
for name in (
'sdxl-api.json',
Expand Down
14 changes: 14 additions & 0 deletions comfyui/tests/test_setup_macos.py
Original file line number Diff line number Diff line change
Expand Up @@ -202,6 +202,20 @@ def test_install_trainer_inherits_comfy_site_packages(self) -> None:
text=True,
)
self.assertNotEqual(mlx.returncode, 0, mlx.stdout + mlx.stderr)
for trainer in (".venv-train", ".venv-train-llm"):
trainer_site = subprocess.check_output(
[
str(install / trainer / "bin" / "python"),
"-c",
"import sysconfig; print(sysconfig.get_path('purelib'))",
],
text=True,
).strip()
with self.subTest(venv=trainer):
self.assertFalse(
Path(trainer_site, "pip").exists(),
"a skipped pip install still bootstrapped pip into the trainer venv",
)
plist = (home / "Library/LaunchAgents/ai.zone.train.plist").read_text(
encoding="utf-8"
)
Expand Down
1 change: 1 addition & 0 deletions docker-compose.dev.yml
Original file line number Diff line number Diff line change
Expand Up @@ -68,6 +68,7 @@ services:
- VPN_OPENVPN_USER=${VPN_OPENVPN_USER:+1}
- "SEARCH_SEARXNG_QUERY_URL=${SEARCH_SEARXNG_QUERY_URL:-http://gluetun:8080/search?q=<query>&format=json}"
- SEARCH_RESULT_COUNT=${SEARCH_RESULT_COUNT:-5}
- SEARCH_TIMEOUT_SECONDS=${SEARCH_TIMEOUT_SECONDS:-15}
- MODEL_SEARCH_PROXY_URL=${MODEL_SEARCH_PROXY_URL:-}
- GITHUB_API_URL=${GITHUB_API_URL:-}
- TOOL_RUNNER_PROXY_URL=${TOOL_RUNNER_PROXY_URL:-}
Expand Down
1 change: 1 addition & 0 deletions docker-compose.yml
Original file line number Diff line number Diff line change
Expand Up @@ -387,6 +387,7 @@ services:
- VPN_OPENVPN_USER=${VPN_OPENVPN_USER:+1}
- "SEARCH_SEARXNG_QUERY_URL=${SEARCH_SEARXNG_QUERY_URL:-http://gluetun:8080/search?q=<query>&format=json}"
- SEARCH_RESULT_COUNT=${SEARCH_RESULT_COUNT:-5}
- SEARCH_TIMEOUT_SECONDS=${SEARCH_TIMEOUT_SECONDS:-15}
# Belt-and-suspenders HTTP proxy. `make up-vpn` also attaches this
# container to Gluetun's network so non-HTTP traffic uses the tunnel.
- MODEL_SEARCH_PROXY_URL=${MODEL_SEARCH_PROXY_URL:-}
Expand Down
10 changes: 7 additions & 3 deletions docs/CONFIGURATION.md
Original file line number Diff line number Diff line change
Expand Up @@ -1623,7 +1623,11 @@ details, and native macOS / bundled NVIDIA instructions.

### `SEARCH_RESULT_COUNT`
- **Default**: `5`
- **Description**: Number of search results supplied to chat
- **Description**: Number of search results supplied to chat (held to 1–20).

### `SEARCH_TIMEOUT_SECONDS`
- **Default**: `15`
- **Description**: Timeout for one SearXNG request, in whole seconds (held to 1–60).

### `SEARCH_SEARXNG_QUERY_URL`
- **Default**: `"http://gluetun:8080/search?q=<query>&format=json"`
Expand Down Expand Up @@ -1841,8 +1845,8 @@ Every notice — an auto-project merge, pause or completion, a regression alert,
- **Description**: How long one channel is given per notice.

### `SMTP_HOST`, `SMTP_PORT`, `SMTP_USER`, `SMTP_PASSWORD`, `SMTP_FROM`, `SMTP_FROM_NAME`
- **Default**: *empty* (`SMTP_PORT` 587, `SMTP_FROM_NAME` Zone)
- **Description**: The relay notices, sign-up verification, password resets and invitations send through. `ALERT_SMTP_*` is Grafana's relay and is separate.
- **Default**: *empty* (`SMTP_PORT` 587, `SMTP_FROM_NAME` Zone; a blank value other than `SMTP_PASSWORD` counts as unset and an unreadable `SMTP_PORT` falls back to 587; sign-up and reset mail falls back to `noreply@zone.app` when `SMTP_FROM` is unset, email notices need it set)
- **Description**: The relay notices, sign-up verification and password resets send through. Port 465 connects with implicit TLS; any other port, 587 included, must upgrade with STARTTLS before signing in. `ALERT_SMTP_*` is Grafana's relay and is separate.

---

Expand Down
4 changes: 2 additions & 2 deletions docs/DEPLOY.md
Original file line number Diff line number Diff line change
Expand Up @@ -58,7 +58,7 @@ Verified on `main` (`d3fb7cf`). Reuse is the default; each row says whether it's
| MQTT broker | `~/Local/mqtt` (Swoole) | OTA substrate for `ota_fleet` |
| Appwrite | Sites, Functions, Storage, Messaging | Delivery targets + backend provisioning (v1.5) |
| Charts, cluster | `helm/zone-apps` (`_helpers.tpl` conventions, security contexts, HPA/PDB/NetworkPolicy), `k8s/kind-config.yaml` (`zone-dev`, ports 30000-30002 pre-mapped, CNPG, HAProxy default IngressClass) | New `helm/zone-app` copies conventions; kind is the first k8s target |
| Notifications | None in the server. `ALERT_DISCORD_WEBHOOK_URL` is consumed only by Grafana (`grafana/entrypoint.sh`); `zone_email::EmailService` has three templated senders; reminders deliver as assistant messages into a chat (`db/reminders.rs:61-90`) | New `notify` module |
| Notifications | None in the server. `ALERT_DISCORD_WEBHOOK_URL` is consumed only by Grafana (`grafana/entrypoint.sh`); `services::mail::AccountMail` sends two templated account messages (abnegate-notify `Mailer`); reminders deliver as assistant messages into a chat (`db/reminders.rs:61-90`) | New `notify` module |

**Gaps that block the feature (phase 0):**

Expand Down Expand Up @@ -639,7 +639,7 @@ Stated tradeoff: while blocked, a task holds one of `MAX_CONCURRENT_TASKS = 5` p
`Notifier` trait with three channels, fanned out with `join_all`, failures logged and never blocking the deployment:

- `DiscordWebhook` — an embed with app, target, artifact kind, digest, requester, values digest, and a deep link to `/deployments?id=…` (the desktop/mobile shell opens it too). Webhook URL from the target's `notify` block, falling back to a server-level `ALERT_DISCORD_WEBHOOK_URL` that `config.rs` now reads as well as Grafana. Webhooks are one-way; one-tap approve from Discord needs bot interactions and is a later phase.
- `Email` — new `zone_email::send_approval_request`.
- `Email` — a new approval-request `services::mail::Template`, sent through `AccountMail`.
- `InApp` — an assistant message into the originating chat, the reminders pattern (`db/reminders.rs:61-90`).

### 7.6 Agent tools — `runner/zone_server/src/agent/deploy.rs`
Expand Down
Loading
Loading