@adhdev/daemon-core 0.9.82-rc.7 → 0.9.82-rc.9
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/boot/daemon-lifecycle.d.ts +2 -0
- package/dist/commands/router.d.ts +2 -0
- package/dist/git/git-commands.d.ts +1 -0
- package/dist/git/git-status.d.ts +5 -0
- package/dist/git/git-types.d.ts +10 -0
- package/dist/index.d.ts +1 -1
- package/dist/index.js +206 -15
- package/dist/index.js.map +1 -1
- package/dist/index.mjs +206 -15
- package/dist/index.mjs.map +1 -1
- package/dist/repo-mesh-types.d.ts +121 -0
- package/package.json +1 -1
- package/src/boot/daemon-lifecycle.ts +3 -0
- package/src/commands/router.ts +148 -13
- package/src/git/git-commands.ts +3 -3
- package/src/git/git-status.ts +97 -6
- package/src/git/git-summary.ts +3 -0
- package/src/git/git-types.ts +11 -0
- package/src/index.ts +8 -0
- package/src/repo-mesh-types.ts +131 -0
|
@@ -213,14 +213,135 @@ export interface RepoMeshStatus {
|
|
|
213
213
|
repoIdentity: string;
|
|
214
214
|
refreshedAt: string;
|
|
215
215
|
nodes: RepoMeshNodeStatus[];
|
|
216
|
+
queue?: RepoMeshQueueStatus;
|
|
217
|
+
ledger?: RepoMeshLedgerStatus;
|
|
218
|
+
}
|
|
219
|
+
export interface RepoMeshSessionStatus {
|
|
220
|
+
sessionId: string;
|
|
221
|
+
providerType?: string;
|
|
222
|
+
state?: string;
|
|
223
|
+
lifecycle?: 'starting' | 'running' | 'stopping' | 'stopped' | 'failed' | 'interrupted';
|
|
224
|
+
surfaceKind?: 'live_runtime' | 'recovery_snapshot' | 'inactive_record';
|
|
225
|
+
recoveryState?: string | null;
|
|
226
|
+
workspace?: string | null;
|
|
227
|
+
title?: string | null;
|
|
228
|
+
lastActivityAt?: string | null;
|
|
229
|
+
isCached?: boolean;
|
|
230
|
+
}
|
|
231
|
+
export type RepoMeshPeerConnectionState = 'self' | 'connected' | 'connecting' | 'disconnected' | 'failed' | 'closed' | 'unknown';
|
|
232
|
+
export type RepoMeshPeerConnectionTransport = 'local' | 'direct' | 'relay' | 'unknown';
|
|
233
|
+
export interface RepoMeshPeerConnectionStatus {
|
|
234
|
+
perspective: 'selected_coordinator';
|
|
235
|
+
source: 'mesh_peer_status' | 'not_reported';
|
|
236
|
+
state: RepoMeshPeerConnectionState;
|
|
237
|
+
transport: RepoMeshPeerConnectionTransport;
|
|
238
|
+
reported: boolean;
|
|
239
|
+
reason?: string;
|
|
240
|
+
lastStateChangeAt?: string;
|
|
241
|
+
lastConnectedAt?: string;
|
|
242
|
+
lastCommandAt?: string;
|
|
216
243
|
}
|
|
217
244
|
export interface RepoMeshNodeStatus {
|
|
218
245
|
nodeId: string;
|
|
219
246
|
machineLabel: string;
|
|
220
247
|
workspace: string;
|
|
248
|
+
repoRoot?: string;
|
|
249
|
+
daemonId?: string;
|
|
250
|
+
machineId?: string;
|
|
251
|
+
machineStatus?: string;
|
|
252
|
+
isLocalWorktree?: boolean;
|
|
253
|
+
worktreeBranch?: string;
|
|
221
254
|
health: RepoMeshNodeHealth;
|
|
222
255
|
git?: GitRepoStatus;
|
|
223
256
|
providers: string[];
|
|
224
257
|
activeSessions: string[];
|
|
258
|
+
activeSessionDetails?: RepoMeshSessionStatus[];
|
|
259
|
+
providerPriority?: string[];
|
|
260
|
+
launchReady?: boolean;
|
|
261
|
+
lastSeenAt?: string;
|
|
262
|
+
updatedAt?: string;
|
|
263
|
+
connection?: RepoMeshPeerConnectionStatus;
|
|
225
264
|
error?: string;
|
|
226
265
|
}
|
|
266
|
+
export type RepoMeshQueueTaskStatus = 'pending' | 'assigned' | 'completed' | 'failed' | 'cancelled';
|
|
267
|
+
export interface RepoMeshQueueTask {
|
|
268
|
+
id: string;
|
|
269
|
+
meshId: string;
|
|
270
|
+
message: string;
|
|
271
|
+
status: RepoMeshQueueTaskStatus;
|
|
272
|
+
targetNodeId?: string;
|
|
273
|
+
targetSessionId?: string;
|
|
274
|
+
assignedNodeId?: string;
|
|
275
|
+
assignedSessionId?: string;
|
|
276
|
+
cancelReason?: string;
|
|
277
|
+
cancelledAt?: string;
|
|
278
|
+
requeueReason?: string;
|
|
279
|
+
requeuedAt?: string;
|
|
280
|
+
requeueCount?: number;
|
|
281
|
+
autoLaunch?: {
|
|
282
|
+
status: 'skipped' | 'started' | 'failed' | 'completed';
|
|
283
|
+
reason?: string;
|
|
284
|
+
nodeId?: string;
|
|
285
|
+
providerType?: string;
|
|
286
|
+
sessionId?: string;
|
|
287
|
+
updatedAt: string;
|
|
288
|
+
};
|
|
289
|
+
dispatchTimestamp?: string;
|
|
290
|
+
createdAt: string;
|
|
291
|
+
updatedAt: string;
|
|
292
|
+
}
|
|
293
|
+
export interface RepoMeshQueueSummary {
|
|
294
|
+
total: number;
|
|
295
|
+
active: number;
|
|
296
|
+
historical: number;
|
|
297
|
+
pending: number;
|
|
298
|
+
assigned: number;
|
|
299
|
+
completed: number;
|
|
300
|
+
failed: number;
|
|
301
|
+
cancelled: number;
|
|
302
|
+
activeCounts: {
|
|
303
|
+
pending: number;
|
|
304
|
+
assigned: number;
|
|
305
|
+
};
|
|
306
|
+
historicalCounts: {
|
|
307
|
+
completed: number;
|
|
308
|
+
failed: number;
|
|
309
|
+
cancelled: number;
|
|
310
|
+
};
|
|
311
|
+
activeAssignments: Array<{
|
|
312
|
+
id: string;
|
|
313
|
+
nodeId?: string;
|
|
314
|
+
sessionId?: string;
|
|
315
|
+
message: string;
|
|
316
|
+
}>;
|
|
317
|
+
}
|
|
318
|
+
export interface RepoMeshQueueStatus {
|
|
319
|
+
tasks: RepoMeshQueueTask[];
|
|
320
|
+
summary: RepoMeshQueueSummary;
|
|
321
|
+
}
|
|
322
|
+
export interface RepoMeshLedgerEntryStatus {
|
|
323
|
+
id: string;
|
|
324
|
+
meshId: string;
|
|
325
|
+
timestamp: string;
|
|
326
|
+
kind: string;
|
|
327
|
+
nodeId?: string;
|
|
328
|
+
sessionId?: string;
|
|
329
|
+
providerType?: string;
|
|
330
|
+
payload: Record<string, unknown>;
|
|
331
|
+
}
|
|
332
|
+
export interface RepoMeshLedgerSummaryStatus {
|
|
333
|
+
meshId: string;
|
|
334
|
+
totalEntries: number;
|
|
335
|
+
taskDispatched: number;
|
|
336
|
+
taskCompleted: number;
|
|
337
|
+
taskFailed: number;
|
|
338
|
+
taskStalled: number;
|
|
339
|
+
sessionLaunched: number;
|
|
340
|
+
checkpointCreated: number;
|
|
341
|
+
lastActivityAt: string | null;
|
|
342
|
+
recentFailures: number;
|
|
343
|
+
}
|
|
344
|
+
export interface RepoMeshLedgerStatus {
|
|
345
|
+
entries: RepoMeshLedgerEntryStatus[];
|
|
346
|
+
summary: RepoMeshLedgerSummaryStatus;
|
|
347
|
+
}
|
package/package.json
CHANGED
|
@@ -86,6 +86,8 @@ export interface DaemonInitConfig {
|
|
|
86
86
|
|
|
87
87
|
/** Relays a command to a remote mesh node daemon */
|
|
88
88
|
dispatchMeshCommand?: (daemonId: string, command: string, args: Record<string, unknown>) => Promise<any>;
|
|
89
|
+
/** Returns selected-coordinator mesh peer telemetry for a target daemon when available. */
|
|
90
|
+
getMeshPeerConnectionStatus?: (daemonId: string) => Record<string, unknown> | null;
|
|
89
91
|
}
|
|
90
92
|
|
|
91
93
|
// ─── Result ───
|
|
@@ -306,6 +308,7 @@ export async function initDaemonComponents(config: DaemonInitConfig): Promise<Da
|
|
|
306
308
|
sessionHostControl: config.sessionHostControl,
|
|
307
309
|
statusInstanceId: config.statusInstanceId,
|
|
308
310
|
statusVersion: config.statusVersion,
|
|
311
|
+
getMeshPeerConnectionStatus: config.getMeshPeerConnectionStatus,
|
|
309
312
|
getCdpLogFn: config.getCdpLogFn || ((ideType: string) => LOG.forComponent(`CDP:${ideType}`).asLogFn()),
|
|
310
313
|
});
|
|
311
314
|
|
package/src/commands/router.ts
CHANGED
|
@@ -259,17 +259,93 @@ function readCachedInlineMeshActiveSessions(node: any): string[] {
|
|
|
259
259
|
return sessionId ? [sessionId] : [];
|
|
260
260
|
}
|
|
261
261
|
|
|
262
|
+
function readCachedInlineMeshActiveSessionDetails(node: any): Array<Record<string, unknown>> {
|
|
263
|
+
const cachedStatus = readObjectRecord(node?.cachedStatus);
|
|
264
|
+
const activeSession = readObjectRecord(cachedStatus.activeSession);
|
|
265
|
+
const fallbackSession = Object.keys(activeSession).length
|
|
266
|
+
? activeSession
|
|
267
|
+
: readObjectRecord(node?.activeSession ?? node?.active_session);
|
|
268
|
+
const sessionId = readStringValue(
|
|
269
|
+
fallbackSession.id,
|
|
270
|
+
fallbackSession.sessionId,
|
|
271
|
+
fallbackSession.session_id,
|
|
272
|
+
node?.activeSessionId,
|
|
273
|
+
node?.active_session_id,
|
|
274
|
+
node?.sessionId,
|
|
275
|
+
node?.session_id,
|
|
276
|
+
);
|
|
277
|
+
if (!sessionId) return [];
|
|
278
|
+
return [{
|
|
279
|
+
sessionId,
|
|
280
|
+
providerType: readStringValue(
|
|
281
|
+
fallbackSession.providerType,
|
|
282
|
+
fallbackSession.provider_type,
|
|
283
|
+
fallbackSession.cliType,
|
|
284
|
+
fallbackSession.cli_type,
|
|
285
|
+
fallbackSession.provider,
|
|
286
|
+
node?.providerType,
|
|
287
|
+
node?.provider_type,
|
|
288
|
+
),
|
|
289
|
+
state: readStringValue(fallbackSession.status, fallbackSession.state, fallbackSession.lifecycle),
|
|
290
|
+
lifecycle: readStringValue(fallbackSession.lifecycle),
|
|
291
|
+
title: readStringValue(fallbackSession.title, fallbackSession.displayName, fallbackSession.display_name) ?? null,
|
|
292
|
+
workspace: readStringValue(fallbackSession.workspace, node?.workspace) ?? null,
|
|
293
|
+
lastActivityAt: readStringValue(fallbackSession.lastActivityAt, fallbackSession.last_activity_at) ?? null,
|
|
294
|
+
recoveryState: readStringValue(fallbackSession.recoveryState, fallbackSession.recovery_state) ?? null,
|
|
295
|
+
isCached: true,
|
|
296
|
+
}];
|
|
297
|
+
}
|
|
298
|
+
|
|
299
|
+
function readLiveMeshSessionState(record: any): string | undefined {
|
|
300
|
+
return readStringValue(
|
|
301
|
+
record?.meta?.sessionStatus,
|
|
302
|
+
record?.meta?.status,
|
|
303
|
+
record?.meta?.providerStatus,
|
|
304
|
+
record?.status,
|
|
305
|
+
record?.state,
|
|
306
|
+
record?.lifecycle,
|
|
307
|
+
);
|
|
308
|
+
}
|
|
309
|
+
|
|
310
|
+
function toIsoTimestamp(value: unknown): string | null {
|
|
311
|
+
if (typeof value === 'number' && Number.isFinite(value)) return new Date(value).toISOString();
|
|
312
|
+
const stringValue = readStringValue(value);
|
|
313
|
+
return stringValue || null;
|
|
314
|
+
}
|
|
315
|
+
|
|
316
|
+
function summarizeMeshSessionRecord(record: any): Record<string, unknown> {
|
|
317
|
+
return {
|
|
318
|
+
sessionId: readStringValue(record?.sessionId) || 'unknown',
|
|
319
|
+
providerType: readStringValue(record?.providerType),
|
|
320
|
+
state: readLiveMeshSessionState(record),
|
|
321
|
+
lifecycle: readStringValue(record?.lifecycle),
|
|
322
|
+
surfaceKind: getSessionHostSurfaceKind(record as any),
|
|
323
|
+
recoveryState: readStringValue(record?.meta?.runtimeRecoveryState) ?? null,
|
|
324
|
+
workspace: readStringValue(record?.workspace) ?? null,
|
|
325
|
+
title: readStringValue(record?.displayName, record?.workspaceLabel) ?? null,
|
|
326
|
+
lastActivityAt: toIsoTimestamp(record?.updatedAt ?? record?.lastActivityAt ?? record?.last_activity_at),
|
|
327
|
+
isCached: false,
|
|
328
|
+
};
|
|
329
|
+
}
|
|
330
|
+
|
|
262
331
|
function applyCachedInlineMeshNodeStatus(status: Record<string, unknown>, node: any): boolean {
|
|
263
332
|
const cachedStatus = readObjectRecord(node?.cachedStatus);
|
|
264
333
|
const git = buildCachedInlineMeshGitStatus(node);
|
|
265
334
|
const error = readStringValue(cachedStatus.error, node?.error);
|
|
266
335
|
const health = readStringValue(cachedStatus.health, node?.health);
|
|
267
336
|
const machineStatus = readStringValue(cachedStatus.machineStatus, node?.machineStatus);
|
|
337
|
+
const lastSeenAt = toIsoTimestamp(cachedStatus.lastSeenAt ?? cachedStatus.last_seen_at ?? node?.lastSeenAt ?? node?.last_seen_at);
|
|
338
|
+
const updatedAt = toIsoTimestamp(cachedStatus.updatedAt ?? cachedStatus.updated_at ?? node?.updatedAt ?? node?.updated_at);
|
|
268
339
|
const activeSessions = readCachedInlineMeshActiveSessions(node);
|
|
269
|
-
|
|
340
|
+
const activeSessionDetails = readCachedInlineMeshActiveSessionDetails(node);
|
|
341
|
+
if (!git && !error && !health && !machineStatus && !lastSeenAt && !updatedAt && activeSessions.length === 0) return false;
|
|
270
342
|
if (git) status.git = git;
|
|
271
343
|
if (error) status.error = error;
|
|
344
|
+
if (machineStatus) status.machineStatus = machineStatus;
|
|
345
|
+
if (lastSeenAt) status.lastSeenAt = lastSeenAt;
|
|
346
|
+
if (updatedAt) status.updatedAt = updatedAt;
|
|
272
347
|
if (activeSessions.length > 0) status.activeSessions = activeSessions;
|
|
348
|
+
if (activeSessionDetails.length > 0) status.activeSessionDetails = activeSessionDetails;
|
|
273
349
|
if (health) {
|
|
274
350
|
status.health = health;
|
|
275
351
|
return true;
|
|
@@ -278,7 +354,7 @@ function applyCachedInlineMeshNodeStatus(status: Record<string, unknown>, node:
|
|
|
278
354
|
status.health = deriveMeshNodeHealthFromGit(git);
|
|
279
355
|
return true;
|
|
280
356
|
}
|
|
281
|
-
return activeSessions.length > 0 || !!machineStatus;
|
|
357
|
+
return activeSessions.length > 0 || !!machineStatus || !!lastSeenAt || !!updatedAt;
|
|
282
358
|
}
|
|
283
359
|
|
|
284
360
|
async function resolveProviderTypeFromPriority(args: {
|
|
@@ -698,6 +774,8 @@ export interface CommandRouterDeps {
|
|
|
698
774
|
statusVersion?: string;
|
|
699
775
|
/** Session host control plane */
|
|
700
776
|
sessionHostControl?: SessionHostControlPlane | null;
|
|
777
|
+
/** Selected-coordinator mesh peer telemetry surface for target daemons, when supported by the runtime. */
|
|
778
|
+
getMeshPeerConnectionStatus?: (daemonId: string) => Record<string, unknown> | null;
|
|
701
779
|
}
|
|
702
780
|
|
|
703
781
|
export interface CommandRouterResult {
|
|
@@ -2955,36 +3033,91 @@ export class DaemonCommandRouter {
|
|
|
2955
3033
|
: [];
|
|
2956
3034
|
const liveMeshSessions = partitionSessionHostRecords(Array.isArray(sessionHostRecords) ? sessionHostRecords : []).liveRuntimes;
|
|
2957
3035
|
|
|
3036
|
+
const localMachineId = loadConfig().machineId || '';
|
|
3037
|
+
const inlineCoordinatorNodeId = meshRecord?.inline && Array.isArray(mesh.nodes)
|
|
3038
|
+
? readStringValue((mesh.nodes[0] as any)?.id, (mesh.nodes[0] as any)?.nodeId)
|
|
3039
|
+
: undefined;
|
|
3040
|
+
const refreshedAt = new Date().toISOString();
|
|
2958
3041
|
const nodeStatuses = [];
|
|
2959
|
-
for (const node of mesh.nodes || []) {
|
|
3042
|
+
for (const [nodeIndex, node] of (mesh.nodes || []).entries()) {
|
|
3043
|
+
const nodeId = String(node.id || node.nodeId || '');
|
|
3044
|
+
const daemonId = readStringValue(node.daemonId);
|
|
3045
|
+
const providerPriority = readProviderPriorityFromPolicy(node.policy);
|
|
3046
|
+
const isSelfNode = Boolean(
|
|
3047
|
+
nodeId && inlineCoordinatorNodeId && nodeId === inlineCoordinatorNodeId,
|
|
3048
|
+
) || Boolean(
|
|
3049
|
+
daemonId && (daemonId === localMachineId || daemonId === this.deps.statusInstanceId),
|
|
3050
|
+
) || Boolean(meshRecord?.inline && nodeIndex === 0);
|
|
2960
3051
|
const status: Record<string, unknown> = {
|
|
2961
|
-
nodeId
|
|
3052
|
+
nodeId,
|
|
2962
3053
|
machineLabel: node.machineLabel || node.id || node.nodeId,
|
|
2963
3054
|
workspace: node.workspace,
|
|
2964
3055
|
repoRoot: node.repoRoot,
|
|
2965
3056
|
isLocalWorktree: node.isLocalWorktree,
|
|
2966
3057
|
worktreeBranch: node.worktreeBranch,
|
|
2967
|
-
daemonId
|
|
3058
|
+
daemonId,
|
|
2968
3059
|
machineId: node.machineId,
|
|
3060
|
+
machineStatus: node.machineStatus,
|
|
2969
3061
|
health: 'unknown',
|
|
2970
3062
|
providers: node.providers || [],
|
|
3063
|
+
providerPriority,
|
|
2971
3064
|
activeSessions: [],
|
|
3065
|
+
activeSessionDetails: [],
|
|
3066
|
+
launchReady: false,
|
|
2972
3067
|
};
|
|
2973
|
-
|
|
2974
|
-
|
|
2975
|
-
|
|
2976
|
-
|
|
2977
|
-
|
|
2978
|
-
|
|
2979
|
-
|
|
3068
|
+
if (isSelfNode) {
|
|
3069
|
+
status.connection = {
|
|
3070
|
+
perspective: 'selected_coordinator',
|
|
3071
|
+
source: 'mesh_peer_status',
|
|
3072
|
+
state: 'self',
|
|
3073
|
+
transport: 'local',
|
|
3074
|
+
reported: true,
|
|
3075
|
+
reason: 'Selected coordinator daemon',
|
|
3076
|
+
lastStateChangeAt: refreshedAt,
|
|
3077
|
+
};
|
|
3078
|
+
} else if (daemonId) {
|
|
3079
|
+
const connection = this.deps.getMeshPeerConnectionStatus?.(daemonId);
|
|
3080
|
+
status.connection = connection ?? {
|
|
3081
|
+
perspective: 'selected_coordinator',
|
|
3082
|
+
source: 'not_reported',
|
|
3083
|
+
state: 'unknown',
|
|
3084
|
+
transport: 'unknown',
|
|
3085
|
+
reported: false,
|
|
3086
|
+
reason: 'No live mesh peer telemetry reported by the selected coordinator yet.',
|
|
3087
|
+
};
|
|
3088
|
+
} else {
|
|
3089
|
+
status.connection = {
|
|
3090
|
+
perspective: 'selected_coordinator',
|
|
3091
|
+
source: 'not_reported',
|
|
3092
|
+
state: 'unknown',
|
|
3093
|
+
transport: 'unknown',
|
|
3094
|
+
reported: false,
|
|
3095
|
+
reason: 'Node has no daemon id, so mesh transport cannot be reported from the selected coordinator.',
|
|
3096
|
+
};
|
|
3097
|
+
}
|
|
3098
|
+
const matchedLiveSessionRecords = liveMeshSessions
|
|
3099
|
+
.filter((record) => this.sessionMatchesMeshNode(record, node, nodeId));
|
|
3100
|
+
if (matchedLiveSessionRecords.length > 0) {
|
|
3101
|
+
const sessionIds = matchedLiveSessionRecords
|
|
3102
|
+
.map((record: any) => typeof record?.sessionId === 'string' ? record.sessionId : '')
|
|
3103
|
+
.filter(Boolean);
|
|
3104
|
+
const providerTypes = matchedLiveSessionRecords
|
|
3105
|
+
.map((record: any) => readStringValue(record?.providerType))
|
|
3106
|
+
.filter(Boolean) as string[];
|
|
3107
|
+
status.activeSessions = sessionIds;
|
|
3108
|
+
status.activeSessionDetails = matchedLiveSessionRecords.map(summarizeMeshSessionRecord);
|
|
3109
|
+
if (providerTypes.length > 0) {
|
|
3110
|
+
status.providers = Array.from(new Set([...(Array.isArray(status.providers) ? status.providers as string[] : []), ...providerTypes]));
|
|
3111
|
+
}
|
|
2980
3112
|
}
|
|
2981
3113
|
if (node.workspace && typeof node.workspace === 'string') {
|
|
2982
3114
|
if (!fs.existsSync(node.workspace as string) && applyCachedInlineMeshNodeStatus(status, node)) {
|
|
3115
|
+
status.launchReady = !!daemonId && (readStringValue(status.machineStatus) === 'online' || isSelfNode);
|
|
2983
3116
|
nodeStatuses.push(status);
|
|
2984
3117
|
continue;
|
|
2985
3118
|
}
|
|
2986
3119
|
try {
|
|
2987
|
-
const gitStatus = await getGitRepoStatus(node.workspace as string, { timeoutMs: 10_000 });
|
|
3120
|
+
const gitStatus = await getGitRepoStatus(node.workspace as string, { timeoutMs: 10_000, refreshUpstream: true });
|
|
2988
3121
|
status.git = gitStatus;
|
|
2989
3122
|
if (gitStatus.isGitRepo) {
|
|
2990
3123
|
status.health = deriveMeshNodeHealthFromGit(gitStatus as unknown as Record<string, unknown>);
|
|
@@ -3000,6 +3133,7 @@ export class DaemonCommandRouter {
|
|
|
3000
3133
|
} else {
|
|
3001
3134
|
applyCachedInlineMeshNodeStatus(status, node);
|
|
3002
3135
|
}
|
|
3136
|
+
status.launchReady = !!daemonId && (readStringValue(status.machineStatus) === 'online' || isSelfNode);
|
|
3003
3137
|
nodeStatuses.push(status);
|
|
3004
3138
|
}
|
|
3005
3139
|
|
|
@@ -3009,6 +3143,7 @@ export class DaemonCommandRouter {
|
|
|
3009
3143
|
meshName: mesh.name,
|
|
3010
3144
|
repoIdentity: mesh.repoIdentity,
|
|
3011
3145
|
defaultBranch: mesh.defaultBranch,
|
|
3146
|
+
refreshedAt: new Date().toISOString(),
|
|
3012
3147
|
nodes: nodeStatuses,
|
|
3013
3148
|
queue: { tasks: queue, summary: queueSummary },
|
|
3014
3149
|
ledger: { entries: ledgerEntries, summary: ledgerSummary },
|
package/src/git/git-commands.ts
CHANGED
|
@@ -62,7 +62,7 @@ export interface GitPushResult extends GitRepoIdentity {
|
|
|
62
62
|
}
|
|
63
63
|
|
|
64
64
|
export interface GitCommandServices {
|
|
65
|
-
getStatus?: (params: { workspace: string }) => Promise<GitRepoStatus> | GitRepoStatus;
|
|
65
|
+
getStatus?: (params: { workspace: string; refreshUpstream?: boolean }) => Promise<GitRepoStatus> | GitRepoStatus;
|
|
66
66
|
getDiffSummary?: (params: { workspace: string; staged?: boolean }) => Promise<GitDiffSummary> | GitDiffSummary;
|
|
67
67
|
getDiffFile?: (params: { workspace: string; path: string; staged?: boolean }) => Promise<GitFileDiff> | GitFileDiff;
|
|
68
68
|
createSnapshot?: (params: {
|
|
@@ -171,7 +171,7 @@ const defaultSnapshotStore = createGitSnapshotStore({
|
|
|
171
171
|
|
|
172
172
|
export function createDefaultGitCommandServices(): GitCommandServices {
|
|
173
173
|
return {
|
|
174
|
-
getStatus: ({ workspace }) => getGitRepoStatus(workspace),
|
|
174
|
+
getStatus: ({ workspace, refreshUpstream }) => getGitRepoStatus(workspace, { refreshUpstream }),
|
|
175
175
|
getDiffSummary: ({ workspace }) => getGitDiffSummary(workspace),
|
|
176
176
|
getDiffFile: ({ workspace, path: filePath }) => getGitFileDiff(workspace, filePath),
|
|
177
177
|
createSnapshot: ({ workspace, reason, sessionId, turnId }) => defaultSnapshotStore.create({
|
|
@@ -290,7 +290,7 @@ export async function handleGitCommand(
|
|
|
290
290
|
switch (command) {
|
|
291
291
|
case 'git_status': {
|
|
292
292
|
if (!services.getStatus) return serviceNotImplemented(command);
|
|
293
|
-
const status = await runService(() => services.getStatus!({ workspace }));
|
|
293
|
+
const status = await runService(() => services.getStatus!({ workspace, refreshUpstream: optionalBoolean(args?.refreshUpstream) }));
|
|
294
294
|
return 'success' in status ? status : { success: true, status };
|
|
295
295
|
}
|
|
296
296
|
|
package/src/git/git-status.ts
CHANGED
|
@@ -1,12 +1,25 @@
|
|
|
1
|
-
import type { GitRepoStatus, GitSubmoduleStatus } from './git-types.js';
|
|
1
|
+
import type { GitRepoStatus, GitSubmoduleStatus, GitUpstreamFreshness } from './git-types.js';
|
|
2
2
|
import { GitCommandError, resolveGitRepository, runGit } from './git-executor.js';
|
|
3
3
|
|
|
4
|
+
type ResolvedGitRepo = { workspace: string; repoRoot: string | null; isGitRepo: boolean };
|
|
5
|
+
|
|
4
6
|
export interface GitStatusOptions {
|
|
5
7
|
timeoutMs?: number;
|
|
6
8
|
/** When true, include submodule status in the result. Defaults to true. */
|
|
7
9
|
includeSubmodules?: boolean;
|
|
8
10
|
/** Optional filter to exclude specific submodule paths from status */
|
|
9
11
|
submoduleIgnorePaths?: string[];
|
|
12
|
+
/**
|
|
13
|
+
* When true, refresh the tracked remote before trusting ahead/behind.
|
|
14
|
+
* Callers should opt into this only for convergence-critical surfaces.
|
|
15
|
+
*/
|
|
16
|
+
refreshUpstream?: boolean;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
interface GitUpstreamProbe {
|
|
20
|
+
upstreamStatus: GitUpstreamFreshness;
|
|
21
|
+
upstreamFetchedAt?: number;
|
|
22
|
+
upstreamFetchError?: string;
|
|
10
23
|
}
|
|
11
24
|
|
|
12
25
|
export async function getGitRepoStatus(
|
|
@@ -18,8 +31,16 @@ export async function getGitRepoStatus(
|
|
|
18
31
|
|
|
19
32
|
try {
|
|
20
33
|
const repo = await resolveGitRepository(workspace, options);
|
|
21
|
-
|
|
22
|
-
|
|
34
|
+
let parsed = await readPorcelainStatus(repo, options);
|
|
35
|
+
let upstreamProbe: GitUpstreamProbe = getInitialUpstreamProbe(parsed);
|
|
36
|
+
|
|
37
|
+
if (options.refreshUpstream) {
|
|
38
|
+
upstreamProbe = await refreshTrackedUpstream(repo, parsed, options);
|
|
39
|
+
if (upstreamProbe.upstreamStatus === 'fresh') {
|
|
40
|
+
parsed = await readPorcelainStatus(repo, options);
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
|
|
23
44
|
const head = await readHead(repo, options);
|
|
24
45
|
const stashCount = await readStashCount(repo, options);
|
|
25
46
|
|
|
@@ -36,6 +57,9 @@ export async function getGitRepoStatus(
|
|
|
36
57
|
headCommit: head.commit,
|
|
37
58
|
headMessage: head.message,
|
|
38
59
|
upstream: parsed.upstream,
|
|
60
|
+
upstreamStatus: parsed.upstream ? upstreamProbe.upstreamStatus : 'no_upstream',
|
|
61
|
+
upstreamFetchedAt: upstreamProbe.upstreamFetchedAt,
|
|
62
|
+
upstreamFetchError: upstreamProbe.upstreamFetchError,
|
|
39
63
|
ahead: parsed.ahead,
|
|
40
64
|
behind: parsed.behind,
|
|
41
65
|
staged: parsed.staged,
|
|
@@ -74,6 +98,72 @@ interface ParsedPorcelainStatus {
|
|
|
74
98
|
conflictFiles: string[];
|
|
75
99
|
}
|
|
76
100
|
|
|
101
|
+
async function readPorcelainStatus(repo: ResolvedGitRepo, options: GitStatusOptions): Promise<ParsedPorcelainStatus> {
|
|
102
|
+
const statusOutput = await runGit(repo, ['status', '--porcelain=v2', '--branch'], options);
|
|
103
|
+
return parsePorcelainV2Status(statusOutput.stdout);
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
function getInitialUpstreamProbe(parsed: ParsedPorcelainStatus): GitUpstreamProbe {
|
|
107
|
+
return {
|
|
108
|
+
upstreamStatus: parsed.upstream ? 'unchecked' : 'no_upstream',
|
|
109
|
+
};
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
async function refreshTrackedUpstream(
|
|
113
|
+
repo: ResolvedGitRepo,
|
|
114
|
+
parsed: ParsedPorcelainStatus,
|
|
115
|
+
options: GitStatusOptions,
|
|
116
|
+
): Promise<GitUpstreamProbe> {
|
|
117
|
+
if (!parsed.upstream || !parsed.branch) {
|
|
118
|
+
return { upstreamStatus: 'no_upstream' };
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
const remoteName = (await readBranchRemote(repo, parsed.branch, options)) ?? inferRemoteName(parsed.upstream);
|
|
122
|
+
if (!remoteName) {
|
|
123
|
+
return {
|
|
124
|
+
upstreamStatus: 'stale',
|
|
125
|
+
upstreamFetchError: `Unable to resolve remote for upstream '${parsed.upstream}'`,
|
|
126
|
+
};
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
try {
|
|
130
|
+
await runGit(repo, ['fetch', '--quiet', '--prune', '--no-tags', remoteName], options);
|
|
131
|
+
return {
|
|
132
|
+
upstreamStatus: 'fresh',
|
|
133
|
+
upstreamFetchedAt: Date.now(),
|
|
134
|
+
};
|
|
135
|
+
} catch (error) {
|
|
136
|
+
return {
|
|
137
|
+
upstreamStatus: 'stale',
|
|
138
|
+
upstreamFetchError: formatGitError(error),
|
|
139
|
+
};
|
|
140
|
+
}
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
async function readBranchRemote(repo: ResolvedGitRepo, branch: string, options: GitStatusOptions): Promise<string | null> {
|
|
144
|
+
try {
|
|
145
|
+
const result = await runGit(repo, ['config', '--get', `branch.${branch}.remote`], options);
|
|
146
|
+
return result.stdout.trim() || null;
|
|
147
|
+
} catch {
|
|
148
|
+
return null;
|
|
149
|
+
}
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
function inferRemoteName(upstream: string): string | null {
|
|
153
|
+
const [remoteName] = upstream.split('/');
|
|
154
|
+
return remoteName?.trim() || null;
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
function formatGitError(error: unknown): string {
|
|
158
|
+
if (error instanceof GitCommandError) {
|
|
159
|
+
return error.stderr || error.message;
|
|
160
|
+
}
|
|
161
|
+
if (error instanceof Error) {
|
|
162
|
+
return error.message;
|
|
163
|
+
}
|
|
164
|
+
return String(error);
|
|
165
|
+
}
|
|
166
|
+
|
|
77
167
|
export function parsePorcelainV2Status(output: string): ParsedPorcelainStatus {
|
|
78
168
|
const parsed: ParsedPorcelainStatus = {
|
|
79
169
|
branch: null,
|
|
@@ -145,7 +235,7 @@ export function parsePorcelainV2Status(output: string): ParsedPorcelainStatus {
|
|
|
145
235
|
}
|
|
146
236
|
|
|
147
237
|
async function readHead(
|
|
148
|
-
repo:
|
|
238
|
+
repo: ResolvedGitRepo,
|
|
149
239
|
options: GitStatusOptions,
|
|
150
240
|
): Promise<{ commit: string | null; message: string | null }> {
|
|
151
241
|
try {
|
|
@@ -163,7 +253,7 @@ async function readHead(
|
|
|
163
253
|
}
|
|
164
254
|
|
|
165
255
|
async function readStashCount(
|
|
166
|
-
repo:
|
|
256
|
+
repo: ResolvedGitRepo,
|
|
167
257
|
options: GitStatusOptions,
|
|
168
258
|
): Promise<number> {
|
|
169
259
|
try {
|
|
@@ -187,6 +277,7 @@ function emptyStatus(workspace: string, lastCheckedAt: number, error: GitCommand
|
|
|
187
277
|
headCommit: null,
|
|
188
278
|
headMessage: null,
|
|
189
279
|
upstream: null,
|
|
280
|
+
upstreamStatus: 'unavailable',
|
|
190
281
|
ahead: 0,
|
|
191
282
|
behind: 0,
|
|
192
283
|
staged: 0,
|
|
@@ -206,7 +297,7 @@ function emptyStatus(workspace: string, lastCheckedAt: number, error: GitCommand
|
|
|
206
297
|
// ─── Submodule Status ───────────────────────────
|
|
207
298
|
|
|
208
299
|
async function getSubmoduleStatuses(
|
|
209
|
-
repo:
|
|
300
|
+
repo: ResolvedGitRepo,
|
|
210
301
|
options: GitStatusOptions,
|
|
211
302
|
): Promise<GitSubmoduleStatus[]> {
|
|
212
303
|
if (!repo.repoRoot) return [];
|
package/src/git/git-summary.ts
CHANGED
|
@@ -22,6 +22,9 @@ export function createGitCompactSummary(status: GitRepoStatus, diffSummary?: Git
|
|
|
22
22
|
isGitRepo: status.isGitRepo,
|
|
23
23
|
repoRoot: status.repoRoot,
|
|
24
24
|
branch: status.branch,
|
|
25
|
+
upstreamStatus: status.upstreamStatus,
|
|
26
|
+
upstreamFetchedAt: status.upstreamFetchedAt,
|
|
27
|
+
upstreamFetchError: status.upstreamFetchError,
|
|
25
28
|
dirty:
|
|
26
29
|
status.staged > 0 ||
|
|
27
30
|
status.modified > 0 ||
|
package/src/git/git-types.ts
CHANGED
|
@@ -40,11 +40,19 @@ export interface GitSubmoduleStatus {
|
|
|
40
40
|
error?: string;
|
|
41
41
|
}
|
|
42
42
|
|
|
43
|
+
export type GitUpstreamFreshness = 'fresh' | 'unchecked' | 'stale' | 'no_upstream' | 'unavailable';
|
|
44
|
+
|
|
43
45
|
export interface GitRepoStatus extends GitRepoIdentity {
|
|
44
46
|
branch: string | null;
|
|
45
47
|
headCommit: string | null;
|
|
46
48
|
headMessage: string | null;
|
|
47
49
|
upstream: string | null;
|
|
50
|
+
/** Whether ahead/behind was verified against a freshly fetched upstream ref. */
|
|
51
|
+
upstreamStatus: GitUpstreamFreshness;
|
|
52
|
+
/** Timestamp for the fetch that refreshed upstream refs when upstreamStatus === 'fresh'. */
|
|
53
|
+
upstreamFetchedAt?: number;
|
|
54
|
+
/** Error from the last refresh attempt when upstreamStatus === 'stale'. */
|
|
55
|
+
upstreamFetchError?: string;
|
|
48
56
|
ahead: number;
|
|
49
57
|
behind: number;
|
|
50
58
|
staged: number;
|
|
@@ -134,6 +142,9 @@ export interface GitCompactSummary {
|
|
|
134
142
|
isGitRepo: boolean;
|
|
135
143
|
repoRoot: string | null;
|
|
136
144
|
branch: string | null;
|
|
145
|
+
upstreamStatus: GitUpstreamFreshness;
|
|
146
|
+
upstreamFetchedAt?: number;
|
|
147
|
+
upstreamFetchError?: string;
|
|
137
148
|
dirty: boolean;
|
|
138
149
|
changedFiles: number;
|
|
139
150
|
ahead: number;
|
package/src/index.ts
CHANGED
|
@@ -103,6 +103,14 @@ export type {
|
|
|
103
103
|
LocalMeshNodeEntry,
|
|
104
104
|
RepoMeshStatus,
|
|
105
105
|
RepoMeshNodeStatus,
|
|
106
|
+
RepoMeshSessionStatus,
|
|
107
|
+
RepoMeshQueueTask,
|
|
108
|
+
RepoMeshQueueTaskStatus,
|
|
109
|
+
RepoMeshQueueSummary,
|
|
110
|
+
RepoMeshQueueStatus,
|
|
111
|
+
RepoMeshLedgerEntryStatus,
|
|
112
|
+
RepoMeshLedgerSummaryStatus,
|
|
113
|
+
RepoMeshLedgerStatus,
|
|
106
114
|
} from './repo-mesh-types.js';
|
|
107
115
|
export { DEFAULT_MESH_POLICY } from './repo-mesh-types.js';
|
|
108
116
|
|