@karmaniverous/jeeves-meta 0.13.6 → 0.13.8
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 +153 -164
- package/dist/index.d.ts +11 -1
- package/dist/index.js +153 -164
- 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. */
|
|
@@ -9405,17 +9275,20 @@ class GatewayExecutor {
|
|
|
9405
9275
|
}
|
|
9406
9276
|
}
|
|
9407
9277
|
/** Invoke a gateway tool via the /tools/invoke HTTP endpoint. */
|
|
9408
|
-
async invoke(tool, args) {
|
|
9278
|
+
async invoke(tool, args, sessionKey) {
|
|
9409
9279
|
const headers = {
|
|
9410
9280
|
'Content-Type': 'application/json',
|
|
9411
9281
|
};
|
|
9412
9282
|
if (this.apiKey) {
|
|
9413
9283
|
headers['Authorization'] = 'Bearer ' + this.apiKey;
|
|
9414
9284
|
}
|
|
9285
|
+
const body = { tool, args };
|
|
9286
|
+
if (sessionKey)
|
|
9287
|
+
body.sessionKey = sessionKey;
|
|
9415
9288
|
const res = await fetch(this.gatewayUrl + '/tools/invoke', {
|
|
9416
9289
|
method: 'POST',
|
|
9417
9290
|
headers,
|
|
9418
|
-
body: JSON.stringify(
|
|
9291
|
+
body: JSON.stringify(body),
|
|
9419
9292
|
});
|
|
9420
9293
|
if (!res.ok) {
|
|
9421
9294
|
const text = await res.text();
|
|
@@ -9428,12 +9301,12 @@ class GatewayExecutor {
|
|
|
9428
9301
|
return data;
|
|
9429
9302
|
}
|
|
9430
9303
|
/** Look up totalTokens for a session via sessions_list. */
|
|
9431
|
-
async getSessionTokens(sessionKey) {
|
|
9304
|
+
async getSessionTokens(sessionKey, invokeSessionKey) {
|
|
9432
9305
|
try {
|
|
9433
9306
|
const result = await this.invoke('sessions_list', {
|
|
9434
9307
|
limit: 20,
|
|
9435
9308
|
messageLimit: 0,
|
|
9436
|
-
});
|
|
9309
|
+
}, invokeSessionKey);
|
|
9437
9310
|
const sessions = (result.result?.details?.sessions ??
|
|
9438
9311
|
result.result?.sessions ??
|
|
9439
9312
|
[]);
|
|
@@ -9461,6 +9334,7 @@ class GatewayExecutor {
|
|
|
9461
9334
|
// Generate unique output path for file-based output
|
|
9462
9335
|
const outputId = randomUUID();
|
|
9463
9336
|
const outputPath = this.workspaceDir + '/output-' + outputId + '.json';
|
|
9337
|
+
const invokeSessionKey = 'agent:main:meta-invoke:' + outputId;
|
|
9464
9338
|
// Append file output instruction to the task
|
|
9465
9339
|
const taskWithOutput = task +
|
|
9466
9340
|
'\n\n## OUTPUT DELIVERY\n\n' +
|
|
@@ -9478,7 +9352,7 @@ class GatewayExecutor {
|
|
|
9478
9352
|
runTimeoutSeconds: timeoutSeconds,
|
|
9479
9353
|
...(options?.thinking ? { thinking: options.thinking } : {}),
|
|
9480
9354
|
...(options?.model ? { model: options.model } : {}),
|
|
9481
|
-
});
|
|
9355
|
+
}, invokeSessionKey);
|
|
9482
9356
|
const details = (spawnResult.result?.details ?? spawnResult.result);
|
|
9483
9357
|
const sessionKey = details?.childSessionKey ?? details?.sessionKey;
|
|
9484
9358
|
if (typeof sessionKey !== 'string' || !sessionKey) {
|
|
@@ -9498,7 +9372,7 @@ class GatewayExecutor {
|
|
|
9498
9372
|
sessionKey,
|
|
9499
9373
|
limit: 5,
|
|
9500
9374
|
includeTools: false,
|
|
9501
|
-
});
|
|
9375
|
+
}, invokeSessionKey);
|
|
9502
9376
|
const messages = historyResult.result?.details?.messages ??
|
|
9503
9377
|
historyResult.result?.messages ??
|
|
9504
9378
|
[];
|
|
@@ -9511,7 +9385,7 @@ class GatewayExecutor {
|
|
|
9511
9385
|
lastMsg.stopReason !== 'toolUse' &&
|
|
9512
9386
|
lastMsg.stopReason !== 'error') {
|
|
9513
9387
|
// Fetch token usage from session metadata
|
|
9514
|
-
const tokens = await this.getSessionTokens(sessionKey);
|
|
9388
|
+
const tokens = await this.getSessionTokens(sessionKey, invokeSessionKey);
|
|
9515
9389
|
// Read output from file (sub-agent wrote it via Write tool)
|
|
9516
9390
|
if (existsSync(outputPath)) {
|
|
9517
9391
|
try {
|
|
@@ -10964,6 +10838,11 @@ async function orchestrate(config, executor, watcher, targetPath, onProgress, lo
|
|
|
10964
10838
|
function formatNumber(n) {
|
|
10965
10839
|
return n.toLocaleString('en-US');
|
|
10966
10840
|
}
|
|
10841
|
+
function formatTokens(tokens) {
|
|
10842
|
+
return tokens !== undefined
|
|
10843
|
+
? formatNumber(tokens) + ' tokens'
|
|
10844
|
+
: 'unknown tokens';
|
|
10845
|
+
}
|
|
10967
10846
|
function formatSeconds(durationMs) {
|
|
10968
10847
|
const seconds = durationMs / 1000;
|
|
10969
10848
|
return Math.round(seconds).toString() + 's';
|
|
@@ -11014,17 +10893,17 @@ function formatProgressEvent(event, serverBaseUrl) {
|
|
|
11014
10893
|
}
|
|
11015
10894
|
case 'phase_complete': {
|
|
11016
10895
|
const phase = event.phase ? titleCasePhase(event.phase) : 'Phase';
|
|
11017
|
-
const
|
|
10896
|
+
const tokenStr = formatTokens(event.tokens);
|
|
11018
10897
|
const duration = event.durationMs !== undefined ? formatSeconds(event.durationMs) : '0s';
|
|
11019
|
-
return ` ✅ ${phase} complete (${
|
|
10898
|
+
return ` ✅ ${phase} complete (${tokenStr} / ${duration})`;
|
|
11020
10899
|
}
|
|
11021
10900
|
case 'synthesis_complete': {
|
|
11022
10901
|
const metaLink = buildMetaJsonLink(event.path, serverBaseUrl);
|
|
11023
|
-
const
|
|
10902
|
+
const tokenStr = formatTokens(event.tokens);
|
|
11024
10903
|
const duration = event.durationMs !== undefined
|
|
11025
10904
|
? formatSeconds(event.durationMs)
|
|
11026
10905
|
: '0.0s';
|
|
11027
|
-
return `✅ Completed: ${metaLink} (${
|
|
10906
|
+
return `✅ Completed: ${metaLink} (${tokenStr} / ${duration})`;
|
|
11028
10907
|
}
|
|
11029
10908
|
case 'error': {
|
|
11030
10909
|
const dirLink = buildDirectoryLink(event.path, serverBaseUrl);
|
|
@@ -11045,19 +10924,23 @@ class ProgressReporter {
|
|
|
11045
10924
|
this.logger = logger;
|
|
11046
10925
|
}
|
|
11047
10926
|
async report(event) {
|
|
11048
|
-
|
|
10927
|
+
// Multi-channel mode: reportTarget is the destination, reportChannel is the platform.
|
|
10928
|
+
// Legacy mode: reportChannel alone acts as the target (backward compatible).
|
|
10929
|
+
const target = this.config.reportTarget ?? this.config.reportChannel;
|
|
11049
10930
|
if (!target)
|
|
11050
10931
|
return;
|
|
11051
10932
|
const message = formatProgressEvent(event, this.config.serverBaseUrl);
|
|
11052
10933
|
const url = new URL('/tools/invoke', this.config.gatewayUrl);
|
|
11053
|
-
const
|
|
11054
|
-
|
|
11055
|
-
|
|
11056
|
-
|
|
11057
|
-
target,
|
|
11058
|
-
message,
|
|
11059
|
-
},
|
|
10934
|
+
const args = {
|
|
10935
|
+
action: 'send',
|
|
10936
|
+
target,
|
|
10937
|
+
message,
|
|
11060
10938
|
};
|
|
10939
|
+
// Include channel field only in multi-channel mode (reportTarget is set)
|
|
10940
|
+
if (this.config.reportTarget && this.config.reportChannel) {
|
|
10941
|
+
args.channel = this.config.reportChannel;
|
|
10942
|
+
}
|
|
10943
|
+
const payload = { tool: 'message', args };
|
|
11061
10944
|
try {
|
|
11062
10945
|
const res = await fetch(url, {
|
|
11063
10946
|
method: 'POST',
|
|
@@ -11662,14 +11545,19 @@ function metaExists(ownerPath) {
|
|
|
11662
11545
|
* Walk returns file paths; we need the unique set of immediate parent
|
|
11663
11546
|
* directories that could be owners.
|
|
11664
11547
|
*/
|
|
11665
|
-
function extractDirectories(filePaths) {
|
|
11548
|
+
function extractDirectories(filePaths, logger) {
|
|
11666
11549
|
const dirs = new Set();
|
|
11667
11550
|
for (const fp of filePaths) {
|
|
11668
|
-
|
|
11551
|
+
// Normalize backslash paths (Windows) to forward slashes before posix.dirname
|
|
11552
|
+
const normalized = normalizePath(fp);
|
|
11553
|
+
const dir = posix.dirname(normalized);
|
|
11669
11554
|
if (dir !== '.' && dir !== '/') {
|
|
11670
11555
|
dirs.add(dir);
|
|
11671
11556
|
}
|
|
11672
11557
|
}
|
|
11558
|
+
if (filePaths.length > 0 && dirs.size === 0) {
|
|
11559
|
+
logger?.warn({ fileCount: filePaths.length }, 'extractDirectories returned zero results despite non-empty input');
|
|
11560
|
+
}
|
|
11673
11561
|
return [...dirs];
|
|
11674
11562
|
}
|
|
11675
11563
|
/**
|
|
@@ -11687,7 +11575,7 @@ async function autoSeedPass(rules, watcher, logger) {
|
|
|
11687
11575
|
const candidates = new Map();
|
|
11688
11576
|
for (const rule of rules) {
|
|
11689
11577
|
const files = await watcher.walk([rule.match]);
|
|
11690
|
-
const dirs = extractDirectories(files);
|
|
11578
|
+
const dirs = extractDirectories(files, logger);
|
|
11691
11579
|
for (const dir of dirs) {
|
|
11692
11580
|
candidates.set(dir, {
|
|
11693
11581
|
steer: rule.steer,
|
|
@@ -11905,16 +11793,97 @@ function registerConfigRoute(app, deps) {
|
|
|
11905
11793
|
}
|
|
11906
11794
|
|
|
11907
11795
|
/**
|
|
11908
|
-
* POST /config/apply — apply a config patch
|
|
11796
|
+
* POST /config/apply — apply a config patch using the runtime config path.
|
|
11797
|
+
*
|
|
11798
|
+
* The core SDK's `createConfigApplyHandler` derives the config path from
|
|
11799
|
+
* `getComponentConfigDir()` which uses the npm global config root. This
|
|
11800
|
+
* local implementation uses the actual runtime config path instead, so
|
|
11801
|
+
* temp files are written alongside the active config file.
|
|
11909
11802
|
*
|
|
11910
11803
|
* @module routes/configApply
|
|
11911
11804
|
*/
|
|
11912
11805
|
/** Register the POST /config/apply route. */
|
|
11913
|
-
function registerConfigApplyRoute(app) {
|
|
11914
|
-
const handler = createConfigApplyHandler(metaDescriptor);
|
|
11806
|
+
function registerConfigApplyRoute(app, configPath) {
|
|
11915
11807
|
app.post('/config/apply', async (request, reply) => {
|
|
11916
|
-
|
|
11917
|
-
|
|
11808
|
+
if (!configPath) {
|
|
11809
|
+
return reply
|
|
11810
|
+
.status(500)
|
|
11811
|
+
.send({ error: 'No runtime config path available' });
|
|
11812
|
+
}
|
|
11813
|
+
// Validate request body
|
|
11814
|
+
const body = request.body;
|
|
11815
|
+
if (!body || typeof body !== 'object' || Array.isArray(body)) {
|
|
11816
|
+
return reply
|
|
11817
|
+
.status(400)
|
|
11818
|
+
.send({ error: 'Request body must be a JSON object' });
|
|
11819
|
+
}
|
|
11820
|
+
const { patch, replace } = body;
|
|
11821
|
+
if (patch === null ||
|
|
11822
|
+
patch === undefined ||
|
|
11823
|
+
typeof patch !== 'object' ||
|
|
11824
|
+
Array.isArray(patch)) {
|
|
11825
|
+
return reply
|
|
11826
|
+
.status(400)
|
|
11827
|
+
.send({ error: '`patch` must be a non-null object' });
|
|
11828
|
+
}
|
|
11829
|
+
if (replace !== undefined && typeof replace !== 'boolean') {
|
|
11830
|
+
return reply
|
|
11831
|
+
.status(400)
|
|
11832
|
+
.send({ error: '`replace` must be a boolean if provided' });
|
|
11833
|
+
}
|
|
11834
|
+
// Read existing config from the runtime config path
|
|
11835
|
+
let existing = {};
|
|
11836
|
+
try {
|
|
11837
|
+
existing = JSON.parse(readFileSync(configPath, 'utf8'));
|
|
11838
|
+
}
|
|
11839
|
+
catch (err) {
|
|
11840
|
+
if (err instanceof SyntaxError ||
|
|
11841
|
+
(err instanceof Error && err.message.includes('JSON'))) {
|
|
11842
|
+
return reply.status(400).send({
|
|
11843
|
+
error: `Existing config file contains invalid JSON: ${err.message}`,
|
|
11844
|
+
});
|
|
11845
|
+
}
|
|
11846
|
+
// File missing — start from empty
|
|
11847
|
+
}
|
|
11848
|
+
// Merge or replace
|
|
11849
|
+
const merged = replace
|
|
11850
|
+
? { ...patch }
|
|
11851
|
+
: { ...existing, ...patch };
|
|
11852
|
+
// Validate against schema
|
|
11853
|
+
const parseResult = serviceConfigSchema.safeParse(merged);
|
|
11854
|
+
if (!parseResult.success) {
|
|
11855
|
+
return reply.status(400).send({
|
|
11856
|
+
error: 'Config validation failed',
|
|
11857
|
+
issues: parseResult.error.issues,
|
|
11858
|
+
});
|
|
11859
|
+
}
|
|
11860
|
+
const validatedConfig = parseResult.data;
|
|
11861
|
+
// Write atomically — temp file lands next to the runtime config file
|
|
11862
|
+
try {
|
|
11863
|
+
const json = JSON.stringify(validatedConfig, null, 2) + '\n';
|
|
11864
|
+
atomicWrite(configPath, json);
|
|
11865
|
+
}
|
|
11866
|
+
catch (err) {
|
|
11867
|
+
const message = err instanceof Error ? err.message : String(err);
|
|
11868
|
+
return reply
|
|
11869
|
+
.status(500)
|
|
11870
|
+
.send({ error: `Failed to write config: ${message}` });
|
|
11871
|
+
}
|
|
11872
|
+
// Apply hot-reload callback
|
|
11873
|
+
try {
|
|
11874
|
+
applyHotReloadedConfig(validatedConfig);
|
|
11875
|
+
}
|
|
11876
|
+
catch (err) {
|
|
11877
|
+
const message = err instanceof Error ? err.message : String(err);
|
|
11878
|
+
return reply.status(200).send({
|
|
11879
|
+
applied: true,
|
|
11880
|
+
warning: `Config written but callback failed: ${message}`,
|
|
11881
|
+
restartRequired: RESTART_REQUIRED_FIELDS,
|
|
11882
|
+
});
|
|
11883
|
+
}
|
|
11884
|
+
return reply.status(200).send({
|
|
11885
|
+
applied: true,
|
|
11886
|
+
});
|
|
11918
11887
|
});
|
|
11919
11888
|
}
|
|
11920
11889
|
|
|
@@ -12348,6 +12317,16 @@ async function checkWatcher(url) {
|
|
|
12348
12317
|
return { url, status: 'unreachable', checkedAt };
|
|
12349
12318
|
}
|
|
12350
12319
|
}
|
|
12320
|
+
/** Derive service-specific state from current activity and lifecycle. */
|
|
12321
|
+
function deriveServiceState(deps) {
|
|
12322
|
+
if (deps.shuttingDown)
|
|
12323
|
+
return 'stopping';
|
|
12324
|
+
if (deps.queue.current)
|
|
12325
|
+
return 'synthesizing';
|
|
12326
|
+
if (deps.queue.depth > 0)
|
|
12327
|
+
return 'waiting';
|
|
12328
|
+
return 'idle';
|
|
12329
|
+
}
|
|
12351
12330
|
function registerStatusRoute(app, deps) {
|
|
12352
12331
|
const statusHandler = createStatusHandler({
|
|
12353
12332
|
name: SERVICE_NAME,
|
|
@@ -12360,6 +12339,7 @@ function registerStatusRoute(app, deps) {
|
|
|
12360
12339
|
checkDependency(config.gatewayUrl, '/status'),
|
|
12361
12340
|
]);
|
|
12362
12341
|
return {
|
|
12342
|
+
serviceState: deriveServiceState(deps),
|
|
12363
12343
|
currentTarget: queue.current?.path ?? null,
|
|
12364
12344
|
queue: queue.getState(),
|
|
12365
12345
|
stats: {
|
|
@@ -12503,7 +12483,7 @@ function registerRoutes(app, deps) {
|
|
|
12503
12483
|
registerSeedRoute(app, deps);
|
|
12504
12484
|
registerUnlockRoute(app, deps);
|
|
12505
12485
|
registerConfigRoute(app, deps);
|
|
12506
|
-
registerConfigApplyRoute(app);
|
|
12486
|
+
registerConfigApplyRoute(app, deps.configPath);
|
|
12507
12487
|
registerQueueRoutes(app, deps);
|
|
12508
12488
|
}
|
|
12509
12489
|
|
|
@@ -12707,6 +12687,7 @@ async function startService(config, configPath) {
|
|
|
12707
12687
|
scheduler,
|
|
12708
12688
|
stats,
|
|
12709
12689
|
executor,
|
|
12690
|
+
configPath,
|
|
12710
12691
|
};
|
|
12711
12692
|
registerConfigHotReloadRuntime({
|
|
12712
12693
|
config,
|
|
@@ -12742,9 +12723,17 @@ async function startService(config, configPath) {
|
|
|
12742
12723
|
try {
|
|
12743
12724
|
const results = await orchestrate(config, executor, watcher, path, async (evt) => {
|
|
12744
12725
|
// Track token stats from phase completions
|
|
12745
|
-
if (evt.type === 'phase_complete'
|
|
12746
|
-
|
|
12747
|
-
|
|
12726
|
+
if (evt.type === 'phase_complete') {
|
|
12727
|
+
if (evt.tokens !== undefined) {
|
|
12728
|
+
stats.totalTokens += evt.tokens;
|
|
12729
|
+
if (cycleTokens !== undefined) {
|
|
12730
|
+
cycleTokens += evt.tokens;
|
|
12731
|
+
}
|
|
12732
|
+
}
|
|
12733
|
+
else {
|
|
12734
|
+
cycleTokens = undefined;
|
|
12735
|
+
logger.warn({ path: ownerPath, phase: evt.phase }, 'Token count unavailable (session lookup may have timed out)');
|
|
12736
|
+
}
|
|
12748
12737
|
}
|
|
12749
12738
|
await progress.report(evt);
|
|
12750
12739
|
}, 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. */
|
|
@@ -9124,17 +8994,20 @@ class GatewayExecutor {
|
|
|
9124
8994
|
}
|
|
9125
8995
|
}
|
|
9126
8996
|
/** Invoke a gateway tool via the /tools/invoke HTTP endpoint. */
|
|
9127
|
-
async invoke(tool, args) {
|
|
8997
|
+
async invoke(tool, args, sessionKey) {
|
|
9128
8998
|
const headers = {
|
|
9129
8999
|
'Content-Type': 'application/json',
|
|
9130
9000
|
};
|
|
9131
9001
|
if (this.apiKey) {
|
|
9132
9002
|
headers['Authorization'] = 'Bearer ' + this.apiKey;
|
|
9133
9003
|
}
|
|
9004
|
+
const body = { tool, args };
|
|
9005
|
+
if (sessionKey)
|
|
9006
|
+
body.sessionKey = sessionKey;
|
|
9134
9007
|
const res = await fetch(this.gatewayUrl + '/tools/invoke', {
|
|
9135
9008
|
method: 'POST',
|
|
9136
9009
|
headers,
|
|
9137
|
-
body: JSON.stringify(
|
|
9010
|
+
body: JSON.stringify(body),
|
|
9138
9011
|
});
|
|
9139
9012
|
if (!res.ok) {
|
|
9140
9013
|
const text = await res.text();
|
|
@@ -9147,12 +9020,12 @@ class GatewayExecutor {
|
|
|
9147
9020
|
return data;
|
|
9148
9021
|
}
|
|
9149
9022
|
/** Look up totalTokens for a session via sessions_list. */
|
|
9150
|
-
async getSessionTokens(sessionKey) {
|
|
9023
|
+
async getSessionTokens(sessionKey, invokeSessionKey) {
|
|
9151
9024
|
try {
|
|
9152
9025
|
const result = await this.invoke('sessions_list', {
|
|
9153
9026
|
limit: 20,
|
|
9154
9027
|
messageLimit: 0,
|
|
9155
|
-
});
|
|
9028
|
+
}, invokeSessionKey);
|
|
9156
9029
|
const sessions = (result.result?.details?.sessions ??
|
|
9157
9030
|
result.result?.sessions ??
|
|
9158
9031
|
[]);
|
|
@@ -9180,6 +9053,7 @@ class GatewayExecutor {
|
|
|
9180
9053
|
// Generate unique output path for file-based output
|
|
9181
9054
|
const outputId = randomUUID();
|
|
9182
9055
|
const outputPath = this.workspaceDir + '/output-' + outputId + '.json';
|
|
9056
|
+
const invokeSessionKey = 'agent:main:meta-invoke:' + outputId;
|
|
9183
9057
|
// Append file output instruction to the task
|
|
9184
9058
|
const taskWithOutput = task +
|
|
9185
9059
|
'\n\n## OUTPUT DELIVERY\n\n' +
|
|
@@ -9197,7 +9071,7 @@ class GatewayExecutor {
|
|
|
9197
9071
|
runTimeoutSeconds: timeoutSeconds,
|
|
9198
9072
|
...(options?.thinking ? { thinking: options.thinking } : {}),
|
|
9199
9073
|
...(options?.model ? { model: options.model } : {}),
|
|
9200
|
-
});
|
|
9074
|
+
}, invokeSessionKey);
|
|
9201
9075
|
const details = (spawnResult.result?.details ?? spawnResult.result);
|
|
9202
9076
|
const sessionKey = details?.childSessionKey ?? details?.sessionKey;
|
|
9203
9077
|
if (typeof sessionKey !== 'string' || !sessionKey) {
|
|
@@ -9217,7 +9091,7 @@ class GatewayExecutor {
|
|
|
9217
9091
|
sessionKey,
|
|
9218
9092
|
limit: 5,
|
|
9219
9093
|
includeTools: false,
|
|
9220
|
-
});
|
|
9094
|
+
}, invokeSessionKey);
|
|
9221
9095
|
const messages = historyResult.result?.details?.messages ??
|
|
9222
9096
|
historyResult.result?.messages ??
|
|
9223
9097
|
[];
|
|
@@ -9230,7 +9104,7 @@ class GatewayExecutor {
|
|
|
9230
9104
|
lastMsg.stopReason !== 'toolUse' &&
|
|
9231
9105
|
lastMsg.stopReason !== 'error') {
|
|
9232
9106
|
// Fetch token usage from session metadata
|
|
9233
|
-
const tokens = await this.getSessionTokens(sessionKey);
|
|
9107
|
+
const tokens = await this.getSessionTokens(sessionKey, invokeSessionKey);
|
|
9234
9108
|
// Read output from file (sub-agent wrote it via Write tool)
|
|
9235
9109
|
if (existsSync(outputPath)) {
|
|
9236
9110
|
try {
|
|
@@ -10495,6 +10369,11 @@ async function orchestrate(config, executor, watcher, targetPath, onProgress, lo
|
|
|
10495
10369
|
function formatNumber(n) {
|
|
10496
10370
|
return n.toLocaleString('en-US');
|
|
10497
10371
|
}
|
|
10372
|
+
function formatTokens(tokens) {
|
|
10373
|
+
return tokens !== undefined
|
|
10374
|
+
? formatNumber(tokens) + ' tokens'
|
|
10375
|
+
: 'unknown tokens';
|
|
10376
|
+
}
|
|
10498
10377
|
function formatSeconds(durationMs) {
|
|
10499
10378
|
const seconds = durationMs / 1000;
|
|
10500
10379
|
return Math.round(seconds).toString() + 's';
|
|
@@ -10545,17 +10424,17 @@ function formatProgressEvent(event, serverBaseUrl) {
|
|
|
10545
10424
|
}
|
|
10546
10425
|
case 'phase_complete': {
|
|
10547
10426
|
const phase = event.phase ? titleCasePhase(event.phase) : 'Phase';
|
|
10548
|
-
const
|
|
10427
|
+
const tokenStr = formatTokens(event.tokens);
|
|
10549
10428
|
const duration = event.durationMs !== undefined ? formatSeconds(event.durationMs) : '0s';
|
|
10550
|
-
return ` ✅ ${phase} complete (${
|
|
10429
|
+
return ` ✅ ${phase} complete (${tokenStr} / ${duration})`;
|
|
10551
10430
|
}
|
|
10552
10431
|
case 'synthesis_complete': {
|
|
10553
10432
|
const metaLink = buildMetaJsonLink(event.path, serverBaseUrl);
|
|
10554
|
-
const
|
|
10433
|
+
const tokenStr = formatTokens(event.tokens);
|
|
10555
10434
|
const duration = event.durationMs !== undefined
|
|
10556
10435
|
? formatSeconds(event.durationMs)
|
|
10557
10436
|
: '0.0s';
|
|
10558
|
-
return `✅ Completed: ${metaLink} (${
|
|
10437
|
+
return `✅ Completed: ${metaLink} (${tokenStr} / ${duration})`;
|
|
10559
10438
|
}
|
|
10560
10439
|
case 'error': {
|
|
10561
10440
|
const dirLink = buildDirectoryLink(event.path, serverBaseUrl);
|
|
@@ -10576,19 +10455,23 @@ class ProgressReporter {
|
|
|
10576
10455
|
this.logger = logger;
|
|
10577
10456
|
}
|
|
10578
10457
|
async report(event) {
|
|
10579
|
-
|
|
10458
|
+
// Multi-channel mode: reportTarget is the destination, reportChannel is the platform.
|
|
10459
|
+
// Legacy mode: reportChannel alone acts as the target (backward compatible).
|
|
10460
|
+
const target = this.config.reportTarget ?? this.config.reportChannel;
|
|
10580
10461
|
if (!target)
|
|
10581
10462
|
return;
|
|
10582
10463
|
const message = formatProgressEvent(event, this.config.serverBaseUrl);
|
|
10583
10464
|
const url = new URL('/tools/invoke', this.config.gatewayUrl);
|
|
10584
|
-
const
|
|
10585
|
-
|
|
10586
|
-
|
|
10587
|
-
|
|
10588
|
-
target,
|
|
10589
|
-
message,
|
|
10590
|
-
},
|
|
10465
|
+
const args = {
|
|
10466
|
+
action: 'send',
|
|
10467
|
+
target,
|
|
10468
|
+
message,
|
|
10591
10469
|
};
|
|
10470
|
+
// Include channel field only in multi-channel mode (reportTarget is set)
|
|
10471
|
+
if (this.config.reportTarget && this.config.reportChannel) {
|
|
10472
|
+
args.channel = this.config.reportChannel;
|
|
10473
|
+
}
|
|
10474
|
+
const payload = { tool: 'message', args };
|
|
10592
10475
|
try {
|
|
10593
10476
|
const res = await fetch(url, {
|
|
10594
10477
|
method: 'POST',
|
|
@@ -11193,14 +11076,19 @@ function metaExists(ownerPath) {
|
|
|
11193
11076
|
* Walk returns file paths; we need the unique set of immediate parent
|
|
11194
11077
|
* directories that could be owners.
|
|
11195
11078
|
*/
|
|
11196
|
-
function extractDirectories(filePaths) {
|
|
11079
|
+
function extractDirectories(filePaths, logger) {
|
|
11197
11080
|
const dirs = new Set();
|
|
11198
11081
|
for (const fp of filePaths) {
|
|
11199
|
-
|
|
11082
|
+
// Normalize backslash paths (Windows) to forward slashes before posix.dirname
|
|
11083
|
+
const normalized = normalizePath(fp);
|
|
11084
|
+
const dir = posix.dirname(normalized);
|
|
11200
11085
|
if (dir !== '.' && dir !== '/') {
|
|
11201
11086
|
dirs.add(dir);
|
|
11202
11087
|
}
|
|
11203
11088
|
}
|
|
11089
|
+
if (filePaths.length > 0 && dirs.size === 0) {
|
|
11090
|
+
logger?.warn({ fileCount: filePaths.length }, 'extractDirectories returned zero results despite non-empty input');
|
|
11091
|
+
}
|
|
11204
11092
|
return [...dirs];
|
|
11205
11093
|
}
|
|
11206
11094
|
/**
|
|
@@ -11218,7 +11106,7 @@ async function autoSeedPass(rules, watcher, logger) {
|
|
|
11218
11106
|
const candidates = new Map();
|
|
11219
11107
|
for (const rule of rules) {
|
|
11220
11108
|
const files = await watcher.walk([rule.match]);
|
|
11221
|
-
const dirs = extractDirectories(files);
|
|
11109
|
+
const dirs = extractDirectories(files, logger);
|
|
11222
11110
|
for (const dir of dirs) {
|
|
11223
11111
|
candidates.set(dir, {
|
|
11224
11112
|
steer: rule.steer,
|
|
@@ -11436,16 +11324,97 @@ function registerConfigRoute(app, deps) {
|
|
|
11436
11324
|
}
|
|
11437
11325
|
|
|
11438
11326
|
/**
|
|
11439
|
-
* POST /config/apply — apply a config patch
|
|
11327
|
+
* POST /config/apply — apply a config patch using the runtime config path.
|
|
11328
|
+
*
|
|
11329
|
+
* The core SDK's `createConfigApplyHandler` derives the config path from
|
|
11330
|
+
* `getComponentConfigDir()` which uses the npm global config root. This
|
|
11331
|
+
* local implementation uses the actual runtime config path instead, so
|
|
11332
|
+
* temp files are written alongside the active config file.
|
|
11440
11333
|
*
|
|
11441
11334
|
* @module routes/configApply
|
|
11442
11335
|
*/
|
|
11443
11336
|
/** Register the POST /config/apply route. */
|
|
11444
|
-
function registerConfigApplyRoute(app) {
|
|
11445
|
-
const handler = createConfigApplyHandler(metaDescriptor);
|
|
11337
|
+
function registerConfigApplyRoute(app, configPath) {
|
|
11446
11338
|
app.post('/config/apply', async (request, reply) => {
|
|
11447
|
-
|
|
11448
|
-
|
|
11339
|
+
if (!configPath) {
|
|
11340
|
+
return reply
|
|
11341
|
+
.status(500)
|
|
11342
|
+
.send({ error: 'No runtime config path available' });
|
|
11343
|
+
}
|
|
11344
|
+
// Validate request body
|
|
11345
|
+
const body = request.body;
|
|
11346
|
+
if (!body || typeof body !== 'object' || Array.isArray(body)) {
|
|
11347
|
+
return reply
|
|
11348
|
+
.status(400)
|
|
11349
|
+
.send({ error: 'Request body must be a JSON object' });
|
|
11350
|
+
}
|
|
11351
|
+
const { patch, replace } = body;
|
|
11352
|
+
if (patch === null ||
|
|
11353
|
+
patch === undefined ||
|
|
11354
|
+
typeof patch !== 'object' ||
|
|
11355
|
+
Array.isArray(patch)) {
|
|
11356
|
+
return reply
|
|
11357
|
+
.status(400)
|
|
11358
|
+
.send({ error: '`patch` must be a non-null object' });
|
|
11359
|
+
}
|
|
11360
|
+
if (replace !== undefined && typeof replace !== 'boolean') {
|
|
11361
|
+
return reply
|
|
11362
|
+
.status(400)
|
|
11363
|
+
.send({ error: '`replace` must be a boolean if provided' });
|
|
11364
|
+
}
|
|
11365
|
+
// Read existing config from the runtime config path
|
|
11366
|
+
let existing = {};
|
|
11367
|
+
try {
|
|
11368
|
+
existing = JSON.parse(readFileSync(configPath, 'utf8'));
|
|
11369
|
+
}
|
|
11370
|
+
catch (err) {
|
|
11371
|
+
if (err instanceof SyntaxError ||
|
|
11372
|
+
(err instanceof Error && err.message.includes('JSON'))) {
|
|
11373
|
+
return reply.status(400).send({
|
|
11374
|
+
error: `Existing config file contains invalid JSON: ${err.message}`,
|
|
11375
|
+
});
|
|
11376
|
+
}
|
|
11377
|
+
// File missing — start from empty
|
|
11378
|
+
}
|
|
11379
|
+
// Merge or replace
|
|
11380
|
+
const merged = replace
|
|
11381
|
+
? { ...patch }
|
|
11382
|
+
: { ...existing, ...patch };
|
|
11383
|
+
// Validate against schema
|
|
11384
|
+
const parseResult = serviceConfigSchema.safeParse(merged);
|
|
11385
|
+
if (!parseResult.success) {
|
|
11386
|
+
return reply.status(400).send({
|
|
11387
|
+
error: 'Config validation failed',
|
|
11388
|
+
issues: parseResult.error.issues,
|
|
11389
|
+
});
|
|
11390
|
+
}
|
|
11391
|
+
const validatedConfig = parseResult.data;
|
|
11392
|
+
// Write atomically — temp file lands next to the runtime config file
|
|
11393
|
+
try {
|
|
11394
|
+
const json = JSON.stringify(validatedConfig, null, 2) + '\n';
|
|
11395
|
+
atomicWrite(configPath, json);
|
|
11396
|
+
}
|
|
11397
|
+
catch (err) {
|
|
11398
|
+
const message = err instanceof Error ? err.message : String(err);
|
|
11399
|
+
return reply
|
|
11400
|
+
.status(500)
|
|
11401
|
+
.send({ error: `Failed to write config: ${message}` });
|
|
11402
|
+
}
|
|
11403
|
+
// Apply hot-reload callback
|
|
11404
|
+
try {
|
|
11405
|
+
applyHotReloadedConfig(validatedConfig);
|
|
11406
|
+
}
|
|
11407
|
+
catch (err) {
|
|
11408
|
+
const message = err instanceof Error ? err.message : String(err);
|
|
11409
|
+
return reply.status(200).send({
|
|
11410
|
+
applied: true,
|
|
11411
|
+
warning: `Config written but callback failed: ${message}`,
|
|
11412
|
+
restartRequired: RESTART_REQUIRED_FIELDS,
|
|
11413
|
+
});
|
|
11414
|
+
}
|
|
11415
|
+
return reply.status(200).send({
|
|
11416
|
+
applied: true,
|
|
11417
|
+
});
|
|
11449
11418
|
});
|
|
11450
11419
|
}
|
|
11451
11420
|
|
|
@@ -11851,6 +11820,16 @@ async function checkWatcher(url) {
|
|
|
11851
11820
|
return { url, status: 'unreachable', checkedAt };
|
|
11852
11821
|
}
|
|
11853
11822
|
}
|
|
11823
|
+
/** Derive service-specific state from current activity and lifecycle. */
|
|
11824
|
+
function deriveServiceState(deps) {
|
|
11825
|
+
if (deps.shuttingDown)
|
|
11826
|
+
return 'stopping';
|
|
11827
|
+
if (deps.queue.current)
|
|
11828
|
+
return 'synthesizing';
|
|
11829
|
+
if (deps.queue.depth > 0)
|
|
11830
|
+
return 'waiting';
|
|
11831
|
+
return 'idle';
|
|
11832
|
+
}
|
|
11854
11833
|
function registerStatusRoute(app, deps) {
|
|
11855
11834
|
const statusHandler = createStatusHandler({
|
|
11856
11835
|
name: SERVICE_NAME,
|
|
@@ -11863,6 +11842,7 @@ function registerStatusRoute(app, deps) {
|
|
|
11863
11842
|
checkDependency(config.gatewayUrl, '/status'),
|
|
11864
11843
|
]);
|
|
11865
11844
|
return {
|
|
11845
|
+
serviceState: deriveServiceState(deps),
|
|
11866
11846
|
currentTarget: queue.current?.path ?? null,
|
|
11867
11847
|
queue: queue.getState(),
|
|
11868
11848
|
stats: {
|
|
@@ -12006,7 +11986,7 @@ function registerRoutes(app, deps) {
|
|
|
12006
11986
|
registerSeedRoute(app, deps);
|
|
12007
11987
|
registerUnlockRoute(app, deps);
|
|
12008
11988
|
registerConfigRoute(app, deps);
|
|
12009
|
-
registerConfigApplyRoute(app);
|
|
11989
|
+
registerConfigApplyRoute(app, deps.configPath);
|
|
12010
11990
|
registerQueueRoutes(app, deps);
|
|
12011
11991
|
}
|
|
12012
11992
|
|
|
@@ -12210,6 +12190,7 @@ async function startService(config, configPath) {
|
|
|
12210
12190
|
scheduler,
|
|
12211
12191
|
stats,
|
|
12212
12192
|
executor,
|
|
12193
|
+
configPath,
|
|
12213
12194
|
};
|
|
12214
12195
|
registerConfigHotReloadRuntime({
|
|
12215
12196
|
config,
|
|
@@ -12245,9 +12226,17 @@ async function startService(config, configPath) {
|
|
|
12245
12226
|
try {
|
|
12246
12227
|
const results = await orchestrate(config, executor, watcher, path, async (evt) => {
|
|
12247
12228
|
// Track token stats from phase completions
|
|
12248
|
-
if (evt.type === 'phase_complete'
|
|
12249
|
-
|
|
12250
|
-
|
|
12229
|
+
if (evt.type === 'phase_complete') {
|
|
12230
|
+
if (evt.tokens !== undefined) {
|
|
12231
|
+
stats.totalTokens += evt.tokens;
|
|
12232
|
+
if (cycleTokens !== undefined) {
|
|
12233
|
+
cycleTokens += evt.tokens;
|
|
12234
|
+
}
|
|
12235
|
+
}
|
|
12236
|
+
else {
|
|
12237
|
+
cycleTokens = undefined;
|
|
12238
|
+
logger.warn({ path: ownerPath, phase: evt.phase }, 'Token count unavailable (session lookup may have timed out)');
|
|
12239
|
+
}
|
|
12251
12240
|
}
|
|
12252
12241
|
await progress.report(evt);
|
|
12253
12242
|
}, logger);
|