Files
qinglong/test/back/approvedActionRecoveryReconciler.test.cjs

313 lines
8.9 KiB
JavaScript

require('ts-node/register/transpile-only');
const assert = require('node:assert/strict');
const { test } = require('node:test');
const {
ApprovedActionRecoveryReconciler,
} = require('../../back/runtime/application/approvedActionRecoveryReconciler');
function recoverySnapshot() {
return {
action: {
dispatch: {
id: 'dispatch-one',
approvalRequestId: 'approval-one',
approvalRequestVersion: 3,
projectId: 'default',
state: 'pending',
action: {
permission: 'tool.call:filesystem.write',
actionType: 'tool_call',
actionRef: 'planned-one',
actionDigest: 'a'.repeat(64),
previewDigest: 'f'.repeat(64),
},
requestedBy: { type: 'agent', id: 'agent-1' },
consumedBy: { type: 'system', id: 'approval-dispatcher' },
createdAtMs: 100,
},
execution: {
dispatchId: 'dispatch-one',
projectId: 'default',
status: 'executing',
version: 2,
attemptCount: 1,
maxAttempts: 5,
eligibleAtMs: null,
nextAttemptAtMs: null,
leaseOwner: 'dispatcher-1',
leaseToken: 'execution-lease-1',
leaseExpiresAtMs: 200,
startedAtMs: 110,
resultMutationId: null,
lastResultCode: null,
completedAtMs: null,
createdAtMs: 100,
updatedAtMs: 110,
},
},
recovery: {
dispatchId: 'dispatch-one',
projectId: 'default',
executionVersion: 2,
status: 'armed',
version: 0,
nextScanAtMs: 200,
leaseOwner: null,
leaseToken: null,
leaseExpiresAtMs: null,
findingCount: 0,
lastFindingMutationId: null,
lastFinding: null,
lastResultCode: null,
lastEvidenceDigest: null,
resolutionMutationId: null,
createdAtMs: 110,
updatedAtMs: 110,
},
resolution: null,
};
}
function fakeRepository() {
const calls = [];
const initial = recoverySnapshot();
return {
calls,
async listDue(query) {
calls.push(['list', query]);
return { recoveries: [initial], truncated: false };
},
async claim(command) {
calls.push(['claim', command]);
return {
status: 'claimed',
snapshot: {
...initial,
recovery: {
...initial.recovery,
status: 'leased',
version: 1,
nextScanAtMs: command.nowMs + command.leaseDurationMs,
leaseOwner: command.owner,
leaseToken: command.leaseToken,
leaseExpiresAtMs: command.nowMs + command.leaseDurationMs,
updatedAtMs: command.nowMs,
},
},
};
},
async recordFinding(command) {
calls.push(['finding', command]);
return {
...initial,
recovery: {
...initial.recovery,
status: command.retryAtMs ? 'armed' : 'manual_required',
version: command.expectedRecoveryVersion + 1,
nextScanAtMs: command.retryAtMs ?? null,
findingCount: 1,
lastFindingMutationId: command.findingMutationId,
lastFinding: command.finding,
lastResultCode: command.resultCode,
lastEvidenceDigest: command.evidenceDigest ?? null,
updatedAtMs: command.observedAtMs,
},
};
},
async resolve(command) {
calls.push(['resolve', command]);
const status =
command.decision === 'confirm_succeeded' ? 'succeeded' : 'failed';
return {
status: 'resolved',
snapshot: {
...initial,
action: {
...initial.action,
execution: {
...initial.action.execution,
status,
version: command.expectedExecutionVersion + 1,
leaseOwner: null,
leaseToken: null,
leaseExpiresAtMs: null,
resultMutationId: command.mutationId,
lastResultCode: command.reasonCode,
completedAtMs: command.resolvedAtMs,
updatedAtMs: command.resolvedAtMs,
},
},
recovery: {
...initial.recovery,
status: 'resolved',
version: command.expectedRecoveryVersion + 1,
executionVersion: command.expectedExecutionVersion + 1,
nextScanAtMs: null,
resolutionMutationId: command.mutationId,
updatedAtMs: command.resolvedAtMs,
},
},
};
},
};
}
function options() {
let now = 200;
let id = 0;
return {
owner: 'resolver-1',
leaseDurationMs: 1_000,
retryBaseMs: 10,
retryMaxMs: 100,
clock: () => ++now,
createId: () => `recovery-mutation-${++id}`,
};
}
function provider(overrides = {}) {
return {
actionType: 'tool_call',
capability: 'automatic',
async inspect() {
return {
finding: 'verified_succeeded',
resultCode: 'provider_receipt_verified',
evidenceDigest: 'e'.repeat(64),
};
},
...overrides,
};
}
test('verified evidence resolves without exposing an execute capability', async () => {
const repository = fakeRepository();
let inspected = 0;
const reconciler = new ApprovedActionRecoveryReconciler(
repository,
[
provider({
async inspect(context) {
inspected += 1;
assert.equal(context.idempotencyKey, 'dispatch-one');
assert.equal(context.snapshot.action.execution.status, 'executing');
assert.equal('execute' in context, false);
return {
finding: 'verified_succeeded',
resultCode: 'provider_receipt_verified',
evidenceDigest: 'e'.repeat(64),
};
},
}),
],
options(),
);
const summary = await reconciler.reconcileBatch({ limit: 1 });
assert.equal(inspected, 1);
assert.deepEqual(
repository.calls.map((entry) => entry[0]),
['list', 'claim', 'resolve'],
);
assert.equal(summary.verifiedSucceeded, 1);
assert.equal(summary.manualRequired, 0);
});
test('manual-only providers are never inspected and move to manual-required', async () => {
const repository = fakeRepository();
let inspected = false;
const reconciler = new ApprovedActionRecoveryReconciler(
repository,
[
provider({
capability: 'manual_only',
async inspect() {
inspected = true;
throw new Error('must not be called');
},
}),
],
options(),
);
const summary = await reconciler.reconcileBatch();
assert.equal(inspected, false);
const finding = repository.calls.find((entry) => entry[0] === 'finding')[1];
assert.equal(finding.finding, 'unsupported');
assert.equal('retryAtMs' in finding, false);
assert.equal(summary.manualRequired, 1);
});
test('missing and unavailable evidence defer with bounded retry instead of resolving', async () => {
for (const inspect of [
async () => ({ finding: 'missing', resultCode: 'receipt_missing' }),
async () => {
throw new Error('provider unavailable');
},
]) {
const repository = fakeRepository();
const reconciler = new ApprovedActionRecoveryReconciler(
repository,
[provider({ inspect })],
options(),
);
const summary = await reconciler.reconcileBatch();
const finding = repository.calls.find((entry) => entry[0] === 'finding')[1];
assert.ok(['missing', 'unavailable'].includes(finding.finding));
assert.ok(finding.retryAtMs > finding.observedAtMs);
assert.equal(
repository.calls.some((entry) => entry[0] === 'resolve'),
false,
);
assert.equal(summary.deferred, 1);
}
});
test('extensible or malformed evidence fails closed into manual review', async () => {
const repository = fakeRepository();
const reconciler = new ApprovedActionRecoveryReconciler(
repository,
[
provider({
async inspect() {
return {
finding: 'verified_succeeded',
resultCode: 'ok',
evidenceDigest: 'e'.repeat(64),
injected: true,
};
},
}),
],
options(),
);
const summary = await reconciler.reconcileBatch();
const finding = repository.calls.find((entry) => entry[0] === 'finding')[1];
assert.equal(finding.finding, 'conflict');
assert.equal(finding.resultCode, 'recovery_evidence_invalid');
assert.equal('retryAtMs' in finding, false);
assert.equal(summary.manualRequired, 1);
});
test('rejects duplicate providers and keeps each batch to one bounded page', async () => {
assert.throws(
() =>
new ApprovedActionRecoveryReconciler(
fakeRepository(),
[provider(), provider()],
options(),
),
/Duplicate approved action recovery provider/,
);
const repository = fakeRepository();
const reconciler = new ApprovedActionRecoveryReconciler(
repository,
[provider()],
options(),
);
await reconciler.reconcileBatch({ limit: 1 });
assert.equal(
repository.calls.filter((entry) => entry[0] === 'list').length,
1,
);
});