@bbigbang/core 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/config.js +380 -0
- package/dist/execution/executionDispatcher.js +3810 -0
- package/dist/main.js +90 -0
- package/dist/nodeEventHistory.js +206 -0
- package/dist/scheduler/dreamLogic.js +50 -0
- package/dist/scheduler/dreamScheduler.js +65 -0
- package/dist/services/agentFileAccessService.js +1913 -0
- package/dist/services/agentRuntimeCleanupBroker.js +62 -0
- package/dist/services/agentSkillsBroker.js +118 -0
- package/dist/services/agentSkillsService.js +83 -0
- package/dist/services/agentWorkspaceBroker.js +937 -0
- package/dist/services/agentWorkspaceService.js +70 -0
- package/dist/services/appVersion.js +14 -0
- package/dist/services/auth.js +586 -0
- package/dist/services/claudeControlBroker.js +154 -0
- package/dist/services/claudeTranscriptBroker.js +100 -0
- package/dist/services/claudeTranscriptService.js +359 -0
- package/dist/services/codexAppServerBroker.js +155 -0
- package/dist/services/codexTranscriptBroker.js +98 -0
- package/dist/services/codexTranscriptService.js +961 -0
- package/dist/services/droidMissionBroker.js +124 -0
- package/dist/services/droidMissionImporter.js +630 -0
- package/dist/services/droidModelOptions.js +165 -0
- package/dist/services/hubServerRegistrationService.js +268 -0
- package/dist/services/libraryManifest.js +43 -0
- package/dist/services/libraryScaffold.js +26 -0
- package/dist/services/libraryService.js +2263 -0
- package/dist/services/memoryService.js +386 -0
- package/dist/services/missionEvidence.js +377 -0
- package/dist/services/missionService.js +2361 -0
- package/dist/services/missionTrace.js +158 -0
- package/dist/services/nativeMissionBriefParser.js +120 -0
- package/dist/services/nativeMissionOrchestrator.js +2045 -0
- package/dist/services/nativeMissionReportGenerator.js +227 -0
- package/dist/services/nativeMissionValidationRunner.js +452 -0
- package/dist/services/nativeMissionWorkerBroker.js +190 -0
- package/dist/services/nodeRegistry.js +34 -0
- package/dist/services/nodeStateReconciler.js +97 -0
- package/dist/services/panelMediaScanner.js +119 -0
- package/dist/services/persistentRuntimeJsonlClient.js +153 -0
- package/dist/services/platformAgentPolicy.js +180 -0
- package/dist/services/platformAgentService.js +2041 -0
- package/dist/services/projectAccessResolver.js +93 -0
- package/dist/services/projectService.js +392 -0
- package/dist/services/resourceSpaceService.js +140 -0
- package/dist/services/scenarioRuntimeService.js +1130 -0
- package/dist/services/suggestedPlannerService.js +868 -0
- package/dist/services/workbenchGitBroker.js +161 -0
- package/dist/services/workbenchGitService.js +69 -0
- package/dist/services/workbenchInspectBroker.js +65 -0
- package/dist/services/workbenchNodePathService.js +79 -0
- package/dist/services/workbenchRegistryService.js +240 -0
- package/dist/services/workbenchRootService.js +181 -0
- package/dist/services/workbenchTerminalBroker.js +378 -0
- package/dist/services/workspaceRunOwnership.js +60 -0
- package/dist/services/workspaceScaffold.js +105 -0
- package/dist/services/workspaceSessionRuntimeService.js +576 -0
- package/dist/services/workspaceSessionService.js +245 -0
- package/dist/services/workspaceToolActionRunner.js +1582 -0
- package/dist/services/workspaceToolErrors.js +10 -0
- package/dist/services/workspaceToolExecutionUtils.js +895 -0
- package/dist/services/workspaceToolLatestStateProjector.js +91 -0
- package/dist/services/workspaceToolManifest.js +572 -0
- package/dist/services/workspaceToolMutationQueue.js +43 -0
- package/dist/services/workspaceToolPanelProjection.js +460 -0
- package/dist/services/workspaceToolPromotion.js +255 -0
- package/dist/services/workspaceToolPromotionState.js +224 -0
- package/dist/services/workspaceToolPublishDiagnostics.js +189 -0
- package/dist/services/workspaceToolPublishIdentityResolver.js +146 -0
- package/dist/services/workspaceToolReadModel.js +378 -0
- package/dist/services/workspaceToolRunLedger.js +239 -0
- package/dist/services/workspaceToolService.js +3067 -0
- package/dist/services/workspaceToolSnapshotPanelSync.js +293 -0
- package/dist/services/workspaceToolTerminalLifecycle.js +283 -0
- package/dist/services/workspaceToolTypes.js +1 -0
- package/dist/services/workspaceToolUploadMaterializer.js +228 -0
- package/dist/web/actionCardRoutes.js +129 -0
- package/dist/web/actionCards.js +469 -0
- package/dist/web/activationContext.js +684 -0
- package/dist/web/agentChannelGuards.js +48 -0
- package/dist/web/agentMentionCooldowns.js +32 -0
- package/dist/web/agentReminders.js +1668 -0
- package/dist/web/agentRuntimePresence.js +197 -0
- package/dist/web/agentSelfState.js +494 -0
- package/dist/web/agentTaskLinks.js +26 -0
- package/dist/web/agentVisibility.js +79 -0
- package/dist/web/assets.js +95 -0
- package/dist/web/channelActivationPrompt.js +395 -0
- package/dist/web/channelMemoryNotes.js +127 -0
- package/dist/web/channelMentions.js +10 -0
- package/dist/web/channelMessageSequences.js +19 -0
- package/dist/web/channelSubscriptions.js +26 -0
- package/dist/web/clearedTaskRoots.js +10 -0
- package/dist/web/collaborationPromptGuidance.js +36 -0
- package/dist/web/collaborationSurfaceState.js +140 -0
- package/dist/web/contextBundleRanking.js +154 -0
- package/dist/web/contextBundleResolver.js +488 -0
- package/dist/web/conversationBuiltinSkillRoots.js +50 -0
- package/dist/web/conversationControls.js +232 -0
- package/dist/web/conversationHandoffs.js +612 -0
- package/dist/web/conversationManager.js +2511 -0
- package/dist/web/conversationSummaries.js +876 -0
- package/dist/web/conversationSurfaceKinds.js +17 -0
- package/dist/web/conversationTargets.js +173 -0
- package/dist/web/directActivationPrompt.js +122 -0
- package/dist/web/directReplyTargets.js +69 -0
- package/dist/web/directThreadResolver.js +129 -0
- package/dist/web/dmTaskHandoffPrompt.js +120 -0
- package/dist/web/dmTaskThreadStatusProjection.js +229 -0
- package/dist/web/ftsQuery.js +33 -0
- package/dist/web/internalAgentRouter.js +11341 -0
- package/dist/web/libraryCuratorScheduler.js +58 -0
- package/dist/web/libraryDocumentPromptGuidance.js +8 -0
- package/dist/web/messageCheckpoints.js +19 -0
- package/dist/web/nodeWsHandler.js +2495 -0
- package/dist/web/notificationRounds.js +1061 -0
- package/dist/web/panelActionMessages.js +108 -0
- package/dist/web/panelActivationPrompt.js +18 -0
- package/dist/web/panelAudit.js +273 -0
- package/dist/web/panelLifecycle.js +222 -0
- package/dist/web/panelMediaPolicy.js +43 -0
- package/dist/web/panelPathPolicy.js +63 -0
- package/dist/web/panelPreviews.js +175 -0
- package/dist/web/panelQueryHandles.js +2749 -0
- package/dist/web/panelRoutes.js +2147 -0
- package/dist/web/panels.js +904 -0
- package/dist/web/peerInboxAggregates.js +1247 -0
- package/dist/web/planApprovalState.js +92 -0
- package/dist/web/platformAgentScheduler.js +66 -0
- package/dist/web/proactiveOpportunities.js +452 -0
- package/dist/web/promptContextSections.js +242 -0
- package/dist/web/promptHistorySanitizer.js +26 -0
- package/dist/web/promptSlashCommands.js +158 -0
- package/dist/web/rollingConversationSummary.js +453 -0
- package/dist/web/routeHelpers.js +11 -0
- package/dist/web/routes/handoff.js +288 -0
- package/dist/web/routes/history.js +345 -0
- package/dist/web/routes/memory.js +258 -0
- package/dist/web/routes/selfState.js +171 -0
- package/dist/web/routes/workspace.js +154 -0
- package/dist/web/runSurfaceWatermarks.js +431 -0
- package/dist/web/runtimeCapabilities.js +48 -0
- package/dist/web/sameAgentHandoffs.js +494 -0
- package/dist/web/server.js +15567 -0
- package/dist/web/sharedCollaborationCapsules.js +163 -0
- package/dist/web/soloSessionRelay.js +42 -0
- package/dist/web/soloWsHandler.js +138 -0
- package/dist/web/suggestedPlannerScheduler.js +56 -0
- package/dist/web/surfaceActivationPolicy.js +108 -0
- package/dist/web/surfaceCollaborators.js +61 -0
- package/dist/web/surfaceSystemStatus.js +263 -0
- package/dist/web/targetParticipants.js +77 -0
- package/dist/web/taskEvents.js +49 -0
- package/dist/web/taskLifecycleMessages.js +165 -0
- package/dist/web/taskLoops.js +732 -0
- package/dist/web/taskMemoryNotes.js +224 -0
- package/dist/web/taskNumbers.js +16 -0
- package/dist/web/taskOwnerGuards.js +49 -0
- package/dist/web/taskParticipantResolver.js +42 -0
- package/dist/web/taskParticipants.js +97 -0
- package/dist/web/taskSourceDetails.js +20 -0
- package/dist/web/taskStateViews.js +210 -0
- package/dist/web/taskStatusTransitions.js +9 -0
- package/dist/web/taskThreadFollowups.js +599 -0
- package/dist/web/taskThreadRuntimeClosure.js +685 -0
- package/dist/web/taskUpdateDelivery.js +104 -0
- package/dist/web/threadReplyContentHeuristics.js +30 -0
- package/dist/web/threadRoots.js +61 -0
- package/dist/web/threadTaskBindings.js +365 -0
- package/dist/web/uiPanelPromptGuidance.js +27 -0
- package/dist/web/workspaceMemoryHints.js +143 -0
- package/dist/web/workspaceToolPromptGuidance.js +30 -0
- package/dist/web/wsHandler.js +397 -0
- package/dist/web/wsSink.js +116 -0
- package/package.json +54 -0
|
@@ -0,0 +1,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
|
+
}
|