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 new
+101
@@ -0,0 +1,101 @@
1 +# frozen_string_literal: true
2 +
3 +module Api
4 + module V1
5 + # siGit Code Cloud Sessions (Cloud Session): named, resumable conversations
6 + # persisted server-side so they sync across the user's signed-in surfaces.
7 + # Token-based, scoped to current_user; a user can only see and mutate their
8 + # own sessions.
9 + #
10 + # GET /api/v1/sessions → list (most recent first)
11 + # POST /api/v1/sessions → create { title?, model? }
12 + # GET /api/v1/sessions/:id → session + messages
13 + # PATCH /api/v1/sessions/:id → update { title?, model? }
14 + # DELETE /api/v1/sessions/:id → 204
15 + # POST /api/v1/sessions/:id/messages → append { role, content }
16 + #
17 + # Streaming inference is unchanged: the client still calls
18 + # /api/v1/chat/completions. These endpoints only persist the transcript, so a
19 + # session can be resumed later. Mapped at path "sessions" but kept a distinct
20 + # controller from the auth SessionsController (sign_in/sign_out).
21 + class CloudSessionsController < Api::BaseController
22 + before_action :authenticate_token!
23 + before_action :set_session, only: %i[show update destroy create_message]
24 +
25 + def index
26 + sessions = current_user.cloud_sessions.order(updated_at: :desc)
27 + render json: sessions.map { |s| session_json(s) }, status: :ok
28 + end
29 +
30 + def create
31 + session = current_user.cloud_sessions.create!(create_params)
32 + render json: session_json(session), status: :created
33 + end
34 +
35 + def show
36 + render json: session_json(@session, include_messages: true), status: :ok
37 + end
38 +
39 + def update
40 + @session.update!(update_params)
41 + render json: session_json(@session), status: :ok
42 + end
43 +
44 + def destroy
45 + @session.destroy!
46 + head :no_content
47 + end
48 +
49 + # Append one message to the session. The client persists the user message
50 + # when it sends, and the assistant message once streaming completes.
51 + def create_message
52 + message = @session.append_message!(
53 + role: message_params[:role],
54 + content: message_params[:content]
55 + )
56 + render json: { session: session_json(@session.reload), message: message_json(message) },
57 + status: :created
58 + end
59 +
60 + private
61 +
62 + def set_session
63 + @session = current_user.cloud_sessions.find_by(id: params[:id])
64 + return if @session
65 +
66 + render_error(ERR_INVALID, "Session not found.", status: :not_found)
67 + end
68 +
69 + def create_params
70 + params.permit(:title, :model).to_h.slice("title", "model").compact_blank
71 + end
72 + alias update_params create_params
73 +
74 + def message_params
75 + params.permit(:role, :content)
76 + end
77 +
78 + def session_json(session, include_messages: false)
79 + json = {
80 + id: session.id,
81 + title: session.title,
82 + model: session.model,
83 + last_message_at: session.last_message_at&.iso8601,
84 + created_at: session.created_at.iso8601,
85 + updated_at: session.updated_at.iso8601
86 + }
87 + json[:messages] = session.cloud_messages.map { |m| message_json(m) } if include_messages
88 + json
89 + end
90 +
91 + def message_json(message)
92 + {
93 + id: message.id,
94 + role: message.role,
95 + content: message.content,
96 + created_at: message.created_at.iso8601
97 + }
98 + end
99 + end
100 + end
101 +end
app/models/cloud_message.rb new
+12
@@ -0,0 +1,12 @@
1 +# frozen_string_literal: true
2 +
3 +# One message in a CloudSession. Roles mirror the OpenAI chat contract the client
4 +# already speaks (user / assistant / system).
5 +class CloudMessage < ApplicationRecord
6 + ROLES = %w[user assistant system].freeze
7 +
8 + belongs_to :cloud_session
9 +
10 + validates :role, presence: true, inclusion: { in: ROLES }
11 + validates :content, length: { maximum: 200_000 }
12 +end
app/models/cloud_session.rb new
+44
@@ -0,0 +1,44 @@
1 +# frozen_string_literal: true
2 +
3 +# A siGit Code Cloud Session: a named, resumable conversation persisted so it
4 +# follows the user across devices. Owns its messages in order; `model` is the
5 +# neutral cloud tier wire id (onde-fast/balanced/large), never an upstream name.
6 +class CloudSession < ApplicationRecord
7 + belongs_to :user
8 + has_many :cloud_messages, -> { order(:created_at, :id) }, dependent: :destroy
9 +
10 + validates :title, presence: true, length: { maximum: 200 }
11 + validates :model, presence: true
12 +
13 + before_validation :default_title
14 +
15 + # Append a message and bump the session's activity timestamp so the list stays
16 + # ordered by most-recent. Auto-titles the session from the first user message
17 + # while it still has the default title.
18 + def append_message!(role:, content:)
19 + message = nil
20 + transaction do
21 + message = cloud_messages.create!(role: role, content: content.to_s)
22 + retitle_from(content) if role == "user" && default_title?
23 + update!(last_message_at: message.created_at)
24 + end
25 + message
26 + end
27 +
28 + private
29 +
30 + def default_title
31 + self.title = "New session" if title.blank?
32 + end
33 +
34 + def default_title?
35 + title.blank? || title == "New session"
36 + end
37 +
38 + def retitle_from(content)
39 + snippet = content.to_s.strip.split("\n").first.to_s.strip
40 + return if snippet.blank?
41 +
42 + self.title = snippet.length > 80 ? "#{snippet[0, 80].rstrip}…" : snippet
43 + end
44 +end
app/models/user.rb
+1
@@ -7,6 +7,7 @@ class User < ApplicationRecord
7 has_many :starred_repositories, through: :stars, source: :repository
8 has_one :subscription, dependent: :destroy
9 has_many :cloud_usages, dependent: :destroy
10 + has_many :cloud_sessions, dependent: :destroy
11
12 # Whether this user may use siGit Code Cloud (the paid cloud tiers). Free /
13 # unsubscribed users run on-device only.
config/routes.rb
+6
@@ -80,6 +80,12 @@ Rails.application.routes.draw do
80 get "billing", to: "billing#show"
81 post "billing/checkout", to: "billing#checkout"
82 post "billing/portal", to: "billing#portal"
83 +
84 + # siGit Code Cloud Sessions — persisted, resumable conversations.
85 + resources :cloud_sessions, path: "sessions",
86 + only: %i[index create show update destroy] do
87 + post "messages", on: :member, to: "cloud_sessions#create_message"
88 + end
89 end
90 end
91
db/migrate/20250101000007_create_cloud_sessions.rb new
+36
@@ -0,0 +1,36 @@
1 +# frozen_string_literal: true
2 +
3 +# siGit Code Cloud Sessions (short: Cloud Session). A Cloud Session is a named,
4 +# resumable conversation with siGit Code Cloud, persisted server-side so it syncs
5 +# across the web client and any other signed-in surface. Each session pins a
6 +# quality tier (`model` = the neutral wire id, e.g. onde-balanced) and owns an
7 +# ordered list of messages. On-device chat is never stored here.
8 +#
9 +# The session id is a UUID because it appears in a shareable, public-facing URL
10 +# (code.sigit.si/cloud/sessions/<uuid>); a sequential integer would leak volume
11 +# and be guessable. Messages keep a plain bigint id (never surfaced) but key off
12 +# the session UUID.
13 +class CreateCloudSessions < ActiveRecord::Migration[8.1]
14 + def change
15 + create_table :cloud_sessions, id: :uuid do |t|
16 + t.references :user, null: false, foreign_key: true
17 + t.string :title, null: false, default: "New session"
18 + t.string :model, null: false, default: "onde-balanced"
19 + t.datetime :last_message_at
20 +
21 + t.timestamps
22 + end
23 +
24 + add_index :cloud_sessions, %i[user_id updated_at]
25 +
26 + create_table :cloud_messages do |t|
27 + t.references :cloud_session, null: false, foreign_key: true, type: :uuid
28 + t.string :role, null: false
29 + t.text :content, null: false, default: ""
30 +
31 + t.timestamps
32 + end
33 +
34 + add_index :cloud_messages, %i[cloud_session_id created_at]
35 + end
36 +end
db/schema.rb
+24 -1
@@ -10,10 +10,31 @@
10 #
11 # It's strongly recommended that you check this file into your version control system.
12
13 -ActiveRecord::Schema[8.1].define(version: 2025_01_01_000006) do
13 +ActiveRecord::Schema[8.1].define(version: 2025_01_01_000007) do
14 # These are extensions that must be enabled in order to support this database
15 enable_extension "pg_catalog.plpgsql"
16
17 + create_table "cloud_messages", force: :cascade do |t|
18 + t.uuid "cloud_session_id", null: false
19 + t.text "content", default: "", null: false
20 + t.datetime "created_at", null: false
21 + t.string "role", null: false
22 + t.datetime "updated_at", null: false
23 + t.index ["cloud_session_id", "created_at"], name: "index_cloud_messages_on_cloud_session_id_and_created_at"
24 + t.index ["cloud_session_id"], name: "index_cloud_messages_on_cloud_session_id"
25 + end
26 +
27 + create_table "cloud_sessions", id: :uuid, default: -> { "gen_random_uuid()" }, force: :cascade do |t|
28 + t.datetime "created_at", null: false
29 + t.datetime "last_message_at"
30 + t.string "model", default: "onde-balanced", null: false
31 + t.string "title", default: "New session", null: false
32 + t.datetime "updated_at", null: false
33 + t.bigint "user_id", null: false
34 + t.index ["user_id", "updated_at"], name: "index_cloud_sessions_on_user_id_and_updated_at"
35 + t.index ["user_id"], name: "index_cloud_sessions_on_user_id"
36 + end
37 +
38 create_table "cloud_usages", force: :cascade do |t|
39 t.datetime "created_at", null: false
40 t.string "period_key", null: false
@@ -89,6 +110,8 @@ ActiveRecord::Schema[8.1].define(version: 2025_01_01_000006) do
110 t.index ["username"], name: "index_users_on_username", unique: true
111 end
112
113 + add_foreign_key "cloud_messages", "cloud_sessions"
114 + add_foreign_key "cloud_sessions", "users"
115 add_foreign_key "cloud_usages", "users"
116 add_foreign_key "repositories", "users"
117 add_foreign_key "ssh_keys", "users"