@adhdev/daemon-core 0.9.77-rc.51 → 0.9.77-rc.53
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/index.d.ts +2 -0
- package/dist/index.js +227 -0
- package/dist/index.js.map +1 -1
- package/dist/index.mjs +226 -0
- package/dist/index.mjs.map +1 -1
- package/dist/mesh/mesh-visualization.d.ts +70 -0
- package/package.json +1 -1
- package/src/index.ts +4 -0
- package/src/mesh/mesh-visualization.ts +341 -0
|
@@ -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
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
|
+
}
|