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"