Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
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
20 changes: 20 additions & 0 deletions .cursor/environment.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
{
"name": "Naruon dev (backend + frontend + Postgres/pgvector)",
"user": "ubuntu",
"install": "bash .cursor/install.sh",
"start": "bash .cursor/start.sh",
"terminals": [
{
"name": "backend",
"command": "cd backend && . .venv/bin/activate && python scripts/start_backend.py --host 0.0.0.0 --port 8000"
},
{
"name": "frontend",
"command": "cd frontend && corepack pnpm@11.5.3 dev --port 3000 --hostname 0.0.0.0"
}
],
"ports": [
{ "name": "backend", "port": 8000 },
{ "name": "frontend", "port": 3000 }
]
}
42 changes: 42 additions & 0 deletions .cursor/install.sh
Original file line number Diff line number Diff line change
@@ -0,0 +1,42 @@
#!/usr/bin/env bash
# Idempotent repository bootstrap for Naruon Cloud Agent environments.
#
# Prepares durable, source-derived state after checkout:
# * system packages (PostgreSQL 16 + pgvector, Python venv/build tooling)
# * the backend virtualenv + pinned Python requirements
# * the frontend pnpm dependency tree
#
# Per-boot service startup (Postgres, schema migrations, dev secrets) lives in
# .cursor/start.sh so it re-runs on every VM boot, including builds.
set -euo pipefail

REPO_ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
cd "$REPO_ROOT"

echo "==> [install] system packages (postgresql-16, pgvector, python venv, build tools)"
export DEBIAN_FRONTEND=noninteractive
sudo apt-get update -qq
sudo apt-get install -y -qq \
postgresql-16 \
postgresql-16-pgvector \
postgresql-client-16 \
python3.12-venv \
python3.12-dev \
build-essential

echo "==> [install] backend virtualenv + requirements"
cd "$REPO_ROOT/backend"
if [ ! -x ".venv/bin/python" ]; then
python3 -m venv .venv
fi
# shellcheck disable=SC1091
. .venv/bin/activate
python -m pip install --require-hashes -r requirements-hashes.txt

echo "==> [install] frontend dependencies (pnpm@11.5.3)"
cd "$REPO_ROOT/frontend"
corepack enable
corepack prepare pnpm@11.5.3 --activate
corepack pnpm@11.5.3 install --frozen-lockfile

echo "==> [install] done"
80 changes: 80 additions & 0 deletions .cursor/start.sh
Original file line number Diff line number Diff line change
@@ -0,0 +1,80 @@
#!/usr/bin/env bash
# Per-boot runtime reconciliation for Naruon Cloud Agent environments.
#
# Runs on every VM start (idempotent): brings up the PostgreSQL cluster,
# materializes a local dev .env with generated secrets on first boot, ensures
# the app database + pgvector extension exist, and applies Alembic migrations.
#
# Dependency installation lives in .cursor/install.sh; this script only
# reconciles per-boot state and then returns so the backend/frontend terminals
# can start.
set -euo pipefail

REPO_ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
cd "$REPO_ROOT"

ENV_FILE="$HOME/.env"
PY="$REPO_ROOT/backend/.venv/bin/python"

echo "==> [start] ensuring PostgreSQL 16 cluster is online"
if ! pg_lsclusters -h 2>/dev/null | awk '{print $4}' | grep -q online; then
sudo pg_ctlcluster 16 main start || true
fi
# Wait for the server to accept connections before touching it.
for _ in $(seq 1 30); do
if sudo -u postgres pg_isready -q; then break; fi
sleep 1
done
if ! sudo -u postgres pg_isready -q; then
echo "==> [start] PostgreSQL did not become ready" >&2
exit 1
fi

echo "==> [start] generating local dev .env on first boot (secrets are per-VM)"
if [ ! -f "$ENV_FILE" ]; then
"$PY" - "$ENV_FILE" <<'PYGEN'
import os, secrets, sys
from pathlib import Path
from cryptography.fernet import Fernet

env_path = Path(sys.argv[1])
db_password = secrets.token_urlsafe(24)
hmac_secret = secrets.token_urlsafe(48)
enc_key = Fernet.generate_key().decode()

env_path.write_text(
"# Naruon local dev environment (generated per-VM; not committed).\n"
f"DATABASE_URL=postgresql+asyncpg://postgres:{db_password}@127.0.0.1:5432/ai_email\n"
f"AUTH_SESSION_HMAC_SECRET={hmac_secret}\n"
f"ENCRYPTION_KEY={enc_key}\n"
"DEBUG=false\n"
"RUNTIME_ENVIRONMENT=development\n"
"ENABLE_PROMETHEUS_METRICS=false\n"
"ALLOWED_CORS_ORIGINS=http://localhost:3000,http://127.0.0.1:3000,"
"http://localhost:8000,http://127.0.0.1:8000\n"
"SMTP_MODE=simulated\n"
"OPENAI_API_KEY=\n"
"OPENAI_EMBEDDING_MODEL=text-embedding-3-small\n"
"OPENAI_MODEL=gpt-4o\n",
encoding="utf-8",
)
os.chmod(env_path, 0o600)
print(f"wrote {env_path}")
PYGEN
fi

echo "==> [start] reconciling database role, database, and pgvector extension"
# Keep the local postgres role secret in sync with DATABASE_URL without
# interpolating it into SQL or the process argument list.
"$PY" "$REPO_ROOT/backend/scripts/reconcile_local_postgres_role.py" --env-file "$ENV_FILE"
if ! sudo -u postgres psql -tAc "SELECT 1 FROM pg_database WHERE datname='ai_email'" | grep -q 1; then
sudo -u postgres createdb ai_email
fi
sudo -u postgres psql -d ai_email -v ON_ERROR_STOP=1 \
-c "CREATE EXTENSION IF NOT EXISTS vector;" >/dev/null

echo "==> [start] applying database migrations (alembic upgrade head)"
cd "$REPO_ROOT/backend"
"$PY" scripts/migrate_db.py

echo "==> [start] done"
6 changes: 6 additions & 0 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -348,6 +348,12 @@ in this repo.
embedding generation is unavailable. Tests must cover the local
`embeddinggemma` path so Data workspace imports do not silently bypass the
selected embedding model.
- PostgreSQL `text` / `hashtext()` cannot encode a NUL (`0x00`) octet. Do not
pass a raw `user_id\\x00organization_id` (or any other NUL-separated payload)
as a `pg_advisory_lock` bind value. Derive a NUL-free digest such as
SHA-256 hex and keep mocked/SQLite tests from asserting the raw NUL form —
those dialects skip the lock and hide `CharacterNotInRepertoireError` until
a real Postgres import.
- Home/Today dashboard reply-wait surfaces must read signed
`/api/emails/pending-replies` data instead of inferring pending replies from
generic inbox fixtures or static copy. Tests and E2E mocks must verify the
Expand Down
6 changes: 6 additions & 0 deletions ARCHITECTURE.md
Original file line number Diff line number Diff line change
Expand Up @@ -145,6 +145,12 @@ remains a local/dev compatibility path that creates the `vector` extension,
metadata-defined tables for fresh local databases, and idempotent backfills for
existing local databases.

Cloud Agent VMs use the same managed migration path through
`.cursor/start.sh` → `scripts/migrate_db.py`. Local role-secret alignment is
stdin dollar-quoted SQL (`scripts/reconcile_local_postgres_role.py`); do not
interpolate `DATABASE_URL` secrets into `psql -c`. See
[`docs/development/cloud-agent-environment.md`](docs/development/cloud-agent-environment.md).

## Send boundary

Outbound replies preserve `In-Reply-To` and `References` headers in the built
Expand Down
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
## [Unreleased]
- Cloud Agent `.cursor/start.sh`는 `DATABASE_URL` 역할 비밀을 `psql -c`에 보간하지 않고 `scripts/reconcile_local_postgres_role.py`의 dollar-quoted stdin으로 맞춥니다. `install.sh`는 `requirements-hashes.txt`를 `--require-hashes`로 설치합니다. 신선한 DB에서 폐기된 `emails` 테이블을 가정하던 `0011_email_read_state`는 테이블이 없으면 no-op이고, `0019_email_record_read_state`가 `0018_email_send_rate_buckets` 다음에 정식 `email_records.is_read DEFAULT true`를 가드로 맞춥니다. 가져오기 quota advisory lock 키는 NUL 없는 SHA-256 hex입니다.
- EmailDetail 테스트가 지원하지 않는 스레드 병합/분리 버튼을 `textContent`뿐 아니라 `aria-label`과 `title` 접근 가능 이름으로도 검출하도록 바꿔, 아이콘 전용 버튼 회귀를 놓치지 않습니다.

### 캘린더 충돌 (Status-weighted conflicts)
Expand Down
11 changes: 11 additions & 0 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -98,6 +98,17 @@ The other compose files are purpose-specific evaluation/evidence stacks:
defaults anywhere; Compose/Kubernetes/operators must inject them, and startup
fails closed when they are missing.

### Cloud Agent environment (`.cursor/`)

Repo-managed Cloud Agent bootstrap lives in `.cursor/environment.json`.
`install.sh` installs PostgreSQL 16/pgvector and hashed Python deps;
`start.sh` brings Postgres online, mints a per-VM `~/.env`, and runs
`scripts/migrate_db.py`. Role-secret sync goes through
`backend/scripts/reconcile_local_postgres_role.py` (dollar-quoted stdin, never
`psql -c` interpolation). After boot, open http://127.0.0.1:3000 and sign in
with a minted HMAC session — see
`docs/development/cloud-agent-environment.md`.

## Architecture

Naruon is an AI email workspace: a web client/control plane over
Expand Down
18 changes: 17 additions & 1 deletion backend/alembic/versions/0011_email_read_state.py
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,19 @@


def upgrade() -> None:
# ``emails`` was the pre-reconciliation email table. ``email_records`` is now
# the single source of truth and already declares ``is_read`` via the model
# metadata created in 0001, so this legacy column add only applies to older
# databases that still carry the retired ``emails`` table. Guard on the table
# existing (matching the has_table/has_column pattern used by later
# revisions) so ``alembic upgrade head`` succeeds on fresh databases.
bind = op.get_bind()
inspector = sa.inspect(bind)
if not inspector.has_table("emails"):
return
existing_columns = {column["name"] for column in inspector.get_columns("emails")}
if "is_read" in existing_columns:
return
op.add_column(
"emails",
sa.Column(
Expand All @@ -26,4 +39,7 @@ def upgrade() -> None:


def downgrade() -> None:
op.drop_column("emails", "is_read")
# No-op: this revision only reconciles a retired ``emails`` table. Dropping
# ``is_read`` when the column is present would also remove a pre-existing
# column this revision did not create.
return
62 changes: 62 additions & 0 deletions backend/alembic/versions/0019_email_record_read_state.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,62 @@
"""Guard email_records.is_read with NOT NULL DEFAULT true.

Revision ID: 0019_email_record_read_state
Revises: 0018_email_send_rate_buckets

``0011_email_read_state`` only mutates the retired ``emails`` table. Fresh
databases already receive ``email_records.is_read`` from ``0001``
``create_all`` plus the current model ``server_default``. Existing databases
whose ``email_records`` row predates that column (or lacks a server default)
still need a guarded additive revision so raw INSERTs that omit ``is_read``
succeed.
"""

from __future__ import annotations

from alembic import op
import sqlalchemy as sa

revision = "0019_email_record_read_state"
down_revision = "0018_email_send_rate_buckets"
branch_labels = None
depends_on = None

_EMAIL_RECORDS_TABLE = "email_records"
_READ_STATE_COLUMN = "is_read"


def upgrade() -> None:
bind = op.get_bind()
inspector = sa.inspect(bind)
if not inspector.has_table(_EMAIL_RECORDS_TABLE):
return
columns = {
column["name"]: column
for column in inspector.get_columns(_EMAIL_RECORDS_TABLE)
}
if _READ_STATE_COLUMN not in columns:
op.add_column(
_EMAIL_RECORDS_TABLE,
sa.Column(
_READ_STATE_COLUMN,
sa.Boolean(),
nullable=False,
server_default=sa.text("true"),
),
)
return
if columns[_READ_STATE_COLUMN].get("default") is None:
op.alter_column(
_EMAIL_RECORDS_TABLE,
_READ_STATE_COLUMN,
existing_type=sa.Boolean(),
existing_nullable=False,
server_default=sa.text("true"),
)


def downgrade() -> None:
# No-op: ``create_all`` and later model metadata may already own this
# column. Dropping it would remove a default this revision did not
# necessarily create.
return
8 changes: 7 additions & 1 deletion backend/db/models.py
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@
Text,
UniqueConstraint,
func,
text,
)
from sqlalchemy.orm import declarative_base, Mapped, mapped_column, relationship
from sqlalchemy.types import TypeDecorator
Expand Down Expand Up @@ -803,7 +804,12 @@ def owner_filters(cls, user_id: str, organization_id: str | None):
date: Mapped[datetime.datetime] = mapped_column(DateTime(timezone=True), index=True)
body: Mapped[str] = mapped_column(Text)
# IMAP \Seen read state; defaults read so historical/file imports don't nag.
is_read: Mapped[bool] = mapped_column(Boolean, nullable=False, default=True)
# A DB-level server_default keeps create_all/bootstrap_db consistent with the
# 0011_email_read_state migration intent so raw inserts that omit is_read
# (e.g. postgres smoke seeds) don't hit a NOT NULL violation.
is_read: Mapped[bool] = mapped_column(
Boolean, nullable=False, default=True, server_default=text("true")
)
# Defer large pgvector payloads on default entity loads.
embedding = mapped_column(Vector(1536), deferred=True)
attachments: Mapped[list["Attachment"]] = relationship(
Expand Down
4 changes: 0 additions & 4 deletions backend/scripts/bootstrap_db.py
Original file line number Diff line number Diff line change
Expand Up @@ -186,10 +186,6 @@ def _get_create_indexes_statements() -> list[Executable]:
"CREATE INDEX IF NOT EXISTS ix_email_records_owner_date "
"ON email_records (user_id, organization_id, date)"
),
text(
"CREATE INDEX IF NOT EXISTS ix_emails_owner_date "
"ON emails (user_id, organization_id, date)"
),
text(
"CREATE INDEX IF NOT EXISTS ix_sender_relationships_owner_source "
"ON sender_relationships "
Expand Down
Loading