fraim-hub 2.0.325 → 2.0.327

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.
@@ -17,22 +17,19 @@ const conversation_search_projection_1 = require("./conversation-search-projecti
17
17
  // They are NOT filesystem paths and must never be passed through path.resolve.
18
18
  exports.MANAGER_SCOPE_KEY = '@manager';
19
19
  exports.COMPANY_SCOPE_KEY = '@company';
20
- // Fields stripped from a header (the light shape the list UI renders). Everything else
21
- // (id, title, jobId, jobTitle, agentName, personaKey, status, runId, createdAt, lastUpdatedAt,
22
- // scope, reviewHandoff, ...) is kept. Client-only runtime flags are omitted too so stale index
23
- // cache state cannot make the frontend skip lazy body hydration.
24
- const HEADER_OMITTED_FIELDS = [
25
- 'messages',
26
- 'events',
27
- 'artifacts',
28
- 'run',
29
- 'handoffSummary',
30
- '_bodyLoaded',
31
- '_stopping',
32
- '_priorEvents',
33
- '_priorMessages',
20
+ // Issue #1765: public list metadata is opt-in. These fields feed the shared Projects,
21
+ // Manager and Company rail identity, ordering, status, trigger and delegation controls
22
+ // (buildAreaRunButton / conversationUiState in public/ai-hub/script.js). New body fields
23
+ // must never silently enlarge every list response or short-circuit lazy hydration.
24
+ const HEADER_FIELDS = [
25
+ 'id', 'projectPath', 'scope', 'invokedArea', 'title', 'jobId', 'jobTitle',
26
+ 'agentName', 'configuredAgentId', 'configuredAgentLabel', 'baseHostId', 'personaKey',
27
+ 'runId', 'status', 'pauseReason', 'createdAt', 'lastUpdatedAt', 'issueNumber',
28
+ 'sourceTrigger', 'blocked', 'stopped', 'reviewApproved', 'compareMode', 'compareRunId',
29
+ 'managedByRunId', 'managedByPersonaKey', 'managedReviewStatus', 'delegationTaskId',
30
+ 'humanCoachingDisabled', 'personaSnapshot', 'reviewHandoff',
34
31
  ];
35
- const HEADER_OMITTED_FIELD_SET = new Set(HEADER_OMITTED_FIELDS);
32
+ const HEADER_FIELD_SET = new Set([...HEADER_FIELDS, 'personaSnapshot', 'delegation', 'reviewHandoff']);
36
33
  /**
37
34
  * Issue #708: resolve the conversation store bucket key for a given scope.
38
35
  * - 'manager'/'company' → a stable sentinel key (project-independent home).
@@ -340,7 +337,7 @@ function normalizeConversation(projectPath, raw) {
340
337
  status: value.status,
341
338
  // Issue #904: carry pauseReason explicitly so it survives the ...value spread round-trip.
342
339
  ...(value.pauseReason !== undefined && { pauseReason: value.pauseReason }),
343
- createdAt: value.createdAt ?? new Date().toISOString(),
340
+ createdAt: value.createdAt ?? value.lastUpdatedAt ?? new Date().toISOString(),
344
341
  lastUpdatedAt: value.lastUpdatedAt ?? value.createdAt ?? new Date().toISOString(),
345
342
  };
346
343
  }
@@ -361,18 +358,23 @@ function newestFirst(a, b) {
361
358
  return timestampValue(b.lastUpdatedAt) - timestampValue(a.lastUpdatedAt);
362
359
  }
363
360
  function toHeader(conv) {
364
- const header = { ...conv };
365
- const delegation = compactDelegationForHeader(header.delegation);
366
- for (const field of HEADER_OMITTED_FIELDS)
367
- delete header[field];
368
- if (delegation)
369
- header.delegation = delegation;
370
- for (const field of Object.keys(header)) {
371
- if (field.startsWith('_'))
372
- delete header[field];
361
+ const header = pickHeaderFields(conv, HEADER_FIELDS);
362
+ if (conv.delegation !== undefined) {
363
+ header.delegation = compactDelegationForHeader(conv.delegation) ?? null;
373
364
  }
374
365
  return header;
375
366
  }
367
+ function pickHeaderFields(raw, fields) {
368
+ const result = {};
369
+ if (!raw || typeof raw !== 'object')
370
+ return result;
371
+ const value = raw;
372
+ for (const field of fields) {
373
+ if (Object.prototype.hasOwnProperty.call(value, field))
374
+ result[field] = value[field];
375
+ }
376
+ return result;
377
+ }
376
378
  function compactDelegationForHeader(raw) {
377
379
  if (!raw || typeof raw !== 'object')
378
380
  return undefined;
@@ -402,15 +404,15 @@ function compactDelegationForHeader(raw) {
402
404
  };
403
405
  }
404
406
  function headerNeedsSanitization(header) {
405
- const value = header;
406
- return Object.keys(value).some((field) => HEADER_OMITTED_FIELD_SET.has(field) || field.startsWith('_'));
407
+ return Object.keys(header).some((field) => !HEADER_FIELD_SET.has(field));
407
408
  }
408
409
  function sanitizeBucketIndex(index) {
409
410
  let changed = false;
410
411
  const headers = index.headers.map((header) => {
412
+ const compact = toHeader(header);
411
413
  if (headerNeedsSanitization(header))
412
414
  changed = true;
413
- return toHeader(header);
415
+ return compact;
414
416
  }).sort(newestFirst);
415
417
  const activeId = index.activeId && headers.some((header) => header.id === index.activeId) ? index.activeId : null;
416
418
  if (activeId !== index.activeId)
@@ -0,0 +1,79 @@
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.readInstallerSetupApiKey = readInstallerSetupApiKey;
7
+ exports.repairInstallerIdeMcpEntries = repairInstallerIdeMcpEntries;
8
+ exports.runDesktopInstallerSetup = runDesktopInstallerSetup;
9
+ const fs_1 = __importDefault(require("fs"));
10
+ const path_1 = __importDefault(require("path"));
11
+ const child_process_1 = require("child_process");
12
+ const project_fraim_paths_1 = require("../core/utils/project-fraim-paths");
13
+ const ide_detector_1 = require("../cli/setup/ide-detector");
14
+ const auto_mcp_setup_1 = require("../cli/setup/auto-mcp-setup");
15
+ const fraim_mcp_latest_launcher_1 = require("../cli/mcp/fraim-mcp-latest-launcher");
16
+ const managed_node_runtime_1 = require("../cli/utils/managed-node-runtime");
17
+ const win32_cmd_spawn_1 = require("../cli/utils/win32-cmd-spawn");
18
+ function readInstallerSetupApiKey(configPath = path_1.default.join((0, project_fraim_paths_1.getUserFraimDirPath)(), 'config.json')) {
19
+ if (!fs_1.default.existsSync(configPath))
20
+ return null;
21
+ try {
22
+ const parsed = JSON.parse(fs_1.default.readFileSync(configPath, 'utf8'));
23
+ return typeof parsed.apiKey === 'string' && parsed.apiKey.trim() ? parsed.apiKey.trim() : null;
24
+ }
25
+ catch {
26
+ return null;
27
+ }
28
+ }
29
+ async function repairInstallerIdeMcpEntries(options = {}) {
30
+ const logger = options.logger ?? console;
31
+ const fraimKey = readInstallerSetupApiKey(options.configPath);
32
+ if (!fraimKey) {
33
+ logger.log('[fraim-installer-setup] no existing config.json apiKey; IDE MCP repair will run from first-run');
34
+ return { status: 'skipped', ideCount: 0, reason: 'missing-config-api-key' };
35
+ }
36
+ const detectedIDEs = (options.detectInstalledIDEs ?? ide_detector_1.detectInstalledIDEs)();
37
+ const ideNames = detectedIDEs.map((ide) => ide.name).filter(Boolean);
38
+ if (ideNames.length === 0) {
39
+ logger.log('[fraim-installer-setup] no supported IDEs detected for MCP repair');
40
+ return { status: 'skipped', ideCount: 0, reason: 'no-detected-ides' };
41
+ }
42
+ const previousNonInteractive = process.env.FRAIM_NON_INTERACTIVE;
43
+ process.env.FRAIM_NON_INTERACTIVE = '1';
44
+ try {
45
+ await (options.autoConfigureMCP ?? auto_mcp_setup_1.autoConfigureMCP)(fraimKey, ideNames);
46
+ logger.log(`[fraim-installer-setup] repaired MCP config for ${ideNames.length} detected IDE(s)`);
47
+ return { status: 'repaired', ideCount: ideNames.length };
48
+ }
49
+ catch (error) {
50
+ const message = error instanceof Error ? error.message : String(error);
51
+ logger.warn(`[fraim-installer-setup] IDE MCP repair skipped after error: ${message}`);
52
+ return { status: 'failed', ideCount: ideNames.length, reason: message };
53
+ }
54
+ finally {
55
+ if (previousNonInteractive === undefined)
56
+ delete process.env.FRAIM_NON_INTERACTIVE;
57
+ else
58
+ process.env.FRAIM_NON_INTERACTIVE = previousNonInteractive;
59
+ }
60
+ }
61
+ function warmInstallerCliCache() {
62
+ const cliShim = (0, fraim_mcp_latest_launcher_1.getFraimCliShimPath)();
63
+ const options = { env: process.env, encoding: 'utf8', stdio: 'inherit' };
64
+ const result = process.platform === 'win32'
65
+ ? (0, win32_cmd_spawn_1.spawnViaCmdExe)(cliShim, ['--version'], options)
66
+ : (0, child_process_1.spawnSync)(cliShim, ['--version'], options);
67
+ if (result.status !== 0) {
68
+ throw new Error(`FRAIM CLI bootstrap exited with code ${result.status ?? 'unknown'}.`);
69
+ }
70
+ }
71
+ async function runDesktopInstallerSetup(logger = console) {
72
+ await (0, managed_node_runtime_1.ensureManagedNpm)({
73
+ onProgress: (message) => logger.log(`[fraim-installer-setup] ${message}`),
74
+ });
75
+ (0, fraim_mcp_latest_launcher_1.ensureFraimMcpLatestLauncher)();
76
+ warmInstallerCliCache();
77
+ await repairInstallerIdeMcpEntries({ logger });
78
+ logger.log('[fraim-installer-setup] stable CLI and MCP launchers are ready');
79
+ }
@@ -20,6 +20,10 @@ async function bootstrapFrom(entryPath) {
20
20
  await entry.bootstrap();
21
21
  }
22
22
  async function launch() {
23
+ if (process.argv.includes('--fraim-installer-setup')) {
24
+ await bootstrapFrom(BUNDLED_ENTRY_PATH);
25
+ return;
26
+ }
23
27
  // Issue #1415: deterministic escape hatch for integration testing — skips the real npm
24
28
  // resolution (and its dependence on whatever happens to be published at test time) so a test can
25
29
  // prove the Electron main-process boot chain itself (resolveHubAppEntry -> require -> bootstrap
@@ -20,7 +20,7 @@ const window_open_decision_1 = require("./window-open-decision");
20
20
  const bundled_asset_resolver_1 = require("./bundled-asset-resolver");
21
21
  const server_2 = require("../first-run/server");
22
22
  const session_service_1 = require("../first-run/session-service");
23
- const fraim_mcp_latest_launcher_1 = require("../cli/mcp/fraim-mcp-latest-launcher");
23
+ const desktop_installer_setup_1 = require("./desktop-installer-setup");
24
24
  const desktop_auto_updater_1 = require("./desktop-auto-updater");
25
25
  const hub_app_materializer_1 = require("./hub-app-materializer");
26
26
  const hub_instance_reconciliation_1 = require("./hub-instance-reconciliation");
@@ -89,13 +89,6 @@ function applyUserDataOverride() {
89
89
  fs_1.default.mkdirSync(userDataDir, { recursive: true });
90
90
  electron_1.app.setPath('userData', userDataDir);
91
91
  }
92
- function configurePackagedFraimRuntime() {
93
- if (!electron_1.app.isPackaged && process.env.FRAIM_DESKTOP_FIRST_RUN !== '1')
94
- return;
95
- process.env.FRAIM_PACKAGED_CLI_EXECUTABLE = process.execPath;
96
- process.env.FRAIM_PACKAGED_CLI_SCRIPT = (0, hub_app_materializer_1.resolvePackagedCliScriptPath)(__dirname);
97
- (0, fraim_mcp_latest_launcher_1.ensureFraimMcpLatestLauncher)();
98
- }
99
92
  function shouldShowEmbeddedFirstRun() {
100
93
  if (!electron_1.app.isPackaged && process.env.FRAIM_DESKTOP_FIRST_RUN !== '1')
101
94
  return false;
@@ -153,6 +146,16 @@ async function configureAutoUpdater() {
153
146
  });
154
147
  return result.action === 'installing';
155
148
  }
149
+ async function runPackagedInstallerRepair() {
150
+ if (!electron_1.app.isPackaged || process.env.FRAIM_INSTALLER_LIFECYCLE_TEST === '1')
151
+ return;
152
+ try {
153
+ await (0, desktop_installer_setup_1.runDesktopInstallerSetup)(console);
154
+ }
155
+ catch (error) {
156
+ console.warn('[fraim] packaged installer repair failed:', error);
157
+ }
158
+ }
156
159
  // Issue #1415: shared by every path that needs to know "is a newer FRAIM Hub published than the
157
160
  // one currently running" — the automatic second-instance/activate recheck below, and the
158
161
  // manually-triggered restart from the web UI's update badge (see restartToLatestNow). Compares
@@ -655,6 +658,12 @@ async function bootstrap() {
655
658
  preferredPort: options.preferredPort,
656
659
  hasProjectPath: Boolean(options.projectPath),
657
660
  });
661
+ if (process.argv.includes('--fraim-installer-setup')) {
662
+ await electron_1.app.whenReady();
663
+ await (0, desktop_installer_setup_1.runDesktopInstallerSetup)(console);
664
+ electron_1.app.exit(0);
665
+ return;
666
+ }
658
667
  electron_1.app.on('child-process-gone', (_event, details) => {
659
668
  (0, hub_main_diagnostics_1.recordHubMainDiagnostic)('app.child_process_gone', details);
660
669
  });
@@ -679,7 +688,6 @@ async function bootstrap() {
679
688
  });
680
689
  await electron_1.app.whenReady();
681
690
  electron_1.app.setName(displayName(options.runtimeId));
682
- configurePackagedFraimRuntime();
683
691
  // Issue #1415 (follow-up): app.requestSingleInstanceLock() above only sees another instance of
684
692
  // *this same identity* — it cannot see a Hub already running via a different launch path (e.g.
685
693
  // `npx fraim-hub`'s raw electron.exe, confirmed live to end up under a different userData scope
@@ -710,6 +718,7 @@ async function bootstrap() {
710
718
  // First-launch housekeeping (idempotent, fast on subsequent runs)
711
719
  if (process.env.FRAIM_INSTALLER_LIFECYCLE_TEST !== '1') {
712
720
  ensureLoginItem();
721
+ await runPackagedInstallerRepair();
713
722
  const installingUpdate = await configureAutoUpdater();
714
723
  if (installingUpdate)
715
724
  return;
@@ -603,7 +603,7 @@ function stringifyMentorCallArgs(rawArgs) {
603
603
  }
604
604
  function resultTextFromValue(value) {
605
605
  if (typeof value === 'string')
606
- return value.trim() || null;
606
+ return value.trim() ? value : null;
607
607
  if (Array.isArray(value)) {
608
608
  const parts = value
609
609
  .map((entry) => resultTextFromValue(entry))
@@ -693,6 +693,9 @@ function parseMicroEventsSignal(hostId, line) {
693
693
  }
694
694
  else if (itemType !== 'agent_message' && itemType !== 'error') {
695
695
  events.push(microEventFromNonMentorToolCall(item) || { kind: 'tool_call', text: itemType });
696
+ const output = resultTextFromValue(item.result ?? item.output ?? item.aggregated_output);
697
+ if (output)
698
+ events.push({ kind: 'tool_result', text: output });
696
699
  }
697
700
  }
698
701
  if (item && item.type === 'mcp_tool_call' && isFraimTool(readToolName(item), 'seekMentoring')) {
@@ -724,7 +727,7 @@ function parseMicroEventsSignal(hostId, line) {
724
727
  }
725
728
  else if (root.type === 'tool.execution_complete') {
726
729
  const resultText = resultTextFromValue(data.result ?? data.output ?? data.content);
727
- events.push({ kind: 'tool_call', text: resultText ? `${rootType}: ${resultText}` : rootType });
730
+ events.push({ kind: 'tool_result', text: resultText || rootType });
728
731
  rootTypeHandled = true;
729
732
  }
730
733
  }
@@ -5,11 +5,11 @@ var __importDefault = (this && this.__importDefault) || function (mod) {
5
5
  Object.defineProperty(exports, "__esModule", { value: true });
6
6
  exports.HUB_APP_ENTRY_RELATIVE = exports.HUB_APP_PACKAGE_NAME = void 0;
7
7
  exports.redactCredentialLikeSubstrings = redactCredentialLikeSubstrings;
8
- exports.resolvePackagedCliScriptPath = resolvePackagedCliScriptPath;
9
8
  exports.planHubAppMaterialization = planHubAppMaterialization;
10
9
  exports.resolveHubAppCacheDir = resolveHubAppCacheDir;
11
10
  exports.isHubAppCacheComplete = isHubAppCacheComplete;
12
11
  exports.listCachedHubAppVersions = listCachedHubAppVersions;
12
+ exports.runNpmInstall = runNpmInstall;
13
13
  exports.ensureMaterializedHubApp = ensureMaterializedHubApp;
14
14
  exports.resolveHubAppEntry = resolveHubAppEntry;
15
15
  const fs_1 = __importDefault(require("fs"));
@@ -55,19 +55,6 @@ function redactCredentialLikeSubstrings(text) {
55
55
  exports.HUB_APP_PACKAGE_NAME = 'fraim-hub';
56
56
  /** Relative path, from a materialized (or bundled) package root, to the Electron main entry. */
57
57
  exports.HUB_APP_ENTRY_RELATIVE = path_1.default.join('dist', 'src', 'ai-hub', 'desktop-main.js');
58
- /**
59
- * Where the packaged CLI/MCP shim (`ensureFraimMcpLatestLauncher()`) should find `fraim`'s CLI
60
- * script, derived from `desktop-main.ts`'s own directory (`<packageRoot>/dist/src/ai-hub/`) rather
61
- * than Electron's `app.getAppPath()`. `desktop-launcher.ts` may have loaded `desktop-main.js` from a
62
- * materialized `fraim-hub@<latest>` copy under `~/.fraim/bin/hub-app/<version>/` rather than from
63
- * the original app.asar, and `app.getAppPath()` always names the latter regardless of which copy is
64
- * actually running. `moduleDir` tracks whichever copy is really executing. Kept in this
65
- * Electron-free module (rather than inlined in `desktop-main.ts`) so it is unit-testable without an
66
- * Electron `app` object.
67
- */
68
- function resolvePackagedCliScriptPath(moduleDir) {
69
- return path_1.default.join(moduleDir, '..', '..', '..', 'node_modules', 'fraim', 'bin', 'fraim.js');
70
- }
71
58
  function pickNewestComplete(cachedVersions) {
72
59
  const versions = cachedVersions.filter((entry) => entry.complete).map((entry) => entry.version);
73
60
  if (versions.length === 0)
@@ -178,16 +165,19 @@ function listCachedHubAppVersions(fraimDir = (0, project_fraim_paths_1.getUserFr
178
165
  * every dependency to that top level; nothing here relies on nested-dependency resolution.
179
166
  *
180
167
  * The `npm` executable itself is resolved via `ensureManagedNpm()` (system PATH, an already-managed
181
- * runtime, or a freshly downloaded one) rather than assumed present on PATH — a genuinely GUI-only
182
- * install (native installer only, no CLI ever run) has neither system npm nor FRAIM's own managed
183
- * one, since first-run's embedded-desktop path only needs Electron's own bundled Node for its own
184
- * purposes and never downloads a standalone npm (see managed-node-runtime.ts's own header comment).
168
+ * runtime, or a freshly downloaded one) rather than assumed present on PATH. Installer setup
169
+ * prepares that shared runtime, while this fallback also supports older installs and recovery
170
+ * when installer setup did not finish.
185
171
  */
186
172
  function runNpmInstall(npmPath, args) {
173
+ // POSIX npm uses /usr/bin/env node. A GUI launch may have no system Node
174
+ // on PATH even after ensureManagedNpm has downloaded a complete runtime.
175
+ const npmPathValue = [path_1.default.dirname(npmPath), process.env.PATH].filter(Boolean).join(path_1.default.delimiter);
176
+ const env = { ...process.env, PATH: npmPathValue, ...(process.platform === 'win32' ? { Path: npmPathValue } : {}) };
187
177
  if (process.platform !== 'win32') {
188
- return (0, child_process_1.spawnSync)(npmPath, args, { encoding: 'utf8' });
178
+ return (0, child_process_1.spawnSync)(npmPath, args, { encoding: 'utf8', env });
189
179
  }
190
- return (0, win32_cmd_spawn_1.spawnViaCmdExe)(npmPath, args, { encoding: 'utf8' });
180
+ return (0, win32_cmd_spawn_1.spawnViaCmdExe)(npmPath, args, { encoding: 'utf8', env });
191
181
  }
192
182
  const defaultInstallPackage = async (version, destDir) => {
193
183
  fs_1.default.mkdirSync(destDir, { recursive: true });
@@ -6,16 +6,11 @@ Object.defineProperty(exports, "__esModule", { value: true });
6
6
  exports.AiHubRawEventLogStore = void 0;
7
7
  const node_crypto_1 = __importDefault(require("node:crypto"));
8
8
  const node_fs_1 = __importDefault(require("node:fs"));
9
- const node_os_1 = __importDefault(require("node:os"));
10
9
  const node_path_1 = __importDefault(require("node:path"));
10
+ const types_1 = require("./types");
11
11
  const project_fraim_paths_1 = require("../core/utils/project-fraim-paths");
12
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
- }
13
+ return node_path_1.default.join((0, project_fraim_paths_1.getUserFraimDirPath)(), 'ai-hub-event-logs');
19
14
  }
20
15
  function hashSegment(value, length = 24) {
21
16
  return node_crypto_1.default.createHash('sha256').update(value || 'unknown').digest('hex').slice(0, length);
@@ -23,20 +18,39 @@ function hashSegment(value, length = 24) {
23
18
  function safeRunFileName(runId) {
24
19
  return `${hashSegment(runId, 40)}.jsonl`;
25
20
  }
21
+ function parseEntry(line) {
22
+ try {
23
+ const value = JSON.parse(line);
24
+ if (typeof value.text !== 'string' || !['stdout', 'stderr', 'system'].includes(value.channel))
25
+ return null;
26
+ return { createdAt: typeof value.createdAt === 'string' ? value.createdAt : new Date(0).toISOString(), channel: value.channel, text: value.text,
27
+ ...(types_1.AI_HUB_MICRO_EVENT_KINDS.includes(value.hubKind) ? { hubKind: value.hubKind } : {}),
28
+ ...(typeof value.hostId === 'string' ? { hostId: value.hostId } : {}),
29
+ ...(Array.isArray(value.microEvents) ? { microEvents: value.microEvents.filter((event) => event && types_1.AI_HUB_MICRO_EVENT_KINDS.includes(event.kind) && typeof event.text === 'string') } : {}) };
30
+ }
31
+ catch {
32
+ return null;
33
+ }
34
+ }
26
35
  class AiHubRawEventLogStore {
27
36
  constructor(rootDir = defaultRawEventLogRoot()) {
28
37
  this.rootDir = node_path_1.default.resolve(rootDir);
29
38
  }
30
39
  append(bucketKey, conversationId, runId, entry) {
31
40
  const dir = node_path_1.default.join(this.rootDir, hashSegment(bucketKey), hashSegment(conversationId));
32
- node_fs_1.default.mkdirSync(dir, { recursive: true });
33
41
  const logPath = node_path_1.default.join(dir, safeRunFileName(runId));
34
42
  const metaPath = `${logPath}.meta.json`;
43
+ if (!this.resolveReadableLogPath(logPath) || !this.resolveReadableLogPath(metaPath))
44
+ throw new Error('Event archive path is outside its root');
45
+ node_fs_1.default.mkdirSync(dir, { recursive: true });
35
46
  const createdAt = entry.createdAt || new Date().toISOString();
36
47
  const line = {
37
48
  createdAt,
38
49
  channel: entry.channel,
39
50
  text: entry.text,
51
+ ...(entry.hubKind ? { hubKind: entry.hubKind } : {}),
52
+ ...(entry.hostId ? { hostId: entry.hostId } : {}),
53
+ ...(entry.microEvents ? { microEvents: entry.microEvents } : {}),
40
54
  };
41
55
  node_fs_1.default.appendFileSync(logPath, `${JSON.stringify(line)}\n`, 'utf8');
42
56
  const prior = this.readMeta(metaPath, runId, logPath);
@@ -48,6 +62,8 @@ class AiHubRawEventLogStore {
48
62
  eventCount: prior.eventCount + 1,
49
63
  createdAt: prior.createdAt || createdAt,
50
64
  updatedAt: createdAt,
65
+ ownerPid: process.pid,
66
+ ...(entry.hostId || prior.hostId ? { hostId: entry.hostId || prior.hostId } : {}),
51
67
  ...(prior.truncated ? { truncated: true } : {}),
52
68
  };
53
69
  node_fs_1.default.writeFileSync(metaPath, `${JSON.stringify(ref, null, 2)}\n`, 'utf8');
@@ -60,22 +76,90 @@ class AiHubRawEventLogStore {
60
76
  const lines = node_fs_1.default.readFileSync(logPath, 'utf8').split(/\r?\n/).filter(Boolean);
61
77
  const entries = [];
62
78
  for (const line of lines) {
79
+ const entry = parseEntry(line);
80
+ if (entry)
81
+ entries.push(entry);
82
+ }
83
+ return entries;
84
+ }
85
+ markEnded(ref, endedAt) {
86
+ const logPath = this.resolveReadableLogPath(ref.path);
87
+ if (!logPath || !node_fs_1.default.existsSync(logPath))
88
+ return ref;
89
+ if (!this.resolveReadableLogPath(`${logPath}.meta.json`))
90
+ throw new Error('Event archive metadata is outside its root');
91
+ const next = { ...this.readMeta(`${logPath}.meta.json`, ref.runId, logPath), endedAt };
92
+ delete next.ownerPid;
93
+ node_fs_1.default.writeFileSync(`${logPath}.meta.json`, `${JSON.stringify(next, null, 2)}\n`, 'utf8');
94
+ return next;
95
+ }
96
+ /** Read display-ready entries in chronological order without reconstructing host output. */
97
+ async readMicroEvents(refs) {
98
+ const events = [];
99
+ let missingArchives = 0;
100
+ let legacyArchives = 0;
101
+ for (const ref of refs) {
102
+ const logPath = this.resolveReadableLogPath(ref.path);
103
+ if (!logPath) {
104
+ missingArchives++;
105
+ continue;
106
+ }
107
+ let size;
108
+ try {
109
+ size = (await node_fs_1.default.promises.stat(logPath)).size;
110
+ }
111
+ catch (error) {
112
+ if (error.code === 'ENOENT') {
113
+ missingArchives++;
114
+ continue;
115
+ }
116
+ throw error;
117
+ }
118
+ if (!size)
119
+ continue;
120
+ let hasLegacyEntries = false;
121
+ // Snapshot the size; only newline-terminated records are committed. The
122
+ // stream owns its descriptor and decodes UTF-8 across chunk boundaries.
123
+ const stream = node_fs_1.default.createReadStream(logPath, { encoding: 'utf8', end: size - 1 });
124
+ let pending = '';
125
+ let lineNumber = 0;
63
126
  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
- });
127
+ for await (const chunk of stream) {
128
+ const lines = (pending + chunk).split('\n');
129
+ pending = lines.pop();
130
+ for (const line of lines) {
131
+ const entry = parseEntry(line);
132
+ const id = `${ref.runId}:${lineNumber++}`;
133
+ if (!entry)
134
+ continue;
135
+ const displayed = entry.hubKind ? [{ kind: entry.hubKind, text: entry.text }] : entry.microEvents;
136
+ if (!displayed) {
137
+ hasLegacyEntries = true;
138
+ continue;
139
+ }
140
+ displayed.forEach((event, index) => events.push({
141
+ id: `${id}:${index}`, kind: event.kind, text: event.text,
142
+ channel: event.kind === 'manager_input' ? 'manager' : entry.channel, createdAt: entry.createdAt,
143
+ }));
144
+ }
145
+ await new Promise((resolve) => setImmediate(resolve));
72
146
  }
73
147
  }
74
- catch {
75
- // Corrupt diagnostic lines are ignored; callers fall back to other session sources.
148
+ catch (error) {
149
+ if (error.code === 'ENOENT') {
150
+ missingArchives++;
151
+ continue;
152
+ }
153
+ throw error;
76
154
  }
155
+ finally {
156
+ stream.destroy();
157
+ }
158
+ if (hasLegacyEntries)
159
+ legacyArchives++;
77
160
  }
78
- return entries;
161
+ events.sort((a, b) => Date.parse(a.createdAt) - Date.parse(b.createdAt));
162
+ return { events, archiveAvailable: refs.length > missingArchives, missingArchives, legacyArchives };
79
163
  }
80
164
  readMeta(metaPath, runId, logPath) {
81
165
  if (!node_fs_1.default.existsSync(metaPath)) {
@@ -90,6 +174,9 @@ class AiHubRawEventLogStore {
90
174
  eventCount: typeof parsed.eventCount === 'number' ? parsed.eventCount : 0,
91
175
  createdAt: typeof parsed.createdAt === 'string' ? parsed.createdAt : '',
92
176
  updatedAt: typeof parsed.updatedAt === 'string' ? parsed.updatedAt : '',
177
+ ...(typeof parsed.endedAt === 'string' ? { endedAt: parsed.endedAt } : {}),
178
+ ...(typeof parsed.ownerPid === 'number' ? { ownerPid: parsed.ownerPid } : {}),
179
+ ...(typeof parsed.hostId === 'string' ? { hostId: parsed.hostId } : {}),
93
180
  ...(parsed.truncated ? { truncated: true } : {}),
94
181
  };
95
182
  }
@@ -104,7 +191,30 @@ class AiHubRawEventLogStore {
104
191
  const rootPrefix = `${this.rootDir}${node_path_1.default.sep}`;
105
192
  const comparableResolved = process.platform === 'win32' ? resolved.toLowerCase() : resolved;
106
193
  const comparableRootPrefix = process.platform === 'win32' ? rootPrefix.toLowerCase() : rootPrefix;
107
- return comparableResolved.startsWith(comparableRootPrefix) ? resolved : null;
194
+ if (!comparableResolved.startsWith(comparableRootPrefix))
195
+ return null;
196
+ // A symlink inside the archive root must not grant access outside it.
197
+ try {
198
+ const real = node_fs_1.default.realpathSync(resolved);
199
+ const root = node_fs_1.default.realpathSync(this.rootDir) + node_path_1.default.sep;
200
+ return (process.platform === 'win32' ? real.toLowerCase().startsWith(root.toLowerCase()) : real.startsWith(root)) ? real : null;
201
+ }
202
+ catch (error) {
203
+ if (error.code === 'ENOENT') {
204
+ // Missing leaves still inherit the confinement of their nearest real
205
+ // parent, including Windows junctions inside a hashed bucket directory.
206
+ let parent = node_path_1.default.dirname(resolved);
207
+ while (!node_fs_1.default.existsSync(parent) && parent !== node_path_1.default.dirname(parent))
208
+ parent = node_path_1.default.dirname(parent);
209
+ if (!node_fs_1.default.existsSync(this.rootDir))
210
+ return resolved;
211
+ const realParent = node_fs_1.default.realpathSync(parent);
212
+ const realRoot = node_fs_1.default.realpathSync(this.rootDir);
213
+ const relative = node_path_1.default.relative(realRoot, realParent);
214
+ return relative === '' || (!relative.startsWith('..') && !node_path_1.default.isAbsolute(relative)) ? resolved : null;
215
+ }
216
+ throw error;
217
+ }
108
218
  }
109
219
  }
110
220
  exports.AiHubRawEventLogStore = AiHubRawEventLogStore;