@karmaniverous/jeeves-meta 0.13.6 → 0.13.7
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/cli/jeeves-meta/index.js +142 -157
- package/dist/index.d.ts +11 -1
- package/dist/index.js +142 -157
- package/package.json +1 -1
|
@@ -7146,138 +7146,6 @@ function getErrorMessage(err) {
|
|
|
7146
7146
|
return err instanceof Error ? err.message : String(err);
|
|
7147
7147
|
}
|
|
7148
7148
|
|
|
7149
|
-
/**
|
|
7150
|
-
* Factory for a framework-agnostic config apply HTTP handler.
|
|
7151
|
-
*
|
|
7152
|
-
* @remarks
|
|
7153
|
-
* Derives the config file path from the descriptor, validates patches
|
|
7154
|
-
* against the descriptor's Zod schema, deep-merges (or replaces),
|
|
7155
|
-
* writes atomically, and calls the optional `onConfigApply` callback.
|
|
7156
|
-
*/
|
|
7157
|
-
/**
|
|
7158
|
-
* Deep-merge two plain objects. Arrays and non-objects are replaced.
|
|
7159
|
-
*
|
|
7160
|
-
* @param target - Base object.
|
|
7161
|
-
* @param source - Object to merge on top.
|
|
7162
|
-
* @returns A new merged object.
|
|
7163
|
-
*/
|
|
7164
|
-
function deepMerge(target, source) {
|
|
7165
|
-
const result = { ...target };
|
|
7166
|
-
for (const key of Object.keys(source)) {
|
|
7167
|
-
const tVal = target[key];
|
|
7168
|
-
const sVal = source[key];
|
|
7169
|
-
if (isPlainObject(tVal) && isPlainObject(sVal)) {
|
|
7170
|
-
result[key] = deepMerge(tVal, sVal);
|
|
7171
|
-
}
|
|
7172
|
-
else {
|
|
7173
|
-
result[key] = sVal;
|
|
7174
|
-
}
|
|
7175
|
-
}
|
|
7176
|
-
return result;
|
|
7177
|
-
}
|
|
7178
|
-
/**
|
|
7179
|
-
* Check if a value is a plain object (not null, not an array).
|
|
7180
|
-
*
|
|
7181
|
-
* @param val - Value to check.
|
|
7182
|
-
* @returns True if the value is a plain object.
|
|
7183
|
-
*/
|
|
7184
|
-
function isPlainObject(val) {
|
|
7185
|
-
return typeof val === 'object' && val !== null && !Array.isArray(val);
|
|
7186
|
-
}
|
|
7187
|
-
/**
|
|
7188
|
-
* Read and parse a JSON config file.
|
|
7189
|
-
*
|
|
7190
|
-
* @param filePath - Absolute path to the file.
|
|
7191
|
-
* @returns Parsed object or empty object if not found.
|
|
7192
|
-
*/
|
|
7193
|
-
function readConfigFile(filePath) {
|
|
7194
|
-
if (!existsSync(filePath))
|
|
7195
|
-
return {};
|
|
7196
|
-
try {
|
|
7197
|
-
const raw = readFileSync(filePath, 'utf-8');
|
|
7198
|
-
return JSON.parse(raw);
|
|
7199
|
-
}
|
|
7200
|
-
catch (err) {
|
|
7201
|
-
console.warn(`jeeves-core: Could not read config file ${filePath}: ${getErrorMessage(err)}`);
|
|
7202
|
-
return {};
|
|
7203
|
-
}
|
|
7204
|
-
}
|
|
7205
|
-
/**
|
|
7206
|
-
* Create a framework-agnostic config apply handler.
|
|
7207
|
-
*
|
|
7208
|
-
* @remarks
|
|
7209
|
-
* The handler:
|
|
7210
|
-
* 1. Reads existing config from `{configRoot}/jeeves-{name}/{configFileName}`
|
|
7211
|
-
* 2. Deep-merges the patch (or replaces if `replace: true`)
|
|
7212
|
-
* 3. Validates the merged result against `descriptor.configSchema`
|
|
7213
|
-
* 4. Writes atomically
|
|
7214
|
-
* 5. Calls `descriptor.onConfigApply` with the merged config (if defined)
|
|
7215
|
-
*
|
|
7216
|
-
* @param descriptor - The component descriptor.
|
|
7217
|
-
* @returns An async handler returning `{ status, body }`.
|
|
7218
|
-
*/
|
|
7219
|
-
function createConfigApplyHandler(descriptor) {
|
|
7220
|
-
return async (request) => {
|
|
7221
|
-
const { patch, replace } = request;
|
|
7222
|
-
// Derive config path
|
|
7223
|
-
const configDir = getComponentConfigDir(descriptor.name);
|
|
7224
|
-
const configPath = join(configDir, descriptor.configFileName);
|
|
7225
|
-
// Read existing config
|
|
7226
|
-
const existing = readConfigFile(configPath);
|
|
7227
|
-
// Merge or replace
|
|
7228
|
-
const mergeFn = descriptor.customMerge ?? deepMerge;
|
|
7229
|
-
const merged = replace ? { ...patch } : mergeFn(existing, patch);
|
|
7230
|
-
// Validate against schema
|
|
7231
|
-
const schema = descriptor.configSchema;
|
|
7232
|
-
const parseResult = schema.safeParse(merged);
|
|
7233
|
-
if (!parseResult.success) {
|
|
7234
|
-
return {
|
|
7235
|
-
status: 400,
|
|
7236
|
-
body: {
|
|
7237
|
-
error: 'Config validation failed',
|
|
7238
|
-
issues: parseResult.error.issues,
|
|
7239
|
-
},
|
|
7240
|
-
};
|
|
7241
|
-
}
|
|
7242
|
-
// Extract validated data (Zod returns unknown from ZodTypeAny)
|
|
7243
|
-
const validatedConfig = parseResult.data;
|
|
7244
|
-
// Write atomically
|
|
7245
|
-
try {
|
|
7246
|
-
const json = JSON.stringify(validatedConfig, null, 2) + '\n';
|
|
7247
|
-
atomicWrite(configPath, json);
|
|
7248
|
-
}
|
|
7249
|
-
catch (err) {
|
|
7250
|
-
return {
|
|
7251
|
-
status: 500,
|
|
7252
|
-
body: { error: `Failed to write config: ${getErrorMessage(err)}` },
|
|
7253
|
-
};
|
|
7254
|
-
}
|
|
7255
|
-
// Call onConfigApply callback if defined
|
|
7256
|
-
if (descriptor.onConfigApply) {
|
|
7257
|
-
try {
|
|
7258
|
-
await descriptor.onConfigApply(validatedConfig);
|
|
7259
|
-
}
|
|
7260
|
-
catch (err) {
|
|
7261
|
-
return {
|
|
7262
|
-
status: 200,
|
|
7263
|
-
body: {
|
|
7264
|
-
applied: true,
|
|
7265
|
-
warning: `Config written but callback failed: ${getErrorMessage(err)}`,
|
|
7266
|
-
config: validatedConfig,
|
|
7267
|
-
},
|
|
7268
|
-
};
|
|
7269
|
-
}
|
|
7270
|
-
}
|
|
7271
|
-
return {
|
|
7272
|
-
status: 200,
|
|
7273
|
-
body: {
|
|
7274
|
-
applied: true,
|
|
7275
|
-
config: validatedConfig,
|
|
7276
|
-
},
|
|
7277
|
-
};
|
|
7278
|
-
};
|
|
7279
|
-
}
|
|
7280
|
-
|
|
7281
7149
|
/**
|
|
7282
7150
|
* Generic config query handler with JSONPath support.
|
|
7283
7151
|
*
|
|
@@ -8581,8 +8449,10 @@ const serviceConfigSchema = metaConfigSchema.extend({
|
|
|
8581
8449
|
port: z.number().int().min(1).max(65535).default(1938),
|
|
8582
8450
|
/** Cron schedule for synthesis cycles (default: every 30 min). */
|
|
8583
8451
|
schedule: z.string().default('*/30 * * * *'),
|
|
8584
|
-
/**
|
|
8452
|
+
/** Messaging channel name (e.g. 'slack'). Legacy: also used as target if reportTarget is unset. */
|
|
8585
8453
|
reportChannel: z.string().optional(),
|
|
8454
|
+
/** Channel/user ID to send progress messages to. */
|
|
8455
|
+
reportTarget: z.string().optional(),
|
|
8586
8456
|
/** Optional base URL for the service, used to construct entity links in progress reports. */
|
|
8587
8457
|
serverBaseUrl: z.string().optional(),
|
|
8588
8458
|
/** Interval in ms for periodic watcher health check. 0 = disabled. Default: 60000. */
|
|
@@ -10964,6 +10834,11 @@ async function orchestrate(config, executor, watcher, targetPath, onProgress, lo
|
|
|
10964
10834
|
function formatNumber(n) {
|
|
10965
10835
|
return n.toLocaleString('en-US');
|
|
10966
10836
|
}
|
|
10837
|
+
function formatTokens(tokens) {
|
|
10838
|
+
return tokens !== undefined
|
|
10839
|
+
? formatNumber(tokens) + ' tokens'
|
|
10840
|
+
: 'unknown tokens';
|
|
10841
|
+
}
|
|
10967
10842
|
function formatSeconds(durationMs) {
|
|
10968
10843
|
const seconds = durationMs / 1000;
|
|
10969
10844
|
return Math.round(seconds).toString() + 's';
|
|
@@ -11014,17 +10889,17 @@ function formatProgressEvent(event, serverBaseUrl) {
|
|
|
11014
10889
|
}
|
|
11015
10890
|
case 'phase_complete': {
|
|
11016
10891
|
const phase = event.phase ? titleCasePhase(event.phase) : 'Phase';
|
|
11017
|
-
const
|
|
10892
|
+
const tokenStr = formatTokens(event.tokens);
|
|
11018
10893
|
const duration = event.durationMs !== undefined ? formatSeconds(event.durationMs) : '0s';
|
|
11019
|
-
return ` ✅ ${phase} complete (${
|
|
10894
|
+
return ` ✅ ${phase} complete (${tokenStr} / ${duration})`;
|
|
11020
10895
|
}
|
|
11021
10896
|
case 'synthesis_complete': {
|
|
11022
10897
|
const metaLink = buildMetaJsonLink(event.path, serverBaseUrl);
|
|
11023
|
-
const
|
|
10898
|
+
const tokenStr = formatTokens(event.tokens);
|
|
11024
10899
|
const duration = event.durationMs !== undefined
|
|
11025
10900
|
? formatSeconds(event.durationMs)
|
|
11026
10901
|
: '0.0s';
|
|
11027
|
-
return `✅ Completed: ${metaLink} (${
|
|
10902
|
+
return `✅ Completed: ${metaLink} (${tokenStr} / ${duration})`;
|
|
11028
10903
|
}
|
|
11029
10904
|
case 'error': {
|
|
11030
10905
|
const dirLink = buildDirectoryLink(event.path, serverBaseUrl);
|
|
@@ -11045,19 +10920,23 @@ class ProgressReporter {
|
|
|
11045
10920
|
this.logger = logger;
|
|
11046
10921
|
}
|
|
11047
10922
|
async report(event) {
|
|
11048
|
-
|
|
10923
|
+
// Multi-channel mode: reportTarget is the destination, reportChannel is the platform.
|
|
10924
|
+
// Legacy mode: reportChannel alone acts as the target (backward compatible).
|
|
10925
|
+
const target = this.config.reportTarget ?? this.config.reportChannel;
|
|
11049
10926
|
if (!target)
|
|
11050
10927
|
return;
|
|
11051
10928
|
const message = formatProgressEvent(event, this.config.serverBaseUrl);
|
|
11052
10929
|
const url = new URL('/tools/invoke', this.config.gatewayUrl);
|
|
11053
|
-
const
|
|
11054
|
-
|
|
11055
|
-
|
|
11056
|
-
|
|
11057
|
-
target,
|
|
11058
|
-
message,
|
|
11059
|
-
},
|
|
10930
|
+
const args = {
|
|
10931
|
+
action: 'send',
|
|
10932
|
+
target,
|
|
10933
|
+
message,
|
|
11060
10934
|
};
|
|
10935
|
+
// Include channel field only in multi-channel mode (reportTarget is set)
|
|
10936
|
+
if (this.config.reportTarget && this.config.reportChannel) {
|
|
10937
|
+
args.channel = this.config.reportChannel;
|
|
10938
|
+
}
|
|
10939
|
+
const payload = { tool: 'message', args };
|
|
11061
10940
|
try {
|
|
11062
10941
|
const res = await fetch(url, {
|
|
11063
10942
|
method: 'POST',
|
|
@@ -11662,14 +11541,19 @@ function metaExists(ownerPath) {
|
|
|
11662
11541
|
* Walk returns file paths; we need the unique set of immediate parent
|
|
11663
11542
|
* directories that could be owners.
|
|
11664
11543
|
*/
|
|
11665
|
-
function extractDirectories(filePaths) {
|
|
11544
|
+
function extractDirectories(filePaths, logger) {
|
|
11666
11545
|
const dirs = new Set();
|
|
11667
11546
|
for (const fp of filePaths) {
|
|
11668
|
-
|
|
11547
|
+
// Normalize backslash paths (Windows) to forward slashes before posix.dirname
|
|
11548
|
+
const normalized = normalizePath(fp);
|
|
11549
|
+
const dir = posix.dirname(normalized);
|
|
11669
11550
|
if (dir !== '.' && dir !== '/') {
|
|
11670
11551
|
dirs.add(dir);
|
|
11671
11552
|
}
|
|
11672
11553
|
}
|
|
11554
|
+
if (filePaths.length > 0 && dirs.size === 0) {
|
|
11555
|
+
logger?.warn({ fileCount: filePaths.length }, 'extractDirectories returned zero results despite non-empty input');
|
|
11556
|
+
}
|
|
11673
11557
|
return [...dirs];
|
|
11674
11558
|
}
|
|
11675
11559
|
/**
|
|
@@ -11687,7 +11571,7 @@ async function autoSeedPass(rules, watcher, logger) {
|
|
|
11687
11571
|
const candidates = new Map();
|
|
11688
11572
|
for (const rule of rules) {
|
|
11689
11573
|
const files = await watcher.walk([rule.match]);
|
|
11690
|
-
const dirs = extractDirectories(files);
|
|
11574
|
+
const dirs = extractDirectories(files, logger);
|
|
11691
11575
|
for (const dir of dirs) {
|
|
11692
11576
|
candidates.set(dir, {
|
|
11693
11577
|
steer: rule.steer,
|
|
@@ -11905,16 +11789,97 @@ function registerConfigRoute(app, deps) {
|
|
|
11905
11789
|
}
|
|
11906
11790
|
|
|
11907
11791
|
/**
|
|
11908
|
-
* POST /config/apply — apply a config patch
|
|
11792
|
+
* POST /config/apply — apply a config patch using the runtime config path.
|
|
11793
|
+
*
|
|
11794
|
+
* The core SDK's `createConfigApplyHandler` derives the config path from
|
|
11795
|
+
* `getComponentConfigDir()` which uses the npm global config root. This
|
|
11796
|
+
* local implementation uses the actual runtime config path instead, so
|
|
11797
|
+
* temp files are written alongside the active config file.
|
|
11909
11798
|
*
|
|
11910
11799
|
* @module routes/configApply
|
|
11911
11800
|
*/
|
|
11912
11801
|
/** Register the POST /config/apply route. */
|
|
11913
|
-
function registerConfigApplyRoute(app) {
|
|
11914
|
-
const handler = createConfigApplyHandler(metaDescriptor);
|
|
11802
|
+
function registerConfigApplyRoute(app, configPath) {
|
|
11915
11803
|
app.post('/config/apply', async (request, reply) => {
|
|
11916
|
-
|
|
11917
|
-
|
|
11804
|
+
if (!configPath) {
|
|
11805
|
+
return reply
|
|
11806
|
+
.status(500)
|
|
11807
|
+
.send({ error: 'No runtime config path available' });
|
|
11808
|
+
}
|
|
11809
|
+
// Validate request body
|
|
11810
|
+
const body = request.body;
|
|
11811
|
+
if (!body || typeof body !== 'object' || Array.isArray(body)) {
|
|
11812
|
+
return reply
|
|
11813
|
+
.status(400)
|
|
11814
|
+
.send({ error: 'Request body must be a JSON object' });
|
|
11815
|
+
}
|
|
11816
|
+
const { patch, replace } = body;
|
|
11817
|
+
if (patch === null ||
|
|
11818
|
+
patch === undefined ||
|
|
11819
|
+
typeof patch !== 'object' ||
|
|
11820
|
+
Array.isArray(patch)) {
|
|
11821
|
+
return reply
|
|
11822
|
+
.status(400)
|
|
11823
|
+
.send({ error: '`patch` must be a non-null object' });
|
|
11824
|
+
}
|
|
11825
|
+
if (replace !== undefined && typeof replace !== 'boolean') {
|
|
11826
|
+
return reply
|
|
11827
|
+
.status(400)
|
|
11828
|
+
.send({ error: '`replace` must be a boolean if provided' });
|
|
11829
|
+
}
|
|
11830
|
+
// Read existing config from the runtime config path
|
|
11831
|
+
let existing = {};
|
|
11832
|
+
try {
|
|
11833
|
+
existing = JSON.parse(readFileSync(configPath, 'utf8'));
|
|
11834
|
+
}
|
|
11835
|
+
catch (err) {
|
|
11836
|
+
if (err instanceof SyntaxError ||
|
|
11837
|
+
(err instanceof Error && err.message.includes('JSON'))) {
|
|
11838
|
+
return reply.status(400).send({
|
|
11839
|
+
error: `Existing config file contains invalid JSON: ${err.message}`,
|
|
11840
|
+
});
|
|
11841
|
+
}
|
|
11842
|
+
// File missing — start from empty
|
|
11843
|
+
}
|
|
11844
|
+
// Merge or replace
|
|
11845
|
+
const merged = replace
|
|
11846
|
+
? { ...patch }
|
|
11847
|
+
: { ...existing, ...patch };
|
|
11848
|
+
// Validate against schema
|
|
11849
|
+
const parseResult = serviceConfigSchema.safeParse(merged);
|
|
11850
|
+
if (!parseResult.success) {
|
|
11851
|
+
return reply.status(400).send({
|
|
11852
|
+
error: 'Config validation failed',
|
|
11853
|
+
issues: parseResult.error.issues,
|
|
11854
|
+
});
|
|
11855
|
+
}
|
|
11856
|
+
const validatedConfig = parseResult.data;
|
|
11857
|
+
// Write atomically — temp file lands next to the runtime config file
|
|
11858
|
+
try {
|
|
11859
|
+
const json = JSON.stringify(validatedConfig, null, 2) + '\n';
|
|
11860
|
+
atomicWrite(configPath, json);
|
|
11861
|
+
}
|
|
11862
|
+
catch (err) {
|
|
11863
|
+
const message = err instanceof Error ? err.message : String(err);
|
|
11864
|
+
return reply
|
|
11865
|
+
.status(500)
|
|
11866
|
+
.send({ error: `Failed to write config: ${message}` });
|
|
11867
|
+
}
|
|
11868
|
+
// Apply hot-reload callback
|
|
11869
|
+
try {
|
|
11870
|
+
applyHotReloadedConfig(validatedConfig);
|
|
11871
|
+
}
|
|
11872
|
+
catch (err) {
|
|
11873
|
+
const message = err instanceof Error ? err.message : String(err);
|
|
11874
|
+
return reply.status(200).send({
|
|
11875
|
+
applied: true,
|
|
11876
|
+
warning: `Config written but callback failed: ${message}`,
|
|
11877
|
+
restartRequired: RESTART_REQUIRED_FIELDS,
|
|
11878
|
+
});
|
|
11879
|
+
}
|
|
11880
|
+
return reply.status(200).send({
|
|
11881
|
+
applied: true,
|
|
11882
|
+
});
|
|
11918
11883
|
});
|
|
11919
11884
|
}
|
|
11920
11885
|
|
|
@@ -12348,6 +12313,16 @@ async function checkWatcher(url) {
|
|
|
12348
12313
|
return { url, status: 'unreachable', checkedAt };
|
|
12349
12314
|
}
|
|
12350
12315
|
}
|
|
12316
|
+
/** Derive service-specific state from current activity and lifecycle. */
|
|
12317
|
+
function deriveServiceState(deps) {
|
|
12318
|
+
if (deps.shuttingDown)
|
|
12319
|
+
return 'stopping';
|
|
12320
|
+
if (deps.queue.current)
|
|
12321
|
+
return 'synthesizing';
|
|
12322
|
+
if (deps.queue.depth > 0)
|
|
12323
|
+
return 'waiting';
|
|
12324
|
+
return 'idle';
|
|
12325
|
+
}
|
|
12351
12326
|
function registerStatusRoute(app, deps) {
|
|
12352
12327
|
const statusHandler = createStatusHandler({
|
|
12353
12328
|
name: SERVICE_NAME,
|
|
@@ -12360,6 +12335,7 @@ function registerStatusRoute(app, deps) {
|
|
|
12360
12335
|
checkDependency(config.gatewayUrl, '/status'),
|
|
12361
12336
|
]);
|
|
12362
12337
|
return {
|
|
12338
|
+
serviceState: deriveServiceState(deps),
|
|
12363
12339
|
currentTarget: queue.current?.path ?? null,
|
|
12364
12340
|
queue: queue.getState(),
|
|
12365
12341
|
stats: {
|
|
@@ -12503,7 +12479,7 @@ function registerRoutes(app, deps) {
|
|
|
12503
12479
|
registerSeedRoute(app, deps);
|
|
12504
12480
|
registerUnlockRoute(app, deps);
|
|
12505
12481
|
registerConfigRoute(app, deps);
|
|
12506
|
-
registerConfigApplyRoute(app);
|
|
12482
|
+
registerConfigApplyRoute(app, deps.configPath);
|
|
12507
12483
|
registerQueueRoutes(app, deps);
|
|
12508
12484
|
}
|
|
12509
12485
|
|
|
@@ -12707,6 +12683,7 @@ async function startService(config, configPath) {
|
|
|
12707
12683
|
scheduler,
|
|
12708
12684
|
stats,
|
|
12709
12685
|
executor,
|
|
12686
|
+
configPath,
|
|
12710
12687
|
};
|
|
12711
12688
|
registerConfigHotReloadRuntime({
|
|
12712
12689
|
config,
|
|
@@ -12742,9 +12719,17 @@ async function startService(config, configPath) {
|
|
|
12742
12719
|
try {
|
|
12743
12720
|
const results = await orchestrate(config, executor, watcher, path, async (evt) => {
|
|
12744
12721
|
// Track token stats from phase completions
|
|
12745
|
-
if (evt.type === 'phase_complete'
|
|
12746
|
-
|
|
12747
|
-
|
|
12722
|
+
if (evt.type === 'phase_complete') {
|
|
12723
|
+
if (evt.tokens !== undefined) {
|
|
12724
|
+
stats.totalTokens += evt.tokens;
|
|
12725
|
+
if (cycleTokens !== undefined) {
|
|
12726
|
+
cycleTokens += evt.tokens;
|
|
12727
|
+
}
|
|
12728
|
+
}
|
|
12729
|
+
else {
|
|
12730
|
+
cycleTokens = undefined;
|
|
12731
|
+
logger.warn({ path: ownerPath, phase: evt.phase }, 'Token count unavailable (session lookup may have timed out)');
|
|
12732
|
+
}
|
|
12748
12733
|
}
|
|
12749
12734
|
await progress.report(evt);
|
|
12750
12735
|
}, logger);
|
package/dist/index.d.ts
CHANGED
|
@@ -239,6 +239,7 @@ declare const serviceConfigSchema: z.ZodObject<{
|
|
|
239
239
|
port: z.ZodDefault<z.ZodNumber>;
|
|
240
240
|
schedule: z.ZodDefault<z.ZodString>;
|
|
241
241
|
reportChannel: z.ZodOptional<z.ZodString>;
|
|
242
|
+
reportTarget: z.ZodOptional<z.ZodString>;
|
|
242
243
|
serverBaseUrl: z.ZodOptional<z.ZodString>;
|
|
243
244
|
watcherHealthIntervalMs: z.ZodDefault<z.ZodNumber>;
|
|
244
245
|
logging: z.ZodDefault<z.ZodObject<{
|
|
@@ -1248,8 +1249,15 @@ type ProgressEvent = {
|
|
|
1248
1249
|
type ProgressReporterConfig = {
|
|
1249
1250
|
gatewayUrl: string;
|
|
1250
1251
|
gatewayApiKey?: string;
|
|
1251
|
-
/**
|
|
1252
|
+
/**
|
|
1253
|
+
* Messaging channel name (e.g. 'slack'). When set alongside reportTarget,
|
|
1254
|
+
* included in the gateway message payload as `channel`.
|
|
1255
|
+
* Legacy: if reportTarget is unset, reportChannel is used as the target
|
|
1256
|
+
* (single-channel mode, backward compatible).
|
|
1257
|
+
*/
|
|
1252
1258
|
reportChannel?: string;
|
|
1259
|
+
/** Channel/user ID to send messages to. Takes priority over reportChannel as target. */
|
|
1260
|
+
reportTarget?: string;
|
|
1253
1261
|
/** Optional base URL for the service, used to construct entity links. */
|
|
1254
1262
|
serverBaseUrl?: string;
|
|
1255
1263
|
};
|
|
@@ -1429,6 +1437,8 @@ interface RouteDeps {
|
|
|
1429
1437
|
executor?: Pick<GatewayExecutor, 'abort'>;
|
|
1430
1438
|
/** Set to true during graceful shutdown. */
|
|
1431
1439
|
shuttingDown?: boolean;
|
|
1440
|
+
/** Runtime config file path for config-apply. */
|
|
1441
|
+
configPath?: string;
|
|
1432
1442
|
}
|
|
1433
1443
|
/** Register all HTTP routes on the Fastify instance. */
|
|
1434
1444
|
declare function registerRoutes(app: FastifyInstance, deps: RouteDeps): void;
|
package/dist/index.js
CHANGED
|
@@ -7345,138 +7345,6 @@ function getErrorMessage(err) {
|
|
|
7345
7345
|
return err instanceof Error ? err.message : String(err);
|
|
7346
7346
|
}
|
|
7347
7347
|
|
|
7348
|
-
/**
|
|
7349
|
-
* Factory for a framework-agnostic config apply HTTP handler.
|
|
7350
|
-
*
|
|
7351
|
-
* @remarks
|
|
7352
|
-
* Derives the config file path from the descriptor, validates patches
|
|
7353
|
-
* against the descriptor's Zod schema, deep-merges (or replaces),
|
|
7354
|
-
* writes atomically, and calls the optional `onConfigApply` callback.
|
|
7355
|
-
*/
|
|
7356
|
-
/**
|
|
7357
|
-
* Deep-merge two plain objects. Arrays and non-objects are replaced.
|
|
7358
|
-
*
|
|
7359
|
-
* @param target - Base object.
|
|
7360
|
-
* @param source - Object to merge on top.
|
|
7361
|
-
* @returns A new merged object.
|
|
7362
|
-
*/
|
|
7363
|
-
function deepMerge(target, source) {
|
|
7364
|
-
const result = { ...target };
|
|
7365
|
-
for (const key of Object.keys(source)) {
|
|
7366
|
-
const tVal = target[key];
|
|
7367
|
-
const sVal = source[key];
|
|
7368
|
-
if (isPlainObject(tVal) && isPlainObject(sVal)) {
|
|
7369
|
-
result[key] = deepMerge(tVal, sVal);
|
|
7370
|
-
}
|
|
7371
|
-
else {
|
|
7372
|
-
result[key] = sVal;
|
|
7373
|
-
}
|
|
7374
|
-
}
|
|
7375
|
-
return result;
|
|
7376
|
-
}
|
|
7377
|
-
/**
|
|
7378
|
-
* Check if a value is a plain object (not null, not an array).
|
|
7379
|
-
*
|
|
7380
|
-
* @param val - Value to check.
|
|
7381
|
-
* @returns True if the value is a plain object.
|
|
7382
|
-
*/
|
|
7383
|
-
function isPlainObject(val) {
|
|
7384
|
-
return typeof val === 'object' && val !== null && !Array.isArray(val);
|
|
7385
|
-
}
|
|
7386
|
-
/**
|
|
7387
|
-
* Read and parse a JSON config file.
|
|
7388
|
-
*
|
|
7389
|
-
* @param filePath - Absolute path to the file.
|
|
7390
|
-
* @returns Parsed object or empty object if not found.
|
|
7391
|
-
*/
|
|
7392
|
-
function readConfigFile(filePath) {
|
|
7393
|
-
if (!existsSync(filePath))
|
|
7394
|
-
return {};
|
|
7395
|
-
try {
|
|
7396
|
-
const raw = readFileSync(filePath, 'utf-8');
|
|
7397
|
-
return JSON.parse(raw);
|
|
7398
|
-
}
|
|
7399
|
-
catch (err) {
|
|
7400
|
-
console.warn(`jeeves-core: Could not read config file ${filePath}: ${getErrorMessage(err)}`);
|
|
7401
|
-
return {};
|
|
7402
|
-
}
|
|
7403
|
-
}
|
|
7404
|
-
/**
|
|
7405
|
-
* Create a framework-agnostic config apply handler.
|
|
7406
|
-
*
|
|
7407
|
-
* @remarks
|
|
7408
|
-
* The handler:
|
|
7409
|
-
* 1. Reads existing config from `{configRoot}/jeeves-{name}/{configFileName}`
|
|
7410
|
-
* 2. Deep-merges the patch (or replaces if `replace: true`)
|
|
7411
|
-
* 3. Validates the merged result against `descriptor.configSchema`
|
|
7412
|
-
* 4. Writes atomically
|
|
7413
|
-
* 5. Calls `descriptor.onConfigApply` with the merged config (if defined)
|
|
7414
|
-
*
|
|
7415
|
-
* @param descriptor - The component descriptor.
|
|
7416
|
-
* @returns An async handler returning `{ status, body }`.
|
|
7417
|
-
*/
|
|
7418
|
-
function createConfigApplyHandler(descriptor) {
|
|
7419
|
-
return async (request) => {
|
|
7420
|
-
const { patch, replace } = request;
|
|
7421
|
-
// Derive config path
|
|
7422
|
-
const configDir = getComponentConfigDir(descriptor.name);
|
|
7423
|
-
const configPath = join(configDir, descriptor.configFileName);
|
|
7424
|
-
// Read existing config
|
|
7425
|
-
const existing = readConfigFile(configPath);
|
|
7426
|
-
// Merge or replace
|
|
7427
|
-
const mergeFn = descriptor.customMerge ?? deepMerge;
|
|
7428
|
-
const merged = replace ? { ...patch } : mergeFn(existing, patch);
|
|
7429
|
-
// Validate against schema
|
|
7430
|
-
const schema = descriptor.configSchema;
|
|
7431
|
-
const parseResult = schema.safeParse(merged);
|
|
7432
|
-
if (!parseResult.success) {
|
|
7433
|
-
return {
|
|
7434
|
-
status: 400,
|
|
7435
|
-
body: {
|
|
7436
|
-
error: 'Config validation failed',
|
|
7437
|
-
issues: parseResult.error.issues,
|
|
7438
|
-
},
|
|
7439
|
-
};
|
|
7440
|
-
}
|
|
7441
|
-
// Extract validated data (Zod returns unknown from ZodTypeAny)
|
|
7442
|
-
const validatedConfig = parseResult.data;
|
|
7443
|
-
// Write atomically
|
|
7444
|
-
try {
|
|
7445
|
-
const json = JSON.stringify(validatedConfig, null, 2) + '\n';
|
|
7446
|
-
atomicWrite(configPath, json);
|
|
7447
|
-
}
|
|
7448
|
-
catch (err) {
|
|
7449
|
-
return {
|
|
7450
|
-
status: 500,
|
|
7451
|
-
body: { error: `Failed to write config: ${getErrorMessage(err)}` },
|
|
7452
|
-
};
|
|
7453
|
-
}
|
|
7454
|
-
// Call onConfigApply callback if defined
|
|
7455
|
-
if (descriptor.onConfigApply) {
|
|
7456
|
-
try {
|
|
7457
|
-
await descriptor.onConfigApply(validatedConfig);
|
|
7458
|
-
}
|
|
7459
|
-
catch (err) {
|
|
7460
|
-
return {
|
|
7461
|
-
status: 200,
|
|
7462
|
-
body: {
|
|
7463
|
-
applied: true,
|
|
7464
|
-
warning: `Config written but callback failed: ${getErrorMessage(err)}`,
|
|
7465
|
-
config: validatedConfig,
|
|
7466
|
-
},
|
|
7467
|
-
};
|
|
7468
|
-
}
|
|
7469
|
-
}
|
|
7470
|
-
return {
|
|
7471
|
-
status: 200,
|
|
7472
|
-
body: {
|
|
7473
|
-
applied: true,
|
|
7474
|
-
config: validatedConfig,
|
|
7475
|
-
},
|
|
7476
|
-
};
|
|
7477
|
-
};
|
|
7478
|
-
}
|
|
7479
|
-
|
|
7480
7348
|
/**
|
|
7481
7349
|
* Generic config query handler with JSONPath support.
|
|
7482
7350
|
*
|
|
@@ -8274,8 +8142,10 @@ const serviceConfigSchema = metaConfigSchema.extend({
|
|
|
8274
8142
|
port: z.number().int().min(1).max(65535).default(1938),
|
|
8275
8143
|
/** Cron schedule for synthesis cycles (default: every 30 min). */
|
|
8276
8144
|
schedule: z.string().default('*/30 * * * *'),
|
|
8277
|
-
/**
|
|
8145
|
+
/** Messaging channel name (e.g. 'slack'). Legacy: also used as target if reportTarget is unset. */
|
|
8278
8146
|
reportChannel: z.string().optional(),
|
|
8147
|
+
/** Channel/user ID to send progress messages to. */
|
|
8148
|
+
reportTarget: z.string().optional(),
|
|
8279
8149
|
/** Optional base URL for the service, used to construct entity links in progress reports. */
|
|
8280
8150
|
serverBaseUrl: z.string().optional(),
|
|
8281
8151
|
/** Interval in ms for periodic watcher health check. 0 = disabled. Default: 60000. */
|
|
@@ -10495,6 +10365,11 @@ async function orchestrate(config, executor, watcher, targetPath, onProgress, lo
|
|
|
10495
10365
|
function formatNumber(n) {
|
|
10496
10366
|
return n.toLocaleString('en-US');
|
|
10497
10367
|
}
|
|
10368
|
+
function formatTokens(tokens) {
|
|
10369
|
+
return tokens !== undefined
|
|
10370
|
+
? formatNumber(tokens) + ' tokens'
|
|
10371
|
+
: 'unknown tokens';
|
|
10372
|
+
}
|
|
10498
10373
|
function formatSeconds(durationMs) {
|
|
10499
10374
|
const seconds = durationMs / 1000;
|
|
10500
10375
|
return Math.round(seconds).toString() + 's';
|
|
@@ -10545,17 +10420,17 @@ function formatProgressEvent(event, serverBaseUrl) {
|
|
|
10545
10420
|
}
|
|
10546
10421
|
case 'phase_complete': {
|
|
10547
10422
|
const phase = event.phase ? titleCasePhase(event.phase) : 'Phase';
|
|
10548
|
-
const
|
|
10423
|
+
const tokenStr = formatTokens(event.tokens);
|
|
10549
10424
|
const duration = event.durationMs !== undefined ? formatSeconds(event.durationMs) : '0s';
|
|
10550
|
-
return ` ✅ ${phase} complete (${
|
|
10425
|
+
return ` ✅ ${phase} complete (${tokenStr} / ${duration})`;
|
|
10551
10426
|
}
|
|
10552
10427
|
case 'synthesis_complete': {
|
|
10553
10428
|
const metaLink = buildMetaJsonLink(event.path, serverBaseUrl);
|
|
10554
|
-
const
|
|
10429
|
+
const tokenStr = formatTokens(event.tokens);
|
|
10555
10430
|
const duration = event.durationMs !== undefined
|
|
10556
10431
|
? formatSeconds(event.durationMs)
|
|
10557
10432
|
: '0.0s';
|
|
10558
|
-
return `✅ Completed: ${metaLink} (${
|
|
10433
|
+
return `✅ Completed: ${metaLink} (${tokenStr} / ${duration})`;
|
|
10559
10434
|
}
|
|
10560
10435
|
case 'error': {
|
|
10561
10436
|
const dirLink = buildDirectoryLink(event.path, serverBaseUrl);
|
|
@@ -10576,19 +10451,23 @@ class ProgressReporter {
|
|
|
10576
10451
|
this.logger = logger;
|
|
10577
10452
|
}
|
|
10578
10453
|
async report(event) {
|
|
10579
|
-
|
|
10454
|
+
// Multi-channel mode: reportTarget is the destination, reportChannel is the platform.
|
|
10455
|
+
// Legacy mode: reportChannel alone acts as the target (backward compatible).
|
|
10456
|
+
const target = this.config.reportTarget ?? this.config.reportChannel;
|
|
10580
10457
|
if (!target)
|
|
10581
10458
|
return;
|
|
10582
10459
|
const message = formatProgressEvent(event, this.config.serverBaseUrl);
|
|
10583
10460
|
const url = new URL('/tools/invoke', this.config.gatewayUrl);
|
|
10584
|
-
const
|
|
10585
|
-
|
|
10586
|
-
|
|
10587
|
-
|
|
10588
|
-
target,
|
|
10589
|
-
message,
|
|
10590
|
-
},
|
|
10461
|
+
const args = {
|
|
10462
|
+
action: 'send',
|
|
10463
|
+
target,
|
|
10464
|
+
message,
|
|
10591
10465
|
};
|
|
10466
|
+
// Include channel field only in multi-channel mode (reportTarget is set)
|
|
10467
|
+
if (this.config.reportTarget && this.config.reportChannel) {
|
|
10468
|
+
args.channel = this.config.reportChannel;
|
|
10469
|
+
}
|
|
10470
|
+
const payload = { tool: 'message', args };
|
|
10592
10471
|
try {
|
|
10593
10472
|
const res = await fetch(url, {
|
|
10594
10473
|
method: 'POST',
|
|
@@ -11193,14 +11072,19 @@ function metaExists(ownerPath) {
|
|
|
11193
11072
|
* Walk returns file paths; we need the unique set of immediate parent
|
|
11194
11073
|
* directories that could be owners.
|
|
11195
11074
|
*/
|
|
11196
|
-
function extractDirectories(filePaths) {
|
|
11075
|
+
function extractDirectories(filePaths, logger) {
|
|
11197
11076
|
const dirs = new Set();
|
|
11198
11077
|
for (const fp of filePaths) {
|
|
11199
|
-
|
|
11078
|
+
// Normalize backslash paths (Windows) to forward slashes before posix.dirname
|
|
11079
|
+
const normalized = normalizePath(fp);
|
|
11080
|
+
const dir = posix.dirname(normalized);
|
|
11200
11081
|
if (dir !== '.' && dir !== '/') {
|
|
11201
11082
|
dirs.add(dir);
|
|
11202
11083
|
}
|
|
11203
11084
|
}
|
|
11085
|
+
if (filePaths.length > 0 && dirs.size === 0) {
|
|
11086
|
+
logger?.warn({ fileCount: filePaths.length }, 'extractDirectories returned zero results despite non-empty input');
|
|
11087
|
+
}
|
|
11204
11088
|
return [...dirs];
|
|
11205
11089
|
}
|
|
11206
11090
|
/**
|
|
@@ -11218,7 +11102,7 @@ async function autoSeedPass(rules, watcher, logger) {
|
|
|
11218
11102
|
const candidates = new Map();
|
|
11219
11103
|
for (const rule of rules) {
|
|
11220
11104
|
const files = await watcher.walk([rule.match]);
|
|
11221
|
-
const dirs = extractDirectories(files);
|
|
11105
|
+
const dirs = extractDirectories(files, logger);
|
|
11222
11106
|
for (const dir of dirs) {
|
|
11223
11107
|
candidates.set(dir, {
|
|
11224
11108
|
steer: rule.steer,
|
|
@@ -11436,16 +11320,97 @@ function registerConfigRoute(app, deps) {
|
|
|
11436
11320
|
}
|
|
11437
11321
|
|
|
11438
11322
|
/**
|
|
11439
|
-
* POST /config/apply — apply a config patch
|
|
11323
|
+
* POST /config/apply — apply a config patch using the runtime config path.
|
|
11324
|
+
*
|
|
11325
|
+
* The core SDK's `createConfigApplyHandler` derives the config path from
|
|
11326
|
+
* `getComponentConfigDir()` which uses the npm global config root. This
|
|
11327
|
+
* local implementation uses the actual runtime config path instead, so
|
|
11328
|
+
* temp files are written alongside the active config file.
|
|
11440
11329
|
*
|
|
11441
11330
|
* @module routes/configApply
|
|
11442
11331
|
*/
|
|
11443
11332
|
/** Register the POST /config/apply route. */
|
|
11444
|
-
function registerConfigApplyRoute(app) {
|
|
11445
|
-
const handler = createConfigApplyHandler(metaDescriptor);
|
|
11333
|
+
function registerConfigApplyRoute(app, configPath) {
|
|
11446
11334
|
app.post('/config/apply', async (request, reply) => {
|
|
11447
|
-
|
|
11448
|
-
|
|
11335
|
+
if (!configPath) {
|
|
11336
|
+
return reply
|
|
11337
|
+
.status(500)
|
|
11338
|
+
.send({ error: 'No runtime config path available' });
|
|
11339
|
+
}
|
|
11340
|
+
// Validate request body
|
|
11341
|
+
const body = request.body;
|
|
11342
|
+
if (!body || typeof body !== 'object' || Array.isArray(body)) {
|
|
11343
|
+
return reply
|
|
11344
|
+
.status(400)
|
|
11345
|
+
.send({ error: 'Request body must be a JSON object' });
|
|
11346
|
+
}
|
|
11347
|
+
const { patch, replace } = body;
|
|
11348
|
+
if (patch === null ||
|
|
11349
|
+
patch === undefined ||
|
|
11350
|
+
typeof patch !== 'object' ||
|
|
11351
|
+
Array.isArray(patch)) {
|
|
11352
|
+
return reply
|
|
11353
|
+
.status(400)
|
|
11354
|
+
.send({ error: '`patch` must be a non-null object' });
|
|
11355
|
+
}
|
|
11356
|
+
if (replace !== undefined && typeof replace !== 'boolean') {
|
|
11357
|
+
return reply
|
|
11358
|
+
.status(400)
|
|
11359
|
+
.send({ error: '`replace` must be a boolean if provided' });
|
|
11360
|
+
}
|
|
11361
|
+
// Read existing config from the runtime config path
|
|
11362
|
+
let existing = {};
|
|
11363
|
+
try {
|
|
11364
|
+
existing = JSON.parse(readFileSync(configPath, 'utf8'));
|
|
11365
|
+
}
|
|
11366
|
+
catch (err) {
|
|
11367
|
+
if (err instanceof SyntaxError ||
|
|
11368
|
+
(err instanceof Error && err.message.includes('JSON'))) {
|
|
11369
|
+
return reply.status(400).send({
|
|
11370
|
+
error: `Existing config file contains invalid JSON: ${err.message}`,
|
|
11371
|
+
});
|
|
11372
|
+
}
|
|
11373
|
+
// File missing — start from empty
|
|
11374
|
+
}
|
|
11375
|
+
// Merge or replace
|
|
11376
|
+
const merged = replace
|
|
11377
|
+
? { ...patch }
|
|
11378
|
+
: { ...existing, ...patch };
|
|
11379
|
+
// Validate against schema
|
|
11380
|
+
const parseResult = serviceConfigSchema.safeParse(merged);
|
|
11381
|
+
if (!parseResult.success) {
|
|
11382
|
+
return reply.status(400).send({
|
|
11383
|
+
error: 'Config validation failed',
|
|
11384
|
+
issues: parseResult.error.issues,
|
|
11385
|
+
});
|
|
11386
|
+
}
|
|
11387
|
+
const validatedConfig = parseResult.data;
|
|
11388
|
+
// Write atomically — temp file lands next to the runtime config file
|
|
11389
|
+
try {
|
|
11390
|
+
const json = JSON.stringify(validatedConfig, null, 2) + '\n';
|
|
11391
|
+
atomicWrite(configPath, json);
|
|
11392
|
+
}
|
|
11393
|
+
catch (err) {
|
|
11394
|
+
const message = err instanceof Error ? err.message : String(err);
|
|
11395
|
+
return reply
|
|
11396
|
+
.status(500)
|
|
11397
|
+
.send({ error: `Failed to write config: ${message}` });
|
|
11398
|
+
}
|
|
11399
|
+
// Apply hot-reload callback
|
|
11400
|
+
try {
|
|
11401
|
+
applyHotReloadedConfig(validatedConfig);
|
|
11402
|
+
}
|
|
11403
|
+
catch (err) {
|
|
11404
|
+
const message = err instanceof Error ? err.message : String(err);
|
|
11405
|
+
return reply.status(200).send({
|
|
11406
|
+
applied: true,
|
|
11407
|
+
warning: `Config written but callback failed: ${message}`,
|
|
11408
|
+
restartRequired: RESTART_REQUIRED_FIELDS,
|
|
11409
|
+
});
|
|
11410
|
+
}
|
|
11411
|
+
return reply.status(200).send({
|
|
11412
|
+
applied: true,
|
|
11413
|
+
});
|
|
11449
11414
|
});
|
|
11450
11415
|
}
|
|
11451
11416
|
|
|
@@ -11851,6 +11816,16 @@ async function checkWatcher(url) {
|
|
|
11851
11816
|
return { url, status: 'unreachable', checkedAt };
|
|
11852
11817
|
}
|
|
11853
11818
|
}
|
|
11819
|
+
/** Derive service-specific state from current activity and lifecycle. */
|
|
11820
|
+
function deriveServiceState(deps) {
|
|
11821
|
+
if (deps.shuttingDown)
|
|
11822
|
+
return 'stopping';
|
|
11823
|
+
if (deps.queue.current)
|
|
11824
|
+
return 'synthesizing';
|
|
11825
|
+
if (deps.queue.depth > 0)
|
|
11826
|
+
return 'waiting';
|
|
11827
|
+
return 'idle';
|
|
11828
|
+
}
|
|
11854
11829
|
function registerStatusRoute(app, deps) {
|
|
11855
11830
|
const statusHandler = createStatusHandler({
|
|
11856
11831
|
name: SERVICE_NAME,
|
|
@@ -11863,6 +11838,7 @@ function registerStatusRoute(app, deps) {
|
|
|
11863
11838
|
checkDependency(config.gatewayUrl, '/status'),
|
|
11864
11839
|
]);
|
|
11865
11840
|
return {
|
|
11841
|
+
serviceState: deriveServiceState(deps),
|
|
11866
11842
|
currentTarget: queue.current?.path ?? null,
|
|
11867
11843
|
queue: queue.getState(),
|
|
11868
11844
|
stats: {
|
|
@@ -12006,7 +11982,7 @@ function registerRoutes(app, deps) {
|
|
|
12006
11982
|
registerSeedRoute(app, deps);
|
|
12007
11983
|
registerUnlockRoute(app, deps);
|
|
12008
11984
|
registerConfigRoute(app, deps);
|
|
12009
|
-
registerConfigApplyRoute(app);
|
|
11985
|
+
registerConfigApplyRoute(app, deps.configPath);
|
|
12010
11986
|
registerQueueRoutes(app, deps);
|
|
12011
11987
|
}
|
|
12012
11988
|
|
|
@@ -12210,6 +12186,7 @@ async function startService(config, configPath) {
|
|
|
12210
12186
|
scheduler,
|
|
12211
12187
|
stats,
|
|
12212
12188
|
executor,
|
|
12189
|
+
configPath,
|
|
12213
12190
|
};
|
|
12214
12191
|
registerConfigHotReloadRuntime({
|
|
12215
12192
|
config,
|
|
@@ -12245,9 +12222,17 @@ async function startService(config, configPath) {
|
|
|
12245
12222
|
try {
|
|
12246
12223
|
const results = await orchestrate(config, executor, watcher, path, async (evt) => {
|
|
12247
12224
|
// Track token stats from phase completions
|
|
12248
|
-
if (evt.type === 'phase_complete'
|
|
12249
|
-
|
|
12250
|
-
|
|
12225
|
+
if (evt.type === 'phase_complete') {
|
|
12226
|
+
if (evt.tokens !== undefined) {
|
|
12227
|
+
stats.totalTokens += evt.tokens;
|
|
12228
|
+
if (cycleTokens !== undefined) {
|
|
12229
|
+
cycleTokens += evt.tokens;
|
|
12230
|
+
}
|
|
12231
|
+
}
|
|
12232
|
+
else {
|
|
12233
|
+
cycleTokens = undefined;
|
|
12234
|
+
logger.warn({ path: ownerPath, phase: evt.phase }, 'Token count unavailable (session lookup may have timed out)');
|
|
12235
|
+
}
|
|
12251
12236
|
}
|
|
12252
12237
|
await progress.report(evt);
|
|
12253
12238
|
}, logger);
|