@agentsdock/server 1.0.11-beta.2 → 1.0.12-beta.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/package.json +1 -1
- package/server/VERSION +1 -1
- package/server/agent_server.py +1608 -351
- package/server/agentsdock_notify.py +123 -0
- package/server/chat_mailbox.py +12 -7
- package/server/claude_sdk_client.py +585 -317
- package/server/codex_app_server.py +19 -10
- package/server/codex_side_question.py +85 -7
- package/server/cursor_agent_client.py +22 -2
- package/server/install.sh +20 -3
- package/server/interactive_chat_share_routes.py +1 -1
- package/server/interactive_chat_share_web.py +2 -2
- package/server/npm/agentsdock/QR-NOTICES.txt +56 -0
- package/server/npm/agentsdock/pair.cjs +123 -0
- package/server/npm/agentsdock/qr-renderer.cjs +15 -0
- package/server/public_chat_share_routes.py +8 -6
- package/server/public_chat_shares.py +17 -7
- package/server/public_chat_transcript.py +11 -3
- package/server/session_unread.py +184 -0
- package/server/shared_chat_analytics_web.py +5 -0
- package/server/side_questions.py +46 -14
|
@@ -0,0 +1,184 @@
|
|
|
1
|
+
"""Incremental unread provenance; journal recovery runs off the event loop."""
|
|
2
|
+
from __future__ import annotations
|
|
3
|
+
|
|
4
|
+
from pathlib import Path
|
|
5
|
+
from typing import Any, Callable, Iterable
|
|
6
|
+
from transcript_reader import jsonl_records
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
COUNTER = "latest_non_job_agent_event_seq"
|
|
10
|
+
CHECKPOINT = "_non_job_agent_event_checkpoint_seq"
|
|
11
|
+
PENDING_COUNTER = "_non_job_agent_event_pending_seq"
|
|
12
|
+
LAST_RUN = "_non_job_agent_last_run"
|
|
13
|
+
JOURNAL = "_non_job_agent_event_journal"
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
def imported_event(event: dict[str, Any]) -> bool:
|
|
17
|
+
return event.get("imported") is True or str(event.get("run_id") or "").startswith("import_")
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
def scheduled_event(event: dict[str, Any]) -> bool:
|
|
21
|
+
job = event.get("job")
|
|
22
|
+
return bool(
|
|
23
|
+
event.get("purpose") == "scheduled_job"
|
|
24
|
+
or str(event.get("job_id") or "").strip()
|
|
25
|
+
or isinstance(job, dict) and str(job.get("id") or "").strip()
|
|
26
|
+
or str(event.get("type") or "").startswith("job_")
|
|
27
|
+
)
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
def project_event(
|
|
31
|
+
session: dict[str, Any], event: dict[str, Any], *,
|
|
32
|
+
visible: bool, run_metadata: dict[str, Any],
|
|
33
|
+
) -> None:
|
|
34
|
+
"""Apply an appended event without I/O or changing the ordinary read cursor."""
|
|
35
|
+
seq = int(event.get("seq") or 0)
|
|
36
|
+
if seq <= 0:
|
|
37
|
+
return
|
|
38
|
+
# Only a new journal can establish an empty baseline without recovery.
|
|
39
|
+
if seq == 1 and not int(session.get("latest_event_seq") or 0):
|
|
40
|
+
session.setdefault(COUNTER, 0)
|
|
41
|
+
session.setdefault(CHECKPOINT, 0)
|
|
42
|
+
known = isinstance(session.get(COUNTER), int) and CHECKPOINT in session
|
|
43
|
+
run_id = str(event.get("run_id") or "")
|
|
44
|
+
contexts: list[dict[str, Any]] = []
|
|
45
|
+
if event.get("type") == "turn_started" and run_id:
|
|
46
|
+
context = {"run_id": run_id, "scheduled_job": scheduled_event(event) or scheduled_event(run_metadata)}
|
|
47
|
+
session[LAST_RUN] = context
|
|
48
|
+
contexts.append(context)
|
|
49
|
+
else:
|
|
50
|
+
for candidate in (session.get("active_run"), session.get(LAST_RUN)):
|
|
51
|
+
if isinstance(candidate, dict) and run_id and candidate.get("run_id") == run_id:
|
|
52
|
+
contexts.append(candidate)
|
|
53
|
+
scheduled = scheduled_event(event) or scheduled_event(run_metadata) or any(
|
|
54
|
+
scheduled_event(context) or context.get("scheduled_job") is True for context in contexts
|
|
55
|
+
)
|
|
56
|
+
if scheduled and run_id and (session.get(LAST_RUN) or {}).get("run_id") == run_id:
|
|
57
|
+
session[LAST_RUN] = {"run_id": run_id, "scheduled_job": True}
|
|
58
|
+
if visible and not scheduled and not imported_event(event):
|
|
59
|
+
field = COUNTER if known else PENDING_COUNTER
|
|
60
|
+
session[field] = max(int(session.get(field) or 0), seq)
|
|
61
|
+
if known:
|
|
62
|
+
session[CHECKPOINT] = max(int(session.get(CHECKPOINT) or 0), seq)
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
def recover_projection(
|
|
66
|
+
path: Path, snapshot: dict[str, Any], *,
|
|
67
|
+
visible: Callable[[str, dict[str, Any]], bool],
|
|
68
|
+
resume_offset: Callable[[Path, int], int],
|
|
69
|
+
prior_events: Callable[[], Iterable[dict[str, Any]]],
|
|
70
|
+
cancelled: Callable[[], bool],
|
|
71
|
+
) -> dict[str, Any]:
|
|
72
|
+
"""Project one fixed journal extent, then resolve missing run provenance once.
|
|
73
|
+
|
|
74
|
+
Legacy chats need one forward pass. A durable checkpoint resumes through the
|
|
75
|
+
existing sparse index; only an untagged tail run whose start precedes that
|
|
76
|
+
checkpoint requires a backwards provenance pass. No history is read by the
|
|
77
|
+
session-list endpoint or the live event projection.
|
|
78
|
+
"""
|
|
79
|
+
known = isinstance(snapshot.get(COUNTER), int) and CHECKPOINT in snapshot
|
|
80
|
+
after = int(snapshot.get(CHECKPOINT) or 0) if known else 0
|
|
81
|
+
latest = int(snapshot.get(COUNTER) or 0) if known else 0
|
|
82
|
+
through = after
|
|
83
|
+
last_run = dict(snapshot.get(LAST_RUN) or {})
|
|
84
|
+
runs: dict[str, dict[str, Any]] = {}
|
|
85
|
+
unresolved: dict[str, list[dict[str, Any]]] = {}
|
|
86
|
+
|
|
87
|
+
def finish(run_id: str, run: dict[str, Any]) -> None:
|
|
88
|
+
nonlocal latest
|
|
89
|
+
if run["scheduled"] or not run["visible_seq"]:
|
|
90
|
+
return
|
|
91
|
+
if run["origin"]:
|
|
92
|
+
latest = max(latest, run["visible_seq"])
|
|
93
|
+
else:
|
|
94
|
+
unresolved.setdefault(run_id, []).append(run)
|
|
95
|
+
|
|
96
|
+
try:
|
|
97
|
+
source = path.open("rb")
|
|
98
|
+
except FileNotFoundError:
|
|
99
|
+
if after or int(snapshot.get("latest_event_seq") or 0):
|
|
100
|
+
raise
|
|
101
|
+
return {COUNTER: 0, CHECKPOINT: 0, LAST_RUN: {}}
|
|
102
|
+
with source:
|
|
103
|
+
extent = source.seek(0, 2)
|
|
104
|
+
inode = path.stat().st_ino
|
|
105
|
+
journal = snapshot.get(JOURNAL) or {}
|
|
106
|
+
if known and journal and (journal.get("inode") != inode or int(journal.get("offset") or 0) > extent):
|
|
107
|
+
after = latest = through = 0
|
|
108
|
+
last_run = {}
|
|
109
|
+
offset = int(journal.get("offset") or 0) if after and journal.get("inode") == inode else 0
|
|
110
|
+
source.seek(offset or (resume_offset(path, after) if after else 0))
|
|
111
|
+
complete_offset = source.tell()
|
|
112
|
+
for event, ending, raw in jsonl_records(
|
|
113
|
+
source, end=extent, project=False, require_newline=False, deadline=float("inf"), cancelled=cancelled,
|
|
114
|
+
max_records=float("inf"), max_nodes=None, max_depth=512,
|
|
115
|
+
retained_fields=frozenset({"seq", "type", "run_id", "purpose", "job_id", "job", "text", "result_text", "goal", "imported"}),
|
|
116
|
+
):
|
|
117
|
+
if not raw.complete:
|
|
118
|
+
break # Leave a concurrently written/crash fragment unclaimed.
|
|
119
|
+
complete_offset = ending
|
|
120
|
+
if not isinstance(event, dict):
|
|
121
|
+
continue
|
|
122
|
+
seq = int(event.get("seq") or 0)
|
|
123
|
+
if seq <= after:
|
|
124
|
+
continue
|
|
125
|
+
through = max(through, seq)
|
|
126
|
+
event_type = str(event.get("type") or "")
|
|
127
|
+
is_visible = visible(event_type, event) and not imported_event(event)
|
|
128
|
+
run_id = str(event.get("run_id") or "")
|
|
129
|
+
scheduled = scheduled_event(event)
|
|
130
|
+
if not run_id:
|
|
131
|
+
if is_visible and not scheduled:
|
|
132
|
+
latest = max(latest, seq)
|
|
133
|
+
continue
|
|
134
|
+
if event_type == "turn_started" and run_id in runs:
|
|
135
|
+
finish(run_id, runs.pop(run_id))
|
|
136
|
+
run = runs.setdefault(run_id, {"first_seq": seq, "visible_seq": 0, "scheduled": False, "origin": False})
|
|
137
|
+
run["scheduled"] = run["scheduled"] or scheduled
|
|
138
|
+
if event_type == "turn_started":
|
|
139
|
+
run["origin"] = True
|
|
140
|
+
last_run = {"run_id": run_id, "scheduled_job": scheduled}
|
|
141
|
+
elif scheduled and last_run.get("run_id") == run_id:
|
|
142
|
+
last_run["scheduled_job"] = True
|
|
143
|
+
if is_visible:
|
|
144
|
+
run["visible_seq"] = max(run["visible_seq"], seq)
|
|
145
|
+
if event_type in {"turn_finished", "turn_stopped"} and (run["scheduled"] or not run["visible_seq"]):
|
|
146
|
+
# Completed cron runs do not accumulate with the schedule's
|
|
147
|
+
# history. A later untagged event is resolved by the single
|
|
148
|
+
# backwards provenance pass, just like a restart tail.
|
|
149
|
+
runs.pop(run_id, None)
|
|
150
|
+
for run_id, run in runs.items():
|
|
151
|
+
finish(run_id, run)
|
|
152
|
+
# One reverse pass resolves all tail runs together, including stripped raw
|
|
153
|
+
# provider events and a job_ran marker emitted after the provider's start.
|
|
154
|
+
if unresolved:
|
|
155
|
+
for event in prior_events():
|
|
156
|
+
if cancelled():
|
|
157
|
+
raise InterruptedError("unread projection cancelled")
|
|
158
|
+
run_id = str(event.get("run_id") or "")
|
|
159
|
+
candidates = unresolved.get(run_id)
|
|
160
|
+
if not candidates:
|
|
161
|
+
continue
|
|
162
|
+
seq = int(event.get("seq") or 0)
|
|
163
|
+
scheduled = scheduled_event(event)
|
|
164
|
+
if not scheduled and event.get("type") != "turn_started":
|
|
165
|
+
continue
|
|
166
|
+
remaining = []
|
|
167
|
+
for run in candidates:
|
|
168
|
+
if seq >= run["first_seq"]:
|
|
169
|
+
remaining.append(run)
|
|
170
|
+
elif not scheduled:
|
|
171
|
+
latest = max(latest, run["visible_seq"])
|
|
172
|
+
if remaining:
|
|
173
|
+
unresolved[run_id] = remaining
|
|
174
|
+
else:
|
|
175
|
+
unresolved.pop(run_id)
|
|
176
|
+
if not unresolved:
|
|
177
|
+
break
|
|
178
|
+
# Legacy output with no recorded job provenance remains ordinary;
|
|
179
|
+
# merely owning a schedule never suppresses a human conversation.
|
|
180
|
+
for candidates in unresolved.values():
|
|
181
|
+
for run in candidates:
|
|
182
|
+
latest = max(latest, run["visible_seq"])
|
|
183
|
+
return {COUNTER: latest, CHECKPOINT: through, LAST_RUN: last_run,
|
|
184
|
+
JOURNAL: {"inode": inode, "offset": complete_offset}}
|
|
@@ -0,0 +1,5 @@
|
|
|
1
|
+
"""Generated from the limited shared-chat analytics entry; do not edit by hand."""
|
|
2
|
+
import json
|
|
3
|
+
|
|
4
|
+
SCRIPT = json.loads("\"/*! @noble/hashes\\nThe MIT License (MIT)\\n\\nCopyright (c) 2022 Paul Miller (https://paulmillr.com)\\n\\nPermission is hereby granted, free of charge, to any person obtaining a copy\\nof this software and associated documentation files (the “Software”), to deal\\nin the Software without restriction, including without limitation the rights\\nto use, copy, modify, merge, publish, distribute, sublicense, and/or sell\\ncopies of the Software, and to permit persons to whom the Software is\\nfurnished to do so, subject to the following conditions:\\n\\nThe above copyright notice and this permission notice shall be included in\\nall copies or substantial portions of the Software.\\n\\nTHE SOFTWARE IS PROVIDED “AS IS”, WITHOUT WARRANTY OF ANY KIND, EXPRESS OR\\nIMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,\\nFITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE\\nAUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER\\nLIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,\\nOUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN\\nTHE SOFTWARE.\\n*/(function(){\\\"use strict\\\";function H(e){return e instanceof Uint8Array||ArrayBuffer.isView(e)&&e.constructor.name===\\\"Uint8Array\\\"&&\\\"BYTES_PER_ELEMENT\\\"in e&&e.BYTES_PER_ELEMENT===1}function p(e,t,s=\\\"\\\"){const n=H(e),r=e?.length;if(!n||t!==void 0){const i=s&&`\\\"${s}\\\" `,o=\\\"\\\",f=n?`length=${r}`:`type=${typeof e}`,h=i+\\\"expected Uint8Array\\\"+o+\\\", got \\\"+f;throw n?new RangeError(h):new TypeError(h)}return e}function m(e,t=!0){if(e.destroyed)throw new Error(\\\"Hash instance has been destroyed\\\");if(t&&e.finished)throw new Error(\\\"Hash#digest() has already been called\\\")}function S(e,t){p(e,void 0,\\\"digestInto() output\\\");const s=t.outputLen;if(e.length<s)throw new RangeError('\\\"digestInto() output\\\" expected to be of length >='+s)}function y(...e){for(let t=0;t<e.length;t++)e[t].fill(0)}function _(e){return new DataView(e.buffer,e.byteOffset,e.byteLength)}function d(e,t){return e<<32-t|e>>>t}const T=typeof Uint8Array.from([]).toHex==\\\"function\\\"&&typeof Uint8Array.fromHex==\\\"function\\\",k=Array.from({length:256},(e,t)=>t.toString(16).padStart(2,\\\"0\\\"));function B(e){if(p(e),T)return e.toHex();let t=\\\"\\\";for(let s=0;s<e.length;s++)t+=k[e[s]];return t}function O(e){if(typeof e!=\\\"string\\\")throw new TypeError(\\\"string expected\\\");return new Uint8Array(new TextEncoder().encode(e))}function U(e,t={}){const s=(r,c)=>e(c).update(r).digest(),n=e(void 0);return s.outputLen=n.outputLen,s.blockLen=n.blockLen,s.canXOF=n.canXOF,s.create=r=>e(r),Object.assign(s,t),Object.freeze(s)}const v=e=>({oid:Uint8Array.from([6,9,96,134,72,1,101,3,4,2,e])});function C(e,t,s){return e&t^~e&s}function I(e,t,s){return e&t^e&s^t&s}class D{blockLen;outputLen;canXOF=!1;padOffset;isLE;buffer;view;finished=!1;length=0;pos=0;destroyed=!1;constructor(t,s,n,r){this.blockLen=t,this.outputLen=s,this.padOffset=n,this.isLE=r,this.buffer=new Uint8Array(t),this.view=_(this.buffer)}update(t){m(this),p(t);const{view:s,buffer:n,blockLen:r}=this,c=t.length;for(let i=0;i<c;){const o=Math.min(r-this.pos,c-i);if(o===r){const f=_(t);for(;r<=c-i;i+=r)this.process(f,i);continue}n.set(t.subarray(i,i+o),this.pos),this.pos+=o,i+=o,this.pos===r&&(this.process(s,0),this.pos=0)}return this.length+=t.length,this.roundClean(),this}digestInto(t){m(this),S(t,this),this.finished=!0;const{buffer:s,view:n,blockLen:r,isLE:c}=this;let{pos:i}=this;s[i++]=128,y(this.buffer.subarray(i)),this.padOffset>r-i&&(this.process(n,0),i=0);for(let a=i;a<r;a++)s[a]=0;n.setBigUint64(r-8,BigInt(this.length*8),c),this.process(n,0);const o=_(t),f=this.outputLen;if(f%4)throw new Error(\\\"_sha2: outputLen must be aligned to 32bit\\\");const h=f/4,l=this.get();if(h>l.length)throw new Error(\\\"_sha2: outputLen bigger than state\\\");for(let a=0;a<h;a++)o.setUint32(4*a,l[a],c)}digest(){const{buffer:t,outputLen:s}=this;this.digestInto(t);const n=t.slice(0,s);return this.destroy(),n}_cloneInto(t){t||=new this.constructor,t.set(...this.get());const{blockLen:s,buffer:n,length:r,finished:c,destroyed:i,pos:o}=this;return t.destroyed=i,t.finished=c,t.length=r,t.pos=o,r%s&&t.buffer.set(n),t}clone(){return this._cloneInto()}}const x=Uint32Array.from([1779033703,3144134277,1013904242,2773480762,1359893119,2600822924,528734635,1541459225]),N=Uint32Array.from([1116352408,1899447441,3049323471,3921009573,961987163,1508970993,2453635748,2870763221,3624381080,310598401,607225278,1426881987,1925078388,2162078206,2614888103,3248222580,3835390401,4022224774,264347078,604807628,770255983,1249150122,1555081692,1996064986,2554220882,2821834349,2952996808,3210313671,3336571891,3584528711,113926993,338241895,666307205,773529912,1294757372,1396182291,1695183700,1986661051,2177026350,2456956037,2730485921,2820302411,3259730800,3345764771,3516065817,3600352804,4094571909,275423344,430227734,506948616,659060556,883997877,958139571,1322822218,1537002063,1747873779,1955562222,2024104815,2227730452,2361852424,2428436474,2756734187,3204031479,3329325298]),u=new Uint32Array(64);class F extends D{constructor(t){super(64,t,8,!1)}get(){const{A:t,B:s,C:n,D:r,E:c,F:i,G:o,H:f}=this;return[t,s,n,r,c,i,o,f]}set(t,s,n,r,c,i,o,f){this.A=t|0,this.B=s|0,this.C=n|0,this.D=r|0,this.E=c|0,this.F=i|0,this.G=o|0,this.H=f|0}process(t,s){for(let a=0;a<16;a++,s+=4)u[a]=t.getUint32(s,!1);for(let a=16;a<64;a++){const g=u[a-15],b=u[a-2],L=d(g,7)^d(g,18)^g>>>3,w=d(b,17)^d(b,19)^b>>>10;u[a]=w+u[a-7]+L+u[a-16]|0}let{A:n,B:r,C:c,D:i,E:o,F:f,G:h,H:l}=this;for(let a=0;a<64;a++){const g=d(o,6)^d(o,11)^d(o,25),b=l+g+C(o,f,h)+N[a]+u[a]|0,w=(d(n,2)^d(n,13)^d(n,22))+I(n,r,c)|0;l=h,h=f,f=o,o=i+b|0,i=c,c=r,r=n,n=b+w|0}n=n+this.A|0,r=r+this.B|0,c=c+this.C|0,i=i+this.D|0,o=o+this.E|0,f=f+this.F|0,h=h+this.G|0,l=l+this.H|0,this.set(n,r,c,i,o,f,h,l)}roundClean(){y(u)}destroy(){this.destroyed=!0,this.set(0,0,0,0,0,0,0,0),y(this.buffer)}}class M extends F{A=x[0]|0;B=x[1]|0;C=x[2]|0;D=x[3]|0;E=x[4]|0;F=x[5]|0;G=x[6]|0;H=x[7]|0;constructor(){super(32)}}const V=U(()=>new M,v(1)),$=[\\\"shared_chat_snapshot_activated\\\",\\\"shared_chat_interactive_activated\\\",\\\"shared_chat_message_sent\\\"],E=\\\"agentsdock:shared-analytics-seen:v1\\\",A=new Set;function G(e,t,s=\\\"\\\"){try{if(!$.includes(e)||!/^\\\\/(?:interactive-chat\\\\/interactive_[a-f0-9]{32}|shared-chat\\\\/share_[a-f0-9]{32}|share\\\\/[A-Za-z0-9_-]{43})$/.test(t)||e===\\\"shared_chat_message_sent\\\"&&!/^[A-Za-z0-9_-]{16,128}$/.test(s)||navigator.doNotTrack===\\\"1\\\"||navigator.globalPrivacyControl||localStorage.getItem(\\\"agentsdock:analytics-consent:v1\\\")===\\\"denied\\\")return;const n=B(V(O(JSON.stringify([e,t,s])))),r=JSON.parse(sessionStorage.getItem(E)??\\\"[]\\\"),c=Array.isArray(r)?r.filter(f=>typeof f==\\\"string\\\"&&/^[a-f0-9]{64}$/.test(f)):[];if(A.has(n)||c.includes(n)||c.length>=4096)return;sessionStorage.setItem(E,JSON.stringify([...c,n])),A.add(n);const i=new AbortController,o=setTimeout(()=>i.abort(),5e3);try{fetch(\\\"https://api.mixpanel.com/track?ip=0\\\",{method:\\\"POST\\\",credentials:\\\"omit\\\",referrerPolicy:\\\"no-referrer\\\",headers:{\\\"Content-Type\\\":\\\"application/json\\\"},signal:i.signal,body:JSON.stringify([{event:e,properties:{token:\\\"66b4fef625e5750d5527870f0ca96d5e\\\",distinct_id:\\\"\\\",platform:\\\"shared_web\\\",time:Math.floor(Date.now()/1e3)}}])}).catch(()=>{}).finally(()=>clearTimeout(o))}catch{clearTimeout(o)}}catch{}}G(\\\"shared_chat_snapshot_activated\\\",location.pathname)})();\"")
|
|
5
|
+
CSP_HASH = "mGoIxolAbIiMaBueHq0pZetv9HOC3PsCEy+QNKDYsaM="
|
package/server/side_questions.py
CHANGED
|
@@ -27,10 +27,11 @@ from fastapi import APIRouter, HTTPException, Request
|
|
|
27
27
|
from fastapi.responses import JSONResponse
|
|
28
28
|
|
|
29
29
|
|
|
30
|
-
MAX_QUESTION_CHARS = 8000
|
|
31
30
|
MAX_HISTORY_ITEMS = 32
|
|
32
31
|
MAX_HISTORY_CHARS = 60000
|
|
33
32
|
MAX_REQUEST_BYTES = 512 * 1024
|
|
33
|
+
# The streamed body limit bounds input; avoid a second, smaller model-text cap.
|
|
34
|
+
MAX_QUESTION_CHARS = MAX_REQUEST_BYTES
|
|
34
35
|
MAX_CONTEXT_CHARS = 60000
|
|
35
36
|
MAX_LOG_BYTES = 4 * 1024 * 1024
|
|
36
37
|
MAX_OUTPUT_BYTES = 1024 * 1024
|
|
@@ -369,11 +370,6 @@ class _NativeConversation:
|
|
|
369
370
|
closed: bool = False
|
|
370
371
|
timer: object = None
|
|
371
372
|
close_task: asyncio.Task | None = None
|
|
372
|
-
|
|
373
|
-
|
|
374
|
-
NATIVE_IDLE_SECONDS = 30 * 60
|
|
375
|
-
|
|
376
|
-
|
|
377
373
|
class SideQuestions:
|
|
378
374
|
def __init__(self, answer=None, *, native_factory=None, storage_path=None, notify=None, admission_check=None, runtime_settings_for_session=None):
|
|
379
375
|
self.answer = answer
|
|
@@ -396,6 +392,43 @@ class SideQuestions:
|
|
|
396
392
|
def active_work_labels(self):
|
|
397
393
|
return [f"Side chat in {session_id}" for session_id in sorted(self.active_session_ids())]
|
|
398
394
|
|
|
395
|
+
def background_work_scopes(self):
|
|
396
|
+
handles = [(key[1], conversation.handle) for key, conversation in self.conversations.items()
|
|
397
|
+
if not conversation.closed and conversation.handle is not None]
|
|
398
|
+
if self.synced is not None:
|
|
399
|
+
handles.extend((key[1], handle) for key, handle in self.synced.handles.items())
|
|
400
|
+
scopes = []
|
|
401
|
+
for session_id, handle in handles:
|
|
402
|
+
inspect = getattr(handle, "background_work_scope", None)
|
|
403
|
+
scope = inspect() if callable(inspect) else None
|
|
404
|
+
if scope is not None:
|
|
405
|
+
scopes.append((session_id, handle, scope))
|
|
406
|
+
return tuple(scopes)
|
|
407
|
+
|
|
408
|
+
async def prepare_background_work_snapshot(self, *, timeout_seconds=5.0):
|
|
409
|
+
scopes = self.background_work_scopes()
|
|
410
|
+
|
|
411
|
+
async def inspect(session_id, handle, _scope):
|
|
412
|
+
try:
|
|
413
|
+
active = await handle.has_background_work()
|
|
414
|
+
except Exception:
|
|
415
|
+
return f"Side chat provider state unknown in {session_id}"
|
|
416
|
+
return f"Side chat background work in {session_id}" if active else None
|
|
417
|
+
|
|
418
|
+
try:
|
|
419
|
+
values = await asyncio.wait_for(asyncio.gather(*(inspect(*scope) for scope in scopes)),
|
|
420
|
+
timeout=timeout_seconds)
|
|
421
|
+
labels = tuple(sorted(set(value for value in values if value)))
|
|
422
|
+
except asyncio.TimeoutError:
|
|
423
|
+
labels = tuple(f"Side chat provider state unknown in {session_id}" for session_id, _, _ in scopes)
|
|
424
|
+
return {"scopes": scopes, "labels": labels}
|
|
425
|
+
|
|
426
|
+
def background_work_labels_from_snapshot(self, snapshot):
|
|
427
|
+
scopes = self.background_work_scopes()
|
|
428
|
+
if snapshot.get("scopes", ()) != scopes:
|
|
429
|
+
return ["Side chat provider state changed during inspection"]
|
|
430
|
+
return list(snapshot.get("labels", ()))
|
|
431
|
+
|
|
399
432
|
async def _close_conversation(self, conversation):
|
|
400
433
|
conversation.closed = True
|
|
401
434
|
if conversation.timer is not None:
|
|
@@ -415,7 +448,7 @@ class SideQuestions:
|
|
|
415
448
|
await asyncio.shield(conversation.close_task)
|
|
416
449
|
|
|
417
450
|
def _expire_conversation(self, key, conversation):
|
|
418
|
-
if self.conversations.get(key) is not conversation or conversation.busy:
|
|
451
|
+
if self.conversations.get(key) is not conversation or conversation.busy or not conversation.closed:
|
|
419
452
|
return
|
|
420
453
|
self.conversations.pop(key, None)
|
|
421
454
|
task = asyncio.create_task(self._close_conversation(conversation), name="side-chat-expiry")
|
|
@@ -461,8 +494,9 @@ class SideQuestions:
|
|
|
461
494
|
raise
|
|
462
495
|
finally:
|
|
463
496
|
conversation.busy = False
|
|
464
|
-
conversation.
|
|
465
|
-
|
|
497
|
+
if conversation.closed:
|
|
498
|
+
conversation.timer = asyncio.get_running_loop().call_later(
|
|
499
|
+
RECEIPT_TTL_SECONDS, self._expire_conversation, key, conversation)
|
|
466
500
|
|
|
467
501
|
async def close_conversation(self, owner, session_id, side_chat_id):
|
|
468
502
|
key = (owner, session_id, side_chat_id)
|
|
@@ -473,7 +507,7 @@ class SideQuestions:
|
|
|
473
507
|
await self.cancel(*receipt_key)
|
|
474
508
|
await self._close_conversation(conversation)
|
|
475
509
|
conversation.timer = asyncio.get_running_loop().call_later(
|
|
476
|
-
|
|
510
|
+
RECEIPT_TTL_SECONDS, self._expire_conversation, key, conversation)
|
|
477
511
|
|
|
478
512
|
def _prune(self):
|
|
479
513
|
now = time.monotonic()
|
|
@@ -874,8 +908,6 @@ class SyncedSideChats:
|
|
|
874
908
|
self.cleanup_tasks.discard(cleanup)
|
|
875
909
|
if self.tasks.get(key) is asyncio.current_task():
|
|
876
910
|
self.tasks.pop(key, None)
|
|
877
|
-
if not self.stopping:
|
|
878
|
-
self.timers[key] = asyncio.get_running_loop().call_later(NATIVE_IDLE_SECONDS, self._expire, key)
|
|
879
911
|
|
|
880
912
|
async def _finish(self, key, side_chat_id, request_id, status, **result):
|
|
881
913
|
async with self._lock(key):
|
|
@@ -1053,7 +1085,7 @@ def create_side_question_router(*, authorize, session_exists, runtime: SideQuest
|
|
|
1053
1085
|
if not isinstance(request_id, str) or not IDENTIFIER.fullmatch(request_id):
|
|
1054
1086
|
raise HTTPException(400, "Invalid side question request ID")
|
|
1055
1087
|
if not isinstance(question, str) or not question.strip() or len(question) > MAX_QUESTION_CHARS:
|
|
1056
|
-
raise HTTPException(400, "Question must contain 1 to
|
|
1088
|
+
raise HTTPException(400, f"Question must contain 1 to {MAX_QUESTION_CHARS} characters")
|
|
1057
1089
|
try:
|
|
1058
1090
|
question.encode("utf-8")
|
|
1059
1091
|
except UnicodeEncodeError:
|
|
@@ -1152,7 +1184,7 @@ def create_side_question_router(*, authorize, session_exists, runtime: SideQuest
|
|
|
1152
1184
|
raise HTTPException(400, "Invalid side conversation ID")
|
|
1153
1185
|
question = value["question"]
|
|
1154
1186
|
if not isinstance(question, str) or not question.strip() or len(question) > MAX_QUESTION_CHARS:
|
|
1155
|
-
raise HTTPException(400, "Question must contain 1 to
|
|
1187
|
+
raise HTTPException(400, f"Question must contain 1 to {MAX_QUESTION_CHARS} characters")
|
|
1156
1188
|
try:
|
|
1157
1189
|
question.encode("utf-8")
|
|
1158
1190
|
except UnicodeEncodeError:
|