@adhdev/daemon-core 0.9.82-rc.5 → 0.9.82-rc.50
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 +13 -0
- package/dist/config/mesh-config.d.ts +66 -1
- 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 +6 -3
- package/dist/index.js +2483 -434
- package/dist/index.js.map +1 -1
- package/dist/index.mjs +2463 -427
- package/dist/index.mjs.map +1 -1
- package/dist/mesh/mesh-events.d.ts +17 -5
- package/dist/mesh/mesh-host-ownership.d.ts +9 -0
- package/dist/mesh/mesh-ledger.d.ts +1 -1
- package/dist/mesh/mesh-work-queue.d.ts +11 -5
- package/dist/mesh/refine-config.d.ts +119 -0
- package/dist/providers/chat-message-normalization.d.ts +1 -0
- package/dist/repo-mesh-types.d.ts +160 -0
- package/package.json +1 -1
- package/src/boot/daemon-lifecycle.ts +4 -0
- package/src/commands/router.ts +1831 -296
- package/src/config/mesh-config.ts +244 -1
- 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 +32 -2
- package/src/mesh/mesh-events.ts +168 -30
- package/src/mesh/mesh-host-ownership.ts +73 -0
- package/src/mesh/mesh-ledger.ts +1 -0
- package/src/mesh/mesh-work-queue.ts +149 -122
- package/src/mesh/refine-config.ts +306 -0
- package/src/providers/chat-message-normalization.ts +3 -1
- package/src/repo-mesh-types.ts +174 -0
package/src/mesh/mesh-events.ts
CHANGED
|
@@ -1,57 +1,106 @@
|
|
|
1
|
+
import { appendFileSync, existsSync, readFileSync, unlinkSync } from 'fs';
|
|
2
|
+
import { join } from 'path';
|
|
1
3
|
import type { DaemonComponents } from '../boot/daemon-lifecycle.js';
|
|
2
4
|
import { loadConfig } from '../config/config.js';
|
|
3
5
|
import { getMesh, getMeshByRepo } from '../config/mesh-config.js';
|
|
4
6
|
import { detectCLI } from '../detection/cli-detector.js';
|
|
5
7
|
import { LOG } from '../logging/logger.js';
|
|
6
|
-
import { appendLedgerEntry, buildTaskCompletionEvidence, getSessionRecoveryContext, isIntentionalCleanupStopEntry, readLedgerEntries } from './mesh-ledger.js';
|
|
8
|
+
import { appendLedgerEntry, buildTaskCompletionEvidence, getLedgerDir, getSessionRecoveryContext, isIntentionalCleanupStopEntry, readLedgerEntries } from './mesh-ledger.js';
|
|
7
9
|
import type { MeshLedgerKind, SessionRecoveryContext } from './mesh-ledger.js';
|
|
8
10
|
import { claimNextTask, updateSessionTaskStatus, enqueueTask, updateTaskStatus, getQueue, recordTaskAutoLaunch } from './mesh-work-queue.js';
|
|
9
11
|
|
|
10
12
|
// ---------------------------------------------------------------------------
|
|
11
13
|
// Remote Node Idle Session Tracking
|
|
12
14
|
// ---------------------------------------------------------------------------
|
|
13
|
-
// Tracks remote sessions that emitted 'agent:ready' so triggerMeshQueue
|
|
14
|
-
// can assign tasks to them.
|
|
15
|
+
// Tracks remote sessions that emitted 'agent:ready' so triggerMeshQueue
|
|
16
|
+
// can assign tasks to them. Each entry carries an expiresAt timestamp;
|
|
17
|
+
// entries are swept on insertion to prevent unbounded growth.
|
|
15
18
|
// ---------------------------------------------------------------------------
|
|
16
19
|
interface RemoteIdleSession {
|
|
17
20
|
nodeId: string;
|
|
18
21
|
sessionId: string;
|
|
19
22
|
providerType: string;
|
|
23
|
+
expiresAt: number;
|
|
20
24
|
}
|
|
25
|
+
const REMOTE_IDLE_SESSION_TTL_MS = 5 * 60 * 1000; // 5 minutes
|
|
21
26
|
const remoteIdleSessions = new Map<string, RemoteIdleSession>(); // key: `${nodeId}:${sessionId}`
|
|
22
27
|
|
|
28
|
+
function sweepExpiredRemoteIdleSessions(): void {
|
|
29
|
+
const now = Date.now();
|
|
30
|
+
for (const [key, session] of remoteIdleSessions) {
|
|
31
|
+
if (session.expiresAt <= now) remoteIdleSessions.delete(key);
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
|
|
23
35
|
// ---------------------------------------------------------------------------
|
|
24
|
-
// MCP coordinator pending-event queue
|
|
36
|
+
// MCP coordinator pending-event queue — FILE-BASED PERSISTENCE
|
|
25
37
|
// ---------------------------------------------------------------------------
|
|
26
38
|
// When a mesh event fires but no CLI coordinator session is registered (e.g.
|
|
27
|
-
// the coordinator is Claude Code running via MCP), we
|
|
28
|
-
//
|
|
39
|
+
// the coordinator is Claude Code running via MCP), we persist the event to a
|
|
40
|
+
// per-mesh JSONL file so it survives daemon restarts. The 50-entry hard cap
|
|
41
|
+
// is removed; the file is drained atomically on each get_pending_mesh_events
|
|
42
|
+
// call and limited to 100 KB to prevent runaway growth.
|
|
43
|
+
//
|
|
44
|
+
// File: <ledgerDir>/<meshId>.pending-events.jsonl
|
|
29
45
|
// ---------------------------------------------------------------------------
|
|
30
46
|
|
|
31
47
|
export interface PendingMeshCoordinatorEvent {
|
|
32
48
|
event: string;
|
|
33
49
|
meshId: string;
|
|
34
50
|
nodeLabel: string;
|
|
51
|
+
nodeId?: string;
|
|
52
|
+
workspace?: string;
|
|
35
53
|
metadataEvent: Record<string, unknown>;
|
|
36
54
|
queuedAt: number;
|
|
37
55
|
}
|
|
38
56
|
|
|
39
|
-
|
|
40
|
-
const
|
|
57
|
+
function getPendingEventsPath(meshId: string): string {
|
|
58
|
+
const safe = meshId.replace(/[^a-zA-Z0-9_-]/g, '_');
|
|
59
|
+
return join(getLedgerDir(), `${safe}.pending-events.jsonl`);
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
export function queuePendingMeshCoordinatorEvent(event: PendingMeshCoordinatorEvent): boolean {
|
|
63
|
+
try {
|
|
64
|
+
appendFileSync(getPendingEventsPath(event.meshId), JSON.stringify(event) + '\n', 'utf-8');
|
|
65
|
+
return true;
|
|
66
|
+
} catch (e: any) {
|
|
67
|
+
LOG.warn('MeshEvents', `Failed to persist pending coordinator event: ${e?.message || e}`);
|
|
68
|
+
return false;
|
|
69
|
+
}
|
|
70
|
+
}
|
|
41
71
|
|
|
42
|
-
/** Drain and return all pending coordinator events,
|
|
43
|
-
export function drainPendingMeshCoordinatorEvents(): PendingMeshCoordinatorEvent[] {
|
|
44
|
-
|
|
72
|
+
/** Drain and return all pending coordinator events for meshId, removing them from disk. */
|
|
73
|
+
export function drainPendingMeshCoordinatorEvents(meshId?: string): PendingMeshCoordinatorEvent[] {
|
|
74
|
+
if (!meshId) return [];
|
|
75
|
+
const path = getPendingEventsPath(meshId);
|
|
76
|
+
if (!existsSync(path)) return [];
|
|
77
|
+
try {
|
|
78
|
+
const raw = readFileSync(path, 'utf-8');
|
|
79
|
+
try { unlinkSync(path); } catch { /* concurrent drain already removed it */ }
|
|
80
|
+
return raw.split('\n').filter(Boolean).flatMap(line => {
|
|
81
|
+
try { return [JSON.parse(line) as PendingMeshCoordinatorEvent]; } catch { return []; }
|
|
82
|
+
});
|
|
83
|
+
} catch { return []; }
|
|
45
84
|
}
|
|
46
85
|
|
|
47
86
|
/** Peek at pending coordinator events without draining (non-destructive). */
|
|
48
|
-
export function getPendingMeshCoordinatorEvents(): readonly PendingMeshCoordinatorEvent[] {
|
|
49
|
-
|
|
87
|
+
export function getPendingMeshCoordinatorEvents(meshId?: string): readonly PendingMeshCoordinatorEvent[] {
|
|
88
|
+
if (!meshId) return [];
|
|
89
|
+
const path = getPendingEventsPath(meshId);
|
|
90
|
+
if (!existsSync(path)) return [];
|
|
91
|
+
try {
|
|
92
|
+
const raw = readFileSync(path, 'utf-8');
|
|
93
|
+
return raw.split('\n').filter(Boolean).flatMap(line => {
|
|
94
|
+
try { return [JSON.parse(line) as PendingMeshCoordinatorEvent]; } catch { return []; }
|
|
95
|
+
});
|
|
96
|
+
} catch { return []; }
|
|
50
97
|
}
|
|
51
98
|
|
|
52
|
-
/** Explicitly clear all pending coordinator events. */
|
|
53
|
-
export function clearPendingMeshCoordinatorEvents(): void {
|
|
54
|
-
|
|
99
|
+
/** Explicitly clear all pending coordinator events for a mesh. */
|
|
100
|
+
export function clearPendingMeshCoordinatorEvents(meshId?: string): void {
|
|
101
|
+
if (!meshId) return;
|
|
102
|
+
const path = getPendingEventsPath(meshId);
|
|
103
|
+
if (existsSync(path)) try { unlinkSync(path); } catch { /* already removed */ }
|
|
55
104
|
}
|
|
56
105
|
|
|
57
106
|
function readNonEmptyString(value: unknown): string {
|
|
@@ -140,6 +189,62 @@ function shouldSuppressIntentionalCleanupStop(args: {
|
|
|
140
189
|
return hasRecentIntentionalCleanupStop(args.meshId, args.sessionId, args.nodeId);
|
|
141
190
|
}
|
|
142
191
|
|
|
192
|
+
const RECENT_COMPLETION_FINGERPRINT_TTL_MS = 10 * 60 * 1000;
|
|
193
|
+
const recentCompletionFingerprints = new Map<string, number>();
|
|
194
|
+
|
|
195
|
+
function readEventTimestamp(value: unknown): number | null {
|
|
196
|
+
if (typeof value === 'number' && Number.isFinite(value)) return value;
|
|
197
|
+
if (typeof value === 'string' && value.trim()) {
|
|
198
|
+
const numeric = Number(value);
|
|
199
|
+
if (Number.isFinite(numeric)) return numeric;
|
|
200
|
+
const parsed = Date.parse(value);
|
|
201
|
+
if (Number.isFinite(parsed)) return parsed;
|
|
202
|
+
}
|
|
203
|
+
return null;
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
function buildMeshCompletionFingerprint(args: {
|
|
207
|
+
meshId: string;
|
|
208
|
+
event: string;
|
|
209
|
+
sessionId: string;
|
|
210
|
+
providerType?: string;
|
|
211
|
+
providerSessionId?: string;
|
|
212
|
+
timestamp?: number | null;
|
|
213
|
+
finalSummary?: string;
|
|
214
|
+
}): string {
|
|
215
|
+
const timestampPart = Number.isFinite(args.timestamp)
|
|
216
|
+
? String(args.timestamp)
|
|
217
|
+
: readNonEmptyString(args.finalSummary).slice(0, 200);
|
|
218
|
+
return [
|
|
219
|
+
args.meshId,
|
|
220
|
+
args.event,
|
|
221
|
+
args.sessionId,
|
|
222
|
+
args.providerType || '',
|
|
223
|
+
args.providerSessionId || '',
|
|
224
|
+
timestampPart,
|
|
225
|
+
].join('::');
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
function isDuplicateMeshCompletionEvent(args: {
|
|
229
|
+
meshId: string;
|
|
230
|
+
event: string;
|
|
231
|
+
sessionId: string;
|
|
232
|
+
providerType?: string;
|
|
233
|
+
providerSessionId?: string;
|
|
234
|
+
timestamp?: number | null;
|
|
235
|
+
finalSummary?: string;
|
|
236
|
+
}): boolean {
|
|
237
|
+
const fingerprint = buildMeshCompletionFingerprint(args);
|
|
238
|
+
if (!fingerprint) return false;
|
|
239
|
+
const now = Date.now();
|
|
240
|
+
for (const [key, seenAt] of recentCompletionFingerprints.entries()) {
|
|
241
|
+
if (now - seenAt > RECENT_COMPLETION_FINGERPRINT_TTL_MS) recentCompletionFingerprints.delete(key);
|
|
242
|
+
}
|
|
243
|
+
if (recentCompletionFingerprints.has(fingerprint)) return true;
|
|
244
|
+
recentCompletionFingerprints.set(fingerprint, now);
|
|
245
|
+
return false;
|
|
246
|
+
}
|
|
247
|
+
|
|
143
248
|
|
|
144
249
|
export function tryAssignQueueTask(
|
|
145
250
|
components: DaemonComponents,
|
|
@@ -170,7 +275,16 @@ export function tryAssignQueueTask(
|
|
|
170
275
|
message: task.message,
|
|
171
276
|
}).catch((e: any) => {
|
|
172
277
|
LOG.error('MeshQueue', `Failed to dispatch task via P2P to remote node ${nodeId}: ${e?.message}`);
|
|
173
|
-
|
|
278
|
+
// Revert to pending so the task can be retried rather than permanently failing
|
|
279
|
+
updateTaskStatus(meshId, task.id, 'pending');
|
|
280
|
+
try {
|
|
281
|
+
appendLedgerEntry(meshId, {
|
|
282
|
+
kind: 'dispatch_failed' as any,
|
|
283
|
+
nodeId,
|
|
284
|
+
sessionId,
|
|
285
|
+
payload: { taskId: task.id, error: e?.message, retryable: true },
|
|
286
|
+
});
|
|
287
|
+
} catch { /* ledger write is best-effort */ }
|
|
174
288
|
});
|
|
175
289
|
return true;
|
|
176
290
|
}
|
|
@@ -593,6 +707,23 @@ function injectMeshSystemMessage(components: DaemonComponents, args: {
|
|
|
593
707
|
return { success: true, forwarded: 0, suppressed: true, intentionalCleanupStop: true };
|
|
594
708
|
}
|
|
595
709
|
|
|
710
|
+
const eventTimestamp = readEventTimestamp(args.metadataEvent.timestamp);
|
|
711
|
+
if (args.event === 'agent:generating_completed' && eventSessionId) {
|
|
712
|
+
const duplicateCompletion = isDuplicateMeshCompletionEvent({
|
|
713
|
+
meshId: args.meshId,
|
|
714
|
+
event: args.event,
|
|
715
|
+
sessionId: eventSessionId,
|
|
716
|
+
providerType: readNonEmptyString(args.metadataEvent.providerType) || undefined,
|
|
717
|
+
providerSessionId: readNonEmptyString(args.metadataEvent.providerSessionId) || undefined,
|
|
718
|
+
timestamp: eventTimestamp,
|
|
719
|
+
finalSummary: readNonEmptyString(args.metadataEvent.finalSummary) || undefined,
|
|
720
|
+
});
|
|
721
|
+
if (duplicateCompletion) {
|
|
722
|
+
LOG.info('MeshEvents', `Suppressed duplicate completion for mesh ${args.meshId} session ${eventSessionId}`);
|
|
723
|
+
return { success: true, forwarded: 0, suppressed: true, duplicateCompletion: true };
|
|
724
|
+
}
|
|
725
|
+
}
|
|
726
|
+
|
|
596
727
|
// ── Task Queue & Ledger ──
|
|
597
728
|
let completedTaskForLedger: { id?: string } | null = null;
|
|
598
729
|
if (args.event === 'agent:generating_completed') {
|
|
@@ -601,13 +732,16 @@ function injectMeshSystemMessage(components: DaemonComponents, args: {
|
|
|
601
732
|
const providerType = readNonEmptyString(args.metadataEvent.providerType);
|
|
602
733
|
|
|
603
734
|
if (sessionId) {
|
|
604
|
-
const completedTask = updateSessionTaskStatus(args.meshId, sessionId, 'completed'
|
|
735
|
+
const completedTask = updateSessionTaskStatus(args.meshId, sessionId, 'completed', {
|
|
736
|
+
occurredAt: eventTimestamp !== null ? new Date(eventTimestamp).toISOString() : undefined,
|
|
737
|
+
});
|
|
605
738
|
completedTaskForLedger = completedTask ? { id: completedTask.id } : null;
|
|
606
739
|
if (nodeId && providerType) {
|
|
607
|
-
//
|
|
608
|
-
|
|
740
|
+
// Queue state is already updated above; setImmediate avoids the
|
|
741
|
+
// 500 ms artificial delay while still deferring past this call frame.
|
|
742
|
+
setImmediate(() => {
|
|
609
743
|
tryAssignQueueTask(components, args.meshId, nodeId, sessionId, providerType);
|
|
610
|
-
}
|
|
744
|
+
});
|
|
611
745
|
}
|
|
612
746
|
}
|
|
613
747
|
} else if (args.event === 'agent:ready') {
|
|
@@ -648,13 +782,15 @@ function injectMeshSystemMessage(components: DaemonComponents, args: {
|
|
|
648
782
|
}
|
|
649
783
|
|
|
650
784
|
if (sessionId && nodeId && providerType) {
|
|
651
|
-
|
|
652
|
-
|
|
785
|
+
sweepExpiredRemoteIdleSessions();
|
|
786
|
+
remoteIdleSessions.set(`${nodeId}:${sessionId}`, {
|
|
787
|
+
nodeId, sessionId, providerType,
|
|
788
|
+
expiresAt: Date.now() + REMOTE_IDLE_SESSION_TTL_MS,
|
|
789
|
+
});
|
|
790
|
+
setImmediate(() => {
|
|
653
791
|
const assigned = tryAssignQueueTask(components, args.meshId, nodeId, sessionId, providerType);
|
|
654
|
-
if (assigned) {
|
|
655
|
-
|
|
656
|
-
}
|
|
657
|
-
}, 500);
|
|
792
|
+
if (assigned) remoteIdleSessions.delete(`${nodeId}:${sessionId}`);
|
|
793
|
+
});
|
|
658
794
|
}
|
|
659
795
|
} else if (args.event === 'agent:generating_started') {
|
|
660
796
|
const sessionId = resolveEventSessionId(args.metadataEvent, args.sourceInstanceId);
|
|
@@ -781,17 +917,18 @@ function injectMeshSystemMessage(components: DaemonComponents, args: {
|
|
|
781
917
|
|
|
782
918
|
if (coordinatorInstances.length === 0) {
|
|
783
919
|
// No CLI coordinator session found — buffer for MCP-based coordinators.
|
|
784
|
-
if (
|
|
785
|
-
pendingMeshCoordinatorEvents.push({
|
|
920
|
+
if (queuePendingMeshCoordinatorEvent({
|
|
786
921
|
event: args.event,
|
|
787
922
|
meshId: args.meshId,
|
|
788
923
|
nodeLabel: args.nodeLabel,
|
|
924
|
+
nodeId: args.nodeId || undefined,
|
|
925
|
+
workspace: readNonEmptyString(args.metadataEvent.workspace),
|
|
789
926
|
metadataEvent: {
|
|
790
927
|
...args.metadataEvent,
|
|
791
928
|
...(recoveryContext ? { recoveryContext } : {}),
|
|
792
929
|
},
|
|
793
930
|
queuedAt: Date.now(),
|
|
794
|
-
})
|
|
931
|
+
})) {
|
|
795
932
|
LOG.info('MeshEvents', `Queued ${args.event} for MCP coordinator (mesh ${args.meshId})`);
|
|
796
933
|
}
|
|
797
934
|
return { success: true, forwarded: 0 };
|
|
@@ -834,6 +971,7 @@ export function handleMeshForwardEvent(components: DaemonComponents, payload: Re
|
|
|
834
971
|
providerType: readNonEmptyString(payload.providerType),
|
|
835
972
|
providerSessionId: readNonEmptyString(payload.providerSessionId),
|
|
836
973
|
finalSummary: readNonEmptyString(payload.finalSummary) || readNonEmptyString(payload.summary),
|
|
974
|
+
...(payload.timestamp !== undefined ? { timestamp: payload.timestamp } : {}),
|
|
837
975
|
intentional: payload.intentional === true,
|
|
838
976
|
intentionalStop: payload.intentionalStop === true,
|
|
839
977
|
operatorCleanup: payload.operatorCleanup === true,
|
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
import type { RepoMeshDaemonRole, RepoMeshHostMetadata, RepoMeshHostStatus } from '../repo-mesh-types.js';
|
|
2
|
+
|
|
3
|
+
function readObject(value: unknown): Record<string, unknown> | null {
|
|
4
|
+
return value && typeof value === 'object' && !Array.isArray(value) ? value as Record<string, unknown> : null;
|
|
5
|
+
}
|
|
6
|
+
|
|
7
|
+
function readString(value: unknown): string | undefined {
|
|
8
|
+
return typeof value === 'string' && value.trim() ? value.trim() : undefined;
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
export function normalizeMeshDaemonRole(value: unknown): RepoMeshDaemonRole | undefined {
|
|
12
|
+
return value === 'host' || value === 'member' ? value : undefined;
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
export function resolveMeshHostStatus(mesh: unknown): RepoMeshHostStatus {
|
|
16
|
+
const meshRecord = readObject(mesh);
|
|
17
|
+
const raw = readObject(meshRecord?.meshHost);
|
|
18
|
+
const role = normalizeMeshDaemonRole(raw?.role) ?? 'host';
|
|
19
|
+
const pairing = readObject(raw?.pairing);
|
|
20
|
+
const normalized: RepoMeshHostStatus = {
|
|
21
|
+
role,
|
|
22
|
+
canOwnCoordinator: role === 'host',
|
|
23
|
+
canOwnQueue: role === 'host',
|
|
24
|
+
defaulted: !raw,
|
|
25
|
+
};
|
|
26
|
+
const hostDaemonId = readString(raw?.hostDaemonId);
|
|
27
|
+
const hostNodeId = readString(raw?.hostNodeId);
|
|
28
|
+
const hostAddress = readString(raw?.hostAddress);
|
|
29
|
+
if (hostDaemonId) normalized.hostDaemonId = hostDaemonId;
|
|
30
|
+
if (hostNodeId) normalized.hostNodeId = hostNodeId;
|
|
31
|
+
if (hostAddress) normalized.hostAddress = hostAddress;
|
|
32
|
+
if (pairing) {
|
|
33
|
+
const status = pairing.status === 'pairing' || pairing.status === 'paired' || pairing.status === 'rejected' || pairing.status === 'revoked'
|
|
34
|
+
? pairing.status
|
|
35
|
+
: 'not_configured';
|
|
36
|
+
normalized.pairing = {
|
|
37
|
+
status,
|
|
38
|
+
...(readString(pairing.tokenId) ? { tokenId: readString(pairing.tokenId) } : {}),
|
|
39
|
+
...(readString(pairing.joinedAt) ? { joinedAt: readString(pairing.joinedAt) } : {}),
|
|
40
|
+
...(readString(pairing.lastPairedAt) ? { lastPairedAt: readString(pairing.lastPairedAt) } : {}),
|
|
41
|
+
...(readString(pairing.lastRejectedAt) ? { lastRejectedAt: readString(pairing.lastRejectedAt) } : {}),
|
|
42
|
+
...(readString(pairing.expiresAt) ? { expiresAt: readString(pairing.expiresAt) } : {}),
|
|
43
|
+
};
|
|
44
|
+
}
|
|
45
|
+
return normalized;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
export function isMeshHostOwner(mesh: unknown): boolean {
|
|
49
|
+
return resolveMeshHostStatus(mesh).role === 'host';
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
export function buildMeshHostRequiredFailure(mesh: unknown, operation: string): Record<string, unknown> {
|
|
53
|
+
const meshHost = resolveMeshHostStatus(mesh);
|
|
54
|
+
return {
|
|
55
|
+
success: false,
|
|
56
|
+
code: 'mesh_host_required',
|
|
57
|
+
error: `Mesh Host daemon required for ${operation}; member daemons must pair with the host and cannot own coordinator/queue mutations.`,
|
|
58
|
+
meshHost,
|
|
59
|
+
};
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
export function requireMeshHostQueueOwner(opts?: { ownerRole?: RepoMeshDaemonRole }): void {
|
|
63
|
+
if (opts?.ownerRole === 'member') {
|
|
64
|
+
throw new Error('Mesh Host daemon required to mutate mesh queue; member daemons must use the host-owned queue.');
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
export function createDefaultMeshHostMetadata(): RepoMeshHostMetadata {
|
|
69
|
+
return {
|
|
70
|
+
role: 'host',
|
|
71
|
+
pairing: { status: 'not_configured' },
|
|
72
|
+
};
|
|
73
|
+
}
|