@karmaniverous/jeeves-meta 0.4.1 → 0.5.0
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/README.md +1 -1
- package/dist/cli/jeeves-meta/index.js +414 -331
- package/dist/index.d.ts +81 -149
- package/dist/index.js +401 -318
- package/package.json +2 -1
|
@@ -1,9 +1,13 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
2
|
import { Command } from 'commander';
|
|
3
|
-
import { readFileSync, readdirSync, unlinkSync, mkdirSync, writeFileSync, existsSync, statSync, copyFileSync, watchFile } from 'node:fs';
|
|
4
|
-
import { dirname, join, relative } from 'node:path';
|
|
3
|
+
import fs, { readFileSync, readdirSync, unlinkSync, mkdirSync, writeFileSync, existsSync, statSync, copyFileSync, watchFile } from 'node:fs';
|
|
4
|
+
import path, { dirname, join, resolve, relative } from 'node:path';
|
|
5
5
|
import { z } from 'zod';
|
|
6
|
+
import { fileURLToPath } from 'node:url';
|
|
7
|
+
import 'node:fs/promises';
|
|
8
|
+
import process$1 from 'node:process';
|
|
6
9
|
import { createHash, randomUUID } from 'node:crypto';
|
|
10
|
+
import { tmpdir } from 'node:os';
|
|
7
11
|
import pino from 'pino';
|
|
8
12
|
import { Cron } from 'croner';
|
|
9
13
|
import Fastify from 'fastify';
|
|
@@ -67,6 +71,8 @@ const serviceConfigSchema = metaConfigSchema.extend({
|
|
|
67
71
|
schedule: z.string().default('*/30 * * * *'),
|
|
68
72
|
/** Optional channel identifier for reporting. */
|
|
69
73
|
reportChannel: z.string().optional(),
|
|
74
|
+
/** Interval in ms for periodic watcher health check. 0 = disabled. Default: 60000. */
|
|
75
|
+
watcherHealthIntervalMs: z.number().int().min(0).default(60_000),
|
|
70
76
|
/** Logging configuration. */
|
|
71
77
|
logging: loggingSchema.default(() => loggingSchema.parse({})),
|
|
72
78
|
});
|
|
@@ -166,6 +172,122 @@ var configLoader = /*#__PURE__*/Object.freeze({
|
|
|
166
172
|
resolveConfigPath: resolveConfigPath
|
|
167
173
|
});
|
|
168
174
|
|
|
175
|
+
const toPath = urlOrPath => urlOrPath instanceof URL ? fileURLToPath(urlOrPath) : urlOrPath;
|
|
176
|
+
|
|
177
|
+
function findUpSync(name, {
|
|
178
|
+
cwd = process$1.cwd(),
|
|
179
|
+
type = 'file',
|
|
180
|
+
stopAt,
|
|
181
|
+
} = {}) {
|
|
182
|
+
let directory = path.resolve(toPath(cwd) ?? '');
|
|
183
|
+
const {root} = path.parse(directory);
|
|
184
|
+
stopAt = path.resolve(directory, toPath(stopAt) ?? root);
|
|
185
|
+
const isAbsoluteName = path.isAbsolute(name);
|
|
186
|
+
|
|
187
|
+
while (directory) {
|
|
188
|
+
const filePath = isAbsoluteName ? name : path.join(directory, name);
|
|
189
|
+
|
|
190
|
+
try {
|
|
191
|
+
const stats = fs.statSync(filePath, {throwIfNoEntry: false});
|
|
192
|
+
if ((type === 'file' && stats?.isFile()) || (type === 'directory' && stats?.isDirectory())) {
|
|
193
|
+
return filePath;
|
|
194
|
+
}
|
|
195
|
+
} catch {}
|
|
196
|
+
|
|
197
|
+
if (directory === stopAt || directory === root) {
|
|
198
|
+
break;
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
directory = path.dirname(directory);
|
|
202
|
+
}
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
const isTypeOnlyPackageJsonData = packageData => {
|
|
206
|
+
if (!packageData || typeof packageData !== 'object' || Array.isArray(packageData)) {
|
|
207
|
+
return false;
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
const keys = Object.keys(packageData);
|
|
211
|
+
return keys.length === 1 && keys[0] === 'type' && typeof packageData.type === 'string';
|
|
212
|
+
};
|
|
213
|
+
|
|
214
|
+
const isTypeOnlyPackageJsonSync = filePath => {
|
|
215
|
+
let fileContents;
|
|
216
|
+
|
|
217
|
+
try {
|
|
218
|
+
fileContents = fs.readFileSync(filePath, 'utf8');
|
|
219
|
+
} catch {
|
|
220
|
+
return false;
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
try {
|
|
224
|
+
return isTypeOnlyPackageJsonData(JSON.parse(fileContents));
|
|
225
|
+
} catch {
|
|
226
|
+
return false;
|
|
227
|
+
}
|
|
228
|
+
};
|
|
229
|
+
|
|
230
|
+
const getNextSearchDirectory = filePath => {
|
|
231
|
+
const packageDirectoryPath = path.dirname(filePath);
|
|
232
|
+
const parentDirectoryPath = path.dirname(packageDirectoryPath);
|
|
233
|
+
return parentDirectoryPath === packageDirectoryPath ? undefined : parentDirectoryPath;
|
|
234
|
+
};
|
|
235
|
+
|
|
236
|
+
const findPackageDirectorySync = (directory, ignoreTypeOnlyPackageJson) => {
|
|
237
|
+
const filePath = findUpSync('package.json', {cwd: directory});
|
|
238
|
+
if (!filePath) {
|
|
239
|
+
return undefined;
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
const packageDirectoryPath = path.dirname(filePath);
|
|
243
|
+
if (!ignoreTypeOnlyPackageJson) {
|
|
244
|
+
return packageDirectoryPath;
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
if (!isTypeOnlyPackageJsonSync(filePath)) {
|
|
248
|
+
return packageDirectoryPath;
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
const nextDirectory = getNextSearchDirectory(filePath);
|
|
252
|
+
if (!nextDirectory) {
|
|
253
|
+
return undefined;
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
return findPackageDirectorySync(nextDirectory, ignoreTypeOnlyPackageJson);
|
|
257
|
+
};
|
|
258
|
+
|
|
259
|
+
function packageDirectorySync({cwd, ignoreTypeOnlyPackageJson} = {}) {
|
|
260
|
+
return findPackageDirectorySync(cwd ?? process$1.cwd(), ignoreTypeOnlyPackageJson);
|
|
261
|
+
}
|
|
262
|
+
|
|
263
|
+
/**
|
|
264
|
+
* Shared constants for the jeeves-meta service package.
|
|
265
|
+
*
|
|
266
|
+
* @module constants
|
|
267
|
+
*/
|
|
268
|
+
/** Default HTTP port for the jeeves-meta service. */
|
|
269
|
+
const DEFAULT_PORT = 1938;
|
|
270
|
+
/** Default port as a string (for Commander CLI defaults). */
|
|
271
|
+
const DEFAULT_PORT_STR = String(DEFAULT_PORT);
|
|
272
|
+
/** Service name identifier. */
|
|
273
|
+
const SERVICE_NAME = 'jeeves-meta';
|
|
274
|
+
/** Service version, read from package.json at startup. */
|
|
275
|
+
const SERVICE_VERSION = (() => {
|
|
276
|
+
try {
|
|
277
|
+
const dir = dirname(fileURLToPath(import.meta.url));
|
|
278
|
+
const root = packageDirectorySync({ cwd: dir });
|
|
279
|
+
if (root) {
|
|
280
|
+
const pkg = JSON.parse(readFileSync(resolve(root, 'package.json'), 'utf8'));
|
|
281
|
+
if (pkg.version)
|
|
282
|
+
return pkg.version;
|
|
283
|
+
}
|
|
284
|
+
return 'unknown';
|
|
285
|
+
}
|
|
286
|
+
catch {
|
|
287
|
+
return 'unknown';
|
|
288
|
+
}
|
|
289
|
+
})();
|
|
290
|
+
|
|
169
291
|
/**
|
|
170
292
|
* List archive snapshot files in chronological order.
|
|
171
293
|
*
|
|
@@ -282,114 +404,29 @@ function normalizePath(p) {
|
|
|
282
404
|
}
|
|
283
405
|
|
|
284
406
|
/**
|
|
285
|
-
*
|
|
407
|
+
* Discover .meta/ directories via watcher `/walk` endpoint.
|
|
286
408
|
*
|
|
287
|
-
*
|
|
288
|
-
|
|
289
|
-
/**
|
|
290
|
-
* Perform a paginated scan that follows cursor tokens until exhausted.
|
|
291
|
-
*
|
|
292
|
-
* @param watcher - WatcherClient instance.
|
|
293
|
-
* @param params - Base scan parameters (cursor is managed internally).
|
|
294
|
-
* @returns All matching files across all pages.
|
|
295
|
-
*/
|
|
296
|
-
async function paginatedScan(watcher, params, logger) {
|
|
297
|
-
const allFiles = [];
|
|
298
|
-
let cursor;
|
|
299
|
-
let pageCount = 0;
|
|
300
|
-
const start = Date.now();
|
|
301
|
-
do {
|
|
302
|
-
const pageStart = Date.now();
|
|
303
|
-
const result = await watcher.scan({ ...params, cursor });
|
|
304
|
-
allFiles.push(...result.files);
|
|
305
|
-
pageCount++;
|
|
306
|
-
logger?.debug({
|
|
307
|
-
page: pageCount,
|
|
308
|
-
files: result.files.length,
|
|
309
|
-
pageMs: Date.now() - pageStart,
|
|
310
|
-
hasNext: Boolean(result.next),
|
|
311
|
-
}, 'paginatedScan page');
|
|
312
|
-
cursor = result.next;
|
|
313
|
-
} while (cursor);
|
|
314
|
-
logger?.debug({
|
|
315
|
-
pages: pageCount,
|
|
316
|
-
totalFiles: allFiles.length,
|
|
317
|
-
totalMs: Date.now() - start,
|
|
318
|
-
}, 'paginatedScan complete');
|
|
319
|
-
return allFiles;
|
|
320
|
-
}
|
|
321
|
-
|
|
322
|
-
/**
|
|
323
|
-
* Discover .meta/ directories via watcher scan.
|
|
324
|
-
*
|
|
325
|
-
* Replaces filesystem-based globMetas() with a watcher query
|
|
326
|
-
* that returns indexed .meta/meta.json points, filtered by domain.
|
|
409
|
+
* Uses filesystem enumeration through the watcher (not Qdrant) to find
|
|
410
|
+
* all `.meta/meta.json` files and returns deduplicated meta directory paths.
|
|
327
411
|
*
|
|
328
412
|
* @module discovery/discoverMetas
|
|
329
413
|
*/
|
|
330
414
|
/**
|
|
331
|
-
*
|
|
415
|
+
* Discover all .meta/ directories via watcher walk.
|
|
332
416
|
*
|
|
333
|
-
*
|
|
334
|
-
*
|
|
335
|
-
* Objects and other non-filterable types are skipped with a warning.
|
|
336
|
-
*/
|
|
337
|
-
function buildMatchClause(key, value) {
|
|
338
|
-
if (Array.isArray(value)) {
|
|
339
|
-
if (value.length === 0)
|
|
340
|
-
return null;
|
|
341
|
-
return { key, match: { value: value[0] } };
|
|
342
|
-
}
|
|
343
|
-
if (typeof value === 'string' ||
|
|
344
|
-
typeof value === 'number' ||
|
|
345
|
-
typeof value === 'boolean') {
|
|
346
|
-
return { key, match: { value } };
|
|
347
|
-
}
|
|
348
|
-
// Non-filterable value (object, null, etc.) — valid for tagging but
|
|
349
|
-
// cannot be expressed as a Qdrant match clause.
|
|
350
|
-
return null;
|
|
351
|
-
}
|
|
352
|
-
/**
|
|
353
|
-
* Build a Qdrant filter from config metaProperty.
|
|
354
|
-
*
|
|
355
|
-
* Iterates all key-value pairs in `metaProperty` (a generic record)
|
|
356
|
-
* to construct `must` clauses. Always appends `file_path: meta.json`
|
|
357
|
-
* for deduplication.
|
|
417
|
+
* Uses the watcher's `/walk` endpoint to find all `.meta/meta.json` files
|
|
418
|
+
* and returns deduplicated meta directory paths.
|
|
358
419
|
*
|
|
359
|
-
* @param
|
|
360
|
-
* @returns Qdrant filter object for scanning live metas.
|
|
361
|
-
*/
|
|
362
|
-
function buildMetaFilter(config) {
|
|
363
|
-
const must = [];
|
|
364
|
-
for (const [key, value] of Object.entries(config.metaProperty)) {
|
|
365
|
-
const clause = buildMatchClause(key, value);
|
|
366
|
-
if (clause)
|
|
367
|
-
must.push(clause);
|
|
368
|
-
}
|
|
369
|
-
must.push({
|
|
370
|
-
key: 'file_path',
|
|
371
|
-
match: { text: '.meta/meta.json' },
|
|
372
|
-
});
|
|
373
|
-
return { must };
|
|
374
|
-
}
|
|
375
|
-
/**
|
|
376
|
-
* Discover all .meta/ directories via watcher scan.
|
|
377
|
-
*
|
|
378
|
-
* Queries the watcher for indexed .meta/meta.json points using the
|
|
379
|
-
* configured domain filter. Returns deduplicated meta directory paths.
|
|
380
|
-
*
|
|
381
|
-
* @param config - Meta config (for domain filter).
|
|
382
|
-
* @param watcher - WatcherClient for scan queries.
|
|
420
|
+
* @param watcher - WatcherClient for walk queries.
|
|
383
421
|
* @returns Array of normalized .meta/ directory paths.
|
|
384
422
|
*/
|
|
385
|
-
async function discoverMetas(
|
|
386
|
-
const
|
|
387
|
-
const scanFiles = await paginatedScan(watcher, { filter, fields: ['file_path'] }, logger);
|
|
423
|
+
async function discoverMetas(watcher) {
|
|
424
|
+
const allPaths = await watcher.walk(['**/.meta/meta.json']);
|
|
388
425
|
// Deduplicate by .meta/ directory path (handles multi-chunk files)
|
|
389
426
|
const seen = new Set();
|
|
390
427
|
const metaPaths = [];
|
|
391
|
-
for (const
|
|
392
|
-
const fp = normalizePath(
|
|
428
|
+
for (const filePath of allPaths) {
|
|
429
|
+
const fp = normalizePath(filePath);
|
|
393
430
|
// Derive .meta/ directory from file_path (strip /meta.json)
|
|
394
431
|
const metaPath = fp.replace(/\/meta\.json$/, '');
|
|
395
432
|
if (seen.has(metaPath))
|
|
@@ -530,6 +567,25 @@ function cleanupStaleLocks(metaPaths, logger) {
|
|
|
530
567
|
}
|
|
531
568
|
}
|
|
532
569
|
|
|
570
|
+
/**
|
|
571
|
+
* Read and parse a meta.json file from a `.meta/` directory.
|
|
572
|
+
*
|
|
573
|
+
* Shared utility to eliminate repeated `JSON.parse(readFileSync(...))` across
|
|
574
|
+
* discovery, orchestration, and route handlers.
|
|
575
|
+
*
|
|
576
|
+
* @module readMetaJson
|
|
577
|
+
*/
|
|
578
|
+
/**
|
|
579
|
+
* Read and parse a meta.json file from a `.meta/` directory path.
|
|
580
|
+
*
|
|
581
|
+
* @param metaPath - Path to the `.meta/` directory.
|
|
582
|
+
* @returns Parsed meta.json content.
|
|
583
|
+
* @throws If the file doesn't exist or contains invalid JSON.
|
|
584
|
+
*/
|
|
585
|
+
function readMetaJson(metaPath) {
|
|
586
|
+
return JSON.parse(readFileSync(join(metaPath, 'meta.json'), 'utf8'));
|
|
587
|
+
}
|
|
588
|
+
|
|
533
589
|
/**
|
|
534
590
|
* Build the ownership tree from discovered .meta/ paths.
|
|
535
591
|
*
|
|
@@ -625,9 +681,9 @@ const MAX_STALENESS_SECONDS$1 = 365 * 86_400;
|
|
|
625
681
|
* @param watcher - Watcher HTTP client for discovery.
|
|
626
682
|
* @returns Enriched meta list with summary statistics and ownership tree.
|
|
627
683
|
*/
|
|
628
|
-
async function listMetas(config, watcher
|
|
629
|
-
// Step 1: Discover deduplicated meta paths via watcher
|
|
630
|
-
const metaPaths = await discoverMetas(
|
|
684
|
+
async function listMetas(config, watcher) {
|
|
685
|
+
// Step 1: Discover deduplicated meta paths via watcher walk
|
|
686
|
+
const metaPaths = await discoverMetas(watcher);
|
|
631
687
|
// Step 2: Build ownership tree
|
|
632
688
|
const tree = buildOwnershipTree(metaPaths);
|
|
633
689
|
// Step 3: Read and enrich each meta from disk
|
|
@@ -646,7 +702,7 @@ async function listMetas(config, watcher, logger) {
|
|
|
646
702
|
for (const node of tree.nodes.values()) {
|
|
647
703
|
let meta;
|
|
648
704
|
try {
|
|
649
|
-
meta =
|
|
705
|
+
meta = readMetaJson(node.metaPath);
|
|
650
706
|
}
|
|
651
707
|
catch {
|
|
652
708
|
// Skip unreadable metas
|
|
@@ -734,67 +790,50 @@ async function listMetas(config, watcher, logger) {
|
|
|
734
790
|
}
|
|
735
791
|
|
|
736
792
|
/**
|
|
737
|
-
*
|
|
793
|
+
* Filter file paths by modification time.
|
|
738
794
|
*
|
|
739
|
-
*
|
|
740
|
-
*
|
|
795
|
+
* Shared utility for staleness detection and delta file enumeration.
|
|
796
|
+
* Uses `fs.statSync` for fast local mtime checks on known paths.
|
|
741
797
|
*
|
|
742
|
-
* @module
|
|
798
|
+
* @module mtimeFilter
|
|
743
799
|
*/
|
|
744
|
-
/** Default directory names to always skip. */
|
|
745
|
-
const DEFAULT_SKIP = new Set([
|
|
746
|
-
'node_modules',
|
|
747
|
-
'.git',
|
|
748
|
-
'.rollup.cache',
|
|
749
|
-
'dist',
|
|
750
|
-
'Thumbs.db',
|
|
751
|
-
]);
|
|
752
800
|
/**
|
|
753
|
-
*
|
|
801
|
+
* Check if any file in the list was modified after the given timestamp.
|
|
754
802
|
*
|
|
755
|
-
*
|
|
756
|
-
*
|
|
757
|
-
* @
|
|
803
|
+
* Short-circuits on first match for efficiency (staleness checks).
|
|
804
|
+
*
|
|
805
|
+
* @param files - Array of file paths to check.
|
|
806
|
+
* @param afterMs - Timestamp in milliseconds. Files with `mtimeMs > afterMs` match.
|
|
807
|
+
* @returns True if any file was modified after the timestamp.
|
|
758
808
|
*/
|
|
759
|
-
function
|
|
760
|
-
|
|
761
|
-
const modifiedAfter = options?.modifiedAfter;
|
|
762
|
-
const maxDepth = options?.maxDepth ?? 50;
|
|
763
|
-
const results = [];
|
|
764
|
-
function walk(dir, depth) {
|
|
765
|
-
if (depth > maxDepth)
|
|
766
|
-
return;
|
|
767
|
-
let entries;
|
|
809
|
+
function hasModifiedAfter(files, afterMs) {
|
|
810
|
+
for (const filePath of files) {
|
|
768
811
|
try {
|
|
769
|
-
|
|
812
|
+
if (statSync(filePath).mtimeMs > afterMs)
|
|
813
|
+
return true;
|
|
770
814
|
}
|
|
771
815
|
catch {
|
|
772
|
-
|
|
773
|
-
}
|
|
774
|
-
for (const entry of entries) {
|
|
775
|
-
if (exclude.has(entry.name))
|
|
776
|
-
continue;
|
|
777
|
-
const fullPath = join(dir, entry.name);
|
|
778
|
-
if (entry.isDirectory()) {
|
|
779
|
-
walk(fullPath, depth + 1);
|
|
780
|
-
}
|
|
781
|
-
else if (entry.isFile()) {
|
|
782
|
-
if (modifiedAfter !== undefined) {
|
|
783
|
-
try {
|
|
784
|
-
const stat = statSync(fullPath);
|
|
785
|
-
if (Math.floor(stat.mtimeMs / 1000) <= modifiedAfter)
|
|
786
|
-
continue;
|
|
787
|
-
}
|
|
788
|
-
catch {
|
|
789
|
-
continue;
|
|
790
|
-
}
|
|
791
|
-
}
|
|
792
|
-
results.push(normalizePath(fullPath));
|
|
793
|
-
}
|
|
816
|
+
// Unreadable file — skip
|
|
794
817
|
}
|
|
795
818
|
}
|
|
796
|
-
|
|
797
|
-
|
|
819
|
+
return false;
|
|
820
|
+
}
|
|
821
|
+
/**
|
|
822
|
+
* Filter files to only those modified after the given timestamp.
|
|
823
|
+
*
|
|
824
|
+
* @param files - Array of file paths to filter.
|
|
825
|
+
* @param afterMs - Timestamp in milliseconds. Files with `mtimeMs > afterMs` are included.
|
|
826
|
+
* @returns Filtered array of file paths.
|
|
827
|
+
*/
|
|
828
|
+
function filterModifiedAfter(files, afterMs) {
|
|
829
|
+
return files.filter((filePath) => {
|
|
830
|
+
try {
|
|
831
|
+
return statSync(filePath).mtimeMs > afterMs;
|
|
832
|
+
}
|
|
833
|
+
catch {
|
|
834
|
+
return false;
|
|
835
|
+
}
|
|
836
|
+
});
|
|
798
837
|
}
|
|
799
838
|
|
|
800
839
|
/**
|
|
@@ -804,7 +843,7 @@ function walkFiles(root, options) {
|
|
|
804
843
|
* - Its own .meta/ subtree (outputs, not inputs)
|
|
805
844
|
* - Child meta ownerPath subtrees (except their .meta/meta.json for rollups)
|
|
806
845
|
*
|
|
807
|
-
*
|
|
846
|
+
* All filesystem enumeration delegated to the watcher's `/walk` endpoint.
|
|
808
847
|
*
|
|
809
848
|
* @module discovery/scope
|
|
810
849
|
*/
|
|
@@ -821,7 +860,7 @@ function getScopePrefix(node) {
|
|
|
821
860
|
* - The node's own .meta/ subtree (synthesis outputs are not scope inputs)
|
|
822
861
|
* - Child meta ownerPath subtrees (except child .meta/meta.json for rollups)
|
|
823
862
|
*
|
|
824
|
-
*
|
|
863
|
+
* Watcher walk returns normalized forward-slash paths.
|
|
825
864
|
*/
|
|
826
865
|
function filterInScope(node, files) {
|
|
827
866
|
const prefix = node.ownerPath + '/';
|
|
@@ -846,10 +885,10 @@ function filterInScope(node, files) {
|
|
|
846
885
|
});
|
|
847
886
|
}
|
|
848
887
|
/**
|
|
849
|
-
* Get all files in scope for a meta node via
|
|
888
|
+
* Get all files in scope for a meta node via watcher walk.
|
|
850
889
|
*/
|
|
851
|
-
function getScopeFiles(node) {
|
|
852
|
-
const allFiles =
|
|
890
|
+
async function getScopeFiles(node, watcher) {
|
|
891
|
+
const allFiles = await watcher.walk([`${node.ownerPath}/**`]);
|
|
853
892
|
return {
|
|
854
893
|
scopeFiles: filterInScope(node, allFiles),
|
|
855
894
|
allFiles,
|
|
@@ -859,13 +898,12 @@ function getScopeFiles(node) {
|
|
|
859
898
|
* Get files modified since a given timestamp within a meta node's scope.
|
|
860
899
|
*
|
|
861
900
|
* If no generatedAt is provided (first run), returns all scope files.
|
|
901
|
+
* Reuses scope files from getScopeFiles() and filters locally by mtime.
|
|
862
902
|
*/
|
|
863
|
-
function getDeltaFiles(
|
|
903
|
+
function getDeltaFiles(generatedAt, scopeFiles) {
|
|
864
904
|
if (!generatedAt)
|
|
865
905
|
return scopeFiles;
|
|
866
|
-
|
|
867
|
-
const deltaFiles = walkFiles(node.ownerPath, { modifiedAfter });
|
|
868
|
-
return filterInScope(node, deltaFiles);
|
|
906
|
+
return filterModifiedAfter(scopeFiles, new Date(generatedAt).getTime());
|
|
869
907
|
}
|
|
870
908
|
|
|
871
909
|
/**
|
|
@@ -962,7 +1000,7 @@ class GatewayExecutor {
|
|
|
962
1000
|
this.gatewayUrl = (options.gatewayUrl ?? 'http://127.0.0.1:18789').replace(/\/+$/, '');
|
|
963
1001
|
this.apiKey = options.apiKey;
|
|
964
1002
|
this.pollIntervalMs = options.pollIntervalMs ?? DEFAULT_POLL_INTERVAL_MS;
|
|
965
|
-
this.workspaceDir = options.workspaceDir ?? '
|
|
1003
|
+
this.workspaceDir = options.workspaceDir ?? join(tmpdir(), 'jeeves-meta');
|
|
966
1004
|
}
|
|
967
1005
|
/** Invoke a gateway tool via the /tools/invoke HTTP endpoint. */
|
|
968
1006
|
async invoke(tool, args) {
|
|
@@ -1166,10 +1204,10 @@ function condenseScopeFiles(files, maxIndividual = 30) {
|
|
|
1166
1204
|
* @param watcher - WatcherClient for scope enumeration.
|
|
1167
1205
|
* @returns The computed context package.
|
|
1168
1206
|
*/
|
|
1169
|
-
function buildContextPackage(node, meta) {
|
|
1170
|
-
// Scope and delta files via watcher
|
|
1171
|
-
const { scopeFiles } = getScopeFiles(node);
|
|
1172
|
-
const deltaFiles = getDeltaFiles(
|
|
1207
|
+
async function buildContextPackage(node, meta, watcher) {
|
|
1208
|
+
// Scope and delta files via watcher walk
|
|
1209
|
+
const { scopeFiles } = await getScopeFiles(node, watcher);
|
|
1210
|
+
const deltaFiles = getDeltaFiles(meta._generatedAt, scopeFiles);
|
|
1173
1211
|
// Child meta outputs
|
|
1174
1212
|
const childMetas = {};
|
|
1175
1213
|
for (const child of node.children) {
|
|
@@ -1493,6 +1531,67 @@ function mergeAndWrite(options) {
|
|
|
1493
1531
|
return result.data;
|
|
1494
1532
|
}
|
|
1495
1533
|
|
|
1534
|
+
/**
|
|
1535
|
+
* Build a minimal MetaNode from a known meta path using watcher walk.
|
|
1536
|
+
*
|
|
1537
|
+
* Used for targeted synthesis (when a specific path is requested) to avoid
|
|
1538
|
+
* the full discovery + ownership tree build. Discovers only immediate child
|
|
1539
|
+
* `.meta/` directories.
|
|
1540
|
+
*
|
|
1541
|
+
* @module discovery/buildMinimalNode
|
|
1542
|
+
*/
|
|
1543
|
+
/**
|
|
1544
|
+
* Build a minimal MetaNode for a known meta path.
|
|
1545
|
+
*
|
|
1546
|
+
* Walks the owner directory for child `.meta/meta.json` files and constructs
|
|
1547
|
+
* a shallow ownership tree (self + direct children only).
|
|
1548
|
+
*
|
|
1549
|
+
* @param metaPath - Absolute path to the `.meta/` directory.
|
|
1550
|
+
* @param watcher - WatcherClient for filesystem enumeration.
|
|
1551
|
+
* @returns MetaNode with direct children wired.
|
|
1552
|
+
*/
|
|
1553
|
+
async function buildMinimalNode(metaPath, watcher) {
|
|
1554
|
+
const normalized = normalizePath(metaPath);
|
|
1555
|
+
const ownerPath = normalizePath(dirname(metaPath));
|
|
1556
|
+
// Find child metas using watcher walk.
|
|
1557
|
+
// We include only *direct* children (nearest descendants in the ownership tree)
|
|
1558
|
+
// to match the ownership semantics used elsewhere.
|
|
1559
|
+
const rawMetaJsonPaths = await watcher.walk([
|
|
1560
|
+
`${ownerPath}/**/.meta/meta.json`,
|
|
1561
|
+
]);
|
|
1562
|
+
const candidateMetaPaths = [
|
|
1563
|
+
...new Set(rawMetaJsonPaths.map((p) => normalizePath(dirname(p)))),
|
|
1564
|
+
].filter((p) => p !== normalized);
|
|
1565
|
+
const candidates = candidateMetaPaths
|
|
1566
|
+
.map((mp) => ({ metaPath: mp, ownerPath: normalizePath(dirname(mp)) }))
|
|
1567
|
+
.sort((a, b) => a.ownerPath.length - b.ownerPath.length);
|
|
1568
|
+
const directChildren = [];
|
|
1569
|
+
for (const c of candidates) {
|
|
1570
|
+
const nestedUnderExisting = directChildren.some((d) => c.ownerPath === d.ownerPath ||
|
|
1571
|
+
c.ownerPath.startsWith(d.ownerPath + '/'));
|
|
1572
|
+
if (!nestedUnderExisting)
|
|
1573
|
+
directChildren.push(c);
|
|
1574
|
+
}
|
|
1575
|
+
const children = directChildren.map((c) => ({
|
|
1576
|
+
metaPath: c.metaPath,
|
|
1577
|
+
ownerPath: c.ownerPath,
|
|
1578
|
+
treeDepth: 1,
|
|
1579
|
+
children: [],
|
|
1580
|
+
parent: null,
|
|
1581
|
+
}));
|
|
1582
|
+
const node = {
|
|
1583
|
+
metaPath: normalized,
|
|
1584
|
+
ownerPath,
|
|
1585
|
+
treeDepth: 0,
|
|
1586
|
+
children,
|
|
1587
|
+
parent: null,
|
|
1588
|
+
};
|
|
1589
|
+
for (const child of children) {
|
|
1590
|
+
child.parent = node;
|
|
1591
|
+
}
|
|
1592
|
+
return node;
|
|
1593
|
+
}
|
|
1594
|
+
|
|
1496
1595
|
/**
|
|
1497
1596
|
* Weighted staleness formula for candidate selection.
|
|
1498
1597
|
*
|
|
@@ -1572,29 +1671,30 @@ function discoverStalestPath(candidates, depthWeight) {
|
|
|
1572
1671
|
}
|
|
1573
1672
|
|
|
1574
1673
|
/**
|
|
1575
|
-
* Staleness detection via watcher
|
|
1674
|
+
* Staleness detection via watcher walk.
|
|
1576
1675
|
*
|
|
1577
|
-
* A meta is stale when any file in its scope was modified after
|
|
1676
|
+
* A meta is stale when any watched file in its scope was modified after
|
|
1677
|
+
* `_generatedAt`.
|
|
1578
1678
|
*
|
|
1579
1679
|
* @module scheduling/staleness
|
|
1580
1680
|
*/
|
|
1581
1681
|
/**
|
|
1582
|
-
* Check if a meta is stale
|
|
1682
|
+
* Check if a meta is stale.
|
|
1683
|
+
*
|
|
1684
|
+
* Uses watcher `/walk` to enumerate watched files under the scope prefix,
|
|
1685
|
+
* then applies a local mtime check (fast) to detect any modifications since
|
|
1686
|
+
* `_generatedAt`. Short-circuits on first match.
|
|
1583
1687
|
*
|
|
1584
1688
|
* @param scopePrefix - Path prefix for this meta's scope.
|
|
1585
1689
|
* @param meta - Current meta.json content.
|
|
1586
1690
|
* @param watcher - WatcherClient instance.
|
|
1587
|
-
* @returns True if any file in scope was modified after _generatedAt
|
|
1691
|
+
* @returns True if any file in scope was modified after `_generatedAt`.
|
|
1588
1692
|
*/
|
|
1589
|
-
function isStale(scopePrefix, meta) {
|
|
1693
|
+
async function isStale(scopePrefix, meta, watcher) {
|
|
1590
1694
|
if (!meta._generatedAt)
|
|
1591
1695
|
return true; // Never synthesized = stale
|
|
1592
|
-
const
|
|
1593
|
-
|
|
1594
|
-
modifiedAfter: generatedAtUnix,
|
|
1595
|
-
maxDepth: 1,
|
|
1596
|
-
});
|
|
1597
|
-
return modified.length > 0;
|
|
1696
|
+
const files = await watcher.walk([`${scopePrefix}/**`]);
|
|
1697
|
+
return hasModifiedAfter(files, new Date(meta._generatedAt).getTime());
|
|
1598
1698
|
}
|
|
1599
1699
|
/** Maximum staleness for never-synthesized metas (1 year in seconds). */
|
|
1600
1700
|
const MAX_STALENESS_SECONDS = 365 * 86_400;
|
|
@@ -1804,72 +1904,13 @@ function finalizeCycle(opts) {
|
|
|
1804
1904
|
* @param watcher - Watcher HTTP client.
|
|
1805
1905
|
* @returns Result indicating whether synthesis occurred.
|
|
1806
1906
|
*/
|
|
1807
|
-
/**
|
|
1808
|
-
* Build a minimal MetaNode from the filesystem for a known meta path.
|
|
1809
|
-
* Discovers immediate child .meta/ dirs without a full watcher scan.
|
|
1810
|
-
*/
|
|
1811
|
-
function buildMinimalNode(metaPath) {
|
|
1812
|
-
const normalized = normalizePath(metaPath);
|
|
1813
|
-
const ownerPath = normalizePath(dirname(metaPath));
|
|
1814
|
-
// Find child .meta/ directories by scanning the owner directory
|
|
1815
|
-
const children = [];
|
|
1816
|
-
function findChildMetas(dir, depth) {
|
|
1817
|
-
if (depth > 10)
|
|
1818
|
-
return; // Safety limit
|
|
1819
|
-
try {
|
|
1820
|
-
const entries = readdirSync(dir, { withFileTypes: true });
|
|
1821
|
-
for (const entry of entries) {
|
|
1822
|
-
if (!entry.isDirectory())
|
|
1823
|
-
continue;
|
|
1824
|
-
const fullPath = normalizePath(join(dir, entry.name));
|
|
1825
|
-
if (entry.name === '.meta' && fullPath !== normalized) {
|
|
1826
|
-
// Found a child .meta — check it has meta.json
|
|
1827
|
-
if (existsSync(join(fullPath, 'meta.json'))) {
|
|
1828
|
-
children.push({
|
|
1829
|
-
metaPath: fullPath,
|
|
1830
|
-
ownerPath: normalizePath(dirname(fullPath)),
|
|
1831
|
-
treeDepth: 1, // Relative to target
|
|
1832
|
-
children: [],
|
|
1833
|
-
parent: null, // Set below
|
|
1834
|
-
});
|
|
1835
|
-
}
|
|
1836
|
-
// Don't recurse into .meta dirs
|
|
1837
|
-
return;
|
|
1838
|
-
}
|
|
1839
|
-
if (entry.name === 'node_modules' ||
|
|
1840
|
-
entry.name === '.git' ||
|
|
1841
|
-
entry.name === 'archive')
|
|
1842
|
-
continue;
|
|
1843
|
-
findChildMetas(fullPath, depth + 1);
|
|
1844
|
-
}
|
|
1845
|
-
}
|
|
1846
|
-
catch {
|
|
1847
|
-
// Permission errors, etc — skip
|
|
1848
|
-
}
|
|
1849
|
-
}
|
|
1850
|
-
findChildMetas(ownerPath, 0);
|
|
1851
|
-
const node = {
|
|
1852
|
-
metaPath: normalized,
|
|
1853
|
-
ownerPath,
|
|
1854
|
-
treeDepth: 0,
|
|
1855
|
-
children,
|
|
1856
|
-
parent: null,
|
|
1857
|
-
};
|
|
1858
|
-
// Wire parent references
|
|
1859
|
-
for (const child of children) {
|
|
1860
|
-
child.parent = node;
|
|
1861
|
-
}
|
|
1862
|
-
return node;
|
|
1863
|
-
}
|
|
1864
1907
|
/** Run the architect/builder/critic pipeline on a single node. */
|
|
1865
1908
|
async function synthesizeNode(node, currentMeta, config, executor, watcher, onProgress) {
|
|
1866
|
-
const architectPrompt = currentMeta._architect ?? config.defaultArchitect;
|
|
1867
|
-
const criticPrompt = currentMeta._critic ?? config.defaultCritic;
|
|
1868
1909
|
// Step 5-6: Steer change detection
|
|
1869
1910
|
const latestArchive = readLatestArchive(node.metaPath);
|
|
1870
1911
|
const steerChanged = hasSteerChanged(currentMeta._steer, latestArchive?._steer, Boolean(latestArchive));
|
|
1871
1912
|
// Step 7: Compute context (includes scope files and delta files)
|
|
1872
|
-
const ctx = buildContextPackage(node, currentMeta);
|
|
1913
|
+
const ctx = await buildContextPackage(node, currentMeta, watcher);
|
|
1873
1914
|
// Step 5 (deferred): Structure hash from context scope files
|
|
1874
1915
|
const newStructureHash = computeStructureHash(ctx.scopeFiles);
|
|
1875
1916
|
const structureChanged = newStructureHash !== currentMeta._structureHash;
|
|
@@ -1913,9 +1954,9 @@ async function synthesizeNode(node, currentMeta, config, executor, watcher, onPr
|
|
|
1913
1954
|
metaPath: node.metaPath,
|
|
1914
1955
|
current: currentMeta,
|
|
1915
1956
|
config,
|
|
1916
|
-
architect:
|
|
1957
|
+
architect: currentMeta._architect ?? '',
|
|
1917
1958
|
builder: '',
|
|
1918
|
-
critic:
|
|
1959
|
+
critic: currentMeta._critic ?? '',
|
|
1919
1960
|
builderOutput: null,
|
|
1920
1961
|
feedback: null,
|
|
1921
1962
|
structureHash: newStructureHash,
|
|
@@ -1999,9 +2040,9 @@ async function synthesizeNode(node, currentMeta, config, executor, watcher, onPr
|
|
|
1999
2040
|
metaPath: node.metaPath,
|
|
2000
2041
|
current: currentMeta,
|
|
2001
2042
|
config,
|
|
2002
|
-
architect:
|
|
2043
|
+
architect: currentMeta._architect ?? '',
|
|
2003
2044
|
builder: builderBrief,
|
|
2004
|
-
critic:
|
|
2045
|
+
critic: currentMeta._critic ?? '',
|
|
2005
2046
|
builderOutput,
|
|
2006
2047
|
feedback,
|
|
2007
2048
|
structureHash: newStructureHash,
|
|
@@ -2025,26 +2066,25 @@ async function orchestrateOnce(config, executor, watcher, targetPath, onProgress
|
|
|
2025
2066
|
const targetMetaJson = join(normalizedTarget, 'meta.json');
|
|
2026
2067
|
if (!existsSync(targetMetaJson))
|
|
2027
2068
|
return { synthesized: false };
|
|
2028
|
-
const node = buildMinimalNode(normalizedTarget);
|
|
2069
|
+
const node = await buildMinimalNode(normalizedTarget, watcher);
|
|
2029
2070
|
if (!acquireLock(node.metaPath))
|
|
2030
2071
|
return { synthesized: false };
|
|
2031
2072
|
try {
|
|
2032
|
-
const currentMeta =
|
|
2073
|
+
const currentMeta = readMetaJson(normalizedTarget);
|
|
2033
2074
|
return await synthesizeNode(node, currentMeta, config, executor, watcher, onProgress);
|
|
2034
2075
|
}
|
|
2035
2076
|
finally {
|
|
2036
2077
|
releaseLock(node.metaPath);
|
|
2037
2078
|
}
|
|
2038
2079
|
}
|
|
2039
|
-
const metaPaths = await discoverMetas(
|
|
2080
|
+
const metaPaths = await discoverMetas(watcher);
|
|
2040
2081
|
if (metaPaths.length === 0)
|
|
2041
2082
|
return { synthesized: false };
|
|
2042
2083
|
// Read meta.json for each discovered meta
|
|
2043
2084
|
const metas = new Map();
|
|
2044
2085
|
for (const mp of metaPaths) {
|
|
2045
|
-
const metaFilePath = join(mp, 'meta.json');
|
|
2046
2086
|
try {
|
|
2047
|
-
metas.set(normalizePath(mp),
|
|
2087
|
+
metas.set(normalizePath(mp), readMetaJson(mp));
|
|
2048
2088
|
}
|
|
2049
2089
|
catch {
|
|
2050
2090
|
// Skip metas with unreadable meta.json
|
|
@@ -2077,13 +2117,12 @@ async function orchestrateOnce(config, executor, watcher, targetPath, onProgress
|
|
|
2077
2117
|
for (const candidate of ranked) {
|
|
2078
2118
|
if (!acquireLock(candidate.node.metaPath))
|
|
2079
2119
|
continue;
|
|
2080
|
-
const verifiedStale = isStale(getScopePrefix(candidate.node), candidate.meta);
|
|
2120
|
+
const verifiedStale = await isStale(getScopePrefix(candidate.node), candidate.meta, watcher);
|
|
2081
2121
|
if (!verifiedStale && candidate.meta._generatedAt) {
|
|
2082
2122
|
// Bump _generatedAt so it doesn't win next cycle
|
|
2083
|
-
const
|
|
2084
|
-
const freshMeta = JSON.parse(readFileSync(metaFilePath, 'utf8'));
|
|
2123
|
+
const freshMeta = readMetaJson(candidate.node.metaPath);
|
|
2085
2124
|
freshMeta._generatedAt = new Date().toISOString();
|
|
2086
|
-
writeFileSync(
|
|
2125
|
+
writeFileSync(join(candidate.node.metaPath, 'meta.json'), JSON.stringify(freshMeta, null, 2));
|
|
2087
2126
|
releaseLock(candidate.node.metaPath);
|
|
2088
2127
|
if (config.skipUnchanged)
|
|
2089
2128
|
continue;
|
|
@@ -2096,7 +2135,7 @@ async function orchestrateOnce(config, executor, watcher, targetPath, onProgress
|
|
|
2096
2135
|
return { synthesized: false };
|
|
2097
2136
|
const node = winner.node;
|
|
2098
2137
|
try {
|
|
2099
|
-
const currentMeta =
|
|
2138
|
+
const currentMeta = readMetaJson(node.metaPath);
|
|
2100
2139
|
return await synthesizeNode(node, currentMeta, config, executor, watcher, onProgress);
|
|
2101
2140
|
}
|
|
2102
2141
|
finally {
|
|
@@ -2117,7 +2156,7 @@ async function orchestrateOnce(config, executor, watcher, targetPath, onProgress
|
|
|
2117
2156
|
* @returns Array with a single result.
|
|
2118
2157
|
*/
|
|
2119
2158
|
async function orchestrate(config, executor, watcher, targetPath, onProgress, logger) {
|
|
2120
|
-
const result = await orchestrateOnce(config, executor, watcher, targetPath, onProgress
|
|
2159
|
+
const result = await orchestrateOnce(config, executor, watcher, targetPath, onProgress);
|
|
2121
2160
|
return [result];
|
|
2122
2161
|
}
|
|
2123
2162
|
|
|
@@ -2340,7 +2379,7 @@ class Scheduler {
|
|
|
2340
2379
|
*/
|
|
2341
2380
|
async discoverStalest() {
|
|
2342
2381
|
try {
|
|
2343
|
-
const result = await listMetas(this.config, this.watcher
|
|
2382
|
+
const result = await listMetas(this.config, this.watcher);
|
|
2344
2383
|
const stale = result.entries
|
|
2345
2384
|
.filter((e) => e.stalenessSeconds > 0)
|
|
2346
2385
|
.map((e) => ({
|
|
@@ -2626,7 +2665,7 @@ function registerMetasRoutes(app, deps) {
|
|
|
2626
2665
|
app.get('/metas', async (request) => {
|
|
2627
2666
|
const query = metasQuerySchema.parse(request.query);
|
|
2628
2667
|
const { config, watcher } = deps;
|
|
2629
|
-
const result = await listMetas(config, watcher
|
|
2668
|
+
const result = await listMetas(config, watcher);
|
|
2630
2669
|
let entries = result.entries;
|
|
2631
2670
|
// Apply filters
|
|
2632
2671
|
if (query.pathPrefix) {
|
|
@@ -2689,7 +2728,7 @@ function registerMetasRoutes(app, deps) {
|
|
|
2689
2728
|
const query = metaDetailQuerySchema.parse(request.query);
|
|
2690
2729
|
const { config, watcher } = deps;
|
|
2691
2730
|
const targetPath = normalizePath(decodeURIComponent(request.params.path));
|
|
2692
|
-
const result = await listMetas(config, watcher
|
|
2731
|
+
const result = await listMetas(config, watcher);
|
|
2693
2732
|
const targetNode = findNode(result.tree, targetPath);
|
|
2694
2733
|
if (!targetNode) {
|
|
2695
2734
|
return reply.status(404).send({
|
|
@@ -2722,7 +2761,7 @@ function registerMetasRoutes(app, deps) {
|
|
|
2722
2761
|
return r;
|
|
2723
2762
|
};
|
|
2724
2763
|
// Compute scope
|
|
2725
|
-
const { scopeFiles, allFiles } = getScopeFiles(targetNode);
|
|
2764
|
+
const { scopeFiles, allFiles } = await getScopeFiles(targetNode, watcher);
|
|
2726
2765
|
// Compute staleness
|
|
2727
2766
|
const metaTyped = meta;
|
|
2728
2767
|
const staleSeconds = metaTyped._generatedAt
|
|
@@ -2769,7 +2808,7 @@ function registerPreviewRoute(app, deps) {
|
|
|
2769
2808
|
const query = request.query;
|
|
2770
2809
|
let result;
|
|
2771
2810
|
try {
|
|
2772
|
-
result = await listMetas(config, watcher
|
|
2811
|
+
result = await listMetas(config, watcher);
|
|
2773
2812
|
}
|
|
2774
2813
|
catch {
|
|
2775
2814
|
return reply.status(503).send({
|
|
@@ -2803,16 +2842,16 @@ function registerPreviewRoute(app, deps) {
|
|
|
2803
2842
|
}
|
|
2804
2843
|
targetNode = findNode(result.tree, stalestPath);
|
|
2805
2844
|
}
|
|
2806
|
-
const meta =
|
|
2845
|
+
const meta = readMetaJson(targetNode.metaPath);
|
|
2807
2846
|
// Scope files
|
|
2808
|
-
const { scopeFiles } = getScopeFiles(targetNode);
|
|
2847
|
+
const { scopeFiles } = await getScopeFiles(targetNode, watcher);
|
|
2809
2848
|
const structureHash = computeStructureHash(scopeFiles);
|
|
2810
2849
|
const structureChanged = structureHash !== meta._structureHash;
|
|
2811
2850
|
const latestArchive = readLatestArchive(targetNode.metaPath);
|
|
2812
2851
|
const steerChanged = hasSteerChanged(meta._steer, latestArchive?._steer, Boolean(latestArchive));
|
|
2813
2852
|
const architectTriggered = isArchitectTriggered(meta, structureChanged, steerChanged, config.architectEvery);
|
|
2814
2853
|
// Delta files
|
|
2815
|
-
const deltaFiles = getDeltaFiles(
|
|
2854
|
+
const deltaFiles = getDeltaFiles(meta._generatedAt, scopeFiles);
|
|
2816
2855
|
// EMA token estimates
|
|
2817
2856
|
const estimatedTokens = {
|
|
2818
2857
|
architect: meta._architectTokensAvg ?? meta._architectTokens ?? 0,
|
|
@@ -2927,11 +2966,11 @@ function registerStatusRoute(app, deps) {
|
|
|
2927
2966
|
else {
|
|
2928
2967
|
status = 'idle';
|
|
2929
2968
|
}
|
|
2930
|
-
// Metas summary is expensive (
|
|
2969
|
+
// Metas summary is expensive (watcher walk + disk reads).
|
|
2931
2970
|
// Use GET /metas for full inventory; status is a lightweight health check.
|
|
2932
2971
|
return {
|
|
2933
|
-
service:
|
|
2934
|
-
version:
|
|
2972
|
+
service: SERVICE_NAME,
|
|
2973
|
+
version: SERVICE_VERSION,
|
|
2935
2974
|
uptime: process.uptime(),
|
|
2936
2975
|
status,
|
|
2937
2976
|
currentTarget: queue.current?.path ?? null,
|
|
@@ -2948,7 +2987,10 @@ function registerStatusRoute(app, deps) {
|
|
|
2948
2987
|
nextAt: scheduler?.nextRunAt?.toISOString() ?? null,
|
|
2949
2988
|
},
|
|
2950
2989
|
dependencies: {
|
|
2951
|
-
watcher:
|
|
2990
|
+
watcher: {
|
|
2991
|
+
...watcherHealth,
|
|
2992
|
+
rulesRegistered: deps.registrar?.isRegistered ?? false,
|
|
2993
|
+
},
|
|
2952
2994
|
gateway: gatewayHealth,
|
|
2953
2995
|
},
|
|
2954
2996
|
};
|
|
@@ -2976,7 +3018,7 @@ function registerSynthesizeRoute(app, deps) {
|
|
|
2976
3018
|
// Discover stalest candidate
|
|
2977
3019
|
let result;
|
|
2978
3020
|
try {
|
|
2979
|
-
result = await listMetas(config, watcher
|
|
3021
|
+
result = await listMetas(config, watcher);
|
|
2980
3022
|
}
|
|
2981
3023
|
catch {
|
|
2982
3024
|
return reply.status(503).send({
|
|
@@ -3153,7 +3195,15 @@ function buildMetaRules(config) {
|
|
|
3153
3195
|
},
|
|
3154
3196
|
],
|
|
3155
3197
|
render: {
|
|
3156
|
-
frontmatter: [
|
|
3198
|
+
frontmatter: [
|
|
3199
|
+
'meta_id',
|
|
3200
|
+
'generated_at',
|
|
3201
|
+
'*',
|
|
3202
|
+
'!_*',
|
|
3203
|
+
'!json',
|
|
3204
|
+
'!file',
|
|
3205
|
+
'!has_error',
|
|
3206
|
+
],
|
|
3157
3207
|
body: [{ path: 'json._content', heading: 1, label: 'Synthesis' }],
|
|
3158
3208
|
},
|
|
3159
3209
|
renderAs: 'md',
|
|
@@ -3345,6 +3395,8 @@ function registerShutdownHandlers(deps) {
|
|
|
3345
3395
|
if (deps.routeDeps) {
|
|
3346
3396
|
deps.routeDeps.shuttingDown = true;
|
|
3347
3397
|
}
|
|
3398
|
+
// 0. Run optional cleanup
|
|
3399
|
+
deps.onShutdown?.();
|
|
3348
3400
|
// 1. Stop scheduler
|
|
3349
3401
|
if (deps.scheduler) {
|
|
3350
3402
|
deps.scheduler.stop();
|
|
@@ -3378,7 +3430,7 @@ function registerShutdownHandlers(deps) {
|
|
|
3378
3430
|
/**
|
|
3379
3431
|
* HTTP implementation of the WatcherClient interface.
|
|
3380
3432
|
*
|
|
3381
|
-
* Talks to jeeves-watcher's POST /
|
|
3433
|
+
* Talks to jeeves-watcher's POST /walk and POST /rules/register endpoints
|
|
3382
3434
|
* with retry and exponential backoff.
|
|
3383
3435
|
*
|
|
3384
3436
|
* @module watcher-client/HttpWatcherClient
|
|
@@ -3432,61 +3484,80 @@ class HttpWatcherClient {
|
|
|
3432
3484
|
// Unreachable, but TypeScript needs it
|
|
3433
3485
|
throw new Error('Retry exhausted');
|
|
3434
3486
|
}
|
|
3435
|
-
async
|
|
3436
|
-
|
|
3437
|
-
|
|
3438
|
-
|
|
3439
|
-
|
|
3440
|
-
|
|
3441
|
-
|
|
3442
|
-
|
|
3443
|
-
|
|
3444
|
-
|
|
3445
|
-
|
|
3446
|
-
|
|
3447
|
-
|
|
3448
|
-
|
|
3449
|
-
|
|
3450
|
-
|
|
3487
|
+
async registerRules(source, rules) {
|
|
3488
|
+
await this.post('/rules/register', { source, rules });
|
|
3489
|
+
}
|
|
3490
|
+
async walk(globs) {
|
|
3491
|
+
const raw = (await this.post('/walk', { globs }));
|
|
3492
|
+
return (raw.paths ?? []);
|
|
3493
|
+
}
|
|
3494
|
+
}
|
|
3495
|
+
|
|
3496
|
+
/**
|
|
3497
|
+
* Periodic watcher health check for rule registration resilience.
|
|
3498
|
+
*
|
|
3499
|
+
* Pings watcher `/status` on a configurable interval, detects restarts
|
|
3500
|
+
* (uptime decrease), and re-registers virtual rules automatically.
|
|
3501
|
+
* Independent of the synthesis scheduler.
|
|
3502
|
+
*
|
|
3503
|
+
* @module rules/healthCheck
|
|
3504
|
+
*/
|
|
3505
|
+
/**
|
|
3506
|
+
* Manages the periodic watcher health check loop.
|
|
3507
|
+
*
|
|
3508
|
+
* Starts a `setInterval` that pings the watcher and delegates
|
|
3509
|
+
* restart detection to `RuleRegistrar.checkAndReregister()`.
|
|
3510
|
+
*/
|
|
3511
|
+
class WatcherHealthCheck {
|
|
3512
|
+
watcherUrl;
|
|
3513
|
+
intervalMs;
|
|
3514
|
+
registrar;
|
|
3515
|
+
logger;
|
|
3516
|
+
handle = null;
|
|
3517
|
+
constructor(opts) {
|
|
3518
|
+
this.watcherUrl = opts.watcherUrl.replace(/\/+$/, '');
|
|
3519
|
+
this.intervalMs = opts.intervalMs;
|
|
3520
|
+
this.registrar = opts.registrar;
|
|
3521
|
+
this.logger = opts.logger;
|
|
3522
|
+
}
|
|
3523
|
+
/** Start the periodic health check. No-op if intervalMs is 0. */
|
|
3524
|
+
start() {
|
|
3525
|
+
if (this.intervalMs <= 0) {
|
|
3526
|
+
this.logger.info('Watcher health check disabled (interval = 0)');
|
|
3527
|
+
return;
|
|
3451
3528
|
}
|
|
3452
|
-
|
|
3453
|
-
|
|
3454
|
-
|
|
3455
|
-
|
|
3456
|
-
|
|
3457
|
-
|
|
3529
|
+
this.handle = setInterval(() => {
|
|
3530
|
+
void this.check();
|
|
3531
|
+
}, this.intervalMs);
|
|
3532
|
+
// Don't prevent process exit
|
|
3533
|
+
if (typeof this.handle === 'object' && 'unref' in this.handle) {
|
|
3534
|
+
this.handle.unref();
|
|
3458
3535
|
}
|
|
3459
|
-
|
|
3460
|
-
|
|
3461
|
-
|
|
3462
|
-
|
|
3536
|
+
this.logger.info({ intervalMs: this.intervalMs }, 'Watcher health check started');
|
|
3537
|
+
}
|
|
3538
|
+
/** Stop the periodic health check. */
|
|
3539
|
+
stop() {
|
|
3540
|
+
if (this.handle) {
|
|
3541
|
+
clearInterval(this.handle);
|
|
3542
|
+
this.handle = null;
|
|
3463
3543
|
}
|
|
3464
|
-
|
|
3465
|
-
|
|
3544
|
+
}
|
|
3545
|
+
/** Single health check iteration. */
|
|
3546
|
+
async check() {
|
|
3547
|
+
try {
|
|
3548
|
+
const res = await fetch(this.watcherUrl + '/status', {
|
|
3549
|
+
signal: AbortSignal.timeout(5000),
|
|
3550
|
+
});
|
|
3551
|
+
if (!res.ok) {
|
|
3552
|
+
this.logger.warn({ status: res.status }, 'Watcher health check: non-OK response');
|
|
3553
|
+
return;
|
|
3554
|
+
}
|
|
3555
|
+
const data = (await res.json());
|
|
3556
|
+
await this.registrar.checkAndReregister(data.uptime);
|
|
3466
3557
|
}
|
|
3467
|
-
|
|
3468
|
-
|
|
3558
|
+
catch (err) {
|
|
3559
|
+
this.logger.debug({ err }, 'Watcher health check: unreachable (expected during startup)');
|
|
3469
3560
|
}
|
|
3470
|
-
const raw = (await this.post('/scan', body));
|
|
3471
|
-
// jeeves-watcher returns { points, cursor }; map to ScanResponse.
|
|
3472
|
-
const points = (raw.points ?? raw.files ?? []);
|
|
3473
|
-
const next = (raw.cursor ?? raw.next);
|
|
3474
|
-
const files = points.map((p) => {
|
|
3475
|
-
const payload = (p.payload ?? p);
|
|
3476
|
-
return {
|
|
3477
|
-
file_path: (payload.file_path ?? payload.path ?? ''),
|
|
3478
|
-
modified_at: (payload.modified_at ?? payload.mtime ?? 0),
|
|
3479
|
-
content_hash: (payload.content_hash ?? ''),
|
|
3480
|
-
...payload,
|
|
3481
|
-
};
|
|
3482
|
-
});
|
|
3483
|
-
return { files, next: next ?? undefined };
|
|
3484
|
-
}
|
|
3485
|
-
async registerRules(source, rules) {
|
|
3486
|
-
await this.post('/rules/register', { source, rules });
|
|
3487
|
-
}
|
|
3488
|
-
async unregisterRules(source) {
|
|
3489
|
-
await this.post('/rules/unregister', { source });
|
|
3490
3561
|
}
|
|
3491
3562
|
}
|
|
3492
3563
|
|
|
@@ -3626,7 +3697,16 @@ async function startService(config, configPath) {
|
|
|
3626
3697
|
// Rule registration (fire-and-forget with retries)
|
|
3627
3698
|
const registrar = new RuleRegistrar(config, logger, watcher);
|
|
3628
3699
|
scheduler.setRegistrar(registrar);
|
|
3700
|
+
routeDeps.registrar = registrar;
|
|
3629
3701
|
void registrar.register();
|
|
3702
|
+
// Periodic watcher health check (independent of scheduler)
|
|
3703
|
+
const healthCheck = new WatcherHealthCheck({
|
|
3704
|
+
watcherUrl: config.watcherUrl,
|
|
3705
|
+
intervalMs: config.watcherHealthIntervalMs,
|
|
3706
|
+
registrar,
|
|
3707
|
+
logger,
|
|
3708
|
+
});
|
|
3709
|
+
healthCheck.start();
|
|
3630
3710
|
// Config hot-reload (gap #12)
|
|
3631
3711
|
if (configPath) {
|
|
3632
3712
|
watchFile(configPath, { interval: 5000 }, () => {
|
|
@@ -3660,6 +3740,9 @@ async function startService(config, configPath) {
|
|
|
3660
3740
|
queue,
|
|
3661
3741
|
logger,
|
|
3662
3742
|
routeDeps,
|
|
3743
|
+
onShutdown: () => {
|
|
3744
|
+
healthCheck.stop();
|
|
3745
|
+
},
|
|
3663
3746
|
});
|
|
3664
3747
|
logger.info('Service fully initialized');
|
|
3665
3748
|
}
|
|
@@ -3670,7 +3753,7 @@ async function startService(config, configPath) {
|
|
|
3670
3753
|
* @module cli
|
|
3671
3754
|
*/
|
|
3672
3755
|
const program = new Command();
|
|
3673
|
-
program.name(
|
|
3756
|
+
program.name(SERVICE_NAME).description('Jeeves Meta synthesis service');
|
|
3674
3757
|
// ─── start ──────────────────────────────────────────────────────────
|
|
3675
3758
|
program
|
|
3676
3759
|
.command('start')
|
|
@@ -3709,7 +3792,7 @@ async function apiPost(port, path, body) {
|
|
|
3709
3792
|
program
|
|
3710
3793
|
.command('status')
|
|
3711
3794
|
.description('Show service status')
|
|
3712
|
-
.option('-p, --port <port>', 'Service port',
|
|
3795
|
+
.option('-p, --port <port>', 'Service port', DEFAULT_PORT_STR)
|
|
3713
3796
|
.action(async (opts) => {
|
|
3714
3797
|
try {
|
|
3715
3798
|
const data = await apiGet(parseInt(opts.port, 10), '/status');
|
|
@@ -3724,7 +3807,7 @@ program
|
|
|
3724
3807
|
program
|
|
3725
3808
|
.command('list')
|
|
3726
3809
|
.description('List all discovered meta entities')
|
|
3727
|
-
.option('-p, --port <port>', 'Service port',
|
|
3810
|
+
.option('-p, --port <port>', 'Service port', DEFAULT_PORT_STR)
|
|
3728
3811
|
.action(async (opts) => {
|
|
3729
3812
|
try {
|
|
3730
3813
|
const data = await apiGet(parseInt(opts.port, 10), '/metas');
|
|
@@ -3739,7 +3822,7 @@ program
|
|
|
3739
3822
|
program
|
|
3740
3823
|
.command('detail <path>')
|
|
3741
3824
|
.description('Show full detail for a single meta entity')
|
|
3742
|
-
.option('-p, --port <port>', 'Service port',
|
|
3825
|
+
.option('-p, --port <port>', 'Service port', DEFAULT_PORT_STR)
|
|
3743
3826
|
.action(async (metaPath, opts) => {
|
|
3744
3827
|
try {
|
|
3745
3828
|
const encoded = encodeURIComponent(metaPath);
|
|
@@ -3755,7 +3838,7 @@ program
|
|
|
3755
3838
|
program
|
|
3756
3839
|
.command('preview')
|
|
3757
3840
|
.description('Dry-run: preview inputs for next synthesis cycle')
|
|
3758
|
-
.option('-p, --port <port>', 'Service port',
|
|
3841
|
+
.option('-p, --port <port>', 'Service port', DEFAULT_PORT_STR)
|
|
3759
3842
|
.option('--path <path>', 'Specific meta path to preview')
|
|
3760
3843
|
.action(async (opts) => {
|
|
3761
3844
|
try {
|
|
@@ -3772,7 +3855,7 @@ program
|
|
|
3772
3855
|
program
|
|
3773
3856
|
.command('synthesize')
|
|
3774
3857
|
.description('Trigger synthesis (enqueues work)')
|
|
3775
|
-
.option('-p, --port <port>', 'Service port',
|
|
3858
|
+
.option('-p, --port <port>', 'Service port', DEFAULT_PORT_STR)
|
|
3776
3859
|
.option('--path <path>', 'Specific meta path to synthesize')
|
|
3777
3860
|
.action(async (opts) => {
|
|
3778
3861
|
try {
|
|
@@ -3789,7 +3872,7 @@ program
|
|
|
3789
3872
|
program
|
|
3790
3873
|
.command('seed <path>')
|
|
3791
3874
|
.description('Create .meta/ directory + meta.json for a path')
|
|
3792
|
-
.option('-p, --port <port>', 'Service port',
|
|
3875
|
+
.option('-p, --port <port>', 'Service port', DEFAULT_PORT_STR)
|
|
3793
3876
|
.action(async (metaPath, opts) => {
|
|
3794
3877
|
try {
|
|
3795
3878
|
const data = await apiPost(parseInt(opts.port, 10), '/seed', {
|
|
@@ -3806,7 +3889,7 @@ program
|
|
|
3806
3889
|
program
|
|
3807
3890
|
.command('unlock <path>')
|
|
3808
3891
|
.description('Remove .lock file from a meta entity')
|
|
3809
|
-
.option('-p, --port <port>', 'Service port',
|
|
3892
|
+
.option('-p, --port <port>', 'Service port', DEFAULT_PORT_STR)
|
|
3810
3893
|
.action(async (metaPath, opts) => {
|
|
3811
3894
|
try {
|
|
3812
3895
|
const data = await apiPost(parseInt(opts.port, 10), '/unlock', {
|
|
@@ -3823,7 +3906,7 @@ program
|
|
|
3823
3906
|
program
|
|
3824
3907
|
.command('validate')
|
|
3825
3908
|
.description('Validate current or candidate config')
|
|
3826
|
-
.option('-p, --port <port>', 'Service port',
|
|
3909
|
+
.option('-p, --port <port>', 'Service port', DEFAULT_PORT_STR)
|
|
3827
3910
|
.option('-c, --config <path>', 'Validate a candidate config file locally')
|
|
3828
3911
|
.action(async (opts) => {
|
|
3829
3912
|
try {
|
|
@@ -3856,7 +3939,7 @@ const service = program
|
|
|
3856
3939
|
service.addCommand(new Command('install')
|
|
3857
3940
|
.description('Print install instructions for a system service')
|
|
3858
3941
|
.option('-c, --config <path>', 'Path to configuration file')
|
|
3859
|
-
.option('-n, --name <name>', 'Service name', '
|
|
3942
|
+
.option('-n, --name <name>', 'Service name', 'jeeves-meta')
|
|
3860
3943
|
.action((options) => {
|
|
3861
3944
|
const { name } = options;
|
|
3862
3945
|
const configFlag = options.config ? ` -c "${options.config}"` : '';
|
|
@@ -3921,7 +4004,7 @@ service.addCommand(new Command('install')
|
|
|
3921
4004
|
// start command (prints OS-specific start instructions)
|
|
3922
4005
|
service.addCommand(new Command('start')
|
|
3923
4006
|
.description('Print start instructions for the installed service')
|
|
3924
|
-
.option('-n, --name <name>', 'Service name', '
|
|
4007
|
+
.option('-n, --name <name>', 'Service name', 'jeeves-meta')
|
|
3925
4008
|
.action((options) => {
|
|
3926
4009
|
const { name } = options;
|
|
3927
4010
|
if (process.platform === 'win32') {
|
|
@@ -3940,7 +4023,7 @@ service.addCommand(new Command('start')
|
|
|
3940
4023
|
// stop command
|
|
3941
4024
|
service.addCommand(new Command('stop')
|
|
3942
4025
|
.description('Stop the running service')
|
|
3943
|
-
.option('-n, --name <name>', 'Service name', '
|
|
4026
|
+
.option('-n, --name <name>', 'Service name', 'jeeves-meta')
|
|
3944
4027
|
.action((options) => {
|
|
3945
4028
|
const { name } = options;
|
|
3946
4029
|
if (process.platform === 'win32') {
|
|
@@ -3959,7 +4042,7 @@ service.addCommand(new Command('stop')
|
|
|
3959
4042
|
// status command (service subcommand — queries HTTP API)
|
|
3960
4043
|
service.addCommand(new Command('status')
|
|
3961
4044
|
.description('Show service status via HTTP API')
|
|
3962
|
-
.option('-p, --port <port>', 'Service port',
|
|
4045
|
+
.option('-p, --port <port>', 'Service port', DEFAULT_PORT_STR)
|
|
3963
4046
|
.action(async (opts) => {
|
|
3964
4047
|
try {
|
|
3965
4048
|
const data = await apiGet(parseInt(opts.port, 10), '/status');
|
|
@@ -3972,7 +4055,7 @@ service.addCommand(new Command('status')
|
|
|
3972
4055
|
}));
|
|
3973
4056
|
service.addCommand(new Command('remove')
|
|
3974
4057
|
.description('Print remove instructions for a system service')
|
|
3975
|
-
.option('-n, --name <name>', 'Service name', '
|
|
4058
|
+
.option('-n, --name <name>', 'Service name', 'jeeves-meta')
|
|
3976
4059
|
.action((options) => {
|
|
3977
4060
|
const { name } = options;
|
|
3978
4061
|
if (process.platform === 'win32') {
|