mstro-app 0.4.33 → 0.4.35
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/server/cli/headless/claude-invoker-stream.d.ts.map +1 -1
- package/dist/server/cli/headless/claude-invoker-stream.js +63 -0
- package/dist/server/cli/headless/claude-invoker-stream.js.map +1 -1
- package/dist/server/cli/headless/haiku-assessments.d.ts.map +1 -1
- package/dist/server/cli/headless/haiku-assessments.js +10 -5
- package/dist/server/cli/headless/haiku-assessments.js.map +1 -1
- package/dist/server/cli/improvisation-retry.d.ts.map +1 -1
- package/dist/server/cli/improvisation-retry.js +17 -2
- package/dist/server/cli/improvisation-retry.js.map +1 -1
- package/dist/server/cli/improvisation-session-manager.d.ts +1 -0
- package/dist/server/cli/improvisation-session-manager.d.ts.map +1 -1
- package/dist/server/cli/improvisation-session-manager.js +13 -5
- package/dist/server/cli/improvisation-session-manager.js.map +1 -1
- package/dist/server/cli/improvisation-types.d.ts +1 -0
- package/dist/server/cli/improvisation-types.d.ts.map +1 -1
- package/dist/server/cli/improvisation-types.js.map +1 -1
- package/dist/server/services/plan/composer.d.ts +1 -1
- package/dist/server/services/plan/composer.d.ts.map +1 -1
- package/dist/server/services/plan/composer.js +2 -2
- package/dist/server/services/plan/composer.js.map +1 -1
- package/dist/server/services/plan/executor.d.ts +3 -1
- package/dist/server/services/plan/executor.d.ts.map +1 -1
- package/dist/server/services/plan/executor.js +8 -3
- package/dist/server/services/plan/executor.js.map +1 -1
- package/dist/server/services/plan/parser-core.d.ts.map +1 -1
- package/dist/server/services/plan/parser-core.js +15 -0
- package/dist/server/services/plan/parser-core.js.map +1 -1
- package/dist/server/services/plan/types.d.ts +5 -0
- package/dist/server/services/plan/types.d.ts.map +1 -1
- package/dist/server/services/websocket/git-head-watcher.d.ts +25 -0
- package/dist/server/services/websocket/git-head-watcher.d.ts.map +1 -0
- package/dist/server/services/websocket/git-head-watcher.js +136 -0
- package/dist/server/services/websocket/git-head-watcher.js.map +1 -0
- package/dist/server/services/websocket/git-worktree-handlers.d.ts.map +1 -1
- package/dist/server/services/websocket/git-worktree-handlers.js +84 -13
- package/dist/server/services/websocket/git-worktree-handlers.js.map +1 -1
- package/dist/server/services/websocket/handler-context.d.ts +2 -0
- package/dist/server/services/websocket/handler-context.d.ts.map +1 -1
- package/dist/server/services/websocket/handler.d.ts +3 -1
- package/dist/server/services/websocket/handler.d.ts.map +1 -1
- package/dist/server/services/websocket/handler.js +18 -6
- package/dist/server/services/websocket/handler.js.map +1 -1
- package/dist/server/services/websocket/plan-board-handlers.d.ts +1 -0
- package/dist/server/services/websocket/plan-board-handlers.d.ts.map +1 -1
- package/dist/server/services/websocket/plan-board-handlers.js +94 -0
- package/dist/server/services/websocket/plan-board-handlers.js.map +1 -1
- package/dist/server/services/websocket/plan-execution-handlers.d.ts.map +1 -1
- package/dist/server/services/websocket/plan-execution-handlers.js +4 -2
- package/dist/server/services/websocket/plan-execution-handlers.js.map +1 -1
- package/dist/server/services/websocket/plan-handlers.d.ts.map +1 -1
- package/dist/server/services/websocket/plan-handlers.js +3 -1
- package/dist/server/services/websocket/plan-handlers.js.map +1 -1
- package/dist/server/services/websocket/plan-issue-handlers.d.ts.map +1 -1
- package/dist/server/services/websocket/plan-issue-handlers.js +9 -0
- package/dist/server/services/websocket/plan-issue-handlers.js.map +1 -1
- package/dist/server/services/websocket/quality-persistence.d.ts +7 -0
- package/dist/server/services/websocket/quality-persistence.d.ts.map +1 -1
- package/dist/server/services/websocket/quality-persistence.js +15 -7
- package/dist/server/services/websocket/quality-persistence.js.map +1 -1
- package/dist/server/services/websocket/quality-review-agent.d.ts.map +1 -1
- package/dist/server/services/websocket/quality-review-agent.js +2 -13
- package/dist/server/services/websocket/quality-review-agent.js.map +1 -1
- package/dist/server/services/websocket/quality-service.d.ts +12 -3
- package/dist/server/services/websocket/quality-service.d.ts.map +1 -1
- package/dist/server/services/websocket/quality-service.js +101 -81
- package/dist/server/services/websocket/quality-service.js.map +1 -1
- package/dist/server/services/websocket/quality-tools.d.ts.map +1 -1
- package/dist/server/services/websocket/quality-tools.js +6 -1
- package/dist/server/services/websocket/quality-tools.js.map +1 -1
- package/dist/server/services/websocket/quality-types.d.ts +15 -2
- package/dist/server/services/websocket/quality-types.d.ts.map +1 -1
- package/dist/server/services/websocket/quality-types.js.map +1 -1
- package/dist/server/services/websocket/session-handlers.js +2 -2
- package/dist/server/services/websocket/session-handlers.js.map +1 -1
- package/dist/server/services/websocket/tab-handlers.d.ts.map +1 -1
- package/dist/server/services/websocket/tab-handlers.js +9 -2
- package/dist/server/services/websocket/tab-handlers.js.map +1 -1
- package/dist/server/services/websocket/types.d.ts +2 -2
- package/dist/server/services/websocket/types.d.ts.map +1 -1
- package/package.json +2 -1
- package/server/cli/headless/claude-invoker-stream.ts +63 -0
- package/server/cli/headless/haiku-assessments.ts +10 -5
- package/server/cli/improvisation-retry.ts +18 -2
- package/server/cli/improvisation-session-manager.ts +13 -5
- package/server/cli/improvisation-types.ts +1 -0
- package/server/services/plan/agents/assess-stall.md +21 -0
- package/server/services/plan/agents/check-injection.md +36 -0
- package/server/services/plan/agents/classify-error.md +29 -0
- package/server/services/plan/agents/detect-context-loss.md +29 -0
- package/server/services/plan/agents/execute-issue.md +42 -0
- package/server/services/plan/agents/plan-coordinator.md +71 -0
- package/server/services/plan/agents/retry-task.md +26 -0
- package/server/services/plan/agents/review-code.md +4 -1
- package/server/services/plan/agents/review-criteria.md +53 -0
- package/server/services/plan/agents/review-custom.md +4 -1
- package/server/services/plan/agents/review-quality.md +4 -1
- package/server/services/plan/agents/verify-review.md +56 -0
- package/server/services/plan/composer.ts +2 -1
- package/server/services/plan/executor.ts +8 -3
- package/server/services/plan/parser-core.ts +14 -0
- package/server/services/plan/types.ts +6 -0
- package/server/services/websocket/git-head-watcher.ts +120 -0
- package/server/services/websocket/git-worktree-handlers.ts +85 -15
- package/server/services/websocket/handler-context.ts +2 -0
- package/server/services/websocket/handler.ts +19 -6
- package/server/services/websocket/plan-board-handlers.ts +116 -0
- package/server/services/websocket/plan-execution-handlers.ts +4 -2
- package/server/services/websocket/plan-handlers.ts +3 -1
- package/server/services/websocket/plan-issue-handlers.ts +10 -0
- package/server/services/websocket/quality-persistence.ts +23 -7
- package/server/services/websocket/quality-review-agent.ts +2 -12
- package/server/services/websocket/quality-service.ts +116 -99
- package/server/services/websocket/quality-tools.ts +6 -1
- package/server/services/websocket/quality-types.ts +17 -2
- package/server/services/websocket/session-handlers.ts +2 -2
- package/server/services/websocket/tab-handlers.ts +8 -2
- package/server/services/websocket/types.ts +7 -2
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
---
|
|
2
|
+
name: review-criteria
|
|
3
|
+
description: "Help write effective custom review criteria for PM board issue reviews. Use when configuring what the AI reviewer should check for on completed work."
|
|
4
|
+
user-invocable: false
|
|
5
|
+
disable-model-invocation: true
|
|
6
|
+
---
|
|
7
|
+
|
|
8
|
+
You are helping the user write effective review criteria for their PM board. Review criteria tell the AI reviewer what to check when evaluating completed work.
|
|
9
|
+
|
|
10
|
+
## What Are Review Criteria?
|
|
11
|
+
|
|
12
|
+
Review criteria are custom instructions that the AI reviewer follows when checking completed issues. They supplement the issue's acceptance criteria with board-level quality standards.
|
|
13
|
+
|
|
14
|
+
## How to Write Good Criteria
|
|
15
|
+
|
|
16
|
+
Good criteria are:
|
|
17
|
+
- **Specific**: "Verify all API endpoints return proper error codes (4xx/5xx)" not "Check for errors"
|
|
18
|
+
- **Observable**: Things the reviewer can verify by reading code/output
|
|
19
|
+
- **Relevant**: Match the type of work on the board (code, writing, research, design)
|
|
20
|
+
|
|
21
|
+
## Examples by Task Type
|
|
22
|
+
|
|
23
|
+
### Code Tasks
|
|
24
|
+
- Verify all new functions have TypeScript types (no `any`)
|
|
25
|
+
- Ensure error handling exists for all async operations
|
|
26
|
+
- Check that no hardcoded credentials or secrets are present
|
|
27
|
+
- Verify tests exist for new functionality
|
|
28
|
+
- Ensure all endpoints have input validation
|
|
29
|
+
|
|
30
|
+
### Writing/Content Tasks
|
|
31
|
+
- Verify the document follows the company style guide
|
|
32
|
+
- Check that all claims have citations or evidence
|
|
33
|
+
- Ensure the tone matches the target audience
|
|
34
|
+
- Verify all sections from the outline are addressed
|
|
35
|
+
|
|
36
|
+
### Design Tasks
|
|
37
|
+
- Verify designs match the Figma source files
|
|
38
|
+
- Check responsive behavior is documented for mobile/tablet/desktop
|
|
39
|
+
- Ensure accessibility requirements (contrast ratios, ARIA labels) are noted
|
|
40
|
+
|
|
41
|
+
### Research Tasks
|
|
42
|
+
- Verify at least 3 sources are cited for each major finding
|
|
43
|
+
- Check that methodology is documented
|
|
44
|
+
- Ensure conclusions follow logically from the evidence
|
|
45
|
+
|
|
46
|
+
## Your Task
|
|
47
|
+
|
|
48
|
+
Help the user craft review criteria for their board. Ask them:
|
|
49
|
+
1. What type of work does this board contain? (code, writing, research, design, mixed)
|
|
50
|
+
2. What quality standards matter most?
|
|
51
|
+
3. Are there specific patterns or anti-patterns to watch for?
|
|
52
|
+
|
|
53
|
+
Then generate 3-7 clear, actionable review criteria they can paste into their board's review criteria field.
|
|
@@ -1,7 +1,10 @@
|
|
|
1
1
|
---
|
|
2
2
|
name: review-custom
|
|
3
|
-
description: Reviews work using board-defined custom criteria alongside acceptance criteria — works for code, content, research, planning, and any other task type
|
|
3
|
+
description: "Reviews work using board-defined custom criteria alongside acceptance criteria — works for code, content, research, planning, and any other task type. Use when a PM board has custom review criteria configured."
|
|
4
|
+
user-invocable: false
|
|
4
5
|
type: review
|
|
6
|
+
allowed-tools: Read, Grep, Glob, Bash
|
|
7
|
+
context: fork
|
|
5
8
|
variables: [issue_id, issue_title, context_section, acceptance_criteria, review_criteria, read_instruction]
|
|
6
9
|
checks: [criteria_met, review_criteria]
|
|
7
10
|
---
|
|
@@ -1,7 +1,10 @@
|
|
|
1
1
|
---
|
|
2
2
|
name: review-quality
|
|
3
|
-
description: Reviews non-code output (writing, research, plans, designs, analysis) for completeness, accuracy, and quality against acceptance criteria
|
|
3
|
+
description: "Reviews non-code output (writing, research, plans, designs, analysis) for completeness, accuracy, and quality against acceptance criteria. Use when reviewing completed PM board issues that produce documents or deliverables."
|
|
4
|
+
user-invocable: false
|
|
4
5
|
type: review
|
|
6
|
+
allowed-tools: Read, Grep, Glob, Bash
|
|
7
|
+
context: fork
|
|
5
8
|
variables: [issue_id, issue_title, output_path, issue_spec_path, acceptance_criteria]
|
|
6
9
|
checks: [criteria_met, output_quality, completeness]
|
|
7
10
|
---
|
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
---
|
|
2
|
+
name: verify-review
|
|
3
|
+
description: "Independent verification pass for code review findings — skeptically re-checks each finding against actual code to catch hallucinations and false positives. Use after an AI code review to validate findings."
|
|
4
|
+
user-invocable: false
|
|
5
|
+
allowed-tools: Read, Grep, Glob, Bash
|
|
6
|
+
context: fork
|
|
7
|
+
---
|
|
8
|
+
|
|
9
|
+
You are an independent code review VERIFIER. A separate reviewer produced the findings below. Your job is to VERIFY each finding against the actual code. You are a skeptic — do NOT trust the original reviewer's claims.
|
|
10
|
+
|
|
11
|
+
IMPORTANT: Your current working directory is "{{dirPath}}". Only read files within this directory.
|
|
12
|
+
|
|
13
|
+
## Findings to Verify
|
|
14
|
+
|
|
15
|
+
{{findingsJson}}
|
|
16
|
+
|
|
17
|
+
## Verification Process
|
|
18
|
+
|
|
19
|
+
For EACH finding:
|
|
20
|
+
|
|
21
|
+
1. **Read the cited file and line** using the Read tool. Read at least 20 lines around the cited line for context.
|
|
22
|
+
2. **Check the specific claim** in the description. Does the code actually do what the finding claims?
|
|
23
|
+
3. **Search for counter-evidence**:
|
|
24
|
+
- If the finding claims something is missing (no validation, no cleanup, no guard): search for it with Grep
|
|
25
|
+
- If the finding claims an API is used: verify the actual API call at that line
|
|
26
|
+
- If the finding claims a value is leaked/exposed: check if it's filtered/deleted elsewhere in the same function
|
|
27
|
+
4. **Verdict**: Mark as "confirmed" or "rejected" with a brief explanation
|
|
28
|
+
|
|
29
|
+
## Rules
|
|
30
|
+
|
|
31
|
+
- You MUST actually Read each cited file. Do not rely on memory or assumptions.
|
|
32
|
+
- Use Grep to search for patterns the finding claims exist (or don't exist).
|
|
33
|
+
- A finding is "rejected" if:
|
|
34
|
+
- The code does NOT match what the description claims
|
|
35
|
+
- There IS a guard/fix that the finding claims is missing
|
|
36
|
+
- The line number doesn't contain the relevant code
|
|
37
|
+
- The finding is about a different version of the code than what exists now
|
|
38
|
+
- A finding is "confirmed" if you can independently verify the issue exists in the current code.
|
|
39
|
+
- Be thorough but efficient — focus verification effort on high/critical severity findings.
|
|
40
|
+
|
|
41
|
+
## Output
|
|
42
|
+
|
|
43
|
+
Output EXACTLY one JSON code block. No other text after the JSON block.
|
|
44
|
+
|
|
45
|
+
```json
|
|
46
|
+
{
|
|
47
|
+
"verifications": [
|
|
48
|
+
{
|
|
49
|
+
"id": 1,
|
|
50
|
+
"verdict": "confirmed|rejected",
|
|
51
|
+
"confidence": 0.95,
|
|
52
|
+
"note": "Brief explanation of what you found when checking the code"
|
|
53
|
+
}
|
|
54
|
+
]
|
|
55
|
+
}
|
|
56
|
+
```
|
|
@@ -129,6 +129,7 @@ export async function handlePlanPrompt(
|
|
|
129
129
|
userPrompt: string,
|
|
130
130
|
workingDir: string,
|
|
131
131
|
boardId?: string,
|
|
132
|
+
executionDir?: string,
|
|
132
133
|
): Promise<void> {
|
|
133
134
|
const pmDir = resolvePmDir(workingDir) ?? defaultPmDir(workingDir);
|
|
134
135
|
const projectContent = readFileOrEmpty(join(pmDir, 'project.md'));
|
|
@@ -246,7 +247,7 @@ User request: ${userPrompt}`;
|
|
|
246
247
|
});
|
|
247
248
|
|
|
248
249
|
const runner = new HeadlessRunner({
|
|
249
|
-
workingDir,
|
|
250
|
+
workingDir: executionDir || workingDir,
|
|
250
251
|
directPrompt: enrichedPrompt,
|
|
251
252
|
stallWarningMs: 300_000, // 5 min — compose usually finishes quickly
|
|
252
253
|
stallKillMs: 900_000, // 15 min
|
|
@@ -73,6 +73,8 @@ export class PlanExecutor extends EventEmitter {
|
|
|
73
73
|
private _scopeSetByCall = false;
|
|
74
74
|
/** Extra environment variables forwarded to HeadlessRunner child processes (e.g. API keys) */
|
|
75
75
|
private extraEnv?: Record<string, string>;
|
|
76
|
+
/** Optional worktree directory for running AI agents. PM data is always read from workingDir. */
|
|
77
|
+
private executionDir: string | null = null;
|
|
76
78
|
private metrics: ExecutionMetrics = {
|
|
77
79
|
issuesCompleted: 0,
|
|
78
80
|
issuesAttempted: 0,
|
|
@@ -98,8 +100,9 @@ export class PlanExecutor extends EventEmitter {
|
|
|
98
100
|
}
|
|
99
101
|
|
|
100
102
|
/** Start execution, optionally scoped to a specific board. */
|
|
101
|
-
async startBoard(boardId: string): Promise<void> {
|
|
103
|
+
async startBoard(boardId: string, executionDir?: string): Promise<void> {
|
|
102
104
|
this.boardId = boardId;
|
|
105
|
+
this.executionDir = executionDir ?? null;
|
|
103
106
|
this._scopeSetByCall = true;
|
|
104
107
|
return this.start();
|
|
105
108
|
}
|
|
@@ -113,6 +116,7 @@ export class PlanExecutor extends EventEmitter {
|
|
|
113
116
|
if (!this._scopeSetByCall) {
|
|
114
117
|
this.epicScope = null;
|
|
115
118
|
this.boardId = null;
|
|
119
|
+
this.executionDir = null;
|
|
116
120
|
}
|
|
117
121
|
this._scopeSetByCall = false;
|
|
118
122
|
this.status = 'starting';
|
|
@@ -247,10 +251,11 @@ export class PlanExecutor extends EventEmitter {
|
|
|
247
251
|
waveLabel: string,
|
|
248
252
|
abortSignal?: AbortSignal,
|
|
249
253
|
): Promise<void> {
|
|
254
|
+
const effectiveDir = this.executionDir || this.workingDir;
|
|
250
255
|
const outputPath = resolveOutputPath(issue, this.workingDir, this.boardDir);
|
|
251
256
|
const prompt = buildIssuePrompt({
|
|
252
257
|
issue,
|
|
253
|
-
workingDir:
|
|
258
|
+
workingDir: effectiveDir,
|
|
254
259
|
pmDir,
|
|
255
260
|
boardDir: this.boardDir,
|
|
256
261
|
existingDocs,
|
|
@@ -259,7 +264,7 @@ export class PlanExecutor extends EventEmitter {
|
|
|
259
264
|
|
|
260
265
|
const boardLogDir = this.boardDir ? join(this.boardDir, 'logs') : undefined;
|
|
261
266
|
const result = await runWithFileLogger(`pm-issue-${issue.id}`, () => runIssueWithRetry({
|
|
262
|
-
workingDir:
|
|
267
|
+
workingDir: effectiveDir,
|
|
263
268
|
prompt,
|
|
264
269
|
stallWarningMs: ISSUE_STALL_WARNING_MS,
|
|
265
270
|
stallKillMs: ISSUE_STALL_KILL_MS,
|
|
@@ -402,12 +402,26 @@ export function parseBoard(content: string, filePath: string): Board {
|
|
|
402
402
|
};
|
|
403
403
|
}
|
|
404
404
|
|
|
405
|
+
function parseWorktreeEntry(v: unknown): { path: string; branch: string } | null {
|
|
406
|
+
if (!v || typeof v !== 'object' || !('path' in v) || !('branch' in v)) return null;
|
|
407
|
+
const e = v as { path: unknown; branch: unknown };
|
|
408
|
+
return typeof e.path === 'string' && typeof e.branch === 'string' ? { path: e.path, branch: e.branch } : null;
|
|
409
|
+
}
|
|
410
|
+
|
|
405
411
|
export function parseWorkspace(content: string): Workspace {
|
|
406
412
|
try {
|
|
407
413
|
const parsed = JSON.parse(content) as Record<string, unknown>;
|
|
414
|
+
const boardWorktrees: Record<string, { path: string; branch: string }> = {};
|
|
415
|
+
if (parsed.boardWorktrees && typeof parsed.boardWorktrees === 'object') {
|
|
416
|
+
for (const [k, v] of Object.entries(parsed.boardWorktrees as Record<string, unknown>)) {
|
|
417
|
+
const entry = parseWorktreeEntry(v);
|
|
418
|
+
if (entry) boardWorktrees[k] = entry;
|
|
419
|
+
}
|
|
420
|
+
}
|
|
408
421
|
return {
|
|
409
422
|
activeBoardId: typeof parsed.activeBoardId === 'string' ? parsed.activeBoardId : null,
|
|
410
423
|
boardOrder: Array.isArray(parsed.boardOrder) ? parsed.boardOrder.map(String) : [],
|
|
424
|
+
...(Object.keys(boardWorktrees).length > 0 ? { boardWorktrees } : {}),
|
|
411
425
|
};
|
|
412
426
|
} catch {
|
|
413
427
|
return { activeBoardId: null, boardOrder: [] };
|
|
@@ -138,9 +138,15 @@ export interface BoardExecutionSummary {
|
|
|
138
138
|
// Workspace (workspace.json)
|
|
139
139
|
// ============================================================================
|
|
140
140
|
|
|
141
|
+
export interface BoardWorktreeEntry {
|
|
142
|
+
path: string;
|
|
143
|
+
branch: string;
|
|
144
|
+
}
|
|
145
|
+
|
|
141
146
|
export interface Workspace {
|
|
142
147
|
activeBoardId: string | null;
|
|
143
148
|
boardOrder: string[];
|
|
149
|
+
boardWorktrees?: Record<string, BoardWorktreeEntry>;
|
|
144
150
|
}
|
|
145
151
|
|
|
146
152
|
// ============================================================================
|
|
@@ -0,0 +1,120 @@
|
|
|
1
|
+
// Copyright (c) 2025-present Mstro, Inc. All rights reserved.
|
|
2
|
+
// Licensed under the MIT License. See LICENSE file for details.
|
|
3
|
+
|
|
4
|
+
import { existsSync, type FSWatcher, watch } from 'node:fs';
|
|
5
|
+
import { join } from 'node:path';
|
|
6
|
+
import { executeGitCommand } from './git-utils.js';
|
|
7
|
+
import type { HandlerContext } from './handler-context.js';
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* Watches .git/HEAD and .git/worktrees/ for branch changes and
|
|
11
|
+
* broadcasts updates to all connected web clients.
|
|
12
|
+
*/
|
|
13
|
+
export class GitHeadWatcher {
|
|
14
|
+
private headWatcher: FSWatcher | null = null;
|
|
15
|
+
private worktreeWatcher: FSWatcher | null = null;
|
|
16
|
+
private debounceTimer: ReturnType<typeof setTimeout> | null = null;
|
|
17
|
+
private lastKnownBranch = '';
|
|
18
|
+
private lastKnownWorktreeBranches = new Map<string, string>();
|
|
19
|
+
private started = false;
|
|
20
|
+
|
|
21
|
+
constructor(
|
|
22
|
+
private readonly workingDir: string,
|
|
23
|
+
private readonly ctx: HandlerContext,
|
|
24
|
+
) {}
|
|
25
|
+
|
|
26
|
+
start(): void {
|
|
27
|
+
if (this.started) return;
|
|
28
|
+
|
|
29
|
+
const gitDir = join(this.workingDir, '.git');
|
|
30
|
+
if (!existsSync(gitDir)) return;
|
|
31
|
+
|
|
32
|
+
this.initLastKnownBranches();
|
|
33
|
+
|
|
34
|
+
try {
|
|
35
|
+
this.headWatcher = watch(join(gitDir, 'HEAD'), () => this.debounce());
|
|
36
|
+
} catch { /* not a git repo or permission issue */ }
|
|
37
|
+
|
|
38
|
+
this.startWorktreeWatcher();
|
|
39
|
+
this.started = true;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
startWorktreeWatcher(): void {
|
|
43
|
+
if (this.worktreeWatcher) return;
|
|
44
|
+
try {
|
|
45
|
+
const worktreesDir = join(this.workingDir, '.git', 'worktrees');
|
|
46
|
+
if (!existsSync(worktreesDir)) return;
|
|
47
|
+
this.worktreeWatcher = watch(worktreesDir, { recursive: true }, (_event, filename) => {
|
|
48
|
+
if (filename?.endsWith('HEAD')) this.debounce();
|
|
49
|
+
});
|
|
50
|
+
} catch { /* recursive watch not supported or dir missing */ }
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
stop(): void {
|
|
54
|
+
if (this.headWatcher) { this.headWatcher.close(); this.headWatcher = null; }
|
|
55
|
+
if (this.worktreeWatcher) { this.worktreeWatcher.close(); this.worktreeWatcher = null; }
|
|
56
|
+
if (this.debounceTimer) { clearTimeout(this.debounceTimer); this.debounceTimer = null; }
|
|
57
|
+
this.started = false;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
private async initLastKnownBranches(): Promise<void> {
|
|
61
|
+
try {
|
|
62
|
+
const result = await executeGitCommand(['rev-parse', '--abbrev-ref', 'HEAD'], this.workingDir);
|
|
63
|
+
if (result.exitCode === 0) this.lastKnownBranch = result.stdout.trim();
|
|
64
|
+
} catch { /* ignore */ }
|
|
65
|
+
|
|
66
|
+
for (const [tabId, wtPath] of this.ctx.gitDirectories) {
|
|
67
|
+
if (wtPath === this.workingDir) continue;
|
|
68
|
+
try {
|
|
69
|
+
const result = await executeGitCommand(['rev-parse', '--abbrev-ref', 'HEAD'], wtPath);
|
|
70
|
+
if (result.exitCode === 0) this.lastKnownWorktreeBranches.set(tabId, result.stdout.trim());
|
|
71
|
+
} catch { /* ignore */ }
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
private debounce(): void {
|
|
76
|
+
if (this.debounceTimer) clearTimeout(this.debounceTimer);
|
|
77
|
+
this.debounceTimer = setTimeout(() => { this.handleChange(); }, 300);
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
private async handleChange(): Promise<void> {
|
|
81
|
+
try {
|
|
82
|
+
await this.checkMainBranch();
|
|
83
|
+
await this.checkWorktreeBranches();
|
|
84
|
+
} catch { /* ignore errors from concurrent git operations */ }
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
private async checkMainBranch(): Promise<void> {
|
|
88
|
+
const result = await executeGitCommand(['rev-parse', '--abbrev-ref', 'HEAD'], this.workingDir);
|
|
89
|
+
if (result.exitCode !== 0) return;
|
|
90
|
+
const branch = result.stdout.trim();
|
|
91
|
+
if (!branch || branch === this.lastKnownBranch) return;
|
|
92
|
+
this.lastKnownBranch = branch;
|
|
93
|
+
this.ctx.broadcastToAll({
|
|
94
|
+
type: 'gitBranchChanged',
|
|
95
|
+
data: { directory: this.workingDir, branch },
|
|
96
|
+
});
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
private async checkWorktreeBranches(): Promise<void> {
|
|
100
|
+
for (const [tabId, wtPath] of this.ctx.gitDirectories) {
|
|
101
|
+
if (wtPath === this.workingDir) continue;
|
|
102
|
+
try {
|
|
103
|
+
const result = await executeGitCommand(['rev-parse', '--abbrev-ref', 'HEAD'], wtPath);
|
|
104
|
+
if (result.exitCode !== 0) continue;
|
|
105
|
+
const branch = result.stdout.trim();
|
|
106
|
+
if (!branch) continue;
|
|
107
|
+
const lastBranch = this.lastKnownWorktreeBranches.get(tabId);
|
|
108
|
+
if (branch === lastBranch) continue;
|
|
109
|
+
this.lastKnownWorktreeBranches.set(tabId, branch);
|
|
110
|
+
this.ctx.gitBranches.set(tabId, branch);
|
|
111
|
+
const registry = this.ctx.getRegistry(this.workingDir);
|
|
112
|
+
registry.updateTabWorktree(tabId, wtPath, branch);
|
|
113
|
+
this.ctx.broadcastToAll({
|
|
114
|
+
type: 'gitBranchChanged',
|
|
115
|
+
data: { directory: wtPath, branch },
|
|
116
|
+
});
|
|
117
|
+
} catch { /* ignore */ }
|
|
118
|
+
}
|
|
119
|
+
}
|
|
120
|
+
}
|
|
@@ -1,11 +1,35 @@
|
|
|
1
1
|
// Copyright (c) 2025-present Mstro, Inc. All rights reserved.
|
|
2
2
|
// Licensed under the MIT License. See LICENSE file for details.
|
|
3
3
|
|
|
4
|
+
import { existsSync, readFileSync, writeFileSync } from 'node:fs';
|
|
4
5
|
import { dirname, join } from 'node:path';
|
|
6
|
+
import { resolvePmDir } from '../plan/parser.js';
|
|
7
|
+
import type { Workspace } from '../plan/types.js';
|
|
5
8
|
import { executeGitCommand, handleGitStatus, spawnWithOutput } from './git-handlers.js';
|
|
6
9
|
import type { HandlerContext } from './handler-context.js';
|
|
7
10
|
import type { WebSocketMessage, WorktreeInfo, WSContext } from './types.js';
|
|
8
11
|
|
|
12
|
+
function persistBoardWorktree(workingDir: string, boardId: string, worktreePath: string | null, branch: string | null): void {
|
|
13
|
+
const pmDir = resolvePmDir(workingDir);
|
|
14
|
+
if (!pmDir) return;
|
|
15
|
+
const wsPath = join(pmDir, 'workspace.json');
|
|
16
|
+
if (!existsSync(wsPath)) return;
|
|
17
|
+
try {
|
|
18
|
+
const workspace: Workspace = JSON.parse(readFileSync(wsPath, 'utf-8'));
|
|
19
|
+
if (!workspace.boardWorktrees) workspace.boardWorktrees = {};
|
|
20
|
+
if (worktreePath && branch) {
|
|
21
|
+
workspace.boardWorktrees[boardId] = { path: worktreePath, branch };
|
|
22
|
+
} else {
|
|
23
|
+
delete workspace.boardWorktrees[boardId];
|
|
24
|
+
}
|
|
25
|
+
writeFileSync(wsPath, JSON.stringify(workspace, null, 2), 'utf-8');
|
|
26
|
+
} catch { /* non-fatal */ }
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
function isBoardId(id: string): boolean {
|
|
30
|
+
return id.startsWith('BOARD-');
|
|
31
|
+
}
|
|
32
|
+
|
|
9
33
|
export async function handleGitWorktreeMessage(ctx: HandlerContext, ws: WSContext, msg: WebSocketMessage, tabId: string, gitDir: string, workingDir: string): Promise<void> {
|
|
10
34
|
const handlers: Record<string, () => Promise<void>> = {
|
|
11
35
|
gitWorktreeList: () => handleGitWorktreeList(ctx, ws, tabId, gitDir),
|
|
@@ -116,6 +140,9 @@ async function handleGitWorktreeCreateAndAssign(ctx: HandlerContext, ws: WSConte
|
|
|
116
140
|
ctx.gitBranches.set(tabId, branchName);
|
|
117
141
|
const registry = ctx.getRegistry(workingDir);
|
|
118
142
|
registry.updateTabWorktree(tabId, wtPath, branchName);
|
|
143
|
+
if (isBoardId(tabId)) {
|
|
144
|
+
persistBoardWorktree(workingDir, tabId, wtPath, branchName);
|
|
145
|
+
}
|
|
119
146
|
|
|
120
147
|
ctx.send(ws, {
|
|
121
148
|
type: 'gitWorktreeCreatedAndAssigned',
|
|
@@ -129,6 +156,17 @@ async function handleGitWorktreeCreateAndAssign(ctx: HandlerContext, ws: WSConte
|
|
|
129
156
|
}
|
|
130
157
|
}
|
|
131
158
|
|
|
159
|
+
function cleanupWorktreeReferences(ctx: HandlerContext, workingDir: string, wtPath: string): void {
|
|
160
|
+
const resolvedWtPath = join(wtPath);
|
|
161
|
+
for (const [tid, dir] of ctx.gitDirectories) {
|
|
162
|
+
if (dir === resolvedWtPath || dir === wtPath) {
|
|
163
|
+
ctx.gitDirectories.delete(tid);
|
|
164
|
+
ctx.gitBranches.delete(tid);
|
|
165
|
+
if (isBoardId(tid)) persistBoardWorktree(workingDir, tid, null, null);
|
|
166
|
+
}
|
|
167
|
+
}
|
|
168
|
+
}
|
|
169
|
+
|
|
132
170
|
async function handleGitWorktreeRemove(ctx: HandlerContext, ws: WSContext, msg: WebSocketMessage, tabId: string, workingDir: string): Promise<void> {
|
|
133
171
|
try {
|
|
134
172
|
const { path: wtPath, force, deleteBranch } = msg.data || {};
|
|
@@ -155,15 +193,7 @@ async function handleGitWorktreeRemove(ctx: HandlerContext, ws: WSContext, msg:
|
|
|
155
193
|
}
|
|
156
194
|
|
|
157
195
|
await executeGitCommand(['worktree', 'prune'], workingDir);
|
|
158
|
-
|
|
159
|
-
// Clean up gitDirectories entries for any tabs referencing the removed worktree
|
|
160
|
-
const resolvedWtPath = join(wtPath); // normalize
|
|
161
|
-
for (const [tid, dir] of ctx.gitDirectories) {
|
|
162
|
-
if (dir === resolvedWtPath || dir === wtPath) {
|
|
163
|
-
ctx.gitDirectories.delete(tid);
|
|
164
|
-
ctx.gitBranches.delete(tid);
|
|
165
|
-
}
|
|
166
|
-
}
|
|
196
|
+
cleanupWorktreeReferences(ctx, workingDir, wtPath);
|
|
167
197
|
|
|
168
198
|
ctx.send(ws, { type: 'gitWorktreeRemoved', tabId, data: { path: wtPath } });
|
|
169
199
|
} catch (error: unknown) {
|
|
@@ -180,6 +210,9 @@ async function handleTabWorktreeSwitch(ctx: HandlerContext, ws: WSContext, msg:
|
|
|
180
210
|
ctx.gitDirectories.delete(resolvedTabId);
|
|
181
211
|
ctx.gitBranches.delete(resolvedTabId);
|
|
182
212
|
registry.updateTabWorktree(resolvedTabId, null, null);
|
|
213
|
+
if (isBoardId(resolvedTabId)) {
|
|
214
|
+
persistBoardWorktree(workingDir, resolvedTabId, null, null);
|
|
215
|
+
}
|
|
183
216
|
ctx.send(ws, { type: 'tabWorktreeSwitched', tabId: resolvedTabId, data: { tabId: resolvedTabId, worktreePath: workingDir, branch: '' } });
|
|
184
217
|
handleGitStatus(ctx, ws, resolvedTabId, workingDir);
|
|
185
218
|
return;
|
|
@@ -191,6 +224,9 @@ async function handleTabWorktreeSwitch(ctx: HandlerContext, ws: WSContext, msg:
|
|
|
191
224
|
const branch = branchResult.stdout.trim();
|
|
192
225
|
ctx.gitBranches.set(resolvedTabId, branch);
|
|
193
226
|
registry.updateTabWorktree(resolvedTabId, worktreePath, branch);
|
|
227
|
+
if (isBoardId(resolvedTabId)) {
|
|
228
|
+
persistBoardWorktree(workingDir, resolvedTabId, worktreePath, branch);
|
|
229
|
+
}
|
|
194
230
|
|
|
195
231
|
ctx.send(ws, { type: 'tabWorktreeSwitched', tabId: resolvedTabId, data: { tabId: resolvedTabId, worktreePath, branch } });
|
|
196
232
|
handleGitStatus(ctx, ws, resolvedTabId, worktreePath);
|
|
@@ -361,26 +397,44 @@ async function cleanupAfterMerge(
|
|
|
361
397
|
strategy: string,
|
|
362
398
|
deleteWorktree: boolean,
|
|
363
399
|
deleteBranch: boolean,
|
|
364
|
-
): Promise<
|
|
400
|
+
): Promise<{ warnings: string[]; removedWorktreePath: string | null }> {
|
|
401
|
+
const warnings: string[] = [];
|
|
402
|
+
let removedWorktreePath: string | null = null;
|
|
403
|
+
|
|
365
404
|
if (deleteWorktree) {
|
|
366
405
|
const wtList = await executeGitCommand(['worktree', 'list', '--porcelain'], mainPath);
|
|
367
406
|
const worktreePath = findWorktreePathForBranch(wtList.stdout, sourceBranch);
|
|
368
407
|
if (worktreePath && worktreePath !== mainPath) {
|
|
369
|
-
await executeGitCommand(['worktree', 'remove', worktreePath], mainPath);
|
|
408
|
+
const removeResult = await executeGitCommand(['worktree', 'remove', worktreePath], mainPath);
|
|
409
|
+
if (removeResult.exitCode !== 0) {
|
|
410
|
+
const forceResult = await executeGitCommand(['worktree', 'remove', '--force', worktreePath], mainPath);
|
|
411
|
+
if (forceResult.exitCode !== 0) {
|
|
412
|
+
warnings.push(`Failed to remove worktree: ${forceResult.stderr || 'unknown error'}`);
|
|
413
|
+
} else {
|
|
414
|
+
removedWorktreePath = worktreePath;
|
|
415
|
+
}
|
|
416
|
+
} else {
|
|
417
|
+
removedWorktreePath = worktreePath;
|
|
418
|
+
}
|
|
370
419
|
}
|
|
371
420
|
}
|
|
372
421
|
if (deleteBranch) {
|
|
373
422
|
const deleteFlag = strategy === 'squash' ? '-D' : '-d';
|
|
374
|
-
await executeGitCommand(['branch', deleteFlag, sourceBranch], mainPath);
|
|
423
|
+
const branchResult = await executeGitCommand(['branch', deleteFlag, sourceBranch], mainPath);
|
|
424
|
+
if (branchResult.exitCode !== 0) {
|
|
425
|
+
warnings.push(`Failed to delete branch: ${branchResult.stderr || 'unknown error'}`);
|
|
426
|
+
}
|
|
375
427
|
}
|
|
376
428
|
await executeGitCommand(['worktree', 'prune'], mainPath);
|
|
429
|
+
return { warnings, removedWorktreePath };
|
|
377
430
|
}
|
|
378
431
|
|
|
379
432
|
function findWorktreePathForBranch(porcelainOutput: string, branchName: string): string | null {
|
|
380
433
|
let currentWtPath = '';
|
|
434
|
+
const fullRef = `refs/heads/${branchName}`;
|
|
381
435
|
for (const line of porcelainOutput.split('\n')) {
|
|
382
436
|
if (line.startsWith('worktree ')) currentWtPath = line.slice(9).trim();
|
|
383
|
-
if (line.startsWith('branch ') && line.
|
|
437
|
+
if (line.startsWith('branch ') && line.slice(7).trim() === fullRef) return currentWtPath;
|
|
384
438
|
}
|
|
385
439
|
return null;
|
|
386
440
|
}
|
|
@@ -401,6 +455,8 @@ async function handleGitWorktreeMerge(ctx: HandlerContext, ws: WSContext, msg: W
|
|
|
401
455
|
return;
|
|
402
456
|
}
|
|
403
457
|
|
|
458
|
+
const headBefore = await executeGitCommand(['rev-parse', 'HEAD'], mainPath);
|
|
459
|
+
|
|
404
460
|
const mergeResult = await executeMergeStrategy(strategy, sourceBranch, commitMessage, mainPath);
|
|
405
461
|
if (mergeResult.exitCode !== 0) {
|
|
406
462
|
const conflictFiles = await detectMergeConflicts(mainPath);
|
|
@@ -411,10 +467,24 @@ async function handleGitWorktreeMerge(ctx: HandlerContext, ws: WSContext, msg: W
|
|
|
411
467
|
return;
|
|
412
468
|
}
|
|
413
469
|
|
|
470
|
+
const headAfter = await executeGitCommand(['rev-parse', 'HEAD'], mainPath);
|
|
471
|
+
if (headBefore.stdout.trim() === headAfter.stdout.trim()) {
|
|
472
|
+
ctx.send(ws, { type: 'gitWorktreeMergeResult', tabId, data: { success: false, error: `Already up to date — "${sourceBranch}" has no new commits to merge into "${targetBranch}"` } });
|
|
473
|
+
return;
|
|
474
|
+
}
|
|
475
|
+
|
|
414
476
|
const commitHashResult = await executeGitCommand(['rev-parse', '--short', 'HEAD'], mainPath);
|
|
415
|
-
await cleanupAfterMerge(mainPath, sourceBranch, strategy, !!deleteWorktree, !!deleteBranch);
|
|
477
|
+
const { warnings, removedWorktreePath } = await cleanupAfterMerge(mainPath, sourceBranch, strategy, !!deleteWorktree, !!deleteBranch);
|
|
416
478
|
|
|
417
|
-
|
|
479
|
+
if (removedWorktreePath) {
|
|
480
|
+
cleanupWorktreeReferences(ctx, workingDir, removedWorktreePath);
|
|
481
|
+
}
|
|
482
|
+
|
|
483
|
+
const data: Record<string, unknown> = { success: true, mergeCommit: commitHashResult.stdout.trim() };
|
|
484
|
+
if (warnings.length > 0) {
|
|
485
|
+
data.warnings = warnings;
|
|
486
|
+
}
|
|
487
|
+
ctx.send(ws, { type: 'gitWorktreeMergeResult', tabId, data });
|
|
418
488
|
} catch (error: unknown) {
|
|
419
489
|
ctx.send(ws, { type: 'gitError', tabId, data: { error: error instanceof Error ? error.message : String(error) } });
|
|
420
490
|
}
|
|
@@ -5,6 +5,7 @@ import type { ChildProcess } from 'node:child_process';
|
|
|
5
5
|
import type { ImprovisationSessionManager } from '../../cli/improvisation-session-manager.js';
|
|
6
6
|
import type { AutocompleteService } from './autocomplete.js';
|
|
7
7
|
import type { FileUploadHandler } from './file-upload-handler.js';
|
|
8
|
+
import type { GitHeadWatcher } from './git-head-watcher.js';
|
|
8
9
|
import type { SessionRegistry } from './session-registry.js';
|
|
9
10
|
import type { WebSocketResponse, WSContext } from './types.js';
|
|
10
11
|
|
|
@@ -33,6 +34,7 @@ export interface HandlerContext {
|
|
|
33
34
|
autocompleteService: AutocompleteService;
|
|
34
35
|
usageReporter: UsageReporter | null;
|
|
35
36
|
fileUploadHandler: FileUploadHandler | null;
|
|
37
|
+
gitHeadWatcher: GitHeadWatcher | null;
|
|
36
38
|
|
|
37
39
|
// Registry access
|
|
38
40
|
getRegistry(workingDir: string): SessionRegistry;
|
|
@@ -19,6 +19,7 @@ import { handleDeployMessage } from './deploy-handlers.js';
|
|
|
19
19
|
import { handleFileExplorerMessage, handleFileMessage } from './file-explorer-handlers.js';
|
|
20
20
|
import { FileUploadHandler } from './file-upload-handler.js';
|
|
21
21
|
import { handleGitMessage } from './git-handlers.js';
|
|
22
|
+
import { GitHeadWatcher } from './git-head-watcher.js';
|
|
22
23
|
import type { HandlerContext, UsageReporter } from './handler-context.js';
|
|
23
24
|
import { handlePlanMessage } from './plan-handlers.js';
|
|
24
25
|
import { handleQualityMessage } from './quality-handlers.js';
|
|
@@ -46,6 +47,7 @@ export class WebSocketImproviseHandler implements HandlerContext {
|
|
|
46
47
|
terminalListenerCleanups: Map<string, () => void> = new Map();
|
|
47
48
|
terminalSubscribers: Map<string, Set<WSContext>> = new Map();
|
|
48
49
|
fileUploadHandler: FileUploadHandler | null = null;
|
|
50
|
+
gitHeadWatcher: GitHeadWatcher | null = null;
|
|
49
51
|
|
|
50
52
|
constructor() {
|
|
51
53
|
this.frecencyPath = join(homedir(), '.mstro', 'autocomplete-frecency.json');
|
|
@@ -103,9 +105,14 @@ export class WebSocketImproviseHandler implements HandlerContext {
|
|
|
103
105
|
}
|
|
104
106
|
}
|
|
105
107
|
|
|
106
|
-
handleConnection(ws: WSContext,
|
|
108
|
+
handleConnection(ws: WSContext, workingDir: string): void {
|
|
107
109
|
this.connections.set(ws, new Map());
|
|
108
110
|
this.allConnections.add(ws);
|
|
111
|
+
|
|
112
|
+
if (!this.gitHeadWatcher && workingDir) {
|
|
113
|
+
this.gitHeadWatcher = new GitHeadWatcher(workingDir, this);
|
|
114
|
+
this.gitHeadWatcher.start();
|
|
115
|
+
}
|
|
109
116
|
}
|
|
110
117
|
|
|
111
118
|
async handleMessage(
|
|
@@ -147,7 +154,7 @@ export class WebSocketImproviseHandler implements HandlerContext {
|
|
|
147
154
|
// Quality
|
|
148
155
|
qualityDetectTools: 'quality', qualityScan: 'quality', qualityInstallTools: 'quality', qualityCodeReview: 'quality', qualityFixIssues: 'quality', qualityLoadState: 'quality', qualitySaveDirectories: 'quality',
|
|
149
156
|
// Plan + boards + sprints
|
|
150
|
-
planInit: 'plan', planGetState: 'plan', planListIssues: 'plan', planGetIssue: 'plan', planGetSprint: 'plan', planGetMilestone: 'plan', planCreateIssue: 'plan', planUpdateIssue: 'plan', planDeleteIssue: 'plan', planScaffold: 'plan', planPrompt: 'plan', planExecute: 'plan', planExecuteEpic: 'plan', planPause: 'plan', planStop: 'plan', planResume: 'plan', planCreateBoard: 'plan', planUpdateBoard: 'plan', planArchiveBoard: 'plan', planGetBoard: 'plan', planGetBoardState: 'plan', planReorderBoards: 'plan', planSetActiveBoard: 'plan', planGetBoardArtifacts: 'plan', planCreateSprint: 'plan', planActivateSprint: 'plan', planCompleteSprint: 'plan', planGetSprintArtifacts: 'plan',
|
|
157
|
+
planInit: 'plan', planGetState: 'plan', planListIssues: 'plan', planGetIssue: 'plan', planGetSprint: 'plan', planGetMilestone: 'plan', planCreateIssue: 'plan', planUpdateIssue: 'plan', planDeleteIssue: 'plan', planScaffold: 'plan', planPrompt: 'plan', planExecute: 'plan', planExecuteEpic: 'plan', planPause: 'plan', planStop: 'plan', planResume: 'plan', planCreateBoard: 'plan', planUpdateBoard: 'plan', planArchiveBoard: 'plan', planGetBoard: 'plan', planGetBoardState: 'plan', planReorderBoards: 'plan', planSetActiveBoard: 'plan', planGetBoardArtifacts: 'plan', planCreateSprint: 'plan', planActivateSprint: 'plan', planCompleteSprint: 'plan', planGetSprintArtifacts: 'plan', chatToBoard: 'plan',
|
|
151
158
|
// File upload
|
|
152
159
|
fileUploadStart: 'fileUpload', fileUploadChunk: 'fileUpload', fileUploadComplete: 'fileUpload', fileUploadCancel: 'fileUpload',
|
|
153
160
|
// Deploy management + HTTP relay
|
|
@@ -243,10 +250,16 @@ export class WebSocketImproviseHandler implements HandlerContext {
|
|
|
243
250
|
this.allConnections.delete(ws);
|
|
244
251
|
cleanupTerminalSubscribers(this, ws);
|
|
245
252
|
|
|
246
|
-
// Clean up
|
|
247
|
-
if (this.allConnections.size === 0
|
|
248
|
-
this.fileUploadHandler
|
|
249
|
-
|
|
253
|
+
// Clean up resources when no connections remain
|
|
254
|
+
if (this.allConnections.size === 0) {
|
|
255
|
+
if (this.fileUploadHandler) {
|
|
256
|
+
this.fileUploadHandler.destroy();
|
|
257
|
+
this.fileUploadHandler = null;
|
|
258
|
+
}
|
|
259
|
+
if (this.gitHeadWatcher) {
|
|
260
|
+
this.gitHeadWatcher.stop();
|
|
261
|
+
this.gitHeadWatcher = null;
|
|
262
|
+
}
|
|
250
263
|
}
|
|
251
264
|
}
|
|
252
265
|
|