fraim 2.0.203 → 2.0.205
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/README.md +3 -3
- package/bin/fraim.js +8 -18
- package/dist/src/cli/commands/first-run.js +2 -37
- package/dist/src/cli/doctor/checks/mcp-connectivity-checks.js +1 -1
- package/dist/src/cli/fraim.js +0 -2
- package/dist/src/cli/setup/ide-invocation-surfaces.js +6 -6
- package/dist/src/cli/utils/managed-agent-paths.js +76 -6
- package/dist/src/cli/utils/version-utils.js +2 -2
- package/dist/src/config/persona-capability-bundles.js +3 -3
- package/dist/src/core/job-phases.js +168 -0
- package/dist/src/core/utils/ports.js +32 -0
- package/dist/src/core/utils/stub-generator.js +24 -2
- package/dist/src/first-run/session-service.js +106 -24
- package/dist/src/local-mcp-server/learning-context-builder.js +1 -1
- package/dist/src/local-mcp-server/stdio-server.js +41 -9
- package/dist/src/routes/analytics.js +2 -2
- package/dist/src/services/registry-service.js +2 -2
- package/index.js +1 -1
- package/package.json +61 -181
- package/dist/src/ai-hub/catalog.js +0 -406
- package/dist/src/ai-hub/cert-store.js +0 -70
- package/dist/src/ai-hub/conversation-store.js +0 -359
- package/dist/src/ai-hub/desktop-main.js +0 -449
- package/dist/src/ai-hub/hosts.js +0 -1664
- package/dist/src/ai-hub/hub-latest-version.js +0 -52
- package/dist/src/ai-hub/hub-launch-decision.js +0 -57
- package/dist/src/ai-hub/hub-runtime-file.js +0 -43
- package/dist/src/ai-hub/managed-browser.js +0 -269
- package/dist/src/ai-hub/manager-turns.js +0 -64
- package/dist/src/ai-hub/office-sideload.js +0 -156
- package/dist/src/ai-hub/openclaw-bridge.js +0 -250
- package/dist/src/ai-hub/preferences.js +0 -201
- package/dist/src/ai-hub/remote-hub-gateway.js +0 -88
- package/dist/src/ai-hub/server.js +0 -3827
- package/dist/src/ai-hub/types.js +0 -2
- package/dist/src/ai-hub/word-sideload.js +0 -95
- package/dist/src/cli/commands/hub.js +0 -238
- package/dist/src/cli/commands/test-mcp.js +0 -171
- package/dist/src/cli/setup/first-run.js +0 -242
- 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
- package/dist/src/services/email-service-clean.js +0 -782
- package/extensions/office-word/favicon.ico +0 -0
- package/extensions/office-word/icon-64.png +0 -0
- package/extensions/office-word/manifest.xml +0 -33
- package/extensions/office-word/taskpane.html +0 -242
- package/public/ai-hub/index.html +0 -894
- package/public/ai-hub/powerpoint-taskpane/icon-64.png +0 -0
- package/public/ai-hub/powerpoint-taskpane/index.html +0 -236
- package/public/ai-hub/powerpoint-taskpane/manifest.xml +0 -30
- package/public/ai-hub/review.css +0 -381
- package/public/ai-hub/script.js +0 -11015
- package/public/ai-hub/styles.css +0 -4873
- package/public/portfolio/ashley.html +0 -523
- package/public/portfolio/auditya.html +0 -83
- package/public/portfolio/banke.html +0 -83
- package/public/portfolio/beza.html +0 -659
- package/public/portfolio/careena.html +0 -632
- package/public/portfolio/casey.html +0 -568
- package/public/portfolio/celia.html +0 -490
- package/public/portfolio/deidre.html +0 -642
- package/public/portfolio/gautam.html +0 -597
- package/public/portfolio/hari.html +0 -469
- package/public/portfolio/huxley.html +0 -1354
- package/public/portfolio/index.html +0 -741
- package/public/portfolio/maestro.html +0 -518
- package/public/portfolio/mandy.html +0 -590
- package/public/portfolio/mona.html +0 -597
- package/public/portfolio/pam.html +0 -887
- package/public/portfolio/procella.html +0 -107
- package/public/portfolio/qasm.html +0 -569
- package/public/portfolio/ricardo.html +0 -489
- package/public/portfolio/sade.html +0 -560
- package/public/portfolio/sam.html +0 -654
- package/public/portfolio/sechar.html +0 -580
- package/public/portfolio/sreya.html +0 -599
- package/public/portfolio/swen.html +0 -601
- /package/dist/src/{ai-hub → core}/brand-store.js +0 -0
|
@@ -1,3827 +0,0 @@
|
|
|
1
|
-
"use strict";
|
|
2
|
-
var __createBinding = (this && this.__createBinding) || (Object.create ? (function(o, m, k, k2) {
|
|
3
|
-
if (k2 === undefined) k2 = k;
|
|
4
|
-
var desc = Object.getOwnPropertyDescriptor(m, k);
|
|
5
|
-
if (!desc || ("get" in desc ? !m.__esModule : desc.writable || desc.configurable)) {
|
|
6
|
-
desc = { enumerable: true, get: function() { return m[k]; } };
|
|
7
|
-
}
|
|
8
|
-
Object.defineProperty(o, k2, desc);
|
|
9
|
-
}) : (function(o, m, k, k2) {
|
|
10
|
-
if (k2 === undefined) k2 = k;
|
|
11
|
-
o[k2] = m[k];
|
|
12
|
-
}));
|
|
13
|
-
var __setModuleDefault = (this && this.__setModuleDefault) || (Object.create ? (function(o, v) {
|
|
14
|
-
Object.defineProperty(o, "default", { enumerable: true, value: v });
|
|
15
|
-
}) : function(o, v) {
|
|
16
|
-
o["default"] = v;
|
|
17
|
-
});
|
|
18
|
-
var __importStar = (this && this.__importStar) || (function () {
|
|
19
|
-
var ownKeys = function(o) {
|
|
20
|
-
ownKeys = Object.getOwnPropertyNames || function (o) {
|
|
21
|
-
var ar = [];
|
|
22
|
-
for (var k in o) if (Object.prototype.hasOwnProperty.call(o, k)) ar[ar.length] = k;
|
|
23
|
-
return ar;
|
|
24
|
-
};
|
|
25
|
-
return ownKeys(o);
|
|
26
|
-
};
|
|
27
|
-
return function (mod) {
|
|
28
|
-
if (mod && mod.__esModule) return mod;
|
|
29
|
-
var result = {};
|
|
30
|
-
if (mod != null) for (var k = ownKeys(mod), i = 0; i < k.length; i++) if (k[i] !== "default") __createBinding(result, mod, k[i]);
|
|
31
|
-
__setModuleDefault(result, mod);
|
|
32
|
-
return result;
|
|
33
|
-
};
|
|
34
|
-
})();
|
|
35
|
-
var __importDefault = (this && this.__importDefault) || function (mod) {
|
|
36
|
-
return (mod && mod.__esModule) ? mod : { "default": mod };
|
|
37
|
-
};
|
|
38
|
-
Object.defineProperty(exports, "__esModule", { value: true });
|
|
39
|
-
exports.AiHubServer = exports.HostConfigStore = exports.DeploymentStore = void 0;
|
|
40
|
-
exports.configureFraimForHubAgent = configureFraimForHubAgent;
|
|
41
|
-
exports.buildOpenFileInvocation = buildOpenFileInvocation;
|
|
42
|
-
exports.findAvailablePort = findAvailablePort;
|
|
43
|
-
exports.findAvailablePortExcluding = findAvailablePortExcluding;
|
|
44
|
-
const express_1 = __importDefault(require("express"));
|
|
45
|
-
const path_1 = __importDefault(require("path"));
|
|
46
|
-
const fs_1 = __importDefault(require("fs"));
|
|
47
|
-
const net_1 = __importDefault(require("net"));
|
|
48
|
-
const os_1 = __importDefault(require("os"));
|
|
49
|
-
const crypto_1 = require("crypto");
|
|
50
|
-
const child_process_1 = require("child_process");
|
|
51
|
-
const https_1 = __importDefault(require("https"));
|
|
52
|
-
const types_1 = require("../first-run/types");
|
|
53
|
-
const learning_context_builder_1 = require("../local-mcp-server/learning-context-builder");
|
|
54
|
-
const brand_store_1 = require("./brand-store");
|
|
55
|
-
const project_fraim_paths_1 = require("../core/utils/project-fraim-paths");
|
|
56
|
-
const catalog_1 = require("./catalog");
|
|
57
|
-
const agent_token_prices_1 = require("../local-mcp-server/agent-token-prices");
|
|
58
|
-
const hosts_1 = require("./hosts");
|
|
59
|
-
const manager_turns_1 = require("./manager-turns");
|
|
60
|
-
const preferences_1 = require("./preferences");
|
|
61
|
-
const conversation_store_1 = require("./conversation-store");
|
|
62
|
-
const remote_hub_gateway_1 = require("./remote-hub-gateway");
|
|
63
|
-
const managed_browser_1 = require("./managed-browser");
|
|
64
|
-
const managed_agent_paths_1 = require("../cli/utils/managed-agent-paths");
|
|
65
|
-
const user_config_1 = require("../cli/utils/user-config");
|
|
66
|
-
const version_utils_1 = require("../cli/utils/version-utils");
|
|
67
|
-
const hub_latest_version_1 = require("./hub-latest-version");
|
|
68
|
-
const semver = __importStar(require("semver"));
|
|
69
|
-
let personaHiringModule;
|
|
70
|
-
let managerHiringModule;
|
|
71
|
-
function loadPersonaHiringModule() {
|
|
72
|
-
const cached = personaHiringModule;
|
|
73
|
-
if (cached !== undefined) {
|
|
74
|
-
return cached;
|
|
75
|
-
}
|
|
76
|
-
try {
|
|
77
|
-
// Server deployments include the persona catalog. The npm client package
|
|
78
|
-
// intentionally does not, so importing ai-hub/server must not require it.
|
|
79
|
-
// eslint-disable-next-line @typescript-eslint/no-var-requires
|
|
80
|
-
const loaded = require('../config/persona-hiring');
|
|
81
|
-
personaHiringModule = loaded;
|
|
82
|
-
return loaded;
|
|
83
|
-
}
|
|
84
|
-
catch {
|
|
85
|
-
personaHiringModule = null;
|
|
86
|
-
return null;
|
|
87
|
-
}
|
|
88
|
-
}
|
|
89
|
-
function loadManagerHiringModule() {
|
|
90
|
-
const cached = managerHiringModule;
|
|
91
|
-
if (cached !== undefined) {
|
|
92
|
-
return cached;
|
|
93
|
-
}
|
|
94
|
-
try {
|
|
95
|
-
// Server deployments include the manager-hiring catalog. The npm client
|
|
96
|
-
// package intentionally does not, so Hub bootstrap must not require it.
|
|
97
|
-
// eslint-disable-next-line @typescript-eslint/no-var-requires
|
|
98
|
-
const loaded = require('../config/ai-manager-hiring');
|
|
99
|
-
managerHiringModule = loaded;
|
|
100
|
-
return loaded;
|
|
101
|
-
}
|
|
102
|
-
catch {
|
|
103
|
-
managerHiringModule = null;
|
|
104
|
-
return null;
|
|
105
|
-
}
|
|
106
|
-
}
|
|
107
|
-
function loadPersonaCapabilityModule() {
|
|
108
|
-
try {
|
|
109
|
-
// Server deployments include the persona catalog. The npm client package
|
|
110
|
-
// intentionally does not, so Hub setup must degrade without loading it.
|
|
111
|
-
// eslint-disable-next-line @typescript-eslint/no-var-requires
|
|
112
|
-
return require('../config/persona-capability-bundles');
|
|
113
|
-
}
|
|
114
|
-
catch {
|
|
115
|
-
return null;
|
|
116
|
-
}
|
|
117
|
-
}
|
|
118
|
-
function getProtectedPersonaForHubJob(jobName) {
|
|
119
|
-
return loadPersonaCapabilityModule()?.getProtectedPersonaForJob(jobName) ?? null;
|
|
120
|
-
}
|
|
121
|
-
const FRAIM_INTERNAL_JOB_IDS = new Set([
|
|
122
|
-
'contribute-to-fraim',
|
|
123
|
-
'create-registry-asset',
|
|
124
|
-
'extract-ashley-learnings',
|
|
125
|
-
'file-fraim-issue',
|
|
126
|
-
'praise-fraim',
|
|
127
|
-
'run-on-remote-hub',
|
|
128
|
-
'setup-remote-hub',
|
|
129
|
-
'update-registry-override',
|
|
130
|
-
]);
|
|
131
|
-
function listHubPersonaBundles() {
|
|
132
|
-
return loadPersonaCapabilityModule()?.listPersonaCapabilityBundles() ?? [];
|
|
133
|
-
}
|
|
134
|
-
function buildHubPersonaHireUrl(personaKey, hireMode = 'job') {
|
|
135
|
-
const params = new URLSearchParams({ persona: personaKey, mode: hireMode });
|
|
136
|
-
return `/pricing?${params.toString()}`;
|
|
137
|
-
}
|
|
138
|
-
function buildLocalHubReturnUrl(req, surface) {
|
|
139
|
-
const proto = req.protocol || 'http';
|
|
140
|
-
const host = req.get('host') || '127.0.0.1';
|
|
141
|
-
const target = new URL('/ai-hub/', `${proto}://${host}`);
|
|
142
|
-
target.searchParams.set('connected', surface);
|
|
143
|
-
return target.toString();
|
|
144
|
-
}
|
|
145
|
-
function buildHostedAuthUrl(surface, redirectTo, hubReturn) {
|
|
146
|
-
const target = new URL('/auth/sign-in.html', (0, remote_hub_gateway_1.resolveFraimRemoteUrl)());
|
|
147
|
-
target.searchParams.set('surface', surface);
|
|
148
|
-
target.searchParams.set('redirect_to', redirectTo);
|
|
149
|
-
if (hubReturn)
|
|
150
|
-
target.searchParams.set('hub_return', hubReturn);
|
|
151
|
-
return target.toString();
|
|
152
|
-
}
|
|
153
|
-
function buildHostedPathUrl(pathname, queryString) {
|
|
154
|
-
const target = new URL(pathname, (0, remote_hub_gateway_1.resolveFraimRemoteUrl)());
|
|
155
|
-
target.search = queryString;
|
|
156
|
-
return target.toString();
|
|
157
|
-
}
|
|
158
|
-
function buildHubPersonaAvatarUrl(personaKey) {
|
|
159
|
-
return loadPersonaHiringModule()?.buildPersonaAvatarUrl(personaKey) ?? '';
|
|
160
|
-
}
|
|
161
|
-
function buildHubManagerHiringCatalog() {
|
|
162
|
-
return loadManagerHiringModule()?.buildManagerHiringCatalog() ?? {
|
|
163
|
-
qualities: [],
|
|
164
|
-
services: [],
|
|
165
|
-
roles: {},
|
|
166
|
-
};
|
|
167
|
-
}
|
|
168
|
-
class AiHubRunRegistry {
|
|
169
|
-
constructor() {
|
|
170
|
-
this.runs = new Map();
|
|
171
|
-
this.children = new Map();
|
|
172
|
-
}
|
|
173
|
-
listLatest() {
|
|
174
|
-
return [...this.runs.values()].sort((a, b) => b.updatedAt.localeCompare(a.updatedAt))[0];
|
|
175
|
-
}
|
|
176
|
-
get(runId) {
|
|
177
|
-
return this.runs.get(runId);
|
|
178
|
-
}
|
|
179
|
-
all() {
|
|
180
|
-
return [...this.runs.values()];
|
|
181
|
-
}
|
|
182
|
-
create(run, child) {
|
|
183
|
-
this.runs.set(run.id, run);
|
|
184
|
-
this.children.set(run.id, child);
|
|
185
|
-
return run;
|
|
186
|
-
}
|
|
187
|
-
attachChildIfRunning(runId, child) {
|
|
188
|
-
const run = this.runs.get(runId);
|
|
189
|
-
if (!run || run.status !== 'running')
|
|
190
|
-
return false;
|
|
191
|
-
this.children.set(runId, child);
|
|
192
|
-
return true;
|
|
193
|
-
}
|
|
194
|
-
update(runId, updater) {
|
|
195
|
-
const run = this.runs.get(runId);
|
|
196
|
-
if (!run) {
|
|
197
|
-
throw new Error(`Run ${runId} not found`);
|
|
198
|
-
}
|
|
199
|
-
const priorStatus = run.status;
|
|
200
|
-
updater(run);
|
|
201
|
-
const now = new Date().toISOString();
|
|
202
|
-
// Issue #347: when status flipped, accumulate the previous status's
|
|
203
|
-
// duration into the appropriate working/waiting bucket. lastStatusChangeAt
|
|
204
|
-
// is the wall-clock timestamp of the most recent flip; on the first
|
|
205
|
-
// call it falls back to createdAt.
|
|
206
|
-
if (run.status !== priorStatus) {
|
|
207
|
-
const lastAt = run.lastStatusChangeAt || run.createdAt;
|
|
208
|
-
const elapsedMs = Date.parse(now) - Date.parse(lastAt);
|
|
209
|
-
if (Number.isFinite(elapsedMs) && elapsedMs > 0) {
|
|
210
|
-
run.totals = run.totals || emptyTotals();
|
|
211
|
-
if (priorStatus === 'running') {
|
|
212
|
-
run.totals.workingDurationMs += elapsedMs;
|
|
213
|
-
}
|
|
214
|
-
else {
|
|
215
|
-
run.totals.waitingDurationMs += elapsedMs;
|
|
216
|
-
}
|
|
217
|
-
}
|
|
218
|
-
run.lastStatusChangeAt = now;
|
|
219
|
-
}
|
|
220
|
-
run.updatedAt = now;
|
|
221
|
-
return run;
|
|
222
|
-
}
|
|
223
|
-
dispose(runId) {
|
|
224
|
-
this.children.delete(runId);
|
|
225
|
-
}
|
|
226
|
-
// #521: terminate the agent process for a run (manager clicked Stop). Returns
|
|
227
|
-
// true if a live child was signalled. The child's onExit handler then fires and
|
|
228
|
-
// parks the run in its waiting state.
|
|
229
|
-
stop(runId) {
|
|
230
|
-
const child = this.children.get(runId);
|
|
231
|
-
if (!child || typeof child.kill !== 'function')
|
|
232
|
-
return false;
|
|
233
|
-
try {
|
|
234
|
-
child.kill();
|
|
235
|
-
return true;
|
|
236
|
-
}
|
|
237
|
-
catch {
|
|
238
|
-
return false;
|
|
239
|
-
}
|
|
240
|
-
}
|
|
241
|
-
}
|
|
242
|
-
function safeHttpUrl(value) {
|
|
243
|
-
if (typeof value !== 'string' || value.trim().length === 0)
|
|
244
|
-
return null;
|
|
245
|
-
try {
|
|
246
|
-
const parsed = new URL(value.trim());
|
|
247
|
-
if (parsed.protocol !== 'http:' && parsed.protocol !== 'https:')
|
|
248
|
-
return null;
|
|
249
|
-
return parsed.href;
|
|
250
|
-
}
|
|
251
|
-
catch {
|
|
252
|
-
return null;
|
|
253
|
-
}
|
|
254
|
-
}
|
|
255
|
-
// ─── Issue #578: Deployment + Host stores ─────────────────────────────────────
|
|
256
|
-
const VALID_EMPLOYEE_IDS = ['codex', 'claude', 'gemini', 'copilot'];
|
|
257
|
-
function startSessionSeedForHost(hostId, runId) {
|
|
258
|
-
return hostId === 'gemini' ? undefined : runId;
|
|
259
|
-
}
|
|
260
|
-
class DeploymentStore {
|
|
261
|
-
constructor(filePath) {
|
|
262
|
-
this.filePath = filePath ?? path_1.default.join(getUserHubDir(), 'hub-deployments.json');
|
|
263
|
-
}
|
|
264
|
-
load() {
|
|
265
|
-
try {
|
|
266
|
-
if (!fs_1.default.existsSync(this.filePath))
|
|
267
|
-
return [];
|
|
268
|
-
return JSON.parse(fs_1.default.readFileSync(this.filePath, 'utf8'));
|
|
269
|
-
}
|
|
270
|
-
catch {
|
|
271
|
-
return [];
|
|
272
|
-
}
|
|
273
|
-
}
|
|
274
|
-
save(deployments) {
|
|
275
|
-
fs_1.default.mkdirSync(path_1.default.dirname(this.filePath), { recursive: true });
|
|
276
|
-
fs_1.default.writeFileSync(this.filePath, JSON.stringify(deployments, null, 2));
|
|
277
|
-
}
|
|
278
|
-
create(deployment) {
|
|
279
|
-
const list = this.load();
|
|
280
|
-
list.push(deployment);
|
|
281
|
-
this.save(list);
|
|
282
|
-
return deployment;
|
|
283
|
-
}
|
|
284
|
-
update(id, updater) {
|
|
285
|
-
const list = this.load();
|
|
286
|
-
const dep = list.find((d) => d.id === id);
|
|
287
|
-
if (!dep)
|
|
288
|
-
return false;
|
|
289
|
-
updater(dep);
|
|
290
|
-
dep.updatedAt = new Date().toISOString();
|
|
291
|
-
this.save(list);
|
|
292
|
-
return true;
|
|
293
|
-
}
|
|
294
|
-
delete(id) {
|
|
295
|
-
const list = this.load();
|
|
296
|
-
const next = list.filter((d) => d.id !== id);
|
|
297
|
-
if (next.length === list.length)
|
|
298
|
-
return false;
|
|
299
|
-
this.save(next);
|
|
300
|
-
return true;
|
|
301
|
-
}
|
|
302
|
-
}
|
|
303
|
-
exports.DeploymentStore = DeploymentStore;
|
|
304
|
-
class HostConfigStore {
|
|
305
|
-
constructor(filePath) {
|
|
306
|
-
this.filePath = filePath ?? path_1.default.join(getUserHubDir(), 'hub-hosts.json');
|
|
307
|
-
}
|
|
308
|
-
load() {
|
|
309
|
-
try {
|
|
310
|
-
if (!fs_1.default.existsSync(this.filePath))
|
|
311
|
-
return [];
|
|
312
|
-
return JSON.parse(fs_1.default.readFileSync(this.filePath, 'utf8'));
|
|
313
|
-
}
|
|
314
|
-
catch {
|
|
315
|
-
return [];
|
|
316
|
-
}
|
|
317
|
-
}
|
|
318
|
-
save(hosts) {
|
|
319
|
-
fs_1.default.mkdirSync(path_1.default.dirname(this.filePath), { recursive: true });
|
|
320
|
-
fs_1.default.writeFileSync(this.filePath, JSON.stringify(hosts, null, 2));
|
|
321
|
-
}
|
|
322
|
-
add(host) {
|
|
323
|
-
const list = this.load();
|
|
324
|
-
list.push(host);
|
|
325
|
-
this.save(list);
|
|
326
|
-
return host;
|
|
327
|
-
}
|
|
328
|
-
delete(id) {
|
|
329
|
-
const list = this.load();
|
|
330
|
-
const next = list.filter((h) => h.id !== id);
|
|
331
|
-
if (next.length === list.length)
|
|
332
|
-
return false;
|
|
333
|
-
this.save(next);
|
|
334
|
-
return true;
|
|
335
|
-
}
|
|
336
|
-
}
|
|
337
|
-
exports.HostConfigStore = HostConfigStore;
|
|
338
|
-
async function pingHost(host) {
|
|
339
|
-
const start = Date.now();
|
|
340
|
-
try {
|
|
341
|
-
const resp = await fetch(`${host.url.replace(/\/$/, '')}/health`, {
|
|
342
|
-
signal: AbortSignal.timeout(5000),
|
|
343
|
-
headers: host.authToken ? { 'X-Hub-Auth': host.authToken } : {},
|
|
344
|
-
});
|
|
345
|
-
const latencyMs = Date.now() - start;
|
|
346
|
-
return {
|
|
347
|
-
id: host.id,
|
|
348
|
-
label: host.label,
|
|
349
|
-
url: host.url,
|
|
350
|
-
status: resp.ok ? 'online' : 'degraded',
|
|
351
|
-
latencyMs,
|
|
352
|
-
lastPingAt: new Date().toISOString(),
|
|
353
|
-
};
|
|
354
|
-
}
|
|
355
|
-
catch {
|
|
356
|
-
return {
|
|
357
|
-
id: host.id,
|
|
358
|
-
label: host.label,
|
|
359
|
-
url: host.url,
|
|
360
|
-
status: 'offline',
|
|
361
|
-
latencyMs: null,
|
|
362
|
-
lastPingAt: new Date().toISOString(),
|
|
363
|
-
};
|
|
364
|
-
}
|
|
365
|
-
}
|
|
366
|
-
function normalizeReviewArtifact(raw, index = 0) {
|
|
367
|
-
if (!raw || typeof raw !== 'object')
|
|
368
|
-
return null;
|
|
369
|
-
const value = raw;
|
|
370
|
-
const artifactPath = typeof value.path === 'string' && value.path.trim().length > 0 ? value.path.trim() : null;
|
|
371
|
-
const url = safeHttpUrl(value.url);
|
|
372
|
-
const type = typeof value.type === 'string' && value.type.trim().length > 0 ? value.type.trim() : 'file';
|
|
373
|
-
const label = typeof value.label === 'string' && value.label.trim().length > 0
|
|
374
|
-
? value.label.trim()
|
|
375
|
-
: artifactPath
|
|
376
|
-
? artifactPath.split(/[\\/]/).filter(Boolean).pop() || `Artifact ${index + 1}`
|
|
377
|
-
: url || `Artifact ${index + 1}`;
|
|
378
|
-
if (!artifactPath && !url)
|
|
379
|
-
return null;
|
|
380
|
-
return { type, label, path: artifactPath, url };
|
|
381
|
-
}
|
|
382
|
-
function normalizeReviewHandoff(raw) {
|
|
383
|
-
if (!raw || typeof raw !== 'object')
|
|
384
|
-
return null;
|
|
385
|
-
const value = raw;
|
|
386
|
-
if (typeof value.reviewRequired !== 'boolean')
|
|
387
|
-
return null;
|
|
388
|
-
const artifacts = Array.isArray(value.artifacts)
|
|
389
|
-
? value.artifacts.map((artifact, index) => normalizeReviewArtifact(artifact, index)).filter((artifact) => Boolean(artifact))
|
|
390
|
-
: [];
|
|
391
|
-
const rawTarget = value.reviewTarget && typeof value.reviewTarget === 'object' ? value.reviewTarget : null;
|
|
392
|
-
const targetType = rawTarget && typeof rawTarget.type === 'string' ? rawTarget.type.trim() : '';
|
|
393
|
-
const targetLabel = rawTarget && typeof rawTarget.label === 'string' && rawTarget.label.trim().length > 0
|
|
394
|
-
? rawTarget.label.trim()
|
|
395
|
-
: '';
|
|
396
|
-
if (!value.reviewRequired) {
|
|
397
|
-
return {
|
|
398
|
-
reviewRequired: false,
|
|
399
|
-
reviewTarget: rawTarget ? { type: targetType || 'none', label: targetLabel || 'Completed work' } : null,
|
|
400
|
-
artifacts,
|
|
401
|
-
summary: typeof value.summary === 'string' ? value.summary.trim() : '',
|
|
402
|
-
feedbackMode: typeof value.feedbackMode === 'string' ? value.feedbackMode.trim() : '',
|
|
403
|
-
};
|
|
404
|
-
}
|
|
405
|
-
if (targetType === 'pull_request') {
|
|
406
|
-
const url = safeHttpUrl(rawTarget?.url);
|
|
407
|
-
if (!url)
|
|
408
|
-
return null;
|
|
409
|
-
if (artifacts.length > 0)
|
|
410
|
-
return null;
|
|
411
|
-
return {
|
|
412
|
-
reviewRequired: true,
|
|
413
|
-
reviewTarget: { type: 'pull_request', label: targetLabel || 'Pull request', url },
|
|
414
|
-
artifacts: [],
|
|
415
|
-
summary: typeof value.summary === 'string' ? value.summary.trim() : '',
|
|
416
|
-
feedbackMode: typeof value.feedbackMode === 'string' ? value.feedbackMode.trim() : 'pull_request_comments',
|
|
417
|
-
};
|
|
418
|
-
}
|
|
419
|
-
const fileArtifacts = artifacts.filter((artifact) => artifact.path && !artifact.url);
|
|
420
|
-
if (targetType === 'artifact_set' && fileArtifacts.length > 0 && fileArtifacts.length === artifacts.length) {
|
|
421
|
-
return {
|
|
422
|
-
reviewRequired: true,
|
|
423
|
-
reviewTarget: { type: 'artifact_set', label: targetLabel || `${artifacts.length} artifact${artifacts.length === 1 ? '' : 's'}` },
|
|
424
|
-
artifacts: fileArtifacts,
|
|
425
|
-
summary: typeof value.summary === 'string' ? value.summary.trim() : '',
|
|
426
|
-
feedbackMode: typeof value.feedbackMode === 'string' ? value.feedbackMode.trim() : 'inline',
|
|
427
|
-
};
|
|
428
|
-
}
|
|
429
|
-
return null;
|
|
430
|
-
}
|
|
431
|
-
const DELEGATION_TASK_STATUSES = new Set([
|
|
432
|
-
'planned',
|
|
433
|
-
'running',
|
|
434
|
-
'submitted',
|
|
435
|
-
'reviewed',
|
|
436
|
-
'blocked',
|
|
437
|
-
'completed',
|
|
438
|
-
'failed',
|
|
439
|
-
]);
|
|
440
|
-
function cleanString(value) {
|
|
441
|
-
return typeof value === 'string' ? value.trim() : '';
|
|
442
|
-
}
|
|
443
|
-
function cleanNullableString(value) {
|
|
444
|
-
const cleaned = cleanString(value);
|
|
445
|
-
return cleaned.length > 0 ? cleaned : null;
|
|
446
|
-
}
|
|
447
|
-
function normalizeDelegationLedger(raw) {
|
|
448
|
-
if (!raw || typeof raw !== 'object')
|
|
449
|
-
return null;
|
|
450
|
-
const value = raw;
|
|
451
|
-
// Accept delegationRequired:true OR presence of goal/objective + tasks/jobs/groups (AI may omit the flag).
|
|
452
|
-
const hasFlag = value.delegationRequired === true;
|
|
453
|
-
const objective = cleanString((value.objective || value.goal));
|
|
454
|
-
if (!hasFlag && !objective)
|
|
455
|
-
return null;
|
|
456
|
-
if (!objective)
|
|
457
|
-
return null;
|
|
458
|
-
// Flatten tasks from multiple schemas:
|
|
459
|
-
// canonical: tasks/jobs: [{title, taskId, personaKey, jobId, instructions, dependsOn}]
|
|
460
|
-
// group-based: groups: [{runs: [{run_id, job, persona, briefing, expected_artifact}]}]
|
|
461
|
-
// with optional depends_on referencing other run_id values
|
|
462
|
-
let rawTasks = [];
|
|
463
|
-
const rawJobs = (value.jobs || value.tasks);
|
|
464
|
-
if (Array.isArray(rawJobs)) {
|
|
465
|
-
rawTasks = rawJobs;
|
|
466
|
-
}
|
|
467
|
-
else if (Array.isArray(value.groups)) {
|
|
468
|
-
// Flatten groups[].runs into a linear task list, resolving depends_on from group membership.
|
|
469
|
-
let groupDepIds = [];
|
|
470
|
-
for (const group of value.groups) {
|
|
471
|
-
const runs = Array.isArray(group.runs) ? group.runs : [];
|
|
472
|
-
const currentIds = runs.map((r) => cleanString(r.run_id) || '').filter(Boolean);
|
|
473
|
-
for (const run of runs) {
|
|
474
|
-
// Synthesise a canonical task from the run schema.
|
|
475
|
-
const dependsOnRaw = Array.isArray(group.depends_on) ? group.depends_on.map(String) : groupDepIds.length ? groupDepIds : [];
|
|
476
|
-
rawTasks.push({
|
|
477
|
-
taskId: run.run_id,
|
|
478
|
-
title: cleanString(run.briefing)?.slice(0, 80) || cleanString(run.job) || 'Task',
|
|
479
|
-
personaKey: run.persona,
|
|
480
|
-
jobId: run.job,
|
|
481
|
-
instructions: run.briefing,
|
|
482
|
-
dependsOn: dependsOnRaw,
|
|
483
|
-
status: 'planned',
|
|
484
|
-
});
|
|
485
|
-
}
|
|
486
|
-
groupDepIds = currentIds;
|
|
487
|
-
}
|
|
488
|
-
}
|
|
489
|
-
const tasks = rawTasks.map((task, index) => {
|
|
490
|
-
if (!task || typeof task !== 'object')
|
|
491
|
-
return null;
|
|
492
|
-
const rawTask = task;
|
|
493
|
-
// Accept title or first 80 chars of briefing/instructions
|
|
494
|
-
const title = cleanString((rawTask.title || rawTask.briefing || rawTask.instructions))?.slice(0, 80);
|
|
495
|
-
if (!title)
|
|
496
|
-
return null;
|
|
497
|
-
const taskId = cleanString((rawTask.taskId || rawTask.run_id || rawTask.id))
|
|
498
|
-
|| title.toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/^-|-$/g, '')
|
|
499
|
-
|| `task-${index + 1}`;
|
|
500
|
-
const rawStatus = cleanString(rawTask.status);
|
|
501
|
-
const status = DELEGATION_TASK_STATUSES.has(rawStatus) ? rawStatus : 'planned';
|
|
502
|
-
const artifacts = Array.isArray(rawTask.artifacts)
|
|
503
|
-
? rawTask.artifacts.map((a, ai) => normalizeReviewArtifact(a, ai)).filter((a) => Boolean(a))
|
|
504
|
-
: [];
|
|
505
|
-
const reviewHandoff = normalizeReviewHandoff(rawTask.reviewHandoff);
|
|
506
|
-
// Accept dependsOn or depends_on as string array
|
|
507
|
-
const rawDeps = rawTask.dependsOn || rawTask.depends_on;
|
|
508
|
-
return {
|
|
509
|
-
taskId,
|
|
510
|
-
title,
|
|
511
|
-
status,
|
|
512
|
-
personaKey: cleanNullableString((rawTask.personaKey || rawTask.persona)),
|
|
513
|
-
jobId: cleanNullableString((rawTask.jobId || rawTask.job || rawTask.job_id)),
|
|
514
|
-
reviewJobId: cleanNullableString(rawTask.reviewJobId),
|
|
515
|
-
reviewType: cleanNullableString(rawTask.reviewType),
|
|
516
|
-
instructions: cleanString((rawTask.instructions || rawTask.briefing)) || undefined,
|
|
517
|
-
latestSummary: cleanString(rawTask.latestSummary) || undefined,
|
|
518
|
-
hostThreadId: cleanNullableString(rawTask.hostThreadId),
|
|
519
|
-
hostSessionId: cleanNullableString(rawTask.hostSessionId),
|
|
520
|
-
runId: cleanNullableString(rawTask.runId),
|
|
521
|
-
conversationId: cleanNullableString(rawTask.conversationId),
|
|
522
|
-
dependsOn: Array.isArray(rawDeps) ? rawDeps.map(String).filter(Boolean) : [],
|
|
523
|
-
artifacts,
|
|
524
|
-
reviewHandoff,
|
|
525
|
-
};
|
|
526
|
-
}).filter((task) => Boolean(task));
|
|
527
|
-
if (tasks.length === 0)
|
|
528
|
-
return null;
|
|
529
|
-
return {
|
|
530
|
-
delegationRequired: true,
|
|
531
|
-
objective,
|
|
532
|
-
orchestratorPersonaKey: cleanNullableString((value.orchestratorPersonaKey || value.manager_persona)),
|
|
533
|
-
rootRunId: cleanNullableString(value.rootRunId),
|
|
534
|
-
managerRunId: cleanNullableString((value.managerRunId || value.manager_session)),
|
|
535
|
-
latestSummary: cleanString(value.latestSummary) || undefined,
|
|
536
|
-
tasks,
|
|
537
|
-
};
|
|
538
|
-
}
|
|
539
|
-
function extractReviewHandoffFromText(text) {
|
|
540
|
-
if (!text || !/reviewRequired|reviewTarget|review_handoff/i.test(text))
|
|
541
|
-
return null;
|
|
542
|
-
const candidates = [];
|
|
543
|
-
for (const match of String(text).matchAll(/```(?:json)?\s*([\s\S]*?)```/gi))
|
|
544
|
-
candidates.push(match[1]);
|
|
545
|
-
const tagged = String(text).match(/<review_handoff>\s*([\s\S]*?)\s*<\/review_handoff>/i);
|
|
546
|
-
if (tagged)
|
|
547
|
-
candidates.push(tagged[1]);
|
|
548
|
-
const inline = String(text).match(/(\{\s*"reviewRequired"[\s\S]*\})/i);
|
|
549
|
-
if (inline)
|
|
550
|
-
candidates.push(inline[1]);
|
|
551
|
-
for (const candidate of candidates) {
|
|
552
|
-
try {
|
|
553
|
-
const handoff = normalizeReviewHandoff(JSON.parse(candidate.trim()));
|
|
554
|
-
if (handoff)
|
|
555
|
-
return handoff;
|
|
556
|
-
}
|
|
557
|
-
catch {
|
|
558
|
-
// Malformed snippets are ignored; the UI can still surface legacy fallback state.
|
|
559
|
-
}
|
|
560
|
-
}
|
|
561
|
-
return null;
|
|
562
|
-
}
|
|
563
|
-
function extractDelegationLedgerFromText(text) {
|
|
564
|
-
if (!text || !/delegationRequired|delegation_ledger|delegationLedger|"goal"\s*:.*"groups"\s*:/is.test(text))
|
|
565
|
-
return null;
|
|
566
|
-
const candidates = [];
|
|
567
|
-
for (const match of String(text).matchAll(/```(?:json)?\s*([\s\S]*?)```/gi))
|
|
568
|
-
candidates.push(match[1]);
|
|
569
|
-
const tagged = String(text).match(/<delegation_ledger>\s*([\s\S]*?)\s*<\/delegation_ledger>/i);
|
|
570
|
-
if (tagged)
|
|
571
|
-
candidates.push(tagged[1]);
|
|
572
|
-
const inline = String(text).match(/(\{\s*"delegationRequired"[\s\S]*\})/i);
|
|
573
|
-
if (inline)
|
|
574
|
-
candidates.push(inline[1]);
|
|
575
|
-
for (const candidate of candidates) {
|
|
576
|
-
try {
|
|
577
|
-
const ledger = normalizeDelegationLedger(JSON.parse(candidate.trim()));
|
|
578
|
-
if (ledger)
|
|
579
|
-
return ledger;
|
|
580
|
-
}
|
|
581
|
-
catch {
|
|
582
|
-
// Malformed delegation snippets are ignored; the host transcript remains available in Micro-manage.
|
|
583
|
-
}
|
|
584
|
-
}
|
|
585
|
-
return null;
|
|
586
|
-
}
|
|
587
|
-
function applyReviewProjection(run, text) {
|
|
588
|
-
const delegation = extractDelegationLedgerFromText(text);
|
|
589
|
-
if (delegation) {
|
|
590
|
-
run.delegation = {
|
|
591
|
-
...delegation,
|
|
592
|
-
rootRunId: delegation.rootRunId || run.id,
|
|
593
|
-
managerRunId: delegation.managerRunId || run.id,
|
|
594
|
-
};
|
|
595
|
-
}
|
|
596
|
-
const handoff = extractReviewHandoffFromText(text);
|
|
597
|
-
if (handoff) {
|
|
598
|
-
run.reviewHandoff = handoff;
|
|
599
|
-
run.artifacts = handoff.reviewTarget?.type === 'artifact_set' ? handoff.artifacts : [];
|
|
600
|
-
return;
|
|
601
|
-
}
|
|
602
|
-
}
|
|
603
|
-
function emptyTotals() {
|
|
604
|
-
return {
|
|
605
|
-
totalDurationMs: 0,
|
|
606
|
-
workingDurationMs: 0,
|
|
607
|
-
waitingDurationMs: 0,
|
|
608
|
-
tokenTotals: {
|
|
609
|
-
inputTokens: null,
|
|
610
|
-
outputTokens: null,
|
|
611
|
-
costUsd: null,
|
|
612
|
-
// Issue #347: Hub server cannot read tokenSnapshot from the local
|
|
613
|
-
// proxy in-process. Cross-process attribution is a follow-up;
|
|
614
|
-
// until then, every Hub-driven run reports unavailable. The Hub UI
|
|
615
|
-
// renders "—" per spec R4.6.
|
|
616
|
-
coverage: 'unavailable',
|
|
617
|
-
},
|
|
618
|
-
};
|
|
619
|
-
}
|
|
620
|
-
const PHASE_STATUSES = new Set(['starting', 'complete', 'incomplete', 'failure']);
|
|
621
|
-
function normalizePersistedPhaseHistory(value) {
|
|
622
|
-
if (!Array.isArray(value))
|
|
623
|
-
return [];
|
|
624
|
-
return value
|
|
625
|
-
.map((entry) => {
|
|
626
|
-
if (!entry || typeof entry !== 'object')
|
|
627
|
-
return null;
|
|
628
|
-
const raw = entry;
|
|
629
|
-
if (typeof raw.phaseId !== 'string' || !raw.phaseId)
|
|
630
|
-
return null;
|
|
631
|
-
const latestStatus = typeof raw.latestStatus === 'string' && PHASE_STATUSES.has(raw.latestStatus)
|
|
632
|
-
? raw.latestStatus
|
|
633
|
-
: null;
|
|
634
|
-
return {
|
|
635
|
-
phaseId: raw.phaseId,
|
|
636
|
-
enteredAt: typeof raw.enteredAt === 'string' ? raw.enteredAt : new Date().toISOString(),
|
|
637
|
-
latestStatus,
|
|
638
|
-
latestText: typeof raw.latestText === 'string' ? raw.latestText : null,
|
|
639
|
-
};
|
|
640
|
-
})
|
|
641
|
-
.filter((entry) => Boolean(entry));
|
|
642
|
-
}
|
|
643
|
-
function readPersistedRunProjection(conversation) {
|
|
644
|
-
const rawRun = conversation?.run;
|
|
645
|
-
if (!rawRun || typeof rawRun !== 'object')
|
|
646
|
-
return null;
|
|
647
|
-
const value = rawRun;
|
|
648
|
-
return {
|
|
649
|
-
currentPhase: typeof value.currentPhase === 'string' ? value.currentPhase : null,
|
|
650
|
-
phaseHistory: normalizePersistedPhaseHistory(value.phaseHistory),
|
|
651
|
-
stages: Array.isArray(value.stages) ? value.stages : [],
|
|
652
|
-
totals: value.totals && typeof value.totals === 'object' ? value.totals : null,
|
|
653
|
-
runDiscriminant: typeof value.runDiscriminant === 'string' ? value.runDiscriminant : null,
|
|
654
|
-
};
|
|
655
|
-
}
|
|
656
|
-
// Issue #347 — apply per-turn usage from the host stream into the
|
|
657
|
-
// run's tokenTotals. Idempotent on the same turn (we replace, not add)
|
|
658
|
-
// because Codex's `turn.completed.usage` is cumulative, not delta.
|
|
659
|
-
// When the host doesn't emit costUsd directly (Codex), we compute it
|
|
660
|
-
// from the captured agent identity via the price table.
|
|
661
|
-
function applyUsageSignal(run, usage) {
|
|
662
|
-
run.totals = run.totals || emptyTotals();
|
|
663
|
-
const tt = run.totals.tokenTotals;
|
|
664
|
-
// Total input tokens (for the user-facing display) = non-cached + cached.
|
|
665
|
-
// Cumulative across the whole run on both hosts; take max to guard
|
|
666
|
-
// against any out-of-order delivery.
|
|
667
|
-
const totalInput = usage.nonCachedInputTokens + usage.cachedInputTokens;
|
|
668
|
-
tt.inputTokens = Math.max(tt.inputTokens || 0, totalInput);
|
|
669
|
-
tt.outputTokens = Math.max(tt.outputTokens || 0, usage.outputTokens);
|
|
670
|
-
// Cost: prefer the host's own number (Claude). For hosts that don't
|
|
671
|
-
// emit it (Codex), compute from the captured agent identity + price
|
|
672
|
-
// table. If neither source applies, leave costUsd null and coverage
|
|
673
|
-
// stays 'partial'.
|
|
674
|
-
let computedCost = null;
|
|
675
|
-
if (typeof usage.costUsd === 'number') {
|
|
676
|
-
computedCost = usage.costUsd;
|
|
677
|
-
}
|
|
678
|
-
else if (run.agentName && run.agentModel) {
|
|
679
|
-
const price = (0, agent_token_prices_1.lookupPrice)(run.agentName.toLowerCase(), run.agentModel.toLowerCase());
|
|
680
|
-
if (price) {
|
|
681
|
-
computedCost =
|
|
682
|
-
(usage.nonCachedInputTokens / 1_000_000) * price.inputPerMTok +
|
|
683
|
-
(usage.cachedInputTokens / 1_000_000) * price.cacheReadPerMTok +
|
|
684
|
-
(usage.cacheCreationTokens / 1_000_000) * price.cacheCreationPerMTok +
|
|
685
|
-
(usage.outputTokens / 1_000_000) * price.outputPerMTok;
|
|
686
|
-
}
|
|
687
|
-
}
|
|
688
|
-
if (computedCost !== null) {
|
|
689
|
-
tt.costUsd = Math.max(tt.costUsd || 0, computedCost);
|
|
690
|
-
tt.coverage = 'complete';
|
|
691
|
-
}
|
|
692
|
-
else if (tt.coverage !== 'complete') {
|
|
693
|
-
tt.coverage = 'partial';
|
|
694
|
-
}
|
|
695
|
-
}
|
|
696
|
-
// Issue #347 — capture agent identity from the host stream's fraim_connect
|
|
697
|
-
// call. Used downstream by applyUsageSignal to compute cost via the price
|
|
698
|
-
// table when the host does not emit costUsd directly.
|
|
699
|
-
function applyAgentIdentitySignal(run, identity) {
|
|
700
|
-
run.agentName = identity.agentName;
|
|
701
|
-
run.agentModel = identity.agentModel;
|
|
702
|
-
}
|
|
703
|
-
function stripStructuredHostPayloads(text) {
|
|
704
|
-
return text
|
|
705
|
-
.replace(/<delegation_ledger>\s*[\s\S]*?\s*<\/delegation_ledger>/gi, '')
|
|
706
|
-
.replace(/<review_handoff>\s*[\s\S]*?\s*<\/review_handoff>/gi, '')
|
|
707
|
-
.trim();
|
|
708
|
-
}
|
|
709
|
-
function appendHostMessage(run, hostId, event, channel) {
|
|
710
|
-
if (!event.message || channel !== 'stdout')
|
|
711
|
-
return;
|
|
712
|
-
applyReviewProjection(run, event.message);
|
|
713
|
-
if (hostId === 'gemini') {
|
|
714
|
-
const last = run.messages[run.messages.length - 1];
|
|
715
|
-
if (last?.role === 'employee') {
|
|
716
|
-
last.text = `${last.text}\n${event.message}`;
|
|
717
|
-
last.createdAt = new Date().toISOString();
|
|
718
|
-
applyReviewProjection(run, last.text);
|
|
719
|
-
last.text = stripStructuredHostPayloads(last.text);
|
|
720
|
-
if (!last.text)
|
|
721
|
-
run.messages.pop();
|
|
722
|
-
return;
|
|
723
|
-
}
|
|
724
|
-
}
|
|
725
|
-
const displayMessage = stripStructuredHostPayloads(event.message);
|
|
726
|
-
if (!displayMessage)
|
|
727
|
-
return;
|
|
728
|
-
run.messages.push((0, hosts_1.createHubMessage)('employee', displayMessage));
|
|
729
|
-
}
|
|
730
|
-
// Apply a parsed seekMentoring tool-use signal from the host stream to
|
|
731
|
-
// the run state. Returns the updated currentPhase.
|
|
732
|
-
function applySeekMentoringSignal(run, signal) {
|
|
733
|
-
// Issue #347: filter cross-job pollution. The agent may call
|
|
734
|
-
// seekMentoring for jobs OTHER than the one this run is tracking
|
|
735
|
-
// (e.g., consulting `organizational-learning-synthesis` mid-run).
|
|
736
|
-
// Only apply signals whose job identity matches this run's.
|
|
737
|
-
// Issue #732: match on `jobName` — the stable job-name slug that equals
|
|
738
|
-
// run.jobId — NOT `jobId`. get_fraim_job mints a fresh randomUUID as the
|
|
739
|
-
// job id on every call and the agent echoes that UUID into
|
|
740
|
-
// seekMentoring.jobId, so a UUID never equals run.jobId. Comparing the UUID
|
|
741
|
-
// discarded every real phase signal as if it were foreign-job pollution and
|
|
742
|
-
// froze the tracker at "no phases done". The UUID cannot positively identify
|
|
743
|
-
// this run's job, so it must not participate in the match.
|
|
744
|
-
const targetJobId = run.jobId;
|
|
745
|
-
const callJobName = signal.jobName;
|
|
746
|
-
if (callJobName && targetJobId && callJobName !== targetJobId)
|
|
747
|
-
return;
|
|
748
|
-
if (signal.reviewHandoff) {
|
|
749
|
-
const normalizedReviewHandoff = normalizeReviewHandoff(signal.reviewHandoff);
|
|
750
|
-
run.reviewHandoff = normalizedReviewHandoff || signal.reviewHandoff;
|
|
751
|
-
run.artifacts = normalizedReviewHandoff?.reviewTarget?.type === 'artifact_set'
|
|
752
|
-
? normalizedReviewHandoff.artifacts
|
|
753
|
-
: [];
|
|
754
|
-
}
|
|
755
|
-
if (signal.delegationLedger &&
|
|
756
|
-
run.jobId === 'fully-delegate' &&
|
|
757
|
-
signal.phaseStatus === 'complete' &&
|
|
758
|
-
(signal.phaseId === 'confirm-or-fallback' || signal.phaseId === 'create-delegation-graph')) {
|
|
759
|
-
run.delegation = {
|
|
760
|
-
...signal.delegationLedger,
|
|
761
|
-
rootRunId: signal.delegationLedger.rootRunId || run.id,
|
|
762
|
-
managerRunId: signal.delegationLedger.managerRunId || run.id,
|
|
763
|
-
};
|
|
764
|
-
}
|
|
765
|
-
// Discriminant signals are routing hints only — they don't move the
|
|
766
|
-
// tracker, but they do change which phase id will be considered
|
|
767
|
-
// reachable next time stages are derived. Persist on the run.
|
|
768
|
-
if (signal.phaseId === '__discriminant__') {
|
|
769
|
-
if (signal.discriminant)
|
|
770
|
-
run.runDiscriminant = signal.discriminant;
|
|
771
|
-
return;
|
|
772
|
-
}
|
|
773
|
-
if (signal.discriminant)
|
|
774
|
-
run.runDiscriminant = signal.discriminant;
|
|
775
|
-
// The seekMentoring workflow uses `currentPhase: "starting"` as a
|
|
776
|
-
// sentinel for the very first call ("give me phase 1 instructions").
|
|
777
|
-
// It is not an actual phase id — skip it so the tracker doesn't
|
|
778
|
-
// render an extra "starting" stage at the end of the path.
|
|
779
|
-
if (signal.phaseId === 'starting')
|
|
780
|
-
return;
|
|
781
|
-
run.phaseHistory = run.phaseHistory || [];
|
|
782
|
-
const existing = run.phaseHistory.find((entry) => entry.phaseId === signal.phaseId);
|
|
783
|
-
if (existing) {
|
|
784
|
-
existing.latestStatus = signal.phaseStatus;
|
|
785
|
-
if (signal.findingsText)
|
|
786
|
-
existing.latestText = signal.findingsText;
|
|
787
|
-
}
|
|
788
|
-
else {
|
|
789
|
-
const entry = {
|
|
790
|
-
phaseId: signal.phaseId,
|
|
791
|
-
enteredAt: new Date().toISOString(),
|
|
792
|
-
latestStatus: signal.phaseStatus,
|
|
793
|
-
latestText: signal.findingsText || null,
|
|
794
|
-
};
|
|
795
|
-
run.phaseHistory.push(entry);
|
|
796
|
-
}
|
|
797
|
-
run.currentPhase = signal.phaseId;
|
|
798
|
-
}
|
|
799
|
-
// Build the stage list for a run. Combines the FSM's reachable path with
|
|
800
|
-
// any phases the run has actually visited (in case the run took an
|
|
801
|
-
// onFailure back-edge into a phase the simple onSuccess walk wouldn't
|
|
802
|
-
// surface). Each stage is marked done / current / upcoming based on
|
|
803
|
-
// whether it precedes / matches / follows the current phase along the
|
|
804
|
-
// rendered order.
|
|
805
|
-
function deriveStages(run, projectPath) {
|
|
806
|
-
const declaredPath = (0, catalog_1.loadJobPhases)(run.jobId, projectPath, run.runDiscriminant || 'feature');
|
|
807
|
-
if (declaredPath.length === 0)
|
|
808
|
-
return [];
|
|
809
|
-
// Merge in any visited phase that's not on the declared path BUT is
|
|
810
|
-
// declared in the job's frontmatter (e.g., a phase reached via an
|
|
811
|
-
// onFailure back-edge that the simple onSuccess walk didn't surface).
|
|
812
|
-
// Skip the 'starting' sentinel, the __discriminant__ marker, and any
|
|
813
|
-
// phase id NOT in the frontmatter — that last filter prevents
|
|
814
|
-
// cross-job pollution from showing up on the tracker.
|
|
815
|
-
const allDeclared = (0, catalog_1.loadAllJobPhaseIds)(run.jobId, projectPath);
|
|
816
|
-
const visited = (run.phaseHistory || []).map((entry) => entry.phaseId);
|
|
817
|
-
const known = new Set(declaredPath.map((p) => p.id));
|
|
818
|
-
for (const visitedId of visited) {
|
|
819
|
-
if (visitedId === 'starting' || visitedId === '__discriminant__')
|
|
820
|
-
continue;
|
|
821
|
-
if (!allDeclared.has(visitedId))
|
|
822
|
-
continue;
|
|
823
|
-
if (!known.has(visitedId)) {
|
|
824
|
-
declaredPath.push({ id: visitedId, label: (0, catalog_1.labelForPhaseId)(visitedId, run.jobId, projectPath) });
|
|
825
|
-
known.add(visitedId);
|
|
826
|
-
}
|
|
827
|
-
}
|
|
828
|
-
const currentIndex = run.currentPhase
|
|
829
|
-
? declaredPath.findIndex((p) => p.id === run.currentPhase)
|
|
830
|
-
: -1;
|
|
831
|
-
const historyMap = new Map((run.phaseHistory || []).map((e) => [e.phaseId, e]));
|
|
832
|
-
const completedWithoutPhaseTelemetry = run.status === 'completed' &&
|
|
833
|
-
currentIndex < 0 &&
|
|
834
|
-
historyMap.size === 0;
|
|
835
|
-
if (completedWithoutPhaseTelemetry) {
|
|
836
|
-
return declaredPath.map((phase) => ({ phaseId: phase.id, label: phase.label, state: 'done' }));
|
|
837
|
-
}
|
|
838
|
-
return declaredPath.map((phase, index) => {
|
|
839
|
-
let state;
|
|
840
|
-
const entry = historyMap.get(phase.id);
|
|
841
|
-
if (index === currentIndex) {
|
|
842
|
-
// If the agent has already reported this phase as 'complete', advance
|
|
843
|
-
// its visual state to 'done' so the tracker doesn't look frozen while
|
|
844
|
-
// waiting for the agent to start the next phase (e.g. after
|
|
845
|
-
// implement-submission completes but before address-feedback starts).
|
|
846
|
-
state = entry?.latestStatus === 'complete' ? 'done' : 'current';
|
|
847
|
-
}
|
|
848
|
-
else if (entry?.latestStatus === 'complete' || (currentIndex >= 0 && index < currentIndex && entry)) {
|
|
849
|
-
state = 'done';
|
|
850
|
-
}
|
|
851
|
-
else {
|
|
852
|
-
state = 'upcoming';
|
|
853
|
-
}
|
|
854
|
-
return { phaseId: phase.id, label: phase.label, state };
|
|
855
|
-
});
|
|
856
|
-
}
|
|
857
|
-
// ---------------------------------------------------------------------------
|
|
858
|
-
// Agent install helpers (shared with first-run; duplicated here to avoid
|
|
859
|
-
// the session-key dependency in that module's public API).
|
|
860
|
-
// ---------------------------------------------------------------------------
|
|
861
|
-
const HUB_TO_FIRST_RUN_ID = {
|
|
862
|
-
claude: 'claude-code',
|
|
863
|
-
codex: 'codex',
|
|
864
|
-
gemini: 'gemini-cli',
|
|
865
|
-
copilot: 'copilot-cli',
|
|
866
|
-
};
|
|
867
|
-
function hubAgentOption(hubId) {
|
|
868
|
-
const frId = HUB_TO_FIRST_RUN_ID[hubId];
|
|
869
|
-
return frId ? types_1.FIRST_RUN_AGENT_OPTIONS.find((o) => o.id === frId) : undefined;
|
|
870
|
-
}
|
|
871
|
-
/**
|
|
872
|
-
* Issue #747: after the Hub installs an agent CLI, run the `add-ide` command for that agent so the
|
|
873
|
-
* FRAIM MCP (plus slash commands / rules) is wired into its config and its first run works.
|
|
874
|
-
* Previously the install only ran `npm install -g` + a version probe, so the agent launched with
|
|
875
|
-
* no `fraim` MCP server. This invokes the same `runAddIDE` the `fraim add-ide` CLI runs — scoped to
|
|
876
|
-
* the just-installed agent — rather than re-implementing the MCP-config write.
|
|
877
|
-
*
|
|
878
|
-
* `runAddIDE` reads the FRAIM key from ~/.fraim/config.json (written by setup/first-run) and
|
|
879
|
-
* `process.exit(1)`s when it is missing. We guard on that here via the exported `loadGlobalConfig`
|
|
880
|
-
* so a missing key degrades to a logged no-op instead of terminating the long-running Hub server.
|
|
881
|
-
* `skipTokenPrompts` keeps the run fully non-interactive.
|
|
882
|
-
*/
|
|
883
|
-
async function configureFraimForHubAgent(hubId) {
|
|
884
|
-
const frId = HUB_TO_FIRST_RUN_ID[hubId];
|
|
885
|
-
if (!frId)
|
|
886
|
-
return { configured: false, error: `Unknown hub agent: ${hubId}` };
|
|
887
|
-
try {
|
|
888
|
-
const { runAddIDE, loadGlobalConfig } = await Promise.resolve().then(() => __importStar(require('../cli/commands/add-ide')));
|
|
889
|
-
const config = await loadGlobalConfig();
|
|
890
|
-
if (!config?.fraimKey) {
|
|
891
|
-
return { configured: false, error: 'No FRAIM key in ~/.fraim/config.json; run `fraim setup` first.' };
|
|
892
|
-
}
|
|
893
|
-
// `frId` (e.g. 'claude-code', 'codex', 'gemini-cli', 'copilot-cli') is a valid add-ide
|
|
894
|
-
// `--ide` name/alias; runAddIDE resolves and configures it even when not yet detected.
|
|
895
|
-
await runAddIDE({ ide: frId, skipTokenPrompts: true });
|
|
896
|
-
return { configured: true, ideName: frId };
|
|
897
|
-
}
|
|
898
|
-
catch (e) {
|
|
899
|
-
return { configured: false, error: e instanceof Error ? e.message : String(e) };
|
|
900
|
-
}
|
|
901
|
-
}
|
|
902
|
-
function hubCommandVersion(command, extraBinDirs) {
|
|
903
|
-
const executable = process.platform === 'win32' ? 'cmd.exe' : command;
|
|
904
|
-
const args = process.platform === 'win32'
|
|
905
|
-
? ['/d', '/s', '/c', `${command} --version`]
|
|
906
|
-
: ['--version'];
|
|
907
|
-
const env = extraBinDirs && extraBinDirs.length > 0
|
|
908
|
-
? {
|
|
909
|
-
...process.env,
|
|
910
|
-
PATH: [...new Set([...extraBinDirs, ...(process.env.PATH || '').split(path_1.default.delimiter).filter(Boolean)])].join(path_1.default.delimiter),
|
|
911
|
-
}
|
|
912
|
-
: undefined;
|
|
913
|
-
const result = (0, child_process_1.spawnSync)(executable, args, { encoding: 'utf8', timeout: 5000, ...(env ? { env } : {}) });
|
|
914
|
-
if (result.status !== 0 || result.error)
|
|
915
|
-
return null;
|
|
916
|
-
const raw = (result.stdout || result.stderr || '').trim();
|
|
917
|
-
return raw || null;
|
|
918
|
-
}
|
|
919
|
-
function hubRunProcess(command, args, env) {
|
|
920
|
-
return new Promise((resolve, reject) => {
|
|
921
|
-
const [realCmd, realArgs] = process.platform === 'win32'
|
|
922
|
-
? ['cmd.exe', ['/d', '/s', '/c', command, ...args]]
|
|
923
|
-
: [command, args];
|
|
924
|
-
const child = (0, child_process_1.spawn)(realCmd, realArgs, {
|
|
925
|
-
env: { ...process.env, ...env },
|
|
926
|
-
stdio: ['ignore', 'pipe', 'pipe'],
|
|
927
|
-
});
|
|
928
|
-
let stdout = '';
|
|
929
|
-
let stderr = '';
|
|
930
|
-
child.stdout.on('data', (d) => { stdout += d.toString(); });
|
|
931
|
-
child.stderr.on('data', (d) => { stderr += d.toString(); });
|
|
932
|
-
child.on('close', (code) => {
|
|
933
|
-
if (code === 0)
|
|
934
|
-
resolve({ stdout, stderr });
|
|
935
|
-
else
|
|
936
|
-
reject(new Error(stderr || `Process exited with code ${code}`));
|
|
937
|
-
});
|
|
938
|
-
child.on('error', reject);
|
|
939
|
-
});
|
|
940
|
-
}
|
|
941
|
-
function hubOpenTerminal(command) {
|
|
942
|
-
if (process.platform === 'win32') {
|
|
943
|
-
(0, child_process_1.spawn)('cmd.exe', ['/c', 'start', 'cmd.exe', '/k', command], { detached: true, stdio: 'ignore' }).unref();
|
|
944
|
-
return;
|
|
945
|
-
}
|
|
946
|
-
if (process.platform === 'darwin') {
|
|
947
|
-
const script = `tell application "Terminal" to do script "${command.replace(/"/g, '\\"')}"`;
|
|
948
|
-
(0, child_process_1.spawn)('osascript', ['-e', script], { detached: true, stdio: 'ignore' }).unref();
|
|
949
|
-
return;
|
|
950
|
-
}
|
|
951
|
-
const linux = [
|
|
952
|
-
['gnome-terminal', ['--', 'bash', '-c', `${command}; exec bash`]],
|
|
953
|
-
['xterm', ['-e', `bash -c '${command}; exec bash'`]],
|
|
954
|
-
['konsole', ['--noclose', '-e', 'bash', '-c', command]],
|
|
955
|
-
['x-terminal-emulator', ['-e', `bash -c '${command}; exec bash'`]],
|
|
956
|
-
];
|
|
957
|
-
for (const [term, args] of linux) {
|
|
958
|
-
if ((0, child_process_1.spawnSync)('which', [term], { encoding: 'utf8' }).status === 0) {
|
|
959
|
-
(0, child_process_1.spawn)(term, args, { detached: true, stdio: 'ignore' }).unref();
|
|
960
|
-
return;
|
|
961
|
-
}
|
|
962
|
-
}
|
|
963
|
-
(0, child_process_1.spawn)('bash', ['-c', command], { detached: true, stdio: 'ignore' }).unref();
|
|
964
|
-
}
|
|
965
|
-
function pathWithin(root, candidate) {
|
|
966
|
-
const resolvedRoot = path_1.default.resolve(root);
|
|
967
|
-
const resolvedCandidate = path_1.default.resolve(candidate);
|
|
968
|
-
return resolvedCandidate === resolvedRoot || resolvedCandidate.startsWith(resolvedRoot + path_1.default.sep);
|
|
969
|
-
}
|
|
970
|
-
function resolveSafeArtifactPath(rawPath, projectPath) {
|
|
971
|
-
const trimmed = rawPath.trim();
|
|
972
|
-
if (!trimmed || /^https?:\/\//i.test(trimmed))
|
|
973
|
-
return null;
|
|
974
|
-
const resolved = path_1.default.resolve(path_1.default.isAbsolute(trimmed) ? trimmed : path_1.default.join(projectPath, trimmed));
|
|
975
|
-
const safeRoots = [
|
|
976
|
-
path_1.default.resolve(projectPath),
|
|
977
|
-
path_1.default.resolve(os_1.default.homedir()),
|
|
978
|
-
];
|
|
979
|
-
return safeRoots.some((root) => pathWithin(root, resolved)) ? resolved : null;
|
|
980
|
-
}
|
|
981
|
-
// Builds the OS "open this file with its default app" invocation. Exported for regression
|
|
982
|
-
// testing (see tests/isolated/test-ai-hub-open-file.ts).
|
|
983
|
-
//
|
|
984
|
-
// Windows contract: the path travels via an ENVIRONMENT VARIABLE, never as a trailing `-Command`
|
|
985
|
-
// argument. PowerShell re-parses trailing args on its own command line and mangles Windows paths —
|
|
986
|
-
// it strips backslashes and truncates at the first space — so `Invoke-Item -LiteralPath $p`
|
|
987
|
-
// received a broken path (e.g. `C:UserssidmaOneDriveCodeDrug`) and failed for every real artifact
|
|
988
|
-
// path, which is why the Hub's "open file" button did nothing. `$env:FRAIM_OPEN_PATH` is read
|
|
989
|
-
// directly from the environment and is not re-parsed, so the path arrives intact (verified against
|
|
990
|
-
// paths containing spaces and backslashes).
|
|
991
|
-
function buildOpenFileInvocation(filePath, platform = process.platform) {
|
|
992
|
-
if (platform === 'win32') {
|
|
993
|
-
return {
|
|
994
|
-
command: 'powershell.exe',
|
|
995
|
-
args: [
|
|
996
|
-
'-NoProfile',
|
|
997
|
-
'-ExecutionPolicy',
|
|
998
|
-
'Bypass',
|
|
999
|
-
'-Command',
|
|
1000
|
-
'try { Invoke-Item -LiteralPath $env:FRAIM_OPEN_PATH; exit 0 } catch { Write-Error $_; exit 1 }',
|
|
1001
|
-
],
|
|
1002
|
-
envPatch: { FRAIM_OPEN_PATH: filePath },
|
|
1003
|
-
};
|
|
1004
|
-
}
|
|
1005
|
-
return {
|
|
1006
|
-
command: platform === 'darwin' ? 'open' : 'xdg-open',
|
|
1007
|
-
args: [filePath],
|
|
1008
|
-
envPatch: {},
|
|
1009
|
-
};
|
|
1010
|
-
}
|
|
1011
|
-
function hubOpenFile(filePath) {
|
|
1012
|
-
return new Promise((resolve, reject) => {
|
|
1013
|
-
const { command, args, envPatch } = buildOpenFileInvocation(filePath);
|
|
1014
|
-
const child = (0, child_process_1.spawn)(command, args, {
|
|
1015
|
-
stdio: ['ignore', 'ignore', 'pipe'],
|
|
1016
|
-
windowsHide: process.platform === 'win32',
|
|
1017
|
-
env: { ...process.env, ...envPatch },
|
|
1018
|
-
});
|
|
1019
|
-
let stderr = '';
|
|
1020
|
-
child.stderr?.on('data', (chunk) => {
|
|
1021
|
-
stderr += String(chunk);
|
|
1022
|
-
});
|
|
1023
|
-
child.on('error', reject);
|
|
1024
|
-
child.on('close', (code) => {
|
|
1025
|
-
if (code === 0) {
|
|
1026
|
-
resolve();
|
|
1027
|
-
return;
|
|
1028
|
-
}
|
|
1029
|
-
reject(new Error(stderr.trim() || `Open command failed with exit code ${code ?? 'unknown'}.`));
|
|
1030
|
-
});
|
|
1031
|
-
});
|
|
1032
|
-
}
|
|
1033
|
-
function buildManagedLoginCommand(command) {
|
|
1034
|
-
const managedPath = (0, managed_agent_paths_1.buildPathWithManagedAgentBins)(process.env.PATH);
|
|
1035
|
-
if (process.platform === 'win32') {
|
|
1036
|
-
return `set "PATH=${managedPath}" && ${command}`;
|
|
1037
|
-
}
|
|
1038
|
-
return `export PATH="${managedPath}"; ${command}`;
|
|
1039
|
-
}
|
|
1040
|
-
function getUserHubDir() {
|
|
1041
|
-
return path_1.default.join(os_1.default.homedir(), '.fraim');
|
|
1042
|
-
}
|
|
1043
|
-
function ensureDirectoryPath(projectPath) {
|
|
1044
|
-
const trimmed = (projectPath || '').trim();
|
|
1045
|
-
if (!trimmed) {
|
|
1046
|
-
throw new Error('Project path is required.');
|
|
1047
|
-
}
|
|
1048
|
-
const resolved = path_1.default.resolve(trimmed);
|
|
1049
|
-
const stat = fs_1.default.existsSync(resolved) ? fs_1.default.statSync(resolved) : null;
|
|
1050
|
-
if (!stat || !stat.isDirectory()) {
|
|
1051
|
-
throw new Error('Project path must point to an existing directory.');
|
|
1052
|
-
}
|
|
1053
|
-
return resolved;
|
|
1054
|
-
}
|
|
1055
|
-
function normalizedDirectoryPath(projectPath) {
|
|
1056
|
-
const resolved = path_1.default.resolve(projectPath);
|
|
1057
|
-
return process.platform === 'win32' ? resolved.toLowerCase() : resolved;
|
|
1058
|
-
}
|
|
1059
|
-
function sameDirectoryPath(left, right) {
|
|
1060
|
-
return normalizedDirectoryPath(left) === normalizedDirectoryPath(right);
|
|
1061
|
-
}
|
|
1062
|
-
function deploymentProjectFilter(rawProjectPath, fallbackProjectPath) {
|
|
1063
|
-
return ensureDirectoryPath(typeof rawProjectPath === 'string' && rawProjectPath.trim() ? rawProjectPath : fallbackProjectPath);
|
|
1064
|
-
}
|
|
1065
|
-
function deploymentBelongsToProject(deployment, projectPath, fallbackProjectPath) {
|
|
1066
|
-
const deploymentProjectPath = typeof deployment.projectPath === 'string' && deployment.projectPath.trim()
|
|
1067
|
-
? deployment.projectPath
|
|
1068
|
-
: fallbackProjectPath;
|
|
1069
|
-
return sameDirectoryPath(deploymentProjectPath, projectPath);
|
|
1070
|
-
}
|
|
1071
|
-
// ---------------------------------------------------------------------------
|
|
1072
|
-
// Issue #512 (S3) — Hub bootstrap projection helpers.
|
|
1073
|
-
// ---------------------------------------------------------------------------
|
|
1074
|
-
// Issue #750: the Hub's identity — for persona/entitlement resolution, the
|
|
1075
|
-
// displayed "signed in as" email, and personal (L1) learnings keying — comes
|
|
1076
|
-
// from ~/.fraim/config.json's apiKey (architecture.md §4.3.4's "Global config
|
|
1077
|
-
// stores only identity"), the same credential every other FRAIM CLI/MCP
|
|
1078
|
-
// surface already reads via readUserFraimConfig(). There is no fallback: a
|
|
1079
|
-
// missing or invalid apiKey resolves to null (rendered as a "not connected"
|
|
1080
|
-
// state), never a guess from another local file.
|
|
1081
|
-
function resolveApiKey() {
|
|
1082
|
-
return (0, user_config_1.readUserFraimConfig)().apiKey;
|
|
1083
|
-
}
|
|
1084
|
-
// Read persisted Get-started step states from ~/.fraim/install-state.json
|
|
1085
|
-
// (architecture §3.5) and ~/.fraim/preferences.json. Returns a partial — any
|
|
1086
|
-
// missing key falls back to derivation. Persisted `true` is sticky so the rail
|
|
1087
|
-
// never re-shows once a step is completed (R13.4).
|
|
1088
|
-
function readPersistedFirstRun() {
|
|
1089
|
-
const out = {};
|
|
1090
|
-
const apply = (source) => {
|
|
1091
|
-
if (!source || typeof source !== 'object')
|
|
1092
|
-
return;
|
|
1093
|
-
// Support both a flat shape ({ company: true }) and a nested
|
|
1094
|
-
// ({ firstRun: { company: true } }) one — the rail mirrors to whichever.
|
|
1095
|
-
const nested = source.firstRun;
|
|
1096
|
-
const candidates = [source, nested].filter((c) => !!c && typeof c === 'object');
|
|
1097
|
-
for (const c of candidates) {
|
|
1098
|
-
for (const key of ['install', 'company', 'hire', 'project']) {
|
|
1099
|
-
if (c[key] === true)
|
|
1100
|
-
out[key] = true;
|
|
1101
|
-
}
|
|
1102
|
-
}
|
|
1103
|
-
};
|
|
1104
|
-
const readJson = (fileName) => {
|
|
1105
|
-
try {
|
|
1106
|
-
const p = path_1.default.join((0, project_fraim_paths_1.getUserFraimDirPath)(), fileName);
|
|
1107
|
-
if (!fs_1.default.existsSync(p))
|
|
1108
|
-
return null;
|
|
1109
|
-
return JSON.parse(fs_1.default.readFileSync(p, 'utf8'));
|
|
1110
|
-
}
|
|
1111
|
-
catch {
|
|
1112
|
-
return null;
|
|
1113
|
-
}
|
|
1114
|
-
};
|
|
1115
|
-
apply(readJson('install-state.json'));
|
|
1116
|
-
apply(readJson('preferences.json'));
|
|
1117
|
-
return out;
|
|
1118
|
-
}
|
|
1119
|
-
// Count .md files recursively under a directory (skills/rules catalog counts).
|
|
1120
|
-
function countMarkdownFilesRecursive(dirPath) {
|
|
1121
|
-
if (!fs_1.default.existsSync(dirPath))
|
|
1122
|
-
return 0;
|
|
1123
|
-
let total = 0;
|
|
1124
|
-
try {
|
|
1125
|
-
for (const entry of fs_1.default.readdirSync(dirPath, { withFileTypes: true })) {
|
|
1126
|
-
const child = path_1.default.join(dirPath, entry.name);
|
|
1127
|
-
if (entry.isDirectory()) {
|
|
1128
|
-
total += countMarkdownFilesRecursive(child);
|
|
1129
|
-
}
|
|
1130
|
-
else if (entry.isFile() && entry.name.endsWith('.md')) {
|
|
1131
|
-
total += 1;
|
|
1132
|
-
}
|
|
1133
|
-
}
|
|
1134
|
-
}
|
|
1135
|
-
catch {
|
|
1136
|
-
// ignore unreadable dirs
|
|
1137
|
-
}
|
|
1138
|
-
return total;
|
|
1139
|
-
}
|
|
1140
|
-
class AiHubServer {
|
|
1141
|
-
get hubBase() {
|
|
1142
|
-
return process.env.FRAIM_HUB_BASE_URL || `http://127.0.0.1:${this.httpPort}`;
|
|
1143
|
-
}
|
|
1144
|
-
constructor(options = {}) {
|
|
1145
|
-
this.app = (0, express_1.default)();
|
|
1146
|
-
this.runRegistry = new AiHubRunRegistry();
|
|
1147
|
-
this.cronHandles = new Map();
|
|
1148
|
-
this.projectPath = options.projectPath || process.cwd();
|
|
1149
|
-
this.preferencesStore = options.preferencesStore || new preferences_1.AiHubPreferencesStore();
|
|
1150
|
-
this.conversationStore = options.conversationStore || new conversation_store_1.AiHubConversationStore();
|
|
1151
|
-
this.wordTaskpaneDir = options.wordTaskpaneDir ?? resolveWordTaskpaneDir(this.projectPath);
|
|
1152
|
-
this.folderPicker = options.folderPicker ?? pickProjectPath;
|
|
1153
|
-
this.httpsPort = options.httpsPort;
|
|
1154
|
-
this.certBundle = options.certBundle;
|
|
1155
|
-
this.managedBrowser = options.managedBrowser || new managed_browser_1.ManagedBrowser({
|
|
1156
|
-
channel: process.env.FRAIM_BROWSER_CHANNEL || 'auto',
|
|
1157
|
-
port: process.env.FRAIM_BROWSER_PORT ? Number(process.env.FRAIM_BROWSER_PORT) : undefined,
|
|
1158
|
-
userDataDir: process.env.FRAIM_BROWSER_USER_DATA_DIR || undefined,
|
|
1159
|
-
explicitPath: process.env.FRAIM_BROWSER_PATH || undefined,
|
|
1160
|
-
});
|
|
1161
|
-
this.hostRuntime = options.hostRuntime || (process.env.FRAIM_AI_HUB_FAKE_HOST === '1' ? new hosts_1.FakeHostRuntime() : new hosts_1.CliHostRuntime());
|
|
1162
|
-
// Issue #701 / #749: the AI Hub is a loopback companion that runs on user machines and
|
|
1163
|
-
// never touches a database. Persona and manager-team state resolve from the hosted server
|
|
1164
|
-
// through the remote gateway; the hosted server is the sole owner of DB access.
|
|
1165
|
-
this.remoteGateway = options.remoteGateway ?? new remote_hub_gateway_1.HttpHubRemoteGateway();
|
|
1166
|
-
this.deploymentStore = options.deploymentStore ?? new DeploymentStore();
|
|
1167
|
-
this.hostConfigStore = options.hostConfigStore ?? new HostConfigStore();
|
|
1168
|
-
this.app.use(express_1.default.json({ limit: '10mb' }));
|
|
1169
|
-
// CORS + Chrome Private Network Access for browser extensions and Office add-in task panes
|
|
1170
|
-
// calling the Hub from a public origin (word-edit.officeapps.live.com, etc.).
|
|
1171
|
-
this.app.use((_req, res, next) => {
|
|
1172
|
-
res.setHeader('Access-Control-Allow-Origin', '*');
|
|
1173
|
-
res.setHeader('Access-Control-Allow-Methods', 'GET, POST, PUT, PATCH, DELETE, OPTIONS');
|
|
1174
|
-
res.setHeader('Access-Control-Allow-Headers', 'Content-Type');
|
|
1175
|
-
res.setHeader('Access-Control-Allow-Private-Network', 'true');
|
|
1176
|
-
if (_req.method === 'OPTIONS') {
|
|
1177
|
-
res.sendStatus(204);
|
|
1178
|
-
return;
|
|
1179
|
-
}
|
|
1180
|
-
next();
|
|
1181
|
-
});
|
|
1182
|
-
// Payment success redirect: sync entitlement then bounce to the hub.
|
|
1183
|
-
// Stripe redirects here after a completed persona-hire checkout.
|
|
1184
|
-
this.app.get('/ai-hub/payment-success', (req, res) => {
|
|
1185
|
-
const email = req.query['email'];
|
|
1186
|
-
// Persist the buyer's email so bootstrap can look up entitlements by userId.
|
|
1187
|
-
if (email) {
|
|
1188
|
-
try {
|
|
1189
|
-
const prefsPath = path_1.default.join((0, project_fraim_paths_1.getUserFraimDirPath)(), 'preferences.json');
|
|
1190
|
-
const existing = fs_1.default.existsSync(prefsPath)
|
|
1191
|
-
? JSON.parse(fs_1.default.readFileSync(prefsPath, 'utf8'))
|
|
1192
|
-
: {};
|
|
1193
|
-
fs_1.default.writeFileSync(prefsPath, JSON.stringify({ ...existing, userEmail: email }, null, 2));
|
|
1194
|
-
}
|
|
1195
|
-
catch (err) {
|
|
1196
|
-
console.warn('[ai-hub] could not persist userEmail:', err);
|
|
1197
|
-
}
|
|
1198
|
-
}
|
|
1199
|
-
// Issue #749: the Hub holds no DB. Entitlement is synced authoritatively by the backend
|
|
1200
|
-
// Stripe webhook; the Hub re-reads status via the remote gateway on the next bootstrap.
|
|
1201
|
-
res.redirect('/ai-hub/?hired=1');
|
|
1202
|
-
});
|
|
1203
|
-
this.app.use('/ai-hub', express_1.default.static(resolveAiHubPublicDir()));
|
|
1204
|
-
// Issue #489: Serve the Word task pane assets at /word-taskpane/*.
|
|
1205
|
-
// Office JS appends ?_host_Info=Word$Win32$... to every request — we must
|
|
1206
|
-
// strip the query string before resolving the file path, otherwise every
|
|
1207
|
-
// request returns 404. express.static does NOT strip query strings, so we
|
|
1208
|
-
// use a custom middleware that resolves the pathname manually.
|
|
1209
|
-
if (this.wordTaskpaneDir) {
|
|
1210
|
-
const wordDir = this.wordTaskpaneDir; // capture for closure
|
|
1211
|
-
this.app.use('/word-taskpane', (req, res, next) => {
|
|
1212
|
-
// Chrome Private Network Access: public origin (word-edit.officeapps.live.com)
|
|
1213
|
-
// loading a private-network resource (localhost) requires this header on both the
|
|
1214
|
-
// preflight OPTIONS response and the final GET response.
|
|
1215
|
-
res.setHeader('Access-Control-Allow-Private-Network', 'true');
|
|
1216
|
-
res.setHeader('Access-Control-Allow-Origin', '*');
|
|
1217
|
-
res.setHeader('Access-Control-Allow-Methods', 'GET, OPTIONS');
|
|
1218
|
-
res.setHeader('Access-Control-Allow-Headers', 'Content-Type');
|
|
1219
|
-
if (req.method === 'OPTIONS') {
|
|
1220
|
-
res.sendStatus(204);
|
|
1221
|
-
return;
|
|
1222
|
-
}
|
|
1223
|
-
// Strip query string — Office appends ?_host_Info=Word$Win32$... to every request
|
|
1224
|
-
const { pathname } = new URL(req.url, 'http://localhost');
|
|
1225
|
-
const target = pathname === '/' || pathname === '' ? '/taskpane.html' : pathname;
|
|
1226
|
-
// Prevent path traversal
|
|
1227
|
-
const safeTarget = path_1.default.normalize(target).replace(/^(\.\.(\/|\\|$))+/, '');
|
|
1228
|
-
const filePath = path_1.default.join(wordDir, safeTarget);
|
|
1229
|
-
if (!filePath.startsWith(wordDir + path_1.default.sep) && filePath !== wordDir) {
|
|
1230
|
-
res.status(403).end();
|
|
1231
|
-
return;
|
|
1232
|
-
}
|
|
1233
|
-
const ext = path_1.default.extname(filePath);
|
|
1234
|
-
const contentTypes = {
|
|
1235
|
-
'.html': 'text/html; charset=utf-8',
|
|
1236
|
-
'.css': 'text/css; charset=utf-8',
|
|
1237
|
-
'.js': 'application/javascript; charset=utf-8',
|
|
1238
|
-
'.xml': 'application/xml; charset=utf-8',
|
|
1239
|
-
};
|
|
1240
|
-
const contentType = contentTypes[ext] || 'text/plain; charset=utf-8';
|
|
1241
|
-
fs_1.default.readFile(filePath, (err, data) => {
|
|
1242
|
-
if (err) {
|
|
1243
|
-
next(); // fall through to 404
|
|
1244
|
-
return;
|
|
1245
|
-
}
|
|
1246
|
-
res.setHeader('Content-Type', contentType);
|
|
1247
|
-
res.end(data);
|
|
1248
|
-
});
|
|
1249
|
-
});
|
|
1250
|
-
}
|
|
1251
|
-
this.app.get(['/word-taskpane/config.js', '/powerpoint-taskpane/config.js'], (_req, res) => {
|
|
1252
|
-
const port = this.httpPort || 43091;
|
|
1253
|
-
const origin = `http://127.0.0.1:${port}`;
|
|
1254
|
-
res.setHeader('Content-Type', 'application/javascript; charset=utf-8');
|
|
1255
|
-
res.setHeader('Access-Control-Allow-Origin', '*');
|
|
1256
|
-
res.end(`window.FRAIM_HUB_ORIGIN=${JSON.stringify(origin)};\n`);
|
|
1257
|
-
});
|
|
1258
|
-
this.app.get('/health', (_req, res) => {
|
|
1259
|
-
res.json({ status: 'ok', service: 'fraim-ai-hub' });
|
|
1260
|
-
});
|
|
1261
|
-
// Extended health endpoint for trigger surfaces (browser extension, Office add-ins, tray)
|
|
1262
|
-
this.app.get('/api/health', (_req, res) => {
|
|
1263
|
-
res.json({ status: 'ok', service: 'fraim-ai-hub' });
|
|
1264
|
-
});
|
|
1265
|
-
// Issue #659: Inbound auth middleware for remote-exposed hubs.
|
|
1266
|
-
// Activated only when FRAIM_HUB_AUTH_TOKEN env var is set. Gates all
|
|
1267
|
-
// /api/ai-hub/* routes. /health and /api/health are registered above
|
|
1268
|
-
// this block and are never gated.
|
|
1269
|
-
if (process.env.FRAIM_HUB_AUTH_TOKEN) {
|
|
1270
|
-
const expectedToken = process.env.FRAIM_HUB_AUTH_TOKEN;
|
|
1271
|
-
this.app.use('/api/ai-hub', (req, res, next) => {
|
|
1272
|
-
const token = req.headers['x-hub-auth'];
|
|
1273
|
-
if (typeof token !== 'string' ||
|
|
1274
|
-
token.length !== expectedToken.length ||
|
|
1275
|
-
!(0, crypto_1.timingSafeEqual)(Buffer.from(token), Buffer.from(expectedToken))) {
|
|
1276
|
-
res.status(401).json({ error: 'Unauthorized' });
|
|
1277
|
-
return;
|
|
1278
|
-
}
|
|
1279
|
-
next();
|
|
1280
|
-
});
|
|
1281
|
-
}
|
|
1282
|
-
this.registerRoutes();
|
|
1283
|
-
}
|
|
1284
|
-
getApp() {
|
|
1285
|
-
return this.app;
|
|
1286
|
-
}
|
|
1287
|
-
async start(port) {
|
|
1288
|
-
this.httpPort = port;
|
|
1289
|
-
await new Promise((resolve, reject) => {
|
|
1290
|
-
this.httpServer = this.app.listen(port, '127.0.0.1');
|
|
1291
|
-
this.httpServer.once('listening', () => resolve());
|
|
1292
|
-
this.httpServer.once('error', (error) => reject(error));
|
|
1293
|
-
});
|
|
1294
|
-
// #521: publish where the shared browser lives + how to bring it up, so every
|
|
1295
|
-
// agent inherits them. The browser is NOT launched here — it's started lazily
|
|
1296
|
-
// (the browser-use skill POSTs /browser/start when it actually needs one, which
|
|
1297
|
-
// also relaunches it if the manager closed the window). Deterministic endpoint,
|
|
1298
|
-
// so it's known even before the browser is running.
|
|
1299
|
-
process.env.FRAIM_BROWSER_CDP_ENDPOINT = this.managedBrowser.cdpEndpoint();
|
|
1300
|
-
process.env.FRAIM_HUB_BASE_URL = `http://127.0.0.1:${port}`;
|
|
1301
|
-
// Issue #578: rehydrate active scheduled deployments from disk.
|
|
1302
|
-
this.rehydrateScheduledDeployments();
|
|
1303
|
-
// Start HTTPS server when a cert bundle and port are provided.
|
|
1304
|
-
// Word Online requires HTTPS; the HTTPS server shares the same Express app
|
|
1305
|
-
// so all routes (including /word-taskpane/*) are available over both protocols.
|
|
1306
|
-
if (this.httpsPort && this.certBundle) {
|
|
1307
|
-
await new Promise((resolve, reject) => {
|
|
1308
|
-
this.httpsServer = https_1.default.createServer({ key: this.certBundle.key, cert: this.certBundle.cert }, this.app);
|
|
1309
|
-
this.httpsServer.listen(this.httpsPort, '127.0.0.1');
|
|
1310
|
-
this.httpsServer.once('listening', () => resolve());
|
|
1311
|
-
this.httpsServer.once('error', (error) => reject(error));
|
|
1312
|
-
});
|
|
1313
|
-
}
|
|
1314
|
-
// #521: when the shared browser is enabled, bring it up at boot so the CDP
|
|
1315
|
-
// endpoint is already published when the first run spawns. Best-effort — a
|
|
1316
|
-
// missing browser must not stop the Hub from serving.
|
|
1317
|
-
if (process.env.FRAIM_BROWSER_ENABLED === '1') {
|
|
1318
|
-
try {
|
|
1319
|
-
const r = await this.ensureManagedBrowser();
|
|
1320
|
-
console.log(`[ai-hub] shared browser ready at ${r.endpoint}${r.reused ? ' (reused)' : ''}`);
|
|
1321
|
-
}
|
|
1322
|
-
catch (err) {
|
|
1323
|
-
console.warn('[ai-hub] shared browser not started:', err instanceof Error ? err.message : err);
|
|
1324
|
-
}
|
|
1325
|
-
}
|
|
1326
|
-
}
|
|
1327
|
-
async stop() {
|
|
1328
|
-
const closeServer = (srv) => new Promise((resolve, reject) => {
|
|
1329
|
-
const closable = srv;
|
|
1330
|
-
closable.closeIdleConnections?.();
|
|
1331
|
-
const forceCloseTimer = setTimeout(() => {
|
|
1332
|
-
closable.closeIdleConnections?.();
|
|
1333
|
-
closable.closeAllConnections?.();
|
|
1334
|
-
}, 250);
|
|
1335
|
-
srv.close((error) => {
|
|
1336
|
-
clearTimeout(forceCloseTimer);
|
|
1337
|
-
if (error)
|
|
1338
|
-
reject(error);
|
|
1339
|
-
else
|
|
1340
|
-
resolve();
|
|
1341
|
-
});
|
|
1342
|
-
});
|
|
1343
|
-
if (this.httpsServer) {
|
|
1344
|
-
await closeServer(this.httpsServer);
|
|
1345
|
-
this.httpsServer = undefined;
|
|
1346
|
-
}
|
|
1347
|
-
if (this.httpServer) {
|
|
1348
|
-
await closeServer(this.httpServer);
|
|
1349
|
-
this.httpServer = undefined;
|
|
1350
|
-
}
|
|
1351
|
-
// Issue #578: stop all active scheduled deployments.
|
|
1352
|
-
for (const [, task] of this.cronHandles) {
|
|
1353
|
-
task.stop();
|
|
1354
|
-
}
|
|
1355
|
-
this.cronHandles.clear();
|
|
1356
|
-
// #521: tear down the shared browser if WE launched it (stop() no-ops on a
|
|
1357
|
-
// browser the manager owns).
|
|
1358
|
-
this.managedBrowser.stop();
|
|
1359
|
-
}
|
|
1360
|
-
getHttpsPort() { return this.httpsPort; }
|
|
1361
|
-
knownProjects(projectPath, extras = []) {
|
|
1362
|
-
const normalizedProjectPath = path_1.default.resolve(projectPath || this.projectPath);
|
|
1363
|
-
const preferences = this.preferencesStore.load(normalizedProjectPath);
|
|
1364
|
-
const conversationProjects = this.conversationStore.listProjectPaths().map((folderPath) => ({ folderPath }));
|
|
1365
|
-
return (0, preferences_1.normalizeAiHubProjectList)([
|
|
1366
|
-
...(preferences.projects || []),
|
|
1367
|
-
...conversationProjects,
|
|
1368
|
-
...extras,
|
|
1369
|
-
], normalizedProjectPath, { removedProjectPaths: preferences.removedProjectPaths || [] });
|
|
1370
|
-
}
|
|
1371
|
-
async bootstrapResponse(projectPath) {
|
|
1372
|
-
const normalizedProjectPath = path_1.default.resolve(projectPath || this.projectPath);
|
|
1373
|
-
const employees = this.hostRuntime.detectEmployees();
|
|
1374
|
-
let preferences = this.preferencesStore.load(normalizedProjectPath);
|
|
1375
|
-
// If the stored employee isn't available on this machine, auto-select the
|
|
1376
|
-
// first available one so the Hub never opens showing "(unavailable)".
|
|
1377
|
-
const storedAvailable = employees.find((e) => e.id === preferences.employeeId)?.available ?? false;
|
|
1378
|
-
if (!storedAvailable) {
|
|
1379
|
-
const firstAvailable = employees.find((e) => e.available);
|
|
1380
|
-
if (firstAvailable) {
|
|
1381
|
-
preferences = { ...preferences, employeeId: firstAvailable.id };
|
|
1382
|
-
this.preferencesStore.save(preferences);
|
|
1383
|
-
}
|
|
1384
|
-
}
|
|
1385
|
-
const project = (0, catalog_1.summarizeProject)(normalizedProjectPath);
|
|
1386
|
-
const catalogOptions = { includeRegistry: true };
|
|
1387
|
-
const rawJobs = (0, catalog_1.discoverEmployeeJobs)(normalizedProjectPath, catalogOptions);
|
|
1388
|
-
// Issue #566 (R7): jobs already carry `personalized` from catalog discovery
|
|
1389
|
-
// (true for the fraim/personalized-employee layer). The Hub renders a plain
|
|
1390
|
-
// "Personalized" marking from that flag — no author/attribution is tracked.
|
|
1391
|
-
const jobs = rawJobs
|
|
1392
|
-
.filter((job) => !FRAIM_INTERNAL_JOB_IDS.has(job.id))
|
|
1393
|
-
.map((job) => ({
|
|
1394
|
-
...job,
|
|
1395
|
-
requiredPersonaKey: getProtectedPersonaForHubJob(job.id),
|
|
1396
|
-
}));
|
|
1397
|
-
const managerTemplates = (0, catalog_1.discoverManagerTemplates)(normalizedProjectPath, catalogOptions);
|
|
1398
|
-
// Issue #750: the apiKey always comes from ~/.fraim/config.json — no header
|
|
1399
|
-
// override, no ai-hub-state.json copy, no fallback chain.
|
|
1400
|
-
const resolvedApiKey = resolveApiKey();
|
|
1401
|
-
const { personas, subscriptionActive, workspaceId, userKey } = await this.computePersonas(resolvedApiKey);
|
|
1402
|
-
const managerTeam = await this.computeManagerTeam(resolvedApiKey);
|
|
1403
|
-
const resolvedUserEmail = userKey ?? null;
|
|
1404
|
-
const projects = this.knownProjects(normalizedProjectPath);
|
|
1405
|
-
preferences = { ...preferences, projectPath: normalizedProjectPath, projects };
|
|
1406
|
-
this.preferencesStore.save(preferences);
|
|
1407
|
-
// Issue #347: enrich the activeRun the same way GET /runs/:id does
|
|
1408
|
-
// so the bootstrap surface (used on first paint) carries stages and
|
|
1409
|
-
// live totals — not just the raw run state.
|
|
1410
|
-
const latest = this.runRegistry.listLatest();
|
|
1411
|
-
const activeRun = latest ? this.enrichRunForResponse(latest) : undefined;
|
|
1412
|
-
return {
|
|
1413
|
-
title: 'AI Hub',
|
|
1414
|
-
remoteBaseUrl: (0, remote_hub_gateway_1.resolveFraimRemoteUrl)(),
|
|
1415
|
-
// #755: the running build version, so the account menu can show it and flag
|
|
1416
|
-
// when a newer version is available (see GET /api/ai-hub/version for `latest`).
|
|
1417
|
-
version: (0, version_utils_1.getFraimVersion)(),
|
|
1418
|
-
project,
|
|
1419
|
-
// Issue #750: `apiKey` is no longer a persisted AiHubPreferences field —
|
|
1420
|
-
// it is overlaid on the wire response only, freshly resolved from
|
|
1421
|
-
// ~/.fraim/config.json, so public/ai-hub/script.js's existing
|
|
1422
|
-
// tfConnectedApiKey()/tfConnectedSurfaceUrl read path needs no changes.
|
|
1423
|
-
preferences: { ...preferences, apiKey: resolvedApiKey },
|
|
1424
|
-
categories: (0, catalog_1.getAiHubCategories)(normalizedProjectPath, catalogOptions),
|
|
1425
|
-
jobs,
|
|
1426
|
-
managerTemplates,
|
|
1427
|
-
employees,
|
|
1428
|
-
personas,
|
|
1429
|
-
subscriptionActive,
|
|
1430
|
-
activeRun,
|
|
1431
|
-
projects,
|
|
1432
|
-
// Issue #512 (S3) — additive manager-flow projections.
|
|
1433
|
-
firstRun: this.computeFirstRun(normalizedProjectPath, jobs.length, personas),
|
|
1434
|
-
teamContext: this.computeTeamContext(normalizedProjectPath),
|
|
1435
|
-
brain: this.computeBrain(normalizedProjectPath, jobs.length + managerTemplates.length, resolvedUserEmail),
|
|
1436
|
-
// #533/#750: the resolved account email (from the same ~/.fraim/config.json
|
|
1437
|
-
// apiKey used for personas above), or null when not connected — so the
|
|
1438
|
-
// profile card and personas can never disagree about who's signed in.
|
|
1439
|
-
userEmail: resolvedUserEmail,
|
|
1440
|
-
// #744: the org cobrand identity (name/color/logo) from the org context
|
|
1441
|
-
// storage, or null when unset so the Hub falls back to FRAIM identity.
|
|
1442
|
-
orgBrand: (0, learning_context_builder_1.readOrgBrand)(normalizedProjectPath),
|
|
1443
|
-
assignments: { byProject: {}, source: 'client-localStorage' },
|
|
1444
|
-
// Issue #538 — source of truth for the "Hire a human manager" UI. Lazy-required
|
|
1445
|
-
// (not a top-level import) so the lightweight client CLI paths that import this
|
|
1446
|
-
// module for findAvailablePort do not eagerly load server-only config (repro #422).
|
|
1447
|
-
managerHiring: buildHubManagerHiringCatalog(),
|
|
1448
|
-
// Issue #540: server-authoritative manager team (personas assigned by this manager).
|
|
1449
|
-
managerTeam,
|
|
1450
|
-
};
|
|
1451
|
-
}
|
|
1452
|
-
conversationRecordFromRun(run) {
|
|
1453
|
-
const lastUpdatedAt = run.updatedAt || new Date().toISOString();
|
|
1454
|
-
const stages = deriveStages(run, run.projectPath);
|
|
1455
|
-
return {
|
|
1456
|
-
id: run.conversationId || run.id,
|
|
1457
|
-
projectPath: path_1.default.resolve(run.projectPath),
|
|
1458
|
-
title: run.conversationTitle || run.jobTitle || run.jobId,
|
|
1459
|
-
jobId: run.jobId,
|
|
1460
|
-
jobTitle: run.jobTitle || run.jobId,
|
|
1461
|
-
agentName: run.hostId,
|
|
1462
|
-
personaKey: run.personaKey ?? null,
|
|
1463
|
-
runId: run.id,
|
|
1464
|
-
sessionId: run.sessionId || null,
|
|
1465
|
-
status: run.status,
|
|
1466
|
-
createdAt: run.createdAt,
|
|
1467
|
-
lastUpdatedAt,
|
|
1468
|
-
messages: run.messages.map((message) => ({
|
|
1469
|
-
role: message.role,
|
|
1470
|
-
text: message.text,
|
|
1471
|
-
at: Date.parse(message.createdAt) || Date.now(),
|
|
1472
|
-
})),
|
|
1473
|
-
events: run.events.map((event) => ({
|
|
1474
|
-
channel: event.channel,
|
|
1475
|
-
text: event.text,
|
|
1476
|
-
})),
|
|
1477
|
-
artifacts: run.artifacts || [],
|
|
1478
|
-
reviewHandoff: run.reviewHandoff || null,
|
|
1479
|
-
delegation: run.delegation || null,
|
|
1480
|
-
delegationTaskId: run.delegationTaskId || null,
|
|
1481
|
-
managedByRunId: run.managedByRunId || null,
|
|
1482
|
-
managedByPersonaKey: run.managedByPersonaKey || null,
|
|
1483
|
-
humanCoachingDisabled: run.humanCoachingDisabled || false,
|
|
1484
|
-
managedReviewStatus: run.managedReviewStatus || null,
|
|
1485
|
-
compareMode: run.runRole === 'fraim' && run.compareRunId ? 'ab' : undefined,
|
|
1486
|
-
compareRunId: run.compareRunId || null,
|
|
1487
|
-
// Issue #578: preserve trigger source so the UI can render the chip.
|
|
1488
|
-
sourceTrigger: run.sourceTrigger,
|
|
1489
|
-
// Issue #708: carry the invocation scope so the record lands in (and is keyed to)
|
|
1490
|
-
// the right bucket. Falls back to the legacy client `invokedArea` when present.
|
|
1491
|
-
scope: run.scope
|
|
1492
|
-
?? run.invokedArea
|
|
1493
|
-
?? 'project',
|
|
1494
|
-
run: {
|
|
1495
|
-
stages,
|
|
1496
|
-
currentPhase: run.currentPhase || null,
|
|
1497
|
-
phaseHistory: run.phaseHistory || [],
|
|
1498
|
-
totals: run.totals || null,
|
|
1499
|
-
runDiscriminant: run.runDiscriminant || null,
|
|
1500
|
-
},
|
|
1501
|
-
};
|
|
1502
|
-
}
|
|
1503
|
-
persistRunConversation(run, activeId) {
|
|
1504
|
-
try {
|
|
1505
|
-
// Issue #708: route the record to its scope bucket (manager/company runs get a
|
|
1506
|
-
// project-independent home); project runs continue to key by project path.
|
|
1507
|
-
const record = this.conversationRecordFromRun(run);
|
|
1508
|
-
const key = (0, conversation_store_1.conversationScopeKey)(record.scope, run.projectPath);
|
|
1509
|
-
this.conversationStore.upsertConversation(key, record, activeId);
|
|
1510
|
-
}
|
|
1511
|
-
catch (error) {
|
|
1512
|
-
console.warn('[ai-hub] conversation store write failed:', error instanceof Error ? error.message : error);
|
|
1513
|
-
}
|
|
1514
|
-
}
|
|
1515
|
-
// Issue #512 (S3, R13) — derive the four Get-started step states, then let any
|
|
1516
|
-
// persisted `true` in ~/.fraim/{install-state,preferences}.json override the
|
|
1517
|
-
// derivation (so a completed step stays completed even if its signal vanishes).
|
|
1518
|
-
maybeStartDelegatedChildRuns(managerRun) {
|
|
1519
|
-
const ledger = managerRun.delegation;
|
|
1520
|
-
if (!ledger || managerRun.managedByRunId)
|
|
1521
|
-
return;
|
|
1522
|
-
managerRun.orchestratedDelegationTaskIds = managerRun.orchestratedDelegationTaskIds || [];
|
|
1523
|
-
const started = new Set(managerRun.orchestratedDelegationTaskIds);
|
|
1524
|
-
for (const task of ledger.tasks || []) {
|
|
1525
|
-
if (!task.taskId || started.has(task.taskId))
|
|
1526
|
-
continue;
|
|
1527
|
-
if (!task.jobId || !this.delegationDependenciesSatisfied(ledger, task.dependsOn || []))
|
|
1528
|
-
continue;
|
|
1529
|
-
if (!task.personaKey)
|
|
1530
|
-
task.personaKey = getProtectedPersonaForHubJob(task.jobId);
|
|
1531
|
-
this.startDelegatedChildRun(managerRun, task.taskId);
|
|
1532
|
-
started.add(task.taskId);
|
|
1533
|
-
managerRun.orchestratedDelegationTaskIds.push(task.taskId);
|
|
1534
|
-
}
|
|
1535
|
-
}
|
|
1536
|
-
delegationDependenciesSatisfied(ledger, dependsOn) {
|
|
1537
|
-
if (!dependsOn.length)
|
|
1538
|
-
return true;
|
|
1539
|
-
const terminal = new Set(['reviewed', 'completed']);
|
|
1540
|
-
return dependsOn.every((dependency) => {
|
|
1541
|
-
const matched = ledger.tasks.find((task) => task.taskId === dependency || task.jobId === dependency);
|
|
1542
|
-
return !!matched && terminal.has(matched.status);
|
|
1543
|
-
});
|
|
1544
|
-
}
|
|
1545
|
-
startDelegatedChildRun(managerRun, taskId) {
|
|
1546
|
-
const ledger = managerRun.delegation;
|
|
1547
|
-
const task = ledger?.tasks.find((entry) => entry.taskId === taskId);
|
|
1548
|
-
if (!ledger || !task || !task.jobId)
|
|
1549
|
-
return;
|
|
1550
|
-
if (!task.personaKey)
|
|
1551
|
-
task.personaKey = getProtectedPersonaForHubJob(task.jobId);
|
|
1552
|
-
const resolvedJob = this.resolveHubJob(managerRun.projectPath, task.jobId);
|
|
1553
|
-
if (!resolvedJob) {
|
|
1554
|
-
task.status = 'blocked';
|
|
1555
|
-
task.latestSummary = `Delegation blocked: job "${task.jobId}" is not available in this project.`;
|
|
1556
|
-
managerRun.events.push((0, hosts_1.createHubEvent)('system', task.latestSummary));
|
|
1557
|
-
this.persistRunConversation(managerRun, managerRun.conversationId || managerRun.id);
|
|
1558
|
-
return;
|
|
1559
|
-
}
|
|
1560
|
-
const childConversationId = task.conversationId || `${managerRun.id}-${task.taskId}`;
|
|
1561
|
-
const now = new Date().toISOString();
|
|
1562
|
-
const childInstructions = [
|
|
1563
|
-
task.instructions || task.latestSummary || `Complete the delegated workstream: ${task.title}.`,
|
|
1564
|
-
'',
|
|
1565
|
-
task.personaKey
|
|
1566
|
-
? `You are working as ${task.personaKey} for the manager job.`
|
|
1567
|
-
: 'You are working as the specialist assigned by this delegated job.',
|
|
1568
|
-
`Parent objective: ${ledger.objective}.`,
|
|
1569
|
-
task.reviewJobId ? `Manager review route: this output should be reviewed using ${task.reviewJobId}.` : '',
|
|
1570
|
-
'Submit a concise deliverable summary and any artifact references back to Mandy.',
|
|
1571
|
-
'This is a delegated subtask, not an end-to-end FRAIM submission workflow. After you provide the deliverable for Mandy, stop. Do not create evidence docs, open or update PRs, update GitHub issues, or ask the human for review.',
|
|
1572
|
-
'Do not ask the human for coaching; Mandy is your manager for this workstream.',
|
|
1573
|
-
].join('\n');
|
|
1574
|
-
const prepared = this.prepareStartPayload(managerRun.projectPath, managerRun.hostId, task.jobId, childInstructions);
|
|
1575
|
-
const childRun = {
|
|
1576
|
-
id: (0, crypto_1.randomUUID)(),
|
|
1577
|
-
conversationId: childConversationId,
|
|
1578
|
-
conversationTitle: task.title,
|
|
1579
|
-
jobTitle: task.title,
|
|
1580
|
-
jobId: prepared.jobId || task.jobId,
|
|
1581
|
-
hostId: managerRun.hostId,
|
|
1582
|
-
projectPath: managerRun.projectPath,
|
|
1583
|
-
status: 'running',
|
|
1584
|
-
createdAt: now,
|
|
1585
|
-
updatedAt: now,
|
|
1586
|
-
messages: [(0, hosts_1.createHubMessage)('manager', `Mandy delegated: ${task.title}\n\n${childInstructions}`)],
|
|
1587
|
-
events: [(0, hosts_1.createHubEvent)('system', `Mandy started delegated workstream ${task.taskId} for ${task.personaKey || task.jobId}.`)],
|
|
1588
|
-
currentPhase: null,
|
|
1589
|
-
phaseHistory: [],
|
|
1590
|
-
totals: emptyTotals(),
|
|
1591
|
-
lastStatusChangeAt: now,
|
|
1592
|
-
personaKey: task.personaKey,
|
|
1593
|
-
delegationTaskId: task.taskId,
|
|
1594
|
-
managedByRunId: managerRun.id,
|
|
1595
|
-
managedByPersonaKey: managerRun.personaKey || ledger.orchestratorPersonaKey || 'mandy',
|
|
1596
|
-
humanCoachingDisabled: true,
|
|
1597
|
-
};
|
|
1598
|
-
task.runId = childRun.id;
|
|
1599
|
-
task.conversationId = childConversationId;
|
|
1600
|
-
task.status = 'running';
|
|
1601
|
-
this.runRegistry.create(childRun, {});
|
|
1602
|
-
this.persistRunConversation(childRun);
|
|
1603
|
-
this.persistRunConversation(managerRun, managerRun.conversationId || managerRun.id);
|
|
1604
|
-
const child = this.hostRuntime.startRun(managerRun.hostId, managerRun.projectPath, prepared.message, {
|
|
1605
|
-
onEvent: (event, channel) => {
|
|
1606
|
-
this.runRegistry.update(childRun.id, (current) => {
|
|
1607
|
-
if (event.sessionId)
|
|
1608
|
-
current.sessionId = event.sessionId;
|
|
1609
|
-
appendHostMessage(current, managerRun.hostId, event, channel);
|
|
1610
|
-
if (event.raw) {
|
|
1611
|
-
current.events.push((0, hosts_1.createHubEvent)(channel, event.raw));
|
|
1612
|
-
applyReviewProjection(current, event.raw);
|
|
1613
|
-
}
|
|
1614
|
-
if (event.agentIdentity)
|
|
1615
|
-
applyAgentIdentitySignal(current, event.agentIdentity);
|
|
1616
|
-
if (event.fraimJob)
|
|
1617
|
-
this.applyFraimJobSignalToRun(current, event.fraimJob);
|
|
1618
|
-
if (event.seekMentoring)
|
|
1619
|
-
this.applySeekMentoringSignalToRun(current, event.seekMentoring);
|
|
1620
|
-
if (event.usage)
|
|
1621
|
-
applyUsageSignal(current, event.usage);
|
|
1622
|
-
});
|
|
1623
|
-
const updatedChild = this.runRegistry.get(childRun.id);
|
|
1624
|
-
if (updatedChild)
|
|
1625
|
-
this.persistRunConversation(updatedChild);
|
|
1626
|
-
},
|
|
1627
|
-
onExit: (exitCode) => {
|
|
1628
|
-
this.runRegistry.update(childRun.id, (current) => {
|
|
1629
|
-
current.exitCode = exitCode;
|
|
1630
|
-
current.status = exitCode === 0 ? 'completed' : 'failed';
|
|
1631
|
-
current.events.push((0, hosts_1.createHubEvent)('system', `Delegated workstream exited with code ${exitCode ?? 'unknown'}.`));
|
|
1632
|
-
});
|
|
1633
|
-
const updatedChild = this.runRegistry.get(childRun.id);
|
|
1634
|
-
if (updatedChild) {
|
|
1635
|
-
this.persistRunConversation(updatedChild);
|
|
1636
|
-
this.markDelegatedChildSubmitted(managerRun.id, updatedChild);
|
|
1637
|
-
this.notifyManagerOfDelegatedChild(managerRun.id, updatedChild);
|
|
1638
|
-
}
|
|
1639
|
-
this.runRegistry.dispose(childRun.id);
|
|
1640
|
-
},
|
|
1641
|
-
}, startSessionSeedForHost(managerRun.hostId, childRun.id));
|
|
1642
|
-
this.runRegistry.attachChildIfRunning(childRun.id, child);
|
|
1643
|
-
}
|
|
1644
|
-
notifyManagerOfDelegatedChild(managerRunId, childRun) {
|
|
1645
|
-
const managerRun = this.runRegistry.get(managerRunId);
|
|
1646
|
-
if (!managerRun) {
|
|
1647
|
-
childRun.events.push((0, hosts_1.createHubEvent)('system', 'Delegated deliverable could not be routed because the manager run is unavailable.'));
|
|
1648
|
-
this.persistRunConversation(childRun);
|
|
1649
|
-
return;
|
|
1650
|
-
}
|
|
1651
|
-
if (!managerRun.sessionId || managerRun.status === 'running') {
|
|
1652
|
-
this.enqueueDelegatedReview(managerRun, childRun, !managerRun.sessionId ? 'manager session is unavailable' : 'manager is still running');
|
|
1653
|
-
return;
|
|
1654
|
-
}
|
|
1655
|
-
const latest = [...(childRun.messages || [])].reverse().find((message) => message.role === 'employee')?.text || '';
|
|
1656
|
-
const artifacts = childRun.reviewHandoff?.artifacts?.map((artifact) => artifact.label || artifact.path || artifact.url).filter(Boolean).join(', ') || 'none reported';
|
|
1657
|
-
const reviewPrompt = [
|
|
1658
|
-
`Delegated workstream submitted by ${childRun.personaKey || 'a peer agent'}: ${childRun.conversationTitle || childRun.jobTitle || childRun.jobId}.`,
|
|
1659
|
-
`Status: ${childRun.status}.`,
|
|
1660
|
-
`Latest deliverable summary: ${latest || 'No employee summary captured.'}`,
|
|
1661
|
-
`Artifacts: ${artifacts}.`,
|
|
1662
|
-
childRun.delegationTaskId ? this.delegatedReviewRouteLine(managerRun, childRun.delegationTaskId) : '',
|
|
1663
|
-
'',
|
|
1664
|
-
'As Mandy, review this child deliverable. If it is acceptable, say it is reviewed for synthesis. If it needs correction, write specific coaching feedback for that child workstream.',
|
|
1665
|
-
].join('\n');
|
|
1666
|
-
this.continueManagerRunForDelegation(managerRun, reviewPrompt, `Mandy reviews ${childRun.conversationTitle || childRun.jobTitle || childRun.jobId}`, childRun);
|
|
1667
|
-
}
|
|
1668
|
-
delegatedReviewRouteLine(managerRun, taskId) {
|
|
1669
|
-
const task = managerRun.delegation?.tasks.find((entry) => entry.taskId === taskId);
|
|
1670
|
-
if (!task?.reviewJobId)
|
|
1671
|
-
return '';
|
|
1672
|
-
return `Declared manager review job: ${task.reviewJobId}${task.reviewType ? ` (${task.reviewType})` : ''}. Use that review standard when judging the deliverable.`;
|
|
1673
|
-
}
|
|
1674
|
-
enqueueDelegatedReview(managerRun, childRun, reason) {
|
|
1675
|
-
managerRun.pendingDelegatedReviewChildRunIds = managerRun.pendingDelegatedReviewChildRunIds || [];
|
|
1676
|
-
if (!managerRun.pendingDelegatedReviewChildRunIds.includes(childRun.id)) {
|
|
1677
|
-
managerRun.pendingDelegatedReviewChildRunIds.push(childRun.id);
|
|
1678
|
-
managerRun.events.push((0, hosts_1.createHubEvent)('system', `Delegated deliverable queued for Mandy review because ${reason}.`));
|
|
1679
|
-
}
|
|
1680
|
-
childRun.events.push((0, hosts_1.createHubEvent)('system', `Deliverable submitted to Mandy; review is queued because ${reason}.`));
|
|
1681
|
-
this.persistRunConversation(managerRun, managerRun.conversationId || managerRun.id);
|
|
1682
|
-
this.persistRunConversation(childRun);
|
|
1683
|
-
}
|
|
1684
|
-
drainPendingDelegatedReviews(managerRun) {
|
|
1685
|
-
if (managerRun.status === 'running' || !managerRun.sessionId)
|
|
1686
|
-
return;
|
|
1687
|
-
const queue = managerRun.pendingDelegatedReviewChildRunIds || [];
|
|
1688
|
-
while (queue.length > 0) {
|
|
1689
|
-
const childRunId = queue.shift();
|
|
1690
|
-
const childRun = this.runRegistry.get(childRunId);
|
|
1691
|
-
if (!childRun || childRun.managedReviewStatus === 'reviewed')
|
|
1692
|
-
continue;
|
|
1693
|
-
managerRun.pendingDelegatedReviewChildRunIds = queue;
|
|
1694
|
-
this.persistRunConversation(managerRun, managerRun.conversationId || managerRun.id);
|
|
1695
|
-
this.notifyManagerOfDelegatedChild(managerRun.id, childRun);
|
|
1696
|
-
return;
|
|
1697
|
-
}
|
|
1698
|
-
managerRun.pendingDelegatedReviewChildRunIds = queue;
|
|
1699
|
-
this.persistRunConversation(managerRun, managerRun.conversationId || managerRun.id);
|
|
1700
|
-
}
|
|
1701
|
-
markDelegatedChildSubmitted(managerRunId, childRun) {
|
|
1702
|
-
const managerRun = this.runRegistry.get(managerRunId);
|
|
1703
|
-
if (!managerRun?.delegation)
|
|
1704
|
-
return;
|
|
1705
|
-
const task = managerRun.delegation.tasks.find((entry) => entry.taskId === childRun.delegationTaskId || entry.runId === childRun.id);
|
|
1706
|
-
if (!task)
|
|
1707
|
-
return;
|
|
1708
|
-
task.status = childRun.status === 'completed' ? 'submitted' : 'failed';
|
|
1709
|
-
const latest = [...(childRun.messages || [])].reverse().find((message) => message.role === 'employee')?.text || '';
|
|
1710
|
-
if (latest)
|
|
1711
|
-
task.latestSummary = latest;
|
|
1712
|
-
if (childRun.reviewHandoff)
|
|
1713
|
-
task.reviewHandoff = childRun.reviewHandoff;
|
|
1714
|
-
const artifacts = childRun.reviewHandoff?.artifacts?.length ? childRun.reviewHandoff.artifacts : childRun.artifacts || [];
|
|
1715
|
-
if (artifacts.length)
|
|
1716
|
-
task.artifacts = artifacts;
|
|
1717
|
-
this.persistRunConversation(managerRun, managerRun.conversationId || managerRun.id);
|
|
1718
|
-
}
|
|
1719
|
-
markDelegatedChildReviewed(childRun, managerRun) {
|
|
1720
|
-
const reviewText = [...(managerRun.messages || [])].reverse().find((message) => message.role === 'employee')?.text || '';
|
|
1721
|
-
childRun.managedReviewStatus = 'reviewed';
|
|
1722
|
-
childRun.updatedAt = new Date().toISOString();
|
|
1723
|
-
if (reviewText) {
|
|
1724
|
-
childRun.messages.push((0, hosts_1.createHubMessage)('manager', `Mandy reviewed this workstream:\n\n${reviewText}`));
|
|
1725
|
-
}
|
|
1726
|
-
childRun.events.push((0, hosts_1.createHubEvent)('system', 'Mandy reviewed the delegated deliverable.'));
|
|
1727
|
-
this.persistRunConversation(childRun);
|
|
1728
|
-
const task = managerRun.delegation?.tasks.find((entry) => entry.taskId === childRun.delegationTaskId || entry.runId === childRun.id);
|
|
1729
|
-
if (task) {
|
|
1730
|
-
task.status = 'reviewed';
|
|
1731
|
-
task.latestSummary = reviewText || task.latestSummary;
|
|
1732
|
-
if (childRun.reviewHandoff)
|
|
1733
|
-
task.reviewHandoff = childRun.reviewHandoff;
|
|
1734
|
-
const artifacts = childRun.reviewHandoff?.artifacts?.length ? childRun.reviewHandoff.artifacts : childRun.artifacts || [];
|
|
1735
|
-
if (artifacts.length)
|
|
1736
|
-
task.artifacts = artifacts;
|
|
1737
|
-
}
|
|
1738
|
-
this.maybeStartDelegatedChildRuns(managerRun);
|
|
1739
|
-
}
|
|
1740
|
-
continueManagerRunForDelegation(managerRun, instructions, display, reviewedChildRun) {
|
|
1741
|
-
if (!managerRun.sessionId || managerRun.status === 'running')
|
|
1742
|
-
return;
|
|
1743
|
-
const prepared = this.prepareContinueMessage(managerRun, instructions);
|
|
1744
|
-
this.runRegistry.update(managerRun.id, (current) => {
|
|
1745
|
-
current.status = 'running';
|
|
1746
|
-
current.messages.push((0, hosts_1.createHubMessage)('manager', display));
|
|
1747
|
-
current.events.push((0, hosts_1.createHubEvent)('system', 'Delegated child output routed back to Mandy for review.'));
|
|
1748
|
-
});
|
|
1749
|
-
const started = this.runRegistry.get(managerRun.id);
|
|
1750
|
-
if (started)
|
|
1751
|
-
this.persistRunConversation(started, started.conversationId || started.id);
|
|
1752
|
-
this.runRegistry.create(managerRun, {});
|
|
1753
|
-
const child = this.hostRuntime.continueRun(managerRun.hostId, managerRun.projectPath, managerRun.sessionId, prepared.message, {
|
|
1754
|
-
onEvent: (event, channel) => {
|
|
1755
|
-
this.runRegistry.update(managerRun.id, (current) => {
|
|
1756
|
-
if (event.sessionId)
|
|
1757
|
-
current.sessionId = event.sessionId;
|
|
1758
|
-
appendHostMessage(current, managerRun.hostId, event, channel);
|
|
1759
|
-
if (event.raw) {
|
|
1760
|
-
current.events.push((0, hosts_1.createHubEvent)(channel, event.raw));
|
|
1761
|
-
applyReviewProjection(current, event.raw);
|
|
1762
|
-
}
|
|
1763
|
-
if (event.agentIdentity)
|
|
1764
|
-
applyAgentIdentitySignal(current, event.agentIdentity);
|
|
1765
|
-
if (event.fraimJob)
|
|
1766
|
-
this.applyFraimJobSignalToRun(current, event.fraimJob);
|
|
1767
|
-
if (event.seekMentoring)
|
|
1768
|
-
this.applySeekMentoringSignalToRun(current, event.seekMentoring);
|
|
1769
|
-
if (event.usage)
|
|
1770
|
-
applyUsageSignal(current, event.usage);
|
|
1771
|
-
});
|
|
1772
|
-
const updated = this.runRegistry.get(managerRun.id);
|
|
1773
|
-
if (updated)
|
|
1774
|
-
this.persistRunConversation(updated, updated.conversationId || updated.id);
|
|
1775
|
-
},
|
|
1776
|
-
onExit: (exitCode) => {
|
|
1777
|
-
this.runRegistry.update(managerRun.id, (current) => {
|
|
1778
|
-
current.exitCode = exitCode;
|
|
1779
|
-
current.status = exitCode === 0 ? 'completed' : 'failed';
|
|
1780
|
-
current.events.push((0, hosts_1.createHubEvent)('system', `Mandy review turn exited with code ${exitCode ?? 'unknown'}.`));
|
|
1781
|
-
});
|
|
1782
|
-
const updated = this.runRegistry.get(managerRun.id);
|
|
1783
|
-
if (updated) {
|
|
1784
|
-
if (exitCode === 0 && reviewedChildRun)
|
|
1785
|
-
this.markDelegatedChildReviewed(reviewedChildRun, updated);
|
|
1786
|
-
this.persistRunConversation(updated, updated.conversationId || updated.id);
|
|
1787
|
-
}
|
|
1788
|
-
this.runRegistry.dispose(managerRun.id);
|
|
1789
|
-
const latestManager = this.runRegistry.get(managerRun.id);
|
|
1790
|
-
if (latestManager)
|
|
1791
|
-
this.drainPendingDelegatedReviews(latestManager);
|
|
1792
|
-
},
|
|
1793
|
-
});
|
|
1794
|
-
this.runRegistry.attachChildIfRunning(managerRun.id, child);
|
|
1795
|
-
}
|
|
1796
|
-
computeFirstRun(projectPath, jobCount, personas) {
|
|
1797
|
-
const tc = (0, learning_context_builder_1.resolveTeamContextFiles)(projectPath);
|
|
1798
|
-
// company = any organization-layer context written by org onboarding.
|
|
1799
|
-
const companyDone = tc.orgContext.present || tc.managerContext.present || tc.orgRules.present || tc.managerRules.present;
|
|
1800
|
-
// hire = any persona hired (entitlement active).
|
|
1801
|
-
const hireDone = personas.some((p) => p.status === 'hired');
|
|
1802
|
-
// project = a project brief exists OR the workspace has FRAIM jobs set up.
|
|
1803
|
-
const projectDone = tc.projectContext.present || tc.projectBrief.present || tc.projectRules.present || tc.projectQa.present || jobCount > 0;
|
|
1804
|
-
const derived = {
|
|
1805
|
-
install: true, // the Hub is running, so the toolchain install step is satisfied.
|
|
1806
|
-
company: companyDone,
|
|
1807
|
-
hire: hireDone,
|
|
1808
|
-
project: projectDone,
|
|
1809
|
-
};
|
|
1810
|
-
const persisted = readPersistedFirstRun();
|
|
1811
|
-
return {
|
|
1812
|
-
install: persisted.install ?? derived.install,
|
|
1813
|
-
company: persisted.company || derived.company,
|
|
1814
|
-
hire: persisted.hire || derived.hire,
|
|
1815
|
-
project: persisted.project || derived.project,
|
|
1816
|
-
};
|
|
1817
|
-
}
|
|
1818
|
-
// Issue #512 (S3, R3) — presence + display paths of the three-layer context
|
|
1819
|
-
// files. Reuses the learning-context-builder resolvers (same layering as the
|
|
1820
|
-
// auto-load Team Context block).
|
|
1821
|
-
computeTeamContext(projectPath) {
|
|
1822
|
-
return (0, learning_context_builder_1.resolveTeamContextFiles)(projectPath);
|
|
1823
|
-
}
|
|
1824
|
-
// Issue #512 (S3, R14) — Brain summary: preserved-learning counts by scope +
|
|
1825
|
-
// registry catalog counts. A read projection; no new storage.
|
|
1826
|
-
// Issue #750: `userEmail` is the config.json-resolved identity, or `null` when
|
|
1827
|
-
// not connected. `organization`/`rawSignals` are identity-independent (org-*
|
|
1828
|
-
// files and repo-level raw signals) and must always be counted regardless of
|
|
1829
|
-
// connection state — only `manager`/`project` (L1, individual-keyed) are
|
|
1830
|
-
// zeroed when not connected, rather than guessing an identity via
|
|
1831
|
-
// resolveLearningUserId's single-stamped-user fallback (a legitimate
|
|
1832
|
-
// convenience for MCP auto-load context, but not appropriate here).
|
|
1833
|
-
computeBrain(projectPath, jobCount, userEmail) {
|
|
1834
|
-
const learnings = (0, learning_context_builder_1.countPreservedLearnings)(projectPath, userEmail || '');
|
|
1835
|
-
if (!userEmail) {
|
|
1836
|
-
learnings.manager = 0;
|
|
1837
|
-
learnings.project = 0;
|
|
1838
|
-
}
|
|
1839
|
-
return {
|
|
1840
|
-
learnings,
|
|
1841
|
-
catalog: {
|
|
1842
|
-
jobs: jobCount,
|
|
1843
|
-
skills: countMarkdownFilesRecursive(path_1.default.join(projectPath, 'registry', 'skills')) +
|
|
1844
|
-
countMarkdownFilesRecursive(path_1.default.join((0, project_fraim_paths_1.getWorkspaceFraimDir)(projectPath), 'ai-employee', 'skills')),
|
|
1845
|
-
rules: countMarkdownFilesRecursive(path_1.default.join(projectPath, 'registry', 'rules')) +
|
|
1846
|
-
countMarkdownFilesRecursive(path_1.default.join((0, project_fraim_paths_1.getWorkspaceFraimDir)(projectPath), 'ai-employee', 'rules')),
|
|
1847
|
-
},
|
|
1848
|
-
};
|
|
1849
|
-
}
|
|
1850
|
-
resolveHubJob(projectPath, jobId) {
|
|
1851
|
-
if (!jobId || jobId === '__freeform__')
|
|
1852
|
-
return null;
|
|
1853
|
-
const employeeJob = (0, catalog_1.discoverEmployeeJobs)(projectPath).find((job) => job.id === jobId);
|
|
1854
|
-
if (employeeJob) {
|
|
1855
|
-
return {
|
|
1856
|
-
id: employeeJob.id,
|
|
1857
|
-
title: employeeJob.title,
|
|
1858
|
-
stubPath: employeeJob.stubPath,
|
|
1859
|
-
personaKey: employeeJob.requiredPersonaKey ?? getProtectedPersonaForHubJob(employeeJob.id),
|
|
1860
|
-
};
|
|
1861
|
-
}
|
|
1862
|
-
const managerTemplate = (0, catalog_1.discoverManagerTemplates)(projectPath).find((job) => job.id === jobId);
|
|
1863
|
-
if (managerTemplate) {
|
|
1864
|
-
return {
|
|
1865
|
-
id: managerTemplate.id,
|
|
1866
|
-
title: managerTemplate.title,
|
|
1867
|
-
stubPath: managerTemplate.stubPath,
|
|
1868
|
-
personaKey: getProtectedPersonaForHubJob(managerTemplate.id),
|
|
1869
|
-
};
|
|
1870
|
-
}
|
|
1871
|
-
return null;
|
|
1872
|
-
}
|
|
1873
|
-
applySeekMentoringSignalToRun(run, signal) {
|
|
1874
|
-
// Issue #732: promote using the stable jobName slug, not the per-call UUID
|
|
1875
|
-
// jobId (resolveHubJob would never match a UUID, leaving a freeform run
|
|
1876
|
-
// unpromoted and then dropping its phase signals).
|
|
1877
|
-
this.maybePromoteFreeformRunToJob(run, signal.jobName || signal.jobId);
|
|
1878
|
-
applySeekMentoringSignal(run, signal);
|
|
1879
|
-
}
|
|
1880
|
-
applyFraimJobSignalToRun(run, signal) {
|
|
1881
|
-
this.maybePromoteFreeformRunToJob(run, signal.jobId);
|
|
1882
|
-
}
|
|
1883
|
-
maybePromoteFreeformRunToJob(run, signalJobId) {
|
|
1884
|
-
const normalizedJobId = (signalJobId || '').trim().toLowerCase();
|
|
1885
|
-
if (run.jobId !== '__freeform__' || !normalizedJobId)
|
|
1886
|
-
return;
|
|
1887
|
-
const metadata = this.resolveHubJob(run.projectPath, normalizedJobId);
|
|
1888
|
-
if (!metadata)
|
|
1889
|
-
return;
|
|
1890
|
-
run.jobId = metadata.id;
|
|
1891
|
-
run.jobTitle = metadata.title;
|
|
1892
|
-
run.personaKey = metadata.personaKey;
|
|
1893
|
-
run.updatedAt = new Date().toISOString();
|
|
1894
|
-
run.events.push((0, hosts_1.createHubEvent)('system', `Recognized FRAIM job ${metadata.id} from structured telemetry.`));
|
|
1895
|
-
}
|
|
1896
|
-
// Lightweight markdown → .docx. Shared by the GET (file path) and POST (inline
|
|
1897
|
-
// content) export routes so a conversational deliverable with no on-disk file
|
|
1898
|
-
// can still be downloaded for Word annotation.
|
|
1899
|
-
prepareStartPayload(projectPath, hostId, selectedJobId, instructions) {
|
|
1900
|
-
const explicit = (0, manager_turns_1.extractExplicitFraimInvocation)(instructions);
|
|
1901
|
-
const resolvedJobId = explicit?.jobId || selectedJobId;
|
|
1902
|
-
if (!resolvedJobId) {
|
|
1903
|
-
throw new Error('Choose a FRAIM job before starting a run, or start with /fraim <job-id>.');
|
|
1904
|
-
}
|
|
1905
|
-
// `display` is the manager conversation bubble. It keeps the user's full
|
|
1906
|
-
// coaching text with the host-specific FRAIM invocation. The agent payload
|
|
1907
|
-
// still receives Hub-only helper blocks such as `[FRAIM shared browser]`
|
|
1908
|
-
// and `[How to talk to me]`; those are omitted from the visible bubble.
|
|
1909
|
-
// #521: the shared-browser guidance is injected HERE, at the Hub layer — never
|
|
1910
|
-
// baked into the registry job/skill. It only appears when a shared browser is
|
|
1911
|
-
// available (env published at boot).
|
|
1912
|
-
const browserNote = (0, managed_browser_1.buildBrowserContextNote)(process.env.FRAIM_BROWSER_CDP_ENDPOINT, process.env.FRAIM_HUB_BASE_URL);
|
|
1913
|
-
const styleNote = (0, manager_turns_1.buildCommunicationStyleNote)();
|
|
1914
|
-
if (resolvedJobId === '__freeform__') {
|
|
1915
|
-
const display = (0, manager_turns_1.buildManagerMessage)(hostId, resolvedJobId, 'start', instructions);
|
|
1916
|
-
return {
|
|
1917
|
-
jobId: resolvedJobId,
|
|
1918
|
-
message: (0, manager_turns_1.buildManagerMessage)(hostId, resolvedJobId, 'start', instructions) + browserNote + styleNote,
|
|
1919
|
-
display,
|
|
1920
|
-
};
|
|
1921
|
-
}
|
|
1922
|
-
const resolvedJob = this.resolveHubJob(projectPath, resolvedJobId);
|
|
1923
|
-
const absoluteStubPath = resolvedJob?.stubPath
|
|
1924
|
-
? [projectPath, resolvedJob.stubPath].join('/').replace(/\\/g, '/').replace(/\/+/g, '/')
|
|
1925
|
-
: undefined;
|
|
1926
|
-
const display = (0, manager_turns_1.buildManagerMessage)(hostId, resolvedJobId, 'start', instructions);
|
|
1927
|
-
return {
|
|
1928
|
-
jobId: resolvedJobId,
|
|
1929
|
-
message: (0, manager_turns_1.buildManagerMessage)(hostId, resolvedJobId, 'start', instructions, absoluteStubPath) + browserNote + styleNote,
|
|
1930
|
-
display,
|
|
1931
|
-
};
|
|
1932
|
-
}
|
|
1933
|
-
prepareContinueMessage(run, instructions, coachingJobId) {
|
|
1934
|
-
// coachingJobId is set when the user selected a manager coaching template
|
|
1935
|
-
// (e.g. follow-your-mentor). When present, it overrides the run's own jobId
|
|
1936
|
-
// as the target of the FRAIM invocation. The server picks the correct
|
|
1937
|
-
// invocation prefix ($fraim / /fraim) based on run.hostId — the UI never
|
|
1938
|
-
// passes raw invocation syntax.
|
|
1939
|
-
const explicit = (0, manager_turns_1.extractExplicitFraimInvocation)(instructions);
|
|
1940
|
-
const effectiveJobId = explicit?.jobId || coachingJobId || run.jobId;
|
|
1941
|
-
const userText = (explicit?.remainder || instructions || '').trim();
|
|
1942
|
-
// Issue #732: a plain continue of the SAME active real job must not re-load
|
|
1943
|
-
// the job via get_fraim_job on every coaching turn — the host session is
|
|
1944
|
-
// resumed with the job already in context, so re-fetching is wasted latency
|
|
1945
|
-
// and tokens (the reported hub slowness) and mints a fresh UUID job id each
|
|
1946
|
-
// time. Only load the job when the continue SWITCHES jobs (a coaching
|
|
1947
|
-
// template or an explicit /fraim <other>). Freeform/adhoc runs keep the
|
|
1948
|
-
// existing buildManagerMessage path (which already emits no invocation).
|
|
1949
|
-
const switchesJob = effectiveJobId !== run.jobId;
|
|
1950
|
-
const showsInvocation = switchesJob || run.jobId === '__freeform__';
|
|
1951
|
-
// Issue #756: the manager bubble must MIRROR what actually happens. A
|
|
1952
|
-
// same-job coaching continue resumes the active job WITHOUT re-invoking it
|
|
1953
|
-
// (the `message` below carries no `/fraim`), so the bubble shows only what
|
|
1954
|
-
// the manager SAID — never a `/fraim <same-job>` line, which reads as a full
|
|
1955
|
-
// re-run of the active job Y and misled managers into thinking coaching
|
|
1956
|
-
// restarts the workflow. Only a real job SWITCH actually issues an
|
|
1957
|
-
// invocation, so only then does the bubble lead with the command. This still
|
|
1958
|
-
// upholds the #730 guarantee: the manager's coaching text is never dropped,
|
|
1959
|
-
// and when a command IS issued it appears in front of that text.
|
|
1960
|
-
// The invocation form (`/fraim <job>` + words) is built once and reused for
|
|
1961
|
-
// both the bubble and the agent payload when a command is actually issued.
|
|
1962
|
-
const invocationForm = showsInvocation
|
|
1963
|
-
? (0, manager_turns_1.buildManagerMessage)(run.hostId, effectiveJobId, 'continue', instructions)
|
|
1964
|
-
: null;
|
|
1965
|
-
// Bubble: the command form for a real switch, else the manager's own words.
|
|
1966
|
-
const display = invocationForm ?? userText;
|
|
1967
|
-
// Agent payload: the same command form for a switch, else a lightweight
|
|
1968
|
-
// same-job continue; the communication-style note is agent-only.
|
|
1969
|
-
const message = (invocationForm ?? (0, manager_turns_1.buildSameJobContinueMessage)(userText)) + (0, manager_turns_1.buildCommunicationStyleNote)();
|
|
1970
|
-
return { message, display };
|
|
1971
|
-
}
|
|
1972
|
-
async computePersonas(apiKey) {
|
|
1973
|
-
const allBundles = listHubPersonaBundles();
|
|
1974
|
-
const fallbackPersonas = allBundles.map((bundle) => ({
|
|
1975
|
-
key: bundle.personaKey,
|
|
1976
|
-
displayName: bundle.catalogMetadata.displayName,
|
|
1977
|
-
role: bundle.catalogMetadata.role,
|
|
1978
|
-
avatarUrl: buildHubPersonaAvatarUrl(bundle.personaKey),
|
|
1979
|
-
pricingLabel: bundle.catalogMetadata.pricingLabel,
|
|
1980
|
-
status: 'locked',
|
|
1981
|
-
hireUrl: buildHubPersonaHireUrl(bundle.personaKey, bundle.defaultHireMode),
|
|
1982
|
-
seatCount: 0,
|
|
1983
|
-
seatsInUse: 0,
|
|
1984
|
-
}));
|
|
1985
|
-
try {
|
|
1986
|
-
// Issue #701: persona state comes from the hosted server (GET /api/personas/me)
|
|
1987
|
-
// via the user's API key — never a local MongoDB connection.
|
|
1988
|
-
const state = await this.remoteGateway.getPersonaState(apiKey);
|
|
1989
|
-
// A null result means we could not reach the authority (no/expired key or a
|
|
1990
|
-
// transport error). That is the ONLY access decision the Hub makes on its own —
|
|
1991
|
-
// render the locked "not-signed-in" fallback. When a state IS returned, its
|
|
1992
|
-
// per-persona `status` is authoritative and rendered verbatim: the hired/locked
|
|
1993
|
-
// decision (feature-off, legacy bypass, per-entitlement gating) lives solely in
|
|
1994
|
-
// persona-entitlement-service.resolvePersonaAccessStatuses — the Hub does not
|
|
1995
|
-
// re-derive it.
|
|
1996
|
-
if (!state) {
|
|
1997
|
-
return { personas: fallbackPersonas, subscriptionActive: false, workspaceId: null, userKey: null };
|
|
1998
|
-
}
|
|
1999
|
-
const verdictByKey = new Map((state.personas || []).map((p) => [p.personaKey, p]));
|
|
2000
|
-
// seatsInUse (manager-team assignments) is display-only accounting fetched
|
|
2001
|
-
// separately; it is not part of the access verdict.
|
|
2002
|
-
const seatsInUseByKey = {};
|
|
2003
|
-
const team = await this.remoteGateway.listManagerTeam(apiKey);
|
|
2004
|
-
for (const entry of team) {
|
|
2005
|
-
seatsInUseByKey[entry.personaKey] = (seatsInUseByKey[entry.personaKey] ?? 0) + 1;
|
|
2006
|
-
}
|
|
2007
|
-
const personas = allBundles.map((bundle) => {
|
|
2008
|
-
const verdict = verdictByKey.get(bundle.personaKey);
|
|
2009
|
-
const status = (verdict?.status ?? 'locked');
|
|
2010
|
-
return {
|
|
2011
|
-
key: bundle.personaKey,
|
|
2012
|
-
displayName: bundle.catalogMetadata.displayName,
|
|
2013
|
-
role: bundle.catalogMetadata.role,
|
|
2014
|
-
avatarUrl: buildHubPersonaAvatarUrl(bundle.personaKey),
|
|
2015
|
-
pricingLabel: status === 'hired' ? '' : bundle.catalogMetadata.pricingLabel,
|
|
2016
|
-
status,
|
|
2017
|
-
hireUrl: buildHubPersonaHireUrl(bundle.personaKey, bundle.defaultHireMode),
|
|
2018
|
-
seatCount: verdict?.seatCount ?? 0,
|
|
2019
|
-
seatsInUse: seatsInUseByKey[bundle.personaKey] ?? 0,
|
|
2020
|
-
};
|
|
2021
|
-
});
|
|
2022
|
-
return { personas, subscriptionActive: state.subscriptionActive, workspaceId: state.workspaceId, userKey: state.userId ?? null };
|
|
2023
|
-
}
|
|
2024
|
-
catch (err) {
|
|
2025
|
-
console.error('[ai-hub] persona lookup failed:', err);
|
|
2026
|
-
return { personas: fallbackPersonas, subscriptionActive: false, workspaceId: null, userKey: null };
|
|
2027
|
-
}
|
|
2028
|
-
}
|
|
2029
|
-
async computeManagerTeam(apiKey) {
|
|
2030
|
-
// Issue #701: manager team comes from the hosted server, not local Mongo.
|
|
2031
|
-
return this.remoteGateway.listManagerTeam(apiKey);
|
|
2032
|
-
}
|
|
2033
|
-
// Issue #750: identity for routes that don't already call computePersonas()
|
|
2034
|
-
// (which fetches this same hosted state for personas/manager-team). Always
|
|
2035
|
-
// sources the apiKey from ~/.fraim/config.json; null means "not connected" —
|
|
2036
|
-
// no local-guess fallback.
|
|
2037
|
-
async resolveHubIdentity() {
|
|
2038
|
-
const apiKey = resolveApiKey();
|
|
2039
|
-
if (!apiKey)
|
|
2040
|
-
return null;
|
|
2041
|
-
const state = await this.remoteGateway.getPersonaState(apiKey);
|
|
2042
|
-
return state?.userId ?? null;
|
|
2043
|
-
}
|
|
2044
|
-
// Issue #750: shared by the two learnings routes below. 'org' scope never
|
|
2045
|
-
// needs identity (readPreservedLearnings/applyLearningEntryChange ignore the
|
|
2046
|
-
// userId param for it); every other scope resolves through resolveHubIdentity,
|
|
2047
|
-
// returning '' (not a guess) when not connected — callers distinguish
|
|
2048
|
-
// "org" from "not connected" via the scope check they already have to make.
|
|
2049
|
-
async resolveLearningIdentity(scope) {
|
|
2050
|
-
if (scope === 'org')
|
|
2051
|
-
return '';
|
|
2052
|
-
return (await this.resolveHubIdentity()) || '';
|
|
2053
|
-
}
|
|
2054
|
-
registerRoutes() {
|
|
2055
|
-
// Issue #512 / #749: the account and analytics surfaces live outside /ai-hub. The
|
|
2056
|
-
// on-machine Hub holds no DB, so it always redirects these (and /auth) to the hosted
|
|
2057
|
-
// server, which owns the authenticated, DB-backed pages.
|
|
2058
|
-
this.app.get(/^\/auth(\/.*)?$/, (req, res) => {
|
|
2059
|
-
const parsed = new URL(req.originalUrl, 'http://127.0.0.1');
|
|
2060
|
-
if (parsed.pathname === '/auth/sign-in.html' && !parsed.searchParams.has('hub_return')) {
|
|
2061
|
-
const rawSurface = parsed.searchParams.get('surface');
|
|
2062
|
-
const surface = rawSurface === 'account' || rawSurface === 'brain' || rawSurface === 'analytics'
|
|
2063
|
-
? rawSurface
|
|
2064
|
-
: 'analytics';
|
|
2065
|
-
parsed.searchParams.set('hub_return', buildLocalHubReturnUrl(req, surface));
|
|
2066
|
-
}
|
|
2067
|
-
res.redirect(buildHostedPathUrl(parsed.pathname, parsed.search));
|
|
2068
|
-
});
|
|
2069
|
-
this.app.get(['/account', '/account/'], (req, res) => {
|
|
2070
|
-
res.redirect(buildHostedAuthUrl('account', '/account/', buildLocalHubReturnUrl(req, 'account')));
|
|
2071
|
-
});
|
|
2072
|
-
this.app.get(['/analytics', '/analytics/'], (req, res) => {
|
|
2073
|
-
res.redirect(buildHostedAuthUrl('analytics', '/analytics/', buildLocalHubReturnUrl(req, 'analytics')));
|
|
2074
|
-
});
|
|
2075
|
-
// Issue #478: Serve the PowerPoint task pane HTML and manifest.
|
|
2076
|
-
// Office JS appends query strings (?_host_Info=PowerPoint$Win32$...) to every
|
|
2077
|
-
// request, so we must strip them before resolving the file path. Use a custom
|
|
2078
|
-
// route rather than express.static so we can apply the new URL().pathname fix.
|
|
2079
|
-
this.app.get(/^\/powerpoint-taskpane(\/.*)?$/, (req, res) => {
|
|
2080
|
-
const taskpaneDir = resolveTaskpaneDir('powerpoint-taskpane');
|
|
2081
|
-
// Strip query string — Office appends ?_host_Info=PowerPoint$... to every fetch.
|
|
2082
|
-
const { pathname } = new URL(req.url, 'http://localhost');
|
|
2083
|
-
// Map root /powerpoint-taskpane/ to index.html
|
|
2084
|
-
const relativePath = pathname.replace(/^\/powerpoint-taskpane\/?/, '') || 'index.html';
|
|
2085
|
-
const filePath = path_1.default.join(taskpaneDir, relativePath);
|
|
2086
|
-
if (!filePath.startsWith(taskpaneDir)) {
|
|
2087
|
-
// Path traversal guard
|
|
2088
|
-
res.status(403).end();
|
|
2089
|
-
return;
|
|
2090
|
-
}
|
|
2091
|
-
const ext = path_1.default.extname(filePath).toLowerCase();
|
|
2092
|
-
const contentTypeMap = {
|
|
2093
|
-
'.html': 'text/html; charset=utf-8',
|
|
2094
|
-
'.xml': 'application/xml',
|
|
2095
|
-
'.js': 'application/javascript',
|
|
2096
|
-
'.css': 'text/css',
|
|
2097
|
-
'.png': 'image/png',
|
|
2098
|
-
};
|
|
2099
|
-
const contentType = contentTypeMap[ext] || 'text/plain';
|
|
2100
|
-
fs_1.default.readFile(filePath, (err, data) => {
|
|
2101
|
-
if (err) {
|
|
2102
|
-
res.status(404).end('Not found');
|
|
2103
|
-
return;
|
|
2104
|
-
}
|
|
2105
|
-
res.setHeader('Content-Type', contentType);
|
|
2106
|
-
res.setHeader('Access-Control-Allow-Origin', '*');
|
|
2107
|
-
res.end(data);
|
|
2108
|
-
});
|
|
2109
|
-
});
|
|
2110
|
-
this.app.get('/api/ai-hub/bootstrap', async (req, res) => {
|
|
2111
|
-
let projectPath = typeof req.query.projectPath === 'string' && req.query.projectPath.length > 0
|
|
2112
|
-
? req.query.projectPath
|
|
2113
|
-
: null;
|
|
2114
|
-
// If no explicit projectPath but a file:// docUrl was passed (Word Desktop),
|
|
2115
|
-
// derive the project directory from the document's local path.
|
|
2116
|
-
if (!projectPath && typeof req.query.docUrl === 'string' && req.query.docUrl.startsWith('file://')) {
|
|
2117
|
-
try {
|
|
2118
|
-
const fileUrl = new URL(req.query.docUrl);
|
|
2119
|
-
const rawPath = process.platform === 'win32'
|
|
2120
|
-
? fileUrl.pathname.replace(/^\/([A-Za-z]:)/, '$1')
|
|
2121
|
-
: fileUrl.pathname;
|
|
2122
|
-
projectPath = path_1.default.dirname(decodeURIComponent(rawPath));
|
|
2123
|
-
}
|
|
2124
|
-
catch { }
|
|
2125
|
-
}
|
|
2126
|
-
// #719: a bare reload (no projectPath query) must land on the last-recorded
|
|
2127
|
-
// workspace project, not the launch folder. The bootstrap saves its resolved
|
|
2128
|
-
// current path back into the projects list, so defaulting to the launch
|
|
2129
|
-
// folder would resurrect a removed launch-folder project on every reload
|
|
2130
|
-
// (tfSwitchProjectFolder already documents the recorded folder as "the
|
|
2131
|
-
// default on next load"). The launch folder stays the fallback when nothing
|
|
2132
|
-
// is recorded or the recorded folder no longer exists.
|
|
2133
|
-
if (!projectPath) {
|
|
2134
|
-
const recorded = this.preferencesStore.load(this.projectPath).projectPath;
|
|
2135
|
-
if (recorded && path_1.default.resolve(recorded) !== path_1.default.resolve(this.projectPath) && fs_1.default.existsSync(recorded)) {
|
|
2136
|
-
projectPath = recorded;
|
|
2137
|
-
}
|
|
2138
|
-
}
|
|
2139
|
-
res.json(await this.bootstrapResponse(projectPath || this.projectPath));
|
|
2140
|
-
});
|
|
2141
|
-
// Issue #512 (S3, R14) — Brain summary as a standalone route, returning the
|
|
2142
|
-
// same projection folded into bootstrap. Useful for the avatar→Brain view
|
|
2143
|
-
// without re-fetching the whole bootstrap payload.
|
|
2144
|
-
this.app.get('/api/ai-hub/brain', async (req, res) => {
|
|
2145
|
-
const projectPath = typeof req.query.projectPath === 'string' && req.query.projectPath.length > 0
|
|
2146
|
-
? path_1.default.resolve(req.query.projectPath)
|
|
2147
|
-
: this.projectPath;
|
|
2148
|
-
const jobCount = (0, catalog_1.discoverEmployeeJobs)(projectPath).length + (0, catalog_1.discoverManagerTemplates)(projectPath).length;
|
|
2149
|
-
const userEmail = await this.resolveHubIdentity();
|
|
2150
|
-
return res.json(this.computeBrain(projectPath, jobCount, userEmail));
|
|
2151
|
-
});
|
|
2152
|
-
// #533: read the PRESERVED learnings for a section + storage level so the
|
|
2153
|
-
// Company/Manager sections (machine level) and the project workspace (project
|
|
2154
|
-
// level) can DISPLAY and edit them.
|
|
2155
|
-
const VALID_SCOPES = ['org', 'manager', 'reverse'];
|
|
2156
|
-
const VALID_LEVELS = ['machine', 'project'];
|
|
2157
|
-
const VALID_CATEGORIES = ['avoid', 'preference', 'repeat', 'coaching'];
|
|
2158
|
-
const resolveProjectPath = (req) => typeof req.query.projectPath === 'string' && req.query.projectPath.length > 0
|
|
2159
|
-
? path_1.default.resolve(req.query.projectPath)
|
|
2160
|
-
: (typeof (req.body && req.body.projectPath) === 'string' && req.body.projectPath.length > 0
|
|
2161
|
-
? path_1.default.resolve(req.body.projectPath)
|
|
2162
|
-
: this.projectPath);
|
|
2163
|
-
this.app.get('/api/ai-hub/learnings', async (req, res) => {
|
|
2164
|
-
const scope = req.query.scope;
|
|
2165
|
-
if (typeof scope !== 'string' || !VALID_SCOPES.includes(scope)) {
|
|
2166
|
-
return res.status(400).json({ error: `scope must be one of: ${VALID_SCOPES.join(', ')}` });
|
|
2167
|
-
}
|
|
2168
|
-
const level = (typeof req.query.level === 'string' && VALID_LEVELS.includes(req.query.level))
|
|
2169
|
-
? req.query.level : 'machine';
|
|
2170
|
-
try {
|
|
2171
|
-
const userEmail = await this.resolveLearningIdentity(scope);
|
|
2172
|
-
if (scope !== 'org' && !userEmail) {
|
|
2173
|
-
return res.json({ scope, level, entries: [] });
|
|
2174
|
-
}
|
|
2175
|
-
const entries = (0, learning_context_builder_1.readPreservedLearnings)(resolveProjectPath(req), userEmail, scope, level);
|
|
2176
|
-
return res.json({ scope, level, entries });
|
|
2177
|
-
}
|
|
2178
|
-
catch (error) {
|
|
2179
|
-
return res.status(500).json({ error: error instanceof Error ? error.message : 'Could not read learnings.' });
|
|
2180
|
-
}
|
|
2181
|
-
});
|
|
2182
|
-
// #533 §3: add / edit / delete a single learning entry — writes the real file
|
|
2183
|
-
// at the section's level (machine for the Manager/Company tabs, project for the
|
|
2184
|
-
// project workspace). The action targets exactly the card's file.
|
|
2185
|
-
this.app.post('/api/ai-hub/learnings/entry', async (req, res) => {
|
|
2186
|
-
const b = (req.body || {});
|
|
2187
|
-
if (!['add', 'edit', 'delete'].includes(b.action || '')) {
|
|
2188
|
-
return res.status(400).json({ error: 'action must be one of: add, edit, delete' });
|
|
2189
|
-
}
|
|
2190
|
-
if (!b.scope || !VALID_SCOPES.includes(b.scope))
|
|
2191
|
-
return res.status(400).json({ error: `scope must be one of: ${VALID_SCOPES.join(', ')}` });
|
|
2192
|
-
if (!b.category || !VALID_CATEGORIES.includes(b.category))
|
|
2193
|
-
return res.status(400).json({ error: `category must be one of: ${VALID_CATEGORIES.join(', ')}` });
|
|
2194
|
-
const level = (b.level && VALID_LEVELS.includes(b.level)) ? b.level : 'machine';
|
|
2195
|
-
const ref = { scope: b.scope, level, category: b.category };
|
|
2196
|
-
// Issue #750: writes to a personal-scope file need a resolved identity —
|
|
2197
|
-
// fail explicitly rather than writing to a guessed or empty-string file.
|
|
2198
|
-
const userEmail = await this.resolveLearningIdentity(b.scope);
|
|
2199
|
-
if (b.scope !== 'org' && !userEmail) {
|
|
2200
|
-
return res.status(400).json({ error: 'not_connected', message: 'No FRAIM identity resolved. Run `fraim setup` first.' });
|
|
2201
|
-
}
|
|
2202
|
-
try {
|
|
2203
|
-
const result = (0, learning_context_builder_1.applyLearningEntryChange)(resolveProjectPath(req), userEmail, ref, b.action, {
|
|
2204
|
-
originalTitle: b.originalTitle,
|
|
2205
|
-
severity: b.severity,
|
|
2206
|
-
title: b.title,
|
|
2207
|
-
body: b.body,
|
|
2208
|
-
});
|
|
2209
|
-
return res.json({ ok: true, path: result.path });
|
|
2210
|
-
}
|
|
2211
|
-
catch (error) {
|
|
2212
|
-
return res.status(400).json({ error: error instanceof Error ? error.message : 'Could not write learning.' });
|
|
2213
|
-
}
|
|
2214
|
-
});
|
|
2215
|
-
// Issue #540/#701/#750: POST /api/ai-hub/manager-team/assign
|
|
2216
|
-
// Proxies to the hosted server (which owns the DB + seat enforcement), using
|
|
2217
|
-
// the Hub's own ~/.fraim/config.json apiKey — not a caller-supplied header.
|
|
2218
|
-
// Hosted returns 404 no_company_seat / 409 out_of_stock; those are passed through.
|
|
2219
|
-
this.app.post('/api/ai-hub/manager-team/assign', async (req, res) => {
|
|
2220
|
-
const { personaKey } = (req.body ?? {});
|
|
2221
|
-
if (!personaKey)
|
|
2222
|
-
return res.status(400).json({ error: 'personaKey required' });
|
|
2223
|
-
const result = await this.remoteGateway.assignManagerTeam(resolveApiKey(), personaKey);
|
|
2224
|
-
return res.status(result.status).json(result.body);
|
|
2225
|
-
});
|
|
2226
|
-
// Issue #540/#701/#750: DELETE /api/ai-hub/manager-team/assign/:personaKey
|
|
2227
|
-
// Proxies the seat release to the hosted server.
|
|
2228
|
-
this.app.delete('/api/ai-hub/manager-team/assign/:personaKey', async (req, res) => {
|
|
2229
|
-
const { personaKey } = req.params;
|
|
2230
|
-
await this.remoteGateway.removeManagerTeam(resolveApiKey(), personaKey);
|
|
2231
|
-
return res.status(204).end();
|
|
2232
|
-
});
|
|
2233
|
-
// Issue #708: manager/company scopes resolve to a project-independent sentinel bucket
|
|
2234
|
-
// key (which must bypass path/dir resolution); everything else keys by project path.
|
|
2235
|
-
const scopeParam = (raw) => (raw === 'manager' || raw === 'company') ? raw : undefined;
|
|
2236
|
-
this.app.get('/api/ai-hub/conversations', (req, res) => {
|
|
2237
|
-
const scope = scopeParam(req.query.scope);
|
|
2238
|
-
const key = scope
|
|
2239
|
-
? (0, conversation_store_1.conversationScopeKey)(scope, '')
|
|
2240
|
-
: (typeof req.query.projectPath === 'string' && req.query.projectPath.length > 0
|
|
2241
|
-
? path_1.default.resolve(req.query.projectPath)
|
|
2242
|
-
: this.projectPath);
|
|
2243
|
-
const loaded = this.conversationStore.loadProject(key);
|
|
2244
|
-
return res.json({ projectPath: key, scope: scope ?? 'project', ...loaded, source: 'disk' });
|
|
2245
|
-
});
|
|
2246
|
-
this.app.put('/api/ai-hub/conversations', (req, res) => {
|
|
2247
|
-
try {
|
|
2248
|
-
const body = (req.body ?? {});
|
|
2249
|
-
const scope = scopeParam(body.scope);
|
|
2250
|
-
const key = scope ? (0, conversation_store_1.conversationScopeKey)(scope, '') : ensureDirectoryPath(body.projectPath || this.projectPath);
|
|
2251
|
-
if (!Array.isArray(body.conversations)) {
|
|
2252
|
-
return res.status(400).json({ error: 'conversations array required' });
|
|
2253
|
-
}
|
|
2254
|
-
// The client strips server-owned heavy fields (messages/events/artifacts/run/
|
|
2255
|
-
// delegation) from its PUT payload to stay under the body-size limit. Merge them
|
|
2256
|
-
// back from the stored record so a client-owned change (e.g. reviewApproved) never
|
|
2257
|
-
// erases run history. List membership still comes from the incoming array, so
|
|
2258
|
-
// deletes are honored (a conversation absent here is dropped).
|
|
2259
|
-
const prior = this.conversationStore.loadProject(key);
|
|
2260
|
-
const priorById = new Map(prior.conversations.map((entry) => [entry.id, entry]));
|
|
2261
|
-
// Write-boundary guard for the cross-project leak (docs/rca/hub-conversation-cross-project-leak.md):
|
|
2262
|
-
// a project PUT persists the client's current-project list, but that list can transiently
|
|
2263
|
-
// include a conversation belonging to ANOTHER project (e.g. the just-finished run of the
|
|
2264
|
-
// project the user switched away from). Never file such a record into this project bucket —
|
|
2265
|
-
// its authoritative home is its own project bucket. Scope (manager/company) PUTs are exempt:
|
|
2266
|
-
// their sentinel buckets legitimately hold records whose projectPath points at a project.
|
|
2267
|
-
const belongsInBucket = (incoming) => {
|
|
2268
|
-
if (scope)
|
|
2269
|
-
return true;
|
|
2270
|
-
const incomingScope = incoming.scope
|
|
2271
|
-
?? incoming.invokedArea;
|
|
2272
|
-
if (incomingScope === 'manager' || incomingScope === 'company')
|
|
2273
|
-
return false;
|
|
2274
|
-
const own = incoming && typeof incoming.projectPath === 'string' ? incoming.projectPath : '';
|
|
2275
|
-
if (!own)
|
|
2276
|
-
return true;
|
|
2277
|
-
return normalizedDirectoryPath(own) === normalizedDirectoryPath(key);
|
|
2278
|
-
};
|
|
2279
|
-
const conversations = body.conversations
|
|
2280
|
-
.filter((incoming) => belongsInBucket(incoming))
|
|
2281
|
-
.map((incoming) => {
|
|
2282
|
-
const existing = incoming && incoming.id ? priorById.get(incoming.id) : undefined;
|
|
2283
|
-
return existing ? { ...existing, ...incoming } : incoming;
|
|
2284
|
-
});
|
|
2285
|
-
const saved = this.conversationStore.replaceProject(key, {
|
|
2286
|
-
activeId: body.activeId ?? null,
|
|
2287
|
-
conversations,
|
|
2288
|
-
});
|
|
2289
|
-
return res.json({ projectPath: key, scope: scope ?? 'project', ...saved, source: 'disk' });
|
|
2290
|
-
}
|
|
2291
|
-
catch (error) {
|
|
2292
|
-
return res.status(400).json({ error: error instanceof Error ? error.message : 'Could not persist conversations.' });
|
|
2293
|
-
}
|
|
2294
|
-
});
|
|
2295
|
-
this.app.patch('/api/ai-hub/conversations/:conversationId', (req, res) => {
|
|
2296
|
-
try {
|
|
2297
|
-
const body = (req.body ?? {});
|
|
2298
|
-
const scope = scopeParam(body.scope);
|
|
2299
|
-
const projectPath = scope ? (0, conversation_store_1.conversationScopeKey)(scope, '') : ensureDirectoryPath(body.projectPath || this.projectPath);
|
|
2300
|
-
const saved = this.conversationStore.patchConversation(projectPath, req.params.conversationId, body);
|
|
2301
|
-
if (body.activeId !== undefined) {
|
|
2302
|
-
const withActive = this.conversationStore.replaceProject(projectPath, { ...saved, activeId: body.activeId });
|
|
2303
|
-
return res.json({ projectPath, ...withActive, source: 'disk' });
|
|
2304
|
-
}
|
|
2305
|
-
return res.json({ projectPath, ...saved, source: 'disk' });
|
|
2306
|
-
}
|
|
2307
|
-
catch (error) {
|
|
2308
|
-
return res.status(400).json({ error: error instanceof Error ? error.message : 'Could not persist conversation.' });
|
|
2309
|
-
}
|
|
2310
|
-
});
|
|
2311
|
-
this.app.get('/api/ai-hub/projects', (req, res) => {
|
|
2312
|
-
const projectPath = typeof req.query.projectPath === 'string' && req.query.projectPath.length > 0
|
|
2313
|
-
? path_1.default.resolve(req.query.projectPath)
|
|
2314
|
-
: this.projectPath;
|
|
2315
|
-
return res.json({ projectPath, projects: this.knownProjects(projectPath), source: 'disk' });
|
|
2316
|
-
});
|
|
2317
|
-
this.app.put('/api/ai-hub/projects', (req, res) => {
|
|
2318
|
-
try {
|
|
2319
|
-
const body = (req.body ?? {});
|
|
2320
|
-
const projectPath = ensureDirectoryPath(body.projectPath || this.projectPath);
|
|
2321
|
-
if (!Array.isArray(body.projects)) {
|
|
2322
|
-
return res.status(400).json({ error: 'projects array required' });
|
|
2323
|
-
}
|
|
2324
|
-
const projects = this.preferencesStore.saveProjects(projectPath, [...this.knownProjects(projectPath), ...body.projects], { reviveRemovedPaths: Array.isArray(body.reviveRemovedPaths) ? body.reviveRemovedPaths : [] });
|
|
2325
|
-
return res.json({ projectPath, projects, source: 'disk' });
|
|
2326
|
-
}
|
|
2327
|
-
catch (error) {
|
|
2328
|
-
return res.status(400).json({ error: error instanceof Error ? error.message : 'Could not persist projects.' });
|
|
2329
|
-
}
|
|
2330
|
-
});
|
|
2331
|
-
// Issue #719: DELETE /api/ai-hub/projects/:id — remove a project from the Hub.
|
|
2332
|
-
// PUT is a merge by design and cannot express removal, so removal gets its own
|
|
2333
|
-
// route (same convention as /schedules/:id, /webhooks/:id, /hosts/:id). Deletes
|
|
2334
|
-
// every server-side derivation layer — preferences entry, conversation-store
|
|
2335
|
-
// KEY, project-scoped deployments — so the project cannot resurrect (R3/R6/R9).
|
|
2336
|
-
// Never touches anything under the project's folderPath (R8).
|
|
2337
|
-
this.app.delete('/api/ai-hub/projects/:id', (req, res) => {
|
|
2338
|
-
const projectPath = typeof req.query.projectPath === 'string' && req.query.projectPath.length > 0
|
|
2339
|
-
? path_1.default.resolve(req.query.projectPath)
|
|
2340
|
-
: this.projectPath;
|
|
2341
|
-
const known = this.knownProjects(projectPath);
|
|
2342
|
-
// Match by id, falling back to the canonical folderPath. The client and server
|
|
2343
|
-
// hash a path to an id with different algorithms, and the client mints its own id
|
|
2344
|
-
// for the CURRENT project (tfEnsureCurrentProject), so a delete-by-id can carry an
|
|
2345
|
-
// id the server never assigned — the folderPath is the true project identity (#726).
|
|
2346
|
-
const requestedFolderPath = typeof req.query.folderPath === 'string' && req.query.folderPath.length > 0
|
|
2347
|
-
? req.query.folderPath
|
|
2348
|
-
: null;
|
|
2349
|
-
const entry = known.find((project) => project.id === req.params.id)
|
|
2350
|
-
|| (requestedFolderPath ? known.find((project) => sameDirectoryPath(project.folderPath, requestedFolderPath)) : undefined);
|
|
2351
|
-
if (!entry)
|
|
2352
|
-
return res.status(404).json({ error: 'Project not found.' });
|
|
2353
|
-
// The current projectPath is unconditionally re-injected on every load, so
|
|
2354
|
-
// deleting it would resurrect on the next request — reject as a sequencing
|
|
2355
|
-
// error; the client switches workspaces before deleting (R5/D4).
|
|
2356
|
-
if (sameDirectoryPath(entry.folderPath, projectPath)) {
|
|
2357
|
-
return res.status(409).json({ error: 'Cannot remove the current project. Switch to another project first.' });
|
|
2358
|
-
}
|
|
2359
|
-
// A live schedule/webhook would fire later, write a conversation under the
|
|
2360
|
-
// removed folderPath, and resurrect the project in the background (R6).
|
|
2361
|
-
for (const deployment of this.deploymentStore.load()) {
|
|
2362
|
-
if (!deploymentBelongsToProject(deployment, entry.folderPath, this.projectPath))
|
|
2363
|
-
continue;
|
|
2364
|
-
const task = this.cronHandles.get(deployment.id);
|
|
2365
|
-
if (task) {
|
|
2366
|
-
task.stop();
|
|
2367
|
-
this.cronHandles.delete(deployment.id);
|
|
2368
|
-
}
|
|
2369
|
-
this.deploymentStore.delete(deployment.id);
|
|
2370
|
-
}
|
|
2371
|
-
// Delete the conversation-store KEY — an empty list would still re-derive
|
|
2372
|
-
// the project via listProjectPaths (R9).
|
|
2373
|
-
this.conversationStore.removeProject(entry.folderPath);
|
|
2374
|
-
// Persist the filtered list with a tombstone: a normal project save
|
|
2375
|
-
// still merges derived projects and cannot express removal.
|
|
2376
|
-
const projects = this.preferencesStore.removeProject(projectPath, known.filter((project) => project.id !== entry.id), entry.folderPath);
|
|
2377
|
-
return res.json({ ok: true, projects });
|
|
2378
|
-
});
|
|
2379
|
-
// Issue #750: POST /api/ai-hub/api-key is removed — there is no Hub-local
|
|
2380
|
-
// apiKey to persist. Identity always comes from ~/.fraim/config.json
|
|
2381
|
-
// (set by `fraim setup`), read fresh via resolveApiKey() on every request.
|
|
2382
|
-
this.app.post('/api/ai-hub/preferences', (req, res) => {
|
|
2383
|
-
const { personaKey } = req.body;
|
|
2384
|
-
const prefs = this.preferencesStore.load(this.projectPath);
|
|
2385
|
-
this.preferencesStore.save({ ...prefs, personaKey: personaKey ?? null });
|
|
2386
|
-
return res.json({ ok: true });
|
|
2387
|
-
});
|
|
2388
|
-
// #755: running build version + best-effort latest published version, so the
|
|
2389
|
-
// account menu can show the version and flag when an update is available. The
|
|
2390
|
-
// `fraim hub` CLI also queries this to confirm a running instance's version.
|
|
2391
|
-
this.app.get('/api/ai-hub/version', async (_req, res) => {
|
|
2392
|
-
const version = (0, version_utils_1.getFraimVersion)();
|
|
2393
|
-
const latest = await (0, hub_latest_version_1.getLatestPublishedVersion)();
|
|
2394
|
-
const updateAvailable = !!(latest && semver.valid(version) && semver.valid(latest) && semver.gt(latest, version));
|
|
2395
|
-
return res.json({ version, latest, updateAvailable });
|
|
2396
|
-
});
|
|
2397
|
-
this.app.post('/api/ai-hub/project-path/pick', async (_req, res) => {
|
|
2398
|
-
try {
|
|
2399
|
-
const projectPath = await this.folderPicker();
|
|
2400
|
-
if (!projectPath) {
|
|
2401
|
-
// User cancelled the native dialog, or no interactive dialog was
|
|
2402
|
-
// available (e.g. CI/headless). Front-end treats 204 as "no change".
|
|
2403
|
-
return res.status(204).end();
|
|
2404
|
-
}
|
|
2405
|
-
return res.json({ path: projectPath });
|
|
2406
|
-
}
|
|
2407
|
-
catch (error) {
|
|
2408
|
-
return res.status(500).json({ error: error instanceof Error ? error.message : 'Could not open the folder picker.' });
|
|
2409
|
-
}
|
|
2410
|
-
});
|
|
2411
|
-
// ── Issue #512 R3/R8: Team Context inline read/write ────────────────────
|
|
2412
|
-
// GET /api/ai-hub/context[?key=<key>] → file content for the editor.
|
|
2413
|
-
// With ?key=<one of eight>: { key, present, displayPath, scope, content }.
|
|
2414
|
-
// Without key: { files: { <key>: {present, displayPath, scope, content} } }.
|
|
2415
|
-
// POST /api/ai-hub/context { key, content } → persists to the correct
|
|
2416
|
-
// destination (user-level for org/manager/orgRules/managerRules unless a repo-local
|
|
2417
|
-
// override exists; repo-local for project*), creating parent dirs.
|
|
2418
|
-
// Loopback-only (the Hub binds 127.0.0.1); writes are constrained to live
|
|
2419
|
-
// under a personalized-employee/ directory (path-traversal guard).
|
|
2420
|
-
const readContextFile = (projectPath, key) => {
|
|
2421
|
-
const loc = (0, learning_context_builder_1.resolveTeamContextFile)(projectPath, key);
|
|
2422
|
-
let content = '';
|
|
2423
|
-
if (loc.present && loc.readPath) {
|
|
2424
|
-
try {
|
|
2425
|
-
content = fs_1.default.readFileSync(loc.readPath, 'utf8');
|
|
2426
|
-
}
|
|
2427
|
-
catch {
|
|
2428
|
-
content = '';
|
|
2429
|
-
}
|
|
2430
|
-
}
|
|
2431
|
-
return {
|
|
2432
|
-
key,
|
|
2433
|
-
present: loc.present,
|
|
2434
|
-
displayPath: loc.displayPath,
|
|
2435
|
-
scope: loc.scope,
|
|
2436
|
-
content,
|
|
2437
|
-
};
|
|
2438
|
-
};
|
|
2439
|
-
this.app.get('/api/ai-hub/context', (req, res) => {
|
|
2440
|
-
const projectPath = typeof req.query.projectPath === 'string' && req.query.projectPath.length > 0
|
|
2441
|
-
? path_1.default.resolve(req.query.projectPath)
|
|
2442
|
-
: this.projectPath;
|
|
2443
|
-
const rawKey = typeof req.query.key === 'string' ? req.query.key : undefined;
|
|
2444
|
-
if (rawKey !== undefined) {
|
|
2445
|
-
if (!(0, learning_context_builder_1.isTeamContextKey)(rawKey)) {
|
|
2446
|
-
return res.status(400).json({ error: `Unknown context key: ${rawKey}` });
|
|
2447
|
-
}
|
|
2448
|
-
return res.json(readContextFile(projectPath, rawKey));
|
|
2449
|
-
}
|
|
2450
|
-
const keys = ['org', 'manager', 'orgRules', 'managerRules', 'projectContext', 'projectBrief', 'projectRules', 'projectQa'];
|
|
2451
|
-
const files = {};
|
|
2452
|
-
for (const key of keys)
|
|
2453
|
-
files[key] = readContextFile(projectPath, key);
|
|
2454
|
-
return res.json({ files });
|
|
2455
|
-
});
|
|
2456
|
-
this.app.post('/api/ai-hub/context', (req, res) => {
|
|
2457
|
-
const body = (req.body ?? {});
|
|
2458
|
-
if (!(0, learning_context_builder_1.isTeamContextKey)(body.key)) {
|
|
2459
|
-
return res.status(400).json({ error: 'key must be one of org|manager|orgRules|managerRules|projectContext|projectBrief|projectRules|projectQa.' });
|
|
2460
|
-
}
|
|
2461
|
-
if (typeof body.content !== 'string') {
|
|
2462
|
-
return res.status(400).json({ error: 'content (string) is required.' });
|
|
2463
|
-
}
|
|
2464
|
-
const projectPath = typeof body.projectPath === 'string' && body.projectPath.length > 0
|
|
2465
|
-
? path_1.default.resolve(body.projectPath)
|
|
2466
|
-
: this.projectPath;
|
|
2467
|
-
const loc = (0, learning_context_builder_1.resolveTeamContextFile)(projectPath, body.key);
|
|
2468
|
-
if (loc.managedByOrgSync || loc.managedByManagerSync || !loc.writePath) {
|
|
2469
|
-
// Enforcement only: block editing a synced org file (it would be
|
|
2470
|
-
// overwritten on next sync). The how-to-change procedure lives in the
|
|
2471
|
-
// organization-onboarding job, not in this error body (issue #563 review).
|
|
2472
|
-
return res.status(409).json({
|
|
2473
|
-
error: loc.managedByManagerSync
|
|
2474
|
-
? 'This manager file is managed by manager sync and is read-only here.'
|
|
2475
|
-
: 'This organization file is managed by org sync and is read-only here.'
|
|
2476
|
-
});
|
|
2477
|
-
}
|
|
2478
|
-
const dest = path_1.default.resolve(loc.writePath);
|
|
2479
|
-
// Path-traversal guard: the resolved destination must live under a
|
|
2480
|
-
// personalized-employee directory (covers both ~/.fraim/... and repo-local).
|
|
2481
|
-
const segments = dest.split(path_1.default.sep);
|
|
2482
|
-
if (!segments.includes('personalized-employee')) {
|
|
2483
|
-
return res.status(403).json({ error: 'Write destination outside personalized-employee.' });
|
|
2484
|
-
}
|
|
2485
|
-
try {
|
|
2486
|
-
fs_1.default.mkdirSync(path_1.default.dirname(dest), { recursive: true });
|
|
2487
|
-
fs_1.default.writeFileSync(dest, body.content, 'utf8');
|
|
2488
|
-
}
|
|
2489
|
-
catch (err) {
|
|
2490
|
-
return res.status(500).json({ error: err instanceof Error ? err.message : 'Write failed.' });
|
|
2491
|
-
}
|
|
2492
|
-
// Re-read so the client gets the canonical post-write state (present flips).
|
|
2493
|
-
return res.json(readContextFile(projectPath, body.key));
|
|
2494
|
-
});
|
|
2495
|
-
// Issue #744 — Hub cobranding.
|
|
2496
|
-
// GET /api/ai-hub/brand → { brand: OrgBrand | null } resolved from the org
|
|
2497
|
-
// context storage (same layering as org_context.md).
|
|
2498
|
-
// POST /api/ai-hub/brand { name?, color?, logo?, projectPath? } → sanitizes
|
|
2499
|
-
// + normalizes (SVG allowlist, hex normalize) and persists org_brand.json to
|
|
2500
|
-
// the org context write location. Loopback-only; write is constrained under
|
|
2501
|
-
// a personalized-employee/ directory (same path-traversal guard as context).
|
|
2502
|
-
this.app.get('/api/ai-hub/brand', (req, res) => {
|
|
2503
|
-
const projectPath = typeof req.query.projectPath === 'string' && req.query.projectPath.length > 0
|
|
2504
|
-
? path_1.default.resolve(req.query.projectPath)
|
|
2505
|
-
: this.projectPath;
|
|
2506
|
-
return res.json({ brand: (0, learning_context_builder_1.readOrgBrand)(projectPath) });
|
|
2507
|
-
});
|
|
2508
|
-
this.app.post('/api/ai-hub/brand', (req, res) => {
|
|
2509
|
-
const body = (req.body ?? {});
|
|
2510
|
-
const projectPath = typeof body.projectPath === 'string' && body.projectPath.length > 0
|
|
2511
|
-
? path_1.default.resolve(body.projectPath)
|
|
2512
|
-
: this.projectPath;
|
|
2513
|
-
const input = {
|
|
2514
|
-
name: typeof body.name === 'string' ? body.name : undefined,
|
|
2515
|
-
color: typeof body.color === 'string' ? body.color : undefined,
|
|
2516
|
-
logo: typeof body.logo === 'string' ? body.logo : undefined,
|
|
2517
|
-
};
|
|
2518
|
-
const dest = path_1.default.resolve((0, learning_context_builder_1.resolveOrgBrandWriteDir)(projectPath));
|
|
2519
|
-
// Path-traversal guard: the write dir must live under a personalized-employee
|
|
2520
|
-
// directory (matches the context write guard; never the managed org cache).
|
|
2521
|
-
if (!dest.split(path_1.default.sep).includes('personalized-employee')) {
|
|
2522
|
-
return res.status(403).json({ error: 'Brand write destination outside personalized-employee.' });
|
|
2523
|
-
}
|
|
2524
|
-
try {
|
|
2525
|
-
// writeBrandToDir sanitizes the logo (SVG allowlist) and normalizes the
|
|
2526
|
-
// color before persisting, so unsafe input never reaches disk.
|
|
2527
|
-
const stored = (0, brand_store_1.writeBrandToDir)(dest, input);
|
|
2528
|
-
return res.json({ brand: stored });
|
|
2529
|
-
}
|
|
2530
|
-
catch (err) {
|
|
2531
|
-
return res.status(500).json({ error: err instanceof Error ? err.message : 'Brand write failed.' });
|
|
2532
|
-
}
|
|
2533
|
-
});
|
|
2534
|
-
this.app.post('/api/ai-hub/artifact/open', async (req, res) => {
|
|
2535
|
-
const rawPath = typeof req.body?.path === 'string' ? req.body.path : '';
|
|
2536
|
-
const projectPath = typeof req.body?.projectPath === 'string' && req.body.projectPath.length > 0
|
|
2537
|
-
? path_1.default.resolve(req.body.projectPath)
|
|
2538
|
-
: this.projectPath;
|
|
2539
|
-
if (!rawPath)
|
|
2540
|
-
return res.status(400).json({ error: 'path is required.' });
|
|
2541
|
-
const resolved = resolveSafeArtifactPath(rawPath, projectPath);
|
|
2542
|
-
if (!resolved)
|
|
2543
|
-
return res.status(403).json({ error: 'Path outside allowed roots.' });
|
|
2544
|
-
if (!fs_1.default.existsSync(resolved))
|
|
2545
|
-
return res.status(404).json({ error: 'File not found.' });
|
|
2546
|
-
try {
|
|
2547
|
-
await hubOpenFile(resolved);
|
|
2548
|
-
return res.json({ ok: true, path: resolved });
|
|
2549
|
-
}
|
|
2550
|
-
catch (err) {
|
|
2551
|
-
return res.status(500).json({ error: err instanceof Error ? err.message : 'Failed to open artifact.' });
|
|
2552
|
-
}
|
|
2553
|
-
});
|
|
2554
|
-
this.app.post('/api/ai-hub/install-agent', async (req, res) => {
|
|
2555
|
-
const { hubId } = req.body;
|
|
2556
|
-
if (!hubId)
|
|
2557
|
-
return res.status(400).json({ error: 'hubId is required.' });
|
|
2558
|
-
const option = hubAgentOption(hubId);
|
|
2559
|
-
if (!option)
|
|
2560
|
-
return res.status(400).json({ error: `Unknown agent: ${hubId}` });
|
|
2561
|
-
try {
|
|
2562
|
-
const prefix = (0, managed_agent_paths_1.getManagedNodeRoot)();
|
|
2563
|
-
fs_1.default.mkdirSync(prefix, { recursive: true });
|
|
2564
|
-
await hubRunProcess('npm', ['install', '-g', option.installPackage], { npm_config_prefix: prefix });
|
|
2565
|
-
const ver = hubCommandVersion(option.launchCommand, (0, managed_agent_paths_1.getManagedAgentBinDirs)());
|
|
2566
|
-
if (!ver) {
|
|
2567
|
-
throw new Error(`${option.label} install completed, but the CLI is not runnable from FRAIM's managed PATH.`);
|
|
2568
|
-
}
|
|
2569
|
-
// Issue #747: run `add-ide` for the newly installed agent so the FRAIM MCP is wired in
|
|
2570
|
-
// and its first run works (previously the agent launched with no `fraim` MCP server).
|
|
2571
|
-
const mcp = await configureFraimForHubAgent(hubId);
|
|
2572
|
-
if (!mcp.configured) {
|
|
2573
|
-
console.warn(`[ai-hub] install-agent: FRAIM add-ide did not run for ${option.label}: ${mcp.error || 'unknown reason'}`);
|
|
2574
|
-
}
|
|
2575
|
-
return res.json({
|
|
2576
|
-
ok: true,
|
|
2577
|
-
message: `${option.label} installed successfully.`,
|
|
2578
|
-
needsLogin: true,
|
|
2579
|
-
loginCommand: option.loginCommand,
|
|
2580
|
-
loginHint: `Sign in to ${option.label} to activate it. A terminal window will open — complete sign-in there, then click "Check if Ready".`,
|
|
2581
|
-
fraimConfigured: mcp.configured,
|
|
2582
|
-
});
|
|
2583
|
-
}
|
|
2584
|
-
catch (error) {
|
|
2585
|
-
const detail = error instanceof Error ? error.message : 'Unknown error';
|
|
2586
|
-
return res.status(500).json({ ok: false, error: `Failed to install ${option.label}: ${detail}` });
|
|
2587
|
-
}
|
|
2588
|
-
});
|
|
2589
|
-
this.app.post('/api/ai-hub/trigger-agent-login', (req, res) => {
|
|
2590
|
-
const { hubId } = req.body;
|
|
2591
|
-
if (!hubId)
|
|
2592
|
-
return res.status(400).json({ error: 'hubId is required.' });
|
|
2593
|
-
const option = hubAgentOption(hubId);
|
|
2594
|
-
if (!option)
|
|
2595
|
-
return res.status(400).json({ error: `Unknown agent: ${hubId}` });
|
|
2596
|
-
try {
|
|
2597
|
-
hubOpenTerminal(buildManagedLoginCommand(option.loginCommand));
|
|
2598
|
-
return res.json({
|
|
2599
|
-
ok: true,
|
|
2600
|
-
message: `A terminal window opened with the ${option.label} sign-in command. Complete sign-in there, then return here.`,
|
|
2601
|
-
});
|
|
2602
|
-
}
|
|
2603
|
-
catch (error) {
|
|
2604
|
-
const detail = error instanceof Error ? error.message : 'Unknown error';
|
|
2605
|
-
return res.json({
|
|
2606
|
-
ok: false,
|
|
2607
|
-
message: `Could not open a terminal automatically: ${detail}. Run \`${option.loginCommand}\` in a terminal to sign in.`,
|
|
2608
|
-
});
|
|
2609
|
-
}
|
|
2610
|
-
});
|
|
2611
|
-
this.app.post('/api/ai-hub/check-agent', (req, res) => {
|
|
2612
|
-
const { hubId } = req.body;
|
|
2613
|
-
if (!hubId)
|
|
2614
|
-
return res.status(400).json({ error: 'hubId is required.' });
|
|
2615
|
-
const option = hubAgentOption(hubId);
|
|
2616
|
-
if (!option)
|
|
2617
|
-
return res.status(400).json({ error: `Unknown agent: ${hubId}` });
|
|
2618
|
-
const ver = hubCommandVersion(option.launchCommand, (0, managed_agent_paths_1.getManagedAgentBinDirs)());
|
|
2619
|
-
if (ver) {
|
|
2620
|
-
return res.json({ ok: true, ready: true, message: `${option.label} is ready.` });
|
|
2621
|
-
}
|
|
2622
|
-
return res.json({
|
|
2623
|
-
ok: true,
|
|
2624
|
-
ready: false,
|
|
2625
|
-
message: `${option.label} is not detected yet. Make sure sign-in is complete and try again.`,
|
|
2626
|
-
});
|
|
2627
|
-
});
|
|
2628
|
-
this.app.post('/api/ai-hub/runs', (req, res) => {
|
|
2629
|
-
try {
|
|
2630
|
-
const projectPath = ensureDirectoryPath(req.body.projectPath || this.projectPath);
|
|
2631
|
-
const hostId = req.body.hostId;
|
|
2632
|
-
const instructions = (req.body.instructions || '').trim();
|
|
2633
|
-
const legacyMessage = (req.body.message || '').trim();
|
|
2634
|
-
const compareMode = req.body.compareMode;
|
|
2635
|
-
if (hostId !== 'codex' && hostId !== 'claude' && hostId !== 'gemini') {
|
|
2636
|
-
throw new Error('Choose an available employee before starting a job.');
|
|
2637
|
-
}
|
|
2638
|
-
if (!instructions && !legacyMessage) {
|
|
2639
|
-
throw new Error('Coach your employee before starting the run.');
|
|
2640
|
-
}
|
|
2641
|
-
const employee = this.hostRuntime.detectEmployees().find((entry) => entry.id === hostId);
|
|
2642
|
-
if (!employee?.available) {
|
|
2643
|
-
throw new Error(`${employee?.label || 'Selected employee'} is not available on this machine.`);
|
|
2644
|
-
}
|
|
2645
|
-
const prepared = instructions
|
|
2646
|
-
? this.prepareStartPayload(projectPath, hostId, req.body.jobId, instructions)
|
|
2647
|
-
: {
|
|
2648
|
-
jobId: req.body.jobId,
|
|
2649
|
-
message: legacyMessage,
|
|
2650
|
-
display: legacyMessage,
|
|
2651
|
-
};
|
|
2652
|
-
const jobId = prepared.jobId;
|
|
2653
|
-
const message = prepared.message;
|
|
2654
|
-
// #521: store only the manager's own words in the conversation; the agent
|
|
2655
|
-
// still receives `message` (with the Hub-injected invocation + notes).
|
|
2656
|
-
const managerDisplay = prepared.display || message;
|
|
2657
|
-
if (!jobId) {
|
|
2658
|
-
throw new Error('Choose a FRAIM job before starting a run.');
|
|
2659
|
-
}
|
|
2660
|
-
const startTimestamp = new Date().toISOString();
|
|
2661
|
-
const jobMetadata = this.resolveHubJob(projectPath, jobId);
|
|
2662
|
-
const fallbackJobTitle = typeof req.body.jobTitle === 'string' && req.body.jobTitle.trim()
|
|
2663
|
-
? req.body.jobTitle.trim()
|
|
2664
|
-
: jobId;
|
|
2665
|
-
const run = {
|
|
2666
|
-
id: (0, crypto_1.randomUUID)(),
|
|
2667
|
-
conversationId: typeof req.body.conversationId === 'string' && req.body.conversationId.trim() ? req.body.conversationId.trim() : undefined,
|
|
2668
|
-
conversationTitle: typeof req.body.conversationTitle === 'string' && req.body.conversationTitle.trim() ? req.body.conversationTitle.trim() : undefined,
|
|
2669
|
-
jobTitle: jobMetadata?.title || fallbackJobTitle,
|
|
2670
|
-
jobId,
|
|
2671
|
-
hostId,
|
|
2672
|
-
projectPath,
|
|
2673
|
-
status: 'running',
|
|
2674
|
-
createdAt: startTimestamp,
|
|
2675
|
-
updatedAt: startTimestamp,
|
|
2676
|
-
messages: [(0, hosts_1.createHubMessage)('manager', managerDisplay)],
|
|
2677
|
-
events: [(0, hosts_1.createHubEvent)('system', `Starting ${hostId} in ${projectPath}`)],
|
|
2678
|
-
// Issue #347 — seed phase + totals state on creation.
|
|
2679
|
-
currentPhase: null,
|
|
2680
|
-
phaseHistory: [],
|
|
2681
|
-
totals: emptyTotals(),
|
|
2682
|
-
lastStatusChangeAt: startTimestamp,
|
|
2683
|
-
personaKey: jobMetadata?.personaKey ?? getProtectedPersonaForHubJob(jobId),
|
|
2684
|
-
// Issue #442: mark this as the FRAIM side of an A/B pair when applicable.
|
|
2685
|
-
...(compareMode === 'ab' ? { runRole: 'fraim' } : {}),
|
|
2686
|
-
// #0: trigger source — defaults to 'manager' when not provided by the caller.
|
|
2687
|
-
sourceTrigger: req.body.sourceTrigger ?? 'manager',
|
|
2688
|
-
};
|
|
2689
|
-
this.runRegistry.create(run, {});
|
|
2690
|
-
this.persistRunConversation(run, run.conversationId || run.id);
|
|
2691
|
-
// Issue #442: create the Direct (B) run before spawning either process
|
|
2692
|
-
// so we can cross-link both runs via compareRunId before any events arrive.
|
|
2693
|
-
// directMsg is the plain user instructions — no FRAIM invocation prefix.
|
|
2694
|
-
const directMsg = compareMode === 'ab'
|
|
2695
|
-
? ((req.body.directInstructions || '').trim() || instructions || message)
|
|
2696
|
-
: message;
|
|
2697
|
-
let directRun;
|
|
2698
|
-
if (compareMode === 'ab') {
|
|
2699
|
-
const directTimestamp = new Date().toISOString();
|
|
2700
|
-
directRun = {
|
|
2701
|
-
id: (0, crypto_1.randomUUID)(),
|
|
2702
|
-
jobId: 'direct',
|
|
2703
|
-
hostId,
|
|
2704
|
-
projectPath,
|
|
2705
|
-
status: 'running',
|
|
2706
|
-
createdAt: directTimestamp,
|
|
2707
|
-
updatedAt: directTimestamp,
|
|
2708
|
-
messages: [(0, hosts_1.createHubMessage)('manager', directMsg)],
|
|
2709
|
-
events: [(0, hosts_1.createHubEvent)('system', `Starting direct (no FRAIM) ${hostId} in ${projectPath}`)],
|
|
2710
|
-
currentPhase: null,
|
|
2711
|
-
phaseHistory: [],
|
|
2712
|
-
totals: emptyTotals(),
|
|
2713
|
-
lastStatusChangeAt: directTimestamp,
|
|
2714
|
-
personaKey: null,
|
|
2715
|
-
runRole: 'direct',
|
|
2716
|
-
compareRunId: run.id,
|
|
2717
|
-
};
|
|
2718
|
-
// Back-link the FRAIM run to the Direct run.
|
|
2719
|
-
this.runRegistry.update(run.id, (current) => {
|
|
2720
|
-
current.compareRunId = directRun.id;
|
|
2721
|
-
});
|
|
2722
|
-
this.runRegistry.create(directRun, {});
|
|
2723
|
-
}
|
|
2724
|
-
const child = this.hostRuntime.startRun(hostId, projectPath, message, {
|
|
2725
|
-
onEvent: (event, channel) => {
|
|
2726
|
-
this.runRegistry.update(run.id, (current) => {
|
|
2727
|
-
if (event.sessionId)
|
|
2728
|
-
current.sessionId = event.sessionId;
|
|
2729
|
-
appendHostMessage(current, hostId, event, channel);
|
|
2730
|
-
if (event.raw) {
|
|
2731
|
-
current.events.push((0, hosts_1.createHubEvent)(channel, event.raw));
|
|
2732
|
-
applyReviewProjection(current, event.raw);
|
|
2733
|
-
}
|
|
2734
|
-
if (event.agentIdentity)
|
|
2735
|
-
applyAgentIdentitySignal(current, event.agentIdentity);
|
|
2736
|
-
if (event.fraimJob)
|
|
2737
|
-
this.applyFraimJobSignalToRun(current, event.fraimJob);
|
|
2738
|
-
if (event.seekMentoring)
|
|
2739
|
-
this.applySeekMentoringSignalToRun(current, event.seekMentoring);
|
|
2740
|
-
if (event.usage)
|
|
2741
|
-
applyUsageSignal(current, event.usage);
|
|
2742
|
-
});
|
|
2743
|
-
const updated = this.runRegistry.get(run.id);
|
|
2744
|
-
if (updated) {
|
|
2745
|
-
this.maybeStartDelegatedChildRuns(updated);
|
|
2746
|
-
this.persistRunConversation(updated, updated.conversationId || updated.id);
|
|
2747
|
-
}
|
|
2748
|
-
},
|
|
2749
|
-
onExit: (exitCode) => {
|
|
2750
|
-
this.runRegistry.update(run.id, (current) => {
|
|
2751
|
-
current.exitCode = exitCode;
|
|
2752
|
-
if (current.stoppedByUser) {
|
|
2753
|
-
// Manager stopped it — park in "waiting on you", don't call it a failure.
|
|
2754
|
-
current.status = 'failed';
|
|
2755
|
-
current.events.push((0, hosts_1.createHubEvent)('system', '⏹ Run stopped by you. The employee is paused — send the next instruction to continue.'));
|
|
2756
|
-
}
|
|
2757
|
-
else {
|
|
2758
|
-
current.status = exitCode === 0 ? 'completed' : 'failed';
|
|
2759
|
-
current.events.push((0, hosts_1.createHubEvent)('system', `Run exited with code ${exitCode ?? 'unknown'}.`));
|
|
2760
|
-
}
|
|
2761
|
-
});
|
|
2762
|
-
const updated = this.runRegistry.get(run.id);
|
|
2763
|
-
if (updated) {
|
|
2764
|
-
this.maybeStartDelegatedChildRuns(updated);
|
|
2765
|
-
this.persistRunConversation(updated, updated.conversationId || updated.id);
|
|
2766
|
-
}
|
|
2767
|
-
this.runRegistry.dispose(run.id);
|
|
2768
|
-
const latest = this.runRegistry.get(run.id);
|
|
2769
|
-
if (latest)
|
|
2770
|
-
this.drainPendingDelegatedReviews(latest);
|
|
2771
|
-
},
|
|
2772
|
-
}, startSessionSeedForHost(hostId, run.id));
|
|
2773
|
-
this.runRegistry.attachChildIfRunning(run.id, child);
|
|
2774
|
-
// Issue #442: spawn the Direct run via startDirectRun so CliHostRuntime
|
|
2775
|
-
// uses buildDirectStartPlan (--strict-mcp-config, raw stdin) rather than
|
|
2776
|
-
// the FRAIM-wrapping buildStartPlan path.
|
|
2777
|
-
if (directRun) {
|
|
2778
|
-
const directId = directRun.id;
|
|
2779
|
-
const directChild = this.hostRuntime.startDirectRun(hostId, directMsg, projectPath, {
|
|
2780
|
-
onEvent: (event, channel) => {
|
|
2781
|
-
this.runRegistry.update(directId, (current) => {
|
|
2782
|
-
if (event.sessionId)
|
|
2783
|
-
current.sessionId = event.sessionId;
|
|
2784
|
-
appendHostMessage(current, hostId, event, channel);
|
|
2785
|
-
if (event.raw)
|
|
2786
|
-
current.events.push((0, hosts_1.createHubEvent)(channel, event.raw));
|
|
2787
|
-
if (event.agentIdentity)
|
|
2788
|
-
applyAgentIdentitySignal(current, event.agentIdentity);
|
|
2789
|
-
if (event.usage)
|
|
2790
|
-
applyUsageSignal(current, event.usage);
|
|
2791
|
-
});
|
|
2792
|
-
},
|
|
2793
|
-
onExit: (exitCode) => {
|
|
2794
|
-
this.runRegistry.update(directId, (current) => {
|
|
2795
|
-
current.exitCode = exitCode;
|
|
2796
|
-
current.status = exitCode === 0 ? 'completed' : 'failed';
|
|
2797
|
-
current.events.push((0, hosts_1.createHubEvent)('system', `Direct run exited with code ${exitCode ?? 'unknown'}.`));
|
|
2798
|
-
});
|
|
2799
|
-
this.runRegistry.dispose(directId);
|
|
2800
|
-
},
|
|
2801
|
-
}, startSessionSeedForHost(hostId, directRun.id));
|
|
2802
|
-
this.runRegistry.attachChildIfRunning(directRun.id, directChild);
|
|
2803
|
-
}
|
|
2804
|
-
const existingPreferences = this.preferencesStore.load(projectPath);
|
|
2805
|
-
this.preferencesStore.remember({
|
|
2806
|
-
...existingPreferences,
|
|
2807
|
-
projectPath,
|
|
2808
|
-
employeeId: hostId,
|
|
2809
|
-
recentJobIds: existingPreferences.recentJobIds,
|
|
2810
|
-
}, jobId, typeof instructions === 'string' ? instructions : undefined);
|
|
2811
|
-
const fraimRunEnriched = this.enrichRunForResponse(this.runRegistry.get(run.id) ?? run);
|
|
2812
|
-
const responsePayload = directRun
|
|
2813
|
-
? { ...fraimRunEnriched, compareRun: this.enrichRunForResponse(directRun) }
|
|
2814
|
-
: fraimRunEnriched;
|
|
2815
|
-
res.status(201).json(responsePayload);
|
|
2816
|
-
// Background sync: refresh the local FRAIM catalog so the next job
|
|
2817
|
-
// picker load sees any jobs that were added or updated since last run.
|
|
2818
|
-
try {
|
|
2819
|
-
const syncChild = (0, child_process_1.spawn)('npx', ['fraim', 'sync'], { cwd: projectPath, detached: true, stdio: 'ignore' });
|
|
2820
|
-
syncChild.on('error', () => { });
|
|
2821
|
-
syncChild.unref();
|
|
2822
|
-
}
|
|
2823
|
-
catch { /* ignore if spawn itself throws synchronously */ }
|
|
2824
|
-
}
|
|
2825
|
-
catch (error) {
|
|
2826
|
-
res.status(400).json({ error: error instanceof Error ? error.message : 'Could not start run.' });
|
|
2827
|
-
}
|
|
2828
|
-
});
|
|
2829
|
-
// #521: Shared persistent browser. start() launches (or reuses) the one
|
|
2830
|
-
// FRAIM-owned Chrome/Edge and publishes its CDP endpoint to agents via env so
|
|
2831
|
-
// the browser-use skill connects to it instead of launching a throwaway one.
|
|
2832
|
-
this.app.get('/api/ai-hub/browser/status', async (_req, res) => {
|
|
2833
|
-
const running = await this.managedBrowser.isRunning();
|
|
2834
|
-
res.json({ running, ...this.managedBrowser.status() });
|
|
2835
|
-
});
|
|
2836
|
-
this.app.post('/api/ai-hub/browser/start', async (_req, res) => {
|
|
2837
|
-
try {
|
|
2838
|
-
const result = await this.ensureManagedBrowser();
|
|
2839
|
-
res.json({
|
|
2840
|
-
ok: true,
|
|
2841
|
-
...result,
|
|
2842
|
-
loginHint: 'Log into your sites in the FRAIM browser window — the session persists across agent turns.',
|
|
2843
|
-
});
|
|
2844
|
-
}
|
|
2845
|
-
catch (err) {
|
|
2846
|
-
res.status(500).json({ error: err instanceof Error ? err.message : 'Could not start the browser.' });
|
|
2847
|
-
}
|
|
2848
|
-
});
|
|
2849
|
-
this.app.post('/api/ai-hub/browser/stop', (_req, res) => {
|
|
2850
|
-
// Kill the process; keep FRAIM_BROWSER_CDP_ENDPOINT published — it's the
|
|
2851
|
-
// deterministic location, so the agent can re-ensure and reconnect later.
|
|
2852
|
-
this.managedBrowser.stop();
|
|
2853
|
-
res.json({ ok: true });
|
|
2854
|
-
});
|
|
2855
|
-
// #521: Stop — the manager interrupts a running agent. Kills the process and
|
|
2856
|
-
// parks the run in a "waiting on you" state so they can give the next
|
|
2857
|
-
// instruction (continue) or leave it. Idempotent for already-finished runs.
|
|
2858
|
-
this.app.post('/api/ai-hub/runs/:runId/stop', (req, res) => {
|
|
2859
|
-
const run = this.runRegistry.get(req.params.runId);
|
|
2860
|
-
if (!run) {
|
|
2861
|
-
return res.status(404).json({ error: 'Run not found.' });
|
|
2862
|
-
}
|
|
2863
|
-
if (run.status !== 'running') {
|
|
2864
|
-
// Nothing to stop — return the current state unchanged.
|
|
2865
|
-
return res.json(this.enrichRunForResponse(run));
|
|
2866
|
-
}
|
|
2867
|
-
this.runRegistry.update(run.id, (current) => { current.stoppedByUser = true; });
|
|
2868
|
-
const killed = this.runRegistry.stop(run.id);
|
|
2869
|
-
// Park it immediately (don't wait for onExit, which may lag or not fire on a
|
|
2870
|
-
// host that already detached). onExit, if it fires, keeps this same state.
|
|
2871
|
-
this.runRegistry.update(run.id, (current) => {
|
|
2872
|
-
current.status = 'failed';
|
|
2873
|
-
current.events.push((0, hosts_1.createHubEvent)('system', killed
|
|
2874
|
-
? '⏹ Run stopped by you. The employee is paused — send the next instruction to continue.'
|
|
2875
|
-
: '⏹ Stop requested. The employee was already wrapping up — send the next instruction to continue.'));
|
|
2876
|
-
});
|
|
2877
|
-
const stopped = this.runRegistry.get(run.id);
|
|
2878
|
-
if (stopped)
|
|
2879
|
-
this.persistRunConversation(stopped, stopped.conversationId || stopped.id);
|
|
2880
|
-
this.runRegistry.dispose(run.id);
|
|
2881
|
-
return res.json(this.enrichRunForResponse(this.runRegistry.get(run.id) ?? run));
|
|
2882
|
-
});
|
|
2883
|
-
this.app.post('/api/ai-hub/runs/:runId/messages', (req, res) => {
|
|
2884
|
-
try {
|
|
2885
|
-
const run = this.runRegistry.get(req.params.runId);
|
|
2886
|
-
if (!run) {
|
|
2887
|
-
return res.status(404).json({ error: 'Run not found.' });
|
|
2888
|
-
}
|
|
2889
|
-
if (!run.sessionId) {
|
|
2890
|
-
return res.status(409).json({ error: 'This run does not have a resumable host session yet.' });
|
|
2891
|
-
}
|
|
2892
|
-
const instructions = (req.body.instructions || '').trim();
|
|
2893
|
-
const coachingJobId = req.body.coachingJobId?.trim() || undefined;
|
|
2894
|
-
// When coachingJobId is present (user picked a manager template via the UI),
|
|
2895
|
-
// it overrides the run's own jobId in the invocation. The server always adds
|
|
2896
|
-
// the correct $fraim / /fraim prefix — the UI never passes raw invocation syntax.
|
|
2897
|
-
const prepared = instructions
|
|
2898
|
-
? this.prepareContinueMessage(run, instructions, coachingJobId)
|
|
2899
|
-
: coachingJobId
|
|
2900
|
-
? this.prepareContinueMessage(run, '', coachingJobId)
|
|
2901
|
-
: { message: (req.body.message || '').trim(), display: (req.body.message || '').trim() };
|
|
2902
|
-
const message = prepared.message;
|
|
2903
|
-
if (!message) {
|
|
2904
|
-
return res.status(400).json({ error: 'Coach your employee before sending the next turn.' });
|
|
2905
|
-
}
|
|
2906
|
-
this.runRegistry.update(run.id, (current) => {
|
|
2907
|
-
current.status = 'running';
|
|
2908
|
-
// #521: bubble shows the manager's words; the agent gets the full message.
|
|
2909
|
-
current.messages.push((0, hosts_1.createHubMessage)('manager', prepared.display || message));
|
|
2910
|
-
});
|
|
2911
|
-
const started = this.runRegistry.get(run.id);
|
|
2912
|
-
if (started)
|
|
2913
|
-
this.persistRunConversation(started, started.conversationId || started.id);
|
|
2914
|
-
this.runRegistry.create(run, {});
|
|
2915
|
-
const child = this.hostRuntime.continueRun(run.hostId, run.projectPath, run.sessionId, message, {
|
|
2916
|
-
onEvent: (event, channel) => {
|
|
2917
|
-
this.runRegistry.update(run.id, (current) => {
|
|
2918
|
-
if (event.sessionId)
|
|
2919
|
-
current.sessionId = event.sessionId;
|
|
2920
|
-
appendHostMessage(current, run.hostId, event, channel);
|
|
2921
|
-
if (event.raw) {
|
|
2922
|
-
current.events.push((0, hosts_1.createHubEvent)(channel, event.raw));
|
|
2923
|
-
applyReviewProjection(current, event.raw);
|
|
2924
|
-
}
|
|
2925
|
-
if (event.agentIdentity)
|
|
2926
|
-
applyAgentIdentitySignal(current, event.agentIdentity);
|
|
2927
|
-
if (event.fraimJob)
|
|
2928
|
-
this.applyFraimJobSignalToRun(current, event.fraimJob);
|
|
2929
|
-
if (event.seekMentoring)
|
|
2930
|
-
this.applySeekMentoringSignalToRun(current, event.seekMentoring);
|
|
2931
|
-
if (event.usage)
|
|
2932
|
-
applyUsageSignal(current, event.usage);
|
|
2933
|
-
});
|
|
2934
|
-
const updated = this.runRegistry.get(run.id);
|
|
2935
|
-
if (updated) {
|
|
2936
|
-
this.maybeStartDelegatedChildRuns(updated);
|
|
2937
|
-
this.persistRunConversation(updated, updated.conversationId || updated.id);
|
|
2938
|
-
}
|
|
2939
|
-
},
|
|
2940
|
-
onExit: (exitCode) => {
|
|
2941
|
-
this.runRegistry.update(run.id, (current) => {
|
|
2942
|
-
current.exitCode = exitCode;
|
|
2943
|
-
current.status = exitCode === 0 ? 'completed' : 'failed';
|
|
2944
|
-
current.events.push((0, hosts_1.createHubEvent)('system', `Run exited with code ${exitCode ?? 'unknown'}.`));
|
|
2945
|
-
});
|
|
2946
|
-
const updated = this.runRegistry.get(run.id);
|
|
2947
|
-
if (updated) {
|
|
2948
|
-
this.maybeStartDelegatedChildRuns(updated);
|
|
2949
|
-
this.persistRunConversation(updated, updated.conversationId || updated.id);
|
|
2950
|
-
}
|
|
2951
|
-
this.runRegistry.dispose(run.id);
|
|
2952
|
-
const latest = this.runRegistry.get(run.id);
|
|
2953
|
-
if (latest)
|
|
2954
|
-
this.drainPendingDelegatedReviews(latest);
|
|
2955
|
-
},
|
|
2956
|
-
});
|
|
2957
|
-
this.runRegistry.attachChildIfRunning(run.id, child);
|
|
2958
|
-
const refreshed = this.runRegistry.get(run.id);
|
|
2959
|
-
res.json(refreshed ? this.enrichRunForResponse(refreshed) : refreshed);
|
|
2960
|
-
}
|
|
2961
|
-
catch (error) {
|
|
2962
|
-
res.status(400).json({ error: error instanceof Error ? error.message : 'Could not continue run.' });
|
|
2963
|
-
}
|
|
2964
|
-
});
|
|
2965
|
-
// #521: resume a conversation whose Hub run was lost (e.g. a server restart)
|
|
2966
|
-
// but whose agent session still exists on disk. Recreates a run bound to the
|
|
2967
|
-
// existing sessionId and continues it via the host's resume path — so a
|
|
2968
|
-
// conversation can be carried forward without losing its context.
|
|
2969
|
-
this.app.post('/api/ai-hub/runs/resume', (req, res) => {
|
|
2970
|
-
try {
|
|
2971
|
-
const body = (req.body ?? {});
|
|
2972
|
-
const projectPath = ensureDirectoryPath(body.projectPath || this.projectPath);
|
|
2973
|
-
const hostId = body.hostId;
|
|
2974
|
-
const sessionId = (body.sessionId || '').trim();
|
|
2975
|
-
const jobId = (body.jobId || '').trim();
|
|
2976
|
-
const instructions = (body.instructions || '').trim();
|
|
2977
|
-
const coachingJobId = body.coachingJobId?.trim() || undefined;
|
|
2978
|
-
if (hostId !== 'codex' && hostId !== 'claude' && hostId !== 'gemini') {
|
|
2979
|
-
throw new Error('Choose an available employee to resume.');
|
|
2980
|
-
}
|
|
2981
|
-
if (!sessionId)
|
|
2982
|
-
throw new Error('A host sessionId is required to resume.');
|
|
2983
|
-
if (!jobId)
|
|
2984
|
-
throw new Error('A jobId is required to resume.');
|
|
2985
|
-
if (!instructions)
|
|
2986
|
-
throw new Error('Provide an instruction to continue.');
|
|
2987
|
-
const employee = this.hostRuntime.detectEmployees().find((entry) => entry.id === hostId);
|
|
2988
|
-
if (!employee?.available) {
|
|
2989
|
-
throw new Error(`${employee?.label || 'Selected employee'} is not available on this machine.`);
|
|
2990
|
-
}
|
|
2991
|
-
const conversationId = typeof body.conversationId === 'string' && body.conversationId.trim()
|
|
2992
|
-
? body.conversationId.trim()
|
|
2993
|
-
: undefined;
|
|
2994
|
-
const persistedConversation = conversationId
|
|
2995
|
-
? this.conversationStore.loadProject(projectPath).conversations.find((entry) => entry.id === conversationId)
|
|
2996
|
-
: undefined;
|
|
2997
|
-
const persistedRun = readPersistedRunProjection(persistedConversation);
|
|
2998
|
-
const now = new Date().toISOString();
|
|
2999
|
-
const run = {
|
|
3000
|
-
id: (0, crypto_1.randomUUID)(),
|
|
3001
|
-
conversationId,
|
|
3002
|
-
conversationTitle: typeof body.conversationTitle === 'string' && body.conversationTitle.trim() ? body.conversationTitle.trim() : undefined,
|
|
3003
|
-
jobTitle: typeof body.jobTitle === 'string' && body.jobTitle.trim() ? body.jobTitle.trim() : jobId,
|
|
3004
|
-
jobId, hostId, projectPath, status: 'running', sessionId,
|
|
3005
|
-
createdAt: now, updatedAt: now, messages: [],
|
|
3006
|
-
events: [(0, hosts_1.createHubEvent)('system', `Resuming ${hostId} session ${sessionId} in ${projectPath}`)],
|
|
3007
|
-
currentPhase: persistedRun?.currentPhase || null,
|
|
3008
|
-
phaseHistory: persistedRun?.phaseHistory || [],
|
|
3009
|
-
totals: persistedRun?.totals || emptyTotals(),
|
|
3010
|
-
lastStatusChangeAt: now,
|
|
3011
|
-
runDiscriminant: persistedRun?.runDiscriminant || undefined,
|
|
3012
|
-
personaKey: getProtectedPersonaForHubJob(jobId),
|
|
3013
|
-
};
|
|
3014
|
-
// Continue-turn message (FRAIM invocation for the job + instructions) plus
|
|
3015
|
-
// the shared-browser note so the resumed agent knows about it.
|
|
3016
|
-
const preparedResume = this.prepareContinueMessage(run, instructions, coachingJobId);
|
|
3017
|
-
const message = preparedResume.message
|
|
3018
|
-
+ (0, managed_browser_1.buildBrowserContextNote)(process.env.FRAIM_BROWSER_CDP_ENDPOINT, process.env.FRAIM_HUB_BASE_URL);
|
|
3019
|
-
// #521: bubble shows the manager's words; the agent gets the full message.
|
|
3020
|
-
run.messages.push((0, hosts_1.createHubMessage)('manager', preparedResume.display || message));
|
|
3021
|
-
this.runRegistry.create(run, {});
|
|
3022
|
-
this.persistRunConversation(run, run.conversationId || run.id);
|
|
3023
|
-
const child = this.hostRuntime.continueRun(hostId, projectPath, sessionId, message, {
|
|
3024
|
-
onEvent: (event, channel) => {
|
|
3025
|
-
this.runRegistry.update(run.id, (current) => {
|
|
3026
|
-
if (event.sessionId)
|
|
3027
|
-
current.sessionId = event.sessionId;
|
|
3028
|
-
appendHostMessage(current, hostId, event, channel);
|
|
3029
|
-
if (event.raw) {
|
|
3030
|
-
current.events.push((0, hosts_1.createHubEvent)(channel, event.raw));
|
|
3031
|
-
applyReviewProjection(current, event.raw);
|
|
3032
|
-
}
|
|
3033
|
-
if (event.agentIdentity)
|
|
3034
|
-
applyAgentIdentitySignal(current, event.agentIdentity);
|
|
3035
|
-
if (event.fraimJob)
|
|
3036
|
-
this.applyFraimJobSignalToRun(current, event.fraimJob);
|
|
3037
|
-
if (event.seekMentoring)
|
|
3038
|
-
this.applySeekMentoringSignalToRun(current, event.seekMentoring);
|
|
3039
|
-
if (event.usage)
|
|
3040
|
-
applyUsageSignal(current, event.usage);
|
|
3041
|
-
});
|
|
3042
|
-
const updated = this.runRegistry.get(run.id);
|
|
3043
|
-
if (updated)
|
|
3044
|
-
this.persistRunConversation(updated, updated.conversationId || updated.id);
|
|
3045
|
-
},
|
|
3046
|
-
onExit: (exitCode) => {
|
|
3047
|
-
this.runRegistry.update(run.id, (current) => {
|
|
3048
|
-
current.exitCode = exitCode;
|
|
3049
|
-
current.status = exitCode === 0 ? 'completed' : 'failed';
|
|
3050
|
-
current.events.push((0, hosts_1.createHubEvent)('system', `Run exited with code ${exitCode ?? 'unknown'}.`));
|
|
3051
|
-
});
|
|
3052
|
-
const updated = this.runRegistry.get(run.id);
|
|
3053
|
-
if (updated)
|
|
3054
|
-
this.persistRunConversation(updated, updated.conversationId || updated.id);
|
|
3055
|
-
this.runRegistry.dispose(run.id);
|
|
3056
|
-
const latest = this.runRegistry.get(run.id);
|
|
3057
|
-
if (latest)
|
|
3058
|
-
this.drainPendingDelegatedReviews(latest);
|
|
3059
|
-
},
|
|
3060
|
-
});
|
|
3061
|
-
this.runRegistry.attachChildIfRunning(run.id, child);
|
|
3062
|
-
res.status(201).json(this.enrichRunForResponse(this.runRegistry.get(run.id) ?? run));
|
|
3063
|
-
}
|
|
3064
|
-
catch (error) {
|
|
3065
|
-
res.status(400).json({ error: error instanceof Error ? error.message : 'Could not resume the conversation.' });
|
|
3066
|
-
}
|
|
3067
|
-
});
|
|
3068
|
-
// Issue #442: continue the Direct (B) run without FRAIM MCP servers.
|
|
3069
|
-
this.app.post('/api/ai-hub/runs/:runId/direct-messages', (req, res) => {
|
|
3070
|
-
try {
|
|
3071
|
-
const run = this.runRegistry.get(req.params.runId);
|
|
3072
|
-
if (!run)
|
|
3073
|
-
return res.status(404).json({ error: 'Run not found.' });
|
|
3074
|
-
if (run.runRole !== 'direct')
|
|
3075
|
-
return res.status(400).json({ error: 'Run is not a Direct run.' });
|
|
3076
|
-
if (!run.sessionId)
|
|
3077
|
-
return res.status(409).json({ error: 'Direct run does not have a resumable session yet.' });
|
|
3078
|
-
const message = (req.body.message || '').trim();
|
|
3079
|
-
if (!message)
|
|
3080
|
-
return res.status(400).json({ error: 'Message is required.' });
|
|
3081
|
-
this.runRegistry.update(run.id, (current) => {
|
|
3082
|
-
current.status = 'running';
|
|
3083
|
-
current.messages.push((0, hosts_1.createHubMessage)('manager', message));
|
|
3084
|
-
});
|
|
3085
|
-
this.runRegistry.create(run, {});
|
|
3086
|
-
const child = this.hostRuntime.continueDirectRun(run.hostId, run.sessionId, message, run.projectPath, {
|
|
3087
|
-
onEvent: (event, channel) => {
|
|
3088
|
-
this.runRegistry.update(run.id, (current) => {
|
|
3089
|
-
if (event.sessionId)
|
|
3090
|
-
current.sessionId = event.sessionId;
|
|
3091
|
-
appendHostMessage(current, run.hostId, event, channel);
|
|
3092
|
-
if (event.raw)
|
|
3093
|
-
current.events.push((0, hosts_1.createHubEvent)(channel, event.raw));
|
|
3094
|
-
if (event.usage)
|
|
3095
|
-
applyUsageSignal(current, event.usage);
|
|
3096
|
-
});
|
|
3097
|
-
},
|
|
3098
|
-
onExit: (exitCode) => {
|
|
3099
|
-
this.runRegistry.update(run.id, (current) => {
|
|
3100
|
-
current.exitCode = exitCode;
|
|
3101
|
-
current.status = exitCode === 0 ? 'completed' : 'failed';
|
|
3102
|
-
current.events.push((0, hosts_1.createHubEvent)('system', `Direct run exited with code ${exitCode ?? 'unknown'}.`));
|
|
3103
|
-
});
|
|
3104
|
-
this.runRegistry.dispose(run.id);
|
|
3105
|
-
},
|
|
3106
|
-
});
|
|
3107
|
-
this.runRegistry.attachChildIfRunning(run.id, child);
|
|
3108
|
-
const refreshed = this.runRegistry.get(run.id);
|
|
3109
|
-
res.json(refreshed ? this.enrichRunForResponse(refreshed) : refreshed);
|
|
3110
|
-
}
|
|
3111
|
-
catch (error) {
|
|
3112
|
-
res.status(400).json({ error: error instanceof Error ? error.message : 'Could not continue Direct run.' });
|
|
3113
|
-
}
|
|
3114
|
-
});
|
|
3115
|
-
// GET /api/ai-hub/runs — list runs for cross-host polling and the UI ledger.
|
|
3116
|
-
// Query params: status (filter), sourceTrigger (filter), limit (default 100, max 200).
|
|
3117
|
-
// Must be registered BEFORE the :runId route to avoid 'runs' being matched as a runId.
|
|
3118
|
-
this.app.get('/api/ai-hub/runs', (req, res) => {
|
|
3119
|
-
const statusFilter = typeof req.query.status === 'string' ? req.query.status : null;
|
|
3120
|
-
const triggerFilter = typeof req.query.sourceTrigger === 'string' ? req.query.sourceTrigger : null;
|
|
3121
|
-
const limit = Math.min(200, parseInt(String(req.query.limit || '100'), 10) || 100);
|
|
3122
|
-
let runs = this.runRegistry.all();
|
|
3123
|
-
if (statusFilter)
|
|
3124
|
-
runs = runs.filter((r) => r.status === statusFilter);
|
|
3125
|
-
if (triggerFilter)
|
|
3126
|
-
runs = runs.filter((r) => (r.sourceTrigger ?? 'manager') === triggerFilter);
|
|
3127
|
-
runs = runs.sort((a, b) => b.updatedAt.localeCompare(a.updatedAt)).slice(0, limit);
|
|
3128
|
-
return res.json(runs.map((r) => this.enrichRunForResponse(r)));
|
|
3129
|
-
});
|
|
3130
|
-
// ─── Issue #578: /api/ai-hub/runs/merged must be registered BEFORE :runId ──
|
|
3131
|
-
this.app.get('/api/ai-hub/runs/merged', async (req, res) => {
|
|
3132
|
-
const local = this.runRegistry.all().map((r) => this.enrichRunForResponse(r));
|
|
3133
|
-
const hosts = this.hostConfigStore.load();
|
|
3134
|
-
const remoteResults = await Promise.allSettled(hosts.map(async (host) => {
|
|
3135
|
-
const url = `${host.url.replace(/\/$/, '')}/api/ai-hub/runs`;
|
|
3136
|
-
const resp = await fetch(url, {
|
|
3137
|
-
signal: AbortSignal.timeout(8000),
|
|
3138
|
-
headers: host.authToken ? { 'X-Hub-Auth': host.authToken } : {},
|
|
3139
|
-
});
|
|
3140
|
-
if (!resp.ok)
|
|
3141
|
-
return [];
|
|
3142
|
-
return resp.json();
|
|
3143
|
-
}));
|
|
3144
|
-
const remote = remoteResults
|
|
3145
|
-
.filter((r) => r.status === 'fulfilled')
|
|
3146
|
-
.flatMap((r) => r.value);
|
|
3147
|
-
const merged = [...local, ...remote].sort((a, b) => b.updatedAt.localeCompare(a.updatedAt));
|
|
3148
|
-
return res.json(merged);
|
|
3149
|
-
});
|
|
3150
|
-
// GET /api/ai-hub/runs/:runId — registered AFTER /merged to avoid shadowing.
|
|
3151
|
-
this.app.get('/api/ai-hub/runs/:runId', (req, res) => {
|
|
3152
|
-
const run = this.runRegistry.get(req.params.runId);
|
|
3153
|
-
if (!run) {
|
|
3154
|
-
return res.status(404).json({ error: 'Run not found.' });
|
|
3155
|
-
}
|
|
3156
|
-
return res.json(this.enrichRunForResponse(run));
|
|
3157
|
-
});
|
|
3158
|
-
// ─── Issue #578: Scheduled + Reactive Employees ───────────────────────────
|
|
3159
|
-
// POST /api/ai-hub/schedules — create a recurring scheduled deployment.
|
|
3160
|
-
this.app.post('/api/ai-hub/schedules', (req, res) => {
|
|
3161
|
-
const { label, jobId, projectPath, hostId, cronExpr, instructions, outputChannel, allowConcurrent } = req.body ?? {};
|
|
3162
|
-
if (!label || !jobId || !cronExpr) {
|
|
3163
|
-
return res.status(400).json({ error: 'label, jobId, and cronExpr are required.' });
|
|
3164
|
-
}
|
|
3165
|
-
// eslint-disable-next-line @typescript-eslint/no-var-requires
|
|
3166
|
-
const cronLib = require('node-cron');
|
|
3167
|
-
if (!cronLib.validate(cronExpr)) {
|
|
3168
|
-
return res.status(400).json({ error: 'Invalid cron expression.' });
|
|
3169
|
-
}
|
|
3170
|
-
const validEmployees = VALID_EMPLOYEE_IDS;
|
|
3171
|
-
const resolvedHostId = validEmployees.includes(hostId) ? hostId : 'claude';
|
|
3172
|
-
const now = new Date().toISOString();
|
|
3173
|
-
const deployment = {
|
|
3174
|
-
id: (0, crypto_1.randomUUID)(),
|
|
3175
|
-
type: 'scheduled',
|
|
3176
|
-
label,
|
|
3177
|
-
jobId,
|
|
3178
|
-
projectPath: ensureDirectoryPath(projectPath || this.projectPath),
|
|
3179
|
-
hostId: resolvedHostId,
|
|
3180
|
-
cronExpr,
|
|
3181
|
-
instructions: typeof instructions === 'string' ? instructions : undefined,
|
|
3182
|
-
outputChannel: typeof outputChannel === 'string' ? outputChannel : undefined,
|
|
3183
|
-
allowConcurrent: allowConcurrent === true,
|
|
3184
|
-
active: true,
|
|
3185
|
-
createdAt: now,
|
|
3186
|
-
updatedAt: now,
|
|
3187
|
-
};
|
|
3188
|
-
this.deploymentStore.create(deployment);
|
|
3189
|
-
this.scheduleDeployment(deployment);
|
|
3190
|
-
return res.status(201).json(deployment);
|
|
3191
|
-
});
|
|
3192
|
-
// GET /api/ai-hub/schedules — list scheduled deployments for one project.
|
|
3193
|
-
this.app.get('/api/ai-hub/schedules', (req, res) => {
|
|
3194
|
-
try {
|
|
3195
|
-
const projectPath = deploymentProjectFilter(req.query.projectPath, this.projectPath);
|
|
3196
|
-
return res.json(this.deploymentStore.load().filter((d) => d.type === 'scheduled' && deploymentBelongsToProject(d, projectPath, this.projectPath)));
|
|
3197
|
-
}
|
|
3198
|
-
catch (err) {
|
|
3199
|
-
return res.status(400).json({ error: err instanceof Error ? err.message : 'Invalid project path.' });
|
|
3200
|
-
}
|
|
3201
|
-
});
|
|
3202
|
-
// DELETE /api/ai-hub/schedules/:id — remove a scheduled deployment.
|
|
3203
|
-
this.app.delete('/api/ai-hub/schedules/:id', (req, res) => {
|
|
3204
|
-
const { id } = req.params;
|
|
3205
|
-
const task = this.cronHandles.get(id);
|
|
3206
|
-
if (task) {
|
|
3207
|
-
task.stop();
|
|
3208
|
-
this.cronHandles.delete(id);
|
|
3209
|
-
}
|
|
3210
|
-
const deleted = this.deploymentStore.delete(id);
|
|
3211
|
-
if (!deleted)
|
|
3212
|
-
return res.status(404).json({ error: 'Deployment not found.' });
|
|
3213
|
-
return res.json({ ok: true });
|
|
3214
|
-
});
|
|
3215
|
-
// PUT /api/ai-hub/schedules/:id — update an existing scheduled deployment.
|
|
3216
|
-
this.app.put('/api/ai-hub/schedules/:id', (req, res) => {
|
|
3217
|
-
const { id } = req.params;
|
|
3218
|
-
const { label, jobId, projectPath, cronExpr, hostId, instructions, allowConcurrent } = req.body ?? {};
|
|
3219
|
-
if (cronExpr !== undefined) {
|
|
3220
|
-
// eslint-disable-next-line @typescript-eslint/no-var-requires
|
|
3221
|
-
const cronLib = require('node-cron');
|
|
3222
|
-
if (!cronLib.validate(cronExpr)) {
|
|
3223
|
-
return res.status(400).json({ error: 'Invalid cron expression.' });
|
|
3224
|
-
}
|
|
3225
|
-
}
|
|
3226
|
-
let resolvedProjectPath;
|
|
3227
|
-
try {
|
|
3228
|
-
if (projectPath !== undefined)
|
|
3229
|
-
resolvedProjectPath = ensureDirectoryPath(projectPath || this.projectPath);
|
|
3230
|
-
}
|
|
3231
|
-
catch (err) {
|
|
3232
|
-
return res.status(400).json({ error: err instanceof Error ? err.message : 'Invalid project path.' });
|
|
3233
|
-
}
|
|
3234
|
-
const validEmployees = VALID_EMPLOYEE_IDS;
|
|
3235
|
-
let updated = null;
|
|
3236
|
-
const ok = this.deploymentStore.update(id, (dep) => {
|
|
3237
|
-
if (label !== undefined)
|
|
3238
|
-
dep.label = label;
|
|
3239
|
-
if (jobId !== undefined)
|
|
3240
|
-
dep.jobId = jobId;
|
|
3241
|
-
if (resolvedProjectPath !== undefined)
|
|
3242
|
-
dep.projectPath = resolvedProjectPath;
|
|
3243
|
-
if (cronExpr !== undefined)
|
|
3244
|
-
dep.cronExpr = cronExpr;
|
|
3245
|
-
if (hostId !== undefined && validEmployees.includes(hostId))
|
|
3246
|
-
dep.hostId = hostId;
|
|
3247
|
-
if (instructions !== undefined)
|
|
3248
|
-
dep.instructions = typeof instructions === 'string' ? instructions : undefined;
|
|
3249
|
-
if (allowConcurrent !== undefined)
|
|
3250
|
-
dep.allowConcurrent = allowConcurrent === true;
|
|
3251
|
-
updated = dep;
|
|
3252
|
-
});
|
|
3253
|
-
if (!ok || !updated)
|
|
3254
|
-
return res.status(404).json({ error: 'Deployment not found.' });
|
|
3255
|
-
const oldTask = this.cronHandles.get(id);
|
|
3256
|
-
if (oldTask) {
|
|
3257
|
-
oldTask.stop();
|
|
3258
|
-
this.cronHandles.delete(id);
|
|
3259
|
-
}
|
|
3260
|
-
this.scheduleDeployment(updated);
|
|
3261
|
-
return res.json(updated);
|
|
3262
|
-
});
|
|
3263
|
-
// POST /api/ai-hub/webhooks — register an inbound webhook deployment.
|
|
3264
|
-
this.app.post('/api/ai-hub/webhooks', (req, res) => {
|
|
3265
|
-
const { label, jobId, projectPath, hostId, instructions, outputChannel, allowConcurrent } = req.body ?? {};
|
|
3266
|
-
if (!label || !jobId) {
|
|
3267
|
-
return res.status(400).json({ error: 'label and jobId are required.' });
|
|
3268
|
-
}
|
|
3269
|
-
const validEmployees = VALID_EMPLOYEE_IDS;
|
|
3270
|
-
const resolvedHostId = validEmployees.includes(hostId) ? hostId : 'claude';
|
|
3271
|
-
const now = new Date().toISOString();
|
|
3272
|
-
const deployment = {
|
|
3273
|
-
id: (0, crypto_1.randomUUID)(),
|
|
3274
|
-
type: 'webhook',
|
|
3275
|
-
label,
|
|
3276
|
-
jobId,
|
|
3277
|
-
projectPath: ensureDirectoryPath(projectPath || this.projectPath),
|
|
3278
|
-
hostId: resolvedHostId,
|
|
3279
|
-
instructions: typeof instructions === 'string' ? instructions : undefined,
|
|
3280
|
-
outputChannel: typeof outputChannel === 'string' ? outputChannel : undefined,
|
|
3281
|
-
allowConcurrent: allowConcurrent === true,
|
|
3282
|
-
active: true,
|
|
3283
|
-
createdAt: now,
|
|
3284
|
-
updatedAt: now,
|
|
3285
|
-
};
|
|
3286
|
-
this.deploymentStore.create(deployment);
|
|
3287
|
-
return res.status(201).json({ ...deployment, inboundUrl: `${this.hubBase}/api/ai-hub/webhooks/${deployment.id}/inbound` });
|
|
3288
|
-
});
|
|
3289
|
-
// GET /api/ai-hub/webhooks — list webhook deployments for one project.
|
|
3290
|
-
this.app.get('/api/ai-hub/webhooks', (req, res) => {
|
|
3291
|
-
const hubBase = this.hubBase;
|
|
3292
|
-
try {
|
|
3293
|
-
const projectPath = deploymentProjectFilter(req.query.projectPath, this.projectPath);
|
|
3294
|
-
return res.json(this.deploymentStore.load()
|
|
3295
|
-
.filter((d) => d.type === 'webhook' && deploymentBelongsToProject(d, projectPath, this.projectPath))
|
|
3296
|
-
.map((d) => ({ ...d, inboundUrl: `${hubBase}/api/ai-hub/webhooks/${d.id}/inbound` })));
|
|
3297
|
-
}
|
|
3298
|
-
catch (err) {
|
|
3299
|
-
return res.status(400).json({ error: err instanceof Error ? err.message : 'Invalid project path.' });
|
|
3300
|
-
}
|
|
3301
|
-
});
|
|
3302
|
-
// DELETE /api/ai-hub/webhooks/:id — remove a webhook deployment.
|
|
3303
|
-
this.app.delete('/api/ai-hub/webhooks/:id', (req, res) => {
|
|
3304
|
-
const deleted = this.deploymentStore.delete(req.params.id);
|
|
3305
|
-
if (!deleted)
|
|
3306
|
-
return res.status(404).json({ error: 'Deployment not found.' });
|
|
3307
|
-
return res.json({ ok: true });
|
|
3308
|
-
});
|
|
3309
|
-
// PUT /api/ai-hub/webhooks/:id — update an existing webhook deployment.
|
|
3310
|
-
this.app.put('/api/ai-hub/webhooks/:id', (req, res) => {
|
|
3311
|
-
const { id } = req.params;
|
|
3312
|
-
const { label, jobId, projectPath, hostId, instructions, allowConcurrent } = req.body ?? {};
|
|
3313
|
-
let resolvedProjectPath;
|
|
3314
|
-
try {
|
|
3315
|
-
if (projectPath !== undefined)
|
|
3316
|
-
resolvedProjectPath = ensureDirectoryPath(projectPath || this.projectPath);
|
|
3317
|
-
}
|
|
3318
|
-
catch (err) {
|
|
3319
|
-
return res.status(400).json({ error: err instanceof Error ? err.message : 'Invalid project path.' });
|
|
3320
|
-
}
|
|
3321
|
-
const validEmployees = VALID_EMPLOYEE_IDS;
|
|
3322
|
-
let updated = null;
|
|
3323
|
-
const ok = this.deploymentStore.update(id, (dep) => {
|
|
3324
|
-
if (label !== undefined)
|
|
3325
|
-
dep.label = label;
|
|
3326
|
-
if (jobId !== undefined)
|
|
3327
|
-
dep.jobId = jobId;
|
|
3328
|
-
if (resolvedProjectPath !== undefined)
|
|
3329
|
-
dep.projectPath = resolvedProjectPath;
|
|
3330
|
-
if (hostId !== undefined && validEmployees.includes(hostId))
|
|
3331
|
-
dep.hostId = hostId;
|
|
3332
|
-
if (instructions !== undefined)
|
|
3333
|
-
dep.instructions = typeof instructions === 'string' ? instructions : undefined;
|
|
3334
|
-
if (allowConcurrent !== undefined)
|
|
3335
|
-
dep.allowConcurrent = allowConcurrent === true;
|
|
3336
|
-
updated = dep;
|
|
3337
|
-
});
|
|
3338
|
-
if (!ok || !updated)
|
|
3339
|
-
return res.status(404).json({ error: 'Deployment not found.' });
|
|
3340
|
-
return res.json({ ...updated, inboundUrl: `${this.hubBase}/api/ai-hub/webhooks/${id}/inbound` });
|
|
3341
|
-
});
|
|
3342
|
-
// POST /api/ai-hub/webhooks/:id/inbound — webhook inbound trigger from external systems.
|
|
3343
|
-
this.app.post('/api/ai-hub/webhooks/:id/inbound', async (req, res) => {
|
|
3344
|
-
const deployments = this.deploymentStore.load();
|
|
3345
|
-
const deployment = deployments.find((d) => d.id === req.params.id && d.type === 'webhook' && d.active);
|
|
3346
|
-
if (!deployment) {
|
|
3347
|
-
return res.status(404).json({ error: 'Webhook not found or inactive.' });
|
|
3348
|
-
}
|
|
3349
|
-
try {
|
|
3350
|
-
const run = await this.fireDeploymentRun(deployment, req.body);
|
|
3351
|
-
return res.status(202).json({ runId: run.id, status: run.status });
|
|
3352
|
-
}
|
|
3353
|
-
catch (err) {
|
|
3354
|
-
const msg = err instanceof Error ? err.message : 'Failed to start run.';
|
|
3355
|
-
return res.status(500).json({ error: msg });
|
|
3356
|
-
}
|
|
3357
|
-
});
|
|
3358
|
-
// GET /api/ai-hub/hosts — list registered remote hosts with health status.
|
|
3359
|
-
this.app.get('/api/ai-hub/hosts', async (_req, res) => {
|
|
3360
|
-
const hosts = this.hostConfigStore.load();
|
|
3361
|
-
const healthResults = await Promise.allSettled(hosts.map((h) => pingHost(h)));
|
|
3362
|
-
const health = healthResults.map((r, i) => r.status === 'fulfilled'
|
|
3363
|
-
? r.value
|
|
3364
|
-
: { id: hosts[i].id, label: hosts[i].label, url: hosts[i].url, status: 'offline', latencyMs: null, lastPingAt: new Date().toISOString() });
|
|
3365
|
-
return res.json(health);
|
|
3366
|
-
});
|
|
3367
|
-
// POST /api/ai-hub/hosts — register a named remote hub host.
|
|
3368
|
-
this.app.post('/api/ai-hub/hosts', (req, res) => {
|
|
3369
|
-
const { label, url, authToken } = req.body ?? {};
|
|
3370
|
-
const validUrl = safeHttpUrl(url);
|
|
3371
|
-
if (!label || !validUrl) {
|
|
3372
|
-
return res.status(400).json({ error: 'label and a valid http(s) url are required.' });
|
|
3373
|
-
}
|
|
3374
|
-
const host = {
|
|
3375
|
-
id: (0, crypto_1.randomUUID)(),
|
|
3376
|
-
label,
|
|
3377
|
-
url: validUrl,
|
|
3378
|
-
authToken: typeof authToken === 'string' && authToken ? authToken : undefined,
|
|
3379
|
-
createdAt: new Date().toISOString(),
|
|
3380
|
-
};
|
|
3381
|
-
this.hostConfigStore.add(host);
|
|
3382
|
-
return res.status(201).json({ id: host.id, label: host.label, url: host.url, createdAt: host.createdAt });
|
|
3383
|
-
});
|
|
3384
|
-
// DELETE /api/ai-hub/hosts/:id — remove a named remote host.
|
|
3385
|
-
this.app.delete('/api/ai-hub/hosts/:id', (req, res) => {
|
|
3386
|
-
const deleted = this.hostConfigStore.delete(req.params.id);
|
|
3387
|
-
if (!deleted)
|
|
3388
|
-
return res.status(404).json({ error: 'Host not found.' });
|
|
3389
|
-
return res.json({ ok: true });
|
|
3390
|
-
});
|
|
3391
|
-
// GET /api/ai-hub/hosts/:id/health — ping a single host.
|
|
3392
|
-
this.app.get('/api/ai-hub/hosts/:id/health', async (req, res) => {
|
|
3393
|
-
const host = this.hostConfigStore.load().find((h) => h.id === req.params.id);
|
|
3394
|
-
if (!host)
|
|
3395
|
-
return res.status(404).json({ error: 'Host not found.' });
|
|
3396
|
-
const health = await pingHost(host);
|
|
3397
|
-
return res.json(health);
|
|
3398
|
-
});
|
|
3399
|
-
// ─── End Issue #578 ───────────────────────────────────────────────────────
|
|
3400
|
-
// -------------------------------------------------------------------------
|
|
3401
|
-
// Issue #489: POST /api/trigger
|
|
3402
|
-
// Stable API endpoint for extension surfaces (Office add-ins, browser
|
|
3403
|
-
// extensions, VS Code extensions, Electron tray) to start FRAIM jobs.
|
|
3404
|
-
//
|
|
3405
|
-
// Body: { employeeId, jobName, context?: { text, sourceApp, fileName, ... }, projectPath? }
|
|
3406
|
-
// Response: { runId, status: "started", employee, job }
|
|
3407
|
-
// -------------------------------------------------------------------------
|
|
3408
|
-
this.app.post('/api/trigger', (req, res) => {
|
|
3409
|
-
try {
|
|
3410
|
-
const { employeeId, jobName, context, projectPath: reqProjectPath } = req.body;
|
|
3411
|
-
if (!employeeId) {
|
|
3412
|
-
return res.status(400).json({ error: 'employeeId is required.' });
|
|
3413
|
-
}
|
|
3414
|
-
if (!jobName) {
|
|
3415
|
-
return res.status(400).json({ error: 'jobName is required.' });
|
|
3416
|
-
}
|
|
3417
|
-
const projectPath = ensureDirectoryPath(reqProjectPath || this.projectPath);
|
|
3418
|
-
// Use the requested agent directly — caller specifies which agent to run (claude, codex, gemini).
|
|
3419
|
-
const employees = this.hostRuntime.detectEmployees();
|
|
3420
|
-
const hostId = employeeId;
|
|
3421
|
-
const employee = employees.find(e => e.id === hostId);
|
|
3422
|
-
if (!employee?.available) {
|
|
3423
|
-
return res.status(503).json({ error: `${hostId} is not available on this machine.` });
|
|
3424
|
-
}
|
|
3425
|
-
const contextText = context?.text?.trim() || '';
|
|
3426
|
-
const sourceInfo = [
|
|
3427
|
-
context?.sourceApp ? `sourceApp: ${context.sourceApp}` : '',
|
|
3428
|
-
context?.fileName ? `file: ${context.fileName}` : '',
|
|
3429
|
-
].filter(Boolean).join(', ');
|
|
3430
|
-
const message = [
|
|
3431
|
-
`/fraim ${jobName}`,
|
|
3432
|
-
'',
|
|
3433
|
-
sourceInfo ? `Context (${sourceInfo}):` : 'Context:',
|
|
3434
|
-
contextText || '(no context provided)',
|
|
3435
|
-
].join('\n');
|
|
3436
|
-
const startTimestamp = new Date().toISOString();
|
|
3437
|
-
const run = {
|
|
3438
|
-
id: (0, crypto_1.randomUUID)(),
|
|
3439
|
-
jobId: jobName,
|
|
3440
|
-
hostId,
|
|
3441
|
-
projectPath,
|
|
3442
|
-
status: 'running',
|
|
3443
|
-
createdAt: startTimestamp,
|
|
3444
|
-
updatedAt: startTimestamp,
|
|
3445
|
-
messages: [(0, hosts_1.createHubMessage)('manager', message)],
|
|
3446
|
-
events: [(0, hosts_1.createHubEvent)('system', `Trigger: ${employeeId} / ${jobName} from ${context?.sourceApp || 'unknown'} in ${projectPath}`)],
|
|
3447
|
-
currentPhase: null,
|
|
3448
|
-
phaseHistory: [],
|
|
3449
|
-
totals: emptyTotals(),
|
|
3450
|
-
lastStatusChangeAt: startTimestamp,
|
|
3451
|
-
personaKey: getProtectedPersonaForHubJob(jobName),
|
|
3452
|
-
};
|
|
3453
|
-
// Register the run before spawning so onEvent/onExit callbacks can
|
|
3454
|
-
// safely call update() even if they fire synchronously (FakeHostRuntime).
|
|
3455
|
-
this.runRegistry.create(run, {});
|
|
3456
|
-
const child = this.hostRuntime.startRun(hostId, projectPath, message, {
|
|
3457
|
-
onEvent: (event, channel) => {
|
|
3458
|
-
this.runRegistry.update(run.id, (current) => {
|
|
3459
|
-
if (event.sessionId)
|
|
3460
|
-
current.sessionId = event.sessionId;
|
|
3461
|
-
appendHostMessage(current, hostId, event, channel);
|
|
3462
|
-
if (event.raw)
|
|
3463
|
-
current.events.push((0, hosts_1.createHubEvent)(channel, event.raw));
|
|
3464
|
-
if (event.agentIdentity)
|
|
3465
|
-
applyAgentIdentitySignal(current, event.agentIdentity);
|
|
3466
|
-
if (event.fraimJob)
|
|
3467
|
-
this.applyFraimJobSignalToRun(current, event.fraimJob);
|
|
3468
|
-
if (event.seekMentoring)
|
|
3469
|
-
this.applySeekMentoringSignalToRun(current, event.seekMentoring);
|
|
3470
|
-
if (event.usage)
|
|
3471
|
-
applyUsageSignal(current, event.usage);
|
|
3472
|
-
});
|
|
3473
|
-
},
|
|
3474
|
-
onExit: (exitCode) => {
|
|
3475
|
-
this.runRegistry.update(run.id, (current) => {
|
|
3476
|
-
current.exitCode = exitCode;
|
|
3477
|
-
current.status = exitCode === 0 ? 'completed' : 'failed';
|
|
3478
|
-
current.events.push((0, hosts_1.createHubEvent)('system', `Trigger run exited with code ${exitCode ?? 'unknown'}.`));
|
|
3479
|
-
});
|
|
3480
|
-
this.runRegistry.dispose(run.id);
|
|
3481
|
-
},
|
|
3482
|
-
}, startSessionSeedForHost(hostId, run.id));
|
|
3483
|
-
// Update the registry entry with the real child process handle.
|
|
3484
|
-
this.runRegistry.attachChildIfRunning(run.id, child);
|
|
3485
|
-
return res.json({ runId: run.id, status: 'started', employee: employeeId, job: jobName });
|
|
3486
|
-
}
|
|
3487
|
-
catch (error) {
|
|
3488
|
-
return res.status(400).json({ error: error instanceof Error ? error.message : 'Could not start run.' });
|
|
3489
|
-
}
|
|
3490
|
-
});
|
|
3491
|
-
}
|
|
3492
|
-
// #521: start (or reuse) the shared browser and publish its CDP endpoint so
|
|
3493
|
-
// every agent spawned afterward inherits FRAIM_BROWSER_CDP_ENDPOINT and its
|
|
3494
|
-
// browser-use skill connects to the long-lived window instead of a throwaway.
|
|
3495
|
-
async ensureManagedBrowser() {
|
|
3496
|
-
const result = await this.managedBrowser.start();
|
|
3497
|
-
process.env.FRAIM_BROWSER_CDP_ENDPOINT = result.endpoint;
|
|
3498
|
-
return { endpoint: result.endpoint, reused: result.reused, channel: result.channel };
|
|
3499
|
-
}
|
|
3500
|
-
// ─── Issue #578: Scheduled deployment helpers ─────────────────────────────
|
|
3501
|
-
rehydrateScheduledDeployments() {
|
|
3502
|
-
const active = this.deploymentStore.load().filter((d) => d.type === 'scheduled' && d.active);
|
|
3503
|
-
for (const dep of active) {
|
|
3504
|
-
this.scheduleDeployment(dep);
|
|
3505
|
-
}
|
|
3506
|
-
if (active.length > 0) {
|
|
3507
|
-
console.log(`[ai-hub] rehydrated ${active.length} scheduled deployment(s)`);
|
|
3508
|
-
}
|
|
3509
|
-
}
|
|
3510
|
-
scheduleDeployment(deployment) {
|
|
3511
|
-
if (!deployment.cronExpr)
|
|
3512
|
-
return;
|
|
3513
|
-
try {
|
|
3514
|
-
// eslint-disable-next-line @typescript-eslint/no-var-requires
|
|
3515
|
-
const cron = require('node-cron');
|
|
3516
|
-
if (!cron.validate(deployment.cronExpr)) {
|
|
3517
|
-
console.warn(`[ai-hub] invalid cronExpr for deployment ${deployment.id}: ${deployment.cronExpr}`);
|
|
3518
|
-
return;
|
|
3519
|
-
}
|
|
3520
|
-
const task = cron.schedule(deployment.cronExpr, async () => {
|
|
3521
|
-
try {
|
|
3522
|
-
await this.fireDeploymentRun(deployment);
|
|
3523
|
-
}
|
|
3524
|
-
catch (err) {
|
|
3525
|
-
console.warn(`[ai-hub] scheduled deployment ${deployment.id} fire failed:`, err);
|
|
3526
|
-
}
|
|
3527
|
-
});
|
|
3528
|
-
this.cronHandles.set(deployment.id, task);
|
|
3529
|
-
}
|
|
3530
|
-
catch (err) {
|
|
3531
|
-
console.warn('[ai-hub] node-cron not available — scheduled deployments require node-cron:', err);
|
|
3532
|
-
}
|
|
3533
|
-
}
|
|
3534
|
-
async fireDeploymentRun(deployment, webhookBody) {
|
|
3535
|
-
// Overlapping run guard: if the prior run is still active and allowConcurrent is false, skip.
|
|
3536
|
-
if (!deployment.allowConcurrent && deployment.activeRunId) {
|
|
3537
|
-
const active = this.runRegistry.get(deployment.activeRunId);
|
|
3538
|
-
if (active && active.status === 'running') {
|
|
3539
|
-
console.log(`[ai-hub] deployment ${deployment.id} skipped — prior run ${deployment.activeRunId} still running`);
|
|
3540
|
-
return active;
|
|
3541
|
-
}
|
|
3542
|
-
}
|
|
3543
|
-
const employees = this.hostRuntime.detectEmployees();
|
|
3544
|
-
const employee = employees.find((e) => e.id === deployment.hostId);
|
|
3545
|
-
if (!employee?.available) {
|
|
3546
|
-
throw new Error(`Employee ${deployment.hostId} is not available for scheduled/webhook run.`);
|
|
3547
|
-
}
|
|
3548
|
-
const instructions = [
|
|
3549
|
-
deployment.instructions ?? `/fraim ${deployment.jobId}`,
|
|
3550
|
-
webhookBody ? `\n\nInbound payload:\n${JSON.stringify(webhookBody, null, 2)}` : '',
|
|
3551
|
-
].join('').trim();
|
|
3552
|
-
const startTimestamp = new Date().toISOString();
|
|
3553
|
-
const run = {
|
|
3554
|
-
id: (0, crypto_1.randomUUID)(),
|
|
3555
|
-
jobId: deployment.jobId,
|
|
3556
|
-
hostId: deployment.hostId,
|
|
3557
|
-
projectPath: deployment.projectPath,
|
|
3558
|
-
status: 'running',
|
|
3559
|
-
sourceTrigger: deployment.type === 'scheduled' ? 'scheduled' : 'webhook',
|
|
3560
|
-
createdAt: startTimestamp,
|
|
3561
|
-
updatedAt: startTimestamp,
|
|
3562
|
-
messages: [(0, hosts_1.createHubMessage)('manager', instructions)],
|
|
3563
|
-
events: [(0, hosts_1.createHubEvent)('system', `Triggered by deployment: ${deployment.label} (${deployment.type})`)],
|
|
3564
|
-
currentPhase: null,
|
|
3565
|
-
phaseHistory: [],
|
|
3566
|
-
totals: emptyTotals(),
|
|
3567
|
-
lastStatusChangeAt: startTimestamp,
|
|
3568
|
-
// Deployment runs group under the selected employee (hostId), not the job's
|
|
3569
|
-
// protected persona. Leaving personaKey unset lets conversationRecordFromRun
|
|
3570
|
-
// fall through to agentName (= hostId), which is what the rail groups by.
|
|
3571
|
-
personaKey: null,
|
|
3572
|
-
};
|
|
3573
|
-
// Pre-register before startRun so synchronous onEvent calls (e.g. FakeHostRuntime)
|
|
3574
|
-
// can call runRegistry.update without "Run not found" throws.
|
|
3575
|
-
this.runRegistry.create(run, {});
|
|
3576
|
-
const child = this.hostRuntime.startRun(deployment.hostId, deployment.projectPath, instructions, {
|
|
3577
|
-
onEvent: (event, channel) => {
|
|
3578
|
-
this.runRegistry.update(run.id, (current) => {
|
|
3579
|
-
if (event.sessionId)
|
|
3580
|
-
current.sessionId = event.sessionId;
|
|
3581
|
-
appendHostMessage(current, deployment.hostId, event, channel);
|
|
3582
|
-
if (event.raw) {
|
|
3583
|
-
current.events.push((0, hosts_1.createHubEvent)(channel, event.raw));
|
|
3584
|
-
applyReviewProjection(current, event.raw);
|
|
3585
|
-
}
|
|
3586
|
-
if (event.agentIdentity)
|
|
3587
|
-
applyAgentIdentitySignal(current, event.agentIdentity);
|
|
3588
|
-
if (event.fraimJob)
|
|
3589
|
-
this.applyFraimJobSignalToRun(current, event.fraimJob);
|
|
3590
|
-
if (event.seekMentoring)
|
|
3591
|
-
this.applySeekMentoringSignalToRun(current, event.seekMentoring);
|
|
3592
|
-
if (event.usage)
|
|
3593
|
-
applyUsageSignal(current, event.usage);
|
|
3594
|
-
});
|
|
3595
|
-
const updated = this.runRegistry.get(run.id);
|
|
3596
|
-
if (updated)
|
|
3597
|
-
this.persistRunConversation(updated, updated.conversationId || updated.id);
|
|
3598
|
-
},
|
|
3599
|
-
onExit: (exitCode) => {
|
|
3600
|
-
this.runRegistry.update(run.id, (r) => {
|
|
3601
|
-
r.exitCode = exitCode;
|
|
3602
|
-
r.status = exitCode === 0 ? 'completed' : 'failed';
|
|
3603
|
-
r.events.push((0, hosts_1.createHubEvent)('system', `Run exited with code ${exitCode ?? 'unknown'}.`));
|
|
3604
|
-
});
|
|
3605
|
-
const updated = this.runRegistry.get(run.id);
|
|
3606
|
-
if (updated)
|
|
3607
|
-
this.persistRunConversation(updated, updated.conversationId || updated.id);
|
|
3608
|
-
this.deploymentStore.update(deployment.id, (d) => { d.activeRunId = undefined; });
|
|
3609
|
-
this.runRegistry.dispose(run.id);
|
|
3610
|
-
},
|
|
3611
|
-
}, startSessionSeedForHost(deployment.hostId, run.id));
|
|
3612
|
-
this.runRegistry.create(run, child);
|
|
3613
|
-
this.deploymentStore.update(deployment.id, (d) => { d.activeRunId = run.id; });
|
|
3614
|
-
return run;
|
|
3615
|
-
}
|
|
3616
|
-
// ─── End Issue #578 helpers ───────────────────────────────────────────────
|
|
3617
|
-
// Issue #347 — assemble the read-side projection of a run. Stages are
|
|
3618
|
-
// derived from job frontmatter + visited phases; totalDurationMs ticks
|
|
3619
|
-
// forward while the run is still running so the UI's totals line
|
|
3620
|
-
// updates each poll without the server having to rewrite the run on
|
|
3621
|
-
// every tick.
|
|
3622
|
-
enrichRunForResponse(run) {
|
|
3623
|
-
const stages = deriveStages(run, run.projectPath);
|
|
3624
|
-
const now = Date.now();
|
|
3625
|
-
const created = Date.parse(run.createdAt);
|
|
3626
|
-
const updated = Date.parse(run.updatedAt);
|
|
3627
|
-
const liveTotalMs = run.status === 'running'
|
|
3628
|
-
? Math.max(0, now - created)
|
|
3629
|
-
: Math.max(0, updated - created);
|
|
3630
|
-
const baseTotals = run.totals || emptyTotals();
|
|
3631
|
-
// While the run is still in its current status (not yet flipped), the
|
|
3632
|
-
// current bucket needs to include the in-flight delta since the last
|
|
3633
|
-
// flip. Compute it here so the client sees a smoothly increasing
|
|
3634
|
-
// working/waiting figure instead of frozen counters between flips.
|
|
3635
|
-
const lastFlipMs = run.lastStatusChangeAt
|
|
3636
|
-
? Date.parse(run.lastStatusChangeAt)
|
|
3637
|
-
: created;
|
|
3638
|
-
const inflightMs = Math.max(0, now - lastFlipMs);
|
|
3639
|
-
const liveTotals = {
|
|
3640
|
-
totalDurationMs: liveTotalMs,
|
|
3641
|
-
workingDurationMs: baseTotals.workingDurationMs + (run.status === 'running' ? inflightMs : 0),
|
|
3642
|
-
waitingDurationMs: baseTotals.waitingDurationMs + (run.status !== 'running' ? inflightMs : 0),
|
|
3643
|
-
tokenTotals: baseTotals.tokenTotals,
|
|
3644
|
-
};
|
|
3645
|
-
// Defensive cap: working + waiting should never exceed total. Trim
|
|
3646
|
-
// the trailing bucket if rounding pushes us past.
|
|
3647
|
-
const sum = liveTotals.workingDurationMs + liveTotals.waitingDurationMs;
|
|
3648
|
-
if (sum > liveTotalMs) {
|
|
3649
|
-
const overflow = sum - liveTotalMs;
|
|
3650
|
-
if (run.status === 'running') {
|
|
3651
|
-
liveTotals.workingDurationMs = Math.max(0, liveTotals.workingDurationMs - overflow);
|
|
3652
|
-
}
|
|
3653
|
-
else {
|
|
3654
|
-
liveTotals.waitingDurationMs = Math.max(0, liveTotals.waitingDurationMs - overflow);
|
|
3655
|
-
}
|
|
3656
|
-
}
|
|
3657
|
-
return { ...run, stages, totals: liveTotals, artifacts: run.artifacts || [] };
|
|
3658
|
-
}
|
|
3659
|
-
}
|
|
3660
|
-
exports.AiHubServer = AiHubServer;
|
|
3661
|
-
async function findAvailablePort(preferredPort) {
|
|
3662
|
-
return findAvailablePortExcluding(preferredPort, new Set());
|
|
3663
|
-
}
|
|
3664
|
-
async function findAvailablePortExcluding(preferredPort, excludedPorts) {
|
|
3665
|
-
let port = preferredPort;
|
|
3666
|
-
while (port < preferredPort + 20) {
|
|
3667
|
-
if (excludedPorts.has(port)) {
|
|
3668
|
-
port += 1;
|
|
3669
|
-
continue;
|
|
3670
|
-
}
|
|
3671
|
-
const available = await new Promise((resolve) => {
|
|
3672
|
-
const server = net_1.default.createServer();
|
|
3673
|
-
server.once('error', () => resolve(false));
|
|
3674
|
-
server.once('listening', () => {
|
|
3675
|
-
server.close(() => resolve(true));
|
|
3676
|
-
});
|
|
3677
|
-
server.listen(port, '127.0.0.1');
|
|
3678
|
-
});
|
|
3679
|
-
if (available)
|
|
3680
|
-
return port;
|
|
3681
|
-
port += 1;
|
|
3682
|
-
}
|
|
3683
|
-
throw new Error(`No free loopback port found near ${preferredPort}.`);
|
|
3684
|
-
}
|
|
3685
|
-
function resolveAiHubPublicDir() {
|
|
3686
|
-
const candidates = [
|
|
3687
|
-
path_1.default.resolve(process.cwd(), 'public/ai-hub'),
|
|
3688
|
-
path_1.default.resolve(__dirname, '..', '..', 'public/ai-hub'),
|
|
3689
|
-
path_1.default.resolve(__dirname, '..', '..', '..', 'public/ai-hub'),
|
|
3690
|
-
];
|
|
3691
|
-
for (const candidate of candidates) {
|
|
3692
|
-
if (fs_1.default.existsSync(candidate)) {
|
|
3693
|
-
return candidate;
|
|
3694
|
-
}
|
|
3695
|
-
}
|
|
3696
|
-
throw new Error('Could not locate public/ai-hub assets.');
|
|
3697
|
-
}
|
|
3698
|
-
// Issue #489: Resolve the word taskpane static assets directory.
|
|
3699
|
-
// Returns null (not throws) when the directory does not exist so the Hub
|
|
3700
|
-
// can start without the Word add-in assets present (e.g., older installs).
|
|
3701
|
-
function resolveWordTaskpaneDir(projectPath) {
|
|
3702
|
-
const base = projectPath || process.cwd();
|
|
3703
|
-
const candidates = [
|
|
3704
|
-
path_1.default.resolve(base, 'extensions/office-word'),
|
|
3705
|
-
path_1.default.resolve(__dirname, '..', '..', 'extensions/office-word'),
|
|
3706
|
-
path_1.default.resolve(__dirname, '..', '..', '..', 'extensions/office-word'),
|
|
3707
|
-
];
|
|
3708
|
-
for (const candidate of candidates) {
|
|
3709
|
-
if (fs_1.default.existsSync(candidate)) {
|
|
3710
|
-
return candidate;
|
|
3711
|
-
}
|
|
3712
|
-
}
|
|
3713
|
-
// Word add-in assets not found — /word-taskpane/* routes will not be registered.
|
|
3714
|
-
return null;
|
|
3715
|
-
}
|
|
3716
|
-
// Issue #478: resolve the directory that contains a named Office task pane.
|
|
3717
|
-
// Example: resolveTaskpaneDir('powerpoint-taskpane') → <repo>/public/ai-hub/powerpoint-taskpane
|
|
3718
|
-
function resolveTaskpaneDir(pane) {
|
|
3719
|
-
const aiHubDir = resolveAiHubPublicDir();
|
|
3720
|
-
const taskpaneDir = path_1.default.join(aiHubDir, pane);
|
|
3721
|
-
if (!fs_1.default.existsSync(taskpaneDir)) {
|
|
3722
|
-
throw new Error(`Task pane directory not found: ${taskpaneDir}`);
|
|
3723
|
-
}
|
|
3724
|
-
return taskpaneDir;
|
|
3725
|
-
}
|
|
3726
|
-
/**
|
|
3727
|
-
* Open the OS-native "choose a folder" dialog and resolve to the selected
|
|
3728
|
-
* absolute path, or null if the user cancelled / no dialog was available.
|
|
3729
|
-
*
|
|
3730
|
-
* This is the genuinely native picker: it spawns the platform's own folder
|
|
3731
|
-
* chooser (WinForms FolderBrowserDialog on Windows, `choose folder` on macOS,
|
|
3732
|
-
* zenity/kdialog on Linux). The Hub runs on loopback on the user's machine, so
|
|
3733
|
-
* it can launch the real dialog itself.
|
|
3734
|
-
*
|
|
3735
|
-
* Implementation notes (kept in lockstep with src/first-run/server.ts, which
|
|
3736
|
-
* proved this out — see the long-form comment there):
|
|
3737
|
-
* - ASYNC (`spawn`, not `spawnSync`). The dialog blocks until the user
|
|
3738
|
-
* dismisses it, which can be many seconds. With `spawnSync` the entire Node
|
|
3739
|
-
* event loop freezes for that whole time — every other Hub HTTP request
|
|
3740
|
-
* (bootstrap, run polling) stalls and the Hub looks dead. `spawn` keeps the
|
|
3741
|
-
* server responsive while the dialog is up.
|
|
3742
|
-
* - Windows needs `-STA` (Single-Threaded Apartment) for FolderBrowserDialog
|
|
3743
|
-
* to work reliably, plus a hidden TopMost `$owner` form so the picker comes
|
|
3744
|
-
* to the foreground instead of opening behind the browser window.
|
|
3745
|
-
* - A hard timeout guarantees the endpoint can never hang forever: if nothing
|
|
3746
|
-
* is selected within the window (or the spawn wedges) we resolve null and
|
|
3747
|
-
* the caller degrades to "no change".
|
|
3748
|
-
* - NON-INTERACTIVE GUARD: a real OS folder dialog blocks waiting for a human.
|
|
3749
|
-
* In CI / automated UI tests (which DO reach this live endpoint) that would
|
|
3750
|
-
* hang. When AI_HUB_DISABLE_NATIVE_PICKER is set (or NODE_ENV=test) we skip
|
|
3751
|
-
* the dialog entirely and resolve null, so the endpoint returns 204 without
|
|
3752
|
-
* ever popping a window. Real desktop/loopback usage leaves it unset and
|
|
3753
|
-
* gets the genuine native dialog.
|
|
3754
|
-
*/
|
|
3755
|
-
function pickProjectPath() {
|
|
3756
|
-
if (process.env.AI_HUB_DISABLE_NATIVE_PICKER === '1' ||
|
|
3757
|
-
process.env.NODE_ENV === 'test') {
|
|
3758
|
-
return Promise.resolve(null);
|
|
3759
|
-
}
|
|
3760
|
-
if (process.platform === 'win32') {
|
|
3761
|
-
const script = [
|
|
3762
|
-
'Add-Type -AssemblyName System.Windows.Forms',
|
|
3763
|
-
'$dialog = New-Object System.Windows.Forms.FolderBrowserDialog',
|
|
3764
|
-
'$dialog.Description = "Select a FRAIM project folder"',
|
|
3765
|
-
'$dialog.ShowNewFolderButton = $true',
|
|
3766
|
-
// Hidden owner form forces the dialog above the user's browser. Without
|
|
3767
|
-
// this the dialog often appears behind the browser tab and the user sees
|
|
3768
|
-
// nothing happen when they click the folder button.
|
|
3769
|
-
'$owner = New-Object System.Windows.Forms.Form',
|
|
3770
|
-
'$owner.TopMost = $true',
|
|
3771
|
-
'$owner.ShowInTaskbar = $false',
|
|
3772
|
-
'if ($dialog.ShowDialog($owner) -eq [System.Windows.Forms.DialogResult]::OK) {',
|
|
3773
|
-
' Write-Output $dialog.SelectedPath',
|
|
3774
|
-
'}',
|
|
3775
|
-
'$owner.Dispose()',
|
|
3776
|
-
].join('; ');
|
|
3777
|
-
return runPickerProcess('powershell', ['-NoProfile', '-STA', '-Command', script]);
|
|
3778
|
-
}
|
|
3779
|
-
if (process.platform === 'darwin') {
|
|
3780
|
-
return runPickerProcess('osascript', ['-e', 'POSIX path of (choose folder with prompt "Select a FRAIM project folder")']);
|
|
3781
|
-
}
|
|
3782
|
-
return runPickerProcess('bash', ['-lc', 'zenity --file-selection --directory 2>/dev/null || kdialog --getexistingdirectory 2>/dev/null']);
|
|
3783
|
-
}
|
|
3784
|
-
/**
|
|
3785
|
-
* Spawn a native folder-dialog process and resolve to its trimmed stdout (the
|
|
3786
|
-
* chosen path) or null. Hardened so it can never hang the server: any spawn
|
|
3787
|
-
* error resolves null, and a hard timeout kills a wedged child and resolves
|
|
3788
|
-
* null. The timeout is generous (the user may take a while to browse) but
|
|
3789
|
-
* finite.
|
|
3790
|
-
*/
|
|
3791
|
-
function runPickerProcess(command, args, timeoutMs = 5 * 60_000) {
|
|
3792
|
-
return new Promise((resolve) => {
|
|
3793
|
-
let settled = false;
|
|
3794
|
-
const finish = (value) => {
|
|
3795
|
-
if (settled)
|
|
3796
|
-
return;
|
|
3797
|
-
settled = true;
|
|
3798
|
-
clearTimeout(timer);
|
|
3799
|
-
resolve(value);
|
|
3800
|
-
};
|
|
3801
|
-
let proc;
|
|
3802
|
-
try {
|
|
3803
|
-
proc = (0, child_process_1.spawn)(command, args, {
|
|
3804
|
-
stdio: ['ignore', 'pipe', 'pipe'],
|
|
3805
|
-
windowsHide: true,
|
|
3806
|
-
});
|
|
3807
|
-
}
|
|
3808
|
-
catch {
|
|
3809
|
-
finish(null);
|
|
3810
|
-
return;
|
|
3811
|
-
}
|
|
3812
|
-
const timer = setTimeout(() => {
|
|
3813
|
-
try {
|
|
3814
|
-
proc.kill();
|
|
3815
|
-
}
|
|
3816
|
-
catch {
|
|
3817
|
-
/* ignore */
|
|
3818
|
-
}
|
|
3819
|
-
finish(null);
|
|
3820
|
-
}, timeoutMs);
|
|
3821
|
-
let stdout = '';
|
|
3822
|
-
proc.stdout?.on('data', (chunk) => { stdout += chunk.toString('utf8'); });
|
|
3823
|
-
proc.stderr?.on('data', () => { });
|
|
3824
|
-
proc.on('close', () => finish(stdout.trim() || null));
|
|
3825
|
-
proc.on('error', () => finish(null));
|
|
3826
|
-
});
|
|
3827
|
-
}
|