@xmanrui/dsh-im 4.19.1 → 4.20.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/README.en.md +96 -4
- package/README.md +96 -4
- package/lib/client.js +666 -217
- package/lib/index.js +277 -273
- package/package.json +13 -1
- package/plugin-src/client/channels/weixin/api.js +35 -17
- package/plugin-src/client/channels/weixin/connection-error.js +68 -0
- package/plugin-src/client/channels/weixin/index.js +36 -12
- package/plugin-src/client/i18n.js +2 -0
- package/plugin-src/client/index.js +15 -0
- package/plugin-src/client/interface-language.js +89 -0
- package/plugin-src/host/channels/shared/startup.mjs +30 -4
- package/plugin-src/host/channels/weixin/connection-supervisor.mjs +13 -1
- package/plugin-src/host/channels/weixin/index.mjs +12 -3
- package/plugin-src/host/channels/weixin/production.mjs +53 -3
- package/plugin-src/host/channels/weixin/rpc.mjs +22 -17
- package/plugin-src/host/host-language-rpc.mjs +71 -0
- package/plugin-src/host/host-language.mjs +157 -0
- package/plugin-src/host/index.mjs +15 -2
- package/scripts/verify-interface-language.mjs +333 -0
- package/src/channels/dingtalk/dingtalk-bridge.mjs +4 -1
- package/src/channels/dingtalk/dingtalk-menu.mjs +8 -4
- package/src/channels/discord/discord-runtime.mjs +1 -1
- package/src/channels/feishu/bridge.mjs +44 -13
- package/src/channels/qq/qq-bridge.mjs +12 -4
- package/src/channels/qq/qq-menu.mjs +11 -8
- package/src/channels/shared/bot-workspace-store.mjs +532 -40
- package/src/channels/shared/command-catalog.mjs +5 -0
- package/src/channels/shared/compact-command.mjs +14 -4
- package/src/channels/shared/control-command.mjs +1 -1
- package/src/channels/shared/deferred-delivery-coordinator.mjs +1 -1
- package/src/channels/shared/history-command.mjs +1 -1
- package/src/channels/shared/i18n-en/discord.mjs +2 -0
- package/src/channels/shared/i18n-en/shared-a.mjs +41 -0
- package/src/channels/shared/i18n-en/telegram.mjs +5 -0
- package/src/channels/shared/i18n-en/weixin.mjs +2 -0
- package/src/channels/shared/i18n.mjs +46 -3
- package/src/channels/shared/interface-language-store.mjs +127 -0
- package/src/channels/shared/interface-language.mjs +51 -0
- package/src/channels/shared/model-command.mjs +5 -3
- package/src/channels/shared/token-bot-controller.mjs +26 -0
- package/src/channels/shared/workspace-command.mjs +114 -9
- package/src/channels/shared/workspace-session.mjs +55 -5
- package/src/channels/telegram/telegram-runtime.mjs +76 -14
- package/src/channels/wecom/wecom-bridge.mjs +2 -2
- package/src/channels/weixin/connection-error.en.mjs +116 -0
- package/src/channels/weixin/connection-error.mjs +204 -0
- package/src/channels/weixin/diagnostic-details.mjs +40 -0
- package/src/channels/weixin/state-store.mjs +4 -3
- package/src/channels/weixin/weixin-api.mjs +20 -8
- package/src/channels/weixin/weixin-bridge.mjs +3 -2
- package/src/channels/weixin/weixin-controller.mjs +133 -104
- package/src/channels/weixin/weixin-runtime.mjs +35 -24
|
@@ -102,6 +102,35 @@ function conversationKeyOf(value) {
|
|
|
102
102
|
return value;
|
|
103
103
|
}
|
|
104
104
|
|
|
105
|
+
function conversationWorkspaceGenerationKey(botId, conversationKey) {
|
|
106
|
+
// Conversation keys validated by conversationKeyOf never contain the null
|
|
107
|
+
// separator, so the compound key stays unambiguous.
|
|
108
|
+
return `${botIdOf(botId)}\u0000${conversationKeyOf(conversationKey)}`;
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
function normalizeConversationWorkspaces(value) {
|
|
112
|
+
const conversationWorkspaces = Object.create(null);
|
|
113
|
+
if (value === undefined) return conversationWorkspaces;
|
|
114
|
+
// Override damage is isolated: an invalid entry is dropped rather than
|
|
115
|
+
// failing the whole document, because a missing/invalid override falls back
|
|
116
|
+
// to the bot workspace safely.
|
|
117
|
+
if (!value || typeof value !== 'object' || Array.isArray(value)) {
|
|
118
|
+
return conversationWorkspaces;
|
|
119
|
+
}
|
|
120
|
+
for (const [botId, overrides] of Object.entries(value)) {
|
|
121
|
+
if (!/^[A-Za-z0-9_-]{1,128}$/.test(botId)) continue;
|
|
122
|
+
if (!overrides || typeof overrides !== 'object' || Array.isArray(overrides)) continue;
|
|
123
|
+
const normalized = Object.create(null);
|
|
124
|
+
for (const [conversationKey, workspace] of Object.entries(overrides)) {
|
|
125
|
+
if (typeof conversationKey !== 'string' || !conversationKey
|
|
126
|
+
|| typeof workspace !== 'string' || !isAbsolute(workspace)) continue;
|
|
127
|
+
normalized[conversationKey] = resolve(workspace);
|
|
128
|
+
}
|
|
129
|
+
if (Object.keys(normalized).length > 0) conversationWorkspaces[botId] = normalized;
|
|
130
|
+
}
|
|
131
|
+
return conversationWorkspaces;
|
|
132
|
+
}
|
|
133
|
+
|
|
105
134
|
function normalizeDeliveryTarget(value, { targetId, allowSessionSync = false } = {}) {
|
|
106
135
|
if (!value || typeof value !== 'object' || Array.isArray(value)) {
|
|
107
136
|
throw deliveryTargetError('invalid-target', 'Invalid delivery target');
|
|
@@ -221,6 +250,7 @@ function normalizeDocument(value) {
|
|
|
221
250
|
|| typeof workspace !== 'string' || !isAbsolute(workspace)) return null;
|
|
222
251
|
workspaces[botId] = resolve(workspace);
|
|
223
252
|
}
|
|
253
|
+
const conversationWorkspaces = normalizeConversationWorkspaces(value.conversationWorkspaces);
|
|
224
254
|
let agentPresets = {};
|
|
225
255
|
if (value.agentPresets !== undefined) {
|
|
226
256
|
if (!value.agentPresets || typeof value.agentPresets !== 'object'
|
|
@@ -285,6 +315,7 @@ function normalizeDocument(value) {
|
|
|
285
315
|
// recovered from an interrupted/manual edit, retain it on the next write.
|
|
286
316
|
version,
|
|
287
317
|
workspaces,
|
|
318
|
+
conversationWorkspaces,
|
|
288
319
|
agentPresets,
|
|
289
320
|
models,
|
|
290
321
|
contextEnhancement,
|
|
@@ -297,6 +328,7 @@ function normalizeDocument(value) {
|
|
|
297
328
|
function storedDocument({
|
|
298
329
|
version,
|
|
299
330
|
workspaces,
|
|
331
|
+
conversationWorkspaces,
|
|
300
332
|
agentPresets,
|
|
301
333
|
models,
|
|
302
334
|
contextEnhancement,
|
|
@@ -306,6 +338,9 @@ function storedDocument({
|
|
|
306
338
|
}) {
|
|
307
339
|
const document = { version, workspaces };
|
|
308
340
|
if (Object.keys(aliases).length > 0) document.aliases = aliases;
|
|
341
|
+
if (Object.keys(conversationWorkspaces).length > 0) {
|
|
342
|
+
document.conversationWorkspaces = conversationWorkspaces;
|
|
343
|
+
}
|
|
309
344
|
if (Object.keys(agentPresets).length > 0) document.agentPresets = agentPresets;
|
|
310
345
|
if (Object.keys(models).length > 0) document.models = models;
|
|
311
346
|
if (Object.keys(contextEnhancement).length > 0) {
|
|
@@ -364,8 +399,11 @@ export class BotWorkspaceStore {
|
|
|
364
399
|
#contextEnhancement = {};
|
|
365
400
|
#deliveryTargets = Object.create(null);
|
|
366
401
|
#accessPolicies = Object.create(null);
|
|
402
|
+
#conversationWorkspaces = Object.create(null);
|
|
367
403
|
#generations = new Map();
|
|
368
404
|
#nextGeneration = 1;
|
|
405
|
+
#conversationGenerations = new Map();
|
|
406
|
+
#nextConversationGeneration = 1;
|
|
369
407
|
#incarnations = new Map();
|
|
370
408
|
#nextIncarnation = 1;
|
|
371
409
|
#removals = new Map();
|
|
@@ -392,6 +430,7 @@ export class BotWorkspaceStore {
|
|
|
392
430
|
this.#contextEnhancement = normalized.contextEnhancement;
|
|
393
431
|
this.#deliveryTargets = normalized.deliveryTargets;
|
|
394
432
|
this.#accessPolicies = normalized.accessPolicies;
|
|
433
|
+
this.#conversationWorkspaces = normalized.conversationWorkspaces;
|
|
395
434
|
} catch (error) {
|
|
396
435
|
if (error?.code !== 'ENOENT') throw error;
|
|
397
436
|
this.#version = 1;
|
|
@@ -402,9 +441,12 @@ export class BotWorkspaceStore {
|
|
|
402
441
|
this.#contextEnhancement = {};
|
|
403
442
|
this.#deliveryTargets = Object.create(null);
|
|
404
443
|
this.#accessPolicies = Object.create(null);
|
|
444
|
+
this.#conversationWorkspaces = Object.create(null);
|
|
405
445
|
}
|
|
406
446
|
this.#generations.clear();
|
|
407
447
|
this.#nextGeneration = 1;
|
|
448
|
+
this.#conversationGenerations.clear();
|
|
449
|
+
this.#nextConversationGeneration = 1;
|
|
408
450
|
this.#incarnations.clear();
|
|
409
451
|
this.#nextIncarnation = 1;
|
|
410
452
|
this.#removals.clear();
|
|
@@ -435,6 +477,30 @@ export class BotWorkspaceStore {
|
|
|
435
477
|
return this.#workspaces[botIdOf(botId)] ?? this.#defaultWorkspace;
|
|
436
478
|
}
|
|
437
479
|
|
|
480
|
+
/**
|
|
481
|
+
* An explicit override is stored even when it equals the current bot default,
|
|
482
|
+
* so a later default change must never move a conversation that pinned its
|
|
483
|
+
* own workspace.
|
|
484
|
+
*/
|
|
485
|
+
hasConversationWorkspaceOverride(botId, conversationKey) {
|
|
486
|
+
const id = botIdOf(botId);
|
|
487
|
+
return Boolean(conversationKey) && Boolean(this.#conversationWorkspaces[id]?.[conversationKey]);
|
|
488
|
+
}
|
|
489
|
+
|
|
490
|
+
conversationWorkspaceFor(botId, conversationKey) {
|
|
491
|
+
const id = botIdOf(botId);
|
|
492
|
+
const override = this.#conversationWorkspaces[id]?.[conversationKey];
|
|
493
|
+
if (override) return override;
|
|
494
|
+
return this.workspaceFor(id);
|
|
495
|
+
}
|
|
496
|
+
|
|
497
|
+
conversationGenerationFor(botId, conversationKey) {
|
|
498
|
+
if (typeof conversationKey !== 'string' || !conversationKey) return null;
|
|
499
|
+
return this.#conversationGenerations.get(
|
|
500
|
+
conversationWorkspaceGenerationKey(botId, conversationKey),
|
|
501
|
+
) ?? null;
|
|
502
|
+
}
|
|
503
|
+
|
|
438
504
|
agentPresetFor(botId) {
|
|
439
505
|
return this.#agentPresets[botIdOf(botId)] ?? null;
|
|
440
506
|
}
|
|
@@ -705,6 +771,30 @@ export class BotWorkspaceStore {
|
|
|
705
771
|
});
|
|
706
772
|
}
|
|
707
773
|
|
|
774
|
+
async setConversationWorkspace(botId, conversationKey, value, {
|
|
775
|
+
clearSession,
|
|
776
|
+
incarnation,
|
|
777
|
+
} = {}) {
|
|
778
|
+
const id = botIdOf(botId);
|
|
779
|
+
if (typeof conversationKey !== 'string' || !conversationKey
|
|
780
|
+
|| conversationKey.length > 1_024 || conversationKey.trim() !== conversationKey
|
|
781
|
+
|| /[\u0000-\u001f\u007f]/u.test(conversationKey)) {
|
|
782
|
+
throw new TypeError('conversationKey is required');
|
|
783
|
+
}
|
|
784
|
+
if (!this.has(id)
|
|
785
|
+
|| (incarnation !== undefined && incarnation !== this.incarnationFor(id))) {
|
|
786
|
+
const error = new Error('找不到要修改的机器人。');
|
|
787
|
+
error.code = 'workspace-bot-not-found';
|
|
788
|
+
throw error;
|
|
789
|
+
}
|
|
790
|
+
const token = this.publishConversationWorkspaceSwitch(id, conversationKey);
|
|
791
|
+
return this.applyConversationWorkspaceSwitch(id, conversationKey, value, {
|
|
792
|
+
token,
|
|
793
|
+
clearSession,
|
|
794
|
+
incarnation,
|
|
795
|
+
});
|
|
796
|
+
}
|
|
797
|
+
|
|
708
798
|
async setAgentPreset(botId, value, { incarnation } = {}) {
|
|
709
799
|
const id = botIdOf(botId);
|
|
710
800
|
if (!this.has(id)
|
|
@@ -829,13 +919,124 @@ export class BotWorkspaceStore {
|
|
|
829
919
|
});
|
|
830
920
|
}
|
|
831
921
|
|
|
922
|
+
/**
|
|
923
|
+
* Publish the fence for a conversation-level switch before its asynchronous
|
|
924
|
+
* work starts. Every session that was resolved for this conversation is
|
|
925
|
+
* invalidated from this moment on: a bind or prompt already in flight must
|
|
926
|
+
* not keep running (or be written back) in the workspace being left behind.
|
|
927
|
+
* Returns the opaque token that applyConversationWorkspaceSwitch requires, so
|
|
928
|
+
* two overlapping switches cannot adopt each other's fence.
|
|
929
|
+
*/
|
|
930
|
+
publishConversationWorkspaceSwitch(botId, conversationKey) {
|
|
931
|
+
const id = botIdOf(botId);
|
|
932
|
+
conversationKeyOf(conversationKey);
|
|
933
|
+
const token = this.#freshConversationGeneration();
|
|
934
|
+
this.#conversationGenerations.set(
|
|
935
|
+
conversationWorkspaceGenerationKey(id, conversationKey),
|
|
936
|
+
token,
|
|
937
|
+
);
|
|
938
|
+
return token;
|
|
939
|
+
}
|
|
940
|
+
|
|
941
|
+
/** True while this token is still the current fence for the conversation. */
|
|
942
|
+
isConversationWorkspaceSwitchCurrent(botId, conversationKey, token) {
|
|
943
|
+
const id = botIdOf(botId);
|
|
944
|
+
conversationKeyOf(conversationKey);
|
|
945
|
+
return this.#conversationGenerations.get(
|
|
946
|
+
conversationWorkspaceGenerationKey(id, conversationKey),
|
|
947
|
+
) === token;
|
|
948
|
+
}
|
|
949
|
+
|
|
950
|
+
async applyConversationWorkspaceSwitch(botId, conversationKey, value, {
|
|
951
|
+
token,
|
|
952
|
+
clearSession,
|
|
953
|
+
sessionMatchesWorkspace,
|
|
954
|
+
incarnation,
|
|
955
|
+
} = {}) {
|
|
956
|
+
const id = botIdOf(botId);
|
|
957
|
+
const key = conversationKeyOf(conversationKey);
|
|
958
|
+
if (typeof token !== 'number') throw new TypeError('token is required');
|
|
959
|
+
if (!this.has(id)
|
|
960
|
+
|| (incarnation !== undefined && incarnation !== this.incarnationFor(id))) {
|
|
961
|
+
const error = new Error('找不到要修改的机器人。');
|
|
962
|
+
error.code = 'workspace-bot-not-found';
|
|
963
|
+
throw error;
|
|
964
|
+
}
|
|
965
|
+
// Validate before queueing so an invalid path fails without disturbing the
|
|
966
|
+
// conversation's current workspace or its session.
|
|
967
|
+
const workspace = value === null ? null : await validateWorkspacePath(value);
|
|
968
|
+
return this.#enqueue(id, async () => {
|
|
969
|
+
const assertCurrentSwitch = () => {
|
|
970
|
+
if (!this.has(id)
|
|
971
|
+
|| (incarnation !== undefined && incarnation !== this.incarnationFor(id))) {
|
|
972
|
+
const error = new Error('找不到要修改的机器人。');
|
|
973
|
+
error.code = 'workspace-bot-not-found';
|
|
974
|
+
throw error;
|
|
975
|
+
}
|
|
976
|
+
if (!this.isConversationWorkspaceSwitchCurrent(id, key, token)) {
|
|
977
|
+
throw workspaceSessionStale(
|
|
978
|
+
'The conversation workspace changed before this switch could be committed.',
|
|
979
|
+
);
|
|
980
|
+
}
|
|
981
|
+
};
|
|
982
|
+
assertCurrentSwitch();
|
|
983
|
+
const previousOverrides = this.#conversationWorkspaces[id];
|
|
984
|
+
const previous = previousOverrides?.[key];
|
|
985
|
+
const hadOverride = Boolean(previousOverrides)
|
|
986
|
+
&& Object.hasOwn(previousOverrides, key);
|
|
987
|
+
if (workspace === null ? !hadOverride : (hadOverride && previous === workspace)) {
|
|
988
|
+
// The override may be unchanged while a binding persisted by an older
|
|
989
|
+
// version still points elsewhere. Only preserve a verified matching
|
|
990
|
+
// Session; never acknowledge /conv while keeping a foreign cwd.
|
|
991
|
+
const matches = await sessionMatchesWorkspace?.(this.conversationWorkspaceFor(id, key));
|
|
992
|
+
assertCurrentSwitch();
|
|
993
|
+
if (matches === false) {
|
|
994
|
+
await clearSession?.();
|
|
995
|
+
assertCurrentSwitch();
|
|
996
|
+
}
|
|
997
|
+
return this.conversationWorkspaceFor(id, key);
|
|
998
|
+
}
|
|
999
|
+
const next = workspace === null
|
|
1000
|
+
? (() => {
|
|
1001
|
+
const overrides = { ...previousOverrides };
|
|
1002
|
+
delete overrides[key];
|
|
1003
|
+
return overrides;
|
|
1004
|
+
})()
|
|
1005
|
+
: { ...previousOverrides, [key]: workspace };
|
|
1006
|
+
await clearSession?.();
|
|
1007
|
+
assertCurrentSwitch();
|
|
1008
|
+
if (Object.keys(next).length > 0) this.#conversationWorkspaces[id] = next;
|
|
1009
|
+
else delete this.#conversationWorkspaces[id];
|
|
1010
|
+
try {
|
|
1011
|
+
// Binding a conversation to its own current default is still persisted:
|
|
1012
|
+
// a later bot-default change must not move a conversation that pinned
|
|
1013
|
+
// the workspace it was using.
|
|
1014
|
+
await this.#persist();
|
|
1015
|
+
} catch (error) {
|
|
1016
|
+
if (Object.keys(next).length > 0 || hadOverride) {
|
|
1017
|
+
this.#conversationWorkspaces[id] = {
|
|
1018
|
+
...(previousOverrides ?? {}),
|
|
1019
|
+
...(hadOverride ? { [key]: previous } : {}),
|
|
1020
|
+
};
|
|
1021
|
+
} else {
|
|
1022
|
+
delete this.#conversationWorkspaces[id];
|
|
1023
|
+
}
|
|
1024
|
+
throw error;
|
|
1025
|
+
}
|
|
1026
|
+
return this.conversationWorkspaceFor(id, key);
|
|
1027
|
+
});
|
|
1028
|
+
}
|
|
1029
|
+
|
|
832
1030
|
async bindWorkspaceSession(botId, value, {
|
|
833
1031
|
conversationKey,
|
|
834
1032
|
sessionId,
|
|
835
1033
|
clearSessions,
|
|
1034
|
+
clearSession,
|
|
836
1035
|
setSession,
|
|
1036
|
+
onConversationGeneration,
|
|
837
1037
|
incarnation,
|
|
838
1038
|
expectedGeneration,
|
|
1039
|
+
expectedConversationGeneration,
|
|
839
1040
|
} = {}) {
|
|
840
1041
|
const id = botIdOf(botId);
|
|
841
1042
|
if (typeof conversationKey !== 'string' || !conversationKey
|
|
@@ -851,6 +1052,18 @@ export class BotWorkspaceStore {
|
|
|
851
1052
|
error.code = 'workspace-bot-not-found';
|
|
852
1053
|
throw error;
|
|
853
1054
|
}
|
|
1055
|
+
// Capture before any asynchronous lookup, unless the caller already captured
|
|
1056
|
+
// the fence before adopting the Session.
|
|
1057
|
+
let conversationGeneration = expectedConversationGeneration === undefined
|
|
1058
|
+
? this.conversationGenerationFor(id, conversationKey)
|
|
1059
|
+
: expectedConversationGeneration;
|
|
1060
|
+
const assertConversationCurrent = () => {
|
|
1061
|
+
if (conversationGeneration !== this.conversationGenerationFor(id, conversationKey)) {
|
|
1062
|
+
throw workspaceSessionStale(
|
|
1063
|
+
'The conversation workspace changed before the session binding could be committed.',
|
|
1064
|
+
);
|
|
1065
|
+
}
|
|
1066
|
+
};
|
|
854
1067
|
const workspace = await canonicalWorkspacePath(await validateWorkspacePath(value));
|
|
855
1068
|
return this.#enqueue(id, async () => {
|
|
856
1069
|
if (!this.has(id)
|
|
@@ -865,14 +1078,41 @@ export class BotWorkspaceStore {
|
|
|
865
1078
|
'The bot workspace changed before the session binding could be committed.',
|
|
866
1079
|
);
|
|
867
1080
|
}
|
|
868
|
-
|
|
869
|
-
|
|
1081
|
+
// An explicit session binding is also a conversation-level statement: a
|
|
1082
|
+
// workspace switch queued while the session was being adopted must not be
|
|
1083
|
+
// silently overwritten by the binding that started before it.
|
|
1084
|
+
assertConversationCurrent();
|
|
1085
|
+
const sameWorkspace = await sameWorkspacePath(workspace, this.workspaceFor(id));
|
|
1086
|
+
const previousOverrides = this.#conversationWorkspaces[id];
|
|
1087
|
+
const override = previousOverrides?.[conversationKey];
|
|
1088
|
+
const clearsOverride = override !== undefined && !(await sameWorkspacePath(workspace, override));
|
|
1089
|
+
assertConversationCurrent();
|
|
1090
|
+
if (!sameWorkspace || clearsOverride) {
|
|
1091
|
+
if (sameWorkspace && typeof clearSession !== 'function') {
|
|
1092
|
+
throw new TypeError('clearSession is required to reconcile a conversation workspace');
|
|
1093
|
+
}
|
|
870
1094
|
const previous = this.#workspaces[id];
|
|
871
|
-
|
|
872
|
-
|
|
873
|
-
|
|
874
|
-
|
|
875
|
-
|
|
1095
|
+
if (clearsOverride) {
|
|
1096
|
+
// /session keeps its bot-default semantics, but cannot leave this
|
|
1097
|
+
// conversation pinned to a different cwd from the adopted Session.
|
|
1098
|
+
// Publish the new fence to the scope before any asynchronous work.
|
|
1099
|
+
conversationGeneration = this.publishConversationWorkspaceSwitch(id, conversationKey);
|
|
1100
|
+
onConversationGeneration?.(conversationGeneration);
|
|
1101
|
+
}
|
|
1102
|
+
if (!sameWorkspace) {
|
|
1103
|
+
this.#generations.set(id, this.#freshGeneration());
|
|
1104
|
+
await clearSessions();
|
|
1105
|
+
} else {
|
|
1106
|
+
await clearSession(conversationKey);
|
|
1107
|
+
}
|
|
1108
|
+
assertConversationCurrent();
|
|
1109
|
+
if (!sameWorkspace) this.#workspaces[id] = workspace;
|
|
1110
|
+
if (clearsOverride) {
|
|
1111
|
+
const next = { ...previousOverrides };
|
|
1112
|
+
delete next[conversationKey];
|
|
1113
|
+
if (Object.keys(next).length) this.#conversationWorkspaces[id] = next;
|
|
1114
|
+
else delete this.#conversationWorkspaces[id];
|
|
1115
|
+
}
|
|
876
1116
|
try {
|
|
877
1117
|
await this.#persist();
|
|
878
1118
|
} catch (error) {
|
|
@@ -880,17 +1120,21 @@ export class BotWorkspaceStore {
|
|
|
880
1120
|
// fenced. Restoring either could pair an old session with a state
|
|
881
1121
|
// transition whose durable outcome is unknown.
|
|
882
1122
|
this.#workspaces[id] = previous;
|
|
1123
|
+
if (clearsOverride) this.#conversationWorkspaces[id] = previousOverrides;
|
|
883
1124
|
throw error;
|
|
884
1125
|
}
|
|
885
1126
|
}
|
|
886
1127
|
|
|
887
1128
|
// This write remains inside the same bot transition as the workspace
|
|
888
1129
|
// mutation, so another switch or bind cannot interleave between them.
|
|
1130
|
+
assertConversationCurrent();
|
|
889
1131
|
await setSession(conversationKey, sessionId);
|
|
1132
|
+
assertConversationCurrent();
|
|
890
1133
|
return {
|
|
891
1134
|
workspace,
|
|
892
1135
|
sessionId,
|
|
893
1136
|
generation: this.#generations.get(id),
|
|
1137
|
+
conversationGeneration,
|
|
894
1138
|
};
|
|
895
1139
|
});
|
|
896
1140
|
}
|
|
@@ -991,6 +1235,7 @@ export class BotWorkspaceStore {
|
|
|
991
1235
|
...Object.keys(this.#contextEnhancement),
|
|
992
1236
|
...Object.keys(this.#deliveryTargets),
|
|
993
1237
|
...Object.keys(this.#accessPolicies),
|
|
1238
|
+
...Object.keys(this.#conversationWorkspaces),
|
|
994
1239
|
...this.#dirtyRemovals,
|
|
995
1240
|
]);
|
|
996
1241
|
for (const botId of candidates) {
|
|
@@ -1028,6 +1273,12 @@ export class BotWorkspaceStore {
|
|
|
1028
1273
|
return incarnation;
|
|
1029
1274
|
}
|
|
1030
1275
|
|
|
1276
|
+
#freshConversationGeneration() {
|
|
1277
|
+
const generation = this.#nextConversationGeneration;
|
|
1278
|
+
this.#nextConversationGeneration += 1;
|
|
1279
|
+
return generation;
|
|
1280
|
+
}
|
|
1281
|
+
|
|
1031
1282
|
#removalDetailsFor(transaction) {
|
|
1032
1283
|
if (!transaction || typeof transaction !== 'object') {
|
|
1033
1284
|
throw new TypeError('Invalid workspace removal transaction');
|
|
@@ -1045,8 +1296,10 @@ export class BotWorkspaceStore {
|
|
|
1045
1296
|
const hadContextEnhancement = Object.hasOwn(this.#contextEnhancement, id);
|
|
1046
1297
|
const hadDeliveryTargets = Object.hasOwn(this.#deliveryTargets, id);
|
|
1047
1298
|
const hadAccessPolicy = Object.hasOwn(this.#accessPolicies, id);
|
|
1299
|
+
const hadConversationWorkspaces = Object.hasOwn(this.#conversationWorkspaces, id);
|
|
1048
1300
|
const needsCleanup = hadWorkspace || hadPreset || hadModel || hadAlias || hadContextEnhancement
|
|
1049
|
-
|| hadDeliveryTargets || hadAccessPolicy ||
|
|
1301
|
+
|| hadDeliveryTargets || hadAccessPolicy || hadConversationWorkspaces
|
|
1302
|
+
|| this.#dirtyRemovals.has(id);
|
|
1050
1303
|
delete this.#workspaces[id];
|
|
1051
1304
|
delete this.#agentPresets[id];
|
|
1052
1305
|
delete this.#models[id];
|
|
@@ -1054,6 +1307,7 @@ export class BotWorkspaceStore {
|
|
|
1054
1307
|
delete this.#contextEnhancement[id];
|
|
1055
1308
|
delete this.#deliveryTargets[id];
|
|
1056
1309
|
delete this.#accessPolicies[id];
|
|
1310
|
+
delete this.#conversationWorkspaces[id];
|
|
1057
1311
|
this.#generations.delete(id);
|
|
1058
1312
|
this.#incarnations.delete(id);
|
|
1059
1313
|
if (!needsCleanup) return {
|
|
@@ -1089,10 +1343,12 @@ export class BotWorkspaceStore {
|
|
|
1089
1343
|
version = this.#version,
|
|
1090
1344
|
accessPolicies = this.#accessPolicies,
|
|
1091
1345
|
aliases = this.#aliases,
|
|
1346
|
+
conversationWorkspaces = this.#conversationWorkspaces,
|
|
1092
1347
|
) {
|
|
1093
1348
|
await writeStoredDocument(this.#path, storedDocument({
|
|
1094
1349
|
version,
|
|
1095
1350
|
workspaces: this.#workspaces,
|
|
1351
|
+
conversationWorkspaces,
|
|
1096
1352
|
agentPresets: this.#agentPresets,
|
|
1097
1353
|
models: this.#models,
|
|
1098
1354
|
contextEnhancement,
|
|
@@ -1110,7 +1366,8 @@ export class BotWorkspaceStore {
|
|
|
1110
1366
|
|| Object.keys(this.#aliases).length > 0
|
|
1111
1367
|
|| Object.keys(this.#contextEnhancement).length > 0
|
|
1112
1368
|
|| Object.keys(this.#deliveryTargets).length > 0
|
|
1113
|
-
|| Object.keys(this.#accessPolicies).length > 0
|
|
1369
|
+
|| Object.keys(this.#accessPolicies).length > 0
|
|
1370
|
+
|| Object.keys(this.#conversationWorkspaces).length > 0) {
|
|
1114
1371
|
await this.#persist();
|
|
1115
1372
|
return;
|
|
1116
1373
|
}
|
|
@@ -1246,6 +1503,107 @@ export function createBotWorkspaceScope(
|
|
|
1246
1503
|
};
|
|
1247
1504
|
};
|
|
1248
1505
|
const sessionGenerations = new Map();
|
|
1506
|
+
// A conversation-level switch/clear publishes an opaque mask token before it
|
|
1507
|
+
// starts its asynchronous work, so a bind or prompt that was already resolved
|
|
1508
|
+
// for that conversation is rejected instead of running in the old workspace.
|
|
1509
|
+
// The store keeps the same token as the conversation's generation, so the
|
|
1510
|
+
// scope marker and the durable generation never disagree.
|
|
1511
|
+
const conversationSwitchMasks = new Map();
|
|
1512
|
+
// The in-flight switch of each conversation, so a message that starts while
|
|
1513
|
+
// /conv is still committing waits for the new workspace instead of resolving
|
|
1514
|
+
// a session in the old one.
|
|
1515
|
+
const pendingConversationSwitches = new Map();
|
|
1516
|
+
|
|
1517
|
+
function trackConversationSwitch(conversationKey, promise) {
|
|
1518
|
+
pendingConversationSwitches.set(conversationKey, promise);
|
|
1519
|
+
return promise.finally(() => {
|
|
1520
|
+
if (pendingConversationSwitches.get(conversationKey) === promise) {
|
|
1521
|
+
pendingConversationSwitches.delete(conversationKey);
|
|
1522
|
+
if (isCurrentScope()) refreshRetainedSession(conversationKey);
|
|
1523
|
+
}
|
|
1524
|
+
});
|
|
1525
|
+
}
|
|
1526
|
+
|
|
1527
|
+
function currentConversationGeneration(conversationKey) {
|
|
1528
|
+
// The store reports "no override recorded yet" as null; map it to an opaque
|
|
1529
|
+
// token so "before the first switch" is still a comparable state.
|
|
1530
|
+
const generation = workspaces.conversationGenerationFor(botId, conversationKey);
|
|
1531
|
+
return generation ?? 0;
|
|
1532
|
+
}
|
|
1533
|
+
|
|
1534
|
+
function maskConversationSwitch(conversationKey, token) {
|
|
1535
|
+
if (!conversationKey) return;
|
|
1536
|
+
conversationSwitchMasks.set(
|
|
1537
|
+
conversationKey,
|
|
1538
|
+
token === undefined ? currentConversationGeneration(conversationKey) : token,
|
|
1539
|
+
);
|
|
1540
|
+
}
|
|
1541
|
+
|
|
1542
|
+
function conversationGenerationIsStale(conversationKey, expected) {
|
|
1543
|
+
if (typeof conversationKey !== 'string' || !conversationKey) return false;
|
|
1544
|
+
const mask = conversationSwitchMasks.get(conversationKey);
|
|
1545
|
+
if (mask !== undefined && mask !== currentConversationGeneration(conversationKey)) {
|
|
1546
|
+
return true;
|
|
1547
|
+
}
|
|
1548
|
+
if (expected === undefined) return false;
|
|
1549
|
+
return expected !== currentConversationGeneration(conversationKey);
|
|
1550
|
+
}
|
|
1551
|
+
|
|
1552
|
+
function generationIsStale(entry, conversationKey = entry?.conversationKey) {
|
|
1553
|
+
return Boolean(entry)
|
|
1554
|
+
&& ((entry.generation !== undefined && entry.generation !== workspaces.generationFor(botId))
|
|
1555
|
+
|| (entry.conversationKey === conversationKey
|
|
1556
|
+
&& conversationGenerationIsStale(conversationKey, entry.conversationGeneration)));
|
|
1557
|
+
}
|
|
1558
|
+
|
|
1559
|
+
/**
|
|
1560
|
+
* Refresh provenance only for a mapping that survived an unchanged/failed
|
|
1561
|
+
* switch. Removed mappings and pending creates retain their old fence, so a
|
|
1562
|
+
* delayed setSession cannot resurrect them after the switch finishes.
|
|
1563
|
+
*/
|
|
1564
|
+
function refreshRetainedSession(conversationKey) {
|
|
1565
|
+
const sessionId = state.sessionFor?.(conversationKey);
|
|
1566
|
+
const entry = sessionGenerations.get(sessionId);
|
|
1567
|
+
if (entry?.conversationKey === conversationKey
|
|
1568
|
+
&& entry.generation === workspaces.generationFor(botId)) {
|
|
1569
|
+
sessionGenerations.set(sessionId, {
|
|
1570
|
+
...entry,
|
|
1571
|
+
conversationGeneration: currentConversationGeneration(conversationKey),
|
|
1572
|
+
});
|
|
1573
|
+
}
|
|
1574
|
+
}
|
|
1575
|
+
|
|
1576
|
+
async function conversationSessionMatchesWorkspace(conversationKey, workspace) {
|
|
1577
|
+
const sessionId = state.sessionFor?.(conversationKey);
|
|
1578
|
+
if (!sessionId) return true;
|
|
1579
|
+
let sessionWorkspace = sessionGenerations.get(sessionId)?.workspace;
|
|
1580
|
+
let matches = false;
|
|
1581
|
+
if (!sessionWorkspace && typeof harness.rpc === 'function') {
|
|
1582
|
+
// Read registration metadata only: adopting a Session is not a lookup.
|
|
1583
|
+
// Resolve by id before comparing real paths so symlink pins remain valid.
|
|
1584
|
+
const listed = await harness.rpc('workspace.list', {}, 30_000);
|
|
1585
|
+
if (!Array.isArray(listed?.items)) {
|
|
1586
|
+
throw new TypeError('Harness returned an invalid workspace list');
|
|
1587
|
+
}
|
|
1588
|
+
const owners = listed.items.filter((item) => Array.isArray(item?.sessionIds)
|
|
1589
|
+
&& item.sessionIds.includes(sessionId));
|
|
1590
|
+
if (owners.length === 1 && typeof owners[0].path === 'string' && isAbsolute(owners[0].path)) {
|
|
1591
|
+
sessionWorkspace = owners[0].path;
|
|
1592
|
+
}
|
|
1593
|
+
} else if (!sessionWorkspace && typeof harness.listWorkspaceSessions === 'function') {
|
|
1594
|
+
const listed = await harness.listWorkspaceSessions(await canonicalWorkspacePath(workspace));
|
|
1595
|
+
if (!Array.isArray(listed?.sessions)) {
|
|
1596
|
+
throw new TypeError('Harness returned an invalid workspace session list');
|
|
1597
|
+
}
|
|
1598
|
+
matches = listed.sessions.some((session) => session?.sessionId === sessionId);
|
|
1599
|
+
}
|
|
1600
|
+
if (sessionWorkspace) matches = await sameWorkspacePath(sessionWorkspace, workspace);
|
|
1601
|
+
if (state.sessionFor(conversationKey) !== sessionId) {
|
|
1602
|
+
throw workspaceSessionStale('The session binding changed while its workspace was being checked.');
|
|
1603
|
+
}
|
|
1604
|
+
return matches;
|
|
1605
|
+
}
|
|
1606
|
+
|
|
1249
1607
|
const scopedHarness = new Proxy(harness, {
|
|
1250
1608
|
get(target, property) {
|
|
1251
1609
|
if (property === 'agentPresetSettings') {
|
|
@@ -1341,6 +1699,73 @@ export function createBotWorkspaceScope(
|
|
|
1341
1699
|
});
|
|
1342
1700
|
};
|
|
1343
1701
|
}
|
|
1702
|
+
if (property === 'currentConversationWorkspace') {
|
|
1703
|
+
return (conversationKey) => {
|
|
1704
|
+
if (!isCurrentScope()) {
|
|
1705
|
+
const error = new Error('找不到要修改的机器人。');
|
|
1706
|
+
error.code = 'workspace-bot-not-found';
|
|
1707
|
+
throw error;
|
|
1708
|
+
}
|
|
1709
|
+
return workspaces.conversationWorkspaceFor(botId, conversationKey);
|
|
1710
|
+
};
|
|
1711
|
+
}
|
|
1712
|
+
if (property === 'hasConversationWorkspaceOverride') {
|
|
1713
|
+
return (conversationKey) => {
|
|
1714
|
+
assertCurrentBotScope(isCurrentScope);
|
|
1715
|
+
return workspaces.hasConversationWorkspaceOverride(botId, conversationKey);
|
|
1716
|
+
};
|
|
1717
|
+
}
|
|
1718
|
+
if (property === 'pendingConversationWorkspaceSwitch') {
|
|
1719
|
+
// Read-only: callers in the message path wait for a switch that is
|
|
1720
|
+
// still committing before they resolve a session for this conversation.
|
|
1721
|
+
return (conversationKey) => pendingConversationSwitches.get(conversationKey) ?? null;
|
|
1722
|
+
}
|
|
1723
|
+
if (property === 'conversationWorkspaceGeneration') {
|
|
1724
|
+
// Read-only fence token for the conversation's effective workspace.
|
|
1725
|
+
// Callers outside this scope (message bridging) compare it across the
|
|
1726
|
+
// bind and the send so a late switch cannot be outrun.
|
|
1727
|
+
return (conversationKey) => currentConversationGeneration(conversationKey);
|
|
1728
|
+
}
|
|
1729
|
+
if (property === 'switchConversationWorkspace') {
|
|
1730
|
+
return (conversationKey, workspace) => {
|
|
1731
|
+
if (!isCurrentScope()) {
|
|
1732
|
+
const error = new Error('找不到要修改的机器人。');
|
|
1733
|
+
error.code = 'workspace-bot-not-found';
|
|
1734
|
+
return Promise.reject(error);
|
|
1735
|
+
}
|
|
1736
|
+
const token = workspaces.publishConversationWorkspaceSwitch(botId, conversationKey);
|
|
1737
|
+
maskConversationSwitch(conversationKey, token);
|
|
1738
|
+
return trackConversationSwitch(conversationKey, workspaces.applyConversationWorkspaceSwitch(botId, conversationKey, workspace, {
|
|
1739
|
+
token,
|
|
1740
|
+
sessionMatchesWorkspace: (selected) => conversationSessionMatchesWorkspace(conversationKey, selected),
|
|
1741
|
+
clearSession: async () => {
|
|
1742
|
+
await state.clearSession(conversationKey);
|
|
1743
|
+
// A handle resolved before this switch must not stay usable: the
|
|
1744
|
+
// conversation now belongs to another workspace.
|
|
1745
|
+
},
|
|
1746
|
+
incarnation,
|
|
1747
|
+
}));
|
|
1748
|
+
};
|
|
1749
|
+
}
|
|
1750
|
+
if (property === 'clearConversationWorkspace') {
|
|
1751
|
+
return (conversationKey) => {
|
|
1752
|
+
if (!isCurrentScope()) {
|
|
1753
|
+
const error = new Error('找不到要修改的机器人。');
|
|
1754
|
+
error.code = 'workspace-bot-not-found';
|
|
1755
|
+
return Promise.reject(error);
|
|
1756
|
+
}
|
|
1757
|
+
const token = workspaces.publishConversationWorkspaceSwitch(botId, conversationKey);
|
|
1758
|
+
maskConversationSwitch(conversationKey, token);
|
|
1759
|
+
return trackConversationSwitch(conversationKey, workspaces.applyConversationWorkspaceSwitch(botId, conversationKey, null, {
|
|
1760
|
+
token,
|
|
1761
|
+
sessionMatchesWorkspace: (selected) => conversationSessionMatchesWorkspace(conversationKey, selected),
|
|
1762
|
+
clearSession: async () => {
|
|
1763
|
+
await state.clearSession(conversationKey);
|
|
1764
|
+
},
|
|
1765
|
+
incarnation,
|
|
1766
|
+
}));
|
|
1767
|
+
};
|
|
1768
|
+
}
|
|
1344
1769
|
if (property === 'bindWorkspaceSession') {
|
|
1345
1770
|
return async (conversationKey, sessionId) => {
|
|
1346
1771
|
if (typeof conversationKey !== 'string' || !conversationKey
|
|
@@ -1356,6 +1781,7 @@ export function createBotWorkspaceScope(
|
|
|
1356
1781
|
throw new TypeError('Harness does not support adopting workspace sessions');
|
|
1357
1782
|
}
|
|
1358
1783
|
const expectedGeneration = workspaces.generationFor(botId);
|
|
1784
|
+
const expectedConversationGeneration = workspaces.conversationGenerationFor(botId, conversationKey);
|
|
1359
1785
|
const adopted = await target.adoptWorkspaceSession(sessionId);
|
|
1360
1786
|
if (!isCurrentScope()) {
|
|
1361
1787
|
const error = new Error('找不到要修改的机器人。');
|
|
@@ -1367,6 +1793,12 @@ export function createBotWorkspaceScope(
|
|
|
1367
1793
|
'The bot workspace changed while the session was being adopted.',
|
|
1368
1794
|
);
|
|
1369
1795
|
}
|
|
1796
|
+
if (expectedConversationGeneration
|
|
1797
|
+
!== workspaces.conversationGenerationFor(botId, conversationKey)) {
|
|
1798
|
+
throw workspaceSessionStale(
|
|
1799
|
+
'The conversation workspace changed while the session was being adopted.',
|
|
1800
|
+
);
|
|
1801
|
+
}
|
|
1370
1802
|
if (!adopted || typeof adopted !== 'object'
|
|
1371
1803
|
|| adopted.sessionId !== sessionId || typeof adopted.workspace !== 'string') {
|
|
1372
1804
|
throw new TypeError('Harness returned an invalid adopted workspace session');
|
|
@@ -1375,21 +1807,31 @@ export function createBotWorkspaceScope(
|
|
|
1375
1807
|
conversationKey,
|
|
1376
1808
|
sessionId,
|
|
1377
1809
|
clearSessions: () => state.clearSessions(),
|
|
1810
|
+
clearSession: (key) => state.clearSession(key),
|
|
1378
1811
|
setSession: (key, selectedSessionId) => state.setSession(key, selectedSessionId),
|
|
1812
|
+
onConversationGeneration: (generation) => maskConversationSwitch(conversationKey, generation),
|
|
1379
1813
|
incarnation,
|
|
1380
1814
|
expectedGeneration,
|
|
1815
|
+
expectedConversationGeneration,
|
|
1381
1816
|
});
|
|
1382
1817
|
if (!isCurrentScope()) {
|
|
1383
1818
|
const error = new Error('找不到要修改的机器人。');
|
|
1384
1819
|
error.code = 'workspace-bot-not-found';
|
|
1385
1820
|
throw error;
|
|
1386
1821
|
}
|
|
1387
|
-
if (bound.generation !== workspaces.generationFor(botId)
|
|
1822
|
+
if (bound.generation !== workspaces.generationFor(botId)
|
|
1823
|
+
|| bound.conversationGeneration
|
|
1824
|
+
!== workspaces.conversationGenerationFor(botId, conversationKey)) {
|
|
1388
1825
|
throw workspaceSessionStale(
|
|
1389
1826
|
'The bot workspace changed before the session binding completed.',
|
|
1390
1827
|
);
|
|
1391
1828
|
}
|
|
1392
|
-
sessionGenerations.set(sessionId,
|
|
1829
|
+
sessionGenerations.set(sessionId, {
|
|
1830
|
+
generation: bound.generation,
|
|
1831
|
+
workspace: bound.workspace,
|
|
1832
|
+
conversationKey,
|
|
1833
|
+
conversationGeneration: bound.conversationGeneration ?? 0,
|
|
1834
|
+
});
|
|
1393
1835
|
return {
|
|
1394
1836
|
...adopted,
|
|
1395
1837
|
workspace: bound.workspace,
|
|
@@ -1399,21 +1841,41 @@ export function createBotWorkspaceScope(
|
|
|
1399
1841
|
}
|
|
1400
1842
|
if (property === 'createSession') {
|
|
1401
1843
|
return async (options = {}) => {
|
|
1402
|
-
const { inheritBotModel = true, ...createOptions } = options;
|
|
1403
|
-
|
|
1844
|
+
const { inheritBotModel = true, conversationKey, ...createOptions } = options;
|
|
1845
|
+
// /conv publishes its generation before path validation enters the
|
|
1846
|
+
// bot queue. Wait for both phases before pairing a workspace with its
|
|
1847
|
+
// generation; otherwise a new session can carry a new fence but old cwd.
|
|
1848
|
+
while (true) {
|
|
1849
|
+
const pendingSwitch = pendingConversationSwitches.get(conversationKey);
|
|
1850
|
+
if (pendingSwitch) await pendingSwitch;
|
|
1851
|
+
await workspaces.whenBotIdle(botId);
|
|
1852
|
+
if (!pendingConversationSwitches.has(conversationKey)) break;
|
|
1853
|
+
}
|
|
1404
1854
|
if (!isCurrentScope()) {
|
|
1405
1855
|
const error = new Error('找不到要修改的机器人。');
|
|
1406
1856
|
error.code = 'workspace-bot-not-found';
|
|
1407
1857
|
throw error;
|
|
1408
1858
|
}
|
|
1409
1859
|
const generation = workspaces.generationFor(botId);
|
|
1860
|
+
const conversationGeneration = conversationKey
|
|
1861
|
+
? currentConversationGeneration(conversationKey)
|
|
1862
|
+
: null;
|
|
1410
1863
|
const agentPreset = workspaces.agentPresetFor(botId);
|
|
1411
1864
|
const model = inheritBotModel === false ? null : workspaces.modelFor(botId);
|
|
1865
|
+
const workspace = workspaces.conversationWorkspaceFor(botId, conversationKey);
|
|
1412
1866
|
const sessionId = await target.createSession({
|
|
1413
1867
|
...createOptions,
|
|
1414
|
-
workspace
|
|
1868
|
+
workspace,
|
|
1415
1869
|
...(agentPreset == null ? {} : { agentPreset }),
|
|
1416
1870
|
});
|
|
1871
|
+
// A conversation-level workspace switch can commit while the session is
|
|
1872
|
+
// being created; never bind a session created in the stale workspace to
|
|
1873
|
+
// a conversation whose override already moved on.
|
|
1874
|
+
if (conversationGenerationIsStale(conversationKey, conversationGeneration)) {
|
|
1875
|
+
throw workspaceSessionStale(
|
|
1876
|
+
'The conversation workspace changed while the session was being created.',
|
|
1877
|
+
);
|
|
1878
|
+
}
|
|
1417
1879
|
if (model) {
|
|
1418
1880
|
if (typeof target.selectSessionModel !== 'function') {
|
|
1419
1881
|
throw new TypeError('Harness does not support model selection');
|
|
@@ -1429,23 +1891,42 @@ export function createBotWorkspaceScope(
|
|
|
1429
1891
|
throw error;
|
|
1430
1892
|
}
|
|
1431
1893
|
}
|
|
1432
|
-
sessionGenerations.set(sessionId,
|
|
1894
|
+
sessionGenerations.set(sessionId, {
|
|
1895
|
+
generation,
|
|
1896
|
+
workspace,
|
|
1897
|
+
conversationKey,
|
|
1898
|
+
conversationGeneration,
|
|
1899
|
+
});
|
|
1433
1900
|
return sessionId;
|
|
1434
1901
|
};
|
|
1435
1902
|
}
|
|
1436
1903
|
if (property === 'workspaceSession') {
|
|
1437
|
-
return (sessionId) => {
|
|
1904
|
+
return (sessionId, sessionConversationKey) => {
|
|
1438
1905
|
if (typeof sessionId !== 'string' || !sessionId) {
|
|
1439
|
-
|
|
1906
|
+
// A caller whose mapping was cleared under it must retry the
|
|
1907
|
+
// resolution instead of crashing on a handle it cannot use.
|
|
1908
|
+
return null;
|
|
1440
1909
|
}
|
|
1441
|
-
const
|
|
1910
|
+
const entry = sessionGenerations.get(sessionId);
|
|
1911
|
+
const generation = entry?.generation
|
|
1442
1912
|
?? workspaces.generationFor(botId);
|
|
1443
|
-
//
|
|
1444
|
-
//
|
|
1445
|
-
//
|
|
1446
|
-
|
|
1913
|
+
// A session handle that names its conversation also fences the
|
|
1914
|
+
// conversation's effective workspace: a switch that starts after the
|
|
1915
|
+
// bind but before the prompt is sent must not run in the old workspace.
|
|
1916
|
+
const conversationKey = typeof sessionConversationKey === 'string'
|
|
1917
|
+
&& sessionConversationKey
|
|
1918
|
+
? sessionConversationKey
|
|
1919
|
+
: entry?.conversationKey ?? null;
|
|
1920
|
+
const conversationGeneration = conversationKey
|
|
1921
|
+
? (entry?.conversationKey === conversationKey
|
|
1922
|
+
? entry.conversationGeneration
|
|
1923
|
+
: currentConversationGeneration(conversationKey))
|
|
1924
|
+
: null;
|
|
1925
|
+
// Copy provenance into the immutable handle, but retain it for a later
|
|
1926
|
+
// setSession (notably /model, which binds after asynchronous selection).
|
|
1447
1927
|
const isCurrentSession = () => isCurrentScope()
|
|
1448
|
-
&& generation === workspaces.generationFor(botId)
|
|
1928
|
+
&& generation === workspaces.generationFor(botId)
|
|
1929
|
+
&& !conversationGenerationIsStale(conversationKey, conversationGeneration);
|
|
1449
1930
|
const invokeCurrentSession = async (method, args, action) => {
|
|
1450
1931
|
if (!isCurrentSession()) {
|
|
1451
1932
|
throw workspaceSessionStale(
|
|
@@ -1506,6 +1987,11 @@ export function createBotWorkspaceScope(
|
|
|
1506
1987
|
steerActiveTurn(...args) {
|
|
1507
1988
|
return invokeStartedSessionMutation('steerActiveTurn', args, 'turn steering');
|
|
1508
1989
|
},
|
|
1990
|
+
...(typeof target.executeCommand === 'function' ? {
|
|
1991
|
+
executeCommand(...args) {
|
|
1992
|
+
return invokeStartedSessionMutation('executeCommand', args, 'command execution');
|
|
1993
|
+
},
|
|
1994
|
+
} : {}),
|
|
1509
1995
|
ask(...args) {
|
|
1510
1996
|
if (!isCurrentSession()) {
|
|
1511
1997
|
throw workspaceSessionStale(
|
|
@@ -1520,9 +2006,8 @@ export function createBotWorkspaceScope(
|
|
|
1520
2006
|
if (property === 'sessionExists') {
|
|
1521
2007
|
return (sessionId, ...args) => {
|
|
1522
2008
|
if (!isCurrentScope()) return false;
|
|
1523
|
-
const
|
|
1524
|
-
if (
|
|
1525
|
-
sessionGenerations.delete(sessionId);
|
|
2009
|
+
const entry = sessionGenerations.get(sessionId);
|
|
2010
|
+
if (generationIsStale(entry)) {
|
|
1526
2011
|
return false;
|
|
1527
2012
|
}
|
|
1528
2013
|
return target.sessionExists(sessionId, ...args);
|
|
@@ -1530,10 +2015,8 @@ export function createBotWorkspaceScope(
|
|
|
1530
2015
|
}
|
|
1531
2016
|
if (property === 'ask') {
|
|
1532
2017
|
return (sessionId, ...args) => {
|
|
1533
|
-
const
|
|
1534
|
-
|
|
1535
|
-
if (!isCurrentScope()
|
|
1536
|
-
|| (generation !== undefined && generation !== workspaces.generationFor(botId))) {
|
|
2018
|
+
const entry = sessionGenerations.get(sessionId);
|
|
2019
|
+
if (!isCurrentScope() || generationIsStale(entry)) {
|
|
1537
2020
|
const error = new Error('The bot workspace changed before this prompt started.');
|
|
1538
2021
|
error.code = WORKSPACE_SESSION_STALE;
|
|
1539
2022
|
throw error;
|
|
@@ -1543,10 +2026,8 @@ export function createBotWorkspaceScope(
|
|
|
1543
2026
|
}
|
|
1544
2027
|
if (property === 'executeCommand' && typeof target.executeCommand === 'function') {
|
|
1545
2028
|
return (sessionId, ...args) => {
|
|
1546
|
-
const
|
|
1547
|
-
|
|
1548
|
-
if (!isCurrentScope()
|
|
1549
|
-
|| (generation !== undefined && generation !== workspaces.generationFor(botId))) {
|
|
2029
|
+
const entry = sessionGenerations.get(sessionId);
|
|
2030
|
+
if (!isCurrentScope() || generationIsStale(entry)) {
|
|
1550
2031
|
const error = new Error('The bot workspace changed before this command started.');
|
|
1551
2032
|
error.code = WORKSPACE_SESSION_STALE;
|
|
1552
2033
|
throw error;
|
|
@@ -1565,18 +2046,29 @@ export function createBotWorkspaceScope(
|
|
|
1565
2046
|
return (key, ...args) => {
|
|
1566
2047
|
if (!isCurrentScope()) return null;
|
|
1567
2048
|
const sessionId = target.sessionFor(key, ...args);
|
|
1568
|
-
if (sessionId
|
|
1569
|
-
sessionGenerations.
|
|
2049
|
+
if (sessionId) {
|
|
2050
|
+
const entry = sessionGenerations.get(sessionId);
|
|
2051
|
+
if (generationIsStale(entry, key)) {
|
|
2052
|
+
// The conversation's effective workspace already moved on, so the
|
|
2053
|
+
// stored mapping must not be treated as a usable session. The
|
|
2054
|
+
// caller re-resolves one in the current workspace instead.
|
|
2055
|
+
return null;
|
|
2056
|
+
}
|
|
2057
|
+
if (!entry) {
|
|
2058
|
+
sessionGenerations.set(sessionId, {
|
|
2059
|
+
generation: workspaces.generationFor(botId),
|
|
2060
|
+
conversationKey: key,
|
|
2061
|
+
conversationGeneration: currentConversationGeneration(key),
|
|
2062
|
+
});
|
|
2063
|
+
}
|
|
1570
2064
|
}
|
|
1571
2065
|
return sessionId;
|
|
1572
2066
|
};
|
|
1573
2067
|
}
|
|
1574
2068
|
if (property === 'setSession') {
|
|
1575
2069
|
return (key, sessionId, ...args) => {
|
|
1576
|
-
const
|
|
1577
|
-
if (!isCurrentScope()
|
|
1578
|
-
|| (generation !== undefined && generation !== workspaces.generationFor(botId))) {
|
|
1579
|
-
sessionGenerations.delete(sessionId);
|
|
2070
|
+
const entry = sessionGenerations.get(sessionId);
|
|
2071
|
+
if (!isCurrentScope() || generationIsStale(entry, key)) {
|
|
1580
2072
|
return false;
|
|
1581
2073
|
}
|
|
1582
2074
|
return target.setSession(key, sessionId, ...args);
|