services.spread distributes replicas across every online, schedulable
agent (replica i → agents[i mod n], deterministic). auto placement now
orders by real headroom — memory utilisation, cpu usage, then container
count — instead of count alone.
Lifecycle (start/stop/restart/delete) and GET /runtime are now driven
by container_instances inventory, so remote replicas are visible and
tear down wherever they run — covers spread, pins, and stale pin
leftovers. Spread and explicit pins are mutually exclusive.
Generated with [Devin](https://devin.ai)
Co-authored-by: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com>
* feat(scheduling): multi-node service placement + remote container lifecycle
services.node_id pins a service to a node agent: explicit id, "auto"
(least-loaded online node), or "local"/empty for the local Docker host.
- DeploymentEngine gains a NodeRunner seam; ReconcileService routes
pinned services to the agent's command queue (create_container with
image/env/command/ports/volumes/labels/resource limits), tracked in
container_instances with deterministic per-replica ids.
- Stop/start/restart/delete fan out to the pinned node — no more leaked
remote containers on service delete.
- Agent create_container is idempotent (rm -f first) and honors restart
policy, labels, memory/cpus, published+ephemeral ports, volumes, env.
- GET /nodes/options exposes id/name/status to authed users; full agent
management stays admin-only.
- CLI services create/update --node, MCP node_id docs, OpenAPI
node_id/node_name + /nodes/options, service-detail Placement section.
Ceilings: remote nodes run registry-pulled images only — git builds,
Traefik domains, and remote sleep detection stay local until
build-on-node and per-node ingress land.
Generated with [Devin](https://devin.ai)
Co-Authored-By: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com>
* feat(scheduling): node cordon/drain + schedulable gate
- node_agents.schedulable column; "auto" placement and explicit pins skip
cordoned nodes; already-pinned services keep running (reconcile is
unaffected — cordon is not eviction).
- POST /agents/:id/cordon|uncordon|drain (admin). Drain cordons, removes
every service container via the agent queue, and clears the services'
pins so they redeploy on the local host. Requires the node online —
an offline node can't acknowledge removals.
- CLI nodes cordon|uncordon|drain (drain confirm-gated), MCP
containr_nodes_cordon|uncordon|drain, OpenAPI paths + schedulable
field, node-detail page cordon badge + drain/cordon controls.
- Fix: re-registering an agent no longer resets auto_prune.
Generated with [Devin](https://devin.ai)
Co-Authored-By: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com>
* feat(scheduling): remote registry pulls + post-run health check
- Dispatch payload carries the project owner's registry credentials for
the image host; the agent docker-logins for the pull and logs out
after, so private images deploy to remote nodes.
- Completed command payloads get their registry password scrubbed —
secrets don't linger in agent_commands rows.
- Agent verifies the container is still running ~1.5s after docker run;
crash-loops now fail the deployment with the container's last log
lines instead of reporting "running" optimistically.
Generated with [Devin](https://devin.ai)
Co-Authored-By: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com>
---------
Co-authored-by: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com>
GET /services/:id/terminal upgrades to a WebSocket attached to a
`docker exec` PTY in the service's running replica. JSON frames both
ways: stdin/resize out, stdout/exit/error in. Shell probe tries each
candidate (bash→sh) since `command -v` needs a shell to run; distroless
images get a clean "no shell" error instead of an OCI failure dump.
- docker.Client gains ExecAttach + ExecResize wrappers
- Frontend: xterm.js replaces the one-off console section — live PTY,
fit-addon resizes forwarded over the socket
- CLI: `containr shell <id>` bridges the local TTY (raw mode, SIGWINCH
resize forwarding); `containr exec` keeps the one-off command path
- Access mirrors service logs: project manage scope required
Generated with [Devin](https://devin.ai)
Co-authored-by: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com>
GET /services/:id/env-check (read scope) reports, without exposing values:
- unresolved: ${{service.KEY}} refs that don't match a sibling service/env
- empty: variables with blank values
- unreadable: secrets whose ciphertext fails decryption (key rotation)
install.sh now generates SECRETS_KEY explicitly (was falling back to
JWT_SECRET — a rotation would orphan ciphertext). .env.example documents it.
Surfaces: CLI `services env-check`, MCP containr_services_env_check
(90 tools), OpenAPI path, and a warning banner in the service Variables
panel listing each issue.
Verified live: injected a bogus ${{nosuchservice.PORT}} var plus an
unreadable ciphertext row; the endpoint flagged both, ok=false.
Generated with [Devin](https://devin.ai)
Co-Authored-By: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com>
Services gain sleep_enabled + sleep_idle_minutes. A 30s sweeper tracks
container NetworkIO deltas; a flat counter for the configured window
scales the service to zero (status=sleeping, workload containers removed).
While slept, a busybox wake container takes over the service's Traefik
host rule at priority 100: it serves a self-reloading "waking" page and
calls the unauthenticated idempotent GET/POST /api/v1/internal/wake/:id,
which reconciles the service back to running. Wake placeholders carry a
dedicated label (containr.wake-for) so runtime state never counts them;
the sweeper prunes them once the workload is up. The wake URL resolves
the API via its container name when containerized, else the edge
network's IPAM gateway — bare-metal installs need HOST=0.0.0.0 for the
callback.
Surfaces: PUT fields on services, POST /services/:id/sleep|wake,
CLI --sleep/--sleep-idle + services sleep|wake, 2 MCP tools (89 total),
OpenAPI paths/schema, and a Settings > Sleep card with toggle, idle
minutes, and manual sleep/wake plus a Sleeping badge.
Verified live: idle sweep scaled whoami-web to zero; the wake container
served the page on the edge network and its callback woke the service
(container recreated, status running, placeholder pruned).
Generated with [Devin](https://devin.ai)
Co-Authored-By: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com>
Service lifecycle: POST /services/:id/clone duplicates config, volumes,
domains, access gates, builder settings, and variables (secret ciphertext
copied verbatim); POST /services/:id/move re-parents to another owned
project. Both enforce ownership, reject name conflicts, and audit.
Registries: CRUD at /registries — host-matched pull credentials for
prebuilt-image deploys, encrypted at rest via secrets pkg, never returned
(has_password flag). Empty password on update keeps the stored credential;
host normalized (scheme/case/trailing slash). Credentials thread through
BuildConfig → BuildRequest → pull auth config, used only for the pull.
Surfaces: CLI services clone|move + registries group, 7 MCP tools,
OpenAPI paths/schemas, frontend Lifecycle card (clone/move/delete) and
Settings registries section, llms.txt + CLI.md.
Generated with [Devin](https://devin.ai)
Co-Authored-By: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com>
services.builder picks the build strategy per service: auto detects from
repo contents (Dockerfile → railpack → nixpacks), explicit overrides force
a builder, and static builds assets in a node stage then serves them from
an nginx image (static_build_cmd/static_dir, port 80 default).
- cpu_reserve/memory_reserve → Docker CPU shares + MemoryReservation;
cpu/memory stay hard caps (NanoCPUs/Memory). Empty string clears.
- app_settings.default_cpu/default_memory override service-create
fallbacks; capacity_policy=block fails deploys whose requested memory
exceeds node capacity (default warn logs via slog).
- Resource specs are validated — "512mb" fails instead of silently
dropping the limit.
- Fixes: deployments.go hardcoded nixpacks (auto-detect was dead code);
DockerfileBuilder passed the Dockerfile *path* as build-context content;
BuildImage never drained the daemon stream so builds could abort and
errors went unseen — stream is now consumed and errorDetail surfaced.
- CLI: --builder/--cpu-reserve/--memory-reserve/--static-cmd/--static-dir
on create+update; MCP args on services create/update; UI Build &
Resources card; OpenAPI + llms.txt + CLI.md.
Verified live: static build of a real repo → nginx image → deployed;
reserves reach HostConfig (shares=256, MemoryReservation=128Mi);
validation rejects bad specs; builder persists + clears.
Generated with [Devin](https://devin.ai)
Co-Authored-By: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com>
service_domains table holds the full hostname set; services.domain stays
the derived default and is synced on every domain write. Runtime spec
carries all domains into a multi-Host Traefik rule plus access gates.
- GET|POST /services/:id/domains, DELETE + POST /:domain_id/default
- GET /services/:id/domains/check — DNS preflight vs public_ip/base_url
(ok | wrong-target | pending), stamps last_checked_at
- maintenance_mode → Traefik redirectregex to /api/v1/maintenance
(base_url/BASE_URL setting; falls back to noop@internal offline)
- basic_auth [{username,password}] → bcrypt htpasswd on the label;
responses return usernames only
- First domain auto-defaults; domain="" via PUT clears all domains
- CLI: services domains list|add|remove|default|check, update
--maintenance/--basic-auth; MCP: 5 domain tools + update args
- Settings UI: Domains card (CRUD + DNS check badges) + Access card
- Fixes latent C1 bug: PUT without volumes 500'd — nil []byte sent as
bytea, not NULL; COALESCE needs untyped nil + ::jsonb cast
Verified live: domain CRUD + default sync, pending/wrong-target checks,
bcrypt htpasswd and both middlewares on the deployed container's labels.
Generated with [Devin](https://devin.ai)
Co-Authored-By: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com>
services.volumes JSONB [{type: volume|bind, source, target, read_only}]
mapped into every replica's container config at deploy and reconcile
time, hashed into the runtime spec so mount changes trigger
reconciliation. Mounted via existing docker.HostConfig.Mounts.
- RuntimeSpec.Volumes + specHash + mount.Mount mapping in createReplica
- Create/update service accepts volumes; PUT replaces the whole list
- Replica-count reconcile now goes through the deploy queue — the last
raw `go` reconcile left that could race a build
- GET /admin/volumes inventories node volumes with in_use flags;
DELETE /admin/volumes/:name removes unused ones (409 on in-use)
- CLI: services update --volume/--bind/--clear-volumes, volumes
list|delete; MCP: services_update + volumes_list/delete
- Settings UI: Volumes section with mount editor
- Migration normalizes the nullable baseline column to NOT NULL [].
Verified live: deploy applies mounts to the container, docker
auto-creates named volumes, in-use deletion is refused.
Generated with [Devin](https://devin.ai)
Co-Authored-By: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com>
- internal/deployqueue: serialized FIFO per service — concurrent deploys
of one service no longer race (the container-name-conflict failure
mode); different services still run in parallel.
- Deploys and rollbacks enqueue through the queue; a busy service
returns status 'queued' and promotes when the active job finishes.
- POST /deployments/:id/cancel — queued jobs drop immediately
(200 cancelled); active jobs get ctx cancelled so build/reconcile
abort (202 cancelling, settles 'cancelled'); finished deployments
return 409 NOT_CANCELLABLE.
- Redeploy and variables save now route through the same queue so they
can't overlap a build; PUT /services/:id/variables accepts
redeploy:true to apply env changes to running containers.
- no_cache on deployment create plumbs BuildRequest -> BuildOptions for
nixpacks, dockerfile and railpack builders.
- CLI: deployments cancel, deploy --no-cache, variables set/unset
--redeploy. MCP: containr_deployments_cancel; deploy/variables_set
document no_cache/redeploy. UI: Cancel button + queued/cancelled
badges on the deployments table. OpenAPI + types regenerated.
Generated with [Devin](https://devin.ai)
Co-Authored-By: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com>
* fix: e2e QA hardening — honest runtime URLs, health, and read-only anon UI
Backend:
- managed DB provisioning: attach DB containers to the platform network and
probe the container IP, fixing the 127.0.0.1 reachability failure inside
the backend container; log provisioning failures
- stop fabricating connection URLs when provisioning produced none
- template deploys now carry port + health_check into service runtime fields
- service health probed over the project network (backend joins it) instead
of an in-container wget that fails on shell-less images
- runtime/public/connection URLs rewrite loopback to the request Host so
links are usable for LAN/remote clients
Frontend:
- service Networking panel shows real runtime URLs/ports/health instead of
hardcoded containr.dev + 8080->443
- anonymous views hide all mutation controls (new project, add service,
group, context menu)
- project cards: remove decorative svcN labels; badge says DOWN only when
zero services run, IDLE when empty, DEGRADED for partial
- mobile projects header no longer clips (fluid search width)
- unknown routes render a not-found page instead of silently redirecting
Generated with [Devin](https://devin.ai)
Co-Authored-By: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com>
* fix: telemetry scope label, chunk split, remove stale vite artifacts
- /system/host now reports scope=host|docker-desktop-vm and the daemon OS;
Projects dashboard labels cards VM.* when the daemon is a Desktop VM so
memory/core numbers aren't mistaken for physical host values
- manualChunks: split @xyflow/react and recharts out of the main bundle;
removes the >1MB chunk warning
- drop tracked vite.config.js/.d.ts build artifacts that shadowed the real
vite.config.ts
Generated with [Devin](https://devin.ai)
Co-Authored-By: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com>
* fix: persist service host port; demo-data scope for telemetry
Public URLs were ephemeral: every restart/redeploy rebound a random host
port, so endpoint links rotted. Persist published_port on the service and
prefer it for replica 0 on create. If the port is taken, fall back to
ephemeral and repersist the new assignment. Post-start the binding is
verified — Docker Desktop accepts a conflicting publish silently, which
previously left services running with no endpoint at all.
demo-data.ts gains the required scope field for HostMonitoring (CI fix).
Generated with [Devin](https://devin.ai)
Co-Authored-By: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com>
---------
Co-authored-by: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com>
The inspector is now a proper service panel: address with copy, group
assignment (Ungrouped / any group / "+ New group" wrapping the node),
masked env vars with a deep link into the service's Variables tab, a
config grid deep-linking to Config, inferred connections, and the
lifecycle actions. ServiceDetailPage honours ?section= for the deep
links.
Grouping is first-class: assign via the inspector or drag nodes onto a
frame; members slot beneath existing ones and the frame grows only as
needed instead of clamping or ballooning. Fixed a drag bug where moving
a member inside its own group ejected it.
Context menus everywhere: right-click a node (existing actions), a
group frame (Rename via inline edit, Ungroup keeps children in place),
or empty pane (Add service, Add group here, Tidy layout, Fit view).
Generated with [Devin](https://devin.ai)
Co-Authored-By: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com>
Move /projects/:id and its service detail route out of PlatformShell —
the project view now owns the entire viewport like Railway's canvas:
no sidebar, no global nav, just the slim project bar over the graph.
Service detail inherits the same chrome-free context with a breadcrumb
back to the canvas, and back/delete navigation preserves ?demo=1.
Generated with [Devin](https://devin.ai)
Co-Authored-By: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com>
Resolve a service's tech (postgres, redis, go, node, nginx, kafka,
~50 slugs) from its image/name and render the Simple Icons logo in
the tinted icon tile — canvas nodes, inspector, service detail
header, and database rows — with graceful fallback to type icons.
Demo services get resolvable images, ports, and domains; a Redis
Cache service joins Core Platform so the demo canvas mirrors the
landing preview (5 services, web/api/worker → api → postgres/redis).
Generated with [Devin](https://devin.ai)
Co-Authored-By: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com>
- Central demo-mode helper (lib/demo-mode.ts): ?demo=1 query flag plus
VITE_PUBLIC_DEMO build flag that makes every route demo for the
public Vercel showcase.
- Single demo fixture source (lib/demo-data.ts): projects, services,
builds, databases, cron, HA, security, host monitoring, templates.
Fixes cross-page ID drift that broke project navigation.
- Demo support on Projects, HA, Security, Usage, Templates, Landing,
Service detail; DemoRestricted gate on People/Settings/Audit/Admin.
- Demo-aware PlatformShell: nav filtering, env:demo, demo account menu,
?demo=1 preserved through internal links.
- TemplatesPage: selection moved into ?template= URL param for deep
links; template fixtures shared via demo-data.
- requestJson returns {} for unparseable 200 bodies (SPA fallback) so
envelope unwraps degrade instead of dereferencing null.
- Recharts ResponsiveContainer initialDimension kills -1 warnings.
- vercel.json: vite framework + SPA rewrites for direct deep links.
Generated with [Devin](https://devin.ai)
Co-Authored-By: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com>
Backend:
- tolerate concurrent project-network creation (re-resolve on conflict)
- always publish an ephemeral host port when a port is configured, even
with a domain set — Traefik routes on top when the edge network exists
- public_url now prefers the configured domain, falls back to the first
published port
- service logs resolve replicas by containr.service label and merge all
replica streams — previously queried a nonexistent containr-<id> name
Canvas/detail:
- degraded status renders amber end-to-end (node card, detail badge,
status dot) instead of falling through to "Stopped"
- edges pick handle sides by relative node position — no more backward
loops when the source sits right of the target
- context menu gains Start for stopped services, Visit uses the
published URL when no domain is set, menu clamps to canvas bounds
- node cards show the image tag instead of a redundant hint
- scaling tab falls back to live runtime replica counts when no
autoscaler state exists; manual scale no longer requires it
Generated with [Devin](https://devin.ai)
Co-Authored-By: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com>
Services now reconcile into actual Docker containers instead of
bookkeeping rows:
- Per-project bridge network with service-name DNS aliases and
${{service.KEY}} env expansion (HOST/PORT builtins)
- Replicas, published ports, Traefik domain routing, health checks,
restart policies persisted on the service spec; spec-hash drift
recreates containers on redeploy
- Lifecycle API: start/stop/restart/redeploy, live runtime state,
real image rollback (replaces the fake sleep)
- Manual scale and PUT replicas reconcile live containers; service
and project delete remove their containers and the project network
- Canvas: real URLs, replica badges, env filter, right-click
deploy/restart/stop/delete menu, empty state
- Service detail: real lifecycle buttons, live replica/URL strip,
Networking editor, ${{ }} variable helper
- Fix prebuilt-image pulls racing the tag (drain the pull stream)
Generated with [Devin](https://devin.ai)
Co-Authored-By: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com>
POST /services/:id/exec runs a one-off sh -c command inside the
service's container via ExecRun (30s ceiling, 64KB output cap), with
the standard service→project ownership check. Console section on the
service detail page submits commands and renders captured output with
exit codes.
Verified live: real exec output, stderr + exit code propagation, 404 on
foreign service, 401 unauthenticated, 400 on empty command.
Authored By: TDvorak <[email protected]>
Generated with [Devin](https://devin.ai)
Co-Authored-By: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com>
Adds demoDatabases fixtures (postgres/redis/clickhouse) wired into
/databases?demo=1 and demoCronJobsByService fixtures for the service
Cron tab, so demo mode no longer renders empty sections on the new
surfaces.
Authored By: TDvorak <[email protected]>
Generated with [Devin](https://devin.ai)
Co-Authored-By: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com>
Exposes the existing /scaling/* API in the frontend: replica state,
policy create/edit form (min/max, target CPU/memory, enable toggle),
manual scale control, and disable-policy action matching the DELETE
endpoint's disable-not-remove semantics.
Verified live: policy CRUD, state auto-registration on policy create,
replica bounds rejection, scheduler dispatch (fails cleanly with "no
ready nodes" when no agents are registered). Noted in roadmap:
policies are in-memory and lost on restart — tracked as Phase 4 debt.
Authored By: TDvorak <[email protected]>
Generated with [Devin](https://devin.ai)
Co-Authored-By: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com>
- Service detail gains a Previews section: list, create (branch/PR/TTL),
promote-to-production, delete — all confirmed actions. Verified e2e
create→list→promote→delete.
- fix: preview create scanned services.type which real services leave NULL
(writes go to service_type); every create 404'd. Now COALESCE(type,
service_type, '') matching the rest of the codebase.
- fix: deployments.version is NOT NULL but three inserts omitted it —
handleCreateDeployment, rollback, and preview promote all failed on any
fresh-install schema. All three now write a version.
- Roadmap notes preview envs are bookkeeping-only; real branch deploy is
tracked separately.
Authored By: TDvorak <[email protected]>
The cron backend was a stub: calculateNextRun returned now+1h for any
input and executeCronJob slept 2s then wrote "success" without running
the command. Now robfig/cron parses and validates schedules (400 on
invalid), a 60s scheduler goroutine executes due enabled jobs, and
commands run via docker exec sh -c inside the service's container with
stdout+stderr captured into cron_executions. Added Client.ExecRun for
attached output capture. UI: Cron tab on the service detail page with
create/edit/enable-toggle, manual Run, and execution history with logs.
Verified e2e against real Postgres + Docker (trigger and scheduler tick
both produce real captured output).
Generated with [Devin](https://devin.ai)
Co-Authored-By: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com>
Canvas nodes were static: servicesQuery had no refetchInterval, so
build → deploy status never reached the node without a manual refresh.
It now polls at 3s while any service is transitional and 15s otherwise.
Deployment history rows link directly into that deployment's log tail
(service detail logs section, with a "show latest" reset), and rollback
now asks for confirmation before firing — it previously fired on a
single click.
Generated with [Devin](https://devin.ai)
Co-Authored-By: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com>
PUT /services/{id}/variables replaced every row, and GET masks secrets as
******** — echoing the fetched list back would have overwritten real
secrets with asterisks. The backend now keeps the stored value when a
submitted secret still carries the mask.
Service detail gains a Variables section: add/edit/remove rows, secret
toggle, bulk .env paste (comments, export prefix, quoted values), and
inline validation for empty/invalid/duplicate keys. Parser and validator
live in variable-utils.ts with unit coverage.
Generated OpenAPI types drive the new updateServiceVariables client call.
ROADMAP: Variables UX marked done; per-environment values are already
structural — each environment owns its service instances.
Generated with [Devin](https://devin.ai)
Co-Authored-By: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com>
- Service detail strip: EnhancedMetricCard with CPU sparkline, memory
gauge, RX-rate sparkline, instance count — real telemetry or honest
empty states, seeded series only in demo mode
- Usage: host monitoring becomes CPU sparkline + memory gauge + disk
segmented-bar trio; build stats upgraded to EnhancedMetricCard with a
real builds-per-day bar chart
- DonutChart: fix gauge geometry — arc baseline was below the viewBox,
so only a sliver rendered; also fix SegmentedBar dynamic Tailwind
class that never compiled
- auth sidecar: trust localhost:5175 origin for the second dev server
Generated with [Devin](https://devin.ai)
Co-Authored-By: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com>
Backend:
- GET /services/:id/metrics with real Docker container stats
- DELETE /git/providers/:id and GET repo branches endpoint
Frontend:
- Service detail shows real telemetry or honest empty states
- Live status distinguishes idle/polling/reconnecting
- Git providers section in Settings; repo+branch pickers in Add Service
- Real logo, compact Projects header, canvas fitView fix
- index.css deduplicated (1725 -> 1384 lines)
- Fixed smoke tests, lint errors, ToastProvider/context unification
Repo hygiene:
- Added LICENSE (MIT), CONTRIBUTING.md, SECURITY.md; rewrote README
- Untracked .env.prod and data/letsencrypt secrets; fixed .gitignore
- Removed legacy root src/internal/cmd and stale configs/docs/artifacts
- Moved guides and design references under docs/
Generated with [Devin](https://devin.ai)
Co-Authored-By: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com>