tide-runtime 0.2.5__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.
- tide/__init__.py +250 -0
- tide/policy.py +43 -0
- tide/store.py +101 -0
- tide_runtime-0.2.5.dist-info/METADATA +37 -0
- tide_runtime-0.2.5.dist-info/RECORD +7 -0
- tide_runtime-0.2.5.dist-info/WHEEL +5 -0
- tide_runtime-0.2.5.dist-info/top_level.txt +1 -0
tide/__init__.py
ADDED
|
@@ -0,0 +1,250 @@
|
|
|
1
|
+
"""Tide runtime governance for agent tools with real side effects.
|
|
2
|
+
|
|
3
|
+
Wrap the tools your agent uses to touch business systems. Before each call a
|
|
4
|
+
deterministic policy decides allow / allow_with_log / require_approval / block;
|
|
5
|
+
every call, approval, and outcome is appended to a local JSONL ledger that the
|
|
6
|
+
`tide ledger` CLI reads.
|
|
7
|
+
|
|
8
|
+
from tide import configure, controlled_tool, record_outcome
|
|
9
|
+
|
|
10
|
+
configure(agent_id="crm-agent", workflow_id="lead-sync", environment="prod")
|
|
11
|
+
|
|
12
|
+
@controlled_tool(name="crm.update_lead", risk_class="internal_write",
|
|
13
|
+
capture=["lead_id"])
|
|
14
|
+
def update_lead(lead_id, status):
|
|
15
|
+
...
|
|
16
|
+
|
|
17
|
+
Zero dependencies, Python >= 3.8. Payloads are hashed by default; `capture`
|
|
18
|
+
opts specific fields into the ledger.
|
|
19
|
+
"""
|
|
20
|
+
|
|
21
|
+
import functools
|
|
22
|
+
import inspect
|
|
23
|
+
import os
|
|
24
|
+
import time
|
|
25
|
+
|
|
26
|
+
from . import policy as _policy
|
|
27
|
+
from . import store as _store
|
|
28
|
+
|
|
29
|
+
__version__ = "0.1.0"
|
|
30
|
+
|
|
31
|
+
__all__ = [
|
|
32
|
+
"configure",
|
|
33
|
+
"controlled_tool",
|
|
34
|
+
"record_outcome",
|
|
35
|
+
"LedgerBlocked",
|
|
36
|
+
"LedgerDenied",
|
|
37
|
+
"LedgerPending",
|
|
38
|
+
]
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
class LedgerBlocked(Exception):
|
|
42
|
+
"""Policy decision was block; the tool did not run."""
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
class LedgerDenied(Exception):
|
|
46
|
+
"""A human denied the approval request; the tool did not run."""
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
class LedgerPending(Exception):
|
|
50
|
+
"""Approval is pending. Approve via `tide ledger approve <id>` and call again."""
|
|
51
|
+
|
|
52
|
+
def __init__(self, approval_id, message):
|
|
53
|
+
super().__init__(message)
|
|
54
|
+
self.approval_id = approval_id
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
_config = {
|
|
58
|
+
"project": os.environ.get("TIDE_LEDGER_DIR", ".tide/ledger"),
|
|
59
|
+
"agent_id": "agent",
|
|
60
|
+
"workflow_id": None,
|
|
61
|
+
"environment": None,
|
|
62
|
+
"trace_id": None,
|
|
63
|
+
"on_approval": "wait",
|
|
64
|
+
"approval_timeout": 300,
|
|
65
|
+
"approval_poll": 2,
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
def configure(**kwargs):
|
|
70
|
+
"""Set run-level config; starts a new trace unless trace_id is passed."""
|
|
71
|
+
unknown = set(kwargs) - set(_config)
|
|
72
|
+
if unknown:
|
|
73
|
+
raise TypeError("unknown configure() options: %s" % ", ".join(sorted(unknown)))
|
|
74
|
+
_config.update(kwargs)
|
|
75
|
+
if not kwargs.get("trace_id"):
|
|
76
|
+
_config["trace_id"] = _store.new_id()
|
|
77
|
+
return dict(_config)
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
def _trace_id():
|
|
81
|
+
if not _config["trace_id"]:
|
|
82
|
+
_config["trace_id"] = _store.new_id()
|
|
83
|
+
return _config["trace_id"]
|
|
84
|
+
|
|
85
|
+
|
|
86
|
+
def _environment(pol):
|
|
87
|
+
return (
|
|
88
|
+
_config["environment"]
|
|
89
|
+
or os.environ.get("TIDE_ENV")
|
|
90
|
+
or os.environ.get("LEDGER_ENV")
|
|
91
|
+
or (pol or {}).get("environment")
|
|
92
|
+
or "dev"
|
|
93
|
+
)
|
|
94
|
+
|
|
95
|
+
|
|
96
|
+
def _bound_args(fn, args, kwargs):
|
|
97
|
+
try:
|
|
98
|
+
bound = inspect.signature(fn).bind(*args, **kwargs)
|
|
99
|
+
bound.apply_defaults()
|
|
100
|
+
return dict(bound.arguments)
|
|
101
|
+
except TypeError:
|
|
102
|
+
return {"args": list(args), "kwargs": kwargs}
|
|
103
|
+
|
|
104
|
+
|
|
105
|
+
def _wait_for_decision(project, approval_id):
|
|
106
|
+
deadline = time.time() + _config["approval_timeout"]
|
|
107
|
+
while time.time() < deadline:
|
|
108
|
+
state = _store.approval_state(project, approval_id)
|
|
109
|
+
if state == "approved":
|
|
110
|
+
return "approved"
|
|
111
|
+
if state == "denied":
|
|
112
|
+
return "denied"
|
|
113
|
+
time.sleep(_config["approval_poll"])
|
|
114
|
+
return "timeout"
|
|
115
|
+
|
|
116
|
+
|
|
117
|
+
def record_outcome(status, trace_id=None, workflow_id=None, human_corrected=False,
|
|
118
|
+
failure_tag=None, source="api", project=None):
|
|
119
|
+
"""Record what the workflow achieved: usually success or failure."""
|
|
120
|
+
project = project or _config["project"]
|
|
121
|
+
record = {
|
|
122
|
+
"outcome_id": _store.new_id(),
|
|
123
|
+
"trace_id": trace_id or _trace_id(),
|
|
124
|
+
"workflow_id": workflow_id or _config["workflow_id"],
|
|
125
|
+
"status": status,
|
|
126
|
+
"source": source,
|
|
127
|
+
"human_corrected": bool(human_corrected),
|
|
128
|
+
"failure_tag": failure_tag,
|
|
129
|
+
"created_at": _store.now_iso(),
|
|
130
|
+
}
|
|
131
|
+
_store.append_jsonl(project, _store.OUTCOMES_FILE, record)
|
|
132
|
+
return record
|
|
133
|
+
|
|
134
|
+
|
|
135
|
+
def controlled_tool(name, version="0.0.0", risk_class="internal_write", owner=None,
|
|
136
|
+
capture=None, cost_usd=None):
|
|
137
|
+
"""Decorator: gate a tool behind policy and record each call."""
|
|
138
|
+
capture = list(capture or [])
|
|
139
|
+
|
|
140
|
+
def decorator(fn):
|
|
141
|
+
@functools.wraps(fn)
|
|
142
|
+
def wrapper(*args, **kwargs):
|
|
143
|
+
project = _config["project"]
|
|
144
|
+
pol = _store.load_policy(project) or dict(_policy.DEFAULT_POLICY)
|
|
145
|
+
env = _environment(pol)
|
|
146
|
+
trace_id = _trace_id()
|
|
147
|
+
named = _bound_args(fn, args, kwargs)
|
|
148
|
+
resolved_risk_class = risk_class(named) if callable(risk_class) else risk_class
|
|
149
|
+
input_hash = _store.canonical_hash(named)
|
|
150
|
+
captured = {k: named[k] for k in capture if k in named}
|
|
151
|
+
decision, reason = _policy.decide(pol, name, resolved_risk_class, env)
|
|
152
|
+
|
|
153
|
+
event = {
|
|
154
|
+
"event_id": _store.new_id(),
|
|
155
|
+
"trace_id": trace_id,
|
|
156
|
+
"workflow_id": _config["workflow_id"],
|
|
157
|
+
"agent_id": _config["agent_id"],
|
|
158
|
+
"tool": name,
|
|
159
|
+
"tool_version": version,
|
|
160
|
+
"owner": owner,
|
|
161
|
+
"risk_class": resolved_risk_class,
|
|
162
|
+
"environment": env,
|
|
163
|
+
"input_hash": input_hash,
|
|
164
|
+
"captured": captured,
|
|
165
|
+
"policy_decision": decision,
|
|
166
|
+
"policy_reason": reason,
|
|
167
|
+
"approval_id": None,
|
|
168
|
+
"approval_status": None,
|
|
169
|
+
"output_hash": None,
|
|
170
|
+
"latency_ms": None,
|
|
171
|
+
"cost_usd": cost_usd,
|
|
172
|
+
"error_code": None,
|
|
173
|
+
"created_at": _store.now_iso(),
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
if decision == "block":
|
|
177
|
+
event["error_code"] = "blocked_by_policy"
|
|
178
|
+
_store.append_jsonl(project, _store.EVENTS_FILE, event)
|
|
179
|
+
raise LedgerBlocked("%s blocked by policy: %s" % (name, reason))
|
|
180
|
+
|
|
181
|
+
if decision == "require_approval":
|
|
182
|
+
grant = _store.find_grant(project, name, input_hash)
|
|
183
|
+
if grant:
|
|
184
|
+
_store.append_jsonl(project, _store.APPROVALS_FILE, {
|
|
185
|
+
"type": "consumed",
|
|
186
|
+
"approval_id": grant,
|
|
187
|
+
"event_id": event["event_id"],
|
|
188
|
+
"created_at": _store.now_iso(),
|
|
189
|
+
})
|
|
190
|
+
event["approval_id"] = grant
|
|
191
|
+
event["approval_status"] = "approved"
|
|
192
|
+
else:
|
|
193
|
+
approval_id = _store.new_id()
|
|
194
|
+
_store.append_jsonl(project, _store.APPROVALS_FILE, {
|
|
195
|
+
"type": "request",
|
|
196
|
+
"approval_id": approval_id,
|
|
197
|
+
"event_id": event["event_id"],
|
|
198
|
+
"trace_id": event["trace_id"],
|
|
199
|
+
"tool": name,
|
|
200
|
+
"tool_version": version,
|
|
201
|
+
"risk_class": resolved_risk_class,
|
|
202
|
+
"input_hash": input_hash,
|
|
203
|
+
"input_summary": captured,
|
|
204
|
+
"policy_reason": reason,
|
|
205
|
+
"agent_id": _config["agent_id"],
|
|
206
|
+
"workflow_id": _config["workflow_id"],
|
|
207
|
+
"created_at": _store.now_iso(),
|
|
208
|
+
})
|
|
209
|
+
event["approval_id"] = approval_id
|
|
210
|
+
hint = "approve with: tide ledger approve %s --project %s" % (approval_id[:8], project)
|
|
211
|
+
if _config["on_approval"] == "raise":
|
|
212
|
+
event["approval_status"] = "pending"
|
|
213
|
+
_store.append_jsonl(project, _store.EVENTS_FILE, event)
|
|
214
|
+
raise LedgerPending(approval_id, "%s needs approval - %s" % (name, hint))
|
|
215
|
+
result_state = _wait_for_decision(project, approval_id)
|
|
216
|
+
if result_state == "denied":
|
|
217
|
+
event["approval_status"] = "denied"
|
|
218
|
+
event["error_code"] = "approval_denied"
|
|
219
|
+
_store.append_jsonl(project, _store.EVENTS_FILE, event)
|
|
220
|
+
raise LedgerDenied("%s was denied by a human approver" % name)
|
|
221
|
+
if result_state == "timeout":
|
|
222
|
+
event["approval_status"] = "pending"
|
|
223
|
+
event["error_code"] = "approval_timeout"
|
|
224
|
+
_store.append_jsonl(project, _store.EVENTS_FILE, event)
|
|
225
|
+
raise LedgerPending(approval_id, "%s approval timed out - %s" % (name, hint))
|
|
226
|
+
_store.append_jsonl(project, _store.APPROVALS_FILE, {
|
|
227
|
+
"type": "consumed",
|
|
228
|
+
"approval_id": approval_id,
|
|
229
|
+
"event_id": event["event_id"],
|
|
230
|
+
"created_at": _store.now_iso(),
|
|
231
|
+
})
|
|
232
|
+
event["approval_status"] = "approved"
|
|
233
|
+
|
|
234
|
+
start = time.time()
|
|
235
|
+
try:
|
|
236
|
+
result = fn(*args, **kwargs)
|
|
237
|
+
except Exception as exc:
|
|
238
|
+
event["latency_ms"] = int((time.time() - start) * 1000)
|
|
239
|
+
event["error_code"] = type(exc).__name__
|
|
240
|
+
_store.append_jsonl(project, _store.EVENTS_FILE, event)
|
|
241
|
+
raise
|
|
242
|
+
event["latency_ms"] = int((time.time() - start) * 1000)
|
|
243
|
+
event["output_hash"] = _store.canonical_hash(result)
|
|
244
|
+
_store.append_jsonl(project, _store.EVENTS_FILE, event)
|
|
245
|
+
return result
|
|
246
|
+
|
|
247
|
+
wrapper.__tide__ = {"name": name, "version": version, "risk_class": risk_class, "owner": owner}
|
|
248
|
+
return wrapper
|
|
249
|
+
|
|
250
|
+
return decorator
|
tide/policy.py
ADDED
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
"""Deterministic policy engine: risk class x environment -> decision."""
|
|
2
|
+
|
|
3
|
+
RISK_CLASSES = (
|
|
4
|
+
"read_only",
|
|
5
|
+
"draft_only",
|
|
6
|
+
"internal_write",
|
|
7
|
+
"customer_visible",
|
|
8
|
+
"money_movement",
|
|
9
|
+
"destructive",
|
|
10
|
+
)
|
|
11
|
+
|
|
12
|
+
DECISIONS = ("allow", "allow_with_log", "require_approval", "block")
|
|
13
|
+
|
|
14
|
+
DEFAULT_POLICY = {
|
|
15
|
+
"version": 1,
|
|
16
|
+
"environment": "dev",
|
|
17
|
+
"decisions": {
|
|
18
|
+
"read_only": {"dev": "allow", "prod": "allow"},
|
|
19
|
+
"draft_only": {"dev": "allow", "prod": "allow_with_log"},
|
|
20
|
+
"internal_write": {"dev": "allow_with_log", "prod": "require_approval"},
|
|
21
|
+
"customer_visible": {"dev": "require_approval", "prod": "require_approval"},
|
|
22
|
+
"money_movement": {"dev": "require_approval", "prod": "require_approval"},
|
|
23
|
+
"destructive": {"dev": "require_approval", "prod": "block"},
|
|
24
|
+
},
|
|
25
|
+
"tools": {},
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
def decide(policy, tool_name, risk_class, environment):
|
|
30
|
+
"""Return (decision, reason). Per-tool override wins over the class table."""
|
|
31
|
+
tool_rule = (policy.get("tools") or {}).get(tool_name)
|
|
32
|
+
if tool_rule:
|
|
33
|
+
decision = tool_rule.get("decision")
|
|
34
|
+
if decision in DECISIONS:
|
|
35
|
+
return decision, tool_rule.get("reason") or ("per-tool override for " + tool_name)
|
|
36
|
+
return "block", "invalid per-tool override for %s - failing closed" % tool_name
|
|
37
|
+
table = (policy.get("decisions") or {}).get(risk_class)
|
|
38
|
+
if not isinstance(table, dict):
|
|
39
|
+
return "block", "unknown risk class '%s' - failing closed" % risk_class
|
|
40
|
+
decision = table.get(environment, table.get("prod"))
|
|
41
|
+
if decision not in DECISIONS:
|
|
42
|
+
return "block", "no valid decision for %s in '%s' - failing closed" % (risk_class, environment)
|
|
43
|
+
return decision, "%s in %s -> %s" % (risk_class, environment, decision)
|
tide/store.py
ADDED
|
@@ -0,0 +1,101 @@
|
|
|
1
|
+
"""File contract for the Tide ledger directory.
|
|
2
|
+
|
|
3
|
+
policy.json policy config
|
|
4
|
+
events.jsonl one record per controlled tool call
|
|
5
|
+
approvals.jsonl request / decision / consumed records
|
|
6
|
+
outcomes.jsonl workflow outcome events
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
import hashlib
|
|
10
|
+
import json
|
|
11
|
+
import os
|
|
12
|
+
import uuid
|
|
13
|
+
from datetime import datetime, timezone
|
|
14
|
+
|
|
15
|
+
POLICY_FILE = "policy.json"
|
|
16
|
+
EVENTS_FILE = "events.jsonl"
|
|
17
|
+
APPROVALS_FILE = "approvals.jsonl"
|
|
18
|
+
OUTCOMES_FILE = "outcomes.jsonl"
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
def now_iso():
|
|
22
|
+
return datetime.now(timezone.utc).isoformat()
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
def new_id():
|
|
26
|
+
return uuid.uuid4().hex
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
def canonical_hash(obj):
|
|
30
|
+
blob = json.dumps(obj, sort_keys=True, separators=(",", ":"), default=str)
|
|
31
|
+
return "sha256:" + hashlib.sha256(blob.encode("utf-8")).hexdigest()
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
def ensure_project(project):
|
|
35
|
+
os.makedirs(project, exist_ok=True)
|
|
36
|
+
return project
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
def load_policy(project):
|
|
40
|
+
path = os.path.join(project, POLICY_FILE)
|
|
41
|
+
if not os.path.exists(path):
|
|
42
|
+
return None
|
|
43
|
+
with open(path, "r", encoding="utf-8") as f:
|
|
44
|
+
return json.load(f)
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
def append_jsonl(project, filename, record):
|
|
48
|
+
ensure_project(project)
|
|
49
|
+
with open(os.path.join(project, filename), "a", encoding="utf-8") as f:
|
|
50
|
+
f.write(json.dumps(record, sort_keys=True, default=str) + "\n")
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
def read_jsonl(project, filename):
|
|
54
|
+
path = os.path.join(project, filename)
|
|
55
|
+
if not os.path.exists(path):
|
|
56
|
+
return []
|
|
57
|
+
records = []
|
|
58
|
+
with open(path, "r", encoding="utf-8") as f:
|
|
59
|
+
for line in f:
|
|
60
|
+
line = line.strip()
|
|
61
|
+
if not line:
|
|
62
|
+
continue
|
|
63
|
+
try:
|
|
64
|
+
records.append(json.loads(line))
|
|
65
|
+
except ValueError:
|
|
66
|
+
continue
|
|
67
|
+
return records
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
def approval_state(project, approval_id):
|
|
71
|
+
state = None
|
|
72
|
+
for r in read_jsonl(project, APPROVALS_FILE):
|
|
73
|
+
if r.get("approval_id") != approval_id:
|
|
74
|
+
continue
|
|
75
|
+
kind = r.get("type")
|
|
76
|
+
if kind == "request":
|
|
77
|
+
state = "pending"
|
|
78
|
+
elif kind == "decision":
|
|
79
|
+
state = "approved" if r.get("decision") == "approved" else "denied"
|
|
80
|
+
elif kind == "consumed":
|
|
81
|
+
state = "consumed"
|
|
82
|
+
return state
|
|
83
|
+
|
|
84
|
+
|
|
85
|
+
def find_grant(project, tool_name, input_hash):
|
|
86
|
+
"""Find an approved, unconsumed approval bound to this exact tool and input."""
|
|
87
|
+
requests = {}
|
|
88
|
+
for r in read_jsonl(project, APPROVALS_FILE):
|
|
89
|
+
aid = r.get("approval_id")
|
|
90
|
+
kind = r.get("type")
|
|
91
|
+
if kind == "request":
|
|
92
|
+
requests[aid] = {"record": r, "state": "pending"}
|
|
93
|
+
elif kind == "decision" and aid in requests:
|
|
94
|
+
requests[aid]["state"] = "approved" if r.get("decision") == "approved" else "denied"
|
|
95
|
+
elif kind == "consumed" and aid in requests:
|
|
96
|
+
requests[aid]["state"] = "consumed"
|
|
97
|
+
for aid, info in requests.items():
|
|
98
|
+
req = info["record"]
|
|
99
|
+
if info["state"] == "approved" and req.get("tool") == tool_name and req.get("input_hash") == input_hash:
|
|
100
|
+
return aid
|
|
101
|
+
return None
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: tide-runtime
|
|
3
|
+
Version: 0.2.5
|
|
4
|
+
Summary: Tide runtime policy, approvals, and earned-use ledger for AI agent tools.
|
|
5
|
+
Project-URL: Repository, https://github.com/rippletideco/rippletide-package
|
|
6
|
+
Requires-Python: >=3.8
|
|
7
|
+
Description-Content-Type: text/markdown
|
|
8
|
+
|
|
9
|
+
# Tide Runtime SDK
|
|
10
|
+
|
|
11
|
+
Python SDK for wrapping production agent tools with Tide policy, approvals, and
|
|
12
|
+
earned-use logging.
|
|
13
|
+
|
|
14
|
+
```bash
|
|
15
|
+
pip install tide-runtime
|
|
16
|
+
tide ledger init
|
|
17
|
+
```
|
|
18
|
+
|
|
19
|
+
```python
|
|
20
|
+
from tide import configure, controlled_tool, record_outcome
|
|
21
|
+
|
|
22
|
+
configure(agent_id="crm-agent", workflow_id="lead-sync", environment="prod")
|
|
23
|
+
|
|
24
|
+
@controlled_tool(name="crm.update_lead", risk_class="internal_write", capture=["lead_id"])
|
|
25
|
+
def update_lead(lead_id, status):
|
|
26
|
+
return {"lead_id": lead_id, "status": status}
|
|
27
|
+
|
|
28
|
+
try:
|
|
29
|
+
update_lead("L-1", "qualified")
|
|
30
|
+
record_outcome("success")
|
|
31
|
+
except Exception:
|
|
32
|
+
record_outcome("failure")
|
|
33
|
+
raise
|
|
34
|
+
```
|
|
35
|
+
|
|
36
|
+
Default files live in `.tide/ledger/`. Override with `TIDE_LEDGER_DIR` or
|
|
37
|
+
`configure(project="...")`.
|
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
tide/__init__.py,sha256=QanIyZb3Z-hJDvpyUlJfQSZ8QlcDGHXT5-MPYkHHycc,9478
|
|
2
|
+
tide/policy.py,sha256=Ds5JF4ZaurCFMut9jT11o-5Mw6k3EolaqW8a4rcjDWs,1760
|
|
3
|
+
tide/store.py,sha256=XwMlrzgfe9zfiSEYOpeEDSPnsMMtu9WfYr40bclBc9o,2990
|
|
4
|
+
tide_runtime-0.2.5.dist-info/METADATA,sha256=CN3ZhDft-miHeWr_XCIH76MKBO5qXmBKPihO0QW8cF8,1016
|
|
5
|
+
tide_runtime-0.2.5.dist-info/WHEEL,sha256=K260EYznzXsJYBQGqmI8VTxEdiZYNvDZwW9cBh9-_MA,91
|
|
6
|
+
tide_runtime-0.2.5.dist-info/top_level.txt,sha256=VWFFwJ9JGbJhNRC7ewPF2GjEHeIMv-bnDWco7XP7xMc,5
|
|
7
|
+
tide_runtime-0.2.5.dist-info/RECORD,,
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
tide
|