Add reusable multi-session skill and five-page project workbench
This commit is contained in:
@@ -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))
|
||||
@@ -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
|
||||
@@ -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()
|
||||
@@ -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
|
||||
Reference in New Issue
Block a user