fraim-hub 2.0.269 → 2.0.270
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/cli.js +18 -24
- package/dist/src/ai-hub/conversation-store-lock.js +2 -11
- package/dist/src/ai-hub/electron-dist.js +265 -0
- package/dist/src/ai-hub/process-liveness.js +25 -0
- package/dist/src/ai-hub/raw-event-log-store.js +110 -0
- package/dist/src/ai-hub/server.js +137 -72
- package/package.json +5 -3
- package/public/ai-hub/script.js +15 -4
package/dist/src/ai-hub/cli.js
CHANGED
|
@@ -53,15 +53,8 @@ const hub_launch_decision_1 = require("./hub-launch-decision");
|
|
|
53
53
|
const hub_runtime_file_1 = require("./hub-runtime-file");
|
|
54
54
|
const project_fraim_paths_1 = require("../core/utils/project-fraim-paths");
|
|
55
55
|
const version_utils_1 = require("../cli/utils/version-utils");
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
// eslint-disable-next-line @typescript-eslint/no-var-requires
|
|
59
|
-
return require('electron');
|
|
60
|
-
}
|
|
61
|
-
catch {
|
|
62
|
-
return null;
|
|
63
|
-
}
|
|
64
|
-
}
|
|
56
|
+
const electron_dist_1 = require("./electron-dist");
|
|
57
|
+
const process_liveness_1 = require("./process-liveness");
|
|
65
58
|
function resolveDesktopEntry() {
|
|
66
59
|
const candidates = [
|
|
67
60
|
path_1.default.resolve(__dirname, 'desktop-main.js'),
|
|
@@ -78,10 +71,20 @@ function resolveDesktopEntry() {
|
|
|
78
71
|
}
|
|
79
72
|
return null;
|
|
80
73
|
}
|
|
81
|
-
|
|
82
|
-
|
|
74
|
+
// #1112: the Electron binary is resolved (and, on a first launch for a version, extracted) before
|
|
75
|
+
// the spawn, not inside waitForDesktopHubReady. A one-time extraction is minutes of work on a slow
|
|
76
|
+
// disk; charging it to the readiness budget is exactly the mistake #1110 fixed, so it happens here
|
|
77
|
+
// with progress on stdout while nothing is being waited on.
|
|
78
|
+
async function openDesktopWindow(projectPath, preferredPort, runtimeId) {
|
|
83
79
|
const desktopEntry = resolveDesktopEntry();
|
|
84
|
-
if (!
|
|
80
|
+
if (!desktopEntry) {
|
|
81
|
+
return null;
|
|
82
|
+
}
|
|
83
|
+
const electron = await (0, electron_dist_1.resolveHubElectronBinary)({
|
|
84
|
+
onProgress: (message) => console.log(message),
|
|
85
|
+
onError: (error) => console.log(`Could not prepare the shared Electron runtime (${error.message}). Opening the FRAIM Hub in your browser instead.`),
|
|
86
|
+
});
|
|
87
|
+
if (!electron) {
|
|
85
88
|
return null;
|
|
86
89
|
}
|
|
87
90
|
const args = projectPath
|
|
@@ -90,7 +93,7 @@ function openDesktopWindow(projectPath, preferredPort, runtimeId) {
|
|
|
90
93
|
if (runtimeId && runtimeId !== 'hub') {
|
|
91
94
|
args.push('--hub-runtime-id', runtimeId);
|
|
92
95
|
}
|
|
93
|
-
const child = (0, child_process_1.spawn)(
|
|
96
|
+
const child = (0, child_process_1.spawn)(electron.binaryPath, args, {
|
|
94
97
|
detached: true,
|
|
95
98
|
stdio: 'ignore',
|
|
96
99
|
});
|
|
@@ -110,15 +113,6 @@ function openBrowser(url) {
|
|
|
110
113
|
const child = (0, child_process_1.spawn)('xdg-open', [url], { detached: true, stdio: 'ignore' });
|
|
111
114
|
child.unref();
|
|
112
115
|
}
|
|
113
|
-
function isProcessAlive(pid) {
|
|
114
|
-
try {
|
|
115
|
-
process.kill(pid, 0);
|
|
116
|
-
return true;
|
|
117
|
-
}
|
|
118
|
-
catch (e) {
|
|
119
|
-
return !!(e && e.code === 'EPERM');
|
|
120
|
-
}
|
|
121
|
-
}
|
|
122
116
|
function killPid(pid) {
|
|
123
117
|
try {
|
|
124
118
|
if (process.platform === 'win32') {
|
|
@@ -307,7 +301,7 @@ async function waitForDesktopHubReady(child, preferredPort, options = {}) {
|
|
|
307
301
|
async function reconcileRunningHub(flags, runtimeId = 'hub') {
|
|
308
302
|
const running = (0, hub_runtime_file_1.readHubRuntimeFile)((0, project_fraim_paths_1.getUserFraimDirPath)(), runtimeId);
|
|
309
303
|
const confirmedVersion = running ? await fetchRunningHubVersion(running.port) : null;
|
|
310
|
-
const live = !!(running && confirmedVersion &&
|
|
304
|
+
const live = !!(running && confirmedVersion && (0, process_liveness_1.isPidAlive)(running.pid));
|
|
311
305
|
const effective = live && running ? { ...running, version: confirmedVersion } : null;
|
|
312
306
|
const decision = (0, hub_launch_decision_1.decideHubLaunch)({
|
|
313
307
|
running: effective,
|
|
@@ -340,7 +334,7 @@ async function runHub(options) {
|
|
|
340
334
|
if (wantDesktop) {
|
|
341
335
|
await reconcileRunningHub({ restart: !!options.restart, keepRunning: !!options.keepRunning }, runtimeId);
|
|
342
336
|
}
|
|
343
|
-
const desktopChild = wantDesktop ? openDesktopWindow(projectPath, preferredPort, runtimeId) : null;
|
|
337
|
+
const desktopChild = wantDesktop ? await openDesktopWindow(projectPath, preferredPort, runtimeId) : null;
|
|
344
338
|
if (!desktopChild) {
|
|
345
339
|
const port = await findAvailablePort(preferredPort);
|
|
346
340
|
const server = new AiHubServer(projectPath ? { projectPath } : {});
|
|
@@ -7,22 +7,13 @@ exports.isLockStale = isLockStale;
|
|
|
7
7
|
exports.withBucketLock = withBucketLock;
|
|
8
8
|
const fs_1 = __importDefault(require("fs"));
|
|
9
9
|
const os_1 = __importDefault(require("os"));
|
|
10
|
+
const process_liveness_1 = require("./process-liveness");
|
|
10
11
|
const DEFAULT_TIMEOUT_MS = 5000;
|
|
11
12
|
const DEFAULT_STALE_MS = 10000;
|
|
12
13
|
// Synchronous sleep without a busy spin (the store's write path is synchronous).
|
|
13
14
|
function sleepMs(ms) {
|
|
14
15
|
Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, ms);
|
|
15
16
|
}
|
|
16
|
-
function pidAlive(pid) {
|
|
17
|
-
try {
|
|
18
|
-
process.kill(pid, 0);
|
|
19
|
-
return true;
|
|
20
|
-
}
|
|
21
|
-
catch (error) {
|
|
22
|
-
// ESRCH: no such process. EPERM: exists but not ours (still alive).
|
|
23
|
-
return error.code === 'EPERM';
|
|
24
|
-
}
|
|
25
|
-
}
|
|
26
17
|
// A lock is stale (safe to steal) if its file is unreadable/absent, its timestamp is older than
|
|
27
18
|
// staleMs, or it names a dead pid on this same host.
|
|
28
19
|
function isLockStale(lockPath, staleMs = DEFAULT_STALE_MS) {
|
|
@@ -35,7 +26,7 @@ function isLockStale(lockPath, staleMs = DEFAULT_STALE_MS) {
|
|
|
35
26
|
}
|
|
36
27
|
if (typeof info.ts === 'number' && Date.now() - info.ts > staleMs)
|
|
37
28
|
return true;
|
|
38
|
-
if (typeof info.pid === 'number' && info.host === os_1.default.hostname() && !
|
|
29
|
+
if (typeof info.pid === 'number' && info.host === os_1.default.hostname() && !(0, process_liveness_1.isPidAlive)(info.pid))
|
|
39
30
|
return true;
|
|
40
31
|
return false;
|
|
41
32
|
}
|
|
@@ -0,0 +1,265 @@
|
|
|
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.HUB_ELECTRON_VERSION = void 0;
|
|
7
|
+
exports.electronExecutableRelativePath = electronExecutableRelativePath;
|
|
8
|
+
exports.resolveSharedElectronDistDir = resolveSharedElectronDistDir;
|
|
9
|
+
exports.resolveSharedElectronBinaryPath = resolveSharedElectronBinaryPath;
|
|
10
|
+
exports.isElectronDistComplete = isElectronDistComplete;
|
|
11
|
+
exports.ensureSharedElectronDist = ensureSharedElectronDist;
|
|
12
|
+
exports.resolveHubElectronBinary = resolveHubElectronBinary;
|
|
13
|
+
// #1112: one shared, versioned Electron dist instead of a private 345 MB copy per npx install.
|
|
14
|
+
//
|
|
15
|
+
// `electron` used to be a hard dependency of packages/fraim-hub. npm runs its postinstall in every
|
|
16
|
+
// tree it installs into, and that postinstall extracts the ~345 MB platform zip. Because
|
|
17
|
+
// `npx fraim-hub@latest` creates a fresh `_npx/<hash>/` tree for every published version, the same
|
|
18
|
+
// dist was unpacked again on every upgrade: 27 copies and 9.1 GB on one developer machine, and a
|
|
19
|
+
// first-execution cost on freshly written binaries that was the largest remaining term in the Hub
|
|
20
|
+
// cold start measured in #1110.
|
|
21
|
+
//
|
|
22
|
+
// The extraction cannot be skipped at launch time. electron/install.js short-circuits only on
|
|
23
|
+
// ELECTRON_SKIP_BINARY_DOWNLOAD, read from the environment of the npm process, and by the time
|
|
24
|
+
// src/ai-hub/cli.ts runs npm has already unpacked. So `electron` is no longer declared at all, and
|
|
25
|
+
// the Hub resolves the binary itself from ~/.fraim/bin/electron/<version>/ — extracted once per
|
|
26
|
+
// Electron version per machine, shared by every installed and future fraim-hub version.
|
|
27
|
+
//
|
|
28
|
+
// The zip still comes from the same shared cache npm used (~/AppData/Local/electron/Cache on
|
|
29
|
+
// Windows, ~/Library/Caches/electron on macOS, ~/.cache/electron on Linux), because this reuses
|
|
30
|
+
// @electron/get and extract-zip — the two modules electron/install.js itself uses. That keeps
|
|
31
|
+
// checksum validation, mirror and proxy environment variables, and symlink- and mode-preserving
|
|
32
|
+
// extraction behaving exactly as they do today.
|
|
33
|
+
const fs_1 = __importDefault(require("fs"));
|
|
34
|
+
const path_1 = __importDefault(require("path"));
|
|
35
|
+
const project_fraim_paths_1 = require("../core/utils/project-fraim-paths");
|
|
36
|
+
const process_liveness_1 = require("./process-liveness");
|
|
37
|
+
/**
|
|
38
|
+
* The exact Electron release the Hub runs on, and the runtime source of truth for it.
|
|
39
|
+
*
|
|
40
|
+
* @electron/get needs an exact version: a range cannot name a download. Two other declarations are
|
|
41
|
+
* pinned to this value and `scripts/validate-package-split.ts` fails the build if either drifts:
|
|
42
|
+
* - `packages/fraim-hub` `build.electronVersion`, which is how electron-builder learns the
|
|
43
|
+
* version now that `electron` is absent from that manifest.
|
|
44
|
+
* - the repo root's `dependencies.electron` range, which still installs Electron for local dev,
|
|
45
|
+
* `npm run hub:desktop`, and the Electron-launching test suites.
|
|
46
|
+
*/
|
|
47
|
+
exports.HUB_ELECTRON_VERSION = '41.2.2';
|
|
48
|
+
/** Relative path of the Electron executable inside a dist, matching getPlatformPath() in electron/install.js. */
|
|
49
|
+
function electronExecutableRelativePath(platform = process.platform) {
|
|
50
|
+
switch (platform) {
|
|
51
|
+
case 'darwin':
|
|
52
|
+
return path_1.default.join('Electron.app', 'Contents', 'MacOS', 'Electron');
|
|
53
|
+
case 'win32':
|
|
54
|
+
return 'electron.exe';
|
|
55
|
+
default:
|
|
56
|
+
return 'electron';
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
// An Electron release is `x.y.z` or `x.y.z-<prerelease>`, and nothing else. Anchoring on that shape
|
|
60
|
+
// means a version string can never carry path syntax into the directory name below, which matters
|
|
61
|
+
// because `ensureSharedElectronDist` calls a recursive `fs.rmSync` on the resulting path: a version
|
|
62
|
+
// of `../../..` would delete an unrelated tree. Not reachable today - every caller resolves to the
|
|
63
|
+
// compile-time `HUB_ELECTRON_VERSION` - so this is a guard at the write boundary, kept next to the
|
|
64
|
+
// path construction it protects rather than left to every future caller to remember.
|
|
65
|
+
const EXACT_ELECTRON_VERSION = /^\d+\.\d+\.\d+(?:-[0-9A-Za-z.-]+)?$/;
|
|
66
|
+
function assertPathSafeVersion(version) {
|
|
67
|
+
if (!EXACT_ELECTRON_VERSION.test(version)) {
|
|
68
|
+
throw new Error(`Refusing to use "${version}" as an Electron version: expected an exact release such as 41.2.2.`);
|
|
69
|
+
}
|
|
70
|
+
return version;
|
|
71
|
+
}
|
|
72
|
+
/**
|
|
73
|
+
* `~/.fraim/bin/electron/<version>/`.
|
|
74
|
+
*
|
|
75
|
+
* Deliberately a sibling of, not a child of, `resolveHubInstallDir()`
|
|
76
|
+
* (`~/.fraim/bin/fraim-hub-electron/`). That directory belongs to the installed Hub application:
|
|
77
|
+
* `runHubInstall` writes `fraim-hub.exe` and the downloaded release asset into it, and
|
|
78
|
+
* `installDownloadedAsset` does an `fs.rmSync` inside it on macOS. A shared runtime with a
|
|
79
|
+
* different lifecycle should not live inside a tree another code path deletes from.
|
|
80
|
+
*/
|
|
81
|
+
function resolveSharedElectronDistDir(version, fraimDir = (0, project_fraim_paths_1.getUserFraimDirPath)()) {
|
|
82
|
+
return path_1.default.join(fraimDir, 'bin', 'electron', assertPathSafeVersion(version));
|
|
83
|
+
}
|
|
84
|
+
/** Absolute path of the Electron executable inside the shared dist for a version. */
|
|
85
|
+
function resolveSharedElectronBinaryPath(version, fraimDir = (0, project_fraim_paths_1.getUserFraimDirPath)(), platform = process.platform) {
|
|
86
|
+
return path_1.default.join(resolveSharedElectronDistDir(version, fraimDir), electronExecutableRelativePath(platform));
|
|
87
|
+
}
|
|
88
|
+
/**
|
|
89
|
+
* Whether a directory holds a complete dist for exactly this version.
|
|
90
|
+
*
|
|
91
|
+
* Same two conditions electron's own `isInstalled()` checks: the `version` file names this release,
|
|
92
|
+
* and the platform executable is present. A dist that fails either is stale or half-written, and
|
|
93
|
+
* spawning from it would fail at launch instead of here.
|
|
94
|
+
*/
|
|
95
|
+
function isElectronDistComplete(distDir, version, platform = process.platform) {
|
|
96
|
+
try {
|
|
97
|
+
const recorded = fs_1.default.readFileSync(path_1.default.join(distDir, 'version'), 'utf8').trim().replace(/^v/, '');
|
|
98
|
+
if (recorded !== version)
|
|
99
|
+
return false;
|
|
100
|
+
return fs_1.default.existsSync(path_1.default.join(distDir, electronExecutableRelativePath(platform)));
|
|
101
|
+
}
|
|
102
|
+
catch {
|
|
103
|
+
return false;
|
|
104
|
+
}
|
|
105
|
+
}
|
|
106
|
+
/**
|
|
107
|
+
* Resolve the platform zip through @electron/get, so a zip npm already cached for this version is a
|
|
108
|
+
* cache hit and no bytes cross the network. Checksums stay enabled: @electron/get validates the
|
|
109
|
+
* artifact against SHASUMS256.txt, fetching that 2 KB file when no local checksums.json is
|
|
110
|
+
* available. That is the only integrity check standing between a 345 MB download and executing it.
|
|
111
|
+
*/
|
|
112
|
+
const downloadElectronZip = async ({ version, platform, arch }) => {
|
|
113
|
+
// Required lazily: @electron/get pulls in got, fs-extra, and sumchecker, and none of that belongs
|
|
114
|
+
// in the module graph of a launch that resolves an already-extracted dist.
|
|
115
|
+
// eslint-disable-next-line @typescript-eslint/no-var-requires
|
|
116
|
+
const { downloadArtifact } = require('@electron/get');
|
|
117
|
+
return downloadArtifact({ version, artifactName: 'electron', platform, arch });
|
|
118
|
+
};
|
|
119
|
+
const extractElectronZip = async (zipPath, destDir) => {
|
|
120
|
+
// extract-zip is what electron/install.js uses, and it preserves the mode bits and the symlinks
|
|
121
|
+
// inside Electron.app/Contents/Frameworks that a naive zip reader drops.
|
|
122
|
+
// eslint-disable-next-line @typescript-eslint/no-var-requires
|
|
123
|
+
const extract = require('extract-zip');
|
|
124
|
+
await extract(zipPath, { dir: destDir });
|
|
125
|
+
};
|
|
126
|
+
/**
|
|
127
|
+
* Remove staging trees whose owning process is gone.
|
|
128
|
+
*
|
|
129
|
+
* The `finally` that cleans up a staging tree does not run when the process is killed, and the
|
|
130
|
+
* window it covers is a visible "Preparing the shared Electron runtime" message that invites a
|
|
131
|
+
* Ctrl+C. Without this sweep, one impatient interrupt strands ~345 MB under `~/.fraim` forever,
|
|
132
|
+
* which is the same disk leak this change exists to stop. A staging tree whose pid is still alive
|
|
133
|
+
* belongs to a concurrent launch and is left alone.
|
|
134
|
+
*/
|
|
135
|
+
function sweepDeadStagingDirs(distDir) {
|
|
136
|
+
const parent = path_1.default.dirname(distDir);
|
|
137
|
+
const prefix = `${path_1.default.basename(distDir)}.staging-`;
|
|
138
|
+
let entries;
|
|
139
|
+
try {
|
|
140
|
+
entries = fs_1.default.readdirSync(parent, { withFileTypes: true });
|
|
141
|
+
}
|
|
142
|
+
catch {
|
|
143
|
+
return;
|
|
144
|
+
}
|
|
145
|
+
for (const entry of entries) {
|
|
146
|
+
if (!entry.isDirectory() || !entry.name.startsWith(prefix))
|
|
147
|
+
continue;
|
|
148
|
+
const pid = Number(entry.name.slice(prefix.length));
|
|
149
|
+
if (Number.isInteger(pid) && pid > 0 && (0, process_liveness_1.isPidAlive)(pid))
|
|
150
|
+
continue;
|
|
151
|
+
try {
|
|
152
|
+
fs_1.default.rmSync(path_1.default.join(parent, entry.name), { recursive: true, force: true });
|
|
153
|
+
}
|
|
154
|
+
catch {
|
|
155
|
+
/* best effort: a tree we cannot delete is not a reason to fail a launch */
|
|
156
|
+
}
|
|
157
|
+
}
|
|
158
|
+
}
|
|
159
|
+
/**
|
|
160
|
+
* Return the Electron executable in the shared dist for this version, extracting it first if it is
|
|
161
|
+
* not already there. Returns immediately when a complete dist exists, which is the case on every
|
|
162
|
+
* launch after the first for a given Electron version.
|
|
163
|
+
*
|
|
164
|
+
* Extraction goes to a staging sibling and is promoted with a rename, matching the
|
|
165
|
+
* `${dir}.staging-${pid}` pattern in `org-pack-sync.ts` and `manager-pack-sync.ts`. A failure
|
|
166
|
+
* therefore leaves no partial tree at the versioned path, so the next launch cannot mistake one for
|
|
167
|
+
* a usable dist.
|
|
168
|
+
*/
|
|
169
|
+
async function ensureSharedElectronDist(options = {}) {
|
|
170
|
+
const version = options.version ?? exports.HUB_ELECTRON_VERSION;
|
|
171
|
+
const fraimDir = options.fraimDir ?? (0, project_fraim_paths_1.getUserFraimDirPath)();
|
|
172
|
+
const platform = options.platform ?? process.platform;
|
|
173
|
+
const arch = options.arch ?? process.arch;
|
|
174
|
+
const distDir = resolveSharedElectronDistDir(version, fraimDir);
|
|
175
|
+
const binaryPath = resolveSharedElectronBinaryPath(version, fraimDir, platform);
|
|
176
|
+
if (isElectronDistComplete(distDir, version, platform)) {
|
|
177
|
+
return binaryPath;
|
|
178
|
+
}
|
|
179
|
+
const downloadZip = options.downloadZip ?? downloadElectronZip;
|
|
180
|
+
const extractZip = options.extractZip ?? extractElectronZip;
|
|
181
|
+
const onProgress = options.onProgress;
|
|
182
|
+
onProgress?.(`Preparing the shared Electron ${version} runtime in ${distDir}. `
|
|
183
|
+
+ 'This happens once per Electron version on this machine; later Hub launches reuse it.');
|
|
184
|
+
const zipPath = await downloadZip({ version, platform, arch });
|
|
185
|
+
await extractAndPromote({ zipPath, distDir, version, platform, extractZip });
|
|
186
|
+
onProgress?.(`Shared Electron ${version} runtime ready.`);
|
|
187
|
+
return binaryPath;
|
|
188
|
+
}
|
|
189
|
+
/**
|
|
190
|
+
* Unpack a zip into `distDir`, atomically.
|
|
191
|
+
*
|
|
192
|
+
* Extraction goes to a `${distDir}.staging-${pid}` sibling and is promoted with a rename, matching
|
|
193
|
+
* the pattern in `org-pack-sync.ts` and `manager-pack-sync.ts`. A failure therefore leaves no
|
|
194
|
+
* partial tree at the versioned path, so the next launch cannot mistake one for a usable dist.
|
|
195
|
+
*/
|
|
196
|
+
async function extractAndPromote(args) {
|
|
197
|
+
const { zipPath, distDir, version, platform, extractZip } = args;
|
|
198
|
+
const stagingDir = `${distDir}.staging-${process.pid}`;
|
|
199
|
+
fs_1.default.mkdirSync(path_1.default.dirname(distDir), { recursive: true });
|
|
200
|
+
sweepDeadStagingDirs(distDir);
|
|
201
|
+
fs_1.default.rmSync(stagingDir, { recursive: true, force: true });
|
|
202
|
+
try {
|
|
203
|
+
await extractZip(zipPath, stagingDir);
|
|
204
|
+
if (!isElectronDistComplete(stagingDir, version, platform)) {
|
|
205
|
+
throw new Error(`Extracted Electron ${version} is missing its version file or ${electronExecutableRelativePath(platform)}; `
|
|
206
|
+
+ `refusing to install an incomplete dist from ${zipPath}.`);
|
|
207
|
+
}
|
|
208
|
+
// A concurrent launch may have finished extracting the same version while this one worked.
|
|
209
|
+
// Promoting over a complete dist would delete the tree that launch is spawning from, so the
|
|
210
|
+
// winner keeps it and this one discards its own copy.
|
|
211
|
+
if (!isElectronDistComplete(distDir, version, platform)) {
|
|
212
|
+
fs_1.default.rmSync(distDir, { recursive: true, force: true });
|
|
213
|
+
try {
|
|
214
|
+
fs_1.default.renameSync(stagingDir, distDir);
|
|
215
|
+
}
|
|
216
|
+
catch (error) {
|
|
217
|
+
// The same race, one step later: the target can appear between that check and this rename.
|
|
218
|
+
// A complete dist at the target is the outcome this call wanted, so it is success.
|
|
219
|
+
if (!isElectronDistComplete(distDir, version, platform))
|
|
220
|
+
throw error;
|
|
221
|
+
}
|
|
222
|
+
}
|
|
223
|
+
}
|
|
224
|
+
finally {
|
|
225
|
+
fs_1.default.rmSync(stagingDir, { recursive: true, force: true });
|
|
226
|
+
}
|
|
227
|
+
}
|
|
228
|
+
/** Resolve the `electron` npm package's own binary, or null when it is absent or points nowhere. */
|
|
229
|
+
function resolveInstalledElectronBinary(requireElectron) {
|
|
230
|
+
try {
|
|
231
|
+
const binaryPath = requireElectron();
|
|
232
|
+
return typeof binaryPath === 'string' && fs_1.default.existsSync(binaryPath) ? binaryPath : null;
|
|
233
|
+
}
|
|
234
|
+
catch {
|
|
235
|
+
return null;
|
|
236
|
+
}
|
|
237
|
+
}
|
|
238
|
+
/**
|
|
239
|
+
* The Electron binary this Hub should launch, or null when there is none.
|
|
240
|
+
*
|
|
241
|
+
* An installed `electron` package wins when one is present. That is this repo, and any consumer who
|
|
242
|
+
* depends on Electron themselves: its dist is already on disk, so preferring it avoids a needless
|
|
243
|
+
* download and keeps `npm run hub:desktop` and the Electron-launching suites on the exact path they
|
|
244
|
+
* use today. The published `fraim-hub` package declares no `electron`, so it takes the shared dist.
|
|
245
|
+
*
|
|
246
|
+
* Null is a supported outcome, not a failure: `openDesktopWindow()` returns null in that case and
|
|
247
|
+
* `runHub` starts the in-process server and opens a browser. An offline first launch must reach that
|
|
248
|
+
* fallback rather than throwing, so a download failure is reported through `onError` and swallowed.
|
|
249
|
+
*/
|
|
250
|
+
async function resolveHubElectronBinary(options = {}) {
|
|
251
|
+
const requireElectron = options.requireElectron
|
|
252
|
+
// eslint-disable-next-line @typescript-eslint/no-var-requires
|
|
253
|
+
?? (() => require('electron'));
|
|
254
|
+
const installed = resolveInstalledElectronBinary(requireElectron);
|
|
255
|
+
if (installed) {
|
|
256
|
+
return { binaryPath: installed, source: 'installed-package' };
|
|
257
|
+
}
|
|
258
|
+
try {
|
|
259
|
+
return { binaryPath: await ensureSharedElectronDist(options), source: 'shared-dist' };
|
|
260
|
+
}
|
|
261
|
+
catch (error) {
|
|
262
|
+
options.onError?.(error instanceof Error ? error : new Error(String(error)));
|
|
263
|
+
return null;
|
|
264
|
+
}
|
|
265
|
+
}
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
// Is a pid on this host still running? A leaf with no dependencies, so any Hub module can use it
|
|
3
|
+
// without pulling in a graph or creating an import cycle.
|
|
4
|
+
//
|
|
5
|
+
// #1112 consolidated three byte-identical copies of this: `isProcessAlive` in `cli.ts` (which
|
|
6
|
+
// decides whether a recorded Hub instance is live), `pidAlive` in `conversation-store-lock.ts`
|
|
7
|
+
// (which decides whether a lock is stealable), and a third that the shared-Electron-dist sweep
|
|
8
|
+
// would otherwise have added.
|
|
9
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
10
|
+
exports.isPidAlive = isPidAlive;
|
|
11
|
+
/**
|
|
12
|
+
* `process.kill(pid, 0)` sends no signal; it only checks that the process exists and is signalable.
|
|
13
|
+
* `ESRCH` means no such process. `EPERM` means it exists but belongs to another user, which still
|
|
14
|
+
* counts as alive — treating it as dead is what would let a caller steal a live lock or delete a
|
|
15
|
+
* live process's working tree.
|
|
16
|
+
*/
|
|
17
|
+
function isPidAlive(pid) {
|
|
18
|
+
try {
|
|
19
|
+
process.kill(pid, 0);
|
|
20
|
+
return true;
|
|
21
|
+
}
|
|
22
|
+
catch (error) {
|
|
23
|
+
return error.code === 'EPERM';
|
|
24
|
+
}
|
|
25
|
+
}
|
|
@@ -0,0 +1,110 @@
|
|
|
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.AiHubRawEventLogStore = void 0;
|
|
7
|
+
const node_crypto_1 = __importDefault(require("node:crypto"));
|
|
8
|
+
const node_fs_1 = __importDefault(require("node:fs"));
|
|
9
|
+
const node_os_1 = __importDefault(require("node:os"));
|
|
10
|
+
const node_path_1 = __importDefault(require("node:path"));
|
|
11
|
+
const project_fraim_paths_1 = require("../core/utils/project-fraim-paths");
|
|
12
|
+
function defaultRawEventLogRoot() {
|
|
13
|
+
try {
|
|
14
|
+
return node_path_1.default.join((0, project_fraim_paths_1.getUserFraimDirPath)(), 'ai-hub-event-logs');
|
|
15
|
+
}
|
|
16
|
+
catch {
|
|
17
|
+
return node_path_1.default.join(node_os_1.default.homedir(), '.fraim', 'ai-hub-event-logs');
|
|
18
|
+
}
|
|
19
|
+
}
|
|
20
|
+
function hashSegment(value, length = 24) {
|
|
21
|
+
return node_crypto_1.default.createHash('sha256').update(value || 'unknown').digest('hex').slice(0, length);
|
|
22
|
+
}
|
|
23
|
+
function safeRunFileName(runId) {
|
|
24
|
+
return `${hashSegment(runId, 40)}.jsonl`;
|
|
25
|
+
}
|
|
26
|
+
class AiHubRawEventLogStore {
|
|
27
|
+
constructor(rootDir = defaultRawEventLogRoot()) {
|
|
28
|
+
this.rootDir = node_path_1.default.resolve(rootDir);
|
|
29
|
+
}
|
|
30
|
+
append(bucketKey, conversationId, runId, entry) {
|
|
31
|
+
const dir = node_path_1.default.join(this.rootDir, hashSegment(bucketKey), hashSegment(conversationId));
|
|
32
|
+
node_fs_1.default.mkdirSync(dir, { recursive: true });
|
|
33
|
+
const logPath = node_path_1.default.join(dir, safeRunFileName(runId));
|
|
34
|
+
const metaPath = `${logPath}.meta.json`;
|
|
35
|
+
const createdAt = entry.createdAt || new Date().toISOString();
|
|
36
|
+
const line = {
|
|
37
|
+
createdAt,
|
|
38
|
+
channel: entry.channel,
|
|
39
|
+
text: entry.text,
|
|
40
|
+
};
|
|
41
|
+
node_fs_1.default.appendFileSync(logPath, `${JSON.stringify(line)}\n`, 'utf8');
|
|
42
|
+
const prior = this.readMeta(metaPath, runId, logPath);
|
|
43
|
+
const stat = node_fs_1.default.statSync(logPath);
|
|
44
|
+
const ref = {
|
|
45
|
+
runId,
|
|
46
|
+
path: logPath,
|
|
47
|
+
bytes: stat.size,
|
|
48
|
+
eventCount: prior.eventCount + 1,
|
|
49
|
+
createdAt: prior.createdAt || createdAt,
|
|
50
|
+
updatedAt: createdAt,
|
|
51
|
+
...(prior.truncated ? { truncated: true } : {}),
|
|
52
|
+
};
|
|
53
|
+
node_fs_1.default.writeFileSync(metaPath, `${JSON.stringify(ref, null, 2)}\n`, 'utf8');
|
|
54
|
+
return ref;
|
|
55
|
+
}
|
|
56
|
+
readEntries(ref) {
|
|
57
|
+
const logPath = this.resolveReadableLogPath(ref.path);
|
|
58
|
+
if (!logPath || !node_fs_1.default.existsSync(logPath))
|
|
59
|
+
return [];
|
|
60
|
+
const lines = node_fs_1.default.readFileSync(logPath, 'utf8').split(/\r?\n/).filter(Boolean);
|
|
61
|
+
const entries = [];
|
|
62
|
+
for (const line of lines) {
|
|
63
|
+
try {
|
|
64
|
+
const parsed = JSON.parse(line);
|
|
65
|
+
if (typeof parsed.text === 'string' &&
|
|
66
|
+
(parsed.channel === 'stdout' || parsed.channel === 'stderr' || parsed.channel === 'system')) {
|
|
67
|
+
entries.push({
|
|
68
|
+
createdAt: typeof parsed.createdAt === 'string' ? parsed.createdAt : new Date(0).toISOString(),
|
|
69
|
+
channel: parsed.channel,
|
|
70
|
+
text: parsed.text,
|
|
71
|
+
});
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
catch {
|
|
75
|
+
// Corrupt diagnostic lines are ignored; callers fall back to other session sources.
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
return entries;
|
|
79
|
+
}
|
|
80
|
+
readMeta(metaPath, runId, logPath) {
|
|
81
|
+
if (!node_fs_1.default.existsSync(metaPath)) {
|
|
82
|
+
return { runId, path: logPath, bytes: 0, eventCount: 0, createdAt: '', updatedAt: '' };
|
|
83
|
+
}
|
|
84
|
+
try {
|
|
85
|
+
const parsed = JSON.parse(node_fs_1.default.readFileSync(metaPath, 'utf8'));
|
|
86
|
+
return {
|
|
87
|
+
runId,
|
|
88
|
+
path: logPath,
|
|
89
|
+
bytes: typeof parsed.bytes === 'number' ? parsed.bytes : 0,
|
|
90
|
+
eventCount: typeof parsed.eventCount === 'number' ? parsed.eventCount : 0,
|
|
91
|
+
createdAt: typeof parsed.createdAt === 'string' ? parsed.createdAt : '',
|
|
92
|
+
updatedAt: typeof parsed.updatedAt === 'string' ? parsed.updatedAt : '',
|
|
93
|
+
...(parsed.truncated ? { truncated: true } : {}),
|
|
94
|
+
};
|
|
95
|
+
}
|
|
96
|
+
catch {
|
|
97
|
+
return { runId, path: logPath, bytes: 0, eventCount: 0, createdAt: '', updatedAt: '' };
|
|
98
|
+
}
|
|
99
|
+
}
|
|
100
|
+
resolveReadableLogPath(candidate) {
|
|
101
|
+
if (!candidate)
|
|
102
|
+
return null;
|
|
103
|
+
const resolved = node_path_1.default.resolve(candidate);
|
|
104
|
+
const rootPrefix = `${this.rootDir}${node_path_1.default.sep}`;
|
|
105
|
+
const comparableResolved = process.platform === 'win32' ? resolved.toLowerCase() : resolved;
|
|
106
|
+
const comparableRootPrefix = process.platform === 'win32' ? rootPrefix.toLowerCase() : rootPrefix;
|
|
107
|
+
return comparableResolved.startsWith(comparableRootPrefix) ? resolved : null;
|
|
108
|
+
}
|
|
109
|
+
}
|
|
110
|
+
exports.AiHubRawEventLogStore = AiHubRawEventLogStore;
|
|
@@ -60,6 +60,7 @@ const url_safety_1 = require("./url-safety");
|
|
|
60
60
|
const manager_turns_1 = require("./manager-turns");
|
|
61
61
|
const preferences_1 = require("./preferences");
|
|
62
62
|
const conversation_store_1 = require("./conversation-store");
|
|
63
|
+
const raw_event_log_store_1 = require("./raw-event-log-store");
|
|
63
64
|
const conversation_search_1 = require("./conversation-search");
|
|
64
65
|
const conversation_search_index_1 = require("./conversation-search-index");
|
|
65
66
|
const conversation_store_lock_1 = require("./conversation-store-lock");
|
|
@@ -1220,6 +1221,45 @@ function appendHostMessage(run, hostId, event, channel) {
|
|
|
1220
1221
|
return;
|
|
1221
1222
|
run.messages.push((0, hosts_1.createHubMessage)('employee', displayMessage));
|
|
1222
1223
|
}
|
|
1224
|
+
const MAX_PROJECTED_EVENT_CHARS = 300;
|
|
1225
|
+
const MAX_PROJECTED_EVENTS_PER_RUN = 200;
|
|
1226
|
+
function boundedEventText(text) {
|
|
1227
|
+
const trimmed = text.trim();
|
|
1228
|
+
if (trimmed.length <= MAX_PROJECTED_EVENT_CHARS)
|
|
1229
|
+
return trimmed;
|
|
1230
|
+
return `${trimmed.slice(0, MAX_PROJECTED_EVENT_CHARS - 1).trimEnd()}...`;
|
|
1231
|
+
}
|
|
1232
|
+
function looksLikeRawHostPayload(text) {
|
|
1233
|
+
const trimmed = text.trim();
|
|
1234
|
+
if (!trimmed)
|
|
1235
|
+
return false;
|
|
1236
|
+
if (trimmed.length > 1000)
|
|
1237
|
+
return true;
|
|
1238
|
+
return trimmed.startsWith('{') || trimmed.startsWith('[');
|
|
1239
|
+
}
|
|
1240
|
+
function projectHostEventForConversation(event, channel) {
|
|
1241
|
+
if (event.message && channel === 'stdout') {
|
|
1242
|
+
const display = stripStructuredHostPayloads(event.message);
|
|
1243
|
+
return display ? (0, hosts_1.createHubEvent)(channel, boundedEventText(display)) : null;
|
|
1244
|
+
}
|
|
1245
|
+
if (!event.raw)
|
|
1246
|
+
return null;
|
|
1247
|
+
if (looksLikeRawHostPayload(event.raw))
|
|
1248
|
+
return null;
|
|
1249
|
+
if (channel === 'stdout')
|
|
1250
|
+
return null;
|
|
1251
|
+
return (0, hosts_1.createHubEvent)(channel, boundedEventText(event.raw));
|
|
1252
|
+
}
|
|
1253
|
+
function upsertEventLogRef(refs, ref) {
|
|
1254
|
+
const next = (refs || []).filter((entry) => entry.runId !== ref.runId || entry.path !== ref.path);
|
|
1255
|
+
next.push(ref);
|
|
1256
|
+
return next;
|
|
1257
|
+
}
|
|
1258
|
+
function capProjectedEvents(run) {
|
|
1259
|
+
if (run.events.length <= MAX_PROJECTED_EVENTS_PER_RUN)
|
|
1260
|
+
return;
|
|
1261
|
+
run.events = run.events.slice(run.events.length - MAX_PROJECTED_EVENTS_PER_RUN);
|
|
1262
|
+
}
|
|
1223
1263
|
function isCodexMissingReasoningResumeError(run) {
|
|
1224
1264
|
return (run.events || []).some((event) => {
|
|
1225
1265
|
const text = String(event.text || '');
|
|
@@ -1888,6 +1928,7 @@ class AiHubServer {
|
|
|
1888
1928
|
? path_1.default.resolve(options.projectPath)
|
|
1889
1929
|
: resolveInitialHubProjectPath(this.preferencesStore);
|
|
1890
1930
|
this.conversationStore = options.conversationStore || new conversation_store_1.AiHubConversationStore();
|
|
1931
|
+
this.rawEventLogStore = options.rawEventLogStore || new raw_event_log_store_1.AiHubRawEventLogStore();
|
|
1891
1932
|
this.searchIndex = new conversation_search_index_1.ConversationSearchIndex(this.conversationStore);
|
|
1892
1933
|
this.configuredAgentStore = options.configuredAgentStore || new configured_agents_1.AiHubConfiguredAgentStore();
|
|
1893
1934
|
this.wordTaskpaneDir = options.wordTaskpaneDir ?? resolveWordTaskpaneDir(this.projectPath);
|
|
@@ -2441,8 +2482,24 @@ class AiHubServer {
|
|
|
2441
2482
|
if (!conversationId)
|
|
2442
2483
|
return { ok: true, continuityDecision: 'new_conversation' };
|
|
2443
2484
|
const existing = this.conversationStore.loadConversation(options.projectPath, conversationId);
|
|
2444
|
-
if (!existing)
|
|
2485
|
+
if (!existing) {
|
|
2486
|
+
const headerExists = this.conversationStore
|
|
2487
|
+
.loadProjectHeaders(options.projectPath)
|
|
2488
|
+
.some((conversation) => conversation.id === conversationId);
|
|
2489
|
+
if (headerExists) {
|
|
2490
|
+
console.warn('[ai-hub] hub.conversation_continuity.hydration_failed', { conversationId });
|
|
2491
|
+
return {
|
|
2492
|
+
ok: false,
|
|
2493
|
+
status: 409,
|
|
2494
|
+
body: {
|
|
2495
|
+
error: 'Conversation body could not be loaded. Reload the conversation before continuing.',
|
|
2496
|
+
continuityDecision: 'not_found',
|
|
2497
|
+
conversationId,
|
|
2498
|
+
},
|
|
2499
|
+
};
|
|
2500
|
+
}
|
|
2445
2501
|
return { ok: true, continuityDecision: 'new_conversation' };
|
|
2502
|
+
}
|
|
2446
2503
|
const existingAgentId = this.configuredAgentIdForConversation(existing, options.employees);
|
|
2447
2504
|
if (existingAgentId !== options.requestedAgent.id) {
|
|
2448
2505
|
console.warn('[ai-hub] hub.conversation_continuity.agent_switch_required', {
|
|
@@ -2517,18 +2574,36 @@ class AiHubServer {
|
|
|
2517
2574
|
host_session_state_1.hostSessionState.applySession(run, owner, sessionId, { sourceRunId: run.id });
|
|
2518
2575
|
run.resumeCommand = (0, hosts_1.buildInteractiveResumeCommand)(hostIdForCommand, sessionId);
|
|
2519
2576
|
}
|
|
2577
|
+
recordHostEvent(run, hostId, event, channel) {
|
|
2578
|
+
appendHostMessage(run, hostId, event, channel);
|
|
2579
|
+
if (event.raw) {
|
|
2580
|
+
applyReviewProjection(run, event.raw);
|
|
2581
|
+
try {
|
|
2582
|
+
const ref = this.rawEventLogStore.append((0, conversation_store_1.conversationScopeKey)(run.scope, run.projectPath), run.conversationId || run.id, run.id, { channel, text: event.raw });
|
|
2583
|
+
run.eventLogRefs = upsertEventLogRef(run.eventLogRefs, ref);
|
|
2584
|
+
}
|
|
2585
|
+
catch (error) {
|
|
2586
|
+
run.events.push((0, hosts_1.createHubEvent)('system', 'Raw host event logging failed; visible conversation state was preserved.'));
|
|
2587
|
+
console.error('[ai-hub] raw host event log write failed:', error instanceof Error ? error.message : error);
|
|
2588
|
+
}
|
|
2589
|
+
}
|
|
2590
|
+
const projected = projectHostEventForConversation(event, channel);
|
|
2591
|
+
if (projected)
|
|
2592
|
+
run.events.push(projected);
|
|
2593
|
+
capProjectedEvents(run);
|
|
2594
|
+
}
|
|
2520
2595
|
repairConversationHostSessionFromEvents(conversation, owner) {
|
|
2521
|
-
if (!conversation
|
|
2596
|
+
if (!conversation)
|
|
2522
2597
|
return false;
|
|
2523
2598
|
let repaired = false;
|
|
2524
|
-
|
|
2525
|
-
const text = typeof
|
|
2599
|
+
const repairFromText = (textValue) => {
|
|
2600
|
+
const text = typeof textValue === 'string' ? textValue.trim() : '';
|
|
2526
2601
|
if (!text)
|
|
2527
|
-
|
|
2602
|
+
return;
|
|
2528
2603
|
try {
|
|
2529
2604
|
const parsed = (0, hosts_1.parseHostLine)(owner.baseHostId, text);
|
|
2530
2605
|
if (!parsed.sessionId)
|
|
2531
|
-
|
|
2606
|
+
return;
|
|
2532
2607
|
const projectedRun = {
|
|
2533
2608
|
id: conversation.runId || conversation.id,
|
|
2534
2609
|
hostSessions: conversation.hostSessions,
|
|
@@ -2544,6 +2619,16 @@ class AiHubServer {
|
|
|
2544
2619
|
// Persisted event logs include plain text, diagnostics, and historical host output.
|
|
2545
2620
|
// Non-parseable lines are not session signals.
|
|
2546
2621
|
}
|
|
2622
|
+
};
|
|
2623
|
+
for (const event of Array.isArray(conversation.events) ? conversation.events : []) {
|
|
2624
|
+
repairFromText(event?.text);
|
|
2625
|
+
}
|
|
2626
|
+
for (const ref of [...(conversation.eventLogRefs || [])].reverse()) {
|
|
2627
|
+
for (const entry of this.rawEventLogStore.readEntries(ref).reverse()) {
|
|
2628
|
+
repairFromText(entry.text);
|
|
2629
|
+
if (repaired)
|
|
2630
|
+
return true;
|
|
2631
|
+
}
|
|
2547
2632
|
}
|
|
2548
2633
|
return repaired;
|
|
2549
2634
|
}
|
|
@@ -2582,6 +2667,7 @@ class AiHubServer {
|
|
|
2582
2667
|
channel: event.channel,
|
|
2583
2668
|
text: event.text,
|
|
2584
2669
|
})),
|
|
2670
|
+
eventLogRefs: run.eventLogRefs || [],
|
|
2585
2671
|
artifacts: run.artifacts || [],
|
|
2586
2672
|
reviewHandoff: run.reviewHandoff || null,
|
|
2587
2673
|
delegation: run.delegation || null,
|
|
@@ -2651,6 +2737,17 @@ class AiHubServer {
|
|
|
2651
2737
|
this.pendingConversationWrites.delete(run.id);
|
|
2652
2738
|
this.persistRunConversationNow(run, activeId);
|
|
2653
2739
|
}
|
|
2740
|
+
finalizeRunConversationProjection(runId, activeId) {
|
|
2741
|
+
this.pendingConversationWrites.delete(runId);
|
|
2742
|
+
const run = this.runRegistry.get(runId);
|
|
2743
|
+
if (!run)
|
|
2744
|
+
return;
|
|
2745
|
+
this.persistRunConversationNow(run, activeId ?? run.conversationId ?? run.id);
|
|
2746
|
+
console.info('[ai-hub] hub.conversation_projection.finalized', {
|
|
2747
|
+
conversationId: run.conversationId || run.id,
|
|
2748
|
+
runId,
|
|
2749
|
+
});
|
|
2750
|
+
}
|
|
2654
2751
|
startFreshCodexReviewApprovalFallback(runId, managerNote) {
|
|
2655
2752
|
const run = this.runRegistry.get(runId);
|
|
2656
2753
|
if (!run)
|
|
@@ -2682,11 +2779,7 @@ class AiHubServer {
|
|
|
2682
2779
|
clearCompactionLifecycle(current);
|
|
2683
2780
|
}
|
|
2684
2781
|
applyHostLifecycleSignal(current, event.hostLifecycle);
|
|
2685
|
-
|
|
2686
|
-
if (event.raw) {
|
|
2687
|
-
current.events.push((0, hosts_1.createHubEvent)(channel, event.raw));
|
|
2688
|
-
applyReviewProjection(current, event.raw);
|
|
2689
|
-
}
|
|
2782
|
+
this.recordHostEvent(current, run.hostId, event, channel);
|
|
2690
2783
|
if (event.agentIdentity)
|
|
2691
2784
|
applyAgentIdentitySignal(current, event.agentIdentity);
|
|
2692
2785
|
if (event.fraimJob)
|
|
@@ -2884,6 +2977,7 @@ class AiHubServer {
|
|
|
2884
2977
|
...persistedEventsForRun(conversation),
|
|
2885
2978
|
(0, hosts_1.createHubEvent)('system', `Hub restart recovery reattached this conversation (${reason}).`),
|
|
2886
2979
|
],
|
|
2980
|
+
eventLogRefs: conversation.eventLogRefs || [],
|
|
2887
2981
|
currentPhase: persistedRun?.currentPhase || null,
|
|
2888
2982
|
phaseHistory: persistedRun?.phaseHistory || [],
|
|
2889
2983
|
phaseVisits: persistedRun?.phaseVisits || [],
|
|
@@ -2942,11 +3036,7 @@ class AiHubServer {
|
|
|
2942
3036
|
clearCompactionLifecycle(current);
|
|
2943
3037
|
}
|
|
2944
3038
|
applyHostLifecycleSignal(current, event.hostLifecycle);
|
|
2945
|
-
|
|
2946
|
-
if (event.raw) {
|
|
2947
|
-
current.events.push((0, hosts_1.createHubEvent)(channel, event.raw));
|
|
2948
|
-
applyReviewProjection(current, event.raw);
|
|
2949
|
-
}
|
|
3039
|
+
this.recordHostEvent(current, current.hostId, event, channel);
|
|
2950
3040
|
if (event.agentIdentity)
|
|
2951
3041
|
applyAgentIdentitySignal(current, event.agentIdentity);
|
|
2952
3042
|
if (event.fraimJob)
|
|
@@ -3268,11 +3358,7 @@ class AiHubServer {
|
|
|
3268
3358
|
clearCompactionLifecycle(current);
|
|
3269
3359
|
}
|
|
3270
3360
|
applyHostLifecycleSignal(current, event.hostLifecycle);
|
|
3271
|
-
|
|
3272
|
-
if (event.raw) {
|
|
3273
|
-
current.events.push((0, hosts_1.createHubEvent)(channel, event.raw));
|
|
3274
|
-
applyReviewProjection(current, event.raw);
|
|
3275
|
-
}
|
|
3361
|
+
this.recordHostEvent(current, managerRun.hostId, event, channel);
|
|
3276
3362
|
if (event.agentIdentity)
|
|
3277
3363
|
applyAgentIdentitySignal(current, event.agentIdentity);
|
|
3278
3364
|
if (event.fraimJob)
|
|
@@ -3421,11 +3507,7 @@ class AiHubServer {
|
|
|
3421
3507
|
clearCompactionLifecycle(current);
|
|
3422
3508
|
}
|
|
3423
3509
|
applyHostLifecycleSignal(current, event.hostLifecycle);
|
|
3424
|
-
|
|
3425
|
-
if (event.raw) {
|
|
3426
|
-
current.events.push((0, hosts_1.createHubEvent)(channel, event.raw));
|
|
3427
|
-
applyReviewProjection(current, event.raw);
|
|
3428
|
-
}
|
|
3510
|
+
this.recordHostEvent(current, managerRun.hostId, event, channel);
|
|
3429
3511
|
if (event.agentIdentity)
|
|
3430
3512
|
applyAgentIdentitySignal(current, event.agentIdentity);
|
|
3431
3513
|
if (event.fraimJob)
|
|
@@ -4246,6 +4328,7 @@ class AiHubServer {
|
|
|
4246
4328
|
(0, hosts_1.createHubEvent)('system', switchEventText),
|
|
4247
4329
|
...(fallbackReason ? [(0, hosts_1.createHubEvent)('system', `Same-host resume fell back to handoff: ${fallbackReason}`)] : []),
|
|
4248
4330
|
],
|
|
4331
|
+
eventLogRefs: conversation.eventLogRefs || [],
|
|
4249
4332
|
currentPhase: persistedRun?.currentPhase || null,
|
|
4250
4333
|
phaseHistory: persistedRun?.phaseHistory || [],
|
|
4251
4334
|
phaseVisits: persistedRun?.phaseVisits || [],
|
|
@@ -4282,11 +4365,7 @@ class AiHubServer {
|
|
|
4282
4365
|
clearCompactionLifecycle(current);
|
|
4283
4366
|
}
|
|
4284
4367
|
applyHostLifecycleSignal(current, event.hostLifecycle);
|
|
4285
|
-
|
|
4286
|
-
if (event.raw) {
|
|
4287
|
-
current.events.push((0, hosts_1.createHubEvent)(channel, event.raw));
|
|
4288
|
-
applyReviewProjection(current, event.raw);
|
|
4289
|
-
}
|
|
4368
|
+
this.recordHostEvent(current, run.hostId, event, channel);
|
|
4290
4369
|
if (event.agentIdentity)
|
|
4291
4370
|
applyAgentIdentitySignal(current, event.agentIdentity);
|
|
4292
4371
|
if (event.fraimJob)
|
|
@@ -5021,11 +5100,7 @@ class AiHubServer {
|
|
|
5021
5100
|
clearCompactionLifecycle(current);
|
|
5022
5101
|
}
|
|
5023
5102
|
applyHostLifecycleSignal(current, event.hostLifecycle);
|
|
5024
|
-
|
|
5025
|
-
if (event.raw) {
|
|
5026
|
-
current.events.push((0, hosts_1.createHubEvent)(channel, event.raw));
|
|
5027
|
-
applyReviewProjection(current, event.raw);
|
|
5028
|
-
}
|
|
5103
|
+
this.recordHostEvent(current, hostId, event, channel);
|
|
5029
5104
|
if (event.agentIdentity)
|
|
5030
5105
|
applyAgentIdentitySignal(current, event.agentIdentity);
|
|
5031
5106
|
if (event.fraimJob)
|
|
@@ -5063,9 +5138,7 @@ class AiHubServer {
|
|
|
5063
5138
|
clearCompactionLifecycle(current);
|
|
5064
5139
|
}
|
|
5065
5140
|
applyHostLifecycleSignal(current, event.hostLifecycle);
|
|
5066
|
-
|
|
5067
|
-
if (event.raw)
|
|
5068
|
-
current.events.push((0, hosts_1.createHubEvent)(channel, event.raw));
|
|
5141
|
+
this.recordHostEvent(current, hostId, event, channel);
|
|
5069
5142
|
if (event.agentIdentity)
|
|
5070
5143
|
applyAgentIdentitySignal(current, event.agentIdentity);
|
|
5071
5144
|
if (event.usage)
|
|
@@ -5078,6 +5151,9 @@ class AiHubServer {
|
|
|
5078
5151
|
current.status = exitCode === 0 ? 'completed' : 'failed';
|
|
5079
5152
|
current.events.push((0, hosts_1.createHubEvent)('system', `Direct run exited with code ${exitCode ?? 'unknown'}.`));
|
|
5080
5153
|
});
|
|
5154
|
+
const updated = this.runRegistry.get(directId);
|
|
5155
|
+
if (updated)
|
|
5156
|
+
this.finalizeRunConversationProjection(directId, updated.conversationId || updated.id);
|
|
5081
5157
|
this.runRegistry.dispose(directId);
|
|
5082
5158
|
},
|
|
5083
5159
|
}, startSessionSeedForHost(hostId, directRun.id), launchContext);
|
|
@@ -5231,11 +5307,7 @@ class AiHubServer {
|
|
|
5231
5307
|
clearCompactionLifecycle(current);
|
|
5232
5308
|
}
|
|
5233
5309
|
applyHostLifecycleSignal(current, event.hostLifecycle);
|
|
5234
|
-
|
|
5235
|
-
if (event.raw) {
|
|
5236
|
-
current.events.push((0, hosts_1.createHubEvent)(channel, event.raw));
|
|
5237
|
-
applyReviewProjection(current, event.raw);
|
|
5238
|
-
}
|
|
5310
|
+
this.recordHostEvent(current, run.hostId, event, channel);
|
|
5239
5311
|
if (event.agentIdentity)
|
|
5240
5312
|
applyAgentIdentitySignal(current, event.agentIdentity);
|
|
5241
5313
|
if (event.fraimJob)
|
|
@@ -5282,11 +5354,7 @@ class AiHubServer {
|
|
|
5282
5354
|
clearCompactionLifecycle(current);
|
|
5283
5355
|
}
|
|
5284
5356
|
applyHostLifecycleSignal(current, event.hostLifecycle);
|
|
5285
|
-
|
|
5286
|
-
if (event.raw) {
|
|
5287
|
-
current.events.push((0, hosts_1.createHubEvent)(channel, event.raw));
|
|
5288
|
-
applyReviewProjection(current, event.raw);
|
|
5289
|
-
}
|
|
5357
|
+
this.recordHostEvent(current, run.hostId, event, channel);
|
|
5290
5358
|
if (event.agentIdentity)
|
|
5291
5359
|
applyAgentIdentitySignal(current, event.agentIdentity);
|
|
5292
5360
|
if (event.fraimJob)
|
|
@@ -5374,6 +5442,8 @@ class AiHubServer {
|
|
|
5374
5442
|
}
|
|
5375
5443
|
const sessionId = resolvedHostSession?.sessionId || requestedSessionId;
|
|
5376
5444
|
const now = new Date().toISOString();
|
|
5445
|
+
const resumeEvents = persistedConversation ? persistedEventsForRun(persistedConversation) : [];
|
|
5446
|
+
resumeEvents.push((0, hosts_1.createHubEvent)('system', `Resuming ${configuredAgent.label} (${hostId}) session ${sessionId} in ${projectPath}`));
|
|
5377
5447
|
const run = {
|
|
5378
5448
|
id: (0, crypto_1.randomUUID)(),
|
|
5379
5449
|
conversationId,
|
|
@@ -5385,8 +5455,11 @@ class AiHubServer {
|
|
|
5385
5455
|
// Issue #892: keep the invocation scope so a resumed manager/company run stays
|
|
5386
5456
|
// in its project-independent conversation bucket.
|
|
5387
5457
|
scope,
|
|
5388
|
-
createdAt:
|
|
5389
|
-
|
|
5458
|
+
createdAt: typeof persistedConversation?.createdAt === 'string' ? persistedConversation.createdAt : now,
|
|
5459
|
+
updatedAt: now,
|
|
5460
|
+
messages: persistedConversation ? persistedMessagesForRun(persistedConversation) : [],
|
|
5461
|
+
events: resumeEvents,
|
|
5462
|
+
eventLogRefs: persistedConversation?.eventLogRefs || [],
|
|
5390
5463
|
// Only carry phase state forward when the prior run was interrupted mid-job
|
|
5391
5464
|
// (status !== 'completed'). A completed conversation is a finished run;
|
|
5392
5465
|
// resuming the session starts a fresh job, so the tracker must be blank.
|
|
@@ -5423,15 +5496,13 @@ class AiHubServer {
|
|
|
5423
5496
|
clearCompactionLifecycle(current);
|
|
5424
5497
|
}
|
|
5425
5498
|
applyHostLifecycleSignal(current, event.hostLifecycle);
|
|
5426
|
-
|
|
5499
|
+
this.recordHostEvent(current, hostId, event, channel);
|
|
5427
5500
|
if (event.raw) {
|
|
5428
5501
|
const missingSessionId = extractMissingHostSessionId(event.raw);
|
|
5429
5502
|
if (missingSessionId) {
|
|
5430
5503
|
host_session_state_1.hostSessionState.markInvalidRun(current, { configuredAgentId: current.configuredAgentId || null, baseHostId: current.baseHostId || current.hostId }, missingSessionId, 'host-reported-missing-session');
|
|
5431
5504
|
invalidatedMissingHostSession = true;
|
|
5432
5505
|
}
|
|
5433
|
-
current.events.push((0, hosts_1.createHubEvent)(channel, event.raw));
|
|
5434
|
-
applyReviewProjection(current, event.raw);
|
|
5435
5506
|
}
|
|
5436
5507
|
if (event.agentIdentity)
|
|
5437
5508
|
applyAgentIdentitySignal(current, event.agentIdentity);
|
|
@@ -5490,9 +5561,7 @@ class AiHubServer {
|
|
|
5490
5561
|
clearCompactionLifecycle(current);
|
|
5491
5562
|
}
|
|
5492
5563
|
applyHostLifecycleSignal(current, event.hostLifecycle);
|
|
5493
|
-
|
|
5494
|
-
if (event.raw)
|
|
5495
|
-
current.events.push((0, hosts_1.createHubEvent)(channel, event.raw));
|
|
5564
|
+
this.recordHostEvent(current, run.hostId, event, channel);
|
|
5496
5565
|
if (event.usage)
|
|
5497
5566
|
applyUsageSignal(current, event.usage);
|
|
5498
5567
|
});
|
|
@@ -5503,6 +5572,9 @@ class AiHubServer {
|
|
|
5503
5572
|
current.status = exitCode === 0 ? 'completed' : 'failed';
|
|
5504
5573
|
current.events.push((0, hosts_1.createHubEvent)('system', `Direct run exited with code ${exitCode ?? 'unknown'}.`));
|
|
5505
5574
|
});
|
|
5575
|
+
const updated = this.runRegistry.get(run.id);
|
|
5576
|
+
if (updated)
|
|
5577
|
+
this.finalizeRunConversationProjection(run.id, updated.conversationId || updated.id);
|
|
5506
5578
|
this.runRegistry.dispose(run.id);
|
|
5507
5579
|
},
|
|
5508
5580
|
}, directLaunch.launchContext);
|
|
@@ -6086,9 +6158,7 @@ class AiHubServer {
|
|
|
6086
6158
|
clearCompactionLifecycle(current);
|
|
6087
6159
|
}
|
|
6088
6160
|
applyHostLifecycleSignal(current, event.hostLifecycle);
|
|
6089
|
-
|
|
6090
|
-
if (event.raw)
|
|
6091
|
-
current.events.push((0, hosts_1.createHubEvent)(channel, event.raw));
|
|
6161
|
+
this.recordHostEvent(current, hostId, event, channel);
|
|
6092
6162
|
if (event.agentIdentity)
|
|
6093
6163
|
applyAgentIdentitySignal(current, event.agentIdentity);
|
|
6094
6164
|
if (event.fraimJob)
|
|
@@ -6105,6 +6175,9 @@ class AiHubServer {
|
|
|
6105
6175
|
current.status = exitCode === 0 ? 'completed' : 'failed';
|
|
6106
6176
|
current.events.push((0, hosts_1.createHubEvent)('system', `Trigger run exited with code ${exitCode ?? 'unknown'}.`));
|
|
6107
6177
|
});
|
|
6178
|
+
const updated = this.runRegistry.get(run.id);
|
|
6179
|
+
if (updated)
|
|
6180
|
+
this.finalizeRunConversationProjection(run.id, updated.conversationId || updated.id);
|
|
6108
6181
|
this.runRegistry.dispose(run.id);
|
|
6109
6182
|
},
|
|
6110
6183
|
}, startSessionSeedForHost(hostId, run.id), launchContext);
|
|
@@ -6238,11 +6311,7 @@ class AiHubServer {
|
|
|
6238
6311
|
clearCompactionLifecycle(current);
|
|
6239
6312
|
}
|
|
6240
6313
|
applyHostLifecycleSignal(current, event.hostLifecycle);
|
|
6241
|
-
|
|
6242
|
-
if (event.raw) {
|
|
6243
|
-
current.events.push((0, hosts_1.createHubEvent)(channel, event.raw));
|
|
6244
|
-
applyReviewProjection(current, event.raw);
|
|
6245
|
-
}
|
|
6314
|
+
this.recordHostEvent(current, hostId, event, channel);
|
|
6246
6315
|
if (event.agentIdentity)
|
|
6247
6316
|
applyAgentIdentitySignal(current, event.agentIdentity);
|
|
6248
6317
|
if (event.fraimJob)
|
|
@@ -6302,7 +6371,7 @@ class AiHubServer {
|
|
|
6302
6371
|
});
|
|
6303
6372
|
const parked = this.runRegistry.get(runId);
|
|
6304
6373
|
if (parked)
|
|
6305
|
-
this.
|
|
6374
|
+
this.finalizeRunConversationProjection(runId, parked.conversationId || parked.id);
|
|
6306
6375
|
this.runRegistry.dispose(runId);
|
|
6307
6376
|
return;
|
|
6308
6377
|
}
|
|
@@ -6323,11 +6392,7 @@ class AiHubServer {
|
|
|
6323
6392
|
clearCompactionLifecycle(r);
|
|
6324
6393
|
}
|
|
6325
6394
|
applyHostLifecycleSignal(r, event.hostLifecycle);
|
|
6326
|
-
|
|
6327
|
-
if (event.raw) {
|
|
6328
|
-
r.events.push((0, hosts_1.createHubEvent)(channel, event.raw));
|
|
6329
|
-
applyReviewProjection(r, event.raw);
|
|
6330
|
-
}
|
|
6395
|
+
this.recordHostEvent(r, current.hostId, event, channel);
|
|
6331
6396
|
if (event.seekMentoring)
|
|
6332
6397
|
this.applySeekMentoringSignalToRun(r, event.seekMentoring);
|
|
6333
6398
|
if (event.usage)
|
|
@@ -6370,7 +6435,7 @@ class AiHubServer {
|
|
|
6370
6435
|
if (updated) {
|
|
6371
6436
|
if (postPark)
|
|
6372
6437
|
postPark(updated);
|
|
6373
|
-
this.
|
|
6438
|
+
this.finalizeRunConversationProjection(runId, updated.conversationId || updated.id);
|
|
6374
6439
|
}
|
|
6375
6440
|
this.runRegistry.dispose(runId);
|
|
6376
6441
|
const latest = this.runRegistry.get(runId);
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "fraim-hub",
|
|
3
|
-
"version": "2.0.
|
|
3
|
+
"version": "2.0.270",
|
|
4
4
|
"description": "FRAIM Hub local companion package.",
|
|
5
5
|
"bin": {
|
|
6
6
|
"fraim-hub": "bin/fraim-hub.js",
|
|
@@ -91,6 +91,7 @@
|
|
|
91
91
|
"build": {
|
|
92
92
|
"appId": "ai.fraim.hub",
|
|
93
93
|
"productName": "FRAIM Hub",
|
|
94
|
+
"electronVersion": "41.2.2",
|
|
94
95
|
"artifactName": "FRAIM-Hub-${version}-${os}-${arch}.${ext}",
|
|
95
96
|
"directories": {
|
|
96
97
|
"output": "../../release/fraim-hub"
|
|
@@ -153,6 +154,7 @@
|
|
|
153
154
|
"node": ">=16.0.0"
|
|
154
155
|
},
|
|
155
156
|
"dependencies": {
|
|
157
|
+
"@electron/get": "^2.0.3",
|
|
156
158
|
"@octokit/rest": "^22.0.1",
|
|
157
159
|
"adm-zip": "^0.6.0",
|
|
158
160
|
"axios": "^1.7.0",
|
|
@@ -160,10 +162,10 @@
|
|
|
160
162
|
"commander": "^14.0.2",
|
|
161
163
|
"cors": "^2.8.5",
|
|
162
164
|
"dotenv": "^16.4.7",
|
|
163
|
-
"electron": "^41.2.2",
|
|
164
165
|
"electron-updater": "^6.8.9",
|
|
165
166
|
"express": "^5.2.1",
|
|
166
|
-
"
|
|
167
|
+
"extract-zip": "^2.0.1",
|
|
168
|
+
"fraim": "2.0.270",
|
|
167
169
|
"mongodb": "^7.0.0",
|
|
168
170
|
"node-cron": "4.2.1",
|
|
169
171
|
"node-edge-tts": "^1.2.10",
|
package/public/ai-hub/script.js
CHANGED
|
@@ -10510,6 +10510,7 @@ function tfAdoptServerProjects(projects) {
|
|
|
10510
10510
|
tf.projects = tfApplyUniqueProjectIds(normalized);
|
|
10511
10511
|
return;
|
|
10512
10512
|
}
|
|
10513
|
+
if (normalized.length === 0) return;
|
|
10513
10514
|
const byPath = new Map();
|
|
10514
10515
|
for (const project of normalized) {
|
|
10515
10516
|
const key = tfCanonicalProjectPath(project.folderPath || project.path || project.folder || '');
|
|
@@ -12154,10 +12155,11 @@ function tfRenderAreaConvPanel(el, conv) {
|
|
|
12154
12155
|
}
|
|
12155
12156
|
|
|
12156
12157
|
// #594 R2: return the most-relevant org-scoped conversation to show in the Company area.
|
|
12157
|
-
//
|
|
12158
|
+
// Issue #1124: only look in the @company bucket. Scanning all buckets caused project-scoped
|
|
12159
|
+
// org-learning-synthesis runs to hijack the Company tab.
|
|
12158
12160
|
function tfActiveOrgConv() {
|
|
12159
12161
|
const orgJobs = new Set(['organization-onboarding', 'organizational-learning-synthesis']);
|
|
12160
|
-
const convs =
|
|
12162
|
+
const convs = ((state.conversations || {})[COMPANY_CONV_KEY] || []).filter((c) => orgJobs.has(c.jobId));
|
|
12161
12163
|
// Prefer running > waiting (needs coaching) > most recently updated completed.
|
|
12162
12164
|
return (
|
|
12163
12165
|
convs.find((c) => c.status === 'running') ||
|
|
@@ -12309,13 +12311,18 @@ function tfRenderCompany() {
|
|
|
12309
12311
|
const rail = document.getElementById('company-rail');
|
|
12310
12312
|
if (rail) {
|
|
12311
12313
|
rail.innerHTML = '';
|
|
12312
|
-
const
|
|
12314
|
+
const orgConv = tfActiveOrgConv();
|
|
12315
|
+
const orgConvActive = !!orgConv;
|
|
12313
12316
|
const infoBtn = document.createElement('button');
|
|
12314
12317
|
infoBtn.type = 'button';
|
|
12315
12318
|
infoBtn.className = 'area-rail-info-btn' + (orgConvActive ? '' : ' info-active');
|
|
12316
12319
|
infoBtn.textContent = '📋 Company Info';
|
|
12317
12320
|
infoBtn.addEventListener('click', () => tfToggleAreaView('company', 'info'));
|
|
12318
12321
|
rail.appendChild(infoBtn);
|
|
12322
|
+
// Issue #1124: when a company-scoped org conv exists, add a visible rail entry so
|
|
12323
|
+
// the user can see and navigate to it. Previously the rail had only the static info
|
|
12324
|
+
// button and the conversation was unreachable from the left nav.
|
|
12325
|
+
if (orgConv) rail.appendChild(tfBuildManagerRunItem(orgConv));
|
|
12319
12326
|
// #693 R1 (PR round 2): the "Company jobs" launcher list is retired. Its jobs
|
|
12320
12327
|
// now run from the section they populate — Run Organization Onboarding in
|
|
12321
12328
|
// "Context & rules", Synthesize company learnings in "Company learnings".
|
|
@@ -15129,7 +15136,11 @@ function tfRefreshAfterBootstrap() {
|
|
|
15129
15136
|
// for the session as a dot + "run fraim setup"; re-populate here so a later
|
|
15130
15137
|
// bootstrap that resolves the identity heals the avatar without a reload.
|
|
15131
15138
|
tfPopulateAccountMenu();
|
|
15132
|
-
|
|
15139
|
+
// Do NOT re-apply state.bootstrap.projects here. The bootstrap list intentionally
|
|
15140
|
+
// excludes conversation-store projects (Issue #975 perf opt); applying it after
|
|
15141
|
+
// tfRefreshProjectsFromServerInBackground has already populated tf.projects with the
|
|
15142
|
+
// full list would strip those conv-store projects and empty the overview.
|
|
15143
|
+
// tfEnsureCurrentProject handles any missing active project without wiping the list.
|
|
15133
15144
|
tfEnsureCurrentProject();
|
|
15134
15145
|
tfRenderProjectTabs();
|
|
15135
15146
|
if (tf.area === 'company') tfRenderCompany();
|