@bbigbang/core 0.1.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/dist/config.js +380 -0
- package/dist/execution/executionDispatcher.js +3810 -0
- package/dist/main.js +90 -0
- package/dist/nodeEventHistory.js +206 -0
- package/dist/scheduler/dreamLogic.js +50 -0
- package/dist/scheduler/dreamScheduler.js +65 -0
- package/dist/services/agentFileAccessService.js +1913 -0
- package/dist/services/agentRuntimeCleanupBroker.js +62 -0
- package/dist/services/agentSkillsBroker.js +118 -0
- package/dist/services/agentSkillsService.js +83 -0
- package/dist/services/agentWorkspaceBroker.js +937 -0
- package/dist/services/agentWorkspaceService.js +70 -0
- package/dist/services/appVersion.js +14 -0
- package/dist/services/auth.js +586 -0
- package/dist/services/claudeControlBroker.js +154 -0
- package/dist/services/claudeTranscriptBroker.js +100 -0
- package/dist/services/claudeTranscriptService.js +359 -0
- package/dist/services/codexAppServerBroker.js +155 -0
- package/dist/services/codexTranscriptBroker.js +98 -0
- package/dist/services/codexTranscriptService.js +961 -0
- package/dist/services/droidMissionBroker.js +124 -0
- package/dist/services/droidMissionImporter.js +630 -0
- package/dist/services/droidModelOptions.js +165 -0
- package/dist/services/hubServerRegistrationService.js +268 -0
- package/dist/services/libraryManifest.js +43 -0
- package/dist/services/libraryScaffold.js +26 -0
- package/dist/services/libraryService.js +2263 -0
- package/dist/services/memoryService.js +386 -0
- package/dist/services/missionEvidence.js +377 -0
- package/dist/services/missionService.js +2361 -0
- package/dist/services/missionTrace.js +158 -0
- package/dist/services/nativeMissionBriefParser.js +120 -0
- package/dist/services/nativeMissionOrchestrator.js +2045 -0
- package/dist/services/nativeMissionReportGenerator.js +227 -0
- package/dist/services/nativeMissionValidationRunner.js +452 -0
- package/dist/services/nativeMissionWorkerBroker.js +190 -0
- package/dist/services/nodeRegistry.js +34 -0
- package/dist/services/nodeStateReconciler.js +97 -0
- package/dist/services/panelMediaScanner.js +119 -0
- package/dist/services/persistentRuntimeJsonlClient.js +153 -0
- package/dist/services/platformAgentPolicy.js +180 -0
- package/dist/services/platformAgentService.js +2041 -0
- package/dist/services/projectAccessResolver.js +93 -0
- package/dist/services/projectService.js +392 -0
- package/dist/services/resourceSpaceService.js +140 -0
- package/dist/services/scenarioRuntimeService.js +1130 -0
- package/dist/services/suggestedPlannerService.js +868 -0
- package/dist/services/workbenchGitBroker.js +161 -0
- package/dist/services/workbenchGitService.js +69 -0
- package/dist/services/workbenchInspectBroker.js +65 -0
- package/dist/services/workbenchNodePathService.js +79 -0
- package/dist/services/workbenchRegistryService.js +240 -0
- package/dist/services/workbenchRootService.js +181 -0
- package/dist/services/workbenchTerminalBroker.js +378 -0
- package/dist/services/workspaceRunOwnership.js +60 -0
- package/dist/services/workspaceScaffold.js +105 -0
- package/dist/services/workspaceSessionRuntimeService.js +576 -0
- package/dist/services/workspaceSessionService.js +245 -0
- package/dist/services/workspaceToolActionRunner.js +1582 -0
- package/dist/services/workspaceToolErrors.js +10 -0
- package/dist/services/workspaceToolExecutionUtils.js +895 -0
- package/dist/services/workspaceToolLatestStateProjector.js +91 -0
- package/dist/services/workspaceToolManifest.js +572 -0
- package/dist/services/workspaceToolMutationQueue.js +43 -0
- package/dist/services/workspaceToolPanelProjection.js +460 -0
- package/dist/services/workspaceToolPromotion.js +255 -0
- package/dist/services/workspaceToolPromotionState.js +224 -0
- package/dist/services/workspaceToolPublishDiagnostics.js +189 -0
- package/dist/services/workspaceToolPublishIdentityResolver.js +146 -0
- package/dist/services/workspaceToolReadModel.js +378 -0
- package/dist/services/workspaceToolRunLedger.js +239 -0
- package/dist/services/workspaceToolService.js +3067 -0
- package/dist/services/workspaceToolSnapshotPanelSync.js +293 -0
- package/dist/services/workspaceToolTerminalLifecycle.js +283 -0
- package/dist/services/workspaceToolTypes.js +1 -0
- package/dist/services/workspaceToolUploadMaterializer.js +228 -0
- package/dist/web/actionCardRoutes.js +129 -0
- package/dist/web/actionCards.js +469 -0
- package/dist/web/activationContext.js +684 -0
- package/dist/web/agentChannelGuards.js +48 -0
- package/dist/web/agentMentionCooldowns.js +32 -0
- package/dist/web/agentReminders.js +1668 -0
- package/dist/web/agentRuntimePresence.js +197 -0
- package/dist/web/agentSelfState.js +494 -0
- package/dist/web/agentTaskLinks.js +26 -0
- package/dist/web/agentVisibility.js +79 -0
- package/dist/web/assets.js +95 -0
- package/dist/web/channelActivationPrompt.js +395 -0
- package/dist/web/channelMemoryNotes.js +127 -0
- package/dist/web/channelMentions.js +10 -0
- package/dist/web/channelMessageSequences.js +19 -0
- package/dist/web/channelSubscriptions.js +26 -0
- package/dist/web/clearedTaskRoots.js +10 -0
- package/dist/web/collaborationPromptGuidance.js +36 -0
- package/dist/web/collaborationSurfaceState.js +140 -0
- package/dist/web/contextBundleRanking.js +154 -0
- package/dist/web/contextBundleResolver.js +488 -0
- package/dist/web/conversationBuiltinSkillRoots.js +50 -0
- package/dist/web/conversationControls.js +232 -0
- package/dist/web/conversationHandoffs.js +612 -0
- package/dist/web/conversationManager.js +2511 -0
- package/dist/web/conversationSummaries.js +876 -0
- package/dist/web/conversationSurfaceKinds.js +17 -0
- package/dist/web/conversationTargets.js +173 -0
- package/dist/web/directActivationPrompt.js +122 -0
- package/dist/web/directReplyTargets.js +69 -0
- package/dist/web/directThreadResolver.js +129 -0
- package/dist/web/dmTaskHandoffPrompt.js +120 -0
- package/dist/web/dmTaskThreadStatusProjection.js +229 -0
- package/dist/web/ftsQuery.js +33 -0
- package/dist/web/internalAgentRouter.js +11341 -0
- package/dist/web/libraryCuratorScheduler.js +58 -0
- package/dist/web/libraryDocumentPromptGuidance.js +8 -0
- package/dist/web/messageCheckpoints.js +19 -0
- package/dist/web/nodeWsHandler.js +2495 -0
- package/dist/web/notificationRounds.js +1061 -0
- package/dist/web/panelActionMessages.js +108 -0
- package/dist/web/panelActivationPrompt.js +18 -0
- package/dist/web/panelAudit.js +273 -0
- package/dist/web/panelLifecycle.js +222 -0
- package/dist/web/panelMediaPolicy.js +43 -0
- package/dist/web/panelPathPolicy.js +63 -0
- package/dist/web/panelPreviews.js +175 -0
- package/dist/web/panelQueryHandles.js +2749 -0
- package/dist/web/panelRoutes.js +2147 -0
- package/dist/web/panels.js +904 -0
- package/dist/web/peerInboxAggregates.js +1247 -0
- package/dist/web/planApprovalState.js +92 -0
- package/dist/web/platformAgentScheduler.js +66 -0
- package/dist/web/proactiveOpportunities.js +452 -0
- package/dist/web/promptContextSections.js +242 -0
- package/dist/web/promptHistorySanitizer.js +26 -0
- package/dist/web/promptSlashCommands.js +158 -0
- package/dist/web/rollingConversationSummary.js +453 -0
- package/dist/web/routeHelpers.js +11 -0
- package/dist/web/routes/handoff.js +288 -0
- package/dist/web/routes/history.js +345 -0
- package/dist/web/routes/memory.js +258 -0
- package/dist/web/routes/selfState.js +171 -0
- package/dist/web/routes/workspace.js +154 -0
- package/dist/web/runSurfaceWatermarks.js +431 -0
- package/dist/web/runtimeCapabilities.js +48 -0
- package/dist/web/sameAgentHandoffs.js +494 -0
- package/dist/web/server.js +15567 -0
- package/dist/web/sharedCollaborationCapsules.js +163 -0
- package/dist/web/soloSessionRelay.js +42 -0
- package/dist/web/soloWsHandler.js +138 -0
- package/dist/web/suggestedPlannerScheduler.js +56 -0
- package/dist/web/surfaceActivationPolicy.js +108 -0
- package/dist/web/surfaceCollaborators.js +61 -0
- package/dist/web/surfaceSystemStatus.js +263 -0
- package/dist/web/targetParticipants.js +77 -0
- package/dist/web/taskEvents.js +49 -0
- package/dist/web/taskLifecycleMessages.js +165 -0
- package/dist/web/taskLoops.js +732 -0
- package/dist/web/taskMemoryNotes.js +224 -0
- package/dist/web/taskNumbers.js +16 -0
- package/dist/web/taskOwnerGuards.js +49 -0
- package/dist/web/taskParticipantResolver.js +42 -0
- package/dist/web/taskParticipants.js +97 -0
- package/dist/web/taskSourceDetails.js +20 -0
- package/dist/web/taskStateViews.js +210 -0
- package/dist/web/taskStatusTransitions.js +9 -0
- package/dist/web/taskThreadFollowups.js +599 -0
- package/dist/web/taskThreadRuntimeClosure.js +685 -0
- package/dist/web/taskUpdateDelivery.js +104 -0
- package/dist/web/threadReplyContentHeuristics.js +30 -0
- package/dist/web/threadRoots.js +61 -0
- package/dist/web/threadTaskBindings.js +365 -0
- package/dist/web/uiPanelPromptGuidance.js +27 -0
- package/dist/web/workspaceMemoryHints.js +143 -0
- package/dist/web/workspaceToolPromptGuidance.js +30 -0
- package/dist/web/wsHandler.js +397 -0
- package/dist/web/wsSink.js +116 -0
- package/package.json +54 -0
|
@@ -0,0 +1,2147 @@
|
|
|
1
|
+
import { randomUUID } from 'node:crypto';
|
|
2
|
+
import fs from 'node:fs';
|
|
3
|
+
import path from 'node:path';
|
|
4
|
+
import { getComponentContract, getEffectiveDatasetSchema, normalizePanelApiJsonlAllowedOrigins, normalizePanelApiJsonlAllowedUrlPrefixes, normalizePanelApiJsonlAuthProfileNames, walkPanelTemplateNodes, } from '@bbigbang/protocol';
|
|
5
|
+
import { validateSession, getUserAgentAccess, getUserChannelAccess } from '../services/auth.js';
|
|
6
|
+
import { buildCanonicalAssetStoragePath, sanitizeAssetFilename, } from './assets.js';
|
|
7
|
+
import { resolveDirectUserIdFromReplyTarget } from './directReplyTargets.js';
|
|
8
|
+
import { getPanelById, getPanelRowByIndex, getPanelState, getSharedPanelState, aggregatePanelRows, listPanelRows, loadAttachmentRow, updatePanelRowCachedAssetIdIfCurrent, upsertPanelState, validateStateBlob, applySharedSelectionPatch, normalizePanelSelectionForRowCount, normalizePanelStateSelectionForRowCount, } from './panels.js';
|
|
9
|
+
import { deletePanelAttachmentArtifacts, hardDeletePanelWithArtifacts, } from './panelLifecycle.js';
|
|
10
|
+
import { deleteExpiredPanelPreviews, getPanelPreviewById, getPanelPreviewRowByIndex, isPanelPreviewExpired, listPanelPreviewRows, } from './panelPreviews.js';
|
|
11
|
+
import { WorkspaceToolServiceError, getWorkspaceToolMediaRelativePathCandidates, } from '../services/workspaceToolService.js';
|
|
12
|
+
import { insertPanelAuditEvent, listPanelAuditEvents, } from './panelAudit.js';
|
|
13
|
+
import { ActionCardError, cancelActionCard, confirmActionCard, getActionCardById, } from './actionCards.js';
|
|
14
|
+
import { aggregatePanelQueryHandleRows, isPanelQueryHandleSource, listPanelQueryHandleRows, listPanelQueryHandleRowsByIndices, PanelQueryHandleError, } from './panelQueryHandles.js';
|
|
15
|
+
import { ensureSurfaceOwner, getSurfaceCollaboratorRole, listSurfaceCollaborators, removeSurfaceCollaborator, upsertSurfaceCollaborator, } from './surfaceCollaborators.js';
|
|
16
|
+
import { buildPanelActionActivationPrompt, buildPanelActionActivationContextSections, } from './panelActivationPrompt.js';
|
|
17
|
+
import { insertPanelActionMessage, insertPanelPlatformExecMessage } from './panelActionMessages.js';
|
|
18
|
+
import { resolvePanelMediaPath } from './panelPathPolicy.js';
|
|
19
|
+
import { panelMediaDefaultFilename, panelMediaDisplayTypeLimitBytes, panelMediaMimeMatchesDisplayType, panelMediaSizeCapExceededError, } from './panelMediaPolicy.js';
|
|
20
|
+
import { isPlatformAgent } from '../services/suggestedPlannerService.js';
|
|
21
|
+
function getRequestUser(req, db) {
|
|
22
|
+
const authHeader = typeof req.headers.authorization === 'string' ? req.headers.authorization : '';
|
|
23
|
+
const token = authHeader.startsWith('Bearer ') ? authHeader.slice(7).trim() : '';
|
|
24
|
+
return token ? validateSession(db, token) : null;
|
|
25
|
+
}
|
|
26
|
+
function requireUser(req, reply, db) {
|
|
27
|
+
const user = getRequestUser(req, db);
|
|
28
|
+
if (!user) {
|
|
29
|
+
reply.code(401);
|
|
30
|
+
return null;
|
|
31
|
+
}
|
|
32
|
+
return user;
|
|
33
|
+
}
|
|
34
|
+
function requireAdmin(req, reply, db) {
|
|
35
|
+
const user = requireUser(req, reply, db);
|
|
36
|
+
if (!user)
|
|
37
|
+
return null;
|
|
38
|
+
if (!user.isAdmin) {
|
|
39
|
+
reply.code(403);
|
|
40
|
+
return null;
|
|
41
|
+
}
|
|
42
|
+
return user;
|
|
43
|
+
}
|
|
44
|
+
function getDeclaredPanelMediaDisplayType(panel, slotName) {
|
|
45
|
+
const mediaSlots = panel.props?.mediaSlots;
|
|
46
|
+
if (!Array.isArray(mediaSlots))
|
|
47
|
+
return undefined;
|
|
48
|
+
const slot = mediaSlots.find((candidate) => (candidate
|
|
49
|
+
&& typeof candidate === 'object'
|
|
50
|
+
&& 'name' in candidate
|
|
51
|
+
&& candidate.name === slotName));
|
|
52
|
+
if (slot?.displayType === 'text' || slot?.displayType === 'latex')
|
|
53
|
+
return slot.displayType;
|
|
54
|
+
if (slot?.displayType === 'img')
|
|
55
|
+
return 'img';
|
|
56
|
+
return undefined;
|
|
57
|
+
}
|
|
58
|
+
function getWorkspaceToolManifestMediaDisplayType(tool, slotName) {
|
|
59
|
+
return tool.manifest.view.mediaSlots?.find((slot) => slot.name === slotName)?.displayType;
|
|
60
|
+
}
|
|
61
|
+
function loadNodeRuntimeCapabilities(db, nodeId) {
|
|
62
|
+
const normalizedNodeId = nodeId?.trim();
|
|
63
|
+
if (!normalizedNodeId)
|
|
64
|
+
return null;
|
|
65
|
+
const row = db.prepare(`SELECT capabilities_json as capabilitiesJson
|
|
66
|
+
FROM node_runtime_snapshots
|
|
67
|
+
WHERE node_id = ?
|
|
68
|
+
LIMIT 1`).get(normalizedNodeId);
|
|
69
|
+
if (!row?.capabilitiesJson)
|
|
70
|
+
return null;
|
|
71
|
+
try {
|
|
72
|
+
const parsed = JSON.parse(row.capabilitiesJson);
|
|
73
|
+
return parsed && typeof parsed === 'object' ? parsed : null;
|
|
74
|
+
}
|
|
75
|
+
catch {
|
|
76
|
+
return null;
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
function loadPanelApiJsonlAllowedOrigins(db, conversationManager, panelAgentId) {
|
|
80
|
+
const agent = conversationManager.getAgent(panelAgentId);
|
|
81
|
+
const capabilities = loadNodeRuntimeCapabilities(db, agent?.nodeId);
|
|
82
|
+
return normalizePanelApiJsonlAllowedOrigins(capabilities?.panelApiJsonlAllowedOrigins);
|
|
83
|
+
}
|
|
84
|
+
function loadPanelApiJsonlAllowedUrlPrefixes(db, conversationManager, panelAgentId) {
|
|
85
|
+
const agent = conversationManager.getAgent(panelAgentId);
|
|
86
|
+
const capabilities = loadNodeRuntimeCapabilities(db, agent?.nodeId);
|
|
87
|
+
return normalizePanelApiJsonlAllowedUrlPrefixes(capabilities?.panelApiJsonlAllowedUrlPrefixes);
|
|
88
|
+
}
|
|
89
|
+
function loadPanelApiJsonlAuthProfiles(db, conversationManager, panelAgentId) {
|
|
90
|
+
const agent = conversationManager.getAgent(panelAgentId);
|
|
91
|
+
const capabilities = loadNodeRuntimeCapabilities(db, agent?.nodeId);
|
|
92
|
+
return normalizePanelApiJsonlAuthProfileNames(capabilities?.panelApiJsonlAuthProfiles);
|
|
93
|
+
}
|
|
94
|
+
function isWorkspaceToolVisibilityError(error) {
|
|
95
|
+
return error instanceof WorkspaceToolServiceError
|
|
96
|
+
&& (error.statusCode === 403 || error.statusCode === 404);
|
|
97
|
+
}
|
|
98
|
+
function asToolPanelLookupError(error) {
|
|
99
|
+
if (error instanceof WorkspaceToolServiceError) {
|
|
100
|
+
const serverError = new Error(error.message);
|
|
101
|
+
serverError.cause = error;
|
|
102
|
+
return serverError;
|
|
103
|
+
}
|
|
104
|
+
return error;
|
|
105
|
+
}
|
|
106
|
+
function panelMediaCacheControl(scopeType) {
|
|
107
|
+
if (scopeType === 'tool') {
|
|
108
|
+
return 'private, no-cache, max-age=0, must-revalidate';
|
|
109
|
+
}
|
|
110
|
+
return 'private, max-age=31536000, immutable';
|
|
111
|
+
}
|
|
112
|
+
function uncachedPanelMediaCacheControl() {
|
|
113
|
+
return 'private, no-cache, max-age=0, must-revalidate';
|
|
114
|
+
}
|
|
115
|
+
function canAccessConversation(user, conversationId, conversationManager, db) {
|
|
116
|
+
if (user.isAdmin)
|
|
117
|
+
return true;
|
|
118
|
+
const conv = conversationManager.getConversation(conversationId);
|
|
119
|
+
if (!conv)
|
|
120
|
+
return false;
|
|
121
|
+
if (conv.threadKind === 'direct')
|
|
122
|
+
return conv.userId === user.id;
|
|
123
|
+
return getUserChannelAccess(db, user.id).includes(conv.channelId);
|
|
124
|
+
}
|
|
125
|
+
function canUserActOnActionCardPanel(db, user, actionCardId) {
|
|
126
|
+
const card = getActionCardById(db, actionCardId);
|
|
127
|
+
if (!card)
|
|
128
|
+
return false;
|
|
129
|
+
if (user.isAdmin)
|
|
130
|
+
return true;
|
|
131
|
+
if (card.actionType !== 'channel:add_member' || !card.originChannelId)
|
|
132
|
+
return false;
|
|
133
|
+
const targetChannelId = typeof card.payload.channelId === 'string' ? card.payload.channelId.trim() : '';
|
|
134
|
+
if (!targetChannelId)
|
|
135
|
+
return false;
|
|
136
|
+
const allowedChannels = new Set(getUserChannelAccess(db, user.id));
|
|
137
|
+
return allowedChannels.has(card.originChannelId) && allowedChannels.has(targetChannelId);
|
|
138
|
+
}
|
|
139
|
+
function requireConversationAccess(req, reply, conversationId, db, conversationManager) {
|
|
140
|
+
const user = requireUser(req, reply, db);
|
|
141
|
+
if (!user)
|
|
142
|
+
return null;
|
|
143
|
+
if (!canAccessConversation(user, conversationId, conversationManager, db)) {
|
|
144
|
+
reply.code(403);
|
|
145
|
+
return null;
|
|
146
|
+
}
|
|
147
|
+
return user;
|
|
148
|
+
}
|
|
149
|
+
function parseFilter(raw) {
|
|
150
|
+
if (!raw)
|
|
151
|
+
return null;
|
|
152
|
+
const eqIdx = raw.indexOf('=');
|
|
153
|
+
if (eqIdx <= 0)
|
|
154
|
+
return null;
|
|
155
|
+
const field = raw.slice(0, eqIdx).trim();
|
|
156
|
+
const value = raw.slice(eqIdx + 1);
|
|
157
|
+
if (!field)
|
|
158
|
+
return null;
|
|
159
|
+
return { field, value };
|
|
160
|
+
}
|
|
161
|
+
function parseSort(raw) {
|
|
162
|
+
if (!raw)
|
|
163
|
+
return null;
|
|
164
|
+
const colonIdx = raw.lastIndexOf(':');
|
|
165
|
+
if (colonIdx <= 0)
|
|
166
|
+
return null;
|
|
167
|
+
const field = raw.slice(0, colonIdx).trim();
|
|
168
|
+
const direction = raw.slice(colonIdx + 1).trim().toLowerCase();
|
|
169
|
+
if (!field)
|
|
170
|
+
return null;
|
|
171
|
+
if (direction !== 'asc' && direction !== 'desc')
|
|
172
|
+
return null;
|
|
173
|
+
return { field, direction };
|
|
174
|
+
}
|
|
175
|
+
function parsePanelAggregateOp(raw) {
|
|
176
|
+
if (raw === 'count' || raw === 'sum' || raw === 'avg' || raw === 'min' || raw === 'max')
|
|
177
|
+
return raw;
|
|
178
|
+
return null;
|
|
179
|
+
}
|
|
180
|
+
function resolvePanelOwnerUserId(db, panel, conversationManager) {
|
|
181
|
+
const explicitOwner = panel.ownerUserId?.trim();
|
|
182
|
+
if (explicitOwner)
|
|
183
|
+
return explicitOwner;
|
|
184
|
+
const conversation = conversationManager.getConversation(panel.conversationId);
|
|
185
|
+
const directConversationUserId = conversation?.userId?.trim();
|
|
186
|
+
if (directConversationUserId)
|
|
187
|
+
return directConversationUserId;
|
|
188
|
+
if (panel.scopeType === 'direct') {
|
|
189
|
+
const replyTarget = conversation?.replyTarget ?? null;
|
|
190
|
+
return resolveDirectUserIdFromReplyTarget(db, replyTarget);
|
|
191
|
+
}
|
|
192
|
+
return null;
|
|
193
|
+
}
|
|
194
|
+
function isPlainRecord(value) {
|
|
195
|
+
return typeof value === 'object' && value !== null && !Array.isArray(value);
|
|
196
|
+
}
|
|
197
|
+
function resolvePanelActionCommand(action) {
|
|
198
|
+
const command = action.command?.trim() || action.rpcCommand?.trim() || '';
|
|
199
|
+
return command || null;
|
|
200
|
+
}
|
|
201
|
+
function resolvePanelActionDeclaredCwd(action) {
|
|
202
|
+
return action.cwd?.trim() || action.rpcCwd?.trim() || undefined;
|
|
203
|
+
}
|
|
204
|
+
const PANEL_RPC_TIMEOUT_MS = 30_000;
|
|
205
|
+
const PANEL_RPC_POLL_INTERVAL_MS = 150;
|
|
206
|
+
const FORM_FIELD_KEY_PATTERN = /^([0-9]+):([A-Za-z_][A-Za-z0-9_:-]{0,63})$/;
|
|
207
|
+
const PARAMETER_FIELD_KEY_PATTERN = /^[A-Za-z_][A-Za-z0-9_:-]{0,63}$/;
|
|
208
|
+
function validatePanelSubmitForm(form, changedRowIndices) {
|
|
209
|
+
if (form === undefined) {
|
|
210
|
+
if (changedRowIndices !== undefined && (!Array.isArray(changedRowIndices) || changedRowIndices.length > 0)) {
|
|
211
|
+
return { ok: false, error: 'changedRowIndices requires row-scoped form field keys' };
|
|
212
|
+
}
|
|
213
|
+
return { ok: true, form: {}, changedRowIndices: [] };
|
|
214
|
+
}
|
|
215
|
+
if (!isPlainRecord(form)) {
|
|
216
|
+
return { ok: false, error: 'form must be an object' };
|
|
217
|
+
}
|
|
218
|
+
const rowIndices = new Set();
|
|
219
|
+
for (const key of Object.keys(form)) {
|
|
220
|
+
const match = FORM_FIELD_KEY_PATTERN.exec(key);
|
|
221
|
+
if (match) {
|
|
222
|
+
rowIndices.add(Number(match[1]));
|
|
223
|
+
continue;
|
|
224
|
+
}
|
|
225
|
+
if (!PARAMETER_FIELD_KEY_PATTERN.test(key)) {
|
|
226
|
+
return { ok: false, error: `Invalid form field key: ${key}` };
|
|
227
|
+
}
|
|
228
|
+
}
|
|
229
|
+
const derived = Array.from(rowIndices).sort((a, b) => a - b);
|
|
230
|
+
if (changedRowIndices !== undefined) {
|
|
231
|
+
if (!Array.isArray(changedRowIndices)) {
|
|
232
|
+
return { ok: false, error: 'changedRowIndices must be an array' };
|
|
233
|
+
}
|
|
234
|
+
const normalized = Array.from(new Set(changedRowIndices.filter((value) => Number.isInteger(value) && value >= 0))).sort((a, b) => a - b);
|
|
235
|
+
if (normalized.length !== changedRowIndices.length) {
|
|
236
|
+
return { ok: false, error: 'changedRowIndices must contain non-negative integers' };
|
|
237
|
+
}
|
|
238
|
+
if (normalized.length !== derived.length || normalized.some((value, index) => value !== derived[index])) {
|
|
239
|
+
return { ok: false, error: 'changedRowIndices must match rows referenced by row-scoped form keys' };
|
|
240
|
+
}
|
|
241
|
+
}
|
|
242
|
+
return { ok: true, form, changedRowIndices: derived };
|
|
243
|
+
}
|
|
244
|
+
function panelRowIdsForIndices(db, panelId, rowIndices) {
|
|
245
|
+
if (rowIndices.length === 0)
|
|
246
|
+
return [];
|
|
247
|
+
const rowIds = [];
|
|
248
|
+
for (const rowIndex of rowIndices) {
|
|
249
|
+
const rowId = getPanelRowByIndex(db, panelId, rowIndex)?.rowId?.trim();
|
|
250
|
+
if (!rowId)
|
|
251
|
+
return null;
|
|
252
|
+
rowIds.push(rowId);
|
|
253
|
+
}
|
|
254
|
+
return rowIds;
|
|
255
|
+
}
|
|
256
|
+
function isPanelAnnotationBox(value) {
|
|
257
|
+
if (!value || typeof value !== 'object' || Array.isArray(value))
|
|
258
|
+
return false;
|
|
259
|
+
const box = value;
|
|
260
|
+
return ['x', 'y', 'w', 'h'].every((key) => typeof box[key] === 'number' && Number.isFinite(box[key]));
|
|
261
|
+
}
|
|
262
|
+
function isPanelAnnotation(value) {
|
|
263
|
+
if (!value || typeof value !== 'object' || Array.isArray(value))
|
|
264
|
+
return false;
|
|
265
|
+
const record = value;
|
|
266
|
+
return typeof record.label === 'string' && record.label.trim().length > 0 && isPanelAnnotationBox(record.box);
|
|
267
|
+
}
|
|
268
|
+
function containsPanelAnnotations(value) {
|
|
269
|
+
if (!Array.isArray(value))
|
|
270
|
+
return false;
|
|
271
|
+
return value.length > 0 && value.every(isPanelAnnotation);
|
|
272
|
+
}
|
|
273
|
+
function collectAnnotationFields(panel) {
|
|
274
|
+
if (panel.component !== 'RowTemplateGrid')
|
|
275
|
+
return new Set();
|
|
276
|
+
const template = panel.props?.template;
|
|
277
|
+
const fields = new Set();
|
|
278
|
+
walkPanelTemplateNodes(template, (node) => {
|
|
279
|
+
if (node.type === 'ImageSlot' && node.editable === true && typeof node.annotationsField === 'string') {
|
|
280
|
+
fields.add(node.annotationsField);
|
|
281
|
+
}
|
|
282
|
+
});
|
|
283
|
+
return fields;
|
|
284
|
+
}
|
|
285
|
+
function isAnnotationFormKey(key, annotationFields) {
|
|
286
|
+
const separatorIndex = key.indexOf(':');
|
|
287
|
+
if (separatorIndex <= 0)
|
|
288
|
+
return false;
|
|
289
|
+
return annotationFields.has(key.slice(separatorIndex + 1));
|
|
290
|
+
}
|
|
291
|
+
function normalizeStoredSelectionForPanel(panel, selection) {
|
|
292
|
+
return normalizePanelSelectionForRowCount(panel.rowCount, selection);
|
|
293
|
+
}
|
|
294
|
+
function normalizeStateSelectionForPanel(panel, state) {
|
|
295
|
+
return normalizePanelStateSelectionForRowCount(panel.rowCount, state);
|
|
296
|
+
}
|
|
297
|
+
function validateSelectionIndices(panel, selection) {
|
|
298
|
+
const seen = new Set();
|
|
299
|
+
for (const value of selection) {
|
|
300
|
+
if (typeof value !== 'number' || !Number.isInteger(value) || value < 0 || seen.has(value)) {
|
|
301
|
+
return { ok: false, error: 'selection must contain unique non-negative row indices' };
|
|
302
|
+
}
|
|
303
|
+
seen.add(value);
|
|
304
|
+
}
|
|
305
|
+
const normalized = Array.from(seen).sort((a, b) => a - b);
|
|
306
|
+
if (normalized.length !== selection.length) {
|
|
307
|
+
return { ok: false, error: 'selection must contain unique non-negative row indices' };
|
|
308
|
+
}
|
|
309
|
+
const bounded = normalizeStoredSelectionForPanel(panel, normalized);
|
|
310
|
+
if (bounded.length !== normalized.length) {
|
|
311
|
+
return { ok: false, error: 'selection references row indices outside the panel row range' };
|
|
312
|
+
}
|
|
313
|
+
return { ok: true, selection: bounded };
|
|
314
|
+
}
|
|
315
|
+
export function registerPanelRoutes(app, db, conversationManager, workspaceBroker, workspaceToolService, terminalBroker, assetStoreRoot, broadcastToChannel, broadcast) {
|
|
316
|
+
const resolvePanelActionMode = (action) => action.mode ?? (resolvePanelActionCommand(action) ? 'platform_exec' : 'notify_agent');
|
|
317
|
+
const sanitizePanelActionsForClient = (actions) => (actions ?? []).map((action) => ({
|
|
318
|
+
id: action.id,
|
|
319
|
+
label: action.label,
|
|
320
|
+
mode: resolvePanelActionMode(action),
|
|
321
|
+
...(action.variant ? { variant: action.variant } : {}),
|
|
322
|
+
...(action.description ? { description: action.description } : {}),
|
|
323
|
+
}));
|
|
324
|
+
const emitPanelDeleted = (panel) => {
|
|
325
|
+
const event = {
|
|
326
|
+
type: 'panel.deleted',
|
|
327
|
+
panelId: panel.id,
|
|
328
|
+
deletedAt: Date.now(),
|
|
329
|
+
};
|
|
330
|
+
const conv = conversationManager.getConversation(panel.conversationId);
|
|
331
|
+
if (panel.scopeType === 'direct' && broadcast) {
|
|
332
|
+
broadcast(panel.conversationId, event);
|
|
333
|
+
return;
|
|
334
|
+
}
|
|
335
|
+
if (panel.scopeType === 'tool') {
|
|
336
|
+
return;
|
|
337
|
+
}
|
|
338
|
+
if (conv?.channelId && broadcastToChannel) {
|
|
339
|
+
if (panel.scopeType === 'thread' && conv.threadRootId) {
|
|
340
|
+
event.threadRootId = conv.threadRootId;
|
|
341
|
+
}
|
|
342
|
+
broadcastToChannel(conv.channelId, event);
|
|
343
|
+
}
|
|
344
|
+
};
|
|
345
|
+
const canDeletePanelForUser = (user, panel) => {
|
|
346
|
+
if (panel.scopeType === 'tool')
|
|
347
|
+
return false;
|
|
348
|
+
if (user.isAdmin)
|
|
349
|
+
return true;
|
|
350
|
+
return resolvePanelOwnerUserId(db, panel, conversationManager) === user.id;
|
|
351
|
+
};
|
|
352
|
+
const ensurePanelSurfaceOwner = (panel) => {
|
|
353
|
+
const createdAt = typeof panel.createdAt === 'number'
|
|
354
|
+
? panel.createdAt
|
|
355
|
+
: typeof panel.createdAt === 'string'
|
|
356
|
+
? Date.parse(panel.createdAt)
|
|
357
|
+
: undefined;
|
|
358
|
+
ensureSurfaceOwner(db, {
|
|
359
|
+
surfaceType: 'panel',
|
|
360
|
+
surfaceId: panel.id,
|
|
361
|
+
agentId: panel.agentId,
|
|
362
|
+
createdAt: Number.isFinite(createdAt) ? createdAt : undefined,
|
|
363
|
+
});
|
|
364
|
+
};
|
|
365
|
+
const canManagePanelCollaboratorsForUser = (user, panel) => {
|
|
366
|
+
if (panel.scopeType === 'tool')
|
|
367
|
+
return false;
|
|
368
|
+
if (panel.archivedAt)
|
|
369
|
+
return false;
|
|
370
|
+
return canDeletePanelForUser(user, panel);
|
|
371
|
+
};
|
|
372
|
+
const formatPanelCollaboratorsForUser = (user, panel) => {
|
|
373
|
+
ensurePanelSurfaceOwner(panel);
|
|
374
|
+
return {
|
|
375
|
+
panelId: panel.id,
|
|
376
|
+
canManage: canManagePanelCollaboratorsForUser(user, panel),
|
|
377
|
+
collaborators: listSurfaceCollaborators(db, 'panel', panel.id).map((row) => {
|
|
378
|
+
const agent = conversationManager.getAgent(row.agentId);
|
|
379
|
+
return {
|
|
380
|
+
agentId: row.agentId,
|
|
381
|
+
name: agent?.name ?? null,
|
|
382
|
+
role: row.role,
|
|
383
|
+
addedBy: row.addedBy ?? null,
|
|
384
|
+
createdAt: new Date(row.createdAt).toISOString(),
|
|
385
|
+
};
|
|
386
|
+
}),
|
|
387
|
+
};
|
|
388
|
+
};
|
|
389
|
+
const canAccessPanel = async (user, panel) => {
|
|
390
|
+
if (panel.scopeType === 'tool') {
|
|
391
|
+
try {
|
|
392
|
+
await resolveToolProjectionForViewer(user, panel.id);
|
|
393
|
+
return true;
|
|
394
|
+
}
|
|
395
|
+
catch (error) {
|
|
396
|
+
if (!isWorkspaceToolVisibilityError(error)) {
|
|
397
|
+
throw error;
|
|
398
|
+
}
|
|
399
|
+
return false;
|
|
400
|
+
}
|
|
401
|
+
}
|
|
402
|
+
return canAccessConversation(user, panel.conversationId, conversationManager, db);
|
|
403
|
+
};
|
|
404
|
+
const resolveToolProjectionForViewer = async (user, panelId) => {
|
|
405
|
+
try {
|
|
406
|
+
return await workspaceToolService.getToolForViewerByPanelId(user.id, panelId);
|
|
407
|
+
}
|
|
408
|
+
catch (error) {
|
|
409
|
+
if (!isWorkspaceToolVisibilityError(error)) {
|
|
410
|
+
throw asToolPanelLookupError(error);
|
|
411
|
+
}
|
|
412
|
+
throw error;
|
|
413
|
+
}
|
|
414
|
+
};
|
|
415
|
+
const getPanelSurfaceContractForViewer = async (user, panel) => {
|
|
416
|
+
if (panel.scopeType !== 'tool') {
|
|
417
|
+
return {
|
|
418
|
+
scopeId: null,
|
|
419
|
+
surfaceProfile: 'panel',
|
|
420
|
+
surfaceFeatures: { browseControls: true },
|
|
421
|
+
};
|
|
422
|
+
}
|
|
423
|
+
const surface = workspaceToolService.getToolSurfaceMetadataForViewer(user.id, panel.id);
|
|
424
|
+
return {
|
|
425
|
+
scopeId: panel.id,
|
|
426
|
+
surfaceProfile: surface.surfaceProfile,
|
|
427
|
+
surfaceFeatures: surface.surfaceFeatures,
|
|
428
|
+
};
|
|
429
|
+
};
|
|
430
|
+
const canViewerSeeToolMaintenanceConversationId = (user, panel) => {
|
|
431
|
+
const panelOwnerUserId = resolvePanelOwnerUserId(db, panel, conversationManager);
|
|
432
|
+
return user.isAdmin || panelOwnerUserId === user.id;
|
|
433
|
+
};
|
|
434
|
+
const canRunPanelPlatformExec = (user, panel) => {
|
|
435
|
+
const conversation = conversationManager.getConversation(panel.conversationId);
|
|
436
|
+
if (!conversation) {
|
|
437
|
+
return { ok: false, statusCode: 409, error: 'Panel conversation is unavailable' };
|
|
438
|
+
}
|
|
439
|
+
if (panel.scopeType === 'direct') {
|
|
440
|
+
if (conversation.threadKind !== 'direct') {
|
|
441
|
+
return { ok: false, statusCode: 409, error: 'Direct panel platform_exec actions require a direct conversation' };
|
|
442
|
+
}
|
|
443
|
+
const directUserId = conversation.userId?.trim()
|
|
444
|
+
|| resolveDirectUserIdFromReplyTarget(db, conversation.replyTarget ?? null);
|
|
445
|
+
if (!directUserId) {
|
|
446
|
+
return { ok: false, statusCode: 409, error: 'Direct panel platform_exec actions require a user-bound direct panel' };
|
|
447
|
+
}
|
|
448
|
+
return user.isAdmin || directUserId === user.id
|
|
449
|
+
? { ok: true }
|
|
450
|
+
: { ok: false, statusCode: 403, error: 'Access denied' };
|
|
451
|
+
}
|
|
452
|
+
if (panel.scopeType === 'channel' || panel.scopeType === 'thread') {
|
|
453
|
+
if (!conversation.channelId) {
|
|
454
|
+
return { ok: false, statusCode: 409, error: 'Panel channel is unavailable' };
|
|
455
|
+
}
|
|
456
|
+
return user.isAdmin || getUserChannelAccess(db, user.id).includes(conversation.channelId)
|
|
457
|
+
? { ok: true }
|
|
458
|
+
: { ok: false, statusCode: 403, error: 'Access denied' };
|
|
459
|
+
}
|
|
460
|
+
return { ok: false, statusCode: 409, error: 'Tool-scoped panel actions must be delegated to the workspace tool' };
|
|
461
|
+
};
|
|
462
|
+
const emitPanelActionTriggered = (panelId, conversationId, scopeType, userId, submitKind, actionId) => {
|
|
463
|
+
const conv = conversationManager.getConversation(conversationId);
|
|
464
|
+
if (!conv)
|
|
465
|
+
return;
|
|
466
|
+
const actionEvent = {
|
|
467
|
+
type: 'panel.action_triggered',
|
|
468
|
+
panelId,
|
|
469
|
+
submitKind,
|
|
470
|
+
...(actionId ? { actionId } : undefined),
|
|
471
|
+
triggeredByUserId: userId,
|
|
472
|
+
triggeredAt: Date.now(),
|
|
473
|
+
};
|
|
474
|
+
if (scopeType === 'direct' && broadcast) {
|
|
475
|
+
broadcast(conversationId, actionEvent);
|
|
476
|
+
return;
|
|
477
|
+
}
|
|
478
|
+
if (scopeType === 'tool') {
|
|
479
|
+
return;
|
|
480
|
+
}
|
|
481
|
+
if (conv.channelId && broadcastToChannel) {
|
|
482
|
+
if (scopeType === 'thread' && conv.threadRootId) {
|
|
483
|
+
actionEvent.threadRootId = conv.threadRootId;
|
|
484
|
+
}
|
|
485
|
+
broadcastToChannel(conv.channelId, actionEvent);
|
|
486
|
+
}
|
|
487
|
+
};
|
|
488
|
+
const resolvePanelActionExecutionCwd = (workspaceRoot, actionCwd) => {
|
|
489
|
+
if (!actionCwd)
|
|
490
|
+
return { ok: true, cwd: workspaceRoot };
|
|
491
|
+
if (path.isAbsolute(actionCwd)) {
|
|
492
|
+
return { ok: false, error: 'action cwd must be relative to the agent workspace root' };
|
|
493
|
+
}
|
|
494
|
+
const resolved = path.resolve(workspaceRoot, actionCwd);
|
|
495
|
+
const relative = path.relative(workspaceRoot, resolved);
|
|
496
|
+
if (relative.startsWith('..') || path.isAbsolute(relative)) {
|
|
497
|
+
return { ok: false, error: 'action cwd must stay inside the agent workspace root' };
|
|
498
|
+
}
|
|
499
|
+
return { ok: true, cwd: resolved };
|
|
500
|
+
};
|
|
501
|
+
const waitForPanelActionTerminalExit = async (nodeId, terminalId) => {
|
|
502
|
+
const deadline = Date.now() + PANEL_RPC_TIMEOUT_MS;
|
|
503
|
+
while (Date.now() < deadline) {
|
|
504
|
+
const snapshot = await terminalBroker.snapshotTerminal(nodeId, terminalId);
|
|
505
|
+
if (snapshot.terminal.exited)
|
|
506
|
+
return snapshot;
|
|
507
|
+
await new Promise((resolve) => setTimeout(resolve, PANEL_RPC_POLL_INTERVAL_MS));
|
|
508
|
+
}
|
|
509
|
+
throw new Error('Timed out waiting for panel RPC action to finish.');
|
|
510
|
+
};
|
|
511
|
+
const submitPanel = async (params) => {
|
|
512
|
+
const panel = getPanelById(db, params.panelId);
|
|
513
|
+
if (!panel) {
|
|
514
|
+
params.reply.code(404);
|
|
515
|
+
return { error: 'Not found' };
|
|
516
|
+
}
|
|
517
|
+
if (panel.archivedAt) {
|
|
518
|
+
params.reply.code(409);
|
|
519
|
+
return { error: 'Panel is archived' };
|
|
520
|
+
}
|
|
521
|
+
if (!(await canAccessPanel(params.user, panel))) {
|
|
522
|
+
params.reply.code(403);
|
|
523
|
+
return { error: 'Access denied' };
|
|
524
|
+
}
|
|
525
|
+
if (panel.scopeType === 'tool') {
|
|
526
|
+
params.reply.code(409);
|
|
527
|
+
return { error: 'Tool snapshot panels do not accept panel submits' };
|
|
528
|
+
}
|
|
529
|
+
const action = params.submitKind === 'action'
|
|
530
|
+
? (panel.actions ?? []).find((a) => a.id === params.actionId)
|
|
531
|
+
: undefined;
|
|
532
|
+
if (params.submitKind === 'action' && !action) {
|
|
533
|
+
params.reply.code(400);
|
|
534
|
+
return { error: 'Action not declared for this panel' };
|
|
535
|
+
}
|
|
536
|
+
if (params.submitKind !== 'action' && params.actionId !== undefined) {
|
|
537
|
+
params.reply.code(400);
|
|
538
|
+
return { error: 'actionId is only valid for action submits' };
|
|
539
|
+
}
|
|
540
|
+
const conv = conversationManager.getConversation(panel.conversationId);
|
|
541
|
+
if (!conv) {
|
|
542
|
+
params.reply.code(404);
|
|
543
|
+
return { error: 'Conversation not found' };
|
|
544
|
+
}
|
|
545
|
+
if (panel.scopeType !== 'direct' && !conv.channelId) {
|
|
546
|
+
params.reply.code(400);
|
|
547
|
+
return { error: 'Panel conversation is missing channel scope' };
|
|
548
|
+
}
|
|
549
|
+
if (panel.scopeType === 'thread' && !conv.threadRootId) {
|
|
550
|
+
params.reply.code(400);
|
|
551
|
+
return { error: 'Panel conversation is missing thread scope' };
|
|
552
|
+
}
|
|
553
|
+
const scope = panel.scopeType === 'direct'
|
|
554
|
+
? { type: 'direct', conversationId: panel.conversationId }
|
|
555
|
+
: panel.scopeType === 'thread'
|
|
556
|
+
? {
|
|
557
|
+
type: 'thread',
|
|
558
|
+
conversationId: panel.conversationId,
|
|
559
|
+
channelId: conv.channelId,
|
|
560
|
+
threadRootId: conv.threadRootId,
|
|
561
|
+
}
|
|
562
|
+
: {
|
|
563
|
+
type: 'channel',
|
|
564
|
+
conversationId: panel.conversationId,
|
|
565
|
+
channelId: conv.channelId,
|
|
566
|
+
};
|
|
567
|
+
const perUserState = getPanelState(db, panel.id, params.user.id);
|
|
568
|
+
const filters = perUserState?.state?.filters ?? {};
|
|
569
|
+
const sort = perUserState?.state?.sort ?? {};
|
|
570
|
+
let selection;
|
|
571
|
+
let latestVersion;
|
|
572
|
+
if (panel.scopeType !== 'direct') {
|
|
573
|
+
const sharedState = getSharedPanelState(db, panel.id);
|
|
574
|
+
selection = normalizeStoredSelectionForPanel(panel, sharedState?.state?.selection);
|
|
575
|
+
latestVersion = sharedState?.version ?? 0;
|
|
576
|
+
}
|
|
577
|
+
else {
|
|
578
|
+
selection = normalizeStoredSelectionForPanel(panel, perUserState?.state?.selection);
|
|
579
|
+
latestVersion = panel.version;
|
|
580
|
+
}
|
|
581
|
+
const validatedForm = validatePanelSubmitForm(params.form, params.changedRowIndices);
|
|
582
|
+
if (!validatedForm.ok) {
|
|
583
|
+
params.reply.code(400);
|
|
584
|
+
return { error: validatedForm.error };
|
|
585
|
+
}
|
|
586
|
+
const changedRowIndices = validatedForm.changedRowIndices;
|
|
587
|
+
const form = validatedForm.form;
|
|
588
|
+
if (changedRowIndices.some((rowIndex) => !getPanelRowByIndex(db, panel.id, rowIndex))) {
|
|
589
|
+
params.reply.code(400);
|
|
590
|
+
return { error: 'form references unknown row index' };
|
|
591
|
+
}
|
|
592
|
+
const submittedBaseVersion = Number.isFinite(params.baseVersion)
|
|
593
|
+
? Number(params.baseVersion)
|
|
594
|
+
: latestVersion;
|
|
595
|
+
const previewLimit = 5;
|
|
596
|
+
const previewSource = changedRowIndices.length > 0 ? changedRowIndices : selection;
|
|
597
|
+
const selectedRowIds = panelRowIdsForIndices(db, panel.id, selection);
|
|
598
|
+
const changedRowIds = panelRowIdsForIndices(db, panel.id, changedRowIndices);
|
|
599
|
+
const previewRows = previewSource
|
|
600
|
+
.slice(0, previewLimit)
|
|
601
|
+
.map((rowIndex) => getPanelRowByIndex(db, panel.id, rowIndex))
|
|
602
|
+
.filter((row) => Boolean(row))
|
|
603
|
+
.map((row) => ({
|
|
604
|
+
rowIndex: row.rowIndex,
|
|
605
|
+
fields: row.fields,
|
|
606
|
+
}));
|
|
607
|
+
const formDelta = Object.fromEntries(Object.entries(form).map(([key, value]) => [key, { to: value }]));
|
|
608
|
+
const basePayload = {
|
|
609
|
+
panelId: panel.id,
|
|
610
|
+
actor: {
|
|
611
|
+
userId: params.user.id,
|
|
612
|
+
username: params.user.username ?? 'User',
|
|
613
|
+
},
|
|
614
|
+
scope,
|
|
615
|
+
baseVersion: submittedBaseVersion,
|
|
616
|
+
submittedVersion: latestVersion,
|
|
617
|
+
...(submittedBaseVersion < latestVersion
|
|
618
|
+
? { concurrency: { staleBase: true, latestVersion } }
|
|
619
|
+
: undefined),
|
|
620
|
+
viewState: {
|
|
621
|
+
filters,
|
|
622
|
+
sort,
|
|
623
|
+
},
|
|
624
|
+
refs: {
|
|
625
|
+
selectedRowIndices: selection,
|
|
626
|
+
...(selectedRowIds && selectedRowIds.length > 0 ? { selectedRowIds } : undefined),
|
|
627
|
+
...(changedRowIndices.length > 0 ? { changedRowIndices } : undefined),
|
|
628
|
+
...(changedRowIds && changedRowIds.length > 0 ? { changedRowIds } : undefined),
|
|
629
|
+
},
|
|
630
|
+
delta: {
|
|
631
|
+
...(panel.scopeType !== 'direct' ? { shared: { selection } } : undefined),
|
|
632
|
+
perUser: { filters, sort },
|
|
633
|
+
...(Object.keys(formDelta).length > 0 ? { form: formDelta } : undefined),
|
|
634
|
+
},
|
|
635
|
+
summary: {
|
|
636
|
+
selectedCount: selection.length,
|
|
637
|
+
...(Object.keys(formDelta).length > 0 ? { changedFieldCount: Object.keys(formDelta).length } : undefined),
|
|
638
|
+
truncated: previewSource.length > previewRows.length,
|
|
639
|
+
},
|
|
640
|
+
preview: {
|
|
641
|
+
rows: previewRows,
|
|
642
|
+
},
|
|
643
|
+
metadata: {
|
|
644
|
+
...(params.metadata ?? {}),
|
|
645
|
+
...(action
|
|
646
|
+
? {
|
|
647
|
+
action: {
|
|
648
|
+
id: action.id,
|
|
649
|
+
label: action.label,
|
|
650
|
+
...(action.variant ? { variant: action.variant } : {}),
|
|
651
|
+
...(action.description ? { description: action.description } : {}),
|
|
652
|
+
},
|
|
653
|
+
}
|
|
654
|
+
: undefined),
|
|
655
|
+
component: panel.component,
|
|
656
|
+
},
|
|
657
|
+
};
|
|
658
|
+
const payload = params.submitKind === 'action'
|
|
659
|
+
? {
|
|
660
|
+
...basePayload,
|
|
661
|
+
submitKind: 'action',
|
|
662
|
+
actionId: action.id,
|
|
663
|
+
}
|
|
664
|
+
: {
|
|
665
|
+
...basePayload,
|
|
666
|
+
submitKind: params.submitKind,
|
|
667
|
+
};
|
|
668
|
+
const prompt = buildPanelActionActivationPrompt({ payload });
|
|
669
|
+
const activationContextSections = buildPanelActionActivationContextSections({ payload });
|
|
670
|
+
const auditChannelId = conv.threadKind === 'direct' ? `dm:${panel.agentId}` : conv.channelId;
|
|
671
|
+
const submitLabel = action?.label ?? params.metadata?.label ?? params.submitKind;
|
|
672
|
+
insertPanelActionMessage(db, {
|
|
673
|
+
channelId: auditChannelId,
|
|
674
|
+
target: conv.replyTarget ?? `dm:${panel.agentId}`,
|
|
675
|
+
actionLabel: submitLabel,
|
|
676
|
+
payload,
|
|
677
|
+
threadRootId: conv.threadRootId ?? null,
|
|
678
|
+
});
|
|
679
|
+
const result = await conversationManager.submitPrompt(panel.conversationId, prompt, {
|
|
680
|
+
recordAsUserMessage: false,
|
|
681
|
+
activationContextSections,
|
|
682
|
+
});
|
|
683
|
+
emitPanelActionTriggered(panel.id, panel.conversationId, panel.scopeType, params.user.id, params.submitKind, action?.id);
|
|
684
|
+
insertPanelAuditEvent(db, {
|
|
685
|
+
panelId: panel.id,
|
|
686
|
+
eventType: params.submitKind === 'action' ? 'actioned' : 'submitted',
|
|
687
|
+
version: panel.version,
|
|
688
|
+
submitKind: params.submitKind,
|
|
689
|
+
actionId: params.actionId ?? undefined,
|
|
690
|
+
actorType: 'user',
|
|
691
|
+
actorId: params.user.id,
|
|
692
|
+
runId: result.runId ?? null,
|
|
693
|
+
conversationId: panel.conversationId,
|
|
694
|
+
scopeType: panel.scopeType,
|
|
695
|
+
scopeId: panel.scopeId ?? null,
|
|
696
|
+
metadata: params.submitKind === 'action'
|
|
697
|
+
? { actionId: params.actionId, actionMode: 'notify_agent' }
|
|
698
|
+
: {
|
|
699
|
+
baseVersion: submittedBaseVersion,
|
|
700
|
+
submittedVersion: latestVersion,
|
|
701
|
+
selectedCount: selection.length,
|
|
702
|
+
...(selection.length > 0 ? { selectedRowIndices: selection } : undefined),
|
|
703
|
+
...(selectedRowIds && selectedRowIds.length > 0 ? { selectedRowIds } : undefined),
|
|
704
|
+
...(changedRowIndices.length > 0 ? { changedRowIndices } : undefined),
|
|
705
|
+
...(changedRowIds && changedRowIds.length > 0 ? { changedRowIds } : undefined),
|
|
706
|
+
...(Object.keys(formDelta).length > 0 ? { changedFieldCount: Object.keys(formDelta).length } : {}),
|
|
707
|
+
},
|
|
708
|
+
});
|
|
709
|
+
return { ok: true, queued: result.queued, runId: result.runId };
|
|
710
|
+
};
|
|
711
|
+
app.get('/api/panel-previews/:id', async (req, reply) => {
|
|
712
|
+
const user = requireUser(req, reply, db);
|
|
713
|
+
if (!user)
|
|
714
|
+
return { error: 'Unauthorized' };
|
|
715
|
+
const preview = getPanelPreviewById(db, req.params.id);
|
|
716
|
+
if (!preview) {
|
|
717
|
+
reply.code(404);
|
|
718
|
+
return { error: 'Not found' };
|
|
719
|
+
}
|
|
720
|
+
if (isPanelPreviewExpired(preview)) {
|
|
721
|
+
deleteExpiredPanelPreviews(db);
|
|
722
|
+
reply.code(410);
|
|
723
|
+
return { error: 'Panel preview expired' };
|
|
724
|
+
}
|
|
725
|
+
if (!canAccessConversation(user, preview.conversationId, conversationManager, db)) {
|
|
726
|
+
reply.code(403);
|
|
727
|
+
return { error: 'Access denied' };
|
|
728
|
+
}
|
|
729
|
+
return {
|
|
730
|
+
id: preview.id,
|
|
731
|
+
component: preview.component,
|
|
732
|
+
props: preview.props ?? {},
|
|
733
|
+
actions: [],
|
|
734
|
+
rowCount: preview.rowCount ?? 0,
|
|
735
|
+
rowCountKnown: preview.rowCountKnown ?? true,
|
|
736
|
+
datasetSource: preview.datasetSource,
|
|
737
|
+
status: preview.status,
|
|
738
|
+
progress: null,
|
|
739
|
+
result: null,
|
|
740
|
+
scopeType: 'preview',
|
|
741
|
+
scopeId: preview.conversationId,
|
|
742
|
+
surfaceProfile: 'preview',
|
|
743
|
+
surfaceFeatures: { browseControls: true },
|
|
744
|
+
conversationId: preview.conversationId,
|
|
745
|
+
agentId: preview.agentId,
|
|
746
|
+
createdAt: preview.createdAt,
|
|
747
|
+
updatedAt: preview.updatedAt,
|
|
748
|
+
archivedAt: null,
|
|
749
|
+
expiresAt: preview.expiresAt,
|
|
750
|
+
version: 1,
|
|
751
|
+
canDelete: false,
|
|
752
|
+
toolPromotion: null,
|
|
753
|
+
};
|
|
754
|
+
});
|
|
755
|
+
app.get('/api/panel-previews/:id/rows', async (req, reply) => {
|
|
756
|
+
const user = requireUser(req, reply, db);
|
|
757
|
+
if (!user)
|
|
758
|
+
return { error: 'Unauthorized' };
|
|
759
|
+
const preview = getPanelPreviewById(db, req.params.id);
|
|
760
|
+
if (!preview) {
|
|
761
|
+
reply.code(404);
|
|
762
|
+
return { error: 'Not found' };
|
|
763
|
+
}
|
|
764
|
+
if (isPanelPreviewExpired(preview)) {
|
|
765
|
+
deleteExpiredPanelPreviews(db);
|
|
766
|
+
reply.code(410);
|
|
767
|
+
return { error: 'Panel preview expired' };
|
|
768
|
+
}
|
|
769
|
+
if (!canAccessConversation(user, preview.conversationId, conversationManager, db)) {
|
|
770
|
+
reply.code(403);
|
|
771
|
+
return { error: 'Access denied' };
|
|
772
|
+
}
|
|
773
|
+
const contract = getComponentContract(preview.component);
|
|
774
|
+
if (!contract) {
|
|
775
|
+
reply.code(400);
|
|
776
|
+
return { error: 'Unknown component contract' };
|
|
777
|
+
}
|
|
778
|
+
const effectiveDatasetSchema = getEffectiveDatasetSchema(contract, preview.props ?? {});
|
|
779
|
+
const fieldByName = new Map(effectiveDatasetSchema?.fields.map((f) => [f.name, f]) ?? []);
|
|
780
|
+
const declaredFields = new Set(effectiveDatasetSchema?.fields.map((f) => f.name) ?? []);
|
|
781
|
+
const filterableFields = new Set(effectiveDatasetSchema?.fields.filter((f) => f.filterable).map((f) => f.name) ?? []);
|
|
782
|
+
const sortableFields = new Set(effectiveDatasetSchema?.fields.filter((f) => f.sortable).map((f) => f.name) ?? []);
|
|
783
|
+
const filter = parseFilter(req.query.filter);
|
|
784
|
+
if (filter) {
|
|
785
|
+
if (!declaredFields.has(filter.field)) {
|
|
786
|
+
reply.code(400);
|
|
787
|
+
return { error: `Filter field "${filter.field}" is not declared` };
|
|
788
|
+
}
|
|
789
|
+
if (!filterableFields.has(filter.field)) {
|
|
790
|
+
reply.code(400);
|
|
791
|
+
return { error: `Filter field "${filter.field}" is not filterable` };
|
|
792
|
+
}
|
|
793
|
+
}
|
|
794
|
+
const sort = parseSort(req.query.sort);
|
|
795
|
+
if (sort) {
|
|
796
|
+
if (!declaredFields.has(sort.field)) {
|
|
797
|
+
reply.code(400);
|
|
798
|
+
return { error: `Sort field "${sort.field}" is not declared` };
|
|
799
|
+
}
|
|
800
|
+
if (!sortableFields.has(sort.field)) {
|
|
801
|
+
reply.code(400);
|
|
802
|
+
return { error: `Sort field "${sort.field}" is not sortable` };
|
|
803
|
+
}
|
|
804
|
+
}
|
|
805
|
+
const limit = Number(req.query.limit ?? '50');
|
|
806
|
+
const options = {
|
|
807
|
+
filterField: filter?.field,
|
|
808
|
+
filterValue: filter?.value,
|
|
809
|
+
sortField: sort?.field,
|
|
810
|
+
sortDirection: sort?.direction,
|
|
811
|
+
sortFieldType: sort ? fieldByName.get(sort.field)?.type : undefined,
|
|
812
|
+
cursor: req.query.cursor ?? null,
|
|
813
|
+
limit: Number.isFinite(limit) && limit > 0 ? limit : 50,
|
|
814
|
+
};
|
|
815
|
+
const result = listPanelPreviewRows(db, preview.id, options);
|
|
816
|
+
return { rows: result.rows, nextCursor: result.nextCursor };
|
|
817
|
+
});
|
|
818
|
+
app.get('/api/panel-previews/:id/rows/:rowIndex/media/:slot', async (req, reply) => {
|
|
819
|
+
const user = requireUser(req, reply, db);
|
|
820
|
+
if (!user)
|
|
821
|
+
return { error: 'Unauthorized' };
|
|
822
|
+
const preview = getPanelPreviewById(db, req.params.id);
|
|
823
|
+
if (!preview) {
|
|
824
|
+
reply.code(404);
|
|
825
|
+
return { error: 'Not found' };
|
|
826
|
+
}
|
|
827
|
+
if (isPanelPreviewExpired(preview)) {
|
|
828
|
+
deleteExpiredPanelPreviews(db);
|
|
829
|
+
reply.code(410);
|
|
830
|
+
return { error: 'Panel preview expired' };
|
|
831
|
+
}
|
|
832
|
+
if (!canAccessConversation(user, preview.conversationId, conversationManager, db)) {
|
|
833
|
+
reply.code(403);
|
|
834
|
+
return { error: 'Access denied' };
|
|
835
|
+
}
|
|
836
|
+
const rowIndex = Number(req.params.rowIndex);
|
|
837
|
+
if (!Number.isFinite(rowIndex) || rowIndex < 0 || !Number.isInteger(rowIndex)) {
|
|
838
|
+
reply.code(404);
|
|
839
|
+
return { error: 'Not found' };
|
|
840
|
+
}
|
|
841
|
+
const row = getPanelPreviewRowByIndex(db, preview.id, rowIndex);
|
|
842
|
+
if (!row) {
|
|
843
|
+
reply.code(404);
|
|
844
|
+
return { error: 'Not found' };
|
|
845
|
+
}
|
|
846
|
+
const mediaSlot = row.media[req.params.slot];
|
|
847
|
+
if (!mediaSlot || mediaSlot.kind !== 'workspace_path') {
|
|
848
|
+
reply.code(404);
|
|
849
|
+
return { error: 'Not found' };
|
|
850
|
+
}
|
|
851
|
+
if (!row.nodeId) {
|
|
852
|
+
reply.code(503);
|
|
853
|
+
return { error: 'Agent node unavailable' };
|
|
854
|
+
}
|
|
855
|
+
const declaredDisplayType = getDeclaredPanelMediaDisplayType(preview, req.params.slot) ?? 'img';
|
|
856
|
+
const agent = conversationManager.getAgent(preview.agentId);
|
|
857
|
+
const resolvedMediaPath = resolvePanelMediaPath(agent?.workspacePath, mediaSlot.value, conversationManager.getConfig());
|
|
858
|
+
if (!resolvedMediaPath.ok) {
|
|
859
|
+
reply.code(resolvedMediaPath.statusCode);
|
|
860
|
+
return { error: resolvedMediaPath.error };
|
|
861
|
+
}
|
|
862
|
+
const result = await workspaceBroker.streamExactPath(row.nodeId, resolvedMediaPath.absolutePath, { allowedRoot: resolvedMediaPath.allowedRoot });
|
|
863
|
+
if (!panelMediaMimeMatchesDisplayType(result.mimeType, declaredDisplayType)) {
|
|
864
|
+
reply.code(400);
|
|
865
|
+
return { error: declaredDisplayType === 'img' ? 'Non-image media not supported' : `Media mime type does not match declared displayType ${declaredDisplayType}` };
|
|
866
|
+
}
|
|
867
|
+
if (result.size > panelMediaDisplayTypeLimitBytes(declaredDisplayType)) {
|
|
868
|
+
const error = panelMediaSizeCapExceededError(declaredDisplayType);
|
|
869
|
+
reply.code(error.statusCode);
|
|
870
|
+
return { error: error.error };
|
|
871
|
+
}
|
|
872
|
+
const chunks = [];
|
|
873
|
+
for await (const chunk of result.stream) {
|
|
874
|
+
chunks.push(Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk));
|
|
875
|
+
}
|
|
876
|
+
const buffer = Buffer.concat(chunks);
|
|
877
|
+
reply.header('Content-Type', result.mimeType);
|
|
878
|
+
reply.header('Content-Length', String(buffer.length));
|
|
879
|
+
reply.header('Cache-Control', uncachedPanelMediaCacheControl());
|
|
880
|
+
reply.header('X-Content-Type-Options', 'nosniff');
|
|
881
|
+
return reply.send(buffer);
|
|
882
|
+
});
|
|
883
|
+
app.get('/api/panels/:id', async (req, reply) => {
|
|
884
|
+
const user = requireUser(req, reply, db);
|
|
885
|
+
if (!user)
|
|
886
|
+
return { error: 'Unauthorized' };
|
|
887
|
+
const panel = getPanelById(db, req.params.id);
|
|
888
|
+
if (!panel) {
|
|
889
|
+
reply.code(404);
|
|
890
|
+
return { error: 'Not found' };
|
|
891
|
+
}
|
|
892
|
+
if (!(await canAccessPanel(user, panel))) {
|
|
893
|
+
reply.code(403);
|
|
894
|
+
return { error: 'Access denied' };
|
|
895
|
+
}
|
|
896
|
+
const canViewToolMaintenanceConversationId = canViewerSeeToolMaintenanceConversationId(user, panel);
|
|
897
|
+
const surfaceContract = await getPanelSurfaceContractForViewer(user, panel);
|
|
898
|
+
return {
|
|
899
|
+
id: panel.id,
|
|
900
|
+
component: panel.component,
|
|
901
|
+
props: panel.props ?? {},
|
|
902
|
+
actions: sanitizePanelActionsForClient(panel.actions),
|
|
903
|
+
rowCount: panel.rowCount ?? 0,
|
|
904
|
+
rowCountKnown: panel.rowCountKnown ?? true,
|
|
905
|
+
datasetSource: panel.datasetSource,
|
|
906
|
+
status: panel.status,
|
|
907
|
+
progress: panel.progress ?? null,
|
|
908
|
+
result: panel.result ?? null,
|
|
909
|
+
scopeType: panel.scopeType,
|
|
910
|
+
scopeId: panel.scopeId ?? surfaceContract.scopeId ?? null,
|
|
911
|
+
toolId: panel.toolId ?? null,
|
|
912
|
+
surfaceProfile: surfaceContract.surfaceProfile,
|
|
913
|
+
surfaceFeatures: surfaceContract.surfaceFeatures,
|
|
914
|
+
conversationId: panel.scopeType === 'tool' && !canViewToolMaintenanceConversationId ? '' : panel.conversationId,
|
|
915
|
+
agentId: panel.agentId,
|
|
916
|
+
createdAt: panel.createdAt,
|
|
917
|
+
updatedAt: panel.updatedAt,
|
|
918
|
+
archivedAt: panel.archivedAt ?? null,
|
|
919
|
+
version: panel.version,
|
|
920
|
+
canDelete: canDeletePanelForUser(user, panel),
|
|
921
|
+
toolPromotion: panel.scopeType === 'tool' ? null : workspaceToolService.getPanelPromotionInfoForUser(user.id, panel.id),
|
|
922
|
+
};
|
|
923
|
+
});
|
|
924
|
+
app.get('/api/panels/:id/collaborators', async (req, reply) => {
|
|
925
|
+
const user = requireUser(req, reply, db);
|
|
926
|
+
if (!user)
|
|
927
|
+
return { error: 'Unauthorized' };
|
|
928
|
+
const panel = getPanelById(db, req.params.id);
|
|
929
|
+
if (!panel) {
|
|
930
|
+
reply.code(404);
|
|
931
|
+
return { error: 'Not found' };
|
|
932
|
+
}
|
|
933
|
+
if (!(await canAccessPanel(user, panel))) {
|
|
934
|
+
reply.code(403);
|
|
935
|
+
return { error: 'Access denied' };
|
|
936
|
+
}
|
|
937
|
+
if (panel.scopeType === 'tool') {
|
|
938
|
+
reply.code(409);
|
|
939
|
+
return { error: 'Tool panel collaborators are managed through the workspace tool surface' };
|
|
940
|
+
}
|
|
941
|
+
return formatPanelCollaboratorsForUser(user, panel);
|
|
942
|
+
});
|
|
943
|
+
app.post('/api/panels/:id/collaborators', async (req, reply) => {
|
|
944
|
+
const user = requireUser(req, reply, db);
|
|
945
|
+
if (!user)
|
|
946
|
+
return { error: 'Unauthorized' };
|
|
947
|
+
const panel = getPanelById(db, req.params.id);
|
|
948
|
+
if (!panel) {
|
|
949
|
+
reply.code(404);
|
|
950
|
+
return { error: 'Not found' };
|
|
951
|
+
}
|
|
952
|
+
if (!(await canAccessPanel(user, panel))) {
|
|
953
|
+
reply.code(403);
|
|
954
|
+
return { error: 'Access denied' };
|
|
955
|
+
}
|
|
956
|
+
if (panel.scopeType === 'tool') {
|
|
957
|
+
reply.code(409);
|
|
958
|
+
return { error: 'Tool panel collaborators are managed through the workspace tool surface' };
|
|
959
|
+
}
|
|
960
|
+
if (!canManagePanelCollaboratorsForUser(user, panel)) {
|
|
961
|
+
reply.code(panel.archivedAt ? 409 : 403);
|
|
962
|
+
return { error: panel.archivedAt ? 'Panel is archived' : 'Access denied' };
|
|
963
|
+
}
|
|
964
|
+
const body = req.body ?? {};
|
|
965
|
+
const collaboratorAgentId = typeof body.collaboratorAgentId === 'string' && body.collaboratorAgentId.trim()
|
|
966
|
+
? body.collaboratorAgentId.trim()
|
|
967
|
+
: typeof body.agentId === 'string' && body.agentId.trim()
|
|
968
|
+
? body.agentId.trim()
|
|
969
|
+
: '';
|
|
970
|
+
if (!collaboratorAgentId) {
|
|
971
|
+
reply.code(400);
|
|
972
|
+
return { error: 'collaboratorAgentId is required' };
|
|
973
|
+
}
|
|
974
|
+
const collaboratorAgent = conversationManager.getAgent(collaboratorAgentId);
|
|
975
|
+
if (!collaboratorAgent || isPlatformAgent(collaboratorAgent)) {
|
|
976
|
+
reply.code(404);
|
|
977
|
+
return { error: 'Collaborator agent not found' };
|
|
978
|
+
}
|
|
979
|
+
if (!user.isAdmin && !getUserAgentAccess(db, user.id).includes(collaboratorAgent.agentId)) {
|
|
980
|
+
reply.code(403);
|
|
981
|
+
return { error: 'Access denied' };
|
|
982
|
+
}
|
|
983
|
+
ensurePanelSurfaceOwner(panel);
|
|
984
|
+
upsertSurfaceCollaborator(db, {
|
|
985
|
+
surfaceType: 'panel',
|
|
986
|
+
surfaceId: panel.id,
|
|
987
|
+
agentId: collaboratorAgent.agentId,
|
|
988
|
+
role: collaboratorAgent.agentId === panel.agentId ? 'owner' : 'collaborator',
|
|
989
|
+
addedBy: `user:${user.id}`,
|
|
990
|
+
});
|
|
991
|
+
return formatPanelCollaboratorsForUser(user, panel);
|
|
992
|
+
});
|
|
993
|
+
app.delete('/api/panels/:id/collaborators/:collaboratorAgentId', async (req, reply) => {
|
|
994
|
+
const user = requireUser(req, reply, db);
|
|
995
|
+
if (!user)
|
|
996
|
+
return { error: 'Unauthorized' };
|
|
997
|
+
const panel = getPanelById(db, req.params.id);
|
|
998
|
+
if (!panel) {
|
|
999
|
+
reply.code(404);
|
|
1000
|
+
return { error: 'Not found' };
|
|
1001
|
+
}
|
|
1002
|
+
if (!(await canAccessPanel(user, panel))) {
|
|
1003
|
+
reply.code(403);
|
|
1004
|
+
return { error: 'Access denied' };
|
|
1005
|
+
}
|
|
1006
|
+
if (panel.scopeType === 'tool') {
|
|
1007
|
+
reply.code(409);
|
|
1008
|
+
return { error: 'Tool panel collaborators are managed through the workspace tool surface' };
|
|
1009
|
+
}
|
|
1010
|
+
if (!canManagePanelCollaboratorsForUser(user, panel)) {
|
|
1011
|
+
reply.code(panel.archivedAt ? 409 : 403);
|
|
1012
|
+
return { error: panel.archivedAt ? 'Panel is archived' : 'Access denied' };
|
|
1013
|
+
}
|
|
1014
|
+
ensurePanelSurfaceOwner(panel);
|
|
1015
|
+
const collaboratorAgentId = req.params.collaboratorAgentId.trim();
|
|
1016
|
+
const role = getSurfaceCollaboratorRole(db, 'panel', panel.id, collaboratorAgentId);
|
|
1017
|
+
if (!role) {
|
|
1018
|
+
reply.code(404);
|
|
1019
|
+
return { error: 'Collaborator not found' };
|
|
1020
|
+
}
|
|
1021
|
+
if (role === 'owner') {
|
|
1022
|
+
reply.code(400);
|
|
1023
|
+
return { error: 'Panel owner cannot be removed' };
|
|
1024
|
+
}
|
|
1025
|
+
removeSurfaceCollaborator(db, 'panel', panel.id, collaboratorAgentId);
|
|
1026
|
+
return formatPanelCollaboratorsForUser(user, panel);
|
|
1027
|
+
});
|
|
1028
|
+
app.delete('/api/panels/:id', async (req, reply) => {
|
|
1029
|
+
const user = requireUser(req, reply, db);
|
|
1030
|
+
if (!user)
|
|
1031
|
+
return { error: 'Unauthorized' };
|
|
1032
|
+
const panel = getPanelById(db, req.params.id);
|
|
1033
|
+
if (!panel) {
|
|
1034
|
+
reply.code(404);
|
|
1035
|
+
return { error: 'Not found' };
|
|
1036
|
+
}
|
|
1037
|
+
if (!canDeletePanelForUser(user, panel)) {
|
|
1038
|
+
reply.code(403);
|
|
1039
|
+
return { error: 'Access denied' };
|
|
1040
|
+
}
|
|
1041
|
+
const deleted = hardDeletePanelWithArtifacts(db, panel.id);
|
|
1042
|
+
if (!deleted) {
|
|
1043
|
+
reply.code(404);
|
|
1044
|
+
return { error: 'Not found' };
|
|
1045
|
+
}
|
|
1046
|
+
emitPanelDeleted(deleted);
|
|
1047
|
+
reply.code(204);
|
|
1048
|
+
return;
|
|
1049
|
+
});
|
|
1050
|
+
app.post('/api/panels/:id/promote-to-tool', async (req, reply) => {
|
|
1051
|
+
const user = requireUser(req, reply, db);
|
|
1052
|
+
if (!user)
|
|
1053
|
+
return { error: 'Unauthorized' };
|
|
1054
|
+
const panel = getPanelById(db, req.params.id);
|
|
1055
|
+
if (!panel) {
|
|
1056
|
+
reply.code(404);
|
|
1057
|
+
return { error: 'Not found' };
|
|
1058
|
+
}
|
|
1059
|
+
const promotionInfo = workspaceToolService.getPanelPromotionInfoForUser(user.id, panel.id);
|
|
1060
|
+
const canOpenArchivedLinkedTool = promotionInfo?.state === 'source_archived_linked_live_tool';
|
|
1061
|
+
if (!canOpenArchivedLinkedTool && !(await canAccessPanel(user, panel))) {
|
|
1062
|
+
reply.code(403);
|
|
1063
|
+
return { error: 'Access denied' };
|
|
1064
|
+
}
|
|
1065
|
+
try {
|
|
1066
|
+
return await workspaceToolService.promotePanelToTool({
|
|
1067
|
+
userId: user.id,
|
|
1068
|
+
panelId: panel.id,
|
|
1069
|
+
});
|
|
1070
|
+
}
|
|
1071
|
+
catch (error) {
|
|
1072
|
+
const statusCode = typeof error.statusCode === 'number'
|
|
1073
|
+
? error.statusCode
|
|
1074
|
+
: 500;
|
|
1075
|
+
reply.code(statusCode);
|
|
1076
|
+
return { error: String(error?.message ?? error) };
|
|
1077
|
+
}
|
|
1078
|
+
});
|
|
1079
|
+
app.get('/api/panels/:id/rows', async (req, reply) => {
|
|
1080
|
+
const user = requireUser(req, reply, db);
|
|
1081
|
+
if (!user)
|
|
1082
|
+
return { error: 'Unauthorized' };
|
|
1083
|
+
const panel = getPanelById(db, req.params.id);
|
|
1084
|
+
if (!panel) {
|
|
1085
|
+
reply.code(404);
|
|
1086
|
+
return { error: 'Not found' };
|
|
1087
|
+
}
|
|
1088
|
+
if (!(await canAccessPanel(user, panel))) {
|
|
1089
|
+
reply.code(403);
|
|
1090
|
+
return { error: 'Access denied' };
|
|
1091
|
+
}
|
|
1092
|
+
if (panel.archivedAt) {
|
|
1093
|
+
reply.code(409);
|
|
1094
|
+
return { error: 'Panel is archived' };
|
|
1095
|
+
}
|
|
1096
|
+
const contract = getComponentContract(panel.component);
|
|
1097
|
+
if (!contract) {
|
|
1098
|
+
reply.code(400);
|
|
1099
|
+
return { error: 'Unknown component contract' };
|
|
1100
|
+
}
|
|
1101
|
+
const effectiveDatasetSchema = getEffectiveDatasetSchema(contract, panel.props ?? {});
|
|
1102
|
+
const fieldByName = new Map(effectiveDatasetSchema?.fields.map((f) => [f.name, f]) ?? []);
|
|
1103
|
+
const declaredFields = new Set(effectiveDatasetSchema?.fields.map((f) => f.name) ?? []);
|
|
1104
|
+
const filterableFields = new Set(effectiveDatasetSchema?.fields.filter((f) => f.filterable).map((f) => f.name) ?? []);
|
|
1105
|
+
const sortableFields = new Set(effectiveDatasetSchema?.fields.filter((f) => f.sortable).map((f) => f.name) ?? []);
|
|
1106
|
+
const filter = parseFilter(req.query.filter);
|
|
1107
|
+
if (filter) {
|
|
1108
|
+
if (!declaredFields.has(filter.field)) {
|
|
1109
|
+
reply.code(400);
|
|
1110
|
+
return { error: `Filter field "${filter.field}" is not declared` };
|
|
1111
|
+
}
|
|
1112
|
+
if (!filterableFields.has(filter.field)) {
|
|
1113
|
+
reply.code(400);
|
|
1114
|
+
return { error: `Filter field "${filter.field}" is not filterable` };
|
|
1115
|
+
}
|
|
1116
|
+
}
|
|
1117
|
+
const sort = parseSort(req.query.sort);
|
|
1118
|
+
if (sort) {
|
|
1119
|
+
if (!declaredFields.has(sort.field)) {
|
|
1120
|
+
reply.code(400);
|
|
1121
|
+
return { error: `Sort field "${sort.field}" is not declared` };
|
|
1122
|
+
}
|
|
1123
|
+
if (!sortableFields.has(sort.field)) {
|
|
1124
|
+
reply.code(400);
|
|
1125
|
+
return { error: `Sort field "${sort.field}" is not sortable` };
|
|
1126
|
+
}
|
|
1127
|
+
}
|
|
1128
|
+
const limit = Number(req.query.limit ?? '50');
|
|
1129
|
+
let result;
|
|
1130
|
+
try {
|
|
1131
|
+
const options = {
|
|
1132
|
+
filterField: filter?.field,
|
|
1133
|
+
filterValue: filter?.value,
|
|
1134
|
+
sortField: sort?.field,
|
|
1135
|
+
sortDirection: sort?.direction,
|
|
1136
|
+
sortFieldType: sort ? fieldByName.get(sort.field)?.type : undefined,
|
|
1137
|
+
cursor: req.query.cursor ?? null,
|
|
1138
|
+
limit: Number.isFinite(limit) && limit > 0 ? limit : 50,
|
|
1139
|
+
};
|
|
1140
|
+
result = isPanelQueryHandleSource(panel)
|
|
1141
|
+
? await listPanelQueryHandleRows({
|
|
1142
|
+
db,
|
|
1143
|
+
panel,
|
|
1144
|
+
conversationManager,
|
|
1145
|
+
workspaceBroker,
|
|
1146
|
+
config: conversationManager.getConfig(),
|
|
1147
|
+
apiJsonlAllowedOrigins: loadPanelApiJsonlAllowedOrigins(db, conversationManager, panel.agentId),
|
|
1148
|
+
apiJsonlAllowedUrlPrefixes: loadPanelApiJsonlAllowedUrlPrefixes(db, conversationManager, panel.agentId),
|
|
1149
|
+
apiJsonlAuthProfiles: loadPanelApiJsonlAuthProfiles(db, conversationManager, panel.agentId),
|
|
1150
|
+
datasetSchema: effectiveDatasetSchema,
|
|
1151
|
+
options,
|
|
1152
|
+
})
|
|
1153
|
+
: listPanelRows(db, panel.id, options);
|
|
1154
|
+
}
|
|
1155
|
+
catch (error) {
|
|
1156
|
+
if (error instanceof PanelQueryHandleError) {
|
|
1157
|
+
reply.code(error.statusCode);
|
|
1158
|
+
return { error: error.message };
|
|
1159
|
+
}
|
|
1160
|
+
throw error;
|
|
1161
|
+
}
|
|
1162
|
+
return {
|
|
1163
|
+
rows: result.rows,
|
|
1164
|
+
nextCursor: result.nextCursor,
|
|
1165
|
+
};
|
|
1166
|
+
});
|
|
1167
|
+
app.get('/api/panels/:id/aggregate', async (req, reply) => {
|
|
1168
|
+
const user = requireUser(req, reply, db);
|
|
1169
|
+
if (!user)
|
|
1170
|
+
return { error: 'Unauthorized' };
|
|
1171
|
+
const panel = getPanelById(db, req.params.id);
|
|
1172
|
+
if (!panel) {
|
|
1173
|
+
reply.code(404);
|
|
1174
|
+
return { error: 'Not found' };
|
|
1175
|
+
}
|
|
1176
|
+
if (!(await canAccessPanel(user, panel))) {
|
|
1177
|
+
reply.code(403);
|
|
1178
|
+
return { error: 'Access denied' };
|
|
1179
|
+
}
|
|
1180
|
+
if (panel.archivedAt) {
|
|
1181
|
+
reply.code(409);
|
|
1182
|
+
return { error: 'Panel is archived' };
|
|
1183
|
+
}
|
|
1184
|
+
const contract = getComponentContract(panel.component);
|
|
1185
|
+
if (!contract) {
|
|
1186
|
+
reply.code(400);
|
|
1187
|
+
return { error: 'Unknown component contract' };
|
|
1188
|
+
}
|
|
1189
|
+
const op = parsePanelAggregateOp(req.query.op);
|
|
1190
|
+
if (!op) {
|
|
1191
|
+
reply.code(400);
|
|
1192
|
+
return { error: 'Aggregate op must be count, sum, avg, min, or max' };
|
|
1193
|
+
}
|
|
1194
|
+
const effectiveDatasetSchema = getEffectiveDatasetSchema(contract, panel.props ?? {});
|
|
1195
|
+
const declaredFields = new Set(effectiveDatasetSchema?.fields.map((f) => f.name) ?? []);
|
|
1196
|
+
const filterableFields = new Set(effectiveDatasetSchema?.fields.filter((f) => f.filterable).map((f) => f.name) ?? []);
|
|
1197
|
+
const field = typeof req.query.field === 'string' && req.query.field.trim()
|
|
1198
|
+
? req.query.field.trim()
|
|
1199
|
+
: undefined;
|
|
1200
|
+
if (field && !declaredFields.has(field)) {
|
|
1201
|
+
reply.code(400);
|
|
1202
|
+
return { error: `Aggregate field "${field}" is not declared` };
|
|
1203
|
+
}
|
|
1204
|
+
if (op !== 'count' && !field) {
|
|
1205
|
+
reply.code(400);
|
|
1206
|
+
return { error: `Aggregate field is required for ${op}` };
|
|
1207
|
+
}
|
|
1208
|
+
const filter = parseFilter(req.query.filter);
|
|
1209
|
+
if (filter) {
|
|
1210
|
+
if (!declaredFields.has(filter.field)) {
|
|
1211
|
+
reply.code(400);
|
|
1212
|
+
return { error: `Filter field "${filter.field}" is not declared` };
|
|
1213
|
+
}
|
|
1214
|
+
if (!filterableFields.has(filter.field)) {
|
|
1215
|
+
reply.code(400);
|
|
1216
|
+
return { error: `Filter field "${filter.field}" is not filterable` };
|
|
1217
|
+
}
|
|
1218
|
+
}
|
|
1219
|
+
try {
|
|
1220
|
+
const result = isPanelQueryHandleSource(panel)
|
|
1221
|
+
? await aggregatePanelQueryHandleRows({
|
|
1222
|
+
db,
|
|
1223
|
+
panel,
|
|
1224
|
+
conversationManager,
|
|
1225
|
+
workspaceBroker,
|
|
1226
|
+
config: conversationManager.getConfig(),
|
|
1227
|
+
apiJsonlAllowedOrigins: loadPanelApiJsonlAllowedOrigins(db, conversationManager, panel.agentId),
|
|
1228
|
+
apiJsonlAllowedUrlPrefixes: loadPanelApiJsonlAllowedUrlPrefixes(db, conversationManager, panel.agentId),
|
|
1229
|
+
apiJsonlAuthProfiles: loadPanelApiJsonlAuthProfiles(db, conversationManager, panel.agentId),
|
|
1230
|
+
datasetSchema: effectiveDatasetSchema,
|
|
1231
|
+
options: {
|
|
1232
|
+
op,
|
|
1233
|
+
field,
|
|
1234
|
+
filterField: filter?.field,
|
|
1235
|
+
filterValue: filter?.value,
|
|
1236
|
+
},
|
|
1237
|
+
})
|
|
1238
|
+
: aggregatePanelRows(db, panel.id, {
|
|
1239
|
+
op,
|
|
1240
|
+
field,
|
|
1241
|
+
filterField: filter?.field,
|
|
1242
|
+
filterValue: filter?.value,
|
|
1243
|
+
});
|
|
1244
|
+
return result;
|
|
1245
|
+
}
|
|
1246
|
+
catch (error) {
|
|
1247
|
+
if (error instanceof PanelQueryHandleError) {
|
|
1248
|
+
reply.code(error.statusCode);
|
|
1249
|
+
return { error: error.message };
|
|
1250
|
+
}
|
|
1251
|
+
throw error;
|
|
1252
|
+
}
|
|
1253
|
+
});
|
|
1254
|
+
app.get('/api/panels/:id/rows/:rowIndex/media/:slot', async (req, reply) => {
|
|
1255
|
+
const user = requireUser(req, reply, db);
|
|
1256
|
+
if (!user)
|
|
1257
|
+
return { error: 'Unauthorized' };
|
|
1258
|
+
const panel = getPanelById(db, req.params.id);
|
|
1259
|
+
if (!panel) {
|
|
1260
|
+
reply.code(404);
|
|
1261
|
+
return { error: 'Not found' };
|
|
1262
|
+
}
|
|
1263
|
+
if (panel.archivedAt) {
|
|
1264
|
+
reply.code(409);
|
|
1265
|
+
return { error: 'Panel is archived' };
|
|
1266
|
+
}
|
|
1267
|
+
if (!(await canAccessPanel(user, panel))) {
|
|
1268
|
+
reply.code(403);
|
|
1269
|
+
return { error: 'Access denied' };
|
|
1270
|
+
}
|
|
1271
|
+
const rowIndex = Number(req.params.rowIndex);
|
|
1272
|
+
if (!Number.isFinite(rowIndex) || rowIndex < 0 || !Number.isInteger(rowIndex)) {
|
|
1273
|
+
reply.code(404);
|
|
1274
|
+
return { error: 'Not found' };
|
|
1275
|
+
}
|
|
1276
|
+
const agent = conversationManager.getAgent(panel.agentId);
|
|
1277
|
+
let row = getPanelRowByIndex(db, panel.id, rowIndex);
|
|
1278
|
+
if (!row && isPanelQueryHandleSource(panel)) {
|
|
1279
|
+
const contract = getComponentContract(panel.component);
|
|
1280
|
+
if (!contract) {
|
|
1281
|
+
reply.code(400);
|
|
1282
|
+
return { error: 'Unknown component contract' };
|
|
1283
|
+
}
|
|
1284
|
+
try {
|
|
1285
|
+
const dynamicRows = await listPanelQueryHandleRowsByIndices({
|
|
1286
|
+
db,
|
|
1287
|
+
panel,
|
|
1288
|
+
conversationManager,
|
|
1289
|
+
workspaceBroker,
|
|
1290
|
+
config: conversationManager.getConfig(),
|
|
1291
|
+
apiJsonlAllowedOrigins: loadPanelApiJsonlAllowedOrigins(db, conversationManager, panel.agentId),
|
|
1292
|
+
apiJsonlAllowedUrlPrefixes: loadPanelApiJsonlAllowedUrlPrefixes(db, conversationManager, panel.agentId),
|
|
1293
|
+
apiJsonlAuthProfiles: loadPanelApiJsonlAuthProfiles(db, conversationManager, panel.agentId),
|
|
1294
|
+
datasetSchema: getEffectiveDatasetSchema(contract, panel.props ?? {}),
|
|
1295
|
+
rowIndices: [rowIndex],
|
|
1296
|
+
});
|
|
1297
|
+
const dynamicRow = dynamicRows.rows[0];
|
|
1298
|
+
row = dynamicRow
|
|
1299
|
+
? {
|
|
1300
|
+
rowIndex: dynamicRow.rowIndex,
|
|
1301
|
+
rowId: dynamicRow.rowId,
|
|
1302
|
+
fields: dynamicRow.fields,
|
|
1303
|
+
media: dynamicRow.media,
|
|
1304
|
+
nodeId: agent?.nodeId ?? null,
|
|
1305
|
+
cachedAssetIds: {},
|
|
1306
|
+
}
|
|
1307
|
+
: null;
|
|
1308
|
+
}
|
|
1309
|
+
catch (error) {
|
|
1310
|
+
if (error instanceof PanelQueryHandleError) {
|
|
1311
|
+
reply.code(error.statusCode);
|
|
1312
|
+
return { error: error.message };
|
|
1313
|
+
}
|
|
1314
|
+
throw error;
|
|
1315
|
+
}
|
|
1316
|
+
}
|
|
1317
|
+
if (!row) {
|
|
1318
|
+
reply.code(404);
|
|
1319
|
+
return { error: 'Not found' };
|
|
1320
|
+
}
|
|
1321
|
+
const mediaSlot = row.media[req.params.slot];
|
|
1322
|
+
if (!mediaSlot) {
|
|
1323
|
+
reply.code(404);
|
|
1324
|
+
return { error: 'Slot not found' };
|
|
1325
|
+
}
|
|
1326
|
+
const panelDeclaredDisplayType = getDeclaredPanelMediaDisplayType(panel, req.params.slot);
|
|
1327
|
+
let declaredDisplayType = panelDeclaredDisplayType ?? 'img';
|
|
1328
|
+
// Tool snapshots may serve owner/admin workspace fallbacks, so their media cache cannot be shared safely.
|
|
1329
|
+
const canUseMediaCache = panel.scopeType !== 'tool' && !isPanelQueryHandleSource(panel);
|
|
1330
|
+
const cachedAssetId = canUseMediaCache ? row.cachedAssetIds[req.params.slot] : undefined;
|
|
1331
|
+
if (canUseMediaCache && cachedAssetId) {
|
|
1332
|
+
const assetRow = loadAttachmentRow(db, cachedAssetId);
|
|
1333
|
+
if (assetRow && assetRow.storagePath && fs.existsSync(assetRow.storagePath)) {
|
|
1334
|
+
const buffer = fs.readFileSync(assetRow.storagePath);
|
|
1335
|
+
reply.header('Content-Type', assetRow.mimeType ?? 'application/octet-stream');
|
|
1336
|
+
reply.header('Content-Length', String(buffer.length));
|
|
1337
|
+
reply.header('Cache-Control', panelMediaCacheControl(panel.scopeType));
|
|
1338
|
+
reply.header('X-Content-Type-Options', 'nosniff');
|
|
1339
|
+
return reply.send(buffer);
|
|
1340
|
+
}
|
|
1341
|
+
// Stale cache entry; fall through to fetch
|
|
1342
|
+
}
|
|
1343
|
+
if (mediaSlot.kind === 'asset') {
|
|
1344
|
+
reply.code(404);
|
|
1345
|
+
return { error: 'Not found' };
|
|
1346
|
+
}
|
|
1347
|
+
if (mediaSlot.kind !== 'workspace_path') {
|
|
1348
|
+
reply.code(404);
|
|
1349
|
+
return { error: 'Not found' };
|
|
1350
|
+
}
|
|
1351
|
+
if (!row.nodeId) {
|
|
1352
|
+
reply.code(503);
|
|
1353
|
+
return { error: 'Agent node unavailable' };
|
|
1354
|
+
}
|
|
1355
|
+
let mediaPathCandidates = [{
|
|
1356
|
+
relativePath: mediaSlot.value,
|
|
1357
|
+
source: 'workspace_fallback',
|
|
1358
|
+
}];
|
|
1359
|
+
if (panel.scopeType === 'tool') {
|
|
1360
|
+
try {
|
|
1361
|
+
const resolvedTool = await workspaceToolService.getToolForViewerByPanelId(user.id, panel.id);
|
|
1362
|
+
declaredDisplayType = panelDeclaredDisplayType
|
|
1363
|
+
?? getWorkspaceToolManifestMediaDisplayType(resolvedTool, req.params.slot)
|
|
1364
|
+
?? 'img';
|
|
1365
|
+
if (path.isAbsolute(mediaSlot.value)) {
|
|
1366
|
+
mediaPathCandidates = [{
|
|
1367
|
+
relativePath: mediaSlot.value,
|
|
1368
|
+
source: 'workspace_fallback',
|
|
1369
|
+
}];
|
|
1370
|
+
}
|
|
1371
|
+
else {
|
|
1372
|
+
const toolLookup = await workspaceToolService.getToolMediaLookupForViewer(user.id, resolvedTool.toolId, {
|
|
1373
|
+
allowAdminBypass: true,
|
|
1374
|
+
});
|
|
1375
|
+
mediaPathCandidates = getWorkspaceToolMediaRelativePathCandidates({
|
|
1376
|
+
toolId: resolvedTool.toolId,
|
|
1377
|
+
revision: resolvedTool.revision,
|
|
1378
|
+
sourceBundleRoot: toolLookup.sourceBundleRoot,
|
|
1379
|
+
}, mediaSlot.value, {
|
|
1380
|
+
includeWorkspaceFallback: resolvedTool.isOwner || user.isAdmin,
|
|
1381
|
+
});
|
|
1382
|
+
}
|
|
1383
|
+
}
|
|
1384
|
+
catch (error) {
|
|
1385
|
+
if (!isWorkspaceToolVisibilityError(error)) {
|
|
1386
|
+
throw asToolPanelLookupError(error);
|
|
1387
|
+
}
|
|
1388
|
+
reply.code(403);
|
|
1389
|
+
return { error: 'Access denied' };
|
|
1390
|
+
}
|
|
1391
|
+
}
|
|
1392
|
+
try {
|
|
1393
|
+
let lastCandidateError = null;
|
|
1394
|
+
for (const candidatePath of mediaPathCandidates) {
|
|
1395
|
+
const resolvedMediaPath = resolvePanelMediaPath(agent?.workspacePath, candidatePath.relativePath, conversationManager.getConfig(), { allowedRelativeRoot: candidatePath.allowedRelativeRoot });
|
|
1396
|
+
if (!resolvedMediaPath.ok) {
|
|
1397
|
+
lastCandidateError = {
|
|
1398
|
+
statusCode: resolvedMediaPath.statusCode,
|
|
1399
|
+
error: resolvedMediaPath.error,
|
|
1400
|
+
};
|
|
1401
|
+
continue;
|
|
1402
|
+
}
|
|
1403
|
+
const { absolutePath, allowedRoot } = resolvedMediaPath;
|
|
1404
|
+
try {
|
|
1405
|
+
const result = await workspaceBroker.streamExactPath(row.nodeId, absolutePath, { allowedRoot });
|
|
1406
|
+
if (!panelMediaMimeMatchesDisplayType(result.mimeType, declaredDisplayType)) {
|
|
1407
|
+
lastCandidateError = {
|
|
1408
|
+
statusCode: 400,
|
|
1409
|
+
error: declaredDisplayType === 'img'
|
|
1410
|
+
? 'Non-image media not supported'
|
|
1411
|
+
: `Media mime type does not match declared displayType ${declaredDisplayType}`,
|
|
1412
|
+
};
|
|
1413
|
+
continue;
|
|
1414
|
+
}
|
|
1415
|
+
const mediaLimitBytes = panelMediaDisplayTypeLimitBytes(declaredDisplayType);
|
|
1416
|
+
if (result.size > mediaLimitBytes) {
|
|
1417
|
+
lastCandidateError = panelMediaSizeCapExceededError(declaredDisplayType);
|
|
1418
|
+
continue;
|
|
1419
|
+
}
|
|
1420
|
+
const chunks = [];
|
|
1421
|
+
for await (const chunk of result.stream) {
|
|
1422
|
+
chunks.push(Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk));
|
|
1423
|
+
}
|
|
1424
|
+
const buffer = Buffer.concat(chunks);
|
|
1425
|
+
if (!canUseMediaCache) {
|
|
1426
|
+
reply.header('Content-Type', result.mimeType);
|
|
1427
|
+
reply.header('Content-Length', String(buffer.length));
|
|
1428
|
+
reply.header('Cache-Control', uncachedPanelMediaCacheControl());
|
|
1429
|
+
reply.header('X-Content-Type-Options', 'nosniff');
|
|
1430
|
+
return reply.send(buffer);
|
|
1431
|
+
}
|
|
1432
|
+
const assetId = randomUUID();
|
|
1433
|
+
const filename = path.basename(absolutePath) || panelMediaDefaultFilename(declaredDisplayType);
|
|
1434
|
+
const sanitizedFilename = sanitizeAssetFilename(filename);
|
|
1435
|
+
const storagePath = buildCanonicalAssetStoragePath({
|
|
1436
|
+
assetsRoot: assetStoreRoot,
|
|
1437
|
+
assetId,
|
|
1438
|
+
filename: sanitizedFilename,
|
|
1439
|
+
scopeType: 'agent_upload',
|
|
1440
|
+
scopeId: panel.id,
|
|
1441
|
+
});
|
|
1442
|
+
fs.mkdirSync(path.dirname(storagePath), { recursive: true });
|
|
1443
|
+
fs.writeFileSync(storagePath, buffer);
|
|
1444
|
+
db.prepare(`INSERT INTO attachments(
|
|
1445
|
+
id, filename, original_filename, mime_type, size_bytes, storage_path,
|
|
1446
|
+
channel_id, agent_id, user_id, scope_type, scope_id, kind, materialization_policy, created_at
|
|
1447
|
+
)
|
|
1448
|
+
VALUES(?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, 'workspace_cache', ?)`).run(assetId, sanitizedFilename, filename, result.mimeType, buffer.length, storagePath, null, panel.agentId, null, 'agent_upload', panel.id, declaredDisplayType === 'img' ? 'image' : 'binary', Date.now());
|
|
1449
|
+
const cached = updatePanelRowCachedAssetIdIfCurrent(db, {
|
|
1450
|
+
panelId: panel.id,
|
|
1451
|
+
panelVersion: panel.version,
|
|
1452
|
+
rowIndex,
|
|
1453
|
+
rowId: row.rowId,
|
|
1454
|
+
nodeId: row.nodeId,
|
|
1455
|
+
slot: req.params.slot,
|
|
1456
|
+
expectedCachedAssetId: cachedAssetId ?? null,
|
|
1457
|
+
mediaSlot,
|
|
1458
|
+
assetId,
|
|
1459
|
+
});
|
|
1460
|
+
if (!cached) {
|
|
1461
|
+
deletePanelAttachmentArtifacts(db, {
|
|
1462
|
+
panelId: panel.id,
|
|
1463
|
+
attachmentIds: [assetId],
|
|
1464
|
+
});
|
|
1465
|
+
reply.code(409);
|
|
1466
|
+
return { error: 'Panel row changed; retry media request' };
|
|
1467
|
+
}
|
|
1468
|
+
reply.header('Content-Type', result.mimeType);
|
|
1469
|
+
reply.header('Content-Length', String(buffer.length));
|
|
1470
|
+
reply.header('Cache-Control', panelMediaCacheControl(panel.scopeType));
|
|
1471
|
+
reply.header('X-Content-Type-Options', 'nosniff');
|
|
1472
|
+
return reply.send(buffer);
|
|
1473
|
+
}
|
|
1474
|
+
catch (error) {
|
|
1475
|
+
const message = String(error?.message ?? error);
|
|
1476
|
+
if (message === 'Agent node is offline.'
|
|
1477
|
+
|| message.startsWith('Agent node disconnected:')
|
|
1478
|
+
|| message.includes('request timed out.')) {
|
|
1479
|
+
throw error;
|
|
1480
|
+
}
|
|
1481
|
+
lastCandidateError = { statusCode: 400, error: message };
|
|
1482
|
+
}
|
|
1483
|
+
}
|
|
1484
|
+
reply.code(lastCandidateError?.statusCode ?? 404);
|
|
1485
|
+
return { error: lastCandidateError?.error ?? 'Not found' };
|
|
1486
|
+
}
|
|
1487
|
+
catch (error) {
|
|
1488
|
+
const message = String(error?.message ?? error);
|
|
1489
|
+
if (message === 'Agent node is offline.' || message.startsWith('Agent node disconnected:')) {
|
|
1490
|
+
reply.code(503);
|
|
1491
|
+
return { error: 'Agent node is offline' };
|
|
1492
|
+
}
|
|
1493
|
+
if (message.includes('request timed out.')) {
|
|
1494
|
+
reply.code(504);
|
|
1495
|
+
return { error: 'Workspace stream request timed out' };
|
|
1496
|
+
}
|
|
1497
|
+
if (message.startsWith('binary_file:')
|
|
1498
|
+
|| message.startsWith('file_too_large:')
|
|
1499
|
+
|| message.startsWith('not_file:')
|
|
1500
|
+
|| message.startsWith('not_found:')) {
|
|
1501
|
+
reply.code(400);
|
|
1502
|
+
return { error: message };
|
|
1503
|
+
}
|
|
1504
|
+
reply.code(502);
|
|
1505
|
+
return { error: message };
|
|
1506
|
+
}
|
|
1507
|
+
});
|
|
1508
|
+
app.get('/api/panels/:id/state', async (req, reply) => {
|
|
1509
|
+
const user = requireUser(req, reply, db);
|
|
1510
|
+
if (!user)
|
|
1511
|
+
return { error: 'Unauthorized' };
|
|
1512
|
+
const panel = getPanelById(db, req.params.id);
|
|
1513
|
+
if (!panel) {
|
|
1514
|
+
reply.code(404);
|
|
1515
|
+
return { error: 'Not found' };
|
|
1516
|
+
}
|
|
1517
|
+
if (panel.archivedAt) {
|
|
1518
|
+
reply.code(409);
|
|
1519
|
+
return { error: 'Panel is archived' };
|
|
1520
|
+
}
|
|
1521
|
+
if (!(await canAccessPanel(user, panel))) {
|
|
1522
|
+
reply.code(403);
|
|
1523
|
+
return { error: 'Access denied' };
|
|
1524
|
+
}
|
|
1525
|
+
const defaultState = { filters: {}, sort: {}, selection: [] };
|
|
1526
|
+
// For non-direct scopes, return shared + per-user envelope
|
|
1527
|
+
if (panel.scopeType === 'channel' || panel.scopeType === 'thread') {
|
|
1528
|
+
const perUser = normalizeStateSelectionForPanel(panel, getPanelState(db, panel.id, user.id)?.state ?? defaultState);
|
|
1529
|
+
const shared = getSharedPanelState(db, panel.id);
|
|
1530
|
+
return {
|
|
1531
|
+
perUser,
|
|
1532
|
+
shared: normalizeStateSelectionForPanel(panel, shared?.state ?? { selection: [] }),
|
|
1533
|
+
version: shared?.version ?? 0,
|
|
1534
|
+
};
|
|
1535
|
+
}
|
|
1536
|
+
const state = getPanelState(db, panel.id, user.id);
|
|
1537
|
+
return {
|
|
1538
|
+
state: normalizeStateSelectionForPanel(panel, state?.state ?? defaultState),
|
|
1539
|
+
};
|
|
1540
|
+
});
|
|
1541
|
+
app.put('/api/panels/:id/state', async (req, reply) => {
|
|
1542
|
+
const user = requireUser(req, reply, db);
|
|
1543
|
+
if (!user)
|
|
1544
|
+
return { error: 'Unauthorized' };
|
|
1545
|
+
const panel = getPanelById(db, req.params.id);
|
|
1546
|
+
if (!panel) {
|
|
1547
|
+
reply.code(404);
|
|
1548
|
+
return { error: 'Not found' };
|
|
1549
|
+
}
|
|
1550
|
+
if (panel.archivedAt) {
|
|
1551
|
+
reply.code(409);
|
|
1552
|
+
return { error: 'Panel is archived' };
|
|
1553
|
+
}
|
|
1554
|
+
if (!(await canAccessPanel(user, panel))) {
|
|
1555
|
+
reply.code(403);
|
|
1556
|
+
return { error: 'Access denied' };
|
|
1557
|
+
}
|
|
1558
|
+
let latestVersion;
|
|
1559
|
+
if (panel.scopeType === 'direct') {
|
|
1560
|
+
latestVersion = panel.version;
|
|
1561
|
+
}
|
|
1562
|
+
else {
|
|
1563
|
+
latestVersion = getSharedPanelState(db, panel.id)?.version ?? panel.version;
|
|
1564
|
+
}
|
|
1565
|
+
const submittedBaseVersion = Number.isFinite(req.body?.baseVersion)
|
|
1566
|
+
? Number(req.body.baseVersion)
|
|
1567
|
+
: latestVersion;
|
|
1568
|
+
if (submittedBaseVersion < latestVersion) {
|
|
1569
|
+
reply.code(409);
|
|
1570
|
+
return {
|
|
1571
|
+
error: 'Stale base version',
|
|
1572
|
+
staleBase: true,
|
|
1573
|
+
latestVersion,
|
|
1574
|
+
};
|
|
1575
|
+
}
|
|
1576
|
+
const contract = getComponentContract(panel.component);
|
|
1577
|
+
const stateBlob = req.body?.state;
|
|
1578
|
+
const errors = validateStateBlob(contract?.stateSchema, stateBlob);
|
|
1579
|
+
if (errors.length > 0) {
|
|
1580
|
+
reply.code(400);
|
|
1581
|
+
return { error: 'Invalid state', details: errors };
|
|
1582
|
+
}
|
|
1583
|
+
const stateRecord = { ...stateBlob };
|
|
1584
|
+
if (Array.isArray(stateRecord.selection)) {
|
|
1585
|
+
const validatedSelection = validateSelectionIndices(panel, stateRecord.selection);
|
|
1586
|
+
if (!validatedSelection.ok) {
|
|
1587
|
+
reply.code(400);
|
|
1588
|
+
return { error: validatedSelection.error };
|
|
1589
|
+
}
|
|
1590
|
+
stateRecord.selection = validatedSelection.selection;
|
|
1591
|
+
}
|
|
1592
|
+
upsertPanelState(db, panel.id, user.id, stateRecord);
|
|
1593
|
+
const formValues = stateRecord.formValues;
|
|
1594
|
+
const formValueKeys = formValues && typeof formValues === 'object' && !Array.isArray(formValues)
|
|
1595
|
+
? Object.keys(formValues)
|
|
1596
|
+
: [];
|
|
1597
|
+
const annotationFields = collectAnnotationFields(panel);
|
|
1598
|
+
const hasAnnotationValues = formValues && typeof formValues === 'object' && !Array.isArray(formValues)
|
|
1599
|
+
? Object.entries(formValues).some(([key, value]) => (containsPanelAnnotations(value) || (Array.isArray(value) && value.length === 0 && isAnnotationFormKey(key, annotationFields))))
|
|
1600
|
+
: false;
|
|
1601
|
+
insertPanelAuditEvent(db, {
|
|
1602
|
+
panelId: panel.id,
|
|
1603
|
+
eventType: hasAnnotationValues ? 'annotation_saved' : 'state_saved',
|
|
1604
|
+
version: panel.version,
|
|
1605
|
+
actorType: 'user',
|
|
1606
|
+
actorId: user.id,
|
|
1607
|
+
conversationId: panel.conversationId,
|
|
1608
|
+
scopeType: panel.scopeType,
|
|
1609
|
+
scopeId: panel.scopeId ?? null,
|
|
1610
|
+
metadata: {
|
|
1611
|
+
stateKeys: Object.keys(stateRecord),
|
|
1612
|
+
...(formValueKeys.length > 0 ? { formValueKeyCount: formValueKeys.length } : {}),
|
|
1613
|
+
},
|
|
1614
|
+
});
|
|
1615
|
+
return { ok: true, version: latestVersion };
|
|
1616
|
+
});
|
|
1617
|
+
app.patch('/api/panels/:id/shared-state', async (req, reply) => {
|
|
1618
|
+
const user = requireUser(req, reply, db);
|
|
1619
|
+
if (!user)
|
|
1620
|
+
return { error: 'Unauthorized' };
|
|
1621
|
+
const panel = getPanelById(db, req.params.id);
|
|
1622
|
+
if (!panel) {
|
|
1623
|
+
reply.code(404);
|
|
1624
|
+
return { error: 'Not found' };
|
|
1625
|
+
}
|
|
1626
|
+
if (panel.archivedAt) {
|
|
1627
|
+
reply.code(409);
|
|
1628
|
+
return { error: 'Panel is archived' };
|
|
1629
|
+
}
|
|
1630
|
+
if (!(await canAccessPanel(user, panel))) {
|
|
1631
|
+
reply.code(403);
|
|
1632
|
+
return { error: 'Access denied' };
|
|
1633
|
+
}
|
|
1634
|
+
// Shared state only applies to non-direct panels
|
|
1635
|
+
if (panel.scopeType === 'direct' || panel.scopeType === 'tool') {
|
|
1636
|
+
reply.code(400);
|
|
1637
|
+
return { error: 'Shared state is not available for direct panels' };
|
|
1638
|
+
}
|
|
1639
|
+
const rawSelection = Array.isArray(req.body?.selection) ? req.body.selection : [];
|
|
1640
|
+
const validatedSelection = validateSelectionIndices(panel, rawSelection);
|
|
1641
|
+
if (!validatedSelection.ok) {
|
|
1642
|
+
reply.code(400);
|
|
1643
|
+
return { error: validatedSelection.error };
|
|
1644
|
+
}
|
|
1645
|
+
const currentShared = getSharedPanelState(db, panel.id);
|
|
1646
|
+
const baseSelection = normalizeStoredSelectionForPanel(panel, currentShared?.state?.selection);
|
|
1647
|
+
const result = applySharedSelectionPatch(db, panel.id, { selection: validatedSelection.selection, baseSelection }, user.id);
|
|
1648
|
+
const committedSelection = Array.isArray(result.state.selection) ? result.state.selection : [];
|
|
1649
|
+
const committedRowIds = panelRowIdsForIndices(db, panel.id, committedSelection);
|
|
1650
|
+
insertPanelAuditEvent(db, {
|
|
1651
|
+
panelId: panel.id,
|
|
1652
|
+
eventType: 'selection_committed',
|
|
1653
|
+
version: panel.version,
|
|
1654
|
+
actorType: 'user',
|
|
1655
|
+
actorId: user.id,
|
|
1656
|
+
conversationId: panel.conversationId,
|
|
1657
|
+
scopeType: panel.scopeType,
|
|
1658
|
+
scopeId: panel.scopeId ?? null,
|
|
1659
|
+
metadata: {
|
|
1660
|
+
selectedCount: committedSelection.length,
|
|
1661
|
+
...(committedSelection.length > 0 ? { selectedRowIndices: committedSelection } : undefined),
|
|
1662
|
+
...(committedRowIds && committedRowIds.length > 0 ? { selectedRowIds: committedRowIds } : undefined),
|
|
1663
|
+
sharedVersion: result.version,
|
|
1664
|
+
},
|
|
1665
|
+
});
|
|
1666
|
+
// Broadcast shared state update
|
|
1667
|
+
const conv = conversationManager.getConversation(panel.conversationId);
|
|
1668
|
+
if (conv?.channelId && broadcastToChannel) {
|
|
1669
|
+
const sharedStateEvent = {
|
|
1670
|
+
type: 'panel.shared_state_updated',
|
|
1671
|
+
panelId: panel.id,
|
|
1672
|
+
shared: result.state,
|
|
1673
|
+
version: result.version,
|
|
1674
|
+
updatedByUserId: user.id,
|
|
1675
|
+
updatedAt: Date.now(),
|
|
1676
|
+
};
|
|
1677
|
+
if (panel.scopeType === 'thread' && conv.threadRootId) {
|
|
1678
|
+
sharedStateEvent.threadRootId = conv.threadRootId;
|
|
1679
|
+
}
|
|
1680
|
+
broadcastToChannel(conv.channelId, sharedStateEvent);
|
|
1681
|
+
}
|
|
1682
|
+
return { ok: true, shared: result.state, version: result.version };
|
|
1683
|
+
});
|
|
1684
|
+
app.post('/api/panels/:id/actions/:actionId', async (req, reply) => {
|
|
1685
|
+
const user = requireUser(req, reply, db);
|
|
1686
|
+
if (!user)
|
|
1687
|
+
return { error: 'Unauthorized' };
|
|
1688
|
+
const panel = getPanelById(db, req.params.id);
|
|
1689
|
+
if (!panel) {
|
|
1690
|
+
reply.code(404);
|
|
1691
|
+
return { error: 'Not found' };
|
|
1692
|
+
}
|
|
1693
|
+
if (panel.archivedAt) {
|
|
1694
|
+
reply.code(409);
|
|
1695
|
+
return { error: 'Panel is archived' };
|
|
1696
|
+
}
|
|
1697
|
+
const action = (panel.actions ?? []).find((item) => item.id === req.params.actionId);
|
|
1698
|
+
if (!action) {
|
|
1699
|
+
reply.code(400);
|
|
1700
|
+
return { error: 'Action not declared for this panel' };
|
|
1701
|
+
}
|
|
1702
|
+
if (panel.scopeType === 'tool') {
|
|
1703
|
+
let resolvedTool;
|
|
1704
|
+
try {
|
|
1705
|
+
resolvedTool = await resolveToolProjectionForViewer(user, panel.id);
|
|
1706
|
+
}
|
|
1707
|
+
catch (error) {
|
|
1708
|
+
if (!isWorkspaceToolVisibilityError(error)) {
|
|
1709
|
+
throw error;
|
|
1710
|
+
}
|
|
1711
|
+
reply.code(403);
|
|
1712
|
+
return { error: 'Access denied' };
|
|
1713
|
+
}
|
|
1714
|
+
const panelContext = { panel };
|
|
1715
|
+
try {
|
|
1716
|
+
const result = await workspaceToolService.invokeAction({
|
|
1717
|
+
userId: user.id,
|
|
1718
|
+
toolId: resolvedTool.toolId,
|
|
1719
|
+
actionId: req.params.actionId,
|
|
1720
|
+
inputParams: req.body?.params,
|
|
1721
|
+
allowAdminBypass: panelContext != null,
|
|
1722
|
+
});
|
|
1723
|
+
const auditedPanel = getPanelById(db, panel.id) ?? panel;
|
|
1724
|
+
emitPanelActionTriggered(auditedPanel.id, auditedPanel.conversationId, auditedPanel.scopeType, user.id, 'action', req.params.actionId);
|
|
1725
|
+
insertPanelAuditEvent(db, {
|
|
1726
|
+
panelId: auditedPanel.id,
|
|
1727
|
+
eventType: 'actioned',
|
|
1728
|
+
version: auditedPanel.version,
|
|
1729
|
+
submitKind: 'action',
|
|
1730
|
+
actionId: req.params.actionId,
|
|
1731
|
+
actorType: 'user',
|
|
1732
|
+
actorId: user.id,
|
|
1733
|
+
runId: result.mode === 'platform_exec' ? result.run.runId : (result.runId ?? null),
|
|
1734
|
+
conversationId: auditedPanel.conversationId,
|
|
1735
|
+
scopeType: auditedPanel.scopeType,
|
|
1736
|
+
scopeId: resolvedTool.toolId,
|
|
1737
|
+
metadata: {
|
|
1738
|
+
delegatedToToolAction: true,
|
|
1739
|
+
toolActionMode: result.mode,
|
|
1740
|
+
actionId: req.params.actionId,
|
|
1741
|
+
toolId: resolvedTool.toolId,
|
|
1742
|
+
...(result.mode === 'platform_exec'
|
|
1743
|
+
? {
|
|
1744
|
+
toolRunId: result.run.runId,
|
|
1745
|
+
toolRunStatus: result.run.status,
|
|
1746
|
+
}
|
|
1747
|
+
: {
|
|
1748
|
+
notifiedConversationId: result.conversationId,
|
|
1749
|
+
notifiedRunId: result.runId ?? null,
|
|
1750
|
+
queued: result.queued,
|
|
1751
|
+
}),
|
|
1752
|
+
},
|
|
1753
|
+
});
|
|
1754
|
+
return {
|
|
1755
|
+
ok: true,
|
|
1756
|
+
mode: result.mode,
|
|
1757
|
+
queued: result.mode === 'notify_agent' ? result.queued : false,
|
|
1758
|
+
outputText: result.outputText ?? null,
|
|
1759
|
+
latestState: result.latestState ?? null,
|
|
1760
|
+
runId: result.mode === 'platform_exec' ? result.run.runId : result.runId,
|
|
1761
|
+
tool: result.tool,
|
|
1762
|
+
};
|
|
1763
|
+
}
|
|
1764
|
+
catch (error) {
|
|
1765
|
+
const statusCode = typeof error.statusCode === 'number'
|
|
1766
|
+
? error.statusCode
|
|
1767
|
+
: 500;
|
|
1768
|
+
const failedRunId = typeof error.runId === 'string'
|
|
1769
|
+
? error.runId
|
|
1770
|
+
: null;
|
|
1771
|
+
const auditedPanel = getPanelById(db, panel.id) ?? panel;
|
|
1772
|
+
insertPanelAuditEvent(db, {
|
|
1773
|
+
panelId: auditedPanel.id,
|
|
1774
|
+
eventType: 'failed',
|
|
1775
|
+
version: auditedPanel.version,
|
|
1776
|
+
submitKind: 'action',
|
|
1777
|
+
actionId: req.params.actionId,
|
|
1778
|
+
failureClass: 'tool_panel_action_failed',
|
|
1779
|
+
actorType: 'user',
|
|
1780
|
+
actorId: user.id,
|
|
1781
|
+
runId: failedRunId,
|
|
1782
|
+
conversationId: auditedPanel.conversationId,
|
|
1783
|
+
scopeType: auditedPanel.scopeType,
|
|
1784
|
+
scopeId: resolvedTool.toolId,
|
|
1785
|
+
metadata: {
|
|
1786
|
+
delegatedToToolAction: true,
|
|
1787
|
+
actionId: req.params.actionId,
|
|
1788
|
+
toolId: resolvedTool.toolId,
|
|
1789
|
+
toolRunId: failedRunId,
|
|
1790
|
+
statusCode,
|
|
1791
|
+
error: String(error?.message ?? error),
|
|
1792
|
+
},
|
|
1793
|
+
});
|
|
1794
|
+
reply.code(statusCode);
|
|
1795
|
+
return { error: String(error?.message ?? error) };
|
|
1796
|
+
}
|
|
1797
|
+
}
|
|
1798
|
+
const actionCardId = typeof panel.props?.actionCardId === 'string'
|
|
1799
|
+
? panel.props.actionCardId.trim()
|
|
1800
|
+
: '';
|
|
1801
|
+
if (actionCardId && (req.params.actionId === 'confirm' || req.params.actionId === 'cancel')) {
|
|
1802
|
+
if (!canUserActOnActionCardPanel(db, user, actionCardId)) {
|
|
1803
|
+
reply.code(403);
|
|
1804
|
+
return { error: 'Action card access required' };
|
|
1805
|
+
}
|
|
1806
|
+
try {
|
|
1807
|
+
const result = req.params.actionId === 'confirm'
|
|
1808
|
+
? confirmActionCard(db, {
|
|
1809
|
+
id: actionCardId,
|
|
1810
|
+
confirmedBy: user.id,
|
|
1811
|
+
confirmedByName: user.username,
|
|
1812
|
+
conversationManager,
|
|
1813
|
+
broadcastToChannel,
|
|
1814
|
+
})
|
|
1815
|
+
: cancelActionCard(db, {
|
|
1816
|
+
id: actionCardId,
|
|
1817
|
+
cancelledBy: user.id,
|
|
1818
|
+
cancelledByName: user.username,
|
|
1819
|
+
broadcastToChannel,
|
|
1820
|
+
});
|
|
1821
|
+
emitPanelActionTriggered(panel.id, panel.conversationId, panel.scopeType, user.id, 'action', req.params.actionId);
|
|
1822
|
+
insertPanelAuditEvent(db, {
|
|
1823
|
+
panelId: panel.id,
|
|
1824
|
+
eventType: 'actioned',
|
|
1825
|
+
version: panel.version,
|
|
1826
|
+
submitKind: 'action',
|
|
1827
|
+
actionId: req.params.actionId,
|
|
1828
|
+
actorType: 'user',
|
|
1829
|
+
actorId: user.id,
|
|
1830
|
+
conversationId: panel.conversationId,
|
|
1831
|
+
scopeType: panel.scopeType,
|
|
1832
|
+
scopeId: panel.scopeId ?? null,
|
|
1833
|
+
metadata: {
|
|
1834
|
+
actionCardId,
|
|
1835
|
+
actionCardAction: req.params.actionId,
|
|
1836
|
+
},
|
|
1837
|
+
});
|
|
1838
|
+
return {
|
|
1839
|
+
ok: true,
|
|
1840
|
+
mode: 'action_card',
|
|
1841
|
+
...result,
|
|
1842
|
+
};
|
|
1843
|
+
}
|
|
1844
|
+
catch (error) {
|
|
1845
|
+
const statusCode = error instanceof ActionCardError ? error.statusCode : 500;
|
|
1846
|
+
reply.code(statusCode);
|
|
1847
|
+
return {
|
|
1848
|
+
error: String(error?.message ?? error),
|
|
1849
|
+
...(error instanceof ActionCardError && error.errorCode
|
|
1850
|
+
? { error_code: error.errorCode, errorCode: error.errorCode }
|
|
1851
|
+
: {}),
|
|
1852
|
+
};
|
|
1853
|
+
}
|
|
1854
|
+
}
|
|
1855
|
+
const actionMode = resolvePanelActionMode(action);
|
|
1856
|
+
if (actionMode === 'notify_agent' && !(await canAccessPanel(user, panel))) {
|
|
1857
|
+
reply.code(403);
|
|
1858
|
+
return { error: 'Access denied' };
|
|
1859
|
+
}
|
|
1860
|
+
if (actionMode === 'notify_agent') {
|
|
1861
|
+
const result = await submitPanel({
|
|
1862
|
+
panelId: req.params.id,
|
|
1863
|
+
user,
|
|
1864
|
+
reply,
|
|
1865
|
+
submitKind: 'action',
|
|
1866
|
+
actionId: req.params.actionId,
|
|
1867
|
+
baseVersion: req.body?.baseVersion,
|
|
1868
|
+
});
|
|
1869
|
+
return 'error' in result ? result : { ...result, mode: 'notify_agent' };
|
|
1870
|
+
}
|
|
1871
|
+
const actionCommand = resolvePanelActionCommand(action);
|
|
1872
|
+
if (!actionCommand) {
|
|
1873
|
+
reply.code(400);
|
|
1874
|
+
return { error: 'platform_exec panel actions require command' };
|
|
1875
|
+
}
|
|
1876
|
+
const platformExecAccess = canRunPanelPlatformExec(user, panel);
|
|
1877
|
+
if (!platformExecAccess.ok) {
|
|
1878
|
+
reply.code(platformExecAccess.statusCode);
|
|
1879
|
+
return { error: platformExecAccess.error };
|
|
1880
|
+
}
|
|
1881
|
+
const conv = conversationManager.getConversation(panel.conversationId);
|
|
1882
|
+
if (!conv) {
|
|
1883
|
+
reply.code(409);
|
|
1884
|
+
return { error: 'Panel conversation is unavailable' };
|
|
1885
|
+
}
|
|
1886
|
+
const auditChannelId = conv.threadKind === 'direct' ? `dm:${panel.agentId}` : conv.channelId;
|
|
1887
|
+
if (!auditChannelId) {
|
|
1888
|
+
reply.code(409);
|
|
1889
|
+
return { error: 'Panel channel is unavailable' };
|
|
1890
|
+
}
|
|
1891
|
+
const agent = conversationManager.getAgent(panel.agentId);
|
|
1892
|
+
if (!agent?.nodeId || !agent.workspacePath) {
|
|
1893
|
+
reply.code(503);
|
|
1894
|
+
return { error: 'Agent node unavailable' };
|
|
1895
|
+
}
|
|
1896
|
+
const resolvedCwd = resolvePanelActionExecutionCwd(agent.workspacePath, resolvePanelActionDeclaredCwd(action));
|
|
1897
|
+
if (!resolvedCwd.ok) {
|
|
1898
|
+
reply.code(400);
|
|
1899
|
+
return { error: resolvedCwd.error };
|
|
1900
|
+
}
|
|
1901
|
+
emitPanelActionTriggered(panel.id, panel.conversationId, panel.scopeType, user.id, 'action', action.id);
|
|
1902
|
+
let terminalId = null;
|
|
1903
|
+
try {
|
|
1904
|
+
const terminal = await terminalBroker.createTerminal(agent.nodeId, {
|
|
1905
|
+
workspaceRoot: agent.workspacePath,
|
|
1906
|
+
cwd: resolvedCwd.cwd,
|
|
1907
|
+
name: `panel-action:${action.id}`,
|
|
1908
|
+
startupCommand: actionCommand,
|
|
1909
|
+
closeOnStartupCommand: true,
|
|
1910
|
+
});
|
|
1911
|
+
terminalId = terminal.terminalId;
|
|
1912
|
+
const snapshot = await waitForPanelActionTerminalExit(agent.nodeId, terminal.terminalId);
|
|
1913
|
+
const signal = snapshot.terminal.signal && snapshot.terminal.signal !== '0'
|
|
1914
|
+
? snapshot.terminal.signal
|
|
1915
|
+
: null;
|
|
1916
|
+
const exitCode = snapshot.terminal.exitCode ?? (snapshot.terminal.exited && !signal ? 0 : null);
|
|
1917
|
+
const status = signal || exitCode !== 0 ? 'failed' : 'completed';
|
|
1918
|
+
insertPanelPlatformExecMessage(db, {
|
|
1919
|
+
channelId: auditChannelId,
|
|
1920
|
+
target: conv.replyTarget ?? `dm:${panel.agentId}`,
|
|
1921
|
+
panelId: panel.id,
|
|
1922
|
+
component: panel.component,
|
|
1923
|
+
actionId: action.id,
|
|
1924
|
+
actionLabel: action.label,
|
|
1925
|
+
actorUserId: user.id,
|
|
1926
|
+
actorUsername: user.username,
|
|
1927
|
+
status,
|
|
1928
|
+
exitCode,
|
|
1929
|
+
signal,
|
|
1930
|
+
outputText: snapshot.buffer,
|
|
1931
|
+
threadRootId: conv.threadRootId ?? null,
|
|
1932
|
+
});
|
|
1933
|
+
if (signal || exitCode !== 0) {
|
|
1934
|
+
insertPanelAuditEvent(db, {
|
|
1935
|
+
panelId: panel.id,
|
|
1936
|
+
eventType: 'failed',
|
|
1937
|
+
version: panel.version,
|
|
1938
|
+
submitKind: 'action',
|
|
1939
|
+
actionId: action.id,
|
|
1940
|
+
failureClass: 'panel_action_rpc_failed',
|
|
1941
|
+
actorType: 'user',
|
|
1942
|
+
actorId: user.id,
|
|
1943
|
+
runId: null,
|
|
1944
|
+
conversationId: panel.conversationId,
|
|
1945
|
+
scopeType: panel.scopeType,
|
|
1946
|
+
scopeId: panel.scopeId ?? null,
|
|
1947
|
+
metadata: {
|
|
1948
|
+
rpc: true,
|
|
1949
|
+
exitCode,
|
|
1950
|
+
signal,
|
|
1951
|
+
outputBytes: snapshot.buffer.length,
|
|
1952
|
+
},
|
|
1953
|
+
});
|
|
1954
|
+
reply.code(500);
|
|
1955
|
+
return {
|
|
1956
|
+
error: signal
|
|
1957
|
+
? `Panel RPC action "${action.label}" exited with signal ${signal}`
|
|
1958
|
+
: `Panel RPC action "${action.label}" failed with exit code ${exitCode ?? 'unknown'}`,
|
|
1959
|
+
};
|
|
1960
|
+
}
|
|
1961
|
+
insertPanelAuditEvent(db, {
|
|
1962
|
+
panelId: panel.id,
|
|
1963
|
+
eventType: 'actioned',
|
|
1964
|
+
version: panel.version,
|
|
1965
|
+
submitKind: 'action',
|
|
1966
|
+
actionId: action.id,
|
|
1967
|
+
actorType: 'user',
|
|
1968
|
+
actorId: user.id,
|
|
1969
|
+
runId: null,
|
|
1970
|
+
conversationId: panel.conversationId,
|
|
1971
|
+
scopeType: panel.scopeType,
|
|
1972
|
+
scopeId: panel.scopeId ?? null,
|
|
1973
|
+
metadata: {
|
|
1974
|
+
rpc: true,
|
|
1975
|
+
exitCode,
|
|
1976
|
+
signal,
|
|
1977
|
+
outputBytes: snapshot.buffer.length,
|
|
1978
|
+
},
|
|
1979
|
+
});
|
|
1980
|
+
return {
|
|
1981
|
+
ok: true,
|
|
1982
|
+
mode: 'platform_exec',
|
|
1983
|
+
queued: false,
|
|
1984
|
+
outputText: snapshot.buffer,
|
|
1985
|
+
exitCode,
|
|
1986
|
+
signal,
|
|
1987
|
+
};
|
|
1988
|
+
}
|
|
1989
|
+
catch (error) {
|
|
1990
|
+
const message = String(error?.message ?? error);
|
|
1991
|
+
reply.code(message.includes('offline') ? 503 : 500);
|
|
1992
|
+
return { error: message };
|
|
1993
|
+
}
|
|
1994
|
+
finally {
|
|
1995
|
+
if (terminalId) {
|
|
1996
|
+
await Promise.resolve(terminalBroker.closeTerminal(agent.nodeId, terminalId)).catch(() => undefined);
|
|
1997
|
+
}
|
|
1998
|
+
}
|
|
1999
|
+
});
|
|
2000
|
+
app.post('/api/panels/:id/submits', async (req, reply) => {
|
|
2001
|
+
const user = requireUser(req, reply, db);
|
|
2002
|
+
if (!user)
|
|
2003
|
+
return { error: 'Unauthorized' };
|
|
2004
|
+
const submitKind = req.body?.submitKind;
|
|
2005
|
+
if (submitKind !== 'form' && submitKind !== 'apply' && submitKind !== 'done') {
|
|
2006
|
+
reply.code(400);
|
|
2007
|
+
return { error: 'submitKind must be form, apply, or done' };
|
|
2008
|
+
}
|
|
2009
|
+
return submitPanel({
|
|
2010
|
+
panelId: req.params.id,
|
|
2011
|
+
user,
|
|
2012
|
+
reply,
|
|
2013
|
+
submitKind,
|
|
2014
|
+
actionId: req.body?.actionId,
|
|
2015
|
+
baseVersion: req.body?.baseVersion,
|
|
2016
|
+
form: req.body?.form,
|
|
2017
|
+
changedRowIndices: req.body?.changedRowIndices,
|
|
2018
|
+
metadata: req.body?.metadata,
|
|
2019
|
+
});
|
|
2020
|
+
});
|
|
2021
|
+
app.get('/internal/panels/:panelId/audit', async (req, reply) => {
|
|
2022
|
+
const user = requireAdmin(req, reply, db);
|
|
2023
|
+
if (!user)
|
|
2024
|
+
return { error: 'Forbidden' };
|
|
2025
|
+
const events = listPanelAuditEvents(db, req.params.panelId);
|
|
2026
|
+
return events.map((event) => ({
|
|
2027
|
+
panelId: event.panelId,
|
|
2028
|
+
event: event.event,
|
|
2029
|
+
timestamp: event.timestamp,
|
|
2030
|
+
version: event.version,
|
|
2031
|
+
changed: event.changed ? JSON.parse(event.changed) : undefined,
|
|
2032
|
+
submitKind: event.submitKind ?? undefined,
|
|
2033
|
+
actionId: event.actionId ?? undefined,
|
|
2034
|
+
failureClass: event.failureClass ?? undefined,
|
|
2035
|
+
actor: {
|
|
2036
|
+
type: event.actorType,
|
|
2037
|
+
id: event.actorId,
|
|
2038
|
+
runId: event.runId,
|
|
2039
|
+
},
|
|
2040
|
+
conversationId: event.conversationId,
|
|
2041
|
+
scopeType: event.scopeType,
|
|
2042
|
+
scopeId: event.scopeId ?? undefined,
|
|
2043
|
+
metadata: JSON.parse(event.metadataJson),
|
|
2044
|
+
}));
|
|
2045
|
+
});
|
|
2046
|
+
// Service proxy: browser → core → agent-node WS → localhost:{port}/{path}
|
|
2047
|
+
// Requires the tool manifest to declare a service.port.
|
|
2048
|
+
// Only the tool owner or collaborators may call this.
|
|
2049
|
+
const SERVICE_PROXY_METHODS = new Set(['GET', 'POST', 'PUT', 'PATCH', 'DELETE']);
|
|
2050
|
+
const SERVICE_PROXY_MAX_BYTES = 4 * 1024 * 1024; // 4 MB response cap
|
|
2051
|
+
app.all('/api/workspace-tools/:toolId/service-proxy/*', async (req, reply) => {
|
|
2052
|
+
const user = requireUser(req, reply, db);
|
|
2053
|
+
if (!user)
|
|
2054
|
+
return { error: 'Unauthorized' };
|
|
2055
|
+
const method = req.method.toUpperCase();
|
|
2056
|
+
if (!SERVICE_PROXY_METHODS.has(method)) {
|
|
2057
|
+
reply.code(405);
|
|
2058
|
+
return { error: 'Method not allowed' };
|
|
2059
|
+
}
|
|
2060
|
+
let tool;
|
|
2061
|
+
try {
|
|
2062
|
+
tool = await workspaceToolService.getToolForViewer(user.id, req.params.toolId);
|
|
2063
|
+
}
|
|
2064
|
+
catch (error) {
|
|
2065
|
+
if (error instanceof Error && 'statusCode' in error && error.statusCode === 404) {
|
|
2066
|
+
reply.code(404);
|
|
2067
|
+
return { error: 'Tool not found' };
|
|
2068
|
+
}
|
|
2069
|
+
throw error;
|
|
2070
|
+
}
|
|
2071
|
+
const serviceManifest = tool.manifest.service;
|
|
2072
|
+
if (!serviceManifest) {
|
|
2073
|
+
reply.code(409);
|
|
2074
|
+
return { error: 'Tool does not declare a service port in its manifest' };
|
|
2075
|
+
}
|
|
2076
|
+
const nodeId = tool.agentNodeId;
|
|
2077
|
+
if (!nodeId) {
|
|
2078
|
+
reply.code(503);
|
|
2079
|
+
return { error: 'Tool agent node is not connected' };
|
|
2080
|
+
}
|
|
2081
|
+
const subpath = req.params['*'] ?? '';
|
|
2082
|
+
const targetUrl = `http://localhost:${serviceManifest.port}/${subpath}`;
|
|
2083
|
+
const body = typeof req.body === 'string'
|
|
2084
|
+
? req.body
|
|
2085
|
+
: req.body != null
|
|
2086
|
+
? JSON.stringify(req.body)
|
|
2087
|
+
: undefined;
|
|
2088
|
+
const forwardHeaders = {};
|
|
2089
|
+
const contentType = req.headers['content-type'];
|
|
2090
|
+
if (contentType)
|
|
2091
|
+
forwardHeaders['content-type'] = contentType;
|
|
2092
|
+
const accept = req.headers['accept'];
|
|
2093
|
+
if (accept)
|
|
2094
|
+
forwardHeaders['accept'] = accept;
|
|
2095
|
+
let result;
|
|
2096
|
+
try {
|
|
2097
|
+
result = await workspaceBroker.proxyWorkspaceService(nodeId, targetUrl, {
|
|
2098
|
+
method: method,
|
|
2099
|
+
headers: forwardHeaders,
|
|
2100
|
+
...(body !== undefined ? { body } : {}),
|
|
2101
|
+
maxBytes: SERVICE_PROXY_MAX_BYTES,
|
|
2102
|
+
});
|
|
2103
|
+
}
|
|
2104
|
+
catch (error) {
|
|
2105
|
+
const msg = error instanceof Error ? error.message : 'Service proxy failed';
|
|
2106
|
+
if (msg.includes('offline') || msg.includes('not connected')) {
|
|
2107
|
+
reply.code(503);
|
|
2108
|
+
return { error: msg };
|
|
2109
|
+
}
|
|
2110
|
+
if (msg.includes('timed out')) {
|
|
2111
|
+
reply.code(504);
|
|
2112
|
+
return { error: msg };
|
|
2113
|
+
}
|
|
2114
|
+
reply.code(502);
|
|
2115
|
+
return { error: msg };
|
|
2116
|
+
}
|
|
2117
|
+
reply.code(result.status);
|
|
2118
|
+
if (result.contentType)
|
|
2119
|
+
reply.header('content-type', result.contentType);
|
|
2120
|
+
return reply.send(result.body);
|
|
2121
|
+
});
|
|
2122
|
+
app.get('/api/internal/admin/panels/:id/trace', async (req, reply) => {
|
|
2123
|
+
const user = requireAdmin(req, reply, db);
|
|
2124
|
+
if (!user)
|
|
2125
|
+
return { error: 'Forbidden' };
|
|
2126
|
+
const events = listPanelAuditEvents(db, req.params.id);
|
|
2127
|
+
return events.map((event) => ({
|
|
2128
|
+
panelId: event.panelId,
|
|
2129
|
+
eventType: event.event,
|
|
2130
|
+
timestamp: event.timestamp,
|
|
2131
|
+
version: event.version,
|
|
2132
|
+
changed: event.changed ? JSON.parse(event.changed) : undefined,
|
|
2133
|
+
submitKind: event.submitKind ?? undefined,
|
|
2134
|
+
actionId: event.actionId ?? undefined,
|
|
2135
|
+
failureClass: event.failureClass ?? undefined,
|
|
2136
|
+
actor: {
|
|
2137
|
+
type: event.actorType,
|
|
2138
|
+
id: event.actorId,
|
|
2139
|
+
runId: event.runId,
|
|
2140
|
+
},
|
|
2141
|
+
conversationId: event.conversationId,
|
|
2142
|
+
scopeType: event.scopeType,
|
|
2143
|
+
scopeId: event.scopeId ?? undefined,
|
|
2144
|
+
metadata: JSON.parse(event.metadataJson),
|
|
2145
|
+
}));
|
|
2146
|
+
});
|
|
2147
|
+
}
|