@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.
@@ -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
- const queue = readQueue(meshId);
78
- const entry: MeshWorkQueueEntry = {
79
- id: randomUUID(),
80
- meshId,
81
- message,
82
- status: 'pending',
83
- targetNodeId: opts?.targetNodeId,
84
- targetSessionId: opts?.targetSessionId,
85
- createdAt: new Date().toISOString(),
86
- updatedAt: new Date().toISOString(),
87
- };
88
- queue.push(entry);
89
- writeQueue(meshId, queue);
90
- return entry;
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
- const queue = readQueue(meshId);
110
-
111
- // A worker must finish or fail its current queued assignment before it can
112
- // claim another one. maxParallelTasks limits total mesh concurrency; it is
113
- // not permission for one node/session to accumulate multiple assigned items.
114
- const hasActiveAssignment = queue.some(q => q.status === 'assigned' && (
115
- q.assignedSessionId === sessionId || q.assignedNodeId === nodeId
116
- ));
117
- if (hasActiveAssignment) return null;
118
-
119
- // Find highest priority task:
120
- // 1. Pending tasks explicitly targeted at this runtime session
121
- // 2. Pending tasks explicitly targeted at this node (but not another session)
122
- // 3. Pending tasks with no target node/session
123
- let targetIdx = queue.findIndex(q => q.status === 'pending' && q.targetSessionId === sessionId);
124
- if (targetIdx === -1) {
125
- targetIdx = queue.findIndex(q => q.status === 'pending' && q.targetNodeId === nodeId && !q.targetSessionId);
126
- }
127
- if (targetIdx === -1) {
128
- targetIdx = queue.findIndex(q => q.status === 'pending' && !q.targetNodeId && !q.targetSessionId);
129
- }
130
-
131
- if (targetIdx === -1) return null;
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
- const queue = readQueue(meshId);
154
- const idx = queue.findIndex(q => q.id === taskId);
155
- if (idx === -1) return null;
156
-
157
- queue[idx].status = status;
158
- queue[idx].updatedAt = new Date().toISOString();
159
- writeQueue(meshId, queue);
160
- return queue[idx];
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
- const queue = readQueue(meshId);
169
- const idx = queue.findIndex(q => q.id === taskId);
170
- if (idx === -1) return null;
171
- const now = new Date().toISOString();
172
- queue[idx].autoLaunch = {
173
- ...autoLaunch,
174
- updatedAt: now,
175
- };
176
- queue[idx].updatedAt = now;
177
- writeQueue(meshId, queue);
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
- const queue = readQueue(meshId);
190
- const idx = queue.findIndex(q => q.id === taskId);
191
- if (idx === -1) return null;
192
-
193
- const now = new Date().toISOString();
194
- queue[idx].status = 'cancelled';
195
- queue[idx].updatedAt = now;
196
- queue[idx].cancelledAt = now;
197
- if (opts?.reason) queue[idx].cancelReason = opts.reason;
198
- writeQueue(meshId, queue);
199
- return queue[idx];
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
- const queue = readQueue(meshId);
218
- const idx = queue.findIndex(q => q.id === taskId);
219
- if (idx === -1) return null;
220
-
221
- const entry = queue[idx];
222
- const now = new Date().toISOString();
223
- entry.status = 'pending';
224
- delete entry.assignedNodeId;
225
- delete entry.assignedSessionId;
226
- delete entry.cancelledAt;
227
- delete entry.cancelReason;
228
- if (opts?.clearTargetNode) delete entry.targetNodeId;
229
- if (typeof opts?.targetNodeId === 'string') entry.targetNodeId = opts.targetNodeId;
230
- if (opts?.clearTargetSession !== false) delete entry.targetSessionId;
231
- if (typeof opts?.targetSessionId === 'string') entry.targetSessionId = opts.targetSessionId;
232
- entry.updatedAt = now;
233
- entry.requeuedAt = now;
234
- entry.requeueCount = (entry.requeueCount || 0) + 1;
235
- if (opts?.reason) entry.requeueReason = opts.reason;
236
- writeQueue(meshId, queue);
237
- return entry;
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
- const queue = readQueue(meshId);
249
- // Collect all assigned tasks for this session, then pick the one with the
250
- // most recent dispatchTimestamp (or updatedAt fallback for legacy entries).
251
- // This prevents completing the wrong task when multiple tasks were assigned
252
- // to the same session in rapid succession.
253
- let bestIdx = -1;
254
- let bestTime = 0;
255
- for (let i = queue.length - 1; i >= 0; i--) {
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 > bestTime) {
259
- bestTime = time;
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
- if (bestIdx === -1) return null;
265
-
266
- queue[bestIdx].status = status;
267
- queue[bestIdx].updatedAt = new Date().toISOString();
268
- writeQueue(meshId, queue);
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 = 500,
8
+ maxChars: number = DEFAULT_FINAL_SUMMARY_MAX_CHARS,
7
9
  ): string {
8
10
  if (!Array.isArray(messages) || messages.length === 0) return '';
9
11