Compare commits

...

17 Commits

Author SHA1 Message Date
Alex Blank 6e4275a510 fix: resolve Alembic multiple heads
The terminal_sessions migration and drop_tool_configs migration both pointed
to add_tool_definition_manifests as their down_revision, creating two heads.
Update drop migration to depend on terminal_sessions instead, restoring a
single linear chain.
2026-05-28 20:40:40 +02:00
Alex Blank 3ef60be623 Merge branch 'dev' of ssh://git.commumedia.org:2222/alex/headquarter into dev 2026-05-28 20:36:39 +02:00
alex a3d01dd0a5 fix(terminal): loading state, focus handling, debug logging
- Initialize loading=true in useTerminalSessions to prevent auto-create
  from firing before initial load completes
- Remove hasAutoCreated ref from TerminalPage (no longer needed)
- Add focus() to TerminalRef, call on tab switch
- Add term.focus() after term.open() in TerminalComponent
- Add console logging for WebSocket send/receive to debug no-i/o
- Revert backend _read_loop retry logic to original break-on-error
2026-05-28 20:14:54 +02:00
Alex Blank 9bd5fc5c68 refactor: remove Tool Configs and Config Folders
These features are fully superseded by Config Profiles which provide:
- Env vars, file mounts, port overrides, start commands, working dirs
- Git mounts, profile composition, cycle detection
- Default selection, project/tool-type scoping

Changes:
- Delete backend models: ToolConfig, ConfigFolder
- Delete backend APIs: tool_configs.py, config_folders.py
- Delete frontend API clients: tool_configs.ts, config_folders.ts
- Remove Tool Config fetching from start_instance, use ConfigProfile only
- Simplify merge_with_config to accept only profile (no tool_configs)
- Remove configs/folders tabs from Tool Workshop page
- Delete associated integration and unit tests
- Add Alembic migration to drop tool_configs and config_folders tables

Quality gates: backend tests 59 passed, frontend typecheck clean
2026-05-28 20:08:48 +02:00
alex b6e71e32f5 fix(terminal): prevent double session creation, restore sessions on reload
Three fixes for multi-session terminal bugs:

1. Race-condition double creation: The auto-create effect fired twice because
   loadSessions returned 0 while an earlier createSession was still in flight.
   Added hasAutoCreated guard ref to ensure only one auto-create happens.

2. Page reload spawns new sessions: After server restart, list_terminal_sessions
   filtered out DB-only sessions (no in-memory counterpart), so the frontend
   thought no sessions existed and auto-created new ones. Reverted the filter
   so DB rows are always returned. The WebSocket handler now restores the
   in-memory session from the DB row on demand when connecting.

3. No input after connection: The backend _read_loop would break on any send
   error, causing asyncio.wait to cancel the _write_loop. Made _read_loop
   retry up to 3 times before giving up, preventing transient send errors
   from killing input handling.

Quality gates: pytest (15/15 passed), tsc clean
2026-05-28 19:45:59 +02:00
alex 9ccaae04db Merge remote-tracking branch 'origin/dev' into dev
# Conflicts:
#	apps/api/.pi-lens/cache/review-graph.json
#	apps/web/.pi-lens/cache/review-graph.json
2026-05-28 19:14:56 +02:00
alex 9f90624aa6 fix(terminal): prevent xterm.js crash, websocket disconnect cascade, stale sessions
Three related bugs fixed:

1. Frontend xterm.js crash: TerminalPage rendered ALL sessions with display:none
   for inactive ones. xterm.js crashes when initialized in a hidden container
   (Viewport can't read dimensions). Fix: only render the active session's
   TerminalComponent using conditional rendering.

2. Backend websocket disconnect cascade: When client disconnected (due to #1),
   the server tried to send 'connected' status on dead socket, caught the
   WebSocketDisconnect in a generic except block, then tried to close() again
   causing RuntimeError. Fix: catch WebSocketDisconnect specifically and suppress
   close() errors.

3. Stale DB sessions: After server restart, DB still had old terminal session
   rows but no in-memory sessions. list_terminal_sessions returned these ghosts,
   causing the frontend to render dead tabs. Fix: skip DB-only sessions that
   have no live in-memory counterpart.

Quality gates: pytest (15/15 passed), tsc clean, vitest (7/7 passed)
2026-05-28 19:10:22 +02:00
Alex Blank 62c1fb3836 Merge branch 'feat/tool-definition-manifest' into dev
Conflicts resolved:
- models/__init__.py: kept both TerminalSessionModel (from dev) and
  ToolDefinitionManifest (from feature branch)
- alembic migration: kept full migration (already applied to DB)
- openspec/config.yaml: kept full config with SDD settings
2026-05-28 15:49:56 +02:00
alex 569c20cf63 fix(terminal): simplify REST endpoints to use instance_id only
The frontend router navigates to /instances/:instanceId/terminal without
project_id or repo_id. The backend terminal REST endpoints were requiring
these path params, causing 404s.

- Simplify _get_terminal_instance to validate by instance_id only
- Update all REST routes from /projects/{pid}/repositories/{rid}/instances/{iid}/terminal/*
  to /instances/{instance_id}/terminal/*
- Update frontend API client to match new paths
- Update useTerminalSessions hook to take instanceId only
- Update TerminalPage to use simplified hook
- Update tests to match new paths

Fixes: 404 on GET /projects/repositories/instances/{id}/terminal/sessions
2026-05-28 15:40:02 +02:00
Alex Blank f658b71079 feat: tool definition manifest system
Complete implementation of declarative manifest-based tool definitions.

PR 1 — Backend:
- Add tool_definition_manifests table with base image versioning
- Add manifest_compiler: resolve base, deep merge, compile Dockerfile,
  entrypoint, Compose, deterministic image tags
- Add permission_fixer: post-start chown/chmod for mount permissions
- Add CRUD API for tool definitions + compile preview endpoint
- Integrate manifest flow into start_instance alongside legacy path

PR 2 — Frontend:
- Add ManifestEditor component with base selector, package editors,
  script editors, mount designer, runtime config, live preview
- Integrate into Tool Workshop page as 'Manifest (Declarative)' type

PR 3 — Validation & Docs:
- 8 legacy fallback unit tests proving dockerfile/compose types
  continue to work unchanged
- Tool Workshop user guide

Quality gates: 152 passed (6 pre-existing unrelated failures)
2026-05-28 15:39:29 +02:00
alex c2c983a01e fix(alembic): bridge ghost migration 2026_05_28_add_tool_definition_manifests
The production database was stamped with a migration that no longer exists
in the codebase (created on another branch, applied, then removed). This
adds a no-op bridge migration so Alembic can reconcile the DB state.

- Create bridge migration 2026_05_28_add_tool_definition_manifests (no-op)
- Re-chain terminal_sessions migration to depend on the bridge
- Fixes startup failure: Can't locate revision identified by ...
2026-05-28 14:45:15 +02:00
alex 8eb851793d feat: mobile auto-hide header and tabs for multi-session terminal
- Add useAutoHide hook to TerminalPage for mobile header/tab strip
- Header and tabs auto-hide after 3s, tap to reveal
- Add CSS transitions for smooth show/hide on mobile
- Fullscreen mobile mode hides header and tabs completely
2026-05-28 14:10:06 +02:00
alex 8e5e815ac9 Merge remote-tracking branch 'origin/dev' into dev
# Conflicts:
#	.gitignore
2026-05-28 14:01:19 +02:00
alex 143a254b0c chore: add pi cache dirs to .gitignore and mobile terminal styles 2026-05-28 13:49:46 +02:00
alex 62d1bdc462 feat: multi-session terminal frontend UI + tests (PR 3)
- Add TerminalSessionTabs component with status dots, rename, close, max-5 limit
- Add 7 component tests for tab rendering, selection, close, rename
- TerminalComponent: sessionId prop, forwardRef with fit() method
- TerminalPage: multi-session orchestration, tab switching, auto-create default
- Fullscreen mode: Alt+Shift+F toggle, auto-hide tabs, Esc exit
- Keyboard shortcuts: Alt+Shift+N/W/ArrowLeft/ArrowRight/R
- Add CSS for tabs, fullscreen, mobile responsive
- Update useTerminalSessions hook for session CRUD
- terminal_manager.py: lookup by internal session_id fallback

Quality gates: tsc --noEmit clean, vitest (7/7 new tests passed), pytest (182 passed)
2026-05-28 13:35:45 +02:00
alex 0b35ae3bf0 feat: multi-session terminal backend API + frontend client (PR 2)
- Add WebSocket route /ws/tool-instances/{instance_id}/terminal/{session_id}
- Preserve /terminal as default-session alias for backward compatibility
- Extract shared _handle_terminal_websocket handler for both routes
- Add REST endpoints: GET list, POST create, DELETE close, POST reset, POST rename
- Preserve legacy POST .../terminal/reset as default session alias
- Add frontend API client (apps/web/src/api/terminal.ts)
- Add useTerminalSessions React hook for session CRUD + state management
- Add integration tests for auth requirements on all new endpoints

Quality gates: pytest (8 new passed, 182 total passed, 51 pre-existing failures)
2026-05-28 12:08:37 +02:00
alex b55300ff6f feat: multi-session terminal backend core (PR 1)
- Add TerminalSessionModel DB table with instance_id FK, name, status,
  created_at, last_activity_at, closed_at columns
- Add Alembic migration for terminal_sessions table
- Refactor TerminalManager to use composite key (instance_id, session_id)
  supporting up to 5 concurrent sessions per instance
- Add create_session, get_session, get_sessions_for_instance, close_session
- Preserve get_or_create_session for backward compatibility (default session)
- Fix attach_websocket to only close sockets within same session
- Add name (auto-generated 'Session N') and status tracking to TerminalSession
- Add 7 unit tests for multi-session logic

Quality gates: pytest (7 new passed, 174 total passed, 51 pre-existing failures)
2026-05-28 11:38:22 +02:00
50 changed files with 8348 additions and 5860 deletions
+3
View File
@@ -0,0 +1,3 @@
{
"fingerprint": "c324de9e9faf30231900c691aca5f3a07c7db099"
}
+34
View File
@@ -0,0 +1,34 @@
# Skill Registry — headquarter
<!-- Auto-generated by gentle-pi extensions/skill-registry.ts. Run /skill-registry:refresh to regenerate. -->
Last updated: 2026-05-27
## Sources scanned
- .opencode/skills
- /home/alex/.config/opencode/skills
## Contract
**Delegator use only.** This registry is an index, not a summary. Any agent that launches subagents reads it to select relevant skills, then passes exact `SKILL.md` paths for the subagent to read before work.
`SKILL.md` remains the source of truth. Do not inject generated summaries or compact rules by default; pass paths so subagents load the full runtime contract and preserve author intent.
## Skills
| Skill | Trigger / description | Scope | Path |
| --- | --- | --- | --- |
| `auto-commit` | Use when you are making multiple edits or completing significant work in a git repository to automatically create commits | user | `/home/alex/.config/opencode/skills/auto-commit/SKILL.md` |
| `openspec` | Use OpenSpec as the source of truth for planning, implementation, verification, and archive discipline. | user | `/home/alex/.config/opencode/skills/openspec/SKILL.md` |
| `openspec-apply-change` | Implement tasks from an OpenSpec change. Use when the user wants to start implementing, continue implementation, or work through tasks. | project | `/home/alex/projects/headquarter/.opencode/skills/openspec-apply-change/SKILL.md` |
| `openspec-archive-change` | Archive a completed change in the experimental workflow. Use when the user wants to finalize and archive a change after implementation is complete. | project | `/home/alex/projects/headquarter/.opencode/skills/openspec-archive-change/SKILL.md` |
| `openspec-explore` | Enter explore mode - a thinking partner for exploring ideas, investigating problems, and clarifying requirements. Use when the user wants to think through something before or during a change. | project | `/home/alex/projects/headquarter/.opencode/skills/openspec-explore/SKILL.md` |
| `openspec-propose` | Propose a new change with all artifacts generated in one step. Use when the user wants to quickly describe what they want to build and get a complete proposal with design, specs, and tasks ready for implementation. | project | `/home/alex/projects/headquarter/.opencode/skills/openspec-propose/SKILL.md` |
## Loading protocol
1. Match task context and target files against the `Trigger / description` column.
2. Pass only the matching `Path` values to the subagent under `## Skills to load before work`.
3. Instruct the subagent to read those exact `SKILL.md` files before reading, writing, reviewing, testing, or creating artifacts.
4. If no matching skill exists, proceed without project skill injection and report `skill_resolution: none`.
+4 -1
View File
@@ -49,5 +49,8 @@ apps/web/dist/
.DS_Store
Thumbs.db
/.stoneforge/.worktrees/
# Local Pi runtime state
# Pi / agent cache
.pi/
.atl/
.sisyphus/
.pi-lens/
@@ -0,0 +1,10 @@
{
"sessionID": "ses_1da2608b1ffergOzow3NQt1mGr",
"updatedAt": "2026-05-15T23:50:42.832Z",
"sources": {
"background-task": {
"state": "idle",
"updatedAt": "2026-05-15T23:50:42.832Z"
}
}
}
+568
View File
@@ -0,0 +1,568 @@
{
"version": "v2",
"timestamp": 1779889907001,
"ruleHash": "fd9b2b15f2ac8993",
"queries": [
{
"id": "bare-except",
"name": "Bare Except Clause",
"severity": "warning",
"language": "python",
"message": "Bare 'except:' clause — catches SystemExit, KeyboardInterrupt",
"query": " (except_clause\n \"except\") @CLAUSE",
"metavars": [
"CLAUSE"
],
"post_filter": "bare_except_only",
"defect_class": "silent-error",
"inline_tier": "blocking",
"filePath": "/home/alex/.npm-global/lib/node_modules/pi-lens/rules/tree-sitter-queries/python/bare-except.yml"
},
{
"id": "eval-exec",
"name": "Eval/Exec Usage",
"severity": "warning",
"language": "python",
"message": "{{FUNC}}() detected — security risk, code injection vulnerability",
"query": " (call\n function: (identifier) @FUNC\n (#match? @FUNC \"^(eval|exec)$\")\n arguments: (argument_list) @ARGS)",
"metavars": [
"FUNC",
"ARGS"
],
"defect_class": "injection",
"inline_tier": "blocking",
"filePath": "/home/alex/.npm-global/lib/node_modules/pi-lens/rules/tree-sitter-queries/python/eval-exec.yml"
},
{
"id": "exit-signature-check",
"name": "__exit__ Missing Parameters",
"severity": "error",
"language": "python",
"message": "__exit__ should accept type, value, and traceback arguments",
"query": " (function_definition\n name: (identifier) @NAME (#eq? @NAME \"__exit__\")\n parameters: (parameters\n (_) @SELF\n . (_) @PARAM1?\n . (_) @PARAM2?\n . (_) @PARAM3?))",
"metavars": [
"NAME",
"SELF",
"PARAM1",
"PARAM2",
"PARAM3"
],
"post_filter": "exit_params_insufficient",
"defect_class": "correctness",
"inline_tier": "blocking",
"filePath": "/home/alex/.npm-global/lib/node_modules/pi-lens/rules/tree-sitter-queries/python/exit-signature-check.yml"
},
{
"id": "in-operator-unsupported",
"name": "In and Not In Operators Should Be Used on Valid Objects",
"severity": "warning",
"language": "python",
"message": "'in' operator used on object that may not support containment",
"query": " (comparison_operator\n (identifier) @OBJ\n \"in\"\n (identifier) @TARGET)\n (comparison_operator\n (identifier) @OBJ\n \"not\"\n \"in\"\n (identifier) @TARGET)",
"metavars": [
"OBJ",
"TARGET"
],
"post_filter": "check_in_operator_types",
"defect_class": "correctness",
"inline_tier": "warning",
"filePath": "/home/alex/.npm-global/lib/node_modules/pi-lens/rules/tree-sitter-queries/python/in-operator-unsupported.yml"
},
{
"id": "is-vs-equals",
"name": "Is vs Equals for Literals",
"severity": "warning",
"language": "python",
"message": "Using 'is' with literal — use '==' for value comparison",
"query": " (comparison_operator\n (identifier)\n (\"is\")\n (string) @LITERAL)\n (comparison_operator\n (identifier)\n (\"is not\")\n (string) @LITERAL)\n (comparison_operator\n (identifier)\n (\"is\")\n (integer) @LITERAL)\n (comparison_operator\n (identifier)\n (\"is not\")\n (integer) @LITERAL)",
"metavars": [
"LITERAL"
],
"defect_class": "correctness",
"inline_tier": "blocking",
"filePath": "/home/alex/.npm-global/lib/node_modules/pi-lens/rules/tree-sitter-queries/python/is-vs-equals.yml"
},
{
"id": "iter-return-iterator",
"name": "__iter__ Should Return Iterator",
"severity": "warning",
"language": "python",
"message": "__iter__ should return an iterator (object with __next__ method)",
"query": " (function_definition\n name: (identifier) @NAME (#eq? @NAME \"__iter__\")\n body: (block\n (return_statement) @RETURN))",
"metavars": [
"NAME",
"RETURN"
],
"defect_class": "correctness",
"inline_tier": "blocking",
"filePath": "/home/alex/.npm-global/lib/node_modules/pi-lens/rules/tree-sitter-queries/python/iter-return-iterator.yml"
},
{
"id": "mutable-default-arg",
"name": "Mutable Default Argument",
"severity": "warning",
"language": "python",
"message": "Mutable default argument — list/dict/set as default value",
"query": " (function_definition\n (parameters\n (default_parameter\n (identifier) @PARAM\n [(list) (dictionary) (set)] @MUTABLE)))",
"metavars": [
"PARAM",
"MUTABLE"
],
"defect_class": "correctness",
"inline_tier": "blocking",
"filePath": "/home/alex/.npm-global/lib/node_modules/pi-lens/rules/tree-sitter-queries/python/mutable-default-arg.yml"
},
{
"id": "no-super-torchscript",
"name": "super Should Not Be Used in TorchScript Methods",
"severity": "error",
"language": "python",
"message": "super() calls should not be used in TorchScript methods",
"query": " (function_definition\n (decorator\n (call\n function: (identifier) @DEC (#match? @DEC \"^(torch\\.jit\\.script|jit\\.script)$\")))\n body: (block\n (call\n function: (identifier) @FUNC (#eq? @FUNC \"super\")) @CALL))",
"metavars": [
"DEC",
"FUNC",
"CALL"
],
"defect_class": "correctness",
"inline_tier": "blocking",
"filePath": "/home/alex/.npm-global/lib/node_modules/pi-lens/rules/tree-sitter-queries/python/no-super-torchscript.yml"
},
{
"id": "notimplemented-boolean-context",
"name": "NotImplemented in Boolean Context",
"severity": "error",
"language": "python",
"message": "NotImplemented should not be used in boolean contexts",
"query": " (if_statement\n condition: (identifier) @COND (#eq? @COND \"NotImplemented\"))\n (while_statement\n condition: (identifier) @COND (#eq? @COND \"NotImplemented\"))\n (binary_operator\n (identifier) @COND (#eq? @COND \"NotImplemented\")\n (\"and\" | \"or\"))\n (boolean_operator\n (identifier) @COND (#eq? @COND \"NotImplemented\"))\n (unary_operator\n operator: (\"not\")\n argument: (identifier) @COND (#eq? @COND \"NotImplemented\"))",
"metavars": [
"COND"
],
"defect_class": "correctness",
"inline_tier": "blocking",
"filePath": "/home/alex/.npm-global/lib/node_modules/pi-lens/rules/tree-sitter-queries/python/notimplemented-boolean-context.yml"
},
{
"id": "python-assert-production",
"name": "Assert in Production Code",
"severity": "warning",
"language": "python",
"message": "assert statement stripped by Python -O flag — use explicit checks with exceptions in production code",
"query": " (assert_statement) @ASSERT",
"metavars": [
"ASSERT"
],
"defect_class": "correctness",
"inline_tier": "warning",
"filePath": "/home/alex/.npm-global/lib/node_modules/pi-lens/rules/tree-sitter-queries/python/python-assert-production.yml"
},
{
"id": "python-command-injection",
"name": "Command Injection Sink",
"severity": "error",
"language": "python",
"message": "Potential command injection sink — avoid shell execution with dynamic input",
"query": " (call\n function: (attribute\n object: (identifier) @MOD\n attribute: (identifier) @FN)\n arguments: (argument_list) @ARGS\n (#eq? @MOD \"os\")\n (#match? @FN \"^(system|popen)$\"))\n\n (call\n function: (attribute\n object: (identifier) @MOD\n attribute: (identifier) @FN)\n arguments: (argument_list\n (keyword_argument\n name: (identifier) @KW\n value: (true)))\n (#eq? @MOD \"subprocess\")\n (#match? @FN \"^(run|Popen|call|check_output|check_call)$\")\n (#eq? @KW \"shell\"))",
"metavars": [
"MOD",
"FN",
"ARGS",
"KW"
],
"post_filter": "py_command_injection_sink",
"defect_class": "injection",
"inline_tier": "blocking",
"filePath": "/home/alex/.npm-global/lib/node_modules/pi-lens/rules/tree-sitter-queries/python/python-command-injection.yml"
},
{
"id": "python-cross-language-method",
"name": "Cross-Language Method Leakage",
"severity": "warning",
"language": "python",
"message": "'{METHOD}' is not a Python method — likely a {LANG} idiom leaking in",
"query": " (call\n function: (attribute\n object: (_) @OBJ\n attribute: (identifier) @METHOD)\n (#match? @METHOD \"^(push|forEach|indexOf|charAt|substring|hasOwnProperty|unshift|flatMap|padStart|padEnd|trimStart|trimEnd|equals|isEmpty|println|printf|getClass|hashCode|toCharArray|getBytes|compareTo|equalsIgnoreCase|startsWith|endsWith|each|collect|select|reject|detect|inject|chomp|chop|gsub|upcase|downcase|present|blank|Add|Contains|ToLower|ToUpper|Trim|Substring|WriteLine|ReadLine|TryParse|forEach|includes|assign|freeze|splice|unshift|shift|flatMap)$\"))",
"metavars": [
"OBJ",
"METHOD"
],
"post_filter": "match_captures",
"post_filter_params": {
"METHOD": "^(push|forEach|indexOf|charAt|substring|hasOwnProperty|unshift|flatMap|padStart|padEnd|trimStart|trimEnd|equals|isEmpty|println|printf|getClass|hashCode|toCharArray|getBytes|compareTo|equalsIgnoreCase|startsWith|endsWith|each|collect|select|reject|detect|inject|chomp|chop|gsub|upcase|downcase|present|blank|Add|Contains|ToLower|ToUpper|Trim|Substring|WriteLine|ReadLine|TryParse|forEach|includes|assign|freeze|splice|unshift|shift|flatMap)$"
},
"defect_class": "hallucination",
"inline_tier": "warning",
"filePath": "/home/alex/.npm-global/lib/node_modules/pi-lens/rules/tree-sitter-queries/python/python-cross-language-method.yml"
},
{
"id": "python-debugger",
"name": "Debugger Statement",
"severity": "warning",
"language": "python",
"message": "Debugger call '{{FUNC}}' — remove before committing",
"query": " (call\n function: (identifier) @FUNC\n (#eq? @FUNC \"breakpoint\"))\n\n (call\n function: (attribute\n object: (identifier) @MOD\n attribute: (identifier) @FUNC)\n (#eq? @MOD \"pdb\")\n (#match? @FUNC \"^(set_trace|post_mortem|pm|run|runcall)$\"))",
"metavars": [
"FUNC",
"MOD"
],
"defect_class": "safety",
"inline_tier": "blocking",
"filePath": "/home/alex/.npm-global/lib/node_modules/pi-lens/rules/tree-sitter-queries/python/python-debugger.yml"
},
{
"id": "python-empty-except",
"name": "Empty Except Block",
"severity": "warning",
"language": "python",
"message": "Except block only contains 'pass' — handle or re-raise the exception",
"query": " (try_statement\n (except_clause\n body: (block) @BODY))",
"metavars": [
"BODY"
],
"post_filter": "python_empty_except",
"defect_class": "silent-error",
"inline_tier": "blocking",
"filePath": "/home/alex/.npm-global/lib/node_modules/pi-lens/rules/tree-sitter-queries/python/python-empty-except.yml"
},
{
"id": "python-hallucinated-import",
"name": "Hallucinated Import",
"severity": "warning",
"language": "python",
"message": "Hallucinated import — '{NAME}' does not exist in '{MODULE}'",
"query": " (import_from_statement\n module_name: (dotted_name) @MODULE\n name: (dotted_name) @NAME)",
"metavars": [
"MODULE",
"NAME"
],
"post_filter": "match_captures",
"post_filter_params": {
"MODULE": "^(requests|flask|django|typing|collections|asyncio|json|unittest|pytest|urllib|sqlalchemy)$",
"NAME": "^(JSONResponse|HTMLResponse|RedirectResponse|StreamingResponse|Depends|Query|Path|Body|Header|Cookie|Form|File|UploadFile|FastAPI|APIRouter|HTTPException|BackgroundTasks|dataclass|fields|BaseModel|Field|validator|aiohttp|parse|stringify|fixture|TestCase|get|post|put|delete|Model|Session|Column|Integer|String)$"
},
"defect_class": "hallucination",
"inline_tier": "blocking",
"filePath": "/home/alex/.npm-global/lib/node_modules/pi-lens/rules/tree-sitter-queries/python/python-hallucinated-import.yml"
},
{
"id": "python-hardcoded-secrets",
"name": "Hardcoded Secret",
"severity": "warning",
"language": "python",
"message": "Hardcoded {{VARNAME}} — use environment variables or a secrets manager",
"query": " (assignment\n left: (identifier) @VARNAME\n right: (string) @VALUE)",
"metavars": [
"VARNAME",
"VALUE"
],
"post_filter": "check_secret_pattern",
"defect_class": "secrets",
"inline_tier": "blocking",
"filePath": "/home/alex/.npm-global/lib/node_modules/pi-lens/rules/tree-sitter-queries/python/python-hardcoded-secrets.yml"
},
{
"id": "python-insecure-deserialization",
"name": "Insecure Deserialization",
"severity": "error",
"language": "python",
"message": "Potential insecure deserialization sink — avoid unsafe loaders",
"query": " (call\n function: (attribute\n object: (identifier) @MOD\n attribute: (identifier) @FN)\n arguments: (argument_list (_) @DATA)\n (#match? @MOD \"^(pickle|yaml)$\")\n (#match? @FN \"^(load|loads|unsafe_load)$\"))",
"metavars": [
"MOD",
"FN",
"DATA"
],
"post_filter": "py_insecure_deserialization_sink",
"defect_class": "injection",
"inline_tier": "blocking",
"filePath": "/home/alex/.npm-global/lib/node_modules/pi-lens/rules/tree-sitter-queries/python/python-insecure-deserialization.yml"
},
{
"id": "python-insecure-random",
"name": "Insecure Randomness",
"severity": "warning",
"language": "python",
"message": "Insecure randomness source detected — use secrets or os.urandom for security-sensitive values",
"query": " (call\n function: (attribute\n object: (identifier) @MOD\n attribute: (identifier) @FN)\n arguments: (argument_list) @ARGS\n (#eq? @MOD \"random\")\n (#match? @FN \"^(random|randint|randrange|choice|choices)$\"))",
"metavars": [
"MOD",
"FN",
"ARGS"
],
"defect_class": "injection",
"inline_tier": "warning",
"filePath": "/home/alex/.npm-global/lib/node_modules/pi-lens/rules/tree-sitter-queries/python/python-insecure-random.yml"
},
{
"id": "python-mutable-class-attr",
"name": "Mutable Class Attribute",
"severity": "warning",
"language": "python",
"message": "Class attribute '{{VARNAME}}' is mutable — shared across all instances",
"query": " (class_definition\n body: (block\n (expression_statement\n (assignment\n left: (identifier) @VARNAME\n right: [\n (list) @VALUE\n (dictionary) @VALUE\n (set) @VALUE\n ]))))",
"metavars": [
"VARNAME",
"VALUE"
],
"post_filter": "not_in_function",
"defect_class": "correctness",
"inline_tier": "blocking",
"filePath": "/home/alex/.npm-global/lib/node_modules/pi-lens/rules/tree-sitter-queries/python/python-mutable-class-attr.yml"
},
{
"id": "python-path-traversal",
"name": "Path Traversal Risk",
"severity": "warning",
"language": "python",
"message": "Potential path traversal sink — sanitize and constrain file paths",
"query": " [\n (call\n function: (identifier) @FN\n arguments: (argument_list\n [(identifier) (binary_operator) (call)] @PATH))\n (call\n function: (attribute\n object: (identifier) @MOD\n attribute: (identifier) @FN)\n arguments: (argument_list\n [(identifier) (binary_operator) (call)] @PATH))\n ]\n (#match? @FN \"^(open|read_text|read_bytes|write_text|write_bytes|remove|unlink|rmdir)$\")",
"metavars": [
"MOD",
"FN",
"PATH"
],
"post_filter": "py_path_traversal_sink",
"defect_class": "injection",
"inline_tier": "warning",
"filePath": "/home/alex/.npm-global/lib/node_modules/pi-lens/rules/tree-sitter-queries/python/python-path-traversal.yml"
},
{
"id": "python-print-statement",
"name": "Print Statement in Production",
"severity": "warning",
"language": "python",
"message": "print() — remove debug output before committing",
"query": " (call\n function: (identifier) @FUNC\n (#eq? @FUNC \"print\")\n arguments: (argument_list) @ARGS)",
"metavars": [
"FUNC",
"ARGS"
],
"post_filter": "not_in_test_block",
"defect_class": "safety",
"inline_tier": "warning",
"filePath": "/home/alex/.npm-global/lib/node_modules/pi-lens/rules/tree-sitter-queries/python/python-print-statement.yml"
},
{
"id": "python-raise-string",
"name": "Raise String Instead of Exception",
"severity": "warning",
"language": "python",
"message": "raise with string literal — Python 3 requires exception instances",
"query": " (raise_statement\n (string) @VALUE)",
"metavars": [
"VALUE"
],
"defect_class": "correctness",
"inline_tier": "blocking",
"filePath": "/home/alex/.npm-global/lib/node_modules/pi-lens/rules/tree-sitter-queries/python/python-raise-string.yml"
},
{
"id": "python-sleep-in-test",
"name": "time.sleep in Test",
"severity": "warning",
"language": "python",
"message": "time.sleep() in test — use synchronisation primitives or polling helpers instead of fixed sleeps",
"query": " (call\n function: (attribute\n object: (identifier) @MOD\n attribute: (identifier) @FN)\n (#eq? @MOD \"time\")\n (#eq? @FN \"sleep\")) @CALL",
"metavars": [
"MOD",
"FN",
"CALL"
],
"defect_class": "async-misuse",
"inline_tier": "warning",
"filePath": "/home/alex/.npm-global/lib/node_modules/pi-lens/rules/tree-sitter-queries/python/python-sleep-in-test.yml"
},
{
"id": "python-sql-injection",
"name": "SQL Injection Risk",
"severity": "error",
"language": "python",
"message": "Potential SQL injection sink — use parameterized queries",
"query": " (call\n function: (attribute\n object: (_) @OBJ\n attribute: (identifier) @FN)\n arguments: (argument_list\n [(binary_operator) (identifier) (call)] @SQL\n (_)*))",
"metavars": [
"OBJ",
"FN",
"SQL"
],
"post_filter": "py_sql_injection_sink",
"defect_class": "injection",
"inline_tier": "blocking",
"filePath": "/home/alex/.npm-global/lib/node_modules/pi-lens/rules/tree-sitter-queries/python/python-sql-injection.yml"
},
{
"id": "python-ssrf",
"name": "SSRF Risk",
"severity": "warning",
"language": "python",
"message": "Potential SSRF sink — validate/allowlist outbound URLs",
"query": " (call\n function: (attribute\n object: (identifier) @MOD\n attribute: (identifier) @FN)\n arguments: (argument_list\n [(identifier) (subscript) (call)] @URL)\n (#eq? @MOD \"requests\")\n (#match? @FN \"^(get|post|put|patch|delete|request|head|options)$\"))",
"metavars": [
"MOD",
"FN",
"URL"
],
"post_filter": "py_ssrf_sink",
"defect_class": "injection",
"inline_tier": "warning",
"filePath": "/home/alex/.npm-global/lib/node_modules/pi-lens/rules/tree-sitter-queries/python/python-ssrf.yml"
},
{
"id": "python-subprocess-shell",
"name": "subprocess with shell=True",
"severity": "warning",
"language": "python",
"message": "subprocess called with shell=True — command injection risk if any argument is user-controlled",
"query": " (call\n function: (attribute\n object: (identifier) @MOD\n attribute: (identifier) @FN)\n arguments: (argument_list\n (keyword_argument\n name: (identifier) @KW\n value: (true) @VAL))\n (#eq? @MOD \"subprocess\")\n (#match? @FN \"^(run|Popen|call|check_output|check_call)$\")\n (#eq? @KW \"shell\"))",
"metavars": [
"MOD",
"FN",
"KW"
],
"defect_class": "injection",
"inline_tier": "warning",
"filePath": "/home/alex/.npm-global/lib/node_modules/pi-lens/rules/tree-sitter-queries/python/python-subprocess-shell.yml"
},
{
"id": "python-thread-global-write",
"name": "Threaded Shared State Risk",
"severity": "warning",
"language": "python",
"message": "Thread creation detected — ensure shared state mutations are synchronized",
"query": " (call\n function: (attribute\n object: (identifier) @MOD\n attribute: (identifier) @FN)\n arguments: (argument_list) @ARGS)\n (#eq? @MOD \"threading\")\n (#eq? @FN \"Thread\")",
"metavars": [
"MOD",
"FN",
"ARGS"
],
"defect_class": "async-misuse",
"inline_tier": "warning",
"filePath": "/home/alex/.npm-global/lib/node_modules/pi-lens/rules/tree-sitter-queries/python/python-thread-global-write.yml"
},
{
"id": "python-unsafe-regex",
"name": "Unsafe Dynamic Regex",
"severity": "warning",
"language": "python",
"message": "re.{{FUNC}}() with variable pattern — ReDoS risk if pattern is user-controlled",
"query": " (call\n function: (attribute\n object: (identifier) @MOD\n attribute: (identifier) @FUNC)\n arguments: (argument_list\n (identifier) @PATTERN)\n (#eq? @MOD \"re\")\n (#match? @FUNC \"^(compile|match|search|fullmatch|findall|finditer|sub|subn|split)$\"))",
"metavars": [
"MOD",
"FUNC",
"PATTERN"
],
"defect_class": "injection",
"inline_tier": "blocking",
"filePath": "/home/alex/.npm-global/lib/node_modules/pi-lens/rules/tree-sitter-queries/python/python-unsafe-regex.yml"
},
{
"id": "python-weak-hash",
"name": "Weak Hash Primitive",
"severity": "error",
"language": "python",
"message": "Weak hash primitive detected (MD5/SHA1) — use SHA-256+ for security-sensitive contexts",
"query": " (call\n function: (attribute\n object: (identifier) @MOD\n attribute: (identifier) @FN)\n arguments: (argument_list) @ARGS\n (#eq? @MOD \"hashlib\")\n (#match? @FN \"^(md5|sha1)$\"))",
"metavars": [
"MOD",
"FN",
"ARGS"
],
"defect_class": "injection",
"inline_tier": "blocking",
"filePath": "/home/alex/.npm-global/lib/node_modules/pi-lens/rules/tree-sitter-queries/python/python-weak-hash.yml"
},
{
"id": "return-in-generator",
"name": "Return with Value in Generator",
"severity": "error",
"language": "python",
"message": "'return' with a value should not be used in a generator function",
"query": " (function_definition\n body: (block\n (return_statement\n (_) @RETURN_VAL) @RETURN)) @FUNCTION",
"metavars": [
"FUNCTION",
"RETURN",
"RETURN_VAL"
],
"post_filter": "is_generator_with_valued_return",
"defect_class": "correctness",
"inline_tier": "blocking",
"filePath": "/home/alex/.npm-global/lib/node_modules/pi-lens/rules/tree-sitter-queries/python/return-in-generator.yml"
},
{
"id": "return-in-init",
"name": "Return Value in __init__",
"severity": "error",
"language": "python",
"message": "__init__ should not return a value — it must always return None",
"query": " (function_definition\n name: (identifier) @NAME (#eq? @NAME \"__init__\")\n body: (block\n (return_statement\n (_) @RETURN_VAL) @RETURN))",
"metavars": [
"NAME",
"RETURN",
"RETURN_VAL"
],
"post_filter": "has_return_value",
"defect_class": "correctness",
"inline_tier": "blocking",
"filePath": "/home/alex/.npm-global/lib/node_modules/pi-lens/rules/tree-sitter-queries/python/return-in-init.yml"
},
{
"id": "send-file-mimetype",
"name": "send_file Should Specify Mimetype or Download Name",
"severity": "error",
"language": "python",
"message": "send_file should specify 'mimetype' or 'download_name' when used with file-like objects",
"query": " (call\n function: (identifier) @FUNC (#eq? @FUNC \"send_file\")\n arguments: (argument_list\n (_) @FIRST_ARG\n (keyword_argument)? @KW))",
"metavars": [
"FUNC",
"FIRST_ARG",
"KW"
],
"post_filter": "missing_mimetype_and_download_name",
"defect_class": "correctness",
"inline_tier": "blocking",
"filePath": "/home/alex/.npm-global/lib/node_modules/pi-lens/rules/tree-sitter-queries/python/send-file-mimetype.yml"
},
{
"id": "unreachable-except",
"name": "Unreachable Except Clause",
"severity": "warning",
"language": "python",
"message": "Unreachable except clause — earlier except catches all",
"query": " (try_statement\n (except_clause\n \"except\") @GENERAL\n (except_clause\n \"except\"\n (identifier) @SPECIFIC))",
"metavars": [
"GENERAL",
"SPECIFIC"
],
"defect_class": "correctness",
"inline_tier": "blocking",
"filePath": "/home/alex/.npm-global/lib/node_modules/pi-lens/rules/tree-sitter-queries/python/unreachable-except.yml"
},
{
"id": "wildcard-import",
"name": "Wildcard Import",
"severity": "warning",
"language": "python",
"message": "Wildcard import — pollutes namespace, hard to track origin",
"query": " (import_from_statement\n module_name: (dotted_name) @MODULE\n (wildcard_import) @WILDCARD)",
"metavars": [
"MODULE",
"WILDCARD"
],
"defect_class": "safety",
"inline_tier": "warning",
"filePath": "/home/alex/.npm-global/lib/node_modules/pi-lens/rules/tree-sitter-queries/python/wildcard-import.yml"
},
{
"id": "yield-return-outside-function",
"name": "Yield/Return Outside Function",
"severity": "error",
"language": "python",
"message": "{{STATEMENT}} used outside function — syntax error",
"query": " (module\n (expression_statement\n (yield) @STATEMENT))\n (module\n (expression_statement\n (yield_expression) @STATEMENT))\n (module\n (return_statement) @STATEMENT)",
"metavars": [
"STATEMENT"
],
"defect_class": "correctness",
"inline_tier": "blocking",
"filePath": "/home/alex/.npm-global/lib/node_modules/pi-lens/rules/tree-sitter-queries/python/yield-return-outside-function.yml"
}
]
}
File diff suppressed because one or more lines are too long
@@ -0,0 +1,61 @@
"""add terminal_sessions table
Revision ID: 2026_05_28_add_terminal_sessions
Revises: 20260527_160017_add_pi_agent
Create Date: 2026-05-28
"""
from collections.abc import Sequence
from alembic import op
import sqlalchemy as sa
# revision identifiers, used by Alembic.
revision: str = "2026_05_28_add_terminal_sessions"
down_revision: str | None = "2026_05_28_add_tool_definition_manifests"
branch_labels: str | Sequence[str] | None = None
depends_on: str | Sequence[str] | None = None
def upgrade() -> None:
op.create_table(
"terminal_sessions",
sa.Column("id", sa.UUID(), nullable=False),
sa.Column("instance_id", sa.UUID(), nullable=False),
sa.Column("name", sa.String(length=255), nullable=True),
sa.Column("status", sa.String(length=50), nullable=False),
sa.Column("last_activity_at", sa.DateTime(timezone=True), nullable=True),
sa.Column("closed_at", sa.DateTime(timezone=True), nullable=True),
sa.Column(
"created_at",
sa.DateTime(timezone=True),
server_default=sa.text("now()"),
nullable=False,
),
sa.Column(
"updated_at",
sa.DateTime(timezone=True),
server_default=sa.text("now()"),
onupdate=sa.text("now()"),
nullable=False,
),
sa.ForeignKeyConstraint(
["instance_id"], ["tool_instances.id"], ondelete="CASCADE"
),
sa.PrimaryKeyConstraint("id"),
)
op.create_index(
op.f("ix_terminal_sessions_instance_id"),
"terminal_sessions",
["instance_id"],
unique=False,
)
def downgrade() -> None:
op.drop_index(
op.f("ix_terminal_sessions_instance_id"),
table_name="terminal_sessions",
)
op.drop_table("terminal_sessions")
@@ -0,0 +1,89 @@
"""drop tool_configs and config_folders tables
Revision ID: 2026_05_28_drop_tool_configs_and_config_folders
Revises: 2026_05_28_add_tool_definition_manifests
Create Date: 2026-05-28
"""
from typing import Sequence, Union
from alembic import op
import sqlalchemy as sa
# revision identifiers, used by Alembic.
revision: str = "2026_05_28_drop_tool_configs_and_config_folders"
down_revision: Union[str, None] = "2026_05_28_add_terminal_sessions"
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
def upgrade() -> None:
conn = op.get_bind()
# Drop tool_configs table if it exists
result = conn.execute(
sa.text("""
SELECT table_name FROM information_schema.tables
WHERE table_name = 'tool_configs'
""")
)
if result.fetchone():
op.drop_table("tool_configs")
# Drop config_folders table if it exists
result = conn.execute(
sa.text("""
SELECT table_name FROM information_schema.tables
WHERE table_name = 'config_folders'
""")
)
if result.fetchone():
op.drop_table("config_folders")
def downgrade() -> None:
# Recreate config_folders table
op.create_table(
"config_folders",
sa.Column("id", sa.UUID(), nullable=False),
sa.Column("user_id", sa.UUID(), nullable=False),
sa.Column("name", sa.String(255), nullable=False),
sa.Column("description", sa.Text(), nullable=True),
sa.Column("mount_path", sa.String(1024), nullable=False),
sa.Column("files", sa.JSON(), default=dict, nullable=False),
sa.Column("project_overrides", sa.JSON(), default=dict, nullable=True),
sa.Column("is_active", sa.Boolean(), default=True, nullable=False),
sa.Column(
"created_at", sa.TIMESTAMP(timezone=True), server_default=sa.func.now()
),
sa.Column(
"updated_at", sa.TIMESTAMP(timezone=True), server_default=sa.func.now()
),
sa.PrimaryKeyConstraint("id"),
)
# Recreate tool_configs table
op.create_table(
"tool_configs",
sa.Column("id", sa.UUID(), nullable=False),
sa.Column("user_id", sa.UUID(), nullable=False),
sa.Column("tool_type_id", sa.UUID(), nullable=False),
sa.Column("project_id", sa.UUID(), nullable=True),
sa.Column("key", sa.String(255), nullable=False),
sa.Column("value", sa.Text(), nullable=False),
sa.Column("config_type", sa.String(20), default="env", nullable=False),
sa.Column("file_path", sa.String(1024), nullable=True),
sa.Column("port_override", sa.Integer(), nullable=True),
sa.Column("start_command", sa.Text(), nullable=True),
sa.Column("working_directory", sa.Text(), nullable=True),
sa.Column("environment_variables", sa.JSON(), default=dict, nullable=True),
sa.Column("volumes", sa.JSON(), default=list, nullable=True),
sa.Column(
"created_at", sa.TIMESTAMP(timezone=True), server_default=sa.func.now()
),
sa.Column(
"updated_at", sa.TIMESTAMP(timezone=True), server_default=sa.func.now()
),
sa.PrimaryKeyConstraint("id"),
)
-337
View File
@@ -1,337 +0,0 @@
"""Config folder API endpoints."""
import logging
import uuid
from fastapi import APIRouter, Depends, HTTPException, status
from pydantic import BaseModel, Field, field_validator
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from src.api.shared_validators import validate_files as _validate_files, validate_mount_path as _validate_mount_path
from src.auth.dependencies import get_current_user_id, get_db_session
from src.models.config_folder import ConfigFolder
logger = logging.getLogger(__name__)
router = APIRouter(prefix="/config-folders", tags=["config-folders"])
class ConfigFolderCreate(BaseModel):
name: str = Field(description="Folder name (unique per user)")
description: str | None = Field(default=None, description="Optional description")
mount_path: str = Field(description="Default mount path in container")
files: dict = Field(default_factory=dict, description="Files as {path: content}")
@field_validator("mount_path")
@classmethod
def validate_mount_path(cls, v: str) -> str:
return _validate_mount_path(v)
@field_validator("files")
@classmethod
def validate_files(cls, v: dict) -> dict:
return _validate_files(v)
class ConfigFolderUpdate(BaseModel):
name: str | None = Field(default=None, description="Folder name")
description: str | None = Field(default=None, description="Optional description")
mount_path: str | None = Field(default=None, description="Default mount path")
files: dict | None = Field(default=None, description="Files as {path: content}")
is_active: bool | None = Field(default=None, description="Active/inactive toggle")
@field_validator("mount_path")
@classmethod
def validate_mount_path(cls, v: str | None) -> str | None:
return _validate_mount_path(v)
@field_validator("files")
@classmethod
def validate_files(cls, v: dict | None) -> dict | None:
return _validate_files(v)
class ProjectOverrideCreate(BaseModel):
mount_path: str | None = Field(default=None, description="Override mount path")
files: dict = Field(default_factory=dict, description="Override files")
@field_validator("mount_path")
@classmethod
def validate_mount_path(cls, v: str | None) -> str | None:
return _validate_mount_path(v)
class ConfigFolderResponse(BaseModel):
id: str
user_id: str
name: str
description: str | None
mount_path: str
files: dict
project_overrides: dict | None
is_active: bool
created_at: str
updated_at: str
@router.get("", summary="List config folders", description="Get all config folders for the current user.")
async def list_config_folders(
user_id: uuid.UUID = Depends(get_current_user_id),
session: AsyncSession = Depends(get_db_session),
) -> dict:
"""List config folders for the current user."""
query = select(ConfigFolder).where(ConfigFolder.user_id == user_id)
result = await session.execute(query)
folders = result.scalars().all()
return {
"folders": [
{
"id": str(f.id),
"user_id": str(f.user_id),
"name": f.name,
"description": f.description,
"mount_path": f.mount_path,
"files": f.files,
"project_overrides": f.project_overrides,
"is_active": f.is_active,
"created_at": f.created_at.isoformat() if f.created_at else None,
"updated_at": f.updated_at.isoformat() if f.updated_at else None,
}
for f in folders
]
}
@router.post("", summary="Create config folder", description="Create a new config folder.", status_code=status.HTTP_201_CREATED)
async def create_config_folder(
data: ConfigFolderCreate,
user_id: uuid.UUID = Depends(get_current_user_id),
session: AsyncSession = Depends(get_db_session),
) -> dict:
"""Create a config folder."""
# Check for duplicate name
existing = await session.scalar(
select(ConfigFolder).where(
ConfigFolder.user_id == user_id,
ConfigFolder.name == data.name,
)
)
if existing:
raise HTTPException(
status_code=status.HTTP_409_CONFLICT,
detail=f"config folder with name '{data.name}' already exists"
)
folder = ConfigFolder(
user_id=user_id,
name=data.name,
description=data.description,
mount_path=data.mount_path,
files=data.files,
)
session.add(folder)
await session.commit()
await session.refresh(folder)
return {
"id": str(folder.id),
"user_id": str(folder.user_id),
"name": folder.name,
"description": folder.description,
"mount_path": folder.mount_path,
"files": folder.files,
"project_overrides": folder.project_overrides,
"is_active": folder.is_active,
"created_at": folder.created_at.isoformat() if folder.created_at else None,
"updated_at": folder.updated_at.isoformat() if folder.updated_at else None,
}
@router.put("/{folder_id}", summary="Update config folder", description="Update an existing config folder.")
async def update_config_folder(
folder_id: uuid.UUID,
data: ConfigFolderUpdate,
user_id: uuid.UUID = Depends(get_current_user_id),
session: AsyncSession = Depends(get_db_session),
) -> dict:
"""Update a config folder."""
folder = await session.get(ConfigFolder, folder_id)
if folder is None or folder.user_id != user_id:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="config folder not found")
if data.name is not None:
folder.name = data.name
if data.description is not None:
folder.description = data.description
if data.mount_path is not None:
folder.mount_path = data.mount_path
if data.files is not None:
folder.files = data.files
if data.is_active is not None:
folder.is_active = data.is_active
await session.commit()
await session.refresh(folder)
return {
"id": str(folder.id),
"user_id": str(folder.user_id),
"name": folder.name,
"description": folder.description,
"mount_path": folder.mount_path,
"files": folder.files,
"project_overrides": folder.project_overrides,
"is_active": folder.is_active,
"created_at": folder.created_at.isoformat() if folder.created_at else None,
"updated_at": folder.updated_at.isoformat() if folder.updated_at else None,
}
@router.delete("/{folder_id}", summary="Delete config folder", description="Delete a config folder.", status_code=status.HTTP_204_NO_CONTENT)
async def delete_config_folder(
folder_id: uuid.UUID,
user_id: uuid.UUID = Depends(get_current_user_id),
session: AsyncSession = Depends(get_db_session),
) -> None:
"""Delete a config folder."""
folder = await session.get(ConfigFolder, folder_id)
if folder is None or folder.user_id != user_id:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="config folder not found")
await session.delete(folder)
await session.commit()
class ProjectOverrideWithId(ProjectOverrideCreate):
project_id: uuid.UUID = Field(description="Project ID for the override")
@router.get("/{folder_id}", summary="Get config folder by ID", description="Get a single config folder by its ID.")
async def get_config_folder(
folder_id: uuid.UUID,
user_id: uuid.UUID = Depends(get_current_user_id),
session: AsyncSession = Depends(get_db_session),
) -> dict:
"""Get a config folder by ID."""
folder = await session.get(ConfigFolder, folder_id)
if folder is None or folder.user_id != user_id:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="config folder not found")
return {
"id": str(folder.id),
"user_id": str(folder.user_id),
"name": folder.name,
"description": folder.description,
"mount_path": folder.mount_path,
"files": folder.files,
"project_overrides": folder.project_overrides,
"is_active": folder.is_active,
"created_at": folder.created_at.isoformat() if folder.created_at else None,
"updated_at": folder.updated_at.isoformat() if folder.updated_at else None,
}
@router.post("/{folder_id}/overrides", summary="Add project override", description="Add a project override to a config folder.")
async def add_project_override(
folder_id: uuid.UUID,
data: ProjectOverrideWithId,
user_id: uuid.UUID = Depends(get_current_user_id),
session: AsyncSession = Depends(get_db_session),
) -> dict:
"""Add a project override to a config folder."""
folder = await session.get(ConfigFolder, folder_id)
if folder is None or folder.user_id != user_id:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="config folder not found")
# Initialize project_overrides if None
if folder.project_overrides is None:
folder.project_overrides = {}
# Add/update override
override_data = {}
if data.mount_path is not None:
override_data["mount_path"] = data.mount_path
if data.files is not None:
override_data["files"] = data.files
# Use a copy to trigger SQLAlchemy change detection on JSONB
current_overrides = dict(folder.project_overrides or {})
current_overrides[str(data.project_id)] = override_data
folder.project_overrides = current_overrides
await session.commit()
await session.refresh(folder)
return {
"id": str(folder.id),
"project_overrides": folder.project_overrides,
}
@router.put("/{folder_id}/overrides/{project_id}", summary="Update project override", description="Update a project override.")
async def update_project_override(
folder_id: uuid.UUID,
project_id: uuid.UUID,
data: ProjectOverrideCreate,
user_id: uuid.UUID = Depends(get_current_user_id),
session: AsyncSession = Depends(get_db_session),
) -> dict:
"""Update a project override."""
folder = await session.get(ConfigFolder, folder_id)
if folder is None or folder.user_id != user_id:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="config folder not found")
# Initialize project_overrides if None
if folder.project_overrides is None:
folder.project_overrides = {}
# Update override
current_overrides = dict(folder.project_overrides or {})
override_data = current_overrides.get(str(project_id), {})
if data.mount_path is not None:
override_data["mount_path"] = data.mount_path
if data.files is not None:
override_data["files"] = data.files
current_overrides[str(project_id)] = override_data
folder.project_overrides = current_overrides
# Mark the field as modified to ensure SQLAlchemy detects the change
from sqlalchemy.orm.attributes import flag_modified
flag_modified(folder, "project_overrides")
await session.commit()
await session.refresh(folder)
return {
"id": str(folder.id),
"project_overrides": folder.project_overrides,
}
@router.delete("/{folder_id}/overrides/{project_id}", summary="Remove project override", description="Remove a project override.")
async def remove_project_override(
folder_id: uuid.UUID,
project_id: uuid.UUID,
user_id: uuid.UUID = Depends(get_current_user_id),
session: AsyncSession = Depends(get_db_session),
) -> None:
"""Remove a project override."""
folder = await session.get(ConfigFolder, folder_id)
if folder is None or folder.user_id != user_id:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="config folder not found")
# Remove override if exists
current_overrides = dict(folder.project_overrides or {})
if str(project_id) in current_overrides:
del current_overrides[str(project_id)]
folder.project_overrides = current_overrides
await session.commit()
await session.refresh(folder)
return {
"id": str(folder.id),
"project_overrides": folder.project_overrides or {},
}
+502 -73
View File
@@ -1,16 +1,21 @@
"""WebSocket terminal endpoint for tool instances."""
import asyncio
import json
import logging
import uuid
from contextlib import suppress
from fastapi import APIRouter, Depends, HTTPException, WebSocket, status
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from starlette.websockets import WebSocketDisconnect
from src.auth.dependencies import get_db_session
from src.auth.dependencies import get_current_user_id, get_db_session
from src.models.terminal_session import TerminalSessionModel
from src.models.tool_instance import ToolInstance
from src.models.tool_type import ToolType
from src.services.terminal_manager import terminal_manager
from src.services.terminal_manager import MaxSessionsExceededError, terminal_manager
router = APIRouter()
logger = logging.getLogger(__name__)
@@ -19,32 +24,58 @@ logger = logging.getLogger(__name__)
class SessionRef:
"""Mutable reference to a terminal session, allowing updates during reset."""
def __init__(self, session):
def __init__(self, session, slot_session_id: str | None = None):
self.session = session
self.slot_session_id = slot_session_id or session.session_id
@router.websocket(
"/ws/tool-instances/{instance_id}/terminal",
)
async def terminal_websocket(
async def terminal_websocket_default(
websocket: WebSocket,
instance_id: str,
db_session: AsyncSession = Depends(get_db_session),
) -> None:
"""WebSocket endpoint for terminal access to a tool instance.
"""WebSocket endpoint for terminal access (default session alias).
Provides an interactive terminal session inside a running tool instance container.
Sessions persist across WebSocket disconnections.
Backward-compatible route that maps to the default session.
"""
await _handle_terminal_websocket(websocket, instance_id, None, db_session)
@router.websocket(
"/ws/tool-instances/{instance_id}/terminal/{session_id}",
)
async def terminal_websocket_specific(
websocket: WebSocket,
instance_id: str,
session_id: str,
db_session: AsyncSession = Depends(get_db_session),
) -> None:
"""WebSocket endpoint for a specific terminal session."""
await _handle_terminal_websocket(websocket, instance_id, session_id, db_session)
async def _handle_terminal_websocket(
websocket: WebSocket,
instance_id: str,
target_session_id: str | None,
db_session: AsyncSession,
) -> None:
"""Shared WebSocket handler for terminal sessions.
Args:
websocket: The WebSocket connection.
instance_id: UUID string of the tool instance.
target_session_id: Specific session ID (slot key). None means default session.
db_session: Database session.
Returns:
None. Communicates via WebSocket messages.
"""
logger.debug("Terminal WebSocket connection attempt for instance %s", instance_id)
logger.debug(
"Terminal WebSocket connection attempt for instance %s (session=%s)",
instance_id,
target_session_id or "default",
)
await websocket.accept()
logger.debug("Terminal WebSocket accepted for instance %s", instance_id)
@@ -59,7 +90,9 @@ async def terminal_websocket(
# Authenticate user from session cookie
user_id = await _get_user_from_websocket(websocket, db_session)
if user_id is None:
logger.warning("Unauthorized terminal access attempt for instance %s", instance_id)
logger.warning(
"Unauthorized terminal access attempt for instance %s", instance_id
)
await websocket.close(code=4003, reason="Unauthorized")
return
@@ -71,12 +104,21 @@ async def terminal_websocket(
return
if instance.owner_id != user_id:
logger.warning("Forbidden terminal access for instance %s by user %s", instance_id, user_id)
logger.warning(
"Forbidden terminal access for instance %s by user %s",
instance_id,
user_id,
)
await websocket.close(code=4003, reason="Forbidden")
return
if instance.status != "running" or not instance.container_id:
logger.warning("Instance %s not running (status=%s, container_id=%s)", instance_id, instance.status, instance.container_id)
logger.warning(
"Instance %s not running (status=%s, container_id=%s)",
instance_id,
instance.status,
instance.container_id,
)
await websocket.close(code=4004, reason="Instance not running")
return
@@ -86,16 +128,73 @@ async def terminal_websocket(
tool_type = await db_session.get(ToolType, instance.tool_type_id)
startup_command = tool_type.startup_command if tool_type else None
if startup_command:
logger.debug("Using startup command for instance %s: %s", instance_id, startup_command)
logger.debug(
"Using startup command for instance %s: %s",
instance_id,
startup_command,
)
session = None
# Get or create terminal session
try:
session = await terminal_manager.get_or_create_session(
instance_uuid,
instance.container_id,
startup_command=startup_command,
if target_session_id is None:
# Default session alias
session = await terminal_manager.get_or_create_session(
instance_uuid,
instance.container_id,
startup_command=startup_command,
)
slot_session_id = "default"
else:
# Specific session
session = terminal_manager.get_session(
instance_id,
target_session_id,
)
if session is None:
# Session not in memory — may have been lost on server restart.
# Try to restore from the DB row.
db_row = await db_session.get(
TerminalSessionModel, uuid.UUID(target_session_id)
)
if (
db_row is not None
and db_row.instance_id == instance_uuid
and db_row.status != "closed"
):
logger.info(
"Restoring terminal session %s for instance %s from DB",
target_session_id,
instance_id,
)
session = await terminal_manager.create_session(
instance_uuid,
instance.container_id,
startup_command=startup_command,
name=db_row.name,
session_id=target_session_id,
)
else:
logger.warning(
"Session %s not found for instance %s",
target_session_id,
instance_id,
)
await websocket.close(code=4004, reason="Session not found")
return
# Determine slot key for reset scoping
key = terminal_manager._find_key_by_internal_id(
instance_id, session.session_id
)
slot_session_id = key[1] if key else target_session_id
logger.debug(
"Terminal session ready for instance %s (session_id=%s, slot=%s)",
instance_id,
session.session_id,
slot_session_id,
)
logger.debug("Terminal session ready for instance %s (session_id=%s)", instance_id, session.session_id)
# Attach WebSocket to session
await terminal_manager.attach_websocket(session, websocket)
@@ -106,11 +205,13 @@ async def terminal_websocket(
logger.debug("Sent connected status for instance %s", instance_id)
# Use mutable session reference so loops can survive reset
session_ref = SessionRef(session)
session_ref = SessionRef(session, slot_session_id)
# Start I/O loops and heartbeat
read_task = asyncio.create_task(_read_loop(session_ref, websocket))
write_task = asyncio.create_task(_write_loop(session_ref, websocket, instance_id))
write_task = asyncio.create_task(
_write_loop(session_ref, websocket, instance_id)
)
heartbeat_task = asyncio.create_task(_heartbeat_loop(websocket))
logger.debug("Started terminal loops for instance %s", instance_id)
@@ -119,24 +220,36 @@ async def terminal_websocket(
[read_task, write_task, heartbeat_task],
return_when=asyncio.FIRST_COMPLETED,
)
logger.debug("Terminal loop completed for instance %s, done=%s", instance_id, len(done))
logger.debug(
"Terminal loop completed for instance %s, done=%s",
instance_id,
len(done),
)
# Cancel remaining tasks
for task in pending:
task.cancel()
except WebSocketDisconnect:
logger.debug("WebSocket disconnected for instance %s", instance_id)
except Exception as exc:
logger.error("Terminal session error for instance %s: %s", instance_id, str(exc), exc_info=True)
await websocket.close(code=4000, reason=f"Error: {exc}")
logger.error(
"Terminal session error for instance %s: %s",
instance_id,
str(exc),
exc_info=True,
)
with suppress(Exception):
await websocket.close(code=4000, reason=f"Error: {exc}")
finally:
# Detach WebSocket, don't kill session
try:
if 'session' in locals():
with suppress(Exception):
if session is not None:
await terminal_manager.detach_websocket(session, websocket)
logger.debug("WebSocket detached from session for instance %s", instance_id)
except Exception:
pass
logger.debug(
"WebSocket detached from session for instance %s", instance_id
)
async def _read_loop(session_ref: SessionRef, websocket) -> None:
@@ -151,6 +264,8 @@ async def _read_loop(session_ref: SessionRef, websocket) -> None:
if data:
try:
await websocket.send_bytes(data)
except WebSocketDisconnect:
break
except Exception:
break
else:
@@ -175,38 +290,54 @@ async def _write_loop(session_ref: SessionRef, websocket, instance_id: str) -> N
text = message["text"]
if text.startswith("{"):
# Control message (JSON)
import json
try:
ctrl = json.loads(text)
msg_type = ctrl.get("type")
if msg_type == "resize":
cols = ctrl.get("cols", 80)
rows = ctrl.get("rows", 24)
logger.debug(f"Received resize message for instance {instance_id}: {cols}x{rows}")
logger.debug(
"Received resize message for instance %s: %sx%s",
instance_id,
cols,
rows,
)
await session.resize(cols, rows)
elif msg_type == "reset":
# Reset terminal session
logger.debug("Resetting terminal session for instance %s", session.instance_id)
await websocket.send_json({"type": "status", "status": "resetting"})
# Reset the session
# Reset terminal session (scoped to current slot)
logger.debug(
"Resetting terminal session for instance %s (slot=%s)",
session.instance_id,
session_ref.slot_session_id,
)
await websocket.send_json(
{"type": "status", "status": "resetting"}
)
# Reset the session scoped to its slot
new_session = await terminal_manager.reset_session(
session.instance_id,
session.container_id,
startup_command=session.startup_command,
session_id=session_ref.slot_session_id,
name=session.name,
)
# Update the mutable session reference so read_loop uses the new session
# Update the mutable session reference
session_ref.session = new_session
# Attach to new session
await terminal_manager.attach_websocket(new_session, websocket)
await websocket.send_json({"type": "status", "status": "connected"})
await terminal_manager.attach_websocket(
new_session, websocket
)
await websocket.send_json(
{"type": "status", "status": "connected"}
)
# Continue the loop with the new session
continue
except json.JSONDecodeError:
# Not a valid JSON control message, treat as regular input
await session.write_input(text.encode("utf-8"))
@@ -232,56 +363,349 @@ async def _heartbeat_loop(websocket: WebSocket) -> None:
pass
@router.post(
"/projects/{project_id}/repositories/{repo_id}/instances/{instance_id}/terminal/reset",
summary="Reset terminal session",
description="Reset the terminal session for a tool instance, killing the current shell and starting fresh.",
)
async def reset_terminal_session(
project_id: uuid.UUID,
repo_id: uuid.UUID,
async def _get_terminal_instance(
instance_id: uuid.UUID,
db_session: AsyncSession = Depends(get_db_session),
) -> dict:
"""Reset the terminal session for an instance.
user_id: uuid.UUID,
db_session: AsyncSession,
) -> ToolInstance:
"""Fetch instance and validate auth, ownership, and running status.
Args:
project_id: UUID of the project.
repo_id: UUID of the repository.
instance_id: UUID of the tool instance.
user_id: ID of the authenticated user.
db_session: Database session.
Returns:
Dictionary with status message.
The validated ToolInstance.
Raises:
HTTPException: If instance not found, not owned, or not running.
"""
# Get instance and verify it exists and is running
instance = await db_session.get(ToolInstance, instance_id)
if instance is None:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail="Instance not found"
status_code=status.HTTP_404_NOT_FOUND, detail="Instance not found"
)
if instance.owner_id != user_id:
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail="Not authorized to access this instance",
)
if instance.status != "running" or not instance.container_id:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="Instance is not running"
status_code=status.HTTP_400_BAD_REQUEST, detail="Instance is not running"
)
return instance
@router.get(
"/instances/{instance_id}/terminal/sessions",
summary="List terminal sessions",
description="List terminal sessions for a tool instance with live WebSocket state.",
)
async def list_terminal_sessions(
instance_id: uuid.UUID,
user_id: uuid.UUID = Depends(get_current_user_id),
db_session: AsyncSession = Depends(get_db_session),
) -> dict:
"""List terminal sessions for an instance.
Args:
instance_id: UUID of the tool instance.
user_id: ID of the authenticated user.
db_session: Database session.
Returns:
Dictionary with sessions list.
"""
await _get_terminal_instance(instance_id, user_id, db_session)
# Query active DB rows for this instance
result = await db_session.execute(
select(TerminalSessionModel)
.where(TerminalSessionModel.instance_id == instance_id)
.where(TerminalSessionModel.status != "closed")
.order_by(TerminalSessionModel.created_at.asc())
)
db_rows = result.scalars().all()
# Build response with live has_websockets flag.
# Include DB rows even without in-memory counterparts (e.g. after
# server restart) so the frontend can display tabs and reconnect.
sessions = []
for row in db_rows:
live_session = terminal_manager.get_session(str(instance_id), str(row.id))
sessions.append(
{
"id": str(row.id),
"name": row.name,
"status": row.status,
"has_websockets": live_session.has_websockets()
if live_session
else False,
"created_at": row.created_at.isoformat() if row.created_at else None,
"last_activity_at": row.last_activity_at.isoformat()
if row.last_activity_at
else None,
}
)
return {"sessions": sessions}
@router.post(
"/instances/{instance_id}/terminal/sessions",
summary="Create terminal session",
description="Create a new terminal session for a running tool instance.",
status_code=status.HTTP_201_CREATED,
)
async def create_terminal_session(
instance_id: uuid.UUID,
data: dict,
user_id: uuid.UUID = Depends(get_current_user_id),
db_session: AsyncSession = Depends(get_db_session),
) -> dict:
"""Create a new terminal session.
Args:
instance_id: UUID of the tool instance.
data: Request body with optional name.
user_id: ID of the authenticated user.
db_session: Database session.
Returns:
Dictionary with new session details.
Raises:
HTTPException: 409 if max sessions reached.
"""
instance = await _get_terminal_instance(instance_id, user_id, db_session)
assert instance.container_id is not None
# Fetch tool type to get startup_command
tool_type = await db_session.get(ToolType, instance.tool_type_id)
startup_command = tool_type.startup_command if tool_type else None
name = data.get("name")
try:
session = await terminal_manager.create_session(
instance_id,
instance.container_id,
startup_command=startup_command,
name=name,
)
except MaxSessionsExceededError:
raise HTTPException(
status_code=status.HTTP_409_CONFLICT,
detail="Maximum of 5 terminal sessions reached for this instance",
) from None
return {
"id": session.session_id,
"name": session.name,
"status": session.status,
"created_at": session.last_activity,
}
@router.delete(
"/instances/{instance_id}/terminal/sessions/{session_id}",
summary="Close terminal session",
description="Close a specific terminal session.",
)
async def close_terminal_session(
instance_id: uuid.UUID,
session_id: str,
user_id: uuid.UUID = Depends(get_current_user_id),
db_session: AsyncSession = Depends(get_db_session),
) -> dict:
"""Close a terminal session.
Args:
instance_id: UUID of the tool instance.
session_id: ID of the session to close.
user_id: ID of the authenticated user.
db_session: Database session.
Returns:
Dictionary with closure status.
"""
await _get_terminal_instance(instance_id, user_id, db_session)
# Find the session by internal ID to determine its slot key
key = terminal_manager._find_key_by_internal_id(str(instance_id), session_id)
if (
key is None
and terminal_manager.get_session(str(instance_id), session_id) is not None
):
key = (str(instance_id), session_id)
if key is None:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND, detail="Session not found"
)
await terminal_manager.close_session(key[0], key[1])
return {"status": "closed", "session_id": session_id}
@router.post(
"/instances/{instance_id}/terminal/sessions/{session_id}/reset",
summary="Reset terminal session",
description="Reset a specific terminal session, killing the current shell and starting fresh.",
)
async def reset_specific_terminal_session(
instance_id: uuid.UUID,
session_id: str,
user_id: uuid.UUID = Depends(get_current_user_id),
db_session: AsyncSession = Depends(get_db_session),
) -> dict:
"""Reset a specific terminal session.
Args:
instance_id: UUID of the tool instance.
session_id: ID of the session to reset.
user_id: ID of the authenticated user.
db_session: Database session.
Returns:
Dictionary with reset session details.
"""
instance = await _get_terminal_instance(instance_id, user_id, db_session)
assert instance.container_id is not None
# Determine slot key for reset
key = terminal_manager._find_key_by_internal_id(str(instance_id), session_id)
if (
key is None
and terminal_manager.get_session(str(instance_id), session_id) is not None
):
key = (str(instance_id), session_id)
if key is None:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND, detail="Session not found"
)
# Fetch tool type to get startup_command
tool_type = await db_session.get(ToolType, instance.tool_type_id)
startup_command = tool_type.startup_command if tool_type else None
# Preserve name if possible
live_session = terminal_manager.get_session(str(instance_id), session_id)
name = live_session.name if live_session else None
new_session = await terminal_manager.reset_session(
instance_id,
instance.container_id,
startup_command=startup_command,
session_id=key[1],
name=name,
)
return {
"id": new_session.session_id,
"name": new_session.name,
"status": new_session.status,
}
@router.post(
"/instances/{instance_id}/terminal/sessions/{session_id}/rename",
summary="Rename terminal session",
description="Rename a specific terminal session.",
)
async def rename_terminal_session(
instance_id: uuid.UUID,
session_id: str,
data: dict,
user_id: uuid.UUID = Depends(get_current_user_id),
db_session: AsyncSession = Depends(get_db_session),
) -> dict:
"""Rename a terminal session.
Args:
instance_id: UUID of the tool instance.
session_id: ID of the session to rename.
data: Request body with new name.
user_id: ID of the authenticated user.
db_session: Database session.
Returns:
Dictionary with updated session details.
"""
await _get_terminal_instance(instance_id, user_id, db_session)
new_name = data.get("name")
if not new_name or not isinstance(new_name, str):
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST, detail="Name is required"
)
# Update in-memory session name if live
live_session = terminal_manager.get_session(str(instance_id), session_id)
if live_session:
live_session.name = new_name
# Update DB row
db_row = await db_session.get(TerminalSessionModel, uuid.UUID(session_id))
if db_row is None:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND, detail="Session not found"
)
db_row.name = new_name
await db_session.commit()
return {"id": str(db_row.id), "name": new_name}
@router.post(
"/instances/{instance_id}/terminal/reset",
summary="Reset terminal session (legacy alias)",
description="Reset the default terminal session for a tool instance. Preserved for backward compatibility.",
)
async def reset_terminal_session(
instance_id: uuid.UUID,
user_id: uuid.UUID = Depends(get_current_user_id),
db_session: AsyncSession = Depends(get_db_session),
) -> dict:
"""Reset the default terminal session for an instance (legacy alias).
Args:
instance_id: UUID of the tool instance.
user_id: ID of the authenticated user.
db_session: Database session.
Returns:
Dictionary with status message.
"""
instance = await _get_terminal_instance(instance_id, user_id, db_session)
assert instance.container_id is not None
# Fetch tool type to get startup_command
tool_type = await db_session.get(ToolType, instance.tool_type_id)
startup_command = tool_type.startup_command if tool_type else None
try:
# Reset the session
# Reset the default session
new_session = await terminal_manager.reset_session(
instance_id,
instance.container_id,
startup_command=startup_command,
)
logger.info("Terminal session reset for instance %s (new session_id=%s)", instance_id, new_session.session_id)
logger.info(
"Terminal session reset for instance %s (new session_id=%s)",
instance_id,
new_session.session_id,
)
return {
"status": "success",
"message": "Terminal session reset successfully",
@@ -289,11 +713,16 @@ async def reset_terminal_session(
"session_id": new_session.session_id,
}
except Exception as exc:
logger.error("Failed to reset terminal session for instance %s: %s", instance_id, str(exc), exc_info=True)
logger.error(
"Failed to reset terminal session for instance %s: %s",
instance_id,
str(exc),
exc_info=True,
)
raise HTTPException(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
detail=f"Failed to reset terminal session: {exc}"
)
detail=f"Failed to reset terminal session: {exc}",
) from exc
async def _get_user_from_websocket(
-290
View File
@@ -1,290 +0,0 @@
"""Tool configuration API endpoints."""
import uuid
from fastapi import APIRouter, Depends, HTTPException, status
from pydantic import BaseModel, Field, field_validator
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from src.api.shared_validators import validate_env_vars as _validate_env_vars, validate_volumes as _validate_volumes
from src.auth.dependencies import get_current_user_id, get_db_session
from src.models.tool_config import ToolConfig
from src.models.tool_type import ToolType
router = APIRouter(prefix="/tool-configs", tags=["tool-configs"])
class ToolConfigCreate(BaseModel):
tool_type_id: str = Field(description="UUID of the tool type")
project_id: str | None = Field(default=None, description="Optional project ID for project-scoped config")
key: str = Field(description="Config key name")
value: str = Field(description="Config value")
config_type: str = Field(default="env", description="Type: env or file")
file_path: str | None = Field(default=None, description="File path for file-type configs")
port_override: int | None = Field(default=None, description="Port override (1-65535)")
start_command: str | None = Field(default=None, description="Override container start command")
working_directory: str | None = Field(default=None, description="Working directory inside container")
environment_variables: dict | None = Field(default=None, description="Environment variables as JSON object")
volumes: list[dict] | None = Field(default=None, description="Volume mounts as JSON array")
@field_validator("port_override")
@classmethod
def validate_port(cls, v: int | None) -> int | None:
if v is None:
return v
if v < 1 or v > 65535:
raise ValueError("Port must be between 1 and 65535")
return v
@field_validator("environment_variables")
@classmethod
def validate_env_vars(cls, v: dict | None) -> dict | None:
return _validate_env_vars(v)
@field_validator("volumes")
@classmethod
def validate_volumes(cls, v: list | None) -> list | None:
return _validate_volumes(v)
class ToolConfigUpdate(BaseModel):
key: str | None = Field(default=None, description="Config key name")
value: str | None = Field(default=None, description="Config value")
config_type: str | None = Field(default=None, description="Type: env or file")
file_path: str | None = Field(default=None, description="File path for file-type configs")
port_override: int | None = Field(default=None, description="Port override (1-65535)")
start_command: str | None = Field(default=None, description="Override container start command")
working_directory: str | None = Field(default=None, description="Working directory inside container")
environment_variables: dict | None = Field(default=None, description="Environment variables as JSON object")
volumes: list[dict] | None = Field(default=None, description="Volume mounts as JSON array")
@field_validator("port_override")
@classmethod
def validate_port(cls, v: int | None) -> int | None:
if v is None:
return v
if v < 1 or v > 65535:
raise ValueError("Port must be between 1 and 65535")
return v
@field_validator("environment_variables")
@classmethod
def validate_env_vars(cls, v: dict | None) -> dict | None:
return _validate_env_vars(v)
@field_validator("volumes")
@classmethod
def validate_volumes(cls, v: list | None) -> list | None:
return _validate_volumes(v)
class ToolConfigResponse(BaseModel):
id: str
tool_type_id: str
project_id: str | None
key: str
value: str
config_type: str
file_path: str | None
port_override: int | None
start_command: str | None
working_directory: str | None
environment_variables: dict | None
volumes: list[dict] | None
@router.get("", summary="List tool configs", description="Get all tool configs for the current user.")
async def list_configs(
tool_type_id: str | None = None,
project_id: str | None = None,
user_id: uuid.UUID = Depends(get_current_user_id),
session: AsyncSession = Depends(get_db_session),
) -> list:
"""List tool configs for the current user."""
query = select(ToolConfig).where(ToolConfig.user_id == user_id)
if tool_type_id:
query = query.where(ToolConfig.tool_type_id == uuid.UUID(tool_type_id))
if project_id:
query = query.where(ToolConfig.project_id == uuid.UUID(project_id))
else:
# If no project specified, get only global configs (project_id is None)
query = query.where(ToolConfig.project_id.is_(None))
result = await session.execute(query)
configs = result.scalars().all()
return [
{
"id": str(c.id),
"tool_type_id": str(c.tool_type_id),
"project_id": str(c.project_id) if c.project_id else None,
"key": c.key,
"value": c.value,
"config_type": c.config_type,
"file_path": c.file_path,
"port_override": c.port_override,
"start_command": c.start_command,
"working_directory": c.working_directory,
"environment_variables": c.environment_variables,
"volumes": c.volumes,
}
for c in configs
]
@router.post("", summary="Create tool config", description="Create a new tool config.", status_code=status.HTTP_201_CREATED)
async def create_config(
data: ToolConfigCreate,
user_id: uuid.UUID = Depends(get_current_user_id),
session: AsyncSession = Depends(get_db_session),
) -> dict:
"""Create a tool config."""
# Verify tool type exists
tool_type = await session.get(ToolType, uuid.UUID(data.tool_type_id))
if tool_type is None:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="tool type not found")
# Check for existing config with same key
query = select(ToolConfig).where(
ToolConfig.user_id == user_id,
ToolConfig.tool_type_id == uuid.UUID(data.tool_type_id),
ToolConfig.key == data.key,
)
if data.project_id:
query = query.where(ToolConfig.project_id == uuid.UUID(data.project_id))
else:
query = query.where(ToolConfig.project_id.is_(None))
existing = await session.scalar(query)
if existing:
raise HTTPException(
status_code=status.HTTP_409_CONFLICT,
detail=f"config with key '{data.key}' already exists"
)
config = ToolConfig(
user_id=user_id,
tool_type_id=uuid.UUID(data.tool_type_id),
project_id=uuid.UUID(data.project_id) if data.project_id else None,
key=data.key,
value=data.value,
config_type=data.config_type,
file_path=data.file_path,
port_override=data.port_override,
start_command=data.start_command,
working_directory=data.working_directory,
environment_variables=data.environment_variables,
volumes=data.volumes,
)
session.add(config)
await session.commit()
await session.refresh(config)
return {
"id": str(config.id),
"tool_type_id": str(config.tool_type_id),
"project_id": str(config.project_id) if config.project_id else None,
"key": config.key,
"value": config.value,
"config_type": config.config_type,
"file_path": config.file_path,
"port_override": config.port_override,
"start_command": config.start_command,
"working_directory": config.working_directory,
"environment_variables": config.environment_variables,
"volumes": config.volumes,
}
@router.put("/{config_id}", summary="Update tool config", description="Update an existing tool config.")
async def update_config(
config_id: uuid.UUID,
data: ToolConfigUpdate,
user_id: uuid.UUID = Depends(get_current_user_id),
session: AsyncSession = Depends(get_db_session),
) -> dict:
"""Update a tool config."""
config = await session.get(ToolConfig, config_id)
if config is None or config.user_id != user_id:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="config not found")
if data.key is not None:
config.key = data.key
if data.value is not None:
config.value = data.value
if data.config_type is not None:
config.config_type = data.config_type
if data.file_path is not None:
config.file_path = data.file_path
if data.port_override is not None:
config.port_override = data.port_override
if data.start_command is not None:
config.start_command = data.start_command
if data.working_directory is not None:
config.working_directory = data.working_directory
if data.environment_variables is not None:
config.environment_variables = data.environment_variables
if data.volumes is not None:
config.volumes = data.volumes
await session.commit()
await session.refresh(config)
return {
"id": str(config.id),
"tool_type_id": str(config.tool_type_id),
"project_id": str(config.project_id) if config.project_id else None,
"key": config.key,
"value": config.value,
"config_type": config.config_type,
"file_path": config.file_path,
"port_override": config.port_override,
"start_command": config.start_command,
"working_directory": config.working_directory,
"environment_variables": config.environment_variables,
"volumes": config.volumes,
}
@router.get("/defaults/{tool_type_id}", summary="Get default configs", description="Get suggested default configs for a tool type.")
async def get_default_configs(
tool_type_id: str,
user_id: uuid.UUID = Depends(get_current_user_id),
session: AsyncSession = Depends(get_db_session),
) -> dict:
"""Get suggested default configs for a tool type."""
tool_type = await session.get(ToolType, uuid.UUID(tool_type_id))
if tool_type is None:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="tool type not found")
# Return suggested defaults based on required_variables
defaults = []
for var in tool_type.required_variables:
defaults.append({
"key": var,
"value": "",
"config_type": "env",
"description": f"Required variable: {var}",
})
return {
"tool_type_id": tool_type_id,
"suggested_configs": defaults,
}
@router.delete("/{config_id}", summary="Delete tool config", description="Delete a tool config.")
async def delete_config(
config_id: uuid.UUID,
user_id: uuid.UUID = Depends(get_current_user_id),
session: AsyncSession = Depends(get_db_session),
) -> None:
"""Delete a tool config."""
config = await session.get(ToolConfig, config_id)
if config is None or config.user_id != user_id:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="config not found")
await session.delete(config)
await session.commit()
+2 -57
View File
@@ -32,7 +32,6 @@ from src.models.config_profile import ConfigProfile
from src.models.git_repository import GitRepository
from src.models.project import Project
from src.models.ssh_key import SSHKey
from src.models.tool_config import ToolConfig
from src.models.tool_instance import ToolInstance
from src.models.tool_type import ToolType
from src.services.clone import check_dirty_state, clone_repository
@@ -976,7 +975,6 @@ async def _prepare_manifest_instance(
instance: ToolInstance,
instance_dir: str,
repo_path: str,
configs: list,
env_vars: dict,
extra_volumes: list,
working_directory: str | None,
@@ -1011,22 +1009,7 @@ async def _prepare_manifest_instance(
manifest_def.id,
)
# Merge tool configs
tool_config_dicts = [
{
"config_type": c.config_type,
"key": c.key,
"value": c.value,
"file_path": c.file_path,
"port_override": c.port_override,
"start_command": c.start_command,
"working_directory": c.working_directory,
"environment_variables": c.environment_variables,
"volumes": c.volumes,
}
for c in configs
]
manifest = merge_with_config(manifest, tool_config_dicts)
manifest = merge_with_config(manifest)
# Resolve extra env and volumes from merge_with_config
extra_env = manifest.pop("_extra_env", {})
@@ -1159,51 +1142,14 @@ async def start_instance(
await session.commit()
logger.info("Starting instance %s (name=%s)", instance.id, instance.name)
# Fetch tool configs for this tool type
# Runtime overrides populated by config profiles
env_vars = {}
config_files = {}
port_override = None
start_command = None
working_directory = None
extra_env_vars = {}
extra_volumes = []
config_query = (
select(ToolConfig)
.where(
ToolConfig.user_id == user_id,
ToolConfig.tool_type_id == instance.tool_type_id,
)
.where(
(ToolConfig.project_id == project_id) | (ToolConfig.project_id.is_(None))
)
)
config_result = await session.execute(config_query)
configs = config_result.scalars().all()
logger.debug("Found %d tool configs for instance %s", len(configs), instance.id)
for config in configs:
if config.config_type == "env":
env_vars[config.key] = config.value
elif config.config_type == "file" and config.file_path:
config_files[config.file_path] = config.value
# Handle new config fields
if config.port_override:
port_override = config.port_override
if config.start_command:
start_command = config.start_command
if config.working_directory:
working_directory = config.working_directory
if config.environment_variables:
extra_env_vars.update(config.environment_variables)
if config.volumes:
extra_volumes.extend(config.volumes)
# Merge extra env vars
env_vars.update(extra_env_vars)
# Apply selected config profile if any
instance_dir = os.path.dirname(instance.compose_path)
if instance.selected_config_profile_id is not None:
@@ -1288,7 +1234,6 @@ async def start_instance(
instance=instance,
instance_dir=instance_dir,
repo_path=repo_path,
configs=configs,
env_vars=env_vars,
extra_volumes=extra_volumes,
working_directory=working_directory,
+1 -4
View File
@@ -15,9 +15,7 @@ from src.api.projects import router as projects_router
from src.api.ssh_keys import router as ssh_keys_router
from src.api.terminal import router as terminal_router
from src.api.instance_proxy import router as instance_proxy_router
from src.api.config_folders import router as config_folders_router
from src.api.config_profiles import router as config_profiles_router
from src.api.tool_configs import router as tool_configs_router
from src.api.tool_definitions import router as tool_definitions_router
from src.api.tool_instances import router as tool_instances_router
from src.api.tool_instances import sessions_router
@@ -25,6 +23,7 @@ from src.api.tool_types import router as tool_types_router
from src.api.user_config import router as user_config_router
from src.api.users import router as users_router
from src.config import Settings
from src.models.terminal_session import TerminalSessionModel # noqa: F401 Alembic model discovery
from src.database import init_database
from src.logging_config import (
ExceptionLoggingMiddleware,
@@ -129,10 +128,8 @@ app.include_router(git_repositories_router)
app.include_router(user_config_router)
app.include_router(tool_types_router)
app.include_router(tool_definitions_router)
app.include_router(config_folders_router)
app.include_router(config_profiles_router)
app.include_router(tool_instances_router)
app.include_router(tool_configs_router)
app.include_router(sessions_router)
app.include_router(instance_proxy_router)
app.include_router(terminal_router)
+2 -2
View File
@@ -1,9 +1,9 @@
from src.models.base import Base
from src.models.config_folder import ConfigFolder
from src.models.config_profile import ConfigProfile, ConfigProfileInclude
from src.models.git_repository import GitRepository
from src.models.project import Project
from src.models.ssh_key import SSHKey
from src.models.terminal_session import TerminalSessionModel
from src.models.tool_definition_manifest import ToolDefinitionManifest
from src.models.tool_instance import ToolInstance
from src.models.tool_type import ToolType
@@ -12,12 +12,12 @@ from src.models.user_config import UserConfig
__all__ = [
"Base",
"ConfigFolder",
"ConfigProfile",
"ConfigProfileInclude",
"GitRepository",
"Project",
"SSHKey",
"TerminalSessionModel",
"ToolDefinitionManifest",
"ToolInstance",
"ToolType",
-31
View File
@@ -1,31 +0,0 @@
import uuid
from typing import TYPE_CHECKING
from sqlalchemy import Boolean, ForeignKey, JSON, String, Text
from sqlalchemy import Uuid as UUID
from sqlalchemy.orm import Mapped, mapped_column, relationship
from src.models.base import Base, TimestampMixin, UUIDPrimaryKeyMixin
if TYPE_CHECKING:
from src.models.user import User
class ConfigFolder(UUIDPrimaryKeyMixin, TimestampMixin, Base):
__tablename__ = "config_folders"
user_id: Mapped[uuid.UUID] = mapped_column(
UUID(), ForeignKey("users.id", ondelete="CASCADE"), nullable=False
)
name: Mapped[str] = mapped_column(String(255), nullable=False)
description: Mapped[str | None] = mapped_column(Text, nullable=True)
mount_path: Mapped[str] = mapped_column(String(1024), nullable=False)
files: Mapped[dict] = mapped_column(
JSON, default=dict, nullable=False
) # {"relative/path": "content", ...}
project_overrides: Mapped[dict | None] = mapped_column(
JSON, default=dict, nullable=True
) # {"project_id": {"mount_path": "...", "files": {...}}}
is_active: Mapped[bool] = mapped_column(Boolean, default=True, nullable=False)
user: Mapped["User"] = relationship()
+37
View File
@@ -0,0 +1,37 @@
"""Terminal session database model."""
import uuid
from datetime import datetime
from sqlalchemy import DateTime, ForeignKey, String
from sqlalchemy import Uuid as UUID
from sqlalchemy.orm import Mapped, mapped_column
from src.models.base import Base, TimestampMixin, UUIDPrimaryKeyMixin
class TerminalSessionModel(UUIDPrimaryKeyMixin, TimestampMixin, Base):
"""Database model for terminal session metadata."""
__tablename__ = "terminal_sessions"
instance_id: Mapped[uuid.UUID] = mapped_column(
UUID(),
ForeignKey("tool_instances.id", ondelete="CASCADE"),
nullable=False,
index=True,
)
name: Mapped[str | None] = mapped_column(String(255), nullable=True)
status: Mapped[str] = mapped_column(
String(50),
nullable=False,
default="active",
)
last_activity_at: Mapped[datetime | None] = mapped_column(
DateTime(timezone=True),
nullable=True,
)
closed_at: Mapped[datetime | None] = mapped_column(
DateTime(timezone=True),
nullable=True,
)
-48
View File
@@ -1,48 +0,0 @@
import uuid
from typing import TYPE_CHECKING
from sqlalchemy import ForeignKey, JSON, String, Text
from sqlalchemy import Uuid as UUID
from sqlalchemy.orm import Mapped, mapped_column, relationship
from src.models.base import Base, TimestampMixin, UUIDPrimaryKeyMixin
if TYPE_CHECKING:
from src.models.project import Project
from src.models.tool_type import ToolType
from src.models.user import User
class ToolConfig(UUIDPrimaryKeyMixin, TimestampMixin, Base):
__tablename__ = "tool_configs"
user_id: Mapped[uuid.UUID] = mapped_column(
UUID(), ForeignKey("users.id"), nullable=False
)
tool_type_id: Mapped[uuid.UUID] = mapped_column(
UUID(), ForeignKey("tool_types.id"), nullable=False
)
project_id: Mapped[uuid.UUID | None] = mapped_column(
UUID(), ForeignKey("projects.id"), nullable=True
)
key: Mapped[str] = mapped_column(String(255), nullable=False)
value: Mapped[str] = mapped_column(Text, nullable=False)
config_type: Mapped[str] = mapped_column(
String(20), nullable=False, default="env"
) # "env" or "file"
file_path: Mapped[str | None] = mapped_column(
String(1024), nullable=True
) # Only for file type
port_override: Mapped[int | None] = mapped_column(nullable=True)
start_command: Mapped[str | None] = mapped_column(Text, nullable=True)
working_directory: Mapped[str | None] = mapped_column(Text, nullable=True)
environment_variables: Mapped[dict | None] = mapped_column(
JSON, default=dict, nullable=True
)
volumes: Mapped[list[dict] | None] = mapped_column(
JSON, default=list, nullable=True
)
user: Mapped["User"] = relationship()
tool_type: Mapped["ToolType"] = relationship()
project: Mapped["Project | None"] = relationship()
+2 -25
View File
@@ -350,14 +350,11 @@ def compute_image_tag(tool_name: str, manifest: dict) -> str:
return f"headquarter/{safe_name}-{hash_suffix}:latest"
def merge_with_config(
manifest: dict, tool_configs: list[dict], profile: dict | None = None
) -> dict:
"""Merge ToolConfig and ConfigProfile overrides into a manifest.
def merge_with_config(manifest: dict, profile: dict | None = None) -> dict:
"""Merge ConfigProfile overrides into a manifest.
Args:
manifest: Base manifest from tool definition.
tool_configs: List of ToolConfig records.
profile: Resolved ConfigProfile (optional).
Returns:
@@ -365,29 +362,9 @@ def merge_with_config(
"""
result = deepcopy(manifest)
# Apply ToolConfigs
extra_env: dict[str, str] = {}
extra_volumes: list[dict] = []
for config in tool_configs:
if config.get("config_type") == "env":
extra_env[config["key"]] = config["value"]
elif config.get("config_type") == "file" and config.get("file_path"):
# Files are handled outside the manifest (written to instance dir)
pass
if config.get("port_override"):
result["default_port"] = config["port_override"]
if config.get("start_command"):
result["runtime"] = result.get("runtime", {})
result["runtime"]["command"] = config["start_command"].split()
if config.get("working_directory"):
result["runtime"] = result.get("runtime", {})
result["runtime"]["working_dir"] = config["working_directory"]
if config.get("environment_variables"):
extra_env.update(config["environment_variables"])
if config.get("volumes"):
extra_volumes.extend(config["volumes"])
# Apply ConfigProfile
if profile:
if profile.get("environment_variables"):
+302 -46
View File
@@ -3,20 +3,37 @@
import asyncio
import logging
import uuid
from datetime import datetime, timezone
from fastapi import WebSocket
from src.database import SessionLocal
from src.models.terminal_session import TerminalSessionModel
from src.services.terminal_session import TerminalSession
logger = logging.getLogger(__name__)
class MaxSessionsExceededError(Exception):
"""Raised when the maximum number of terminal sessions per instance is reached."""
def __init__(self, instance_id: str, max_sessions: int = 5) -> None:
self.instance_id = instance_id
self.max_sessions = max_sessions
super().__init__(
f"Maximum of {max_sessions} terminal sessions reached for instance {instance_id}"
)
class TerminalManager:
"""Manages active terminal sessions with persistence support."""
# Maximum sessions per tool instance
MAX_SESSIONS_PER_INSTANCE = 5
def __init__(self) -> None:
# Track sessions by instance_id for persistence
self._sessions: dict[str, TerminalSession] = {}
# Track sessions by (instance_id, session_id) for multi-session support
self._sessions: dict[tuple[str, str], TerminalSession] = {}
self._idle_check_task: asyncio.Task | None = None
self._start_idle_check()
@@ -42,16 +59,133 @@ class TerminalManager:
async def _cleanup_idle_sessions(self) -> None:
"""Clean up sessions that have been idle for too long."""
idle_sessions = []
for instance_id, session in list(self._sessions.items()):
idle_keys = []
for (instance_id, session_id), session in list(self._sessions.items()):
if session.is_idle():
idle_sessions.append(instance_id)
for instance_id in idle_sessions:
logger.info("Cleaning up idle terminal session for instance %s", instance_id)
session = self._sessions.pop(instance_id, None)
idle_keys.append((instance_id, session_id))
for key in idle_keys:
instance_id, session_id = key
logger.info(
"Cleaning up idle terminal session %s for instance %s",
session_id,
instance_id,
)
session = self._sessions.pop(key, None)
if session:
await session.close()
# Update DB status fire-and-forget
asyncio.create_task(self._mark_closed_in_db(session_id))
async def _insert_db_session_row(
self,
session_id: str,
instance_id: uuid.UUID,
name: str,
) -> None:
"""Insert a TerminalSessionModel row into the database."""
try:
async with SessionLocal() as db_session:
db_row = TerminalSessionModel(
id=uuid.UUID(session_id),
instance_id=instance_id,
name=name,
status="active",
created_at=datetime.now(timezone.utc),
last_activity_at=datetime.now(timezone.utc),
)
db_session.add(db_row)
await db_session.commit()
logger.debug(
"Inserted terminal session row %s for instance %s",
session_id,
instance_id,
)
except Exception as exc:
logger.error("Failed to insert terminal session row: %s", exc)
async def _mark_closed_in_db(self, session_id: str) -> None:
"""Mark a terminal session as closed in the database."""
try:
async with SessionLocal() as db_session:
db_row = await db_session.get(
TerminalSessionModel, uuid.UUID(session_id)
)
if db_row:
db_row.status = "closed"
db_row.closed_at = datetime.now(timezone.utc)
await db_session.commit()
logger.debug(
"Marked terminal session %s as closed in DB", session_id
)
except Exception as exc:
logger.error("Failed to mark terminal session as closed in DB: %s", exc)
def _count_sessions_for_instance(self, instance_id_str: str) -> int:
"""Count active in-memory sessions for a given instance."""
return sum(1 for (iid, _sid) in self._sessions if iid == instance_id_str)
async def create_session(
self,
instance_id: uuid.UUID,
container_id: str,
startup_command: str | None = None,
name: str | None = None,
session_id: str | None = None,
) -> TerminalSession:
"""Create a new terminal session for an instance.
Enforces a maximum of MAX_SESSIONS_PER_INSTANCE sessions per instance.
Inserts a DB row fire-and-forget.
Args:
instance_id: UUID of the tool instance.
container_id: Docker container ID.
startup_command: Optional startup command to run.
name: Optional session name (auto-generated if omitted).
Returns:
The newly created TerminalSession.
Raises:
MaxSessionsExceededError: If the instance already has max sessions.
"""
instance_id_str = str(instance_id)
if (
self._count_sessions_for_instance(instance_id_str)
>= self.MAX_SESSIONS_PER_INSTANCE
):
raise MaxSessionsExceededError(
instance_id_str, self.MAX_SESSIONS_PER_INSTANCE
)
if session_id is None:
session_id = str(uuid.uuid4())
session = TerminalSession(
session_id=session_id,
instance_id=instance_id,
container_id=container_id,
startup_command=startup_command,
name=name,
)
await session.start(startup_command=startup_command)
key = (instance_id_str, session_id)
self._sessions[key] = session
# Fire-and-forget DB insert (skip if row already exists)
asyncio.create_task(
self._insert_db_session_row(session_id, instance_id, session.name)
)
logger.info(
"Created terminal session %s for instance %s (name=%s)",
session_id,
instance_id,
session.name,
)
return session
async def get_or_create_session(
self,
@@ -59,61 +193,146 @@ class TerminalManager:
container_id: str,
startup_command: str | None = None,
) -> TerminalSession:
"""Get existing session or create a new one."""
"""Get existing session or create a new one.
Backward-compatible alias that uses 'default' as the session_id.
"""
# Ensure idle check is running (lazy start)
self._start_idle_check()
instance_id_str = str(instance_id)
# Check for existing session
if instance_id_str in self._sessions:
session = self._sessions[instance_id_str]
key = (instance_id_str, "default")
# Check for existing default session
if key in self._sessions:
session = self._sessions[key]
# Check if session is still alive
if session.is_alive():
logger.debug("Reattaching to existing terminal session for instance %s", instance_id)
logger.debug(
"Reattaching to existing terminal session for instance %s",
instance_id,
)
return session
else:
# Session died, clean it up
logger.debug("Existing session for instance %s is dead, cleaning up", instance_id)
logger.debug(
"Existing session for instance %s is dead, cleaning up",
instance_id,
)
await session.close()
del self._sessions[instance_id_str]
# Create new session
logger.info("Creating new terminal session for instance %s", instance_id)
del self._sessions[key]
# Create new default session
logger.info(
"Creating new default terminal session for instance %s", instance_id
)
session_id = str(uuid.uuid4())
session = TerminalSession(session_id, instance_id, container_id, startup_command=startup_command)
session = TerminalSession(
session_id=session_id,
instance_id=instance_id,
container_id=container_id,
startup_command=startup_command,
name="Session 1",
)
await session.start(startup_command=startup_command)
self._sessions[instance_id_str] = session
self._sessions[key] = session
# Fire-and-forget DB insert
asyncio.create_task(
self._insert_db_session_row(session_id, instance_id, session.name)
)
return session
def get_session(
self,
instance_id: str,
session_id: str,
) -> TerminalSession | None:
"""Lookup a session by composite key, or by internal session_id."""
session = self._sessions.get((instance_id, session_id))
if session is not None:
return session
# Fallback: search by internal TerminalSession.session_id
for (iid, _sid), sess in self._sessions.items():
if iid == instance_id and sess.session_id == session_id:
return sess
return None
def _find_key_by_internal_id(
self,
instance_id: str,
internal_session_id: str,
) -> tuple[str, str] | None:
"""Find the manager dict key for a session by its internal session_id."""
for (iid, sid), session in self._sessions.items():
if iid == instance_id and session.session_id == internal_session_id:
return (iid, sid)
return None
def get_sessions_for_instance(
self,
instance_id: str,
) -> list[TerminalSession]:
"""Return all in-memory sessions for a given instance."""
return [
session
for (iid, _sid), session in self._sessions.items()
if iid == instance_id
]
async def close_session(
self,
instance_id: str,
session_id: str,
) -> None:
"""Close a specific session and update its DB status."""
key = (instance_id, session_id)
session = self._sessions.pop(key, None)
if session:
await session.close()
# Fire-and-forget DB update
asyncio.create_task(self._mark_closed_in_db(session_id))
logger.info(
"Closed terminal session %s for instance %s",
session_id,
instance_id,
)
async def attach_websocket(
self,
session: TerminalSession,
websocket: WebSocket,
) -> None:
"""Attach a WebSocket to an existing session."""
# Handle concurrent connections - close existing ones
"""Attach a WebSocket to an existing session.
Closes existing WebSocket connections only for this specific session.
"""
# Handle concurrent connections - close existing ones within the same session
if session.has_websockets():
logger.debug("Closing existing WebSocket connections for instance %s", session.instance_id)
logger.debug(
"Closing existing WebSocket connections for session %s (instance %s)",
session.session_id,
session.instance_id,
)
for ws in list(session._websockets):
try:
await ws.close(code=4000, reason="New connection established")
except Exception:
pass
pass # noqa: S110
session._websockets.clear()
# Attach new WebSocket
session.attach_websocket(websocket)
# Replay buffer
buffer = session.get_buffer()
if buffer:
try:
await websocket.send_bytes(buffer)
except Exception:
pass
pass # noqa: S110
async def detach_websocket(
self,
@@ -128,23 +347,60 @@ class TerminalManager:
instance_id: uuid.UUID,
container_id: str,
startup_command: str | None = None,
session_id: str | None = None,
name: str | None = None,
) -> TerminalSession:
"""Reset a session by killing it and creating a new one."""
"""Reset a session by killing it and creating a new one.
Args:
instance_id: UUID of the tool instance.
container_id: Docker container ID.
startup_command: Optional startup command.
session_id: Specific session to reset. If None, resets the default session.
name: Optional name to preserve for the new session.
Returns:
The newly created TerminalSession.
"""
instance_id_str = str(instance_id)
target_session_id = session_id or "default"
key = (instance_id_str, target_session_id)
# Preserve old name if not provided
old_name = name
if old_name is None and key in self._sessions:
old_name = self._sessions[key].name
# Close existing session if any
if instance_id_str in self._sessions:
logger.debug("Resetting terminal session for instance %s", instance_id)
old_session = self._sessions.pop(instance_id_str)
if key in self._sessions:
logger.debug(
"Resetting terminal session %s for instance %s",
target_session_id,
instance_id,
)
old_session = self._sessions.pop(key)
await old_session.close()
# Create new session
session_id = str(uuid.uuid4())
session = TerminalSession(session_id, instance_id, container_id, startup_command=startup_command)
await session.start(startup_command=startup_command)
self._sessions[instance_id_str] = session
return session
# Fire-and-forget DB update for old session
asyncio.create_task(self._mark_closed_in_db(old_session.session_id))
# Create new session preserving the same session_id slot
new_session_id = str(uuid.uuid4())
new_session = TerminalSession(
session_id=new_session_id,
instance_id=instance_id,
container_id=container_id,
startup_command=startup_command,
name=old_name or ("Session 1" if target_session_id == "default" else None),
)
await new_session.start(startup_command=startup_command)
self._sessions[key] = new_session
# Fire-and-forget DB insert
asyncio.create_task(
self._insert_db_session_row(new_session_id, instance_id, new_session.name)
)
return new_session
async def close_all(self) -> None:
"""Close all active sessions."""
@@ -152,7 +408,7 @@ class TerminalManager:
self._sessions.clear()
for session in sessions:
await session.close()
if self._idle_check_task and not self._idle_check_task.done():
self._idle_check_task.cancel()
+55 -23
View File
@@ -18,18 +18,28 @@ logger = logging.getLogger(__name__)
class TerminalSession:
"""Manages a single terminal session connected to a docker container.
Supports persistent sessions that survive WebSocket disconnections.
Multiple WebSocket connections can attach/detach from the same session.
"""
# Circular buffer size (10KB)
BUFFER_SIZE = 10 * 1024
# Idle timeout in seconds (30 minutes)
IDLE_TIMEOUT = 30 * 60
def __init__(self, session_id: str, instance_id: uuid.UUID, container_id: str, startup_command: str | None = None) -> None:
# Session number counter per instance_id for auto-naming
_instance_counters: dict[str, int] = {}
def __init__(
self,
session_id: str,
instance_id: uuid.UUID,
container_id: str,
startup_command: str | None = None,
name: str | None = None,
) -> None:
self.session_id = session_id
self.instance_id = instance_id
self.container_id = container_id
@@ -38,37 +48,52 @@ class TerminalSession:
self._closed = False
self._master_fd: int | None = None
self._slave_fd: int | None = None
# Circular buffer for output replay
self._output_buffer: deque[bytes] = deque(maxlen=self.BUFFER_SIZE)
self._buffer_size = 0
# WebSocket connections
self._websockets: set[Any] = set()
# Activity tracking
self.last_activity = time.time()
# Terminal size
self._cols = 80
self._rows = 24
# Session metadata
self.name = name or self._generate_name(str(instance_id))
self.status: str = "active"
@classmethod
def _generate_name(cls, instance_id: str) -> str:
"""Generate an auto-incremented session name for the instance."""
count = cls._instance_counters.get(instance_id, 0) + 1
cls._instance_counters[instance_id] = count
return f"Session {count}"
async def start(self, startup_command: str | None = None) -> None:
"""Start the docker exec process with a shell using a PTY."""
# Create a pseudo-terminal on the host
self._master_fd, self._slave_fd = pty.openpty()
# Set the terminal size initially
self._set_terminal_size(self._cols, self._rows)
logger.debug(f"Starting terminal session {self.session_id} for container {self.container_id} with initial size {self._cols}x{self._rows}")
logger.debug(
f"Starting terminal session {self.session_id} for container {self.container_id} with initial size {self._cols}x{self._rows}"
)
# Build the shell command
if startup_command:
shell_cmd = f'bash -c "{startup_command}" || true; exec bash -il'
logger.debug(f"Using startup command for session {self.session_id}: {startup_command}")
logger.debug(
f"Using startup command for session {self.session_id}: {startup_command}"
)
else:
shell_cmd = "bash -il"
# Start docker exec with the slave fd as stdin/stdout/stderr
# Using -it because the slave fd IS a TTY
self.process = await asyncio.create_subprocess_exec(
@@ -85,11 +110,11 @@ class TerminalSession:
stdout=self._slave_fd,
stderr=self._slave_fd,
)
# Close slave fd in parent process
os.close(self._slave_fd)
self._slave_fd = None
self.last_activity = time.time()
def _set_terminal_size(self, cols: int, rows: int) -> None:
@@ -99,7 +124,7 @@ class TerminalSession:
return
# TIOCSWINSZ = 0x5414 on Linux
TIOCSWINSZ = 0x5414
size = struct.pack('HHHH', rows, cols, 0, 0)
size = struct.pack("HHHH", rows, cols, 0, 0)
try:
fcntl.ioctl(self._master_fd, TIOCSWINSZ, size)
logger.debug(f"Resized PTY to {cols}x{rows} (fd={self._master_fd})")
@@ -127,7 +152,7 @@ class TerminalSession:
"""Add data to circular buffer, maintaining size limit."""
self._output_buffer.append(data)
self._buffer_size += len(data)
# Trim if exceeds max size
while self._buffer_size > self.BUFFER_SIZE and self._output_buffer:
removed = self._output_buffer.popleft()
@@ -152,16 +177,16 @@ class TerminalSession:
if self._closed:
logger.warning("Cannot resize: session is closed")
return
# Only resize if dimensions actually changed
if cols == self._cols and rows == self._rows:
return
self._cols = cols
self._rows = rows
logger.debug(f"resize() called for session {self.session_id}: {cols}x{rows}")
self._set_terminal_size(cols, rows)
# Docker exec -it creates its own PTY inside the container,
# so host PTY resize doesn't propagate to the container shell.
# Send SIGWINCH to the docker exec process on the host.
@@ -170,14 +195,19 @@ class TerminalSession:
if self.process and self.process.pid:
try:
os.kill(self.process.pid, signal.SIGWINCH)
logger.debug(f"Sent SIGWINCH to docker exec process {self.process.pid} for session {self.session_id}")
logger.debug(
f"Sent SIGWINCH to docker exec process {self.process.pid} for session {self.session_id}"
)
except ProcessLookupError:
logger.warning(f"docker exec process {self.process.pid} not found for session {self.session_id}")
logger.warning(
f"docker exec process {self.process.pid} not found for session {self.session_id}"
)
except Exception as e:
logger.warning(f"Failed to send SIGWINCH: {e}")
async def reset(self) -> None:
"""Reset the session by killing the process and clearing state."""
self.status = "resetting"
await self.close()
self._closed = False
self._output_buffer.clear()
@@ -186,18 +216,20 @@ class TerminalSession:
self.process = None
self._master_fd = None
self._slave_fd = None
self.status = "active"
async def close(self) -> None:
"""Close the session and cleanup."""
if self._closed:
return
self._closed = True
self.status = "closed"
if self._master_fd is not None:
try:
os.close(self._master_fd)
except OSError:
pass
pass # noqa: S110
self._master_fd = None
if self.process is not None:
@@ -240,7 +272,7 @@ class TerminalSession:
await ws.send_bytes(data)
except Exception:
dead_sockets.add(ws)
# Clean up dead sockets
for ws in dead_sockets:
self._websockets.discard(ws)
@@ -0,0 +1,67 @@
"""Integration tests for multi-session terminal WebSocket and REST API."""
import pytest
from fastapi.testclient import TestClient
from src.main import app
@pytest.fixture
def client():
return TestClient(app)
class TestTerminalWebSocketMultiSession:
"""Tests for multi-session WebSocket routing."""
def test_specific_session_websocket_route_exists(self, client):
"""The specific session WebSocket route should be registered."""
# We can't easily test WebSocket without auth, but we can verify
# the route exists by checking for a 403 (no auth cookie)
response = client.get("/ws/tool-instances/test-instance/terminal/test-session")
# WebSocket endpoint returns 403 when accessed via HTTP GET
assert response.status_code in (403, 404)
def test_default_session_alias_route_exists(self, client):
"""The default session alias route should still exist."""
response = client.get("/ws/tool-instances/test-instance/terminal")
assert response.status_code in (403, 404)
class TestTerminalRestApi:
"""Tests for REST API endpoints."""
def test_list_sessions_requires_auth(self, client):
"""List sessions endpoint requires authentication."""
response = client.get("/instances/test/terminal/sessions")
assert response.status_code == 401
def test_create_session_requires_auth(self, client):
"""Create session endpoint requires authentication."""
response = client.post(
"/instances/test/terminal/sessions",
json={},
)
assert response.status_code == 401
def test_close_session_requires_auth(self, client):
"""Close session endpoint requires authentication."""
response = client.delete("/instances/test/terminal/sessions/test-session")
assert response.status_code == 401
def test_reset_session_requires_auth(self, client):
"""Reset session endpoint requires authentication."""
response = client.post("/instances/test/terminal/sessions/test-session/reset")
assert response.status_code == 401
def test_rename_session_requires_auth(self, client):
"""Rename session endpoint requires authentication."""
response = client.post(
"/instances/test/terminal/sessions/test-session/rename",
json={"name": "New Name"},
)
assert response.status_code == 401
def test_legacy_reset_alias_requires_auth(self, client):
"""Legacy reset endpoint still requires auth."""
response = client.post("/instances/test/terminal/reset")
assert response.status_code == 401
@@ -1,255 +0,0 @@
import uuid
import pytest
from fastapi.testclient import TestClient
@pytest.mark.integration
class TestConfigFoldersAPI:
"""Integration tests for config folders API."""
def test_list_config_folders_requires_authentication(self, test_client: TestClient) -> None:
"""Test that listing config folders requires authentication."""
response = test_client.get("/config-folders")
assert response.status_code == 401
def test_list_config_folders_returns_user_folders(self, authenticated_client: TestClient) -> None:
"""Test that authenticated users can list their folders."""
response = authenticated_client.get("/config-folders")
assert response.status_code == 200
data = response.json()
assert isinstance(data, dict)
assert "folders" in data
assert isinstance(data["folders"], list)
def test_create_config_folder_successfully(self, authenticated_client: TestClient) -> None:
"""Test creating a config folder."""
response = authenticated_client.post(
"/config-folders",
json={
"name": "test-folder",
"description": "Test folder",
"mount_path": "/home/user",
"files": {"test.txt": "hello world"},
},
)
assert response.status_code == 201
data = response.json()
assert data["name"] == "test-folder"
assert data["mount_path"] == "/home/user"
assert data["files"] == {"test.txt": "hello world"}
def test_create_config_folder_duplicate_name(self, authenticated_client: TestClient) -> None:
"""Test that duplicate folder names are rejected."""
# Create first folder
response = authenticated_client.post(
"/config-folders",
json={
"name": "duplicate-folder",
"mount_path": "/home/user",
"files": {},
},
)
assert response.status_code == 201
# Try to create second with same name
response = authenticated_client.post(
"/config-folders",
json={
"name": "duplicate-folder",
"mount_path": "/home/user",
"files": {},
},
)
assert response.status_code == 409
def test_create_config_folder_exceeds_size_limit(self, authenticated_client: TestClient) -> None:
"""Test that folders exceeding 10MB are rejected."""
large_content = "x" * (11 * 1024 * 1024) # 11MB
response = authenticated_client.post(
"/config-folders",
json={
"name": "large-folder",
"mount_path": "/home/user",
"files": {"large.txt": large_content},
},
)
assert response.status_code == 422
def test_create_config_folder_path_traversal_attack(self, authenticated_client: TestClient) -> None:
"""Test that path traversal in file paths is prevented."""
response = authenticated_client.post(
"/config-folders",
json={
"name": "bad-folder",
"mount_path": "/home/user",
"files": {"../../../etc/passwd": "malicious"},
},
)
assert response.status_code == 422
def test_get_config_folder_by_id(self, authenticated_client: TestClient) -> None:
"""Test getting a config folder by ID."""
# Create folder first
create_response = authenticated_client.post(
"/config-folders",
json={
"name": "get-test",
"mount_path": "/home/user",
"files": {},
},
)
folder_id = create_response.json()["id"]
# Get it back
response = authenticated_client.get(f"/config-folders/{folder_id}")
assert response.status_code == 200
data = response.json()
assert data["name"] == "get-test"
def test_get_config_folder_not_found(self, authenticated_client: TestClient) -> None:
"""Test getting a non-existent folder."""
response = authenticated_client.get(f"/config-folders/{uuid.uuid4()}")
assert response.status_code == 404
def test_update_config_folder_successfully(self, authenticated_client: TestClient) -> None:
"""Test updating a config folder."""
# Create folder first
create_response = authenticated_client.post(
"/config-folders",
json={
"name": "update-test",
"mount_path": "/home/user",
"files": {},
},
)
folder_id = create_response.json()["id"]
# Update it
response = authenticated_client.put(
f"/config-folders/{folder_id}",
json={
"name": "updated-name",
"mount_path": "/workspace",
"files": {"new.txt": "content"},
},
)
assert response.status_code == 200
data = response.json()
assert data["name"] == "updated-name"
assert data["mount_path"] == "/workspace"
def test_delete_config_folder_successfully(self, authenticated_client: TestClient) -> None:
"""Test deleting a config folder."""
# Create folder first
create_response = authenticated_client.post(
"/config-folders",
json={
"name": "delete-test",
"mount_path": "/home/user",
"files": {},
},
)
folder_id = create_response.json()["id"]
# Delete it
response = authenticated_client.delete(f"/config-folders/{folder_id}")
assert response.status_code == 204
# Verify it's gone
get_response = authenticated_client.get(f"/config-folders/{folder_id}")
assert get_response.status_code == 404
def test_add_project_override_successfully(self, authenticated_client: TestClient) -> None:
"""Test adding a project override."""
# Create folder first
create_response = authenticated_client.post(
"/config-folders",
json={
"name": "override-test",
"mount_path": "/home/user",
"files": {"global.txt": "global"},
},
)
folder_id = create_response.json()["id"]
project_id = str(uuid.uuid4())
# Add override
response = authenticated_client.post(
f"/config-folders/{folder_id}/overrides",
json={
"project_id": project_id,
"mount_path": "/workspace",
"files": {"project.txt": "project"},
},
)
assert response.status_code == 200
data = response.json()
assert project_id in data["project_overrides"]
def test_update_project_override_successfully(self, authenticated_client: TestClient) -> None:
"""Test updating a project override."""
# Create folder with override
create_response = authenticated_client.post(
"/config-folders",
json={
"name": "update-override-test",
"mount_path": "/home/user",
"files": {},
},
)
folder_id = create_response.json()["id"]
project_id = str(uuid.uuid4())
# Add override
authenticated_client.post(
f"/config-folders/{folder_id}/overrides",
json={
"project_id": project_id,
"mount_path": "/workspace",
"files": {"old.txt": "old"},
},
)
# Update override
response = authenticated_client.put(
f"/config-folders/{folder_id}/overrides/{project_id}",
json={
"mount_path": "/app",
"files": {"new.txt": "new"},
},
)
assert response.status_code == 200
data = response.json()
assert data["project_overrides"][project_id]["mount_path"] == "/app"
def test_delete_project_override_successfully(self, authenticated_client: TestClient) -> None:
"""Test deleting a project override."""
# Create folder with override
create_response = authenticated_client.post(
"/config-folders",
json={
"name": "delete-override-test",
"mount_path": "/home/user",
"files": {},
},
)
folder_id = create_response.json()["id"]
project_id = str(uuid.uuid4())
# Add override
authenticated_client.post(
f"/config-folders/{folder_id}/overrides",
json={
"project_id": project_id,
"mount_path": "/workspace",
"files": {},
},
)
# Delete override
response = authenticated_client.delete(
f"/config-folders/{folder_id}/overrides/{project_id}"
)
assert response.status_code == 200
data = response.json()
assert project_id not in data["project_overrides"]
@@ -1,255 +0,0 @@
import pytest
from fastapi.testclient import TestClient
@pytest.mark.integration
class TestToolConfigsAPIExtended:
"""Integration tests for tool configs API with new fields."""
def test_create_tool_config_with_new_fields(self, authenticated_client: TestClient) -> None:
"""Test creating a tool config with all new fields."""
# Create a tool type first
tool_response = authenticated_client.post(
"/tool-types",
json={
"name": "config-test-tool",
"display_name": "Config Test Tool",
"default_port": 8080,
"definition_type": "compose",
"compose_template": "version: '3.8'\nservices:\n app:\n image: nginx",
"required_variables": [],
},
)
tool_id = tool_response.json()["id"]
# Create config with new fields
response = authenticated_client.post(
"/tool-configs",
json={
"tool_type_id": tool_id,
"key": "ADVANCED_CONFIG",
"value": "test-value",
"config_type": "env",
"port_override": 9090,
"start_command": "python app.py",
"working_directory": "/app",
"environment_variables": {"DEBUG": "true", "LOG_LEVEL": "debug"},
"volumes": [
{"source": "data", "target": "/data", "type": "bind"}
],
},
)
assert response.status_code == 201
data = response.json()
assert data["key"] == "ADVANCED_CONFIG"
assert data["port_override"] == 9090
assert data["start_command"] == "python app.py"
assert data["working_directory"] == "/app"
assert data["environment_variables"] == {"DEBUG": "true", "LOG_LEVEL": "debug"}
assert data["volumes"] == [{"source": "data", "target": "/data", "type": "bind"}]
def test_create_tool_config_invalid_port(self, authenticated_client: TestClient) -> None:
"""Test that invalid port numbers are rejected."""
# Create a tool type first
tool_response = authenticated_client.post(
"/tool-types",
json={
"name": "port-test-tool",
"display_name": "Port Test Tool",
"default_port": 8080,
"definition_type": "compose",
"compose_template": "version: '3.8'\nservices:\n app:\n image: nginx",
"required_variables": [],
},
)
tool_id = tool_response.json()["id"]
# Try to create config with invalid port
response = authenticated_client.post(
"/tool-configs",
json={
"tool_type_id": tool_id,
"key": "BAD_PORT",
"value": "test",
"config_type": "env",
"port_override": 99999,
},
)
assert response.status_code == 422
def test_create_tool_config_invalid_volume_structure(self, authenticated_client: TestClient) -> None:
"""Test that invalid volume structures are rejected."""
# Create a tool type first
tool_response = authenticated_client.post(
"/tool-types",
json={
"name": "volume-test-tool",
"display_name": "Volume Test Tool",
"default_port": 8080,
"definition_type": "compose",
"compose_template": "version: '3.8'\nservices:\n app:\n image: nginx",
"required_variables": [],
},
)
tool_id = tool_response.json()["id"]
# Try to create config with invalid volume
response = authenticated_client.post(
"/tool-configs",
json={
"tool_type_id": tool_id,
"key": "BAD_VOLUME",
"value": "test",
"config_type": "env",
"volumes": [{"invalid": "structure"}],
},
)
assert response.status_code == 422
def test_update_tool_config_with_new_fields(self, authenticated_client: TestClient) -> None:
"""Test updating a tool config with new fields."""
# Create a tool type first
tool_response = authenticated_client.post(
"/tool-types",
json={
"name": "update-config-tool",
"display_name": "Update Config Tool",
"default_port": 8080,
"definition_type": "compose",
"compose_template": "version: '3.8'\nservices:\n app:\n image: nginx",
"required_variables": [],
},
)
tool_id = tool_response.json()["id"]
# Create config
create_response = authenticated_client.post(
"/tool-configs",
json={
"tool_type_id": tool_id,
"key": "UPDATE_TEST",
"value": "original",
"config_type": "env",
},
)
config_id = create_response.json()["id"]
# Update with new fields
response = authenticated_client.put(
f"/tool-configs/{config_id}",
json={
"value": "updated",
"port_override": 3000,
"start_command": "npm start",
"working_directory": "/workspace",
"environment_variables": {"NODE_ENV": "production"},
"volumes": [{"source": "src", "target": "/app/src", "type": "bind"}],
},
)
assert response.status_code == 200
data = response.json()
assert data["value"] == "updated"
assert data["port_override"] == 3000
assert data["start_command"] == "npm start"
assert data["working_directory"] == "/workspace"
assert data["environment_variables"] == {"NODE_ENV": "production"}
def test_list_tool_configs_returns_new_fields(self, authenticated_client: TestClient) -> None:
"""Test that listing configs returns new fields."""
# Create a tool type first
tool_response = authenticated_client.post(
"/tool-types",
json={
"name": "list-config-tool",
"display_name": "List Config Tool",
"default_port": 8080,
"definition_type": "compose",
"compose_template": "version: '3.8'\nservices:\n app:\n image: nginx",
"required_variables": [],
},
)
tool_id = tool_response.json()["id"]
# Create config with new fields
authenticated_client.post(
"/tool-configs",
json={
"tool_type_id": tool_id,
"key": "LIST_TEST",
"value": "test",
"config_type": "env",
"port_override": 5000,
"environment_variables": {"TEST": "true"},
},
)
# List configs
response = authenticated_client.get("/tool-configs")
assert response.status_code == 200
data = response.json()
assert len(data) > 0
config = data[0]
assert "port_override" in config
assert "start_command" in config
assert "working_directory" in config
assert "environment_variables" in config
assert "volumes" in config
def test_get_tool_config_defaults(self, authenticated_client: TestClient) -> None:
"""Test getting tool config defaults."""
# Create a tool type first
tool_response = authenticated_client.post(
"/tool-types",
json={
"name": "defaults-tool",
"display_name": "Defaults Tool",
"default_port": 8080,
"definition_type": "compose",
"compose_template": "version: '3.8'\nservices:\n app:\n image: nginx\n volumes:\n - \"{{REPO_PATH}}:/workspace\"\n",
"required_variables": ["REPO_PATH"],
},
)
tool_id = tool_response.json()["id"]
# Get defaults
response = authenticated_client.get(f"/tool-configs/defaults/{tool_id}")
assert response.status_code == 200
data = response.json()
assert data["tool_type_id"] == tool_id
assert "suggested_configs" in data
def test_tool_config_backward_compatibility(self, authenticated_client: TestClient) -> None:
"""Test that old configs without new fields still work."""
# Create a tool type first
tool_response = authenticated_client.post(
"/tool-types",
json={
"name": "backward-compat-tool",
"display_name": "Backward Compat Tool",
"default_port": 8080,
"definition_type": "compose",
"compose_template": "version: '3.8'\nservices:\n app:\n image: nginx",
"required_variables": [],
},
)
tool_id = tool_response.json()["id"]
# Create config without new fields (simulating old client)
response = authenticated_client.post(
"/tool-configs",
json={
"tool_type_id": tool_id,
"key": "OLD_STYLE",
"value": "value",
"config_type": "env",
},
)
assert response.status_code == 201
data = response.json()
assert data["key"] == "OLD_STYLE"
# New fields should have default values
assert data["port_override"] is None
assert data["start_command"] is None
assert data["working_directory"] is None
assert data["environment_variables"] is None
assert data["volumes"] is None
@@ -0,0 +1,203 @@
"""Unit tests for TerminalManager multi-session support."""
import asyncio
import uuid
from unittest.mock import AsyncMock, patch
import pytest
from src.services.terminal_manager import MaxSessionsExceededError, TerminalManager
from src.services.terminal_session import TerminalSession
@pytest.fixture
def manager() -> TerminalManager:
"""Provide a fresh TerminalManager instance for each test."""
tm = TerminalManager()
# Cancel the background idle check to avoid side effects
if tm._idle_check_task and not tm._idle_check_task.done():
tm._idle_check_task.cancel()
return tm
@pytest.fixture
def mock_terminal_session(monkeypatch) -> None:
"""Monkeypatch TerminalSession.start and is_alive for unit tests."""
async def fake_start(self, startup_command=None):
self.last_activity = __import__("time").time()
monkeypatch.setattr(TerminalSession, "start", fake_start)
monkeypatch.setattr(TerminalSession, "is_alive", lambda self: True)
@pytest.fixture
def instance_id() -> uuid.UUID:
return uuid.uuid4()
class FakeWebSocket:
"""Minimal fake WebSocket for testing attach/detach behavior."""
def __init__(self, name: str = "ws") -> None:
self.name = name
self.closed = False
self.close_code: int | None = None
self.close_reason: str | None = None
self._sent: list[bytes] = []
async def close(self, code: int = 1000, reason: str = "") -> None:
self.closed = True
self.close_code = code
self.close_reason = reason
async def send_bytes(self, data: bytes) -> None:
self._sent.append(data)
@pytest.mark.asyncio
async def test_create_session_increases_count(
manager: TerminalManager,
mock_terminal_session,
instance_id: uuid.UUID,
) -> None:
"""Creating sessions increments the per-instance count."""
assert len(manager.get_sessions_for_instance(str(instance_id))) == 0
session1 = await manager.create_session(instance_id, "container-1")
assert len(manager.get_sessions_for_instance(str(instance_id))) == 1
assert session1.session_id in [
s.session_id for s in manager.get_sessions_for_instance(str(instance_id))
]
session2 = await manager.create_session(instance_id, "container-1")
assert len(manager.get_sessions_for_instance(str(instance_id))) == 2
# Verify sessions are distinct
assert session1.session_id != session2.session_id
@pytest.mark.asyncio
async def test_create_session_enforces_max_5(
manager: TerminalManager,
mock_terminal_session,
instance_id: uuid.UUID,
) -> None:
"""The 6th session creation raises MaxSessionsExceededError."""
for i in range(5):
await manager.create_session(instance_id, f"container-{i}")
assert len(manager.get_sessions_for_instance(str(instance_id))) == 5
with pytest.raises(MaxSessionsExceededError):
await manager.create_session(instance_id, "container-overflow")
@pytest.mark.asyncio
async def test_get_sessions_for_instance_filters_by_instance(
manager: TerminalManager,
mock_terminal_session,
) -> None:
"""get_sessions_for_instance returns only sessions for the requested instance."""
instance_a = uuid.uuid4()
instance_b = uuid.uuid4()
await manager.create_session(instance_a, "container-a")
await manager.create_session(instance_a, "container-a2")
await manager.create_session(instance_b, "container-b")
assert len(manager.get_sessions_for_instance(str(instance_a))) == 2
assert len(manager.get_sessions_for_instance(str(instance_b))) == 1
@pytest.mark.asyncio
async def test_close_session_removes_from_dict(
manager: TerminalManager,
mock_terminal_session,
instance_id: uuid.UUID,
) -> None:
"""close_session removes the key from _sessions and marks DB closed."""
session = await manager.create_session(instance_id, "container-1")
session_id = session.session_id
assert manager.get_session(str(instance_id), session_id) is not None
with patch.object(manager, "_mark_closed_in_db", new=AsyncMock()) as mock_mark:
await manager.close_session(str(instance_id), session_id)
# Give the fire-and-forget task a chance to be scheduled
await asyncio.sleep(0)
assert manager.get_session(str(instance_id), session_id) is None
mock_mark.assert_called_once_with(session_id)
@pytest.mark.asyncio
async def test_attach_websocket_only_closes_same_session(
manager: TerminalManager,
mock_terminal_session,
instance_id: uuid.UUID,
) -> None:
"""Attaching to session A must not close WebSockets on session B."""
session_a = await manager.create_session(instance_id, "container-1")
session_b = await manager.create_session(instance_id, "container-1")
ws_a1 = FakeWebSocket("ws-a1")
ws_b1 = FakeWebSocket("ws-b1")
# Manually attach websockets (simulate prior connections)
session_a.attach_websocket(ws_a1)
session_b.attach_websocket(ws_b1)
# Now attach a new websocket to session_a
ws_a2 = FakeWebSocket("ws-a2")
await manager.attach_websocket(session_a, ws_a2)
# ws_a1 should have been closed because it's on the same session
assert ws_a1.closed is True
# ws_b1 should NOT have been closed because it's on a different session
assert ws_b1.closed is False
# ws_a2 should be attached and receive buffer
assert ws_a2 in session_a._websockets
@pytest.mark.asyncio
async def test_default_session_keyed_separately(
manager: TerminalManager,
mock_terminal_session,
instance_id: uuid.UUID,
) -> None:
"""Default session uses 'default' session_id and does not collide with named sessions."""
default_session = await manager.get_or_create_session(instance_id, "container-1")
explicit_session = await manager.create_session(instance_id, "container-1")
# Both should exist
assert manager.get_session(str(instance_id), "default") is default_session
assert (
manager.get_session(str(instance_id), explicit_session.session_id)
is explicit_session
)
# They should be different objects
assert default_session.session_id != explicit_session.session_id
@pytest.mark.asyncio
async def test_idle_cleanup_updates_db_status(
manager: TerminalManager,
mock_terminal_session,
instance_id: uuid.UUID,
) -> None:
"""Idle cleanup removes sessions from dict and calls DB update."""
session = await manager.create_session(instance_id, "container-1")
session_id = session.session_id
# Make session appear idle (no websockets, old last_activity)
session.last_activity = 0
with patch.object(manager, "_mark_closed_in_db", new=AsyncMock()) as mock_mark:
await manager._cleanup_idle_sessions()
assert manager.get_session(str(instance_id), session_id) is None
mock_mark.assert_called_once_with(session_id)
+29 -12
View File
@@ -292,24 +292,41 @@ class TestComputeImageTag:
class TestMergeWithConfig:
"""Tests for merge_with_config."""
"""Tests for merge_with_config (ConfigProfile only)."""
def test_applies_tool_config_env(self) -> None:
def test_no_profile_returns_manifest_unchanged(self) -> None:
manifest = {"name": "test"}
configs = [
{"config_type": "env", "key": "FOO", "value": "bar"},
]
result = merge_with_config(manifest, configs)
result = merge_with_config(manifest)
assert result["name"] == "test"
assert result["_extra_env"] == {}
assert result["_extra_volumes"] == []
def test_profile_env_vars(self) -> None:
manifest = {"name": "test"}
profile = {"environment_variables": {"FOO": "bar"}}
result = merge_with_config(manifest, profile)
assert result["_extra_env"]["FOO"] == "bar"
def test_applies_port_override(self) -> None:
def test_profile_mounts(self) -> None:
manifest = {"name": "test"}
profile = {"mounts": [{"source": "/host", "target": "/container"}]}
result = merge_with_config(manifest, profile)
assert len(result["_extra_volumes"]) == 1
def test_profile_port_override(self) -> None:
manifest = {"name": "test", "default_port": 8080}
configs = [{"port_override": 3000}]
result = merge_with_config(manifest, configs)
profile = {"hints": {"port_override": 3000}}
result = merge_with_config(manifest, profile)
assert result["default_port"] == 3000
def test_applies_start_command(self) -> None:
def test_profile_start_command(self) -> None:
manifest = {"name": "test", "runtime": {"command": ["/bin/bash"]}}
configs = [{"start_command": "/bin/sh"}]
result = merge_with_config(manifest, configs)
profile = {"hints": {"start_command": "/bin/sh"}}
result = merge_with_config(manifest, profile)
assert result["runtime"]["command"] == ["/bin/sh"]
def test_profile_working_directory(self) -> None:
manifest = {"name": "test"}
profile = {"hints": {"working_directory": "/workspace"}}
result = merge_with_config(manifest, profile)
assert result["runtime"]["working_dir"] == "/workspace"
File diff suppressed because one or more lines are too long
+36
View File
@@ -0,0 +1,36 @@
{
"version": "v2",
"timestamp": 1779892231625,
"ruleHash": "0a2423849fae7580",
"queries": [
{
"id": "dangerously-set-inner-html",
"name": "Dangerously Set Inner HTML",
"severity": "error",
"language": "tsx",
"message": "dangerouslySetInnerHTML — XSS risk, sanitize user input",
"query": " (jsx_attribute\n (property_identifier) @ATTR\n (#match? @ATTR \"dangerouslySetInnerHTML\"))",
"metavars": [
"ATTR"
],
"defect_class": "injection",
"inline_tier": "blocking",
"filePath": "/home/alex/.npm-global/lib/node_modules/pi-lens/rules/tree-sitter-queries/tsx/dangerously-set-inner-html.yml"
},
{
"id": "no-nested-links",
"name": "Nested anchor tags",
"severity": "error",
"language": "tsx",
"message": "Nested <a> tags are invalid HTML and cause unexpected behavior",
"query": " (jsx_element\n open_tag: (jsx_opening_element\n (identifier) @OUTER\n (#eq? @OUTER \"a\"))\n (jsx_element\n open_tag: (jsx_opening_element\n (identifier) @INNER\n (#eq? @INNER \"a\"))))",
"metavars": [
"OUTER",
"INNER"
],
"defect_class": "correctness",
"inline_tier": "blocking",
"filePath": "/home/alex/.npm-global/lib/node_modules/pi-lens/rules/tree-sitter-queries/tsx/no-nested-links.yml"
}
]
}
+477
View File
@@ -0,0 +1,477 @@
{
"version": "v2",
"timestamp": 1779889832502,
"ruleHash": "45ab8be323739a4e",
"queries": [
{
"id": "console-statement",
"name": "Console Statement",
"severity": "warning",
"language": "typescript",
"message": "{{METHOD}} — remove debug statements before committing",
"query": " (call_expression\n function: (member_expression\n object: (identifier) @OBJ (#eq? @OBJ \"console\")\n property: (property_identifier) @METHOD (#not-eq? @METHOD \"dbg\"))\n arguments: (arguments) @ARGS)",
"metavars": [
"OBJ",
"METHOD",
"ARGS"
],
"post_filter": "not_in_test_block # skip test blocks — no-console-in-tests handles that case",
"defect_class": "safety",
"inline_tier": "warning",
"filePath": "/home/alex/.npm-global/lib/node_modules/pi-lens/rules/tree-sitter-queries/typescript/console-statement.yml"
},
{
"id": "debugger-statement",
"name": "Debugger Statement",
"severity": "error",
"language": "typescript",
"message": "Debugger statement — remove before committing",
"query": " (debugger_statement) @DEBUGGER",
"metavars": [
"DEBUGGER"
],
"defect_class": "safety",
"inline_tier": "blocking",
"filePath": "/home/alex/.npm-global/lib/node_modules/pi-lens/rules/tree-sitter-queries/typescript/debugger.yml"
},
{
"id": "deep-nesting",
"name": "Deep Nesting",
"severity": "warning",
"language": "typescript",
"message": "Deep nesting (3+ levels) — consider early returns or extract functions",
"query": " [\n ;; Pattern 1: if inside if inside if\n (statement_block\n (if_statement\n consequence: (statement_block\n (if_statement\n consequence: (statement_block\n (if_statement) @IF_NESTED)))))\n\n ;; Pattern 2: for inside if inside if\n (statement_block\n (if_statement\n consequence: (statement_block\n (if_statement\n consequence: (statement_block\n (for_statement) @FOR_NESTED)))))\n\n ;; Pattern 3: while inside if inside if\n (statement_block\n (if_statement\n consequence: (statement_block\n (if_statement\n consequence: (statement_block\n (while_statement) @WHILE_NESTED)))))\n\n ;; Pattern 4: try inside if inside if\n (statement_block\n (if_statement\n consequence: (statement_block\n (if_statement\n consequence: (statement_block\n (try_statement) @TRY_NESTED)))))\n\n ;; Pattern 5: if inside for inside if\n (statement_block\n (if_statement\n consequence: (statement_block\n (for_statement\n body: (statement_block\n (if_statement) @IF_IN_FOR)))))\n\n ;; Pattern 6: if inside while inside if\n (statement_block\n (if_statement\n consequence: (statement_block\n (while_statement\n body: (statement_block\n (if_statement) @IF_IN_WHILE)))))\n\n ;; Pattern 7: for inside for inside for\n (statement_block\n (for_statement\n body: (statement_block\n (for_statement\n body: (statement_block\n (for_statement) @FOR_NESTED)))))\n ]",
"metavars": [
"IF_NESTED",
"FOR_NESTED",
"WHILE_NESTED",
"TRY_NESTED",
"IF_IN_FOR",
"IF_IN_WHILE"
],
"defect_class": "safety",
"inline_tier": "review",
"filePath": "/home/alex/.npm-global/lib/node_modules/pi-lens/rules/tree-sitter-queries/typescript/deep-nesting.yml"
},
{
"id": "deep-promise-chain",
"name": "Deep Promise Chain (4+ levels)",
"severity": "warning",
"language": "typescript",
"message": "Promise chain {{M1}} → {{M2}} → {{M3}} → {{M4}} — consider async/await",
"query": " (call_expression\n function: (member_expression\n object: (call_expression\n function: (member_expression\n object: (call_expression\n function: (member_expression\n object: (call_expression\n function: (member_expression\n property: (property_identifier) @M1)\n arguments: (arguments))\n property: (property_identifier) @M2)\n arguments: (arguments))\n property: (property_identifier) @M3)\n arguments: (arguments))\n property: (property_identifier) @M4)\n arguments: (arguments)\n (#match? @M1 \"^(then|catch|finally)$\")\n (#match? @M2 \"^(then|catch|finally)$\")\n (#match? @M3 \"^(then|catch|finally)$\")\n (#match? @M4 \"^(then|catch|finally)$\"))",
"metavars": [
"M1",
"M2",
"M3",
"M4"
],
"defect_class": "async-misuse",
"inline_tier": "warning",
"filePath": "/home/alex/.npm-global/lib/node_modules/pi-lens/rules/tree-sitter-queries/typescript/deep-promise-chain.yml"
},
{
"id": "default-not-last",
"name": "Default Clauses Should Be Last",
"severity": "error",
"language": "typescript",
"message": "default clause should be the last case",
"query": " (switch_statement\n body: (switch_body\n (switch_default) @DEFAULT\n (switch_case) @AFTER_CASE))",
"metavars": [
"DEFAULT",
"AFTER_CASE"
],
"defect_class": "correctness",
"inline_tier": "blocking",
"filePath": "/home/alex/.npm-global/lib/node_modules/pi-lens/rules/tree-sitter-queries/typescript/default-not-last.yml"
},
{
"id": "duplicate-function-arg",
"name": "Function Argument Names Should Be Unique",
"severity": "error",
"language": "typescript",
"message": "Duplicate parameter name '{{NAME}}'",
"query": " (function_declaration\n parameters: (formal_parameters\n (identifier) @PARAM1\n (identifier) @PARAM2))\n (arrow_function\n parameters: (formal_parameters\n (identifier) @PARAM1\n (identifier) @PARAM2))",
"metavars": [
"PARAM1",
"PARAM2"
],
"post_filter": "same_param_name",
"defect_class": "correctness",
"inline_tier": "blocking",
"filePath": "/home/alex/.npm-global/lib/node_modules/pi-lens/rules/tree-sitter-queries/typescript/duplicate-function-arg.yml"
},
{
"id": "empty-switch-case",
"name": "Switch Cases Should Not Be Empty",
"severity": "error",
"language": "typescript",
"message": "Switch case should not be empty",
"query": " (switch_statement\n body: (switch_body\n (switch_case\n consequence: (statement_block) @BLOCK)))",
"metavars": [
"BLOCK"
],
"post_filter": "is_empty_block",
"defect_class": "correctness",
"inline_tier": "blocking",
"filePath": "/home/alex/.npm-global/lib/node_modules/pi-lens/rules/tree-sitter-queries/typescript/empty-switch-case.yml"
},
{
"id": "no-eval",
"name": "Eval Usage",
"severity": "error",
"language": "typescript",
"message": "eval() detected — security risk, never use eval",
"query": " (call_expression\n function: (identifier) @FUNC\n (#eq? @FUNC \"eval\")\n arguments: (arguments) @ARGS)",
"metavars": [
"FUNC",
"ARGS"
],
"defect_class": "injection",
"inline_tier": "blocking",
"filePath": "/home/alex/.npm-global/lib/node_modules/pi-lens/rules/tree-sitter-queries/typescript/eval.yml"
},
{
"id": "ts-incomplete-assertion",
"name": "Incomplete Test Assertion",
"severity": "error",
"language": "typescript",
"message": "Incomplete assertion — expect() chain is not called",
"query": " (call_expression\n function: (identifier) @EXPECT\n (#eq? @EXPECT \"expect\")\n arguments: (arguments)) @EXPR",
"metavars": [
"EXPECT",
"EXPR"
],
"post_filter": "incomplete_assertion",
"defect_class": "correctness",
"inline_tier": "blocking",
"filePath": "/home/alex/.npm-global/lib/node_modules/pi-lens/rules/tree-sitter-queries/typescript/incomplete-assertion.yml"
},
{
"id": "infinite-loop",
"name": "Loops Should Not Be Infinite",
"severity": "error",
"language": "typescript",
"message": "Loop appears to be infinite with no termination condition",
"query": " (while_statement\n condition: (true)\n body: (statement_block) @BODY)\n (for_statement\n condition: (null)\n body: (statement_block) @BODY)",
"metavars": [
"BODY"
],
"post_filter": "no_break_or_return_in_body",
"defect_class": "correctness",
"inline_tier": "blocking",
"filePath": "/home/alex/.npm-global/lib/node_modules/pi-lens/rules/tree-sitter-queries/typescript/infinite-loop.yml"
},
{
"id": "mixed-async-styles",
"name": "Mixed Async/Await and Promise Chains",
"severity": "warning",
"language": "typescript",
"message": "Mixed async/await + promise chains — use consistent async style",
"query": " (function_declaration\n (async_modifier)\n body: (statement_block) @BODY)\n\n# Post-filter: Check if body contains both await and .then()",
"metavars": [
"BODY"
],
"post_filter": "has_mixed_async",
"defect_class": "async-misuse",
"inline_tier": "warning",
"filePath": "/home/alex/.npm-global/lib/node_modules/pi-lens/rules/tree-sitter-queries/typescript/mixed-async-styles.yml"
},
{
"id": "no-console-in-tests",
"name": "Console Statement in Test",
"severity": "warning",
"language": "typescript",
"message": "console.{{METHOD}} in test block — use proper assertions or logging",
"query": " (call_expression\n function: (member_expression\n object: (identifier) @OBJ (#eq? @OBJ \"console\")\n property: (property_identifier) @METHOD)\n arguments: (arguments) @ARGS)",
"metavars": [
"OBJ",
"METHOD",
"ARGS"
],
"post_filter": "in_test_block",
"defect_class": "safety",
"inline_tier": "warning",
"filePath": "/home/alex/.npm-global/lib/node_modules/pi-lens/rules/tree-sitter-queries/typescript/no-console-in-tests.yml"
},
{
"id": "self-assignment",
"name": "Variables Should Not Be Self-Assigned",
"severity": "error",
"language": "typescript",
"message": "'{{VAR}}' is assigned to itself",
"query": " (assignment_expression\n left: (identifier) @VAR\n right: (identifier) @SAME\n (#eq? @VAR @SAME))",
"metavars": [
"VAR",
"SAME"
],
"defect_class": "correctness",
"inline_tier": "blocking",
"filePath": "/home/alex/.npm-global/lib/node_modules/pi-lens/rules/tree-sitter-queries/typescript/self-assignment.yml"
},
{
"id": "sql-injection",
"name": "SQL Injection Risk",
"severity": "error",
"language": "typescript",
"message": "SQL injection risk — use parameterized queries, never interpolate into SQL",
"query": " (call_expression\n function: [\n (identifier) @SQL_FUNC\n (member_expression property: (property_identifier) @SQL_FUNC)\n ]\n arguments: (arguments\n (template_string (template_substitution) @INTERPOLATION))\n (#match? @SQL_FUNC \"^(query|execute|exec|run)$\"))",
"metavars": [
"SQL_FUNC",
"INTERPOLATION"
],
"defect_class": "injection",
"inline_tier": "blocking",
"filePath": "/home/alex/.npm-global/lib/node_modules/pi-lens/rules/tree-sitter-queries/typescript/sql-injection.yml"
},
{
"id": "switch-case-termination",
"name": "Switch Cases Should End With Terminating Statement",
"severity": "error",
"language": "typescript",
"message": "Switch case should end with break, return, throw, or continue",
"query": " (switch_statement\n body: (switch_body\n (switch_case\n consequence: (statement_block\n (expression_statement) @LAST))\n (switch_case) @NEXT))",
"metavars": [
"LAST",
"NEXT"
],
"post_filter": "no_terminating_statement",
"defect_class": "correctness",
"inline_tier": "blocking",
"filePath": "/home/alex/.npm-global/lib/node_modules/pi-lens/rules/tree-sitter-queries/typescript/switch-case-termination.yml"
},
{
"id": "switch-non-case-labels-ts",
"name": "Switch Should Not Contain Non-Case Labels",
"severity": "error",
"language": "typescript",
"message": "switch statements should not contain non-case labels",
"query": " (switch_statement\n body: (switch_body\n (switch_case\n (labeled_statement\n (statement_identifier) @LABEL) @LABELED)))",
"metavars": [
"LABEL",
"LABELED"
],
"defect_class": "correctness",
"inline_tier": "blocking",
"filePath": "/home/alex/.npm-global/lib/node_modules/pi-lens/rules/tree-sitter-queries/typescript/switch-non-case-labels.yml"
},
{
"id": "ts-command-injection",
"name": "Command Injection Sink",
"severity": "error",
"language": "typescript",
"message": "Potential command injection sink — avoid child_process command execution with untrusted input",
"query": " [\n (call_expression\n function: (member_expression\n object: (identifier) @MOD\n property: (property_identifier) @FN)\n arguments: (arguments) @ARGS\n (#eq? @MOD \"child_process\")\n (#match? @FN \"^(exec|execSync)$\"))\n (call_expression\n function: (member_expression\n object: (member_expression\n object: (identifier) @MOD\n property: (property_identifier) @NS)\n property: (property_identifier) @FN)\n arguments: (arguments) @ARGS\n (#eq? @MOD \"child_process\")\n (#match? @FN \"^(exec|execSync)$\"))\n ]",
"metavars": [
"MOD",
"NS",
"FN",
"ARGS"
],
"post_filter": "ts_command_injection_sink",
"defect_class": "injection",
"inline_tier": "blocking",
"filePath": "/home/alex/.npm-global/lib/node_modules/pi-lens/rules/tree-sitter-queries/typescript/ts-command-injection.yml"
},
{
"id": "ts-detached-async-call",
"name": "Detached Async Call",
"severity": "warning",
"language": "typescript",
"message": "Detached async call — ensure this Promise is awaited or explicitly handled",
"query": " (expression_statement\n (call_expression\n function: [\n (identifier) @FN\n (member_expression\n property: (property_identifier) @FN)\n ]\n arguments: (arguments) @ARGS)\n (#match? @FN \"(Async$|fetch$|request$)\"))",
"metavars": [
"FN",
"ARGS"
],
"post_filter": "ts_detached_async_call",
"defect_class": "async-misuse",
"inline_tier": "warning",
"filePath": "/home/alex/.npm-global/lib/node_modules/pi-lens/rules/tree-sitter-queries/typescript/ts-detached-async-call.yml"
},
{
"id": "ts-dynamic-require",
"name": "Dynamic Require Injection",
"severity": "error",
"language": "typescript",
"message": "Dynamic require() — non-literal argument allows loading arbitrary modules",
"query": " (call_expression\n function: (identifier) @FN\n arguments: (arguments [(identifier) (member_expression) (call_expression) (await_expression)] @ARG)\n (#eq? @FN \"require\"))",
"metavars": [
"FN",
"ARG"
],
"defect_class": "injection",
"inline_tier": "blocking",
"filePath": "/home/alex/.npm-global/lib/node_modules/pi-lens/rules/tree-sitter-queries/typescript/ts-dynamic-require.yml"
},
{
"id": "ts-hallucinated-react-import",
"name": "Hallucinated React Import",
"severity": "error",
"language": "typescript",
"message": "'{NAME}' is a Next.js API, not from 'react' — import from 'next/{CORRECT}' instead",
"query": " (import_statement\n (import_clause\n (named_imports\n (import_specifier\n name: (identifier) @NAME)))\n source: (string) @SRC)\n (#match? @SRC \"^['\\\"]react['\\\"]$\")\n (#match? @NAME \"^(useRouter|usePathname|useSearchParams|useParams|Link|Image|Script|Head|getServerSideProps|getStaticProps|getStaticPaths|NextPage|NextApiRequest|NextApiResponse|GetServerSideProps|GetStaticProps|GetStaticPaths|notFound|redirect|permanentRedirect)$\")",
"metavars": [
"NAME",
"SRC"
],
"post_filter": "match_captures",
"post_filter_params": {
"SRC": "^['\\\"]react['\\\"]$",
"NAME": "^(useRouter|usePathname|useSearchParams|useParams|Link|Image|Script|Head|getServerSideProps|getStaticProps|getStaticPaths|NextPage|NextApiRequest|NextApiResponse|GetServerSideProps|GetStaticProps|GetStaticPaths|notFound|redirect|permanentRedirect)$"
},
"defect_class": "hallucination",
"inline_tier": "blocking",
"filePath": "/home/alex/.npm-global/lib/node_modules/pi-lens/rules/tree-sitter-queries/typescript/ts-hallucinated-react-import.yml"
},
{
"id": "ts-insecure-random",
"name": "Insecure Randomness",
"severity": "warning",
"language": "typescript",
"message": "Insecure randomness source detected — use crypto.getRandomValues or secure RNG APIs",
"query": " (variable_declarator\n name: (identifier) @VAR\n value: (call_expression\n function: (member_expression\n object: (identifier) @OBJ\n property: (property_identifier) @FN)\n arguments: (arguments) @ARGS)\n (#eq? @OBJ \"Math\")\n (#eq? @FN \"random\")\n (#match? @VAR \"(?i)(token|secret|password|key|nonce|salt|csrf|auth|session|credential|hash|otp|pin)\"))",
"metavars": [
"OBJ",
"FN",
"ARGS",
"VAR"
],
"post_filter": "ts_insecure_random_source",
"defect_class": "injection",
"inline_tier": "warning",
"filePath": "/home/alex/.npm-global/lib/node_modules/pi-lens/rules/tree-sitter-queries/typescript/ts-insecure-random.yml"
},
{
"id": "ts-nosql-injection",
"name": "NoSQL Injection",
"severity": "error",
"language": "typescript",
"message": "NoSQL injection — $where executes JavaScript server-side and must never be used with user input",
"query": " (pair\n key: [(property_identifier) (string)] @KEY\n (#match? @KEY \"\\\\$where\"))",
"metavars": [
"KEY"
],
"defect_class": "injection",
"inline_tier": "blocking",
"filePath": "/home/alex/.npm-global/lib/node_modules/pi-lens/rules/tree-sitter-queries/typescript/ts-nosql-injection.yml"
},
{
"id": "ts-open-redirect",
"name": "Open Redirect",
"severity": "error",
"language": "typescript",
"message": "Open redirect — unvalidated URL in redirect/location lets attackers send users to malicious sites",
"query": " [\n (call_expression\n function: (member_expression\n object: (identifier) @OBJ\n property: (property_identifier) @FN)\n arguments: (arguments (identifier) @URL)\n (#match? @OBJ \"^(res|response|ctx|context)$\")\n (#eq? @FN \"redirect\"))\n (call_expression\n function: (member_expression\n object: (identifier) @OBJ\n property: (property_identifier) @FN)\n arguments: (arguments (member_expression) @URL)\n (#match? @OBJ \"^(res|response|ctx|context)$\")\n (#eq? @FN \"redirect\"))\n (call_expression\n function: (member_expression\n object: (identifier) @OBJ\n property: (property_identifier) @FN)\n arguments: (arguments (call_expression) @URL)\n (#match? @OBJ \"^(res|response|ctx|context)$\")\n (#eq? @FN \"redirect\"))\n ]\n [\n (assignment_expression\n left: (member_expression\n object: (member_expression\n object: (identifier) @WIN\n property: (property_identifier) @LOC)\n property: (property_identifier) @PROP)\n right: (identifier) @VALUE\n (#eq? @WIN \"window\")\n (#eq? @LOC \"location\")\n (#eq? @PROP \"href\"))\n (assignment_expression\n left: (member_expression\n object: (member_expression\n object: (identifier) @WIN\n property: (property_identifier) @LOC)\n property: (property_identifier) @PROP)\n right: (member_expression) @VALUE\n (#eq? @WIN \"window\")\n (#eq? @LOC \"location\")\n (#eq? @PROP \"href\"))\n (assignment_expression\n left: (member_expression\n object: (member_expression\n object: (identifier) @WIN\n property: (property_identifier) @LOC)\n property: (property_identifier) @PROP)\n right: (call_expression) @VALUE\n (#eq? @WIN \"window\")\n (#eq? @LOC \"location\")\n (#eq? @PROP \"href\"))\n ]",
"metavars": [
"OBJ",
"FN",
"URL",
"WIN",
"LOC",
"PROP",
"VALUE"
],
"defect_class": "injection",
"inline_tier": "blocking",
"filePath": "/home/alex/.npm-global/lib/node_modules/pi-lens/rules/tree-sitter-queries/typescript/ts-open-redirect.yml"
},
{
"id": "ts-react-antipatterns",
"name": "React Anti-Pattern",
"severity": "warning",
"language": "typescript",
"message": "React anti-pattern: setState inside a loop causes multiple re-renders — batch with a single state update",
"query": " [\n (for_statement\n (statement_block) @BODY\n (#match? @BODY \"set[A-Z]\")\n (#not-match? @BODY \"set(Timeout|Interval|Immediate)\"))\n (for_in_statement\n (statement_block) @BODY\n (#match? @BODY \"set[A-Z]\")\n (#not-match? @BODY \"set(Timeout|Interval|Immediate)\"))\n (while_statement\n (statement_block) @BODY\n (#match? @BODY \"set[A-Z]\")\n (#not-match? @BODY \"set(Timeout|Interval|Immediate)\"))\n ]",
"metavars": [
"BODY"
],
"defect_class": "logic-error",
"inline_tier": "warning",
"filePath": "/home/alex/.npm-global/lib/node_modules/pi-lens/rules/tree-sitter-queries/typescript/ts-react-antipatterns.yml"
},
{
"id": "ts-ssrf",
"name": "SSRF Risk",
"severity": "error",
"language": "typescript",
"message": "Potential SSRF sink — validate and allowlist outbound URLs",
"query": " [\n (call_expression\n function: (identifier) @FN\n arguments: (arguments [(identifier) (member_expression) (call_expression) (await_expression)] @URL)\n (#match? @FN \"^(fetch|get|post|put|patch|delete|request)$\"))\n (call_expression\n function: (member_expression\n object: (identifier) @OBJ\n property: (property_identifier) @FN)\n arguments: (arguments [(identifier) (member_expression) (call_expression) (await_expression)] @URL)\n (#match? @FN \"^(fetch|get|post|put|patch|delete|request)$\"))\n ]",
"metavars": [
"OBJ",
"FN",
"URL"
],
"post_filter": "ts_ssrf_sink",
"defect_class": "injection",
"inline_tier": "blocking",
"filePath": "/home/alex/.npm-global/lib/node_modules/pi-lens/rules/tree-sitter-queries/typescript/ts-ssrf.yml"
},
{
"id": "ts-weak-hash",
"name": "Weak Hash Primitive",
"severity": "error",
"language": "typescript",
"message": "Weak hash primitive selected (md5/sha1) — use sha256+ for security-sensitive contexts",
"query": " (call_expression\n function: (member_expression\n property: (property_identifier) @FN)\n arguments: (arguments\n (string (string_fragment) @ALG)\n (_)*)\n (#eq? @FN \"createHash\")\n (#match? @ALG \"^(md5|sha1)$\"))",
"metavars": [
"FN",
"ALG"
],
"post_filter": "ts_weak_hash_algorithm",
"defect_class": "injection",
"inline_tier": "blocking",
"filePath": "/home/alex/.npm-global/lib/node_modules/pi-lens/rules/tree-sitter-queries/typescript/ts-weak-hash.yml"
},
{
"id": "ts-xss-dom-sink",
"name": "XSS DOM Sink",
"severity": "error",
"language": "typescript",
"message": "XSS risk — dynamic value written to innerHTML/outerHTML or document.write()",
"query": " [\n (assignment_expression\n left: (member_expression\n property: (property_identifier) @PROP)\n right: (identifier) @VALUE\n (#match? @PROP \"^(innerHTML|outerHTML)$\"))\n (assignment_expression\n left: (member_expression\n property: (property_identifier) @PROP)\n right: (member_expression) @VALUE\n (#match? @PROP \"^(innerHTML|outerHTML)$\"))\n (assignment_expression\n left: (member_expression\n property: (property_identifier) @PROP)\n right: (call_expression) @VALUE\n (#match? @PROP \"^(innerHTML|outerHTML)$\"))\n (assignment_expression\n left: (member_expression\n property: (property_identifier) @PROP)\n right: (await_expression) @VALUE\n (#match? @PROP \"^(innerHTML|outerHTML)$\"))\n ]\n [\n (call_expression\n function: (member_expression\n object: (identifier) @OBJ\n property: (property_identifier) @FN)\n arguments: (arguments (identifier) @ARG)\n (#eq? @OBJ \"document\")\n (#match? @FN \"^(write|writeln)$\"))\n (call_expression\n function: (member_expression\n object: (identifier) @OBJ\n property: (property_identifier) @FN)\n arguments: (arguments (member_expression) @ARG)\n (#eq? @OBJ \"document\")\n (#match? @FN \"^(write|writeln)$\"))\n (call_expression\n function: (member_expression\n object: (identifier) @OBJ\n property: (property_identifier) @FN)\n arguments: (arguments (call_expression) @ARG)\n (#eq? @OBJ \"document\")\n (#match? @FN \"^(write|writeln)$\"))\n ]",
"metavars": [
"PROP",
"VALUE",
"OBJ",
"FN",
"ARG"
],
"defect_class": "injection",
"inline_tier": "blocking",
"filePath": "/home/alex/.npm-global/lib/node_modules/pi-lens/rules/tree-sitter-queries/typescript/ts-xss-dom-sink.yml"
},
{
"id": "unsafe-regex",
"name": "Dynamic Regex Construction",
"severity": "error",
"language": "typescript",
"message": "Dynamic regex from user input — can cause ReDoS (Regular Expression Denial of Service)",
"query": " (new_expression\n constructor: (identifier) @CTOR\n (#eq? @CTOR \"RegExp\")\n arguments: (arguments\n (template_string\n (template_substitution) @INTERPOLATION) @PATTERN)\n (#not-match? @INTERPOLATION \"escape|Escape|replace\"))",
"metavars": [
"CTOR",
"INTERPOLATION",
"PATTERN"
],
"defect_class": "injection",
"inline_tier": "blocking",
"filePath": "/home/alex/.npm-global/lib/node_modules/pi-lens/rules/tree-sitter-queries/typescript/unsafe-regex.yml"
},
{
"id": "variable-shadowing",
"name": "Variable Shadowing",
"severity": "warning",
"language": "typescript",
"message": "Variable '{{NAME}}' shadows a parameter — use a distinct name",
"query": " (function_declaration\n parameters: (formal_parameters\n (required_parameter\n pattern: (identifier) @PARAM))\n body: (statement_block\n (lexical_declaration\n (variable_declarator\n name: (identifier) @NAME))))",
"metavars": [
"PARAM",
"NAME"
],
"post_filter": "name_matches_param",
"defect_class": "safety",
"inline_tier": "review",
"filePath": "/home/alex/.npm-global/lib/node_modules/pi-lens/rules/tree-sitter-queries/typescript/variable-shadowing.yml"
}
]
}
-131
View File
@@ -1,131 +0,0 @@
import { describe, expect, it, vi } from "vitest";
import {
createConfigFolder,
deleteConfigFolder,
listConfigFolders,
updateConfigFolder,
} from "../api/config_folders";
const mockGet = vi.fn();
const mockPost = vi.fn();
const mockPut = vi.fn();
const mockDelete = vi.fn();
vi.mock("../api/client", () => ({
apiClient: {
get: (...args: unknown[]) => mockGet(...args),
post: (...args: unknown[]) => mockPost(...args),
put: (...args: unknown[]) => mockPut(...args),
delete: (...args: unknown[]) => mockDelete(...args),
interceptors: {
response: {
use: vi.fn(),
},
},
},
shouldSkipAuthRedirect: vi.fn(() => false),
}));
describe("config_folders API", () => {
describe("listConfigFolders", () => {
it("returns folders with files and overrides", async () => {
const mockResponse = {
data: [
{
id: "folder-1",
name: "my-dotfiles",
description: "My personal config files",
mount_path: "/home/user",
files: { ".zshrc": "export ZSH=\"$HOME/.oh-my-zsh\"" },
project_overrides: {},
is_active: true,
user_id: "user-1",
created_at: "2024-01-01T00:00:00Z",
updated_at: "2024-01-01T00:00:00Z",
},
],
};
mockGet.mockResolvedValue(mockResponse);
const result = await listConfigFolders();
expect(result[0].name).toBe("my-dotfiles");
expect(result[0].files).toEqual({ ".zshrc": "export ZSH=\"$HOME/.oh-my-zsh\"" });
expect(mockGet).toHaveBeenCalledWith("/config-folders");
});
});
describe("createConfigFolder", () => {
it("creates folder with files", async () => {
const mockResponse = {
data: {
id: "folder-new",
name: "new-folder",
mount_path: "/workspace",
files: { ".env": "API_URL=http://localhost" },
is_active: true,
user_id: "user-1",
created_at: "2024-01-01T00:00:00Z",
updated_at: "2024-01-01T00:00:00Z",
},
};
mockPost.mockResolvedValue(mockResponse);
const result = await createConfigFolder({
name: "new-folder",
mount_path: "/workspace",
files: { ".env": "API_URL=http://localhost" },
});
expect(result.name).toBe("new-folder");
expect(mockPost).toHaveBeenCalledWith(
"/config-folders",
expect.objectContaining({
name: "new-folder",
mount_path: "/workspace",
})
);
});
});
describe("updateConfigFolder", () => {
it("updates folder files", async () => {
const mockResponse = {
data: {
id: "folder-1",
name: "updated-folder",
mount_path: "/home/user",
files: { ".bashrc": "alias ll='ls -la'" },
is_active: true,
user_id: "user-1",
created_at: "2024-01-01T00:00:00Z",
updated_at: "2024-01-01T00:00:00Z",
},
};
mockPut.mockResolvedValue(mockResponse);
const result = await updateConfigFolder("folder-1", {
files: { ".bashrc": "alias ll='ls -la'" },
});
expect(result.files).toEqual({ ".bashrc": "alias ll='ls -la'" });
expect(mockPut).toHaveBeenCalledWith(
"/config-folders/folder-1",
expect.objectContaining({
files: { ".bashrc": "alias ll='ls -la'" },
})
);
});
});
describe("deleteConfigFolder", () => {
it("deletes folder", async () => {
mockDelete.mockResolvedValue({ data: undefined });
await deleteConfigFolder("folder-1");
expect(mockDelete).toHaveBeenCalledWith("/config-folders/folder-1");
});
});
});
-95
View File
@@ -1,95 +0,0 @@
import { apiClient } from "./client";
export interface ConfigFolder {
id: string;
user_id: string;
name: string;
description: string | null;
mount_path: string;
files: Record<string, string>;
project_overrides: Record<string, { mount_path?: string; files?: Record<string, string> }> | null;
is_active: boolean;
created_at: string;
updated_at: string;
}
export interface CreateConfigFolderRequest {
name: string;
description?: string;
mount_path: string;
files?: Record<string, string>;
is_active?: boolean;
}
export interface UpdateConfigFolderRequest {
name?: string;
description?: string;
mount_path?: string;
files?: Record<string, string>;
is_active?: boolean;
}
export interface ProjectOverrideRequest {
mount_path?: string;
files?: Record<string, string>;
}
export const listConfigFolders = async (): Promise<ConfigFolder[]> => {
const response = await apiClient.get<ConfigFolder[]>("/config-folders");
return response.data;
};
export const getConfigFolder = async (id: string): Promise<ConfigFolder> => {
const response = await apiClient.get<ConfigFolder>(`/config-folders/${id}`);
return response.data;
};
export const createConfigFolder = async (
data: CreateConfigFolderRequest
): Promise<ConfigFolder> => {
const response = await apiClient.post<ConfigFolder>("/config-folders", data);
return response.data;
};
export const updateConfigFolder = async (
id: string,
data: UpdateConfigFolderRequest
): Promise<ConfigFolder> => {
const response = await apiClient.put<ConfigFolder>(`/config-folders/${id}`, data);
return response.data;
};
export const deleteConfigFolder = async (id: string): Promise<void> => {
await apiClient.delete(`/config-folders/${id}`);
};
export const addProjectOverride = async (
id: string,
projectId: string,
data: ProjectOverrideRequest
): Promise<ConfigFolder> => {
const response = await apiClient.post<ConfigFolder>(
`/config-folders/${id}/overrides/${projectId}`,
data
);
return response.data;
};
export const updateProjectOverride = async (
id: string,
projectId: string,
data: ProjectOverrideRequest
): Promise<ConfigFolder> => {
const response = await apiClient.put<ConfigFolder>(
`/config-folders/${id}/overrides/${projectId}`,
data
);
return response.data;
};
export const deleteProjectOverride = async (
id: string,
projectId: string
): Promise<void> => {
await apiClient.delete(`/config-folders/${id}/overrides/${projectId}`);
};
+77
View File
@@ -0,0 +1,77 @@
import { apiClient } from "./client";
export interface TerminalSession {
id: string;
name: string;
status: string;
has_websockets: boolean;
created_at: string;
last_activity_at: string | null;
}
export interface TerminalSessionListResponse {
sessions: TerminalSession[];
}
export interface TerminalSessionCreateRequest {
name?: string;
}
export interface TerminalSessionCreateResponse {
id: string;
name: string;
status: string;
created_at: string;
}
export async function listTerminalSessions(
instanceId: string,
): Promise<TerminalSession[]> {
const response = await apiClient.get(
`/instances/${instanceId}/terminal/sessions`,
);
return response.data.sessions;
}
export async function createTerminalSession(
instanceId: string,
name?: string,
): Promise<TerminalSessionCreateResponse> {
const response = await apiClient.post(
`/instances/${instanceId}/terminal/sessions`,
{ name },
);
return response.data;
}
export async function closeTerminalSession(
instanceId: string,
sessionId: string,
): Promise<{ status: string; session_id: string }> {
const response = await apiClient.delete(
`/instances/${instanceId}/terminal/sessions/${sessionId}`,
);
return response.data;
}
export async function resetTerminalSession(
instanceId: string,
sessionId: string,
): Promise<{ id: string; name: string; status: string }> {
const response = await apiClient.post(
`/instances/${instanceId}/terminal/sessions/${sessionId}/reset`,
);
return response.data;
}
export async function renameTerminalSession(
instanceId: string,
sessionId: string,
name: string,
): Promise<{ id: string; name: string }> {
const response = await apiClient.post(
`/instances/${instanceId}/terminal/sessions/${sessionId}/rename`,
{ name },
);
return response.data;
}
-75
View File
@@ -1,75 +0,0 @@
import { apiClient } from "./client";
export interface ToolConfig {
id: string;
tool_type_id: string;
project_id: string | null;
key: string;
value: string;
config_type: string;
file_path: string | null;
port_override: number | null;
start_command: string | null;
working_directory: string | null;
environment_variables: Record<string, string> | null;
volumes: Array<{ source: string; target: string; type?: string }> | null;
}
export interface CreateToolConfigRequest {
tool_type_id: string;
project_id?: string;
key: string;
value: string;
config_type?: string;
file_path?: string;
port_override?: number;
start_command?: string;
working_directory?: string;
environment_variables?: Record<string, string>;
volumes?: Array<{ source: string; target: string; type?: string }>;
}
export const listToolConfigs = async (
tool_type_id?: string,
project_id?: string
): Promise<ToolConfig[]> => {
const params = new URLSearchParams();
if (tool_type_id) params.append("tool_type_id", tool_type_id);
if (project_id) params.append("project_id", project_id);
const response = await apiClient.get<{ configs: ToolConfig[] }>(
`/tool-configs?${params.toString()}`
);
return response.data.configs;
};
export const createToolConfig = async (
data: CreateToolConfigRequest
): Promise<ToolConfig> => {
const response = await apiClient.post<{ configs: ToolConfig[] }>("/tool-configs", data);
return response.data.configs[0];
};
export const updateToolConfig = async (
id: string,
data: CreateToolConfigRequest
): Promise<ToolConfig> => {
const response = await apiClient.put<{ configs: ToolConfig[] }>(
`/tool-configs/${id}`,
data
);
return response.data.configs[0];
};
export const deleteToolConfig = async (id: string): Promise<void> => {
await apiClient.delete(`/tool-configs/${id}`);
};
export const getToolConfigDefaults = async (
toolTypeId: string
): Promise<ToolConfig> => {
const response = await apiClient.get<ToolConfig>(
`/tool-configs/defaults/${toolTypeId}`
);
return response.data;
};
@@ -0,0 +1,161 @@
import { render, screen, fireEvent, cleanup } from "@testing-library/react";
import { afterEach, describe, expect, it, vi } from "vitest";
import {
TerminalSessionTabs,
type TerminalSessionInfo,
} from "./terminal-session-tabs";
const mockSessions: TerminalSessionInfo[] = [
{ id: "s1", name: "Session 1", status: "connected" },
{ id: "s2", name: "Session 2", status: "connecting" },
{ id: "s3", name: "Session 3", status: "disconnected" },
];
afterEach(() => {
cleanup();
});
describe("TerminalSessionTabs", () => {
it("renders all tabs", () => {
render(
<TerminalSessionTabs
sessions={mockSessions}
activeSessionId="s1"
onSelect={vi.fn()}
onClose={vi.fn()}
onCreate={vi.fn()}
onRename={vi.fn()}
/>
);
expect(screen.getByText("Session 1")).toBeInTheDocument();
expect(screen.getByText("Session 2")).toBeInTheDocument();
expect(screen.getByText("Session 3")).toBeInTheDocument();
expect(screen.getByRole("button", { name: /new session/i })).toBeInTheDocument();
});
it("clicking a tab calls onSelect", () => {
const onSelect = vi.fn();
render(
<TerminalSessionTabs
sessions={mockSessions}
activeSessionId="s1"
onSelect={onSelect}
onClose={vi.fn()}
onCreate={vi.fn()}
onRename={vi.fn()}
/>
);
fireEvent.click(screen.getAllByText("Session 2")[0]);
expect(onSelect).toHaveBeenCalledWith("s2");
});
it("close button calls onClose after confirmation", () => {
const onClose = vi.fn();
render(
<TerminalSessionTabs
sessions={mockSessions}
activeSessionId="s1"
onSelect={vi.fn()}
onClose={onClose}
onCreate={vi.fn()}
onRename={vi.fn()}
/>
);
const closeButton = screen.getByLabelText("Close session Session 1");
// First click shows confirm
fireEvent.click(closeButton);
expect(screen.getByText("Close?")).toBeInTheDocument();
// Click confirm text
fireEvent.click(screen.getByText("Close?"));
expect(onClose).toHaveBeenCalledWith("s1");
});
it("double-click enables rename and Enter commits", () => {
const onRename = vi.fn();
render(
<TerminalSessionTabs
sessions={mockSessions}
activeSessionId="s1"
onSelect={vi.fn()}
onClose={vi.fn()}
onCreate={vi.fn()}
onRename={onRename}
/>
);
fireEvent.doubleClick(screen.getAllByText("Session 1")[0]);
const input = screen.getByLabelText("Rename session");
expect(input).toBeInTheDocument();
fireEvent.change(input, { target: { value: "Renamed" } });
fireEvent.keyDown(input, { key: "Enter" });
expect(onRename).toHaveBeenCalledWith("s1", "Renamed");
});
it("double-click enables rename and Escape cancels", () => {
const onRename = vi.fn();
render(
<TerminalSessionTabs
sessions={mockSessions}
activeSessionId="s1"
onSelect={vi.fn()}
onClose={vi.fn()}
onCreate={vi.fn()}
onRename={onRename}
/>
);
fireEvent.doubleClick(screen.getAllByText("Session 1")[0]);
const input = screen.getByLabelText("Rename session");
fireEvent.change(input, { target: { value: "Renamed" } });
fireEvent.keyDown(input, { key: "Escape" });
expect(onRename).not.toHaveBeenCalled();
expect(screen.getByText("Session 1")).toBeInTheDocument();
});
it("plus button is disabled at 5 sessions", () => {
const fiveSessions: TerminalSessionInfo[] = Array.from({ length: 5 }, (_, i) => ({
id: `s${i + 1}`,
name: `Session ${i + 1}`,
status: "connected",
}));
render(
<TerminalSessionTabs
sessions={fiveSessions}
activeSessionId="s1"
onSelect={vi.fn()}
onClose={vi.fn()}
onCreate={vi.fn()}
onRename={vi.fn()}
/>
);
const newButton = screen.getByRole("button", { name: /new session/i });
expect(newButton).toBeDisabled();
});
it("status dot reflects connection state", () => {
render(
<TerminalSessionTabs
sessions={mockSessions}
activeSessionId="s1"
onSelect={vi.fn()}
onClose={vi.fn()}
onCreate={vi.fn()}
onRename={vi.fn()}
/>
);
const tabs = screen.getAllByRole("tab");
expect(tabs).toHaveLength(3);
expect(tabs[0].querySelector(".connected")).toBeInTheDocument();
expect(tabs[1].querySelector(".connecting")).toBeInTheDocument();
expect(tabs[2].querySelector(".disconnected")).toBeInTheDocument();
});
});
@@ -0,0 +1,167 @@
import React, { useState, useRef, useCallback } from "react";
export interface TerminalSessionInfo {
id: string;
name: string;
status: "connecting" | "connected" | "disconnected" | "error" | "resetting";
}
export interface TerminalSessionTabsProps {
sessions: TerminalSessionInfo[];
activeSessionId: string;
onSelect: (sessionId: string) => void;
onClose: (sessionId: string) => void;
onCreate: () => void;
onRename: (sessionId: string, newName: string) => void;
isMobile?: boolean;
}
export const TerminalSessionTabs: React.FC<TerminalSessionTabsProps> = ({
sessions,
activeSessionId,
onSelect,
onClose,
onCreate,
onRename,
isMobile = false,
}) => {
const [renamingId, setRenamingId] = useState<string | null>(null);
const [renameValue, setRenameValue] = useState("");
const [confirmCloseId, setConfirmCloseId] = useState<string | null>(null);
const renameInputRef = useRef<HTMLInputElement>(null);
const scrollRef = useRef<HTMLDivElement>(null);
const handleDoubleClick = useCallback((session: TerminalSessionInfo) => {
setRenamingId(session.id);
setRenameValue(session.name);
requestAnimationFrame(() => {
renameInputRef.current?.focus();
renameInputRef.current?.select();
});
}, []);
const commitRename = useCallback(() => {
if (renamingId && renameValue.trim()) {
onRename(renamingId, renameValue.trim());
}
setRenamingId(null);
setRenameValue("");
}, [renamingId, renameValue, onRename]);
const cancelRename = useCallback(() => {
setRenamingId(null);
setRenameValue("");
}, []);
const handleRenameKeyDown = useCallback(
(e: React.KeyboardEvent<HTMLInputElement>) => {
if (e.key === "Enter") {
commitRename();
} else if (e.key === "Escape") {
cancelRename();
}
},
[commitRename, cancelRename],
);
const handleCloseClick = useCallback(
(e: React.MouseEvent, sessionId: string) => {
e.stopPropagation();
if (confirmCloseId === sessionId) {
setConfirmCloseId(null);
onClose(sessionId);
} else {
setConfirmCloseId(sessionId);
// Auto-dismiss confirm after 3s
setTimeout(() => {
setConfirmCloseId((prev) => (prev === sessionId ? null : prev));
}, 3000);
}
},
[confirmCloseId, onClose],
);
const isMaxSessions = sessions.length >= 5;
return (
<div
className={`terminal-session-tabs ${isMobile ? "mobile" : ""}`}
role="tablist"
aria-label="Terminal sessions"
>
<div className="terminal-session-tabs-scroll" ref={scrollRef}>
{sessions.map((session) => {
const isActive = session.id === activeSessionId;
const isRenaming = renamingId === session.id;
const isConfirmingClose = confirmCloseId === session.id;
return (
<div
key={session.id}
className={`terminal-session-tab ${isActive ? "active" : ""}`}
role="tab"
aria-selected={isActive}
onClick={() => onSelect(session.id)}
onDoubleClick={() => handleDoubleClick(session)}
title={isRenaming ? "" : `${session.name} (${session.status})`}
>
<span
className={`terminal-session-tab-status ${session.status}`}
aria-hidden="true"
/>
{isRenaming ? (
<input
ref={renameInputRef}
className="terminal-session-tab-input"
value={renameValue}
onChange={(e) => setRenameValue(e.target.value)}
onKeyDown={handleRenameKeyDown}
onBlur={commitRename}
onClick={(e) => e.stopPropagation()}
aria-label="Rename session"
/>
) : (
<span className="terminal-session-tab-name">
{session.name}
</span>
)}
{isConfirmingClose ? (
<button
className="terminal-session-tab-confirm"
onClick={(e) => {
e.stopPropagation();
setConfirmCloseId(null);
onClose(session.id);
}}
type="button"
>
Close?
</button>
) : (
<button
className="terminal-session-tab-close"
onClick={(e) => handleCloseClick(e, session.id)}
type="button"
aria-label={`Close session ${session.name}`}
tabIndex={-1}
>
×
</button>
)}
</div>
);
})}
<button
className="terminal-session-tab new-session"
onClick={onCreate}
disabled={isMaxSessions}
type="button"
aria-label="New session"
title={isMaxSessions ? "Maximum 5 sessions reached" : "New session"}
>
+
</button>
</div>
</div>
);
};
File diff suppressed because it is too large Load Diff
+145
View File
@@ -0,0 +1,145 @@
import { useCallback, useEffect, useState } from "react";
import {
listTerminalSessions,
createTerminalSession,
closeTerminalSession,
resetTerminalSession,
renameTerminalSession,
type TerminalSession,
} from "../api/terminal";
export interface UseTerminalSessionsResult {
sessions: TerminalSession[];
activeSessionId: string | null;
setActiveSessionId: (id: string) => void;
createSession: (name?: string) => Promise<TerminalSession | null>;
closeSession: (sessionId: string) => Promise<void>;
renameSession: (sessionId: string, name: string) => Promise<void>;
resetSession: (sessionId: string) => Promise<void>;
loading: boolean;
error: string | null;
}
export function useTerminalSessions(
instanceId: string,
): UseTerminalSessionsResult {
const [sessions, setSessions] = useState<TerminalSession[]>([]);
const [activeSessionId, setActiveSessionId] = useState<string | null>(null);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
const loadSessions = useCallback(async () => {
setLoading(true);
setError(null);
try {
const sess = await listTerminalSessions(instanceId);
setSessions(sess);
if (sess.length > 0 && !activeSessionId) {
setActiveSessionId(sess[0].id);
}
} catch (err) {
setError(err instanceof Error ? err.message : "Failed to load sessions");
} finally {
setLoading(false);
}
}, [instanceId, activeSessionId]);
const createSession = useCallback(
async (name?: string) => {
setError(null);
try {
const newSession = await createTerminalSession(instanceId, name);
const session: TerminalSession = {
id: newSession.id,
name: newSession.name,
status: newSession.status,
has_websockets: false,
created_at: newSession.created_at,
last_activity_at: null,
};
setSessions((prev) => [...prev, session]);
setActiveSessionId(session.id);
return session;
} catch (err) {
const msg =
err instanceof Error ? err.message : "Failed to create session";
setError(msg);
return null;
}
},
[instanceId],
);
const closeSession = useCallback(
async (sessionId: string) => {
setError(null);
try {
await closeTerminalSession(instanceId, sessionId);
setSessions((prev) => {
const filtered = prev.filter((s) => s.id !== sessionId);
if (activeSessionId === sessionId && filtered.length > 0) {
setActiveSessionId(filtered[0].id);
} else if (filtered.length === 0) {
setActiveSessionId(null);
}
return filtered;
});
} catch (err) {
setError(
err instanceof Error ? err.message : "Failed to close session",
);
}
},
[instanceId, activeSessionId],
);
const renameSession = useCallback(
async (sessionId: string, name: string) => {
setError(null);
try {
await renameTerminalSession(instanceId, sessionId, name);
setSessions((prev) =>
prev.map((s) => (s.id === sessionId ? { ...s, name } : s)),
);
} catch (err) {
setError(
err instanceof Error ? err.message : "Failed to rename session",
);
}
},
[instanceId],
);
const resetSession = useCallback(
async (sessionId: string) => {
setError(null);
try {
await resetTerminalSession(instanceId, sessionId);
// Refetch to get updated session info
await loadSessions();
} catch (err) {
setError(
err instanceof Error ? err.message : "Failed to reset session",
);
}
},
[instanceId, loadSessions],
);
// Initial load
useEffect(() => {
void loadSessions();
}, [loadSessions]);
return {
sessions,
activeSessionId,
setActiveSessionId,
createSession,
closeSession,
renameSession,
resetSession,
loading,
error,
};
}
+302 -42
View File
@@ -1,50 +1,310 @@
import React from "react";
import React, { useCallback, useEffect, useRef, useState } from "react";
import { useNavigate, useParams } from "react-router-dom";
import { TerminalComponent } from "../components/terminal";
import { MobileTerminalWrapper } from "../components/mobile-terminal-wrapper";
import { TerminalComponent, type TerminalRef } from "../components/terminal";
import {
TerminalSessionTabs,
type TerminalSessionInfo,
} from "../components/terminal-session-tabs";
import { useMobileViewport } from "../hooks/use-mobile-viewport";
import { useAutoHide } from "../hooks/use-auto-hide";
import { useTerminalSessions } from "../hooks/use-terminal-sessions";
import type { TerminalSession } from "../api/terminal";
const SESSIONS_TO_INFO = (sessions: TerminalSession[]): TerminalSessionInfo[] =>
sessions.map((s) => ({
id: s.id,
name: s.name,
status: s.status as TerminalSessionInfo["status"],
}));
export const TerminalPage: React.FC = () => {
const { instanceId } = useParams<{ instanceId: string }>();
const navigate = useNavigate();
const isMobile = useMobileViewport();
const { instanceId } = useParams<{
instanceId: string;
}>();
const navigate = useNavigate();
const isMobile = useMobileViewport();
const [isFullscreen, setIsFullscreen] = useState(false);
const terminalRefs = useRef<Record<string, React.RefObject<TerminalRef>>>({});
const headerAutoHide = useAutoHide({ timeout: 3000, enabled: isMobile });
if (!instanceId) {
return (
<section className="stack">
<h1>Terminal</h1>
<p className="muted">No instance ID provided.</p>
</section>
);
}
const {
sessions,
activeSessionId,
setActiveSessionId,
createSession,
closeSession,
renameSession,
resetSession,
loading,
error,
} = useTerminalSessions(instanceId ?? "");
if (isMobile) {
return (
<MobileTerminalWrapper
instanceId={instanceId}
onBack={() => navigate(-1)}
onClose={() => navigate(-1)}
/>
);
}
// Auto-create default session if none exist after loading completes
useEffect(() => {
if (!loading && sessions.length === 0 && !error && instanceId) {
void createSession("Session 1");
}
}, [loading, sessions.length, error, instanceId, createSession]);
return (
<section className="terminal-page">
<div className="terminal-page-header">
<button
className="secondary-button"
onClick={() => navigate(-1)}
type="button"
>
Back
</button>
<h1>Terminal</h1>
</div>
<TerminalComponent
instanceId={instanceId}
onClose={() => navigate(-1)}
isMobile={false}
/>
</section>
);
// Ensure refs map is kept in sync with sessions
useEffect(() => {
for (const session of sessions) {
if (!terminalRefs.current[session.id]) {
terminalRefs.current[session.id] = React.createRef<TerminalRef>();
}
}
// Clean up refs for closed sessions
const currentIds = new Set(sessions.map((s) => s.id));
for (const id of Object.keys(terminalRefs.current)) {
if (!currentIds.has(id)) {
delete terminalRefs.current[id];
}
}
}, [sessions]);
// Fit and focus active terminal when switching tabs
useEffect(() => {
if (activeSessionId && terminalRefs.current[activeSessionId]) {
const ref = terminalRefs.current[activeSessionId];
// Small delay to allow display:block to apply
const timer = setTimeout(() => {
ref.current?.fit();
ref.current?.focus();
}, 50);
return () => clearTimeout(timer);
}
}, [activeSessionId]);
// Keyboard shortcuts
useEffect(() => {
const handleKeyDown = (e: KeyboardEvent) => {
const isAltShift = e.altKey && e.shiftKey && !e.ctrlKey && !e.metaKey;
if (!isAltShift) return;
switch (e.key.toLowerCase()) {
case "n":
e.preventDefault();
if (sessions.length < 5) {
void createSession(`Session ${sessions.length + 1}`);
}
break;
case "w":
e.preventDefault();
if (
activeSessionId &&
window.confirm("Close this terminal session?")
) {
void closeSession(activeSessionId);
}
break;
case "arrowleft":
e.preventDefault();
if (activeSessionId) {
const idx = sessions.findIndex((s) => s.id === activeSessionId);
if (idx > 0) {
setActiveSessionId(sessions[idx - 1].id);
}
}
break;
case "arrowright":
e.preventDefault();
if (activeSessionId) {
const idx = sessions.findIndex((s) => s.id === activeSessionId);
if (idx < sessions.length - 1) {
setActiveSessionId(sessions[idx + 1].id);
}
}
break;
case "r":
e.preventDefault();
if (activeSessionId) {
void resetSession(activeSessionId);
}
break;
case "f":
e.preventDefault();
setIsFullscreen((prev) => !prev);
break;
default:
break;
}
};
window.addEventListener("keydown", handleKeyDown);
return () => window.removeEventListener("keydown", handleKeyDown);
}, [
sessions,
activeSessionId,
createSession,
closeSession,
resetSession,
setActiveSessionId,
]);
// Exit fullscreen on Escape
useEffect(() => {
if (!isFullscreen) return;
const handleEscape = (e: KeyboardEvent) => {
if (e.key === "Escape") {
setIsFullscreen(false);
}
};
window.addEventListener("keydown", handleEscape);
return () => window.removeEventListener("keydown", handleEscape);
}, [isFullscreen]);
const handleSelect = useCallback(
(sessionId: string) => {
setActiveSessionId(sessionId);
},
[setActiveSessionId],
);
const handleClose = useCallback(
async (sessionId: string) => {
await closeSession(sessionId);
},
[closeSession],
);
const handleCreate = useCallback(() => {
void createSession(`Session ${sessions.length + 1}`);
}, [createSession, sessions.length]);
const handleRename = useCallback(
(sessionId: string, newName: string) => {
void renameSession(sessionId, newName);
},
[renameSession],
);
if (!instanceId) {
return (
<section className="stack">
<h1>Terminal</h1>
<p className="muted">No instance ID provided.</p>
</section>
);
}
const sessionInfos = SESSIONS_TO_INFO(sessions);
if (isMobile) {
return (
<section
className={`terminal-page mobile ${isFullscreen ? "fullscreen" : ""}`}
>
<div
className={`terminal-page-header mobile-header ${headerAutoHide.isVisible ? "visible" : "hidden"}`}
onClick={() => headerAutoHide.show()}
>
<button
className="secondary-button"
onClick={() => navigate(-1)}
type="button"
>
Back
</button>
<h1>Terminal</h1>
<button
className="secondary-button"
onClick={() => setIsFullscreen((p) => !p)}
type="button"
>
{isFullscreen ? "Exit" : "Fullscreen"}
</button>
</div>
<div
className={`mobile-tabs-container ${headerAutoHide.isVisible ? "visible" : "hidden"}`}
onClick={() => headerAutoHide.show()}
>
<TerminalSessionTabs
sessions={sessionInfos}
activeSessionId={activeSessionId ?? ""}
onSelect={handleSelect}
onClose={handleClose}
onCreate={handleCreate}
onRename={handleRename}
isMobile={true}
/>
</div>
<div className="terminal-page-content">
{error && <div className="terminal-error-banner">{error}</div>}
{sessions
.filter((session) => session.id === activeSessionId)
.map((session) => (
<div key={session.id} className="terminal-instance active">
<TerminalComponent
ref={terminalRefs.current[session.id]}
instanceId={instanceId}
sessionId={session.id}
onClose={() => handleClose(session.id)}
isMobile={true}
/>
</div>
))}
{sessions.length === 0 && !loading && (
<div className="terminal-empty-state">
<p>No terminal sessions. Press Alt+Shift+N to create one.</p>
</div>
)}
</div>
</section>
);
}
return (
<section className={`terminal-page ${isFullscreen ? "fullscreen" : ""}`}>
{!isFullscreen && (
<div className="terminal-page-header">
<button
className="secondary-button"
onClick={() => navigate(-1)}
type="button"
>
Back
</button>
<h1>Terminal</h1>
<button
className="secondary-button"
onClick={() => setIsFullscreen((p) => !p)}
type="button"
title="Toggle fullscreen (Alt+Shift+F)"
>
{isFullscreen ? "Exit Fullscreen" : "Fullscreen"}
</button>
</div>
)}
<TerminalSessionTabs
sessions={sessionInfos}
activeSessionId={activeSessionId ?? ""}
onSelect={handleSelect}
onClose={handleClose}
onCreate={handleCreate}
onRename={handleRename}
isMobile={false}
/>
<div className="terminal-page-content">
{error && <div className="terminal-error-banner">{error}</div>}
{sessions
.filter((session) => session.id === activeSessionId)
.map((session) => (
<div key={session.id} className="terminal-instance active">
<TerminalComponent
ref={terminalRefs.current[session.id]}
instanceId={instanceId}
sessionId={session.id}
onClose={() => handleClose(session.id)}
isMobile={false}
/>
</div>
))}
{sessions.length === 0 && !loading && (
<div className="terminal-empty-state">
<p>No terminal sessions. Press Alt+Shift+N to create one.</p>
</div>
)}
</div>
</section>
);
};
-399
View File
@@ -1,399 +0,0 @@
import { cleanup, fireEvent, render, screen, waitFor, within } from "@testing-library/react";
import { afterEach, describe, expect, it, vi } from "vitest";
import { ToolWorkshopPage } from "./tool-workshop";
import * as toolTypesApi from "../api/tool_types";
import * as toolConfigsApi from "../api/tool_configs";
import * as configFoldersApi from "../api/config_folders";
const mockToolTypes = [
{
id: "type-1",
name: "code-server",
display_name: "VS Code Server",
description: "VS Code in browser",
category: "editor",
interface_type: "web",
requires_port: true,
default_port: 8443,
definition_type: "compose",
compose_template: "version: '3.8'\\nservices:\\n app:\\n image: codercom/code-server",
dockerfile_template: null,
build_context: null,
readiness_probe: null,
required_variables: ["REPO_PATH"],
is_builtin: true,
created_by_id: null,
created_at: "2024-01-01T00:00:00Z",
updated_at: "2024-01-01T00:00:00Z",
},
{
id: "type-2",
name: "custom-tool",
display_name: "Custom Tool",
description: "My custom tool",
category: "utility",
interface_type: "terminal",
requires_port: false,
default_port: 8080,
definition_type: "dockerfile",
compose_template: null,
dockerfile_template: "FROM python:3.11",
build_context: null,
readiness_probe: {
command: "python --version",
timeout: 30,
interval: 2,
},
required_variables: [],
is_builtin: false,
created_by_id: "user-1",
created_at: "2024-01-01T00:00:00Z",
updated_at: "2024-01-01T00:00:00Z",
},
];
const mockConfigs = [
{
id: "config-1",
tool_type_id: "type-1",
project_id: null,
key: "OPENAI_API_KEY",
value: "sk-test123",
config_type: "env",
file_path: null,
port_override: null,
start_command: null,
working_directory: null,
environment_variables: {},
volumes: [],
},
{
id: "config-2",
tool_type_id: "type-2",
project_id: null,
key: "advanced-config",
value: "test-value",
config_type: "env",
file_path: null,
port_override: 9090,
start_command: "python app.py",
working_directory: "/app",
environment_variables: { DEBUG: "true" },
volumes: [{ source: "data", target: "/data", type: "bind" }],
},
];
const mockFolders = [
{
id: "folder-1",
user_id: "user-1",
name: "my-dotfiles",
description: "My personal config files",
mount_path: "/home/user",
files: { ".zshrc": "export ZSH=\"$HOME/.oh-my-zsh\"" },
project_overrides: {},
is_active: true,
created_at: "2024-01-01T00:00:00Z",
updated_at: "2024-01-01T00:00:00Z",
},
{
id: "folder-2",
user_id: "user-1",
name: "project-configs",
description: "Project specific configs",
mount_path: "/workspace",
files: { ".env": "API_URL=http://localhost:8080" },
project_overrides: {
"proj-1": {
mount_path: "/app",
files: { ".env": "API_URL=http://prod.api" },
},
},
is_active: false,
created_at: "2024-01-01T00:00:00Z",
updated_at: "2024-01-01T00:00:00Z",
},
];
afterEach(() => {
cleanup();
vi.restoreAllMocks();
});
describe("ToolWorkshopPage", () => {
it("renders loading state initially", () => {
vi.spyOn(toolTypesApi, "listToolTypes").mockImplementation(() => new Promise(() => {}));
vi.spyOn(toolConfigsApi, "listToolConfigs").mockImplementation(() => new Promise(() => {}));
vi.spyOn(configFoldersApi, "listConfigFolders").mockImplementation(() => new Promise(() => {}));
render(<ToolWorkshopPage />);
expect(screen.getByText(/loading/i)).toBeInTheDocument();
});
it("renders tool types tab by default", async () => {
vi.spyOn(toolTypesApi, "listToolTypes").mockResolvedValue(mockToolTypes as unknown as toolTypesApi.ToolType[]);
vi.spyOn(toolConfigsApi, "listToolConfigs").mockResolvedValue(mockConfigs as unknown as toolConfigsApi.ToolConfig[]);
vi.spyOn(configFoldersApi, "listConfigFolders").mockResolvedValue(mockFolders as unknown as configFoldersApi.ConfigFolder[]);
render(<ToolWorkshopPage />);
await waitFor(() => {
expect(screen.getByText("VS Code Server")).toBeInTheDocument();
});
expect(screen.getByText("Custom Tool")).toBeInTheDocument();
});
it("switches to configs tab", async () => {
vi.spyOn(toolTypesApi, "listToolTypes").mockResolvedValue(mockToolTypes as unknown as toolTypesApi.ToolType[]);
vi.spyOn(toolConfigsApi, "listToolConfigs").mockResolvedValue(mockConfigs as unknown as toolConfigsApi.ToolConfig[]);
vi.spyOn(configFoldersApi, "listConfigFolders").mockResolvedValue(mockFolders as unknown as configFoldersApi.ConfigFolder[]);
render(<ToolWorkshopPage />);
await waitFor(() => {
expect(screen.getByText("VS Code Server")).toBeInTheDocument();
});
fireEvent.click(screen.getByText("VS Code Server"));
await waitFor(() => {
expect(screen.getByRole("button", { name: /configs/i })).toBeInTheDocument();
});
fireEvent.click(screen.getByRole("button", { name: /configs/i }));
await waitFor(() => {
expect(screen.getByText("OPENAI_API_KEY")).toBeInTheDocument();
});
fireEvent.click(screen.getByRole("button", { name: /add config/i }));
expect(screen.getByPlaceholderText("e.g., OPENAI_API_KEY")).toBeInTheDocument();
expect(screen.getByPlaceholderText(/Enter value/i)).toBeInTheDocument();
});
it("creates config with advanced fields", async () => {
vi.spyOn(toolTypesApi, "listToolTypes").mockResolvedValue(mockToolTypes as unknown as toolTypesApi.ToolType[]);
const configsListMock = vi.spyOn(toolConfigsApi, "listToolConfigs").mockResolvedValue(mockConfigs as unknown as toolConfigsApi.ToolConfig[]);
const createMock = vi.spyOn(toolConfigsApi, "createToolConfig").mockResolvedValue(mockConfigs[1] as unknown as toolConfigsApi.ToolConfig);
vi.spyOn(configFoldersApi, "listConfigFolders").mockResolvedValue(mockFolders as unknown as configFoldersApi.ConfigFolder[]);
render(<ToolWorkshopPage />);
await waitFor(() => {
expect(screen.getByText("VS Code Server")).toBeInTheDocument();
});
fireEvent.click(screen.getByText("VS Code Server"));
await waitFor(() => {
expect(screen.getByRole("button", { name: /configs/i })).toBeInTheDocument();
});
fireEvent.click(screen.getByRole("button", { name: /configs/i }));
await waitFor(() => {
expect(screen.getByText("OPENAI_API_KEY")).toBeInTheDocument();
});
fireEvent.click(screen.getByRole("button", { name: /add config/i }));
fireEvent.change(screen.getByPlaceholderText("e.g., OPENAI_API_KEY"), {
target: { value: "MY_CONFIG" },
});
fireEvent.change(screen.getByPlaceholderText(/Enter value/i), {
target: { value: "my-value" },
});
fireEvent.change(screen.getByPlaceholderText("e.g., 8080"), {
target: { value: "9090" },
});
fireEvent.change(screen.getByPlaceholderText("e.g., npm start"), {
target: { value: "python app.py" },
});
fireEvent.click(screen.getByRole("button", { name: /add$/i }));
await waitFor(() => {
expect(createMock).toHaveBeenCalledWith(
expect.objectContaining({
key: "MY_CONFIG",
value: "my-value",
port_override: 9090,
start_command: "python app.py",
})
);
});
expect(configsListMock).toHaveBeenCalledTimes(2);
});
it("opens folder creation form", async () => {
vi.spyOn(toolTypesApi, "listToolTypes").mockResolvedValue(mockToolTypes as unknown as toolTypesApi.ToolType[]);
vi.spyOn(toolConfigsApi, "listToolConfigs").mockResolvedValue(mockConfigs as unknown as toolConfigsApi.ToolConfig[]);
vi.spyOn(configFoldersApi, "listConfigFolders").mockResolvedValue(mockFolders as unknown as configFoldersApi.ConfigFolder[]);
render(<ToolWorkshopPage />);
await waitFor(() => {
expect(screen.getByText("VS Code Server")).toBeInTheDocument();
});
fireEvent.click(screen.getByText("VS Code Server"));
await waitFor(() => {
expect(screen.getByRole("button", { name: /folders/i })).toBeInTheDocument();
});
fireEvent.click(screen.getByRole("button", { name: /folders/i }));
await waitFor(() => {
expect(screen.getByText("my-dotfiles")).toBeInTheDocument();
});
fireEvent.click(screen.getByRole("button", { name: /create folder/i }));
expect(screen.getByPlaceholderText("e.g., my-dotfiles")).toBeInTheDocument();
expect(screen.getByPlaceholderText("e.g., /home/user")).toBeInTheDocument();
});
it("creates config folder successfully", async () => {
const foldersListMock = vi.spyOn(configFoldersApi, "listConfigFolders").mockResolvedValue(mockFolders as unknown as configFoldersApi.ConfigFolder[]);
const createMock = vi.spyOn(configFoldersApi, "createConfigFolder").mockResolvedValue(mockFolders[0] as unknown as configFoldersApi.ConfigFolder);
vi.spyOn(toolTypesApi, "listToolTypes").mockResolvedValue(mockToolTypes as unknown as toolTypesApi.ToolType[]);
vi.spyOn(toolConfigsApi, "listToolConfigs").mockResolvedValue(mockConfigs as unknown as toolConfigsApi.ToolConfig[]);
render(<ToolWorkshopPage />);
await waitFor(() => {
expect(screen.getByText("VS Code Server")).toBeInTheDocument();
});
fireEvent.click(screen.getByText("VS Code Server"));
await waitFor(() => {
expect(screen.getByRole("button", { name: /folders/i })).toBeInTheDocument();
});
fireEvent.click(screen.getByRole("button", { name: /folders/i }));
await waitFor(() => {
expect(screen.getByText("my-dotfiles")).toBeInTheDocument();
});
fireEvent.click(screen.getByRole("button", { name: /create folder/i }));
fireEvent.change(screen.getByPlaceholderText("e.g., my-dotfiles"), {
target: { value: "new-folder" },
});
fireEvent.change(screen.getByPlaceholderText("e.g., /home/user"), {
target: { value: "/home/dev" },
});
fireEvent.click(screen.getByRole("button", { name: /create$/i }));
await waitFor(() => {
expect(createMock).toHaveBeenCalledWith(
expect.objectContaining({
name: "new-folder",
mount_path: "/home/dev",
})
);
});
expect(foldersListMock).toHaveBeenCalledTimes(2);
});
it("shows folder active/inactive status", async () => {
vi.spyOn(toolTypesApi, "listToolTypes").mockResolvedValue(mockToolTypes as unknown as toolTypesApi.ToolType[]);
vi.spyOn(toolConfigsApi, "listToolConfigs").mockResolvedValue(mockConfigs as unknown as toolConfigsApi.ToolConfig[]);
vi.spyOn(configFoldersApi, "listConfigFolders").mockResolvedValue(mockFolders as unknown as configFoldersApi.ConfigFolder[]);
render(<ToolWorkshopPage />);
await waitFor(() => {
expect(screen.getByText("VS Code Server")).toBeInTheDocument();
});
fireEvent.click(screen.getByText("VS Code Server"));
await waitFor(() => {
expect(screen.getByRole("button", { name: /folders/i })).toBeInTheDocument();
});
fireEvent.click(screen.getByRole("button", { name: /folders/i }));
await waitFor(() => {
expect(screen.getByText("my-dotfiles")).toBeInTheDocument();
});
// Check that active folder shows Active badge
expect(screen.getByText("Active")).toBeInTheDocument();
});
it("handles error state gracefully", async () => {
vi.spyOn(toolTypesApi, "listToolTypes").mockRejectedValue(new Error("Network error"));
vi.spyOn(toolConfigsApi, "listToolConfigs").mockRejectedValue(new Error("Network error"));
vi.spyOn(configFoldersApi, "listConfigFolders").mockRejectedValue(new Error("Network error"));
render(<ToolWorkshopPage />);
await waitFor(() => {
expect(screen.getByText(/failed to load/i)).toBeInTheDocument();
});
expect(screen.getByRole("button", { name: /retry/i })).toBeInTheDocument();
});
it("retries loading after error", async () => {
const listMock = vi.spyOn(toolTypesApi, "listToolTypes")
.mockRejectedValueOnce(new Error("Network error"))
.mockResolvedValueOnce(mockToolTypes as unknown as toolTypesApi.ToolType[]);
vi.spyOn(toolConfigsApi, "listToolConfigs")
.mockRejectedValueOnce(new Error("Network error"))
.mockResolvedValueOnce(mockConfigs as unknown as toolConfigsApi.ToolConfig[]);
vi.spyOn(configFoldersApi, "listConfigFolders")
.mockRejectedValueOnce(new Error("Network error"))
.mockResolvedValueOnce(mockFolders as unknown as configFoldersApi.ConfigFolder[]);
render(<ToolWorkshopPage />);
await waitFor(() => {
expect(screen.getByText(/failed to load/i)).toBeInTheDocument();
});
fireEvent.click(screen.getByRole("button", { name: /retry/i }));
await waitFor(() => {
expect(screen.getByText("VS Code Server")).toBeInTheDocument();
});
expect(listMock).toHaveBeenCalledTimes(2);
});
it("deletes tool type successfully", async () => {
const listMock = vi.spyOn(toolTypesApi, "listToolTypes").mockResolvedValue(mockToolTypes as unknown as toolTypesApi.ToolType[]);
const deleteMock = vi.spyOn(toolTypesApi, "deleteToolType").mockResolvedValue(undefined);
vi.spyOn(toolConfigsApi, "listToolConfigs").mockResolvedValue(mockConfigs as unknown as toolConfigsApi.ToolConfig[]);
vi.spyOn(configFoldersApi, "listConfigFolders").mockResolvedValue(mockFolders as unknown as configFoldersApi.ConfigFolder[]);
render(<ToolWorkshopPage />);
await waitFor(() => {
expect(screen.getByText("Custom Tool")).toBeInTheDocument();
});
// Find and click delete button for custom tool (not built-in)
const customToolCard = screen.getByText("Custom Tool").closest(".card") ||
screen.getByText("Custom Tool").parentElement;
if (customToolCard) {
const deleteButton = within(customToolCard as HTMLElement).queryByRole("button", { name: /delete/i });
if (deleteButton) {
vi.spyOn(window, "confirm").mockReturnValue(true);
fireEvent.click(deleteButton);
await waitFor(() => {
expect(deleteMock).toHaveBeenCalledWith("type-2");
});
expect(listMock).toHaveBeenCalledTimes(2);
}
}
});
});
+2 -778
View File
@@ -26,25 +26,6 @@ import {
type ToolDefinitionManifest,
} from "../api/tool_definitions";
import { ManifestEditor } from "../components/manifest-editor";
import {
createToolConfig,
deleteToolConfig,
listToolConfigs,
updateToolConfig,
type CreateToolConfigRequest,
type ToolConfig,
} from "../api/tool_configs";
import {
createConfigFolder,
deleteConfigFolder,
listConfigFolders,
updateConfigFolder,
type ConfigFolder,
type CreateConfigFolderRequest,
type UpdateConfigFolderRequest,
} from "../api/config_folders";
type RightPanelTab = "details" | "configs" | "folders";
type Status = "loading" | "ready" | "error";
type MobileView = "list" | "detail" | "edit";
@@ -54,8 +35,6 @@ export const ToolWorkshopPage = () => {
const [mobileView, setMobileView] = useState<MobileView>("list");
const [status, setStatus] = useState<Status>("loading");
const [toolTypes, setToolTypes] = useState<ToolType[]>([]);
const [configs, setConfigs] = useState<ToolConfig[]>([]);
const [folders, setFolders] = useState<ConfigFolder[]>([]);
const [baseDefinitions, setBaseDefinitions] = useState<
ToolDefinitionManifest[]
>([]);
@@ -72,7 +51,6 @@ export const ToolWorkshopPage = () => {
null,
);
const [isCreating, setIsCreating] = useState(false);
const [rightPanelTab, setRightPanelTab] = useState<RightPanelTab>("details");
// Tool Type form state
const [toolTypeForm, setToolTypeForm] = useState({
@@ -95,55 +73,18 @@ export const ToolWorkshopPage = () => {
const [toolTypeError, setToolTypeError] = useState<string | null>(null);
const [toolTypeDirty, setToolTypeDirty] = useState(false);
// Config form state
const [configForm, setConfigForm] = useState({
key: "",
value: "",
config_type: "env",
file_path: "",
port_override: "",
start_command: "",
working_directory: "",
env_vars_json: "{}",
volumes_json: "[]",
});
const [configError, setConfigError] = useState<string | null>(null);
const [showConfigForm, setShowConfigForm] = useState(false);
const [selectedConfig, setSelectedConfig] = useState<ToolConfig | null>(null);
// Folder form state
const [folderForm, setFolderForm] = useState({
name: "",
description: "",
mount_path: "/home/user",
files_json: "{}",
is_active: true,
});
const [folderError, setFolderError] = useState<string | null>(null);
const [showFolderForm, setShowFolderForm] = useState(false);
const [selectedFolder, setSelectedFolder] = useState<ConfigFolder | null>(
null,
);
const selectedToolType =
(toolTypes || []).find((t) => t.id === selectedToolTypeId) || null;
const toolConfigs = (configs || []).filter(
(c) => c.tool_type_id === selectedToolTypeId,
);
const toolFolders = folders || []; // Config folders are global, not per-tool-type in current API
const loadData = useCallback(async () => {
setStatus("loading");
try {
const [types, cfgs, fldrs, defs] = await Promise.all([
const [types, defs] = await Promise.all([
listToolTypes(),
listToolConfigs(),
listConfigFolders(),
listToolDefinitions(),
]);
setToolTypes(types || []);
setConfigs(cfgs || []);
setFolders(fldrs || []);
setBaseDefinitions((defs || []).filter((d) => d.is_base));
setStatus("ready");
} catch {
@@ -219,9 +160,6 @@ export const ToolWorkshopPage = () => {
} else {
setSelectedToolTypeId(null);
}
setRightPanelTab("details");
setShowConfigForm(false);
setShowFolderForm(false);
};
const handleCreateNew = () => {
@@ -233,9 +171,6 @@ export const ToolWorkshopPage = () => {
setSelectedToolTypeId(null);
setIsCreating(true);
resetToolTypeForm();
setRightPanelTab("details");
setShowConfigForm(false);
setShowFolderForm(false);
};
const handleToolTypeSubmit = async (e: React.FormEvent) => {
@@ -366,213 +301,6 @@ export const ToolWorkshopPage = () => {
}
};
// Config handlers
const resetConfigForm = () => {
setConfigForm({
key: "",
value: "",
config_type: "env",
file_path: "",
port_override: "",
start_command: "",
working_directory: "",
env_vars_json: "{}",
volumes_json: "[]",
});
setConfigError(null);
setSelectedConfig(null);
};
const openCreateConfig = () => {
resetConfigForm();
setShowConfigForm(true);
};
const openEditConfig = (config: ToolConfig) => {
setConfigForm({
key: config.key,
value: config.value,
config_type: config.config_type,
file_path: config.file_path || "",
port_override: config.port_override?.toString() || "",
start_command: config.start_command || "",
working_directory: config.working_directory || "",
env_vars_json: config.environment_variables
? JSON.stringify(config.environment_variables, null, 2)
: "{}",
volumes_json: config.volumes
? JSON.stringify(config.volumes, null, 2)
: "[]",
});
setConfigError(null);
setShowConfigForm(true);
setSelectedConfig(config);
};
const handleConfigSubmit = async (e: React.FormEvent) => {
e.preventDefault();
setConfigError(null);
if (!selectedToolTypeId || !configForm.key.trim()) {
setConfigError("Tool type and key are required");
return;
}
let envVars: Record<string, string> | undefined;
let volumes:
| Array<{ source: string; target: string; type?: string }>
| undefined;
try {
if (
configForm.env_vars_json.trim() &&
configForm.env_vars_json.trim() !== "{}"
) {
envVars = JSON.parse(configForm.env_vars_json);
}
} catch {
setConfigError("Environment variables must be valid JSON");
return;
}
try {
if (
configForm.volumes_json.trim() &&
configForm.volumes_json.trim() !== "[]"
) {
volumes = JSON.parse(configForm.volumes_json);
}
} catch {
setConfigError("Volumes must be valid JSON array");
return;
}
const data: CreateToolConfigRequest = {
tool_type_id: selectedToolTypeId,
key: configForm.key.trim(),
value: configForm.value,
config_type: configForm.config_type,
file_path:
configForm.config_type === "file" ? configForm.file_path : undefined,
port_override: configForm.port_override
? Number(configForm.port_override)
: undefined,
start_command: configForm.start_command.trim() || undefined,
working_directory: configForm.working_directory.trim() || undefined,
environment_variables: envVars,
volumes,
};
try {
if (selectedConfig) {
await updateToolConfig(selectedConfig.id, data);
} else {
await createToolConfig(data);
}
setShowConfigForm(false);
setSelectedConfig(null);
resetConfigForm();
await loadData();
} catch (err) {
setConfigError(extractErrorMessage(err));
}
};
const handleDeleteConfig = async (id: string) => {
if (!window.confirm("Delete this config?")) return;
try {
await deleteToolConfig(id);
await loadData();
} catch {
alert("Failed to delete config");
}
};
// Folder handlers
const resetFolderForm = () => {
setFolderForm({
name: "",
description: "",
mount_path: "/home/user",
files_json: "{}",
is_active: true,
});
setFolderError(null);
setSelectedFolder(null);
};
const openCreateFolder = () => {
resetFolderForm();
setShowFolderForm(true);
};
const openEditFolder = (folder: ConfigFolder) => {
setFolderForm({
name: folder.name,
description: folder.description || "",
mount_path: folder.mount_path,
files_json: JSON.stringify(folder.files, null, 2),
is_active: folder.is_active,
});
setFolderError(null);
setShowFolderForm(true);
setSelectedFolder(folder);
};
const handleFolderSubmit = async (e: React.FormEvent) => {
e.preventDefault();
setFolderError(null);
if (!folderForm.name.trim() || !folderForm.mount_path.trim()) {
setFolderError("Name and mount path are required");
return;
}
let files: Record<string, string> | undefined;
try {
if (
folderForm.files_json.trim() &&
folderForm.files_json.trim() !== "{}"
) {
files = JSON.parse(folderForm.files_json);
}
} catch {
setFolderError("Files must be valid JSON object");
return;
}
const data: CreateConfigFolderRequest | UpdateConfigFolderRequest = {
name: folderForm.name.trim(),
description: folderForm.description.trim() || undefined,
mount_path: folderForm.mount_path.trim(),
files,
is_active: folderForm.is_active,
};
try {
if (selectedFolder) {
await updateConfigFolder(selectedFolder.id, data);
} else {
await createConfigFolder(data as CreateConfigFolderRequest);
}
setShowFolderForm(false);
setSelectedFolder(null);
resetFolderForm();
await loadData();
} catch (err) {
setFolderError(extractErrorMessage(err));
}
};
const handleDeleteFolder = async (id: string) => {
if (!window.confirm("Delete this config folder?")) return;
try {
await deleteConfigFolder(id);
await loadData();
} catch {
alert("Failed to delete folder");
}
};
if (status === "loading") {
return (
@@ -1143,45 +871,7 @@ export const ToolWorkshopPage = () => {
)}
</div>
{/* Right Panel Tabs */}
<div
style={{
display: "flex",
gap: "0",
marginBottom: "1.5rem",
borderBottom: "1px solid var(--border)",
}}
>
{(["details", "configs", "folders"] as RightPanelTab[]).map(
(tab) => (
<button
key={tab}
onClick={() => setRightPanelTab(tab)}
style={{
padding: "0.625rem 1.25rem",
borderBottom:
rightPanelTab === tab
? "2px solid var(--brand)"
: "2px solid transparent",
background: "none",
border: "none",
cursor: "pointer",
fontWeight: rightPanelTab === tab ? 600 : 400,
color:
rightPanelTab === tab ? "var(--brand)" : "var(--muted)",
fontSize: "0.9375rem",
marginBottom: "-1px",
textTransform: "capitalize",
}}
>
{tab}
</button>
),
)}
</div>
{/* Details Tab */}
{rightPanelTab === "details" && (
{(
<form
onSubmit={handleToolTypeSubmit}
className="stack"
@@ -1513,472 +1203,6 @@ export const ToolWorkshopPage = () => {
</form>
)}
{/* Configs Tab */}
{rightPanelTab === "configs" && selectedToolTypeId && (
<div>
<div
style={{
display: "flex",
justifyContent: "space-between",
alignItems: "center",
marginBottom: "1rem",
}}
>
<h3 style={{ margin: 0 }}>
Configurations for {selectedToolType?.display_name}
</h3>
<button onClick={openCreateConfig}>
<Icon name="add" size="sm" /> Add Config
</button>
</div>
{showConfigForm && (
<div
className="card stack"
style={{ marginBottom: "1rem", padding: "1rem" }}
>
<h4>{selectedConfig ? "Edit" : "Add"} Config</h4>
<form
onSubmit={handleConfigSubmit}
className="stack"
style={{ gap: "0.75rem" }}
>
<div className="form-group">
<label>Key *</label>
<input
type="text"
value={configForm.key}
onChange={(e) =>
setConfigForm({
...configForm,
key: e.target.value,
})
}
placeholder="e.g., OPENAI_API_KEY"
className="form-input"
required
/>
</div>
<div className="form-group">
<label>Config Type</label>
<select
value={configForm.config_type}
onChange={(e) =>
setConfigForm({
...configForm,
config_type: e.target.value,
})
}
className="form-input"
>
<option value="env">Environment Variable</option>
<option value="file">Configuration File</option>
</select>
</div>
{configForm.config_type === "file" && (
<div className="form-group">
<label>File Path</label>
<input
type="text"
value={configForm.file_path}
onChange={(e) =>
setConfigForm({
...configForm,
file_path: e.target.value,
})
}
placeholder="e.g., /app/config.json"
className="form-input"
/>
</div>
)}
<div className="form-group">
<label>Value</label>
<textarea
value={configForm.value}
onChange={(e) =>
setConfigForm({
...configForm,
value: e.target.value,
})
}
placeholder={
configForm.config_type === "env"
? "Enter value..."
: "Enter file contents..."
}
className="form-input"
rows={configForm.config_type === "file" ? 8 : 2}
required
/>
</div>
<div className="row" style={{ gap: "1rem" }}>
<div className="form-group" style={{ flex: 1 }}>
<label>Port Override</label>
<input
type="number"
value={configForm.port_override}
onChange={(e) =>
setConfigForm({
...configForm,
port_override: e.target.value,
})
}
placeholder="e.g., 8080"
className="form-input"
/>
</div>
<div className="form-group" style={{ flex: 1 }}>
<label>Start Command</label>
<input
type="text"
value={configForm.start_command}
onChange={(e) =>
setConfigForm({
...configForm,
start_command: e.target.value,
})
}
placeholder="e.g., npm start"
className="form-input"
/>
</div>
</div>
<div className="form-group">
<label>Working Directory</label>
<input
type="text"
value={configForm.working_directory}
onChange={(e) =>
setConfigForm({
...configForm,
working_directory: e.target.value,
})
}
placeholder="e.g., /workspace"
className="form-input"
/>
</div>
<div className="form-group">
<label>Environment Variables (JSON)</label>
<textarea
value={configForm.env_vars_json}
onChange={(e) =>
setConfigForm({
...configForm,
env_vars_json: e.target.value,
})
}
placeholder='{"KEY": "value"}'
className="form-input"
rows={3}
style={{
fontFamily: "monospace",
fontSize: "0.8125rem",
}}
/>
</div>
<div className="form-group">
<label>Volumes (JSON array)</label>
<textarea
value={configForm.volumes_json}
onChange={(e) =>
setConfigForm({
...configForm,
volumes_json: e.target.value,
})
}
placeholder='[{"source": "/host", "target": "/container"}]'
className="form-input"
rows={3}
style={{
fontFamily: "monospace",
fontSize: "0.8125rem",
}}
/>
</div>
{configError && (
<p className="text-error">{configError}</p>
)}
<div className="dialog-actions">
<button type="submit">
{selectedConfig ? "Update" : "Add"}
</button>
<button
type="button"
onClick={() => {
setShowConfigForm(false);
resetConfigForm();
}}
className="button-secondary"
>
Cancel
</button>
</div>
</form>
</div>
)}
<div className="stack" style={{ gap: "0.5rem" }}>
{toolConfigs.length === 0 ? (
<EmptyState message="No configurations for this tool type yet." />
) : (
toolConfigs.map((config) => (
<div
key={config.id}
className="card"
style={{
display: "flex",
justifyContent: "space-between",
alignItems: "center",
padding: "0.75rem 1rem",
}}
>
<div>
<div
className="row"
style={{ gap: "0.5rem", alignItems: "center" }}
>
<code style={{ fontWeight: 600 }}>
{config.key}
</code>
<span
style={{
fontSize: "0.7rem",
textTransform: "uppercase",
padding: "0.125rem 0.5rem",
borderRadius: "9999px",
background:
config.config_type === "env"
? "var(--info, #3b82f6)"
: "var(--warning, #f59e0b)",
color: "white",
fontWeight: 600,
}}
>
{config.config_type}
</span>
</div>
<p
className="muted"
style={{
marginTop: "0.25rem",
fontSize: "0.875rem",
}}
>
{config.config_type === "file" && config.file_path
? `File: ${config.file_path}`
: "Environment variable"}
{config.port_override &&
` · Port: ${config.port_override}`}
</p>
</div>
<div className="row" style={{ gap: "0.5rem" }}>
<button
className="ghost-button small"
onClick={() => openEditConfig(config)}
type="button"
>
<Icon name="edit" size="sm" />
</button>
<button
className="ghost-button small"
onClick={() => handleDeleteConfig(config.id)}
type="button"
>
<Icon name="delete" size="sm" />
</button>
</div>
</div>
))
)}
</div>
</div>
)}
{/* Folders Tab */}
{rightPanelTab === "folders" && (
<div>
<div
style={{
display: "flex",
justifyContent: "space-between",
alignItems: "center",
marginBottom: "1rem",
}}
>
<h3 style={{ margin: 0 }}>Config Folders</h3>
<button onClick={openCreateFolder}>
<Icon name="add" size="sm" /> Create Folder
</button>
</div>
{showFolderForm && (
<div
className="card stack"
style={{ marginBottom: "1rem", padding: "1rem" }}
>
<h4>{selectedFolder ? "Edit" : "Create"} Config Folder</h4>
<form
onSubmit={handleFolderSubmit}
className="stack"
style={{ gap: "0.75rem" }}
>
<div className="form-group">
<label>Name *</label>
<input
type="text"
value={folderForm.name}
onChange={(e) =>
setFolderForm({
...folderForm,
name: e.target.value,
})
}
placeholder="e.g., my-dotfiles"
className="form-input"
required
/>
</div>
<div className="form-group">
<label>Description</label>
<input
type="text"
value={folderForm.description}
onChange={(e) =>
setFolderForm({
...folderForm,
description: e.target.value,
})
}
placeholder="Optional description"
className="form-input"
/>
</div>
<div className="form-group">
<label>Mount Path *</label>
<input
type="text"
value={folderForm.mount_path}
onChange={(e) =>
setFolderForm({
...folderForm,
mount_path: e.target.value,
})
}
placeholder="e.g., /home/user"
className="form-input"
required
/>
</div>
<div className="form-group">
<label>Files (JSON object)</label>
<textarea
value={folderForm.files_json}
onChange={(e) =>
setFolderForm({
...folderForm,
files_json: e.target.value,
})
}
placeholder='{".zshrc": "export ZSH=...", ".gitconfig": "[user]\\nname = ..."}'
className="form-input"
rows={8}
style={{
fontFamily: "monospace",
fontSize: "0.8125rem",
}}
/>
</div>
<div className="form-group">
<label className="checkbox-label">
<input
type="checkbox"
checked={folderForm.is_active}
onChange={(e) =>
setFolderForm({
...folderForm,
is_active: e.target.checked,
})
}
/>
Active (mount into new instances)
</label>
</div>
{folderError && (
<p className="text-error">{folderError}</p>
)}
<div className="dialog-actions">
<button type="submit">
{selectedFolder ? "Update" : "Create"}
</button>
<button
type="button"
onClick={() => {
setShowFolderForm(false);
resetFolderForm();
}}
className="button-secondary"
>
Cancel
</button>
</div>
</form>
</div>
)}
<div className="card-grid">
{toolFolders.map((folder) => (
<div key={folder.id} className="card">
<div className="card-header">
<h4>{folder.name}</h4>
{folder.is_active && (
<span className="badge">Active</span>
)}
</div>
<p className="text-secondary">
{folder.description || "No description"}
</p>
<div className="tool-type-meta">
<span>Mount: {folder.mount_path}</span>
<span>
Files: {Object.keys(folder.files || {}).length}
</span>
</div>
<div className="card-actions">
<button
onClick={() => openEditFolder(folder)}
className="button-secondary"
>
<Icon name="edit" size="sm" /> Edit
</button>
<button
onClick={() => handleDeleteFolder(folder.id)}
className="button-danger"
>
<Icon name="delete" size="sm" /> Delete
</button>
</div>
</div>
))}
</div>
</div>
)}
</div>
)}
</div>
+2627 -2331
View File
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,3 @@
name: multi-session-terminal-ux
status: exploring
started_at: 2026-05-28
@@ -0,0 +1,74 @@
# Apply Report: PR 1 Database + Backend Core for Multi-Session Terminal UX
## Summary
Implemented the database schema, Alembic migration, TerminalManager multi-session core, and TerminalSession name/status tracking for the multi-session terminal UX feature. All changes are backward-compatible with the existing single-session `/terminal` WebSocket endpoint.
### Key Changes
1. **Database Schema** Added `terminal_sessions` table with `UUIDPrimaryKeyMixin` + `TimestampMixin`, storing `instance_id`, `name`, `status`, `last_activity_at`, and `closed_at`.
2. **Alembic Migration** Created migration `2026_05_28_add_terminal_sessions` (down-revision from `20260527_160017_add_pi_agent`).
3. **TerminalSession** Added `name` (auto-generated as "Session N"), `status` field (`active`/`resetting`/`closed`), and updated `reset()`/`close()` to set status appropriately.
4. **TerminalManager** Migrated `_sessions` dict from `dict[str, TerminalSession]` to `dict[tuple[str, str], TerminalSession]`. Added `create_session()`, `get_session()`, `get_sessions_for_instance()`, `close_session()`, and updated `reset_session()` to accept an optional `session_id`. Preserved `get_or_create_session()` for backward compatibility (uses `"default"` session_id). Idle cleanup now operates on composite keys and fires DB status updates asynchronously.
5. **Tests** Created 7 unit tests covering session creation, max-5 enforcement, filtering, close/removal, WebSocket isolation, default session keying, and idle cleanup DB updates.
## Files Created
- `apps/api/src/models/terminal_session.py`
- `apps/api/alembic/versions/2026_05_28_add_terminal_sessions_table.py`
- `apps/api/tests/services/test_terminal_manager_multi.py`
## Files Modified
- `apps/api/src/models/__init__.py` Imported `TerminalSessionModel`
- `apps/api/src/main.py` Imported `TerminalSessionModel` for Alembic model discovery
- `apps/api/src/services/terminal_manager.py` Full refactor to composite-key session management with DB fire-and-forget helpers
- `apps/api/src/services/terminal_session.py` Added `name`, `status`, `_instance_counters`, and status transitions
## Test Results
### New Tests (7/7 passed)
```
$ cd apps/api && python -m pytest tests/services/test_terminal_manager_multi.py -v
tests/services/test_terminal_manager_multi.py::test_create_session_increases_count PASSED
tests/services/test_terminal_manager_multi.py::test_create_session_enforces_max_5 PASSED
tests/services/test_terminal_manager_multi.py::test_get_sessions_for_instance_filters_by_instance PASSED
tests/services/test_terminal_manager_multi.py::test_close_session_removes_from_dict PASSED
tests/services/test_terminal_manager_multi.py::test_attach_websocket_only_closes_same_session PASSED
tests/services/test_terminal_manager_multi.py::test_default_session_keyed_separately PASSED
tests/services/test_terminal_manager_multi.py::test_idle_cleanup_updates_db_status PASSED
======================== 7 passed, 4 warnings in 0.11s =========================
```
### Full Suite (no regressions)
```
$ cd apps/api && python -m pytest tests/ -q
51 failed, 174 passed, 6 warnings in 15.96s
```
- **Baseline failures**: 51 (pre-existing, unchanged by this PR)
- **New passes**: +7 (from `test_terminal_manager_multi.py`)
- **No new failures introduced**
## Deviations from Design
1. **Duplicate `created_at` column** The design spec and its Alembic snippet listed `created_at` twice (once explicitly, once from `TimestampMixin`). I removed the explicit `created_at` from the model and migration, relying on `TimestampMixin` which provides `server_default=func.now()`.
2. **DB write implementation** The design showed DB writes inside `TerminalManager` but didn't specify the exact async pattern. I implemented them as `asyncio.create_task`-wrapped coroutines using `SessionLocal()` so they are non-blocking. Unit tests mock `_mark_closed_in_db` and `_insert_db_session_row` to verify calls without needing a live DB.
3. **`get_or_create_session` auto-name** The design said default session should count toward the 5-session limit. The current implementation does count it, but `get_or_create_session` creates the default session outside the `create_session` path (to preserve backward compat). Future REST endpoints can enforce the limit at the API layer before calling either path.
## Blockers / Risks
- **Global singleton test isolation** `TerminalManager` is still a global singleton (`terminal_manager = TerminalManager()`). The unit tests create fresh instances via the `manager` fixture, but integration tests that import the global may need care to reset state between tests.
- **DB fire-and-forget in tests** The aiosqlite background thread emits `RuntimeError: Event loop is closed` warnings when the test event loop tears down before the fire-and-forget DB task completes. This is harmless in tests but worth monitoring.
- **Migration head** The migration chains from `20260527_160017_add_pi_agent`. If a new migration lands on `dev` before this PR merges, the `down_revision` must be updated.
## Next Recommended Action
1. **Task 5 (WebSocket endpoint + REST API)** Implement the new `/ws/tool-instances/{instance_id}/terminal/{session_id}` WebSocket route and the REST endpoints (`GET/POST/DELETE .../terminal/sessions`) in `apps/api/src/api/terminal.py`. Extract the shared auth/validation/I/O loop into `_handle_terminal_websocket()` as specified in the design.
2. **Run migration in a staging environment** Verify `alembic upgrade head` applies cleanly and `downgrade` reverses without data loss.
3. **Integration tests for WebSocket multi-session** Create `apps/api/tests/api/test_terminal_ws_multi.py` to validate concurrent session isolation and the default-session alias.
@@ -0,0 +1,40 @@
# PR 3: Frontend Multi-Session Terminal UI
## Summary
Implemented the frontend UI for multi-session terminal support: tabbed session management, fullscreen mode, keyboard shortcuts, and mobile integration.
## Files Created
- `apps/web/src/components/terminal-session-tabs.tsx` — Tab bar component with rename, close, status dots, overflow scroll
- `apps/web/src/components/terminal-session-tabs.test.tsx` — 7 passing component tests
## Files Modified
- `apps/web/src/components/terminal.tsx` — Added `sessionId` prop, `TerminalRef` with `fit()`, `forwardRef` wrapper
- `apps/web/src/pages/terminal.tsx` — Multi-session orchestration with tabs, fullscreen, keyboard shortcuts
- `apps/web/src/hooks/use-terminal-sessions.ts` — Hook for session CRUD + state management
- `apps/web/src/api/terminal.ts` — API client for terminal session endpoints
- `apps/web/src/styles.css` — Terminal tab styles, fullscreen mode, mobile responsive
- `apps/api/src/services/terminal_manager.py` — Added lookup by internal session_id fallback
## Acceptance Criteria
- [x] TerminalComponent accepts optional sessionId prop
- [x] WS URL includes sessionId when provided
- [x] TerminalSessionTabs renders sessions with status dots
- [x] Double-click to rename, click × to close (with confirm)
- [x] New session (+) button, disabled at 5 sessions
- [x] Tab switching updates active terminal, calls fit()
- [x] Fullscreen toggle (Alt+Shift+F), exit via Esc
- [x] Keyboard shortcuts: Alt+Shift+N (new), W (close), ←/→ (navigate), R (reset)
- [x] Auto-creates default session if none exist
- [x] Closing last session auto-creates new default
- [x] Mobile: tabs in compact strip, same keyboard shortcuts
- [x] No browser shortcuts overridden (uses Alt+Shift, not Ctrl+Shift)
## Quality Gates
- TypeScript typecheck: ✅ clean
- Frontend tests: ✅ 7/7 terminal-session-tabs tests passing
- Backend tests: ✅ 182 passed, 51 pre-existing failures (no regressions)
- Lint: ✅ 0 errors
## Blockers / Deviations
- MobileTerminalWrapper was not fully integrated with session tabs due to complexity. Mobile path uses inline tab rendering instead.
- This is acceptable for MVP; full mobile integration can be refined in follow-up.
@@ -0,0 +1,867 @@
# SDD Design: Multi-Session Terminal UX
## Architecture Overview
The multi-session terminal extends the existing persistent-session foundation to support up to 5 concurrent terminal sessions per tool instance. The architecture uses a **hybrid storage model**: active PTY processes and WebSocket routing live in-memory (performance-critical path), while session metadata (name, status, timestamps) persists in a new `terminal_sessions` database table.
### High-Level Flow
```
┌─────────────────────────────────────────────────────────────────────────────┐
│ Frontend (React) │
│ ┌──────────────────┐ ┌──────────────────┐ ┌──────────────────┐ │
│ │ TerminalSession │ │ TerminalSession │ │ TerminalSession │ ... │
│ │ Tabs (Desktop) │ │ Tabs (Mobile) │ │ FullscreenMgr │ │
│ └────────┬─────────┘ └────────┬─────────┘ └────────┬─────────┘ │
│ │ │ │ │
│ ┌────────▼──────────────────────▼──────────────────────▼─────────┐ │
│ │ TerminalSessionManager │ │
│ │ (React state: sessions[], activeSessionId) │ │
│ └────────┬──────────────────────┬──────────────────────┬─────────┘ │
│ │ │ │ │
│ ┌────────▼─────────┐ ┌────────▼─────────┐ ┌────────▼─────────┐ │
│ │ TerminalComponent│ │ TerminalComponent│ │ TerminalComponent│ ... │
│ │ (xterm.js + WS) │ │ (xterm.js + WS) │ │ (xterm.js + WS) │ │
│ └────────┬─────────┘ └────────┬─────────┘ └────────┬─────────┘ │
└───────────┼─────────────────────┼─────────────────────┼────────────────────┘
│ │ │
▼ ▼ ▼
┌─────────────────────────────────────────────────────────────┐
│ FastAPI Backend │
│ ┌──────────────────┐ ┌──────────────────┐ ┌────────────┐ │
│ │ /terminal │ │ /terminal/{sid} │ │ REST /ses- │ │
│ │ (default alias) │ │ (specific sess) │ │ sions │ │
│ └────────┬─────────┘ └────────┬─────────┘ └─────┬──────┘ │
│ │ │ │ │
│ ┌────────▼──────────────────────▼────────────────────▼─────┐ │
│ │ TerminalManager │ │
│ │ dict[(instance_id, session_id)] → TerminalSession │ │
│ └────────┬──────────────────────┬──────────────────────────┘ │
│ │ │ │
│ ┌────────▼─────────┐ ┌────────▼─────────┐ │
│ │ TerminalSession │ │ TerminalSession │ ... │
│ │ (PTY + docker │ │ (PTY + docker │ │
│ │ exec process) │ │ exec process) │ │
│ └────────┬─────────┘ └────────┬─────────┘ │
│ │ │ │
│ ┌────────▼──────────────────────▼───────────────────────────┐│
│ │ TerminalSessionModel (DB) ││
│ │ instance_id | name | status | created_at | closed_at ││
│ └───────────────────────────────────────────────────────────┘│
└───────────────────────────────────────────────────────────────┘
```
### Key Principles
- **One WebSocket per session**: Each `TerminalComponent` opens its own WebSocket to its specific `session_id`. Inactive sessions keep their WebSocket open to preserve scrollback and real-time output.
- **Max 5 sessions per instance**: Enforced in `TerminalManager.create_session()` and validated in the REST endpoint.
- **Default session alias**: `/ws/tool-instances/{instance_id}/terminal` maps to the single legacy session (or the first/only active session) for backward compatibility.
- **Tab-only UI**: No split panes for MVP. Sessions are presented as tabs on desktop and as a scrollable tab strip integrated into the mobile header area.
---
## Backend Design
### 1. TerminalManager Changes
**File**: `apps/api/src/services/terminal_manager.py`
#### Session Key Change
```python
# BEFORE
self._sessions: dict[str, TerminalSession] = {} # keyed by instance_id
# AFTER
self._sessions: dict[tuple[str, str], TerminalSession] = {} # keyed by (instance_id, session_id)
```
#### New / Modified Methods
| Method | Signature | Behavior |
|--------|-----------|----------|
| `create_session` | `(instance_id, container_id, startup_command=None, name=None) → TerminalSession` | Creates a new `TerminalSession`, starts it, stores under `(instance_id, session_id)`, and inserts a `TerminalSessionModel` DB row. Enforces max 5 sessions. |
| `get_or_create_session` | *(preserved)* | **Backward-compat only.** Returns existing default session or creates one with `session_id="default"`. Called by the legacy `/terminal` WebSocket endpoint. |
| `get_session` | `(instance_id, session_id) → TerminalSession \| None` | Lookup by composite key. |
| `get_sessions_for_instance` | `(instance_id) → list[TerminalSession]` | Returns all in-memory sessions for an instance. |
| `close_session` | `(instance_id, session_id) → None` | Kills the PTY process, removes from `_sessions`, updates DB row `status=closed`, `closed_at=now()`. |
| `reset_session` | *(modified)* | Now accepts an optional `session_id`. If omitted, resets the default session. |
| `attach_websocket` | *(preserved)* | **Critical fix**: The "close existing WebSockets" logic must only close sockets **within the same `(instance_id, session_id)`**. Previously it closed all sockets for the instance. |
#### Default Session Behavior
- The first time a client hits `/ws/.../terminal` (no `session_id`), `TerminalManager` checks if a "default" session exists under key `(instance_id, "default")`.
- If none exists, it creates one (same as `get_or_create_session`).
- The default session counts toward the 5-session limit.
#### Idle Cleanup
```python
async def _cleanup_idle_sessions(self) -> None:
idle_keys = []
for (instance_id, session_id), session in list(self._sessions.items()):
if session.is_idle():
idle_keys.append((instance_id, session_id))
for key in idle_keys:
session = self._sessions.pop(key, None)
if session:
await session.close()
# Update DB status
await self._mark_closed_in_db(key[1])
```
### 2. TerminalSession Changes
**File**: `apps/api/src/services/terminal_session.py`
#### New Fields
```python
class TerminalSession:
# ... existing fields ...
def __init__(self, session_id: str, instance_id: uuid.UUID, container_id: str,
startup_command: str | None = None, name: str | None = None) -> None:
# ... existing init ...
self.name = name or f"Session {self._next_session_number(instance_id)}"
self.status: str = "active" # active, resetting, closed
```
The `name` field is runtime-only in `TerminalSession`. Renames update the DB via REST, then the frontend uses the new name on next mount or via a lightweight WS status broadcast (optional optimization).
#### Status Tracking
- `active`: Normal operation.
- `resetting`: Transient during `reset()` — cleared after new process starts.
- `closed`: Set after `close()` is called.
### 3. WebSocket Endpoint Changes
**File**: `apps/api/src/api/terminal.py`
#### New Route (Specific Session)
```python
@router.websocket("/ws/tool-instances/{instance_id}/terminal/{session_id}")
async def terminal_websocket_specific(
websocket: WebSocket,
instance_id: str,
session_id: str,
db_session: AsyncSession = Depends(get_db_session),
) -> None:
...
```
#### Backward-Compatible Route (Default Session)
```python
@router.websocket("/ws/tool-instances/{instance_id}/terminal")
async def terminal_websocket_default(
websocket: WebSocket,
instance_id: str,
db_session: AsyncSession = Depends(get_db_session),
) -> None:
# Identical auth/validation logic
# Calls terminal_manager.get_or_create_session(...) # uses "default" session_id
# Rest of the loop is identical to specific-session endpoint
...
```
#### Refactoring
Both endpoints share the same auth/validation and I/O loop logic. Extract a common coroutine:
```python
async def _handle_terminal_websocket(
websocket: WebSocket,
instance_id: str,
session_id: str | None, # None means default
db_session: AsyncSession,
) -> None:
# Shared: auth, instance lookup, tool_type fetch, session fetch/create,
# attach_websocket, read/write/heartbeat loops, detach_websocket
```
#### Control Messages (Unchanged)
The WebSocket control message protocol is unchanged:
- `{"type": "resize", "cols": 80, "rows": 24}`
- `{"type": "reset"}` — resets the **current** session only
### 4. Database Schema
**File**: `apps/api/src/models/terminal_session.py` (new)
```python
import uuid
from datetime import datetime
from sqlalchemy import DateTime, ForeignKey, String
from sqlalchemy import Uuid as UUID
from sqlalchemy.orm import Mapped, mapped_column
from src.models.base import Base, TimestampMixin, UUIDPrimaryKeyMixin
class TerminalSessionModel(UUIDPrimaryKeyMixin, TimestampMixin, Base):
__tablename__ = "terminal_sessions"
instance_id: Mapped[uuid.UUID] = mapped_column(
UUID(),
ForeignKey("tool_instances.id", ondelete="CASCADE"),
nullable=False,
index=True,
)
name: Mapped[str | None] = mapped_column(String(255), nullable=True)
status: Mapped[str] = mapped_column(
String(50),
nullable=False,
default="active",
)
created_at: Mapped[datetime] = mapped_column(
DateTime(timezone=True),
nullable=False,
)
last_activity_at: Mapped[datetime | None] = mapped_column(
DateTime(timezone=True),
nullable=True,
)
closed_at: Mapped[datetime | None] = mapped_column(
DateTime(timezone=True),
nullable=True,
)
```
#### Rationale
- `instance_id` is indexed because lookups by instance are frequent (listing sessions, cleanup).
- `name` is nullable; auto-generated names are stored here so they survive page reloads.
- `status` tracks `active` vs `closed`. The `TerminalManager` updates `last_activity_at` whenever a WebSocket attaches/detaches or I/O occurs.
- On API restart, in-memory sessions are lost, but `terminal_sessions` rows remain as metadata history. A future enhancement could resurrect sessions, but that is out of scope.
### 5. Alembic Migration
**File**: `apps/api/src/alembic/versions/XXXX_add_terminal_sessions_table.py`
```python
"""Add terminal_sessions table."""
from alembic import op
import sqlalchemy as sa
# revision identifiers, used by Alembic.
revision = "<generated>"
down_revision = "<previous>"
def upgrade() -> None:
op.create_table(
"terminal_sessions",
sa.Column("id", sa.UUID(), nullable=False),
sa.Column("instance_id", sa.UUID(), nullable=False),
sa.Column("name", sa.String(length=255), nullable=True),
sa.Column("status", sa.String(length=50), nullable=False),
sa.Column("created_at", sa.DateTime(timezone=True), nullable=False),
sa.Column("last_activity_at", sa.DateTime(timezone=True), nullable=True),
sa.Column("closed_at", sa.DateTime(timezone=True), nullable=True),
sa.Column("created_at", sa.DateTime(timezone=True), nullable=False), # TimestampMixin
sa.Column("updated_at", sa.DateTime(timezone=True), nullable=False), # TimestampMixin
sa.ForeignKeyConstraint(["instance_id"], ["tool_instances.id"], ondelete="CASCADE"),
sa.PrimaryKeyConstraint("id"),
)
op.create_index(op.f("ix_terminal_sessions_instance_id"), "terminal_sessions", ["instance_id"], unique=False)
def downgrade() -> None:
op.drop_index(op.f("ix_terminal_sessions_instance_id"), table_name="terminal_sessions")
op.drop_table("terminal_sessions")
```
### 6. REST API Additions
**File**: `apps/api/src/api/terminal.py` (same file as WebSocket endpoint)
All new endpoints follow the existing URL pattern: `/projects/{project_id}/repositories/{repo_id}/instances/{instance_id}/terminal/sessions`.
#### Endpoints
| Method | Path | Description |
|--------|------|-------------|
| `GET` | `.../instances/{instance_id}/terminal/sessions` | List sessions for an instance. Returns metadata from DB + live `has_websockets` flag by querying `TerminalManager`. |
| `POST` | `.../instances/{instance_id}/terminal/sessions` | Create a new session. Optional body: `{ "name": "Custom Name" }`. Returns `{ session_id, name, status, created_at }`. Enforces max 5. |
| `DELETE` | `.../instances/{instance_id}/terminal/sessions/{session_id}` | Close a specific session. Kills PTY, updates DB. Returns `{ status: "closed" }`. |
| `POST` | `.../instances/{instance_id}/terminal/sessions/{session_id}/reset` | Reset a specific session (kill + recreate). Returns `{ session_id, name, status }`. |
| `POST` | `.../instances/{instance_id}/terminal/sessions/{session_id}/rename` | Rename a session. Body: `{ "name": "New Name" }`. Updates DB; name reflected on next session list fetch. |
#### Existing Endpoint Preservation
| Method | Path | Behavior |
|--------|------|----------|
| `POST` | `.../instances/{instance_id}/terminal/reset` | **Preserved as alias.** Resets the default session (same as `POST .../sessions/default/reset`). |
#### Response Schema (List Sessions)
```json
{
"sessions": [
{
"id": "uuid",
"name": "Session 1",
"status": "active",
"has_websockets": true,
"created_at": "2026-05-28T10:00:00Z",
"last_activity_at": "2026-05-28T10:05:00Z"
}
]
}
```
---
## Frontend Design
### 1. Session Tabs Component (`TerminalSessionTabs`)
**File**: `apps/web/src/components/terminal-session-tabs.tsx`
#### Props
```typescript
interface TerminalSessionTabsProps {
sessions: TerminalSessionInfo[];
activeSessionId: string;
onSelect: (sessionId: string) => void;
onClose: (sessionId: string) => void;
onCreate: () => void;
onRename: (sessionId: string, newName: string) => void;
isMobile?: boolean;
}
interface TerminalSessionInfo {
id: string;
name: string;
status: "connecting" | "connected" | "disconnected" | "error" | "resetting";
}
```
#### Desktop Behavior
- Horizontal tab strip positioned **above** the terminal container.
- Each tab shows: session name, status dot (colored), close button (×) visible on hover/active.
- Overflow: horizontal scroll with subtle fade indicator.
- **New session button (+)**: Fixed at the right end of the tab strip. Disabled when 5 sessions exist.
- **Double-click to rename**: Inline `<input>` replaces tab text. `Enter` to confirm, `Escape` to cancel. Blur confirms.
- **Close confirmation**: For sessions with an active process and WebSocket, show a lightweight inline confirm tooltip (not a full modal) to avoid friction.
#### Mobile Behavior
- Tab strip is integrated into the existing auto-hide chrome.
- `MobileTerminalHeader` gains a `sessionTabs` render prop or child area below the title row.
- Tabs are compact (icon + truncated name + ×). Horizontal swipe scrolls.
- New session (+) is the rightmost item.
- The tab strip shares the auto-hide behavior with the header (tapping the terminal toggles visibility).
### 2. Modified `TerminalPage`
**File**: `apps/web/src/pages/terminal.tsx`
#### State Management
```typescript
interface TerminalPageState {
sessions: TerminalSessionInfo[];
activeSessionId: string | null;
isFullscreen: boolean;
isLoading: boolean;
}
```
#### Session Lifecycle
1. **Mount**: `useEffect` calls `GET .../terminal/sessions`. If no sessions exist, auto-creates one via `POST`.
2. **Active session**: Only one tab is visually active. **All `TerminalComponent` instances remain mounted** but inactive ones use CSS `display: none` to preserve xterm.js scrollback and WebSocket connections.
3. **Switch tabs**: Updates `activeSessionId`. The newly active tab's `TerminalComponent` triggers `fitAddon.fit()` via a ref callback after becoming visible (using a `useEffect` on visibility).
#### Render Structure
```tsx
<section className={`terminal-page ${isFullscreen ? "fullscreen" : ""}`}>
{!isFullscreen && (
<div className="terminal-page-header">...</div>
)}
<TerminalSessionTabs
sessions={sessions}
activeSessionId={activeSessionId}
onSelect={setActiveSessionId}
onClose={handleCloseSession}
onCreate={handleCreateSession}
onRename={handleRenameSession}
/>
<div className="terminal-sessions-container">
{sessions.map((s) => (
<div
key={s.id}
className={s.id === activeSessionId ? "active" : "hidden"}
>
<TerminalComponent
instanceId={instanceId}
sessionId={s.id} // NEW PROP
onClose={() => handleCloseSession(s.id)}
isMobile={isMobile}
// ... other props
/>
</div>
))}
</div>
</section>
```
### 3. Modified `TerminalComponent`
**File**: `apps/web/src/components/terminal.tsx`
#### New Props
```typescript
interface TerminalProps {
instanceId: string;
sessionId?: string; // NEW: omitted → uses default session (backward compat)
// ... existing props
}
```
#### WebSocket URL
```typescript
const wsPath = sessionId
? `/ws/tool-instances/${instanceId}/terminal/${sessionId}`
: `/ws/tool-instances/${instanceId}/terminal`;
```
#### Reset Semantics Update
The component's reset button now sends `{"type": "reset"}` to its own session. The `SessionRef` loop in the backend handles resetting that specific session. After reset, the backend sends `{"type": "status", "status": "connected"}` with the new session object, and the frontend clears the terminal.
#### Fullscreen Awareness
When `TerminalPage` enters fullscreen, it passes `isFullscreen` down (via context or prop drilling). `TerminalComponent` adjusts its container height to `100vh` (minus tab strip if visible in fullscreen).
### 4. Mobile Integration
**File**: `apps/web/src/components/mobile-terminal-wrapper.tsx`
#### Changes
- Accepts `sessions`, `activeSessionId`, and tab callbacks as props from `TerminalPage`.
- Renders `TerminalSessionTabs` between `MobileTerminalHeader` and the terminal content area.
- The tab strip auto-hides along with the header (`useAutoHide`).
- `MobileTerminalHeader` title is updated to show `activeSession.name` instead of generic "Terminal".
- Fullscreen on mobile: hides the header, tab strip, and special-keys strip. A tap in the bottom-right corner (or swipe from edge) reveals the tab strip temporarily.
### 5. Fullscreen Mode
**Trigger**: UI button (maximize icon in header) or `Ctrl+Shift+F`.
#### Desktop Fullscreen
- `TerminalPage` adds `.fullscreen` class.
- Header and page chrome are hidden (`display: none`).
- Tab strip remains visible as a minimal overlay (semi-transparent, auto-hides after 3s of inactivity, reappears on mouse move).
- Terminal container fills viewport.
- Exit: `Esc` key or click exit-fullscreen button.
#### Mobile Fullscreen
- Same as desktop but also hides `SpecialKeysStrip` and `SpecialKeysPanel`.
- A small floating handle at the bottom center reveals the tab strip and special keys on tap.
### 6. Keyboard Shortcuts
**Constraint**: Do not override browser defaults. All shortcuts use combinations that are either unassigned or safe in major browsers.
| Shortcut | Action | Browser Conflict? |
|----------|--------|-------------------|
| `Ctrl+Shift+F` | Toggle fullscreen | None major |
| `Alt+Shift+N` | New session | None major |
| `Alt+Shift+W` | Close current session | None major |
| `Alt+Shift+←` / `Alt+Shift+→` | Previous / next session | None major |
| `Alt+Shift+R` | Reset current session | None major |
All actions are also accessible via UI buttons. Shortcuts are registered in `TerminalPage` via a `useEffect` on `keydown` with `event.preventDefault()` only for the specific combos above.
### 7. Session State Management
**File**: `apps/web/src/hooks/use-terminal-sessions.ts` (new hook)
```typescript
export function useTerminalSessions(instanceId: string) {
const [sessions, setSessions] = useState<TerminalSessionInfo[]>([]);
const [activeSessionId, setActiveSessionId] = useState<string | null>(null);
const createSession = useCallback(async (name?: string) => { ... }, [instanceId]);
const closeSession = useCallback(async (sessionId: string) => { ... }, [instanceId]);
const renameSession = useCallback(async (sessionId: string, name: string) => { ... }, [instanceId]);
const resetSession = useCallback(async (sessionId: string) => { ... }, [instanceId]);
// Initial load
useEffect(() => {
loadSessions().then((sess) => {
if (sess.length === 0) {
createSession().then((s) => setActiveSessionId(s.id));
} else {
setSessions(sess);
setActiveSessionId(sess[0].id);
}
});
}, [instanceId]);
return { sessions, activeSessionId, setActiveSessionId, createSession, closeSession, renameSession, resetSession };
}
```
---
## Data Flow
### 1. Create New Session
```
User clicks [+] tab
Frontend: POST /instances/{id}/terminal/sessions { name?: "Session 3" }
Backend:
1. Auth + validate instance running
2. Check session count < 5
3. TerminalManager.create_session()
- Generates UUID session_id
- Starts docker exec PTY
- Inserts TerminalSessionModel row
4. Returns { session_id, name, status, created_at }
Frontend:
1. Append session to sessions[]
2. setActiveSessionId(newId)
3. React renders new <TerminalComponent> with sessionId prop
4. Component opens WS to /terminal/{session_id}
5. Backend attaches WS, replays buffer
```
### 2. Switch Between Sessions
```
User clicks tab "Session 2"
Frontend: setActiveSessionId("session-2-uuid")
React re-renders:
- Session 1 container → className="hidden" (display: none)
- Session 2 container → className="active" (display: block)
Session 2 useEffect (on visibility change):
- Calls fitAddon.fit()
- Sends resize message over its existing WS
(Backend: no operation needed. Both WS connections remain open.)
```
### 3. Close Session
```
User clicks [×] on "Session 2"
Frontend: confirm() or inline tooltip
Frontend: DELETE /instances/{id}/terminal/sessions/{session_id}
Backend:
1. Auth
2. TerminalManager.close_session(instance_id, session_id)
- Kills docker exec process
- Removes from _sessions dict
- Updates DB: status=closed, closed_at=now()
3. Returns { status: "closed" }
Frontend:
1. Remove session from sessions[]
2. Unmount <TerminalComponent> (WS closes with code 1000)
3. If closed session was active, setActiveSessionId to another session (or create one if none left)
```
### 4. Reconnect to Existing Session
```
User reloads page
Frontend: GET /instances/{id}/terminal/sessions
Backend: Returns all DB rows with status != "closed"
Frontend: Populate sessions[]. For each session, render <TerminalComponent>.
Each TerminalComponent opens its WS:
WS URL: /ws/tool-instances/{id}/terminal/{session_id}
Backend:
1. Auth
2. TerminalManager.get_session(instance_id, session_id)
- If found in-memory: attach_websocket, replay buffer
- If not found in-memory (API restarted): WS closes with code 4004 "Session not found"
(Frontend handles by showing "Session expired" with option to reset/recreate.)
```
---
## Contracts
### WebSocket Protocol
#### Connection URLs
| URL | Purpose |
|-----|---------|
| `/ws/tool-instances/{instance_id}/terminal` | Default session (backward compatible). Creates/attaches to the single legacy session. |
| `/ws/tool-instances/{instance_id}/terminal/{session_id}` | Specific session. Attaches to an existing session or fails if not found. |
#### Client → Server Messages
| Type | Payload | Purpose |
|------|---------|---------|
| `resize` | `{ cols: number, rows: number }` | Resize PTY |
| `reset` | `{}` | Kill and restart the **current** session's shell |
| `pong` | `{}` | Heartbeat response |
#### Server → Client Messages
| Type | Payload | Purpose |
|------|---------|---------|
| (binary) | `bytes` | PTY output |
| `status` | `{ status: "connected" \| "resetting" }` | Lifecycle status |
| `ping` | `{}` | Heartbeat |
### REST API Contract
#### `GET /projects/{pid}/repositories/{rid}/instances/{iid}/terminal/sessions`
**Response 200:**
```json
{
"sessions": [
{
"id": "uuid",
"name": "Session 1",
"status": "active",
"has_websockets": true,
"created_at": "2026-05-28T10:00:00Z",
"last_activity_at": "2026-05-28T10:05:00Z"
}
]
}
```
#### `POST /projects/{pid}/repositories/{rid}/instances/{iid}/terminal/sessions`
**Request body:**
```json
{ "name": "Optional Custom Name" }
```
**Response 201:**
```json
{
"id": "uuid",
"name": "Session 2",
"status": "active",
"created_at": "2026-05-28T10:00:00Z"
}
```
**Response 409:** (max sessions reached)
```json
{ "detail": "Maximum of 5 terminal sessions reached for this instance" }
```
#### `DELETE /projects/{pid}/repositories/{rid}/instances/{iid}/terminal/sessions/{sid}`
**Response 200:**
```json
{ "status": "closed", "session_id": "uuid" }
```
#### `POST /projects/{pid}/repositories/{rid}/instances/{iid}/terminal/sessions/{sid}/reset`
**Response 200:**
```json
{
"id": "uuid",
"name": "Session 1",
"status": "active"
}
```
#### `POST /projects/{pid}/repositories/{rid}/instances/{iid}/terminal/sessions/{sid}/rename`
**Request body:**
```json
{ "name": "New Name" }
```
**Response 200:**
```json
{ "id": "uuid", "name": "New Name" }
```
---
## Testing Strategy
### Unit Tests
**Backend**: `apps/api/tests/services/test_terminal_manager.py`
| Test | Scenario |
|------|----------|
| `test_create_session_increases_count` | Creating sessions increments the per-instance count |
| `test_create_session_enforces_max_5` | 6th creation raises `MaxSessionsExceededError` |
| `test_get_sessions_for_instance` | Returns only sessions for the requested instance |
| `test_close_session_removes_from_dict` | `close_session` removes key from `_sessions` |
| `test_attach_websocket_only_closes_same_session` | Attaching to session A does not close websockets on session B |
| `test_default_session_keyed_separately` | Default session uses `"default"` session_id and does not collide with named sessions |
| `test_idle_cleanup_updates_db` | Idle cleanup calls DB update with `status=closed` |
**Frontend**: `apps/web/src/components/terminal-session-tabs.test.tsx`
| Test | Scenario |
|------|----------|
| `test_renders_all_tabs` | Renders one tab per session |
| `test_click_tab_selects_session` | Clicking a tab calls `onSelect` with correct ID |
| `test_close_button_calls_onClose` | Clicking × calls `onClose` |
| `test_double_click_enables_rename` | Double-click shows input; Enter commits |
| `test_plus_disabled_at_max_sessions` | `+` button is disabled when 5 sessions exist |
### Integration Tests
**Backend**: `apps/api/tests/api/test_terminal_ws.py`
| Test | Scenario |
|------|----------|
| `test_specific_session_websocket` | Connect to `/terminal/{session_id}`, verify output |
| `test_default_session_alias` | Connect to `/terminal`, verify it creates/uses default session |
| `test_concurrent_sessions_isolated` | Two WS connections to different session_ids receive independent output |
| `test_reset_control_message_scoped` | `{"type":"reset"}` only resets the current session |
| `test_list_sessions_returns_live_and_db` | `GET /sessions` reflects both in-memory state and DB rows |
**Frontend**: `apps/web/src/pages/terminal.test.tsx` (or E2E)
| Test | Scenario |
|------|----------|
| `test_create_session_adds_tab` | Clicking + creates a new tab and switches to it |
| `test_switch_tab_preserves_scrollback` | Switching back to a previous tab shows prior output |
| `test_close_last_session_creates_default` | Closing the final session auto-creates a new default session |
| `test_fullscreen_toggle` | `Ctrl+Shift+F` toggles fullscreen class |
---
## Rollout Plan
### Phase 1: Database (Zero-Downtime)
1. Run Alembic migration to create `terminal_sessions` table.
2. No code reads from or writes to this table yet. Existing sessions remain purely in-memory.
3. **Rollback**: Alembic downgrade removes table (no data loss risk since table is empty).
### Phase 2: Backend API (Backward Compatible)
1. Deploy updated `TerminalManager` with composite key `_sessions`.
2. Deploy updated `TerminalSession` with `name` support.
3. Deploy new WebSocket route `/terminal/{session_id}` and preserve `/terminal` alias.
4. Deploy new REST endpoints (`GET/POST/DELETE .../sessions`).
5. Update DB writes on session lifecycle (create, close, activity update).
6. **Rollback**: Revert code. Old `/terminal` endpoint continues to work. New `/terminal/{session_id}` returns 404, but no clients call it yet.
### Phase 3: Frontend (Feature Flag Optional)
1. Deploy new components (`TerminalSessionTabs`, `useTerminalSessions`).
2. Update `TerminalPage` and `MobileTerminalWrapper`.
3. Update `TerminalComponent` to accept optional `sessionId` prop.
4. If a feature flag is used, enable multi-session UI for beta users first.
5. **Rollback**: Revert frontend. Users see the old single-session UI. Backend `/terminal` alias continues to serve them.
### Phase 4: Deprecation & Cleanup (Follow-Up Task)
1. Monitor usage of the legacy `/terminal` WebSocket endpoint and `POST .../terminal/reset` REST endpoint.
2. After 2-4 weeks of stable multi-session usage:
- Mark legacy endpoints as deprecated in OpenAPI docs.
- Update frontend to always use `/terminal/{session_id}` (never rely on default alias).
3. In a future release, remove the default alias if desired (not required for correctness).
### Backward Compatibility Strategy
| Layer | Compat Mechanism |
|-------|-----------------|
| WebSocket | `/terminal` remains default-session alias forever (or until explicit deprecation). Old clients continue to work. |
| REST API | Existing `POST .../terminal/reset` preserved as alias. No breaking changes to response shape. |
| Frontend | `sessionId` prop on `TerminalComponent` is optional. Omitting it uses the default session path. |
| DB | New table is additive only. No changes to `tool_instances` schema. |
---
## Files to Create / Modify
### New Files
| File | Description |
|------|-------------|
| `apps/api/src/models/terminal_session.py` | SQLAlchemy `TerminalSessionModel` |
| `apps/api/src/alembic/versions/XXXX_add_terminal_sessions_table.py` | Alembic migration |
| `apps/web/src/components/terminal-session-tabs.tsx` | Tab bar UI (desktop + mobile) |
| `apps/web/src/hooks/use-terminal-sessions.ts` | Session CRUD + state hook |
| `apps/web/src/components/terminal-session-tabs.test.tsx` | Unit tests |
| `apps/api/tests/services/test_terminal_manager_multi.py` | TerminalManager multi-session tests |
| `apps/api/tests/api/test_terminal_ws_multi.py` | WS integration tests |
### Modified Files
| File | Changes |
|------|---------|
| `apps/api/src/services/terminal_manager.py` | Composite key dict, new CRUD methods, max session limit, DB integration |
| `apps/api/src/services/terminal_session.py` | Add `name` field, status tracking |
| `apps/api/src/api/terminal.py` | New WS route, REST endpoints, shared handler coroutine |
| `apps/api/src/main.py` | Import new model (if needed for Alembic autogenerate) |
| `apps/web/src/components/terminal.tsx` | Accept `sessionId` prop, use it in WS URL |
| `apps/web/src/pages/terminal.tsx` | Multi-session orchestration, tabs, fullscreen |
| `apps/web/src/components/mobile-terminal-wrapper.tsx` | Integrate tabs, pass session state |
| `apps/web/src/components/mobile-terminal-header.tsx` | Show active session name |
| `apps/web/src/api/sessions.ts` (or new `terminal.ts`) | REST client functions for session CRUD |
---
## Risks & Mitigations
| Risk | Likelihood | Impact | Mitigation |
|------|------------|--------|------------|
| Resource exhaustion from 5× docker exec per instance | Medium | High | Max 5 enforced. Idle timeout (30 min) still applies per session. |
| Mobile UX degraded by tab bar + special keys strip | Medium | Medium | Auto-hide shared between tabs and header. Minimal tab design. |
| Concurrent WS policy closes wrong session's sockets | Medium | High | Unit test explicitly: attach to session A must not affect session B's websockets. |
| DB writes on hot path (activity tracking) | Low | Medium | `last_activity_at` updates are non-blocking fire-and-forget asyncio tasks. No await on commit. |
| Frontend performance with 5 mounted xterm.js instances | Low | Medium | Max 5 sessions. Inactive terminals are `display: none` (not unmounted). GPU acceleration in xterm.js handles this well. |
| Default session alias ambiguity | Low | Low | Document that `/terminal` maps to `"default"` session. Future deprecation can migrate default to explicit ID. |
@@ -0,0 +1,256 @@
# SDD Explore: Multi-Session Terminal UX
## Executive Summary
The codebase has a well-built persistent terminal foundation from the `persistent-terminal-sessions` change. `TerminalManager` currently tracks exactly one `TerminalSession` per `instance_id` in an in-memory dict. `TerminalSession` already supports WebSocket attach/detach, circular output buffer replay, idle timeout, and process lifecycle management.
Implementing multi-session terminal support is a **moderate-complexity, medium-risk** change. The core backend refactor is straightforward: change the session tracking key from `instance_id` to `(instance_id, session_id)` and update the WebSocket endpoint to accept a `session_id`. The frontend work is more involved: designing a tabbed session UI that works on both desktop and mobile, handling session creation/switching/closing, and integrating with the existing `MobileTerminalWrapper`.
No database schema change is **strictly required** for an MVP—sessions can remain purely in-memory with the same idle-timeout cleanup. However, adding a `terminal_sessions` table would provide cross-API-restart persistence, session auditability, and a foundation for future features like session history or named sessions.
## Current Architecture (as explored)
### Backend
- **`TerminalManager`** (`apps/api/src/services/terminal_manager.py`):
- `self._sessions: dict[str, TerminalSession]` keyed by `instance_id` string.
- `get_or_create_session(instance_id, container_id, startup_command)` — returns the single existing session or creates a new one.
- `attach_websocket(session, websocket)` — detaches any *existing* WebSocket connections on that session (closes them with code 4000) before attaching the new one. This enforces single-active-client per session.
- `reset_session(instance_id, container_id, ...)` — kills the existing session and creates a new one.
- Idle check loop every 60s; sessions with no WebSockets attached for 30 minutes are cleaned up.
- **`TerminalSession`** (`apps/api/src/services/terminal_session.py`):
- Already has a `session_id: str` field (UUID) but it is not used as a lookup key.
- Manages one `docker exec` PTY process per session.
- Circular buffer (10KB) for output replay.
- Tracks `self._websockets: set[Any]` for attached connections.
- **`api/terminal.py`** (`apps/api/src/api/terminal.py`):
- WebSocket endpoint: `/ws/tool-instances/{instance_id}/terminal`
- Authenticates user, verifies instance ownership/running state, then calls `terminal_manager.get_or_create_session()`.
- Supports JSON control messages: `resize`, `reset`.
- POST endpoint: `/projects/{project_id}/repositories/{repo_id}/instances/{instance_id}/terminal/reset` — resets the single session.
- **Database**:
- No `terminal_sessions` table exists. Terminal sessions are purely in-memory.
- `ToolInstance` model (`apps/api/src/models/tool_instance.py`) has no terminal-related fields.
### Frontend
- **`TerminalComponent`** (`apps/web/src/components/terminal.tsx`):
- Single xterm.js terminal per component.
- One WebSocket connection to `/ws/tool-instances/{instance_id}/terminal`.
- Handles reconnect with exponential backoff (max 3 attempts).
- Font size persisted globally in `localStorage` under key `terminal-font-size`.
- Copy/paste buttons on mobile only.
- Status indicator: connecting, connected, disconnected, error, resetting.
- **`TerminalPage`** (`apps/web/src/pages/terminal.tsx`):
- Desktop: renders one `TerminalComponent` inside a page shell.
- Mobile: renders `MobileTerminalWrapper` which composes `MobileTerminalHeader`, `TerminalComponent`, `SpecialKeysStrip`, and `SpecialKeysPanel`.
- **`MobileTerminalWrapper`** (`apps/web/src/components/mobile-terminal-wrapper.tsx`):
- Already handles auto-hide header, virtual keyboard height, special keys, and mobile viewport detection.
- Manages terminal ref callbacks (`sendData`, `connectionStatus`, `focusInput`, `changeFontSize`).
### Prior Art
- **`persistent-terminal-sessions`** (fully implemented):
- Sessions survive WebSocket disconnections.
- Buffer replay on reconnect.
- Idle timeout cleanup.
- Reset functionality.
- **`mobile-terminal-ux`** (mostly implemented):
- Mobile fullscreen terminal with collapsible chrome.
- Special keys toolbar.
- Dynamic viewport handling for virtual keyboard.
## Architecture Options for Multi-Session
### Option A: In-Memory Multi-Session (MVP)
- Change `TerminalManager._sessions` to `dict[tuple[str, str], TerminalSession]` keyed by `(instance_id, session_id)`.
- Add `create_session(instance_id, container_id, ...)` that always creates a new session.
- Keep `get_or_create_session()` for backward compatibility (returns the "default" or only session).
- Add `get_sessions_for_instance(instance_id) -> list[TerminalSession]`.
- Add `close_session(instance_id, session_id)` to kill a specific session.
- **Tradeoffs**: Simplest, no DB migration, survives existing patterns. Loses sessions on API restart.
### Option B: Database-Backed Session Metadata
- Create `terminal_sessions` table:
```sql
id UUID PRIMARY KEY,
instance_id UUID FK(tool_instances.id, ondelete=CASCADE),
session_name VARCHAR(255),
status VARCHAR(50), -- active, idle, closed
created_at TIMESTAMPTZ,
last_activity_at TIMESTAMPTZ,
closed_at TIMESTAMPTZ
```
- `TerminalManager` still keeps `TerminalSession` objects in memory, but creates/updates DB rows on lifecycle events.
- **Tradeoffs**: Enables cross-restart persistence, session history, named sessions, and auditability. Adds migration and async DB overhead to hot paths.
### Option C: Hybrid (Recommended)
- In-memory active sessions for performance.
- DB table for metadata, created on session start, updated on activity/close.
- On API restart, sessions are gone (no process resurrection), but metadata remains for history.
- **Tradeoffs**: Best of both worlds. Slightly more complex than Option A but much simpler than full persistence.
### Decision Matrix
| Criterion | Option A | Option B | Option C |
|-----------|----------|----------|----------|
| Implementation complexity | Low | Medium | Medium |
| DB migration required | No | Yes | Yes |
| Cross-restart persistence | No | Yes (full) | Metadata only |
| Resource auditability | No | Yes | Yes |
| Performance | Best | Good (cacheable) | Best |
| Recommended for MVP | **Yes** | No | **Preferred** |
## WebSocket Protocol Options
### Option 1: URL Path Segment (Recommended)
```
/ws/tool-instances/{instance_id}/terminal/{session_id}
```
- Clean, RESTful, easy to route in FastAPI.
- Default session can use a reserved ID like `default` or keep `/terminal` as an alias.
- **Tradeoff**: Breaks existing hardcoded URLs; needs backward-compatibility route.
### Option 2: Query Parameter
```
/ws/tool-instances/{instance_id}/terminal?session_id=...
```
- Easier to add without changing route structure.
- Less idiomatic for WebSocket APIs.
- **Tradeoff**: Query params in WebSocket URLs can be inconsistently supported by proxies.
### Option 3: First-Message JSON Payload
- Client connects to `/terminal`, then sends `{"type": "attach", "session_id": "..."}`.
- Server must hold the connection in limbo until the attach message arrives.
- **Tradeoff**: More complex state machine; harder to reject invalid sessions early.
**Recommendation**: Option 1 with a backward-compatible fallback:
- `/ws/tool-instances/{instance_id}/terminal` → attaches to the "default" session (existing behavior).
- `/ws/tool-instances/{instance_id}/terminal/{session_id}` → attaches to the specified session.
## Frontend UX Design Options
### Session Presentation: Tabs vs Panes
| Feature | Tabs | Panes (Split) |
|---------|------|---------------|
| Desktop UX | Good | Excellent (tmux-like) |
| Mobile UX | Good | Poor (too cramped) |
| Implementation | Medium | High |
| Accessibility | Good | Complex |
| Recommendation | **Preferred** | Future enhancement |
**Decision**: Start with tabs. A split-pane layout can be added later as an advanced feature without breaking the tab model.
### Tab Bar Design
- Position: Above the terminal container on desktop; integrated into `MobileTerminalHeader` on mobile.
- Contents:
- Session name (auto-named "Session 1", "Session 2", or custom).
- Status dot (connecting, connected, error).
- Close button (×) on hover/active.
- New tab button (+).
- Overflow: Horizontal scroll on mobile; wrap or scroll on desktop.
### Fullscreen Mode
- **Behavior**: Toggle hides all page chrome (header, sidebar, tab bar can optionally be shown as a minimal overlay).
- **Trigger**: `Ctrl+Shift+F` or UI button.
- **Mobile**: Should integrate with existing mobile fullscreen behavior (already hides AppShell). Fullscreen on mobile could mean hiding the special-keys strip too, with a gesture to reveal.
- **Exit**: `Esc` or UI button.
### Keyboard Shortcuts
| Shortcut | Action | Notes |
|----------|--------|-------|
| `Ctrl+Shift+N` | New session | May conflict with browser "New window" on some platforms. Consider `Ctrl+Shift+T` if not used for "Reopen tab". |
| `Ctrl+Shift+W` | Close current session | Conflicts with browser "Close window". May need `Ctrl+Shift+D` or accept override with `preventDefault()`. |
| `Ctrl+Shift+F` | Toggle fullscreen | Safe, no major browser conflict. |
| `Ctrl+Shift+T` | Toggle tab bar visibility | Conflicts with "Reopen closed tab" in browsers. Consider `Ctrl+Shift+B` or `Ctrl+Shift+~`. |
**Recommendation**: Use `preventDefault()` aggressively and show a shortcuts help modal (e.g., `Ctrl+Shift+/` or `?`).
### Session Naming
- **Auto-name**: "Session 1", "Session 2", etc. based on creation order.
- **Custom name**: Editable by double-clicking the tab. Persisted in DB if Option B/C, or in-memory only for Option A.
- **Default session**: The first session created for an instance can be unnamed or named "Default".
### Reset/Kill Semantics
Current behavior: "Reset Terminal" kills the single session and starts fresh.
With multi-session:
- **Close Session** (× on tab): Kills the `docker exec` process and removes the session.
- **New Session** (+ on tab bar): Creates a new session and switches to it.
- **Reset Session** (in menu): Same as current reset but scoped to the active session.
- **Reset All** (optional, in menu): Kill all sessions for the instance and recreate a default one.
### Font Size Persistence
- Currently global (`localStorage` key `terminal-font-size`).
- With multi-session, users may want different font sizes per session (e.g., larger for presentations, smaller for logs).
- **Options**:
1. Keep global (simplest, no change).
2. Per-session font size (stored in session state or DB).
3. Per-instance font size.
- **Recommendation**: Keep global for MVP. Per-session font size is a nice-to-have that adds complexity.
### Status Per Session
- Each tab shows a status dot.
- Possible statuses: `connecting` (pulsing), `connected` (green), `disconnected` (yellow), `error` (red), `closed` (gray).
- The terminal component already tracks these statuses; they just need to be surfaced at the tab level.
## Database Schema Recommendation (Option C)
```python
class TerminalSessionModel(UUIDPrimaryKeyMixin, TimestampMixin, Base):
__tablename__ = "terminal_sessions"
instance_id: Mapped[uuid.UUID] = mapped_column(
UUID(), ForeignKey("tool_instances.id", ondelete="CASCADE"), nullable=False
)
name: Mapped[str | None] = mapped_column(String(255), nullable=True)
status: Mapped[str] = mapped_column(
String(50), nullable=False, default="active"
)
# Not storing process PID here — that's runtime-only in TerminalManager
created_at: Mapped[datetime] = mapped_column(
DateTime(timezone=True), nullable=False, default=datetime.utcnow
)
last_activity_at: Mapped[datetime | None] = mapped_column(
DateTime(timezone=True), nullable=True
)
closed_at: Mapped[datetime | None] = mapped_column(
DateTime(timezone=True), nullable=True
)
```
**Migration**: New alembic revision adding `terminal_sessions` table.
## Open Questions Needing User/Product Decisions
1. **Max sessions per instance?** Suggest 5 for MVP to prevent resource exhaustion.
2. **Should we persist sessions across API restarts?** Option A = no; Option C = metadata only. Product call.
3. **Tab vs Pane UI?** Strongly recommend tabs for MVP. Panes as future work.
4. **Keyboard shortcuts — override browser defaults?** `Ctrl+Shift+W` closes browser window. We can `preventDefault()` but should warn users.
5. **Should the existing `/terminal` endpoint remain as a default-session alias?** Yes for backward compatibility, but confirm.
6. **Session idle timeout per session or global per instance?** Currently per session. Keep per session.
7. **Should font size be global, per-instance, or per-session?** Recommend global for MVP.
8. **Copy/paste on desktop — any gaps?** Current desktop relies on native xterm.js copy/paste (`Ctrl+C`/`Ctrl+V` with selection). This is standard and sufficient. Mobile already has buttons.
## Risks and Feasibility Assessment
| Risk | Likelihood | Impact | Mitigation |
|------|------------|--------|------------|
| Resource exhaustion from too many docker exec processes | Medium | High | Enforce max sessions per instance (5). Idle timeout already exists. |
| Mobile UX degradation from tab bar clutter | Medium | Medium | Integrate tabs into existing `MobileTerminalHeader` auto-hide. Limit visible tabs, overflow scroll. |
| Backward compat breakage from URL change | Low | Medium | Keep `/terminal` as default-session alias. |
| Concurrent WebSocket policy bugs | Medium | High | Ensure "close existing" only applies within same `(instance_id, session_id)`, not across sessions. |
| Scope creep (panes, detachable windows) | High | Medium | Explicitly exclude split panes and detachable windows from MVP. |
## Feasibility: Green/Yellow/Red
**Yellow-Green**. The backend changes are well-scoped and build on solid existing infrastructure. The frontend tab UI is the largest unknown, especially mobile integration, but the existing `MobileTerminalWrapper` provides a good foundation. No external dependencies needed.
## Recommended Next Step
**Proceed to `design` phase** after resolving these scoping decisions:
1. Choose Option A or C for session storage (recommend Option C).
2. Confirm max sessions limit (recommend 5).
3. Confirm tab-only UI for MVP (no panes).
4. Confirm backward-compatible WebSocket URL strategy.
Then write `design.md` with concrete decisions and `tasks.md` with implementation steps.
@@ -0,0 +1,33 @@
## Why
Currently, each tool instance (e.g., pi-agent, code-server) supports exactly one terminal session. Users who want to run multiple concurrent tasks (e.g., a long-running build in one pane, an editor in another, and a shell for quick commands) must open multiple tool instances or use tmux/screen inside a single session. This is inefficient and confusing.
Additionally, the web terminal lacks basic usability features found in modern terminal emulators: fullscreen mode, detachable panes, session tabs, and keyboard shortcuts for common actions.
## What Changes
- **Backend**: Allow multiple `TerminalSession` objects per `ToolInstance`, each with a unique `session_id`
- **Backend**: Update `TerminalManager` to track and route multiple sessions per instance
- **Backend**: Update terminal WebSocket protocol to include `session_id` in connection URL or message
- **Frontend**: Add session tabs/management UI (create new session, switch between sessions, close sessions)
- **Frontend**: Add fullscreen mode for the terminal
- **Frontend**: Add keyboard shortcuts for session management (Ctrl+Shift+N new session, etc.)
- **Frontend**: Session list panel showing active sessions per instance
## Capabilities
### New Capabilities
- `multi-session-terminal`: Multiple independent terminal sessions per tool instance
- `terminal-fullscreen`: Fullscreen terminal mode
- `terminal-session-management`: Create, switch, rename, and close terminal sessions
### Modified Capabilities
- `tool-terminal`: Extend WebSocket protocol and UI to support multiple sessions per instance
- `terminal-session-lifecycle`: Session creation, naming, and cleanup for multi-session model
## Impact
- Backend: `TerminalManager`, `TerminalSession`, `api/terminal.py`, database schema (session tracking)
- Frontend: `TerminalComponent`, `terminal.tsx`, new `TerminalSessionTabs`, `TerminalSessionManager`
- Protocol: WebSocket message format changes (add session_id field)
- Database: New or extended table to track terminal sessions per instance
@@ -0,0 +1,404 @@
# SDD Tasks: Multi-Session Terminal UX
## Review Workload Forecast
| Field | Value |
|-------|-------|
| Estimated changed lines | ~1,4001,600 (new ~900, modified ~600700) |
| 400-line budget risk | High |
| Chained PRs recommended | Yes |
| Suggested split | PR 1: DB + Backend Core → PR 2: Backend API + Tests → PR 3: Frontend + Tests |
| Delivery strategy | auto-chain |
| Chain strategy | stacked-to-main |
```text
Decision needed before apply: Yes
Chained PRs recommended: Yes
Chain strategy: stacked-to-main
400-line budget risk: High
```
---
## Task Overview
| # | Task | PR | Est. Lines | Dependencies |
|---|------|-----|------------|--------------|
| 1 | Database schema and Alembic migration | 1 | ~80 | None |
| 2 | TerminalManager multi-session core | 1 | ~250 | Task 1 |
| 3 | TerminalSession name and status fields | 1 | ~40 | Task 2 |
| 4 | WebSocket routing and backward-compat alias | 2 | ~200 | Task 2 |
| 5 | REST endpoints for session CRUD | 2 | ~180 | Task 2, 4 |
| 6 | Frontend API client and `useTerminalSessions` hook | 3 | ~180 | Task 5 |
| 7 | `TerminalComponent` `sessionId` support | 3 | ~100 | Task 4, 6 |
| 8 | `TerminalSessionTabs` UI component | 3 | ~220 | Task 6 |
| 9 | `TerminalPage` multi-session orchestration and fullscreen | 3 | ~200 | Task 7, 8 |
| 10 | Mobile terminal integration | 3 | ~100 | Task 8, 9 |
| 11 | Backend integration tests | 2 | ~250 | Task 4, 5 |
| 12 | Frontend component tests | 3 | ~150 | Task 8, 9, 10 |
---
## PR 1: Database + Backend Core
### Task 1: Database Schema and Alembic Migration
**Scope**: Create the `terminal_sessions` metadata table and corresponding Alembic migration.
**Files to create**:
- `apps/api/src/models/terminal_session.py`
- `apps/api/alembic/versions/XXXX_add_terminal_sessions_table.py`
**Files to modify**:
- `apps/api/src/main.py` — import new model so Alembic autogenerate discovers it
**Acceptance Criteria**:
- `TerminalSessionModel` extends `Base`, `UUIDPrimaryKeyMixin`, `TimestampMixin`
- Columns: `instance_id` (UUID, FK `tool_instances.id` ON DELETE CASCADE, indexed), `name` (String 255, nullable), `status` (String 50, default `"active"`), `created_at` (DateTime TZ, non-nullable), `last_activity_at` (DateTime TZ, nullable), `closed_at` (DateTime TZ, nullable)
- Migration is reversible (`downgrade` drops table + index)
- `make migrate` applies successfully in local dev
**Testing (TDD)**:
- RED: Write a migration metadata test asserting the new table exists in `Base.metadata` and has expected columns
- GREEN: Create model and migration
- Run `pytest tests/integration/test_models.py` or equivalent to verify table registration
---
### Task 2: TerminalManager Multi-Session Core
**Scope**: Refactor `TerminalManager` to support up to 5 concurrent sessions per instance using composite keys.
**Files to modify**:
- `apps/api/src/services/terminal_manager.py`
**Acceptance Criteria**:
- `self._sessions` keyed by `(instance_id: str, session_id: str)`
- `create_session(instance_id, container_id, startup_command=None, name=None)`:
- Generates UUID `session_id`
- Enforces max 5 active sessions per instance (raise `MaxSessionsExceededError` / HTTP 409)
- Inserts `TerminalSessionModel` DB row (fire-and-forget async task acceptable)
- Returns `TerminalSession`
- `get_or_create_session(instance_id, container_id, ...)` preserved for backward compatibility; uses `"default"` session_id
- `get_session(instance_id, session_id)` returns session or `None`
- `get_sessions_for_instance(instance_id)` returns list of in-memory sessions
- `close_session(instance_id, session_id)`: kills PTY, removes from `_sessions`, updates DB `status=closed`, `closed_at=now()`
- `reset_session(instance_id, container_id, session_id=None)`: if `session_id` omitted, resets `"default"` session
- `attach_websocket` only closes existing WebSockets **within the same `(instance_id, session_id)`**
- `_cleanup_idle_sessions` uses composite keys and updates DB status on cleanup
- Idle timeout (30 min) and buffer replay behavior preserved
**Testing (TDD)**:
- RED: Create `apps/api/tests/services/test_terminal_manager_multi.py` with tests:
- `test_create_session_increases_count`
- `test_create_session_enforces_max_5`
- `test_get_sessions_for_instance_filters_by_instance`
- `test_close_session_removes_from_dict_and_updates_db`
- `test_attach_websocket_only_closes_same_session`
- `test_default_session_keyed_separately`
- `test_idle_cleanup_updates_db_status`
- GREEN: Implement `TerminalManager` changes
- Run `make test-unit`
---
### Task 3: TerminalSession Name and Status Fields
**Scope**: Add runtime `name` and `status` tracking to `TerminalSession`.
**Files to modify**:
- `apps/api/src/services/terminal_session.py`
**Acceptance Criteria**:
- `__init__` accepts optional `name`; auto-generates `"Session N"` if omitted (N = per-instance counter)
- `self.name` stored as runtime attribute
- `self.status` enum-like string: `"active"`, `"resetting"`, `"closed"`
- `reset()` sets `status="resetting"` during transition, `"active"` after restart
- `close()` sets `status="closed"`
- No breaking changes to existing `TerminalSession` behavior
**Testing (TDD)**:
- RED: Extend `test_terminal_manager_multi.py` or add `test_terminal_session_name_and_status.py` covering auto-naming, status transitions, and reset/close side effects
- GREEN: Implement fields and transitions
- Run `make test-unit`
---
## PR 2: Backend API + Tests
### Task 4: WebSocket Routing and Backward-Compat Alias
**Scope**: Add session-scoped WebSocket route, extract shared handler, preserve legacy alias.
**Files to modify**:
- `apps/api/src/api/terminal.py`
**Files to create**:
- `apps/api/tests/api/test_terminal_ws_multi.py`
**Acceptance Criteria**:
- New route: `@router.websocket("/ws/tool-instances/{instance_id}/terminal/{session_id}")`
- Existing route `@router.websocket("/ws/tool-instances/{instance_id}/terminal")` preserved; calls `get_or_create_session(...)` for `"default"` session
- Extract `async def _handle_terminal_websocket(websocket, instance_id, session_id, db_session)` containing shared auth/validation/I/O loop logic
- Both routes call `_handle_terminal_websocket`
- Auth/validation logic unchanged (cookie-based, ownership check, running status)
- `reset` control message scoped to the current session only (via `SessionRef` update)
- On unknown `session_id`, close WS with code `4004` "Session not found"
**Testing (TDD)**:
- RED: Write `test_terminal_ws_multi.py`:
- `test_specific_session_websocket_connects`
- `test_default_session_alias_creates_default`
- `test_concurrent_sessions_isolated_output`
- `test_reset_control_message_scoped_to_session`
- `test_unknown_session_id_returns_4004`
- GREEN: Implement routes and shared handler
- Run `pytest tests/api/test_terminal_ws_multi.py`
---
### Task 5: REST Endpoints for Session CRUD
**Scope**: Add REST endpoints for listing, creating, closing, resetting, and renaming sessions.
**Files to modify**:
- `apps/api/src/api/terminal.py`
**Acceptance Criteria**:
- `GET /projects/{pid}/repositories/{rid}/instances/{iid}/terminal/sessions`
- Returns `{ sessions: [...] }` with `id`, `name`, `status`, `has_websockets`, `created_at`, `last_activity_at`
- `has_websockets` queried live from `TerminalManager`
- `POST .../terminal/sessions` — body `{ name?: string }`
- Returns `201` with `{ id, name, status, created_at }`
- Returns `409` if max 5 reached
- `DELETE .../terminal/sessions/{sid}` — returns `{ status: "closed", session_id }`
- `POST .../terminal/sessions/{sid}/reset` — returns `{ id, name, status }`
- `POST .../terminal/sessions/{sid}/rename` — body `{ name: string }`, returns `{ id, name }`
- Existing `POST .../terminal/reset` preserved as alias for default session reset
- All endpoints validate auth, ownership, and running instance status
**Testing (TDD)**:
- RED: Add integration tests in `test_terminal_ws_multi.py` or new `test_terminal_rest.py`:
- `test_list_sessions_returns_db_and_live_state`
- `test_create_session_201`
- `test_create_session_409_at_max`
- `test_close_session_200`
- `test_reset_session_200`
- `test_rename_session_200`
- `test_legacy_reset_alias_still_works`
- GREEN: Implement endpoints
- Run `make test-integration`
---
### Task 6: Frontend API Client and `useTerminalSessions` Hook
**Scope**: Add frontend REST client functions and the central session state hook.
**Files to create**:
- `apps/web/src/api/terminal.ts` (new file for terminal-specific API calls)
- `apps/web/src/hooks/use-terminal-sessions.ts`
**Files to modify**:
- `apps/web/src/api/sessions.ts` — optional, or keep terminal API separate
**Acceptance Criteria**:
- API functions: `listTerminalSessions`, `createTerminalSession`, `closeTerminalSession`, `resetTerminalSession`, `renameTerminalSession`
- `useTerminalSessions(instanceId: string)` hook:
- Loads sessions on mount; auto-creates one if list is empty
- Exposes `sessions`, `activeSessionId`, `setActiveSessionId`
- Exposes `createSession`, `closeSession`, `renameSession`, `resetSession` with optimistic UI updates
- Handles 409 errors (max sessions) gracefully
- Refetches after reset/rename to stay in sync
**Testing (TDD)**:
- RED: Write hook unit tests mocking API client:
- `test_loads_sessions_on_mount`
- `test_auto_creates_session_if_empty`
- `test_close_session_removes_from_state`
- `test_create_session_enforces_max_5_error`
- GREEN: Implement hook and API client
- Run `cd apps/web && npm test`
---
## PR 3: Frontend + Tests
### Task 7: `TerminalComponent` `sessionId` Support
**Scope**: Update `TerminalComponent` to accept an optional `sessionId` and route WS accordingly.
**Files to modify**:
- `apps/web/src/components/terminal.tsx`
**Acceptance Criteria**:
- New optional prop `sessionId?: string`
- WS URL constructed as:
- `/ws/tool-instances/{instanceId}/terminal/{sessionId}` if `sessionId` provided
- `/ws/tool-instances/{instanceId}/terminal` if omitted (backward compat)
- Reset button sends `{"type": "reset"}` to the correct session's WS
- Component still supports all existing props and mobile behavior
- `onTerminalReady` callback still works; parent can differentiate sessions by key
**Testing (TDD)**:
- RED: Add/update `terminal.test.tsx` (or similar) to assert WS URL includes `sessionId` when provided
- GREEN: Implement prop and URL logic
- Run `cd apps/web && npm test`
---
### Task 8: `TerminalSessionTabs` UI Component
**Scope**: Build the tab bar for desktop and mobile.
**Files to create**:
- `apps/web/src/components/terminal-session-tabs.tsx`
- `apps/web/src/components/terminal-session-tabs.test.tsx`
**Acceptance Criteria**:
- Props interface: `sessions`, `activeSessionId`, `onSelect`, `onClose`, `onCreate`, `onRename`, `isMobile?`
- Desktop: horizontal tab strip above terminal, overflow scroll with fade indicator
- Mobile: compact tabs integrated into auto-hide chrome, horizontal swipe scroll
- Each tab shows: name, status dot (connecting/connected/disconnected/error), close button (×) on hover/active
- Double-click to rename: inline `<input>`, `Enter` to confirm, `Escape` to cancel, blur confirms
- New session button (+) at right end; disabled when 5 sessions exist
- Close confirmation: lightweight inline confirm tooltip (not modal)
- Accessible: `role="tablist"`, `role="tab"`, keyboard navigation
**Testing (TDD)**:
- RED: Write `terminal-session-tabs.test.tsx`:
- `test_renders_all_tabs`
- `test_click_tab_calls_onSelect`
- `test_close_button_calls_onClose`
- `test_double_click_enables_rename`
- `test_plus_disabled_at_max_sessions`
- `test_status_dot_reflects_connection_state`
- GREEN: Implement component
- Run `cd apps/web && npm test`
---
### Task 9: `TerminalPage` Multi-Session Orchestration, Fullscreen, and Shortcuts
**Scope**: Rewrite `TerminalPage` to manage multiple mounted terminals, fullscreen mode, and keyboard shortcuts.
**Files to modify**:
- `apps/web/src/pages/terminal.tsx`
**Acceptance Criteria**:
- Uses `useTerminalSessions` hook
- Renders `<TerminalSessionTabs />` above terminal area
- Renders one `<TerminalComponent />` per session; inactive sessions hidden via `display: none` (preserves scrollback and WS)
- On tab switch, active terminal calls `fitAddon.fit()` via ref + `useEffect` on visibility
- Fullscreen toggle:
- `Ctrl+Shift+F` toggles `.fullscreen` class
- Desktop: hides page header; tab strip becomes minimal overlay (auto-hides after 3s, reappears on mouse move)
- Mobile: hides header, tab strip, special keys; floating handle reveals chrome
- Exit via `Esc` or UI button
- Keyboard shortcuts (registered in `useEffect` on `keydown`):
- `Alt+Shift+N` — new session
- `Alt+Shift+W` — close current session
- `Alt+Shift+←` / `Alt+Shift+→` — prev/next session
- `Alt+Shift+R` — reset current session
- All use `preventDefault()` only for the exact combo; no browser overrides
- Closing last session auto-creates a new default session
**Testing (TDD)**:
- RED: Add `terminal-page.test.tsx`:
- `test_creates_default_session_on_empty_load`
- `test_switching_tabs_hides_inactive_terminals`
- `test_fullscreen_toggle_adds_class`
- `test_keyboard_shortcut_creates_session`
- `test_close_last_session_auto_creates_default`
- GREEN: Implement page orchestration
- Run `cd apps/web && npm test`
---
### Task 10: Mobile Terminal Integration
**Scope**: Integrate session tabs into mobile terminal wrapper and update header.
**Files to modify**:
- `apps/web/src/components/mobile-terminal-wrapper.tsx`
- `apps/web/src/components/mobile-terminal-header.tsx`
**Acceptance Criteria**:
- `MobileTerminalWrapper` accepts session-related props from `TerminalPage` and passes them to `TerminalSessionTabs`
- `MobileTerminalHeader` displays `activeSession.name` instead of generic `"Terminal"`
- Tab strip shares `useAutoHide` behavior with header (tapping terminal toggles visibility)
- Special keys strip remains functional; no z-index conflicts with tabs
- Fullscreen on mobile correctly hides/shows all chrome layers
**Testing (TDD)**:
- RED: Add/update mobile wrapper tests:
- `test_renders_session_tabs`
- `test_header_shows_session_name`
- `test_auto_hide_applies_to_tabs`
- GREEN: Implement mobile integration
- Run `cd apps/web && npm test`
---
### Task 11: Backend Integration Tests
**Scope**: Complete backend test coverage for multi-session WebSocket and REST behavior.
**Files to create / modify**:
- `apps/api/tests/services/test_terminal_manager_multi.py` (finalize)
- `apps/api/tests/api/test_terminal_ws_multi.py` (finalize)
**Acceptance Criteria**:
- All tests from Tasks 2, 4, 5 pass
- Additional integration tests:
- `test_list_sessions_after_api_restart_shows_db_metadata` (simulates restart by clearing in-memory dict)
- `test_two_websockets_on_same_session_receive_same_output`
- `test_idle_cleanup_per_session_not_global`
- `make test` passes (unit + integration)
**Testing (TDD)**:
- These are the GREEN/TRIANGULATE phases for earlier backend tasks; ensure coverage is comprehensive
---
### Task 12: Frontend Component Tests
**Scope**: Finalize frontend test coverage for tabs, page, and hook.
**Files to create / modify**:
- `apps/web/src/components/terminal-session-tabs.test.tsx` (finalize)
- `apps/web/src/hooks/use-terminal-sessions.test.ts` (new, if not created earlier)
- `apps/web/src/pages/terminal.test.tsx` (new)
**Acceptance Criteria**:
- Tab component tests cover rendering, selection, close, rename, and max-session disable
- Hook tests cover load, create, close, error handling
- Page tests cover session lifecycle, fullscreen, and keyboard shortcuts
- `cd apps/web && npm test` passes
**Testing (TDD)**:
- Finalize RED→GREEN→TRIANGULATE for all frontend tasks
---
## Risks and Mitigations
| Risk | Likelihood | Impact | Mitigation |
|------|------------|--------|------------|
| Resource exhaustion (5× docker exec per instance) | Medium | High | Max 5 enforced in `create_session`. Idle timeout (30 min) applies per session. |
| Mobile UX degraded by tab bar + special keys strip | Medium | Medium | Auto-hide shared between tabs and header. Compact tab design. Overflow scroll. |
| Concurrent WS policy closes wrong session's sockets | Medium | High | Explicit unit test: `attach_websocket` must only affect same `(instance_id, session_id)`. |
| DB writes on hot path (activity tracking) | Low | Medium | `last_activity_at` updates are fire-and-forget async tasks; do not block I/O loop. |
| Frontend performance with 5 mounted xterm.js instances | Low | Medium | Max 5 sessions. Inactive terminals use `display: none` (not unmounted). xterm.js GPU acceleration handles this. |
| Default session alias ambiguity | Low | Low | Document that `/terminal` maps to `"default"`. Future deprecation can migrate to explicit IDs. |
| Browser shortcut conflicts | Low | Medium | Use `Alt+Shift+*` instead of `Ctrl+Shift+W/N`. Only `preventDefault()` on exact matching combos. |
---
## Rollback Plan
- **PR 1 rollback**: Alembic downgrade removes `terminal_sessions` table. Old `TerminalManager` code is fully replaced, so reverting PR 1 requires reverting all subsequent PRs.
- **PR 2 rollback**: Revert API changes. Legacy `/terminal` WS route and `POST .../terminal/reset` continue to work; new `/terminal/{session_id}` returns 404 but no clients call it until PR 3 is deployed.
- **PR 3 rollback**: Revert frontend. Users see old single-session UI. Backend `/terminal` alias continues to serve them.
Because PRs are stacked, rolling back PR 2 or PR 1 requires rolling back all dependent PRs above it.
+18
View File
@@ -0,0 +1,18 @@
#!/bin/bash
# Fix ghost migration on production server
# Run this INSIDE the api container or on the server
echo "=== Checking for ghost migration file ==="
find /app/alembic/versions -name "*tool_definition*" 2>/dev/null
echo ""
echo "=== Current alembic_version in DB ==="
psql "$DATABASE_URL" -c "SELECT * FROM alembic_version;"
echo ""
echo "=== Checking if tool_types has definition_manifests column ==="
psql "$DATABASE_URL" -c "\d tool_types" | grep -i manifest
echo ""
echo "=== Our known migration heads ==="
grep -r "revision.*=" /app/alembic/versions/*.py | grep "2026_05_28"
+72
View File
@@ -0,0 +1,72 @@
#!/usr/bin/env python3
"""Securely update the PostgreSQL password for the headquarter database user.
Usage:
python scripts/update_db_password.py
The script will prompt for the new password (no echo) and update it via the
running hq-postgres Docker container.
After running, remember to update your .env file:
POSTGRES_PASSWORD=<your-new-password>
"""
import getpass
import subprocess
import sys
def main() -> None:
# Prompt for new password securely (no echo to terminal)
new_password = getpass.getpass("Enter new password for 'headquarter' user: ")
if not new_password:
print("Error: password cannot be empty.", file=sys.stderr)
sys.exit(1)
confirm = getpass.getpass("Confirm new password: ")
if new_password != confirm:
print("Error: passwords do not match.", file=sys.stderr)
sys.exit(1)
# Use psql inside the running postgres container to avoid exposing
# the password in host shell history.
sql = f"ALTER ROLE headquarter WITH PASSWORD '{new_password}';"
try:
result = subprocess.run(
[
"docker",
"exec",
"-i",
"hq-postgres",
"psql",
"-U",
"headquarter",
"-d",
"headquarter",
"-c",
sql,
],
capture_output=True,
text=True,
check=True,
)
print(result.stdout.strip())
print("\n✅ Password updated successfully.")
print("\n⚠️ IMPORTANT: Update your .env file:")
print(f" POSTGRES_PASSWORD={new_password}")
print("\n⚠️ Then restart the application containers:")
print(" docker compose up -d")
except subprocess.CalledProcessError as exc:
print(f"Error: {exc.stderr or exc.stdout}", file=sys.stderr)
sys.exit(1)
except FileNotFoundError:
print(
"Error: 'docker' command not found. Is Docker installed and running?",
file=sys.stderr,
)
sys.exit(1)
if __name__ == "__main__":
main()