@karmaniverous/jeeves-meta 0.4.2 → 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 +376 -331
- package/dist/index.d.ts +66 -148
- package/dist/index.js +373 -328
- package/package.json +2 -1
|
@@ -1,9 +1,11 @@
|
|
|
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, resolve, 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
6
|
import { fileURLToPath } from 'node:url';
|
|
7
|
+
import 'node:fs/promises';
|
|
8
|
+
import process$1 from 'node:process';
|
|
7
9
|
import { createHash, randomUUID } from 'node:crypto';
|
|
8
10
|
import { tmpdir } from 'node:os';
|
|
9
11
|
import pino from 'pino';
|
|
@@ -69,6 +71,8 @@ const serviceConfigSchema = metaConfigSchema.extend({
|
|
|
69
71
|
schedule: z.string().default('*/30 * * * *'),
|
|
70
72
|
/** Optional channel identifier for reporting. */
|
|
71
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),
|
|
72
76
|
/** Logging configuration. */
|
|
73
77
|
logging: loggingSchema.default(() => loggingSchema.parse({})),
|
|
74
78
|
});
|
|
@@ -168,6 +172,94 @@ var configLoader = /*#__PURE__*/Object.freeze({
|
|
|
168
172
|
resolveConfigPath: resolveConfigPath
|
|
169
173
|
});
|
|
170
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
|
+
|
|
171
263
|
/**
|
|
172
264
|
* Shared constants for the jeeves-meta service package.
|
|
173
265
|
*
|
|
@@ -183,19 +275,11 @@ const SERVICE_NAME = 'jeeves-meta';
|
|
|
183
275
|
const SERVICE_VERSION = (() => {
|
|
184
276
|
try {
|
|
185
277
|
const dir = dirname(fileURLToPath(import.meta.url));
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
resolve(
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
try {
|
|
192
|
-
const pkg = JSON.parse(readFileSync(candidate, 'utf8'));
|
|
193
|
-
if (pkg.version)
|
|
194
|
-
return pkg.version;
|
|
195
|
-
}
|
|
196
|
-
catch {
|
|
197
|
-
// try next candidate
|
|
198
|
-
}
|
|
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;
|
|
199
283
|
}
|
|
200
284
|
return 'unknown';
|
|
201
285
|
}
|
|
@@ -320,114 +404,29 @@ function normalizePath(p) {
|
|
|
320
404
|
}
|
|
321
405
|
|
|
322
406
|
/**
|
|
323
|
-
*
|
|
407
|
+
* Discover .meta/ directories via watcher `/walk` endpoint.
|
|
324
408
|
*
|
|
325
|
-
*
|
|
326
|
-
|
|
327
|
-
/**
|
|
328
|
-
* Perform a paginated scan that follows cursor tokens until exhausted.
|
|
329
|
-
*
|
|
330
|
-
* @param watcher - WatcherClient instance.
|
|
331
|
-
* @param params - Base scan parameters (cursor is managed internally).
|
|
332
|
-
* @returns All matching files across all pages.
|
|
333
|
-
*/
|
|
334
|
-
async function paginatedScan(watcher, params, logger) {
|
|
335
|
-
const allFiles = [];
|
|
336
|
-
let cursor;
|
|
337
|
-
let pageCount = 0;
|
|
338
|
-
const start = Date.now();
|
|
339
|
-
do {
|
|
340
|
-
const pageStart = Date.now();
|
|
341
|
-
const result = await watcher.scan({ ...params, cursor });
|
|
342
|
-
allFiles.push(...result.files);
|
|
343
|
-
pageCount++;
|
|
344
|
-
logger?.debug({
|
|
345
|
-
page: pageCount,
|
|
346
|
-
files: result.files.length,
|
|
347
|
-
pageMs: Date.now() - pageStart,
|
|
348
|
-
hasNext: Boolean(result.next),
|
|
349
|
-
}, 'paginatedScan page');
|
|
350
|
-
cursor = result.next;
|
|
351
|
-
} while (cursor);
|
|
352
|
-
logger?.debug({
|
|
353
|
-
pages: pageCount,
|
|
354
|
-
totalFiles: allFiles.length,
|
|
355
|
-
totalMs: Date.now() - start,
|
|
356
|
-
}, 'paginatedScan complete');
|
|
357
|
-
return allFiles;
|
|
358
|
-
}
|
|
359
|
-
|
|
360
|
-
/**
|
|
361
|
-
* Discover .meta/ directories via watcher scan.
|
|
362
|
-
*
|
|
363
|
-
* Replaces filesystem-based globMetas() with a watcher query
|
|
364
|
-
* 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.
|
|
365
411
|
*
|
|
366
412
|
* @module discovery/discoverMetas
|
|
367
413
|
*/
|
|
368
414
|
/**
|
|
369
|
-
*
|
|
415
|
+
* Discover all .meta/ directories via watcher walk.
|
|
370
416
|
*
|
|
371
|
-
*
|
|
372
|
-
*
|
|
373
|
-
* Objects and other non-filterable types are skipped with a warning.
|
|
374
|
-
*/
|
|
375
|
-
function buildMatchClause(key, value) {
|
|
376
|
-
if (Array.isArray(value)) {
|
|
377
|
-
if (value.length === 0)
|
|
378
|
-
return null;
|
|
379
|
-
return { key, match: { value: value[0] } };
|
|
380
|
-
}
|
|
381
|
-
if (typeof value === 'string' ||
|
|
382
|
-
typeof value === 'number' ||
|
|
383
|
-
typeof value === 'boolean') {
|
|
384
|
-
return { key, match: { value } };
|
|
385
|
-
}
|
|
386
|
-
// Non-filterable value (object, null, etc.) — valid for tagging but
|
|
387
|
-
// cannot be expressed as a Qdrant match clause.
|
|
388
|
-
return null;
|
|
389
|
-
}
|
|
390
|
-
/**
|
|
391
|
-
* Build a Qdrant filter from config metaProperty.
|
|
392
|
-
*
|
|
393
|
-
* Iterates all key-value pairs in `metaProperty` (a generic record)
|
|
394
|
-
* to construct `must` clauses. Always appends `file_path: meta.json`
|
|
395
|
-
* for deduplication.
|
|
396
|
-
*
|
|
397
|
-
* @param config - Meta config with metaProperty.
|
|
398
|
-
* @returns Qdrant filter object for scanning live metas.
|
|
399
|
-
*/
|
|
400
|
-
function buildMetaFilter(config) {
|
|
401
|
-
const must = [];
|
|
402
|
-
for (const [key, value] of Object.entries(config.metaProperty)) {
|
|
403
|
-
const clause = buildMatchClause(key, value);
|
|
404
|
-
if (clause)
|
|
405
|
-
must.push(clause);
|
|
406
|
-
}
|
|
407
|
-
must.push({
|
|
408
|
-
key: 'file_path',
|
|
409
|
-
match: { text: '.meta/meta.json' },
|
|
410
|
-
});
|
|
411
|
-
return { must };
|
|
412
|
-
}
|
|
413
|
-
/**
|
|
414
|
-
* Discover all .meta/ directories via watcher scan.
|
|
417
|
+
* Uses the watcher's `/walk` endpoint to find all `.meta/meta.json` files
|
|
418
|
+
* and returns deduplicated meta directory paths.
|
|
415
419
|
*
|
|
416
|
-
*
|
|
417
|
-
* configured domain filter. Returns deduplicated meta directory paths.
|
|
418
|
-
*
|
|
419
|
-
* @param config - Meta config (for domain filter).
|
|
420
|
-
* @param watcher - WatcherClient for scan queries.
|
|
420
|
+
* @param watcher - WatcherClient for walk queries.
|
|
421
421
|
* @returns Array of normalized .meta/ directory paths.
|
|
422
422
|
*/
|
|
423
|
-
async function discoverMetas(
|
|
424
|
-
const
|
|
425
|
-
const scanFiles = await paginatedScan(watcher, { filter, fields: ['file_path'] }, logger);
|
|
423
|
+
async function discoverMetas(watcher) {
|
|
424
|
+
const allPaths = await watcher.walk(['**/.meta/meta.json']);
|
|
426
425
|
// Deduplicate by .meta/ directory path (handles multi-chunk files)
|
|
427
426
|
const seen = new Set();
|
|
428
427
|
const metaPaths = [];
|
|
429
|
-
for (const
|
|
430
|
-
const fp = normalizePath(
|
|
428
|
+
for (const filePath of allPaths) {
|
|
429
|
+
const fp = normalizePath(filePath);
|
|
431
430
|
// Derive .meta/ directory from file_path (strip /meta.json)
|
|
432
431
|
const metaPath = fp.replace(/\/meta\.json$/, '');
|
|
433
432
|
if (seen.has(metaPath))
|
|
@@ -568,6 +567,25 @@ function cleanupStaleLocks(metaPaths, logger) {
|
|
|
568
567
|
}
|
|
569
568
|
}
|
|
570
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
|
+
|
|
571
589
|
/**
|
|
572
590
|
* Build the ownership tree from discovered .meta/ paths.
|
|
573
591
|
*
|
|
@@ -663,9 +681,9 @@ const MAX_STALENESS_SECONDS$1 = 365 * 86_400;
|
|
|
663
681
|
* @param watcher - Watcher HTTP client for discovery.
|
|
664
682
|
* @returns Enriched meta list with summary statistics and ownership tree.
|
|
665
683
|
*/
|
|
666
|
-
async function listMetas(config, watcher
|
|
667
|
-
// Step 1: Discover deduplicated meta paths via watcher
|
|
668
|
-
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);
|
|
669
687
|
// Step 2: Build ownership tree
|
|
670
688
|
const tree = buildOwnershipTree(metaPaths);
|
|
671
689
|
// Step 3: Read and enrich each meta from disk
|
|
@@ -684,7 +702,7 @@ async function listMetas(config, watcher, logger) {
|
|
|
684
702
|
for (const node of tree.nodes.values()) {
|
|
685
703
|
let meta;
|
|
686
704
|
try {
|
|
687
|
-
meta =
|
|
705
|
+
meta = readMetaJson(node.metaPath);
|
|
688
706
|
}
|
|
689
707
|
catch {
|
|
690
708
|
// Skip unreadable metas
|
|
@@ -772,67 +790,50 @@ async function listMetas(config, watcher, logger) {
|
|
|
772
790
|
}
|
|
773
791
|
|
|
774
792
|
/**
|
|
775
|
-
*
|
|
793
|
+
* Filter file paths by modification time.
|
|
776
794
|
*
|
|
777
|
-
*
|
|
778
|
-
*
|
|
795
|
+
* Shared utility for staleness detection and delta file enumeration.
|
|
796
|
+
* Uses `fs.statSync` for fast local mtime checks on known paths.
|
|
779
797
|
*
|
|
780
|
-
* @module
|
|
798
|
+
* @module mtimeFilter
|
|
781
799
|
*/
|
|
782
|
-
/** Default directory names to always skip. */
|
|
783
|
-
const DEFAULT_SKIP = new Set([
|
|
784
|
-
'node_modules',
|
|
785
|
-
'.git',
|
|
786
|
-
'.rollup.cache',
|
|
787
|
-
'dist',
|
|
788
|
-
'Thumbs.db',
|
|
789
|
-
]);
|
|
790
800
|
/**
|
|
791
|
-
*
|
|
801
|
+
* Check if any file in the list was modified after the given timestamp.
|
|
802
|
+
*
|
|
803
|
+
* Short-circuits on first match for efficiency (staleness checks).
|
|
792
804
|
*
|
|
793
|
-
* @param
|
|
794
|
-
* @param
|
|
795
|
-
* @returns
|
|
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.
|
|
796
808
|
*/
|
|
797
|
-
function
|
|
798
|
-
|
|
799
|
-
const modifiedAfter = options?.modifiedAfter;
|
|
800
|
-
const maxDepth = options?.maxDepth ?? 50;
|
|
801
|
-
const results = [];
|
|
802
|
-
function walk(dir, depth) {
|
|
803
|
-
if (depth > maxDepth)
|
|
804
|
-
return;
|
|
805
|
-
let entries;
|
|
809
|
+
function hasModifiedAfter(files, afterMs) {
|
|
810
|
+
for (const filePath of files) {
|
|
806
811
|
try {
|
|
807
|
-
|
|
812
|
+
if (statSync(filePath).mtimeMs > afterMs)
|
|
813
|
+
return true;
|
|
808
814
|
}
|
|
809
815
|
catch {
|
|
810
|
-
|
|
811
|
-
}
|
|
812
|
-
for (const entry of entries) {
|
|
813
|
-
if (exclude.has(entry.name))
|
|
814
|
-
continue;
|
|
815
|
-
const fullPath = join(dir, entry.name);
|
|
816
|
-
if (entry.isDirectory()) {
|
|
817
|
-
walk(fullPath, depth + 1);
|
|
818
|
-
}
|
|
819
|
-
else if (entry.isFile()) {
|
|
820
|
-
if (modifiedAfter !== undefined) {
|
|
821
|
-
try {
|
|
822
|
-
const stat = statSync(fullPath);
|
|
823
|
-
if (Math.floor(stat.mtimeMs / 1000) <= modifiedAfter)
|
|
824
|
-
continue;
|
|
825
|
-
}
|
|
826
|
-
catch {
|
|
827
|
-
continue;
|
|
828
|
-
}
|
|
829
|
-
}
|
|
830
|
-
results.push(normalizePath(fullPath));
|
|
831
|
-
}
|
|
816
|
+
// Unreadable file — skip
|
|
832
817
|
}
|
|
833
818
|
}
|
|
834
|
-
|
|
835
|
-
|
|
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
|
+
});
|
|
836
837
|
}
|
|
837
838
|
|
|
838
839
|
/**
|
|
@@ -842,7 +843,7 @@ function walkFiles(root, options) {
|
|
|
842
843
|
* - Its own .meta/ subtree (outputs, not inputs)
|
|
843
844
|
* - Child meta ownerPath subtrees (except their .meta/meta.json for rollups)
|
|
844
845
|
*
|
|
845
|
-
*
|
|
846
|
+
* All filesystem enumeration delegated to the watcher's `/walk` endpoint.
|
|
846
847
|
*
|
|
847
848
|
* @module discovery/scope
|
|
848
849
|
*/
|
|
@@ -859,7 +860,7 @@ function getScopePrefix(node) {
|
|
|
859
860
|
* - The node's own .meta/ subtree (synthesis outputs are not scope inputs)
|
|
860
861
|
* - Child meta ownerPath subtrees (except child .meta/meta.json for rollups)
|
|
861
862
|
*
|
|
862
|
-
*
|
|
863
|
+
* Watcher walk returns normalized forward-slash paths.
|
|
863
864
|
*/
|
|
864
865
|
function filterInScope(node, files) {
|
|
865
866
|
const prefix = node.ownerPath + '/';
|
|
@@ -884,10 +885,10 @@ function filterInScope(node, files) {
|
|
|
884
885
|
});
|
|
885
886
|
}
|
|
886
887
|
/**
|
|
887
|
-
* Get all files in scope for a meta node via
|
|
888
|
+
* Get all files in scope for a meta node via watcher walk.
|
|
888
889
|
*/
|
|
889
|
-
function getScopeFiles(node) {
|
|
890
|
-
const allFiles =
|
|
890
|
+
async function getScopeFiles(node, watcher) {
|
|
891
|
+
const allFiles = await watcher.walk([`${node.ownerPath}/**`]);
|
|
891
892
|
return {
|
|
892
893
|
scopeFiles: filterInScope(node, allFiles),
|
|
893
894
|
allFiles,
|
|
@@ -897,13 +898,12 @@ function getScopeFiles(node) {
|
|
|
897
898
|
* Get files modified since a given timestamp within a meta node's scope.
|
|
898
899
|
*
|
|
899
900
|
* If no generatedAt is provided (first run), returns all scope files.
|
|
901
|
+
* Reuses scope files from getScopeFiles() and filters locally by mtime.
|
|
900
902
|
*/
|
|
901
|
-
function getDeltaFiles(
|
|
903
|
+
function getDeltaFiles(generatedAt, scopeFiles) {
|
|
902
904
|
if (!generatedAt)
|
|
903
905
|
return scopeFiles;
|
|
904
|
-
|
|
905
|
-
const deltaFiles = walkFiles(node.ownerPath, { modifiedAfter });
|
|
906
|
-
return filterInScope(node, deltaFiles);
|
|
906
|
+
return filterModifiedAfter(scopeFiles, new Date(generatedAt).getTime());
|
|
907
907
|
}
|
|
908
908
|
|
|
909
909
|
/**
|
|
@@ -1204,10 +1204,10 @@ function condenseScopeFiles(files, maxIndividual = 30) {
|
|
|
1204
1204
|
* @param watcher - WatcherClient for scope enumeration.
|
|
1205
1205
|
* @returns The computed context package.
|
|
1206
1206
|
*/
|
|
1207
|
-
function buildContextPackage(node, meta) {
|
|
1208
|
-
// Scope and delta files via watcher
|
|
1209
|
-
const { scopeFiles } = getScopeFiles(node);
|
|
1210
|
-
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);
|
|
1211
1211
|
// Child meta outputs
|
|
1212
1212
|
const childMetas = {};
|
|
1213
1213
|
for (const child of node.children) {
|
|
@@ -1531,6 +1531,67 @@ function mergeAndWrite(options) {
|
|
|
1531
1531
|
return result.data;
|
|
1532
1532
|
}
|
|
1533
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
|
+
|
|
1534
1595
|
/**
|
|
1535
1596
|
* Weighted staleness formula for candidate selection.
|
|
1536
1597
|
*
|
|
@@ -1610,29 +1671,30 @@ function discoverStalestPath(candidates, depthWeight) {
|
|
|
1610
1671
|
}
|
|
1611
1672
|
|
|
1612
1673
|
/**
|
|
1613
|
-
* Staleness detection via watcher
|
|
1674
|
+
* Staleness detection via watcher walk.
|
|
1614
1675
|
*
|
|
1615
|
-
* 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`.
|
|
1616
1678
|
*
|
|
1617
1679
|
* @module scheduling/staleness
|
|
1618
1680
|
*/
|
|
1619
1681
|
/**
|
|
1620
|
-
* 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.
|
|
1621
1687
|
*
|
|
1622
1688
|
* @param scopePrefix - Path prefix for this meta's scope.
|
|
1623
1689
|
* @param meta - Current meta.json content.
|
|
1624
1690
|
* @param watcher - WatcherClient instance.
|
|
1625
|
-
* @returns True if any file in scope was modified after _generatedAt
|
|
1691
|
+
* @returns True if any file in scope was modified after `_generatedAt`.
|
|
1626
1692
|
*/
|
|
1627
|
-
function isStale(scopePrefix, meta) {
|
|
1693
|
+
async function isStale(scopePrefix, meta, watcher) {
|
|
1628
1694
|
if (!meta._generatedAt)
|
|
1629
1695
|
return true; // Never synthesized = stale
|
|
1630
|
-
const
|
|
1631
|
-
|
|
1632
|
-
modifiedAfter: generatedAtUnix,
|
|
1633
|
-
maxDepth: 1,
|
|
1634
|
-
});
|
|
1635
|
-
return modified.length > 0;
|
|
1696
|
+
const files = await watcher.walk([`${scopePrefix}/**`]);
|
|
1697
|
+
return hasModifiedAfter(files, new Date(meta._generatedAt).getTime());
|
|
1636
1698
|
}
|
|
1637
1699
|
/** Maximum staleness for never-synthesized metas (1 year in seconds). */
|
|
1638
1700
|
const MAX_STALENESS_SECONDS = 365 * 86_400;
|
|
@@ -1842,72 +1904,13 @@ function finalizeCycle(opts) {
|
|
|
1842
1904
|
* @param watcher - Watcher HTTP client.
|
|
1843
1905
|
* @returns Result indicating whether synthesis occurred.
|
|
1844
1906
|
*/
|
|
1845
|
-
/**
|
|
1846
|
-
* Build a minimal MetaNode from the filesystem for a known meta path.
|
|
1847
|
-
* Discovers immediate child .meta/ dirs without a full watcher scan.
|
|
1848
|
-
*/
|
|
1849
|
-
function buildMinimalNode(metaPath) {
|
|
1850
|
-
const normalized = normalizePath(metaPath);
|
|
1851
|
-
const ownerPath = normalizePath(dirname(metaPath));
|
|
1852
|
-
// Find child .meta/ directories by scanning the owner directory
|
|
1853
|
-
const children = [];
|
|
1854
|
-
function findChildMetas(dir, depth) {
|
|
1855
|
-
if (depth > 10)
|
|
1856
|
-
return; // Safety limit
|
|
1857
|
-
try {
|
|
1858
|
-
const entries = readdirSync(dir, { withFileTypes: true });
|
|
1859
|
-
for (const entry of entries) {
|
|
1860
|
-
if (!entry.isDirectory())
|
|
1861
|
-
continue;
|
|
1862
|
-
const fullPath = normalizePath(join(dir, entry.name));
|
|
1863
|
-
if (entry.name === '.meta' && fullPath !== normalized) {
|
|
1864
|
-
// Found a child .meta — check it has meta.json
|
|
1865
|
-
if (existsSync(join(fullPath, 'meta.json'))) {
|
|
1866
|
-
children.push({
|
|
1867
|
-
metaPath: fullPath,
|
|
1868
|
-
ownerPath: normalizePath(dirname(fullPath)),
|
|
1869
|
-
treeDepth: 1, // Relative to target
|
|
1870
|
-
children: [],
|
|
1871
|
-
parent: null, // Set below
|
|
1872
|
-
});
|
|
1873
|
-
}
|
|
1874
|
-
// Don't recurse into .meta dirs
|
|
1875
|
-
return;
|
|
1876
|
-
}
|
|
1877
|
-
if (entry.name === 'node_modules' ||
|
|
1878
|
-
entry.name === '.git' ||
|
|
1879
|
-
entry.name === 'archive')
|
|
1880
|
-
continue;
|
|
1881
|
-
findChildMetas(fullPath, depth + 1);
|
|
1882
|
-
}
|
|
1883
|
-
}
|
|
1884
|
-
catch {
|
|
1885
|
-
// Permission errors, etc — skip
|
|
1886
|
-
}
|
|
1887
|
-
}
|
|
1888
|
-
findChildMetas(ownerPath, 0);
|
|
1889
|
-
const node = {
|
|
1890
|
-
metaPath: normalized,
|
|
1891
|
-
ownerPath,
|
|
1892
|
-
treeDepth: 0,
|
|
1893
|
-
children,
|
|
1894
|
-
parent: null,
|
|
1895
|
-
};
|
|
1896
|
-
// Wire parent references
|
|
1897
|
-
for (const child of children) {
|
|
1898
|
-
child.parent = node;
|
|
1899
|
-
}
|
|
1900
|
-
return node;
|
|
1901
|
-
}
|
|
1902
1907
|
/** Run the architect/builder/critic pipeline on a single node. */
|
|
1903
1908
|
async function synthesizeNode(node, currentMeta, config, executor, watcher, onProgress) {
|
|
1904
|
-
const architectPrompt = currentMeta._architect ?? config.defaultArchitect;
|
|
1905
|
-
const criticPrompt = currentMeta._critic ?? config.defaultCritic;
|
|
1906
1909
|
// Step 5-6: Steer change detection
|
|
1907
1910
|
const latestArchive = readLatestArchive(node.metaPath);
|
|
1908
1911
|
const steerChanged = hasSteerChanged(currentMeta._steer, latestArchive?._steer, Boolean(latestArchive));
|
|
1909
1912
|
// Step 7: Compute context (includes scope files and delta files)
|
|
1910
|
-
const ctx = buildContextPackage(node, currentMeta);
|
|
1913
|
+
const ctx = await buildContextPackage(node, currentMeta, watcher);
|
|
1911
1914
|
// Step 5 (deferred): Structure hash from context scope files
|
|
1912
1915
|
const newStructureHash = computeStructureHash(ctx.scopeFiles);
|
|
1913
1916
|
const structureChanged = newStructureHash !== currentMeta._structureHash;
|
|
@@ -1951,9 +1954,9 @@ async function synthesizeNode(node, currentMeta, config, executor, watcher, onPr
|
|
|
1951
1954
|
metaPath: node.metaPath,
|
|
1952
1955
|
current: currentMeta,
|
|
1953
1956
|
config,
|
|
1954
|
-
architect:
|
|
1957
|
+
architect: currentMeta._architect ?? '',
|
|
1955
1958
|
builder: '',
|
|
1956
|
-
critic:
|
|
1959
|
+
critic: currentMeta._critic ?? '',
|
|
1957
1960
|
builderOutput: null,
|
|
1958
1961
|
feedback: null,
|
|
1959
1962
|
structureHash: newStructureHash,
|
|
@@ -2037,9 +2040,9 @@ async function synthesizeNode(node, currentMeta, config, executor, watcher, onPr
|
|
|
2037
2040
|
metaPath: node.metaPath,
|
|
2038
2041
|
current: currentMeta,
|
|
2039
2042
|
config,
|
|
2040
|
-
architect:
|
|
2043
|
+
architect: currentMeta._architect ?? '',
|
|
2041
2044
|
builder: builderBrief,
|
|
2042
|
-
critic:
|
|
2045
|
+
critic: currentMeta._critic ?? '',
|
|
2043
2046
|
builderOutput,
|
|
2044
2047
|
feedback,
|
|
2045
2048
|
structureHash: newStructureHash,
|
|
@@ -2063,26 +2066,25 @@ async function orchestrateOnce(config, executor, watcher, targetPath, onProgress
|
|
|
2063
2066
|
const targetMetaJson = join(normalizedTarget, 'meta.json');
|
|
2064
2067
|
if (!existsSync(targetMetaJson))
|
|
2065
2068
|
return { synthesized: false };
|
|
2066
|
-
const node = buildMinimalNode(normalizedTarget);
|
|
2069
|
+
const node = await buildMinimalNode(normalizedTarget, watcher);
|
|
2067
2070
|
if (!acquireLock(node.metaPath))
|
|
2068
2071
|
return { synthesized: false };
|
|
2069
2072
|
try {
|
|
2070
|
-
const currentMeta =
|
|
2073
|
+
const currentMeta = readMetaJson(normalizedTarget);
|
|
2071
2074
|
return await synthesizeNode(node, currentMeta, config, executor, watcher, onProgress);
|
|
2072
2075
|
}
|
|
2073
2076
|
finally {
|
|
2074
2077
|
releaseLock(node.metaPath);
|
|
2075
2078
|
}
|
|
2076
2079
|
}
|
|
2077
|
-
const metaPaths = await discoverMetas(
|
|
2080
|
+
const metaPaths = await discoverMetas(watcher);
|
|
2078
2081
|
if (metaPaths.length === 0)
|
|
2079
2082
|
return { synthesized: false };
|
|
2080
2083
|
// Read meta.json for each discovered meta
|
|
2081
2084
|
const metas = new Map();
|
|
2082
2085
|
for (const mp of metaPaths) {
|
|
2083
|
-
const metaFilePath = join(mp, 'meta.json');
|
|
2084
2086
|
try {
|
|
2085
|
-
metas.set(normalizePath(mp),
|
|
2087
|
+
metas.set(normalizePath(mp), readMetaJson(mp));
|
|
2086
2088
|
}
|
|
2087
2089
|
catch {
|
|
2088
2090
|
// Skip metas with unreadable meta.json
|
|
@@ -2115,13 +2117,12 @@ async function orchestrateOnce(config, executor, watcher, targetPath, onProgress
|
|
|
2115
2117
|
for (const candidate of ranked) {
|
|
2116
2118
|
if (!acquireLock(candidate.node.metaPath))
|
|
2117
2119
|
continue;
|
|
2118
|
-
const verifiedStale = isStale(getScopePrefix(candidate.node), candidate.meta);
|
|
2120
|
+
const verifiedStale = await isStale(getScopePrefix(candidate.node), candidate.meta, watcher);
|
|
2119
2121
|
if (!verifiedStale && candidate.meta._generatedAt) {
|
|
2120
2122
|
// Bump _generatedAt so it doesn't win next cycle
|
|
2121
|
-
const
|
|
2122
|
-
const freshMeta = JSON.parse(readFileSync(metaFilePath, 'utf8'));
|
|
2123
|
+
const freshMeta = readMetaJson(candidate.node.metaPath);
|
|
2123
2124
|
freshMeta._generatedAt = new Date().toISOString();
|
|
2124
|
-
writeFileSync(
|
|
2125
|
+
writeFileSync(join(candidate.node.metaPath, 'meta.json'), JSON.stringify(freshMeta, null, 2));
|
|
2125
2126
|
releaseLock(candidate.node.metaPath);
|
|
2126
2127
|
if (config.skipUnchanged)
|
|
2127
2128
|
continue;
|
|
@@ -2134,7 +2135,7 @@ async function orchestrateOnce(config, executor, watcher, targetPath, onProgress
|
|
|
2134
2135
|
return { synthesized: false };
|
|
2135
2136
|
const node = winner.node;
|
|
2136
2137
|
try {
|
|
2137
|
-
const currentMeta =
|
|
2138
|
+
const currentMeta = readMetaJson(node.metaPath);
|
|
2138
2139
|
return await synthesizeNode(node, currentMeta, config, executor, watcher, onProgress);
|
|
2139
2140
|
}
|
|
2140
2141
|
finally {
|
|
@@ -2155,7 +2156,7 @@ async function orchestrateOnce(config, executor, watcher, targetPath, onProgress
|
|
|
2155
2156
|
* @returns Array with a single result.
|
|
2156
2157
|
*/
|
|
2157
2158
|
async function orchestrate(config, executor, watcher, targetPath, onProgress, logger) {
|
|
2158
|
-
const result = await orchestrateOnce(config, executor, watcher, targetPath, onProgress
|
|
2159
|
+
const result = await orchestrateOnce(config, executor, watcher, targetPath, onProgress);
|
|
2159
2160
|
return [result];
|
|
2160
2161
|
}
|
|
2161
2162
|
|
|
@@ -2378,7 +2379,7 @@ class Scheduler {
|
|
|
2378
2379
|
*/
|
|
2379
2380
|
async discoverStalest() {
|
|
2380
2381
|
try {
|
|
2381
|
-
const result = await listMetas(this.config, this.watcher
|
|
2382
|
+
const result = await listMetas(this.config, this.watcher);
|
|
2382
2383
|
const stale = result.entries
|
|
2383
2384
|
.filter((e) => e.stalenessSeconds > 0)
|
|
2384
2385
|
.map((e) => ({
|
|
@@ -2664,7 +2665,7 @@ function registerMetasRoutes(app, deps) {
|
|
|
2664
2665
|
app.get('/metas', async (request) => {
|
|
2665
2666
|
const query = metasQuerySchema.parse(request.query);
|
|
2666
2667
|
const { config, watcher } = deps;
|
|
2667
|
-
const result = await listMetas(config, watcher
|
|
2668
|
+
const result = await listMetas(config, watcher);
|
|
2668
2669
|
let entries = result.entries;
|
|
2669
2670
|
// Apply filters
|
|
2670
2671
|
if (query.pathPrefix) {
|
|
@@ -2727,7 +2728,7 @@ function registerMetasRoutes(app, deps) {
|
|
|
2727
2728
|
const query = metaDetailQuerySchema.parse(request.query);
|
|
2728
2729
|
const { config, watcher } = deps;
|
|
2729
2730
|
const targetPath = normalizePath(decodeURIComponent(request.params.path));
|
|
2730
|
-
const result = await listMetas(config, watcher
|
|
2731
|
+
const result = await listMetas(config, watcher);
|
|
2731
2732
|
const targetNode = findNode(result.tree, targetPath);
|
|
2732
2733
|
if (!targetNode) {
|
|
2733
2734
|
return reply.status(404).send({
|
|
@@ -2760,7 +2761,7 @@ function registerMetasRoutes(app, deps) {
|
|
|
2760
2761
|
return r;
|
|
2761
2762
|
};
|
|
2762
2763
|
// Compute scope
|
|
2763
|
-
const { scopeFiles, allFiles } = getScopeFiles(targetNode);
|
|
2764
|
+
const { scopeFiles, allFiles } = await getScopeFiles(targetNode, watcher);
|
|
2764
2765
|
// Compute staleness
|
|
2765
2766
|
const metaTyped = meta;
|
|
2766
2767
|
const staleSeconds = metaTyped._generatedAt
|
|
@@ -2807,7 +2808,7 @@ function registerPreviewRoute(app, deps) {
|
|
|
2807
2808
|
const query = request.query;
|
|
2808
2809
|
let result;
|
|
2809
2810
|
try {
|
|
2810
|
-
result = await listMetas(config, watcher
|
|
2811
|
+
result = await listMetas(config, watcher);
|
|
2811
2812
|
}
|
|
2812
2813
|
catch {
|
|
2813
2814
|
return reply.status(503).send({
|
|
@@ -2841,16 +2842,16 @@ function registerPreviewRoute(app, deps) {
|
|
|
2841
2842
|
}
|
|
2842
2843
|
targetNode = findNode(result.tree, stalestPath);
|
|
2843
2844
|
}
|
|
2844
|
-
const meta =
|
|
2845
|
+
const meta = readMetaJson(targetNode.metaPath);
|
|
2845
2846
|
// Scope files
|
|
2846
|
-
const { scopeFiles } = getScopeFiles(targetNode);
|
|
2847
|
+
const { scopeFiles } = await getScopeFiles(targetNode, watcher);
|
|
2847
2848
|
const structureHash = computeStructureHash(scopeFiles);
|
|
2848
2849
|
const structureChanged = structureHash !== meta._structureHash;
|
|
2849
2850
|
const latestArchive = readLatestArchive(targetNode.metaPath);
|
|
2850
2851
|
const steerChanged = hasSteerChanged(meta._steer, latestArchive?._steer, Boolean(latestArchive));
|
|
2851
2852
|
const architectTriggered = isArchitectTriggered(meta, structureChanged, steerChanged, config.architectEvery);
|
|
2852
2853
|
// Delta files
|
|
2853
|
-
const deltaFiles = getDeltaFiles(
|
|
2854
|
+
const deltaFiles = getDeltaFiles(meta._generatedAt, scopeFiles);
|
|
2854
2855
|
// EMA token estimates
|
|
2855
2856
|
const estimatedTokens = {
|
|
2856
2857
|
architect: meta._architectTokensAvg ?? meta._architectTokens ?? 0,
|
|
@@ -2965,7 +2966,7 @@ function registerStatusRoute(app, deps) {
|
|
|
2965
2966
|
else {
|
|
2966
2967
|
status = 'idle';
|
|
2967
2968
|
}
|
|
2968
|
-
// Metas summary is expensive (
|
|
2969
|
+
// Metas summary is expensive (watcher walk + disk reads).
|
|
2969
2970
|
// Use GET /metas for full inventory; status is a lightweight health check.
|
|
2970
2971
|
return {
|
|
2971
2972
|
service: SERVICE_NAME,
|
|
@@ -2986,7 +2987,10 @@ function registerStatusRoute(app, deps) {
|
|
|
2986
2987
|
nextAt: scheduler?.nextRunAt?.toISOString() ?? null,
|
|
2987
2988
|
},
|
|
2988
2989
|
dependencies: {
|
|
2989
|
-
watcher:
|
|
2990
|
+
watcher: {
|
|
2991
|
+
...watcherHealth,
|
|
2992
|
+
rulesRegistered: deps.registrar?.isRegistered ?? false,
|
|
2993
|
+
},
|
|
2990
2994
|
gateway: gatewayHealth,
|
|
2991
2995
|
},
|
|
2992
2996
|
};
|
|
@@ -3014,7 +3018,7 @@ function registerSynthesizeRoute(app, deps) {
|
|
|
3014
3018
|
// Discover stalest candidate
|
|
3015
3019
|
let result;
|
|
3016
3020
|
try {
|
|
3017
|
-
result = await listMetas(config, watcher
|
|
3021
|
+
result = await listMetas(config, watcher);
|
|
3018
3022
|
}
|
|
3019
3023
|
catch {
|
|
3020
3024
|
return reply.status(503).send({
|
|
@@ -3191,7 +3195,15 @@ function buildMetaRules(config) {
|
|
|
3191
3195
|
},
|
|
3192
3196
|
],
|
|
3193
3197
|
render: {
|
|
3194
|
-
frontmatter: [
|
|
3198
|
+
frontmatter: [
|
|
3199
|
+
'meta_id',
|
|
3200
|
+
'generated_at',
|
|
3201
|
+
'*',
|
|
3202
|
+
'!_*',
|
|
3203
|
+
'!json',
|
|
3204
|
+
'!file',
|
|
3205
|
+
'!has_error',
|
|
3206
|
+
],
|
|
3195
3207
|
body: [{ path: 'json._content', heading: 1, label: 'Synthesis' }],
|
|
3196
3208
|
},
|
|
3197
3209
|
renderAs: 'md',
|
|
@@ -3383,6 +3395,8 @@ function registerShutdownHandlers(deps) {
|
|
|
3383
3395
|
if (deps.routeDeps) {
|
|
3384
3396
|
deps.routeDeps.shuttingDown = true;
|
|
3385
3397
|
}
|
|
3398
|
+
// 0. Run optional cleanup
|
|
3399
|
+
deps.onShutdown?.();
|
|
3386
3400
|
// 1. Stop scheduler
|
|
3387
3401
|
if (deps.scheduler) {
|
|
3388
3402
|
deps.scheduler.stop();
|
|
@@ -3416,7 +3430,7 @@ function registerShutdownHandlers(deps) {
|
|
|
3416
3430
|
/**
|
|
3417
3431
|
* HTTP implementation of the WatcherClient interface.
|
|
3418
3432
|
*
|
|
3419
|
-
* Talks to jeeves-watcher's POST /
|
|
3433
|
+
* Talks to jeeves-watcher's POST /walk and POST /rules/register endpoints
|
|
3420
3434
|
* with retry and exponential backoff.
|
|
3421
3435
|
*
|
|
3422
3436
|
* @module watcher-client/HttpWatcherClient
|
|
@@ -3470,61 +3484,80 @@ class HttpWatcherClient {
|
|
|
3470
3484
|
// Unreachable, but TypeScript needs it
|
|
3471
3485
|
throw new Error('Retry exhausted');
|
|
3472
3486
|
}
|
|
3473
|
-
async
|
|
3474
|
-
|
|
3475
|
-
|
|
3476
|
-
|
|
3477
|
-
|
|
3478
|
-
|
|
3479
|
-
|
|
3480
|
-
|
|
3481
|
-
|
|
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;
|
|
3482
3528
|
}
|
|
3483
|
-
|
|
3484
|
-
|
|
3485
|
-
|
|
3486
|
-
|
|
3487
|
-
|
|
3488
|
-
|
|
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();
|
|
3489
3535
|
}
|
|
3490
|
-
|
|
3491
|
-
|
|
3492
|
-
|
|
3493
|
-
|
|
3494
|
-
|
|
3495
|
-
|
|
3496
|
-
|
|
3497
|
-
const filter = { must: mustClauses };
|
|
3498
|
-
const body = { filter };
|
|
3499
|
-
if (params.fields !== undefined) {
|
|
3500
|
-
body.fields = params.fields;
|
|
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;
|
|
3501
3543
|
}
|
|
3502
|
-
|
|
3503
|
-
|
|
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);
|
|
3504
3557
|
}
|
|
3505
|
-
|
|
3506
|
-
|
|
3558
|
+
catch (err) {
|
|
3559
|
+
this.logger.debug({ err }, 'Watcher health check: unreachable (expected during startup)');
|
|
3507
3560
|
}
|
|
3508
|
-
const raw = (await this.post('/scan', body));
|
|
3509
|
-
// jeeves-watcher returns { points, cursor }; map to ScanResponse.
|
|
3510
|
-
const points = (raw.points ?? raw.files ?? []);
|
|
3511
|
-
const next = (raw.cursor ?? raw.next);
|
|
3512
|
-
const files = points.map((p) => {
|
|
3513
|
-
const payload = (p.payload ?? p);
|
|
3514
|
-
return {
|
|
3515
|
-
file_path: (payload.file_path ?? payload.path ?? ''),
|
|
3516
|
-
modified_at: (payload.modified_at ?? payload.mtime ?? 0),
|
|
3517
|
-
content_hash: (payload.content_hash ?? ''),
|
|
3518
|
-
...payload,
|
|
3519
|
-
};
|
|
3520
|
-
});
|
|
3521
|
-
return { files, next: next ?? undefined };
|
|
3522
|
-
}
|
|
3523
|
-
async registerRules(source, rules) {
|
|
3524
|
-
await this.post('/rules/register', { source, rules });
|
|
3525
|
-
}
|
|
3526
|
-
async unregisterRules(source) {
|
|
3527
|
-
await this.post('/rules/unregister', { source });
|
|
3528
3561
|
}
|
|
3529
3562
|
}
|
|
3530
3563
|
|
|
@@ -3664,7 +3697,16 @@ async function startService(config, configPath) {
|
|
|
3664
3697
|
// Rule registration (fire-and-forget with retries)
|
|
3665
3698
|
const registrar = new RuleRegistrar(config, logger, watcher);
|
|
3666
3699
|
scheduler.setRegistrar(registrar);
|
|
3700
|
+
routeDeps.registrar = registrar;
|
|
3667
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();
|
|
3668
3710
|
// Config hot-reload (gap #12)
|
|
3669
3711
|
if (configPath) {
|
|
3670
3712
|
watchFile(configPath, { interval: 5000 }, () => {
|
|
@@ -3698,6 +3740,9 @@ async function startService(config, configPath) {
|
|
|
3698
3740
|
queue,
|
|
3699
3741
|
logger,
|
|
3700
3742
|
routeDeps,
|
|
3743
|
+
onShutdown: () => {
|
|
3744
|
+
healthCheck.stop();
|
|
3745
|
+
},
|
|
3701
3746
|
});
|
|
3702
3747
|
logger.info('Service fully initialized');
|
|
3703
3748
|
}
|
|
@@ -3894,7 +3939,7 @@ const service = program
|
|
|
3894
3939
|
service.addCommand(new Command('install')
|
|
3895
3940
|
.description('Print install instructions for a system service')
|
|
3896
3941
|
.option('-c, --config <path>', 'Path to configuration file')
|
|
3897
|
-
.option('-n, --name <name>', 'Service name', '
|
|
3942
|
+
.option('-n, --name <name>', 'Service name', 'jeeves-meta')
|
|
3898
3943
|
.action((options) => {
|
|
3899
3944
|
const { name } = options;
|
|
3900
3945
|
const configFlag = options.config ? ` -c "${options.config}"` : '';
|
|
@@ -3959,7 +4004,7 @@ service.addCommand(new Command('install')
|
|
|
3959
4004
|
// start command (prints OS-specific start instructions)
|
|
3960
4005
|
service.addCommand(new Command('start')
|
|
3961
4006
|
.description('Print start instructions for the installed service')
|
|
3962
|
-
.option('-n, --name <name>', 'Service name', '
|
|
4007
|
+
.option('-n, --name <name>', 'Service name', 'jeeves-meta')
|
|
3963
4008
|
.action((options) => {
|
|
3964
4009
|
const { name } = options;
|
|
3965
4010
|
if (process.platform === 'win32') {
|
|
@@ -3978,7 +4023,7 @@ service.addCommand(new Command('start')
|
|
|
3978
4023
|
// stop command
|
|
3979
4024
|
service.addCommand(new Command('stop')
|
|
3980
4025
|
.description('Stop the running service')
|
|
3981
|
-
.option('-n, --name <name>', 'Service name', '
|
|
4026
|
+
.option('-n, --name <name>', 'Service name', 'jeeves-meta')
|
|
3982
4027
|
.action((options) => {
|
|
3983
4028
|
const { name } = options;
|
|
3984
4029
|
if (process.platform === 'win32') {
|
|
@@ -4010,7 +4055,7 @@ service.addCommand(new Command('status')
|
|
|
4010
4055
|
}));
|
|
4011
4056
|
service.addCommand(new Command('remove')
|
|
4012
4057
|
.description('Print remove instructions for a system service')
|
|
4013
|
-
.option('-n, --name <name>', 'Service name', '
|
|
4058
|
+
.option('-n, --name <name>', 'Service name', 'jeeves-meta')
|
|
4014
4059
|
.action((options) => {
|
|
4015
4060
|
const { name } = options;
|
|
4016
4061
|
if (process.platform === 'win32') {
|