From 5ed634990ee203507276948877a19e097d6dde9e Mon Sep 17 00:00:00 2001 From: seb Date: Fri, 16 Oct 2026 15:22:13 +0200 Subject: [PATCH] u --- docs/api/README.md | 1 - docs/api/routes-all.md | 5 +- docs/api/routes/vfs/drafts/response.md | 48 ----- src/api/routes/vfs.js | 75 +------- src/index.js | 3 + src/services/conversationManager.js | 38 +++- src/services/draftManager.js | 231 +++++++------------------ src/tools/patch.js | 9 +- src/tools/read-file.js | 8 +- 9 files changed, 118 insertions(+), 300 deletions(-) delete mode 100644 docs/api/routes/vfs/drafts/response.md diff --git a/docs/api/README.md b/docs/api/README.md index b5494c4..120e37b 100644 --- a/docs/api/README.md +++ b/docs/api/README.md @@ -266,7 +266,6 @@ Generated from `@openapi` annotations in [`src/api/routes/`](../../src/api/route | [`routes/v1/product.md`](routes/v1/product.md) | `GET /v1/product` | | [`routes/vfs/list.md`](routes/vfs/list.md) | `GET /vfs` | | [`routes/vfs/applets-restore.md`](routes/vfs/applets-restore.md) | `POST /vfs/applets/restore` | -| [`routes/vfs/drafts/response.md`](routes/vfs/drafts/response.md) | `POST /vfs/drafts/response` | | [`routes/vfs/mkdir.md`](routes/vfs/mkdir.md) | `POST /vfs/mkdir` | | [`routes/vfs/rename.md`](routes/vfs/rename.md) | `POST /vfs/rename` | | [`routes/vfs/skills-restore.md`](routes/vfs/skills-restore.md) | `POST /vfs/skills/restore` | diff --git a/docs/api/routes-all.md b/docs/api/routes-all.md index b91f801..289946f 100644 --- a/docs/api/routes-all.md +++ b/docs/api/routes-all.md @@ -1,6 +1,6 @@ # Dash API Routes Overview (All) -Overview of all 250 API endpoints across all modules. +Overview of all 249 API endpoints across all modules. ### Notation @@ -915,9 +915,6 @@ _none_ #### Restore system default applets — `POST /vfs/applets/restore` _none_ - Returns: `200 { success: bool, restored: str[], count: int }` -#### Respond to a frontend draft request — `POST /vfs/drafts/response` -- Params: `body: { requestId: str, content?: any, error?: str, files?: any[] }` -- Returns: `200 { success: bool }` #### Create directory in virtual filesystem — `POST /vfs/mkdir` - Params: `body: { path: str }` - Returns: `200 { success: bool, dir: str }` diff --git a/docs/api/routes/vfs/drafts/response.md b/docs/api/routes/vfs/drafts/response.md deleted file mode 100644 index c8e8eb8..0000000 --- a/docs/api/routes/vfs/drafts/response.md +++ /dev/null @@ -1,48 +0,0 @@ -# `POST /vfs/drafts/response` - -Protected. Requires a valid session cookie (`credentials: 'include'`). - -Respond to a frontend draft request - -Used by the frontend to answer a server-initiated `draft:request` SSE event -when reading or listing virtual drafts (`drafts/quote`, `drafts/order`, `drafts/delivery`). - - -## Body (JSON) - -| Field | Required | Type | Description | -|-------|----------|------|-------------| -| `requestId` | yes | string | Unique request ID from the draft:request SSE event | -| `content` | no | | File content when action was read | -| `error` | no | string | Error message if draft retrieval failed on frontend | -| `files` | no | any[] | File listing when action was list | - -```json -{ - "requestId": "…", - "content": "…", - "error": "…", - "files": [] -} -``` - -## Response `200` - -Response received and resolved - -```json -{ - "success": true -} -``` - -## Errors - -| Status | Description | -|--------|-------------| -| 400 | Missing requestId or no company selected — `{"error":"…"}` | -| 404 | Request ID not found or already timed out — `{"error":"…"}` | - ---- - -_Generated from `@openapi` JSDoc. Do not edit by hand — run `npm run docs:api`._ diff --git a/src/api/routes/vfs.js b/src/api/routes/vfs.js index bbb2b27..3140428 100644 --- a/src/api/routes/vfs.js +++ b/src/api/routes/vfs.js @@ -15,7 +15,6 @@ import { parseDraftPath, requestDraftRead, requestDraftList, - handleDraftResponse, notifyDraftUpdate, } from '../../services/draftManager.js'; @@ -1319,8 +1318,7 @@ export function register(app, basePath = '') { res.setHeader('Content-Disposition', `inline; filename="${fallbackName}"`); return res.status(200).send(buffer); } catch (err) { - const status = err.message?.includes('timed out') ? 504 : 404; - return res.status(status).json({ error: err.message }); + return res.status(404).json({ error: err.message }); } } @@ -1399,78 +1397,7 @@ export function register(app, basePath = '') { // #endregion - /** - * @openapi - * /vfs/drafts/response: - * post: - * tags: [Vfs] - * summary: Respond to a frontend draft request - * description: | - * Used by the frontend to answer a server-initiated `draft:request` SSE event - * when reading or listing virtual drafts (`drafts/quote`, `drafts/order`, `drafts/delivery`). - * security: - * - cookieAuth: [] - * requestBody: - * required: true - * content: - * application/json: - * schema: - * type: object - * required: [requestId] - * properties: - * requestId: - * type: string - * description: Unique request ID from the draft:request SSE event - * content: - * description: File content when action was read - * error: - * type: string - * description: Error message if draft retrieval failed on frontend - * files: - * type: array - * description: File listing when action was list - * responses: - * 200: - * description: Response received and resolved - * content: - * application/json: - * schema: - * type: object - * properties: - * success: - * type: boolean - * 400: - * description: Missing requestId or no company selected - * content: - * application/json: - * schema: - * $ref: '#/components/schemas/Error' - * 404: - * description: Request ID not found or already timed out - * content: - * application/json: - * schema: - * $ref: '#/components/schemas/Error' - */ - // #region POST /vfs/drafts/response - app.post(`${basePath}/vfs/drafts/response`, async (req, res) => { - const selected = requireSelectedCompany(req); - if (selected.error) { - return res.status(400).json({ error: selected.error }); - } - const { requestId, content, error, files } = req.body || {}; - if (!requestId) { - return res.status(400).json({ error: 'requestId is required' }); - } - const result = handleDraftResponse({ requestId, content, error, files }); - if (!result.handled) { - return res.status(404).json({ error: result.error || 'Request expired or not found' }); - } - return res.json({ success: true }); - }); - - // #endregion /** * @openapi diff --git a/src/index.js b/src/index.js index d0e9d0f..3eff502 100644 --- a/src/index.js +++ b/src/index.js @@ -21,6 +21,9 @@ process.on('SIGINT', () => { process.on('SIGTERM', () => { shutdown().then(() => process.exit(0)); }); +process.on('unhandledRejection', (reason) => { + sysLog.error('Unhandled rejection:', reason); +}); // MARK: calls startApi from ./api/index.js startApi().catch((err) => { diff --git a/src/services/conversationManager.js b/src/services/conversationManager.js index 51f287a..efee031 100644 --- a/src/services/conversationManager.js +++ b/src/services/conversationManager.js @@ -17,6 +17,7 @@ import { createApiSuggestionsOrIssuesTool } from '../tools/apisuggestionsorissue import { createFindFilesTool } from '../tools/find-files.js'; import { createReadFileTool } from '../tools/read-file.js'; import { createPatchTool } from '../tools/patch.js'; +import { setCachedDraft, parseDraftPath } from '../services/draftManager.js'; import { createRmTool } from '../tools/rm.js'; import { createSearchImagesTool } from '../tools/search-images.js'; import { createAnalyzeImageTool } from '../tools/analyze-image.js'; @@ -501,6 +502,39 @@ export class ConversationManager { }); toolMsg = await db.addMessage(googleAuthId, this.conversationId, { message: payload, role: 'tool' }); await db.setState(googleAuthId, this.conversationId, 'busy'); + + // If this tool response is answering a draft read, cache the draft + try { + let parsedContent = null; + try { parsedContent = JSON.parse(rawContent); } catch {} + if (parsedContent && (parsedContent.manifest || parsedContent.lines)) { + const messages = await db.listMessages(googleAuthId, this.conversationId); + for (let i = messages.length - 1; i >= 0; i--) { + const m = messages[i]; + if (m.role === 'assistant' || m.role === 'model') { + let p = null; + try { p = typeof m.message === 'string' ? JSON.parse(m.message) : m.message; } catch {} + const tc = p?.tool_calls?.find((c) => c.id === tool_call_id); + if (tc) { + let tcArgs = {}; + try { tcArgs = typeof tc.function?.arguments === 'string' ? JSON.parse(tc.function.arguments) : (tc.function?.arguments || {}); } catch {} + const draftInfo = parseDraftPath(tcArgs.filename || tcArgs.file || ''); + if (draftInfo.isDraft) { + setCachedDraft({ + companyId: this.companyId, + userId: googleAuthId, + name: draftInfo.name, + content: parsedContent, + }); + } + break; + } + } + } + } + } catch (err) { + console.warn('[conversationManager] could not cache draft from tool message:', err.message); + } } catch (err) { this.isProcessing = false; throw err; @@ -739,8 +773,8 @@ export class ConversationManager { // can never end up pointed at different companies. createJavascriptExecuteTool({ googleAuthId }), createFindFilesTool({ googleAuthId }), - createReadFileTool({ googleAuthId }), - createPatchTool({ googleAuthId }), + createReadFileTool({ googleAuthId, conversationId: this.conversationId }), + createPatchTool({ googleAuthId, conversationId: this.conversationId }), createRmTool({ googleAuthId }), createSearchImagesTool({ googleAuthId }), createAnalyzeImageTool({ googleAuthId, model }), diff --git a/src/services/draftManager.js b/src/services/draftManager.js index 9940997..1188c25 100644 --- a/src/services/draftManager.js +++ b/src/services/draftManager.js @@ -1,5 +1,6 @@ -import { emit, hasActiveClient } from '../api/sse.js'; +import { emit } from '../api/sse.js'; import { getStore } from '../db/store.js'; +import { DirectReturnError } from 'libllmorbiter'; export const DRAFT_NAMES = new Set(['quote', 'order', 'delivery']); @@ -37,6 +38,27 @@ export function parseDraftPath(filePath) { return { isFolder: false, isDraft: false }; } +/** + * Default empty draft structure. + * @param {string} name + * @returns {object} + */ +export function defaultDraftContent(name) { + return { + manifest: { + kind: name, + mode: 'create', + id: null, + date: null, + partyId: null, + number: null, + externalNumber: null, + comment: null, + lines: [], + }, + }; +} + /** * In-memory cache for drafts: key: `${companyId}:${userId}:${name}` -> { content, updatedAt, size } */ @@ -111,200 +133,81 @@ export async function resolveCompanyForUser(googleAuthId) { } /** - * Pending requests map: requestId -> { resolve, reject, timer, companyId, userId, action, name } - */ -const pendingRequests = new Map(); - -/** - * Request a draft content from the frontend via SSE and wait for its response. + * Request a draft content. + * Stateless model: + * - If cached, returns immediately. + * - In conversation: emits `draft:request` with conversationId and raises DirectReturnError(..., { recordResult: false }) + * so the assistant tool call remains the last message in `modelmessage` until frontend responds. + * - Outside conversation: returns default empty draft envelope. * - * @param {{ companyId?: number, userId: number, clientId?: string, name: string, timeoutMs?: number }} options + * @param {{ companyId?: number, userId: number, conversationId?: number, clientId?: string, name: string }} options * @returns {Promise} */ -export async function requestDraftRead({ companyId, userId, clientId, name, timeoutMs = 15000 }) { +export async function requestDraftRead({ companyId, userId, conversationId, clientId, name }) { const effectiveCompanyId = companyId != null ? companyId : await resolveCompanyForUser(userId); if (effectiveCompanyId == null) { throw new Error('No company selected'); } - // Check if client is active; if not and we have cache, return cache immediately - const hasClient = hasActiveClient({ companyId: effectiveCompanyId, userId, clientId }); - if (!hasClient) { - const cached = getCachedDraft({ companyId: effectiveCompanyId, userId, name }); - if (cached) { - return cached.content; - } + const cached = getCachedDraft({ companyId: effectiveCompanyId, userId, name }); + if (cached) { + return cached.content; } - const requestId = `draft_req_${Date.now()}_${Math.random().toString(36).slice(2, 9)}`; + if (conversationId) { + const requestId = `conv_${conversationId}_${name}`; - return new Promise((resolve, reject) => { - const timer = setTimeout(() => { - pendingRequests.delete(requestId); - // Fallback to cache if available - const cached = getCachedDraft({ companyId: effectiveCompanyId, userId, name }); - if (cached) { - return resolve(cached.content); - } - reject(new Error(`Frontend draft response timed out for "${name}"`)); - }, timeoutMs); - - pendingRequests.set(requestId, { - resolve, - reject, - timer, - companyId: effectiveCompanyId, - userId, - action: 'read', - name, - }); - - // Emit request over SSE + // Emit request over SSE with conversationId emit('draft:request', { companyId: effectiveCompanyId, targetUserId: userId, targetClientId: clientId, data: { requestId, + conversationId, action: 'read', name, target: name, sender: clientId || '', }, }); - }); + + // Halt turn statelessly; assistant tool call stays as the last message in modelmessage + throw new DirectReturnError({ + action: 'draft_read', + draft: name, + conversationId, + }, { recordResult: false }); + } + + return defaultDraftContent(name); } /** - * Request draft listing from the frontend via SSE. - * If frontend doesn't respond or times out, returns default metadata for all 3 drafts. + * Request draft listing for VFS. + * Returns immediately from cache or default empty draft metadata. Completely stateless without timers or promises. * - * @param {{ companyId?: number, userId: number, clientId?: string, timeoutMs?: number }} options - * @returns {Promise>} + * @param {{ companyId?: number, userId: number }} options + * @returns {Array} */ -export async function requestDraftList({ companyId, userId, clientId, timeoutMs = 2500 }) { - const effectiveCompanyId = companyId != null ? companyId : await resolveCompanyForUser(userId); - if (effectiveCompanyId == null) { - return []; - } - - const defaultDrafts = () => { - const now = new Date().toISOString(); - return Array.from(DRAFT_NAMES).map((name) => { - const cached = getCachedDraft({ companyId: effectiveCompanyId, userId, name }); - return { - name: `drafts/${name}`, - size: cached ? cached.size : 0, - mime: 'application/json; charset=utf-8', - updated_at: cached ? cached.updatedAt : now, - created_at: cached ? cached.updatedAt : now, - isText: true, - isImage: false, - isPdf: false, - isDraft: true, - }; - }); - }; - - // If no client is connected, return defaults immediately without delaying listing - const hasClient = hasActiveClient({ companyId: effectiveCompanyId, userId, clientId }); - if (!hasClient) { - return defaultDrafts(); - } - - const requestId = `draft_list_${Date.now()}_${Math.random().toString(36).slice(2, 9)}`; - - return new Promise((resolve) => { - const timer = setTimeout(() => { - pendingRequests.delete(requestId); - resolve(defaultDrafts()); - }, timeoutMs); - - pendingRequests.set(requestId, { - resolve: (filesOrList) => { - if (Array.isArray(filesOrList)) { - const now = new Date().toISOString(); - const normalized = filesOrList.map((item) => { - const rawName = typeof item === 'string' ? item : (item.name || item.filename || ''); - const parsed = parseDraftPath(rawName.startsWith('drafts/') ? rawName : `drafts/${rawName}`); - const nameKey = parsed.name || rawName; - const cached = getCachedDraft({ companyId: effectiveCompanyId, userId, name: nameKey }); - return { - name: `drafts/${nameKey}`, - size: typeof item === 'object' && item.size != null ? item.size : (cached ? cached.size : 0), - mime: 'application/json; charset=utf-8', - updated_at: typeof item === 'object' && item.updated_at ? item.updated_at : (cached ? cached.updatedAt : now), - created_at: typeof item === 'object' && item.created_at ? item.created_at : (cached ? cached.updatedAt : now), - isText: true, - isImage: false, - isPdf: false, - isDraft: true, - }; - }); - resolve(normalized); - } else { - resolve(defaultDrafts()); - } - }, - reject: () => { - resolve(defaultDrafts()); - }, - timer, - companyId: effectiveCompanyId, - userId, - action: 'list', - }); - - emit('draft:request', { - companyId: effectiveCompanyId, - targetUserId: userId, - targetClientId: clientId, - data: { - requestId, - action: 'list', - sender: clientId || '', - }, - }); +export function requestDraftList({ companyId, userId }) { + const now = new Date().toISOString(); + return Array.from(DRAFT_NAMES).map((name) => { + const cached = companyId ? getCachedDraft({ companyId, userId, name }) : null; + return { + name: `drafts/${name}`, + size: cached ? cached.size : 0, + mime: 'application/json; charset=utf-8', + updated_at: cached ? cached.updatedAt : now, + created_at: cached ? cached.updatedAt : now, + isText: true, + isImage: false, + isPdf: false, + isDraft: true, + }; }); } -/** - * Handle frontend response to a previous draft:request. - * - * @param {{ requestId: string, content?: any, error?: string, files?: any[] }} payload - * @returns {{ handled: boolean, error?: string }} - */ -export function handleDraftResponse({ requestId, content, error, files }) { - if (!requestId || !pendingRequests.has(requestId)) { - return { handled: false, error: 'Unknown or expired requestId' }; - } - - const pending = pendingRequests.get(requestId); - pendingRequests.delete(requestId); - clearTimeout(pending.timer); - - if (error) { - pending.reject(new Error(error)); - return { handled: true }; - } - - if (pending.action === 'list') { - pending.resolve(files !== undefined ? files : content); - return { handled: true }; - } - - // action === 'read' - const normalized = normalizeDraftContent(content); - setCachedDraft({ - companyId: pending.companyId, - userId: pending.userId, - name: pending.name, - content: normalized, - }); - pending.resolve(normalized); - return { handled: true }; -} - /** * Notify frontend about a draft update via the normal /events SSE endpoint. * diff --git a/src/tools/patch.js b/src/tools/patch.js index 4230376..61348a2 100644 --- a/src/tools/patch.js +++ b/src/tools/patch.js @@ -1,6 +1,6 @@ import fs from 'fs'; import path from 'path'; -import { defineTool, ToolError } from 'libllmorbiter'; +import { defineTool, ToolError, DirectReturnError } from 'libllmorbiter'; import { isReadOnlyDatabasePath } from './vfs.js'; import { isSkillPath, renderSkill, parseSkill, SKILL_EXT, skillFileName } from './skill.js'; import { prepareVfs, syncVfsToDb } from './vfs.js'; @@ -64,9 +64,9 @@ function toLines(text) { /** * Creates the patch tool bound to a company's virtual filesystem. - * @param {{ googleAuthId: number }} ctx + * @param {{ googleAuthId: number, conversationId?: number }} ctx */ -export function createPatchTool({ googleAuthId }) { +export function createPatchTool({ googleAuthId, conversationId }) { return defineTool({ name: 'patch', description: TOOL_DESCRIPTIONS.patch, @@ -197,9 +197,10 @@ export function createPatchTool({ googleAuthId }) { let original = ''; if (!isCreate) { try { - const readVal = await requestDraftRead({ userId: googleAuthId, name: draftInfo.name }); + const readVal = await requestDraftRead({ userId: googleAuthId, conversationId, name: draftInfo.name }); original = typeof readVal === 'string' ? readVal : JSON.stringify(readVal ?? {}, null, 2); } catch (err) { + if (err instanceof DirectReturnError) throw err; const cached = getCachedDraft({ userId: googleAuthId, name: draftInfo.name }); if (cached) { original = typeof cached.content === 'string' ? cached.content : JSON.stringify(cached.content ?? {}, null, 2); diff --git a/src/tools/read-file.js b/src/tools/read-file.js index d0f52d0..ef346cf 100644 --- a/src/tools/read-file.js +++ b/src/tools/read-file.js @@ -1,6 +1,6 @@ import fs from 'fs'; import path from 'path'; -import { defineTool, ToolError } from 'libllmorbiter'; +import { defineTool, ToolError, DirectReturnError } from 'libllmorbiter'; import { isSkillPath, parseSkill, SKILL_EXT } from './skill.js'; import { getApiRouteSkill } from './routes-skills.js'; import { prepareVfs } from './vfs.js'; @@ -58,9 +58,9 @@ function looksBinary(buf) { /** * Creates the read_file tool bound to a company's virtual filesystem. - * @param {{ googleAuthId: number }} ctx + * @param {{ googleAuthId: number, conversationId?: number }} ctx */ -export function createReadFileTool({ googleAuthId }) { +export function createReadFileTool({ googleAuthId, conversationId }) { return defineTool({ name: 'read_file', description: TOOL_DESCRIPTIONS.read_file, @@ -120,11 +120,13 @@ export function createReadFileTool({ googleAuthId }) { try { const content = await requestDraftRead({ userId: googleAuthId, + conversationId, name: draftInfo.name, }); const str = typeof content === 'string' ? content : JSON.stringify(content ?? {}, null, 2); raw = Buffer.from(str, 'utf8'); } catch (err) { + if (err instanceof DirectReturnError) throw err; throw new ToolError(`Failed to read draft "${draftInfo.name}": ${err.message}`); } } else {