@a9i5k4/dsh-auto-memory 2.1.5 → 2.1.7
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.
- package/lib/client.js +2 -2
- package/lib/python-setup.js +5 -5
- package/package.json +6 -3
- package/python/m7_activation_features_v2.py +392 -0
- package/python/m7_embedding_v1.py +381 -0
- package/python/policies/activation_policy_v2.json +88 -0
- package/python/policies/decision-record-activation-v2-delta-exp-override-20260824.json +21 -0
- package/python/policies/decision-record-reasoning-kind-admission-20260826.json +20 -0
- package/python/policies/decision-record-stale-gate-per-candidate-20260825.json +33 -0
- package/python/policies/recall_intent_lr_v1.json +1 -0
- package/python/verify_policy_artifact.py +122 -0
- package/python/worker_semantic_v1.py +1344 -0
- package/python/worker_v1.py +623 -0
|
@@ -0,0 +1,623 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
# -*- coding: utf-8 -*-
|
|
3
|
+
"""M7-0/M7-1 deterministic fake Python sidecar worker (docs/PYTHON-SIDECAR-CONTRACT.md).
|
|
4
|
+
|
|
5
|
+
Discipline:
|
|
6
|
+
- Python standard library ONLY; no third-party dependencies.
|
|
7
|
+
- stdin/stdout: one UTF-8 JSON object per line; stdout carries PROTOCOL FRAMES ONLY;
|
|
8
|
+
bounded diagnostics go to stderr.
|
|
9
|
+
- Deterministic: identical fixture input produces byte-identical output frames
|
|
10
|
+
(no wall clock, no randomness; sentAt/frameId are derived from the request).
|
|
11
|
+
- No HTTP listener; no reads of DSH files/Markdown/sidecars/session logs/workspace files.
|
|
12
|
+
- The ONLY filesystem write is the rebuildable derived corpus under
|
|
13
|
+
<dsh-home>/memory/semantic/ (path supplied explicitly by JS via --dsh-home;
|
|
14
|
+
never discovered). Atomic switch = temp file + os.replace.
|
|
15
|
+
- Creates no evidence, no ReferenceTailPacket, no prompt text.
|
|
16
|
+
"""
|
|
17
|
+
|
|
18
|
+
import argparse
|
|
19
|
+
import hashlib
|
|
20
|
+
import json
|
|
21
|
+
import os
|
|
22
|
+
import re
|
|
23
|
+
import sys
|
|
24
|
+
import tempfile
|
|
25
|
+
|
|
26
|
+
PROTOCOL = 'm7_wire_v1'
|
|
27
|
+
NAMESPACE = 'dsh-auto-memory'
|
|
28
|
+
INDEX_POLICY = 'index_sync_v1'
|
|
29
|
+
MAX_LINE_BYTES = 256 * 1024
|
|
30
|
+
MAX_RECORDS_PER_PAGE = 64
|
|
31
|
+
STDERR_BUDGET = 64
|
|
32
|
+
|
|
33
|
+
JS_TYPES = frozenset(['health', 'context_push', 'index_sync_begin', 'index_sync_page',
|
|
34
|
+
'index_sync_commit', 'cancel', 'close_session'])
|
|
35
|
+
PY_TYPES = frozenset(['health_result', 'context_ack', 'index_ack', 'activation_request', 'error'])
|
|
36
|
+
|
|
37
|
+
RE_MEMORY_ID = re.compile(r'^mem_[0-9a-f]{32}$')
|
|
38
|
+
RE_HEX64 = re.compile(r'^[0-9a-f]{64}$')
|
|
39
|
+
RE_IDX = re.compile(r'^idx_[0-9a-f]{32}$')
|
|
40
|
+
RE_WSR = re.compile(r'^wsr_[0-9a-f]{32}$')
|
|
41
|
+
RE_OBS = re.compile(r'^obs_')
|
|
42
|
+
RE_SOURCE_REF = re.compile(r'^(user|workspace|workspace-log):[A-Za-z0-9._\u4e00-\u9fff-]+$')
|
|
43
|
+
|
|
44
|
+
_stderr_used = {'n': 0}
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
def diag(msg):
|
|
48
|
+
"""Bounded diagnostics: never stdout."""
|
|
49
|
+
if _stderr_used['n'] >= STDERR_BUDGET:
|
|
50
|
+
return
|
|
51
|
+
_stderr_used['n'] += 1
|
|
52
|
+
try:
|
|
53
|
+
sys.stderr.write('[worker_v1] ' + str(msg)[:400] + '\n')
|
|
54
|
+
sys.stderr.flush()
|
|
55
|
+
except Exception:
|
|
56
|
+
pass
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
def first32(h):
|
|
60
|
+
return h[:32]
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
def sha_hex(data):
|
|
64
|
+
return hashlib.sha256(data).hexdigest()
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
def sha_str(s):
|
|
68
|
+
return sha_hex(str(s).encode('utf-8'))
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
def dumps(v):
|
|
72
|
+
return json.dumps(v, ensure_ascii=False, separators=(',', ':'))
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
def canonical(v):
|
|
76
|
+
"""Byte-identical twin of lib/m7-wire.js canonicalJson()."""
|
|
77
|
+
if v is None:
|
|
78
|
+
return 'null'
|
|
79
|
+
if isinstance(v, bool):
|
|
80
|
+
return 'true' if v else 'false'
|
|
81
|
+
if isinstance(v, (int, float, str)):
|
|
82
|
+
return dumps(v)
|
|
83
|
+
if isinstance(v, list):
|
|
84
|
+
return '[' + ','.join(canonical(x) for x in v) + ']'
|
|
85
|
+
if isinstance(v, dict):
|
|
86
|
+
return '{' + ','.join(dumps(str(k)) + ':' + canonical(v[k]) for k in sorted(v.keys())) + '}'
|
|
87
|
+
return 'null'
|
|
88
|
+
|
|
89
|
+
|
|
90
|
+
class ProtocolError(Exception):
|
|
91
|
+
def __init__(self, code, detail=''):
|
|
92
|
+
super().__init__(code + (':' + detail if detail else ''))
|
|
93
|
+
self.code = code
|
|
94
|
+
self.detail = detail
|
|
95
|
+
|
|
96
|
+
|
|
97
|
+
# ---------- payload validators (mirror of lib/m7-wire.js) ----------
|
|
98
|
+
|
|
99
|
+
|
|
100
|
+
def validate_semantic_record(rec):
|
|
101
|
+
if not isinstance(rec, dict):
|
|
102
|
+
raise ProtocolError('invalid-record', 'not-object')
|
|
103
|
+
def need(cond, field):
|
|
104
|
+
if not cond:
|
|
105
|
+
raise ProtocolError('invalid-record', field)
|
|
106
|
+
need(RE_MEMORY_ID.match(str(rec.get('memoryId', ''))), 'memoryId')
|
|
107
|
+
need(isinstance(rec.get('anchorId'), str) and rec.get('anchorId'), 'anchorId')
|
|
108
|
+
need(rec.get('scope') in ('Workspace', 'User'), 'scope')
|
|
109
|
+
need(RE_WSR.match(str(rec.get('workspaceRef', ''))), 'workspaceRef')
|
|
110
|
+
need(RE_SOURCE_REF.match(str(rec.get('sourceRef', ''))), 'sourceRef')
|
|
111
|
+
need(isinstance(rec.get('sourceEpoch'), str) and rec.get('sourceEpoch'), 'sourceEpoch')
|
|
112
|
+
need(isinstance(rec.get('sourceVersion'), int) and not isinstance(rec.get('sourceVersion'), bool)
|
|
113
|
+
and rec.get('sourceVersion') >= 1, 'sourceVersion')
|
|
114
|
+
need(RE_HEX64.match(str(rec.get('fileDigest', ''))), 'fileDigest')
|
|
115
|
+
need(RE_HEX64.match(str(rec.get('recordDigest', ''))), 'recordDigest')
|
|
116
|
+
need(rec.get('heading') is None or isinstance(rec.get('heading'), str), 'heading')
|
|
117
|
+
need(isinstance(rec.get('text'), str), 'text')
|
|
118
|
+
need(isinstance(rec.get('chunkId'), str) and str(rec.get('chunkId')).startswith('chk_'), 'chunkId')
|
|
119
|
+
ordinal = rec.get('chunkOrdinal')
|
|
120
|
+
count = rec.get('chunkCount')
|
|
121
|
+
need(isinstance(ordinal, int) and not isinstance(ordinal, bool) and ordinal >= 0, 'chunkOrdinal')
|
|
122
|
+
need(isinstance(count, int) and not isinstance(count, bool) and count >= 1, 'chunkCount')
|
|
123
|
+
need(ordinal < count, 'chunkOrdinal-range')
|
|
124
|
+
|
|
125
|
+
|
|
126
|
+
def require(cond, code, detail=''):
|
|
127
|
+
if not cond:
|
|
128
|
+
raise ProtocolError(code, detail)
|
|
129
|
+
|
|
130
|
+
|
|
131
|
+
class Worker:
|
|
132
|
+
def __init__(self, expect_epoch, dsh_home):
|
|
133
|
+
self.expect_epoch = str(expect_epoch or '')
|
|
134
|
+
self.dsh_home = str(dsh_home or '')
|
|
135
|
+
self.seen_obs = {} # observationId -> sessionId (idempotence + close_session purge)
|
|
136
|
+
self.active_sync = None # at most one in-flight index sync (JS serializes anyway)
|
|
137
|
+
self.derived = {} # (workspaceRef, scope) -> current committed entry ONLY (old versions discarded)
|
|
138
|
+
self.counts = {'frames': 0, 'errors': 0, 'acks': 0, 'activations': 0, 'commits': 0,
|
|
139
|
+
'cancels': 0, 'close_sessions': 0}
|
|
140
|
+
|
|
141
|
+
# ---------- outbound ----------
|
|
142
|
+
|
|
143
|
+
def _frame(self, req, ftype, payload, fid_prefix='res_'):
|
|
144
|
+
rid = str(req.get('requestId', ''))
|
|
145
|
+
return {
|
|
146
|
+
'protocolVersion': PROTOCOL,
|
|
147
|
+
'frameId': fid_prefix + first32(sha_str(rid + ':' + ftype)),
|
|
148
|
+
'requestId': rid,
|
|
149
|
+
'workerEpoch': str(req.get('workerEpoch', '')),
|
|
150
|
+
'type': ftype,
|
|
151
|
+
'payload': payload,
|
|
152
|
+
'sentAt': req.get('sentAt', 0),
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
def error_frame(self, req, code, detail=''):
|
|
156
|
+
payload = {'code': code, 'reason': code + (':' + detail if detail else '')}
|
|
157
|
+
return self._frame(req, 'error', payload, fid_prefix='err_')
|
|
158
|
+
|
|
159
|
+
# ---------- health ----------
|
|
160
|
+
|
|
161
|
+
def corpus_view(self):
|
|
162
|
+
view = []
|
|
163
|
+
for (ws_ref, scope) in sorted(self.derived.keys()):
|
|
164
|
+
e = self.derived[(ws_ref, scope)]
|
|
165
|
+
view.append({'workspaceRef': ws_ref, 'scope': scope,
|
|
166
|
+
'memoryIndexVersion': e['memoryIndexVersion'],
|
|
167
|
+
'recordCount': e['recordCount']})
|
|
168
|
+
return view
|
|
169
|
+
|
|
170
|
+
def handle_health(self, req):
|
|
171
|
+
payload = {
|
|
172
|
+
'protocol': PROTOCOL,
|
|
173
|
+
'worker': 'fake',
|
|
174
|
+
'capabilities': ['fake-deterministic'],
|
|
175
|
+
'indexPolicyVersion': INDEX_POLICY,
|
|
176
|
+
'corpus': self.corpus_view(),
|
|
177
|
+
'counts': dict(self.counts),
|
|
178
|
+
}
|
|
179
|
+
return [self._frame(req, 'health_result', payload)]
|
|
180
|
+
|
|
181
|
+
# ---------- context_push ----------
|
|
182
|
+
|
|
183
|
+
def handle_context_push(self, req):
|
|
184
|
+
p = req.get('payload') or {}
|
|
185
|
+
require(isinstance(p, dict), 'invalid-payload', 'not-object')
|
|
186
|
+
require(p.get('kind') == 'context_push', 'invalid-payload', 'kind')
|
|
187
|
+
obs = str(p.get('observationId', ''))
|
|
188
|
+
require(RE_OBS.match(obs), 'invalid-payload', 'observationId')
|
|
189
|
+
session = p.get('session') if isinstance(p.get('session'), dict) else {}
|
|
190
|
+
sid = str((session or {}).get('sessionId', ''))
|
|
191
|
+
ack_payload = {'schemaVersion': 1, 'observationId': obs}
|
|
192
|
+
if obs in self.seen_obs:
|
|
193
|
+
ack_payload['accepted'] = False
|
|
194
|
+
ack_payload['reason'] = 'busy'
|
|
195
|
+
self.counts['acks'] += 1
|
|
196
|
+
return [self._frame(req, 'context_ack', ack_payload)]
|
|
197
|
+
self.seen_obs[obs] = sid
|
|
198
|
+
ack_payload['accepted'] = True
|
|
199
|
+
ack_payload['reason'] = 'ok'
|
|
200
|
+
ack_payload['workerEpoch'] = str(req.get('workerEpoch', ''))
|
|
201
|
+
frames = [self._frame(req, 'context_ack', ack_payload)]
|
|
202
|
+
act = self.maybe_activation(req, p)
|
|
203
|
+
if act is not None:
|
|
204
|
+
frames.append(act)
|
|
205
|
+
self.counts['activations'] += 1
|
|
206
|
+
self.counts['acks'] += 1
|
|
207
|
+
return frames
|
|
208
|
+
|
|
209
|
+
def maybe_activation(self, req, p):
|
|
210
|
+
"""Deterministic fake ActivationRequestPre copied from JS-owned provenance.
|
|
211
|
+
Emits only when the frame carries valid identity + idx_ index version;
|
|
212
|
+
otherwise stays silent (fail closed). Field-compatible with M6 validator."""
|
|
213
|
+
miv = str((p.get('index') or {}).get('memoryIndexVersion', ''))
|
|
214
|
+
if not RE_IDX.match(miv):
|
|
215
|
+
return None
|
|
216
|
+
session = p.get('session') if isinstance(p.get('session'), dict) else {}
|
|
217
|
+
cursor = p.get('cursor') if isinstance(p.get('cursor'), dict) else {}
|
|
218
|
+
sid = str((session or {}).get('sessionId', ''))
|
|
219
|
+
agent = str((session or {}).get('agentId', ''))
|
|
220
|
+
wskey = str((session or {}).get('workspaceKey', ''))
|
|
221
|
+
scope = (session or {}).get('scope')
|
|
222
|
+
cv = cursor.get('contextVersion')
|
|
223
|
+
if not sid or not agent or not wskey or scope not in ('Session', 'Workspace', 'User'):
|
|
224
|
+
return None
|
|
225
|
+
if not isinstance(cv, int) or isinstance(cv, bool) or cv < 0:
|
|
226
|
+
return None
|
|
227
|
+
obs = str(p.get('observationId', ''))
|
|
228
|
+
refs = p.get('memoryRefs') if isinstance(p.get('memoryRefs'), list) else []
|
|
229
|
+
candidates = []
|
|
230
|
+
for i, ref in enumerate(refs[:8]):
|
|
231
|
+
if not isinstance(ref, dict):
|
|
232
|
+
continue
|
|
233
|
+
mid = str(ref.get('memoryId', ''))
|
|
234
|
+
if not RE_MEMORY_ID.match(mid):
|
|
235
|
+
continue
|
|
236
|
+
aid = str(ref.get('anchorId', ''))
|
|
237
|
+
rscope = ref.get('scope')
|
|
238
|
+
sref = str(ref.get('sourceRef', ''))
|
|
239
|
+
sepoch = str(ref.get('sourceEpoch', ''))
|
|
240
|
+
sver = ref.get('sourceVersion')
|
|
241
|
+
fdig = str(ref.get('fileDigest', ''))
|
|
242
|
+
rdig = str(ref.get('recordDigest', ''))
|
|
243
|
+
if not aid or rscope not in ('Workspace', 'User') or not RE_SOURCE_REF.match(sref):
|
|
244
|
+
continue
|
|
245
|
+
if not sepoch or not isinstance(sver, int) or isinstance(sver, bool) or sver < 1:
|
|
246
|
+
continue
|
|
247
|
+
if not RE_HEX64.match(fdig) or not RE_HEX64.match(rdig):
|
|
248
|
+
continue
|
|
249
|
+
activation_id = 'act_' + first32(sha_str('m7-fake-activation-pre-v1\u0000' + obs))
|
|
250
|
+
cand = {
|
|
251
|
+
'candidateId': 'cand_' + first32(sha_str(activation_id + '\u0000' + mid + '\u0000' + str(i))),
|
|
252
|
+
'memoryId': mid,
|
|
253
|
+
'anchorId': aid,
|
|
254
|
+
'scope': rscope,
|
|
255
|
+
'sourceRef': sref,
|
|
256
|
+
'sourceEpoch': sepoch,
|
|
257
|
+
'sourceVersion': sver,
|
|
258
|
+
'fileDigest': fdig,
|
|
259
|
+
'recordDigest': rdig,
|
|
260
|
+
'score': round(0.9 - 0.05 * len(candidates), 4),
|
|
261
|
+
}
|
|
262
|
+
candidates.append(cand)
|
|
263
|
+
if not candidates:
|
|
264
|
+
return None
|
|
265
|
+
activation_id = 'act_' + first32(sha_str('m7-fake-activation-pre-v1\u0000' + obs))
|
|
266
|
+
created = req.get('sentAt', 0)
|
|
267
|
+
ttl_steps = 2
|
|
268
|
+
activation = {
|
|
269
|
+
'schemaVersion': 1,
|
|
270
|
+
'namespace': NAMESPACE,
|
|
271
|
+
'kind': 'activation_request',
|
|
272
|
+
'activationId': activation_id,
|
|
273
|
+
'observationId': obs,
|
|
274
|
+
'workerEpoch': str(req.get('workerEpoch', '')),
|
|
275
|
+
'sessionId': sid,
|
|
276
|
+
'agentId': agent,
|
|
277
|
+
'workspaceKey': wskey,
|
|
278
|
+
'scope': scope,
|
|
279
|
+
'contextVersion': cv,
|
|
280
|
+
'memoryIndexVersion': miv,
|
|
281
|
+
'threshold': {
|
|
282
|
+
'policyVersion': 'm7_fake_threshold_v1',
|
|
283
|
+
'score': 0.92,
|
|
284
|
+
'threshold': 0.8,
|
|
285
|
+
'reason': 'deterministic fake activation (python worker)',
|
|
286
|
+
},
|
|
287
|
+
'level': 'excerpt',
|
|
288
|
+
'candidates': candidates,
|
|
289
|
+
'ttlSteps': ttl_steps,
|
|
290
|
+
'createdAt': created,
|
|
291
|
+
'expiresAt': created + ttl_steps * 60000,
|
|
292
|
+
}
|
|
293
|
+
return self._frame(req, 'activation_request', {'activation': activation}, fid_prefix='act_')
|
|
294
|
+
|
|
295
|
+
# ---------- cancel / close_session (no response frames by contract) ----------
|
|
296
|
+
|
|
297
|
+
def handle_cancel(self, req):
|
|
298
|
+
self.counts['cancels'] += 1
|
|
299
|
+
diag('cancel requestId=' + str(req.get('requestId', '')))
|
|
300
|
+
return []
|
|
301
|
+
|
|
302
|
+
def handle_close_session(self, req):
|
|
303
|
+
p = req.get('payload') or {}
|
|
304
|
+
sid = str(p.get('sessionId', ''))
|
|
305
|
+
if sid:
|
|
306
|
+
for obs in [o for o, s in self.seen_obs.items() if s == sid]:
|
|
307
|
+
del self.seen_obs[obs]
|
|
308
|
+
self.counts['close_sessions'] += 1
|
|
309
|
+
return []
|
|
310
|
+
|
|
311
|
+
# ---------- index_sync (M7-1) ----------
|
|
312
|
+
|
|
313
|
+
def handle_index_begin(self, req):
|
|
314
|
+
p = req.get('payload') or {}
|
|
315
|
+
require(isinstance(p, dict), 'invalid-payload', 'not-object')
|
|
316
|
+
require(p.get('schemaVersion') == 1, 'invalid-payload', 'schemaVersion')
|
|
317
|
+
sync_id = str(p.get('syncId', ''))
|
|
318
|
+
require(sync_id.startswith('syn_'), 'invalid-payload', 'syncId')
|
|
319
|
+
ws_ref = str(p.get('workspaceRef', ''))
|
|
320
|
+
require(RE_WSR.match(ws_ref), 'invalid-payload', 'workspaceRef')
|
|
321
|
+
scope = p.get('scope')
|
|
322
|
+
require(scope in ('Workspace', 'User'), 'invalid-payload', 'scope')
|
|
323
|
+
miv = str(p.get('memoryIndexVersion', ''))
|
|
324
|
+
require(RE_IDX.match(miv), 'invalid-payload', 'memoryIndexVersion')
|
|
325
|
+
tuples = p.get('sourceTuples')
|
|
326
|
+
require(isinstance(tuples, list), 'invalid-payload', 'sourceTuples')
|
|
327
|
+
for t in tuples:
|
|
328
|
+
require(isinstance(t, dict), 'invalid-payload', 'sourceTuples.entry')
|
|
329
|
+
require(RE_SOURCE_REF.match(str(t.get('sourceRef', ''))), 'invalid-payload', 'sourceTuples.sourceRef')
|
|
330
|
+
require(isinstance(t.get('sourceEpoch'), str) and t.get('sourceEpoch'), 'invalid-payload', 'sourceTuples.sourceEpoch')
|
|
331
|
+
sv = t.get('sourceVersion')
|
|
332
|
+
require(isinstance(sv, int) and not isinstance(sv, bool) and sv >= 1, 'invalid-payload', 'sourceTuples.sourceVersion')
|
|
333
|
+
require(RE_HEX64.match(str(t.get('fileDigest', ''))), 'invalid-payload', 'sourceTuples.fileDigest')
|
|
334
|
+
record_count = p.get('recordCount')
|
|
335
|
+
page_count = p.get('pageCount')
|
|
336
|
+
require(isinstance(record_count, int) and not isinstance(record_count, bool) and record_count >= 0,
|
|
337
|
+
'invalid-payload', 'recordCount')
|
|
338
|
+
require(isinstance(page_count, int) and not isinstance(page_count, bool) and page_count >= 0,
|
|
339
|
+
'invalid-payload', 'pageCount')
|
|
340
|
+
require((record_count == 0) == (page_count == 0), 'invalid-payload', 'count-consistency')
|
|
341
|
+
require(p.get('indexPolicyVersion') == INDEX_POLICY, 'invalid-payload', 'indexPolicyVersion')
|
|
342
|
+
if self.active_sync is not None:
|
|
343
|
+
return [self.index_ack(req, p, 'begin', False, reason='sync-in-progress')]
|
|
344
|
+
self.active_sync = {
|
|
345
|
+
'syncId': sync_id, 'workspaceRef': ws_ref, 'scope': scope, 'memoryIndexVersion': miv,
|
|
346
|
+
'recordCount': record_count, 'pageCount': page_count, 'tuples': tuples,
|
|
347
|
+
'pages': {}, 'next_page': 0, 'received': 0, 'page_digests': [],
|
|
348
|
+
}
|
|
349
|
+
return [self.index_ack(req, p, 'begin', True)]
|
|
350
|
+
|
|
351
|
+
def index_ack(self, req, begin_or_page, phase, accepted, reason=None, extra=None):
|
|
352
|
+
src = begin_or_page
|
|
353
|
+
payload = {
|
|
354
|
+
'schemaVersion': 1,
|
|
355
|
+
'syncId': str(src.get('syncId', '')),
|
|
356
|
+
'phase': phase,
|
|
357
|
+
'accepted': bool(accepted),
|
|
358
|
+
'memoryIndexVersion': str(src.get('memoryIndexVersion', '')),
|
|
359
|
+
'workspaceRef': str(src.get('workspaceRef', '')),
|
|
360
|
+
'scope': src.get('scope'),
|
|
361
|
+
}
|
|
362
|
+
if not accepted:
|
|
363
|
+
payload['reason'] = str(reason or 'rejected')
|
|
364
|
+
if extra:
|
|
365
|
+
payload.update(extra)
|
|
366
|
+
self.counts['acks'] += 1
|
|
367
|
+
return self._frame(req, 'index_ack', payload)
|
|
368
|
+
|
|
369
|
+
def reject_sync(self, req, base, phase, reason, extra=None):
|
|
370
|
+
"""整次 sync 拒绝:任一终局失败即作废 active sync(后续同 syncId 帧 → no-active-sync)。"""
|
|
371
|
+
self.active_sync = None
|
|
372
|
+
return [self.index_ack(req, base, phase, False, reason=reason, extra=extra)]
|
|
373
|
+
|
|
374
|
+
def handle_index_page(self, req):
|
|
375
|
+
p = req.get('payload') or {}
|
|
376
|
+
require(isinstance(p, dict), 'invalid-payload', 'not-object')
|
|
377
|
+
st = self.active_sync
|
|
378
|
+
if st is None:
|
|
379
|
+
raise ProtocolError('no-active-sync', 'page-before-begin')
|
|
380
|
+
sync_id = str(p.get('syncId', ''))
|
|
381
|
+
if sync_id != st['syncId']:
|
|
382
|
+
return self.reject_sync(req, st, 'page', 'unknown-sync', extra={'pageNo': p.get('pageNo')})
|
|
383
|
+
base = st
|
|
384
|
+
page_no = p.get('pageNo')
|
|
385
|
+
if not isinstance(page_no, int) or isinstance(page_no, bool) or page_no < 0:
|
|
386
|
+
raise ProtocolError('invalid-payload', 'pageNo')
|
|
387
|
+
if page_no < st['next_page']:
|
|
388
|
+
return self.reject_sync(req, base, 'page', 'page-duplicate', extra={'pageNo': page_no})
|
|
389
|
+
if page_no != st['next_page']:
|
|
390
|
+
return self.reject_sync(req, base, 'page', 'page-out-of-order', extra={'pageNo': page_no})
|
|
391
|
+
if p.get('pageCount') != st['pageCount'] or p.get('schemaVersion') != 1:
|
|
392
|
+
return self.reject_sync(req, base, 'page', 'count-mismatch', extra={'pageNo': page_no})
|
|
393
|
+
# §8.4 页 payload 不含 scope/workspaceRef/memoryIndexVersion 字段:
|
|
394
|
+
# 一致性由 syncId 绑定 begin + 逐条 records 检查 + commit 校验共同承担。
|
|
395
|
+
records = p.get('records')
|
|
396
|
+
require(isinstance(records, list), 'invalid-payload', 'records')
|
|
397
|
+
if len(records) > MAX_RECORDS_PER_PAGE:
|
|
398
|
+
return self.reject_sync(req, base, 'page', 'page-size', extra={'pageNo': page_no})
|
|
399
|
+
if st['received'] + len(records) > st['recordCount']:
|
|
400
|
+
return self.reject_sync(req, base, 'page', 'record-count-mismatch', extra={'pageNo': page_no})
|
|
401
|
+
for r in records:
|
|
402
|
+
validate_semantic_record(r)
|
|
403
|
+
if r.get('workspaceRef') != st['workspaceRef'] or r.get('scope') != st['scope']:
|
|
404
|
+
return self.reject_sync(req, base, 'page', 'record-scope-mismatch', extra={'pageNo': page_no})
|
|
405
|
+
body = dumps(p)
|
|
406
|
+
if len(body.encode('utf-8')) > MAX_LINE_BYTES:
|
|
407
|
+
return self.reject_sync(req, base, 'page', 'page-oversize', extra={'pageNo': page_no})
|
|
408
|
+
recomputed = sha_hex(canonical(records).encode('utf-8'))
|
|
409
|
+
if recomputed != str(p.get('pageDigest', '')):
|
|
410
|
+
return self.reject_sync(req, base, 'page', 'digest-mismatch', extra={'pageNo': page_no})
|
|
411
|
+
st['pages'][page_no] = records
|
|
412
|
+
st['next_page'] = page_no + 1
|
|
413
|
+
st['received'] += len(records)
|
|
414
|
+
st['page_digests'].append(recomputed)
|
|
415
|
+
return [self.index_ack(req, base, 'page', True, extra={
|
|
416
|
+
'pageNo': page_no, 'receivedPages': len(st['pages']), 'receivedRecords': st['received']})]
|
|
417
|
+
|
|
418
|
+
def handle_index_commit(self, req):
|
|
419
|
+
p = req.get('payload') or {}
|
|
420
|
+
require(isinstance(p, dict), 'invalid-payload', 'not-object')
|
|
421
|
+
st = self.active_sync
|
|
422
|
+
if st is None:
|
|
423
|
+
raise ProtocolError('no-active-sync', 'commit-before-begin')
|
|
424
|
+
if str(p.get('syncId', '')) != st['syncId']:
|
|
425
|
+
return self.reject_sync(req, st, 'commit', 'unknown-sync')
|
|
426
|
+
if p.get('schemaVersion') != 1:
|
|
427
|
+
return self.reject_sync(req, st, 'commit', 'count-mismatch')
|
|
428
|
+
if str(p.get('memoryIndexVersion', '')) != st['memoryIndexVersion']:
|
|
429
|
+
return self.reject_sync(req, st, 'commit', 'version-mismatch')
|
|
430
|
+
if len(st['pages']) != st['pageCount']:
|
|
431
|
+
return self.reject_sync(req, st, 'commit', 'missing-page')
|
|
432
|
+
if st['received'] != st['recordCount']:
|
|
433
|
+
return self.reject_sync(req, st, 'commit', 'record-count-mismatch')
|
|
434
|
+
flat = []
|
|
435
|
+
for i in range(st['pageCount']):
|
|
436
|
+
flat.extend(st['pages'][i])
|
|
437
|
+
expected_final = sha_hex(canonical({
|
|
438
|
+
'kind': 'index_sync_final_v1',
|
|
439
|
+
'syncId': st['syncId'],
|
|
440
|
+
'memoryIndexVersion': st['memoryIndexVersion'],
|
|
441
|
+
'workspaceRef': st['workspaceRef'],
|
|
442
|
+
'scope': st['scope'],
|
|
443
|
+
'recordCount': st['recordCount'],
|
|
444
|
+
'pageCount': st['pageCount'],
|
|
445
|
+
'pageDigests': st['page_digests'],
|
|
446
|
+
}).encode('utf-8'))
|
|
447
|
+
if expected_final != str(p.get('finalDigest', '')):
|
|
448
|
+
return self.reject_sync(req, st, 'commit', 'final-digest-mismatch')
|
|
449
|
+
entry = {
|
|
450
|
+
'memoryIndexVersion': st['memoryIndexVersion'],
|
|
451
|
+
'workspaceRef': st['workspaceRef'],
|
|
452
|
+
'scope': st['scope'],
|
|
453
|
+
'recordCount': st['recordCount'],
|
|
454
|
+
'pageDigests': list(st['page_digests']),
|
|
455
|
+
'finalDigest': expected_final,
|
|
456
|
+
'records': flat,
|
|
457
|
+
}
|
|
458
|
+
self.derived[(st['workspaceRef'], st['scope'])] = entry # wholesale swap; old version gone
|
|
459
|
+
persisted = self.persist_derived()
|
|
460
|
+
self.counts['commits'] += 1
|
|
461
|
+
ack = self.index_ack(req, st, 'commit', True, extra={
|
|
462
|
+
'recordCount': st['recordCount'], 'pageCount': st['pageCount'], 'persisted': persisted})
|
|
463
|
+
self.active_sync = None
|
|
464
|
+
return [ack]
|
|
465
|
+
|
|
466
|
+
def persist_derived(self):
|
|
467
|
+
if not self.dsh_home:
|
|
468
|
+
return False
|
|
469
|
+
dir_path = os.path.join(self.dsh_home, 'memory', 'semantic')
|
|
470
|
+
entries = []
|
|
471
|
+
for key in sorted(self.derived.keys()):
|
|
472
|
+
e = self.derived[key]
|
|
473
|
+
entries.append({
|
|
474
|
+
'workspaceRef': e['workspaceRef'], 'scope': e['scope'],
|
|
475
|
+
'memoryIndexVersion': e['memoryIndexVersion'], 'recordCount': e['recordCount'],
|
|
476
|
+
'pageDigests': e['pageDigests'], 'finalDigest': e['finalDigest'],
|
|
477
|
+
'records': e['records'],
|
|
478
|
+
})
|
|
479
|
+
payload = {'schemaVersion': 1, 'namespace': NAMESPACE,
|
|
480
|
+
'policyVersion': 'semantic_derived_v1', 'entries': entries}
|
|
481
|
+
data = (dumps(payload) + '\n').encode('utf-8')
|
|
482
|
+
try:
|
|
483
|
+
os.makedirs(dir_path, exist_ok=True)
|
|
484
|
+
fd, tmp = tempfile.mkstemp(dir=dir_path, prefix='.tmp-derived-', suffix='.json')
|
|
485
|
+
try:
|
|
486
|
+
with os.fdopen(fd, 'wb') as fh:
|
|
487
|
+
fh.write(data)
|
|
488
|
+
fh.flush()
|
|
489
|
+
os.fsync(fh.fileno())
|
|
490
|
+
os.replace(tmp, os.path.join(dir_path, 'derived-corpus.json'))
|
|
491
|
+
except BaseException:
|
|
492
|
+
try:
|
|
493
|
+
os.unlink(tmp)
|
|
494
|
+
except OSError:
|
|
495
|
+
pass
|
|
496
|
+
raise
|
|
497
|
+
return True
|
|
498
|
+
except OSError as exc:
|
|
499
|
+
diag('persist-failed: ' + str(exc))
|
|
500
|
+
return False
|
|
501
|
+
|
|
502
|
+
# ---------- dispatch ----------
|
|
503
|
+
|
|
504
|
+
def handle_frame(self, req):
|
|
505
|
+
self.counts['frames'] += 1
|
|
506
|
+
ftype = req.get('type')
|
|
507
|
+
if ftype == 'health':
|
|
508
|
+
return self.handle_health(req)
|
|
509
|
+
if ftype == 'context_push':
|
|
510
|
+
return self.handle_context_push(req)
|
|
511
|
+
if ftype == 'index_sync_begin':
|
|
512
|
+
return self.handle_index_begin(req)
|
|
513
|
+
if ftype == 'index_sync_page':
|
|
514
|
+
return self.handle_index_page(req)
|
|
515
|
+
if ftype == 'index_sync_commit':
|
|
516
|
+
return self.handle_index_commit(req)
|
|
517
|
+
if ftype == 'cancel':
|
|
518
|
+
return self.handle_cancel(req)
|
|
519
|
+
if ftype == 'close_session':
|
|
520
|
+
return self.handle_close_session(req)
|
|
521
|
+
raise ProtocolError('unknown-type', str(ftype))
|
|
522
|
+
|
|
523
|
+
|
|
524
|
+
def obs_id_of(payload):
|
|
525
|
+
return str(payload.get('observationId', ''))
|
|
526
|
+
|
|
527
|
+
|
|
528
|
+
def envelope_shape_ok(obj):
|
|
529
|
+
return (isinstance(obj, dict)
|
|
530
|
+
and obj.get('protocolVersion') == PROTOCOL
|
|
531
|
+
and isinstance(obj.get('frameId'), str) and obj.get('frameId')
|
|
532
|
+
and isinstance(obj.get('requestId'), str)
|
|
533
|
+
and isinstance(obj.get('workerEpoch'), str)
|
|
534
|
+
and obj.get('type') in JS_TYPES
|
|
535
|
+
and isinstance(obj.get('payload'), dict)
|
|
536
|
+
and isinstance(obj.get('sentAt'), (int, float))
|
|
537
|
+
and not isinstance(obj.get('sentAt'), bool))
|
|
538
|
+
|
|
539
|
+
|
|
540
|
+
def run_selftest():
|
|
541
|
+
checks = 0
|
|
542
|
+
assert canonical({'b': 1, 'a': ['x', {'z': None, 'y': True}]}) == '{"a":["x",{"y":true,"z":null}],"b":1}'
|
|
543
|
+
checks += 1
|
|
544
|
+
assert canonical('中\n"文"') == dumps('中\n"文"')
|
|
545
|
+
checks += 1
|
|
546
|
+
w = Worker('ep', '')
|
|
547
|
+
req = {'requestId': 'r1', 'workerEpoch': 'ep', 'sentAt': 12, 'frameId': 'f', 'type': 'health', 'payload': {}}
|
|
548
|
+
frames = w.handle_frame(req)
|
|
549
|
+
assert len(frames) == 1 and frames[0]['type'] == 'health_result' and frames[0]['sentAt'] == 12
|
|
550
|
+
assert frames[0]['frameId'] == 'res_' + first32(sha_str('r1:health_result'))
|
|
551
|
+
checks += 1
|
|
552
|
+
push = {'requestId': 'r2', 'workerEpoch': 'ep', 'sentAt': 20, 'frameId': 'f2', 'type': 'context_push',
|
|
553
|
+
'payload': {'kind': 'context_push', 'observationId': 'obs_' + '0' * 32,
|
|
554
|
+
'session': {'sessionId': 's', 'agentId': 'a', 'workspaceKey': 'w', 'scope': 'Workspace'},
|
|
555
|
+
'cursor': {'eventSeq': 1, 'contextVersion': 3},
|
|
556
|
+
'index': {'memoryIndexVersion': 'idx_' + 'ab' * 16}, 'memoryRefs': []}}
|
|
557
|
+
f2 = w.handle_frame(push)
|
|
558
|
+
assert len(f2) == 1 and f2[0]['payload']['accepted'] is True
|
|
559
|
+
again = w.handle_frame(push)
|
|
560
|
+
assert again[0]['payload']['accepted'] is False and again[0]['payload']['reason'] == 'busy'
|
|
561
|
+
checks += 1
|
|
562
|
+
sys.stderr.write('SELFTEST OK ' + str(checks) + ' checks\n')
|
|
563
|
+
|
|
564
|
+
|
|
565
|
+
def main():
|
|
566
|
+
ap = argparse.ArgumentParser(add_help=False)
|
|
567
|
+
ap.add_argument('--expect-epoch', default='')
|
|
568
|
+
ap.add_argument('--dsh-home', default='')
|
|
569
|
+
ap.add_argument('--selftest', action='store_true')
|
|
570
|
+
args, _unknown = ap.parse_known_args()
|
|
571
|
+
if args.selftest:
|
|
572
|
+
run_selftest()
|
|
573
|
+
return 0
|
|
574
|
+
worker = Worker(args.expect_epoch, args.dsh_home)
|
|
575
|
+
out = sys.stdout.buffer
|
|
576
|
+
inp = sys.stdin.buffer
|
|
577
|
+
while True:
|
|
578
|
+
raw = inp.readline(MAX_LINE_BYTES + 2)
|
|
579
|
+
if raw == b'':
|
|
580
|
+
break
|
|
581
|
+
ended_with_newline = raw.endswith(b'\n')
|
|
582
|
+
line = raw[:-1] if ended_with_newline else raw
|
|
583
|
+
oversized = (len(line) > MAX_LINE_BYTES) or (not ended_with_newline and len(raw) >= MAX_LINE_BYTES + 1)
|
|
584
|
+
if oversized:
|
|
585
|
+
err = worker.error_frame({'requestId': '', 'workerEpoch': '', 'sentAt': 0}, 'line-oversize')
|
|
586
|
+
out.write((dumps(err) + '\n').encode('utf-8'))
|
|
587
|
+
out.flush()
|
|
588
|
+
break # fail closed: cannot resync a lost framing boundary
|
|
589
|
+
req_for_error = {'requestId': '', 'workerEpoch': '', 'sentAt': 0}
|
|
590
|
+
try:
|
|
591
|
+
obj = json.loads(line.decode('utf-8'))
|
|
592
|
+
if isinstance(obj, dict):
|
|
593
|
+
req_for_error = {'requestId': str(obj.get('requestId', '')),
|
|
594
|
+
'workerEpoch': str(obj.get('workerEpoch', '')),
|
|
595
|
+
'sentAt': obj.get('sentAt', 0)}
|
|
596
|
+
except (UnicodeDecodeError, ValueError):
|
|
597
|
+
obj = None
|
|
598
|
+
if not isinstance(obj, dict) or not envelope_shape_ok(obj):
|
|
599
|
+
out.write((dumps(worker.error_frame(req_for_error, 'invalid-envelope')) + '\n').encode('utf-8'))
|
|
600
|
+
out.flush()
|
|
601
|
+
worker.counts['errors'] += 1
|
|
602
|
+
continue
|
|
603
|
+
if worker.expect_epoch and obj['workerEpoch'] != worker.expect_epoch:
|
|
604
|
+
out.write((dumps(worker.error_frame(req_for_error, 'epoch-mismatch')) + '\n').encode('utf-8'))
|
|
605
|
+
out.flush()
|
|
606
|
+
worker.counts['errors'] += 1
|
|
607
|
+
continue
|
|
608
|
+
try:
|
|
609
|
+
frames = worker.handle_frame(obj)
|
|
610
|
+
except ProtocolError as exc:
|
|
611
|
+
worker.counts['errors'] += 1
|
|
612
|
+
frames = [worker.error_frame(req_for_error, exc.code, exc.detail)]
|
|
613
|
+
except Exception as exc: # noqa: BLE001 - worker must never die on a bad frame
|
|
614
|
+
worker.counts['errors'] += 1
|
|
615
|
+
frames = [worker.error_frame(req_for_error, 'internal-error', str(exc)[:120])]
|
|
616
|
+
for fr in frames:
|
|
617
|
+
out.write((dumps(fr) + '\n').encode('utf-8'))
|
|
618
|
+
out.flush()
|
|
619
|
+
return 0
|
|
620
|
+
|
|
621
|
+
|
|
622
|
+
if __name__ == '__main__':
|
|
623
|
+
sys.exit(main())
|