mirror of
https://github.com/penpot/penpot.git
synced 2026-07-30 01:46:14 +00:00
* 🐳 Split devenv compose for parallel workspaces Move shared services into an infra compose file and keep the main devenv container plus Valkey in a separate compose file driven by defaults.env. Parameterize host-side ports, container names, source path, and runtime env while keeping container-internal ports fixed for same-origin proxying. Make tmux startup idempotent, add attach-devenv for the live instance, move shared MinIO user setup to infra startup, and let exporter scripts load backend _env.local overrides. Co-authored-by: Codex <codex@openai.com> * 🐳 Run parallel devenv instances against shared infra Add support for running N parallel devenv instances under separate compose projects sharing Postgres, MinIO, mailer, and LDAP. Each instance has its own main container, Valkey, source checkout, tmux session, and host port range offset by 10000 (3449 -> 13449 -> 23449, etc.). ./manage.sh run-devenv-agentic --n-instances N reconciles the running set to exactly {ws0..ws(N-1)}: missing instances are created (workspace sync from the live repo via git ls-files + per-instance env-file generation under docker/devenv/instances/ + detached tmux startup), surplus instances are stopped highest-first via compose down (never -v), already-running instances are left untouched. ws0 binds the live repo at PWD; ws1+ are scratch clones under ~/.penpot/penpot_workspaces/. Backend workers (enable-backend-worker) are gated on PENPOT_BACKEND_WORKER in backend/scripts/_env; ws1+ overlays disable them so async-task notifications stay bound to a single Valkey Pub/Sub instance. Compose helpers wrap docker compose with env -i so per-instance overlay --env-file actually overrides defaults.env -- without the strip, the shell env from sourcing defaults.env at startup would shadow the overlay (Compose gives shell precedence over --env-file). Other: - Drop network aliases (- main, - redis); use container_name for cross-container DNS so multiple instances on the shared network don't fight over the same DNS name. - Pin volume names via name: (PENPOT_*_VOLUME) so volumes survive project renames; ws0 keeps the pre-existing physical names (penpotdev_*). - Remove cross-project depends_on from main.yml (postgres/minio-setup now live in penpotdev-infra); manage.sh ensure-infra-up docker-waits on the minio-setup one-shot. - Strict arg parsing in run-devenv / run-devenv-agentic; --n-instances 0 rejected. - Remove unused Host-matched server block from the Caddyfile. Memory mem:devenv/core and developer docs updated. Co-authored-by: Codex <codex@openai.com> * ✨ Document and stabilise the parallel-workspace CLI; wire AI agents Improve parallel-workspaces developer CLI, and add an opt-in layer that lets four AI coding agents (Claude Code, opencode, VS Code Copilot, OpenAI Codex CLI) drive a specific workspace through a single launcher command. Parallel-workspace semantics ---------------------------- each run-devenv-agentic call brings up one wsN; --ws N (integer; default 0) targets a specific workspace and auto-starts ws0 first when N>=1 so the worker invariant holds. --sync is forbidden on ws0 and re-seeds the workspace from the live repo for ws1+. Stop semantics mirror the start invariant -- ws0 is the last to stop, shared infra stops with it, --all walks every instance highest-first. The worker policy section explains why workers run only on ws0 (Postgres FOR UPDATE SKIP LOCKED is safe across many workers but the cron dedup primitive is best-effort, and :telemetry / :audit-log-archive are not idempotent). Per-instance Valkey Pub/Sub isolation, msgbus topology, and the "async task notifications miss ws1+ tabs" caveat are stated explicitly. The mem:prod-infra/core memory captures the same external-services and task-queue / Pub-Sub topology in agent-readable form, and mem:backend/core and mem:critical-info now cross-link it so backend work surfaces the horizontal-scaling constraints from the start. AI coding agent integration --------------------------- New top-level .devenv/ directory holds committed templates (templates/{claude-code,opencode,vscode}.json and templates/codex.toml, each with \${PENPOT_MCP_PORT} and \${SERENA_MCP_PORT} placeholders) plus committed shared entries (matching shared/* files for Playwright, the only workspace-independent server we ship today). ./manage.sh start-coding-agent <claude|opencode|vscode|codex> [--ws N] launches the chosen client against one workspace. It cd's into the target's directory (the live repo for ws0; workspace-path "wsN" for ws1+) and refuses to launch unless (a) the binary is on PATH, (b) the workspace directory exists for ws1+, and (c) the instance is up (devenv-main-running) -- the MCP servers only exist while the devenv is running. The agentic-devenv guide is restructured around this Quick start path, with a per-client table and a Manual configuration fallback for clients we don't cover. Co-Authored-By: Codex <codex@openai.com> Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * ♻️ Scope the shadow devtools to the dev build --------- Co-authored-by: Codex <codex@openai.com> Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
205 lines
8.0 KiB
Python
Executable File
205 lines
8.0 KiB
Python
Executable File
#!/usr/bin/env python3
|
|
"""Combine a shared MCP-server config with a port-substituted template for one
|
|
AI coding-agent client.
|
|
|
|
Invoked per workspace by manage.sh's `write-instance-mcp-configs` (JSON
|
|
clients) and by `start-coding-agent` (Codex). Each supported client ships a
|
|
`.devenv/shared/<tool>.{json,toml}` (workspace-independent entries, e.g.
|
|
Playwright) and a `.devenv/templates/<tool>.{json,toml}` (per-workspace entries
|
|
with `${PENPOT_MCP_PORT}` / `${SERENA_MCP_PORT}` placeholders). This script
|
|
combines the two for the target client.
|
|
|
|
Two output modes are supported:
|
|
|
|
json Deep-merge two JSON documents under a configurable top-level key
|
|
(`mcpServers` for Claude Code, `mcp` for opencode, `servers` for
|
|
VS Code Copilot) and write the result to <out>. Same-name
|
|
entries in the template override entries in shared. With
|
|
--merge-into-existing, any pre-existing <out> file is loaded as
|
|
the lowest-precedence layer first, so entries the developer
|
|
already had are preserved (ours win on name collision). This is
|
|
used for VS Code's auto-discovered `.vscode/mcp.json`, which on
|
|
ws0 IS the live repo's file and may hold the developer's own
|
|
servers; the Claude/opencode outputs live in a dedicated,
|
|
gitignored `.devenv/mcp/` path and are written without the flag
|
|
(a clean overwrite).
|
|
|
|
codex-args Deep-merge the two TOML chunks and print one
|
|
`dotted.key=<toml-value>` assignment per line to stdout (no
|
|
<out> file). The caller wraps each line in a `codex -c` flag.
|
|
Codex has no way to load an MCP config from an arbitrary file
|
|
path (CODEX_HOME would relocate auth/history too), so rather than
|
|
writing the auto-discovered `.codex/config.toml` we inject our
|
|
servers as ephemeral per-invocation overrides. This never
|
|
touches the developer's project- or user-level Codex config.
|
|
|
|
In both modes, `${VAR}` placeholders inside *either* chunk are resolved from
|
|
the current environment (only template chunks carry placeholders in practice,
|
|
but the substitution is uniform either way) using Python's
|
|
`os.path.expandvars`. Undefined placeholders are left as `${VAR}` literal text
|
|
-- callers (i.e. manage.sh) are responsible for exporting the variables before
|
|
invoking the script.
|
|
|
|
Usage:
|
|
merge-mcp-config.py --format json --key <key> [--merge-into-existing] \
|
|
<shared> <template> <out>
|
|
merge-mcp-config.py --format codex-args <shared> <template>
|
|
|
|
Exit codes:
|
|
0 success
|
|
2 argparse error (missing required option, bad value, unreadable input)
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import argparse
|
|
import json
|
|
import os
|
|
import re
|
|
import sys
|
|
import tomllib
|
|
from pathlib import Path
|
|
|
|
|
|
def merge_json(
|
|
shared_path: Path,
|
|
tpl_path: Path,
|
|
out_path: Path,
|
|
key: str,
|
|
merge_into_existing: bool,
|
|
) -> None:
|
|
"""Deep-merge JSON documents under a single top-level dict key into out.
|
|
|
|
Precedence (lowest to highest): an existing <out> file (only when
|
|
merge_into_existing is set), then shared, then the template. Entries under
|
|
`key` are merged by name, so the template wins on a name collision while
|
|
every other entry the lower layers contributed is kept. Top-level keys
|
|
other than `key` come from the existing file and shared (shared wins).
|
|
"""
|
|
shared = json.loads(shared_path.read_text())
|
|
tpl = json.loads(os.path.expandvars(tpl_path.read_text()))
|
|
|
|
base: dict = {}
|
|
if merge_into_existing and out_path.exists():
|
|
base = json.loads(out_path.read_text())
|
|
|
|
merged: dict = {**base, **shared}
|
|
merged[key] = {**base.get(key, {}), **shared.get(key, {}), **tpl.get(key, {})}
|
|
|
|
out_path.write_text(json.dumps(merged, indent=2) + "\n")
|
|
|
|
|
|
def _deep_merge(base: dict, overlay: dict) -> dict:
|
|
"""Recursively merge overlay into base; overlay wins on scalar/list keys."""
|
|
out = dict(base)
|
|
for k, v in overlay.items():
|
|
if isinstance(out.get(k), dict) and isinstance(v, dict):
|
|
out[k] = _deep_merge(out[k], v)
|
|
else:
|
|
out[k] = v
|
|
return out
|
|
|
|
|
|
def _toml_value(value: object) -> str:
|
|
"""Serialize a scalar/list as a TOML literal for a `codex -c` value.
|
|
|
|
bool is checked before int because `isinstance(True, int)` is True. Strings
|
|
are emitted as JSON strings, which are valid TOML basic strings for the
|
|
ASCII values our configs carry (commands, args, URLs). Tables never reach
|
|
here -- they are flattened into dotted keys by _flatten.
|
|
"""
|
|
if isinstance(value, bool):
|
|
return "true" if value else "false"
|
|
if isinstance(value, (int, float)):
|
|
return repr(value)
|
|
if isinstance(value, str):
|
|
return json.dumps(value)
|
|
if isinstance(value, list):
|
|
return "[" + ", ".join(_toml_value(v) for v in value) + "]"
|
|
raise TypeError(f"unsupported TOML value type: {type(value).__name__}")
|
|
|
|
|
|
_BARE_KEY = re.compile(r"^[A-Za-z0-9_-]+$")
|
|
|
|
|
|
def _key_segment(seg: str) -> str:
|
|
"""A dotted-key segment: bare if TOML-safe, else a quoted key."""
|
|
return seg if _BARE_KEY.match(seg) else json.dumps(seg)
|
|
|
|
|
|
def _flatten(obj: dict, prefix: list[str]):
|
|
"""Yield (dotted-path-segments, leaf-value) for every non-table leaf.
|
|
|
|
Lists are leaves (TOML arrays), so we do not recurse into them; nested
|
|
tables (e.g. an `env` table) are flattened into further dotted keys.
|
|
"""
|
|
for k, v in obj.items():
|
|
path = prefix + [k]
|
|
if isinstance(v, dict):
|
|
yield from _flatten(v, path)
|
|
else:
|
|
yield path, v
|
|
|
|
|
|
def emit_codex_args(shared_path: Path, tpl_path: Path) -> None:
|
|
"""Print `dotted.key=<toml-value>` lines from the merged TOML chunks."""
|
|
shared = tomllib.loads(os.path.expandvars(shared_path.read_text()))
|
|
tpl = tomllib.loads(os.path.expandvars(tpl_path.read_text()))
|
|
merged = _deep_merge(shared, tpl)
|
|
for path, value in _flatten(merged, []):
|
|
dotted = ".".join(_key_segment(s) for s in path)
|
|
sys.stdout.write(f"{dotted}={_toml_value(value)}\n")
|
|
|
|
|
|
def main(argv: list[str]) -> int:
|
|
parser = argparse.ArgumentParser(
|
|
description=__doc__.split("\n\n", 1)[0],
|
|
formatter_class=argparse.RawDescriptionHelpFormatter,
|
|
)
|
|
parser.add_argument(
|
|
"--format",
|
|
choices=("json", "codex-args"),
|
|
required=True,
|
|
help="Output mode: 'json' writes a merged file; 'codex-args' prints -c assignments.",
|
|
)
|
|
parser.add_argument(
|
|
"--key",
|
|
help="Top-level JSON key under which MCP entries live (required for --format json).",
|
|
)
|
|
parser.add_argument(
|
|
"--merge-into-existing",
|
|
action="store_true",
|
|
help="json only: layer the merge on top of an existing <out> file, "
|
|
"preserving entries already there (ours still win on name collision).",
|
|
)
|
|
parser.add_argument("shared", type=Path, help="Path to the shared chunk.")
|
|
parser.add_argument("template", type=Path, help="Path to the port-placeholder template chunk.")
|
|
parser.add_argument(
|
|
"out",
|
|
type=Path,
|
|
nargs="?",
|
|
help="Path the merged result is written to (json only; codex-args writes stdout).",
|
|
)
|
|
args = parser.parse_args(argv)
|
|
|
|
if args.format == "json":
|
|
if not args.key:
|
|
parser.error("--key is required when --format json")
|
|
if args.out is None:
|
|
parser.error("out is required when --format json")
|
|
merge_json(args.shared, args.template, args.out, args.key, args.merge_into_existing)
|
|
else: # codex-args
|
|
if args.key:
|
|
parser.error("--key is not accepted when --format codex-args")
|
|
if args.merge_into_existing:
|
|
parser.error("--merge-into-existing is not accepted when --format codex-args")
|
|
if args.out is not None:
|
|
parser.error("out path is not accepted when --format codex-args (result goes to stdout)")
|
|
emit_codex_args(args.shared, args.template)
|
|
|
|
return 0
|
|
|
|
|
|
if __name__ == "__main__":
|
|
sys.exit(main(sys.argv[1:]))
|