@pie-players/pie-assessment-toolkit 0.3.69 → 0.3.70
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 +3 -0
- package/dist/components/ItemToolBar.custom-element.js +1 -1
- package/dist/components/PieAssessmentToolkit.custom-element.js +7 -7
- package/dist/components/SectionToolBar.custom-element.js +1 -1
- package/dist/components/chunks/ItemToolBar-12jy8e7c.js +49 -0
- package/dist/components/chunks/ItemToolBar-16t5ary7.js +46 -0
- package/dist/services/HighlightCoordinator.d.ts +5 -1
- package/dist/services/HighlightCoordinator.js +5 -8
- package/dist/services/ToolRegistry.d.ts +14 -0
- package/dist/services/ToolkitCoordinator.d.ts +23 -13
- package/dist/services/ToolkitCoordinator.js +373 -66
- package/dist/services/tool-providers/CortexToolProvider.d.ts +2 -7
- package/dist/services/tool-providers/DesmosToolProvider.d.ts +2 -7
- package/dist/services/tool-providers/DesmosToolProvider.js +1 -1
- package/dist/services/tool-providers/GeoGebraToolProvider.d.ts +2 -7
- package/dist/services/tool-providers/LazyCalculatorToolProvider.d.ts +1 -1
- package/dist/services/tool-request.d.ts +1 -0
- package/dist/services/tool-request.js +4 -0
- package/package.json +16 -16
- package/dist/components/chunks/ItemToolBar-pe5szfyx.js +0 -46
- package/dist/components/chunks/ItemToolBar-rd7te9r0.js +0 -51
|
@@ -29,6 +29,18 @@ import { ToolRegistry } from "./ToolRegistry.js";
|
|
|
29
29
|
import { ToolRequestRegistry } from "./tool-request.js";
|
|
30
30
|
import { ToolPolicyEngine, } from "../policy/engine.js";
|
|
31
31
|
import { resolveDefaultPnpEnforcement } from "../policy/internal.js";
|
|
32
|
+
class ToolkitCoordinatorDisposedError extends Error {
|
|
33
|
+
constructor() {
|
|
34
|
+
super("ToolkitCoordinator has been disposed.");
|
|
35
|
+
this.name = "ToolkitCoordinatorDisposedError";
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
class SectionControllerRetiredError extends Error {
|
|
39
|
+
constructor() {
|
|
40
|
+
super("Section controller acquisition was retired during initialization.");
|
|
41
|
+
this.name = "SectionControllerRetiredError";
|
|
42
|
+
}
|
|
43
|
+
}
|
|
32
44
|
const isPlainRecord = (value) => !!value && typeof value === "object" && !Array.isArray(value);
|
|
33
45
|
const mergeToolConfigUpdate = (toolId, current, updates) => {
|
|
34
46
|
const next = { ...current, ...updates };
|
|
@@ -120,7 +132,8 @@ export class ToolkitCoordinator {
|
|
|
120
132
|
toolRequests = new ToolRequestRegistry();
|
|
121
133
|
sectionControllers = new Map();
|
|
122
134
|
sectionControllerKeys = new Map();
|
|
123
|
-
|
|
135
|
+
sectionControllerInitEntries = new Map();
|
|
136
|
+
sectionControllerDisposePromises = new Map();
|
|
124
137
|
sectionPersistenceStrategies = new Map();
|
|
125
138
|
sectionControllerLifecycleListeners = new Set();
|
|
126
139
|
/**
|
|
@@ -153,14 +166,10 @@ export class ToolkitCoordinator {
|
|
|
153
166
|
telemetryListeners = new Set();
|
|
154
167
|
frameworkErrorBus;
|
|
155
168
|
ownsFrameworkErrorBus;
|
|
169
|
+
frameworkErrorHookUnsubscribe = null;
|
|
170
|
+
disposePromise = null;
|
|
156
171
|
/**
|
|
157
|
-
* Unified Tool Policy Engine. Owned by the coordinator and
|
|
158
|
-
* for the lifetime of the coordinator instance — there is no
|
|
159
|
-
* explicit teardown path today; the engine and its listener set
|
|
160
|
-
* are reclaimed by GC when the coordinator becomes unreachable.
|
|
161
|
-
* Subscribers attached via {@link onPolicyChange} must therefore
|
|
162
|
-
* detach via the unsubscribe function the engine returns; do not
|
|
163
|
-
* rely on a `disposed` event being emitted on coordinator teardown.
|
|
172
|
+
* Unified Tool Policy Engine. Owned by the coordinator and disposed with it.
|
|
164
173
|
*
|
|
165
174
|
* Hosts read decisions via {@link decideToolPolicy} or subscribe
|
|
166
175
|
* to changes via {@link onPolicyChange}.
|
|
@@ -298,11 +307,18 @@ export class ToolkitCoordinator {
|
|
|
298
307
|
});
|
|
299
308
|
if (!this.lazyInit) {
|
|
300
309
|
void this.waitUntilReady().catch((err) => {
|
|
310
|
+
if (err instanceof ToolkitCoordinatorDisposedError)
|
|
311
|
+
return;
|
|
301
312
|
console.error("[ToolkitCoordinator] Failed eager initialization:", err);
|
|
302
313
|
this.handleError(err, { phase: "coordinator-ready" });
|
|
303
314
|
});
|
|
304
315
|
}
|
|
305
316
|
}
|
|
317
|
+
assertNotDisposed() {
|
|
318
|
+
if (this.disposePromise !== null) {
|
|
319
|
+
throw new ToolkitCoordinatorDisposedError();
|
|
320
|
+
}
|
|
321
|
+
}
|
|
306
322
|
/**
|
|
307
323
|
* Subscribe the canonical `onFrameworkError` hook adapter to the
|
|
308
324
|
* framework-error bus.
|
|
@@ -312,17 +328,18 @@ export class ToolkitCoordinator {
|
|
|
312
328
|
* is picked up automatically without re-subscribing.
|
|
313
329
|
*/
|
|
314
330
|
subscribeFrameworkErrorHookAdapters() {
|
|
315
|
-
this.
|
|
316
|
-
|
|
317
|
-
|
|
318
|
-
|
|
319
|
-
|
|
320
|
-
|
|
321
|
-
|
|
322
|
-
|
|
323
|
-
|
|
324
|
-
|
|
325
|
-
|
|
331
|
+
this.frameworkErrorHookUnsubscribe =
|
|
332
|
+
this.frameworkErrorBus.subscribeFrameworkErrors((model) => {
|
|
333
|
+
const hook = this.hooks.onFrameworkError;
|
|
334
|
+
if (!hook)
|
|
335
|
+
return;
|
|
336
|
+
try {
|
|
337
|
+
hook(model);
|
|
338
|
+
}
|
|
339
|
+
catch (hookError) {
|
|
340
|
+
console.warn("[ToolkitCoordinator] onFrameworkError hook failed:", hookError);
|
|
341
|
+
}
|
|
342
|
+
});
|
|
326
343
|
}
|
|
327
344
|
/**
|
|
328
345
|
* Emit a telemetry event to `onTelemetry` hook + all `subscribeTelemetry`
|
|
@@ -491,6 +508,7 @@ export class ToolkitCoordinator {
|
|
|
491
508
|
}
|
|
492
509
|
try {
|
|
493
510
|
const state = await loader();
|
|
511
|
+
this.assertNotDisposed();
|
|
494
512
|
if (state && typeof state === "object") {
|
|
495
513
|
this.elementToolStateStore.loadState(state);
|
|
496
514
|
}
|
|
@@ -500,6 +518,8 @@ export class ToolkitCoordinator {
|
|
|
500
518
|
});
|
|
501
519
|
}
|
|
502
520
|
catch (err) {
|
|
521
|
+
if (err instanceof ToolkitCoordinatorDisposedError)
|
|
522
|
+
throw err;
|
|
503
523
|
this.handleError(err, { phase: "state-load" });
|
|
504
524
|
}
|
|
505
525
|
})().finally(() => {
|
|
@@ -601,23 +621,31 @@ export class ToolkitCoordinator {
|
|
|
601
621
|
}
|
|
602
622
|
}
|
|
603
623
|
async ensureProviderReady(providerId) {
|
|
624
|
+
this.assertNotDisposed();
|
|
604
625
|
const existing = this.providerInitPromises.get(providerId);
|
|
605
626
|
if (existing)
|
|
606
627
|
return existing;
|
|
607
628
|
const promise = (async () => {
|
|
608
629
|
const provider = await this.toolProviderRegistry.getProvider(providerId, false);
|
|
630
|
+
this.assertNotDisposed();
|
|
609
631
|
const meta = {
|
|
610
632
|
providerId,
|
|
611
633
|
providerName: provider.providerName,
|
|
612
634
|
};
|
|
613
635
|
try {
|
|
614
636
|
await this.hooks.onProviderInitStart?.(providerId, meta);
|
|
637
|
+
this.assertNotDisposed();
|
|
615
638
|
await this.toolProviderRegistry.initialize(providerId);
|
|
639
|
+
this.assertNotDisposed();
|
|
616
640
|
await this.hooks.onProviderReady?.(providerId, meta);
|
|
641
|
+
this.assertNotDisposed();
|
|
617
642
|
await this.emitTelemetry("pie-toolkit-provider-ready", { providerId });
|
|
643
|
+
this.assertNotDisposed();
|
|
618
644
|
return provider;
|
|
619
645
|
}
|
|
620
646
|
catch (err) {
|
|
647
|
+
if (err instanceof ToolkitCoordinatorDisposedError)
|
|
648
|
+
throw err;
|
|
621
649
|
const error = err instanceof Error ? err : new Error(String(err));
|
|
622
650
|
this.handleError(error, { phase: "provider-init", providerId });
|
|
623
651
|
throw error;
|
|
@@ -631,7 +659,9 @@ export class ToolkitCoordinator {
|
|
|
631
659
|
setHooks(hooks) {
|
|
632
660
|
Object.assign(this.hooks, hooks);
|
|
633
661
|
this.setupStatePersistenceHooks();
|
|
634
|
-
if (
|
|
662
|
+
if (this.disposePromise === null &&
|
|
663
|
+
hooks.onCoordinatorReady &&
|
|
664
|
+
this.isReady()) {
|
|
635
665
|
void Promise.resolve(hooks.onCoordinatorReady(this)).catch((err) => {
|
|
636
666
|
this.handleError(err, { phase: "coordinator-ready" });
|
|
637
667
|
});
|
|
@@ -939,6 +969,9 @@ export class ToolkitCoordinator {
|
|
|
939
969
|
};
|
|
940
970
|
}
|
|
941
971
|
async getOrCreateSectionController(args) {
|
|
972
|
+
if (this.disposePromise !== null) {
|
|
973
|
+
throw new ToolkitCoordinatorDisposedError();
|
|
974
|
+
}
|
|
942
975
|
const key = {
|
|
943
976
|
assessmentId: this.assessmentId,
|
|
944
977
|
sectionId: args.sectionId,
|
|
@@ -953,24 +986,50 @@ export class ToolkitCoordinator {
|
|
|
953
986
|
input: args.input,
|
|
954
987
|
updateExisting: args.updateExisting,
|
|
955
988
|
});
|
|
956
|
-
if (existingController)
|
|
989
|
+
if (existingController) {
|
|
990
|
+
if (this.disposePromise !== null) {
|
|
991
|
+
throw new ToolkitCoordinatorDisposedError();
|
|
992
|
+
}
|
|
957
993
|
return existingController;
|
|
958
|
-
|
|
959
|
-
|
|
960
|
-
|
|
994
|
+
}
|
|
995
|
+
const pendingDisposal = this.sectionControllerDisposePromises.get(mapKey);
|
|
996
|
+
if (pendingDisposal) {
|
|
997
|
+
// Persistence and hydration share the cohort's durable state. Keep the
|
|
998
|
+
// retired controller out of the cache immediately, but do not let its
|
|
999
|
+
// replacement hydrate until that exact cohort's save/dispose pipeline has
|
|
1000
|
+
// settled. Other cohorts remain independent.
|
|
1001
|
+
await Promise.allSettled([pendingDisposal]);
|
|
1002
|
+
}
|
|
1003
|
+
if (this.disposePromise !== null) {
|
|
1004
|
+
throw new ToolkitCoordinatorDisposedError();
|
|
1005
|
+
}
|
|
1006
|
+
const existingEntry = this.sectionControllerInitEntries.get(mapKey);
|
|
1007
|
+
if (existingEntry)
|
|
1008
|
+
return existingEntry.promise;
|
|
1009
|
+
const token = {
|
|
1010
|
+
retired: false,
|
|
1011
|
+
candidateClaimed: false,
|
|
1012
|
+
};
|
|
961
1013
|
const initPromise = this.initializeNewSectionController({
|
|
962
1014
|
args,
|
|
963
1015
|
key,
|
|
964
1016
|
mapKey,
|
|
1017
|
+
token,
|
|
965
1018
|
})
|
|
966
1019
|
.catch((err) => {
|
|
967
1020
|
this.handleSectionControllerInitError(err, args);
|
|
968
1021
|
throw err;
|
|
969
1022
|
})
|
|
970
1023
|
.finally(() => {
|
|
971
|
-
this.
|
|
1024
|
+
if (this.sectionControllerInitEntries.get(mapKey)?.token === token) {
|
|
1025
|
+
this.sectionControllerInitEntries.delete(mapKey);
|
|
1026
|
+
}
|
|
1027
|
+
});
|
|
1028
|
+
this.sectionControllerInitEntries.set(mapKey, {
|
|
1029
|
+
key,
|
|
1030
|
+
token,
|
|
1031
|
+
promise: initPromise,
|
|
972
1032
|
});
|
|
973
|
-
this.sectionControllerInitPromises.set(mapKey, initPromise);
|
|
974
1033
|
return initPromise;
|
|
975
1034
|
}
|
|
976
1035
|
async resolveExistingSectionController(args) {
|
|
@@ -984,6 +1043,11 @@ export class ToolkitCoordinator {
|
|
|
984
1043
|
// without resetting responses.
|
|
985
1044
|
await existingController.updateInput?.(args.input);
|
|
986
1045
|
}
|
|
1046
|
+
// `updateInput` may yield to a teardown. Never hand a controller back
|
|
1047
|
+
// after its exact cache entry has been removed or replaced.
|
|
1048
|
+
if (this.sectionControllers.get(args.mapKey) !== existingController) {
|
|
1049
|
+
return undefined;
|
|
1050
|
+
}
|
|
987
1051
|
// PIE-512 Phase D: a `getOrCreateSectionController` call that
|
|
988
1052
|
// resolves to a previously-created controller still represents a
|
|
989
1053
|
// cohort transition from the toolkit's perspective (same-cohort
|
|
@@ -1007,28 +1071,94 @@ export class ToolkitCoordinator {
|
|
|
1007
1071
|
input: args.args.input,
|
|
1008
1072
|
});
|
|
1009
1073
|
const persistence = await this.resolveSectionPersistence(context);
|
|
1074
|
+
if (this.disposePromise !== null || args.token.retired) {
|
|
1075
|
+
if (this.sectionPersistenceStrategies.get(args.mapKey) === persistence) {
|
|
1076
|
+
this.sectionPersistenceStrategies.delete(args.mapKey);
|
|
1077
|
+
}
|
|
1078
|
+
throw this.createSectionControllerRetirementError();
|
|
1079
|
+
}
|
|
1010
1080
|
const defaults = {
|
|
1011
1081
|
createDefaultController: args.args.createDefaultController,
|
|
1012
1082
|
};
|
|
1013
1083
|
const controller = (await this.hooks.createSectionController?.(context, defaults)) ??
|
|
1014
1084
|
(await defaults.createDefaultController());
|
|
1015
|
-
|
|
1016
|
-
strategy: persistence,
|
|
1017
|
-
context,
|
|
1018
|
-
});
|
|
1019
|
-
await controller.initialize?.(args.args.input);
|
|
1020
|
-
await controller.hydrate?.();
|
|
1021
|
-
await this.finalizeSectionControllerReady({
|
|
1085
|
+
const candidate = {
|
|
1022
1086
|
mapKey: args.mapKey,
|
|
1023
1087
|
key: args.key,
|
|
1024
|
-
context,
|
|
1025
1088
|
controller,
|
|
1026
|
-
|
|
1027
|
-
|
|
1089
|
+
persistence,
|
|
1090
|
+
token: args.token,
|
|
1091
|
+
};
|
|
1092
|
+
try {
|
|
1093
|
+
await this.retireUnpublishedSectionControllerIfNeeded(candidate);
|
|
1094
|
+
await controller.configureSessionPersistence?.({
|
|
1095
|
+
strategy: persistence,
|
|
1096
|
+
context,
|
|
1097
|
+
});
|
|
1098
|
+
await this.retireUnpublishedSectionControllerIfNeeded(candidate);
|
|
1099
|
+
await controller.initialize?.(args.args.input);
|
|
1100
|
+
await this.retireUnpublishedSectionControllerIfNeeded(candidate);
|
|
1101
|
+
await controller.hydrate?.();
|
|
1102
|
+
await this.finalizeSectionControllerReady({
|
|
1103
|
+
...candidate,
|
|
1104
|
+
context,
|
|
1105
|
+
});
|
|
1106
|
+
return controller;
|
|
1107
|
+
}
|
|
1108
|
+
catch (error) {
|
|
1109
|
+
await this.cleanupUnpublishedSectionController(candidate);
|
|
1110
|
+
throw error;
|
|
1111
|
+
}
|
|
1112
|
+
}
|
|
1113
|
+
createSectionControllerRetirementError() {
|
|
1114
|
+
return this.disposePromise !== null
|
|
1115
|
+
? new ToolkitCoordinatorDisposedError()
|
|
1116
|
+
: new SectionControllerRetiredError();
|
|
1117
|
+
}
|
|
1118
|
+
async retireUnpublishedSectionControllerIfNeeded(args) {
|
|
1119
|
+
if (this.disposePromise === null && !args.token.retired)
|
|
1120
|
+
return;
|
|
1121
|
+
await this.cleanupUnpublishedSectionController(args);
|
|
1122
|
+
throw this.createSectionControllerRetirementError();
|
|
1123
|
+
}
|
|
1124
|
+
async cleanupUnpublishedSectionController(args) {
|
|
1125
|
+
if (args.token.candidateClaimed)
|
|
1126
|
+
return;
|
|
1127
|
+
args.token.candidateClaimed = true;
|
|
1128
|
+
try {
|
|
1129
|
+
await args.controller.dispose?.();
|
|
1130
|
+
}
|
|
1131
|
+
catch (error) {
|
|
1132
|
+
this.handleError(error, {
|
|
1133
|
+
phase: "section-controller-dispose",
|
|
1134
|
+
details: {
|
|
1135
|
+
sectionId: args.key.sectionId,
|
|
1136
|
+
attemptId: args.key.attemptId,
|
|
1137
|
+
},
|
|
1138
|
+
});
|
|
1139
|
+
}
|
|
1140
|
+
finally {
|
|
1141
|
+
if (this.sectionPersistenceStrategies.get(args.mapKey) === args.persistence) {
|
|
1142
|
+
this.sectionPersistenceStrategies.delete(args.mapKey);
|
|
1143
|
+
}
|
|
1144
|
+
}
|
|
1145
|
+
}
|
|
1146
|
+
retirePublishedSectionControllerIfNeeded(args) {
|
|
1147
|
+
if (this.disposePromise !== null && !args.token.retired) {
|
|
1148
|
+
void this.disposeSectionController({
|
|
1149
|
+
sectionId: args.key.sectionId,
|
|
1150
|
+
attemptId: args.key.attemptId,
|
|
1151
|
+
});
|
|
1152
|
+
}
|
|
1153
|
+
if (args.token.retired) {
|
|
1154
|
+
throw this.createSectionControllerRetirementError();
|
|
1155
|
+
}
|
|
1028
1156
|
}
|
|
1029
1157
|
async finalizeSectionControllerReady(args) {
|
|
1158
|
+
await this.retireUnpublishedSectionControllerIfNeeded(args);
|
|
1030
1159
|
this.sectionControllers.set(args.mapKey, args.controller);
|
|
1031
1160
|
this.sectionControllerKeys.set(args.mapKey, args.key);
|
|
1161
|
+
args.token.candidateClaimed = true;
|
|
1032
1162
|
// PIE-512 Phase D: a freshly-resolved controller becomes the
|
|
1033
1163
|
// active cohort. Active subscriptions migrate to it before the
|
|
1034
1164
|
// `ready` lifecycle event and `onSectionControllerReady` hook
|
|
@@ -1041,13 +1171,19 @@ export class ToolkitCoordinator {
|
|
|
1041
1171
|
controller: args.controller,
|
|
1042
1172
|
});
|
|
1043
1173
|
await this.hooks.onSectionControllerReady?.(args.context, args.controller);
|
|
1174
|
+
this.retirePublishedSectionControllerIfNeeded(args);
|
|
1044
1175
|
await this.emitTelemetry("pie-toolkit-section-controller-ready", {
|
|
1045
1176
|
assessmentId: args.key.assessmentId,
|
|
1046
1177
|
sectionId: args.key.sectionId,
|
|
1047
1178
|
attemptId: args.key.attemptId,
|
|
1048
1179
|
});
|
|
1180
|
+
this.retirePublishedSectionControllerIfNeeded(args);
|
|
1049
1181
|
}
|
|
1050
1182
|
handleSectionControllerInitError(err, args) {
|
|
1183
|
+
if (err instanceof ToolkitCoordinatorDisposedError ||
|
|
1184
|
+
err instanceof SectionControllerRetiredError) {
|
|
1185
|
+
return;
|
|
1186
|
+
}
|
|
1051
1187
|
this.handleError(err, {
|
|
1052
1188
|
phase: "section-controller-init",
|
|
1053
1189
|
details: {
|
|
@@ -1056,57 +1192,122 @@ export class ToolkitCoordinator {
|
|
|
1056
1192
|
},
|
|
1057
1193
|
});
|
|
1058
1194
|
}
|
|
1059
|
-
|
|
1195
|
+
disposeSectionController(args) {
|
|
1060
1196
|
const key = {
|
|
1061
1197
|
assessmentId: this.assessmentId,
|
|
1062
1198
|
sectionId: args.sectionId,
|
|
1063
1199
|
attemptId: args.attemptId,
|
|
1064
1200
|
};
|
|
1065
1201
|
const mapKey = this.getSectionControllerMapKey(key);
|
|
1202
|
+
const initEntry = this.sectionControllerInitEntries.get(mapKey);
|
|
1203
|
+
if (initEntry) {
|
|
1204
|
+
initEntry.token.retired = true;
|
|
1205
|
+
}
|
|
1066
1206
|
const controller = this.sectionControllers.get(mapKey);
|
|
1067
|
-
if (!controller)
|
|
1068
|
-
|
|
1207
|
+
if (!controller) {
|
|
1208
|
+
const existingDisposal = this.sectionControllerDisposePromises.get(mapKey);
|
|
1209
|
+
if (existingDisposal)
|
|
1210
|
+
return existingDisposal;
|
|
1211
|
+
if (!initEntry)
|
|
1212
|
+
return Promise.resolve();
|
|
1213
|
+
const retirementBarrier = Promise.allSettled([initEntry.promise]).then(() => undefined);
|
|
1214
|
+
return this.trackSectionControllerDisposal(mapKey, retirementBarrier);
|
|
1215
|
+
}
|
|
1216
|
+
const persistenceStrategy = this.sectionPersistenceStrategies.get(mapKey);
|
|
1069
1217
|
// PIE-512 Phase D: if the disposing cohort is the active one,
|
|
1070
1218
|
// detach all listener-controller bindings before the controller
|
|
1071
1219
|
// itself disposes. The subscription registry stays intact so a
|
|
1072
1220
|
// later `getOrCreateSectionController(...)` re-binds the same
|
|
1073
1221
|
// listeners to the new controller.
|
|
1074
1222
|
this.clearActiveCohortIfMatches(mapKey);
|
|
1223
|
+
// Relinquish this exact controller before the first asynchronous step.
|
|
1224
|
+
// A new mount for the same cohort must create a fresh controller rather
|
|
1225
|
+
// than reacquire the one whose persistence/disposal is still in flight.
|
|
1226
|
+
if (this.sectionControllers.get(mapKey) === controller) {
|
|
1227
|
+
this.sectionControllers.delete(mapKey);
|
|
1228
|
+
this.sectionControllerKeys.delete(mapKey);
|
|
1229
|
+
this.sectionPersistenceStrategies.delete(mapKey);
|
|
1230
|
+
// Lifecycle consumers match by cohort key rather than controller identity.
|
|
1231
|
+
// Publish retirement now so a replacement can only produce
|
|
1232
|
+
// disposed -> ready, never ready -> stale disposed.
|
|
1233
|
+
this.emitSectionControllerLifecycle({
|
|
1234
|
+
type: "disposed",
|
|
1235
|
+
key,
|
|
1236
|
+
});
|
|
1237
|
+
}
|
|
1075
1238
|
const context = this.createSectionControllerContext({
|
|
1076
1239
|
key,
|
|
1077
1240
|
input: undefined,
|
|
1078
1241
|
});
|
|
1242
|
+
const controllerDisposalPromise = this.disposeSectionControllerEntry({
|
|
1243
|
+
args,
|
|
1244
|
+
key,
|
|
1245
|
+
context,
|
|
1246
|
+
controller,
|
|
1247
|
+
persistenceStrategy,
|
|
1248
|
+
});
|
|
1249
|
+
const disposalBarrier = initEntry
|
|
1250
|
+
? Promise.allSettled([initEntry.promise]).then(() => controllerDisposalPromise)
|
|
1251
|
+
: controllerDisposalPromise;
|
|
1252
|
+
return this.trackSectionControllerDisposal(mapKey, disposalBarrier);
|
|
1253
|
+
}
|
|
1254
|
+
trackSectionControllerDisposal(mapKey, disposePromise) {
|
|
1255
|
+
this.sectionControllerDisposePromises.set(mapKey, disposePromise);
|
|
1256
|
+
const forgetDisposePromise = () => {
|
|
1257
|
+
if (this.sectionControllerDisposePromises.get(mapKey) === disposePromise) {
|
|
1258
|
+
this.sectionControllerDisposePromises.delete(mapKey);
|
|
1259
|
+
}
|
|
1260
|
+
};
|
|
1261
|
+
void disposePromise.then(forgetDisposePromise, forgetDisposePromise);
|
|
1262
|
+
return disposePromise;
|
|
1263
|
+
}
|
|
1264
|
+
async disposeSectionControllerEntry(args) {
|
|
1079
1265
|
try {
|
|
1080
1266
|
await this.runSectionControllerDisposePipeline({
|
|
1081
|
-
key,
|
|
1082
|
-
context,
|
|
1083
|
-
controller,
|
|
1084
|
-
persistBeforeDispose: args.persistBeforeDispose,
|
|
1267
|
+
key: args.key,
|
|
1268
|
+
context: args.context,
|
|
1269
|
+
controller: args.controller,
|
|
1270
|
+
persistBeforeDispose: args.args.persistBeforeDispose,
|
|
1085
1271
|
});
|
|
1086
1272
|
}
|
|
1087
1273
|
catch (err) {
|
|
1088
1274
|
this.handleError(err, {
|
|
1089
1275
|
phase: "section-controller-dispose",
|
|
1090
1276
|
details: {
|
|
1091
|
-
sectionId: args.sectionId,
|
|
1092
|
-
attemptId: args.attemptId,
|
|
1277
|
+
sectionId: args.args.sectionId,
|
|
1278
|
+
attemptId: args.args.attemptId,
|
|
1093
1279
|
},
|
|
1094
1280
|
});
|
|
1095
1281
|
}
|
|
1096
1282
|
finally {
|
|
1097
1283
|
await this.finalizeSectionControllerDispose({
|
|
1098
|
-
|
|
1099
|
-
|
|
1100
|
-
|
|
1101
|
-
clearPersistence: args.clearPersistence,
|
|
1284
|
+
context: args.context,
|
|
1285
|
+
persistenceStrategy: args.persistenceStrategy,
|
|
1286
|
+
clearPersistence: args.args.clearPersistence,
|
|
1102
1287
|
});
|
|
1103
1288
|
}
|
|
1104
1289
|
}
|
|
1105
1290
|
async runSectionControllerDisposePipeline(args) {
|
|
1291
|
+
const failures = [];
|
|
1106
1292
|
if (args.persistBeforeDispose !== false) {
|
|
1107
|
-
|
|
1293
|
+
try {
|
|
1294
|
+
await args.controller.persist?.();
|
|
1295
|
+
}
|
|
1296
|
+
catch (error) {
|
|
1297
|
+
failures.push(error);
|
|
1298
|
+
}
|
|
1299
|
+
}
|
|
1300
|
+
try {
|
|
1301
|
+
await args.controller.dispose?.();
|
|
1302
|
+
}
|
|
1303
|
+
catch (error) {
|
|
1304
|
+
failures.push(error);
|
|
1305
|
+
}
|
|
1306
|
+
if (failures.length === 1)
|
|
1307
|
+
throw failures[0];
|
|
1308
|
+
if (failures.length > 1) {
|
|
1309
|
+
throw new AggregateError(failures, "Section controller persistence and disposal both failed.");
|
|
1108
1310
|
}
|
|
1109
|
-
await args.controller.dispose?.();
|
|
1110
1311
|
await this.hooks.onSectionControllerDispose?.(args.context, args.controller);
|
|
1111
1312
|
await this.emitTelemetry("pie-toolkit-section-controller-disposed", {
|
|
1112
1313
|
assessmentId: args.key.assessmentId,
|
|
@@ -1115,28 +1316,119 @@ export class ToolkitCoordinator {
|
|
|
1115
1316
|
});
|
|
1116
1317
|
}
|
|
1117
1318
|
async finalizeSectionControllerDispose(args) {
|
|
1118
|
-
this.sectionControllers.delete(args.mapKey);
|
|
1119
|
-
this.sectionControllerKeys.delete(args.mapKey);
|
|
1120
|
-
this.emitSectionControllerLifecycle({
|
|
1121
|
-
type: "disposed",
|
|
1122
|
-
key: args.key,
|
|
1123
|
-
});
|
|
1124
1319
|
if (args.clearPersistence) {
|
|
1125
|
-
|
|
1126
|
-
|
|
1320
|
+
await args.persistenceStrategy?.clearSession?.(args.context);
|
|
1321
|
+
}
|
|
1322
|
+
}
|
|
1323
|
+
/**
|
|
1324
|
+
* Release every resource whose lifetime is owned by this coordinator.
|
|
1325
|
+
* Borrowed constructor inputs, including a host framework-error bus and tool
|
|
1326
|
+
* registry, remain owned by their caller.
|
|
1327
|
+
*/
|
|
1328
|
+
dispose() {
|
|
1329
|
+
if (this.disposePromise)
|
|
1330
|
+
return this.disposePromise;
|
|
1331
|
+
// Defer cleanup until after this promise is assigned. The promise itself is
|
|
1332
|
+
// the single lifetime flag observed by every admission guard.
|
|
1333
|
+
this.disposePromise = Promise.resolve().then(() => this.disposeOwnedResources());
|
|
1334
|
+
return this.disposePromise;
|
|
1335
|
+
}
|
|
1336
|
+
async disposeOwnedResources() {
|
|
1337
|
+
const errors = [];
|
|
1338
|
+
const cleanup = async (action) => {
|
|
1339
|
+
try {
|
|
1340
|
+
await action();
|
|
1341
|
+
}
|
|
1342
|
+
catch (error) {
|
|
1343
|
+
errors.push(error);
|
|
1344
|
+
}
|
|
1345
|
+
};
|
|
1346
|
+
// Work admitted before `dispose()` may still be inside a host hook or a
|
|
1347
|
+
// provider/service initializer. Let it observe the disposal promise and
|
|
1348
|
+
// settle before destroying the owned registries and services it is using.
|
|
1349
|
+
await this.waitForAdmittedInitialization();
|
|
1350
|
+
// Retire every admitted section initialization before waiting. A section
|
|
1351
|
+
// with no cached controller still gets a per-key barrier; a controller that
|
|
1352
|
+
// reached `ready` while its init hook was pending is removed and disposed by
|
|
1353
|
+
// the same path as any other cached controller.
|
|
1354
|
+
const sectionKeys = new Map();
|
|
1355
|
+
for (const [mapKey, entry] of this.sectionControllerInitEntries) {
|
|
1356
|
+
sectionKeys.set(mapKey, entry.key);
|
|
1357
|
+
}
|
|
1358
|
+
for (const [mapKey, key] of this.sectionControllerKeys) {
|
|
1359
|
+
sectionKeys.set(mapKey, key);
|
|
1360
|
+
}
|
|
1361
|
+
for (const key of sectionKeys.values()) {
|
|
1362
|
+
void this.disposeSectionController({
|
|
1363
|
+
sectionId: key.sectionId,
|
|
1364
|
+
attemptId: key.attemptId,
|
|
1365
|
+
});
|
|
1366
|
+
}
|
|
1367
|
+
await Promise.allSettled(Array.from(this.sectionControllerDisposePromises.values()));
|
|
1368
|
+
this.sectionControllers.clear();
|
|
1369
|
+
this.sectionControllerKeys.clear();
|
|
1370
|
+
this.sectionControllerInitEntries.clear();
|
|
1371
|
+
this.sectionPersistenceStrategies.clear();
|
|
1372
|
+
for (const subscription of this.activeSubscriptions.values()) {
|
|
1373
|
+
await cleanup(() => subscription.unsubscribeCurrent?.());
|
|
1374
|
+
subscription.unsubscribeCurrent = null;
|
|
1375
|
+
}
|
|
1376
|
+
this.activeSubscriptions.clear();
|
|
1377
|
+
this.activeCohortMapKey = null;
|
|
1378
|
+
this.latestRequestedActiveCohortMapKey = null;
|
|
1379
|
+
await cleanup(() => this.ttsService.stop());
|
|
1380
|
+
await cleanup(() => this.toolProviderRegistry.destroy());
|
|
1381
|
+
await cleanup(() => this.highlightCoordinator.destroy());
|
|
1382
|
+
for (const toolId of this.toolCoordinator.getRegisteredTools()) {
|
|
1383
|
+
await cleanup(() => this.toolCoordinator.releaseTool(toolId));
|
|
1384
|
+
}
|
|
1385
|
+
await cleanup(() => this.catalogResolver.destroy());
|
|
1386
|
+
await cleanup(() => this.toolRequests.dispose());
|
|
1387
|
+
await cleanup(() => this.policyEngine.dispose());
|
|
1388
|
+
this.toolContextResolvers.clear();
|
|
1389
|
+
this.toolContextResolverChangeListeners.clear();
|
|
1390
|
+
this.sectionControllerLifecycleListeners.clear();
|
|
1391
|
+
this.telemetryListeners.clear();
|
|
1392
|
+
this.frameworkErrorHookUnsubscribe?.();
|
|
1393
|
+
this.frameworkErrorHookUnsubscribe = null;
|
|
1394
|
+
if (this.ownsFrameworkErrorBus) {
|
|
1395
|
+
this.frameworkErrorBus.dispose();
|
|
1396
|
+
}
|
|
1397
|
+
if (errors.length === 1)
|
|
1398
|
+
throw errors[0];
|
|
1399
|
+
if (errors.length > 1) {
|
|
1400
|
+
throw new AggregateError(errors, "ToolkitCoordinator cleanup failed for multiple owned resources.");
|
|
1127
1401
|
}
|
|
1128
|
-
|
|
1402
|
+
}
|
|
1403
|
+
async waitForAdmittedInitialization() {
|
|
1404
|
+
const pending = new Set();
|
|
1405
|
+
if (this.coordinatorReadyPromise) {
|
|
1406
|
+
pending.add(this.coordinatorReadyPromise);
|
|
1407
|
+
}
|
|
1408
|
+
if (this.ttsInitPromise)
|
|
1409
|
+
pending.add(this.ttsInitPromise);
|
|
1410
|
+
if (this.ttsReconfigurePromise) {
|
|
1411
|
+
pending.add(this.ttsReconfigurePromise);
|
|
1412
|
+
}
|
|
1413
|
+
for (const promise of this.providerInitPromises.values()) {
|
|
1414
|
+
pending.add(promise);
|
|
1415
|
+
}
|
|
1416
|
+
await Promise.allSettled(pending);
|
|
1129
1417
|
}
|
|
1130
1418
|
/**
|
|
1131
1419
|
* Initialize TTS service with provider
|
|
1132
1420
|
*/
|
|
1133
1421
|
async ensureTTSReady(config) {
|
|
1422
|
+
this.assertNotDisposed();
|
|
1134
1423
|
await this.waitForPendingTTSReconfigure();
|
|
1424
|
+
this.assertNotDisposed();
|
|
1135
1425
|
if (this.ttsInitialized)
|
|
1136
1426
|
return;
|
|
1137
1427
|
if (this.ttsInitPromise)
|
|
1138
1428
|
return this.ttsInitPromise;
|
|
1139
|
-
this.ttsInitPromise = this._initializeTTS(config)
|
|
1429
|
+
this.ttsInitPromise = this._initializeTTS(config)
|
|
1430
|
+
.then(() => this.assertNotDisposed())
|
|
1431
|
+
.finally(() => {
|
|
1140
1432
|
this.ttsInitPromise = undefined;
|
|
1141
1433
|
});
|
|
1142
1434
|
return this.ttsInitPromise;
|
|
@@ -1161,6 +1453,7 @@ export class ToolkitCoordinator {
|
|
|
1161
1453
|
backend: resolvedBackend,
|
|
1162
1454
|
},
|
|
1163
1455
|
});
|
|
1456
|
+
this.assertNotDisposed();
|
|
1164
1457
|
await this.emitTelemetry("pie-toolkit-tts-init-start", {
|
|
1165
1458
|
backend: resolvedBackend,
|
|
1166
1459
|
});
|
|
@@ -1188,6 +1481,8 @@ export class ToolkitCoordinator {
|
|
|
1188
1481
|
return;
|
|
1189
1482
|
}
|
|
1190
1483
|
catch (error) {
|
|
1484
|
+
if (error instanceof ToolkitCoordinatorDisposedError)
|
|
1485
|
+
throw error;
|
|
1191
1486
|
const normalized = error instanceof Error ? error : new Error(String(error));
|
|
1192
1487
|
await this.emitTelemetry("pie-tool-init-error", {
|
|
1193
1488
|
toolId: "textToSpeech",
|
|
@@ -1225,6 +1520,8 @@ export class ToolkitCoordinator {
|
|
|
1225
1520
|
});
|
|
1226
1521
|
}
|
|
1227
1522
|
catch (error) {
|
|
1523
|
+
if (error instanceof ToolkitCoordinatorDisposedError)
|
|
1524
|
+
throw error;
|
|
1228
1525
|
const normalized = error instanceof Error ? error : new Error(String(error));
|
|
1229
1526
|
this.handleError(normalized, { phase: "tts-init" });
|
|
1230
1527
|
await this.emitTelemetry("pie-toolkit-tts-init-error", {
|
|
@@ -1259,9 +1556,11 @@ export class ToolkitCoordinator {
|
|
|
1259
1556
|
};
|
|
1260
1557
|
await this.ttsService.initialize(provider, nextConfig);
|
|
1261
1558
|
await this.ensureBrowserVoicesReady(provider);
|
|
1559
|
+
this.assertNotDisposed();
|
|
1262
1560
|
this.ttsService.setCatalogResolver(this.catalogResolver);
|
|
1263
1561
|
this.ttsInitialized = true;
|
|
1264
1562
|
await this.hooks.onTTSReady?.();
|
|
1563
|
+
this.assertNotDisposed();
|
|
1265
1564
|
}
|
|
1266
1565
|
async ensureBrowserVoicesReady(provider, timeoutMs = 1200) {
|
|
1267
1566
|
if (provider.providerId !== "browser")
|
|
@@ -1344,19 +1643,23 @@ export class ToolkitCoordinator {
|
|
|
1344
1643
|
return this.ensureProviderReady(providerId);
|
|
1345
1644
|
}
|
|
1346
1645
|
async waitUntilReady() {
|
|
1646
|
+
this.assertNotDisposed();
|
|
1347
1647
|
if (this.isReady())
|
|
1348
1648
|
return;
|
|
1349
1649
|
if (this.coordinatorReadyPromise)
|
|
1350
1650
|
return this.coordinatorReadyPromise;
|
|
1351
1651
|
this.coordinatorReadyPromise = (async () => {
|
|
1352
1652
|
await this.ensureStateLoaded();
|
|
1653
|
+
this.assertNotDisposed();
|
|
1353
1654
|
const ttsConfig = this.getTTSConfigFromProviders();
|
|
1354
1655
|
if (ttsConfig?.enabled !== false) {
|
|
1355
1656
|
await this.ensureTTSReady(ttsConfig);
|
|
1356
1657
|
}
|
|
1658
|
+
this.assertNotDisposed();
|
|
1357
1659
|
if (!this.coordinatorReadyNotified) {
|
|
1358
1660
|
this.coordinatorReadyNotified = true;
|
|
1359
1661
|
await this.hooks.onCoordinatorReady?.(this);
|
|
1662
|
+
this.assertNotDisposed();
|
|
1360
1663
|
await this.emitTelemetry("pie-toolkit-coordinator-ready", {
|
|
1361
1664
|
assessmentId: this.assessmentId,
|
|
1362
1665
|
});
|
|
@@ -1585,11 +1888,9 @@ export class ToolkitCoordinator {
|
|
|
1585
1888
|
* that want the new visible tool set should call
|
|
1586
1889
|
* {@link decideToolPolicy} with their level / scope.
|
|
1587
1890
|
*
|
|
1588
|
-
*
|
|
1589
|
-
*
|
|
1590
|
-
*
|
|
1591
|
-
* reason. Detach via the returned unsubscribe function instead of
|
|
1592
|
-
* relying on a `disposed` event.
|
|
1891
|
+
* The owned engine emits `reason: "disposed"` during coordinator teardown.
|
|
1892
|
+
* Callers should still detach through the returned unsubscribe function when
|
|
1893
|
+
* their own lifetime ends before the coordinator's.
|
|
1593
1894
|
*/
|
|
1594
1895
|
onPolicyChange(listener) {
|
|
1595
1896
|
return this.policyEngine.onPolicyChange(listener);
|
|
@@ -1811,6 +2112,8 @@ export class ToolkitCoordinator {
|
|
|
1811
2112
|
* Called after updateToolConfig().
|
|
1812
2113
|
*/
|
|
1813
2114
|
_applyToolConfigChange(toolId, _updates) {
|
|
2115
|
+
if (this.disposePromise !== null)
|
|
2116
|
+
return;
|
|
1814
2117
|
// Apply configuration changes based on tool
|
|
1815
2118
|
switch (toolId) {
|
|
1816
2119
|
case "textToSpeech": {
|
|
@@ -1822,6 +2125,8 @@ export class ToolkitCoordinator {
|
|
|
1822
2125
|
}
|
|
1823
2126
|
});
|
|
1824
2127
|
void reconfigurePromise.then(async () => {
|
|
2128
|
+
if (this.disposePromise !== null)
|
|
2129
|
+
return;
|
|
1825
2130
|
const ttsConfig = this.getTTSConfigFromProviders();
|
|
1826
2131
|
if (!this.lazyInit && ttsConfig?.enabled !== false) {
|
|
1827
2132
|
await this.ensureTTSReady(ttsConfig);
|
|
@@ -1847,6 +2152,8 @@ export class ToolkitCoordinator {
|
|
|
1847
2152
|
if (this.toolProviderRegistry.has("tts")) {
|
|
1848
2153
|
await this.toolProviderRegistry.unregister("tts");
|
|
1849
2154
|
}
|
|
2155
|
+
if (this.disposePromise !== null)
|
|
2156
|
+
return;
|
|
1850
2157
|
const ttsRegistration = this.getProviderDescriptorTools().find((tool) => tool.toolId === "textToSpeech");
|
|
1851
2158
|
if (!ttsRegistration)
|
|
1852
2159
|
return;
|