@adhdev/daemon-core 0.9.82-rc.5 → 0.9.82-rc.51
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
|
@@ -1,7 +1,9 @@
|
|
|
1
|
-
import { existsSync, writeFileSync, readFileSync } from 'fs';
|
|
1
|
+
import { existsSync, writeFileSync, readFileSync, openSync, closeSync, unlinkSync } from 'fs';
|
|
2
2
|
import { join } from 'path';
|
|
3
3
|
import { randomUUID } from 'crypto';
|
|
4
4
|
import { getLedgerDir } from './mesh-ledger.js';
|
|
5
|
+
import { requireMeshHostQueueOwner } from './mesh-host-ownership.js';
|
|
6
|
+
import type { RepoMeshDaemonRole } from '../repo-mesh-types.js';
|
|
5
7
|
|
|
6
8
|
export type MeshTaskStatus = 'pending' | 'assigned' | 'completed' | 'failed' | 'cancelled';
|
|
7
9
|
export type MeshActiveTaskStatus = Extract<MeshTaskStatus, 'pending' | 'assigned'>;
|
|
@@ -45,11 +47,40 @@ export interface MeshWorkQueueEntry {
|
|
|
45
47
|
updatedAt: string;
|
|
46
48
|
}
|
|
47
49
|
|
|
50
|
+
export interface MeshQueueMutationOptions {
|
|
51
|
+
ownerRole?: RepoMeshDaemonRole;
|
|
52
|
+
}
|
|
53
|
+
|
|
48
54
|
function getQueuePath(meshId: string): string {
|
|
49
55
|
const safe = meshId.replace(/[^a-zA-Z0-9_-]/g, '_');
|
|
50
56
|
return join(getLedgerDir(), `${safe}.queue.json`);
|
|
51
57
|
}
|
|
52
58
|
|
|
59
|
+
function getLockPath(meshId: string): string {
|
|
60
|
+
const safe = meshId.replace(/[^a-zA-Z0-9_-]/g, '_');
|
|
61
|
+
return join(getLedgerDir(), `${safe}.queue.lock`);
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
/**
|
|
65
|
+
* Simple advisory file lock using O_EXCL (atomic create) for queue mutations.
|
|
66
|
+
* Retries up to 10 times at 30 ms intervals; proceeds without lock on timeout
|
|
67
|
+
* to prevent deadlock (best-effort — far better than no locking at all).
|
|
68
|
+
*/
|
|
69
|
+
function withQueueLock<T>(meshId: string, fn: () => T): T {
|
|
70
|
+
const lockPath = getLockPath(meshId);
|
|
71
|
+
let fd = -1;
|
|
72
|
+
for (let i = 0; i < 10; i++) {
|
|
73
|
+
try { fd = openSync(lockPath, 'wx'); break; } catch {
|
|
74
|
+
const deadline = Date.now() + 30;
|
|
75
|
+
while (Date.now() < deadline) { /* spin */ }
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
try { return fn(); } finally {
|
|
79
|
+
if (fd !== -1) try { closeSync(fd); } catch { /* noop */ }
|
|
80
|
+
try { unlinkSync(lockPath); } catch { /* already removed */ }
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
|
|
53
84
|
function readQueue(meshId: string): MeshWorkQueueEntry[] {
|
|
54
85
|
const path = getQueuePath(meshId);
|
|
55
86
|
if (!existsSync(path)) return [];
|
|
@@ -72,22 +103,25 @@ function writeQueue(meshId: string, queue: MeshWorkQueueEntry[]): void {
|
|
|
72
103
|
export function enqueueTask(
|
|
73
104
|
meshId: string,
|
|
74
105
|
message: string,
|
|
75
|
-
opts?: { targetNodeId?: string; targetSessionId?: string }
|
|
106
|
+
opts?: { targetNodeId?: string; targetSessionId?: string } & MeshQueueMutationOptions,
|
|
76
107
|
): MeshWorkQueueEntry {
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
108
|
+
requireMeshHostQueueOwner(opts);
|
|
109
|
+
return withQueueLock(meshId, () => {
|
|
110
|
+
const queue = readQueue(meshId);
|
|
111
|
+
const entry: MeshWorkQueueEntry = {
|
|
112
|
+
id: randomUUID(),
|
|
113
|
+
meshId,
|
|
114
|
+
message,
|
|
115
|
+
status: 'pending',
|
|
116
|
+
targetNodeId: opts?.targetNodeId,
|
|
117
|
+
targetSessionId: opts?.targetSessionId,
|
|
118
|
+
createdAt: new Date().toISOString(),
|
|
119
|
+
updatedAt: new Date().toISOString(),
|
|
120
|
+
};
|
|
121
|
+
queue.push(entry);
|
|
122
|
+
writeQueue(meshId, queue);
|
|
123
|
+
return entry;
|
|
124
|
+
});
|
|
91
125
|
}
|
|
92
126
|
|
|
93
127
|
/**
|
|
@@ -106,39 +140,29 @@ export function getQueue(meshId: string, opts?: { status?: MeshTaskStatus[] }):
|
|
|
106
140
|
* Find the next pending task that this node is allowed to claim, and mark it as assigned.
|
|
107
141
|
*/
|
|
108
142
|
export function claimNextTask(meshId: string, nodeId: string, sessionId: string): MeshWorkQueueEntry | null {
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
q.
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
const entry = queue[targetIdx];
|
|
134
|
-
entry.status = 'assigned';
|
|
135
|
-
entry.assignedNodeId = nodeId;
|
|
136
|
-
entry.assignedSessionId = sessionId;
|
|
137
|
-
entry.dispatchTimestamp = new Date().toISOString();
|
|
138
|
-
entry.updatedAt = new Date().toISOString();
|
|
139
|
-
|
|
140
|
-
writeQueue(meshId, queue);
|
|
141
|
-
return entry;
|
|
143
|
+
return withQueueLock(meshId, () => {
|
|
144
|
+
const queue = readQueue(meshId);
|
|
145
|
+
const hasActiveAssignment = queue.some(q => q.status === 'assigned' && (
|
|
146
|
+
q.assignedSessionId === sessionId || q.assignedNodeId === nodeId
|
|
147
|
+
));
|
|
148
|
+
if (hasActiveAssignment) return null;
|
|
149
|
+
let targetIdx = queue.findIndex(q => q.status === 'pending' && q.targetSessionId === sessionId);
|
|
150
|
+
if (targetIdx === -1) {
|
|
151
|
+
targetIdx = queue.findIndex(q => q.status === 'pending' && q.targetNodeId === nodeId && !q.targetSessionId);
|
|
152
|
+
}
|
|
153
|
+
if (targetIdx === -1) {
|
|
154
|
+
targetIdx = queue.findIndex(q => q.status === 'pending' && !q.targetNodeId && !q.targetSessionId);
|
|
155
|
+
}
|
|
156
|
+
if (targetIdx === -1) return null;
|
|
157
|
+
const entry = queue[targetIdx];
|
|
158
|
+
entry.status = 'assigned';
|
|
159
|
+
entry.assignedNodeId = nodeId;
|
|
160
|
+
entry.assignedSessionId = sessionId;
|
|
161
|
+
entry.dispatchTimestamp = new Date().toISOString();
|
|
162
|
+
entry.updatedAt = new Date().toISOString();
|
|
163
|
+
writeQueue(meshId, queue);
|
|
164
|
+
return entry;
|
|
165
|
+
});
|
|
142
166
|
}
|
|
143
167
|
|
|
144
168
|
/**
|
|
@@ -149,15 +173,18 @@ export function updateTaskStatus(
|
|
|
149
173
|
meshId: string,
|
|
150
174
|
taskId: string,
|
|
151
175
|
status: MeshTaskStatus,
|
|
176
|
+
opts?: MeshQueueMutationOptions,
|
|
152
177
|
): MeshWorkQueueEntry | null {
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
178
|
+
requireMeshHostQueueOwner(opts);
|
|
179
|
+
return withQueueLock(meshId, () => {
|
|
180
|
+
const queue = readQueue(meshId);
|
|
181
|
+
const idx = queue.findIndex(q => q.id === taskId);
|
|
182
|
+
if (idx === -1) return null;
|
|
183
|
+
queue[idx].status = status;
|
|
184
|
+
queue[idx].updatedAt = new Date().toISOString();
|
|
185
|
+
writeQueue(meshId, queue);
|
|
186
|
+
return queue[idx];
|
|
187
|
+
});
|
|
161
188
|
}
|
|
162
189
|
|
|
163
190
|
export function recordTaskAutoLaunch(
|
|
@@ -165,17 +192,16 @@ export function recordTaskAutoLaunch(
|
|
|
165
192
|
taskId: string,
|
|
166
193
|
autoLaunch: Omit<NonNullable<MeshWorkQueueEntry['autoLaunch']>, 'updatedAt'>,
|
|
167
194
|
): MeshWorkQueueEntry | null {
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
...autoLaunch,
|
|
174
|
-
updatedAt
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
return queue[idx];
|
|
195
|
+
return withQueueLock(meshId, () => {
|
|
196
|
+
const queue = readQueue(meshId);
|
|
197
|
+
const idx = queue.findIndex(q => q.id === taskId);
|
|
198
|
+
if (idx === -1) return null;
|
|
199
|
+
const now = new Date().toISOString();
|
|
200
|
+
queue[idx].autoLaunch = { ...autoLaunch, updatedAt: now };
|
|
201
|
+
queue[idx].updatedAt = now;
|
|
202
|
+
writeQueue(meshId, queue);
|
|
203
|
+
return queue[idx];
|
|
204
|
+
});
|
|
179
205
|
}
|
|
180
206
|
|
|
181
207
|
/**
|
|
@@ -184,19 +210,21 @@ export function recordTaskAutoLaunch(
|
|
|
184
210
|
export function cancelTask(
|
|
185
211
|
meshId: string,
|
|
186
212
|
taskId: string,
|
|
187
|
-
opts?: { reason?: string },
|
|
213
|
+
opts?: { reason?: string } & MeshQueueMutationOptions,
|
|
188
214
|
): MeshWorkQueueEntry | null {
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
215
|
+
requireMeshHostQueueOwner(opts);
|
|
216
|
+
return withQueueLock(meshId, () => {
|
|
217
|
+
const queue = readQueue(meshId);
|
|
218
|
+
const idx = queue.findIndex(q => q.id === taskId);
|
|
219
|
+
if (idx === -1) return null;
|
|
220
|
+
const now = new Date().toISOString();
|
|
221
|
+
queue[idx].status = 'cancelled';
|
|
222
|
+
queue[idx].updatedAt = now;
|
|
223
|
+
queue[idx].cancelledAt = now;
|
|
224
|
+
if (opts?.reason) queue[idx].cancelReason = opts.reason;
|
|
225
|
+
writeQueue(meshId, queue);
|
|
226
|
+
return queue[idx];
|
|
227
|
+
});
|
|
200
228
|
}
|
|
201
229
|
|
|
202
230
|
/**
|
|
@@ -212,29 +240,31 @@ export function requeueTask(
|
|
|
212
240
|
targetSessionId?: string;
|
|
213
241
|
clearTargetNode?: boolean;
|
|
214
242
|
clearTargetSession?: boolean;
|
|
215
|
-
},
|
|
243
|
+
} & MeshQueueMutationOptions,
|
|
216
244
|
): MeshWorkQueueEntry | null {
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
245
|
+
requireMeshHostQueueOwner(opts);
|
|
246
|
+
return withQueueLock(meshId, () => {
|
|
247
|
+
const queue = readQueue(meshId);
|
|
248
|
+
const idx = queue.findIndex(q => q.id === taskId);
|
|
249
|
+
if (idx === -1) return null;
|
|
250
|
+
const entry = queue[idx];
|
|
251
|
+
const now = new Date().toISOString();
|
|
252
|
+
entry.status = 'pending';
|
|
253
|
+
delete entry.assignedNodeId;
|
|
254
|
+
delete entry.assignedSessionId;
|
|
255
|
+
delete entry.cancelledAt;
|
|
256
|
+
delete entry.cancelReason;
|
|
257
|
+
if (opts?.clearTargetNode) delete entry.targetNodeId;
|
|
258
|
+
if (typeof opts?.targetNodeId === 'string') entry.targetNodeId = opts.targetNodeId;
|
|
259
|
+
if (opts?.clearTargetSession !== false) delete entry.targetSessionId;
|
|
260
|
+
if (typeof opts?.targetSessionId === 'string') entry.targetSessionId = opts.targetSessionId;
|
|
261
|
+
entry.updatedAt = now;
|
|
262
|
+
entry.requeuedAt = now;
|
|
263
|
+
entry.requeueCount = (entry.requeueCount || 0) + 1;
|
|
264
|
+
if (opts?.reason) entry.requeueReason = opts.reason;
|
|
265
|
+
writeQueue(meshId, queue);
|
|
266
|
+
return entry;
|
|
267
|
+
});
|
|
238
268
|
}
|
|
239
269
|
|
|
240
270
|
/**
|
|
@@ -244,29 +274,26 @@ export function updateSessionTaskStatus(
|
|
|
244
274
|
meshId: string,
|
|
245
275
|
sessionId: string,
|
|
246
276
|
status: MeshTaskStatus,
|
|
277
|
+
opts?: { occurredAt?: string },
|
|
247
278
|
): MeshWorkQueueEntry | null {
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
if (queue[i].assignedSessionId === sessionId && queue[i].status === 'assigned') {
|
|
279
|
+
return withQueueLock(meshId, () => {
|
|
280
|
+
const queue = readQueue(meshId);
|
|
281
|
+
const occurredAtTime = opts?.occurredAt ? new Date(opts.occurredAt).getTime() : Number.NaN;
|
|
282
|
+
const hasOccurredAt = Number.isFinite(occurredAtTime);
|
|
283
|
+
let bestIdx = -1;
|
|
284
|
+
let bestTime = 0;
|
|
285
|
+
for (let i = queue.length - 1; i >= 0; i--) {
|
|
286
|
+
if (queue[i].assignedSessionId !== sessionId || queue[i].status !== 'assigned') continue;
|
|
257
287
|
const time = new Date(queue[i].dispatchTimestamp || queue[i].updatedAt).getTime();
|
|
258
|
-
if (time >
|
|
259
|
-
|
|
260
|
-
bestIdx = i;
|
|
261
|
-
}
|
|
288
|
+
if (hasOccurredAt && Number.isFinite(time) && time > occurredAtTime) continue;
|
|
289
|
+
if (time > bestTime) { bestTime = time; bestIdx = i; }
|
|
262
290
|
}
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
return queue[bestIdx];
|
|
291
|
+
if (bestIdx === -1) return null;
|
|
292
|
+
queue[bestIdx].status = status;
|
|
293
|
+
queue[bestIdx].updatedAt = new Date().toISOString();
|
|
294
|
+
writeQueue(meshId, queue);
|
|
295
|
+
return queue[bestIdx];
|
|
296
|
+
});
|
|
270
297
|
}
|
|
271
298
|
|
|
272
299
|
export interface MeshWorkQueueStats {
|
|
@@ -0,0 +1,306 @@
|
|
|
1
|
+
import { existsSync, readFileSync } from 'fs';
|
|
2
|
+
import { join } from 'path';
|
|
3
|
+
import * as yaml from 'js-yaml';
|
|
4
|
+
|
|
5
|
+
export const MESH_REFINE_VALIDATION_CATEGORIES = ['typecheck', 'test', 'lint', 'build'] as const;
|
|
6
|
+
export type MeshRefineValidationCategory = typeof MESH_REFINE_VALIDATION_CATEGORIES[number];
|
|
7
|
+
|
|
8
|
+
export interface RepoMeshRefineValidationCommandConfig {
|
|
9
|
+
/** Executable name or a whitespace-tokenized command string. Never executed through a shell. */
|
|
10
|
+
command: string;
|
|
11
|
+
/** Optional explicit argv. Prefer this over shell-like command strings. */
|
|
12
|
+
args?: string[];
|
|
13
|
+
category?: MeshRefineValidationCategory;
|
|
14
|
+
cwd?: string;
|
|
15
|
+
timeoutMs?: number;
|
|
16
|
+
env?: Record<string, string>;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
export interface RepoMeshRefineConfig {
|
|
20
|
+
version: 1;
|
|
21
|
+
validation?: {
|
|
22
|
+
required?: boolean;
|
|
23
|
+
commands?: RepoMeshRefineValidationCommandConfig[];
|
|
24
|
+
};
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
export interface MeshRefineValidationCommandPlan {
|
|
28
|
+
command: string;
|
|
29
|
+
args: string[];
|
|
30
|
+
displayCommand: string;
|
|
31
|
+
category: MeshRefineValidationCategory | 'custom';
|
|
32
|
+
source: string;
|
|
33
|
+
cwd?: string;
|
|
34
|
+
timeoutMs?: number;
|
|
35
|
+
env?: Record<string, string>;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
export interface MeshRefineConfigLoadResult {
|
|
39
|
+
config?: RepoMeshRefineConfig;
|
|
40
|
+
source: string;
|
|
41
|
+
sourceType: 'mesh_policy' | 'repo_file' | 'unavailable' | 'invalid';
|
|
42
|
+
path?: string;
|
|
43
|
+
error?: string;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
export interface MeshRefineValidationPlan {
|
|
47
|
+
source: string;
|
|
48
|
+
sourceType: MeshRefineConfigLoadResult['sourceType'];
|
|
49
|
+
commands: MeshRefineValidationCommandPlan[];
|
|
50
|
+
rejectedCommands: Array<Record<string, unknown>>;
|
|
51
|
+
suggestions: RepoMeshRefineValidationCommandConfig[];
|
|
52
|
+
suggestedConfig?: RepoMeshRefineConfig;
|
|
53
|
+
unavailableReason?: string;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
export const MESH_REFINE_CONFIG_LOCATIONS = [
|
|
57
|
+
'.adhdev/refine.json',
|
|
58
|
+
'.adhdev/refine.yaml',
|
|
59
|
+
'.adhdev/refine.yml',
|
|
60
|
+
'.adhdev/repo-mesh-refine.json',
|
|
61
|
+
'.adhdev/repo-mesh-refine.yaml',
|
|
62
|
+
'.adhdev/repo-mesh-refine.yml',
|
|
63
|
+
'repo-mesh.refine.json',
|
|
64
|
+
'repo-mesh.refine.yaml',
|
|
65
|
+
'repo-mesh.refine.yml',
|
|
66
|
+
];
|
|
67
|
+
|
|
68
|
+
export const MESH_REFINE_CONFIG_SCHEMA = {
|
|
69
|
+
$schema: 'https://json-schema.org/draft/2020-12/schema',
|
|
70
|
+
title: 'ADHDev Repo Mesh Refinery Config',
|
|
71
|
+
type: 'object',
|
|
72
|
+
additionalProperties: false,
|
|
73
|
+
required: ['version'],
|
|
74
|
+
properties: {
|
|
75
|
+
version: { const: 1 },
|
|
76
|
+
validation: {
|
|
77
|
+
type: 'object',
|
|
78
|
+
additionalProperties: false,
|
|
79
|
+
properties: {
|
|
80
|
+
required: { type: 'boolean', default: true },
|
|
81
|
+
commands: {
|
|
82
|
+
type: 'array',
|
|
83
|
+
minItems: 1,
|
|
84
|
+
maxItems: 8,
|
|
85
|
+
items: {
|
|
86
|
+
type: 'object',
|
|
87
|
+
additionalProperties: false,
|
|
88
|
+
required: ['command'],
|
|
89
|
+
properties: {
|
|
90
|
+
command: { type: 'string', minLength: 1 },
|
|
91
|
+
args: { type: 'array', items: { type: 'string' } },
|
|
92
|
+
category: { enum: [...MESH_REFINE_VALIDATION_CATEGORIES, 'custom'] },
|
|
93
|
+
cwd: { type: 'string' },
|
|
94
|
+
timeoutMs: { type: 'number', minimum: 1000, maximum: 600000 },
|
|
95
|
+
env: { type: 'object', additionalProperties: { type: 'string' } },
|
|
96
|
+
},
|
|
97
|
+
},
|
|
98
|
+
},
|
|
99
|
+
},
|
|
100
|
+
},
|
|
101
|
+
},
|
|
102
|
+
} as const;
|
|
103
|
+
|
|
104
|
+
function isRecord(value: unknown): value is Record<string, unknown> {
|
|
105
|
+
return !!value && typeof value === 'object' && !Array.isArray(value);
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
function tokenizeCommandString(command: string): string[] | null {
|
|
109
|
+
const trimmed = command.trim();
|
|
110
|
+
if (!trimmed) return null;
|
|
111
|
+
// Explicit config may name any executable, but the Refinery never invokes a shell.
|
|
112
|
+
// Reject shell syntax, quotes and substitutions so config cannot smuggle a compound command.
|
|
113
|
+
if (/[;&|<>`$\\\n\r'\"]/.test(trimmed)) return null;
|
|
114
|
+
const tokens = trimmed.split(/\s+/).filter(Boolean);
|
|
115
|
+
if (!tokens.length) return null;
|
|
116
|
+
if (tokens.some(token => !/^[A-Za-z0-9_@./:=+-]+$/.test(token))) return null;
|
|
117
|
+
return tokens;
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
function validateCategory(value: unknown): MeshRefineValidationCategory | 'custom' {
|
|
121
|
+
return typeof value === 'string' && ([...MESH_REFINE_VALIDATION_CATEGORIES, 'custom'] as string[]).includes(value)
|
|
122
|
+
? value as MeshRefineValidationCategory | 'custom'
|
|
123
|
+
: 'custom';
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
function normalizeCommandConfig(entry: unknown, source: string): { command?: MeshRefineValidationCommandPlan; rejected?: Record<string, unknown> } {
|
|
127
|
+
if (!isRecord(entry) || typeof entry.command !== 'string') {
|
|
128
|
+
return { rejected: { source, reason: 'validation command must be an object with a command string' } };
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
const commandText = entry.command.trim();
|
|
132
|
+
const explicitArgs = Array.isArray(entry.args) ? entry.args : undefined;
|
|
133
|
+
if (explicitArgs && !explicitArgs.every(arg => typeof arg === 'string')) {
|
|
134
|
+
return { rejected: { source, command: commandText, reason: 'args must be an array of strings' } };
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
let command = commandText;
|
|
138
|
+
let args = explicitArgs ? [...explicitArgs] : [];
|
|
139
|
+
if (!explicitArgs) {
|
|
140
|
+
const tokens = tokenizeCommandString(commandText);
|
|
141
|
+
if (!tokens) return { rejected: { source, command: commandText, reason: 'unsafe command string is not allowlisted' } };
|
|
142
|
+
command = tokens[0];
|
|
143
|
+
args = tokens.slice(1);
|
|
144
|
+
} else if (!tokenizeCommandString(command)) {
|
|
145
|
+
return { rejected: { source, command: commandText, reason: 'unsafe executable name is not allowlisted' } };
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
if (args.some(arg => /[\n\r\0]/.test(arg))) {
|
|
149
|
+
return { rejected: { source, command: commandText, reason: 'args cannot contain control characters' } };
|
|
150
|
+
}
|
|
151
|
+
if (entry.cwd !== undefined && typeof entry.cwd !== 'string') {
|
|
152
|
+
return { rejected: { source, command: commandText, reason: 'cwd must be a string when provided' } };
|
|
153
|
+
}
|
|
154
|
+
if (entry.timeoutMs !== undefined && (typeof entry.timeoutMs !== 'number' || !Number.isFinite(entry.timeoutMs) || entry.timeoutMs < 1000 || entry.timeoutMs > 600000)) {
|
|
155
|
+
return { rejected: { source, command: commandText, reason: 'timeoutMs must be between 1000 and 600000' } };
|
|
156
|
+
}
|
|
157
|
+
if (entry.env !== undefined && (!isRecord(entry.env) || !Object.values(entry.env).every(value => typeof value === 'string'))) {
|
|
158
|
+
return { rejected: { source, command: commandText, reason: 'env must be an object of string values' } };
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
return {
|
|
162
|
+
command: {
|
|
163
|
+
command,
|
|
164
|
+
args,
|
|
165
|
+
displayCommand: [command, ...args].join(' '),
|
|
166
|
+
category: validateCategory(entry.category),
|
|
167
|
+
source,
|
|
168
|
+
...(typeof entry.cwd === 'string' && entry.cwd.trim() ? { cwd: entry.cwd.trim() } : {}),
|
|
169
|
+
...(typeof entry.timeoutMs === 'number' ? { timeoutMs: entry.timeoutMs } : {}),
|
|
170
|
+
...(isRecord(entry.env) ? { env: entry.env as Record<string, string> } : {}),
|
|
171
|
+
},
|
|
172
|
+
};
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
export function validateMeshRefineConfig(config: unknown, source = 'inline'): { valid: boolean; errors: string[]; commands: MeshRefineValidationCommandPlan[]; rejectedCommands: Array<Record<string, unknown>> } {
|
|
176
|
+
const errors: string[] = [];
|
|
177
|
+
const commands: MeshRefineValidationCommandPlan[] = [];
|
|
178
|
+
const rejectedCommands: Array<Record<string, unknown>> = [];
|
|
179
|
+
|
|
180
|
+
if (!isRecord(config)) return { valid: false, errors: ['config must be an object'], commands, rejectedCommands };
|
|
181
|
+
if (config.version !== 1) errors.push('version must be 1');
|
|
182
|
+
const validation = config.validation;
|
|
183
|
+
if (validation !== undefined && !isRecord(validation)) errors.push('validation must be an object');
|
|
184
|
+
const rawCommands = isRecord(validation) ? validation.commands : undefined;
|
|
185
|
+
if (rawCommands !== undefined && !Array.isArray(rawCommands)) errors.push('validation.commands must be an array');
|
|
186
|
+
if (Array.isArray(rawCommands)) {
|
|
187
|
+
rawCommands.forEach((entry, index) => {
|
|
188
|
+
const normalized = normalizeCommandConfig(entry, `${source}:validation.commands[${index}]`);
|
|
189
|
+
if (normalized.command) commands.push(normalized.command);
|
|
190
|
+
if (normalized.rejected) rejectedCommands.push(normalized.rejected);
|
|
191
|
+
});
|
|
192
|
+
}
|
|
193
|
+
if (rejectedCommands.length) errors.push('one or more validation commands are invalid');
|
|
194
|
+
return { valid: errors.length === 0, errors, commands, rejectedCommands };
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
function parseConfigText(path: string, text: string): unknown {
|
|
198
|
+
if (/\.json$/i.test(path)) return JSON.parse(text);
|
|
199
|
+
return yaml.load(text);
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
export function loadMeshRefineConfig(mesh: any, workspace: string): MeshRefineConfigLoadResult {
|
|
203
|
+
const policy = mesh?.policy && typeof mesh.policy === 'object' && !Array.isArray(mesh.policy) ? mesh.policy : {};
|
|
204
|
+
const inline = mesh?.refineConfig || (policy as any).refineConfig || (policy as any).refine;
|
|
205
|
+
if (inline !== undefined) {
|
|
206
|
+
const validation = validateMeshRefineConfig(inline, 'mesh.policy.refineConfig');
|
|
207
|
+
if (!validation.valid) return { source: 'mesh.policy.refineConfig', sourceType: 'invalid', error: String(validation.rejectedCommands[0]?.reason || validation.errors.join('; ')) };
|
|
208
|
+
return { config: inline as RepoMeshRefineConfig, source: 'mesh.policy.refineConfig', sourceType: 'mesh_policy' };
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
for (const relative of MESH_REFINE_CONFIG_LOCATIONS) {
|
|
212
|
+
const configPath = join(workspace, relative);
|
|
213
|
+
if (!existsSync(configPath)) continue;
|
|
214
|
+
try {
|
|
215
|
+
const parsed = parseConfigText(configPath, readFileSync(configPath, 'utf-8'));
|
|
216
|
+
const validation = validateMeshRefineConfig(parsed, relative);
|
|
217
|
+
if (!validation.valid) return { source: relative, sourceType: 'invalid', path: configPath, error: String(validation.rejectedCommands[0]?.reason || validation.errors.join('; ')) };
|
|
218
|
+
return { config: parsed as RepoMeshRefineConfig, source: relative, sourceType: 'repo_file', path: configPath };
|
|
219
|
+
} catch (error: any) {
|
|
220
|
+
return { source: relative, sourceType: 'invalid', path: configPath, error: error?.message || String(error) };
|
|
221
|
+
}
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
return {
|
|
225
|
+
source: 'unavailable',
|
|
226
|
+
sourceType: 'unavailable',
|
|
227
|
+
error: `No repo mesh/refine config found. Checked: ${MESH_REFINE_CONFIG_LOCATIONS.join(', ')}`,
|
|
228
|
+
};
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
function readPackageScripts(workspace: string): Record<string, string> {
|
|
232
|
+
try {
|
|
233
|
+
const parsed = JSON.parse(readFileSync(join(workspace, 'package.json'), 'utf-8'));
|
|
234
|
+
return isRecord(parsed?.scripts) ? parsed.scripts as Record<string, string> : {};
|
|
235
|
+
} catch {
|
|
236
|
+
return {};
|
|
237
|
+
}
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
function collectProjectContextSuggestions(mesh: any): RepoMeshRefineValidationCommandConfig[] {
|
|
241
|
+
const commands = mesh?.projectContext?.commands;
|
|
242
|
+
if (!isRecord(commands)) return [];
|
|
243
|
+
const suggestions: RepoMeshRefineValidationCommandConfig[] = [];
|
|
244
|
+
for (const category of MESH_REFINE_VALIDATION_CATEGORIES) {
|
|
245
|
+
const entries = Array.isArray(commands[category]) ? commands[category] : [];
|
|
246
|
+
for (const entry of entries) {
|
|
247
|
+
if (isRecord(entry) && typeof entry.command === 'string') suggestions.push({ command: entry.command, category });
|
|
248
|
+
}
|
|
249
|
+
}
|
|
250
|
+
return suggestions;
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
function collectPackageScriptSuggestions(workspace: string): RepoMeshRefineValidationCommandConfig[] {
|
|
254
|
+
const scripts = readPackageScripts(workspace);
|
|
255
|
+
const suggestions: RepoMeshRefineValidationCommandConfig[] = [];
|
|
256
|
+
for (const category of MESH_REFINE_VALIDATION_CATEGORIES) {
|
|
257
|
+
for (const scriptName of Object.keys(scripts)) {
|
|
258
|
+
if (scriptName === category || scriptName.startsWith(`${category}:`)) {
|
|
259
|
+
suggestions.push({ command: 'npm', args: ['run', scriptName], category });
|
|
260
|
+
}
|
|
261
|
+
}
|
|
262
|
+
}
|
|
263
|
+
return suggestions;
|
|
264
|
+
}
|
|
265
|
+
|
|
266
|
+
export function suggestMeshRefineConfig(mesh: any, workspace: string): { suggestions: RepoMeshRefineValidationCommandConfig[]; suggestedConfig?: RepoMeshRefineConfig } {
|
|
267
|
+
const seen = new Set<string>();
|
|
268
|
+
const suggestions: RepoMeshRefineValidationCommandConfig[] = [];
|
|
269
|
+
for (const entry of [...collectProjectContextSuggestions(mesh), ...collectPackageScriptSuggestions(workspace)]) {
|
|
270
|
+
const key = `${entry.command} ${(entry.args || []).join(' ')}`.trim();
|
|
271
|
+
if (seen.has(key)) continue;
|
|
272
|
+
seen.add(key);
|
|
273
|
+
suggestions.push(entry);
|
|
274
|
+
}
|
|
275
|
+
return {
|
|
276
|
+
suggestions,
|
|
277
|
+
suggestedConfig: suggestions.length ? { version: 1, validation: { required: true, commands: suggestions.slice(0, 4) } } : undefined,
|
|
278
|
+
};
|
|
279
|
+
}
|
|
280
|
+
|
|
281
|
+
export function resolveMeshRefineValidationPlan(mesh: any, workspace: string): MeshRefineValidationPlan {
|
|
282
|
+
const loaded = loadMeshRefineConfig(mesh, workspace);
|
|
283
|
+
const suggestion = suggestMeshRefineConfig(mesh, workspace);
|
|
284
|
+
if (!loaded.config) {
|
|
285
|
+
return {
|
|
286
|
+
source: loaded.source,
|
|
287
|
+
sourceType: loaded.sourceType,
|
|
288
|
+
commands: [],
|
|
289
|
+
rejectedCommands: loaded.error ? [{ source: loaded.source, reason: loaded.error }] : [],
|
|
290
|
+
suggestions: suggestion.suggestions,
|
|
291
|
+
suggestedConfig: suggestion.suggestedConfig,
|
|
292
|
+
unavailableReason: loaded.error || 'validation_unavailable: repo mesh/refine config missing',
|
|
293
|
+
};
|
|
294
|
+
}
|
|
295
|
+
|
|
296
|
+
const validation = validateMeshRefineConfig(loaded.config, loaded.source);
|
|
297
|
+
return {
|
|
298
|
+
source: loaded.path || loaded.source,
|
|
299
|
+
sourceType: loaded.sourceType,
|
|
300
|
+
commands: validation.commands,
|
|
301
|
+
rejectedCommands: validation.rejectedCommands,
|
|
302
|
+
suggestions: suggestion.suggestions,
|
|
303
|
+
suggestedConfig: suggestion.suggestedConfig,
|
|
304
|
+
unavailableReason: validation.commands.length ? undefined : 'validation_unavailable: repo mesh/refine config has no validation.commands',
|
|
305
|
+
};
|
|
306
|
+
}
|
|
@@ -1,9 +1,11 @@
|
|
|
1
1
|
import type { ChatMessage } from '../types.js';
|
|
2
2
|
import { flattenContent } from './contracts.js';
|
|
3
3
|
|
|
4
|
+
export const DEFAULT_FINAL_SUMMARY_MAX_CHARS = 4_000;
|
|
5
|
+
|
|
4
6
|
export function extractFinalSummaryFromMessages(
|
|
5
7
|
messages: ChatMessage[] | null | undefined,
|
|
6
|
-
maxChars: number =
|
|
8
|
+
maxChars: number = DEFAULT_FINAL_SUMMARY_MAX_CHARS,
|
|
7
9
|
): string {
|
|
8
10
|
if (!Array.isArray(messages) || messages.length === 0) return '';
|
|
9
11
|
|