skeleton: package, schema, config, CLI, alembic, CI, deploy, docs
ci / lint-test-leaks (pull_request) Canceled after 0s

- cayceops core: env-only config (CAYCE_OPS_*), engine/session, ORM models
  with state machines (proposal/mail/content) and audited writes
- provider-agnostic LLM interface (null provider default, stdlib openai-compat)
- Typer CLI with lazy component loading (scanner/executor/maild/publisher/monitor)
- alembic 0001 initial schema; sqlite-portable JSON columns
- tests: 7 passing on throwaway sqlite; fixtures invented, never real data
- ci: ruff + alembic smoke + pytest + gitleaks(full history) in
  .gitea/workflows + .github/workflows; local gate = make check
- deploy: docker-compose (postgres 16) + hardened systemd units/timers
- docs: DECISIONS D1-D9; README project (grant text retired)
This commit is contained in:
2026-10-19 21:35:50 +00:00
parent dbabb26c22
commit 34a5f331ac
32 changed files with 2275 additions and 60 deletions
+59
View File
@@ -0,0 +1,59 @@
"""Alembic environment — URL injected at runtime from CAYCE_OPS_DATABASE_URL."""
from __future__ import annotations
from logging.config import fileConfig
from sqlalchemy import engine_from_config, pool
from alembic import context
from cayceops.models import Base
config = context.config
if config.config_file_name is not None:
fileConfig(config.config_file_name)
if not config.get_main_option("sqlalchemy.url"):
import os
config.set_main_option(
"sqlalchemy.url",
os.environ.get("CAYCE_OPS_DATABASE_URL", "sqlite:///cayce-ops.db"),
)
target_metadata = Base.metadata
def run_migrations_offline() -> None:
context.configure(
url=config.get_main_option("sqlalchemy.url"),
target_metadata=target_metadata,
literal_binds=True,
dialect_opts={"paramstyle": "named"},
render_as_batch=True,
)
with context.begin_transaction():
context.run_migrations()
def run_migrations_online() -> None:
connectable = engine_from_config(
config.get_section(config.config_ini_section, {}),
prefix="sqlalchemy.",
poolclass=pool.NullPool,
)
with connectable.connect() as connection:
context.configure(
connection=connection,
target_metadata=target_metadata,
render_as_batch=True, # required for SQLite ALTER support
)
with context.begin_transaction():
context.run_migrations()
if context.is_offline_mode():
run_migrations_offline()
else:
run_migrations_online()
+24
View File
@@ -0,0 +1,24 @@
"""${message}
Revision ID: ${up_revision}
Revises: ${down_revision | comma,n}
Create Date: ${create_date}
"""
from __future__ import annotations
import sqlalchemy as sa
from alembic import op
${imports if imports else ""}
revision = ${repr(up_revision)}
down_revision = ${repr(down_revision)}
branch_labels = ${repr(branch_labels)}
depends_on = ${repr(depends_on)}
def upgrade() -> None:
${upgrades if upgrades else "pass"}
def downgrade() -> None:
${downgrades if downgrades else "pass"}
+144
View File
@@ -0,0 +1,144 @@
"""initial schema: proposals, audit_log, mail_messages, content_items, daemon_runs
Revision ID: 0001
Revises:
Create Date: 2026-10-19
"""
import sqlalchemy as sa
from alembic import op
revision = "0001"
down_revision = None
branch_labels = None
depends_on = None
def upgrade() -> None:
op.create_table(
"proposals",
sa.Column("id", sa.Integer(), primary_key=True),
sa.Column(
"source",
sa.Enum("SCANNER", "MANUAL", name="proposalsource"),
nullable=False,
),
sa.Column("kind", sa.String(length=64), nullable=False),
sa.Column("title", sa.String(length=512), nullable=False),
sa.Column("url", sa.String(length=1024), nullable=True),
sa.Column("detail", sa.JSON(), nullable=False),
sa.Column("risk_score", sa.Float(), nullable=False),
sa.Column(
"status",
sa.Enum(
"PROPOSED", "APPROVED", "REJECTED", "RUNNING", "DONE", "FAILED",
name="proposalstatus",
),
nullable=False,
),
sa.Column("attempts", sa.Integer(), nullable=False),
sa.Column("dedupe_key", sa.String(length=256), nullable=True),
sa.Column("approved_by", sa.String(length=128), nullable=True),
sa.Column("approved_at", sa.DateTime(timezone=True), nullable=True),
sa.Column("created_at", sa.DateTime(timezone=True), nullable=False),
sa.Column("updated_at", sa.DateTime(timezone=True), nullable=False),
sa.UniqueConstraint("dedupe_key"),
)
op.create_index("ix_proposals_status", "proposals", ["status"])
op.create_table(
"audit_log",
sa.Column("id", sa.Integer(), primary_key=True),
sa.Column("ts", sa.DateTime(timezone=True), nullable=False),
sa.Column("actor", sa.String(length=128), nullable=False),
sa.Column("proposal_id", sa.Integer(), nullable=True),
sa.Column("action", sa.String(length=64), nullable=False),
sa.Column("detail", sa.JSON(), nullable=False),
sa.Column("result", sa.String(length=32), nullable=False),
sa.ForeignKeyConstraint(["proposal_id"], ["proposals.id"], ondelete="SET NULL"),
)
op.create_index("ix_audit_log_ts", "audit_log", ["ts"])
op.create_index("ix_audit_log_proposal_id", "audit_log", ["proposal_id"])
op.create_table(
"mail_messages",
sa.Column("id", sa.Integer(), primary_key=True),
sa.Column(
"direction",
sa.Enum("INBOUND", "OUTBOUND", name="maildirection"),
nullable=False,
),
sa.Column("message_id", sa.String(length=998), nullable=True),
sa.Column("in_reply_to", sa.String(length=998), nullable=True),
sa.Column("from_addr", sa.String(length=320), nullable=True),
sa.Column("to_addr", sa.String(length=320), nullable=True),
sa.Column("subject", sa.String(length=998), nullable=True),
sa.Column("body", sa.Text(), nullable=True),
sa.Column(
"classification",
sa.Enum(
"APPROVAL", "REJECTION", "QUESTION", "OTHER", "NEEDS_REVIEW",
name="mailclassification",
),
nullable=True,
),
sa.Column("confidence", sa.Float(), nullable=False),
sa.Column("proposal_id", sa.Integer(), nullable=True),
sa.Column(
"draft_status",
sa.Enum(
"NONE", "DRAFTED", "APPROVED", "SENT", "SKIPPED", name="draftstatus"
),
nullable=False,
),
sa.Column("draft_reply", sa.Text(), nullable=True),
sa.Column("processed_at", sa.DateTime(timezone=True), nullable=True),
sa.ForeignKeyConstraint(["proposal_id"], ["proposals.id"], ondelete="SET NULL"),
)
op.create_index("ix_mail_messages_message_id", "mail_messages", ["message_id"])
op.create_index("ix_mail_messages_proposal_id", "mail_messages", ["proposal_id"])
op.create_table(
"content_items",
sa.Column("id", sa.Integer(), primary_key=True),
sa.Column("theme", sa.String(length=128), nullable=False),
sa.Column("channel", sa.String(length=32), nullable=False),
sa.Column("caption", sa.Text(), nullable=False),
sa.Column("media_path", sa.String(length=1024), nullable=True),
sa.Column("link", sa.String(length=1024), nullable=True),
sa.Column(
"status",
sa.Enum("DRAFT", "APPROVED", "PUBLISHED", name="contentstatus"),
nullable=False,
),
sa.Column("scheduled_for", sa.DateTime(timezone=True), nullable=True),
sa.Column("published_at", sa.DateTime(timezone=True), nullable=True),
sa.Column("post_ref", sa.String(length=512), nullable=True),
sa.Column("error", sa.Text(), nullable=True),
sa.Column("created_at", sa.DateTime(timezone=True), nullable=False),
)
op.create_index("ix_content_items_status", "content_items", ["status"])
op.create_table(
"daemon_runs",
sa.Column("id", sa.Integer(), primary_key=True),
sa.Column("daemon", sa.String(length=32), nullable=False),
sa.Column("started_at", sa.DateTime(timezone=True), nullable=False),
sa.Column("finished_at", sa.DateTime(timezone=True), nullable=True),
sa.Column("status", sa.String(length=16), nullable=False),
sa.Column("detail", sa.JSON(), nullable=False),
)
op.create_index("ix_daemon_runs_daemon", "daemon_runs", ["daemon"])
def downgrade() -> None:
op.drop_table("daemon_runs")
op.drop_table("content_items")
op.drop_table("mail_messages")
op.drop_table("audit_log")
op.drop_table("proposals")
for enum_name in (
"proposalsource", "proposalstatus", "maildirection",
"mailclassification", "draftstatus", "contentstatus",
):
op.execute(f"DROP TYPE IF EXISTS {enum_name}")