mirror of
https://github.com/whyour/qinglong.git
synced 2026-09-21 09:58:46 +08:00
feat(ql3): establish 3.0 incubation baseline
This commit is contained in:
@@ -0,0 +1,312 @@
|
||||
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,
|
||||
);
|
||||
});
|
||||
Reference in New Issue
Block a user