intentos 0.4.1__tar.gz → 0.4.2__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (102) hide show
  1. {intentos-0.4.1 → intentos-0.4.2}/PKG-INFO +1 -1
  2. {intentos-0.4.1 → intentos-0.4.2}/cli.py +23 -1
  3. intentos-0.4.2/commands/doctor.py +210 -0
  4. intentos-0.4.2/commands/proxy.py +84 -0
  5. {intentos-0.4.1 → intentos-0.4.2}/core/event_store.py +121 -1
  6. intentos-0.4.2/core/federated.py +268 -0
  7. {intentos-0.4.1 → intentos-0.4.2}/core/scheduler.py +56 -8
  8. {intentos-0.4.1 → intentos-0.4.2}/intentos.egg-info/PKG-INFO +1 -1
  9. {intentos-0.4.1 → intentos-0.4.2}/intentos.egg-info/SOURCES.txt +8 -0
  10. {intentos-0.4.1 → intentos-0.4.2}/intentos.egg-info/top_level.txt +1 -0
  11. intentos-0.4.2/proxy/__init__.py +1 -0
  12. intentos-0.4.2/proxy/server.py +249 -0
  13. intentos-0.4.2/proxy/tracer.py +165 -0
  14. {intentos-0.4.1 → intentos-0.4.2}/pyproject.toml +2 -2
  15. intentos-0.4.2/tests/test_doctor.py +144 -0
  16. intentos-0.4.2/tests/test_proxy.py +270 -0
  17. {intentos-0.4.1 → intentos-0.4.2}/README.md +0 -0
  18. {intentos-0.4.1 → intentos-0.4.2}/adapters/__init__.py +0 -0
  19. {intentos-0.4.1 → intentos-0.4.2}/adapters/anthropic_adapter.py +0 -0
  20. {intentos-0.4.1 → intentos-0.4.2}/adapters/base.py +0 -0
  21. {intentos-0.4.1 → intentos-0.4.2}/adapters/github_models_adapter.py +0 -0
  22. {intentos-0.4.1 → intentos-0.4.2}/adapters/ollama_adapter.py +0 -0
  23. {intentos-0.4.1 → intentos-0.4.2}/adapters/openai_adapter.py +0 -0
  24. {intentos-0.4.1 → intentos-0.4.2}/adapters/openrouter_adapter.py +0 -0
  25. {intentos-0.4.1 → intentos-0.4.2}/commands/__init__.py +0 -0
  26. {intentos-0.4.1 → intentos-0.4.2}/commands/analytics.py +0 -0
  27. {intentos-0.4.1 → intentos-0.4.2}/commands/ask.py +0 -0
  28. {intentos-0.4.1 → intentos-0.4.2}/commands/compare.py +0 -0
  29. {intentos-0.4.1 → intentos-0.4.2}/commands/demo.py +0 -0
  30. {intentos-0.4.1 → intentos-0.4.2}/commands/event.py +0 -0
  31. {intentos-0.4.1 → intentos-0.4.2}/commands/evolution.py +0 -0
  32. {intentos-0.4.1 → intentos-0.4.2}/commands/export.py +0 -0
  33. {intentos-0.4.1 → intentos-0.4.2}/commands/helpers.py +0 -0
  34. {intentos-0.4.1 → intentos-0.4.2}/commands/import_cmd.py +0 -0
  35. {intentos-0.4.1 → intentos-0.4.2}/commands/list.py +0 -0
  36. {intentos-0.4.1 → intentos-0.4.2}/commands/mcp_server.py +0 -0
  37. {intentos-0.4.1 → intentos-0.4.2}/commands/quickstart.py +0 -0
  38. {intentos-0.4.1 → intentos-0.4.2}/commands/registry.py +0 -0
  39. {intentos-0.4.1 → intentos-0.4.2}/commands/run.py +0 -0
  40. {intentos-0.4.1 → intentos-0.4.2}/commands/security.py +0 -0
  41. {intentos-0.4.1 → intentos-0.4.2}/commands/trace.py +0 -0
  42. {intentos-0.4.1 → intentos-0.4.2}/commands/validate.py +0 -0
  43. {intentos-0.4.1 → intentos-0.4.2}/commands/workflow.py +0 -0
  44. {intentos-0.4.1 → intentos-0.4.2}/core/__init__.py +0 -0
  45. {intentos-0.4.1 → intentos-0.4.2}/core/analytics.py +0 -0
  46. {intentos-0.4.1 → intentos-0.4.2}/core/ask.py +0 -0
  47. {intentos-0.4.1 → intentos-0.4.2}/core/ask_preferences.py +0 -0
  48. {intentos-0.4.1 → intentos-0.4.2}/core/conditions.py +0 -0
  49. {intentos-0.4.1 → intentos-0.4.2}/core/cost_model.py +0 -0
  50. {intentos-0.4.1 → intentos-0.4.2}/core/evolution.py +0 -0
  51. {intentos-0.4.1 → intentos-0.4.2}/core/executor.py +0 -0
  52. {intentos-0.4.1 → intentos-0.4.2}/core/llm_provider.py +0 -0
  53. {intentos-0.4.1 → intentos-0.4.2}/core/models.py +0 -0
  54. {intentos-0.4.1 → intentos-0.4.2}/core/parser.py +0 -0
  55. {intentos-0.4.1 → intentos-0.4.2}/core/planner.py +0 -0
  56. {intentos-0.4.1 → intentos-0.4.2}/core/recorder.py +0 -0
  57. {intentos-0.4.1 → intentos-0.4.2}/core/registry.py +0 -0
  58. {intentos-0.4.1 → intentos-0.4.2}/core/search.py +0 -0
  59. {intentos-0.4.1 → intentos-0.4.2}/core/security.py +0 -0
  60. {intentos-0.4.1 → intentos-0.4.2}/core/workflow.py +0 -0
  61. {intentos-0.4.1 → intentos-0.4.2}/core/workflow_parser.py +0 -0
  62. {intentos-0.4.1 → intentos-0.4.2}/core/workflow_runner.py +0 -0
  63. {intentos-0.4.1 → intentos-0.4.2}/examples/__init__.py +0 -0
  64. {intentos-0.4.1 → intentos-0.4.2}/examples/code_review.yaml +0 -0
  65. {intentos-0.4.1 → intentos-0.4.2}/examples/data_extract.yaml +0 -0
  66. {intentos-0.4.1 → intentos-0.4.2}/examples/image_analyze.yaml +0 -0
  67. {intentos-0.4.1 → intentos-0.4.2}/examples/research_workflow.yaml +0 -0
  68. {intentos-0.4.1 → intentos-0.4.2}/examples/sentiment_analyze.yaml +0 -0
  69. {intentos-0.4.1 → intentos-0.4.2}/examples/text_summarize.yaml +0 -0
  70. {intentos-0.4.1 → intentos-0.4.2}/examples/translate.yaml +0 -0
  71. {intentos-0.4.1 → intentos-0.4.2}/intentos.egg-info/dependency_links.txt +0 -0
  72. {intentos-0.4.1 → intentos-0.4.2}/intentos.egg-info/entry_points.txt +0 -0
  73. {intentos-0.4.1 → intentos-0.4.2}/intentos.egg-info/requires.txt +0 -0
  74. {intentos-0.4.1 → intentos-0.4.2}/mcp_server.py +0 -0
  75. {intentos-0.4.1 → intentos-0.4.2}/setup.cfg +0 -0
  76. {intentos-0.4.1 → intentos-0.4.2}/tests/test_adapters.py +0 -0
  77. {intentos-0.4.1 → intentos-0.4.2}/tests/test_adaptive_workflow.py +0 -0
  78. {intentos-0.4.1 → intentos-0.4.2}/tests/test_ask.py +0 -0
  79. {intentos-0.4.1 → intentos-0.4.2}/tests/test_ask_integration.py +0 -0
  80. {intentos-0.4.1 → intentos-0.4.2}/tests/test_cli_commands.py +0 -0
  81. {intentos-0.4.1 → intentos-0.4.2}/tests/test_conditions.py +0 -0
  82. {intentos-0.4.1 → intentos-0.4.2}/tests/test_cost_model.py +0 -0
  83. {intentos-0.4.1 → intentos-0.4.2}/tests/test_cross_runtime.py +0 -0
  84. {intentos-0.4.1 → intentos-0.4.2}/tests/test_event_store.py +0 -0
  85. {intentos-0.4.1 → intentos-0.4.2}/tests/test_evolution.py +0 -0
  86. {intentos-0.4.1 → intentos-0.4.2}/tests/test_executor.py +0 -0
  87. {intentos-0.4.1 → intentos-0.4.2}/tests/test_import_export.py +0 -0
  88. {intentos-0.4.1 → intentos-0.4.2}/tests/test_mcp_server.py +0 -0
  89. {intentos-0.4.1 → intentos-0.4.2}/tests/test_registry_persistence.py +0 -0
  90. {intentos-0.4.1 → intentos-0.4.2}/tests/test_search.py +0 -0
  91. {intentos-0.4.1 → intentos-0.4.2}/tests/test_security.py +0 -0
  92. {intentos-0.4.1 → intentos-0.4.2}/tests/test_security_integration.py +0 -0
  93. {intentos-0.4.1 → intentos-0.4.2}/tests/test_workflow.py +0 -0
  94. {intentos-0.4.1 → intentos-0.4.2}/tests/test_workflow_adapter_integration.py +0 -0
  95. {intentos-0.4.1 → intentos-0.4.2}/tests/test_workflow_integration.py +0 -0
  96. {intentos-0.4.1 → intentos-0.4.2}/tests/test_workflow_parser.py +0 -0
  97. {intentos-0.4.1 → intentos-0.4.2}/tools/__init__.py +0 -0
  98. {intentos-0.4.1 → intentos-0.4.2}/tools/exporter.py +0 -0
  99. {intentos-0.4.1 → intentos-0.4.2}/tools/formats/__init__.py +0 -0
  100. {intentos-0.4.1 → intentos-0.4.2}/tools/formats/mcp.py +0 -0
  101. {intentos-0.4.1 → intentos-0.4.2}/tools/formats/openai.py +0 -0
  102. {intentos-0.4.1 → intentos-0.4.2}/tools/importer.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: intentos
3
- Version: 0.4.1
3
+ Version: 0.4.2
4
4
  Summary: An open interoperability layer for AI capabilities, workflows, and execution
5
5
  Author: Intent OS Project
6
6
  License-Expression: MIT
@@ -23,6 +23,8 @@ import commands.evolution
23
23
  import commands.ask
24
24
  import commands.demo
25
25
  import commands.trace
26
+ import commands.proxy
27
+ import commands.doctor
26
28
 
27
29
  # All cmd_* functions are imported from command modules via the registry pattern below
28
30
  CMD_MAP = {
@@ -42,6 +44,8 @@ CMD_MAP = {
42
44
  "ask": commands.ask.cmd_ask,
43
45
  "demo": commands.demo.cmd_demo,
44
46
  "inspect": commands.trace.cmd_inspect,
47
+ "proxy": commands.proxy.cmd_proxy,
48
+ "doctor": commands.doctor.cmd_doctor,
45
49
  }
46
50
 
47
51
  def build_parser() -> argparse.ArgumentParser:
@@ -51,7 +55,7 @@ def build_parser() -> argparse.ArgumentParser:
51
55
  description="Intent OS Reference Runtime - Open AI Capability Interoperability",
52
56
  epilog="Phase 0 - Prove that one Manifest can run on multiple runtimes.",
53
57
  )
54
- parser.add_argument("--version", action="version", version="intent-os 0.4.1")
58
+ parser.add_argument("--version", action="version", version="intent-os 0.4.2")
55
59
 
56
60
  subparsers = parser.add_subparsers(dest="command", help="Available commands")
57
61
 
@@ -235,6 +239,24 @@ def build_parser() -> argparse.ArgumentParser:
235
239
  help="Export trace as a standalone HTML file for sharing")
236
240
  inspect_parser.set_defaults(func=CMD_MAP["inspect"])
237
241
 
242
+ # proxy
243
+ proxy_parser = subparsers.add_parser("proxy",
244
+ help="Start the Agent Hook proxy to record AI agent API calls")
245
+ proxy_sub = proxy_parser.add_subparsers(dest="proxy_action", help="Proxy actions")
246
+ ps = proxy_sub.add_parser("start", help="Start the proxy server")
247
+ ps.add_argument("--port", type=int, default=8377, help="Port (default: 8377)")
248
+ ps.add_argument("--host", default="127.0.0.1", help="Host (default: 127.0.0.1)")
249
+ ps.set_defaults(func=CMD_MAP["proxy"])
250
+ pst = proxy_sub.add_parser("status", help="Check if the proxy is running")
251
+ pst.add_argument("--port", type=int, default=8377, help="Port (default: 8377)")
252
+ pst.add_argument("--host", default="127.0.0.1", help="Host (default: 127.0.0.1)")
253
+ pst.set_defaults(func=CMD_MAP["proxy"])
254
+
255
+ # doctor
256
+ doctor_parser = subparsers.add_parser("doctor",
257
+ help="Check your AI agent's health — see what happened, what went wrong, and how to fix it")
258
+ doctor_parser.set_defaults(func=CMD_MAP["doctor"])
259
+
238
260
  return parser
239
261
 
240
262
 
@@ -0,0 +1,210 @@
1
+ """Intent OS — doctor command: AI Agent Health Report.
2
+
3
+ The first thing a user runs when something feels wrong.
4
+ No understanding of traces, events, or manifests required.
5
+
6
+ intent-os doctor
7
+
8
+ Shows whether the last agent run succeeded or failed,
9
+ what went wrong, and how to fix it.
10
+ """
11
+ from __future__ import annotations
12
+
13
+ import sys
14
+ from pathlib import Path
15
+ from typing import Any
16
+
17
+ from commands.helpers import get_event_store, setup_executor, find_builtin_manifest
18
+
19
+ _project_root = Path(__file__).parent.parent
20
+ if str(_project_root) not in sys.path:
21
+ sys.path.insert(0, str(_project_root))
22
+
23
+
24
+ def _get_agent_status(record: dict[str, Any] | None) -> str:
25
+ """Classify the agent's last run as healthy, failed, or unknown."""
26
+ if record is None:
27
+ return "unknown"
28
+ status = record.get("status", "")
29
+ if status == "success":
30
+ return "healthy"
31
+ elif status == "failure":
32
+ return "failed"
33
+ elif status == "partial":
34
+ return "partial"
35
+ return "unknown"
36
+
37
+
38
+ def _summarize_failure(events: list[dict[str, Any]], record: dict[str, Any] | None) -> dict[str, Any]:
39
+ """Analyze failure: find the failed step and reason."""
40
+ result = {
41
+ "failed_events": [],
42
+ "error_message": None,
43
+ "suggestion": None,
44
+ "failed_at": None,
45
+ }
46
+
47
+ if record and record.get("error"):
48
+ result["error_message"] = record["error"]
49
+
50
+ # Scan events for failures
51
+ for evt in events:
52
+ etype = evt.get("event_type", "")
53
+ if etype in ("TaskFailed", "WorkflowFailed"):
54
+ cap = evt.get("capability", "")
55
+ source = evt.get("source", "")
56
+ result["failed_events"].append(f"{cap} ({source})")
57
+ result["failed_at"] = cap or source or "unknown"
58
+
59
+ # Extract error from payload (stored as JSON string in Event Store)
60
+ raw_payload = evt.get("payload", "{}")
61
+ if isinstance(raw_payload, str):
62
+ import json
63
+ try:
64
+ payload = json.loads(raw_payload)
65
+ except json.JSONDecodeError:
66
+ payload = {}
67
+ else:
68
+ payload = raw_payload
69
+ if isinstance(payload, dict) and payload.get("error_message"):
70
+ result["error_message"] = payload["error_message"]
71
+
72
+ # Generate suggestion from error patterns
73
+ err = (result["error_message"] or "").lower()
74
+ if "timeout" in err or "timed out" in err:
75
+ result["suggestion"] = "Increase timeout duration or retry with a simpler request."
76
+ elif "rate limit" in err or "429" in err:
77
+ result["suggestion"] = "You're being rate-limited. Slow down requests or check your API plan."
78
+ elif "authentication" in err or "api key" in err or "unauthorized" in err:
79
+ result["suggestion"] = "Check that your API key is set and valid."
80
+ elif "context" in err and ("length" in err or "token" in err):
81
+ result["suggestion"] = "The input was too long. Try a shorter prompt or use a model with larger context."
82
+ elif "connect" in err or "connection" in err:
83
+ result["suggestion"] = "Could not reach the API. Check your network or if the service is down."
84
+ elif "permission" in err or "denied" in err or "forbidden" in err:
85
+ result["suggestion"] = "Permission denied. Check file permissions or access rights."
86
+ elif "not found" in err or "no such" in err:
87
+ result["suggestion"] = "A required file or resource was not found. Check the path exists."
88
+ elif "server error" in err or "500" in err or "502" in err or "503" in err:
89
+ result["suggestion"] = "The API server returned an error. This is usually temporary — try again."
90
+ else:
91
+ result["suggestion"] = "Check the full trace for details."
92
+
93
+ return result
94
+
95
+
96
+ def _suggest_next_step(record: dict[str, Any] | None) -> str:
97
+ """Suggest what the user should do next."""
98
+ if record is None:
99
+ return "Run a capability first: intent-os run translate -p text=hello -p target_lang=zh"
100
+ return "View full trace: intent-os inspect latest"
101
+
102
+
103
+ def cmd_doctor(args: Any) -> None:
104
+ """Check the health of your last AI agent execution.
105
+
106
+ Analyzes the most recent execution record and tells you:
107
+ - Did it succeed or fail?
108
+ - What went wrong?
109
+ - How to fix it?
110
+ - How much it cost?
111
+ """
112
+ store = get_event_store()
113
+ all_ids = store.get_all_trace_ids()
114
+
115
+ if not all_ids:
116
+ # No traces yet — guide the user to create one
117
+ print()
118
+ print(" ================================================")
119
+ print(" Intent OS Doctor - Health Check")
120
+ print(" ================================================")
121
+ print()
122
+ print(" No agent executions found.")
123
+ print()
124
+ print(" Run a demo to see how it works:")
125
+ print(" intent-os demo --auto")
126
+ print()
127
+ print(" Or run a capability:")
128
+ print(" intent-os run translate -p text=hello -p target_lang=zh")
129
+ print()
130
+ print(" Want to record your own AI agent?")
131
+ print(" intent-os proxy start")
132
+ print()
133
+ return
134
+
135
+ # Get latest trace
136
+ trace_id = all_ids[0]
137
+ record = store.get_record(trace_id)
138
+ events = store.get_events_by_trace(trace_id)
139
+
140
+ status = _get_agent_status(record)
141
+
142
+ print()
143
+ print(" ================================================")
144
+ print(" Intent OS Doctor - Health Check")
145
+ print(" ================================================")
146
+ print()
147
+
148
+ if status == "healthy":
149
+ name = record.get("manifest_name", "?") if record else "?"
150
+ version = record.get("manifest_version", "?") if record else "?"
151
+ runtime = record.get("runtime_id", "?") if record else "?"
152
+ adapter = record.get("adapter", "?") if record else "?"
153
+ latency = record.get("total_latency_ms", 0) if record else 0
154
+ cost = record.get("total_cost_usd", 0.0) if record else 0.0
155
+ tokens = record.get("total_tokens", 0) if record else 0
156
+
157
+ print(f" [OK] Last Agent Run: Healthy")
158
+ print()
159
+ print(f" Capability:")
160
+ print(f" {name}@{version}")
161
+ print()
162
+ print(f" Runtime: {runtime} ({adapter})")
163
+ print(f" Duration: {latency:.0f}ms")
164
+ print(f" Steps: {len(events)}")
165
+ print(f" Cost: ${cost:.4f}")
166
+ print(f" Tokens: {tokens}")
167
+
168
+ elif status == "failed":
169
+ name = record.get("manifest_name", "?") if record else "?"
170
+ analysis = _summarize_failure(events, record)
171
+
172
+ print(f" [!!] Last Agent Run: Failed")
173
+ print()
174
+ print(f" Capability: {name}")
175
+
176
+ if analysis["failed_at"]:
177
+ print(f" Failed at: {analysis['failed_at']}")
178
+
179
+ print()
180
+ print(" Problem:")
181
+ print(f" {analysis['error_message'] or 'Unknown error'}")
182
+ print()
183
+
184
+ if analysis["failed_events"]:
185
+ print(" Failed events:")
186
+ for fe in analysis["failed_events"]:
187
+ print(f" - {fe}")
188
+ print()
189
+
190
+ if analysis["suggestion"]:
191
+ print(" Suggestion:")
192
+ print(f" {analysis['suggestion']}")
193
+ print()
194
+
195
+ elif status == "partial":
196
+ print(f" [..] Last Agent Run: Partial")
197
+ print()
198
+ print(" Some steps succeeded, some failed.")
199
+
200
+ else:
201
+ print(f" [?] Last Agent Run: Unknown")
202
+ print()
203
+
204
+ # Next step suggestion
205
+ print()
206
+ next_step = _suggest_next_step(record)
207
+ print(f" {'=' * 48}")
208
+ print(f" {next_step}")
209
+ print(f" {'=' * 48}")
210
+ print()
@@ -0,0 +1,84 @@
1
+ """Intent OS CLI — proxy command: Agent Hook proxy server."""
2
+ from __future__ import annotations
3
+
4
+ import sys
5
+ from typing import Any
6
+
7
+
8
+ def cmd_proxy(args: Any) -> None:
9
+ """Start or manage the Agent Hook proxy server.
10
+
11
+ The proxy intercepts OpenAI and Anthropic API calls from AI coding
12
+ agents (Claude Code, Cursor, Copilot, etc.) and records every call
13
+ to the Intent OS Event Store for later inspection.
14
+ """
15
+ action = args.proxy_action
16
+
17
+ if action == "start":
18
+ _cmd_start(args)
19
+ elif action == "status":
20
+ _cmd_status(args)
21
+ else:
22
+ print(f"Unknown proxy action: {action}", file=sys.stderr)
23
+ sys.exit(1)
24
+
25
+
26
+ def _cmd_start(args: Any) -> None:
27
+ """Start the proxy server."""
28
+ from proxy.server import start_proxy
29
+
30
+ port = getattr(args, "port", 8377)
31
+ host = getattr(args, "host", "127.0.0.1")
32
+
33
+ print()
34
+ print(" ================================================")
35
+ print(" Intent OS Agent Hook Proxy")
36
+ print(" ================================================")
37
+ print()
38
+ print(f" Proxy listening on http://{host}:{port}")
39
+ print()
40
+ print(" Connect your AI agent:")
41
+ print()
42
+ print(f" export OPENAI_BASE_URL=http://{host}:{port}")
43
+ print(f" export ANTHROPIC_BASE_URL=http://{host}:{port}")
44
+ print()
45
+ print(" Then restart your agent. All executions will be recorded.")
46
+ print()
47
+ print(" Check your agent's health with: intent-os doctor")
48
+ print()
49
+
50
+ try:
51
+ server = start_proxy(port=port, host=host)
52
+ server.serve_forever()
53
+ except PermissionError:
54
+ print(f" Error: Permission denied for port {port}.", file=sys.stderr)
55
+ print(f" Try a higher port: intent-os proxy start --port 8377", file=sys.stderr)
56
+ sys.exit(1)
57
+ except OSError as exc:
58
+ print(f" Error: {exc}", file=sys.stderr)
59
+ sys.exit(1)
60
+
61
+
62
+ def _cmd_status(args: Any) -> None:
63
+ """Check if the proxy server is running."""
64
+ import socket
65
+
66
+ port = getattr(args, "port", 8377)
67
+ host = getattr(args, "host", "127.0.0.1")
68
+
69
+ sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
70
+ try:
71
+ sock.settimeout(2)
72
+ result = sock.connect_ex((host, port))
73
+ if result == 0:
74
+ print(f" Agent Hook Proxy is RUNNING on http://{host}:{port}")
75
+ print()
76
+ print(" To view captured traces:")
77
+ print(" intent-os inspect latest")
78
+ else:
79
+ print(f" Agent Hook Proxy is NOT running on http://{host}:{port}")
80
+ print()
81
+ print(" To start:")
82
+ print(f" intent-os proxy start --port {port}")
83
+ finally:
84
+ sock.close()
@@ -34,7 +34,7 @@ from core.recorder import ExecutionRecorder
34
34
 
35
35
 
36
36
  # Schema version to support future migrations
37
- SCHEMA_VERSION = 1
37
+ SCHEMA_VERSION = 2
38
38
 
39
39
  # SQL statements for table creation
40
40
  CREATE_EVENTS_TABLE = """
@@ -97,6 +97,18 @@ CREATE TABLE IF NOT EXISTS meta (
97
97
  );
98
98
  """
99
99
 
100
+ CREATE_TASK_STATE_TABLE = """
101
+ CREATE TABLE IF NOT EXISTS task_state (
102
+ trace_id TEXT NOT NULL,
103
+ task_id TEXT NOT NULL,
104
+ workflow_id TEXT NOT NULL DEFAULT '',
105
+ field_name TEXT NOT NULL,
106
+ field_value TEXT NOT NULL DEFAULT '{}',
107
+ updated_at TEXT NOT NULL DEFAULT (datetime('now')),
108
+ PRIMARY KEY (trace_id, task_id, field_name)
109
+ );
110
+ """
111
+
100
112
 
101
113
  class EventStoreError(Exception):
102
114
  """Raised when Event Store operations fail."""
@@ -154,6 +166,7 @@ class EventStore:
154
166
  conn = self._get_conn()
155
167
  conn.execute(CREATE_EVENTS_TABLE)
156
168
  conn.execute(CREATE_EXECUTION_RECORDS_TABLE)
169
+ conn.execute(CREATE_TASK_STATE_TABLE)
157
170
  conn.execute(CREATE_META_TABLE)
158
171
  for idx in CREATE_INDEXES:
159
172
  try:
@@ -517,6 +530,113 @@ class EventStore:
517
530
  cursor = conn.execute(sql, params)
518
531
  return [dict(row) for row in cursor.fetchall()]
519
532
 
533
+ # ── Task State (Data Plane storage for Scheduler) ──
534
+
535
+ def save_task_state(
536
+ self,
537
+ trace_id: str,
538
+ task_id: str,
539
+ field_name: str,
540
+ field_value: str,
541
+ workflow_id: str = "",
542
+ ) -> None:
543
+ """Persist a single task state field to the Data Plane.
544
+
545
+ This is how the Scheduler satisfies R1 (Control Plane owns no
546
+ state) — instead of holding ``_task_outputs`` in memory, the
547
+ Scheduler writes each field as a row in the ``task_state`` table.
548
+ """
549
+ conn = self._get_conn()
550
+ conn.execute(
551
+ """INSERT OR REPLACE INTO task_state
552
+ (trace_id, task_id, workflow_id, field_name, field_value, updated_at)
553
+ VALUES (?, ?, ?, ?, ?, datetime('now'))""",
554
+ (trace_id, task_id, workflow_id, field_name, json.dumps(field_value)),
555
+ )
556
+ conn.commit()
557
+
558
+ def save_task_output(
559
+ self,
560
+ trace_id: str,
561
+ task_id: str,
562
+ output: dict[str, Any],
563
+ workflow_id: str = "",
564
+ ) -> None:
565
+ """Convenience: save a task's output dict as the ``output`` field."""
566
+ self.save_task_state(trace_id, task_id, "output", output, workflow_id)
567
+
568
+ def save_task_error(
569
+ self,
570
+ trace_id: str,
571
+ task_id: str,
572
+ error: str,
573
+ workflow_id: str = "",
574
+ ) -> None:
575
+ """Convenience: save a task's error as the ``error`` field."""
576
+ self.save_task_state(trace_id, task_id, "error", error, workflow_id)
577
+
578
+ def get_task_state(
579
+ self,
580
+ trace_id: str,
581
+ task_id: str,
582
+ field_name: str,
583
+ ) -> Any:
584
+ """Read a single task state field from the Data Plane."""
585
+ conn = self._get_conn()
586
+ cursor = conn.execute(
587
+ """SELECT field_value FROM task_state
588
+ WHERE trace_id = ? AND task_id = ? AND field_name = ?""",
589
+ (trace_id, task_id, field_name),
590
+ )
591
+ row = cursor.fetchone()
592
+ if row is None:
593
+ return None
594
+ try:
595
+ return json.loads(row["field_value"])
596
+ except (json.JSONDecodeError, TypeError):
597
+ return row["field_value"]
598
+
599
+ def get_all_task_outputs(self, trace_id: str) -> dict[str, Any]:
600
+ """Read all task outputs for a trace (used by Scheduler for condition evaluation)."""
601
+ conn = self._get_conn()
602
+ cursor = conn.execute(
603
+ """SELECT task_id, field_value FROM task_state
604
+ WHERE trace_id = ? AND field_name = 'output'""",
605
+ (trace_id,),
606
+ )
607
+ result: dict[str, Any] = {}
608
+ for row in cursor.fetchall():
609
+ try:
610
+ result[row["task_id"]] = json.loads(row["field_value"])
611
+ except (json.JSONDecodeError, TypeError):
612
+ result[row["task_id"]] = row["field_value"]
613
+ return result
614
+
615
+ def get_all_task_errors(self, trace_id: str) -> dict[str, str]:
616
+ """Read all task errors for a trace."""
617
+ conn = self._get_conn()
618
+ cursor = conn.execute(
619
+ """SELECT task_id, field_value FROM task_state
620
+ WHERE trace_id = ? AND field_name = 'error'""",
621
+ (trace_id,),
622
+ )
623
+ result: dict[str, str] = {}
624
+ for row in cursor.fetchall():
625
+ try:
626
+ val = json.loads(row["field_value"])
627
+ result[row["task_id"]] = str(val)
628
+ except (json.JSONDecodeError, TypeError):
629
+ result[row["task_id"]] = str(row["field_value"])
630
+ return result
631
+
632
+ def delete_task_state(self, trace_id: str) -> None:
633
+ """Clean up task state for a trace when it's no longer needed."""
634
+ conn = self._get_conn()
635
+ conn.execute("DELETE FROM task_state WHERE trace_id = ?", (trace_id,))
636
+ conn.commit()
637
+
638
+ # ── Legacy query interface ──
639
+
520
640
  def get_all_trace_ids(self) -> list[str]:
521
641
  """List all trace IDs in the store."""
522
642
  conn = self._get_conn()