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"