fraim-framework 2.0.167 → 2.0.168

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 (54) hide show
  1. package/dist/src/ai-hub/catalog.js +28 -14
  2. package/dist/src/ai-hub/server.js +10 -403
  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/index.js +1 -1
  20. package/package.json +1 -2
  21. package/public/ai-hub/index.html +0 -81
  22. package/public/ai-hub/powerpoint-taskpane/index.html +236 -236
  23. package/public/ai-hub/powerpoint-taskpane/manifest.xml +29 -29
  24. package/public/ai-hub/script.js +3 -219
  25. package/public/ai-hub/styles.css +8 -36
  26. package/public/first-run/index.html +1 -1
  27. package/public/first-run/script.js +459 -530
  28. package/public/first-run/styles.css +288 -73
  29. package/public/portfolio/ashley.html +1 -1
  30. package/public/portfolio/casey.html +1 -1
  31. package/public/portfolio/celia.html +1 -1
  32. package/public/portfolio/gautam.html +1 -1
  33. package/public/portfolio/hari.html +1 -1
  34. package/public/portfolio/maestro.html +1 -1
  35. package/public/portfolio/mandy.html +1 -1
  36. package/public/portfolio/pam.html +6 -6
  37. package/public/portfolio/qasm.html +1 -1
  38. package/public/portfolio/sade.html +1 -1
  39. package/public/portfolio/sam.html +1 -1
  40. package/public/portfolio/swen.html +6 -6
  41. package/dist/src/ai-hub/word-sideload.js +0 -95
  42. package/dist/src/cli/commands/test-mcp.js +0 -171
  43. package/dist/src/cli/setup/first-run.js +0 -242
  44. package/dist/src/config/ai-manager-hiring.js +0 -121
  45. package/dist/src/config/compat.js +0 -16
  46. package/dist/src/config/feature-flags.js +0 -25
  47. package/dist/src/config/persona-capability-bundles.js +0 -273
  48. package/dist/src/config/persona-hiring.js +0 -270
  49. package/dist/src/config/portfolio-slug-overrides.js +0 -17
  50. package/dist/src/config/pricing.js +0 -37
  51. package/dist/src/config/stripe.js +0 -43
  52. package/dist/src/core/config-writer.js +0 -75
  53. package/dist/src/core/utils/job-aliases.js +0 -47
  54. package/dist/src/core/utils/workflow-parser.js +0 -174
@@ -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"));
@@ -180,113 +180,6 @@ function safeHttpUrl(value) {
180
180
  return null;
181
181
  }
182
182
  }
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
183
  function normalizeReviewArtifact(raw, index = 0) {
291
184
  if (!raw || typeof raw !== 'object')
292
185
  return null;
@@ -855,7 +748,6 @@ class AiHubServer {
855
748
  constructor(options = {}) {
856
749
  this.app = (0, express_1.default)();
857
750
  this.runRegistry = new AiHubRunRegistry();
858
- this.cronHandles = new Map();
859
751
  this.projectPath = options.projectPath || process.cwd();
860
752
  this.preferencesStore = options.preferencesStore || new preferences_1.AiHubPreferencesStore();
861
753
  this.conversationStore = options.conversationStore || new conversation_store_1.AiHubConversationStore();
@@ -878,9 +770,7 @@ class AiHubServer {
878
770
  this.dbService = createDefaultDbService();
879
771
  this.ownsDbService = this.dbService !== undefined;
880
772
  }
881
- this.deploymentStore = options.deploymentStore ?? new DeploymentStore();
882
- this.hostConfigStore = options.hostConfigStore ?? new HostConfigStore();
883
- this.app.use(express_1.default.json({ limit: '10mb' }));
773
+ this.app.use(express_1.default.json());
884
774
  if (this.dbService) {
885
775
  const { registerPaymentRoutes } = require('../routes/payment-routes');
886
776
  registerPaymentRoutes(this.app, () => this.paymentRepo ?? null, this.dbService);
@@ -1024,8 +914,6 @@ class AiHubServer {
1024
914
  // so it's known even before the browser is running.
1025
915
  process.env.FRAIM_BROWSER_CDP_ENDPOINT = this.managedBrowser.cdpEndpoint();
1026
916
  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
917
  // Start HTTPS server when a cert bundle and port are provided.
1030
918
  // Word Online requires HTTPS; the HTTPS server shares the same Express app
1031
919
  // so all routes (including /word-taskpane/*) are available over both protocols.
@@ -1065,11 +953,6 @@ class AiHubServer {
1065
953
  await closeServer(this.httpServer);
1066
954
  this.httpServer = undefined;
1067
955
  }
1068
- // Issue #578: stop all active scheduled deployments.
1069
- for (const [, task] of this.cronHandles) {
1070
- task.stop();
1071
- }
1072
- this.cronHandles.clear();
1073
956
  // #521: tear down the shared browser if WE launched it (stop() no-ops on a
1074
957
  // browser the manager owns).
1075
958
  this.managedBrowser.stop();
@@ -1094,7 +977,8 @@ class AiHubServer {
1094
977
  }
1095
978
  }
1096
979
  const project = (0, catalog_1.summarizeProject)(normalizedProjectPath);
1097
- const rawJobs = (0, catalog_1.discoverEmployeeJobs)(normalizedProjectPath);
980
+ const catalogOptions = { includeRegistry: true };
981
+ const rawJobs = (0, catalog_1.discoverEmployeeJobs)(normalizedProjectPath, catalogOptions);
1098
982
  // Issue #566 (R7): jobs already carry `personalized` from catalog discovery
1099
983
  // (true for the fraim/personalized-employee layer). The Hub renders a plain
1100
984
  // "Personalized" marking from that flag — no author/attribution is tracked.
@@ -1102,7 +986,7 @@ class AiHubServer {
1102
986
  ...job,
1103
987
  requiredPersonaKey: getProtectedPersonaForHubJob(job.id),
1104
988
  }));
1105
- const managerTemplates = (0, catalog_1.discoverManagerTemplates)(normalizedProjectPath);
989
+ const managerTemplates = (0, catalog_1.discoverManagerTemplates)(normalizedProjectPath, catalogOptions);
1106
990
  const { personas, subscriptionActive, workspaceId, userKey } = await this.computePersonas(apiKey || preferences.apiKey);
1107
991
  const managerTeam = await this.computeManagerTeam(workspaceId, userKey);
1108
992
  // Issue #347: enrich the activeRun the same way GET /runs/:id does
@@ -1114,7 +998,7 @@ class AiHubServer {
1114
998
  title: 'AI Hub',
1115
999
  project,
1116
1000
  preferences,
1117
- categories: (0, catalog_1.getAiHubCategories)(normalizedProjectPath),
1001
+ categories: (0, catalog_1.getAiHubCategories)(normalizedProjectPath, catalogOptions),
1118
1002
  jobs,
1119
1003
  managerTemplates,
1120
1004
  employees,
@@ -1165,8 +1049,6 @@ class AiHubServer {
1165
1049
  reviewHandoff: run.reviewHandoff || null,
1166
1050
  compareMode: run.runRole === 'fraim' && run.compareRunId ? 'ab' : undefined,
1167
1051
  compareRunId: run.compareRunId || null,
1168
- // Issue #578: preserve trigger source so the UI can render the chip.
1169
- sourceTrigger: run.sourceTrigger,
1170
1052
  };
1171
1053
  }
1172
1054
  persistRunConversation(run, activeId) {
@@ -1674,12 +1556,14 @@ class AiHubServer {
1674
1556
  ? path_1.default.resolve(body.projectPath)
1675
1557
  : this.projectPath;
1676
1558
  const loc = (0, learning_context_builder_1.resolveTeamContextFile)(projectPath, body.key);
1677
- if (loc.managedByOrgSync || !loc.writePath) {
1559
+ if (loc.managedByOrgSync || loc.managedByManagerSync || !loc.writePath) {
1678
1560
  // Enforcement only: block editing a synced org file (it would be
1679
1561
  // overwritten on next sync). The how-to-change procedure lives in the
1680
1562
  // organization-onboarding job, not in this error body (issue #563 review).
1681
1563
  return res.status(409).json({
1682
- error: 'This organization file is managed by org sync and is read-only here.'
1564
+ error: loc.managedByManagerSync
1565
+ ? 'This manager file is managed by manager sync and is read-only here.'
1566
+ : 'This organization file is managed by org sync and is read-only here.'
1683
1567
  });
1684
1568
  }
1685
1569
  const dest = path_1.default.resolve(loc.writePath);
@@ -2250,27 +2134,6 @@ class AiHubServer {
2250
2134
  runs = runs.sort((a, b) => b.updatedAt.localeCompare(a.updatedAt)).slice(0, limit);
2251
2135
  return res.json(runs.map((r) => this.enrichRunForResponse(r)));
2252
2136
  });
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
2137
  this.app.get('/api/ai-hub/runs/:runId', (req, res) => {
2275
2138
  const run = this.runRegistry.get(req.params.runId);
2276
2139
  if (!run) {
@@ -2278,151 +2141,6 @@ class AiHubServer {
2278
2141
  }
2279
2142
  return res.json(this.enrichRunForResponse(run));
2280
2143
  });
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
2144
  // -------------------------------------------------------------------------
2427
2145
  // Issue #489: POST /api/trigger
2428
2146
  // Stable API endpoint for extension surfaces (Office add-ins, browser
@@ -2521,117 +2239,6 @@ class AiHubServer {
2521
2239
  process.env.FRAIM_BROWSER_CDP_ENDPOINT = result.endpoint;
2522
2240
  return { endpoint: result.endpoint, reused: result.reused, channel: result.channel };
2523
2241
  }
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
2242
  // Issue #347 — assemble the read-side projection of a run. Stages are
2636
2243
  // derived from job frontmatter + visited phases; totalDurationMs ticks
2637
2244
  // forward while the run is still running so the UI's totals line