commandmate 0.3.5 → 0.4.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.next/BUILD_ID +1 -1
- package/.next/app-build-manifest.json +19 -23
- package/.next/app-path-routes-manifest.json +1 -1
- package/.next/build-manifest.json +5 -5
- package/.next/cache/.tsbuildinfo +1 -1
- package/.next/cache/config.json +3 -3
- package/.next/cache/webpack/client-production/0.pack +0 -0
- package/.next/cache/webpack/client-production/1.pack +0 -0
- package/.next/cache/webpack/client-production/2.pack +0 -0
- package/.next/cache/webpack/client-production/index.pack +0 -0
- package/.next/cache/webpack/client-production/index.pack.old +0 -0
- package/.next/cache/webpack/edge-server-production/0.pack +0 -0
- package/.next/cache/webpack/edge-server-production/index.pack +0 -0
- package/.next/cache/webpack/server-production/0.pack +0 -0
- package/.next/cache/webpack/server-production/index.pack +0 -0
- package/.next/next-server.js.nft.json +1 -1
- package/.next/prerender-manifest.json +1 -1
- package/.next/react-loadable-manifest.json +69 -55
- package/.next/required-server-files.json +1 -1
- package/.next/server/app/_not-found/page.js +1 -1
- package/.next/server/app/_not-found/page.js.nft.json +1 -1
- package/.next/server/app/_not-found/page_client-reference-manifest.js +1 -1
- package/.next/server/app/api/app/update-check/route.js +1 -1
- package/.next/server/app/api/repositories/clone/route.js +1 -1
- package/.next/server/app/api/repositories/route.js +8 -8
- package/.next/server/app/api/repositories/scan/route.js +1 -1
- package/.next/server/app/api/worktrees/[id]/capture/route.js +1 -2
- package/.next/server/app/api/worktrees/[id]/capture/route.js.nft.json +1 -1
- package/.next/server/app/api/worktrees/[id]/current-output/route.js +1 -1
- package/.next/server/app/api/worktrees/[id]/files/[...path]/route.js +1 -1
- package/.next/server/app/api/worktrees/[id]/prompt-response/route.js +1 -1
- package/.next/server/app/api/worktrees/[id]/respond/route.js +1 -1
- package/.next/server/app/api/worktrees/[id]/route.js +1 -1
- package/.next/server/app/api/worktrees/[id]/schedules/[scheduleId]/route.js +1 -1
- package/.next/server/app/api/worktrees/[id]/schedules/route.js +2 -2
- package/.next/server/app/api/worktrees/[id]/search/route.js +1 -1
- package/.next/server/app/api/worktrees/[id]/terminal/route.js +1 -1
- package/.next/server/app/api/worktrees/[id]/terminal/route.js.nft.json +1 -1
- package/.next/server/app/api/worktrees/[id]/tree/[...path]/route.js +1 -1
- package/.next/server/app/api/worktrees/[id]/upload/[...path]/route.js +1 -1
- package/.next/server/app/api/worktrees/route.js +1 -1
- package/.next/server/app/login/page.js +1 -1
- package/.next/server/app/login/page.js.nft.json +1 -1
- package/.next/server/app/login/page_client-reference-manifest.js +1 -1
- package/.next/server/app/page.js +3 -3
- package/.next/server/app/page.js.nft.json +1 -1
- package/.next/server/app/page_client-reference-manifest.js +1 -1
- package/.next/server/app/proxy/[...path]/route.js +4 -4
- package/.next/server/app/worktrees/[id]/files/[...path]/page.js +1 -1
- package/.next/server/app/worktrees/[id]/files/[...path]/page.js.nft.json +1 -1
- package/.next/server/app/worktrees/[id]/files/[...path]/page_client-reference-manifest.js +1 -1
- package/.next/server/app/worktrees/[id]/page.js +6 -6
- package/.next/server/app/worktrees/[id]/page.js.nft.json +1 -1
- package/.next/server/app/worktrees/[id]/page_client-reference-manifest.js +1 -1
- package/.next/server/app/worktrees/[id]/terminal/page.js +2 -4
- package/.next/server/app/worktrees/[id]/terminal/page.js.nft.json +1 -1
- package/.next/server/app/worktrees/[id]/terminal/page_client-reference-manifest.js +1 -1
- package/.next/server/app-paths-manifest.json +8 -8
- package/.next/server/chunks/{3294.js → 1628.js} +3 -3
- package/.next/server/chunks/185.js +36 -0
- package/.next/server/chunks/3860.js +1 -1
- package/.next/server/chunks/4893.js +2 -2
- package/.next/server/chunks/4952.js +1 -1
- package/.next/server/chunks/5488.js +6 -6
- package/.next/server/chunks/7425.js +34 -31
- package/.next/server/chunks/7566.js +2 -2
- package/.next/server/chunks/8199.js +1 -0
- package/.next/server/chunks/8585.js +1 -1
- package/.next/server/chunks/8693.js +1 -1
- package/.next/server/middleware-build-manifest.js +1 -1
- package/.next/server/middleware-manifest.json +5 -5
- package/.next/server/middleware-react-loadable-manifest.js +1 -1
- package/.next/server/pages/500.html +1 -1
- package/.next/server/server-reference-manifest.json +1 -1
- package/.next/static/chunks/12-00c528d46a0a0a1d.js +1 -0
- package/.next/static/chunks/{13.feeafc7cc620f8c4.js → 13.b9521543496f4468.js} +1 -1
- package/.next/static/chunks/1334.bfedf44ee9fe2761.js +1 -0
- package/.next/static/chunks/143.eb6b4671490cd223.js +1 -0
- package/.next/static/chunks/{3574.7a94c27e6a496a56.js → 1442.74b5f4de9a4b4e1b.js} +1 -1
- package/.next/static/chunks/2083-b5bed0c77cc53281.js +1 -0
- package/.next/static/chunks/2725.eb2d236c8030711c.js +1 -0
- package/.next/static/chunks/3398-3d40a17387bd554b.js +1 -0
- package/.next/static/chunks/3516.3c576047408cae6b.js +1 -0
- package/.next/static/chunks/3559.422c6ca760b85750.js +1 -0
- package/.next/static/chunks/3956.52c5b9a0071a641d.js +1 -0
- package/.next/static/chunks/4012.32b576a4fa621774.js +1 -0
- package/.next/static/chunks/4212.e7ba1009bc1da62d.js +131 -0
- package/.next/static/chunks/4303.caf91e86105d5e70.js +1 -0
- package/.next/static/chunks/4327.4dcda9b6fab6a385.js +82 -0
- package/.next/static/chunks/4671.d86d21d0dfdace41.js +1 -0
- package/.next/static/chunks/5518.ec88dcb5a27b17fe.js +1 -0
- package/.next/static/chunks/6434.08d262283371d333.js +1 -0
- package/.next/static/chunks/{656.5e2de0173f5a06bd.js → 656.dc26b973d07d9627.js} +5 -5
- package/.next/static/chunks/7119.01777af21b55740c.js +1 -0
- package/.next/static/chunks/7293.fb88bb102af4aa04.js +1 -0
- package/.next/static/chunks/8913-40625650292eb3d0.js +1 -0
- package/.next/static/chunks/8977.fc18b8260cd8bc1f.js +1 -0
- package/.next/static/chunks/9552.d959149efd41e84b.js +1 -0
- package/.next/static/chunks/app/layout-7198a7a49aa21a97.js +1 -0
- package/.next/static/chunks/app/page-7498cf75e69d9227.js +1 -0
- package/.next/static/chunks/app/worktrees/[id]/files/[...path]/page-0599f64a8e80d255.js +1 -0
- package/.next/static/chunks/app/worktrees/[id]/page-94ad7a1ce1f0c440.js +1 -0
- package/.next/static/chunks/app/worktrees/[id]/terminal/page-175b618c047bc992.js +1 -0
- package/.next/static/chunks/d3ac728e.daf595a898e9b720.js +1 -0
- package/.next/static/chunks/webpack-f7111aab807d73b9.js +1 -0
- package/.next/static/css/f7dc01350168df01.css +3 -0
- package/.next/trace +5 -5
- package/README.md +66 -56
- package/dist/server/server.js +5 -0
- package/dist/server/src/lib/auto-yes-manager.js +58 -18
- package/dist/server/src/lib/claude-session.js +9 -3
- package/dist/server/src/lib/cli-session.js +60 -10
- package/dist/server/src/lib/cli-tools/codex.js +7 -7
- package/dist/server/src/lib/cli-tools/gemini.js +3 -0
- package/dist/server/src/lib/cli-tools/opencode-config.js +179 -33
- package/dist/server/src/lib/cli-tools/opencode.js +5 -0
- package/dist/server/src/lib/cli-tools/vibe-local.js +3 -0
- package/dist/server/src/lib/cmate-parser.js +7 -7
- package/dist/server/src/lib/db-migrations.js +18 -1
- package/dist/server/src/lib/errors.js +153 -0
- package/dist/server/src/lib/prompt-answer-sender.js +3 -0
- package/dist/server/src/lib/prompt-detector.js +49 -7
- package/dist/server/src/lib/resource-cleanup.js +257 -0
- package/dist/server/src/lib/schedule-manager.js +269 -83
- package/dist/server/src/lib/tmux-capture-cache.js +221 -0
- package/dist/server/src/lib/tmux.js +41 -20
- package/dist/server/src/types/markdown-editor.js +9 -1
- package/package.json +11 -8
- package/.next/server/chunks/539.js +0 -35
- package/.next/server/chunks/7458.js +0 -1
- package/.next/server/chunks/7808.js +0 -1
- package/.next/static/chunks/1038-3509435b68c0967e.js +0 -1
- package/.next/static/chunks/1098.49268c9fe1b028fa.js +0 -1
- package/.next/static/chunks/2335-98a211e00b94c7ac.js +0 -1
- package/.next/static/chunks/3559.f073f72c4466ce0e.js +0 -1
- package/.next/static/chunks/3843.3fdda732987f7bb8.js +0 -1
- package/.next/static/chunks/4212.52c1bb34fc97d0d0.js +0 -131
- package/.next/static/chunks/4327.157a4c226d919531.js +0 -60
- package/.next/static/chunks/4362.7bd6f0282e49d79b.js +0 -1
- package/.next/static/chunks/4721.40615a5f4f32b5fb.js +0 -1
- package/.next/static/chunks/5112.17318d1c6b28044b.js +0 -1
- package/.next/static/chunks/6406.9653f0d41ab85059.js +0 -1
- package/.next/static/chunks/6792.3c01ac4dda4b5c6d.js +0 -1
- package/.next/static/chunks/8091-d65d2ab6daed23c6.js +0 -1
- package/.next/static/chunks/8125.245a9df052d274fb.js +0 -1
- package/.next/static/chunks/8522.1607e96011c66877.js +0 -1
- package/.next/static/chunks/8841.dadeb1ece8e46004.js +0 -1
- package/.next/static/chunks/8885.f8d9912b40d74811.js +0 -1
- package/.next/static/chunks/9178-88850a7c48deea07.js +0 -1
- package/.next/static/chunks/9552.b7dfb7903ead934b.js +0 -1
- package/.next/static/chunks/app/layout-9110f9a5e41c6bf4.js +0 -1
- package/.next/static/chunks/app/page-9e523a8f415bc707.js +0 -1
- package/.next/static/chunks/app/worktrees/[id]/files/[...path]/page-4a3c0861367e0391.js +0 -1
- package/.next/static/chunks/app/worktrees/[id]/page-8fb4dc30b58a5681.js +0 -1
- package/.next/static/chunks/app/worktrees/[id]/terminal/page-5d85a7e508ce36d3.js +0 -1
- package/.next/static/chunks/d3ac728e.6c9c508274d4d2d5.js +0 -1
- package/.next/static/chunks/webpack-81c97591dd5567ac.js +0 -1
- package/.next/static/css/45b3a41370668314.css +0 -3
- /package/.next/static/chunks/{30d07d85-393352a92199f695.js → 30d07d85.1dc99a921fc18e34.js} +0 -0
- /package/.next/static/{p3hosTZoJ22r35fWwUoLr → dwGMLEU53HOvFOWqiZOT0}/_buildManifest.js +0 -0
- /package/.next/static/{p3hosTZoJ22r35fWwUoLr → dwGMLEU53HOvFOWqiZOT0}/_ssgManifest.js +0 -0
|
@@ -2,6 +2,7 @@
|
|
|
2
2
|
/**
|
|
3
3
|
* Schedule Manager
|
|
4
4
|
* Issue #294: Manages scheduled execution of claude -p commands
|
|
5
|
+
* Issue #409: Performance optimization with mtime caching and batch upsert
|
|
5
6
|
*
|
|
6
7
|
* Uses a single timer to periodically scan all worktrees for CMATE.md changes
|
|
7
8
|
* and execute scheduled tasks via croner cron expressions.
|
|
@@ -10,20 +11,30 @@
|
|
|
10
11
|
* - globalThis for hot reload persistence (same as auto-yes-manager.ts)
|
|
11
12
|
* - Single timer for all worktrees (60 second polling interval)
|
|
12
13
|
* - SIGKILL fire-and-forget for stopAllSchedules (< 1ms, within 3s graceful shutdown)
|
|
14
|
+
* - mtime caching to skip unchanged CMATE.md files (Issue #409)
|
|
13
15
|
*
|
|
14
16
|
* [S3-001] stopAllSchedules() uses synchronous process.kill for immediate cleanup
|
|
15
17
|
* [S3-010] initScheduleManager() is called after initializeWorktrees()
|
|
16
18
|
*/
|
|
19
|
+
var __importDefault = (this && this.__importDefault) || function (mod) {
|
|
20
|
+
return (mod && mod.__esModule) ? mod : { "default": mod };
|
|
21
|
+
};
|
|
17
22
|
Object.defineProperty(exports, "__esModule", { value: true });
|
|
18
23
|
exports.MAX_CONCURRENT_SCHEDULES = exports.POLL_INTERVAL_MS = void 0;
|
|
24
|
+
exports.batchUpsertSchedules = batchUpsertSchedules;
|
|
19
25
|
exports.initScheduleManager = initScheduleManager;
|
|
20
26
|
exports.stopAllSchedules = stopAllSchedules;
|
|
27
|
+
exports.stopScheduleForWorktree = stopScheduleForWorktree;
|
|
21
28
|
exports.getActiveScheduleCount = getActiveScheduleCount;
|
|
22
29
|
exports.isScheduleManagerInitialized = isScheduleManagerInitialized;
|
|
30
|
+
exports.getScheduleWorktreeIds = getScheduleWorktreeIds;
|
|
23
31
|
const crypto_1 = require("crypto");
|
|
32
|
+
const fs_1 = require("fs");
|
|
33
|
+
const path_1 = __importDefault(require("path"));
|
|
24
34
|
const croner_1 = require("croner");
|
|
25
35
|
const cmate_parser_1 = require("./cmate-parser");
|
|
26
36
|
const claude_executor_1 = require("./claude-executor");
|
|
37
|
+
const cmate_constants_1 = require("../config/cmate-constants");
|
|
27
38
|
// =============================================================================
|
|
28
39
|
// Constants
|
|
29
40
|
// =============================================================================
|
|
@@ -40,6 +51,8 @@ function getManagerState() {
|
|
|
40
51
|
timerId: null,
|
|
41
52
|
schedules: new Map(),
|
|
42
53
|
initialized: false,
|
|
54
|
+
isSyncing: false,
|
|
55
|
+
cmateFileCache: new Map(),
|
|
43
56
|
};
|
|
44
57
|
}
|
|
45
58
|
return globalThis.__scheduleManagerStates;
|
|
@@ -60,6 +73,38 @@ function getLazyDbInstance() {
|
|
|
60
73
|
return getDbInstance();
|
|
61
74
|
}
|
|
62
75
|
// =============================================================================
|
|
76
|
+
// CMATE.md mtime Helper
|
|
77
|
+
// =============================================================================
|
|
78
|
+
/**
|
|
79
|
+
* Get the modification time (mtimeMs) of the CMATE.md file in a worktree directory.
|
|
80
|
+
*
|
|
81
|
+
* Trust boundary (SEC4-003): worktreePath is DB-derived from getAllWorktrees()
|
|
82
|
+
* and was validated by validateWorktreePath() at worktree registration time.
|
|
83
|
+
* Therefore, path traversal re-validation is not needed here.
|
|
84
|
+
* readCmateFile() has validateCmatePath() because it can be called externally,
|
|
85
|
+
* whereas getCmateMtime() is an internal function called only from syncSchedules().
|
|
86
|
+
*
|
|
87
|
+
* @param worktreePath - Path to the worktree directory (DB-derived, trusted)
|
|
88
|
+
* @returns mtimeMs value, or null if the file does not exist or cannot be read
|
|
89
|
+
*/
|
|
90
|
+
function getCmateMtime(worktreePath) {
|
|
91
|
+
// Uses CMATE_FILENAME from @/config/cmate-constants directly (DR1-001, CR2-001)
|
|
92
|
+
const filePath = path_1.default.join(worktreePath, cmate_constants_1.CMATE_FILENAME);
|
|
93
|
+
try {
|
|
94
|
+
return (0, fs_1.statSync)(filePath).mtimeMs;
|
|
95
|
+
}
|
|
96
|
+
catch (error) {
|
|
97
|
+
if (error instanceof Error &&
|
|
98
|
+
'code' in error &&
|
|
99
|
+
error.code === 'ENOENT') {
|
|
100
|
+
return null;
|
|
101
|
+
}
|
|
102
|
+
// Permission errors etc. - log and treat as no file (SEC4-004-note)
|
|
103
|
+
console.warn(`[schedule-manager] Failed to stat ${filePath}:`, error);
|
|
104
|
+
return null;
|
|
105
|
+
}
|
|
106
|
+
}
|
|
107
|
+
// =============================================================================
|
|
63
108
|
// DB Operations
|
|
64
109
|
// =============================================================================
|
|
65
110
|
/**
|
|
@@ -78,33 +123,66 @@ function getAllWorktrees() {
|
|
|
78
123
|
}
|
|
79
124
|
}
|
|
80
125
|
/**
|
|
81
|
-
*
|
|
82
|
-
*
|
|
83
|
-
* Otherwise, a new schedule is created.
|
|
126
|
+
* Batch upsert schedule entries into the database.
|
|
127
|
+
* Replaces the previous per-entry upsertSchedule() function (DR1-002).
|
|
84
128
|
*
|
|
85
|
-
*
|
|
86
|
-
*
|
|
87
|
-
*
|
|
129
|
+
* Uses a single SELECT to build an existing schedules map, then performs
|
|
130
|
+
* all UPDATE/INSERT operations within a single transaction.
|
|
131
|
+
*
|
|
132
|
+
* Sanitization chain (SEC4-004): This function is called exclusively from
|
|
133
|
+
* syncSchedules(), which passes entries produced by parseSchedulesSection().
|
|
134
|
+
* parseSchedulesSection() calls sanitizeMessageContent() (S4-002) on all
|
|
135
|
+
* message fields, so entries arriving here are already sanitized.
|
|
136
|
+
* If batchUpsertSchedules() is called from a different code path in the
|
|
137
|
+
* future, an input validation layer must be added at the call site.
|
|
138
|
+
*
|
|
139
|
+
* Note on next_execute_at (CR2-002): The scheduled_executions table has a
|
|
140
|
+
* next_execute_at column (INTEGER, nullable) that is intentionally not
|
|
141
|
+
* operated on by this function, consistent with the prior upsertSchedule().
|
|
142
|
+
*
|
|
143
|
+
* @param worktreeId - The worktree ID to associate schedules with
|
|
144
|
+
* @param entries - Schedule entries from CMATE.md (sanitized by parseSchedulesSection)
|
|
145
|
+
* @returns Array of schedule IDs (existing or newly created), in the same order as entries
|
|
88
146
|
*/
|
|
89
|
-
function
|
|
147
|
+
function batchUpsertSchedules(worktreeId, entries) {
|
|
148
|
+
if (entries.length === 0)
|
|
149
|
+
return [];
|
|
90
150
|
const db = getLazyDbInstance();
|
|
91
151
|
const now = Date.now();
|
|
92
|
-
//
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
`).run(entry.message, entry.cronExpression, entry.cliToolId, entry.enabled ? 1 : 0, now, existing.id);
|
|
100
|
-
return existing.id;
|
|
152
|
+
// Bulk fetch existing schedules for this worktree.
|
|
153
|
+
// better-sqlite3's prepare() is cached at the DB instance level (DR1-007),
|
|
154
|
+
// so calling it each invocation does not incur additional compilation cost.
|
|
155
|
+
const existingRows = db.prepare('SELECT id, name FROM scheduled_executions WHERE worktree_id = ?').all(worktreeId);
|
|
156
|
+
const existingByName = new Map();
|
|
157
|
+
for (const row of existingRows) {
|
|
158
|
+
existingByName.set(row.name, row.id);
|
|
101
159
|
}
|
|
102
|
-
const
|
|
103
|
-
db.prepare(`
|
|
160
|
+
const resultIds = [];
|
|
161
|
+
const updateStmt = db.prepare(`
|
|
162
|
+
UPDATE scheduled_executions
|
|
163
|
+
SET message = ?, cron_expression = ?, cli_tool_id = ?, enabled = ?, updated_at = ?
|
|
164
|
+
WHERE id = ?
|
|
165
|
+
`);
|
|
166
|
+
const insertStmt = db.prepare(`
|
|
104
167
|
INSERT INTO scheduled_executions (id, worktree_id, name, message, cron_expression, cli_tool_id, enabled, created_at, updated_at)
|
|
105
168
|
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)
|
|
106
|
-
`)
|
|
107
|
-
|
|
169
|
+
`);
|
|
170
|
+
const runTransaction = db.transaction(() => {
|
|
171
|
+
for (const entry of entries) {
|
|
172
|
+
const existingId = existingByName.get(entry.name);
|
|
173
|
+
if (existingId) {
|
|
174
|
+
updateStmt.run(entry.message, entry.cronExpression, entry.cliToolId, entry.enabled ? 1 : 0, now, existingId);
|
|
175
|
+
resultIds.push(existingId);
|
|
176
|
+
}
|
|
177
|
+
else {
|
|
178
|
+
const id = (0, crypto_1.randomUUID)();
|
|
179
|
+
insertStmt.run(id, worktreeId, entry.name, entry.message, entry.cronExpression, entry.cliToolId, entry.enabled ? 1 : 0, now, now);
|
|
180
|
+
resultIds.push(id);
|
|
181
|
+
}
|
|
182
|
+
}
|
|
183
|
+
});
|
|
184
|
+
runTransaction();
|
|
185
|
+
return resultIds;
|
|
108
186
|
}
|
|
109
187
|
/**
|
|
110
188
|
* Create an execution log entry in 'running' status.
|
|
@@ -183,6 +261,11 @@ function disableStaleSchedules(activeScheduleIds, worktreeIds) {
|
|
|
183
261
|
try {
|
|
184
262
|
const db = getLazyDbInstance();
|
|
185
263
|
const now = Date.now();
|
|
264
|
+
// SEC4-002: Dynamic placeholder generation is SQL injection-safe because only
|
|
265
|
+
// '?' placeholders are interpolated into SQL; actual values are passed via
|
|
266
|
+
// prepared statement parameter binding. worktreeIds originates from
|
|
267
|
+
// getAllWorktrees() (bounded by MAX_CONCURRENT_SCHEDULES ~100), well within
|
|
268
|
+
// SQLite's SQLITE_MAX_VARIABLE_NUMBER (default 999).
|
|
186
269
|
const placeholders = worktreeIds.map(() => '?').join(',');
|
|
187
270
|
// Get enabled schedules for the scanned worktrees
|
|
188
271
|
const rows = db.prepare(`SELECT id FROM scheduled_executions WHERE worktree_id IN (${placeholders}) AND enabled = 1`).all(...worktreeIds);
|
|
@@ -250,77 +333,121 @@ async function executeSchedule(state) {
|
|
|
250
333
|
* Sync schedules from CMATE.md files for all worktrees.
|
|
251
334
|
* Reads CMATE.md from each worktree, upserts schedules to DB,
|
|
252
335
|
* creates/updates cron jobs, and removes stale schedules.
|
|
336
|
+
*
|
|
337
|
+
* Issue #406: Async I/O for readCmateFile() to avoid event loop blocking.
|
|
338
|
+
* Issue #409: Uses mtime caching to skip unchanged CMATE.md files
|
|
339
|
+
* and batchUpsertSchedules() for efficient DB operations.
|
|
340
|
+
*
|
|
341
|
+
* DJ-007: isSyncing guard prevents concurrent execution when async
|
|
342
|
+
* operations exceed the 60-second polling interval.
|
|
253
343
|
*/
|
|
254
|
-
function syncSchedules() {
|
|
344
|
+
async function syncSchedules() {
|
|
255
345
|
const manager = getManagerState();
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
if (
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
if (existingState) {
|
|
278
|
-
// Update entry if changed
|
|
279
|
-
existingState.entry = entry;
|
|
346
|
+
// DJ-007: Prevent concurrent execution (SEC4-004)
|
|
347
|
+
if (manager.isSyncing)
|
|
348
|
+
return;
|
|
349
|
+
manager.isSyncing = true;
|
|
350
|
+
try {
|
|
351
|
+
const worktrees = getAllWorktrees();
|
|
352
|
+
// Track which scheduleIds are still valid
|
|
353
|
+
const activeScheduleIds = new Set();
|
|
354
|
+
for (const worktree of worktrees) {
|
|
355
|
+
try {
|
|
356
|
+
// Issue #409: Check CMATE.md mtime for change detection
|
|
357
|
+
const mtime = getCmateMtime(worktree.path);
|
|
358
|
+
const cachedMtime = manager.cmateFileCache.get(worktree.path);
|
|
359
|
+
if (mtime === null) {
|
|
360
|
+
// CMATE.md does not exist (or was deleted).
|
|
361
|
+
// DR1-009: By not adding to activeScheduleIds, this worktree's
|
|
362
|
+
// schedules will be cleaned up in Step 4 (stale cron job removal)
|
|
363
|
+
// and disableStaleSchedules() (DB enabled=0 update).
|
|
364
|
+
if (cachedMtime !== undefined) {
|
|
365
|
+
manager.cmateFileCache.delete(worktree.path);
|
|
366
|
+
}
|
|
280
367
|
continue;
|
|
281
368
|
}
|
|
282
|
-
|
|
369
|
+
// If mtime matches cached value, skip DB operations for this worktree
|
|
370
|
+
if (cachedMtime !== undefined && cachedMtime === mtime) {
|
|
371
|
+
// File unchanged - re-add existing schedule IDs to keep them active
|
|
372
|
+
for (const [scheduleId, state] of manager.schedules) {
|
|
373
|
+
if (state.worktreeId === worktree.id) {
|
|
374
|
+
activeScheduleIds.add(scheduleId);
|
|
375
|
+
}
|
|
376
|
+
}
|
|
283
377
|
continue;
|
|
284
|
-
// Create new cron job
|
|
285
|
-
try {
|
|
286
|
-
const cronJob = new croner_1.Cron(entry.cronExpression, {
|
|
287
|
-
paused: false,
|
|
288
|
-
protect: true, // Prevent overlapping
|
|
289
|
-
});
|
|
290
|
-
const state = {
|
|
291
|
-
scheduleId,
|
|
292
|
-
worktreeId: worktree.id,
|
|
293
|
-
cronJob,
|
|
294
|
-
isExecuting: false,
|
|
295
|
-
entry,
|
|
296
|
-
};
|
|
297
|
-
// Schedule execution
|
|
298
|
-
cronJob.schedule(() => {
|
|
299
|
-
void executeSchedule(state);
|
|
300
|
-
});
|
|
301
|
-
manager.schedules.set(scheduleId, state);
|
|
302
|
-
console.log(`[schedule-manager] Scheduled ${entry.name} (${entry.cronExpression})`);
|
|
303
378
|
}
|
|
304
|
-
|
|
305
|
-
|
|
379
|
+
// Update mtime cache
|
|
380
|
+
manager.cmateFileCache.set(worktree.path, mtime);
|
|
381
|
+
const config = await (0, cmate_parser_1.readCmateFile)(worktree.path);
|
|
382
|
+
if (!config)
|
|
383
|
+
continue;
|
|
384
|
+
const scheduleRows = config.get('Schedules');
|
|
385
|
+
if (!scheduleRows)
|
|
386
|
+
continue;
|
|
387
|
+
const entries = (0, cmate_parser_1.parseSchedulesSection)(scheduleRows);
|
|
388
|
+
// Issue #409: Batch upsert all entries for this worktree
|
|
389
|
+
const scheduleIds = batchUpsertSchedules(worktree.id, entries);
|
|
390
|
+
for (let i = 0; i < entries.length; i++) {
|
|
391
|
+
const entry = entries[i];
|
|
392
|
+
const scheduleId = scheduleIds[i];
|
|
393
|
+
if (manager.schedules.size >= exports.MAX_CONCURRENT_SCHEDULES) {
|
|
394
|
+
console.warn(`[schedule-manager] MAX_CONCURRENT_SCHEDULES (${exports.MAX_CONCURRENT_SCHEDULES}) reached`);
|
|
395
|
+
return;
|
|
396
|
+
}
|
|
397
|
+
activeScheduleIds.add(scheduleId);
|
|
398
|
+
// Check if this schedule already has a running cron job
|
|
399
|
+
const existingState = manager.schedules.get(scheduleId);
|
|
400
|
+
if (existingState) {
|
|
401
|
+
// Update entry if changed
|
|
402
|
+
existingState.entry = entry;
|
|
403
|
+
continue;
|
|
404
|
+
}
|
|
405
|
+
if (!entry.enabled || !entry.cronExpression)
|
|
406
|
+
continue;
|
|
407
|
+
// Create new cron job
|
|
408
|
+
try {
|
|
409
|
+
const cronJob = new croner_1.Cron(entry.cronExpression, {
|
|
410
|
+
paused: false,
|
|
411
|
+
protect: true, // Prevent overlapping
|
|
412
|
+
});
|
|
413
|
+
const state = {
|
|
414
|
+
scheduleId,
|
|
415
|
+
worktreeId: worktree.id,
|
|
416
|
+
cronJob,
|
|
417
|
+
isExecuting: false,
|
|
418
|
+
entry,
|
|
419
|
+
};
|
|
420
|
+
// Schedule execution
|
|
421
|
+
cronJob.schedule(() => {
|
|
422
|
+
void executeSchedule(state);
|
|
423
|
+
});
|
|
424
|
+
manager.schedules.set(scheduleId, state);
|
|
425
|
+
console.log(`[schedule-manager] Scheduled ${entry.name} (${entry.cronExpression})`);
|
|
426
|
+
}
|
|
427
|
+
catch (cronError) {
|
|
428
|
+
console.warn(`[schedule-manager] Invalid cron for ${entry.name}:`, cronError);
|
|
429
|
+
}
|
|
306
430
|
}
|
|
307
431
|
}
|
|
432
|
+
catch (error) {
|
|
433
|
+
console.error(`[schedule-manager] Error syncing schedules for worktree ${worktree.id}:`, error);
|
|
434
|
+
}
|
|
308
435
|
}
|
|
309
|
-
|
|
310
|
-
|
|
436
|
+
// Clean up schedules that no longer exist in CMATE.md
|
|
437
|
+
for (const [scheduleId, state] of manager.schedules) {
|
|
438
|
+
if (!activeScheduleIds.has(scheduleId)) {
|
|
439
|
+
state.cronJob.stop();
|
|
440
|
+
manager.schedules.delete(scheduleId);
|
|
441
|
+
console.log(`[schedule-manager] Removed stale schedule ${state.entry.name}`);
|
|
442
|
+
}
|
|
311
443
|
}
|
|
444
|
+
// Disable DB records for schedules no longer in CMATE.md
|
|
445
|
+
const worktreeIds = worktrees.map(w => w.id);
|
|
446
|
+
disableStaleSchedules(activeScheduleIds, worktreeIds);
|
|
312
447
|
}
|
|
313
|
-
|
|
314
|
-
|
|
315
|
-
if (!activeScheduleIds.has(scheduleId)) {
|
|
316
|
-
state.cronJob.stop();
|
|
317
|
-
manager.schedules.delete(scheduleId);
|
|
318
|
-
console.log(`[schedule-manager] Removed stale schedule ${state.entry.name}`);
|
|
319
|
-
}
|
|
448
|
+
finally {
|
|
449
|
+
manager.isSyncing = false;
|
|
320
450
|
}
|
|
321
|
-
// Disable DB records for schedules no longer in CMATE.md
|
|
322
|
-
const worktreeIds = worktrees.map(w => w.id);
|
|
323
|
-
disableStaleSchedules(activeScheduleIds, worktreeIds);
|
|
324
451
|
}
|
|
325
452
|
// =============================================================================
|
|
326
453
|
// Manager Lifecycle
|
|
@@ -340,11 +467,11 @@ function initScheduleManager() {
|
|
|
340
467
|
console.log('[schedule-manager] Initializing...');
|
|
341
468
|
// Recovery: mark stale running logs as failed
|
|
342
469
|
recoverRunningLogs();
|
|
343
|
-
// Initial sync
|
|
344
|
-
syncSchedules();
|
|
345
|
-
// Start periodic sync timer
|
|
470
|
+
// Initial sync (DJ-002: fire-and-forget, no .catch - fail-fast for fatal errors)
|
|
471
|
+
void syncSchedules();
|
|
472
|
+
// Start periodic sync timer (DJ-003: .catch for repeated execution safety)
|
|
346
473
|
manager.timerId = setInterval(() => {
|
|
347
|
-
syncSchedules();
|
|
474
|
+
void syncSchedules().catch(err => console.error('[schedule-manager] Unexpected sync error:', err));
|
|
348
475
|
}, exports.POLL_INTERVAL_MS);
|
|
349
476
|
manager.initialized = true;
|
|
350
477
|
console.log(`[schedule-manager] Initialized with ${manager.schedules.size} schedule(s)`);
|
|
@@ -372,6 +499,9 @@ function stopAllSchedules() {
|
|
|
372
499
|
}
|
|
373
500
|
}
|
|
374
501
|
manager.schedules.clear();
|
|
502
|
+
// DR1-008: Clear mtime cache to prevent stale values from causing
|
|
503
|
+
// incorrect skip decisions on next initScheduleManager() call
|
|
504
|
+
manager.cmateFileCache.clear();
|
|
375
505
|
// Kill all active child processes (fire-and-forget SIGKILL)
|
|
376
506
|
const activeProcesses = (0, claude_executor_1.getActiveProcesses)();
|
|
377
507
|
for (const [pid] of activeProcesses) {
|
|
@@ -386,6 +516,47 @@ function stopAllSchedules() {
|
|
|
386
516
|
manager.initialized = false;
|
|
387
517
|
console.log('[schedule-manager] All schedules stopped');
|
|
388
518
|
}
|
|
519
|
+
/**
|
|
520
|
+
* Stop schedules for a specific worktree.
|
|
521
|
+
* Issue #404: Used during worktree deletion to prevent resource leaks.
|
|
522
|
+
*
|
|
523
|
+
* Iterates the schedules map (O(N), N<=MAX_CONCURRENT_SCHEDULES=100),
|
|
524
|
+
* stops cron jobs for the target worktree, and removes their entries.
|
|
525
|
+
* Also removes the cmateFileCache entry for the worktree path (via DB lookup).
|
|
526
|
+
*
|
|
527
|
+
* activeProcesses are NOT killed (method (c): natural reclamation).
|
|
528
|
+
* cronJob.stop() prevents new executions; running processes finish naturally.
|
|
529
|
+
*
|
|
530
|
+
* @param worktreeId - The worktree ID whose schedules should be stopped
|
|
531
|
+
*/
|
|
532
|
+
function stopScheduleForWorktree(worktreeId) {
|
|
533
|
+
const manager = getManagerState();
|
|
534
|
+
// Stop and remove cron jobs for the target worktree
|
|
535
|
+
for (const [scheduleId, state] of manager.schedules) {
|
|
536
|
+
if (state.worktreeId === worktreeId) {
|
|
537
|
+
try {
|
|
538
|
+
state.cronJob.stop();
|
|
539
|
+
}
|
|
540
|
+
catch {
|
|
541
|
+
// Ignore errors during cleanup
|
|
542
|
+
}
|
|
543
|
+
manager.schedules.delete(scheduleId);
|
|
544
|
+
}
|
|
545
|
+
}
|
|
546
|
+
// Remove cmateFileCache entry via DB lookup (worktreeId -> path)
|
|
547
|
+
try {
|
|
548
|
+
const db = getLazyDbInstance();
|
|
549
|
+
const row = db.prepare('SELECT path FROM worktrees WHERE id = ?').get(worktreeId);
|
|
550
|
+
if (row?.path) {
|
|
551
|
+
manager.cmateFileCache.delete(row.path);
|
|
552
|
+
}
|
|
553
|
+
}
|
|
554
|
+
catch (error) {
|
|
555
|
+
// DB lookup failed - schedule stop already completed above (fallback)
|
|
556
|
+
console.warn(`[schedule-manager] Failed to resolve worktree path for cache cleanup (worktreeId: ${worktreeId}):`, error);
|
|
557
|
+
}
|
|
558
|
+
console.log(`[schedule-manager] Stopped schedules for worktree: ${worktreeId}`);
|
|
559
|
+
}
|
|
389
560
|
/**
|
|
390
561
|
* Get the current number of active schedules.
|
|
391
562
|
* Useful for monitoring and testing.
|
|
@@ -399,3 +570,18 @@ function getActiveScheduleCount() {
|
|
|
399
570
|
function isScheduleManagerInitialized() {
|
|
400
571
|
return getManagerState().initialized;
|
|
401
572
|
}
|
|
573
|
+
/**
|
|
574
|
+
* Get all unique worktree IDs that have active schedule entries.
|
|
575
|
+
* Used by periodic resource cleanup to detect orphaned entries.
|
|
576
|
+
*
|
|
577
|
+
* @internal Exported for resource-cleanup and testing purposes.
|
|
578
|
+
* @returns Array of unique worktree IDs present in the schedules Map
|
|
579
|
+
*/
|
|
580
|
+
function getScheduleWorktreeIds() {
|
|
581
|
+
const manager = getManagerState();
|
|
582
|
+
const worktreeIds = new Set();
|
|
583
|
+
for (const [, state] of manager.schedules) {
|
|
584
|
+
worktreeIds.add(state.worktreeId);
|
|
585
|
+
}
|
|
586
|
+
return Array.from(worktreeIds);
|
|
587
|
+
}
|
|
@@ -0,0 +1,221 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
/**
|
|
3
|
+
* tmux capture cache module
|
|
4
|
+
* Issue #405: TTL-based cache for tmux capture-pane output with singleflight deduplication
|
|
5
|
+
*
|
|
6
|
+
* Provides in-memory caching of tmux capture-pane results to eliminate redundant
|
|
7
|
+
* tmux process invocations. Uses globalThis pattern for Next.js hot reload persistence.
|
|
8
|
+
*
|
|
9
|
+
* Cache key: sessionName (mcbd-{cliToolId}-{worktreeId} format)
|
|
10
|
+
* Cache value: CacheEntry (output + metadata)
|
|
11
|
+
* TTL: 2 seconds (CACHE_TTL_MS)
|
|
12
|
+
*
|
|
13
|
+
* [SEC4-001] Trust Boundary: sessionName is validated by the caller chain
|
|
14
|
+
* (CLIToolManager.getTool(cliToolId).getSessionName()). This module does not
|
|
15
|
+
* perform additional sessionName validation.
|
|
16
|
+
*
|
|
17
|
+
* [DA3-002] Singleflight key uses sessionName, which contains cliToolId.
|
|
18
|
+
* Different cliToolIds cannot share the same singleflight entry, preventing
|
|
19
|
+
* error message context mismatch across callers.
|
|
20
|
+
*/
|
|
21
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
22
|
+
exports.CACHE_MAX_CAPTURE_LINES = exports.CACHE_MAX_ENTRIES = exports.CACHE_TTL_MS = void 0;
|
|
23
|
+
exports.sliceOutput = sliceOutput;
|
|
24
|
+
exports.getCachedCapture = getCachedCapture;
|
|
25
|
+
exports.setCachedCapture = setCachedCapture;
|
|
26
|
+
exports.invalidateCache = invalidateCache;
|
|
27
|
+
exports.clearAllCache = clearAllCache;
|
|
28
|
+
exports.resetCacheForTesting = resetCacheForTesting;
|
|
29
|
+
exports.getOrFetchCapture = getOrFetchCapture;
|
|
30
|
+
// =========================================================================
|
|
31
|
+
// Constants
|
|
32
|
+
// =========================================================================
|
|
33
|
+
/** Cache TTL in milliseconds */
|
|
34
|
+
exports.CACHE_TTL_MS = 2000;
|
|
35
|
+
/** Maximum number of cache entries */
|
|
36
|
+
exports.CACHE_MAX_ENTRIES = 100;
|
|
37
|
+
/** Maximum capture lines for cache storage */
|
|
38
|
+
exports.CACHE_MAX_CAPTURE_LINES = 10000;
|
|
39
|
+
function getCache() {
|
|
40
|
+
return (globalThis.__tmuxCaptureCache ??= new Map());
|
|
41
|
+
}
|
|
42
|
+
function getInflight() {
|
|
43
|
+
return (globalThis.__tmuxCaptureCacheInflight ??= new Map());
|
|
44
|
+
}
|
|
45
|
+
// =========================================================================
|
|
46
|
+
// sliceOutput()
|
|
47
|
+
// =========================================================================
|
|
48
|
+
/**
|
|
49
|
+
* Slice output from the end to return the last requestedLines lines.
|
|
50
|
+
* If requestedLines >= total lines, returns the full output.
|
|
51
|
+
*
|
|
52
|
+
* @param fullOutput - Full captured output string
|
|
53
|
+
* @param requestedLines - Number of lines requested from the end
|
|
54
|
+
* @returns Sliced output string
|
|
55
|
+
*/
|
|
56
|
+
function sliceOutput(fullOutput, requestedLines) {
|
|
57
|
+
if (fullOutput === '')
|
|
58
|
+
return '';
|
|
59
|
+
const lines = fullOutput.split('\n');
|
|
60
|
+
if (requestedLines >= lines.length)
|
|
61
|
+
return fullOutput;
|
|
62
|
+
return lines.slice(-requestedLines).join('\n');
|
|
63
|
+
}
|
|
64
|
+
// =========================================================================
|
|
65
|
+
// getCachedCapture()
|
|
66
|
+
// =========================================================================
|
|
67
|
+
/**
|
|
68
|
+
* Get cached capture output for a session.
|
|
69
|
+
* Returns null on cache miss, TTL expiration, or insufficient cached lines.
|
|
70
|
+
* Performs lazy eviction of TTL-expired entries.
|
|
71
|
+
*
|
|
72
|
+
* [SEC4-002] Lazy eviction only: expired entries that are never queried
|
|
73
|
+
* may persist in memory. Full sweep is performed in setCachedCapture().
|
|
74
|
+
*
|
|
75
|
+
* @param sessionName - tmux session name (cache key)
|
|
76
|
+
* @param requestedLines - Number of lines requested
|
|
77
|
+
* @returns Cached output (sliced to requestedLines) or null
|
|
78
|
+
*/
|
|
79
|
+
function getCachedCapture(sessionName, requestedLines) {
|
|
80
|
+
const cache = getCache();
|
|
81
|
+
const entry = cache.get(sessionName);
|
|
82
|
+
if (!entry)
|
|
83
|
+
return null;
|
|
84
|
+
// TTL check with lazy eviction
|
|
85
|
+
const now = Date.now();
|
|
86
|
+
if (now - entry.timestamp > exports.CACHE_TTL_MS) {
|
|
87
|
+
cache.delete(sessionName);
|
|
88
|
+
return null;
|
|
89
|
+
}
|
|
90
|
+
// Insufficient cached lines check
|
|
91
|
+
if (requestedLines > entry.capturedLines) {
|
|
92
|
+
return null;
|
|
93
|
+
}
|
|
94
|
+
return sliceOutput(entry.output, requestedLines);
|
|
95
|
+
}
|
|
96
|
+
// =========================================================================
|
|
97
|
+
// setCachedCapture()
|
|
98
|
+
// =========================================================================
|
|
99
|
+
/**
|
|
100
|
+
* Store capture output in cache.
|
|
101
|
+
* Performs full sweep of TTL-expired entries before writing (SEC4-002).
|
|
102
|
+
* Enforces CACHE_MAX_ENTRIES limit by evicting oldest entry.
|
|
103
|
+
*
|
|
104
|
+
* @param sessionName - tmux session name (cache key)
|
|
105
|
+
* @param output - Captured output string
|
|
106
|
+
* @param capturedLines - Number of lines the output was captured with
|
|
107
|
+
*/
|
|
108
|
+
function setCachedCapture(sessionName, output, capturedLines) {
|
|
109
|
+
const cache = getCache();
|
|
110
|
+
const now = Date.now();
|
|
111
|
+
// [SEC4-002] Full sweep: remove all TTL-expired entries
|
|
112
|
+
for (const [key, entry] of cache) {
|
|
113
|
+
if (now - entry.timestamp > exports.CACHE_TTL_MS) {
|
|
114
|
+
cache.delete(key);
|
|
115
|
+
}
|
|
116
|
+
}
|
|
117
|
+
// Enforce size limit (evict oldest if at capacity)
|
|
118
|
+
if (cache.size >= exports.CACHE_MAX_ENTRIES && !cache.has(sessionName)) {
|
|
119
|
+
// Find and delete oldest entry
|
|
120
|
+
let oldestKey = null;
|
|
121
|
+
let oldestTimestamp = Infinity;
|
|
122
|
+
for (const [key, entry] of cache) {
|
|
123
|
+
if (entry.timestamp < oldestTimestamp) {
|
|
124
|
+
oldestTimestamp = entry.timestamp;
|
|
125
|
+
oldestKey = key;
|
|
126
|
+
}
|
|
127
|
+
}
|
|
128
|
+
if (oldestKey) {
|
|
129
|
+
cache.delete(oldestKey);
|
|
130
|
+
}
|
|
131
|
+
}
|
|
132
|
+
cache.set(sessionName, {
|
|
133
|
+
output,
|
|
134
|
+
capturedLines,
|
|
135
|
+
timestamp: now,
|
|
136
|
+
});
|
|
137
|
+
}
|
|
138
|
+
// =========================================================================
|
|
139
|
+
// invalidateCache()
|
|
140
|
+
// =========================================================================
|
|
141
|
+
/**
|
|
142
|
+
* Invalidate cache for a specific session.
|
|
143
|
+
* [SEC4-006] Logs debug message with session name for troubleshooting.
|
|
144
|
+
*
|
|
145
|
+
* @param sessionName - tmux session name to invalidate
|
|
146
|
+
*/
|
|
147
|
+
function invalidateCache(sessionName) {
|
|
148
|
+
const cache = getCache();
|
|
149
|
+
cache.delete(sessionName);
|
|
150
|
+
// [SEC4-006] Debug log for cache invalidation chain tracking
|
|
151
|
+
console.debug('invalidateCache:', { sessionName });
|
|
152
|
+
}
|
|
153
|
+
// =========================================================================
|
|
154
|
+
// clearAllCache()
|
|
155
|
+
// =========================================================================
|
|
156
|
+
/**
|
|
157
|
+
* Clear all cache entries and inflight requests.
|
|
158
|
+
* Used for graceful shutdown.
|
|
159
|
+
*/
|
|
160
|
+
function clearAllCache() {
|
|
161
|
+
const cache = getCache();
|
|
162
|
+
cache.clear();
|
|
163
|
+
const inflight = getInflight();
|
|
164
|
+
inflight.clear();
|
|
165
|
+
}
|
|
166
|
+
// =========================================================================
|
|
167
|
+
// resetCacheForTesting()
|
|
168
|
+
// =========================================================================
|
|
169
|
+
/**
|
|
170
|
+
* Reset cache and inflight maps for test isolation.
|
|
171
|
+
* @internal Exported for testing purposes only.
|
|
172
|
+
*/
|
|
173
|
+
function resetCacheForTesting() {
|
|
174
|
+
globalThis.__tmuxCaptureCache = undefined;
|
|
175
|
+
globalThis.__tmuxCaptureCacheInflight = undefined;
|
|
176
|
+
}
|
|
177
|
+
// =========================================================================
|
|
178
|
+
// getOrFetchCapture() - singleflight pattern
|
|
179
|
+
// =========================================================================
|
|
180
|
+
/**
|
|
181
|
+
* Get capture output from cache or fetch it.
|
|
182
|
+
* Implements singleflight pattern: concurrent requests for the same session
|
|
183
|
+
* share a single fetchFn invocation.
|
|
184
|
+
*
|
|
185
|
+
* [DA3-002] Singleflight key is sessionName which contains cliToolId,
|
|
186
|
+
* preventing cross-cliTool error context mismatch.
|
|
187
|
+
*
|
|
188
|
+
* @param sessionName - tmux session name
|
|
189
|
+
* @param requestedLines - Number of lines requested
|
|
190
|
+
* @param fetchFn - Function to fetch output on cache miss
|
|
191
|
+
* @returns Captured output (sliced to requestedLines)
|
|
192
|
+
*/
|
|
193
|
+
async function getOrFetchCapture(sessionName, requestedLines, fetchFn) {
|
|
194
|
+
// 1. Check cache first
|
|
195
|
+
const cached = getCachedCapture(sessionName, requestedLines);
|
|
196
|
+
if (cached !== null) {
|
|
197
|
+
return cached;
|
|
198
|
+
}
|
|
199
|
+
// 2. Check for inflight request (singleflight)
|
|
200
|
+
const inflight = getInflight();
|
|
201
|
+
const existingPromise = inflight.get(sessionName);
|
|
202
|
+
if (existingPromise) {
|
|
203
|
+
const result = await existingPromise;
|
|
204
|
+
return sliceOutput(result, requestedLines);
|
|
205
|
+
}
|
|
206
|
+
// 3. Create new fetch promise
|
|
207
|
+
const fetchPromise = fetchFn();
|
|
208
|
+
inflight.set(sessionName, fetchPromise);
|
|
209
|
+
try {
|
|
210
|
+
const output = await fetchPromise;
|
|
211
|
+
// Cache non-empty results only [SEC4-007]
|
|
212
|
+
if (output.length > 0) {
|
|
213
|
+
setCachedCapture(sessionName, output, exports.CACHE_MAX_CAPTURE_LINES);
|
|
214
|
+
}
|
|
215
|
+
return sliceOutput(output, requestedLines);
|
|
216
|
+
}
|
|
217
|
+
finally {
|
|
218
|
+
// Clean up inflight entry
|
|
219
|
+
inflight.delete(sessionName);
|
|
220
|
+
}
|
|
221
|
+
}
|