codex-telegram 0.6.0__tar.gz → 0.6.1__tar.gz
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.
- {codex_telegram-0.6.0 → codex_telegram-0.6.1}/PKG-INFO +12 -1
- {codex_telegram-0.6.0 → codex_telegram-0.6.1}/README.md +11 -0
- {codex_telegram-0.6.0 → codex_telegram-0.6.1}/WEB.md +4 -1
- {codex_telegram-0.6.0 → codex_telegram-0.6.1}/codex_telegram/__init__.py +1 -1
- {codex_telegram-0.6.0 → codex_telegram-0.6.1}/codex_telegram.egg-info/PKG-INFO +12 -1
- {codex_telegram-0.6.0 → codex_telegram-0.6.1}/codex_telegram.egg-info/SOURCES.txt +4 -2
- codex_telegram-0.6.1/tests/test_project_config.py +42 -0
- codex_telegram-0.6.1/tests/test_project_routes.py +59 -0
- {codex_telegram-0.6.0 → codex_telegram-0.6.1}/tests/test_web_server_local_command.py +44 -0
- {codex_telegram-0.6.0 → codex_telegram-0.6.1}/utils/config.py +65 -2
- {codex_telegram-0.6.0 → codex_telegram-0.6.1}/web/routes.py +49 -12
- codex_telegram-0.6.0/web/static/dist/assets/index-wjzNlOuf.js → codex_telegram-0.6.1/web/static/dist/assets/index-0u8Hjl9v.js +27 -27
- codex_telegram-0.6.1/web/static/dist/assets/index-Bh3KHDwf.css +1 -0
- {codex_telegram-0.6.0 → codex_telegram-0.6.1}/web/static/dist/index.html +2 -2
- codex_telegram-0.6.0/web/static/dist/assets/index-Cb6qZVCI.css +0 -1
- {codex_telegram-0.6.0 → codex_telegram-0.6.1}/LICENSE +0 -0
- {codex_telegram-0.6.0 → codex_telegram-0.6.1}/MANIFEST.in +0 -0
- {codex_telegram-0.6.0 → codex_telegram-0.6.1}/TELEGRAM.md +0 -0
- {codex_telegram-0.6.0 → codex_telegram-0.6.1}/app_runtime/__init__.py +0 -0
- {codex_telegram-0.6.0 → codex_telegram-0.6.1}/app_runtime/bootstrap.py +0 -0
- {codex_telegram-0.6.0 → codex_telegram-0.6.1}/app_runtime/telegram_app.py +0 -0
- {codex_telegram-0.6.0 → codex_telegram-0.6.1}/app_runtime/web_server.py +0 -0
- {codex_telegram-0.6.0 → codex_telegram-0.6.1}/bot/__init__.py +0 -0
- {codex_telegram-0.6.0 → codex_telegram-0.6.1}/bot/callbacks.py +0 -0
- {codex_telegram-0.6.0 → codex_telegram-0.6.1}/bot/features_ui.py +0 -0
- {codex_telegram-0.6.0 → codex_telegram-0.6.1}/bot/handlers.py +0 -0
- {codex_telegram-0.6.0 → codex_telegram-0.6.1}/bot/keyboard.py +0 -0
- {codex_telegram-0.6.0 → codex_telegram-0.6.1}/bot/projects_ui.py +0 -0
- {codex_telegram-0.6.0 → codex_telegram-0.6.1}/bot/skills_ui.py +0 -0
- {codex_telegram-0.6.0 → codex_telegram-0.6.1}/bot/thread_ui.py +0 -0
- {codex_telegram-0.6.0 → codex_telegram-0.6.1}/codex/__init__.py +0 -0
- {codex_telegram-0.6.0 → codex_telegram-0.6.1}/codex/approval_flow.py +0 -0
- {codex_telegram-0.6.0 → codex_telegram-0.6.1}/codex/approval_guardian.py +0 -0
- {codex_telegram-0.6.0 → codex_telegram-0.6.1}/codex/client.py +0 -0
- {codex_telegram-0.6.0 → codex_telegram-0.6.1}/codex/client_pool.py +0 -0
- {codex_telegram-0.6.0 → codex_telegram-0.6.1}/codex/collaboration_mode.py +0 -0
- {codex_telegram-0.6.0 → codex_telegram-0.6.1}/codex/command_router/__init__.py +0 -0
- {codex_telegram-0.6.0 → codex_telegram-0.6.1}/codex/command_router/common.py +0 -0
- {codex_telegram-0.6.0 → codex_telegram-0.6.1}/codex/command_router/context.py +0 -0
- {codex_telegram-0.6.0 → codex_telegram-0.6.1}/codex/command_router/contracts.py +0 -0
- {codex_telegram-0.6.0 → codex_telegram-0.6.1}/codex/command_router/core.py +0 -0
- {codex_telegram-0.6.0 → codex_telegram-0.6.1}/codex/command_router/projects.py +0 -0
- {codex_telegram-0.6.0 → codex_telegram-0.6.1}/codex/command_router/review.py +0 -0
- {codex_telegram-0.6.0 → codex_telegram-0.6.1}/codex/command_router/system.py +0 -0
- {codex_telegram-0.6.0 → codex_telegram-0.6.1}/codex/command_router/threads.py +0 -0
- {codex_telegram-0.6.0 → codex_telegram-0.6.1}/codex/commands.py +0 -0
- {codex_telegram-0.6.0 → codex_telegram-0.6.1}/codex/event_forwarding.py +0 -0
- {codex_telegram-0.6.0 → codex_telegram-0.6.1}/codex/events.py +0 -0
- {codex_telegram-0.6.0 → codex_telegram-0.6.1}/codex/protocol.py +0 -0
- {codex_telegram-0.6.0 → codex_telegram-0.6.1}/codex_telegram/cli.py +0 -0
- {codex_telegram-0.6.0 → codex_telegram-0.6.1}/codex_telegram.egg-info/dependency_links.txt +0 -0
- {codex_telegram-0.6.0 → codex_telegram-0.6.1}/codex_telegram.egg-info/entry_points.txt +0 -0
- {codex_telegram-0.6.0 → codex_telegram-0.6.1}/codex_telegram.egg-info/requires.txt +0 -0
- {codex_telegram-0.6.0 → codex_telegram-0.6.1}/codex_telegram.egg-info/top_level.txt +0 -0
- {codex_telegram-0.6.0 → codex_telegram-0.6.1}/conf.toml.example +0 -0
- {codex_telegram-0.6.0 → codex_telegram-0.6.1}/main.py +0 -0
- {codex_telegram-0.6.0 → codex_telegram-0.6.1}/models/__init__.py +0 -0
- {codex_telegram-0.6.0 → codex_telegram-0.6.1}/models/state.py +0 -0
- {codex_telegram-0.6.0 → codex_telegram-0.6.1}/models/telegram_bridge.py +0 -0
- {codex_telegram-0.6.0 → codex_telegram-0.6.1}/models/thread.py +0 -0
- {codex_telegram-0.6.0 → codex_telegram-0.6.1}/models/user.py +0 -0
- {codex_telegram-0.6.0 → codex_telegram-0.6.1}/pyproject.toml +0 -0
- {codex_telegram-0.6.0 → codex_telegram-0.6.1}/setup.cfg +0 -0
- {codex_telegram-0.6.0 → codex_telegram-0.6.1}/tests/test_approval_guardian.py +0 -0
- {codex_telegram-0.6.0 → codex_telegram-0.6.1}/tests/test_approval_policy.py +0 -0
- {codex_telegram-0.6.0 → codex_telegram-0.6.1}/tests/test_bootstrap_update_check.py +0 -0
- {codex_telegram-0.6.0 → codex_telegram-0.6.1}/tests/test_callbacks_results.py +0 -0
- {codex_telegram-0.6.0 → codex_telegram-0.6.1}/tests/test_cli_entrypoint.py +0 -0
- {codex_telegram-0.6.0 → codex_telegram-0.6.1}/tests/test_codex_client.py +0 -0
- {codex_telegram-0.6.0 → codex_telegram-0.6.1}/tests/test_codex_client_pool.py +0 -0
- {codex_telegram-0.6.0 → codex_telegram-0.6.1}/tests/test_command_router.py +0 -0
- {codex_telegram-0.6.0 → codex_telegram-0.6.1}/tests/test_guardian_config.py +0 -0
- {codex_telegram-0.6.0 → codex_telegram-0.6.1}/tests/test_handlers_results.py +0 -0
- {codex_telegram-0.6.0 → codex_telegram-0.6.1}/tests/test_keyboard.py +0 -0
- {codex_telegram-0.6.0 → codex_telegram-0.6.1}/tests/test_main_approval_flow.py +0 -0
- {codex_telegram-0.6.0 → codex_telegram-0.6.1}/tests/test_normalize.py +0 -0
- {codex_telegram-0.6.0 → codex_telegram-0.6.1}/tests/test_pypi_check.py +0 -0
- {codex_telegram-0.6.0 → codex_telegram-0.6.1}/tests/test_single_instance.py +0 -0
- {codex_telegram-0.6.0 → codex_telegram-0.6.1}/tests/test_telegram_bot_config.py +0 -0
- {codex_telegram-0.6.0 → codex_telegram-0.6.1}/tests/test_thread_history.py +0 -0
- {codex_telegram-0.6.0 → codex_telegram-0.6.1}/tests/test_web_runtime.py +0 -0
- {codex_telegram-0.6.0 → codex_telegram-0.6.1}/utils/__init__.py +0 -0
- {codex_telegram-0.6.0 → codex_telegram-0.6.1}/utils/approval_policy.py +0 -0
- {codex_telegram-0.6.0 → codex_telegram-0.6.1}/utils/local_command.py +0 -0
- {codex_telegram-0.6.0 → codex_telegram-0.6.1}/utils/logger.py +0 -0
- {codex_telegram-0.6.0 → codex_telegram-0.6.1}/utils/normalize.py +0 -0
- {codex_telegram-0.6.0 → codex_telegram-0.6.1}/utils/pypi_check.py +0 -0
- {codex_telegram-0.6.0 → codex_telegram-0.6.1}/utils/single_instance.py +0 -0
- {codex_telegram-0.6.0 → codex_telegram-0.6.1}/utils/web_config.py +0 -0
- {codex_telegram-0.6.0 → codex_telegram-0.6.1}/web/__init__.py +0 -0
- {codex_telegram-0.6.0 → codex_telegram-0.6.1}/web/dependencies.py +0 -0
- {codex_telegram-0.6.0 → codex_telegram-0.6.1}/web/runtime.py +0 -0
- {codex_telegram-0.6.0 → codex_telegram-0.6.1}/web/server.py +0 -0
- {codex_telegram-0.6.0 → codex_telegram-0.6.1}/web/static/dist/assets/codex-telegram-icon-black.svg +0 -0
- {codex_telegram-0.6.0 → codex_telegram-0.6.1}/web/static/dist/assets/codex-telegram-icon-ivory.svg +0 -0
- {codex_telegram-0.6.0 → codex_telegram-0.6.1}/web/static/dist/assets/codex-telegram-login-image.png +0 -0
- {codex_telegram-0.6.0 → codex_telegram-0.6.1}/web/static/dist/favicon.ico +0 -0
- {codex_telegram-0.6.0 → codex_telegram-0.6.1}/web/telegram_sync.py +0 -0
- {codex_telegram-0.6.0 → codex_telegram-0.6.1}/web/telegram_thread_selection.py +0 -0
- {codex_telegram-0.6.0 → codex_telegram-0.6.1}/web/thread_history.py +0 -0
- {codex_telegram-0.6.0 → codex_telegram-0.6.1}/web/workspace.py +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: codex-telegram
|
|
3
|
-
Version: 0.6.
|
|
3
|
+
Version: 0.6.1
|
|
4
4
|
Summary: Bridge for controlling the Codex App Server from Telegram and Web UI
|
|
5
5
|
Author: heungtae
|
|
6
6
|
License: Apache License
|
|
@@ -382,6 +382,17 @@ cp conf.toml.example ~/.config/codex-telegram/conf.toml
|
|
|
382
382
|
./run_build_and_main.sh
|
|
383
383
|
```
|
|
384
384
|
|
|
385
|
+
## Publishing a Release
|
|
386
|
+
|
|
387
|
+
1. Update `codex_telegram/__init__.py` (`__version__`), validate the changes, and push the commits.
|
|
388
|
+
2. In GitHub Releases, create a draft with a matching tag (for example, `v0.6.0` for version `0.6.0`) targeting the intended commit. The commit must contain `.github/workflows/pypi-publish.yml` with the release trigger.
|
|
389
|
+
3. Write the release notes and publish the Release. Drafts and tag pushes alone do not deploy.
|
|
390
|
+
4. Wait for **Publish release to PyPI** to succeed: it builds the Web UI, builds/checks the wheel and sdist (including UI assets), publishes to PyPI, then attaches the same distributions and `SHA256SUMS` to the Release.
|
|
391
|
+
|
|
392
|
+
Configure `PYPI_API_TOKEN` as a repository secret or a secret on the `pypi` environment before publishing. Existing environment protection rules still apply; remove required reviewers from that environment if fully automatic publishing is desired. Published prereleases also trigger this workflow, so use a matching Python prerelease version such as `0.6.1rc1` / `v0.6.1rc1`.
|
|
393
|
+
|
|
394
|
+
A published GitHub Release alone does not mean PyPI deployment succeeded; check the workflow result. If attachment fails after PyPI publishing succeeds, rerun only failed jobs to attach the retained files without republishing. For code changes after a successful PyPI upload, use a new version and Release.
|
|
395
|
+
|
|
385
396
|
## Documentation
|
|
386
397
|
|
|
387
398
|
| File | Description |
|
|
@@ -157,6 +157,17 @@ cp conf.toml.example ~/.config/codex-telegram/conf.toml
|
|
|
157
157
|
./run_build_and_main.sh
|
|
158
158
|
```
|
|
159
159
|
|
|
160
|
+
## Publishing a Release
|
|
161
|
+
|
|
162
|
+
1. Update `codex_telegram/__init__.py` (`__version__`), validate the changes, and push the commits.
|
|
163
|
+
2. In GitHub Releases, create a draft with a matching tag (for example, `v0.6.0` for version `0.6.0`) targeting the intended commit. The commit must contain `.github/workflows/pypi-publish.yml` with the release trigger.
|
|
164
|
+
3. Write the release notes and publish the Release. Drafts and tag pushes alone do not deploy.
|
|
165
|
+
4. Wait for **Publish release to PyPI** to succeed: it builds the Web UI, builds/checks the wheel and sdist (including UI assets), publishes to PyPI, then attaches the same distributions and `SHA256SUMS` to the Release.
|
|
166
|
+
|
|
167
|
+
Configure `PYPI_API_TOKEN` as a repository secret or a secret on the `pypi` environment before publishing. Existing environment protection rules still apply; remove required reviewers from that environment if fully automatic publishing is desired. Published prereleases also trigger this workflow, so use a matching Python prerelease version such as `0.6.1rc1` / `v0.6.1rc1`.
|
|
168
|
+
|
|
169
|
+
A published GitHub Release alone does not mean PyPI deployment succeeded; check the workflow result. If attachment fails after PyPI publishing succeeds, rerun only failed jobs to attach the retained files without republishing. For code changes after a successful PyPI upload, use a new version and Release.
|
|
170
|
+
|
|
160
171
|
## Documentation
|
|
161
172
|
|
|
162
173
|
| File | Description |
|
|
@@ -20,6 +20,7 @@ A ChatGPT-style web interface for interacting with Codex App Server. Provides re
|
|
|
20
20
|
- **Thread tabs**: Multiple conversations per project, each with independent chat state
|
|
21
21
|
- **Thread states**: Visual indicators for idle, running, completed, failed, or cancelled
|
|
22
22
|
- **Unread notifications**: Badge indicators for new completions
|
|
23
|
+
- **Project management**: Use the project menu → Modify Project to edit its name/path. The key remains fixed to preserve chat associations. Add/Modify Project includes a Default project checkbox. The default project is marked D and its Remove menu is disabled; choose another default before removing it. Changing the default updates the shared configuration without switching existing open chats.
|
|
23
24
|
- **Persistent state**: Thread and project tabs preserved across sessions
|
|
24
25
|
|
|
25
26
|
### Workspace Browser
|
|
@@ -162,7 +163,9 @@ Guardian provides automated safety checks before approval decisions:
|
|
|
162
163
|
| Method | Endpoint | Description |
|
|
163
164
|
|--------|----------|-------------|
|
|
164
165
|
| GET | `/api/projects` | List projects |
|
|
165
|
-
| POST | `/api/projects` | Add project |
|
|
166
|
+
| POST | `/api/projects` | Add project (`default: true` optionally sets the default) |
|
|
167
|
+
| PUT | `/api/projects/{key}` | Modify name/path and optionally set default |
|
|
168
|
+
| DELETE | `/api/projects/{key}` | Remove a non-default project |
|
|
166
169
|
| POST | `/api/projects/select` | Switch project |
|
|
167
170
|
| POST | `/api/projects/open-thread` | Open thread in project |
|
|
168
171
|
| POST | `/api/projects/open-explorer` | Open project folder in OS file explorer |
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: codex-telegram
|
|
3
|
-
Version: 0.6.
|
|
3
|
+
Version: 0.6.1
|
|
4
4
|
Summary: Bridge for controlling the Codex App Server from Telegram and Web UI
|
|
5
5
|
Author: heungtae
|
|
6
6
|
License: Apache License
|
|
@@ -382,6 +382,17 @@ cp conf.toml.example ~/.config/codex-telegram/conf.toml
|
|
|
382
382
|
./run_build_and_main.sh
|
|
383
383
|
```
|
|
384
384
|
|
|
385
|
+
## Publishing a Release
|
|
386
|
+
|
|
387
|
+
1. Update `codex_telegram/__init__.py` (`__version__`), validate the changes, and push the commits.
|
|
388
|
+
2. In GitHub Releases, create a draft with a matching tag (for example, `v0.6.0` for version `0.6.0`) targeting the intended commit. The commit must contain `.github/workflows/pypi-publish.yml` with the release trigger.
|
|
389
|
+
3. Write the release notes and publish the Release. Drafts and tag pushes alone do not deploy.
|
|
390
|
+
4. Wait for **Publish release to PyPI** to succeed: it builds the Web UI, builds/checks the wheel and sdist (including UI assets), publishes to PyPI, then attaches the same distributions and `SHA256SUMS` to the Release.
|
|
391
|
+
|
|
392
|
+
Configure `PYPI_API_TOKEN` as a repository secret or a secret on the `pypi` environment before publishing. Existing environment protection rules still apply; remove required reviewers from that environment if fully automatic publishing is desired. Published prereleases also trigger this workflow, so use a matching Python prerelease version such as `0.6.1rc1` / `v0.6.1rc1`.
|
|
393
|
+
|
|
394
|
+
A published GitHub Release alone does not mean PyPI deployment succeeded; check the workflow result. If attachment fails after PyPI publishing succeeds, rerun only failed jobs to attach the retained files without republishing. For code changes after a successful PyPI upload, use a new version and Release.
|
|
395
|
+
|
|
385
396
|
## Documentation
|
|
386
397
|
|
|
387
398
|
| File | Description |
|
|
@@ -63,6 +63,8 @@ tests/test_handlers_results.py
|
|
|
63
63
|
tests/test_keyboard.py
|
|
64
64
|
tests/test_main_approval_flow.py
|
|
65
65
|
tests/test_normalize.py
|
|
66
|
+
tests/test_project_config.py
|
|
67
|
+
tests/test_project_routes.py
|
|
66
68
|
tests/test_pypi_check.py
|
|
67
69
|
tests/test_single_instance.py
|
|
68
70
|
tests/test_telegram_bot_config.py
|
|
@@ -92,5 +94,5 @@ web/static/dist/index.html
|
|
|
92
94
|
web/static/dist/assets/codex-telegram-icon-black.svg
|
|
93
95
|
web/static/dist/assets/codex-telegram-icon-ivory.svg
|
|
94
96
|
web/static/dist/assets/codex-telegram-login-image.png
|
|
95
|
-
web/static/dist/assets/index-
|
|
96
|
-
web/static/dist/assets/index-
|
|
97
|
+
web/static/dist/assets/index-0u8Hjl9v.js
|
|
98
|
+
web/static/dist/assets/index-Bh3KHDwf.css
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
import tomllib
|
|
2
|
+
from unittest.mock import patch
|
|
3
|
+
|
|
4
|
+
import pytest
|
|
5
|
+
|
|
6
|
+
from utils.config import remove_project_profile, save_project_profile, update_project_profile
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
@pytest.fixture
|
|
10
|
+
def project_config(tmp_path):
|
|
11
|
+
path = tmp_path / "conf.toml"
|
|
12
|
+
path.write_text('project = "alpha"\n\n[projects.alpha]\nname = "Alpha"\npath = "/alpha"\nextra = true\n\n[web]\nenabled = true\n')
|
|
13
|
+
with patch("utils.config._get_config_path", return_value=path):
|
|
14
|
+
yield path
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
def test_modify_and_change_default_preserves_other_settings(project_config):
|
|
18
|
+
save_project_profile("beta", "Beta", "/beta")
|
|
19
|
+
update_project_profile("beta", 'New "Beta"', r"/new\beta", make_default=True)
|
|
20
|
+
parsed = tomllib.loads(project_config.read_text())
|
|
21
|
+
assert parsed["project"] == "beta"
|
|
22
|
+
assert parsed["projects"]["beta"] == {"name": 'New "Beta"', "path": r"/new\beta"}
|
|
23
|
+
assert parsed["projects"]["alpha"]["extra"] is True
|
|
24
|
+
assert parsed["web"]["enabled"] is True
|
|
25
|
+
with pytest.raises(ValueError, match="default project"):
|
|
26
|
+
remove_project_profile("beta")
|
|
27
|
+
remove_project_profile("alpha")
|
|
28
|
+
assert "alpha" not in tomllib.loads(project_config.read_text())["projects"]
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
def test_add_default_and_modify_without_unsetting_default(project_config):
|
|
32
|
+
save_project_profile("beta", "Beta", "/beta", make_default=True)
|
|
33
|
+
update_project_profile("beta", "Changed", "/changed")
|
|
34
|
+
assert tomllib.loads(project_config.read_text())["project"] == "beta"
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
@pytest.mark.parametrize("key,name,path", [("missing", "Name", "/path"), ("alpha", " ", "/path"), ("alpha", "Name", " ")])
|
|
38
|
+
def test_invalid_modify_does_not_write(project_config, key, name, path):
|
|
39
|
+
original = project_config.read_text()
|
|
40
|
+
with pytest.raises(ValueError):
|
|
41
|
+
update_project_profile(key, name, path, make_default=True)
|
|
42
|
+
assert project_config.read_text() == original
|
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
import asyncio
|
|
2
|
+
import unittest
|
|
3
|
+
from types import SimpleNamespace
|
|
4
|
+
from unittest.mock import AsyncMock, patch
|
|
5
|
+
|
|
6
|
+
from fastapi import FastAPI, HTTPException
|
|
7
|
+
|
|
8
|
+
from web.routes import register_project_routes
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
class ProjectRouteTests(unittest.TestCase):
|
|
12
|
+
def setUp(self):
|
|
13
|
+
app = FastAPI()
|
|
14
|
+
register_project_routes(app)
|
|
15
|
+
self.modify = next(route.endpoint for route in app.routes if "PUT" in getattr(route, "methods", set()))
|
|
16
|
+
self.add = next(route.endpoint for route in app.routes if getattr(route, "path", "") == "/api/projects" and "POST" in route.methods)
|
|
17
|
+
|
|
18
|
+
def test_modify_saves_default_and_reloads(self):
|
|
19
|
+
with (
|
|
20
|
+
patch("web.routes.session_from_request", new=AsyncMock(return_value=SimpleNamespace(user_id=1))),
|
|
21
|
+
patch("web.routes.update_project_profile") as save,
|
|
22
|
+
patch("web.routes.reload") as reload_config,
|
|
23
|
+
patch("web.routes.route_command", new=AsyncMock(return_value={"ok": True})),
|
|
24
|
+
):
|
|
25
|
+
result = asyncio.run(self.modify("alpha", {"name": "Alpha", "path": "/alpha", "default": True}, object()))
|
|
26
|
+
save.assert_called_once_with("alpha", "Alpha", "/alpha", make_default=True)
|
|
27
|
+
reload_config.assert_called_once()
|
|
28
|
+
self.assertTrue(result["ok"])
|
|
29
|
+
|
|
30
|
+
def test_add_can_set_default(self):
|
|
31
|
+
with (
|
|
32
|
+
patch("web.routes.session_from_request", new=AsyncMock(return_value=SimpleNamespace(user_id=1))),
|
|
33
|
+
patch("web.routes.save_project_profile") as save,
|
|
34
|
+
patch("web.routes.reload"),
|
|
35
|
+
patch("web.routes.route_command", new=AsyncMock(return_value={})),
|
|
36
|
+
):
|
|
37
|
+
asyncio.run(self.add({"key": "alpha", "name": "Alpha", "path": "/alpha", "default": True}, object()))
|
|
38
|
+
save.assert_called_once_with("alpha", "Alpha", "/alpha", make_default=True)
|
|
39
|
+
|
|
40
|
+
def test_modify_reports_invalid_project(self):
|
|
41
|
+
with (
|
|
42
|
+
patch("web.routes.session_from_request", new=AsyncMock(return_value=SimpleNamespace(user_id=1))),
|
|
43
|
+
patch("web.routes.update_project_profile", side_effect=ValueError("not found")),
|
|
44
|
+
patch("web.routes.reload") as reload_config,
|
|
45
|
+
):
|
|
46
|
+
with self.assertRaises(HTTPException) as raised:
|
|
47
|
+
asyncio.run(self.modify("missing", {"name": "Name", "path": "/path"}, object()))
|
|
48
|
+
self.assertEqual(400, raised.exception.status_code)
|
|
49
|
+
reload_config.assert_not_called()
|
|
50
|
+
|
|
51
|
+
def test_modify_requires_authentication_before_writing(self):
|
|
52
|
+
with (
|
|
53
|
+
patch("web.routes.session_from_request", new=AsyncMock(side_effect=HTTPException(401))),
|
|
54
|
+
patch("web.routes.update_project_profile") as save,
|
|
55
|
+
):
|
|
56
|
+
with self.assertRaises(HTTPException) as raised:
|
|
57
|
+
asyncio.run(self.modify("alpha", {"name": "Name", "path": "/path"}, object()))
|
|
58
|
+
self.assertEqual(401, raised.exception.status_code)
|
|
59
|
+
save.assert_not_called()
|
|
@@ -1274,6 +1274,50 @@ path_glob_any = ["helm/**"]
|
|
|
1274
1274
|
body["items"],
|
|
1275
1275
|
)
|
|
1276
1276
|
|
|
1277
|
+
def test_thread_summaries_paginates_before_project_limit_and_offset(self):
|
|
1278
|
+
app = create_web_app()
|
|
1279
|
+
endpoint = next(route.endpoint for route in app.routes
|
|
1280
|
+
if getattr(route, "path", None) == "/api/threads/summaries")
|
|
1281
|
+
request = SimpleNamespace(cookies={COOKIE_NAME: self.session.token})
|
|
1282
|
+
user_manager.get(self.session.user_id).selected_project_key = "default"
|
|
1283
|
+
for tid in ("t-1", "t-2", "t-3"):
|
|
1284
|
+
user_manager.bind_thread_project(tid, "default")
|
|
1285
|
+
user_manager.bind_thread_project("other", "other")
|
|
1286
|
+
for offset, expected in ((0, ["t-1", "t-2"]), (1, ["t-2", "t-3"])):
|
|
1287
|
+
with self.subTest(offset=offset):
|
|
1288
|
+
state.codex_client.call = AsyncMock(side_effect=[
|
|
1289
|
+
{"data": [{"id": "other"}], "nextCursor": "page-2"},
|
|
1290
|
+
{"data": [{"id": "t-1"}], "nextCursor": "page-3"},
|
|
1291
|
+
{"data": [{"id": "t-1"}, {"id": "t-2"}, {"id": "t-3"}],
|
|
1292
|
+
"nextCursor": "unused"},
|
|
1293
|
+
{"thread": {}}, {"thread": {}},
|
|
1294
|
+
])
|
|
1295
|
+
body = asyncio.run(endpoint(request, limit=2, offset=offset))
|
|
1296
|
+
self.assertEqual(expected, [item["id"] for item in body["items"]])
|
|
1297
|
+
list_calls = [c for c in state.codex_client.call.await_args_list
|
|
1298
|
+
if c.args[0] == "thread/list"]
|
|
1299
|
+
self.assertEqual(3, len(list_calls))
|
|
1300
|
+
self.assertNotIn("cursor", list_calls[0].args[1])
|
|
1301
|
+
self.assertEqual("page-2", list_calls[1].args[1]["cursor"])
|
|
1302
|
+
self.assertEqual("page-3", list_calls[2].args[1]["cursor"])
|
|
1303
|
+
|
|
1304
|
+
def test_thread_summaries_stops_on_exhaustion_or_repeated_cursor(self):
|
|
1305
|
+
app = create_web_app()
|
|
1306
|
+
endpoint = next(route.endpoint for route in app.routes
|
|
1307
|
+
if getattr(route, "path", None) == "/api/threads/summaries")
|
|
1308
|
+
request = SimpleNamespace(cookies={COOKIE_NAME: self.session.token})
|
|
1309
|
+
user_manager.get(self.session.user_id).selected_project_key = "default"
|
|
1310
|
+
user_manager.bind_thread_project("other", "other")
|
|
1311
|
+
for page in ({"data": [{"id": "other"}]},
|
|
1312
|
+
{"data": None, "nextCursor": "repeat"}):
|
|
1313
|
+
with self.subTest(page=page):
|
|
1314
|
+
state.codex_client.call = AsyncMock(return_value=page)
|
|
1315
|
+
body = asyncio.run(endpoint(request, limit=2, archived=True))
|
|
1316
|
+
self.assertEqual([], body["items"])
|
|
1317
|
+
self.assertEqual(2 if page.get("nextCursor") else 1,
|
|
1318
|
+
state.codex_client.call.await_count)
|
|
1319
|
+
self.assertTrue(state.codex_client.call.await_args.args[1]["archived"])
|
|
1320
|
+
|
|
1277
1321
|
def test_thread_summaries_use_user_request_excerpt_as_title(self):
|
|
1278
1322
|
app = create_web_app()
|
|
1279
1323
|
endpoint = next(
|
|
@@ -682,7 +682,9 @@ def save_guardian_settings(
|
|
|
682
682
|
return get_guardian_settings()
|
|
683
683
|
|
|
684
684
|
|
|
685
|
-
def save_project_profile(
|
|
685
|
+
def save_project_profile(
|
|
686
|
+
key: str, name: str, path: str, *, make_default: bool = False
|
|
687
|
+
) -> None:
|
|
686
688
|
if not re.fullmatch(r"[A-Za-z0-9_-]+", key):
|
|
687
689
|
raise ValueError("Invalid project key. Use letters, numbers, '_' or '-'.")
|
|
688
690
|
if not name.strip():
|
|
@@ -711,6 +713,64 @@ def save_project_profile(key: str, name: str, path: str) -> None:
|
|
|
711
713
|
f'path = "{_escape_toml_string(path)}"\n'
|
|
712
714
|
)
|
|
713
715
|
|
|
716
|
+
if make_default:
|
|
717
|
+
updated = _with_default_project(updated, key)
|
|
718
|
+
tomllib.loads(updated)
|
|
719
|
+
config_path.write_text(updated, encoding="utf-8")
|
|
720
|
+
|
|
721
|
+
|
|
722
|
+
def _with_default_project(raw: str, key: str) -> str:
|
|
723
|
+
lines = raw.splitlines(keepends=True)
|
|
724
|
+
for index, line in enumerate(lines):
|
|
725
|
+
if line.lstrip().startswith("["):
|
|
726
|
+
break
|
|
727
|
+
if re.match(r"^\s*project\s*=", line):
|
|
728
|
+
lines[index] = f'project = "{_escape_toml_string(key)}"\n'
|
|
729
|
+
return "".join(lines)
|
|
730
|
+
return f'project = "{_escape_toml_string(key)}"\n' + raw
|
|
731
|
+
|
|
732
|
+
|
|
733
|
+
def update_project_profile(
|
|
734
|
+
key: str, name: str, path: str, *, make_default: bool = False
|
|
735
|
+
) -> None:
|
|
736
|
+
if not name.strip() or not path.strip():
|
|
737
|
+
raise ValueError("Project name and path must not be empty.")
|
|
738
|
+
_ensure_config_exists()
|
|
739
|
+
config_path = _get_config_path()
|
|
740
|
+
raw = config_path.read_text(encoding="utf-8")
|
|
741
|
+
parsed = tomllib.loads(raw)
|
|
742
|
+
if key not in parsed.get("projects", {}):
|
|
743
|
+
raise ValueError(f"Project key '{key}' not found.")
|
|
744
|
+
lines = raw.splitlines(keepends=True)
|
|
745
|
+
start = None
|
|
746
|
+
end = len(lines)
|
|
747
|
+
for index, line in enumerate(lines):
|
|
748
|
+
if not line.lstrip().startswith("["):
|
|
749
|
+
continue
|
|
750
|
+
if start is not None:
|
|
751
|
+
end = index
|
|
752
|
+
break
|
|
753
|
+
try:
|
|
754
|
+
header = tomllib.loads(line.strip())
|
|
755
|
+
except tomllib.TOMLDecodeError:
|
|
756
|
+
continue
|
|
757
|
+
if header.get("projects", {}).get(key) == {}:
|
|
758
|
+
start = index
|
|
759
|
+
if start is None:
|
|
760
|
+
raise ValueError("Project table could not be updated.")
|
|
761
|
+
body = "".join(lines[start + 1:end])
|
|
762
|
+
for field, value in (("name", name), ("path", path)):
|
|
763
|
+
pattern = rf"(?m)^\s*{field}\s*=.*$"
|
|
764
|
+
replacement = f'{field} = "{_escape_toml_string(value)}"'
|
|
765
|
+
if re.search(pattern, body):
|
|
766
|
+
body = re.sub(pattern, lambda match: replacement, body)
|
|
767
|
+
else:
|
|
768
|
+
body = body.rstrip() + "\n" + replacement + "\n"
|
|
769
|
+
updated = "".join(lines[:start + 1]) + body + "".join(lines[end:])
|
|
770
|
+
tomllib.loads(updated)
|
|
771
|
+
if make_default:
|
|
772
|
+
updated = _with_default_project(updated, key)
|
|
773
|
+
tomllib.loads(updated)
|
|
714
774
|
config_path.write_text(updated, encoding="utf-8")
|
|
715
775
|
|
|
716
776
|
|
|
@@ -725,7 +785,10 @@ def remove_project_profile(key: str) -> None:
|
|
|
725
785
|
raise ValueError(f"Project key '{key}' not found.")
|
|
726
786
|
|
|
727
787
|
if parsed.get("project") == key:
|
|
728
|
-
raise ValueError(
|
|
788
|
+
raise ValueError(
|
|
789
|
+
f"Cannot remove the default project '{key}'. "
|
|
790
|
+
"Set another default project first."
|
|
791
|
+
)
|
|
729
792
|
|
|
730
793
|
lines = raw.splitlines(keepends=True)
|
|
731
794
|
result: list[str] = []
|
|
@@ -16,7 +16,14 @@ from codex_telegram import __version__
|
|
|
16
16
|
from codex.collaboration_mode import codex_mode_name
|
|
17
17
|
from models import state
|
|
18
18
|
from models.user import user_manager
|
|
19
|
-
from utils.config import
|
|
19
|
+
from utils.config import (
|
|
20
|
+
get,
|
|
21
|
+
get_web_password,
|
|
22
|
+
reload,
|
|
23
|
+
remove_project_profile,
|
|
24
|
+
save_project_profile,
|
|
25
|
+
update_project_profile,
|
|
26
|
+
)
|
|
20
27
|
from utils.local_command import resolve_command_cwd
|
|
21
28
|
from utils.normalize import clamp_int, parse_bool
|
|
22
29
|
from web.dependencies import (
|
|
@@ -322,10 +329,6 @@ def register_thread_routes(app: FastAPI) -> None:
|
|
|
322
329
|
params: dict[str, Any] = {"limit": min(100, safe_limit + safe_offset)}
|
|
323
330
|
if archived:
|
|
324
331
|
params["archived"] = True
|
|
325
|
-
result = await state.codex_client.call("thread/list", params)
|
|
326
|
-
rows = result.get("data", [])
|
|
327
|
-
if not isinstance(rows, list):
|
|
328
|
-
rows = []
|
|
329
332
|
state_user = user_manager.get(session.user_id)
|
|
330
333
|
current_profile_key = project_key.strip()
|
|
331
334
|
if not current_profile_key:
|
|
@@ -334,12 +337,30 @@ def register_thread_routes(app: FastAPI) -> None:
|
|
|
334
337
|
default_key = get("project")
|
|
335
338
|
if isinstance(default_key, str) and default_key.strip():
|
|
336
339
|
current_profile_key = default_key.strip()
|
|
337
|
-
|
|
338
|
-
|
|
339
|
-
|
|
340
|
-
|
|
341
|
-
|
|
342
|
-
|
|
340
|
+
# Apply the limit to matching threads, rather than the global first page.
|
|
341
|
+
rows: list[dict[str, Any]] = []
|
|
342
|
+
seen_ids: set[str] = set()
|
|
343
|
+
seen_cursors: set[str] = set()
|
|
344
|
+
while len(rows) < safe_offset + safe_limit:
|
|
345
|
+
result = await state.codex_client.call("thread/list", dict(params))
|
|
346
|
+
page = result.get("data", [])
|
|
347
|
+
if not isinstance(page, list):
|
|
348
|
+
page = []
|
|
349
|
+
for thread in page:
|
|
350
|
+
if not isinstance(thread, dict):
|
|
351
|
+
continue
|
|
352
|
+
tid = thread.get("id")
|
|
353
|
+
if not isinstance(tid, str) or not tid or tid in seen_ids:
|
|
354
|
+
continue
|
|
355
|
+
seen_ids.add(tid)
|
|
356
|
+
if current_profile_key and thread_profile_key(thread, current_profile_key) != current_profile_key:
|
|
357
|
+
continue
|
|
358
|
+
rows.append(thread)
|
|
359
|
+
cursor = result.get("nextCursor")
|
|
360
|
+
if not isinstance(cursor, str) or not cursor or cursor in seen_cursors:
|
|
361
|
+
break
|
|
362
|
+
seen_cursors.add(cursor)
|
|
363
|
+
params["cursor"] = cursor
|
|
343
364
|
if safe_offset:
|
|
344
365
|
rows = rows[safe_offset:]
|
|
345
366
|
rows = rows[:safe_limit]
|
|
@@ -692,7 +713,23 @@ def register_project_routes(app: FastAPI) -> None:
|
|
|
692
713
|
name = _required_str(payload, "name")
|
|
693
714
|
path = _required_str(payload, "path")
|
|
694
715
|
try:
|
|
695
|
-
save_project_profile(key, name, path)
|
|
716
|
+
save_project_profile(key, name, path, make_default=payload.get("default") is True)
|
|
717
|
+
reload()
|
|
718
|
+
except ValueError as exc:
|
|
719
|
+
raise HTTPException(status_code=400, detail=str(exc)) from exc
|
|
720
|
+
return await route_command("/projects", ["--list"], session.user_id)
|
|
721
|
+
|
|
722
|
+
@app.put("/api/projects/{project_key}")
|
|
723
|
+
async def modify_project(
|
|
724
|
+
project_key: str, payload: dict[str, Any], request: Request
|
|
725
|
+
) -> dict[str, Any]:
|
|
726
|
+
session = await session_from_request(request)
|
|
727
|
+
name = _required_str(payload, "name")
|
|
728
|
+
path = _required_str(payload, "path")
|
|
729
|
+
try:
|
|
730
|
+
update_project_profile(
|
|
731
|
+
project_key, name, path, make_default=payload.get("default") is True
|
|
732
|
+
)
|
|
696
733
|
reload()
|
|
697
734
|
except ValueError as exc:
|
|
698
735
|
raise HTTPException(status_code=400, detail=str(exc)) from exc
|