zwarm 0.1.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
zwarm/core/state.py ADDED
@@ -0,0 +1,224 @@
1
+ """
2
+ Flat-file state management for zwarm.
3
+
4
+ State structure:
5
+ .zwarm/
6
+ ├── state.json # Current state (sessions, tasks)
7
+ ├── events.jsonl # Append-only event log
8
+ ├── sessions/
9
+ │ └── <session-id>/
10
+ │ ├── messages.json # Full conversation history
11
+ │ └── output.log # Agent stdout/stderr
12
+ └── orchestrator/
13
+ └── messages.json # Orchestrator's message history (for resume)
14
+ """
15
+
16
+ from __future__ import annotations
17
+
18
+ import json
19
+ from datetime import datetime
20
+ from pathlib import Path
21
+ from typing import Any
22
+
23
+ from .models import ConversationSession, Event, Task
24
+
25
+
26
+ def _json_serializer(obj: Any) -> Any:
27
+ """Custom JSON serializer for non-standard types."""
28
+ # Handle pydantic models
29
+ if hasattr(obj, "model_dump"):
30
+ return obj.model_dump()
31
+ # Handle objects with __dict__
32
+ if hasattr(obj, "__dict__"):
33
+ return {k: v for k, v in obj.__dict__.items() if not k.startswith("_")}
34
+ # Handle datetime
35
+ if hasattr(obj, "isoformat"):
36
+ return obj.isoformat()
37
+ # Fallback to string representation
38
+ return str(obj)
39
+
40
+
41
+ class StateManager:
42
+ """
43
+ Manages flat-file state for zwarm.
44
+
45
+ All state is stored as JSON files in a directory (default: .zwarm/).
46
+ This enables:
47
+ - Git-backed history
48
+ - Easy debugging (just read the files)
49
+ - Resume from previous state
50
+ """
51
+
52
+ def __init__(self, state_dir: Path | str = ".zwarm"):
53
+ self.state_dir = Path(state_dir)
54
+ self._sessions: dict[str, ConversationSession] = {}
55
+ self._tasks: dict[str, Task] = {}
56
+ self._orchestrator_messages: list[dict[str, Any]] = []
57
+
58
+ def init(self) -> None:
59
+ """Initialize state directory structure."""
60
+ self.state_dir.mkdir(parents=True, exist_ok=True)
61
+ (self.state_dir / "sessions").mkdir(exist_ok=True)
62
+ (self.state_dir / "orchestrator").mkdir(exist_ok=True)
63
+
64
+ # Touch events.jsonl
65
+ events_file = self.state_dir / "events.jsonl"
66
+ if not events_file.exists():
67
+ events_file.touch()
68
+
69
+ # --- Sessions ---
70
+
71
+ def add_session(self, session: ConversationSession) -> None:
72
+ """Add a session and persist it."""
73
+ self._sessions[session.id] = session
74
+ self._save_session(session)
75
+ self._save_state()
76
+
77
+ def get_session(self, session_id: str) -> ConversationSession | None:
78
+ """Get a session by ID."""
79
+ return self._sessions.get(session_id)
80
+
81
+ def update_session(self, session: ConversationSession) -> None:
82
+ """Update a session and persist it."""
83
+ self._sessions[session.id] = session
84
+ self._save_session(session)
85
+ self._save_state()
86
+
87
+ def list_sessions(self, status: str | None = None) -> list[ConversationSession]:
88
+ """List sessions, optionally filtered by status."""
89
+ sessions = list(self._sessions.values())
90
+ if status:
91
+ sessions = [s for s in sessions if s.status.value == status]
92
+ return sessions
93
+
94
+ def _save_session(self, session: ConversationSession) -> None:
95
+ """Save session to its own directory."""
96
+ session_dir = self.state_dir / "sessions" / session.id
97
+ session_dir.mkdir(parents=True, exist_ok=True)
98
+
99
+ # Save messages
100
+ messages_file = session_dir / "messages.json"
101
+ messages_file.write_text(json.dumps([m.to_dict() for m in session.messages], indent=2))
102
+
103
+ # --- Tasks ---
104
+
105
+ def add_task(self, task: Task) -> None:
106
+ """Add a task and persist it."""
107
+ self._tasks[task.id] = task
108
+ self._save_state()
109
+
110
+ def get_task(self, task_id: str) -> Task | None:
111
+ """Get a task by ID."""
112
+ return self._tasks.get(task_id)
113
+
114
+ def update_task(self, task: Task) -> None:
115
+ """Update a task and persist it."""
116
+ self._tasks[task.id] = task
117
+ self._save_state()
118
+
119
+ def list_tasks(self, status: str | None = None) -> list[Task]:
120
+ """List tasks, optionally filtered by status."""
121
+ tasks = list(self._tasks.values())
122
+ if status:
123
+ tasks = [t for t in tasks if t.status.value == status]
124
+ return tasks
125
+
126
+ # --- Events ---
127
+
128
+ def log_event(self, event: Event) -> None:
129
+ """Append an event to the log."""
130
+ events_file = self.state_dir / "events.jsonl"
131
+ with open(events_file, "a") as f:
132
+ f.write(json.dumps(event.to_dict()) + "\n")
133
+
134
+ def get_events(
135
+ self,
136
+ session_id: str | None = None,
137
+ task_id: str | None = None,
138
+ kind: str | None = None,
139
+ limit: int | None = None,
140
+ ) -> list[Event]:
141
+ """Read events from the log, optionally filtered."""
142
+ events_file = self.state_dir / "events.jsonl"
143
+ if not events_file.exists():
144
+ return []
145
+
146
+ events = []
147
+ with open(events_file) as f:
148
+ for line in f:
149
+ line = line.strip()
150
+ if not line:
151
+ continue
152
+ event = Event.from_dict(json.loads(line))
153
+ if session_id and event.session_id != session_id:
154
+ continue
155
+ if task_id and event.task_id != task_id:
156
+ continue
157
+ if kind and event.kind != kind:
158
+ continue
159
+ events.append(event)
160
+
161
+ # Most recent first
162
+ events.reverse()
163
+ if limit:
164
+ events = events[:limit]
165
+ return events
166
+
167
+ # --- Orchestrator State ---
168
+
169
+ def save_orchestrator_messages(self, messages: list[dict[str, Any]]) -> None:
170
+ """Save orchestrator's message history for resume."""
171
+ self._orchestrator_messages = messages
172
+ messages_file = self.state_dir / "orchestrator" / "messages.json"
173
+ # Use custom encoder to handle non-serializable types
174
+ messages_file.write_text(json.dumps(messages, indent=2, default=_json_serializer))
175
+
176
+ def load_orchestrator_messages(self) -> list[dict[str, Any]]:
177
+ """Load orchestrator's message history for resume."""
178
+ messages_file = self.state_dir / "orchestrator" / "messages.json"
179
+ if not messages_file.exists():
180
+ return []
181
+ return json.loads(messages_file.read_text())
182
+
183
+ # --- State Persistence ---
184
+
185
+ def _save_state(self) -> None:
186
+ """Save current state to state.json."""
187
+ state = {
188
+ "updated_at": datetime.now().isoformat(),
189
+ "sessions": {sid: s.to_dict() for sid, s in self._sessions.items()},
190
+ "tasks": {tid: t.to_dict() for tid, t in self._tasks.items()},
191
+ }
192
+ state_file = self.state_dir / "state.json"
193
+ state_file.write_text(json.dumps(state, indent=2))
194
+
195
+ def load(self) -> None:
196
+ """Load state from state.json."""
197
+ state_file = self.state_dir / "state.json"
198
+ if not state_file.exists():
199
+ return
200
+
201
+ state = json.loads(state_file.read_text())
202
+
203
+ # Load sessions
204
+ for sid, sdata in state.get("sessions", {}).items():
205
+ self._sessions[sid] = ConversationSession.from_dict(sdata)
206
+
207
+ # Load tasks
208
+ for tid, tdata in state.get("tasks", {}).items():
209
+ self._tasks[tid] = Task.from_dict(tdata)
210
+
211
+ def clear(self) -> None:
212
+ """Clear all state (for testing)."""
213
+ self._sessions.clear()
214
+ self._tasks.clear()
215
+ self._orchestrator_messages.clear()
216
+
217
+ # Clear files
218
+ state_file = self.state_dir / "state.json"
219
+ if state_file.exists():
220
+ state_file.unlink()
221
+
222
+ events_file = self.state_dir / "events.jsonl"
223
+ if events_file.exists():
224
+ events_file.write_text("")
@@ -0,0 +1,160 @@
1
+ """Tests for the config system."""
2
+
3
+ import tempfile
4
+ from pathlib import Path
5
+
6
+ import pytest
7
+
8
+ from zwarm.core.config import (
9
+ ZwarmConfig,
10
+ apply_overrides,
11
+ deep_merge,
12
+ load_config,
13
+ load_yaml_config,
14
+ )
15
+
16
+
17
+ def test_default_config():
18
+ """Test default configuration values."""
19
+ config = ZwarmConfig()
20
+ assert config.executor.adapter == "codex_mcp"
21
+ assert config.executor.sandbox == "workspace-write"
22
+ assert config.orchestrator.lm == "gpt-5-mini"
23
+ assert config.orchestrator.sync_first is True
24
+ assert config.state_dir == ".zwarm"
25
+
26
+
27
+ def test_config_from_dict():
28
+ """Test creating config from dictionary."""
29
+ data = {
30
+ "executor": {"adapter": "claude_code", "model": "claude-sonnet"},
31
+ "orchestrator": {"lm": "gpt-5", "max_steps": 100},
32
+ "state_dir": ".my_state",
33
+ }
34
+ config = ZwarmConfig.from_dict(data)
35
+ assert config.executor.adapter == "claude_code"
36
+ assert config.executor.model == "claude-sonnet"
37
+ assert config.orchestrator.lm == "gpt-5"
38
+ assert config.orchestrator.max_steps == 100
39
+ assert config.state_dir == ".my_state"
40
+
41
+
42
+ def test_config_to_dict():
43
+ """Test converting config to dictionary."""
44
+ config = ZwarmConfig()
45
+ data = config.to_dict()
46
+ assert data["executor"]["adapter"] == "codex_mcp"
47
+ assert data["orchestrator"]["lm"] == "gpt-5-mini"
48
+
49
+
50
+ def test_deep_merge():
51
+ """Test deep merging of dictionaries."""
52
+ base = {"a": 1, "b": {"c": 2, "d": 3}}
53
+ override = {"b": {"c": 99}, "e": 4}
54
+ result = deep_merge(base, override)
55
+ assert result == {"a": 1, "b": {"c": 99, "d": 3}, "e": 4}
56
+
57
+
58
+ def test_apply_overrides():
59
+ """Test CLI override application."""
60
+ config = {"executor": {"adapter": "codex"}, "orchestrator": {"max_steps": 10}}
61
+
62
+ # Override nested value
63
+ result = apply_overrides(config, ["orchestrator.max_steps=50"])
64
+ assert result["orchestrator"]["max_steps"] == 50
65
+
66
+ # Override with string
67
+ result = apply_overrides(config, ["executor.adapter=claude_code"])
68
+ assert result["executor"]["adapter"] == "claude_code"
69
+
70
+ # Override with boolean
71
+ result = apply_overrides(config, ["orchestrator.sync_first=false"])
72
+ assert result["orchestrator"]["sync_first"] is False
73
+
74
+ # Create new nested path
75
+ result = apply_overrides(config, ["weave.project=my-project"])
76
+ assert result["weave"]["project"] == "my-project"
77
+
78
+
79
+ def test_yaml_inheritance():
80
+ """Test YAML config inheritance via extends."""
81
+ with tempfile.TemporaryDirectory() as tmpdir:
82
+ base_path = Path(tmpdir) / "base.yaml"
83
+ child_path = Path(tmpdir) / "child.yaml"
84
+
85
+ # Write base config
86
+ base_path.write_text("""
87
+ executor:
88
+ adapter: codex_mcp
89
+ timeout: 3600
90
+ orchestrator:
91
+ lm: gpt-5-mini
92
+ max_steps: 30
93
+ """)
94
+
95
+ # Write child config that extends base
96
+ child_path.write_text("""
97
+ extends: base.yaml
98
+ orchestrator:
99
+ lm: gpt-5
100
+ prompt: prompts/aggressive.yaml
101
+ """)
102
+
103
+ config = load_yaml_config(child_path)
104
+ assert config["executor"]["adapter"] == "codex_mcp"
105
+ assert config["executor"]["timeout"] == 3600
106
+ assert config["orchestrator"]["lm"] == "gpt-5" # overridden
107
+ assert config["orchestrator"]["max_steps"] == 30 # inherited
108
+ assert config["orchestrator"]["prompt"] == "prompts/aggressive.yaml" # new
109
+
110
+
111
+ def test_load_config_full_chain():
112
+ """Test full config loading with precedence."""
113
+ import os
114
+
115
+ # Save and clear WEAVE_PROJECT to test config precedence
116
+ orig_weave = os.environ.pop("WEAVE_PROJECT", None)
117
+
118
+ try:
119
+ with tempfile.TemporaryDirectory() as tmpdir:
120
+ tmpdir = Path(tmpdir)
121
+
122
+ # Write config.toml
123
+ toml_path = tmpdir / "config.toml"
124
+ toml_path.write_text("""
125
+ [weave]
126
+ project = "my-weave-project"
127
+
128
+ [executor]
129
+ adapter = "codex_mcp"
130
+ """)
131
+
132
+ # Write yaml config
133
+ yaml_path = tmpdir / "experiment.yaml"
134
+ yaml_path.write_text("""
135
+ orchestrator:
136
+ lm: claude-sonnet
137
+ max_steps: 100
138
+ """)
139
+
140
+ # Load with override (use non-existent env_path to prevent loading cwd's .env)
141
+ config = load_config(
142
+ config_path=yaml_path,
143
+ toml_path=toml_path,
144
+ env_path=tmpdir / ".env.nonexistent",
145
+ overrides=["orchestrator.max_steps=200"],
146
+ )
147
+
148
+ # Check precedence: override > yaml > toml > default
149
+ assert config.weave.project == "my-weave-project" # from toml
150
+ assert config.executor.adapter == "codex_mcp" # from toml
151
+ assert config.orchestrator.lm == "claude-sonnet" # from yaml
152
+ assert config.orchestrator.max_steps == 200 # from override
153
+ finally:
154
+ # Restore WEAVE_PROJECT
155
+ if orig_weave is not None:
156
+ os.environ["WEAVE_PROJECT"] = orig_weave
157
+
158
+
159
+ if __name__ == "__main__":
160
+ pytest.main([__file__, "-v"])
@@ -0,0 +1,265 @@
1
+ """Tests for core models and state management."""
2
+
3
+ import tempfile
4
+ from pathlib import Path
5
+
6
+ import pytest
7
+
8
+ from zwarm.core.models import (
9
+ ConversationSession,
10
+ Event,
11
+ Message,
12
+ SessionMode,
13
+ SessionStatus,
14
+ Task,
15
+ TaskStatus,
16
+ event_session_completed,
17
+ event_session_started,
18
+ event_task_created,
19
+ )
20
+ from zwarm.core.state import StateManager
21
+
22
+
23
+ class TestMessage:
24
+ def test_create_message(self):
25
+ msg = Message(role="user", content="Hello")
26
+ assert msg.role == "user"
27
+ assert msg.content == "Hello"
28
+ assert msg.timestamp is not None
29
+
30
+ def test_message_serialization(self):
31
+ msg = Message(role="assistant", content="Hi there")
32
+ data = msg.to_dict()
33
+ restored = Message.from_dict(data)
34
+ assert restored.role == msg.role
35
+ assert restored.content == msg.content
36
+
37
+
38
+ class TestConversationSession:
39
+ def test_create_session(self):
40
+ session = ConversationSession(
41
+ adapter="codex_mcp",
42
+ mode=SessionMode.SYNC,
43
+ task_description="Test task",
44
+ )
45
+ assert session.id is not None
46
+ assert session.adapter == "codex_mcp"
47
+ assert session.mode == SessionMode.SYNC
48
+ assert session.status == SessionStatus.ACTIVE
49
+
50
+ def test_add_message(self):
51
+ session = ConversationSession()
52
+ session.add_message("user", "Hello")
53
+ session.add_message("assistant", "Hi there")
54
+ assert len(session.messages) == 2
55
+ assert session.messages[0].role == "user"
56
+ assert session.messages[1].role == "assistant"
57
+
58
+ def test_complete_session(self):
59
+ session = ConversationSession()
60
+ session.complete("Done!")
61
+ assert session.status == SessionStatus.COMPLETED
62
+ assert session.completed_at is not None
63
+ assert session.exit_message == "Done!"
64
+
65
+ def test_fail_session(self):
66
+ session = ConversationSession()
67
+ session.fail("Error occurred")
68
+ assert session.status == SessionStatus.FAILED
69
+ assert session.exit_message == "Error occurred"
70
+
71
+ def test_session_serialization(self):
72
+ session = ConversationSession(
73
+ adapter="claude_code",
74
+ mode=SessionMode.ASYNC,
75
+ task_description="Build feature",
76
+ model="claude-sonnet",
77
+ )
78
+ session.add_message("user", "Start")
79
+ session.conversation_id = "conv-123"
80
+
81
+ data = session.to_dict()
82
+ restored = ConversationSession.from_dict(data)
83
+
84
+ assert restored.id == session.id
85
+ assert restored.adapter == "claude_code"
86
+ assert restored.mode == SessionMode.ASYNC
87
+ assert restored.conversation_id == "conv-123"
88
+ assert len(restored.messages) == 1
89
+
90
+
91
+ class TestTask:
92
+ def test_create_task(self):
93
+ task = Task(description="Fix the bug")
94
+ assert task.id is not None
95
+ assert task.status == TaskStatus.PENDING
96
+
97
+ def test_task_lifecycle(self):
98
+ task = Task(description="Implement feature")
99
+
100
+ # Start
101
+ task.start("session-123")
102
+ assert task.status == TaskStatus.IN_PROGRESS
103
+ assert task.session_id == "session-123"
104
+
105
+ # Complete
106
+ task.complete("Feature implemented")
107
+ assert task.status == TaskStatus.COMPLETED
108
+ assert task.result == "Feature implemented"
109
+
110
+ def test_task_serialization(self):
111
+ task = Task(description="Test task", parent_task_id="parent-123")
112
+ task.start("session-456")
113
+
114
+ data = task.to_dict()
115
+ restored = Task.from_dict(data)
116
+
117
+ assert restored.id == task.id
118
+ assert restored.description == "Test task"
119
+ assert restored.parent_task_id == "parent-123"
120
+ assert restored.session_id == "session-456"
121
+
122
+
123
+ class TestEvent:
124
+ def test_create_event(self):
125
+ event = Event(
126
+ kind="test_event",
127
+ session_id="session-123",
128
+ payload={"key": "value"},
129
+ )
130
+ assert event.id is not None
131
+ assert event.kind == "test_event"
132
+
133
+ def test_event_factories(self):
134
+ session = ConversationSession(task_description="Test")
135
+ event = event_session_started(session)
136
+ assert event.kind == "session_started"
137
+ assert event.session_id == session.id
138
+
139
+ task = Task(description="Do something")
140
+ event = event_task_created(task)
141
+ assert event.kind == "task_created"
142
+ assert event.task_id == task.id
143
+
144
+
145
+ class TestStateManager:
146
+ def test_init_creates_directories(self):
147
+ with tempfile.TemporaryDirectory() as tmpdir:
148
+ state_dir = Path(tmpdir) / ".zwarm"
149
+ manager = StateManager(state_dir)
150
+ manager.init()
151
+
152
+ assert state_dir.exists()
153
+ assert (state_dir / "sessions").exists()
154
+ assert (state_dir / "orchestrator").exists()
155
+ assert (state_dir / "events.jsonl").exists()
156
+
157
+ def test_session_crud(self):
158
+ with tempfile.TemporaryDirectory() as tmpdir:
159
+ manager = StateManager(Path(tmpdir) / ".zwarm")
160
+ manager.init()
161
+
162
+ # Add
163
+ session = ConversationSession(task_description="Test")
164
+ manager.add_session(session)
165
+
166
+ # Get
167
+ retrieved = manager.get_session(session.id)
168
+ assert retrieved is not None
169
+ assert retrieved.task_description == "Test"
170
+
171
+ # Update
172
+ session.add_message("user", "Hello")
173
+ manager.update_session(session)
174
+
175
+ # List
176
+ sessions = manager.list_sessions()
177
+ assert len(sessions) == 1
178
+
179
+ # Filter by status
180
+ active = manager.list_sessions(status="active")
181
+ assert len(active) == 1
182
+
183
+ def test_task_crud(self):
184
+ with tempfile.TemporaryDirectory() as tmpdir:
185
+ manager = StateManager(Path(tmpdir) / ".zwarm")
186
+ manager.init()
187
+
188
+ # Add
189
+ task = Task(description="Build feature")
190
+ manager.add_task(task)
191
+
192
+ # Get
193
+ retrieved = manager.get_task(task.id)
194
+ assert retrieved is not None
195
+
196
+ # Update
197
+ task.start("session-123")
198
+ manager.update_task(task)
199
+
200
+ # List
201
+ tasks = manager.list_tasks(status="in_progress")
202
+ assert len(tasks) == 1
203
+
204
+ def test_event_logging(self):
205
+ with tempfile.TemporaryDirectory() as tmpdir:
206
+ manager = StateManager(Path(tmpdir) / ".zwarm")
207
+ manager.init()
208
+
209
+ # Log events
210
+ session = ConversationSession()
211
+ manager.log_event(event_session_started(session))
212
+ manager.log_event(event_session_completed(session))
213
+
214
+ # Read events
215
+ events = manager.get_events()
216
+ assert len(events) == 2
217
+ assert events[0].kind == "session_completed" # Most recent first
218
+
219
+ # Filter by session
220
+ events = manager.get_events(session_id=session.id)
221
+ assert len(events) == 2
222
+
223
+ # Filter by kind
224
+ events = manager.get_events(kind="session_started")
225
+ assert len(events) == 1
226
+
227
+ def test_orchestrator_messages(self):
228
+ with tempfile.TemporaryDirectory() as tmpdir:
229
+ manager = StateManager(Path(tmpdir) / ".zwarm")
230
+ manager.init()
231
+
232
+ messages = [
233
+ {"role": "system", "content": "You are an orchestrator"},
234
+ {"role": "user", "content": "Build a feature"},
235
+ ]
236
+ manager.save_orchestrator_messages(messages)
237
+
238
+ loaded = manager.load_orchestrator_messages()
239
+ assert len(loaded) == 2
240
+ assert loaded[0]["role"] == "system"
241
+
242
+ def test_state_persistence(self):
243
+ with tempfile.TemporaryDirectory() as tmpdir:
244
+ state_dir = Path(tmpdir) / ".zwarm"
245
+
246
+ # Create and save state
247
+ manager1 = StateManager(state_dir)
248
+ manager1.init()
249
+
250
+ session = ConversationSession(task_description="Persistent session")
251
+ manager1.add_session(session)
252
+
253
+ task = Task(description="Persistent task")
254
+ manager1.add_task(task)
255
+
256
+ # Load in new manager
257
+ manager2 = StateManager(state_dir)
258
+ manager2.load()
259
+
260
+ assert manager2.get_session(session.id) is not None
261
+ assert manager2.get_task(task.id) is not None
262
+
263
+
264
+ if __name__ == "__main__":
265
+ pytest.main([__file__, "-v"])