Add reusable multi-session skill and five-page project workbench

This commit is contained in:
aiagent
2026-10-19 03:03:29 -07:00
parent 939c14de77
commit 579dece104
75 changed files with 13096 additions and 0 deletions
View File
+54
View File
@@ -0,0 +1,54 @@
"""Small authenticated client for an external, already authenticated AI worker.
This module never launches a model, reads model credentials, or executes commands.
The worker calls claim(), then start() before invoking its trusted model adapter.
It renews the lease during execution and calls complete() with the real result.
A lost lease or ambiguous model exit requires uncertain(), never automatic replay.
"""
import json
from urllib.parse import urlsplit
from urllib.request import Request, build_opener, HTTPRedirectHandler, ProxyHandler
from .server import restricted_token
class NoRedirect(HTTPRedirectHandler):
def redirect_request(self, req, fp, code, msg, headers, newurl):
return None
class Bridge:
def __init__(self, url, token_file, worker_id):
parsed = urlsplit(url)
if parsed.scheme not in ('http','https') or not parsed.hostname or parsed.username or parsed.password or parsed.query or parsed.fragment or parsed.path not in ('','/'):
raise ValueError('expected server origin')
self.url = url.rstrip('/')
self.token = restricted_token(token_file)
self.worker_id = worker_id
self.opener = build_opener(ProxyHandler({}), NoRedirect())
def request(self, path, data):
if not path.startswith('/api/') or '?' in path:
raise ValueError('invalid API path')
request = Request(self.url + path, data=json.dumps(data).encode('utf-8'), headers={
'Authorization':'Bearer ' + self.token, 'Content-Type':'application/json', 'X-CSRF-Token':'1'})
with self.opener.open(request, timeout=20) as response:
return json.load(response)
def claim(self, session_id, lease_seconds=300):
return self.request('/api/worker/claim', dict(worker_id=self.worker_id, session_id=session_id, lease_seconds=lease_seconds))['message']
def start(self, message):
return self.request('/api/worker/start', dict(worker_id=self.worker_id, session_id=message['session_id'],
message_id=message['id'], lease_id=message['lease_id']))
def renew(self, message, lease_seconds=300):
return self.request('/api/worker/renew', dict(worker_id=self.worker_id, session_id=message['session_id'],
message_id=message['id'], lease_id=message['lease_id'], lease_seconds=lease_seconds))
def uncertain(self, message):
return self.request('/api/worker/uncertain', dict(worker_id=self.worker_id, session_id=message['session_id'],
message_id=message['id'], lease_id=message['lease_id']))
def complete(self, message, text, thread_id=None, outcome='completed'):
return self.request('/api/worker/complete', dict(worker_id=self.worker_id, session_id=message['session_id'],
message_id=message['id'], lease_id=message['lease_id'], text=text, thread_id=thread_id, outcome=outcome))
+277
View File
@@ -0,0 +1,277 @@
"""Read-only Linux metrics with explicit site configuration.
Configuration is trusted operator input loaded from a local runtime file, NEVER
HTTP request data. Paths select installed scheduler wrappers, not shell text.
A wrapper accepts {slurm|lsf} followed by the fixed collector argv. It must be
owned and reviewed by the operator. Status means collection, not system health.
"""
import concurrent.futures
from collections import Counter
from datetime import datetime, timezone
import math
import os
import re
import shlex
import signal
import subprocess
import time
COMMANDS = {
'node': ['/bin/sh', '-c', 'cat /proc/meminfo && echo __LOAD__ && cat /proc/loadavg'],
'nfs': ['df', '-B1', '--output=source,fstype,size,used,avail,pcent,target', '-t', 'nfs', '-t', 'nfs4'],
'slurm_nodes': ['sinfo', '-N', '-h', '-o', '%N|%T|%c|%m'],
'slurm_jobs': ['squeue', '-h', '-o', '%T'],
'lsf_hosts': ['bhosts', '-w'],
'lsf_queues': ['bqueues', '-w'],
'lsf_jobs': ['bjobs', '-u', 'all', '-noheader', '-o', 'stat'],
}
def _now():
return datetime.now(timezone.utc).isoformat().replace('+00:00', 'Z')
def _nonnegative(value):
result = int(value)
if result < 0:
raise ValueError('negative counter')
return result
def parse_node(text):
memory, load = text.split('__LOAD__', 1)
values = {}
for line in memory.splitlines():
fields = line.split()
if fields and fields[0] in ('MemTotal:', 'MemAvailable:'):
if len(fields) != 3 or fields[2] != 'kB':
raise ValueError('invalid memory unit')
values[fields[0][:-1]] = _nonnegative(fields[1]) * 1024
total, available = values['MemTotal'], values['MemAvailable']
loads = [float(v) for v in load.split()[:3]]
if total <= 0 or available > total or len(loads) != 3 or any(not math.isfinite(v) or v < 0 for v in loads):
raise ValueError('invalid memory/load sample')
return {'memory_total_bytes': total, 'memory_available_bytes': available,
'memory_used_percent': round(100 * (total - available) / total, 2),
'load_1m': loads[0], 'load_5m': loads[1], 'load_15m': loads[2]}
def parse_nfs(text):
lines = text.strip().splitlines()
if not lines or not lines[0].startswith('Filesystem'):
raise ValueError('missing df header')
rows = []
for line in lines[1:]:
source, fstype, size, used, avail, percent, target = line.split(None, 6)
size, used, avail = map(_nonnegative, (size, used, avail))
percentage = _nonnegative(percent.rstrip('%'))
if fstype not in ('nfs', 'nfs4') or size <= 0 or not percent.endswith('%') or percentage > 100:
raise ValueError('invalid NFS sample')
rows.append({'mount_source': source, 'mount_target': target, 'fstype': fstype,
'total_bytes': size, 'used_bytes': used, 'available_bytes': avail,
'used_percent': percentage})
if not rows:
raise ValueError('no NFS mounts reported')
return rows
def parse_slurm_nodes(text):
nodes = {}
for line in text.splitlines():
if not line.strip():
continue
name, state, cpus, memory = line.strip().split('|')
row = {'name': name, 'state': state, 'cpus': _nonnegative(cpus), 'memory_mb': _nonnegative(memory)}
if name in nodes and nodes[name] != row:
raise ValueError('inconsistent duplicate Slurm node')
nodes[name] = row
return {'count': len(nodes), 'by_state': dict(Counter(n['state'] for n in nodes.values())), 'nodes': list(nodes.values())}
def parse_jobs(text):
states = [line.strip() for line in text.splitlines() if line.strip()]
allowed = {'PENDING', 'RUNNING', 'SUSPENDED', 'COMPLETING', 'CONFIGURING', 'COMPLETED', 'CANCELLED', 'FAILED', 'TIMEOUT', 'NODE_FAIL', 'PREEMPTED', 'BOOT_FAIL', 'DEADLINE', 'OUT_OF_MEMORY', 'REQUEUED', 'REQUEUE_FED', 'REQUEUE_HOLD', 'RESIZING', 'REVOKED', 'SIGNALING', 'SPECIAL_EXIT', 'STAGE_OUT', 'STOPPED', 'PEND', 'RUN', 'PSUSP', 'USUSP', 'SSUSP', 'DONE', 'EXIT', 'UNKWN', 'ZOMBI', 'WAIT', 'PROV'}
if any(s not in allowed for s in states):
raise ValueError('unexpected scheduler state output')
return {'count': len(states), 'by_state': dict(Counter(states)), 'scope': 'visible jobs; no user/job details collected'}
def parse_lsf_table(text, kind):
lines = [line.split() for line in text.splitlines() if line.strip()]
first = 'HOST_NAME' if kind == 'hosts' else 'QUEUE_NAME'
required = ('STATUS', 'NJOBS', 'RUN') if kind == 'hosts' else ('STATUS', 'NJOBS', 'PEND', 'RUN', 'SUSP')
if not lines or lines[0][0] != first or any(k not in lines[0] for k in required):
raise ValueError('missing LSF table header')
header, rows = lines[0], []
for fields in lines[1:]:
if len(fields) != len(header):
raise ValueError('invalid LSF table row')
data = dict(zip(header, fields))
row = {'name': data[first], 'state': data['STATUS']}
for key in required:
if key != 'STATUS':
row[key.lower()] = _nonnegative(data[key])
rows.append(row)
return {'count': len(rows), 'by_state': dict(Counter(r['state'] for r in rows)), kind: rows,
'counter_note': 'LSF NJOBS/RUN table counters may count slots; jobs.count counts job records'}
PARSERS = {'node': parse_node, 'nfs': parse_nfs, 'slurm_nodes': parse_slurm_nodes,
'slurm_jobs': parse_jobs, 'lsf_jobs': parse_jobs,
'lsf_hosts': lambda s: parse_lsf_table(s, 'hosts'),
'lsf_queues': lambda s: parse_lsf_table(s, 'queues')}
def _path(value):
if not isinstance(value, str) or not re.fullmatch(r'/[A-Za-z0-9_./-]+', value) or '..' in value.split('/'):
raise ValueError('expected an absolute executable/identity path without shell syntax')
return value
def _host(value):
if not isinstance(value, str) or len(value) > 253 or not re.fullmatch(r'[A-Za-z0-9](?:[A-Za-z0-9.-]*[A-Za-z0-9])?', value):
raise ValueError('invalid hostname')
if any(not label or len(label) > 63 or label.startswith('-') or label.endswith('-') for label in value.split('.')):
raise ValueError('invalid hostname')
return value
def _settings(config):
keys = {'hosts', 'nodes', 'authorized_hosts', 'entry_hostname', 'storage_hosts', 'schedulers',
'wrapper_paths', 'ssh_user', 'ssh_identity_file', 'command_timeout_seconds', 'total_timeout_seconds'}
if not isinstance(config, dict) or set(config) - keys or ('hosts' in config and 'nodes' in config):
raise ValueError('unsupported or conflicting metrics configuration keys')
entry = _host(config.get('entry_hostname', 'localhost'))
allowed = config.get('authorized_hosts', [])
nodes = config.get('hosts', config.get('nodes', [entry]))
storage = config.get('storage_hosts', [])
schedulers = config.get('schedulers', ['slurm', 'lsf'])
for values in (allowed, nodes, storage, schedulers):
if not isinstance(values, list) or len(values) > 32 or any(not isinstance(v, str) for v in values) or len(values) != len(set(values)):
raise ValueError('invalid or duplicate collector targets')
for host in allowed + nodes + storage:
_host(host)
if any(h not in allowed and h != entry for h in nodes + storage):
raise ValueError('remote target not in authorized_hosts')
if any(s not in ('slurm', 'lsf') for s in schedulers):
raise ValueError('unsupported scheduler')
wrappers = config.get('wrapper_paths', {})
if not isinstance(wrappers, dict) or set(wrappers) - {'slurm', 'lsf'}:
raise ValueError('invalid scheduler wrapper mapping')
wrappers = {k: _path(v) for k, v in wrappers.items()}
user = config.get('ssh_user')
if user is not None and (not isinstance(user, str) or not re.fullmatch(r'[a-zA-Z_][a-zA-Z0-9_-]{0,63}', user)):
raise ValueError('invalid ssh_user')
if any(h != entry for h in nodes + storage) and not user:
raise ValueError('remote collection requires explicit ssh_user')
identity = config.get('ssh_identity_file')
if identity is not None:
_path(identity)
timeout = float(config.get('command_timeout_seconds', 5))
total = float(config.get('total_timeout_seconds', 20))
if not math.isfinite(timeout) or not 0.1 <= timeout <= 8 or not math.isfinite(total) or not 0.1 <= total <= 30:
raise ValueError('invalid collection timeout')
return dict(entry=entry, nodes=nodes, storage=storage, schedulers=schedulers,
wrappers=wrappers, user=user, identity=identity, timeout=timeout, total=total)
def _command(collector, settings):
command = list(COMMANDS[collector])
if '_' in collector:
scheduler = collector.split('_')[0]
wrapper = settings['wrappers'].get(scheduler)
if not wrapper:
return None
command = [wrapper, scheduler] + command
return command
def _run(host, collector, timeout, settings):
command = _command(collector, settings)
if command is None:
raise RuntimeError('not_configured: scheduler wrapper missing')
if host != settings['entry']:
remote = 'LC_ALL=C timeout -k 1s %ss %s' % (max(1, int(timeout)), shlex.join(command))
ssh = ['ssh', '-o', 'BatchMode=yes', '-o', 'StrictHostKeyChecking=yes',
'-o', 'ConnectTimeout=3', '-o', 'ConnectionAttempts=1',
'-o', 'ServerAliveInterval=2', '-o', 'ServerAliveCountMax=1']
if settings['identity']:
ssh += ['-i', settings['identity'], '-o', 'IdentitiesOnly=yes']
command = ssh + [settings['user'] + '@' + host, remote]
process = subprocess.Popen(command, stdout=subprocess.PIPE, stderr=subprocess.PIPE,
text=True, env=dict(os.environ, LC_ALL='C'), start_new_session=True)
try:
stdout, stderr = process.communicate(timeout=timeout)
except subprocess.TimeoutExpired:
try:
os.killpg(process.pid, signal.SIGKILL)
except ProcessLookupError:
pass
process.communicate()
raise TimeoutError('collector timed out')
if process.returncode:
if collector == 'lsf_jobs' and (stdout + stderr).strip().rstrip('.') == 'No unfinished job found':
return ''
category = 'permission denied' if 'permission denied' in stderr.lower() else 'command failed'
if 'not found' in stderr.lower() or process.returncode == 127:
category = 'command or environment unavailable'
raise RuntimeError('%s (exit %s)' % (category, process.returncode))
return stdout
def _sample(host, collector, settings, deadline):
command = _command(collector, settings)
record = {'host': host, 'collector': collector,
'source': host + ':' + (shlex.join(command) if command else collector),
'collected_at': _now(), 'status': 'error', 'error': None, 'data': None}
if command is None:
record.update(status='not_configured', error='scheduler wrapper not configured')
return record
try:
remaining = deadline - time.monotonic()
if remaining <= 0:
raise TimeoutError('collection deadline exceeded')
record['data'] = PARSERS[collector](_run(host, collector, min(settings['timeout'], remaining), settings))
record['status'] = 'ok'
except (OSError, RuntimeError, TimeoutError, ValueError, KeyError, IndexError) as exc:
record['error'] = str(exc) if isinstance(exc, (RuntimeError, TimeoutError)) else 'unavailable or malformed collector output (%s)' % type(exc).__name__
record['collected_at'] = _now()
return record
def collect(config):
"""Collect from trusted local configuration; default: localhost node only.
Optional schedulers appear as not_configured until wrapper_paths is supplied.
entry_hostname labels this process's host and must describe its deployment;
it never initiates an SSH connection. Remote hosts require authorized_hosts
AND ssh_user. The service must never accept these settings from web clients.
"""
result = {'collected_at': _now(), 'status': 'error', 'nodes': [], 'storage': [], 'schedulers': {}, 'errors': []}
try:
settings = _settings(config)
except (ValueError, TypeError) as exc:
result['errors'].append({'source': 'config', 'collected_at': _now(), 'error': str(exc)})
return result
tasks = [(h, 'node') for h in settings['nodes']] + [(h, 'nfs') for h in settings['storage']]
tasks += [(settings['entry'], c) for c in COMMANDS if '_' in c and c.split('_')[0] in settings['schedulers']]
deadline = time.monotonic() + settings['total']
with concurrent.futures.ThreadPoolExecutor(max_workers=6) as executor:
records = list(executor.map(lambda pair: _sample(pair[0], pair[1], settings, deadline), tasks))
for record in records:
collector = record['collector']
if collector == 'node':
result['nodes'].append(record)
elif collector == 'nfs':
result['storage'].append(record)
else:
scheduler, part = collector.split('_', 1)
result['schedulers'].setdefault(scheduler, {})[part] = record
if record['error']:
result['errors'].append({k: record[k] for k in ('host', 'collector', 'source', 'collected_at', 'error')})
successful = sum(r['status'] == 'ok' for r in records)
result['status'] = 'ok' if records and successful == len(records) else 'partial' if successful else 'error'
if not records:
result['errors'].append({'source': 'config', 'collected_at': _now(), 'error': 'no collectors selected'})
result['collected_at'] = _now()
return result
+284
View File
@@ -0,0 +1,284 @@
"""Authenticated LAN coordination service; intentionally has no shell endpoint."""
import argparse
import hmac
import ipaddress
import json
import mimetypes
import os
import stat
import threading
import time
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
from pathlib import Path
from urllib.parse import parse_qs, unquote, urlsplit
from .store import Conflict, Invalid, Store
WEB = Path(__file__).resolve().parents[1] / 'assets' / 'dashboard' / 'dist'
def restricted_token(path):
path = Path(path)
flags = os.O_RDONLY | getattr(os, 'O_NOFOLLOW', 0)
fd = os.open(str(path), flags)
try:
info = os.fstat(fd)
if not stat.S_ISREG(info.st_mode) or (os.name == 'posix' and (info.st_uid != os.getuid() or info.st_mode & 0o077)):
raise ValueError('token file must be owned by the service user and mode 600')
with os.fdopen(fd, 'r', encoding='utf-8') as stream:
fd = None
value = stream.read(4097).strip()
if len(value) < 32 or len(value) > 4096 or any(c.isspace() for c in value):
raise ValueError('token must contain 32 to 4096 non-whitespace characters')
return value
finally:
if fd is not None:
os.close(fd)
def local_runtime(path):
root = Path(path).expanduser().resolve()
root.mkdir(parents=True, exist_ok=True, mode=0o700)
if os.name == 'posix':
info = root.stat()
if info.st_uid != os.getuid() or info.st_mode & 0o077:
raise ValueError('runtime root must be owned by service user and mode 700')
# Longest mount-point match handles nested mounts and bind mounts.
mounts = []
for line in Path('/proc/mounts').read_text().splitlines():
fields = line.split()
mount = fields[1].replace('\\040', ' ').replace('\\134', '\\')
if str(root) == mount or str(root).startswith(mount.rstrip('/') + '/'):
mounts.append((len(mount), fields[2]))
if not mounts or max(mounts)[1] not in {'xfs','ext4','ext3','ext2','btrfs','tmpfs','overlay','f2fs','zfs'}:
raise ValueError('runtime root must use a verified local filesystem')
elif str(root).startswith('\\\\'):
raise ValueError('runtime root must be local')
return root
class MetricsCache:
def __init__(self, config):
self.config = config
self.value = {'status':'waiting', 'message':'等待首次采集'}
self.lock = threading.Lock()
self.stop = threading.Event()
self.thread = threading.Thread(target=self.run, daemon=True)
self.thread.start()
def run(self):
while not self.stop.is_set():
try:
from .metrics import collect
value = collect(self.config)
# Reject non-JSON collectors before exposing the cache.
json.dumps(value, allow_nan=False)
except Exception:
value = {'status':'unavailable', 'message':'采集暂不可用'}
with self.lock:
self.value = {'sampled_at':time.time(), 'data':value}
self.stop.wait(15)
def snapshot(self):
with self.lock:
return self.value
class Server(ThreadingHTTPServer):
daemon_threads = True
def __init__(self, config):
bind = config['bind']
address = ipaddress.IPv4Address(bind)
allowed = any(address in ipaddress.ip_network(net) for net in ('10.0.0.0/8','172.16.0.0/12','192.168.0.0/16','127.0.0.0/8'))
if not allowed:
raise ValueError('bind must be an explicit LAN or loopback IPv4')
port = config.get('port', 8796)
if isinstance(port, bool) or not isinstance(port, int) or not 0 <= port <= 65535:
raise ValueError('invalid port')
self.primary_session_id = config.get('primary_session_id', 'human-progress')
if not isinstance(self.primary_session_id, str) or not self.primary_session_id or len(self.primary_session_id) > 128:
raise ValueError('invalid primary_session_id')
self.token = restricted_token(Path(config['token_file']).expanduser())
self.web_root = Path(config.get('web_root', WEB)).expanduser().resolve()
self.title = config.get('title', 'Agent Workbench')
if not isinstance(self.title,str) or not 1 <= len(self.title) <= 200:
raise ValueError('invalid title')
root = local_runtime(config['runtime_root'])
dbpath = root / 'control.sqlite3'
if dbpath.is_symlink():
raise ValueError('database cannot be a symlink')
self.store = Store(dbpath)
if os.name == 'posix':
dbpath.chmod(0o600)
super().__init__((bind, port), Handler)
self.authority = bind + ':' + str(self.server_port)
self.allowed_hosts = set(config.get('allowed_hosts', [])) | {self.authority}
if any(not isinstance(host,str) or not host or '/' in host or '@' in host or any(c.isspace() for c in host) for host in self.allowed_hosts):
raise ValueError('invalid allowed_hosts')
self.metrics = MetricsCache(config.get('metrics', {}))
def server_close(self):
if hasattr(self, 'metrics'):
self.metrics.stop.set()
super().server_close()
class Handler(BaseHTTPRequestHandler):
server_version = 'AgentControl'
sys_version = ''
def setup(self):
super().setup()
self.connection.settimeout(10)
def log_message(self, *args):
# Do not log request targets, bodies, headers or credentials.
pass
def output(self, code, value, content_type='application/json; charset=utf-8'):
payload = value if isinstance(value, bytes) else json.dumps(value, ensure_ascii=False, allow_nan=False).encode('utf-8')
self.send_response(code)
self.send_header('Content-Type', content_type)
self.send_header('Content-Length', str(len(payload)))
self.send_header('Cache-Control', 'no-store')
self.send_header('X-Content-Type-Options', 'nosniff')
self.send_header('Referrer-Policy', 'no-referrer')
self.send_header('Content-Security-Policy', "default-src 'self'; script-src 'self'; style-src 'self'; style-src-attr 'unsafe-inline'; connect-src 'self'; object-src 'none'; frame-ancestors 'none'; base-uri 'none'; form-action 'self'")
self.end_headers()
self.wfile.write(payload)
def guard(self, write=False):
hosts = self.headers.get_all('Host', [])
if len(hosts) != 1 or hosts[0] not in self.server.allowed_hosts:
self.output(403, {'error':'host rejected'})
return False
origins = self.headers.get_all('Origin', [])
if origins and origins != ['http://' + hosts[0]]:
self.output(403, {'error':'origin rejected'})
return False
if self.headers.get('Sec-Fetch-Site') == 'cross-site':
self.output(403, {'error':'cross-site request rejected'})
return False
if write and self.headers.get('X-CSRF-Token') != '1':
self.output(403, {'error':'csrf rejected'})
return False
return True
def auth(self):
supplied = self.headers.get('Authorization', '')
if not hmac.compare_digest(supplied.encode('utf-8'), ('Bearer ' + self.server.token).encode('utf-8')):
self.output(401, {'error':'authentication required'})
return False
return True
def do_GET(self):
if not self.guard():
return
parts = urlsplit(self.path)
if parts.path.startswith('/api/'):
if not self.auth():
return
try:
if parts.path == '/api/state' and not parts.query:
state = self.server.store.state()
state['title'] = self.server.title
state['primary_session_id'] = self.server.primary_session_id
state['metrics'] = self.server.metrics.snapshot()
self.output(200, state)
elif parts.path == '/api/messages':
query = parse_qs(parts.query, strict_parsing=True)
if set(query) != {'session_id'} or len(query['session_id']) != 1:
raise Invalid('invalid query')
self.output(200, {'messages':self.server.store.messages(query['session_id'][0])})
else:
self.output(404, {'error':'not found'})
except (Invalid,ValueError):
self.output(400, {'error':'invalid request'})
except Exception:
self.output(500, {'error':'request failed'})
return
# Only built dashboard files under the configured root are public.
if parts.query:
self.output(404, {'error':'not found'})
return
relative = unquote(parts.path).lstrip('/') or 'index.html'
allowed_suffixes = {'.html','.js','.css','.svg','.ico','.png','.webp','.woff','.woff2'}
target = (self.server.web_root / relative).resolve()
if ('\\' in relative or any(p in ('.','..') for p in relative.split('/'))
or not target.is_relative_to(self.server.web_root) or target.suffix not in allowed_suffixes):
self.output(404, {'error':'not found'})
return
if not target.is_file():
if relative == 'index.html' and not self.server.web_root.is_dir():
self.output(503, {'error':'dashboard not built; run the documented frontend build'})
return
self.output(404, {'error':'not found'})
return
content_type = mimetypes.guess_type(str(target))[0] or 'application/octet-stream'
if target.suffix in ('.html','.js','.css','.svg'):
content_type += '; charset=utf-8'
self.output(200, target.read_bytes(), content_type)
def do_POST(self):
self.mutate()
def do_PATCH(self):
self.mutate()
def mutate(self):
if not self.guard(write=True) or not self.auth():
return
try:
if self.headers.get('Content-Type', '').split(';')[0] != 'application/json' or self.headers.get('Transfer-Encoding'):
raise Invalid('invalid content type')
lengths = self.headers.get_all('Content-Length', [])
if len(lengths) != 1:
raise Invalid('length required')
length = int(lengths[0])
body_limit = 4 * 1024 * 1024 if self.path in ('/api/mirror','/api/worker/complete') else 65536
if not 0 < length <= body_limit:
raise Invalid('invalid length')
data = json.loads(self.rfile.read(length))
if not isinstance(data, dict):
raise Invalid('object required')
store = self.server.store
routes = {'/api/sessions':store.session, '/api/session-requests':store.request,
'/api/tasks':store.task, '/api/messages':store.send,
'/api/worker/claim':store.claim, '/api/worker/renew':store.renew, '/api/worker/complete':store.complete,
'/api/events':store.event, '/api/mirror':store.mirror, '/api/worker/start':store.start,
'/api/worker/submission':store.submission, '/api/worker/uncertain':store.uncertain, '/api/messages/reconcile':store.reconcile}
if self.command == 'PATCH' and self.path.startswith('/api/tasks/'):
result = store.task(data, self.path[len('/api/tasks/'):])
elif self.command == 'POST' and self.path in routes:
result = routes[self.path](data)
else:
self.output(404, {'error':'not found'})
return
self.output(200, result)
except Conflict:
self.output(409, {'error':'state conflict'})
except (Invalid, ValueError, TypeError, KeyError):
self.output(400, {'error':'invalid request'})
except Exception:
self.output(500, {'error':'request failed'})
def main():
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument('--config', required=True)
args = parser.parse_args()
config = json.loads(Path(args.config).read_text(encoding='utf-8-sig'))
os.umask(0o077)
server = Server(config)
try:
print('Agent control listening at http://' + server.authority, flush=True)
server.serve_forever()
except KeyboardInterrupt:
pass
finally:
server.server_close()
if __name__ == '__main__':
main()
+470
View File
@@ -0,0 +1,470 @@
"""Durable coordination data; no model credentials or process execution."""
import hashlib
import json
import math
import re
import secrets
import sqlite3
import time
from contextlib import contextmanager
from pathlib import Path
class Invalid(ValueError):
pass
class Conflict(Invalid):
pass
def string(value, name, limit=2000, optional=False):
if optional and value is None:
return ''
if not isinstance(value, str) or (not optional and not value.strip()) or len(value) > limit:
raise Invalid('invalid ' + name)
return value
def identifier(value, name='id'):
value = string(value, name, 128)
if not re.fullmatch(r'[A-Za-z0-9_.:-]+', value):
raise Invalid('invalid ' + name)
return value
class Store:
def __init__(self, path):
self.path = str(path)
with self.db() as db:
db.executescript('''
PRAGMA journal_mode=WAL;
CREATE TABLE IF NOT EXISTS sessions(id TEXT PRIMARY KEY, data TEXT NOT NULL);
CREATE TABLE IF NOT EXISTS tasks(id TEXT PRIMARY KEY, data TEXT NOT NULL);
CREATE TABLE IF NOT EXISTS requests(id TEXT PRIMARY KEY, data TEXT NOT NULL);
CREATE TABLE IF NOT EXISTS events(id INTEGER PRIMARY KEY AUTOINCREMENT, data TEXT NOT NULL);
CREATE TABLE IF NOT EXISTS metadata(key TEXT PRIMARY KEY, value TEXT NOT NULL);
CREATE TABLE IF NOT EXISTS messages(
id TEXT PRIMARY KEY, session_id TEXT NOT NULL, text TEXT NOT NULL,
source TEXT NOT NULL, reply_to TEXT, idempotency_key TEXT NOT NULL,
status TEXT NOT NULL, created REAL NOT NULL, worker_id TEXT,
lease_id TEXT, lease_until REAL, response_id TEXT,
UNIQUE(session_id,idempotency_key));
CREATE INDEX IF NOT EXISTS messages_session ON messages(session_id,created);
CREATE TABLE IF NOT EXISTS history_links(
session_id TEXT NOT NULL, external_id TEXT NOT NULL,
source TEXT NOT NULL, data TEXT NOT NULL,
PRIMARY KEY(session_id,external_id,source));
''')
db.execute('INSERT OR IGNORE INTO metadata(key,value) VALUES(?,?)',('instance_id',secrets.token_hex(16)))
self.instance_id=db.execute('SELECT value FROM metadata WHERE key=?',('instance_id',)).fetchone()['value']
columns = {row['name'] for row in db.execute('PRAGMA table_info(messages)')}
if 'started' not in columns:
db.execute('ALTER TABLE messages ADD COLUMN started INTEGER NOT NULL DEFAULT 0')
if 'submission' not in columns:
db.execute('ALTER TABLE messages ADD COLUMN submission TEXT')
if 'origin' not in columns:
db.execute("ALTER TABLE messages ADD COLUMN origin TEXT NOT NULL DEFAULT 'coordination'")
@contextmanager
def db(self):
db = sqlite3.connect(self.path, timeout=15)
db.row_factory = sqlite3.Row
try:
with db:
yield db
finally:
db.close()
def _get(self, db, table, key):
row = db.execute('SELECT data FROM ' + table + ' WHERE id=?', (key,)).fetchone()
if not row:
raise Invalid('unknown ' + table)
return json.loads(row['data'])
def _put(self, db, table, data):
db.execute('INSERT OR REPLACE INTO ' + table + '(id,data) VALUES(?,?)',
(data['id'], json.dumps(data, ensure_ascii=False)))
def _event(self, db, kind, text, session_id=None):
data = dict(kind=kind, text=text, session_id=session_id, created=time.time())
cursor = db.execute('INSERT INTO events(data) VALUES(?)', (json.dumps(data, ensure_ascii=False),))
return dict(data, id=cursor.lastrowid)
def session(self, data):
sid = identifier(data.get('id') or secrets.token_hex(12))
record = dict(id=sid, name=string(data.get('name'), 'name', 200),
wp=string(data.get('wp', ''), 'wp', 100, True),
role=string(data.get('role', 'worker'), 'role', 100),
worktree=string(data.get('worktree', ''), 'worktree', 2000, True))
thread = string(data.get('thread_id', ''), 'thread_id', 200, True)
thread_url=string(data.get('thread_url',''), 'thread_url', 250, True)
if thread_url and (not thread or thread_url!='codex://threads/'+thread or not re.fullmatch(r'[0-9a-fA-F-]{36}',thread)):
raise Invalid('invalid thread_url')
with self.db() as db:
db.execute('BEGIN IMMEDIATE')
old = db.execute('SELECT data FROM sessions WHERE id=?', (sid,)).fetchone()
old = json.loads(old['data']) if old else {}
record.update(thread_id=thread or old.get('thread_id', ''),
last_seen=old.get('last_seen'), created=old.get('created', time.time()))
record['thread_url']=thread_url or old.get('thread_url','')
self._put(db, 'sessions', record)
self._event(db, 'session.registered', '会话记录已登记', sid)
return record
def request(self, data):
record = dict(id=secrets.token_hex(12), name=string(data.get('name'), 'name', 200),
wp=string(data.get('wp', ''), 'wp', 100, True),
prompt=string(data.get('prompt'), 'prompt', 16000),
status='requested', created=time.time())
with self.db() as db:
self._put(db, 'requests', record)
self._event(db, 'session.requested', '新建请求等待人工或本机桥接接入')
return record
def task(self, data, key=None):
key = identifier(key or data.get('id') or secrets.token_hex(12))
with self.db() as db:
db.execute('BEGIN IMMEDIATE')
row = db.execute('SELECT data FROM tasks WHERE id=?', (key,)).fetchone()
old = json.loads(row['data']) if row else {}
record = dict(old, **data)
record['id'] = key
allowed = {'id','name','session_id','status','phase','eta_min_minutes','eta_max_minutes','eta_basis','decision','updated'}
if set(record) - allowed:
raise Invalid('unknown task field')
record['name'] = string(record.get('name'), 'name', 200)
record.setdefault('status', 'queued')
record.setdefault('phase', 'planning')
if record['status'] not in {'queued','running','blocked','waiting','completed','failed','cancelled'}:
raise Invalid('invalid status')
if record['phase'] not in {'planning','implementation','validation','integration','done'}:
raise Invalid('invalid phase')
if record['status'] == 'completed' and record['phase'] != 'done':
raise Invalid('completed task must have done phase')
if record.get('session_id'):
self._get(db, 'sessions', identifier(record['session_id']))
lo, hi = record.get('eta_min_minutes'), record.get('eta_max_minutes')
if lo is not None or hi is not None:
if any(isinstance(v, bool) or not isinstance(v, (int,float)) or not math.isfinite(v) or v < 0 for v in (lo,hi)) or lo > hi:
raise Invalid('invalid ETA range')
string(record.get('eta_basis'), 'eta_basis', 2000)
for field in ('eta_basis','decision'):
record[field] = string(record.get(field, ''), field, 4000, True)
record['updated'] = time.time()
self._put(db, 'tasks', record)
self._event(db, 'task.updated', record['name'], record.get('session_id'))
return record
@staticmethod
def public_message(row):
result = {k: row[k] for k in ('id','session_id','text','source','reply_to','status','created','response_id')}
result['outcome'] = row['status'] if row['status'] in ('completed','failed','uncertain') else None
result['origin'] = row['origin']
result['execution'] = 'started' if row['started'] else 'not_started'
result['submission'] = json.loads(row['submission']) if row['submission'] else None
return result
def send(self, data):
sid = identifier(data.get('session_id'))
body = string(data.get('text'), 'text', 32000)
source = data.get('source', 'human')
if source not in ('human','agent'):
raise Invalid('invalid source')
key = identifier(data.get('idempotency_key'), 'idempotency_key')
reply = data.get('reply_to')
with self.db() as db:
db.execute('BEGIN IMMEDIATE')
self._get(db, 'sessions', sid)
old = db.execute('SELECT * FROM messages WHERE session_id=? AND idempotency_key=?', (sid,key)).fetchone()
if old:
if (old['text'],old['source'],old['reply_to']) != (body,source,reply):
raise Conflict('idempotency conflict')
return self.public_message(old)
if reply:
target = db.execute('SELECT session_id FROM messages WHERE id=?', (identifier(reply),)).fetchone()
if not target or target['session_id'] != sid:
raise Invalid('reply must belong to same session')
mid = secrets.token_hex(12)
db.execute('INSERT INTO messages(id,session_id,text,source,reply_to,idempotency_key,status,created) VALUES(?,?,?,?,?,?,?,?)',
(mid,sid,body,source,reply,key,'queued' if source == 'human' else 'delivered',time.time()))
self._event(db, 'message.sent', '消息已持久保存', sid)
return self.public_message(db.execute('SELECT * FROM messages WHERE id=?', (mid,)).fetchone())
def submission(self, data):
sid, worker = identifier(data.get('session_id')), identifier(data.get('worker_id'))
mid, lease = identifier(data.get('message_id')), identifier(data.get('lease_id'))
phase = data.get('state')
ranks = {'submitting': 1, 'accepted': 2, 'observed': 3}
if phase not in ranks:
raise Invalid('invalid submission state')
thread = string(data.get('thread_id'), 'thread_id', 200)
digest = string(data.get('input_sha256'), 'input_sha256', 64)
if not re.fullmatch(r'[0-9a-f]{64}', digest):
raise Invalid('invalid input_sha256')
update = dict(state=phase, thread_id=thread, input_sha256=digest)
for field in ('receipt_id', 'turn_id'):
if data.get(field) is not None:
update[field] = string(data[field], field, 200)
if phase == 'accepted' and not update.get('receipt_id'):
raise Invalid('accepted requires receipt_id')
if phase == 'observed' and not update.get('turn_id'):
raise Invalid('observed requires turn_id')
with self.db() as db:
db.execute('BEGIN IMMEDIATE')
row = db.execute('SELECT * FROM messages WHERE id=? AND session_id=?', (mid,sid)).fetchone()
if (not row or not row['started'] or row['worker_id'] != worker or row['lease_id'] != lease
or row['status'] not in ('claimed','completed','failed')
or (row['status'] == 'claimed' and row['lease_until'] <= time.time())):
raise Conflict('expired or invalid submission claim')
session = self._get(db, 'sessions', sid)
if session.get('thread_id') != thread:
raise Conflict('submission requires matching bound thread')
old = json.loads(row['submission']) if row['submission'] else {}
for field in ('thread_id','input_sha256','receipt_id','turn_id'):
if old.get(field) and update.get(field) and old[field] != update[field]:
raise Conflict('submission identity conflict')
merged = dict(old, **update)
if ranks.get(old.get('state'),0) > ranks[phase]:
merged['state'] = old['state']
db.execute('UPDATE messages SET submission=? WHERE id=?', (json.dumps(merged),mid))
return {'submission': merged}
def mirror(self, data):
sid = identifier(data.get('session_id'))
thread = string(data.get('thread_id'), 'thread_id', 200)
items = data.get('items')
if not isinstance(items, list) or len(items) > 100:
raise Invalid('invalid mirror batch')
prepared = []
for item in items:
if not isinstance(item, dict):
raise Invalid('invalid mirror item')
external = string(item.get('id'), 'external item id', 500)
source = item.get('source')
if source not in ('human','agent'):
raise Invalid('invalid mirror source')
body = string(item.get('text'), 'text', 262144)
created = item.get('created', time.time())
if isinstance(created, bool) or not isinstance(created, (int,float)) or not math.isfinite(created) or created < 0:
raise Invalid('invalid mirror timestamp')
key = 'mirror:' + hashlib.sha256((thread+'\0'+source+'\0'+external).encode()).hexdigest()
related = item.get('related_message_id')
if related is not None:
related = identifier(related)
phase = string(item.get('phase',''), 'phase', 100, True)
turn_status = item.get('turn_status','')
if turn_status not in ('','inProgress','completed','failed','interrupted'):
raise Invalid('invalid turn status')
turn_id = string(item.get('turn_id',''), 'turn_id', 200, True)
input_text = string(item.get('input_text'), 'input_text', 262144) if related else None
prepared.append((key,source,body,created,external,related,phase,turn_status,turn_id,bool(item.get('allow_update',False)),input_text))
with self.db() as db:
db.execute('BEGIN IMMEDIATE')
session = self._get(db, 'sessions', sid)
if not session.get('thread_id') or session['thread_id'] != thread:
raise Conflict('mirror requires matching bound thread')
imported = 0
deferred = []
for key,source,body,created,external,related,phase,turn_status,turn_id,allow_update,input_text in prepared:
local_id = None
if related:
original = db.execute('SELECT * FROM messages WHERE id=? AND session_id=?', (related,sid)).fetchone()
if not original or original['source'] != 'human':
raise Conflict('unknown related input')
submission = json.loads(original['submission']) if original['submission'] else {}
if (not original['started'] or original['origin'] != 'coordination'
or submission.get('thread_id') != thread
or submission.get('input_sha256') != hashlib.sha256(input_text.encode('utf-8')).hexdigest()
or not turn_id or (submission.get('turn_id') and submission['turn_id'] != turn_id)):
raise Conflict('unproven history association')
if not submission.get('turn_id'):
submission.update(turn_id=turn_id, state='observed')
db.execute('UPDATE messages SET submission=? WHERE id=?', (json.dumps(submission),related))
if source == 'human':
if body != original['text']:
raise Conflict('related input text mismatch')
local_id = related
elif phase == 'final_answer' and turn_status in ('completed','failed','interrupted'):
if not original['started']:
raise Conflict('cannot reconcile unstarted execution')
if original['status'] == 'claimed' and original['lease_until'] > time.time():
deferred.append(external)
continue
outcome = 'completed' if turn_status == 'completed' else 'failed'
local_id = original['response_id'] or secrets.token_hex(12)
if original['response_id']:
db.execute("UPDATE messages SET text=?,status=? WHERE id=?", (body,'delivered' if outcome=='completed' else 'failed',local_id))
else:
db.execute('INSERT INTO messages(id,session_id,text,source,reply_to,idempotency_key,status,created,origin) VALUES(?,?,?,?,?,?,?,?,?)',
(local_id,sid,body,'agent',related,'reply:'+related,'delivered' if outcome=='completed' else 'failed',created,'session_history'))
imported += 1
db.execute('UPDATE messages SET status=?,response_id=? WHERE id=?', (outcome,local_id,related))
if local_id:
link=dict(message_id=local_id,thread_id=thread,turn_id=turn_id,phase=phase,text_sha256=hashlib.sha256(body.encode()).hexdigest())
db.execute('INSERT OR REPLACE INTO history_links VALUES(?,?,?,?)',(sid,external,source,json.dumps(link)))
continue
old = db.execute('SELECT * FROM messages WHERE session_id=? AND idempotency_key=?', (sid,key)).fetchone()
if old:
if old['source'] != source or old['origin'] != 'session_history' or (old['text'] != body and not allow_update):
raise Conflict('mirror item conflict')
if old['text'] != body:
db.execute('UPDATE messages SET text=? WHERE id=?',(body,old['id']))
continue
local_id = secrets.token_hex(12)
db.execute('INSERT INTO messages(id,session_id,text,source,idempotency_key,status,created,origin) VALUES(?,?,?,?,?,?,?,?)',
(local_id,sid,body,source,key,'delivered',created,'session_history'))
link=dict(message_id=local_id,thread_id=thread,turn_id=turn_id,phase=phase,text_sha256=hashlib.sha256(body.encode()).hexdigest())
db.execute('INSERT OR REPLACE INTO history_links VALUES(?,?,?,?)',(sid,external,source,json.dumps(link)))
imported += 1
# History synchronization is not proof of an available execution bridge.
session['history_synced_at'] = time.time()
self._put(db, 'sessions', session)
return {'imported': imported, 'received': len(items), 'deferred': deferred}
def messages(self, sid):
sid = identifier(sid)
with self.db() as db:
self._get(db, 'sessions', sid)
rows = db.execute('SELECT * FROM (SELECT * FROM messages WHERE session_id=? ORDER BY created DESC LIMIT 500) ORDER BY created', (sid,)).fetchall()
return [self.public_message(row) for row in rows]
def claim(self, data):
sid, worker = identifier(data.get('session_id')), identifier(data.get('worker_id'))
seconds = data.get('lease_seconds', 300)
if isinstance(seconds, bool) or not isinstance(seconds, int) or not 10 <= seconds <= 3600:
raise Invalid('invalid lease_seconds')
now = time.time()
with self.db() as db:
db.execute('BEGIN IMMEDIATE')
session = self._get(db, 'sessions', sid)
session['last_seen'] = now
self._put(db, 'sessions', session)
db.execute("UPDATE messages SET status='uncertain' WHERE session_id=? AND status='claimed' AND started=1 AND lease_until<=?", (sid,now))
if db.execute("SELECT id FROM messages WHERE session_id=? AND status='uncertain'", (sid,)).fetchone():
return {'message': None, 'needs_reconciliation': True}
# Serialize each session, even when several bridge workers poll it.
if db.execute("SELECT id FROM messages WHERE session_id=? AND status='claimed' AND lease_until>?", (sid,now)).fetchone():
return {'message': None}
row = db.execute("SELECT * FROM messages WHERE session_id=? AND (status='queued' OR (status='claimed' AND lease_until<=?)) ORDER BY created LIMIT 1", (sid,now)).fetchone()
if not row:
return {'message': None}
lease = secrets.token_hex(24)
db.execute("UPDATE messages SET status='claimed',worker_id=?,lease_id=?,lease_until=? WHERE id=?", (worker,lease,now+seconds,row['id']))
result = self.public_message(db.execute('SELECT * FROM messages WHERE id=?', (row['id'],)).fetchone())
result.update(lease_id=lease, lease_until=now+seconds)
return {'message': result}
def start(self, data):
sid, worker = identifier(data.get('session_id')), identifier(data.get('worker_id'))
mid, lease = identifier(data.get('message_id')), identifier(data.get('lease_id'))
with self.db() as db:
db.execute('BEGIN IMMEDIATE')
row = db.execute('SELECT * FROM messages WHERE id=? AND session_id=?', (mid,sid)).fetchone()
if not row or row['worker_id'] != worker or row['lease_id'] != lease or row['status'] != 'claimed' or row['lease_until'] <= time.time():
raise Conflict('expired or invalid claim')
db.execute('UPDATE messages SET started=1 WHERE id=?', (mid,))
return {'started': True}
def uncertain(self, data):
sid, worker = identifier(data.get('session_id')), identifier(data.get('worker_id'))
mid, lease = identifier(data.get('message_id')), identifier(data.get('lease_id'))
with self.db() as db:
db.execute('BEGIN IMMEDIATE')
row = db.execute('SELECT * FROM messages WHERE id=? AND session_id=?', (mid,sid)).fetchone()
if not row or row['worker_id'] != worker or row['lease_id'] != lease or row['status'] not in ('claimed','uncertain'):
raise Conflict('invalid claim')
db.execute("UPDATE messages SET status='uncertain',started=1 WHERE id=?", (mid,))
self._event(db, 'message.uncertain', '执行结果不确定;会话已暂停自动领取,等待人工核查', sid)
return {'needs_reconciliation': True}
def reconcile(self, data):
sid, mid = identifier(data.get('session_id')), identifier(data.get('message_id'))
action = data.get('action')
resolution = string(data.get('resolution'), 'resolution', 4000)
if action not in ('retry','failed'):
raise Invalid('invalid reconciliation action')
with self.db() as db:
db.execute('BEGIN IMMEDIATE')
row = db.execute('SELECT * FROM messages WHERE id=? AND session_id=?', (mid,sid)).fetchone()
if not row or row['status'] != 'uncertain':
raise Conflict('not awaiting reconciliation')
db.execute('UPDATE messages SET status=?,started=0,worker_id=NULL,lease_id=NULL,lease_until=NULL WHERE id=?',
('queued' if action == 'retry' else 'failed',mid))
self._event(db, 'message.reconciled', action + ': ' + resolution, sid)
return {'status': 'queued' if action == 'retry' else 'failed'}
def renew(self, data):
sid, worker = identifier(data.get('session_id')), identifier(data.get('worker_id'))
mid, lease = identifier(data.get('message_id')), identifier(data.get('lease_id'))
seconds = data.get('lease_seconds', 300)
if isinstance(seconds, bool) or not isinstance(seconds, int) or not 10 <= seconds <= 3600:
raise Invalid('invalid lease_seconds')
now = time.time()
with self.db() as db:
db.execute('BEGIN IMMEDIATE')
row = db.execute('SELECT * FROM messages WHERE id=? AND session_id=?', (mid,sid)).fetchone()
if not row or row['worker_id'] != worker or row['lease_id'] != lease or row['status'] != 'claimed' or row['lease_until'] <= now:
raise Conflict('expired or invalid claim')
db.execute('UPDATE messages SET lease_until=? WHERE id=?', (now+seconds,mid))
session = self._get(db, 'sessions', sid)
session['last_seen'] = now
self._put(db, 'sessions', session)
return {'lease_until': now+seconds}
def complete(self, data):
sid, worker = identifier(data.get('session_id')), identifier(data.get('worker_id'))
mid, lease = identifier(data.get('message_id')), identifier(data.get('lease_id'))
body = string(data.get('text'), 'text', 262144)
outcome = data.get('outcome', 'completed')
if outcome not in ('completed','failed'):
raise Invalid('invalid outcome')
thread = string(data.get('thread_id'), 'thread_id', 200, outcome == 'failed')
now = time.time()
with self.db() as db:
db.execute('BEGIN IMMEDIATE')
row = db.execute('SELECT * FROM messages WHERE id=? AND session_id=?', (mid,sid)).fetchone()
if not row or row['worker_id'] != worker or row['lease_id'] != lease:
raise Conflict('invalid claim')
if row['status'] in ('completed','failed'):
response = db.execute('SELECT * FROM messages WHERE id=?', (row['response_id'],)).fetchone()
if not response or response['text'] != body or row['status'] != outcome:
raise Conflict('completion conflict')
return self.public_message(response)
if row['status'] != 'claimed' or row['lease_until'] <= now:
raise Conflict('expired claim')
session = self._get(db, 'sessions', sid)
if thread and session.get('thread_id') and session['thread_id'] != thread:
raise Conflict('thread identity conflict')
rid = secrets.token_hex(12)
db.execute('INSERT INTO messages(id,session_id,text,source,reply_to,idempotency_key,status,created) VALUES(?,?,?,?,?,?,?,?)',
(rid,sid,body,'agent',mid,'reply:'+mid,'delivered' if outcome == 'completed' else 'failed',now))
db.execute("UPDATE messages SET status=?,response_id=? WHERE id=?", (outcome,rid,mid))
session.update(thread_id=thread or session.get('thread_id',''),last_seen=now)
self._put(db, 'sessions', session)
self._event(db, 'message.' + outcome, '本机桥接已回传结果' if outcome == 'completed' else '本机桥接执行失败', sid)
return self.public_message(db.execute('SELECT * FROM messages WHERE id=?', (rid,)).fetchone())
def event(self, data):
kind = string(data.get('kind'), 'kind', 100)
body = string(data.get('text'), 'text', 8000)
sid = data.get('session_id')
with self.db() as db:
if sid:
self._get(db, 'sessions', identifier(sid))
return self._event(db, kind, body, sid)
def state(self):
with self.db() as db:
db.execute('BEGIN')
result = {table: [json.loads(row['data']) for row in db.execute('SELECT data FROM ' + table + ' ORDER BY id')]
for table in ('sessions','tasks','requests')}
result['events'] = [dict(json.loads(row['data']),id=row['id']) for row in db.execute('SELECT * FROM events ORDER BY id DESC LIMIT 100')]
result['summary'] = {row['status']: row['n'] for row in db.execute('SELECT json_extract(data,\'$.status\') status,count(*) n FROM tasks GROUP BY status')}
now = time.time()
for session in result['sessions']:
session['connection'] = ('connected' if session.get('thread_id') and session.get('last_seen') and now-session['last_seen'] < 120 else 'waiting')
result['updated'] = now
result['instance_id'] = self.instance_id
return result