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.
- package/dist/src/ai-hub/catalog.js +28 -14
- package/dist/src/ai-hub/server.js +10 -403
- package/dist/src/cli/commands/init-project.js +1 -98
- package/dist/src/cli/commands/manager.js +40 -0
- package/dist/src/cli/commands/sync.js +17 -21
- package/dist/src/cli/fraim.js +2 -0
- package/dist/src/cli/utils/github-workflow-sync.js +12 -146
- package/dist/src/cli/utils/manager-pack-sync.js +188 -0
- package/dist/src/cli/utils/manager-publish.js +76 -0
- package/dist/src/cli/utils/user-config.js +20 -0
- package/dist/src/core/fraim-config-schema.generated.js +85 -10
- package/dist/src/core/manager-pack.js +26 -0
- package/dist/src/first-run/install-state.js +1 -0
- package/dist/src/first-run/server.js +9 -0
- package/dist/src/first-run/session-service.js +117 -23
- package/dist/src/first-run/types.js +2 -5
- package/dist/src/local-mcp-server/learning-context-builder.js +45 -8
- package/dist/src/local-mcp-server/stdio-server.js +28 -0
- package/index.js +1 -1
- package/package.json +1 -2
- package/public/ai-hub/index.html +0 -81
- package/public/ai-hub/powerpoint-taskpane/index.html +236 -236
- package/public/ai-hub/powerpoint-taskpane/manifest.xml +29 -29
- package/public/ai-hub/script.js +3 -219
- package/public/ai-hub/styles.css +8 -36
- package/public/first-run/index.html +1 -1
- package/public/first-run/script.js +459 -530
- package/public/first-run/styles.css +288 -73
- package/public/portfolio/ashley.html +1 -1
- package/public/portfolio/casey.html +1 -1
- package/public/portfolio/celia.html +1 -1
- package/public/portfolio/gautam.html +1 -1
- package/public/portfolio/hari.html +1 -1
- package/public/portfolio/maestro.html +1 -1
- package/public/portfolio/mandy.html +1 -1
- package/public/portfolio/pam.html +6 -6
- package/public/portfolio/qasm.html +1 -1
- package/public/portfolio/sade.html +1 -1
- package/public/portfolio/sam.html +1 -1
- package/public/portfolio/swen.html +6 -6
- package/dist/src/ai-hub/word-sideload.js +0 -95
- package/dist/src/cli/commands/test-mcp.js +0 -171
- package/dist/src/cli/setup/first-run.js +0 -242
- package/dist/src/config/ai-manager-hiring.js +0 -121
- package/dist/src/config/compat.js +0 -16
- package/dist/src/config/feature-flags.js +0 -25
- package/dist/src/config/persona-capability-bundles.js +0 -273
- package/dist/src/config/persona-hiring.js +0 -270
- package/dist/src/config/portfolio-slug-overrides.js +0 -17
- package/dist/src/config/pricing.js +0 -37
- package/dist/src/config/stripe.js +0 -43
- package/dist/src/core/config-writer.js +0 -75
- package/dist/src/core/utils/job-aliases.js +0 -47
- 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
|
|
20
|
-
// and the personalized override.
|
|
21
|
-
//
|
|
22
|
-
// - <project>/fraim/ai-employee/jobs/<category>/
|
|
23
|
-
// - <project>/fraim/personalized-employee/jobs/<category>/
|
|
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,
|
|
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,
|
|
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,
|
|
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 =
|
|
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.
|
|
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
|
|
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:
|
|
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
|