fraim-hub 2.0.324 → 2.0.326

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).
@@ -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)
@@ -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
  }
@@ -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;