@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.
Files changed (31) hide show
  1. package/README.md +13 -0
  2. package/dist/console/assets/index-QhCFuxQV.js +28 -0
  3. package/dist/console/assets/index-ibLhWBmX.css +1 -0
  4. package/dist/console/index.html +2 -2
  5. package/dist/infrastructure/storage/workflow-resolution.js +6 -6
  6. package/dist/manifest.json +44 -44
  7. package/dist/mcp/server.js +25 -4
  8. package/dist/mcp/tool-call-timing.d.ts +4 -0
  9. package/dist/mcp/tool-call-timing.js +52 -0
  10. package/dist/mcp-server.js +0 -0
  11. package/dist/types/workflow-source.js +1 -1
  12. package/dist/v2/durable-core/domain/observation-builder.d.ts +0 -3
  13. package/dist/v2/durable-core/domain/observation-builder.js +2 -2
  14. package/dist/v2/durable-core/domain/prompt-renderer.js +5 -1
  15. package/dist/v2/infra/local/data-dir/index.d.ts +1 -0
  16. package/dist/v2/infra/local/data-dir/index.js +3 -0
  17. package/dist/v2/infra/local/session-summary-provider/index.js +1 -2
  18. package/dist/v2/ports/data-dir.port.d.ts +1 -0
  19. package/dist/v2/projections/resume-ranking.d.ts +0 -1
  20. package/dist/v2/usecases/console-routes.d.ts +1 -1
  21. package/dist/v2/usecases/console-routes.js +126 -21
  22. package/dist/v2/usecases/console-service.js +4 -14
  23. package/dist/v2/usecases/console-types.d.ts +15 -1
  24. package/dist/v2/usecases/worktree-service.d.ts +1 -0
  25. package/dist/v2/usecases/worktree-service.js +143 -15
  26. package/package.json +3 -2
  27. package/workflows/coding-task-workflow-agentic.lean.v2.json +132 -1
  28. package/workflows/mr-review-workflow.agentic.v2.json +24 -10
  29. package/workflows/workflow-for-workflows.json +33 -5
  30. package/dist/console/assets/index-BZNM03t1.css +0 -1
  31. 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 observations = timingRingBuffer ? timingRingBuffer.recent(safeLimit) : [];
92
- res.json({ success: true, data: { observations, total: timingRingBuffer?.size ?? 0, devMode } });
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
- cwdRepoRootPromise ?? (cwdRepoRootPromise = (0, worktree_service_js_1.resolveRepoRoot)(process.cwd()));
111
- const cwdRoot = await cwdRepoRootPromise;
112
- const cutoffMs = Date.now() - REPO_ROOT_SESSION_STALENESS_MS;
113
- const rawRoots = sessions
114
- .filter(s => s.lastModifiedMs >= cutoffMs)
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
- repoRootSet.add(cwdRoot);
121
- cachedRepoRoots = [...repoRootSet];
212
+ repoRootsSet.add(cwdRoot);
213
+ cachedRepoRoots = [...repoRootsSet];
122
214
  repoRootsExpiresAt = Date.now() + REPO_ROOTS_TTL_MS;
123
215
  }
124
- const data = await (0, worktree_service_js_1.getWorktreeList)(cachedRepoRoots, activeSessions);
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
- res.status(500).json({ success: false, error: e instanceof Error ? e.message : String(e) });
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 = 3 * 24 * 60 * 60 * 1000;
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
- return e instanceof Error && 'killed' in e;
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(() => { activeEnrichments++; resolve(); });
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 branchDescription = descriptionRaw?.trim() ?? '';
126
- return { headHash, headMessage, headTimestampMs, changedCount, changedFiles, aheadCount, unpushedCommits, isMerged, branchDescription };
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 enrichRepo(repoRoot, activeSessions) {
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 acquireEnrichmentSlot();
190
+ await acquireBackgroundSlot();
141
191
  try {
142
192
  return await enrichWorktree(wt);
143
193
  }
144
194
  finally {
145
- releaseEnrichmentSlot();
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: wt.branch ? (activeSessions.counts.get(wt.branch) ?? 0) : 0,
168
- ...(e.branchDescription ? { description: e.branchDescription } : {}),
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
- async function getWorktreeList(repoRoots, activeSessions) {
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, activeSessions);
258
+ const worktrees = await enrichRepo(repoRoot);
191
259
  return { repoRoot, worktrees };
192
260
  }));
193
- const repos = repoResults.flatMap((result) => {
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
- const sortedRepos = [...repos].sort((a, b) => {
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.18.0",
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",