fraim-hub 2.0.244 → 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.
- package/dist/src/ai-hub/catalog.js +38 -8
- package/dist/src/ai-hub/custom-employees.js +99 -33
- package/dist/src/ai-hub/restart-recovery-policy.js +108 -0
- package/dist/src/ai-hub/server.js +22 -53
- package/dist/src/local-mcp-server/learning-context-builder.js +232 -77
- package/package.json +2 -2
- package/public/ai-hub/review.css +139 -0
- package/public/ai-hub/script.js +567 -71
- package/public/ai-hub/styles.css +18 -0
|
@@ -22,14 +22,27 @@ const project_fraim_paths_1 = require("../core/utils/project-fraim-paths");
|
|
|
22
22
|
// - <project>/fraim/ai-employee/jobs/<category>/ - synced baseline
|
|
23
23
|
// - <project>/fraim/personalized-employee/jobs/<category>/ - taught/customized override
|
|
24
24
|
// Only the personalized-employee layer is "personalized" (issue #566).
|
|
25
|
+
// Issue #1002: the machine-level layers are added here so a capability authored
|
|
26
|
+
// at the manager level is DISCOVERABLE, not merely resolvable by exact name.
|
|
27
|
+
// Order matters and mirrors LocalRegistryResolver.resolveFile precedence
|
|
28
|
+
// inverted, because later entries win on a {categoryId, jobId} collision:
|
|
29
|
+
// synced baseline < company cache < manager synced cache < manager local < project
|
|
30
|
+
// A job that resolves from the manager level must therefore also DISPLAY as the
|
|
31
|
+
// manager's, otherwise the rail would show a baseline job the runtime never runs.
|
|
25
32
|
const EMPLOYEE_JOB_LAYERS = [
|
|
26
33
|
{ segments: ['ai-employee', 'jobs'], personalized: false },
|
|
27
|
-
{ segments: ['
|
|
34
|
+
{ base: 'user', segments: ['org', 'jobs'], personalized: true, scope: 'org' },
|
|
35
|
+
{ base: 'user', segments: ['manager', 'jobs'], personalized: true, scope: 'manager' },
|
|
36
|
+
{ base: 'user', segments: ['personalized-employee', 'jobs'], personalized: true, scope: 'manager' },
|
|
37
|
+
{ segments: ['personalized-employee', 'jobs'], personalized: true, scope: 'project' },
|
|
28
38
|
];
|
|
29
39
|
// Manager templates use the matching layer model.
|
|
30
40
|
const MANAGER_JOB_LAYERS = [
|
|
31
41
|
{ segments: ['ai-manager', 'jobs'], personalized: false },
|
|
32
|
-
{ segments: ['
|
|
42
|
+
{ base: 'user', segments: ['org', 'manager-jobs'], personalized: true, scope: 'org' },
|
|
43
|
+
{ base: 'user', segments: ['manager', 'manager-jobs'], personalized: true, scope: 'manager' },
|
|
44
|
+
{ base: 'user', segments: ['personalized-employee', 'manager-jobs'], personalized: true, scope: 'manager' },
|
|
45
|
+
{ segments: ['personalized-employee', 'manager-jobs'], personalized: true, scope: 'project' },
|
|
33
46
|
];
|
|
34
47
|
const REGISTRY_EMPLOYEE_JOB_LAYERS = [
|
|
35
48
|
{ base: 'project', segments: ['registry', 'jobs', 'ai-employee'], personalized: false },
|
|
@@ -93,7 +106,18 @@ function readMarkdownFileNames(dirPath) {
|
|
|
93
106
|
.map((entry) => entry.name)
|
|
94
107
|
.sort((a, b) => a.localeCompare(b));
|
|
95
108
|
}
|
|
96
|
-
|
|
109
|
+
/**
|
|
110
|
+
* Issue #1002: a stub that lives under the machine-level FRAIM home is displayed
|
|
111
|
+
* as `~/.fraim/...` rather than as a long `../../..` walk out of the project,
|
|
112
|
+
* which is what path.relative would otherwise produce for a user-level layer.
|
|
113
|
+
*/
|
|
114
|
+
function stubDisplayPath(filePath, projectPath, userLevel) {
|
|
115
|
+
if (!userLevel)
|
|
116
|
+
return toPosix(path_1.default.relative(projectPath, filePath));
|
|
117
|
+
const rel = toPosix(path_1.default.relative((0, project_fraim_paths_1.getUserFraimDirPath)(), filePath));
|
|
118
|
+
return (0, project_fraim_paths_1.getUserFraimDisplayPath)(rel);
|
|
119
|
+
}
|
|
120
|
+
function parseJobStub(filePath, categoryId, categoryLabel, projectPath, personalized, scope, userLevel) {
|
|
97
121
|
const content = fs_1.default.readFileSync(filePath, 'utf8');
|
|
98
122
|
const fileName = path_1.default.basename(filePath, '.md');
|
|
99
123
|
const frontmatter = readJobFrontmatter(filePath);
|
|
@@ -107,11 +131,12 @@ function parseJobStub(filePath, categoryId, categoryLabel, projectPath, personal
|
|
|
107
131
|
categoryLabel,
|
|
108
132
|
intent,
|
|
109
133
|
outcome,
|
|
110
|
-
stubPath:
|
|
134
|
+
stubPath: stubDisplayPath(filePath, projectPath, userLevel),
|
|
111
135
|
personalized: !!personalized,
|
|
136
|
+
...(scope ? { scope } : {}),
|
|
112
137
|
};
|
|
113
138
|
}
|
|
114
|
-
function parseManagerStub(filePath, groupId, groupLabel, projectPath) {
|
|
139
|
+
function parseManagerStub(filePath, groupId, groupLabel, projectPath, scope, userLevel) {
|
|
115
140
|
const content = fs_1.default.readFileSync(filePath, 'utf8');
|
|
116
141
|
const fileName = path_1.default.basename(filePath, '.md');
|
|
117
142
|
const frontmatter = readJobFrontmatter(filePath);
|
|
@@ -123,7 +148,8 @@ function parseManagerStub(filePath, groupId, groupLabel, projectPath) {
|
|
|
123
148
|
groupId,
|
|
124
149
|
groupLabel,
|
|
125
150
|
intent,
|
|
126
|
-
stubPath:
|
|
151
|
+
stubPath: stubDisplayPath(filePath, projectPath, userLevel),
|
|
152
|
+
...(scope ? { scope } : {}),
|
|
127
153
|
};
|
|
128
154
|
}
|
|
129
155
|
function summarizeProject(projectPath) {
|
|
@@ -163,6 +189,8 @@ function summarizeProject(projectPath) {
|
|
|
163
189
|
function resolveLayerRoot(projectPath, layer) {
|
|
164
190
|
if (layer.base === 'project')
|
|
165
191
|
return path_1.default.join(projectPath, ...layer.segments);
|
|
192
|
+
if (layer.base === 'user')
|
|
193
|
+
return path_1.default.join((0, project_fraim_paths_1.getUserFraimDirPath)(), ...layer.segments);
|
|
166
194
|
return path_1.default.join((0, project_fraim_paths_1.getWorkspaceFraimDir)(projectPath), ...layer.segments);
|
|
167
195
|
}
|
|
168
196
|
function discoverLayers(projectPath, layers) {
|
|
@@ -175,6 +203,8 @@ function discoverLayers(projectPath, layers) {
|
|
|
175
203
|
categoryId: categoryName,
|
|
176
204
|
categoryDir: path_1.default.join(layerRoot, categoryName),
|
|
177
205
|
personalized: layer.personalized,
|
|
206
|
+
scope: layer.scope,
|
|
207
|
+
userLevel: layer.base === 'user',
|
|
178
208
|
});
|
|
179
209
|
}
|
|
180
210
|
}
|
|
@@ -193,7 +223,7 @@ function discoverEmployeeJobs(projectPath, options = {}) {
|
|
|
193
223
|
const categoryLabel = humanizeName(layer.categoryId);
|
|
194
224
|
for (const fileName of readMarkdownFileNames(layer.categoryDir)) {
|
|
195
225
|
const filePath = path_1.default.join(layer.categoryDir, fileName);
|
|
196
|
-
const job = parseJobStub(filePath, layer.categoryId, categoryLabel, projectPath, layer.personalized);
|
|
226
|
+
const job = parseJobStub(filePath, layer.categoryId, categoryLabel, projectPath, layer.personalized, layer.scope, layer.userLevel);
|
|
197
227
|
// Later layers override earlier layers on {category, jobId} collision —
|
|
198
228
|
// personalized-employee wins over the synced ai-employee baseline. The
|
|
199
229
|
// winning job carries its own layer's `personalized` flag (issue #566).
|
|
@@ -219,7 +249,7 @@ function discoverManagerTemplates(projectPath, options = {}) {
|
|
|
219
249
|
const groupLabel = humanizeName(layer.categoryId);
|
|
220
250
|
for (const fileName of readMarkdownFileNames(layer.categoryDir)) {
|
|
221
251
|
const filePath = path_1.default.join(layer.categoryDir, fileName);
|
|
222
|
-
const template = parseManagerStub(filePath, layer.categoryId, groupLabel, projectPath);
|
|
252
|
+
const template = parseManagerStub(filePath, layer.categoryId, groupLabel, projectPath, layer.scope, layer.userLevel);
|
|
223
253
|
templatesByKey.set(`${template.groupId}::${template.id}`, template);
|
|
224
254
|
}
|
|
225
255
|
}
|
|
@@ -17,63 +17,129 @@ exports.buildCustomEmployeePersona = buildCustomEmployeePersona;
|
|
|
17
17
|
// evaluatePersonaAccess) must never call resolveEmployee.
|
|
18
18
|
const fs_1 = __importDefault(require("fs"));
|
|
19
19
|
const path_1 = __importDefault(require("path"));
|
|
20
|
+
const project_fraim_paths_1 = require("../core/utils/project-fraim-paths");
|
|
20
21
|
const persona_hiring_1 = require("../config/persona-hiring");
|
|
21
22
|
const EMPLOYEES_DIR_REL = path_1.default.join('fraim', 'personalized-employee', 'employees');
|
|
22
23
|
function employeesDir(projectDir) {
|
|
23
24
|
return path_1.default.join(projectDir, EMPLOYEES_DIR_REL);
|
|
24
25
|
}
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
function
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
return slug;
|
|
35
|
-
}
|
|
36
|
-
function filePath(projectDir, key) {
|
|
37
|
-
const slug = safeSlug(key);
|
|
38
|
-
if (!slug)
|
|
39
|
-
return null;
|
|
40
|
-
return path_1.default.join(employeesDir(projectDir), `${slug}.json`);
|
|
26
|
+
// Issue #1002: the manager level is the default home for a custom employee, so
|
|
27
|
+
// an employee the manager created is available in every project on the machine.
|
|
28
|
+
// Two roots, matching capability precedence: the writable home wins over the
|
|
29
|
+
// synced cache, so a just-created employee appears before any publish or sync.
|
|
30
|
+
function managerEmployeesDirs() {
|
|
31
|
+
return {
|
|
32
|
+
local: path_1.default.join((0, project_fraim_paths_1.getUserFraimDirPath)(), 'personalized-employee', 'employees'),
|
|
33
|
+
cache: path_1.default.join((0, project_fraim_paths_1.getUserFraimDirPath)(), 'manager', 'employees'),
|
|
34
|
+
};
|
|
41
35
|
}
|
|
42
|
-
function
|
|
43
|
-
const dir = employeesDir(projectDir);
|
|
36
|
+
function readEmployeesFromDir(dir, scope) {
|
|
44
37
|
if (!fs_1.default.existsSync(dir))
|
|
45
38
|
return [];
|
|
46
|
-
|
|
47
|
-
|
|
39
|
+
let names;
|
|
40
|
+
try {
|
|
41
|
+
names = fs_1.default.readdirSync(dir);
|
|
42
|
+
}
|
|
43
|
+
catch {
|
|
44
|
+
return [];
|
|
45
|
+
}
|
|
46
|
+
const out = [];
|
|
47
|
+
for (const file of names) {
|
|
48
48
|
if (!file.endsWith('.json'))
|
|
49
49
|
continue;
|
|
50
50
|
try {
|
|
51
|
-
const
|
|
52
|
-
const parsed = JSON.parse(raw);
|
|
51
|
+
const parsed = JSON.parse(fs_1.default.readFileSync(path_1.default.join(dir, file), 'utf8'));
|
|
53
52
|
if (parsed && typeof parsed.key === 'string' && parsed.key.startsWith('custom:')) {
|
|
54
|
-
|
|
53
|
+
// The record's own scope is authoritative when present; otherwise the
|
|
54
|
+
// directory it was found in names its level. A record written before this
|
|
55
|
+
// change carries scope 'project' and is found in the project dir, so both
|
|
56
|
+
// agree and nothing is reinterpreted.
|
|
57
|
+
out.push({ ...parsed, scope: parsed.scope ?? scope });
|
|
55
58
|
}
|
|
56
59
|
}
|
|
57
60
|
catch {
|
|
58
61
|
// silently skip malformed files
|
|
59
62
|
}
|
|
60
63
|
}
|
|
61
|
-
return
|
|
64
|
+
return out;
|
|
65
|
+
}
|
|
66
|
+
function slugFor(key) {
|
|
67
|
+
// key is 'custom:<slug>' — file is named '<slug>.json'
|
|
68
|
+
return key.replace(/^custom:/, '');
|
|
62
69
|
}
|
|
70
|
+
function safeSlug(key) {
|
|
71
|
+
// Reject keys whose slug component contains path separators or traversal sequences.
|
|
72
|
+
const slug = slugFor(key);
|
|
73
|
+
if (!slug || /[/\\]|\.\./.test(slug))
|
|
74
|
+
return null;
|
|
75
|
+
return slug;
|
|
76
|
+
}
|
|
77
|
+
/**
|
|
78
|
+
* Read every custom employee visible from this project, across levels.
|
|
79
|
+
*
|
|
80
|
+
* Issue #1002: precedence matches capability resolution. The project copy wins
|
|
81
|
+
* on a key collision, then the manager's writable home, then the synced manager
|
|
82
|
+
* cache. Reading the manager level here is what makes the manager-level default
|
|
83
|
+
* usable: without it an employee created at the manager level would exist on
|
|
84
|
+
* disk and appear in no roster.
|
|
85
|
+
*/
|
|
86
|
+
function readCustomEmployees(projectDir) {
|
|
87
|
+
const dirs = managerEmployeesDirs();
|
|
88
|
+
const byKey = new Map();
|
|
89
|
+
// Lowest precedence first, so a later write overwrites on key collision.
|
|
90
|
+
for (const emp of readEmployeesFromDir(dirs.cache, 'manager'))
|
|
91
|
+
byKey.set(emp.key, emp);
|
|
92
|
+
for (const emp of readEmployeesFromDir(dirs.local, 'manager'))
|
|
93
|
+
byKey.set(emp.key, emp);
|
|
94
|
+
for (const emp of readEmployeesFromDir(employeesDir(projectDir), 'project'))
|
|
95
|
+
byKey.set(emp.key, emp);
|
|
96
|
+
return [...byKey.values()];
|
|
97
|
+
}
|
|
98
|
+
/**
|
|
99
|
+
* Write a custom employee at its declared level.
|
|
100
|
+
*
|
|
101
|
+
* Issue #1002 R1: the manager level is the default, so a record with no scope,
|
|
102
|
+
* or scope 'manager', is written to the manager's writable home and is available
|
|
103
|
+
* in every project. Only scope 'project' writes into the repository.
|
|
104
|
+
*/
|
|
63
105
|
function writeCustomEmployee(projectDir, employee) {
|
|
64
|
-
const
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
106
|
+
const scope = employee.scope ?? 'manager';
|
|
107
|
+
const record = { ...employee, scope };
|
|
108
|
+
const slug = safeSlug(record.key);
|
|
109
|
+
if (!slug)
|
|
110
|
+
throw new Error(`Invalid employee key: ${record.key}`);
|
|
111
|
+
const dir = scope === 'project' ? employeesDir(projectDir) : managerEmployeesDirs().local;
|
|
68
112
|
fs_1.default.mkdirSync(dir, { recursive: true });
|
|
69
|
-
fs_1.default.writeFileSync(
|
|
113
|
+
fs_1.default.writeFileSync(path_1.default.join(dir, `${slug}.json`), JSON.stringify(record, null, 2), 'utf8');
|
|
70
114
|
}
|
|
115
|
+
/**
|
|
116
|
+
* Delete a custom employee wherever it lives. Issue #1002: a manager-level
|
|
117
|
+
* employee is not in the project directory, so deleting only there would report
|
|
118
|
+
* success while leaving the record live in every other project.
|
|
119
|
+
*/
|
|
71
120
|
function deleteCustomEmployee(projectDir, key) {
|
|
72
|
-
const
|
|
73
|
-
if (!
|
|
121
|
+
const slug = safeSlug(key);
|
|
122
|
+
if (!slug)
|
|
74
123
|
return false;
|
|
75
|
-
|
|
76
|
-
|
|
124
|
+
const dirs = managerEmployeesDirs();
|
|
125
|
+
const candidates = [
|
|
126
|
+
path_1.default.join(employeesDir(projectDir), `${slug}.json`),
|
|
127
|
+
path_1.default.join(dirs.local, `${slug}.json`),
|
|
128
|
+
path_1.default.join(dirs.cache, `${slug}.json`),
|
|
129
|
+
];
|
|
130
|
+
let removed = false;
|
|
131
|
+
for (const fp of candidates) {
|
|
132
|
+
if (!fs_1.default.existsSync(fp))
|
|
133
|
+
continue;
|
|
134
|
+
try {
|
|
135
|
+
fs_1.default.unlinkSync(fp);
|
|
136
|
+
removed = true;
|
|
137
|
+
}
|
|
138
|
+
catch {
|
|
139
|
+
// keep going, and report whether anything was removed
|
|
140
|
+
}
|
|
141
|
+
}
|
|
142
|
+
return removed;
|
|
77
143
|
}
|
|
78
144
|
function slugifyDisplayName(displayName, existingKeys = []) {
|
|
79
145
|
const base = displayName
|
|
@@ -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 =
|
|
2459
|
-
|
|
2460
|
-
|
|
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 =
|
|
2471
|
-
|
|
2472
|
-
|
|
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,
|
|
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
|
-
|
|
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,
|
|
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) {
|
|
@@ -3188,8 +3153,12 @@ class AiHubServer {
|
|
|
3188
3153
|
computeBrain(projectPath, jobCount, userEmail) {
|
|
3189
3154
|
const learnings = (0, learning_context_builder_1.countPreservedLearnings)(projectPath, userEmail || '');
|
|
3190
3155
|
if (!userEmail) {
|
|
3156
|
+
// Issue #1002 R11: reverseMentoring is individual-keyed like manager and
|
|
3157
|
+
// project, so it is zeroed on the same condition. Missing it would leak a
|
|
3158
|
+
// count for a user whose identity was never resolved.
|
|
3191
3159
|
learnings.manager = 0;
|
|
3192
3160
|
learnings.project = 0;
|
|
3161
|
+
learnings.reverseMentoring = 0;
|
|
3193
3162
|
}
|
|
3194
3163
|
return {
|
|
3195
3164
|
learnings,
|