fraim 2.0.342 → 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/config/persona-hiring.js +7 -1
- package/dist/src/core/ai-mentor.js +42 -11
- package/dist/src/core/config-loader.js +4 -0
- package/dist/src/core/fraim-config-schema.generated.js +22 -0
- package/dist/src/core/handoff-contracts.js +8 -3
- 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 +10 -7
- package/dist/src/local-mcp-server/usage-collector.js +2 -0
- package/dist/src/mcp/tool-schemas.js +5 -1
- package/dist/src/services/mcp-service.js +6 -4
- 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);
|
|
@@ -65,6 +65,9 @@ class AIMentor {
|
|
|
65
65
|
const unique = Array.from(new Set(matches));
|
|
66
66
|
throw new Error(`Unresolved include directives in ${context}: ${unique.join(', ')}`);
|
|
67
67
|
}
|
|
68
|
+
buildPhaseArrivalInstruction(jobName, phaseId) {
|
|
69
|
+
return `## On Arrival\nBefore starting any work in this phase, call \`seekMentoring({ jobName: "${jobName}", issueNumber: "<issue_number>", currentPhase: "${phaseId}", status: "starting" })\`.\n\n`;
|
|
70
|
+
}
|
|
68
71
|
buildReportBackFooter(jobName, phaseId, phaseFlow) {
|
|
69
72
|
if (!phaseFlow)
|
|
70
73
|
return '';
|
|
@@ -99,6 +102,9 @@ class AIMentor {
|
|
|
99
102
|
getCompactPhaseAuthority() {
|
|
100
103
|
return AIMentor.COMPACT_PHASE_AUTHORITY;
|
|
101
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
|
+
}
|
|
102
108
|
buildPhasedJobOverview(workflow) {
|
|
103
109
|
const metadataPhases = Object.keys(workflow.metadata.phases || {});
|
|
104
110
|
const initialPhase = workflow.metadata.initialPhase || metadataPhases[0] || Array.from(workflow.phases.keys())[0] || 'starting';
|
|
@@ -120,9 +126,10 @@ class AIMentor {
|
|
|
120
126
|
+ ' `get_fraim_file({ path: "skills/engineering/set-up-workspace.md" })`'
|
|
121
127
|
+ ' to work on the issue\'s branch when a repository is configured, or in place otherwise.');
|
|
122
128
|
}
|
|
129
|
+
lines.push('', this.getEvidenceInitialization());
|
|
123
130
|
return lines.join('\n');
|
|
124
131
|
}
|
|
125
|
-
async generateStartingMessage(workflow, phaseId, skipIncludes) {
|
|
132
|
+
async generateStartingMessage(workflow, phaseId, skipIncludes, requestInstructions) {
|
|
126
133
|
const entityType = 'Job';
|
|
127
134
|
if (workflow.isSimple) {
|
|
128
135
|
const message = `Starting ${entityType}: ${workflow.metadata.name}\n\n${workflow.overview}`;
|
|
@@ -134,26 +141,42 @@ class AIMentor {
|
|
|
134
141
|
};
|
|
135
142
|
}
|
|
136
143
|
const isVeryFirstCall = phaseId === 'starting';
|
|
137
|
-
|
|
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;
|
|
138
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;
|
|
139
153
|
if ((0, resolve_phase_edge_1.isRecurrencePhase)(targetDefinition)) {
|
|
140
154
|
targetPhase = isVeryFirstCall ? targetDefinition.setup.phase : targetDefinition.recur.phase;
|
|
141
155
|
}
|
|
142
156
|
let message = `### Current Phase: ${targetPhase}\n\n`;
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
instructions = skipIncludes ? instructions : await this.resolveIncludes(instructions, workflow.path);
|
|
146
|
-
message += instructions;
|
|
157
|
+
if (isVeryFirstCall) {
|
|
158
|
+
message += this.buildPhaseArrivalInstruction(workflow.metadata.name, targetPhase);
|
|
147
159
|
}
|
|
148
|
-
|
|
149
|
-
|
|
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
|
+
}
|
|
150
173
|
}
|
|
151
174
|
const recurrenceMember = (0, resolve_phase_edge_1.findRecurrenceForMember)(workflow.metadata.phases, targetPhase);
|
|
152
175
|
const phaseFlow = recurrenceMember
|
|
153
176
|
? { onSuccess: undefined, onFailure: recurrenceMember.node[recurrenceMember.role].onFailure }
|
|
154
177
|
: ((0, resolve_phase_edge_1.isRecurrencePhase)(workflow.metadata.phases?.[targetPhase]) ? undefined : workflow.metadata.phases?.[targetPhase]);
|
|
155
178
|
message += this.buildReportBackFooter(workflow.metadata.name, targetPhase, phaseFlow);
|
|
156
|
-
if (!skipIncludes) {
|
|
179
|
+
if (deliverInstructions && !skipIncludes) {
|
|
157
180
|
this.assertNoUnresolvedIncludes(message, `${workflow.metadata.name}:${targetPhase} (starting)`);
|
|
158
181
|
}
|
|
159
182
|
return {
|
|
@@ -229,6 +252,7 @@ class AIMentor {
|
|
|
229
252
|
}
|
|
230
253
|
else if (nextPhaseId) {
|
|
231
254
|
message += `Moving to the next phase: **${nextPhaseId}**.\n\n`;
|
|
255
|
+
message += this.buildPhaseArrivalInstruction(workflow.metadata.name, nextPhaseId);
|
|
232
256
|
let nextInstructions = workflow.phases.get(nextPhaseId);
|
|
233
257
|
if (nextInstructions) {
|
|
234
258
|
nextInstructions = skipIncludes ? nextInstructions : await this.resolveIncludes(nextInstructions, workflow.path);
|
|
@@ -275,6 +299,13 @@ class AIMentor {
|
|
|
275
299
|
?? (!(0, resolve_phase_edge_1.isRecurrencePhase)(phaseMeta) ? phaseMeta?.onFailure : undefined), (0, resolve_phase_edge_1.resolveDiscriminant)(findings, evidence)) || phaseId)
|
|
276
300
|
: phaseId;
|
|
277
301
|
let message = `### Current Phase: ${targetPhaseId}\n\n`;
|
|
302
|
+
// Issue #1935: a `failure` commits a completion for the phase being left, so
|
|
303
|
+
// the phase resolved above is entered with no open instance and needs its own
|
|
304
|
+
// `status: "starting"` call, for a back-edge and for the self-retry alike.
|
|
305
|
+
// `incomplete` commits nothing and never leaves the phase, so it gets none.
|
|
306
|
+
if (status === 'failure') {
|
|
307
|
+
message += this.buildPhaseArrivalInstruction(workflow.metadata.name, targetPhaseId);
|
|
308
|
+
}
|
|
278
309
|
let instructions = workflow.phases.get(targetPhaseId);
|
|
279
310
|
if (instructions) {
|
|
280
311
|
instructions = skipIncludes ? instructions : await this.resolveIncludes(instructions, workflow.path);
|
|
@@ -329,7 +360,7 @@ class AIMentor {
|
|
|
329
360
|
if (!job)
|
|
330
361
|
return null;
|
|
331
362
|
if (job.isSimple) {
|
|
332
|
-
return { overview: job.overview
|
|
363
|
+
return { overview: `${job.overview}\n\n${this.getEvidenceInitialization()}`, isSimple: true };
|
|
333
364
|
}
|
|
334
365
|
return {
|
|
335
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",
|
|
@@ -297,11 +297,16 @@ function upsertPhaseStarted(marker, phaseId, timestamp) {
|
|
|
297
297
|
startedPhases[phaseId] = timestamp;
|
|
298
298
|
return { startedPhases };
|
|
299
299
|
}
|
|
300
|
-
/** Builds the warn-only notice for any phase completed without an on-arrival start.
|
|
301
|
-
|
|
302
|
-
|
|
300
|
+
/** Builds the warn-only notice for any phase completed without an on-arrival start.
|
|
301
|
+
* When nextPhase is supplied, a forward-looking instruction is appended so the agent
|
|
302
|
+
* knows to call seekMentoring({status:"starting"}) before beginning the next phase. */
|
|
303
|
+
function buildPhaseStartWarning(phaseId, nextPhase) {
|
|
304
|
+
const base = (`⚠️ **Phase-start notice**: phase \`${phaseId}\` completed without an on-arrival ` +
|
|
303
305
|
`\`seekMentoring({ currentPhase: "${phaseId}", status: "starting" })\` call in this session. ` +
|
|
304
306
|
`The missing call was flagged, but this completion remains accepted.\n\n`);
|
|
307
|
+
if (!nextPhase)
|
|
308
|
+
return base;
|
|
309
|
+
return base + `Before starting any work in **${nextPhase}**, call \`seekMentoring({ currentPhase: "${nextPhase}", status: "starting" })\` immediately.\n\n`;
|
|
305
310
|
}
|
|
306
311
|
function isAbsoluteHttpUrl(url) {
|
|
307
312
|
if (typeof url !== 'string' || !url)
|
|
@@ -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
|
}
|