zettcode-engine 0.3.4__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (90) hide show
  1. zettcode/_compat.py +35 -0
  2. zettcode/budget.py +24 -0
  3. zettcode/config.py +1088 -0
  4. zettcode/config_edit.py +457 -0
  5. zettcode/engine/__init__.py +39 -0
  6. zettcode/engine/agent/__init__.py +81 -0
  7. zettcode/engine/agent/base.py +60 -0
  8. zettcode/engine/agent/context.py +166 -0
  9. zettcode/engine/agent/exports.py +73 -0
  10. zettcode/engine/agent/interaction.py +54 -0
  11. zettcode/engine/agent/sessions.py +218 -0
  12. zettcode/engine/agent/settings.py +269 -0
  13. zettcode/engine/agent/turns.py +308 -0
  14. zettcode/engine/core.py +819 -0
  15. zettcode/engine/dialogue/__init__.py +6 -0
  16. zettcode/engine/dialogue/ask.py +113 -0
  17. zettcode/engine/dialogue/side.py +197 -0
  18. zettcode/engine/dialogue/title.py +80 -0
  19. zettcode/engine/export/__init__.py +421 -0
  20. zettcode/engine/export/trace/script.js +18 -0
  21. zettcode/engine/export/trace/style.css +136 -0
  22. zettcode/engine/export/trace/template.html +31 -0
  23. zettcode/engine/extensions/__init__.py +8 -0
  24. zettcode/engine/extensions/capabilities.py +68 -0
  25. zettcode/engine/extensions/compaction.py +259 -0
  26. zettcode/engine/extensions/context.py +521 -0
  27. zettcode/engine/extensions/files.py +101 -0
  28. zettcode/engine/extensions/guidance.py +17 -0
  29. zettcode/engine/extensions/harness.py +208 -0
  30. zettcode/engine/extensions/harness_switch.py +18 -0
  31. zettcode/engine/extensions/mcp_notice.py +49 -0
  32. zettcode/engine/extensions/relay.py +93 -0
  33. zettcode/engine/extensions/subagents.py +391 -0
  34. zettcode/engine/extensions/tool_output.py +153 -0
  35. zettcode/engine/extensions/truncation.py +156 -0
  36. zettcode/engine/extensions/usage.py +304 -0
  37. zettcode/engine/harness_paths.py +74 -0
  38. zettcode/engine/permission/__init__.py +197 -0
  39. zettcode/engine/permission/approval.py +66 -0
  40. zettcode/engine/permission/readonly.py +435 -0
  41. zettcode/engine/permission/sandbox.py +140 -0
  42. zettcode/engine/pipeline.py +191 -0
  43. zettcode/engine/plugins/__init__.py +72 -0
  44. zettcode/engine/plugins/commands.py +152 -0
  45. zettcode/engine/plugins/context.py +251 -0
  46. zettcode/engine/plugins/extension.py +183 -0
  47. zettcode/engine/plugins/groups.py +74 -0
  48. zettcode/engine/plugins/loader.py +367 -0
  49. zettcode/engine/plugins/mixins.py +156 -0
  50. zettcode/engine/plugins/plugin.py +190 -0
  51. zettcode/engine/plugins/services.py +68 -0
  52. zettcode/engine/plugins/settings.py +117 -0
  53. zettcode/engine/plugins/state.py +64 -0
  54. zettcode/engine/plugins/switches.py +553 -0
  55. zettcode/engine/py.typed +0 -0
  56. zettcode/engine/server_tools.py +179 -0
  57. zettcode/engine/storage/__init__.py +51 -0
  58. zettcode/engine/storage/layout.py +161 -0
  59. zettcode/engine/storage/ledger.py +659 -0
  60. zettcode/engine/storage/locks.py +182 -0
  61. zettcode/engine/storage/metadata.py +278 -0
  62. zettcode/engine/storage/records.py +373 -0
  63. zettcode/engine/storage/store.py +1056 -0
  64. zettcode/engine/tools/__init__.py +7 -0
  65. zettcode/engine/tools/boundary.py +56 -0
  66. zettcode/engine/tools/files.py +262 -0
  67. zettcode/engine/tools/images.py +32 -0
  68. zettcode/engine/tools/output.py +81 -0
  69. zettcode/engine/tools/shell.py +181 -0
  70. zettcode/engine/tools/workspace.py +271 -0
  71. zettcode/error_text.py +217 -0
  72. zettcode/filelock.py +125 -0
  73. zettcode/logging_setup.py +196 -0
  74. zettcode/mention.py +124 -0
  75. zettcode/modes.py +18 -0
  76. zettcode/paths.py +113 -0
  77. zettcode/plugin_tui.py +133 -0
  78. zettcode/processes.py +126 -0
  79. zettcode/prompt.py +94 -0
  80. zettcode/providers.py +215 -0
  81. zettcode/py.typed +0 -0
  82. zettcode/registry.py +63 -0
  83. zettcode/relay.py +267 -0
  84. zettcode/statusline_parts.py +84 -0
  85. zettcode/version.py +11 -0
  86. zettcode/wire.py +30 -0
  87. zettcode/wording.py +11 -0
  88. zettcode_engine-0.3.4.dist-info/METADATA +23 -0
  89. zettcode_engine-0.3.4.dist-info/RECORD +90 -0
  90. zettcode_engine-0.3.4.dist-info/WHEEL +4 -0
@@ -0,0 +1,182 @@
1
+ """The lock on a session: only the process that holds it may open the session to write.
2
+
3
+ A session's file is an append-only tree that "the caller is the single process owning the session" (see
4
+ :mod:`.store`) — an assumption nothing enforced. Two terminals resumed on one session each appended to it, each
5
+ holding a different idea of where the conversation had got to, and the tree forked behind both of them.
6
+
7
+ So a session is **locked** by the process that has it open. The lock is an operating-system file lock on a file
8
+ in the session's own folder (:data:`LOCK_FILE`), taken when a session becomes the active one and given up when
9
+ another does, and it goes with the process however the process ends: a crashed or killed ZettCode leaves no lock
10
+ behind to clear by hand, which a pid in a file could not promise. A process that cannot take it still shows the
11
+ session — reading is harmless — and does nothing that would write to it.
12
+
13
+ One process holds at most one session lock: the active one. Starting a new session, resuming or forking moves it.
14
+ """
15
+
16
+ from __future__ import annotations
17
+
18
+ from collections.abc import Callable
19
+ from pathlib import Path
20
+ from typing import Protocol
21
+
22
+ from zettcode.filelock import SingletonLock, is_held
23
+
24
+ from .records import SAFE_SESSION_ID, STORE_FILE
25
+
26
+ #: The file in a session's folder the lock is taken on.
27
+ LOCK_FILE = "session.lock"
28
+
29
+ #: What a process that cannot write to a session says, for the one reader who asks why.
30
+ LOCKED_MESSAGE = "This session is open in another ZettCode process, so it is read-only here."
31
+
32
+
33
+ class SessionLocked(ValueError):
34
+ """An attempt to write to a session that another process has open.
35
+
36
+ A ``ValueError`` because the shell already turns one into a line in the conversation, which is what a command
37
+ refused for this reason should be.
38
+ """
39
+
40
+ def __init__(self, message: str = LOCKED_MESSAGE) -> None:
41
+ super().__init__(message)
42
+
43
+
44
+ class SessionGuard(Protocol):
45
+ """What the runtime asks of a session lock: take it for a session, ask whether it is held, give it up."""
46
+
47
+ def claim(self, session_id: str) -> bool:
48
+ """Take the lock for ``session_id``, letting go of any other, and say whether it is held now."""
49
+ ...
50
+
51
+ def holds(self, session_id: str) -> bool:
52
+ """Return whether this process holds the lock for ``session_id``."""
53
+ ...
54
+
55
+ def release(self) -> None:
56
+ """Let go of the lock this process holds, if it holds one."""
57
+ ...
58
+
59
+
60
+ class NoLocks:
61
+ """The guard of a runtime with no store on disk: every claim succeeds, because nothing is shared."""
62
+
63
+ def claim(self, session_id: str) -> bool:
64
+ """Say that the session is held; there is nobody to hold it against."""
65
+ return True
66
+
67
+ def holds(self, session_id: str) -> bool:
68
+ """Say that the session is held."""
69
+ return True
70
+
71
+ def release(self) -> None:
72
+ """Hold nothing, so there is nothing to let go of."""
73
+
74
+
75
+ class SessionLocks:
76
+ """The lock of the one session a process has open.
77
+
78
+ Args:
79
+ directory_of: Returns the folder of a session (the store's own ``session_dir``), which refuses an id
80
+ that would leave the store.
81
+ """
82
+
83
+ def __init__(self, directory_of: Callable[[str], Path]) -> None:
84
+ self._directory_of = directory_of
85
+ self._lock: SingletonLock | None = None
86
+ self._session_id: str | None = None
87
+
88
+ @property
89
+ def held(self) -> str | None:
90
+ """The id of the session this process holds, or ``None``."""
91
+ return self._session_id if self._lock is not None and self._lock.held else None
92
+
93
+ def holds(self, session_id: str) -> bool:
94
+ """Return whether this process holds the lock for ``session_id`` now."""
95
+ return self.held == session_id
96
+
97
+ def claim(self, session_id: str) -> bool:
98
+ """Take the lock for a session and let go of the one held before, whichever way it goes.
99
+
100
+ Asking again for the session already held is a yes and changes nothing. The session being left is
101
+ released even when the new one cannot be had: the process is no longer working in it.
102
+
103
+ Raises:
104
+ ValueError: ``session_id`` is not one the store could have written.
105
+ """
106
+ if self.holds(session_id):
107
+ return True
108
+ lock = SingletonLock(self._directory_of(session_id) / LOCK_FILE)
109
+ taken = lock.acquire()
110
+ self.release()
111
+ if taken:
112
+ self._lock, self._session_id = lock, session_id
113
+ return taken
114
+
115
+ def release(self) -> None:
116
+ """Let go of the lock; a session that was never written leaves no folder behind."""
117
+ lock, session_id = self._lock, self._session_id
118
+ self._lock = self._session_id = None
119
+ if lock is None or session_id is None:
120
+ return
121
+ path = lock.path
122
+ lock.release()
123
+ # A new session that was left before its first message has a folder with nothing in it but this file;
124
+ # tidying it keeps every launch that was closed unused from leaving one. A session that has data keeps
125
+ # its lock file: the file is how the next process asks, and it is empty.
126
+ if not (path.parent / STORE_FILE).exists():
127
+ _remove_if_empty(path.parent)
128
+
129
+
130
+ def _remove_if_empty(folder: Path) -> bool:
131
+ """Remove a session folder that holds nothing but its lock file, and then its workspace folder if that is bare.
132
+
133
+ The workspace folder goes only when nothing is left in it, which is what an unused launch in a project
134
+ nobody has stored anything for leaves behind. ``rmdir`` refuses a folder with anything in it, so another
135
+ session, or a process that has just made its own, is never in the way of this.
136
+
137
+ Returns:
138
+ Whether the session folder is gone.
139
+ """
140
+ try:
141
+ (folder / LOCK_FILE).unlink(missing_ok=True)
142
+ folder.rmdir()
143
+ except OSError:
144
+ return False
145
+ try:
146
+ folder.parent.rmdir()
147
+ except OSError:
148
+ pass
149
+ return True
150
+
151
+
152
+ def prune_empty(workspace_dir: Path) -> int:
153
+ """Remove the session folders in a workspace that never held a conversation and that nobody has open.
154
+
155
+ A launch that is closed in the ordinary way tidies its own folder (:meth:`SessionLocks.release`). One
156
+ that is killed, or whose terminal is closed under it, cannot, and leaves a folder with a lock file in
157
+ it and nothing else. Those are what this removes, so a store does not fill up with them. A folder is
158
+ only touched when it has no ``data.jsonl``, holds nothing but the lock file, and the lock is not held:
159
+ a session another process has just begun has its lock, and is left alone.
160
+
161
+ Args:
162
+ workspace_dir: The workspace's folder under the store root; a folder that is not there is not an error.
163
+
164
+ Returns:
165
+ How many session folders were removed.
166
+ """
167
+ removed = 0
168
+ try:
169
+ candidates = sorted(workspace_dir.iterdir())
170
+ except OSError:
171
+ return 0
172
+ for folder in candidates:
173
+ if not SAFE_SESSION_ID.match(folder.name) or ".." in folder.name:
174
+ continue
175
+ try:
176
+ names = {entry.name for entry in folder.iterdir()}
177
+ except OSError:
178
+ continue # not a folder, or gone since the listing
179
+ if not names <= {LOCK_FILE} or is_held(folder / LOCK_FILE):
180
+ continue
181
+ removed += _remove_if_empty(folder)
182
+ return removed
@@ -0,0 +1,278 @@
1
+ """Session metadata: one small file per session, and the old per-workspace log it replaced.
2
+
3
+ **The file.** Each session folder holds a ``metadata.json`` beside its ``data.jsonl``::
4
+
5
+ {"version": 1, "session_id": "01a1...", "created_at": "...", "updated_at": "...",
6
+ "title": "Fix the parser", "model": "gpt-5-mini"}
7
+
8
+ It is written when a session begins, when it is renamed, and when the model it uses changes, and
9
+ at no other time — about two hundred bytes, whatever the conversation does. A file written before
10
+ the model was recorded simply has none, and a reader that does not know the field ignores it, so
11
+ the layout version is unchanged. What a list needs besides that is when the
12
+ conversation last changed, and the conversation file already says so: its modification time moves
13
+ with every message it is given, so the age a list shows is read from there instead of being
14
+ written down again on every append. The folder is also the unit a person copies, moves or
15
+ deletes, so the metadata that describes a session now travels with it.
16
+
17
+ Writes replace the file whole (a temporary file, then a rename), so a reader sees the old file or
18
+ the new one and never half of either. A file that is missing, damaged, or names another session
19
+ is not an error: it is rebuilt from what the store still knows, which is how a session survives
20
+ losing it.
21
+
22
+ **The log it replaced.** ``metadata.jsonl`` sat beside the session folders and took one line per
23
+ write — and one write per *message*, so it grew without bound: thousands of lines for a
24
+ workspace, a few hundred bytes for every message ever sent, folded in full on every read::
25
+
26
+ {"created_at": "...", "kind": "session", "session_id": "01a1..."}
27
+ {"kind": "activity", "session_id": "01a1...", "updated_at": "..."}
28
+ {"kind": "title", "session_id": "01a1...", "title": "Fix the parser"}
29
+
30
+ Nothing writes it any more. It is still *read*, for a session that has no file of its own: that is
31
+ a session an older version made, and the store copies what the log says about it into the
32
+ session's folder the first time it is looked at, so the log is consulted once per old session and
33
+ never again. A torn final line is ignored, as in a conversation file, because a crash can cut the
34
+ line being written.
35
+ """
36
+
37
+ from __future__ import annotations
38
+
39
+ import contextlib
40
+ import json
41
+ import os
42
+ import tempfile
43
+ from dataclasses import dataclass, replace
44
+ from datetime import datetime, timezone
45
+ from pathlib import Path
46
+ from typing import Annotated, Any, Literal, TypeAlias, cast
47
+
48
+ from pydantic import BaseModel, Field, TypeAdapter, ValidationError
49
+
50
+ from zettcode._compat import StrEnum
51
+
52
+ #: The old per-workspace log, inside the workspace folder. Read, never written.
53
+ METADATA_FILE = "metadata.jsonl"
54
+
55
+ #: One session's own metadata, inside its folder.
56
+ SESSION_METADATA_FILE = "metadata.json"
57
+
58
+ #: Layout of :class:`SessionMetadata` on disk. A reader that finds a number it does not know
59
+ #: treats the file as missing and rebuilds it, so a newer layout is never half-understood.
60
+ SESSION_METADATA_VERSION = 1
61
+
62
+ MAX_TITLE = 200
63
+ MAX_MODEL = 200
64
+
65
+
66
+ class SessionMetadata(BaseModel):
67
+ """What a session says about itself, in its own folder.
68
+
69
+ Attributes:
70
+ version: Layout of this file.
71
+ session_id: The session it describes; a file that names another session — a folder
72
+ copied under a new name — is not trusted.
73
+ created_at: When the session's first record was written.
74
+ updated_at: The newest conversation activity this file knows of. Not the whole answer:
75
+ the conversation file's own modification time is newer after any later message, and
76
+ :func:`effective_updated_at` takes the later of the two.
77
+ title: The display title, or ``None`` while the session is unnamed.
78
+ model: The model the session last used, by the name the model picker shows, or ``None``
79
+ before any turn has run. It is what resuming the session switches back to. It is
80
+ written when it *changes*, not on every turn, so it costs the file no more writes
81
+ than a rename does.
82
+ """
83
+
84
+ version: int = SESSION_METADATA_VERSION
85
+ session_id: str
86
+ created_at: datetime
87
+ updated_at: datetime
88
+ title: str | None = None
89
+ model: str | None = None
90
+ #: This session's own choice about letting the model request compaction: ``True`` or ``False``, or
91
+ #: ``None`` when it never chose and follows the configuration's default. Older files hold no choice.
92
+ self_compact: bool | None = Field(default=None, strict=True)
93
+ #: The permission level this session last ran at — ``ask``, ``safe``, ``full`` or ``deny`` — or ``None`` when
94
+ #: it never chose one and a run starts at the level it was built with. Resuming the session puts it back in
95
+ #: force, so a reader who chose ``full`` for a job does not answer the question again on the next start.
96
+ permission: str | None = None
97
+
98
+
99
+ @dataclass(frozen=True, slots=True)
100
+ class SessionInfo:
101
+ """What a session list needs about one session.
102
+
103
+ Attributes:
104
+ session_id: Session identity.
105
+ title: Display title written by the application, or ``None`` when the
106
+ session is unnamed.
107
+ created_at: When the session's first record was written.
108
+ updated_at: When its conversation last changed; a title write does not move it,
109
+ so the age a list shows is conversation activity.
110
+ """
111
+
112
+ session_id: str
113
+ title: str | None
114
+ created_at: datetime
115
+ updated_at: datetime
116
+
117
+
118
+ def info_of(metadata: SessionMetadata, conversation: Path | None = None) -> SessionInfo:
119
+ """Return the list row a session's metadata makes, with its real last-activity time.
120
+
121
+ Args:
122
+ metadata: The session's own file.
123
+ conversation: The conversation file, whose modification time is when the session last
124
+ changed; ``None`` leaves the stored time as it is.
125
+ """
126
+ return SessionInfo(
127
+ metadata.session_id,
128
+ metadata.title,
129
+ metadata.created_at,
130
+ effective_updated_at(metadata, conversation),
131
+ )
132
+
133
+
134
+ def effective_updated_at(metadata: SessionMetadata, conversation: Path | None) -> datetime:
135
+ """Return when the session last changed: the later of the stored time and the file's.
136
+
137
+ The file's time wins for every session written since this layout began, because every
138
+ message moves it. The stored time wins when a copy of the folder has an older one — and is
139
+ what carries the right order across for a session an older version recorded.
140
+ """
141
+ if conversation is None:
142
+ return metadata.updated_at
143
+ try:
144
+ modified = datetime.fromtimestamp(conversation.stat().st_mtime_ns / 1e9, tz=timezone.utc)
145
+ except OSError:
146
+ return metadata.updated_at
147
+ return max(metadata.updated_at, modified)
148
+
149
+
150
+ def read_session_metadata(path: Path, session_id: str) -> SessionMetadata | None:
151
+ """Return a session's own metadata, or ``None`` when it has to be rebuilt.
152
+
153
+ ``None`` is the answer for a missing file, one that does not parse, one in a layout this
154
+ version does not know, and one that names another session. None of those is an error the
155
+ reader can do anything about, and each has the same remedy.
156
+
157
+ Args:
158
+ path: The session's ``metadata.json``.
159
+ session_id: The session the folder belongs to.
160
+ """
161
+ try:
162
+ text = path.read_bytes()
163
+ except OSError:
164
+ return None
165
+ try:
166
+ found = SessionMetadata.model_validate_json(text)
167
+ except ValidationError:
168
+ return None
169
+ if found.version != SESSION_METADATA_VERSION or found.session_id != session_id:
170
+ return None
171
+ return found
172
+
173
+
174
+ def write_session_metadata(path: Path, metadata: SessionMetadata) -> bool:
175
+ """Replace a session's metadata file whole, and report whether it was written.
176
+
177
+ The new text goes to a temporary file in the same folder and is renamed over the old one,
178
+ so a reader never sees half of a write and two writers cannot interleave their bytes. A
179
+ failure — a read-only store, a full disk — leaves the old file as it was and returns
180
+ ``False``: metadata is never worth failing a conversation for, and the next look at the
181
+ session rebuilds what was not saved.
182
+ """
183
+ text = json.dumps(metadata.model_dump(mode="json"), ensure_ascii=False, allow_nan=False, sort_keys=True)
184
+ temporary: str | None = None
185
+ try:
186
+ path.parent.mkdir(parents=True, exist_ok=True)
187
+ handle = tempfile.NamedTemporaryFile(
188
+ "w", encoding="utf-8", dir=path.parent, prefix=f"{path.name}.", suffix=".tmp", delete=False
189
+ )
190
+ temporary = handle.name
191
+ with handle:
192
+ handle.write(text + "\n")
193
+ os.replace(temporary, path)
194
+ except OSError:
195
+ if temporary is not None:
196
+ with contextlib.suppress(OSError):
197
+ os.unlink(temporary)
198
+ return False
199
+ return True
200
+
201
+
202
+ # -- the log this replaced: read, to carry old sessions over ---------------------------------
203
+
204
+
205
+ class MetadataKind(StrEnum):
206
+ """Discriminator values of the old log's records, written as ``kind``."""
207
+
208
+ SESSION = "session"
209
+ ACTIVITY = "activity"
210
+ TITLE = "title"
211
+
212
+
213
+ class SessionStarted(BaseModel):
214
+ """A session's first record in the old log: when its conversation began."""
215
+
216
+ kind: Literal[MetadataKind.SESSION] = MetadataKind.SESSION
217
+ session_id: str
218
+ created_at: datetime
219
+
220
+
221
+ class SessionActivity(BaseModel):
222
+ """One activity stamp in the old log: the newest one is the session's last change."""
223
+
224
+ kind: Literal[MetadataKind.ACTIVITY] = MetadataKind.ACTIVITY
225
+ session_id: str
226
+ updated_at: datetime
227
+
228
+
229
+ class SessionTitle(BaseModel):
230
+ """One display title in the old log; the newest line for a session replaces the earlier ones."""
231
+
232
+ kind: Literal[MetadataKind.TITLE] = MetadataKind.TITLE
233
+ session_id: str
234
+ title: str
235
+
236
+
237
+ MetadataRecord: TypeAlias = Annotated[SessionStarted | SessionActivity | SessionTitle, Field(discriminator="kind")]
238
+ # The alias is a ``TypeForm`` pydantic accepts; mypy 1.20 does not read an ``Annotated`` union as one.
239
+ METADATA: TypeAdapter[SessionStarted | SessionActivity | SessionTitle] = TypeAdapter(cast(Any, MetadataRecord))
240
+
241
+
242
+ def read_metadata(path: Path) -> list[SessionInfo]:
243
+ """Fold the old log into one row per session, newest activity first.
244
+
245
+ Args:
246
+ path: The log; a missing file is an empty log, not an error.
247
+
248
+ Returns:
249
+ Every session the log describes. Activity and title lines whose session
250
+ line is absent are ignored, so a truncated or edited file can never
251
+ invent a session.
252
+
253
+ Raises:
254
+ ValueError: A line other than the last is damaged.
255
+ """
256
+ rows: dict[str, SessionInfo] = {}
257
+ if not path.is_file():
258
+ return []
259
+ lines = path.read_text(encoding="utf-8").splitlines()
260
+ for index, line in enumerate(lines):
261
+ if not line.strip():
262
+ continue
263
+ try:
264
+ record = METADATA.validate_json(line)
265
+ except ValidationError as error:
266
+ if index == len(lines) - 1:
267
+ break # A crash can tear the line being appended; the rest is intact.
268
+ raise ValueError(f"Corrupt metadata file {path.name} at line {index + 1}") from error
269
+ match record:
270
+ case SessionStarted():
271
+ rows[record.session_id] = SessionInfo(record.session_id, None, record.created_at, record.created_at)
272
+ case SessionActivity() if (row := rows.get(record.session_id)) is not None:
273
+ rows[record.session_id] = replace(row, updated_at=max(row.updated_at, record.updated_at))
274
+ case SessionTitle() if (row := rows.get(record.session_id)) is not None:
275
+ rows[record.session_id] = replace(row, title=record.title)
276
+ sessions = list(rows.values())
277
+ sessions.sort(key=lambda info: (info.updated_at, info.session_id), reverse=True)
278
+ return sessions