Class: Xeno::SessionsController

Inherits:
ApiController
  • Object
show all
Defined in:
app/controllers/xeno/sessions_controller.rb

Overview

The HTTP channel: always mounted, fail-closed. The channel owns the continuation token (the resume handle): create returns one, follow-up messages must present it, terminal states release it, reset retires the session so the same token can start fresh.

Constant Summary

Constants inherited from ApiController

ApiController::DEV_PRINCIPAL

Instance Method Summary collapse

Instance Method Details

#cancelObject

POST /v1/sessions/:id/cancel — stop the active turn, session intact. A parked/pending turn cancels immediately (no process holds it); a running turn is cancelled cooperatively via the persisted flag the runner's checker polls (takes effect within a chunk or step boundary).



80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
# File 'app/controllers/xeno/sessions_controller.rb', line 80

def cancel
  session = find_owned_session!
  turn = session.turns.where(status: %w[pending running waiting]).order(:sequence).first

  return render json: { error: "no active turn" }, status: :conflict unless turn

  if turn.status == "running"
    session.chat.cancel!
    render json: { turn_id: turn.id, cancelling: true }, status: :accepted
  else
    # Settle BEFORE cancelling: a parked turn's assistant tool_calls are
    # still unanswered, and leaving them dangling bricks the session
    # (every later generate replays the malformed transcript).
    session.settle_unanswered_tool_calls!(turn, reason: "cancelled by user")
    turn.update!(status: "cancelled")
    session.update!(status: "running") if session.status == "waiting"
    session.emit("turn.cancelled", { turn_id: turn.id })
    render json: { turn_id: turn.id, cancelled: true }
  end
end

#compactObject

POST /v1/sessions/:id/compact — request a manual compaction. It runs as a claimed turn, so it queues behind any active or parked turn and executes the moment the session is free. Never appends a user message.



104
105
106
107
108
109
110
111
112
113
114
115
# File 'app/controllers/xeno/sessions_controller.rb', line 104

def compact
  session = find_owned_session!

  return render json: { error: "session is finished" }, status: :gone unless session.active?
  if session.turns.where(kind: "compaction", status: %w[pending running]).exists?
    return render json: { error: "a compaction is already queued" }, status: :conflict
  end

  turn = session.stage_compaction_turn!(reason: "manual")
  turn.enqueue!
  render json: { session_id: session.id, turn_id: turn.id, status: "requested" }, status: :accepted
end

#createObject

POST /v1/sessions { message:, continuation_token: (optional) } Client-supplied tokens are restricted to the reserved http: namespace — a crafted slack:<channel>:<ts> token would otherwise hijack which Slack thread the agent posts into.



11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
# File 'app/controllers/xeno/sessions_controller.rb', line 11

def create
  message = params.require(:message)
  token = params[:continuation_token].presence
  if token && !token.start_with?("http:")
    return render json: { error: "client-supplied continuation_token must use the http: namespace" },
                  status: :unprocessable_entity
  end
  token ||= "http:#{SecureRandom.base58(24)}"

  session = Session.start!(
    message: message,
    channel: "http",
    principal: current_principal,
    continuation_token: token
  )

  render json: { session_id: session.id, continuation_token: session.continuation_token },
         status: :created
rescue ActiveRecord::RecordNotUnique
  render json: { error: "continuation_token already in use by an active session" },
         status: :conflict
end

#inputObject

POST /v1/sessions/:id/inputs { action_id:, decision: approve|deny, reason: } or { action_id:, answer: }



56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
# File 'app/controllers/xeno/sessions_controller.rb', line 56

def input
  session = find_owned_session!
  action = Action.joins(:turn).where(xeno_turns: { session_id: session.id })
                 .find(params.require(:action_id))

  if action.kind == "question"
    Inputs.answer!(action, params.require(:answer), principal: current_principal)
  else
    case params.require(:decision)
    when "approve" then Inputs.approve!(action, principal: current_principal)
    when "deny" then Inputs.deny!(action, reason: params[:reason], principal: current_principal)
    else
      return render json: { error: "decision must be approve or deny" }, status: :unprocessable_entity
    end
  end

  render json: { action_id: action.id, status: action.reload.status,
                 turn_status: action.turn.reload.status }
end

#messageObject

POST /v1/sessions/:id/messages { message:, continuation_token:, steer: } steer: true stops the active turn (settled safely) and makes this message the next turn instead of queueing behind the current work.



37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
# File 'app/controllers/xeno/sessions_controller.rb', line 37

def message
  session = find_owned_session!

  return render json: { error: "session is finished — start a new one" }, status: :gone unless session.active?

  supplied = params[:continuation_token].to_s
  unless supplied.present? && session.continuation_token.present? &&
         ActiveSupport::SecurityUtils.secure_compare(supplied, session.continuation_token)
    return render json: { error: "continuation_token missing or wrong" }, status: :forbidden
  end

  content = params.require(:message)
  steer = ActiveModel::Type::Boolean.new.cast(params[:steer])
  turn = steer ? session.steer!(content) : session.receive_message!(content)
  render json: { session_id: session.id, queued: turn.nil?, turn_id: turn&.id, steered: steer || nil }.compact,
         status: :accepted
end

#resetObject

POST /v1/sessions/:id/reset — retire the session (the /new command); releases the continuation token so the same handle can start fresh.



119
120
121
122
123
124
125
126
127
128
129
# File 'app/controllers/xeno/sessions_controller.rb', line 119

def reset
  session = find_owned_session!

  return render json: { error: "session already finished" }, status: :gone unless session.active?

  session.chat.cancel! if session.turns.where(status: "running").exists?
  released = session.continuation_token
  session.finish!("completed")

  render json: { session_id: session.id, released_continuation_token: released }
end