fraim 2.0.343 → 2.0.344
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/registry/scripts/artifact-paths.js +108 -0
- package/dist/registry/scripts/learning/completion-state.js +205 -5
- package/dist/registry/scripts/learning-usage/local-runtime.js +217 -0
- package/dist/src/cli/commands/init-project.js +2 -12
- package/dist/src/cli/commands/sync.js +36 -162
- package/dist/src/cli/setup/ide-invocation-surfaces.js +3 -3
- package/dist/src/cli/setup/user-level-sync.js +3 -3
- package/dist/src/cli/utils/agent-adapters.js +12 -12
- package/dist/src/cli/utils/fraim-gitignore.js +28 -3
- package/dist/src/cli/utils/pack-home.js +10 -0
- package/dist/src/cli/utils/project-catalog-cleanup.js +62 -0
- package/dist/src/cli/utils/remote-sync.js +26 -2
- package/dist/src/core/ai-mentor.js +29 -12
- package/dist/src/core/config-loader.js +4 -0
- package/dist/src/core/fraim-config-schema.generated.js +22 -0
- package/dist/src/core/utils/local-registry-resolver.js +33 -57
- package/dist/src/fraim/db-service.js +127 -118
- package/dist/src/local-mcp-server/artifact-retention-cleanup.js +79 -6
- package/dist/src/local-mcp-server/learning-context-builder.js +101 -105
- package/dist/src/local-mcp-server/learning-source-record.js +122 -0
- package/dist/src/local-mcp-server/learning-usage-attestation.js +66 -56
- package/dist/src/local-mcp-server/learning-usage-command.js +5 -4
- package/dist/src/local-mcp-server/stdio-server.js +9 -6
- package/dist/src/local-mcp-server/usage-collector.js +2 -0
- package/dist/src/mcp/tool-schemas.js +4 -0
- package/dist/src/services/mcp-service.js +5 -3
- package/dist/src/services/usage-analytics-service.js +1 -0
- package/package.json +3 -1
|
@@ -32,7 +32,7 @@ class AIMentor {
|
|
|
32
32
|
}
|
|
33
33
|
}
|
|
34
34
|
if (args.status === 'starting') {
|
|
35
|
-
return await this.generateStartingMessage(workflow, args.currentPhase, args.skipIncludes);
|
|
35
|
+
return await this.generateStartingMessage(workflow, args.currentPhase, args.skipIncludes, args.requestInstructions);
|
|
36
36
|
}
|
|
37
37
|
else if (args.status === 'complete') {
|
|
38
38
|
return await this.generateCompletionMessage(workflow, args.currentPhase, args.findings, args.evidence, args.skipIncludes);
|
|
@@ -102,6 +102,9 @@ class AIMentor {
|
|
|
102
102
|
getCompactPhaseAuthority() {
|
|
103
103
|
return AIMentor.COMPACT_PHASE_AUTHORITY;
|
|
104
104
|
}
|
|
105
|
+
getEvidenceInitialization() {
|
|
106
|
+
return '## Initialize the Work Record\nAfter resolving the workspace and owning home, before substantive work, read and follow `get_fraim_file({ path: "delivery/initialize-evidence.md" })`. Resume the existing run record when continuing work.';
|
|
107
|
+
}
|
|
105
108
|
buildPhasedJobOverview(workflow) {
|
|
106
109
|
const metadataPhases = Object.keys(workflow.metadata.phases || {});
|
|
107
110
|
const initialPhase = workflow.metadata.initialPhase || metadataPhases[0] || Array.from(workflow.phases.keys())[0] || 'starting';
|
|
@@ -123,9 +126,10 @@ class AIMentor {
|
|
|
123
126
|
+ ' `get_fraim_file({ path: "skills/engineering/set-up-workspace.md" })`'
|
|
124
127
|
+ ' to work on the issue\'s branch when a repository is configured, or in place otherwise.');
|
|
125
128
|
}
|
|
129
|
+
lines.push('', this.getEvidenceInitialization());
|
|
126
130
|
return lines.join('\n');
|
|
127
131
|
}
|
|
128
|
-
async generateStartingMessage(workflow, phaseId, skipIncludes) {
|
|
132
|
+
async generateStartingMessage(workflow, phaseId, skipIncludes, requestInstructions) {
|
|
129
133
|
const entityType = 'Job';
|
|
130
134
|
if (workflow.isSimple) {
|
|
131
135
|
const message = `Starting ${entityType}: ${workflow.metadata.name}\n\n${workflow.overview}`;
|
|
@@ -137,8 +141,15 @@ class AIMentor {
|
|
|
137
141
|
};
|
|
138
142
|
}
|
|
139
143
|
const isVeryFirstCall = phaseId === 'starting';
|
|
140
|
-
|
|
144
|
+
const initialPhase = workflow.metadata.initialPhase || Object.keys(workflow.metadata.phases || {})[0] || Array.from(workflow.phases.keys())[0] || 'starting';
|
|
145
|
+
let targetPhase = isVeryFirstCall ? initialPhase : phaseId;
|
|
141
146
|
const targetDefinition = workflow.metadata.phases?.[targetPhase];
|
|
147
|
+
// Named initial phases and scheduled recurrence entry have no preceding
|
|
148
|
+
// completion response supplying their instructions.
|
|
149
|
+
const deliverInstructions = isVeryFirstCall || phaseId === initialPhase
|
|
150
|
+
|| (0, resolve_phase_edge_1.isRecurrencePhase)(targetDefinition)
|
|
151
|
+
|| (0, resolve_phase_edge_1.findRecurrenceForMember)(workflow.metadata.phases, phaseId)?.role === 'recur'
|
|
152
|
+
|| requestInstructions === true;
|
|
142
153
|
if ((0, resolve_phase_edge_1.isRecurrencePhase)(targetDefinition)) {
|
|
143
154
|
targetPhase = isVeryFirstCall ? targetDefinition.setup.phase : targetDefinition.recur.phase;
|
|
144
155
|
}
|
|
@@ -146,20 +157,26 @@ class AIMentor {
|
|
|
146
157
|
if (isVeryFirstCall) {
|
|
147
158
|
message += this.buildPhaseArrivalInstruction(workflow.metadata.name, targetPhase);
|
|
148
159
|
}
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
160
|
+
if (deliverInstructions) {
|
|
161
|
+
let instructions = workflow.phases.get(targetPhase);
|
|
162
|
+
if (instructions) {
|
|
163
|
+
// The proxy survives compaction with a warmed skill cache. An
|
|
164
|
+
// explicit recovery must restore nested skill bodies as well.
|
|
165
|
+
instructions = skipIncludes ? instructions : requestInstructions === true
|
|
166
|
+
? await (0, include_resolver_1.resolveIncludes)(instructions, this.resolver, workflow.path)
|
|
167
|
+
: await this.resolveIncludes(instructions, workflow.path);
|
|
168
|
+
message += instructions;
|
|
169
|
+
}
|
|
170
|
+
else {
|
|
171
|
+
message += `No specific instructions found for phase: ${targetPhase}`;
|
|
172
|
+
}
|
|
156
173
|
}
|
|
157
174
|
const recurrenceMember = (0, resolve_phase_edge_1.findRecurrenceForMember)(workflow.metadata.phases, targetPhase);
|
|
158
175
|
const phaseFlow = recurrenceMember
|
|
159
176
|
? { onSuccess: undefined, onFailure: recurrenceMember.node[recurrenceMember.role].onFailure }
|
|
160
177
|
: ((0, resolve_phase_edge_1.isRecurrencePhase)(workflow.metadata.phases?.[targetPhase]) ? undefined : workflow.metadata.phases?.[targetPhase]);
|
|
161
178
|
message += this.buildReportBackFooter(workflow.metadata.name, targetPhase, phaseFlow);
|
|
162
|
-
if (!skipIncludes) {
|
|
179
|
+
if (deliverInstructions && !skipIncludes) {
|
|
163
180
|
this.assertNoUnresolvedIncludes(message, `${workflow.metadata.name}:${targetPhase} (starting)`);
|
|
164
181
|
}
|
|
165
182
|
return {
|
|
@@ -343,7 +360,7 @@ class AIMentor {
|
|
|
343
360
|
if (!job)
|
|
344
361
|
return null;
|
|
345
362
|
if (job.isSimple) {
|
|
346
|
-
return { overview: job.overview
|
|
363
|
+
return { overview: `${job.overview}\n\n${this.getEvidenceInitialization()}`, isSimple: true };
|
|
347
364
|
}
|
|
348
365
|
return {
|
|
349
366
|
overview: this.buildPhasedJobOverview(job),
|
|
@@ -169,6 +169,10 @@ function normalizeFraimConfig(config, options = {}) {
|
|
|
169
169
|
if (config.learning) {
|
|
170
170
|
mergedConfig.learning = config.learning;
|
|
171
171
|
}
|
|
172
|
+
if (config.artifactPaths !== undefined) {
|
|
173
|
+
// Preserve configuration; the shared artifact resolver owns validation and defaults.
|
|
174
|
+
mergedConfig.artifactPaths = config.artifactPaths;
|
|
175
|
+
}
|
|
172
176
|
if (config.competitors && typeof config.competitors === 'object') {
|
|
173
177
|
mergedConfig.competitors = config.competitors;
|
|
174
178
|
}
|
|
@@ -230,6 +230,23 @@ exports.FRAIM_CONFIG_SCHEMA = {
|
|
|
230
230
|
}
|
|
231
231
|
}
|
|
232
232
|
},
|
|
233
|
+
"artifactPaths": {
|
|
234
|
+
"kind": "object",
|
|
235
|
+
"properties": {
|
|
236
|
+
"evidence": {
|
|
237
|
+
"kind": "string"
|
|
238
|
+
},
|
|
239
|
+
"rawLearnings": {
|
|
240
|
+
"kind": "string"
|
|
241
|
+
},
|
|
242
|
+
"learningRetrospectives": {
|
|
243
|
+
"kind": "string"
|
|
244
|
+
},
|
|
245
|
+
"learningArchive": {
|
|
246
|
+
"kind": "string"
|
|
247
|
+
}
|
|
248
|
+
}
|
|
249
|
+
},
|
|
233
250
|
"customer-communication": {
|
|
234
251
|
"kind": "object",
|
|
235
252
|
"properties": {
|
|
@@ -693,6 +710,11 @@ exports.SUPPORTED_FRAIM_CONFIG_PATHS = [
|
|
|
693
710
|
"learning.usage.firingLogLimit",
|
|
694
711
|
"learning.usage.offerLogLimit",
|
|
695
712
|
"learning.usage.retirementOfferThreshold",
|
|
713
|
+
"artifactPaths",
|
|
714
|
+
"artifactPaths.evidence",
|
|
715
|
+
"artifactPaths.rawLearnings",
|
|
716
|
+
"artifactPaths.learningRetrospectives",
|
|
717
|
+
"artifactPaths.learningArchive",
|
|
696
718
|
"customer-communication",
|
|
697
719
|
"customer-communication.productName",
|
|
698
720
|
"customer-communication.productUrl",
|
|
@@ -72,6 +72,17 @@ class LocalRegistryResolver {
|
|
|
72
72
|
}
|
|
73
73
|
return (0, project_fraim_paths_1.getWorkspaceFraimPath)(this.workspaceRoot, ...parts);
|
|
74
74
|
}
|
|
75
|
+
/**
|
|
76
|
+
* Machine catalogs own synced content. Legacy project catalogs are a fallback
|
|
77
|
+
* only when that machine catalog is absent, so removed entries cannot revive
|
|
78
|
+
* from an old project copy. Explicit user-level roots remain self-contained.
|
|
79
|
+
*/
|
|
80
|
+
getSyncedCatalogRoot(role, type) {
|
|
81
|
+
if (this.effectiveFraimDir)
|
|
82
|
+
return (0, path_1.join)(this.effectiveFraimDir, role, type);
|
|
83
|
+
const machineRoot = (0, path_1.join)((0, project_fraim_paths_1.getUserFraimDirPath)(), role, type);
|
|
84
|
+
return (0, fs_1.existsSync)(machineRoot) ? machineRoot : this.getFraimPath(role, type);
|
|
85
|
+
}
|
|
75
86
|
/**
|
|
76
87
|
* Check if a local override exists for the given path
|
|
77
88
|
*/
|
|
@@ -102,16 +113,16 @@ class LocalRegistryResolver {
|
|
|
102
113
|
(0, path_1.join)(this.managerCacheRoot, dir),
|
|
103
114
|
(0, path_1.join)(this.orgCacheRoot, dir),
|
|
104
115
|
(0, path_1.join)(this.workspaceRoot, 'registry', dir),
|
|
105
|
-
// Installed projects
|
|
106
|
-
|
|
107
|
-
...(type
|
|
116
|
+
// Installed projects discover categorized capabilities in machine catalogs.
|
|
117
|
+
this.getSyncedCatalogRoot('ai-employee', dir),
|
|
118
|
+
...(type !== 'skills' ? [this.getSyncedCatalogRoot('ai-manager', dir)] : []),
|
|
108
119
|
];
|
|
109
120
|
for (const root of searchRoots) {
|
|
110
121
|
const found = this.searchFileRecursively(root, baseName);
|
|
111
122
|
if (!found)
|
|
112
123
|
continue;
|
|
113
|
-
const syncedRole = ['ai-employee', 'ai-manager'].find(role => root === this.
|
|
114
|
-
if (syncedRole)
|
|
124
|
+
const syncedRole = ['ai-employee', 'ai-manager'].find(role => root === this.getSyncedCatalogRoot(role, dir));
|
|
125
|
+
if (syncedRole && type !== 'skills')
|
|
115
126
|
return `${dir}/${syncedRole}/${found.slice(root.length + 1).replace(/\\/g, '/')}`;
|
|
116
127
|
// Convert absolute back to a registry-relative path.
|
|
117
128
|
const rel = found.replace(/\\/g, '/');
|
|
@@ -157,67 +168,32 @@ class LocalRegistryResolver {
|
|
|
157
168
|
return this.getFraimPath('personalized-employee', normalized);
|
|
158
169
|
}
|
|
159
170
|
/**
|
|
160
|
-
*
|
|
161
|
-
*
|
|
162
|
-
* - skills/* -> fraim/ai-employee/skills/*
|
|
163
|
-
* - rules/* -> fraim/ai-employee/rules/*
|
|
171
|
+
* Resolve synced content from machine catalogs, retaining legacy project
|
|
172
|
+
* catalogs only when the corresponding machine catalog is unavailable.
|
|
164
173
|
*/
|
|
165
174
|
getSyncedFilePath(path) {
|
|
166
175
|
const normalizedPath = path.replace(/\\/g, '/').replace(/^\/+/, '');
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
176
|
+
const parts = normalizedPath.split('/');
|
|
177
|
+
const type = parts[0];
|
|
178
|
+
if (type === 'jobs' || type === 'workflows') {
|
|
170
179
|
if (parts.length >= 3 && (parts[1] === 'ai-employee' || parts[1] === 'ai-manager')) {
|
|
171
|
-
|
|
172
|
-
const subPath = parts.slice(2).join('/');
|
|
173
|
-
return this.getFraimPath(role, 'workflows', subPath);
|
|
180
|
+
return (0, path_1.join)(this.getSyncedCatalogRoot(parts[1], type), ...parts.slice(2));
|
|
174
181
|
}
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
return employeePath;
|
|
180
|
-
const managerPath = this.getFraimPath('ai-manager', 'workflows', subPath);
|
|
181
|
-
if (fs.existsSync(managerPath))
|
|
182
|
-
return managerPath;
|
|
183
|
-
// Fallback for non-role-prefixed direct workspace paths
|
|
184
|
-
return this.getFraimPath(normalizedPath);
|
|
185
|
-
}
|
|
186
|
-
// 2. Jobs: jobs/[role]/path -> fraim/[role]/jobs/path
|
|
187
|
-
if (normalizedPath.startsWith('jobs/')) {
|
|
188
|
-
const parts = normalizedPath.split('/');
|
|
189
|
-
if (parts.length >= 3 && (parts[1] === 'ai-employee' || parts[1] === 'ai-manager')) {
|
|
190
|
-
const role = parts[1];
|
|
191
|
-
const subPath = parts.slice(2).join('/');
|
|
192
|
-
return this.getFraimPath(role, 'jobs', subPath);
|
|
182
|
+
for (const role of ['ai-employee', 'ai-manager']) {
|
|
183
|
+
const candidate = (0, path_1.join)(this.getSyncedCatalogRoot(role, type), ...parts.slice(1));
|
|
184
|
+
if ((0, fs_1.existsSync)(candidate))
|
|
185
|
+
return candidate;
|
|
193
186
|
}
|
|
194
|
-
//
|
|
195
|
-
const subPath = normalizedPath.substring('jobs/'.length);
|
|
196
|
-
const employeePath = this.getFraimPath('ai-employee', 'jobs', subPath);
|
|
197
|
-
if (fs.existsSync(employeePath))
|
|
198
|
-
return employeePath;
|
|
199
|
-
const managerPath = this.getFraimPath('ai-manager', 'jobs', subPath);
|
|
200
|
-
if (fs.existsSync(managerPath))
|
|
201
|
-
return managerPath;
|
|
202
|
-
// Fallback
|
|
187
|
+
// Retain direct workspace job/workflow files authored outside the catalogs.
|
|
203
188
|
return this.getFraimPath(normalizedPath);
|
|
204
189
|
}
|
|
205
|
-
// 3. Rules: [role]/rules/path -> fraim/[role]/rules/path
|
|
206
190
|
if (normalizedPath.includes('/rules/')) {
|
|
207
|
-
const role =
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
const subPath = normalizedPath.substring(rulesIdx + 'rules/'.length);
|
|
211
|
-
return this.getFraimPath(role, 'rules', subPath);
|
|
212
|
-
}
|
|
213
|
-
// 4. Skills: skills/path -> fraim/ai-employee/skills/path (default to ai-employee)
|
|
214
|
-
if (normalizedPath.startsWith('skills/')) {
|
|
215
|
-
const subPath = normalizedPath.substring('skills/'.length);
|
|
216
|
-
return this.getFraimPath('ai-employee', 'skills', subPath);
|
|
191
|
+
const role = parts.includes('ai-manager') ? 'ai-manager' : 'ai-employee';
|
|
192
|
+
const subPath = normalizedPath.substring(normalizedPath.indexOf('rules/') + 'rules/'.length);
|
|
193
|
+
return (0, path_1.join)(this.getSyncedCatalogRoot(role, 'rules'), subPath);
|
|
217
194
|
}
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
return this.getFraimPath('ai-employee', normalizedPath);
|
|
195
|
+
if (type === 'skills' || type === 'rules') {
|
|
196
|
+
return (0, path_1.join)(this.getSyncedCatalogRoot('ai-employee', type), ...parts.slice(1));
|
|
221
197
|
}
|
|
222
198
|
return null;
|
|
223
199
|
}
|
|
@@ -579,7 +555,7 @@ class LocalRegistryResolver {
|
|
|
579
555
|
}
|
|
580
556
|
const syncedLocalContent = this.readSyncedLocalFile(path);
|
|
581
557
|
if (syncedLocalContent !== null) {
|
|
582
|
-
// Synced baseline
|
|
558
|
+
// Synced machine baseline, not personalized.
|
|
583
559
|
return {
|
|
584
560
|
content: syncedLocalContent,
|
|
585
561
|
source: 'local',
|
|
@@ -1090,6 +1090,22 @@ class FraimDbService {
|
|
|
1090
1090
|
});
|
|
1091
1091
|
return latest?.createdAt ?? null;
|
|
1092
1092
|
}
|
|
1093
|
+
/** Keep the earliest observed start per execution before grouping into time buckets. */
|
|
1094
|
+
distinctJobStartStages() {
|
|
1095
|
+
return [
|
|
1096
|
+
{ $sort: { createdAt: 1, _id: 1 } },
|
|
1097
|
+
{ $group: {
|
|
1098
|
+
_id: { $cond: [
|
|
1099
|
+
{ $and: [{ $eq: ['$type', 'job'] }, { $ne: [{ $ifNull: ['$jobId', ''] }, ''] }] },
|
|
1100
|
+
{ userId: '$userId', name: '$name', jobId: '$jobId' },
|
|
1101
|
+
// Preserve legacy event counts when no execution ID is available.
|
|
1102
|
+
'$_id',
|
|
1103
|
+
] },
|
|
1104
|
+
event: { $first: '$$ROOT' },
|
|
1105
|
+
} },
|
|
1106
|
+
{ $replaceRoot: { newRoot: '$event' } },
|
|
1107
|
+
];
|
|
1108
|
+
}
|
|
1093
1109
|
/**
|
|
1094
1110
|
* Get top components by usage count
|
|
1095
1111
|
*/
|
|
@@ -1106,18 +1122,19 @@ class FraimDbService {
|
|
|
1106
1122
|
if (types && types.length > 0) {
|
|
1107
1123
|
matchStage.type = { $in: types };
|
|
1108
1124
|
}
|
|
1109
|
-
//
|
|
1110
|
-
//
|
|
1111
|
-
// filter the dashboard would show double the runs count vs the runs list.
|
|
1112
|
-
// Old-path events (no args.action field) pass through because $ne includes
|
|
1113
|
-
// documents where the field is absent.
|
|
1125
|
+
// Job instruction reloads are continuation events, not new executions.
|
|
1126
|
+
// Preserve other component usage and legacy job loads without action metadata.
|
|
1114
1127
|
if (!types || types.includes('job')) {
|
|
1115
|
-
matchStage
|
|
1128
|
+
matchStage.$or = [
|
|
1129
|
+
{ type: { $ne: 'job' } },
|
|
1130
|
+
{ 'args.action': { $ne: 'complete' }, 'args.isReload': { $ne: true } },
|
|
1131
|
+
];
|
|
1116
1132
|
}
|
|
1117
1133
|
const pipeline = [
|
|
1118
1134
|
{
|
|
1119
1135
|
$match: matchStage
|
|
1120
1136
|
},
|
|
1137
|
+
...this.distinctJobStartStages(),
|
|
1121
1138
|
{
|
|
1122
1139
|
$group: {
|
|
1123
1140
|
_id: { name: '$name', type: '$type' },
|
|
@@ -1250,6 +1267,7 @@ class FraimDbService {
|
|
|
1250
1267
|
// Exclude explicit complete events so each run is counted once (at start).
|
|
1251
1268
|
// Old-path events have no args.action and are included by $ne.
|
|
1252
1269
|
'args.action': { $ne: 'complete' },
|
|
1270
|
+
'args.isReload': { $ne: true },
|
|
1253
1271
|
createdAt: this.buildCreatedAtMatch(timeWindow)
|
|
1254
1272
|
};
|
|
1255
1273
|
if (userId) {
|
|
@@ -1277,6 +1295,7 @@ class FraimDbService {
|
|
|
1277
1295
|
}
|
|
1278
1296
|
const pipeline = [
|
|
1279
1297
|
{ $match: matchStage },
|
|
1298
|
+
...this.distinctJobStartStages(),
|
|
1280
1299
|
{
|
|
1281
1300
|
$group: {
|
|
1282
1301
|
_id: {
|
|
@@ -1837,133 +1856,123 @@ class FraimDbService {
|
|
|
1837
1856
|
const collection = this.db.collection(this.usageEventsCollectionName);
|
|
1838
1857
|
const sanitizedRepo = repoIdentifier ? (0, git_utils_1.sanitizeRepoIdentifier)(repoIdentifier) : undefined;
|
|
1839
1858
|
const createdAtMatch = { $gte: startDate, ...(endDate ? { $lte: endDate } : {}) };
|
|
1840
|
-
//
|
|
1841
|
-
const
|
|
1842
|
-
if (jobName)
|
|
1843
|
-
explicitMatch.name = jobName;
|
|
1844
|
-
if (userId)
|
|
1845
|
-
explicitMatch.userId = userId;
|
|
1846
|
-
if (sanitizedRepo)
|
|
1847
|
-
explicitMatch.repoIdentifier = sanitizedRepo;
|
|
1848
|
-
const hasExplicit = await collection.countDocuments(explicitMatch) > 0;
|
|
1849
|
-
// Use explicit events if available, fall back to mentoring events for legacy data
|
|
1850
|
-
const matchStage = hasExplicit
|
|
1851
|
-
? { type: 'job', 'args.action': { $in: ['start', 'complete'] }, createdAt: createdAtMatch }
|
|
1852
|
-
: { type: 'mentoring', createdAt: createdAtMatch };
|
|
1859
|
+
// Mentoring carries issue context that is not known when instructions load.
|
|
1860
|
+
const matchStage = { type: { $in: ['job', 'mentoring'] }, createdAt: createdAtMatch };
|
|
1853
1861
|
if (jobName)
|
|
1854
1862
|
matchStage.name = jobName;
|
|
1855
1863
|
if (userId)
|
|
1856
1864
|
matchStage.userId = userId;
|
|
1857
1865
|
if (sanitizedRepo)
|
|
1858
1866
|
matchStage.repoIdentifier = sanitizedRepo;
|
|
1859
|
-
const
|
|
1867
|
+
const isStart = { $and: [
|
|
1868
|
+
{ $eq: ['$type', 'job'] }, { $eq: ['$args.action', 'start'] },
|
|
1869
|
+
{ $ne: [{ $ifNull: ['$args.isReload', false] }, true] },
|
|
1870
|
+
] };
|
|
1871
|
+
const isLegacyLoad = { $and: [
|
|
1872
|
+
{ $eq: ['$type', 'job'] }, { $eq: [{ $ifNull: ['$args.action', null] }, null] },
|
|
1873
|
+
{ $ne: [{ $ifNull: ['$args.isReload', false] }, true] },
|
|
1874
|
+
] };
|
|
1875
|
+
const isExplicitComplete = { $and: [{ $eq: ['$type', 'job'] }, { $eq: ['$args.action', 'complete'] }] };
|
|
1876
|
+
const isLegacyComplete = { $and: [
|
|
1877
|
+
{ $eq: ['$type', 'mentoring'] }, { $eq: ['$args.status', 'complete'] },
|
|
1878
|
+
{ $eq: ['$args.nextPhase', null] },
|
|
1879
|
+
] };
|
|
1880
|
+
const executions = await collection.aggregate([
|
|
1860
1881
|
{ $match: matchStage },
|
|
1861
|
-
{ $sort: { createdAt: 1 } },
|
|
1862
|
-
{
|
|
1863
|
-
$group: {
|
|
1864
|
-
_id: { jobName: '$name', jobId: '$jobId' },
|
|
1865
|
-
startTime: { $min: '$createdAt' },
|
|
1866
|
-
completeTime: {
|
|
1867
|
-
$max: {
|
|
1868
|
-
$cond: [
|
|
1869
|
-
{ $or: [
|
|
1870
|
-
{ $eq: ['$args.action', 'complete'] },
|
|
1871
|
-
{ $eq: ['$args.nextPhase', null] }
|
|
1872
|
-
] },
|
|
1873
|
-
'$createdAt', null
|
|
1874
|
-
]
|
|
1875
|
-
}
|
|
1876
|
-
},
|
|
1877
|
-
hasStart: { $first: { $cond: [true, true, false] } },
|
|
1878
|
-
hasComplete: {
|
|
1879
|
-
$max: {
|
|
1880
|
-
$cond: [
|
|
1881
|
-
{ $or: [
|
|
1882
|
-
{ $eq: ['$args.action', 'complete'] },
|
|
1883
|
-
{ $eq: ['$args.nextPhase', null] }
|
|
1884
|
-
] },
|
|
1885
|
-
true, false
|
|
1886
|
-
]
|
|
1887
|
-
}
|
|
1888
|
-
},
|
|
1889
|
-
eventCount: { $sum: 1 }
|
|
1890
|
-
}
|
|
1891
|
-
},
|
|
1892
1882
|
{
|
|
1893
1883
|
$group: {
|
|
1894
|
-
_id:
|
|
1895
|
-
|
|
1896
|
-
|
|
1897
|
-
completionDurations: {
|
|
1898
|
-
$push: {
|
|
1899
|
-
$cond: [
|
|
1900
|
-
{ $and: ['$hasStart', '$hasComplete', { $ne: ['$startTime', null] }, { $ne: ['$completeTime', null] }] },
|
|
1901
|
-
{ $subtract: ['$completeTime', '$startTime'] },
|
|
1902
|
-
null
|
|
1903
|
-
]
|
|
1904
|
-
}
|
|
1905
|
-
},
|
|
1906
|
-
// Track last phase for abandoned jobs (started but not completed)
|
|
1907
|
-
abandonedPhases: {
|
|
1908
|
-
$push: {
|
|
1909
|
-
$cond: [
|
|
1910
|
-
{ $and: ['$hasStart', { $not: '$hasComplete' }] },
|
|
1911
|
-
'$finalPhase',
|
|
1912
|
-
null
|
|
1913
|
-
]
|
|
1914
|
-
}
|
|
1915
|
-
}
|
|
1916
|
-
}
|
|
1917
|
-
},
|
|
1918
|
-
{
|
|
1919
|
-
$project: {
|
|
1920
|
-
jobName: '$_id',
|
|
1921
|
-
totalStarted: 1,
|
|
1922
|
-
totalCompleted: 1,
|
|
1923
|
-
totalAbandoned: { $subtract: ['$totalStarted', '$totalCompleted'] },
|
|
1924
|
-
completionRate: {
|
|
1925
|
-
$cond: [
|
|
1926
|
-
{ $gt: ['$totalStarted', 0] },
|
|
1927
|
-
{ $divide: ['$totalCompleted', '$totalStarted'] },
|
|
1928
|
-
0
|
|
1929
|
-
]
|
|
1930
|
-
},
|
|
1931
|
-
avgCompletionMs: {
|
|
1932
|
-
$cond: [
|
|
1933
|
-
{ $gt: [{ $size: '$completionDurations' }, 0] },
|
|
1934
|
-
{ $avg: '$completionDurations' },
|
|
1935
|
-
null
|
|
1936
|
-
]
|
|
1937
|
-
},
|
|
1938
|
-
medianIdx: {
|
|
1939
|
-
$cond: [
|
|
1940
|
-
{ $gt: [{ $size: '$completionDurations' }, 0] },
|
|
1941
|
-
{ $floor: { $divide: [{ $size: '$completionDurations' }, 2] } },
|
|
1942
|
-
null
|
|
1943
|
-
]
|
|
1884
|
+
_id: {
|
|
1885
|
+
jobName: '$name', userId: '$userId',
|
|
1886
|
+
execution: { $ifNull: ['$jobId', { $concat: ['sess_', { $ifNull: ['$sessionId', { $toString: '$_id' }] }] }] },
|
|
1944
1887
|
},
|
|
1945
|
-
|
|
1888
|
+
firstEventTime: { $min: '$createdAt' },
|
|
1889
|
+
startTime: { $min: { $cond: [isStart, '$createdAt', null] } },
|
|
1890
|
+
completeTime: { $max: { $cond: [isExplicitComplete, '$createdAt', null] } },
|
|
1891
|
+
legacyCompleteTime: { $max: { $cond: [isLegacyComplete, '$createdAt', null] } },
|
|
1892
|
+
hasStart: { $max: { $cond: [isStart, true, false] } },
|
|
1893
|
+
hasLegacyLoad: { $max: { $cond: [isLegacyLoad, true, false] } },
|
|
1894
|
+
hasJobLifecycle: { $max: { $cond: [
|
|
1895
|
+
{ $and: [{ $eq: ['$type', 'job'] }, { $in: ['$args.action', ['start', 'complete']] }] }, true, false,
|
|
1896
|
+
] } },
|
|
1897
|
+
hasMentoring: { $max: { $cond: [{ $eq: ['$type', 'mentoring'] }, true, false] } },
|
|
1898
|
+
legacyAmbiguous: { $max: { $cond: [
|
|
1899
|
+
{ $and: [isStart, { $or: [
|
|
1900
|
+
{ $ne: [{ $ifNull: ['$args.isReload', null] }, false] },
|
|
1901
|
+
{ $eq: [{ $ifNull: ['$jobId', ''] }, ''] },
|
|
1902
|
+
] }] }, true, false,
|
|
1903
|
+
] } },
|
|
1904
|
+
repositories: { $addToSet: '$repoIdentifier' },
|
|
1905
|
+
issues: { $addToSet: '$args.issueNumber' },
|
|
1946
1906
|
}
|
|
1947
1907
|
}
|
|
1948
|
-
];
|
|
1949
|
-
const
|
|
1950
|
-
|
|
1951
|
-
//
|
|
1952
|
-
|
|
1953
|
-
const
|
|
1908
|
+
]).toArray();
|
|
1909
|
+
const grouped = new Map();
|
|
1910
|
+
for (const execution of executions) {
|
|
1911
|
+
// A reload or completion alone does not establish a start in this window.
|
|
1912
|
+
// Keep old load/mentoring estimates, clearly labeled, without rewriting history.
|
|
1913
|
+
const estimated = !execution.hasStart && (execution.hasLegacyLoad || (!execution.hasJobLifecycle && execution.hasMentoring));
|
|
1914
|
+
if (!execution.hasStart && !estimated)
|
|
1915
|
+
continue;
|
|
1916
|
+
const name = execution._id.jobName;
|
|
1917
|
+
const result = grouped.get(name) || { count: 0, completed: 0, known: 0, legacy: 0, conflicts: 0, workItems: new Set(), durations: [] };
|
|
1918
|
+
grouped.set(name, result);
|
|
1919
|
+
result.count++;
|
|
1920
|
+
if (estimated || execution.legacyAmbiguous)
|
|
1921
|
+
result.legacy++;
|
|
1922
|
+
const repositories = new Set((execution.repositories || [])
|
|
1923
|
+
.filter((value) => typeof value === 'string' && value.trim().length > 0)
|
|
1924
|
+
.map((value) => (0, git_utils_1.sanitizeRepoIdentifier)(value)));
|
|
1925
|
+
const issues = new Set((execution.issues || [])
|
|
1926
|
+
.filter((value) => (typeof value === 'string' && value.trim().length > 0)
|
|
1927
|
+
|| (typeof value === 'number' && Number.isFinite(value)))
|
|
1928
|
+
.map((value) => String(value).trim()));
|
|
1929
|
+
if (repositories.size === 1 && issues.size === 1) {
|
|
1930
|
+
result.known++;
|
|
1931
|
+
result.workItems.add(JSON.stringify([[...repositories][0], [...issues][0], name]));
|
|
1932
|
+
}
|
|
1933
|
+
else if (repositories.size > 1 || issues.size > 1) {
|
|
1934
|
+
result.conflicts++;
|
|
1935
|
+
}
|
|
1936
|
+
// Modern executions require explicit completion; legacy mentoring fallback
|
|
1937
|
+
// requires a complete status and an explicitly terminal nextPhase.
|
|
1938
|
+
const completedAt = execution.completeTime || (estimated ? execution.legacyCompleteTime : null);
|
|
1939
|
+
if (completedAt) {
|
|
1940
|
+
result.completed++;
|
|
1941
|
+
const startedAt = execution.startTime || execution.firstEventTime;
|
|
1942
|
+
const duration = new Date(completedAt).getTime() - new Date(startedAt).getTime();
|
|
1943
|
+
if (duration >= 0)
|
|
1944
|
+
result.durations.push(duration);
|
|
1945
|
+
}
|
|
1946
|
+
}
|
|
1947
|
+
return [...grouped].map(([name, result]) => {
|
|
1948
|
+
const durations = result.durations.sort((a, b) => a - b);
|
|
1949
|
+
const notes = [];
|
|
1950
|
+
if (result.legacy)
|
|
1951
|
+
notes.push(`${result.legacy} legacy execution identities are estimates; historical reloads cannot be distinguished reliably.`);
|
|
1952
|
+
if (result.count > result.known)
|
|
1953
|
+
notes.push(`${result.count - result.known} executions have unknown work-item identity; unique work items count only known identities.`);
|
|
1954
|
+
if (result.conflicts)
|
|
1955
|
+
notes.push(`${result.conflicts} executions have conflicting work-item metadata and are counted as unknown.`);
|
|
1956
|
+
const completionRate = result.completed / result.count;
|
|
1954
1957
|
return {
|
|
1955
|
-
jobName:
|
|
1956
|
-
|
|
1957
|
-
|
|
1958
|
-
|
|
1959
|
-
|
|
1958
|
+
jobName: name,
|
|
1959
|
+
executionCount: result.count,
|
|
1960
|
+
uniqueWorkItemCount: result.workItems.size,
|
|
1961
|
+
executionsWithKnownWorkItem: result.known,
|
|
1962
|
+
executionsWithUnknownWorkItem: result.count - result.known,
|
|
1963
|
+
legacyAmbiguousExecutionCount: result.legacy,
|
|
1964
|
+
identityNote: notes.length ? notes.join(' ') : null,
|
|
1965
|
+
totalRequested: result.count,
|
|
1966
|
+
totalStarted: result.count,
|
|
1967
|
+
totalCompleted: result.completed,
|
|
1968
|
+
totalAbandoned: result.count - result.completed,
|
|
1960
1969
|
startRate: 1.0,
|
|
1961
|
-
completionRate
|
|
1962
|
-
overallRate:
|
|
1963
|
-
averageCompletionTime:
|
|
1964
|
-
medianCompletionTime:
|
|
1970
|
+
completionRate,
|
|
1971
|
+
overallRate: completionRate,
|
|
1972
|
+
averageCompletionTime: durations.length ? durations.reduce((sum, duration) => sum + duration, 0) / durations.length : undefined,
|
|
1973
|
+
medianCompletionTime: durations.length ? durations[Math.floor(durations.length / 2)] : undefined,
|
|
1965
1974
|
commonAbandonmentPhases: [],
|
|
1966
|
-
timeRange: { start: startDate, end: new Date() }
|
|
1975
|
+
timeRange: { start: startDate, end: endDate || new Date() }
|
|
1967
1976
|
};
|
|
1968
1977
|
});
|
|
1969
1978
|
}
|