Add siGit Code Cloud Sessions (persisted, resumable conversations)

Cloud Sessions are named, server-persisted conversations so siGit Code Cloud chats sync across signed-in surfaces and can be resumed. Each session pins a quality tier (neutral wire model id) and owns an ordered message list. - CloudSession (UUID pk, surfaced in shareable URLs) + CloudMessage models; append_message! auto-titles from the first user turn and bumps activity. - Api::V1::CloudSessionsController at path "sessions": index/create/show/ update/destroy + POST :id/messages, token-scoped to current_user. - Inference is unchanged: clients still stream via /api/v1/chat/completions; these endpoints only persist the transcript. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

Seto Elkahfi committed Jun 26, 2026 at 16:43 UTC f8c63a7cfc595d92f76dc65d129f5a2f7e5a83a6
7 files changed +224 -1
app/controllers/api/v1/cloud_sessions_controller.rb
+101
new file mode 100644 index 0000000..d913635 --- /dev/null +++ b/app/controllers/api/v1/cloud_sessions_controller.rb @@ -0,0 +1,101 @@ +# frozen_string_literal: true + +module Api + module V1 + # siGit Code Cloud Sessions (Cloud Session): named, resumable conversations + # persisted server-side so they sync across the user's signed-in surfaces. + # Token-based, scoped to current_user; a user can only see and mutate their + # own sessions. + # + # GET /api/v1/sessions → list (most recent first) + # POST /api/v1/sessions → create { title?, model? } + # GET /api/v1/sessions/:id → session + messages + # PATCH /api/v1/sessions/:id → update { title?, model? } + # DELETE /api/v1/sessions/:id → 204 + # POST /api/v1/sessions/:id/messages → append { role, content } + # + # Streaming inference is unchanged: the client still calls + # /api/v1/chat/completions. These endpoints only persist the transcript, so a + # session can be resumed later. Mapped at path "sessions" but kept a distinct + # controller from the auth SessionsController (sign_in/sign_out). + class CloudSessionsController < Api::BaseController + before_action :authenticate_token! + before_action :set_session, only: %i[show update destroy create_message] + + def index + sessions = current_user.cloud_sessions.order(updated_at: :desc) + render json: sessions.map { |s| session_json(s) }, status: :ok + end + + def create + session = current_user.cloud_sessions.create!(create_params) + render json: session_json(session), status: :created + end + + def show + render json: session_json(@session, include_messages: true), status: :ok + end + + def update + @session.update!(update_params) + render json: session_json(@session), status: :ok + end + + def destroy + @session.destroy! + head :no_content + end + + # Append one message to the session. The client persists the user message + # when it sends, and the assistant message once streaming completes. + def create_message + message = @session.append_message!( + role: message_params[:role], + content: message_params[:content] + ) + render json: { session: session_json(@session.reload), message: message_json(message) }, + status: :created + end + + private + + def set_session + @session = current_user.cloud_sessions.find_by(id: params[:id]) + return if @session + + render_error(ERR_INVALID, "Session not found.", status: :not_found) + end + + def create_params + params.permit(:title, :model).to_h.slice("title", "model").compact_blank + end + alias update_params create_params + + def message_params + params.permit(:role, :content) + end + + def session_json(session, include_messages: false) + json = { + id: session.id, + title: session.title, + model: session.model, + last_message_at: session.last_message_at&.iso8601, + created_at: session.created_at.iso8601, + updated_at: session.updated_at.iso8601 + } + json[:messages] = session.cloud_messages.map { |m| message_json(m) } if include_messages + json + end + + def message_json(message) + { + id: message.id, + role: message.role, + content: message.content, + created_at: message.created_at.iso8601 + } + end + end + end +end
app/models/cloud_message.rb
+12
new file mode 100644 index 0000000..da7b2c1 --- /dev/null +++ b/app/models/cloud_message.rb @@ -0,0 +1,12 @@ +# frozen_string_literal: true + +# One message in a CloudSession. Roles mirror the OpenAI chat contract the client +# already speaks (user / assistant / system). +class CloudMessage < ApplicationRecord + ROLES = %w[user assistant system].freeze + + belongs_to :cloud_session + + validates :role, presence: true, inclusion: { in: ROLES } + validates :content, length: { maximum: 200_000 } +end
app/models/cloud_session.rb
+44
new file mode 100644 index 0000000..de05fb9 --- /dev/null +++ b/app/models/cloud_session.rb @@ -0,0 +1,44 @@ +# frozen_string_literal: true + +# A siGit Code Cloud Session: a named, resumable conversation persisted so it +# follows the user across devices. Owns its messages in order; `model` is the +# neutral cloud tier wire id (onde-fast/balanced/large), never an upstream name. +class CloudSession < ApplicationRecord + belongs_to :user + has_many :cloud_messages, -> { order(:created_at, :id) }, dependent: :destroy + + validates :title, presence: true, length: { maximum: 200 } + validates :model, presence: true + + before_validation :default_title + + # Append a message and bump the session's activity timestamp so the list stays + # ordered by most-recent. Auto-titles the session from the first user message + # while it still has the default title. + def append_message!(role:, content:) + message = nil + transaction do + message = cloud_messages.create!(role: role, content: content.to_s) + retitle_from(content) if role == "user" && default_title? + update!(last_message_at: message.created_at) + end + message + end + + private + + def default_title + self.title = "New session" if title.blank? + end + + def default_title? + title.blank? || title == "New session" + end + + def retitle_from(content) + snippet = content.to_s.strip.split("\n").first.to_s.strip + return if snippet.blank? + + self.title = snippet.length > 80 ? "#{snippet[0, 80].rstrip}…" : snippet + end +end
app/models/user.rb
+1
index ad71ec4..40c8b9a 100644 --- a/app/models/user.rb +++ b/app/models/user.rb @@ -7,6 +7,7 @@ class User < ApplicationRecord has_many :starred_repositories, through: :stars, source: :repository has_one :subscription, dependent: :destroy has_many :cloud_usages, dependent: :destroy + has_many :cloud_sessions, dependent: :destroy # Whether this user may use siGit Code Cloud (the paid cloud tiers). Free / # unsubscribed users run on-device only.
config/routes.rb
+6
index e187ac1..1bd5596 100644 --- a/config/routes.rb +++ b/config/routes.rb @@ -80,6 +80,12 @@ Rails.application.routes.draw do get "billing", to: "billing#show" post "billing/checkout", to: "billing#checkout" post "billing/portal", to: "billing#portal" + + # siGit Code Cloud Sessions — persisted, resumable conversations. + resources :cloud_sessions, path: "sessions", + only: %i[index create show update destroy] do + post "messages", on: :member, to: "cloud_sessions#create_message" + end end end
db/migrate/20250101000007_create_cloud_sessions.rb
+36
new file mode 100644 index 0000000..0e1462c --- /dev/null +++ b/db/migrate/20250101000007_create_cloud_sessions.rb @@ -0,0 +1,36 @@ +# frozen_string_literal: true + +# siGit Code Cloud Sessions (short: Cloud Session). A Cloud Session is a named, +# resumable conversation with siGit Code Cloud, persisted server-side so it syncs +# across the web client and any other signed-in surface. Each session pins a +# quality tier (`model` = the neutral wire id, e.g. onde-balanced) and owns an +# ordered list of messages. On-device chat is never stored here. +# +# The session id is a UUID because it appears in a shareable, public-facing URL +# (code.sigit.si/cloud/sessions/<uuid>); a sequential integer would leak volume +# and be guessable. Messages keep a plain bigint id (never surfaced) but key off +# the session UUID. +class CreateCloudSessions < ActiveRecord::Migration[8.1] + def change + create_table :cloud_sessions, id: :uuid do |t| + t.references :user, null: false, foreign_key: true + t.string :title, null: false, default: "New session" + t.string :model, null: false, default: "onde-balanced" + t.datetime :last_message_at + + t.timestamps + end + + add_index :cloud_sessions, %i[user_id updated_at] + + create_table :cloud_messages do |t| + t.references :cloud_session, null: false, foreign_key: true, type: :uuid + t.string :role, null: false + t.text :content, null: false, default: "" + + t.timestamps + end + + add_index :cloud_messages, %i[cloud_session_id created_at] + end +end
db/schema.rb
+24 -1
index f0538ec..127f5cb 100644 --- a/db/schema.rb +++ b/db/schema.rb @@ -10,10 +10,31 @@ # # It's strongly recommended that you check this file into your version control system. -ActiveRecord::Schema[8.1].define(version: 2025_01_01_000006) do +ActiveRecord::Schema[8.1].define(version: 2025_01_01_000007) do # These are extensions that must be enabled in order to support this database enable_extension "pg_catalog.plpgsql" + create_table "cloud_messages", force: :cascade do |t| + t.uuid "cloud_session_id", null: false + t.text "content", default: "", null: false + t.datetime "created_at", null: false + t.string "role", null: false + t.datetime "updated_at", null: false + t.index ["cloud_session_id", "created_at"], name: "index_cloud_messages_on_cloud_session_id_and_created_at" + t.index ["cloud_session_id"], name: "index_cloud_messages_on_cloud_session_id" + end + + create_table "cloud_sessions", id: :uuid, default: -> { "gen_random_uuid()" }, force: :cascade do |t| + t.datetime "created_at", null: false + t.datetime "last_message_at" + t.string "model", default: "onde-balanced", null: false + t.string "title", default: "New session", null: false + t.datetime "updated_at", null: false + t.bigint "user_id", null: false + t.index ["user_id", "updated_at"], name: "index_cloud_sessions_on_user_id_and_updated_at" + t.index ["user_id"], name: "index_cloud_sessions_on_user_id" + end + create_table "cloud_usages", force: :cascade do |t| t.datetime "created_at", null: false t.string "period_key", null: false @@ -89,6 +110,8 @@ ActiveRecord::Schema[8.1].define(version: 2025_01_01_000006) do t.index ["username"], name: "index_users_on_username", unique: true end + add_foreign_key "cloud_messages", "cloud_sessions" + add_foreign_key "cloud_sessions", "users" add_foreign_key "cloud_usages", "users" add_foreign_key "repositories", "users" add_foreign_key "ssh_keys", "users"