mirror of
https://github.com/ultraworkers/claw-code.git
synced 2026-04-24 21:28:11 +08:00
feat(#160): wire claw list-sessions and delete-session CLI commands
Closes the last #160 gap: claws can now manage session lifecycle entirely through the CLI without filesystem hacks. New commands: - claw list-sessions [--directory DIR] [--output-format text|json] Enumerates stored session IDs. JSON mode emits {sessions, count}. Missing/empty directories return empty list (exit 0), not an error. - claw delete-session SESSION_ID [--directory DIR] [--output-format text|json] Idempotent: not-found is exit 0 with status='not_found' (no raise). Partial-failure: exit 1 with typed JSON error envelope: {session_id, deleted: false, error: {kind, message, retryable}} The 'session_delete_failed' kind is retryable=true so orchestrators know to retry vs escalate. Public API surface extended in src/__init__.py: - list_sessions, session_exists, delete_session - SessionNotFoundError, SessionDeleteError Tests added (tests/test_porting_workspace.py): - test_list_sessions_cli_runs: text + json modes against tempdir - test_delete_session_cli_idempotent: first call deleted=true, second call deleted=false (exit 0, status=not_found) - test_delete_session_cli_partial_failure_exit_1: permission error surfaces as exit 1 + typed JSON error with retryable=true All 43 tests pass. The session storage abstraction chapter is closed: - storage layer decoupled from claw code (#160 initial impl) - delete contract hardened + caller-audited (#160 hardening pass) - CLI wired with idempotency preserved at exit-code boundary (this commit)
This commit is contained in:
parent
527c0f971c
commit
6a76cc7c08
@ -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',
|
||||
]
|
||||
|
||||
87
src/main.py
87
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
|
||||
|
||||
@ -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'],
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user