fraim 2.0.346 → 2.0.349
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/learning/completion-state.js +80 -4
- package/dist/registry/scripts/learning/corpus-audit.js +7 -7
- package/dist/registry/scripts/learning/entry-authoring.js +181 -0
- package/dist/registry/scripts/learning/entry-format.js +10 -1
- package/dist/registry/scripts/learning/outcome-assessment.js +162 -0
- package/dist/registry/scripts/learning/outcome-types.js +92 -0
- package/dist/registry/scripts/learning/owner-identity.js +11 -0
- package/dist/registry/scripts/learning/proposal-queue.js +81 -0
- package/dist/registry/scripts/learning/run-application.js +1003 -0
- package/dist/registry/scripts/learning/run-publication.js +364 -0
- package/dist/registry/scripts/learning/run-record.js +895 -0
- package/dist/registry/scripts/learning-usage/learning-source-record.js +131 -0
- package/dist/src/cli/commands/learning.js +64 -0
- package/dist/src/cli/doctor/checks/scripts-checks.js +53 -1
- package/dist/src/cli/fraim.js +2 -0
- package/dist/src/cli/learning/inventory.js +285 -0
- package/dist/src/cli/learning/queue-operations.js +199 -0
- package/dist/src/cli/learning/runtime.js +82 -0
- package/dist/src/cli/learning/usage.js +277 -0
- package/dist/src/core/fraim-config-schema.generated.js +0 -4
- package/dist/src/core/utils/local-registry-resolver.js +13 -3
- package/dist/src/local-mcp-server/learning-context-builder.js +109 -379
- package/dist/src/local-mcp-server/learning-entry-format.js +25 -0
- package/dist/src/local-mcp-server/learning-usage-analysis.js +12 -410
- package/dist/src/local-mcp-server/learning-usage-eligibility.js +8 -14
- package/dist/src/local-mcp-server/learning-usage-projection.js +5 -22
- package/dist/src/local-mcp-server/learning-usage-store.js +93 -393
- package/dist/src/local-mcp-server/learning-usage-verify.js +9 -40
- package/dist/src/local-mcp-server/stdio-server.js +50 -114
- package/dist/src/mcp/tool-schemas.js +2 -1
- package/package.json +9 -1
- package/dist/src/local-mcp-server/learning-firing-parser.js +0 -247
- package/dist/src/local-mcp-server/learning-usage-attestation.js +0 -238
- package/dist/src/local-mcp-server/learning-usage-command.js +0 -518
|
@@ -0,0 +1,895 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
var __importDefault = (this && this.__importDefault) || function (mod) {
|
|
3
|
+
return (mod && mod.__esModule) ? mod : { "default": mod };
|
|
4
|
+
};
|
|
5
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
6
|
+
exports.RunRecord = exports.RECOVERY_DIRECTORIES = exports.decodeCompletion = exports.canonical = void 0;
|
|
7
|
+
exports.bindCleanupItem = bindCleanupItem;
|
|
8
|
+
exports.validateDeliveryReader = validateDeliveryReader;
|
|
9
|
+
exports.completedRunActions = completedRunActions;
|
|
10
|
+
exports.actionDigest = actionDigest;
|
|
11
|
+
exports.readRunRecord = readRunRecord;
|
|
12
|
+
exports.readSupportedRunRecord = readSupportedRunRecord;
|
|
13
|
+
exports.assertNoRunSyncConflicts = assertNoRunSyncConflicts;
|
|
14
|
+
const node_fs_1 = __importDefault(require("node:fs"));
|
|
15
|
+
const node_path_1 = __importDefault(require("node:path"));
|
|
16
|
+
const node_crypto_1 = require("node:crypto");
|
|
17
|
+
const proposal_queue_1 = require("./proposal-queue");
|
|
18
|
+
const entry_authoring_1 = require("./entry-authoring");
|
|
19
|
+
const owner_identity_1 = require("./owner-identity");
|
|
20
|
+
const corpus_audit_1 = require("./corpus-audit");
|
|
21
|
+
const outcome_types_1 = require("./outcome-types");
|
|
22
|
+
const completion_state_1 = require("./completion-state");
|
|
23
|
+
var completion_state_2 = require("./completion-state");
|
|
24
|
+
Object.defineProperty(exports, "canonical", { enumerable: true, get: function () { return completion_state_2.canonicalJournal; } });
|
|
25
|
+
var completion_state_3 = require("./completion-state");
|
|
26
|
+
Object.defineProperty(exports, "decodeCompletion", { enumerable: true, get: function () { return completion_state_3.decodeCompletion; } });
|
|
27
|
+
exports.RECOVERY_DIRECTORIES = ['review-queue', 'review-archive', 'review-evidence', 'archive'];
|
|
28
|
+
function bindCleanupItem(item, home) {
|
|
29
|
+
if (item.location)
|
|
30
|
+
(0, owner_identity_1.assertSameOwner)(item.location.owner, home.owner);
|
|
31
|
+
const relativePath = node_path_1.default.relative(home.contentRoot, item.path).replace(/\\/g, '/');
|
|
32
|
+
if (!exports.RECOVERY_DIRECTORIES.some(directory => relativePath.startsWith(`learnings/${directory}/`)))
|
|
33
|
+
throw new Error('cleanup-owner-unresolved');
|
|
34
|
+
const file = (0, owner_identity_1.ownerArtifactPath)(home, relativePath);
|
|
35
|
+
if (node_path_1.default.resolve(item.path) !== file)
|
|
36
|
+
throw new Error('cleanup-owner-unresolved');
|
|
37
|
+
for (let current = file; current !== node_path_1.default.dirname(node_path_1.default.resolve(home.root)); current = node_path_1.default.dirname(current)) {
|
|
38
|
+
try {
|
|
39
|
+
if (node_fs_1.default.lstatSync(current).isSymbolicLink())
|
|
40
|
+
throw new Error('cleanup-link-conflict');
|
|
41
|
+
}
|
|
42
|
+
catch (error) {
|
|
43
|
+
if (error.code !== 'ENOENT')
|
|
44
|
+
throw error;
|
|
45
|
+
}
|
|
46
|
+
if (current === node_path_1.default.dirname(current))
|
|
47
|
+
throw new Error('cleanup-owner-unresolved');
|
|
48
|
+
}
|
|
49
|
+
return { ...item, path: file, location: { owner: home.owner, relativePath } };
|
|
50
|
+
}
|
|
51
|
+
function sameCleanupIdentity(a, b) {
|
|
52
|
+
return node_path_1.default.resolve(a.path) === node_path_1.default.resolve(b.path) || !!(a.location && b.location
|
|
53
|
+
&& (0, completion_state_1.canonicalJournal)(a.location) === (0, completion_state_1.canonicalJournal)(b.location) && a.sha256 === b.sha256);
|
|
54
|
+
}
|
|
55
|
+
function validateDeliveryReader(reader) { validateReader(reader, false); }
|
|
56
|
+
function validateReader(reader, historical) {
|
|
57
|
+
if (!reader || typeof reader !== 'object')
|
|
58
|
+
throw new Error('unsupported-capability-reader');
|
|
59
|
+
if (reader.kind === 'team-context' && Object.keys(reader).sort().join(',') === 'key,kind'
|
|
60
|
+
&& ['org', 'manager', 'orgRules', 'managerRules', 'projectContext', 'projectBrief', 'projectRules', 'projectQa'].includes(reader.key))
|
|
61
|
+
return;
|
|
62
|
+
if (reader.kind === 'workspace-config' && Object.keys(reader).sort().join(',') === 'expectedValue,key,kind'
|
|
63
|
+
&& ['learning.scoreThreshold', 'learning.usage.firingLogLimit', 'learning.usage.offerLogLimit', ...(historical ? ['learning.usage.retirementOfferThreshold'] : [])].includes(reader.key)
|
|
64
|
+
&& Number.isFinite(reader.expectedValue) && reader.expectedValue > 0
|
|
65
|
+
&& (reader.key === 'learning.scoreThreshold' || Number.isInteger(reader.expectedValue)))
|
|
66
|
+
return;
|
|
67
|
+
throw new Error('unsupported-capability-reader');
|
|
68
|
+
}
|
|
69
|
+
/**
|
|
70
|
+
* Issue #1857 R6 and D2.
|
|
71
|
+
*
|
|
72
|
+
* `basis` records who reached the decision. A suppression inherited from a
|
|
73
|
+
* matching earlier rejection is a decision the software made, and recording it
|
|
74
|
+
* with only a `reviewer` makes it indistinguishable from one the manager made.
|
|
75
|
+
* That is an audit-integrity problem rather than a cosmetic one: the suppression
|
|
76
|
+
* report tells the manager what was decided on their behalf, and it can only be
|
|
77
|
+
* honest if the record knows the difference. `predecessorId` names the rejection
|
|
78
|
+
* an inherited decision came from, so the report can cite it.
|
|
79
|
+
*
|
|
80
|
+
* `kind` separates the two things a rejection can mean. The manager can refuse a
|
|
81
|
+
* claim, or they can discard a queued learning because a rule or skill now owns
|
|
82
|
+
* it, which `learning-review-presentation` step 6 records as a reject. Both
|
|
83
|
+
* suppress future proposals, so without this the report would tell the manager
|
|
84
|
+
* they refused a claim they actually promoted.
|
|
85
|
+
*/
|
|
86
|
+
/**
|
|
87
|
+
* Issue #1857: the provenance fields are optional so records written before this
|
|
88
|
+
* change stay valid, but a half-filled pair is not. An inherited decision without
|
|
89
|
+
* its predecessor cannot be cited in the suppression report, and a `kind` on an
|
|
90
|
+
* approval is meaningless.
|
|
91
|
+
*/
|
|
92
|
+
function validDecisionProvenance(d) {
|
|
93
|
+
if (d.basis !== undefined && d.basis !== 'manager' && d.basis !== 'inherited')
|
|
94
|
+
return false;
|
|
95
|
+
if (d.basis === 'inherited' && (typeof d.predecessorId !== 'string' || !d.predecessorId.trim()))
|
|
96
|
+
return false;
|
|
97
|
+
if (d.predecessorId !== undefined && d.basis !== 'inherited')
|
|
98
|
+
return false;
|
|
99
|
+
if (d.kind !== undefined && (d.action !== 'reject' || (d.kind !== 'refused' && d.kind !== 'superseded')))
|
|
100
|
+
return false;
|
|
101
|
+
return true;
|
|
102
|
+
}
|
|
103
|
+
/** Only terminal, attributable actions with terminal dependencies belong in a receipt. */
|
|
104
|
+
function completedRunActions(state) {
|
|
105
|
+
const remaining = new Map(Object.values(state.actions).filter(action => action.decision
|
|
106
|
+
&& (action.decision.action === 'reject' ? !action.application : action.application?.stage === 'applied')
|
|
107
|
+
&& action.sourceRefs.every(ref => action.sourceHashes?.[ref])).map(action => [action.actionId, action]));
|
|
108
|
+
let changed = true;
|
|
109
|
+
while (changed) {
|
|
110
|
+
changed = false;
|
|
111
|
+
for (const [id, action] of remaining)
|
|
112
|
+
if (action.dependencies.some(dependency => !remaining.has(dependency))) {
|
|
113
|
+
remaining.delete(id);
|
|
114
|
+
changed = true;
|
|
115
|
+
}
|
|
116
|
+
}
|
|
117
|
+
return [...remaining.values()];
|
|
118
|
+
}
|
|
119
|
+
const idPattern = /^[a-zA-Z0-9][a-zA-Z0-9_.-]{0,159}$/;
|
|
120
|
+
function actionDigest(action) {
|
|
121
|
+
const { decision: _decision, application: _application, digest: _digest, ...revision } = action;
|
|
122
|
+
return (0, proposal_queue_1.hashContent)((0, completion_state_1.canonicalJournal)(revision));
|
|
123
|
+
}
|
|
124
|
+
function checkRef(ref) {
|
|
125
|
+
(0, owner_identity_1.validateOwner)(ref?.owner);
|
|
126
|
+
if (!ref.relativePath || node_path_1.default.isAbsolute(ref.relativePath) || ref.relativePath.includes('\\') || ref.relativePath.split('/').some(p => !p || p === '..' || p === '.') || ref.relativePath.includes(':'))
|
|
127
|
+
throw new Error('invalid-artifact-path');
|
|
128
|
+
}
|
|
129
|
+
function reviewedInput(input) {
|
|
130
|
+
const fields = ['kind', 'operation', 'claimKey', 'evidenceFingerprint', 'sourceRefs', 'sourceHashes', 'target', 'source', 'sourceHash', 'destinationHash', 'copies', 'content', 'specification', 'delivery', 'dependencies', 'verification'];
|
|
131
|
+
const result = {};
|
|
132
|
+
for (const key of fields)
|
|
133
|
+
if (input[key] !== undefined)
|
|
134
|
+
result[key] = structuredClone(input[key]);
|
|
135
|
+
return result;
|
|
136
|
+
}
|
|
137
|
+
function validateAction(action, historical = false) {
|
|
138
|
+
if (!['learning', 'capability'].includes(action.kind) || !['insert', 'update', 'move', 'remove', 'deliver', 'reconcile'].includes(action.operation))
|
|
139
|
+
throw new Error('invalid-action');
|
|
140
|
+
if (action.source !== undefined && action.operation !== 'move')
|
|
141
|
+
throw new Error('invalid-action-source');
|
|
142
|
+
checkRef(action.target);
|
|
143
|
+
if (action.source)
|
|
144
|
+
checkRef(action.source);
|
|
145
|
+
if (!action.claimKey?.trim() || !action.evidenceFingerprint?.trim() || !Array.isArray(action.sourceRefs) || !action.sourceRefs.length || action.sourceRefs.some(s => typeof s !== 'string' || !s))
|
|
146
|
+
throw new Error('invalid-action-evidence');
|
|
147
|
+
if (action.sourceHashes && (Object.entries(action.sourceHashes).some(([ref, hash]) => !action.sourceRefs.includes(ref) || !/^[a-f0-9]{64}$/.test(hash))))
|
|
148
|
+
throw new Error('invalid-source-hashes');
|
|
149
|
+
if (!Array.isArray(action.dependencies) || new Set(action.dependencies).size !== action.dependencies.length || !Array.isArray(action.verification) || !action.verification.length)
|
|
150
|
+
throw new Error('invalid-action-dependencies');
|
|
151
|
+
if (action.kind === 'learning') {
|
|
152
|
+
if (!/^L-[a-f0-9]{10}$/.test(action.target.entryId || ''))
|
|
153
|
+
throw new Error('invalid-entry-id');
|
|
154
|
+
if (action.operation !== 'remove') {
|
|
155
|
+
(0, entry_authoring_1.validateEntry)(action.content);
|
|
156
|
+
if (action.content.domain.status !== 'resolved')
|
|
157
|
+
throw new Error('needs-domain');
|
|
158
|
+
(0, owner_identity_1.assertLearningTargetPath)(action.target, action.content.domain.value);
|
|
159
|
+
}
|
|
160
|
+
if (action.operation === 'deliver')
|
|
161
|
+
throw new Error('invalid-learning-operation');
|
|
162
|
+
}
|
|
163
|
+
else {
|
|
164
|
+
if (action.operation !== 'deliver' || !action.specification?.trim())
|
|
165
|
+
throw new Error('capability-delivery-specification-required');
|
|
166
|
+
if (action.delivery !== undefined) {
|
|
167
|
+
if (!/^[a-f0-9]{64}$/.test(action.delivery.expectedHash) || !Array.isArray(action.delivery.callers))
|
|
168
|
+
throw new Error('invalid-capability-delivery');
|
|
169
|
+
if (action.delivery.reader !== undefined) {
|
|
170
|
+
// Historic revisions retain exact approval bytes; current proposals and execution use the active reader contract.
|
|
171
|
+
validateReader(action.delivery.reader, historical);
|
|
172
|
+
if (action.delivery.callers.length)
|
|
173
|
+
throw new Error('invalid-capability-reader-callers');
|
|
174
|
+
if (action.delivery.reader.kind === 'workspace-config' && (action.target.owner.level !== 'Project' || action.target.relativePath !== 'config.json'))
|
|
175
|
+
throw new Error('invalid-workspace-config-target');
|
|
176
|
+
}
|
|
177
|
+
else if (!action.delivery.callers.length)
|
|
178
|
+
throw new Error('invalid-capability-delivery');
|
|
179
|
+
if (action.target.owner.level === 'Project' && action.target.relativePath === 'config.json' && action.delivery.reader?.kind !== 'workspace-config')
|
|
180
|
+
throw new Error('workspace-config-reader-required');
|
|
181
|
+
for (const caller of action.delivery.callers)
|
|
182
|
+
checkRef(caller);
|
|
183
|
+
}
|
|
184
|
+
}
|
|
185
|
+
if (['update', 'move', 'remove'].includes(action.operation) && !/^[a-f0-9]{64}$/.test(action.sourceHash || action.destinationHash || ''))
|
|
186
|
+
throw new Error('source-hash-required');
|
|
187
|
+
if (action.operation === 'move' && (!action.source || action.source.entryId !== action.target.entryId || (0, completion_state_1.canonicalJournal)(action.source) === (0, completion_state_1.canonicalJournal)(action.target)))
|
|
188
|
+
throw new Error('invalid-move');
|
|
189
|
+
if (action.operation === 'reconcile') {
|
|
190
|
+
if (action.kind !== 'learning' || action.source || !Array.isArray(action.copies) || !action.copies.length
|
|
191
|
+
|| (action.destinationHash !== undefined && !/^[a-f0-9]{64}$/.test(action.destinationHash)))
|
|
192
|
+
throw new Error('invalid-reconciliation');
|
|
193
|
+
const keys = new Set([(0, corpus_audit_1.corpusEntryKey)({ ref: action.target, sha256: '' })]);
|
|
194
|
+
for (const copy of action.copies) {
|
|
195
|
+
checkRef(copy.ref);
|
|
196
|
+
const key = (0, corpus_audit_1.corpusEntryKey)({ ref: copy.ref, sha256: copy.sha256 });
|
|
197
|
+
if (copy.ref.entryId !== action.target.entryId || keys.has(key) || !/^[a-f0-9]{64}$/.test(copy.sha256)
|
|
198
|
+
|| !/^learnings\/.+-(preferences|mistake-patterns|validated-patterns|manager-coaching)(\.cold)?\.md$/.test(copy.ref.relativePath))
|
|
199
|
+
throw new Error('invalid-reconciliation');
|
|
200
|
+
keys.add(key);
|
|
201
|
+
}
|
|
202
|
+
}
|
|
203
|
+
else if (action.copies !== undefined)
|
|
204
|
+
throw new Error('invalid-reconciliation');
|
|
205
|
+
}
|
|
206
|
+
function validateApplication(action, application) {
|
|
207
|
+
if (!application || !['prepared', 'written', 'published', 'applied'].includes(application.stage) || !Array.isArray(application.steps))
|
|
208
|
+
throw new Error('invalid-application');
|
|
209
|
+
const refs = action.kind === 'learning' ? [action.target, ...(action.operation === 'move' ? [action.source] : action.copies?.map(copy => copy.ref) || [])] : [];
|
|
210
|
+
if (application.steps.length !== refs.length)
|
|
211
|
+
throw new Error('invalid-application-targets');
|
|
212
|
+
for (const [index, step] of application.steps.entries()) {
|
|
213
|
+
const after = index > 0 || action.operation === 'remove' ? null : (0, entry_authoring_1.renderEntry)(action.target.entryId, action.content).trimEnd();
|
|
214
|
+
const expectedHash = index > 0 ? action.operation === 'reconcile' ? action.copies[index - 1].sha256 : action.sourceHash : action.destinationHash || (action.operation !== 'move' ? action.sourceHash : undefined);
|
|
215
|
+
if ((0, completion_state_1.canonicalJournal)(step.ref) !== (0, completion_state_1.canonicalJournal)(refs[index]) || step.after !== after
|
|
216
|
+
|| (index === 0 && (['insert', 'move'].includes(action.operation) || (action.operation === 'reconcile' && !action.destinationHash)) ? step.before !== null : typeof step.before !== 'string' || (0, proposal_queue_1.hashContent)(step.before) !== expectedHash)
|
|
217
|
+
|| (step.published && !step.written) || (application.stage === 'applied' && !step.published))
|
|
218
|
+
throw new Error('invalid-application-targets');
|
|
219
|
+
const prior = action.application?.steps[index];
|
|
220
|
+
if (prior && (prior.before !== step.before || prior.after !== step.after || (0, completion_state_1.canonicalJournal)(prior.ref) !== (0, completion_state_1.canonicalJournal)(step.ref)
|
|
221
|
+
|| (prior.written && !step.written) || (prior.published && !step.published)))
|
|
222
|
+
throw new Error('invalid-application-transition');
|
|
223
|
+
}
|
|
224
|
+
if (action.application?.stage === 'applied' && application.stage !== 'applied')
|
|
225
|
+
throw new Error('invalid-application-transition');
|
|
226
|
+
}
|
|
227
|
+
function validateReconciliation(item, state) {
|
|
228
|
+
(0, owner_identity_1.validateOwner)(item.owner);
|
|
229
|
+
if (!item.sourceRef || !node_path_1.default.isAbsolute(item.sourceRef) || !/^[a-f0-9]{64}$/.test(item.sourceHash)
|
|
230
|
+
|| !exports.RECOVERY_DIRECTORIES.some(directory => item.relativePath?.startsWith(`learnings/${directory}/`))
|
|
231
|
+
|| /[\\:]/.test(item.relativePath) || item.relativePath.split('/').some(part => !part || part === '.' || part === '..')
|
|
232
|
+
|| !['accounted', 'unrelated', 'raw-source'].includes(item.disposition) || !item.rationale?.trim()
|
|
233
|
+
|| item.reviewer !== state.reviewer || !Array.isArray(item.preservedDecisions) || item.preservedDecisions.some(value => typeof value !== 'string' || !value.trim())
|
|
234
|
+
|| !Array.isArray(item.actionIds) || new Set(item.actionIds).size !== item.actionIds.length || !Array.isArray(item.actionBindings)
|
|
235
|
+
|| item.actionBindings.length !== item.actionIds.length)
|
|
236
|
+
throw new Error('invalid-source-reconciliation');
|
|
237
|
+
for (const [index, id] of item.actionIds.entries()) {
|
|
238
|
+
const action = state.actions[id];
|
|
239
|
+
const binding = item.actionBindings[index];
|
|
240
|
+
if (!action?.decision || action.decision.digest !== action.digest || binding.actionId !== id || binding.revision !== action.revision || binding.digest !== action.digest
|
|
241
|
+
|| !action.sourceRefs.some(ref => ref.replace(/\\/g, '/') === item.sourceRef.replace(/\\/g, '/') && action.sourceHashes?.[ref] === item.sourceHash))
|
|
242
|
+
throw new Error('reconciliation-action-conflict');
|
|
243
|
+
}
|
|
244
|
+
}
|
|
245
|
+
function replay(file) {
|
|
246
|
+
const journal = (0, completion_state_1.readRunJournal)(file);
|
|
247
|
+
if (journal.format === 'historical')
|
|
248
|
+
throw new completion_state_1.UnsupportedRunHistoryError();
|
|
249
|
+
let state;
|
|
250
|
+
for (const event of journal.events) {
|
|
251
|
+
const { type, data } = event.payload;
|
|
252
|
+
if (!state) {
|
|
253
|
+
if (type !== 'created' || data.schemaVersion !== 1 || !idPattern.test(data.runId) || !data.reviewer)
|
|
254
|
+
throw new Error('invalid-run');
|
|
255
|
+
(0, owner_identity_1.validateOwner)(data.owner);
|
|
256
|
+
if (data.completionDirectory !== undefined)
|
|
257
|
+
validateCompletionDirectory(data.completionDirectory, data.owner);
|
|
258
|
+
state = { schemaVersion: 1, runId: data.runId, owner: data.owner, reviewer: data.reviewer, sequence: 0, actions: {}, appliedOrder: [], sourceDispositions: [], cleanup: [], reconciliations: [], pendingReviewActionIds: [],
|
|
259
|
+
...(data.completionDirectory !== undefined ? { completionDirectory: data.completionDirectory } : {}),
|
|
260
|
+
...(data.sourceParticipants !== undefined ? { sourceParticipants: (0, completion_state_1.normalizeSourceParticipants)(data.sourceParticipants, data.owner) } : {}),
|
|
261
|
+
...(data.corpusAuditRequired === true ? { corpusAuditRequired: true } : {}) };
|
|
262
|
+
}
|
|
263
|
+
else if (type === 'action') {
|
|
264
|
+
validateAction(data, true);
|
|
265
|
+
if (!idPattern.test(data.actionId) || !Number.isInteger(data.revision) || data.revision < 1 || actionDigest(data) !== data.digest)
|
|
266
|
+
throw new Error('invalid-revision');
|
|
267
|
+
const prior = state.actions[data.actionId];
|
|
268
|
+
if (data.revision !== (prior?.revision || 0) + 1 || prior?.application || data.application || data.decision)
|
|
269
|
+
throw new Error('invalid-revision-transition');
|
|
270
|
+
state.actions[data.actionId] = data;
|
|
271
|
+
}
|
|
272
|
+
else if (type === 'decision') {
|
|
273
|
+
const action = state.actions[data.actionId];
|
|
274
|
+
const d = data.decision;
|
|
275
|
+
if (!action || action.application || d.revision !== action.revision || d.digest !== action.digest || d.reviewer !== state.reviewer || !['approve', 'reject'].includes(d.action) || typeof d.reason !== 'string' || !d.reason.trim() || (d.revisitCondition !== undefined && (d.action !== 'reject' || typeof d.revisitCondition !== 'string' || !d.revisitCondition.trim())) || !validDecisionProvenance(d))
|
|
276
|
+
throw new Error('invalid-decision');
|
|
277
|
+
action.decision = d;
|
|
278
|
+
}
|
|
279
|
+
else if (type === 'decision-batch') {
|
|
280
|
+
if (!Array.isArray(data) || !data.length || new Set(data.map(item => item.actionId)).size !== data.length)
|
|
281
|
+
throw new Error('invalid-batch-decision');
|
|
282
|
+
for (const item of data) {
|
|
283
|
+
const action = state.actions[item.actionId];
|
|
284
|
+
const decision = item.decision;
|
|
285
|
+
if (!action || action.application || decision.action !== 'approve' || decision.revision !== action.revision || decision.digest !== action.digest || decision.reviewer !== state.reviewer || !decision.reason)
|
|
286
|
+
throw new Error('invalid-batch-decision');
|
|
287
|
+
}
|
|
288
|
+
for (const item of data)
|
|
289
|
+
state.actions[item.actionId].decision = item.decision;
|
|
290
|
+
}
|
|
291
|
+
else if (type === 'application') {
|
|
292
|
+
const action = state.actions[data.actionId];
|
|
293
|
+
if (!action || action.decision?.action !== 'approve' || data.digest !== action.digest || !['prepared', 'written', 'published', 'applied'].includes(data.application?.stage))
|
|
294
|
+
throw new Error('invalid-application');
|
|
295
|
+
validateApplication(action, data.application);
|
|
296
|
+
action.application = data.application;
|
|
297
|
+
if (action.application?.stage === 'applied')
|
|
298
|
+
state.appliedOrder = [...state.appliedOrder.filter(id => id !== action.actionId), action.actionId];
|
|
299
|
+
}
|
|
300
|
+
else if (type === 'reconciliation') {
|
|
301
|
+
const { reconciliation, cleanup } = data;
|
|
302
|
+
validateReconciliation(reconciliation, state);
|
|
303
|
+
state.reconciliations = state.reconciliations.filter(item => item.sourceRef !== reconciliation.sourceRef).concat(reconciliation);
|
|
304
|
+
state.cleanup = state.cleanup.filter(item => item.path !== cleanup.path).concat(cleanup);
|
|
305
|
+
}
|
|
306
|
+
else if (type === 'assessment-prepared') {
|
|
307
|
+
(0, outcome_types_1.validateOutcomeAssessmentRecord)(data.record);
|
|
308
|
+
if (data.record.runId !== state.runId || data.record.reviewer !== state.reviewer
|
|
309
|
+
|| !Array.isArray(data.sourcePaths) || data.sourcePaths.length !== data.record.sourceRefs.length || data.sourcePaths.some((file) => typeof file !== 'string' || !node_path_1.default.isAbsolute(file))
|
|
310
|
+
|| [...(state.outcomeAssessments ?? []), ...(state.pendingOutcomeAssessments ?? [])].some(item => item.digest === data.record.digest))
|
|
311
|
+
throw new Error('invalid-assessment-transition');
|
|
312
|
+
state.pendingOutcomeAssessments = [...(state.pendingOutcomeAssessments ?? []), data.record];
|
|
313
|
+
}
|
|
314
|
+
else if (type === 'assessment-committed') {
|
|
315
|
+
const prepared = state.pendingOutcomeAssessments?.find(item => item.digest === data.digest);
|
|
316
|
+
if (!prepared)
|
|
317
|
+
throw new Error('invalid-assessment-transition');
|
|
318
|
+
state.outcomeAssessments = [...(state.outcomeAssessments ?? []), prepared];
|
|
319
|
+
state.pendingOutcomeAssessments = state.pendingOutcomeAssessments.filter(item => item.digest !== data.digest);
|
|
320
|
+
}
|
|
321
|
+
else if (type === 'corpus-audit') {
|
|
322
|
+
(0, corpus_audit_1.validateCorpusAudit)(data, state.reviewer);
|
|
323
|
+
state.corpusAudit = data;
|
|
324
|
+
}
|
|
325
|
+
else if (type === 'disposition')
|
|
326
|
+
state.sourceDispositions.push(data);
|
|
327
|
+
else if (type === 'cleanup')
|
|
328
|
+
state.cleanup.push(data);
|
|
329
|
+
else if (type === 'source-completion') {
|
|
330
|
+
(0, completion_state_1.validateProcessedSources)(data);
|
|
331
|
+
if (state.processedSources?.some(prior => !data.some((item) => (0, completion_state_1.canonicalJournal)(item) === (0, completion_state_1.canonicalJournal)(prior))))
|
|
332
|
+
throw new Error('completion-sources-conflict');
|
|
333
|
+
state.processedSources = data;
|
|
334
|
+
}
|
|
335
|
+
else if (type === 'checkpoint') {
|
|
336
|
+
const receipt = (0, completion_state_1.decodeCompletion)(`<!-- fraim-sleep-decisions:v1 -->\n\x60\x60\x60json\n${JSON.stringify(data.receipt)}\n\x60\x60\x60\n<!-- /fraim-sleep-decisions -->`, { runId: state.runId, owner: state.owner });
|
|
337
|
+
if (!receipt.deferred || !/^[a-f0-9]{64}$/.test(data.evidenceHash))
|
|
338
|
+
throw new Error('invalid-checkpoint');
|
|
339
|
+
for (const item of [...receipt.decisions, ...receipt.deferred.actions])
|
|
340
|
+
if (state.actions[item.actionId]?.digest !== item.digest || state.actions[item.actionId]?.revision !== item.revision)
|
|
341
|
+
throw new Error('checkpoint-action-conflict');
|
|
342
|
+
const completed = completedRunActions(state);
|
|
343
|
+
if (receipt.decisions.length !== completed.length || receipt.decisions.some(item => !completed.some(action => action.actionId === item.actionId
|
|
344
|
+
&& (0, completion_state_1.canonicalJournal)(action.decision) === (0, completion_state_1.canonicalJournal)(item.decision) && (0, completion_state_1.canonicalJournal)(action.target) === (0, completion_state_1.canonicalJournal)(item.target))))
|
|
345
|
+
throw new Error('checkpoint-action-conflict');
|
|
346
|
+
if ((0, completion_state_1.canonicalJournal)(receipt.processedSources) !== (0, completion_state_1.canonicalJournal)(state.processedSources))
|
|
347
|
+
throw new Error('completion-sources-conflict');
|
|
348
|
+
if ((0, completion_state_1.canonicalJournal)(receipt.outcomeAssessments) !== (0, completion_state_1.canonicalJournal)(state.outcomeAssessments))
|
|
349
|
+
throw new Error('completion-assessments-conflict');
|
|
350
|
+
state.checkpoint = data;
|
|
351
|
+
if (receipt.finalizationState === 'complete' && !state.deferredHistory?.some(prior => (0, completion_state_1.canonicalJournal)(prior) === (0, completion_state_1.canonicalJournal)(receipt.deferred)))
|
|
352
|
+
state.deferredHistory = [...(state.deferredHistory || []), receipt.deferred];
|
|
353
|
+
}
|
|
354
|
+
else if (type === 'finalization')
|
|
355
|
+
state.finalization = data;
|
|
356
|
+
else if (type === 'deferred-attempt') {
|
|
357
|
+
(0, completion_state_1.validateDeferredAttempt)(data);
|
|
358
|
+
if (!state.checkpoint || state.checkpoint.receipt.finalizationState !== 'complete' || state.checkpoint.evidenceHash !== data.evidenceHash
|
|
359
|
+
|| (data.deferred !== undefined && (0, completion_state_1.canonicalJournal)(state.checkpoint.receipt.deferred) !== (0, completion_state_1.canonicalJournal)(data.deferred)))
|
|
360
|
+
throw new Error('deferred-attempt-conflict');
|
|
361
|
+
state.deferredAttempts = [...(state.deferredAttempts ?? []), data];
|
|
362
|
+
}
|
|
363
|
+
else
|
|
364
|
+
throw new Error('unknown-journal-event');
|
|
365
|
+
state.sequence = event.sequence;
|
|
366
|
+
}
|
|
367
|
+
if (!state)
|
|
368
|
+
throw new Error('empty-journal');
|
|
369
|
+
state.pendingReviewActionIds = Object.values(state.actions).filter(action => !action.decision).map(action => action.actionId);
|
|
370
|
+
return { state, events: journal.events, lastHash: journal.lastHash };
|
|
371
|
+
}
|
|
372
|
+
function readRunRecord(file) { return replay(file).state; }
|
|
373
|
+
/** Enumeration skips only recognized historical records; current corruption must remain visible. */
|
|
374
|
+
function readSupportedRunRecord(file) {
|
|
375
|
+
try {
|
|
376
|
+
return readRunRecord(file);
|
|
377
|
+
}
|
|
378
|
+
catch (error) {
|
|
379
|
+
if (error instanceof completion_state_1.UnsupportedRunHistoryError)
|
|
380
|
+
return undefined;
|
|
381
|
+
throw error;
|
|
382
|
+
}
|
|
383
|
+
}
|
|
384
|
+
function assertNoRunSyncConflicts(directory, runId) {
|
|
385
|
+
if (node_fs_1.default.existsSync(directory) && node_fs_1.default.readdirSync(directory).some(name => name.startsWith(runId + ' ') && /jsonl/i.test(name)))
|
|
386
|
+
throw new Error('journal-sync-conflict');
|
|
387
|
+
}
|
|
388
|
+
/** One append-only record holds every reviewed revision, decision and recovery step. */
|
|
389
|
+
function validateCompletionDirectory(directory, owner) {
|
|
390
|
+
if (typeof directory !== 'string' || !node_path_1.default.isAbsolute(directory) || /[\x00-\x1f]/.test(directory))
|
|
391
|
+
throw new Error('invalid-completion-directory');
|
|
392
|
+
const relative = node_path_1.default.relative((0, completion_state_1.sourcePathKey)(owner.path), (0, completion_state_1.sourcePathKey)(directory));
|
|
393
|
+
if (!relative || relative === '..' || relative.startsWith('..' + node_path_1.default.sep) || node_path_1.default.isAbsolute(relative))
|
|
394
|
+
throw new Error('invalid-completion-directory');
|
|
395
|
+
}
|
|
396
|
+
class RunRecord {
|
|
397
|
+
constructor(directory, runId, owner, reviewer, completionDirectory = directory) {
|
|
398
|
+
this.directory = directory;
|
|
399
|
+
this.runId = runId;
|
|
400
|
+
this.owner = owner;
|
|
401
|
+
this.reviewer = reviewer;
|
|
402
|
+
this.completionDirectory = completionDirectory;
|
|
403
|
+
(0, owner_identity_1.validateOwner)(owner);
|
|
404
|
+
if (!idPattern.test(runId) || !reviewer?.trim())
|
|
405
|
+
throw new Error('invalid-run-context');
|
|
406
|
+
validateCompletionDirectory(completionDirectory, owner);
|
|
407
|
+
this.file = node_path_1.default.join(directory, runId + '.queue.jsonl');
|
|
408
|
+
this.completionFile = node_path_1.default.join(completionDirectory, runId + '-completion.md');
|
|
409
|
+
for (const file of [this.file, this.completionFile])
|
|
410
|
+
if (node_fs_1.default.existsSync(file) && node_fs_1.default.lstatSync(file).isSymbolicLink())
|
|
411
|
+
throw new Error('run-path-link-conflict');
|
|
412
|
+
}
|
|
413
|
+
read() {
|
|
414
|
+
assertNoRunSyncConflicts(this.directory, this.runId);
|
|
415
|
+
const state = readRunRecord(this.file);
|
|
416
|
+
(0, owner_identity_1.assertSameOwner)(this.owner, state.owner);
|
|
417
|
+
if (state.runId !== this.runId || state.reviewer !== this.reviewer)
|
|
418
|
+
throw new Error('run-context-mismatch');
|
|
419
|
+
this.assertCompletionDirectory(state);
|
|
420
|
+
return state;
|
|
421
|
+
}
|
|
422
|
+
assertCompletionDirectory(state) {
|
|
423
|
+
if ((0, completion_state_1.sourcePathKey)(state.completionDirectory ?? this.directory) !== (0, completion_state_1.sourcePathKey)(this.completionDirectory))
|
|
424
|
+
throw new Error('completion-evidence-path-changed');
|
|
425
|
+
}
|
|
426
|
+
append(type, data, operationId = (0, node_crypto_1.randomUUID)()) {
|
|
427
|
+
const prior = node_fs_1.default.existsSync(this.file) ? replay(this.file) : undefined;
|
|
428
|
+
if (prior)
|
|
429
|
+
this.assertCompletionDirectory(prior.state);
|
|
430
|
+
const payload = { type, data };
|
|
431
|
+
const event = { sequence: (prior?.state.sequence || 0) + 1, previousHash: prior?.lastHash || '', operationId, payloadHash: (0, proposal_queue_1.hashContent)((0, completion_state_1.canonicalJournal)(payload)), payload };
|
|
432
|
+
const fd = node_fs_1.default.openSync(this.file, 'a', 0o600);
|
|
433
|
+
try {
|
|
434
|
+
node_fs_1.default.writeSync(fd, (0, completion_state_1.canonicalJournal)(event) + '\n');
|
|
435
|
+
node_fs_1.default.fsyncSync(fd);
|
|
436
|
+
}
|
|
437
|
+
finally {
|
|
438
|
+
node_fs_1.default.closeSync(fd);
|
|
439
|
+
}
|
|
440
|
+
}
|
|
441
|
+
create(sourceParticipants) {
|
|
442
|
+
const grants = sourceParticipants === undefined ? undefined : (0, completion_state_1.normalizeSourceParticipants)(sourceParticipants, this.owner);
|
|
443
|
+
return (0, proposal_queue_1.withFileLock)(this.file, () => {
|
|
444
|
+
if (node_fs_1.default.existsSync(this.file)) {
|
|
445
|
+
const state = this.read();
|
|
446
|
+
if (grants !== undefined && (0, completion_state_1.canonicalJournal)(grants) !== (0, completion_state_1.canonicalJournal)(state.sourceParticipants ?? []))
|
|
447
|
+
throw new Error('source-participants-conflict');
|
|
448
|
+
return state;
|
|
449
|
+
}
|
|
450
|
+
if (node_fs_1.default.existsSync(this.completionFile))
|
|
451
|
+
throw new Error('run-completed');
|
|
452
|
+
this.append('created', { schemaVersion: 1, runId: this.runId, owner: this.owner, reviewer: this.reviewer, completionDirectory: node_path_1.default.resolve(this.completionDirectory), ...(this.owner.level === 'Manager' ? { corpusAuditRequired: true } : {}), ...(grants !== undefined ? { sourceParticipants: grants } : {}) });
|
|
453
|
+
return this.read();
|
|
454
|
+
});
|
|
455
|
+
}
|
|
456
|
+
mutate(expectedSequence, fn) {
|
|
457
|
+
return (0, proposal_queue_1.withFileLock)(this.file, () => {
|
|
458
|
+
const state = this.read();
|
|
459
|
+
if (!Number.isInteger(expectedSequence) || state.sequence !== expectedSequence)
|
|
460
|
+
throw new Error('stale-sequence');
|
|
461
|
+
if (state.finalization || state.checkpoint?.receipt.finalizationState === 'pending')
|
|
462
|
+
throw new Error('finalization-started');
|
|
463
|
+
return fn(state);
|
|
464
|
+
});
|
|
465
|
+
}
|
|
466
|
+
prepareOutcomeAssessment(record, expectedSequence, sourcePaths) {
|
|
467
|
+
(0, outcome_types_1.validateOutcomeAssessmentRecord)(record);
|
|
468
|
+
if (!Array.isArray(sourcePaths) || sourcePaths.length !== record.sourceRefs.length || sourcePaths.some(file => !node_path_1.default.isAbsolute(file)))
|
|
469
|
+
throw new Error('invalid-assessment-sources');
|
|
470
|
+
this.mutate(expectedSequence, state => {
|
|
471
|
+
if (record.runId !== state.runId || record.reviewer !== state.reviewer)
|
|
472
|
+
throw new Error('run-context-mismatch');
|
|
473
|
+
if ([...(state.outcomeAssessments ?? []), ...(state.pendingOutcomeAssessments ?? [])].some(item => item.digest === record.digest))
|
|
474
|
+
return;
|
|
475
|
+
this.append('assessment-prepared', { record, sourcePaths });
|
|
476
|
+
});
|
|
477
|
+
}
|
|
478
|
+
commitOutcomeAssessment(digest, expectedSequence) {
|
|
479
|
+
this.mutate(expectedSequence, state => {
|
|
480
|
+
if (state.outcomeAssessments?.some(item => item.digest === digest))
|
|
481
|
+
return;
|
|
482
|
+
if (!state.pendingOutcomeAssessments?.some(item => item.digest === digest))
|
|
483
|
+
throw new Error('assessment-not-prepared');
|
|
484
|
+
this.append('assessment-committed', { digest });
|
|
485
|
+
});
|
|
486
|
+
}
|
|
487
|
+
propose(input, expectedSequence) {
|
|
488
|
+
input = reviewedInput(input);
|
|
489
|
+
validateAction(input);
|
|
490
|
+
return this.mutate(expectedSequence, state => {
|
|
491
|
+
for (const dependency of input.dependencies)
|
|
492
|
+
if (!state.actions[dependency])
|
|
493
|
+
throw new Error('missing-dependency');
|
|
494
|
+
const prior = Object.values(state.actions).find(a => a.claimKey === input.claimKey && a.evidenceFingerprint === input.evidenceFingerprint && (0, owner_identity_1.ownerKey)(a.target.owner) === (0, owner_identity_1.ownerKey)(input.target.owner));
|
|
495
|
+
if (prior)
|
|
496
|
+
return prior;
|
|
497
|
+
for (const name of node_fs_1.default.readdirSync(this.directory).filter(n => n.endsWith('-completion.md'))) {
|
|
498
|
+
const history = node_fs_1.default.readFileSync(node_path_1.default.join(this.directory, name), 'utf8');
|
|
499
|
+
let completed;
|
|
500
|
+
try {
|
|
501
|
+
completed = (0, completion_state_1.decodeCompletion)(history);
|
|
502
|
+
}
|
|
503
|
+
catch {
|
|
504
|
+
continue;
|
|
505
|
+
} // Historical formats are interpreted by the agent, not imported here.
|
|
506
|
+
if (completed.decisions.some(d => d.claimKey === input.claimKey && d.evidenceFingerprint === input.evidenceFingerprint && (0, owner_identity_1.ownerKey)(d.owner) === (0, owner_identity_1.ownerKey)(input.target.owner)))
|
|
507
|
+
throw new Error('already-reviewed: unchanged evidence');
|
|
508
|
+
}
|
|
509
|
+
const action = { ...structuredClone(input), actionId: (0, node_crypto_1.randomUUID)(), revision: 1 };
|
|
510
|
+
action.digest = actionDigest(action);
|
|
511
|
+
this.append('action', action);
|
|
512
|
+
return action;
|
|
513
|
+
});
|
|
514
|
+
}
|
|
515
|
+
edit(actionId, revision, input, expectedSequence) {
|
|
516
|
+
input = reviewedInput(input);
|
|
517
|
+
validateAction(input);
|
|
518
|
+
return this.mutate(expectedSequence, state => {
|
|
519
|
+
const prior = state.actions[actionId];
|
|
520
|
+
if (!prior || prior.revision !== revision)
|
|
521
|
+
throw new Error('stale-revision');
|
|
522
|
+
if (state.checkpoint?.receipt.decisions.some(item => item.actionId === actionId))
|
|
523
|
+
throw new Error('action-completed');
|
|
524
|
+
if (prior.application)
|
|
525
|
+
throw new Error('application-started');
|
|
526
|
+
if (prior.content && input.content && (0, completion_state_1.canonicalJournal)(prior.content.trace) !== (0, completion_state_1.canonicalJournal)(input.content.trace))
|
|
527
|
+
throw new Error('immutable-history');
|
|
528
|
+
if (prior.target.entryId !== input.target.entryId)
|
|
529
|
+
throw new Error('immutable-entry-id');
|
|
530
|
+
if (input.dependencies.includes(actionId) || input.dependencies.some(id => !state.actions[id]))
|
|
531
|
+
throw new Error('invalid-dependencies');
|
|
532
|
+
const reaches = (id, visited = new Set()) => {
|
|
533
|
+
if (id === actionId)
|
|
534
|
+
return true;
|
|
535
|
+
if (visited.has(id))
|
|
536
|
+
return false;
|
|
537
|
+
visited.add(id);
|
|
538
|
+
return state.actions[id]?.dependencies.some(dependency => reaches(dependency, visited)) || false;
|
|
539
|
+
};
|
|
540
|
+
if (input.dependencies.some(id => reaches(id)))
|
|
541
|
+
throw new Error('dependency-cycle');
|
|
542
|
+
const action = { ...structuredClone(input), actionId, revision: revision + 1 };
|
|
543
|
+
action.digest = actionDigest(action);
|
|
544
|
+
this.append('action', action);
|
|
545
|
+
return action;
|
|
546
|
+
});
|
|
547
|
+
}
|
|
548
|
+
decide(actionId, revision, decision, expectedSequence) {
|
|
549
|
+
return this.mutate(expectedSequence, state => {
|
|
550
|
+
const action = state.actions[actionId];
|
|
551
|
+
if (!action || action.revision !== revision)
|
|
552
|
+
throw new Error('stale-revision');
|
|
553
|
+
if (state.checkpoint?.receipt.decisions.some(item => item.actionId === actionId))
|
|
554
|
+
throw new Error('action-completed');
|
|
555
|
+
if (action.application || decision.reviewer !== state.reviewer || typeof decision.reason !== 'string' || !decision.reason.trim() || !['approve', 'reject'].includes(decision.action) || (decision.revisitCondition !== undefined && (decision.action !== 'reject' || typeof decision.revisitCondition !== 'string' || !decision.revisitCondition.trim())) || !validDecisionProvenance(decision))
|
|
556
|
+
throw new Error('invalid-decision');
|
|
557
|
+
this.append('decision', { actionId, decision: { ...decision, revision, digest: action.digest, at: new Date().toISOString() } });
|
|
558
|
+
return this.read().actions[actionId];
|
|
559
|
+
});
|
|
560
|
+
}
|
|
561
|
+
decideBatch(tuples, reviewer, reason, expectedSequence) {
|
|
562
|
+
return this.mutate(expectedSequence, state => {
|
|
563
|
+
if (reviewer !== state.reviewer || !reason.trim() || !tuples.length || new Set(tuples.map(t => t.actionId)).size !== tuples.length)
|
|
564
|
+
throw new Error('invalid-batch-decision');
|
|
565
|
+
for (const tuple of tuples) {
|
|
566
|
+
const action = state.actions[tuple.actionId];
|
|
567
|
+
if (!action || action.application || action.revision !== tuple.revision || action.digest !== tuple.digest)
|
|
568
|
+
throw new Error('stale-batch-decision');
|
|
569
|
+
if (state.checkpoint?.receipt.decisions.some(item => item.actionId === tuple.actionId))
|
|
570
|
+
throw new Error('action-completed');
|
|
571
|
+
}
|
|
572
|
+
// One event makes batch approval interruption-atomic.
|
|
573
|
+
this.append('decision-batch', tuples.map(tuple => ({ actionId: tuple.actionId, decision: { action: 'approve', revision: tuple.revision, digest: tuple.digest, reviewer, reason, at: new Date().toISOString() } })));
|
|
574
|
+
return this.read();
|
|
575
|
+
});
|
|
576
|
+
}
|
|
577
|
+
recoverTail(expectedPrefixHash) {
|
|
578
|
+
return (0, proposal_queue_1.withFileLock)(this.file, () => {
|
|
579
|
+
const raw = node_fs_1.default.readFileSync(this.file, 'utf8');
|
|
580
|
+
if (raw.endsWith('\n'))
|
|
581
|
+
return this.read();
|
|
582
|
+
const end = raw.lastIndexOf('\n') + 1;
|
|
583
|
+
const prefix = raw.slice(0, end);
|
|
584
|
+
if (!end || (0, proposal_queue_1.hashContent)(prefix) !== expectedPrefixHash)
|
|
585
|
+
throw new Error('recovery-prefix-conflict');
|
|
586
|
+
// Validate the intact prefix through the same replay parser before truncating.
|
|
587
|
+
const temporary = this.file + '.recovery-check';
|
|
588
|
+
try {
|
|
589
|
+
node_fs_1.default.writeFileSync(temporary, prefix, { flag: 'wx' });
|
|
590
|
+
this.assertCompletionDirectory(replay(temporary).state);
|
|
591
|
+
}
|
|
592
|
+
finally {
|
|
593
|
+
if (node_fs_1.default.existsSync(temporary))
|
|
594
|
+
node_fs_1.default.unlinkSync(temporary);
|
|
595
|
+
}
|
|
596
|
+
const fd = node_fs_1.default.openSync(this.file, 'r+');
|
|
597
|
+
try {
|
|
598
|
+
node_fs_1.default.ftruncateSync(fd, Buffer.byteLength(prefix));
|
|
599
|
+
node_fs_1.default.fsyncSync(fd);
|
|
600
|
+
}
|
|
601
|
+
finally {
|
|
602
|
+
node_fs_1.default.closeSync(fd);
|
|
603
|
+
}
|
|
604
|
+
return this.read();
|
|
605
|
+
});
|
|
606
|
+
}
|
|
607
|
+
progress(actionId, application) {
|
|
608
|
+
const state = this.read();
|
|
609
|
+
const action = state.actions[actionId];
|
|
610
|
+
if (!action || action.decision?.action !== 'approve' || action.decision.digest !== action.digest)
|
|
611
|
+
throw new Error('not-approved');
|
|
612
|
+
validateApplication(action, application);
|
|
613
|
+
this.append('application', { actionId, digest: action.digest, application });
|
|
614
|
+
}
|
|
615
|
+
recordCleanup(item, expectedSequence) {
|
|
616
|
+
// The normal run location supplies an unambiguous same-owner home for
|
|
617
|
+
// direct registrations. Cross-owner reconciliations bind their resolved home explicitly.
|
|
618
|
+
if (!item.location) {
|
|
619
|
+
const root = node_fs_1.default.realpathSync.native(this.owner.path);
|
|
620
|
+
for (const contentRoot of [node_path_1.default.join(root, 'personalized-employee'), root]) {
|
|
621
|
+
const relative = node_path_1.default.relative(contentRoot, item.path).replace(/\\/g, '/');
|
|
622
|
+
if (exports.RECOVERY_DIRECTORIES.some(directory => relative.startsWith(`learnings/${directory}/`))) {
|
|
623
|
+
item = bindCleanupItem(item, { owner: this.owner, level: this.owner.level, root, contentRoot, backend: 'local-folder', requiresPublish: false });
|
|
624
|
+
break;
|
|
625
|
+
}
|
|
626
|
+
}
|
|
627
|
+
}
|
|
628
|
+
this.mutate(expectedSequence, () => this.append('cleanup', item));
|
|
629
|
+
}
|
|
630
|
+
/** Record agent interpretation only after exact source identity and current decisions are bound. */
|
|
631
|
+
reconcileSource(input, home, expectedSequence) {
|
|
632
|
+
(0, owner_identity_1.assertSameOwner)(input.owner, home.owner);
|
|
633
|
+
const file = (0, owner_identity_1.ownerArtifactPath)(home, input.relativePath);
|
|
634
|
+
const cleanup = bindCleanupItem({ path: file, sha256: input.sha256,
|
|
635
|
+
outcome: input.disposition === 'accounted' ? 'pending' : 'retained',
|
|
636
|
+
reason: input.disposition === 'accounted' ? 'reviewed-source' : input.disposition }, home);
|
|
637
|
+
if (!node_fs_1.default.lstatSync(file).isFile() || (0, proposal_queue_1.hashContent)(node_fs_1.default.readFileSync(file, 'utf8')) !== input.sha256)
|
|
638
|
+
throw new Error('source-content-conflict');
|
|
639
|
+
return this.mutate(expectedSequence, state => {
|
|
640
|
+
const reconciliation = {
|
|
641
|
+
sourceRef: file, sourceHash: input.sha256, owner: input.owner, relativePath: input.relativePath,
|
|
642
|
+
disposition: input.disposition, rationale: input.rationale, preservedDecisions: input.preservedDecisions, reviewer: input.reviewer,
|
|
643
|
+
actionIds: input.actionIds, actionBindings: (input.actionIds || []).map(actionId => {
|
|
644
|
+
const action = state.actions[actionId];
|
|
645
|
+
if (!action)
|
|
646
|
+
throw new Error('reconciliation-action-missing');
|
|
647
|
+
return { actionId, revision: action.revision, digest: action.digest };
|
|
648
|
+
}),
|
|
649
|
+
};
|
|
650
|
+
validateReconciliation(reconciliation, state);
|
|
651
|
+
this.append('reconciliation', { reconciliation, cleanup });
|
|
652
|
+
return this.read();
|
|
653
|
+
});
|
|
654
|
+
}
|
|
655
|
+
assertReconciliations() {
|
|
656
|
+
const state = this.read();
|
|
657
|
+
for (const item of state.reconciliations)
|
|
658
|
+
validateReconciliation(item, state);
|
|
659
|
+
}
|
|
660
|
+
prepareSourceCompletion(sources, expectedSequence, extend = false) {
|
|
661
|
+
(0, completion_state_1.validateProcessedSources)(sources);
|
|
662
|
+
this.mutate(expectedSequence, state => {
|
|
663
|
+
if (state.processedSources && (0, completion_state_1.canonicalJournal)(state.processedSources) !== (0, completion_state_1.canonicalJournal)(sources) && (!extend || state.processedSources.some(prior => !sources.some(item => (0, completion_state_1.canonicalJournal)(item) === (0, completion_state_1.canonicalJournal)(prior)))))
|
|
664
|
+
throw new Error('completion-sources-conflict');
|
|
665
|
+
if ((0, completion_state_1.canonicalJournal)(state.processedSources) !== (0, completion_state_1.canonicalJournal)(sources))
|
|
666
|
+
this.append('source-completion', sources);
|
|
667
|
+
});
|
|
668
|
+
}
|
|
669
|
+
recordCorpusAudit(input, expectedSequence) {
|
|
670
|
+
return this.mutate(expectedSequence, state => {
|
|
671
|
+
const audit = structuredClone(input);
|
|
672
|
+
for (const disposition of audit.dispositions)
|
|
673
|
+
disposition.actionBindings = disposition.actionIds.map(id => {
|
|
674
|
+
const action = state.actions[id];
|
|
675
|
+
if (!action)
|
|
676
|
+
throw new Error('corpus-audit-action-missing');
|
|
677
|
+
const refs = [action.target, ...(action.source ? [action.source] : []), ...(action.copies?.map(copy => copy.ref) || [])];
|
|
678
|
+
if (!refs.some(ref => (0, corpus_audit_1.corpusEntryKey)({ ref, sha256: '' }) === (0, corpus_audit_1.corpusEntryKey)(disposition)))
|
|
679
|
+
throw new Error('corpus-audit-action-unrelated');
|
|
680
|
+
return { actionId: id, revision: action.revision, digest: action.digest };
|
|
681
|
+
});
|
|
682
|
+
(0, corpus_audit_1.validateCorpusAudit)(audit, state.reviewer);
|
|
683
|
+
this.append('corpus-audit', audit);
|
|
684
|
+
return this.read();
|
|
685
|
+
});
|
|
686
|
+
}
|
|
687
|
+
complete(summary, inventory, afterStage, verifyApplied, retrospective, finishSources, deferred) {
|
|
688
|
+
return (0, proposal_queue_1.withFileLock)(this.file, () => {
|
|
689
|
+
assertNoRunSyncConflicts(this.directory, this.runId);
|
|
690
|
+
const inspect = () => {
|
|
691
|
+
const items = new Map();
|
|
692
|
+
for (const item of inventory()) {
|
|
693
|
+
const key = node_path_1.default.resolve(item.path);
|
|
694
|
+
const prior = items.get(key);
|
|
695
|
+
if (prior && (0, completion_state_1.canonicalJournal)(prior) !== (0, completion_state_1.canonicalJournal)(item))
|
|
696
|
+
throw new Error('cleanup-inventory-conflict');
|
|
697
|
+
if (item.outcome === 'retained' && item.reason !== 'unrelated' && item.reason !== 'raw-source')
|
|
698
|
+
throw new Error('cleanup-unresolved');
|
|
699
|
+
items.set(key, item);
|
|
700
|
+
}
|
|
701
|
+
return [...items.values()];
|
|
702
|
+
};
|
|
703
|
+
const finishedInventory = () => {
|
|
704
|
+
const items = inspect();
|
|
705
|
+
if (items.some(item => item.outcome === 'pending'))
|
|
706
|
+
throw new Error('cleanup-incomplete');
|
|
707
|
+
return items;
|
|
708
|
+
};
|
|
709
|
+
if (!node_fs_1.default.existsSync(this.file)) {
|
|
710
|
+
const result = (0, completion_state_1.decodeCompletion)(node_fs_1.default.readFileSync(this.completionFile, 'utf8'), { runId: this.runId, owner: this.owner });
|
|
711
|
+
if (result.finalizationState !== 'complete')
|
|
712
|
+
throw new Error('incomplete-finalization');
|
|
713
|
+
if ((0, completion_state_1.canonicalJournal)(result.retrospective) !== (0, completion_state_1.canonicalJournal)(retrospective))
|
|
714
|
+
throw new Error('completion-retrospective-conflict');
|
|
715
|
+
verifyApplied?.(true);
|
|
716
|
+
finishedInventory();
|
|
717
|
+
return result;
|
|
718
|
+
}
|
|
719
|
+
const state = this.read();
|
|
720
|
+
if (state.pendingOutcomeAssessments?.length && !deferred)
|
|
721
|
+
throw new Error('assessment-processing-pending');
|
|
722
|
+
if (!summary?.trim() || summary.includes('fraim-sleep-decisions'))
|
|
723
|
+
throw new Error('invalid-completion-summary');
|
|
724
|
+
const actions = deferred ? completedRunActions(state) : Object.values(state.actions);
|
|
725
|
+
if (deferred) {
|
|
726
|
+
(0, completion_state_1.validateDeferredWork)(deferred);
|
|
727
|
+
const ids = new Set(actions.map(action => action.actionId));
|
|
728
|
+
if ((0, completion_state_1.canonicalJournal)(deferred.actions.map(item => [item.actionId, item.revision, item.digest])) !== (0, completion_state_1.canonicalJournal)(Object.values(state.actions).filter(action => !ids.has(action.actionId)).map(action => [action.actionId, action.revision, action.digest])))
|
|
729
|
+
throw new Error('checkpoint-action-conflict');
|
|
730
|
+
}
|
|
731
|
+
else {
|
|
732
|
+
if (state.corpusAuditRequired && !state.corpusAudit)
|
|
733
|
+
throw new Error('corpus-audit-required');
|
|
734
|
+
this.assertReconciliations();
|
|
735
|
+
if (actions.some(a => !a.decision || (a.decision.action === 'approve' && a.application?.stage !== 'applied') || (a.decision.action !== 'approve' && a.application)))
|
|
736
|
+
throw new Error('run-unfinished');
|
|
737
|
+
}
|
|
738
|
+
if (actions.some(action => action.sourceRefs.some(ref => !action.sourceHashes?.[ref])))
|
|
739
|
+
throw new Error('source-hashes-required: record the actual source hash before completion');
|
|
740
|
+
if ((deferred || state.corpusAudit || actions.some(action => action.application)) && !verifyApplied)
|
|
741
|
+
throw new Error('completion-verifier-required');
|
|
742
|
+
verifyApplied?.();
|
|
743
|
+
const snapshot = deferred ? [] : inspect();
|
|
744
|
+
const reconciliationHistory = actions.filter(action => action.operation === 'reconcile' && action.application?.stage === 'applied').map(action => ({
|
|
745
|
+
actionId: action.actionId, revision: action.revision, digest: action.digest, target: action.target,
|
|
746
|
+
resultHash: (0, proposal_queue_1.hashContent)(action.application.steps[0].after),
|
|
747
|
+
occurrences: action.application.steps.filter(step => step.before !== null).map(step => ({ ref: step.ref, sha256: (0, proposal_queue_1.hashContent)(step.before), before: step.before })),
|
|
748
|
+
}));
|
|
749
|
+
const result = {
|
|
750
|
+
schemaVersion: 1, runId: this.runId, owner: this.owner, finalizationState: 'pending',
|
|
751
|
+
decisions: actions.map(a => ({ actionId: a.actionId, revision: a.revision, digest: a.digest, owner: a.target.owner, target: a.target, claimKey: a.claimKey, evidenceFingerprint: a.evidenceFingerprint, sourceRefs: a.sourceRefs, decision: a.decision, dependencies: a.dependencies.map(id => ({ actionId: id, outcome: state.actions[id].decision?.action === 'approve' ? 'applied' : 'rejected' })), ...(a.application?.publicationRef ? { publicationRef: a.application.publicationRef } : {}) })),
|
|
752
|
+
sourceDispositions: actions.flatMap(a => a.sourceRefs.map(sourceRef => ({ sourceRef, sourceHash: a.sourceHashes[sourceRef], outcome: a.decision.action === 'approve' ? 'applied' : 'rejected', actionId: a.actionId }))),
|
|
753
|
+
cleanup: snapshot,
|
|
754
|
+
...(state.processedSources ? { processedSources: state.processedSources } : {}),
|
|
755
|
+
...(state.sourceParticipants !== undefined ? { sourceParticipants: state.sourceParticipants } : {}),
|
|
756
|
+
...(state.outcomeAssessments?.length ? { outcomeAssessments: state.outcomeAssessments } : {}),
|
|
757
|
+
...(retrospective ? { retrospective } : {}),
|
|
758
|
+
...(deferred ? { deferred } : {
|
|
759
|
+
...(state.reconciliations.length ? { reconciliations: state.reconciliations } : {}),
|
|
760
|
+
...(state.corpusAudit ? { corpusAudit: state.corpusAudit } : {}),
|
|
761
|
+
...(reconciliationHistory.length ? { reconciliationHistory } : {}),
|
|
762
|
+
...(state.deferredHistory?.length ? { deferredHistory: state.deferredHistory } : {}),
|
|
763
|
+
...(state.deferredAttempts?.length ? { deferredAttempts: state.deferredAttempts } : {}),
|
|
764
|
+
}),
|
|
765
|
+
};
|
|
766
|
+
let narrative = `---\nartifactKind: work-evidence\ncontainsFeedback: ${actions.length > 0}\ndate: ${new Date().toISOString().slice(0, 10)}\n---\n# Sleep completion: ${this.runId}\n\n${summary}\n\nApplied: ${actions.filter(a => a.application?.stage === 'applied').length}; rejected: ${actions.filter(a => a.decision?.action === 'reject').length}.\n\n## Decisions and source disposition\n\n`;
|
|
767
|
+
// Submission and feedback already own this path before the completion receipt exists.
|
|
768
|
+
const existingEvidence = node_fs_1.default.existsSync(this.completionFile) ? node_fs_1.default.readFileSync(this.completionFile, 'utf8') : undefined;
|
|
769
|
+
const hasReceipt = existingEvidence?.includes('fraim-sleep-decisions');
|
|
770
|
+
if (existingEvidence && !hasReceipt) {
|
|
771
|
+
const header = existingEvidence.match(/^---\r?\n([\s\S]*?)\r?\n---(?:\r?\n|$)/);
|
|
772
|
+
if (!header || (header[1].match(/^artifactKind:/gm) || []).length !== 1 || !/^artifactKind: work-evidence\s*$/m.test(header[1])
|
|
773
|
+
|| (header[1].match(/^containsFeedback:/gm) || []).length !== 1 || !/^containsFeedback: (true|false)\s*$/m.test(header[1]))
|
|
774
|
+
throw new Error('completion-evidence-conflict');
|
|
775
|
+
narrative = existingEvidence + `\n\n## Sleep completion: ${this.runId}\n\n${summary}\n\n## Decisions and source disposition\n\n`;
|
|
776
|
+
}
|
|
777
|
+
let trailingHistory = '\n';
|
|
778
|
+
const writeEvidence = () => {
|
|
779
|
+
if (actions.length)
|
|
780
|
+
narrative = narrative.replace(/^---\r?\n([\s\S]*?)\r?\n---(?:\r?\n|$)/, header => header.replace(/^containsFeedback: false[ \t]*(?=\r?$)/m, 'containsFeedback: true'));
|
|
781
|
+
const encoded = JSON.stringify(result, null, 2).split('<').join(String.raw `\u003c`);
|
|
782
|
+
const body = narrative + `<!-- fraim-sleep-decisions:v1 -->\n\x60\x60\x60json\n${encoded}\n\x60\x60\x60\n<!-- /fraim-sleep-decisions -->` + trailingHistory;
|
|
783
|
+
(0, completion_state_1.decodeCompletion)(body, { runId: this.runId, owner: this.owner });
|
|
784
|
+
const previousEvidenceHash = node_fs_1.default.existsSync(this.completionFile) ? (0, proposal_queue_1.hashContent)(node_fs_1.default.readFileSync(this.completionFile, 'utf8')) : undefined;
|
|
785
|
+
// A replacement intent names both recoverable states. A crash before
|
|
786
|
+
// or after the atomic rename can resume without accepting a third state.
|
|
787
|
+
if (deferred)
|
|
788
|
+
this.append('checkpoint', { evidenceHash: (0, proposal_queue_1.hashContent)(body), previousEvidenceHash, receipt: result });
|
|
789
|
+
else if (previousEvidenceHash)
|
|
790
|
+
this.append('finalization', { evidenceHash: (0, proposal_queue_1.hashContent)(body), previousEvidenceHash });
|
|
791
|
+
(0, proposal_queue_1.atomicWrite)(this.completionFile, body);
|
|
792
|
+
const verified = node_fs_1.default.readFileSync(this.completionFile, 'utf8');
|
|
793
|
+
if ((0, proposal_queue_1.hashContent)(verified) !== (0, proposal_queue_1.hashContent)(body))
|
|
794
|
+
throw new Error('completion-write-conflict');
|
|
795
|
+
(0, completion_state_1.decodeCompletion)(verified, { runId: this.runId, owner: this.owner });
|
|
796
|
+
if (!deferred && !previousEvidenceHash)
|
|
797
|
+
this.append('finalization', { evidenceHash: (0, proposal_queue_1.hashContent)(body) });
|
|
798
|
+
};
|
|
799
|
+
if (node_fs_1.default.existsSync(this.completionFile) && hasReceipt) {
|
|
800
|
+
const body = node_fs_1.default.readFileSync(this.completionFile, 'utf8');
|
|
801
|
+
const prior = (0, completion_state_1.decodeCompletion)(body, { runId: this.runId, owner: this.owner });
|
|
802
|
+
if (!deferred && !prior.deferred && prior.finalizationState === 'complete' && (0, completion_state_1.canonicalJournal)(prior.retrospective) !== (0, completion_state_1.canonicalJournal)(retrospective))
|
|
803
|
+
throw new Error('completion-retrospective-conflict');
|
|
804
|
+
if (!deferred && state.finalization && ![state.finalization.evidenceHash, state.finalization.previousEvidenceHash].includes((0, proposal_queue_1.hashContent)(body)))
|
|
805
|
+
throw new Error('completion-evidence-conflict');
|
|
806
|
+
if (deferred || prior.deferred) {
|
|
807
|
+
if (!state.checkpoint || ![state.checkpoint.evidenceHash, state.checkpoint.previousEvidenceHash].includes((0, proposal_queue_1.hashContent)(body))
|
|
808
|
+
|| prior.decisions.some(item => !result.decisions.some(current => (0, completion_state_1.canonicalJournal)(current) === (0, completion_state_1.canonicalJournal)(item)))
|
|
809
|
+
|| prior.processedSources?.some(item => !result.processedSources?.some(current => (0, completion_state_1.canonicalJournal)(current) === (0, completion_state_1.canonicalJournal)(item))))
|
|
810
|
+
throw new Error('completion-decisions-conflict');
|
|
811
|
+
}
|
|
812
|
+
else if ((0, completion_state_1.canonicalJournal)(prior.decisions) !== (0, completion_state_1.canonicalJournal)(result.decisions) || (0, completion_state_1.canonicalJournal)(prior.sourceDispositions) !== (0, completion_state_1.canonicalJournal)(result.sourceDispositions) || (0, completion_state_1.canonicalJournal)(prior.reconciliations) !== (0, completion_state_1.canonicalJournal)(result.reconciliations) || (0, completion_state_1.canonicalJournal)(prior.processedSources) !== (0, completion_state_1.canonicalJournal)(result.processedSources) || (0, completion_state_1.canonicalJournal)(prior.corpusAudit) !== (0, completion_state_1.canonicalJournal)(result.corpusAudit) || (0, completion_state_1.canonicalJournal)(prior.reconciliationHistory) !== (0, completion_state_1.canonicalJournal)(result.reconciliationHistory) || (0, completion_state_1.canonicalJournal)(prior.outcomeAssessments) !== (0, completion_state_1.canonicalJournal)(result.outcomeAssessments))
|
|
813
|
+
throw new Error('completion-decisions-conflict');
|
|
814
|
+
if (deferred && prior.finalizationState === 'complete' && (0, completion_state_1.canonicalJournal)({ ...prior, finalizationState: 'pending' }) === (0, completion_state_1.canonicalJournal)(result)) {
|
|
815
|
+
finishSources?.();
|
|
816
|
+
verifyApplied?.();
|
|
817
|
+
this.append('deferred-attempt', { at: new Date().toISOString(), evidenceHash: (0, proposal_queue_1.hashContent)(body), reason: 'verified-subset-unchanged' });
|
|
818
|
+
return prior;
|
|
819
|
+
}
|
|
820
|
+
// While pending, the agent may update process evidence and the
|
|
821
|
+
// retrospective to explain a failed completion attempt. Exact
|
|
822
|
+
// approved actions stay fixed; the replacement intent binds new bytes.
|
|
823
|
+
narrative = body.slice(0, body.indexOf('<!-- fraim-sleep-decisions:v1 -->'));
|
|
824
|
+
if (prior.deferred && (!deferred || (0, completion_state_1.canonicalJournal)({ ...prior, finalizationState: 'pending' }) !== (0, completion_state_1.canonicalJournal)(result)))
|
|
825
|
+
narrative += `\n## Recovery completion\n\n${summary}\n\n`;
|
|
826
|
+
trailingHistory = body.slice(body.indexOf('<!-- /fraim-sleep-decisions -->') + '<!-- /fraim-sleep-decisions -->'.length);
|
|
827
|
+
if (!deferred && !prior.deferred && prior.finalizationState === 'complete' && !snapshot.some(item => item.outcome === 'pending')) {
|
|
828
|
+
finishedInventory();
|
|
829
|
+
finishSources?.();
|
|
830
|
+
verifyApplied?.(true);
|
|
831
|
+
node_fs_1.default.unlinkSync(this.file);
|
|
832
|
+
return prior;
|
|
833
|
+
}
|
|
834
|
+
if (!deferred && !prior.deferred && prior.finalizationState === 'complete' && snapshot.some(item => item.outcome === 'pending'))
|
|
835
|
+
throw new Error('cleanup-content-reappeared');
|
|
836
|
+
// The fresh inventory authorizes every remaining deletion. Never
|
|
837
|
+
// execute a path merely because it appeared in prior evidence.
|
|
838
|
+
for (const item of deferred ? [] : prior.cleanup) {
|
|
839
|
+
const found = snapshot.find(current => sameCleanupIdentity(item, current));
|
|
840
|
+
if (!found)
|
|
841
|
+
throw new Error('cleanup-inventory-missing');
|
|
842
|
+
if ((item.outcome === 'deleted' || item.outcome === 'absent') && found.outcome === 'pending')
|
|
843
|
+
throw new Error('cleanup-content-reappeared');
|
|
844
|
+
if (found.outcome === 'absent' && item.outcome === 'deleted')
|
|
845
|
+
found.outcome = 'deleted';
|
|
846
|
+
}
|
|
847
|
+
}
|
|
848
|
+
writeEvidence();
|
|
849
|
+
afterStage?.('evidence');
|
|
850
|
+
finishSources?.();
|
|
851
|
+
afterStage?.('sources');
|
|
852
|
+
if (!deferred) {
|
|
853
|
+
for (const item of result.cleanup) {
|
|
854
|
+
if (item.outcome !== 'pending')
|
|
855
|
+
continue;
|
|
856
|
+
const authorized = inspect().find(current => node_path_1.default.resolve(current.path) === node_path_1.default.resolve(item.path));
|
|
857
|
+
if (!authorized || !['pending', 'absent'].includes(authorized.outcome) || authorized.sha256 !== item.sha256)
|
|
858
|
+
throw new Error('cleanup-authorization-changed');
|
|
859
|
+
if (node_fs_1.default.existsSync(item.path)) {
|
|
860
|
+
if (!item.sha256 || (0, proposal_queue_1.hashContent)(node_fs_1.default.readFileSync(item.path, 'utf8')) !== item.sha256)
|
|
861
|
+
throw new Error('cleanup-content-conflict');
|
|
862
|
+
node_fs_1.default.unlinkSync(item.path);
|
|
863
|
+
item.outcome = 'deleted';
|
|
864
|
+
}
|
|
865
|
+
else
|
|
866
|
+
item.outcome = 'absent';
|
|
867
|
+
writeEvidence();
|
|
868
|
+
afterStage?.('cleanup');
|
|
869
|
+
}
|
|
870
|
+
const finalInventory = finishedInventory();
|
|
871
|
+
const completed = new Map(result.cleanup.map(item => [node_path_1.default.resolve(item.path), item]));
|
|
872
|
+
for (const item of finalInventory)
|
|
873
|
+
if (!completed.has(node_path_1.default.resolve(item.path)))
|
|
874
|
+
result.cleanup.push(item);
|
|
875
|
+
}
|
|
876
|
+
else
|
|
877
|
+
verifyApplied?.();
|
|
878
|
+
result.finalizationState = 'complete';
|
|
879
|
+
writeEvidence();
|
|
880
|
+
afterStage?.('complete-evidence');
|
|
881
|
+
const binding = deferred ? this.read().checkpoint : this.read().finalization;
|
|
882
|
+
if ((0, proposal_queue_1.hashContent)(node_fs_1.default.readFileSync(this.completionFile, 'utf8')) !== binding?.evidenceHash)
|
|
883
|
+
throw new Error('completion-evidence-conflict');
|
|
884
|
+
// Complete attests this receipt's terminal subset. Only full coverage permits retiring run recovery.
|
|
885
|
+
if (!deferred) {
|
|
886
|
+
finishedInventory();
|
|
887
|
+
finishSources?.();
|
|
888
|
+
verifyApplied?.(true);
|
|
889
|
+
node_fs_1.default.unlinkSync(this.file);
|
|
890
|
+
}
|
|
891
|
+
return result;
|
|
892
|
+
});
|
|
893
|
+
}
|
|
894
|
+
}
|
|
895
|
+
exports.RunRecord = RunRecord;
|