Skip to content

Commit 687e9b2

Browse files
fix: batch streamed events and simplify project exports (#6915) (#6918)
* fix: batch streamed message event persistence * fix: derive export scope from project id (cherry picked from commit cf094aa) Co-authored-by: lefarcen <935902669@qq.com>
1 parent 90dc5c7 commit 687e9b2

12 files changed

Lines changed: 535 additions & 63 deletions

apps/daemon/src/cli.ts

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -469,6 +469,8 @@ async function runAgent(args) {
469469

470470
const EXPORT_STRING_FLAGS = new Set([
471471
'daemon-url', 'project', 'format', 'out', 'output', 'image-format', 'title', 'file',
472+
// Backwards-compatible no-ops. Older scripts may still pass these, but
473+
// export authority is derived from the project id by the daemon.
472474
'workspace', 'workspace-member',
473475
]);
474476
const EXPORT_BOOLEAN_FLAGS = new Set(['help', 'h', 'json', 'deck', 'page', 'no-deck']);
@@ -493,8 +495,6 @@ Options:
493495
--deck Treat the artifact as a multi-slide deck
494496
--page, --no-deck Treat the artifact as a normal scrollable page
495497
--title <title> Title used for metadata / default filename
496-
--workspace <id> Explicit Workspace id for a bound project
497-
--workspace-member <id> Explicit Workspace member id for a bound project
498498
--json Print a machine-readable result envelope
499499
--daemon-url <url> Override daemon URL
500500
@@ -541,7 +541,7 @@ async function runExport(args) {
541541
const token = process.env.OD_TOOL_TOKEN;
542542
const requestHeaders = token
543543
? { authorization: `Bearer ${token}` }
544-
: workspaceHeadersFromExplicitFlags(flags) ?? {};
544+
: {};
545545
// Visual formats rasterize through the desktop screenshot renderer so the
546546
// CLI matches the UI exactly. In particular `pdf` uses `/export/pdf-image`
547547
// (one raster page per deck slide / per viewport for a page) — NOT the generic

apps/daemon/src/db.ts

Lines changed: 65 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -2584,6 +2584,9 @@ export function conversationTurnIndexForRun(
25842584
}
25852585

25862586
export function upsertMessage(db: SqliteDb, conversationId: string, m: DbRow) {
2587+
const persistedEvents = Array.isArray(m.events)
2588+
? compactAdjacentMessageAgentEvents(m.events)
2589+
: m.events;
25872590
const existing = db
25882591
.prepare(`SELECT position FROM messages WHERE id = ?`)
25892592
.get(m.id) as DbRow | undefined;
@@ -2613,7 +2616,7 @@ export function upsertMessage(db: SqliteDb, conversationId: string, m: DbRow) {
26132616
m.runStatus ?? null,
26142617
normalizeResultDeliveryStateForStorage(m.resultDeliveryState),
26152618
m.lastRunEventId ?? null,
2616-
m.events ? JSON.stringify(m.events) : null,
2619+
persistedEvents ? JSON.stringify(persistedEvents) : null,
26172620
m.attachments ? JSON.stringify(m.attachments) : null,
26182621
m.commentAttachments ? JSON.stringify(m.commentAttachments) : null,
26192622
m.producedFiles ? JSON.stringify(m.producedFiles) : null,
@@ -2667,7 +2670,7 @@ export function upsertMessage(db: SqliteDb, conversationId: string, m: DbRow) {
26672670
m.runStatus ?? null,
26682671
normalizeResultDeliveryStateForStorage(m.resultDeliveryState),
26692672
m.lastRunEventId ?? null,
2670-
m.events ? JSON.stringify(m.events) : null,
2673+
persistedEvents ? JSON.stringify(persistedEvents) : null,
26712674
m.attachments ? JSON.stringify(m.attachments) : null,
26722675
m.commentAttachments ? JSON.stringify(m.commentAttachments) : null,
26732676
m.producedFiles ? JSON.stringify(m.producedFiles) : null,
@@ -2759,25 +2762,73 @@ export function appendMessageStatusEvent(db: SqliteDb, messageId: string, event:
27592762
return next;
27602763
}
27612764

2762-
export function appendMessageAgentEvent(db: SqliteDb, messageId: string, event: DbRow) {
2763-
if (!event || typeof event !== 'object') return null;
2764-
const kind = typeof event.kind === 'string' ? event.kind : '';
2765-
if (!kind) return null;
2765+
export function compactAdjacentMessageAgentEvents(
2766+
incomingEvents: readonly DbRow[],
2767+
): DbRow[] {
2768+
const events: DbRow[] = [];
2769+
for (const event of incomingEvents) {
2770+
const kind = typeof event?.kind === 'string' ? event.kind : '';
2771+
const last = events[events.length - 1];
2772+
const isMergeableDelta =
2773+
(kind === 'text' || kind === 'thinking') && typeof event?.text === 'string';
2774+
if (isMergeableDelta && last?.kind === kind && typeof last.text === 'string') {
2775+
events[events.length - 1] = { ...last, text: last.text + event.text };
2776+
} else {
2777+
events.push(event);
2778+
}
2779+
}
2780+
return events;
2781+
}
2782+
2783+
export function appendMessageAgentEvents(
2784+
db: SqliteDb,
2785+
messageId: string,
2786+
incomingEvents: readonly DbRow[],
2787+
): DbRow[] | null {
2788+
if (incomingEvents.length === 0) return null;
27662789
const row = db
27672790
.prepare(`SELECT content, events_json AS eventsJson FROM messages WHERE id = ?`)
27682791
.get(messageId) as DbRow | undefined;
27692792
if (!row) return null;
27702793
const parsed = parseJsonOrUndef(row.eventsJson);
2771-
const events = Array.isArray(parsed) ? parsed : [];
2772-
const last = events[events.length - 1];
2773-
if (last && JSON.stringify(last) === JSON.stringify(event)) {
2774-
return events;
2794+
const parsedEvents = Array.isArray(parsed) ? parsed : [];
2795+
const events = compactAdjacentMessageAgentEvents(parsedEvents);
2796+
let textDelta = '';
2797+
let changed = events.length !== parsedEvents.length;
2798+
2799+
for (const event of incomingEvents) {
2800+
if (!event || typeof event !== 'object') continue;
2801+
const kind = typeof event.kind === 'string' ? event.kind : '';
2802+
if (!kind) continue;
2803+
const last = events[events.length - 1];
2804+
const isMergeableDelta =
2805+
(kind === 'text' || kind === 'thinking') && typeof event.text === 'string';
2806+
if (isMergeableDelta && last?.kind === kind && typeof last.text === 'string') {
2807+
last.text += event.text;
2808+
if (kind === 'text') textDelta += event.text;
2809+
changed = changed || event.text.length > 0;
2810+
continue;
2811+
}
2812+
if (!isMergeableDelta && last && JSON.stringify(last) === JSON.stringify(event)) {
2813+
continue;
2814+
}
2815+
events.push(event);
2816+
if (kind === 'text' && typeof event.text === 'string') textDelta += event.text;
2817+
changed = true;
27752818
}
2776-
const next = [...events, event];
2777-
const textDelta = kind === 'text' && typeof event.text === 'string' ? event.text : '';
2819+
2820+
if (!changed) return events;
27782821
db.prepare(`UPDATE messages SET content = COALESCE(content, '') || ?, events_json = ? WHERE id = ?`)
2779-
.run(textDelta, JSON.stringify(next), messageId);
2780-
return next;
2822+
.run(textDelta, JSON.stringify(events), messageId);
2823+
return events;
2824+
}
2825+
2826+
export function appendMessageAgentEvent(
2827+
db: SqliteDb,
2828+
messageId: string,
2829+
event: DbRow,
2830+
): DbRow[] | null {
2831+
return appendMessageAgentEvents(db, messageId, [event]);
27812832
}
27822833

27832834
export function deleteMessage(db: SqliteDb, id: string) {

apps/daemon/src/import-export-routes.ts

Lines changed: 33 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -602,7 +602,11 @@ export function registerProjectExportRoutes(app: Express, ctx: RegisterProjectEx
602602
async function authorizeExportRead(
603603
req: any,
604604
res: any,
605-
options: { allowNavigationQuery?: boolean; toolEndpoint?: string } = {},
605+
options: {
606+
allowNavigationQuery?: boolean;
607+
deriveWorkspaceFromProject?: boolean;
608+
toolEndpoint?: string;
609+
} = {},
606610
): Promise<AuthorizedExportRead | null> {
607611
const authorization = req.get('authorization');
608612
if (
@@ -627,6 +631,14 @@ export function registerProjectExportRoutes(app: Express, ctx: RegisterProjectEx
627631
);
628632
return authority ? { previewWorkspace: authority.workspace } : null;
629633
}
634+
if (options.deriveWorkspaceFromProject) {
635+
const authority = await ctx.authorizeProjectToolRequest(
636+
res,
637+
req.params.id,
638+
{ mode: 'read' },
639+
);
640+
return authority ? { previewWorkspace: authority.workspace } : null;
641+
}
630642
const authorized = await ctx.authorizeProjectRequest(
631643
req,
632644
res,
@@ -1392,7 +1404,10 @@ export function registerProjectExportRoutes(app: Express, ctx: RegisterProjectEx
13921404
// PNG and assemble a one-image-per-slide .pptx. Replaces the old "send a prompt
13931405
// to the agent and hope it runs python-pptx" path with a deterministic export.
13941406
app.post('/api/projects/:id/export/pptx', async (req, res) => {
1395-
const authority = await authorizeExportRead(req, res, { toolEndpoint: PROJECT_EXPORT_TOOL_ENDPOINT });
1407+
const authority = await authorizeExportRead(req, res, {
1408+
deriveWorkspaceFromProject: true,
1409+
toolEndpoint: PROJECT_EXPORT_TOOL_ENDPOINT,
1410+
});
13961411
if (!authority) return;
13971412
await handleScreenshotExport(res, 'pptx', req.params.id, { authority, body: req.body });
13981413
});
@@ -1401,7 +1416,10 @@ export function registerProjectExportRoutes(app: Express, ctx: RegisterProjectEx
14011416
// The print-ready vector PDF stays on POST /export/pdf; this is the "exactly
14021417
// what you see" counterpart that shares the slide renderer with PPTX.
14031418
app.post('/api/projects/:id/export/pdf-image', async (req, res) => {
1404-
const authority = await authorizeExportRead(req, res, { toolEndpoint: PROJECT_EXPORT_TOOL_ENDPOINT });
1419+
const authority = await authorizeExportRead(req, res, {
1420+
deriveWorkspaceFromProject: true,
1421+
toolEndpoint: PROJECT_EXPORT_TOOL_ENDPOINT,
1422+
});
14051423
if (!authority) return;
14061424
await handleScreenshotExport(res, 'pdf', req.params.id, { authority, body: req.body });
14071425
});
@@ -1411,7 +1429,10 @@ export function registerProjectExportRoutes(app: Express, ctx: RegisterProjectEx
14111429
// the whole document at natural size. Viewport-independent — unlike the
14121430
// host-compositor snapshot, the size never depends on the preview pane.
14131431
app.post('/api/projects/:id/export/image', async (req, res) => {
1414-
const authority = await authorizeExportRead(req, res, { toolEndpoint: PROJECT_EXPORT_TOOL_ENDPOINT });
1432+
const authority = await authorizeExportRead(req, res, {
1433+
deriveWorkspaceFromProject: true,
1434+
toolEndpoint: PROJECT_EXPORT_TOOL_ENDPOINT,
1435+
});
14151436
if (!authority) return;
14161437
await handleScreenshotExport(res, 'image', req.params.id, { authority, body: req.body });
14171438
});
@@ -1420,7 +1441,10 @@ export function registerProjectExportRoutes(app: Express, ctx: RegisterProjectEx
14201441
// embedded by the daemon. Remote HTTP(S) dependencies remain external and
14211442
// are listed in a machine-readable manifest inside the output.
14221443
app.post('/api/projects/:id/export/html', async (req, res) => {
1423-
const authority = await authorizeExportRead(req, res, { toolEndpoint: PROJECT_EXPORT_TOOL_ENDPOINT });
1444+
const authority = await authorizeExportRead(req, res, {
1445+
deriveWorkspaceFromProject: true,
1446+
toolEndpoint: PROJECT_EXPORT_TOOL_ENDPOINT,
1447+
});
14241448
if (!authority) return;
14251449
await handleStandaloneHtmlExport(res, req.params.id, req.body);
14261450
});
@@ -1438,7 +1462,10 @@ export function registerProjectExportRoutes(app: Express, ctx: RegisterProjectEx
14381462
if (!isExportFormat(format)) {
14391463
return sendApiError(res, 400, 'BAD_REQUEST', 'invalid export format');
14401464
}
1441-
const authority = await authorizeExportRead(req, res, { toolEndpoint: PROJECT_EXPORT_TOOL_ENDPOINT });
1465+
const authority = await authorizeExportRead(req, res, {
1466+
deriveWorkspaceFromProject: true,
1467+
toolEndpoint: PROJECT_EXPORT_TOOL_ENDPOINT,
1468+
});
14421469
if (!authority) return;
14431470
if (format === 'html') {
14441471
return handleStandaloneHtmlExport(res, req.params.id, {

apps/daemon/src/routes/project/conversations.ts

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@ import { TERMINAL_RUN_STATUSES } from '../../runtimes/runs.js';
1010
import { registerProjectCommentRoutes } from './comments.js';
1111
import { cancelRunsOwnedBy } from './cancel-owned-runs.js';
1212
import {
13+
compactAdjacentMessageAgentEvents,
1314
deleteConversationAndRepairTeamCommentAnchor,
1415
isProjectCommentAnchorConversationId,
1516
} from '../../db.js';
@@ -548,8 +549,11 @@ export function registerProjectConversationRoutes(app: Express, ctx: RegisterPro
548549
if (existing === null && getMessage(db, req.params.mid) !== null) {
549550
return res.status(404).json({ error: 'message not found' });
550551
}
552+
const normalizedMessage = Array.isArray(m.events)
553+
? { ...m, events: compactAdjacentMessageAgentEvents(m.events) }
554+
: m;
551555
const saved = upsertMessage(db, req.params.cid, {
552-
...mergeMessageWriteForDaemonBacked(existing, m),
556+
...mergeMessageWriteForDaemonBacked(existing, normalizedMessage),
553557
id: req.params.mid,
554558
});
555559
// Bump the parent project's updatedAt so the project list re-orders.

apps/daemon/src/runtimes/chat-run-messages.ts

Lines changed: 73 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
import type Database from 'better-sqlite3';
22
import type { PersistedAgentEvent } from '@open-design/contracts';
33
import {
4-
appendMessageAgentEvent,
4+
appendMessageAgentEvents,
55
upsertMessage,
66
} from '../db.js';
77

@@ -22,6 +22,18 @@ type ChatRunMessageState = {
2222
failureDetail?: string | null;
2323
};
2424

25+
type PendingMessageEvents = {
26+
db: SqliteDb;
27+
messageId: string;
28+
events: PersistedAgentEvent[];
29+
bytes: number;
30+
timer: ReturnType<typeof setTimeout> | null;
31+
};
32+
33+
export const RUN_MESSAGE_EVENT_FLUSH_INTERVAL_MS = 250;
34+
const RUN_MESSAGE_EVENT_FLUSH_BYTES = 64 * 1024;
35+
const pendingMessageEvents = new WeakMap<ChatRunMessageState, PendingMessageEvents>();
36+
2537
function isRecord(value: unknown): value is Record<string, unknown> {
2638
return Boolean(value && typeof value === 'object' && !Array.isArray(value));
2739
}
@@ -34,9 +46,67 @@ export function persistRunEventToAssistantMessage(
3446
): void {
3547
if (!run.assistantMessageId) return;
3648
const persisted = runSseEventToPersistedAgentEvent(event, data);
37-
if (!persisted) return;
49+
if (!persisted) {
50+
if (event === 'end' || event === 'close') flushRunMessageEvents(run);
51+
return;
52+
}
53+
54+
let pending = pendingMessageEvents.get(run);
55+
if (pending && (pending.db !== db || pending.messageId !== run.assistantMessageId)) {
56+
flushRunMessageEvents(run);
57+
pending = undefined;
58+
}
59+
if (!pending) {
60+
pending = {
61+
db,
62+
messageId: run.assistantMessageId,
63+
events: [],
64+
bytes: 0,
65+
timer: null,
66+
};
67+
pendingMessageEvents.set(run, pending);
68+
}
69+
appendPendingMessageEvent(pending, persisted);
70+
71+
const isDelta = persisted.kind === 'text' || persisted.kind === 'thinking';
72+
if (!isDelta || pending.bytes >= RUN_MESSAGE_EVENT_FLUSH_BYTES) {
73+
flushRunMessageEvents(run);
74+
return;
75+
}
76+
if (!pending.timer) {
77+
pending.timer = setTimeout(() => {
78+
flushRunMessageEvents(run);
79+
}, RUN_MESSAGE_EVENT_FLUSH_INTERVAL_MS);
80+
pending.timer.unref?.();
81+
}
82+
}
83+
84+
function appendPendingMessageEvent(
85+
pending: PendingMessageEvents,
86+
event: PersistedAgentEvent,
87+
): void {
88+
const last = pending.events[pending.events.length - 1];
89+
if (
90+
(event.kind === 'text' || event.kind === 'thinking') &&
91+
last?.kind === event.kind
92+
) {
93+
last.text += event.text;
94+
} else {
95+
pending.events.push(event);
96+
}
97+
pending.bytes += event.kind === 'text' || event.kind === 'thinking'
98+
? event.text.length
99+
: JSON.stringify(event).length;
100+
}
101+
102+
export function flushRunMessageEvents(run: ChatRunMessageState): void {
103+
const pending = pendingMessageEvents.get(run);
104+
if (!pending) return;
105+
pendingMessageEvents.delete(run);
106+
if (pending.timer) clearTimeout(pending.timer);
107+
if (pending.events.length === 0) return;
38108
try {
39-
appendMessageAgentEvent(db, run.assistantMessageId, persisted);
109+
appendMessageAgentEvents(pending.db, pending.messageId, pending.events);
40110
} catch (err) {
41111
console.warn('[runs] message event persistence failed', err);
42112
}

0 commit comments

Comments
 (0)