fraim-framework 2.0.195 → 2.0.196
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.
|
@@ -1971,59 +1971,41 @@ class AiHubServer {
|
|
|
1971
1971
|
}));
|
|
1972
1972
|
try {
|
|
1973
1973
|
// Issue #701: persona state comes from the hosted server (GET /api/personas/me)
|
|
1974
|
-
// via the user's API key — never a local MongoDB connection.
|
|
1975
|
-
// no/expired key or the feature is off; render the locked fallback (not-signed-in).
|
|
1974
|
+
// via the user's API key — never a local MongoDB connection.
|
|
1976
1975
|
const state = await this.remoteGateway.getPersonaState(apiKey);
|
|
1976
|
+
// A null result means we could not reach the authority (no/expired key or a
|
|
1977
|
+
// transport error). That is the ONLY access decision the Hub makes on its own —
|
|
1978
|
+
// render the locked "not-signed-in" fallback. When a state IS returned, its
|
|
1979
|
+
// per-persona `status` is authoritative and rendered verbatim: the hired/locked
|
|
1980
|
+
// decision (feature-off, legacy bypass, per-entitlement gating) lives solely in
|
|
1981
|
+
// persona-entitlement-service.resolvePersonaAccessStatuses — the Hub does not
|
|
1982
|
+
// re-derive it.
|
|
1977
1983
|
if (!state) {
|
|
1978
1984
|
return { personas: fallbackPersonas, subscriptionActive: false, workspaceId: null, userKey: null };
|
|
1979
1985
|
}
|
|
1980
|
-
|
|
1981
|
-
//
|
|
1982
|
-
//
|
|
1983
|
-
if (!state.subscriptionActive) {
|
|
1984
|
-
const allPersonas = allBundles.map((bundle) => ({
|
|
1985
|
-
key: bundle.personaKey,
|
|
1986
|
-
displayName: bundle.catalogMetadata.displayName,
|
|
1987
|
-
role: bundle.catalogMetadata.role,
|
|
1988
|
-
avatarUrl: buildHubPersonaAvatarUrl(bundle.personaKey),
|
|
1989
|
-
pricingLabel: '',
|
|
1990
|
-
status: 'hired',
|
|
1991
|
-
hireUrl: buildHubPersonaHireUrl(bundle.personaKey, bundle.defaultHireMode),
|
|
1992
|
-
seatCount: 0,
|
|
1993
|
-
seatsInUse: 0,
|
|
1994
|
-
}));
|
|
1995
|
-
return { personas: allPersonas, subscriptionActive: false, workspaceId: state.workspaceId, userKey: state.userId ?? null };
|
|
1996
|
-
}
|
|
1997
|
-
const hiredKeys = new Set(state.entitlements
|
|
1998
|
-
.filter((e) => e.status === 'active')
|
|
1999
|
-
.map((e) => e.personaKey));
|
|
2000
|
-
// Build per-persona seat counts from entitlement records.
|
|
2001
|
-
// jobCreditsRemaining represents the number of job-credits (seats) purchased.
|
|
2002
|
-
const seatCountByKey = {};
|
|
2003
|
-
for (const e of state.entitlements) {
|
|
2004
|
-
if (e.status === 'active') {
|
|
2005
|
-
seatCountByKey[e.personaKey] = (seatCountByKey[e.personaKey] ?? 0) + (e.jobCreditsRemaining ?? 1);
|
|
2006
|
-
}
|
|
2007
|
-
}
|
|
2008
|
-
// seatsInUse for display: derived from the hosted manager team. Authoritative
|
|
2009
|
-
// out-of-stock enforcement lives on the hosted assign endpoint (server-side),
|
|
2010
|
-
// so this display count does not need to be workspace-wide.
|
|
1986
|
+
const verdictByKey = new Map((state.personas || []).map((p) => [p.personaKey, p]));
|
|
1987
|
+
// seatsInUse (manager-team assignments) is display-only accounting fetched
|
|
1988
|
+
// separately; it is not part of the access verdict.
|
|
2011
1989
|
const seatsInUseByKey = {};
|
|
2012
1990
|
const team = await this.remoteGateway.listManagerTeam(apiKey);
|
|
2013
1991
|
for (const entry of team) {
|
|
2014
1992
|
seatsInUseByKey[entry.personaKey] = (seatsInUseByKey[entry.personaKey] ?? 0) + 1;
|
|
2015
1993
|
}
|
|
2016
|
-
const personas = allBundles.map((bundle) =>
|
|
2017
|
-
|
|
2018
|
-
|
|
2019
|
-
|
|
2020
|
-
|
|
2021
|
-
|
|
2022
|
-
|
|
2023
|
-
|
|
2024
|
-
|
|
2025
|
-
|
|
2026
|
-
|
|
1994
|
+
const personas = allBundles.map((bundle) => {
|
|
1995
|
+
const verdict = verdictByKey.get(bundle.personaKey);
|
|
1996
|
+
const status = (verdict?.status ?? 'locked');
|
|
1997
|
+
return {
|
|
1998
|
+
key: bundle.personaKey,
|
|
1999
|
+
displayName: bundle.catalogMetadata.displayName,
|
|
2000
|
+
role: bundle.catalogMetadata.role,
|
|
2001
|
+
avatarUrl: buildHubPersonaAvatarUrl(bundle.personaKey),
|
|
2002
|
+
pricingLabel: status === 'hired' ? '' : bundle.catalogMetadata.pricingLabel,
|
|
2003
|
+
status,
|
|
2004
|
+
hireUrl: buildHubPersonaHireUrl(bundle.personaKey, bundle.defaultHireMode),
|
|
2005
|
+
seatCount: verdict?.seatCount ?? 0,
|
|
2006
|
+
seatsInUse: seatsInUseByKey[bundle.personaKey] ?? 0,
|
|
2007
|
+
};
|
|
2008
|
+
});
|
|
2027
2009
|
return { personas, subscriptionActive: state.subscriptionActive, workspaceId: state.workspaceId, userKey: state.userId ?? null };
|
|
2028
2010
|
}
|
|
2029
2011
|
catch (err) {
|
|
@@ -10,13 +10,13 @@ async function getMyPersonas(req, res, dbService) {
|
|
|
10
10
|
res.status(401).json({ error: 'Authentication required' });
|
|
11
11
|
return;
|
|
12
12
|
}
|
|
13
|
+
// Feature OFF => the persona system isn't gating anything, so report the
|
|
14
|
+
// ungated state (every persona accessible) rather than a 404. This makes the
|
|
15
|
+
// authenticated persona view a single source of truth the Hub renders
|
|
16
|
+
// verbatim, and matches the execution gate (maybeBuildPersonaLockResponse),
|
|
17
|
+
// which also treats "flag off" as "allowed". No DB lookup needed.
|
|
13
18
|
if (!(0, feature_flags_1.isPersonaEntitlementsEnabled)()) {
|
|
14
|
-
res.
|
|
15
|
-
error: 'Not found',
|
|
16
|
-
featureFlags: {
|
|
17
|
-
personaEntitlements: false
|
|
18
|
-
}
|
|
19
|
-
});
|
|
19
|
+
res.json((0, persona_entitlement_service_1.buildUngatedPersonaWorkspaceState)(apiKeyData.userId, apiKeyData.key));
|
|
20
20
|
return;
|
|
21
21
|
}
|
|
22
22
|
const state = await (0, persona_entitlement_service_1.getWorkspacePersonaState)(dbService, apiKeyData.userId, apiKeyData.key);
|
|
@@ -1,5 +1,8 @@
|
|
|
1
1
|
"use strict";
|
|
2
2
|
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
exports.computeSubscriptionActive = computeSubscriptionActive;
|
|
4
|
+
exports.resolvePersonaAccessStatuses = resolvePersonaAccessStatuses;
|
|
5
|
+
exports.buildUngatedPersonaWorkspaceState = buildUngatedPersonaWorkspaceState;
|
|
3
6
|
exports.buildPersonaHireUrl = buildPersonaHireUrl;
|
|
4
7
|
exports.syncPersonaEntitlementPurchase = syncPersonaEntitlementPurchase;
|
|
5
8
|
exports.syncPersonaEntitlementFromCheckoutSession = syncPersonaEntitlementFromCheckoutSession;
|
|
@@ -9,6 +12,66 @@ const persona_hiring_1 = require("../config/persona-hiring");
|
|
|
9
12
|
const persona_capability_bundles_1 = require("../config/persona-capability-bundles");
|
|
10
13
|
const workspace_identity_1 = require("./workspace-identity");
|
|
11
14
|
const DEFAULT_JOB_CREDITS = 1;
|
|
15
|
+
// The one place the "is the persona system gating this workspace?" question is
|
|
16
|
+
// answered. A workspace is subscribed if it explicitly opted in (personaSystemActive)
|
|
17
|
+
// or has ever held an active entitlement. Shared by getWorkspacePersonaState and
|
|
18
|
+
// evaluatePersonaAccess so display and enforcement can never diverge.
|
|
19
|
+
function computeSubscriptionActive(apiKeyData, entitlements) {
|
|
20
|
+
return apiKeyData?.personaSystemActive === true || entitlements.some((e) => e.status === 'active');
|
|
21
|
+
}
|
|
22
|
+
// Single source of truth for per-persona access, mirroring evaluatePersonaAccess's
|
|
23
|
+
// gating rules exactly:
|
|
24
|
+
// - `ungated` (feature flag OFF, or legacy/never-subscribed workspace) => every
|
|
25
|
+
// persona is accessible ('hired').
|
|
26
|
+
// - otherwise => 'hired' iff the workspace holds an active entitlement for it.
|
|
27
|
+
// Pure and DB-free so it is trivially testable and callable from either code path.
|
|
28
|
+
function resolvePersonaAccessStatuses(ungated, entitlements) {
|
|
29
|
+
const activeByKey = new Map();
|
|
30
|
+
for (const entitlement of entitlements) {
|
|
31
|
+
if (entitlement.status !== 'active')
|
|
32
|
+
continue;
|
|
33
|
+
const list = activeByKey.get(entitlement.personaKey) || [];
|
|
34
|
+
list.push(entitlement);
|
|
35
|
+
activeByKey.set(entitlement.personaKey, list);
|
|
36
|
+
}
|
|
37
|
+
return (0, persona_capability_bundles_1.listPersonaCapabilityBundles)().map((bundle) => {
|
|
38
|
+
const active = activeByKey.get(bundle.personaKey) || [];
|
|
39
|
+
const seatCount = active.reduce((sum, e) => sum + (e.jobCreditsRemaining ?? 1), 0);
|
|
40
|
+
return {
|
|
41
|
+
personaKey: bundle.personaKey,
|
|
42
|
+
personaName: bundle.catalogMetadata.displayName,
|
|
43
|
+
personaRole: bundle.catalogMetadata.role,
|
|
44
|
+
hireUrl: buildPersonaHireUrl(bundle.personaKey, bundle.defaultHireMode),
|
|
45
|
+
status: (ungated || active.length > 0) ? 'hired' : 'locked',
|
|
46
|
+
seatCount,
|
|
47
|
+
};
|
|
48
|
+
});
|
|
49
|
+
}
|
|
50
|
+
// The persona-entitlements feature is OFF: there is no gating, so every persona is
|
|
51
|
+
// accessible. Returned by /api/personas/me instead of a 404 so downstream consumers
|
|
52
|
+
// (the Hub especially) inherit "flag off => all employees available" rather than
|
|
53
|
+
// inventing their own fallback. Matches maybeBuildPersonaLockResponse, which also
|
|
54
|
+
// short-circuits to "allowed" when the flag is off. No DB lookup is required.
|
|
55
|
+
function buildUngatedPersonaWorkspaceState(userId, apiKey) {
|
|
56
|
+
return {
|
|
57
|
+
featureFlagEnabled: false,
|
|
58
|
+
featureFlags: { personaEntitlementsEnabled: false },
|
|
59
|
+
subscriptionActive: false,
|
|
60
|
+
workspaceId: normalizeEmail(userId),
|
|
61
|
+
userId: normalizeEmail(userId),
|
|
62
|
+
apiKey: apiKey || null,
|
|
63
|
+
entitlements: [],
|
|
64
|
+
catalog: (0, persona_capability_bundles_1.listPersonaCapabilityBundles)().map((bundle) => ({
|
|
65
|
+
personaKey: bundle.personaKey,
|
|
66
|
+
personaName: bundle.catalogMetadata.displayName,
|
|
67
|
+
personaRole: bundle.catalogMetadata.role,
|
|
68
|
+
hireUrl: buildPersonaHireUrl(bundle.personaKey, bundle.defaultHireMode),
|
|
69
|
+
protectedJobs: bundle.protectedJobs,
|
|
70
|
+
})),
|
|
71
|
+
lockedPersonas: [],
|
|
72
|
+
personas: resolvePersonaAccessStatuses(true, []),
|
|
73
|
+
};
|
|
74
|
+
}
|
|
12
75
|
function normalizeEmail(email) {
|
|
13
76
|
return email.trim().toLowerCase();
|
|
14
77
|
}
|
|
@@ -154,9 +217,7 @@ async function getWorkspacePersonaState(dbService, userId, apiKey) {
|
|
|
154
217
|
protectedJobs: bundle.protectedJobs
|
|
155
218
|
}));
|
|
156
219
|
const knownPersonaKeys = new Set(entitlements.map((entitlement) => entitlement.personaKey));
|
|
157
|
-
|
|
158
|
-
const subscriptionActive = apiKeyData?.personaSystemActive === true ||
|
|
159
|
-
entitlements.some((e) => e.status === 'active');
|
|
220
|
+
const subscriptionActive = computeSubscriptionActive(apiKeyData, entitlements);
|
|
160
221
|
return {
|
|
161
222
|
featureFlagEnabled: true,
|
|
162
223
|
featureFlags: {
|
|
@@ -185,7 +246,10 @@ async function getWorkspacePersonaState(dbService, userId, apiKey) {
|
|
|
185
246
|
.map((persona) => ({
|
|
186
247
|
...persona,
|
|
187
248
|
status: 'locked'
|
|
188
|
-
}))
|
|
249
|
+
})),
|
|
250
|
+
// Authoritative per-persona verdict: a non-subscribed (legacy) workspace is
|
|
251
|
+
// ungated, so all personas are accessible; a subscribed one gates to owned.
|
|
252
|
+
personas: resolvePersonaAccessStatuses(!subscriptionActive, entitlements),
|
|
189
253
|
};
|
|
190
254
|
}
|
|
191
255
|
async function evaluatePersonaAccess(dbService, userId, jobName, apiKey, returnTo) {
|
|
@@ -205,9 +269,9 @@ async function evaluatePersonaAccess(dbService, userId, jobName, apiKey, returnT
|
|
|
205
269
|
});
|
|
206
270
|
const workspaceEntitlements = await dbService.getPersonaEntitlementsByWorkspaceId(workspace.workspaceId, false);
|
|
207
271
|
// Legacy bypass: if the workspace has never subscribed to the persona system,
|
|
208
|
-
// all jobs are freely accessible — no lock enforcement.
|
|
209
|
-
|
|
210
|
-
|
|
272
|
+
// all jobs are freely accessible — no lock enforcement. Same rule the Hub
|
|
273
|
+
// display inherits via resolvePersonaAccessStatuses(!subscriptionActive, ...).
|
|
274
|
+
const subscriptionActive = computeSubscriptionActive(apiKeyData, workspaceEntitlements);
|
|
211
275
|
if (!subscriptionActive) {
|
|
212
276
|
return {
|
|
213
277
|
allowed: true,
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "fraim-framework",
|
|
3
|
-
"version": "2.0.
|
|
3
|
+
"version": "2.0.196",
|
|
4
4
|
"description": "FRAIM: AI Workforce Infrastructure — the organizational capability that turns AI agents into an accountable workforce, their operators into capable AI managers, and executives into leaders with clear optics on AI proficiency.",
|
|
5
5
|
"main": "index.js",
|
|
6
6
|
"bin": {
|
package/public/ai-hub/script.js
CHANGED
|
@@ -3277,6 +3277,10 @@ function convAwaitingReview(conv) {
|
|
|
3277
3277
|
// output, not deliverables submitted to the human manager. Other manager jobs (e.g.,
|
|
3278
3278
|
// stakeholder-status-reporting) may legitimately surface artifacts for human review.
|
|
3279
3279
|
if (delegationLedgerForConversation(conv)) return false;
|
|
3280
|
+
const runArtifacts = Array.isArray(conv.artifacts) ? conv.artifacts : [];
|
|
3281
|
+
if (conv.status === 'completed' && runArtifacts.some((artifact) => artifactLocalPath(artifact) || artifactExternalUrl(artifact))) {
|
|
3282
|
+
return true;
|
|
3283
|
+
}
|
|
3280
3284
|
// #521: a plain completed turn is NOT awaiting review — the approve/reject card
|
|
3281
3285
|
// only belongs when the employee actually submits for review (a review_handoff,
|
|
3282
3286
|
// emitted by the submit phase). A turn that just finished (a question, a pause
|
|
@@ -3483,12 +3487,32 @@ function hasReviewHandoffSignal(text) {
|
|
|
3483
3487
|
return !!(text && /reviewRequired|reviewTarget|review_handoff/i.test(text));
|
|
3484
3488
|
}
|
|
3485
3489
|
|
|
3490
|
+
function reviewSubmissionClaimText(text) {
|
|
3491
|
+
return String(stripMarkdownForDisplay(text) || '')
|
|
3492
|
+
.replace(/\s+/g, ' ')
|
|
3493
|
+
.trim();
|
|
3494
|
+
}
|
|
3495
|
+
|
|
3496
|
+
function hasExplicitReviewSubmissionClaim(text) {
|
|
3497
|
+
const cleaned = reviewSubmissionClaimText(text);
|
|
3498
|
+
if (!cleaned) return false;
|
|
3499
|
+
if (/\b(?:not\s+ready|not\s+yet\s+ready|will\s+be\s+ready|soon\s+be\s+ready)\b.{0,60}\breview\b/i.test(cleaned)) {
|
|
3500
|
+
return false;
|
|
3501
|
+
}
|
|
3502
|
+
return /\bready\s+for\s+(?:your\s+)?review\b/i.test(cleaned)
|
|
3503
|
+
|| /\bplease\s+review\b/i.test(cleaned)
|
|
3504
|
+
|| /\bsubmitted\s+(?:the\s+|this\s+|my\s+)?(?:work|deliverable|artifact|bundle|pr|pull request|document|doc|file|files)\s+for\s+(?:your\s+)?review\b/i.test(cleaned)
|
|
3505
|
+
|| /\b(?:review|open)\s+(?:the\s+)?(?:pr|pull request|artifact|document|doc|file|files|deliverable|bundle)\b/i.test(cleaned);
|
|
3506
|
+
}
|
|
3507
|
+
|
|
3486
3508
|
function reviewHandoffIssueForConversation(conv) {
|
|
3487
3509
|
if (!conv) return '';
|
|
3488
3510
|
if (conv.reviewHandoff && !normalizeReviewHandoff(conv.reviewHandoff)) {
|
|
3489
3511
|
return 'The review handoff contract is invalid.';
|
|
3490
3512
|
}
|
|
3491
3513
|
const messages = conv.messages || [];
|
|
3514
|
+
const hasRunArtifacts = Array.isArray(conv.artifacts) && conv.artifacts.length > 0;
|
|
3515
|
+
const latestEmployee = [...messages].reverse().find((message) => message.role === 'employee');
|
|
3492
3516
|
for (let i = messages.length - 1; i >= 0; i -= 1) {
|
|
3493
3517
|
if (messages[i].role !== 'employee') continue;
|
|
3494
3518
|
if (!hasReviewHandoffSignal(messages[i].text)) continue;
|
|
@@ -3496,6 +3520,9 @@ function reviewHandoffIssueForConversation(conv) {
|
|
|
3496
3520
|
return 'The review handoff contract is malformed or missing required fields.';
|
|
3497
3521
|
}
|
|
3498
3522
|
}
|
|
3523
|
+
if (!conv.reviewHandoff && !hasRunArtifacts && latestEmployee && hasExplicitReviewSubmissionClaim(latestEmployee.text)) {
|
|
3524
|
+
return 'This run says it is ready for review, but did not include a structured review handoff.';
|
|
3525
|
+
}
|
|
3499
3526
|
return '';
|
|
3500
3527
|
}
|
|
3501
3528
|
|
|
@@ -3851,7 +3878,7 @@ function renderReviewExperience(conv) {
|
|
|
3851
3878
|
if (handoff) {
|
|
3852
3879
|
const hasArtifact = !!(fmt.actions && fmt.actions.length);
|
|
3853
3880
|
handoff.textContent = fmt.key === 'contract_error'
|
|
3854
|
-
? 'The review handoff contract is invalid. Ask the employee to resend it before review
|
|
3881
|
+
? `${fmt.issue || 'The review handoff contract is invalid.'} Ask the employee to resend it before review.`
|
|
3855
3882
|
: fmt.key === 'pull_request'
|
|
3856
3883
|
? 'Comment on the PR to leave inline notes, or approve / type changes below.'
|
|
3857
3884
|
: isManagerTemplateJob(conv && conv.jobId)
|