# -*- coding: utf-8 -*- """``compositor.session`` — Per-browser-tab compositor session. A session represents one active OWL shell instance in a browser tab. Each session has its own: - Wayland display (``$XDG_RUNTIME_DIR/wayland-N``) on the proxy host - Forked Python proxy worker process - JWT (15-min, rotated by the OWL shell) - Set of launched app instances (``compositor.session.app``) """ import uuid from odoo import api, fields, models class CompositorSession(models.Model): _name = "compositor.session" _description = "1B Space Compositor Session" _inherit = ["mail.thread"] _order = "started_at desc" # ─── Identity ──────────────────────────────────────────────────────────── compositor_session_id = fields.Char( string="Compositor Session UUID", required=True, copy=False, default=lambda self: str(uuid.uuid4()), tracking=True, help="The UUID generated by the OWL shell and embedded in the JWT. " "The proxy uses this to scope Wayland display + websocket connections.", ) # ─── Relations ────────────────────────────────────────────────────────── user_id = fields.Many2one( "res.users", string="User", required=True, default=lambda self: self.env.user, tracking=True, ) company_id = fields.Many2one( "res.company", string="Company", default=lambda self: self.env.company, ) app_instance_ids = fields.One2many( "compositor.session.app", "session_id", string="App Instances", ) app_count = fields.Integer( string="Apps Launched", compute="_compute_app_count", ) running_app_count = fields.Integer( string="Running Now", compute="_compute_app_count", ) # ─── State ────────────────────────────────────────────────────────────── state = fields.Selection( [ ("pending", "Pending"), ("active", "Active"), ("disconnected", "Disconnected"), ("closed", "Closed"), ], string="State", default="pending", required=True, tracking=True, ) # ─── Proxy info ────────────────────────────────────────────────────────── proxy_url = fields.Char( string="Proxy URL", help="WebSocket URL of the Python compositor proxy that this session connects to.", ) proxy_pid = fields.Integer( string="Proxy Worker PID", help="PID of the forked Python proxy worker process on the proxy host.", ) wayland_display = fields.Char( string="Wayland Display", help="The WAYLAND_DISPLAY value used by this session's apps, e.g. 'wayland-3'.", ) # ─── Lifecycle ────────────────────────────────────────────────────────── started_at = fields.Datetime( string="Started At", default=fields.Datetime.now, ) last_activity = fields.Datetime( string="Last Activity", tracking=True, ) ended_at = fields.Datetime( string="Ended At", ) duration_seconds = fields.Integer( string="Duration (s)", compute="_compute_duration_seconds", ) # ─── JWT ──────────────────────────────────────────────────────────────── last_jwt_issued_at = fields.Datetime( string="Last JWT Issued", ) last_jwt_expires_at = fields.Datetime( string="Last JWT Expires", ) @api.depends("app_instance_ids", "app_instance_ids.state") def _compute_app_count(self): for record in self: record.app_count = len(record.app_instance_ids) record.running_app_count = sum( 1 for inst in record.app_instance_ids if inst.state == "running" ) @api.depends("started_at", "ended_at") def _compute_duration_seconds(self): for record in self: end = record.ended_at or fields.Datetime.now() if record.started_at: record.duration_seconds = int((end - record.started_at).total_seconds()) else: record.duration_seconds = 0 # ─── Public methods ──────────────────────────────────────────────────── def action_close(self): """Force-close the session and all running apps.""" for session in self: # In a real impl, this calls the proxy via HTTP to terminate # For now, mark as closed session.app_instance_ids.write({"state": "terminated", "ended_at": fields.Datetime.now()}) session.write({ "state": "closed", "ended_at": fields.Datetime.now(), }) def action_reconnect(self): """Mark the session as active again (e.g. after a browser refresh).""" for session in self: session.state = "active" session.last_activity = fields.Datetime.now() def action_view_app_instances(self): """Open the app instances view filtered to this session.""" self.ensure_one() return { "type": "ir.actions.act_window", "name": "App Instances", "res_model": "compositor.session.app", "view_mode": "list,form", "domain": [("session_id", "=", self.id)], "context": {"default_session_id": self.id}, } def touch(self): """Update last_activity timestamp. Called by the OWL shell periodically.""" self.write({"last_activity": fields.Datetime.now()}) class CompositorSessionApp(models.Model): _name = "compositor.session.app" _description = "App Instance Running in a Compositor Session" _order = "started_at desc" session_id = fields.Many2one( "compositor.session", string="Session", required=True, ondelete="cascade", ) app_id = fields.Many2one( "compositor.app", string="Application", required=True, ondelete="restrict", ) # ─── State ────────────────────────────────────────────────────────────── state = fields.Selection( [ ("launching", "Launching"), ("running", "Running"), ("terminated", "Terminated"), ("crashed", "Crashed"), ], string="State", default="launching", required=True, ) # ─── Process info (from proxy) ────────────────────────────────────────── pid = fields.Integer(string="PID", help="Process ID on the proxy host.") signal_url = fields.Char(string="Signal WebSocket URL") compositor_surface_id = fields.Char( string="Surface ID", help="Internal ID used by the OWL shell to identify the wl_surface.", ) # ─── Window state ─────────────────────────────────────────────────────── title = fields.Char(string="Window Title", default="(no title)") app_id_str = fields.Char( string="App ID String", help="The xdg_toplevel.app_id sent by the app (e.g. 'org.gnome.Calculator').", ) width = fields.Integer(string="Width", default=800) height = fields.Integer(string="Height", default=600) is_maximized = fields.Boolean(string="Maximized", default=False) is_fullscreen = fields.Boolean(string="Fullscreen", default=False) is_minimized = fields.Boolean(string="Minimized", default=False) is_focused = fields.Boolean(string="Focused", default=False) # ─── Lifecycle ────────────────────────────────────────────────────────── started_at = fields.Datetime( string="Started At", default=fields.Datetime.now, ) ended_at = fields.Datetime( string="Ended At", ) exit_code = fields.Integer(string="Exit Code") signal_received = fields.Char(string="Signal Received") # ─── Computed ─────────────────────────────────────────────────────────── duration_seconds = fields.Integer( string="Duration (s)", compute="_compute_duration_seconds", ) @api.depends("started_at", "ended_at") def _compute_duration_seconds(self): for record in self: end = record.ended_at or fields.Datetime.now() if record.started_at: record.duration_seconds = int((end - record.started_at).total_seconds()) else: record.duration_seconds = 0 def action_terminate(self): """Send a SIGTERM to the sandboxed app via the proxy.""" for inst in self: # In a real impl, calls the proxy via HTTP inst.write({ "state": "terminated", "ended_at": fields.Datetime.now(), }) def action_force_kill(self): """Send a SIGKILL to the sandboxed app via the proxy.""" for inst in self: inst.write({ "state": "terminated", "ended_at": fields.Datetime.now(), "signal_received": "SIGKILL", })