fraim-hub 2.0.310 → 2.0.312
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/src/ai-hub/desktop-main.js +101 -5
- package/dist/src/ai-hub/devtools-window.js +17 -0
- package/dist/src/ai-hub/hosts.js +71 -12
- package/dist/src/ai-hub/hub-app-materializer.js +33 -3
- package/dist/src/ai-hub/hub-main-diagnostics.js +278 -0
- package/dist/src/ai-hub/restart-recovery-policy.js +11 -3
- package/dist/src/ai-hub/server.js +68 -16
- package/dist/src/cli/setup/ide-invocation-surfaces.js +1 -1
- package/dist/src/cli/setup/user-level-sync.js +4 -0
- package/dist/src/config/persona-capability-bundles.js +12 -1
- package/package.json +2 -2
- package/public/ai-hub/index.html +1 -1
- package/public/ai-hub/script.js +482 -120
- package/public/ai-hub/styles.css +29 -0
|
@@ -0,0 +1,278 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
var __importDefault = (this && this.__importDefault) || function (mod) {
|
|
3
|
+
return (mod && mod.__esModule) ? mod : { "default": mod };
|
|
4
|
+
};
|
|
5
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
6
|
+
exports.HubMainDiagnostics = void 0;
|
|
7
|
+
exports.getHubMainDiagnostics = getHubMainDiagnostics;
|
|
8
|
+
exports.resetHubMainDiagnosticsForTest = resetHubMainDiagnosticsForTest;
|
|
9
|
+
exports.recordHubMainDiagnostic = recordHubMainDiagnostic;
|
|
10
|
+
exports.getHubMainDiagnosticsSnapshot = getHubMainDiagnosticsSnapshot;
|
|
11
|
+
exports.installHubMainConsoleTee = installHubMainConsoleTee;
|
|
12
|
+
const node_fs_1 = __importDefault(require("node:fs"));
|
|
13
|
+
const node_os_1 = __importDefault(require("node:os"));
|
|
14
|
+
const node_path_1 = __importDefault(require("node:path"));
|
|
15
|
+
const node_util_1 = __importDefault(require("node:util"));
|
|
16
|
+
const project_fraim_paths_1 = require("../core/utils/project-fraim-paths");
|
|
17
|
+
const version_utils_1 = require("../cli/utils/version-utils");
|
|
18
|
+
const DEFAULT_MAX_EVENTS = 100;
|
|
19
|
+
const DEFAULT_MAX_CURRENT_BYTES = 1024 * 1024;
|
|
20
|
+
const DEFAULT_MAX_RETAINED_CHUNKS = 20;
|
|
21
|
+
const DEFAULT_MAX_RETAINED_BYTES = 50 * 1024 * 1024;
|
|
22
|
+
const MAX_STRING_LENGTH = 1000;
|
|
23
|
+
const SENSITIVE_KEY_PATTERN = /token|secret|password|api[-_]?key|credential|authorization/i;
|
|
24
|
+
const EMAIL_PATTERN = /\b[A-Z0-9._%+-]+@[A-Z0-9.-]+\.[A-Z]{2,}\b/gi;
|
|
25
|
+
const SENSITIVE_TEXT_PATTERNS = [
|
|
26
|
+
/\bsk-ant-[A-Za-z0-9_-]{20,}\b/g,
|
|
27
|
+
/\bsk-[A-Za-z0-9]{48,}\b/g,
|
|
28
|
+
/\bgh[pousr]_[A-Za-z0-9]{36,}\b/g,
|
|
29
|
+
/\bAKIA[0-9A-Z]{16}\b/g,
|
|
30
|
+
/\bSG\.[A-Za-z0-9_-]{22}\.[A-Za-z0-9_-]{43}\b/g,
|
|
31
|
+
/\bsk_live_[A-Za-z0-9]{24,}\b/g,
|
|
32
|
+
/(?:authorization|api[-_]?key|token|secret|password|credential)(["'\s:=]+)([^"',\s]{8,})/gi,
|
|
33
|
+
];
|
|
34
|
+
function resolveDefaultLogDir() {
|
|
35
|
+
try {
|
|
36
|
+
return node_path_1.default.join((0, project_fraim_paths_1.getUserFraimDirPath)(), 'logs');
|
|
37
|
+
}
|
|
38
|
+
catch {
|
|
39
|
+
return node_path_1.default.join(node_os_1.default.homedir(), '.fraim', 'logs');
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
function redactSensitiveText(value) {
|
|
43
|
+
let redacted = value.replace(EMAIL_PATTERN, '[redacted-email]');
|
|
44
|
+
for (const pattern of SENSITIVE_TEXT_PATTERNS) {
|
|
45
|
+
redacted = redacted.replace(pattern, (match, separator) => {
|
|
46
|
+
if (typeof separator === 'string' && match.includes(separator)) {
|
|
47
|
+
return match.replace(new RegExp(`${separator.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')}.*$`), `${separator}[redacted]`);
|
|
48
|
+
}
|
|
49
|
+
return '[redacted-secret]';
|
|
50
|
+
});
|
|
51
|
+
}
|
|
52
|
+
return redacted;
|
|
53
|
+
}
|
|
54
|
+
function boundedString(value, max = MAX_STRING_LENGTH) {
|
|
55
|
+
const redacted = redactSensitiveText(value);
|
|
56
|
+
return redacted.length > max ? `${redacted.slice(0, max)}...[truncated ${redacted.length - max} chars]` : redacted;
|
|
57
|
+
}
|
|
58
|
+
function safeFileSegment(value) {
|
|
59
|
+
const sanitized = value.replace(/[^a-z0-9._-]+/gi, '-').replace(/^-+|-+$/g, '');
|
|
60
|
+
return sanitized || 'hub';
|
|
61
|
+
}
|
|
62
|
+
function sanitizeDetail(value, depth = 0) {
|
|
63
|
+
if (value == null || typeof value === 'boolean' || typeof value === 'number')
|
|
64
|
+
return value;
|
|
65
|
+
if (typeof value === 'string')
|
|
66
|
+
return boundedString(value);
|
|
67
|
+
if (value instanceof Error)
|
|
68
|
+
return { name: value.name, message: boundedString(value.message) };
|
|
69
|
+
if (depth >= 4)
|
|
70
|
+
return '[truncated-depth]';
|
|
71
|
+
if (Array.isArray(value))
|
|
72
|
+
return value.slice(0, 20).map((item) => sanitizeDetail(item, depth + 1));
|
|
73
|
+
if (typeof value === 'object') {
|
|
74
|
+
const out = {};
|
|
75
|
+
for (const [key, item] of Object.entries(value).slice(0, 40)) {
|
|
76
|
+
out[key] = SENSITIVE_KEY_PATTERN.test(key) ? '[redacted]' : sanitizeDetail(item, depth + 1);
|
|
77
|
+
}
|
|
78
|
+
return out;
|
|
79
|
+
}
|
|
80
|
+
return boundedString(String(value));
|
|
81
|
+
}
|
|
82
|
+
function toDetails(details) {
|
|
83
|
+
if (!details)
|
|
84
|
+
return undefined;
|
|
85
|
+
const sanitized = sanitizeDetail(details);
|
|
86
|
+
return sanitized && typeof sanitized === 'object' && !Array.isArray(sanitized)
|
|
87
|
+
? sanitized
|
|
88
|
+
: { value: sanitized };
|
|
89
|
+
}
|
|
90
|
+
function safeStat(filePath) {
|
|
91
|
+
try {
|
|
92
|
+
return node_fs_1.default.statSync(filePath);
|
|
93
|
+
}
|
|
94
|
+
catch {
|
|
95
|
+
return null;
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
function buildVersions() {
|
|
99
|
+
return {
|
|
100
|
+
node: process.versions.node,
|
|
101
|
+
...(process.versions.electron ? { electron: process.versions.electron } : {}),
|
|
102
|
+
...(process.versions.chrome ? { chrome: process.versions.chrome } : {}),
|
|
103
|
+
};
|
|
104
|
+
}
|
|
105
|
+
class HubMainDiagnostics {
|
|
106
|
+
constructor(options = {}) {
|
|
107
|
+
this.events = [];
|
|
108
|
+
this.rotationSequence = 0;
|
|
109
|
+
this.lastWriteError = null;
|
|
110
|
+
this.lastRenderProcessGone = null;
|
|
111
|
+
this.lastChildProcessGone = null;
|
|
112
|
+
this.lastUnresponsiveAt = null;
|
|
113
|
+
this.lastResponsiveAt = null;
|
|
114
|
+
this.runtimeId = options.runtimeId || process.env.FRAIM_HUB_RUNTIME_ID || 'hub';
|
|
115
|
+
this.now = options.now || (() => new Date());
|
|
116
|
+
this.maxEvents = options.maxEvents || DEFAULT_MAX_EVENTS;
|
|
117
|
+
this.maxCurrentBytes = options.maxCurrentBytes || DEFAULT_MAX_CURRENT_BYTES;
|
|
118
|
+
this.maxRetainedChunks = options.maxRetainedChunks || DEFAULT_MAX_RETAINED_CHUNKS;
|
|
119
|
+
this.maxRetainedBytes = options.maxRetainedBytes || DEFAULT_MAX_RETAINED_BYTES;
|
|
120
|
+
this.logDir = node_path_1.default.resolve(options.logDir || resolveDefaultLogDir());
|
|
121
|
+
this.logPath = node_path_1.default.join(this.logDir, 'hub-main.log');
|
|
122
|
+
}
|
|
123
|
+
record(type, details) {
|
|
124
|
+
const event = {
|
|
125
|
+
type,
|
|
126
|
+
timestamp: this.now().toISOString(),
|
|
127
|
+
runtimeId: this.runtimeId,
|
|
128
|
+
pid: process.pid,
|
|
129
|
+
appVersion: (0, version_utils_1.getFraimVersion)(),
|
|
130
|
+
...(details ? { details: toDetails(details) } : {}),
|
|
131
|
+
};
|
|
132
|
+
this.events.push(event);
|
|
133
|
+
if (this.events.length > this.maxEvents)
|
|
134
|
+
this.events.splice(0, this.events.length - this.maxEvents);
|
|
135
|
+
if (type === 'webcontents.render_process_gone')
|
|
136
|
+
this.lastRenderProcessGone = event;
|
|
137
|
+
if (type === 'app.child_process_gone')
|
|
138
|
+
this.lastChildProcessGone = event;
|
|
139
|
+
if (type === 'webcontents.unresponsive')
|
|
140
|
+
this.lastUnresponsiveAt = event.timestamp;
|
|
141
|
+
if (type === 'webcontents.responsive')
|
|
142
|
+
this.lastResponsiveAt = event.timestamp;
|
|
143
|
+
this.persist(event);
|
|
144
|
+
return event;
|
|
145
|
+
}
|
|
146
|
+
snapshot(window = null) {
|
|
147
|
+
const current = safeStat(this.logPath);
|
|
148
|
+
return {
|
|
149
|
+
ok: true,
|
|
150
|
+
process: {
|
|
151
|
+
pid: process.pid,
|
|
152
|
+
platform: process.platform,
|
|
153
|
+
versions: buildVersions(),
|
|
154
|
+
uptimeSeconds: Math.round(process.uptime()),
|
|
155
|
+
},
|
|
156
|
+
log: {
|
|
157
|
+
path: this.logPath,
|
|
158
|
+
directory: this.logDir,
|
|
159
|
+
exists: Boolean(current),
|
|
160
|
+
bytes: current?.size || 0,
|
|
161
|
+
maxCurrentBytes: this.maxCurrentBytes,
|
|
162
|
+
retainedChunks: this.retainedChunks(),
|
|
163
|
+
lastWriteError: this.lastWriteError,
|
|
164
|
+
},
|
|
165
|
+
window,
|
|
166
|
+
lastEvents: [...this.events],
|
|
167
|
+
lastRenderProcessGone: this.lastRenderProcessGone,
|
|
168
|
+
lastChildProcessGone: this.lastChildProcessGone,
|
|
169
|
+
lastUnresponsiveAt: this.lastUnresponsiveAt,
|
|
170
|
+
lastResponsiveAt: this.lastResponsiveAt,
|
|
171
|
+
capturedAt: this.now().toISOString(),
|
|
172
|
+
};
|
|
173
|
+
}
|
|
174
|
+
persist(event) {
|
|
175
|
+
try {
|
|
176
|
+
node_fs_1.default.mkdirSync(this.logDir, { recursive: true });
|
|
177
|
+
const line = `${JSON.stringify(event)}\n`;
|
|
178
|
+
const current = safeStat(this.logPath);
|
|
179
|
+
if (current && current.size > 0 && current.size + Buffer.byteLength(line, 'utf8') > this.maxCurrentBytes) {
|
|
180
|
+
this.rotateCurrentLog();
|
|
181
|
+
}
|
|
182
|
+
node_fs_1.default.appendFileSync(this.logPath, line, 'utf8');
|
|
183
|
+
this.lastWriteError = null;
|
|
184
|
+
}
|
|
185
|
+
catch (error) {
|
|
186
|
+
this.lastWriteError = error instanceof Error ? boundedString(error.message, 500) : boundedString(String(error), 500);
|
|
187
|
+
}
|
|
188
|
+
}
|
|
189
|
+
rotateCurrentLog() {
|
|
190
|
+
if (!node_fs_1.default.existsSync(this.logPath))
|
|
191
|
+
return;
|
|
192
|
+
const timestamp = this.now().toISOString().replace(/[-:]/g, '').replace(/\.\d{3}Z$/, 'Z');
|
|
193
|
+
const chunkPath = node_path_1.default.join(this.logDir, `hub-main-${timestamp}-${safeFileSegment(this.runtimeId)}-${String(this.rotationSequence).padStart(4, '0')}.log`);
|
|
194
|
+
this.rotationSequence += 1;
|
|
195
|
+
node_fs_1.default.renameSync(this.logPath, chunkPath);
|
|
196
|
+
this.pruneRetainedChunks();
|
|
197
|
+
}
|
|
198
|
+
retainedChunks() {
|
|
199
|
+
try {
|
|
200
|
+
return node_fs_1.default.readdirSync(this.logDir)
|
|
201
|
+
.filter((name) => /^hub-main-.+\.log$/.test(name))
|
|
202
|
+
.map((name) => {
|
|
203
|
+
const filePath = node_path_1.default.join(this.logDir, name);
|
|
204
|
+
const stat = node_fs_1.default.statSync(filePath);
|
|
205
|
+
return { path: filePath, bytes: stat.size, modifiedAt: stat.mtime.toISOString() };
|
|
206
|
+
})
|
|
207
|
+
.sort((a, b) => b.modifiedAt.localeCompare(a.modifiedAt));
|
|
208
|
+
}
|
|
209
|
+
catch {
|
|
210
|
+
return [];
|
|
211
|
+
}
|
|
212
|
+
}
|
|
213
|
+
pruneRetainedChunks() {
|
|
214
|
+
const chunks = this.retainedChunks();
|
|
215
|
+
let retainedBytes = 0;
|
|
216
|
+
chunks.forEach((chunk, index) => {
|
|
217
|
+
retainedBytes += chunk.bytes;
|
|
218
|
+
if (index < this.maxRetainedChunks && retainedBytes <= this.maxRetainedBytes)
|
|
219
|
+
return;
|
|
220
|
+
try {
|
|
221
|
+
node_fs_1.default.unlinkSync(chunk.path);
|
|
222
|
+
}
|
|
223
|
+
catch {
|
|
224
|
+
// Best effort pruning; endpoint still reports remaining chunks.
|
|
225
|
+
}
|
|
226
|
+
});
|
|
227
|
+
}
|
|
228
|
+
}
|
|
229
|
+
exports.HubMainDiagnostics = HubMainDiagnostics;
|
|
230
|
+
let singleton = null;
|
|
231
|
+
let consoleTeeInstalled = false;
|
|
232
|
+
let inConsoleTee = false;
|
|
233
|
+
function getHubMainDiagnostics() {
|
|
234
|
+
if (!singleton)
|
|
235
|
+
singleton = new HubMainDiagnostics();
|
|
236
|
+
return singleton;
|
|
237
|
+
}
|
|
238
|
+
function resetHubMainDiagnosticsForTest(options = {}) {
|
|
239
|
+
singleton = new HubMainDiagnostics(options);
|
|
240
|
+
return singleton;
|
|
241
|
+
}
|
|
242
|
+
function recordHubMainDiagnostic(type, details) {
|
|
243
|
+
return getHubMainDiagnostics().record(type, details);
|
|
244
|
+
}
|
|
245
|
+
function getHubMainDiagnosticsSnapshot(window = null) {
|
|
246
|
+
return getHubMainDiagnostics().snapshot(window);
|
|
247
|
+
}
|
|
248
|
+
function installHubMainConsoleTee() {
|
|
249
|
+
if (consoleTeeInstalled)
|
|
250
|
+
return;
|
|
251
|
+
consoleTeeInstalled = true;
|
|
252
|
+
const originals = {
|
|
253
|
+
log: console.log.bind(console),
|
|
254
|
+
warn: console.warn.bind(console),
|
|
255
|
+
error: console.error.bind(console),
|
|
256
|
+
};
|
|
257
|
+
const wrap = (level, type) => {
|
|
258
|
+
return (...args) => {
|
|
259
|
+
originals[level](...args);
|
|
260
|
+
if (inConsoleTee)
|
|
261
|
+
return;
|
|
262
|
+
inConsoleTee = true;
|
|
263
|
+
try {
|
|
264
|
+
getHubMainDiagnostics().record(type, { message: node_util_1.default.format(...args) });
|
|
265
|
+
}
|
|
266
|
+
catch (error) {
|
|
267
|
+
originals.warn('[fraim] hub main diagnostic console tee failed:', error);
|
|
268
|
+
}
|
|
269
|
+
finally {
|
|
270
|
+
inConsoleTee = false;
|
|
271
|
+
}
|
|
272
|
+
};
|
|
273
|
+
};
|
|
274
|
+
console.log = wrap('log', 'log.info');
|
|
275
|
+
console.warn = wrap('warn', 'log.warn');
|
|
276
|
+
console.error = wrap('error', 'log.error');
|
|
277
|
+
getHubMainDiagnostics().record('hub.log_tee_installed');
|
|
278
|
+
}
|
|
@@ -67,6 +67,17 @@ class RestartRecoveryPolicy {
|
|
|
67
67
|
if (['stopped', 'done', 'awaiting_review', 'awaiting_user'].includes(pauseReason)) {
|
|
68
68
|
return { action: 'skip', reason: `pause_${pauseReason}` };
|
|
69
69
|
}
|
|
70
|
+
// Issue #1634: `activeRunExists` is direct, in-process proof this run is not
|
|
71
|
+
// orphaned — it must outrank every "does this LOOK orphaned" heuristic below.
|
|
72
|
+
// A freshly-fired scheduled/webhook run legitimately has no sessionId yet (the
|
|
73
|
+
// host CLI hasn't reported one) for its entire lifetime if it is short-lived;
|
|
74
|
+
// checking `missing_session` first force-failed a run that was actively
|
|
75
|
+
// executing in this same process, the instant its status became observably
|
|
76
|
+
// 'running' (issue #1634's own fix for that visibility gap is what turned this
|
|
77
|
+
// from a narrow, mostly-unreachable window into a routinely-hit one).
|
|
78
|
+
if (options.activeRunExists) {
|
|
79
|
+
return { action: 'defer', reason: 'active_run_exists' };
|
|
80
|
+
}
|
|
70
81
|
if (!conversation.sessionId || typeof conversation.sessionId !== 'string' || !conversation.sessionId.trim()) {
|
|
71
82
|
return { action: 'skip', reason: 'missing_session' };
|
|
72
83
|
}
|
|
@@ -81,9 +92,6 @@ class RestartRecoveryPolicy {
|
|
|
81
92
|
if (conversation.reviewHandoff?.reviewRequired) {
|
|
82
93
|
return { action: 'skip', reason: 'awaiting_review' };
|
|
83
94
|
}
|
|
84
|
-
if (options.activeRunExists) {
|
|
85
|
-
return { action: 'defer', reason: 'active_run_exists' };
|
|
86
|
-
}
|
|
87
95
|
// Issue #1159: `activeRunExists` only sees this process's run registry, so it
|
|
88
96
|
// cannot tell that a *different* live Hub owns this run. Two Hubs on one
|
|
89
97
|
// machine is the normal case here: the desktop Hub plus any Hub a job starts
|
|
@@ -69,6 +69,7 @@ const manager_turns_1 = require("./manager-turns");
|
|
|
69
69
|
const preferences_1 = require("./preferences");
|
|
70
70
|
const conversation_store_1 = require("./conversation-store");
|
|
71
71
|
const raw_event_log_store_1 = require("./raw-event-log-store");
|
|
72
|
+
const hub_main_diagnostics_1 = require("./hub-main-diagnostics");
|
|
72
73
|
const run_working_directory_1 = require("./run-working-directory");
|
|
73
74
|
const conversation_search_1 = require("./conversation-search");
|
|
74
75
|
const conversation_search_index_1 = require("./conversation-search-index");
|
|
@@ -3005,6 +3006,7 @@ class AiHubServer {
|
|
|
3005
3006
|
userDataDir: process.env.FRAIM_BROWSER_USER_DATA_DIR || undefined,
|
|
3006
3007
|
explicitPath: process.env.FRAIM_BROWSER_PATH || undefined,
|
|
3007
3008
|
});
|
|
3009
|
+
this.hubMainDiagnostics = options.hubMainDiagnostics || { snapshot: () => (0, hub_main_diagnostics_1.getHubMainDiagnosticsSnapshot)(null) };
|
|
3008
3010
|
this.hostRuntime = options.hostRuntime || (process.env.FRAIM_AI_HUB_FAKE_HOST === '1' ? new hosts_1.FakeHostRuntime() : new hosts_1.CliHostRuntime());
|
|
3009
3011
|
// Issue #701 / #749: the AI Hub is a loopback companion that runs on user machines and
|
|
3010
3012
|
// never touches a database. Persona and manager-team state resolve from the hosted server
|
|
@@ -5008,6 +5010,9 @@ class AiHubServer {
|
|
|
5008
5010
|
'from the Hub job catalog, not the per-run tracking UUID returned by get_fraim_job\'s "Job ID" field.',
|
|
5009
5011
|
};
|
|
5010
5012
|
}
|
|
5013
|
+
isAdhocPromptJobId(jobId) {
|
|
5014
|
+
return typeof jobId === 'string' && jobId.trim() === 'adhoc-prompt';
|
|
5015
|
+
}
|
|
5011
5016
|
applySeekMentoringSignalToRun(run, signal) {
|
|
5012
5017
|
// Issue #732: promote using the stable jobName slug, not the per-call UUID
|
|
5013
5018
|
// jobId (resolveHubJob would never match a UUID, leaving a freeform run
|
|
@@ -6122,6 +6127,17 @@ class AiHubServer {
|
|
|
6122
6127
|
this.app.get('/api/ai-hub/pid', (_req, res) => {
|
|
6123
6128
|
return res.json({ pid: process.pid });
|
|
6124
6129
|
});
|
|
6130
|
+
this.app.get('/api/ai-hub/debug/window-state', (_req, res) => {
|
|
6131
|
+
try {
|
|
6132
|
+
(0, hub_main_diagnostics_1.recordHubMainDiagnostic)('debug.window_state_requested');
|
|
6133
|
+
return res.json(this.hubMainDiagnostics.snapshot());
|
|
6134
|
+
}
|
|
6135
|
+
catch (error) {
|
|
6136
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
6137
|
+
(0, hub_main_diagnostics_1.recordHubMainDiagnostic)('debug.window_state_failed', { error: message });
|
|
6138
|
+
return res.status(500).json({ error: message });
|
|
6139
|
+
}
|
|
6140
|
+
});
|
|
6125
6141
|
// Issue #1415/#1379: the update badge's click handler POSTs here instead of just telling the
|
|
6126
6142
|
// user to quit and relaunch by hand. `restartToLatest` (the Electron shell's
|
|
6127
6143
|
// restartToLatestNow) responds before it actually tears the process down, so the client can
|
|
@@ -6485,10 +6501,25 @@ class AiHubServer {
|
|
|
6485
6501
|
return res.status(404).json({ error: 'Configured agent not found or cannot be deleted.' });
|
|
6486
6502
|
return res.json({ ok: true });
|
|
6487
6503
|
});
|
|
6488
|
-
this.app.post('/api/ai-hub/configured-agents/:id/check', (req, res) => {
|
|
6504
|
+
this.app.post('/api/ai-hub/configured-agents/:id/check', async (req, res) => {
|
|
6489
6505
|
if (!this.requireTrustedHubOrigin(req, res))
|
|
6490
6506
|
return;
|
|
6491
|
-
|
|
6507
|
+
// Issue #1618: this is the manager's explicit "check now" action, so it must reflect
|
|
6508
|
+
// reality immediately rather than serving the up-to-5-minute-stale detection cache -
|
|
6509
|
+
// the same reasoning installAgentAndRefreshDetection() already applies after install
|
|
6510
|
+
// (see the comment above that function, server.ts:~2430).
|
|
6511
|
+
(0, hosts_1.invalidateEmployeeDetectionCache)();
|
|
6512
|
+
// Security review (implement-security-review, issue #1618): prefer the non-blocking
|
|
6513
|
+
// parallel probe here for the same reason bootstrapResponse() does (server.ts:~3939) -
|
|
6514
|
+
// invalidating the cache on every click means this route now pays the full re-probe
|
|
6515
|
+
// cost every time instead of at most once per TTL window, so the synchronous form
|
|
6516
|
+
// would let a manager repeatedly clicking Check block the single-threaded Hub event
|
|
6517
|
+
// loop for ~1-3s per click, reintroducing the exact issue-#1010 unresponsiveness this
|
|
6518
|
+
// route previously avoided only by accident (via the cache). Falls back to the sync
|
|
6519
|
+
// form for HostRuntime stubs that do not implement the async variant.
|
|
6520
|
+
const employees = this.hostRuntime.detectEmployeesAsync
|
|
6521
|
+
? await this.hostRuntime.detectEmployeesAsync()
|
|
6522
|
+
: this.hostRuntime.detectEmployees();
|
|
6492
6523
|
const agent = this.configuredAgentsForCurrentMachine(employees).find((entry) => entry.id === req.params.id);
|
|
6493
6524
|
if (!agent)
|
|
6494
6525
|
return res.status(404).json({ error: 'Configured agent not found.' });
|
|
@@ -7496,9 +7527,15 @@ class AiHubServer {
|
|
|
7496
7527
|
// Issue #1477 R1/R2: reject a jobId that doesn't resolve to a known catalog entry
|
|
7497
7528
|
// (e.g. the per-run tracking UUID from get_fraim_job, mistaken for this field in
|
|
7498
7529
|
// the reported repro) before the deployment is ever persisted.
|
|
7499
|
-
|
|
7530
|
+
const isAdhocPrompt = this.isAdhocPromptJobId(jobId);
|
|
7531
|
+
if (!isAdhocPrompt && !this.resolveHubJob(resolvedProjectPath, jobId)) {
|
|
7500
7532
|
return res.status(400).json(this.invalidScheduleJobIdError(jobId));
|
|
7501
7533
|
}
|
|
7534
|
+
// Issue #1610 R3: adhoc-prompt has no fixed task — the instructions field is its
|
|
7535
|
+
// sole driver. An adhoc-prompt schedule with no instructions would silently do nothing.
|
|
7536
|
+
if (isAdhocPrompt && !(typeof instructions === 'string' && instructions.trim())) {
|
|
7537
|
+
return res.status(400).json({ error: 'Ad-hoc assignments require a non-empty instructions field.' });
|
|
7538
|
+
}
|
|
7502
7539
|
const normalizedConversationId = typeof conversationId === 'string' && conversationId.trim()
|
|
7503
7540
|
? conversationId.trim()
|
|
7504
7541
|
: undefined;
|
|
@@ -7598,9 +7635,19 @@ class AiHubServer {
|
|
|
7598
7635
|
return res.status(404).json({ error: 'Deployment not found.' });
|
|
7599
7636
|
// Issue #1477 R3: same catalog validation as create, applied only when jobId is
|
|
7600
7637
|
// actually part of this update.
|
|
7601
|
-
if (jobId !== undefined && !this.resolveHubJob(resolvedProjectPath !== undefined ? resolvedProjectPath : existing.projectPath, jobId)) {
|
|
7638
|
+
if (jobId !== undefined && !this.isAdhocPromptJobId(jobId) && !this.resolveHubJob(resolvedProjectPath !== undefined ? resolvedProjectPath : existing.projectPath, jobId)) {
|
|
7602
7639
|
return res.status(400).json(this.invalidScheduleJobIdError(jobId));
|
|
7603
7640
|
}
|
|
7641
|
+
// Issue #1610 R4: guard the same invariant as POST — an adhoc-prompt deployment
|
|
7642
|
+
// must always have non-empty instructions. Compute the effective post-update values
|
|
7643
|
+
// before writing so we can reject before any state change.
|
|
7644
|
+
const effectiveJobId = jobId !== undefined ? jobId : existing.jobId;
|
|
7645
|
+
const effectiveInstructions = instructions !== undefined
|
|
7646
|
+
? (typeof instructions === 'string' ? instructions.trim() : undefined)
|
|
7647
|
+
: existing.instructions;
|
|
7648
|
+
if (effectiveJobId === 'adhoc-prompt' && !effectiveInstructions) {
|
|
7649
|
+
return res.status(400).json({ error: 'Ad-hoc assignments require a non-empty instructions field.' });
|
|
7650
|
+
}
|
|
7604
7651
|
const nextHostId = hostId !== undefined && validHosts.includes(hostId) ? hostId : existing.hostId;
|
|
7605
7652
|
const nextConfiguredAgentId = configuredAgentId !== undefined
|
|
7606
7653
|
? (typeof configuredAgentId === 'string' && configuredAgentId.trim() ? configuredAgentId.trim() : undefined)
|
|
@@ -8354,6 +8401,13 @@ class AiHubServer {
|
|
|
8354
8401
|
// can call runRegistry.update without "Run not found" throws.
|
|
8355
8402
|
this.runRegistry.create(run, {});
|
|
8356
8403
|
this.deploymentStore.update(deployment.id, (d) => { d.activeRunId = run.id; });
|
|
8404
|
+
// Issue #1634 (Defect B): persist status: 'running' synchronously, before the
|
|
8405
|
+
// host process is launched. Without this, the persisted conversation is only
|
|
8406
|
+
// ever written on the first stream event (onEvent) or at exit (onExit) — a
|
|
8407
|
+
// job fast enough to finish before its first stream event lands can go
|
|
8408
|
+
// straight from one 'completed' state to the next, with 'running' never
|
|
8409
|
+
// observably written for any poll to find.
|
|
8410
|
+
this.persistRunConversation(run, run.conversationId || run.id);
|
|
8357
8411
|
const handlers = {
|
|
8358
8412
|
onEvent: (event, channel) => {
|
|
8359
8413
|
this.runRegistry.update(run.id, (current) => {
|
|
@@ -8376,19 +8430,17 @@ class AiHubServer {
|
|
|
8376
8430
|
if (updated)
|
|
8377
8431
|
this.scheduleRunConversationPersistence(updated, updated.conversationId || updated.id);
|
|
8378
8432
|
},
|
|
8379
|
-
|
|
8380
|
-
|
|
8381
|
-
|
|
8382
|
-
|
|
8383
|
-
|
|
8384
|
-
|
|
8385
|
-
|
|
8386
|
-
|
|
8387
|
-
|
|
8388
|
-
this.persistRunConversation(updated, updated.conversationId || updated.id);
|
|
8433
|
+
// Issue #1634 (Defect A): route through the shared handleRunExit()/classifyExit()
|
|
8434
|
+
// chokepoint (issue #904), matching every other run-exit call site in this file
|
|
8435
|
+
// (server.ts:4678, 4992, 7396, 7632, 7726, 9315 as of this change). The previous
|
|
8436
|
+
// bespoke handler below set status/exitCode directly and never called
|
|
8437
|
+
// classifyExit(), so a deployment-triggered exit's pauseReason was never computed
|
|
8438
|
+
// — the recurringPark latch set mid-run (via the shared onEvent -> recordHostEvent
|
|
8439
|
+
// path above) was silently discarded, and a compaction/background-task exit was
|
|
8440
|
+
// force-terminated instead of auto-continuing like a manager-started run.
|
|
8441
|
+
onExit: (exitCode) => this.handleRunExit(run.id, exitCode, () => {
|
|
8389
8442
|
this.deploymentStore.update(deployment.id, (d) => { d.activeRunId = undefined; });
|
|
8390
|
-
|
|
8391
|
-
},
|
|
8443
|
+
}),
|
|
8392
8444
|
};
|
|
8393
8445
|
const childLaunchContext = this.withHubRunIdEnv(launchContext, run.id);
|
|
8394
8446
|
const child = existingHostSession
|
|
@@ -72,7 +72,7 @@ ${buildDeferredToolBootstrapSection(profile)}1. **Confirm FRAIM activation**:
|
|
|
72
72
|
If local FRAIM job stubs are present in the workspace, inspect those first and match the request locally. Also inspect \`fraim/personalized-employee/jobs/\` for local overrides or repo-specific jobs. If local files are missing or you cannot inspect workspace files, call \`list_fraim_jobs()\` to view the full catalog, including any proxy-discoverable personalized jobs.
|
|
73
73
|
|
|
74
74
|
3. **Find the match**:
|
|
75
|
-
If the user names an exact FRAIM job, call \`get_fraim_job({ job: "<job-name>" })\` directly. Otherwise, match the user's request to a FRAIM job from the local stub catalog, \`fraim/personalized-employee/jobs/\`, or the full \`list_fraim_jobs()\` response. If no exact or high-confidence job match exists,
|
|
75
|
+
If the user names an exact FRAIM job, call \`get_fraim_job({ job: "<job-name>" })\` directly. Otherwise, match the user's request to a FRAIM job from the local stub catalog, \`fraim/personalized-employee/jobs/\`, or the full \`list_fraim_jobs()\` response. If no exact or high-confidence job match exists, ask once: "No catalog job matches. Would you like to run this as an ad-hoc task?" On confirmation, call \`get_fraim_job({ job: "adhoc-prompt" })\` and execute it with the user's instructions as the task input — do not pick the nearest catalog job. Do not ask again if the user already provided instructions.
|
|
76
76
|
|
|
77
77
|
4. **Load the full content**:
|
|
78
78
|
- For jobs, call \`get_fraim_job({ job: "<matched-job-name>" })\`.
|
|
@@ -125,6 +125,10 @@ function ensureUserLevelDependencies(userFraimDir) {
|
|
|
125
125
|
if (missing.length === 0) {
|
|
126
126
|
return;
|
|
127
127
|
}
|
|
128
|
+
if (process.env.FRAIM_SKIP_USER_LEVEL_DEP_INSTALL === '1') {
|
|
129
|
+
console.log(chalk_1.default.yellow(`TEST_MODE: skipping user-level runtime dependency install (${missing.join(', ')}).`));
|
|
130
|
+
return;
|
|
131
|
+
}
|
|
128
132
|
console.log(chalk_1.default.blue(`📦 Installing user-level runtime dependencies (${missing.join(', ')})...`));
|
|
129
133
|
try {
|
|
130
134
|
(0, child_process_1.execSync)('npm install --no-audit --no-fund --no-save --no-package-lock --omit=dev', {
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
"use strict";
|
|
2
2
|
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
-
exports.PERSONA_CAPABILITY_BUNDLES = exports.FREE_JOBS = exports.GENERIC_WORKER_PERSONA_KEY = void 0;
|
|
3
|
+
exports.UNOWNED_EXEMPT_JOBS = exports.PERSONA_CAPABILITY_BUNDLES = exports.FREE_JOBS = exports.GENERIC_WORKER_PERSONA_KEY = void 0;
|
|
4
4
|
exports.isFreeJob = isFreeJob;
|
|
5
5
|
exports.getPersonaCapabilityBundle = getPersonaCapabilityBundle;
|
|
6
6
|
exports.getProtectedPersonaForJob = getProtectedPersonaForJob;
|
|
@@ -315,6 +315,17 @@ const GENERIC_WORKER_OWNED_JOBS = new Set([
|
|
|
315
315
|
'organization-onboarding',
|
|
316
316
|
'organizational-learning-synthesis',
|
|
317
317
|
]);
|
|
318
|
+
// Jobs intentionally left without a named persona owner. They resolve to null
|
|
319
|
+
// from getProtectedPersonaForJob (runs ungated; Hub attributes them to the
|
|
320
|
+
// DEFAULT_UNASSIGNED_PERSONA_KEY/MANdy — same behavior as today's adhoc runs).
|
|
321
|
+
// validate-job-ownership exempts these from the "every job must have an owner"
|
|
322
|
+
// assertion so the validator still catches accidentally unowned jobs.
|
|
323
|
+
//
|
|
324
|
+
// Issue #1610: adhoc-prompt is a manager-directed fallback job, not tied to any
|
|
325
|
+
// specialist hire, matching the existing no-employee attribution of adhoc runs.
|
|
326
|
+
exports.UNOWNED_EXEMPT_JOBS = new Set([
|
|
327
|
+
'adhoc-prompt',
|
|
328
|
+
]);
|
|
318
329
|
function getPersonaCapabilityBundle(personaKey) {
|
|
319
330
|
return exports.PERSONA_CAPABILITY_BUNDLES[personaKey];
|
|
320
331
|
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "fraim-hub",
|
|
3
|
-
"version": "2.0.
|
|
3
|
+
"version": "2.0.312",
|
|
4
4
|
"description": "FRAIM Hub local companion package.",
|
|
5
5
|
"author": "Sid Mathur <sid.mathur@gmail.com>",
|
|
6
6
|
"homepage": "https://github.com/mathursrus/FRAIM#readme",
|
|
@@ -211,7 +211,7 @@
|
|
|
211
211
|
"electron-updater": "^6.8.9",
|
|
212
212
|
"express": "^5.2.1",
|
|
213
213
|
"extract-zip": "^2.0.1",
|
|
214
|
-
"fraim": "2.0.
|
|
214
|
+
"fraim": "2.0.312",
|
|
215
215
|
"mongodb": "^7.0.0",
|
|
216
216
|
"node-cron": "4.2.1",
|
|
217
217
|
"node-edge-tts": "^1.2.10",
|
package/public/ai-hub/index.html
CHANGED
|
@@ -1145,7 +1145,7 @@
|
|
|
1145
1145
|
<p class="dep-trig-note">Runs when an external system POSTs to the inbound URL, generated after you add the assignment.</p>
|
|
1146
1146
|
</div>
|
|
1147
1147
|
<div class="hm-field">
|
|
1148
|
-
<label for="dep-instructions">Instructions <span class="dep-optional">(optional)</span></label>
|
|
1148
|
+
<label for="dep-instructions">Instructions <span class="dep-optional" id="dep-inst-optional-label">(optional)</span></label>
|
|
1149
1149
|
<textarea id="dep-instructions" rows="2" placeholder="Optional message sent to the agent at the start of each run"></textarea>
|
|
1150
1150
|
</div>
|
|
1151
1151
|
<div class="dep-modal-actions">
|