opencandle 0.9.0 → 0.11.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 +7 -5
- package/dist/analysts/orchestrator.d.ts +1 -4
- package/dist/analysts/orchestrator.js +15 -44
- package/dist/analysts/orchestrator.js.map +1 -1
- package/dist/cli-main.js +242 -9
- package/dist/cli-main.js.map +1 -1
- package/dist/config.d.ts +4 -9
- package/dist/config.js +7 -10
- package/dist/config.js.map +1 -1
- package/dist/market-state/daily-report.d.ts +1 -1
- package/dist/market-state/daily-report.js +5 -16
- package/dist/market-state/daily-report.js.map +1 -1
- package/dist/market-state/service.d.ts +0 -35
- package/dist/market-state/service.js +0 -63
- package/dist/market-state/service.js.map +1 -1
- package/dist/memory/sqlite.js +20 -19
- package/dist/memory/sqlite.js.map +1 -1
- package/dist/pi/opencandle-extension.js +13 -221
- package/dist/pi/opencandle-extension.js.map +1 -1
- package/dist/pi/session-action-dedupe.d.ts +6 -0
- package/dist/pi/session-action-dedupe.js +126 -0
- package/dist/pi/session-action-dedupe.js.map +1 -0
- package/dist/pi/session-writer-lock.d.ts +26 -2
- package/dist/pi/session-writer-lock.js +230 -18
- package/dist/pi/session-writer-lock.js.map +1 -1
- package/dist/pi/setup.js +5 -5
- package/dist/pi/setup.js.map +1 -1
- package/dist/pi/tui-session-coordinator.d.ts +15 -0
- package/dist/pi/tui-session-coordinator.js +283 -0
- package/dist/pi/tui-session-coordinator.js.map +1 -0
- package/dist/prompts/context-builder.js +1 -1
- package/dist/prompts/policy-cards.js +1 -1
- package/dist/prompts/policy-cards.js.map +1 -1
- package/dist/routing/classify-intent.js +4 -5
- package/dist/routing/classify-intent.js.map +1 -1
- package/dist/routing/route-manifest.js +0 -1
- package/dist/routing/route-manifest.js.map +1 -1
- package/dist/routing/router-prompt.js +1 -1
- package/dist/routing/router.js +35 -4
- package/dist/routing/router.js.map +1 -1
- package/dist/runtime/session-coordinator.js +2 -13
- package/dist/runtime/session-coordinator.js.map +1 -1
- package/dist/system-prompt.js +1 -1
- package/dist/tools/fundamentals/dcf.d.ts +1 -1
- package/dist/tools/fundamentals/dcf.js +49 -6
- package/dist/tools/fundamentals/dcf.js.map +1 -1
- package/dist/tools/index.d.ts +0 -1
- package/dist/tools/index.js +0 -3
- package/dist/tools/index.js.map +1 -1
- package/dist/tools/portfolio/daily-report.js +10 -4
- package/dist/tools/portfolio/daily-report.js.map +1 -1
- package/dist/tools/technical/backtest.d.ts +23 -5
- package/dist/tools/technical/backtest.js +131 -94
- package/dist/tools/technical/backtest.js.map +1 -1
- package/gui/server/http-routes.ts +395 -19
- package/gui/server/invoke-tool.ts +164 -16
- package/gui/server/local-session-coordinator.ts +97 -0
- package/gui/server/market-state-api.ts +1 -43
- package/gui/server/server.ts +51 -6
- package/gui/server/session-actions.ts +117 -8
- package/gui/server/tool-metadata.ts +3 -1
- package/gui/server/ws-hub.ts +61 -6
- package/gui/web/dist/assets/CatalogOverlay-ChRTKNlf.js +1 -0
- package/gui/web/dist/assets/index-BzyqyVnd.css +2 -0
- package/gui/web/dist/assets/index-DvMjkOP9.js +65 -0
- package/gui/web/dist/index.html +2 -2
- package/package.json +8 -5
- package/src/analysts/orchestrator.ts +15 -56
- package/src/cli-main.ts +253 -13
- package/src/config.ts +12 -20
- package/src/market-state/daily-report.ts +6 -16
- package/src/market-state/service.ts +0 -136
- package/src/memory/sqlite.ts +23 -19
- package/src/pi/opencandle-extension.ts +12 -265
- package/src/pi/session-action-dedupe.ts +155 -0
- package/src/pi/session-writer-lock.ts +290 -20
- package/src/pi/setup.ts +6 -6
- package/src/pi/tui-session-coordinator.ts +351 -0
- package/src/prompts/context-builder.ts +1 -1
- package/src/prompts/policy-cards.ts +1 -1
- package/src/routing/classify-intent.ts +4 -5
- package/src/routing/route-manifest.ts +0 -1
- package/src/routing/router-prompt.ts +1 -1
- package/src/routing/router.ts +39 -3
- package/src/runtime/session-coordinator.ts +2 -17
- package/src/system-prompt.ts +1 -1
- package/src/tools/AGENTS.md +1 -1
- package/src/tools/fundamentals/dcf.ts +57 -7
- package/src/tools/index.ts +0 -3
- package/src/tools/portfolio/daily-report.ts +10 -4
- package/src/tools/technical/backtest.ts +167 -108
- package/dist/tools/portfolio/predictions.d.ts +0 -55
- package/dist/tools/portfolio/predictions.js +0 -422
- package/dist/tools/portfolio/predictions.js.map +0 -1
- package/gui/web/dist/assets/CatalogOverlay-DcTXLc7u.js +0 -1
- package/gui/web/dist/assets/index-DdMnIvZ7.css +0 -2
- package/gui/web/dist/assets/index-YlMaahAS.js +0 -65
- package/src/tools/portfolio/predictions.ts +0 -553
|
@@ -4,14 +4,24 @@ import type { SessionManager } from "@earendil-works/pi-coding-agent";
|
|
|
4
4
|
import type { TSchema } from "@sinclair/typebox";
|
|
5
5
|
import { Value } from "@sinclair/typebox/value";
|
|
6
6
|
import { getDefaults } from "../../src/memory/tool-defaults.js";
|
|
7
|
+
import {
|
|
8
|
+
clearPendingSessionAction,
|
|
9
|
+
hasAcceptedSessionAction,
|
|
10
|
+
hasPendingSessionAction,
|
|
11
|
+
recordAcceptedSessionAction,
|
|
12
|
+
recordPendingSessionAction,
|
|
13
|
+
} from "../../src/pi/session-action-dedupe.js";
|
|
7
14
|
import { wrapWithDefaults } from "../../src/runtime/tool-defaults-wrapper.js";
|
|
8
15
|
import { getAllTools } from "../../src/tools/index.js";
|
|
9
16
|
import type { AskUserHandler } from "../../src/types/index.js";
|
|
17
|
+
import type { LocalSessionCoordinator } from "./local-session-coordinator.js";
|
|
10
18
|
import { buildToolInvokeAckMessage } from "./tool-invoke-ack.js";
|
|
11
19
|
import {
|
|
12
20
|
acquireWriterLock,
|
|
21
|
+
readWriterLock,
|
|
13
22
|
refreshWriterLock,
|
|
14
23
|
releaseWriterLock,
|
|
24
|
+
shouldBlockFailedCoordinatorAction,
|
|
15
25
|
writerLockScopeForSession,
|
|
16
26
|
} from "./writer-lock.js";
|
|
17
27
|
|
|
@@ -30,6 +40,7 @@ export interface ToolInvokeController {
|
|
|
30
40
|
toolName: string,
|
|
31
41
|
args: Record<string, unknown>,
|
|
32
42
|
sessionId?: string,
|
|
43
|
+
options?: { actionId?: string; allowProxy?: boolean },
|
|
33
44
|
): Promise<InvokeToolResult>;
|
|
34
45
|
handleToolInvokeMessage(client: ToolInvokeClient, data: Record<string, unknown>): Promise<void>;
|
|
35
46
|
}
|
|
@@ -46,6 +57,10 @@ export interface ToolInvokeControllerOptions {
|
|
|
46
57
|
resolveSessionManager?: (sessionId: string) => Promise<SessionManager | null>;
|
|
47
58
|
broadcastSessionSnapshot?: (sessionManager: SessionManager) => void;
|
|
48
59
|
broadcastSessions?: () => void;
|
|
60
|
+
localSessionCoordinator?: LocalSessionCoordinator;
|
|
61
|
+
localCoordinatorEndpoint?: string;
|
|
62
|
+
localCoordinatorSecret?: string;
|
|
63
|
+
syncWriterLockScope?: () => void;
|
|
49
64
|
}
|
|
50
65
|
|
|
51
66
|
export function createToolInvokeController({
|
|
@@ -60,13 +75,17 @@ export function createToolInvokeController({
|
|
|
60
75
|
resolveSessionManager,
|
|
61
76
|
broadcastSessionSnapshot,
|
|
62
77
|
broadcastSessions,
|
|
78
|
+
localSessionCoordinator,
|
|
79
|
+
localCoordinatorEndpoint,
|
|
80
|
+
localCoordinatorSecret,
|
|
81
|
+
syncWriterLockScope,
|
|
63
82
|
}: ToolInvokeControllerOptions): ToolInvokeController {
|
|
64
83
|
async function handleToolInvoke(
|
|
65
84
|
toolName: string,
|
|
66
85
|
args: Record<string, unknown>,
|
|
67
86
|
sessionId = "",
|
|
87
|
+
options: { actionId?: string; allowProxy?: boolean } = {},
|
|
68
88
|
): Promise<InvokeToolResult> {
|
|
69
|
-
if (role !== "writer") throw new Error("Read-only follower mode");
|
|
70
89
|
const tool = getTools().find((candidate) => candidate.name === toolName);
|
|
71
90
|
if (!tool) throw new Error(`Unknown tool: ${toolName}`);
|
|
72
91
|
|
|
@@ -77,15 +96,50 @@ export function createToolInvokeController({
|
|
|
77
96
|
resolveSessionManager,
|
|
78
97
|
);
|
|
79
98
|
const useCurrentSession = sameSessionStorage(currentSessionManager, runSessionManager);
|
|
99
|
+
const allowProxy = options.allowProxy !== false;
|
|
100
|
+
const shouldProxyToolInvoke =
|
|
101
|
+
allowProxy &&
|
|
102
|
+
(role !== "writer" || !useCurrentSession) &&
|
|
103
|
+
canProxyToolInvokeToCoordinator(runSessionManager);
|
|
104
|
+
if (shouldProxyToolInvoke) {
|
|
105
|
+
const proxied = await proxyToolInvokeToCoordinator(
|
|
106
|
+
runSessionManager,
|
|
107
|
+
toolName,
|
|
108
|
+
args,
|
|
109
|
+
options.actionId,
|
|
110
|
+
);
|
|
111
|
+
if (proxied) return proxied;
|
|
112
|
+
if (options.actionId && hasAcceptedSessionAction(runSessionManager, options.actionId)) {
|
|
113
|
+
throw new Error("OpenCandle already accepted this action in the active session.");
|
|
114
|
+
}
|
|
115
|
+
if (options.actionId && hasPendingSessionAction(runSessionManager, options.actionId)) {
|
|
116
|
+
throw new Error("OpenCandle is reconnecting to this session.");
|
|
117
|
+
}
|
|
118
|
+
if (options.actionId && shouldBlockFailedCoordinatorAction(runSessionManager)) {
|
|
119
|
+
throw new Error("OpenCandle is reconnecting to this session.");
|
|
120
|
+
}
|
|
121
|
+
}
|
|
122
|
+
if (options.actionId && hasAcceptedSessionAction(runSessionManager, options.actionId)) {
|
|
123
|
+
throw new Error("OpenCandle already accepted this action in the active session.");
|
|
124
|
+
}
|
|
125
|
+
if (options.actionId && hasPendingSessionAction(runSessionManager, options.actionId)) {
|
|
126
|
+
throw new Error("OpenCandle is reconnecting to this session.");
|
|
127
|
+
}
|
|
80
128
|
let acquiredLockScope = "";
|
|
81
129
|
let lockHeartbeat: ReturnType<typeof setInterval> | undefined;
|
|
82
|
-
|
|
130
|
+
const needsWriterLock = !useCurrentSession || role !== "writer";
|
|
131
|
+
// The writer fast path must fail closed like chat runs when this process
|
|
132
|
+
// lost its lock scope migration, or it can double-write to a session
|
|
133
|
+
// another process now owns.
|
|
134
|
+
if (!needsWriterLock) syncWriterLockScope?.();
|
|
135
|
+
if (needsWriterLock) {
|
|
83
136
|
const lockScope = writerLockScopeForSession(runSessionManager);
|
|
84
|
-
const lockResult = await acquireWriterLock(lockScope, "gui"
|
|
137
|
+
const lockResult = await acquireWriterLock(lockScope, "gui", {
|
|
138
|
+
coordinatorEndpoint: localCoordinatorEndpoint,
|
|
139
|
+
coordinatorSecret: localCoordinatorSecret,
|
|
140
|
+
});
|
|
85
141
|
if (lockResult.role !== "writer") {
|
|
86
|
-
throw new Error(
|
|
87
|
-
`Session is currently being written by ${lockResult.lock.processKind} (pid ${lockResult.lock.pid}).`,
|
|
88
|
-
);
|
|
142
|
+
throw new Error("OpenCandle is reconnecting to this session.");
|
|
89
143
|
}
|
|
90
144
|
acquiredLockScope = lockScope;
|
|
91
145
|
lockHeartbeat = setInterval(() => refreshWriterLock(lockScope), 5000);
|
|
@@ -93,12 +147,27 @@ export function createToolInvokeController({
|
|
|
93
147
|
|
|
94
148
|
try {
|
|
95
149
|
const runSessionId = safeSessionId(runSessionManager);
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
150
|
+
recordPendingSessionAction(runSessionManager, options.actionId ?? "");
|
|
151
|
+
let actionAccepted = false;
|
|
152
|
+
const recordAcceptedAction = () => {
|
|
153
|
+
if (actionAccepted) return;
|
|
154
|
+
recordAcceptedSessionAction(runSessionManager, options.actionId ?? "");
|
|
155
|
+
actionAccepted = true;
|
|
156
|
+
};
|
|
157
|
+
let result: InvokeToolResult;
|
|
158
|
+
try {
|
|
159
|
+
result = await invokeTool(runSessionManager, tool, args, "ui", {
|
|
160
|
+
askUserHandler:
|
|
161
|
+
runSessionId && askUserHandlerForSessionId
|
|
162
|
+
? askUserHandlerForSessionId(runSessionId)
|
|
163
|
+
: askUserHandler,
|
|
164
|
+
onTranscriptStarted: recordAcceptedAction,
|
|
165
|
+
});
|
|
166
|
+
} catch (error) {
|
|
167
|
+
if (!actionAccepted) clearPendingSessionAction(runSessionManager, options.actionId ?? "");
|
|
168
|
+
throw error;
|
|
169
|
+
}
|
|
170
|
+
recordAcceptedAction();
|
|
102
171
|
if (!result.isError && marketStateToolMapping(toolName) != null) {
|
|
103
172
|
onMarketStateChanged?.();
|
|
104
173
|
}
|
|
@@ -120,10 +189,28 @@ export function createToolInvokeController({
|
|
|
120
189
|
data: Record<string, unknown>,
|
|
121
190
|
): Promise<void> {
|
|
122
191
|
const requestId = typeof data.requestId === "string" ? data.requestId : "";
|
|
192
|
+
const actionId = typeof data.actionId === "string" ? data.actionId : "";
|
|
123
193
|
const toolName = String(data.toolName ?? "");
|
|
124
194
|
const sessionId = typeof data.sessionId === "string" ? data.sessionId : "";
|
|
195
|
+
const allowProxy = data.allowProxy !== false;
|
|
125
196
|
try {
|
|
126
|
-
const
|
|
197
|
+
const invoke = () =>
|
|
198
|
+
handleToolInvoke(toolName, requestArgs(data.args), sessionId, { actionId, allowProxy });
|
|
199
|
+
const actionResult =
|
|
200
|
+
localSessionCoordinator && actionId
|
|
201
|
+
? await localSessionCoordinator.runSessionAction(
|
|
202
|
+
{
|
|
203
|
+
sessionId: sessionId || safeSessionId(getSessionManager()),
|
|
204
|
+
actionId,
|
|
205
|
+
actionType: "tool.invoke",
|
|
206
|
+
payload: { toolName, args: requestArgs(data.args) },
|
|
207
|
+
source: "browser",
|
|
208
|
+
},
|
|
209
|
+
invoke,
|
|
210
|
+
)
|
|
211
|
+
: await invoke().then((result) => ({ ok: true as const, duplicate: false, result }));
|
|
212
|
+
if (!actionResult.ok) throw new Error(actionResult.message);
|
|
213
|
+
const result = actionResult.result;
|
|
127
214
|
if (requestId) {
|
|
128
215
|
client.send(buildToolInvokeAckMessage(requestId, toolName, result));
|
|
129
216
|
}
|
|
@@ -146,6 +233,64 @@ export function createToolInvokeController({
|
|
|
146
233
|
return { handleToolInvoke, handleToolInvokeMessage };
|
|
147
234
|
}
|
|
148
235
|
|
|
236
|
+
export function canProxyToolInvokeToCoordinator(runSessionManager: SessionManager): boolean {
|
|
237
|
+
let lockScope: string;
|
|
238
|
+
try {
|
|
239
|
+
lockScope = writerLockScopeForSession(runSessionManager);
|
|
240
|
+
} catch {
|
|
241
|
+
return false;
|
|
242
|
+
}
|
|
243
|
+
const lock = readWriterLock(lockScope);
|
|
244
|
+
return Boolean(
|
|
245
|
+
lock?.processKind === "gui" &&
|
|
246
|
+
lock.coordinatorEndpoint &&
|
|
247
|
+
lock.coordinatorSecret &&
|
|
248
|
+
lock.pid !== process.pid,
|
|
249
|
+
);
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
async function proxyToolInvokeToCoordinator(
|
|
253
|
+
runSessionManager: SessionManager,
|
|
254
|
+
toolName: string,
|
|
255
|
+
args: Record<string, unknown>,
|
|
256
|
+
actionId = "",
|
|
257
|
+
): Promise<InvokeToolResult | null> {
|
|
258
|
+
const lock = readWriterLock(writerLockScopeForSession(runSessionManager));
|
|
259
|
+
if (!lock?.coordinatorEndpoint || !lock.coordinatorSecret) return null;
|
|
260
|
+
const endpoint = new URL("/api/local-coordinator/tool-invoke", lock.coordinatorEndpoint);
|
|
261
|
+
let response: Response;
|
|
262
|
+
try {
|
|
263
|
+
response = await fetch(endpoint, {
|
|
264
|
+
method: "POST",
|
|
265
|
+
headers: {
|
|
266
|
+
"content-type": "application/json",
|
|
267
|
+
"x-opencandle-coordinator-secret": lock.coordinatorSecret,
|
|
268
|
+
},
|
|
269
|
+
body: JSON.stringify({
|
|
270
|
+
sessionId: safeSessionId(runSessionManager),
|
|
271
|
+
actionId,
|
|
272
|
+
toolName,
|
|
273
|
+
args,
|
|
274
|
+
}),
|
|
275
|
+
});
|
|
276
|
+
} catch {
|
|
277
|
+
return null;
|
|
278
|
+
}
|
|
279
|
+
const body = asRecord(await response.json());
|
|
280
|
+
const ackResult = asRecord(body.result);
|
|
281
|
+
if (!response.ok || !ackResult.toolCallId) {
|
|
282
|
+
throw new Error(String(body.error ?? "OpenCandle is reconnecting to this session."));
|
|
283
|
+
}
|
|
284
|
+
return {
|
|
285
|
+
toolCallId: String(ackResult.toolCallId),
|
|
286
|
+
result: {
|
|
287
|
+
content: Array.isArray(ackResult.content) ? ackResult.content : [],
|
|
288
|
+
details: ackResult.details,
|
|
289
|
+
},
|
|
290
|
+
isError: Boolean(ackResult.isError),
|
|
291
|
+
};
|
|
292
|
+
}
|
|
293
|
+
|
|
149
294
|
function sameSessionStorage(current: SessionManager, target: SessionManager): boolean {
|
|
150
295
|
if (current === target) return true;
|
|
151
296
|
const currentFile = safeSessionFile(current);
|
|
@@ -192,7 +337,11 @@ export async function invokeToolFromUi(
|
|
|
192
337
|
tool: AgentTool<TSchema, unknown>,
|
|
193
338
|
args: Record<string, unknown>,
|
|
194
339
|
source: "ui" | "background" = "ui",
|
|
195
|
-
options: {
|
|
340
|
+
options: {
|
|
341
|
+
askUserHandler?: AskUserHandler;
|
|
342
|
+
recordTranscript?: boolean;
|
|
343
|
+
onTranscriptStarted?: () => void;
|
|
344
|
+
} = {},
|
|
196
345
|
): Promise<InvokeToolResult> {
|
|
197
346
|
if (!Value.Check(tool.parameters, args)) {
|
|
198
347
|
const errors = [...Value.Errors(tool.parameters, args)]
|
|
@@ -222,6 +371,7 @@ export async function invokeToolFromUi(
|
|
|
222
371
|
const recordTranscript = options.recordTranscript ?? true;
|
|
223
372
|
if (recordTranscript) {
|
|
224
373
|
sessionManager.appendMessage(assistant);
|
|
374
|
+
options.onTranscriptStarted?.();
|
|
225
375
|
}
|
|
226
376
|
|
|
227
377
|
const wrapped = wrapWithDefaults(tool, getDefaults(tool.name));
|
|
@@ -299,8 +449,6 @@ function marketStateToolMapping(toolName: string): { domain: string; targetType:
|
|
|
299
449
|
return { domain: "watchlist", targetType: "watchlist_item" };
|
|
300
450
|
case "track_portfolio":
|
|
301
451
|
return { domain: "portfolio", targetType: "portfolio_lot" };
|
|
302
|
-
case "track_prediction":
|
|
303
|
-
return { domain: "predictions", targetType: "prediction" };
|
|
304
452
|
case "manage_alerts":
|
|
305
453
|
return { domain: "alerts", targetType: "alert_rule" };
|
|
306
454
|
case "daily_watchlist_report":
|
|
@@ -0,0 +1,97 @@
|
|
|
1
|
+
export type SessionActionType =
|
|
2
|
+
| "chat.prompt"
|
|
3
|
+
| "tool.invoke"
|
|
4
|
+
| "ask_user.answer"
|
|
5
|
+
| "ask_user.cancel"
|
|
6
|
+
| "run.cancel";
|
|
7
|
+
|
|
8
|
+
export type SessionActionSource = "gui" | "tui" | "browser";
|
|
9
|
+
|
|
10
|
+
export interface SessionActionEnvelope {
|
|
11
|
+
sessionId: string;
|
|
12
|
+
actionId: string;
|
|
13
|
+
actionType: SessionActionType;
|
|
14
|
+
payload: Record<string, unknown>;
|
|
15
|
+
source: SessionActionSource;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
export type SessionActionResult<T = unknown> =
|
|
19
|
+
| { ok: true; duplicate: boolean; result: T }
|
|
20
|
+
| { ok: false; code: "session_busy"; message: string };
|
|
21
|
+
|
|
22
|
+
export interface LocalSessionCoordinator {
|
|
23
|
+
runSessionAction<T>(
|
|
24
|
+
action: SessionActionEnvelope,
|
|
25
|
+
handler: (action: SessionActionEnvelope) => Promise<T>,
|
|
26
|
+
): Promise<SessionActionResult<T>>;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
export interface LocalSessionCoordinatorOptions {
|
|
30
|
+
dedupeRetentionMs?: number;
|
|
31
|
+
now?: () => number;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
interface AcceptedAction {
|
|
35
|
+
expiresAt: number;
|
|
36
|
+
result: unknown;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
const DEFAULT_DEDUPE_RETENTION_MS = 10 * 60 * 1000;
|
|
40
|
+
const BUSY_MESSAGE = "OpenCandle is still working in this session. Try again when it finishes.";
|
|
41
|
+
|
|
42
|
+
export function createLocalSessionCoordinator(
|
|
43
|
+
options: LocalSessionCoordinatorOptions = {},
|
|
44
|
+
): LocalSessionCoordinator {
|
|
45
|
+
const now = options.now ?? Date.now;
|
|
46
|
+
const dedupeRetentionMs = options.dedupeRetentionMs ?? DEFAULT_DEDUPE_RETENTION_MS;
|
|
47
|
+
const acceptedActions = new Map<string, AcceptedAction>();
|
|
48
|
+
const activeActions = new Map<string, Promise<unknown>>();
|
|
49
|
+
const activeRunSessions = new Set<string>();
|
|
50
|
+
|
|
51
|
+
async function runSessionAction<T>(
|
|
52
|
+
action: SessionActionEnvelope,
|
|
53
|
+
handler: (action: SessionActionEnvelope) => Promise<T>,
|
|
54
|
+
): Promise<SessionActionResult<T>> {
|
|
55
|
+
pruneExpiredActions();
|
|
56
|
+
const actionKey = `${action.sessionId}:${action.actionId}`;
|
|
57
|
+
const accepted = acceptedActions.get(actionKey);
|
|
58
|
+
if (accepted) return { ok: true, duplicate: true, result: accepted.result as T };
|
|
59
|
+
const active = activeActions.get(actionKey);
|
|
60
|
+
if (active) return { ok: true, duplicate: true, result: (await active) as T };
|
|
61
|
+
|
|
62
|
+
if (isRunAdmissionAction(action) && activeRunSessions.has(action.sessionId)) {
|
|
63
|
+
return { ok: false, code: "session_busy", message: BUSY_MESSAGE };
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
if (isRunAdmissionAction(action)) activeRunSessions.add(action.sessionId);
|
|
67
|
+
const runAction = (async () => {
|
|
68
|
+
const result = await handler(action);
|
|
69
|
+
acceptedActions.set(actionKey, {
|
|
70
|
+
expiresAt: now() + dedupeRetentionMs,
|
|
71
|
+
result,
|
|
72
|
+
});
|
|
73
|
+
return result;
|
|
74
|
+
})();
|
|
75
|
+
activeActions.set(actionKey, runAction);
|
|
76
|
+
try {
|
|
77
|
+
const result = await runAction;
|
|
78
|
+
return { ok: true, duplicate: false, result };
|
|
79
|
+
} finally {
|
|
80
|
+
activeActions.delete(actionKey);
|
|
81
|
+
if (isRunAdmissionAction(action)) activeRunSessions.delete(action.sessionId);
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
function pruneExpiredActions(): void {
|
|
86
|
+
const currentTime = now();
|
|
87
|
+
for (const [actionKey, accepted] of acceptedActions) {
|
|
88
|
+
if (accepted.expiresAt <= currentTime) acceptedActions.delete(actionKey);
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
return { runSessionAction };
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
function isRunAdmissionAction(action: SessionActionEnvelope): boolean {
|
|
96
|
+
return action.actionType === "chat.prompt" || action.actionType === "tool.invoke";
|
|
97
|
+
}
|
|
@@ -9,7 +9,6 @@ export interface MarketStateSnapshot {
|
|
|
9
9
|
instruments: Array<NonNullable<ReturnType<MarketStateService["getInstrument"]>>>;
|
|
10
10
|
watchlist: ReturnType<MarketStateService["listWatchlistItems"]>;
|
|
11
11
|
portfolio: ReturnType<MarketStateService["listPortfolioLots"]>;
|
|
12
|
-
predictions: ReturnType<MarketStateService["listPredictions"]>;
|
|
13
12
|
alerts: ReturnType<MarketStateService["listAlertRules"]>;
|
|
14
13
|
alertEvents: ReturnType<MarketStateService["listAlertEvents"]>;
|
|
15
14
|
alertCheckRuns: ReturnType<MarketStateService["listAlertCheckRuns"]>;
|
|
@@ -51,17 +50,6 @@ export interface MarketStateQuoteSnapshot {
|
|
|
51
50
|
stale?: boolean;
|
|
52
51
|
reason?: string;
|
|
53
52
|
}>;
|
|
54
|
-
predictionQuotes: Array<{
|
|
55
|
-
predictionId: number;
|
|
56
|
-
instrumentId: number;
|
|
57
|
-
symbol: string;
|
|
58
|
-
status: "ok" | "unavailable";
|
|
59
|
-
currentPrice?: number;
|
|
60
|
-
changePercent?: number;
|
|
61
|
-
fetchedAt?: string;
|
|
62
|
-
stale?: boolean;
|
|
63
|
-
reason?: string;
|
|
64
|
-
}>;
|
|
65
53
|
portfolioSummary: {
|
|
66
54
|
baseCurrency: string;
|
|
67
55
|
totalValue: number;
|
|
@@ -86,7 +74,6 @@ export function buildMarketStateSnapshot(db?: Database.Database): MarketStateSna
|
|
|
86
74
|
.filter((instrument) => instrument != null),
|
|
87
75
|
watchlist: service.listWatchlistItems(),
|
|
88
76
|
portfolio: service.listPortfolioLots(),
|
|
89
|
-
predictions: service.listPredictions(),
|
|
90
77
|
alerts,
|
|
91
78
|
alertEvents: service.listAlertEvents(),
|
|
92
79
|
alertCheckRuns: service.listAlertCheckRuns(),
|
|
@@ -109,13 +96,8 @@ export async function buildMarketStateQuoteSnapshot(
|
|
|
109
96
|
try {
|
|
110
97
|
const watchlist = service.listWatchlistItems();
|
|
111
98
|
const portfolio = service.listPortfolioLots();
|
|
112
|
-
const predictions = service.listPredictions();
|
|
113
99
|
const symbols = [
|
|
114
|
-
...new Set([
|
|
115
|
-
...watchlist.map((item) => item.symbol),
|
|
116
|
-
...portfolio.map((lot) => lot.symbol),
|
|
117
|
-
...predictions.map((prediction) => prediction.symbol),
|
|
118
|
-
]),
|
|
100
|
+
...new Set([...watchlist.map((item) => item.symbol), ...portfolio.map((lot) => lot.symbol)]),
|
|
119
101
|
];
|
|
120
102
|
const quoteMap = new Map<string, Awaited<ReturnType<typeof fetchQuoteSnapshot>>>();
|
|
121
103
|
for (const symbol of symbols) {
|
|
@@ -224,34 +206,10 @@ export async function buildMarketStateQuoteSnapshot(
|
|
|
224
206
|
currency: quote.currency,
|
|
225
207
|
reason: quote.reason ?? "quote unavailable",
|
|
226
208
|
}));
|
|
227
|
-
const predictionQuotes = predictions.map((prediction) => {
|
|
228
|
-
const quote = quoteMap.get(prediction.symbol);
|
|
229
|
-
if (quote == null || quote.status === "unavailable") {
|
|
230
|
-
return {
|
|
231
|
-
predictionId: prediction.id,
|
|
232
|
-
instrumentId: prediction.instrumentId,
|
|
233
|
-
symbol: prediction.symbol,
|
|
234
|
-
status: "unavailable" as const,
|
|
235
|
-
reason: quote?.reason ?? "quote unavailable",
|
|
236
|
-
};
|
|
237
|
-
}
|
|
238
|
-
return {
|
|
239
|
-
predictionId: prediction.id,
|
|
240
|
-
instrumentId: prediction.instrumentId,
|
|
241
|
-
symbol: prediction.symbol,
|
|
242
|
-
status: "ok" as const,
|
|
243
|
-
currentPrice: quote.price,
|
|
244
|
-
changePercent: quote.changePercent,
|
|
245
|
-
fetchedAt: quote.fetchedAt,
|
|
246
|
-
stale: quote.stale,
|
|
247
|
-
};
|
|
248
|
-
});
|
|
249
|
-
|
|
250
209
|
return {
|
|
251
210
|
generatedAt,
|
|
252
211
|
watchlistQuotes,
|
|
253
212
|
portfolioQuotes: portfolioQuotesWithAllocation,
|
|
254
|
-
predictionQuotes,
|
|
255
213
|
portfolioSummary: {
|
|
256
214
|
baseCurrency,
|
|
257
215
|
totalValue,
|
package/gui/server/server.ts
CHANGED
|
@@ -25,6 +25,7 @@ import {
|
|
|
25
25
|
import { createInitialGuiSessionManager } from "./gui-session-manager.js";
|
|
26
26
|
import { createHttpRequestHandler, resolveSessionManagerById } from "./http-routes.js";
|
|
27
27
|
import { createToolInvokeController } from "./invoke-tool.js";
|
|
28
|
+
import { createLocalSessionCoordinator } from "./local-session-coordinator.js";
|
|
28
29
|
import { buildMarketStateQuoteSnapshot } from "./market-state-api.js";
|
|
29
30
|
import { createModelSetupController } from "./model-setup.js";
|
|
30
31
|
import { isTrustedPrivateApiRequest } from "./private-api-access.js";
|
|
@@ -33,6 +34,7 @@ import { createSessionActionsController } from "./session-actions.js";
|
|
|
33
34
|
import { createGracefulShutdown } from "./shutdown.js";
|
|
34
35
|
import {
|
|
35
36
|
acquireWriterLock,
|
|
37
|
+
migrateWriterLockScope,
|
|
36
38
|
refreshWriterLock,
|
|
37
39
|
releaseWriterLock,
|
|
38
40
|
writerLockScopeForSession,
|
|
@@ -49,6 +51,8 @@ const automationHeartbeatMs = normalizeAutomationHeartbeatMs(
|
|
|
49
51
|
);
|
|
50
52
|
const allowRemotePrivateApi = process.env.OPENCANDLE_GUI_ALLOW_REMOTE_PRIVATE_API === "1";
|
|
51
53
|
const privateApiSessionToken = randomBytes(32).toString("base64url");
|
|
54
|
+
const localCoordinatorSecret = randomBytes(32).toString("base64url");
|
|
55
|
+
const localCoordinatorEndpoint = `http://${coordinatorEndpointHost(host)}:${port}`;
|
|
52
56
|
const __dirname = fileURLToPath(new URL(".", import.meta.url));
|
|
53
57
|
const webDist = resolve(__dirname, "../web/dist");
|
|
54
58
|
|
|
@@ -60,8 +64,12 @@ const initialSessionManager = createInitialGuiSessionManager(cwd);
|
|
|
60
64
|
let sessionManager = initialSessionManager;
|
|
61
65
|
const sessionDir = sessionManager.getSessionDir();
|
|
62
66
|
const initialWriterLockScope = writerLockScopeForSession(sessionManager);
|
|
63
|
-
const lockResult = await acquireWriterLock(initialWriterLockScope, "gui"
|
|
67
|
+
const lockResult = await acquireWriterLock(initialWriterLockScope, "gui", {
|
|
68
|
+
coordinatorEndpoint: localCoordinatorEndpoint,
|
|
69
|
+
coordinatorSecret: localCoordinatorSecret,
|
|
70
|
+
});
|
|
64
71
|
let activeWriterLockScope = initialWriterLockScope;
|
|
72
|
+
let currentWriterLockLost = false;
|
|
65
73
|
let wsHub: WsHub;
|
|
66
74
|
let quotePoller: BackgroundQuotePoller;
|
|
67
75
|
const askUserBridge = createAskUserBridge({
|
|
@@ -91,8 +99,28 @@ const runtime = await createAgentSessionRuntime(
|
|
|
91
99
|
{ cwd, agentDir, sessionManager },
|
|
92
100
|
);
|
|
93
101
|
let session = runtime.session;
|
|
94
|
-
|
|
102
|
+
function syncCurrentWriterLockScope(): void {
|
|
103
|
+
if (currentWriterLockLost) throw new Error("OpenCandle is reconnecting to this session.");
|
|
104
|
+
if (lockResult.role !== "writer") return;
|
|
105
|
+
const nextScope = writerLockScopeForSession(sessionManager);
|
|
106
|
+
if (nextScope === activeWriterLockScope) return;
|
|
107
|
+
if (migrateWriterLockScope(activeWriterLockScope, nextScope)) {
|
|
108
|
+
activeWriterLockScope = nextScope;
|
|
109
|
+
} else {
|
|
110
|
+
currentWriterLockLost = true;
|
|
111
|
+
throw new Error("OpenCandle is reconnecting to this session.");
|
|
112
|
+
}
|
|
113
|
+
}
|
|
114
|
+
const heartbeat = setInterval(() => {
|
|
115
|
+
try {
|
|
116
|
+
syncCurrentWriterLockScope();
|
|
117
|
+
refreshWriterLock(activeWriterLockScope);
|
|
118
|
+
} catch {
|
|
119
|
+
clearInterval(heartbeat);
|
|
120
|
+
}
|
|
121
|
+
}, 5000);
|
|
95
122
|
const backgroundQuoteRefreshes = new BackgroundQuoteRefreshes();
|
|
123
|
+
const localSessionCoordinator = createLocalSessionCoordinator();
|
|
96
124
|
const quoteSnapshotStore = new QuoteSnapshotStore(() => buildMarketStateQuoteSnapshot());
|
|
97
125
|
quotePoller = createBackgroundQuotePoller({
|
|
98
126
|
getClientCount: () => wsHub.getClientCount(),
|
|
@@ -121,6 +149,10 @@ const toolInvokeController = createToolInvokeController({
|
|
|
121
149
|
onMarketStateChanged: () => quoteSnapshotStore.invalidate(),
|
|
122
150
|
askUserHandler: askUserBridge.ask,
|
|
123
151
|
askUserHandlerForSessionId: (sessionId) => askUserBridge.askForSession(sessionId),
|
|
152
|
+
localCoordinatorEndpoint,
|
|
153
|
+
localCoordinatorSecret,
|
|
154
|
+
localSessionCoordinator,
|
|
155
|
+
syncWriterLockScope: syncCurrentWriterLockScope,
|
|
124
156
|
resolveSessionManager: (sessionId) =>
|
|
125
157
|
resolveSessionManagerById(
|
|
126
158
|
{ cwd, sessionDir, getSessionManager: () => sessionManager },
|
|
@@ -139,6 +171,7 @@ const sessionActionsController = createSessionActionsController({
|
|
|
139
171
|
sendBoot: (client) => wsHub.sendBoot(client),
|
|
140
172
|
broadcastState: () => wsHub.broadcastState(),
|
|
141
173
|
broadcastSessions: () => wsHub.broadcastSessions(),
|
|
174
|
+
localSessionCoordinator,
|
|
142
175
|
});
|
|
143
176
|
wsHub = createWsHub({
|
|
144
177
|
role: lockResult.role,
|
|
@@ -163,11 +196,12 @@ let unsubscribeSession = wsHub.subscribeToSessionEvents();
|
|
|
163
196
|
runtime.setRebindSession(async (nextSession) => {
|
|
164
197
|
const nextWriterLockScope = writerLockScopeForSession(nextSession.sessionManager);
|
|
165
198
|
if (lockResult.role === "writer" && nextWriterLockScope !== activeWriterLockScope) {
|
|
166
|
-
const nextLockResult = await acquireWriterLock(nextWriterLockScope, "gui"
|
|
199
|
+
const nextLockResult = await acquireWriterLock(nextWriterLockScope, "gui", {
|
|
200
|
+
coordinatorEndpoint: localCoordinatorEndpoint,
|
|
201
|
+
coordinatorSecret: localCoordinatorSecret,
|
|
202
|
+
});
|
|
167
203
|
if (nextLockResult.role !== "writer") {
|
|
168
|
-
throw new Error(
|
|
169
|
-
`Session is currently being written by ${nextLockResult.lock.processKind} (pid ${nextLockResult.lock.pid}).`,
|
|
170
|
-
);
|
|
204
|
+
throw new Error("OpenCandle is reconnecting to this session.");
|
|
171
205
|
}
|
|
172
206
|
releaseWriterLock(activeWriterLockScope);
|
|
173
207
|
activeWriterLockScope = nextWriterLockScope;
|
|
@@ -187,7 +221,10 @@ const httpRequestHandler = createHttpRequestHandler({
|
|
|
187
221
|
agentDir,
|
|
188
222
|
sessionDir,
|
|
189
223
|
privateApiSessionToken,
|
|
224
|
+
localCoordinatorEndpoint,
|
|
225
|
+
localCoordinatorSecret,
|
|
190
226
|
allowRemotePrivateApi,
|
|
227
|
+
syncCurrentWriterLockScope,
|
|
191
228
|
getSession: () => session,
|
|
192
229
|
getSessionManager: () => sessionManager,
|
|
193
230
|
createSessionForManager: async (targetSessionManager) =>
|
|
@@ -203,7 +240,9 @@ const httpRequestHandler = createHttpRequestHandler({
|
|
|
203
240
|
wsHub,
|
|
204
241
|
modelSetupController,
|
|
205
242
|
sessionActionsController,
|
|
243
|
+
toolInvokeController,
|
|
206
244
|
quoteSnapshotStore,
|
|
245
|
+
localSessionCoordinator,
|
|
207
246
|
});
|
|
208
247
|
|
|
209
248
|
const server = createServer((req, res) => {
|
|
@@ -240,5 +279,11 @@ const shutdown = createGracefulShutdown({
|
|
|
240
279
|
exit: (code) => process.exit(code),
|
|
241
280
|
});
|
|
242
281
|
|
|
282
|
+
function coordinatorEndpointHost(bindHost: string): string {
|
|
283
|
+
if (bindHost === "0.0.0.0") return "127.0.0.1";
|
|
284
|
+
if (bindHost === "::") return "[::1]";
|
|
285
|
+
return bindHost.includes(":") && !bindHost.startsWith("[") ? `[${bindHost}]` : bindHost;
|
|
286
|
+
}
|
|
287
|
+
|
|
243
288
|
process.once("SIGINT", shutdown);
|
|
244
289
|
process.once("SIGTERM", shutdown);
|