fraim 2.0.165 → 2.0.167

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 (55) hide show
  1. package/dist/src/ai-hub/catalog.js +20 -27
  2. package/dist/src/ai-hub/server.js +418 -2
  3. package/dist/src/ai-hub/word-sideload.js +95 -0
  4. package/dist/src/cli/commands/org.js +40 -0
  5. package/dist/src/cli/commands/test-mcp.js +171 -0
  6. package/dist/src/cli/fraim.js +2 -0
  7. package/dist/src/cli/setup/first-run.js +242 -0
  8. package/dist/src/cli/utils/org-publish.js +98 -0
  9. package/dist/src/config/ai-manager-hiring.js +121 -0
  10. package/dist/src/config/compat.js +16 -0
  11. package/dist/src/config/feature-flags.js +25 -0
  12. package/dist/src/config/persona-capability-bundles.js +273 -0
  13. package/dist/src/config/persona-hiring.js +270 -0
  14. package/dist/src/config/portfolio-slug-overrides.js +17 -0
  15. package/dist/src/config/pricing.js +37 -0
  16. package/dist/src/config/stripe.js +43 -0
  17. package/dist/src/core/config-loader.js +9 -5
  18. package/dist/src/core/config-writer.js +75 -0
  19. package/dist/src/core/fraim-config-schema.generated.js +0 -21
  20. package/dist/src/core/utils/job-aliases.js +47 -0
  21. package/dist/src/core/utils/local-registry-resolver.js +8 -1
  22. package/dist/src/core/utils/workflow-parser.js +174 -0
  23. package/index.js +1 -1
  24. package/package.json +5 -1
  25. package/public/ai-hub/index.html +81 -0
  26. package/public/ai-hub/powerpoint-taskpane/index.html +236 -236
  27. package/public/ai-hub/powerpoint-taskpane/manifest.xml +29 -29
  28. package/public/ai-hub/review.css +13 -0
  29. package/public/ai-hub/script.js +414 -4
  30. package/public/ai-hub/styles.css +56 -0
  31. package/public/first-run/styles.css +73 -73
  32. package/public/portfolio/ashley.html +523 -0
  33. package/public/portfolio/auditya.html +83 -0
  34. package/public/portfolio/banke.html +83 -0
  35. package/public/portfolio/beza.html +659 -0
  36. package/public/portfolio/careena.html +632 -0
  37. package/public/portfolio/casey.html +568 -0
  38. package/public/portfolio/celia.html +490 -0
  39. package/public/portfolio/deidre.html +642 -0
  40. package/public/portfolio/gautam.html +597 -0
  41. package/public/portfolio/hari.html +469 -0
  42. package/public/portfolio/huxley.html +1354 -0
  43. package/public/portfolio/index.html +741 -0
  44. package/public/portfolio/maestro.html +518 -0
  45. package/public/portfolio/mandy.html +590 -0
  46. package/public/portfolio/mona.html +597 -0
  47. package/public/portfolio/pam.html +887 -0
  48. package/public/portfolio/procella.html +107 -0
  49. package/public/portfolio/qasm.html +569 -0
  50. package/public/portfolio/ricardo.html +489 -0
  51. package/public/portfolio/sade.html +560 -0
  52. package/public/portfolio/sam.html +654 -0
  53. package/public/portfolio/sechar.html +580 -0
  54. package/public/portfolio/sreya.html +599 -0
  55. package/public/portfolio/swen.html +601 -0
@@ -13,29 +13,23 @@ exports.getAiHubCategories = getAiHubCategories;
13
13
  const fs_1 = __importDefault(require("fs"));
14
14
  const path_1 = __importDefault(require("path"));
15
15
  const project_fraim_paths_1 = require("../core/utils/project-fraim-paths");
16
- // Directories scanned for employee jobs, in lowest-to-highest precedence
17
- // order. Later entries win on {categoryId, jobId} collision. All present
18
- // layers contribute their category structure.
16
+ // Directories scanned for employee jobs at runtime, in lowest-to-highest
17
+ // precedence order. Later entries win on {categoryId, jobId} collision.
19
18
  //
20
- // Each entry is `[base, ...segments]` resolved relative to the project root:
21
- // - 'registry' → <project>/registry/jobs/ai-employee/<category>/
22
- // (present in the FRAIM source repo and in dist/)
23
- // - 'fraim' → <project>/fraim/ai-employee/jobs/<category>/
24
- // (the baseline synced into a regular project via
25
- // `fraim setup` / `fraim sync`)
26
- // - 'fraim' → <project>/fraim/personalized-employee/jobs/<category>/
27
- // (per-project override layer; CLAUDE.md says it
28
- // "takes precedence over synced baseline content")
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
24
+ // Only the personalized-employee layer is "personalized" (issue #566).
29
25
  const EMPLOYEE_JOB_LAYERS = [
30
- { base: 'registry', segments: ['jobs', 'ai-employee'] },
31
- { base: 'fraim', segments: ['ai-employee', 'jobs'] },
32
- { base: 'fraim', segments: ['personalized-employee', 'jobs'] },
26
+ { segments: ['ai-employee', 'jobs'], personalized: false },
27
+ { segments: ['personalized-employee', 'jobs'], personalized: true },
33
28
  ];
34
- // Manager templates use the matching two-layer model.
29
+ // Manager templates use the matching layer model.
35
30
  const MANAGER_JOB_LAYERS = [
36
- { base: 'registry', segments: ['jobs', 'ai-manager'] },
37
- { base: 'fraim', segments: ['ai-manager', 'jobs'] },
38
- { base: 'fraim', segments: ['personalized-employee', 'manager-jobs'] },
31
+ { segments: ['ai-manager', 'jobs'], personalized: false },
32
+ { segments: ['personalized-employee', 'manager-jobs'], personalized: true },
39
33
  ];
40
34
  const KNOWN_LABEL_OVERRIDES = {
41
35
  // Project conventions where a directory name should render as a recognized
@@ -93,7 +87,7 @@ function readMarkdownFileNames(dirPath) {
93
87
  .map((entry) => entry.name)
94
88
  .sort((a, b) => a.localeCompare(b));
95
89
  }
96
- function parseJobStub(filePath, categoryId, categoryLabel, projectPath) {
90
+ function parseJobStub(filePath, categoryId, categoryLabel, projectPath, personalized) {
97
91
  const content = fs_1.default.readFileSync(filePath, 'utf8');
98
92
  const fileName = path_1.default.basename(filePath, '.md');
99
93
  const intent = sectionValue(content, 'Intent')[0] || 'No intent summary available.';
@@ -106,6 +100,7 @@ function parseJobStub(filePath, categoryId, categoryLabel, projectPath) {
106
100
  intent,
107
101
  outcome,
108
102
  stubPath: toPosix(path_1.default.relative(projectPath, filePath)),
103
+ personalized: !!personalized,
109
104
  };
110
105
  }
111
106
  function parseManagerStub(filePath, groupId, groupLabel, projectPath) {
@@ -139,8 +134,7 @@ function summarizeProject(projectPath) {
139
134
  };
140
135
  }
141
136
  const fraimDir = (0, project_fraim_paths_1.getWorkspaceFraimDir)(projectPath);
142
- const registryJobsDir = path_1.default.join(projectPath, 'registry', 'jobs');
143
- const hasFraim = fs_1.default.existsSync(fraimDir) || fs_1.default.existsSync(registryJobsDir);
137
+ const hasFraim = fs_1.default.existsSync(fraimDir);
144
138
  if (!hasFraim) {
145
139
  return {
146
140
  path: projectPath,
@@ -157,9 +151,6 @@ function summarizeProject(projectPath) {
157
151
  };
158
152
  }
159
153
  function resolveLayerRoot(projectPath, layer) {
160
- if (layer.base === 'registry') {
161
- return path_1.default.join(projectPath, 'registry', ...layer.segments);
162
- }
163
154
  return path_1.default.join((0, project_fraim_paths_1.getWorkspaceFraimDir)(projectPath), ...layer.segments);
164
155
  }
165
156
  function discoverLayers(projectPath, layers) {
@@ -171,6 +162,7 @@ function discoverLayers(projectPath, layers) {
171
162
  layerRoot,
172
163
  categoryId: categoryName,
173
164
  categoryDir: path_1.default.join(layerRoot, categoryName),
165
+ personalized: layer.personalized,
174
166
  });
175
167
  }
176
168
  }
@@ -187,9 +179,10 @@ function discoverEmployeeJobs(projectPath) {
187
179
  const categoryLabel = humanizeName(layer.categoryId);
188
180
  for (const fileName of readMarkdownFileNames(layer.categoryDir)) {
189
181
  const filePath = path_1.default.join(layer.categoryDir, fileName);
190
- const job = parseJobStub(filePath, layer.categoryId, categoryLabel, projectPath);
182
+ const job = parseJobStub(filePath, layer.categoryId, categoryLabel, projectPath, layer.personalized);
191
183
  // Later layers override earlier layers on {category, jobId} collision —
192
- // personalized-employee wins over the synced ai-employee baseline.
184
+ // personalized-employee wins over the synced ai-employee baseline. The
185
+ // winning job carries its own layer's `personalized` flag (issue #566).
193
186
  jobsByKey.set(`${job.categoryId}::${job.id}`, job);
194
187
  }
195
188
  }
@@ -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 = void 0;
39
+ exports.AiHubServer = exports.HostConfigStore = exports.DeploymentStore = void 0;
40
40
  exports.findAvailablePort = findAvailablePort;
41
41
  exports.findAvailablePortExcluding = findAvailablePortExcluding;
42
42
  const express_1 = __importDefault(require("express"));
@@ -180,6 +180,113 @@ 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
+ }
183
290
  function normalizeReviewArtifact(raw, index = 0) {
184
291
  if (!raw || typeof raw !== 'object')
185
292
  return null;
@@ -748,6 +855,7 @@ class AiHubServer {
748
855
  constructor(options = {}) {
749
856
  this.app = (0, express_1.default)();
750
857
  this.runRegistry = new AiHubRunRegistry();
858
+ this.cronHandles = new Map();
751
859
  this.projectPath = options.projectPath || process.cwd();
752
860
  this.preferencesStore = options.preferencesStore || new preferences_1.AiHubPreferencesStore();
753
861
  this.conversationStore = options.conversationStore || new conversation_store_1.AiHubConversationStore();
@@ -770,7 +878,9 @@ class AiHubServer {
770
878
  this.dbService = createDefaultDbService();
771
879
  this.ownsDbService = this.dbService !== undefined;
772
880
  }
773
- this.app.use(express_1.default.json());
881
+ this.deploymentStore = options.deploymentStore ?? new DeploymentStore();
882
+ this.hostConfigStore = options.hostConfigStore ?? new HostConfigStore();
883
+ this.app.use(express_1.default.json({ limit: '10mb' }));
774
884
  if (this.dbService) {
775
885
  const { registerPaymentRoutes } = require('../routes/payment-routes');
776
886
  registerPaymentRoutes(this.app, () => this.paymentRepo ?? null, this.dbService);
@@ -914,6 +1024,8 @@ class AiHubServer {
914
1024
  // so it's known even before the browser is running.
915
1025
  process.env.FRAIM_BROWSER_CDP_ENDPOINT = this.managedBrowser.cdpEndpoint();
916
1026
  process.env.FRAIM_HUB_BASE_URL = `http://127.0.0.1:${port}`;
1027
+ // Issue #578: rehydrate active scheduled deployments from disk.
1028
+ this.rehydrateScheduledDeployments();
917
1029
  // Start HTTPS server when a cert bundle and port are provided.
918
1030
  // Word Online requires HTTPS; the HTTPS server shares the same Express app
919
1031
  // so all routes (including /word-taskpane/*) are available over both protocols.
@@ -953,6 +1065,11 @@ class AiHubServer {
953
1065
  await closeServer(this.httpServer);
954
1066
  this.httpServer = undefined;
955
1067
  }
1068
+ // Issue #578: stop all active scheduled deployments.
1069
+ for (const [, task] of this.cronHandles) {
1070
+ task.stop();
1071
+ }
1072
+ this.cronHandles.clear();
956
1073
  // #521: tear down the shared browser if WE launched it (stop() no-ops on a
957
1074
  // browser the manager owns).
958
1075
  this.managedBrowser.stop();
@@ -978,6 +1095,9 @@ class AiHubServer {
978
1095
  }
979
1096
  const project = (0, catalog_1.summarizeProject)(normalizedProjectPath);
980
1097
  const rawJobs = (0, catalog_1.discoverEmployeeJobs)(normalizedProjectPath);
1098
+ // Issue #566 (R7): jobs already carry `personalized` from catalog discovery
1099
+ // (true for the fraim/personalized-employee layer). The Hub renders a plain
1100
+ // "Personalized" marking from that flag — no author/attribution is tracked.
981
1101
  const jobs = rawJobs.map((job) => ({
982
1102
  ...job,
983
1103
  requiredPersonaKey: getProtectedPersonaForHubJob(job.id),
@@ -1045,6 +1165,8 @@ class AiHubServer {
1045
1165
  reviewHandoff: run.reviewHandoff || null,
1046
1166
  compareMode: run.runRole === 'fraim' && run.compareRunId ? 'ab' : undefined,
1047
1167
  compareRunId: run.compareRunId || null,
1168
+ // Issue #578: preserve trigger source so the UI can render the chip.
1169
+ sourceTrigger: run.sourceTrigger,
1048
1170
  };
1049
1171
  }
1050
1172
  persistRunConversation(run, activeId) {
@@ -1718,6 +1840,8 @@ class AiHubServer {
1718
1840
  personaKey: getProtectedPersonaForHubJob(jobId),
1719
1841
  // Issue #442: mark this as the FRAIM side of an A/B pair when applicable.
1720
1842
  ...(compareMode === 'ab' ? { runRole: 'fraim' } : {}),
1843
+ // #0: trigger source — defaults to 'manager' when not provided by the caller.
1844
+ sourceTrigger: req.body.sourceTrigger ?? 'manager',
1721
1845
  };
1722
1846
  this.runRegistry.create(run, {});
1723
1847
  this.persistRunConversation(run, run.conversationId || run.id);
@@ -2111,6 +2235,42 @@ class AiHubServer {
2111
2235
  res.status(400).json({ error: error instanceof Error ? error.message : 'Could not continue Direct run.' });
2112
2236
  }
2113
2237
  });
2238
+ // GET /api/ai-hub/runs — list runs for cross-host polling and the UI ledger.
2239
+ // Query params: status (filter), sourceTrigger (filter), limit (default 100, max 200).
2240
+ // Must be registered BEFORE the :runId route to avoid 'runs' being matched as a runId.
2241
+ this.app.get('/api/ai-hub/runs', (req, res) => {
2242
+ const statusFilter = typeof req.query.status === 'string' ? req.query.status : null;
2243
+ const triggerFilter = typeof req.query.sourceTrigger === 'string' ? req.query.sourceTrigger : null;
2244
+ const limit = Math.min(200, parseInt(String(req.query.limit || '100'), 10) || 100);
2245
+ let runs = this.runRegistry.all();
2246
+ if (statusFilter)
2247
+ runs = runs.filter((r) => r.status === statusFilter);
2248
+ if (triggerFilter)
2249
+ runs = runs.filter((r) => (r.sourceTrigger ?? 'manager') === triggerFilter);
2250
+ runs = runs.sort((a, b) => b.updatedAt.localeCompare(a.updatedAt)).slice(0, limit);
2251
+ return res.json(runs.map((r) => this.enrichRunForResponse(r)));
2252
+ });
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.
2114
2274
  this.app.get('/api/ai-hub/runs/:runId', (req, res) => {
2115
2275
  const run = this.runRegistry.get(req.params.runId);
2116
2276
  if (!run) {
@@ -2118,6 +2278,151 @@ class AiHubServer {
2118
2278
  }
2119
2279
  return res.json(this.enrichRunForResponse(run));
2120
2280
  });
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 ───────────────────────────────────────────────────────
2121
2426
  // -------------------------------------------------------------------------
2122
2427
  // Issue #489: POST /api/trigger
2123
2428
  // Stable API endpoint for extension surfaces (Office add-ins, browser
@@ -2216,6 +2521,117 @@ class AiHubServer {
2216
2521
  process.env.FRAIM_BROWSER_CDP_ENDPOINT = result.endpoint;
2217
2522
  return { endpoint: result.endpoint, reused: result.reused, channel: result.channel };
2218
2523
  }
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 ───────────────────────────────────────────────
2219
2635
  // Issue #347 — assemble the read-side projection of a run. Stages are
2220
2636
  // derived from job frontmatter + visited phases; totalDurationMs ticks
2221
2637
  // forward while the run is still running so the UI's totals line