fraim-hub 2.0.245 → 2.0.246

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.
@@ -0,0 +1,108 @@
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.RestartRecoveryPolicy = exports.DEFAULT_RESTART_RECOVERY_LEASE_MS = void 0;
7
+ const fs_1 = __importDefault(require("fs"));
8
+ const path_1 = __importDefault(require("path"));
9
+ const crypto_1 = require("crypto");
10
+ const conversation_store_1 = require("./conversation-store");
11
+ exports.DEFAULT_RESTART_RECOVERY_LEASE_MS = 60_000;
12
+ function normalizedDirectoryPath(projectPath) {
13
+ const resolved = path_1.default.resolve(projectPath);
14
+ return process.platform === 'win32' ? resolved.toLowerCase() : resolved;
15
+ }
16
+ function sameDirectoryPath(left, right) {
17
+ return normalizedDirectoryPath(left) === normalizedDirectoryPath(right);
18
+ }
19
+ function timestampMs(value) {
20
+ if (typeof value === 'number')
21
+ return Number.isFinite(value) ? value : 0;
22
+ if (typeof value === 'string') {
23
+ const parsed = Date.parse(value);
24
+ if (Number.isFinite(parsed))
25
+ return parsed;
26
+ const numeric = Number(value);
27
+ return Number.isFinite(numeric) ? numeric : 0;
28
+ }
29
+ return 0;
30
+ }
31
+ function restartRecoveryBucketOwnershipReason(conversation, bucketKey) {
32
+ if (!bucketKey)
33
+ return null;
34
+ const scope = (conversation.scope ?? conversation.invokedArea) || 'project';
35
+ if (scope === 'manager')
36
+ return bucketKey === conversation_store_1.MANAGER_SCOPE_KEY ? null : 'bucket_scope_mismatch';
37
+ if (scope === 'company')
38
+ return bucketKey === conversation_store_1.COMPANY_SCOPE_KEY ? null : 'bucket_scope_mismatch';
39
+ if (bucketKey === conversation_store_1.MANAGER_SCOPE_KEY || bucketKey === conversation_store_1.COMPANY_SCOPE_KEY)
40
+ return 'bucket_scope_mismatch';
41
+ const ownProjectPath = typeof conversation.projectPath === 'string' ? conversation.projectPath.trim() : '';
42
+ if (ownProjectPath && !sameDirectoryPath(ownProjectPath, bucketKey))
43
+ return 'bucket_project_mismatch';
44
+ return null;
45
+ }
46
+ class RestartRecoveryPolicy {
47
+ constructor(options = {}) {
48
+ this.nowMs = options.nowMs || Date.now;
49
+ this.recoveryLeaseMs = options.recoveryLeaseMs ?? exports.DEFAULT_RESTART_RECOVERY_LEASE_MS;
50
+ this.projectExists = options.projectExists || ((projectPath) => fs_1.default.existsSync(projectPath));
51
+ this.machineLevelJobIds = options.machineLevelJobIds || new Set();
52
+ }
53
+ classify(conversation, bucketKey, options = {}) {
54
+ const bucketReason = restartRecoveryBucketOwnershipReason(conversation, bucketKey);
55
+ if (bucketReason)
56
+ return { action: 'skip', reason: bucketReason };
57
+ if (conversation.status !== 'running')
58
+ return { action: 'skip', reason: 'not_running' };
59
+ const pauseReason = typeof conversation.pauseReason === 'string' ? conversation.pauseReason : '';
60
+ if (['stopped', 'done', 'awaiting_review', 'awaiting_user', 'error'].includes(pauseReason)) {
61
+ return { action: 'skip', reason: `pause_${pauseReason}` };
62
+ }
63
+ if (!conversation.sessionId || typeof conversation.sessionId !== 'string' || !conversation.sessionId.trim()) {
64
+ return { action: 'skip', reason: 'missing_session' };
65
+ }
66
+ if (!conversation.jobId || typeof conversation.jobId !== 'string' || !conversation.jobId.trim()) {
67
+ return { action: 'skip', reason: 'missing_job' };
68
+ }
69
+ const scope = conversation.scope || 'project';
70
+ const projectPath = typeof conversation.projectPath === 'string' ? conversation.projectPath : '';
71
+ if (scope === 'project' && !this.machineLevelJobIds.has(conversation.jobId) && (!projectPath || !this.projectExists(projectPath))) {
72
+ return { action: 'skip', reason: 'missing_project' };
73
+ }
74
+ if (conversation.reviewHandoff?.reviewRequired) {
75
+ return { action: 'skip', reason: 'awaiting_review' };
76
+ }
77
+ if (options.activeRunExists) {
78
+ return { action: 'defer', reason: 'active_run_exists' };
79
+ }
80
+ const recoveredAt = timestampMs(conversation.restartRecovery?.recoveredAt);
81
+ if (recoveredAt > 0 && this.nowMs() - recoveredAt < this.recoveryLeaseMs) {
82
+ return { action: 'defer', reason: 'recent_recovery' };
83
+ }
84
+ return { action: 'recover', attempt: 1 };
85
+ }
86
+ buildContinueMessage(run, decision) {
87
+ return [
88
+ '[FRAIM Hub system recovery]',
89
+ 'The FRAIM Hub process restarted while this run was marked in progress in durable conversation state.',
90
+ 'This is not a manager-authored instruction. Do not say the manager asked you to continue.',
91
+ `Run id: ${run.id}`,
92
+ `Conversation id: ${run.conversationId || 'unknown'}`,
93
+ `Session id: ${run.sessionId || 'unknown'}`,
94
+ `Restart recovery attempt: ${decision.attempt}`,
95
+ 'Resume only if the tracked FRAIM phase is non-terminal and not waiting for human review or approval.',
96
+ ].join('\n');
97
+ }
98
+ createRecoveryEvent(run, decision) {
99
+ const now = new Date(this.nowMs()).toISOString();
100
+ return {
101
+ id: (0, crypto_1.randomUUID)(),
102
+ channel: 'system',
103
+ createdAt: now,
104
+ text: `Hub restart recovery attempt ${decision.attempt} for run ${run.id} session ${run.sessionId || 'unknown'}.`,
105
+ };
106
+ }
107
+ }
108
+ exports.RestartRecoveryPolicy = RestartRecoveryPolicy;
@@ -60,6 +60,7 @@ const manager_turns_1 = require("./manager-turns");
60
60
  const preferences_1 = require("./preferences");
61
61
  const conversation_store_1 = require("./conversation-store");
62
62
  const conversation_store_lock_1 = require("./conversation-store-lock");
63
+ const restart_recovery_policy_1 = require("./restart-recovery-policy");
63
64
  const remote_hub_gateway_1 = require("./remote-hub-gateway");
64
65
  const managed_browser_1 = require("./managed-browser");
65
66
  const managed_agent_paths_1 = require("../cli/utils/managed-agent-paths");
@@ -186,7 +187,6 @@ const MACHINE_LEVEL_JOB_IDS = new Set([
186
187
  const DEFAULT_CONVERSATION_FLUSH_DELAY_MS = 2000;
187
188
  const HUB_RESTART_RECOVERY_LOCK_TIMEOUT_MS = 5000;
188
189
  const HUB_RESTART_RECOVERY_LOCK_STALE_MS = 10000;
189
- const HUB_RESTART_RECOVERY_CONTINUE_MESSAGE = 'Continue where you left off. The Hub process restarted and recovered this in-progress run from durable conversation state.';
190
190
  function conversationFlushDelayMs() {
191
191
  const configured = Number(process.env.FRAIM_HUB_CONVERSATION_FLUSH_MS);
192
192
  if (Number.isFinite(configured) && configured >= 0)
@@ -1578,47 +1578,6 @@ function isHumanActionGate(run) {
1578
1578
  const lastEntry = phaseHistory.length > 0 ? phaseHistory[phaseHistory.length - 1] : null;
1579
1579
  return lastEntry?.latestStatus === 'incomplete' || lastEntry?.latestStatus === 'failure';
1580
1580
  }
1581
- function restartRecoveryBucketOwnershipReason(conversation, bucketKey) {
1582
- if (!bucketKey)
1583
- return null;
1584
- const scope = (conversation.scope ?? conversation.invokedArea) || 'project';
1585
- if (scope === 'manager')
1586
- return bucketKey === conversation_store_1.MANAGER_SCOPE_KEY ? null : 'bucket_scope_mismatch';
1587
- if (scope === 'company')
1588
- return bucketKey === conversation_store_1.COMPANY_SCOPE_KEY ? null : 'bucket_scope_mismatch';
1589
- if (bucketKey === conversation_store_1.MANAGER_SCOPE_KEY || bucketKey === conversation_store_1.COMPANY_SCOPE_KEY)
1590
- return 'bucket_scope_mismatch';
1591
- const ownProjectPath = typeof conversation.projectPath === 'string' ? conversation.projectPath.trim() : '';
1592
- if (ownProjectPath && !sameDirectoryPath(ownProjectPath, bucketKey))
1593
- return 'bucket_project_mismatch';
1594
- return null;
1595
- }
1596
- function classifyRestartRecoveryEligibility(conversation, bucketKey) {
1597
- const bucketReason = restartRecoveryBucketOwnershipReason(conversation, bucketKey);
1598
- if (bucketReason)
1599
- return { eligible: false, reason: bucketReason };
1600
- if (conversation.status !== 'running')
1601
- return { eligible: false, reason: 'not_running' };
1602
- const pauseReason = typeof conversation.pauseReason === 'string' ? conversation.pauseReason : '';
1603
- if (['stopped', 'done', 'awaiting_review', 'awaiting_user', 'error'].includes(pauseReason)) {
1604
- return { eligible: false, reason: `pause_${pauseReason}` };
1605
- }
1606
- if (!conversation.sessionId || typeof conversation.sessionId !== 'string' || !conversation.sessionId.trim()) {
1607
- return { eligible: false, reason: 'missing_session' };
1608
- }
1609
- if (!conversation.jobId || typeof conversation.jobId !== 'string' || !conversation.jobId.trim()) {
1610
- return { eligible: false, reason: 'missing_job' };
1611
- }
1612
- const scope = conversation.scope || 'project';
1613
- const projectPath = typeof conversation.projectPath === 'string' ? conversation.projectPath : '';
1614
- if (scope === 'project' && !MACHINE_LEVEL_JOB_IDS.has(conversation.jobId) && (!projectPath || !fs_1.default.existsSync(projectPath))) {
1615
- return { eligible: false, reason: 'missing_project' };
1616
- }
1617
- if (conversation.reviewHandoff?.reviewRequired) {
1618
- return { eligible: false, reason: 'awaiting_review' };
1619
- }
1620
- return { eligible: true };
1621
- }
1622
1581
  function classifyExit(run, exitCode) {
1623
1582
  if (run.stoppedByUser) {
1624
1583
  return { action: 'park', pauseReason: 'stopped' };
@@ -1708,6 +1667,7 @@ class AiHubServer {
1708
1667
  this.deploymentStoreProvided = Boolean(options.deploymentStore);
1709
1668
  this.deploymentStore = options.deploymentStore ?? new DeploymentStore();
1710
1669
  this.hostConfigStore = options.hostConfigStore ?? new HostConfigStore();
1670
+ this.restartRecoveryPolicy = new restart_recovery_policy_1.RestartRecoveryPolicy({ machineLevelJobIds: MACHINE_LEVEL_JOB_IDS });
1711
1671
  this.app.use(express_1.default.json({ limit: '10mb' }));
1712
1672
  // CORS + Chrome Private Network Access for browser extensions and Office add-in task panes
1713
1673
  // calling the Hub from a public origin (word-edit.officeapps.live.com, etc.).
@@ -2455,9 +2415,11 @@ class AiHubServer {
2455
2415
  for (const bucketKey of bucketKeys) {
2456
2416
  const headers = this.conversationStore.loadProjectHeaders(bucketKey);
2457
2417
  for (const header of headers) {
2458
- const quickDecision = classifyRestartRecoveryEligibility(header, bucketKey);
2459
- if (!quickDecision.eligible) {
2460
- if (header.status === 'running') {
2418
+ const quickDecision = this.restartRecoveryPolicy.classify(header, bucketKey, {
2419
+ activeRunExists: Boolean(header.runId && this.runRegistry.get(header.runId)),
2420
+ });
2421
+ if (quickDecision.action !== 'recover') {
2422
+ if (quickDecision.action === 'skip' && header.status === 'running') {
2461
2423
  const skipped = this.conversationStore.loadConversation(bucketKey, header.id);
2462
2424
  if (skipped)
2463
2425
  this.markRestartRecoverySkipped(bucketKey, skipped, quickDecision.reason || 'ineligible');
@@ -2467,11 +2429,13 @@ class AiHubServer {
2467
2429
  const conversation = this.conversationStore.loadConversation(bucketKey, header.id);
2468
2430
  if (!conversation)
2469
2431
  continue;
2470
- const decision = classifyRestartRecoveryEligibility(conversation, bucketKey);
2471
- if (decision.eligible) {
2472
- candidates.push({ bucketKey, conversation });
2432
+ const decision = this.restartRecoveryPolicy.classify(conversation, bucketKey, {
2433
+ activeRunExists: Boolean(conversation.runId && this.runRegistry.get(conversation.runId)),
2434
+ });
2435
+ if (decision.action === 'recover') {
2436
+ candidates.push({ bucketKey, conversation, decision });
2473
2437
  }
2474
- else if (conversation.status === 'running') {
2438
+ else if (decision.action === 'skip' && conversation.status === 'running') {
2475
2439
  this.markRestartRecoverySkipped(bucketKey, conversation, decision.reason || 'ineligible');
2476
2440
  }
2477
2441
  }
@@ -2498,7 +2462,8 @@ class AiHubServer {
2498
2462
  const activeId = activeIdByBucket.get(candidate.bucketKey) ?? null;
2499
2463
  this.persistRunConversationNow(run, activeId);
2500
2464
  recoveredBucketKeys.add(candidate.bucketKey);
2501
- this.continueRecoveredRun(run, HUB_RESTART_RECOVERY_CONTINUE_MESSAGE, {
2465
+ this.continueRecoveredRun(run, this.restartRecoveryPolicy.buildContinueMessage(run, candidate.decision), {
2466
+ recoveryEvent: this.restartRecoveryPolicy.createRecoveryEvent(run, candidate.decision),
2502
2467
  postPark: (updated) => this.maybeStartDelegatedChildRuns(updated),
2503
2468
  activeId,
2504
2469
  });
@@ -2616,18 +2581,18 @@ class AiHubServer {
2616
2581
  continueRecoveredRun(run, instructions, options = {}) {
2617
2582
  if (!run.sessionId)
2618
2583
  return;
2619
- const prepared = this.prepareContinueMessage(run, instructions);
2620
2584
  this.runRegistry.update(run.id, (current) => {
2621
2585
  current.status = 'running';
2622
2586
  current.pauseReason = 'working';
2623
- current.messages.push((0, hosts_1.createHubMessage)('manager', prepared.display || instructions));
2587
+ if (options.recoveryEvent)
2588
+ current.events.push(options.recoveryEvent);
2624
2589
  });
2625
2590
  const currentRun = this.runRegistry.get(run.id) || run;
2626
2591
  const sessionId = currentRun.sessionId;
2627
2592
  if (!sessionId)
2628
2593
  return;
2629
2594
  const launch = this.resolveLaunchAgent(currentRun.configuredAgentId, currentRun.hostId);
2630
- const child = this.hostRuntime.continueRun(currentRun.hostId, currentRun.projectPath, sessionId, prepared.message, {
2595
+ const child = this.hostRuntime.continueRun(currentRun.hostId, currentRun.projectPath, sessionId, instructions, {
2631
2596
  onEvent: (event, channel) => {
2632
2597
  this.runRegistry.update(currentRun.id, (current) => {
2633
2598
  if (event.sessionId) {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "fraim-hub",
3
- "version": "2.0.245",
3
+ "version": "2.0.246",
4
4
  "description": "FRAIM Hub local companion package.",
5
5
  "bin": {
6
6
  "fraim-hub": "bin/fraim-hub.js",
@@ -161,7 +161,7 @@
161
161
  "electron": "^41.2.2",
162
162
  "electron-updater": "^6.8.9",
163
163
  "express": "^5.2.1",
164
- "fraim": "2.0.245",
164
+ "fraim": "2.0.246",
165
165
  "mongodb": "^7.0.0",
166
166
  "node-cron": "4.2.1",
167
167
  "node-edge-tts": "^1.2.10",