aizz-server 1.0.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Potentially problematic release.
This version of aizz-server might be problematic. Click here for more details.
- aizz_server/CHAT_INSTRUCTIONS.example.md +4 -0
- aizz_server/CHAT_MEMORY.example.md +3 -0
- aizz_server/__init__.py +2 -0
- aizz_server/app.py +2184 -0
- aizz_server/auth_middleware.py +73 -0
- aizz_server/background_tasks.py +248 -0
- aizz_server/chat_tools.py +487 -0
- aizz_server/cli.py +105 -0
- aizz_server/codex_bridge.py +386 -0
- aizz_server/codex_proxy.py +836 -0
- aizz_server/config.example.json +34 -0
- aizz_server/device_auth.py +155 -0
- aizz_server/migrate_storage.py +127 -0
- aizz_server/pairing.py +30 -0
- aizz_server/service_logging.py +27 -0
- aizz_server/skills.py +137 -0
- aizz_server/storage_paths.py +28 -0
- aizz_server/task_notifications.py +145 -0
- aizz_server/terminal.py +140 -0
- aizz_server/tool_policy.example.json +11 -0
- aizz_server/tool_policy.py +114 -0
- aizz_server/workspace_git.py +409 -0
- aizz_server/workspace_preview.py +30 -0
- aizz_server/workspaces.example.json +4 -0
- aizz_server/worktrees.py +45 -0
- aizz_server-1.0.0.dist-info/METADATA +546 -0
- aizz_server-1.0.0.dist-info/RECORD +30 -0
- aizz_server-1.0.0.dist-info/WHEEL +5 -0
- aizz_server-1.0.0.dist-info/entry_points.txt +2 -0
- aizz_server-1.0.0.dist-info/top_level.txt +1 -0
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
"""Authenticate all HTTP routes, and cancel in-flight work after device revocation."""
|
|
2
|
+
import asyncio
|
|
3
|
+
from contextlib import suppress
|
|
4
|
+
|
|
5
|
+
from starlette.requests import Request
|
|
6
|
+
from starlette.responses import JSONResponse
|
|
7
|
+
|
|
8
|
+
from . import device_auth
|
|
9
|
+
from . import codex_proxy
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
class DeviceAuthMiddleware:
|
|
13
|
+
def __init__(self, app, db_path):
|
|
14
|
+
self.app = app
|
|
15
|
+
self.db_path = db_path
|
|
16
|
+
|
|
17
|
+
async def __call__(self, scope, receive, send):
|
|
18
|
+
if scope['type'] != 'http':
|
|
19
|
+
return await self.app(scope, receive, send)
|
|
20
|
+
request = Request(scope)
|
|
21
|
+
compatible = codex_proxy.is_v1_path(request.url.path)
|
|
22
|
+
public = (request.method, request.url.path) in {
|
|
23
|
+
('GET', '/api/health'), ('POST', '/api/auth/pair'),
|
|
24
|
+
}
|
|
25
|
+
if compatible:
|
|
26
|
+
try:
|
|
27
|
+
codex_proxy.authorize(request, scope['app'].state.codex_proxy.settings)
|
|
28
|
+
except codex_proxy.ProxyError as exc:
|
|
29
|
+
response = await codex_proxy.proxy_error_handler(request, exc)
|
|
30
|
+
return await response(scope, receive, send)
|
|
31
|
+
return await self.app(scope, receive, send)
|
|
32
|
+
token = request.headers.get('authorization', '')
|
|
33
|
+
token = token[7:] if token.startswith('Bearer ') else ''
|
|
34
|
+
device = None if public else await asyncio.to_thread(device_auth.authenticate, self.db_path(), token)
|
|
35
|
+
if not public and device is None:
|
|
36
|
+
return await JSONResponse({'detail': '设备未授权或授权已撤销'}, status_code=401,
|
|
37
|
+
headers={'WWW-Authenticate': 'Bearer', 'Cache-Control': 'no-store'})(scope, receive, send)
|
|
38
|
+
scope.setdefault('state', {})['device'] = device
|
|
39
|
+
started = False
|
|
40
|
+
finished = False
|
|
41
|
+
|
|
42
|
+
async def secure_send(message):
|
|
43
|
+
nonlocal started, finished
|
|
44
|
+
if message['type'] == 'http.response.start':
|
|
45
|
+
started = True
|
|
46
|
+
message['headers'] = list(message.get('headers', [])) + [(b'cache-control', b'no-store')]
|
|
47
|
+
if message['type'] == 'http.response.body' and not message.get('more_body', False):
|
|
48
|
+
finished = True
|
|
49
|
+
await send(message)
|
|
50
|
+
|
|
51
|
+
if public:
|
|
52
|
+
return await self.app(scope, receive, secure_send)
|
|
53
|
+
task = asyncio.create_task(self.app(scope, receive, secure_send))
|
|
54
|
+
try:
|
|
55
|
+
while not task.done():
|
|
56
|
+
done, _ = await asyncio.wait({task}, timeout=1)
|
|
57
|
+
if done:
|
|
58
|
+
break
|
|
59
|
+
if not await asyncio.to_thread(device_auth.authenticate, self.db_path(), token, touch=False):
|
|
60
|
+
task.cancel()
|
|
61
|
+
with suppress(asyncio.CancelledError):
|
|
62
|
+
await task
|
|
63
|
+
if not started:
|
|
64
|
+
await JSONResponse({'detail': '设备授权已撤销'}, status_code=401)(scope, receive, secure_send)
|
|
65
|
+
elif not finished:
|
|
66
|
+
await secure_send({'type': 'http.response.body', 'body': b'', 'more_body': False})
|
|
67
|
+
return
|
|
68
|
+
await task
|
|
69
|
+
finally:
|
|
70
|
+
if not task.done():
|
|
71
|
+
task.cancel()
|
|
72
|
+
with suppress(asyncio.CancelledError):
|
|
73
|
+
await task
|
|
@@ -0,0 +1,248 @@
|
|
|
1
|
+
"""Durable, reconnectable subscriptions to independently owned generation tasks."""
|
|
2
|
+
import asyncio
|
|
3
|
+
import hashlib
|
|
4
|
+
import logging
|
|
5
|
+
from datetime import datetime, timezone
|
|
6
|
+
import json
|
|
7
|
+
import time
|
|
8
|
+
import uuid
|
|
9
|
+
from contextlib import suppress
|
|
10
|
+
from contextvars import ContextVar
|
|
11
|
+
|
|
12
|
+
from fastapi import HTTPException
|
|
13
|
+
|
|
14
|
+
ACCEPTANCE = ContextVar('task_acceptance', default=None)
|
|
15
|
+
|
|
16
|
+
TERMINAL = {'completed', 'failed', 'stopped', 'interrupted'}
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
class TaskManager:
|
|
20
|
+
def __init__(self, db, stop, notify):
|
|
21
|
+
self.db, self.stop, self.notify = db, stop, notify
|
|
22
|
+
self.workers = {}
|
|
23
|
+
self.lock = asyncio.Lock()
|
|
24
|
+
self.controls = {}
|
|
25
|
+
|
|
26
|
+
def initialize(self):
|
|
27
|
+
self.lock = asyncio.Lock()
|
|
28
|
+
with self.db() as c:
|
|
29
|
+
c.executescript('''
|
|
30
|
+
CREATE TABLE IF NOT EXISTS generation_tasks (
|
|
31
|
+
id TEXT PRIMARY KEY, session_id TEXT NOT NULL REFERENCES sessions(id) ON DELETE CASCADE,
|
|
32
|
+
device_id TEXT NOT NULL, request_id TEXT NOT NULL, request_hash TEXT NOT NULL,
|
|
33
|
+
state TEXT NOT NULL, snapshot TEXT NOT NULL, created_at REAL NOT NULL,
|
|
34
|
+
UNIQUE(device_id, request_id)
|
|
35
|
+
);
|
|
36
|
+
CREATE TABLE IF NOT EXISTS generation_events (
|
|
37
|
+
task_id TEXT NOT NULL REFERENCES generation_tasks(id) ON DELETE CASCADE,
|
|
38
|
+
seq INTEGER NOT NULL, event TEXT NOT NULL, created_at REAL NOT NULL,
|
|
39
|
+
PRIMARY KEY(task_id,seq)
|
|
40
|
+
);
|
|
41
|
+
CREATE INDEX IF NOT EXISTS generation_session ON generation_tasks(session_id,created_at);
|
|
42
|
+
''')
|
|
43
|
+
# A command may already have executed before a crash. Never replay it.
|
|
44
|
+
rows = c.execute("SELECT * FROM generation_tasks WHERE state NOT IN ('completed','failed','stopped','interrupted')").fetchall()
|
|
45
|
+
for row in rows:
|
|
46
|
+
snap = json.loads(row['snapshot'])
|
|
47
|
+
snap.update(state='interrupted', pending={}, error='服务端已重启,任务已中断,请确认已有操作后重新发送')
|
|
48
|
+
snap['content'] += '\n\n(服务端重启,任务已中断)'
|
|
49
|
+
saved = c.execute("SELECT id,content FROM messages WHERE session_id=? AND role='assistant' AND rowid>(SELECT rowid FROM messages WHERE id=?) ORDER BY rowid LIMIT 1", (row['session_id'], snap.get('user_message_id'))).fetchone()
|
|
50
|
+
message = saved['id'] if saved else str(uuid.uuid4())
|
|
51
|
+
snap['message_id'] = message
|
|
52
|
+
if saved:
|
|
53
|
+
# The message transaction may have committed just before the process died.
|
|
54
|
+
snap['content'] = saved['content'] + '\n\n(服务端重启,任务已中断)'
|
|
55
|
+
c.execute('UPDATE messages SET content=?,mode=? WHERE id=?', (snap['content'], 'error', message))
|
|
56
|
+
else:
|
|
57
|
+
c.execute('INSERT INTO messages VALUES (?,?,?,?,?,?)', (message, row['session_id'], 'assistant', snap['content'], 'error', datetime.now(timezone.utc).isoformat()))
|
|
58
|
+
c.execute('INSERT OR REPLACE INTO message_runs VALUES (?,?)', (message, json.dumps(snap['execution'], ensure_ascii=False)))
|
|
59
|
+
snap['seq'] += 1
|
|
60
|
+
c.execute('UPDATE generation_tasks SET state=?,snapshot=? WHERE id=?', ('interrupted', json.dumps(snap, ensure_ascii=False), row['id']))
|
|
61
|
+
c.execute('INSERT INTO generation_events VALUES (?,?,?,?)', (row['id'], snap['seq'], json.dumps({'type': 'snapshot', 'data': snap, 'seq': snap['seq']}, ensure_ascii=False), time.time()))
|
|
62
|
+
c.execute('DELETE FROM generation_events WHERE created_at < ?', (time.time() - 7 * 86400,))
|
|
63
|
+
|
|
64
|
+
def get(self, task_id):
|
|
65
|
+
with self.db() as c:
|
|
66
|
+
row = c.execute('SELECT * FROM generation_tasks WHERE id=?', (task_id,)).fetchone()
|
|
67
|
+
if row is None:
|
|
68
|
+
raise HTTPException(404, '任务不存在')
|
|
69
|
+
return json.loads(row['snapshot'])
|
|
70
|
+
|
|
71
|
+
def session(self, session_id):
|
|
72
|
+
with self.db() as c:
|
|
73
|
+
row = c.execute('SELECT id FROM generation_tasks WHERE session_id=? ORDER BY created_at DESC,rowid DESC LIMIT 1', (session_id,)).fetchone()
|
|
74
|
+
return self.get(row['id']) if row else None
|
|
75
|
+
|
|
76
|
+
def dismiss_plan(self, task_id):
|
|
77
|
+
with self.db() as c:
|
|
78
|
+
row = c.execute('SELECT snapshot FROM generation_tasks WHERE id=?', (task_id,)).fetchone()
|
|
79
|
+
if row is None:
|
|
80
|
+
raise HTTPException(404, '任务不存在')
|
|
81
|
+
snap = json.loads(row['snapshot'])
|
|
82
|
+
if snap['state'] != 'completed' or not snap.get('plan'):
|
|
83
|
+
raise HTTPException(409, '没有待确认的计划')
|
|
84
|
+
snap['plan_dismissed'] = True
|
|
85
|
+
c.execute('UPDATE generation_tasks SET snapshot=? WHERE id=?', (json.dumps(snap, ensure_ascii=False), task_id))
|
|
86
|
+
return snap
|
|
87
|
+
|
|
88
|
+
async def create(self, session_id, device_id, request_id, body, prepare):
|
|
89
|
+
if not 16 <= len(request_id) <= 128:
|
|
90
|
+
raise HTTPException(400, '请求 ID 长度必须为 16–128 字符')
|
|
91
|
+
fingerprint = hashlib.sha256(json.dumps({'session': session_id, 'body': body}, sort_keys=True).encode()).hexdigest()
|
|
92
|
+
async with self.lock:
|
|
93
|
+
with self.db() as c:
|
|
94
|
+
existing = c.execute('SELECT id,request_hash FROM generation_tasks WHERE device_id=? AND request_id=?', (device_id, request_id)).fetchone()
|
|
95
|
+
if existing:
|
|
96
|
+
if existing['request_hash'] != fingerprint:
|
|
97
|
+
raise HTTPException(409, '请求 ID 已用于不同消息')
|
|
98
|
+
return self.get(existing['id'])
|
|
99
|
+
current = self.session(session_id)
|
|
100
|
+
if current and current['state'] not in TERMINAL:
|
|
101
|
+
raise HTTPException(409, '当前会话已有回复正在生成')
|
|
102
|
+
task_id = str(uuid.uuid4())
|
|
103
|
+
snap = dict(user_message_id=None, id=task_id, session_id=session_id, request_id=request_id, state='running', seq=0,
|
|
104
|
+
content='', mode=body['mode'], plan=body.get('plan', False), plan_dismissed=False, execution={'duration_ms': 0, 'items': []},
|
|
105
|
+
pending={}, message_id=None, error=None)
|
|
106
|
+
# The existing message transaction also writes the idempotency record.
|
|
107
|
+
# Either both are durable, or neither is accepted.
|
|
108
|
+
token = ACCEPTANCE.set((snap, device_id, fingerprint))
|
|
109
|
+
try:
|
|
110
|
+
response = await prepare()
|
|
111
|
+
finally:
|
|
112
|
+
ACCEPTANCE.reset(token)
|
|
113
|
+
self.workers[task_id] = asyncio.create_task(self.run(snap, device_id, response))
|
|
114
|
+
return snap.copy()
|
|
115
|
+
|
|
116
|
+
def accept_message(self, connection, role, message_id):
|
|
117
|
+
acceptance = ACCEPTANCE.get()
|
|
118
|
+
if role != 'user' or acceptance is None:
|
|
119
|
+
return
|
|
120
|
+
snap, device_id, fingerprint = acceptance
|
|
121
|
+
snap['user_message_id'] = message_id
|
|
122
|
+
snap['mode'] = connection.execute('SELECT mode FROM messages WHERE id=?', (message_id,)).fetchone()['mode']
|
|
123
|
+
connection.execute('INSERT INTO generation_tasks VALUES (?,?,?,?,?,?,?,?)',
|
|
124
|
+
(snap['id'], snap['session_id'], device_id, snap['request_id'], fingerprint,
|
|
125
|
+
snap['state'], json.dumps(snap, ensure_ascii=False), time.time()))
|
|
126
|
+
|
|
127
|
+
async def publish(self, snap, kind, data):
|
|
128
|
+
if kind == 'heartbeat':
|
|
129
|
+
return
|
|
130
|
+
if kind == 'delta':
|
|
131
|
+
snap['content'] += data
|
|
132
|
+
elif kind == 'execution':
|
|
133
|
+
items = snap['execution']['items']
|
|
134
|
+
items[:] = [item for item in items if item['id'] != data['id']] + [data]
|
|
135
|
+
elif kind == 'activity':
|
|
136
|
+
snap['execution']['items'].append({'id': str(uuid.uuid4()), 'type': 'notice', 'text': str(data)})
|
|
137
|
+
elif kind in ('approval', 'user_input'):
|
|
138
|
+
snap['pending'][data['token']] = {'type': kind, 'data': data}
|
|
139
|
+
elif kind == 'approval_resolved':
|
|
140
|
+
snap['pending'].pop(data, None)
|
|
141
|
+
elif kind == 'error':
|
|
142
|
+
snap.update(state='failed', error=str(data))
|
|
143
|
+
elif kind == 'interrupted':
|
|
144
|
+
snap.update(state='interrupted', error=str(data))
|
|
145
|
+
elif kind == 'done':
|
|
146
|
+
snap['state'] = 'stopped' if data.get('stopped') else 'completed'
|
|
147
|
+
if kind not in ('done', 'error', 'interrupted'):
|
|
148
|
+
snap['state'] = ('waiting_approval' if any(p['type'] == 'approval' for p in snap['pending'].values()) else
|
|
149
|
+
'waiting_input' if snap['pending'] else 'running')
|
|
150
|
+
if snap['state'] in TERMINAL:
|
|
151
|
+
snap['pending'].clear()
|
|
152
|
+
with self.db() as c:
|
|
153
|
+
row = c.execute("SELECT id FROM messages WHERE session_id=? AND role='assistant' ORDER BY rowid DESC LIMIT 1", (snap['session_id'],)).fetchone()
|
|
154
|
+
snap['message_id'] = row['id'] if row else None
|
|
155
|
+
if kind == 'interrupted' and row:
|
|
156
|
+
with self.db() as c:
|
|
157
|
+
c.execute('UPDATE messages SET content=?,mode=? WHERE id=?', (snap['content'] + '\n\n(服务端关闭,任务已中断)', 'error', row['id']))
|
|
158
|
+
snap['seq'] += 1
|
|
159
|
+
payload = {'type': 'snapshot' if kind == 'interrupted' else kind, 'data': snap if kind == 'interrupted' else data, 'seq': snap['seq']}
|
|
160
|
+
with self.db() as c:
|
|
161
|
+
if snap['state'] in TERMINAL:
|
|
162
|
+
c.execute('DELETE FROM generation_events WHERE created_at < ?', (time.time() - 7 * 86400,))
|
|
163
|
+
c.execute('UPDATE generation_tasks SET state=?,snapshot=? WHERE id=?', (snap['state'], json.dumps(snap, ensure_ascii=False), snap['id']))
|
|
164
|
+
c.execute('INSERT INTO generation_events VALUES (?,?,?,?)', (snap['id'], snap['seq'], json.dumps(payload, ensure_ascii=False), time.time()))
|
|
165
|
+
if kind in ('approval', 'user_input', 'error') or (kind == 'done' and not data.get('stopped')):
|
|
166
|
+
try:
|
|
167
|
+
await self.notify(snap, kind, data)
|
|
168
|
+
except Exception:
|
|
169
|
+
logging.getLogger('aizz.agent').exception('任务通知入队失败 task=%s', snap['id'])
|
|
170
|
+
|
|
171
|
+
async def resolved(self, session_id, token):
|
|
172
|
+
current = self.session(session_id)
|
|
173
|
+
if current and token in current['pending']:
|
|
174
|
+
# The runner owns its snapshot; mutate that object via a control event.
|
|
175
|
+
self.controls.setdefault(current['id'], []).append(token)
|
|
176
|
+
|
|
177
|
+
async def run(self, snap, device_id, response):
|
|
178
|
+
started = time.monotonic()
|
|
179
|
+
iterator = response.body_iterator
|
|
180
|
+
pending = None
|
|
181
|
+
try:
|
|
182
|
+
while True:
|
|
183
|
+
if pending is None:
|
|
184
|
+
pending = asyncio.create_task(anext(iterator))
|
|
185
|
+
done, _ = await asyncio.wait({pending}, timeout=0.25)
|
|
186
|
+
for token in self.controls.pop(snap['id'], []):
|
|
187
|
+
if token in snap['pending']:
|
|
188
|
+
await self.publish(snap, 'approval_resolved', token)
|
|
189
|
+
with self.db() as c:
|
|
190
|
+
active = c.execute('SELECT 1 FROM authorized_devices WHERE id=? AND revoked_at IS NULL', (device_id,)).fetchone()
|
|
191
|
+
if not active:
|
|
192
|
+
await self.stop(snap['session_id'])
|
|
193
|
+
if not done:
|
|
194
|
+
continue
|
|
195
|
+
try:
|
|
196
|
+
raw = pending.result()
|
|
197
|
+
except StopAsyncIteration:
|
|
198
|
+
break
|
|
199
|
+
pending = None
|
|
200
|
+
if isinstance(raw, bytes):
|
|
201
|
+
raw = raw.decode()
|
|
202
|
+
item = json.loads(raw.removeprefix('data: ').strip())
|
|
203
|
+
snap['execution']['duration_ms'] = int((time.monotonic() - started) * 1000)
|
|
204
|
+
await self.publish(snap, item['type'], item['data'])
|
|
205
|
+
except asyncio.CancelledError:
|
|
206
|
+
raise
|
|
207
|
+
except Exception as exc:
|
|
208
|
+
await self.stop(snap['session_id'])
|
|
209
|
+
await self.publish(snap, 'error', str(exc))
|
|
210
|
+
finally:
|
|
211
|
+
if pending:
|
|
212
|
+
pending.cancel()
|
|
213
|
+
await asyncio.gather(pending, return_exceptions=True)
|
|
214
|
+
with suppress(Exception):
|
|
215
|
+
await iterator.aclose()
|
|
216
|
+
self.workers.pop(snap['id'], None)
|
|
217
|
+
|
|
218
|
+
async def events(self, task_id, after):
|
|
219
|
+
while True:
|
|
220
|
+
snap = self.get(task_id)
|
|
221
|
+
with self.db() as c:
|
|
222
|
+
rows = c.execute('SELECT seq,event FROM generation_events WHERE task_id=? AND seq>? ORDER BY seq LIMIT 100', (task_id, after)).fetchall()
|
|
223
|
+
if rows and rows[0]['seq'] == after + 1:
|
|
224
|
+
for row in rows:
|
|
225
|
+
after = row['seq']
|
|
226
|
+
yield 'data: ' + row['event'] + '\n\n'
|
|
227
|
+
continue
|
|
228
|
+
if after != snap['seq'] or after == 0:
|
|
229
|
+
after = snap['seq']
|
|
230
|
+
yield 'data: ' + json.dumps({'type': 'snapshot', 'data': snap, 'seq': after}, ensure_ascii=False) + '\n\n'
|
|
231
|
+
if snap['state'] in TERMINAL:
|
|
232
|
+
return
|
|
233
|
+
yield ': heartbeat\n\n'
|
|
234
|
+
await asyncio.sleep(0.5)
|
|
235
|
+
|
|
236
|
+
async def shutdown(self):
|
|
237
|
+
running = list(self.workers)
|
|
238
|
+
for task_id in running:
|
|
239
|
+
await self.stop(self.get(task_id)['session_id'])
|
|
240
|
+
if self.workers:
|
|
241
|
+
await asyncio.gather(*list(self.workers.values()), return_exceptions=True)
|
|
242
|
+
for task_id in running:
|
|
243
|
+
try:
|
|
244
|
+
snap = self.get(task_id)
|
|
245
|
+
if snap['state'] == 'stopped':
|
|
246
|
+
await self.publish(snap, 'interrupted', '服务端关闭,任务已中断,请确认已有操作后重新发送')
|
|
247
|
+
except HTTPException:
|
|
248
|
+
pass
|