@exaudeus/workrail 3.18.0 → 3.19.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/README.md +13 -0
- package/dist/console/assets/index-QhCFuxQV.js +28 -0
- package/dist/console/assets/index-ibLhWBmX.css +1 -0
- package/dist/console/index.html +2 -2
- package/dist/infrastructure/storage/workflow-resolution.js +6 -6
- package/dist/manifest.json +44 -44
- package/dist/mcp/server.js +25 -4
- package/dist/mcp/tool-call-timing.d.ts +4 -0
- package/dist/mcp/tool-call-timing.js +52 -0
- package/dist/mcp-server.js +0 -0
- package/dist/types/workflow-source.js +1 -1
- package/dist/v2/durable-core/domain/observation-builder.d.ts +0 -3
- package/dist/v2/durable-core/domain/observation-builder.js +2 -2
- package/dist/v2/durable-core/domain/prompt-renderer.js +5 -1
- package/dist/v2/infra/local/data-dir/index.d.ts +1 -0
- package/dist/v2/infra/local/data-dir/index.js +3 -0
- package/dist/v2/infra/local/session-summary-provider/index.js +1 -2
- package/dist/v2/ports/data-dir.port.d.ts +1 -0
- package/dist/v2/projections/resume-ranking.d.ts +0 -1
- package/dist/v2/usecases/console-routes.d.ts +1 -1
- package/dist/v2/usecases/console-routes.js +126 -21
- package/dist/v2/usecases/console-service.js +4 -14
- package/dist/v2/usecases/console-types.d.ts +15 -1
- package/dist/v2/usecases/worktree-service.d.ts +1 -0
- package/dist/v2/usecases/worktree-service.js +143 -15
- package/package.json +3 -2
- package/workflows/coding-task-workflow-agentic.lean.v2.json +132 -1
- package/workflows/mr-review-workflow.agentic.v2.json +24 -10
- package/workflows/workflow-for-workflows.json +33 -5
- package/dist/console/assets/index-BZNM03t1.css +0 -1
- package/dist/console/assets/index-BwJelCXK.js +0 -28
|
@@ -8,6 +8,7 @@ const express_1 = __importDefault(require("express"));
|
|
|
8
8
|
const path_1 = __importDefault(require("path"));
|
|
9
9
|
const fs_1 = __importDefault(require("fs"));
|
|
10
10
|
const worktree_service_js_1 = require("./worktree-service.js");
|
|
11
|
+
const workflow_js_1 = require("../../types/workflow.js");
|
|
11
12
|
const dev_mode_js_1 = require("../../mcp/dev-mode.js");
|
|
12
13
|
function watchSessionsDir(sessionsDir, onChanged) {
|
|
13
14
|
try {
|
|
@@ -52,7 +53,7 @@ function loadWorkflowTags() {
|
|
|
52
53
|
return { version: 0, tags: [], workflows: {} };
|
|
53
54
|
}
|
|
54
55
|
}
|
|
55
|
-
function mountConsoleRoutes(app, consoleService, workflowService, timingRingBuffer) {
|
|
56
|
+
function mountConsoleRoutes(app, consoleService, workflowService, timingRingBuffer, toolCallsPerfFile, serverVersion) {
|
|
56
57
|
const sseClients = new Set();
|
|
57
58
|
let sseDebounceTimer = null;
|
|
58
59
|
function broadcastChange() {
|
|
@@ -71,6 +72,22 @@ function mountConsoleRoutes(app, consoleService, workflowService, timingRingBuff
|
|
|
71
72
|
}, 200);
|
|
72
73
|
}
|
|
73
74
|
const stopWatcher = watchSessionsDir(consoleService.getSessionsDir(), broadcastChange);
|
|
75
|
+
let enrichmentBroadcastTimer = null;
|
|
76
|
+
(0, worktree_service_js_1.setEnrichmentCompleteCallback)(() => {
|
|
77
|
+
if (enrichmentBroadcastTimer !== null)
|
|
78
|
+
clearTimeout(enrichmentBroadcastTimer);
|
|
79
|
+
enrichmentBroadcastTimer = setTimeout(() => {
|
|
80
|
+
enrichmentBroadcastTimer = null;
|
|
81
|
+
for (const client of sseClients) {
|
|
82
|
+
try {
|
|
83
|
+
client.write('data: {"type":"worktrees-updated"}\n\n');
|
|
84
|
+
}
|
|
85
|
+
catch {
|
|
86
|
+
sseClients.delete(client);
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
}, 2000);
|
|
90
|
+
});
|
|
74
91
|
app.get('/api/v2/workspace/events', (req, res) => {
|
|
75
92
|
res.setHeader('Content-Type', 'text/event-stream');
|
|
76
93
|
res.setHeader('Cache-Control', 'no-cache');
|
|
@@ -82,14 +99,71 @@ function mountConsoleRoutes(app, consoleService, workflowService, timingRingBuff
|
|
|
82
99
|
req.on('close', () => { sseClients.delete(res); });
|
|
83
100
|
res.on('close', () => { sseClients.delete(res); });
|
|
84
101
|
});
|
|
102
|
+
const THIRTY_DAYS_MS = 30 * 24 * 60 * 60 * 1000;
|
|
103
|
+
const PERF_FILE_READ_LIMIT_BYTES = 5 * 1024 * 1024;
|
|
104
|
+
async function readDiskEntries(perfFile) {
|
|
105
|
+
try {
|
|
106
|
+
const stat = await fs_1.default.promises.stat(perfFile);
|
|
107
|
+
let raw;
|
|
108
|
+
if (stat.size > PERF_FILE_READ_LIMIT_BYTES) {
|
|
109
|
+
const fd = await fs_1.default.promises.open(perfFile, 'r');
|
|
110
|
+
const offset = stat.size - PERF_FILE_READ_LIMIT_BYTES;
|
|
111
|
+
const buf = Buffer.alloc(PERF_FILE_READ_LIMIT_BYTES);
|
|
112
|
+
await fd.read(buf, 0, PERF_FILE_READ_LIMIT_BYTES, offset);
|
|
113
|
+
await fd.close();
|
|
114
|
+
raw = buf.toString('utf8');
|
|
115
|
+
}
|
|
116
|
+
else {
|
|
117
|
+
raw = await fs_1.default.promises.readFile(perfFile, 'utf8');
|
|
118
|
+
}
|
|
119
|
+
const cutoff = Date.now() - THIRTY_DAYS_MS;
|
|
120
|
+
return raw
|
|
121
|
+
.split('\n')
|
|
122
|
+
.filter(Boolean)
|
|
123
|
+
.flatMap((line) => {
|
|
124
|
+
try {
|
|
125
|
+
const entry = JSON.parse(line);
|
|
126
|
+
if (typeof entry.toolName !== 'string' ||
|
|
127
|
+
typeof entry.startedAtMs !== 'number' ||
|
|
128
|
+
typeof entry.durationMs !== 'number' ||
|
|
129
|
+
(entry.outcome !== 'success' && entry.outcome !== 'error' && entry.outcome !== 'unknown_tool'))
|
|
130
|
+
return [];
|
|
131
|
+
const safeEntry = typeof entry.serverVersion === 'string'
|
|
132
|
+
? entry
|
|
133
|
+
: { ...entry, serverVersion: 'unknown' };
|
|
134
|
+
if (safeEntry.startedAtMs < cutoff)
|
|
135
|
+
return [];
|
|
136
|
+
return [safeEntry];
|
|
137
|
+
}
|
|
138
|
+
catch {
|
|
139
|
+
return [];
|
|
140
|
+
}
|
|
141
|
+
});
|
|
142
|
+
}
|
|
143
|
+
catch {
|
|
144
|
+
return [];
|
|
145
|
+
}
|
|
146
|
+
}
|
|
85
147
|
const devMode = (0, dev_mode_js_1.isDevMode)();
|
|
86
148
|
if (devMode) {
|
|
87
|
-
app.get('/api/v2/perf/tool-calls', (req, res) => {
|
|
149
|
+
app.get('/api/v2/perf/tool-calls', async (req, res) => {
|
|
88
150
|
const rawLimit = req.query['limit'];
|
|
89
151
|
const limit = typeof rawLimit === 'string' ? parseInt(rawLimit, 10) : undefined;
|
|
90
152
|
const safeLimit = (limit !== undefined && Number.isFinite(limit) && limit > 0) ? limit : undefined;
|
|
91
|
-
const
|
|
92
|
-
|
|
153
|
+
const diskEntries = toolCallsPerfFile ? await readDiskEntries(toolCallsPerfFile) : [];
|
|
154
|
+
const ringEntries = timingRingBuffer ? timingRingBuffer.recent(safeLimit) : [];
|
|
155
|
+
const version = serverVersion ?? 'unknown';
|
|
156
|
+
const ringEntriesWithVersion = ringEntries.map((t) => ({
|
|
157
|
+
...t,
|
|
158
|
+
serverVersion: version,
|
|
159
|
+
}));
|
|
160
|
+
const dedupeKey = (e) => `${e.toolName}:${e.startedAtMs}:${e.durationMs}`;
|
|
161
|
+
const inMemoryKeys = new Set(ringEntriesWithVersion.map(dedupeKey));
|
|
162
|
+
const diskOnlyEntries = diskEntries.filter((e) => !inMemoryKeys.has(dedupeKey(e)));
|
|
163
|
+
const allEntries = [...ringEntriesWithVersion, ...diskOnlyEntries]
|
|
164
|
+
.sort((a, b) => b.startedAtMs - a.startedAtMs)
|
|
165
|
+
.slice(0, safeLimit ?? undefined);
|
|
166
|
+
res.json({ success: true, data: { observations: allEntries, devMode } });
|
|
93
167
|
});
|
|
94
168
|
}
|
|
95
169
|
app.get('/api/v2/sessions', async (_req, res) => {
|
|
@@ -97,35 +171,66 @@ function mountConsoleRoutes(app, consoleService, workflowService, timingRingBuff
|
|
|
97
171
|
result.match((data) => res.json({ success: true, data }), (error) => res.status(500).json({ success: false, error: error.message }));
|
|
98
172
|
});
|
|
99
173
|
let cwdRepoRootPromise = null;
|
|
100
|
-
const REPO_ROOTS_TTL_MS = 60000;
|
|
101
|
-
const REPO_ROOT_SESSION_STALENESS_MS = 30 * 24 * 60 * 60 * 1000;
|
|
102
174
|
let cachedRepoRoots = [];
|
|
103
175
|
let repoRootsExpiresAt = 0;
|
|
176
|
+
const REPO_ROOTS_TTL_MS = 60000;
|
|
177
|
+
async function discoverMainRepoRoots() {
|
|
178
|
+
const dataDir = process.env['WORKRAIL_DATA_DIR']
|
|
179
|
+
?? path_1.default.join(process.env.HOME ?? '/tmp', '.workrail', 'data');
|
|
180
|
+
const rootsFile = path_1.default.join(dataDir, 'workflow-sources', 'remembered-roots.json');
|
|
181
|
+
let workspacePaths = [];
|
|
182
|
+
try {
|
|
183
|
+
const raw = await fs_1.default.promises.readFile(rootsFile, 'utf8');
|
|
184
|
+
const parsed = JSON.parse(raw);
|
|
185
|
+
workspacePaths = (parsed.roots ?? []).map((r) => r.path).filter(Boolean);
|
|
186
|
+
}
|
|
187
|
+
catch {
|
|
188
|
+
return [];
|
|
189
|
+
}
|
|
190
|
+
const resolved = await Promise.all(workspacePaths.map((p) => (0, worktree_service_js_1.resolveRepoRoot)(p)));
|
|
191
|
+
const roots = new Set(resolved.filter((r) => r !== null));
|
|
192
|
+
return [...roots];
|
|
193
|
+
}
|
|
194
|
+
const WORKTREES_REQUEST_TIMEOUT_MS = 12000;
|
|
104
195
|
app.get('/api/v2/worktrees', async (_req, res) => {
|
|
196
|
+
let timeoutId = null;
|
|
197
|
+
const timeoutPromise = new Promise((_, reject) => {
|
|
198
|
+
timeoutId = setTimeout(() => reject(new Error('worktrees scan timeout')), WORKTREES_REQUEST_TIMEOUT_MS);
|
|
199
|
+
});
|
|
105
200
|
try {
|
|
106
201
|
const sessionResult = await consoleService.getSessionList();
|
|
107
202
|
const sessions = sessionResult.isOk() ? sessionResult.value.sessions : [];
|
|
108
203
|
const activeSessions = (0, worktree_service_js_1.buildActiveSessionCounts)(sessions);
|
|
204
|
+
cwdRepoRootPromise ?? (cwdRepoRootPromise = (0, worktree_service_js_1.resolveRepoRoot)(process.cwd()));
|
|
109
205
|
if (Date.now() > repoRootsExpiresAt) {
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
.map(s => s.repoRoot)
|
|
116
|
-
.filter((r) => r !== null);
|
|
117
|
-
const resolvedRoots = await Promise.all(rawRoots.map(r => (0, worktree_service_js_1.resolveRepoRoot)(r)));
|
|
118
|
-
const repoRootSet = new Set(resolvedRoots.filter((r) => r !== null));
|
|
206
|
+
const [cwdRoot, discovered] = await Promise.all([
|
|
207
|
+
cwdRepoRootPromise,
|
|
208
|
+
discoverMainRepoRoots(),
|
|
209
|
+
]);
|
|
210
|
+
const repoRootsSet = new Set(discovered);
|
|
119
211
|
if (cwdRoot !== null)
|
|
120
|
-
|
|
121
|
-
cachedRepoRoots = [...
|
|
212
|
+
repoRootsSet.add(cwdRoot);
|
|
213
|
+
cachedRepoRoots = [...repoRootsSet];
|
|
122
214
|
repoRootsExpiresAt = Date.now() + REPO_ROOTS_TTL_MS;
|
|
123
215
|
}
|
|
124
|
-
const
|
|
216
|
+
const repoRoots = cachedRepoRoots;
|
|
217
|
+
const data = await Promise.race([
|
|
218
|
+
(0, worktree_service_js_1.getWorktreeList)(repoRoots, activeSessions),
|
|
219
|
+
timeoutPromise,
|
|
220
|
+
]);
|
|
221
|
+
if (timeoutId !== null)
|
|
222
|
+
clearTimeout(timeoutId);
|
|
125
223
|
res.json({ success: true, data });
|
|
126
224
|
}
|
|
127
225
|
catch (e) {
|
|
128
|
-
|
|
226
|
+
if (timeoutId !== null)
|
|
227
|
+
clearTimeout(timeoutId);
|
|
228
|
+
if (e instanceof Error && e.message === 'worktrees scan timeout') {
|
|
229
|
+
res.json({ success: true, data: { repos: [] } });
|
|
230
|
+
}
|
|
231
|
+
else {
|
|
232
|
+
res.status(500).json({ success: false, error: e instanceof Error ? e.message : String(e) });
|
|
233
|
+
}
|
|
129
234
|
}
|
|
130
235
|
});
|
|
131
236
|
app.get('/api/v2/sessions/:sessionId', async (req, res) => {
|
|
@@ -162,7 +267,7 @@ function mountConsoleRoutes(app, consoleService, workflowService, timingRingBuff
|
|
|
162
267
|
description: definition.description,
|
|
163
268
|
version: definition.version,
|
|
164
269
|
tags: tagEntry?.tags ?? [],
|
|
165
|
-
source,
|
|
270
|
+
source: (0, workflow_js_1.toWorkflowSourceInfo)(source),
|
|
166
271
|
...(definition.about !== undefined ? { about: definition.about } : {}),
|
|
167
272
|
...(definition.examples?.length ? { examples: [...definition.examples] } : {}),
|
|
168
273
|
};
|
|
@@ -194,7 +299,7 @@ function mountConsoleRoutes(app, consoleService, workflowService, timingRingBuff
|
|
|
194
299
|
description: definition.description,
|
|
195
300
|
version: definition.version,
|
|
196
301
|
tags: tagEntry?.tags ?? [],
|
|
197
|
-
source,
|
|
302
|
+
source: (0, workflow_js_1.toWorkflowSourceInfo)(source),
|
|
198
303
|
stepCount: definition.steps.length,
|
|
199
304
|
...(definition.about !== undefined ? { about: definition.about } : {}),
|
|
200
305
|
...(definition.examples?.length ? { examples: [...definition.examples] } : {}),
|
|
@@ -16,7 +16,10 @@ const run_execution_trace_js_1 = require("../projections/run-execution-trace.js"
|
|
|
16
16
|
const constants_js_1 = require("../durable-core/constants.js");
|
|
17
17
|
const index_js_1 = require("../durable-core/ids/index.js");
|
|
18
18
|
const MAX_SESSIONS_TO_LOAD = 500;
|
|
19
|
-
const DORMANCY_THRESHOLD_MS =
|
|
19
|
+
const DORMANCY_THRESHOLD_MS = (() => {
|
|
20
|
+
const override = parseInt(process.env['WORKRAIL_DORMANCY_THRESHOLD_MS'] ?? '', 10);
|
|
21
|
+
return Number.isFinite(override) && override > 0 ? override : 60 * 60 * 1000;
|
|
22
|
+
})();
|
|
20
23
|
class ConsoleService {
|
|
21
24
|
constructor(ports) {
|
|
22
25
|
this.ports = ports;
|
|
@@ -337,16 +340,6 @@ function extractGitBranch(events) {
|
|
|
337
340
|
}
|
|
338
341
|
return null;
|
|
339
342
|
}
|
|
340
|
-
function extractRepoRoot(events) {
|
|
341
|
-
for (const e of events) {
|
|
342
|
-
if (e.kind !== constants_js_1.EVENT_KIND.OBSERVATION_RECORDED)
|
|
343
|
-
continue;
|
|
344
|
-
if (e.data.key === 'repo_root') {
|
|
345
|
-
return e.data.value.value;
|
|
346
|
-
}
|
|
347
|
-
}
|
|
348
|
-
return null;
|
|
349
|
-
}
|
|
350
343
|
function truncateTitle(text, maxLen = 120) {
|
|
351
344
|
if (text.length <= maxLen)
|
|
352
345
|
return text;
|
|
@@ -373,7 +366,6 @@ function projectSessionSummary(sessionId, truth, completionByRunId, workflowName
|
|
|
373
366
|
const gapsRes = sortedEventsRes.isOk() ? (0, gaps_js_1.projectGapsV2)(sortedEventsRes.value) : (0, neverthrow_2.err)(sortedEventsRes.error);
|
|
374
367
|
const sessionTitle = sortedEventsRes.isOk() ? deriveSessionTitle(sortedEventsRes.value) : null;
|
|
375
368
|
const gitBranch = extractGitBranch(events);
|
|
376
|
-
const repoRoot = extractRepoRoot(events);
|
|
377
369
|
const runs = Object.values(dag.runsById);
|
|
378
370
|
const run = runs[0];
|
|
379
371
|
if (!run) {
|
|
@@ -393,7 +385,6 @@ function projectSessionSummary(sessionId, truth, completionByRunId, workflowName
|
|
|
393
385
|
hasUnresolvedGaps: false,
|
|
394
386
|
recapSnippet: null,
|
|
395
387
|
gitBranch,
|
|
396
|
-
repoRoot,
|
|
397
388
|
lastModifiedMs,
|
|
398
389
|
};
|
|
399
390
|
}
|
|
@@ -436,7 +427,6 @@ function projectSessionSummary(sessionId, truth, completionByRunId, workflowName
|
|
|
436
427
|
hasUnresolvedGaps,
|
|
437
428
|
recapSnippet,
|
|
438
429
|
gitBranch,
|
|
439
|
-
repoRoot,
|
|
440
430
|
lastModifiedMs,
|
|
441
431
|
};
|
|
442
432
|
}
|
|
@@ -16,7 +16,6 @@ export interface ConsoleSessionSummary {
|
|
|
16
16
|
readonly hasUnresolvedGaps: boolean;
|
|
17
17
|
readonly recapSnippet: string | null;
|
|
18
18
|
readonly gitBranch: string | null;
|
|
19
|
-
readonly repoRoot: string | null;
|
|
20
19
|
readonly lastModifiedMs: number;
|
|
21
20
|
}
|
|
22
21
|
export interface ConsoleSessionListResponse {
|
|
@@ -112,6 +111,20 @@ export interface ChangedFile {
|
|
|
112
111
|
readonly status: FileChangeStatus;
|
|
113
112
|
readonly path: string;
|
|
114
113
|
}
|
|
114
|
+
export interface WorktreeEnrichment {
|
|
115
|
+
readonly headHash: string;
|
|
116
|
+
readonly headMessage: string;
|
|
117
|
+
readonly headTimestampMs: number;
|
|
118
|
+
readonly changedCount: number;
|
|
119
|
+
readonly changedFiles: readonly ChangedFile[];
|
|
120
|
+
readonly aheadCount: number;
|
|
121
|
+
readonly unpushedCommits: readonly {
|
|
122
|
+
readonly hash: string;
|
|
123
|
+
readonly message: string;
|
|
124
|
+
}[];
|
|
125
|
+
readonly isMerged: boolean;
|
|
126
|
+
readonly description: string;
|
|
127
|
+
}
|
|
115
128
|
export interface ConsoleWorktreeSummary {
|
|
116
129
|
readonly path: string;
|
|
117
130
|
readonly name: string;
|
|
@@ -129,6 +142,7 @@ export interface ConsoleWorktreeSummary {
|
|
|
129
142
|
readonly isMerged: boolean;
|
|
130
143
|
readonly activeSessionCount: number;
|
|
131
144
|
readonly description?: string;
|
|
145
|
+
readonly enrichment: WorktreeEnrichment | null;
|
|
132
146
|
}
|
|
133
147
|
export interface ConsoleRepoWorktrees {
|
|
134
148
|
readonly repoName: string;
|
|
@@ -7,4 +7,5 @@ export declare function buildActiveSessionCounts(sessions: ReadonlyArray<{
|
|
|
7
7
|
gitBranch: string | null;
|
|
8
8
|
status: ConsoleSessionStatus;
|
|
9
9
|
}>): ActiveSessionsByBranch;
|
|
10
|
+
export declare function setEnrichmentCompleteCallback(cb: () => void): void;
|
|
10
11
|
export declare function getWorktreeList(repoRoots: readonly string[], activeSessions: ActiveSessionsByBranch): Promise<ConsoleWorktreeListResponse>;
|
|
@@ -2,6 +2,7 @@
|
|
|
2
2
|
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
3
|
exports.resolveRepoRoot = resolveRepoRoot;
|
|
4
4
|
exports.buildActiveSessionCounts = buildActiveSessionCounts;
|
|
5
|
+
exports.setEnrichmentCompleteCallback = setEnrichmentCompleteCallback;
|
|
5
6
|
exports.getWorktreeList = getWorktreeList;
|
|
6
7
|
const child_process_1 = require("child_process");
|
|
7
8
|
const util_1 = require("util");
|
|
@@ -9,7 +10,12 @@ const path_1 = require("path");
|
|
|
9
10
|
const execFileAsync = (0, util_1.promisify)(child_process_1.execFile);
|
|
10
11
|
const GIT_TIMEOUT_MS = 5000;
|
|
11
12
|
function isExecError(e) {
|
|
12
|
-
|
|
13
|
+
if (!(e instanceof Error))
|
|
14
|
+
return false;
|
|
15
|
+
if ('killed' in e)
|
|
16
|
+
return true;
|
|
17
|
+
const sys = e.syscall ?? '';
|
|
18
|
+
return sys.startsWith('spawn');
|
|
13
19
|
}
|
|
14
20
|
async function git(cwd, args) {
|
|
15
21
|
try {
|
|
@@ -54,7 +60,7 @@ function acquireEnrichmentSlot() {
|
|
|
54
60
|
resolve();
|
|
55
61
|
}
|
|
56
62
|
else {
|
|
57
|
-
enrichmentQueue.push(
|
|
63
|
+
enrichmentQueue.push(resolve);
|
|
58
64
|
}
|
|
59
65
|
});
|
|
60
66
|
}
|
|
@@ -67,6 +73,29 @@ function releaseEnrichmentSlot() {
|
|
|
67
73
|
activeEnrichments--;
|
|
68
74
|
}
|
|
69
75
|
}
|
|
76
|
+
const MAX_BACKGROUND_ENRICHMENTS = 16;
|
|
77
|
+
let activeBackgroundEnrichments = 0;
|
|
78
|
+
const backgroundEnrichmentQueue = [];
|
|
79
|
+
function acquireBackgroundSlot() {
|
|
80
|
+
return new Promise((resolve) => {
|
|
81
|
+
if (activeBackgroundEnrichments < MAX_BACKGROUND_ENRICHMENTS) {
|
|
82
|
+
activeBackgroundEnrichments++;
|
|
83
|
+
resolve();
|
|
84
|
+
}
|
|
85
|
+
else {
|
|
86
|
+
backgroundEnrichmentQueue.push(resolve);
|
|
87
|
+
}
|
|
88
|
+
});
|
|
89
|
+
}
|
|
90
|
+
function releaseBackgroundSlot() {
|
|
91
|
+
const next = backgroundEnrichmentQueue.shift();
|
|
92
|
+
if (next) {
|
|
93
|
+
next();
|
|
94
|
+
}
|
|
95
|
+
else {
|
|
96
|
+
activeBackgroundEnrichments--;
|
|
97
|
+
}
|
|
98
|
+
}
|
|
70
99
|
function parseFileStatus(xy) {
|
|
71
100
|
if (xy === '??')
|
|
72
101
|
return 'untracked';
|
|
@@ -122,8 +151,8 @@ async function enrichWorktree(wt) {
|
|
|
122
151
|
wt.branch !== 'main' &&
|
|
123
152
|
mergedBranchesRaw !== null &&
|
|
124
153
|
mergedBranchesRaw.split('\n').some(line => line.trim() === wt.branch);
|
|
125
|
-
const
|
|
126
|
-
return { headHash, headMessage, headTimestampMs, changedCount, changedFiles, aheadCount, unpushedCommits, isMerged,
|
|
154
|
+
const description = descriptionRaw?.trim() ?? '';
|
|
155
|
+
return { headHash, headMessage, headTimestampMs, changedCount, changedFiles, aheadCount, unpushedCommits, isMerged, description };
|
|
127
156
|
}
|
|
128
157
|
async function resolveRepoRoot(path) {
|
|
129
158
|
const commonDir = await git(path, ['rev-parse', '--path-format=absolute', '--git-common-dir']);
|
|
@@ -131,18 +160,39 @@ async function resolveRepoRoot(path) {
|
|
|
131
160
|
return null;
|
|
132
161
|
return commonDir.replace(/\/\.git\/?$/, '') || null;
|
|
133
162
|
}
|
|
134
|
-
async function
|
|
163
|
+
async function buildFastWorktrees(repoRoot) {
|
|
164
|
+
const porcelain = await git(repoRoot, ['worktree', 'list', '--porcelain']);
|
|
165
|
+
if (porcelain === null)
|
|
166
|
+
return null;
|
|
167
|
+
const rawWorktrees = parseWorktreePorcelain(porcelain);
|
|
168
|
+
return rawWorktrees.map((wt) => ({
|
|
169
|
+
path: wt.path,
|
|
170
|
+
name: (0, path_1.basename)(wt.path),
|
|
171
|
+
branch: wt.branch,
|
|
172
|
+
headHash: wt.head.slice(0, 7),
|
|
173
|
+
headMessage: '',
|
|
174
|
+
headTimestampMs: 0,
|
|
175
|
+
changedCount: 0,
|
|
176
|
+
changedFiles: [],
|
|
177
|
+
aheadCount: 0,
|
|
178
|
+
unpushedCommits: [],
|
|
179
|
+
isMerged: false,
|
|
180
|
+
activeSessionCount: 0,
|
|
181
|
+
enrichment: null,
|
|
182
|
+
}));
|
|
183
|
+
}
|
|
184
|
+
async function enrichRepo(repoRoot) {
|
|
135
185
|
const porcelain = await git(repoRoot, ['worktree', 'list', '--porcelain']);
|
|
136
186
|
if (porcelain === null)
|
|
137
187
|
return null;
|
|
138
188
|
const rawWorktrees = parseWorktreePorcelain(porcelain);
|
|
139
189
|
const results = await Promise.allSettled(rawWorktrees.map(async (wt) => {
|
|
140
|
-
await
|
|
190
|
+
await acquireBackgroundSlot();
|
|
141
191
|
try {
|
|
142
192
|
return await enrichWorktree(wt);
|
|
143
193
|
}
|
|
144
194
|
finally {
|
|
145
|
-
|
|
195
|
+
releaseBackgroundSlot();
|
|
146
196
|
}
|
|
147
197
|
}));
|
|
148
198
|
const worktrees = rawWorktrees.flatMap((wt, i) => {
|
|
@@ -152,6 +202,17 @@ async function enrichRepo(repoRoot, activeSessions) {
|
|
|
152
202
|
return [];
|
|
153
203
|
}
|
|
154
204
|
const e = result.value;
|
|
205
|
+
const enrichment = {
|
|
206
|
+
headHash: e.headHash,
|
|
207
|
+
headMessage: e.headMessage,
|
|
208
|
+
headTimestampMs: e.headTimestampMs,
|
|
209
|
+
changedCount: e.changedCount,
|
|
210
|
+
changedFiles: e.changedFiles,
|
|
211
|
+
aheadCount: e.aheadCount,
|
|
212
|
+
unpushedCommits: e.unpushedCommits,
|
|
213
|
+
isMerged: e.isMerged,
|
|
214
|
+
description: e.description,
|
|
215
|
+
};
|
|
155
216
|
return [{
|
|
156
217
|
path: wt.path,
|
|
157
218
|
name: (0, path_1.basename)(wt.path),
|
|
@@ -164,13 +225,12 @@ async function enrichRepo(repoRoot, activeSessions) {
|
|
|
164
225
|
aheadCount: e.aheadCount,
|
|
165
226
|
unpushedCommits: e.unpushedCommits,
|
|
166
227
|
isMerged: e.isMerged,
|
|
167
|
-
activeSessionCount:
|
|
168
|
-
...(e.
|
|
228
|
+
activeSessionCount: 0,
|
|
229
|
+
...(e.description ? { description: e.description } : {}),
|
|
230
|
+
enrichment,
|
|
169
231
|
}];
|
|
170
232
|
});
|
|
171
233
|
return [...worktrees].sort((a, b) => {
|
|
172
|
-
if (b.activeSessionCount !== a.activeSessionCount)
|
|
173
|
-
return b.activeSessionCount - a.activeSessionCount;
|
|
174
234
|
if (b.changedCount !== a.changedCount)
|
|
175
235
|
return b.changedCount - a.changedCount;
|
|
176
236
|
return b.headTimestampMs - a.headTimestampMs;
|
|
@@ -185,12 +245,20 @@ function buildActiveSessionCounts(sessions) {
|
|
|
185
245
|
}
|
|
186
246
|
return { counts };
|
|
187
247
|
}
|
|
188
|
-
|
|
248
|
+
const WORKTREE_CACHE_TTL_MS = 45000;
|
|
249
|
+
let worktreeCache = null;
|
|
250
|
+
let backgroundEnrichmentInFlight = false;
|
|
251
|
+
const BACKGROUND_ENRICHMENT_TIMEOUT_MS = 120000;
|
|
252
|
+
let onEnrichmentComplete = null;
|
|
253
|
+
function setEnrichmentCompleteCallback(cb) {
|
|
254
|
+
onEnrichmentComplete = cb;
|
|
255
|
+
}
|
|
256
|
+
async function scanRepos(repoRoots) {
|
|
189
257
|
const repoResults = await Promise.allSettled(repoRoots.map(async (repoRoot) => {
|
|
190
|
-
const worktrees = await enrichRepo(repoRoot
|
|
258
|
+
const worktrees = await enrichRepo(repoRoot);
|
|
191
259
|
return { repoRoot, worktrees };
|
|
192
260
|
}));
|
|
193
|
-
|
|
261
|
+
return repoResults.flatMap((result) => {
|
|
194
262
|
if (result.status === 'rejected') {
|
|
195
263
|
console.warn(`[WorktreeService] Failed to enrich repo:`, result.reason);
|
|
196
264
|
return [];
|
|
@@ -204,7 +272,33 @@ async function getWorktreeList(repoRoots, activeSessions) {
|
|
|
204
272
|
worktrees,
|
|
205
273
|
}];
|
|
206
274
|
});
|
|
207
|
-
|
|
275
|
+
}
|
|
276
|
+
async function runBackgroundEnrichment(repoRoots, repoRootsKey) {
|
|
277
|
+
try {
|
|
278
|
+
const enriched = await Promise.race([
|
|
279
|
+
scanRepos(repoRoots),
|
|
280
|
+
new Promise((_, reject) => setTimeout(() => reject(new Error('background enrichment timeout')), BACKGROUND_ENRICHMENT_TIMEOUT_MS)),
|
|
281
|
+
]);
|
|
282
|
+
if (worktreeCache?.repoRootsKey === repoRootsKey) {
|
|
283
|
+
worktreeCache = { ...worktreeCache, enrichedRepos: enriched };
|
|
284
|
+
onEnrichmentComplete?.();
|
|
285
|
+
}
|
|
286
|
+
}
|
|
287
|
+
catch {
|
|
288
|
+
}
|
|
289
|
+
finally {
|
|
290
|
+
backgroundEnrichmentInFlight = false;
|
|
291
|
+
}
|
|
292
|
+
}
|
|
293
|
+
function applyActiveSessionsAndSort(repos, activeSessions) {
|
|
294
|
+
const reposWithActiveSessions = repos.map((repo) => ({
|
|
295
|
+
...repo,
|
|
296
|
+
worktrees: repo.worktrees.map((wt) => ({
|
|
297
|
+
...wt,
|
|
298
|
+
activeSessionCount: wt.branch ? (activeSessions.counts.get(wt.branch) ?? 0) : 0,
|
|
299
|
+
})),
|
|
300
|
+
}));
|
|
301
|
+
const sortedRepos = [...reposWithActiveSessions].sort((a, b) => {
|
|
208
302
|
const aActive = a.worktrees.some(w => w.activeSessionCount > 0) ? 0 : 1;
|
|
209
303
|
const bActive = b.worktrees.some(w => w.activeSessionCount > 0) ? 0 : 1;
|
|
210
304
|
if (aActive !== bActive)
|
|
@@ -213,3 +307,37 @@ async function getWorktreeList(repoRoots, activeSessions) {
|
|
|
213
307
|
});
|
|
214
308
|
return { repos: sortedRepos };
|
|
215
309
|
}
|
|
310
|
+
async function getWorktreeList(repoRoots, activeSessions) {
|
|
311
|
+
const repoRootsKey = [...repoRoots].sort().join(',');
|
|
312
|
+
const nowMs = Date.now();
|
|
313
|
+
const isCacheValid = worktreeCache !== null &&
|
|
314
|
+
worktreeCache.repoRootsKey === repoRootsKey &&
|
|
315
|
+
nowMs - worktreeCache.cachedAtMs < WORKTREE_CACHE_TTL_MS;
|
|
316
|
+
if (!isCacheValid) {
|
|
317
|
+
const fastRepoResults = await Promise.allSettled(repoRoots.map(async (repoRoot) => {
|
|
318
|
+
const worktrees = await buildFastWorktrees(repoRoot);
|
|
319
|
+
return { repoRoot, worktrees };
|
|
320
|
+
}));
|
|
321
|
+
const fastRepos = fastRepoResults.flatMap((result) => {
|
|
322
|
+
if (result.status === 'rejected')
|
|
323
|
+
return [];
|
|
324
|
+
const { repoRoot, worktrees } = result.value;
|
|
325
|
+
if (!worktrees || worktrees.length === 0)
|
|
326
|
+
return [];
|
|
327
|
+
return [{ repoName: (0, path_1.basename)(repoRoot), repoRoot, worktrees }];
|
|
328
|
+
});
|
|
329
|
+
worktreeCache = {
|
|
330
|
+
unenrichedRepos: fastRepos,
|
|
331
|
+
enrichedRepos: null,
|
|
332
|
+
cachedAtMs: nowMs,
|
|
333
|
+
repoRootsKey,
|
|
334
|
+
};
|
|
335
|
+
if (!backgroundEnrichmentInFlight) {
|
|
336
|
+
backgroundEnrichmentInFlight = true;
|
|
337
|
+
void runBackgroundEnrichment(repoRoots, repoRootsKey);
|
|
338
|
+
}
|
|
339
|
+
}
|
|
340
|
+
const cache = worktreeCache;
|
|
341
|
+
const repos = cache.enrichedRepos ?? cache.unenrichedRepos;
|
|
342
|
+
return applyActiveSessionsAndSort(repos, activeSessions);
|
|
343
|
+
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@exaudeus/workrail",
|
|
3
|
-
"version": "3.
|
|
3
|
+
"version": "3.19.0",
|
|
4
4
|
"description": "Step-by-step workflow enforcement for AI agents via MCP",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"repository": {
|
|
@@ -28,7 +28,8 @@
|
|
|
28
28
|
"web"
|
|
29
29
|
],
|
|
30
30
|
"scripts": {
|
|
31
|
-
"build": "node -e \"require('fs').rmSync('dist',{recursive:true,force:true});\" && tsc -p tsconfig.build.json && npm run console:build",
|
|
31
|
+
"build": "node -e \"require('fs').rmSync('dist',{recursive:true,force:true});\" && tsc -p tsconfig.build.json && npm run console:build && node -e \"require('fs').chmodSync('dist/mcp-server.js',0o755)\"",
|
|
32
|
+
"prepack": "node -e \"require('fs').chmodSync('dist/mcp-server.js',0o755)\"",
|
|
32
33
|
"console:build": "cd console && npm install && npm run build",
|
|
33
34
|
"console:dev": "cd console && npm run dev",
|
|
34
35
|
"build:all": "npm run build",
|