What it enables
- Project names are display-only. Spaces, mixed case, accents, emoji-adjacent characters are all fine. Users type “My new landing page” and that string is what shows up everywhere user-facing.
- Slug is the identity. A collision-free
slug(auto-suffixed-2,-3, … on name collision) drives every external resource: GitHub repo path, Vercel project name, production URL, and the worker’s local<runtime>/<instance>/projects/<slug>dir. - No more silent shared state. Before this change, two “test” projects upserted into a single Postgres row, shared one GitHub repo (
gdi-labs/test.git), one Vercel project, and one production URL. Now each one gets its own row, its own<gdi-labs/test, test-2, test-3>repo, and its own Vercel project / production URL. - Workflows list shows the project name.
/workflowsnow readsmetadata.project.nameper workflow and renders it as the row title; non-project workflows still fall back to the objective text.
What’s changed
Postgres (worker/worker/storage/postgres_repo.py)
Idempotent migration in ensure_schema():
alter table projects add column if not exists slug textupdate projects set slug = name where slug is null(backfill — existing names were already unique)alter table projects drop constraint if exists projects_name_key(drop oldname UNIQUE)create unique index if not exists idx_projects_slug on projects (slug)alter table projects alter column slug set not null
slug TEXT NOT NULL UNIQUE and name TEXT NOT NULL (no longer unique).
mother-ai (mother-ai/src/models.rs, mother-ai/src/routes.rs)
ProjectRecordgainsslug: String;CreateProjectRequeststays as{name, kind, repo_url, base_branch, created_by}— slug is server-side derived.validate_project_namerelaxed to 1–80 characters; rejects control chars only. Accepts spaces, mixed case, Unicode.slugify(name)mirrors the worker’s_sanitizeso the slug is safe as both a GitHub repo name and a filesystem directory (alphanumeric +._-, collapsed dashes, lowercased,unnamedfallback).insert_project_in_postgresallocates a unique slug with bounded retry: scanslug = $base OR slug LIKE '$base%', pick the first free-Nsuffix (starting at-2),INSERT, retry on theidx_projects_slugunique-violation if another worker won the race (8 attempts max).list_projects_from_postgresand the create response both serializeslug.
worker/worker/runtime/project_workspace.py, worker/worker/runtime/vercel_deployment.py)
ProjectMetadatagainsslug: str.from_jobreadsmetadata.project.slug; for workflows queued before this rollout (no slug field), it falls back to_sanitize(name)so the existing identity chain keeps working.- Three identity sites switched from
_sanitize(metadata.name)to_sanitize(metadata.slug):_derive_public_remote— GitHub repo path component_ensure_remote_repository— repo name passed toensure_github_repositoryprepare()— localproject_dir = projects_dir / slug
VercelDeploymentManager.ensure_project—name=project_metadata.slug(Vercel project names share a global team namespace, so the slug is what guarantees one Cerebrum project ↔ one Vercel project ↔ one stable production URL).
apps/frontend/lib/mother-ai-client.ts, apps/frontend/components/layout/footer/command-widget.tsx, apps/frontend/components/workflow/panels/jobs-list-panel.tsx)
ProjectJobMetadataandProjecttypes gainslug. The picker / project list reads it back from the API.- The command widget no longer slugifies the name client-side. It cap-limits (1–80 chars) and strips control characters but otherwise sends the user’s literal input as
name. AftercreateProjectreturns the canonical slug, the widget threads it into the workflow’smetadata.project.slugso the worker uses the right identity. - The workflows list panel prefers
metadata.project.namefor the row title; falls back toworkflow.objectivefor non-project workflows (intent jobs, prompt-only tasks).
Impact scope
- Additive on the wire.
slugis optional inProjectJobMetadatafor backwards compatibility — workflows queued before this rollout omit it and the worker derives one from the sanitized name on the fly (same behavior as before this change). - Migration is idempotent. Re-running
ensure_schema()after the column already exists is a no-op. Existing project rows keep theirid,name, and (now-populated)slug(== old name). - No data loss. Existing names map 1:1 to their slugs on first boot; nothing is renamed, no GitHub repo gets a different slug retroactively.
- Two display names “test” are no longer the same project. Previously the second creation upserted into the first row; now it gets its own row with
slug = "test-2"and an isolated GitHub repo / Vercel project. This is the intentional behavior change. If an operator wants two users to share a workspace, they pick the same project from the existing-project list (matched byid/slug, not by typing the same name).
Tests
Worker:pytest tests/test_tool_loop_runner.py tests/test_thread_runner.py — all pass (the two ProjectMetadata(...) construction sites in test_thread_runner.py updated to pass slug=).
Mother-ai: cargo check && cargo test — all 12 pass.
Frontend: npx --no-install tsc --noEmit clean; pnpm run lint clean.
- Create a project named “test” in the FE; confirm slug
testin the projects list. - Have another user create “test” — confirm a new row with slug
test-2. - Check Postgres:
select name, slug from projects order by created_at desc limit 5;. - Fire a project task on each — confirm the worker logs show two distinct
project_dirpaths (projects/test,projects/test-2), the Vercelvercel_deployedevent shows two distinctproject_namevalues, and the production URLs differ. - Open
/workflows— confirm the row titles show the display names (“test” for both), not the slug or the objective.