fraim-framework 2.0.167 → 2.0.169

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.
Files changed (33) hide show
  1. package/dist/src/ai-hub/catalog.js +28 -14
  2. package/dist/src/ai-hub/server.js +34 -406
  3. package/dist/src/cli/commands/init-project.js +1 -98
  4. package/dist/src/cli/commands/manager.js +40 -0
  5. package/dist/src/cli/commands/sync.js +17 -21
  6. package/dist/src/cli/fraim.js +2 -0
  7. package/dist/src/cli/utils/github-workflow-sync.js +12 -146
  8. package/dist/src/cli/utils/manager-pack-sync.js +188 -0
  9. package/dist/src/cli/utils/manager-publish.js +76 -0
  10. package/dist/src/cli/utils/user-config.js +20 -0
  11. package/dist/src/core/fraim-config-schema.generated.js +85 -10
  12. package/dist/src/core/manager-pack.js +26 -0
  13. package/dist/src/first-run/install-state.js +1 -0
  14. package/dist/src/first-run/server.js +9 -0
  15. package/dist/src/first-run/session-service.js +117 -23
  16. package/dist/src/first-run/types.js +2 -5
  17. package/dist/src/local-mcp-server/learning-context-builder.js +45 -8
  18. package/dist/src/local-mcp-server/stdio-server.js +28 -0
  19. package/package.json +1 -2
  20. package/public/ai-hub/index.html +0 -81
  21. package/public/ai-hub/script.js +3 -219
  22. package/public/ai-hub/styles.css +8 -36
  23. package/public/first-run/index.html +1 -1
  24. package/public/first-run/script.js +459 -530
  25. package/public/first-run/styles.css +288 -73
  26. package/dist/src/config/ai-manager-hiring.js +0 -121
  27. package/dist/src/config/compat.js +0 -16
  28. package/dist/src/config/feature-flags.js +0 -25
  29. package/dist/src/config/persona-capability-bundles.js +0 -273
  30. package/dist/src/config/persona-hiring.js +0 -270
  31. package/dist/src/config/portfolio-slug-overrides.js +0 -17
  32. package/dist/src/config/pricing.js +0 -37
  33. package/dist/src/config/stripe.js +0 -43
@@ -16,11 +16,11 @@ const project_fraim_paths_1 = require("../core/utils/project-fraim-paths");
16
16
  // Directories scanned for employee jobs at runtime, in lowest-to-highest
17
17
  // precedence order. Later entries win on {categoryId, jobId} collision.
18
18
  //
19
- // FRAIM execution only ever reads the two `fraim/` layers the synced baseline
20
- // and the personalized override. (`registry/` is a development-only source tree,
21
- // never read at runtime; jobs reach a project via sync into `fraim/ai-employee`.)
22
- // - <project>/fraim/ai-employee/jobs/<category>/ synced baseline (shared)
23
- // - <project>/fraim/personalized-employee/jobs/<category>/ taught/customized override
19
+ // FRAIM execution normally reads only the two `fraim/` layers: the synced
20
+ // baseline and the personalized override. The Hub can explicitly opt into the
21
+ // source `registry/` fallback when running against the FRAIM repo before sync.
22
+ // - <project>/fraim/ai-employee/jobs/<category>/ - synced baseline
23
+ // - <project>/fraim/personalized-employee/jobs/<category>/ - taught/customized override
24
24
  // Only the personalized-employee layer is "personalized" (issue #566).
25
25
  const EMPLOYEE_JOB_LAYERS = [
26
26
  { segments: ['ai-employee', 'jobs'], personalized: false },
@@ -31,6 +31,12 @@ const MANAGER_JOB_LAYERS = [
31
31
  { segments: ['ai-manager', 'jobs'], personalized: false },
32
32
  { segments: ['personalized-employee', 'manager-jobs'], personalized: true },
33
33
  ];
34
+ const REGISTRY_EMPLOYEE_JOB_LAYERS = [
35
+ { base: 'project', segments: ['registry', 'jobs', 'ai-employee'], personalized: false },
36
+ ];
37
+ const REGISTRY_MANAGER_JOB_LAYERS = [
38
+ { base: 'project', segments: ['registry', 'jobs', 'ai-manager'], personalized: false },
39
+ ];
34
40
  const KNOWN_LABEL_OVERRIDES = {
35
41
  // Project conventions where a directory name should render as a recognized
36
42
  // short form rather than its mechanical title-case spelling.
@@ -151,6 +157,8 @@ function summarizeProject(projectPath) {
151
157
  };
152
158
  }
153
159
  function resolveLayerRoot(projectPath, layer) {
160
+ if (layer.base === 'project')
161
+ return path_1.default.join(projectPath, ...layer.segments);
154
162
  return path_1.default.join((0, project_fraim_paths_1.getWorkspaceFraimDir)(projectPath), ...layer.segments);
155
163
  }
156
164
  function discoverLayers(projectPath, layers) {
@@ -168,11 +176,13 @@ function discoverLayers(projectPath, layers) {
168
176
  }
169
177
  return out;
170
178
  }
171
- function discoverEmployeeJobs(projectPath) {
179
+ function discoverEmployeeJobs(projectPath, options = {}) {
172
180
  const project = summarizeProject(projectPath);
173
- if (!project.exists || !project.hasFraim)
181
+ if (!project.exists || (!project.hasFraim && !options.includeRegistry))
174
182
  return [];
175
- const layers = discoverLayers(projectPath, EMPLOYEE_JOB_LAYERS);
183
+ const layers = discoverLayers(projectPath, options.includeRegistry
184
+ ? [...REGISTRY_EMPLOYEE_JOB_LAYERS, ...EMPLOYEE_JOB_LAYERS]
185
+ : EMPLOYEE_JOB_LAYERS);
176
186
  // Group by categoryId so all layers contribute to the same labelled category.
177
187
  const jobsByKey = new Map();
178
188
  for (const layer of layers) {
@@ -193,11 +203,13 @@ function discoverEmployeeJobs(projectPath) {
193
203
  return a.title.localeCompare(b.title);
194
204
  });
195
205
  }
196
- function discoverManagerTemplates(projectPath) {
206
+ function discoverManagerTemplates(projectPath, options = {}) {
197
207
  const project = summarizeProject(projectPath);
198
- if (!project.exists || !project.hasFraim)
208
+ if (!project.exists || (!project.hasFraim && !options.includeRegistry))
199
209
  return [];
200
- const layers = discoverLayers(projectPath, MANAGER_JOB_LAYERS);
210
+ const layers = discoverLayers(projectPath, options.includeRegistry
211
+ ? [...REGISTRY_MANAGER_JOB_LAYERS, ...MANAGER_JOB_LAYERS]
212
+ : MANAGER_JOB_LAYERS);
201
213
  const templatesByKey = new Map();
202
214
  for (const layer of layers) {
203
215
  const groupLabel = humanizeName(layer.categoryId);
@@ -372,12 +384,14 @@ function labelForPhaseId(phaseId, jobId, projectPath) {
372
384
  }
373
385
  return friendlyPhaseLabel(phaseId);
374
386
  }
375
- function getAiHubCategories(projectPath) {
387
+ function getAiHubCategories(projectPath, options = {}) {
376
388
  const project = summarizeProject(projectPath);
377
- if (!project.exists || !project.hasFraim)
389
+ if (!project.exists || (!project.hasFraim && !options.includeRegistry))
378
390
  return [];
379
391
  // A category is any directory found at any layer; deduplicate by id.
380
- const layers = discoverLayers(projectPath, EMPLOYEE_JOB_LAYERS);
392
+ const layers = discoverLayers(projectPath, options.includeRegistry
393
+ ? [...REGISTRY_EMPLOYEE_JOB_LAYERS, ...EMPLOYEE_JOB_LAYERS]
394
+ : EMPLOYEE_JOB_LAYERS);
381
395
  const seen = new Map();
382
396
  for (const layer of layers) {
383
397
  if (!seen.has(layer.categoryId)) {
@@ -36,7 +36,7 @@ var __importDefault = (this && this.__importDefault) || function (mod) {
36
36
  return (mod && mod.__esModule) ? mod : { "default": mod };
37
37
  };
38
38
  Object.defineProperty(exports, "__esModule", { value: true });
39
- exports.AiHubServer = exports.HostConfigStore = exports.DeploymentStore = void 0;
39
+ exports.AiHubServer = void 0;
40
40
  exports.findAvailablePort = findAvailablePort;
41
41
  exports.findAvailablePortExcluding = findAvailablePortExcluding;
42
42
  const express_1 = __importDefault(require("express"));
@@ -50,7 +50,6 @@ const https_1 = __importDefault(require("https"));
50
50
  const types_1 = require("../first-run/types");
51
51
  const learning_context_builder_1 = require("../local-mcp-server/learning-context-builder");
52
52
  const project_fraim_paths_1 = require("../core/utils/project-fraim-paths");
53
- const persona_hiring_1 = require("../config/persona-hiring");
54
53
  const catalog_1 = require("./catalog");
55
54
  const agent_token_prices_1 = require("../local-mcp-server/agent-token-prices");
56
55
  const hosts_1 = require("./hosts");
@@ -59,6 +58,25 @@ const preferences_1 = require("./preferences");
59
58
  const conversation_store_1 = require("./conversation-store");
60
59
  const managed_browser_1 = require("./managed-browser");
61
60
  const managed_agent_paths_1 = require("../cli/utils/managed-agent-paths");
61
+ let personaHiringModule;
62
+ function loadPersonaHiringModule() {
63
+ const cached = personaHiringModule;
64
+ if (cached !== undefined) {
65
+ return cached;
66
+ }
67
+ try {
68
+ // Server deployments include the persona catalog. The npm client package
69
+ // intentionally does not, so importing ai-hub/server must not require it.
70
+ // eslint-disable-next-line @typescript-eslint/no-var-requires
71
+ const loaded = require('../config/persona-hiring');
72
+ personaHiringModule = loaded;
73
+ return loaded;
74
+ }
75
+ catch {
76
+ personaHiringModule = null;
77
+ return null;
78
+ }
79
+ }
62
80
  function loadPersonaCapabilityModule() {
63
81
  try {
64
82
  // Server deployments include the persona catalog. The npm client package
@@ -80,6 +98,9 @@ function buildHubPersonaHireUrl(personaKey, hireMode = 'job') {
80
98
  const params = new URLSearchParams({ persona: personaKey, mode: hireMode });
81
99
  return `/pricing?${params.toString()}`;
82
100
  }
101
+ function buildHubPersonaAvatarUrl(personaKey) {
102
+ return loadPersonaHiringModule()?.buildPersonaAvatarUrl(personaKey) ?? '';
103
+ }
83
104
  async function getHubWorkspacePersonaState(dbService, userId, apiKey) {
84
105
  try {
85
106
  // eslint-disable-next-line @typescript-eslint/no-var-requires
@@ -180,113 +201,6 @@ function safeHttpUrl(value) {
180
201
  return null;
181
202
  }
182
203
  }
183
- // ─── Issue #578: Deployment + Host stores ─────────────────────────────────────
184
- class DeploymentStore {
185
- constructor(filePath) {
186
- this.filePath = filePath ?? path_1.default.join(getUserHubDir(), 'hub-deployments.json');
187
- }
188
- load() {
189
- try {
190
- if (!fs_1.default.existsSync(this.filePath))
191
- return [];
192
- return JSON.parse(fs_1.default.readFileSync(this.filePath, 'utf8'));
193
- }
194
- catch {
195
- return [];
196
- }
197
- }
198
- save(deployments) {
199
- fs_1.default.mkdirSync(path_1.default.dirname(this.filePath), { recursive: true });
200
- fs_1.default.writeFileSync(this.filePath, JSON.stringify(deployments, null, 2));
201
- }
202
- create(deployment) {
203
- const list = this.load();
204
- list.push(deployment);
205
- this.save(list);
206
- return deployment;
207
- }
208
- update(id, updater) {
209
- const list = this.load();
210
- const dep = list.find((d) => d.id === id);
211
- if (!dep)
212
- return false;
213
- updater(dep);
214
- dep.updatedAt = new Date().toISOString();
215
- this.save(list);
216
- return true;
217
- }
218
- delete(id) {
219
- const list = this.load();
220
- const next = list.filter((d) => d.id !== id);
221
- if (next.length === list.length)
222
- return false;
223
- this.save(next);
224
- return true;
225
- }
226
- }
227
- exports.DeploymentStore = DeploymentStore;
228
- class HostConfigStore {
229
- constructor(filePath) {
230
- this.filePath = filePath ?? path_1.default.join(getUserHubDir(), 'hub-hosts.json');
231
- }
232
- load() {
233
- try {
234
- if (!fs_1.default.existsSync(this.filePath))
235
- return [];
236
- return JSON.parse(fs_1.default.readFileSync(this.filePath, 'utf8'));
237
- }
238
- catch {
239
- return [];
240
- }
241
- }
242
- save(hosts) {
243
- fs_1.default.mkdirSync(path_1.default.dirname(this.filePath), { recursive: true });
244
- fs_1.default.writeFileSync(this.filePath, JSON.stringify(hosts, null, 2));
245
- }
246
- add(host) {
247
- const list = this.load();
248
- list.push(host);
249
- this.save(list);
250
- return host;
251
- }
252
- delete(id) {
253
- const list = this.load();
254
- const next = list.filter((h) => h.id !== id);
255
- if (next.length === list.length)
256
- return false;
257
- this.save(next);
258
- return true;
259
- }
260
- }
261
- exports.HostConfigStore = HostConfigStore;
262
- async function pingHost(host) {
263
- const start = Date.now();
264
- try {
265
- const resp = await fetch(`${host.url.replace(/\/$/, '')}/health`, {
266
- signal: AbortSignal.timeout(5000),
267
- headers: host.authToken ? { 'X-Hub-Auth': host.authToken } : {},
268
- });
269
- const latencyMs = Date.now() - start;
270
- return {
271
- id: host.id,
272
- label: host.label,
273
- url: host.url,
274
- status: resp.ok ? 'online' : 'degraded',
275
- latencyMs,
276
- lastPingAt: new Date().toISOString(),
277
- };
278
- }
279
- catch {
280
- return {
281
- id: host.id,
282
- label: host.label,
283
- url: host.url,
284
- status: 'offline',
285
- latencyMs: null,
286
- lastPingAt: new Date().toISOString(),
287
- };
288
- }
289
- }
290
204
  function normalizeReviewArtifact(raw, index = 0) {
291
205
  if (!raw || typeof raw !== 'object')
292
206
  return null;
@@ -855,7 +769,6 @@ class AiHubServer {
855
769
  constructor(options = {}) {
856
770
  this.app = (0, express_1.default)();
857
771
  this.runRegistry = new AiHubRunRegistry();
858
- this.cronHandles = new Map();
859
772
  this.projectPath = options.projectPath || process.cwd();
860
773
  this.preferencesStore = options.preferencesStore || new preferences_1.AiHubPreferencesStore();
861
774
  this.conversationStore = options.conversationStore || new conversation_store_1.AiHubConversationStore();
@@ -878,9 +791,7 @@ class AiHubServer {
878
791
  this.dbService = createDefaultDbService();
879
792
  this.ownsDbService = this.dbService !== undefined;
880
793
  }
881
- this.deploymentStore = options.deploymentStore ?? new DeploymentStore();
882
- this.hostConfigStore = options.hostConfigStore ?? new HostConfigStore();
883
- this.app.use(express_1.default.json({ limit: '10mb' }));
794
+ this.app.use(express_1.default.json());
884
795
  if (this.dbService) {
885
796
  const { registerPaymentRoutes } = require('../routes/payment-routes');
886
797
  registerPaymentRoutes(this.app, () => this.paymentRepo ?? null, this.dbService);
@@ -1024,8 +935,6 @@ class AiHubServer {
1024
935
  // so it's known even before the browser is running.
1025
936
  process.env.FRAIM_BROWSER_CDP_ENDPOINT = this.managedBrowser.cdpEndpoint();
1026
937
  process.env.FRAIM_HUB_BASE_URL = `http://127.0.0.1:${port}`;
1027
- // Issue #578: rehydrate active scheduled deployments from disk.
1028
- this.rehydrateScheduledDeployments();
1029
938
  // Start HTTPS server when a cert bundle and port are provided.
1030
939
  // Word Online requires HTTPS; the HTTPS server shares the same Express app
1031
940
  // so all routes (including /word-taskpane/*) are available over both protocols.
@@ -1065,11 +974,6 @@ class AiHubServer {
1065
974
  await closeServer(this.httpServer);
1066
975
  this.httpServer = undefined;
1067
976
  }
1068
- // Issue #578: stop all active scheduled deployments.
1069
- for (const [, task] of this.cronHandles) {
1070
- task.stop();
1071
- }
1072
- this.cronHandles.clear();
1073
977
  // #521: tear down the shared browser if WE launched it (stop() no-ops on a
1074
978
  // browser the manager owns).
1075
979
  this.managedBrowser.stop();
@@ -1094,7 +998,8 @@ class AiHubServer {
1094
998
  }
1095
999
  }
1096
1000
  const project = (0, catalog_1.summarizeProject)(normalizedProjectPath);
1097
- const rawJobs = (0, catalog_1.discoverEmployeeJobs)(normalizedProjectPath);
1001
+ const catalogOptions = { includeRegistry: true };
1002
+ const rawJobs = (0, catalog_1.discoverEmployeeJobs)(normalizedProjectPath, catalogOptions);
1098
1003
  // Issue #566 (R7): jobs already carry `personalized` from catalog discovery
1099
1004
  // (true for the fraim/personalized-employee layer). The Hub renders a plain
1100
1005
  // "Personalized" marking from that flag — no author/attribution is tracked.
@@ -1102,7 +1007,7 @@ class AiHubServer {
1102
1007
  ...job,
1103
1008
  requiredPersonaKey: getProtectedPersonaForHubJob(job.id),
1104
1009
  }));
1105
- const managerTemplates = (0, catalog_1.discoverManagerTemplates)(normalizedProjectPath);
1010
+ const managerTemplates = (0, catalog_1.discoverManagerTemplates)(normalizedProjectPath, catalogOptions);
1106
1011
  const { personas, subscriptionActive, workspaceId, userKey } = await this.computePersonas(apiKey || preferences.apiKey);
1107
1012
  const managerTeam = await this.computeManagerTeam(workspaceId, userKey);
1108
1013
  // Issue #347: enrich the activeRun the same way GET /runs/:id does
@@ -1114,7 +1019,7 @@ class AiHubServer {
1114
1019
  title: 'AI Hub',
1115
1020
  project,
1116
1021
  preferences,
1117
- categories: (0, catalog_1.getAiHubCategories)(normalizedProjectPath),
1022
+ categories: (0, catalog_1.getAiHubCategories)(normalizedProjectPath, catalogOptions),
1118
1023
  jobs,
1119
1024
  managerTemplates,
1120
1025
  employees,
@@ -1165,8 +1070,6 @@ class AiHubServer {
1165
1070
  reviewHandoff: run.reviewHandoff || null,
1166
1071
  compareMode: run.runRole === 'fraim' && run.compareRunId ? 'ab' : undefined,
1167
1072
  compareRunId: run.compareRunId || null,
1168
- // Issue #578: preserve trigger source so the UI can render the chip.
1169
- sourceTrigger: run.sourceTrigger,
1170
1073
  };
1171
1074
  }
1172
1075
  persistRunConversation(run, activeId) {
@@ -1293,7 +1196,7 @@ class AiHubServer {
1293
1196
  key: bundle.personaKey,
1294
1197
  displayName: bundle.catalogMetadata.displayName,
1295
1198
  role: bundle.catalogMetadata.role,
1296
- avatarUrl: (0, persona_hiring_1.buildPersonaAvatarUrl)(bundle.personaKey),
1199
+ avatarUrl: buildHubPersonaAvatarUrl(bundle.personaKey),
1297
1200
  pricingLabel: bundle.catalogMetadata.pricingLabel,
1298
1201
  status: 'locked',
1299
1202
  hireUrl: buildHubPersonaHireUrl(bundle.personaKey, bundle.defaultHireMode),
@@ -1331,7 +1234,7 @@ class AiHubServer {
1331
1234
  key: bundle.personaKey,
1332
1235
  displayName: bundle.catalogMetadata.displayName,
1333
1236
  role: bundle.catalogMetadata.role,
1334
- avatarUrl: (0, persona_hiring_1.buildPersonaAvatarUrl)(bundle.personaKey),
1237
+ avatarUrl: buildHubPersonaAvatarUrl(bundle.personaKey),
1335
1238
  pricingLabel: hiredKeys.has(bundle.personaKey) ? '' : bundle.catalogMetadata.pricingLabel,
1336
1239
  status: (hiredKeys.has(bundle.personaKey) ? 'hired' : 'locked'),
1337
1240
  hireUrl: buildHubPersonaHireUrl(bundle.personaKey, bundle.defaultHireMode),
@@ -1674,12 +1577,14 @@ class AiHubServer {
1674
1577
  ? path_1.default.resolve(body.projectPath)
1675
1578
  : this.projectPath;
1676
1579
  const loc = (0, learning_context_builder_1.resolveTeamContextFile)(projectPath, body.key);
1677
- if (loc.managedByOrgSync || !loc.writePath) {
1580
+ if (loc.managedByOrgSync || loc.managedByManagerSync || !loc.writePath) {
1678
1581
  // Enforcement only: block editing a synced org file (it would be
1679
1582
  // overwritten on next sync). The how-to-change procedure lives in the
1680
1583
  // organization-onboarding job, not in this error body (issue #563 review).
1681
1584
  return res.status(409).json({
1682
- error: 'This organization file is managed by org sync and is read-only here.'
1585
+ error: loc.managedByManagerSync
1586
+ ? 'This manager file is managed by manager sync and is read-only here.'
1587
+ : 'This organization file is managed by org sync and is read-only here.'
1683
1588
  });
1684
1589
  }
1685
1590
  const dest = path_1.default.resolve(loc.writePath);
@@ -2250,27 +2155,6 @@ class AiHubServer {
2250
2155
  runs = runs.sort((a, b) => b.updatedAt.localeCompare(a.updatedAt)).slice(0, limit);
2251
2156
  return res.json(runs.map((r) => this.enrichRunForResponse(r)));
2252
2157
  });
2253
- // ─── Issue #578: /api/ai-hub/runs/merged must be registered BEFORE :runId ──
2254
- this.app.get('/api/ai-hub/runs/merged', async (req, res) => {
2255
- const local = this.runRegistry.all().map((r) => this.enrichRunForResponse(r));
2256
- const hosts = this.hostConfigStore.load();
2257
- const remoteResults = await Promise.allSettled(hosts.map(async (host) => {
2258
- const url = `${host.url.replace(/\/$/, '')}/api/ai-hub/runs`;
2259
- const resp = await fetch(url, {
2260
- signal: AbortSignal.timeout(8000),
2261
- headers: host.authToken ? { 'X-Hub-Auth': host.authToken } : {},
2262
- });
2263
- if (!resp.ok)
2264
- return [];
2265
- return resp.json();
2266
- }));
2267
- const remote = remoteResults
2268
- .filter((r) => r.status === 'fulfilled')
2269
- .flatMap((r) => r.value);
2270
- const merged = [...local, ...remote].sort((a, b) => b.updatedAt.localeCompare(a.updatedAt));
2271
- return res.json(merged);
2272
- });
2273
- // GET /api/ai-hub/runs/:runId — registered AFTER /merged to avoid shadowing.
2274
2158
  this.app.get('/api/ai-hub/runs/:runId', (req, res) => {
2275
2159
  const run = this.runRegistry.get(req.params.runId);
2276
2160
  if (!run) {
@@ -2278,151 +2162,6 @@ class AiHubServer {
2278
2162
  }
2279
2163
  return res.json(this.enrichRunForResponse(run));
2280
2164
  });
2281
- // ─── Issue #578: Scheduled + Reactive Employees ───────────────────────────
2282
- // POST /api/ai-hub/schedules — create a recurring scheduled deployment.
2283
- this.app.post('/api/ai-hub/schedules', (req, res) => {
2284
- const { label, jobId, projectPath, hostId, cronExpr, instructions, outputChannel, allowConcurrent } = req.body ?? {};
2285
- if (!label || !jobId || !cronExpr) {
2286
- return res.status(400).json({ error: 'label, jobId, and cronExpr are required.' });
2287
- }
2288
- const validEmployees = ['codex', 'claude', 'gemini', 'copilot'];
2289
- const resolvedHostId = validEmployees.includes(hostId) ? hostId : 'claude';
2290
- const now = new Date().toISOString();
2291
- const deployment = {
2292
- id: (0, crypto_1.randomUUID)(),
2293
- type: 'scheduled',
2294
- label,
2295
- jobId,
2296
- projectPath: ensureDirectoryPath(projectPath || this.projectPath),
2297
- hostId: resolvedHostId,
2298
- cronExpr,
2299
- instructions: typeof instructions === 'string' ? instructions : undefined,
2300
- outputChannel: typeof outputChannel === 'string' ? outputChannel : undefined,
2301
- allowConcurrent: allowConcurrent === true,
2302
- active: true,
2303
- createdAt: now,
2304
- updatedAt: now,
2305
- };
2306
- this.deploymentStore.create(deployment);
2307
- this.scheduleDeployment(deployment);
2308
- return res.status(201).json(deployment);
2309
- });
2310
- // GET /api/ai-hub/schedules — list all scheduled deployments.
2311
- this.app.get('/api/ai-hub/schedules', (_req, res) => {
2312
- return res.json(this.deploymentStore.load().filter((d) => d.type === 'scheduled'));
2313
- });
2314
- // DELETE /api/ai-hub/schedules/:id — remove a scheduled deployment.
2315
- this.app.delete('/api/ai-hub/schedules/:id', (req, res) => {
2316
- const { id } = req.params;
2317
- const task = this.cronHandles.get(id);
2318
- if (task) {
2319
- task.stop();
2320
- this.cronHandles.delete(id);
2321
- }
2322
- const deleted = this.deploymentStore.delete(id);
2323
- if (!deleted)
2324
- return res.status(404).json({ error: 'Deployment not found.' });
2325
- return res.json({ ok: true });
2326
- });
2327
- // POST /api/ai-hub/webhooks — register an inbound webhook deployment.
2328
- this.app.post('/api/ai-hub/webhooks', (req, res) => {
2329
- const { label, jobId, projectPath, hostId, instructions, outputChannel, allowConcurrent } = req.body ?? {};
2330
- if (!label || !jobId) {
2331
- return res.status(400).json({ error: 'label and jobId are required.' });
2332
- }
2333
- const validEmployees = ['codex', 'claude', 'gemini', 'copilot'];
2334
- const resolvedHostId = validEmployees.includes(hostId) ? hostId : 'claude';
2335
- const now = new Date().toISOString();
2336
- const deployment = {
2337
- id: (0, crypto_1.randomUUID)(),
2338
- type: 'webhook',
2339
- label,
2340
- jobId,
2341
- projectPath: ensureDirectoryPath(projectPath || this.projectPath),
2342
- hostId: resolvedHostId,
2343
- instructions: typeof instructions === 'string' ? instructions : undefined,
2344
- outputChannel: typeof outputChannel === 'string' ? outputChannel : undefined,
2345
- allowConcurrent: allowConcurrent === true,
2346
- active: true,
2347
- createdAt: now,
2348
- updatedAt: now,
2349
- };
2350
- this.deploymentStore.create(deployment);
2351
- const hubBase = process.env.FRAIM_HUB_BASE_URL || `http://127.0.0.1:${this.httpPort}`;
2352
- return res.status(201).json({ ...deployment, inboundUrl: `${hubBase}/api/ai-hub/webhooks/${deployment.id}/inbound` });
2353
- });
2354
- // GET /api/ai-hub/webhooks — list all webhook deployments.
2355
- this.app.get('/api/ai-hub/webhooks', (_req, res) => {
2356
- const hubBase = process.env.FRAIM_HUB_BASE_URL || `http://127.0.0.1:${this.httpPort}`;
2357
- return res.json(this.deploymentStore.load()
2358
- .filter((d) => d.type === 'webhook')
2359
- .map((d) => ({ ...d, inboundUrl: `${hubBase}/api/ai-hub/webhooks/${d.id}/inbound` })));
2360
- });
2361
- // DELETE /api/ai-hub/webhooks/:id — remove a webhook deployment.
2362
- this.app.delete('/api/ai-hub/webhooks/:id', (req, res) => {
2363
- const deleted = this.deploymentStore.delete(req.params.id);
2364
- if (!deleted)
2365
- return res.status(404).json({ error: 'Deployment not found.' });
2366
- return res.json({ ok: true });
2367
- });
2368
- // POST /api/ai-hub/webhooks/:id/inbound — webhook inbound trigger from external systems.
2369
- this.app.post('/api/ai-hub/webhooks/:id/inbound', async (req, res) => {
2370
- const deployments = this.deploymentStore.load();
2371
- const deployment = deployments.find((d) => d.id === req.params.id && d.type === 'webhook' && d.active);
2372
- if (!deployment) {
2373
- return res.status(404).json({ error: 'Webhook not found or inactive.' });
2374
- }
2375
- try {
2376
- const run = await this.fireDeploymentRun(deployment, req.body);
2377
- return res.status(202).json({ runId: run.id, status: run.status });
2378
- }
2379
- catch (err) {
2380
- const msg = err instanceof Error ? err.message : 'Failed to start run.';
2381
- return res.status(500).json({ error: msg });
2382
- }
2383
- });
2384
- // GET /api/ai-hub/hosts — list registered remote hosts with health status.
2385
- this.app.get('/api/ai-hub/hosts', async (_req, res) => {
2386
- const hosts = this.hostConfigStore.load();
2387
- const healthResults = await Promise.allSettled(hosts.map((h) => pingHost(h)));
2388
- const health = healthResults.map((r, i) => r.status === 'fulfilled'
2389
- ? r.value
2390
- : { id: hosts[i].id, label: hosts[i].label, url: hosts[i].url, status: 'offline', latencyMs: null, lastPingAt: new Date().toISOString() });
2391
- return res.json(health);
2392
- });
2393
- // POST /api/ai-hub/hosts — register a named remote hub host.
2394
- this.app.post('/api/ai-hub/hosts', (req, res) => {
2395
- const { label, url, authToken } = req.body ?? {};
2396
- const validUrl = safeHttpUrl(url);
2397
- if (!label || !validUrl) {
2398
- return res.status(400).json({ error: 'label and a valid http(s) url are required.' });
2399
- }
2400
- const host = {
2401
- id: (0, crypto_1.randomUUID)(),
2402
- label,
2403
- url: validUrl,
2404
- authToken: typeof authToken === 'string' && authToken ? authToken : undefined,
2405
- createdAt: new Date().toISOString(),
2406
- };
2407
- this.hostConfigStore.add(host);
2408
- return res.status(201).json({ id: host.id, label: host.label, url: host.url, createdAt: host.createdAt });
2409
- });
2410
- // DELETE /api/ai-hub/hosts/:id — remove a named remote host.
2411
- this.app.delete('/api/ai-hub/hosts/:id', (req, res) => {
2412
- const deleted = this.hostConfigStore.delete(req.params.id);
2413
- if (!deleted)
2414
- return res.status(404).json({ error: 'Host not found.' });
2415
- return res.json({ ok: true });
2416
- });
2417
- // GET /api/ai-hub/hosts/:id/health — ping a single host.
2418
- this.app.get('/api/ai-hub/hosts/:id/health', async (req, res) => {
2419
- const host = this.hostConfigStore.load().find((h) => h.id === req.params.id);
2420
- if (!host)
2421
- return res.status(404).json({ error: 'Host not found.' });
2422
- const health = await pingHost(host);
2423
- return res.json(health);
2424
- });
2425
- // ─── End Issue #578 ───────────────────────────────────────────────────────
2426
2165
  // -------------------------------------------------------------------------
2427
2166
  // Issue #489: POST /api/trigger
2428
2167
  // Stable API endpoint for extension surfaces (Office add-ins, browser
@@ -2521,117 +2260,6 @@ class AiHubServer {
2521
2260
  process.env.FRAIM_BROWSER_CDP_ENDPOINT = result.endpoint;
2522
2261
  return { endpoint: result.endpoint, reused: result.reused, channel: result.channel };
2523
2262
  }
2524
- // ─── Issue #578: Scheduled deployment helpers ─────────────────────────────
2525
- rehydrateScheduledDeployments() {
2526
- const deployments = this.deploymentStore.load();
2527
- for (const dep of deployments) {
2528
- if (dep.type === 'scheduled' && dep.active) {
2529
- this.scheduleDeployment(dep);
2530
- }
2531
- }
2532
- if (deployments.filter((d) => d.type === 'scheduled' && d.active).length > 0) {
2533
- console.log(`[ai-hub] rehydrated ${deployments.filter((d) => d.type === 'scheduled' && d.active).length} scheduled deployment(s)`);
2534
- }
2535
- }
2536
- scheduleDeployment(deployment) {
2537
- if (!deployment.cronExpr)
2538
- return;
2539
- try {
2540
- // eslint-disable-next-line @typescript-eslint/no-var-requires
2541
- const cron = require('node-cron');
2542
- if (!cron.validate(deployment.cronExpr)) {
2543
- console.warn(`[ai-hub] invalid cronExpr for deployment ${deployment.id}: ${deployment.cronExpr}`);
2544
- return;
2545
- }
2546
- const task = cron.schedule(deployment.cronExpr, async () => {
2547
- try {
2548
- await this.fireDeploymentRun(deployment);
2549
- }
2550
- catch (err) {
2551
- console.warn(`[ai-hub] scheduled deployment ${deployment.id} fire failed:`, err);
2552
- }
2553
- });
2554
- this.cronHandles.set(deployment.id, task);
2555
- }
2556
- catch (err) {
2557
- console.warn('[ai-hub] node-cron not available — scheduled deployments require node-cron:', err);
2558
- }
2559
- }
2560
- async fireDeploymentRun(deployment, webhookBody) {
2561
- // Overlapping run guard: if the prior run is still active and allowConcurrent is false, skip.
2562
- if (!deployment.allowConcurrent && deployment.activeRunId) {
2563
- const active = this.runRegistry.get(deployment.activeRunId);
2564
- if (active && active.status === 'running') {
2565
- console.log(`[ai-hub] deployment ${deployment.id} skipped — prior run ${deployment.activeRunId} still running`);
2566
- return active;
2567
- }
2568
- }
2569
- const employees = this.hostRuntime.detectEmployees();
2570
- const employee = employees.find((e) => e.id === deployment.hostId);
2571
- if (!employee?.available) {
2572
- throw new Error(`Employee ${deployment.hostId} is not available for scheduled/webhook run.`);
2573
- }
2574
- const instructions = [
2575
- deployment.instructions ?? `/fraim ${deployment.jobId}`,
2576
- webhookBody ? `\n\nInbound payload:\n${JSON.stringify(webhookBody, null, 2)}` : '',
2577
- ].join('').trim();
2578
- const startTimestamp = new Date().toISOString();
2579
- const run = {
2580
- id: (0, crypto_1.randomUUID)(),
2581
- jobId: deployment.jobId,
2582
- hostId: deployment.hostId,
2583
- projectPath: deployment.projectPath,
2584
- status: 'running',
2585
- sourceTrigger: deployment.type === 'scheduled' ? 'scheduled' : 'webhook',
2586
- createdAt: startTimestamp,
2587
- updatedAt: startTimestamp,
2588
- messages: [(0, hosts_1.createHubMessage)('manager', instructions)],
2589
- events: [(0, hosts_1.createHubEvent)('system', `Triggered by deployment: ${deployment.label} (${deployment.type})`)],
2590
- currentPhase: null,
2591
- phaseHistory: [],
2592
- totals: emptyTotals(),
2593
- lastStatusChangeAt: startTimestamp,
2594
- personaKey: getProtectedPersonaForHubJob(deployment.jobId),
2595
- };
2596
- const child = this.hostRuntime.startRun(deployment.hostId, deployment.projectPath, instructions, {
2597
- onEvent: (event, channel) => {
2598
- this.runRegistry.update(run.id, (current) => {
2599
- if (event.sessionId)
2600
- current.sessionId = event.sessionId;
2601
- appendHostMessage(current, deployment.hostId, event, channel);
2602
- if (event.raw) {
2603
- current.events.push((0, hosts_1.createHubEvent)(channel, event.raw));
2604
- applyReviewProjection(current, event.raw);
2605
- }
2606
- if (event.agentIdentity)
2607
- applyAgentIdentitySignal(current, event.agentIdentity);
2608
- if (event.seekMentoring)
2609
- applySeekMentoringSignal(current, event.seekMentoring);
2610
- if (event.usage)
2611
- applyUsageSignal(current, event.usage);
2612
- });
2613
- const updated = this.runRegistry.get(run.id);
2614
- if (updated)
2615
- this.persistRunConversation(updated, updated.conversationId || updated.id);
2616
- },
2617
- onExit: (exitCode) => {
2618
- this.runRegistry.update(run.id, (r) => {
2619
- r.exitCode = exitCode;
2620
- r.status = exitCode === 0 ? 'completed' : 'failed';
2621
- r.events.push((0, hosts_1.createHubEvent)('system', `Run exited with code ${exitCode ?? 'unknown'}.`));
2622
- });
2623
- const updated = this.runRegistry.get(run.id);
2624
- if (updated)
2625
- this.persistRunConversation(updated, updated.conversationId || updated.id);
2626
- this.deploymentStore.update(deployment.id, (d) => { d.activeRunId = undefined; });
2627
- this.runRegistry.dispose(run.id);
2628
- },
2629
- });
2630
- this.runRegistry.create(run, child);
2631
- this.deploymentStore.update(deployment.id, (d) => { d.activeRunId = run.id; });
2632
- return run;
2633
- }
2634
- // ─── End Issue #578 helpers ───────────────────────────────────────────────
2635
2263
  // Issue #347 — assemble the read-side projection of a run. Stages are
2636
2264
  // derived from job frontmatter + visited phases; totalDurationMs ticks
2637
2265
  // forward while the run is still running so the UI's totals line