@adhdev/daemon-core 0.9.82-rc.363 → 0.9.82-rc.365
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/commands/med-family/cli-agent.d.ts +2 -0
- package/dist/commands/med-family/fast-forward.d.ts +2 -0
- package/dist/commands/med-family/ide.d.ts +10 -0
- package/dist/commands/med-family/index.d.ts +3 -0
- package/dist/commands/med-family/mesh-crud.d.ts +2 -0
- package/dist/commands/med-family/mesh-host-pairing.d.ts +2 -0
- package/dist/commands/med-family/mesh-queue.d.ts +2 -0
- package/dist/commands/med-family/types.d.ts +116 -0
- package/dist/commands/router.d.ts +83 -0
- package/dist/index.js +1652 -1515
- package/dist/index.js.map +1 -1
- package/dist/index.mjs +1655 -1519
- package/dist/index.mjs.map +1 -1
- package/dist/providers/spec/fsm-driver.d.ts +27 -0
- package/dist/system/hash.d.ts +8 -0
- package/package.json +2 -2
- package/src/commands/cli-manager.ts +2 -1
- package/src/commands/med-family/cli-agent.ts +218 -0
- package/src/commands/med-family/fast-forward.ts +198 -0
- package/src/commands/med-family/ide.ts +163 -0
- package/src/commands/med-family/index.ts +35 -0
- package/src/commands/med-family/mesh-crud.ts +788 -0
- package/src/commands/med-family/mesh-host-pairing.ts +234 -0
- package/src/commands/med-family/mesh-queue.ts +131 -0
- package/src/commands/med-family/types.ts +120 -0
- package/src/commands/mesh-coordinator.ts +2 -2
- package/src/commands/router.ts +57 -1602
- package/src/config/mesh-config.ts +3 -2
- package/src/mesh/mesh-active-work.ts +59 -81
- package/src/providers/spec/fsm-driver.ts +56 -2
- package/src/system/hash.ts +23 -0
|
@@ -198,6 +198,11 @@ export declare class FsmDriver implements ISpecDriver {
|
|
|
198
198
|
private lastWin32WriteAt;
|
|
199
199
|
/** Pending paced chunk-write timer for a large win32 body (see writeWin32Body). */
|
|
200
200
|
private win32WriteTimer;
|
|
201
|
+
/** Timer driving the win32 verification-based modal-confirm CR resend loop (see
|
|
202
|
+
* scheduleWin32ModalConfirm). A lone CR that confirms an approval/picker choice
|
|
203
|
+
* is absorbed by ConPTY the same way a send_message submit CR is, so the confirm
|
|
204
|
+
* must be resent until the modal actually resolves (status leaves 'approval'). */
|
|
205
|
+
private win32ModalConfirmTimer;
|
|
201
206
|
private currentEval;
|
|
202
207
|
private stateHistory;
|
|
203
208
|
private prevStateAt;
|
|
@@ -358,6 +363,28 @@ export declare class FsmDriver implements ISpecDriver {
|
|
|
358
363
|
private scheduleWin32Submit;
|
|
359
364
|
private handleClickControl;
|
|
360
365
|
private handleClickModalButton;
|
|
366
|
+
/**
|
|
367
|
+
* Submit a modal-confirm key sequence (the choice key + its trailing CR).
|
|
368
|
+
*
|
|
369
|
+
* On win32 the trailing CR is the SAME lone-CR-swallow case as a send_message
|
|
370
|
+
* submit: ConPTY can absorb a single CR as a literal newline instead of a
|
|
371
|
+
* confirm, so the approval/picker modal never resolves and the FSM flaps
|
|
372
|
+
* approval↔busy while auto-approve keeps firing into the void (APPROVESTUCK).
|
|
373
|
+
* So we split any non-CR prefix (e.g. the "1" of "1\r") off, write it once, and
|
|
374
|
+
* resend the CR on a fixed cadence until the modal actually resolves (status
|
|
375
|
+
* leaves 'approval'). Non-win32 keeps the single direct write — its CR submits
|
|
376
|
+
* on the first try.
|
|
377
|
+
*/
|
|
378
|
+
private submitModalConfirm;
|
|
379
|
+
/**
|
|
380
|
+
* win32 modal-confirm CR resend loop. Mirrors scheduleWin32Submit's phase-2
|
|
381
|
+
* verified resend, but gated on still being IN a modal (status 'approval')
|
|
382
|
+
* rather than still idle: the first CR fires immediately, then resends every
|
|
383
|
+
* WIN32_SUBMIT_RESEND_GAP_MS while the FSM is still showing the modal, up to
|
|
384
|
+
* WIN32_SUBMIT_MAX_RESENDS. The instant the modal resolves (status flips to
|
|
385
|
+
* generating/idle) we stop, so no stray CR leaks into the next turn's composer.
|
|
386
|
+
*/
|
|
387
|
+
private scheduleWin32ModalConfirm;
|
|
361
388
|
private handleAttachImage;
|
|
362
389
|
private tryAdvancePicker;
|
|
363
390
|
private handleExit;
|
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
/** Full lowercase hex SHA-256 digest of `input`. */
|
|
2
|
+
export declare function sha256Hex(input: string): string;
|
|
3
|
+
/**
|
|
4
|
+
* Truncated SHA-256 hex digest — the first `length` hex chars (default 16).
|
|
5
|
+
* Used for stable short identifiers (workspace hashes, token ids, coordinator
|
|
6
|
+
* home dirs) where collision risk at 16 hex chars (64 bits) is negligible.
|
|
7
|
+
*/
|
|
8
|
+
export declare function shortHash(input: string, length?: number): string;
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@adhdev/daemon-core",
|
|
3
|
-
"version": "0.9.82-rc.
|
|
3
|
+
"version": "0.9.82-rc.365",
|
|
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",
|
|
@@ -46,7 +46,7 @@
|
|
|
46
46
|
"author": "vilmire",
|
|
47
47
|
"license": "AGPL-3.0-or-later",
|
|
48
48
|
"dependencies": {
|
|
49
|
-
"@adhdev/mesh-shared": "0.9.82-rc.
|
|
49
|
+
"@adhdev/mesh-shared": "0.9.82-rc.365",
|
|
50
50
|
"@adhdev/session-host-core": "*",
|
|
51
51
|
"@agentclientprotocol/sdk": "^0.16.1",
|
|
52
52
|
"ajv": "^8.20.0",
|
|
@@ -18,6 +18,7 @@ import { loadConfig } from '../config/config.js';
|
|
|
18
18
|
import { loadState, saveState } from '../config/state-store.js';
|
|
19
19
|
import { getWorkspaceState, resolveLaunchDirectory } from '../config/workspaces.js';
|
|
20
20
|
import { appendRecentActivity } from '../config/recent-activity.js';
|
|
21
|
+
import { shortHash } from '../system/hash.js';
|
|
21
22
|
import { unregisterMeshCoordinator, getCoordinatorForSession } from '../mesh/coordinator-registry.js';
|
|
22
23
|
import { upsertSavedProviderSession } from '../config/saved-sessions.js';
|
|
23
24
|
import { buildLegacyModelModeSummaryMetadata, normalizeProviderSummaryMetadata } from '../providers/summary-metadata.js';
|
|
@@ -301,7 +302,7 @@ function hasConfigOverride(args: string[], key: string): boolean {
|
|
|
301
302
|
function ensureEmptyDelegatedMcpConfig(workspace: string): string {
|
|
302
303
|
const baseDir = path.join(os.tmpdir(), 'adhdev-delegated-agent-empty-mcp');
|
|
303
304
|
mkdirSync(baseDir, { recursive: true });
|
|
304
|
-
const workspaceHash =
|
|
305
|
+
const workspaceHash = shortHash(path.resolve(workspace || os.tmpdir()));
|
|
305
306
|
const filePath = path.join(baseDir, `${workspaceHash}.json`);
|
|
306
307
|
writeFileSync(filePath, JSON.stringify({ mcpServers: {} }, null, 2), 'utf-8');
|
|
307
308
|
return filePath;
|
|
@@ -0,0 +1,218 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* RF-ROUTER MED family — CLI/ACP agent + saved-session + restart commands.
|
|
3
|
+
*
|
|
4
|
+
* launch_cli, stop_cli / set_cli_view_mode / record_provider_pty, agent_command,
|
|
5
|
+
* list_saved_sessions and restart_session. launch_cli and agent_command stamp
|
|
6
|
+
* mesh-worker relay metadata and surface worktree-bootstrap-pending hints around a
|
|
7
|
+
* delegation to cliManager. restart_session dispatches IDE restarts (via ctx.stopIde
|
|
8
|
+
* + ctx.launchIde — no executeDaemonCommand recursion) or CLI/ACP restarts.
|
|
9
|
+
* Extracted verbatim from executeDaemonCommand.
|
|
10
|
+
*/
|
|
11
|
+
import { meshNodeIdMatches } from '@adhdev/mesh-shared';
|
|
12
|
+
import { supportsExplicitSessionResume } from '../cli-manager.js';
|
|
13
|
+
import { loadState } from '../../config/state-store.js';
|
|
14
|
+
import { getRecentActivity } from '../../config/recent-activity.js';
|
|
15
|
+
import { getSavedProviderSessions } from '../../config/saved-sessions.js';
|
|
16
|
+
import { listProviderHistorySessions } from '../../config/chat-history.js';
|
|
17
|
+
import { buildMeshWorkerRelayStamp } from '../../mesh/mesh-events-utils.js';
|
|
18
|
+
import { readStringValue } from '../router.js';
|
|
19
|
+
import type { MedFamilyContext, MedFamilyHandler } from './types.js';
|
|
20
|
+
|
|
21
|
+
export const cliAgentHandlers: Record<string, MedFamilyHandler> = {
|
|
22
|
+
launch_cli: async (ctx: MedFamilyContext, args: any) => {
|
|
23
|
+
// The coordinator routing anchor (meshCoordinatorDaemonId) is stamped
|
|
24
|
+
// upstream by mesh_launch_session, which resolves
|
|
25
|
+
// coordinatorNode.daemonId || ctx.localDaemonId || ctx.localMachineId and
|
|
26
|
+
// fail-closes for a remote node when none resolve. We deliberately do NOT
|
|
27
|
+
// self-stamp this daemon's own id when the field is missing: for a
|
|
28
|
+
// P2P-relayed remote worker launch, stamping the worker's own id would make
|
|
29
|
+
// the self-forward gate (mesh-events-coordinator: daemonIdsEquivalent) treat the
|
|
30
|
+
// worker as its own coordinator, suppressing the spontaneous completion-event
|
|
31
|
+
// forward and leaving the event in the pending inbox until a read_chat
|
|
32
|
+
// reconcile drains it. If the anchor is genuinely absent here, leave it
|
|
33
|
+
// absent rather than poison the routing.
|
|
34
|
+
const launchResult = await ctx.deps.cliManager.handleCliCommand('launch_cli', args);
|
|
35
|
+
// Bug C fix (part 1): when launching a mesh node worker session, surface
|
|
36
|
+
// bootstrapPending:true if the node's worktree bootstrap is still running.
|
|
37
|
+
// This is informational — the launch is NOT blocked here (blocking is done
|
|
38
|
+
// upstream by getWorktreeBootstrapLaunchBlock in the MCP layer).
|
|
39
|
+
const meshNodeId = readStringValue((args?.settings as any)?.meshNodeId);
|
|
40
|
+
const meshId = readStringValue((args?.settings as any)?.meshNodeFor);
|
|
41
|
+
if (meshNodeId && meshId && launchResult?.success !== false) {
|
|
42
|
+
try {
|
|
43
|
+
const { getMesh } = await import('../../config/mesh-config.js');
|
|
44
|
+
const meshObj = getMesh(meshId) ?? ctx.getCachedInlineMesh(meshId);
|
|
45
|
+
const nodeObj = Array.isArray(meshObj?.nodes)
|
|
46
|
+
? meshObj.nodes.find((n: any) => meshNodeIdMatches(n, meshNodeId))
|
|
47
|
+
: undefined;
|
|
48
|
+
const bootstrapStatus = readStringValue(nodeObj?.worktreeBootstrap?.status);
|
|
49
|
+
if (bootstrapStatus === 'running') {
|
|
50
|
+
return { success: true, ...launchResult, bootstrapPending: true };
|
|
51
|
+
}
|
|
52
|
+
} catch { /* best-effort — do not fail launch for bootstrap probe errors */ }
|
|
53
|
+
}
|
|
54
|
+
return launchResult;
|
|
55
|
+
},
|
|
56
|
+
|
|
57
|
+
stop_cli: async (ctx: MedFamilyContext, args: any) => {
|
|
58
|
+
return ctx.deps.cliManager.handleCliCommand('stop_cli', args);
|
|
59
|
+
},
|
|
60
|
+
set_cli_view_mode: async (ctx: MedFamilyContext, args: any) => {
|
|
61
|
+
return ctx.deps.cliManager.handleCliCommand('set_cli_view_mode', args);
|
|
62
|
+
},
|
|
63
|
+
record_provider_pty: async (ctx: MedFamilyContext, args: any) => {
|
|
64
|
+
return ctx.deps.cliManager.handleCliCommand('record_provider_pty', args);
|
|
65
|
+
},
|
|
66
|
+
|
|
67
|
+
agent_command: async (ctx: MedFamilyContext, args: any) => {
|
|
68
|
+
// Relay-safety stamp: a dispatch carrying meshContext.coordinatorDaemonId
|
|
69
|
+
// (mesh_send_task / queue assignment over P2P) is the worker daemon's chance
|
|
70
|
+
// to persist the coordinator routing anchor onto the target session BEFORE the
|
|
71
|
+
// turn runs. Without meshCoordinatorDaemonId on the session, the core forwarder
|
|
72
|
+
// (injectMeshSystemMessage) cannot resolve a remote coordinator target, so the
|
|
73
|
+
// completion event sits in the pending queue until a read_chat reconcile drains
|
|
74
|
+
// it. Stamping here makes a reused/relaunched remote session relay-safe at
|
|
75
|
+
// dispatch time even when it was not launched via mesh_launch_session.
|
|
76
|
+
{
|
|
77
|
+
const dispatchSessionId = readStringValue(args?.targetSessionId, (args as any)?.sessionId, (args as any)?.instanceId);
|
|
78
|
+
const dispatchMeshContext = args?.meshContext as Record<string, unknown> | undefined;
|
|
79
|
+
if (dispatchSessionId && dispatchMeshContext) {
|
|
80
|
+
try {
|
|
81
|
+
const inst = ctx.deps.instanceManager.getInstance(dispatchSessionId);
|
|
82
|
+
if (inst && typeof inst.updateSettings === 'function') {
|
|
83
|
+
const stamp = buildMeshWorkerRelayStamp(
|
|
84
|
+
inst.getState?.()?.settings as Record<string, unknown> | undefined,
|
|
85
|
+
{
|
|
86
|
+
meshId: dispatchMeshContext.meshId,
|
|
87
|
+
nodeId: dispatchMeshContext.nodeId,
|
|
88
|
+
coordinatorDaemonId: dispatchMeshContext.coordinatorDaemonId,
|
|
89
|
+
// Session-level anchor: preserved across the P2P dispatch to a
|
|
90
|
+
// remote worker so its completion echoes back to the right session.
|
|
91
|
+
coordinatorSessionId: dispatchMeshContext.coordinatorSessionId,
|
|
92
|
+
},
|
|
93
|
+
);
|
|
94
|
+
if (stamp) inst.updateSettings(stamp);
|
|
95
|
+
}
|
|
96
|
+
} catch { /* best-effort — dispatch still proceeds without the stamp */ }
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
const agentResult = await ctx.deps.cliManager.handleCliCommand('agent_command', args);
|
|
100
|
+
// Bug C fix (part 2): when dispatching a task to a mesh node session, override
|
|
101
|
+
// the dispatch acknowledgement risk reason to 'bootstrap_still_running' when
|
|
102
|
+
// the target node's worktree bootstrap is still running. Informational only —
|
|
103
|
+
// dispatch is NOT blocked.
|
|
104
|
+
const meshCtx = args?.meshContext as Record<string, unknown> | undefined;
|
|
105
|
+
const dispatchNodeId = readStringValue(meshCtx?.nodeId);
|
|
106
|
+
const dispatchMeshId = readStringValue(meshCtx?.meshId);
|
|
107
|
+
if (dispatchNodeId && dispatchMeshId && agentResult?.success !== false) {
|
|
108
|
+
try {
|
|
109
|
+
const { getMesh } = await import('../../config/mesh-config.js');
|
|
110
|
+
const meshObj = getMesh(dispatchMeshId) ?? ctx.getCachedInlineMesh(dispatchMeshId);
|
|
111
|
+
const nodeObj = Array.isArray(meshObj?.nodes)
|
|
112
|
+
? meshObj.nodes.find((n: any) => meshNodeIdMatches(n, dispatchNodeId))
|
|
113
|
+
: undefined;
|
|
114
|
+
const bootstrapStatus = readStringValue(nodeObj?.worktreeBootstrap?.status);
|
|
115
|
+
if (bootstrapStatus === 'running') {
|
|
116
|
+
return {
|
|
117
|
+
success: true,
|
|
118
|
+
...agentResult,
|
|
119
|
+
dispatchAcknowledgementRisk: true,
|
|
120
|
+
dispatchAcknowledgementRiskReason: 'bootstrap_still_running',
|
|
121
|
+
nextAction: 'Wait for worktree_bootstrap_complete event before dispatching work to this node.',
|
|
122
|
+
};
|
|
123
|
+
}
|
|
124
|
+
} catch { /* best-effort */ }
|
|
125
|
+
}
|
|
126
|
+
return agentResult;
|
|
127
|
+
},
|
|
128
|
+
|
|
129
|
+
// ─── Logs ───
|
|
130
|
+
list_saved_sessions: async (ctx: MedFamilyContext, args: any) => {
|
|
131
|
+
const providerType = typeof args?.providerType === 'string'
|
|
132
|
+
? args.providerType.trim()
|
|
133
|
+
: typeof args?.agentType === 'string'
|
|
134
|
+
? args.agentType.trim()
|
|
135
|
+
: '';
|
|
136
|
+
const kind = args?.kind === 'acp' ? 'acp' : 'cli';
|
|
137
|
+
if (!providerType) {
|
|
138
|
+
return { success: false, error: 'providerType required' };
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
const wantsAll = args?.all === true;
|
|
142
|
+
const offset = wantsAll ? 0 : Math.max(0, Number(args?.offset) || 0);
|
|
143
|
+
const limit = wantsAll ? Number.MAX_SAFE_INTEGER : Math.max(1, Math.min(100, Number(args?.limit) || 30));
|
|
144
|
+
const requestedWorkspace = typeof args?.workspace === 'string' ? args.workspace.trim() : '';
|
|
145
|
+
const requestedProviderSessionId = typeof args?.providerSessionId === 'string'
|
|
146
|
+
? args.providerSessionId.trim()
|
|
147
|
+
: typeof args?.activeProviderSessionId === 'string'
|
|
148
|
+
? args.activeProviderSessionId.trim()
|
|
149
|
+
: '';
|
|
150
|
+
const providerMeta = ctx.deps.providerLoader.resolve?.(providerType) || ctx.deps.providerLoader.getMeta(providerType);
|
|
151
|
+
const { sessions: historySessions, hasMore, source } = listProviderHistorySessions(providerType, {
|
|
152
|
+
canonicalHistory: providerMeta?.nativeHistory,
|
|
153
|
+
offset,
|
|
154
|
+
limit,
|
|
155
|
+
historyBehavior: providerMeta?.historyBehavior,
|
|
156
|
+
scripts: providerMeta?.scripts as any,
|
|
157
|
+
});
|
|
158
|
+
const state = loadState();
|
|
159
|
+
const savedSessions = getSavedProviderSessions(state, { providerType, kind });
|
|
160
|
+
const recentSessions = getRecentActivity(state, 200)
|
|
161
|
+
.filter(entry => entry.providerType === providerType && entry.kind === kind && entry.providerSessionId);
|
|
162
|
+
const savedSessionById = new Map(savedSessions.map(entry => [entry.providerSessionId, entry]));
|
|
163
|
+
const recentSessionById = new Map(recentSessions.map(entry => [entry.providerSessionId!, entry]));
|
|
164
|
+
const canResumeById = supportsExplicitSessionResume(providerMeta?.resume);
|
|
165
|
+
|
|
166
|
+
return {
|
|
167
|
+
success: true,
|
|
168
|
+
sessions: historySessions.map(session => {
|
|
169
|
+
const saved = savedSessionById.get(session.historySessionId);
|
|
170
|
+
const recent = recentSessionById.get(session.historySessionId);
|
|
171
|
+
const workspace = saved?.workspace
|
|
172
|
+
|| recent?.workspace
|
|
173
|
+
|| session.workspace
|
|
174
|
+
|| (requestedWorkspace && requestedProviderSessionId === session.historySessionId ? requestedWorkspace : undefined);
|
|
175
|
+
return {
|
|
176
|
+
id: session.historySessionId,
|
|
177
|
+
providerSessionId: session.historySessionId,
|
|
178
|
+
providerType,
|
|
179
|
+
providerName: saved?.providerName || recent?.providerName || providerType,
|
|
180
|
+
kind: saved?.kind || recent?.kind || kind,
|
|
181
|
+
title: saved?.title || recent?.title || session.sessionTitle || session.preview || providerType,
|
|
182
|
+
workspace,
|
|
183
|
+
summaryMetadata: saved?.summaryMetadata || recent?.summaryMetadata,
|
|
184
|
+
preview: session.preview,
|
|
185
|
+
messageCount: session.messageCount,
|
|
186
|
+
firstMessageAt: session.firstMessageAt,
|
|
187
|
+
lastMessageAt: session.lastMessageAt,
|
|
188
|
+
canResume: !!workspace && canResumeById,
|
|
189
|
+
historySource: session.source,
|
|
190
|
+
sourcePath: session.sourcePath,
|
|
191
|
+
sourceMtimeMs: session.sourceMtimeMs,
|
|
192
|
+
};
|
|
193
|
+
}),
|
|
194
|
+
hasMore,
|
|
195
|
+
source,
|
|
196
|
+
};
|
|
197
|
+
},
|
|
198
|
+
|
|
199
|
+
// ─── restart_session: IDE / CLI / ACP unified ───
|
|
200
|
+
restart_session: async (ctx: MedFamilyContext, args: any) => {
|
|
201
|
+
const targetType = args?.cliType || args?.agentType || args?.ideType;
|
|
202
|
+
if (!targetType) throw new Error('cliType or ideType required');
|
|
203
|
+
|
|
204
|
+
// Check if IDE (in cdpManagers or provider category is ide)
|
|
205
|
+
const isIde = ctx.deps.cdpManagers.has(targetType) ||
|
|
206
|
+
ctx.deps.providerLoader.getMeta(targetType)?.category === 'ide';
|
|
207
|
+
|
|
208
|
+
if (isIde) {
|
|
209
|
+
// IDE restart: stop (with process kill) → launch
|
|
210
|
+
await ctx.stopIde(targetType, true);
|
|
211
|
+
const launchResult = await ctx.launchIde({ ideType: targetType, enableCdp: true, workspace: args?.workspace });
|
|
212
|
+
return { success: true, restarted: true, ideType: targetType, launch: launchResult };
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
// CLI/ACP restart: delegate to CliManager
|
|
216
|
+
return ctx.deps.cliManager.handleCliCommand('restart_session', args);
|
|
217
|
+
},
|
|
218
|
+
};
|
|
@@ -0,0 +1,198 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* RF-ROUTER MED family — fast-forward / refine convergence commands.
|
|
3
|
+
*
|
|
4
|
+
* mesh_init, plan_mesh_refine_node, fast_forward_mesh_node, refine_mesh_node and
|
|
5
|
+
* batch_refine_mesh_nodes. These resolve the target node, forward to the owning
|
|
6
|
+
* daemon when the node is remote, and run (or plan) the fast-forward / refine
|
|
7
|
+
* convergence. Extracted verbatim from executeDaemonCommand; the async execute
|
|
8
|
+
* paths delegate back to the router's refine-job starters via ctx.
|
|
9
|
+
*/
|
|
10
|
+
import { daemonIdsEquivalent, meshNodeIdMatches } from '@adhdev/mesh-shared';
|
|
11
|
+
import { fastForwardMeshNode } from '../../mesh/mesh-fast-forward.js';
|
|
12
|
+
import { runMeshInit } from '../../mesh/mesh-init.js';
|
|
13
|
+
import { detectCLIs } from '../../detection/cli-detector.js';
|
|
14
|
+
import { buildMeshRefineValidationPlan } from '../router.js';
|
|
15
|
+
import type { CommandRouterResult } from '../router.js';
|
|
16
|
+
import type { MedFamilyContext, MedFamilyHandler } from './types.js';
|
|
17
|
+
|
|
18
|
+
export const fastForwardHandlers: Record<string, MedFamilyHandler> = {
|
|
19
|
+
mesh_init: async (ctx: MedFamilyContext, args: any) => {
|
|
20
|
+
const workspace = typeof args?.workspace === 'string' && args.workspace.trim() ? args.workspace.trim() : process.cwd();
|
|
21
|
+
const mesh = args?.inlineMesh || {};
|
|
22
|
+
try {
|
|
23
|
+
const detected = await detectCLIs(ctx.deps.providerLoader, { includeVersion: true });
|
|
24
|
+
return { ...runMeshInit(mesh, workspace, detected, {
|
|
25
|
+
write: args?.write === true,
|
|
26
|
+
overwrite: args?.overwrite === true,
|
|
27
|
+
}) };
|
|
28
|
+
} catch (e: any) {
|
|
29
|
+
return { success: false, error: e?.message || String(e) };
|
|
30
|
+
}
|
|
31
|
+
},
|
|
32
|
+
|
|
33
|
+
plan_mesh_refine_node: async (ctx: MedFamilyContext, args: any) => {
|
|
34
|
+
const meshId = typeof args?.meshId === 'string' ? args.meshId.trim() : '';
|
|
35
|
+
const nodeId = typeof args?.nodeId === 'string' ? args.nodeId.trim() : '';
|
|
36
|
+
if (!meshId || !nodeId) return { success: false, error: 'meshId and nodeId required' };
|
|
37
|
+
// preferInline: plan is the dry-run sibling of refine — clone nodes must resolve.
|
|
38
|
+
const meshRecord = await ctx.getMeshForCommand(meshId, args?.inlineMesh, { preferInline: true });
|
|
39
|
+
const mesh = meshRecord?.mesh;
|
|
40
|
+
const node = mesh?.nodes?.find((n: any) => meshNodeIdMatches(n, nodeId));
|
|
41
|
+
if (!node?.workspace) return { success: false, error: `Node '${nodeId}' workspace not found` };
|
|
42
|
+
return {
|
|
43
|
+
success: true,
|
|
44
|
+
dryRun: true,
|
|
45
|
+
nodeId,
|
|
46
|
+
workspace: node.workspace,
|
|
47
|
+
validationPlan: buildMeshRefineValidationPlan(mesh, node.workspace),
|
|
48
|
+
mergeWillRun: false,
|
|
49
|
+
cleanupWillRun: false,
|
|
50
|
+
};
|
|
51
|
+
},
|
|
52
|
+
|
|
53
|
+
fast_forward_mesh_node: async (ctx: MedFamilyContext, args: any) => {
|
|
54
|
+
const meshId = typeof args?.meshId === 'string' ? args.meshId.trim() : '';
|
|
55
|
+
const nodeId = typeof args?.nodeId === 'string' ? args.nodeId.trim() : '';
|
|
56
|
+
let workspace = typeof args?.workspace === 'string' ? args.workspace.trim() : '';
|
|
57
|
+
let submoduleIgnorePaths = Array.isArray(args?.submoduleIgnorePaths)
|
|
58
|
+
? args.submoduleIgnorePaths.filter((value: unknown): value is string => typeof value === 'string')
|
|
59
|
+
: undefined;
|
|
60
|
+
let nodeDaemonId: string | undefined;
|
|
61
|
+
let allowAutoPublishSubmoduleMainCommits = false;
|
|
62
|
+
if (meshId && nodeId) {
|
|
63
|
+
// preferInline so fast-forward can resolve inline-cache-only clone worktree nodes.
|
|
64
|
+
const meshRecord = await ctx.getMeshForCommand(meshId, args?.inlineMesh, { preferInline: true });
|
|
65
|
+
const mesh = meshRecord?.mesh;
|
|
66
|
+
const node = mesh?.nodes?.find((n: any) => meshNodeIdMatches(n, nodeId));
|
|
67
|
+
if (!workspace) {
|
|
68
|
+
workspace = typeof node?.workspace === 'string' ? node.workspace.trim() : '';
|
|
69
|
+
}
|
|
70
|
+
if (!submoduleIgnorePaths && Array.isArray(node?.policy?.submoduleIgnorePaths)) {
|
|
71
|
+
submoduleIgnorePaths = node.policy.submoduleIgnorePaths.filter((value: unknown): value is string => typeof value === 'string');
|
|
72
|
+
}
|
|
73
|
+
allowAutoPublishSubmoduleMainCommits = mesh?.policy?.allowAutoPublishSubmoduleMainCommits === true;
|
|
74
|
+
nodeDaemonId = typeof node?.daemonId === 'string' ? node.daemonId.trim() : undefined;
|
|
75
|
+
}
|
|
76
|
+
// If the target node belongs to a remote daemon, forward the command there.
|
|
77
|
+
// _meshDirectDispatch prevents re-forwarding (and P2P self-dial) when the stored
|
|
78
|
+
// daemonId uses a legacy format that doesn't match the receiving daemon's identity.
|
|
79
|
+
const selfDaemonId = ctx.deps.statusInstanceId;
|
|
80
|
+
// daemonIdsEquivalent: a legacy-form stored daemonId that resolves to THIS
|
|
81
|
+
// machine's core must be treated as local (not remote) so it is not forwarded /
|
|
82
|
+
// P2P self-dialed. Equivalent → local.
|
|
83
|
+
const isRemote = nodeDaemonId && selfDaemonId && !daemonIdsEquivalent(nodeDaemonId, selfDaemonId);
|
|
84
|
+
if (isRemote && ctx.deps.dispatchMeshCommand && !args?._meshDirectDispatch) {
|
|
85
|
+
const forwarded = await ctx.deps.dispatchMeshCommand(nodeDaemonId!, 'fast_forward_mesh_node', {
|
|
86
|
+
...(typeof args === 'object' && args !== null ? args as Record<string, unknown> : {}),
|
|
87
|
+
workspace,
|
|
88
|
+
_meshDirectDispatch: true,
|
|
89
|
+
});
|
|
90
|
+
return (forwarded ?? { success: false, error: 'no response from remote node' }) as CommandRouterResult;
|
|
91
|
+
}
|
|
92
|
+
const result = await (fastForwardMeshNode({
|
|
93
|
+
meshId: meshId || undefined,
|
|
94
|
+
nodeId: nodeId || undefined,
|
|
95
|
+
workspace,
|
|
96
|
+
branch: typeof args?.branch === 'string' ? args.branch : undefined,
|
|
97
|
+
execute: args?.execute === true,
|
|
98
|
+
dryRun: args?.dryRun === true,
|
|
99
|
+
updateSubmodules: args?.updateSubmodules === true,
|
|
100
|
+
submoduleIgnorePaths,
|
|
101
|
+
mode: args?.mode === 'push' ? 'push' : 'merge',
|
|
102
|
+
pushSubmodules: args?.pushSubmodules === true,
|
|
103
|
+
allowAutoPublishSubmoduleMainCommits,
|
|
104
|
+
}) as Promise<unknown>);
|
|
105
|
+
return result as CommandRouterResult;
|
|
106
|
+
},
|
|
107
|
+
|
|
108
|
+
refine_mesh_node: async (ctx: MedFamilyContext, args: any) => {
|
|
109
|
+
const meshId = typeof args?.meshId === 'string' ? args.meshId.trim() : '';
|
|
110
|
+
const nodeId = typeof args?.nodeId === 'string' ? args.nodeId.trim() : '';
|
|
111
|
+
if (!meshId || !nodeId) return { success: false, error: 'meshId and nodeId required' };
|
|
112
|
+
|
|
113
|
+
// Remote forward: a worktree node lives on its OWN daemon's machine, so the
|
|
114
|
+
// refine (cd into node.workspace, merge → push → cleanup) must run on THAT
|
|
115
|
+
// daemon — not the coordinator, whose filesystem has no such path. The sibling
|
|
116
|
+
// fast_forward_mesh_node / clone_mesh_node handlers already forward to the
|
|
117
|
+
// node's daemon; refine_mesh_node was the gap (the coordinator would cd into a
|
|
118
|
+
// non-existent local path and fail), so remote-machine worktrees could not be
|
|
119
|
+
// converged at all. Forward both dry-run (plan reads the worktree git state)
|
|
120
|
+
// and execute (async merge job) so the same machine that owns the worktree
|
|
121
|
+
// resolves it.
|
|
122
|
+
//
|
|
123
|
+
// coordinatorDaemonId: refine is ASYNC — the completed/failed event is queued
|
|
124
|
+
// on the executing daemon's pending-events queue scoped to a coordinator id and
|
|
125
|
+
// recovered by the coordinator's reconcile loop (pullRemoteNodeQueues →
|
|
126
|
+
// get_pending_mesh_events). Without stamping our own status id, the remote
|
|
127
|
+
// daemon would fall back to ITS OWN statusInstanceId as the coordinator
|
|
128
|
+
// (startMeshRefineJob), scoping the terminal event to the wrong inbox where the
|
|
129
|
+
// real coordinator never pulls it. Stamp the canonical status id (which is in
|
|
130
|
+
// the coordinator's self-identity set used to scope the remote drain) so the
|
|
131
|
+
// event routes back here. Preserve any caller-supplied coordinatorDaemonId.
|
|
132
|
+
//
|
|
133
|
+
// _meshDirectDispatch prevents re-forwarding (and P2P self-dial) once the call
|
|
134
|
+
// has landed on the owning daemon — that daemon then executes locally even if
|
|
135
|
+
// the stored daemonId uses a legacy form that doesn't match its own identity.
|
|
136
|
+
{
|
|
137
|
+
const meshRecordForForward = await ctx.getMeshForCommand(meshId, args?.inlineMesh, { preferInline: true });
|
|
138
|
+
const forwardNode = meshRecordForForward?.mesh?.nodes?.find((n: any) => meshNodeIdMatches(n, nodeId));
|
|
139
|
+
const nodeDaemonId = typeof forwardNode?.daemonId === 'string' ? forwardNode.daemonId.trim() : undefined;
|
|
140
|
+
const selfDaemonId = ctx.deps.statusInstanceId;
|
|
141
|
+
// daemonIdsEquivalent: a legacy-form daemonId resolving to this machine's core
|
|
142
|
+
// is local — execute locally instead of forwarding. Equivalent → local.
|
|
143
|
+
const isRemote = nodeDaemonId && selfDaemonId && !daemonIdsEquivalent(nodeDaemonId, selfDaemonId);
|
|
144
|
+
if (isRemote && ctx.deps.dispatchMeshCommand && !args?._meshDirectDispatch) {
|
|
145
|
+
const callerCoordinatorDaemonId = typeof args?.coordinatorDaemonId === 'string' && args.coordinatorDaemonId.trim()
|
|
146
|
+
? args.coordinatorDaemonId.trim()
|
|
147
|
+
: undefined;
|
|
148
|
+
const forwarded = await ctx.deps.dispatchMeshCommand(nodeDaemonId!, 'refine_mesh_node', {
|
|
149
|
+
...(typeof args === 'object' && args !== null ? args as Record<string, unknown> : {}),
|
|
150
|
+
coordinatorDaemonId: callerCoordinatorDaemonId || selfDaemonId,
|
|
151
|
+
_meshDirectDispatch: true,
|
|
152
|
+
});
|
|
153
|
+
return (forwarded ?? { success: false, error: 'no response from remote node' }) as CommandRouterResult;
|
|
154
|
+
}
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
// Dry-run (plan-only) is the default and stays synchronous: it does no
|
|
158
|
+
// validation/merge/push and returns the plan instantly. Only execute=true
|
|
159
|
+
// (and not dry_run) goes through the async refine job that actually
|
|
160
|
+
// validates → merges → pushes → cleans up. Mirrors the
|
|
161
|
+
// batch_refine_mesh_nodes / fast_forward_mesh_node dry_run/execute contract.
|
|
162
|
+
const isDryRun = args?.dryRun !== false && args?.execute !== true;
|
|
163
|
+
if (isDryRun) {
|
|
164
|
+
// preferInline: plan is the dry-run sibling of refine — clone nodes must resolve.
|
|
165
|
+
const meshRecord = await ctx.getMeshForCommand(meshId, args?.inlineMesh, { preferInline: true });
|
|
166
|
+
const mesh = meshRecord?.mesh;
|
|
167
|
+
const node = mesh?.nodes?.find((n: any) => meshNodeIdMatches(n, nodeId));
|
|
168
|
+
if (!node?.workspace) return { success: false, error: `Node '${nodeId}' workspace not found` };
|
|
169
|
+
return {
|
|
170
|
+
success: true,
|
|
171
|
+
dryRun: true,
|
|
172
|
+
nodeId,
|
|
173
|
+
workspace: node.workspace,
|
|
174
|
+
validationPlan: buildMeshRefineValidationPlan(mesh, node.workspace),
|
|
175
|
+
mergeWillRun: false,
|
|
176
|
+
cleanupWillRun: false,
|
|
177
|
+
hint: 'Dry-run only — no merge/push/cleanup performed. Re-invoke with execute:true to converge this node.',
|
|
178
|
+
};
|
|
179
|
+
}
|
|
180
|
+
return ctx.startMeshRefineJob(meshId, nodeId, args);
|
|
181
|
+
},
|
|
182
|
+
|
|
183
|
+
batch_refine_mesh_nodes: async (ctx: MedFamilyContext, args: any) => {
|
|
184
|
+
const meshId = typeof args?.meshId === 'string' ? args.meshId.trim() : '';
|
|
185
|
+
if (!meshId) return { success: false, error: 'meshId required' };
|
|
186
|
+
const requestedNodeIds = Array.isArray(args?.nodeIds)
|
|
187
|
+
? (args.nodeIds as unknown[]).filter((v): v is string => typeof v === 'string' && v.trim().length > 0).map(v => v.trim())
|
|
188
|
+
: undefined;
|
|
189
|
+
// Dry-run (plan-only) stays synchronous: it does no validation/merge and
|
|
190
|
+
// returns instantly. Execute goes through the async batch job — immediate
|
|
191
|
+
// {async:true, status:'accepted'} + background convergence + terminal event,
|
|
192
|
+
// matching the single-node refine_mesh_node contract so long validation
|
|
193
|
+
// suites can't time out the IPC and strand the coordinator.
|
|
194
|
+
const isDryRun = args?.dryRun !== false && args?.execute !== true;
|
|
195
|
+
if (isDryRun) return ctx.batchRefineMeshNodes(meshId, requestedNodeIds, args);
|
|
196
|
+
return ctx.startMeshRefineBatchJob(meshId, requestedNodeIds, args);
|
|
197
|
+
},
|
|
198
|
+
};
|