@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
package/dist/index.js
CHANGED
|
@@ -1,7 +1,11 @@
|
|
|
1
|
-
import { readdirSync, unlinkSync, readFileSync, mkdirSync, writeFileSync, existsSync, statSync, copyFileSync, watchFile } from 'node:fs';
|
|
2
|
-
import { join, dirname, relative } from 'node:path';
|
|
1
|
+
import fs, { readdirSync, unlinkSync, readFileSync, mkdirSync, writeFileSync, existsSync, statSync, copyFileSync, watchFile } from 'node:fs';
|
|
2
|
+
import path, { join, dirname, resolve, relative } from 'node:path';
|
|
3
|
+
import { fileURLToPath } from 'node:url';
|
|
4
|
+
import 'node:fs/promises';
|
|
5
|
+
import process$1 from 'node:process';
|
|
3
6
|
import { z } from 'zod';
|
|
4
7
|
import { createHash, randomUUID } from 'node:crypto';
|
|
8
|
+
import { tmpdir } from 'node:os';
|
|
5
9
|
import pino from 'pino';
|
|
6
10
|
import { Cron } from 'croner';
|
|
7
11
|
import Fastify from 'fastify';
|
|
@@ -102,6 +106,122 @@ function createSnapshot(metaPath, meta) {
|
|
|
102
106
|
return archiveFile;
|
|
103
107
|
}
|
|
104
108
|
|
|
109
|
+
const toPath = urlOrPath => urlOrPath instanceof URL ? fileURLToPath(urlOrPath) : urlOrPath;
|
|
110
|
+
|
|
111
|
+
function findUpSync(name, {
|
|
112
|
+
cwd = process$1.cwd(),
|
|
113
|
+
type = 'file',
|
|
114
|
+
stopAt,
|
|
115
|
+
} = {}) {
|
|
116
|
+
let directory = path.resolve(toPath(cwd) ?? '');
|
|
117
|
+
const {root} = path.parse(directory);
|
|
118
|
+
stopAt = path.resolve(directory, toPath(stopAt) ?? root);
|
|
119
|
+
const isAbsoluteName = path.isAbsolute(name);
|
|
120
|
+
|
|
121
|
+
while (directory) {
|
|
122
|
+
const filePath = isAbsoluteName ? name : path.join(directory, name);
|
|
123
|
+
|
|
124
|
+
try {
|
|
125
|
+
const stats = fs.statSync(filePath, {throwIfNoEntry: false});
|
|
126
|
+
if ((type === 'file' && stats?.isFile()) || (type === 'directory' && stats?.isDirectory())) {
|
|
127
|
+
return filePath;
|
|
128
|
+
}
|
|
129
|
+
} catch {}
|
|
130
|
+
|
|
131
|
+
if (directory === stopAt || directory === root) {
|
|
132
|
+
break;
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
directory = path.dirname(directory);
|
|
136
|
+
}
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
const isTypeOnlyPackageJsonData = packageData => {
|
|
140
|
+
if (!packageData || typeof packageData !== 'object' || Array.isArray(packageData)) {
|
|
141
|
+
return false;
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
const keys = Object.keys(packageData);
|
|
145
|
+
return keys.length === 1 && keys[0] === 'type' && typeof packageData.type === 'string';
|
|
146
|
+
};
|
|
147
|
+
|
|
148
|
+
const isTypeOnlyPackageJsonSync = filePath => {
|
|
149
|
+
let fileContents;
|
|
150
|
+
|
|
151
|
+
try {
|
|
152
|
+
fileContents = fs.readFileSync(filePath, 'utf8');
|
|
153
|
+
} catch {
|
|
154
|
+
return false;
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
try {
|
|
158
|
+
return isTypeOnlyPackageJsonData(JSON.parse(fileContents));
|
|
159
|
+
} catch {
|
|
160
|
+
return false;
|
|
161
|
+
}
|
|
162
|
+
};
|
|
163
|
+
|
|
164
|
+
const getNextSearchDirectory = filePath => {
|
|
165
|
+
const packageDirectoryPath = path.dirname(filePath);
|
|
166
|
+
const parentDirectoryPath = path.dirname(packageDirectoryPath);
|
|
167
|
+
return parentDirectoryPath === packageDirectoryPath ? undefined : parentDirectoryPath;
|
|
168
|
+
};
|
|
169
|
+
|
|
170
|
+
const findPackageDirectorySync = (directory, ignoreTypeOnlyPackageJson) => {
|
|
171
|
+
const filePath = findUpSync('package.json', {cwd: directory});
|
|
172
|
+
if (!filePath) {
|
|
173
|
+
return undefined;
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
const packageDirectoryPath = path.dirname(filePath);
|
|
177
|
+
if (!ignoreTypeOnlyPackageJson) {
|
|
178
|
+
return packageDirectoryPath;
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
if (!isTypeOnlyPackageJsonSync(filePath)) {
|
|
182
|
+
return packageDirectoryPath;
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
const nextDirectory = getNextSearchDirectory(filePath);
|
|
186
|
+
if (!nextDirectory) {
|
|
187
|
+
return undefined;
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
return findPackageDirectorySync(nextDirectory, ignoreTypeOnlyPackageJson);
|
|
191
|
+
};
|
|
192
|
+
|
|
193
|
+
function packageDirectorySync({cwd, ignoreTypeOnlyPackageJson} = {}) {
|
|
194
|
+
return findPackageDirectorySync(cwd ?? process$1.cwd(), ignoreTypeOnlyPackageJson);
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
/**
|
|
198
|
+
* Shared constants for the jeeves-meta service package.
|
|
199
|
+
*
|
|
200
|
+
* @module constants
|
|
201
|
+
*/
|
|
202
|
+
/** Default HTTP port for the jeeves-meta service. */
|
|
203
|
+
const DEFAULT_PORT = 1938;
|
|
204
|
+
/** Default port as a string (for Commander CLI defaults). */
|
|
205
|
+
const DEFAULT_PORT_STR = String(DEFAULT_PORT);
|
|
206
|
+
/** Service name identifier. */
|
|
207
|
+
const SERVICE_NAME = 'jeeves-meta';
|
|
208
|
+
/** Service version, read from package.json at startup. */
|
|
209
|
+
const SERVICE_VERSION = (() => {
|
|
210
|
+
try {
|
|
211
|
+
const dir = dirname(fileURLToPath(import.meta.url));
|
|
212
|
+
const root = packageDirectorySync({ cwd: dir });
|
|
213
|
+
if (root) {
|
|
214
|
+
const pkg = JSON.parse(readFileSync(resolve(root, 'package.json'), 'utf8'));
|
|
215
|
+
if (pkg.version)
|
|
216
|
+
return pkg.version;
|
|
217
|
+
}
|
|
218
|
+
return 'unknown';
|
|
219
|
+
}
|
|
220
|
+
catch {
|
|
221
|
+
return 'unknown';
|
|
222
|
+
}
|
|
223
|
+
})();
|
|
224
|
+
|
|
105
225
|
/**
|
|
106
226
|
* Zod schema for jeeves-meta service configuration.
|
|
107
227
|
*
|
|
@@ -161,6 +281,8 @@ const serviceConfigSchema = metaConfigSchema.extend({
|
|
|
161
281
|
schedule: z.string().default('*/30 * * * *'),
|
|
162
282
|
/** Optional channel identifier for reporting. */
|
|
163
283
|
reportChannel: z.string().optional(),
|
|
284
|
+
/** Interval in ms for periodic watcher health check. 0 = disabled. Default: 60000. */
|
|
285
|
+
watcherHealthIntervalMs: z.number().int().min(0).default(60_000),
|
|
164
286
|
/** Logging configuration. */
|
|
165
287
|
logging: loggingSchema.default(() => loggingSchema.parse({})),
|
|
166
288
|
});
|
|
@@ -274,114 +396,29 @@ function normalizePath(p) {
|
|
|
274
396
|
}
|
|
275
397
|
|
|
276
398
|
/**
|
|
277
|
-
*
|
|
399
|
+
* Discover .meta/ directories via watcher `/walk` endpoint.
|
|
278
400
|
*
|
|
279
|
-
*
|
|
280
|
-
|
|
281
|
-
/**
|
|
282
|
-
* Perform a paginated scan that follows cursor tokens until exhausted.
|
|
283
|
-
*
|
|
284
|
-
* @param watcher - WatcherClient instance.
|
|
285
|
-
* @param params - Base scan parameters (cursor is managed internally).
|
|
286
|
-
* @returns All matching files across all pages.
|
|
287
|
-
*/
|
|
288
|
-
async function paginatedScan(watcher, params, logger) {
|
|
289
|
-
const allFiles = [];
|
|
290
|
-
let cursor;
|
|
291
|
-
let pageCount = 0;
|
|
292
|
-
const start = Date.now();
|
|
293
|
-
do {
|
|
294
|
-
const pageStart = Date.now();
|
|
295
|
-
const result = await watcher.scan({ ...params, cursor });
|
|
296
|
-
allFiles.push(...result.files);
|
|
297
|
-
pageCount++;
|
|
298
|
-
logger?.debug({
|
|
299
|
-
page: pageCount,
|
|
300
|
-
files: result.files.length,
|
|
301
|
-
pageMs: Date.now() - pageStart,
|
|
302
|
-
hasNext: Boolean(result.next),
|
|
303
|
-
}, 'paginatedScan page');
|
|
304
|
-
cursor = result.next;
|
|
305
|
-
} while (cursor);
|
|
306
|
-
logger?.debug({
|
|
307
|
-
pages: pageCount,
|
|
308
|
-
totalFiles: allFiles.length,
|
|
309
|
-
totalMs: Date.now() - start,
|
|
310
|
-
}, 'paginatedScan complete');
|
|
311
|
-
return allFiles;
|
|
312
|
-
}
|
|
313
|
-
|
|
314
|
-
/**
|
|
315
|
-
* Discover .meta/ directories via watcher scan.
|
|
316
|
-
*
|
|
317
|
-
* Replaces filesystem-based globMetas() with a watcher query
|
|
318
|
-
* that returns indexed .meta/meta.json points, filtered by domain.
|
|
401
|
+
* Uses filesystem enumeration through the watcher (not Qdrant) to find
|
|
402
|
+
* all `.meta/meta.json` files and returns deduplicated meta directory paths.
|
|
319
403
|
*
|
|
320
404
|
* @module discovery/discoverMetas
|
|
321
405
|
*/
|
|
322
406
|
/**
|
|
323
|
-
*
|
|
407
|
+
* Discover all .meta/ directories via watcher walk.
|
|
324
408
|
*
|
|
325
|
-
*
|
|
326
|
-
*
|
|
327
|
-
* Objects and other non-filterable types are skipped with a warning.
|
|
328
|
-
*/
|
|
329
|
-
function buildMatchClause(key, value) {
|
|
330
|
-
if (Array.isArray(value)) {
|
|
331
|
-
if (value.length === 0)
|
|
332
|
-
return null;
|
|
333
|
-
return { key, match: { value: value[0] } };
|
|
334
|
-
}
|
|
335
|
-
if (typeof value === 'string' ||
|
|
336
|
-
typeof value === 'number' ||
|
|
337
|
-
typeof value === 'boolean') {
|
|
338
|
-
return { key, match: { value } };
|
|
339
|
-
}
|
|
340
|
-
// Non-filterable value (object, null, etc.) — valid for tagging but
|
|
341
|
-
// cannot be expressed as a Qdrant match clause.
|
|
342
|
-
return null;
|
|
343
|
-
}
|
|
344
|
-
/**
|
|
345
|
-
* Build a Qdrant filter from config metaProperty.
|
|
346
|
-
*
|
|
347
|
-
* Iterates all key-value pairs in `metaProperty` (a generic record)
|
|
348
|
-
* to construct `must` clauses. Always appends `file_path: meta.json`
|
|
349
|
-
* for deduplication.
|
|
409
|
+
* Uses the watcher's `/walk` endpoint to find all `.meta/meta.json` files
|
|
410
|
+
* and returns deduplicated meta directory paths.
|
|
350
411
|
*
|
|
351
|
-
* @param
|
|
352
|
-
* @returns Qdrant filter object for scanning live metas.
|
|
353
|
-
*/
|
|
354
|
-
function buildMetaFilter(config) {
|
|
355
|
-
const must = [];
|
|
356
|
-
for (const [key, value] of Object.entries(config.metaProperty)) {
|
|
357
|
-
const clause = buildMatchClause(key, value);
|
|
358
|
-
if (clause)
|
|
359
|
-
must.push(clause);
|
|
360
|
-
}
|
|
361
|
-
must.push({
|
|
362
|
-
key: 'file_path',
|
|
363
|
-
match: { text: '.meta/meta.json' },
|
|
364
|
-
});
|
|
365
|
-
return { must };
|
|
366
|
-
}
|
|
367
|
-
/**
|
|
368
|
-
* Discover all .meta/ directories via watcher scan.
|
|
369
|
-
*
|
|
370
|
-
* Queries the watcher for indexed .meta/meta.json points using the
|
|
371
|
-
* configured domain filter. Returns deduplicated meta directory paths.
|
|
372
|
-
*
|
|
373
|
-
* @param config - Meta config (for domain filter).
|
|
374
|
-
* @param watcher - WatcherClient for scan queries.
|
|
412
|
+
* @param watcher - WatcherClient for walk queries.
|
|
375
413
|
* @returns Array of normalized .meta/ directory paths.
|
|
376
414
|
*/
|
|
377
|
-
async function discoverMetas(
|
|
378
|
-
const
|
|
379
|
-
const scanFiles = await paginatedScan(watcher, { filter, fields: ['file_path'] }, logger);
|
|
415
|
+
async function discoverMetas(watcher) {
|
|
416
|
+
const allPaths = await watcher.walk(['**/.meta/meta.json']);
|
|
380
417
|
// Deduplicate by .meta/ directory path (handles multi-chunk files)
|
|
381
418
|
const seen = new Set();
|
|
382
419
|
const metaPaths = [];
|
|
383
|
-
for (const
|
|
384
|
-
const fp = normalizePath(
|
|
420
|
+
for (const filePath of allPaths) {
|
|
421
|
+
const fp = normalizePath(filePath);
|
|
385
422
|
// Derive .meta/ directory from file_path (strip /meta.json)
|
|
386
423
|
const metaPath = fp.replace(/\/meta\.json$/, '');
|
|
387
424
|
if (seen.has(metaPath))
|
|
@@ -522,6 +559,25 @@ function cleanupStaleLocks(metaPaths, logger) {
|
|
|
522
559
|
}
|
|
523
560
|
}
|
|
524
561
|
|
|
562
|
+
/**
|
|
563
|
+
* Read and parse a meta.json file from a `.meta/` directory.
|
|
564
|
+
*
|
|
565
|
+
* Shared utility to eliminate repeated `JSON.parse(readFileSync(...))` across
|
|
566
|
+
* discovery, orchestration, and route handlers.
|
|
567
|
+
*
|
|
568
|
+
* @module readMetaJson
|
|
569
|
+
*/
|
|
570
|
+
/**
|
|
571
|
+
* Read and parse a meta.json file from a `.meta/` directory path.
|
|
572
|
+
*
|
|
573
|
+
* @param metaPath - Path to the `.meta/` directory.
|
|
574
|
+
* @returns Parsed meta.json content.
|
|
575
|
+
* @throws If the file doesn't exist or contains invalid JSON.
|
|
576
|
+
*/
|
|
577
|
+
function readMetaJson(metaPath) {
|
|
578
|
+
return JSON.parse(readFileSync(join(metaPath, 'meta.json'), 'utf8'));
|
|
579
|
+
}
|
|
580
|
+
|
|
525
581
|
/**
|
|
526
582
|
* Build the ownership tree from discovered .meta/ paths.
|
|
527
583
|
*
|
|
@@ -617,9 +673,9 @@ const MAX_STALENESS_SECONDS$1 = 365 * 86_400;
|
|
|
617
673
|
* @param watcher - Watcher HTTP client for discovery.
|
|
618
674
|
* @returns Enriched meta list with summary statistics and ownership tree.
|
|
619
675
|
*/
|
|
620
|
-
async function listMetas(config, watcher
|
|
621
|
-
// Step 1: Discover deduplicated meta paths via watcher
|
|
622
|
-
const metaPaths = await discoverMetas(
|
|
676
|
+
async function listMetas(config, watcher) {
|
|
677
|
+
// Step 1: Discover deduplicated meta paths via watcher walk
|
|
678
|
+
const metaPaths = await discoverMetas(watcher);
|
|
623
679
|
// Step 2: Build ownership tree
|
|
624
680
|
const tree = buildOwnershipTree(metaPaths);
|
|
625
681
|
// Step 3: Read and enrich each meta from disk
|
|
@@ -638,7 +694,7 @@ async function listMetas(config, watcher, logger) {
|
|
|
638
694
|
for (const node of tree.nodes.values()) {
|
|
639
695
|
let meta;
|
|
640
696
|
try {
|
|
641
|
-
meta =
|
|
697
|
+
meta = readMetaJson(node.metaPath);
|
|
642
698
|
}
|
|
643
699
|
catch {
|
|
644
700
|
// Skip unreadable metas
|
|
@@ -726,67 +782,50 @@ async function listMetas(config, watcher, logger) {
|
|
|
726
782
|
}
|
|
727
783
|
|
|
728
784
|
/**
|
|
729
|
-
*
|
|
785
|
+
* Filter file paths by modification time.
|
|
730
786
|
*
|
|
731
|
-
*
|
|
732
|
-
*
|
|
787
|
+
* Shared utility for staleness detection and delta file enumeration.
|
|
788
|
+
* Uses `fs.statSync` for fast local mtime checks on known paths.
|
|
733
789
|
*
|
|
734
|
-
* @module
|
|
790
|
+
* @module mtimeFilter
|
|
735
791
|
*/
|
|
736
|
-
/** Default directory names to always skip. */
|
|
737
|
-
const DEFAULT_SKIP = new Set([
|
|
738
|
-
'node_modules',
|
|
739
|
-
'.git',
|
|
740
|
-
'.rollup.cache',
|
|
741
|
-
'dist',
|
|
742
|
-
'Thumbs.db',
|
|
743
|
-
]);
|
|
744
792
|
/**
|
|
745
|
-
*
|
|
793
|
+
* Check if any file in the list was modified after the given timestamp.
|
|
746
794
|
*
|
|
747
|
-
*
|
|
748
|
-
*
|
|
749
|
-
* @
|
|
795
|
+
* Short-circuits on first match for efficiency (staleness checks).
|
|
796
|
+
*
|
|
797
|
+
* @param files - Array of file paths to check.
|
|
798
|
+
* @param afterMs - Timestamp in milliseconds. Files with `mtimeMs > afterMs` match.
|
|
799
|
+
* @returns True if any file was modified after the timestamp.
|
|
750
800
|
*/
|
|
751
|
-
function
|
|
752
|
-
|
|
753
|
-
const modifiedAfter = options?.modifiedAfter;
|
|
754
|
-
const maxDepth = options?.maxDepth ?? 50;
|
|
755
|
-
const results = [];
|
|
756
|
-
function walk(dir, depth) {
|
|
757
|
-
if (depth > maxDepth)
|
|
758
|
-
return;
|
|
759
|
-
let entries;
|
|
801
|
+
function hasModifiedAfter(files, afterMs) {
|
|
802
|
+
for (const filePath of files) {
|
|
760
803
|
try {
|
|
761
|
-
|
|
804
|
+
if (statSync(filePath).mtimeMs > afterMs)
|
|
805
|
+
return true;
|
|
762
806
|
}
|
|
763
807
|
catch {
|
|
764
|
-
|
|
765
|
-
}
|
|
766
|
-
for (const entry of entries) {
|
|
767
|
-
if (exclude.has(entry.name))
|
|
768
|
-
continue;
|
|
769
|
-
const fullPath = join(dir, entry.name);
|
|
770
|
-
if (entry.isDirectory()) {
|
|
771
|
-
walk(fullPath, depth + 1);
|
|
772
|
-
}
|
|
773
|
-
else if (entry.isFile()) {
|
|
774
|
-
if (modifiedAfter !== undefined) {
|
|
775
|
-
try {
|
|
776
|
-
const stat = statSync(fullPath);
|
|
777
|
-
if (Math.floor(stat.mtimeMs / 1000) <= modifiedAfter)
|
|
778
|
-
continue;
|
|
779
|
-
}
|
|
780
|
-
catch {
|
|
781
|
-
continue;
|
|
782
|
-
}
|
|
783
|
-
}
|
|
784
|
-
results.push(normalizePath(fullPath));
|
|
785
|
-
}
|
|
808
|
+
// Unreadable file — skip
|
|
786
809
|
}
|
|
787
810
|
}
|
|
788
|
-
|
|
789
|
-
|
|
811
|
+
return false;
|
|
812
|
+
}
|
|
813
|
+
/**
|
|
814
|
+
* Filter files to only those modified after the given timestamp.
|
|
815
|
+
*
|
|
816
|
+
* @param files - Array of file paths to filter.
|
|
817
|
+
* @param afterMs - Timestamp in milliseconds. Files with `mtimeMs > afterMs` are included.
|
|
818
|
+
* @returns Filtered array of file paths.
|
|
819
|
+
*/
|
|
820
|
+
function filterModifiedAfter(files, afterMs) {
|
|
821
|
+
return files.filter((filePath) => {
|
|
822
|
+
try {
|
|
823
|
+
return statSync(filePath).mtimeMs > afterMs;
|
|
824
|
+
}
|
|
825
|
+
catch {
|
|
826
|
+
return false;
|
|
827
|
+
}
|
|
828
|
+
});
|
|
790
829
|
}
|
|
791
830
|
|
|
792
831
|
/**
|
|
@@ -796,7 +835,7 @@ function walkFiles(root, options) {
|
|
|
796
835
|
* - Its own .meta/ subtree (outputs, not inputs)
|
|
797
836
|
* - Child meta ownerPath subtrees (except their .meta/meta.json for rollups)
|
|
798
837
|
*
|
|
799
|
-
*
|
|
838
|
+
* All filesystem enumeration delegated to the watcher's `/walk` endpoint.
|
|
800
839
|
*
|
|
801
840
|
* @module discovery/scope
|
|
802
841
|
*/
|
|
@@ -813,7 +852,7 @@ function getScopePrefix(node) {
|
|
|
813
852
|
* - The node's own .meta/ subtree (synthesis outputs are not scope inputs)
|
|
814
853
|
* - Child meta ownerPath subtrees (except child .meta/meta.json for rollups)
|
|
815
854
|
*
|
|
816
|
-
*
|
|
855
|
+
* Watcher walk returns normalized forward-slash paths.
|
|
817
856
|
*/
|
|
818
857
|
function filterInScope(node, files) {
|
|
819
858
|
const prefix = node.ownerPath + '/';
|
|
@@ -838,10 +877,10 @@ function filterInScope(node, files) {
|
|
|
838
877
|
});
|
|
839
878
|
}
|
|
840
879
|
/**
|
|
841
|
-
* Get all files in scope for a meta node via
|
|
880
|
+
* Get all files in scope for a meta node via watcher walk.
|
|
842
881
|
*/
|
|
843
|
-
function getScopeFiles(node) {
|
|
844
|
-
const allFiles =
|
|
882
|
+
async function getScopeFiles(node, watcher) {
|
|
883
|
+
const allFiles = await watcher.walk([`${node.ownerPath}/**`]);
|
|
845
884
|
return {
|
|
846
885
|
scopeFiles: filterInScope(node, allFiles),
|
|
847
886
|
allFiles,
|
|
@@ -851,13 +890,12 @@ function getScopeFiles(node) {
|
|
|
851
890
|
* Get files modified since a given timestamp within a meta node's scope.
|
|
852
891
|
*
|
|
853
892
|
* If no generatedAt is provided (first run), returns all scope files.
|
|
893
|
+
* Reuses scope files from getScopeFiles() and filters locally by mtime.
|
|
854
894
|
*/
|
|
855
|
-
function getDeltaFiles(
|
|
895
|
+
function getDeltaFiles(generatedAt, scopeFiles) {
|
|
856
896
|
if (!generatedAt)
|
|
857
897
|
return scopeFiles;
|
|
858
|
-
|
|
859
|
-
const deltaFiles = walkFiles(node.ownerPath, { modifiedAfter });
|
|
860
|
-
return filterInScope(node, deltaFiles);
|
|
898
|
+
return filterModifiedAfter(scopeFiles, new Date(generatedAt).getTime());
|
|
861
899
|
}
|
|
862
900
|
|
|
863
901
|
/**
|
|
@@ -954,7 +992,7 @@ class GatewayExecutor {
|
|
|
954
992
|
this.gatewayUrl = (options.gatewayUrl ?? 'http://127.0.0.1:18789').replace(/\/+$/, '');
|
|
955
993
|
this.apiKey = options.apiKey;
|
|
956
994
|
this.pollIntervalMs = options.pollIntervalMs ?? DEFAULT_POLL_INTERVAL_MS;
|
|
957
|
-
this.workspaceDir = options.workspaceDir ?? '
|
|
995
|
+
this.workspaceDir = options.workspaceDir ?? join(tmpdir(), 'jeeves-meta');
|
|
958
996
|
}
|
|
959
997
|
/** Invoke a gateway tool via the /tools/invoke HTTP endpoint. */
|
|
960
998
|
async invoke(tool, args) {
|
|
@@ -1158,10 +1196,10 @@ function condenseScopeFiles(files, maxIndividual = 30) {
|
|
|
1158
1196
|
* @param watcher - WatcherClient for scope enumeration.
|
|
1159
1197
|
* @returns The computed context package.
|
|
1160
1198
|
*/
|
|
1161
|
-
function buildContextPackage(node, meta) {
|
|
1162
|
-
// Scope and delta files via watcher
|
|
1163
|
-
const { scopeFiles } = getScopeFiles(node);
|
|
1164
|
-
const deltaFiles = getDeltaFiles(
|
|
1199
|
+
async function buildContextPackage(node, meta, watcher) {
|
|
1200
|
+
// Scope and delta files via watcher walk
|
|
1201
|
+
const { scopeFiles } = await getScopeFiles(node, watcher);
|
|
1202
|
+
const deltaFiles = getDeltaFiles(meta._generatedAt, scopeFiles);
|
|
1165
1203
|
// Child meta outputs
|
|
1166
1204
|
const childMetas = {};
|
|
1167
1205
|
for (const child of node.children) {
|
|
@@ -1485,6 +1523,67 @@ function mergeAndWrite(options) {
|
|
|
1485
1523
|
return result.data;
|
|
1486
1524
|
}
|
|
1487
1525
|
|
|
1526
|
+
/**
|
|
1527
|
+
* Build a minimal MetaNode from a known meta path using watcher walk.
|
|
1528
|
+
*
|
|
1529
|
+
* Used for targeted synthesis (when a specific path is requested) to avoid
|
|
1530
|
+
* the full discovery + ownership tree build. Discovers only immediate child
|
|
1531
|
+
* `.meta/` directories.
|
|
1532
|
+
*
|
|
1533
|
+
* @module discovery/buildMinimalNode
|
|
1534
|
+
*/
|
|
1535
|
+
/**
|
|
1536
|
+
* Build a minimal MetaNode for a known meta path.
|
|
1537
|
+
*
|
|
1538
|
+
* Walks the owner directory for child `.meta/meta.json` files and constructs
|
|
1539
|
+
* a shallow ownership tree (self + direct children only).
|
|
1540
|
+
*
|
|
1541
|
+
* @param metaPath - Absolute path to the `.meta/` directory.
|
|
1542
|
+
* @param watcher - WatcherClient for filesystem enumeration.
|
|
1543
|
+
* @returns MetaNode with direct children wired.
|
|
1544
|
+
*/
|
|
1545
|
+
async function buildMinimalNode(metaPath, watcher) {
|
|
1546
|
+
const normalized = normalizePath(metaPath);
|
|
1547
|
+
const ownerPath = normalizePath(dirname(metaPath));
|
|
1548
|
+
// Find child metas using watcher walk.
|
|
1549
|
+
// We include only *direct* children (nearest descendants in the ownership tree)
|
|
1550
|
+
// to match the ownership semantics used elsewhere.
|
|
1551
|
+
const rawMetaJsonPaths = await watcher.walk([
|
|
1552
|
+
`${ownerPath}/**/.meta/meta.json`,
|
|
1553
|
+
]);
|
|
1554
|
+
const candidateMetaPaths = [
|
|
1555
|
+
...new Set(rawMetaJsonPaths.map((p) => normalizePath(dirname(p)))),
|
|
1556
|
+
].filter((p) => p !== normalized);
|
|
1557
|
+
const candidates = candidateMetaPaths
|
|
1558
|
+
.map((mp) => ({ metaPath: mp, ownerPath: normalizePath(dirname(mp)) }))
|
|
1559
|
+
.sort((a, b) => a.ownerPath.length - b.ownerPath.length);
|
|
1560
|
+
const directChildren = [];
|
|
1561
|
+
for (const c of candidates) {
|
|
1562
|
+
const nestedUnderExisting = directChildren.some((d) => c.ownerPath === d.ownerPath ||
|
|
1563
|
+
c.ownerPath.startsWith(d.ownerPath + '/'));
|
|
1564
|
+
if (!nestedUnderExisting)
|
|
1565
|
+
directChildren.push(c);
|
|
1566
|
+
}
|
|
1567
|
+
const children = directChildren.map((c) => ({
|
|
1568
|
+
metaPath: c.metaPath,
|
|
1569
|
+
ownerPath: c.ownerPath,
|
|
1570
|
+
treeDepth: 1,
|
|
1571
|
+
children: [],
|
|
1572
|
+
parent: null,
|
|
1573
|
+
}));
|
|
1574
|
+
const node = {
|
|
1575
|
+
metaPath: normalized,
|
|
1576
|
+
ownerPath,
|
|
1577
|
+
treeDepth: 0,
|
|
1578
|
+
children,
|
|
1579
|
+
parent: null,
|
|
1580
|
+
};
|
|
1581
|
+
for (const child of children) {
|
|
1582
|
+
child.parent = node;
|
|
1583
|
+
}
|
|
1584
|
+
return node;
|
|
1585
|
+
}
|
|
1586
|
+
|
|
1488
1587
|
/**
|
|
1489
1588
|
* Weighted staleness formula for candidate selection.
|
|
1490
1589
|
*
|
|
@@ -1564,29 +1663,30 @@ function discoverStalestPath(candidates, depthWeight) {
|
|
|
1564
1663
|
}
|
|
1565
1664
|
|
|
1566
1665
|
/**
|
|
1567
|
-
* Staleness detection via watcher
|
|
1666
|
+
* Staleness detection via watcher walk.
|
|
1568
1667
|
*
|
|
1569
|
-
* A meta is stale when any file in its scope was modified after
|
|
1668
|
+
* A meta is stale when any watched file in its scope was modified after
|
|
1669
|
+
* `_generatedAt`.
|
|
1570
1670
|
*
|
|
1571
1671
|
* @module scheduling/staleness
|
|
1572
1672
|
*/
|
|
1573
1673
|
/**
|
|
1574
|
-
* Check if a meta is stale
|
|
1674
|
+
* Check if a meta is stale.
|
|
1675
|
+
*
|
|
1676
|
+
* Uses watcher `/walk` to enumerate watched files under the scope prefix,
|
|
1677
|
+
* then applies a local mtime check (fast) to detect any modifications since
|
|
1678
|
+
* `_generatedAt`. Short-circuits on first match.
|
|
1575
1679
|
*
|
|
1576
1680
|
* @param scopePrefix - Path prefix for this meta's scope.
|
|
1577
1681
|
* @param meta - Current meta.json content.
|
|
1578
1682
|
* @param watcher - WatcherClient instance.
|
|
1579
|
-
* @returns True if any file in scope was modified after _generatedAt
|
|
1683
|
+
* @returns True if any file in scope was modified after `_generatedAt`.
|
|
1580
1684
|
*/
|
|
1581
|
-
function isStale(scopePrefix, meta) {
|
|
1685
|
+
async function isStale(scopePrefix, meta, watcher) {
|
|
1582
1686
|
if (!meta._generatedAt)
|
|
1583
1687
|
return true; // Never synthesized = stale
|
|
1584
|
-
const
|
|
1585
|
-
|
|
1586
|
-
modifiedAfter: generatedAtUnix,
|
|
1587
|
-
maxDepth: 1,
|
|
1588
|
-
});
|
|
1589
|
-
return modified.length > 0;
|
|
1688
|
+
const files = await watcher.walk([`${scopePrefix}/**`]);
|
|
1689
|
+
return hasModifiedAfter(files, new Date(meta._generatedAt).getTime());
|
|
1590
1690
|
}
|
|
1591
1691
|
/** Maximum staleness for never-synthesized metas (1 year in seconds). */
|
|
1592
1692
|
const MAX_STALENESS_SECONDS = 365 * 86_400;
|
|
@@ -1796,72 +1896,13 @@ function finalizeCycle(opts) {
|
|
|
1796
1896
|
* @param watcher - Watcher HTTP client.
|
|
1797
1897
|
* @returns Result indicating whether synthesis occurred.
|
|
1798
1898
|
*/
|
|
1799
|
-
/**
|
|
1800
|
-
* Build a minimal MetaNode from the filesystem for a known meta path.
|
|
1801
|
-
* Discovers immediate child .meta/ dirs without a full watcher scan.
|
|
1802
|
-
*/
|
|
1803
|
-
function buildMinimalNode(metaPath) {
|
|
1804
|
-
const normalized = normalizePath(metaPath);
|
|
1805
|
-
const ownerPath = normalizePath(dirname(metaPath));
|
|
1806
|
-
// Find child .meta/ directories by scanning the owner directory
|
|
1807
|
-
const children = [];
|
|
1808
|
-
function findChildMetas(dir, depth) {
|
|
1809
|
-
if (depth > 10)
|
|
1810
|
-
return; // Safety limit
|
|
1811
|
-
try {
|
|
1812
|
-
const entries = readdirSync(dir, { withFileTypes: true });
|
|
1813
|
-
for (const entry of entries) {
|
|
1814
|
-
if (!entry.isDirectory())
|
|
1815
|
-
continue;
|
|
1816
|
-
const fullPath = normalizePath(join(dir, entry.name));
|
|
1817
|
-
if (entry.name === '.meta' && fullPath !== normalized) {
|
|
1818
|
-
// Found a child .meta — check it has meta.json
|
|
1819
|
-
if (existsSync(join(fullPath, 'meta.json'))) {
|
|
1820
|
-
children.push({
|
|
1821
|
-
metaPath: fullPath,
|
|
1822
|
-
ownerPath: normalizePath(dirname(fullPath)),
|
|
1823
|
-
treeDepth: 1, // Relative to target
|
|
1824
|
-
children: [],
|
|
1825
|
-
parent: null, // Set below
|
|
1826
|
-
});
|
|
1827
|
-
}
|
|
1828
|
-
// Don't recurse into .meta dirs
|
|
1829
|
-
return;
|
|
1830
|
-
}
|
|
1831
|
-
if (entry.name === 'node_modules' ||
|
|
1832
|
-
entry.name === '.git' ||
|
|
1833
|
-
entry.name === 'archive')
|
|
1834
|
-
continue;
|
|
1835
|
-
findChildMetas(fullPath, depth + 1);
|
|
1836
|
-
}
|
|
1837
|
-
}
|
|
1838
|
-
catch {
|
|
1839
|
-
// Permission errors, etc — skip
|
|
1840
|
-
}
|
|
1841
|
-
}
|
|
1842
|
-
findChildMetas(ownerPath, 0);
|
|
1843
|
-
const node = {
|
|
1844
|
-
metaPath: normalized,
|
|
1845
|
-
ownerPath,
|
|
1846
|
-
treeDepth: 0,
|
|
1847
|
-
children,
|
|
1848
|
-
parent: null,
|
|
1849
|
-
};
|
|
1850
|
-
// Wire parent references
|
|
1851
|
-
for (const child of children) {
|
|
1852
|
-
child.parent = node;
|
|
1853
|
-
}
|
|
1854
|
-
return node;
|
|
1855
|
-
}
|
|
1856
1899
|
/** Run the architect/builder/critic pipeline on a single node. */
|
|
1857
1900
|
async function synthesizeNode(node, currentMeta, config, executor, watcher, onProgress) {
|
|
1858
|
-
const architectPrompt = currentMeta._architect ?? config.defaultArchitect;
|
|
1859
|
-
const criticPrompt = currentMeta._critic ?? config.defaultCritic;
|
|
1860
1901
|
// Step 5-6: Steer change detection
|
|
1861
1902
|
const latestArchive = readLatestArchive(node.metaPath);
|
|
1862
1903
|
const steerChanged = hasSteerChanged(currentMeta._steer, latestArchive?._steer, Boolean(latestArchive));
|
|
1863
1904
|
// Step 7: Compute context (includes scope files and delta files)
|
|
1864
|
-
const ctx = buildContextPackage(node, currentMeta);
|
|
1905
|
+
const ctx = await buildContextPackage(node, currentMeta, watcher);
|
|
1865
1906
|
// Step 5 (deferred): Structure hash from context scope files
|
|
1866
1907
|
const newStructureHash = computeStructureHash(ctx.scopeFiles);
|
|
1867
1908
|
const structureChanged = newStructureHash !== currentMeta._structureHash;
|
|
@@ -1905,9 +1946,9 @@ async function synthesizeNode(node, currentMeta, config, executor, watcher, onPr
|
|
|
1905
1946
|
metaPath: node.metaPath,
|
|
1906
1947
|
current: currentMeta,
|
|
1907
1948
|
config,
|
|
1908
|
-
architect:
|
|
1949
|
+
architect: currentMeta._architect ?? '',
|
|
1909
1950
|
builder: '',
|
|
1910
|
-
critic:
|
|
1951
|
+
critic: currentMeta._critic ?? '',
|
|
1911
1952
|
builderOutput: null,
|
|
1912
1953
|
feedback: null,
|
|
1913
1954
|
structureHash: newStructureHash,
|
|
@@ -1991,9 +2032,9 @@ async function synthesizeNode(node, currentMeta, config, executor, watcher, onPr
|
|
|
1991
2032
|
metaPath: node.metaPath,
|
|
1992
2033
|
current: currentMeta,
|
|
1993
2034
|
config,
|
|
1994
|
-
architect:
|
|
2035
|
+
architect: currentMeta._architect ?? '',
|
|
1995
2036
|
builder: builderBrief,
|
|
1996
|
-
critic:
|
|
2037
|
+
critic: currentMeta._critic ?? '',
|
|
1997
2038
|
builderOutput,
|
|
1998
2039
|
feedback,
|
|
1999
2040
|
structureHash: newStructureHash,
|
|
@@ -2017,11 +2058,11 @@ async function orchestrateOnce(config, executor, watcher, targetPath, onProgress
|
|
|
2017
2058
|
const targetMetaJson = join(normalizedTarget, 'meta.json');
|
|
2018
2059
|
if (!existsSync(targetMetaJson))
|
|
2019
2060
|
return { synthesized: false };
|
|
2020
|
-
const node = buildMinimalNode(normalizedTarget);
|
|
2061
|
+
const node = await buildMinimalNode(normalizedTarget, watcher);
|
|
2021
2062
|
if (!acquireLock(node.metaPath))
|
|
2022
2063
|
return { synthesized: false };
|
|
2023
2064
|
try {
|
|
2024
|
-
const currentMeta =
|
|
2065
|
+
const currentMeta = readMetaJson(normalizedTarget);
|
|
2025
2066
|
return await synthesizeNode(node, currentMeta, config, executor, watcher, onProgress);
|
|
2026
2067
|
}
|
|
2027
2068
|
finally {
|
|
@@ -2029,18 +2070,17 @@ async function orchestrateOnce(config, executor, watcher, targetPath, onProgress
|
|
|
2029
2070
|
}
|
|
2030
2071
|
}
|
|
2031
2072
|
// Full discovery path (scheduler-driven, no specific target)
|
|
2032
|
-
// Step 1: Discover via watcher
|
|
2073
|
+
// Step 1: Discover via watcher walk
|
|
2033
2074
|
const discoveryStart = Date.now();
|
|
2034
|
-
const metaPaths = await discoverMetas(
|
|
2075
|
+
const metaPaths = await discoverMetas(watcher);
|
|
2035
2076
|
logger?.debug({ paths: metaPaths.length, durationMs: Date.now() - discoveryStart }, 'discovery complete');
|
|
2036
2077
|
if (metaPaths.length === 0)
|
|
2037
2078
|
return { synthesized: false };
|
|
2038
2079
|
// Read meta.json for each discovered meta
|
|
2039
2080
|
const metas = new Map();
|
|
2040
2081
|
for (const mp of metaPaths) {
|
|
2041
|
-
const metaFilePath = join(mp, 'meta.json');
|
|
2042
2082
|
try {
|
|
2043
|
-
metas.set(normalizePath(mp),
|
|
2083
|
+
metas.set(normalizePath(mp), readMetaJson(mp));
|
|
2044
2084
|
}
|
|
2045
2085
|
catch {
|
|
2046
2086
|
// Skip metas with unreadable meta.json
|
|
@@ -2073,13 +2113,12 @@ async function orchestrateOnce(config, executor, watcher, targetPath, onProgress
|
|
|
2073
2113
|
for (const candidate of ranked) {
|
|
2074
2114
|
if (!acquireLock(candidate.node.metaPath))
|
|
2075
2115
|
continue;
|
|
2076
|
-
const verifiedStale = isStale(getScopePrefix(candidate.node), candidate.meta);
|
|
2116
|
+
const verifiedStale = await isStale(getScopePrefix(candidate.node), candidate.meta, watcher);
|
|
2077
2117
|
if (!verifiedStale && candidate.meta._generatedAt) {
|
|
2078
2118
|
// Bump _generatedAt so it doesn't win next cycle
|
|
2079
|
-
const
|
|
2080
|
-
const freshMeta = JSON.parse(readFileSync(metaFilePath, 'utf8'));
|
|
2119
|
+
const freshMeta = readMetaJson(candidate.node.metaPath);
|
|
2081
2120
|
freshMeta._generatedAt = new Date().toISOString();
|
|
2082
|
-
writeFileSync(
|
|
2121
|
+
writeFileSync(join(candidate.node.metaPath, 'meta.json'), JSON.stringify(freshMeta, null, 2));
|
|
2083
2122
|
releaseLock(candidate.node.metaPath);
|
|
2084
2123
|
if (config.skipUnchanged)
|
|
2085
2124
|
continue;
|
|
@@ -2092,7 +2131,7 @@ async function orchestrateOnce(config, executor, watcher, targetPath, onProgress
|
|
|
2092
2131
|
return { synthesized: false };
|
|
2093
2132
|
const node = winner.node;
|
|
2094
2133
|
try {
|
|
2095
|
-
const currentMeta =
|
|
2134
|
+
const currentMeta = readMetaJson(node.metaPath);
|
|
2096
2135
|
return await synthesizeNode(node, currentMeta, config, executor, watcher, onProgress);
|
|
2097
2136
|
}
|
|
2098
2137
|
finally {
|
|
@@ -2336,7 +2375,7 @@ class Scheduler {
|
|
|
2336
2375
|
*/
|
|
2337
2376
|
async discoverStalest() {
|
|
2338
2377
|
try {
|
|
2339
|
-
const result = await listMetas(this.config, this.watcher
|
|
2378
|
+
const result = await listMetas(this.config, this.watcher);
|
|
2340
2379
|
const stale = result.entries
|
|
2341
2380
|
.filter((e) => e.stalenessSeconds > 0)
|
|
2342
2381
|
.map((e) => ({
|
|
@@ -2622,7 +2661,7 @@ function registerMetasRoutes(app, deps) {
|
|
|
2622
2661
|
app.get('/metas', async (request) => {
|
|
2623
2662
|
const query = metasQuerySchema.parse(request.query);
|
|
2624
2663
|
const { config, watcher } = deps;
|
|
2625
|
-
const result = await listMetas(config, watcher
|
|
2664
|
+
const result = await listMetas(config, watcher);
|
|
2626
2665
|
let entries = result.entries;
|
|
2627
2666
|
// Apply filters
|
|
2628
2667
|
if (query.pathPrefix) {
|
|
@@ -2685,7 +2724,7 @@ function registerMetasRoutes(app, deps) {
|
|
|
2685
2724
|
const query = metaDetailQuerySchema.parse(request.query);
|
|
2686
2725
|
const { config, watcher } = deps;
|
|
2687
2726
|
const targetPath = normalizePath(decodeURIComponent(request.params.path));
|
|
2688
|
-
const result = await listMetas(config, watcher
|
|
2727
|
+
const result = await listMetas(config, watcher);
|
|
2689
2728
|
const targetNode = findNode(result.tree, targetPath);
|
|
2690
2729
|
if (!targetNode) {
|
|
2691
2730
|
return reply.status(404).send({
|
|
@@ -2718,7 +2757,7 @@ function registerMetasRoutes(app, deps) {
|
|
|
2718
2757
|
return r;
|
|
2719
2758
|
};
|
|
2720
2759
|
// Compute scope
|
|
2721
|
-
const { scopeFiles, allFiles } = getScopeFiles(targetNode);
|
|
2760
|
+
const { scopeFiles, allFiles } = await getScopeFiles(targetNode, watcher);
|
|
2722
2761
|
// Compute staleness
|
|
2723
2762
|
const metaTyped = meta;
|
|
2724
2763
|
const staleSeconds = metaTyped._generatedAt
|
|
@@ -2765,7 +2804,7 @@ function registerPreviewRoute(app, deps) {
|
|
|
2765
2804
|
const query = request.query;
|
|
2766
2805
|
let result;
|
|
2767
2806
|
try {
|
|
2768
|
-
result = await listMetas(config, watcher
|
|
2807
|
+
result = await listMetas(config, watcher);
|
|
2769
2808
|
}
|
|
2770
2809
|
catch {
|
|
2771
2810
|
return reply.status(503).send({
|
|
@@ -2799,16 +2838,16 @@ function registerPreviewRoute(app, deps) {
|
|
|
2799
2838
|
}
|
|
2800
2839
|
targetNode = findNode(result.tree, stalestPath);
|
|
2801
2840
|
}
|
|
2802
|
-
const meta =
|
|
2841
|
+
const meta = readMetaJson(targetNode.metaPath);
|
|
2803
2842
|
// Scope files
|
|
2804
|
-
const { scopeFiles } = getScopeFiles(targetNode);
|
|
2843
|
+
const { scopeFiles } = await getScopeFiles(targetNode, watcher);
|
|
2805
2844
|
const structureHash = computeStructureHash(scopeFiles);
|
|
2806
2845
|
const structureChanged = structureHash !== meta._structureHash;
|
|
2807
2846
|
const latestArchive = readLatestArchive(targetNode.metaPath);
|
|
2808
2847
|
const steerChanged = hasSteerChanged(meta._steer, latestArchive?._steer, Boolean(latestArchive));
|
|
2809
2848
|
const architectTriggered = isArchitectTriggered(meta, structureChanged, steerChanged, config.architectEvery);
|
|
2810
2849
|
// Delta files
|
|
2811
|
-
const deltaFiles = getDeltaFiles(
|
|
2850
|
+
const deltaFiles = getDeltaFiles(meta._generatedAt, scopeFiles);
|
|
2812
2851
|
// EMA token estimates
|
|
2813
2852
|
const estimatedTokens = {
|
|
2814
2853
|
architect: meta._architectTokensAvg ?? meta._architectTokens ?? 0,
|
|
@@ -2923,11 +2962,11 @@ function registerStatusRoute(app, deps) {
|
|
|
2923
2962
|
else {
|
|
2924
2963
|
status = 'idle';
|
|
2925
2964
|
}
|
|
2926
|
-
// Metas summary is expensive (
|
|
2965
|
+
// Metas summary is expensive (watcher walk + disk reads).
|
|
2927
2966
|
// Use GET /metas for full inventory; status is a lightweight health check.
|
|
2928
2967
|
return {
|
|
2929
|
-
service:
|
|
2930
|
-
version:
|
|
2968
|
+
service: SERVICE_NAME,
|
|
2969
|
+
version: SERVICE_VERSION,
|
|
2931
2970
|
uptime: process.uptime(),
|
|
2932
2971
|
status,
|
|
2933
2972
|
currentTarget: queue.current?.path ?? null,
|
|
@@ -2944,7 +2983,10 @@ function registerStatusRoute(app, deps) {
|
|
|
2944
2983
|
nextAt: scheduler?.nextRunAt?.toISOString() ?? null,
|
|
2945
2984
|
},
|
|
2946
2985
|
dependencies: {
|
|
2947
|
-
watcher:
|
|
2986
|
+
watcher: {
|
|
2987
|
+
...watcherHealth,
|
|
2988
|
+
rulesRegistered: deps.registrar?.isRegistered ?? false,
|
|
2989
|
+
},
|
|
2948
2990
|
gateway: gatewayHealth,
|
|
2949
2991
|
},
|
|
2950
2992
|
};
|
|
@@ -2972,7 +3014,7 @@ function registerSynthesizeRoute(app, deps) {
|
|
|
2972
3014
|
// Discover stalest candidate
|
|
2973
3015
|
let result;
|
|
2974
3016
|
try {
|
|
2975
|
-
result = await listMetas(config, watcher
|
|
3017
|
+
result = await listMetas(config, watcher);
|
|
2976
3018
|
}
|
|
2977
3019
|
catch {
|
|
2978
3020
|
return reply.status(503).send({
|
|
@@ -3149,7 +3191,15 @@ function buildMetaRules(config) {
|
|
|
3149
3191
|
},
|
|
3150
3192
|
],
|
|
3151
3193
|
render: {
|
|
3152
|
-
frontmatter: [
|
|
3194
|
+
frontmatter: [
|
|
3195
|
+
'meta_id',
|
|
3196
|
+
'generated_at',
|
|
3197
|
+
'*',
|
|
3198
|
+
'!_*',
|
|
3199
|
+
'!json',
|
|
3200
|
+
'!file',
|
|
3201
|
+
'!has_error',
|
|
3202
|
+
],
|
|
3153
3203
|
body: [{ path: 'json._content', heading: 1, label: 'Synthesis' }],
|
|
3154
3204
|
},
|
|
3155
3205
|
renderAs: 'md',
|
|
@@ -3341,6 +3391,8 @@ function registerShutdownHandlers(deps) {
|
|
|
3341
3391
|
if (deps.routeDeps) {
|
|
3342
3392
|
deps.routeDeps.shuttingDown = true;
|
|
3343
3393
|
}
|
|
3394
|
+
// 0. Run optional cleanup
|
|
3395
|
+
deps.onShutdown?.();
|
|
3344
3396
|
// 1. Stop scheduler
|
|
3345
3397
|
if (deps.scheduler) {
|
|
3346
3398
|
deps.scheduler.stop();
|
|
@@ -3374,7 +3426,7 @@ function registerShutdownHandlers(deps) {
|
|
|
3374
3426
|
/**
|
|
3375
3427
|
* HTTP implementation of the WatcherClient interface.
|
|
3376
3428
|
*
|
|
3377
|
-
* Talks to jeeves-watcher's POST /
|
|
3429
|
+
* Talks to jeeves-watcher's POST /walk and POST /rules/register endpoints
|
|
3378
3430
|
* with retry and exponential backoff.
|
|
3379
3431
|
*
|
|
3380
3432
|
* @module watcher-client/HttpWatcherClient
|
|
@@ -3428,61 +3480,80 @@ class HttpWatcherClient {
|
|
|
3428
3480
|
// Unreachable, but TypeScript needs it
|
|
3429
3481
|
throw new Error('Retry exhausted');
|
|
3430
3482
|
}
|
|
3431
|
-
async
|
|
3432
|
-
|
|
3433
|
-
|
|
3434
|
-
|
|
3435
|
-
|
|
3436
|
-
|
|
3437
|
-
|
|
3438
|
-
|
|
3439
|
-
|
|
3440
|
-
|
|
3441
|
-
|
|
3442
|
-
|
|
3443
|
-
|
|
3444
|
-
|
|
3445
|
-
|
|
3446
|
-
|
|
3483
|
+
async registerRules(source, rules) {
|
|
3484
|
+
await this.post('/rules/register', { source, rules });
|
|
3485
|
+
}
|
|
3486
|
+
async walk(globs) {
|
|
3487
|
+
const raw = (await this.post('/walk', { globs }));
|
|
3488
|
+
return (raw.paths ?? []);
|
|
3489
|
+
}
|
|
3490
|
+
}
|
|
3491
|
+
|
|
3492
|
+
/**
|
|
3493
|
+
* Periodic watcher health check for rule registration resilience.
|
|
3494
|
+
*
|
|
3495
|
+
* Pings watcher `/status` on a configurable interval, detects restarts
|
|
3496
|
+
* (uptime decrease), and re-registers virtual rules automatically.
|
|
3497
|
+
* Independent of the synthesis scheduler.
|
|
3498
|
+
*
|
|
3499
|
+
* @module rules/healthCheck
|
|
3500
|
+
*/
|
|
3501
|
+
/**
|
|
3502
|
+
* Manages the periodic watcher health check loop.
|
|
3503
|
+
*
|
|
3504
|
+
* Starts a `setInterval` that pings the watcher and delegates
|
|
3505
|
+
* restart detection to `RuleRegistrar.checkAndReregister()`.
|
|
3506
|
+
*/
|
|
3507
|
+
class WatcherHealthCheck {
|
|
3508
|
+
watcherUrl;
|
|
3509
|
+
intervalMs;
|
|
3510
|
+
registrar;
|
|
3511
|
+
logger;
|
|
3512
|
+
handle = null;
|
|
3513
|
+
constructor(opts) {
|
|
3514
|
+
this.watcherUrl = opts.watcherUrl.replace(/\/+$/, '');
|
|
3515
|
+
this.intervalMs = opts.intervalMs;
|
|
3516
|
+
this.registrar = opts.registrar;
|
|
3517
|
+
this.logger = opts.logger;
|
|
3518
|
+
}
|
|
3519
|
+
/** Start the periodic health check. No-op if intervalMs is 0. */
|
|
3520
|
+
start() {
|
|
3521
|
+
if (this.intervalMs <= 0) {
|
|
3522
|
+
this.logger.info('Watcher health check disabled (interval = 0)');
|
|
3523
|
+
return;
|
|
3447
3524
|
}
|
|
3448
|
-
|
|
3449
|
-
|
|
3450
|
-
|
|
3451
|
-
|
|
3452
|
-
|
|
3453
|
-
|
|
3525
|
+
this.handle = setInterval(() => {
|
|
3526
|
+
void this.check();
|
|
3527
|
+
}, this.intervalMs);
|
|
3528
|
+
// Don't prevent process exit
|
|
3529
|
+
if (typeof this.handle === 'object' && 'unref' in this.handle) {
|
|
3530
|
+
this.handle.unref();
|
|
3454
3531
|
}
|
|
3455
|
-
|
|
3456
|
-
|
|
3457
|
-
|
|
3458
|
-
|
|
3532
|
+
this.logger.info({ intervalMs: this.intervalMs }, 'Watcher health check started');
|
|
3533
|
+
}
|
|
3534
|
+
/** Stop the periodic health check. */
|
|
3535
|
+
stop() {
|
|
3536
|
+
if (this.handle) {
|
|
3537
|
+
clearInterval(this.handle);
|
|
3538
|
+
this.handle = null;
|
|
3459
3539
|
}
|
|
3460
|
-
|
|
3461
|
-
|
|
3540
|
+
}
|
|
3541
|
+
/** Single health check iteration. */
|
|
3542
|
+
async check() {
|
|
3543
|
+
try {
|
|
3544
|
+
const res = await fetch(this.watcherUrl + '/status', {
|
|
3545
|
+
signal: AbortSignal.timeout(5000),
|
|
3546
|
+
});
|
|
3547
|
+
if (!res.ok) {
|
|
3548
|
+
this.logger.warn({ status: res.status }, 'Watcher health check: non-OK response');
|
|
3549
|
+
return;
|
|
3550
|
+
}
|
|
3551
|
+
const data = (await res.json());
|
|
3552
|
+
await this.registrar.checkAndReregister(data.uptime);
|
|
3462
3553
|
}
|
|
3463
|
-
|
|
3464
|
-
|
|
3554
|
+
catch (err) {
|
|
3555
|
+
this.logger.debug({ err }, 'Watcher health check: unreachable (expected during startup)');
|
|
3465
3556
|
}
|
|
3466
|
-
const raw = (await this.post('/scan', body));
|
|
3467
|
-
// jeeves-watcher returns { points, cursor }; map to ScanResponse.
|
|
3468
|
-
const points = (raw.points ?? raw.files ?? []);
|
|
3469
|
-
const next = (raw.cursor ?? raw.next);
|
|
3470
|
-
const files = points.map((p) => {
|
|
3471
|
-
const payload = (p.payload ?? p);
|
|
3472
|
-
return {
|
|
3473
|
-
file_path: (payload.file_path ?? payload.path ?? ''),
|
|
3474
|
-
modified_at: (payload.modified_at ?? payload.mtime ?? 0),
|
|
3475
|
-
content_hash: (payload.content_hash ?? ''),
|
|
3476
|
-
...payload,
|
|
3477
|
-
};
|
|
3478
|
-
});
|
|
3479
|
-
return { files, next: next ?? undefined };
|
|
3480
|
-
}
|
|
3481
|
-
async registerRules(source, rules) {
|
|
3482
|
-
await this.post('/rules/register', { source, rules });
|
|
3483
|
-
}
|
|
3484
|
-
async unregisterRules(source) {
|
|
3485
|
-
await this.post('/rules/unregister', { source });
|
|
3486
3557
|
}
|
|
3487
3558
|
}
|
|
3488
3559
|
|
|
@@ -3622,7 +3693,16 @@ async function startService(config, configPath) {
|
|
|
3622
3693
|
// Rule registration (fire-and-forget with retries)
|
|
3623
3694
|
const registrar = new RuleRegistrar(config, logger, watcher);
|
|
3624
3695
|
scheduler.setRegistrar(registrar);
|
|
3696
|
+
routeDeps.registrar = registrar;
|
|
3625
3697
|
void registrar.register();
|
|
3698
|
+
// Periodic watcher health check (independent of scheduler)
|
|
3699
|
+
const healthCheck = new WatcherHealthCheck({
|
|
3700
|
+
watcherUrl: config.watcherUrl,
|
|
3701
|
+
intervalMs: config.watcherHealthIntervalMs,
|
|
3702
|
+
registrar,
|
|
3703
|
+
logger,
|
|
3704
|
+
});
|
|
3705
|
+
healthCheck.start();
|
|
3626
3706
|
// Config hot-reload (gap #12)
|
|
3627
3707
|
if (configPath) {
|
|
3628
3708
|
watchFile(configPath, { interval: 5000 }, () => {
|
|
@@ -3656,8 +3736,11 @@ async function startService(config, configPath) {
|
|
|
3656
3736
|
queue,
|
|
3657
3737
|
logger,
|
|
3658
3738
|
routeDeps,
|
|
3739
|
+
onShutdown: () => {
|
|
3740
|
+
healthCheck.stop();
|
|
3741
|
+
},
|
|
3659
3742
|
});
|
|
3660
3743
|
logger.info('Service fully initialized');
|
|
3661
3744
|
}
|
|
3662
3745
|
|
|
3663
|
-
export { GatewayExecutor, HttpWatcherClient, ProgressReporter, RuleRegistrar, Scheduler, SynthesisQueue, acquireLock, actualStaleness, buildArchitectTask, buildBuilderTask, buildContextPackage, buildCriticTask,
|
|
3746
|
+
export { DEFAULT_PORT, DEFAULT_PORT_STR, GatewayExecutor, HttpWatcherClient, ProgressReporter, RuleRegistrar, SERVICE_NAME, SERVICE_VERSION, Scheduler, SynthesisQueue, acquireLock, actualStaleness, buildArchitectTask, buildBuilderTask, buildContextPackage, buildCriticTask, buildOwnershipTree, cleanupStaleLocks, computeEffectiveStaleness, computeEma, computeStructureHash, createLogger, createServer, createSnapshot, discoverMetas, filterInScope, findNode, formatProgressEvent, getScopePrefix, hasSteerChanged, isArchitectTriggered, isLocked, isStale, listArchiveFiles, listMetas, loadServiceConfig, mergeAndWrite, metaConfigSchema, metaErrorSchema, metaJsonSchema, normalizePath, orchestrate, parseArchitectOutput, parseBuilderOutput, parseCriticOutput, pruneArchive, readLatestArchive, readLockState, registerRoutes, registerShutdownHandlers, releaseLock, resolveConfigPath, resolveMetaDir, selectCandidate, serviceConfigSchema, sleep, startService, toMetaError };
|