fraim-framework 2.0.194 → 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) {
|
|
@@ -2252,9 +2234,20 @@ class AiHubServer {
|
|
|
2252
2234
|
if (!Array.isArray(body.conversations)) {
|
|
2253
2235
|
return res.status(400).json({ error: 'conversations array required' });
|
|
2254
2236
|
}
|
|
2237
|
+
// The client strips server-owned heavy fields (messages/events/artifacts/run/
|
|
2238
|
+
// delegation) from its PUT payload to stay under the body-size limit. Merge them
|
|
2239
|
+
// back from the stored record so a client-owned change (e.g. reviewApproved) never
|
|
2240
|
+
// erases run history. List membership still comes from the incoming array, so
|
|
2241
|
+
// deletes are honored (a conversation absent here is dropped).
|
|
2242
|
+
const prior = this.conversationStore.loadProject(key);
|
|
2243
|
+
const priorById = new Map(prior.conversations.map((entry) => [entry.id, entry]));
|
|
2244
|
+
const conversations = body.conversations.map((incoming) => {
|
|
2245
|
+
const existing = incoming && incoming.id ? priorById.get(incoming.id) : undefined;
|
|
2246
|
+
return existing ? { ...existing, ...incoming } : incoming;
|
|
2247
|
+
});
|
|
2255
2248
|
const saved = this.conversationStore.replaceProject(key, {
|
|
2256
2249
|
activeId: body.activeId ?? null,
|
|
2257
|
-
conversations
|
|
2250
|
+
conversations,
|
|
2258
2251
|
});
|
|
2259
2252
|
return res.json({ projectPath: key, scope: scope ?? 'project', ...saved, source: 'disk' });
|
|
2260
2253
|
}
|
|
@@ -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/index.html
CHANGED
|
@@ -33,18 +33,18 @@
|
|
|
33
33
|
<div class="am-email" id="am-email"></div>
|
|
34
34
|
</div>
|
|
35
35
|
</div>
|
|
36
|
-
<button class="am-item" id="am-account" type="button">
|
|
36
|
+
<button class="am-item" id="am-account" type="button">
|
|
37
37
|
<span class="am-ico">👤</span>
|
|
38
38
|
<div><div class="am-label">Account</div><div class="am-sub">API key, sessions, billing</div></div>
|
|
39
|
-
</button>
|
|
39
|
+
</button>
|
|
40
40
|
<button class="am-item" id="am-brain" type="button">
|
|
41
41
|
<span class="am-ico">🧠</span>
|
|
42
42
|
<div><div class="am-label">Brain</div><div class="am-sub">Everything your team knows</div></div>
|
|
43
43
|
</button>
|
|
44
|
-
<button class="am-item" id="am-analytics" type="button">
|
|
44
|
+
<button class="am-item" id="am-analytics" type="button">
|
|
45
45
|
<span class="am-ico">📊</span>
|
|
46
46
|
<div><div class="am-label">Analytics</div><div class="am-sub">Usage, cost, and ROI</div></div>
|
|
47
|
-
</button>
|
|
47
|
+
</button>
|
|
48
48
|
<button class="am-item am-theme" id="am-theme-toggle" type="button" role="switch" aria-checked="false">
|
|
49
49
|
<span class="am-ico" id="am-theme-ico">🌙</span>
|
|
50
50
|
<div><div class="am-label">Dark mode</div><div class="am-sub" id="am-theme-sub">Off</div></div>
|
|
@@ -141,21 +141,21 @@
|
|
|
141
141
|
</div>
|
|
142
142
|
</section>
|
|
143
143
|
|
|
144
|
-
<section class="hub-area" id="area-connected" hidden>
|
|
145
|
-
<div class="connected-shell">
|
|
146
|
-
<div class="connected-toolbar">
|
|
147
|
-
<div class="connected-title-block">
|
|
148
|
-
<div class="connected-kicker" id="connected-kicker">Connected FRAIM</div>
|
|
149
|
-
<div class="connected-title" id="connected-title">Account</div>
|
|
150
|
-
</div>
|
|
151
|
-
<div class="connected-origin" id="connected-origin"></div>
|
|
152
|
-
<button class="connected-close" id="connected-close" type="button">Back</button>
|
|
153
|
-
</div>
|
|
154
|
-
<iframe id="connected-frame" class="connected-frame" title="FRAIM connected surface" allow="clipboard-read; clipboard-write"></iframe>
|
|
155
|
-
</div>
|
|
156
|
-
</section>
|
|
157
|
-
|
|
158
|
-
<!-- Issue #512: Projects area wraps the existing single-surface Hub. -->
|
|
144
|
+
<section class="hub-area" id="area-connected" hidden>
|
|
145
|
+
<div class="connected-shell">
|
|
146
|
+
<div class="connected-toolbar">
|
|
147
|
+
<div class="connected-title-block">
|
|
148
|
+
<div class="connected-kicker" id="connected-kicker">Connected FRAIM</div>
|
|
149
|
+
<div class="connected-title" id="connected-title">Account</div>
|
|
150
|
+
</div>
|
|
151
|
+
<div class="connected-origin" id="connected-origin"></div>
|
|
152
|
+
<button class="connected-close" id="connected-close" type="button">Back</button>
|
|
153
|
+
</div>
|
|
154
|
+
<iframe id="connected-frame" class="connected-frame" title="FRAIM connected surface" allow="clipboard-read; clipboard-write"></iframe>
|
|
155
|
+
</div>
|
|
156
|
+
</section>
|
|
157
|
+
|
|
158
|
+
<!-- Issue #512: Projects area wraps the existing single-surface Hub. -->
|
|
159
159
|
<section class="hub-area on" id="area-projects">
|
|
160
160
|
<div class="proj-tabs" id="proj-tabs">
|
|
161
161
|
<button class="ptab on" id="ptab-overview" type="button" data-view="overview">Overview</button>
|
|
@@ -868,6 +868,6 @@
|
|
|
868
868
|
</div>
|
|
869
869
|
</div>
|
|
870
870
|
|
|
871
|
-
<script src="./script.js"></script>
|
|
871
|
+
<script src="./script.js?v=conv-persist-20260705"></script>
|
|
872
872
|
</body>
|
|
873
873
|
</html>
|
package/public/ai-hub/script.js
CHANGED
|
@@ -507,11 +507,29 @@ function normalizeGeminiConversationMessages(conv) {
|
|
|
507
507
|
return changed;
|
|
508
508
|
}
|
|
509
509
|
|
|
510
|
+
// Server-owned conversation fields. The server re-derives these from the run
|
|
511
|
+
// registry and writes them to the conversation store on every run update
|
|
512
|
+
// (persistRunConversation), so the client must NOT re-upload them: a large run's
|
|
513
|
+
// messages/events/artifacts push the PUT body past the server's size limit, the
|
|
514
|
+
// request 413s, the error is swallowed, and the client-owned flags in the SAME
|
|
515
|
+
// payload (e.g. reviewApproved from "Mark complete") never persist. Strip them
|
|
516
|
+
// here; the server's PUT handler merges them back from the stored record.
|
|
517
|
+
const SERVER_OWNED_CONV_FIELDS = ['messages', 'events', 'artifacts', 'run', 'delegation'];
|
|
518
|
+
function slimConversationForPersist(conv) {
|
|
519
|
+
if (!conv || typeof conv !== 'object') return conv;
|
|
520
|
+
const slim = { ...conv };
|
|
521
|
+
for (const field of SERVER_OWNED_CONV_FIELDS) delete slim[field];
|
|
522
|
+
return slim;
|
|
523
|
+
}
|
|
524
|
+
function slimConversationsForPersist(list) {
|
|
525
|
+
return Array.isArray(list) ? list.map(slimConversationForPersist) : list;
|
|
526
|
+
}
|
|
527
|
+
|
|
510
528
|
function projectConversationPayload() {
|
|
511
529
|
return {
|
|
512
530
|
projectPath: state.projectPath || '',
|
|
513
531
|
activeId: state.activeId || null,
|
|
514
|
-
conversations: projectConversations(),
|
|
532
|
+
conversations: slimConversationsForPersist(projectConversations()),
|
|
515
533
|
};
|
|
516
534
|
}
|
|
517
535
|
|
|
@@ -537,7 +555,7 @@ function scheduleConversationDiskPersist() {
|
|
|
537
555
|
await requestJson('/api/ai-hub/conversations', {
|
|
538
556
|
method: 'PUT',
|
|
539
557
|
headers: { 'Content-Type': 'application/json' },
|
|
540
|
-
body: JSON.stringify({ scope, activeId: null, conversations: bucket }),
|
|
558
|
+
body: JSON.stringify({ scope, activeId: null, conversations: slimConversationsForPersist(bucket) }),
|
|
541
559
|
});
|
|
542
560
|
}
|
|
543
561
|
state.conversationDiskAvailable = true;
|
|
@@ -3259,6 +3277,10 @@ function convAwaitingReview(conv) {
|
|
|
3259
3277
|
// output, not deliverables submitted to the human manager. Other manager jobs (e.g.,
|
|
3260
3278
|
// stakeholder-status-reporting) may legitimately surface artifacts for human review.
|
|
3261
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
|
+
}
|
|
3262
3284
|
// #521: a plain completed turn is NOT awaiting review — the approve/reject card
|
|
3263
3285
|
// only belongs when the employee actually submits for review (a review_handoff,
|
|
3264
3286
|
// emitted by the submit phase). A turn that just finished (a question, a pause
|
|
@@ -3465,12 +3487,32 @@ function hasReviewHandoffSignal(text) {
|
|
|
3465
3487
|
return !!(text && /reviewRequired|reviewTarget|review_handoff/i.test(text));
|
|
3466
3488
|
}
|
|
3467
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
|
+
|
|
3468
3508
|
function reviewHandoffIssueForConversation(conv) {
|
|
3469
3509
|
if (!conv) return '';
|
|
3470
3510
|
if (conv.reviewHandoff && !normalizeReviewHandoff(conv.reviewHandoff)) {
|
|
3471
3511
|
return 'The review handoff contract is invalid.';
|
|
3472
3512
|
}
|
|
3473
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');
|
|
3474
3516
|
for (let i = messages.length - 1; i >= 0; i -= 1) {
|
|
3475
3517
|
if (messages[i].role !== 'employee') continue;
|
|
3476
3518
|
if (!hasReviewHandoffSignal(messages[i].text)) continue;
|
|
@@ -3478,6 +3520,9 @@ function reviewHandoffIssueForConversation(conv) {
|
|
|
3478
3520
|
return 'The review handoff contract is malformed or missing required fields.';
|
|
3479
3521
|
}
|
|
3480
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
|
+
}
|
|
3481
3526
|
return '';
|
|
3482
3527
|
}
|
|
3483
3528
|
|
|
@@ -3833,7 +3878,7 @@ function renderReviewExperience(conv) {
|
|
|
3833
3878
|
if (handoff) {
|
|
3834
3879
|
const hasArtifact = !!(fmt.actions && fmt.actions.length);
|
|
3835
3880
|
handoff.textContent = fmt.key === 'contract_error'
|
|
3836
|
-
? '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.`
|
|
3837
3882
|
: fmt.key === 'pull_request'
|
|
3838
3883
|
? 'Comment on the PR to leave inline notes, or approve / type changes below.'
|
|
3839
3884
|
: isManagerTemplateJob(conv && conv.jobId)
|