@girardmedia/bootspring 2.0.22 → 2.0.23
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/bin/bootspring.js +5 -0
- package/cli/org.js +474 -0
- package/cli/preseed.js +9 -301
- package/cli/seed.js +23 -1074
- package/core/api-client.js +77 -0
- package/core/entitlements.js +36 -0
- package/core/organizations.js +223 -0
- package/core/policies.js +51 -6
- package/core/policy-matrix.js +303 -0
- package/core/project-context.js +1 -0
- package/intelligence/orchestrator/config/index.js +4 -1
- package/intelligence/orchestrator/config/pack-lifecycle.js +262 -0
- package/intelligence/orchestrator.js +17 -512
- package/mcp/contracts/mcp-contract.v1.json +1 -1
- package/package.json +1 -1
|
@@ -0,0 +1,303 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Policy Matrix
|
|
3
|
+
* Comprehensive policy definitions for org-level gates
|
|
4
|
+
* @package bootspring
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* Policy scopes define what can be controlled
|
|
9
|
+
*/
|
|
10
|
+
const POLICY_SCOPES = {
|
|
11
|
+
skills: {
|
|
12
|
+
external: 'skills.external', // Third-party/external skills
|
|
13
|
+
premium: 'skills.premium', // Premium skill categories
|
|
14
|
+
ai: 'skills.ai', // AI-powered skills
|
|
15
|
+
all: 'skills.*'
|
|
16
|
+
},
|
|
17
|
+
workflows: {
|
|
18
|
+
parallel: 'workflows.parallel', // Parallel execution
|
|
19
|
+
premium: 'workflows.premium', // Premium workflow packs
|
|
20
|
+
custom: 'workflows.custom', // Custom workflow definitions
|
|
21
|
+
all: 'workflows.*'
|
|
22
|
+
},
|
|
23
|
+
agents: {
|
|
24
|
+
technical: 'agents.technical', // Technical experts
|
|
25
|
+
business: 'agents.business', // Business/legal experts
|
|
26
|
+
enterprise: 'agents.enterprise', // Enterprise-only agents
|
|
27
|
+
all: 'agents.*'
|
|
28
|
+
},
|
|
29
|
+
features: {
|
|
30
|
+
telemetry: 'features.telemetry', // Usage telemetry
|
|
31
|
+
cloudSync: 'features.cloud_sync', // Cloud synchronization
|
|
32
|
+
teamSharing: 'features.team_sharing', // Team context sharing
|
|
33
|
+
auditLogs: 'features.audit_logs', // Audit logging
|
|
34
|
+
apiAccess: 'features.api_access', // Direct API access
|
|
35
|
+
all: 'features.*'
|
|
36
|
+
}
|
|
37
|
+
};
|
|
38
|
+
|
|
39
|
+
/**
|
|
40
|
+
* Default policy matrix by tier
|
|
41
|
+
* Defines what's allowed/blocked per tier
|
|
42
|
+
*/
|
|
43
|
+
const TIER_POLICY_DEFAULTS = {
|
|
44
|
+
free: {
|
|
45
|
+
allowed: [
|
|
46
|
+
'skills.external',
|
|
47
|
+
'agents.technical',
|
|
48
|
+
'features.telemetry'
|
|
49
|
+
],
|
|
50
|
+
blocked: [
|
|
51
|
+
'skills.premium',
|
|
52
|
+
'skills.ai',
|
|
53
|
+
'workflows.premium',
|
|
54
|
+
'workflows.parallel',
|
|
55
|
+
'agents.business',
|
|
56
|
+
'agents.enterprise',
|
|
57
|
+
'features.cloud_sync',
|
|
58
|
+
'features.team_sharing',
|
|
59
|
+
'features.audit_logs'
|
|
60
|
+
],
|
|
61
|
+
limits: {
|
|
62
|
+
skillsPerDay: 50,
|
|
63
|
+
workflowsPerDay: 10,
|
|
64
|
+
agentInvocationsPerDay: 20
|
|
65
|
+
}
|
|
66
|
+
},
|
|
67
|
+
pro: {
|
|
68
|
+
allowed: [
|
|
69
|
+
'skills.*',
|
|
70
|
+
'workflows.*',
|
|
71
|
+
'agents.technical',
|
|
72
|
+
'agents.business',
|
|
73
|
+
'features.telemetry',
|
|
74
|
+
'features.cloud_sync'
|
|
75
|
+
],
|
|
76
|
+
blocked: [
|
|
77
|
+
'agents.enterprise',
|
|
78
|
+
'features.team_sharing',
|
|
79
|
+
'features.audit_logs'
|
|
80
|
+
],
|
|
81
|
+
limits: {
|
|
82
|
+
skillsPerDay: 500,
|
|
83
|
+
workflowsPerDay: 100,
|
|
84
|
+
agentInvocationsPerDay: 200
|
|
85
|
+
}
|
|
86
|
+
},
|
|
87
|
+
team: {
|
|
88
|
+
allowed: [
|
|
89
|
+
'skills.*',
|
|
90
|
+
'workflows.*',
|
|
91
|
+
'agents.*',
|
|
92
|
+
'features.telemetry',
|
|
93
|
+
'features.cloud_sync',
|
|
94
|
+
'features.team_sharing',
|
|
95
|
+
'features.audit_logs'
|
|
96
|
+
],
|
|
97
|
+
blocked: [],
|
|
98
|
+
limits: {
|
|
99
|
+
skillsPerDay: 2000,
|
|
100
|
+
workflowsPerDay: 500,
|
|
101
|
+
agentInvocationsPerDay: 1000,
|
|
102
|
+
teamMembers: 10
|
|
103
|
+
}
|
|
104
|
+
},
|
|
105
|
+
enterprise: {
|
|
106
|
+
allowed: ['*'],
|
|
107
|
+
blocked: [],
|
|
108
|
+
limits: {
|
|
109
|
+
skillsPerDay: -1, // Unlimited
|
|
110
|
+
workflowsPerDay: -1,
|
|
111
|
+
agentInvocationsPerDay: -1,
|
|
112
|
+
teamMembers: -1
|
|
113
|
+
}
|
|
114
|
+
}
|
|
115
|
+
};
|
|
116
|
+
|
|
117
|
+
/**
|
|
118
|
+
* Profile-specific policy overrides
|
|
119
|
+
* Applied on top of tier defaults
|
|
120
|
+
*/
|
|
121
|
+
const PROFILE_OVERRIDES = {
|
|
122
|
+
startup: {
|
|
123
|
+
// Startup profile: permissive, fast iteration
|
|
124
|
+
overrides: {},
|
|
125
|
+
additionalBlocked: []
|
|
126
|
+
},
|
|
127
|
+
regulated: {
|
|
128
|
+
// Regulated profile: compliance-focused
|
|
129
|
+
overrides: {
|
|
130
|
+
requireApproval: ['workflows.custom', 'skills.external'],
|
|
131
|
+
auditAll: true,
|
|
132
|
+
dataResidency: true
|
|
133
|
+
},
|
|
134
|
+
additionalBlocked: [
|
|
135
|
+
'skills.external',
|
|
136
|
+
'workflows.growth-pack'
|
|
137
|
+
]
|
|
138
|
+
},
|
|
139
|
+
enterprise: {
|
|
140
|
+
// Enterprise profile: full control
|
|
141
|
+
overrides: {
|
|
142
|
+
ssoRequired: true,
|
|
143
|
+
auditAll: true,
|
|
144
|
+
approvalWorkflow: true
|
|
145
|
+
},
|
|
146
|
+
additionalBlocked: []
|
|
147
|
+
}
|
|
148
|
+
};
|
|
149
|
+
|
|
150
|
+
/**
|
|
151
|
+
* Member role permissions
|
|
152
|
+
* What each role can do within an org
|
|
153
|
+
*/
|
|
154
|
+
const ROLE_PERMISSIONS = {
|
|
155
|
+
owner: {
|
|
156
|
+
canManageOrg: true,
|
|
157
|
+
canManageMembers: true,
|
|
158
|
+
canManagePolicies: true,
|
|
159
|
+
canManageBilling: true,
|
|
160
|
+
canUseAllFeatures: true
|
|
161
|
+
},
|
|
162
|
+
admin: {
|
|
163
|
+
canManageOrg: false,
|
|
164
|
+
canManageMembers: true,
|
|
165
|
+
canManagePolicies: true,
|
|
166
|
+
canManageBilling: false,
|
|
167
|
+
canUseAllFeatures: true
|
|
168
|
+
},
|
|
169
|
+
member: {
|
|
170
|
+
canManageOrg: false,
|
|
171
|
+
canManageMembers: false,
|
|
172
|
+
canManagePolicies: false,
|
|
173
|
+
canManageBilling: false,
|
|
174
|
+
canUseAllFeatures: true
|
|
175
|
+
},
|
|
176
|
+
viewer: {
|
|
177
|
+
canManageOrg: false,
|
|
178
|
+
canManageMembers: false,
|
|
179
|
+
canManagePolicies: false,
|
|
180
|
+
canManageBilling: false,
|
|
181
|
+
canUseAllFeatures: false
|
|
182
|
+
}
|
|
183
|
+
};
|
|
184
|
+
|
|
185
|
+
/**
|
|
186
|
+
* Check if a scope matches a pattern
|
|
187
|
+
* @param {string} scope - Specific scope (e.g., 'skills.external')
|
|
188
|
+
* @param {string} pattern - Pattern to match (e.g., 'skills.*' or 'skills.external')
|
|
189
|
+
* @returns {boolean}
|
|
190
|
+
*/
|
|
191
|
+
function matchesScope(scope, pattern) {
|
|
192
|
+
if (pattern === '*') return true;
|
|
193
|
+
if (pattern === scope) return true;
|
|
194
|
+
if (pattern.endsWith('.*')) {
|
|
195
|
+
const prefix = pattern.slice(0, -2);
|
|
196
|
+
return scope.startsWith(prefix + '.');
|
|
197
|
+
}
|
|
198
|
+
return false;
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
/**
|
|
202
|
+
* Check if a scope is allowed by policy
|
|
203
|
+
* @param {string} scope - Scope to check
|
|
204
|
+
* @param {string[]} allowed - Allowed patterns
|
|
205
|
+
* @param {string[]} blocked - Blocked patterns
|
|
206
|
+
* @returns {boolean}
|
|
207
|
+
*/
|
|
208
|
+
function isScopeAllowed(scope, allowed, blocked) {
|
|
209
|
+
// Check blocked first (blocked takes precedence)
|
|
210
|
+
for (const pattern of blocked) {
|
|
211
|
+
if (matchesScope(scope, pattern)) {
|
|
212
|
+
return false;
|
|
213
|
+
}
|
|
214
|
+
}
|
|
215
|
+
// Check allowed
|
|
216
|
+
for (const pattern of allowed) {
|
|
217
|
+
if (matchesScope(scope, pattern)) {
|
|
218
|
+
return true;
|
|
219
|
+
}
|
|
220
|
+
}
|
|
221
|
+
return false;
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
/**
|
|
225
|
+
* Build effective policy for an org member
|
|
226
|
+
* @param {string} tier - Org tier (free/pro/team/enterprise)
|
|
227
|
+
* @param {string} profile - Policy profile (startup/regulated/enterprise)
|
|
228
|
+
* @param {object} memberOverrides - Per-member policy overrides
|
|
229
|
+
* @returns {object} Effective policy
|
|
230
|
+
*/
|
|
231
|
+
function buildEffectivePolicy(tier, profile, memberOverrides = {}) {
|
|
232
|
+
const tierDefaults = TIER_POLICY_DEFAULTS[tier] || TIER_POLICY_DEFAULTS.free;
|
|
233
|
+
const profileOverrides = PROFILE_OVERRIDES[profile] || PROFILE_OVERRIDES.startup;
|
|
234
|
+
|
|
235
|
+
// Merge allowed/blocked lists
|
|
236
|
+
const allowed = [...tierDefaults.allowed];
|
|
237
|
+
const blocked = [
|
|
238
|
+
...tierDefaults.blocked,
|
|
239
|
+
...profileOverrides.additionalBlocked
|
|
240
|
+
];
|
|
241
|
+
|
|
242
|
+
// Apply member overrides
|
|
243
|
+
if (memberOverrides.additionalAllowed) {
|
|
244
|
+
allowed.push(...memberOverrides.additionalAllowed);
|
|
245
|
+
}
|
|
246
|
+
if (memberOverrides.additionalBlocked) {
|
|
247
|
+
blocked.push(...memberOverrides.additionalBlocked);
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
return {
|
|
251
|
+
tier,
|
|
252
|
+
profile,
|
|
253
|
+
allowed: [...new Set(allowed)],
|
|
254
|
+
blocked: [...new Set(blocked)],
|
|
255
|
+
limits: { ...tierDefaults.limits, ...(memberOverrides.limits || {}) },
|
|
256
|
+
overrides: { ...profileOverrides.overrides, ...(memberOverrides.overrides || {}) }
|
|
257
|
+
};
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
/**
|
|
261
|
+
* Check access against effective policy
|
|
262
|
+
* @param {string} scope - Scope to check
|
|
263
|
+
* @param {object} policy - Effective policy
|
|
264
|
+
* @returns {object} Access result
|
|
265
|
+
*/
|
|
266
|
+
function checkPolicyAccess(scope, policy) {
|
|
267
|
+
const allowed = isScopeAllowed(scope, policy.allowed, policy.blocked);
|
|
268
|
+
|
|
269
|
+
if (!allowed) {
|
|
270
|
+
return {
|
|
271
|
+
allowed: false,
|
|
272
|
+
code: 'policy_blocked',
|
|
273
|
+
scope,
|
|
274
|
+
reason: `Scope "${scope}" is blocked by ${policy.profile} policy`
|
|
275
|
+
};
|
|
276
|
+
}
|
|
277
|
+
|
|
278
|
+
// Check if approval is required
|
|
279
|
+
if (policy.overrides.requireApproval?.some(p => matchesScope(scope, p))) {
|
|
280
|
+
return {
|
|
281
|
+
allowed: true,
|
|
282
|
+
requiresApproval: true,
|
|
283
|
+
scope,
|
|
284
|
+
reason: `Scope "${scope}" requires approval under ${policy.profile} policy`
|
|
285
|
+
};
|
|
286
|
+
}
|
|
287
|
+
|
|
288
|
+
return {
|
|
289
|
+
allowed: true,
|
|
290
|
+
scope
|
|
291
|
+
};
|
|
292
|
+
}
|
|
293
|
+
|
|
294
|
+
module.exports = {
|
|
295
|
+
POLICY_SCOPES,
|
|
296
|
+
TIER_POLICY_DEFAULTS,
|
|
297
|
+
PROFILE_OVERRIDES,
|
|
298
|
+
ROLE_PERMISSIONS,
|
|
299
|
+
matchesScope,
|
|
300
|
+
isScopeAllowed,
|
|
301
|
+
buildEffectivePolicy,
|
|
302
|
+
checkPolicyAccess
|
|
303
|
+
};
|
package/core/project-context.js
CHANGED
|
@@ -36,6 +36,7 @@ const EXEMPT_COMMANDS = [
|
|
|
36
36
|
'billing', // Billing status/info accessible without project
|
|
37
37
|
'preseed', // Preseed works locally, auth enhances features
|
|
38
38
|
'seed', // Seed works locally for scaffolding
|
|
39
|
+
'org', // Org policy accessible without project
|
|
39
40
|
];
|
|
40
41
|
|
|
41
42
|
// Sub-commands of auth that are exempt
|
|
@@ -8,6 +8,7 @@ const { PHASE_AGENTS, TECHNICAL_TRIGGERS } = require('./phases');
|
|
|
8
8
|
const { WORKFLOWS } = require('./workflows');
|
|
9
9
|
const { REMEDIATION_PATHS } = require('./remediation');
|
|
10
10
|
const { FAILURE_SIGNATURES, SEVERITY_WEIGHTS, PARALLEL_FAILURE_STRATEGIES } = require('./failure-signatures');
|
|
11
|
+
const packLifecycle = require('./pack-lifecycle');
|
|
11
12
|
|
|
12
13
|
module.exports = {
|
|
13
14
|
PHASE_AGENTS,
|
|
@@ -16,5 +17,7 @@ module.exports = {
|
|
|
16
17
|
REMEDIATION_PATHS,
|
|
17
18
|
FAILURE_SIGNATURES,
|
|
18
19
|
SEVERITY_WEIGHTS,
|
|
19
|
-
PARALLEL_FAILURE_STRATEGIES
|
|
20
|
+
PARALLEL_FAILURE_STRATEGIES,
|
|
21
|
+
// Pack lifecycle exports
|
|
22
|
+
...packLifecycle
|
|
20
23
|
};
|
|
@@ -0,0 +1,262 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Pack Lifecycle Configuration
|
|
3
|
+
* Manages premium pack rollout stages: draft -> QA -> canary -> GA
|
|
4
|
+
* @package bootspring
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* Lifecycle stages
|
|
9
|
+
*/
|
|
10
|
+
const LIFECYCLE_STAGES = {
|
|
11
|
+
DRAFT: 'draft', // Internal development, not visible to users
|
|
12
|
+
QA: 'qa', // Internal testing, visible to team accounts
|
|
13
|
+
CANARY: 'canary', // Limited rollout (25% of eligible users)
|
|
14
|
+
GA: 'ga' // General availability (100% rollout)
|
|
15
|
+
};
|
|
16
|
+
|
|
17
|
+
/**
|
|
18
|
+
* Stage order for progression
|
|
19
|
+
*/
|
|
20
|
+
const STAGE_ORDER = ['draft', 'qa', 'canary', 'ga'];
|
|
21
|
+
|
|
22
|
+
/**
|
|
23
|
+
* Default rollout percentages per stage
|
|
24
|
+
*/
|
|
25
|
+
const DEFAULT_ROLLOUT = {
|
|
26
|
+
draft: 0, // Not available to users
|
|
27
|
+
qa: 5, // 5% of team/enterprise users for internal QA
|
|
28
|
+
canary: 25, // 25% of eligible users
|
|
29
|
+
ga: 100 // All eligible users
|
|
30
|
+
};
|
|
31
|
+
|
|
32
|
+
/**
|
|
33
|
+
* Pack lifecycle definitions
|
|
34
|
+
* Tracks current stage and rollout status for each premium pack
|
|
35
|
+
*/
|
|
36
|
+
const PACK_LIFECYCLE = {
|
|
37
|
+
'launch-pack': {
|
|
38
|
+
name: 'Launch Pack',
|
|
39
|
+
description: 'Premium guided launch workflow from freeze to production validation',
|
|
40
|
+
currentStage: 'ga',
|
|
41
|
+
stageHistory: [
|
|
42
|
+
{ stage: 'draft', date: '2026-01-15', approved_by: 'system' },
|
|
43
|
+
{ stage: 'qa', date: '2026-01-22', approved_by: 'system' },
|
|
44
|
+
{ stage: 'canary', date: '2026-02-01', approved_by: 'system' },
|
|
45
|
+
{ stage: 'ga', date: '2026-02-15', approved_by: 'system' }
|
|
46
|
+
],
|
|
47
|
+
rolloutPercentage: 100,
|
|
48
|
+
qualityGates: {
|
|
49
|
+
errorRateThreshold: 0.01, // Max 1% error rate to advance
|
|
50
|
+
minUsageCount: 10, // Min 10 uses in current stage
|
|
51
|
+
minSuccessRate: 0.95 // 95% completion rate
|
|
52
|
+
},
|
|
53
|
+
featureFlags: {}
|
|
54
|
+
},
|
|
55
|
+
'reliability-pack': {
|
|
56
|
+
name: 'Reliability Pack',
|
|
57
|
+
description: 'Premium reliability hardening workflow for incidents and regressions',
|
|
58
|
+
currentStage: 'ga',
|
|
59
|
+
stageHistory: [
|
|
60
|
+
{ stage: 'draft', date: '2026-01-15', approved_by: 'system' },
|
|
61
|
+
{ stage: 'qa', date: '2026-01-22', approved_by: 'system' },
|
|
62
|
+
{ stage: 'canary', date: '2026-02-01', approved_by: 'system' },
|
|
63
|
+
{ stage: 'ga', date: '2026-02-15', approved_by: 'system' }
|
|
64
|
+
],
|
|
65
|
+
rolloutPercentage: 100,
|
|
66
|
+
qualityGates: {
|
|
67
|
+
errorRateThreshold: 0.01,
|
|
68
|
+
minUsageCount: 10,
|
|
69
|
+
minSuccessRate: 0.95
|
|
70
|
+
},
|
|
71
|
+
featureFlags: {}
|
|
72
|
+
},
|
|
73
|
+
'growth-pack': {
|
|
74
|
+
name: 'Growth Pack',
|
|
75
|
+
description: 'Premium experimentation workflow for activation and conversion improvements',
|
|
76
|
+
currentStage: 'ga',
|
|
77
|
+
stageHistory: [
|
|
78
|
+
{ stage: 'draft', date: '2026-01-15', approved_by: 'system' },
|
|
79
|
+
{ stage: 'qa', date: '2026-01-22', approved_by: 'system' },
|
|
80
|
+
{ stage: 'canary', date: '2026-02-01', approved_by: 'system' },
|
|
81
|
+
{ stage: 'ga', date: '2026-02-15', approved_by: 'system' }
|
|
82
|
+
],
|
|
83
|
+
rolloutPercentage: 100,
|
|
84
|
+
qualityGates: {
|
|
85
|
+
errorRateThreshold: 0.01,
|
|
86
|
+
minUsageCount: 10,
|
|
87
|
+
minSuccessRate: 0.95
|
|
88
|
+
},
|
|
89
|
+
featureFlags: {}
|
|
90
|
+
}
|
|
91
|
+
};
|
|
92
|
+
|
|
93
|
+
/**
|
|
94
|
+
* Get pack lifecycle info
|
|
95
|
+
* @param {string} packName - Pack name (e.g., 'launch-pack')
|
|
96
|
+
* @returns {object|null} Pack lifecycle info
|
|
97
|
+
*/
|
|
98
|
+
function getPackLifecycle(packName) {
|
|
99
|
+
return PACK_LIFECYCLE[packName] || null;
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
/**
|
|
103
|
+
* Get current stage for a pack
|
|
104
|
+
* @param {string} packName - Pack name
|
|
105
|
+
* @returns {string} Current stage
|
|
106
|
+
*/
|
|
107
|
+
function getPackStage(packName) {
|
|
108
|
+
const lifecycle = getPackLifecycle(packName);
|
|
109
|
+
return lifecycle?.currentStage || 'draft';
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
/**
|
|
113
|
+
* Check if pack is visible to user based on stage and rollout
|
|
114
|
+
* @param {string} packName - Pack name
|
|
115
|
+
* @param {object} options - Options including tier, userId
|
|
116
|
+
* @returns {boolean} Whether pack is visible
|
|
117
|
+
*/
|
|
118
|
+
function isPackVisibleToUser(packName, options = {}) {
|
|
119
|
+
const lifecycle = getPackLifecycle(packName);
|
|
120
|
+
if (!lifecycle) return false;
|
|
121
|
+
|
|
122
|
+
const stage = lifecycle.currentStage;
|
|
123
|
+
const rollout = lifecycle.rolloutPercentage;
|
|
124
|
+
|
|
125
|
+
// Draft: never visible
|
|
126
|
+
if (stage === 'draft') return false;
|
|
127
|
+
|
|
128
|
+
// QA: only visible to team/enterprise
|
|
129
|
+
if (stage === 'qa') {
|
|
130
|
+
const tier = options.tier || 'free';
|
|
131
|
+
return ['team', 'enterprise'].includes(tier);
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
// Canary/GA: check rollout percentage
|
|
135
|
+
if (rollout === 100) return true;
|
|
136
|
+
if (rollout === 0) return false;
|
|
137
|
+
|
|
138
|
+
// Hash user ID to determine if in rollout
|
|
139
|
+
const userId = options.userId || options.deviceId || 'anonymous';
|
|
140
|
+
const hash = simpleHash(userId + packName);
|
|
141
|
+
const bucket = hash % 100;
|
|
142
|
+
|
|
143
|
+
return bucket < rollout;
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
/**
|
|
147
|
+
* Simple hash function for rollout bucketing
|
|
148
|
+
* @param {string} str - String to hash
|
|
149
|
+
* @returns {number} Hash value 0-999999
|
|
150
|
+
*/
|
|
151
|
+
function simpleHash(str) {
|
|
152
|
+
let hash = 0;
|
|
153
|
+
for (let i = 0; i < str.length; i++) {
|
|
154
|
+
const char = str.charCodeAt(i);
|
|
155
|
+
hash = ((hash << 5) - hash) + char;
|
|
156
|
+
hash = hash & hash; // Convert to 32-bit integer
|
|
157
|
+
}
|
|
158
|
+
return Math.abs(hash);
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
/**
|
|
162
|
+
* Check if stage can be advanced based on quality gates
|
|
163
|
+
* @param {string} packName - Pack name
|
|
164
|
+
* @param {object} metrics - Current metrics { errorRate, usageCount, successRate }
|
|
165
|
+
* @returns {object} { canAdvance, reasons }
|
|
166
|
+
*/
|
|
167
|
+
function checkQualityGates(packName, metrics = {}) {
|
|
168
|
+
const lifecycle = getPackLifecycle(packName);
|
|
169
|
+
if (!lifecycle) {
|
|
170
|
+
return { canAdvance: false, reasons: ['Pack not found'] };
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
const gates = lifecycle.qualityGates;
|
|
174
|
+
const reasons = [];
|
|
175
|
+
|
|
176
|
+
// Check error rate
|
|
177
|
+
if (metrics.errorRate !== undefined && metrics.errorRate > gates.errorRateThreshold) {
|
|
178
|
+
reasons.push(`Error rate ${(metrics.errorRate * 100).toFixed(2)}% exceeds threshold ${(gates.errorRateThreshold * 100).toFixed(2)}%`);
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
// Check usage count
|
|
182
|
+
if (metrics.usageCount !== undefined && metrics.usageCount < gates.minUsageCount) {
|
|
183
|
+
reasons.push(`Usage count ${metrics.usageCount} below minimum ${gates.minUsageCount}`);
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
// Check success rate
|
|
187
|
+
if (metrics.successRate !== undefined && metrics.successRate < gates.minSuccessRate) {
|
|
188
|
+
reasons.push(`Success rate ${(metrics.successRate * 100).toFixed(2)}% below threshold ${(gates.minSuccessRate * 100).toFixed(2)}%`);
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
return {
|
|
192
|
+
canAdvance: reasons.length === 0,
|
|
193
|
+
reasons
|
|
194
|
+
};
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
/**
|
|
198
|
+
* Get next stage in lifecycle
|
|
199
|
+
* @param {string} currentStage - Current stage
|
|
200
|
+
* @returns {string|null} Next stage or null if at GA
|
|
201
|
+
*/
|
|
202
|
+
function getNextStage(currentStage) {
|
|
203
|
+
const index = STAGE_ORDER.indexOf(currentStage);
|
|
204
|
+
if (index === -1 || index === STAGE_ORDER.length - 1) return null;
|
|
205
|
+
return STAGE_ORDER[index + 1];
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
/**
|
|
209
|
+
* Get previous stage in lifecycle
|
|
210
|
+
* @param {string} currentStage - Current stage
|
|
211
|
+
* @returns {string|null} Previous stage or null if at draft
|
|
212
|
+
*/
|
|
213
|
+
function getPreviousStage(currentStage) {
|
|
214
|
+
const index = STAGE_ORDER.indexOf(currentStage);
|
|
215
|
+
if (index <= 0) return null;
|
|
216
|
+
return STAGE_ORDER[index - 1];
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
/**
|
|
220
|
+
* List all pack lifecycle statuses
|
|
221
|
+
* @returns {Array} Pack status list
|
|
222
|
+
*/
|
|
223
|
+
function listPackStatuses() {
|
|
224
|
+
return Object.entries(PACK_LIFECYCLE).map(([key, pack]) => ({
|
|
225
|
+
key,
|
|
226
|
+
name: pack.name,
|
|
227
|
+
description: pack.description,
|
|
228
|
+
currentStage: pack.currentStage,
|
|
229
|
+
rolloutPercentage: pack.rolloutPercentage,
|
|
230
|
+
lastUpdate: pack.stageHistory[pack.stageHistory.length - 1]?.date
|
|
231
|
+
}));
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
/**
|
|
235
|
+
* Get stage badge for display
|
|
236
|
+
* @param {string} stage - Stage name
|
|
237
|
+
* @returns {string} Badge string
|
|
238
|
+
*/
|
|
239
|
+
function getStageBadge(stage) {
|
|
240
|
+
const badges = {
|
|
241
|
+
draft: '[draft]',
|
|
242
|
+
qa: '[QA]',
|
|
243
|
+
canary: '[canary]',
|
|
244
|
+
ga: '[GA]'
|
|
245
|
+
};
|
|
246
|
+
return badges[stage] || `[${stage}]`;
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
module.exports = {
|
|
250
|
+
LIFECYCLE_STAGES,
|
|
251
|
+
STAGE_ORDER,
|
|
252
|
+
DEFAULT_ROLLOUT,
|
|
253
|
+
PACK_LIFECYCLE,
|
|
254
|
+
getPackLifecycle,
|
|
255
|
+
getPackStage,
|
|
256
|
+
isPackVisibleToUser,
|
|
257
|
+
checkQualityGates,
|
|
258
|
+
getNextStage,
|
|
259
|
+
getPreviousStage,
|
|
260
|
+
listPackStatuses,
|
|
261
|
+
getStageBadge
|
|
262
|
+
};
|