fraim 2.0.360-next.2 → 2.0.360
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/src/core/ai-mentor.js +39 -3
- package/dist/src/core/handoff-contracts.js +28 -2
- package/dist/src/core/review-lifecycle.js +199 -0
- package/dist/src/core/run-recovery-state.js +21 -0
- package/dist/src/local-mcp-server/artifact-retention-cleanup.js +112 -1
- package/dist/src/local-mcp-server/stdio-server.js +164 -11
- package/dist/src/mcp/tool-schemas.js +1 -1
- package/dist/src/services/mcp-service.js +16 -1
- package/package.json +1 -1
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
"use strict";
|
|
2
2
|
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
3
|
exports.AIMentor = void 0;
|
|
4
|
+
const crypto_1 = require("crypto");
|
|
4
5
|
const include_resolver_1 = require("./utils/include-resolver");
|
|
5
6
|
const resolve_phase_edge_1 = require("./resolve-phase-edge");
|
|
6
7
|
class AIMentor {
|
|
@@ -31,18 +32,53 @@ class AIMentor {
|
|
|
31
32
|
throw new Error(`Phase "${args.currentPhase}" not found in job "${args.jobName}".`);
|
|
32
33
|
}
|
|
33
34
|
}
|
|
35
|
+
let response;
|
|
34
36
|
if (args.status === 'starting') {
|
|
35
|
-
|
|
37
|
+
response = await this.generateStartingMessage(workflow, args.currentPhase, args.skipIncludes, args.requestInstructions);
|
|
36
38
|
}
|
|
37
39
|
else if (args.status === 'complete') {
|
|
38
|
-
|
|
40
|
+
response = await this.generateCompletionMessage(workflow, args.currentPhase, args.findings, args.evidence, args.skipIncludes);
|
|
39
41
|
}
|
|
40
42
|
else {
|
|
41
43
|
// Issue #1123: findings/evidence must reach the failure path too. The
|
|
42
44
|
// MCP layer has always forwarded them for every status; only this hop
|
|
43
45
|
// dropped them, which is why a failure edge could not be discriminated.
|
|
44
|
-
|
|
46
|
+
response = await this.generateHelpMessage(workflow, args.currentPhase, args.status, args.skipIncludes, args.findings, args.evidence);
|
|
45
47
|
}
|
|
48
|
+
response.workflowDigest = (0, crypto_1.createHash)('sha256').update(JSON.stringify({ metadata: workflow.metadata, phases: Array.from(workflow.phases.entries()), overview: workflow.overview })).digest('hex');
|
|
49
|
+
const phases = workflow.metadata.phases;
|
|
50
|
+
const member = (0, resolve_phase_edge_1.findRecurrenceForMember)(phases, args.currentPhase);
|
|
51
|
+
const definition = phases?.[args.currentPhase];
|
|
52
|
+
const edge = args.status === 'complete' && !(0, resolve_phase_edge_1.isRecurrencePhase)(definition) ? definition?.onSuccess
|
|
53
|
+
: args.status === 'failure' ? member?.node[member.role].onFailure ?? (!(0, resolve_phase_edge_1.isRecurrencePhase)(definition) ? definition?.onFailure : undefined) : undefined;
|
|
54
|
+
if (edge != null && !(0, resolve_phase_edge_1.resolvePhaseEdge)(edge, (0, resolve_phase_edge_1.resolveDiscriminant)(args.findings, args.evidence))) {
|
|
55
|
+
response.outcome = { kind: 'invalid', reason: 'The workflow edge has no matching valid target.' };
|
|
56
|
+
}
|
|
57
|
+
else if (response.nextPhase === resolve_phase_edge_1.WAITING_FOR_NEXT_INVOCATION) {
|
|
58
|
+
response.outcome = { kind: 'recurring_wait' };
|
|
59
|
+
}
|
|
60
|
+
else if (response.nextPhase === resolve_phase_edge_1.AWAITING_RECURRING_REVIEW) {
|
|
61
|
+
response.outcome = { kind: 'review_wait' };
|
|
62
|
+
}
|
|
63
|
+
else if (response.nextPhase && !workflow.isSimple && !phases?.[response.nextPhase]
|
|
64
|
+
&& !workflow.phases.has(response.nextPhase) && !(0, resolve_phase_edge_1.findRecurrenceForMember)(phases, response.nextPhase)) {
|
|
65
|
+
response.outcome = { kind: 'invalid', reason: `Workflow target ${response.nextPhase} does not exist.` };
|
|
66
|
+
}
|
|
67
|
+
else if (response.nextPhase) {
|
|
68
|
+
response.outcome = { kind: 'next', phaseId: response.nextPhase };
|
|
69
|
+
}
|
|
70
|
+
else if (args.status === 'complete' && (workflow.isSimple || definition || member?.role === 'done')) {
|
|
71
|
+
response.outcome = { kind: 'terminal' };
|
|
72
|
+
}
|
|
73
|
+
else if (workflow.isSimple) {
|
|
74
|
+
response.outcome = { kind: 'next', phaseId: args.currentPhase };
|
|
75
|
+
}
|
|
76
|
+
else {
|
|
77
|
+
response.outcome = { kind: 'invalid', reason: 'Only a declared workflow leaf may terminate execution.' };
|
|
78
|
+
}
|
|
79
|
+
if (response.outcome.kind === 'invalid')
|
|
80
|
+
response.message = `Phase transition rejected: ${response.outcome.reason}`;
|
|
81
|
+
return response;
|
|
46
82
|
}
|
|
47
83
|
async getOrLoadJob(jobType) {
|
|
48
84
|
if (this.jobCache.has(jobType)) {
|
|
@@ -7,7 +7,7 @@
|
|
|
7
7
|
*
|
|
8
8
|
* - evidence.reviewHandoff (job's submission phase, status === "complete";
|
|
9
9
|
* see isSubmitPhase for how that phase is found)
|
|
10
|
-
* - evidence.nextJobRecommendations (
|
|
10
|
+
* - evidence.nextJobRecommendations (optional at submission, or completion for jobs without submission)
|
|
11
11
|
* - evidence.delegationLedger (create-delegation-graph phase)
|
|
12
12
|
*
|
|
13
13
|
* Lives in src/core so both the local MCP proxy and evals can import the same
|
|
@@ -507,6 +507,28 @@ function validateNextJobRecommendations(value) {
|
|
|
507
507
|
if (typeof rec.contextSummary === 'string' && rec.contextSummary.length > 300) {
|
|
508
508
|
errors.push(`evidence.nextJobRecommendations[${i}].contextSummary must be 300 characters or fewer`);
|
|
509
509
|
}
|
|
510
|
+
if (rec.artifactIds !== undefined && (!Array.isArray(rec.artifactIds)
|
|
511
|
+
|| rec.artifactIds.some(id => typeof id !== 'string' || !id.trim()))) {
|
|
512
|
+
errors.push(`evidence.nextJobRecommendations[${i}].artifactIds must be an array of non-empty artifact references`);
|
|
513
|
+
}
|
|
514
|
+
if (rec.prerequisites !== undefined) {
|
|
515
|
+
const kinds = new Set(['approved_artifact', 'delivered_artifact', 'selected_action_success', 'required_child_success', 'external_result']);
|
|
516
|
+
if (!Array.isArray(rec.prerequisites)) {
|
|
517
|
+
errors.push(`evidence.nextJobRecommendations[${i}].prerequisites must be an array`);
|
|
518
|
+
}
|
|
519
|
+
else {
|
|
520
|
+
rec.prerequisites.forEach((value, index) => {
|
|
521
|
+
const prerequisite = value && typeof value === 'object' && !Array.isArray(value) ? value : null;
|
|
522
|
+
if (!prerequisite || typeof prerequisite.kind !== 'string' || !kinds.has(prerequisite.kind)
|
|
523
|
+
|| typeof prerequisite.reference !== 'string' || !prerequisite.reference.trim()) {
|
|
524
|
+
errors.push(`evidence.nextJobRecommendations[${i}].prerequisites[${index}] requires a supported kind and non-empty reference`);
|
|
525
|
+
}
|
|
526
|
+
});
|
|
527
|
+
}
|
|
528
|
+
}
|
|
529
|
+
if (['ready', 'readiness', 'readinessReason', 'candidateId', 'revisionId', 'sourceRunId', 'sourceConversationId', 'startedRunId'].some(key => key in rec)) {
|
|
530
|
+
errors.push(`evidence.nextJobRecommendations[${i}] must not claim readiness or server-owned revision/source/start identities; provide prerequisites and artifactIds instead`);
|
|
531
|
+
}
|
|
510
532
|
});
|
|
511
533
|
return errors.length > 0 ? errors : null;
|
|
512
534
|
}
|
|
@@ -657,7 +679,11 @@ evidence: {
|
|
|
657
679
|
jobId: "<job-slug>",
|
|
658
680
|
label: "<human-readable label>",
|
|
659
681
|
reason: "<optional: why this job is recommended>",
|
|
660
|
-
contextSummary: "<optional: one sentence context for the next agent>"
|
|
682
|
+
contextSummary: "<optional: one sentence context for the next agent>",
|
|
683
|
+
artifactIds: ["<optional: reference from the reviewed artifact manifest>"],
|
|
684
|
+
prerequisites: [{ kind: "approved_artifact", reference: "<artifact reference>" }]
|
|
685
|
+
// Other kinds: delivered_artifact, selected_action_success, required_child_success, external_result.
|
|
686
|
+
// Omit optional dependencies when none apply. Never emit readiness or server-owned identities.
|
|
661
687
|
}
|
|
662
688
|
// 0–3 entries; an empty array [] is valid
|
|
663
689
|
]
|
|
@@ -0,0 +1,199 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
exports.reviewDigest = reviewDigest;
|
|
4
|
+
exports.createReviewLifecycle = createReviewLifecycle;
|
|
5
|
+
exports.activateReviewJob = activateReviewJob;
|
|
6
|
+
exports.activeReview = activeReview;
|
|
7
|
+
exports.currentReviewTerminalReceipt = currentReviewTerminalReceipt;
|
|
8
|
+
exports.obligationDigest = obligationDigest;
|
|
9
|
+
exports.submitReviewRevision = submitReviewRevision;
|
|
10
|
+
exports.decideReviewRevision = decideReviewRevision;
|
|
11
|
+
exports.acceptReviewReceipt = acceptReviewReceipt;
|
|
12
|
+
exports.unsupportedReviewDeliveryActions = unsupportedReviewDeliveryActions;
|
|
13
|
+
exports.projectReviewLifecycle = projectReviewLifecycle;
|
|
14
|
+
exports.normalizeReviewCandidates = normalizeReviewCandidates;
|
|
15
|
+
exports.candidateReadiness = candidateReadiness;
|
|
16
|
+
const crypto_1 = require("crypto");
|
|
17
|
+
function reviewDigest(value) {
|
|
18
|
+
const canonical = (v) => Array.isArray(v) ? v.map(canonical)
|
|
19
|
+
: v && typeof v === 'object' ? Object.fromEntries(Object.entries(v).filter(([, x]) => x !== undefined).sort(([a], [b]) => a.localeCompare(b)).map(([k, x]) => [k, canonical(x)])) : v;
|
|
20
|
+
return (0, crypto_1.createHash)('sha256').update(JSON.stringify(canonical(value))).digest('hex');
|
|
21
|
+
}
|
|
22
|
+
function createReviewLifecycle(source) {
|
|
23
|
+
return { version: 1, sequence: 0, source, jobs: {}, reviewsByJobInstance: {}, operations: {}, consumedReceipts: [] };
|
|
24
|
+
}
|
|
25
|
+
function activateReviewJob(state, job) {
|
|
26
|
+
state.jobs[job.jobInstanceId] = { ...job, ...(state.jobs[job.jobInstanceId]?.manualClosure ? { manualClosure: state.jobs[job.jobInstanceId].manualClosure } : {}) };
|
|
27
|
+
state.activeJob = state.jobs[job.jobInstanceId];
|
|
28
|
+
}
|
|
29
|
+
function activeReview(state) {
|
|
30
|
+
return state?.activeJob ? state.reviewsByJobInstance[state.activeJob.jobInstanceId] : undefined;
|
|
31
|
+
}
|
|
32
|
+
function currentReviewTerminalReceipt(state) {
|
|
33
|
+
const job = state?.activeJob;
|
|
34
|
+
const review = activeReview(state);
|
|
35
|
+
const receipt = review?.terminalReceipt;
|
|
36
|
+
return receipt && job && receipt.sourceRunId === state?.source.runId && receipt.jobInstanceId === job.jobInstanceId
|
|
37
|
+
&& receipt.jobName === job.jobName && receipt.workflowDigest === review?.workflowDigest
|
|
38
|
+
&& receipt.sessionId === job.sessionId && receipt.executionEpoch === job.executionEpoch
|
|
39
|
+
&& receipt.revisionId === review?.revisionId && receipt.obligationSetDigest === obligationDigest(review.obligations)
|
|
40
|
+
? receipt : undefined;
|
|
41
|
+
}
|
|
42
|
+
function obligationDigest(obligations) {
|
|
43
|
+
// Verification outcomes change as delivery completes; membership and identity do not.
|
|
44
|
+
return reviewDigest(obligations.map(({ id, kind, subject, issueNumber, childRunId, childJobName, childJobInstanceId, childRevisionId }) => ({ id, kind, subject, issueNumber, childRunId, childJobName, childJobInstanceId, childRevisionId })).sort((a, b) => a.id.localeCompare(b.id)));
|
|
45
|
+
}
|
|
46
|
+
function submitReviewRevision(state, input, resubmission = false) {
|
|
47
|
+
const manifest = [...input.manifest].sort((a, b) => a.id.localeCompare(b.id));
|
|
48
|
+
const target = input.handoff.reviewTarget;
|
|
49
|
+
const revisionId = reviewDigest({ source: state.source, owner: input.jobInstanceId, jobName: input.jobName, workflowDigest: input.workflowDigest,
|
|
50
|
+
target: target ? { type: target.type, url: target.url || undefined } : undefined, manifest });
|
|
51
|
+
const old = state.reviewsByJobInstance[input.jobInstanceId];
|
|
52
|
+
const actionSetDigest = reviewDigest(input.handoff.reviewActions || []);
|
|
53
|
+
const candidates = input.candidates.map(c => {
|
|
54
|
+
const candidateId = reviewDigest({ revisionId, ...candidateIdentity(c) });
|
|
55
|
+
return { ...c, candidateId, revisionId, sourceRunId: state.source.runId, sourceConversationId: state.source.conversationId };
|
|
56
|
+
}).filter((c, index, all) => all.findIndex(other => other.candidateId === c.candidateId) === index).slice(0, 3);
|
|
57
|
+
if (old?.revisionId === revisionId && old.actionSetDigest === actionSetDigest) {
|
|
58
|
+
if (resubmission && old.decision?.status === 'changes_requested') {
|
|
59
|
+
old.history.push({ revisionId: old.revisionId, decision: old.decision, manifest: old.manifest, handoff: old.handoff });
|
|
60
|
+
delete old.decision;
|
|
61
|
+
delete old.terminalReceipt;
|
|
62
|
+
old.obligations = [];
|
|
63
|
+
}
|
|
64
|
+
old.handoff = input.handoff;
|
|
65
|
+
old.candidates = candidates.map(candidate => ({ ...candidate,
|
|
66
|
+
...(old.candidates.find(prior => prior.candidateId === candidate.candidateId)?.startedRunId
|
|
67
|
+
? { startedRunId: old.candidates.find(prior => prior.candidateId === candidate.candidateId).startedRunId } : {}),
|
|
68
|
+
}));
|
|
69
|
+
return old;
|
|
70
|
+
}
|
|
71
|
+
const review = {
|
|
72
|
+
jobInstanceId: input.jobInstanceId, jobName: input.jobName, workflowDigest: input.workflowDigest,
|
|
73
|
+
handoff: input.handoff, manifest, revisionId, actionSetDigest, candidates, obligations: [],
|
|
74
|
+
history: old ? [...old.history, { revisionId: old.revisionId, decision: old.decision, terminalReceipt: old.terminalReceipt, manifest: old.manifest, handoff: old.handoff }] : [],
|
|
75
|
+
};
|
|
76
|
+
state.reviewsByJobInstance[input.jobInstanceId] = review;
|
|
77
|
+
return review;
|
|
78
|
+
}
|
|
79
|
+
function decideReviewRevision(state, input, obligations = []) {
|
|
80
|
+
const review = activeReview(state);
|
|
81
|
+
if (state.activeJob?.manualClosure)
|
|
82
|
+
throw new Error('This job was closed manually. Start a new job to continue work.');
|
|
83
|
+
if (!review || review.revisionId !== input.revisionId || review.actionSetDigest !== input.actionSetDigest)
|
|
84
|
+
throw new Error('The reviewed result changed. Refresh before deciding.');
|
|
85
|
+
const action = review.handoff.reviewActions?.find(a => a.id === input.actionId);
|
|
86
|
+
if (!action || !['approve', 'approve_delivery', 'request_changes'].includes(action.kind) || !input.decisionId)
|
|
87
|
+
throw new Error('This review action is not available.');
|
|
88
|
+
const existingOperation = state.operations[input.decisionId];
|
|
89
|
+
if (existingOperation && (existingOperation.payloadDigest !== reviewDigest(input) || existingOperation.jobInstanceId !== review.jobInstanceId))
|
|
90
|
+
throw new Error('This decision identifier is already bound to another decision.');
|
|
91
|
+
if (existingOperation)
|
|
92
|
+
return { duplicate: true, review, operation: existingOperation };
|
|
93
|
+
if (review.decision) {
|
|
94
|
+
if (review.decision.decisionId === input.decisionId && review.decision.actionId === input.actionId)
|
|
95
|
+
return { duplicate: true, review, operation: state.operations[input.decisionId] };
|
|
96
|
+
throw new Error('This revision already has a review decision.');
|
|
97
|
+
}
|
|
98
|
+
review.decision = { ...input, status: action.kind === 'request_changes' ? 'changes_requested' : 'approved', at: new Date().toISOString(), action };
|
|
99
|
+
review.obligations = obligations;
|
|
100
|
+
delete review.terminalReceipt;
|
|
101
|
+
const operation = { id: input.decisionId, kind: 'approval', payloadDigest: reviewDigest(input), state: 'reserved', jobInstanceId: review.jobInstanceId, revisionId: review.revisionId };
|
|
102
|
+
state.operations[operation.id] = operation;
|
|
103
|
+
return { duplicate: false, review, operation };
|
|
104
|
+
}
|
|
105
|
+
function acceptReviewReceipt(state, receipt) {
|
|
106
|
+
if (state.consumedReceipts.includes(receipt.acceptanceId))
|
|
107
|
+
return false;
|
|
108
|
+
const review = state.reviewsByJobInstance[receipt.jobInstanceId];
|
|
109
|
+
const job = state.jobs[receipt.jobInstanceId];
|
|
110
|
+
if (!review || !job || receipt.sourceRunId !== state.source.runId || receipt.jobName !== review.jobName
|
|
111
|
+
|| receipt.sessionId !== job.sessionId || receipt.executionEpoch !== job.executionEpoch
|
|
112
|
+
|| receipt.workflowDigest !== review.workflowDigest || receipt.revisionId !== review.revisionId)
|
|
113
|
+
return false;
|
|
114
|
+
if (receipt.requestedStatus === 'complete' && receipt.outcome === 'terminal'
|
|
115
|
+
&& receipt.obligationSetDigest === obligationDigest(review.obligations)) {
|
|
116
|
+
review.terminalReceipt = receipt;
|
|
117
|
+
state.consumedReceipts.push(receipt.acceptanceId);
|
|
118
|
+
return true;
|
|
119
|
+
}
|
|
120
|
+
return false;
|
|
121
|
+
}
|
|
122
|
+
function unsupportedReviewDeliveryActions(subject) {
|
|
123
|
+
return subject.split('+').filter(action => !['merge_pr', 'merge_pr_work_completion', 'cleanup_branch'].includes(action));
|
|
124
|
+
}
|
|
125
|
+
function projectReviewLifecycle(state) {
|
|
126
|
+
const job = state.activeJob;
|
|
127
|
+
const review = activeReview(state);
|
|
128
|
+
const view = { version: 1, sequence: state.sequence, jobInstanceId: job?.jobInstanceId,
|
|
129
|
+
revisionId: review?.revisionId, actionSetDigest: review?.actionSetDigest,
|
|
130
|
+
decisionStatus: review ? review.decision?.status || 'pending' : undefined, displayState: 'working' };
|
|
131
|
+
if (job?.manualClosure)
|
|
132
|
+
return { ...view, displayState: 'closed_manually', attentionReason: 'Closed manually. Delivery and unresolved obligations remain unverified.' };
|
|
133
|
+
if (job?.state === 'error')
|
|
134
|
+
return { ...view, displayState: 'error', attentionReason: 'The active job needs recovery.' };
|
|
135
|
+
if (job?.state === 'scheduled')
|
|
136
|
+
return { ...view, displayState: 'scheduled' };
|
|
137
|
+
if (job?.state === 'waiting')
|
|
138
|
+
return { ...view, displayState: 'waiting', attentionReason: 'The active job needs a decision.' };
|
|
139
|
+
if (!review)
|
|
140
|
+
return { ...view, displayState: job?.state === 'complete' ? 'complete' : 'working' };
|
|
141
|
+
if (review.verificationError)
|
|
142
|
+
return { ...view, displayState: 'error', attentionReason: review.verificationError };
|
|
143
|
+
if (!review.decision || review.decision.status === 'changes_requested')
|
|
144
|
+
return { ...view, displayState: 'waiting', attentionReason: review.decision ? 'Changes requested.' : 'Review the current result.' };
|
|
145
|
+
if (review.obligations.some(o => o.state === 'failed'))
|
|
146
|
+
return { ...view, displayState: 'error', attentionReason: 'Required delivery failed.' };
|
|
147
|
+
if (review.terminalReceipt && review.terminalReceipt.sessionId === job?.sessionId
|
|
148
|
+
&& review.terminalReceipt.executionEpoch === job?.executionEpoch && review.terminalReceipt.revisionId === review.revisionId
|
|
149
|
+
&& review.terminalReceipt.obligationSetDigest === obligationDigest(review.obligations)
|
|
150
|
+
&& review.obligations.every(o => o.state === 'succeeded'))
|
|
151
|
+
return { ...view, displayState: 'complete' };
|
|
152
|
+
const unsupported = review.obligations.filter(o => o.kind === 'delivery' && o.state !== 'succeeded')
|
|
153
|
+
.flatMap(o => unsupportedReviewDeliveryActions(o.subject));
|
|
154
|
+
return { ...view, displayState: 'finalizing', ...(unsupported.length ? { attentionReason: `Cannot automatically verify ${[...new Set(unsupported)].join(', ')}. Mark complete can close the job manually without confirming delivery.` } : {}) };
|
|
155
|
+
}
|
|
156
|
+
function candidateIdentity(candidate) {
|
|
157
|
+
return { jobId: candidate.jobId,
|
|
158
|
+
prerequisites: [...new Set((candidate.prerequisites || []).map(p => JSON.stringify({ kind: p.kind, reference: p.reference })))].sort(),
|
|
159
|
+
artifactIds: [...new Set(candidate.artifactIds || [])].sort() };
|
|
160
|
+
}
|
|
161
|
+
function normalizeReviewCandidates(value) {
|
|
162
|
+
if (!Array.isArray(value))
|
|
163
|
+
return [];
|
|
164
|
+
const kinds = new Set(['approved_artifact', 'delivered_artifact', 'selected_action_success', 'required_child_success', 'external_result']);
|
|
165
|
+
return value.filter((c) => !!c && typeof c === 'object' && typeof c.jobId === 'string'
|
|
166
|
+
&& /^[a-z0-9]+(?:-[a-z0-9]+)*$/.test(c.jobId) && typeof c.label === 'string' && !!c.label.trim()
|
|
167
|
+
&& (c.artifactIds === undefined || Array.isArray(c.artifactIds) && c.artifactIds.every((x) => typeof x === 'string'))
|
|
168
|
+
&& (c.prerequisites === undefined || Array.isArray(c.prerequisites) && c.prerequisites.every((p) => p && kinds.has(p.kind) && typeof p.reference === 'string')))
|
|
169
|
+
.filter((c, index, all) => all.findIndex(other => reviewDigest(candidateIdentity(other)) === reviewDigest(candidateIdentity(c))) === index)
|
|
170
|
+
.slice(0, 3).map(c => ({ jobId: c.jobId, label: c.label, reason: typeof c.reason === 'string' ? c.reason : undefined,
|
|
171
|
+
contextSummary: typeof c.contextSummary === 'string' ? c.contextSummary : undefined, artifactIds: c.artifactIds, prerequisites: c.prerequisites }));
|
|
172
|
+
}
|
|
173
|
+
function candidateReadiness(review, candidate, available) {
|
|
174
|
+
if (!available)
|
|
175
|
+
return { readiness: 'unavailable', readinessReason: 'Unavailable in this project.' };
|
|
176
|
+
if (review.verificationError)
|
|
177
|
+
return { readiness: 'unavailable', readinessReason: review.verificationError };
|
|
178
|
+
if (review.decision?.status !== 'approved')
|
|
179
|
+
return { readiness: 'waiting', readinessReason: 'Waiting for approval of this result.' };
|
|
180
|
+
for (const p of candidate.prerequisites || []) {
|
|
181
|
+
const artifact = review.manifest.find(a => a.id === p.reference);
|
|
182
|
+
const obligation = review.obligations.find(o => o.id === p.reference || o.subject === p.reference);
|
|
183
|
+
const met = p.kind === 'approved_artifact' ? !!artifact
|
|
184
|
+
: p.kind === 'delivered_artifact' ? !!artifact
|
|
185
|
+
&& review.obligations.some(o => o.kind === 'artifact' && o.subject === artifact.id && o.state === 'succeeded')
|
|
186
|
+
// Matching local bytes prove integrity, not publication or receipt at a destination.
|
|
187
|
+
// Delivery remains unknown until the selected action's owning verifier supplies proof.
|
|
188
|
+
&& review.obligations.some(o => o.kind === 'delivery')
|
|
189
|
+
&& review.obligations.filter(o => o.kind === 'delivery').every(o => o.state === 'succeeded' && !!o.evidenceRef)
|
|
190
|
+
: p.kind === 'selected_action_success' ? obligation?.kind === 'delivery' && obligation.state === 'succeeded'
|
|
191
|
+
: p.kind === 'required_child_success' ? obligation?.kind === 'child' && obligation.state === 'succeeded'
|
|
192
|
+
: false; // External facts require an owning provider verifier; absence is never Ready.
|
|
193
|
+
if (!met)
|
|
194
|
+
return { readiness: 'waiting', readinessReason: `Waiting for ${p.kind.replace(/_/g, ' ')}: ${p.reference}.` };
|
|
195
|
+
}
|
|
196
|
+
if (candidate.artifactIds?.some(id => !review.manifest.some(a => a.id === id)))
|
|
197
|
+
return { readiness: 'unavailable', readinessReason: 'A referenced artifact is unavailable.' };
|
|
198
|
+
return { readiness: 'ready', readinessReason: 'Ready' };
|
|
199
|
+
}
|
|
@@ -1,7 +1,28 @@
|
|
|
1
1
|
"use strict";
|
|
2
2
|
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
exports.parseReviewExecutionBinding = parseReviewExecutionBinding;
|
|
3
4
|
exports.safeRunStateKey = safeRunStateKey;
|
|
4
5
|
const crypto_1 = require("crypto");
|
|
6
|
+
/** Supplied only by the executing Hub's launch environment, never tool evidence. */
|
|
7
|
+
function parseReviewExecutionBinding(value) {
|
|
8
|
+
if (!value)
|
|
9
|
+
return undefined;
|
|
10
|
+
let binding;
|
|
11
|
+
try {
|
|
12
|
+
binding = JSON.parse(value);
|
|
13
|
+
}
|
|
14
|
+
catch {
|
|
15
|
+
throw new Error('Invalid Hub review execution binding.');
|
|
16
|
+
}
|
|
17
|
+
const fields = ['sourceRunId', 'jobInstanceId', 'executionEpoch'];
|
|
18
|
+
if (!binding || fields.some(key => typeof binding[key] !== 'string' || !binding[key] || binding[key].length > 512)
|
|
19
|
+
|| ['revisionId', 'obligationSetDigest'].some(key => binding[key] !== undefined && (typeof binding[key] !== 'string' || !binding[key] || binding[key].length > 512))
|
|
20
|
+
|| Boolean(binding.revisionId) !== Boolean(binding.obligationSetDigest)
|
|
21
|
+
|| (binding.sessionId !== undefined && typeof binding.sessionId !== 'string')) {
|
|
22
|
+
throw new Error('Invalid Hub review execution binding.');
|
|
23
|
+
}
|
|
24
|
+
return binding;
|
|
25
|
+
}
|
|
5
26
|
/** UUIDs retain their documented filenames; other IDs cannot become paths or device names. */
|
|
6
27
|
function safeRunStateKey(id) {
|
|
7
28
|
return /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i.test(id)
|
|
@@ -16,6 +16,7 @@ const learning_source_record_1 = require("./learning-source-record");
|
|
|
16
16
|
const artifact_paths_1 = require("../../registry/scripts/artifact-paths");
|
|
17
17
|
const completion_state_1 = require("../../registry/scripts/learning/completion-state");
|
|
18
18
|
const pack_home_1 = require("../cli/utils/pack-home");
|
|
19
|
+
const review_lifecycle_1 = require("../core/review-lifecycle");
|
|
19
20
|
exports.ARTIFACT_RETENTION_CATEGORIES = [
|
|
20
21
|
'learning_archive',
|
|
21
22
|
'retrospectives',
|
|
@@ -32,6 +33,108 @@ exports.ARTIFACT_RETENTION_CATEGORIES = [
|
|
|
32
33
|
'learning_usage',
|
|
33
34
|
'hub_event_logs',
|
|
34
35
|
];
|
|
36
|
+
/** Read-only consumer of canonical conversation shard envelope v2 (not index headers).
|
|
37
|
+
* Keep this layout contract covered by store-written fixtures. The CLI package must
|
|
38
|
+
* not import Hub runtime or create another lifecycle writer/cache. Unknown or unreadable
|
|
39
|
+
* authority conservatively defers cleanup; a later healthy pass can apply retention.
|
|
40
|
+
*/
|
|
41
|
+
function activeReviewReferences() {
|
|
42
|
+
const paths = new Set();
|
|
43
|
+
const root = node_path_1.default.join((0, project_fraim_paths_1.getUserFraimDirPath)(), 'ai-hub-conversations');
|
|
44
|
+
try {
|
|
45
|
+
let rootStat;
|
|
46
|
+
try {
|
|
47
|
+
rootStat = node_fs_1.default.lstatSync(root);
|
|
48
|
+
}
|
|
49
|
+
catch (error) {
|
|
50
|
+
if (error.code === 'ENOENT')
|
|
51
|
+
return { paths, unknown: false };
|
|
52
|
+
throw error;
|
|
53
|
+
}
|
|
54
|
+
if (!rootStat.isDirectory() || rootStat.isSymbolicLink())
|
|
55
|
+
throw new Error('Unknown conversation root');
|
|
56
|
+
const records = [];
|
|
57
|
+
for (const bucket of node_fs_1.default.readdirSync(root, { withFileTypes: true })) {
|
|
58
|
+
if (!/^[a-f0-9]{16}$/.test(bucket.name))
|
|
59
|
+
continue;
|
|
60
|
+
if (!bucket.isDirectory() || bucket.isSymbolicLink())
|
|
61
|
+
throw new Error('Unreadable conversation bucket');
|
|
62
|
+
for (const file of node_fs_1.default.readdirSync(node_path_1.default.join(root, bucket.name), { withFileTypes: true })) {
|
|
63
|
+
if (!/^[a-f0-9]{40}\.json$/.test(file.name))
|
|
64
|
+
continue;
|
|
65
|
+
if (!file.isFile() || file.isSymbolicLink())
|
|
66
|
+
throw new Error('Unreadable conversation shard');
|
|
67
|
+
const envelope = JSON.parse(node_fs_1.default.readFileSync(node_path_1.default.join(root, bucket.name, file.name), 'utf8'));
|
|
68
|
+
if (envelope.version !== 2 || typeof envelope.conversation?.id !== 'string')
|
|
69
|
+
throw new Error('Unknown conversation schema');
|
|
70
|
+
records.push({ bucket: bucket.name, record: envelope.conversation });
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
const complete = (record) => {
|
|
74
|
+
const state = record.reviewLifecycle;
|
|
75
|
+
if (state && state.version !== 1)
|
|
76
|
+
throw new Error('Unknown review version');
|
|
77
|
+
return state ? (0, review_lifecycle_1.projectReviewLifecycle)(state).displayState === 'complete'
|
|
78
|
+
: record.status === 'completed' && record.pauseReason === 'done';
|
|
79
|
+
};
|
|
80
|
+
const protect = (manifest, home) => {
|
|
81
|
+
if (!Array.isArray(manifest) || !home || !node_path_1.default.isAbsolute(home))
|
|
82
|
+
throw new Error('Unresolved input references');
|
|
83
|
+
for (const artifact of manifest) {
|
|
84
|
+
if (typeof artifact.locator !== 'string' || !artifact.locator)
|
|
85
|
+
throw new Error('Unresolved artifact');
|
|
86
|
+
if (/^https?:\/\//i.test(artifact.locator))
|
|
87
|
+
continue;
|
|
88
|
+
const target = node_path_1.default.resolve(home, artifact.locator);
|
|
89
|
+
paths.add(pathKey(target));
|
|
90
|
+
if (node_fs_1.default.existsSync(target))
|
|
91
|
+
paths.add(pathKey(node_fs_1.default.realpathSync(target)));
|
|
92
|
+
}
|
|
93
|
+
};
|
|
94
|
+
for (const { bucket, record } of records) {
|
|
95
|
+
if (record.recommendationInput && !complete(record))
|
|
96
|
+
protect(record.recommendationInput.manifest, record.projectPath);
|
|
97
|
+
const state = record.reviewLifecycle;
|
|
98
|
+
if (!state)
|
|
99
|
+
continue;
|
|
100
|
+
if (state.version !== 1)
|
|
101
|
+
throw new Error('Unknown review version');
|
|
102
|
+
for (const review of Object.values(state.reviewsByJobInstance)) {
|
|
103
|
+
const job = state.jobs[review.jobInstanceId];
|
|
104
|
+
if (!job || (0, review_lifecycle_1.projectReviewLifecycle)({ ...state, activeJob: job }).displayState !== 'complete')
|
|
105
|
+
protect(review.manifest, state.source.projectPath);
|
|
106
|
+
}
|
|
107
|
+
for (const operation of Object.values(state.operations)) {
|
|
108
|
+
if (operation.kind !== 'recommendation')
|
|
109
|
+
continue;
|
|
110
|
+
const child = records.find(entry => entry.bucket === bucket && entry.record.id === operation.runId)?.record;
|
|
111
|
+
if (child && complete(child))
|
|
112
|
+
continue;
|
|
113
|
+
const review = state.reviewsByJobInstance[operation.jobInstanceId];
|
|
114
|
+
const manifest = operation.input?.manifest || (review?.revisionId === operation.revisionId
|
|
115
|
+
? review.manifest : review?.history.find(previous => previous.revisionId === operation.revisionId)?.manifest);
|
|
116
|
+
protect(manifest, state.source.projectPath);
|
|
117
|
+
}
|
|
118
|
+
}
|
|
119
|
+
return { paths, unknown: false };
|
|
120
|
+
}
|
|
121
|
+
catch {
|
|
122
|
+
return { paths, unknown: true };
|
|
123
|
+
}
|
|
124
|
+
}
|
|
125
|
+
function reviewRetentionReason(candidate, references) {
|
|
126
|
+
if (references.unknown)
|
|
127
|
+
return 'Review input references are unavailable; retained until authority can be verified.';
|
|
128
|
+
try {
|
|
129
|
+
if (references.paths.has(pathKey(candidate.absolutePath))
|
|
130
|
+
|| references.paths.has(pathKey(node_fs_1.default.realpathSync(candidate.absolutePath))))
|
|
131
|
+
return 'Active review or dependent assignment still requires this artifact.';
|
|
132
|
+
}
|
|
133
|
+
catch {
|
|
134
|
+
return 'Artifact location changed during cleanup; retained for a later pass.';
|
|
135
|
+
}
|
|
136
|
+
return undefined;
|
|
137
|
+
}
|
|
35
138
|
function contentRoot(workspaceRoot) {
|
|
36
139
|
const packContent = node_path_1.default.join(workspaceRoot, 'personalized-employee');
|
|
37
140
|
return node_fs_1.default.existsSync(packContent) ? packContent : node_path_1.default.join(workspaceRoot, 'fraim', 'personalized-employee');
|
|
@@ -424,6 +527,7 @@ function runArtifactCleanup(workspaceRoot, options) {
|
|
|
424
527
|
const now = options.now ?? new Date();
|
|
425
528
|
const retention = resolveArtifactRetentionConfig(workspaceRoot);
|
|
426
529
|
const candidates = collectCandidates(workspaceRoot, options);
|
|
530
|
+
const reviewReferences = activeReviewReferences();
|
|
427
531
|
const report = {
|
|
428
532
|
generatedAt: now.toISOString(),
|
|
429
533
|
apply: options.apply,
|
|
@@ -438,6 +542,9 @@ function runArtifactCleanup(workspaceRoot, options) {
|
|
|
438
542
|
? categoryValue === -1 || retention.values.feedback === -1 ? -1 : Math.max(categoryValue, retention.values.feedback)
|
|
439
543
|
: categoryValue;
|
|
440
544
|
let decision = shouldDelete(candidate, retentionValue, now);
|
|
545
|
+
const protectedReason = reviewRetentionReason(candidate, reviewReferences);
|
|
546
|
+
if (protectedReason)
|
|
547
|
+
decision = { delete: false, reason: protectedReason };
|
|
441
548
|
let action;
|
|
442
549
|
let error;
|
|
443
550
|
if (!candidate.eligible) {
|
|
@@ -451,6 +558,10 @@ function runArtifactCleanup(workspaceRoot, options) {
|
|
|
451
558
|
}
|
|
452
559
|
else {
|
|
453
560
|
try {
|
|
561
|
+
// A review or dependent assignment may have become active since planning.
|
|
562
|
+
const currentProtection = reviewRetentionReason(candidate, activeReviewReferences());
|
|
563
|
+
if (currentProtection)
|
|
564
|
+
decision = { delete: false, reason: currentProtection };
|
|
454
565
|
if (candidate.recordHash && (!matchesConfiguredLocation(workspaceRoot, candidate) || !isConfinedRegularFile(workspaceRoot, candidate.absolutePath)
|
|
455
566
|
|| recordHash(node_fs_1.default.readFileSync(candidate.absolutePath, 'utf8')) !== candidate.recordHash
|
|
456
567
|
|| reservedSources(workspaceRoot).has(pathKey(candidate.absolutePath)))) {
|
|
@@ -468,7 +579,7 @@ function runArtifactCleanup(workspaceRoot, options) {
|
|
|
468
579
|
|| current.sidecarPath !== candidate.sidecarPath) {
|
|
469
580
|
decision = { delete: false, reason: 'Archive changed or resumed after enumeration; retained for a later cleanup.' };
|
|
470
581
|
}
|
|
471
|
-
else {
|
|
582
|
+
else if (!currentProtection) {
|
|
472
583
|
decision = shouldDelete({ ...candidate, ...current }, retentionValue, now);
|
|
473
584
|
}
|
|
474
585
|
}
|
|
@@ -1277,6 +1277,13 @@ class FraimLocalMCPServer {
|
|
|
1277
1277
|
const temporary = `${path}.${(0, crypto_1.randomUUID)()}.tmp`;
|
|
1278
1278
|
try {
|
|
1279
1279
|
(0, fs_1.writeFileSync)(temporary, JSON.stringify(value), { encoding: 'utf8', mode: 0o600 });
|
|
1280
|
+
const descriptor = (0, fs_1.openSync)(temporary, 'r+');
|
|
1281
|
+
try {
|
|
1282
|
+
(0, fs_1.fsyncSync)(descriptor);
|
|
1283
|
+
}
|
|
1284
|
+
finally {
|
|
1285
|
+
(0, fs_1.closeSync)(descriptor);
|
|
1286
|
+
}
|
|
1280
1287
|
(0, fs_1.renameSync)(temporary, path);
|
|
1281
1288
|
}
|
|
1282
1289
|
finally {
|
|
@@ -1301,6 +1308,9 @@ class FraimLocalMCPServer {
|
|
|
1301
1308
|
return;
|
|
1302
1309
|
try {
|
|
1303
1310
|
const root = this.getRunStateDir();
|
|
1311
|
+
const hubRunId = process.env.FRAIM_HUB_RUN_ID;
|
|
1312
|
+
const runPath = hubRunId ? (0, path_1.join)(root, 'runs', `${(0, run_recovery_state_1.safeRunStateKey)(hubRunId)}.json`) : undefined;
|
|
1313
|
+
const previousPointer = runPath && (0, fs_1.existsSync)(runPath) ? JSON.parse((0, fs_1.readFileSync)(runPath, 'utf8')) : undefined;
|
|
1304
1314
|
const jobId = typeof args.jobId === 'string' && args.jobId ? args.jobId : undefined;
|
|
1305
1315
|
const stateFile = jobId ? `${(0, run_recovery_state_1.safeRunStateKey)(jobId)}.json`
|
|
1306
1316
|
: `anonymous-${(0, run_recovery_state_1.safeRunStateKey)(JSON.stringify([sessionId, args.jobName]))}.json`;
|
|
@@ -1338,13 +1348,27 @@ class FraimLocalMCPServer {
|
|
|
1338
1348
|
}
|
|
1339
1349
|
const timestamp = new Date().toISOString();
|
|
1340
1350
|
const accepted = acceptedNext !== undefined;
|
|
1351
|
+
const acceptedReceipts = previous?.acceptedReceipts || [];
|
|
1352
|
+
let receipt = acceptedNext?.receipt;
|
|
1353
|
+
let replay = false;
|
|
1354
|
+
if (receipt) {
|
|
1355
|
+
const existing = acceptedReceipts.find(item => item.acceptanceId === receipt.acceptanceId);
|
|
1356
|
+
if (existing) {
|
|
1357
|
+
receipt = existing;
|
|
1358
|
+
replay = true;
|
|
1359
|
+
}
|
|
1360
|
+
else {
|
|
1361
|
+
receipt = { ...receipt, acceptedAt: timestamp, sequence: Math.max(previousPointer?.sequence || 0, acceptedReceipts[acceptedReceipts.length - 1]?.sequence || 0) + 1 };
|
|
1362
|
+
acceptedReceipts.push(receipt);
|
|
1363
|
+
}
|
|
1364
|
+
}
|
|
1341
1365
|
const recoveringTerminalReview = previous?.status === 'waiting_review' && previous.currentPhase === null
|
|
1342
1366
|
&& (args.status === 'starting' || args.status === 'incomplete');
|
|
1343
1367
|
const currentPhase = recoveringTerminalReview ? null : accepted
|
|
1344
1368
|
? (args.status === 'complete' ? acceptedNext.nextPhase : acceptedNext.nextPhase || args.currentPhase)
|
|
1345
1369
|
: previous ? previous.currentPhase : args.currentPhase;
|
|
1346
1370
|
const completedPhases = previous?.completedPhases || [];
|
|
1347
|
-
if (accepted && (args.status === 'complete' || args.status === 'failure')) {
|
|
1371
|
+
if (accepted && !replay && (args.status === 'complete' || args.status === 'failure')) {
|
|
1348
1372
|
completedPhases.push({ phase: args.currentPhase, status: args.status, timestamp });
|
|
1349
1373
|
}
|
|
1350
1374
|
const parsedHandoff = [args.reviewHandoff, args.evidence?.reviewHandoff, args.findings?.reviewHandoff]
|
|
@@ -1372,24 +1396,111 @@ class FraimLocalMCPServer {
|
|
|
1372
1396
|
startedPhases: accepted && args.status === 'starting'
|
|
1373
1397
|
? { ...previous?.startedPhases, [currentPhase || args.currentPhase]: timestamp }
|
|
1374
1398
|
: previous?.startedPhases || {},
|
|
1375
|
-
completedPhases,
|
|
1399
|
+
completedPhases, acceptedReceipts,
|
|
1400
|
+
executionBinding: receipt?.revisionId ? {
|
|
1401
|
+
sourceRunId: receipt.sourceRunId, jobInstanceId: receipt.jobInstanceId, executionEpoch: receipt.executionEpoch,
|
|
1402
|
+
sessionId: receipt.sessionId, revisionId: receipt.revisionId, obligationSetDigest: receipt.obligationSetDigest,
|
|
1403
|
+
} : previous?.executionBinding,
|
|
1376
1404
|
};
|
|
1377
|
-
this.writeRecoveryJson(statePath, state);
|
|
1378
1405
|
const pointer = { jobId, sessionId, stateFile };
|
|
1379
1406
|
this.writeRecoveryJson((0, path_1.join)(root, 'sessions', `${(0, run_recovery_state_1.safeRunStateKey)(sessionId)}.json`), pointer);
|
|
1380
|
-
|
|
1381
|
-
|
|
1382
|
-
this.writeRecoveryJson(
|
|
1407
|
+
if (runPath) {
|
|
1408
|
+
const instanceId = jobId || `anonymous:${sessionId}:${args.jobName}`;
|
|
1409
|
+
this.writeRecoveryJson(runPath, { ...pointer, sequence: Math.max(previousPointer?.sequence || 0, receipt?.sequence || 0), jobInstances: { ...previousPointer?.jobInstances, [instanceId]: pointer } });
|
|
1410
|
+
}
|
|
1411
|
+
// The receipt-bearing state is the commit point. Discovery writes may
|
|
1412
|
+
// leave a missing/stale state on failure, but never an accepted receipt.
|
|
1413
|
+
this.writeRecoveryJson(statePath, replay && previous ? previous : state);
|
|
1414
|
+
return receipt;
|
|
1383
1415
|
}
|
|
1384
1416
|
catch (error) {
|
|
1385
1417
|
this.log(`[phase-recovery] Could not persist recovery state: ${error.message}`);
|
|
1418
|
+
if (acceptedNext?.receipt && process.env.FRAIM_HUB_RUN_ID)
|
|
1419
|
+
throw new Error(`Accepted phase receipt could not be persisted: ${error.message}`);
|
|
1386
1420
|
}
|
|
1387
1421
|
}
|
|
1422
|
+
/** Created only after the response has passed finalization, from runtime authority. */
|
|
1423
|
+
persistAcceptedReceipt(args, sessionId, result, requestId) {
|
|
1424
|
+
if (args.status === 'starting' && args.requestInstructions === true)
|
|
1425
|
+
return undefined;
|
|
1426
|
+
if (!result.outcome || result.outcome.kind === 'invalid' || !result.workflowDigest)
|
|
1427
|
+
return undefined;
|
|
1428
|
+
const binding = (0, run_recovery_state_1.parseReviewExecutionBinding)(process.env.FRAIM_REVIEW_BINDING);
|
|
1429
|
+
const sourceRunId = binding?.sourceRunId || process.env.FRAIM_HUB_RUN_ID || '';
|
|
1430
|
+
const jobInstanceId = args.jobId || `anonymous:${sessionId}:${args.jobName}`;
|
|
1431
|
+
let ownBinding = binding?.jobInstanceId === jobInstanceId ? binding : undefined;
|
|
1432
|
+
if (!ownBinding && binding && process.env.FRAIM_REVIEW_BINDINGS) {
|
|
1433
|
+
let approved;
|
|
1434
|
+
try {
|
|
1435
|
+
approved = JSON.parse(process.env.FRAIM_REVIEW_BINDINGS);
|
|
1436
|
+
}
|
|
1437
|
+
catch {
|
|
1438
|
+
throw new Error('Invalid Hub approved review bindings.');
|
|
1439
|
+
}
|
|
1440
|
+
const selected = approved && Object.prototype.hasOwnProperty.call(approved, jobInstanceId) ? approved[jobInstanceId] : undefined;
|
|
1441
|
+
if (selected && typeof selected === 'object') {
|
|
1442
|
+
const prior = (0, run_recovery_state_1.parseReviewExecutionBinding)(JSON.stringify({ ...selected, executionEpoch: binding.executionEpoch }));
|
|
1443
|
+
if (prior && prior.jobInstanceId === jobInstanceId && prior.sourceRunId === binding.sourceRunId && prior.revisionId && prior.obligationSetDigest) {
|
|
1444
|
+
ownBinding = { sourceRunId: binding.sourceRunId, jobInstanceId, executionEpoch: binding.executionEpoch,
|
|
1445
|
+
revisionId: prior.revisionId, obligationSetDigest: prior.obligationSetDigest };
|
|
1446
|
+
}
|
|
1447
|
+
}
|
|
1448
|
+
}
|
|
1449
|
+
if (!ownBinding && binding && typeof args.jobId === 'string') {
|
|
1450
|
+
// A later job can return to an already approved instance in this source.
|
|
1451
|
+
// Recover only that instance's review identity; execution authority still
|
|
1452
|
+
// comes from the current host epoch and the current MCP session.
|
|
1453
|
+
try {
|
|
1454
|
+
const state = JSON.parse((0, fs_1.readFileSync)((0, path_1.join)(this.getRunStateDir(), `${(0, run_recovery_state_1.safeRunStateKey)(args.jobId)}.json`), 'utf8'));
|
|
1455
|
+
const prior = (0, run_recovery_state_1.parseReviewExecutionBinding)(JSON.stringify(state.executionBinding));
|
|
1456
|
+
if (prior && state.jobId === jobInstanceId && state.currentJobName === args.jobName && prior.jobInstanceId === jobInstanceId
|
|
1457
|
+
&& prior.sourceRunId === binding.sourceRunId && prior.revisionId && prior.obligationSetDigest) {
|
|
1458
|
+
ownBinding = { sourceRunId: binding.sourceRunId, jobInstanceId, executionEpoch: binding.executionEpoch,
|
|
1459
|
+
revisionId: prior.revisionId, obligationSetDigest: prior.obligationSetDigest };
|
|
1460
|
+
}
|
|
1461
|
+
}
|
|
1462
|
+
catch { /* Missing owner history cannot supply approval authority. */ }
|
|
1463
|
+
}
|
|
1464
|
+
if (ownBinding?.sessionId && ownBinding.sessionId !== sessionId)
|
|
1465
|
+
throw new Error('Review execution session does not match its Hub binding.');
|
|
1466
|
+
if (!sessionId || sessionId === 'unknown') {
|
|
1467
|
+
if (ownBinding)
|
|
1468
|
+
throw new Error('Review execution requires an identifiable MCP session.');
|
|
1469
|
+
return undefined;
|
|
1470
|
+
}
|
|
1471
|
+
const executionEpoch = binding?.executionEpoch || process.env.FRAIM_EXECUTION_EPOCH || sessionId;
|
|
1472
|
+
const acceptanceId = (0, crypto_1.createHash)('sha256').update(JSON.stringify([sourceRunId, jobInstanceId, sessionId, executionEpoch, requestId, args.currentPhase, args.status, result.workflowDigest, result.outcome, ownBinding?.revisionId, ownBinding?.obligationSetDigest, args.evidence, args.findings])).digest('hex');
|
|
1473
|
+
const parseStructured = (value) => {
|
|
1474
|
+
if (typeof value !== 'string')
|
|
1475
|
+
return value;
|
|
1476
|
+
try {
|
|
1477
|
+
return JSON.parse(value);
|
|
1478
|
+
}
|
|
1479
|
+
catch {
|
|
1480
|
+
return undefined;
|
|
1481
|
+
}
|
|
1482
|
+
};
|
|
1483
|
+
const handoff = parseStructured(args.evidence?.reviewHandoff ?? args.findings?.reviewHandoff ?? args.reviewHandoff);
|
|
1484
|
+
const recommendations = parseStructured(args.evidence?.nextJobRecommendations ?? args.findings?.nextJobRecommendations ?? args.nextJobRecommendations);
|
|
1485
|
+
const receipt = {
|
|
1486
|
+
version: 1, acceptanceId, acceptedAt: new Date().toISOString(), sequence: 0,
|
|
1487
|
+
sourceRunId, jobInstanceId, jobName: args.jobName, workflowDigest: result.workflowDigest,
|
|
1488
|
+
sessionId, executionEpoch, phaseId: args.currentPhase, requestedStatus: args.status,
|
|
1489
|
+
outcome: result.outcome.kind, ...(result.nextPhase ? { nextPhase: result.nextPhase } : {}),
|
|
1490
|
+
...(ownBinding ? { revisionId: ownBinding.revisionId, obligationSetDigest: ownBinding.obligationSetDigest } : {}),
|
|
1491
|
+
verifiedEvidenceRefs: [], ...(handoff ? { reviewHandoff: handoff } : {}),
|
|
1492
|
+
...(recommendations ? { nextJobRecommendations: recommendations } : {}),
|
|
1493
|
+
};
|
|
1494
|
+
if (Buffer.byteLength(JSON.stringify(receipt), 'utf8') > 262144)
|
|
1495
|
+
throw new Error('Accepted phase receipt exceeds the 256 KiB limit.');
|
|
1496
|
+
return this.recordRecoveryState(args, sessionId, { nextPhase: result.nextPhase, receipt });
|
|
1497
|
+
}
|
|
1388
1498
|
/** Called only after the response has passed every rejecting contract. */
|
|
1389
|
-
acceptPhaseTracking(args, sessionId, nextPhase) {
|
|
1499
|
+
acceptPhaseTracking(args, sessionId, nextPhase, recoveryRecorded = false) {
|
|
1390
1500
|
if (args.status === 'starting' && args.requestInstructions === true)
|
|
1391
1501
|
return '';
|
|
1392
|
-
|
|
1502
|
+
if (!recoveryRecorded)
|
|
1503
|
+
this.recordRecoveryState(args, sessionId, { nextPhase });
|
|
1393
1504
|
if (!sessionId || sessionId === 'unknown' || !args.jobName || !args.currentPhase) {
|
|
1394
1505
|
return args.status === 'complete' || args.status === 'failure'
|
|
1395
1506
|
? `Phase-start notice: phase ${args.currentPhase || '(unknown)'} cannot be matched to a run because its session or job identity is missing. Completion remains accepted.\n\n` : '';
|
|
@@ -2694,9 +2805,14 @@ class FraimLocalMCPServer {
|
|
|
2694
2805
|
};
|
|
2695
2806
|
}
|
|
2696
2807
|
}
|
|
2808
|
+
let localWorkflowResolved = false;
|
|
2697
2809
|
try {
|
|
2698
2810
|
const mentor = this.getMentor(requestSessionId);
|
|
2699
2811
|
const tutoringResponse = await mentor.handleMentoringRequest(args);
|
|
2812
|
+
localWorkflowResolved = true;
|
|
2813
|
+
if (tutoringResponse.outcome?.kind === 'invalid') {
|
|
2814
|
+
return { jsonrpc: '2.0', id: request.id, result: { isError: true, content: [{ type: 'text', text: tutoringResponse.message }] } };
|
|
2815
|
+
}
|
|
2700
2816
|
this.log(`✅ Local seekMentoring succeeded for ${args.jobName}:${args.currentPhase}`);
|
|
2701
2817
|
// Quality enforcement (Issue #251).
|
|
2702
2818
|
//
|
|
@@ -2785,7 +2901,11 @@ class FraimLocalMCPServer {
|
|
|
2785
2901
|
phases: handoffPhaseMap,
|
|
2786
2902
|
repo: handoffRepoState,
|
|
2787
2903
|
});
|
|
2788
|
-
|
|
2904
|
+
// Submission candidates are optional; the Hub filters unavailable jobs.
|
|
2905
|
+
// A malformed suggestion must not reject an otherwise valid review.
|
|
2906
|
+
if (!args.currentPhase.includes('submit') && !args.currentPhase.includes('submission')) {
|
|
2907
|
+
handoffErrors.push(...await this.validateNextJobRecommendationJobIds(args.evidence?.nextJobRecommendations, mentor));
|
|
2908
|
+
}
|
|
2789
2909
|
// Issue #1857: enforced on its own terms rather than folded into
|
|
2790
2910
|
// handoffErrors. `missingField` below reads handoffErrors[0], and the
|
|
2791
2911
|
// reviewHandoff escape hatch keys off that, so a reviewHandoff error in
|
|
@@ -2864,7 +2984,11 @@ class FraimLocalMCPServer {
|
|
|
2864
2984
|
const acceptedResponse = await this.finalizeLocalToolTextResponse(request, requestSessionId, requestId, tutoringResponse.message);
|
|
2865
2985
|
if (acceptedResponse.error || acceptedResponse.result?.isError)
|
|
2866
2986
|
return acceptedResponse;
|
|
2867
|
-
const
|
|
2987
|
+
const acceptedReceipt = this.persistAcceptedReceipt(args, requestSessionId, tutoringResponse, request.id);
|
|
2988
|
+
const phaseStartWarning = this.acceptPhaseTracking(args, requestSessionId, tutoringResponse.nextPhase, !!acceptedReceipt);
|
|
2989
|
+
acceptedResponse.result.structuredContent = { ...acceptedResponse.result.structuredContent,
|
|
2990
|
+
phaseAccepted: true, nextPhase: tutoringResponse.nextPhase, outcome: tutoringResponse.outcome,
|
|
2991
|
+
workflowDigest: tutoringResponse.workflowDigest, ...(acceptedReceipt ? { acceptedReceipt } : {}) };
|
|
2868
2992
|
if (phaseStartWarning && acceptedResponse.result?.content?.[0]) {
|
|
2869
2993
|
acceptedResponse.result.content[0].text = phaseStartWarning + acceptedResponse.result.content[0].text;
|
|
2870
2994
|
}
|
|
@@ -2877,6 +3001,9 @@ class FraimLocalMCPServer {
|
|
|
2877
3001
|
return acceptedResponse;
|
|
2878
3002
|
}
|
|
2879
3003
|
catch (error) {
|
|
3004
|
+
if (localWorkflowResolved) {
|
|
3005
|
+
return { jsonrpc: '2.0', id: request.id, result: { isError: true, content: [{ type: 'text', text: `Phase acceptance failed: ${error.message}` }] } };
|
|
3006
|
+
}
|
|
2880
3007
|
this.log(`⚠️ Local seekMentoring failed: ${error.message}. Falling back to remote.`);
|
|
2881
3008
|
// If local fails, we continue to proxy to the remote server
|
|
2882
3009
|
}
|
|
@@ -2999,12 +3126,38 @@ class FraimLocalMCPServer {
|
|
|
2999
3126
|
if (toolName === 'seekMentoring' && !processedResponse.error && !processedResponse.result?.isError
|
|
3000
3127
|
&& response.result?.structuredContent?.phaseAccepted === true) {
|
|
3001
3128
|
const metadata = response.result.structuredContent;
|
|
3129
|
+
let acceptedReceipt;
|
|
3130
|
+
if (metadata.acceptedReceipt) {
|
|
3131
|
+
const remote = metadata.acceptedReceipt;
|
|
3132
|
+
if (remote.jobName !== args.jobName || remote.jobInstanceId !== (args.jobId || `anonymous:${requestSessionId}:${args.jobName}`)
|
|
3133
|
+
|| remote.sessionId !== requestSessionId || remote.phaseId !== args.currentPhase || remote.requestedStatus !== args.status
|
|
3134
|
+
|| !['next', 'terminal', 'recurring_wait', 'review_wait'].includes(remote.outcome)
|
|
3135
|
+
|| typeof remote.workflowDigest !== 'string') {
|
|
3136
|
+
return { jsonrpc: '2.0', id: request.id, result: { isError: true, content: [{ type: 'text', text: 'Remote phase receipt did not match the executing request.' }] } };
|
|
3137
|
+
}
|
|
3138
|
+
try {
|
|
3139
|
+
acceptedReceipt = this.persistAcceptedReceipt(args, requestSessionId, {
|
|
3140
|
+
message: '', status: args.status, nextPhase: remote.nextPhase ?? null, workflowDigest: remote.workflowDigest,
|
|
3141
|
+
outcome: remote.outcome === 'next' ? { kind: 'next', phaseId: remote.nextPhase } : { kind: remote.outcome },
|
|
3142
|
+
}, request.id);
|
|
3143
|
+
}
|
|
3144
|
+
catch (error) {
|
|
3145
|
+
return { jsonrpc: '2.0', id: request.id, result: { isError: true, content: [{ type: 'text', text: `Phase acceptance failed: ${error.message}` }] } };
|
|
3146
|
+
}
|
|
3147
|
+
processedResponse.result.structuredContent = { ...processedResponse.result.structuredContent, ...(acceptedReceipt ? { acceptedReceipt } : {}) };
|
|
3148
|
+
}
|
|
3149
|
+
else if (process.env.FRAIM_REVIEW_BINDING && !(args.status === 'starting' && args.requestInstructions === true)) {
|
|
3150
|
+
const binding = (0, run_recovery_state_1.parseReviewExecutionBinding)(process.env.FRAIM_REVIEW_BINDING);
|
|
3151
|
+
if (binding?.revisionId && binding.jobInstanceId === args.jobId) {
|
|
3152
|
+
return { jsonrpc: '2.0', id: request.id, result: { isError: true, content: [{ type: 'text', text: 'Remote phase acceptance lacks a supported workflow receipt.' }] } };
|
|
3153
|
+
}
|
|
3154
|
+
}
|
|
3002
3155
|
const textBlock = processedResponse.result?.content?.find((item) => item.type === 'text');
|
|
3003
3156
|
if (textBlock) {
|
|
3004
3157
|
const remoteWarning = metadata.phaseStartWarning;
|
|
3005
3158
|
if (remoteWarning)
|
|
3006
3159
|
textBlock.text = textBlock.text.replace(remoteWarning, '');
|
|
3007
|
-
textBlock.text = this.acceptPhaseTracking(args, requestSessionId, metadata.nextPhase) + textBlock.text;
|
|
3160
|
+
textBlock.text = this.acceptPhaseTracking(args, requestSessionId, metadata.nextPhase, !!acceptedReceipt) + textBlock.text;
|
|
3008
3161
|
}
|
|
3009
3162
|
}
|
|
3010
3163
|
this.log(`📤 ${injectedRequest.method} → ${processedResponse.error ? 'ERROR' : 'OK'}`);
|
|
@@ -369,7 +369,7 @@ Do not use this tool for other repositories or external project issue trackers.`
|
|
|
369
369
|
},
|
|
370
370
|
evidence: {
|
|
371
371
|
type: 'object',
|
|
372
|
-
description: 'Structured evidence or data collected (e.g., prospect counts, test results). Submit phases should put review artifacts in evidence.reviewHandoff rather than printing raw JSON to the user.
|
|
372
|
+
description: 'Structured evidence or data collected (e.g., prospect counts, test results). Submit phases should put review artifacts in evidence.reviewHandoff rather than printing raw JSON to the user. Prepare optional follow-on candidates at submission in evidence.nextJobRecommendations (0-3 entries with jobId, label, reason?, contextSummary?, artifactIds?: string[], prerequisites?: {kind, reference}[]); Standalone jobs with an explicit recommendation step may emit there; the Hub retains submitted candidates through finalization. Prerequisite kinds: approved_artifact, delivered_artifact, selected_action_success, required_child_success, external_result. Reference actual artifacts/actions/children/provider evidence. Do not emit readiness or server-owned candidate/revision/source/start identities. Candidates are revealed only after persisted approval; selecting one prepares a draft, and only explicit Send starts a follow-on.',
|
|
373
373
|
additionalProperties: true
|
|
374
374
|
},
|
|
375
375
|
workingDirectory: {
|
|
@@ -732,6 +732,8 @@ class McpService {
|
|
|
732
732
|
findings: args.findings,
|
|
733
733
|
requestInstructions: args.requestInstructions
|
|
734
734
|
});
|
|
735
|
+
if (result.outcome?.kind === 'invalid')
|
|
736
|
+
return { isError: true, content: [{ type: 'text', text: result.message }] };
|
|
735
737
|
// Handoff contract enforcement (Issue #916/#1157/#1276) fall-through
|
|
736
738
|
// safety net, matching the quality-enforcement note just below.
|
|
737
739
|
//
|
|
@@ -892,13 +894,26 @@ class McpService {
|
|
|
892
894
|
console.error(`❌ McpService: Failed to upsert executionMode:`, e);
|
|
893
895
|
}
|
|
894
896
|
}
|
|
895
|
-
|
|
897
|
+
const finalized = await this.addRuleDelivery({
|
|
896
898
|
content: [{
|
|
897
899
|
type: 'text',
|
|
898
900
|
text: trackingWarning + phaseStartWarning + result.message
|
|
899
901
|
}],
|
|
900
902
|
structuredContent: { phaseAccepted: true, nextPhase: result.nextPhase, phaseStartWarning }
|
|
901
903
|
}, args.jobName, args);
|
|
904
|
+
if (!finalized.isError && !restoringInstructions && result.outcome && result.workflowDigest && sessionId) {
|
|
905
|
+
// Remote-only execution has no trusted Hub binding. The executing local
|
|
906
|
+
// proxy verifies this envelope and supplies its own launch binding.
|
|
907
|
+
const acceptedReceipt = {
|
|
908
|
+
version: 1, acceptanceId: (0, crypto_1.randomUUID)(), acceptedAt: new Date().toISOString(), sequence: 0,
|
|
909
|
+
sourceRunId: '', jobInstanceId: args.jobId || `anonymous:${sessionId}:${args.jobName}`,
|
|
910
|
+
jobName: args.jobName, workflowDigest: result.workflowDigest, sessionId,
|
|
911
|
+
executionEpoch: sessionId, phaseId: args.currentPhase, requestedStatus: args.status,
|
|
912
|
+
outcome: result.outcome.kind, ...(result.nextPhase ? { nextPhase: result.nextPhase } : {}), verifiedEvidenceRefs: [],
|
|
913
|
+
};
|
|
914
|
+
finalized.structuredContent = { ...finalized.structuredContent, acceptedReceipt, outcome: result.outcome, workflowDigest: result.workflowDigest };
|
|
915
|
+
}
|
|
916
|
+
return finalized;
|
|
902
917
|
}
|
|
903
918
|
async addRuleDelivery(response, jobName, phaseArgs) {
|
|
904
919
|
if (response.isError || response.personaLock)
|