conceptkernel 1.0.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.
- cklib/__init__.py +29 -0
- cklib/actions.py +277 -0
- cklib/auth.py +98 -0
- cklib/dispatch.py +179 -0
- cklib/entities.py +95 -0
- cklib/events.py +43 -0
- cklib/execution.py +249 -0
- cklib/instance.py +241 -0
- cklib/ledger.py +216 -0
- cklib/processor.py +237 -0
- cklib/prov.py +631 -0
- cklib/serve.py +254 -0
- cklib/urn.py +466 -0
- conceptkernel-1.0.0.dist-info/METADATA +195 -0
- conceptkernel-1.0.0.dist-info/RECORD +18 -0
- conceptkernel-1.0.0.dist-info/WHEEL +5 -0
- conceptkernel-1.0.0.dist-info/licenses/LICENSE +21 -0
- conceptkernel-1.0.0.dist-info/top_level.txt +1 -0
cklib/__init__.py
ADDED
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
"""cklib — CKP v3.5 Python runtime for Concept Kernels.
|
|
2
|
+
|
|
3
|
+
Usage:
|
|
4
|
+
from cklib import KernelProcessor, on, emit
|
|
5
|
+
from cklib.urn import parse_urn, validate_urn, build_urn
|
|
6
|
+
from cklib.instance import create_instance, seal_instance
|
|
7
|
+
from cklib.dispatch import send_action
|
|
8
|
+
from cklib.serve import KernelServer
|
|
9
|
+
from cklib.auth import CKAuth
|
|
10
|
+
from cklib.entities import EntityManager
|
|
11
|
+
from cklib.actions import resolve_action_type, check_access
|
|
12
|
+
from cklib.prov import ProvChain, Session, ActionRecord, verified_action, list_sessions, get_session
|
|
13
|
+
from cklib.ledger import log_action, read_ledger
|
|
14
|
+
"""
|
|
15
|
+
from cklib.processor import KernelProcessor
|
|
16
|
+
from cklib.events import on, emit
|
|
17
|
+
from cklib.entities import EntityManager
|
|
18
|
+
from cklib.actions import resolve_action_type, check_access
|
|
19
|
+
from cklib.prov import ProvChain, Session, ActionRecord, verified_action, list_sessions, get_session
|
|
20
|
+
from cklib.ledger import log_action, read_ledger
|
|
21
|
+
|
|
22
|
+
__version__ = "1.0.0"
|
|
23
|
+
__all__ = [
|
|
24
|
+
"KernelProcessor", "on", "emit", "EntityManager",
|
|
25
|
+
"resolve_action_type", "check_access",
|
|
26
|
+
"ProvChain", "Session", "ActionRecord", "verified_action",
|
|
27
|
+
"list_sessions", "get_session",
|
|
28
|
+
"log_action", "read_ledger",
|
|
29
|
+
]
|
cklib/actions.py
ADDED
|
@@ -0,0 +1,277 @@
|
|
|
1
|
+
"""Action type resolution, composition via edges, and RBAC checks.
|
|
2
|
+
|
|
3
|
+
Implements SPEC.ACTION-TYPES.md §4 (type resolution), §3 (composition),
|
|
4
|
+
and v3.4 access level enforcement.
|
|
5
|
+
|
|
6
|
+
Usage:
|
|
7
|
+
from cklib.actions import resolve_action_type, resolve_composed_actions, check_access
|
|
8
|
+
|
|
9
|
+
atype = resolve_action_type("email.send") # "operate"
|
|
10
|
+
composed = resolve_composed_actions("concepts/CS.Voting")
|
|
11
|
+
# {'email.send': 'CK.Email', 'email.invite': 'CK.Email', 'register': 'CK.Signup'}
|
|
12
|
+
ok = check_access("email.send", "auth", {"sub": "alice"}, grants)
|
|
13
|
+
"""
|
|
14
|
+
import os
|
|
15
|
+
|
|
16
|
+
import yaml
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
# ---------------------------------------------------------------------------
|
|
20
|
+
# §4 — Action Type Map (from SPEC.ACTION-TYPES.md)
|
|
21
|
+
# ---------------------------------------------------------------------------
|
|
22
|
+
|
|
23
|
+
ACTION_TYPE_MAP = {
|
|
24
|
+
# inspect
|
|
25
|
+
"status": "inspect", "ontology": "inspect", "show": "inspect",
|
|
26
|
+
"list": "inspect", "version": "inspect",
|
|
27
|
+
|
|
28
|
+
# check
|
|
29
|
+
"check.*": "check", "validate": "check", "probe.*": "check",
|
|
30
|
+
|
|
31
|
+
# mutate
|
|
32
|
+
"create": "mutate", "update": "mutate", "complete": "mutate",
|
|
33
|
+
"assign": "mutate", "enable": "mutate", "disable": "mutate",
|
|
34
|
+
"save": "mutate", "load": "mutate",
|
|
35
|
+
|
|
36
|
+
# operate
|
|
37
|
+
"spawn": "operate", "execute": "operate", "render": "operate",
|
|
38
|
+
"run": "operate", "fire": "operate", "play": "operate", "stop": "operate",
|
|
39
|
+
"send": "operate", "invite": "operate",
|
|
40
|
+
|
|
41
|
+
# query
|
|
42
|
+
"catalog": "query", "graph": "query", "search": "query",
|
|
43
|
+
"pending": "query", "tasks": "query",
|
|
44
|
+
|
|
45
|
+
# deploy
|
|
46
|
+
"deploy.*": "deploy", "apply": "deploy", "route.*": "deploy",
|
|
47
|
+
|
|
48
|
+
# transfer
|
|
49
|
+
"export.*": "transfer", "import.*": "transfer",
|
|
50
|
+
"sync": "transfer", "regenerate": "transfer",
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
VALID_ACTION_TYPES = {"inspect", "check", "mutate", "operate", "query", "deploy", "transfer"}
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
def resolve_action_type(action_name):
|
|
57
|
+
"""Resolve dotted action name to its type per SPEC.ACTION-TYPES.md §4.
|
|
58
|
+
|
|
59
|
+
Resolution order:
|
|
60
|
+
1. Exact match (e.g. "status" → inspect)
|
|
61
|
+
2. Suffix match (e.g. "task.create" → "create" → mutate)
|
|
62
|
+
3. Prefix wildcard (e.g. "check.identity" → "check.*" → check)
|
|
63
|
+
4. Default: inspect (read-only)
|
|
64
|
+
"""
|
|
65
|
+
# Exact match
|
|
66
|
+
if action_name in ACTION_TYPE_MAP:
|
|
67
|
+
return ACTION_TYPE_MAP[action_name]
|
|
68
|
+
|
|
69
|
+
# Suffix match: task.create → create → mutate
|
|
70
|
+
suffix = action_name.rsplit(".", 1)[-1]
|
|
71
|
+
if suffix in ACTION_TYPE_MAP:
|
|
72
|
+
return ACTION_TYPE_MAP[suffix]
|
|
73
|
+
|
|
74
|
+
# Prefix wildcard: check.identity → check.* → check
|
|
75
|
+
prefix = action_name.split(".")[0]
|
|
76
|
+
pattern = prefix + ".*"
|
|
77
|
+
if pattern in ACTION_TYPE_MAP:
|
|
78
|
+
return ACTION_TYPE_MAP[pattern]
|
|
79
|
+
|
|
80
|
+
return "inspect" # default: read-only
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
# ---------------------------------------------------------------------------
|
|
84
|
+
# §3 — Composed Actions via Edges
|
|
85
|
+
# ---------------------------------------------------------------------------
|
|
86
|
+
|
|
87
|
+
def _load_ck_yaml(ck_dir):
|
|
88
|
+
"""Load conceptkernel.yaml, return (data, error)."""
|
|
89
|
+
yaml_path = os.path.join(ck_dir, "conceptkernel.yaml")
|
|
90
|
+
if not os.path.isfile(yaml_path):
|
|
91
|
+
return None, "conceptkernel.yaml not found"
|
|
92
|
+
try:
|
|
93
|
+
with open(yaml_path) as f:
|
|
94
|
+
return yaml.safe_load(f), None
|
|
95
|
+
except Exception as e:
|
|
96
|
+
return None, str(e)
|
|
97
|
+
|
|
98
|
+
|
|
99
|
+
def _get_actions_from_spec(data):
|
|
100
|
+
"""Extract action names from spec.actions (common + unique)."""
|
|
101
|
+
actions = {}
|
|
102
|
+
spec_actions = data.get("spec", {}).get("actions", {})
|
|
103
|
+
for section in ("common", "unique"):
|
|
104
|
+
for act in spec_actions.get(section, []):
|
|
105
|
+
if isinstance(act, dict) and "name" in act:
|
|
106
|
+
actions[act["name"]] = act
|
|
107
|
+
return actions
|
|
108
|
+
|
|
109
|
+
|
|
110
|
+
def _find_ck_dir_by_name(target_name, concepts_dir):
|
|
111
|
+
"""Find a CK directory by its metadata.name."""
|
|
112
|
+
if not os.path.isdir(concepts_dir):
|
|
113
|
+
return None
|
|
114
|
+
for entry in os.listdir(concepts_dir):
|
|
115
|
+
candidate = os.path.join(concepts_dir, entry)
|
|
116
|
+
if not os.path.isdir(candidate):
|
|
117
|
+
continue
|
|
118
|
+
yaml_path = os.path.join(candidate, "conceptkernel.yaml")
|
|
119
|
+
if not os.path.isfile(yaml_path):
|
|
120
|
+
continue
|
|
121
|
+
try:
|
|
122
|
+
with open(yaml_path) as f:
|
|
123
|
+
d = yaml.safe_load(f)
|
|
124
|
+
if isinstance(d, dict) and d.get("metadata", {}).get("name") == target_name:
|
|
125
|
+
return candidate
|
|
126
|
+
except Exception:
|
|
127
|
+
continue
|
|
128
|
+
return None
|
|
129
|
+
|
|
130
|
+
|
|
131
|
+
def resolve_composed_actions(ck_dir):
|
|
132
|
+
"""Walk COMPOSES/EXTENDS edges, collect target kernel actions.
|
|
133
|
+
|
|
134
|
+
Returns dict: {action_name: target_kernel_name}
|
|
135
|
+
Only follows COMPOSES and EXTENDS edges (not TRIGGERS/PRODUCES/LOOPS_WITH).
|
|
136
|
+
"""
|
|
137
|
+
ck_dir = os.path.abspath(ck_dir)
|
|
138
|
+
data, err = _load_ck_yaml(ck_dir)
|
|
139
|
+
if err:
|
|
140
|
+
return {}
|
|
141
|
+
|
|
142
|
+
# Derive concepts_dir
|
|
143
|
+
concepts_dir = os.path.dirname(ck_dir)
|
|
144
|
+
|
|
145
|
+
edges_section = data.get("spec", {}).get("edges", data.get("edges", {}))
|
|
146
|
+
outbound = edges_section.get("outbound", [])
|
|
147
|
+
|
|
148
|
+
composed = {}
|
|
149
|
+
for edge in outbound:
|
|
150
|
+
if not isinstance(edge, dict):
|
|
151
|
+
continue
|
|
152
|
+
predicate = edge.get("predicate", "")
|
|
153
|
+
if predicate not in ("COMPOSES", "EXTENDS"):
|
|
154
|
+
continue
|
|
155
|
+
|
|
156
|
+
target_name = edge.get("target_kernel", "")
|
|
157
|
+
if not target_name:
|
|
158
|
+
continue
|
|
159
|
+
|
|
160
|
+
target_dir = _find_ck_dir_by_name(target_name, concepts_dir)
|
|
161
|
+
if not target_dir:
|
|
162
|
+
continue
|
|
163
|
+
|
|
164
|
+
target_data, terr = _load_ck_yaml(target_dir)
|
|
165
|
+
if terr:
|
|
166
|
+
continue
|
|
167
|
+
|
|
168
|
+
target_actions = _get_actions_from_spec(target_data)
|
|
169
|
+
for action_name in target_actions:
|
|
170
|
+
# Don't override — first edge wins
|
|
171
|
+
if action_name not in composed:
|
|
172
|
+
composed[action_name] = target_name
|
|
173
|
+
|
|
174
|
+
return composed
|
|
175
|
+
|
|
176
|
+
|
|
177
|
+
def get_effective_actions(ck_dir):
|
|
178
|
+
"""Return the full effective action set: own + composed.
|
|
179
|
+
|
|
180
|
+
Returns dict: {action_name: {"source": "own"|kernel_name, "spec": action_dict}}
|
|
181
|
+
"""
|
|
182
|
+
ck_dir = os.path.abspath(ck_dir)
|
|
183
|
+
data, err = _load_ck_yaml(ck_dir)
|
|
184
|
+
if err:
|
|
185
|
+
return {}
|
|
186
|
+
|
|
187
|
+
effective = {}
|
|
188
|
+
|
|
189
|
+
# Own actions
|
|
190
|
+
own_actions = _get_actions_from_spec(data)
|
|
191
|
+
for name, spec in own_actions.items():
|
|
192
|
+
effective[name] = {"source": "own", "spec": spec}
|
|
193
|
+
|
|
194
|
+
# Composed actions
|
|
195
|
+
composed = resolve_composed_actions(ck_dir)
|
|
196
|
+
for name, kernel_name in composed.items():
|
|
197
|
+
if name not in effective:
|
|
198
|
+
effective[name] = {"source": kernel_name, "spec": {}}
|
|
199
|
+
|
|
200
|
+
return effective
|
|
201
|
+
|
|
202
|
+
|
|
203
|
+
# ---------------------------------------------------------------------------
|
|
204
|
+
# RBAC — Access Level Enforcement
|
|
205
|
+
# ---------------------------------------------------------------------------
|
|
206
|
+
|
|
207
|
+
VALID_ACCESS_LEVELS = {"anon", "auth", "owner"}
|
|
208
|
+
|
|
209
|
+
|
|
210
|
+
def check_access(action_name, access_level, caller_claims, grants=None):
|
|
211
|
+
"""Verify caller has permission for action based on access level + grants.
|
|
212
|
+
|
|
213
|
+
Args:
|
|
214
|
+
action_name: the action being invoked
|
|
215
|
+
access_level: "anon" | "auth" | "owner" from spec.actions
|
|
216
|
+
caller_claims: JWT claims dict (empty for anonymous)
|
|
217
|
+
grants: optional list of grant dicts from conceptkernel.yaml
|
|
218
|
+
|
|
219
|
+
Returns:
|
|
220
|
+
True if access is allowed, False otherwise.
|
|
221
|
+
"""
|
|
222
|
+
if access_level not in VALID_ACCESS_LEVELS:
|
|
223
|
+
return False
|
|
224
|
+
|
|
225
|
+
# anon — always allowed
|
|
226
|
+
if access_level == "anon":
|
|
227
|
+
return True
|
|
228
|
+
|
|
229
|
+
# auth — caller must have a subject
|
|
230
|
+
if not caller_claims or not caller_claims.get("sub"):
|
|
231
|
+
return False
|
|
232
|
+
|
|
233
|
+
if access_level == "auth":
|
|
234
|
+
# If grants exist, check if caller's identity matches any grant
|
|
235
|
+
if grants:
|
|
236
|
+
return _check_grants(action_name, caller_claims, grants)
|
|
237
|
+
# No grants = any authenticated user
|
|
238
|
+
return True
|
|
239
|
+
|
|
240
|
+
# owner — caller must match kernel owner or have admin role
|
|
241
|
+
if access_level == "owner":
|
|
242
|
+
roles = caller_claims.get("realm_access", {}).get("roles", [])
|
|
243
|
+
if "admin" in roles:
|
|
244
|
+
return True
|
|
245
|
+
# Check grants for owner-level access
|
|
246
|
+
if grants:
|
|
247
|
+
return _check_grants(action_name, caller_claims, grants)
|
|
248
|
+
return False
|
|
249
|
+
|
|
250
|
+
return False
|
|
251
|
+
|
|
252
|
+
|
|
253
|
+
def _check_grants(action_name, caller_claims, grants):
|
|
254
|
+
"""Check if caller matches any grant entry for the given action."""
|
|
255
|
+
if not isinstance(grants, list):
|
|
256
|
+
grants = [grants]
|
|
257
|
+
|
|
258
|
+
caller_sub = caller_claims.get("sub", "")
|
|
259
|
+
caller_user = caller_claims.get("preferred_username", "")
|
|
260
|
+
|
|
261
|
+
for grant in grants:
|
|
262
|
+
if not isinstance(grant, dict):
|
|
263
|
+
continue
|
|
264
|
+
|
|
265
|
+
identity = grant.get("identity", "")
|
|
266
|
+
actions = grant.get("actions", [])
|
|
267
|
+
|
|
268
|
+
# Check identity match
|
|
269
|
+
if identity == "*" or identity == caller_sub or identity == caller_user:
|
|
270
|
+
# Check action match
|
|
271
|
+
if isinstance(actions, list):
|
|
272
|
+
if "*" in actions or action_name in actions:
|
|
273
|
+
return True
|
|
274
|
+
elif actions == "*" or actions == action_name:
|
|
275
|
+
return True
|
|
276
|
+
|
|
277
|
+
return False
|
cklib/auth.py
ADDED
|
@@ -0,0 +1,98 @@
|
|
|
1
|
+
"""Shared auth — JWKS + X-Token for CK HTTP endpoints.
|
|
2
|
+
|
|
3
|
+
Loads Keycloak JWKS once at startup. Supports dual auth:
|
|
4
|
+
- X-Token header or ?token= query param (for API clients like Claude voice)
|
|
5
|
+
- Authorization: Bearer <jwt> (for browser/Keycloak-authenticated users)
|
|
6
|
+
|
|
7
|
+
Usage:
|
|
8
|
+
from cklib.auth import CKAuth
|
|
9
|
+
|
|
10
|
+
auth = CKAuth(api_token="my-secret")
|
|
11
|
+
await auth.load_jwks()
|
|
12
|
+
claims = auth.authenticate(request)
|
|
13
|
+
"""
|
|
14
|
+
import os
|
|
15
|
+
|
|
16
|
+
import httpx
|
|
17
|
+
import jwt as pyjwt
|
|
18
|
+
|
|
19
|
+
KEYCLOAK_BASE = "https://id.tech.games"
|
|
20
|
+
KEYCLOAK_REALM = "techgames"
|
|
21
|
+
JWKS_URL = f"{KEYCLOAK_BASE}/realms/{KEYCLOAK_REALM}/protocol/openid-connect/certs"
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
class CKAuth:
|
|
25
|
+
"""Dual auth: static API token + Keycloak JWT."""
|
|
26
|
+
|
|
27
|
+
def __init__(self, api_token=None):
|
|
28
|
+
self.api_token = api_token or os.environ.get("CK_API_TOKEN")
|
|
29
|
+
self._jwks_keys = None
|
|
30
|
+
|
|
31
|
+
async def load_jwks(self):
|
|
32
|
+
"""Fetch JWKS from Keycloak once at startup."""
|
|
33
|
+
try:
|
|
34
|
+
async with httpx.AsyncClient(verify=True, timeout=15) as client:
|
|
35
|
+
resp = await client.get(JWKS_URL)
|
|
36
|
+
resp.raise_for_status()
|
|
37
|
+
data = resp.json()
|
|
38
|
+
self._jwks_keys = {k["kid"]: k for k in data.get("keys", [])}
|
|
39
|
+
return len(self._jwks_keys)
|
|
40
|
+
except Exception as e:
|
|
41
|
+
print(f"[auth] JWKS load failed: {e}")
|
|
42
|
+
self._jwks_keys = {}
|
|
43
|
+
return 0
|
|
44
|
+
|
|
45
|
+
@property
|
|
46
|
+
def jwks_loaded(self):
|
|
47
|
+
return self._jwks_keys is not None and len(self._jwks_keys) > 0
|
|
48
|
+
|
|
49
|
+
def authenticate(self, request) -> dict:
|
|
50
|
+
"""Authenticate request. Returns claims dict or raises HTTPException."""
|
|
51
|
+
from fastapi import HTTPException
|
|
52
|
+
|
|
53
|
+
# Check X-Token first (API client path)
|
|
54
|
+
x_token = (request.headers.get("X-Token")
|
|
55
|
+
or request.query_params.get("token"))
|
|
56
|
+
if x_token:
|
|
57
|
+
if not self.api_token:
|
|
58
|
+
raise HTTPException(401, "API token not configured")
|
|
59
|
+
if x_token != self.api_token:
|
|
60
|
+
raise HTTPException(401, "Invalid API token")
|
|
61
|
+
return {"preferred_username": "api-client", "sub": "api-client"}
|
|
62
|
+
|
|
63
|
+
# Fall back to JWT
|
|
64
|
+
auth_header = request.headers.get("Authorization", "")
|
|
65
|
+
if not auth_header.startswith("Bearer "):
|
|
66
|
+
raise HTTPException(401, "Missing X-Token or Bearer token")
|
|
67
|
+
|
|
68
|
+
token = auth_header[7:]
|
|
69
|
+
return self._verify_jwt(token)
|
|
70
|
+
|
|
71
|
+
def _verify_jwt(self, token: str) -> dict:
|
|
72
|
+
from fastapi import HTTPException
|
|
73
|
+
|
|
74
|
+
if not self._jwks_keys:
|
|
75
|
+
raise HTTPException(401, "JWKS not loaded")
|
|
76
|
+
|
|
77
|
+
try:
|
|
78
|
+
header = pyjwt.get_unverified_header(token)
|
|
79
|
+
except pyjwt.DecodeError:
|
|
80
|
+
raise HTTPException(401, "Invalid token")
|
|
81
|
+
|
|
82
|
+
kid = header.get("kid")
|
|
83
|
+
if kid not in self._jwks_keys:
|
|
84
|
+
raise HTTPException(401, "Unknown key ID")
|
|
85
|
+
|
|
86
|
+
jwk_data = self._jwks_keys[kid]
|
|
87
|
+
alg = header.get("alg", jwk_data.get("alg", "RS256"))
|
|
88
|
+
public_key = pyjwt.algorithms.RSAAlgorithm.from_jwk(jwk_data)
|
|
89
|
+
|
|
90
|
+
try:
|
|
91
|
+
return pyjwt.decode(
|
|
92
|
+
token, public_key, algorithms=[alg],
|
|
93
|
+
options={"verify_aud": False},
|
|
94
|
+
)
|
|
95
|
+
except pyjwt.ExpiredSignatureError:
|
|
96
|
+
raise HTTPException(401, "Token expired")
|
|
97
|
+
except pyjwt.InvalidTokenError as e:
|
|
98
|
+
raise HTTPException(401, f"Invalid token: {e}")
|
cklib/dispatch.py
ADDED
|
@@ -0,0 +1,179 @@
|
|
|
1
|
+
"""Action dispatch — send actions to kernels locally or via queue.
|
|
2
|
+
|
|
3
|
+
All actions go through the queue. The queue processes FIFO locally.
|
|
4
|
+
|
|
5
|
+
Usage:
|
|
6
|
+
from cklib.dispatch import send_action, queue_action, run_queue
|
|
7
|
+
|
|
8
|
+
# Immediate (bypasses queue for inspect/query actions):
|
|
9
|
+
result = send_action("CK.Task", "status")
|
|
10
|
+
|
|
11
|
+
# Queued (for operate/mutate/check/deploy actions):
|
|
12
|
+
queue_action("CK.Task", "task.create", data={...}, persona="builder")
|
|
13
|
+
|
|
14
|
+
# Process queue:
|
|
15
|
+
run_queue()
|
|
16
|
+
"""
|
|
17
|
+
import importlib.util
|
|
18
|
+
import json
|
|
19
|
+
import os
|
|
20
|
+
import time
|
|
21
|
+
import glob
|
|
22
|
+
|
|
23
|
+
PROJECT_ROOT = None
|
|
24
|
+
CONCEPTS_DIR = None
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
def _init_paths():
|
|
28
|
+
global PROJECT_ROOT, CONCEPTS_DIR
|
|
29
|
+
if PROJECT_ROOT is None:
|
|
30
|
+
PROJECT_ROOT = os.path.abspath(
|
|
31
|
+
os.path.join(os.path.dirname(__file__), "..", "..", "..", ".."))
|
|
32
|
+
CONCEPTS_DIR = os.path.join(PROJECT_ROOT, "concepts")
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
def resolve_kernel(name):
|
|
36
|
+
"""Resolve kernel name to directory + processor path."""
|
|
37
|
+
_init_paths()
|
|
38
|
+
for prefix in ["", "CK.", "TG.", "LOCAL.", "Test."]:
|
|
39
|
+
ck_dir = os.path.join(CONCEPTS_DIR, prefix + name)
|
|
40
|
+
proc = os.path.join(ck_dir, "tool", "processor.py")
|
|
41
|
+
if os.path.isfile(proc):
|
|
42
|
+
return ck_dir, proc
|
|
43
|
+
return None, None
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
def load_handler(processor_path):
|
|
47
|
+
"""Import handle_message from a processor.py."""
|
|
48
|
+
spec = importlib.util.spec_from_file_location("processor", processor_path)
|
|
49
|
+
mod = importlib.util.module_from_spec(spec)
|
|
50
|
+
old_cwd = os.getcwd()
|
|
51
|
+
os.chdir(os.path.dirname(processor_path))
|
|
52
|
+
try:
|
|
53
|
+
spec.loader.exec_module(mod)
|
|
54
|
+
finally:
|
|
55
|
+
os.chdir(old_cwd)
|
|
56
|
+
return getattr(mod, "handle_message", None)
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
def send_action(kernel_name, action, data=None):
|
|
60
|
+
"""Send action directly — no queue. For inspect/query (stateless reads)."""
|
|
61
|
+
ck_dir, proc_path = resolve_kernel(kernel_name)
|
|
62
|
+
if not proc_path:
|
|
63
|
+
return {"status": "error", "message": "kernel not found: %s" % kernel_name}
|
|
64
|
+
|
|
65
|
+
handler = load_handler(proc_path)
|
|
66
|
+
if not handler:
|
|
67
|
+
return {"status": "error", "message": "no handle_message in %s" % kernel_name}
|
|
68
|
+
|
|
69
|
+
msg = {"action": action}
|
|
70
|
+
if data:
|
|
71
|
+
msg["data"] = data
|
|
72
|
+
|
|
73
|
+
try:
|
|
74
|
+
return handler(msg)
|
|
75
|
+
except Exception as e:
|
|
76
|
+
return {"status": "error", "message": str(e), "kernel": kernel_name}
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
# ---------------------------------------------------------------------------
|
|
80
|
+
# Queue — FIFO for local execution
|
|
81
|
+
# ---------------------------------------------------------------------------
|
|
82
|
+
|
|
83
|
+
def _queue_dir():
|
|
84
|
+
_init_paths()
|
|
85
|
+
qdir = os.path.join(CONCEPTS_DIR, "LOCAL.ClaudeCode", "storage", "queue")
|
|
86
|
+
os.makedirs(qdir, exist_ok=True)
|
|
87
|
+
return qdir
|
|
88
|
+
|
|
89
|
+
|
|
90
|
+
def queue_action(kernel_name, action, data=None, persona="operator",
|
|
91
|
+
task_id="", goal_id=""):
|
|
92
|
+
"""Add an action to the FIFO queue."""
|
|
93
|
+
qdir = _queue_dir()
|
|
94
|
+
ts = int(time.time() * 1000) # millisecond precision for ordering
|
|
95
|
+
seq = len(glob.glob(os.path.join(qdir, "*.json"))) + 1
|
|
96
|
+
|
|
97
|
+
entry = {
|
|
98
|
+
"seq": seq,
|
|
99
|
+
"kernel": kernel_name,
|
|
100
|
+
"action": action,
|
|
101
|
+
"data": data or {},
|
|
102
|
+
"persona": persona,
|
|
103
|
+
"task_id": task_id,
|
|
104
|
+
"goal_id": goal_id,
|
|
105
|
+
"queued_at": time.strftime("%Y-%m-%dT%H:%M:%SZ", time.gmtime()),
|
|
106
|
+
"status": "pending",
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
filename = "%03d-%s-%s-%d.json" % (seq, kernel_name.replace(".", "-"), action.replace(".", "-"), ts)
|
|
110
|
+
with open(os.path.join(qdir, filename), "w") as f:
|
|
111
|
+
json.dump(entry, f, indent=2)
|
|
112
|
+
|
|
113
|
+
return entry
|
|
114
|
+
|
|
115
|
+
|
|
116
|
+
def list_queue():
|
|
117
|
+
"""List all pending queue entries in FIFO order."""
|
|
118
|
+
qdir = _queue_dir()
|
|
119
|
+
entries = []
|
|
120
|
+
for fname in sorted(os.listdir(qdir)):
|
|
121
|
+
if fname.endswith(".json"):
|
|
122
|
+
with open(os.path.join(qdir, fname)) as f:
|
|
123
|
+
entry = json.load(f)
|
|
124
|
+
entry["_file"] = fname
|
|
125
|
+
entries.append(entry)
|
|
126
|
+
return [e for e in entries if e.get("status") == "pending"]
|
|
127
|
+
|
|
128
|
+
|
|
129
|
+
def run_queue():
|
|
130
|
+
"""Process the queue FIFO — one at a time."""
|
|
131
|
+
_init_paths()
|
|
132
|
+
lock_path = os.path.join(CONCEPTS_DIR, "LOCAL.ClaudeCode", "storage", "lock")
|
|
133
|
+
|
|
134
|
+
# Check lock
|
|
135
|
+
if os.path.exists(lock_path):
|
|
136
|
+
with open(lock_path) as f:
|
|
137
|
+
pid = f.read().strip()
|
|
138
|
+
# Check if process still running
|
|
139
|
+
try:
|
|
140
|
+
os.kill(int(pid), 0)
|
|
141
|
+
return {"status": "locked", "pid": pid, "message": "Queue runner already active"}
|
|
142
|
+
except (OSError, ValueError):
|
|
143
|
+
os.remove(lock_path)
|
|
144
|
+
|
|
145
|
+
# Acquire lock
|
|
146
|
+
with open(lock_path, "w") as f:
|
|
147
|
+
f.write(str(os.getpid()))
|
|
148
|
+
|
|
149
|
+
try:
|
|
150
|
+
pending = list_queue()
|
|
151
|
+
results = []
|
|
152
|
+
total = len(pending)
|
|
153
|
+
|
|
154
|
+
for i, entry in enumerate(pending, 1):
|
|
155
|
+
kernel = entry["kernel"]
|
|
156
|
+
action = entry["action"]
|
|
157
|
+
persona = entry.get("persona", "operator")
|
|
158
|
+
|
|
159
|
+
print("[%d/%d] %s → %s (%s)" % (i, total, entry.get("task_id") or action, kernel, persona))
|
|
160
|
+
|
|
161
|
+
result = send_action(kernel, action, entry.get("data"))
|
|
162
|
+
results.append({"entry": entry, "result": result})
|
|
163
|
+
|
|
164
|
+
# Mark as completed
|
|
165
|
+
entry["status"] = "completed"
|
|
166
|
+
entry["completed_at"] = time.strftime("%Y-%m-%dT%H:%M:%SZ", time.gmtime())
|
|
167
|
+
entry["result_status"] = result.get("status", "unknown")
|
|
168
|
+
|
|
169
|
+
qpath = os.path.join(_queue_dir(), entry["_file"])
|
|
170
|
+
with open(qpath, "w") as f:
|
|
171
|
+
json.dump(entry, f, indent=2)
|
|
172
|
+
|
|
173
|
+
status = result.get("status", "?")
|
|
174
|
+
print(" %s" % status)
|
|
175
|
+
|
|
176
|
+
return {"status": "ok", "processed": len(results), "results": results}
|
|
177
|
+
finally:
|
|
178
|
+
if os.path.exists(lock_path):
|
|
179
|
+
os.remove(lock_path)
|
cklib/entities.py
ADDED
|
@@ -0,0 +1,95 @@
|
|
|
1
|
+
"""EntityManager — shared in-memory entity store with code-based lookup.
|
|
2
|
+
|
|
3
|
+
Extracted from VoiceScreen (_sessions) and CS.Voting (_rooms) patterns.
|
|
4
|
+
Both use: dict store + reverse code index + status tracking + 4-char codes.
|
|
5
|
+
|
|
6
|
+
Usage:
|
|
7
|
+
from cklib.entities import EntityManager
|
|
8
|
+
|
|
9
|
+
rooms = EntityManager(prefix="room")
|
|
10
|
+
room_id, room = rooms.create(owner="alice", name="Quick Vote")
|
|
11
|
+
room_id, room = rooms.get_by_code("AB12")
|
|
12
|
+
rooms.update_status(room_id, "closed")
|
|
13
|
+
"""
|
|
14
|
+
import time
|
|
15
|
+
import uuid
|
|
16
|
+
from datetime import datetime, timezone
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
def generate_code(length=4):
|
|
20
|
+
"""Generate a random uppercase hex code (4 chars by default)."""
|
|
21
|
+
return uuid.uuid4().hex[:length].upper()
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
def generate_entity_id(prefix, code):
|
|
25
|
+
"""Generate a timestamped entity ID: {prefix}-{epoch}-{code}."""
|
|
26
|
+
return f"{prefix}-{int(time.time())}-{code.lower()}"
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
class EntityManager:
|
|
30
|
+
"""In-memory entity store with code-based lookup.
|
|
31
|
+
|
|
32
|
+
Entities are dicts with at least: code, status, created_at.
|
|
33
|
+
Additional fields come from create() kwargs.
|
|
34
|
+
"""
|
|
35
|
+
|
|
36
|
+
def __init__(self, prefix="entity"):
|
|
37
|
+
self.prefix = prefix
|
|
38
|
+
self._store = {} # entity_id → entity dict
|
|
39
|
+
self._by_code = {} # CODE → entity_id
|
|
40
|
+
|
|
41
|
+
def create(self, **fields):
|
|
42
|
+
"""Create a new entity. Returns (entity_id, entity_dict).
|
|
43
|
+
|
|
44
|
+
Always generates: code, status='active', created_at.
|
|
45
|
+
Extra fields are merged in.
|
|
46
|
+
"""
|
|
47
|
+
code = generate_code()
|
|
48
|
+
entity_id = generate_entity_id(self.prefix, code)
|
|
49
|
+
entity = {
|
|
50
|
+
"code": code,
|
|
51
|
+
"status": "active",
|
|
52
|
+
"created_at": datetime.now(timezone.utc).strftime("%Y-%m-%dT%H:%M:%SZ"),
|
|
53
|
+
**fields,
|
|
54
|
+
}
|
|
55
|
+
self._store[entity_id] = entity
|
|
56
|
+
self._by_code[code] = entity_id
|
|
57
|
+
return entity_id, entity
|
|
58
|
+
|
|
59
|
+
def get(self, entity_id):
|
|
60
|
+
"""Get entity by ID. Returns entity dict or None."""
|
|
61
|
+
return self._store.get(entity_id)
|
|
62
|
+
|
|
63
|
+
def get_by_code(self, code):
|
|
64
|
+
"""Look up entity by code. Returns (entity_id, entity_dict) or (None, None)."""
|
|
65
|
+
code = code.upper()
|
|
66
|
+
entity_id = self._by_code.get(code)
|
|
67
|
+
if entity_id and entity_id in self._store:
|
|
68
|
+
return entity_id, self._store[entity_id]
|
|
69
|
+
return None, None
|
|
70
|
+
|
|
71
|
+
def resolve_or_404(self, code):
|
|
72
|
+
"""Look up entity by code, raise FastAPI 404 if not found."""
|
|
73
|
+
from fastapi import HTTPException
|
|
74
|
+
entity_id, entity = self.get_by_code(code)
|
|
75
|
+
if not entity_id:
|
|
76
|
+
raise HTTPException(404, f"No {self.prefix} for code {code}")
|
|
77
|
+
return entity_id, entity
|
|
78
|
+
|
|
79
|
+
def update_status(self, entity_id, status):
|
|
80
|
+
"""Update an entity's status field."""
|
|
81
|
+
entity = self._store.get(entity_id)
|
|
82
|
+
if entity:
|
|
83
|
+
entity["status"] = status
|
|
84
|
+
return entity
|
|
85
|
+
|
|
86
|
+
def list_active(self):
|
|
87
|
+
"""Return list of (entity_id, entity) where status == 'active'."""
|
|
88
|
+
return [
|
|
89
|
+
(eid, e) for eid, e in self._store.items()
|
|
90
|
+
if e.get("status") == "active"
|
|
91
|
+
]
|
|
92
|
+
|
|
93
|
+
def list_all(self):
|
|
94
|
+
"""Return all (entity_id, entity) pairs."""
|
|
95
|
+
return list(self._store.items())
|