@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.
Files changed (175) hide show
  1. package/dist/config.js +380 -0
  2. package/dist/execution/executionDispatcher.js +3810 -0
  3. package/dist/main.js +90 -0
  4. package/dist/nodeEventHistory.js +206 -0
  5. package/dist/scheduler/dreamLogic.js +50 -0
  6. package/dist/scheduler/dreamScheduler.js +65 -0
  7. package/dist/services/agentFileAccessService.js +1913 -0
  8. package/dist/services/agentRuntimeCleanupBroker.js +62 -0
  9. package/dist/services/agentSkillsBroker.js +118 -0
  10. package/dist/services/agentSkillsService.js +83 -0
  11. package/dist/services/agentWorkspaceBroker.js +937 -0
  12. package/dist/services/agentWorkspaceService.js +70 -0
  13. package/dist/services/appVersion.js +14 -0
  14. package/dist/services/auth.js +586 -0
  15. package/dist/services/claudeControlBroker.js +154 -0
  16. package/dist/services/claudeTranscriptBroker.js +100 -0
  17. package/dist/services/claudeTranscriptService.js +359 -0
  18. package/dist/services/codexAppServerBroker.js +155 -0
  19. package/dist/services/codexTranscriptBroker.js +98 -0
  20. package/dist/services/codexTranscriptService.js +961 -0
  21. package/dist/services/droidMissionBroker.js +124 -0
  22. package/dist/services/droidMissionImporter.js +630 -0
  23. package/dist/services/droidModelOptions.js +165 -0
  24. package/dist/services/hubServerRegistrationService.js +268 -0
  25. package/dist/services/libraryManifest.js +43 -0
  26. package/dist/services/libraryScaffold.js +26 -0
  27. package/dist/services/libraryService.js +2263 -0
  28. package/dist/services/memoryService.js +386 -0
  29. package/dist/services/missionEvidence.js +377 -0
  30. package/dist/services/missionService.js +2361 -0
  31. package/dist/services/missionTrace.js +158 -0
  32. package/dist/services/nativeMissionBriefParser.js +120 -0
  33. package/dist/services/nativeMissionOrchestrator.js +2045 -0
  34. package/dist/services/nativeMissionReportGenerator.js +227 -0
  35. package/dist/services/nativeMissionValidationRunner.js +452 -0
  36. package/dist/services/nativeMissionWorkerBroker.js +190 -0
  37. package/dist/services/nodeRegistry.js +34 -0
  38. package/dist/services/nodeStateReconciler.js +97 -0
  39. package/dist/services/panelMediaScanner.js +119 -0
  40. package/dist/services/persistentRuntimeJsonlClient.js +153 -0
  41. package/dist/services/platformAgentPolicy.js +180 -0
  42. package/dist/services/platformAgentService.js +2041 -0
  43. package/dist/services/projectAccessResolver.js +93 -0
  44. package/dist/services/projectService.js +392 -0
  45. package/dist/services/resourceSpaceService.js +140 -0
  46. package/dist/services/scenarioRuntimeService.js +1130 -0
  47. package/dist/services/suggestedPlannerService.js +868 -0
  48. package/dist/services/workbenchGitBroker.js +161 -0
  49. package/dist/services/workbenchGitService.js +69 -0
  50. package/dist/services/workbenchInspectBroker.js +65 -0
  51. package/dist/services/workbenchNodePathService.js +79 -0
  52. package/dist/services/workbenchRegistryService.js +240 -0
  53. package/dist/services/workbenchRootService.js +181 -0
  54. package/dist/services/workbenchTerminalBroker.js +378 -0
  55. package/dist/services/workspaceRunOwnership.js +60 -0
  56. package/dist/services/workspaceScaffold.js +105 -0
  57. package/dist/services/workspaceSessionRuntimeService.js +576 -0
  58. package/dist/services/workspaceSessionService.js +245 -0
  59. package/dist/services/workspaceToolActionRunner.js +1582 -0
  60. package/dist/services/workspaceToolErrors.js +10 -0
  61. package/dist/services/workspaceToolExecutionUtils.js +895 -0
  62. package/dist/services/workspaceToolLatestStateProjector.js +91 -0
  63. package/dist/services/workspaceToolManifest.js +572 -0
  64. package/dist/services/workspaceToolMutationQueue.js +43 -0
  65. package/dist/services/workspaceToolPanelProjection.js +460 -0
  66. package/dist/services/workspaceToolPromotion.js +255 -0
  67. package/dist/services/workspaceToolPromotionState.js +224 -0
  68. package/dist/services/workspaceToolPublishDiagnostics.js +189 -0
  69. package/dist/services/workspaceToolPublishIdentityResolver.js +146 -0
  70. package/dist/services/workspaceToolReadModel.js +378 -0
  71. package/dist/services/workspaceToolRunLedger.js +239 -0
  72. package/dist/services/workspaceToolService.js +3067 -0
  73. package/dist/services/workspaceToolSnapshotPanelSync.js +293 -0
  74. package/dist/services/workspaceToolTerminalLifecycle.js +283 -0
  75. package/dist/services/workspaceToolTypes.js +1 -0
  76. package/dist/services/workspaceToolUploadMaterializer.js +228 -0
  77. package/dist/web/actionCardRoutes.js +129 -0
  78. package/dist/web/actionCards.js +469 -0
  79. package/dist/web/activationContext.js +684 -0
  80. package/dist/web/agentChannelGuards.js +48 -0
  81. package/dist/web/agentMentionCooldowns.js +32 -0
  82. package/dist/web/agentReminders.js +1668 -0
  83. package/dist/web/agentRuntimePresence.js +197 -0
  84. package/dist/web/agentSelfState.js +494 -0
  85. package/dist/web/agentTaskLinks.js +26 -0
  86. package/dist/web/agentVisibility.js +79 -0
  87. package/dist/web/assets.js +95 -0
  88. package/dist/web/channelActivationPrompt.js +395 -0
  89. package/dist/web/channelMemoryNotes.js +127 -0
  90. package/dist/web/channelMentions.js +10 -0
  91. package/dist/web/channelMessageSequences.js +19 -0
  92. package/dist/web/channelSubscriptions.js +26 -0
  93. package/dist/web/clearedTaskRoots.js +10 -0
  94. package/dist/web/collaborationPromptGuidance.js +36 -0
  95. package/dist/web/collaborationSurfaceState.js +140 -0
  96. package/dist/web/contextBundleRanking.js +154 -0
  97. package/dist/web/contextBundleResolver.js +488 -0
  98. package/dist/web/conversationBuiltinSkillRoots.js +50 -0
  99. package/dist/web/conversationControls.js +232 -0
  100. package/dist/web/conversationHandoffs.js +612 -0
  101. package/dist/web/conversationManager.js +2511 -0
  102. package/dist/web/conversationSummaries.js +876 -0
  103. package/dist/web/conversationSurfaceKinds.js +17 -0
  104. package/dist/web/conversationTargets.js +173 -0
  105. package/dist/web/directActivationPrompt.js +122 -0
  106. package/dist/web/directReplyTargets.js +69 -0
  107. package/dist/web/directThreadResolver.js +129 -0
  108. package/dist/web/dmTaskHandoffPrompt.js +120 -0
  109. package/dist/web/dmTaskThreadStatusProjection.js +229 -0
  110. package/dist/web/ftsQuery.js +33 -0
  111. package/dist/web/internalAgentRouter.js +11341 -0
  112. package/dist/web/libraryCuratorScheduler.js +58 -0
  113. package/dist/web/libraryDocumentPromptGuidance.js +8 -0
  114. package/dist/web/messageCheckpoints.js +19 -0
  115. package/dist/web/nodeWsHandler.js +2495 -0
  116. package/dist/web/notificationRounds.js +1061 -0
  117. package/dist/web/panelActionMessages.js +108 -0
  118. package/dist/web/panelActivationPrompt.js +18 -0
  119. package/dist/web/panelAudit.js +273 -0
  120. package/dist/web/panelLifecycle.js +222 -0
  121. package/dist/web/panelMediaPolicy.js +43 -0
  122. package/dist/web/panelPathPolicy.js +63 -0
  123. package/dist/web/panelPreviews.js +175 -0
  124. package/dist/web/panelQueryHandles.js +2749 -0
  125. package/dist/web/panelRoutes.js +2147 -0
  126. package/dist/web/panels.js +904 -0
  127. package/dist/web/peerInboxAggregates.js +1247 -0
  128. package/dist/web/planApprovalState.js +92 -0
  129. package/dist/web/platformAgentScheduler.js +66 -0
  130. package/dist/web/proactiveOpportunities.js +452 -0
  131. package/dist/web/promptContextSections.js +242 -0
  132. package/dist/web/promptHistorySanitizer.js +26 -0
  133. package/dist/web/promptSlashCommands.js +158 -0
  134. package/dist/web/rollingConversationSummary.js +453 -0
  135. package/dist/web/routeHelpers.js +11 -0
  136. package/dist/web/routes/handoff.js +288 -0
  137. package/dist/web/routes/history.js +345 -0
  138. package/dist/web/routes/memory.js +258 -0
  139. package/dist/web/routes/selfState.js +171 -0
  140. package/dist/web/routes/workspace.js +154 -0
  141. package/dist/web/runSurfaceWatermarks.js +431 -0
  142. package/dist/web/runtimeCapabilities.js +48 -0
  143. package/dist/web/sameAgentHandoffs.js +494 -0
  144. package/dist/web/server.js +15567 -0
  145. package/dist/web/sharedCollaborationCapsules.js +163 -0
  146. package/dist/web/soloSessionRelay.js +42 -0
  147. package/dist/web/soloWsHandler.js +138 -0
  148. package/dist/web/suggestedPlannerScheduler.js +56 -0
  149. package/dist/web/surfaceActivationPolicy.js +108 -0
  150. package/dist/web/surfaceCollaborators.js +61 -0
  151. package/dist/web/surfaceSystemStatus.js +263 -0
  152. package/dist/web/targetParticipants.js +77 -0
  153. package/dist/web/taskEvents.js +49 -0
  154. package/dist/web/taskLifecycleMessages.js +165 -0
  155. package/dist/web/taskLoops.js +732 -0
  156. package/dist/web/taskMemoryNotes.js +224 -0
  157. package/dist/web/taskNumbers.js +16 -0
  158. package/dist/web/taskOwnerGuards.js +49 -0
  159. package/dist/web/taskParticipantResolver.js +42 -0
  160. package/dist/web/taskParticipants.js +97 -0
  161. package/dist/web/taskSourceDetails.js +20 -0
  162. package/dist/web/taskStateViews.js +210 -0
  163. package/dist/web/taskStatusTransitions.js +9 -0
  164. package/dist/web/taskThreadFollowups.js +599 -0
  165. package/dist/web/taskThreadRuntimeClosure.js +685 -0
  166. package/dist/web/taskUpdateDelivery.js +104 -0
  167. package/dist/web/threadReplyContentHeuristics.js +30 -0
  168. package/dist/web/threadRoots.js +61 -0
  169. package/dist/web/threadTaskBindings.js +365 -0
  170. package/dist/web/uiPanelPromptGuidance.js +27 -0
  171. package/dist/web/workspaceMemoryHints.js +143 -0
  172. package/dist/web/workspaceToolPromptGuidance.js +30 -0
  173. package/dist/web/wsHandler.js +397 -0
  174. package/dist/web/wsSink.js +116 -0
  175. package/package.json +54 -0
@@ -0,0 +1,3067 @@
1
+ import { randomUUID } from 'node:crypto';
2
+ import path from 'node:path';
3
+ import { z } from 'zod';
4
+ import { BUILTIN_UI_PANEL_SKILL_ROOT_SENTINEL, BUILTIN_WORKSPACE_TOOL_SKILL_ROOT_SENTINEL, buildThreadShortId, normalizeThreadShortIdInput, } from '@bbigbang/protocol';
5
+ import { log } from '@bbigbang/runtime-acp';
6
+ import { allocateNextChannelMessageSeq } from '../web/channelMessageSequences.js';
7
+ import { conversationBuiltinSkillRootsForSlashCommand } from '../web/conversationBuiltinSkillRoots.js';
8
+ import { resolveDirectUserIdFromReplyTarget } from '../web/directReplyTargets.js';
9
+ import { archivePanelWithArtifacts, hardDeletePanelWithArtifacts, unlinkPanelAttachmentStoragePaths, } from '../web/panelLifecycle.js';
10
+ import { getPanelById, listPanelRows, } from '../web/panels.js';
11
+ import { ensureSurfaceOwner, getSurfaceCollaboratorRole, listSurfaceCollaborators, removeSurfaceCollaborator, upsertSurfaceCollaborator, } from '../web/surfaceCollaborators.js';
12
+ import { getUserAgentAccess, getUserChannelAccess } from './auth.js';
13
+ import { WorkspaceToolActionRunner } from './workspaceToolActionRunner.js';
14
+ import { WorkspaceToolLatestStateProjector } from './workspaceToolLatestStateProjector.js';
15
+ import { buildDeletedWorkspaceToolSlug, closeTerminalBestEffort, deriveOneShotRunCompletion, formatBoundedJsonPreview, formatBoundedTextPreview, getPublishedWorkspaceToolBundleRoot, isPlainRecord, normalizeWorkspaceRelativePath, parseWorkspaceToolRunArtifactRefs, resolveToolCwd, runEventRowToInfo, shellEscape, validateManifestConsistency, validateManifestCwds, waitForTerminalExit, } from './workspaceToolExecutionUtils.js';
16
+ import { WorkspaceToolServiceError } from './workspaceToolErrors.js';
17
+ import { parseWorkspaceToolManifest, withPersistedToolSettings, } from './workspaceToolManifest.js';
18
+ import { buildDirectPromotedToolManifest, hasRowTemplateGridPromotionSeed } from './workspaceToolPromotion.js';
19
+ import { WorkspaceToolMutationQueues } from './workspaceToolMutationQueue.js';
20
+ import { isDeletedLinkedToolPromotionRetryable, resolveWorkspaceToolPromotionState, } from './workspaceToolPromotionState.js';
21
+ import { WorkspaceToolPublishIdentityResolver, } from './workspaceToolPublishIdentityResolver.js';
22
+ import { buildWorkspaceToolPublishDiagnostics } from './workspaceToolPublishDiagnostics.js';
23
+ import { WorkspaceToolReadModel, workspaceToolRunRowToInfoForViewer, } from './workspaceToolReadModel.js';
24
+ import { WorkspaceToolSnapshotPanelSyncer } from './workspaceToolSnapshotPanelSync.js';
25
+ export { WorkspaceToolServiceError } from './workspaceToolErrors.js';
26
+ export { getPublishedWorkspaceToolBundleRoot, getWorkspaceToolMediaRelativePathCandidates, } from './workspaceToolExecutionUtils.js';
27
+ const TOOL_MAINTENANCE_TITLE_PREFIX = '[workspace-tool:';
28
+ const PANEL_TOOL_PROMOTION_THREAD_PREFIX = 'paneltool-';
29
+ const DEFAULT_WORKSPACE_TOOL_MAX_ACTIVE_PERSISTENT_RUNS_PER_AGENT = 3;
30
+ const PANEL_PROMOTION_SAMPLE_ROW_LIMIT = 3;
31
+ const WORKSPACE_TOOL_PLATFORM_EXEC_MESSAGE_SOURCE = 'workspace_tool_platform_exec';
32
+ const WORKSPACE_TOOL_PLATFORM_EXEC_OUTPUT_PREVIEW_LIMIT = 800;
33
+ const WORKSPACE_TOOL_PLATFORM_EXEC_PARAMS_PREVIEW_LIMIT = 800;
34
+ export const WORKSPACE_TOOL_LOG_RETENTION_MS = 7 * 24 * 60 * 60 * 1000;
35
+ const WORKSPACE_TOOL_LOG_RETENTION_INTERVAL_MS = 24 * 60 * 60 * 1000;
36
+ const WORKSPACE_TOOL_RUNTIME_LOG_MAX_BYTES_BEFORE_ROTATE = 10 * 1024 * 1024;
37
+ const WORKSPACE_TOOL_CLONE_ARCHIVE_MAX_BYTES = 25 * 1024 * 1024;
38
+ const WORKSPACE_TOOL_CLONE_ARCHIVE_TIMEOUT_MS = 60_000;
39
+ const WORKSPACE_TOOL_RUN_LIST_MAX_LIMIT = 100;
40
+ export const WORKSPACE_TOOL_RUN_HISTORY_MAX_STORED_PER_TOOL = 100;
41
+ const WORKSPACE_TOOL_RUN_EVENT_DEFAULT_LIMIT = 80;
42
+ const WORKSPACE_TOOL_RUN_EVENT_MAX_LIMIT = 500;
43
+ const WORKSPACE_TOOL_RUN_ARTIFACT_DEFAULT_LIMIT = 100;
44
+ const WORKSPACE_TOOL_RUN_ARTIFACT_MAX_LIMIT = 500;
45
+ const WORKSPACE_TOOL_RUN_ARTIFACT_SCAN_BATCH = 100;
46
+ const WORKSPACE_TOOL_RUN_ARTIFACT_MAX_SCANNED_EVENTS = 1_000;
47
+ function normalizeBoundedPositiveInteger(value, defaultValue, maxValue) {
48
+ if (!Number.isInteger(value) || value <= 0)
49
+ return defaultValue;
50
+ return Math.min(value, maxValue);
51
+ }
52
+ function normalizeOptionalBoundedPositiveInteger(value, maxValue) {
53
+ if (!Number.isInteger(value) || value <= 0)
54
+ return null;
55
+ return Math.min(value, maxValue);
56
+ }
57
+ function normalizeBeforeSequence(value) {
58
+ if (!Number.isInteger(value) || value <= 0)
59
+ return null;
60
+ return value;
61
+ }
62
+ function normalizeStringFilters(values) {
63
+ if (!Array.isArray(values))
64
+ return [];
65
+ const out = [];
66
+ const seen = new Set();
67
+ for (const value of values) {
68
+ const normalized = value.trim().toLowerCase();
69
+ if (!normalized || seen.has(normalized))
70
+ continue;
71
+ seen.add(normalized);
72
+ out.push(normalized);
73
+ }
74
+ return out;
75
+ }
76
+ const workspaceToolSettingsUpdateSchema = z.object({
77
+ userId: z.string().trim().min(1),
78
+ toolId: z.string().trim().min(1),
79
+ scope: z.enum(['private', 'channel', 'public']),
80
+ scopeChannelId: z.string().trim().min(1).nullable().optional(),
81
+ allowSharedExec: z.boolean().optional(),
82
+ cancelPendingPublicReview: z.boolean().optional(),
83
+ });
84
+ const workspaceToolCloneSchema = z.object({
85
+ userId: z.string().trim().min(1),
86
+ toolId: z.string().trim().min(1),
87
+ targetAgentId: z.string().trim().min(1),
88
+ slug: z.string().trim().min(1).optional().nullable(),
89
+ name: z.string().trim().min(1).optional().nullable(),
90
+ description: z.string().optional().nullable(),
91
+ });
92
+ const workspaceToolVerificationUpdateSchema = z.object({
93
+ agentId: z.string().trim().min(1),
94
+ toolId: z.string().trim().min(1),
95
+ conversationId: z.string().trim().min(1),
96
+ status: z.enum(['verified', 'failed', 'warning']),
97
+ summary: z.string().trim().min(1).max(2000),
98
+ details: z.record(z.string(), z.unknown()).optional().nullable(),
99
+ smokeRunId: z.string().trim().min(1).optional().nullable(),
100
+ });
101
+ function normalizeMaxActivePersistentRunsPerAgent(value) {
102
+ return typeof value === 'number' && Number.isInteger(value) && value > 0
103
+ ? value
104
+ : DEFAULT_WORKSPACE_TOOL_MAX_ACTIVE_PERSISTENT_RUNS_PER_AGENT;
105
+ }
106
+ function workspaceToolVerificationRowToInfo(row) {
107
+ let details = null;
108
+ if (row.detailsJson) {
109
+ try {
110
+ const parsed = JSON.parse(row.detailsJson);
111
+ if (parsed && typeof parsed === 'object' && !Array.isArray(parsed)) {
112
+ details = parsed;
113
+ }
114
+ }
115
+ catch {
116
+ details = null;
117
+ }
118
+ }
119
+ return {
120
+ verificationId: row.verificationId,
121
+ toolId: row.toolId,
122
+ toolRevision: row.toolRevision,
123
+ sourceToolId: row.sourceToolId,
124
+ sourceRevision: row.sourceRevision,
125
+ targetAgentId: row.targetAgentId,
126
+ targetNodeId: row.targetNodeId,
127
+ targetWorkspacePath: row.targetWorkspacePath,
128
+ conversationId: row.conversationId,
129
+ status: row.status,
130
+ summary: row.summary,
131
+ details,
132
+ smokeRunId: row.smokeRunId,
133
+ startedAt: row.startedAt,
134
+ endedAt: row.endedAt,
135
+ updatedAt: row.updatedAt,
136
+ };
137
+ }
138
+ function findWorkspaceToolVerificationStatusAction(manifest) {
139
+ return manifest.actions.find((action) => (action.kind === 'status'
140
+ && (action.paramsSchema?.length ?? 0) === 0)) ?? null;
141
+ }
142
+ function isWorkspaceFileMissingError(error) {
143
+ const message = String(error?.message ?? error).toLowerCase();
144
+ return message.includes('not_found') || message.includes('path not found');
145
+ }
146
+ function extractRuntimeLogTimestampMs(line) {
147
+ let parsed;
148
+ try {
149
+ parsed = JSON.parse(line);
150
+ }
151
+ catch {
152
+ return null;
153
+ }
154
+ if (!isPlainRecord(parsed))
155
+ return null;
156
+ for (const key of ['timestamp', 'time', 'createdAt', 'updatedAt', 'ts']) {
157
+ const raw = parsed[key];
158
+ if (typeof raw === 'number' && Number.isFinite(raw) && raw > 0) {
159
+ return raw < 1_000_000_000_000 ? Math.floor(raw * 1000) : Math.floor(raw);
160
+ }
161
+ if (typeof raw === 'string' && raw.trim()) {
162
+ const timestamp = Date.parse(raw);
163
+ if (Number.isFinite(timestamp))
164
+ return timestamp;
165
+ }
166
+ }
167
+ return null;
168
+ }
169
+ function normalizeRuntimeLogDirCandidate(value) {
170
+ const normalized = path.posix.normalize(value.replace(/\\/g, '/')).replace(/^\.\/+/, '').replace(/\/+$/, '');
171
+ if (!normalized || normalized === '.' || normalized === '..' || normalized.startsWith('../') || path.posix.isAbsolute(normalized)) {
172
+ return null;
173
+ }
174
+ return normalized;
175
+ }
176
+ function normalizeWorkspaceToolUploadPathCandidate(value) {
177
+ if (typeof value !== 'string')
178
+ return null;
179
+ const normalized = path.posix.normalize(value.replace(/\\/g, '/')).replace(/^\.\/+/, '').replace(/\/+$/, '');
180
+ if (!normalized
181
+ || normalized === '.'
182
+ || normalized === '..'
183
+ || normalized.startsWith('../')
184
+ || path.posix.isAbsolute(normalized)
185
+ || !normalized.includes('/uploads/')
186
+ || !normalized.startsWith('.agent-tools-runtime/')) {
187
+ return null;
188
+ }
189
+ return normalized;
190
+ }
191
+ function normalizeWorkspaceToolCloneSlug(value) {
192
+ const slug = value.trim();
193
+ const normalizedPath = path.posix.normalize(slug.replace(/\\/g, '/'));
194
+ if (!slug
195
+ || slug.includes('/')
196
+ || slug.includes('\\')
197
+ || slug === '.'
198
+ || slug === '..'
199
+ || normalizedPath === '..'
200
+ || normalizedPath.startsWith('../')
201
+ || path.posix.isAbsolute(normalizedPath)) {
202
+ throw new WorkspaceToolServiceError('Tool clone slug must be a single workspace-safe path segment.', 400);
203
+ }
204
+ return slug;
205
+ }
206
+ function workspaceToolCloneManifestHasSourcePathReferences(manifest, sourceSlug) {
207
+ const roots = [
208
+ `.agent-tools/${sourceSlug}`,
209
+ `.agent-tools-runtime/${sourceSlug}`,
210
+ ];
211
+ const visits = new Set();
212
+ const stringContainsRoot = (value, root) => {
213
+ const text = value.replace(/\\/g, '/');
214
+ let index = text.indexOf(root);
215
+ while (index >= 0) {
216
+ const before = index > 0 ? text[index - 1] : '';
217
+ const after = text[index + root.length] ?? '';
218
+ const beforeOk = !before || !/[A-Za-z0-9_.-]/.test(before);
219
+ const afterOk = !after || after === '/' || !/[A-Za-z0-9_.-]/.test(after);
220
+ if (beforeOk && afterOk)
221
+ return true;
222
+ index = text.indexOf(root, index + root.length);
223
+ }
224
+ return false;
225
+ };
226
+ const visit = (value) => {
227
+ if (typeof value === 'string') {
228
+ return roots.some((root) => stringContainsRoot(value, root));
229
+ }
230
+ if (!value || typeof value !== 'object' || visits.has(value))
231
+ return false;
232
+ visits.add(value);
233
+ if (Array.isArray(value))
234
+ return value.some((item) => visit(item));
235
+ return Object.values(value).some((item) => visit(item));
236
+ };
237
+ return visit(manifest);
238
+ }
239
+ function collectWorkspaceToolUploadPathsFromParamsJson(paramsJson) {
240
+ if (!paramsJson)
241
+ return [];
242
+ let parsed;
243
+ try {
244
+ parsed = JSON.parse(paramsJson);
245
+ }
246
+ catch {
247
+ return [];
248
+ }
249
+ const paths = new Set();
250
+ const visit = (value) => {
251
+ if (Array.isArray(value)) {
252
+ for (const item of value)
253
+ visit(item);
254
+ return;
255
+ }
256
+ if (!isPlainRecord(value))
257
+ return;
258
+ if (value.type === 'workspace_tool_upload') {
259
+ const candidate = normalizeWorkspaceToolUploadPathCandidate(value.workspacePath ?? value.path);
260
+ if (candidate)
261
+ paths.add(candidate);
262
+ }
263
+ for (const child of Object.values(value))
264
+ visit(child);
265
+ };
266
+ visit(parsed);
267
+ return [...paths];
268
+ }
269
+ export class WorkspaceToolService {
270
+ db;
271
+ conversationManager;
272
+ workspaceBroker;
273
+ terminalBroker;
274
+ onStateUpdated;
275
+ mutationQueues = new WorkspaceToolMutationQueues();
276
+ maxActivePersistentRunsPerAgent;
277
+ actionRunner;
278
+ publishIdentityResolver;
279
+ readModel;
280
+ snapshotPanels;
281
+ latestStateProjector;
282
+ logRetentionTimer = null;
283
+ constructor(params) {
284
+ this.db = params.db;
285
+ this.conversationManager = params.conversationManager;
286
+ this.workspaceBroker = params.workspaceBroker;
287
+ this.terminalBroker = params.terminalBroker;
288
+ this.onStateUpdated = params.onStateUpdated;
289
+ this.maxActivePersistentRunsPerAgent = normalizeMaxActivePersistentRunsPerAgent(params.maxActivePersistentRunsPerAgent);
290
+ this.readModel = new WorkspaceToolReadModel(this.db);
291
+ this.snapshotPanels = new WorkspaceToolSnapshotPanelSyncer({
292
+ db: this.db,
293
+ conversations: this.conversationManager,
294
+ });
295
+ this.latestStateProjector = new WorkspaceToolLatestStateProjector({
296
+ snapshotPanels: this.snapshotPanels,
297
+ onStateUpdated: this.onStateUpdated,
298
+ getToolInfoForActionViewer: (userId, toolId, allowAdminBypass) => this.getToolInfoForActionViewer(userId, toolId, allowAdminBypass),
299
+ });
300
+ this.publishIdentityResolver = new WorkspaceToolPublishIdentityResolver(this.db);
301
+ this.actionRunner = new WorkspaceToolActionRunner({
302
+ db: this.db,
303
+ conversationManager: this.conversationManager,
304
+ terminalBroker: this.terminalBroker,
305
+ silentStatusRefreshRetryDelayMs: Math.max(0, params.silentStatusRefreshRetryDelayMs ?? 200),
306
+ loadUsername: (userId) => this.readModel.loadUsername(userId),
307
+ isAdminUser: (userId) => this.readModel.isAdminUser(userId),
308
+ enqueueToolMutation: (toolId, work) => this.mutationQueues.enqueueTool(toolId, work),
309
+ enqueueAgentPersistentStart: (agentId, work) => this.mutationQueues.enqueueAgentPersistentStart(agentId, work),
310
+ maxActivePersistentRunsPerAgent: this.maxActivePersistentRunsPerAgent,
311
+ notifyAgentForToolAction: (runnerParams) => this.notifyAgentForToolAction(runnerParams),
312
+ getToolInfoForActionViewer: (userId, toolId, allowAdminBypass) => this.getToolInfoForActionViewer(userId, toolId, allowAdminBypass),
313
+ recordPlatformExecNotification: (runnerParams) => this.recordPlatformExecNotification(runnerParams),
314
+ latestStateProjector: this.latestStateProjector,
315
+ readWorkspaceFile: (nodeId, workspaceRoot, relativePath, options) => this.workspaceBroker.readFile(nodeId, workspaceRoot, relativePath, options),
316
+ writeWorkspaceFile: (nodeId, workspaceRoot, relativePath, content, mode, options) => this.workspaceBroker.writeFile(nodeId, workspaceRoot, relativePath, content, mode, options),
317
+ deleteWorkspaceFile: (nodeId, workspaceRoot, relativePath) => this.workspaceBroker.deleteFile(nodeId, workspaceRoot, relativePath),
318
+ });
319
+ this.terminalBroker.onTerminalTermination((event) => {
320
+ void this.actionRunner.handleTerminalTermination(event).catch((error) => {
321
+ log.warn('[workspace-tools] terminal termination cleanup failed', {
322
+ terminalId: event.terminalId,
323
+ error: String(error?.message ?? error),
324
+ });
325
+ });
326
+ });
327
+ this.terminalBroker.onOutput((event) => {
328
+ this.actionRunner.processTerminalOutput(event.terminalId, event.data);
329
+ });
330
+ }
331
+ startLogRetentionScheduler() {
332
+ if (this.logRetentionTimer)
333
+ return;
334
+ void this.cleanupExpiredLogs().catch((error) => {
335
+ log.warn('[workspace-tools] log retention cleanup failed', {
336
+ error: String(error?.message ?? error),
337
+ });
338
+ });
339
+ this.logRetentionTimer = setInterval(() => {
340
+ void this.cleanupExpiredLogs().catch((error) => {
341
+ log.warn('[workspace-tools] log retention cleanup failed', {
342
+ error: String(error?.message ?? error),
343
+ });
344
+ });
345
+ }, WORKSPACE_TOOL_LOG_RETENTION_INTERVAL_MS);
346
+ }
347
+ stopLogRetentionScheduler() {
348
+ if (!this.logRetentionTimer)
349
+ return;
350
+ clearInterval(this.logRetentionTimer);
351
+ this.logRetentionTimer = null;
352
+ }
353
+ async cleanupExpiredLogs(now = Date.now()) {
354
+ const cutoff = now - WORKSPACE_TOOL_LOG_RETENTION_MS;
355
+ const cleanupRunSelect = `SELECT wtr.run_id as runId,
356
+ wtr.tool_id as toolId,
357
+ wtr.status,
358
+ wtr.tool_revision as toolRevision,
359
+ wtr.runtime_log_dir as runtimeLogDir,
360
+ wtr.execution_node_id as executionNodeId,
361
+ wtr.execution_workspace_path as executionWorkspacePath,
362
+ wtr.params_json as paramsJson,
363
+ wt.manifest_json as manifestJson,
364
+ wt.revision as currentRevision,
365
+ a.node_id as currentNodeId,
366
+ a.workspace_path as currentWorkspacePath
367
+ FROM workspace_tool_runs wtr
368
+ JOIN workspace_tools wt ON wt.tool_id = wtr.tool_id
369
+ LEFT JOIN agents a ON a.agent_id = wt.agent_id`;
370
+ const expiredRuns = this.db.prepare(`${cleanupRunSelect}
371
+ WHERE wtr.started_at < ?
372
+ AND wtr.status != 'running'`).all(cutoff);
373
+ const overflowRuns = this.db.prepare(`WITH ranked_runs AS (
374
+ SELECT run_id,
375
+ ROW_NUMBER() OVER (
376
+ PARTITION BY tool_id
377
+ ORDER BY started_at DESC, run_id DESC
378
+ ) as rank
379
+ FROM workspace_tool_runs
380
+ WHERE status != 'running'
381
+ )
382
+ ${cleanupRunSelect}
383
+ JOIN ranked_runs rr ON rr.run_id = wtr.run_id
384
+ WHERE rr.rank > ?`).all(WORKSPACE_TOOL_RUN_HISTORY_MAX_STORED_PER_TOOL);
385
+ const cleanupRunById = new Map();
386
+ for (const run of [...expiredRuns, ...overflowRuns]) {
387
+ cleanupRunById.set(run.runId, run);
388
+ }
389
+ const cleanupRuns = [...cleanupRunById.values()];
390
+ const skippedRunningRuns = this.db.prepare(`SELECT COUNT(*) as count
391
+ FROM workspace_tool_runs
392
+ WHERE started_at < ?
393
+ AND status = 'running'`).get(cutoff)?.count ?? 0;
394
+ let deletedRuntimeFiles = 0;
395
+ let deletedUploadFiles = 0;
396
+ let rotatedRuntimeLogs = 0;
397
+ const rotatedRuntimeKeys = new Set();
398
+ const activeRuntimeKeys = this.loadActiveRuntimeLogKeys();
399
+ const deletableRunIds = [];
400
+ for (const run of cleanupRuns) {
401
+ deletableRunIds.push(run.runId);
402
+ const targetNodeId = run.executionNodeId?.trim()
403
+ || (run.toolRevision === run.currentRevision ? run.currentNodeId?.trim() : null);
404
+ const targetWorkspacePath = run.executionWorkspacePath?.trim()
405
+ || (run.toolRevision === run.currentRevision ? run.currentWorkspacePath?.trim() : null);
406
+ if (targetNodeId && targetWorkspacePath) {
407
+ for (const uploadPath of collectWorkspaceToolUploadPathsFromParamsJson(run.paramsJson)) {
408
+ if (await this.deleteWorkspaceToolUploadFileBestEffort(targetNodeId, targetWorkspacePath, uploadPath)) {
409
+ deletedUploadFiles += 1;
410
+ }
411
+ }
412
+ }
413
+ const logDir = run.runtimeLogDir?.trim()
414
+ ? normalizeRuntimeLogDirCandidate(run.runtimeLogDir)
415
+ : run.toolRevision === run.currentRevision
416
+ ? this.resolveRuntimeLogDir(run.toolId, run.manifestJson)
417
+ : null;
418
+ if (!logDir)
419
+ continue;
420
+ if (!targetNodeId || !targetWorkspacePath)
421
+ continue;
422
+ if (await this.deleteRuntimeLogFileBestEffort(targetNodeId, targetWorkspacePath, path.posix.join(logDir, 'runs', `${run.runId}.jsonl`))) {
423
+ deletedRuntimeFiles += 1;
424
+ }
425
+ const runtimeKey = `${targetNodeId}\0${targetWorkspacePath}\0${logDir}`;
426
+ if (!activeRuntimeKeys.has(runtimeKey) && !rotatedRuntimeKeys.has(runtimeKey)) {
427
+ rotatedRuntimeKeys.add(runtimeKey);
428
+ if (await this.rotateRuntimeLifecycleLogBestEffort(targetNodeId, targetWorkspacePath, path.posix.join(logDir, 'runtime.jsonl'), cutoff)) {
429
+ rotatedRuntimeLogs += 1;
430
+ }
431
+ }
432
+ }
433
+ let deletedEvents = 0;
434
+ let deletedArtifacts = 0;
435
+ if (deletableRunIds.length > 0) {
436
+ const placeholders = deletableRunIds.map(() => '?').join(', ');
437
+ deletedArtifacts = this.db.prepare(`SELECT COUNT(*) as count
438
+ FROM workspace_tool_run_artifacts
439
+ WHERE run_id IN (${placeholders})`).get(...deletableRunIds)?.count ?? 0;
440
+ deletedEvents = this.db.prepare(`SELECT COUNT(*) as count
441
+ FROM workspace_tool_run_events
442
+ WHERE run_id IN (${placeholders})`).get(...deletableRunIds)?.count ?? 0;
443
+ const tx = this.db.transaction(() => {
444
+ this.db.prepare(`DELETE FROM workspace_tool_run_artifacts
445
+ WHERE run_id IN (${placeholders})`).run(...deletableRunIds);
446
+ this.db.prepare(`DELETE FROM workspace_tool_run_events
447
+ WHERE run_id IN (${placeholders})`).run(...deletableRunIds);
448
+ this.db.prepare(`DELETE FROM workspace_tool_runs
449
+ WHERE run_id IN (${placeholders})`).run(...deletableRunIds);
450
+ });
451
+ tx();
452
+ }
453
+ const result = {
454
+ deletedRuns: deletableRunIds.length,
455
+ deletedEvents,
456
+ deletedArtifacts,
457
+ deletedRuntimeFiles,
458
+ deletedUploadFiles,
459
+ rotatedRuntimeLogs,
460
+ skippedRunningRuns,
461
+ };
462
+ if (deletedArtifacts > 0 || deletedEvents > 0 || cleanupRuns.length > 0 || deletedRuntimeFiles > 0 || deletedUploadFiles > 0 || rotatedRuntimeLogs > 0 || skippedRunningRuns > 0) {
463
+ log.info('[workspace-tools] log retention cleanup complete', result);
464
+ }
465
+ return result;
466
+ }
467
+ loadActiveRuntimeLogKeys() {
468
+ const rows = this.db.prepare(`SELECT wt.tool_id as toolId,
469
+ wt.manifest_json as manifestJson,
470
+ a.node_id as nodeId,
471
+ a.workspace_path as workspacePath
472
+ FROM workspace_tools wt
473
+ JOIN agents a ON a.agent_id = wt.agent_id
474
+ WHERE wt.active_terminal_id IS NOT NULL`).all();
475
+ const keys = new Set();
476
+ for (const row of rows) {
477
+ if (!row.nodeId || !row.workspacePath)
478
+ continue;
479
+ const logDir = this.resolveRuntimeLogDir(row.toolId, row.manifestJson);
480
+ if (!logDir)
481
+ continue;
482
+ keys.add(`${row.nodeId}\0${row.workspacePath}\0${logDir}`);
483
+ }
484
+ return keys;
485
+ }
486
+ resolveRuntimeLogDir(toolId, manifestJson) {
487
+ try {
488
+ const manifest = parseWorkspaceToolManifest(manifestJson);
489
+ if (manifest.runtime?.mode !== 'persistent_runtime')
490
+ return null;
491
+ const raw = manifest.runtime.logDir?.trim() || `.agent-tools-runtime/${toolId}`;
492
+ return normalizeRuntimeLogDirCandidate(raw);
493
+ }
494
+ catch {
495
+ return null;
496
+ }
497
+ }
498
+ async deleteRuntimeLogFileBestEffort(nodeId, workspaceRoot, relativePath) {
499
+ try {
500
+ await this.workspaceBroker.deleteFile(nodeId, workspaceRoot, relativePath);
501
+ return true;
502
+ }
503
+ catch (error) {
504
+ if (!isWorkspaceFileMissingError(error)) {
505
+ log.warn('[workspace-tools] runtime log file cleanup failed', {
506
+ nodeId,
507
+ relativePath,
508
+ error: String(error?.message ?? error),
509
+ });
510
+ }
511
+ return false;
512
+ }
513
+ }
514
+ async deleteWorkspaceToolUploadFileBestEffort(nodeId, workspaceRoot, relativePath) {
515
+ try {
516
+ await this.workspaceBroker.deleteFile(nodeId, workspaceRoot, relativePath);
517
+ return true;
518
+ }
519
+ catch (error) {
520
+ if (!isWorkspaceFileMissingError(error)) {
521
+ log.warn('[workspace-tools] upload staging file cleanup failed', {
522
+ nodeId,
523
+ relativePath,
524
+ error: String(error?.message ?? error),
525
+ });
526
+ }
527
+ return false;
528
+ }
529
+ }
530
+ async deleteRuntimeLogDirBestEffort(nodeId, workspaceRoot, relativePath) {
531
+ try {
532
+ await this.workspaceBroker.deletePath(nodeId, workspaceRoot, relativePath, { recursive: true });
533
+ return true;
534
+ }
535
+ catch (error) {
536
+ if (!isWorkspaceFileMissingError(error)) {
537
+ log.warn('[workspace-tools] runtime log directory cleanup failed', {
538
+ nodeId,
539
+ relativePath,
540
+ error: String(error?.message ?? error),
541
+ });
542
+ }
543
+ return false;
544
+ }
545
+ }
546
+ async cleanupDeletedToolWorkspaceFilesBestEffort(tool, options = {}) {
547
+ const runRows = this.db.prepare(`SELECT run_id as runId,
548
+ params_json as paramsJson,
549
+ runtime_log_dir as runtimeLogDir,
550
+ execution_node_id as executionNodeId,
551
+ execution_workspace_path as executionWorkspacePath
552
+ FROM workspace_tool_runs
553
+ WHERE tool_id = ?`).all(tool.toolId);
554
+ let deletedUploadFiles = 0;
555
+ let deletedRuntimeFiles = 0;
556
+ const runtimeDirs = new Map();
557
+ const defaultRuntimeLogDir = `.agent-tools-runtime/${tool.toolId}`;
558
+ const defaultTarget = tool.agentNodeId?.trim() && tool.agentWorkspacePath?.trim()
559
+ ? { nodeId: tool.agentNodeId.trim(), workspaceRoot: tool.agentWorkspacePath.trim() }
560
+ : null;
561
+ const manifestLogDir = this.resolveRuntimeLogDir(tool.toolId, tool.manifestJson);
562
+ if (!options.skipRuntimeLogCleanup && manifestLogDir === defaultRuntimeLogDir && defaultTarget) {
563
+ runtimeDirs.set(`${defaultTarget.nodeId}\0${defaultTarget.workspaceRoot}\0${manifestLogDir}`, {
564
+ ...defaultTarget,
565
+ relativePath: manifestLogDir,
566
+ });
567
+ }
568
+ for (const row of runRows) {
569
+ const target = row.executionNodeId?.trim() && row.executionWorkspacePath?.trim()
570
+ ? { nodeId: row.executionNodeId.trim(), workspaceRoot: row.executionWorkspacePath.trim() }
571
+ : defaultTarget;
572
+ if (!target)
573
+ continue;
574
+ for (const uploadPath of collectWorkspaceToolUploadPathsFromParamsJson(row.paramsJson)) {
575
+ if (await this.deleteWorkspaceToolUploadFileBestEffort(target.nodeId, target.workspaceRoot, uploadPath)) {
576
+ deletedUploadFiles += 1;
577
+ }
578
+ }
579
+ const runLogDir = row.runtimeLogDir?.trim()
580
+ ? normalizeRuntimeLogDirCandidate(row.runtimeLogDir)
581
+ : null;
582
+ if (runLogDir) {
583
+ if (!options.skipRuntimeLogCleanup) {
584
+ if (await this.deleteRuntimeLogFileBestEffort(target.nodeId, target.workspaceRoot, path.posix.join(runLogDir, 'runs', `${row.runId}.jsonl`))) {
585
+ deletedRuntimeFiles += 1;
586
+ }
587
+ if (runLogDir === defaultRuntimeLogDir) {
588
+ runtimeDirs.set(`${target.nodeId}\0${target.workspaceRoot}\0${runLogDir}`, {
589
+ ...target,
590
+ relativePath: runLogDir,
591
+ });
592
+ }
593
+ }
594
+ }
595
+ }
596
+ let deletedRuntimeDirs = 0;
597
+ for (const runtimeDir of runtimeDirs.values()) {
598
+ if (await this.deleteRuntimeLogDirBestEffort(runtimeDir.nodeId, runtimeDir.workspaceRoot, runtimeDir.relativePath)) {
599
+ deletedRuntimeDirs += 1;
600
+ }
601
+ }
602
+ if (deletedUploadFiles > 0 || deletedRuntimeFiles > 0 || deletedRuntimeDirs > 0) {
603
+ log.info('[workspace-tools] deleted tool workspace cleanup complete', {
604
+ toolId: tool.toolId,
605
+ deletedUploadFiles,
606
+ deletedRuntimeFiles,
607
+ deletedRuntimeDirs,
608
+ });
609
+ }
610
+ }
611
+ async rotateRuntimeLifecycleLogBestEffort(nodeId, workspaceRoot, relativePath, cutoff) {
612
+ try {
613
+ const content = await this.readWorkspaceTextFileInChunks(nodeId, workspaceRoot, relativePath);
614
+ if (!content)
615
+ return false;
616
+ const lines = content.split('\n').filter((line) => line.trim());
617
+ const kept = lines.filter((line) => {
618
+ const timestamp = extractRuntimeLogTimestampMs(line);
619
+ return timestamp === null || timestamp >= cutoff;
620
+ });
621
+ let nextLines = kept;
622
+ const removedOldLines = kept.length !== lines.length;
623
+ const oversized = content.length > WORKSPACE_TOOL_RUNTIME_LOG_MAX_BYTES_BEFORE_ROTATE;
624
+ if (oversized && nextLines.join('\n').length > WORKSPACE_TOOL_RUNTIME_LOG_MAX_BYTES_BEFORE_ROTATE) {
625
+ nextLines = nextLines.slice(-1000);
626
+ }
627
+ if (!removedOldLines && !oversized)
628
+ return false;
629
+ const nextContent = nextLines.length > 0 ? `${nextLines.join('\n')}\n` : '';
630
+ await this.workspaceBroker.writeFile(nodeId, workspaceRoot, relativePath, nextContent, 'overwrite', {
631
+ scaffold: false,
632
+ });
633
+ return true;
634
+ }
635
+ catch (error) {
636
+ if (!isWorkspaceFileMissingError(error)) {
637
+ log.warn('[workspace-tools] runtime lifecycle log rotation failed', {
638
+ nodeId,
639
+ relativePath,
640
+ error: String(error?.message ?? error),
641
+ });
642
+ }
643
+ return false;
644
+ }
645
+ }
646
+ async readWorkspaceTextFileInChunks(nodeId, workspaceRoot, relativePath) {
647
+ let offset = 0;
648
+ let content = '';
649
+ while (true) {
650
+ const chunk = await this.workspaceBroker.readFile(nodeId, workspaceRoot, relativePath, {
651
+ scaffold: false,
652
+ offset,
653
+ limit: 256 * 1024,
654
+ });
655
+ content += chunk.content;
656
+ if (!chunk.hasMore || chunk.nextOffset == null || chunk.nextOffset <= offset)
657
+ break;
658
+ offset = chunk.nextOffset;
659
+ }
660
+ return content;
661
+ }
662
+ async listToolsForUser(userId) {
663
+ return this.listToolsForViewer(userId);
664
+ }
665
+ async listToolsForViewer(userId) {
666
+ const rows = this.readModel.loadVisibleToolRowsForViewer(userId);
667
+ return rows.map((row) => this.readModel.rowToToolInfoForViewer(this.ensureReadableToolSnapshotPanel(row, userId), userId));
668
+ }
669
+ async getToolForUser(userId, toolId) {
670
+ return this.getToolForViewer(userId, toolId);
671
+ }
672
+ async getToolForViewer(userId, toolId) {
673
+ const row = this.readModel.loadToolRowForViewer(userId, toolId);
674
+ if (!row) {
675
+ throw new WorkspaceToolServiceError('Tool not found.', 404);
676
+ }
677
+ return this.readModel.rowToToolInfoForViewer(this.ensureReadableToolSnapshotPanel(row, userId), userId);
678
+ }
679
+ async getToolForViewerByPanelId(userId, panelId) {
680
+ const row = this.readModel.loadToolRowByPanelIdForViewer(userId, panelId);
681
+ if (!row) {
682
+ throw new WorkspaceToolServiceError('Tool not found.', 404);
683
+ }
684
+ return this.readModel.rowToToolInfoForViewer(this.ensureReadableToolSnapshotPanel(row, userId), userId);
685
+ }
686
+ async getToolMediaLookupForViewer(userId, toolId, options) {
687
+ const row = this.readModel.loadToolRowForViewer(userId, toolId, options);
688
+ if (!row) {
689
+ throw new WorkspaceToolServiceError('Tool not found.', 404);
690
+ }
691
+ return {
692
+ tool: this.readModel.rowToToolInfoForViewer(row, userId),
693
+ sourceBundleRoot: row.bundleRoot,
694
+ };
695
+ }
696
+ async listToolCollaboratorsForViewer(userId, toolId) {
697
+ const row = this.readModel.loadToolRowForViewer(userId, toolId);
698
+ if (!row) {
699
+ throw new WorkspaceToolServiceError('Tool not found.', 404);
700
+ }
701
+ ensureSurfaceOwner(this.db, {
702
+ surfaceType: 'tool',
703
+ surfaceId: row.toolId,
704
+ agentId: row.agentId,
705
+ createdAt: row.createdAt,
706
+ });
707
+ return {
708
+ toolId: row.toolId,
709
+ canManage: this.readModel.canManageToolCollaborators(userId, row),
710
+ collaborators: listSurfaceCollaborators(this.db, 'tool', row.toolId).map((collaborator) => {
711
+ const agent = this.conversationManager.getAgent(collaborator.agentId);
712
+ return {
713
+ agentId: collaborator.agentId,
714
+ name: agent?.name ?? null,
715
+ role: collaborator.role,
716
+ addedBy: collaborator.addedBy ?? null,
717
+ createdAt: new Date(collaborator.createdAt).toISOString(),
718
+ };
719
+ }),
720
+ };
721
+ }
722
+ async addToolCollaboratorForViewer(params) {
723
+ const row = this.readModel.loadToolRowForViewer(params.userId, params.toolId, { allowAdminBypass: true });
724
+ if (!row) {
725
+ throw new WorkspaceToolServiceError('Tool not found.', 404);
726
+ }
727
+ if (!this.readModel.canManageToolCollaborators(params.userId, row)) {
728
+ throw new WorkspaceToolServiceError('Access denied.', 403);
729
+ }
730
+ const collaboratorAgent = this.conversationManager.getAgent(params.collaboratorAgentId);
731
+ if (!collaboratorAgent || (collaboratorAgent.agentRole && collaboratorAgent.agentRole !== 'user')) {
732
+ throw new WorkspaceToolServiceError('Collaborator agent not found.', 404);
733
+ }
734
+ ensureSurfaceOwner(this.db, {
735
+ surfaceType: 'tool',
736
+ surfaceId: row.toolId,
737
+ agentId: row.agentId,
738
+ createdAt: row.createdAt,
739
+ });
740
+ if (collaboratorAgent.agentId === row.agentId) {
741
+ return this.listToolCollaboratorsForViewer(params.userId, row.toolId);
742
+ }
743
+ if (!this.readModel.isAdminUser(params.userId) && !getUserAgentAccess(this.db, params.userId).includes(collaboratorAgent.agentId)) {
744
+ throw new WorkspaceToolServiceError('Access denied.', 403);
745
+ }
746
+ upsertSurfaceCollaborator(this.db, {
747
+ surfaceType: 'tool',
748
+ surfaceId: row.toolId,
749
+ agentId: collaboratorAgent.agentId,
750
+ role: 'collaborator',
751
+ addedBy: `user:${params.userId}`,
752
+ });
753
+ return this.listToolCollaboratorsForViewer(params.userId, row.toolId);
754
+ }
755
+ async removeToolCollaboratorForViewer(params) {
756
+ const row = this.readModel.loadToolRowForViewer(params.userId, params.toolId, { allowAdminBypass: true });
757
+ if (!row) {
758
+ throw new WorkspaceToolServiceError('Tool not found.', 404);
759
+ }
760
+ if (!this.readModel.canManageToolCollaborators(params.userId, row)) {
761
+ throw new WorkspaceToolServiceError('Access denied.', 403);
762
+ }
763
+ ensureSurfaceOwner(this.db, {
764
+ surfaceType: 'tool',
765
+ surfaceId: row.toolId,
766
+ agentId: row.agentId,
767
+ createdAt: row.createdAt,
768
+ });
769
+ const role = getSurfaceCollaboratorRole(this.db, 'tool', row.toolId, params.collaboratorAgentId);
770
+ if (!role) {
771
+ throw new WorkspaceToolServiceError('Collaborator not found.', 404);
772
+ }
773
+ if (role === 'owner') {
774
+ throw new WorkspaceToolServiceError('Tool owner cannot be removed.', 400);
775
+ }
776
+ removeSurfaceCollaborator(this.db, 'tool', row.toolId, params.collaboratorAgentId);
777
+ return this.listToolCollaboratorsForViewer(params.userId, row.toolId);
778
+ }
779
+ resolvePanelPromotionStateForUser(params) {
780
+ const promotion = params.promotion ?? this.loadPanelToolPromotion(params.panel.id);
781
+ const eligible = this.isPanelEligibleForToolPromotion(params.userId, params.panel)
782
+ && !params.panel.archivedAt;
783
+ const directManifest = eligible && !promotion?.toolId
784
+ ? buildDirectPromotedToolManifest(params.panel)
785
+ : null;
786
+ let retryUnavailableReason = null;
787
+ if (isDeletedLinkedToolPromotionRetryable(promotion)) {
788
+ const sourceConversation = this.conversationManager.getConversation(params.panel.conversationId);
789
+ if (!sourceConversation?.agentId) {
790
+ retryUnavailableReason = 'Panel source conversation is unavailable.';
791
+ }
792
+ else {
793
+ const availability = getWorkspaceToolAgentAvailability(this.db, this.conversationManager.getAgent(sourceConversation.agentId));
794
+ if (!availability.allowed) {
795
+ retryUnavailableReason = availability.error;
796
+ }
797
+ }
798
+ }
799
+ const resolved = resolveWorkspaceToolPromotionState({
800
+ panelId: params.panel.id,
801
+ panelArchivedAt: params.panel.archivedAt ?? null,
802
+ eligible,
803
+ directPromotable: Boolean(directManifest),
804
+ promotion,
805
+ ineligibleReason: params.panel.archivedAt && !promotion
806
+ ? 'Panel is archived.'
807
+ : 'This panel cannot be upgraded to a tool.',
808
+ retryUnavailableReason,
809
+ });
810
+ return { promotion, directManifest, resolved };
811
+ }
812
+ getPanelPromotionInfoForUser(userId, panelId) {
813
+ const panel = getPanelById(this.db, panelId);
814
+ if (!panel) {
815
+ return null;
816
+ }
817
+ const state = this.resolvePanelPromotionStateForUser({ userId, panel });
818
+ if (state.resolved.state === 'ineligible' && !state.promotion) {
819
+ return null;
820
+ }
821
+ return {
822
+ panelId,
823
+ state: state.resolved.state,
824
+ stateReason: state.resolved.stateReason,
825
+ ctaLabel: state.resolved.ctaLabel,
826
+ disabledReason: state.resolved.disabledReason,
827
+ promotionConversationId: state.promotion?.promotionConversationId ?? null,
828
+ toolId: state.promotion?.toolId ?? null,
829
+ toolName: state.promotion?.toolName ?? null,
830
+ toolRevision: state.promotion?.toolRevision ?? null,
831
+ maintenanceConversationId: state.promotion?.maintenanceConversationId ?? null,
832
+ };
833
+ }
834
+ async promotePanelToTool(params) {
835
+ return this.mutationQueues.enqueuePanelPromotion(params.panelId, async () => {
836
+ const panel = getPanelById(this.db, params.panelId);
837
+ if (!panel) {
838
+ throw new WorkspaceToolServiceError('Panel not found.', 404);
839
+ }
840
+ const state = this.resolvePanelPromotionStateForUser({ userId: params.userId, panel });
841
+ const existingPromotion = state.promotion;
842
+ if (state.resolved.shouldOpenLinkedTool) {
843
+ const linkedTool = await this.openLinkedPanelPromotionTool(params.userId, existingPromotion);
844
+ if (linkedTool)
845
+ return linkedTool;
846
+ throw new WorkspaceToolServiceError('Linked Workspace Tool is unavailable.', 404);
847
+ }
848
+ if (state.resolved.shouldReturnPendingPromotion && existingPromotion) {
849
+ const promotionConversation = this.ensurePanelPromotionConversation({
850
+ panel,
851
+ userId: params.userId,
852
+ existingConversationId: existingPromotion.promotionConversationId,
853
+ });
854
+ if (!promotionConversation) {
855
+ throw new WorkspaceToolServiceError('Unable to open the panel promotion conversation.', 500);
856
+ }
857
+ if (existingPromotion.promotionConversationId !== promotionConversation.id) {
858
+ this.db.prepare(`UPDATE panel_tool_promotions
859
+ SET promotion_conversation_id = ?,
860
+ updated_at = ?
861
+ WHERE panel_id = ?`).run(promotionConversation.id, Date.now(), panel.id);
862
+ }
863
+ return {
864
+ kind: 'promotion',
865
+ conversation: promotionConversation,
866
+ queued: false,
867
+ };
868
+ }
869
+ if (state.resolved.state === 'ineligible') {
870
+ throw new WorkspaceToolServiceError(state.resolved.disabledReason
871
+ ?? (panel.archivedAt && !existingPromotion ? 'Panel is archived.' : 'This panel cannot be upgraded to a tool.'), panel.archivedAt && !existingPromotion ? 410 : 409);
872
+ }
873
+ const sourceConversation = this.conversationManager.getConversation(panel.conversationId);
874
+ if (!sourceConversation?.agentId) {
875
+ throw new WorkspaceToolServiceError('Panel source conversation is unavailable.', 409);
876
+ }
877
+ this.assertWorkspaceToolAgentAvailable(sourceConversation.agentId);
878
+ if (state.resolved.shouldCreateDirectTool && state.directManifest) {
879
+ return this.createDirectPromotedTool({
880
+ userId: params.userId,
881
+ panel,
882
+ agentId: sourceConversation.agentId,
883
+ manifest: state.directManifest,
884
+ existingPromotion,
885
+ });
886
+ }
887
+ const promotionConversation = this.ensurePanelPromotionConversation({
888
+ panel,
889
+ userId: params.userId,
890
+ activeProjectId: sourceConversation.activeProjectId ?? null,
891
+ existingConversationId: existingPromotion?.promotionConversationId ?? null,
892
+ });
893
+ if (!promotionConversation) {
894
+ throw new WorkspaceToolServiceError('Unable to open the panel promotion conversation.', 500);
895
+ }
896
+ const shouldRequeueDeletedPromotion = state.resolved.shouldRequeueDeletedPromotion;
897
+ if (existingPromotion) {
898
+ if (existingPromotion.promotionConversationId !== promotionConversation.id) {
899
+ this.db.prepare(`UPDATE panel_tool_promotions
900
+ SET promotion_conversation_id = ?,
901
+ updated_at = ?
902
+ WHERE panel_id = ?`).run(promotionConversation.id, shouldRequeueDeletedPromotion ? existingPromotion.updatedAt : Date.now(), panel.id);
903
+ }
904
+ if (state.resolved.shouldReturnPendingPromotion) {
905
+ return {
906
+ kind: 'promotion',
907
+ conversation: promotionConversation,
908
+ queued: false,
909
+ };
910
+ }
911
+ }
912
+ const username = this.readModel.loadUsername(params.userId);
913
+ if (!username) {
914
+ throw new WorkspaceToolServiceError('Unable to resolve the requesting user.', 500);
915
+ }
916
+ const now = Date.now();
917
+ if (!existingPromotion) {
918
+ this.db.prepare(`INSERT INTO panel_tool_promotions(
919
+ panel_id,
920
+ user_id,
921
+ agent_id,
922
+ promotion_conversation_id,
923
+ tool_id,
924
+ created_at,
925
+ updated_at
926
+ )
927
+ VALUES(?, ?, ?, ?, NULL, ?, ?)`).run(panel.id, params.userId, sourceConversation.agentId, promotionConversation.id, now, now);
928
+ }
929
+ const bodyText = this.buildPanelPromotionSlashBody(panel);
930
+ const rawText = `/tool ${bodyText}`;
931
+ try {
932
+ const result = await this.conversationManager.submitPrompt(promotionConversation.id, rawText, {
933
+ senderName: username,
934
+ clientMessageId: randomUUID(),
935
+ activationMetadata: {
936
+ slashCommand: {
937
+ name: 'tool',
938
+ bodyText,
939
+ },
940
+ },
941
+ allowActiveSteer: false,
942
+ });
943
+ if (shouldRequeueDeletedPromotion) {
944
+ this.db.prepare(`UPDATE panel_tool_promotions
945
+ SET tool_id = NULL,
946
+ updated_at = ?
947
+ WHERE panel_id = ?
948
+ AND tool_id = ?`).run(Date.now(), panel.id, existingPromotion?.linkedToolId ?? '');
949
+ }
950
+ return {
951
+ kind: 'promotion',
952
+ conversation: promotionConversation,
953
+ queued: result.queued,
954
+ ...(result.runId ? { runId: result.runId } : {}),
955
+ };
956
+ }
957
+ catch (error) {
958
+ if (!existingPromotion) {
959
+ this.db.prepare(`DELETE FROM panel_tool_promotions
960
+ WHERE panel_id = ?
961
+ AND tool_id IS NULL`).run(panel.id);
962
+ }
963
+ throw error;
964
+ }
965
+ });
966
+ }
967
+ async reopenLinkedPanelPromotionTool(params) {
968
+ return this.mutationQueues.enqueuePanelPromotion(params.panelId, async () => {
969
+ const panel = getPanelById(this.db, params.panelId);
970
+ if (!panel) {
971
+ throw new WorkspaceToolServiceError('Panel not found.', 404);
972
+ }
973
+ return this.openLinkedPanelPromotionTool(params.userId, this.loadPanelToolPromotion(panel.id));
974
+ });
975
+ }
976
+ async openLinkedPanelPromotionTool(userId, promotion) {
977
+ if (!promotion?.toolId)
978
+ return null;
979
+ const tool = await this.getToolForUser(userId, promotion.toolId);
980
+ const conversation = await this.getOrOpenMaintenanceConversation(userId, tool.toolId);
981
+ return {
982
+ kind: 'tool',
983
+ tool,
984
+ conversation,
985
+ };
986
+ }
987
+ async createDirectPromotedTool(params) {
988
+ const now = Date.now();
989
+ const toolId = randomUUID();
990
+ const maintenanceConversation = this.ensureMaintenanceConversation({
991
+ toolId,
992
+ agentId: params.agentId,
993
+ userId: params.userId,
994
+ });
995
+ if (!maintenanceConversation) {
996
+ throw new WorkspaceToolServiceError('Unable to open the tool maintenance conversation.', 500);
997
+ }
998
+ const agent = this.conversationManager.getAgent(params.agentId);
999
+ const staleCachedStoragePaths = [];
1000
+ const tx = this.db.transaction(() => {
1001
+ const snapshotSync = this.snapshotPanels.syncPanel({
1002
+ sourcePanelId: params.panel.id,
1003
+ toolId,
1004
+ userId: params.userId,
1005
+ agentId: params.agentId,
1006
+ conversationId: maintenanceConversation.id,
1007
+ nodeId: agent?.nodeId ?? null,
1008
+ manifest: params.manifest,
1009
+ latestState: null,
1010
+ allowSharedExec: false,
1011
+ status: 'idle',
1012
+ });
1013
+ staleCachedStoragePaths.push(...snapshotSync.staleCachedStoragePaths);
1014
+ this.db.prepare(`INSERT INTO workspace_tools(
1015
+ tool_id,
1016
+ user_id,
1017
+ agent_id,
1018
+ slug,
1019
+ name,
1020
+ description,
1021
+ icon,
1022
+ scope,
1023
+ scope_channel_id,
1024
+ allow_shared_exec,
1025
+ public_review_status,
1026
+ pending_public_review,
1027
+ manifest_path,
1028
+ bundle_root,
1029
+ source_panel_id,
1030
+ panel_id,
1031
+ manifest_json,
1032
+ latest_state_json,
1033
+ active_terminal_id,
1034
+ maintenance_conversation_id,
1035
+ source_conversation_id,
1036
+ source_message_id,
1037
+ revision,
1038
+ created_at,
1039
+ updated_at,
1040
+ deleted_at
1041
+ )
1042
+ VALUES(
1043
+ ?, ?, ?, ?, ?, ?, ?, 'private', NULL, 0, 'not_requested', 0,
1044
+ ?, ?, ?, ?, ?,
1045
+ NULL,
1046
+ NULL,
1047
+ ?, ?, NULL, 1, ?, ?,
1048
+ NULL
1049
+ )`).run(toolId, params.userId, params.agentId, params.manifest.slug, params.manifest.name, params.manifest.description, params.manifest.icon ?? null, `panel://${params.panel.id}`, '', params.panel.id, snapshotSync.panelId, JSON.stringify(withPersistedToolSettings(params.manifest, {
1050
+ scope: 'private',
1051
+ scopeChannelId: null,
1052
+ allowSharedExec: false,
1053
+ })), maintenanceConversation.id, params.panel.conversationId, now, now);
1054
+ ensureSurfaceOwner(this.db, {
1055
+ surfaceType: 'tool',
1056
+ surfaceId: toolId,
1057
+ agentId: params.agentId,
1058
+ createdAt: now,
1059
+ });
1060
+ this.copyPanelMaintainersToTool({
1061
+ panelId: params.panel.id,
1062
+ toolId,
1063
+ executionAgentId: params.agentId,
1064
+ createdAt: now,
1065
+ });
1066
+ if (params.existingPromotion) {
1067
+ this.db.prepare(`UPDATE panel_tool_promotions
1068
+ SET promotion_conversation_id = ?,
1069
+ tool_id = ?,
1070
+ updated_at = ?
1071
+ WHERE panel_id = ?`).run(maintenanceConversation.id, toolId, now, params.panel.id);
1072
+ }
1073
+ else {
1074
+ this.db.prepare(`INSERT INTO panel_tool_promotions(
1075
+ panel_id,
1076
+ user_id,
1077
+ agent_id,
1078
+ promotion_conversation_id,
1079
+ tool_id,
1080
+ created_at,
1081
+ updated_at
1082
+ )
1083
+ VALUES(?, ?, ?, ?, ?, ?, ?)`).run(params.panel.id, params.userId, params.agentId, maintenanceConversation.id, toolId, now, now);
1084
+ }
1085
+ });
1086
+ tx();
1087
+ unlinkPanelAttachmentStoragePaths(staleCachedStoragePaths);
1088
+ archivePanelWithArtifacts(this.db, {
1089
+ panelId: params.panel.id,
1090
+ now: Date.now(),
1091
+ reason: 'promoted_to_tool',
1092
+ });
1093
+ return {
1094
+ kind: 'tool',
1095
+ tool: await this.getToolForUser(params.userId, toolId),
1096
+ conversation: maintenanceConversation,
1097
+ };
1098
+ }
1099
+ async listRunsForUser(userId, toolId) {
1100
+ return this.listRunsForViewer(userId, toolId);
1101
+ }
1102
+ async listRunsForViewer(userId, toolId, options = {}) {
1103
+ const tool = this.readModel.loadToolRowForViewer(userId, toolId, { allowAdminBypass: true });
1104
+ if (!tool) {
1105
+ throw new WorkspaceToolServiceError('Tool not found.', 404);
1106
+ }
1107
+ const manifest = parseWorkspaceToolManifest(tool.manifestJson);
1108
+ const actionKindById = new Map(manifest.actions.map((action) => [action.actionId, action.kind]));
1109
+ const limit = normalizeOptionalBoundedPositiveInteger(options.limit, WORKSPACE_TOOL_RUN_LIST_MAX_LIMIT);
1110
+ const queryArgs = [toolId];
1111
+ if (limit != null)
1112
+ queryArgs.push(limit);
1113
+ const rows = this.db.prepare(`SELECT run_id as runId,
1114
+ wtr.tool_id as toolId,
1115
+ tool_revision as toolRevision,
1116
+ action_id as actionId,
1117
+ terminal_id as terminalId,
1118
+ status,
1119
+ params_json as paramsJson,
1120
+ exit_code as exitCode,
1121
+ signal,
1122
+ started_at as startedAt,
1123
+ ended_at as endedAt,
1124
+ requested_by_user_id as requestedByUserId,
1125
+ u.username as requestedByUsername,
1126
+ requested_by_agent_id as requestedByAgentId,
1127
+ a.name as requestedByAgentName,
1128
+ execution_target_kind as executionTargetKind,
1129
+ execution_agent_id as executionAgentId,
1130
+ execution_node_id as executionNodeId,
1131
+ execution_hostname as executionHostname,
1132
+ execution_workspace_path as executionWorkspacePath
1133
+ FROM workspace_tool_runs wtr
1134
+ LEFT JOIN users u ON u.id = wtr.requested_by_user_id
1135
+ LEFT JOIN agents a ON a.agent_id = wtr.requested_by_agent_id
1136
+ WHERE wtr.tool_id = ?
1137
+ ORDER BY started_at DESC
1138
+ ${limit != null ? 'LIMIT ?' : ''}`).all(...queryArgs);
1139
+ const canViewOwnerFields = userId === tool.userId || this.readModel.isAdminUser(userId);
1140
+ const visibleRunIds = rows
1141
+ .filter((row) => canViewOwnerFields || userId === (row.requestedByUserId ?? null))
1142
+ .map((row) => row.runId);
1143
+ const eventsByRunId = options.includeEvents === false
1144
+ ? new Map()
1145
+ : this.loadRunEventsByRunId(visibleRunIds, options.eventLimit);
1146
+ return rows.map((row) => ({
1147
+ ...workspaceToolRunRowToInfoForViewer(row, actionKindById.get(row.actionId), {
1148
+ viewerUserId: userId,
1149
+ ownerUserId: tool.userId,
1150
+ canViewOwnerFields,
1151
+ }),
1152
+ ...(options.includeEvents === false ? {} : { events: eventsByRunId.get(row.runId) ?? [] }),
1153
+ }));
1154
+ }
1155
+ async listRunEventsForViewer(params) {
1156
+ this.loadRunForEventViewer(params.userId, params.toolId, params.runId);
1157
+ const limit = normalizeBoundedPositiveInteger(params.options?.limit, WORKSPACE_TOOL_RUN_EVENT_DEFAULT_LIMIT, WORKSPACE_TOOL_RUN_EVENT_MAX_LIMIT);
1158
+ const beforeSequence = normalizeBeforeSequence(params.options?.beforeSequence);
1159
+ const levelFilters = normalizeStringFilters(params.options?.levels);
1160
+ const args = [params.runId];
1161
+ const filters = ['run_id = ?'];
1162
+ if (beforeSequence != null) {
1163
+ filters.push('sequence < ?');
1164
+ args.push(beforeSequence);
1165
+ }
1166
+ if (levelFilters.length > 0) {
1167
+ const placeholders = levelFilters.map(() => '?').join(', ');
1168
+ filters.push(`(LOWER(COALESCE(level, '')) IN (${placeholders}) OR LOWER(event_type) IN (${placeholders}))`);
1169
+ args.push(...levelFilters, ...levelFilters);
1170
+ }
1171
+ args.push(limit + 1);
1172
+ const rows = this.db.prepare(`SELECT event_id as eventId,
1173
+ run_id as runId,
1174
+ tool_id as toolId,
1175
+ sequence,
1176
+ event_type as eventType,
1177
+ level,
1178
+ summary,
1179
+ payload_json as payloadJson,
1180
+ artifacts_json as artifactsJson,
1181
+ created_at as createdAt
1182
+ FROM workspace_tool_run_events
1183
+ WHERE ${filters.join(' AND ')}
1184
+ ORDER BY sequence DESC
1185
+ LIMIT ?`).all(...args);
1186
+ const pageRows = rows.slice(0, limit);
1187
+ const nextBeforeSequence = rows.length > limit && pageRows.length > 0
1188
+ ? pageRows[pageRows.length - 1].sequence
1189
+ : null;
1190
+ return {
1191
+ events: pageRows.reverse().map(runEventRowToInfo),
1192
+ nextBeforeSequence,
1193
+ };
1194
+ }
1195
+ async listRunArtifactsForViewer(params) {
1196
+ this.loadRunForEventViewer(params.userId, params.toolId, params.runId);
1197
+ const limit = normalizeBoundedPositiveInteger(params.options?.limit, WORKSPACE_TOOL_RUN_ARTIFACT_DEFAULT_LIMIT, WORKSPACE_TOOL_RUN_ARTIFACT_MAX_LIMIT);
1198
+ const beforeSequence = normalizeBeforeSequence(params.options?.beforeSequence);
1199
+ const kindFilters = new Set(normalizeStringFilters(params.options?.kinds));
1200
+ const artifacts = [];
1201
+ let scannedEvents = 0;
1202
+ let exhausted = false;
1203
+ let cursor = beforeSequence;
1204
+ let deferredBeforeSequence = null;
1205
+ let lastIncludedSequence = null;
1206
+ scanLoop: while (artifacts.length < limit && scannedEvents < WORKSPACE_TOOL_RUN_ARTIFACT_MAX_SCANNED_EVENTS) {
1207
+ const args = [params.runId];
1208
+ const filters = [
1209
+ 'run_id = ?',
1210
+ 'artifacts_json IS NOT NULL',
1211
+ "artifacts_json != ''",
1212
+ ];
1213
+ if (cursor != null) {
1214
+ filters.push('sequence < ?');
1215
+ args.push(cursor);
1216
+ }
1217
+ const batchLimit = Math.min(WORKSPACE_TOOL_RUN_ARTIFACT_SCAN_BATCH, WORKSPACE_TOOL_RUN_ARTIFACT_MAX_SCANNED_EVENTS - scannedEvents);
1218
+ args.push(batchLimit);
1219
+ const rows = this.db.prepare(`SELECT event_id as eventId,
1220
+ run_id as runId,
1221
+ tool_id as toolId,
1222
+ sequence,
1223
+ event_type as eventType,
1224
+ level,
1225
+ summary,
1226
+ payload_json as payloadJson,
1227
+ artifacts_json as artifactsJson,
1228
+ created_at as createdAt
1229
+ FROM workspace_tool_run_events
1230
+ WHERE ${filters.join(' AND ')}
1231
+ ORDER BY sequence DESC
1232
+ LIMIT ?`).all(...args);
1233
+ scannedEvents += rows.length;
1234
+ if (rows.length === 0) {
1235
+ exhausted = true;
1236
+ break;
1237
+ }
1238
+ cursor = rows[rows.length - 1].sequence;
1239
+ let processedRows = 0;
1240
+ for (const row of rows) {
1241
+ const refs = parseWorkspaceToolRunArtifactRefs(row.artifactsJson) ?? [];
1242
+ const matchingRefs = kindFilters.size === 0
1243
+ ? refs
1244
+ : refs.filter((artifact) => kindFilters.has((artifact.mimeType ?? '').trim().toLowerCase()));
1245
+ if (matchingRefs.length === 0) {
1246
+ processedRows += 1;
1247
+ continue;
1248
+ }
1249
+ if (artifacts.length > 0 && artifacts.length + matchingRefs.length > limit) {
1250
+ deferredBeforeSequence = row.sequence + 1;
1251
+ break scanLoop;
1252
+ }
1253
+ for (const artifact of matchingRefs) {
1254
+ artifacts.push({
1255
+ eventId: row.eventId,
1256
+ eventSequence: row.sequence,
1257
+ eventCreatedAt: row.createdAt,
1258
+ eventType: row.eventType,
1259
+ eventSummary: row.summary ?? null,
1260
+ artifact,
1261
+ });
1262
+ }
1263
+ lastIncludedSequence = row.sequence;
1264
+ processedRows += 1;
1265
+ if (artifacts.length >= limit)
1266
+ break;
1267
+ }
1268
+ if (rows.length < batchLimit) {
1269
+ exhausted = processedRows === rows.length;
1270
+ break;
1271
+ }
1272
+ if (artifacts.length >= limit)
1273
+ break;
1274
+ }
1275
+ return {
1276
+ artifacts,
1277
+ nextBeforeSequence: exhausted ? null : deferredBeforeSequence ?? lastIncludedSequence ?? cursor,
1278
+ };
1279
+ }
1280
+ async getRunArtifactForViewer(params) {
1281
+ const toolRow = this.readModel.loadToolRowForViewer(params.userId, params.toolId, { allowAdminBypass: true });
1282
+ if (!toolRow) {
1283
+ throw new WorkspaceToolServiceError('Tool not found.', 404);
1284
+ }
1285
+ const runRow = this.db.prepare(`SELECT run_id as runId,
1286
+ tool_id as toolId,
1287
+ action_id as actionId,
1288
+ requested_by_user_id as requestedByUserId,
1289
+ requested_by_agent_id as requestedByAgentId,
1290
+ tool_revision as toolRevision,
1291
+ source_bundle_root as sourceBundleRoot,
1292
+ action_cwd_absolute as actionCwdAbsolute
1293
+ FROM workspace_tool_runs
1294
+ WHERE tool_id = ?
1295
+ AND run_id = ?
1296
+ LIMIT 1`).get(params.toolId, params.runId);
1297
+ if (!runRow) {
1298
+ throw new WorkspaceToolServiceError('Tool run not found.', 404);
1299
+ }
1300
+ const canReadRunEvents = params.userId === toolRow.userId
1301
+ || this.readModel.isAdminUser(params.userId)
1302
+ || params.userId === (runRow.requestedByUserId ?? null);
1303
+ if (!canReadRunEvents) {
1304
+ throw new WorkspaceToolServiceError('Tool run artifact not found.', 404);
1305
+ }
1306
+ const eventRow = this.db.prepare(`SELECT artifacts_json as artifactsJson
1307
+ FROM workspace_tool_run_events
1308
+ WHERE tool_id = ?
1309
+ AND run_id = ?
1310
+ AND event_id = ?
1311
+ LIMIT 1`).get(params.toolId, params.runId, params.eventId);
1312
+ const artifacts = parseWorkspaceToolRunArtifactRefs(eventRow?.artifactsJson ?? null) ?? [];
1313
+ const artifact = artifacts.find((candidate) => candidate.path === params.artifactPath.trim());
1314
+ if (!artifact) {
1315
+ throw new WorkspaceToolServiceError('Tool run artifact not found.', 404);
1316
+ }
1317
+ const manifest = parseWorkspaceToolManifest(toolRow.manifestJson);
1318
+ const action = manifest.actions.find((candidate) => candidate.actionId === runRow.actionId);
1319
+ const agent = this.conversationManager.getAgent(toolRow.agentId);
1320
+ const revision = runRow.toolRevision ?? toolRow.revision;
1321
+ const sourceBundleRoot = runRow.sourceBundleRoot?.trim() || toolRow.bundleRoot;
1322
+ const publishedBundleRoot = getPublishedWorkspaceToolBundleRoot(toolRow.toolId, revision);
1323
+ const actionCwdAbsolute = runRow.actionCwdAbsolute?.trim()
1324
+ || (action && agent?.workspacePath
1325
+ ? resolveToolCwd(agent.workspacePath, manifest, action, sourceBundleRoot, publishedBundleRoot)
1326
+ : null);
1327
+ const tool = this.readModel.rowToToolInfoForViewer(toolRow, params.userId);
1328
+ return {
1329
+ tool,
1330
+ artifact,
1331
+ actionCwdAbsolute,
1332
+ sourceBundleRoot,
1333
+ revision,
1334
+ includeWorkspaceFallback: params.userId === toolRow.userId || this.readModel.isAdminUser(params.userId),
1335
+ };
1336
+ }
1337
+ loadRunEventsByRunId(runIds, eventLimit) {
1338
+ if (runIds.length === 0)
1339
+ return new Map();
1340
+ const limit = normalizeOptionalBoundedPositiveInteger(eventLimit, WORKSPACE_TOOL_RUN_EVENT_MAX_LIMIT);
1341
+ if (limit != null) {
1342
+ const eventsByRunId = new Map();
1343
+ for (const runId of runIds) {
1344
+ const rows = this.db.prepare(`SELECT event_id as eventId,
1345
+ run_id as runId,
1346
+ tool_id as toolId,
1347
+ sequence,
1348
+ event_type as eventType,
1349
+ level,
1350
+ summary,
1351
+ payload_json as payloadJson,
1352
+ artifacts_json as artifactsJson,
1353
+ created_at as createdAt
1354
+ FROM workspace_tool_run_events
1355
+ WHERE run_id = ?
1356
+ ORDER BY sequence DESC
1357
+ LIMIT ?`).all(runId, limit);
1358
+ eventsByRunId.set(runId, rows.reverse().map(runEventRowToInfo));
1359
+ }
1360
+ return eventsByRunId;
1361
+ }
1362
+ const placeholders = runIds.map(() => '?').join(', ');
1363
+ const rows = this.db.prepare(`SELECT event_id as eventId,
1364
+ run_id as runId,
1365
+ tool_id as toolId,
1366
+ sequence,
1367
+ event_type as eventType,
1368
+ level,
1369
+ summary,
1370
+ payload_json as payloadJson,
1371
+ artifacts_json as artifactsJson,
1372
+ created_at as createdAt
1373
+ FROM workspace_tool_run_events
1374
+ WHERE run_id IN (${placeholders})
1375
+ ORDER BY run_id ASC, sequence ASC`).all(...runIds);
1376
+ const eventsByRunId = new Map();
1377
+ for (const row of rows) {
1378
+ const events = eventsByRunId.get(row.runId) ?? [];
1379
+ events.push(runEventRowToInfo(row));
1380
+ eventsByRunId.set(row.runId, events);
1381
+ }
1382
+ return eventsByRunId;
1383
+ }
1384
+ loadRunForEventViewer(userId, toolId, runId) {
1385
+ const tool = this.readModel.loadToolRowForViewer(userId, toolId, { allowAdminBypass: true });
1386
+ if (!tool) {
1387
+ throw new WorkspaceToolServiceError('Tool not found.', 404);
1388
+ }
1389
+ const run = this.db.prepare(`SELECT run_id as runId,
1390
+ tool_id as toolId,
1391
+ action_id as actionId,
1392
+ terminal_id as terminalId,
1393
+ status,
1394
+ params_json as paramsJson,
1395
+ exit_code as exitCode,
1396
+ signal,
1397
+ started_at as startedAt,
1398
+ ended_at as endedAt,
1399
+ requested_by_user_id as requestedByUserId,
1400
+ requested_by_agent_id as requestedByAgentId,
1401
+ tool_revision as toolRevision,
1402
+ source_bundle_root as sourceBundleRoot,
1403
+ action_cwd_absolute as actionCwdAbsolute,
1404
+ runtime_log_dir as runtimeLogDir,
1405
+ execution_target_kind as executionTargetKind,
1406
+ execution_agent_id as executionAgentId,
1407
+ execution_node_id as executionNodeId,
1408
+ execution_hostname as executionHostname,
1409
+ execution_workspace_path as executionWorkspacePath
1410
+ FROM workspace_tool_runs
1411
+ WHERE tool_id = ?
1412
+ AND run_id = ?
1413
+ LIMIT 1`).get(toolId, runId);
1414
+ if (!run) {
1415
+ throw new WorkspaceToolServiceError('Tool run not found.', 404);
1416
+ }
1417
+ const canViewOwnerFields = userId === tool.userId || this.readModel.isAdminUser(userId);
1418
+ if (!canViewOwnerFields && userId !== (run.requestedByUserId ?? null)) {
1419
+ throw new WorkspaceToolServiceError('Tool run not found.', 404);
1420
+ }
1421
+ return run;
1422
+ }
1423
+ async getOrOpenMaintenanceConversation(userId, toolId) {
1424
+ const tool = this.readModel.loadToolRowForUser(userId, toolId);
1425
+ if (!tool) {
1426
+ throw new WorkspaceToolServiceError('Tool not found.', 404);
1427
+ }
1428
+ const conversation = this.ensureMaintenanceConversation(tool);
1429
+ if (!conversation) {
1430
+ throw new WorkspaceToolServiceError('Unable to open maintenance conversation.', 500);
1431
+ }
1432
+ if (conversation.id !== tool.maintenanceConversationId) {
1433
+ this.db.prepare(`UPDATE workspace_tools
1434
+ SET maintenance_conversation_id = ?,
1435
+ updated_at = ?
1436
+ WHERE tool_id = ?`).run(conversation.id, Date.now(), tool.toolId);
1437
+ }
1438
+ if (!tool.panelId || conversation.id !== tool.maintenanceConversationId) {
1439
+ this.snapshotPanels.syncForTool(tool.toolId);
1440
+ }
1441
+ return conversation;
1442
+ }
1443
+ async getMaintenanceConversationForViewer(userId, toolId) {
1444
+ const tool = this.readModel.loadToolRowForViewer(userId, toolId);
1445
+ if (!tool) {
1446
+ throw new WorkspaceToolServiceError('Tool not found.', 404);
1447
+ }
1448
+ if (tool.userId !== userId) {
1449
+ throw new WorkspaceToolServiceError('Tool maintenance chat is only available to the tool owner.', 403);
1450
+ }
1451
+ return this.getOrOpenMaintenanceConversation(userId, toolId);
1452
+ }
1453
+ async publishTool(params) {
1454
+ if (params.dryRun) {
1455
+ const dryRunResult = await this.performDryRunValidation(params);
1456
+ return dryRunResult;
1457
+ }
1458
+ const agent = this.conversationManager.getAgent(params.agentId);
1459
+ if (!agent?.nodeId || !agent.workspacePath) {
1460
+ throw new WorkspaceToolServiceError('Agent workspace is unavailable for publishing.', 409);
1461
+ }
1462
+ const agentNodeId = agent.nodeId;
1463
+ const agentWorkspacePath = agent.workspacePath;
1464
+ const normalizedManifestPath = normalizeWorkspaceRelativePath(agentWorkspacePath, params.manifestPath);
1465
+ const manifestSource = typeof params.manifestContent === 'string' && params.manifestContent.trim()
1466
+ ? params.manifestContent
1467
+ : (await this.workspaceBroker.readFile(agentNodeId, agentWorkspacePath, normalizedManifestPath.relativePath, { scaffold: false })).content;
1468
+ const manifest = parseWorkspaceToolManifest(manifestSource);
1469
+ validateManifestConsistency(agentWorkspacePath, normalizedManifestPath.relativePath, manifest);
1470
+ validateManifestCwds(agentWorkspacePath, manifest, path.posix.dirname(normalizedManifestPath.relativePath));
1471
+ const diagnostics = buildWorkspaceToolPublishDiagnostics(manifest);
1472
+ return this.mutationQueues.enqueuePublish(`${params.userId}:${params.agentId}:${manifest.slug}`, async () => {
1473
+ const identity = this.publishIdentityResolver.resolve({
1474
+ userId: params.userId,
1475
+ agentId: params.agentId,
1476
+ slug: manifest.slug,
1477
+ sourceConversationId: params.sourceConversationId,
1478
+ allowCreateNewTool: params.allowCreateNewTool,
1479
+ });
1480
+ if (identity.loadConflict) {
1481
+ throw new WorkspaceToolServiceError(identity.loadConflict.message, identity.loadConflict.statusCode);
1482
+ }
1483
+ // Keep source-panel ownership conflicts inside the per-tool queue; another
1484
+ // queued republish can change the source-panel identity before mutation.
1485
+ if (identity.publishState.conflict?.kind === 'different_live_tool_slug') {
1486
+ throw new WorkspaceToolServiceError(identity.publishState.conflict.message, identity.publishState.conflict.statusCode);
1487
+ }
1488
+ if (identity.publishState.queueToolId) {
1489
+ return this.mutationQueues.enqueueTool(identity.publishState.queueToolId, async () => this.publishToolMutation({
1490
+ params,
1491
+ manifest,
1492
+ diagnostics,
1493
+ agentNodeId,
1494
+ agentWorkspacePath,
1495
+ }));
1496
+ }
1497
+ return this.publishToolMutation({
1498
+ params,
1499
+ manifest,
1500
+ diagnostics,
1501
+ agentNodeId,
1502
+ agentWorkspacePath,
1503
+ });
1504
+ });
1505
+ }
1506
+ async performDryRunValidation(params) {
1507
+ const agent = this.conversationManager.getAgent(params.agentId);
1508
+ if (!agent?.nodeId || !agent.workspacePath) {
1509
+ return {
1510
+ valid: false,
1511
+ diagnostics: [{
1512
+ severity: 'error',
1513
+ code: 'agent_workspace_unavailable',
1514
+ message: 'Agent workspace is unavailable for publishing.',
1515
+ }],
1516
+ };
1517
+ }
1518
+ const agentNodeId = agent.nodeId;
1519
+ const agentWorkspacePath = agent.workspacePath;
1520
+ let normalizedManifestPath;
1521
+ try {
1522
+ normalizedManifestPath = normalizeWorkspaceRelativePath(agentWorkspacePath, params.manifestPath);
1523
+ }
1524
+ catch (error) {
1525
+ return {
1526
+ valid: false,
1527
+ diagnostics: [{
1528
+ severity: 'error',
1529
+ code: 'manifest_path_invalid',
1530
+ message: String(error?.message ?? error),
1531
+ }],
1532
+ };
1533
+ }
1534
+ let manifestSource;
1535
+ try {
1536
+ manifestSource = typeof params.manifestContent === 'string' && params.manifestContent.trim()
1537
+ ? params.manifestContent
1538
+ : (await this.workspaceBroker.readFile(agentNodeId, agentWorkspacePath, normalizedManifestPath.relativePath, { scaffold: false })).content;
1539
+ }
1540
+ catch (error) {
1541
+ return {
1542
+ valid: false,
1543
+ diagnostics: [{
1544
+ severity: 'error',
1545
+ code: 'manifest_read_failed',
1546
+ message: String(error?.message ?? error),
1547
+ }],
1548
+ };
1549
+ }
1550
+ try {
1551
+ const manifest = parseWorkspaceToolManifest(manifestSource);
1552
+ validateManifestConsistency(agentWorkspacePath, normalizedManifestPath.relativePath, manifest);
1553
+ validateManifestCwds(agentWorkspacePath, manifest, path.posix.dirname(normalizedManifestPath.relativePath));
1554
+ const diagnostics = buildWorkspaceToolPublishDiagnostics(manifest);
1555
+ return {
1556
+ valid: !diagnostics.some((diagnostic) => diagnostic.severity === 'error'),
1557
+ diagnostics,
1558
+ manifest,
1559
+ };
1560
+ }
1561
+ catch (error) {
1562
+ return {
1563
+ valid: false,
1564
+ diagnostics: [{
1565
+ severity: 'error',
1566
+ code: 'manifest_validation_failed',
1567
+ message: String(error?.message ?? error),
1568
+ }],
1569
+ };
1570
+ }
1571
+ }
1572
+ async cloneTool(params) {
1573
+ const parsed = workspaceToolCloneSchema.safeParse(params);
1574
+ if (!parsed.success) {
1575
+ throw new WorkspaceToolServiceError(`Invalid tool clone request: ${parsed.error.issues[0]?.message ?? 'unknown error'}`, 400);
1576
+ }
1577
+ const cloneParams = parsed.data;
1578
+ const sourceRow = this.readModel.loadToolRowForViewer(cloneParams.userId, cloneParams.toolId, { allowAdminBypass: true });
1579
+ if (!sourceRow) {
1580
+ throw new WorkspaceToolServiceError('Tool not found.', 404);
1581
+ }
1582
+ const isAdmin = this.readModel.isAdminUser(cloneParams.userId);
1583
+ if (sourceRow.userId !== cloneParams.userId && !isAdmin) {
1584
+ throw new WorkspaceToolServiceError('Only the tool owner can install this tool on another agent.', 403);
1585
+ }
1586
+ if (!sourceRow.agentNodeId || !sourceRow.agentWorkspacePath) {
1587
+ throw new WorkspaceToolServiceError('Source tool workspace is unavailable for cloning.', 409);
1588
+ }
1589
+ if (sourceRow.activeTerminalId) {
1590
+ throw new WorkspaceToolServiceError('Source tool is currently running. Stop it before installing a copy on another agent.', 409);
1591
+ }
1592
+ const currentRevisionRun = this.db.prepare(`SELECT run_id as runId
1593
+ FROM workspace_tool_runs
1594
+ WHERE tool_id = ?
1595
+ AND (
1596
+ tool_revision = ?
1597
+ OR (tool_revision IS NULL AND started_at >= ?)
1598
+ )
1599
+ LIMIT 1`).get(sourceRow.toolId, sourceRow.revision, sourceRow.updatedAt);
1600
+ if (currentRevisionRun) {
1601
+ throw new WorkspaceToolServiceError('Source tool current revision has run history. Republish the tool to create a clean revision before installing a copy on another agent.', 409);
1602
+ }
1603
+ const targetAgent = this.conversationManager.getAgent(cloneParams.targetAgentId);
1604
+ if (!targetAgent || targetAgent.deletedAt) {
1605
+ throw new WorkspaceToolServiceError('Target agent not found.', 404);
1606
+ }
1607
+ const accessibleTargetAgentIds = new Set(getUserAgentAccess(this.db, cloneParams.userId));
1608
+ if (!isAdmin && !accessibleTargetAgentIds.has(targetAgent.agentId)) {
1609
+ throw new WorkspaceToolServiceError('Access denied for target agent.', 403);
1610
+ }
1611
+ if ((targetAgent.agentRole ?? 'user') !== 'user') {
1612
+ throw new WorkspaceToolServiceError('Workspace tools can only be installed on user agents.', 409);
1613
+ }
1614
+ if (targetAgent.agentId === sourceRow.agentId) {
1615
+ throw new WorkspaceToolServiceError('Workspace tools cannot be cloned onto their source agent.', 409);
1616
+ }
1617
+ if (!targetAgent.nodeId || !targetAgent.workspacePath) {
1618
+ throw new WorkspaceToolServiceError('Target agent workspace is unavailable for cloning.', 409);
1619
+ }
1620
+ const sourceManifest = parseWorkspaceToolManifest(sourceRow.manifestJson);
1621
+ const targetSlug = normalizeWorkspaceToolCloneSlug(cloneParams.slug ?? sourceManifest.slug);
1622
+ if (targetSlug !== sourceManifest.slug
1623
+ && workspaceToolCloneManifestHasSourcePathReferences(sourceManifest, sourceManifest.slug)) {
1624
+ throw new WorkspaceToolServiceError('Cannot override the clone slug because the tool manifest references its source bundle path. Clone without a slug override or republish with slug-relative paths first.', 409);
1625
+ }
1626
+ const targetName = cloneParams.name?.trim() || sourceManifest.name;
1627
+ const targetDescription = cloneParams.description ?? sourceManifest.description;
1628
+ const targetBundleRoot = `.agent-tools/${targetSlug}`;
1629
+ const targetManifestPath = `${targetBundleRoot}/tool.json`;
1630
+ const existingTargetTool = this.db.prepare(`SELECT tool_id as toolId
1631
+ FROM workspace_tools
1632
+ WHERE user_id = ?
1633
+ AND agent_id = ?
1634
+ AND slug = ?
1635
+ AND deleted_at IS NULL
1636
+ LIMIT 1`).get(cloneParams.userId, targetAgent.agentId, targetSlug);
1637
+ if (existingTargetTool) {
1638
+ throw new WorkspaceToolServiceError('Target agent already has a live tool with this slug.', 409);
1639
+ }
1640
+ await this.assertWorkspaceDirectoryAvailableForClone({
1641
+ nodeId: targetAgent.nodeId,
1642
+ workspaceRoot: targetAgent.workspacePath,
1643
+ relativePath: targetBundleRoot,
1644
+ });
1645
+ const adjustedManifest = {
1646
+ ...sourceManifest,
1647
+ slug: targetSlug,
1648
+ name: targetName,
1649
+ description: targetDescription,
1650
+ scope: 'private',
1651
+ scopeChannelId: null,
1652
+ allowSharedExec: false,
1653
+ };
1654
+ const manifestJson = JSON.stringify(adjustedManifest, null, 2);
1655
+ validateManifestConsistency(targetAgent.workspacePath, targetManifestPath, adjustedManifest);
1656
+ validateManifestCwds(targetAgent.workspacePath, adjustedManifest, targetBundleRoot);
1657
+ const sourcePublishedBundleRoot = getPublishedWorkspaceToolBundleRoot(sourceRow.toolId, sourceRow.revision);
1658
+ let archiveBase64;
1659
+ try {
1660
+ const archive = await this.workspaceBroker.archiveDirectory(sourceRow.agentNodeId, sourceRow.agentWorkspacePath, sourcePublishedBundleRoot, {
1661
+ maxBytes: WORKSPACE_TOOL_CLONE_ARCHIVE_MAX_BYTES,
1662
+ timeoutMs: WORKSPACE_TOOL_CLONE_ARCHIVE_TIMEOUT_MS,
1663
+ });
1664
+ archiveBase64 = archive.archiveBase64;
1665
+ }
1666
+ catch (error) {
1667
+ throw new WorkspaceToolServiceError(`Failed to read source tool bundle for cloning: ${String(error?.message ?? error)}`, isWorkspaceFileMissingError(error) ? 409 : 500);
1668
+ }
1669
+ let targetBundleMayNeedRollback = false;
1670
+ try {
1671
+ targetBundleMayNeedRollback = true;
1672
+ await this.workspaceBroker.extractDirectoryArchive(targetAgent.nodeId, targetAgent.workspacePath, targetBundleRoot, archiveBase64, {
1673
+ overwrite: false,
1674
+ maxBytes: WORKSPACE_TOOL_CLONE_ARCHIVE_MAX_BYTES,
1675
+ timeoutMs: WORKSPACE_TOOL_CLONE_ARCHIVE_TIMEOUT_MS,
1676
+ });
1677
+ await this.workspaceBroker.writeFile(targetAgent.nodeId, targetAgent.workspacePath, targetManifestPath, manifestJson, 'overwrite', { scaffold: false });
1678
+ }
1679
+ catch (error) {
1680
+ const message = String(error?.message ?? error);
1681
+ if (targetBundleMayNeedRollback && !message.includes('Target path already exists')) {
1682
+ await this.cleanupFailedClonedToolBundle({
1683
+ nodeId: targetAgent.nodeId,
1684
+ workspaceRoot: targetAgent.workspacePath,
1685
+ relativePath: targetBundleRoot,
1686
+ });
1687
+ }
1688
+ throw new WorkspaceToolServiceError(`Failed to install cloned tool bundle on target agent: ${message}`, message.includes('already exists') ? 409 : 500);
1689
+ }
1690
+ let published;
1691
+ try {
1692
+ published = await this.publishTool({
1693
+ userId: cloneParams.userId,
1694
+ agentId: targetAgent.agentId,
1695
+ manifestPath: targetManifestPath,
1696
+ manifestContent: manifestJson,
1697
+ allowCreateNewTool: true,
1698
+ });
1699
+ }
1700
+ catch (error) {
1701
+ await this.cleanupFailedClonedToolBundle({
1702
+ nodeId: targetAgent.nodeId,
1703
+ workspaceRoot: targetAgent.workspacePath,
1704
+ relativePath: targetBundleRoot,
1705
+ });
1706
+ if (error instanceof WorkspaceToolServiceError) {
1707
+ throw error;
1708
+ }
1709
+ throw new WorkspaceToolServiceError(`Failed to publish cloned tool on target agent: ${String(error?.message ?? error)}`, 500);
1710
+ }
1711
+ const now = Date.now();
1712
+ this.db.prepare(`UPDATE workspace_tools
1713
+ SET cloned_from_tool_id = ?,
1714
+ cloned_from_revision = ?,
1715
+ updated_at = ?
1716
+ WHERE tool_id = ?`).run(sourceRow.toolId, sourceRow.revision, now, published.tool.toolId);
1717
+ const verification = this.createCloneVerification({
1718
+ userId: cloneParams.userId,
1719
+ sourceToolId: sourceRow.toolId,
1720
+ sourceRevision: sourceRow.revision,
1721
+ clonedToolId: published.tool.toolId,
1722
+ clonedToolRevision: published.tool.revision,
1723
+ clonedToolUrl: published.toolUrl,
1724
+ targetAgent,
1725
+ targetManifest: adjustedManifest,
1726
+ });
1727
+ return {
1728
+ tool: await this.getToolForViewer(cloneParams.userId, published.tool.toolId),
1729
+ toolUrl: published.toolUrl,
1730
+ verification,
1731
+ };
1732
+ }
1733
+ createCloneVerification(params) {
1734
+ const conversation = this.conversationManager.openNamedAgentPrimaryThread({
1735
+ agentId: params.targetAgent.agentId,
1736
+ userId: params.userId,
1737
+ title: `[workspace-tool-verify:${params.clonedToolId}] ${params.targetManifest.name}`,
1738
+ });
1739
+ if (!conversation) {
1740
+ log.warn('[workspace-tools] failed to create clone verification conversation', {
1741
+ toolId: params.clonedToolId,
1742
+ agentId: params.targetAgent.agentId,
1743
+ });
1744
+ return null;
1745
+ }
1746
+ const now = Date.now();
1747
+ const verificationId = randomUUID();
1748
+ this.db.prepare(`INSERT INTO workspace_tool_verifications(
1749
+ verification_id,
1750
+ tool_id,
1751
+ tool_revision,
1752
+ source_tool_id,
1753
+ source_revision,
1754
+ target_agent_id,
1755
+ target_node_id,
1756
+ target_workspace_path,
1757
+ conversation_id,
1758
+ status,
1759
+ summary,
1760
+ details_json,
1761
+ smoke_run_id,
1762
+ started_at,
1763
+ ended_at,
1764
+ updated_at
1765
+ )
1766
+ VALUES(?, ?, ?, ?, ?, ?, ?, ?, ?, 'checking', ?, NULL, NULL, ?, NULL, ?)`).run(verificationId, params.clonedToolId, params.clonedToolRevision, params.sourceToolId, params.sourceRevision, params.targetAgent.agentId, params.targetAgent.nodeId ?? null, params.targetAgent.workspacePath ?? null, conversation.id, 'Waiting for target agent verification.', now, now);
1767
+ const verification = this.readModel.loadLatestVerificationForTool(params.clonedToolId, params.clonedToolRevision);
1768
+ const prompt = this.buildCloneVerificationPrompt({
1769
+ toolId: params.clonedToolId,
1770
+ toolUrl: params.clonedToolUrl,
1771
+ manifest: params.targetManifest,
1772
+ targetAgent: params.targetAgent,
1773
+ });
1774
+ void this.conversationManager.submitPrompt(conversation.id, prompt, {
1775
+ recordAsUserMessage: true,
1776
+ senderName: 'Workspace Tool verification',
1777
+ suppressReplyContract: false,
1778
+ }).catch((error) => {
1779
+ log.warn('[workspace-tools] failed to dispatch clone verification prompt', {
1780
+ toolId: params.clonedToolId,
1781
+ conversationId: conversation.id,
1782
+ error: String(error?.message ?? error),
1783
+ });
1784
+ this.markVerificationDispatchFailed(verificationId, String(error?.message ?? error));
1785
+ });
1786
+ return verification ? workspaceToolVerificationRowToInfo(verification) : null;
1787
+ }
1788
+ buildCloneVerificationPrompt(params) {
1789
+ const statusAction = findWorkspaceToolVerificationStatusAction(params.manifest);
1790
+ const env = params.manifest.env?.condaEnv?.trim() || '';
1791
+ const cliEntry = params.manifest.cli?.entry?.trim() || '';
1792
+ const runtimeEntry = params.manifest.runtime?.entry?.trim() || '';
1793
+ const checks = [
1794
+ env
1795
+ ? `- Check conda env "${env}" exists and can run Python, for example: conda env list; conda run -n ${env} python --version.`
1796
+ : '- No manifest env.condaEnv is declared; inspect the default shell/Python/runtime available on this target machine.',
1797
+ cliEntry
1798
+ ? `- Check bundle CLI entry exists and is executable/readable: ${cliEntry}.`
1799
+ : '- No cli.entry is declared; inspect action command/runtime entry paths from tool.json.',
1800
+ runtimeEntry
1801
+ ? `- Check runtime.entry exists and can start under the target workspace: ${runtimeEntry}.`
1802
+ : '- If runtime.mode is persistent_runtime, check the runtime starts through cli.entry or the declared runtime.entry.',
1803
+ statusAction
1804
+ ? `- Run a safe smoke check with: bigbang tool run --tool-id ${params.toolId} --action-id ${statusAction.actionId}`
1805
+ : '- This manifest has no zero-parameter status action; mark warning if environment/entry checks pass but no smoke action can be run.',
1806
+ ];
1807
+ return [
1808
+ `A Workspace Tool was installed on this agent and must be verified before users trust it on this machine.`,
1809
+ '',
1810
+ `Tool: ${params.manifest.name} (${params.manifest.slug})`,
1811
+ `Tool id: ${params.toolId}`,
1812
+ `Tool URL: ${params.toolUrl}`,
1813
+ `Target agent: ${params.targetAgent.name} (${params.targetAgent.agentId})`,
1814
+ `Target node: ${params.targetAgent.nodeId ?? 'unknown'}`,
1815
+ `Target workspace: ${params.targetAgent.workspacePath ?? 'unknown'}`,
1816
+ '',
1817
+ 'Run these checks on the target machine:',
1818
+ ...checks,
1819
+ '',
1820
+ 'When done, write back exactly one verification result:',
1821
+ `- Success: bigbang tool verify --tool-id ${params.toolId} --status verified --summary "<what passed>"`
1822
+ + (statusAction ? ` --smoke-run-id "<run id from the smoke action if available>"` : ''),
1823
+ `- Warning: bigbang tool verify --tool-id ${params.toolId} --status warning --summary "<usable but limited, e.g. no smoke action>"`,
1824
+ `- Failure: bigbang tool verify --tool-id ${params.toolId} --status failed --summary "<what failed and next fix>"`,
1825
+ '',
1826
+ 'Do not finish with an ordinary final answer until the verification result has been written back.',
1827
+ ].join('\n');
1828
+ }
1829
+ markVerificationDispatchFailed(verificationId, summary) {
1830
+ const now = Date.now();
1831
+ this.db.prepare(`UPDATE workspace_tool_verifications
1832
+ SET status = 'failed',
1833
+ summary = ?,
1834
+ ended_at = ?,
1835
+ updated_at = ?
1836
+ WHERE verification_id = ?`).run(`Failed to dispatch verification thread: ${summary}`, now, now, verificationId);
1837
+ }
1838
+ recordToolVerificationResult(params) {
1839
+ const parsed = workspaceToolVerificationUpdateSchema.safeParse(params);
1840
+ if (!parsed.success) {
1841
+ throw new WorkspaceToolServiceError(`Invalid tool verification update: ${parsed.error.issues[0]?.message ?? 'unknown error'}`, 400);
1842
+ }
1843
+ const normalized = parsed.data;
1844
+ const verification = this.readModel.loadVerificationByConversation(normalized.conversationId);
1845
+ if (!verification || verification.toolId !== normalized.toolId) {
1846
+ throw new WorkspaceToolServiceError('Tool verification thread not found for this tool.', 404);
1847
+ }
1848
+ if (verification.targetAgentId !== normalized.agentId) {
1849
+ throw new WorkspaceToolServiceError('Tool verification can only be updated by the target agent.', 403);
1850
+ }
1851
+ const tool = this.readModel.loadToolRowById(normalized.toolId);
1852
+ if (!tool || tool.agentId !== normalized.agentId) {
1853
+ throw new WorkspaceToolServiceError('Tool is no longer bound to this verification target.', 409);
1854
+ }
1855
+ if (verification.toolRevision !== tool.revision) {
1856
+ throw new WorkspaceToolServiceError('Tool verification thread is stale for the current tool revision.', 409);
1857
+ }
1858
+ if (normalized.smokeRunId) {
1859
+ const smokeRun = this.db.prepare(`SELECT run_id as runId,
1860
+ tool_id as toolId,
1861
+ tool_revision as toolRevision
1862
+ FROM workspace_tool_runs
1863
+ WHERE run_id = ?
1864
+ LIMIT 1`).get(normalized.smokeRunId);
1865
+ if (!smokeRun) {
1866
+ throw new WorkspaceToolServiceError('Smoke run was not found for this tool verification.', 400);
1867
+ }
1868
+ if (smokeRun.toolId !== normalized.toolId) {
1869
+ throw new WorkspaceToolServiceError('Smoke run belongs to a different workspace tool.', 409);
1870
+ }
1871
+ if (smokeRun.toolRevision != null && smokeRun.toolRevision !== verification.toolRevision) {
1872
+ throw new WorkspaceToolServiceError('Smoke run belongs to a different workspace tool revision.', 409);
1873
+ }
1874
+ }
1875
+ const now = Date.now();
1876
+ this.db.prepare(`UPDATE workspace_tool_verifications
1877
+ SET status = ?,
1878
+ summary = ?,
1879
+ details_json = ?,
1880
+ smoke_run_id = ?,
1881
+ ended_at = ?,
1882
+ updated_at = ?
1883
+ WHERE verification_id = ?`).run(normalized.status, normalized.summary, normalized.details ? JSON.stringify(normalized.details) : null, normalized.smokeRunId ?? null, now, now, verification.verificationId);
1884
+ const updated = this.readModel.loadLatestVerificationForTool(normalized.toolId, verification.toolRevision);
1885
+ if (!updated) {
1886
+ throw new WorkspaceToolServiceError('Tool verification update was not persisted.', 500);
1887
+ }
1888
+ return workspaceToolVerificationRowToInfo(updated);
1889
+ }
1890
+ async cleanupFailedPublishedToolBundle(params) {
1891
+ try {
1892
+ await this.workspaceBroker.deletePath(params.nodeId, params.workspaceRoot, params.relativePath, { recursive: true });
1893
+ }
1894
+ catch (error) {
1895
+ if (!isWorkspaceFileMissingError(error)) {
1896
+ log.warn('[workspace-tools] failed to rollback published tool bundle', {
1897
+ nodeId: params.nodeId,
1898
+ workspaceRoot: params.workspaceRoot,
1899
+ relativePath: params.relativePath,
1900
+ error: String(error?.message ?? error),
1901
+ });
1902
+ }
1903
+ }
1904
+ }
1905
+ async cleanupFailedClonedToolBundle(params) {
1906
+ try {
1907
+ await this.workspaceBroker.deletePath(params.nodeId, params.workspaceRoot, params.relativePath, { recursive: true });
1908
+ }
1909
+ catch (error) {
1910
+ log.warn('[workspace-tools] failed to rollback cloned tool bundle', {
1911
+ nodeId: params.nodeId,
1912
+ workspaceRoot: params.workspaceRoot,
1913
+ relativePath: params.relativePath,
1914
+ error: String(error?.message ?? error),
1915
+ });
1916
+ }
1917
+ }
1918
+ async publishToolMutation(params) {
1919
+ const { params: publishParams, manifest, diagnostics, agentNodeId, agentWorkspacePath, } = params;
1920
+ const now = Date.now();
1921
+ const identity = this.publishIdentityResolver.resolve({
1922
+ userId: publishParams.userId,
1923
+ agentId: publishParams.agentId,
1924
+ slug: manifest.slug,
1925
+ sourceConversationId: publishParams.sourceConversationId,
1926
+ allowCreateNewTool: publishParams.allowCreateNewTool,
1927
+ });
1928
+ if (identity.loadConflict) {
1929
+ throw new WorkspaceToolServiceError(identity.loadConflict.message, identity.loadConflict.statusCode);
1930
+ }
1931
+ const { publishState } = identity;
1932
+ if (publishState.conflict) {
1933
+ throw new WorkspaceToolServiceError(publishState.conflict.message, publishState.conflict.statusCode);
1934
+ }
1935
+ const existingIdentity = publishState.existingIdentity ?? undefined;
1936
+ const existing = publishState.existing ?? undefined;
1937
+ let existingRow = existing?.toolId
1938
+ ? this.readModel.loadToolRowForUser(publishParams.userId, existing.toolId)
1939
+ : null;
1940
+ const nextSourcePanelId = publishState.nextSourcePanelId;
1941
+ const executionAgentId = existing?.agentId ?? publishParams.agentId;
1942
+ if (existingIdentity?.toolId) {
1943
+ this.assertAgentCanPublishToolRevision({
1944
+ publishingAgentId: publishParams.agentId,
1945
+ agentNodeId,
1946
+ agentWorkspacePath,
1947
+ tool: existingIdentity,
1948
+ });
1949
+ }
1950
+ if (existingRow?.activeTerminalId) {
1951
+ existingRow = await this.clearMissingActiveTerminalBeforePublish(existingRow);
1952
+ }
1953
+ if (existingRow?.activeTerminalId) {
1954
+ throw new WorkspaceToolServiceError('Cannot publish a new tool revision while the current revision is still running.', 409);
1955
+ }
1956
+ const toolId = existing?.toolId ?? randomUUID();
1957
+ const maintenanceConversation = this.ensureMaintenanceConversation({
1958
+ toolId,
1959
+ agentId: executionAgentId,
1960
+ userId: publishParams.userId,
1961
+ maintenanceConversationId: existingIdentity?.maintenanceConversationId ?? null,
1962
+ });
1963
+ if (!maintenanceConversation) {
1964
+ throw new WorkspaceToolServiceError('Unable to create maintenance conversation.', 500);
1965
+ }
1966
+ const manifestPath = `.agent-tools/${manifest.slug}/tool.json`;
1967
+ const bundleRoot = `.agent-tools/${manifest.slug}`;
1968
+ const revision = existing ? existing.revision + 1 : 1;
1969
+ const publishedBundleRoot = getPublishedWorkspaceToolBundleRoot(toolId, revision);
1970
+ try {
1971
+ await this.snapshotPublishedBundle({
1972
+ agentNodeId,
1973
+ workspaceRoot: agentWorkspacePath,
1974
+ sourceBundleRoot: bundleRoot,
1975
+ publishedBundleRoot,
1976
+ });
1977
+ const staleCachedStoragePaths = [];
1978
+ const tx = this.db.transaction(() => {
1979
+ const currentSharing = existing ? this.readModel.loadToolRowForUser(publishParams.userId, toolId) : null;
1980
+ const allowSharedExec = currentSharing?.allowSharedExec ?? 0;
1981
+ const snapshotSync = this.snapshotPanels.syncPanel({
1982
+ panelId: existingRow?.panelId ?? existing?.panelId ?? null,
1983
+ sourcePanelId: nextSourcePanelId,
1984
+ toolId,
1985
+ userId: publishParams.userId,
1986
+ agentId: executionAgentId,
1987
+ conversationId: maintenanceConversation.id,
1988
+ nodeId: agentNodeId,
1989
+ manifest,
1990
+ latestState: null,
1991
+ allowSharedExec: allowSharedExec === 1,
1992
+ status: 'idle',
1993
+ });
1994
+ staleCachedStoragePaths.push(...snapshotSync.staleCachedStoragePaths);
1995
+ const panelId = snapshotSync.panelId;
1996
+ if (existing) {
1997
+ const shouldReopenPublicReview = currentSharing?.pendingPublicReview === 0
1998
+ && (currentSharing.publicReviewStatus === 'approved' || currentSharing.publicReviewStatus === 'rejected');
1999
+ const scope = currentSharing?.scope ?? 'private';
2000
+ const scopeChannelId = scope === 'channel'
2001
+ ? (currentSharing?.scopeChannelId ?? null)
2002
+ : null;
2003
+ const publicReviewStatus = shouldReopenPublicReview
2004
+ ? 'pending_review'
2005
+ : (currentSharing?.publicReviewStatus ?? 'not_requested');
2006
+ const pendingPublicReview = shouldReopenPublicReview
2007
+ ? 1
2008
+ : (currentSharing?.pendingPublicReview ?? 0);
2009
+ this.db.prepare(`UPDATE workspace_tools
2010
+ SET slug = ?,
2011
+ name = ?,
2012
+ description = ?,
2013
+ icon = ?,
2014
+ scope = ?,
2015
+ scope_channel_id = ?,
2016
+ allow_shared_exec = ?,
2017
+ public_review_status = ?,
2018
+ pending_public_review = ?,
2019
+ manifest_path = ?,
2020
+ bundle_root = ?,
2021
+ source_panel_id = ?,
2022
+ panel_id = ?,
2023
+ manifest_json = ?,
2024
+ latest_state_json = NULL,
2025
+ maintenance_conversation_id = ?,
2026
+ source_conversation_id = ?,
2027
+ source_message_id = ?,
2028
+ revision = ?,
2029
+ updated_at = ?,
2030
+ deleted_at = NULL
2031
+ WHERE tool_id = ?`).run(manifest.slug, manifest.name, manifest.description, manifest.icon ?? null, scope, scopeChannelId, allowSharedExec, publicReviewStatus, pendingPublicReview, manifestPath, bundleRoot, nextSourcePanelId, panelId, JSON.stringify(withPersistedToolSettings(manifest, {
2032
+ scope,
2033
+ scopeChannelId,
2034
+ allowSharedExec: allowSharedExec === 1,
2035
+ })), maintenanceConversation.id, publishParams.sourceConversationId ?? null, publishParams.sourceMessageId ?? null, revision, now, toolId);
2036
+ ensureSurfaceOwner(this.db, {
2037
+ surfaceType: 'tool',
2038
+ surfaceId: toolId,
2039
+ agentId: executionAgentId,
2040
+ createdAt: now,
2041
+ });
2042
+ if (nextSourcePanelId) {
2043
+ this.db.prepare(`UPDATE panel_tool_promotions
2044
+ SET tool_id = ?,
2045
+ updated_at = ?
2046
+ WHERE panel_id = ?`).run(toolId, now, nextSourcePanelId);
2047
+ }
2048
+ return;
2049
+ }
2050
+ this.db.prepare(`INSERT INTO workspace_tools(
2051
+ tool_id,
2052
+ user_id,
2053
+ agent_id,
2054
+ slug,
2055
+ name,
2056
+ description,
2057
+ icon,
2058
+ scope,
2059
+ scope_channel_id,
2060
+ allow_shared_exec,
2061
+ public_review_status,
2062
+ pending_public_review,
2063
+ manifest_path,
2064
+ bundle_root,
2065
+ source_panel_id,
2066
+ panel_id,
2067
+ manifest_json,
2068
+ latest_state_json,
2069
+ active_terminal_id,
2070
+ maintenance_conversation_id,
2071
+ source_conversation_id,
2072
+ source_message_id,
2073
+ revision,
2074
+ created_at,
2075
+ updated_at,
2076
+ deleted_at
2077
+ )
2078
+ VALUES(
2079
+ ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?,
2080
+ NULL,
2081
+ NULL,
2082
+ ?, ?, ?, ?, ?, ?,
2083
+ NULL
2084
+ )`).run(toolId, publishParams.userId, publishParams.agentId, manifest.slug, manifest.name, manifest.description, manifest.icon ?? null, 'private', null, 0, 'not_requested', 0, manifestPath, bundleRoot, nextSourcePanelId, panelId, JSON.stringify(withPersistedToolSettings(manifest, {
2085
+ scope: 'private',
2086
+ scopeChannelId: null,
2087
+ allowSharedExec: false,
2088
+ })), maintenanceConversation.id, publishParams.sourceConversationId ?? null, publishParams.sourceMessageId ?? null, revision, now, now);
2089
+ ensureSurfaceOwner(this.db, {
2090
+ surfaceType: 'tool',
2091
+ surfaceId: toolId,
2092
+ agentId: executionAgentId,
2093
+ createdAt: now,
2094
+ });
2095
+ if (nextSourcePanelId) {
2096
+ this.copyPanelMaintainersToTool({
2097
+ panelId: nextSourcePanelId,
2098
+ toolId,
2099
+ executionAgentId,
2100
+ createdAt: now,
2101
+ });
2102
+ }
2103
+ if (nextSourcePanelId) {
2104
+ this.db.prepare(`UPDATE panel_tool_promotions
2105
+ SET tool_id = ?,
2106
+ updated_at = ?
2107
+ WHERE panel_id = ?`).run(toolId, now, nextSourcePanelId);
2108
+ }
2109
+ });
2110
+ tx();
2111
+ unlinkPanelAttachmentStoragePaths(staleCachedStoragePaths);
2112
+ if (identity.promotionLink?.panelId) {
2113
+ archivePanelWithArtifacts(this.db, {
2114
+ panelId: identity.promotionLink.panelId,
2115
+ now: Date.now(),
2116
+ reason: 'promoted_to_tool',
2117
+ });
2118
+ }
2119
+ let tool = await this.getToolForUser(publishParams.userId, toolId);
2120
+ const username = this.readModel.loadUsername(publishParams.userId);
2121
+ const toolUrl = username ? `/u/${encodeURIComponent(username)}/chat/tools/${toolId}` : `/chat/tools/${toolId}`;
2122
+ if (existingRow?.clonedFromToolId && existingRow.clonedFromRevision != null) {
2123
+ const targetAgent = this.conversationManager.getAgent(executionAgentId);
2124
+ if (targetAgent) {
2125
+ const verification = this.createCloneVerification({
2126
+ userId: publishParams.userId,
2127
+ sourceToolId: existingRow.clonedFromToolId,
2128
+ sourceRevision: existingRow.clonedFromRevision,
2129
+ clonedToolId: toolId,
2130
+ clonedToolRevision: revision,
2131
+ clonedToolUrl: toolUrl,
2132
+ targetAgent,
2133
+ targetManifest: manifest,
2134
+ });
2135
+ if (verification) {
2136
+ tool = await this.getToolForUser(publishParams.userId, toolId);
2137
+ }
2138
+ }
2139
+ else {
2140
+ log.warn('[workspace-tools] skipped cloned tool verification after publish because target agent is missing', {
2141
+ toolId,
2142
+ agentId: executionAgentId,
2143
+ });
2144
+ }
2145
+ }
2146
+ return { tool, toolUrl, diagnostics };
2147
+ }
2148
+ catch (error) {
2149
+ await this.cleanupFailedPublishedToolBundle({
2150
+ nodeId: agentNodeId,
2151
+ workspaceRoot: agentWorkspacePath,
2152
+ relativePath: publishedBundleRoot,
2153
+ });
2154
+ throw error;
2155
+ }
2156
+ }
2157
+ async updateToolSettings(params) {
2158
+ const parsed = workspaceToolSettingsUpdateSchema.safeParse(params);
2159
+ if (!parsed.success) {
2160
+ throw new WorkspaceToolServiceError(`Invalid tool settings update: ${parsed.error.issues[0]?.message ?? 'unknown error'}`, 400);
2161
+ }
2162
+ const normalized = parsed.data;
2163
+ return this.mutationQueues.enqueueTool(normalized.toolId, async () => {
2164
+ const tool = this.readModel.loadToolRowForViewer(normalized.userId, normalized.toolId, { allowAdminBypass: true });
2165
+ if (!tool) {
2166
+ throw new WorkspaceToolServiceError('Tool not found.', 404);
2167
+ }
2168
+ if (!this.readModel.canManageToolForViewer(normalized.userId, tool)) {
2169
+ throw new WorkspaceToolServiceError('Access denied.', 403);
2170
+ }
2171
+ const ownerChannelAccess = new Set(getUserChannelAccess(this.db, normalized.userId));
2172
+ const ownerIsAdmin = this.readModel.isAdminUser(normalized.userId);
2173
+ const requestedScopeChannelId = normalized.scopeChannelId ?? null;
2174
+ if (normalized.scope === 'channel') {
2175
+ if (!requestedScopeChannelId) {
2176
+ throw new WorkspaceToolServiceError('Channel-scoped tools require a scope channel id.', 400);
2177
+ }
2178
+ if (!this.conversationManager.getChannel(requestedScopeChannelId)) {
2179
+ throw new WorkspaceToolServiceError('Requested channel scope does not exist.', 404);
2180
+ }
2181
+ if (!ownerIsAdmin && !ownerChannelAccess.has(requestedScopeChannelId)) {
2182
+ throw new WorkspaceToolServiceError('You do not have access to the requested channel scope.', 403);
2183
+ }
2184
+ }
2185
+ const allowSharedExec = normalized.allowSharedExec ?? (tool.allowSharedExec === 1);
2186
+ const now = Date.now();
2187
+ const manifest = parseWorkspaceToolManifest(tool.manifestJson);
2188
+ const cancelPendingPublicReview = normalized.cancelPendingPublicReview === true
2189
+ && tool.pendingPublicReview === 1
2190
+ && tool.publicReviewStatus === 'pending_review';
2191
+ const keepPendingPublicReview = !cancelPendingPublicReview
2192
+ && tool.pendingPublicReview === 1
2193
+ && tool.publicReviewStatus === 'pending_review'
2194
+ && normalized.scope !== 'public';
2195
+ const requestingPublic = normalized.scope === 'public';
2196
+ const alreadyApprovedPublic = tool.scope === 'public' && tool.publicReviewStatus === 'approved' && tool.pendingPublicReview === 0;
2197
+ const preservedScope = (requestingPublic && !alreadyApprovedPublic) || keepPendingPublicReview
2198
+ ? tool.scope
2199
+ : normalized.scope;
2200
+ const nextScope = keepPendingPublicReview
2201
+ ? tool.scope
2202
+ : requestingPublic
2203
+ ? (alreadyApprovedPublic ? 'public' : tool.scope)
2204
+ : normalized.scope;
2205
+ const nextScopeChannelId = keepPendingPublicReview
2206
+ ? tool.scopeChannelId
2207
+ : nextScope === 'channel'
2208
+ ? (preservedScope === 'channel'
2209
+ ? (requestingPublic && !alreadyApprovedPublic ? tool.scopeChannelId : requestedScopeChannelId)
2210
+ : requestedScopeChannelId)
2211
+ : null;
2212
+ const nextPublicReviewStatus = keepPendingPublicReview
2213
+ ? 'pending_review'
2214
+ : requestingPublic
2215
+ ? (alreadyApprovedPublic ? 'approved' : 'pending_review')
2216
+ : 'not_requested';
2217
+ const nextPendingPublicReview = keepPendingPublicReview
2218
+ ? 1
2219
+ : requestingPublic
2220
+ ? (alreadyApprovedPublic ? 0 : 1)
2221
+ : 0;
2222
+ this.db.prepare(`UPDATE workspace_tools
2223
+ SET scope = ?,
2224
+ scope_channel_id = ?,
2225
+ allow_shared_exec = ?,
2226
+ public_review_status = ?,
2227
+ pending_public_review = ?,
2228
+ manifest_json = ?,
2229
+ updated_at = ?
2230
+ WHERE tool_id = ?`).run(nextScope, nextScopeChannelId, allowSharedExec ? 1 : 0, nextPublicReviewStatus, nextPendingPublicReview, JSON.stringify(withPersistedToolSettings(manifest, {
2231
+ scope: nextScope,
2232
+ scopeChannelId: nextScopeChannelId,
2233
+ allowSharedExec,
2234
+ })), now, tool.toolId);
2235
+ const updatedTool = this.readModel.loadToolRowById(normalized.toolId);
2236
+ if (!updatedTool) {
2237
+ throw new WorkspaceToolServiceError('Tool not found.', 404);
2238
+ }
2239
+ return this.readModel.rowToToolInfoForViewer(updatedTool, normalized.userId);
2240
+ });
2241
+ }
2242
+ listPendingPublicToolReviews(status) {
2243
+ const rows = this.readModel.loadPublicReviewToolRows(status);
2244
+ return rows.map((row) => this.readModel.rowToToolInfo(row));
2245
+ }
2246
+ async reviewPublicToolScope(params) {
2247
+ return this.mutationQueues.enqueueTool(params.toolId, async () => {
2248
+ const row = this.db.prepare(`SELECT tool_id as toolId,
2249
+ scope,
2250
+ scope_channel_id as scopeChannelId,
2251
+ pending_public_review as pendingPublicReview,
2252
+ public_review_status as publicReviewStatus,
2253
+ user_id as userId
2254
+ FROM workspace_tools
2255
+ WHERE tool_id = ?
2256
+ AND deleted_at IS NULL
2257
+ LIMIT 1`).get(params.toolId);
2258
+ if (!row) {
2259
+ throw new WorkspaceToolServiceError('Tool not found.', 404);
2260
+ }
2261
+ if (row.pendingPublicReview !== 1) {
2262
+ throw new WorkspaceToolServiceError('Tool has no pending public review request.', 409);
2263
+ }
2264
+ const tool = this.requireToolRow(row.userId, params.toolId);
2265
+ const manifest = parseWorkspaceToolManifest(tool.manifestJson);
2266
+ const nextScope = params.status === 'approved' ? 'public' : tool.scope;
2267
+ const nextScopeChannelId = params.status === 'approved' ? null : tool.scopeChannelId;
2268
+ this.db.prepare(`UPDATE workspace_tools
2269
+ SET scope = ?,
2270
+ scope_channel_id = ?,
2271
+ public_review_status = ?,
2272
+ pending_public_review = 0,
2273
+ manifest_json = ?,
2274
+ updated_at = ?
2275
+ WHERE tool_id = ?`).run(nextScope, nextScopeChannelId, params.status, JSON.stringify(withPersistedToolSettings(manifest, {
2276
+ scope: nextScope,
2277
+ scopeChannelId: nextScopeChannelId,
2278
+ allowSharedExec: tool.allowSharedExec === 1,
2279
+ })), Date.now(), params.toolId);
2280
+ return this.getToolForUser(row.userId, params.toolId);
2281
+ });
2282
+ }
2283
+ async deleteTool(params) {
2284
+ const tool = this.readModel.loadToolRowForViewer(params.userId, params.toolId, { allowAdminBypass: true });
2285
+ if (!tool) {
2286
+ throw new WorkspaceToolServiceError('Tool not found.', 404);
2287
+ }
2288
+ if (!this.readModel.canManageToolForViewer(params.userId, tool)) {
2289
+ throw new WorkspaceToolServiceError('Access denied.', 403);
2290
+ }
2291
+ await this.mutationQueues.enqueueTool(tool.toolId, async () => {
2292
+ const currentTool = this.readModel.loadToolRowForViewer(params.userId, params.toolId, { allowAdminBypass: true });
2293
+ if (!currentTool) {
2294
+ return;
2295
+ }
2296
+ if (!this.readModel.canManageToolForViewer(params.userId, currentTool)) {
2297
+ throw new WorkspaceToolServiceError('Access denied.', 403);
2298
+ }
2299
+ let stoppedActiveTerminal = false;
2300
+ if (currentTool.activeTerminalId) {
2301
+ const activeAgent = this.conversationManager.getAgent(currentTool.agentId);
2302
+ if (!activeAgent?.nodeId) {
2303
+ throw new WorkspaceToolServiceError('Cannot delete a running tool because its terminal is unavailable.', 409);
2304
+ }
2305
+ try {
2306
+ await this.terminalBroker.closeTerminal(activeAgent.nodeId, currentTool.activeTerminalId);
2307
+ }
2308
+ catch (error) {
2309
+ const message = String(error?.message ?? error);
2310
+ if (message.includes('Agent node is offline')) {
2311
+ throw new WorkspaceToolServiceError(message, 409);
2312
+ }
2313
+ throw new WorkspaceToolServiceError('Failed to stop the active tool terminal before deletion.', 409);
2314
+ }
2315
+ this.actionRunner.finishActivePersistentRun(currentTool.toolId, currentTool.activeTerminalId, { status: 'cancelled' });
2316
+ stoppedActiveTerminal = true;
2317
+ }
2318
+ const now = Date.now();
2319
+ if (currentTool.sourcePanelId) {
2320
+ this.db.prepare(`UPDATE panel_tool_promotions
2321
+ SET updated_at = ?
2322
+ WHERE panel_id = ?`).run(now, currentTool.sourcePanelId);
2323
+ }
2324
+ this.db.prepare(`DELETE FROM run_pending_tool_attachments
2325
+ WHERE tool_id = ?`).run(currentTool.toolId);
2326
+ this.db.prepare(`UPDATE workspace_tools
2327
+ SET slug = ?,
2328
+ panel_id = NULL,
2329
+ deleted_at = ?,
2330
+ updated_at = ?,
2331
+ active_terminal_id = NULL
2332
+ WHERE tool_id = ?`).run(buildDeletedWorkspaceToolSlug(currentTool.toolId, currentTool.slug), now, now, currentTool.toolId);
2333
+ if (currentTool.panelId) {
2334
+ hardDeletePanelWithArtifacts(this.db, currentTool.panelId);
2335
+ }
2336
+ await this.cleanupDeletedToolWorkspaceFilesBestEffort(currentTool, {
2337
+ skipRuntimeLogCleanup: stoppedActiveTerminal,
2338
+ });
2339
+ });
2340
+ return { ok: true };
2341
+ }
2342
+ async stopActiveTerminalsForAgent(agentId) {
2343
+ const rows = this.db.prepare(`SELECT tool_id as toolId,
2344
+ active_terminal_id as activeTerminalId
2345
+ FROM workspace_tools
2346
+ WHERE agent_id = ?
2347
+ AND deleted_at IS NULL
2348
+ AND active_terminal_id IS NOT NULL`).all(agentId);
2349
+ if (rows.length === 0)
2350
+ return { stopped: 0 };
2351
+ const agent = this.conversationManager.getAgent(agentId);
2352
+ if (!agent?.nodeId) {
2353
+ throw new WorkspaceToolServiceError('Cannot delete agent because a running workspace tool terminal is unavailable.', 409);
2354
+ }
2355
+ let stopped = 0;
2356
+ for (const row of rows) {
2357
+ await this.mutationQueues.enqueueTool(row.toolId, async () => {
2358
+ const current = this.db.prepare(`SELECT active_terminal_id as activeTerminalId
2359
+ FROM workspace_tools
2360
+ WHERE tool_id = ?
2361
+ AND agent_id = ?
2362
+ AND deleted_at IS NULL`).get(row.toolId, agentId);
2363
+ if (!current?.activeTerminalId)
2364
+ return;
2365
+ try {
2366
+ await this.terminalBroker.closeTerminal(agent.nodeId, current.activeTerminalId);
2367
+ }
2368
+ catch (error) {
2369
+ const message = String(error?.message ?? error);
2370
+ if (message.includes('Agent node is offline')) {
2371
+ throw new WorkspaceToolServiceError(message, 409);
2372
+ }
2373
+ throw new WorkspaceToolServiceError('Failed to stop the active workspace tool terminal before deleting agent.', 409);
2374
+ }
2375
+ this.actionRunner.finishActivePersistentRun(row.toolId, current.activeTerminalId, { status: 'cancelled' });
2376
+ stopped += 1;
2377
+ });
2378
+ }
2379
+ return { stopped };
2380
+ }
2381
+ async reconcileActiveTerminalsForNode(nodeId) {
2382
+ const rows = this.db.prepare(`SELECT wt.tool_id as toolId,
2383
+ wt.user_id as userId,
2384
+ wt.agent_id as agentId,
2385
+ wt.maintenance_conversation_id as maintenanceConversationId,
2386
+ wt.manifest_json as manifestJson,
2387
+ wt.active_terminal_id as activeTerminalId,
2388
+ a.workspace_path as workspacePath,
2389
+ (
2390
+ SELECT wtr.run_id
2391
+ FROM workspace_tool_runs wtr
2392
+ WHERE wtr.tool_id = wt.tool_id
2393
+ AND wtr.terminal_id = wt.active_terminal_id
2394
+ AND wtr.status = 'running'
2395
+ ORDER BY wtr.started_at DESC
2396
+ LIMIT 1
2397
+ ) as runningRunId,
2398
+ (
2399
+ SELECT wtr.action_id
2400
+ FROM workspace_tool_runs wtr
2401
+ WHERE wtr.tool_id = wt.tool_id
2402
+ AND wtr.terminal_id = wt.active_terminal_id
2403
+ AND wtr.status = 'running'
2404
+ ORDER BY wtr.started_at DESC
2405
+ LIMIT 1
2406
+ ) as runningActionId,
2407
+ (
2408
+ SELECT wtr.started_at
2409
+ FROM workspace_tool_runs wtr
2410
+ WHERE wtr.tool_id = wt.tool_id
2411
+ AND wtr.terminal_id = wt.active_terminal_id
2412
+ AND wtr.status = 'running'
2413
+ ORDER BY wtr.started_at DESC
2414
+ LIMIT 1
2415
+ ) as runStartedAt
2416
+ FROM workspace_tools wt
2417
+ JOIN agents a ON a.agent_id = wt.agent_id
2418
+ WHERE a.node_id = ?
2419
+ AND a.deleted_at IS NULL
2420
+ AND wt.deleted_at IS NULL
2421
+ AND wt.active_terminal_id IS NOT NULL`).all(nodeId);
2422
+ if (rows.length === 0) {
2423
+ return { checked: 0, cleared: 0, reattached: 0, skipped: 0 };
2424
+ }
2425
+ const rowsByWorkspacePath = new Map();
2426
+ let skipped = 0;
2427
+ for (const row of rows) {
2428
+ if (!row.workspacePath?.trim()) {
2429
+ skipped += 1;
2430
+ continue;
2431
+ }
2432
+ const group = rowsByWorkspacePath.get(row.workspacePath) ?? [];
2433
+ group.push(row);
2434
+ rowsByWorkspacePath.set(row.workspacePath, group);
2435
+ }
2436
+ let checked = 0;
2437
+ let cleared = 0;
2438
+ let reattached = 0;
2439
+ for (const [workspacePath, group] of rowsByWorkspacePath.entries()) {
2440
+ let nodeTerminals;
2441
+ try {
2442
+ const terminals = await this.terminalBroker.listTerminals(nodeId, workspacePath);
2443
+ nodeTerminals = new Set(terminals.map((terminal) => terminal.terminalId));
2444
+ }
2445
+ catch (error) {
2446
+ skipped += group.length;
2447
+ log.warn('[workspace-tools] active terminal reconcile list failed', {
2448
+ nodeId,
2449
+ workspacePath,
2450
+ error: String(error?.message ?? error),
2451
+ });
2452
+ continue;
2453
+ }
2454
+ for (const row of group) {
2455
+ checked += 1;
2456
+ if (nodeTerminals.has(row.activeTerminalId)) {
2457
+ await this.mutationQueues.enqueueTool(row.toolId, async () => {
2458
+ const current = this.db.prepare(`SELECT wt.active_terminal_id as activeTerminalId,
2459
+ a.node_id as nodeId,
2460
+ a.workspace_path as workspacePath
2461
+ FROM workspace_tools wt
2462
+ JOIN agents a ON a.agent_id = wt.agent_id
2463
+ WHERE wt.tool_id = ?
2464
+ AND wt.deleted_at IS NULL
2465
+ LIMIT 1`).get(row.toolId);
2466
+ if (current?.activeTerminalId !== row.activeTerminalId
2467
+ || current.nodeId !== nodeId
2468
+ || current.workspacePath !== workspacePath) {
2469
+ return;
2470
+ }
2471
+ if (this.actionRunner.reattachActivePersistentTerminal(row, nodeId)) {
2472
+ reattached += 1;
2473
+ }
2474
+ });
2475
+ continue;
2476
+ }
2477
+ await this.mutationQueues.enqueueTool(row.toolId, async () => {
2478
+ const current = this.db.prepare(`SELECT wt.active_terminal_id as activeTerminalId,
2479
+ a.node_id as nodeId,
2480
+ a.workspace_path as workspacePath
2481
+ FROM workspace_tools wt
2482
+ JOIN agents a ON a.agent_id = wt.agent_id
2483
+ WHERE wt.tool_id = ?
2484
+ AND wt.deleted_at IS NULL
2485
+ LIMIT 1`).get(row.toolId);
2486
+ if (current?.activeTerminalId !== row.activeTerminalId
2487
+ || current.nodeId !== nodeId
2488
+ || current.workspacePath !== workspacePath) {
2489
+ return;
2490
+ }
2491
+ this.actionRunner.finishActivePersistentRun(row.toolId, row.activeTerminalId, {
2492
+ status: 'cancelled',
2493
+ signal: 'missing_terminal',
2494
+ });
2495
+ cleared += 1;
2496
+ });
2497
+ }
2498
+ }
2499
+ if (checked > 0 || cleared > 0 || reattached > 0 || skipped > 0) {
2500
+ log.info('[workspace-tools] active terminal reconcile complete', {
2501
+ nodeId,
2502
+ checked,
2503
+ cleared,
2504
+ reattached,
2505
+ skipped,
2506
+ });
2507
+ }
2508
+ return { checked, cleared, reattached, skipped };
2509
+ }
2510
+ async clearMissingActiveTerminalBeforePublish(toolRow) {
2511
+ if (!toolRow.activeTerminalId || !toolRow.agentNodeId || !toolRow.agentNodeId.trim() || !toolRow.agentWorkspacePath?.trim()) {
2512
+ return toolRow;
2513
+ }
2514
+ try {
2515
+ const terminals = await this.terminalBroker.listTerminals(toolRow.agentNodeId, toolRow.agentWorkspacePath);
2516
+ if (terminals.some((terminal) => terminal.terminalId === toolRow.activeTerminalId)) {
2517
+ return toolRow;
2518
+ }
2519
+ }
2520
+ catch (error) {
2521
+ log.warn('[workspace-tools] pre-publish active terminal refresh failed', {
2522
+ toolId: toolRow.toolId,
2523
+ activeTerminalId: toolRow.activeTerminalId,
2524
+ nodeId: toolRow.agentNodeId,
2525
+ error: String(error?.message ?? error),
2526
+ });
2527
+ return toolRow;
2528
+ }
2529
+ this.actionRunner.finishActivePersistentRun(toolRow.toolId, toolRow.activeTerminalId, {
2530
+ status: 'cancelled',
2531
+ signal: 'missing_terminal',
2532
+ });
2533
+ return this.readModel.loadToolRowForUser(toolRow.userId, toolRow.toolId) ?? toolRow;
2534
+ }
2535
+ async invokeAction(params) {
2536
+ const toolRow = this.readModel.loadToolRowForViewer(params.userId, params.toolId, {
2537
+ allowAdminBypass: params.allowAdminBypass,
2538
+ });
2539
+ if (!toolRow) {
2540
+ throw new WorkspaceToolServiceError('Tool not found.', 404);
2541
+ }
2542
+ if (this.conversationManager.isAgentDeletionInProgress(toolRow.agentId)) {
2543
+ throw new WorkspaceToolServiceError('Agent deletion is in progress.', 409);
2544
+ }
2545
+ const canExecute = toolRow.userId === params.userId
2546
+ || toolRow.allowSharedExec === 1
2547
+ || (params.allowAdminBypass && this.readModel.isAdminUser(params.userId));
2548
+ if (!canExecute) {
2549
+ throw new WorkspaceToolServiceError('Shared execution is disabled for this tool.', 403);
2550
+ }
2551
+ return this.mutationQueues.enqueueTool(toolRow.toolId, async () => {
2552
+ const refreshedToolRow = this.readModel.loadToolRowForViewer(params.userId, params.toolId, {
2553
+ allowAdminBypass: params.allowAdminBypass,
2554
+ });
2555
+ if (!refreshedToolRow) {
2556
+ throw new WorkspaceToolServiceError('Tool not found.', 404);
2557
+ }
2558
+ if (this.conversationManager.isAgentDeletionInProgress(refreshedToolRow.agentId)) {
2559
+ throw new WorkspaceToolServiceError('Agent deletion is in progress.', 409);
2560
+ }
2561
+ const refreshedCanExecute = refreshedToolRow.userId === params.userId
2562
+ || refreshedToolRow.allowSharedExec === 1
2563
+ || (params.allowAdminBypass && this.readModel.isAdminUser(params.userId));
2564
+ if (!refreshedCanExecute) {
2565
+ throw new WorkspaceToolServiceError('Shared execution is disabled for this tool.', 403);
2566
+ }
2567
+ return this.actionRunner.invokeAction({
2568
+ toolRow: refreshedToolRow,
2569
+ actionId: params.actionId,
2570
+ viewerUserId: params.userId,
2571
+ inputParams: params.inputParams,
2572
+ allowAdminBypass: params.allowAdminBypass,
2573
+ requestedByAgentId: params.requestedByAgentId,
2574
+ });
2575
+ });
2576
+ }
2577
+ async notifyAgentForToolAction(params) {
2578
+ if (params.toolRow.userId !== params.viewerUserId && !this.readModel.isAdminUser(params.viewerUserId)) {
2579
+ throw new WorkspaceToolServiceError('Notify-agent tool actions are only available to the tool owner or admins.', 403);
2580
+ }
2581
+ const conversation = await this.getOrOpenMaintenanceConversation(params.toolRow.userId, params.toolRow.toolId);
2582
+ const requesterAgent = params.requestedByAgentId
2583
+ ? this.conversationManager.getAgent(params.requestedByAgentId)
2584
+ : null;
2585
+ const username = requesterAgent
2586
+ ? requesterAgent.name
2587
+ : (this.readModel.loadUsername(params.viewerUserId) ?? params.viewerUserId);
2588
+ const payload = {
2589
+ kind: 'workspace_tool_action',
2590
+ mode: 'notify_agent',
2591
+ toolId: params.toolRow.toolId,
2592
+ toolName: params.toolRow.name,
2593
+ actionId: params.action.actionId,
2594
+ actionLabel: params.action.label,
2595
+ actionKind: params.action.kind,
2596
+ requestedByUserId: requesterAgent ? null : params.viewerUserId,
2597
+ requestedByAgentId: requesterAgent ? params.requestedByAgentId : null,
2598
+ params: params.actionParams,
2599
+ };
2600
+ const promptText = [
2601
+ 'Workspace Tool action requested.',
2602
+ '',
2603
+ 'Handle this as a user-triggered tool action in the current maintenance conversation.',
2604
+ 'Do not assume a platform command has already run; inspect the tool bundle or update the tool if needed.',
2605
+ '',
2606
+ 'Payload:',
2607
+ '```json',
2608
+ JSON.stringify(payload, null, 2),
2609
+ '```',
2610
+ ].join('\n');
2611
+ const result = await this.conversationManager.submitPrompt(conversation.id, promptText, {
2612
+ senderName: username,
2613
+ clientMessageId: randomUUID(),
2614
+ allowActiveSteer: false,
2615
+ });
2616
+ const tool = this.getToolInfoForActionViewer(params.viewerUserId, params.toolRow.toolId, params.allowAdminBypass);
2617
+ return {
2618
+ mode: 'notify_agent',
2619
+ tool,
2620
+ conversationId: conversation.id,
2621
+ queued: result.queued,
2622
+ ...(result.runId ? { runId: result.runId } : {}),
2623
+ latestState: tool.latestState ?? null,
2624
+ outputText: null,
2625
+ };
2626
+ }
2627
+ async recordPlatformExecNotification(params) {
2628
+ try {
2629
+ const maintenanceConversationId = params.toolRow.maintenanceConversationId;
2630
+ if (!maintenanceConversationId) {
2631
+ log.warn('[workspace-tools] skipped platform_exec audit message without maintenance conversation', {
2632
+ toolId: params.toolRow.toolId,
2633
+ actionId: params.action.actionId,
2634
+ runId: params.run.runId,
2635
+ });
2636
+ return;
2637
+ }
2638
+ const conversation = this.conversationManager.getConversation(maintenanceConversationId);
2639
+ if (!conversation || conversation.agentId !== params.toolRow.agentId) {
2640
+ log.warn('[workspace-tools] skipped platform_exec audit message for missing maintenance conversation', {
2641
+ toolId: params.toolRow.toolId,
2642
+ actionId: params.action.actionId,
2643
+ runId: params.run.runId,
2644
+ maintenanceConversationId,
2645
+ });
2646
+ return;
2647
+ }
2648
+ this.ensureToolMaintenanceConversationRoots(conversation.id);
2649
+ const actorUsername = this.readModel.loadUsername(params.viewerUserId) ?? params.viewerUserId;
2650
+ const payload = {
2651
+ kind: 'workspace_tool_action_result',
2652
+ mode: 'platform_exec',
2653
+ toolId: params.toolRow.toolId,
2654
+ toolName: params.toolRow.name,
2655
+ actionId: params.action.actionId,
2656
+ actionLabel: params.action.label,
2657
+ actionKind: params.action.kind,
2658
+ requestedByUserId: params.viewerUserId,
2659
+ requestedByUsername: actorUsername,
2660
+ runId: params.run.runId,
2661
+ status: params.run.status,
2662
+ terminalId: params.run.terminalId ?? null,
2663
+ exitCode: params.run.exitCode ?? null,
2664
+ signal: params.run.signal ?? null,
2665
+ params: params.actionParams,
2666
+ };
2667
+ const outputPreview = formatBoundedTextPreview(params.outputText, WORKSPACE_TOOL_PLATFORM_EXEC_OUTPUT_PREVIEW_LIMIT);
2668
+ const content = [
2669
+ 'Workspace Tool platform_exec result recorded.',
2670
+ '',
2671
+ `Tool: ${params.toolRow.name} (${params.toolRow.toolId})`,
2672
+ `Action: ${params.action.label} (${params.action.actionId})`,
2673
+ `Run: ${params.run.runId} status=${params.run.status} exit=${params.run.exitCode ?? 'n/a'} signal=${params.run.signal ?? 'n/a'}`,
2674
+ `Triggered by: ${actorUsername} (${params.viewerUserId})`,
2675
+ '',
2676
+ 'Payload:',
2677
+ '```json',
2678
+ formatBoundedJsonPreview(payload, WORKSPACE_TOOL_PLATFORM_EXEC_PARAMS_PREVIEW_LIMIT),
2679
+ '```',
2680
+ ...(outputPreview
2681
+ ? [
2682
+ '',
2683
+ 'Output preview:',
2684
+ '```text',
2685
+ outputPreview,
2686
+ '```',
2687
+ ]
2688
+ : []),
2689
+ '',
2690
+ 'This is a non-waking audit record. No agent run was started for this notice.',
2691
+ ].join('\n');
2692
+ const messageId = randomUUID();
2693
+ const createdAt = Date.now();
2694
+ const seq = allocateNextChannelMessageSeq(this.db, conversation.channelId);
2695
+ this.db.prepare(`INSERT INTO channel_messages(
2696
+ message_id, channel_id, sender_id, sender_name, sender_type, target, content,
2697
+ seq, created_at, thread_root_id, message_kind, message_source, tool_ids
2698
+ )
2699
+ VALUES(?, ?, 'system', 'Workspace Tool', 'system', ?, ?, ?, ?, ?, 'workspace_tool_action', ?, ?)`).run(messageId, conversation.channelId, conversation.replyTarget, content, seq, createdAt, conversation.threadRootId ?? null, WORKSPACE_TOOL_PLATFORM_EXEC_MESSAGE_SOURCE, JSON.stringify([params.toolRow.toolId]));
2700
+ }
2701
+ catch (error) {
2702
+ log.warn('[workspace-tools] failed to record platform_exec audit message', {
2703
+ toolId: params.toolRow.toolId,
2704
+ actionId: params.action.actionId,
2705
+ runId: params.run.runId,
2706
+ error: String(error?.message ?? error),
2707
+ });
2708
+ }
2709
+ }
2710
+ updateLatestState(toolId, userId, agentId, maintenanceConversationId, state) {
2711
+ this.latestStateProjector.updateLatestState({ toolId, userId, agentId, maintenanceConversationId, state });
2712
+ }
2713
+ ensureMaintenanceConversation(input) {
2714
+ const existing = input.maintenanceConversationId
2715
+ ? this.conversationManager.getConversation(input.maintenanceConversationId)
2716
+ : null;
2717
+ if (existing?.agentId === input.agentId) {
2718
+ this.ensureToolMaintenanceConversationRoots(existing.id);
2719
+ return existing;
2720
+ }
2721
+ const conversation = this.conversationManager.openNamedAgentPrimaryThread({
2722
+ agentId: input.agentId,
2723
+ userId: input.userId ?? null,
2724
+ title: `${TOOL_MAINTENANCE_TITLE_PREFIX}${input.toolId}]`,
2725
+ });
2726
+ if (conversation) {
2727
+ this.ensureToolMaintenanceConversationRoots(conversation.id);
2728
+ }
2729
+ return conversation;
2730
+ }
2731
+ ensureToolMaintenanceConversationRoots(conversationId) {
2732
+ for (const root of conversationBuiltinSkillRootsForSlashCommand('tool')) {
2733
+ this.conversationManager.enableBuiltinSkillRootForConversation(conversationId, root);
2734
+ }
2735
+ }
2736
+ ensureReadableToolSnapshotPanel(row, viewerUserId) {
2737
+ const repairableRow = this.ensureToolSnapshotRepairConversation(row, viewerUserId);
2738
+ if (!this.shouldRepairToolSnapshotPanel(repairableRow)) {
2739
+ return repairableRow;
2740
+ }
2741
+ this.snapshotPanels.syncForTool(repairableRow.toolId);
2742
+ return this.readModel.loadToolRowById(repairableRow.toolId) ?? repairableRow;
2743
+ }
2744
+ ensureToolSnapshotRepairConversation(row, viewerUserId) {
2745
+ if (row.maintenanceConversationId && this.conversationManager.getConversation(row.maintenanceConversationId)) {
2746
+ return row;
2747
+ }
2748
+ if (row.userId !== viewerUserId && !this.readModel.isAdminUser(viewerUserId)) {
2749
+ return row;
2750
+ }
2751
+ const conversation = this.ensureMaintenanceConversation({
2752
+ toolId: row.toolId,
2753
+ userId: row.userId,
2754
+ agentId: row.agentId,
2755
+ maintenanceConversationId: row.maintenanceConversationId,
2756
+ });
2757
+ if (!conversation) {
2758
+ return row;
2759
+ }
2760
+ if (row.maintenanceConversationId !== conversation.id) {
2761
+ this.db.prepare(`UPDATE workspace_tools
2762
+ SET maintenance_conversation_id = ?,
2763
+ updated_at = ?
2764
+ WHERE tool_id = ?`).run(conversation.id, Date.now(), row.toolId);
2765
+ }
2766
+ return { ...row, maintenanceConversationId: conversation.id };
2767
+ }
2768
+ shouldRepairToolSnapshotPanel(row) {
2769
+ if (!row.maintenanceConversationId || !this.conversationManager.getConversation(row.maintenanceConversationId)) {
2770
+ return false;
2771
+ }
2772
+ if (!row.panelId) {
2773
+ return true;
2774
+ }
2775
+ const panel = getPanelById(this.db, row.panelId);
2776
+ if (!panel || panel.archivedAt) {
2777
+ return true;
2778
+ }
2779
+ if (panel.scopeType === 'tool' && panel.scopeId === row.toolId) {
2780
+ return false;
2781
+ }
2782
+ log.warn('[workspace-tools] refusing to repair snapshot panel bound to a different scope', {
2783
+ toolId: row.toolId,
2784
+ panelId: row.panelId,
2785
+ panelScopeType: panel.scopeType,
2786
+ panelScopeId: panel.scopeId,
2787
+ });
2788
+ return false;
2789
+ }
2790
+ requireToolRow(userId, toolId) {
2791
+ const row = this.readModel.loadToolRowForUser(userId, toolId);
2792
+ if (!row) {
2793
+ throw new WorkspaceToolServiceError('Tool not found.', 404);
2794
+ }
2795
+ return row;
2796
+ }
2797
+ getToolInfoForActionViewer(userId, toolId, allowAdminBypass) {
2798
+ const row = this.readModel.loadToolRowForViewer(userId, toolId, { allowAdminBypass });
2799
+ if (!row) {
2800
+ throw new WorkspaceToolServiceError('Tool not found.', 404);
2801
+ }
2802
+ return this.readModel.rowToToolInfoForViewer(row, userId);
2803
+ }
2804
+ copyPanelMaintainersToTool(params) {
2805
+ for (const collaborator of listSurfaceCollaborators(this.db, 'panel', params.panelId)) {
2806
+ if (collaborator.agentId === params.executionAgentId)
2807
+ continue;
2808
+ upsertSurfaceCollaborator(this.db, {
2809
+ surfaceType: 'tool',
2810
+ surfaceId: params.toolId,
2811
+ agentId: collaborator.agentId,
2812
+ role: 'collaborator',
2813
+ addedBy: collaborator.addedBy ?? `panel:${params.panelId}`,
2814
+ createdAt: params.createdAt,
2815
+ });
2816
+ }
2817
+ }
2818
+ assertAgentCanPublishToolRevision(params) {
2819
+ if (params.tool.agentId === params.publishingAgentId) {
2820
+ return;
2821
+ }
2822
+ const role = getSurfaceCollaboratorRole(this.db, 'tool', params.tool.toolId, params.publishingAgentId);
2823
+ if (!role) {
2824
+ throw new WorkspaceToolServiceError('Access denied.', 403);
2825
+ }
2826
+ const executionAgent = this.conversationManager.getAgent(params.tool.agentId);
2827
+ if (!executionAgent?.nodeId || !executionAgent.workspacePath) {
2828
+ throw new WorkspaceToolServiceError('Tool owner workspace is unavailable for collaborator republish.', 409);
2829
+ }
2830
+ if (executionAgent.nodeId !== params.agentNodeId
2831
+ || path.normalize(executionAgent.workspacePath) !== path.normalize(params.agentWorkspacePath)) {
2832
+ throw new WorkspaceToolServiceError('Tool collaborator republish must use the tool owner agent workspace; ask the owner agent to publish this revision.', 409);
2833
+ }
2834
+ }
2835
+ isPanelEligibleForToolPromotion(userId, panel) {
2836
+ if (panel.component !== 'RowTemplateGrid')
2837
+ return false;
2838
+ if (panel.datasetSource?.kind === 'query_handle' || panel.datasetSource?.kind === 'api_jsonl')
2839
+ return false;
2840
+ const conversation = this.conversationManager.getConversation(panel.conversationId);
2841
+ if (!conversation || conversation.threadKind !== 'direct' || !conversation.agentId) {
2842
+ return false;
2843
+ }
2844
+ const conversationUserId = conversation.userId?.trim()
2845
+ || resolveDirectUserIdFromReplyTarget(this.db, conversation.replyTarget);
2846
+ if (!conversationUserId || conversationUserId !== userId) {
2847
+ return false;
2848
+ }
2849
+ return hasRowTemplateGridPromotionSeed(panel.props);
2850
+ }
2851
+ loadPanelToolPromotion(panelId) {
2852
+ return this.db.prepare(`SELECT ptp.panel_id as panelId,
2853
+ ptp.user_id as userId,
2854
+ ptp.agent_id as agentId,
2855
+ ptp.promotion_conversation_id as promotionConversationId,
2856
+ ptp.tool_id as linkedToolId,
2857
+ wt.deleted_at as linkedToolDeletedAt,
2858
+ CASE WHEN wt.deleted_at IS NULL THEN wt.tool_id ELSE NULL END as toolId,
2859
+ CASE WHEN wt.deleted_at IS NULL THEN wt.name ELSE NULL END as toolName,
2860
+ CASE WHEN wt.deleted_at IS NULL THEN wt.revision ELSE NULL END as toolRevision,
2861
+ CASE WHEN wt.deleted_at IS NULL THEN wt.maintenance_conversation_id ELSE NULL END as maintenanceConversationId,
2862
+ ptp.created_at as createdAt,
2863
+ ptp.updated_at as updatedAt
2864
+ FROM panel_tool_promotions ptp
2865
+ LEFT JOIN workspace_tools wt
2866
+ ON wt.tool_id = ptp.tool_id
2867
+ WHERE ptp.panel_id = ?
2868
+ LIMIT 1`).get(panelId) ?? null;
2869
+ }
2870
+ toolProjectionHasBrowseControls(panelId, sourcePanelId) {
2871
+ void panelId;
2872
+ return Boolean(sourcePanelId);
2873
+ }
2874
+ getToolSurfaceMetadataForViewer(userId, panelId) {
2875
+ const row = this.readModel.loadToolRowByPanelIdForViewer(userId, panelId);
2876
+ if (!row) {
2877
+ throw new WorkspaceToolServiceError('Tool not found.', 404);
2878
+ }
2879
+ return {
2880
+ surfaceProfile: 'tool',
2881
+ surfaceFeatures: {
2882
+ browseControls: this.toolProjectionHasBrowseControls(panelId, row.sourcePanelId ?? null),
2883
+ },
2884
+ };
2885
+ }
2886
+ ensurePanelPromotionConversation(input) {
2887
+ const threadRootId = buildPanelPromotionThreadRootId(input.panel.id);
2888
+ const normalizedThreadRootId = normalizeThreadShortIdInput(threadRootId);
2889
+ const equivalentThreadRootIds = new Set([normalizedThreadRootId, normalizedThreadRootId ? buildThreadShortId(normalizedThreadRootId) : null]
2890
+ .filter((value) => Boolean(value)));
2891
+ const existing = input.existingConversationId
2892
+ ? this.conversationManager.getConversation(input.existingConversationId)
2893
+ : null;
2894
+ if (existing?.agentId === input.panel.agentId
2895
+ && existing.threadKind === 'direct'
2896
+ && !existing.isPrimaryThread
2897
+ && equivalentThreadRootIds.has(normalizeThreadShortIdInput(existing.threadRootId) ?? '')
2898
+ && !existing.userId?.trim()) {
2899
+ const existingUserId = existing.userId?.trim()
2900
+ || resolveDirectUserIdFromReplyTarget(this.db, existing.replyTarget);
2901
+ if (existingUserId === input.userId) {
2902
+ this.db.prepare(`UPDATE conversations
2903
+ SET user_id = ?,
2904
+ updated_at = ?
2905
+ WHERE id = ?`).run(input.userId, Date.now(), existing.id);
2906
+ }
2907
+ }
2908
+ const conversation = input.activeProjectId === undefined
2909
+ ? this.conversationManager.openAgentDirectThread(input.panel.agentId, input.userId, threadRootId)
2910
+ : this.conversationManager.openAgentDirectThread(input.panel.agentId, input.userId, threadRootId, input.activeProjectId);
2911
+ if (conversation) {
2912
+ this.ensureToolMaintenanceConversationRoots(conversation.id);
2913
+ }
2914
+ return conversation;
2915
+ }
2916
+ buildPanelPromotionSlashBody(panel) {
2917
+ const props = isPlainRecord(panel.props) ? panel.props : {};
2918
+ const sampleRows = listPanelRows(this.db, panel.id, {
2919
+ limit: PANEL_PROMOTION_SAMPLE_ROW_LIMIT,
2920
+ }).rows.map((row) => ({
2921
+ rowIndex: row.rowIndex,
2922
+ rowId: row.rowId ?? null,
2923
+ fields: row.fields,
2924
+ media: row.media,
2925
+ }));
2926
+ const capsule = {
2927
+ panel_id: panel.id,
2928
+ agent_id: panel.agentId,
2929
+ conversation_id: panel.conversationId,
2930
+ component: panel.component,
2931
+ title: typeof props.title === 'string' ? props.title : null,
2932
+ fields: Array.isArray(props.fields) ? props.fields : [],
2933
+ mediaSlots: Array.isArray(props.mediaSlots) ? props.mediaSlots : [],
2934
+ template: isPlainRecord(props.template) ? props.template : null,
2935
+ status: panel.status,
2936
+ progress: panel.progress ?? null,
2937
+ result: panel.result ?? null,
2938
+ sampleRows,
2939
+ };
2940
+ return [
2941
+ 'Upgrade the existing panel into a reusable Workspace Tool.',
2942
+ 'This is a panel-sourced upgrade. Use the panel only as the tool view seed.',
2943
+ 'If a ready workspace-tool bundle already exists in the agent workspace, publish that bundle directly with publish_workspace_tool instead of recreating it.',
2944
+ 'Do not create or patch another panel.',
2945
+ 'Do not treat panel actions, submits, or read_panel_rows as the tool runtime.',
2946
+ 'Otherwise, define tool actions, status output, and params explicitly, then publish with publish_workspace_tool.',
2947
+ 'If this panel already has a linked tool, update that tool in place instead of creating a parallel tool.',
2948
+ '[Panel promotion capsule]',
2949
+ `\`\`\`json\n${JSON.stringify(capsule, null, 2)}\n\`\`\``,
2950
+ ].join('\n\n');
2951
+ }
2952
+ assertWorkspaceToolAgentAvailable(agentId) {
2953
+ const agent = this.conversationManager.getAgent(agentId);
2954
+ const availability = getWorkspaceToolAgentAvailability(this.db, agent);
2955
+ if (!availability.allowed) {
2956
+ throw new WorkspaceToolServiceError(availability.error, 409);
2957
+ }
2958
+ }
2959
+ async assertWorkspaceDirectoryAvailableForClone(params) {
2960
+ try {
2961
+ await this.workspaceBroker.listDirectory(params.nodeId, params.workspaceRoot, params.relativePath, {
2962
+ scaffold: false,
2963
+ limit: 1,
2964
+ timeoutMs: WORKSPACE_TOOL_CLONE_ARCHIVE_TIMEOUT_MS,
2965
+ });
2966
+ throw new WorkspaceToolServiceError('Target tool bundle directory already exists.', 409);
2967
+ }
2968
+ catch (error) {
2969
+ if (error instanceof WorkspaceToolServiceError)
2970
+ throw error;
2971
+ if (isWorkspaceFileMissingError(error))
2972
+ return;
2973
+ if (String(error?.message ?? error).includes('not_directory')) {
2974
+ throw new WorkspaceToolServiceError('Target tool bundle path already exists and is not a directory.', 409);
2975
+ }
2976
+ throw new WorkspaceToolServiceError(`Unable to inspect target tool bundle directory: ${String(error?.message ?? error)}`, 409);
2977
+ }
2978
+ }
2979
+ async snapshotPublishedBundle(params) {
2980
+ const parentDir = path.posix.dirname(params.publishedBundleRoot);
2981
+ const command = [
2982
+ `mkdir -p -- ${shellEscape(parentDir)}`,
2983
+ `rm -rf -- ${shellEscape(params.publishedBundleRoot)}`,
2984
+ `mkdir -p -- ${shellEscape(params.publishedBundleRoot)}`,
2985
+ `cp -R -- ${shellEscape(`${params.sourceBundleRoot}/.`)} ${shellEscape(`${params.publishedBundleRoot}/`)}`,
2986
+ ].join(' && ');
2987
+ let terminalId = null;
2988
+ try {
2989
+ const terminal = await this.terminalBroker.createTerminal(params.agentNodeId, {
2990
+ workspaceRoot: params.workspaceRoot,
2991
+ cwd: params.workspaceRoot,
2992
+ name: 'workspace-tool publish snapshot',
2993
+ startupCommand: command,
2994
+ closeOnStartupCommand: true,
2995
+ });
2996
+ terminalId = terminal.terminalId;
2997
+ const snapshot = await waitForTerminalExit(this.terminalBroker, params.agentNodeId, terminalId);
2998
+ const completion = deriveOneShotRunCompletion(snapshot);
2999
+ if (completion.status !== 'completed') {
3000
+ throw new WorkspaceToolServiceError(`Failed to snapshot published tool bundle: ${(snapshot.buffer ?? '').trim() || 'copy command failed'}`, 500);
3001
+ }
3002
+ }
3003
+ finally {
3004
+ if (terminalId) {
3005
+ await closeTerminalBestEffort(this.terminalBroker, params.agentNodeId, terminalId);
3006
+ }
3007
+ }
3008
+ }
3009
+ }
3010
+ function buildPanelPromotionThreadRootId(panelId) {
3011
+ return `${PANEL_TOOL_PROMOTION_THREAD_PREFIX}${panelId.trim().toLowerCase()}`;
3012
+ }
3013
+ function canAgentPublishWorkspaceTool(agent) {
3014
+ return Boolean(agent?.nodeId?.trim() && agent?.workspacePath?.trim());
3015
+ }
3016
+ function loadNodeRuntimeCapabilities(db, nodeId) {
3017
+ const normalizedNodeId = nodeId?.trim();
3018
+ if (!normalizedNodeId)
3019
+ return null;
3020
+ const row = db.prepare(`SELECT capabilities_json as capabilitiesJson
3021
+ FROM node_runtime_snapshots
3022
+ WHERE node_id = ?
3023
+ LIMIT 1`).get(normalizedNodeId);
3024
+ if (!row?.capabilitiesJson)
3025
+ return null;
3026
+ try {
3027
+ const parsed = JSON.parse(row.capabilitiesJson);
3028
+ return parsed && typeof parsed === 'object'
3029
+ ? parsed
3030
+ : null;
3031
+ }
3032
+ catch {
3033
+ return null;
3034
+ }
3035
+ }
3036
+ function getWorkspaceToolAgentAvailability(db, agent) {
3037
+ if (!canAgentPublishWorkspaceTool(agent)) {
3038
+ return {
3039
+ allowed: false,
3040
+ error: 'This agent does not have a publishable workspace for /tool',
3041
+ };
3042
+ }
3043
+ const capabilities = loadNodeRuntimeCapabilities(db, agent?.nodeId);
3044
+ if (!capabilities?.skills) {
3045
+ return {
3046
+ allowed: false,
3047
+ error: 'This agent node does not advertise builtin skill support required for /tool. Update the remote node and retry.',
3048
+ };
3049
+ }
3050
+ const builtinSkillRoots = Array.isArray(capabilities.builtinSkillRoots)
3051
+ ? capabilities.builtinSkillRoots
3052
+ .filter((value) => typeof value === 'string')
3053
+ .map((value) => value.trim())
3054
+ .filter(Boolean)
3055
+ : [];
3056
+ const missingRoots = [
3057
+ BUILTIN_WORKSPACE_TOOL_SKILL_ROOT_SENTINEL,
3058
+ BUILTIN_UI_PANEL_SKILL_ROOT_SENTINEL,
3059
+ ].filter((root) => !builtinSkillRoots.includes(root));
3060
+ if (missingRoots.length > 0) {
3061
+ return {
3062
+ allowed: false,
3063
+ error: 'This agent node is missing the builtin workspace-tool skills required for /tool. Push the latest code and run pnpm update:nodes:remote before retrying.',
3064
+ };
3065
+ }
3066
+ return { allowed: true };
3067
+ }