@yadsh/dsh-qa-surface 0.2.0 → 0.3.0
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/CHANGELOG.md +84 -1
- package/README.md +87 -12
- package/compatibility.json +11 -3
- package/docs/ARCHITECTURE.md +1 -1
- package/docs/COMPATIBILITY.md +1 -1
- package/docs/CONFIGURATION.md +88 -1
- package/docs/SOURCES-MIGRATION.md +28 -0
- package/docs/SOURCES-PROVENANCE-AUDIT.md +88 -0
- package/docs/SPEC-dsh-qa-surface-accounts.md +219 -0
- package/docs/SPEC-dsh-qa-surface-sources-provenance.md +1692 -0
- package/lib/accounts/index.js +2 -0
- package/lib/accounts/index.js.map +1 -0
- package/lib/accounts/store.js +478 -0
- package/lib/accounts/store.js.map +1 -0
- package/lib/attestation.js.map +1 -1
- package/lib/cli.js +143 -0
- package/lib/cli.js.map +1 -0
- package/lib/client/QaAccountsController.js +329 -0
- package/lib/client/QaAccountsController.js.map +1 -0
- package/lib/client/QaSessionController.js +203 -127
- package/lib/client/QaSessionController.js.map +1 -1
- package/lib/client/QaSurface.js +234 -72
- package/lib/client/QaSurface.js.map +1 -1
- package/lib/client/QaTranscriptAdapter.js +27 -241
- package/lib/client/QaTranscriptAdapter.js.map +1 -1
- package/lib/client/attestation.js +6 -0
- package/lib/client/attestation.js.map +1 -1
- package/lib/client/components/Markdown.js +54 -13
- package/lib/client/components/Markdown.js.map +1 -1
- package/lib/client/components/QaAuthGate.js +40 -0
- package/lib/client/components/QaAuthGate.js.map +1 -0
- package/lib/client/components/QaChangelog.js +112 -0
- package/lib/client/components/QaChangelog.js.map +1 -0
- package/lib/client/components/QaMessage.js +47 -17
- package/lib/client/components/QaMessage.js.map +1 -1
- package/lib/client/components/QaSidebar.js +63 -20
- package/lib/client/components/QaSidebar.js.map +1 -1
- package/lib/client/components/QaSourcesDrawer.js +113 -32
- package/lib/client/components/QaSourcesDrawer.js.map +1 -1
- package/lib/client/components/QaTurnRail.js +281 -0
- package/lib/client/components/QaTurnRail.js.map +1 -0
- package/lib/client/components/QaWelcomeNotice.js +125 -0
- package/lib/client/components/QaWelcomeNotice.js.map +1 -0
- package/lib/client/components/QaWidthHandle.js +142 -0
- package/lib/client/components/QaWidthHandle.js.map +1 -0
- package/lib/client/components/source-refs.js +78 -0
- package/lib/client/components/source-refs.js.map +1 -0
- package/lib/client/create-session.js +51 -0
- package/lib/client/create-session.js.map +1 -0
- package/lib/client/index.js +100 -12
- package/lib/client/index.js.map +1 -1
- package/lib/client/project-session-state.js +78 -0
- package/lib/client/project-session-state.js.map +1 -0
- package/lib/client/session-admission.js +38 -0
- package/lib/client/session-admission.js.map +1 -0
- package/lib/client/session-assets.js +52 -0
- package/lib/client/session-assets.js.map +1 -0
- package/lib/client/session-sources.js +61 -0
- package/lib/client/session-sources.js.map +1 -0
- package/lib/client/settlement.js +24 -0
- package/lib/client/settlement.js.map +1 -0
- package/lib/client/styles.js +151 -9
- package/lib/client/styles.js.map +1 -1
- package/lib/client/tool-presentation.js +102 -0
- package/lib/client/tool-presentation.js.map +1 -0
- package/lib/client/turn-sources.js +98 -0
- package/lib/client/turn-sources.js.map +1 -0
- package/lib/client/types.js +2 -0
- package/lib/client/types.js.map +1 -1
- package/lib/client/use-session-ui-state.js +68 -0
- package/lib/client/use-session-ui-state.js.map +1 -0
- package/lib/client/use-transcript-view.js +44 -0
- package/lib/client/use-transcript-view.js.map +1 -0
- package/lib/client.js +4649 -861
- package/lib/client.js.map +1 -1
- package/lib/config.js +218 -102
- package/lib/config.js.map +1 -1
- package/lib/entry-redirect.js +44 -0
- package/lib/entry-redirect.js.map +1 -0
- package/lib/host-route.js +25 -1
- package/lib/host-route.js.map +1 -1
- package/lib/index.js +209 -23
- package/lib/index.js.map +1 -1
- package/lib/launch-token.js +42 -0
- package/lib/launch-token.js.map +1 -0
- package/lib/provenance/collector.js +40 -0
- package/lib/provenance/collector.js.map +1 -0
- package/lib/provenance/connectors.js +147 -0
- package/lib/provenance/connectors.js.map +1 -0
- package/lib/provenance/dedupe.js +89 -0
- package/lib/provenance/dedupe.js.map +1 -0
- package/lib/provenance/extractors.js +340 -0
- package/lib/provenance/extractors.js.map +1 -0
- package/lib/provenance/file-preview.js +39 -0
- package/lib/provenance/file-preview.js.map +1 -0
- package/lib/provenance/host-store.js +387 -0
- package/lib/provenance/host-store.js.map +1 -0
- package/lib/provenance/index.js +9 -0
- package/lib/provenance/index.js.map +1 -0
- package/lib/provenance/normalize.js +128 -0
- package/lib/provenance/normalize.js.map +1 -0
- package/lib/provenance/reported.js +53 -0
- package/lib/provenance/reported.js.map +1 -0
- package/lib/provenance/types.js +2 -0
- package/lib/provenance/types.js.map +1 -0
- package/lib/resolve-config.js +161 -0
- package/lib/resolve-config.js.map +1 -1
- package/lib/secure-session.js +50 -8
- package/lib/secure-session.js.map +1 -1
- package/lib/typert.host.js +599 -6
- package/lib/typert.remote-client.d.ts +19 -3
- package/lib/typert.remote-client.js +471 -3
- package/lib/types/accounts/index.d.ts +2 -0
- package/lib/types/accounts/store.d.ts +121 -0
- package/lib/types/attestation.d.ts +1 -1
- package/lib/types/cli.d.ts +9 -0
- package/lib/types/client/QaAccountsController.d.ts +95 -0
- package/lib/types/client/QaConfigController.d.ts +1 -1
- package/lib/types/client/QaSessionController.d.ts +46 -7
- package/lib/types/client/QaSurface.d.ts +8 -3
- package/lib/types/client/QaTranscriptAdapter.d.ts +5 -10
- package/lib/types/client/components/Markdown.d.ts +11 -1
- package/lib/types/client/components/QaAgentsDrawer.d.ts +1 -1
- package/lib/types/client/components/QaAuthGate.d.ts +15 -0
- package/lib/types/client/components/QaChangelog.d.ts +21 -0
- package/lib/types/client/components/QaMessage.d.ts +10 -3
- package/lib/types/client/components/QaSidebar.d.ts +30 -2
- package/lib/types/client/components/QaSourcesDrawer.d.ts +15 -3
- package/lib/types/client/components/QaTurnRail.d.ts +59 -0
- package/lib/types/client/components/QaWelcomeNotice.d.ts +27 -0
- package/lib/types/client/components/QaWidthHandle.d.ts +36 -0
- package/lib/types/client/components/source-refs.d.ts +16 -0
- package/lib/types/client/create-session.d.ts +16 -0
- package/lib/types/client/index.d.ts +1 -0
- package/lib/types/client/project-session-state.d.ts +30 -0
- package/lib/types/client/session-admission.d.ts +23 -0
- package/lib/types/client/session-assets.d.ts +20 -0
- package/lib/types/client/session-sources.d.ts +28 -0
- package/lib/types/client/settlement.d.ts +6 -0
- package/lib/types/client/tool-presentation.d.ts +6 -0
- package/lib/types/client/turn-sources.d.ts +14 -0
- package/lib/types/client/types.d.ts +73 -9
- package/lib/types/client/use-session-ui-state.d.ts +35 -0
- package/lib/types/client/use-transcript-view.d.ts +20 -0
- package/lib/types/entry-redirect.d.ts +20 -0
- package/lib/types/host-route.d.ts +14 -1
- package/lib/types/index.d.ts +47 -3
- package/lib/types/launch-token.d.ts +21 -0
- package/lib/types/provenance/collector.d.ts +26 -0
- package/lib/types/provenance/connectors.d.ts +11 -0
- package/lib/types/provenance/dedupe.d.ts +6 -0
- package/lib/types/provenance/extractors.d.ts +14 -0
- package/lib/types/provenance/file-preview.d.ts +10 -0
- package/lib/types/provenance/host-store.d.ts +54 -0
- package/lib/types/provenance/index.d.ts +10 -0
- package/lib/types/provenance/normalize.d.ts +13 -0
- package/lib/types/provenance/reported.d.ts +5 -0
- package/lib/types/provenance/types.d.ts +78 -0
- package/lib/types/secure-session.d.ts +11 -2
- package/lib/types/types.d.ts +167 -11
- package/package.json +58 -41
|
@@ -5,12 +5,17 @@ function bytesToBase64(bytes) {
|
|
|
5
5
|
}
|
|
6
6
|
return btoa(binary);
|
|
7
7
|
}
|
|
8
|
-
import { QaPolicyAttestationError
|
|
8
|
+
import { QaPolicyAttestationError } from "./attestation.js";
|
|
9
9
|
import { QaChatIndex } from "./chat-index.js";
|
|
10
|
+
import { SessionAssetRepository } from "./session-assets.js";
|
|
11
|
+
import { createQaSession } from "./create-session.js";
|
|
12
|
+
import { attestQaPolicy } from "./session-admission.js";
|
|
13
|
+
import { QaHostSourceBridge } from "./session-sources.js";
|
|
10
14
|
import { QA_SESSION_IDLE_STATE } from "./types.js";
|
|
11
15
|
import { waitFor } from "./wait-for.js";
|
|
12
|
-
import {
|
|
13
|
-
import {
|
|
16
|
+
import { QA_REGENERATE_MARKER } from "./QaTranscriptAdapter.js";
|
|
17
|
+
import { projectBoundSessionState } from "./project-session-state.js";
|
|
18
|
+
import { projectTurnSources } from "./turn-sources.js";
|
|
14
19
|
const CONFIGURATION_ERROR = "Настройки помощника недоступны.";
|
|
15
20
|
/**
|
|
16
21
|
* The only module that couples QA behavior to DSH Session/client APIs. It owns
|
|
@@ -21,15 +26,22 @@ export class QaSessionController {
|
|
|
21
26
|
listeners = new Set();
|
|
22
27
|
sessions;
|
|
23
28
|
api;
|
|
29
|
+
conversation;
|
|
24
30
|
connection;
|
|
25
31
|
config;
|
|
26
32
|
secureSessionRemote;
|
|
33
|
+
sourceApi;
|
|
27
34
|
chats;
|
|
35
|
+
accounts;
|
|
36
|
+
/** Per-chat attachment URL cache; blob URLs die with the chat binding. */
|
|
37
|
+
assets = new SessionAssetRepository();
|
|
28
38
|
timeoutMs;
|
|
29
39
|
streamIntervalMs;
|
|
30
40
|
state = QA_SESSION_IDLE_STATE;
|
|
31
41
|
session;
|
|
42
|
+
conversationBinding;
|
|
32
43
|
unsubscribeSession;
|
|
44
|
+
unsubscribeChat;
|
|
33
45
|
unsubscribeConnection;
|
|
34
46
|
ensuring;
|
|
35
47
|
materializing;
|
|
@@ -45,13 +57,27 @@ export class QaSessionController {
|
|
|
45
57
|
disposed = false;
|
|
46
58
|
generation = 0;
|
|
47
59
|
chatsRevision = 0;
|
|
60
|
+
/** Host-side provenance of the bound chat, merged into the projection. */
|
|
61
|
+
hostSources;
|
|
48
62
|
constructor(options) {
|
|
49
63
|
this.sessions = options.sessions;
|
|
50
64
|
this.api = options.api;
|
|
65
|
+
this.conversation = options.conversation;
|
|
51
66
|
this.connection = options.connection;
|
|
52
67
|
this.config = options.config;
|
|
53
68
|
this.secureSessionRemote = options.secureSession;
|
|
69
|
+
this.sourceApi =
|
|
70
|
+
options.sourceApi ??
|
|
71
|
+
{
|
|
72
|
+
sources: async () => ({ ok: true, value: [] }),
|
|
73
|
+
readSourceFile: async () => ({
|
|
74
|
+
ok: false,
|
|
75
|
+
error: "unavailable",
|
|
76
|
+
}),
|
|
77
|
+
};
|
|
54
78
|
this.chats = new QaChatIndex(options.storage, `${options.config.session.storageKey}:v1:${options.config.route.path}`);
|
|
79
|
+
this.accounts = options.accounts;
|
|
80
|
+
this.hostSources = new QaHostSourceBridge(this.sourceApi, () => this.accounts?.token() ?? "");
|
|
55
81
|
this.timeoutMs = options.timeoutMs ?? 15_000;
|
|
56
82
|
this.streamIntervalMs = options.streamIntervalMs ?? 66;
|
|
57
83
|
this.connectedOnce = this.connection.getSnapshot() !== undefined;
|
|
@@ -96,12 +122,27 @@ export class QaSessionController {
|
|
|
96
122
|
}
|
|
97
123
|
}
|
|
98
124
|
this.operationError = null;
|
|
99
|
-
|
|
100
|
-
if (
|
|
101
|
-
|
|
102
|
-
|
|
125
|
+
const step = await this.attestPolicy();
|
|
126
|
+
if (step.kind === "stale") {
|
|
127
|
+
// The user moved to another chat while the proof was pending; that
|
|
128
|
+
// binding attests through its own flow and must not receive a prompt
|
|
129
|
+
// meant for the old one.
|
|
130
|
+
return false;
|
|
131
|
+
}
|
|
132
|
+
let attestedId = step.kind === "ok" ? step.sessionId : null;
|
|
133
|
+
if (attestedId === null) {
|
|
134
|
+
attestedId = await this.recoverForSend();
|
|
135
|
+
if (attestedId === null)
|
|
103
136
|
return false;
|
|
104
137
|
}
|
|
138
|
+
// Nothing can interleave between the last await and here, so matching the
|
|
139
|
+
// attested id proves the prompt rides exactly the session it proved.
|
|
140
|
+
const target = this.session;
|
|
141
|
+
if (this.disposed ||
|
|
142
|
+
target === undefined ||
|
|
143
|
+
String(target.sessionId) !== attestedId) {
|
|
144
|
+
return false;
|
|
145
|
+
}
|
|
105
146
|
this.admissionPending = true;
|
|
106
147
|
this.publish();
|
|
107
148
|
try {
|
|
@@ -114,7 +155,9 @@ export class QaSessionController {
|
|
|
114
155
|
name: image.name,
|
|
115
156
|
})),
|
|
116
157
|
];
|
|
117
|
-
const result = await
|
|
158
|
+
const result = await target.prompt(content, "queue");
|
|
159
|
+
if (this.session !== target)
|
|
160
|
+
return false;
|
|
118
161
|
if (!result.ok) {
|
|
119
162
|
this.admissionPending = false;
|
|
120
163
|
this.operationError = "Не удалось отправить сообщение.";
|
|
@@ -127,8 +170,10 @@ export class QaSessionController {
|
|
|
127
170
|
catch (error) {
|
|
128
171
|
this.admissionPending = false;
|
|
129
172
|
console.error("dsh-qa-surface: prompt failed", error);
|
|
130
|
-
this.
|
|
131
|
-
|
|
173
|
+
if (this.session === target) {
|
|
174
|
+
this.operationError = "Не удалось отправить сообщение.";
|
|
175
|
+
this.publish();
|
|
176
|
+
}
|
|
132
177
|
return false;
|
|
133
178
|
}
|
|
134
179
|
}
|
|
@@ -143,15 +188,20 @@ export class QaSessionController {
|
|
|
143
188
|
return this.send(QA_REGENERATE_MARKER);
|
|
144
189
|
}
|
|
145
190
|
async stop() {
|
|
146
|
-
|
|
191
|
+
const target = this.session;
|
|
192
|
+
if (target === undefined || !this.state.canStop)
|
|
147
193
|
return;
|
|
148
194
|
try {
|
|
149
|
-
const result = await
|
|
195
|
+
const result = await target.cancel();
|
|
196
|
+
if (this.session !== target)
|
|
197
|
+
return;
|
|
150
198
|
if (!result.ok)
|
|
151
199
|
this.operationError = "Не удалось остановить ответ.";
|
|
152
200
|
}
|
|
153
201
|
catch (error) {
|
|
154
202
|
console.error("dsh-qa-surface: stop failed", error);
|
|
203
|
+
if (this.session !== target)
|
|
204
|
+
return;
|
|
155
205
|
this.operationError = "Не удалось остановить ответ.";
|
|
156
206
|
}
|
|
157
207
|
this.publish();
|
|
@@ -200,7 +250,11 @@ export class QaSessionController {
|
|
|
200
250
|
const attempt = (async () => {
|
|
201
251
|
try {
|
|
202
252
|
await this.waitForConnection();
|
|
203
|
-
const id = await
|
|
253
|
+
const id = await createQaSession({
|
|
254
|
+
sessions: this.sessions,
|
|
255
|
+
api: this.api,
|
|
256
|
+
config: this.config,
|
|
257
|
+
});
|
|
204
258
|
if (this.disposed || operation !== this.generation)
|
|
205
259
|
return false;
|
|
206
260
|
await this.bind(id);
|
|
@@ -213,6 +267,8 @@ export class QaSessionController {
|
|
|
213
267
|
return true;
|
|
214
268
|
}
|
|
215
269
|
catch (error) {
|
|
270
|
+
if (this.disposed || operation !== this.generation)
|
|
271
|
+
return false;
|
|
216
272
|
this.fail(this.operationError === CONFIGURATION_ERROR
|
|
217
273
|
? this.operationError
|
|
218
274
|
: "Не удалось начать чат.", error);
|
|
@@ -267,6 +323,9 @@ export class QaSessionController {
|
|
|
267
323
|
this.chats.saveActive(sessionId);
|
|
268
324
|
}
|
|
269
325
|
catch (error) {
|
|
326
|
+
// A newer operation superseded this switch: its own outcome governs.
|
|
327
|
+
if (this.disposed || operation !== this.generation)
|
|
328
|
+
return;
|
|
270
329
|
this.fail(this.operationError === CONFIGURATION_ERROR
|
|
271
330
|
? this.operationError
|
|
272
331
|
: "Не удалось открыть этот чат.", error);
|
|
@@ -305,6 +364,9 @@ export class QaSessionController {
|
|
|
305
364
|
await this.bind(id, { attest: false, track: false, report: false });
|
|
306
365
|
}
|
|
307
366
|
catch (error) {
|
|
367
|
+
// A newer operation superseded this view request: leave its state alone.
|
|
368
|
+
if (this.disposed || operation !== this.generation)
|
|
369
|
+
return;
|
|
308
370
|
this.viewingSubagent = null;
|
|
309
371
|
this.fail("Не удалось открыть транскрипт субагента.", error);
|
|
310
372
|
}
|
|
@@ -318,9 +380,14 @@ export class QaSessionController {
|
|
|
318
380
|
return;
|
|
319
381
|
await this.switchTo(chat);
|
|
320
382
|
}
|
|
321
|
-
/** This browser's
|
|
383
|
+
/** This browser's chat ids: the owned list when accounts are on, else the
|
|
384
|
+
* local index. Both intersect the host session list at projection time. */
|
|
322
385
|
chatIds() {
|
|
323
|
-
|
|
386
|
+
const owned = this.accounts?.ownedIds() ?? [];
|
|
387
|
+
if (owned.length === 0)
|
|
388
|
+
return this.chats.chatIds();
|
|
389
|
+
const local = this.chats.chatIds();
|
|
390
|
+
return [...new Set([...owned, ...local])];
|
|
324
391
|
}
|
|
325
392
|
/** The bound session id, or null while no chat is bound. */
|
|
326
393
|
activeSessionId() {
|
|
@@ -328,12 +395,21 @@ export class QaSessionController {
|
|
|
328
395
|
}
|
|
329
396
|
/**
|
|
330
397
|
* Resolve one durable image attachment of the bound session into a
|
|
331
|
-
* browser-usable URL.
|
|
398
|
+
* browser-usable URL. Resolutions are cached per chat in the asset
|
|
399
|
+
* repository (attachment ids repeat across chats) and revoked when the chat
|
|
400
|
+
* is unbound; failures surface as broken views and retry on demand.
|
|
332
401
|
*/
|
|
333
402
|
async readImage(attachmentId) {
|
|
334
403
|
if (this.session === undefined)
|
|
335
404
|
throw new Error("no bound session");
|
|
336
|
-
const
|
|
405
|
+
const sessionId = String(this.session.sessionId);
|
|
406
|
+
return this.assets.resolve(sessionId, attachmentId, (id) => this.readAttachmentUrl(id));
|
|
407
|
+
}
|
|
408
|
+
async readAttachmentUrl(attachmentId) {
|
|
409
|
+
const session = this.session;
|
|
410
|
+
if (session === undefined)
|
|
411
|
+
throw new Error("no bound session");
|
|
412
|
+
const result = await session.readAttachment(attachmentId);
|
|
337
413
|
if (!result.ok) {
|
|
338
414
|
throw new Error(`${result.error.code}: ${result.error.message}`);
|
|
339
415
|
}
|
|
@@ -377,6 +453,7 @@ export class QaSessionController {
|
|
|
377
453
|
this.clearStreamFlush();
|
|
378
454
|
this.unsubscribeConnection();
|
|
379
455
|
this.unbind();
|
|
456
|
+
this.assets.dispose();
|
|
380
457
|
this.listeners.clear();
|
|
381
458
|
}
|
|
382
459
|
async ensureSessionNow() {
|
|
@@ -410,7 +487,11 @@ export class QaSessionController {
|
|
|
410
487
|
}
|
|
411
488
|
}
|
|
412
489
|
if (id === null) {
|
|
413
|
-
id = await
|
|
490
|
+
id = await createQaSession({
|
|
491
|
+
sessions: this.sessions,
|
|
492
|
+
api: this.api,
|
|
493
|
+
config: this.config,
|
|
494
|
+
});
|
|
414
495
|
}
|
|
415
496
|
if (this.disposed || operation !== this.generation)
|
|
416
497
|
return;
|
|
@@ -421,6 +502,10 @@ export class QaSessionController {
|
|
|
421
502
|
await this.bind(id, { report: !restored });
|
|
422
503
|
}
|
|
423
504
|
catch (error) {
|
|
505
|
+
// A stale restore must not start its recovery ladder against a newer
|
|
506
|
+
// operation; rethrow for the generation-guarded outer catch.
|
|
507
|
+
if (this.disposed || operation !== this.generation)
|
|
508
|
+
throw error;
|
|
424
509
|
if (!restored ||
|
|
425
510
|
this.config.session.policy !== "browser-persistent" ||
|
|
426
511
|
!(error instanceof QaPolicyAttestationError)) {
|
|
@@ -432,7 +517,11 @@ export class QaSessionController {
|
|
|
432
517
|
this.unbind();
|
|
433
518
|
this.operationError = null;
|
|
434
519
|
this.chats.clearActive();
|
|
435
|
-
id = await
|
|
520
|
+
id = await createQaSession({
|
|
521
|
+
sessions: this.sessions,
|
|
522
|
+
api: this.api,
|
|
523
|
+
config: this.config,
|
|
524
|
+
});
|
|
436
525
|
if (this.disposed || operation !== this.generation)
|
|
437
526
|
return;
|
|
438
527
|
await this.bind(id);
|
|
@@ -444,6 +533,9 @@ export class QaSessionController {
|
|
|
444
533
|
}
|
|
445
534
|
}
|
|
446
535
|
catch (error) {
|
|
536
|
+
// A newer operation superseded this bootstrap: its own outcome governs.
|
|
537
|
+
if (this.disposed || operation !== this.generation)
|
|
538
|
+
return;
|
|
447
539
|
this.fail(this.operationError === CONFIGURATION_ERROR
|
|
448
540
|
? this.operationError
|
|
449
541
|
: "Не удалось начать чат.", error);
|
|
@@ -456,14 +548,15 @@ export class QaSessionController {
|
|
|
456
548
|
* re-opening re-materializes it. Step 2 — when that refused session is
|
|
457
549
|
* still blank (it never received the prompt, so there is nothing to lose) —
|
|
458
550
|
* starts a fresh attested session instead. A session with history is left
|
|
459
|
-
* alone: recreating it would silently drop the conversation.
|
|
551
|
+
* alone: recreating it would silently drop the conversation. Returns the
|
|
552
|
+
* attested session id, or null when nothing was attested.
|
|
460
553
|
*/
|
|
461
554
|
async recoverForSend() {
|
|
462
555
|
const id = this.session === undefined ? null : String(this.session.sessionId);
|
|
463
556
|
if (id === null ||
|
|
464
557
|
this.disposed ||
|
|
465
558
|
this.config.session.policy === "fixed") {
|
|
466
|
-
return
|
|
559
|
+
return null;
|
|
467
560
|
}
|
|
468
561
|
const operation = ++this.generation;
|
|
469
562
|
this.unbind();
|
|
@@ -476,72 +569,44 @@ export class QaSessionController {
|
|
|
476
569
|
try {
|
|
477
570
|
await this.bind(id);
|
|
478
571
|
if (this.disposed || operation !== this.generation)
|
|
479
|
-
return
|
|
572
|
+
return null;
|
|
480
573
|
if (this.policyReady)
|
|
481
|
-
return
|
|
574
|
+
return id;
|
|
482
575
|
}
|
|
483
576
|
catch (error) {
|
|
484
577
|
if (!(error instanceof QaPolicyAttestationError))
|
|
485
578
|
throw error;
|
|
486
579
|
if (this.disposed || operation !== this.generation)
|
|
487
|
-
return
|
|
580
|
+
return null;
|
|
488
581
|
}
|
|
489
582
|
if (this.session !== undefined && !this.session.getSnapshot().blank) {
|
|
490
583
|
this.fail(CONFIGURATION_ERROR, new QaPolicyAttestationError());
|
|
491
|
-
return
|
|
584
|
+
return null;
|
|
492
585
|
}
|
|
493
586
|
this.unbind();
|
|
494
587
|
this.chats.clearActive();
|
|
495
|
-
const created = await
|
|
588
|
+
const created = await createQaSession({
|
|
589
|
+
sessions: this.sessions,
|
|
590
|
+
api: this.api,
|
|
591
|
+
config: this.config,
|
|
592
|
+
});
|
|
496
593
|
if (this.disposed || operation !== this.generation)
|
|
497
|
-
return
|
|
594
|
+
return null;
|
|
498
595
|
await this.bind(created);
|
|
499
596
|
if (this.disposed || operation !== this.generation)
|
|
500
|
-
return
|
|
597
|
+
return null;
|
|
501
598
|
this.chats.saveActive(created);
|
|
502
|
-
return this.policyReady;
|
|
599
|
+
return this.policyReady ? created : null;
|
|
503
600
|
}
|
|
504
601
|
catch (error) {
|
|
602
|
+
if (this.disposed || operation !== this.generation)
|
|
603
|
+
return null;
|
|
505
604
|
this.fail(this.operationError === CONFIGURATION_ERROR
|
|
506
605
|
? this.operationError
|
|
507
606
|
: "Не удалось открыть этот чат.", error);
|
|
508
|
-
return
|
|
607
|
+
return null;
|
|
509
608
|
}
|
|
510
609
|
}
|
|
511
|
-
async createSession() {
|
|
512
|
-
const created = await this.sessions.create({
|
|
513
|
-
...(this.config.session.workspaceId !== null
|
|
514
|
-
? { workspaceId: this.config.session.workspaceId }
|
|
515
|
-
: this.config.session.cwd !== null
|
|
516
|
-
? { cwd: this.config.session.cwd }
|
|
517
|
-
: {}),
|
|
518
|
-
});
|
|
519
|
-
const id = String(created);
|
|
520
|
-
if (this.config.session.agentPreset !== null) {
|
|
521
|
-
const selectedPreset = await this.api.selectAgentPreset({
|
|
522
|
-
sessionId: id,
|
|
523
|
-
agentPreset: this.config.session.agentPreset,
|
|
524
|
-
});
|
|
525
|
-
if (!selectedPreset.result.ok) {
|
|
526
|
-
throw new Error(selectedPreset.result.error.code);
|
|
527
|
-
}
|
|
528
|
-
this.sessions.noteAgentPreset(id, this.config.session.agentPreset);
|
|
529
|
-
}
|
|
530
|
-
if (this.config.session.provider !== null &&
|
|
531
|
-
this.config.session.model !== null) {
|
|
532
|
-
const selected = await this.api.selectModel({
|
|
533
|
-
sessionId: id,
|
|
534
|
-
provider: this.config.session.provider,
|
|
535
|
-
model: this.config.session.model,
|
|
536
|
-
...(this.config.session.reasoningEffort === null
|
|
537
|
-
? {}
|
|
538
|
-
: { reasoningEffort: this.config.session.reasoningEffort }),
|
|
539
|
-
});
|
|
540
|
-
if (!selected.result.ok)
|
|
541
|
-
throw new Error(selected.result.error.code);
|
|
542
|
-
}
|
|
543
|
-
return id;
|
|
544
|
-
}
|
|
545
610
|
async bind(id, options = {}) {
|
|
546
611
|
const { report = true, attest = true, track = true } = options;
|
|
547
612
|
this.sessions.open(id);
|
|
@@ -554,23 +619,34 @@ export class QaSessionController {
|
|
|
554
619
|
throw new Error("Session binding is unavailable.");
|
|
555
620
|
this.unbind();
|
|
556
621
|
this.session = binding.session;
|
|
622
|
+
this.conversationBinding = this.conversation.binding(id);
|
|
557
623
|
this.policyReady = false;
|
|
558
624
|
this.unsubscribeSession = binding.session.subscribe(() => {
|
|
559
625
|
this.admissionPending = false;
|
|
560
626
|
this.operationError = null;
|
|
561
627
|
this.publishSessionUpdate();
|
|
562
628
|
});
|
|
629
|
+
// Subscribing the Chat target activates it, so the assembled transcript
|
|
630
|
+
// (nodes, partials, running calls) materializes for the projection.
|
|
631
|
+
this.unsubscribeChat = this.conversationBinding
|
|
632
|
+
.target("chat")
|
|
633
|
+
.subscribe(() => {
|
|
634
|
+
this.publishSessionUpdate();
|
|
635
|
+
});
|
|
563
636
|
await waitFor(binding.session, (snapshot) => snapshot.openState === "open" || snapshot.openState === "error", this.timeoutMs);
|
|
564
637
|
if (binding.session.getSnapshot().openState !== "open") {
|
|
565
638
|
throw new Error("Session binding could not be opened.");
|
|
566
639
|
}
|
|
567
|
-
if (attest
|
|
568
|
-
|
|
640
|
+
if (attest) {
|
|
641
|
+
const step = await this.attestPolicy(report);
|
|
642
|
+
if (step.kind !== "ok")
|
|
643
|
+
throw new QaPolicyAttestationError();
|
|
569
644
|
}
|
|
570
645
|
if (track) {
|
|
571
646
|
this.chatSessionId = String(this.session.sessionId);
|
|
572
647
|
this.viewingSubagent = null;
|
|
573
648
|
this.chats.addChat(String(this.session.sessionId));
|
|
649
|
+
this.accounts?.onSessionCreated(String(this.session.sessionId));
|
|
574
650
|
}
|
|
575
651
|
this.publish();
|
|
576
652
|
}
|
|
@@ -578,7 +654,16 @@ export class QaSessionController {
|
|
|
578
654
|
this.clearStreamFlush();
|
|
579
655
|
this.unsubscribeSession?.();
|
|
580
656
|
this.unsubscribeSession = undefined;
|
|
657
|
+
this.unsubscribeChat?.();
|
|
658
|
+
this.unsubscribeChat = undefined;
|
|
659
|
+
this.conversationBinding = undefined;
|
|
660
|
+
if (this.session !== undefined) {
|
|
661
|
+
// Leaving the chat retires its attachment URLs; a later re-bind
|
|
662
|
+
// re-resolves them from the durable store.
|
|
663
|
+
this.assets.release(String(this.session.sessionId));
|
|
664
|
+
}
|
|
581
665
|
this.session = undefined;
|
|
666
|
+
this.hostSources.reset();
|
|
582
667
|
this.admissionPending = false;
|
|
583
668
|
this.policyReady = false;
|
|
584
669
|
}
|
|
@@ -646,38 +731,29 @@ export class QaSessionController {
|
|
|
646
731
|
return;
|
|
647
732
|
}
|
|
648
733
|
const snapshot = this.session.getSnapshot();
|
|
649
|
-
const
|
|
650
|
-
const
|
|
651
|
-
|
|
652
|
-
|
|
653
|
-
|
|
654
|
-
|
|
655
|
-
|
|
656
|
-
|
|
657
|
-
|
|
658
|
-
|
|
659
|
-
|
|
660
|
-
|
|
661
|
-
|
|
662
|
-
|
|
663
|
-
|
|
664
|
-
|
|
665
|
-
|
|
666
|
-
|
|
667
|
-
|
|
668
|
-
phase,
|
|
669
|
-
sessionId: String(this.session.sessionId),
|
|
670
|
-
messages: projectTranscript(snapshot, {
|
|
671
|
-
showToolActivity: this.config.ui.showToolActivity,
|
|
672
|
-
showReasoning: this.config.ui.showReasoning,
|
|
673
|
-
}),
|
|
674
|
-
error,
|
|
675
|
-
canSend: connected && phase === "ready" && this.policyReady,
|
|
676
|
-
canStop: connected && snapshot.running && this.config.ui.showStop && !blocked,
|
|
734
|
+
const sessionId = String(this.session.sessionId);
|
|
735
|
+
const conversationSnapshot = this.conversationBinding?.snapshot.getSnapshot();
|
|
736
|
+
const projectedSourceBundles = projectTurnSources(conversationSnapshot, sessionId, this.config.session.cwd ?? undefined);
|
|
737
|
+
const sourceBundles = this.hostSources.merge(projectedSourceBundles);
|
|
738
|
+
if (!snapshot.running) {
|
|
739
|
+
void this.hostSources.refresh(sessionId, () => this.publish());
|
|
740
|
+
}
|
|
741
|
+
this.state = projectBoundSessionState({
|
|
742
|
+
connected,
|
|
743
|
+
sessionId,
|
|
744
|
+
sessionSnapshot: snapshot,
|
|
745
|
+
conversationSnapshot,
|
|
746
|
+
sourceBundles,
|
|
747
|
+
// Ownership is chat-level: every user message of a foreign chat
|
|
748
|
+
// carries its owner's name when an admin reads it.
|
|
749
|
+
author: this.accounts?.messageAuthorOf(sessionId),
|
|
750
|
+
operationError: this.operationError,
|
|
751
|
+
policyReady: this.policyReady,
|
|
752
|
+
admissionPending: this.admissionPending,
|
|
677
753
|
chatsRevision: this.chatsRevision,
|
|
678
|
-
sources: projectSources(snapshot),
|
|
679
754
|
viewingSubagent: this.viewingSubagent,
|
|
680
|
-
|
|
755
|
+
config: this.config,
|
|
756
|
+
});
|
|
681
757
|
this.emit();
|
|
682
758
|
}
|
|
683
759
|
fail(message, error) {
|
|
@@ -697,48 +773,48 @@ export class QaSessionController {
|
|
|
697
773
|
};
|
|
698
774
|
this.emit();
|
|
699
775
|
}
|
|
776
|
+
/**
|
|
777
|
+
* Attest the currently bound session. `ok` carries the session id the proof
|
|
778
|
+
* was issued for; `stale` means the binding changed while the request was
|
|
779
|
+
* in flight, so nothing was written — the newer binding manages its own
|
|
780
|
+
* attestation; `refused` is a genuine admission refusal of this binding.
|
|
781
|
+
*/
|
|
700
782
|
async attestPolicy(reportFailure = true) {
|
|
701
|
-
|
|
702
|
-
|
|
783
|
+
const session = this.session;
|
|
784
|
+
if (session === undefined)
|
|
785
|
+
return { kind: "refused" };
|
|
786
|
+
const sessionId = String(session.sessionId);
|
|
703
787
|
if (!this.config.lockdown.enabled) {
|
|
704
788
|
this.policyReady = true;
|
|
705
|
-
return
|
|
789
|
+
return { kind: "ok", sessionId };
|
|
706
790
|
}
|
|
707
791
|
this.policyReady = false;
|
|
708
792
|
this.publish();
|
|
709
|
-
|
|
710
|
-
|
|
711
|
-
|
|
712
|
-
|
|
713
|
-
|
|
714
|
-
|
|
715
|
-
|
|
716
|
-
|
|
717
|
-
|
|
718
|
-
|
|
719
|
-
};
|
|
720
|
-
try {
|
|
721
|
-
const result = await this.secureSessionRemote(String(this.session.sessionId));
|
|
722
|
-
if (!result.ok) {
|
|
723
|
-
return reject(attestationReasonOf(result.error));
|
|
724
|
-
}
|
|
725
|
-
if (!proofMatchesConfig(result.value, this.config.lockdown, String(this.session.sessionId))) {
|
|
726
|
-
return reject("proof-mismatch");
|
|
727
|
-
}
|
|
793
|
+
const outcome = await attestQaPolicy({
|
|
794
|
+
secureSession: this.secureSessionRemote,
|
|
795
|
+
token: this.accounts?.token() ?? "",
|
|
796
|
+
sessionId,
|
|
797
|
+
lockdown: this.config.lockdown,
|
|
798
|
+
report: reportFailure,
|
|
799
|
+
});
|
|
800
|
+
if (this.session !== session)
|
|
801
|
+
return { kind: "stale" };
|
|
802
|
+
if (outcome.ok) {
|
|
728
803
|
this.policyReady = true;
|
|
729
804
|
this.operationError = null;
|
|
730
805
|
this.publish();
|
|
731
|
-
return
|
|
806
|
+
return { kind: "ok", sessionId };
|
|
732
807
|
}
|
|
733
|
-
|
|
734
|
-
|
|
735
|
-
|
|
736
|
-
|
|
737
|
-
|
|
738
|
-
this.operationError = CONFIGURATION_ERROR;
|
|
739
|
-
this.publish();
|
|
740
|
-
return false;
|
|
808
|
+
if (outcome.authRequired) {
|
|
809
|
+
// The identity expired or was rotated: back to the gate instead of
|
|
810
|
+
// the generic configuration error.
|
|
811
|
+
this.accounts?.onAuthRequired();
|
|
812
|
+
return { kind: "refused" };
|
|
741
813
|
}
|
|
814
|
+
this.policyReady = false;
|
|
815
|
+
this.operationError = CONFIGURATION_ERROR;
|
|
816
|
+
this.publish();
|
|
817
|
+
return { kind: "refused" };
|
|
742
818
|
}
|
|
743
819
|
emit() {
|
|
744
820
|
for (const listener of this.listeners)
|