diff --git a/src/__init__.py b/src/__init__.py index 2dc0c05..c5faf99 100644 --- a/src/__init__.py +++ b/src/__init__.py @@ -5,7 +5,16 @@ from .parity_audit import ParityAuditResult, run_parity_audit from .port_manifest import PortManifest, build_port_manifest from .query_engine import QueryEnginePort, TurnResult from .runtime import PortRuntime, RuntimeSession -from .session_store import StoredSession, load_session, save_session +from .session_store import ( + SessionDeleteError, + SessionNotFoundError, + StoredSession, + delete_session, + list_sessions, + load_session, + save_session, + session_exists, +) from .system_init import build_system_init_message from .tools import PORTED_TOOLS, build_tool_backlog @@ -15,6 +24,8 @@ __all__ = [ 'PortRuntime', 'QueryEnginePort', 'RuntimeSession', + 'SessionDeleteError', + 'SessionNotFoundError', 'StoredSession', 'TurnResult', 'PORTED_COMMANDS', @@ -23,7 +34,10 @@ __all__ = [ 'build_port_manifest', 'build_system_init_message', 'build_tool_backlog', + 'delete_session', + 'list_sessions', 'load_session', 'run_parity_audit', 'save_session', + 'session_exists', ] diff --git a/src/main.py b/src/main.py index e1fa9ed..56103c6 100644 --- a/src/main.py +++ b/src/main.py @@ -12,7 +12,14 @@ from .port_manifest import build_port_manifest from .query_engine import QueryEnginePort from .remote_runtime import run_remote_mode, run_ssh_mode, run_teleport_mode from .runtime import PortRuntime -from .session_store import load_session +from .session_store import ( + SessionDeleteError, + SessionNotFoundError, + delete_session, + list_sessions, + load_session, + session_exists, +) from .setup import run_setup from .tool_pool import assemble_tool_pool from .tools import execute_tool, get_tool, get_tools, render_tool_index @@ -65,6 +72,35 @@ def build_parser() -> argparse.ArgumentParser: load_session_parser = subparsers.add_parser('load-session', help='load a previously persisted session') load_session_parser.add_argument('session_id') + list_sessions_parser = subparsers.add_parser( + 'list-sessions', + help='enumerate stored session IDs (#160: claw-native session API)', + ) + list_sessions_parser.add_argument( + '--directory', help='session storage directory (default: .port_sessions)' + ) + list_sessions_parser.add_argument( + '--output-format', + choices=['text', 'json'], + default='text', + help='output format', + ) + + delete_session_parser = subparsers.add_parser( + 'delete-session', + help='delete a persisted session (#160: idempotent, race-safe)', + ) + delete_session_parser.add_argument('session_id') + delete_session_parser.add_argument( + '--directory', help='session storage directory (default: .port_sessions)' + ) + delete_session_parser.add_argument( + '--output-format', + choices=['text', 'json'], + default='text', + help='output format', + ) + remote_parser = subparsers.add_parser('remote-mode', help='simulate remote-control runtime branching') remote_parser.add_argument('target') ssh_parser = subparsers.add_parser('ssh-mode', help='simulate SSH runtime branching') @@ -168,6 +204,55 @@ def main(argv: list[str] | None = None) -> int: session = load_session(args.session_id) print(f'{session.session_id}\n{len(session.messages)} messages\nin={session.input_tokens} out={session.output_tokens}') return 0 + if args.command == 'list-sessions': + from pathlib import Path as _Path + directory = _Path(args.directory) if args.directory else None + ids = list_sessions(directory) + if args.output_format == 'json': + import json as _json + print(_json.dumps({'sessions': ids, 'count': len(ids)})) + else: + if not ids: + print('(no sessions)') + else: + for sid in ids: + print(sid) + return 0 + if args.command == 'delete-session': + from pathlib import Path as _Path + directory = _Path(args.directory) if args.directory else None + try: + deleted = delete_session(args.session_id, directory) + except SessionDeleteError as exc: + if args.output_format == 'json': + import json as _json + print(_json.dumps({ + 'session_id': args.session_id, + 'deleted': False, + 'error': { + 'kind': 'session_delete_failed', + 'message': str(exc), + 'retryable': True, + }, + })) + else: + print(f'error: {exc}') + return 1 + if args.output_format == 'json': + import json as _json + print(_json.dumps({ + 'session_id': args.session_id, + 'deleted': deleted, + 'status': 'deleted' if deleted else 'not_found', + })) + else: + if deleted: + print(f'deleted: {args.session_id}') + else: + print(f'not found: {args.session_id}') + # Exit 0 for both cases — delete_session is idempotent, + # not-found is success from a cleanup perspective + return 0 if args.command == 'remote-mode': print(run_remote_mode(args.target).as_text()) return 0 diff --git a/tests/test_porting_workspace.py b/tests/test_porting_workspace.py index b332467..0bd9670 100644 --- a/tests/test_porting_workspace.py +++ b/tests/test_porting_workspace.py @@ -173,6 +173,93 @@ class PortingWorkspaceTests(unittest.TestCase): self.assertIn(session_id, result.stdout) self.assertIn('messages', result.stdout) + def test_list_sessions_cli_runs(self) -> None: + """#160: list-sessions CLI enumerates stored sessions in text + json.""" + import json + import tempfile + from src.session_store import StoredSession, save_session + + with tempfile.TemporaryDirectory() as tmp: + tmp_path = Path(tmp) + for sid in ['alpha', 'bravo']: + save_session( + StoredSession(session_id=sid, messages=('hi',), input_tokens=1, output_tokens=2), + tmp_path, + ) + # text mode + text_result = subprocess.run( + [sys.executable, '-m', 'src.main', 'list-sessions', '--directory', str(tmp_path)], + check=True, capture_output=True, text=True, + ) + self.assertIn('alpha', text_result.stdout) + self.assertIn('bravo', text_result.stdout) + # json mode + json_result = subprocess.run( + [sys.executable, '-m', 'src.main', 'list-sessions', + '--directory', str(tmp_path), '--output-format', 'json'], + check=True, capture_output=True, text=True, + ) + data = json.loads(json_result.stdout) + self.assertEqual(data, {'sessions': ['alpha', 'bravo'], 'count': 2}) + + def test_delete_session_cli_idempotent(self) -> None: + """#160: delete-session CLI is idempotent (not-found is exit 0, status=not_found).""" + import json + import tempfile + from src.session_store import StoredSession, save_session + + with tempfile.TemporaryDirectory() as tmp: + tmp_path = Path(tmp) + save_session( + StoredSession(session_id='once', messages=('hi',), input_tokens=1, output_tokens=2), + tmp_path, + ) + # first delete: success + first = subprocess.run( + [sys.executable, '-m', 'src.main', 'delete-session', 'once', + '--directory', str(tmp_path), '--output-format', 'json'], + capture_output=True, text=True, + ) + self.assertEqual(first.returncode, 0) + self.assertEqual( + json.loads(first.stdout), + {'session_id': 'once', 'deleted': True, 'status': 'deleted'}, + ) + # second delete: idempotent, still exit 0 + second = subprocess.run( + [sys.executable, '-m', 'src.main', 'delete-session', 'once', + '--directory', str(tmp_path), '--output-format', 'json'], + capture_output=True, text=True, + ) + self.assertEqual(second.returncode, 0) + self.assertEqual( + json.loads(second.stdout), + {'session_id': 'once', 'deleted': False, 'status': 'not_found'}, + ) + + def test_delete_session_cli_partial_failure_exit_1(self) -> None: + """#160: partial-failure (permission error) surfaces as exit 1 + typed JSON error.""" + import json + import tempfile + + with tempfile.TemporaryDirectory() as tmp: + tmp_path = Path(tmp) + bad = tmp_path / 'locked.json' + bad.mkdir() + try: + result = subprocess.run( + [sys.executable, '-m', 'src.main', 'delete-session', 'locked', + '--directory', str(tmp_path), '--output-format', 'json'], + capture_output=True, text=True, + ) + self.assertEqual(result.returncode, 1) + data = json.loads(result.stdout) + self.assertFalse(data['deleted']) + self.assertEqual(data['error']['kind'], 'session_delete_failed') + self.assertTrue(data['error']['retryable']) + finally: + bad.rmdir() + def test_tool_permission_filtering_cli_runs(self) -> None: result = subprocess.run( [sys.executable, '-m', 'src.main', 'tools', '--limit', '10', '--deny-prefix', 'mcp'],