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.
@@ -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
- function resolveInitialHubProjectPath(preferencesStore, fallbackPath) {
214
- const fallback = path_1.default.resolve(fallbackPath || process.cwd());
215
- const recorded = preferencesStore.load(fallback).projectPath;
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 fallback;
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, process.cwd());
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
- return resolveInitialHubProjectPath(this.preferencesStore, this.projectPath);
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
- const normalizedProjectPath = path_1.default.resolve(projectPath || this.projectPath);
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
- const normalizedProjectPath = path_1.default.resolve(projectPath || this.projectPath);
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
- this.conversationStore.patchConversation(projectPath, req.params.conversationId, body);
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.persistRunConversation(run, run.conversationId || run.id);
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.persistRunConversation(run, run.conversationId || run.id);
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.'