@yadsh/dsh-qa-surface 0.10.0 → 0.11.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/README.md +310 -48
- package/lib/access/service.js +49 -10
- package/lib/access/service.js.map +1 -1
- package/lib/account-remotes.js +41 -0
- package/lib/account-remotes.js.map +1 -1
- package/lib/accounts/database.js +140 -0
- package/lib/accounts/database.js.map +1 -1
- package/lib/accounts/errors.js.map +1 -1
- package/lib/accounts/service-token.js +103 -0
- package/lib/accounts/service-token.js.map +1 -0
- package/lib/accounts/store.js +291 -5
- package/lib/accounts/store.js.map +1 -1
- package/lib/admin/conversation-log.js +14 -8
- package/lib/admin/conversation-log.js.map +1 -1
- package/lib/admin/permissions.js +5 -0
- package/lib/admin/permissions.js.map +1 -1
- package/lib/admin/quality-store.js +4 -0
- package/lib/admin/quality-store.js.map +1 -1
- package/lib/admin/service.js +173 -0
- package/lib/admin/service.js.map +1 -1
- package/lib/admin/session-log.js +39 -8
- package/lib/admin/session-log.js.map +1 -1
- package/lib/attachment-rules.js +39 -6
- package/lib/attachment-rules.js.map +1 -1
- package/lib/cli.js +83 -1
- package/lib/cli.js.map +1 -1
- package/lib/client/QaAccountsController.js +213 -4
- package/lib/client/QaAccountsController.js.map +1 -1
- package/lib/client/QaSessionController.js +37 -21
- package/lib/client/QaSessionController.js.map +1 -1
- package/lib/client/QaSurface.js +28 -4
- package/lib/client/QaSurface.js.map +1 -1
- package/lib/client/admin/QaAdmin.js +9 -1
- package/lib/client/admin/QaAdmin.js.map +1 -1
- package/lib/client/admin/pages/SkillFiles.js +78 -0
- package/lib/client/admin/pages/SkillFiles.js.map +1 -0
- package/lib/client/admin/pages/Users.js +65 -1
- package/lib/client/admin/pages/Users.js.map +1 -1
- package/lib/client/admin/routes.js +8 -1
- package/lib/client/admin/routes.js.map +1 -1
- package/lib/client/admin/styles.js +2 -0
- package/lib/client/admin/styles.js.map +1 -1
- package/lib/client/attachments.js +41 -11
- package/lib/client/attachments.js.map +1 -1
- package/lib/client/components/QaAuthGate.js +19 -10
- package/lib/client/components/QaAuthGate.js.map +1 -1
- package/lib/client/components/QaChangelog.js +43 -0
- package/lib/client/components/QaChangelog.js.map +1 -1
- package/lib/client/index.js +13 -0
- package/lib/client/index.js.map +1 -1
- package/lib/client/lineage.js +64 -0
- package/lib/client/lineage.js.map +1 -1
- package/lib/client/settings/sections/attachments.js +6 -5
- package/lib/client/settings/sections/attachments.js.map +1 -1
- package/lib/client/settings/sections/notes.js +22 -4
- package/lib/client/settings/sections/notes.js.map +1 -1
- package/lib/client/styles.js +9 -0
- package/lib/client/styles.js.map +1 -1
- package/lib/client/types.js.map +1 -1
- package/lib/client/user-settings/IntegrationTokensPage.js +150 -0
- package/lib/client/user-settings/IntegrationTokensPage.js.map +1 -0
- package/lib/client/user-settings/PasswordSettingsPage.js +48 -0
- package/lib/client/user-settings/PasswordSettingsPage.js.map +1 -0
- package/lib/client/user-settings/SkillCatalog.js +2 -2
- package/lib/client/user-settings/SkillCatalog.js.map +1 -1
- package/lib/client/user-settings/UserSettingsDialog.js +17 -2
- package/lib/client/user-settings/UserSettingsDialog.js.map +1 -1
- package/lib/client/user-settings/copy.js +20 -0
- package/lib/client/user-settings/copy.js.map +1 -1
- package/lib/client.js +2480 -137
- package/lib/client.js.map +1 -1
- package/lib/config-resolvers/attachments.js +6 -5
- package/lib/config-resolvers/attachments.js.map +1 -1
- package/lib/config-resolvers/defaults.js +23 -2
- package/lib/config-resolvers/defaults.js.map +1 -1
- package/lib/config-resolvers/integration.js +94 -0
- package/lib/config-resolvers/integration.js.map +1 -0
- package/lib/config-resolvers/notes.js +2 -0
- package/lib/config-resolvers/notes.js.map +1 -1
- package/lib/config.js +72 -0
- package/lib/config.js.map +1 -1
- package/lib/index.js +197 -2
- package/lib/index.js.map +1 -1
- package/lib/integration/answer.js +100 -0
- package/lib/integration/answer.js.map +1 -0
- package/lib/integration/attachments.js +246 -0
- package/lib/integration/attachments.js.map +1 -0
- package/lib/integration/contract.js +53 -0
- package/lib/integration/contract.js.map +1 -0
- package/lib/integration/host-runner.js +243 -0
- package/lib/integration/host-runner.js.map +1 -0
- package/lib/integration/http.js +576 -0
- package/lib/integration/http.js.map +1 -0
- package/lib/integration/service.js +373 -0
- package/lib/integration/service.js.map +1 -0
- package/lib/integration/transcript-reader.js +202 -0
- package/lib/integration/transcript-reader.js.map +1 -0
- package/lib/integration/transcript.js +148 -0
- package/lib/integration/transcript.js.map +1 -0
- package/lib/personal-skills/index.js +2 -2
- package/lib/personal-skills/index.js.map +1 -1
- package/lib/personal-skills/paths.js +14 -8
- package/lib/personal-skills/paths.js.map +1 -1
- package/lib/personal-skills/provider.js +16 -4
- package/lib/personal-skills/provider.js.map +1 -1
- package/lib/personal-skills/service.js +240 -30
- package/lib/personal-skills/service.js.map +1 -1
- package/lib/prompt-notes.js +58 -0
- package/lib/prompt-notes.js.map +1 -1
- package/lib/provenance/document-preview.js +10 -1
- package/lib/provenance/document-preview.js.map +1 -1
- package/lib/qa-tools/catalog.js +12 -1
- package/lib/qa-tools/catalog.js.map +1 -1
- package/lib/qa-tools/docs-tools.js +563 -0
- package/lib/qa-tools/docs-tools.js.map +1 -0
- package/lib/resolve-config.js +4 -0
- package/lib/resolve-config.js.map +1 -1
- package/lib/secure-session.js +83 -3
- package/lib/secure-session.js.map +1 -1
- package/lib/shared/integration-tokens.js +23 -0
- package/lib/shared/integration-tokens.js.map +1 -0
- package/lib/typert.host.js +1176 -213
- package/lib/typert.remote-client.d.ts +27 -1
- package/lib/typert.remote-client.js +911 -69
- package/lib/types/access/service.d.ts +33 -2
- package/lib/types/account-remotes.d.ts +30 -1
- package/lib/types/accounts/database.d.ts +53 -0
- package/lib/types/accounts/errors.d.ts +5 -1
- package/lib/types/accounts/service-token.d.ts +70 -0
- package/lib/types/accounts/store.d.ts +131 -1
- package/lib/types/admin/conversation-log.d.ts +12 -0
- package/lib/types/admin/service.d.ts +56 -1
- package/lib/types/admin/session-log.d.ts +21 -0
- package/lib/types/attachment-rules.d.ts +26 -3
- package/lib/types/client/QaAccountsController.d.ts +64 -5
- package/lib/types/client/QaSessionController.d.ts +24 -2
- package/lib/types/client/admin/pages/SkillFiles.d.ts +17 -0
- package/lib/types/client/admin/routes.d.ts +2 -0
- package/lib/types/client/attachments.d.ts +21 -4
- package/lib/types/client/lineage.d.ts +42 -0
- package/lib/types/client/settings/sections/notes.d.ts +5 -3
- package/lib/types/client/types.d.ts +71 -1
- package/lib/types/client/user-settings/IntegrationTokensPage.d.ts +21 -0
- package/lib/types/client/user-settings/PasswordSettingsPage.d.ts +19 -0
- package/lib/types/client/user-settings/UserSettingsDialog.d.ts +11 -2
- package/lib/types/client/user-settings/copy.d.ts +10 -1
- package/lib/types/config-resolvers/integration.d.ts +47 -0
- package/lib/types/config.d.ts +10 -0
- package/lib/types/index.d.ts +73 -1
- package/lib/types/integration/answer.d.ts +65 -0
- package/lib/types/integration/attachments.d.ts +85 -0
- package/lib/types/integration/contract.d.ts +196 -0
- package/lib/types/integration/host-runner.d.ts +44 -0
- package/lib/types/integration/http.d.ts +79 -0
- package/lib/types/integration/service.d.ts +120 -0
- package/lib/types/integration/transcript-reader.d.ts +100 -0
- package/lib/types/integration/transcript.d.ts +59 -0
- package/lib/types/personal-skills/index.d.ts +2 -2
- package/lib/types/personal-skills/paths.d.ts +15 -3
- package/lib/types/personal-skills/provider.d.ts +14 -2
- package/lib/types/personal-skills/service.d.ts +89 -14
- package/lib/types/prompt-notes.d.ts +28 -0
- package/lib/types/provenance/document-preview.d.ts +4 -0
- package/lib/types/qa-tools/catalog.d.ts +1 -1
- package/lib/types/qa-tools/docs-tools.d.ts +160 -0
- package/lib/types/secure-session.d.ts +52 -1
- package/lib/types/shared/integration-tokens.d.ts +24 -0
- package/lib/types/types.d.ts +170 -2
- package/package.json +2 -2
|
@@ -0,0 +1,100 @@
|
|
|
1
|
+
import { messageContentText } from "../admin/conversation-log.js";
|
|
2
|
+
/** The empty answer: what a turn that committed nothing readable looks like. */
|
|
3
|
+
export const QA_EMPTY_ANSWER = Object.freeze({
|
|
4
|
+
answer: "",
|
|
5
|
+
interrupted: false,
|
|
6
|
+
seq: null,
|
|
7
|
+
});
|
|
8
|
+
function record(value) {
|
|
9
|
+
return typeof value === "object" && value !== null && !Array.isArray(value)
|
|
10
|
+
? value
|
|
11
|
+
: undefined;
|
|
12
|
+
}
|
|
13
|
+
/**
|
|
14
|
+
* Project the answer of the turn that starts after `afterSeq`.
|
|
15
|
+
*
|
|
16
|
+
* Only events after the caller's own prompt are read: a chat is continued
|
|
17
|
+
* across questions, and picking the last assistant message of the whole log
|
|
18
|
+
* would hand question #2 the answer to question #1 whenever the new turn
|
|
19
|
+
* committed nothing.
|
|
20
|
+
*
|
|
21
|
+
* @param events - durable events, in any order.
|
|
22
|
+
* @param afterSeq - the seq of the prompt whose answer is wanted.
|
|
23
|
+
* @returns the publishable answer and whether the turn was interrupted.
|
|
24
|
+
*/
|
|
25
|
+
export function answerAfter(events, afterSeq) {
|
|
26
|
+
const ordered = [...events].sort((left, right) => left.seq - right.seq);
|
|
27
|
+
let answer = "";
|
|
28
|
+
let seq = null;
|
|
29
|
+
let lastWasInterrupted = false;
|
|
30
|
+
for (const event of ordered) {
|
|
31
|
+
if (event.type !== "assistant/message" || event.seq <= afterSeq)
|
|
32
|
+
continue;
|
|
33
|
+
const data = record(event.data);
|
|
34
|
+
const message = record(data?.message);
|
|
35
|
+
lastWasInterrupted = data?.interrupted === true;
|
|
36
|
+
const text = messageContentText(message?.content).trim();
|
|
37
|
+
if (text === "")
|
|
38
|
+
continue;
|
|
39
|
+
answer = text;
|
|
40
|
+
seq = event.seq;
|
|
41
|
+
}
|
|
42
|
+
return Object.freeze({ answer, interrupted: lastWasInterrupted, seq });
|
|
43
|
+
}
|
|
44
|
+
/**
|
|
45
|
+
* Cut an answer down to the size the caller publishes.
|
|
46
|
+
*
|
|
47
|
+
* The bridge posts the answer into a ticket comment, which has a size budget
|
|
48
|
+
* of its own: an over-long comment is not rejected there, it is cut — and a cut
|
|
49
|
+
* that lands in the middle of a sentence, or worse inside a code block, reads
|
|
50
|
+
* like a broken answer. The cut therefore prefers the last paragraph break,
|
|
51
|
+
* then the last line, then the last sentence, and only then an arbitrary word
|
|
52
|
+
* boundary, and it appends an ellipsis so the reader sees that text is missing
|
|
53
|
+
* rather than finished. A boundary that would leave less than half the budget
|
|
54
|
+
* used is refused: a single early paragraph break must not turn a full answer
|
|
55
|
+
* into one line.
|
|
56
|
+
* @param answer - the settled answer, already trimmed.
|
|
57
|
+
* @param maxCharacters - the publish budget; a non-positive value never cuts.
|
|
58
|
+
* @returns the answer, or its bounded head.
|
|
59
|
+
*/
|
|
60
|
+
export function boundAnswer(answer, maxCharacters) {
|
|
61
|
+
if (maxCharacters <= 0 || answer.length <= maxCharacters)
|
|
62
|
+
return answer;
|
|
63
|
+
// The ellipsis itself is part of the budget, so a cut answer still fits in
|
|
64
|
+
// the limit the operator configured.
|
|
65
|
+
const head = answer.slice(0, maxCharacters - 1);
|
|
66
|
+
const floor = Math.floor(head.length / 2);
|
|
67
|
+
let kept = head;
|
|
68
|
+
for (const separator of ["\n\n", "\n", ". ", " "]) {
|
|
69
|
+
const at = head.lastIndexOf(separator);
|
|
70
|
+
if (at >= floor) {
|
|
71
|
+
kept = head.slice(0, separator === ". " ? at + 1 : at);
|
|
72
|
+
break;
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
return `${kept.replace(/\s+$/u, "")}…`;
|
|
76
|
+
}
|
|
77
|
+
/**
|
|
78
|
+
* The seq of the newest user-authored message in a log, which is the floor
|
|
79
|
+
* `answerAfter` reads from when the caller has just submitted a prompt.
|
|
80
|
+
* Synthetic context (injected notes, skill payloads) is skipped: it is model
|
|
81
|
+
* input recorded as a user message, and one arriving after the answer would
|
|
82
|
+
* otherwise hide it.
|
|
83
|
+
* @param events - durable events, in any order.
|
|
84
|
+
* @returns the seq, or 0 when the log holds no human prompt yet.
|
|
85
|
+
*/
|
|
86
|
+
export function lastPromptSeq(events) {
|
|
87
|
+
let seq = 0;
|
|
88
|
+
for (const event of events) {
|
|
89
|
+
if (event.type !== "user/message")
|
|
90
|
+
continue;
|
|
91
|
+
const data = record(event.data);
|
|
92
|
+
const source = record(data?.source);
|
|
93
|
+
if (source?.kind !== "user")
|
|
94
|
+
continue;
|
|
95
|
+
if (event.seq > seq)
|
|
96
|
+
seq = event.seq;
|
|
97
|
+
}
|
|
98
|
+
return seq;
|
|
99
|
+
}
|
|
100
|
+
//# sourceMappingURL=answer.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"answer.js","sourceRoot":"","sources":["../../src/integration/answer.ts"],"names":[],"mappings":"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"}
|
|
@@ -0,0 +1,246 @@
|
|
|
1
|
+
import { mkdtemp, rm, writeFile } from "node:fs/promises";
|
|
2
|
+
import { tmpdir } from "node:os";
|
|
3
|
+
import path from "node:path";
|
|
4
|
+
import { QA_PDF_MEDIA_TYPE, QA_WORD_MEDIA_TYPE } from "../shared/documents.js";
|
|
5
|
+
import { QaIntegrationAttachmentError, } from "./contract.js";
|
|
6
|
+
/**
|
|
7
|
+
* Bringing a ticket's attachments into the prompt.
|
|
8
|
+
*
|
|
9
|
+
* An image rides the prompt inline, because that is what the harness carries.
|
|
10
|
+
* A file does not: `PromptContentPart` names one as an opaque receipt minted by
|
|
11
|
+
* the harness's own upload service, which an external caller has no session to
|
|
12
|
+
* reach — and that service is what stores the copy the model later reads with
|
|
13
|
+
* its file tools. The bridge's non-image attachments are therefore read *here*
|
|
14
|
+
* and arrive as text: a text file is decoded, a document is extracted through
|
|
15
|
+
* the deployment's own document pipeline (the same runtime, providers and
|
|
16
|
+
* limits the `document_*` tools use), and the result joins the prompt as one
|
|
17
|
+
* more text part.
|
|
18
|
+
*
|
|
19
|
+
* That is a deliberate trade. It costs the fidelity of a format the pipeline
|
|
20
|
+
* cannot read — such a request is refused with the bridge's own fallback signal
|
|
21
|
+
* rather than answered with a question the model cannot see the attachment for.
|
|
22
|
+
* It buys a path that works in every deployment shape: nothing is written into
|
|
23
|
+
* a shared workspace, the staging directory is temporary and removed after the
|
|
24
|
+
* extraction, and no second converter is grown inside this plugin.
|
|
25
|
+
*/
|
|
26
|
+
/**
|
|
27
|
+
* Plain-text formats the bridge sends; their bytes *are* the content, so
|
|
28
|
+
* decoding them needs no pipeline and cannot fail on a deployment.
|
|
29
|
+
*/
|
|
30
|
+
export const QA_INTEGRATION_TEXT_MEDIA_TYPES = Object.freeze(["text/plain", "text/csv", "text/markdown"]);
|
|
31
|
+
/**
|
|
32
|
+
* Documents the deployment's pipeline is asked to extract. PDF and the OOXML
|
|
33
|
+
* family are what the bridge's own filter lets through; whether a particular
|
|
34
|
+
* one is readable is the pipeline's answer, not a list kept here.
|
|
35
|
+
*/
|
|
36
|
+
export const QA_INTEGRATION_DOCUMENT_MEDIA_TYPES = Object.freeze([
|
|
37
|
+
QA_PDF_MEDIA_TYPE,
|
|
38
|
+
QA_WORD_MEDIA_TYPE,
|
|
39
|
+
"application/vnd.openxmlformats-officedocument.spreadsheetml.sheet",
|
|
40
|
+
"application/vnd.openxmlformats-officedocument.presentationml.presentation",
|
|
41
|
+
]);
|
|
42
|
+
/** Total inlined attachment text per question, across every attachment. */
|
|
43
|
+
export const QA_INTEGRATION_MAX_ATTACHMENT_CHARS = 120_000;
|
|
44
|
+
/** No single attachment may claim the whole budget. */
|
|
45
|
+
export const QA_INTEGRATION_MAX_ATTACHMENT_CHARS_PER_FILE = 60_000;
|
|
46
|
+
/** A file name is a label in the prompt, not a path. */
|
|
47
|
+
export const QA_INTEGRATION_MAX_ATTACHMENT_NAME = 120;
|
|
48
|
+
/** Whether the bytes are the content: decoding needs only UTF-8. */
|
|
49
|
+
export function isIntegrationTextMediaType(mediaType) {
|
|
50
|
+
return QA_INTEGRATION_TEXT_MEDIA_TYPES.includes(mediaType.trim().toLowerCase());
|
|
51
|
+
}
|
|
52
|
+
/** Whether the attachment has to go through the document pipeline. */
|
|
53
|
+
export function isIntegrationDocumentMediaType(mediaType) {
|
|
54
|
+
return QA_INTEGRATION_DOCUMENT_MEDIA_TYPES.includes(mediaType.trim().toLowerCase());
|
|
55
|
+
}
|
|
56
|
+
/**
|
|
57
|
+
* Drop every C0/C1 control character and DEL.
|
|
58
|
+
*
|
|
59
|
+
* Spelled as a code-point filter rather than a character class: a name is a
|
|
60
|
+
* log field and a prompt heading, and a newline smuggled through one of those
|
|
61
|
+
* would let the caller forge lines in either.
|
|
62
|
+
*/
|
|
63
|
+
function stripControlCharacters(value) {
|
|
64
|
+
return Array.from(value)
|
|
65
|
+
.filter((character) => {
|
|
66
|
+
const code = character.codePointAt(0) ?? 0;
|
|
67
|
+
return code > 0x1f && code !== 0x7f;
|
|
68
|
+
})
|
|
69
|
+
.join("");
|
|
70
|
+
}
|
|
71
|
+
/**
|
|
72
|
+
* Reduce a caller-supplied file name to the label a prompt can carry.
|
|
73
|
+
*
|
|
74
|
+
* The name arrives from another system and is used as a log field, a heading in
|
|
75
|
+
* the prompt and the file name of one temporary file — so directory parts,
|
|
76
|
+
* traversal, control characters and an unbounded length all have to go before
|
|
77
|
+
* it is any of those. An empty result is named by its media type.
|
|
78
|
+
* @param raw - the name the caller sent.
|
|
79
|
+
* @param mediaType - its media type, for the fallback name.
|
|
80
|
+
* @returns a safe, non-empty label.
|
|
81
|
+
*/
|
|
82
|
+
export function safeAttachmentName(raw, mediaType) {
|
|
83
|
+
const base = stripControlCharacters(path.basename(raw.replace(/\\/gu, "/")))
|
|
84
|
+
.replace(/^\.+/u, "")
|
|
85
|
+
.trim();
|
|
86
|
+
const bounded = base.slice(0, QA_INTEGRATION_MAX_ATTACHMENT_NAME);
|
|
87
|
+
if (bounded !== "")
|
|
88
|
+
return bounded;
|
|
89
|
+
const subtype = mediaType.split("/")[1] ?? "file";
|
|
90
|
+
return `attachment.${subtype.replace(/[^a-z0-9.]/giu, "")}`;
|
|
91
|
+
}
|
|
92
|
+
/** A human-readable size for the prompt's heading. */
|
|
93
|
+
function formatBytes(bytes) {
|
|
94
|
+
if (bytes < 1024)
|
|
95
|
+
return `${String(bytes)} Б`;
|
|
96
|
+
if (bytes < 1024 * 1024)
|
|
97
|
+
return `${String(Math.round(bytes / 1024))} КБ`;
|
|
98
|
+
const megabytes = bytes / (1024 * 1024);
|
|
99
|
+
return `${String(Math.round(megabytes * 10) / 10).replace(".", ",")} МБ`;
|
|
100
|
+
}
|
|
101
|
+
/**
|
|
102
|
+
* Whether a text attachment is actually text.
|
|
103
|
+
*
|
|
104
|
+
* A NUL byte in the head of a file is the cheapest reliable signal that the
|
|
105
|
+
* caller labelled something binary as text; decoding it would put replacement
|
|
106
|
+
* characters in the prompt and call it the document's content.
|
|
107
|
+
*/
|
|
108
|
+
function looksLikeText(bytes) {
|
|
109
|
+
return !bytes.subarray(0, 8192).includes(0);
|
|
110
|
+
}
|
|
111
|
+
/** Cut extracted text to the budget, on a line or word boundary where possible. */
|
|
112
|
+
function boundText(text, budget) {
|
|
113
|
+
if (text.length <= budget)
|
|
114
|
+
return text;
|
|
115
|
+
const head = text.slice(0, budget);
|
|
116
|
+
const at = Math.max(head.lastIndexOf("\n"), head.lastIndexOf(" "));
|
|
117
|
+
return `${(at > 0 ? head.slice(0, at) : head).trimEnd()}…`;
|
|
118
|
+
}
|
|
119
|
+
/** One attachment as the prompt carries it: a heading and the readable content. */
|
|
120
|
+
function attachmentBlock(input) {
|
|
121
|
+
const heading = `--- ${input.name} (${input.mediaType}, ${formatBytes(input.bytes)}) ---`;
|
|
122
|
+
const note = input.truncated
|
|
123
|
+
? "\n\n[…текст сокращён, показано начало файла]"
|
|
124
|
+
: "";
|
|
125
|
+
return {
|
|
126
|
+
type: "text",
|
|
127
|
+
text: input.text === ""
|
|
128
|
+
? `${heading}\n[файл прочитан, но не содержит текста]`
|
|
129
|
+
: `${heading}\n${input.text}${note}`,
|
|
130
|
+
};
|
|
131
|
+
}
|
|
132
|
+
/**
|
|
133
|
+
* Extract one document through the deployment's pipeline.
|
|
134
|
+
*
|
|
135
|
+
* The pipeline takes a path, not bytes, so the attachment is written to a
|
|
136
|
+
* temporary directory created for this one extraction, and that directory is
|
|
137
|
+
* the workspace root the conversion is scoped to. An unconfigured deployment
|
|
138
|
+
* therefore keeps the conversion's artifacts beside the input — inside the
|
|
139
|
+
* directory `finally` removes — while a deployment that configured its own
|
|
140
|
+
* artifact root keeps them under it, where its own retention sweep already
|
|
141
|
+
* governs them. Either way nothing lands in a workspace the deployment shares.
|
|
142
|
+
*/
|
|
143
|
+
async function extractDocumentText(file, name, deps) {
|
|
144
|
+
const documents = deps.documents;
|
|
145
|
+
if (documents === undefined) {
|
|
146
|
+
throw new QaIntegrationAttachmentError(name, "unavailable", "this deployment has no document pipeline");
|
|
147
|
+
}
|
|
148
|
+
let directory;
|
|
149
|
+
try {
|
|
150
|
+
directory = await mkdtemp(path.join(tmpdir(), "qa-integration-attachment-"));
|
|
151
|
+
}
|
|
152
|
+
catch (error) {
|
|
153
|
+
// A Host whose temporary directory cannot be written cannot read the
|
|
154
|
+
// attachment either. That is the caller's own answer — repeat the question
|
|
155
|
+
// without it — and not a 5xx: a bridge would retry a 5xx forever against
|
|
156
|
+
// the same broken directory.
|
|
157
|
+
deps.logger.warn("integration.attachment-unreadable", {
|
|
158
|
+
name,
|
|
159
|
+
mediaType: file.mediaType,
|
|
160
|
+
message: error instanceof Error ? error.message : String(error),
|
|
161
|
+
});
|
|
162
|
+
throw new QaIntegrationAttachmentError(name, "unavailable", `could not stage ${name} for extraction`);
|
|
163
|
+
}
|
|
164
|
+
try {
|
|
165
|
+
const input = path.join(directory, name);
|
|
166
|
+
await writeFile(input, file.bytes, { mode: 0o600 });
|
|
167
|
+
const extracted = await documents.toMarkdown({ file: input }, {
|
|
168
|
+
workspaceRoot: directory,
|
|
169
|
+
sessionId: deps.sessionId,
|
|
170
|
+
signal: deps.signal,
|
|
171
|
+
});
|
|
172
|
+
return extracted.markdown;
|
|
173
|
+
}
|
|
174
|
+
catch (error) {
|
|
175
|
+
if (error instanceof QaIntegrationAttachmentError)
|
|
176
|
+
throw error;
|
|
177
|
+
deps.logger.warn("integration.attachment-unreadable", {
|
|
178
|
+
name,
|
|
179
|
+
mediaType: file.mediaType,
|
|
180
|
+
message: error instanceof Error ? error.message : String(error),
|
|
181
|
+
});
|
|
182
|
+
throw new QaIntegrationAttachmentError(name, "unsupported", `the document pipeline could not read ${name}`);
|
|
183
|
+
}
|
|
184
|
+
finally {
|
|
185
|
+
await rm(directory, { recursive: true, force: true }).catch(() => undefined);
|
|
186
|
+
}
|
|
187
|
+
}
|
|
188
|
+
/**
|
|
189
|
+
* Turn the request's file attachments into prompt text parts.
|
|
190
|
+
*
|
|
191
|
+
* The parts are appended after the question, in the order the caller sent them,
|
|
192
|
+
* and the question itself is untouched. A file the Host cannot read refuses the
|
|
193
|
+
* whole request: answering with the question but not its attachments would hand
|
|
194
|
+
* the model a question about material it never received, which is exactly what
|
|
195
|
+
* the bridge's 415 fallback exists to avoid.
|
|
196
|
+
*
|
|
197
|
+
* @param files - the non-image attachments of one request.
|
|
198
|
+
* @param deps - pipeline, session and budgets.
|
|
199
|
+
* @returns one text part per readable attachment, plus a note when the budget cut the list.
|
|
200
|
+
*/
|
|
201
|
+
export async function attachmentPromptParts(files, deps) {
|
|
202
|
+
if (files.length === 0)
|
|
203
|
+
return [];
|
|
204
|
+
const total = deps.maxChars ?? QA_INTEGRATION_MAX_ATTACHMENT_CHARS;
|
|
205
|
+
const parts = [];
|
|
206
|
+
let remaining = total;
|
|
207
|
+
let dropped = 0;
|
|
208
|
+
for (const file of files) {
|
|
209
|
+
if (remaining <= 0) {
|
|
210
|
+
dropped += 1;
|
|
211
|
+
continue;
|
|
212
|
+
}
|
|
213
|
+
const mediaType = file.mediaType.trim().toLowerCase();
|
|
214
|
+
const name = safeAttachmentName(file.name, mediaType);
|
|
215
|
+
let text;
|
|
216
|
+
if (isIntegrationTextMediaType(mediaType)) {
|
|
217
|
+
if (!looksLikeText(file.bytes)) {
|
|
218
|
+
throw new QaIntegrationAttachmentError(name, "unsupported", `${name} is not text`);
|
|
219
|
+
}
|
|
220
|
+
text = file.bytes.toString("utf8");
|
|
221
|
+
}
|
|
222
|
+
else {
|
|
223
|
+
text = await extractDocumentText(file, name, deps);
|
|
224
|
+
}
|
|
225
|
+
const budget = Math.min(remaining, QA_INTEGRATION_MAX_ATTACHMENT_CHARS_PER_FILE);
|
|
226
|
+
const bounded = boundText(text.trim(), budget);
|
|
227
|
+
remaining -= bounded.length;
|
|
228
|
+
parts.push(Object.freeze(attachmentBlock({
|
|
229
|
+
name,
|
|
230
|
+
mediaType,
|
|
231
|
+
bytes: file.bytes.length,
|
|
232
|
+
text: bounded,
|
|
233
|
+
truncated: bounded.length < text.trim().length,
|
|
234
|
+
})));
|
|
235
|
+
}
|
|
236
|
+
if (dropped > 0) {
|
|
237
|
+
parts.push({
|
|
238
|
+
type: "text",
|
|
239
|
+
text: `[${String(dropped)} вложений не показано: исчерпан предел текста вложений]`,
|
|
240
|
+
});
|
|
241
|
+
}
|
|
242
|
+
return Object.freeze(parts);
|
|
243
|
+
}
|
|
244
|
+
/** Re-exported so the runner does not reach into the contract for one name. */
|
|
245
|
+
export { QaIntegrationAttachmentError };
|
|
246
|
+
//# sourceMappingURL=attachments.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"attachments.js","sourceRoot":"","sources":["../../src/integration/attachments.ts"],"names":[],"mappings":"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"}
|
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The integration API's wire contract: what an external application may post to
|
|
3
|
+
* `/qa/api/ask`, what it gets back, and the refusal vocabulary the HTTP layer
|
|
4
|
+
* maps onto status codes.
|
|
5
|
+
*
|
|
6
|
+
* The shape follows the ticket bridge specification the deployment was asked to
|
|
7
|
+
* implement (issue #177): a Markdown `answer`, the `sources` that back it, a
|
|
8
|
+
* `confidence` bucket, an `escalate` flag for "the assistant could not answer,
|
|
9
|
+
* send it to a specialist", and a `reason` for the logs. `chat_id` is the DSH
|
|
10
|
+
* session identity: sending it back as `session_id` continues the same
|
|
11
|
+
* conversation, which is the whole point of the bridge keeping it.
|
|
12
|
+
*/
|
|
13
|
+
/**
|
|
14
|
+
* HTTP status per refusal. The mapping is part of the contract, not a detail:
|
|
15
|
+
* the bridge retries on 5xx and 429, falls back to a bodyless JSON request on
|
|
16
|
+
* 415, and treats 401/403 as a configuration error it must not spin on.
|
|
17
|
+
*/
|
|
18
|
+
export const QA_INTEGRATION_STATUS = Object.freeze({
|
|
19
|
+
unauthorized: 401,
|
|
20
|
+
forbidden: 403,
|
|
21
|
+
"not-found": 404,
|
|
22
|
+
"invalid-request": 400,
|
|
23
|
+
"unsupported-media": 415,
|
|
24
|
+
"payload-too-large": 413,
|
|
25
|
+
"rate-limited": 429,
|
|
26
|
+
busy: 429,
|
|
27
|
+
unavailable: 503,
|
|
28
|
+
timeout: 504,
|
|
29
|
+
});
|
|
30
|
+
/** One refusal, carrying the status the HTTP layer answers with. */
|
|
31
|
+
export class QaIntegrationError extends Error {
|
|
32
|
+
reason;
|
|
33
|
+
constructor(reason, message, options) {
|
|
34
|
+
super(message, options);
|
|
35
|
+
this.name = "QaIntegrationError";
|
|
36
|
+
this.reason = reason;
|
|
37
|
+
}
|
|
38
|
+
get status() {
|
|
39
|
+
return QA_INTEGRATION_STATUS[this.reason];
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
/** One attachment the Host could not turn into prompt content. */
|
|
43
|
+
export class QaIntegrationAttachmentError extends Error {
|
|
44
|
+
attachment;
|
|
45
|
+
reason;
|
|
46
|
+
constructor(attachment, reason, message) {
|
|
47
|
+
super(message);
|
|
48
|
+
this.attachment = attachment;
|
|
49
|
+
this.name = "QaIntegrationAttachmentError";
|
|
50
|
+
this.reason = reason;
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
//# sourceMappingURL=contract.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"contract.js","sourceRoot":"","sources":["../../src/integration/contract.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;GAWG;AAiBH;;;;GAIG;AACH,MAAM,CAAC,MAAM,qBAAqB,GAE9B,MAAM,CAAC,MAAM,CAAC;IAChB,YAAY,EAAE,GAAG;IACjB,SAAS,EAAE,GAAG;IACd,WAAW,EAAE,GAAG;IAChB,iBAAiB,EAAE,GAAG;IACtB,mBAAmB,EAAE,GAAG;IACxB,mBAAmB,EAAE,GAAG;IACxB,cAAc,EAAE,GAAG;IACnB,IAAI,EAAE,GAAG;IACT,WAAW,EAAE,GAAG;IAChB,OAAO,EAAE,GAAG;CACb,CAAC,CAAC;AAEH,oEAAoE;AACpE,MAAM,OAAO,kBAAmB,SAAQ,KAAK;IAClC,MAAM,CAAsB;IAErC,YACE,MAA2B,EAC3B,OAAe,EACf,OAAsC;QAEtC,KAAK,CAAC,OAAO,EAAE,OAAO,CAAC,CAAC;QACxB,IAAI,CAAC,IAAI,GAAG,oBAAoB,CAAC;QACjC,IAAI,CAAC,MAAM,GAAG,MAAM,CAAC;IACvB,CAAC;IAED,IAAI,MAAM;QACR,OAAO,qBAAqB,CAAC,IAAI,CAAC,MAAM,CAAC,CAAC;IAC5C,CAAC;CACF;AA6CD,kEAAkE;AAClE,MAAM,OAAO,4BAA6B,SAAQ,KAAK;IAI1C,UAAU;IAHZ,MAAM,CAAgC;IAE/C,YACW,UAAkB,EAC3B,MAAqC,EACrC,OAAe;QAEf,KAAK,CAAC,OAAO,CAAC,CAAC;0BAJN,UAAU;QAKnB,IAAI,CAAC,IAAI,GAAG,8BAA8B,CAAC;QAC3C,IAAI,CAAC,MAAM,GAAG,MAAM,CAAC;IACvB,CAAC;CACF"}
|
|
@@ -0,0 +1,243 @@
|
|
|
1
|
+
import { randomUUID } from "node:crypto";
|
|
2
|
+
import { SessionId } from "@deepseek-ai/dsh-session/types";
|
|
3
|
+
import { WorkspaceId } from "@deepseek-ai/dsh-workspace";
|
|
4
|
+
import { prepareQaUserWorkspace } from "../user-workspace.js";
|
|
5
|
+
import { answerAfter, lastPromptSeq } from "./answer.js";
|
|
6
|
+
import { attachmentPromptParts } from "./attachments.js";
|
|
7
|
+
import { QaIntegrationTranscriptReader } from "./transcript-reader.js";
|
|
8
|
+
/** Build the runner for one plugin instance. */
|
|
9
|
+
export function createQaIntegrationRunner(options) {
|
|
10
|
+
const { ctx, logger } = options;
|
|
11
|
+
// One reader for the plugin's lifetime: its whole value is the windows it
|
|
12
|
+
// keeps between calls, so a per-request instance would be a slower copy of
|
|
13
|
+
// the plain projection.
|
|
14
|
+
const transcripts = new QaIntegrationTranscriptReader({
|
|
15
|
+
log: options.sessionLog,
|
|
16
|
+
logger,
|
|
17
|
+
});
|
|
18
|
+
const requireAccounts = () => {
|
|
19
|
+
const accounts = options.accounts();
|
|
20
|
+
if (accounts === undefined) {
|
|
21
|
+
throw new Error("QA accounts are not enabled on this deployment.");
|
|
22
|
+
}
|
|
23
|
+
return accounts;
|
|
24
|
+
};
|
|
25
|
+
const openChat = async (input) => {
|
|
26
|
+
const config = options.getConfig();
|
|
27
|
+
const accounts = requireAccounts();
|
|
28
|
+
const owner = accounts.accountById(input.userId);
|
|
29
|
+
if (owner === undefined) {
|
|
30
|
+
throw new Error("the integration token's account no longer exists");
|
|
31
|
+
}
|
|
32
|
+
const id = SessionId(`session-${randomUUID()}`);
|
|
33
|
+
// The ownership record is written before the session exists, exactly as
|
|
34
|
+
// the browser path does it: the id is minted here, so no other caller can
|
|
35
|
+
// claim it in between, and a failure below rolls the record back.
|
|
36
|
+
options.access.reserveSessionForUser(owner, String(id));
|
|
37
|
+
try {
|
|
38
|
+
options.admission.preflightDeployment();
|
|
39
|
+
let userCwd;
|
|
40
|
+
if (config.accounts.perUserWorkspace) {
|
|
41
|
+
const workspaceId = config.session.workspaceId;
|
|
42
|
+
if (workspaceId === null) {
|
|
43
|
+
throw new Error("Per-user QA workspace configuration is unavailable.");
|
|
44
|
+
}
|
|
45
|
+
const workspace = ctx.workspaceRegistry.get(WorkspaceId(workspaceId));
|
|
46
|
+
if (workspace === undefined) {
|
|
47
|
+
throw new Error(`Configured QA workspace ${workspaceId} is unavailable.`);
|
|
48
|
+
}
|
|
49
|
+
userCwd = prepareQaUserWorkspace(workspace.path, owner.id);
|
|
50
|
+
}
|
|
51
|
+
const created = await ctx.sessionController.create({
|
|
52
|
+
sessionId: id,
|
|
53
|
+
...(userCwd !== undefined
|
|
54
|
+
? { cwd: userCwd }
|
|
55
|
+
: config.session.workspaceId !== null
|
|
56
|
+
? { workspaceId: WorkspaceId(config.session.workspaceId) }
|
|
57
|
+
: config.session.cwd !== null
|
|
58
|
+
? { cwd: config.session.cwd }
|
|
59
|
+
: {}),
|
|
60
|
+
...(config.session.agentPreset === null
|
|
61
|
+
? {}
|
|
62
|
+
: { agentPreset: config.session.agentPreset }),
|
|
63
|
+
});
|
|
64
|
+
if (config.session.provider !== null && config.session.model !== null) {
|
|
65
|
+
await ctx.sessionController.selectModel({
|
|
66
|
+
sessionId: created.sessionId,
|
|
67
|
+
provider: config.session.provider,
|
|
68
|
+
model: config.session.model,
|
|
69
|
+
...(config.session.reasoningEffort === null
|
|
70
|
+
? {}
|
|
71
|
+
: { reasoningEffort: config.session.reasoningEffort }),
|
|
72
|
+
});
|
|
73
|
+
}
|
|
74
|
+
const sessionId = String(created.sessionId);
|
|
75
|
+
await options.admission.secureSessionForUser(owner.id, sessionId);
|
|
76
|
+
logger.info("integration.chat-opened", {
|
|
77
|
+
sessionId,
|
|
78
|
+
userId: owner.id,
|
|
79
|
+
ticketKey: input.ticketKey,
|
|
80
|
+
});
|
|
81
|
+
return sessionId;
|
|
82
|
+
}
|
|
83
|
+
catch (error) {
|
|
84
|
+
// Nothing else would ever remove the record: the chat was never
|
|
85
|
+
// returned to the caller, so no later call can name it.
|
|
86
|
+
accounts.releaseSessionReservation(owner.id, String(id));
|
|
87
|
+
throw error;
|
|
88
|
+
}
|
|
89
|
+
};
|
|
90
|
+
/** The newest durable cursor in a log, or 0 when there is nothing to read. */
|
|
91
|
+
const floorOf = async (sessionId) => {
|
|
92
|
+
const read = await options.sessionLog.read(sessionId);
|
|
93
|
+
if (!read.ok)
|
|
94
|
+
return 0;
|
|
95
|
+
return read.events.reduce((max, event) => Math.max(max, event.seq), 0);
|
|
96
|
+
};
|
|
97
|
+
/**
|
|
98
|
+
* The prompt of one question: the question, then the attachments.
|
|
99
|
+
*
|
|
100
|
+
* Images keep the shape the harness carries them in. Files are read on the
|
|
101
|
+
* Host — a text file decoded, a document extracted through the deployment's
|
|
102
|
+
* pipeline — and arrive as text under a heading per file, preceded by one
|
|
103
|
+
* lead line so the model knows why the question has more than it asked for.
|
|
104
|
+
* A file that cannot be read throws, which the service turns into the 415
|
|
105
|
+
* the caller's fallback understands: publishing an answer to a question the
|
|
106
|
+
* model could not see the attachment for is worse than asking again without
|
|
107
|
+
* it.
|
|
108
|
+
*/
|
|
109
|
+
const promptParts = async (message, attachments, sessionId, signal) => {
|
|
110
|
+
const images = attachments
|
|
111
|
+
.filter((attachment) => attachment.kind === "image")
|
|
112
|
+
.map((attachment) => ({
|
|
113
|
+
type: "image",
|
|
114
|
+
mediaType: attachment.mediaType,
|
|
115
|
+
data: attachment.data,
|
|
116
|
+
...(attachment.name === undefined ? {} : { name: attachment.name }),
|
|
117
|
+
}));
|
|
118
|
+
const files = attachments.filter((attachment) => attachment.kind === "file");
|
|
119
|
+
if (files.length === 0) {
|
|
120
|
+
return [{ type: "text", text: message }, ...images];
|
|
121
|
+
}
|
|
122
|
+
const extracted = await attachmentPromptParts(files, {
|
|
123
|
+
documents: options.documents(),
|
|
124
|
+
sessionId,
|
|
125
|
+
signal,
|
|
126
|
+
logger,
|
|
127
|
+
});
|
|
128
|
+
return [
|
|
129
|
+
{ type: "text", text: message },
|
|
130
|
+
...images,
|
|
131
|
+
{ type: "text", text: QA_ATTACHMENT_LEAD },
|
|
132
|
+
...extracted,
|
|
133
|
+
];
|
|
134
|
+
};
|
|
135
|
+
return {
|
|
136
|
+
async run(input) {
|
|
137
|
+
const chatId = input.chatId === null
|
|
138
|
+
? await openChat({
|
|
139
|
+
userId: input.userId,
|
|
140
|
+
ticketKey: input.ticketKey,
|
|
141
|
+
})
|
|
142
|
+
: input.chatId;
|
|
143
|
+
input.onChat?.(chatId);
|
|
144
|
+
if (input.chatId !== null) {
|
|
145
|
+
// A continued chat is re-attested before every question. The same call
|
|
146
|
+
// the browser makes when it opens a session: it is what refuses a
|
|
147
|
+
// chat belonging to another account (the ownership record is the
|
|
148
|
+
// authority) and what keeps the tool policy applied to a session
|
|
149
|
+
// resumed by a process that has never seen it.
|
|
150
|
+
await options.admission.secureSessionForUser(input.userId, chatId);
|
|
151
|
+
}
|
|
152
|
+
const floor = await floorOf(chatId);
|
|
153
|
+
const content = await promptParts(input.message, input.attachments, chatId, input.signal);
|
|
154
|
+
// The client mints this identity; the harness brands it. A plain UUID
|
|
155
|
+
// is exactly what a browser sends, and it is what makes the prompt
|
|
156
|
+
// idempotent if the same question is ever retried.
|
|
157
|
+
const requestId = randomUUID();
|
|
158
|
+
await ctx.sessionController.prompt({
|
|
159
|
+
requestId,
|
|
160
|
+
sessionId: SessionId(chatId),
|
|
161
|
+
mode: "queue",
|
|
162
|
+
content,
|
|
163
|
+
}, input.signal);
|
|
164
|
+
const agent = ctx.agents.get(SessionId(chatId));
|
|
165
|
+
if (agent === undefined) {
|
|
166
|
+
throw new Error("the QA session has no live agent");
|
|
167
|
+
}
|
|
168
|
+
await whenIdleOrAborted(agent, input.signal);
|
|
169
|
+
const read = await options.sessionLog.read(chatId);
|
|
170
|
+
if (!read.ok) {
|
|
171
|
+
throw new Error(`the session log is unavailable (${read.reason})`);
|
|
172
|
+
}
|
|
173
|
+
// The prompt's own event may be missing from an empty snapshot, so the
|
|
174
|
+
// floor is the later of the cursor taken before the question and the
|
|
175
|
+
// newest human prompt the log knows about. Either way the answer is the
|
|
176
|
+
// last assistant message after it, and no earlier turn can be read as
|
|
177
|
+
// this one's answer.
|
|
178
|
+
const projected = answerAfter(read.events, Math.max(floor, lastPromptSeq(read.events)));
|
|
179
|
+
return Object.freeze({
|
|
180
|
+
chatId,
|
|
181
|
+
answer: projected.answer,
|
|
182
|
+
sources: sourcesOf(chatId),
|
|
183
|
+
interrupted: projected.interrupted,
|
|
184
|
+
});
|
|
185
|
+
},
|
|
186
|
+
async transcript(input) {
|
|
187
|
+
return await transcripts.page(input.chatId, {
|
|
188
|
+
after: input.after,
|
|
189
|
+
limit: input.limit,
|
|
190
|
+
});
|
|
191
|
+
},
|
|
192
|
+
async models() {
|
|
193
|
+
const catalog = await ctx.sessionController.modelCatalog();
|
|
194
|
+
return catalog.groups.flatMap((group) => group.models.map((model) => model.id));
|
|
195
|
+
},
|
|
196
|
+
};
|
|
197
|
+
/**
|
|
198
|
+
* The evidence of the chat's latest turn, for the bridge's citations. The
|
|
199
|
+
* provenance store materializes one bundle per turn; the newest is the one
|
|
200
|
+
* that belongs to the answer just written. A deployment that switched
|
|
201
|
+
* source collection off answers with an empty list rather than no answer.
|
|
202
|
+
*/
|
|
203
|
+
function sourcesOf(sessionId) {
|
|
204
|
+
try {
|
|
205
|
+
const bundles = options.provenance.bundles(sessionId);
|
|
206
|
+
return bundles.at(-1)?.sources ?? [];
|
|
207
|
+
}
|
|
208
|
+
catch (error) {
|
|
209
|
+
logger.warn("integration.sources-unavailable", {
|
|
210
|
+
sessionId,
|
|
211
|
+
message: error instanceof Error ? error.message : String(error),
|
|
212
|
+
});
|
|
213
|
+
return [];
|
|
214
|
+
}
|
|
215
|
+
}
|
|
216
|
+
}
|
|
217
|
+
/**
|
|
218
|
+
* The one line that explains the text parts after the question. It says where
|
|
219
|
+
* the material came from and nothing about what to do with it: the deployment's
|
|
220
|
+
* own prompt notes own the instructions, and this block is content.
|
|
221
|
+
*/
|
|
222
|
+
const QA_ATTACHMENT_LEAD = "Вложения к этому вопросу — файлы, пришедшие из внешней системы вместе с " +
|
|
223
|
+
"обращением. Их содержимое приведено ниже под заголовком с именем файла.";
|
|
224
|
+
/** Wait for the agent to reach quiescence, or for the caller to give up. */
|
|
225
|
+
async function whenIdleOrAborted(agent, signal) {
|
|
226
|
+
if (signal.aborted)
|
|
227
|
+
throw abortReason(signal);
|
|
228
|
+
await Promise.race([
|
|
229
|
+
agent.whenIdle(),
|
|
230
|
+
new Promise((_resolve, reject) => {
|
|
231
|
+
signal.addEventListener("abort", () => {
|
|
232
|
+
reject(abortReason(signal));
|
|
233
|
+
}, { once: true });
|
|
234
|
+
}),
|
|
235
|
+
]);
|
|
236
|
+
}
|
|
237
|
+
function abortReason(signal) {
|
|
238
|
+
const reason = signal.reason;
|
|
239
|
+
return reason instanceof Error
|
|
240
|
+
? reason
|
|
241
|
+
: new Error("the integration request was aborted");
|
|
242
|
+
}
|
|
243
|
+
//# sourceMappingURL=host-runner.js.map
|