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.
- package/dist/src/ai-hub/catalog.js +20 -27
- package/dist/src/ai-hub/server.js +418 -2
- package/dist/src/ai-hub/word-sideload.js +95 -0
- package/dist/src/cli/commands/org.js +40 -0
- package/dist/src/cli/commands/test-mcp.js +171 -0
- package/dist/src/cli/fraim.js +2 -0
- package/dist/src/cli/setup/first-run.js +242 -0
- package/dist/src/cli/utils/org-publish.js +98 -0
- package/dist/src/config/ai-manager-hiring.js +121 -0
- package/dist/src/config/compat.js +16 -0
- package/dist/src/config/feature-flags.js +25 -0
- package/dist/src/config/persona-capability-bundles.js +273 -0
- package/dist/src/config/persona-hiring.js +270 -0
- package/dist/src/config/portfolio-slug-overrides.js +17 -0
- package/dist/src/config/pricing.js +37 -0
- package/dist/src/config/stripe.js +43 -0
- package/dist/src/core/config-loader.js +9 -5
- package/dist/src/core/config-writer.js +75 -0
- package/dist/src/core/fraim-config-schema.generated.js +0 -21
- package/dist/src/core/utils/job-aliases.js +47 -0
- package/dist/src/core/utils/local-registry-resolver.js +8 -1
- package/dist/src/core/utils/workflow-parser.js +174 -0
- package/index.js +1 -1
- package/package.json +5 -1
- package/public/ai-hub/index.html +81 -0
- package/public/ai-hub/powerpoint-taskpane/index.html +236 -236
- package/public/ai-hub/powerpoint-taskpane/manifest.xml +29 -29
- package/public/ai-hub/review.css +13 -0
- package/public/ai-hub/script.js +414 -4
- package/public/ai-hub/styles.css +56 -0
- package/public/first-run/styles.css +73 -73
- package/public/portfolio/ashley.html +523 -0
- package/public/portfolio/auditya.html +83 -0
- package/public/portfolio/banke.html +83 -0
- package/public/portfolio/beza.html +659 -0
- package/public/portfolio/careena.html +632 -0
- package/public/portfolio/casey.html +568 -0
- package/public/portfolio/celia.html +490 -0
- package/public/portfolio/deidre.html +642 -0
- package/public/portfolio/gautam.html +597 -0
- package/public/portfolio/hari.html +469 -0
- package/public/portfolio/huxley.html +1354 -0
- package/public/portfolio/index.html +741 -0
- package/public/portfolio/maestro.html +518 -0
- package/public/portfolio/mandy.html +590 -0
- package/public/portfolio/mona.html +597 -0
- package/public/portfolio/pam.html +887 -0
- package/public/portfolio/procella.html +107 -0
- package/public/portfolio/qasm.html +569 -0
- package/public/portfolio/ricardo.html +489 -0
- package/public/portfolio/sade.html +560 -0
- package/public/portfolio/sam.html +654 -0
- package/public/portfolio/sechar.html +580 -0
- package/public/portfolio/sreya.html +599 -0
- 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
|
|
17
|
-
// order. Later entries win on {categoryId, jobId} collision.
|
|
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
|
-
//
|
|
21
|
-
//
|
|
22
|
-
//
|
|
23
|
-
// -
|
|
24
|
-
//
|
|
25
|
-
//
|
|
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
|
-
{
|
|
31
|
-
{
|
|
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
|
|
29
|
+
// Manager templates use the matching layer model.
|
|
35
30
|
const MANAGER_JOB_LAYERS = [
|
|
36
|
-
{
|
|
37
|
-
{
|
|
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
|
|
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.
|
|
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
|