fraim-framework 2.0.199 → 2.0.201
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/conversation-store.js +102 -2
- package/dist/src/ai-hub/desktop-main.js +35 -0
- package/dist/src/ai-hub/hub-latest-version.js +52 -0
- package/dist/src/ai-hub/hub-launch-decision.js +57 -0
- package/dist/src/ai-hub/hub-runtime-file.js +43 -0
- package/dist/src/ai-hub/preferences.js +3 -1
- package/dist/src/ai-hub/server.js +199 -194
- package/dist/src/cli/commands/hub.js +107 -1
- package/package.json +1 -1
- package/public/ai-hub/index.html +5 -0
- package/public/ai-hub/script.js +41 -13
- package/public/ai-hub/styles.css +7 -0
|
@@ -5,6 +5,7 @@ var __importDefault = (this && this.__importDefault) || function (mod) {
|
|
|
5
5
|
Object.defineProperty(exports, "__esModule", { value: true });
|
|
6
6
|
exports.AiHubConversationStore = exports.COMPANY_SCOPE_KEY = exports.MANAGER_SCOPE_KEY = void 0;
|
|
7
7
|
exports.conversationScopeKey = conversationScopeKey;
|
|
8
|
+
exports.migrateConversationStore = migrateConversationStore;
|
|
8
9
|
const fs_1 = __importDefault(require("fs"));
|
|
9
10
|
const path_1 = __importDefault(require("path"));
|
|
10
11
|
const project_fraim_paths_1 = require("../core/utils/project-fraim-paths");
|
|
@@ -93,6 +94,103 @@ function migrateScopeBuckets(store) {
|
|
|
93
94
|
}
|
|
94
95
|
return { version: 1, projects };
|
|
95
96
|
}
|
|
97
|
+
// Canonical comparison key for a project path (case-insensitive on win32), matching the
|
|
98
|
+
// convention used by removeProject and the preferences store.
|
|
99
|
+
function canonicalProjectPathKey(value) {
|
|
100
|
+
const resolved = path_1.default.resolve(value);
|
|
101
|
+
return process.platform === 'win32' ? resolved.toLowerCase() : resolved;
|
|
102
|
+
}
|
|
103
|
+
// How much run history a record carries. Used to decide the winner when the same conversation id
|
|
104
|
+
// exists in two buckets: the copy with real history must never be overwritten by a header-only
|
|
105
|
+
// shell (which is exactly what a mis-filed slim PUT produces).
|
|
106
|
+
function conversationRichness(conv) {
|
|
107
|
+
const value = conv;
|
|
108
|
+
const messages = Array.isArray(value?.messages) ? value.messages.length : 0;
|
|
109
|
+
const events = Array.isArray(value?.events) ? value.events.length : 0;
|
|
110
|
+
return messages + events;
|
|
111
|
+
}
|
|
112
|
+
// Place a raw conversation into a bucket, deduping by id. When the id already exists, keep the
|
|
113
|
+
// richer copy (more run history); tie-break on newer lastUpdatedAt. Order-independent.
|
|
114
|
+
function placeConversationInBucket(bucket, conv) {
|
|
115
|
+
const value = conv;
|
|
116
|
+
const id = value && typeof value.id === 'string' ? value.id : '';
|
|
117
|
+
if (!id) {
|
|
118
|
+
bucket.conversations.push(conv);
|
|
119
|
+
return;
|
|
120
|
+
}
|
|
121
|
+
const idx = bucket.conversations.findIndex((entry) => entry && entry.id === id);
|
|
122
|
+
if (idx < 0) {
|
|
123
|
+
bucket.conversations.push(conv);
|
|
124
|
+
return;
|
|
125
|
+
}
|
|
126
|
+
const existing = bucket.conversations[idx];
|
|
127
|
+
const existingScore = conversationRichness(existing);
|
|
128
|
+
const incomingScore = conversationRichness(conv);
|
|
129
|
+
if (incomingScore > existingScore) {
|
|
130
|
+
bucket.conversations[idx] = conv;
|
|
131
|
+
}
|
|
132
|
+
else if (incomingScore === existingScore
|
|
133
|
+
&& timestampValue(value?.lastUpdatedAt) > timestampValue(existing.lastUpdatedAt)) {
|
|
134
|
+
bucket.conversations[idx] = conv;
|
|
135
|
+
}
|
|
136
|
+
}
|
|
137
|
+
// A conversation's identity is unique only WITHIN a bucket, and a bucket key is a project path.
|
|
138
|
+
// The buggy client PUT path could file a conversation whose own projectPath points at project B
|
|
139
|
+
// into project A's bucket (see docs/rca/hub-conversation-cross-project-leak.md), so the same run
|
|
140
|
+
// appeared under two projects and the mis-filed copy was a header-only shell. This one-time,
|
|
141
|
+
// idempotent, read-time migration relocates every project-scoped record back to the bucket that
|
|
142
|
+
// matches its own projectPath, deduping so the copy with real run history wins. A bucket activeId
|
|
143
|
+
// that pointed at a relocated record is cleared. Sentinel scope buckets (@manager/@company)
|
|
144
|
+
// legitimately hold records whose projectPath points at a project, so they are left untouched.
|
|
145
|
+
function migrateProjectBuckets(store) {
|
|
146
|
+
const projects = {};
|
|
147
|
+
const ensure = (key) => {
|
|
148
|
+
if (!projects[key])
|
|
149
|
+
projects[key] = { activeId: null, conversations: [] };
|
|
150
|
+
return projects[key];
|
|
151
|
+
};
|
|
152
|
+
// Preserve every existing bucket key and its activeId up front.
|
|
153
|
+
for (const [key, state] of Object.entries(store.projects || {})) {
|
|
154
|
+
ensure(key).activeId = state.activeId ?? null;
|
|
155
|
+
}
|
|
156
|
+
for (const [bucketKey, state] of Object.entries(store.projects || {})) {
|
|
157
|
+
const isSentinel = bucketKey === exports.MANAGER_SCOPE_KEY || bucketKey === exports.COMPANY_SCOPE_KEY;
|
|
158
|
+
for (const conv of (state.conversations || [])) {
|
|
159
|
+
if (!conv || typeof conv !== 'object')
|
|
160
|
+
continue;
|
|
161
|
+
const scope = conv.scope
|
|
162
|
+
?? conv.invokedArea;
|
|
163
|
+
const ownPath = typeof conv.projectPath === 'string'
|
|
164
|
+
? conv.projectPath
|
|
165
|
+
: '';
|
|
166
|
+
const mismatched = !isSentinel
|
|
167
|
+
&& scope !== 'manager' && scope !== 'company'
|
|
168
|
+
&& ownPath.length > 0
|
|
169
|
+
&& canonicalProjectPathKey(ownPath) !== canonicalProjectPathKey(bucketKey);
|
|
170
|
+
if (!mismatched) {
|
|
171
|
+
placeConversationInBucket(ensure(bucketKey), conv);
|
|
172
|
+
continue;
|
|
173
|
+
}
|
|
174
|
+
// Relocate to the record's own project bucket.
|
|
175
|
+
placeConversationInBucket(ensure(path_1.default.resolve(ownPath)), conv);
|
|
176
|
+
}
|
|
177
|
+
}
|
|
178
|
+
// Any activeId that no longer resolves to a conversation in its bucket (because that record was
|
|
179
|
+
// relocated out) is stale — clear it so a project never points at another project's run.
|
|
180
|
+
for (const state of Object.values(projects)) {
|
|
181
|
+
if (state.activeId && !state.conversations.some((entry) => entry && entry.id === state.activeId)) {
|
|
182
|
+
state.activeId = null;
|
|
183
|
+
}
|
|
184
|
+
}
|
|
185
|
+
return { version: 1, projects };
|
|
186
|
+
}
|
|
187
|
+
// The full on-read migration pipeline, exported so a one-off maintenance/cleanup pass can heal an
|
|
188
|
+
// existing store file through the EXACT same code path the store uses at runtime (rather than a
|
|
189
|
+
// duplicated, drift-prone reimplementation). Operates on raw records, so it never drops a record
|
|
190
|
+
// via normalization.
|
|
191
|
+
function migrateConversationStore(store) {
|
|
192
|
+
return migrateProjectBuckets(migrateScopeBuckets(store));
|
|
193
|
+
}
|
|
96
194
|
function normalizeConversation(projectPath, raw) {
|
|
97
195
|
if (!raw || typeof raw !== 'object')
|
|
98
196
|
return null;
|
|
@@ -242,8 +340,10 @@ class AiHubConversationStore {
|
|
|
242
340
|
if (raw.version !== 1 || !raw.projects || typeof raw.projects !== 'object') {
|
|
243
341
|
return { version: 1, projects: {} };
|
|
244
342
|
}
|
|
245
|
-
// Issue #708: fold legacy manager/company-invoked runs into scope buckets on read
|
|
246
|
-
|
|
343
|
+
// Issue #708: fold legacy manager/company-invoked runs into scope buckets on read, then
|
|
344
|
+
// relocate any project-scoped record mis-filed under the wrong project bucket back to its
|
|
345
|
+
// own project (see docs/rca/hub-conversation-cross-project-leak.md).
|
|
346
|
+
return migrateProjectBuckets(migrateScopeBuckets({ version: 1, projects: raw.projects }));
|
|
247
347
|
}
|
|
248
348
|
catch {
|
|
249
349
|
return { version: 1, projects: {} };
|
|
@@ -11,6 +11,9 @@ const server_1 = require("./server");
|
|
|
11
11
|
const cert_store_1 = require("./cert-store");
|
|
12
12
|
const office_sideload_1 = require("./office-sideload");
|
|
13
13
|
const remote_hub_gateway_1 = require("./remote-hub-gateway");
|
|
14
|
+
const project_fraim_paths_1 = require("../core/utils/project-fraim-paths");
|
|
15
|
+
const version_utils_1 = require("../cli/utils/version-utils");
|
|
16
|
+
const hub_runtime_file_1 = require("./hub-runtime-file");
|
|
14
17
|
// ---------------------------------------------------------------------------
|
|
15
18
|
// State
|
|
16
19
|
// ---------------------------------------------------------------------------
|
|
@@ -176,6 +179,19 @@ function buildTrayMenu(hubUrl) {
|
|
|
176
179
|
}
|
|
177
180
|
},
|
|
178
181
|
},
|
|
182
|
+
{
|
|
183
|
+
// #755: surface the running build version so staleness is diagnosable at a glance.
|
|
184
|
+
label: 'About FRAIM Hub',
|
|
185
|
+
click: () => {
|
|
186
|
+
void electron_1.dialog.showMessageBox({
|
|
187
|
+
type: 'info',
|
|
188
|
+
title: 'About FRAIM Hub',
|
|
189
|
+
message: 'FRAIM Hub',
|
|
190
|
+
detail: `Version ${(0, version_utils_1.getFraimVersion)()}\nIdentity: ~/.fraim/config.json\nLocal server: ${hubUrl}`,
|
|
191
|
+
buttons: ['OK'],
|
|
192
|
+
});
|
|
193
|
+
},
|
|
194
|
+
},
|
|
179
195
|
{ type: 'separator' },
|
|
180
196
|
{
|
|
181
197
|
label: 'Word Add-in',
|
|
@@ -354,6 +370,19 @@ async function launchDesktopShell(options) {
|
|
|
354
370
|
},
|
|
355
371
|
});
|
|
356
372
|
await server.start(httpPort);
|
|
373
|
+
// #755: record the live instance so `fraim hub` can detect a stale build and
|
|
374
|
+
// replace it instead of re-focusing an outdated process.
|
|
375
|
+
try {
|
|
376
|
+
(0, hub_runtime_file_1.writeHubRuntimeFile)((0, project_fraim_paths_1.getUserFraimDirPath)(), {
|
|
377
|
+
pid: process.pid,
|
|
378
|
+
port: httpPort,
|
|
379
|
+
version: (0, version_utils_1.getFraimVersion)(),
|
|
380
|
+
startedAt: new Date().toISOString(),
|
|
381
|
+
});
|
|
382
|
+
}
|
|
383
|
+
catch (err) {
|
|
384
|
+
console.warn('[fraim] could not write hub-runtime.json:', err);
|
|
385
|
+
}
|
|
357
386
|
ensureWordSideload(options.projectPath, httpPort);
|
|
358
387
|
const hubUrl = `http://127.0.0.1:${httpPort}/ai-hub/`;
|
|
359
388
|
createTray(hubUrl);
|
|
@@ -394,6 +423,12 @@ async function bootstrap() {
|
|
|
394
423
|
});
|
|
395
424
|
electron_1.app.on('before-quit', () => {
|
|
396
425
|
isQuitting = true;
|
|
426
|
+
// #755: clear the runtime file so a later `fraim hub` doesn't treat a
|
|
427
|
+
// cleanly-exited instance as live.
|
|
428
|
+
try {
|
|
429
|
+
(0, hub_runtime_file_1.removeHubRuntimeFile)((0, project_fraim_paths_1.getUserFraimDirPath)());
|
|
430
|
+
}
|
|
431
|
+
catch { /* best-effort */ }
|
|
397
432
|
void stopServerOnce();
|
|
398
433
|
});
|
|
399
434
|
// Keep app alive when all windows are closed — server must keep serving
|
|
@@ -0,0 +1,52 @@
|
|
|
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.getLatestPublishedVersion = getLatestPublishedVersion;
|
|
7
|
+
exports.__resetLatestVersionCache = __resetLatestVersionCache;
|
|
8
|
+
const https_1 = __importDefault(require("https"));
|
|
9
|
+
// Issue #755: best-effort "latest published fraim version" for the Hub's
|
|
10
|
+
// update-available nudge. Cached in-process (1h TTL) and never throws — returns
|
|
11
|
+
// null when offline or the registry is unreachable, so the nudge simply hides.
|
|
12
|
+
const TTL_MS = 60 * 60 * 1000;
|
|
13
|
+
const REGISTRY_URL = 'https://registry.npmjs.org/fraim/latest';
|
|
14
|
+
let cache = { value: null, at: 0 };
|
|
15
|
+
async function getLatestPublishedVersion() {
|
|
16
|
+
const now = Date.now();
|
|
17
|
+
if (cache.at !== 0 && now - cache.at < TTL_MS)
|
|
18
|
+
return cache.value;
|
|
19
|
+
const value = await fetchNpmLatest();
|
|
20
|
+
// Only cache successful lookups; a failed fetch retries next call rather than
|
|
21
|
+
// caching a null for an hour.
|
|
22
|
+
if (value !== null)
|
|
23
|
+
cache = { value, at: now };
|
|
24
|
+
return value ?? cache.value;
|
|
25
|
+
}
|
|
26
|
+
/** Test-only: reset the module cache. */
|
|
27
|
+
function __resetLatestVersionCache() {
|
|
28
|
+
cache = { value: null, at: 0 };
|
|
29
|
+
}
|
|
30
|
+
function fetchNpmLatest() {
|
|
31
|
+
return new Promise((resolve) => {
|
|
32
|
+
const req = https_1.default.get(REGISTRY_URL, { timeout: 4000 }, (res) => {
|
|
33
|
+
if (res.statusCode !== 200) {
|
|
34
|
+
res.resume();
|
|
35
|
+
return resolve(null);
|
|
36
|
+
}
|
|
37
|
+
let body = '';
|
|
38
|
+
res.on('data', (chunk) => { body += chunk; });
|
|
39
|
+
res.on('end', () => {
|
|
40
|
+
try {
|
|
41
|
+
const version = JSON.parse(body).version;
|
|
42
|
+
resolve(typeof version === 'string' ? version : null);
|
|
43
|
+
}
|
|
44
|
+
catch {
|
|
45
|
+
resolve(null);
|
|
46
|
+
}
|
|
47
|
+
});
|
|
48
|
+
});
|
|
49
|
+
req.on('error', () => resolve(null));
|
|
50
|
+
req.on('timeout', () => { req.destroy(); resolve(null); });
|
|
51
|
+
});
|
|
52
|
+
}
|
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
var __createBinding = (this && this.__createBinding) || (Object.create ? (function(o, m, k, k2) {
|
|
3
|
+
if (k2 === undefined) k2 = k;
|
|
4
|
+
var desc = Object.getOwnPropertyDescriptor(m, k);
|
|
5
|
+
if (!desc || ("get" in desc ? !m.__esModule : desc.writable || desc.configurable)) {
|
|
6
|
+
desc = { enumerable: true, get: function() { return m[k]; } };
|
|
7
|
+
}
|
|
8
|
+
Object.defineProperty(o, k2, desc);
|
|
9
|
+
}) : (function(o, m, k, k2) {
|
|
10
|
+
if (k2 === undefined) k2 = k;
|
|
11
|
+
o[k2] = m[k];
|
|
12
|
+
}));
|
|
13
|
+
var __setModuleDefault = (this && this.__setModuleDefault) || (Object.create ? (function(o, v) {
|
|
14
|
+
Object.defineProperty(o, "default", { enumerable: true, value: v });
|
|
15
|
+
}) : function(o, v) {
|
|
16
|
+
o["default"] = v;
|
|
17
|
+
});
|
|
18
|
+
var __importStar = (this && this.__importStar) || (function () {
|
|
19
|
+
var ownKeys = function(o) {
|
|
20
|
+
ownKeys = Object.getOwnPropertyNames || function (o) {
|
|
21
|
+
var ar = [];
|
|
22
|
+
for (var k in o) if (Object.prototype.hasOwnProperty.call(o, k)) ar[ar.length] = k;
|
|
23
|
+
return ar;
|
|
24
|
+
};
|
|
25
|
+
return ownKeys(o);
|
|
26
|
+
};
|
|
27
|
+
return function (mod) {
|
|
28
|
+
if (mod && mod.__esModule) return mod;
|
|
29
|
+
var result = {};
|
|
30
|
+
if (mod != null) for (var k = ownKeys(mod), i = 0; i < k.length; i++) if (k[i] !== "default") __createBinding(result, mod, k[i]);
|
|
31
|
+
__setModuleDefault(result, mod);
|
|
32
|
+
return result;
|
|
33
|
+
};
|
|
34
|
+
})();
|
|
35
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
36
|
+
exports.decideHubLaunch = decideHubLaunch;
|
|
37
|
+
const semver = __importStar(require("semver"));
|
|
38
|
+
function decideHubLaunch(input) {
|
|
39
|
+
const { running, pidAlive, cliVersion, restart, noRestart } = input;
|
|
40
|
+
if (!running || !pidAlive) {
|
|
41
|
+
return { action: 'launch-fresh', reason: 'no live Hub instance is running' };
|
|
42
|
+
}
|
|
43
|
+
if (noRestart) {
|
|
44
|
+
return { action: 'focus-existing', reason: '--no-restart: keeping the running instance' };
|
|
45
|
+
}
|
|
46
|
+
if (restart) {
|
|
47
|
+
return { action: 'replace', reason: '--restart: replacing the running instance' };
|
|
48
|
+
}
|
|
49
|
+
// Only treat as stale when BOTH versions are valid semver and the running one is
|
|
50
|
+
// strictly older. On any uncertainty (unknown/unparseable version) do not kill —
|
|
51
|
+
// conservative default is to focus the existing instance.
|
|
52
|
+
const comparable = !!semver.valid(running.version) && !!semver.valid(cliVersion);
|
|
53
|
+
if (comparable && semver.lt(running.version, cliVersion)) {
|
|
54
|
+
return { action: 'replace', reason: `running ${running.version} is older than ${cliVersion}; replacing with the newer build` };
|
|
55
|
+
}
|
|
56
|
+
return { action: 'focus-existing', reason: `running ${running.version} is current or not comparable to ${cliVersion}` };
|
|
57
|
+
}
|
|
@@ -0,0 +1,43 @@
|
|
|
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.hubRuntimeFilePath = hubRuntimeFilePath;
|
|
7
|
+
exports.writeHubRuntimeFile = writeHubRuntimeFile;
|
|
8
|
+
exports.readHubRuntimeFile = readHubRuntimeFile;
|
|
9
|
+
exports.removeHubRuntimeFile = removeHubRuntimeFile;
|
|
10
|
+
const fs_1 = __importDefault(require("fs"));
|
|
11
|
+
const path_1 = __importDefault(require("path"));
|
|
12
|
+
// Issue #755: the running desktop Hub writes ~/.fraim/hub-runtime.json so the
|
|
13
|
+
// `fraim hub` CLI can discover a live instance (pid/port/version) before deciding
|
|
14
|
+
// whether to replace a stale build. `fraimDir` is injected (never hard-coded to
|
|
15
|
+
// the real ~/.fraim) so tests isolate via a temp directory.
|
|
16
|
+
const RUNTIME_FILE = 'hub-runtime.json';
|
|
17
|
+
function hubRuntimeFilePath(fraimDir) {
|
|
18
|
+
return path_1.default.join(fraimDir, RUNTIME_FILE);
|
|
19
|
+
}
|
|
20
|
+
function writeHubRuntimeFile(fraimDir, info) {
|
|
21
|
+
fs_1.default.mkdirSync(fraimDir, { recursive: true });
|
|
22
|
+
fs_1.default.writeFileSync(hubRuntimeFilePath(fraimDir), JSON.stringify(info, null, 2));
|
|
23
|
+
}
|
|
24
|
+
function readHubRuntimeFile(fraimDir) {
|
|
25
|
+
try {
|
|
26
|
+
const parsed = JSON.parse(fs_1.default.readFileSync(hubRuntimeFilePath(fraimDir), 'utf8'));
|
|
27
|
+
if (typeof parsed.pid !== 'number' || typeof parsed.port !== 'number' || typeof parsed.version !== 'string') {
|
|
28
|
+
return null;
|
|
29
|
+
}
|
|
30
|
+
return { pid: parsed.pid, port: parsed.port, version: parsed.version, startedAt: String(parsed.startedAt ?? '') };
|
|
31
|
+
}
|
|
32
|
+
catch {
|
|
33
|
+
return null;
|
|
34
|
+
}
|
|
35
|
+
}
|
|
36
|
+
function removeHubRuntimeFile(fraimDir) {
|
|
37
|
+
try {
|
|
38
|
+
fs_1.default.rmSync(hubRuntimeFilePath(fraimDir), { force: true });
|
|
39
|
+
}
|
|
40
|
+
catch {
|
|
41
|
+
/* no-op: absent file is fine */
|
|
42
|
+
}
|
|
43
|
+
}
|
|
@@ -140,7 +140,9 @@ class AiHubPreferencesStore {
|
|
|
140
140
|
? raw.recentJobInstructions
|
|
141
141
|
: {},
|
|
142
142
|
personaKey: typeof raw.personaKey === 'string' ? raw.personaKey : null,
|
|
143
|
-
|
|
143
|
+
// Issue #750: apiKey is no longer a persisted field here at all — any
|
|
144
|
+
// legacy apiKey left over from a pre-#750 ai-hub-state.json is silently
|
|
145
|
+
// dropped, never read back. Identity comes from ~/.fraim/config.json only.
|
|
144
146
|
projects: normalizeAiHubProjectList(Array.isArray(raw.projects) ? raw.projects : [], projectPath, { removedProjectPaths }),
|
|
145
147
|
removedProjectPaths,
|
|
146
148
|
};
|
|
@@ -38,6 +38,7 @@ var __importDefault = (this && this.__importDefault) || function (mod) {
|
|
|
38
38
|
Object.defineProperty(exports, "__esModule", { value: true });
|
|
39
39
|
exports.AiHubServer = exports.HostConfigStore = exports.DeploymentStore = void 0;
|
|
40
40
|
exports.configureFraimForHubAgent = configureFraimForHubAgent;
|
|
41
|
+
exports.buildOpenFileInvocation = buildOpenFileInvocation;
|
|
41
42
|
exports.findAvailablePort = findAvailablePort;
|
|
42
43
|
exports.findAvailablePortExcluding = findAvailablePortExcluding;
|
|
43
44
|
const express_1 = __importDefault(require("express"));
|
|
@@ -61,6 +62,10 @@ const conversation_store_1 = require("./conversation-store");
|
|
|
61
62
|
const remote_hub_gateway_1 = require("./remote-hub-gateway");
|
|
62
63
|
const managed_browser_1 = require("./managed-browser");
|
|
63
64
|
const managed_agent_paths_1 = require("../cli/utils/managed-agent-paths");
|
|
65
|
+
const user_config_1 = require("../cli/utils/user-config");
|
|
66
|
+
const version_utils_1 = require("../cli/utils/version-utils");
|
|
67
|
+
const hub_latest_version_1 = require("./hub-latest-version");
|
|
68
|
+
const semver = __importStar(require("semver"));
|
|
64
69
|
let personaHiringModule;
|
|
65
70
|
let managerHiringModule;
|
|
66
71
|
function loadPersonaHiringModule() {
|
|
@@ -973,28 +978,43 @@ function resolveSafeArtifactPath(rawPath, projectPath) {
|
|
|
973
978
|
];
|
|
974
979
|
return safeRoots.some((root) => pathWithin(root, resolved)) ? resolved : null;
|
|
975
980
|
}
|
|
976
|
-
|
|
977
|
-
|
|
978
|
-
|
|
979
|
-
|
|
981
|
+
// Builds the OS "open this file with its default app" invocation. Exported for regression
|
|
982
|
+
// testing (see tests/isolated/test-ai-hub-open-file.ts).
|
|
983
|
+
//
|
|
984
|
+
// Windows contract: the path travels via an ENVIRONMENT VARIABLE, never as a trailing `-Command`
|
|
985
|
+
// argument. PowerShell re-parses trailing args on its own command line and mangles Windows paths —
|
|
986
|
+
// it strips backslashes and truncates at the first space — so `Invoke-Item -LiteralPath $p`
|
|
987
|
+
// received a broken path (e.g. `C:UserssidmaOneDriveCodeDrug`) and failed for every real artifact
|
|
988
|
+
// path, which is why the Hub's "open file" button did nothing. `$env:FRAIM_OPEN_PATH` is read
|
|
989
|
+
// directly from the environment and is not re-parsed, so the path arrives intact (verified against
|
|
990
|
+
// paths containing spaces and backslashes).
|
|
991
|
+
function buildOpenFileInvocation(filePath, platform = process.platform) {
|
|
992
|
+
if (platform === 'win32') {
|
|
993
|
+
return {
|
|
994
|
+
command: 'powershell.exe',
|
|
995
|
+
args: [
|
|
980
996
|
'-NoProfile',
|
|
981
997
|
'-ExecutionPolicy',
|
|
982
998
|
'Bypass',
|
|
983
999
|
'-Command',
|
|
984
|
-
'
|
|
985
|
-
|
|
986
|
-
|
|
987
|
-
|
|
988
|
-
|
|
989
|
-
|
|
990
|
-
|
|
991
|
-
|
|
992
|
-
|
|
993
|
-
|
|
994
|
-
|
|
1000
|
+
'try { Invoke-Item -LiteralPath $env:FRAIM_OPEN_PATH; exit 0 } catch { Write-Error $_; exit 1 }',
|
|
1001
|
+
],
|
|
1002
|
+
envPatch: { FRAIM_OPEN_PATH: filePath },
|
|
1003
|
+
};
|
|
1004
|
+
}
|
|
1005
|
+
return {
|
|
1006
|
+
command: platform === 'darwin' ? 'open' : 'xdg-open',
|
|
1007
|
+
args: [filePath],
|
|
1008
|
+
envPatch: {},
|
|
1009
|
+
};
|
|
1010
|
+
}
|
|
1011
|
+
function hubOpenFile(filePath) {
|
|
1012
|
+
return new Promise((resolve, reject) => {
|
|
1013
|
+
const { command, args, envPatch } = buildOpenFileInvocation(filePath);
|
|
995
1014
|
const child = (0, child_process_1.spawn)(command, args, {
|
|
996
1015
|
stdio: ['ignore', 'ignore', 'pipe'],
|
|
997
1016
|
windowsHide: process.platform === 'win32',
|
|
1017
|
+
env: { ...process.env, ...envPatch },
|
|
998
1018
|
});
|
|
999
1019
|
let stderr = '';
|
|
1000
1020
|
child.stderr?.on('data', (chunk) => {
|
|
@@ -1051,53 +1071,15 @@ function deploymentBelongsToProject(deployment, projectPath, fallbackProjectPath
|
|
|
1051
1071
|
// ---------------------------------------------------------------------------
|
|
1052
1072
|
// Issue #512 (S3) — Hub bootstrap projection helpers.
|
|
1053
1073
|
// ---------------------------------------------------------------------------
|
|
1054
|
-
//
|
|
1055
|
-
//
|
|
1056
|
-
//
|
|
1057
|
-
//
|
|
1058
|
-
|
|
1059
|
-
|
|
1060
|
-
|
|
1061
|
-
|
|
1062
|
-
|
|
1063
|
-
if (typeof raw.userEmail === 'string' && raw.userEmail.length > 0)
|
|
1064
|
-
return raw.userEmail;
|
|
1065
|
-
}
|
|
1066
|
-
}
|
|
1067
|
-
catch {
|
|
1068
|
-
// fall through to default
|
|
1069
|
-
}
|
|
1070
|
-
// #533: preferences.json may not carry the email yet — the proxy persists it on
|
|
1071
|
-
// fraim_connect (see stdio-server). Until then, personal learnings are ALWAYS
|
|
1072
|
-
// stamped `<email>-<type>.md`, so a single-user machine resolves deterministically
|
|
1073
|
-
// from the on-disk files. This is what makes the avatar + personal learnings work.
|
|
1074
|
-
const stamped = resolveSingleStampedUser();
|
|
1075
|
-
if (stamped)
|
|
1076
|
-
return stamped;
|
|
1077
|
-
return 'fraim-user';
|
|
1078
|
-
}
|
|
1079
|
-
// Return the one user prefix stamped on the global learning files, or null if
|
|
1080
|
-
// there is not exactly one (so we never guess between multiple accounts).
|
|
1081
|
-
function resolveSingleStampedUser() {
|
|
1082
|
-
try {
|
|
1083
|
-
const dir = (0, project_fraim_paths_1.getUserFraimLearningsDir)();
|
|
1084
|
-
if (!fs_1.default.existsSync(dir))
|
|
1085
|
-
return null;
|
|
1086
|
-
const prefixes = new Set();
|
|
1087
|
-
for (const f of fs_1.default.readdirSync(dir)) {
|
|
1088
|
-
if (f.startsWith('org-'))
|
|
1089
|
-
continue;
|
|
1090
|
-
const m = f.match(/^(.*)-(preferences|manager-coaching|mistake-patterns|validated-patterns)\.md$/);
|
|
1091
|
-
if (m)
|
|
1092
|
-
prefixes.add(m[1]);
|
|
1093
|
-
}
|
|
1094
|
-
if (prefixes.size === 1)
|
|
1095
|
-
return Array.from(prefixes)[0];
|
|
1096
|
-
}
|
|
1097
|
-
catch {
|
|
1098
|
-
// ignore unreadable dir
|
|
1099
|
-
}
|
|
1100
|
-
return null;
|
|
1074
|
+
// Issue #750: the Hub's identity — for persona/entitlement resolution, the
|
|
1075
|
+
// displayed "signed in as" email, and personal (L1) learnings keying — comes
|
|
1076
|
+
// from ~/.fraim/config.json's apiKey (architecture.md §4.3.4's "Global config
|
|
1077
|
+
// stores only identity"), the same credential every other FRAIM CLI/MCP
|
|
1078
|
+
// surface already reads via readUserFraimConfig(). There is no fallback: a
|
|
1079
|
+
// missing or invalid apiKey resolves to null (rendered as a "not connected"
|
|
1080
|
+
// state), never a guess from another local file.
|
|
1081
|
+
function resolveApiKey() {
|
|
1082
|
+
return (0, user_config_1.readUserFraimConfig)().apiKey;
|
|
1101
1083
|
}
|
|
1102
1084
|
// Read persisted Get-started step states from ~/.fraim/install-state.json
|
|
1103
1085
|
// (architecture §3.5) and ~/.fraim/preferences.json. Returns a partial — any
|
|
@@ -1177,21 +1159,13 @@ class AiHubServer {
|
|
|
1177
1159
|
explicitPath: process.env.FRAIM_BROWSER_PATH || undefined,
|
|
1178
1160
|
});
|
|
1179
1161
|
this.hostRuntime = options.hostRuntime || (process.env.FRAIM_AI_HUB_FAKE_HOST === '1' ? new hosts_1.FakeHostRuntime() : new hosts_1.CliHostRuntime());
|
|
1180
|
-
// Issue #701: the AI Hub is a loopback companion that
|
|
1181
|
-
//
|
|
1182
|
-
//
|
|
1183
|
-
// A dbService is used ONLY when explicitly injected (e.g. the hosted server embedding the
|
|
1184
|
-
// Hub, or a test); it is never auto-created, so there is no dev/prod divergence.
|
|
1185
|
-
this.dbService = options.dbService;
|
|
1186
|
-
this.ownsDbService = false;
|
|
1162
|
+
// Issue #701 / #749: the AI Hub is a loopback companion that runs on user machines and
|
|
1163
|
+
// never touches a database. Persona and manager-team state resolve from the hosted server
|
|
1164
|
+
// through the remote gateway; the hosted server is the sole owner of DB access.
|
|
1187
1165
|
this.remoteGateway = options.remoteGateway ?? new remote_hub_gateway_1.HttpHubRemoteGateway();
|
|
1188
1166
|
this.deploymentStore = options.deploymentStore ?? new DeploymentStore();
|
|
1189
1167
|
this.hostConfigStore = options.hostConfigStore ?? new HostConfigStore();
|
|
1190
1168
|
this.app.use(express_1.default.json({ limit: '10mb' }));
|
|
1191
|
-
if (this.dbService) {
|
|
1192
|
-
const { registerPaymentRoutes } = require('../routes/payment-routes');
|
|
1193
|
-
registerPaymentRoutes(this.app, () => this.paymentRepo ?? null, this.dbService);
|
|
1194
|
-
}
|
|
1195
1169
|
// CORS + Chrome Private Network Access for browser extensions and Office add-in task panes
|
|
1196
1170
|
// calling the Hub from a public origin (word-edit.officeapps.live.com, etc.).
|
|
1197
1171
|
this.app.use((_req, res, next) => {
|
|
@@ -1207,8 +1181,7 @@ class AiHubServer {
|
|
|
1207
1181
|
});
|
|
1208
1182
|
// Payment success redirect: sync entitlement then bounce to the hub.
|
|
1209
1183
|
// Stripe redirects here after a completed persona-hire checkout.
|
|
1210
|
-
this.app.get('/ai-hub/payment-success',
|
|
1211
|
-
const sessionId = req.query['session_id'];
|
|
1184
|
+
this.app.get('/ai-hub/payment-success', (req, res) => {
|
|
1212
1185
|
const email = req.query['email'];
|
|
1213
1186
|
// Persist the buyer's email so bootstrap can look up entitlements by userId.
|
|
1214
1187
|
if (email) {
|
|
@@ -1223,17 +1196,8 @@ class AiHubServer {
|
|
|
1223
1196
|
console.warn('[ai-hub] could not persist userEmail:', err);
|
|
1224
1197
|
}
|
|
1225
1198
|
}
|
|
1226
|
-
|
|
1227
|
-
|
|
1228
|
-
const { syncPersonaEntitlementFromCheckoutSession } = await Promise.resolve().then(() => __importStar(require('../services/persona-entitlement-service')));
|
|
1229
|
-
const { stripe } = await Promise.resolve().then(() => __importStar(require('../config/stripe')));
|
|
1230
|
-
const session = await stripe.checkout.sessions.retrieve(sessionId);
|
|
1231
|
-
await syncPersonaEntitlementFromCheckoutSession(this.dbService, session, 'dashboard-link');
|
|
1232
|
-
}
|
|
1233
|
-
catch (err) {
|
|
1234
|
-
console.warn('[ai-hub] payment-success entitlement sync failed:', err);
|
|
1235
|
-
}
|
|
1236
|
-
}
|
|
1199
|
+
// Issue #749: the Hub holds no DB. Entitlement is synced authoritatively by the backend
|
|
1200
|
+
// Stripe webhook; the Hub re-reads status via the remote gateway on the next bootstrap.
|
|
1237
1201
|
res.redirect('/ai-hub/?hired=1');
|
|
1238
1202
|
});
|
|
1239
1203
|
this.app.use('/ai-hub', express_1.default.static(resolveAiHubPublicDir()));
|
|
@@ -1322,20 +1286,6 @@ class AiHubServer {
|
|
|
1322
1286
|
}
|
|
1323
1287
|
async start(port) {
|
|
1324
1288
|
this.httpPort = port;
|
|
1325
|
-
if (this.dbService) {
|
|
1326
|
-
try {
|
|
1327
|
-
await this.dbService.connect();
|
|
1328
|
-
const mongoClient = this.dbService.getClient();
|
|
1329
|
-
if (mongoClient) {
|
|
1330
|
-
const PaymentRepositoryImpl = require('../db/payment-repository').PaymentRepository;
|
|
1331
|
-
this.paymentRepo = new PaymentRepositoryImpl(mongoClient);
|
|
1332
|
-
}
|
|
1333
|
-
}
|
|
1334
|
-
catch (err) {
|
|
1335
|
-
console.warn('[ai-hub] DB connect failed — personas will show as locked:', err);
|
|
1336
|
-
this.dbService = undefined;
|
|
1337
|
-
}
|
|
1338
|
-
}
|
|
1339
1289
|
await new Promise((resolve, reject) => {
|
|
1340
1290
|
this.httpServer = this.app.listen(port, '127.0.0.1');
|
|
1341
1291
|
this.httpServer.once('listening', () => resolve());
|
|
@@ -1406,10 +1356,6 @@ class AiHubServer {
|
|
|
1406
1356
|
// #521: tear down the shared browser if WE launched it (stop() no-ops on a
|
|
1407
1357
|
// browser the manager owns).
|
|
1408
1358
|
this.managedBrowser.stop();
|
|
1409
|
-
if (this.ownsDbService && this.dbService) {
|
|
1410
|
-
await this.dbService.close();
|
|
1411
|
-
this.dbService = undefined;
|
|
1412
|
-
}
|
|
1413
1359
|
}
|
|
1414
1360
|
getHttpsPort() { return this.httpsPort; }
|
|
1415
1361
|
knownProjects(projectPath, extras = []) {
|
|
@@ -1422,7 +1368,7 @@ class AiHubServer {
|
|
|
1422
1368
|
...extras,
|
|
1423
1369
|
], normalizedProjectPath, { removedProjectPaths: preferences.removedProjectPaths || [] });
|
|
1424
1370
|
}
|
|
1425
|
-
async bootstrapResponse(projectPath
|
|
1371
|
+
async bootstrapResponse(projectPath) {
|
|
1426
1372
|
const normalizedProjectPath = path_1.default.resolve(projectPath || this.projectPath);
|
|
1427
1373
|
const employees = this.hostRuntime.detectEmployees();
|
|
1428
1374
|
let preferences = this.preferencesStore.load(normalizedProjectPath);
|
|
@@ -1449,9 +1395,12 @@ class AiHubServer {
|
|
|
1449
1395
|
requiredPersonaKey: getProtectedPersonaForHubJob(job.id),
|
|
1450
1396
|
}));
|
|
1451
1397
|
const managerTemplates = (0, catalog_1.discoverManagerTemplates)(normalizedProjectPath, catalogOptions);
|
|
1452
|
-
|
|
1398
|
+
// Issue #750: the apiKey always comes from ~/.fraim/config.json — no header
|
|
1399
|
+
// override, no ai-hub-state.json copy, no fallback chain.
|
|
1400
|
+
const resolvedApiKey = resolveApiKey();
|
|
1453
1401
|
const { personas, subscriptionActive, workspaceId, userKey } = await this.computePersonas(resolvedApiKey);
|
|
1454
1402
|
const managerTeam = await this.computeManagerTeam(resolvedApiKey);
|
|
1403
|
+
const resolvedUserEmail = userKey ?? null;
|
|
1455
1404
|
const projects = this.knownProjects(normalizedProjectPath);
|
|
1456
1405
|
preferences = { ...preferences, projectPath: normalizedProjectPath, projects };
|
|
1457
1406
|
this.preferencesStore.save(preferences);
|
|
@@ -1463,8 +1412,15 @@ class AiHubServer {
|
|
|
1463
1412
|
return {
|
|
1464
1413
|
title: 'AI Hub',
|
|
1465
1414
|
remoteBaseUrl: (0, remote_hub_gateway_1.resolveFraimRemoteUrl)(),
|
|
1415
|
+
// #755: the running build version, so the account menu can show it and flag
|
|
1416
|
+
// when a newer version is available (see GET /api/ai-hub/version for `latest`).
|
|
1417
|
+
version: (0, version_utils_1.getFraimVersion)(),
|
|
1466
1418
|
project,
|
|
1467
|
-
|
|
1419
|
+
// Issue #750: `apiKey` is no longer a persisted AiHubPreferences field —
|
|
1420
|
+
// it is overlaid on the wire response only, freshly resolved from
|
|
1421
|
+
// ~/.fraim/config.json, so public/ai-hub/script.js's existing
|
|
1422
|
+
// tfConnectedApiKey()/tfConnectedSurfaceUrl read path needs no changes.
|
|
1423
|
+
preferences: { ...preferences, apiKey: resolvedApiKey },
|
|
1468
1424
|
categories: (0, catalog_1.getAiHubCategories)(normalizedProjectPath, catalogOptions),
|
|
1469
1425
|
jobs,
|
|
1470
1426
|
managerTemplates,
|
|
@@ -1476,10 +1432,11 @@ class AiHubServer {
|
|
|
1476
1432
|
// Issue #512 (S3) — additive manager-flow projections.
|
|
1477
1433
|
firstRun: this.computeFirstRun(normalizedProjectPath, jobs.length, personas),
|
|
1478
1434
|
teamContext: this.computeTeamContext(normalizedProjectPath),
|
|
1479
|
-
brain: this.computeBrain(normalizedProjectPath, jobs.length + managerTemplates.length),
|
|
1480
|
-
// #533: the resolved account email
|
|
1481
|
-
//
|
|
1482
|
-
|
|
1435
|
+
brain: this.computeBrain(normalizedProjectPath, jobs.length + managerTemplates.length, resolvedUserEmail),
|
|
1436
|
+
// #533/#750: the resolved account email (from the same ~/.fraim/config.json
|
|
1437
|
+
// apiKey used for personas above), or null when not connected — so the
|
|
1438
|
+
// profile card and personas can never disagree about who's signed in.
|
|
1439
|
+
userEmail: resolvedUserEmail,
|
|
1483
1440
|
// #744: the org cobrand identity (name/color/logo) from the org context
|
|
1484
1441
|
// storage, or null when unset so the Hub falls back to FRAIM identity.
|
|
1485
1442
|
orgBrand: (0, learning_context_builder_1.readOrgBrand)(normalizedProjectPath),
|
|
@@ -1866,8 +1823,19 @@ class AiHubServer {
|
|
|
1866
1823
|
}
|
|
1867
1824
|
// Issue #512 (S3, R14) — Brain summary: preserved-learning counts by scope +
|
|
1868
1825
|
// registry catalog counts. A read projection; no new storage.
|
|
1869
|
-
|
|
1870
|
-
|
|
1826
|
+
// Issue #750: `userEmail` is the config.json-resolved identity, or `null` when
|
|
1827
|
+
// not connected. `organization`/`rawSignals` are identity-independent (org-*
|
|
1828
|
+
// files and repo-level raw signals) and must always be counted regardless of
|
|
1829
|
+
// connection state — only `manager`/`project` (L1, individual-keyed) are
|
|
1830
|
+
// zeroed when not connected, rather than guessing an identity via
|
|
1831
|
+
// resolveLearningUserId's single-stamped-user fallback (a legitimate
|
|
1832
|
+
// convenience for MCP auto-load context, but not appropriate here).
|
|
1833
|
+
computeBrain(projectPath, jobCount, userEmail) {
|
|
1834
|
+
const learnings = (0, learning_context_builder_1.countPreservedLearnings)(projectPath, userEmail || '');
|
|
1835
|
+
if (!userEmail) {
|
|
1836
|
+
learnings.manager = 0;
|
|
1837
|
+
learnings.project = 0;
|
|
1838
|
+
}
|
|
1871
1839
|
return {
|
|
1872
1840
|
learnings,
|
|
1873
1841
|
catalog: {
|
|
@@ -1970,15 +1938,7 @@ class AiHubServer {
|
|
|
1970
1938
|
// passes raw invocation syntax.
|
|
1971
1939
|
const explicit = (0, manager_turns_1.extractExplicitFraimInvocation)(instructions);
|
|
1972
1940
|
const effectiveJobId = explicit?.jobId || coachingJobId || run.jobId;
|
|
1973
|
-
// #730: the manager thread shows the coaching turn with high fidelity — the
|
|
1974
|
-
// correct FRAIM command in front plus what the manager actually said — built
|
|
1975
|
-
// from the shared normalizer, exactly like the start path. With no
|
|
1976
|
-
// instructions (e.g. a plain coaching-template click) buildManagerMessage
|
|
1977
|
-
// collapses to just the invocation. The employee additionally receives the
|
|
1978
|
-
// communication-style note (and, per #732 below, a lighter same-job payload);
|
|
1979
|
-
// those operational details stay out of the visible bubble.
|
|
1980
1941
|
const userText = (explicit?.remainder || instructions || '').trim();
|
|
1981
|
-
const display = (0, manager_turns_1.buildManagerMessage)(run.hostId, effectiveJobId, 'continue', instructions);
|
|
1982
1942
|
// Issue #732: a plain continue of the SAME active real job must not re-load
|
|
1983
1943
|
// the job via get_fraim_job on every coaching turn — the host session is
|
|
1984
1944
|
// resumed with the job already in context, so re-fetching is wasted latency
|
|
@@ -1987,9 +1947,26 @@ class AiHubServer {
|
|
|
1987
1947
|
// template or an explicit /fraim <other>). Freeform/adhoc runs keep the
|
|
1988
1948
|
// existing buildManagerMessage path (which already emits no invocation).
|
|
1989
1949
|
const switchesJob = effectiveJobId !== run.jobId;
|
|
1990
|
-
const
|
|
1991
|
-
|
|
1992
|
-
|
|
1950
|
+
const showsInvocation = switchesJob || run.jobId === '__freeform__';
|
|
1951
|
+
// Issue #756: the manager bubble must MIRROR what actually happens. A
|
|
1952
|
+
// same-job coaching continue resumes the active job WITHOUT re-invoking it
|
|
1953
|
+
// (the `message` below carries no `/fraim`), so the bubble shows only what
|
|
1954
|
+
// the manager SAID — never a `/fraim <same-job>` line, which reads as a full
|
|
1955
|
+
// re-run of the active job Y and misled managers into thinking coaching
|
|
1956
|
+
// restarts the workflow. Only a real job SWITCH actually issues an
|
|
1957
|
+
// invocation, so only then does the bubble lead with the command. This still
|
|
1958
|
+
// upholds the #730 guarantee: the manager's coaching text is never dropped,
|
|
1959
|
+
// and when a command IS issued it appears in front of that text.
|
|
1960
|
+
// The invocation form (`/fraim <job>` + words) is built once and reused for
|
|
1961
|
+
// both the bubble and the agent payload when a command is actually issued.
|
|
1962
|
+
const invocationForm = showsInvocation
|
|
1963
|
+
? (0, manager_turns_1.buildManagerMessage)(run.hostId, effectiveJobId, 'continue', instructions)
|
|
1964
|
+
: null;
|
|
1965
|
+
// Bubble: the command form for a real switch, else the manager's own words.
|
|
1966
|
+
const display = invocationForm ?? userText;
|
|
1967
|
+
// Agent payload: the same command form for a switch, else a lightweight
|
|
1968
|
+
// same-job continue; the communication-style note is agent-only.
|
|
1969
|
+
const message = (invocationForm ?? (0, manager_turns_1.buildSameJobContinueMessage)(userText)) + (0, manager_turns_1.buildCommunicationStyleNote)();
|
|
1993
1970
|
return { message, display };
|
|
1994
1971
|
}
|
|
1995
1972
|
async computePersonas(apiKey) {
|
|
@@ -2053,51 +2030,48 @@ class AiHubServer {
|
|
|
2053
2030
|
// Issue #701: manager team comes from the hosted server, not local Mongo.
|
|
2054
2031
|
return this.remoteGateway.listManagerTeam(apiKey);
|
|
2055
2032
|
}
|
|
2033
|
+
// Issue #750: identity for routes that don't already call computePersonas()
|
|
2034
|
+
// (which fetches this same hosted state for personas/manager-team). Always
|
|
2035
|
+
// sources the apiKey from ~/.fraim/config.json; null means "not connected" —
|
|
2036
|
+
// no local-guess fallback.
|
|
2037
|
+
async resolveHubIdentity() {
|
|
2038
|
+
const apiKey = resolveApiKey();
|
|
2039
|
+
if (!apiKey)
|
|
2040
|
+
return null;
|
|
2041
|
+
const state = await this.remoteGateway.getPersonaState(apiKey);
|
|
2042
|
+
return state?.userId ?? null;
|
|
2043
|
+
}
|
|
2044
|
+
// Issue #750: shared by the two learnings routes below. 'org' scope never
|
|
2045
|
+
// needs identity (readPreservedLearnings/applyLearningEntryChange ignore the
|
|
2046
|
+
// userId param for it); every other scope resolves through resolveHubIdentity,
|
|
2047
|
+
// returning '' (not a guess) when not connected — callers distinguish
|
|
2048
|
+
// "org" from "not connected" via the scope check they already have to make.
|
|
2049
|
+
async resolveLearningIdentity(scope) {
|
|
2050
|
+
if (scope === 'org')
|
|
2051
|
+
return '';
|
|
2052
|
+
return (await this.resolveHubIdentity()) || '';
|
|
2053
|
+
}
|
|
2056
2054
|
registerRoutes() {
|
|
2057
|
-
// Issue #512:
|
|
2058
|
-
//
|
|
2059
|
-
|
|
2060
|
-
|
|
2061
|
-
|
|
2062
|
-
|
|
2063
|
-
|
|
2064
|
-
|
|
2065
|
-
|
|
2066
|
-
|
|
2067
|
-
|
|
2068
|
-
|
|
2069
|
-
|
|
2070
|
-
|
|
2071
|
-
|
|
2072
|
-
|
|
2073
|
-
|
|
2074
|
-
|
|
2075
|
-
|
|
2076
|
-
|
|
2077
|
-
});
|
|
2078
|
-
}
|
|
2079
|
-
else {
|
|
2080
|
-
this.app.get('/account', (_req, res) => {
|
|
2081
|
-
const filePath = path_1.default.join(publicDir, 'account', 'index.html');
|
|
2082
|
-
if (fs_1.default.existsSync(filePath)) {
|
|
2083
|
-
res.sendFile(filePath);
|
|
2084
|
-
}
|
|
2085
|
-
else {
|
|
2086
|
-
res.status(404).send('Account page not found.');
|
|
2087
|
-
}
|
|
2088
|
-
});
|
|
2089
|
-
this.app.use('/account', express_1.default.static(path_1.default.join(publicDir, 'account')));
|
|
2090
|
-
this.app.get('/analytics', (_req, res) => {
|
|
2091
|
-
const filePath = path_1.default.join(publicDir, 'analytics', 'index.html');
|
|
2092
|
-
if (fs_1.default.existsSync(filePath)) {
|
|
2093
|
-
res.sendFile(filePath);
|
|
2094
|
-
}
|
|
2095
|
-
else {
|
|
2096
|
-
res.status(404).send('Analytics page not found.');
|
|
2097
|
-
}
|
|
2098
|
-
});
|
|
2099
|
-
this.app.use('/analytics', express_1.default.static(path_1.default.join(publicDir, 'analytics')));
|
|
2100
|
-
}
|
|
2055
|
+
// Issue #512 / #749: the account and analytics surfaces live outside /ai-hub. The
|
|
2056
|
+
// on-machine Hub holds no DB, so it always redirects these (and /auth) to the hosted
|
|
2057
|
+
// server, which owns the authenticated, DB-backed pages.
|
|
2058
|
+
this.app.get(/^\/auth(\/.*)?$/, (req, res) => {
|
|
2059
|
+
const parsed = new URL(req.originalUrl, 'http://127.0.0.1');
|
|
2060
|
+
if (parsed.pathname === '/auth/sign-in.html' && !parsed.searchParams.has('hub_return')) {
|
|
2061
|
+
const rawSurface = parsed.searchParams.get('surface');
|
|
2062
|
+
const surface = rawSurface === 'account' || rawSurface === 'brain' || rawSurface === 'analytics'
|
|
2063
|
+
? rawSurface
|
|
2064
|
+
: 'analytics';
|
|
2065
|
+
parsed.searchParams.set('hub_return', buildLocalHubReturnUrl(req, surface));
|
|
2066
|
+
}
|
|
2067
|
+
res.redirect(buildHostedPathUrl(parsed.pathname, parsed.search));
|
|
2068
|
+
});
|
|
2069
|
+
this.app.get(['/account', '/account/'], (req, res) => {
|
|
2070
|
+
res.redirect(buildHostedAuthUrl('account', '/account/', buildLocalHubReturnUrl(req, 'account')));
|
|
2071
|
+
});
|
|
2072
|
+
this.app.get(['/analytics', '/analytics/'], (req, res) => {
|
|
2073
|
+
res.redirect(buildHostedAuthUrl('analytics', '/analytics/', buildLocalHubReturnUrl(req, 'analytics')));
|
|
2074
|
+
});
|
|
2101
2075
|
// Issue #478: Serve the PowerPoint task pane HTML and manifest.
|
|
2102
2076
|
// Office JS appends query strings (?_host_Info=PowerPoint$Win32$...) to every
|
|
2103
2077
|
// request, so we must strip them before resolving the file path. Use a custom
|
|
@@ -2149,8 +2123,6 @@ class AiHubServer {
|
|
|
2149
2123
|
}
|
|
2150
2124
|
catch { }
|
|
2151
2125
|
}
|
|
2152
|
-
// Read API key from header — query-param API keys are prohibited (§3.14)
|
|
2153
|
-
const apiKey = typeof req.headers['x-fraim-api-key'] === 'string' ? req.headers['x-fraim-api-key'] : undefined;
|
|
2154
2126
|
// #719: a bare reload (no projectPath query) must land on the last-recorded
|
|
2155
2127
|
// workspace project, not the launch folder. The bootstrap saves its resolved
|
|
2156
2128
|
// current path back into the projects list, so defaulting to the launch
|
|
@@ -2164,17 +2136,18 @@ class AiHubServer {
|
|
|
2164
2136
|
projectPath = recorded;
|
|
2165
2137
|
}
|
|
2166
2138
|
}
|
|
2167
|
-
res.json(await this.bootstrapResponse(projectPath || this.projectPath
|
|
2139
|
+
res.json(await this.bootstrapResponse(projectPath || this.projectPath));
|
|
2168
2140
|
});
|
|
2169
2141
|
// Issue #512 (S3, R14) — Brain summary as a standalone route, returning the
|
|
2170
2142
|
// same projection folded into bootstrap. Useful for the avatar→Brain view
|
|
2171
2143
|
// without re-fetching the whole bootstrap payload.
|
|
2172
|
-
this.app.get('/api/ai-hub/brain', (req, res) => {
|
|
2144
|
+
this.app.get('/api/ai-hub/brain', async (req, res) => {
|
|
2173
2145
|
const projectPath = typeof req.query.projectPath === 'string' && req.query.projectPath.length > 0
|
|
2174
2146
|
? path_1.default.resolve(req.query.projectPath)
|
|
2175
2147
|
: this.projectPath;
|
|
2176
2148
|
const jobCount = (0, catalog_1.discoverEmployeeJobs)(projectPath).length + (0, catalog_1.discoverManagerTemplates)(projectPath).length;
|
|
2177
|
-
|
|
2149
|
+
const userEmail = await this.resolveHubIdentity();
|
|
2150
|
+
return res.json(this.computeBrain(projectPath, jobCount, userEmail));
|
|
2178
2151
|
});
|
|
2179
2152
|
// #533: read the PRESERVED learnings for a section + storage level so the
|
|
2180
2153
|
// Company/Manager sections (machine level) and the project workspace (project
|
|
@@ -2187,7 +2160,7 @@ class AiHubServer {
|
|
|
2187
2160
|
: (typeof (req.body && req.body.projectPath) === 'string' && req.body.projectPath.length > 0
|
|
2188
2161
|
? path_1.default.resolve(req.body.projectPath)
|
|
2189
2162
|
: this.projectPath);
|
|
2190
|
-
this.app.get('/api/ai-hub/learnings', (req, res) => {
|
|
2163
|
+
this.app.get('/api/ai-hub/learnings', async (req, res) => {
|
|
2191
2164
|
const scope = req.query.scope;
|
|
2192
2165
|
if (typeof scope !== 'string' || !VALID_SCOPES.includes(scope)) {
|
|
2193
2166
|
return res.status(400).json({ error: `scope must be one of: ${VALID_SCOPES.join(', ')}` });
|
|
@@ -2195,7 +2168,11 @@ class AiHubServer {
|
|
|
2195
2168
|
const level = (typeof req.query.level === 'string' && VALID_LEVELS.includes(req.query.level))
|
|
2196
2169
|
? req.query.level : 'machine';
|
|
2197
2170
|
try {
|
|
2198
|
-
const
|
|
2171
|
+
const userEmail = await this.resolveLearningIdentity(scope);
|
|
2172
|
+
if (scope !== 'org' && !userEmail) {
|
|
2173
|
+
return res.json({ scope, level, entries: [] });
|
|
2174
|
+
}
|
|
2175
|
+
const entries = (0, learning_context_builder_1.readPreservedLearnings)(resolveProjectPath(req), userEmail, scope, level);
|
|
2199
2176
|
return res.json({ scope, level, entries });
|
|
2200
2177
|
}
|
|
2201
2178
|
catch (error) {
|
|
@@ -2205,7 +2182,7 @@ class AiHubServer {
|
|
|
2205
2182
|
// #533 §3: add / edit / delete a single learning entry — writes the real file
|
|
2206
2183
|
// at the section's level (machine for the Manager/Company tabs, project for the
|
|
2207
2184
|
// project workspace). The action targets exactly the card's file.
|
|
2208
|
-
this.app.post('/api/ai-hub/learnings/entry', (req, res) => {
|
|
2185
|
+
this.app.post('/api/ai-hub/learnings/entry', async (req, res) => {
|
|
2209
2186
|
const b = (req.body || {});
|
|
2210
2187
|
if (!['add', 'edit', 'delete'].includes(b.action || '')) {
|
|
2211
2188
|
return res.status(400).json({ error: 'action must be one of: add, edit, delete' });
|
|
@@ -2216,8 +2193,14 @@ class AiHubServer {
|
|
|
2216
2193
|
return res.status(400).json({ error: `category must be one of: ${VALID_CATEGORIES.join(', ')}` });
|
|
2217
2194
|
const level = (b.level && VALID_LEVELS.includes(b.level)) ? b.level : 'machine';
|
|
2218
2195
|
const ref = { scope: b.scope, level, category: b.category };
|
|
2196
|
+
// Issue #750: writes to a personal-scope file need a resolved identity —
|
|
2197
|
+
// fail explicitly rather than writing to a guessed or empty-string file.
|
|
2198
|
+
const userEmail = await this.resolveLearningIdentity(b.scope);
|
|
2199
|
+
if (b.scope !== 'org' && !userEmail) {
|
|
2200
|
+
return res.status(400).json({ error: 'not_connected', message: 'No FRAIM identity resolved. Run `fraim setup` first.' });
|
|
2201
|
+
}
|
|
2219
2202
|
try {
|
|
2220
|
-
const result = (0, learning_context_builder_1.applyLearningEntryChange)(resolveProjectPath(req),
|
|
2203
|
+
const result = (0, learning_context_builder_1.applyLearningEntryChange)(resolveProjectPath(req), userEmail, ref, b.action, {
|
|
2221
2204
|
originalTitle: b.originalTitle,
|
|
2222
2205
|
severity: b.severity,
|
|
2223
2206
|
title: b.title,
|
|
@@ -2229,24 +2212,22 @@ class AiHubServer {
|
|
|
2229
2212
|
return res.status(400).json({ error: error instanceof Error ? error.message : 'Could not write learning.' });
|
|
2230
2213
|
}
|
|
2231
2214
|
});
|
|
2232
|
-
// Issue #540/#701: POST /api/ai-hub/manager-team/assign
|
|
2233
|
-
// Proxies to the hosted server (which owns the DB + seat enforcement)
|
|
2234
|
-
//
|
|
2215
|
+
// Issue #540/#701/#750: POST /api/ai-hub/manager-team/assign
|
|
2216
|
+
// Proxies to the hosted server (which owns the DB + seat enforcement), using
|
|
2217
|
+
// the Hub's own ~/.fraim/config.json apiKey — not a caller-supplied header.
|
|
2235
2218
|
// Hosted returns 404 no_company_seat / 409 out_of_stock; those are passed through.
|
|
2236
2219
|
this.app.post('/api/ai-hub/manager-team/assign', async (req, res) => {
|
|
2237
|
-
const apiKey = typeof req.headers['x-fraim-api-key'] === 'string' ? req.headers['x-fraim-api-key'] : undefined;
|
|
2238
2220
|
const { personaKey } = (req.body ?? {});
|
|
2239
2221
|
if (!personaKey)
|
|
2240
2222
|
return res.status(400).json({ error: 'personaKey required' });
|
|
2241
|
-
const result = await this.remoteGateway.assignManagerTeam(
|
|
2223
|
+
const result = await this.remoteGateway.assignManagerTeam(resolveApiKey(), personaKey);
|
|
2242
2224
|
return res.status(result.status).json(result.body);
|
|
2243
2225
|
});
|
|
2244
|
-
// Issue #540/#701: DELETE /api/ai-hub/manager-team/assign/:personaKey
|
|
2226
|
+
// Issue #540/#701/#750: DELETE /api/ai-hub/manager-team/assign/:personaKey
|
|
2245
2227
|
// Proxies the seat release to the hosted server.
|
|
2246
2228
|
this.app.delete('/api/ai-hub/manager-team/assign/:personaKey', async (req, res) => {
|
|
2247
|
-
const apiKey = typeof req.headers['x-fraim-api-key'] === 'string' ? req.headers['x-fraim-api-key'] : undefined;
|
|
2248
2229
|
const { personaKey } = req.params;
|
|
2249
|
-
await this.remoteGateway.removeManagerTeam(
|
|
2230
|
+
await this.remoteGateway.removeManagerTeam(resolveApiKey(), personaKey);
|
|
2250
2231
|
return res.status(204).end();
|
|
2251
2232
|
});
|
|
2252
2233
|
// Issue #708: manager/company scopes resolve to a project-independent sentinel bucket
|
|
@@ -2277,7 +2258,27 @@ class AiHubServer {
|
|
|
2277
2258
|
// deletes are honored (a conversation absent here is dropped).
|
|
2278
2259
|
const prior = this.conversationStore.loadProject(key);
|
|
2279
2260
|
const priorById = new Map(prior.conversations.map((entry) => [entry.id, entry]));
|
|
2280
|
-
|
|
2261
|
+
// Write-boundary guard for the cross-project leak (docs/rca/hub-conversation-cross-project-leak.md):
|
|
2262
|
+
// a project PUT persists the client's current-project list, but that list can transiently
|
|
2263
|
+
// include a conversation belonging to ANOTHER project (e.g. the just-finished run of the
|
|
2264
|
+
// project the user switched away from). Never file such a record into this project bucket —
|
|
2265
|
+
// its authoritative home is its own project bucket. Scope (manager/company) PUTs are exempt:
|
|
2266
|
+
// their sentinel buckets legitimately hold records whose projectPath points at a project.
|
|
2267
|
+
const belongsInBucket = (incoming) => {
|
|
2268
|
+
if (scope)
|
|
2269
|
+
return true;
|
|
2270
|
+
const incomingScope = incoming.scope
|
|
2271
|
+
?? incoming.invokedArea;
|
|
2272
|
+
if (incomingScope === 'manager' || incomingScope === 'company')
|
|
2273
|
+
return false;
|
|
2274
|
+
const own = incoming && typeof incoming.projectPath === 'string' ? incoming.projectPath : '';
|
|
2275
|
+
if (!own)
|
|
2276
|
+
return true;
|
|
2277
|
+
return normalizedDirectoryPath(own) === normalizedDirectoryPath(key);
|
|
2278
|
+
};
|
|
2279
|
+
const conversations = body.conversations
|
|
2280
|
+
.filter((incoming) => belongsInBucket(incoming))
|
|
2281
|
+
.map((incoming) => {
|
|
2281
2282
|
const existing = incoming && incoming.id ? priorById.get(incoming.id) : undefined;
|
|
2282
2283
|
return existing ? { ...existing, ...incoming } : incoming;
|
|
2283
2284
|
});
|
|
@@ -2375,20 +2376,24 @@ class AiHubServer {
|
|
|
2375
2376
|
const projects = this.preferencesStore.removeProject(projectPath, known.filter((project) => project.id !== entry.id), entry.folderPath);
|
|
2376
2377
|
return res.json({ ok: true, projects });
|
|
2377
2378
|
});
|
|
2378
|
-
|
|
2379
|
-
|
|
2380
|
-
|
|
2381
|
-
return res.status(400).json({ error: 'apiKey required' });
|
|
2382
|
-
const prefs = this.preferencesStore.load(this.projectPath);
|
|
2383
|
-
this.preferencesStore.save({ ...prefs, apiKey });
|
|
2384
|
-
return res.json({ ok: true });
|
|
2385
|
-
});
|
|
2379
|
+
// Issue #750: POST /api/ai-hub/api-key is removed — there is no Hub-local
|
|
2380
|
+
// apiKey to persist. Identity always comes from ~/.fraim/config.json
|
|
2381
|
+
// (set by `fraim setup`), read fresh via resolveApiKey() on every request.
|
|
2386
2382
|
this.app.post('/api/ai-hub/preferences', (req, res) => {
|
|
2387
2383
|
const { personaKey } = req.body;
|
|
2388
2384
|
const prefs = this.preferencesStore.load(this.projectPath);
|
|
2389
2385
|
this.preferencesStore.save({ ...prefs, personaKey: personaKey ?? null });
|
|
2390
2386
|
return res.json({ ok: true });
|
|
2391
2387
|
});
|
|
2388
|
+
// #755: running build version + best-effort latest published version, so the
|
|
2389
|
+
// account menu can show the version and flag when an update is available. The
|
|
2390
|
+
// `fraim hub` CLI also queries this to confirm a running instance's version.
|
|
2391
|
+
this.app.get('/api/ai-hub/version', async (_req, res) => {
|
|
2392
|
+
const version = (0, version_utils_1.getFraimVersion)();
|
|
2393
|
+
const latest = await (0, hub_latest_version_1.getLatestPublishedVersion)();
|
|
2394
|
+
const updateAvailable = !!(latest && semver.valid(version) && semver.valid(latest) && semver.gt(latest, version));
|
|
2395
|
+
return res.json({ version, latest, updateAvailable });
|
|
2396
|
+
});
|
|
2392
2397
|
this.app.post('/api/ai-hub/project-path/pick', async (_req, res) => {
|
|
2393
2398
|
try {
|
|
2394
2399
|
const projectPath = await this.folderPicker();
|
|
@@ -45,6 +45,14 @@ const git_utils_1 = require("../../core/utils/git-utils");
|
|
|
45
45
|
const path_1 = __importDefault(require("path"));
|
|
46
46
|
const child_process_1 = require("child_process");
|
|
47
47
|
const fs_1 = __importDefault(require("fs"));
|
|
48
|
+
const net_1 = __importDefault(require("net"));
|
|
49
|
+
const http_1 = __importDefault(require("http"));
|
|
50
|
+
// #755: light, server-free modules — safe to import eagerly in the packed client
|
|
51
|
+
// (unlike ai-hub/server, which is lazy-required per #422).
|
|
52
|
+
const hub_launch_decision_1 = require("../../ai-hub/hub-launch-decision");
|
|
53
|
+
const hub_runtime_file_1 = require("../../ai-hub/hub-runtime-file");
|
|
54
|
+
const project_fraim_paths_1 = require("../../core/utils/project-fraim-paths");
|
|
55
|
+
const version_utils_1 = require("../utils/version-utils");
|
|
48
56
|
function resolveElectronBinary() {
|
|
49
57
|
try {
|
|
50
58
|
// eslint-disable-next-line @typescript-eslint/no-var-requires
|
|
@@ -97,18 +105,116 @@ function openBrowser(url) {
|
|
|
97
105
|
const child = (0, child_process_1.spawn)('xdg-open', [url], { detached: true, stdio: 'ignore' });
|
|
98
106
|
child.unref();
|
|
99
107
|
}
|
|
108
|
+
// #755: version-aware kill-and-replace. Electron's single-instance lock otherwise
|
|
109
|
+
// re-focuses a stale running build instead of launching a newly-released one.
|
|
110
|
+
function isProcessAlive(pid) {
|
|
111
|
+
try {
|
|
112
|
+
process.kill(pid, 0);
|
|
113
|
+
return true;
|
|
114
|
+
}
|
|
115
|
+
catch (e) {
|
|
116
|
+
// EPERM => the process exists but we can't signal it (still "alive").
|
|
117
|
+
return !!(e && e.code === 'EPERM');
|
|
118
|
+
}
|
|
119
|
+
}
|
|
120
|
+
function killPid(pid) {
|
|
121
|
+
try {
|
|
122
|
+
if (process.platform === 'win32') {
|
|
123
|
+
(0, child_process_1.execFileSync)('taskkill', ['/PID', String(pid), '/T', '/F'], { stdio: 'ignore' });
|
|
124
|
+
}
|
|
125
|
+
else {
|
|
126
|
+
process.kill(pid, 'SIGTERM');
|
|
127
|
+
}
|
|
128
|
+
}
|
|
129
|
+
catch {
|
|
130
|
+
/* already gone */
|
|
131
|
+
}
|
|
132
|
+
}
|
|
133
|
+
function isPortFree(port, timeoutMs = 500) {
|
|
134
|
+
return new Promise((resolve) => {
|
|
135
|
+
const sock = net_1.default.connect({ host: '127.0.0.1', port }, () => { sock.destroy(); resolve(false); });
|
|
136
|
+
sock.on('error', () => resolve(true));
|
|
137
|
+
sock.setTimeout(timeoutMs, () => { sock.destroy(); resolve(true); });
|
|
138
|
+
});
|
|
139
|
+
}
|
|
140
|
+
async function waitForPortFree(port, totalMs = 5000) {
|
|
141
|
+
const start = Date.now();
|
|
142
|
+
while (Date.now() - start < totalMs) {
|
|
143
|
+
if (await isPortFree(port))
|
|
144
|
+
return;
|
|
145
|
+
await new Promise((r) => setTimeout(r, 200));
|
|
146
|
+
}
|
|
147
|
+
}
|
|
148
|
+
/**
|
|
149
|
+
* Reconcile a running desktop Hub with the version about to launch. Replaces a
|
|
150
|
+
* stale/older instance (or any instance under --restart) so the new build takes
|
|
151
|
+
* over; keeps the running instance under --keep-running.
|
|
152
|
+
*/
|
|
153
|
+
/** Confirm the recorded port actually serves a FRAIM Hub, returning its authoritative version. */
|
|
154
|
+
function fetchRunningHubVersion(port) {
|
|
155
|
+
return new Promise((resolve) => {
|
|
156
|
+
const req = http_1.default.get({ host: '127.0.0.1', port, path: '/api/ai-hub/version', timeout: 1000 }, (res) => {
|
|
157
|
+
if (res.statusCode !== 200) {
|
|
158
|
+
res.resume();
|
|
159
|
+
return resolve(null);
|
|
160
|
+
}
|
|
161
|
+
let body = '';
|
|
162
|
+
res.on('data', (c) => { body += c; });
|
|
163
|
+
res.on('end', () => { try {
|
|
164
|
+
resolve(JSON.parse(body).version ?? null);
|
|
165
|
+
}
|
|
166
|
+
catch {
|
|
167
|
+
resolve(null);
|
|
168
|
+
} });
|
|
169
|
+
});
|
|
170
|
+
req.on('error', () => resolve(null));
|
|
171
|
+
req.on('timeout', () => { req.destroy(); resolve(null); });
|
|
172
|
+
});
|
|
173
|
+
}
|
|
174
|
+
async function reconcileRunningHub(flags) {
|
|
175
|
+
const running = (0, hub_runtime_file_1.readHubRuntimeFile)((0, project_fraim_paths_1.getUserFraimDirPath)());
|
|
176
|
+
// Confirm the recorded port genuinely serves a FRAIM Hub before trusting the file.
|
|
177
|
+
// This (a) prevents killing an unrelated process that reused a crashed Hub's pid,
|
|
178
|
+
// and (b) yields the authoritative running version (the file may be stale).
|
|
179
|
+
const confirmedVersion = running ? await fetchRunningHubVersion(running.port) : null;
|
|
180
|
+
const live = !!(running && confirmedVersion && isProcessAlive(running.pid));
|
|
181
|
+
const effective = live && running ? { ...running, version: confirmedVersion } : null;
|
|
182
|
+
const decision = (0, hub_launch_decision_1.decideHubLaunch)({
|
|
183
|
+
running: effective,
|
|
184
|
+
pidAlive: live,
|
|
185
|
+
cliVersion: (0, version_utils_1.getFraimVersion)(),
|
|
186
|
+
restart: flags.restart,
|
|
187
|
+
noRestart: flags.keepRunning,
|
|
188
|
+
});
|
|
189
|
+
if (decision.action === 'replace' && effective) {
|
|
190
|
+
console.log(`Replacing running Hub (v${effective.version}, pid ${effective.pid}) — ${decision.reason}`);
|
|
191
|
+
killPid(effective.pid);
|
|
192
|
+
await waitForPortFree(effective.port);
|
|
193
|
+
}
|
|
194
|
+
else if (decision.action === 'focus-existing' && effective) {
|
|
195
|
+
console.log(`A Hub (v${effective.version}) is already running — focusing it. Use --restart to replace it.`);
|
|
196
|
+
}
|
|
197
|
+
}
|
|
100
198
|
exports.hubCommand = new commander_1.Command('hub')
|
|
101
199
|
.description('Start the AI Hub local companion for running FRAIM jobs through Codex or Claude Code')
|
|
102
200
|
.option('--port <port>', 'Preferred local port for the hub', (value) => Number(value), 43091)
|
|
103
201
|
.option('--project-path <path>', 'Initial project path for job discovery', process.cwd())
|
|
104
202
|
.option('--no-open', 'Do not open the hub after startup')
|
|
105
203
|
.option('--browser', 'Open in the default browser instead of the desktop shell')
|
|
204
|
+
.option('--restart', 'Replace any running Hub instance with this build (even at the same version)')
|
|
205
|
+
.option('--keep-running', 'Do not replace a running Hub; keep single-instance focus (default replaces only an older/stale instance)')
|
|
106
206
|
.action(async (options) => {
|
|
107
207
|
const { AiHubServer, findAvailablePort } = await Promise.resolve().then(() => __importStar(require('../../ai-hub/server')));
|
|
108
208
|
const preferredPort = options.port || (0, git_utils_1.getPort)() + 100;
|
|
109
209
|
const projectPath = path_1.default.resolve(options.projectPath || process.cwd());
|
|
110
210
|
if (options.open) {
|
|
111
|
-
const
|
|
211
|
+
const wantDesktop = !options.browser;
|
|
212
|
+
// #755: before spawning Electron (whose single-instance lock would re-focus a
|
|
213
|
+
// stale build), replace an older/forced running instance so the new build wins.
|
|
214
|
+
if (wantDesktop) {
|
|
215
|
+
await reconcileRunningHub({ restart: !!options.restart, keepRunning: !!options.keepRunning });
|
|
216
|
+
}
|
|
217
|
+
const openedDesktop = wantDesktop && openDesktopWindow(projectPath, preferredPort);
|
|
112
218
|
if (!openedDesktop) {
|
|
113
219
|
const port = await findAvailablePort(preferredPort);
|
|
114
220
|
const server = new AiHubServer({ projectPath });
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "fraim-framework",
|
|
3
|
-
"version": "2.0.
|
|
3
|
+
"version": "2.0.201",
|
|
4
4
|
"description": "FRAIM: AI Workforce Infrastructure — the organizational capability that turns AI agents into an accountable workforce, their operators into capable AI managers, and executives into leaders with clear optics on AI proficiency.",
|
|
5
5
|
"main": "index.js",
|
|
6
6
|
"bin": {
|
package/public/ai-hub/index.html
CHANGED
|
@@ -59,7 +59,12 @@
|
|
|
59
59
|
<span class="am-switch" aria-hidden="true"><span class="am-switch-knob"></span></span>
|
|
60
60
|
</button>
|
|
61
61
|
<div class="am-div"></div>
|
|
62
|
+
<div class="am-update" id="am-update" hidden>
|
|
63
|
+
<span class="am-ico">⬆️</span>
|
|
64
|
+
<div><div class="am-label">Update available</div><div class="am-sub" id="am-update-sub"></div></div>
|
|
65
|
+
</div>
|
|
62
66
|
<button class="am-foot" id="am-signout" type="button"><span class="am-ico">⏻</span><span>Sign out</span></button>
|
|
67
|
+
<div class="am-version" id="am-version" title="Running FRAIM Hub build"></div>
|
|
63
68
|
</div>
|
|
64
69
|
</div>
|
|
65
70
|
</nav>
|
package/public/ai-hub/script.js
CHANGED
|
@@ -9631,23 +9631,15 @@ async function tfHandleConnectedAuthMessage(event) {
|
|
|
9631
9631
|
}
|
|
9632
9632
|
}
|
|
9633
9633
|
|
|
9634
|
+
// Issue #750: the Hub's identity comes from ~/.fraim/config.json (set by
|
|
9635
|
+
// `fraim setup`), not a Hub-local copy — there is nothing to persist here.
|
|
9636
|
+
// A key recovered via embedded sign-in is used for the rest of this browser
|
|
9637
|
+
// session only (in-memory); it does not survive a Hub restart, by design.
|
|
9634
9638
|
async function tfRememberConnectedApiKey(apiKey) {
|
|
9635
|
-
const changed = apiKey !== state.storedApiKey;
|
|
9636
9639
|
state.storedApiKey = apiKey;
|
|
9637
9640
|
if (state.bootstrap && state.bootstrap.preferences) {
|
|
9638
9641
|
state.bootstrap.preferences.apiKey = apiKey;
|
|
9639
9642
|
}
|
|
9640
|
-
if (changed) {
|
|
9641
|
-
try {
|
|
9642
|
-
await requestJson('/api/ai-hub/api-key', {
|
|
9643
|
-
method: 'POST',
|
|
9644
|
-
headers: { 'Content-Type': 'application/json' },
|
|
9645
|
-
body: JSON.stringify({ apiKey }),
|
|
9646
|
-
});
|
|
9647
|
-
} catch (error) {
|
|
9648
|
-
console.warn('[ai-hub] Could not persist connected auth key:', error);
|
|
9649
|
-
}
|
|
9650
|
-
}
|
|
9651
9643
|
}
|
|
9652
9644
|
|
|
9653
9645
|
// ---------------------------------------------------------------------------
|
|
@@ -10352,6 +10344,12 @@ async function tfSwitchProjectFolder(folderPath) {
|
|
|
10352
10344
|
return;
|
|
10353
10345
|
}
|
|
10354
10346
|
state.projectPath = normalized.folderPath;
|
|
10347
|
+
// Cross-project leak fix (docs/rca/hub-conversation-cross-project-leak.md): drop the previous
|
|
10348
|
+
// project's active conversation on switch. Otherwise the just-finished run stays state.activeId
|
|
10349
|
+
// while state.projectPath is the new project, and a debounced conversation PUT during the
|
|
10350
|
+
// cached-bucket fast render below would flush that foreign conversation into the new project's
|
|
10351
|
+
// bucket. hydrateConversationsFromServer recomputes the correct activeId for this project.
|
|
10352
|
+
state.activeId = null;
|
|
10355
10353
|
// #521 fix: the context cache (state._ctxCache) is keyed by context key only
|
|
10356
10354
|
// (projectContext/projectBrief/projectRules), NOT by project — so without this
|
|
10357
10355
|
// the new project's Brief section would render the PREVIOUS project's content
|
|
@@ -10850,6 +10848,12 @@ function tfWireShell() {
|
|
|
10850
10848
|
// replacing the hardcoded "SM" placeholder. Falls back gracefully when the email
|
|
10851
10849
|
// isn't known yet (e.g. before the proxy has persisted it).
|
|
10852
10850
|
function tfAccountIdentity() {
|
|
10851
|
+
// Issue #750: bootstrap.userEmail is `null` (not just missing) once the server
|
|
10852
|
+
// has definitively resolved "no ~/.fraim/config.json apiKey" — distinct from
|
|
10853
|
+
// the transient pre-bootstrap window where state.bootstrap doesn't exist yet.
|
|
10854
|
+
if (state.bootstrap && state.bootstrap.userEmail === null) {
|
|
10855
|
+
return { email: '', name: 'You', initials: '·', notConnected: true };
|
|
10856
|
+
}
|
|
10853
10857
|
const email = (state.bootstrap && state.bootstrap.userEmail) || '';
|
|
10854
10858
|
if (!email || !email.includes('@')) {
|
|
10855
10859
|
return { email: '', name: 'You', initials: '·' };
|
|
@@ -10869,9 +10873,33 @@ function tfPopulateAccountMenu() {
|
|
|
10869
10873
|
const avEl = document.getElementById('am-av');
|
|
10870
10874
|
const avatarBtn = document.getElementById('avatar-btn');
|
|
10871
10875
|
if (nameEl) nameEl.textContent = id.name;
|
|
10872
|
-
if (emailEl) emailEl.textContent = id.email;
|
|
10876
|
+
if (emailEl) emailEl.textContent = id.notConnected ? 'Not connected — run `fraim setup`' : id.email;
|
|
10873
10877
|
if (avEl) avEl.textContent = id.initials;
|
|
10874
10878
|
if (avatarBtn) avatarBtn.textContent = id.initials;
|
|
10879
|
+
tfPopulateVersionInfo(); // #755
|
|
10880
|
+
}
|
|
10881
|
+
|
|
10882
|
+
// #755: show the running Hub build version and, when a newer version is published,
|
|
10883
|
+
// an update-available nudge. Running version is cheap (from bootstrap); the latest
|
|
10884
|
+
// check hits the server's cached /api/ai-hub/version (best-effort, hidden on failure).
|
|
10885
|
+
function tfPopulateVersionInfo() {
|
|
10886
|
+
const versionEl = document.getElementById('am-version');
|
|
10887
|
+
const running = (state.bootstrap && state.bootstrap.version) || '';
|
|
10888
|
+
if (versionEl) versionEl.textContent = running ? `FRAIM Hub v${running}` : '';
|
|
10889
|
+
const updateEl = document.getElementById('am-update');
|
|
10890
|
+
const updateSub = document.getElementById('am-update-sub');
|
|
10891
|
+
if (!updateEl) return;
|
|
10892
|
+
fetch('/api/ai-hub/version')
|
|
10893
|
+
.then((r) => (r.ok ? r.json() : null))
|
|
10894
|
+
.then((info) => {
|
|
10895
|
+
if (info && info.updateAvailable && info.latest) {
|
|
10896
|
+
if (updateSub) updateSub.textContent = `v${info.latest} is available — quit the Hub (tray → Quit) and relaunch to update.`;
|
|
10897
|
+
updateEl.hidden = false;
|
|
10898
|
+
} else {
|
|
10899
|
+
updateEl.hidden = true;
|
|
10900
|
+
}
|
|
10901
|
+
})
|
|
10902
|
+
.catch(() => { /* offline / best-effort: leave the nudge hidden */ });
|
|
10875
10903
|
}
|
|
10876
10904
|
|
|
10877
10905
|
function tfRequestedConnectedSurface() {
|
package/public/ai-hub/styles.css
CHANGED
|
@@ -2783,6 +2783,13 @@ body.hub-shell { display: flex; flex-direction: column; height: 100vh; overflow:
|
|
|
2783
2783
|
.am-div { height: 1px; background: var(--line); margin: 4px 0; }
|
|
2784
2784
|
.am-foot { padding: 8px 16px; font-size: 13px; color: var(--muted); cursor: pointer; }
|
|
2785
2785
|
.am-foot:hover { background: var(--bg); }
|
|
2786
|
+
/* #755: running-version footer + update-available nudge in the account menu */
|
|
2787
|
+
.am-version { padding: 6px 16px 10px; font-size: 10px; color: var(--muted); text-align: center; }
|
|
2788
|
+
#account-menu .am-update:not([hidden]) { display: grid; grid-template-columns: 30px 1fr; column-gap: 12px; align-items: start; }
|
|
2789
|
+
#account-menu .am-update { padding: 9px 16px; margin: 2px 8px 4px; background: var(--bg); border: 1px solid var(--line); border-radius: 8px; }
|
|
2790
|
+
#account-menu .am-update .am-ico { width: 30px; margin: 0; text-align: center; font-size: 16px; }
|
|
2791
|
+
.am-update .am-label { font-size: 13px; font-weight: 700; color: var(--text); }
|
|
2792
|
+
.am-update .am-sub { font-size: 11px; color: var(--muted); margin-top: 1px; }
|
|
2786
2793
|
/* #700: theme toggle row + iOS-style switch */
|
|
2787
2794
|
.am-theme { align-items: center; width: 100%; border: none; background: none; text-align: left; font: inherit; color: var(--text); }
|
|
2788
2795
|
.am-theme .am-switch { box-sizing: border-box; margin-left: auto; align-self: center; width: 38px; height: 22px; border-radius: 999px; background: var(--soft); border: 1px solid var(--line); position: relative; flex-shrink: 0; transition: background .18s ease, border-color .18s ease; }
|