handmux 0.23.0 → 0.25.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.md +31 -8
- package/README.zh-CN.md +31 -8
- package/bin/handmux.js +14 -688
- package/bin/node-version.js +20 -0
- package/dist/bin/handmux.js +1015 -0
- package/dist/connectors/bridgeClient.js +385 -0
- package/dist/connectors/claude/index.js +476 -0
- package/dist/connectors/pi/bridgeClient.js +8 -0
- package/dist/connectors/pi/index.js +851 -0
- package/dist/hooks/handmux-notify.sh +64 -0
- package/{hooks → dist/hooks}/handmux-statusline.cjs +28 -6
- package/dist/hooks/handmux-write.cjs +264 -0
- package/dist/package.json +127 -0
- package/dist/public/assets/index-CTrqTqtT.css +32 -0
- package/dist/public/assets/index-DmjeMSQA.js +346 -0
- package/dist/public/assets/pcm-worklet-CxcDy7PB.js +1 -0
- package/{public → dist/public}/index.html +5 -4
- package/dist/src/agent-runtime/adapter.js +204 -0
- package/dist/src/agent-runtime/bridge.js +1006 -0
- package/dist/src/agent-runtime/bridgeStore.js +25 -0
- package/dist/src/agent-runtime/bridgeTransport.js +619 -0
- package/dist/src/agent-runtime/bridgeTypes.js +13 -0
- package/dist/src/agent-runtime/builtinRuntime.js +193 -0
- package/dist/src/agent-runtime/conversation.js +2179 -0
- package/dist/src/agent-runtime/conversationActivation.js +75 -0
- package/dist/src/agent-runtime/conversationActivity.js +69 -0
- package/dist/src/agent-runtime/conversationControls.js +206 -0
- package/dist/src/agent-runtime/conversationLiveHub.js +276 -0
- package/dist/src/agent-runtime/conversationStore.js +352 -0
- package/dist/src/agent-runtime/conversationTypes.js +1 -0
- package/dist/src/agent-runtime/conversationValidation.js +339 -0
- package/dist/src/agent-runtime/inbox.js +973 -0
- package/dist/src/agent-runtime/inboxPushProjection.js +69 -0
- package/dist/src/agent-runtime/inboxStore.js +25 -0
- package/dist/src/agent-runtime/inboxTypes.js +1 -0
- package/dist/src/agent-runtime/interaction.js +829 -0
- package/dist/src/agent-runtime/interactionLiveHub.js +225 -0
- package/dist/src/agent-runtime/interactionStore.js +18 -0
- package/dist/src/agent-runtime/interactionTypes.js +1 -0
- package/dist/src/agent-runtime/legacyInboxProjection.js +70 -0
- package/dist/src/agent-runtime/migrateLegacyCodexOutbox.js +336 -0
- package/dist/src/agent-runtime/resourceTypes.js +1 -0
- package/dist/src/agent-runtime/resources.js +245 -0
- package/dist/src/agent-runtime/run.js +328 -0
- package/dist/src/agent-runtime/runtime.js +793 -0
- package/dist/src/agent-runtime/sessionControl.js +164 -0
- package/dist/src/agent-runtime/subscriptionUsage.js +190 -0
- package/dist/src/agent-runtime/subscriptionUsageLegacy.js +18 -0
- package/dist/src/agent-runtime/tmuxRuntime.js +156 -0
- package/dist/src/agents/claude.js +203 -0
- package/dist/src/agents/claudeConversation.js +362 -0
- package/dist/src/agents/claudeInteraction.js +134 -0
- package/dist/src/agents/claudeSubscriptionUsage.js +61 -0
- package/dist/src/agents/codex.js +262 -0
- package/dist/src/agents/codexConversation.js +1103 -0
- package/dist/src/agents/codexConversationActivation.js +121 -0
- package/dist/src/agents/codexConversationControls.js +256 -0
- package/dist/src/agents/codexInteraction.js +306 -0
- package/dist/src/agents/codexSessionControl.js +118 -0
- package/dist/src/agents/codexSubscriptionUsage.js +313 -0
- package/dist/src/agents/codexTerminalControl.js +23 -0
- package/dist/src/agents/conversationProjectionSafety.js +416 -0
- package/dist/src/agents/index.js +39 -0
- package/dist/src/agents/nativeInbox.js +264 -0
- package/dist/src/agents/nativeInboxSources.js +115 -0
- package/dist/src/agents/pi.js +28 -0
- package/dist/src/agents/piConversation.js +523 -0
- package/dist/src/agents/piConversationHistory.js +502 -0
- package/dist/src/agents/piInboxBridge.js +368 -0
- package/dist/src/agents/piSessionControl.js +32 -0
- package/dist/src/agents/processIdentity.js +94 -0
- package/dist/src/agents/scanUtils.js +262 -0
- package/dist/src/apiAccountProviders.js +194 -0
- package/dist/src/apiAccounts.js +592 -0
- package/dist/src/apiErrors.js +86 -0
- package/{src → dist/src}/appName.js +12 -13
- package/dist/src/asr/iflyConfig.js +7 -0
- package/dist/src/asr/iflySign.js +14 -0
- package/dist/src/auth.js +27 -0
- package/dist/src/browser/bootstrap.js +72 -0
- package/dist/src/browser/cookieProfiles.js +413 -0
- package/dist/src/browser/coordinator.js +51 -0
- package/dist/src/browser/hammerheadCompat.js +104 -0
- package/dist/src/browser/hammerheadRedirectCompat.js +37 -0
- package/dist/src/browser/manager.js +560 -0
- package/dist/src/browser/originLabel.js +15 -0
- package/dist/src/browser/profilePersistence.js +235 -0
- package/dist/src/browser/publicProxy.js +176 -0
- package/dist/src/browser/routes.js +159 -0
- package/dist/src/browser/sessionStore.js +72 -0
- package/dist/src/browser/siteVersion.js +121 -0
- package/dist/src/browser/targetPolicy.js +163 -0
- package/{src → dist/src}/browser/worker.js +14 -18
- package/dist/src/browser/workerClient.js +286 -0
- package/dist/src/browser/workerServer.js +91 -0
- package/dist/src/captureBackground.js +95 -0
- package/dist/src/claudeEvents.js +525 -0
- package/dist/src/cli/agentIntegration.js +131 -0
- package/dist/src/cli/agentLauncher.js +26 -0
- package/dist/src/cli/claudeHooks.js +290 -0
- package/{src → dist/src}/cli/cloudflareUrl.js +2 -3
- package/dist/src/cli/cloudflared.js +106 -0
- package/dist/src/cli/codexManaged.js +80 -0
- package/{src → dist/src}/cli/cpolarUrl.js +8 -9
- package/dist/src/cli/drivers.js +98 -0
- package/dist/src/cli/hookScaffold.js +39 -0
- package/dist/src/cli/i18n/en.js +392 -0
- package/{src → dist/src}/cli/i18n/index.js +27 -25
- package/dist/src/cli/i18n/zh.js +391 -0
- package/dist/src/cli/legacyCodexHooks.js +52 -0
- package/{src → dist/src}/cli/natappUrl.js +2 -3
- package/dist/src/cli/options.js +245 -0
- package/dist/src/cli/piExtension.js +155 -0
- package/dist/src/cli/probe.js +16 -0
- package/{src → dist/src}/cli/prompt.js +4 -6
- package/dist/src/cli/pushCmd.js +102 -0
- package/{src → dist/src}/cli/qr.js +19 -19
- package/dist/src/cli/service.js +154 -0
- package/dist/src/cli/setupModel.js +194 -0
- package/dist/src/cli/setupWizard.js +647 -0
- package/dist/src/cli/shortcutEditor.js +351 -0
- package/dist/src/cli/sshTunnel.js +18 -0
- package/dist/src/cli/state.js +117 -0
- package/dist/src/cli/statusLine.js +115 -0
- package/dist/src/cli/supervisor.js +278 -0
- package/dist/src/cli/supervisorHealth.js +21 -0
- package/dist/src/cli/supervisorLaunch.js +148 -0
- package/dist/src/cli/supervisorProcesses.js +62 -0
- package/dist/src/cli/supervisorState.js +62 -0
- package/dist/src/cli/tmuxVersion.js +53 -0
- package/{src → dist/src}/cli/tunlite.js +7 -10
- package/dist/src/cli/tunnelClients.js +72 -0
- package/{src → dist/src}/cli/updateCheck.js +98 -69
- package/{src → dist/src}/cli/urlHost.js +8 -4
- package/dist/src/cli/workspaceCmd.js +347 -0
- package/dist/src/codexAppServer.js +3436 -0
- package/dist/src/codexConversationProjection.js +199 -0
- package/dist/src/codexDiff.js +118 -0
- package/dist/src/codexMessageIdentity.js +18 -0
- package/dist/src/codexPlan.js +30 -0
- package/dist/src/codexQueueProtocol.js +137 -0
- package/dist/src/codexStreamProtocol.js +207 -0
- package/dist/src/codexToolProtocol.js +68 -0
- package/dist/src/codexTranscriptParse.js +835 -0
- package/dist/src/codexUsageSnapshot.js +228 -0
- package/dist/src/config.js +8 -0
- package/{src → dist/src}/docPath.js +22 -24
- package/dist/src/docs.js +337 -0
- package/dist/src/git.js +284 -0
- package/dist/src/healthProtocol.js +97 -0
- package/dist/src/httpApi.js +96 -0
- package/dist/src/jsonStore.js +15 -0
- package/{src → dist/src}/keyNames.js +3 -3
- package/dist/src/notifications.js +111 -0
- package/dist/src/orphans.js +167 -0
- package/dist/src/paneInput.js +42 -0
- package/dist/src/pendingPrompt.js +194 -0
- package/dist/src/previewServer.js +202 -0
- package/dist/src/previews.js +179 -0
- package/dist/src/privateStateStore.js +124 -0
- package/dist/src/projectTask/backup.js +224 -0
- package/dist/src/projectTask/lock.js +108 -0
- package/dist/src/projectTask/migrations.js +90 -0
- package/dist/src/projectTask/routes.js +155 -0
- package/dist/src/projectTask/runtime.js +182 -0
- package/dist/src/projectTask/schema.js +56 -0
- package/dist/src/projectTask/store.js +457 -0
- package/dist/src/push.js +224 -0
- package/dist/src/routes/agents.js +631 -0
- package/dist/src/routes/apiAccounts.js +82 -0
- package/dist/src/routes/files.js +230 -0
- package/dist/src/routes/git.js +89 -0
- package/dist/src/routes/health.js +16 -0
- package/dist/src/routes/notifications.js +16 -0
- package/dist/src/routes/previews.js +43 -0
- package/dist/src/routes/push.js +145 -0
- package/dist/src/routes/sessions.js +244 -0
- package/dist/src/routes/system.js +220 -0
- package/dist/src/routes/terminal.js +244 -0
- package/dist/src/routes/workspace.js +267 -0
- package/dist/src/server.js +315 -0
- package/dist/src/shortcutConfig.js +83 -0
- package/{src → dist/src}/staticCache.js +5 -3
- package/{src → dist/src}/staticCompression.js +5 -6
- package/dist/src/terminalStream.js +441 -0
- package/{src → dist/src}/tmux/commands.js +185 -172
- package/dist/src/tmux/format.js +47 -0
- package/dist/src/transcriptParse.js +261 -0
- package/dist/src/transcriptReader.js +209 -0
- package/{src → dist/src}/trimCapture.js +11 -11
- package/dist/src/uploadTypes.js +31 -0
- package/dist/src/urlPolicy.js +18 -0
- package/dist/src/usagePaths.js +15 -0
- package/dist/src/workspace/agentRunner.js +301 -0
- package/dist/src/workspace/atomicJson.js +31 -0
- package/dist/src/workspace/capture.js +139 -0
- package/dist/src/workspace/checkpointer.js +228 -0
- package/dist/src/workspace/environment.js +71 -0
- package/dist/src/workspace/lock.js +137 -0
- package/dist/src/workspace/mapping.js +128 -0
- package/dist/src/workspace/operations.js +271 -0
- package/dist/src/workspace/paths.js +15 -0
- package/dist/src/workspace/planner.js +292 -0
- package/dist/src/workspace/restore.js +363 -0
- package/dist/src/workspace/runtime.js +318 -0
- package/dist/src/workspace/schema.js +156 -0
- package/dist/src/workspace/store.js +464 -0
- package/dist/src/workspace/tmuxAdapter.js +704 -0
- package/dist/src/workspaceProtocol.js +158 -0
- package/package.json +30 -17
- package/hooks/handmux-notify.sh +0 -22
- package/hooks/handmux-write.cjs +0 -78
- package/public/assets/index-C3N7OkyP.css +0 -32
- package/public/assets/index-D1DWcC5u.js +0 -337
- package/src/.gitkeep +0 -0
- package/src/agents/claude.js +0 -159
- package/src/agents/codex.js +0 -168
- package/src/agents/index.js +0 -21
- package/src/agents/processIdentity.js +0 -72
- package/src/agents/scanUtils.js +0 -225
- package/src/asr/iflyConfig.js +0 -10
- package/src/asr/iflySign.js +0 -16
- package/src/auth.js +0 -30
- package/src/browser/bootstrap.js +0 -68
- package/src/browser/cookieProfiles.js +0 -403
- package/src/browser/coordinator.js +0 -47
- package/src/browser/hammerheadCompat.js +0 -86
- package/src/browser/hammerheadRedirectCompat.js +0 -38
- package/src/browser/manager.js +0 -556
- package/src/browser/originLabel.js +0 -17
- package/src/browser/profilePersistence.js +0 -241
- package/src/browser/publicProxy.js +0 -175
- package/src/browser/routes.js +0 -154
- package/src/browser/sessionStore.js +0 -78
- package/src/browser/siteVersion.js +0 -124
- package/src/browser/targetPolicy.js +0 -137
- package/src/browser/workerClient.js +0 -253
- package/src/browser/workerServer.js +0 -98
- package/src/captureBackground.js +0 -93
- package/src/claudeEvents.js +0 -399
- package/src/cli/claudeHooks.js +0 -206
- package/src/cli/cloudflared.js +0 -99
- package/src/cli/codexManaged.js +0 -77
- package/src/cli/drivers.js +0 -97
- package/src/cli/hookScaffold.js +0 -33
- package/src/cli/i18n/en.js +0 -385
- package/src/cli/i18n/zh.js +0 -384
- package/src/cli/legacyCodexHooks.js +0 -49
- package/src/cli/options.js +0 -211
- package/src/cli/probe.js +0 -16
- package/src/cli/pushCmd.js +0 -60
- package/src/cli/service.js +0 -126
- package/src/cli/setupModel.js +0 -154
- package/src/cli/setupWizard.js +0 -506
- package/src/cli/shortcutEditor.js +0 -288
- package/src/cli/sshTunnel.js +0 -12
- package/src/cli/state.js +0 -90
- package/src/cli/statusLine.js +0 -90
- package/src/cli/supervisor.js +0 -183
- package/src/cli/supervisorProcesses.js +0 -57
- package/src/cli/tmuxVersion.js +0 -49
- package/src/cli/tunnelClients.js +0 -62
- package/src/cli/workspaceCmd.js +0 -300
- package/src/codexAppServer.js +0 -1620
- package/src/codexDiff.js +0 -108
- package/src/codexTranscriptParse.js +0 -413
- package/src/codexUsageSnapshot.cjs +0 -129
- package/src/config.js +0 -9
- package/src/docs.js +0 -228
- package/src/git.js +0 -208
- package/src/httpApi.js +0 -72
- package/src/jsonStore.js +0 -24
- package/src/notifications.js +0 -68
- package/src/orphans.js +0 -152
- package/src/pendingPrompt.js +0 -163
- package/src/previewServer.js +0 -168
- package/src/previews.js +0 -116
- package/src/push.js +0 -183
- package/src/routes/codex.js +0 -473
- package/src/routes/files.js +0 -172
- package/src/routes/git.js +0 -57
- package/src/routes/notifications.js +0 -19
- package/src/routes/previews.js +0 -35
- package/src/routes/push.js +0 -119
- package/src/routes/sessions.js +0 -168
- package/src/routes/system.js +0 -132
- package/src/routes/terminal.js +0 -210
- package/src/routes/transcript.js +0 -171
- package/src/routes/workspace.js +0 -267
- package/src/server.js +0 -186
- package/src/shortcutConfig.js +0 -75
- package/src/terminalStream.js +0 -391
- package/src/tmux/format.js +0 -45
- package/src/transcriptParse.js +0 -180
- package/src/transcriptReader.js +0 -101
- package/src/uploadTypes.js +0 -30
- package/src/urlPolicy.js +0 -14
- package/src/usage.js +0 -260
- package/src/workspace/agentRunner.js +0 -188
- package/src/workspace/atomicJson.js +0 -32
- package/src/workspace/capture.js +0 -139
- package/src/workspace/checkpointer.js +0 -187
- package/src/workspace/environment.js +0 -75
- package/src/workspace/lock.js +0 -128
- package/src/workspace/mapping.js +0 -124
- package/src/workspace/operations.js +0 -252
- package/src/workspace/paths.js +0 -16
- package/src/workspace/planner.js +0 -247
- package/src/workspace/restore.js +0 -279
- package/src/workspace/runtime.js +0 -314
- package/src/workspace/schema.js +0 -136
- package/src/workspace/store.js +0 -382
- package/src/workspace/tmuxAdapter.js +0 -554
- /package/{public → dist/public}/fonts/JetBrainsMonoNerdFontMono-Regular.woff2 +0 -0
- /package/{public → dist/public}/fonts/TWUnifont.woff2 +0 -0
- /package/{public → dist/public}/icons/apple-touch-icon.png +0 -0
- /package/{public → dist/public}/icons/badge-96.png +0 -0
- /package/{public → dist/public}/icons/icon-192-maskable.png +0 -0
- /package/{public → dist/public}/icons/icon-192.png +0 -0
- /package/{public → dist/public}/icons/icon-512-maskable.png +0 -0
- /package/{public → dist/public}/icons/icon-512.png +0 -0
- /package/{public → dist/public}/icons/logo.svg +0 -0
- /package/{public → dist/public}/manifest.webmanifest +0 -0
- /package/{public → dist/public}/offline.html +0 -0
- /package/{public → dist/public}/sw.js +0 -0
- /package/{src → dist/src}/browser/protocol.js +0 -0
- /package/{src → dist/src}/cli/cfNamed.js +0 -0
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
export class ConversationActivationError extends Error {
|
|
2
|
+
code;
|
|
3
|
+
constructor(message, code) {
|
|
4
|
+
super(message);
|
|
5
|
+
this.code = code;
|
|
6
|
+
this.name = 'ConversationActivationError';
|
|
7
|
+
}
|
|
8
|
+
}
|
|
9
|
+
export class AgentConversationActivationService {
|
|
10
|
+
#controllers;
|
|
11
|
+
#active = new Set();
|
|
12
|
+
#timeoutMs;
|
|
13
|
+
constructor(controllers, { timeoutMs = 15_000 } = {}) {
|
|
14
|
+
if (!controllers || typeof controllers !== 'object' || !Object.keys(controllers).length) {
|
|
15
|
+
throw new TypeError('AgentConversationActivationService requires controllers');
|
|
16
|
+
}
|
|
17
|
+
this.#controllers = new Map(Object.entries(controllers));
|
|
18
|
+
this.#timeoutMs = Math.max(1, timeoutMs);
|
|
19
|
+
}
|
|
20
|
+
async describe(run) {
|
|
21
|
+
const controller = this.#controllers.get(run.ref.agentId);
|
|
22
|
+
if (!controller)
|
|
23
|
+
throw new ConversationActivationError('Conversation activation unsupported', 'unsupported');
|
|
24
|
+
let value;
|
|
25
|
+
try {
|
|
26
|
+
value = await controller.describe(run);
|
|
27
|
+
}
|
|
28
|
+
catch {
|
|
29
|
+
throw new ConversationActivationError('Conversation activation is temporarily unavailable', 'unavailable');
|
|
30
|
+
}
|
|
31
|
+
if (value === null)
|
|
32
|
+
return null;
|
|
33
|
+
if (!value || value.effect !== 'replace-process-preserve-session') {
|
|
34
|
+
throw new ConversationActivationError('Invalid Conversation activation descriptor', 'contract_violation');
|
|
35
|
+
}
|
|
36
|
+
return structuredClone(value);
|
|
37
|
+
}
|
|
38
|
+
async activate(run, signal) {
|
|
39
|
+
const controller = this.#controllers.get(run.ref.agentId);
|
|
40
|
+
if (!controller)
|
|
41
|
+
throw new ConversationActivationError('Conversation activation unsupported', 'unsupported');
|
|
42
|
+
if (run.signal.aborted) {
|
|
43
|
+
throw new ConversationActivationError('The Agent run is no longer active', 'unavailable');
|
|
44
|
+
}
|
|
45
|
+
const key = `${run.ref.paneId}\0${run.ref.runId}`;
|
|
46
|
+
if (this.#active.has(key)) {
|
|
47
|
+
throw new ConversationActivationError('Conversation activation is already in progress', 'in_progress');
|
|
48
|
+
}
|
|
49
|
+
this.#active.add(key);
|
|
50
|
+
const operation = new AbortController();
|
|
51
|
+
const cancel = () => operation.abort(signal?.reason ?? new Error('Activation request cancelled'));
|
|
52
|
+
signal?.addEventListener('abort', cancel, { once: true });
|
|
53
|
+
if (signal?.aborted)
|
|
54
|
+
cancel();
|
|
55
|
+
const timer = setTimeout(() => operation.abort(new Error('Conversation activation timed out')), this.#timeoutMs);
|
|
56
|
+
try {
|
|
57
|
+
if (operation.signal.aborted)
|
|
58
|
+
throw operation.signal.reason;
|
|
59
|
+
const aborted = new Promise((_resolve, reject) => {
|
|
60
|
+
operation.signal.addEventListener('abort', () => reject(operation.signal.reason), { once: true });
|
|
61
|
+
});
|
|
62
|
+
await Promise.race([controller.activate(run, operation.signal), aborted]);
|
|
63
|
+
}
|
|
64
|
+
catch (error) {
|
|
65
|
+
if (error instanceof ConversationActivationError)
|
|
66
|
+
throw error;
|
|
67
|
+
throw new ConversationActivationError('Conversation activation could not finish; continue in the terminal or try again', 'unavailable');
|
|
68
|
+
}
|
|
69
|
+
finally {
|
|
70
|
+
clearTimeout(timer);
|
|
71
|
+
signal?.removeEventListener('abort', cancel);
|
|
72
|
+
this.#active.delete(key);
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
}
|
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
function key(run) {
|
|
2
|
+
return `${run.ref.agentId}\0${run.ref.sessionId ?? ''}`;
|
|
3
|
+
}
|
|
4
|
+
function validActivity(value) {
|
|
5
|
+
return value === 'idle' || value === 'working' || value === 'waiting'
|
|
6
|
+
|| value === 'compacting' || value === 'unknown';
|
|
7
|
+
}
|
|
8
|
+
function normalizeActiveTurn(value) {
|
|
9
|
+
if (!value || typeof value !== 'object')
|
|
10
|
+
return { state: 'unknown' };
|
|
11
|
+
const record = value;
|
|
12
|
+
if (record.state === 'none')
|
|
13
|
+
return { state: 'none' };
|
|
14
|
+
if (record.state === 'active' && typeof record.nativeTurnId === 'string' && record.nativeTurnId) {
|
|
15
|
+
return { state: 'active', nativeTurnId: record.nativeTurnId };
|
|
16
|
+
}
|
|
17
|
+
return { state: 'unknown' };
|
|
18
|
+
}
|
|
19
|
+
/** Runtime-owned normalization: provider readers never own revisions or dispatch state. */
|
|
20
|
+
export class RuntimeConversationActivitySource {
|
|
21
|
+
#readers;
|
|
22
|
+
#snapshots = new Map();
|
|
23
|
+
#readGenerations = new Map();
|
|
24
|
+
constructor(readers) {
|
|
25
|
+
this.#readers = new Map(Object.entries(readers));
|
|
26
|
+
}
|
|
27
|
+
async read(run) {
|
|
28
|
+
const owner = key(run);
|
|
29
|
+
const epoch = run.ref.runId;
|
|
30
|
+
const generation = (this.#readGenerations.get(owner) ?? 0) + 1;
|
|
31
|
+
this.#readGenerations.set(owner, generation);
|
|
32
|
+
const reader = this.#readers.get(run.ref.agentId);
|
|
33
|
+
let activity = 'unknown';
|
|
34
|
+
let activeTurn = { state: 'unknown' };
|
|
35
|
+
let completionToken;
|
|
36
|
+
try {
|
|
37
|
+
const raw = await reader?.read(run);
|
|
38
|
+
if (raw && validActivity(raw.activity)) {
|
|
39
|
+
activity = raw.activity;
|
|
40
|
+
activeTurn = normalizeActiveTurn(raw.activeTurn);
|
|
41
|
+
if (typeof raw.completionToken === 'string' && raw.completionToken
|
|
42
|
+
&& raw.completionToken.length <= 1024)
|
|
43
|
+
completionToken = raw.completionToken;
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
catch { /* an unreadable provider snapshot is explicitly unknown */ }
|
|
47
|
+
const current = this.#snapshots.get(owner);
|
|
48
|
+
if (run.signal.aborted || run.ref.runId !== epoch || key(run) !== owner
|
|
49
|
+
|| this.#readGenerations.get(owner) !== generation) {
|
|
50
|
+
return structuredClone(current ?? {
|
|
51
|
+
activity: 'unknown', activeTurn: { state: 'unknown' }, revision: 1, epoch,
|
|
52
|
+
});
|
|
53
|
+
}
|
|
54
|
+
const previous = current;
|
|
55
|
+
const semantic = `${activity}\0${activeTurn.state}\0${activeTurn.state === 'active' ? activeTurn.nativeTurnId : ''}\0${completionToken ?? ''}`;
|
|
56
|
+
const previousSemantic = previous
|
|
57
|
+
? `${previous.activity}\0${previous.activeTurn.state}\0${previous.activeTurn.state === 'active'
|
|
58
|
+
? previous.activeTurn.nativeTurnId : ''}\0${previous.completionToken ?? ''}` : '';
|
|
59
|
+
const revision = previous?.epoch === epoch
|
|
60
|
+
? previous.revision + (semantic === previousSemantic ? 0 : 1)
|
|
61
|
+
: 1;
|
|
62
|
+
const snapshot = {
|
|
63
|
+
activity, activeTurn, revision, epoch,
|
|
64
|
+
...(completionToken === undefined ? {} : { completionToken }),
|
|
65
|
+
};
|
|
66
|
+
this.#snapshots.set(owner, snapshot);
|
|
67
|
+
return structuredClone(snapshot);
|
|
68
|
+
}
|
|
69
|
+
}
|
|
@@ -0,0 +1,206 @@
|
|
|
1
|
+
export class ConversationControlError extends Error {
|
|
2
|
+
code;
|
|
3
|
+
constructor(message, code) {
|
|
4
|
+
super(message);
|
|
5
|
+
this.code = code;
|
|
6
|
+
this.name = 'ConversationControlError';
|
|
7
|
+
}
|
|
8
|
+
}
|
|
9
|
+
const MAX_OBJECTIVE = 4_000;
|
|
10
|
+
function text(value, max) {
|
|
11
|
+
return typeof value === 'string' && value.trim().length > 0 && value.length <= max;
|
|
12
|
+
}
|
|
13
|
+
function finite(value) {
|
|
14
|
+
return typeof value === 'number' && Number.isFinite(value) && value >= 0;
|
|
15
|
+
}
|
|
16
|
+
function goal(value) {
|
|
17
|
+
if (value === null)
|
|
18
|
+
return null;
|
|
19
|
+
if (!value || !text(value.objective, MAX_OBJECTIVE) || !text(value.status, 64))
|
|
20
|
+
return undefined;
|
|
21
|
+
const numeric = ['createdAt', 'updatedAt', 'tokensUsed', 'timeUsedSeconds'];
|
|
22
|
+
if (numeric.some((key) => value[key] !== undefined && !finite(value[key])))
|
|
23
|
+
return undefined;
|
|
24
|
+
if (value.tokenBudget !== undefined && value.tokenBudget !== null && !finite(value.tokenBudget)) {
|
|
25
|
+
return undefined;
|
|
26
|
+
}
|
|
27
|
+
return structuredClone(value);
|
|
28
|
+
}
|
|
29
|
+
function plan(value) {
|
|
30
|
+
if (value === null)
|
|
31
|
+
return null;
|
|
32
|
+
if (!value || !Array.isArray(value.steps) || value.steps.length > 100
|
|
33
|
+
|| (value.explanation !== undefined && !text(value.explanation, 16_384))
|
|
34
|
+
|| (value.turnId !== undefined && !text(value.turnId, 256))
|
|
35
|
+
|| (value.waiting !== undefined && typeof value.waiting !== 'boolean'))
|
|
36
|
+
return undefined;
|
|
37
|
+
const statuses = new Set(['pending', 'inProgress', 'completed']);
|
|
38
|
+
if (value.steps.some((step) => !step || !text(step.step, 4_096) || !statuses.has(step.status))) {
|
|
39
|
+
return undefined;
|
|
40
|
+
}
|
|
41
|
+
return structuredClone(value);
|
|
42
|
+
}
|
|
43
|
+
function context(value) {
|
|
44
|
+
if (value === null)
|
|
45
|
+
return null;
|
|
46
|
+
if (!value || !['idle', 'working', 'waiting', 'compacting', 'unknown'].includes(value.activity)
|
|
47
|
+
|| (value.usedTokens !== undefined && !finite(value.usedTokens))
|
|
48
|
+
|| (value.totalTokens !== undefined && (!finite(value.totalTokens) || value.totalTokens <= 0))
|
|
49
|
+
|| (value.cwd !== undefined && !text(value.cwd, 4_096))
|
|
50
|
+
|| (value.branch !== undefined && !text(value.branch, 1_024))
|
|
51
|
+
|| (value.access !== undefined && !['read-only', 'workspace-write', 'full-access'].includes(value.access))) {
|
|
52
|
+
return undefined;
|
|
53
|
+
}
|
|
54
|
+
return structuredClone(value);
|
|
55
|
+
}
|
|
56
|
+
function permission(value) {
|
|
57
|
+
if (value === null)
|
|
58
|
+
return null;
|
|
59
|
+
const validModes = ['default', 'auto-review', 'full-access', 'custom'];
|
|
60
|
+
if (!value || !validModes.includes(value.mode) || !Array.isArray(value.options)
|
|
61
|
+
|| value.options.length === 0 || value.options.length > 3
|
|
62
|
+
|| value.options.some((mode) => !['default', 'auto-review', 'full-access'].includes(mode))
|
|
63
|
+
|| new Set(value.options).size !== value.options.length)
|
|
64
|
+
return undefined;
|
|
65
|
+
return structuredClone(value);
|
|
66
|
+
}
|
|
67
|
+
export class AgentConversationControlService {
|
|
68
|
+
#bindings;
|
|
69
|
+
constructor(bindings) {
|
|
70
|
+
if (!bindings || typeof bindings !== 'object' || !Object.keys(bindings).length) {
|
|
71
|
+
throw new TypeError('AgentConversationControlService requires controller bindings');
|
|
72
|
+
}
|
|
73
|
+
this.#bindings = new Map(Object.entries(bindings));
|
|
74
|
+
}
|
|
75
|
+
slots(agentId) {
|
|
76
|
+
const binding = this.#bindings.get(agentId);
|
|
77
|
+
return {
|
|
78
|
+
goal: binding?.goal?.apiVersion === 1,
|
|
79
|
+
plan: binding?.plan?.apiVersion === 1,
|
|
80
|
+
context: binding?.context?.apiVersion === 1,
|
|
81
|
+
permission: binding?.permission?.apiVersion === 1,
|
|
82
|
+
commands: binding?.commands?.apiVersion === 1,
|
|
83
|
+
};
|
|
84
|
+
}
|
|
85
|
+
async read(run) {
|
|
86
|
+
const binding = this.#bindings.get(run.ref.agentId);
|
|
87
|
+
if (!binding)
|
|
88
|
+
throw new ConversationControlError('Conversation controls unsupported', 'unsupported');
|
|
89
|
+
const [goalResult, planResult, contextResult, permissionResult] = await Promise.allSettled([
|
|
90
|
+
binding.goal?.read(run), binding.plan?.read(run), binding.context?.read(run),
|
|
91
|
+
binding.permission?.read(run),
|
|
92
|
+
]);
|
|
93
|
+
const slotErrors = {};
|
|
94
|
+
const resultValue = (slot, result) => {
|
|
95
|
+
if (result.status === 'fulfilled')
|
|
96
|
+
return result.value;
|
|
97
|
+
// Provider failures may contain RPC payloads, credentials, or local paths. The facade exposes only a
|
|
98
|
+
// stable slot-level recovery signal; adapter diagnostics belong in Runtime health/logging.
|
|
99
|
+
slotErrors[slot] = 'temporarily unavailable';
|
|
100
|
+
return undefined;
|
|
101
|
+
};
|
|
102
|
+
const goalValue = resultValue('goal', goalResult);
|
|
103
|
+
const planValue = resultValue('plan', planResult);
|
|
104
|
+
const contextValue = resultValue('context', contextResult);
|
|
105
|
+
const permissionValue = resultValue('permission', permissionResult);
|
|
106
|
+
const normalizedGoal = goalValue === undefined ? undefined : goal(goalValue);
|
|
107
|
+
const normalizedPlan = planValue === undefined ? undefined : plan(planValue);
|
|
108
|
+
const normalizedContext = contextValue === undefined ? undefined : context(contextValue);
|
|
109
|
+
const normalizedPermission = permissionValue === undefined ? undefined : permission(permissionValue);
|
|
110
|
+
if (goalValue !== undefined && normalizedGoal === undefined)
|
|
111
|
+
slotErrors.goal = 'temporarily unavailable';
|
|
112
|
+
if (planValue !== undefined && normalizedPlan === undefined)
|
|
113
|
+
slotErrors.plan = 'temporarily unavailable';
|
|
114
|
+
if (contextValue !== undefined && normalizedContext === undefined) {
|
|
115
|
+
slotErrors.context = 'temporarily unavailable';
|
|
116
|
+
}
|
|
117
|
+
if (permissionValue !== undefined && normalizedPermission === undefined) {
|
|
118
|
+
slotErrors.permission = 'temporarily unavailable';
|
|
119
|
+
}
|
|
120
|
+
return {
|
|
121
|
+
...(normalizedGoal === undefined ? {} : { goal: normalizedGoal }),
|
|
122
|
+
...(binding.goal?.apiVersion === 1 ? { goalActions: [
|
|
123
|
+
...(binding.goal.start ? ['start'] : []),
|
|
124
|
+
...(binding.goal.update ? ['update'] : []),
|
|
125
|
+
...(binding.goal.clear ? ['clear'] : []),
|
|
126
|
+
] } : {}),
|
|
127
|
+
...(normalizedPlan === undefined ? {} : { plan: normalizedPlan }),
|
|
128
|
+
...(normalizedContext === undefined ? {} : { context: normalizedContext }),
|
|
129
|
+
...(normalizedPermission === undefined ? {} : { permission: normalizedPermission }),
|
|
130
|
+
...(binding.permission?.apiVersion === 1 ? {
|
|
131
|
+
permissionCanUpdate: typeof binding.permission.update === 'function',
|
|
132
|
+
} : {}),
|
|
133
|
+
...(binding.commands?.apiVersion === 1 ? { commands: [...binding.commands.commands] } : {}),
|
|
134
|
+
...(Object.keys(slotErrors).length ? { slotErrors } : {}),
|
|
135
|
+
};
|
|
136
|
+
}
|
|
137
|
+
async goalAction(run, request) {
|
|
138
|
+
const control = this.#bindings.get(run.ref.agentId)?.goal;
|
|
139
|
+
if (!control)
|
|
140
|
+
throw new ConversationControlError('Conversation goal unsupported', 'unsupported');
|
|
141
|
+
let raw;
|
|
142
|
+
if (request.action === 'clear') {
|
|
143
|
+
if (!control.clear)
|
|
144
|
+
throw new ConversationControlError('Goal clear unsupported', 'unsupported');
|
|
145
|
+
await control.clear(run);
|
|
146
|
+
return null;
|
|
147
|
+
}
|
|
148
|
+
if (request.action === 'start' && text(request.objective, MAX_OBJECTIVE)) {
|
|
149
|
+
if (!control.start)
|
|
150
|
+
throw new ConversationControlError('Goal start unsupported', 'unsupported');
|
|
151
|
+
raw = await control.start(run, request.objective.trim());
|
|
152
|
+
}
|
|
153
|
+
else if (request.action === 'update') {
|
|
154
|
+
const patch = {};
|
|
155
|
+
if (request.objective !== undefined) {
|
|
156
|
+
if (!text(request.objective, MAX_OBJECTIVE)) {
|
|
157
|
+
throw new ConversationControlError('Invalid goal objective', 'invalid_request');
|
|
158
|
+
}
|
|
159
|
+
patch.objective = request.objective.trim();
|
|
160
|
+
}
|
|
161
|
+
if (request.status !== undefined) {
|
|
162
|
+
if (request.status !== 'active' && request.status !== 'paused') {
|
|
163
|
+
throw new ConversationControlError('Invalid goal status', 'invalid_request');
|
|
164
|
+
}
|
|
165
|
+
patch.status = request.status;
|
|
166
|
+
}
|
|
167
|
+
if (!Object.keys(patch).length) {
|
|
168
|
+
throw new ConversationControlError('Empty goal update', 'invalid_request');
|
|
169
|
+
}
|
|
170
|
+
if (!control.update)
|
|
171
|
+
throw new ConversationControlError('Goal update unsupported', 'unsupported');
|
|
172
|
+
raw = await control.update(run, patch);
|
|
173
|
+
}
|
|
174
|
+
else {
|
|
175
|
+
throw new ConversationControlError('Invalid goal action', 'invalid_request');
|
|
176
|
+
}
|
|
177
|
+
const normalized = goal(raw);
|
|
178
|
+
if (!normalized)
|
|
179
|
+
throw new ConversationControlError('Invalid goal response', 'contract_violation');
|
|
180
|
+
return normalized;
|
|
181
|
+
}
|
|
182
|
+
async permissionAction(run, request) {
|
|
183
|
+
const control = this.#bindings.get(run.ref.agentId)?.permission;
|
|
184
|
+
if (!control)
|
|
185
|
+
throw new ConversationControlError('Conversation permission unsupported', 'unsupported');
|
|
186
|
+
if (!control.update) {
|
|
187
|
+
throw new ConversationControlError('Permission update unsupported', 'unsupported');
|
|
188
|
+
}
|
|
189
|
+
const mode = request.permissionMode;
|
|
190
|
+
if (request.action !== 'set_permission'
|
|
191
|
+
|| !['default', 'auto-review', 'full-access'].includes(String(mode))) {
|
|
192
|
+
throw new ConversationControlError('Invalid permission action', 'invalid_request');
|
|
193
|
+
}
|
|
194
|
+
const normalized = permission(await control.update(run, mode));
|
|
195
|
+
if (!normalized)
|
|
196
|
+
throw new ConversationControlError('Invalid permission response', 'contract_violation');
|
|
197
|
+
return normalized;
|
|
198
|
+
}
|
|
199
|
+
async command(run, value) {
|
|
200
|
+
const control = this.#bindings.get(run.ref.agentId)?.commands;
|
|
201
|
+
if (!control || (value !== 'compact' && value !== 'clear') || !control.commands.includes(value)) {
|
|
202
|
+
throw new ConversationControlError('Conversation command unsupported', 'unsupported');
|
|
203
|
+
}
|
|
204
|
+
await control.execute(run, value);
|
|
205
|
+
}
|
|
206
|
+
}
|
|
@@ -0,0 +1,276 @@
|
|
|
1
|
+
const DEFAULT_MAX_BUFFERED_EVENTS = 512;
|
|
2
|
+
const DEFAULT_MAX_REPLAY_EVENTS = 2_048;
|
|
3
|
+
const DEFAULT_IDLE_GRACE_MS = 5_000;
|
|
4
|
+
export class ConversationLiveHubError extends Error {
|
|
5
|
+
code;
|
|
6
|
+
constructor(code, message) {
|
|
7
|
+
super(message);
|
|
8
|
+
this.code = code;
|
|
9
|
+
this.name = 'ConversationLiveHubError';
|
|
10
|
+
}
|
|
11
|
+
}
|
|
12
|
+
class EventQueue {
|
|
13
|
+
checkpoint;
|
|
14
|
+
#maxBufferedEvents;
|
|
15
|
+
#detach;
|
|
16
|
+
#values = [];
|
|
17
|
+
#waiting;
|
|
18
|
+
#closed = false;
|
|
19
|
+
#finishWhenEmpty = false;
|
|
20
|
+
#detached = false;
|
|
21
|
+
#lastReadSequence;
|
|
22
|
+
constructor(checkpoint, maxBufferedEvents, detach) {
|
|
23
|
+
this.checkpoint = Object.freeze(structuredClone(checkpoint));
|
|
24
|
+
this.#lastReadSequence = checkpoint.streamSequence;
|
|
25
|
+
this.#maxBufferedEvents = maxBufferedEvents;
|
|
26
|
+
this.#detach = detach;
|
|
27
|
+
}
|
|
28
|
+
[Symbol.asyncIterator]() { return this; }
|
|
29
|
+
next() {
|
|
30
|
+
const value = this.#values.shift();
|
|
31
|
+
if (value) {
|
|
32
|
+
this.#lastReadSequence = value.sequence;
|
|
33
|
+
return Promise.resolve({ value: structuredClone(value), done: false });
|
|
34
|
+
}
|
|
35
|
+
if (this.#closed || this.#finishWhenEmpty) {
|
|
36
|
+
this.#closed = true;
|
|
37
|
+
this.#detachOnce();
|
|
38
|
+
return Promise.resolve({ value: undefined, done: true });
|
|
39
|
+
}
|
|
40
|
+
return new Promise((resolve) => { this.#waiting = resolve; });
|
|
41
|
+
}
|
|
42
|
+
push(event, finish = false) {
|
|
43
|
+
if (this.#closed || this.#finishWhenEmpty)
|
|
44
|
+
return;
|
|
45
|
+
const cloned = structuredClone(event);
|
|
46
|
+
const waiting = this.#waiting;
|
|
47
|
+
if (waiting) {
|
|
48
|
+
this.#waiting = undefined;
|
|
49
|
+
this.#lastReadSequence = cloned.sequence;
|
|
50
|
+
if (finish) {
|
|
51
|
+
this.#finishWhenEmpty = true;
|
|
52
|
+
this.#detachOnce();
|
|
53
|
+
}
|
|
54
|
+
waiting({ value: cloned, done: false });
|
|
55
|
+
return;
|
|
56
|
+
}
|
|
57
|
+
if (this.#values.length >= this.#maxBufferedEvents) {
|
|
58
|
+
this.#values.splice(0);
|
|
59
|
+
this.#values.push({
|
|
60
|
+
type: 'stream.gap',
|
|
61
|
+
sequence: cloned.sequence,
|
|
62
|
+
afterSequence: this.#lastReadSequence,
|
|
63
|
+
});
|
|
64
|
+
this.#finishWhenEmpty = true;
|
|
65
|
+
this.#detachOnce();
|
|
66
|
+
return;
|
|
67
|
+
}
|
|
68
|
+
this.#values.push(cloned);
|
|
69
|
+
if (finish) {
|
|
70
|
+
this.#finishWhenEmpty = true;
|
|
71
|
+
this.#detachOnce();
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
finishAfterDrain() {
|
|
75
|
+
if (this.#closed || this.#finishWhenEmpty)
|
|
76
|
+
return;
|
|
77
|
+
this.#finishWhenEmpty = true;
|
|
78
|
+
this.#detachOnce();
|
|
79
|
+
if (!this.#values.length && this.#waiting) {
|
|
80
|
+
const waiting = this.#waiting;
|
|
81
|
+
this.#waiting = undefined;
|
|
82
|
+
this.#closed = true;
|
|
83
|
+
waiting({ value: undefined, done: true });
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
close() {
|
|
87
|
+
if (this.#closed)
|
|
88
|
+
return;
|
|
89
|
+
this.#closed = true;
|
|
90
|
+
this.#values.splice(0);
|
|
91
|
+
this.#detachOnce();
|
|
92
|
+
if (this.#waiting) {
|
|
93
|
+
const waiting = this.#waiting;
|
|
94
|
+
this.#waiting = undefined;
|
|
95
|
+
waiting({ value: undefined, done: true });
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
#detachOnce() {
|
|
99
|
+
if (this.#detached)
|
|
100
|
+
return;
|
|
101
|
+
this.#detached = true;
|
|
102
|
+
this.#detach();
|
|
103
|
+
}
|
|
104
|
+
}
|
|
105
|
+
// App-level fan-out only. ConversationService still owns provider ordering, reconciliation, and durable
|
|
106
|
+
// truth; this hub owns one shared observation plus bounded per-client delivery queues.
|
|
107
|
+
export class ConversationLiveHub {
|
|
108
|
+
#conversation;
|
|
109
|
+
#maxBufferedEvents;
|
|
110
|
+
#maxReplayEvents;
|
|
111
|
+
#idleGraceMs;
|
|
112
|
+
#observations = new Map();
|
|
113
|
+
#closed = false;
|
|
114
|
+
constructor({ conversation, maxBufferedEvents = DEFAULT_MAX_BUFFERED_EVENTS, maxReplayEvents = DEFAULT_MAX_REPLAY_EVENTS, idleGraceMs = DEFAULT_IDLE_GRACE_MS, }) {
|
|
115
|
+
if (!conversation || typeof conversation.open !== 'function'
|
|
116
|
+
|| !Number.isSafeInteger(maxBufferedEvents) || maxBufferedEvents <= 0
|
|
117
|
+
|| !Number.isSafeInteger(maxReplayEvents) || maxReplayEvents <= 0
|
|
118
|
+
|| !Number.isSafeInteger(idleGraceMs) || idleGraceMs < 0) {
|
|
119
|
+
throw new TypeError('Conversation live hub requires a service and bounded queues');
|
|
120
|
+
}
|
|
121
|
+
this.#conversation = conversation;
|
|
122
|
+
this.#maxBufferedEvents = maxBufferedEvents;
|
|
123
|
+
this.#maxReplayEvents = maxReplayEvents;
|
|
124
|
+
this.#idleGraceMs = idleGraceMs;
|
|
125
|
+
}
|
|
126
|
+
async subscribe(run, request = {}) {
|
|
127
|
+
if (this.#closed)
|
|
128
|
+
throw new ConversationLiveHubError('closed', 'Conversation live hub is closed');
|
|
129
|
+
let shared = this.#observations.get(run.ref.runId);
|
|
130
|
+
if (!shared)
|
|
131
|
+
shared = this.#create(run);
|
|
132
|
+
if (shared.run !== run || shared.terminated) {
|
|
133
|
+
throw new ConversationLiveHubError('stale_run', 'Conversation live run is no longer current');
|
|
134
|
+
}
|
|
135
|
+
if (shared.idleTimer) {
|
|
136
|
+
clearTimeout(shared.idleTimer);
|
|
137
|
+
shared.idleTimer = undefined;
|
|
138
|
+
}
|
|
139
|
+
await shared.ready;
|
|
140
|
+
const checkpoint = shared.checkpoint;
|
|
141
|
+
const baseline = shared.baseline;
|
|
142
|
+
if (!checkpoint || !baseline || shared.terminated) {
|
|
143
|
+
throw new ConversationLiveHubError('unavailable', 'Conversation live observation is unavailable');
|
|
144
|
+
}
|
|
145
|
+
if (request.expectedViewId !== undefined && request.expectedViewId !== checkpoint.viewId) {
|
|
146
|
+
this.#idle(shared);
|
|
147
|
+
throw new ConversationLiveHubError('stale_view', 'Conversation view is stale');
|
|
148
|
+
}
|
|
149
|
+
let subscription;
|
|
150
|
+
subscription = new EventQueue(baseline, this.#maxBufferedEvents, () => {
|
|
151
|
+
this.#detach(shared, subscription);
|
|
152
|
+
});
|
|
153
|
+
shared.subscribers.add(subscription);
|
|
154
|
+
for (const event of shared.replay)
|
|
155
|
+
subscription.push(event);
|
|
156
|
+
return subscription;
|
|
157
|
+
}
|
|
158
|
+
close() {
|
|
159
|
+
if (this.#closed)
|
|
160
|
+
return;
|
|
161
|
+
this.#closed = true;
|
|
162
|
+
for (const shared of [...this.#observations.values()])
|
|
163
|
+
this.#terminate(shared, false);
|
|
164
|
+
}
|
|
165
|
+
#create(run) {
|
|
166
|
+
const shared = {
|
|
167
|
+
run,
|
|
168
|
+
ready: Promise.resolve(),
|
|
169
|
+
subscribers: new Set(),
|
|
170
|
+
replay: [],
|
|
171
|
+
idleTimer: undefined,
|
|
172
|
+
detachAbort: () => { },
|
|
173
|
+
terminated: false,
|
|
174
|
+
};
|
|
175
|
+
const abort = () => this.#terminate(shared, false);
|
|
176
|
+
run.signal.addEventListener('abort', abort, { once: true });
|
|
177
|
+
shared.detachAbort = () => run.signal.removeEventListener('abort', abort);
|
|
178
|
+
this.#observations.set(run.ref.runId, shared);
|
|
179
|
+
shared.ready = this.#conversation.open(run, {}, (event) => {
|
|
180
|
+
this.#publish(shared, event);
|
|
181
|
+
}).then((handle) => {
|
|
182
|
+
if (shared.terminated) {
|
|
183
|
+
try {
|
|
184
|
+
void Promise.resolve(handle.close()).catch(() => { });
|
|
185
|
+
}
|
|
186
|
+
catch { /* best effort */ }
|
|
187
|
+
throw new Error('Conversation live run was revoked during open');
|
|
188
|
+
}
|
|
189
|
+
shared.handle = handle;
|
|
190
|
+
shared.baseline = structuredClone(handle.checkpoint);
|
|
191
|
+
shared.checkpoint = structuredClone(handle.checkpoint);
|
|
192
|
+
}).catch((error) => {
|
|
193
|
+
this.#terminate(shared, false);
|
|
194
|
+
throw error;
|
|
195
|
+
});
|
|
196
|
+
return shared;
|
|
197
|
+
}
|
|
198
|
+
#publish(shared, event) {
|
|
199
|
+
if (shared.terminated || !shared.checkpoint
|
|
200
|
+
|| !Number.isSafeInteger(event.sequence)
|
|
201
|
+
|| event.sequence <= shared.checkpoint.streamSequence)
|
|
202
|
+
return;
|
|
203
|
+
const previousSequence = shared.checkpoint.streamSequence;
|
|
204
|
+
if (shared.replay.length >= this.#maxReplayEvents
|
|
205
|
+
&& event.type !== 'history.changed' && event.type !== 'stream.gap') {
|
|
206
|
+
const gap = {
|
|
207
|
+
type: 'stream.gap', sequence: event.sequence, afterSequence: previousSequence,
|
|
208
|
+
};
|
|
209
|
+
shared.checkpoint = { ...shared.checkpoint, streamSequence: event.sequence };
|
|
210
|
+
for (const subscriber of [...shared.subscribers])
|
|
211
|
+
subscriber.push(gap, true);
|
|
212
|
+
this.#terminate(shared, true);
|
|
213
|
+
return;
|
|
214
|
+
}
|
|
215
|
+
shared.checkpoint = {
|
|
216
|
+
viewId: event.type === 'history.changed' ? event.viewId : shared.checkpoint.viewId,
|
|
217
|
+
historyVersion: event.type === 'history.changed'
|
|
218
|
+
? event.historyVersion : shared.checkpoint.historyVersion,
|
|
219
|
+
streamSequence: event.sequence,
|
|
220
|
+
};
|
|
221
|
+
const terminal = event.type === 'stream.gap';
|
|
222
|
+
shared.replay.push(structuredClone(event));
|
|
223
|
+
for (const subscriber of [...shared.subscribers])
|
|
224
|
+
subscriber.push(event, terminal);
|
|
225
|
+
if (event.type === 'history.changed') {
|
|
226
|
+
// A durable barrier advances the baseline without ending the native observation. Existing clients
|
|
227
|
+
// receive the barrier and reconcile their page; later subscribers start directly from the new
|
|
228
|
+
// checkpoint, so pre-barrier replay can be released immediately.
|
|
229
|
+
shared.baseline = structuredClone(shared.checkpoint);
|
|
230
|
+
shared.replay = [];
|
|
231
|
+
}
|
|
232
|
+
if (terminal)
|
|
233
|
+
this.#terminate(shared, true);
|
|
234
|
+
}
|
|
235
|
+
#detach(shared, subscription) {
|
|
236
|
+
shared.subscribers.delete(subscription);
|
|
237
|
+
this.#idle(shared);
|
|
238
|
+
}
|
|
239
|
+
#idle(shared) {
|
|
240
|
+
if (shared.terminated || shared.subscribers.size || shared.idleTimer)
|
|
241
|
+
return;
|
|
242
|
+
if (this.#idleGraceMs === 0) {
|
|
243
|
+
this.#terminate(shared, false);
|
|
244
|
+
return;
|
|
245
|
+
}
|
|
246
|
+
shared.idleTimer = setTimeout(() => {
|
|
247
|
+
shared.idleTimer = undefined;
|
|
248
|
+
if (!shared.subscribers.size)
|
|
249
|
+
this.#terminate(shared, false);
|
|
250
|
+
}, this.#idleGraceMs);
|
|
251
|
+
shared.idleTimer.unref?.();
|
|
252
|
+
}
|
|
253
|
+
#terminate(shared, drain) {
|
|
254
|
+
if (shared.terminated)
|
|
255
|
+
return;
|
|
256
|
+
shared.terminated = true;
|
|
257
|
+
if (this.#observations.get(shared.run.ref.runId) === shared) {
|
|
258
|
+
this.#observations.delete(shared.run.ref.runId);
|
|
259
|
+
}
|
|
260
|
+
if (shared.idleTimer)
|
|
261
|
+
clearTimeout(shared.idleTimer);
|
|
262
|
+
shared.idleTimer = undefined;
|
|
263
|
+
shared.detachAbort();
|
|
264
|
+
for (const subscriber of [...shared.subscribers]) {
|
|
265
|
+
if (drain)
|
|
266
|
+
subscriber.finishAfterDrain();
|
|
267
|
+
else
|
|
268
|
+
subscriber.close();
|
|
269
|
+
}
|
|
270
|
+
shared.subscribers.clear();
|
|
271
|
+
try {
|
|
272
|
+
void Promise.resolve(shared.handle?.close()).catch(() => { });
|
|
273
|
+
}
|
|
274
|
+
catch { /* close is best effort */ }
|
|
275
|
+
}
|
|
276
|
+
}
|