@yadsh/dsh-qa-surface 0.1.1 → 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 +151 -0
- package/README.md +239 -16
- package/SPEC-dsh-qa-surface.md +600 -96
- package/capability-policy.json +5 -0
- package/compatibility.json +18 -4
- package/cordis.patch.yml +11 -0
- package/docs/ARCHITECTURE.md +50 -8
- package/docs/COMPATIBILITY.md +5 -4
- package/docs/CONFIGURATION.md +184 -3
- 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 +10 -0
- package/lib/attestation.js.map +1 -0
- 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/QaConfigController.js +111 -4
- package/lib/client/QaConfigController.js.map +1 -1
- package/lib/client/QaSessionController.js +621 -163
- package/lib/client/QaSessionController.js.map +1 -1
- package/lib/client/QaSurface.js +303 -24
- package/lib/client/QaSurface.js.map +1 -1
- package/lib/client/QaTranscriptAdapter.js +324 -49
- package/lib/client/QaTranscriptAdapter.js.map +1 -1
- package/lib/client/attestation.js +66 -0
- package/lib/client/attestation.js.map +1 -0
- package/lib/client/chat-index.js +86 -0
- package/lib/client/chat-index.js.map +1 -0
- package/lib/client/components/Markdown.js +124 -13
- package/lib/client/components/Markdown.js.map +1 -1
- package/lib/client/components/QaAgentsDrawer.js +40 -0
- package/lib/client/components/QaAgentsDrawer.js.map +1 -0
- 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/QaComposer.js +132 -15
- package/lib/client/components/QaComposer.js.map +1 -1
- package/lib/client/components/QaMessage.js +214 -10
- package/lib/client/components/QaMessage.js.map +1 -1
- package/lib/client/components/QaSidebar.js +167 -0
- package/lib/client/components/QaSidebar.js.map +1 -0
- package/lib/client/components/QaSourcesDrawer.js +127 -0
- package/lib/client/components/QaSourcesDrawer.js.map +1 -0
- 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/QaWorkGroup.js +135 -0
- package/lib/client/components/QaWorkGroup.js.map +1 -0
- package/lib/client/components/VariantSwitcher.js +29 -0
- package/lib/client/components/VariantSwitcher.js.map +1 -0
- package/lib/client/components/format.js +48 -0
- package/lib/client/components/format.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 +166 -35
- 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 +450 -43
- 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 +15 -0
- package/lib/client/types.js.map +1 -0
- 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/wait-for.js +49 -0
- package/lib/client/wait-for.js.map +1 -0
- package/lib/client.js +12551 -918
- package/lib/client.js.map +1 -1
- package/lib/config.js +226 -58
- 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 +354 -72
- package/lib/index.js.map +1 -1
- package/lib/launch-token.js +42 -0
- package/lib/launch-token.js.map +1 -0
- package/lib/lockdown-policy.js +20 -0
- package/lib/lockdown-policy.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 +283 -10
- package/lib/resolve-config.js.map +1 -1
- package/lib/secure-session.js +212 -0
- package/lib/secure-session.js.map +1 -0
- package/lib/typert.host.d.ts +3 -0
- package/lib/typert.host.js +784 -0
- package/lib/typert.remote-client.d.ts +40 -0
- package/lib/typert.remote-client.js +597 -0
- package/lib/types/accounts/index.d.ts +2 -0
- package/lib/types/accounts/store.d.ts +121 -0
- package/lib/types/attestation.d.ts +17 -0
- package/lib/types/cli.d.ts +9 -0
- package/lib/types/client/QaAccountsController.d.ts +95 -0
- package/lib/types/client/QaConfigController.d.ts +35 -3
- package/lib/types/client/QaSessionController.d.ts +146 -20
- package/lib/types/client/QaSurface.d.ts +11 -5
- package/lib/types/client/QaTranscriptAdapter.d.ts +13 -3
- package/lib/types/client/attestation.d.ts +25 -0
- package/lib/types/client/chat-index.d.ts +28 -0
- package/lib/types/client/components/Markdown.d.ts +17 -3
- package/lib/types/client/components/QaAgentsDrawer.d.ts +25 -0
- 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/QaComposer.d.ts +12 -2
- package/lib/types/client/components/QaMessage.d.ts +22 -2
- package/lib/types/client/components/QaSidebar.d.ts +69 -0
- package/lib/types/client/components/QaSourcesDrawer.d.ts +20 -0
- 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/QaWorkGroup.d.ts +19 -0
- package/lib/types/client/components/VariantSwitcher.d.ts +19 -0
- package/lib/types/client/components/format.d.ts +9 -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 +2 -5
- 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 +93 -0
- 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/client/wait-for.d.ts +11 -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 +67 -7
- package/lib/types/launch-token.d.ts +21 -0
- package/lib/types/lockdown-policy.d.ts +18 -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 +31 -0
- package/lib/types/types.d.ts +304 -1
- package/package.json +70 -27
|
@@ -1,80 +1,85 @@
|
|
|
1
|
-
|
|
2
|
-
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
canSend: false,
|
|
8
|
-
canStop: false,
|
|
9
|
-
});
|
|
10
|
-
function waitFor(source, predicate, timeoutMs) {
|
|
11
|
-
const current = source.getSnapshot();
|
|
12
|
-
if (predicate(current))
|
|
13
|
-
return Promise.resolve(current);
|
|
14
|
-
return new Promise((resolve, reject) => {
|
|
15
|
-
let settled = false;
|
|
16
|
-
const timeout = {};
|
|
17
|
-
let unsubscribe = () => undefined;
|
|
18
|
-
const finish = (value) => {
|
|
19
|
-
if (settled)
|
|
20
|
-
return;
|
|
21
|
-
settled = true;
|
|
22
|
-
if (timeout.id !== undefined)
|
|
23
|
-
clearTimeout(timeout.id);
|
|
24
|
-
unsubscribe();
|
|
25
|
-
resolve(value);
|
|
26
|
-
};
|
|
27
|
-
unsubscribe = source.subscribe(() => {
|
|
28
|
-
const next = source.getSnapshot();
|
|
29
|
-
if (predicate(next))
|
|
30
|
-
finish(next);
|
|
31
|
-
});
|
|
32
|
-
// Tolerate observable implementations that notify synchronously while a
|
|
33
|
-
// subscriber is being installed, and close that subscription afterward.
|
|
34
|
-
if (settled) {
|
|
35
|
-
unsubscribe();
|
|
36
|
-
return;
|
|
37
|
-
}
|
|
38
|
-
const afterSubscribe = source.getSnapshot();
|
|
39
|
-
if (predicate(afterSubscribe)) {
|
|
40
|
-
finish(afterSubscribe);
|
|
41
|
-
return;
|
|
42
|
-
}
|
|
43
|
-
timeout.id = setTimeout(() => {
|
|
44
|
-
if (settled)
|
|
45
|
-
return;
|
|
46
|
-
settled = true;
|
|
47
|
-
unsubscribe();
|
|
48
|
-
reject(new Error("Timed out waiting for the DSH runtime."));
|
|
49
|
-
}, timeoutMs);
|
|
50
|
-
});
|
|
1
|
+
function bytesToBase64(bytes) {
|
|
2
|
+
let binary = "";
|
|
3
|
+
for (let index = 0; index < bytes.length; index += 0x8000) {
|
|
4
|
+
binary += String.fromCharCode(...bytes.subarray(index, index + 0x8000));
|
|
5
|
+
}
|
|
6
|
+
return btoa(binary);
|
|
51
7
|
}
|
|
52
|
-
|
|
8
|
+
import { QaPolicyAttestationError } from "./attestation.js";
|
|
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";
|
|
14
|
+
import { QA_SESSION_IDLE_STATE } from "./types.js";
|
|
15
|
+
import { waitFor } from "./wait-for.js";
|
|
16
|
+
import { QA_REGENERATE_MARKER } from "./QaTranscriptAdapter.js";
|
|
17
|
+
import { projectBoundSessionState } from "./project-session-state.js";
|
|
18
|
+
import { projectTurnSources } from "./turn-sources.js";
|
|
19
|
+
const CONFIGURATION_ERROR = "Настройки помощника недоступны.";
|
|
20
|
+
/**
|
|
21
|
+
* The only module that couples QA behavior to DSH Session/client APIs. It owns
|
|
22
|
+
* the chat lifecycle state machine; the observable wait, browser-local chat
|
|
23
|
+
* bookkeeping and policy-attestation details live in their own modules.
|
|
24
|
+
*/
|
|
53
25
|
export class QaSessionController {
|
|
54
26
|
listeners = new Set();
|
|
55
27
|
sessions;
|
|
56
28
|
api;
|
|
29
|
+
conversation;
|
|
57
30
|
connection;
|
|
58
31
|
config;
|
|
59
|
-
|
|
32
|
+
secureSessionRemote;
|
|
33
|
+
sourceApi;
|
|
34
|
+
chats;
|
|
35
|
+
accounts;
|
|
36
|
+
/** Per-chat attachment URL cache; blob URLs die with the chat binding. */
|
|
37
|
+
assets = new SessionAssetRepository();
|
|
60
38
|
timeoutMs;
|
|
61
|
-
|
|
39
|
+
streamIntervalMs;
|
|
40
|
+
state = QA_SESSION_IDLE_STATE;
|
|
62
41
|
session;
|
|
42
|
+
conversationBinding;
|
|
63
43
|
unsubscribeSession;
|
|
44
|
+
unsubscribeChat;
|
|
64
45
|
unsubscribeConnection;
|
|
65
46
|
ensuring;
|
|
47
|
+
materializing;
|
|
66
48
|
operationError = null;
|
|
67
49
|
admissionPending = false;
|
|
50
|
+
policyReady = false;
|
|
51
|
+
drafting = false;
|
|
52
|
+
streamFlushTimer;
|
|
53
|
+
/** The chat session to return to when a subagent view closes. */
|
|
54
|
+
chatSessionId = null;
|
|
55
|
+
viewingSubagent = null;
|
|
68
56
|
connectedOnce;
|
|
69
57
|
disposed = false;
|
|
70
58
|
generation = 0;
|
|
59
|
+
chatsRevision = 0;
|
|
60
|
+
/** Host-side provenance of the bound chat, merged into the projection. */
|
|
61
|
+
hostSources;
|
|
71
62
|
constructor(options) {
|
|
72
63
|
this.sessions = options.sessions;
|
|
73
64
|
this.api = options.api;
|
|
65
|
+
this.conversation = options.conversation;
|
|
74
66
|
this.connection = options.connection;
|
|
75
67
|
this.config = options.config;
|
|
76
|
-
this.
|
|
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
|
+
};
|
|
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() ?? "");
|
|
77
81
|
this.timeoutMs = options.timeoutMs ?? 15_000;
|
|
82
|
+
this.streamIntervalMs = options.streamIntervalMs ?? 66;
|
|
78
83
|
this.connectedOnce = this.connection.getSnapshot() !== undefined;
|
|
79
84
|
this.unsubscribeConnection = this.connection.subscribe(() => {
|
|
80
85
|
if (this.connection.getSnapshot() !== undefined)
|
|
@@ -97,25 +102,65 @@ export class QaSessionController {
|
|
|
97
102
|
this.ensuring = operation;
|
|
98
103
|
return operation;
|
|
99
104
|
}
|
|
100
|
-
async send(text) {
|
|
105
|
+
async send(text, images = []) {
|
|
101
106
|
const prompt = text.trim();
|
|
102
|
-
if (prompt === ""
|
|
107
|
+
if ((prompt === "" && images.length === 0) || !this.state.canSend) {
|
|
103
108
|
return false;
|
|
104
109
|
}
|
|
105
110
|
if (prompt.startsWith("/")) {
|
|
106
|
-
this.operationError =
|
|
107
|
-
"Slash commands are not available in this assistant view.";
|
|
111
|
+
this.operationError = "Команды со слешем недоступны в режиме помощника.";
|
|
108
112
|
this.publish();
|
|
109
113
|
return false;
|
|
110
114
|
}
|
|
115
|
+
if (this.session === undefined) {
|
|
116
|
+
// A draft chat materializes its session only now: nothing was created
|
|
117
|
+
// when the user pressed "New chat", so the first prompt pays for it.
|
|
118
|
+
if (!this.drafting || this.materializing !== undefined)
|
|
119
|
+
return false;
|
|
120
|
+
if (!(await this.materializeDraft()) || this.session === undefined) {
|
|
121
|
+
return false;
|
|
122
|
+
}
|
|
123
|
+
}
|
|
111
124
|
this.operationError = null;
|
|
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)
|
|
136
|
+
return false;
|
|
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
|
+
}
|
|
112
146
|
this.admissionPending = true;
|
|
113
147
|
this.publish();
|
|
114
148
|
try {
|
|
115
|
-
const
|
|
149
|
+
const content = [
|
|
150
|
+
...(prompt === "" ? [] : [{ type: "text", text: prompt }]),
|
|
151
|
+
...images.map((image) => ({
|
|
152
|
+
type: "image",
|
|
153
|
+
mediaType: image.mediaType,
|
|
154
|
+
data: image.data,
|
|
155
|
+
name: image.name,
|
|
156
|
+
})),
|
|
157
|
+
];
|
|
158
|
+
const result = await target.prompt(content, "queue");
|
|
159
|
+
if (this.session !== target)
|
|
160
|
+
return false;
|
|
116
161
|
if (!result.ok) {
|
|
117
162
|
this.admissionPending = false;
|
|
118
|
-
this.operationError = "
|
|
163
|
+
this.operationError = "Не удалось отправить сообщение.";
|
|
119
164
|
this.publish();
|
|
120
165
|
return false;
|
|
121
166
|
}
|
|
@@ -125,27 +170,52 @@ export class QaSessionController {
|
|
|
125
170
|
catch (error) {
|
|
126
171
|
this.admissionPending = false;
|
|
127
172
|
console.error("dsh-qa-surface: prompt failed", error);
|
|
128
|
-
this.
|
|
129
|
-
|
|
173
|
+
if (this.session === target) {
|
|
174
|
+
this.operationError = "Не удалось отправить сообщение.";
|
|
175
|
+
this.publish();
|
|
176
|
+
}
|
|
130
177
|
return false;
|
|
131
178
|
}
|
|
132
179
|
}
|
|
180
|
+
/**
|
|
181
|
+
* Ask for a fresh variant of the previous answer. The session has no
|
|
182
|
+
* truncation seam, so this sends the hidden regeneration instruction as an
|
|
183
|
+
* ordinary prompt: the model produces a follow-up turn, the projection
|
|
184
|
+
* hides the marker message, and the consecutive turns read as variants of
|
|
185
|
+
* the original question (switched in the surface).
|
|
186
|
+
*/
|
|
187
|
+
async regenerate() {
|
|
188
|
+
return this.send(QA_REGENERATE_MARKER);
|
|
189
|
+
}
|
|
133
190
|
async stop() {
|
|
134
|
-
|
|
191
|
+
const target = this.session;
|
|
192
|
+
if (target === undefined || !this.state.canStop)
|
|
135
193
|
return;
|
|
136
194
|
try {
|
|
137
|
-
const result = await
|
|
195
|
+
const result = await target.cancel();
|
|
196
|
+
if (this.session !== target)
|
|
197
|
+
return;
|
|
138
198
|
if (!result.ok)
|
|
139
|
-
this.operationError = "
|
|
199
|
+
this.operationError = "Не удалось остановить ответ.";
|
|
140
200
|
}
|
|
141
201
|
catch (error) {
|
|
142
202
|
console.error("dsh-qa-surface: stop failed", error);
|
|
143
|
-
this.
|
|
203
|
+
if (this.session !== target)
|
|
204
|
+
return;
|
|
205
|
+
this.operationError = "Не удалось остановить ответ.";
|
|
144
206
|
}
|
|
145
207
|
this.publish();
|
|
146
208
|
}
|
|
147
|
-
|
|
148
|
-
|
|
209
|
+
/**
|
|
210
|
+
* Enter the "new chat" draft: show an empty composer without creating any
|
|
211
|
+
* session. Nothing appears in the chat list and no Host session is spent
|
|
212
|
+
* until the first prompt is actually sent, which materializes the session
|
|
213
|
+
* lazily ({@link materializeDraft}). Fixed-policy deployments cannot draft.
|
|
214
|
+
*/
|
|
215
|
+
async startDraft() {
|
|
216
|
+
if (this.disposed ||
|
|
217
|
+
this.config.session.policy === "fixed" ||
|
|
218
|
+
(this.config.lockdown.enabled && !this.config.lockdown.allowSessionReset))
|
|
149
219
|
return;
|
|
150
220
|
const previous = this.session;
|
|
151
221
|
if (previous?.getSnapshot().running === true) {
|
|
@@ -153,44 +223,253 @@ export class QaSessionController {
|
|
|
153
223
|
await previous.cancel();
|
|
154
224
|
}
|
|
155
225
|
catch (error) {
|
|
156
|
-
console.error("dsh-qa-surface: stop before
|
|
226
|
+
console.error("dsh-qa-surface: stop before draft failed", error);
|
|
157
227
|
}
|
|
158
228
|
}
|
|
229
|
+
this.drafting = true;
|
|
230
|
+
this.unbind();
|
|
231
|
+
this.operationError = null;
|
|
232
|
+
this.admissionPending = false;
|
|
233
|
+
this.policyReady = false;
|
|
234
|
+
this.state = {
|
|
235
|
+
...QA_SESSION_IDLE_STATE,
|
|
236
|
+
chatsRevision: this.chatsRevision,
|
|
237
|
+
};
|
|
238
|
+
this.publish();
|
|
239
|
+
}
|
|
240
|
+
/**
|
|
241
|
+
* Turn the current draft into a real attested session. Runs inside the
|
|
242
|
+
* send flow; concurrent sends share one materialization. A refusal here is
|
|
243
|
+
* reported at once — a fresh session's first attestation is the
|
|
244
|
+
* deployment's only chance to learn the reason.
|
|
245
|
+
*/
|
|
246
|
+
materializeDraft() {
|
|
247
|
+
if (this.materializing !== undefined)
|
|
248
|
+
return this.materializing;
|
|
159
249
|
const operation = ++this.generation;
|
|
250
|
+
const attempt = (async () => {
|
|
251
|
+
try {
|
|
252
|
+
await this.waitForConnection();
|
|
253
|
+
const id = await createQaSession({
|
|
254
|
+
sessions: this.sessions,
|
|
255
|
+
api: this.api,
|
|
256
|
+
config: this.config,
|
|
257
|
+
});
|
|
258
|
+
if (this.disposed || operation !== this.generation)
|
|
259
|
+
return false;
|
|
260
|
+
await this.bind(id);
|
|
261
|
+
if (this.disposed || operation !== this.generation)
|
|
262
|
+
return false;
|
|
263
|
+
this.drafting = false;
|
|
264
|
+
if (this.config.session.policy === "browser-persistent") {
|
|
265
|
+
this.chats.saveActive(id);
|
|
266
|
+
}
|
|
267
|
+
return true;
|
|
268
|
+
}
|
|
269
|
+
catch (error) {
|
|
270
|
+
if (this.disposed || operation !== this.generation)
|
|
271
|
+
return false;
|
|
272
|
+
this.fail(this.operationError === CONFIGURATION_ERROR
|
|
273
|
+
? this.operationError
|
|
274
|
+
: "Не удалось начать чат.", error);
|
|
275
|
+
return false;
|
|
276
|
+
}
|
|
277
|
+
})();
|
|
278
|
+
this.materializing = attempt;
|
|
279
|
+
this.publish();
|
|
280
|
+
// `attempt` never rejects: every failure path is caught and reports false.
|
|
281
|
+
void attempt.then(() => {
|
|
282
|
+
if (this.materializing === attempt)
|
|
283
|
+
this.materializing = undefined;
|
|
284
|
+
});
|
|
285
|
+
return attempt;
|
|
286
|
+
}
|
|
287
|
+
/**
|
|
288
|
+
* Open one of this browser's indexed chats. The id must be present in the
|
|
289
|
+
* host session list and pass policy attestation; an unknown id is forgotten
|
|
290
|
+
* from the index. Fixed-policy deployments cannot switch.
|
|
291
|
+
*/
|
|
292
|
+
async switchTo(sessionId) {
|
|
293
|
+
if (this.disposed || this.config.session.policy === "fixed")
|
|
294
|
+
return;
|
|
295
|
+
if (this.session !== undefined &&
|
|
296
|
+
String(this.session.sessionId) === sessionId)
|
|
297
|
+
return;
|
|
298
|
+
const operation = ++this.generation;
|
|
299
|
+
this.drafting = false;
|
|
300
|
+
this.viewingSubagent = null;
|
|
160
301
|
this.unbind();
|
|
161
302
|
this.operationError = null;
|
|
162
303
|
this.admissionPending = false;
|
|
163
|
-
this.
|
|
304
|
+
this.policyReady = false;
|
|
305
|
+
this.state = {
|
|
306
|
+
...QA_SESSION_IDLE_STATE,
|
|
307
|
+
chatsRevision: this.chatsRevision,
|
|
308
|
+
phase: "creating",
|
|
309
|
+
};
|
|
164
310
|
this.emit();
|
|
165
311
|
try {
|
|
166
312
|
await this.waitForConnection();
|
|
167
|
-
const
|
|
313
|
+
const list = await waitFor(this.sessions.list, (snapshot) => snapshot.phase === "ready", this.timeoutMs);
|
|
314
|
+
if (this.disposed || operation !== this.generation)
|
|
315
|
+
return;
|
|
316
|
+
if (!Object.hasOwn(list.byId, sessionId)) {
|
|
317
|
+
this.forgetChat(sessionId);
|
|
318
|
+
throw new Error("Этот чат больше недоступен.");
|
|
319
|
+
}
|
|
320
|
+
await this.bind(sessionId);
|
|
168
321
|
if (this.disposed || operation !== this.generation)
|
|
169
322
|
return;
|
|
170
|
-
this.
|
|
171
|
-
await this.bind(id);
|
|
323
|
+
this.chats.saveActive(sessionId);
|
|
172
324
|
}
|
|
173
325
|
catch (error) {
|
|
174
|
-
this
|
|
326
|
+
// A newer operation superseded this switch: its own outcome governs.
|
|
327
|
+
if (this.disposed || operation !== this.generation)
|
|
328
|
+
return;
|
|
329
|
+
this.fail(this.operationError === CONFIGURATION_ERROR
|
|
330
|
+
? this.operationError
|
|
331
|
+
: "Не удалось открыть этот чат.", error);
|
|
332
|
+
}
|
|
333
|
+
}
|
|
334
|
+
/**
|
|
335
|
+
* Watch a subagent's transcript live. The binding is read-only by
|
|
336
|
+
* construction: no attestation runs (attestation admits sending, and the
|
|
337
|
+
* composer stays disabled here), the chat index is untouched, and
|
|
338
|
+
* {@link closeSubagent} returns to the chat the panel was opened from.
|
|
339
|
+
*/
|
|
340
|
+
async viewSubagent(id, title) {
|
|
341
|
+
if (this.disposed)
|
|
342
|
+
return;
|
|
343
|
+
if (this.viewingSubagent !== null &&
|
|
344
|
+
this.viewingSubagent.id === id &&
|
|
345
|
+
this.session !== undefined) {
|
|
346
|
+
return;
|
|
347
|
+
}
|
|
348
|
+
const operation = ++this.generation;
|
|
349
|
+
this.drafting = false;
|
|
350
|
+
this.viewingSubagent = { id, title };
|
|
351
|
+
this.unbind();
|
|
352
|
+
this.operationError = null;
|
|
353
|
+
this.admissionPending = false;
|
|
354
|
+
this.policyReady = false;
|
|
355
|
+
this.state = {
|
|
356
|
+
...QA_SESSION_IDLE_STATE,
|
|
357
|
+
chatsRevision: this.chatsRevision,
|
|
358
|
+
viewingSubagent: this.viewingSubagent,
|
|
359
|
+
phase: "creating",
|
|
360
|
+
};
|
|
361
|
+
this.emit();
|
|
362
|
+
try {
|
|
363
|
+
await this.waitForConnection();
|
|
364
|
+
await this.bind(id, { attest: false, track: false, report: false });
|
|
365
|
+
}
|
|
366
|
+
catch (error) {
|
|
367
|
+
// A newer operation superseded this view request: leave its state alone.
|
|
368
|
+
if (this.disposed || operation !== this.generation)
|
|
369
|
+
return;
|
|
370
|
+
this.viewingSubagent = null;
|
|
371
|
+
this.fail("Не удалось открыть транскрипт субагента.", error);
|
|
372
|
+
}
|
|
373
|
+
if (this.disposed || operation !== this.generation)
|
|
374
|
+
return;
|
|
375
|
+
}
|
|
376
|
+
/** Leave the subagent transcript and re-open the chat it was launched from. */
|
|
377
|
+
async closeSubagent() {
|
|
378
|
+
const chat = this.chatSessionId;
|
|
379
|
+
if (chat === null)
|
|
380
|
+
return;
|
|
381
|
+
await this.switchTo(chat);
|
|
382
|
+
}
|
|
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. */
|
|
385
|
+
chatIds() {
|
|
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])];
|
|
391
|
+
}
|
|
392
|
+
/** The bound session id, or null while no chat is bound. */
|
|
393
|
+
activeSessionId() {
|
|
394
|
+
return this.session === undefined ? null : String(this.session.sessionId);
|
|
395
|
+
}
|
|
396
|
+
/**
|
|
397
|
+
* Resolve one durable image attachment of the bound session into a
|
|
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.
|
|
401
|
+
*/
|
|
402
|
+
async readImage(attachmentId) {
|
|
403
|
+
if (this.session === undefined)
|
|
404
|
+
throw new Error("no bound session");
|
|
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);
|
|
413
|
+
if (!result.ok) {
|
|
414
|
+
throw new Error(`${result.error.code}: ${result.error.message}`);
|
|
415
|
+
}
|
|
416
|
+
const { attachment, data } = result.value;
|
|
417
|
+
if (typeof URL.createObjectURL !== "function") {
|
|
418
|
+
return `data:${attachment.mediaType};base64,${bytesToBase64(data)}`;
|
|
419
|
+
}
|
|
420
|
+
return URL.createObjectURL(new Blob([Uint8Array.from(data).buffer], {
|
|
421
|
+
type: attachment.mediaType,
|
|
422
|
+
}));
|
|
423
|
+
}
|
|
424
|
+
/**
|
|
425
|
+
* Remove one chat from this browser's index. Deleting the chat that is
|
|
426
|
+
* currently open also forgets the persisted id and starts a fresh attested
|
|
427
|
+
* session (when the deployment allows session resets); the Host-side
|
|
428
|
+
* session itself is not touched — no host deletion seam exists.
|
|
429
|
+
*/
|
|
430
|
+
async deleteChat(id) {
|
|
431
|
+
this.forgetChat(id);
|
|
432
|
+
this.chatsRevision += 1;
|
|
433
|
+
if (this.activeSessionId() === id &&
|
|
434
|
+
this.config.session.policy !== "fixed") {
|
|
435
|
+
// No replacement session is created: deleting the open chat falls back
|
|
436
|
+
// to a draft, and the next prompt materializes a fresh one. The stale
|
|
437
|
+
// persisted id is dropped so a reload cannot resurrect the deleted chat.
|
|
438
|
+
this.chats.clearActive();
|
|
439
|
+
await this.startDraft();
|
|
440
|
+
return;
|
|
175
441
|
}
|
|
442
|
+
this.publish();
|
|
443
|
+
}
|
|
444
|
+
/** Drop one chat id from this browser's index. */
|
|
445
|
+
forgetChat(sessionId) {
|
|
446
|
+
this.chats.forgetChat(sessionId);
|
|
176
447
|
}
|
|
177
448
|
dispose() {
|
|
178
449
|
if (this.disposed)
|
|
179
450
|
return;
|
|
180
451
|
this.disposed = true;
|
|
181
452
|
this.generation += 1;
|
|
453
|
+
this.clearStreamFlush();
|
|
182
454
|
this.unsubscribeConnection();
|
|
183
455
|
this.unbind();
|
|
456
|
+
this.assets.dispose();
|
|
184
457
|
this.listeners.clear();
|
|
185
458
|
}
|
|
186
459
|
async ensureSessionNow() {
|
|
187
460
|
const operation = ++this.generation;
|
|
188
|
-
this.
|
|
461
|
+
this.drafting = false;
|
|
462
|
+
this.state = {
|
|
463
|
+
...QA_SESSION_IDLE_STATE,
|
|
464
|
+
chatsRevision: this.chatsRevision,
|
|
465
|
+
phase: "creating",
|
|
466
|
+
};
|
|
189
467
|
this.emit();
|
|
190
468
|
try {
|
|
191
469
|
await this.waitForConnection();
|
|
192
470
|
const list = await waitFor(this.sessions.list, (snapshot) => snapshot.phase === "ready", this.timeoutMs);
|
|
193
471
|
let id = null;
|
|
472
|
+
let restored = false;
|
|
194
473
|
if (this.config.session.policy === "fixed") {
|
|
195
474
|
id = this.config.session.fixedSessionId;
|
|
196
475
|
if (id === null || !Object.hasOwn(list.byId, id)) {
|
|
@@ -198,56 +477,138 @@ export class QaSessionController {
|
|
|
198
477
|
}
|
|
199
478
|
}
|
|
200
479
|
else if (this.config.session.policy === "browser-persistent") {
|
|
201
|
-
const stored = this.
|
|
480
|
+
const stored = this.chats.activeId();
|
|
202
481
|
if (stored !== null && Object.hasOwn(list.byId, stored)) {
|
|
203
482
|
id = stored;
|
|
483
|
+
restored = true;
|
|
204
484
|
}
|
|
205
485
|
else if (stored !== null) {
|
|
206
|
-
this.
|
|
486
|
+
this.chats.clearActive();
|
|
207
487
|
}
|
|
208
488
|
}
|
|
209
489
|
if (id === null) {
|
|
210
|
-
id = await
|
|
211
|
-
|
|
212
|
-
this.
|
|
490
|
+
id = await createQaSession({
|
|
491
|
+
sessions: this.sessions,
|
|
492
|
+
api: this.api,
|
|
493
|
+
config: this.config,
|
|
494
|
+
});
|
|
213
495
|
}
|
|
214
496
|
if (this.disposed || operation !== this.generation)
|
|
215
497
|
return;
|
|
216
|
-
|
|
498
|
+
// A restored id's first bind stays quiet: the recovery path below may
|
|
499
|
+
// replace it. A freshly created session must report a refusal at once —
|
|
500
|
+
// it is the deployment's only chance to learn the reason.
|
|
501
|
+
try {
|
|
502
|
+
await this.bind(id, { report: !restored });
|
|
503
|
+
}
|
|
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;
|
|
509
|
+
if (!restored ||
|
|
510
|
+
this.config.session.policy !== "browser-persistent" ||
|
|
511
|
+
!(error instanceof QaPolicyAttestationError)) {
|
|
512
|
+
throw error;
|
|
513
|
+
}
|
|
514
|
+
// A stored id is only a hint. If Host refuses that session under the
|
|
515
|
+
// current QA policy (for example after the configured preset changes),
|
|
516
|
+
// forget it and bootstrap one fresh, policy-attested session instead.
|
|
517
|
+
this.unbind();
|
|
518
|
+
this.operationError = null;
|
|
519
|
+
this.chats.clearActive();
|
|
520
|
+
id = await createQaSession({
|
|
521
|
+
sessions: this.sessions,
|
|
522
|
+
api: this.api,
|
|
523
|
+
config: this.config,
|
|
524
|
+
});
|
|
525
|
+
if (this.disposed || operation !== this.generation)
|
|
526
|
+
return;
|
|
527
|
+
await this.bind(id);
|
|
528
|
+
}
|
|
529
|
+
if (this.disposed || operation !== this.generation)
|
|
530
|
+
return;
|
|
531
|
+
if (this.config.session.policy === "browser-persistent") {
|
|
532
|
+
this.chats.saveActive(id);
|
|
533
|
+
}
|
|
217
534
|
}
|
|
218
535
|
catch (error) {
|
|
219
|
-
this
|
|
536
|
+
// A newer operation superseded this bootstrap: its own outcome governs.
|
|
537
|
+
if (this.disposed || operation !== this.generation)
|
|
538
|
+
return;
|
|
539
|
+
this.fail(this.operationError === CONFIGURATION_ERROR
|
|
540
|
+
? this.operationError
|
|
541
|
+
: "Не удалось начать чат.", error);
|
|
220
542
|
}
|
|
221
543
|
}
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
const id = String(
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
this.config.session.
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
544
|
+
/**
|
|
545
|
+
* Recovery ladder after a refused send-time attestation. The prompt was
|
|
546
|
+
* never admitted, so nothing here can duplicate it. Step 1 re-opens the
|
|
547
|
+
* session: a Host that idled the agent out dismantles its tool view, and
|
|
548
|
+
* re-opening re-materializes it. Step 2 — when that refused session is
|
|
549
|
+
* still blank (it never received the prompt, so there is nothing to lose) —
|
|
550
|
+
* starts a fresh attested session instead. A session with history is left
|
|
551
|
+
* alone: recreating it would silently drop the conversation. Returns the
|
|
552
|
+
* attested session id, or null when nothing was attested.
|
|
553
|
+
*/
|
|
554
|
+
async recoverForSend() {
|
|
555
|
+
const id = this.session === undefined ? null : String(this.session.sessionId);
|
|
556
|
+
if (id === null ||
|
|
557
|
+
this.disposed ||
|
|
558
|
+
this.config.session.policy === "fixed") {
|
|
559
|
+
return null;
|
|
560
|
+
}
|
|
561
|
+
const operation = ++this.generation;
|
|
562
|
+
this.unbind();
|
|
563
|
+
this.admissionPending = false;
|
|
564
|
+
this.policyReady = false;
|
|
565
|
+
this.state = { ...this.state, phase: "creating" };
|
|
566
|
+
this.emit();
|
|
567
|
+
try {
|
|
568
|
+
await this.waitForConnection();
|
|
569
|
+
try {
|
|
570
|
+
await this.bind(id);
|
|
571
|
+
if (this.disposed || operation !== this.generation)
|
|
572
|
+
return null;
|
|
573
|
+
if (this.policyReady)
|
|
574
|
+
return id;
|
|
575
|
+
}
|
|
576
|
+
catch (error) {
|
|
577
|
+
if (!(error instanceof QaPolicyAttestationError))
|
|
578
|
+
throw error;
|
|
579
|
+
if (this.disposed || operation !== this.generation)
|
|
580
|
+
return null;
|
|
581
|
+
}
|
|
582
|
+
if (this.session !== undefined && !this.session.getSnapshot().blank) {
|
|
583
|
+
this.fail(CONFIGURATION_ERROR, new QaPolicyAttestationError());
|
|
584
|
+
return null;
|
|
585
|
+
}
|
|
586
|
+
this.unbind();
|
|
587
|
+
this.chats.clearActive();
|
|
588
|
+
const created = await createQaSession({
|
|
589
|
+
sessions: this.sessions,
|
|
590
|
+
api: this.api,
|
|
591
|
+
config: this.config,
|
|
244
592
|
});
|
|
245
|
-
if (
|
|
246
|
-
|
|
593
|
+
if (this.disposed || operation !== this.generation)
|
|
594
|
+
return null;
|
|
595
|
+
await this.bind(created);
|
|
596
|
+
if (this.disposed || operation !== this.generation)
|
|
597
|
+
return null;
|
|
598
|
+
this.chats.saveActive(created);
|
|
599
|
+
return this.policyReady ? created : null;
|
|
600
|
+
}
|
|
601
|
+
catch (error) {
|
|
602
|
+
if (this.disposed || operation !== this.generation)
|
|
603
|
+
return null;
|
|
604
|
+
this.fail(this.operationError === CONFIGURATION_ERROR
|
|
605
|
+
? this.operationError
|
|
606
|
+
: "Не удалось открыть этот чат.", error);
|
|
607
|
+
return null;
|
|
247
608
|
}
|
|
248
|
-
return id;
|
|
249
609
|
}
|
|
250
|
-
async bind(id) {
|
|
610
|
+
async bind(id, options = {}) {
|
|
611
|
+
const { report = true, attest = true, track = true } = options;
|
|
251
612
|
this.sessions.open(id);
|
|
252
613
|
let binding = this.sessions.binding(id);
|
|
253
614
|
if (binding === undefined) {
|
|
@@ -258,26 +619,108 @@ export class QaSessionController {
|
|
|
258
619
|
throw new Error("Session binding is unavailable.");
|
|
259
620
|
this.unbind();
|
|
260
621
|
this.session = binding.session;
|
|
622
|
+
this.conversationBinding = this.conversation.binding(id);
|
|
623
|
+
this.policyReady = false;
|
|
261
624
|
this.unsubscribeSession = binding.session.subscribe(() => {
|
|
262
625
|
this.admissionPending = false;
|
|
263
626
|
this.operationError = null;
|
|
264
|
-
this.
|
|
627
|
+
this.publishSessionUpdate();
|
|
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();
|
|
265
635
|
});
|
|
636
|
+
await waitFor(binding.session, (snapshot) => snapshot.openState === "open" || snapshot.openState === "error", this.timeoutMs);
|
|
637
|
+
if (binding.session.getSnapshot().openState !== "open") {
|
|
638
|
+
throw new Error("Session binding could not be opened.");
|
|
639
|
+
}
|
|
640
|
+
if (attest) {
|
|
641
|
+
const step = await this.attestPolicy(report);
|
|
642
|
+
if (step.kind !== "ok")
|
|
643
|
+
throw new QaPolicyAttestationError();
|
|
644
|
+
}
|
|
645
|
+
if (track) {
|
|
646
|
+
this.chatSessionId = String(this.session.sessionId);
|
|
647
|
+
this.viewingSubagent = null;
|
|
648
|
+
this.chats.addChat(String(this.session.sessionId));
|
|
649
|
+
this.accounts?.onSessionCreated(String(this.session.sessionId));
|
|
650
|
+
}
|
|
266
651
|
this.publish();
|
|
267
652
|
}
|
|
268
653
|
unbind() {
|
|
654
|
+
this.clearStreamFlush();
|
|
269
655
|
this.unsubscribeSession?.();
|
|
270
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
|
+
}
|
|
271
665
|
this.session = undefined;
|
|
666
|
+
this.hostSources.reset();
|
|
272
667
|
this.admissionPending = false;
|
|
668
|
+
this.policyReady = false;
|
|
273
669
|
}
|
|
274
670
|
waitForConnection() {
|
|
275
671
|
return waitFor(this.connection, (description) => description !== undefined, this.timeoutMs);
|
|
276
672
|
}
|
|
673
|
+
/**
|
|
674
|
+
* Project one session notification. A running turn's frames arrive at
|
|
675
|
+
* animation-frame cadence, so re-projections are spaced at least
|
|
676
|
+
* {@link streamIntervalMs} apart: the first frame of a window projects at
|
|
677
|
+
* once and further frames are absorbed (the memoized render path replays
|
|
678
|
+
* nothing, so absorbing a frame only defers it). Everything outside a
|
|
679
|
+
* running turn - phase flips, errors, turn completion - projects
|
|
680
|
+
* immediately.
|
|
681
|
+
*/
|
|
682
|
+
publishSessionUpdate() {
|
|
683
|
+
if (this.streamIntervalMs <= 0 ||
|
|
684
|
+
this.session?.getSnapshot().running !== true) {
|
|
685
|
+
this.clearStreamFlush();
|
|
686
|
+
this.publish();
|
|
687
|
+
return;
|
|
688
|
+
}
|
|
689
|
+
if (this.streamFlushTimer !== undefined)
|
|
690
|
+
return;
|
|
691
|
+
this.publish();
|
|
692
|
+
this.streamFlushTimer = setTimeout(() => {
|
|
693
|
+
this.streamFlushTimer = undefined;
|
|
694
|
+
}, this.streamIntervalMs);
|
|
695
|
+
}
|
|
696
|
+
clearStreamFlush() {
|
|
697
|
+
if (this.streamFlushTimer === undefined)
|
|
698
|
+
return;
|
|
699
|
+
clearTimeout(this.streamFlushTimer);
|
|
700
|
+
this.streamFlushTimer = undefined;
|
|
701
|
+
}
|
|
277
702
|
publish() {
|
|
278
703
|
if (this.disposed)
|
|
279
704
|
return;
|
|
280
705
|
const connected = this.connection.getSnapshot() !== undefined;
|
|
706
|
+
if (this.drafting && this.session === undefined) {
|
|
707
|
+
// Draft state: an empty writable composer without a bound session. The
|
|
708
|
+
// session list is untouched — nothing exists until the first send.
|
|
709
|
+
const materializing = this.materializing !== undefined;
|
|
710
|
+
this.state = {
|
|
711
|
+
...QA_SESSION_IDLE_STATE,
|
|
712
|
+
phase: materializing
|
|
713
|
+
? "creating"
|
|
714
|
+
: !connected && this.connectedOnce
|
|
715
|
+
? "reconnecting"
|
|
716
|
+
: "idle",
|
|
717
|
+
error: this.operationError,
|
|
718
|
+
canSend: connected && !materializing,
|
|
719
|
+
chatsRevision: this.chatsRevision,
|
|
720
|
+
};
|
|
721
|
+
this.emit();
|
|
722
|
+
return;
|
|
723
|
+
}
|
|
281
724
|
if (this.session === undefined) {
|
|
282
725
|
this.state = {
|
|
283
726
|
...this.state,
|
|
@@ -288,75 +731,90 @@ export class QaSessionController {
|
|
|
288
731
|
return;
|
|
289
732
|
}
|
|
290
733
|
const snapshot = this.session.getSnapshot();
|
|
291
|
-
const
|
|
292
|
-
const
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
|
|
302
|
-
|
|
303
|
-
|
|
304
|
-
|
|
305
|
-
|
|
306
|
-
|
|
307
|
-
|
|
308
|
-
|
|
309
|
-
|
|
310
|
-
|
|
311
|
-
|
|
312
|
-
|
|
313
|
-
|
|
314
|
-
}),
|
|
315
|
-
error,
|
|
316
|
-
canSend: connected && phase === "ready",
|
|
317
|
-
canStop: connected && snapshot.running && this.config.ui.showStop && !blocked,
|
|
318
|
-
};
|
|
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,
|
|
753
|
+
chatsRevision: this.chatsRevision,
|
|
754
|
+
viewingSubagent: this.viewingSubagent,
|
|
755
|
+
config: this.config,
|
|
756
|
+
});
|
|
319
757
|
this.emit();
|
|
320
758
|
}
|
|
321
759
|
fail(message, error) {
|
|
322
|
-
|
|
760
|
+
// Policy attestation refusals already logged their precise reason; a
|
|
761
|
+
// second stack trace for the wrapper error is only console noise.
|
|
762
|
+
if (!(error instanceof QaPolicyAttestationError)) {
|
|
763
|
+
console.error("dsh-qa-surface: session operation failed", error);
|
|
764
|
+
}
|
|
323
765
|
if (this.disposed)
|
|
324
766
|
return;
|
|
325
767
|
this.operationError = message;
|
|
326
768
|
this.state = {
|
|
327
|
-
...
|
|
769
|
+
...QA_SESSION_IDLE_STATE,
|
|
328
770
|
phase: "error",
|
|
329
771
|
error: message,
|
|
772
|
+
chatsRevision: this.chatsRevision,
|
|
330
773
|
};
|
|
331
774
|
this.emit();
|
|
332
775
|
}
|
|
333
|
-
|
|
334
|
-
|
|
335
|
-
|
|
336
|
-
|
|
337
|
-
|
|
338
|
-
|
|
339
|
-
|
|
340
|
-
|
|
341
|
-
|
|
342
|
-
return
|
|
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
|
+
*/
|
|
782
|
+
async attestPolicy(reportFailure = true) {
|
|
783
|
+
const session = this.session;
|
|
784
|
+
if (session === undefined)
|
|
785
|
+
return { kind: "refused" };
|
|
786
|
+
const sessionId = String(session.sessionId);
|
|
787
|
+
if (!this.config.lockdown.enabled) {
|
|
788
|
+
this.policyReady = true;
|
|
789
|
+
return { kind: "ok", sessionId };
|
|
343
790
|
}
|
|
344
|
-
|
|
345
|
-
|
|
346
|
-
|
|
347
|
-
this.
|
|
348
|
-
|
|
349
|
-
|
|
350
|
-
|
|
351
|
-
|
|
352
|
-
|
|
353
|
-
|
|
354
|
-
|
|
355
|
-
|
|
791
|
+
this.policyReady = false;
|
|
792
|
+
this.publish();
|
|
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) {
|
|
803
|
+
this.policyReady = true;
|
|
804
|
+
this.operationError = null;
|
|
805
|
+
this.publish();
|
|
806
|
+
return { kind: "ok", sessionId };
|
|
356
807
|
}
|
|
357
|
-
|
|
358
|
-
//
|
|
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" };
|
|
359
813
|
}
|
|
814
|
+
this.policyReady = false;
|
|
815
|
+
this.operationError = CONFIGURATION_ERROR;
|
|
816
|
+
this.publish();
|
|
817
|
+
return { kind: "refused" };
|
|
360
818
|
}
|
|
361
819
|
emit() {
|
|
362
820
|
for (const listener of this.listeners)
|