fraim-hub 2.0.211 → 2.0.213
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/hosts.js +52 -0
- package/dist/src/ai-hub/server.js +279 -11
- package/dist/src/cli/utils/user-config.js +37 -0
- package/dist/src/config/persona-capability-bundles.js +1 -1
- package/dist/src/local-mcp-server/learning-context-builder.js +146 -13
- package/package.json +2 -2
- package/public/ai-hub/index.html +96 -0
- package/public/ai-hub/review.css +57 -0
- package/public/ai-hub/script.js +642 -118
- package/public/ai-hub/styles.css +32 -3
- package/public/first-run/script.js +5 -2
package/dist/src/ai-hub/hosts.js
CHANGED
|
@@ -364,8 +364,12 @@ function extractSignalFromArgs(args) {
|
|
|
364
364
|
const discriminant = typeof args.runDiscriminant === 'string' ? args.runDiscriminant : undefined;
|
|
365
365
|
const jobName = typeof args.jobName === 'string' ? args.jobName : undefined;
|
|
366
366
|
const jobId = typeof args.jobId === 'string' ? args.jobId : undefined;
|
|
367
|
+
const issueNumber = typeof args.issueNumber === 'string' ? args.issueNumber
|
|
368
|
+
: typeof args.issueNumber === 'number' ? String(args.issueNumber)
|
|
369
|
+
: undefined;
|
|
367
370
|
const reviewHandoff = extractReviewHandoffFromArgs(args);
|
|
368
371
|
const delegationLedger = extractDelegationLedgerFromArgs(args);
|
|
372
|
+
const nextJobRecommendations = extractNextJobRecommendationsFromArgs(args);
|
|
369
373
|
return {
|
|
370
374
|
phaseId,
|
|
371
375
|
phaseStatus,
|
|
@@ -373,8 +377,10 @@ function extractSignalFromArgs(args) {
|
|
|
373
377
|
discriminant,
|
|
374
378
|
jobName,
|
|
375
379
|
jobId,
|
|
380
|
+
...(issueNumber ? { issueNumber } : {}),
|
|
376
381
|
...(reviewHandoff ? { reviewHandoff } : {}),
|
|
377
382
|
...(delegationLedger ? { delegationLedger } : {}),
|
|
383
|
+
...(nextJobRecommendations ? { nextJobRecommendations } : {}),
|
|
378
384
|
};
|
|
379
385
|
}
|
|
380
386
|
function extractDelegationLedgerFromArgs(args) {
|
|
@@ -625,6 +631,52 @@ function readReviewHandoffCandidate(value) {
|
|
|
625
631
|
...(reviewActions.length > 0 ? { reviewActions } : {}),
|
|
626
632
|
};
|
|
627
633
|
}
|
|
634
|
+
// Issue #848: next-job recommendations follow the same evidence-channel pattern as
|
|
635
|
+
// reviewHandoff — accept either the top-level arg or evidence.nextJobRecommendations.
|
|
636
|
+
const MAX_NEXT_JOB_RECOMMENDATIONS = 3;
|
|
637
|
+
function extractNextJobRecommendationsFromArgs(args) {
|
|
638
|
+
const direct = readNextJobRecommendationsCandidate(args.nextJobRecommendations);
|
|
639
|
+
if (direct)
|
|
640
|
+
return direct;
|
|
641
|
+
const evidence = args.evidence;
|
|
642
|
+
if (evidence && typeof evidence === 'object' && !Array.isArray(evidence)) {
|
|
643
|
+
return readNextJobRecommendationsCandidate(evidence.nextJobRecommendations);
|
|
644
|
+
}
|
|
645
|
+
return null;
|
|
646
|
+
}
|
|
647
|
+
function readNextJobRecommendationsCandidate(value) {
|
|
648
|
+
let candidate = value;
|
|
649
|
+
if (typeof candidate === 'string') {
|
|
650
|
+
try {
|
|
651
|
+
candidate = JSON.parse(candidate);
|
|
652
|
+
}
|
|
653
|
+
catch {
|
|
654
|
+
return null;
|
|
655
|
+
}
|
|
656
|
+
}
|
|
657
|
+
if (!Array.isArray(candidate))
|
|
658
|
+
return null;
|
|
659
|
+
const recs = candidate
|
|
660
|
+
.map((entry) => readNextJobRecommendationCandidate(entry))
|
|
661
|
+
.filter((entry) => entry !== null)
|
|
662
|
+
.slice(0, MAX_NEXT_JOB_RECOMMENDATIONS);
|
|
663
|
+
return recs.length > 0 ? recs : null;
|
|
664
|
+
}
|
|
665
|
+
function readNextJobRecommendationCandidate(value) {
|
|
666
|
+
if (!value || typeof value !== 'object' || Array.isArray(value))
|
|
667
|
+
return null;
|
|
668
|
+
const obj = value;
|
|
669
|
+
const jobId = typeof obj.jobId === 'string' ? obj.jobId.trim() : '';
|
|
670
|
+
const label = typeof obj.label === 'string' ? obj.label.trim() : '';
|
|
671
|
+
if (!jobId || !label)
|
|
672
|
+
return null;
|
|
673
|
+
return {
|
|
674
|
+
jobId,
|
|
675
|
+
label: label.slice(0, 60),
|
|
676
|
+
...(typeof obj.reason === 'string' && obj.reason.trim() ? { reason: obj.reason.trim().slice(0, 200) } : {}),
|
|
677
|
+
...(typeof obj.contextSummary === 'string' && obj.contextSummary.trim() ? { contextSummary: obj.contextSummary.trim().slice(0, 300) } : {}),
|
|
678
|
+
};
|
|
679
|
+
}
|
|
628
680
|
const EMPLOYEE_LABELS = {
|
|
629
681
|
codex: 'Codex',
|
|
630
682
|
claude: 'Claude Code',
|
|
@@ -210,15 +210,19 @@ function directoryExists(projectPath) {
|
|
|
210
210
|
return false;
|
|
211
211
|
}
|
|
212
212
|
}
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
213
|
+
// Issue #866 R2: the directory the Hub was launched in is NOT a project. The
|
|
214
|
+
// active project is a previously-recorded project that still exists on disk;
|
|
215
|
+
// when none is recorded the Hub has no active project and opens on an empty
|
|
216
|
+
// project list. This deliberately never falls back to process.cwd() — the launch
|
|
217
|
+
// directory must not masquerade as a project.
|
|
218
|
+
function resolveInitialHubProjectPath(preferencesStore) {
|
|
219
|
+
const recorded = preferencesStore.load('').projectPath;
|
|
216
220
|
if (typeof recorded === 'string' && recorded.trim().length > 0) {
|
|
217
221
|
const resolved = path_1.default.resolve(recorded);
|
|
218
222
|
if (directoryExists(resolved))
|
|
219
223
|
return resolved;
|
|
220
224
|
}
|
|
221
|
-
return
|
|
225
|
+
return '';
|
|
222
226
|
}
|
|
223
227
|
class AiHubRunRegistry {
|
|
224
228
|
constructor() {
|
|
@@ -296,9 +300,19 @@ class AiHubRunRegistry {
|
|
|
296
300
|
}
|
|
297
301
|
// ─── Issue #578: Deployment + Host stores ─────────────────────────────────────
|
|
298
302
|
const VALID_EMPLOYEE_IDS = ['codex', 'claude', 'gemini', 'copilot'];
|
|
303
|
+
const SCHEDULED_FIRE_LEASE_TTL_MS = 2 * 60 * 1000;
|
|
304
|
+
const SCHEDULED_FIRE_LEASE_PRUNE_MS = 7 * 24 * 60 * 60 * 1000;
|
|
299
305
|
function startSessionSeedForHost(hostId, runId) {
|
|
300
306
|
return hostId === 'gemini' ? undefined : runId;
|
|
301
307
|
}
|
|
308
|
+
function safeScheduledLeasePart(value) {
|
|
309
|
+
return value.replace(/[^a-zA-Z0-9._-]/g, '_');
|
|
310
|
+
}
|
|
311
|
+
function scheduledFireBucketMs(cronExpr, nowMs = Date.now()) {
|
|
312
|
+
const fieldCount = cronExpr.trim().split(/\s+/).filter(Boolean).length;
|
|
313
|
+
const bucketMs = fieldCount === 6 ? 1000 : 60 * 1000;
|
|
314
|
+
return Math.floor(nowMs / bucketMs) * bucketMs;
|
|
315
|
+
}
|
|
302
316
|
class DeploymentStore {
|
|
303
317
|
constructor(filePath) {
|
|
304
318
|
this.filePath = filePath ?? path_1.default.join(getUserHubDir(), 'hub-deployments.json');
|
|
@@ -341,6 +355,76 @@ class DeploymentStore {
|
|
|
341
355
|
this.save(next);
|
|
342
356
|
return true;
|
|
343
357
|
}
|
|
358
|
+
claimScheduledFire(deploymentId, fireTimeMs, nowMs = Date.now()) {
|
|
359
|
+
const leaseDir = path_1.default.join(path_1.default.dirname(this.filePath), 'hub-scheduler-leases');
|
|
360
|
+
fs_1.default.mkdirSync(leaseDir, { recursive: true });
|
|
361
|
+
this.pruneScheduledFireLeases(leaseDir, nowMs);
|
|
362
|
+
const leasePath = path_1.default.join(leaseDir, `${safeScheduledLeasePart(deploymentId)}-${fireTimeMs}.json`);
|
|
363
|
+
for (let attempt = 0; attempt < 2; attempt += 1) {
|
|
364
|
+
try {
|
|
365
|
+
const fd = fs_1.default.openSync(leasePath, 'wx');
|
|
366
|
+
try {
|
|
367
|
+
fs_1.default.writeFileSync(fd, JSON.stringify({
|
|
368
|
+
deploymentId,
|
|
369
|
+
fireTimeMs,
|
|
370
|
+
claimedAt: new Date(nowMs).toISOString(),
|
|
371
|
+
pid: process.pid,
|
|
372
|
+
}, null, 2));
|
|
373
|
+
}
|
|
374
|
+
finally {
|
|
375
|
+
fs_1.default.closeSync(fd);
|
|
376
|
+
}
|
|
377
|
+
return true;
|
|
378
|
+
}
|
|
379
|
+
catch (err) {
|
|
380
|
+
const code = err.code;
|
|
381
|
+
if (code !== 'EEXIST') {
|
|
382
|
+
console.warn(`[ai-hub] scheduled deployment lease claim failed for ${deploymentId}:`, err);
|
|
383
|
+
return false;
|
|
384
|
+
}
|
|
385
|
+
if (!this.isStaleScheduledFireLease(leasePath, nowMs))
|
|
386
|
+
return false;
|
|
387
|
+
try {
|
|
388
|
+
fs_1.default.unlinkSync(leasePath);
|
|
389
|
+
}
|
|
390
|
+
catch (unlinkErr) {
|
|
391
|
+
if (unlinkErr.code !== 'ENOENT')
|
|
392
|
+
return false;
|
|
393
|
+
}
|
|
394
|
+
}
|
|
395
|
+
}
|
|
396
|
+
return false;
|
|
397
|
+
}
|
|
398
|
+
isStaleScheduledFireLease(leasePath, nowMs) {
|
|
399
|
+
try {
|
|
400
|
+
const stat = fs_1.default.statSync(leasePath);
|
|
401
|
+
return nowMs - stat.mtimeMs > SCHEDULED_FIRE_LEASE_TTL_MS;
|
|
402
|
+
}
|
|
403
|
+
catch {
|
|
404
|
+
return false;
|
|
405
|
+
}
|
|
406
|
+
}
|
|
407
|
+
pruneScheduledFireLeases(leaseDir, nowMs) {
|
|
408
|
+
try {
|
|
409
|
+
for (const entry of fs_1.default.readdirSync(leaseDir, { withFileTypes: true })) {
|
|
410
|
+
if (!entry.isFile() || !entry.name.endsWith('.json'))
|
|
411
|
+
continue;
|
|
412
|
+
const leasePath = path_1.default.join(leaseDir, entry.name);
|
|
413
|
+
try {
|
|
414
|
+
const stat = fs_1.default.statSync(leasePath);
|
|
415
|
+
if (nowMs - stat.mtimeMs > SCHEDULED_FIRE_LEASE_PRUNE_MS) {
|
|
416
|
+
fs_1.default.unlinkSync(leasePath);
|
|
417
|
+
}
|
|
418
|
+
}
|
|
419
|
+
catch {
|
|
420
|
+
// Best-effort cleanup; failed pruning must not block scheduler startup.
|
|
421
|
+
}
|
|
422
|
+
}
|
|
423
|
+
}
|
|
424
|
+
catch {
|
|
425
|
+
// Best-effort cleanup; lease creation below remains the correctness gate.
|
|
426
|
+
}
|
|
427
|
+
}
|
|
344
428
|
}
|
|
345
429
|
exports.DeploymentStore = DeploymentStore;
|
|
346
430
|
class HostConfigStore {
|
|
@@ -506,6 +590,34 @@ function normalizeReviewHandoff(raw) {
|
|
|
506
590
|
}
|
|
507
591
|
return null;
|
|
508
592
|
}
|
|
593
|
+
// Issue #848: defensively re-validate next-job recommendations on the run.
|
|
594
|
+
// hosts.parseSeekMentoringSignal already normalizes the evidence-channel payload,
|
|
595
|
+
// but the server owns the persisted contract: require jobId + label, clamp text
|
|
596
|
+
// lengths, and cap at 3.
|
|
597
|
+
const MAX_NEXT_JOB_RECOMMENDATIONS = 3;
|
|
598
|
+
function normalizeNextJobRecommendations(raw) {
|
|
599
|
+
if (!Array.isArray(raw))
|
|
600
|
+
return null;
|
|
601
|
+
const recs = [];
|
|
602
|
+
for (const entry of raw) {
|
|
603
|
+
if (!entry || typeof entry !== 'object' || Array.isArray(entry))
|
|
604
|
+
continue;
|
|
605
|
+
const obj = entry;
|
|
606
|
+
const jobId = typeof obj.jobId === 'string' ? obj.jobId.trim() : '';
|
|
607
|
+
const label = typeof obj.label === 'string' ? obj.label.trim() : '';
|
|
608
|
+
if (!jobId || !label)
|
|
609
|
+
continue;
|
|
610
|
+
recs.push({
|
|
611
|
+
jobId,
|
|
612
|
+
label: label.slice(0, 60),
|
|
613
|
+
...(typeof obj.reason === 'string' && obj.reason.trim() ? { reason: obj.reason.trim().slice(0, 200) } : {}),
|
|
614
|
+
...(typeof obj.contextSummary === 'string' && obj.contextSummary.trim() ? { contextSummary: obj.contextSummary.trim().slice(0, 300) } : {}),
|
|
615
|
+
});
|
|
616
|
+
if (recs.length >= MAX_NEXT_JOB_RECOMMENDATIONS)
|
|
617
|
+
break;
|
|
618
|
+
}
|
|
619
|
+
return recs.length > 0 ? recs : null;
|
|
620
|
+
}
|
|
509
621
|
const DELEGATION_TASK_STATUSES = new Set([
|
|
510
622
|
'planned',
|
|
511
623
|
'running',
|
|
@@ -823,6 +935,15 @@ function applySeekMentoringSignal(run, signal) {
|
|
|
823
935
|
const callJobName = signal.jobName;
|
|
824
936
|
if (callJobName && targetJobId && callJobName !== targetJobId)
|
|
825
937
|
return;
|
|
938
|
+
// Issue #848: capture the issue number the call carries so a launched follow-on
|
|
939
|
+
// run can reference the originating issue, and persist any next-job recommendations.
|
|
940
|
+
if (signal.issueNumber)
|
|
941
|
+
run.issueNumber = signal.issueNumber;
|
|
942
|
+
if (signal.nextJobRecommendations) {
|
|
943
|
+
const normalized = normalizeNextJobRecommendations(signal.nextJobRecommendations);
|
|
944
|
+
if (normalized)
|
|
945
|
+
run.nextJobRecommendations = normalized;
|
|
946
|
+
}
|
|
826
947
|
if (signal.reviewHandoff) {
|
|
827
948
|
const normalizedReviewHandoff = normalizeReviewHandoff(signal.reviewHandoff);
|
|
828
949
|
run.reviewHandoff = normalizedReviewHandoff || signal.reviewHandoff;
|
|
@@ -1233,7 +1354,7 @@ class AiHubServer {
|
|
|
1233
1354
|
this.preferencesStore = options.preferencesStore || new preferences_1.AiHubPreferencesStore();
|
|
1234
1355
|
this.projectPath = options.projectPath
|
|
1235
1356
|
? path_1.default.resolve(options.projectPath)
|
|
1236
|
-
: resolveInitialHubProjectPath(this.preferencesStore
|
|
1357
|
+
: resolveInitialHubProjectPath(this.preferencesStore);
|
|
1237
1358
|
this.conversationStore = options.conversationStore || new conversation_store_1.AiHubConversationStore();
|
|
1238
1359
|
this.configuredAgentStore = options.configuredAgentStore || new configured_agents_1.AiHubConfiguredAgentStore();
|
|
1239
1360
|
this.wordTaskpaneDir = options.wordTaskpaneDir ?? resolveWordTaskpaneDir(this.projectPath);
|
|
@@ -1376,7 +1497,10 @@ class AiHubServer {
|
|
|
1376
1497
|
return this.projectPath;
|
|
1377
1498
|
}
|
|
1378
1499
|
defaultProjectPath() {
|
|
1379
|
-
|
|
1500
|
+
// Prefer the recorded project (reflects a project switch), then the active
|
|
1501
|
+
// project resolved at construction, then '' when no project has been chosen
|
|
1502
|
+
// (#866 R2). Never falls back to cwd.
|
|
1503
|
+
return resolveInitialHubProjectPath(this.preferencesStore) || this.projectPath;
|
|
1380
1504
|
}
|
|
1381
1505
|
async start(port) {
|
|
1382
1506
|
this.httpPort = port;
|
|
@@ -1454,7 +1578,10 @@ class AiHubServer {
|
|
|
1454
1578
|
}
|
|
1455
1579
|
getHttpsPort() { return this.httpsPort; }
|
|
1456
1580
|
knownProjects(projectPath, extras = []) {
|
|
1457
|
-
|
|
1581
|
+
// #866 R2: guard against path.resolve('') === cwd. When there is no active
|
|
1582
|
+
// project, keep the path empty so no invocation-directory project is injected.
|
|
1583
|
+
const activeProjectPath = projectPath || this.projectPath;
|
|
1584
|
+
const normalizedProjectPath = activeProjectPath ? path_1.default.resolve(activeProjectPath) : '';
|
|
1458
1585
|
const preferences = this.preferencesStore.load(normalizedProjectPath);
|
|
1459
1586
|
const conversationProjects = this.conversationStore.listProjectPaths().map((folderPath) => ({ folderPath }));
|
|
1460
1587
|
return (0, preferences_1.normalizeAiHubProjectList)([
|
|
@@ -1464,7 +1591,9 @@ class AiHubServer {
|
|
|
1464
1591
|
], normalizedProjectPath, { removedProjectPaths: preferences.removedProjectPaths || [] });
|
|
1465
1592
|
}
|
|
1466
1593
|
async bootstrapResponse(projectPath) {
|
|
1467
|
-
|
|
1594
|
+
// #866 R2: guard against path.resolve('') === cwd; '' means "no active project".
|
|
1595
|
+
const activeProjectPath = projectPath || this.projectPath;
|
|
1596
|
+
const normalizedProjectPath = activeProjectPath ? path_1.default.resolve(activeProjectPath) : '';
|
|
1468
1597
|
const employees = this.hostRuntime.detectEmployees();
|
|
1469
1598
|
const configuredAgents = this.configuredAgentStore
|
|
1470
1599
|
.listWithDefaults(employees)
|
|
@@ -1617,6 +1746,10 @@ class AiHubServer {
|
|
|
1617
1746
|
reviewHandoff: run.reviewHandoff || null,
|
|
1618
1747
|
delegation: run.delegation || null,
|
|
1619
1748
|
delegationTaskId: run.delegationTaskId || null,
|
|
1749
|
+
// Issue #848: fold next-job recommendations + issue pointer onto the record
|
|
1750
|
+
// so completed conversations render "What's next?" chips after reload.
|
|
1751
|
+
nextJobRecommendations: run.nextJobRecommendations || null,
|
|
1752
|
+
issueNumber: run.issueNumber ?? null,
|
|
1620
1753
|
managedByRunId: run.managedByRunId || null,
|
|
1621
1754
|
managedByPersonaKey: run.managedByPersonaKey || null,
|
|
1622
1755
|
humanCoachingDisabled: run.humanCoachingDisabled || false,
|
|
@@ -2494,11 +2627,17 @@ class AiHubServer {
|
|
|
2494
2627
|
return res.status(400).json({ error: 'projectPath required for project conversations' });
|
|
2495
2628
|
}
|
|
2496
2629
|
const projectPath = scope ? (0, conversation_store_1.conversationScopeKey)(scope, '') : ensureDirectoryPath(body.projectPath);
|
|
2497
|
-
|
|
2630
|
+
const patchFields = Object.keys(body).filter((key) => key !== 'projectPath' && key !== 'scope' && key !== 'activeId');
|
|
2631
|
+
if (patchFields.length > 0) {
|
|
2632
|
+
this.conversationStore.patchConversation(projectPath, req.params.conversationId, body);
|
|
2633
|
+
}
|
|
2498
2634
|
if (body.activeId !== undefined) {
|
|
2499
2635
|
// Body-safe: set activeId via the index, never by rewriting conversation bodies (#820).
|
|
2500
2636
|
this.conversationStore.setActiveId(projectPath, body.activeId);
|
|
2501
2637
|
}
|
|
2638
|
+
if (patchFields.length === 0 && body.activeId !== undefined) {
|
|
2639
|
+
return res.json({ projectPath, scope: scope ?? 'project', activeId: body.activeId, source: 'disk' });
|
|
2640
|
+
}
|
|
2502
2641
|
const loaded = this.conversationStore.loadProject(projectPath);
|
|
2503
2642
|
return res.json({ projectPath, ...loaded, source: 'disk' });
|
|
2504
2643
|
}
|
|
@@ -2976,7 +3115,7 @@ class AiHubServer {
|
|
|
2976
3115
|
sourceTrigger: req.body.sourceTrigger ?? 'manager',
|
|
2977
3116
|
};
|
|
2978
3117
|
this.runRegistry.create(run, {});
|
|
2979
|
-
this.
|
|
3118
|
+
this.scheduleRunConversationPersistence(run, run.conversationId || run.id);
|
|
2980
3119
|
// Issue #442: create the Direct (B) run before spawning either process
|
|
2981
3120
|
// so we can cross-link both runs via compareRunId before any events arrive.
|
|
2982
3121
|
// directMsg is the plain user instructions — no FRAIM invocation prefix.
|
|
@@ -3012,6 +3151,7 @@ class AiHubServer {
|
|
|
3012
3151
|
current.compareRunId = directRun.id;
|
|
3013
3152
|
});
|
|
3014
3153
|
this.runRegistry.create(directRun, {});
|
|
3154
|
+
this.scheduleRunConversationPersistence(directRun, directRun.id);
|
|
3015
3155
|
}
|
|
3016
3156
|
const child = this.hostRuntime.startRun(hostId, projectPath, message, {
|
|
3017
3157
|
onEvent: (event, channel) => {
|
|
@@ -3314,7 +3454,7 @@ class AiHubServer {
|
|
|
3314
3454
|
if (reviewApprovalSystemEventText)
|
|
3315
3455
|
run.events.push((0, hosts_1.createHubEvent)('system', reviewApprovalSystemEventText));
|
|
3316
3456
|
this.runRegistry.create(run, {});
|
|
3317
|
-
this.
|
|
3457
|
+
this.scheduleRunConversationPersistence(run, run.conversationId || run.id);
|
|
3318
3458
|
const child = this.hostRuntime.continueRun(hostId, projectPath, sessionId, message, {
|
|
3319
3459
|
onEvent: (event, channel) => {
|
|
3320
3460
|
this.runRegistry.update(run.id, (current) => {
|
|
@@ -3451,6 +3591,129 @@ class AiHubServer {
|
|
|
3451
3591
|
}
|
|
3452
3592
|
return res.json(this.enrichRunForResponse(run));
|
|
3453
3593
|
});
|
|
3594
|
+
// ─── Issue #834: Learning sharing map + per-layer backend config ─────────
|
|
3595
|
+
// Strips embedded credentials from a git URL before including it in any response
|
|
3596
|
+
// (e.g. https://token@github.com/org/repo → https://github.com/org/repo).
|
|
3597
|
+
// Used by the sharing-map and sharing-backend GET handlers below.
|
|
3598
|
+
function redactGitUrl(url) {
|
|
3599
|
+
if (!url)
|
|
3600
|
+
return null;
|
|
3601
|
+
try {
|
|
3602
|
+
const parsed = new URL(url);
|
|
3603
|
+
parsed.username = '';
|
|
3604
|
+
parsed.password = '';
|
|
3605
|
+
return parsed.toString();
|
|
3606
|
+
}
|
|
3607
|
+
catch {
|
|
3608
|
+
return null;
|
|
3609
|
+
}
|
|
3610
|
+
}
|
|
3611
|
+
// GET /api/ai-hub/sharing-map — metadata-only projection of all four learning
|
|
3612
|
+
// scopes: org, manager, project, raw. Returns backend posture and recommendation
|
|
3613
|
+
// per scope. No artifact bodies, no credentials, no raw file paths.
|
|
3614
|
+
this.app.get('/api/ai-hub/sharing-map', (req, res) => {
|
|
3615
|
+
const projectPath = typeof req.query.projectPath === 'string' && req.query.projectPath.length > 0
|
|
3616
|
+
? path_1.default.resolve(req.query.projectPath)
|
|
3617
|
+
: this.defaultProjectPath();
|
|
3618
|
+
const orgBackend = (0, user_config_1.getLayerBackend)('org');
|
|
3619
|
+
const managerBackend = (0, user_config_1.getLayerBackend)('manager');
|
|
3620
|
+
// Project backend is read from fraim/config.json (team-shared, repo-local).
|
|
3621
|
+
let projectBackend = 'single-machine';
|
|
3622
|
+
try {
|
|
3623
|
+
const projCfg = path_1.default.join(projectPath, 'fraim', 'config.json');
|
|
3624
|
+
if (fs_1.default.existsSync(projCfg)) {
|
|
3625
|
+
const parsed = JSON.parse(fs_1.default.readFileSync(projCfg, 'utf8'));
|
|
3626
|
+
if (parsed?.projectStorage?.backend)
|
|
3627
|
+
projectBackend = parsed.projectStorage.backend;
|
|
3628
|
+
}
|
|
3629
|
+
}
|
|
3630
|
+
catch { /* serve default */ }
|
|
3631
|
+
const teamCtx = (0, learning_context_builder_1.resolveTeamContextFiles)(projectPath);
|
|
3632
|
+
const map = [
|
|
3633
|
+
{
|
|
3634
|
+
scope: 'org',
|
|
3635
|
+
label: 'Company',
|
|
3636
|
+
backend: orgBackend?.backend ?? 'single-machine',
|
|
3637
|
+
configuredHome: orgBackend ? (orgBackend.backend === 'git' ? redactGitUrl(orgBackend.gitUrl) : orgBackend.backend === 'local-folder' ? orgBackend.localPath ?? null : orgBackend.backend) : null,
|
|
3638
|
+
recommendation: 'git',
|
|
3639
|
+
artifactsPresent: !!(teamCtx.orgContext?.present || teamCtx.orgRules?.present),
|
|
3640
|
+
nextJob: 'organization-onboarding',
|
|
3641
|
+
},
|
|
3642
|
+
{
|
|
3643
|
+
scope: 'manager',
|
|
3644
|
+
label: 'Manager',
|
|
3645
|
+
backend: managerBackend?.backend ?? 'single-machine',
|
|
3646
|
+
configuredHome: managerBackend ? (managerBackend.backend === 'git' ? redactGitUrl(managerBackend.gitUrl) : managerBackend.backend === 'local-folder' ? managerBackend.localPath ?? null : managerBackend.backend) : null,
|
|
3647
|
+
recommendation: 'single-machine',
|
|
3648
|
+
personalDataWarning: true,
|
|
3649
|
+
artifactsPresent: !!(teamCtx.managerContext?.present || teamCtx.managerRules?.present),
|
|
3650
|
+
nextJob: 'manager-agreements',
|
|
3651
|
+
},
|
|
3652
|
+
{
|
|
3653
|
+
scope: 'project',
|
|
3654
|
+
label: 'Project',
|
|
3655
|
+
backend: projectBackend,
|
|
3656
|
+
configuredHome: null,
|
|
3657
|
+
recommendation: 'git',
|
|
3658
|
+
artifactsPresent: !!(teamCtx.projectContext?.present || teamCtx.projectBrief?.present),
|
|
3659
|
+
nextJob: 'project-onboarding',
|
|
3660
|
+
},
|
|
3661
|
+
{
|
|
3662
|
+
scope: 'raw',
|
|
3663
|
+
label: 'Source signals',
|
|
3664
|
+
backend: 'single-machine',
|
|
3665
|
+
configuredHome: null,
|
|
3666
|
+
recommendation: 'single-machine',
|
|
3667
|
+
locked: true,
|
|
3668
|
+
nextJob: 'sleep-on-learnings',
|
|
3669
|
+
},
|
|
3670
|
+
];
|
|
3671
|
+
return res.json(map);
|
|
3672
|
+
});
|
|
3673
|
+
// GET /api/ai-hub/sharing-backend?layer=<org|manager>&projectPath=
|
|
3674
|
+
// Returns the configured backend for the named layer.
|
|
3675
|
+
this.app.get('/api/ai-hub/sharing-backend', (req, res) => {
|
|
3676
|
+
const layer = req.query.layer;
|
|
3677
|
+
if (layer !== 'org' && layer !== 'manager') {
|
|
3678
|
+
return res.status(400).json({ error: 'layer must be org or manager' });
|
|
3679
|
+
}
|
|
3680
|
+
const cfg = (0, user_config_1.getLayerBackend)(layer);
|
|
3681
|
+
return res.json({
|
|
3682
|
+
layer,
|
|
3683
|
+
backend: cfg?.backend ?? 'single-machine',
|
|
3684
|
+
gitUrl: cfg?.backend === 'git' ? redactGitUrl(cfg.gitUrl) : undefined,
|
|
3685
|
+
localPath: cfg?.backend === 'local-folder' ? cfg.localPath : undefined,
|
|
3686
|
+
});
|
|
3687
|
+
});
|
|
3688
|
+
// POST /api/ai-hub/sharing-backend { layer, backend, gitUrl?, override? }
|
|
3689
|
+
// Writes the backend selection for the named layer.
|
|
3690
|
+
// Guardrail: manager + git blocked without override:true (GDPR, issue #834).
|
|
3691
|
+
this.app.post('/api/ai-hub/sharing-backend', (req, res) => {
|
|
3692
|
+
const body = (req.body ?? {});
|
|
3693
|
+
const layer = body.layer;
|
|
3694
|
+
const backend = body.backend;
|
|
3695
|
+
if (layer !== 'org' && layer !== 'manager') {
|
|
3696
|
+
return res.status(400).json({ error: 'layer must be org or manager' });
|
|
3697
|
+
}
|
|
3698
|
+
if (backend !== 'git' && backend !== 'fraim-cloud' && backend !== 'local-folder' && backend !== 'single-machine') {
|
|
3699
|
+
return res.status(400).json({ error: 'backend must be git, fraim-cloud, local-folder, or single-machine' });
|
|
3700
|
+
}
|
|
3701
|
+
// GDPR guardrail: personal-bearing layer + git blocked without explicit override.
|
|
3702
|
+
const personalLayers = ['manager'];
|
|
3703
|
+
if (personalLayers.includes(layer) && backend === 'git' && !body.override) {
|
|
3704
|
+
return res.status(409).json({
|
|
3705
|
+
error: 'guardrailBlocked',
|
|
3706
|
+
message: 'Manager learnings may contain personal data. Git history makes GDPR erasure impossible. Set override:true to proceed.',
|
|
3707
|
+
});
|
|
3708
|
+
}
|
|
3709
|
+
const gitUrl = backend === 'git' && typeof body.gitUrl === 'string' ? body.gitUrl.trim() : undefined;
|
|
3710
|
+
const localPath = backend === 'local-folder' && typeof body.localPath === 'string' ? body.localPath.trim() : undefined;
|
|
3711
|
+
if (backend === 'local-folder' && !localPath) {
|
|
3712
|
+
return res.status(400).json({ error: 'localPath is required for the local-folder backend' });
|
|
3713
|
+
}
|
|
3714
|
+
(0, user_config_1.setLayerBackend)(layer, { backend: backend, gitUrl, localPath });
|
|
3715
|
+
return res.json({ layer, backend, gitUrl: gitUrl ?? null, localPath: localPath ?? null });
|
|
3716
|
+
});
|
|
3454
3717
|
// ─── Issue #578: Scheduled + Reactive Employees ───────────────────────────
|
|
3455
3718
|
// POST /api/ai-hub/schedules — create a recurring scheduled deployment.
|
|
3456
3719
|
this.app.post('/api/ai-hub/schedules', (req, res) => {
|
|
@@ -3821,6 +4084,11 @@ class AiHubServer {
|
|
|
3821
4084
|
}
|
|
3822
4085
|
const task = cron.schedule(deployment.cronExpr, async () => {
|
|
3823
4086
|
try {
|
|
4087
|
+
const fireTimeMs = scheduledFireBucketMs(deployment.cronExpr || '');
|
|
4088
|
+
if (!this.deploymentStore.claimScheduledFire(deployment.id, fireTimeMs)) {
|
|
4089
|
+
console.log(`[ai-hub] scheduled deployment ${deployment.id} skipped - scheduled fire already claimed`);
|
|
4090
|
+
return;
|
|
4091
|
+
}
|
|
3824
4092
|
await this.fireDeploymentRun(deployment);
|
|
3825
4093
|
}
|
|
3826
4094
|
catch (err) {
|
|
@@ -3,6 +3,8 @@ var __importDefault = (this && this.__importDefault) || function (mod) {
|
|
|
3
3
|
return (mod && mod.__esModule) ? mod : { "default": mod };
|
|
4
4
|
};
|
|
5
5
|
Object.defineProperty(exports, "__esModule", { value: true });
|
|
6
|
+
exports.getLayerBackend = getLayerBackend;
|
|
7
|
+
exports.setLayerBackend = setLayerBackend;
|
|
6
8
|
exports.readUserFraimConfig = readUserFraimConfig;
|
|
7
9
|
exports.writeUserFraimConfig = writeUserFraimConfig;
|
|
8
10
|
exports.getInstalledIdes = getInstalledIdes;
|
|
@@ -19,6 +21,23 @@ exports.getManagerStorageConfig = getManagerStorageConfig;
|
|
|
19
21
|
const fs_1 = __importDefault(require("fs"));
|
|
20
22
|
const path_1 = __importDefault(require("path"));
|
|
21
23
|
const project_fraim_paths_1 = require("../../core/utils/project-fraim-paths");
|
|
24
|
+
/** Resolve the backend config for a named layer, or null when unconfigured (issue #834). */
|
|
25
|
+
function getLayerBackend(layer) {
|
|
26
|
+
if (layer === 'org')
|
|
27
|
+
return getOrganizationConfig();
|
|
28
|
+
if (layer === 'manager')
|
|
29
|
+
return getManagerStorageConfig();
|
|
30
|
+
return null;
|
|
31
|
+
}
|
|
32
|
+
/** Write the backend config for a named layer (issue #834). */
|
|
33
|
+
function setLayerBackend(layer, config) {
|
|
34
|
+
if (layer === 'org') {
|
|
35
|
+
writeUserFraimConfig({ organization: config });
|
|
36
|
+
}
|
|
37
|
+
else if (layer === 'manager') {
|
|
38
|
+
writeUserFraimConfig({ managerStorage: config });
|
|
39
|
+
}
|
|
40
|
+
}
|
|
22
41
|
function getUserConfigPath() {
|
|
23
42
|
return path_1.default.join((0, project_fraim_paths_1.getUserFraimDirPath)(), 'config.json');
|
|
24
43
|
}
|
|
@@ -77,6 +96,15 @@ function getOrganizationConfig() {
|
|
|
77
96
|
if (raw.backend === 'fraim-cloud') {
|
|
78
97
|
return { backend: 'fraim-cloud', id: raw.id };
|
|
79
98
|
}
|
|
99
|
+
if (raw.backend === 'local-folder') {
|
|
100
|
+
const localPath = typeof raw.localPath === 'string' ? raw.localPath.trim() : '';
|
|
101
|
+
if (!localPath)
|
|
102
|
+
return null;
|
|
103
|
+
return { backend: 'local-folder', localPath };
|
|
104
|
+
}
|
|
105
|
+
if (raw.backend === 'single-machine') {
|
|
106
|
+
return { backend: 'single-machine' };
|
|
107
|
+
}
|
|
80
108
|
return null;
|
|
81
109
|
}
|
|
82
110
|
/**
|
|
@@ -96,5 +124,14 @@ function getManagerStorageConfig() {
|
|
|
96
124
|
if (raw.backend === 'fraim-cloud') {
|
|
97
125
|
return { backend: 'fraim-cloud', id: raw.id };
|
|
98
126
|
}
|
|
127
|
+
if (raw.backend === 'local-folder') {
|
|
128
|
+
const localPath = typeof raw.localPath === 'string' ? raw.localPath.trim() : '';
|
|
129
|
+
if (!localPath)
|
|
130
|
+
return null;
|
|
131
|
+
return { backend: 'local-folder', localPath };
|
|
132
|
+
}
|
|
133
|
+
if (raw.backend === 'single-machine') {
|
|
134
|
+
return { backend: 'single-machine' };
|
|
135
|
+
}
|
|
99
136
|
return null;
|
|
100
137
|
}
|
|
@@ -130,7 +130,7 @@ exports.PERSONA_CAPABILITY_BUNDLES = {
|
|
|
130
130
|
personaKey: 'ricardo',
|
|
131
131
|
bundleId: 'persona-ricardo-core',
|
|
132
132
|
catalogMetadata: buildCatalogMetadata('ricardo', ['role-intake-and-scorecard', 'job-posting-and-sourcing', 'candidate-pipeline-management']),
|
|
133
|
-
protectedJobs: ['role-intake-and-scorecard', 'job-posting-and-sourcing', 'candidate-pipeline-management', 'interview-coordination', 'offer-and-close-management'],
|
|
133
|
+
protectedJobs: ['role-intake-and-scorecard', 'job-posting-and-sourcing', 'candidate-pipeline-management', 'sourcing-funnel-recap', 'interview-coordination', 'offer-and-close-management'],
|
|
134
134
|
protectedAliases: ['recruiting', 'recruiter', 'hiring'],
|
|
135
135
|
defaultHireMode: 'job',
|
|
136
136
|
lockCopy: 'Hire RECardo to unlock full-cycle recruiting work for this request.'
|