mirror of
https://github.com/whyour/qinglong.git
synced 2026-09-21 01:00:24 +08:00
313 lines
8.9 KiB
JavaScript
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,
|
|
);
|
|
});
|