sylas-edge-worker 0.2.21
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/README.md +293 -0
- package/dist/ActivityPoster.d.ts +15 -0
- package/dist/ActivityPoster.d.ts.map +1 -0
- package/dist/ActivityPoster.js +194 -0
- package/dist/ActivityPoster.js.map +1 -0
- package/dist/AgentSessionManager.d.ts +280 -0
- package/dist/AgentSessionManager.d.ts.map +1 -0
- package/dist/AgentSessionManager.js +1412 -0
- package/dist/AgentSessionManager.js.map +1 -0
- package/dist/AskUserQuestionHandler.d.ts +97 -0
- package/dist/AskUserQuestionHandler.d.ts.map +1 -0
- package/dist/AskUserQuestionHandler.js +206 -0
- package/dist/AskUserQuestionHandler.js.map +1 -0
- package/dist/AttachmentService.d.ts +69 -0
- package/dist/AttachmentService.d.ts.map +1 -0
- package/dist/AttachmentService.js +369 -0
- package/dist/AttachmentService.js.map +1 -0
- package/dist/ChatSessionHandler.d.ts +87 -0
- package/dist/ChatSessionHandler.d.ts.map +1 -0
- package/dist/ChatSessionHandler.js +231 -0
- package/dist/ChatSessionHandler.js.map +1 -0
- package/dist/ConfigManager.d.ts +91 -0
- package/dist/ConfigManager.d.ts.map +1 -0
- package/dist/ConfigManager.js +227 -0
- package/dist/ConfigManager.js.map +1 -0
- package/dist/EdgeWorker.d.ts +670 -0
- package/dist/EdgeWorker.d.ts.map +1 -0
- package/dist/EdgeWorker.js +3801 -0
- package/dist/EdgeWorker.js.map +1 -0
- package/dist/GitService.d.ts +39 -0
- package/dist/GitService.d.ts.map +1 -0
- package/dist/GitService.js +432 -0
- package/dist/GitService.js.map +1 -0
- package/dist/GlobalSessionRegistry.d.ts +142 -0
- package/dist/GlobalSessionRegistry.d.ts.map +1 -0
- package/dist/GlobalSessionRegistry.js +254 -0
- package/dist/GlobalSessionRegistry.js.map +1 -0
- package/dist/PromptBuilder.d.ts +175 -0
- package/dist/PromptBuilder.d.ts.map +1 -0
- package/dist/PromptBuilder.js +884 -0
- package/dist/PromptBuilder.js.map +1 -0
- package/dist/RepositoryRouter.d.ts +152 -0
- package/dist/RepositoryRouter.d.ts.map +1 -0
- package/dist/RepositoryRouter.js +480 -0
- package/dist/RepositoryRouter.js.map +1 -0
- package/dist/RunnerSelectionService.d.ts +62 -0
- package/dist/RunnerSelectionService.d.ts.map +1 -0
- package/dist/RunnerSelectionService.js +379 -0
- package/dist/RunnerSelectionService.js.map +1 -0
- package/dist/SharedApplicationServer.d.ts +107 -0
- package/dist/SharedApplicationServer.d.ts.map +1 -0
- package/dist/SharedApplicationServer.js +247 -0
- package/dist/SharedApplicationServer.js.map +1 -0
- package/dist/SharedWebhookServer.d.ts +39 -0
- package/dist/SharedWebhookServer.d.ts.map +1 -0
- package/dist/SharedWebhookServer.js +150 -0
- package/dist/SharedWebhookServer.js.map +1 -0
- package/dist/SlackChatAdapter.d.ts +25 -0
- package/dist/SlackChatAdapter.d.ts.map +1 -0
- package/dist/SlackChatAdapter.js +143 -0
- package/dist/SlackChatAdapter.js.map +1 -0
- package/dist/UserAccessControl.d.ts +69 -0
- package/dist/UserAccessControl.d.ts.map +1 -0
- package/dist/UserAccessControl.js +171 -0
- package/dist/UserAccessControl.js.map +1 -0
- package/dist/WorktreeIncludeService.d.ts +32 -0
- package/dist/WorktreeIncludeService.d.ts.map +1 -0
- package/dist/WorktreeIncludeService.js +123 -0
- package/dist/WorktreeIncludeService.js.map +1 -0
- package/dist/index.d.ts +22 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +17 -0
- package/dist/index.js.map +1 -0
- package/dist/label-prompt-template.md +27 -0
- package/dist/procedures/ProcedureAnalyzer.d.ts +69 -0
- package/dist/procedures/ProcedureAnalyzer.d.ts.map +1 -0
- package/dist/procedures/ProcedureAnalyzer.js +271 -0
- package/dist/procedures/ProcedureAnalyzer.js.map +1 -0
- package/dist/procedures/index.d.ts +7 -0
- package/dist/procedures/index.d.ts.map +1 -0
- package/dist/procedures/index.js +7 -0
- package/dist/procedures/index.js.map +1 -0
- package/dist/procedures/registry.d.ts +156 -0
- package/dist/procedures/registry.d.ts.map +1 -0
- package/dist/procedures/registry.js +240 -0
- package/dist/procedures/registry.js.map +1 -0
- package/dist/procedures/types.d.ts +103 -0
- package/dist/procedures/types.d.ts.map +1 -0
- package/dist/procedures/types.js +5 -0
- package/dist/procedures/types.js.map +1 -0
- package/dist/prompt-assembly/types.d.ts +80 -0
- package/dist/prompt-assembly/types.d.ts.map +1 -0
- package/dist/prompt-assembly/types.js +8 -0
- package/dist/prompt-assembly/types.js.map +1 -0
- package/dist/prompts/builder.md +191 -0
- package/dist/prompts/debugger.md +128 -0
- package/dist/prompts/graphite-orchestrator.md +362 -0
- package/dist/prompts/orchestrator.md +290 -0
- package/dist/prompts/scoper.md +95 -0
- package/dist/prompts/standard-issue-assigned-user-prompt.md +33 -0
- package/dist/prompts/subroutines/changelog-update.md +79 -0
- package/dist/prompts/subroutines/coding-activity.md +12 -0
- package/dist/prompts/subroutines/concise-summary.md +67 -0
- package/dist/prompts/subroutines/debugger-fix.md +92 -0
- package/dist/prompts/subroutines/debugger-reproduction.md +74 -0
- package/dist/prompts/subroutines/full-delegation.md +68 -0
- package/dist/prompts/subroutines/get-approval.md +175 -0
- package/dist/prompts/subroutines/gh-pr.md +80 -0
- package/dist/prompts/subroutines/git-commit.md +37 -0
- package/dist/prompts/subroutines/plan-summary.md +21 -0
- package/dist/prompts/subroutines/preparation.md +16 -0
- package/dist/prompts/subroutines/question-answer.md +8 -0
- package/dist/prompts/subroutines/question-investigation.md +8 -0
- package/dist/prompts/subroutines/release-execution.md +81 -0
- package/dist/prompts/subroutines/release-summary.md +60 -0
- package/dist/prompts/subroutines/user-testing-summary.md +87 -0
- package/dist/prompts/subroutines/user-testing.md +48 -0
- package/dist/prompts/subroutines/validation-fixer.md +56 -0
- package/dist/prompts/subroutines/verbose-summary.md +46 -0
- package/dist/prompts/subroutines/verifications.md +77 -0
- package/dist/prompts/todolist-system-prompt-extension.md +15 -0
- package/dist/sinks/IActivitySink.d.ts +60 -0
- package/dist/sinks/IActivitySink.d.ts.map +1 -0
- package/dist/sinks/IActivitySink.js +2 -0
- package/dist/sinks/IActivitySink.js.map +1 -0
- package/dist/sinks/LinearActivitySink.d.ts +69 -0
- package/dist/sinks/LinearActivitySink.d.ts.map +1 -0
- package/dist/sinks/LinearActivitySink.js +111 -0
- package/dist/sinks/LinearActivitySink.js.map +1 -0
- package/dist/sinks/NoopActivitySink.d.ts +13 -0
- package/dist/sinks/NoopActivitySink.d.ts.map +1 -0
- package/dist/sinks/NoopActivitySink.js +17 -0
- package/dist/sinks/NoopActivitySink.js.map +1 -0
- package/dist/sinks/index.d.ts +9 -0
- package/dist/sinks/index.d.ts.map +1 -0
- package/dist/sinks/index.js +8 -0
- package/dist/sinks/index.js.map +1 -0
- package/dist/types.d.ts +32 -0
- package/dist/types.d.ts.map +1 -0
- package/dist/types.js +2 -0
- package/dist/types.js.map +1 -0
- package/dist/validation/ValidationLoopController.d.ts +54 -0
- package/dist/validation/ValidationLoopController.d.ts.map +1 -0
- package/dist/validation/ValidationLoopController.js +242 -0
- package/dist/validation/ValidationLoopController.js.map +1 -0
- package/dist/validation/index.d.ts +7 -0
- package/dist/validation/index.d.ts.map +1 -0
- package/dist/validation/index.js +7 -0
- package/dist/validation/index.js.map +1 -0
- package/dist/validation/types.d.ts +82 -0
- package/dist/validation/types.d.ts.map +1 -0
- package/dist/validation/types.js +29 -0
- package/dist/validation/types.js.map +1 -0
- package/label-prompt-template.md +27 -0
- package/package.json +56 -0
- package/prompt-template.md +116 -0
- package/prompts/builder.md +191 -0
- package/prompts/debugger.md +128 -0
- package/prompts/graphite-orchestrator.md +362 -0
- package/prompts/orchestrator.md +290 -0
- package/prompts/scoper.md +95 -0
- package/prompts/standard-issue-assigned-user-prompt.md +33 -0
- package/prompts/todolist-system-prompt-extension.md +15 -0
|
@@ -0,0 +1,3801 @@
|
|
|
1
|
+
import { AsyncLocalStorage } from "node:async_hooks";
|
|
2
|
+
import { EventEmitter } from "node:events";
|
|
3
|
+
import { mkdir, readdir, readFile, writeFile } from "node:fs/promises";
|
|
4
|
+
import { basename, join } from "node:path";
|
|
5
|
+
import { LinearClient } from "@linear/sdk";
|
|
6
|
+
import { Sessions, streamableHttp } from "fastify-mcp";
|
|
7
|
+
import { ClaudeRunner, createImageToolsServer, createSoraToolsServer, } from "sylas-claude-runner";
|
|
8
|
+
import { CodexRunner } from "sylas-codex-runner";
|
|
9
|
+
import { ConfigUpdater } from "sylas-config-updater";
|
|
10
|
+
import { CLIIssueTrackerService, CLIRPCServer, createLogger, DEFAULT_PROXY_URL, isAgentSessionCreatedWebhook, isAgentSessionPromptedWebhook, isContentUpdateMessage, isIssueAssignedWebhook, isIssueCommentMentionWebhook, isIssueNewCommentWebhook, isIssueTitleOrDescriptionUpdateWebhook, isIssueUnassignedWebhook, isSessionStartMessage, isStopSignalMessage, isUnassignMessage, isUserPromptMessage, PersistenceManager, resolvePath, } from "sylas-core";
|
|
11
|
+
import { CursorRunner } from "sylas-cursor-runner";
|
|
12
|
+
import { GeminiRunner } from "sylas-gemini-runner";
|
|
13
|
+
import { extractCommentAuthor, extractCommentBody, extractCommentId, extractCommentUrl, extractPRBranchRef, extractPRNumber, extractPRTitle, extractRepoFullName, extractRepoName, extractRepoOwner, extractSessionKey, GitHubCommentService, GitHubEventTransport, isCommentOnPullRequest, isIssueCommentPayload, isPullRequestReviewCommentPayload, stripMention, } from "sylas-github-event-transport";
|
|
14
|
+
import { LinearEventTransport, LinearIssueTrackerService, } from "sylas-linear-event-transport";
|
|
15
|
+
import { createSylasToolsServer, } from "sylas-mcp-tools";
|
|
16
|
+
import { OpenCodeRunner } from "sylas-opencode-runner";
|
|
17
|
+
import { SlackEventTransport, } from "sylas-slack-event-transport";
|
|
18
|
+
import { ActivityPoster } from "./ActivityPoster.js";
|
|
19
|
+
import { AgentSessionManager } from "./AgentSessionManager.js";
|
|
20
|
+
import { AskUserQuestionHandler } from "./AskUserQuestionHandler.js";
|
|
21
|
+
import { AttachmentService } from "./AttachmentService.js";
|
|
22
|
+
import { ChatSessionHandler } from "./ChatSessionHandler.js";
|
|
23
|
+
import { ConfigManager } from "./ConfigManager.js";
|
|
24
|
+
import { GitService } from "./GitService.js";
|
|
25
|
+
import { GlobalSessionRegistry } from "./GlobalSessionRegistry.js";
|
|
26
|
+
import { PromptBuilder } from "./PromptBuilder.js";
|
|
27
|
+
import { ProcedureAnalyzer, } from "./procedures/index.js";
|
|
28
|
+
import { RepositoryRouter, } from "./RepositoryRouter.js";
|
|
29
|
+
import { RunnerSelectionService } from "./RunnerSelectionService.js";
|
|
30
|
+
import { SharedApplicationServer } from "./SharedApplicationServer.js";
|
|
31
|
+
import { SlackChatAdapter } from "./SlackChatAdapter.js";
|
|
32
|
+
import { LinearActivitySink } from "./sinks/LinearActivitySink.js";
|
|
33
|
+
import { UserAccessControl } from "./UserAccessControl.js";
|
|
34
|
+
/**
|
|
35
|
+
* Unified edge worker that **orchestrates**
|
|
36
|
+
* capturing Linear webhooks,
|
|
37
|
+
* managing Claude Code processes, and
|
|
38
|
+
* processes results through to Linear Agent Activity Sessions
|
|
39
|
+
*/
|
|
40
|
+
export class EdgeWorker extends EventEmitter {
|
|
41
|
+
config;
|
|
42
|
+
repositories = new Map(); // repository 'id' (internal, stored in config.json) mapped to the full repo config
|
|
43
|
+
agentSessionManagers = new Map(); // Maps repository ID to AgentSessionManager, which manages agent runners for a repo
|
|
44
|
+
issueTrackers = new Map(); // one issue tracker per 'repository'
|
|
45
|
+
linearEventTransport = null; // Single event transport for webhook delivery
|
|
46
|
+
gitHubEventTransport = null; // GitHub event transport for forwarded GitHub webhooks
|
|
47
|
+
slackEventTransport = null;
|
|
48
|
+
chatSessionHandler = null;
|
|
49
|
+
gitHubCommentService; // Service for posting comments back to GitHub PRs
|
|
50
|
+
cliRPCServer = null; // CLI RPC server for CLI platform mode
|
|
51
|
+
configUpdater = null; // Single config updater for configuration updates
|
|
52
|
+
persistenceManager;
|
|
53
|
+
sharedApplicationServer;
|
|
54
|
+
sylasHome;
|
|
55
|
+
globalSessionRegistry; // Centralized session storage across all repositories
|
|
56
|
+
childToParentAgentSession = new Map(); // Maps child agentSessionId to parent agentSessionId
|
|
57
|
+
procedureAnalyzer; // Intelligent workflow routing
|
|
58
|
+
configPath; // Path to config.json file
|
|
59
|
+
/** @internal - Exposed for testing only */
|
|
60
|
+
repositoryRouter; // Repository routing and selection
|
|
61
|
+
gitService;
|
|
62
|
+
activeWebhookCount = 0; // Track number of webhooks currently being processed
|
|
63
|
+
/** Handler for AskUserQuestion tool invocations via Linear select signal */
|
|
64
|
+
askUserQuestionHandler;
|
|
65
|
+
/** User access control for whitelisting/blacklisting Linear users */
|
|
66
|
+
userAccessControl;
|
|
67
|
+
logger;
|
|
68
|
+
// Extracted service modules
|
|
69
|
+
attachmentService;
|
|
70
|
+
runnerSelectionService;
|
|
71
|
+
activityPoster;
|
|
72
|
+
configManager;
|
|
73
|
+
promptBuilder;
|
|
74
|
+
sylasToolsMcpEndpoint = "/mcp/sylas-tools";
|
|
75
|
+
sylasToolsMcpRegistered = false;
|
|
76
|
+
sylasToolsMcpContexts = new Map();
|
|
77
|
+
sylasToolsMcpRequestContext = new AsyncLocalStorage();
|
|
78
|
+
sylasToolsMcpSessions = new Sessions();
|
|
79
|
+
constructor(config) {
|
|
80
|
+
super();
|
|
81
|
+
this.config = config;
|
|
82
|
+
this.sylasHome = config.sylasHome;
|
|
83
|
+
this.logger = createLogger({ component: "EdgeWorker" });
|
|
84
|
+
this.persistenceManager = new PersistenceManager(join(this.sylasHome, "state"));
|
|
85
|
+
// Initialize GitHub comment service for posting replies to GitHub PRs
|
|
86
|
+
this.gitHubCommentService = new GitHubCommentService();
|
|
87
|
+
// Initialize global session registry (centralized session storage)
|
|
88
|
+
this.globalSessionRegistry = new GlobalSessionRegistry();
|
|
89
|
+
// Initialize procedure router with haiku for fast classification
|
|
90
|
+
// Default to claude runner
|
|
91
|
+
this.procedureAnalyzer = new ProcedureAnalyzer({
|
|
92
|
+
sylasHome: this.sylasHome,
|
|
93
|
+
model: "gemini-2.5-flash-lite",
|
|
94
|
+
timeoutMs: 100000,
|
|
95
|
+
runnerType: "gemini",
|
|
96
|
+
});
|
|
97
|
+
// Initialize repository router with dependencies
|
|
98
|
+
const repositoryRouterDeps = {
|
|
99
|
+
fetchIssueLabels: async (issueId, workspaceId) => {
|
|
100
|
+
// Find repository for this workspace
|
|
101
|
+
const repo = Array.from(this.repositories.values()).find((r) => r.linearWorkspaceId === workspaceId);
|
|
102
|
+
if (!repo)
|
|
103
|
+
return [];
|
|
104
|
+
// Get issue tracker for this repository
|
|
105
|
+
const issueTracker = this.issueTrackers.get(repo.id);
|
|
106
|
+
if (!issueTracker)
|
|
107
|
+
return [];
|
|
108
|
+
// Use platform-agnostic getIssueLabels method
|
|
109
|
+
return await issueTracker.getIssueLabels(issueId);
|
|
110
|
+
},
|
|
111
|
+
fetchIssueDescription: async (issueId, workspaceId) => {
|
|
112
|
+
// Find repository for this workspace
|
|
113
|
+
const repo = Array.from(this.repositories.values()).find((r) => r.linearWorkspaceId === workspaceId);
|
|
114
|
+
if (!repo)
|
|
115
|
+
return undefined;
|
|
116
|
+
// Get issue tracker for this repository
|
|
117
|
+
const issueTracker = this.issueTrackers.get(repo.id);
|
|
118
|
+
if (!issueTracker)
|
|
119
|
+
return undefined;
|
|
120
|
+
// Fetch issue and get description
|
|
121
|
+
try {
|
|
122
|
+
const issue = await issueTracker.fetchIssue(issueId);
|
|
123
|
+
return issue?.description ?? undefined;
|
|
124
|
+
}
|
|
125
|
+
catch (error) {
|
|
126
|
+
this.logger.error(`Failed to fetch issue description for routing:`, error);
|
|
127
|
+
return undefined;
|
|
128
|
+
}
|
|
129
|
+
},
|
|
130
|
+
hasActiveSession: (issueId, repositoryId) => {
|
|
131
|
+
const sessionManager = this.agentSessionManagers.get(repositoryId);
|
|
132
|
+
if (!sessionManager)
|
|
133
|
+
return false;
|
|
134
|
+
const activeSessions = sessionManager.getActiveSessionsByIssueId(issueId);
|
|
135
|
+
return activeSessions.length > 0;
|
|
136
|
+
},
|
|
137
|
+
getIssueTracker: (workspaceId) => {
|
|
138
|
+
return this.getIssueTrackerForWorkspace(workspaceId);
|
|
139
|
+
},
|
|
140
|
+
};
|
|
141
|
+
this.repositoryRouter = new RepositoryRouter(repositoryRouterDeps);
|
|
142
|
+
this.gitService = new GitService();
|
|
143
|
+
// Initialize AskUserQuestion handler for elicitation via Linear select signal
|
|
144
|
+
this.askUserQuestionHandler = new AskUserQuestionHandler({
|
|
145
|
+
getIssueTracker: (workspaceId) => {
|
|
146
|
+
return this.getIssueTrackerForWorkspace(workspaceId) ?? null;
|
|
147
|
+
},
|
|
148
|
+
});
|
|
149
|
+
// Initialize shared application server
|
|
150
|
+
const serverPort = config.serverPort || config.webhookPort || 3456;
|
|
151
|
+
const serverHost = config.serverHost || "localhost";
|
|
152
|
+
const skipTunnel = config.platform === "cli"; // Skip Cloudflare tunnel in CLI mode
|
|
153
|
+
this.sharedApplicationServer = new SharedApplicationServer(serverPort, serverHost, skipTunnel);
|
|
154
|
+
// Initialize repositories with path resolution
|
|
155
|
+
for (const repo of config.repositories) {
|
|
156
|
+
if (repo.isActive !== false) {
|
|
157
|
+
// Resolve paths that may contain tilde (~) prefix
|
|
158
|
+
const resolvedRepo = {
|
|
159
|
+
...repo,
|
|
160
|
+
repositoryPath: resolvePath(repo.repositoryPath),
|
|
161
|
+
workspaceBaseDir: resolvePath(repo.workspaceBaseDir),
|
|
162
|
+
mcpConfigPath: Array.isArray(repo.mcpConfigPath)
|
|
163
|
+
? repo.mcpConfigPath.map(resolvePath)
|
|
164
|
+
: repo.mcpConfigPath
|
|
165
|
+
? resolvePath(repo.mcpConfigPath)
|
|
166
|
+
: undefined,
|
|
167
|
+
promptTemplatePath: repo.promptTemplatePath
|
|
168
|
+
? resolvePath(repo.promptTemplatePath)
|
|
169
|
+
: undefined,
|
|
170
|
+
openaiOutputDirectory: repo.openaiOutputDirectory
|
|
171
|
+
? resolvePath(repo.openaiOutputDirectory)
|
|
172
|
+
: undefined,
|
|
173
|
+
};
|
|
174
|
+
this.repositories.set(repo.id, resolvedRepo);
|
|
175
|
+
// Create issue tracker for this repository's workspace
|
|
176
|
+
const issueTracker = this.config.platform === "cli"
|
|
177
|
+
? (() => {
|
|
178
|
+
const service = new CLIIssueTrackerService();
|
|
179
|
+
service.seedDefaultData();
|
|
180
|
+
return service;
|
|
181
|
+
})()
|
|
182
|
+
: new LinearIssueTrackerService(new LinearClient({
|
|
183
|
+
accessToken: repo.linearToken,
|
|
184
|
+
}), this.buildOAuthConfig(resolvedRepo));
|
|
185
|
+
this.issueTrackers.set(repo.id, issueTracker);
|
|
186
|
+
// Create AgentSessionManager for this repository with parent session lookup and resume callback
|
|
187
|
+
//
|
|
188
|
+
// Note: This pattern works (despite appearing recursive) because:
|
|
189
|
+
// 1. The agentSessionManager variable is captured by the closure after it's assigned
|
|
190
|
+
// 2. JavaScript's variable hoisting means 'agentSessionManager' exists (but is undefined) when the arrow function is created
|
|
191
|
+
// 3. By the time the callback is actually invoked (when a child session completes), agentSessionManager is fully initialized
|
|
192
|
+
// 4. The callback only executes asynchronously, well after the constructor has completed and agentSessionManager is assigned
|
|
193
|
+
//
|
|
194
|
+
// This allows the AgentSessionManager to call back into itself to access its own sessions,
|
|
195
|
+
// enabling child sessions to trigger parent session resumption using the same manager instance.
|
|
196
|
+
const activitySink = new LinearActivitySink(issueTracker, repo.linearWorkspaceId);
|
|
197
|
+
const agentSessionManager = new AgentSessionManager(activitySink, (childSessionId) => {
|
|
198
|
+
this.logger.debug(`Looking up parent session for child ${childSessionId}`);
|
|
199
|
+
const parentId = this.globalSessionRegistry.getParentSessionId(childSessionId);
|
|
200
|
+
this.logger.debug(`Child ${childSessionId} -> Parent ${parentId || "not found"}`);
|
|
201
|
+
return parentId;
|
|
202
|
+
}, async (parentSessionId, prompt, childSessionId) => {
|
|
203
|
+
await this.handleResumeParentSession(parentSessionId, prompt, childSessionId, repo, agentSessionManager);
|
|
204
|
+
}, this.procedureAnalyzer, this.sharedApplicationServer);
|
|
205
|
+
// Subscribe to subroutine completion events
|
|
206
|
+
agentSessionManager.on("subroutineComplete", async ({ sessionId, session }) => {
|
|
207
|
+
await this.handleSubroutineTransition(sessionId, session, repo, agentSessionManager);
|
|
208
|
+
});
|
|
209
|
+
// Subscribe to validation loop events
|
|
210
|
+
agentSessionManager.on("validationLoopIteration", async ({ sessionId, session, fixerPrompt, iteration, maxIterations, }) => {
|
|
211
|
+
this.logger.info(`Validation loop iteration ${iteration}/${maxIterations}, running fixer`);
|
|
212
|
+
await this.handleValidationLoopFixer(sessionId, session, repo, agentSessionManager, fixerPrompt, iteration);
|
|
213
|
+
});
|
|
214
|
+
agentSessionManager.on("validationLoopRerun", async ({ sessionId, session, iteration }) => {
|
|
215
|
+
this.logger.info(`Validation loop re-running verifications (iteration ${iteration})`);
|
|
216
|
+
await this.handleValidationLoopRerun(sessionId, session, repo, agentSessionManager);
|
|
217
|
+
});
|
|
218
|
+
this.agentSessionManagers.set(repo.id, agentSessionManager);
|
|
219
|
+
}
|
|
220
|
+
}
|
|
221
|
+
// Initialize user access control with global and per-repository configs
|
|
222
|
+
const repoAccessConfigs = new Map();
|
|
223
|
+
for (const repo of config.repositories) {
|
|
224
|
+
if (repo.isActive !== false) {
|
|
225
|
+
repoAccessConfigs.set(repo.id, repo.userAccessControl);
|
|
226
|
+
}
|
|
227
|
+
}
|
|
228
|
+
this.userAccessControl = new UserAccessControl(config.userAccessControl, repoAccessConfigs);
|
|
229
|
+
// Initialize extracted service modules
|
|
230
|
+
this.attachmentService = new AttachmentService(this.logger, this.sylasHome);
|
|
231
|
+
this.runnerSelectionService = new RunnerSelectionService(this.config, this.logger);
|
|
232
|
+
this.activityPoster = new ActivityPoster(this.issueTrackers, this.repositories, this.logger);
|
|
233
|
+
this.configManager = new ConfigManager(this.config, this.logger, this.configPath, this.repositories);
|
|
234
|
+
this.promptBuilder = new PromptBuilder({
|
|
235
|
+
logger: this.logger,
|
|
236
|
+
repositories: this.repositories,
|
|
237
|
+
issueTrackers: this.issueTrackers,
|
|
238
|
+
gitService: this.gitService,
|
|
239
|
+
config: this.config,
|
|
240
|
+
});
|
|
241
|
+
// Components will be initialized and registered in start() method before server starts
|
|
242
|
+
}
|
|
243
|
+
/**
|
|
244
|
+
* Start the edge worker
|
|
245
|
+
*/
|
|
246
|
+
async start() {
|
|
247
|
+
// Load persisted state for each repository
|
|
248
|
+
await this.loadPersistedState();
|
|
249
|
+
// Start config file watcher via ConfigManager
|
|
250
|
+
this.configManager.on("configChanged", async (changes) => {
|
|
251
|
+
await this.removeDeletedRepositories(changes.removed);
|
|
252
|
+
await this.updateModifiedRepositories(changes.modified);
|
|
253
|
+
await this.addNewRepositories(changes.added);
|
|
254
|
+
this.config = changes.newConfig;
|
|
255
|
+
this.configManager.setConfig(changes.newConfig);
|
|
256
|
+
});
|
|
257
|
+
this.configManager.startConfigWatcher();
|
|
258
|
+
// Initialize and register components BEFORE starting server (routes must be registered before listen())
|
|
259
|
+
await this.initializeComponents();
|
|
260
|
+
// Start shared application server (this also starts Cloudflare tunnel if CLOUDFLARE_TOKEN is set)
|
|
261
|
+
await this.sharedApplicationServer.start();
|
|
262
|
+
}
|
|
263
|
+
/**
|
|
264
|
+
* Initialize and register components (routes) before server starts
|
|
265
|
+
*/
|
|
266
|
+
async initializeComponents() {
|
|
267
|
+
// Get the first active repository for configuration
|
|
268
|
+
const firstRepo = Array.from(this.repositories.values())[0];
|
|
269
|
+
if (!firstRepo) {
|
|
270
|
+
throw new Error("No active repositories configured");
|
|
271
|
+
}
|
|
272
|
+
// Platform-specific initialization
|
|
273
|
+
if (this.config.platform === "cli") {
|
|
274
|
+
// CLI mode: Create and register CLIRPCServer
|
|
275
|
+
const firstIssueTracker = this.issueTrackers.get(firstRepo.id);
|
|
276
|
+
if (!firstIssueTracker) {
|
|
277
|
+
throw new Error("Issue tracker not found for first repository");
|
|
278
|
+
}
|
|
279
|
+
// Type guard to ensure it's a CLIIssueTrackerService
|
|
280
|
+
if (!(firstIssueTracker instanceof CLIIssueTrackerService)) {
|
|
281
|
+
throw new Error("CLI platform requires CLIIssueTrackerService but found different implementation");
|
|
282
|
+
}
|
|
283
|
+
this.cliRPCServer = new CLIRPCServer({
|
|
284
|
+
fastifyServer: this.sharedApplicationServer.getFastifyInstance(),
|
|
285
|
+
issueTracker: firstIssueTracker,
|
|
286
|
+
version: "1.0.0",
|
|
287
|
+
});
|
|
288
|
+
// Register the /cli/rpc endpoint
|
|
289
|
+
this.cliRPCServer.register();
|
|
290
|
+
this.logger.info("✅ CLI RPC server registered");
|
|
291
|
+
this.logger.info(" RPC endpoint: /cli/rpc");
|
|
292
|
+
// Create CLI event transport and register listener
|
|
293
|
+
const cliEventTransport = firstIssueTracker.createEventTransport({
|
|
294
|
+
platform: "cli",
|
|
295
|
+
fastifyServer: this.sharedApplicationServer.getFastifyInstance(),
|
|
296
|
+
});
|
|
297
|
+
// Listen for webhook events (same pattern as Linear mode)
|
|
298
|
+
cliEventTransport.on("event", (event) => {
|
|
299
|
+
// Get all active repositories for webhook handling
|
|
300
|
+
const repos = Array.from(this.repositories.values());
|
|
301
|
+
this.handleWebhook(event, repos);
|
|
302
|
+
});
|
|
303
|
+
// Listen for errors
|
|
304
|
+
cliEventTransport.on("error", (error) => {
|
|
305
|
+
this.handleError(error);
|
|
306
|
+
});
|
|
307
|
+
// Register the CLI event transport endpoints
|
|
308
|
+
cliEventTransport.register();
|
|
309
|
+
this.logger.info("✅ CLI event transport registered");
|
|
310
|
+
this.logger.info(" Event listener: listening for AgentSessionCreated events");
|
|
311
|
+
}
|
|
312
|
+
else {
|
|
313
|
+
// Linear mode: Create and register LinearEventTransport
|
|
314
|
+
const useDirectWebhooks = process.env.LINEAR_DIRECT_WEBHOOKS?.toLowerCase() === "true";
|
|
315
|
+
const verificationMode = useDirectWebhooks ? "direct" : "proxy";
|
|
316
|
+
// Get appropriate secret based on mode
|
|
317
|
+
const secret = useDirectWebhooks
|
|
318
|
+
? process.env.LINEAR_WEBHOOK_SECRET || ""
|
|
319
|
+
: process.env.SYLAS_API_KEY || "";
|
|
320
|
+
this.linearEventTransport = new LinearEventTransport({
|
|
321
|
+
fastifyServer: this.sharedApplicationServer.getFastifyInstance(),
|
|
322
|
+
verificationMode,
|
|
323
|
+
secret,
|
|
324
|
+
});
|
|
325
|
+
// Listen for legacy webhook events (deprecated, kept for backward compatibility)
|
|
326
|
+
this.linearEventTransport.on("event", (event) => {
|
|
327
|
+
// Get all active repositories for webhook handling
|
|
328
|
+
const repos = Array.from(this.repositories.values());
|
|
329
|
+
this.handleWebhook(event, repos);
|
|
330
|
+
});
|
|
331
|
+
// Listen for unified internal messages (new message bus)
|
|
332
|
+
this.linearEventTransport.on("message", (message) => {
|
|
333
|
+
this.handleMessage(message);
|
|
334
|
+
});
|
|
335
|
+
// Listen for errors
|
|
336
|
+
this.linearEventTransport.on("error", (error) => {
|
|
337
|
+
this.handleError(error);
|
|
338
|
+
});
|
|
339
|
+
// Register the /webhook endpoint
|
|
340
|
+
this.linearEventTransport.register();
|
|
341
|
+
this.logger.info(`✅ Linear event transport registered (${verificationMode} mode)`);
|
|
342
|
+
this.logger.info(` Webhook endpoint: ${this.sharedApplicationServer.getWebhookUrl()}`);
|
|
343
|
+
}
|
|
344
|
+
// 2. Register GitHub event transport (for forwarded GitHub webhooks from CYHOST)
|
|
345
|
+
// This is registered regardless of platform mode since GitHub webhooks can come from CYHOST
|
|
346
|
+
this.registerGitHubEventTransport();
|
|
347
|
+
// 2b. Register Slack event transport (for forwarded Slack webhooks from CYHOST)
|
|
348
|
+
this.registerSlackEventTransport();
|
|
349
|
+
// 3. Create and register ConfigUpdater (both platforms)
|
|
350
|
+
this.configUpdater = new ConfigUpdater(this.sharedApplicationServer.getFastifyInstance(), this.sylasHome, process.env.SYLAS_API_KEY || "");
|
|
351
|
+
// Register config update routes
|
|
352
|
+
this.configUpdater.register();
|
|
353
|
+
this.logger.info("✅ Config updater registered");
|
|
354
|
+
this.logger.info(" Routes: /api/update/sylas-config, /api/update/sylas-env,");
|
|
355
|
+
this.logger.info(" /api/update/repository, /api/test-mcp, /api/configure-mcp");
|
|
356
|
+
// 3. Register MCP endpoint for sylas-tools on the same Fastify server/port
|
|
357
|
+
await this.registerSylasToolsMcpEndpoint();
|
|
358
|
+
// 4. Register /status endpoint for process activity monitoring
|
|
359
|
+
this.registerStatusEndpoint();
|
|
360
|
+
// 5. Register /version endpoint for CLI version info
|
|
361
|
+
this.registerVersionEndpoint();
|
|
362
|
+
}
|
|
363
|
+
/**
|
|
364
|
+
* Register the /status endpoint for checking if the process is busy or idle
|
|
365
|
+
* This endpoint is used to determine if the process can be safely restarted
|
|
366
|
+
*/
|
|
367
|
+
registerStatusEndpoint() {
|
|
368
|
+
const fastify = this.sharedApplicationServer.getFastifyInstance();
|
|
369
|
+
fastify.get("/status", async (_request, reply) => {
|
|
370
|
+
const status = this.computeStatus();
|
|
371
|
+
return reply.status(200).send({ status });
|
|
372
|
+
});
|
|
373
|
+
this.logger.info("✅ Status endpoint registered");
|
|
374
|
+
this.logger.info(" Route: GET /status");
|
|
375
|
+
}
|
|
376
|
+
/**
|
|
377
|
+
* Register the /version endpoint for CLI version information
|
|
378
|
+
* This endpoint is used by dashboards to display the installed CLI version
|
|
379
|
+
*/
|
|
380
|
+
registerVersionEndpoint() {
|
|
381
|
+
const fastify = this.sharedApplicationServer.getFastifyInstance();
|
|
382
|
+
fastify.get("/version", async (_request, reply) => {
|
|
383
|
+
return reply.status(200).send({
|
|
384
|
+
sylas_cli_version: this.config.version ?? null,
|
|
385
|
+
});
|
|
386
|
+
});
|
|
387
|
+
this.logger.info("✅ Version endpoint registered");
|
|
388
|
+
this.logger.info(" Route: GET /version");
|
|
389
|
+
}
|
|
390
|
+
/**
|
|
391
|
+
* Register the GitHub event transport for receiving forwarded GitHub webhooks from CYHOST.
|
|
392
|
+
* This creates a /github-webhook endpoint that handles @sylasagent mentions on GitHub PRs.
|
|
393
|
+
*/
|
|
394
|
+
registerGitHubEventTransport() {
|
|
395
|
+
// Use the same verification approach as Linear webhooks
|
|
396
|
+
// In proxy mode: Bearer token (SYLAS_API_KEY)
|
|
397
|
+
// In direct/cloud mode: GitHub HMAC-SHA256 signature
|
|
398
|
+
const useSignatureVerification = process.env.GITHUB_WEBHOOK_SECRET != null &&
|
|
399
|
+
process.env.GITHUB_WEBHOOK_SECRET !== "";
|
|
400
|
+
const verificationMode = useSignatureVerification ? "signature" : "proxy";
|
|
401
|
+
const secret = useSignatureVerification
|
|
402
|
+
? process.env.GITHUB_WEBHOOK_SECRET
|
|
403
|
+
: process.env.SYLAS_API_KEY || "";
|
|
404
|
+
this.gitHubEventTransport = new GitHubEventTransport({
|
|
405
|
+
fastifyServer: this.sharedApplicationServer.getFastifyInstance(),
|
|
406
|
+
verificationMode,
|
|
407
|
+
secret,
|
|
408
|
+
});
|
|
409
|
+
// Listen for legacy GitHub webhook events (deprecated, kept for backward compatibility)
|
|
410
|
+
this.gitHubEventTransport.on("event", (event) => {
|
|
411
|
+
this.handleGitHubWebhook(event).catch((error) => {
|
|
412
|
+
this.logger.error("Failed to handle GitHub webhook", error instanceof Error ? error : new Error(String(error)));
|
|
413
|
+
});
|
|
414
|
+
});
|
|
415
|
+
// Listen for unified internal messages (new message bus)
|
|
416
|
+
this.gitHubEventTransport.on("message", (message) => {
|
|
417
|
+
this.handleMessage(message);
|
|
418
|
+
});
|
|
419
|
+
// Listen for errors
|
|
420
|
+
this.gitHubEventTransport.on("error", (error) => {
|
|
421
|
+
this.handleError(error);
|
|
422
|
+
});
|
|
423
|
+
// Register the /github-webhook endpoint
|
|
424
|
+
this.gitHubEventTransport.register();
|
|
425
|
+
this.logger.info(`GitHub event transport registered (${verificationMode} mode)`);
|
|
426
|
+
this.logger.info("Webhook endpoint: POST /github-webhook");
|
|
427
|
+
}
|
|
428
|
+
/**
|
|
429
|
+
* Register the Slack event transport for receiving forwarded Slack webhooks from CYHOST.
|
|
430
|
+
* This creates a /slack-webhook endpoint that handles @mention events from Slack.
|
|
431
|
+
*/
|
|
432
|
+
registerSlackEventTransport() {
|
|
433
|
+
const slackAdapter = new SlackChatAdapter(this.logger);
|
|
434
|
+
// Build MCP config for Slack sessions using the first repository's Linear token
|
|
435
|
+
const firstRepo = Array.from(this.repositories.values())[0];
|
|
436
|
+
const mcpConfig = firstRepo ? this.buildMcpConfig(firstRepo) : undefined;
|
|
437
|
+
if (!firstRepo) {
|
|
438
|
+
this.logger.warn("No repositories configured — Slack sessions will not have access to Linear MCP tools");
|
|
439
|
+
}
|
|
440
|
+
this.chatSessionHandler = new ChatSessionHandler(slackAdapter, {
|
|
441
|
+
sylasHome: this.sylasHome,
|
|
442
|
+
defaultModel: this.config.defaultModel,
|
|
443
|
+
defaultFallbackModel: this.config.defaultFallbackModel,
|
|
444
|
+
mcpConfig,
|
|
445
|
+
onWebhookStart: () => {
|
|
446
|
+
this.activeWebhookCount++;
|
|
447
|
+
},
|
|
448
|
+
onWebhookEnd: () => {
|
|
449
|
+
this.activeWebhookCount--;
|
|
450
|
+
},
|
|
451
|
+
onStateChange: () => this.savePersistedState(),
|
|
452
|
+
onClaudeError: (error) => this.handleClaudeError(error),
|
|
453
|
+
}, this.logger);
|
|
454
|
+
this.slackEventTransport = new SlackEventTransport({
|
|
455
|
+
fastifyServer: this.sharedApplicationServer.getFastifyInstance(),
|
|
456
|
+
verificationMode: "proxy",
|
|
457
|
+
secret: process.env.SYLAS_API_KEY || "",
|
|
458
|
+
});
|
|
459
|
+
this.slackEventTransport.on("event", (event) => {
|
|
460
|
+
this.chatSessionHandler.handleEvent(event).catch((error) => {
|
|
461
|
+
this.logger.error("Failed to handle Slack webhook", error instanceof Error ? error : new Error(String(error)));
|
|
462
|
+
});
|
|
463
|
+
});
|
|
464
|
+
this.slackEventTransport.on("message", (message) => {
|
|
465
|
+
this.handleMessage(message);
|
|
466
|
+
});
|
|
467
|
+
this.slackEventTransport.on("error", (error) => {
|
|
468
|
+
this.handleError(error);
|
|
469
|
+
});
|
|
470
|
+
this.slackEventTransport.register();
|
|
471
|
+
this.logger.info("Slack event transport registered");
|
|
472
|
+
}
|
|
473
|
+
/**
|
|
474
|
+
* Handle a GitHub webhook event (forwarded from CYHOST).
|
|
475
|
+
*
|
|
476
|
+
* This creates a new session for the GitHub PR comment, checks out the PR branch
|
|
477
|
+
* via git worktree, and processes the comment as a task prompt.
|
|
478
|
+
*/
|
|
479
|
+
async handleGitHubWebhook(event) {
|
|
480
|
+
this.activeWebhookCount++;
|
|
481
|
+
try {
|
|
482
|
+
// Only handle comments on pull requests
|
|
483
|
+
if (!isCommentOnPullRequest(event)) {
|
|
484
|
+
this.logger.debug("Ignoring GitHub comment on non-PR issue");
|
|
485
|
+
return;
|
|
486
|
+
}
|
|
487
|
+
const repoFullName = extractRepoFullName(event);
|
|
488
|
+
const prNumber = extractPRNumber(event);
|
|
489
|
+
const commentBody = extractCommentBody(event);
|
|
490
|
+
const commentAuthor = extractCommentAuthor(event);
|
|
491
|
+
const prTitle = extractPRTitle(event);
|
|
492
|
+
const sessionKey = extractSessionKey(event);
|
|
493
|
+
this.logger.info(`Processing GitHub webhook: ${repoFullName}#${prNumber} by @${commentAuthor}`);
|
|
494
|
+
// Add "eyes" reaction to acknowledge receipt
|
|
495
|
+
const reactionToken = event.installationToken || process.env.GITHUB_TOKEN;
|
|
496
|
+
if (reactionToken) {
|
|
497
|
+
const commentId = extractCommentId(event);
|
|
498
|
+
if (commentId) {
|
|
499
|
+
this.gitHubCommentService
|
|
500
|
+
.addReaction({
|
|
501
|
+
token: reactionToken,
|
|
502
|
+
owner: extractRepoOwner(event),
|
|
503
|
+
repo: extractRepoName(event),
|
|
504
|
+
commentId,
|
|
505
|
+
isPullRequestReviewComment: isPullRequestReviewCommentPayload(event.payload),
|
|
506
|
+
content: "eyes",
|
|
507
|
+
})
|
|
508
|
+
.catch((err) => {
|
|
509
|
+
this.logger.warn(`Failed to add reaction: ${err instanceof Error ? err.message : err}`);
|
|
510
|
+
});
|
|
511
|
+
}
|
|
512
|
+
}
|
|
513
|
+
// Find the repository configuration that matches this GitHub repo
|
|
514
|
+
const repository = this.findRepositoryByGitHubUrl(repoFullName);
|
|
515
|
+
if (!repository) {
|
|
516
|
+
this.logger.warn(`No repository configured for GitHub repo: ${repoFullName}`);
|
|
517
|
+
return;
|
|
518
|
+
}
|
|
519
|
+
// Get the agent session manager for this repository
|
|
520
|
+
const agentSessionManager = this.agentSessionManagers.get(repository.id);
|
|
521
|
+
if (!agentSessionManager) {
|
|
522
|
+
this.logger.error(`No AgentSessionManager for repository ${repository.name}`);
|
|
523
|
+
return;
|
|
524
|
+
}
|
|
525
|
+
// Determine the PR branch
|
|
526
|
+
let branchRef = extractPRBranchRef(event);
|
|
527
|
+
// For issue_comment events, the branch ref is not in the payload
|
|
528
|
+
// We need to fetch it from the GitHub API
|
|
529
|
+
if (!branchRef && isIssueCommentPayload(event.payload)) {
|
|
530
|
+
branchRef = await this.fetchPRBranchRef(event, repository);
|
|
531
|
+
}
|
|
532
|
+
if (!branchRef) {
|
|
533
|
+
this.logger.error(`Could not determine branch for ${repoFullName}#${prNumber}`);
|
|
534
|
+
return;
|
|
535
|
+
}
|
|
536
|
+
// Strip the @sylasagent mention to get the task instructions
|
|
537
|
+
const taskInstructions = stripMention(commentBody);
|
|
538
|
+
// Create workspace (git worktree) for the PR branch
|
|
539
|
+
const workspace = await this.createGitHubWorkspace(repository, branchRef, prNumber);
|
|
540
|
+
if (!workspace) {
|
|
541
|
+
this.logger.error(`Failed to create workspace for ${repoFullName}#${prNumber}`);
|
|
542
|
+
return;
|
|
543
|
+
}
|
|
544
|
+
this.logger.info(`GitHub workspace created at: ${workspace.path}`);
|
|
545
|
+
// Check if another active session is already using this branch/workspace
|
|
546
|
+
const existingSessions = agentSessionManager.getActiveSessionsByBranchName(branchRef);
|
|
547
|
+
const firstExisting = existingSessions[0];
|
|
548
|
+
if (firstExisting) {
|
|
549
|
+
this.logger.warn(`Reusing workspace from active session ${firstExisting.id} — concurrent writes possible`);
|
|
550
|
+
}
|
|
551
|
+
// Create a synthetic session for this GitHub PR comment
|
|
552
|
+
const issueMinimal = {
|
|
553
|
+
id: sessionKey,
|
|
554
|
+
identifier: `${extractRepoName(event)}#${prNumber}`,
|
|
555
|
+
title: prTitle || `PR #${prNumber}`,
|
|
556
|
+
branchName: branchRef,
|
|
557
|
+
};
|
|
558
|
+
// Create an internal agent session (no Linear session for GitHub)
|
|
559
|
+
const githubSessionId = `github-${event.deliveryId}`;
|
|
560
|
+
agentSessionManager.createLinearAgentSession(githubSessionId, sessionKey, issueMinimal, workspace, "github");
|
|
561
|
+
const session = agentSessionManager.getSession(githubSessionId);
|
|
562
|
+
if (!session) {
|
|
563
|
+
this.logger.error(`Failed to create session for GitHub webhook ${event.deliveryId}`);
|
|
564
|
+
return;
|
|
565
|
+
}
|
|
566
|
+
// Initialize procedure metadata
|
|
567
|
+
if (!session.metadata) {
|
|
568
|
+
session.metadata = {};
|
|
569
|
+
}
|
|
570
|
+
// Store GitHub-specific metadata for reply posting
|
|
571
|
+
session.metadata.commentId = String(extractCommentId(event));
|
|
572
|
+
// Build the system prompt for this GitHub PR session
|
|
573
|
+
const systemPrompt = this.buildGitHubSystemPrompt(event, branchRef, taskInstructions);
|
|
574
|
+
// Build allowed tools and directories
|
|
575
|
+
const allowedTools = this.buildAllowedTools(repository);
|
|
576
|
+
const disallowedTools = this.buildDisallowedTools(repository);
|
|
577
|
+
const allowedDirectories = [repository.repositoryPath];
|
|
578
|
+
// Create agent runner using the standard config builder
|
|
579
|
+
const { config: runnerConfig } = this.buildAgentRunnerConfig(session, repository, githubSessionId, systemPrompt, allowedTools, allowedDirectories, disallowedTools, undefined, // resumeSessionId
|
|
580
|
+
undefined, // labels
|
|
581
|
+
undefined, // issueDescription
|
|
582
|
+
200, // maxTurns
|
|
583
|
+
false);
|
|
584
|
+
const runner = new ClaudeRunner(runnerConfig);
|
|
585
|
+
// Store the runner in the session manager
|
|
586
|
+
agentSessionManager.addAgentRunner(githubSessionId, runner);
|
|
587
|
+
// Save persisted state
|
|
588
|
+
await this.savePersistedState();
|
|
589
|
+
this.emit("session:started", sessionKey, issueMinimal, repository.id);
|
|
590
|
+
this.logger.info(`Starting Claude runner for GitHub PR ${repoFullName}#${prNumber}`);
|
|
591
|
+
// Start the session and handle completion
|
|
592
|
+
try {
|
|
593
|
+
const sessionInfo = await runner.start(taskInstructions);
|
|
594
|
+
this.logger.info(`GitHub session started: ${sessionInfo.sessionId}`);
|
|
595
|
+
// When session completes, post the reply back to GitHub
|
|
596
|
+
await this.postGitHubReply(event, runner, repository);
|
|
597
|
+
}
|
|
598
|
+
catch (error) {
|
|
599
|
+
this.logger.error(`GitHub session error for ${repoFullName}#${prNumber}`, error instanceof Error ? error : new Error(String(error)));
|
|
600
|
+
}
|
|
601
|
+
finally {
|
|
602
|
+
await this.savePersistedState();
|
|
603
|
+
}
|
|
604
|
+
}
|
|
605
|
+
catch (error) {
|
|
606
|
+
this.logger.error("Failed to process GitHub webhook", error instanceof Error ? error : new Error(String(error)));
|
|
607
|
+
}
|
|
608
|
+
finally {
|
|
609
|
+
this.activeWebhookCount--;
|
|
610
|
+
}
|
|
611
|
+
}
|
|
612
|
+
/**
|
|
613
|
+
* Find a repository configuration that matches a GitHub repository URL.
|
|
614
|
+
* Matches against the githubUrl field in repository config.
|
|
615
|
+
*/
|
|
616
|
+
findRepositoryByGitHubUrl(repoFullName) {
|
|
617
|
+
for (const repo of this.repositories.values()) {
|
|
618
|
+
if (!repo.githubUrl)
|
|
619
|
+
continue;
|
|
620
|
+
// Match against full name (owner/repo) or URL containing it
|
|
621
|
+
if (repo.githubUrl.includes(repoFullName) ||
|
|
622
|
+
repo.githubUrl.endsWith(`/${repoFullName}`)) {
|
|
623
|
+
return repo;
|
|
624
|
+
}
|
|
625
|
+
}
|
|
626
|
+
return null;
|
|
627
|
+
}
|
|
628
|
+
/**
|
|
629
|
+
* Fetch the PR branch ref for an issue_comment webhook.
|
|
630
|
+
* For issue_comment events, the branch ref is not in the payload
|
|
631
|
+
* and must be fetched from the GitHub API.
|
|
632
|
+
*/
|
|
633
|
+
async fetchPRBranchRef(event, _repository) {
|
|
634
|
+
if (!isIssueCommentPayload(event.payload))
|
|
635
|
+
return null;
|
|
636
|
+
const prUrl = event.payload.issue.pull_request?.url;
|
|
637
|
+
if (!prUrl)
|
|
638
|
+
return null;
|
|
639
|
+
try {
|
|
640
|
+
const owner = extractRepoOwner(event);
|
|
641
|
+
const repo = extractRepoName(event);
|
|
642
|
+
const prNumber = event.payload.issue.number;
|
|
643
|
+
const headers = {
|
|
644
|
+
Accept: "application/vnd.github+json",
|
|
645
|
+
"X-GitHub-Api-Version": "2022-11-28",
|
|
646
|
+
};
|
|
647
|
+
// Prefer forwarded installation token, fall back to GITHUB_TOKEN
|
|
648
|
+
const token = event.installationToken || process.env.GITHUB_TOKEN;
|
|
649
|
+
if (token) {
|
|
650
|
+
headers.Authorization = `Bearer ${token}`;
|
|
651
|
+
}
|
|
652
|
+
const response = await fetch(`https://api.github.com/repos/${owner}/${repo}/pulls/${prNumber}`, { headers });
|
|
653
|
+
if (!response.ok) {
|
|
654
|
+
this.logger.warn(`Failed to fetch PR details from GitHub API: ${response.status}`);
|
|
655
|
+
return null;
|
|
656
|
+
}
|
|
657
|
+
const prData = (await response.json());
|
|
658
|
+
return prData.head?.ref ?? null;
|
|
659
|
+
}
|
|
660
|
+
catch (error) {
|
|
661
|
+
this.logger.error("Failed to fetch PR branch ref", error instanceof Error ? error : new Error(String(error)));
|
|
662
|
+
return null;
|
|
663
|
+
}
|
|
664
|
+
}
|
|
665
|
+
/**
|
|
666
|
+
* Create a git worktree for a GitHub PR branch.
|
|
667
|
+
* If the worktree already exists for this branch, reuse it.
|
|
668
|
+
*/
|
|
669
|
+
async createGitHubWorkspace(repository, branchRef, prNumber) {
|
|
670
|
+
try {
|
|
671
|
+
// Use the GitService to create the worktree
|
|
672
|
+
// Create a synthetic issue-like object for the git service
|
|
673
|
+
const syntheticIssue = {
|
|
674
|
+
id: `github-pr-${prNumber}`,
|
|
675
|
+
identifier: `PR-${prNumber}`,
|
|
676
|
+
title: `PR #${prNumber}`,
|
|
677
|
+
description: null,
|
|
678
|
+
url: "",
|
|
679
|
+
branchName: branchRef,
|
|
680
|
+
assigneeId: null,
|
|
681
|
+
stateId: null,
|
|
682
|
+
teamId: null,
|
|
683
|
+
labelIds: [],
|
|
684
|
+
priority: 0,
|
|
685
|
+
createdAt: new Date(),
|
|
686
|
+
updatedAt: new Date(),
|
|
687
|
+
archivedAt: null,
|
|
688
|
+
state: Promise.resolve(undefined),
|
|
689
|
+
assignee: Promise.resolve(undefined),
|
|
690
|
+
team: Promise.resolve(undefined),
|
|
691
|
+
parent: Promise.resolve(undefined),
|
|
692
|
+
project: Promise.resolve(undefined),
|
|
693
|
+
labels: () => Promise.resolve({ nodes: [] }),
|
|
694
|
+
comments: () => Promise.resolve({ nodes: [] }),
|
|
695
|
+
attachments: () => Promise.resolve({ nodes: [] }),
|
|
696
|
+
children: () => Promise.resolve({ nodes: [] }),
|
|
697
|
+
inverseRelations: () => Promise.resolve({ nodes: [] }),
|
|
698
|
+
update: () => Promise.resolve({
|
|
699
|
+
success: true,
|
|
700
|
+
issue: undefined,
|
|
701
|
+
lastSyncId: 0,
|
|
702
|
+
}),
|
|
703
|
+
};
|
|
704
|
+
return await this.gitService.createGitWorktree(syntheticIssue, repository);
|
|
705
|
+
}
|
|
706
|
+
catch (error) {
|
|
707
|
+
this.logger.error(`Failed to create GitHub workspace for PR #${prNumber}`, error instanceof Error ? error : new Error(String(error)));
|
|
708
|
+
return null;
|
|
709
|
+
}
|
|
710
|
+
}
|
|
711
|
+
/**
|
|
712
|
+
* Build a system prompt for a GitHub PR comment session.
|
|
713
|
+
*/
|
|
714
|
+
buildGitHubSystemPrompt(event, branchRef, taskInstructions) {
|
|
715
|
+
const repoFullName = extractRepoFullName(event);
|
|
716
|
+
const prNumber = extractPRNumber(event);
|
|
717
|
+
const prTitle = extractPRTitle(event);
|
|
718
|
+
const commentAuthor = extractCommentAuthor(event);
|
|
719
|
+
const commentUrl = extractCommentUrl(event);
|
|
720
|
+
return `You are working on a GitHub Pull Request.
|
|
721
|
+
|
|
722
|
+
## Context
|
|
723
|
+
- **Repository**: ${repoFullName}
|
|
724
|
+
- **PR**: #${prNumber} - ${prTitle || "Untitled"}
|
|
725
|
+
- **Branch**: ${branchRef}
|
|
726
|
+
- **Requested by**: @${commentAuthor}
|
|
727
|
+
- **Comment URL**: ${commentUrl}
|
|
728
|
+
|
|
729
|
+
## Task
|
|
730
|
+
${taskInstructions}
|
|
731
|
+
|
|
732
|
+
## Instructions
|
|
733
|
+
- You are already checked out on the PR branch \`${branchRef}\`
|
|
734
|
+
- Make changes directly to the code on this branch
|
|
735
|
+
- After making changes, commit and push them to the branch
|
|
736
|
+
- Be concise in your responses as they will be posted back to the GitHub PR`;
|
|
737
|
+
}
|
|
738
|
+
/**
|
|
739
|
+
* Post a reply back to the GitHub PR comment after the session completes.
|
|
740
|
+
*/
|
|
741
|
+
async postGitHubReply(event, runner, _repository) {
|
|
742
|
+
try {
|
|
743
|
+
// Get the last assistant message from the runner as the summary
|
|
744
|
+
const messages = runner.getMessages();
|
|
745
|
+
const lastAssistantMessage = [...messages]
|
|
746
|
+
.reverse()
|
|
747
|
+
.find((m) => m.type === "assistant");
|
|
748
|
+
let summary = "Task completed. Please review the changes on this branch.";
|
|
749
|
+
if (lastAssistantMessage &&
|
|
750
|
+
lastAssistantMessage.type === "assistant" &&
|
|
751
|
+
"message" in lastAssistantMessage) {
|
|
752
|
+
const msg = lastAssistantMessage;
|
|
753
|
+
const textBlock = msg.message.content?.find((block) => block.type === "text" && block.text);
|
|
754
|
+
if (textBlock?.text) {
|
|
755
|
+
summary = textBlock.text;
|
|
756
|
+
}
|
|
757
|
+
}
|
|
758
|
+
const owner = extractRepoOwner(event);
|
|
759
|
+
const repo = extractRepoName(event);
|
|
760
|
+
const prNumber = extractPRNumber(event);
|
|
761
|
+
const commentId = extractCommentId(event);
|
|
762
|
+
if (!prNumber) {
|
|
763
|
+
this.logger.warn("Cannot post GitHub reply: no PR number");
|
|
764
|
+
return;
|
|
765
|
+
}
|
|
766
|
+
// Prefer the forwarded installation token from CYHOST (1-hour expiry)
|
|
767
|
+
// Fall back to process.env.GITHUB_TOKEN if not provided
|
|
768
|
+
const token = event.installationToken || process.env.GITHUB_TOKEN;
|
|
769
|
+
if (!token) {
|
|
770
|
+
this.logger.warn("Cannot post GitHub reply: no installation token or GITHUB_TOKEN configured");
|
|
771
|
+
this.logger.debug(`Would have posted reply to ${owner}/${repo}#${prNumber} (comment ${commentId}): ${summary}`);
|
|
772
|
+
return;
|
|
773
|
+
}
|
|
774
|
+
if (event.eventType === "pull_request_review_comment") {
|
|
775
|
+
// Reply to the specific review comment thread
|
|
776
|
+
await this.gitHubCommentService.postReviewCommentReply({
|
|
777
|
+
token,
|
|
778
|
+
owner,
|
|
779
|
+
repo,
|
|
780
|
+
pullNumber: prNumber,
|
|
781
|
+
commentId,
|
|
782
|
+
body: summary,
|
|
783
|
+
});
|
|
784
|
+
}
|
|
785
|
+
else {
|
|
786
|
+
// Post as a regular issue comment on the PR
|
|
787
|
+
await this.gitHubCommentService.postIssueComment({
|
|
788
|
+
token,
|
|
789
|
+
owner,
|
|
790
|
+
repo,
|
|
791
|
+
issueNumber: prNumber,
|
|
792
|
+
body: summary,
|
|
793
|
+
});
|
|
794
|
+
}
|
|
795
|
+
this.logger.info(`Posted GitHub reply to ${owner}/${repo}#${prNumber}`);
|
|
796
|
+
}
|
|
797
|
+
catch (error) {
|
|
798
|
+
this.logger.error("Failed to post GitHub reply", error instanceof Error ? error : new Error(String(error)));
|
|
799
|
+
}
|
|
800
|
+
}
|
|
801
|
+
/**
|
|
802
|
+
* Compute the current status of the Sylas process
|
|
803
|
+
* @returns "idle" if the process can be safely restarted, "busy" if work is in progress
|
|
804
|
+
*/
|
|
805
|
+
computeStatus() {
|
|
806
|
+
// Busy if any webhooks are currently being processed
|
|
807
|
+
if (this.activeWebhookCount > 0) {
|
|
808
|
+
return "busy";
|
|
809
|
+
}
|
|
810
|
+
// Busy if any runner is actively running (repository-tied sessions)
|
|
811
|
+
for (const manager of this.agentSessionManagers.values()) {
|
|
812
|
+
const runners = manager.getAllAgentRunners();
|
|
813
|
+
for (const runner of runners) {
|
|
814
|
+
if (runner.isRunning()) {
|
|
815
|
+
return "busy";
|
|
816
|
+
}
|
|
817
|
+
}
|
|
818
|
+
}
|
|
819
|
+
// Busy if any chat platform runner is actively running
|
|
820
|
+
if (this.chatSessionHandler?.isAnyRunnerBusy()) {
|
|
821
|
+
return "busy";
|
|
822
|
+
}
|
|
823
|
+
return "idle";
|
|
824
|
+
}
|
|
825
|
+
/**
|
|
826
|
+
* Stop the edge worker
|
|
827
|
+
*/
|
|
828
|
+
async stop() {
|
|
829
|
+
// Stop config file watcher
|
|
830
|
+
await this.configManager.stop();
|
|
831
|
+
try {
|
|
832
|
+
await this.savePersistedState();
|
|
833
|
+
this.logger.info("✅ EdgeWorker state saved successfully");
|
|
834
|
+
}
|
|
835
|
+
catch (error) {
|
|
836
|
+
this.logger.error("❌ Failed to save EdgeWorker state during shutdown:", error);
|
|
837
|
+
}
|
|
838
|
+
// get all agent runners (including chat platform sessions)
|
|
839
|
+
const agentRunners = [];
|
|
840
|
+
for (const agentSessionManager of this.agentSessionManagers.values()) {
|
|
841
|
+
agentRunners.push(...agentSessionManager.getAllAgentRunners());
|
|
842
|
+
}
|
|
843
|
+
if (this.chatSessionHandler) {
|
|
844
|
+
agentRunners.push(...this.chatSessionHandler.getAllRunners());
|
|
845
|
+
}
|
|
846
|
+
// Kill all agent processes with null checking
|
|
847
|
+
for (const runner of agentRunners) {
|
|
848
|
+
if (runner) {
|
|
849
|
+
try {
|
|
850
|
+
runner.stop();
|
|
851
|
+
}
|
|
852
|
+
catch (error) {
|
|
853
|
+
this.logger.error("Error stopping Claude runner:", error);
|
|
854
|
+
}
|
|
855
|
+
}
|
|
856
|
+
}
|
|
857
|
+
// Clear event transport (no explicit cleanup needed, routes are removed when server stops)
|
|
858
|
+
this.linearEventTransport = null;
|
|
859
|
+
this.configUpdater = null;
|
|
860
|
+
this.sylasToolsMcpContexts.clear();
|
|
861
|
+
this.sylasToolsMcpSessions.removeAllListeners();
|
|
862
|
+
this.sylasToolsMcpRegistered = false;
|
|
863
|
+
// Stop shared application server (this also stops Cloudflare tunnel if running)
|
|
864
|
+
await this.sharedApplicationServer.stop();
|
|
865
|
+
}
|
|
866
|
+
/**
|
|
867
|
+
* Set the config file path for dynamic reloading
|
|
868
|
+
*/
|
|
869
|
+
setConfigPath(configPath) {
|
|
870
|
+
this.configPath = configPath;
|
|
871
|
+
this.configManager.setConfigPath(configPath);
|
|
872
|
+
}
|
|
873
|
+
/**
|
|
874
|
+
* Handle resuming a parent session when a child session completes
|
|
875
|
+
* This is the core logic used by the resume parent session callback
|
|
876
|
+
* Extracted to reduce duplication between constructor and addNewRepositories
|
|
877
|
+
*/
|
|
878
|
+
async handleResumeParentSession(parentSessionId, prompt, childSessionId, _childRepo, childAgentSessionManager) {
|
|
879
|
+
const log = this.logger.withContext({ sessionId: parentSessionId });
|
|
880
|
+
log.info(`Child session completed, resuming parent session ${parentSessionId}`);
|
|
881
|
+
// Find parent session across all repositories
|
|
882
|
+
// This is critical for cross-repository orchestration where parent and child
|
|
883
|
+
// may be in different repositories with different AgentSessionManagers
|
|
884
|
+
// See also: feedback delivery code at line ~4413 which uses same pattern
|
|
885
|
+
log.debug(`Searching for parent session ${parentSessionId} across all repositories`);
|
|
886
|
+
let parentSession;
|
|
887
|
+
let parentRepo;
|
|
888
|
+
let parentAgentSessionManager;
|
|
889
|
+
for (const [repoId, manager] of this.agentSessionManagers) {
|
|
890
|
+
const candidate = manager.getSession(parentSessionId);
|
|
891
|
+
if (candidate) {
|
|
892
|
+
parentSession = candidate;
|
|
893
|
+
parentRepo = this.repositories.get(repoId);
|
|
894
|
+
parentAgentSessionManager = manager;
|
|
895
|
+
log.debug(`Found parent session in repository: ${parentRepo?.name || repoId}`);
|
|
896
|
+
break;
|
|
897
|
+
}
|
|
898
|
+
}
|
|
899
|
+
if (!parentSession || !parentRepo || !parentAgentSessionManager) {
|
|
900
|
+
log.error(`Parent session ${parentSessionId} not found in any repository's agent session manager`);
|
|
901
|
+
return;
|
|
902
|
+
}
|
|
903
|
+
log.debug(`Found parent session - Issue: ${parentSession.issueId}, Workspace: ${parentSession.workspace.path}`);
|
|
904
|
+
// Get the child session to access its workspace path
|
|
905
|
+
// Child session is in the child's manager (passed in from the callback)
|
|
906
|
+
const childSession = childAgentSessionManager.getSession(childSessionId);
|
|
907
|
+
const childWorkspaceDirs = [];
|
|
908
|
+
if (childSession) {
|
|
909
|
+
childWorkspaceDirs.push(childSession.workspace.path);
|
|
910
|
+
log.debug(`Adding child workspace to parent allowed directories: ${childSession.workspace.path}`);
|
|
911
|
+
}
|
|
912
|
+
else {
|
|
913
|
+
log.warn(`Could not find child session ${childSessionId} to add workspace to parent allowed directories`);
|
|
914
|
+
}
|
|
915
|
+
await this.postParentResumeAcknowledgment(parentSessionId, parentRepo.id);
|
|
916
|
+
// Post thought showing child result receipt
|
|
917
|
+
// Use parent's issue tracker since we're posting to the parent's session
|
|
918
|
+
const issueTracker = this.issueTrackers.get(parentRepo.id);
|
|
919
|
+
if (issueTracker && childSession) {
|
|
920
|
+
const childIssueIdentifier = childSession.issue?.identifier || childSession.issueId;
|
|
921
|
+
const resultThought = `Received result from sub-issue ${childIssueIdentifier}:\n\n---\n\n${prompt}\n\n---`;
|
|
922
|
+
await this.postActivityDirect(issueTracker, {
|
|
923
|
+
agentSessionId: parentSessionId,
|
|
924
|
+
content: { type: "thought", body: resultThought },
|
|
925
|
+
}, "child result receipt");
|
|
926
|
+
}
|
|
927
|
+
// Use centralized streaming check and routing logic
|
|
928
|
+
log.info(`Handling child result for parent session ${parentSessionId}`);
|
|
929
|
+
try {
|
|
930
|
+
await this.handlePromptWithStreamingCheck(parentSession, parentRepo, parentSessionId, parentAgentSessionManager, prompt, "", // No attachment manifest for child results
|
|
931
|
+
false, // Not a new session
|
|
932
|
+
childWorkspaceDirs, // Add child workspace directories to parent's allowed directories
|
|
933
|
+
"parent resume from child");
|
|
934
|
+
log.info(`Successfully handled child result for parent session ${parentSessionId}`);
|
|
935
|
+
}
|
|
936
|
+
catch (error) {
|
|
937
|
+
log.error(`Failed to resume parent session ${parentSessionId}:`, error);
|
|
938
|
+
log.error(`Error context - Parent issue: ${parentSession.issueId}, Repository: ${parentRepo.name}`);
|
|
939
|
+
}
|
|
940
|
+
}
|
|
941
|
+
/**
|
|
942
|
+
* Handle subroutine transition when a subroutine completes
|
|
943
|
+
* This is triggered by the AgentSessionManager's 'subroutineComplete' event
|
|
944
|
+
*/
|
|
945
|
+
async handleSubroutineTransition(sessionId, session, repo, agentSessionManager) {
|
|
946
|
+
const log = this.logger.withContext({ sessionId });
|
|
947
|
+
log.info(`Handling subroutine completion for session ${sessionId}`);
|
|
948
|
+
// Get next subroutine (advancement already handled by AgentSessionManager)
|
|
949
|
+
const nextSubroutine = this.procedureAnalyzer.getCurrentSubroutine(session);
|
|
950
|
+
if (!nextSubroutine) {
|
|
951
|
+
log.info(`Procedure complete for session ${sessionId}`);
|
|
952
|
+
return;
|
|
953
|
+
}
|
|
954
|
+
log.info(`Next subroutine: ${nextSubroutine.name}`);
|
|
955
|
+
// Load subroutine prompt
|
|
956
|
+
let subroutinePrompt;
|
|
957
|
+
try {
|
|
958
|
+
subroutinePrompt = await this.loadSubroutinePrompt(nextSubroutine, this.config.linearWorkspaceSlug);
|
|
959
|
+
if (!subroutinePrompt) {
|
|
960
|
+
// Fallback if loadSubroutinePrompt returns null
|
|
961
|
+
subroutinePrompt = `Continue with: ${nextSubroutine.description}`;
|
|
962
|
+
}
|
|
963
|
+
}
|
|
964
|
+
catch (error) {
|
|
965
|
+
log.error(`Failed to load subroutine prompt:`, error);
|
|
966
|
+
// Fallback to simple prompt
|
|
967
|
+
subroutinePrompt = `Continue with: ${nextSubroutine.description}`;
|
|
968
|
+
}
|
|
969
|
+
// Resume Claude session with subroutine prompt
|
|
970
|
+
try {
|
|
971
|
+
await this.resumeAgentSession(session, repo, sessionId, agentSessionManager, subroutinePrompt, "", // No attachment manifest
|
|
972
|
+
false, // Not a new session
|
|
973
|
+
[], // No additional allowed directories
|
|
974
|
+
nextSubroutine?.singleTurn ? 1 : undefined);
|
|
975
|
+
log.info(`Successfully resumed session for ${nextSubroutine.name} subroutine${nextSubroutine.singleTurn ? " (singleTurn)" : ""}`);
|
|
976
|
+
}
|
|
977
|
+
catch (error) {
|
|
978
|
+
log.error(`Failed to resume session for ${nextSubroutine.name} subroutine:`, error);
|
|
979
|
+
}
|
|
980
|
+
}
|
|
981
|
+
/**
|
|
982
|
+
* Handle validation loop fixer - run the fixer prompt
|
|
983
|
+
*/
|
|
984
|
+
async handleValidationLoopFixer(sessionId, session, repo, agentSessionManager, fixerPrompt, iteration) {
|
|
985
|
+
this.logger.info(`Running fixer for session ${sessionId}, iteration ${iteration}`);
|
|
986
|
+
try {
|
|
987
|
+
await this.resumeAgentSession(session, repo, sessionId, agentSessionManager, fixerPrompt, "", // No attachment manifest
|
|
988
|
+
false, // Not a new session
|
|
989
|
+
[], // No additional allowed directories
|
|
990
|
+
undefined);
|
|
991
|
+
this.logger.info(`Successfully started fixer for iteration ${iteration}`);
|
|
992
|
+
}
|
|
993
|
+
catch (error) {
|
|
994
|
+
this.logger.error(`Failed to run fixer for iteration ${iteration}:`, error);
|
|
995
|
+
}
|
|
996
|
+
}
|
|
997
|
+
/**
|
|
998
|
+
* Handle validation loop rerun - re-run the verifications subroutine
|
|
999
|
+
*/
|
|
1000
|
+
async handleValidationLoopRerun(sessionId, session, repo, agentSessionManager) {
|
|
1001
|
+
this.logger.info(`Re-running verifications for session ${sessionId}`);
|
|
1002
|
+
// Get the verifications subroutine definition
|
|
1003
|
+
const verificationsSubroutine = this.procedureAnalyzer.getCurrentSubroutine(session);
|
|
1004
|
+
if (!verificationsSubroutine ||
|
|
1005
|
+
verificationsSubroutine.name !== "verifications") {
|
|
1006
|
+
this.logger.error(`Expected verifications subroutine, got: ${verificationsSubroutine?.name}`);
|
|
1007
|
+
return;
|
|
1008
|
+
}
|
|
1009
|
+
try {
|
|
1010
|
+
// Load the verifications prompt
|
|
1011
|
+
const subroutinePrompt = await this.loadSubroutinePrompt(verificationsSubroutine, this.config.linearWorkspaceSlug);
|
|
1012
|
+
if (!subroutinePrompt) {
|
|
1013
|
+
this.logger.error(`Failed to load verifications prompt`);
|
|
1014
|
+
return;
|
|
1015
|
+
}
|
|
1016
|
+
await this.resumeAgentSession(session, repo, sessionId, agentSessionManager, subroutinePrompt, "", // No attachment manifest
|
|
1017
|
+
false, // Not a new session
|
|
1018
|
+
[], // No additional allowed directories
|
|
1019
|
+
undefined);
|
|
1020
|
+
this.logger.info(`Successfully re-started verifications`);
|
|
1021
|
+
}
|
|
1022
|
+
catch (error) {
|
|
1023
|
+
this.logger.error(`Failed to re-run verifications:`, error);
|
|
1024
|
+
}
|
|
1025
|
+
}
|
|
1026
|
+
/**
|
|
1027
|
+
* Add new repositories to the running EdgeWorker
|
|
1028
|
+
*/
|
|
1029
|
+
async addNewRepositories(repos) {
|
|
1030
|
+
for (const repo of repos) {
|
|
1031
|
+
if (repo.isActive === false) {
|
|
1032
|
+
this.logger.info(`⏭️ Skipping inactive repository: ${repo.name}`);
|
|
1033
|
+
continue;
|
|
1034
|
+
}
|
|
1035
|
+
try {
|
|
1036
|
+
this.logger.info(`➕ Adding repository: ${repo.name} (${repo.id})`);
|
|
1037
|
+
// Resolve paths that may contain tilde (~) prefix
|
|
1038
|
+
const resolvedRepo = {
|
|
1039
|
+
...repo,
|
|
1040
|
+
repositoryPath: resolvePath(repo.repositoryPath),
|
|
1041
|
+
workspaceBaseDir: resolvePath(repo.workspaceBaseDir),
|
|
1042
|
+
mcpConfigPath: Array.isArray(repo.mcpConfigPath)
|
|
1043
|
+
? repo.mcpConfigPath.map(resolvePath)
|
|
1044
|
+
: repo.mcpConfigPath
|
|
1045
|
+
? resolvePath(repo.mcpConfigPath)
|
|
1046
|
+
: undefined,
|
|
1047
|
+
promptTemplatePath: repo.promptTemplatePath
|
|
1048
|
+
? resolvePath(repo.promptTemplatePath)
|
|
1049
|
+
: undefined,
|
|
1050
|
+
openaiOutputDirectory: repo.openaiOutputDirectory
|
|
1051
|
+
? resolvePath(repo.openaiOutputDirectory)
|
|
1052
|
+
: undefined,
|
|
1053
|
+
};
|
|
1054
|
+
// Add to internal map
|
|
1055
|
+
this.repositories.set(repo.id, resolvedRepo);
|
|
1056
|
+
// Create issue tracker with OAuth config for token refresh
|
|
1057
|
+
const issueTracker = this.config.platform === "cli"
|
|
1058
|
+
? (() => {
|
|
1059
|
+
const service = new CLIIssueTrackerService();
|
|
1060
|
+
service.seedDefaultData();
|
|
1061
|
+
return service;
|
|
1062
|
+
})()
|
|
1063
|
+
: new LinearIssueTrackerService(new LinearClient({
|
|
1064
|
+
accessToken: repo.linearToken,
|
|
1065
|
+
}), this.buildOAuthConfig(resolvedRepo));
|
|
1066
|
+
this.issueTrackers.set(repo.id, issueTracker);
|
|
1067
|
+
// Create AgentSessionManager with same pattern as constructor
|
|
1068
|
+
const activitySink = new LinearActivitySink(issueTracker, repo.linearWorkspaceId);
|
|
1069
|
+
const agentSessionManager = new AgentSessionManager(activitySink, (childSessionId) => {
|
|
1070
|
+
return this.globalSessionRegistry.getParentSessionId(childSessionId);
|
|
1071
|
+
}, async (parentSessionId, prompt, childSessionId) => {
|
|
1072
|
+
await this.handleResumeParentSession(parentSessionId, prompt, childSessionId, repo, agentSessionManager);
|
|
1073
|
+
}, this.procedureAnalyzer, this.sharedApplicationServer);
|
|
1074
|
+
// Subscribe to subroutine completion events
|
|
1075
|
+
agentSessionManager.on("subroutineComplete", async ({ sessionId, session }) => {
|
|
1076
|
+
await this.handleSubroutineTransition(sessionId, session, repo, agentSessionManager);
|
|
1077
|
+
});
|
|
1078
|
+
// Subscribe to validation loop events
|
|
1079
|
+
agentSessionManager.on("validationLoopIteration", async ({ sessionId, session, fixerPrompt, iteration, maxIterations, }) => {
|
|
1080
|
+
this.logger.info(`Validation loop iteration ${iteration}/${maxIterations}, running fixer`);
|
|
1081
|
+
await this.handleValidationLoopFixer(sessionId, session, repo, agentSessionManager, fixerPrompt, iteration);
|
|
1082
|
+
});
|
|
1083
|
+
agentSessionManager.on("validationLoopRerun", async ({ sessionId, session, iteration }) => {
|
|
1084
|
+
this.logger.info(`Validation loop re-running verifications (iteration ${iteration})`);
|
|
1085
|
+
await this.handleValidationLoopRerun(sessionId, session, repo, agentSessionManager);
|
|
1086
|
+
});
|
|
1087
|
+
this.agentSessionManagers.set(repo.id, agentSessionManager);
|
|
1088
|
+
this.logger.info(`✅ Repository added successfully: ${repo.name}`);
|
|
1089
|
+
}
|
|
1090
|
+
catch (error) {
|
|
1091
|
+
this.logger.error(`❌ Failed to add repository ${repo.name}:`, error);
|
|
1092
|
+
}
|
|
1093
|
+
}
|
|
1094
|
+
}
|
|
1095
|
+
/**
|
|
1096
|
+
* Update existing repositories
|
|
1097
|
+
*/
|
|
1098
|
+
async updateModifiedRepositories(repos) {
|
|
1099
|
+
for (const repo of repos) {
|
|
1100
|
+
try {
|
|
1101
|
+
const oldRepo = this.repositories.get(repo.id);
|
|
1102
|
+
if (!oldRepo) {
|
|
1103
|
+
this.logger.warn(`⚠️ Repository ${repo.id} not found for update, skipping`);
|
|
1104
|
+
continue;
|
|
1105
|
+
}
|
|
1106
|
+
this.logger.info(`🔄 Updating repository: ${repo.name} (${repo.id})`);
|
|
1107
|
+
// Resolve paths that may contain tilde (~) prefix
|
|
1108
|
+
const resolvedRepo = {
|
|
1109
|
+
...repo,
|
|
1110
|
+
repositoryPath: resolvePath(repo.repositoryPath),
|
|
1111
|
+
workspaceBaseDir: resolvePath(repo.workspaceBaseDir),
|
|
1112
|
+
mcpConfigPath: Array.isArray(repo.mcpConfigPath)
|
|
1113
|
+
? repo.mcpConfigPath.map(resolvePath)
|
|
1114
|
+
: repo.mcpConfigPath
|
|
1115
|
+
? resolvePath(repo.mcpConfigPath)
|
|
1116
|
+
: undefined,
|
|
1117
|
+
promptTemplatePath: repo.promptTemplatePath
|
|
1118
|
+
? resolvePath(repo.promptTemplatePath)
|
|
1119
|
+
: undefined,
|
|
1120
|
+
openaiOutputDirectory: repo.openaiOutputDirectory
|
|
1121
|
+
? resolvePath(repo.openaiOutputDirectory)
|
|
1122
|
+
: undefined,
|
|
1123
|
+
};
|
|
1124
|
+
// Update stored config
|
|
1125
|
+
this.repositories.set(repo.id, resolvedRepo);
|
|
1126
|
+
// If token changed, update the issue tracker's client
|
|
1127
|
+
if (oldRepo.linearToken !== repo.linearToken) {
|
|
1128
|
+
this.logger.info(` 🔑 Token changed, updating client`);
|
|
1129
|
+
const issueTracker = this.issueTrackers.get(repo.id);
|
|
1130
|
+
if (issueTracker) {
|
|
1131
|
+
issueTracker.setAccessToken(repo.linearToken);
|
|
1132
|
+
}
|
|
1133
|
+
}
|
|
1134
|
+
// If active status changed
|
|
1135
|
+
if (oldRepo.isActive !== repo.isActive) {
|
|
1136
|
+
if (repo.isActive === false) {
|
|
1137
|
+
this.logger.info(` ⏸️ Repository set to inactive - existing sessions will continue`);
|
|
1138
|
+
}
|
|
1139
|
+
else {
|
|
1140
|
+
this.logger.info(` ▶️ Repository reactivated`);
|
|
1141
|
+
}
|
|
1142
|
+
}
|
|
1143
|
+
this.logger.info(`✅ Repository updated successfully: ${repo.name}`);
|
|
1144
|
+
}
|
|
1145
|
+
catch (error) {
|
|
1146
|
+
this.logger.error(`❌ Failed to update repository ${repo.name}:`, error);
|
|
1147
|
+
}
|
|
1148
|
+
}
|
|
1149
|
+
}
|
|
1150
|
+
/**
|
|
1151
|
+
* Remove deleted repositories
|
|
1152
|
+
*/
|
|
1153
|
+
async removeDeletedRepositories(repos) {
|
|
1154
|
+
for (const repo of repos) {
|
|
1155
|
+
try {
|
|
1156
|
+
this.logger.info(`🗑️ Removing repository: ${repo.name} (${repo.id})`);
|
|
1157
|
+
// Check for active sessions
|
|
1158
|
+
const manager = this.agentSessionManagers.get(repo.id);
|
|
1159
|
+
const activeSessions = manager?.getActiveSessions() || [];
|
|
1160
|
+
if (activeSessions.length > 0) {
|
|
1161
|
+
this.logger.warn(` ⚠️ Repository has ${activeSessions.length} active sessions - stopping them`);
|
|
1162
|
+
// Stop all active sessions and notify Linear
|
|
1163
|
+
for (const session of activeSessions) {
|
|
1164
|
+
try {
|
|
1165
|
+
this.logger.debug(` 🛑 Stopping session for issue ${session.issueId}`);
|
|
1166
|
+
// Get the agent runner for this session
|
|
1167
|
+
const runner = manager?.getAgentRunner(session.id);
|
|
1168
|
+
if (runner) {
|
|
1169
|
+
// Stop the agent process
|
|
1170
|
+
runner.stop();
|
|
1171
|
+
this.logger.debug(` ✅ Stopped Claude runner for session ${session.id}`);
|
|
1172
|
+
}
|
|
1173
|
+
// Post cancellation message to tracker
|
|
1174
|
+
const issueTracker = this.issueTrackers.get(repo.id);
|
|
1175
|
+
if (issueTracker && session.externalSessionId) {
|
|
1176
|
+
await this.postActivityDirect(issueTracker, {
|
|
1177
|
+
agentSessionId: session.externalSessionId,
|
|
1178
|
+
content: {
|
|
1179
|
+
type: "response",
|
|
1180
|
+
body: `**Repository Removed from Configuration**\n\nThis repository (\`${repo.name}\`) has been removed from the Sylas configuration. All active sessions for this repository have been stopped.\n\nIf you need to continue working on this issue, please contact your administrator to restore the repository configuration.`,
|
|
1181
|
+
},
|
|
1182
|
+
}, "repository removal");
|
|
1183
|
+
}
|
|
1184
|
+
}
|
|
1185
|
+
catch (error) {
|
|
1186
|
+
this.logger.error(` ❌ Failed to stop session ${session.id}:`, error);
|
|
1187
|
+
}
|
|
1188
|
+
}
|
|
1189
|
+
}
|
|
1190
|
+
// Remove repository from all maps
|
|
1191
|
+
this.repositories.delete(repo.id);
|
|
1192
|
+
this.issueTrackers.delete(repo.id);
|
|
1193
|
+
this.agentSessionManagers.delete(repo.id);
|
|
1194
|
+
this.logger.info(`✅ Repository removed successfully: ${repo.name}`);
|
|
1195
|
+
}
|
|
1196
|
+
catch (error) {
|
|
1197
|
+
this.logger.error(`❌ Failed to remove repository ${repo.name}:`, error);
|
|
1198
|
+
}
|
|
1199
|
+
}
|
|
1200
|
+
}
|
|
1201
|
+
/**
|
|
1202
|
+
* Handle errors
|
|
1203
|
+
*/
|
|
1204
|
+
handleError(error) {
|
|
1205
|
+
this.emit("error", error);
|
|
1206
|
+
this.config.handlers?.onError?.(error);
|
|
1207
|
+
}
|
|
1208
|
+
/**
|
|
1209
|
+
* Get cached repository for an issue (used by agentSessionPrompted Branch 3)
|
|
1210
|
+
*/
|
|
1211
|
+
getCachedRepository(issueId) {
|
|
1212
|
+
return this.repositoryRouter.getCachedRepository(issueId, this.repositories);
|
|
1213
|
+
}
|
|
1214
|
+
/**
|
|
1215
|
+
* Handle webhook events from proxy - main router for all webhooks
|
|
1216
|
+
*/
|
|
1217
|
+
async handleWebhook(webhook, repos) {
|
|
1218
|
+
// Track active webhook processing for status endpoint
|
|
1219
|
+
this.activeWebhookCount++;
|
|
1220
|
+
// Log verbose webhook info if enabled
|
|
1221
|
+
if (process.env.SYLAS_WEBHOOK_DEBUG === "true") {
|
|
1222
|
+
this.logger.debug(`Full webhook payload:`, JSON.stringify(webhook, null, 2));
|
|
1223
|
+
}
|
|
1224
|
+
try {
|
|
1225
|
+
// Route to specific webhook handlers based on webhook type
|
|
1226
|
+
// NOTE: Traditional webhooks (assigned, comment) are disabled in favor of agent session events
|
|
1227
|
+
if (isIssueAssignedWebhook(webhook)) {
|
|
1228
|
+
return;
|
|
1229
|
+
}
|
|
1230
|
+
else if (isIssueCommentMentionWebhook(webhook)) {
|
|
1231
|
+
return;
|
|
1232
|
+
}
|
|
1233
|
+
else if (isIssueNewCommentWebhook(webhook)) {
|
|
1234
|
+
return;
|
|
1235
|
+
}
|
|
1236
|
+
else if (isIssueUnassignedWebhook(webhook)) {
|
|
1237
|
+
// Keep unassigned webhook active
|
|
1238
|
+
await this.handleIssueUnassignedWebhook(webhook);
|
|
1239
|
+
}
|
|
1240
|
+
else if (isAgentSessionCreatedWebhook(webhook)) {
|
|
1241
|
+
await this.handleAgentSessionCreatedWebhook(webhook, repos);
|
|
1242
|
+
}
|
|
1243
|
+
else if (isAgentSessionPromptedWebhook(webhook)) {
|
|
1244
|
+
await this.handleUserPromptedAgentActivity(webhook);
|
|
1245
|
+
}
|
|
1246
|
+
else if (isIssueTitleOrDescriptionUpdateWebhook(webhook)) {
|
|
1247
|
+
// Handle issue title/description/attachments updates - feed changes into active session
|
|
1248
|
+
await this.handleIssueContentUpdate(webhook);
|
|
1249
|
+
}
|
|
1250
|
+
else {
|
|
1251
|
+
if (process.env.SYLAS_WEBHOOK_DEBUG === "true") {
|
|
1252
|
+
this.logger.debug(`Unhandled webhook type: ${webhook.action}`);
|
|
1253
|
+
}
|
|
1254
|
+
}
|
|
1255
|
+
}
|
|
1256
|
+
catch (error) {
|
|
1257
|
+
this.logger.error(`Failed to process webhook: ${webhook.action}`, error);
|
|
1258
|
+
// Don't re-throw webhook processing errors to prevent application crashes
|
|
1259
|
+
// The error has been logged and individual webhook failures shouldn't crash the entire system
|
|
1260
|
+
}
|
|
1261
|
+
finally {
|
|
1262
|
+
// Always decrement counter when webhook processing completes
|
|
1263
|
+
this.activeWebhookCount--;
|
|
1264
|
+
}
|
|
1265
|
+
}
|
|
1266
|
+
// ============================================================================
|
|
1267
|
+
// INTERNAL MESSAGE BUS HANDLERS
|
|
1268
|
+
// ============================================================================
|
|
1269
|
+
// These handlers process unified InternalMessage types from the message bus.
|
|
1270
|
+
// They provide a platform-agnostic interface for handling events from
|
|
1271
|
+
// Linear, GitHub, Slack, and other platforms.
|
|
1272
|
+
// ============================================================================
|
|
1273
|
+
/**
|
|
1274
|
+
* Handle unified internal messages from the message bus.
|
|
1275
|
+
* This is the new entry point for processing events from all platforms.
|
|
1276
|
+
*
|
|
1277
|
+
* Note: For now, this runs in parallel with legacy webhook handlers.
|
|
1278
|
+
* Once migration is complete, legacy handlers will be removed.
|
|
1279
|
+
*/
|
|
1280
|
+
async handleMessage(message) {
|
|
1281
|
+
// NOTE: activeWebhookCount is NOT tracked here because legacy webhook handlers
|
|
1282
|
+
// already increment/decrement it for every event. Counting here would double-count.
|
|
1283
|
+
// TODO: When legacy handlers are removed, restore activeWebhookCount tracking here.
|
|
1284
|
+
// Log verbose message info if enabled
|
|
1285
|
+
if (process.env.SYLAS_WEBHOOK_DEBUG === "true") {
|
|
1286
|
+
this.logger.debug(`Internal message received: ${message.source}/${message.action}`, JSON.stringify(message, null, 2));
|
|
1287
|
+
}
|
|
1288
|
+
try {
|
|
1289
|
+
// Route to specific message handlers based on action type
|
|
1290
|
+
if (isSessionStartMessage(message)) {
|
|
1291
|
+
await this.handleSessionStartMessage(message);
|
|
1292
|
+
}
|
|
1293
|
+
else if (isUserPromptMessage(message)) {
|
|
1294
|
+
await this.handleUserPromptMessage(message);
|
|
1295
|
+
}
|
|
1296
|
+
else if (isStopSignalMessage(message)) {
|
|
1297
|
+
await this.handleStopSignalMessage(message);
|
|
1298
|
+
}
|
|
1299
|
+
else if (isContentUpdateMessage(message)) {
|
|
1300
|
+
await this.handleContentUpdateMessage(message);
|
|
1301
|
+
}
|
|
1302
|
+
else if (isUnassignMessage(message)) {
|
|
1303
|
+
await this.handleUnassignMessage(message);
|
|
1304
|
+
}
|
|
1305
|
+
else {
|
|
1306
|
+
// This branch should never be reached due to exhaustive type checking
|
|
1307
|
+
// If it is reached, log the unexpected message for debugging
|
|
1308
|
+
if (process.env.SYLAS_WEBHOOK_DEBUG === "true") {
|
|
1309
|
+
const unexpectedMessage = message;
|
|
1310
|
+
this.logger.debug(`Unhandled message action: ${unexpectedMessage.action}`);
|
|
1311
|
+
}
|
|
1312
|
+
}
|
|
1313
|
+
}
|
|
1314
|
+
catch (error) {
|
|
1315
|
+
this.logger.error(`Failed to process message: ${message.source}/${message.action}`, error);
|
|
1316
|
+
// Don't re-throw message processing errors to prevent application crashes
|
|
1317
|
+
}
|
|
1318
|
+
}
|
|
1319
|
+
/**
|
|
1320
|
+
* Handle session start message (unified handler for session creation).
|
|
1321
|
+
*
|
|
1322
|
+
* This is a placeholder that logs the message for now.
|
|
1323
|
+
* TODO: Migrate logic from handleAgentSessionCreatedWebhook and handleGitHubWebhook.
|
|
1324
|
+
*/
|
|
1325
|
+
async handleSessionStartMessage(message) {
|
|
1326
|
+
this.logger.debug(`[MessageBus] Session start: ${message.workItemIdentifier} from ${message.source}`);
|
|
1327
|
+
// TODO: Implement unified session start handling
|
|
1328
|
+
// For now, the legacy handlers (handleAgentSessionCreatedWebhook, handleGitHubWebhook)
|
|
1329
|
+
// continue to process the actual session creation via the 'event' emitter.
|
|
1330
|
+
}
|
|
1331
|
+
/**
|
|
1332
|
+
* Handle user prompt message (unified handler for mid-session prompts).
|
|
1333
|
+
*
|
|
1334
|
+
* This is a placeholder that logs the message for now.
|
|
1335
|
+
* TODO: Migrate logic from handleUserPromptedAgentActivity (branch 3).
|
|
1336
|
+
*/
|
|
1337
|
+
async handleUserPromptMessage(message) {
|
|
1338
|
+
this.logger.debug(`[MessageBus] User prompt: ${message.workItemIdentifier} from ${message.source}`);
|
|
1339
|
+
// TODO: Implement unified user prompt handling
|
|
1340
|
+
// For now, the legacy handler (handleUserPromptedAgentActivity)
|
|
1341
|
+
// continues to process the actual prompt via the 'event' emitter.
|
|
1342
|
+
}
|
|
1343
|
+
/**
|
|
1344
|
+
* Handle stop signal message (unified handler for session termination).
|
|
1345
|
+
*
|
|
1346
|
+
* This is a placeholder that logs the message for now.
|
|
1347
|
+
* TODO: Migrate logic from handleUserPromptedAgentActivity (branch 1).
|
|
1348
|
+
*/
|
|
1349
|
+
async handleStopSignalMessage(message) {
|
|
1350
|
+
this.logger.debug(`[MessageBus] Stop signal: ${message.workItemIdentifier} from ${message.source}`);
|
|
1351
|
+
// TODO: Implement unified stop signal handling
|
|
1352
|
+
// For now, the legacy handler (handleUserPromptedAgentActivity)
|
|
1353
|
+
// continues to process the actual stop via the 'event' emitter.
|
|
1354
|
+
}
|
|
1355
|
+
/**
|
|
1356
|
+
* Handle content update message (unified handler for issue/PR content changes).
|
|
1357
|
+
*
|
|
1358
|
+
* This is a placeholder that logs the message for now.
|
|
1359
|
+
* TODO: Migrate logic from handleIssueContentUpdate.
|
|
1360
|
+
*/
|
|
1361
|
+
async handleContentUpdateMessage(message) {
|
|
1362
|
+
this.logger.debug(`[MessageBus] Content update: ${message.workItemIdentifier} from ${message.source}`);
|
|
1363
|
+
// TODO: Implement unified content update handling
|
|
1364
|
+
// For now, the legacy handler (handleIssueContentUpdate)
|
|
1365
|
+
// continues to process the actual update via the 'event' emitter.
|
|
1366
|
+
}
|
|
1367
|
+
/**
|
|
1368
|
+
* Handle unassign message (unified handler for task unassignment).
|
|
1369
|
+
*
|
|
1370
|
+
* This is a placeholder that logs the message for now.
|
|
1371
|
+
* TODO: Migrate logic from handleIssueUnassignedWebhook.
|
|
1372
|
+
*/
|
|
1373
|
+
async handleUnassignMessage(message) {
|
|
1374
|
+
this.logger.debug(`[MessageBus] Unassign: ${message.workItemIdentifier} from ${message.source}`);
|
|
1375
|
+
// TODO: Implement unified unassign handling
|
|
1376
|
+
// For now, the legacy handler (handleIssueUnassignedWebhook)
|
|
1377
|
+
// continues to process the actual unassignment via the 'event' emitter.
|
|
1378
|
+
}
|
|
1379
|
+
// ============================================================================
|
|
1380
|
+
// LEGACY WEBHOOK HANDLERS
|
|
1381
|
+
// ============================================================================
|
|
1382
|
+
/**
|
|
1383
|
+
* Handle issue unassignment webhook
|
|
1384
|
+
*/
|
|
1385
|
+
async handleIssueUnassignedWebhook(webhook) {
|
|
1386
|
+
if (!webhook.notification.issue) {
|
|
1387
|
+
this.logger.warn("Received issue unassignment webhook without issue");
|
|
1388
|
+
return;
|
|
1389
|
+
}
|
|
1390
|
+
const issueId = webhook.notification.issue.id;
|
|
1391
|
+
// Get cached repository (unassignment should only happen on issues with active sessions)
|
|
1392
|
+
const repository = this.getCachedRepository(issueId);
|
|
1393
|
+
if (!repository) {
|
|
1394
|
+
this.logger.debug(`No cached repository for issue unassignment webhook ${webhook.notification.issue.identifier} (no active sessions to stop)`);
|
|
1395
|
+
return;
|
|
1396
|
+
}
|
|
1397
|
+
this.logger.info(`Handling issue unassignment: ${webhook.notification.issue.identifier}`);
|
|
1398
|
+
// Log the complete webhook payload for TypeScript type definition
|
|
1399
|
+
// console.log('=== ISSUE UNASSIGNMENT WEBHOOK PAYLOAD ===')
|
|
1400
|
+
// console.log(JSON.stringify(webhook, null, 2))
|
|
1401
|
+
// console.log('=== END WEBHOOK PAYLOAD ===')
|
|
1402
|
+
await this.handleIssueUnassigned(webhook.notification.issue, repository);
|
|
1403
|
+
}
|
|
1404
|
+
/**
|
|
1405
|
+
* Handle issue content update webhook (title, description, or attachments).
|
|
1406
|
+
*
|
|
1407
|
+
* When the title, description, or attachments of an issue are updated, this handler feeds
|
|
1408
|
+
* the changes into any active session for that issue, allowing the AI to
|
|
1409
|
+
* compare old vs new values and decide whether to take action.
|
|
1410
|
+
*
|
|
1411
|
+
* The prompt uses XML-style formatting to clearly show what changed:
|
|
1412
|
+
* - <issue_update> wrapper with timestamp and issue identifier
|
|
1413
|
+
* - <title_change> with <old_title> and <new_title> if title changed
|
|
1414
|
+
* - <description_change> with <old_description> and <new_description> if description changed
|
|
1415
|
+
* - <attachments_change> with <old_attachments> and <new_attachments> if attachments changed
|
|
1416
|
+
* - <guidance> section instructing the agent to evaluate whether changes affect its work
|
|
1417
|
+
*
|
|
1418
|
+
* @see https://studio.apollographql.com/public/Linear-Webhooks/variant/current/schema/reference/objects/EntityWebhookPayload
|
|
1419
|
+
* @see https://studio.apollographql.com/public/Linear-Webhooks/variant/current/schema/reference/objects/IssueWebhookPayload
|
|
1420
|
+
* @see https://studio.apollographql.com/public/Linear-Webhooks/variant/current/schema/reference/unions/DataWebhookPayload
|
|
1421
|
+
*/
|
|
1422
|
+
async handleIssueContentUpdate(webhook) {
|
|
1423
|
+
// Check if issue update trigger is enabled (defaults to true if not set)
|
|
1424
|
+
if (this.config.issueUpdateTrigger === false) {
|
|
1425
|
+
if (process.env.SYLAS_WEBHOOK_DEBUG === "true") {
|
|
1426
|
+
this.logger.debug("Issue update trigger is disabled, skipping issue content update");
|
|
1427
|
+
}
|
|
1428
|
+
return;
|
|
1429
|
+
}
|
|
1430
|
+
const issueData = webhook.data;
|
|
1431
|
+
const issueId = issueData.id;
|
|
1432
|
+
const issueIdentifier = issueData.identifier;
|
|
1433
|
+
const updatedFrom = webhook.updatedFrom;
|
|
1434
|
+
if (!updatedFrom) {
|
|
1435
|
+
this.logger.warn(`Issue update webhook for ${issueIdentifier} has no updatedFrom data`);
|
|
1436
|
+
return;
|
|
1437
|
+
}
|
|
1438
|
+
// Get cached repository (updates should only be processed for issues with active sessions)
|
|
1439
|
+
const repository = this.getCachedRepository(issueId);
|
|
1440
|
+
if (!repository) {
|
|
1441
|
+
if (process.env.SYLAS_WEBHOOK_DEBUG === "true") {
|
|
1442
|
+
this.logger.debug(`No cached repository for issue update webhook ${issueIdentifier} (no active sessions to notify)`);
|
|
1443
|
+
}
|
|
1444
|
+
return;
|
|
1445
|
+
}
|
|
1446
|
+
// Determine what changed for logging
|
|
1447
|
+
const changedFields = [];
|
|
1448
|
+
if ("title" in updatedFrom)
|
|
1449
|
+
changedFields.push("title");
|
|
1450
|
+
if ("description" in updatedFrom)
|
|
1451
|
+
changedFields.push("description");
|
|
1452
|
+
if ("attachments" in updatedFrom)
|
|
1453
|
+
changedFields.push("attachments");
|
|
1454
|
+
this.logger.info(`Handling issue content update: ${issueIdentifier} (changed: ${changedFields.join(", ")})`);
|
|
1455
|
+
// Get agent session manager for this repository
|
|
1456
|
+
const agentSessionManager = this.agentSessionManagers.get(repository.id);
|
|
1457
|
+
if (!agentSessionManager) {
|
|
1458
|
+
this.logger.debug(`No agent session manager for repository ${repository.id}`);
|
|
1459
|
+
return;
|
|
1460
|
+
}
|
|
1461
|
+
// Find session(s) for this issue (may be running or paused between subroutines)
|
|
1462
|
+
const sessions = agentSessionManager.getSessionsByIssueId(issueId);
|
|
1463
|
+
if (sessions.length === 0) {
|
|
1464
|
+
if (process.env.SYLAS_WEBHOOK_DEBUG === "true") {
|
|
1465
|
+
this.logger.debug(`No sessions found for issue ${issueIdentifier} to receive update`);
|
|
1466
|
+
}
|
|
1467
|
+
return;
|
|
1468
|
+
}
|
|
1469
|
+
// Process attachments from the updated description if description changed
|
|
1470
|
+
let attachmentManifest = "";
|
|
1471
|
+
if ("description" in updatedFrom && issueData.description) {
|
|
1472
|
+
const firstSession = sessions[0];
|
|
1473
|
+
if (!firstSession) {
|
|
1474
|
+
this.logger.debug(`No sessions found for issue ${issueIdentifier}`);
|
|
1475
|
+
return;
|
|
1476
|
+
}
|
|
1477
|
+
const workspaceFolderName = basename(firstSession.workspace.path);
|
|
1478
|
+
const attachmentsDir = join(this.sylasHome, workspaceFolderName, "attachments");
|
|
1479
|
+
try {
|
|
1480
|
+
// Ensure directory exists
|
|
1481
|
+
await mkdir(attachmentsDir, { recursive: true });
|
|
1482
|
+
// Count existing attachments
|
|
1483
|
+
const existingFiles = await readdir(attachmentsDir).catch(() => []);
|
|
1484
|
+
const existingAttachmentCount = existingFiles.filter((file) => file.startsWith("attachment_") || file.startsWith("image_")).length;
|
|
1485
|
+
// Download attachments from the new description
|
|
1486
|
+
const downloadResult = await this.downloadCommentAttachments(issueData.description, attachmentsDir, repository.linearToken, existingAttachmentCount);
|
|
1487
|
+
if (downloadResult.totalNewAttachments > 0) {
|
|
1488
|
+
attachmentManifest =
|
|
1489
|
+
this.generateNewAttachmentManifest(downloadResult);
|
|
1490
|
+
this.logger.debug(`Downloaded ${downloadResult.totalNewAttachments} attachments from updated description`);
|
|
1491
|
+
}
|
|
1492
|
+
}
|
|
1493
|
+
catch (error) {
|
|
1494
|
+
this.logger.error("Failed to process attachments from updated description:", error);
|
|
1495
|
+
}
|
|
1496
|
+
}
|
|
1497
|
+
// Build the XML-formatted prompt showing old vs new values
|
|
1498
|
+
const promptBody = this.buildIssueUpdatePrompt(issueIdentifier, issueData, updatedFrom);
|
|
1499
|
+
// Feed the update into each active session
|
|
1500
|
+
for (const session of sessions) {
|
|
1501
|
+
const linearAgentActivitySessionId = session.id;
|
|
1502
|
+
// Check if runner is actively running and supports streaming input
|
|
1503
|
+
const existingRunner = session.agentRunner;
|
|
1504
|
+
const isRunning = existingRunner?.isRunning() || false;
|
|
1505
|
+
// Combine prompt body with attachment manifest
|
|
1506
|
+
let fullPrompt = promptBody;
|
|
1507
|
+
if (attachmentManifest) {
|
|
1508
|
+
fullPrompt = `${promptBody}\n\n${attachmentManifest}`;
|
|
1509
|
+
}
|
|
1510
|
+
if (isRunning &&
|
|
1511
|
+
existingRunner?.supportsStreamingInput &&
|
|
1512
|
+
existingRunner.addStreamMessage) {
|
|
1513
|
+
// Add to existing stream
|
|
1514
|
+
this.logger.debug(`Adding issue update to existing stream for ${linearAgentActivitySessionId}`);
|
|
1515
|
+
existingRunner.addStreamMessage(fullPrompt);
|
|
1516
|
+
}
|
|
1517
|
+
else if (isRunning) {
|
|
1518
|
+
// Runner is running but doesn't support streaming input - log and skip
|
|
1519
|
+
this.logger.debug(`Session ${linearAgentActivitySessionId} is running but doesn't support streaming input, skipping issue update`);
|
|
1520
|
+
}
|
|
1521
|
+
else {
|
|
1522
|
+
// Session exists but runner is not running - resume with the update
|
|
1523
|
+
this.logger.debug(`Resuming session ${linearAgentActivitySessionId} with issue update`);
|
|
1524
|
+
await this.handlePromptWithStreamingCheck(session, repository, linearAgentActivitySessionId, agentSessionManager, promptBody, attachmentManifest, false, // Not a new session
|
|
1525
|
+
[], // No additional allowed directories
|
|
1526
|
+
"issue content update", undefined, // No comment author
|
|
1527
|
+
undefined);
|
|
1528
|
+
}
|
|
1529
|
+
}
|
|
1530
|
+
}
|
|
1531
|
+
/**
|
|
1532
|
+
* Build an XML-formatted prompt for issue content updates (title, description, attachments).
|
|
1533
|
+
*
|
|
1534
|
+
* The prompt clearly shows what fields changed by comparing old vs new values,
|
|
1535
|
+
* and includes guidance for the agent to evaluate whether these changes affect
|
|
1536
|
+
* its current implementation or action plan.
|
|
1537
|
+
*/
|
|
1538
|
+
buildIssueUpdatePrompt(issueIdentifier, issueData, updatedFrom) {
|
|
1539
|
+
return this.promptBuilder.buildIssueUpdatePrompt(issueIdentifier, issueData, updatedFrom);
|
|
1540
|
+
}
|
|
1541
|
+
/**
|
|
1542
|
+
* Get issue tracker for a workspace by finding first repository with that workspace ID
|
|
1543
|
+
*/
|
|
1544
|
+
getIssueTrackerForWorkspace(workspaceId) {
|
|
1545
|
+
for (const [repoId, repo] of this.repositories) {
|
|
1546
|
+
if (repo.linearWorkspaceId === workspaceId) {
|
|
1547
|
+
return this.issueTrackers.get(repoId);
|
|
1548
|
+
}
|
|
1549
|
+
}
|
|
1550
|
+
return undefined;
|
|
1551
|
+
}
|
|
1552
|
+
/**
|
|
1553
|
+
* Create a new Linear agent session with all necessary setup
|
|
1554
|
+
* @param sessionId The Linear agent activity session ID
|
|
1555
|
+
* @param issue Linear issue object
|
|
1556
|
+
* @param repository Repository configuration
|
|
1557
|
+
* @param agentSessionManager Agent session manager instance
|
|
1558
|
+
* @returns Object containing session details and setup information
|
|
1559
|
+
*/
|
|
1560
|
+
async createLinearAgentSession(sessionId, issue, repository, agentSessionManager) {
|
|
1561
|
+
// Fetch full Linear issue details
|
|
1562
|
+
const fullIssue = await this.fetchFullIssueDetails(issue.id, repository.id);
|
|
1563
|
+
if (!fullIssue) {
|
|
1564
|
+
throw new Error(`Failed to fetch full issue details for ${issue.id}`);
|
|
1565
|
+
}
|
|
1566
|
+
// Move issue to started state automatically, in case it's not already
|
|
1567
|
+
await this.moveIssueToStartedState(fullIssue, repository.id);
|
|
1568
|
+
// Create workspace using full issue data
|
|
1569
|
+
// Use custom handler if provided, otherwise create a git worktree by default
|
|
1570
|
+
const workspace = this.config.handlers?.createWorkspace
|
|
1571
|
+
? await this.config.handlers.createWorkspace(fullIssue, repository)
|
|
1572
|
+
: await this.gitService.createGitWorktree(fullIssue, repository);
|
|
1573
|
+
this.logger.debug(`Workspace created at: ${workspace.path}`);
|
|
1574
|
+
const issueMinimal = this.convertLinearIssueToCore(fullIssue);
|
|
1575
|
+
agentSessionManager.createLinearAgentSession(sessionId, issue.id, issueMinimal, workspace);
|
|
1576
|
+
// Get the newly created session
|
|
1577
|
+
const session = agentSessionManager.getSession(sessionId);
|
|
1578
|
+
if (!session) {
|
|
1579
|
+
throw new Error(`Failed to create session for agent activity session ${sessionId}`);
|
|
1580
|
+
}
|
|
1581
|
+
// Download attachments before creating Claude runner
|
|
1582
|
+
const attachmentResult = await this.downloadIssueAttachments(fullIssue, repository, workspace.path);
|
|
1583
|
+
// Pre-create attachments directory even if no attachments exist yet
|
|
1584
|
+
const workspaceFolderName = basename(workspace.path);
|
|
1585
|
+
const attachmentsDir = join(this.sylasHome, workspaceFolderName, "attachments");
|
|
1586
|
+
await mkdir(attachmentsDir, { recursive: true });
|
|
1587
|
+
// Build allowed directories list - always include attachments directory
|
|
1588
|
+
const allowedDirectories = [
|
|
1589
|
+
...new Set([
|
|
1590
|
+
attachmentsDir,
|
|
1591
|
+
repository.repositoryPath,
|
|
1592
|
+
...this.gitService.getGitMetadataDirectories(workspace.path),
|
|
1593
|
+
]),
|
|
1594
|
+
];
|
|
1595
|
+
this.logger.debug(`Configured allowed directories for ${fullIssue.identifier}:`, allowedDirectories);
|
|
1596
|
+
// Build allowed tools list with Linear MCP tools
|
|
1597
|
+
const allowedTools = this.buildAllowedTools(repository);
|
|
1598
|
+
const disallowedTools = this.buildDisallowedTools(repository);
|
|
1599
|
+
return {
|
|
1600
|
+
session,
|
|
1601
|
+
fullIssue,
|
|
1602
|
+
workspace,
|
|
1603
|
+
attachmentResult,
|
|
1604
|
+
attachmentsDir,
|
|
1605
|
+
allowedDirectories,
|
|
1606
|
+
allowedTools,
|
|
1607
|
+
disallowedTools,
|
|
1608
|
+
};
|
|
1609
|
+
}
|
|
1610
|
+
/**
|
|
1611
|
+
* Handle agent session created webhook
|
|
1612
|
+
* Can happen due to being 'delegated' or @ mentioned in a new thread
|
|
1613
|
+
* @param webhook The agent session created webhook
|
|
1614
|
+
* @param repos All available repositories for routing
|
|
1615
|
+
*/
|
|
1616
|
+
async handleAgentSessionCreatedWebhook(webhook, repos) {
|
|
1617
|
+
const issueId = webhook.agentSession?.issue?.id;
|
|
1618
|
+
// Check the cache first, as the agentSessionCreated webhook may have been triggered by an @mention
|
|
1619
|
+
// on an issue that already has an agentSession and an associated repository.
|
|
1620
|
+
let repository = null;
|
|
1621
|
+
if (issueId) {
|
|
1622
|
+
repository = this.getCachedRepository(issueId);
|
|
1623
|
+
if (repository) {
|
|
1624
|
+
this.logger.debug(`Using cached repository ${repository.name} for issue ${issueId}`);
|
|
1625
|
+
}
|
|
1626
|
+
}
|
|
1627
|
+
// If not cached, perform routing logic
|
|
1628
|
+
if (!repository) {
|
|
1629
|
+
const routingResult = await this.repositoryRouter.determineRepositoryForWebhook(webhook, repos);
|
|
1630
|
+
if (routingResult.type === "none") {
|
|
1631
|
+
if (process.env.SYLAS_WEBHOOK_DEBUG === "true") {
|
|
1632
|
+
this.logger.info(`No repository configured for webhook from workspace ${webhook.organizationId}`);
|
|
1633
|
+
}
|
|
1634
|
+
return;
|
|
1635
|
+
}
|
|
1636
|
+
// Handle needs_selection case
|
|
1637
|
+
if (routingResult.type === "needs_selection") {
|
|
1638
|
+
await this.repositoryRouter.elicitUserRepositorySelection(webhook, routingResult.workspaceRepos);
|
|
1639
|
+
// Selection in progress - will be handled by handleRepositorySelectionResponse
|
|
1640
|
+
return;
|
|
1641
|
+
}
|
|
1642
|
+
// At this point, routingResult.type === "selected"
|
|
1643
|
+
repository = routingResult.repository;
|
|
1644
|
+
const routingMethod = routingResult.routingMethod;
|
|
1645
|
+
// Cache the repository for this issue
|
|
1646
|
+
if (issueId) {
|
|
1647
|
+
this.repositoryRouter
|
|
1648
|
+
.getIssueRepositoryCache()
|
|
1649
|
+
.set(issueId, repository.id);
|
|
1650
|
+
}
|
|
1651
|
+
// Post agent activity showing auto-matched routing
|
|
1652
|
+
await this.postRepositorySelectionActivity(webhook.agentSession.id, repository.id, repository.name, routingMethod);
|
|
1653
|
+
}
|
|
1654
|
+
if (!webhook.agentSession.issue) {
|
|
1655
|
+
this.logger.warn("Agent session created webhook missing issue");
|
|
1656
|
+
return;
|
|
1657
|
+
}
|
|
1658
|
+
// User access control check
|
|
1659
|
+
const accessResult = this.checkUserAccess(webhook, repository);
|
|
1660
|
+
if (!accessResult.allowed) {
|
|
1661
|
+
this.logger.info(`User ${accessResult.userName} blocked from delegating: ${accessResult.reason}`);
|
|
1662
|
+
await this.handleBlockedUser(webhook, repository, accessResult.reason);
|
|
1663
|
+
return;
|
|
1664
|
+
}
|
|
1665
|
+
const log = this.logger.withContext({
|
|
1666
|
+
sessionId: webhook.agentSession.id,
|
|
1667
|
+
platform: this.getRepositoryPlatform(repository.id),
|
|
1668
|
+
issueIdentifier: webhook.agentSession.issue.identifier,
|
|
1669
|
+
});
|
|
1670
|
+
log.info(`Handling agent session created`);
|
|
1671
|
+
const { agentSession, guidance } = webhook;
|
|
1672
|
+
const commentBody = agentSession.comment?.body;
|
|
1673
|
+
// Initialize agent runner using shared logic
|
|
1674
|
+
await this.initializeAgentRunner(agentSession, repository, guidance, commentBody);
|
|
1675
|
+
}
|
|
1676
|
+
/**
|
|
1677
|
+
|
|
1678
|
+
/**
|
|
1679
|
+
* Initialize and start agent runner for an agent session
|
|
1680
|
+
* This method contains the shared logic for creating an agent runner that both
|
|
1681
|
+
* handleAgentSessionCreatedWebhook and handleUserPromptedAgentActivity use.
|
|
1682
|
+
*
|
|
1683
|
+
* @param agentSession The Linear agent session
|
|
1684
|
+
* @param repository The repository configuration
|
|
1685
|
+
* @param guidance Optional guidance rules from Linear
|
|
1686
|
+
* @param commentBody Optional comment body (for mentions)
|
|
1687
|
+
*/
|
|
1688
|
+
async initializeAgentRunner(agentSession, repository, guidance, commentBody) {
|
|
1689
|
+
const sessionId = agentSession.id;
|
|
1690
|
+
const { issue } = agentSession;
|
|
1691
|
+
if (!issue) {
|
|
1692
|
+
this.logger.warn("Cannot initialize Claude runner without issue");
|
|
1693
|
+
return;
|
|
1694
|
+
}
|
|
1695
|
+
const log = this.logger.withContext({
|
|
1696
|
+
sessionId,
|
|
1697
|
+
issueIdentifier: issue.identifier,
|
|
1698
|
+
});
|
|
1699
|
+
// Log guidance if present
|
|
1700
|
+
if (guidance && guidance.length > 0) {
|
|
1701
|
+
log.debug(`Agent guidance received: ${guidance.length} rule(s)`);
|
|
1702
|
+
for (const rule of guidance) {
|
|
1703
|
+
let origin = "Unknown";
|
|
1704
|
+
if (rule.origin) {
|
|
1705
|
+
if (rule.origin.__typename === "TeamOriginWebhookPayload") {
|
|
1706
|
+
origin = `Team: ${rule.origin.team.displayName}`;
|
|
1707
|
+
}
|
|
1708
|
+
else {
|
|
1709
|
+
origin = "Organization";
|
|
1710
|
+
}
|
|
1711
|
+
}
|
|
1712
|
+
log.info(`- ${origin}: ${rule.body.substring(0, 100)}...`);
|
|
1713
|
+
}
|
|
1714
|
+
}
|
|
1715
|
+
// HACK: This is required since the comment body is always populated, thus there is no other way to differentiate between the two trigger events
|
|
1716
|
+
const AGENT_SESSION_MARKER = "This thread is for an agent session";
|
|
1717
|
+
const isMentionTriggered = commentBody && !commentBody.includes(AGENT_SESSION_MARKER);
|
|
1718
|
+
// Check if the comment contains the /label-based-prompt command
|
|
1719
|
+
const isLabelBasedPromptRequested = commentBody?.includes("/label-based-prompt");
|
|
1720
|
+
// Initialize the agent session in AgentSessionManager
|
|
1721
|
+
const agentSessionManager = this.agentSessionManagers.get(repository.id);
|
|
1722
|
+
if (!agentSessionManager) {
|
|
1723
|
+
log.error("There was no agentSessionManage for the repository with id", repository.id);
|
|
1724
|
+
return;
|
|
1725
|
+
}
|
|
1726
|
+
// Post instant acknowledgment thought
|
|
1727
|
+
await this.postInstantAcknowledgment(sessionId, repository.id);
|
|
1728
|
+
// Create the session using the shared method
|
|
1729
|
+
const sessionData = await this.createLinearAgentSession(sessionId, issue, repository, agentSessionManager);
|
|
1730
|
+
// Destructure the session data (excluding allowedTools which we'll build with promptType)
|
|
1731
|
+
const { session, fullIssue, workspace: _workspace, attachmentResult, attachmentsDir: _attachmentsDir, allowedDirectories, } = sessionData;
|
|
1732
|
+
// Initialize procedure metadata using intelligent routing
|
|
1733
|
+
if (!session.metadata) {
|
|
1734
|
+
session.metadata = {};
|
|
1735
|
+
}
|
|
1736
|
+
// Post ephemeral "Routing..." thought
|
|
1737
|
+
await agentSessionManager.postAnalyzingThought(sessionId);
|
|
1738
|
+
// Fetch labels early (needed for label override check)
|
|
1739
|
+
const labels = await this.fetchIssueLabels(fullIssue);
|
|
1740
|
+
// Lowercase labels for case-insensitive comparison
|
|
1741
|
+
const lowercaseLabels = labels.map((label) => label.toLowerCase());
|
|
1742
|
+
// Check for label overrides BEFORE AI routing
|
|
1743
|
+
const debuggerConfig = repository.labelPrompts?.debugger;
|
|
1744
|
+
const debuggerLabels = Array.isArray(debuggerConfig)
|
|
1745
|
+
? debuggerConfig
|
|
1746
|
+
: debuggerConfig?.labels;
|
|
1747
|
+
const hasDebuggerLabel = debuggerLabels?.some((label) => lowercaseLabels.includes(label.toLowerCase()));
|
|
1748
|
+
// ALWAYS check for 'orchestrator' label (case-insensitive) regardless of EdgeConfig
|
|
1749
|
+
// This is a hardcoded rule: any issue with 'orchestrator'/'Orchestrator' label
|
|
1750
|
+
// goes to orchestrator procedure
|
|
1751
|
+
const hasHardcodedOrchestratorLabel = lowercaseLabels.includes("orchestrator");
|
|
1752
|
+
// Also check any additional orchestrator labels from config
|
|
1753
|
+
const orchestratorConfig = repository.labelPrompts?.orchestrator;
|
|
1754
|
+
const orchestratorLabels = Array.isArray(orchestratorConfig)
|
|
1755
|
+
? orchestratorConfig
|
|
1756
|
+
: orchestratorConfig?.labels;
|
|
1757
|
+
const hasConfiguredOrchestratorLabel = orchestratorLabels?.some((label) => lowercaseLabels.includes(label.toLowerCase())) ?? false;
|
|
1758
|
+
const hasOrchestratorLabel = hasHardcodedOrchestratorLabel || hasConfiguredOrchestratorLabel;
|
|
1759
|
+
// Check for graphite label (for graphite-orchestrator combination)
|
|
1760
|
+
const graphiteConfig = repository.labelPrompts?.graphite;
|
|
1761
|
+
const graphiteLabels = Array.isArray(graphiteConfig)
|
|
1762
|
+
? graphiteConfig
|
|
1763
|
+
: (graphiteConfig?.labels ?? ["graphite"]);
|
|
1764
|
+
const hasGraphiteLabel = graphiteLabels?.some((label) => lowercaseLabels.includes(label.toLowerCase()));
|
|
1765
|
+
// Graphite-orchestrator requires BOTH graphite AND orchestrator labels
|
|
1766
|
+
const hasGraphiteOrchestratorLabels = hasGraphiteLabel && hasOrchestratorLabel;
|
|
1767
|
+
let finalProcedure;
|
|
1768
|
+
let finalClassification;
|
|
1769
|
+
// Smart runner override: OpenCode with oh-my-opencode gets full-delegation
|
|
1770
|
+
// (bypasses AI routing and label-based procedure selection entirely)
|
|
1771
|
+
const earlyRunnerSelection = this.determineRunnerSelection(labels, fullIssue.description || undefined);
|
|
1772
|
+
if (earlyRunnerSelection.runnerType === "opencode") {
|
|
1773
|
+
const fullDelegationProcedure = this.procedureAnalyzer.getProcedure("full-delegation");
|
|
1774
|
+
if (!fullDelegationProcedure) {
|
|
1775
|
+
throw new Error("full-delegation procedure not found in registry");
|
|
1776
|
+
}
|
|
1777
|
+
finalProcedure = fullDelegationProcedure;
|
|
1778
|
+
finalClassification = "code"; // Default classification; the runner handles everything
|
|
1779
|
+
log.info(`Using full-delegation procedure for OpenCode runner (bypassing AI routing)`);
|
|
1780
|
+
}
|
|
1781
|
+
else if (hasDebuggerLabel) {
|
|
1782
|
+
const debuggerProcedure = this.procedureAnalyzer.getProcedure("debugger-full");
|
|
1783
|
+
if (!debuggerProcedure) {
|
|
1784
|
+
throw new Error("debugger-full procedure not found in registry");
|
|
1785
|
+
}
|
|
1786
|
+
finalProcedure = debuggerProcedure;
|
|
1787
|
+
finalClassification = "debugger";
|
|
1788
|
+
log.info(`Using debugger-full procedure due to debugger label (skipping AI routing)`);
|
|
1789
|
+
}
|
|
1790
|
+
else if (hasGraphiteOrchestratorLabels) {
|
|
1791
|
+
// Graphite-orchestrator takes precedence over regular orchestrator when both labels present
|
|
1792
|
+
const orchestratorProcedure = this.procedureAnalyzer.getProcedure("orchestrator-full");
|
|
1793
|
+
if (!orchestratorProcedure) {
|
|
1794
|
+
throw new Error("orchestrator-full procedure not found in registry");
|
|
1795
|
+
}
|
|
1796
|
+
finalProcedure = orchestratorProcedure;
|
|
1797
|
+
// Use orchestrator classification but the system prompt will be graphite-orchestrator
|
|
1798
|
+
finalClassification = "orchestrator";
|
|
1799
|
+
log.info(`Using orchestrator-full procedure with graphite-orchestrator prompt (graphite + orchestrator labels)`);
|
|
1800
|
+
}
|
|
1801
|
+
else if (hasOrchestratorLabel) {
|
|
1802
|
+
const orchestratorProcedure = this.procedureAnalyzer.getProcedure("orchestrator-full");
|
|
1803
|
+
if (!orchestratorProcedure) {
|
|
1804
|
+
throw new Error("orchestrator-full procedure not found in registry");
|
|
1805
|
+
}
|
|
1806
|
+
finalProcedure = orchestratorProcedure;
|
|
1807
|
+
finalClassification = "orchestrator";
|
|
1808
|
+
log.info(`Using orchestrator-full procedure due to orchestrator label (skipping AI routing)`);
|
|
1809
|
+
}
|
|
1810
|
+
else {
|
|
1811
|
+
// No label override - use AI routing
|
|
1812
|
+
const issueDescription = `${issue.title}\n\n${fullIssue.description || ""}`.trim();
|
|
1813
|
+
const routingDecision = await this.procedureAnalyzer.determineRoutine(issueDescription);
|
|
1814
|
+
finalProcedure = routingDecision.procedure;
|
|
1815
|
+
finalClassification = routingDecision.classification;
|
|
1816
|
+
// Log AI routing decision
|
|
1817
|
+
log.info(`AI routing decision for ${sessionId}:`);
|
|
1818
|
+
log.info(` Classification: ${routingDecision.classification}`);
|
|
1819
|
+
log.info(` Procedure: ${finalProcedure.name}`);
|
|
1820
|
+
log.info(` Reasoning: ${routingDecision.reasoning}`);
|
|
1821
|
+
}
|
|
1822
|
+
// Initialize procedure metadata in session with final decision
|
|
1823
|
+
this.procedureAnalyzer.initializeProcedureMetadata(session, finalProcedure);
|
|
1824
|
+
// Post single procedure selection result (replaces ephemeral routing thought)
|
|
1825
|
+
await agentSessionManager.postProcedureSelectionThought(sessionId, finalProcedure.name, finalClassification);
|
|
1826
|
+
// Build and start Claude with initial prompt using full issue (streaming mode)
|
|
1827
|
+
log.info(`Building initial prompt for issue ${fullIssue.identifier}`);
|
|
1828
|
+
try {
|
|
1829
|
+
// Create input for unified prompt assembly
|
|
1830
|
+
const input = {
|
|
1831
|
+
session,
|
|
1832
|
+
fullIssue,
|
|
1833
|
+
repository,
|
|
1834
|
+
userComment: commentBody || "", // Empty for delegation, present for mentions
|
|
1835
|
+
attachmentManifest: attachmentResult.manifest,
|
|
1836
|
+
guidance: guidance || undefined,
|
|
1837
|
+
agentSession,
|
|
1838
|
+
labels,
|
|
1839
|
+
isNewSession: true,
|
|
1840
|
+
isStreaming: false, // Not yet streaming
|
|
1841
|
+
isMentionTriggered: isMentionTriggered || false,
|
|
1842
|
+
isLabelBasedPromptRequested: isLabelBasedPromptRequested || false,
|
|
1843
|
+
};
|
|
1844
|
+
// Use unified prompt assembly
|
|
1845
|
+
const assembly = await this.assemblePrompt(input);
|
|
1846
|
+
// Get systemPromptVersion for tracking (TODO: add to PromptAssembly metadata)
|
|
1847
|
+
let systemPromptVersion;
|
|
1848
|
+
let promptType;
|
|
1849
|
+
if (!isMentionTriggered || isLabelBasedPromptRequested) {
|
|
1850
|
+
const systemPromptResult = await this.determineSystemPromptFromLabels(labels, repository);
|
|
1851
|
+
systemPromptVersion = systemPromptResult?.version;
|
|
1852
|
+
promptType = systemPromptResult?.type;
|
|
1853
|
+
// Post thought about system prompt selection
|
|
1854
|
+
if (assembly.systemPrompt) {
|
|
1855
|
+
await this.postSystemPromptSelectionThought(sessionId, labels, repository.id);
|
|
1856
|
+
}
|
|
1857
|
+
}
|
|
1858
|
+
// Get current subroutine to check for singleTurn mode and disallowAllTools
|
|
1859
|
+
const currentSubroutine = this.procedureAnalyzer.getCurrentSubroutine(session);
|
|
1860
|
+
// Build allowed tools list with Linear MCP tools (now with prompt type context)
|
|
1861
|
+
// If subroutine has disallowAllTools: true, use empty array to disable all tools
|
|
1862
|
+
const allowedTools = currentSubroutine?.disallowAllTools
|
|
1863
|
+
? []
|
|
1864
|
+
: this.buildAllowedTools(repository, promptType);
|
|
1865
|
+
const baseDisallowedTools = this.buildDisallowedTools(repository, promptType);
|
|
1866
|
+
// Merge subroutine-level disallowedTools if applicable
|
|
1867
|
+
const disallowedTools = this.mergeSubroutineDisallowedTools(session, baseDisallowedTools, "EdgeWorker");
|
|
1868
|
+
if (currentSubroutine?.disallowAllTools) {
|
|
1869
|
+
log.debug(`All tools disabled for ${fullIssue.identifier} (subroutine: ${currentSubroutine.name})`);
|
|
1870
|
+
}
|
|
1871
|
+
else {
|
|
1872
|
+
log.debug(`Configured allowed tools for ${fullIssue.identifier}:`, allowedTools);
|
|
1873
|
+
}
|
|
1874
|
+
if (disallowedTools.length > 0) {
|
|
1875
|
+
log.debug(`Configured disallowed tools for ${fullIssue.identifier}:`, disallowedTools);
|
|
1876
|
+
}
|
|
1877
|
+
// Create agent runner with system prompt from assembly
|
|
1878
|
+
// buildAgentRunnerConfig now determines runner type from labels internally
|
|
1879
|
+
const { config: runnerConfig, runnerType } = this.buildAgentRunnerConfig(session, repository, sessionId, assembly.systemPrompt, allowedTools, allowedDirectories, disallowedTools, undefined, // resumeSessionId
|
|
1880
|
+
labels, // Pass labels for runner selection and model override
|
|
1881
|
+
fullIssue.description || undefined, // Description tags can override label selectors
|
|
1882
|
+
undefined, // maxTurns
|
|
1883
|
+
currentSubroutine?.singleTurn, // singleTurn flag
|
|
1884
|
+
currentSubroutine?.disallowAllTools);
|
|
1885
|
+
log.debug(`Label-based runner selection for new session: ${runnerType} (session ${sessionId})`);
|
|
1886
|
+
const runner = runnerType === "opencode"
|
|
1887
|
+
? new OpenCodeRunner(runnerConfig)
|
|
1888
|
+
: runnerType === "claude"
|
|
1889
|
+
? new ClaudeRunner(runnerConfig)
|
|
1890
|
+
: runnerType === "gemini"
|
|
1891
|
+
? new GeminiRunner(runnerConfig)
|
|
1892
|
+
: runnerType === "codex"
|
|
1893
|
+
? new CodexRunner(runnerConfig)
|
|
1894
|
+
: new CursorRunner(runnerConfig);
|
|
1895
|
+
// Store runner by comment ID
|
|
1896
|
+
agentSessionManager.addAgentRunner(sessionId, runner);
|
|
1897
|
+
// Save state after mapping changes
|
|
1898
|
+
await this.savePersistedState();
|
|
1899
|
+
// Emit events using full issue (core Issue type)
|
|
1900
|
+
this.emit("session:started", fullIssue.id, fullIssue, repository.id);
|
|
1901
|
+
this.config.handlers?.onSessionStart?.(fullIssue.id, fullIssue, repository.id);
|
|
1902
|
+
// Update runner with version information (if available)
|
|
1903
|
+
// Note: updatePromptVersions is specific to ClaudeRunner
|
|
1904
|
+
if (systemPromptVersion &&
|
|
1905
|
+
"updatePromptVersions" in runner &&
|
|
1906
|
+
typeof runner.updatePromptVersions === "function") {
|
|
1907
|
+
runner.updatePromptVersions({
|
|
1908
|
+
systemPromptVersion,
|
|
1909
|
+
});
|
|
1910
|
+
}
|
|
1911
|
+
// Log metadata for debugging
|
|
1912
|
+
log.debug(`Initial prompt built successfully - components: ${assembly.metadata.components.join(", ")}, type: ${assembly.metadata.promptType}, length: ${assembly.userPrompt.length} characters`);
|
|
1913
|
+
// Start session - use streaming mode if supported for ability to add messages later
|
|
1914
|
+
if (runner.supportsStreamingInput && runner.startStreaming) {
|
|
1915
|
+
log.debug(`Starting streaming session`);
|
|
1916
|
+
const sessionInfo = await runner.startStreaming(assembly.userPrompt);
|
|
1917
|
+
log.debug(`Streaming session started: ${sessionInfo.sessionId}`);
|
|
1918
|
+
}
|
|
1919
|
+
else {
|
|
1920
|
+
log.debug(`Starting non-streaming session`);
|
|
1921
|
+
const sessionInfo = await runner.start(assembly.userPrompt);
|
|
1922
|
+
log.debug(`Non-streaming session started: ${sessionInfo.sessionId}`);
|
|
1923
|
+
}
|
|
1924
|
+
// Note: AgentSessionManager will be initialized automatically when the first system message
|
|
1925
|
+
// is received via handleClaudeMessage() callback
|
|
1926
|
+
}
|
|
1927
|
+
catch (error) {
|
|
1928
|
+
log.error(`Error in prompt building/starting:`, error);
|
|
1929
|
+
throw error;
|
|
1930
|
+
}
|
|
1931
|
+
}
|
|
1932
|
+
/**
|
|
1933
|
+
* Handle stop signal from prompted webhook
|
|
1934
|
+
* Branch 1 of agentSessionPrompted (see packages/CLAUDE.md)
|
|
1935
|
+
*
|
|
1936
|
+
* IMPORTANT: Stop signals do NOT require repository lookup.
|
|
1937
|
+
* The session must already exist (per CLAUDE.md), so we search
|
|
1938
|
+
* all agent session managers to find it.
|
|
1939
|
+
*/
|
|
1940
|
+
async handleStopSignal(webhook) {
|
|
1941
|
+
const agentSessionId = webhook.agentSession.id;
|
|
1942
|
+
const { issue } = webhook.agentSession;
|
|
1943
|
+
const log = this.logger.withContext({ sessionId: agentSessionId });
|
|
1944
|
+
log.info(`Received stop signal for agent activity session ${agentSessionId}`);
|
|
1945
|
+
// Find the agent session manager that contains this session
|
|
1946
|
+
// We don't need repository lookup - just search all managers
|
|
1947
|
+
let foundManager = null;
|
|
1948
|
+
let foundSession = null;
|
|
1949
|
+
for (const manager of this.agentSessionManagers.values()) {
|
|
1950
|
+
const session = manager.getSession(agentSessionId);
|
|
1951
|
+
if (session) {
|
|
1952
|
+
foundManager = manager;
|
|
1953
|
+
foundSession = session;
|
|
1954
|
+
break;
|
|
1955
|
+
}
|
|
1956
|
+
}
|
|
1957
|
+
if (!foundManager || !foundSession) {
|
|
1958
|
+
log.warn(`No session found for stop signal: ${agentSessionId}`);
|
|
1959
|
+
return;
|
|
1960
|
+
}
|
|
1961
|
+
// Stop the existing runner if it's active
|
|
1962
|
+
const existingRunner = foundSession.agentRunner;
|
|
1963
|
+
foundManager.requestSessionStop(agentSessionId);
|
|
1964
|
+
if (existingRunner) {
|
|
1965
|
+
existingRunner.stop();
|
|
1966
|
+
log.info(`Stopped agent session for agent activity session ${agentSessionId}`);
|
|
1967
|
+
}
|
|
1968
|
+
// Post confirmation
|
|
1969
|
+
const issueTitle = issue?.title || "this issue";
|
|
1970
|
+
const stopConfirmation = `I've stopped working on ${issueTitle} as requested.\n\n**Stop Signal:** Received from ${webhook.agentSession.creator?.name || "user"}\n**Action Taken:** All ongoing work has been halted`;
|
|
1971
|
+
await foundManager.createResponseActivity(agentSessionId, stopConfirmation);
|
|
1972
|
+
}
|
|
1973
|
+
/**
|
|
1974
|
+
* Handle repository selection response from prompted webhook
|
|
1975
|
+
* Branch 2 of agentSessionPrompted (see packages/CLAUDE.md)
|
|
1976
|
+
*
|
|
1977
|
+
* This method extracts the user's repository selection from their response,
|
|
1978
|
+
* or uses the fallback repository if their message doesn't match any option.
|
|
1979
|
+
* In both cases, the selected repository is cached for future use.
|
|
1980
|
+
*/
|
|
1981
|
+
async handleRepositorySelectionResponse(webhook) {
|
|
1982
|
+
const { agentSession, agentActivity, guidance } = webhook;
|
|
1983
|
+
const commentBody = agentSession.comment?.body;
|
|
1984
|
+
const agentSessionId = agentSession.id;
|
|
1985
|
+
const log = this.logger.withContext({ sessionId: agentSessionId });
|
|
1986
|
+
if (!agentActivity) {
|
|
1987
|
+
log.warn("Cannot handle repository selection without agentActivity");
|
|
1988
|
+
return;
|
|
1989
|
+
}
|
|
1990
|
+
if (!agentSession.issue) {
|
|
1991
|
+
log.warn("Cannot handle repository selection without issue");
|
|
1992
|
+
return;
|
|
1993
|
+
}
|
|
1994
|
+
const userMessage = agentActivity.content.body;
|
|
1995
|
+
log.debug(`Processing repository selection response: "${userMessage}"`);
|
|
1996
|
+
// Get the selected repository (or fallback)
|
|
1997
|
+
const repository = await this.repositoryRouter.selectRepositoryFromResponse(agentSessionId, userMessage);
|
|
1998
|
+
if (!repository) {
|
|
1999
|
+
log.error(`Failed to select repository for agent session ${agentSessionId}`);
|
|
2000
|
+
return;
|
|
2001
|
+
}
|
|
2002
|
+
// Cache the selected repository for this issue
|
|
2003
|
+
const issueId = agentSession.issue.id;
|
|
2004
|
+
this.repositoryRouter.getIssueRepositoryCache().set(issueId, repository.id);
|
|
2005
|
+
// Post agent activity showing user-selected repository
|
|
2006
|
+
await this.postRepositorySelectionActivity(agentSessionId, repository.id, repository.name, "user-selected");
|
|
2007
|
+
log.debug(`Initializing agent runner after repository selection: ${agentSession.issue.identifier} -> ${repository.name}`);
|
|
2008
|
+
// Initialize agent runner with the selected repository
|
|
2009
|
+
await this.initializeAgentRunner(agentSession, repository, guidance, commentBody);
|
|
2010
|
+
}
|
|
2011
|
+
/**
|
|
2012
|
+
* Handle AskUserQuestion response from prompted webhook
|
|
2013
|
+
* Branch 2.5: User response to a question posed via AskUserQuestion tool
|
|
2014
|
+
*
|
|
2015
|
+
* @param webhook The prompted webhook containing user's response
|
|
2016
|
+
*/
|
|
2017
|
+
async handleAskUserQuestionResponse(webhook) {
|
|
2018
|
+
const { agentSession, agentActivity } = webhook;
|
|
2019
|
+
const agentSessionId = agentSession.id;
|
|
2020
|
+
if (!agentActivity) {
|
|
2021
|
+
this.logger.warn("Cannot handle AskUserQuestion response without agentActivity");
|
|
2022
|
+
// Resolve with a denial to unblock the waiting promise
|
|
2023
|
+
this.askUserQuestionHandler.cancelPendingQuestion(agentSessionId, "No agent activity in webhook");
|
|
2024
|
+
return;
|
|
2025
|
+
}
|
|
2026
|
+
// Extract the user's response from the activity body
|
|
2027
|
+
const userResponse = agentActivity.content?.body || "";
|
|
2028
|
+
this.logger.debug(`Processing AskUserQuestion response for session ${agentSessionId}: "${userResponse}"`);
|
|
2029
|
+
// Pass the response to the handler to resolve the waiting promise
|
|
2030
|
+
const handled = this.askUserQuestionHandler.handleUserResponse(agentSessionId, userResponse);
|
|
2031
|
+
if (!handled) {
|
|
2032
|
+
this.logger.warn(`AskUserQuestion response not handled for session ${agentSessionId} (no pending question)`);
|
|
2033
|
+
}
|
|
2034
|
+
else {
|
|
2035
|
+
this.logger.debug(`AskUserQuestion response handled for session ${agentSessionId}`);
|
|
2036
|
+
}
|
|
2037
|
+
}
|
|
2038
|
+
/**
|
|
2039
|
+
* Handle normal prompted activity (existing session continuation)
|
|
2040
|
+
* Branch 3 of agentSessionPrompted (see packages/CLAUDE.md)
|
|
2041
|
+
*/
|
|
2042
|
+
async handleNormalPromptedActivity(webhook, repository) {
|
|
2043
|
+
const { agentSession } = webhook;
|
|
2044
|
+
const sessionId = agentSession.id;
|
|
2045
|
+
const { issue } = agentSession;
|
|
2046
|
+
if (!issue) {
|
|
2047
|
+
this.logger.warn("Cannot handle prompted activity without issue");
|
|
2048
|
+
return;
|
|
2049
|
+
}
|
|
2050
|
+
if (!webhook.agentActivity) {
|
|
2051
|
+
this.logger.warn("Cannot handle prompted activity without agentActivity");
|
|
2052
|
+
return;
|
|
2053
|
+
}
|
|
2054
|
+
const commentId = webhook.agentActivity.sourceCommentId;
|
|
2055
|
+
// Initialize the agent session in AgentSessionManager
|
|
2056
|
+
const agentSessionManager = this.agentSessionManagers.get(repository.id);
|
|
2057
|
+
if (!agentSessionManager) {
|
|
2058
|
+
this.logger.error("Unexpected: There was no agentSessionManage for the repository with id", repository.id);
|
|
2059
|
+
return;
|
|
2060
|
+
}
|
|
2061
|
+
let session = agentSessionManager.getSession(sessionId);
|
|
2062
|
+
let isNewSession = false;
|
|
2063
|
+
let fullIssue = null;
|
|
2064
|
+
if (!session) {
|
|
2065
|
+
this.logger.debug(`No existing session found for agent activity session ${sessionId}, creating new session`);
|
|
2066
|
+
isNewSession = true;
|
|
2067
|
+
// Post instant acknowledgment for new session creation
|
|
2068
|
+
await this.postInstantPromptedAcknowledgment(sessionId, repository.id, false);
|
|
2069
|
+
// Create the session using the shared method
|
|
2070
|
+
const sessionData = await this.createLinearAgentSession(sessionId, issue, repository, agentSessionManager);
|
|
2071
|
+
// Destructure session data for new session
|
|
2072
|
+
fullIssue = sessionData.fullIssue;
|
|
2073
|
+
session = sessionData.session;
|
|
2074
|
+
this.logger.debug(`Created new session ${sessionId} (prompted webhook)`);
|
|
2075
|
+
// Save state and emit events for new session
|
|
2076
|
+
await this.savePersistedState();
|
|
2077
|
+
// Emit events using full issue (core Issue type)
|
|
2078
|
+
this.emit("session:started", fullIssue.id, fullIssue, repository.id);
|
|
2079
|
+
this.config.handlers?.onSessionStart?.(fullIssue.id, fullIssue, repository.id);
|
|
2080
|
+
}
|
|
2081
|
+
else {
|
|
2082
|
+
this.logger.debug(`Found existing session ${sessionId} for new user prompt`);
|
|
2083
|
+
// Post instant acknowledgment for existing session BEFORE any async work
|
|
2084
|
+
// Check if runner is currently running (streaming is Claude-specific, use isRunning for both)
|
|
2085
|
+
const isCurrentlyStreaming = session?.agentRunner?.isRunning() || false;
|
|
2086
|
+
await this.postInstantPromptedAcknowledgment(sessionId, repository.id, isCurrentlyStreaming);
|
|
2087
|
+
// Need to fetch full issue for routing context
|
|
2088
|
+
const issueTracker = this.issueTrackers.get(repository.id);
|
|
2089
|
+
if (issueTracker) {
|
|
2090
|
+
try {
|
|
2091
|
+
fullIssue = await issueTracker.fetchIssue(issue.id);
|
|
2092
|
+
}
|
|
2093
|
+
catch (error) {
|
|
2094
|
+
this.logger.warn(`Failed to fetch full issue for routing: ${issue.id}`, error);
|
|
2095
|
+
// Continue with degraded routing context
|
|
2096
|
+
}
|
|
2097
|
+
}
|
|
2098
|
+
}
|
|
2099
|
+
// Note: Routing and streaming check happens later in handlePromptWithStreamingCheck
|
|
2100
|
+
// after attachments are processed
|
|
2101
|
+
// Ensure session is not null after creation/retrieval
|
|
2102
|
+
if (!session) {
|
|
2103
|
+
throw new Error(`Failed to get or create session for agent activity session ${sessionId}`);
|
|
2104
|
+
}
|
|
2105
|
+
// Acknowledgment already posted above for both new and existing sessions
|
|
2106
|
+
// (before any async routing work to ensure instant user feedback)
|
|
2107
|
+
// Get issue tracker for this repository
|
|
2108
|
+
const issueTracker = this.issueTrackers.get(repository.id);
|
|
2109
|
+
if (!issueTracker) {
|
|
2110
|
+
this.logger.error("Unexpected: There was no IssueTrackerService for the repository with id", repository.id);
|
|
2111
|
+
return;
|
|
2112
|
+
}
|
|
2113
|
+
// Always set up attachments directory, even if no attachments in current comment
|
|
2114
|
+
const workspaceFolderName = basename(session.workspace.path);
|
|
2115
|
+
const attachmentsDir = join(this.sylasHome, workspaceFolderName, "attachments");
|
|
2116
|
+
// Ensure directory exists
|
|
2117
|
+
await mkdir(attachmentsDir, { recursive: true });
|
|
2118
|
+
let attachmentManifest = "";
|
|
2119
|
+
let commentAuthor;
|
|
2120
|
+
let commentTimestamp;
|
|
2121
|
+
if (!commentId) {
|
|
2122
|
+
this.logger.warn("No comment ID provided for attachment handling");
|
|
2123
|
+
}
|
|
2124
|
+
try {
|
|
2125
|
+
const comment = commentId
|
|
2126
|
+
? await issueTracker.fetchComment(commentId)
|
|
2127
|
+
: null;
|
|
2128
|
+
// Extract comment metadata for multi-player context
|
|
2129
|
+
if (comment) {
|
|
2130
|
+
const user = await comment.user;
|
|
2131
|
+
commentAuthor =
|
|
2132
|
+
user?.displayName || user?.name || user?.email || "Unknown";
|
|
2133
|
+
commentTimestamp = comment.createdAt
|
|
2134
|
+
? comment.createdAt.toISOString()
|
|
2135
|
+
: new Date().toISOString();
|
|
2136
|
+
}
|
|
2137
|
+
// Count existing attachments
|
|
2138
|
+
const existingFiles = await readdir(attachmentsDir).catch(() => []);
|
|
2139
|
+
const existingAttachmentCount = existingFiles.filter((file) => file.startsWith("attachment_") || file.startsWith("image_")).length;
|
|
2140
|
+
// Download new attachments from the comment
|
|
2141
|
+
const downloadResult = comment
|
|
2142
|
+
? await this.downloadCommentAttachments(comment.body, attachmentsDir, repository.linearToken, existingAttachmentCount)
|
|
2143
|
+
: {
|
|
2144
|
+
totalNewAttachments: 0,
|
|
2145
|
+
newAttachmentMap: {},
|
|
2146
|
+
newImageMap: {},
|
|
2147
|
+
failedCount: 0,
|
|
2148
|
+
};
|
|
2149
|
+
if (downloadResult.totalNewAttachments > 0) {
|
|
2150
|
+
attachmentManifest = this.generateNewAttachmentManifest(downloadResult);
|
|
2151
|
+
}
|
|
2152
|
+
}
|
|
2153
|
+
catch (error) {
|
|
2154
|
+
this.logger.error("Failed to fetch comments for attachments:", error);
|
|
2155
|
+
}
|
|
2156
|
+
const promptBody = webhook.agentActivity.content.body;
|
|
2157
|
+
// Use centralized streaming check and routing logic
|
|
2158
|
+
try {
|
|
2159
|
+
await this.handlePromptWithStreamingCheck(session, repository, sessionId, agentSessionManager, promptBody, attachmentManifest, isNewSession, [], // No additional allowed directories for regular continuation
|
|
2160
|
+
`prompted webhook (${isNewSession ? "new" : "existing"} session)`, commentAuthor, commentTimestamp);
|
|
2161
|
+
}
|
|
2162
|
+
catch (error) {
|
|
2163
|
+
this.logger.error("Failed to handle prompted webhook:", error);
|
|
2164
|
+
}
|
|
2165
|
+
}
|
|
2166
|
+
/**
|
|
2167
|
+
* Handle user-prompted agent activity webhook
|
|
2168
|
+
* Implements three-branch architecture from packages/CLAUDE.md:
|
|
2169
|
+
* 1. Stop signal - terminate existing runner
|
|
2170
|
+
* 2. Repository selection response - initialize Claude runner for first time
|
|
2171
|
+
* 3. Normal prompted activity - continue existing session or create new one
|
|
2172
|
+
*
|
|
2173
|
+
* @param webhook The prompted webhook containing user's message
|
|
2174
|
+
*/
|
|
2175
|
+
async handleUserPromptedAgentActivity(webhook) {
|
|
2176
|
+
const agentSessionId = webhook.agentSession.id;
|
|
2177
|
+
const activityBody = webhook.agentActivity?.content?.body || "";
|
|
2178
|
+
const signal = webhook.agentActivity?.signal;
|
|
2179
|
+
const isTextStopRequest = /^\s*stop(\s+session|\s+working)?[\s.!?]*$/i.test(activityBody);
|
|
2180
|
+
// Branch 1: Handle stop signal (checked FIRST, before any routing work)
|
|
2181
|
+
// Per CLAUDE.md: "an agentSession MUST already exist" for stop signals
|
|
2182
|
+
// IMPORTANT: Stop signals do NOT require repository lookup
|
|
2183
|
+
if (signal === "stop" || isTextStopRequest) {
|
|
2184
|
+
await this.handleStopSignal(webhook);
|
|
2185
|
+
return;
|
|
2186
|
+
}
|
|
2187
|
+
// Branch 2: Handle repository selection response
|
|
2188
|
+
// This is the first Claude runner initialization after user selects a repository.
|
|
2189
|
+
// The selection handler extracts the choice from the response (or uses fallback)
|
|
2190
|
+
// and caches the repository for future use.
|
|
2191
|
+
if (this.repositoryRouter.hasPendingSelection(agentSessionId)) {
|
|
2192
|
+
await this.handleRepositorySelectionResponse(webhook);
|
|
2193
|
+
return;
|
|
2194
|
+
}
|
|
2195
|
+
// Branch 2.5: Handle AskUserQuestion response
|
|
2196
|
+
// This handles responses to questions posed via the AskUserQuestion tool.
|
|
2197
|
+
// The response is passed to the pending promise resolver.
|
|
2198
|
+
if (this.askUserQuestionHandler.hasPendingQuestion(agentSessionId)) {
|
|
2199
|
+
await this.handleAskUserQuestionResponse(webhook);
|
|
2200
|
+
return;
|
|
2201
|
+
}
|
|
2202
|
+
// Branch 3: Handle normal prompted activity (existing session continuation)
|
|
2203
|
+
// Per CLAUDE.md: "an agentSession MUST exist and a repository MUST already
|
|
2204
|
+
// be associated with the Linear issue. The repository will be retrieved from
|
|
2205
|
+
// the issue-to-repository cache - no new routing logic is performed."
|
|
2206
|
+
const issueId = webhook.agentSession?.issue?.id;
|
|
2207
|
+
if (!issueId) {
|
|
2208
|
+
this.logger.error(`No issue ID found in prompted webhook ${agentSessionId}`);
|
|
2209
|
+
return;
|
|
2210
|
+
}
|
|
2211
|
+
const repository = this.getCachedRepository(issueId);
|
|
2212
|
+
if (!repository) {
|
|
2213
|
+
this.logger.warn(`No cached repository found for prompted webhook ${agentSessionId}`);
|
|
2214
|
+
return;
|
|
2215
|
+
}
|
|
2216
|
+
// User access control check for mid-session prompts
|
|
2217
|
+
const accessResult = this.checkUserAccess(webhook, repository);
|
|
2218
|
+
if (!accessResult.allowed) {
|
|
2219
|
+
this.logger.info(`User ${accessResult.userName} blocked from prompting: ${accessResult.reason}`);
|
|
2220
|
+
await this.handleBlockedUser(webhook, repository, accessResult.reason);
|
|
2221
|
+
return;
|
|
2222
|
+
}
|
|
2223
|
+
await this.handleNormalPromptedActivity(webhook, repository);
|
|
2224
|
+
}
|
|
2225
|
+
/**
|
|
2226
|
+
* Handle issue unassignment
|
|
2227
|
+
* @param issue Linear issue object from webhook data
|
|
2228
|
+
* @param repository Repository configuration
|
|
2229
|
+
*/
|
|
2230
|
+
async handleIssueUnassigned(issue, repository) {
|
|
2231
|
+
const agentSessionManager = this.agentSessionManagers.get(repository.id);
|
|
2232
|
+
if (!agentSessionManager) {
|
|
2233
|
+
this.logger.info("No agentSessionManager for unassigned issue, so no sessions to stop");
|
|
2234
|
+
return;
|
|
2235
|
+
}
|
|
2236
|
+
const sessions = agentSessionManager.getSessionsByIssueId(issue.id);
|
|
2237
|
+
const activeThreadCount = sessions.length;
|
|
2238
|
+
// Stop all agent runners for this issue
|
|
2239
|
+
for (const session of sessions) {
|
|
2240
|
+
this.logger.info(`Stopping agent runner for issue ${issue.identifier}`);
|
|
2241
|
+
agentSessionManager.requestSessionStop(session.id);
|
|
2242
|
+
session.agentRunner?.stop();
|
|
2243
|
+
}
|
|
2244
|
+
// Post ONE farewell comment on the issue (not in any thread) if there were active sessions
|
|
2245
|
+
if (activeThreadCount > 0) {
|
|
2246
|
+
await this.postComment(issue.id, "I've been unassigned and am stopping work now.", repository.id);
|
|
2247
|
+
}
|
|
2248
|
+
// Emit events
|
|
2249
|
+
this.logger.info(`Stopped ${activeThreadCount} sessions for unassigned issue ${issue.identifier}`);
|
|
2250
|
+
}
|
|
2251
|
+
/**
|
|
2252
|
+
* Handle Claude messages
|
|
2253
|
+
*/
|
|
2254
|
+
async handleClaudeMessage(sessionId, message, repositoryId) {
|
|
2255
|
+
const agentSessionManager = this.agentSessionManagers.get(repositoryId);
|
|
2256
|
+
// Integrate with AgentSessionManager to capture streaming messages
|
|
2257
|
+
if (agentSessionManager) {
|
|
2258
|
+
await agentSessionManager.handleClaudeMessage(sessionId, message);
|
|
2259
|
+
}
|
|
2260
|
+
}
|
|
2261
|
+
/**
|
|
2262
|
+
* Handle Claude session error
|
|
2263
|
+
* Silently ignores AbortError (user-initiated stop), logs other errors
|
|
2264
|
+
*/
|
|
2265
|
+
async handleClaudeError(error) {
|
|
2266
|
+
// AbortError is expected when user stops Claude process, don't log it
|
|
2267
|
+
// Check by name since the SDK's AbortError class may not match our imported definition
|
|
2268
|
+
const isAbortError = error.name === "AbortError" || error.message.includes("aborted by user");
|
|
2269
|
+
// Also check for SIGTERM (exit code 143), which indicates graceful termination
|
|
2270
|
+
const isSigterm = error.message.includes("Claude Code process exited with code 143");
|
|
2271
|
+
if (isAbortError || isSigterm) {
|
|
2272
|
+
return;
|
|
2273
|
+
}
|
|
2274
|
+
this.logger.error("Unhandled claude error:", error);
|
|
2275
|
+
}
|
|
2276
|
+
/**
|
|
2277
|
+
* Fetch issue labels for a given issue
|
|
2278
|
+
*/
|
|
2279
|
+
async fetchIssueLabels(issue) {
|
|
2280
|
+
return this.promptBuilder.fetchIssueLabels(issue);
|
|
2281
|
+
}
|
|
2282
|
+
/**
|
|
2283
|
+
* Resolve default model for a given runner from config with sensible built-in defaults.
|
|
2284
|
+
* Supports legacy config keys for backwards compatibility.
|
|
2285
|
+
*/
|
|
2286
|
+
getDefaultModelForRunner(runnerType) {
|
|
2287
|
+
return this.runnerSelectionService.getDefaultModelForRunner(runnerType);
|
|
2288
|
+
}
|
|
2289
|
+
/**
|
|
2290
|
+
* Resolve default fallback model for a given runner from config with sensible built-in defaults.
|
|
2291
|
+
* Supports legacy Claude fallback key for backwards compatibility.
|
|
2292
|
+
*/
|
|
2293
|
+
getDefaultFallbackModelForRunner(runnerType) {
|
|
2294
|
+
return this.runnerSelectionService.getDefaultFallbackModelForRunner(runnerType);
|
|
2295
|
+
}
|
|
2296
|
+
/**
|
|
2297
|
+
* Determine runner type and model using labels + issue description tags.
|
|
2298
|
+
*
|
|
2299
|
+
* Supported description tags:
|
|
2300
|
+
* - [agent=claude|gemini|codex|cursor]
|
|
2301
|
+
* - [model=<model-name>]
|
|
2302
|
+
*
|
|
2303
|
+
* Precedence:
|
|
2304
|
+
* - Description tags override labels.
|
|
2305
|
+
* - Agent selection and model selection are independent.
|
|
2306
|
+
* - If agent is not explicit, model can infer runner type.
|
|
2307
|
+
*/
|
|
2308
|
+
determineRunnerSelection(labels, issueDescription) {
|
|
2309
|
+
return this.runnerSelectionService.determineRunnerSelection(labels, issueDescription);
|
|
2310
|
+
}
|
|
2311
|
+
/**
|
|
2312
|
+
* Determine system prompt based on issue labels and repository configuration
|
|
2313
|
+
*/
|
|
2314
|
+
async determineSystemPromptFromLabels(labels, repository) {
|
|
2315
|
+
return this.promptBuilder.determineSystemPromptFromLabels(labels, repository);
|
|
2316
|
+
}
|
|
2317
|
+
/**
|
|
2318
|
+
* Build simplified prompt for label-based workflows
|
|
2319
|
+
* @param issue Full Linear issue
|
|
2320
|
+
* @param repository Repository configuration
|
|
2321
|
+
* @param attachmentManifest Optional attachment manifest
|
|
2322
|
+
* @param guidance Optional agent guidance rules from Linear
|
|
2323
|
+
* @returns Formatted prompt string
|
|
2324
|
+
*/
|
|
2325
|
+
async buildLabelBasedPrompt(issue, repository, attachmentManifest = "", guidance) {
|
|
2326
|
+
return this.promptBuilder.buildLabelBasedPrompt(issue, repository, attachmentManifest, guidance);
|
|
2327
|
+
}
|
|
2328
|
+
/**
|
|
2329
|
+
* Build prompt for mention-triggered sessions
|
|
2330
|
+
* @param issue Full Linear issue object
|
|
2331
|
+
* @param repository Repository configuration
|
|
2332
|
+
* @param agentSession The agent session containing the mention
|
|
2333
|
+
* @param attachmentManifest Optional attachment manifest to append
|
|
2334
|
+
* @param guidance Optional agent guidance rules from Linear
|
|
2335
|
+
* @returns The constructed prompt and optional version tag
|
|
2336
|
+
*/
|
|
2337
|
+
async buildMentionPrompt(issue, agentSession, attachmentManifest = "", guidance) {
|
|
2338
|
+
return this.promptBuilder.buildMentionPrompt(issue, agentSession, attachmentManifest, guidance);
|
|
2339
|
+
}
|
|
2340
|
+
/**
|
|
2341
|
+
* Convert full Linear SDK issue to CoreIssue interface for Session creation
|
|
2342
|
+
*/
|
|
2343
|
+
convertLinearIssueToCore(issue) {
|
|
2344
|
+
return this.promptBuilder.convertLinearIssueToCore(issue);
|
|
2345
|
+
}
|
|
2346
|
+
/**
|
|
2347
|
+
* Build a prompt for Claude using the improved XML-style template
|
|
2348
|
+
* @param issue Full Linear issue
|
|
2349
|
+
* @param repository Repository configuration
|
|
2350
|
+
* @param newComment Optional new comment to focus on (for handleNewRootComment)
|
|
2351
|
+
* @param attachmentManifest Optional attachment manifest
|
|
2352
|
+
* @param guidance Optional agent guidance rules from Linear
|
|
2353
|
+
* @returns Formatted prompt string
|
|
2354
|
+
*/
|
|
2355
|
+
async buildIssueContextPrompt(issue, repository, newComment, attachmentManifest = "", guidance) {
|
|
2356
|
+
return this.promptBuilder.buildIssueContextPrompt(issue, repository, newComment, attachmentManifest, guidance);
|
|
2357
|
+
}
|
|
2358
|
+
/**
|
|
2359
|
+
* Get connection status by repository ID
|
|
2360
|
+
*/
|
|
2361
|
+
getConnectionStatus() {
|
|
2362
|
+
const status = new Map();
|
|
2363
|
+
// Single event transport is "connected" if it exists
|
|
2364
|
+
if (this.linearEventTransport) {
|
|
2365
|
+
// Mark all repositories as connected since they share the single transport
|
|
2366
|
+
for (const repoId of this.repositories.keys()) {
|
|
2367
|
+
status.set(repoId, true);
|
|
2368
|
+
}
|
|
2369
|
+
}
|
|
2370
|
+
return status;
|
|
2371
|
+
}
|
|
2372
|
+
/**
|
|
2373
|
+
* Get event transport (for testing purposes)
|
|
2374
|
+
* @internal
|
|
2375
|
+
*/
|
|
2376
|
+
_getClientByToken(_token) {
|
|
2377
|
+
// Return the single shared event transport
|
|
2378
|
+
return this.linearEventTransport;
|
|
2379
|
+
}
|
|
2380
|
+
/**
|
|
2381
|
+
* Start OAuth flow using the shared application server
|
|
2382
|
+
*/
|
|
2383
|
+
async startOAuthFlow(proxyUrl) {
|
|
2384
|
+
const oauthProxyUrl = proxyUrl || this.config.proxyUrl || DEFAULT_PROXY_URL;
|
|
2385
|
+
return this.sharedApplicationServer.startOAuthFlow(oauthProxyUrl);
|
|
2386
|
+
}
|
|
2387
|
+
/**
|
|
2388
|
+
* Get the server port
|
|
2389
|
+
*/
|
|
2390
|
+
getServerPort() {
|
|
2391
|
+
return this.config.serverPort || this.config.webhookPort || 3456;
|
|
2392
|
+
}
|
|
2393
|
+
/**
|
|
2394
|
+
* Get the OAuth callback URL
|
|
2395
|
+
*/
|
|
2396
|
+
getOAuthCallbackUrl() {
|
|
2397
|
+
return this.sharedApplicationServer.getOAuthCallbackUrl();
|
|
2398
|
+
}
|
|
2399
|
+
/**
|
|
2400
|
+
* Move issue to started state when assigned
|
|
2401
|
+
* @param issue Full Linear issue object from Linear SDK
|
|
2402
|
+
* @param repositoryId Repository ID for issue tracker lookup
|
|
2403
|
+
*/
|
|
2404
|
+
async moveIssueToStartedState(issue, repositoryId) {
|
|
2405
|
+
try {
|
|
2406
|
+
const issueTracker = this.issueTrackers.get(repositoryId);
|
|
2407
|
+
if (!issueTracker) {
|
|
2408
|
+
this.logger.warn(`No issue tracker found for repository ${repositoryId}, skipping state update`);
|
|
2409
|
+
return;
|
|
2410
|
+
}
|
|
2411
|
+
// Check if issue is already in a started state
|
|
2412
|
+
const currentState = await issue.state;
|
|
2413
|
+
if (currentState?.type === "started") {
|
|
2414
|
+
this.logger.debug(`Issue ${issue.identifier} is already in started state (${currentState.name})`);
|
|
2415
|
+
return;
|
|
2416
|
+
}
|
|
2417
|
+
// Get team for the issue
|
|
2418
|
+
const team = await issue.team;
|
|
2419
|
+
if (!team) {
|
|
2420
|
+
this.logger.warn(`No team found for issue ${issue.identifier}, skipping state update`);
|
|
2421
|
+
return;
|
|
2422
|
+
}
|
|
2423
|
+
// Get available workflow states for the issue's team
|
|
2424
|
+
const teamStates = await issueTracker.fetchWorkflowStates(team.id);
|
|
2425
|
+
const states = teamStates;
|
|
2426
|
+
// Find all states with type "started" and pick the one with lowest position
|
|
2427
|
+
// This ensures we pick "In Progress" over "In Review" when both have type "started"
|
|
2428
|
+
// Linear uses standardized state types: triage, backlog, unstarted, started, completed, canceled
|
|
2429
|
+
const startedStates = states.nodes.filter((state) => state.type === "started");
|
|
2430
|
+
const startedState = startedStates.sort((a, b) => a.position - b.position)[0];
|
|
2431
|
+
if (!startedState) {
|
|
2432
|
+
throw new Error('Could not find a state with type "started" for this team');
|
|
2433
|
+
}
|
|
2434
|
+
// Update the issue state
|
|
2435
|
+
this.logger.debug(`Moving issue ${issue.identifier} to started state: ${startedState.name}`);
|
|
2436
|
+
if (!issue.id) {
|
|
2437
|
+
this.logger.warn(`Issue ${issue.identifier} has no ID, skipping state update`);
|
|
2438
|
+
return;
|
|
2439
|
+
}
|
|
2440
|
+
await issueTracker.updateIssue(issue.id, {
|
|
2441
|
+
stateId: startedState.id,
|
|
2442
|
+
});
|
|
2443
|
+
this.logger.debug(`✅ Successfully moved issue ${issue.identifier} to ${startedState.name} state`);
|
|
2444
|
+
}
|
|
2445
|
+
catch (error) {
|
|
2446
|
+
this.logger.error(`Failed to move issue ${issue.identifier} to started state:`, error);
|
|
2447
|
+
// Don't throw - we don't want to fail the entire assignment process due to state update failure
|
|
2448
|
+
}
|
|
2449
|
+
}
|
|
2450
|
+
/**
|
|
2451
|
+
* Post initial comment when assigned to issue
|
|
2452
|
+
*/
|
|
2453
|
+
// private async postInitialComment(issueId: string, repositoryId: string): Promise<void> {
|
|
2454
|
+
// const body = "I'm getting started right away."
|
|
2455
|
+
// // Get the issue tracker for this repository
|
|
2456
|
+
// const issueTracker = this.issueTrackers.get(repositoryId)
|
|
2457
|
+
// if (!issueTracker) {
|
|
2458
|
+
// throw new Error(`No issue tracker found for repository ${repositoryId}`)
|
|
2459
|
+
// }
|
|
2460
|
+
// const commentData = {
|
|
2461
|
+
// body
|
|
2462
|
+
// }
|
|
2463
|
+
// await issueTracker.createComment(commentData)
|
|
2464
|
+
// }
|
|
2465
|
+
/**
|
|
2466
|
+
* Post a comment to Linear
|
|
2467
|
+
*/
|
|
2468
|
+
async postComment(issueId, body, repositoryId, parentId) {
|
|
2469
|
+
return this.activityPoster.postComment(issueId, body, repositoryId, parentId);
|
|
2470
|
+
}
|
|
2471
|
+
/**
|
|
2472
|
+
* Format todos as Linear checklist markdown
|
|
2473
|
+
*/
|
|
2474
|
+
// private formatTodosAsChecklist(todos: Array<{id: string, content: string, status: string, priority: string}>): string {
|
|
2475
|
+
// return todos.map(todo => {
|
|
2476
|
+
// const checkbox = todo.status === 'completed' ? '[x]' : '[ ]'
|
|
2477
|
+
// const statusEmoji = todo.status === 'in_progress' ? ' 🔄' : ''
|
|
2478
|
+
// return `- ${checkbox} ${todo.content}${statusEmoji}`
|
|
2479
|
+
// }).join('\n')
|
|
2480
|
+
// }
|
|
2481
|
+
/**
|
|
2482
|
+
* Download attachments from Linear issue
|
|
2483
|
+
* @param issue Linear issue object from webhook data
|
|
2484
|
+
* @param repository Repository configuration
|
|
2485
|
+
* @param workspacePath Path to workspace directory
|
|
2486
|
+
*/
|
|
2487
|
+
async downloadIssueAttachments(issue, repository, workspacePath) {
|
|
2488
|
+
const issueTracker = this.issueTrackers.get(repository.id);
|
|
2489
|
+
return this.attachmentService.downloadIssueAttachments(issue, repository, workspacePath, issueTracker);
|
|
2490
|
+
}
|
|
2491
|
+
/**
|
|
2492
|
+
* Download attachments from a specific comment
|
|
2493
|
+
* @param commentBody The body text of the comment
|
|
2494
|
+
* @param attachmentsDir Directory where attachments should be saved
|
|
2495
|
+
* @param linearToken Linear API token
|
|
2496
|
+
* @param existingAttachmentCount Current number of attachments already downloaded
|
|
2497
|
+
*/
|
|
2498
|
+
async downloadCommentAttachments(commentBody, attachmentsDir, linearToken, existingAttachmentCount) {
|
|
2499
|
+
return this.attachmentService.downloadCommentAttachments(commentBody, attachmentsDir, linearToken, existingAttachmentCount);
|
|
2500
|
+
}
|
|
2501
|
+
/**
|
|
2502
|
+
* Generate attachment manifest for new comment attachments
|
|
2503
|
+
*/
|
|
2504
|
+
generateNewAttachmentManifest(result) {
|
|
2505
|
+
return this.attachmentService.generateNewAttachmentManifest(result);
|
|
2506
|
+
}
|
|
2507
|
+
async registerSylasToolsMcpEndpoint() {
|
|
2508
|
+
if (this.sylasToolsMcpRegistered) {
|
|
2509
|
+
return;
|
|
2510
|
+
}
|
|
2511
|
+
const fastify = this.sharedApplicationServer.getFastifyInstance();
|
|
2512
|
+
if (typeof fastify.register !== "function" ||
|
|
2513
|
+
typeof fastify.addHook !== "function") {
|
|
2514
|
+
console.warn("[EdgeWorker] Skipping sylas-tools MCP endpoint registration: Fastify instance does not support register/addHook");
|
|
2515
|
+
return;
|
|
2516
|
+
}
|
|
2517
|
+
fastify.addHook("onRequest", (request, _reply, done) => {
|
|
2518
|
+
const rawUrl = typeof request?.raw?.url === "string"
|
|
2519
|
+
? request.raw.url
|
|
2520
|
+
: typeof request?.url === "string"
|
|
2521
|
+
? request.url
|
|
2522
|
+
: "";
|
|
2523
|
+
const requestPath = rawUrl.split("?")[0];
|
|
2524
|
+
if (requestPath !== this.sylasToolsMcpEndpoint) {
|
|
2525
|
+
done();
|
|
2526
|
+
return;
|
|
2527
|
+
}
|
|
2528
|
+
if (!this.isSylasToolsMcpAuthorizationValid(request.headers?.authorization)) {
|
|
2529
|
+
_reply.code(401).send({
|
|
2530
|
+
error: "Unauthorized sylas-tools MCP request",
|
|
2531
|
+
});
|
|
2532
|
+
done();
|
|
2533
|
+
return;
|
|
2534
|
+
}
|
|
2535
|
+
const rawContextHeader = request.headers?.["x-sylas-mcp-context-id"];
|
|
2536
|
+
const contextId = Array.isArray(rawContextHeader)
|
|
2537
|
+
? rawContextHeader[0]
|
|
2538
|
+
: rawContextHeader;
|
|
2539
|
+
this.sylasToolsMcpRequestContext.run({ contextId }, () => {
|
|
2540
|
+
done();
|
|
2541
|
+
});
|
|
2542
|
+
});
|
|
2543
|
+
this.sylasToolsMcpSessions.on("connected", (sessionId) => {
|
|
2544
|
+
console.log(`[EdgeWorker] sylas-tools MCP session connected: ${sessionId}`);
|
|
2545
|
+
});
|
|
2546
|
+
this.sylasToolsMcpSessions.on("terminated", (sessionId) => {
|
|
2547
|
+
console.log(`[EdgeWorker] sylas-tools MCP session terminated: ${sessionId}`);
|
|
2548
|
+
});
|
|
2549
|
+
this.sylasToolsMcpSessions.on("error", (error) => {
|
|
2550
|
+
console.error("[EdgeWorker] sylas-tools MCP session error:", error);
|
|
2551
|
+
});
|
|
2552
|
+
await fastify.register(streamableHttp, {
|
|
2553
|
+
stateful: true,
|
|
2554
|
+
mcpEndpoint: this.sylasToolsMcpEndpoint,
|
|
2555
|
+
sessions: this.sylasToolsMcpSessions,
|
|
2556
|
+
createServer: async () => {
|
|
2557
|
+
const contextId = this.sylasToolsMcpRequestContext.getStore()?.contextId;
|
|
2558
|
+
if (!contextId) {
|
|
2559
|
+
throw new Error("Missing x-sylas-mcp-context-id header for sylas-tools MCP request");
|
|
2560
|
+
}
|
|
2561
|
+
const context = this.sylasToolsMcpContexts.get(contextId);
|
|
2562
|
+
if (!context) {
|
|
2563
|
+
throw new Error(`Unknown sylas-tools MCP context '${contextId}'. Build MCP config before connecting.`);
|
|
2564
|
+
}
|
|
2565
|
+
const sdkServer = context.prebuiltServer ||
|
|
2566
|
+
createSylasToolsServer(context.linearToken, this.createSylasToolsOptions(context.parentSessionId));
|
|
2567
|
+
context.prebuiltServer = undefined;
|
|
2568
|
+
return sdkServer.server;
|
|
2569
|
+
},
|
|
2570
|
+
});
|
|
2571
|
+
this.sylasToolsMcpRegistered = true;
|
|
2572
|
+
console.log(`✅ Sylas tools MCP endpoint registered at ${this.sylasToolsMcpEndpoint}`);
|
|
2573
|
+
}
|
|
2574
|
+
createSylasToolsOptions(parentSessionId) {
|
|
2575
|
+
return {
|
|
2576
|
+
parentSessionId,
|
|
2577
|
+
onSessionCreated: (childSessionId, parentId) => {
|
|
2578
|
+
this.handleChildSessionMapping(childSessionId, parentId);
|
|
2579
|
+
},
|
|
2580
|
+
onFeedbackDelivery: async (childSessionId, message) => {
|
|
2581
|
+
return this.handleFeedbackDeliveryToChildSession(childSessionId, message);
|
|
2582
|
+
},
|
|
2583
|
+
};
|
|
2584
|
+
}
|
|
2585
|
+
handleChildSessionMapping(childSessionId, parentSessionId) {
|
|
2586
|
+
console.log(`[EdgeWorker] Agent session created: ${childSessionId}, mapping to parent ${parentSessionId}`);
|
|
2587
|
+
this.childToParentAgentSession.set(childSessionId, parentSessionId);
|
|
2588
|
+
console.log(`[EdgeWorker] Parent-child mapping updated: ${this.childToParentAgentSession.size} mappings`);
|
|
2589
|
+
}
|
|
2590
|
+
async handleFeedbackDeliveryToChildSession(childSessionId, message) {
|
|
2591
|
+
console.log(`[EdgeWorker] Processing feedback delivery to child session ${childSessionId}`);
|
|
2592
|
+
// Find the parent session ID for context
|
|
2593
|
+
const parentSessionId = this.childToParentAgentSession.get(childSessionId);
|
|
2594
|
+
// Find the repository containing the child session
|
|
2595
|
+
let childRepo;
|
|
2596
|
+
let childAgentSessionManager;
|
|
2597
|
+
for (const [repoId, manager] of this.agentSessionManagers) {
|
|
2598
|
+
if (manager.hasAgentRunner(childSessionId)) {
|
|
2599
|
+
childRepo = this.repositories.get(repoId);
|
|
2600
|
+
childAgentSessionManager = manager;
|
|
2601
|
+
break;
|
|
2602
|
+
}
|
|
2603
|
+
}
|
|
2604
|
+
if (!childRepo || !childAgentSessionManager) {
|
|
2605
|
+
console.error(`[EdgeWorker] Child session ${childSessionId} not found in any repository`);
|
|
2606
|
+
return false;
|
|
2607
|
+
}
|
|
2608
|
+
// Get the child session
|
|
2609
|
+
const childSession = childAgentSessionManager.getSession(childSessionId);
|
|
2610
|
+
if (!childSession) {
|
|
2611
|
+
console.error(`[EdgeWorker] Child session ${childSessionId} not found`);
|
|
2612
|
+
return false;
|
|
2613
|
+
}
|
|
2614
|
+
console.log(`[EdgeWorker] Found child session - Issue: ${childSession.issueId}`);
|
|
2615
|
+
// Get parent session info for better context in the thought
|
|
2616
|
+
let parentIssueId;
|
|
2617
|
+
if (parentSessionId) {
|
|
2618
|
+
for (const manager of this.agentSessionManagers.values()) {
|
|
2619
|
+
const parentSession = manager.getSession(parentSessionId);
|
|
2620
|
+
if (parentSession) {
|
|
2621
|
+
parentIssueId =
|
|
2622
|
+
parentSession.issue?.identifier || parentSession.issueId;
|
|
2623
|
+
break;
|
|
2624
|
+
}
|
|
2625
|
+
}
|
|
2626
|
+
}
|
|
2627
|
+
// Post thought to Linear showing feedback receipt
|
|
2628
|
+
const issueTracker = this.issueTrackers.get(childRepo.id);
|
|
2629
|
+
if (issueTracker) {
|
|
2630
|
+
const feedbackThought = parentIssueId
|
|
2631
|
+
? `Received feedback from orchestrator (${parentIssueId}):\n\n---\n\n${message}\n\n---`
|
|
2632
|
+
: `Received feedback from orchestrator:\n\n---\n\n${message}\n\n---`;
|
|
2633
|
+
try {
|
|
2634
|
+
const result = await issueTracker.createAgentActivity({
|
|
2635
|
+
agentSessionId: childSessionId,
|
|
2636
|
+
content: {
|
|
2637
|
+
type: "thought",
|
|
2638
|
+
body: feedbackThought,
|
|
2639
|
+
},
|
|
2640
|
+
});
|
|
2641
|
+
if (result.success) {
|
|
2642
|
+
console.log(`[EdgeWorker] Posted feedback receipt thought for child session ${childSessionId}`);
|
|
2643
|
+
}
|
|
2644
|
+
else {
|
|
2645
|
+
console.error(`[EdgeWorker] Failed to post feedback receipt thought:`, result);
|
|
2646
|
+
}
|
|
2647
|
+
}
|
|
2648
|
+
catch (error) {
|
|
2649
|
+
console.error(`[EdgeWorker] Error posting feedback receipt thought:`, error);
|
|
2650
|
+
}
|
|
2651
|
+
}
|
|
2652
|
+
const feedbackPrompt = `## Received feedback from orchestrator\n\n---\n\n${message}\n\n---`;
|
|
2653
|
+
console.log(`[EdgeWorker] Handling feedback delivery to child session ${childSessionId}`);
|
|
2654
|
+
this.handlePromptWithStreamingCheck(childSession, childRepo, childSessionId, childAgentSessionManager, feedbackPrompt, "", false, [], "give feedback to child")
|
|
2655
|
+
.then(() => {
|
|
2656
|
+
console.log(`[EdgeWorker] Child session ${childSessionId} completed processing feedback`);
|
|
2657
|
+
})
|
|
2658
|
+
.catch((error) => {
|
|
2659
|
+
console.error(`[EdgeWorker] Failed to process feedback in child session:`, error);
|
|
2660
|
+
});
|
|
2661
|
+
console.log(`[EdgeWorker] Feedback delivered successfully to child session ${childSessionId}`);
|
|
2662
|
+
return true;
|
|
2663
|
+
}
|
|
2664
|
+
buildSylasToolsMcpContextId(repository, parentSessionId) {
|
|
2665
|
+
if (parentSessionId) {
|
|
2666
|
+
return `${repository.id}:${parentSessionId}`;
|
|
2667
|
+
}
|
|
2668
|
+
return `${repository.id}:anon:${Date.now()}:${Math.random().toString(36).slice(2, 8)}`;
|
|
2669
|
+
}
|
|
2670
|
+
getSylasToolsMcpUrl() {
|
|
2671
|
+
const server = this.sharedApplicationServer;
|
|
2672
|
+
const port = typeof server.getPort === "function"
|
|
2673
|
+
? server.getPort()
|
|
2674
|
+
: this.config.serverPort || this.config.webhookPort || 3456;
|
|
2675
|
+
return `http://127.0.0.1:${port}${this.sylasToolsMcpEndpoint}`;
|
|
2676
|
+
}
|
|
2677
|
+
pruneSylasToolsMcpContexts(maxEntries = 500) {
|
|
2678
|
+
if (this.sylasToolsMcpContexts.size <= maxEntries) {
|
|
2679
|
+
return;
|
|
2680
|
+
}
|
|
2681
|
+
const entriesByAge = Array.from(this.sylasToolsMcpContexts.entries()).sort((a, b) => a[1].createdAt - b[1].createdAt);
|
|
2682
|
+
const pruneCount = this.sylasToolsMcpContexts.size - maxEntries;
|
|
2683
|
+
for (let i = 0; i < pruneCount; i++) {
|
|
2684
|
+
const entry = entriesByAge[i];
|
|
2685
|
+
if (!entry) {
|
|
2686
|
+
break;
|
|
2687
|
+
}
|
|
2688
|
+
const [contextId] = entry;
|
|
2689
|
+
this.sylasToolsMcpContexts.delete(contextId);
|
|
2690
|
+
}
|
|
2691
|
+
}
|
|
2692
|
+
/**
|
|
2693
|
+
* Build MCP configuration with automatic Linear server injection and sylas-tools over Fastify MCP.
|
|
2694
|
+
*/
|
|
2695
|
+
buildMcpConfig(repository, parentSessionId) {
|
|
2696
|
+
const contextId = this.buildSylasToolsMcpContextId(repository, parentSessionId);
|
|
2697
|
+
// Prebuild one SDK server for this context so callback wiring remains deterministic.
|
|
2698
|
+
// If the client reconnects and needs another server, the endpoint creates a fresh one.
|
|
2699
|
+
const prebuiltServer = createSylasToolsServer(repository.linearToken, this.createSylasToolsOptions(parentSessionId));
|
|
2700
|
+
this.sylasToolsMcpContexts.set(contextId, {
|
|
2701
|
+
contextId,
|
|
2702
|
+
linearToken: repository.linearToken,
|
|
2703
|
+
parentSessionId,
|
|
2704
|
+
prebuiltServer,
|
|
2705
|
+
createdAt: Date.now(),
|
|
2706
|
+
});
|
|
2707
|
+
this.pruneSylasToolsMcpContexts();
|
|
2708
|
+
const sylasToolsAuthorizationHeader = this.getSylasToolsMcpAuthorizationHeaderValue();
|
|
2709
|
+
// Always inject the Linear MCP servers with the repository's token
|
|
2710
|
+
// https://linear.app/docs/mcp
|
|
2711
|
+
const mcpConfig = {
|
|
2712
|
+
linear: {
|
|
2713
|
+
type: "http",
|
|
2714
|
+
url: "https://mcp.linear.app/mcp",
|
|
2715
|
+
headers: {
|
|
2716
|
+
Authorization: `Bearer ${repository.linearToken}`,
|
|
2717
|
+
},
|
|
2718
|
+
},
|
|
2719
|
+
"sylas-tools": {
|
|
2720
|
+
type: "http",
|
|
2721
|
+
url: this.getSylasToolsMcpUrl(),
|
|
2722
|
+
headers: {
|
|
2723
|
+
"x-sylas-mcp-context-id": contextId,
|
|
2724
|
+
...(sylasToolsAuthorizationHeader
|
|
2725
|
+
? {
|
|
2726
|
+
Authorization: sylasToolsAuthorizationHeader,
|
|
2727
|
+
}
|
|
2728
|
+
: {}),
|
|
2729
|
+
},
|
|
2730
|
+
},
|
|
2731
|
+
};
|
|
2732
|
+
// Add OpenAI-based MCP servers if API key is configured
|
|
2733
|
+
if (repository.openaiApiKey) {
|
|
2734
|
+
// Sora video generation tools
|
|
2735
|
+
mcpConfig["sora-tools"] = createSoraToolsServer({
|
|
2736
|
+
apiKey: repository.openaiApiKey,
|
|
2737
|
+
outputDirectory: repository.openaiOutputDirectory,
|
|
2738
|
+
});
|
|
2739
|
+
// GPT Image generation tools
|
|
2740
|
+
mcpConfig["image-tools"] = createImageToolsServer({
|
|
2741
|
+
apiKey: repository.openaiApiKey,
|
|
2742
|
+
outputDirectory: repository.openaiOutputDirectory,
|
|
2743
|
+
});
|
|
2744
|
+
this.logger.debug(`Configured OpenAI MCP servers (Sora + GPT Image) for repository: ${repository.name}`);
|
|
2745
|
+
}
|
|
2746
|
+
return mcpConfig;
|
|
2747
|
+
}
|
|
2748
|
+
getSylasToolsMcpAuthorizationHeaderValue() {
|
|
2749
|
+
const apiKey = process.env.SYLAS_API_KEY?.trim();
|
|
2750
|
+
if (!apiKey) {
|
|
2751
|
+
return undefined;
|
|
2752
|
+
}
|
|
2753
|
+
return `Bearer ${apiKey}`;
|
|
2754
|
+
}
|
|
2755
|
+
isSylasToolsMcpAuthorizationValid(rawAuthorizationHeader) {
|
|
2756
|
+
const expectedHeader = this.getSylasToolsMcpAuthorizationHeaderValue();
|
|
2757
|
+
if (!expectedHeader) {
|
|
2758
|
+
return true;
|
|
2759
|
+
}
|
|
2760
|
+
const authorizationHeader = Array.isArray(rawAuthorizationHeader)
|
|
2761
|
+
? rawAuthorizationHeader[0]
|
|
2762
|
+
: rawAuthorizationHeader;
|
|
2763
|
+
return authorizationHeader === expectedHeader;
|
|
2764
|
+
}
|
|
2765
|
+
/**
|
|
2766
|
+
* Build the complete prompt for a session - shows full prompt assembly in one place
|
|
2767
|
+
*
|
|
2768
|
+
* New session prompt structure:
|
|
2769
|
+
* 1. Issue context (from buildIssueContextPrompt)
|
|
2770
|
+
* 2. Initial subroutine prompt (if procedure initialized)
|
|
2771
|
+
* 3. User comment
|
|
2772
|
+
*
|
|
2773
|
+
* Existing session prompt structure:
|
|
2774
|
+
* 1. User comment
|
|
2775
|
+
* 2. Attachment manifest (if present)
|
|
2776
|
+
*/
|
|
2777
|
+
async buildSessionPrompt(isNewSession, session, fullIssue, repository, promptBody, attachmentManifest, commentAuthor, commentTimestamp) {
|
|
2778
|
+
// Fetch labels for system prompt determination
|
|
2779
|
+
const labels = await this.fetchIssueLabels(fullIssue);
|
|
2780
|
+
// Create input for unified prompt assembly
|
|
2781
|
+
const input = {
|
|
2782
|
+
session,
|
|
2783
|
+
fullIssue,
|
|
2784
|
+
repository,
|
|
2785
|
+
userComment: promptBody,
|
|
2786
|
+
commentAuthor,
|
|
2787
|
+
commentTimestamp,
|
|
2788
|
+
attachmentManifest,
|
|
2789
|
+
isNewSession,
|
|
2790
|
+
isStreaming: false, // This path is only for non-streaming prompts
|
|
2791
|
+
labels,
|
|
2792
|
+
};
|
|
2793
|
+
// Use unified prompt assembly
|
|
2794
|
+
const assembly = await this.assemblePrompt(input);
|
|
2795
|
+
// Log metadata for debugging
|
|
2796
|
+
this.logger.debug(`Built prompt - components: ${assembly.metadata.components.join(", ")}, type: ${assembly.metadata.promptType}`);
|
|
2797
|
+
return assembly.userPrompt;
|
|
2798
|
+
}
|
|
2799
|
+
/**
|
|
2800
|
+
* Assemble a complete prompt - unified entry point for all prompt building
|
|
2801
|
+
* This method contains all prompt assembly logic in one place
|
|
2802
|
+
*/
|
|
2803
|
+
async assemblePrompt(input) {
|
|
2804
|
+
// If actively streaming, just pass through the comment
|
|
2805
|
+
if (input.isStreaming) {
|
|
2806
|
+
return this.buildStreamingPrompt(input);
|
|
2807
|
+
}
|
|
2808
|
+
// If new session, build full prompt with all components
|
|
2809
|
+
if (input.isNewSession) {
|
|
2810
|
+
return this.buildNewSessionPrompt(input);
|
|
2811
|
+
}
|
|
2812
|
+
// Existing session continuation - just user comment + attachments
|
|
2813
|
+
return this.buildContinuationPrompt(input);
|
|
2814
|
+
}
|
|
2815
|
+
/**
|
|
2816
|
+
* Build prompt for actively streaming session - pass through user comment as-is
|
|
2817
|
+
*/
|
|
2818
|
+
buildStreamingPrompt(input) {
|
|
2819
|
+
const components = ["user-comment"];
|
|
2820
|
+
if (input.attachmentManifest) {
|
|
2821
|
+
components.push("attachment-manifest");
|
|
2822
|
+
}
|
|
2823
|
+
const parts = [input.userComment];
|
|
2824
|
+
if (input.attachmentManifest) {
|
|
2825
|
+
parts.push(input.attachmentManifest);
|
|
2826
|
+
}
|
|
2827
|
+
return {
|
|
2828
|
+
systemPrompt: undefined,
|
|
2829
|
+
userPrompt: parts.join("\n\n"),
|
|
2830
|
+
metadata: {
|
|
2831
|
+
components,
|
|
2832
|
+
promptType: "continuation",
|
|
2833
|
+
isNewSession: false,
|
|
2834
|
+
isStreaming: true,
|
|
2835
|
+
},
|
|
2836
|
+
};
|
|
2837
|
+
}
|
|
2838
|
+
/**
|
|
2839
|
+
* Build prompt for new session - includes issue context, subroutine prompt, and user comment
|
|
2840
|
+
*/
|
|
2841
|
+
async buildNewSessionPrompt(input) {
|
|
2842
|
+
const components = [];
|
|
2843
|
+
const parts = [];
|
|
2844
|
+
// 1. Determine system prompt from labels
|
|
2845
|
+
// Only for delegation (not mentions) or when /label-based-prompt is requested
|
|
2846
|
+
let labelBasedSystemPrompt;
|
|
2847
|
+
if (!input.isMentionTriggered || input.isLabelBasedPromptRequested) {
|
|
2848
|
+
labelBasedSystemPrompt = await this.determineSystemPromptForAssembly(input.labels || [], input.repository);
|
|
2849
|
+
}
|
|
2850
|
+
// 2. Determine system prompt based on prompt type
|
|
2851
|
+
// Label-based: Use only the label-based system prompt
|
|
2852
|
+
// Fallback: Use scenarios system prompt (shared instructions)
|
|
2853
|
+
let systemPrompt;
|
|
2854
|
+
if (labelBasedSystemPrompt) {
|
|
2855
|
+
// Use label-based system prompt as-is (no shared instructions)
|
|
2856
|
+
systemPrompt = labelBasedSystemPrompt;
|
|
2857
|
+
}
|
|
2858
|
+
else {
|
|
2859
|
+
// Use scenarios system prompt for fallback cases
|
|
2860
|
+
const sharedInstructions = await this.loadSharedInstructions();
|
|
2861
|
+
systemPrompt = sharedInstructions;
|
|
2862
|
+
}
|
|
2863
|
+
// 3. Build issue context using appropriate builder
|
|
2864
|
+
// Use label-based prompt ONLY if we have a label-based system prompt
|
|
2865
|
+
const promptType = this.determinePromptType(input, !!labelBasedSystemPrompt);
|
|
2866
|
+
const issueContext = await this.buildIssueContextForPromptAssembly(input.fullIssue, input.repository, promptType, input.attachmentManifest, input.guidance, input.agentSession);
|
|
2867
|
+
parts.push(issueContext.prompt);
|
|
2868
|
+
components.push("issue-context");
|
|
2869
|
+
// 4. Load and append initial subroutine prompt
|
|
2870
|
+
const currentSubroutine = this.procedureAnalyzer.getCurrentSubroutine(input.session);
|
|
2871
|
+
let subroutineName;
|
|
2872
|
+
if (currentSubroutine) {
|
|
2873
|
+
const subroutinePrompt = await this.loadSubroutinePrompt(currentSubroutine, this.config.linearWorkspaceSlug);
|
|
2874
|
+
if (subroutinePrompt) {
|
|
2875
|
+
parts.push(subroutinePrompt);
|
|
2876
|
+
components.push("subroutine-prompt");
|
|
2877
|
+
subroutineName = currentSubroutine.name;
|
|
2878
|
+
}
|
|
2879
|
+
}
|
|
2880
|
+
// 5. Add user comment (if present)
|
|
2881
|
+
// Skip for mention-triggered prompts since the comment is already in the mention block
|
|
2882
|
+
if (input.userComment.trim() && !input.isMentionTriggered) {
|
|
2883
|
+
// If we have author/timestamp metadata, include it for multi-player context
|
|
2884
|
+
if (input.commentAuthor || input.commentTimestamp) {
|
|
2885
|
+
const author = input.commentAuthor || "Unknown";
|
|
2886
|
+
const timestamp = input.commentTimestamp || new Date().toISOString();
|
|
2887
|
+
parts.push(`<user_comment>
|
|
2888
|
+
<author>${author}</author>
|
|
2889
|
+
<timestamp>${timestamp}</timestamp>
|
|
2890
|
+
<content>
|
|
2891
|
+
${input.userComment}
|
|
2892
|
+
</content>
|
|
2893
|
+
</user_comment>`);
|
|
2894
|
+
}
|
|
2895
|
+
else {
|
|
2896
|
+
// Legacy format without metadata
|
|
2897
|
+
parts.push(`<user_comment>\n${input.userComment}\n</user_comment>`);
|
|
2898
|
+
}
|
|
2899
|
+
components.push("user-comment");
|
|
2900
|
+
}
|
|
2901
|
+
// 6. Add guidance rules (if present)
|
|
2902
|
+
if (input.guidance && input.guidance.length > 0) {
|
|
2903
|
+
components.push("guidance-rules");
|
|
2904
|
+
}
|
|
2905
|
+
return {
|
|
2906
|
+
systemPrompt,
|
|
2907
|
+
userPrompt: parts.join("\n\n"),
|
|
2908
|
+
metadata: {
|
|
2909
|
+
components,
|
|
2910
|
+
subroutineName,
|
|
2911
|
+
promptType,
|
|
2912
|
+
isNewSession: true,
|
|
2913
|
+
isStreaming: false,
|
|
2914
|
+
},
|
|
2915
|
+
};
|
|
2916
|
+
}
|
|
2917
|
+
/**
|
|
2918
|
+
* Build prompt for existing session continuation - user comment and attachments only
|
|
2919
|
+
*/
|
|
2920
|
+
buildContinuationPrompt(input) {
|
|
2921
|
+
const components = ["user-comment"];
|
|
2922
|
+
if (input.attachmentManifest) {
|
|
2923
|
+
components.push("attachment-manifest");
|
|
2924
|
+
}
|
|
2925
|
+
// Wrap comment in XML with author and timestamp for multi-player context
|
|
2926
|
+
const author = input.commentAuthor || "Unknown";
|
|
2927
|
+
const timestamp = input.commentTimestamp || new Date().toISOString();
|
|
2928
|
+
const commentXml = `<new_comment>
|
|
2929
|
+
<author>${author}</author>
|
|
2930
|
+
<timestamp>${timestamp}</timestamp>
|
|
2931
|
+
<content>
|
|
2932
|
+
${input.userComment}
|
|
2933
|
+
</content>
|
|
2934
|
+
</new_comment>`;
|
|
2935
|
+
const parts = [commentXml];
|
|
2936
|
+
if (input.attachmentManifest) {
|
|
2937
|
+
parts.push(input.attachmentManifest);
|
|
2938
|
+
}
|
|
2939
|
+
return {
|
|
2940
|
+
systemPrompt: undefined,
|
|
2941
|
+
userPrompt: parts.join("\n\n"),
|
|
2942
|
+
metadata: {
|
|
2943
|
+
components,
|
|
2944
|
+
promptType: "continuation",
|
|
2945
|
+
isNewSession: false,
|
|
2946
|
+
isStreaming: false,
|
|
2947
|
+
},
|
|
2948
|
+
};
|
|
2949
|
+
}
|
|
2950
|
+
/**
|
|
2951
|
+
* Determine the prompt type based on input flags and system prompt availability
|
|
2952
|
+
*/
|
|
2953
|
+
determinePromptType(input, hasSystemPrompt) {
|
|
2954
|
+
if (input.isMentionTriggered && input.isLabelBasedPromptRequested) {
|
|
2955
|
+
return "label-based-prompt-command";
|
|
2956
|
+
}
|
|
2957
|
+
if (input.isMentionTriggered) {
|
|
2958
|
+
return "mention";
|
|
2959
|
+
}
|
|
2960
|
+
if (hasSystemPrompt) {
|
|
2961
|
+
return "label-based";
|
|
2962
|
+
}
|
|
2963
|
+
return "fallback";
|
|
2964
|
+
}
|
|
2965
|
+
/**
|
|
2966
|
+
* Load a subroutine prompt file
|
|
2967
|
+
* Extracted helper to make prompt assembly more readable
|
|
2968
|
+
*/
|
|
2969
|
+
async loadSubroutinePrompt(subroutine, workspaceSlug) {
|
|
2970
|
+
return this.promptBuilder.loadSubroutinePrompt(subroutine, workspaceSlug);
|
|
2971
|
+
}
|
|
2972
|
+
/**
|
|
2973
|
+
* Load shared instructions that get appended to all system prompts
|
|
2974
|
+
*/
|
|
2975
|
+
async loadSharedInstructions() {
|
|
2976
|
+
return this.promptBuilder.loadSharedInstructions();
|
|
2977
|
+
}
|
|
2978
|
+
/**
|
|
2979
|
+
* Adapter method for prompt assembly - extracts just the prompt string
|
|
2980
|
+
*/
|
|
2981
|
+
async determineSystemPromptForAssembly(labels, repository) {
|
|
2982
|
+
const result = await this.determineSystemPromptFromLabels(labels, repository);
|
|
2983
|
+
return result?.prompt;
|
|
2984
|
+
}
|
|
2985
|
+
/**
|
|
2986
|
+
* Adapter method for prompt assembly - routes to appropriate issue context builder
|
|
2987
|
+
*/
|
|
2988
|
+
async buildIssueContextForPromptAssembly(issue, repository, promptType, attachmentManifest, guidance, agentSession) {
|
|
2989
|
+
// Delegate to appropriate builder based on promptType
|
|
2990
|
+
if (promptType === "mention") {
|
|
2991
|
+
if (!agentSession) {
|
|
2992
|
+
throw new Error("agentSession is required for mention-triggered prompts");
|
|
2993
|
+
}
|
|
2994
|
+
return this.buildMentionPrompt(issue, agentSession, attachmentManifest, guidance);
|
|
2995
|
+
}
|
|
2996
|
+
if (promptType === "label-based" ||
|
|
2997
|
+
promptType === "label-based-prompt-command") {
|
|
2998
|
+
return this.buildLabelBasedPrompt(issue, repository, attachmentManifest, guidance);
|
|
2999
|
+
}
|
|
3000
|
+
// Fallback to standard issue context
|
|
3001
|
+
return this.buildIssueContextPrompt(issue, repository, undefined, // No new comment for initial prompt assembly
|
|
3002
|
+
attachmentManifest, guidance);
|
|
3003
|
+
}
|
|
3004
|
+
/**
|
|
3005
|
+
* Build agent runner configuration with common settings.
|
|
3006
|
+
* Also determines which runner type to use based on labels.
|
|
3007
|
+
* @returns Object containing the runner config and runner type to use
|
|
3008
|
+
*/
|
|
3009
|
+
buildAgentRunnerConfig(session, repository, sessionId, systemPrompt, allowedTools, allowedDirectories, disallowedTools, resumeSessionId, labels, issueDescription, maxTurns, singleTurn, disallowAllTools) {
|
|
3010
|
+
const log = this.logger.withContext({
|
|
3011
|
+
sessionId,
|
|
3012
|
+
platform: session.issueContext?.trackerId,
|
|
3013
|
+
issueIdentifier: session.issueContext?.issueIdentifier,
|
|
3014
|
+
});
|
|
3015
|
+
// Configure PostToolUse hooks for screenshot tools to guide Claude to use linear_upload_file
|
|
3016
|
+
// This ensures screenshots can be viewed in Linear comments instead of remaining as local files
|
|
3017
|
+
const hooks = {
|
|
3018
|
+
PostToolUse: [
|
|
3019
|
+
{
|
|
3020
|
+
matcher: "playwright_screenshot",
|
|
3021
|
+
hooks: [
|
|
3022
|
+
async (input, _toolUseID, { signal: _signal }) => {
|
|
3023
|
+
const postToolUseInput = input;
|
|
3024
|
+
log.debug(`Tool ${postToolUseInput.tool_name} completed with response:`, postToolUseInput.tool_response);
|
|
3025
|
+
const response = postToolUseInput.tool_response;
|
|
3026
|
+
const filePath = response?.path || "the screenshot file";
|
|
3027
|
+
return {
|
|
3028
|
+
continue: true,
|
|
3029
|
+
additionalContext: `Screenshot taken successfully. To share this screenshot in Linear comments, use the linear_upload_file tool to upload ${filePath}. This will return an asset URL that can be embedded in markdown. You can also use the Read tool to view the screenshot file to analyze the visual content.`,
|
|
3030
|
+
};
|
|
3031
|
+
},
|
|
3032
|
+
],
|
|
3033
|
+
},
|
|
3034
|
+
{
|
|
3035
|
+
matcher: "mcp__claude-in-chrome__computer",
|
|
3036
|
+
hooks: [
|
|
3037
|
+
async (input, _toolUseID, { signal: _signal }) => {
|
|
3038
|
+
const postToolUseInput = input;
|
|
3039
|
+
const response = postToolUseInput.tool_response;
|
|
3040
|
+
// Only provide upload guidance for screenshot actions
|
|
3041
|
+
if (response?.action === "screenshot") {
|
|
3042
|
+
const filePath = response?.path || "the screenshot file";
|
|
3043
|
+
return {
|
|
3044
|
+
continue: true,
|
|
3045
|
+
additionalContext: `Screenshot captured. To share this screenshot in Linear comments, use the linear_upload_file tool to upload ${filePath}. This will return an asset URL that can be embedded in markdown.`,
|
|
3046
|
+
};
|
|
3047
|
+
}
|
|
3048
|
+
return { continue: true };
|
|
3049
|
+
},
|
|
3050
|
+
],
|
|
3051
|
+
},
|
|
3052
|
+
{
|
|
3053
|
+
matcher: "mcp__claude-in-chrome__gif_creator",
|
|
3054
|
+
hooks: [
|
|
3055
|
+
async (input, _toolUseID, { signal: _signal }) => {
|
|
3056
|
+
const postToolUseInput = input;
|
|
3057
|
+
const response = postToolUseInput.tool_response;
|
|
3058
|
+
// Only provide upload guidance for export actions
|
|
3059
|
+
if (response?.action === "export") {
|
|
3060
|
+
const filePath = response?.path || "the exported GIF";
|
|
3061
|
+
return {
|
|
3062
|
+
continue: true,
|
|
3063
|
+
additionalContext: `GIF exported successfully. To share this GIF in Linear comments, use the linear_upload_file tool to upload ${filePath}. This will return an asset URL that can be embedded in markdown.`,
|
|
3064
|
+
};
|
|
3065
|
+
}
|
|
3066
|
+
return { continue: true };
|
|
3067
|
+
},
|
|
3068
|
+
],
|
|
3069
|
+
},
|
|
3070
|
+
{
|
|
3071
|
+
matcher: "mcp__chrome-devtools__take_screenshot",
|
|
3072
|
+
hooks: [
|
|
3073
|
+
async (input, _toolUseID, { signal: _signal }) => {
|
|
3074
|
+
const postToolUseInput = input;
|
|
3075
|
+
// Extract file path from input (the tool saves to filePath parameter)
|
|
3076
|
+
const toolInput = postToolUseInput.tool_input;
|
|
3077
|
+
const filePath = toolInput?.filePath || "the screenshot file";
|
|
3078
|
+
return {
|
|
3079
|
+
continue: true,
|
|
3080
|
+
additionalContext: `Screenshot saved. To share this screenshot in Linear comments, use the linear_upload_file tool to upload ${filePath}. This will return an asset URL that can be embedded in markdown.`,
|
|
3081
|
+
};
|
|
3082
|
+
},
|
|
3083
|
+
],
|
|
3084
|
+
},
|
|
3085
|
+
],
|
|
3086
|
+
};
|
|
3087
|
+
// Determine runner type and model override from selectors
|
|
3088
|
+
const runnerSelection = this.determineRunnerSelection(labels || [], issueDescription);
|
|
3089
|
+
let runnerType = runnerSelection.runnerType;
|
|
3090
|
+
let modelOverride = runnerSelection.modelOverride;
|
|
3091
|
+
let fallbackModelOverride = runnerSelection.fallbackModelOverride;
|
|
3092
|
+
// If the labels have changed, and we are resuming a session. Use the existing runner for the session.
|
|
3093
|
+
if (session.claudeSessionId && runnerType !== "claude") {
|
|
3094
|
+
runnerType = "claude";
|
|
3095
|
+
modelOverride = this.getDefaultModelForRunner("claude");
|
|
3096
|
+
fallbackModelOverride = this.getDefaultFallbackModelForRunner("claude");
|
|
3097
|
+
}
|
|
3098
|
+
else if (session.geminiSessionId && runnerType !== "gemini") {
|
|
3099
|
+
runnerType = "gemini";
|
|
3100
|
+
modelOverride = this.getDefaultModelForRunner("gemini");
|
|
3101
|
+
fallbackModelOverride = this.getDefaultFallbackModelForRunner("gemini");
|
|
3102
|
+
}
|
|
3103
|
+
else if (session.codexSessionId && runnerType !== "codex") {
|
|
3104
|
+
runnerType = "codex";
|
|
3105
|
+
modelOverride = this.getDefaultModelForRunner("codex");
|
|
3106
|
+
fallbackModelOverride = this.getDefaultFallbackModelForRunner("codex");
|
|
3107
|
+
}
|
|
3108
|
+
else if (session.cursorSessionId && runnerType !== "cursor") {
|
|
3109
|
+
runnerType = "cursor";
|
|
3110
|
+
modelOverride = this.getDefaultModelForRunner("cursor");
|
|
3111
|
+
fallbackModelOverride = this.getDefaultFallbackModelForRunner("cursor");
|
|
3112
|
+
}
|
|
3113
|
+
else if (session.openCodeSessionId && runnerType !== "opencode") {
|
|
3114
|
+
runnerType = "opencode";
|
|
3115
|
+
modelOverride = this.getDefaultModelForRunner("opencode");
|
|
3116
|
+
fallbackModelOverride = this.getDefaultFallbackModelForRunner("opencode");
|
|
3117
|
+
}
|
|
3118
|
+
// Log model override if found
|
|
3119
|
+
if (modelOverride) {
|
|
3120
|
+
log.debug(`Model override via selector: ${modelOverride}`);
|
|
3121
|
+
}
|
|
3122
|
+
// Convert singleTurn flag to effective maxTurns value
|
|
3123
|
+
const effectiveMaxTurns = singleTurn ? 1 : maxTurns;
|
|
3124
|
+
// Determine final model from selectors, repository override, then runner-specific defaults
|
|
3125
|
+
const finalModel = modelOverride ||
|
|
3126
|
+
repository.model ||
|
|
3127
|
+
this.getDefaultModelForRunner(runnerType);
|
|
3128
|
+
// When disallowAllTools is true, don't provide any MCP servers to ensure
|
|
3129
|
+
// the agent cannot use any tools (including MCP-provided tools like Linear create_comment)
|
|
3130
|
+
const mcpConfig = disallowAllTools
|
|
3131
|
+
? undefined
|
|
3132
|
+
: this.buildMcpConfig(repository, sessionId);
|
|
3133
|
+
const mcpConfigPath = disallowAllTools
|
|
3134
|
+
? undefined
|
|
3135
|
+
: repository.mcpConfigPath;
|
|
3136
|
+
if (disallowAllTools) {
|
|
3137
|
+
log.info(`MCP tools disabled for session ${sessionId} (disallowAllTools=true)`);
|
|
3138
|
+
}
|
|
3139
|
+
const config = {
|
|
3140
|
+
workingDirectory: session.workspace.path,
|
|
3141
|
+
allowedTools,
|
|
3142
|
+
disallowedTools,
|
|
3143
|
+
allowedDirectories,
|
|
3144
|
+
workspaceName: session.issue?.identifier || session.issueId,
|
|
3145
|
+
sylasHome: this.sylasHome,
|
|
3146
|
+
mcpConfigPath,
|
|
3147
|
+
mcpConfig,
|
|
3148
|
+
appendSystemPrompt: systemPrompt || "",
|
|
3149
|
+
// When disallowAllTools is true, remove all built-in tools from model context
|
|
3150
|
+
// so Claude cannot see or attempt tool use (distinct from allowedTools which only controls permissions)
|
|
3151
|
+
...(disallowAllTools && { tools: [] }),
|
|
3152
|
+
// Priority order: label override > repository config > global default
|
|
3153
|
+
model: finalModel,
|
|
3154
|
+
fallbackModel: fallbackModelOverride ||
|
|
3155
|
+
repository.fallbackModel ||
|
|
3156
|
+
this.getDefaultFallbackModelForRunner(runnerType),
|
|
3157
|
+
logger: log,
|
|
3158
|
+
hooks,
|
|
3159
|
+
// Enable Chrome integration for Claude runner (disabled for other runners)
|
|
3160
|
+
...(runnerType === "claude" && { extraArgs: { chrome: null } }),
|
|
3161
|
+
// AskUserQuestion callback - only for Claude runner
|
|
3162
|
+
...(runnerType === "claude" && {
|
|
3163
|
+
onAskUserQuestion: this.createAskUserQuestionCallback(sessionId, repository.linearWorkspaceId),
|
|
3164
|
+
}),
|
|
3165
|
+
onMessage: (message) => {
|
|
3166
|
+
this.handleClaudeMessage(sessionId, message, repository.id);
|
|
3167
|
+
},
|
|
3168
|
+
onError: (error) => this.handleClaudeError(error),
|
|
3169
|
+
};
|
|
3170
|
+
// Cursor runner-specific wiring for offline/headless harness
|
|
3171
|
+
// We pass these as loose fields to avoid widening core runner types.
|
|
3172
|
+
if (runnerType === "cursor") {
|
|
3173
|
+
const approvalPolicy = (process.env.SYLAS_APPROVAL_POLICY || "never");
|
|
3174
|
+
// Cursor CLI binary path (defaults to relying on PATH)
|
|
3175
|
+
config.cursorPath =
|
|
3176
|
+
process.env.CURSOR_AGENT_PATH || process.env.CURSOR_PATH || undefined;
|
|
3177
|
+
// API key for headless auth (optional; CLI may also read CURSOR_API_KEY directly)
|
|
3178
|
+
config.cursorApiKey = process.env.CURSOR_API_KEY || undefined;
|
|
3179
|
+
// Keep headless runs non-interactive by default in F1/CLI environments
|
|
3180
|
+
config.askForApproval = approvalPolicy;
|
|
3181
|
+
config.approveMcps = true;
|
|
3182
|
+
// Default to enabled sandbox for tool execution isolation; set SYLAS_SANDBOX=disabled to disable
|
|
3183
|
+
config.sandbox = (process.env.SYLAS_SANDBOX || "enabled");
|
|
3184
|
+
// Expected cursor-agent version for pre-run validation; mismatch posts error to Linear
|
|
3185
|
+
config.cursorAgentVersion =
|
|
3186
|
+
process.env.SYLAS_CURSOR_AGENT_VERSION || undefined;
|
|
3187
|
+
}
|
|
3188
|
+
// OpenCode runner-specific wiring
|
|
3189
|
+
if (runnerType === "opencode") {
|
|
3190
|
+
config.autoApprove = true;
|
|
3191
|
+
config.opencodeAgent =
|
|
3192
|
+
process.env.SYLAS_OPENCODE_AGENT || undefined;
|
|
3193
|
+
config.opencodeReportedModel =
|
|
3194
|
+
process.env.SYLAS_OPENCODE_REPORTED_MODEL || undefined;
|
|
3195
|
+
config.opencodePlugins = (process.env.SYLAS_OPENCODE_PLUGINS || "")
|
|
3196
|
+
.split(",")
|
|
3197
|
+
.map((value) => value.trim())
|
|
3198
|
+
.filter(Boolean);
|
|
3199
|
+
}
|
|
3200
|
+
if (resumeSessionId) {
|
|
3201
|
+
config.resumeSessionId = resumeSessionId;
|
|
3202
|
+
}
|
|
3203
|
+
if (effectiveMaxTurns !== undefined) {
|
|
3204
|
+
config.maxTurns = effectiveMaxTurns;
|
|
3205
|
+
if (singleTurn) {
|
|
3206
|
+
log.debug(`Applied singleTurn maxTurns=1`);
|
|
3207
|
+
}
|
|
3208
|
+
}
|
|
3209
|
+
return { config, runnerType };
|
|
3210
|
+
}
|
|
3211
|
+
/**
|
|
3212
|
+
* Create an onAskUserQuestion callback for the ClaudeRunner.
|
|
3213
|
+
* This callback delegates to the AskUserQuestionHandler which posts
|
|
3214
|
+
* elicitations to Linear and waits for user responses.
|
|
3215
|
+
*
|
|
3216
|
+
* @param linearAgentSessionId - Linear agent session ID for tracking
|
|
3217
|
+
* @param organizationId - Linear organization/workspace ID
|
|
3218
|
+
*/
|
|
3219
|
+
createAskUserQuestionCallback(linearAgentSessionId, organizationId) {
|
|
3220
|
+
return async (input, _sessionId, signal) => {
|
|
3221
|
+
// Note: We use linearAgentSessionId (from closure) instead of the passed sessionId
|
|
3222
|
+
// because the passed sessionId is the Claude session ID, not the Linear agent session ID
|
|
3223
|
+
return this.askUserQuestionHandler.handleAskUserQuestion(input, linearAgentSessionId, organizationId, signal);
|
|
3224
|
+
};
|
|
3225
|
+
}
|
|
3226
|
+
/**
|
|
3227
|
+
* Build disallowed tools list following the same hierarchy as allowed tools
|
|
3228
|
+
*/
|
|
3229
|
+
buildDisallowedTools(repository, promptType) {
|
|
3230
|
+
return this.runnerSelectionService.buildDisallowedTools(repository, promptType);
|
|
3231
|
+
}
|
|
3232
|
+
/**
|
|
3233
|
+
* Merge subroutine-level disallowedTools with base disallowedTools
|
|
3234
|
+
* @param session Current agent session
|
|
3235
|
+
* @param baseDisallowedTools Base disallowed tools from repository/global config
|
|
3236
|
+
* @param logContext Context string for logging (e.g., "EdgeWorker", "resumeClaudeSession")
|
|
3237
|
+
* @returns Merged disallowed tools list
|
|
3238
|
+
*/
|
|
3239
|
+
mergeSubroutineDisallowedTools(session, baseDisallowedTools, logContext) {
|
|
3240
|
+
return this.runnerSelectionService.mergeSubroutineDisallowedTools(session, baseDisallowedTools, logContext, this.procedureAnalyzer);
|
|
3241
|
+
}
|
|
3242
|
+
/**
|
|
3243
|
+
* Build allowed tools list with Linear MCP tools automatically included
|
|
3244
|
+
*/
|
|
3245
|
+
buildAllowedTools(repository, promptType) {
|
|
3246
|
+
return this.runnerSelectionService.buildAllowedTools(repository, promptType);
|
|
3247
|
+
}
|
|
3248
|
+
/**
|
|
3249
|
+
* Get Agent Sessions for an issue
|
|
3250
|
+
*/
|
|
3251
|
+
getAgentSessionsForIssue(issueId, repositoryId) {
|
|
3252
|
+
const agentSessionManager = this.agentSessionManagers.get(repositoryId);
|
|
3253
|
+
if (!agentSessionManager) {
|
|
3254
|
+
return [];
|
|
3255
|
+
}
|
|
3256
|
+
return agentSessionManager.getSessionsByIssueId(issueId);
|
|
3257
|
+
}
|
|
3258
|
+
// ========================================================================
|
|
3259
|
+
// User Access Control
|
|
3260
|
+
// ========================================================================
|
|
3261
|
+
/**
|
|
3262
|
+
* Check if the user who triggered the webhook is allowed to interact.
|
|
3263
|
+
* @param webhook The webhook containing user information
|
|
3264
|
+
* @param repository The repository configuration
|
|
3265
|
+
* @returns Access check result with allowed status and user name
|
|
3266
|
+
*/
|
|
3267
|
+
checkUserAccess(webhook, repository) {
|
|
3268
|
+
const creator = webhook.agentSession.creator;
|
|
3269
|
+
const userId = creator?.id;
|
|
3270
|
+
const userEmail = creator?.email;
|
|
3271
|
+
const userName = creator?.name || userId || "Unknown";
|
|
3272
|
+
const result = this.userAccessControl.checkAccess(userId, userEmail, repository.id);
|
|
3273
|
+
if (!result.allowed) {
|
|
3274
|
+
return { allowed: false, reason: result.reason, userName };
|
|
3275
|
+
}
|
|
3276
|
+
return { allowed: true };
|
|
3277
|
+
}
|
|
3278
|
+
/**
|
|
3279
|
+
* Handle blocked user according to configured behavior.
|
|
3280
|
+
* Posts a response activity to end the session.
|
|
3281
|
+
* @param webhook The webhook that triggered the blocked access
|
|
3282
|
+
* @param repository The repository configuration
|
|
3283
|
+
* @param _reason The reason for blocking (for logging)
|
|
3284
|
+
*/
|
|
3285
|
+
async handleBlockedUser(webhook, repository, _reason) {
|
|
3286
|
+
const issueTracker = this.issueTrackers.get(repository.id);
|
|
3287
|
+
const agentSessionId = webhook.agentSession.id;
|
|
3288
|
+
const behavior = this.userAccessControl.getBlockBehavior(repository.id);
|
|
3289
|
+
if (!issueTracker) {
|
|
3290
|
+
return;
|
|
3291
|
+
}
|
|
3292
|
+
if (behavior === "comment") {
|
|
3293
|
+
// Get user info for templating
|
|
3294
|
+
const creator = webhook.agentSession.creator;
|
|
3295
|
+
const userName = creator?.name || "User";
|
|
3296
|
+
const userId = creator?.id || "";
|
|
3297
|
+
// Get the message template and replace variables
|
|
3298
|
+
// Supported variables:
|
|
3299
|
+
// - {{userName}} - The user's display name
|
|
3300
|
+
// - {{userId}} - The user's Linear ID
|
|
3301
|
+
let message = this.userAccessControl.getBlockMessage(repository.id);
|
|
3302
|
+
message = message
|
|
3303
|
+
.replace(/\{\{userName\}\}/g, userName)
|
|
3304
|
+
.replace(/\{\{userId\}\}/g, userId);
|
|
3305
|
+
await this.postActivityDirect(issueTracker, {
|
|
3306
|
+
agentSessionId,
|
|
3307
|
+
content: { type: "response", body: message },
|
|
3308
|
+
}, "blocked user message");
|
|
3309
|
+
}
|
|
3310
|
+
// For "silent" behavior, we don't post any activity.
|
|
3311
|
+
// The session will remain in "Working" state until manually stopped or timed out.
|
|
3312
|
+
}
|
|
3313
|
+
/**
|
|
3314
|
+
* Load persisted EdgeWorker state for all repositories
|
|
3315
|
+
*/
|
|
3316
|
+
async loadPersistedState() {
|
|
3317
|
+
try {
|
|
3318
|
+
const state = await this.persistenceManager.loadEdgeWorkerState();
|
|
3319
|
+
if (state) {
|
|
3320
|
+
this.restoreMappings(state);
|
|
3321
|
+
this.logger.debug(`✅ Loaded persisted EdgeWorker state with ${Object.keys(state.agentSessions || {}).length} repositories`);
|
|
3322
|
+
}
|
|
3323
|
+
}
|
|
3324
|
+
catch (error) {
|
|
3325
|
+
this.logger.error(`Failed to load persisted EdgeWorker state:`, error);
|
|
3326
|
+
}
|
|
3327
|
+
}
|
|
3328
|
+
/**
|
|
3329
|
+
* Save current EdgeWorker state for all repositories
|
|
3330
|
+
*/
|
|
3331
|
+
async savePersistedState() {
|
|
3332
|
+
try {
|
|
3333
|
+
const state = this.serializeMappings();
|
|
3334
|
+
await this.persistenceManager.saveEdgeWorkerState(state);
|
|
3335
|
+
this.logger.debug(`✅ Saved EdgeWorker state for ${Object.keys(state.agentSessions || {}).length} repositories`);
|
|
3336
|
+
}
|
|
3337
|
+
catch (error) {
|
|
3338
|
+
this.logger.error(`Failed to save persisted EdgeWorker state:`, error);
|
|
3339
|
+
}
|
|
3340
|
+
}
|
|
3341
|
+
/**
|
|
3342
|
+
* Serialize EdgeWorker mappings to a serializable format
|
|
3343
|
+
*/
|
|
3344
|
+
serializeMappings() {
|
|
3345
|
+
// Serialize Agent Session state for all repositories
|
|
3346
|
+
const agentSessions = {};
|
|
3347
|
+
const agentSessionEntries = {};
|
|
3348
|
+
for (const [repositoryId, agentSessionManager,] of this.agentSessionManagers.entries()) {
|
|
3349
|
+
const serializedState = agentSessionManager.serializeState();
|
|
3350
|
+
agentSessions[repositoryId] = serializedState.sessions;
|
|
3351
|
+
agentSessionEntries[repositoryId] = serializedState.entries;
|
|
3352
|
+
}
|
|
3353
|
+
// Serialize child to parent agent session mapping
|
|
3354
|
+
const childToParentAgentSession = Object.fromEntries(this.childToParentAgentSession.entries());
|
|
3355
|
+
// Serialize issue to repository cache from RepositoryRouter
|
|
3356
|
+
const issueRepositoryCache = Object.fromEntries(this.repositoryRouter.getIssueRepositoryCache().entries());
|
|
3357
|
+
return {
|
|
3358
|
+
agentSessions,
|
|
3359
|
+
agentSessionEntries,
|
|
3360
|
+
childToParentAgentSession,
|
|
3361
|
+
issueRepositoryCache,
|
|
3362
|
+
};
|
|
3363
|
+
}
|
|
3364
|
+
/**
|
|
3365
|
+
* Restore EdgeWorker mappings from serialized state
|
|
3366
|
+
*/
|
|
3367
|
+
restoreMappings(state) {
|
|
3368
|
+
// Restore Agent Session state for all repositories
|
|
3369
|
+
if (state.agentSessions && state.agentSessionEntries) {
|
|
3370
|
+
for (const [repositoryId, agentSessionManager,] of this.agentSessionManagers.entries()) {
|
|
3371
|
+
const repositorySessions = state.agentSessions[repositoryId] || {};
|
|
3372
|
+
const repositoryEntries = state.agentSessionEntries[repositoryId] || {};
|
|
3373
|
+
if (Object.keys(repositorySessions).length > 0 ||
|
|
3374
|
+
Object.keys(repositoryEntries).length > 0) {
|
|
3375
|
+
agentSessionManager.restoreState(repositorySessions, repositoryEntries);
|
|
3376
|
+
this.logger.debug(`Restored Agent Session state for repository ${repositoryId}`);
|
|
3377
|
+
}
|
|
3378
|
+
}
|
|
3379
|
+
}
|
|
3380
|
+
// Restore child to parent agent session mapping
|
|
3381
|
+
if (state.childToParentAgentSession) {
|
|
3382
|
+
this.childToParentAgentSession = new Map(Object.entries(state.childToParentAgentSession));
|
|
3383
|
+
this.logger.debug(`Restored ${this.childToParentAgentSession.size} child-to-parent agent session mappings`);
|
|
3384
|
+
}
|
|
3385
|
+
// Restore issue to repository cache in RepositoryRouter
|
|
3386
|
+
if (state.issueRepositoryCache) {
|
|
3387
|
+
const cache = new Map(Object.entries(state.issueRepositoryCache));
|
|
3388
|
+
this.repositoryRouter.restoreIssueRepositoryCache(cache);
|
|
3389
|
+
this.logger.debug(`Restored ${cache.size} issue-to-repository cache mappings`);
|
|
3390
|
+
}
|
|
3391
|
+
}
|
|
3392
|
+
/**
|
|
3393
|
+
* Post an activity directly via an issue tracker instance.
|
|
3394
|
+
* Consolidates try/catch and success/error logging for EdgeWorker call sites
|
|
3395
|
+
* that already have the issueTracker and agentSessionId resolved.
|
|
3396
|
+
*
|
|
3397
|
+
* @returns The activity ID when resolved, `null` otherwise.
|
|
3398
|
+
*/
|
|
3399
|
+
async postActivityDirect(issueTracker, input, label) {
|
|
3400
|
+
return this.activityPoster.postActivityDirect(issueTracker, input, label);
|
|
3401
|
+
}
|
|
3402
|
+
/**
|
|
3403
|
+
* Post instant acknowledgment thought when agent session is created
|
|
3404
|
+
*/
|
|
3405
|
+
async postInstantAcknowledgment(sessionId, repositoryId) {
|
|
3406
|
+
return this.activityPoster.postInstantAcknowledgment(sessionId, repositoryId);
|
|
3407
|
+
}
|
|
3408
|
+
/**
|
|
3409
|
+
* Post parent resume acknowledgment thought when parent session is resumed from child
|
|
3410
|
+
*/
|
|
3411
|
+
async postParentResumeAcknowledgment(sessionId, repositoryId) {
|
|
3412
|
+
return this.activityPoster.postParentResumeAcknowledgment(sessionId, repositoryId);
|
|
3413
|
+
}
|
|
3414
|
+
/**
|
|
3415
|
+
* Post repository selection activity
|
|
3416
|
+
* Shows which method was used to select the repository (auto-routing or user selection)
|
|
3417
|
+
*/
|
|
3418
|
+
async postRepositorySelectionActivity(sessionId, repositoryId, repositoryName, selectionMethod) {
|
|
3419
|
+
return this.activityPoster.postRepositorySelectionActivity(sessionId, repositoryId, repositoryName, selectionMethod);
|
|
3420
|
+
}
|
|
3421
|
+
/**
|
|
3422
|
+
* Re-route procedure for a session (used when resuming from child or give feedback)
|
|
3423
|
+
* This ensures the currentSubroutine is reset to avoid suppression issues
|
|
3424
|
+
*/
|
|
3425
|
+
async rerouteProcedureForSession(session, sessionId, agentSessionManager, promptBody, repository) {
|
|
3426
|
+
// Initialize procedure metadata using intelligent routing
|
|
3427
|
+
if (!session.metadata) {
|
|
3428
|
+
session.metadata = {};
|
|
3429
|
+
}
|
|
3430
|
+
// Post ephemeral "Routing..." thought
|
|
3431
|
+
await agentSessionManager.postAnalyzingThought(sessionId);
|
|
3432
|
+
// Fetch full issue and labels to check for Orchestrator label override
|
|
3433
|
+
const issueTracker = this.issueTrackers.get(repository.id);
|
|
3434
|
+
let hasOrchestratorLabel = false;
|
|
3435
|
+
// Get issueId from issueContext (preferred) or deprecated issueId field
|
|
3436
|
+
const issueId = session.issueContext?.issueId ?? session.issueId;
|
|
3437
|
+
if (issueTracker && issueId) {
|
|
3438
|
+
try {
|
|
3439
|
+
const fullIssue = await issueTracker.fetchIssue(issueId);
|
|
3440
|
+
const labels = await this.fetchIssueLabels(fullIssue);
|
|
3441
|
+
// ALWAYS check for 'orchestrator' label (case-insensitive) regardless of EdgeConfig
|
|
3442
|
+
// This is a hardcoded rule: any issue with 'orchestrator'/'Orchestrator' label
|
|
3443
|
+
// goes to orchestrator procedure
|
|
3444
|
+
const lowercaseLabels = labels.map((label) => label.toLowerCase());
|
|
3445
|
+
const hasHardcodedOrchestratorLabel = lowercaseLabels.includes("orchestrator");
|
|
3446
|
+
// Also check any additional orchestrator labels from config
|
|
3447
|
+
const orchestratorConfig = repository.labelPrompts?.orchestrator;
|
|
3448
|
+
const orchestratorLabels = Array.isArray(orchestratorConfig)
|
|
3449
|
+
? orchestratorConfig
|
|
3450
|
+
: orchestratorConfig?.labels;
|
|
3451
|
+
const hasConfiguredOrchestratorLabel = orchestratorLabels?.some((label) => lowercaseLabels.includes(label.toLowerCase())) ?? false;
|
|
3452
|
+
hasOrchestratorLabel =
|
|
3453
|
+
hasHardcodedOrchestratorLabel || hasConfiguredOrchestratorLabel;
|
|
3454
|
+
}
|
|
3455
|
+
catch (error) {
|
|
3456
|
+
this.logger.error(`Failed to fetch issue labels for routing:`, error);
|
|
3457
|
+
// Continue with AI routing if label fetch fails
|
|
3458
|
+
}
|
|
3459
|
+
}
|
|
3460
|
+
let selectedProcedure;
|
|
3461
|
+
let finalClassification;
|
|
3462
|
+
// If Orchestrator label is present, ALWAYS use orchestrator-full procedure
|
|
3463
|
+
if (hasOrchestratorLabel) {
|
|
3464
|
+
const orchestratorProcedure = this.procedureAnalyzer.getProcedure("orchestrator-full");
|
|
3465
|
+
if (!orchestratorProcedure) {
|
|
3466
|
+
throw new Error("orchestrator-full procedure not found in registry");
|
|
3467
|
+
}
|
|
3468
|
+
selectedProcedure = orchestratorProcedure;
|
|
3469
|
+
finalClassification = "orchestrator";
|
|
3470
|
+
this.logger.info(`Using orchestrator-full procedure due to Orchestrator label (skipping AI routing)`);
|
|
3471
|
+
}
|
|
3472
|
+
else {
|
|
3473
|
+
// No Orchestrator label - use AI routing based on prompt content
|
|
3474
|
+
const routingDecision = await this.procedureAnalyzer.determineRoutine(promptBody.trim());
|
|
3475
|
+
selectedProcedure = routingDecision.procedure;
|
|
3476
|
+
finalClassification = routingDecision.classification;
|
|
3477
|
+
// Log AI routing decision
|
|
3478
|
+
this.logger.info(`AI routing decision for ${sessionId}:`);
|
|
3479
|
+
this.logger.info(` Classification: ${routingDecision.classification}`);
|
|
3480
|
+
this.logger.info(` Procedure: ${selectedProcedure.name}`);
|
|
3481
|
+
this.logger.info(` Reasoning: ${routingDecision.reasoning}`);
|
|
3482
|
+
}
|
|
3483
|
+
// Initialize procedure metadata in session (resets currentSubroutine)
|
|
3484
|
+
this.procedureAnalyzer.initializeProcedureMetadata(session, selectedProcedure);
|
|
3485
|
+
// Post procedure selection result (replaces ephemeral routing thought)
|
|
3486
|
+
await agentSessionManager.postProcedureSelectionThought(sessionId, selectedProcedure.name, finalClassification);
|
|
3487
|
+
}
|
|
3488
|
+
/**
|
|
3489
|
+
* Handle prompt with streaming check - centralized logic for all input types
|
|
3490
|
+
*
|
|
3491
|
+
* This method implements the unified pattern for handling prompts:
|
|
3492
|
+
* 1. Check if runner is actively streaming
|
|
3493
|
+
* 2. Route procedure if NOT streaming (resets currentSubroutine)
|
|
3494
|
+
* 3. Add to stream if streaming, OR resume session if not
|
|
3495
|
+
*
|
|
3496
|
+
* @param session The Sylas agent session
|
|
3497
|
+
* @param repository Repository configuration
|
|
3498
|
+
* @param sessionId Linear agent activity session ID
|
|
3499
|
+
* @param agentSessionManager Agent session manager instance
|
|
3500
|
+
* @param promptBody The prompt text to send
|
|
3501
|
+
* @param attachmentManifest Optional attachment manifest to append
|
|
3502
|
+
* @param isNewSession Whether this is a new session
|
|
3503
|
+
* @param additionalAllowedDirs Additional directories to allow access to
|
|
3504
|
+
* @param logContext Context string for logging (e.g., "prompted webhook", "parent resume")
|
|
3505
|
+
* @returns true if message was added to stream, false if session was resumed
|
|
3506
|
+
*/
|
|
3507
|
+
async handlePromptWithStreamingCheck(session, repository, sessionId, agentSessionManager, promptBody, attachmentManifest, isNewSession, additionalAllowedDirs, logContext, commentAuthor, commentTimestamp) {
|
|
3508
|
+
const log = this.logger.withContext({ sessionId });
|
|
3509
|
+
// Check if runner is actively running before routing
|
|
3510
|
+
const existingRunner = session.agentRunner;
|
|
3511
|
+
const isRunning = existingRunner?.isRunning() || false;
|
|
3512
|
+
// Always route procedure for new input, UNLESS actively running
|
|
3513
|
+
if (!isRunning) {
|
|
3514
|
+
await this.rerouteProcedureForSession(session, sessionId, agentSessionManager, promptBody, repository);
|
|
3515
|
+
log.debug(`Routed procedure for ${logContext}`);
|
|
3516
|
+
}
|
|
3517
|
+
else {
|
|
3518
|
+
log.debug(`Skipping routing for ${sessionId} (${logContext}) - runner is actively running`);
|
|
3519
|
+
}
|
|
3520
|
+
// Handle running case - add message to existing stream (if supported)
|
|
3521
|
+
if (existingRunner?.isRunning() &&
|
|
3522
|
+
existingRunner.supportsStreamingInput &&
|
|
3523
|
+
existingRunner.addStreamMessage) {
|
|
3524
|
+
log.debug(`Adding prompt to existing stream for ${sessionId} (${logContext})`);
|
|
3525
|
+
// Append attachment manifest to the prompt if we have one
|
|
3526
|
+
let fullPrompt = promptBody;
|
|
3527
|
+
if (attachmentManifest) {
|
|
3528
|
+
fullPrompt = `${promptBody}\n\n${attachmentManifest}`;
|
|
3529
|
+
}
|
|
3530
|
+
existingRunner.addStreamMessage(fullPrompt);
|
|
3531
|
+
return true; // Message added to stream
|
|
3532
|
+
}
|
|
3533
|
+
// Not streaming - resume/start session
|
|
3534
|
+
log.debug(`Resuming Claude session for ${sessionId} (${logContext})`);
|
|
3535
|
+
await this.resumeAgentSession(session, repository, sessionId, agentSessionManager, promptBody, attachmentManifest, isNewSession, additionalAllowedDirs, undefined, // maxTurns
|
|
3536
|
+
commentAuthor, commentTimestamp);
|
|
3537
|
+
return false; // Session was resumed
|
|
3538
|
+
}
|
|
3539
|
+
/**
|
|
3540
|
+
* Post thought about system prompt selection based on labels
|
|
3541
|
+
*/
|
|
3542
|
+
async postSystemPromptSelectionThought(sessionId, labels, repositoryId) {
|
|
3543
|
+
return this.activityPoster.postSystemPromptSelectionThought(sessionId, labels, repositoryId);
|
|
3544
|
+
}
|
|
3545
|
+
/**
|
|
3546
|
+
* Resume or create an Agent session with the given prompt
|
|
3547
|
+
* This is the core logic for handling prompted agent activities
|
|
3548
|
+
* @param session The Sylas agent session
|
|
3549
|
+
* @param repository The repository configuration
|
|
3550
|
+
* @param sessionId The Linear agent session ID
|
|
3551
|
+
* @param agentSessionManager The agent session manager
|
|
3552
|
+
* @param promptBody The prompt text to send
|
|
3553
|
+
* @param attachmentManifest Optional attachment manifest
|
|
3554
|
+
* @param isNewSession Whether this is a new session
|
|
3555
|
+
*/
|
|
3556
|
+
async resumeAgentSession(session, repository, sessionId, agentSessionManager, promptBody, attachmentManifest = "", isNewSession = false, additionalAllowedDirectories = [], maxTurns, commentAuthor, commentTimestamp) {
|
|
3557
|
+
const log = this.logger.withContext({ sessionId });
|
|
3558
|
+
// Check for existing runner
|
|
3559
|
+
const existingRunner = session.agentRunner;
|
|
3560
|
+
// If there's an existing running runner that supports streaming, add to it
|
|
3561
|
+
if (existingRunner?.isRunning() &&
|
|
3562
|
+
existingRunner.supportsStreamingInput &&
|
|
3563
|
+
existingRunner.addStreamMessage) {
|
|
3564
|
+
let fullPrompt = promptBody;
|
|
3565
|
+
if (attachmentManifest) {
|
|
3566
|
+
fullPrompt = `${promptBody}\n\n${attachmentManifest}`;
|
|
3567
|
+
}
|
|
3568
|
+
existingRunner.addStreamMessage(fullPrompt);
|
|
3569
|
+
return;
|
|
3570
|
+
}
|
|
3571
|
+
// Stop existing runner if it's not running
|
|
3572
|
+
if (existingRunner) {
|
|
3573
|
+
existingRunner.stop();
|
|
3574
|
+
}
|
|
3575
|
+
// Get issueId from issueContext (preferred) or deprecated issueId field
|
|
3576
|
+
const issueIdForResume = session.issueContext?.issueId ?? session.issueId;
|
|
3577
|
+
if (!issueIdForResume) {
|
|
3578
|
+
log.error(`No issue ID found for session ${session.id}`);
|
|
3579
|
+
throw new Error(`No issue ID found for session ${session.id}`);
|
|
3580
|
+
}
|
|
3581
|
+
// Fetch full issue details
|
|
3582
|
+
const fullIssue = await this.fetchFullIssueDetails(issueIdForResume, repository.id);
|
|
3583
|
+
if (!fullIssue) {
|
|
3584
|
+
log.error(`Failed to fetch full issue details for ${issueIdForResume}`);
|
|
3585
|
+
throw new Error(`Failed to fetch full issue details for ${issueIdForResume}`);
|
|
3586
|
+
}
|
|
3587
|
+
// Fetch issue labels early to determine runner type
|
|
3588
|
+
const labels = await this.fetchIssueLabels(fullIssue);
|
|
3589
|
+
// Determine which runner to use based on existing session IDs
|
|
3590
|
+
const hasClaudeSession = !isNewSession && Boolean(session.claudeSessionId);
|
|
3591
|
+
const hasGeminiSession = !isNewSession && Boolean(session.geminiSessionId);
|
|
3592
|
+
const hasCodexSession = !isNewSession && Boolean(session.codexSessionId);
|
|
3593
|
+
const hasCursorSession = !isNewSession && Boolean(session.cursorSessionId);
|
|
3594
|
+
const needsNewSession = isNewSession ||
|
|
3595
|
+
(!hasClaudeSession &&
|
|
3596
|
+
!hasGeminiSession &&
|
|
3597
|
+
!hasCodexSession &&
|
|
3598
|
+
!hasCursorSession);
|
|
3599
|
+
// Fetch system prompt based on labels
|
|
3600
|
+
const systemPromptResult = await this.determineSystemPromptFromLabels(labels, repository);
|
|
3601
|
+
const systemPrompt = systemPromptResult?.prompt;
|
|
3602
|
+
const promptType = systemPromptResult?.type;
|
|
3603
|
+
// Get current subroutine to check for singleTurn mode and disallowAllTools
|
|
3604
|
+
const currentSubroutine = this.procedureAnalyzer.getCurrentSubroutine(session);
|
|
3605
|
+
// Build allowed tools list
|
|
3606
|
+
// If subroutine has disallowAllTools: true, use empty array to disable all tools
|
|
3607
|
+
const allowedTools = currentSubroutine?.disallowAllTools
|
|
3608
|
+
? []
|
|
3609
|
+
: this.buildAllowedTools(repository, promptType);
|
|
3610
|
+
const baseDisallowedTools = this.buildDisallowedTools(repository, promptType);
|
|
3611
|
+
// Merge subroutine-level disallowedTools if applicable
|
|
3612
|
+
const disallowedTools = this.mergeSubroutineDisallowedTools(session, baseDisallowedTools, "resumeClaudeSession");
|
|
3613
|
+
if (currentSubroutine?.disallowAllTools) {
|
|
3614
|
+
log.debug(`All tools disabled for subroutine: ${currentSubroutine.name}`);
|
|
3615
|
+
}
|
|
3616
|
+
// Set up attachments directory
|
|
3617
|
+
const workspaceFolderName = basename(session.workspace.path);
|
|
3618
|
+
const attachmentsDir = join(this.sylasHome, workspaceFolderName, "attachments");
|
|
3619
|
+
await mkdir(attachmentsDir, { recursive: true });
|
|
3620
|
+
const allowedDirectories = [
|
|
3621
|
+
...new Set([
|
|
3622
|
+
attachmentsDir,
|
|
3623
|
+
repository.repositoryPath,
|
|
3624
|
+
...additionalAllowedDirectories,
|
|
3625
|
+
...this.gitService.getGitMetadataDirectories(session.workspace.path),
|
|
3626
|
+
]),
|
|
3627
|
+
];
|
|
3628
|
+
const resumeSessionId = needsNewSession
|
|
3629
|
+
? undefined
|
|
3630
|
+
: session.claudeSessionId
|
|
3631
|
+
? session.claudeSessionId
|
|
3632
|
+
: session.geminiSessionId
|
|
3633
|
+
? session.geminiSessionId
|
|
3634
|
+
: session.codexSessionId
|
|
3635
|
+
? session.codexSessionId
|
|
3636
|
+
: session.cursorSessionId
|
|
3637
|
+
? session.cursorSessionId
|
|
3638
|
+
: session.openCodeSessionId;
|
|
3639
|
+
console.log(`[resumeAgentSession] needsNewSession=${needsNewSession}, resumeSessionId=${resumeSessionId ?? "none"}`);
|
|
3640
|
+
// Create runner configuration
|
|
3641
|
+
// buildAgentRunnerConfig determines runner type from labels for new sessions
|
|
3642
|
+
// For existing sessions, we still need labels for model override but ignore runner type
|
|
3643
|
+
const { config: runnerConfig, runnerType } = this.buildAgentRunnerConfig(session, repository, sessionId, systemPrompt, allowedTools, allowedDirectories, disallowedTools, resumeSessionId, labels, // Always pass labels to preserve model override
|
|
3644
|
+
fullIssue.description || undefined, // Description tags can override label selectors
|
|
3645
|
+
maxTurns, // Pass maxTurns if specified
|
|
3646
|
+
currentSubroutine?.singleTurn, // singleTurn flag
|
|
3647
|
+
currentSubroutine?.disallowAllTools);
|
|
3648
|
+
// Create the appropriate runner based on session state
|
|
3649
|
+
const runner = runnerType === "opencode"
|
|
3650
|
+
? new OpenCodeRunner(runnerConfig)
|
|
3651
|
+
: runnerType === "claude"
|
|
3652
|
+
? new ClaudeRunner(runnerConfig)
|
|
3653
|
+
: runnerType === "gemini"
|
|
3654
|
+
? new GeminiRunner(runnerConfig)
|
|
3655
|
+
: runnerType === "codex"
|
|
3656
|
+
? new CodexRunner(runnerConfig)
|
|
3657
|
+
: new CursorRunner(runnerConfig);
|
|
3658
|
+
// Store runner
|
|
3659
|
+
agentSessionManager.addAgentRunner(sessionId, runner);
|
|
3660
|
+
// Save state
|
|
3661
|
+
await this.savePersistedState();
|
|
3662
|
+
// Prepare the full prompt
|
|
3663
|
+
const fullPrompt = await this.buildSessionPrompt(isNewSession, session, fullIssue, repository, promptBody, attachmentManifest, commentAuthor, commentTimestamp);
|
|
3664
|
+
// Start session - use streaming mode if supported for ability to add messages later
|
|
3665
|
+
try {
|
|
3666
|
+
if (runner.supportsStreamingInput && runner.startStreaming) {
|
|
3667
|
+
await runner.startStreaming(fullPrompt);
|
|
3668
|
+
}
|
|
3669
|
+
else {
|
|
3670
|
+
await runner.start(fullPrompt);
|
|
3671
|
+
}
|
|
3672
|
+
}
|
|
3673
|
+
catch (error) {
|
|
3674
|
+
log.error(`Failed to start streaming session for ${sessionId}:`, error);
|
|
3675
|
+
throw error;
|
|
3676
|
+
}
|
|
3677
|
+
}
|
|
3678
|
+
/**
|
|
3679
|
+
* Post instant acknowledgment thought when receiving prompted webhook
|
|
3680
|
+
*/
|
|
3681
|
+
async postInstantPromptedAcknowledgment(sessionId, repositoryId, isStreaming) {
|
|
3682
|
+
return this.activityPoster.postInstantPromptedAcknowledgment(sessionId, repositoryId, isStreaming);
|
|
3683
|
+
}
|
|
3684
|
+
/**
|
|
3685
|
+
* Get the platform type for a repository's issue tracker.
|
|
3686
|
+
*/
|
|
3687
|
+
getRepositoryPlatform(repositoryId) {
|
|
3688
|
+
try {
|
|
3689
|
+
return this.issueTrackers.get(repositoryId)?.getPlatformType();
|
|
3690
|
+
}
|
|
3691
|
+
catch {
|
|
3692
|
+
return undefined;
|
|
3693
|
+
}
|
|
3694
|
+
}
|
|
3695
|
+
/**
|
|
3696
|
+
* Fetch complete issue details from Linear API
|
|
3697
|
+
*/
|
|
3698
|
+
async fetchFullIssueDetails(issueId, repositoryId) {
|
|
3699
|
+
const issueTracker = this.issueTrackers.get(repositoryId);
|
|
3700
|
+
if (!issueTracker) {
|
|
3701
|
+
this.logger.warn(`No issue tracker found for repository ${repositoryId}`);
|
|
3702
|
+
return null;
|
|
3703
|
+
}
|
|
3704
|
+
try {
|
|
3705
|
+
this.logger.debug(`Fetching full issue details for ${issueId}`);
|
|
3706
|
+
const fullIssue = await issueTracker.fetchIssue(issueId);
|
|
3707
|
+
this.logger.debug(`Successfully fetched issue details for ${issueId}`);
|
|
3708
|
+
// Check if issue has a parent
|
|
3709
|
+
try {
|
|
3710
|
+
const parent = await fullIssue.parent;
|
|
3711
|
+
if (parent) {
|
|
3712
|
+
this.logger.debug(`Issue ${issueId} has parent: ${parent.identifier}`);
|
|
3713
|
+
}
|
|
3714
|
+
}
|
|
3715
|
+
catch (_error) {
|
|
3716
|
+
// Parent field might not exist, ignore error
|
|
3717
|
+
}
|
|
3718
|
+
return fullIssue;
|
|
3719
|
+
}
|
|
3720
|
+
catch (error) {
|
|
3721
|
+
this.logger.error(`Failed to fetch issue details for ${issueId}:`, error);
|
|
3722
|
+
return null;
|
|
3723
|
+
}
|
|
3724
|
+
}
|
|
3725
|
+
// ========================================================================
|
|
3726
|
+
// OAuth Token Refresh
|
|
3727
|
+
// ========================================================================
|
|
3728
|
+
/**
|
|
3729
|
+
* Build OAuth config for LinearIssueTrackerService.
|
|
3730
|
+
* Returns undefined if OAuth credentials are not available.
|
|
3731
|
+
*/
|
|
3732
|
+
buildOAuthConfig(repo) {
|
|
3733
|
+
const clientId = process.env.LINEAR_CLIENT_ID;
|
|
3734
|
+
const clientSecret = process.env.LINEAR_CLIENT_SECRET;
|
|
3735
|
+
if (!clientId || !clientSecret) {
|
|
3736
|
+
this.logger.warn("LINEAR_CLIENT_ID and LINEAR_CLIENT_SECRET not set, token refresh disabled");
|
|
3737
|
+
return undefined;
|
|
3738
|
+
}
|
|
3739
|
+
if (!repo.linearRefreshToken) {
|
|
3740
|
+
this.logger.warn(`No refresh token for repository ${repo.id}, token refresh disabled`);
|
|
3741
|
+
return undefined;
|
|
3742
|
+
}
|
|
3743
|
+
const workspaceId = repo.linearWorkspaceId;
|
|
3744
|
+
const workspaceName = repo.linearWorkspaceName || workspaceId;
|
|
3745
|
+
return {
|
|
3746
|
+
clientId,
|
|
3747
|
+
clientSecret,
|
|
3748
|
+
refreshToken: repo.linearRefreshToken,
|
|
3749
|
+
workspaceId,
|
|
3750
|
+
onTokenRefresh: async (tokens) => {
|
|
3751
|
+
// Update repository config state (for EdgeWorker's internal tracking)
|
|
3752
|
+
for (const [, repository] of this.repositories) {
|
|
3753
|
+
if (repository.linearWorkspaceId === workspaceId) {
|
|
3754
|
+
repository.linearToken = tokens.accessToken;
|
|
3755
|
+
repository.linearRefreshToken = tokens.refreshToken;
|
|
3756
|
+
}
|
|
3757
|
+
}
|
|
3758
|
+
// Persist tokens to config.json
|
|
3759
|
+
await this.saveOAuthTokens({
|
|
3760
|
+
linearToken: tokens.accessToken,
|
|
3761
|
+
linearRefreshToken: tokens.refreshToken,
|
|
3762
|
+
linearWorkspaceId: workspaceId,
|
|
3763
|
+
linearWorkspaceName: workspaceName,
|
|
3764
|
+
});
|
|
3765
|
+
},
|
|
3766
|
+
};
|
|
3767
|
+
}
|
|
3768
|
+
/**
|
|
3769
|
+
* Save OAuth tokens to config.json
|
|
3770
|
+
*/
|
|
3771
|
+
async saveOAuthTokens(tokens) {
|
|
3772
|
+
if (!this.configPath) {
|
|
3773
|
+
this.logger.warn("No config path set, cannot save OAuth tokens");
|
|
3774
|
+
return;
|
|
3775
|
+
}
|
|
3776
|
+
try {
|
|
3777
|
+
const configContent = await readFile(this.configPath, "utf-8");
|
|
3778
|
+
const config = JSON.parse(configContent);
|
|
3779
|
+
// Find and update all repositories with this workspace ID
|
|
3780
|
+
if (config.repositories && Array.isArray(config.repositories)) {
|
|
3781
|
+
for (const repo of config.repositories) {
|
|
3782
|
+
if (repo.linearWorkspaceId === tokens.linearWorkspaceId) {
|
|
3783
|
+
repo.linearToken = tokens.linearToken;
|
|
3784
|
+
if (tokens.linearRefreshToken) {
|
|
3785
|
+
repo.linearRefreshToken = tokens.linearRefreshToken;
|
|
3786
|
+
}
|
|
3787
|
+
if (tokens.linearWorkspaceName) {
|
|
3788
|
+
repo.linearWorkspaceName = tokens.linearWorkspaceName;
|
|
3789
|
+
}
|
|
3790
|
+
}
|
|
3791
|
+
}
|
|
3792
|
+
}
|
|
3793
|
+
await writeFile(this.configPath, JSON.stringify(config, null, "\t"));
|
|
3794
|
+
this.logger.debug(`OAuth tokens saved to config for workspace ${tokens.linearWorkspaceId}`);
|
|
3795
|
+
}
|
|
3796
|
+
catch (error) {
|
|
3797
|
+
this.logger.error("Failed to save OAuth tokens:", error);
|
|
3798
|
+
}
|
|
3799
|
+
}
|
|
3800
|
+
}
|
|
3801
|
+
//# sourceMappingURL=EdgeWorker.js.map
|