@adhdev/daemon-core 0.9.77-rc.51 → 0.9.77-rc.52

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.
@@ -0,0 +1,70 @@
1
+ /**
2
+ * Mesh Visualization — Transform RepoMeshStatus into a graph structure
3
+ * for SVG/Canvas rendering without external dependencies.
4
+ */
5
+ import type { RepoMeshStatus, RepoMeshNodeStatus, RepoMeshNodeHealth } from '../repo-mesh-types.js';
6
+ export type MeshGraphNodeType = 'defaultBranchNode' | 'worktreeNode' | 'orphanNode';
7
+ export type MeshGraphEdgeType = 'parentBranch' | 'worktreeLink' | 'sessionLink';
8
+ export interface MeshGraphNode {
9
+ id: string;
10
+ type: MeshGraphNodeType;
11
+ label: string;
12
+ workspace: string;
13
+ branch: string | null;
14
+ health: RepoMeshNodeHealth;
15
+ ahead: number;
16
+ behind: number;
17
+ dirty: boolean;
18
+ dirtyFiles: number;
19
+ hasConflicts: boolean;
20
+ activeSessionCount: number;
21
+ activeSessions: string[];
22
+ providers: string[];
23
+ isOrphan: boolean;
24
+ orphanReasons: string[];
25
+ /** Next-step hint from convergence analysis */
26
+ nextStepHint?: string;
27
+ /** Original node status for drill-down */
28
+ source: RepoMeshNodeStatus;
29
+ }
30
+ export interface MeshGraphEdge {
31
+ id: string;
32
+ source: string;
33
+ target: string;
34
+ type: MeshGraphEdgeType;
35
+ label?: string;
36
+ }
37
+ export interface MeshGraph {
38
+ meshId: string;
39
+ meshName: string;
40
+ repoIdentity: string;
41
+ refreshedAt: string;
42
+ nodes: MeshGraphNode[];
43
+ edges: MeshGraphEdge[];
44
+ /** Summary statistics */
45
+ stats: {
46
+ totalNodes: number;
47
+ onlineNodes: number;
48
+ dirtyNodes: number;
49
+ orphanNodes: number;
50
+ errorNodes: number;
51
+ offlineNodes: number;
52
+ totalActiveSessions: number;
53
+ };
54
+ /** Global orphan / stale warnings */
55
+ warnings: string[];
56
+ }
57
+ /**
58
+ * Build a visualization graph from RepoMeshStatus.
59
+ *
60
+ * Nodes:
61
+ * - defaultBranchNode: the mesh's default branch (aggregated from nodes on that branch)
62
+ * - worktreeNode: a node on a feature / worktree branch
63
+ * - orphanNode: a node with orphanReasons (upstream missing, detached HEAD, etc.)
64
+ *
65
+ * Edges:
66
+ * - parentBranch: default branch → worktree branch (when branch name differs)
67
+ * - worktreeLink: links nodes that share the same branch (clustering hint)
68
+ * - sessionLink: node → session (lightweight, optional; not rendered as primary edge)
69
+ */
70
+ export declare function buildMeshGraph(status: RepoMeshStatus): MeshGraph;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@adhdev/daemon-core",
3
- "version": "0.9.77-rc.51",
3
+ "version": "0.9.77-rc.52",
4
4
  "description": "ADHDev daemon core — CDP, IDE detection, providers, command execution",
5
5
  "main": "dist/index.js",
6
6
  "types": "dist/index.d.ts",
package/src/index.ts CHANGED
@@ -159,6 +159,10 @@ export type { MeshLedgerReconciliationEvidence, MeshLedgerReplicaEvidence, MeshL
159
159
  export { enqueueTask, getQueue, claimNextTask, updateTaskStatus, updateSessionTaskStatus, cancelTask, requeueTask, getMeshQueueStats } from './mesh/mesh-work-queue.js';
160
160
  export type { MeshWorkQueueEntry, MeshTaskStatus, MeshWorkQueueStats } from './mesh/mesh-work-queue.js';
161
161
 
162
+ // ── Mesh Visualization ──
163
+ export { buildMeshGraph } from './mesh/mesh-visualization.js';
164
+ export type { MeshGraph, MeshGraphNode, MeshGraphEdge, MeshGraphNodeType, MeshGraphEdgeType } from './mesh/mesh-visualization.js';
165
+
162
166
  // ── Mesh Events ──
163
167
  export { triggerMeshQueue, drainPendingMeshCoordinatorEvents, getPendingMeshCoordinatorEvents, clearPendingMeshCoordinatorEvents } from './mesh/mesh-events.js';
164
168
  export type { PendingMeshCoordinatorEvent } from './mesh/mesh-events.js';
@@ -0,0 +1,341 @@
1
+ /**
2
+ * Mesh Visualization — Transform RepoMeshStatus into a graph structure
3
+ * for SVG/Canvas rendering without external dependencies.
4
+ */
5
+
6
+ import type { RepoMeshStatus, RepoMeshNodeStatus, RepoMeshNodeHealth } from '../repo-mesh-types.js';
7
+ import type { GitRepoStatus } from '../git/git-types.js';
8
+
9
+ // ─── Graph Types ─────────────────────────────────
10
+
11
+ export type MeshGraphNodeType = 'defaultBranchNode' | 'worktreeNode' | 'orphanNode';
12
+ export type MeshGraphEdgeType = 'parentBranch' | 'worktreeLink' | 'sessionLink';
13
+
14
+ export interface MeshGraphNode {
15
+ id: string;
16
+ type: MeshGraphNodeType;
17
+ label: string;
18
+ workspace: string;
19
+ branch: string | null;
20
+ health: RepoMeshNodeHealth;
21
+ ahead: number;
22
+ behind: number;
23
+ dirty: boolean;
24
+ dirtyFiles: number;
25
+ hasConflicts: boolean;
26
+ activeSessionCount: number;
27
+ activeSessions: string[];
28
+ providers: string[];
29
+ isOrphan: boolean;
30
+ orphanReasons: string[];
31
+ /** Next-step hint from convergence analysis */
32
+ nextStepHint?: string;
33
+ /** Original node status for drill-down */
34
+ source: RepoMeshNodeStatus;
35
+ }
36
+
37
+ export interface MeshGraphEdge {
38
+ id: string;
39
+ source: string;
40
+ target: string;
41
+ type: MeshGraphEdgeType;
42
+ label?: string;
43
+ }
44
+
45
+ export interface MeshGraph {
46
+ meshId: string;
47
+ meshName: string;
48
+ repoIdentity: string;
49
+ refreshedAt: string;
50
+ nodes: MeshGraphNode[];
51
+ edges: MeshGraphEdge[];
52
+ /** Summary statistics */
53
+ stats: {
54
+ totalNodes: number;
55
+ onlineNodes: number;
56
+ dirtyNodes: number;
57
+ orphanNodes: number;
58
+ errorNodes: number;
59
+ offlineNodes: number;
60
+ totalActiveSessions: number;
61
+ };
62
+ /** Global orphan / stale warnings */
63
+ warnings: string[];
64
+ }
65
+
66
+ // ─── Helpers ─────────────────────────────────────
67
+
68
+ function isDirty(git?: GitRepoStatus): boolean {
69
+ if (!git) return false;
70
+ return (git.staged + git.modified + git.untracked + git.deleted + git.renamed) > 0;
71
+ }
72
+
73
+ function dirtyFileCount(git?: GitRepoStatus): number {
74
+ if (!git) return 0;
75
+ return git.staged + git.modified + git.untracked + git.deleted + git.renamed;
76
+ }
77
+
78
+ function detectOrphanReasons(node: RepoMeshNodeStatus, defaultBranch?: string | null): string[] {
79
+ const reasons: string[] = [];
80
+ const git = node.git;
81
+
82
+ if (!git) {
83
+ reasons.push('No git status available');
84
+ return reasons;
85
+ }
86
+
87
+ if (!git.isGitRepo) {
88
+ reasons.push('Not a git repository');
89
+ return reasons;
90
+ }
91
+
92
+ // Detached HEAD
93
+ if (git.branch === null && git.headCommit) {
94
+ reasons.push('Detached HEAD');
95
+ }
96
+
97
+ // No upstream tracking
98
+ if (git.branch && !git.upstream) {
99
+ // Local-only branch — orphan if not default
100
+ if (defaultBranch && git.branch !== defaultBranch) {
101
+ reasons.push(`No upstream: ${git.branch}`);
102
+ }
103
+ }
104
+
105
+ // Worktree branch with no upstream
106
+ if (git.branch && git.upstream === null && defaultBranch && git.branch !== defaultBranch) {
107
+ if (!reasons.includes(`No upstream: ${git.branch}`)) {
108
+ reasons.push(`No upstream: ${git.branch}`);
109
+ }
110
+ }
111
+
112
+ // Stale assigned tasks would be detected at queue level, not per node git status
113
+ // We surface node-level errors as orphan indicators
114
+ if (node.error) {
115
+ reasons.push(`Error: ${node.error}`);
116
+ }
117
+
118
+ return reasons;
119
+ }
120
+
121
+ function nodeHealthPriority(health: RepoMeshNodeHealth): number {
122
+ switch (health) {
123
+ case 'online': return 0;
124
+ case 'dirty': return 1;
125
+ case 'degraded': return 2;
126
+ case 'wrong_branch': return 3;
127
+ case 'offline': return 4;
128
+ case 'unknown': return 5;
129
+ default: return 5;
130
+ }
131
+ }
132
+
133
+ function pickDominantHealth(healths: RepoMeshNodeHealth[]): RepoMeshNodeHealth {
134
+ if (healths.length === 0) return 'unknown';
135
+ return healths.reduce((best, h) =>
136
+ nodeHealthPriority(h) > nodeHealthPriority(best) ? h : best
137
+ );
138
+ }
139
+
140
+ // ─── Graph Builder ─────────────────────────────────
141
+
142
+ /**
143
+ * Build a visualization graph from RepoMeshStatus.
144
+ *
145
+ * Nodes:
146
+ * - defaultBranchNode: the mesh's default branch (aggregated from nodes on that branch)
147
+ * - worktreeNode: a node on a feature / worktree branch
148
+ * - orphanNode: a node with orphanReasons (upstream missing, detached HEAD, etc.)
149
+ *
150
+ * Edges:
151
+ * - parentBranch: default branch → worktree branch (when branch name differs)
152
+ * - worktreeLink: links nodes that share the same branch (clustering hint)
153
+ * - sessionLink: node → session (lightweight, optional; not rendered as primary edge)
154
+ */
155
+ export function buildMeshGraph(status: RepoMeshStatus): MeshGraph {
156
+ const nodes: MeshGraphNode[] = [];
157
+ const edges: MeshGraphEdge[] = [];
158
+ const warnings: string[] = [];
159
+
160
+ // Track branch → nodes for edge creation
161
+ const branchToNodeIds = new Map<string, string[]>();
162
+
163
+ for (const nodeStatus of status.nodes) {
164
+ const git = nodeStatus.git;
165
+ const branch = git?.branch || null;
166
+ const orphanReasons = detectOrphanReasons(nodeStatus, null);
167
+ const dirty = isDirty(git);
168
+ const dfc = dirtyFileCount(git);
169
+
170
+ // Determine node type
171
+ let type: MeshGraphNodeType = 'worktreeNode';
172
+ if (orphanReasons.length > 0) {
173
+ type = 'orphanNode';
174
+ }
175
+
176
+ const graphNode: MeshGraphNode = {
177
+ id: nodeStatus.nodeId,
178
+ type,
179
+ label: nodeStatus.machineLabel || nodeStatus.nodeId.slice(0, 8),
180
+ workspace: nodeStatus.workspace,
181
+ branch,
182
+ health: nodeStatus.health,
183
+ ahead: git?.ahead ?? 0,
184
+ behind: git?.behind ?? 0,
185
+ dirty,
186
+ dirtyFiles: dfc,
187
+ hasConflicts: git?.hasConflicts ?? false,
188
+ activeSessionCount: nodeStatus.activeSessions?.length ?? 0,
189
+ activeSessions: nodeStatus.activeSessions ?? [],
190
+ providers: nodeStatus.providers ?? [],
191
+ isOrphan: orphanReasons.length > 0,
192
+ orphanReasons,
193
+ source: nodeStatus,
194
+ };
195
+
196
+ nodes.push(graphNode);
197
+
198
+ if (branch) {
199
+ const list = branchToNodeIds.get(branch) ?? [];
200
+ list.push(graphNode.id);
201
+ branchToNodeIds.set(branch, list);
202
+ }
203
+ }
204
+
205
+ // Create synthetic default branch node if we can infer one
206
+ // Heuristic: most common branch among nodes with upstream
207
+ const branchUpstreamCounts = new Map<string, number>();
208
+ for (const n of status.nodes) {
209
+ const b = n.git?.branch;
210
+ const upstream = n.git?.upstream;
211
+ if (b && upstream) {
212
+ branchUpstreamCounts.set(b, (branchUpstreamCounts.get(b) ?? 0) + 1);
213
+ }
214
+ }
215
+ let inferredDefaultBranch: string | null = null;
216
+ let bestCount = 0;
217
+ for (const [b, count] of branchUpstreamCounts) {
218
+ if (count > bestCount) {
219
+ bestCount = count;
220
+ inferredDefaultBranch = b;
221
+ }
222
+ }
223
+
224
+ // If we have a clear default branch, create a synthetic node for it
225
+ const defaultBranchNodeId = inferredDefaultBranch
226
+ ? `__branch_${inferredDefaultBranch}`
227
+ : null;
228
+
229
+ if (defaultBranchNodeId && inferredDefaultBranch) {
230
+ const branchNodes = branchToNodeIds.get(inferredDefaultBranch) ?? [];
231
+ const branchHealths = branchNodes.map(id =>
232
+ nodes.find(n => n.id === id)!.health
233
+ );
234
+
235
+ const defaultNode: MeshGraphNode = {
236
+ id: defaultBranchNodeId,
237
+ type: 'defaultBranchNode',
238
+ label: inferredDefaultBranch,
239
+ workspace: '',
240
+ branch: inferredDefaultBranch,
241
+ health: pickDominantHealth(branchHealths),
242
+ ahead: 0,
243
+ behind: 0,
244
+ dirty: false,
245
+ dirtyFiles: 0,
246
+ hasConflicts: false,
247
+ activeSessionCount: 0,
248
+ activeSessions: [],
249
+ providers: [],
250
+ isOrphan: false,
251
+ orphanReasons: [],
252
+ nextStepHint: branchNodes.length > 0 ? `${branchNodes.length} node(s) on default branch` : undefined,
253
+ source: {
254
+ nodeId: defaultBranchNodeId,
255
+ machineLabel: inferredDefaultBranch,
256
+ workspace: '',
257
+ health: 'online',
258
+ providers: [],
259
+ activeSessions: [],
260
+ },
261
+ };
262
+ nodes.push(defaultNode);
263
+
264
+ // Edge: default branch → each worktree branch that is NOT the default
265
+ const seenBranches = new Set<string>();
266
+ for (const n of nodes) {
267
+ if (n.type === 'defaultBranchNode') continue;
268
+ if (!n.branch) continue;
269
+ if (n.branch === inferredDefaultBranch) {
270
+ // Link worktree nodes on default branch directly to default branch node
271
+ edges.push({
272
+ id: `${defaultBranchNodeId}--${n.id}`,
273
+ source: defaultBranchNodeId,
274
+ target: n.id,
275
+ type: 'parentBranch',
276
+ label: 'default',
277
+ });
278
+ continue;
279
+ }
280
+ if (seenBranches.has(n.branch)) continue;
281
+ seenBranches.add(n.branch);
282
+ edges.push({
283
+ id: `${defaultBranchNodeId}--branch_${n.branch}`,
284
+ source: defaultBranchNodeId,
285
+ target: n.branch,
286
+ type: 'parentBranch',
287
+ label: n.branch,
288
+ });
289
+ }
290
+ }
291
+
292
+ // worktreeLink edges: connect nodes sharing the same branch
293
+ for (const [branch, ids] of branchToNodeIds) {
294
+ if (ids.length < 2) continue;
295
+ for (let i = 1; i < ids.length; i++) {
296
+ edges.push({
297
+ id: `wt_${ids[0]}--${ids[i]}`,
298
+ source: ids[0],
299
+ target: ids[i],
300
+ type: 'worktreeLink',
301
+ label: branch,
302
+ });
303
+ }
304
+ }
305
+
306
+ // Collect warnings
307
+ const orphanCount = nodes.filter(n => n.isOrphan).length;
308
+ if (orphanCount > 0) {
309
+ warnings.push(`${orphanCount} orphan node(s) detected`);
310
+ }
311
+ const conflictCount = nodes.filter(n => n.hasConflicts).length;
312
+ if (conflictCount > 0) {
313
+ warnings.push(`${conflictCount} node(s) with merge conflicts`);
314
+ }
315
+ const offlineCount = nodes.filter(n => n.health === 'offline').length;
316
+ if (offlineCount > 0) {
317
+ warnings.push(`${offlineCount} node(s) offline`);
318
+ }
319
+
320
+ // Stats
321
+ const stats = {
322
+ totalNodes: status.nodes.length,
323
+ onlineNodes: status.nodes.filter(n => n.health === 'online').length,
324
+ dirtyNodes: nodes.filter(n => n.dirty).length,
325
+ orphanNodes: orphanCount,
326
+ errorNodes: status.nodes.filter(n => !!n.error).length,
327
+ offlineNodes: offlineCount,
328
+ totalActiveSessions: status.nodes.reduce((sum, n) => sum + (n.activeSessions?.length ?? 0), 0),
329
+ };
330
+
331
+ return {
332
+ meshId: status.meshId,
333
+ meshName: status.meshName,
334
+ repoIdentity: status.repoIdentity,
335
+ refreshedAt: status.refreshedAt,
336
+ nodes,
337
+ edges,
338
+ stats,
339
+ warnings,
340
+ };
341
+ }