pi-learning-agent 0.1.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 +19 -0
- package/CONTRIBUTING.md +34 -0
- package/LICENSE +21 -0
- package/README.md +154 -0
- package/SECURITY.md +9 -0
- package/docs/architecture.md +75 -0
- package/docs/images/learning-workspace.png +0 -0
- package/docs/releasing.md +51 -0
- package/extension/commands.ts +181 -0
- package/extension/index.ts +50 -0
- package/extension/learning-session.ts +75 -0
- package/extension/runner/code-runner.ts +275 -0
- package/extension/server/interaction-broker.ts +345 -0
- package/extension/server/learning-server.ts +753 -0
- package/extension/server/sse-hub.ts +63 -0
- package/extension/state/learning-state.ts +458 -0
- package/extension/state/session-persistence.ts +72 -0
- package/extension/tools/ask-code.ts +97 -0
- package/extension/tools/ask-free-response.ts +88 -0
- package/extension/tools/ask-multi-choice.ts +109 -0
- package/extension/tools/ask-record-attempt.ts +50 -0
- package/extension/tools/ask-single-choice.ts +110 -0
- package/extension/tools/index.ts +39 -0
- package/extension/tools/tui-presenter.ts +609 -0
- package/extension/transcript-sync.ts +197 -0
- package/extension/tutor-prompt.ts +64 -0
- package/extension/utils/browser.ts +35 -0
- package/extension/utils/ids.ts +5 -0
- package/extension/utils/validation.ts +138 -0
- package/package.json +82 -0
- package/shared/learning-state.ts +62 -0
- package/shared/protocol.ts +236 -0
- package/web/dist/assets/KaTeX_AMS-Regular-BQhdFMY1.woff2 +0 -0
- package/web/dist/assets/KaTeX_AMS-Regular-DMm9YOAa.woff +0 -0
- package/web/dist/assets/KaTeX_AMS-Regular-DRggAlZN.ttf +0 -0
- package/web/dist/assets/KaTeX_Caligraphic-Bold-ATXxdsX0.ttf +0 -0
- package/web/dist/assets/KaTeX_Caligraphic-Bold-BEiXGLvX.woff +0 -0
- package/web/dist/assets/KaTeX_Caligraphic-Bold-Dq_IR9rO.woff2 +0 -0
- package/web/dist/assets/KaTeX_Caligraphic-Regular-CTRA-rTL.woff +0 -0
- package/web/dist/assets/KaTeX_Caligraphic-Regular-Di6jR-x-.woff2 +0 -0
- package/web/dist/assets/KaTeX_Caligraphic-Regular-wX97UBjC.ttf +0 -0
- package/web/dist/assets/KaTeX_Fraktur-Bold-BdnERNNW.ttf +0 -0
- package/web/dist/assets/KaTeX_Fraktur-Bold-BsDP51OF.woff +0 -0
- package/web/dist/assets/KaTeX_Fraktur-Bold-CL6g_b3V.woff2 +0 -0
- package/web/dist/assets/KaTeX_Fraktur-Regular-CB_wures.ttf +0 -0
- package/web/dist/assets/KaTeX_Fraktur-Regular-CTYiF6lA.woff2 +0 -0
- package/web/dist/assets/KaTeX_Fraktur-Regular-Dxdc4cR9.woff +0 -0
- package/web/dist/assets/KaTeX_Main-Bold-Cx986IdX.woff2 +0 -0
- package/web/dist/assets/KaTeX_Main-Bold-Jm3AIy58.woff +0 -0
- package/web/dist/assets/KaTeX_Main-Bold-waoOVXN0.ttf +0 -0
- package/web/dist/assets/KaTeX_Main-BoldItalic-DxDJ3AOS.woff2 +0 -0
- package/web/dist/assets/KaTeX_Main-BoldItalic-DzxPMmG6.ttf +0 -0
- package/web/dist/assets/KaTeX_Main-BoldItalic-SpSLRI95.woff +0 -0
- package/web/dist/assets/KaTeX_Main-Italic-3WenGoN9.ttf +0 -0
- package/web/dist/assets/KaTeX_Main-Italic-BMLOBm91.woff +0 -0
- package/web/dist/assets/KaTeX_Main-Italic-NWA7e6Wa.woff2 +0 -0
- package/web/dist/assets/KaTeX_Main-Regular-B22Nviop.woff2 +0 -0
- package/web/dist/assets/KaTeX_Main-Regular-Dr94JaBh.woff +0 -0
- package/web/dist/assets/KaTeX_Main-Regular-ypZvNtVU.ttf +0 -0
- package/web/dist/assets/KaTeX_Math-BoldItalic-B3XSjfu4.ttf +0 -0
- package/web/dist/assets/KaTeX_Math-BoldItalic-CZnvNsCZ.woff2 +0 -0
- package/web/dist/assets/KaTeX_Math-BoldItalic-iY-2wyZ7.woff +0 -0
- package/web/dist/assets/KaTeX_Math-Italic-DA0__PXp.woff +0 -0
- package/web/dist/assets/KaTeX_Math-Italic-flOr_0UB.ttf +0 -0
- package/web/dist/assets/KaTeX_Math-Italic-t53AETM-.woff2 +0 -0
- package/web/dist/assets/KaTeX_SansSerif-Bold-CFMepnvq.ttf +0 -0
- package/web/dist/assets/KaTeX_SansSerif-Bold-D1sUS0GD.woff2 +0 -0
- package/web/dist/assets/KaTeX_SansSerif-Bold-DbIhKOiC.woff +0 -0
- package/web/dist/assets/KaTeX_SansSerif-Italic-C3H0VqGB.woff2 +0 -0
- package/web/dist/assets/KaTeX_SansSerif-Italic-DN2j7dab.woff +0 -0
- package/web/dist/assets/KaTeX_SansSerif-Italic-YYjJ1zSn.ttf +0 -0
- package/web/dist/assets/KaTeX_SansSerif-Regular-BNo7hRIc.ttf +0 -0
- package/web/dist/assets/KaTeX_SansSerif-Regular-CS6fqUqJ.woff +0 -0
- package/web/dist/assets/KaTeX_SansSerif-Regular-DDBCnlJ7.woff2 +0 -0
- package/web/dist/assets/KaTeX_Script-Regular-C5JkGWo-.ttf +0 -0
- package/web/dist/assets/KaTeX_Script-Regular-D3wIWfF6.woff2 +0 -0
- package/web/dist/assets/KaTeX_Script-Regular-D5yQViql.woff +0 -0
- package/web/dist/assets/KaTeX_Size1-Regular-C195tn64.woff +0 -0
- package/web/dist/assets/KaTeX_Size1-Regular-Dbsnue_I.ttf +0 -0
- package/web/dist/assets/KaTeX_Size1-Regular-mCD8mA8B.woff2 +0 -0
- package/web/dist/assets/KaTeX_Size2-Regular-B7gKUWhC.ttf +0 -0
- package/web/dist/assets/KaTeX_Size2-Regular-Dy4dx90m.woff2 +0 -0
- package/web/dist/assets/KaTeX_Size2-Regular-oD1tc_U0.woff +0 -0
- package/web/dist/assets/KaTeX_Size3-Regular-CTq5MqoE.woff +0 -0
- package/web/dist/assets/KaTeX_Size3-Regular-DgpXs0kz.ttf +0 -0
- package/web/dist/assets/KaTeX_Size4-Regular-BF-4gkZK.woff +0 -0
- package/web/dist/assets/KaTeX_Size4-Regular-DWFBv043.ttf +0 -0
- package/web/dist/assets/KaTeX_Size4-Regular-Dl5lxZxV.woff2 +0 -0
- package/web/dist/assets/KaTeX_Typewriter-Regular-C0xS9mPB.woff +0 -0
- package/web/dist/assets/KaTeX_Typewriter-Regular-CO6r4hn1.woff2 +0 -0
- package/web/dist/assets/KaTeX_Typewriter-Regular-D3Ib7_Hf.ttf +0 -0
- package/web/dist/assets/abap-08VXUWAP.js +1 -0
- package/web/dist/assets/apex-BWPQTe0t.js +1 -0
- package/web/dist/assets/azcli-Bc_sGQ0U.js +1 -0
- package/web/dist/assets/bat-i0X4ZdIN.js +1 -0
- package/web/dist/assets/bicep-B5-_aFwp.js +2 -0
- package/web/dist/assets/cameligo-DMUM7wLl.js +1 -0
- package/web/dist/assets/clojure-Cm7r79vr.js +1 -0
- package/web/dist/assets/codicon-Brq4_Ui5.ttf +0 -0
- package/web/dist/assets/coffee-Ba7i2nA0.js +1 -0
- package/web/dist/assets/cpp-C7h46wYY.js +1 -0
- package/web/dist/assets/csharp-BKxtCVv1.js +1 -0
- package/web/dist/assets/csp-bTuwJoIa.js +1 -0
- package/web/dist/assets/css-DIMkf-bt.js +3 -0
- package/web/dist/assets/css.worker-DRH2OcAG.js +93 -0
- package/web/dist/assets/cssMode-BpvqYhpN.js +1 -0
- package/web/dist/assets/cypher-CVaqCwHa.js +1 -0
- package/web/dist/assets/dart-onAF5SnQ.js +1 -0
- package/web/dist/assets/dockerfile-DZFCIeNp.js +1 -0
- package/web/dist/assets/ecl-D05T4iGw.js +1 -0
- package/web/dist/assets/editor.worker-D9zwrD0f.js +30 -0
- package/web/dist/assets/elixir-6RTg0lbw.js +1 -0
- package/web/dist/assets/flow9-C5_-GSwl.js +1 -0
- package/web/dist/assets/freemarker2-CmTivYCM.js +3 -0
- package/web/dist/assets/fsharp-C8Ef5oNN.js +1 -0
- package/web/dist/assets/go-C-y9NEjX.js +1 -0
- package/web/dist/assets/graphql-fmXr3nnJ.js +1 -0
- package/web/dist/assets/handlebars-ClhpojlY.js +1 -0
- package/web/dist/assets/hcl-CpzslTdj.js +1 -0
- package/web/dist/assets/html-CTqIxFH7.js +1 -0
- package/web/dist/assets/html.worker-DQLddT9h.js +506 -0
- package/web/dist/assets/htmlMode-BKCTQ4_a.js +1 -0
- package/web/dist/assets/index-6N_lnfyE.js +1243 -0
- package/web/dist/assets/index-B5x0MzHm.css +1 -0
- package/web/dist/assets/ini-sBoK_t0W.js +1 -0
- package/web/dist/assets/java-BEtHBSE6.js +1 -0
- package/web/dist/assets/javascript-Cp_q6aWP.js +1 -0
- package/web/dist/assets/json.worker-D_Dqrls5.js +62 -0
- package/web/dist/assets/jsonMode-CMDRrSMX.js +7 -0
- package/web/dist/assets/julia-Bri6UV-V.js +1 -0
- package/web/dist/assets/kotlin-BOotOW0E.js +1 -0
- package/web/dist/assets/less-B9JPFI3C.js +2 -0
- package/web/dist/assets/lexon-CfSJPG6W.js +1 -0
- package/web/dist/assets/liquid-BFK8j46k.js +1 -0
- package/web/dist/assets/lspLanguageFeatures-nT2b0EDJ.js +4 -0
- package/web/dist/assets/lua-CsQS60Ue.js +1 -0
- package/web/dist/assets/m3-D-oSqn_W.js +1 -0
- package/web/dist/assets/markdown-Cimd5fb3.js +1 -0
- package/web/dist/assets/mdx-cEJtnt-p.js +1 -0
- package/web/dist/assets/mips-CIPQ_RoX.js +1 -0
- package/web/dist/assets/msdax-DauUninz.js +1 -0
- package/web/dist/assets/mysql-SOo6toE5.js +1 -0
- package/web/dist/assets/objective-c-FvmIjYaQ.js +1 -0
- package/web/dist/assets/pascal-DrH0SRf2.js +1 -0
- package/web/dist/assets/pascaligo-D-ptJ9y-.js +1 -0
- package/web/dist/assets/perl-oz_6vUea.js +1 -0
- package/web/dist/assets/pgsql-DTj74zXo.js +1 -0
- package/web/dist/assets/php-nr791fC2.js +1 -0
- package/web/dist/assets/pla-CopQ2nXW.js +1 -0
- package/web/dist/assets/postiats-43DmfD33.js +1 -0
- package/web/dist/assets/powerquery-D3hlyOfw.js +1 -0
- package/web/dist/assets/powershell-DmHpPYUd.js +1 -0
- package/web/dist/assets/protobuf-C531GsRP.js +2 -0
- package/web/dist/assets/pug-Z5eAx3Zn.js +1 -0
- package/web/dist/assets/python-bhI_n2_n.js +1 -0
- package/web/dist/assets/qsharp-DkqhCAOL.js +1 -0
- package/web/dist/assets/r-BwWrilGY.js +1 -0
- package/web/dist/assets/razor-D40-I8aA.js +1 -0
- package/web/dist/assets/redis-ClamHrr6.js +1 -0
- package/web/dist/assets/redshift-DT7zqm-g.js +1 -0
- package/web/dist/assets/restructuredtext-BYgofb2h.js +1 -0
- package/web/dist/assets/ruby-DezsRK8O.js +1 -0
- package/web/dist/assets/rust-DdL9SqIa.js +1 -0
- package/web/dist/assets/sb-CcwsVR0C.js +1 -0
- package/web/dist/assets/scala-DHpiXF5c.js +1 -0
- package/web/dist/assets/scheme-BeGwcela.js +1 -0
- package/web/dist/assets/scss-gp-XZpBa.js +3 -0
- package/web/dist/assets/shell-CC2rA5mh.js +1 -0
- package/web/dist/assets/solidity-BEEn4gHE.js +1 -0
- package/web/dist/assets/sophia-CRfGWb83.js +1 -0
- package/web/dist/assets/sparql-D_Lu-MrJ.js +1 -0
- package/web/dist/assets/sql-NEE52Syq.js +1 -0
- package/web/dist/assets/st-DbInun42.js +1 -0
- package/web/dist/assets/swift-Bxkupp3x.js +1 -0
- package/web/dist/assets/systemverilog-Bz4Y3fRF.js +1 -0
- package/web/dist/assets/tcl-DISqw1ZD.js +1 -0
- package/web/dist/assets/ts.worker-DkYoA6bF.js +67738 -0
- package/web/dist/assets/tsMode-C9OJ2Aji.js +11 -0
- package/web/dist/assets/twig-De2hgUGE.js +1 -0
- package/web/dist/assets/typescript-CZ5RbSdB.js +1 -0
- package/web/dist/assets/typespec-B8J7ngcE.js +1 -0
- package/web/dist/assets/vb-DV3o63ZY.js +1 -0
- package/web/dist/assets/wgsl-DpFanUEy.js +298 -0
- package/web/dist/assets/workers-qb_EZhxe.js +1 -0
- package/web/dist/assets/xml-BdDnAXLl.js +1 -0
- package/web/dist/assets/yaml-BSYHoVQy.js +1 -0
- package/web/dist/index.html +23 -0
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
import type { InteractionBroker } from "./server/interaction-broker.js";
|
|
2
|
+
import { INITIAL_MASTERY } from "./state/learning-state.js";
|
|
3
|
+
import type { LearningStateStore, RecordAttemptInput, StartLearningInput } from "./state/learning-state.js";
|
|
4
|
+
|
|
5
|
+
export interface AttemptEvaluation extends Omit<RecordAttemptInput, "misconception"> {
|
|
6
|
+
previousMastery: number;
|
|
7
|
+
newMastery: number;
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
/** 学习会话拥有评分资格、掌握度与交互生命周期的协调规则。 */
|
|
11
|
+
export class LearningSession {
|
|
12
|
+
readonly state: LearningStateStore;
|
|
13
|
+
readonly broker: InteractionBroker;
|
|
14
|
+
|
|
15
|
+
constructor(state: LearningStateStore, broker: InteractionBroker) {
|
|
16
|
+
this.state = state;
|
|
17
|
+
this.broker = broker;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
start(target: StartLearningInput): void {
|
|
21
|
+
if (!this.isCurrentTarget(target)) {
|
|
22
|
+
this.broker.reset("learning_target_changed");
|
|
23
|
+
}
|
|
24
|
+
this.state.start(target);
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
isCurrentTarget(target: StartLearningInput): boolean {
|
|
28
|
+
const current = this.state.snapshot();
|
|
29
|
+
return current.course?.id === target.course.id && current.topic?.id === target.topic.id;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
stop(): void {
|
|
33
|
+
this.broker.cancelAll("learning_stopped");
|
|
34
|
+
this.state.stop();
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
resetTopic(): void {
|
|
38
|
+
this.broker.reset("learning_reset");
|
|
39
|
+
this.state.resetTopic();
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
resetForSession(): void {
|
|
43
|
+
this.broker.reset("session_changed");
|
|
44
|
+
this.state.resetForSession();
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
recordAttempt(input: RecordAttemptInput): AttemptEvaluation {
|
|
48
|
+
const completion = this.broker.getCompletion(input.interactionId);
|
|
49
|
+
if (completion === undefined) {
|
|
50
|
+
throw new Error(`Learning interaction ${input.interactionId} has not been resolved.`);
|
|
51
|
+
}
|
|
52
|
+
if (completion.skipped) {
|
|
53
|
+
throw new Error(`Learning interaction ${input.interactionId} was skipped.`);
|
|
54
|
+
}
|
|
55
|
+
const expectedEvidence = completion.type === "single_choice" || completion.type === "multi_choice"
|
|
56
|
+
? "choice"
|
|
57
|
+
: completion.type;
|
|
58
|
+
if (input.evidenceType !== expectedEvidence) {
|
|
59
|
+
throw new Error(
|
|
60
|
+
`Evidence type ${input.evidenceType} does not match interaction ${input.interactionId} (${completion.type}); expected ${expectedEvidence}.`
|
|
61
|
+
);
|
|
62
|
+
}
|
|
63
|
+
const previousMastery = this.state.snapshot().concepts[input.conceptId]?.mastery ?? INITIAL_MASTERY;
|
|
64
|
+
const summary = this.state.recordAttempt(input);
|
|
65
|
+
const newMastery = this.state.snapshot().concepts[input.conceptId]?.mastery ?? previousMastery;
|
|
66
|
+
return {
|
|
67
|
+
interactionId: summary.interactionId,
|
|
68
|
+
conceptId: summary.conceptId,
|
|
69
|
+
outcome: summary.outcome,
|
|
70
|
+
evidenceType: summary.evidenceType,
|
|
71
|
+
previousMastery,
|
|
72
|
+
newMastery
|
|
73
|
+
};
|
|
74
|
+
}
|
|
75
|
+
}
|
|
@@ -0,0 +1,275 @@
|
|
|
1
|
+
// 可注入的本地代码 runner(规格 25);不构成主机隔离,正式扩展默认禁用。
|
|
2
|
+
//
|
|
3
|
+
// 安全边界(每条都是硬约束,不是约定):
|
|
4
|
+
// - 语言白名单由程序定义(SUPPORTED_LANGUAGES),学习者只能选语言,不能指定
|
|
5
|
+
// executable / 参数;
|
|
6
|
+
// - 源码写入临时目录(os.tmpdir() 下 pi-learn-*)的固定文件名 main.<ext>,
|
|
7
|
+
// 文件名不含任何用户输入(防注入);
|
|
8
|
+
// - cwd 固定为该临时目录,不允许任意 cwd;
|
|
9
|
+
// - 子进程 env 是显式白名单 MINIMAL_ENV,不继承完整 process.env,天然满足
|
|
10
|
+
// “不把 API key/敏感 env 传入子进程”;
|
|
11
|
+
// - stdout/stderr 各上限 64KB,超出截断(truncated=true)但继续消费防死锁;
|
|
12
|
+
// - 超时(默认 8s)kill;结束(含超时/报错/abort)后 finally 清理临时目录。
|
|
13
|
+
//
|
|
14
|
+
// 已知限制(MVP,规格 25 要求更成熟版本放 Docker/沙箱):
|
|
15
|
+
// - Windows 上 child.kill() 只能终止直接子进程,python 可能留下孤儿进程;
|
|
16
|
+
// 升级路径:taskkill /T /F 按进程树杀,或整体移入容器;
|
|
17
|
+
// - 无 CPU/内存资源限制:恶意代码可以忙等耗尽本机资源。
|
|
18
|
+
|
|
19
|
+
import { spawn } from "node:child_process";
|
|
20
|
+
import type { ChildProcess } from "node:child_process";
|
|
21
|
+
import { mkdtemp, rm, writeFile } from "node:fs/promises";
|
|
22
|
+
import { tmpdir } from "node:os";
|
|
23
|
+
import path from "node:path";
|
|
24
|
+
import { StringDecoder } from "node:string_decoder";
|
|
25
|
+
|
|
26
|
+
import type { CodeRunRequest, CodeRunResult } from "../../shared/protocol.js";
|
|
27
|
+
|
|
28
|
+
export const DEFAULT_TIMEOUT_MS = 8000;
|
|
29
|
+
export const MAX_OUTPUT_BYTES = 64 * 1024;
|
|
30
|
+
|
|
31
|
+
/** 超时 kill 后等待子进程退出的宽限时间;超过则直接返回 timedOut,避免请求挂起。 */
|
|
32
|
+
const KILL_GRACE_MS = 1000;
|
|
33
|
+
|
|
34
|
+
export interface LanguageConfig {
|
|
35
|
+
/** 要 spawn 的 executable;由程序定义,学习者不可控。 */
|
|
36
|
+
command: string;
|
|
37
|
+
/** 临时目录内源码文件的扩展名。 */
|
|
38
|
+
extension: string;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
/** 固定允许语言表(规格 25:固定允许语言、命令模板程序定义)。 */
|
|
42
|
+
export const SUPPORTED_LANGUAGES: Readonly<Record<string, LanguageConfig>> = {
|
|
43
|
+
python: { command: "python", extension: ".py" },
|
|
44
|
+
node: { command: "node", extension: ".js" }
|
|
45
|
+
};
|
|
46
|
+
|
|
47
|
+
export function isSupportedLanguage(language: string): boolean {
|
|
48
|
+
return Object.prototype.hasOwnProperty.call(SUPPORTED_LANGUAGES, language);
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
/**
|
|
52
|
+
* 子进程环境白名单。Windows 上 spawn 需要 PATH 与系统目录才能定位解释器
|
|
53
|
+
* (SystemRoot/WINDIR/COMSPEC);TEMP/TMP 给子进程一个可写的临时位置。
|
|
54
|
+
* 明确不继承完整 process.env:API key、token 等敏感变量不会传入子进程。
|
|
55
|
+
*/
|
|
56
|
+
const MINIMAL_ENV: Readonly<Record<string, string>> = {
|
|
57
|
+
PATH: process.env.PATH ?? "",
|
|
58
|
+
SystemRoot: process.env.SystemRoot ?? "",
|
|
59
|
+
WINDIR: process.env.WINDIR ?? "",
|
|
60
|
+
TEMP: process.env.TEMP ?? "",
|
|
61
|
+
TMP: process.env.TMP ?? "",
|
|
62
|
+
COMSPEC: process.env.COMSPEC ?? ""
|
|
63
|
+
};
|
|
64
|
+
|
|
65
|
+
/** 可注入的测试配置;临时目录与环境白名单不限制代码访问宿主文件系统。 */
|
|
66
|
+
export interface LocalCodeRunnerOptions {
|
|
67
|
+
/** 临时运行目录的父目录。默认 os.tmpdir();可注入便于测试断言清理。 */
|
|
68
|
+
tmpRoot?: string;
|
|
69
|
+
/** 语言表覆盖(测试 seam,合并到内置白名单之上)。 */
|
|
70
|
+
languages?: Record<string, LanguageConfig>;
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
export interface CodeRunner {
|
|
74
|
+
run(request: CodeRunRequest, signal?: AbortSignal): Promise<CodeRunResult>;
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
/** 运行时缺失(spawn ENOENT)时的专用错误,server 映射为 503 runtime_unavailable。 */
|
|
78
|
+
export class CodeRunUnavailableError extends Error {
|
|
79
|
+
readonly language: string;
|
|
80
|
+
|
|
81
|
+
constructor(language: string) {
|
|
82
|
+
super(`Runtime for "${language}" is not available on this machine.`);
|
|
83
|
+
this.name = "CodeRunUnavailableError";
|
|
84
|
+
this.language = language;
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
/** 按 64KB 上限截断收集一个输出流;截断后仍持续消费(防背压死锁)。 */
|
|
89
|
+
class OutputCollector {
|
|
90
|
+
text = "";
|
|
91
|
+
truncated = false;
|
|
92
|
+
private readonly decoder = new StringDecoder("utf8");
|
|
93
|
+
private remaining = MAX_OUTPUT_BYTES;
|
|
94
|
+
|
|
95
|
+
push(chunk: Buffer): void {
|
|
96
|
+
if (this.truncated) {
|
|
97
|
+
return; // 仍在消费(防死锁),但不再缓冲
|
|
98
|
+
}
|
|
99
|
+
if (chunk.length > this.remaining) {
|
|
100
|
+
this.truncated = true;
|
|
101
|
+
}
|
|
102
|
+
const keep = Math.min(chunk.length, this.remaining);
|
|
103
|
+
if (keep > 0) {
|
|
104
|
+
this.text += this.decoder.write(chunk.subarray(0, keep));
|
|
105
|
+
}
|
|
106
|
+
this.remaining -= chunk.length;
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
end(): void {
|
|
110
|
+
this.text += this.decoder.end();
|
|
111
|
+
}
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
export class LocalCodeRunner implements CodeRunner {
|
|
115
|
+
private readonly languages: Readonly<Record<string, LanguageConfig | undefined>>;
|
|
116
|
+
private readonly tmpRoot: string;
|
|
117
|
+
|
|
118
|
+
constructor(options: LocalCodeRunnerOptions = {}) {
|
|
119
|
+
this.languages = { ...SUPPORTED_LANGUAGES, ...options.languages };
|
|
120
|
+
this.tmpRoot = options.tmpRoot ?? tmpdir();
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
async run(request: CodeRunRequest, signal?: AbortSignal): Promise<CodeRunResult> {
|
|
124
|
+
const config = Object.prototype.hasOwnProperty.call(this.languages, request.language)
|
|
125
|
+
? this.languages[request.language]
|
|
126
|
+
: undefined;
|
|
127
|
+
if (config === undefined) {
|
|
128
|
+
throw new Error(`Unsupported language: ${request.language}.`);
|
|
129
|
+
}
|
|
130
|
+
const startedAt = Date.now();
|
|
131
|
+
const tmpDir = await mkdtemp(path.join(this.tmpRoot, "pi-learn-"));
|
|
132
|
+
try {
|
|
133
|
+
const filePath = path.join(tmpDir, `main${config.extension}`);
|
|
134
|
+
await writeFile(filePath, request.code, "utf8");
|
|
135
|
+
return await this.runProcess(
|
|
136
|
+
config.command,
|
|
137
|
+
filePath,
|
|
138
|
+
request.language,
|
|
139
|
+
tmpDir,
|
|
140
|
+
request.timeoutMs ?? DEFAULT_TIMEOUT_MS,
|
|
141
|
+
signal,
|
|
142
|
+
startedAt
|
|
143
|
+
);
|
|
144
|
+
} finally {
|
|
145
|
+
// 超时/报错/abort 路径也要清理;rm 失败(Windows 上孤儿进程占用文件)
|
|
146
|
+
// 只记录,不掩盖原始错误。
|
|
147
|
+
await rm(tmpDir, { recursive: true, force: true, maxRetries: 5, retryDelay: 50 }).catch((error: unknown) => {
|
|
148
|
+
console.warn("[pi-learning] failed to clean up run dir:", tmpDir, error);
|
|
149
|
+
});
|
|
150
|
+
}
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
private runProcess(
|
|
154
|
+
command: string,
|
|
155
|
+
filePath: string,
|
|
156
|
+
language: string,
|
|
157
|
+
cwd: string,
|
|
158
|
+
timeoutMs: number,
|
|
159
|
+
signal: AbortSignal | undefined,
|
|
160
|
+
startedAt: number
|
|
161
|
+
): Promise<CodeRunResult> {
|
|
162
|
+
return new Promise<CodeRunResult>((resolve, reject) => {
|
|
163
|
+
let child: ChildProcess;
|
|
164
|
+
try {
|
|
165
|
+
child = spawn(command, [filePath], {
|
|
166
|
+
cwd,
|
|
167
|
+
env: MINIMAL_ENV,
|
|
168
|
+
stdio: ["ignore", "pipe", "pipe"],
|
|
169
|
+
windowsHide: true
|
|
170
|
+
});
|
|
171
|
+
} catch (error) {
|
|
172
|
+
reject(error);
|
|
173
|
+
return;
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
let settled = false;
|
|
177
|
+
let timedOut = false;
|
|
178
|
+
let aborted = false;
|
|
179
|
+
let timer: NodeJS.Timeout | undefined;
|
|
180
|
+
let graceTimer: NodeJS.Timeout | undefined;
|
|
181
|
+
|
|
182
|
+
const finish = (result: CodeRunResult): void => {
|
|
183
|
+
if (settled) {
|
|
184
|
+
return;
|
|
185
|
+
}
|
|
186
|
+
settled = true;
|
|
187
|
+
clearTimeout(timer);
|
|
188
|
+
clearTimeout(graceTimer);
|
|
189
|
+
signal?.removeEventListener("abort", onAbort);
|
|
190
|
+
resolve(result);
|
|
191
|
+
};
|
|
192
|
+
|
|
193
|
+
const fail = (error: unknown): void => {
|
|
194
|
+
if (settled) {
|
|
195
|
+
return;
|
|
196
|
+
}
|
|
197
|
+
settled = true;
|
|
198
|
+
clearTimeout(timer);
|
|
199
|
+
clearTimeout(graceTimer);
|
|
200
|
+
signal?.removeEventListener("abort", onAbort);
|
|
201
|
+
reject(error);
|
|
202
|
+
};
|
|
203
|
+
|
|
204
|
+
// abort 是调用方取消 → reject;超时是正常结果 → timedOut=true。二者语义不同。
|
|
205
|
+
const onAbort = (): void => {
|
|
206
|
+
aborted = true;
|
|
207
|
+
clearTimeout(timer);
|
|
208
|
+
clearTimeout(graceTimer);
|
|
209
|
+
child.kill();
|
|
210
|
+
// 等待 close 释放文件和工作目录;仍保留有界退出时间。
|
|
211
|
+
graceTimer = setTimeout(() => fail(new Error("Code run aborted.")), KILL_GRACE_MS);
|
|
212
|
+
};
|
|
213
|
+
|
|
214
|
+
const onTimeout = (): void => {
|
|
215
|
+
timedOut = true;
|
|
216
|
+
child.kill();
|
|
217
|
+
// Windows 上 kill 可能杀不掉(python 留孤儿);最多再等 KILL_GRACE_MS,
|
|
218
|
+
// 之后直接 resolve timedOut,避免请求挂起。kill 后若子进程很快退出,
|
|
219
|
+
// close 分支会带着真实 exitCode 先 settle。
|
|
220
|
+
graceTimer = setTimeout(() => {
|
|
221
|
+
finish({
|
|
222
|
+
exitCode: null,
|
|
223
|
+
stdout: stdout.text,
|
|
224
|
+
stderr: stderr.text,
|
|
225
|
+
timedOut: true,
|
|
226
|
+
durationMs: Date.now() - startedAt,
|
|
227
|
+
truncated: stdout.truncated || stderr.truncated
|
|
228
|
+
});
|
|
229
|
+
}, KILL_GRACE_MS);
|
|
230
|
+
};
|
|
231
|
+
|
|
232
|
+
const stdout = new OutputCollector();
|
|
233
|
+
const stderr = new OutputCollector();
|
|
234
|
+
child.stdout?.on("data", (chunk: Buffer) => stdout.push(chunk));
|
|
235
|
+
child.stdout?.on("error", fail);
|
|
236
|
+
child.stderr?.on("data", (chunk: Buffer) => stderr.push(chunk));
|
|
237
|
+
child.stderr?.on("error", fail);
|
|
238
|
+
|
|
239
|
+
child.once("error", (error: NodeJS.ErrnoException) => {
|
|
240
|
+
if (error.code === "ENOENT") {
|
|
241
|
+
fail(new CodeRunUnavailableError(language));
|
|
242
|
+
return;
|
|
243
|
+
}
|
|
244
|
+
fail(error);
|
|
245
|
+
});
|
|
246
|
+
|
|
247
|
+
// close 在 stdio 全部关闭后触发,比 exit 可靠(exit 可能先于 stdout 结束)。
|
|
248
|
+
child.once("close", (exitCode) => {
|
|
249
|
+
stdout.end();
|
|
250
|
+
stderr.end();
|
|
251
|
+
if (aborted) {
|
|
252
|
+
fail(new Error("Code run aborted."));
|
|
253
|
+
return;
|
|
254
|
+
}
|
|
255
|
+
finish({
|
|
256
|
+
exitCode: exitCode ?? null,
|
|
257
|
+
stdout: stdout.text,
|
|
258
|
+
stderr: stderr.text,
|
|
259
|
+
timedOut,
|
|
260
|
+
durationMs: Date.now() - startedAt,
|
|
261
|
+
truncated: stdout.truncated || stderr.truncated
|
|
262
|
+
});
|
|
263
|
+
});
|
|
264
|
+
|
|
265
|
+
timer = setTimeout(onTimeout, timeoutMs);
|
|
266
|
+
if (signal !== undefined) {
|
|
267
|
+
if (signal.aborted) {
|
|
268
|
+
onAbort();
|
|
269
|
+
return;
|
|
270
|
+
}
|
|
271
|
+
signal.addEventListener("abort", onAbort, { once: true });
|
|
272
|
+
}
|
|
273
|
+
});
|
|
274
|
+
}
|
|
275
|
+
}
|
|
@@ -0,0 +1,345 @@
|
|
|
1
|
+
import type {
|
|
2
|
+
InteractionSubmission,
|
|
3
|
+
LearningInteraction,
|
|
4
|
+
ResolvedAnswer,
|
|
5
|
+
SkippedResolvedAnswer,
|
|
6
|
+
SubmitResult
|
|
7
|
+
} from "../../shared/protocol.js";
|
|
8
|
+
import { validateInteractionAnswer } from "../utils/validation.js";
|
|
9
|
+
|
|
10
|
+
/** Optional observability hooks; default is no-op so existing behavior is unchanged. */
|
|
11
|
+
export interface BrokerListeners {
|
|
12
|
+
onPresented?: (interaction: LearningInteraction) => void;
|
|
13
|
+
onResolved?: (answer: ResolvedAnswer) => void;
|
|
14
|
+
onCancelled?: (interactionId: string, reason: string) => void;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
interface PendingInteraction {
|
|
18
|
+
interaction: LearningInteraction;
|
|
19
|
+
resolve: (answer: ResolvedAnswer) => void;
|
|
20
|
+
reject: (error: Error) => void;
|
|
21
|
+
cleanup: () => void;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
const MAX_RESOLVED_IDS = 1_000;
|
|
25
|
+
|
|
26
|
+
export interface InteractionCompletion {
|
|
27
|
+
type: LearningInteraction["type"];
|
|
28
|
+
skipped: boolean;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
export class InteractionCancelledError extends Error {
|
|
32
|
+
constructor(interactionId: string, reason: string) {
|
|
33
|
+
super(`Interaction ${interactionId} was cancelled: ${reason}.`);
|
|
34
|
+
this.name = "InteractionCancelledError";
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
export class InteractionConflictError extends Error {
|
|
39
|
+
constructor(interactionId: string, state: "pending" | "resolved" = "pending") {
|
|
40
|
+
super(
|
|
41
|
+
state === "pending"
|
|
42
|
+
? `Interaction ${interactionId} is already pending.`
|
|
43
|
+
: `Interaction ${interactionId} has already been resolved.`
|
|
44
|
+
);
|
|
45
|
+
this.name = "InteractionConflictError";
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
export class InteractionBroker {
|
|
50
|
+
private readonly pending = new Map<string, PendingInteraction>();
|
|
51
|
+
private readonly resolvedIds = new Map<string, InteractionCompletion>();
|
|
52
|
+
private listeners: BrokerListeners;
|
|
53
|
+
|
|
54
|
+
constructor(listeners: BrokerListeners = {}) {
|
|
55
|
+
this.listeners = listeners;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
/**
|
|
59
|
+
* Attach additional listeners (used by LearningServer after construction).
|
|
60
|
+
* Same-key listeners compose: an earlier onPresented keeps firing when a
|
|
61
|
+
* later one is added, so the SSE broadcast and the phase migration hook can
|
|
62
|
+
* coexist on one broker.
|
|
63
|
+
*/
|
|
64
|
+
subscribe(listeners: BrokerListeners): void {
|
|
65
|
+
const onPresented = compose(
|
|
66
|
+
this.listeners.onPresented,
|
|
67
|
+
listeners.onPresented
|
|
68
|
+
);
|
|
69
|
+
const onResolved = compose(
|
|
70
|
+
this.listeners.onResolved,
|
|
71
|
+
listeners.onResolved
|
|
72
|
+
);
|
|
73
|
+
const onCancelled = compose(
|
|
74
|
+
this.listeners.onCancelled,
|
|
75
|
+
listeners.onCancelled
|
|
76
|
+
);
|
|
77
|
+
this.listeners = {
|
|
78
|
+
...(onPresented === undefined ? {} : { onPresented }),
|
|
79
|
+
...(onResolved === undefined ? {} : { onResolved }),
|
|
80
|
+
...(onCancelled === undefined ? {} : { onCancelled })
|
|
81
|
+
};
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
present(
|
|
85
|
+
interaction: LearningInteraction,
|
|
86
|
+
signal?: AbortSignal
|
|
87
|
+
): Promise<ResolvedAnswer> {
|
|
88
|
+
if (this.pending.has(interaction.id)) {
|
|
89
|
+
return Promise.reject(new InteractionConflictError(interaction.id));
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
if (this.resolvedIds.has(interaction.id)) {
|
|
93
|
+
return Promise.reject(
|
|
94
|
+
new InteractionConflictError(interaction.id, "resolved")
|
|
95
|
+
);
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
if (signal?.aborted) {
|
|
99
|
+
return Promise.reject(
|
|
100
|
+
new InteractionCancelledError(interaction.id, "aborted")
|
|
101
|
+
);
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
return new Promise((resolve, reject) => {
|
|
105
|
+
const onAbort = () => {
|
|
106
|
+
this.rejectPending(
|
|
107
|
+
interaction.id,
|
|
108
|
+
new InteractionCancelledError(interaction.id, "aborted"),
|
|
109
|
+
"aborted"
|
|
110
|
+
);
|
|
111
|
+
};
|
|
112
|
+
const cleanup = () => {
|
|
113
|
+
signal?.removeEventListener("abort", onAbort);
|
|
114
|
+
};
|
|
115
|
+
|
|
116
|
+
this.pending.set(interaction.id, { interaction, resolve, reject, cleanup });
|
|
117
|
+
signal?.addEventListener("abort", onAbort, { once: true });
|
|
118
|
+
notifyListener(() => this.listeners.onPresented?.(interaction));
|
|
119
|
+
});
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
submit(submission: InteractionSubmission): SubmitResult {
|
|
123
|
+
const pending = this.pending.get(submission.interactionId);
|
|
124
|
+
if (!pending) {
|
|
125
|
+
if (this.resolvedIds.has(submission.interactionId)) {
|
|
126
|
+
return {
|
|
127
|
+
ok: false,
|
|
128
|
+
reason: "already_resolved",
|
|
129
|
+
message: `Interaction ${submission.interactionId} has already been resolved.`
|
|
130
|
+
};
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
return {
|
|
134
|
+
ok: false,
|
|
135
|
+
reason: "not_found",
|
|
136
|
+
message: `No pending interaction with id ${submission.interactionId}.`
|
|
137
|
+
};
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
const validation = validateInteractionAnswer(
|
|
141
|
+
pending.interaction,
|
|
142
|
+
submission.answer
|
|
143
|
+
);
|
|
144
|
+
if (!validation.ok) {
|
|
145
|
+
return {
|
|
146
|
+
ok: false,
|
|
147
|
+
reason: "invalid_answer",
|
|
148
|
+
message: validation.message
|
|
149
|
+
};
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
const responseTimeMs = Math.max(
|
|
153
|
+
0,
|
|
154
|
+
Date.now() - pending.interaction.createdAt
|
|
155
|
+
);
|
|
156
|
+
let answer: ResolvedAnswer;
|
|
157
|
+
switch (validation.type) {
|
|
158
|
+
case "single_choice":
|
|
159
|
+
answer = {
|
|
160
|
+
interactionId: pending.interaction.id,
|
|
161
|
+
type: validation.type,
|
|
162
|
+
answer: validation.answer,
|
|
163
|
+
responseTimeMs
|
|
164
|
+
};
|
|
165
|
+
break;
|
|
166
|
+
case "multi_choice":
|
|
167
|
+
answer = {
|
|
168
|
+
interactionId: pending.interaction.id,
|
|
169
|
+
type: validation.type,
|
|
170
|
+
answer: validation.answer,
|
|
171
|
+
responseTimeMs
|
|
172
|
+
};
|
|
173
|
+
break;
|
|
174
|
+
case "free_response":
|
|
175
|
+
answer = {
|
|
176
|
+
interactionId: pending.interaction.id,
|
|
177
|
+
type: validation.type,
|
|
178
|
+
answer: validation.answer,
|
|
179
|
+
responseTimeMs
|
|
180
|
+
};
|
|
181
|
+
break;
|
|
182
|
+
case "code":
|
|
183
|
+
answer = {
|
|
184
|
+
interactionId: pending.interaction.id,
|
|
185
|
+
type: validation.type,
|
|
186
|
+
answer: validation.answer,
|
|
187
|
+
responseTimeMs
|
|
188
|
+
};
|
|
189
|
+
break;
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
this.pending.delete(submission.interactionId);
|
|
193
|
+
pending.cleanup();
|
|
194
|
+
this.rememberResolved(submission.interactionId, answer);
|
|
195
|
+
pending.resolve(answer);
|
|
196
|
+
notifyListener(() => this.listeners.onResolved?.(answer));
|
|
197
|
+
return { ok: true, answer };
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
/**
|
|
201
|
+
* Skip a pending interaction (spec 7.5 allowSkip). Resolves the pending
|
|
202
|
+
* promise with a SkippedResolvedAnswer so the tool result stays structured;
|
|
203
|
+
* rejects (skip_not_allowed) when the interaction does not allow skipping.
|
|
204
|
+
*/
|
|
205
|
+
skip(interactionId: string): SubmitResult {
|
|
206
|
+
const pending = this.pending.get(interactionId);
|
|
207
|
+
if (!pending) {
|
|
208
|
+
if (this.resolvedIds.has(interactionId)) {
|
|
209
|
+
return {
|
|
210
|
+
ok: false,
|
|
211
|
+
reason: "already_resolved",
|
|
212
|
+
message: `Interaction ${interactionId} has already been resolved.`
|
|
213
|
+
};
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
return {
|
|
217
|
+
ok: false,
|
|
218
|
+
reason: "not_found",
|
|
219
|
+
message: `No pending interaction with id ${interactionId}.`
|
|
220
|
+
};
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
// Only single/multi choice carry allowSkip (spec 7.5); free response and
|
|
224
|
+
// code exercises cannot be skipped.
|
|
225
|
+
if (!("allowSkip" in pending.interaction && pending.interaction.allowSkip)) {
|
|
226
|
+
return {
|
|
227
|
+
ok: false,
|
|
228
|
+
reason: "skip_not_allowed",
|
|
229
|
+
message: `Interaction ${interactionId} does not allow skipping.`
|
|
230
|
+
};
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
const answer: SkippedResolvedAnswer = {
|
|
234
|
+
interactionId: pending.interaction.id,
|
|
235
|
+
type:
|
|
236
|
+
pending.interaction.type === "multi_choice"
|
|
237
|
+
? "multi_choice"
|
|
238
|
+
: "single_choice",
|
|
239
|
+
skipped: true,
|
|
240
|
+
responseTimeMs: Math.max(0, Date.now() - pending.interaction.createdAt)
|
|
241
|
+
};
|
|
242
|
+
this.pending.delete(interactionId);
|
|
243
|
+
pending.cleanup();
|
|
244
|
+
this.rememberResolved(interactionId, answer);
|
|
245
|
+
pending.resolve(answer);
|
|
246
|
+
notifyListener(() => this.listeners.onResolved?.(answer));
|
|
247
|
+
return { ok: true, answer };
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
cancel(interactionId: string, reason: string): void {
|
|
251
|
+
this.rejectPending(
|
|
252
|
+
interactionId,
|
|
253
|
+
new InteractionCancelledError(interactionId, reason),
|
|
254
|
+
reason
|
|
255
|
+
);
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
cancelAll(reason: string): void {
|
|
259
|
+
for (const interactionId of [...this.pending.keys()]) {
|
|
260
|
+
this.cancel(interactionId, reason);
|
|
261
|
+
}
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
reset(reason: string): void {
|
|
265
|
+
this.cancelAll(reason);
|
|
266
|
+
this.resolvedIds.clear();
|
|
267
|
+
}
|
|
268
|
+
|
|
269
|
+
getPending(): LearningInteraction[] {
|
|
270
|
+
return [...this.pending.values()].map(({ interaction }) => interaction);
|
|
271
|
+
}
|
|
272
|
+
|
|
273
|
+
hasResolved(interactionId: string): boolean {
|
|
274
|
+
return this.resolvedIds.has(interactionId);
|
|
275
|
+
}
|
|
276
|
+
|
|
277
|
+
getCompletion(interactionId: string): InteractionCompletion | undefined {
|
|
278
|
+
const completion = this.resolvedIds.get(interactionId);
|
|
279
|
+
return completion === undefined ? undefined : { ...completion };
|
|
280
|
+
}
|
|
281
|
+
|
|
282
|
+
private rememberResolved(interactionId: string, answer: ResolvedAnswer): void {
|
|
283
|
+
// 资格凭据不保存代码/答案正文,避免在长会话中保留大块学习者输入。
|
|
284
|
+
this.resolvedIds.set(interactionId, { type: answer.type, skipped: "skipped" in answer });
|
|
285
|
+
if (this.resolvedIds.size <= MAX_RESOLVED_IDS) {
|
|
286
|
+
return;
|
|
287
|
+
}
|
|
288
|
+
|
|
289
|
+
const oldestId = this.resolvedIds.keys().next().value;
|
|
290
|
+
if (oldestId !== undefined) {
|
|
291
|
+
this.resolvedIds.delete(oldestId);
|
|
292
|
+
}
|
|
293
|
+
}
|
|
294
|
+
|
|
295
|
+
private rejectPending(
|
|
296
|
+
interactionId: string,
|
|
297
|
+
error: Error,
|
|
298
|
+
cancellationReason?: string
|
|
299
|
+
): void {
|
|
300
|
+
const pending = this.pending.get(interactionId);
|
|
301
|
+
if (!pending) {
|
|
302
|
+
return;
|
|
303
|
+
}
|
|
304
|
+
|
|
305
|
+
this.pending.delete(interactionId);
|
|
306
|
+
pending.cleanup();
|
|
307
|
+
pending.reject(error);
|
|
308
|
+
if (cancellationReason !== undefined) {
|
|
309
|
+
notifyListener(() =>
|
|
310
|
+
this.listeners.onCancelled?.(interactionId, cancellationReason)
|
|
311
|
+
);
|
|
312
|
+
}
|
|
313
|
+
}
|
|
314
|
+
}
|
|
315
|
+
|
|
316
|
+
/**
|
|
317
|
+
* Compose two optional listeners into one: both fire in registration order.
|
|
318
|
+
* Used by subscribe so multiple subscribers share one broker (server SSE +
|
|
319
|
+
* state phase hooks). Throwing listeners are already contained by the
|
|
320
|
+
* notifyListener wrapper at the call sites.
|
|
321
|
+
*/
|
|
322
|
+
function compose<A extends unknown[]>(
|
|
323
|
+
first: ((...args: A) => void) | undefined,
|
|
324
|
+
second: ((...args: A) => void) | undefined
|
|
325
|
+
): ((...args: A) => void) | undefined {
|
|
326
|
+
if (first === undefined) {
|
|
327
|
+
return second;
|
|
328
|
+
}
|
|
329
|
+
if (second === undefined) {
|
|
330
|
+
return first;
|
|
331
|
+
}
|
|
332
|
+
return (...args: A) => {
|
|
333
|
+
first(...args);
|
|
334
|
+
second(...args);
|
|
335
|
+
};
|
|
336
|
+
}
|
|
337
|
+
|
|
338
|
+
/** Listeners are observability hooks: a throwing listener must not break the broker. */
|
|
339
|
+
function notifyListener(notify: () => void): void {
|
|
340
|
+
try {
|
|
341
|
+
notify();
|
|
342
|
+
} catch (error) {
|
|
343
|
+
console.error("InteractionBroker listener failed:", error);
|
|
344
|
+
}
|
|
345
|
+
}
|