opencandle 0.10.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-B7QAjY5g.js → index-DvMjkOP9.js} +9 -9
- package/gui/web/dist/index.html +2 -2
- package/package.json +6 -3
- 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-CYptsda-.js +0 -1
- package/gui/web/dist/assets/index-D5dbWPfM.css +0 -2
- package/src/tools/portfolio/predictions.ts +0 -553
|
@@ -4,10 +4,23 @@ import { extname, join, resolve } from "node:path";
|
|
|
4
4
|
import { type AgentSession, SessionManager } from "@earendil-works/pi-coding-agent";
|
|
5
5
|
import { buildDoctorReport } from "../../src/doctor/report.js";
|
|
6
6
|
import { probeProviderStatus } from "../../src/onboarding/provider-status.js";
|
|
7
|
+
import {
|
|
8
|
+
clearPendingSessionAction,
|
|
9
|
+
hasAcceptedSessionAction,
|
|
10
|
+
hasPendingSessionAction,
|
|
11
|
+
recordAcceptedSessionAction,
|
|
12
|
+
recordPendingSessionAction,
|
|
13
|
+
} from "../../src/pi/session-action-dedupe.js";
|
|
7
14
|
import type { ChatEvent } from "../shared/chat-events.js";
|
|
8
15
|
import { sessionEntriesToChatEvents } from "./chat-event-adapter.js";
|
|
9
16
|
import { chatRunSessionConflict } from "./chat-run-session.js";
|
|
17
|
+
import type { ToolInvokeController } from "./invoke-tool.js";
|
|
10
18
|
import { createLiveChatEventAdapter } from "./live-chat-event-adapter.js";
|
|
19
|
+
import type {
|
|
20
|
+
LocalSessionCoordinator,
|
|
21
|
+
SessionActionEnvelope,
|
|
22
|
+
SessionActionResult,
|
|
23
|
+
} from "./local-session-coordinator.js";
|
|
11
24
|
import { buildMarketStateSnapshot, searchInstrumentCandidates } from "./market-state-api.js";
|
|
12
25
|
import { buildModelSetupState, type ModelSetupController } from "./model-setup.js";
|
|
13
26
|
import { isTrustedPrivateApiRequest, privateApiCookieHeader } from "./private-api-access.js";
|
|
@@ -22,6 +35,7 @@ import {
|
|
|
22
35
|
readWriterLock,
|
|
23
36
|
refreshWriterLock,
|
|
24
37
|
releaseWriterLock,
|
|
38
|
+
shouldBlockFailedCoordinatorAction as shouldBlockFailedCoordinatorLockAction,
|
|
25
39
|
writerLockScopeForSession,
|
|
26
40
|
} from "./writer-lock.js";
|
|
27
41
|
import type { WsHub } from "./ws-hub.js";
|
|
@@ -35,14 +49,19 @@ interface GuiHttpRouteOptions {
|
|
|
35
49
|
agentDir: string;
|
|
36
50
|
sessionDir: string;
|
|
37
51
|
privateApiSessionToken: string;
|
|
52
|
+
localCoordinatorEndpoint: string;
|
|
53
|
+
localCoordinatorSecret: string;
|
|
38
54
|
allowRemotePrivateApi: boolean;
|
|
55
|
+
syncCurrentWriterLockScope?: () => void;
|
|
39
56
|
getSession: () => AgentSession;
|
|
40
57
|
getSessionManager: () => SessionManager;
|
|
41
58
|
createSessionForManager: (sessionManager: SessionManager) => Promise<{ session: AgentSession }>;
|
|
42
59
|
wsHub: WsHub;
|
|
43
60
|
modelSetupController: ModelSetupController;
|
|
44
61
|
sessionActionsController: SessionActionsController;
|
|
62
|
+
toolInvokeController: ToolInvokeController;
|
|
45
63
|
quoteSnapshotStore: QuoteSnapshotStore;
|
|
64
|
+
localSessionCoordinator?: LocalSessionCoordinator;
|
|
46
65
|
}
|
|
47
66
|
|
|
48
67
|
export function createHttpRequestHandler(options: GuiHttpRouteOptions) {
|
|
@@ -67,7 +86,11 @@ export function createHttpRequestHandler(options: GuiHttpRouteOptions) {
|
|
|
67
86
|
if (url.pathname === "/api/session/new" && req.method === "POST") {
|
|
68
87
|
if (!allowTrustedGuiRequest(req, res, "Session API", options)) return;
|
|
69
88
|
if (options.role !== "writer") {
|
|
70
|
-
writeJson(
|
|
89
|
+
writeJson(
|
|
90
|
+
res,
|
|
91
|
+
{ error: "OpenCandle is reconnecting to this session.", code: "syncing" },
|
|
92
|
+
409,
|
|
93
|
+
);
|
|
71
94
|
return;
|
|
72
95
|
}
|
|
73
96
|
await options.sessionActionsController.handleNewSession();
|
|
@@ -215,6 +238,84 @@ export function createHttpRequestHandler(options: GuiHttpRouteOptions) {
|
|
|
215
238
|
return;
|
|
216
239
|
}
|
|
217
240
|
|
|
241
|
+
if (url.pathname === "/api/local-coordinator/chat-run" && req.method === "POST") {
|
|
242
|
+
if (!allowLocalCoordinatorRequest(req, res, options)) return;
|
|
243
|
+
const body = asRecord(await readJsonBody(req));
|
|
244
|
+
const requestedSessionId = String(body.sessionId ?? "").trim();
|
|
245
|
+
const sessionManager = requestedSessionId
|
|
246
|
+
? await resolveSessionManagerById(options, requestedSessionId)
|
|
247
|
+
: undefined;
|
|
248
|
+
if (requestedSessionId && !sessionManager) {
|
|
249
|
+
writeJson(res, { error: "Unknown saved session" }, 404);
|
|
250
|
+
return;
|
|
251
|
+
}
|
|
252
|
+
await handleSseChatRun(req, res, options, activeRunSessionIds, sessionManager, body);
|
|
253
|
+
return;
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
if (url.pathname === "/api/local-coordinator/tool-invoke" && req.method === "POST") {
|
|
257
|
+
if (!allowLocalCoordinatorRequest(req, res, options)) return;
|
|
258
|
+
const body = asRecord(await readJsonBody(req));
|
|
259
|
+
let ack: unknown;
|
|
260
|
+
await options.toolInvokeController.handleToolInvokeMessage(
|
|
261
|
+
{ send: (message) => (ack = message) },
|
|
262
|
+
{
|
|
263
|
+
requestId: "local-coordinator-tool",
|
|
264
|
+
actionId: String(body.actionId ?? ""),
|
|
265
|
+
sessionId: String(body.sessionId ?? ""),
|
|
266
|
+
toolName: String(body.toolName ?? ""),
|
|
267
|
+
args: asRecord(body.args),
|
|
268
|
+
allowProxy: false,
|
|
269
|
+
},
|
|
270
|
+
);
|
|
271
|
+
const message = asRecord(ack);
|
|
272
|
+
const result = asRecord(message.result);
|
|
273
|
+
if (result.toolCallId) {
|
|
274
|
+
writeJson(res, { result });
|
|
275
|
+
} else {
|
|
276
|
+
writeJson(
|
|
277
|
+
res,
|
|
278
|
+
{ error: String(asRecord(message.error).message ?? "Tool invocation failed") },
|
|
279
|
+
409,
|
|
280
|
+
);
|
|
281
|
+
}
|
|
282
|
+
return;
|
|
283
|
+
}
|
|
284
|
+
|
|
285
|
+
if (url.pathname === "/api/local-coordinator/ask-user" && req.method === "POST") {
|
|
286
|
+
if (!allowLocalCoordinatorRequest(req, res, options)) return;
|
|
287
|
+
const body = asRecord(await readJsonBody(req));
|
|
288
|
+
const payload = asRecord(body.payload);
|
|
289
|
+
try {
|
|
290
|
+
const action = {
|
|
291
|
+
actionId: String(body.actionId ?? ""),
|
|
292
|
+
sessionId: String(body.sessionId ?? ""),
|
|
293
|
+
source: "browser" as const,
|
|
294
|
+
allowProxy: false,
|
|
295
|
+
};
|
|
296
|
+
if (body.actionType === "ask_user.answer") {
|
|
297
|
+
await options.sessionActionsController.handleAskUserAnswer(
|
|
298
|
+
String(payload.id ?? ""),
|
|
299
|
+
payload.answer,
|
|
300
|
+
action,
|
|
301
|
+
);
|
|
302
|
+
} else if (body.actionType === "ask_user.cancel") {
|
|
303
|
+
await options.sessionActionsController.handleAskUserCancel(
|
|
304
|
+
String(payload.id ?? ""),
|
|
305
|
+
action,
|
|
306
|
+
);
|
|
307
|
+
} else {
|
|
308
|
+
writeJson(res, { error: "Unknown ask_user action" }, 400);
|
|
309
|
+
return;
|
|
310
|
+
}
|
|
311
|
+
writeJson(res, { ok: true });
|
|
312
|
+
} catch (error) {
|
|
313
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
314
|
+
writeJson(res, { error: message }, 409);
|
|
315
|
+
}
|
|
316
|
+
return;
|
|
317
|
+
}
|
|
318
|
+
|
|
218
319
|
const runSessionId = sessionIdFromRoute(url.pathname, "runs");
|
|
219
320
|
if (runSessionId && req.method === "POST") {
|
|
220
321
|
if (!allowTrustedGuiRequest(req, res, "Chat run API", options)) return;
|
|
@@ -237,13 +338,9 @@ async function handleSseChatRun(
|
|
|
237
338
|
options: GuiHttpRouteOptions,
|
|
238
339
|
activeRunSessionIds: Set<string>,
|
|
239
340
|
targetSessionManager?: SessionManager,
|
|
341
|
+
bodyOverride?: Record<string, unknown>,
|
|
240
342
|
): Promise<void> {
|
|
241
|
-
|
|
242
|
-
writeJson(res, { error: "Read-only follower mode" }, 409);
|
|
243
|
-
return;
|
|
244
|
-
}
|
|
245
|
-
|
|
246
|
-
const body = await readJsonBody(req);
|
|
343
|
+
const body = bodyOverride ?? asRecord(await readJsonBody(req));
|
|
247
344
|
const prompt = String(asRecord(body).prompt ?? "").trim();
|
|
248
345
|
if (!prompt) {
|
|
249
346
|
writeJson(res, { error: "prompt is required" }, 400);
|
|
@@ -251,7 +348,8 @@ async function handleSseChatRun(
|
|
|
251
348
|
}
|
|
252
349
|
|
|
253
350
|
const currentSessionManager = options.getSessionManager();
|
|
254
|
-
const
|
|
351
|
+
const bodyRecord = asRecord(body);
|
|
352
|
+
const requestedSessionId = String(bodyRecord.sessionId ?? "").trim();
|
|
255
353
|
const runSessionManager = targetSessionManager ?? currentSessionManager;
|
|
256
354
|
const sessionId = runSessionManager.getSessionId();
|
|
257
355
|
if (!targetSessionManager) {
|
|
@@ -269,9 +367,153 @@ async function handleSseChatRun(
|
|
|
269
367
|
return;
|
|
270
368
|
}
|
|
271
369
|
|
|
370
|
+
const proxyAllowed = bodyOverride === undefined;
|
|
371
|
+
const actionId = String(bodyRecord.actionId ?? "").trim();
|
|
372
|
+
const shouldProxyChatRun = proxyAllowed && canProxyChatRunToCoordinator(runSessionManager);
|
|
373
|
+
if (shouldProxyChatRun) {
|
|
374
|
+
if (await proxyChatRunToCoordinator(res, runSessionManager, bodyRecord)) {
|
|
375
|
+
const useCurrentSession =
|
|
376
|
+
!targetSessionManager ||
|
|
377
|
+
currentSessionManager.getSessionFile() === runSessionManager.getSessionFile();
|
|
378
|
+
await broadcastFreshRunSessionSnapshot(options, runSessionManager, useCurrentSession);
|
|
379
|
+
return;
|
|
380
|
+
}
|
|
381
|
+
if (actionId && hasAcceptedSessionAction(runSessionManager, actionId)) {
|
|
382
|
+
writeJson(res, { ok: true, duplicate: true });
|
|
383
|
+
return;
|
|
384
|
+
}
|
|
385
|
+
if (actionId && hasPendingSessionAction(runSessionManager, actionId)) {
|
|
386
|
+
writeJson(
|
|
387
|
+
res,
|
|
388
|
+
{ error: "OpenCandle is reconnecting to this session.", code: "syncing" },
|
|
389
|
+
409,
|
|
390
|
+
);
|
|
391
|
+
return;
|
|
392
|
+
}
|
|
393
|
+
if (shouldBlockFailedCoordinatorAction(runSessionManager, bodyRecord)) {
|
|
394
|
+
writeJson(
|
|
395
|
+
res,
|
|
396
|
+
{ error: "OpenCandle is reconnecting to this session.", code: "syncing" },
|
|
397
|
+
409,
|
|
398
|
+
);
|
|
399
|
+
return;
|
|
400
|
+
}
|
|
401
|
+
}
|
|
402
|
+
if (actionId && hasAcceptedSessionAction(runSessionManager, actionId)) {
|
|
403
|
+
writeJson(res, { ok: true, duplicate: true });
|
|
404
|
+
return;
|
|
405
|
+
}
|
|
406
|
+
if (actionId && hasPendingSessionAction(runSessionManager, actionId)) {
|
|
407
|
+
writeJson(res, { error: "OpenCandle is reconnecting to this session.", code: "syncing" }, 409);
|
|
408
|
+
return;
|
|
409
|
+
}
|
|
410
|
+
|
|
411
|
+
if (options.localSessionCoordinator) {
|
|
412
|
+
const action = buildChatRunActionEnvelope(bodyRecord, sessionId);
|
|
413
|
+
let result: SessionActionResult<{ streamed: boolean }>;
|
|
414
|
+
try {
|
|
415
|
+
result = await options.localSessionCoordinator.runSessionAction(action, async () => {
|
|
416
|
+
const admitted = await streamAcceptedSseChatRun({
|
|
417
|
+
res,
|
|
418
|
+
options,
|
|
419
|
+
activeRunSessionIds,
|
|
420
|
+
targetSessionManager,
|
|
421
|
+
currentSessionManager,
|
|
422
|
+
runSessionManager,
|
|
423
|
+
sessionId,
|
|
424
|
+
prompt,
|
|
425
|
+
actionId: action.actionId,
|
|
426
|
+
});
|
|
427
|
+
if (!admitted) throw new SessionActionNotAdmitted();
|
|
428
|
+
return { streamed: true };
|
|
429
|
+
});
|
|
430
|
+
} catch (error) {
|
|
431
|
+
if (error instanceof SessionActionNotAdmitted) return;
|
|
432
|
+
throw error;
|
|
433
|
+
}
|
|
434
|
+
if (!result.ok) {
|
|
435
|
+
writeJson(res, { error: result.message, code: result.code }, 409);
|
|
436
|
+
return;
|
|
437
|
+
}
|
|
438
|
+
if (result.duplicate && !res.headersSent) {
|
|
439
|
+
writeJson(res, { ok: true, duplicate: true });
|
|
440
|
+
}
|
|
441
|
+
return;
|
|
442
|
+
}
|
|
443
|
+
|
|
444
|
+
await streamAcceptedSseChatRun({
|
|
445
|
+
res,
|
|
446
|
+
options,
|
|
447
|
+
activeRunSessionIds,
|
|
448
|
+
targetSessionManager,
|
|
449
|
+
currentSessionManager,
|
|
450
|
+
runSessionManager,
|
|
451
|
+
sessionId,
|
|
452
|
+
prompt,
|
|
453
|
+
actionId,
|
|
454
|
+
});
|
|
455
|
+
}
|
|
456
|
+
|
|
457
|
+
export function canProxyChatRunToCoordinator(runSessionManager: SessionManager): boolean {
|
|
458
|
+
const lock = readWriterLock(writerLockScopeForSession(runSessionManager));
|
|
459
|
+
return Boolean(lock?.coordinatorEndpoint && lock.coordinatorSecret && lock.pid !== process.pid);
|
|
460
|
+
}
|
|
461
|
+
|
|
462
|
+
async function proxyChatRunToCoordinator(
|
|
463
|
+
res: ServerResponse,
|
|
464
|
+
runSessionManager: SessionManager,
|
|
465
|
+
body: Record<string, unknown>,
|
|
466
|
+
): Promise<boolean> {
|
|
467
|
+
const lock = readWriterLock(writerLockScopeForSession(runSessionManager));
|
|
468
|
+
if (!lock?.coordinatorEndpoint || !lock.coordinatorSecret) return false;
|
|
469
|
+
const endpoint = new URL("/api/local-coordinator/chat-run", lock.coordinatorEndpoint);
|
|
470
|
+
let response: Response;
|
|
471
|
+
try {
|
|
472
|
+
response = await fetch(endpoint, {
|
|
473
|
+
method: "POST",
|
|
474
|
+
headers: {
|
|
475
|
+
"content-type": "application/json",
|
|
476
|
+
"x-opencandle-coordinator-secret": lock.coordinatorSecret,
|
|
477
|
+
},
|
|
478
|
+
body: JSON.stringify({ ...body, sessionId: runSessionManager.getSessionId() }),
|
|
479
|
+
});
|
|
480
|
+
} catch {
|
|
481
|
+
return false;
|
|
482
|
+
}
|
|
483
|
+
res.writeHead(response.status, Object.fromEntries(response.headers));
|
|
484
|
+
if (response.body) {
|
|
485
|
+
for await (const chunk of response.body) {
|
|
486
|
+
res.write(chunk);
|
|
487
|
+
}
|
|
488
|
+
}
|
|
489
|
+
res.end();
|
|
490
|
+
return true;
|
|
491
|
+
}
|
|
492
|
+
|
|
493
|
+
async function streamAcceptedSseChatRun({
|
|
494
|
+
res,
|
|
495
|
+
options,
|
|
496
|
+
activeRunSessionIds,
|
|
497
|
+
targetSessionManager,
|
|
498
|
+
currentSessionManager,
|
|
499
|
+
runSessionManager,
|
|
500
|
+
sessionId,
|
|
501
|
+
prompt,
|
|
502
|
+
actionId,
|
|
503
|
+
}: {
|
|
504
|
+
res: ServerResponse;
|
|
505
|
+
options: GuiHttpRouteOptions;
|
|
506
|
+
activeRunSessionIds: Set<string>;
|
|
507
|
+
targetSessionManager?: SessionManager;
|
|
508
|
+
currentSessionManager: SessionManager;
|
|
509
|
+
runSessionManager: SessionManager;
|
|
510
|
+
sessionId: string;
|
|
511
|
+
prompt: string;
|
|
512
|
+
actionId: string;
|
|
513
|
+
}): Promise<boolean> {
|
|
272
514
|
if (activeRunSessionIds.has(sessionId)) {
|
|
273
515
|
writeJson(res, { error: "Session already has an active run", code: "session_busy" }, 409);
|
|
274
|
-
return;
|
|
516
|
+
return false;
|
|
275
517
|
}
|
|
276
518
|
|
|
277
519
|
const currentSessionFile = currentSessionManager.getSessionFile();
|
|
@@ -279,12 +521,20 @@ async function handleSseChatRun(
|
|
|
279
521
|
const useCurrentSession = !targetSessionManager || currentSessionFile === targetSessionFile;
|
|
280
522
|
let acquiredLockScope = "";
|
|
281
523
|
let lockHeartbeat: ReturnType<typeof setInterval> | undefined;
|
|
282
|
-
|
|
524
|
+
const needsWriterLock = !useCurrentSession || options.role !== "writer";
|
|
525
|
+
if (needsWriterLock) {
|
|
283
526
|
const lockScope = writerLockScopeForSession(runSessionManager);
|
|
284
|
-
const lockResult = await acquireWriterLock(lockScope, "gui"
|
|
527
|
+
const lockResult = await acquireWriterLock(lockScope, "gui", {
|
|
528
|
+
coordinatorEndpoint: options.localCoordinatorEndpoint,
|
|
529
|
+
coordinatorSecret: options.localCoordinatorSecret,
|
|
530
|
+
});
|
|
285
531
|
if (lockResult.role !== "writer") {
|
|
286
|
-
writeJson(
|
|
287
|
-
|
|
532
|
+
writeJson(
|
|
533
|
+
res,
|
|
534
|
+
{ error: "OpenCandle is reconnecting to this session.", code: "syncing" },
|
|
535
|
+
409,
|
|
536
|
+
);
|
|
537
|
+
return false;
|
|
288
538
|
}
|
|
289
539
|
acquiredLockScope = lockScope;
|
|
290
540
|
lockHeartbeat = setInterval(() => refreshWriterLock(lockScope), 5000);
|
|
@@ -302,7 +552,7 @@ async function handleSseChatRun(
|
|
|
302
552
|
if (acquiredLockScope) releaseWriterLock(acquiredLockScope);
|
|
303
553
|
const message = error instanceof Error ? error.message : String(error);
|
|
304
554
|
writeJson(res, { error: message }, 500);
|
|
305
|
-
return;
|
|
555
|
+
return false;
|
|
306
556
|
}
|
|
307
557
|
|
|
308
558
|
res.writeHead(200, {
|
|
@@ -331,15 +581,31 @@ async function handleSseChatRun(
|
|
|
331
581
|
originalPrompt: prompt,
|
|
332
582
|
});
|
|
333
583
|
const observation = createPromptObservation();
|
|
584
|
+
let actionAccepted = false;
|
|
585
|
+
const recordAcceptedAction = () => {
|
|
586
|
+
if (actionAccepted) return;
|
|
587
|
+
recordAcceptedSessionAction(runSessionManager, actionId);
|
|
588
|
+
if (useCurrentSession) options.syncCurrentWriterLockScope?.();
|
|
589
|
+
actionAccepted = true;
|
|
590
|
+
};
|
|
334
591
|
const unsubscribeLive = runSession.subscribe((event) => {
|
|
335
592
|
liveAdapter.handle(event);
|
|
336
593
|
observePromptEvent(observation, event);
|
|
594
|
+
if (
|
|
595
|
+
!prompt.startsWith("/") &&
|
|
596
|
+
!actionAccepted &&
|
|
597
|
+
observation.userTexts.some((text) => text.trim() === prompt.trim())
|
|
598
|
+
) {
|
|
599
|
+
recordAcceptedAction();
|
|
600
|
+
}
|
|
337
601
|
});
|
|
338
602
|
|
|
339
603
|
try {
|
|
604
|
+
recordPendingSessionAction(runSessionManager, actionId);
|
|
340
605
|
const modelSetup = buildModelSetupState(runSession.modelRegistry, runSession.model);
|
|
341
606
|
if (!prompt.startsWith("/") && modelSetup.requirement !== "ready") {
|
|
342
607
|
runSessionManager.appendMessage({ role: "user", content: prompt, timestamp: Date.now() });
|
|
608
|
+
recordAcceptedAction();
|
|
343
609
|
const message =
|
|
344
610
|
modelSetup.requirement === "select_model"
|
|
345
611
|
? "Choose an available model before chat can run. OpenCandle found configured credentials but no active model."
|
|
@@ -348,10 +614,11 @@ async function handleSseChatRun(
|
|
|
348
614
|
source: "gui",
|
|
349
615
|
requirement: modelSetup.requirement,
|
|
350
616
|
});
|
|
351
|
-
broadcastRunSessionSnapshot(options, runSessionManager, useCurrentSession);
|
|
617
|
+
await broadcastRunSessionSnapshot(options, runSessionManager, useCurrentSession);
|
|
352
618
|
} else {
|
|
353
619
|
await promptAndSettle(runSession, prompt, beforeIds, observation);
|
|
354
|
-
|
|
620
|
+
recordAcceptedAction();
|
|
621
|
+
await broadcastRunSessionSnapshot(options, runSessionManager, useCurrentSession);
|
|
355
622
|
}
|
|
356
623
|
seq = liveAdapter.nextSeq();
|
|
357
624
|
if (seq === liveStartSeq) {
|
|
@@ -375,6 +642,7 @@ async function handleSseChatRun(
|
|
|
375
642
|
}
|
|
376
643
|
writeSse(res, { type: "run.completed", runId, sessionId, seq });
|
|
377
644
|
} catch (error) {
|
|
645
|
+
if (!actionAccepted) clearPendingSessionAction(runSessionManager, actionId);
|
|
378
646
|
seq = liveAdapter.nextSeq();
|
|
379
647
|
const message = error instanceof Error ? error.message : String(error);
|
|
380
648
|
writeSse(res, { type: "run.failed", runId, sessionId, error: { message }, seq });
|
|
@@ -386,14 +654,18 @@ async function handleSseChatRun(
|
|
|
386
654
|
if (acquiredLockScope) releaseWriterLock(acquiredLockScope);
|
|
387
655
|
res.end();
|
|
388
656
|
}
|
|
657
|
+
return actionAccepted;
|
|
389
658
|
}
|
|
390
659
|
|
|
391
|
-
|
|
660
|
+
class SessionActionNotAdmitted extends Error {}
|
|
661
|
+
|
|
662
|
+
async function broadcastRunSessionSnapshot(
|
|
392
663
|
options: GuiHttpRouteOptions,
|
|
393
664
|
sessionManager: SessionManager,
|
|
394
665
|
useCurrentSession: boolean,
|
|
395
|
-
): void {
|
|
666
|
+
): Promise<void> {
|
|
396
667
|
if (useCurrentSession) {
|
|
668
|
+
options.syncCurrentWriterLockScope?.();
|
|
397
669
|
options.wsHub.broadcastState();
|
|
398
670
|
} else {
|
|
399
671
|
options.wsHub.broadcastSessionSnapshot(sessionManager);
|
|
@@ -401,6 +673,51 @@ function broadcastRunSessionSnapshot(
|
|
|
401
673
|
}
|
|
402
674
|
}
|
|
403
675
|
|
|
676
|
+
async function broadcastFreshRunSessionSnapshot(
|
|
677
|
+
options: GuiHttpRouteOptions,
|
|
678
|
+
sessionManager: SessionManager,
|
|
679
|
+
useCurrentSession: boolean,
|
|
680
|
+
): Promise<void> {
|
|
681
|
+
const freshSessionManager = await reloadSessionManager(sessionManager, options);
|
|
682
|
+
if (useCurrentSession) {
|
|
683
|
+
options.syncCurrentWriterLockScope?.();
|
|
684
|
+
options.wsHub.broadcast({
|
|
685
|
+
type: "state.snapshot",
|
|
686
|
+
...buildSnapshotPayload(freshSessionManager),
|
|
687
|
+
});
|
|
688
|
+
} else {
|
|
689
|
+
options.wsHub.broadcast({
|
|
690
|
+
type: "session.snapshot",
|
|
691
|
+
...buildSnapshotPayload(freshSessionManager),
|
|
692
|
+
});
|
|
693
|
+
options.wsHub.broadcastSessions();
|
|
694
|
+
}
|
|
695
|
+
}
|
|
696
|
+
|
|
697
|
+
async function reloadSessionManager(
|
|
698
|
+
sessionManager: SessionManager,
|
|
699
|
+
options: Pick<GuiHttpRouteOptions, "sessionDir" | "cwd">,
|
|
700
|
+
): Promise<SessionManager> {
|
|
701
|
+
const sessionFile = sessionManager.getSessionFile();
|
|
702
|
+
return sessionFile
|
|
703
|
+
? SessionManager.open(sessionFile, options.sessionDir, options.cwd)
|
|
704
|
+
: sessionManager;
|
|
705
|
+
}
|
|
706
|
+
|
|
707
|
+
function buildSnapshotPayload(sessionManager: SessionManager): Record<string, unknown> {
|
|
708
|
+
const sessionId = sessionManager.getSessionId();
|
|
709
|
+
const entries = sessionManager.getEntries();
|
|
710
|
+
return {
|
|
711
|
+
sessionId,
|
|
712
|
+
state: projectDashboard(entries, sessionId),
|
|
713
|
+
entries,
|
|
714
|
+
events: sessionEntriesToChatEvents(entries, {
|
|
715
|
+
sessionId,
|
|
716
|
+
title: sessionManager.getSessionName(),
|
|
717
|
+
}),
|
|
718
|
+
};
|
|
719
|
+
}
|
|
720
|
+
|
|
404
721
|
export async function buildSessionBootstrapPayload(
|
|
405
722
|
options: Pick<
|
|
406
723
|
GuiHttpRouteOptions,
|
|
@@ -414,6 +731,11 @@ export async function buildSessionBootstrapPayload(
|
|
|
414
731
|
return {
|
|
415
732
|
role: roleForSessionBootstrap(options, sessionManager),
|
|
416
733
|
sessionId,
|
|
734
|
+
coordination: {
|
|
735
|
+
sessionId,
|
|
736
|
+
status: roleForSessionBootstrap(options, sessionManager) === "writer" ? "ready" : "syncing",
|
|
737
|
+
ownerKind: ownerKindForSessionBootstrap(options, sessionManager),
|
|
738
|
+
},
|
|
417
739
|
catalog: buildCatalog(),
|
|
418
740
|
modelSetup: options.modelSetupController.buildCurrentModelSetupState(),
|
|
419
741
|
askUserPrompts: Array.isArray(bootstrap.askUserPrompts) ? bootstrap.askUserPrompts : [],
|
|
@@ -430,6 +752,14 @@ export async function buildSessionBootstrapPayload(
|
|
|
430
752
|
};
|
|
431
753
|
}
|
|
432
754
|
|
|
755
|
+
function ownerKindForSessionBootstrap(
|
|
756
|
+
options: Pick<GuiHttpRouteOptions, "getSessionManager" | "role">,
|
|
757
|
+
sessionManager: SessionManager,
|
|
758
|
+
): string | undefined {
|
|
759
|
+
if (roleForSessionBootstrap(options, sessionManager) === "writer") return "gui";
|
|
760
|
+
return readWriterLock(writerLockScopeForSession(sessionManager))?.processKind;
|
|
761
|
+
}
|
|
762
|
+
|
|
433
763
|
function roleForSessionBootstrap(
|
|
434
764
|
options: Pick<GuiHttpRouteOptions, "role" | "getSessionManager">,
|
|
435
765
|
sessionManager: SessionManager,
|
|
@@ -462,6 +792,31 @@ export function sessionIdFromRoute(pathname: string, action: "bootstrap" | "runs
|
|
|
462
792
|
}
|
|
463
793
|
}
|
|
464
794
|
|
|
795
|
+
export function buildChatRunActionEnvelope(
|
|
796
|
+
body: Record<string, unknown>,
|
|
797
|
+
sessionId: string,
|
|
798
|
+
): SessionActionEnvelope {
|
|
799
|
+
const actionId = String(body.actionId ?? "").trim() || `legacy-chat-${Date.now()}`;
|
|
800
|
+
return {
|
|
801
|
+
sessionId,
|
|
802
|
+
actionId,
|
|
803
|
+
actionType: "chat.prompt",
|
|
804
|
+
payload: { prompt: String(body.prompt ?? "") },
|
|
805
|
+
source: "browser",
|
|
806
|
+
};
|
|
807
|
+
}
|
|
808
|
+
|
|
809
|
+
function hasClientActionId(body: Record<string, unknown>): boolean {
|
|
810
|
+
return typeof body.actionId === "string" && body.actionId.trim().length > 0;
|
|
811
|
+
}
|
|
812
|
+
|
|
813
|
+
function shouldBlockFailedCoordinatorAction(
|
|
814
|
+
runSessionManager: SessionManager,
|
|
815
|
+
body: Record<string, unknown>,
|
|
816
|
+
): boolean {
|
|
817
|
+
return hasClientActionId(body) && shouldBlockFailedCoordinatorLockAction(runSessionManager);
|
|
818
|
+
}
|
|
819
|
+
|
|
465
820
|
async function handleTrustedGuiMutation(
|
|
466
821
|
req: IncomingMessage,
|
|
467
822
|
res: ServerResponse,
|
|
@@ -473,7 +828,15 @@ async function handleTrustedGuiMutation(
|
|
|
473
828
|
writeJson(res, await options.wsHub.buildBootstrapPayload());
|
|
474
829
|
} catch (error) {
|
|
475
830
|
const message = error instanceof Error ? error.message : String(error);
|
|
476
|
-
|
|
831
|
+
const coordinationError = message === "Read-only follower mode";
|
|
832
|
+
writeJson(
|
|
833
|
+
res,
|
|
834
|
+
{
|
|
835
|
+
error: coordinationError ? "OpenCandle is reconnecting to this session." : message,
|
|
836
|
+
...(coordinationError ? { code: "syncing" } : {}),
|
|
837
|
+
},
|
|
838
|
+
coordinationError ? 409 : 400,
|
|
839
|
+
);
|
|
477
840
|
}
|
|
478
841
|
}
|
|
479
842
|
|
|
@@ -531,6 +894,19 @@ function allowTrustedGuiRequest(
|
|
|
531
894
|
return false;
|
|
532
895
|
}
|
|
533
896
|
|
|
897
|
+
function allowLocalCoordinatorRequest(
|
|
898
|
+
req: IncomingMessage,
|
|
899
|
+
res: ServerResponse,
|
|
900
|
+
options: GuiHttpRouteOptions,
|
|
901
|
+
): boolean {
|
|
902
|
+
if (req.headers["x-opencandle-coordinator-secret"] === options.localCoordinatorSecret) {
|
|
903
|
+
return true;
|
|
904
|
+
}
|
|
905
|
+
res.writeHead(403, { "content-type": "application/json" });
|
|
906
|
+
res.end(JSON.stringify({ error: "Local coordinator request was not authorized." }));
|
|
907
|
+
return false;
|
|
908
|
+
}
|
|
909
|
+
|
|
534
910
|
function privateGuiHeaders(
|
|
535
911
|
contentTypeValue: string,
|
|
536
912
|
options: GuiHttpRouteOptions,
|