feat(member-work-sync): add proof missing recovery contracts
This commit is contained in:
parent
75a3938f84
commit
6aad8b9c2c
8 changed files with 236 additions and 1 deletions
|
|
@ -31,6 +31,18 @@ export async function appendMemberWorkSyncAudit(
|
|||
}
|
||||
|
||||
export function reasonToAuditEvent(reason: string): MemberWorkSyncAuditEventName {
|
||||
if (reason === 'proof_missing_recovery_scheduled') {
|
||||
return 'proof_missing_recovery_scheduled';
|
||||
}
|
||||
if (reason === 'proof_missing_recovery_coalesced') {
|
||||
return 'proof_missing_recovery_coalesced';
|
||||
}
|
||||
if (reason === 'proof_missing_recovery_suppressed') {
|
||||
return 'proof_missing_recovery_suppressed';
|
||||
}
|
||||
if (reason === 'proof_missing_recovery_conflict') {
|
||||
return 'proof_missing_recovery_conflict';
|
||||
}
|
||||
if (reason.startsWith('member_busy:')) {
|
||||
return 'member_busy';
|
||||
}
|
||||
|
|
|
|||
|
|
@ -94,7 +94,11 @@ export type MemberWorkSyncAuditEventName =
|
|||
| 'member_busy'
|
||||
| 'team_inactive'
|
||||
| 'index_repaired'
|
||||
| 'legacy_fallback_used';
|
||||
| 'legacy_fallback_used'
|
||||
| 'proof_missing_recovery_scheduled'
|
||||
| 'proof_missing_recovery_coalesced'
|
||||
| 'proof_missing_recovery_suppressed'
|
||||
| 'proof_missing_recovery_conflict';
|
||||
|
||||
export interface MemberWorkSyncAuditEvent {
|
||||
timestamp: string;
|
||||
|
|
@ -161,6 +165,18 @@ export interface MemberWorkSyncOutboxStorePort {
|
|||
memberName: string;
|
||||
reviewRequestEventIds: string[];
|
||||
}): Promise<string[]>;
|
||||
findRecentRecoveryByIntent?(input: {
|
||||
teamName: string;
|
||||
memberName: string;
|
||||
intentKey: string;
|
||||
sinceIso: string;
|
||||
}): Promise<{
|
||||
id: string;
|
||||
status: MemberWorkSyncOutboxItem['status'];
|
||||
deliveredMessageId?: string;
|
||||
payloadHash: string;
|
||||
updatedAt: string;
|
||||
} | null>;
|
||||
}
|
||||
|
||||
export interface MemberWorkSyncInboxNudgePort {
|
||||
|
|
|
|||
|
|
@ -980,6 +980,46 @@ export class JsonMemberWorkSyncStore
|
|||
return [...delivered].sort();
|
||||
}
|
||||
|
||||
async findRecentRecoveryByIntent(input: {
|
||||
teamName: string;
|
||||
memberName: string;
|
||||
intentKey: string;
|
||||
sinceIso: string;
|
||||
}): Promise<{
|
||||
id: string;
|
||||
status: MemberWorkSyncOutboxItem['status'];
|
||||
deliveredMessageId?: string;
|
||||
payloadHash: string;
|
||||
updatedAt: string;
|
||||
} | null> {
|
||||
const intentKey = input.intentKey.trim();
|
||||
if (!intentKey) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const memberOutbox = await this.readMemberOutboxFile(input.teamName, input.memberName);
|
||||
const matches = Object.values(memberOutbox.items)
|
||||
.filter(
|
||||
(item) =>
|
||||
item.payload.workSyncIntentKey === intentKey &&
|
||||
item.updatedAt >= input.sinceIso &&
|
||||
item.status !== 'failed_terminal'
|
||||
)
|
||||
.sort((left, right) => right.updatedAt.localeCompare(left.updatedAt));
|
||||
const latest = matches[0];
|
||||
if (!latest) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return {
|
||||
id: latest.id,
|
||||
status: latest.status,
|
||||
...(latest.deliveredMessageId ? { deliveredMessageId: latest.deliveredMessageId } : {}),
|
||||
payloadHash: latest.payloadHash,
|
||||
updatedAt: latest.updatedAt,
|
||||
};
|
||||
}
|
||||
|
||||
private async readLegacyStatusFile(teamName: string): Promise<LegacyStatusFile> {
|
||||
return readJsonFile(
|
||||
this.paths.getLegacyStatusPath(teamName),
|
||||
|
|
|
|||
|
|
@ -14,6 +14,7 @@ export type MemberWorkSyncTriggerReason =
|
|||
| 'tool_finished'
|
||||
| 'runtime_activity'
|
||||
| 'turn_settled'
|
||||
| 'proof_missing_recovery'
|
||||
| 'manual_refresh';
|
||||
|
||||
export interface MemberWorkSyncQueueDiagnostics {
|
||||
|
|
@ -461,6 +462,8 @@ function defaultRunAfterMs(reason: MemberWorkSyncTriggerReason): number {
|
|||
switch (reason) {
|
||||
case 'manual_refresh':
|
||||
return 0;
|
||||
case 'proof_missing_recovery':
|
||||
return 5_000;
|
||||
case 'turn_settled':
|
||||
case 'tool_finished':
|
||||
return 5_000;
|
||||
|
|
@ -479,6 +482,8 @@ function defaultMaxCoalesceWaitMs(reason: MemberWorkSyncTriggerReason): number {
|
|||
switch (reason) {
|
||||
case 'manual_refresh':
|
||||
return 0;
|
||||
case 'proof_missing_recovery':
|
||||
return 30_000;
|
||||
case 'turn_settled':
|
||||
case 'tool_finished':
|
||||
return 30_000;
|
||||
|
|
|
|||
|
|
@ -0,0 +1,20 @@
|
|||
import { describe, expect, it } from 'vitest';
|
||||
|
||||
import { reasonToAuditEvent } from '@features/member-work-sync/core/application/MemberWorkSyncAudit';
|
||||
|
||||
describe('MemberWorkSyncAudit', () => {
|
||||
it('maps proof-missing recovery reasons to typed audit events', () => {
|
||||
expect(reasonToAuditEvent('proof_missing_recovery_scheduled')).toBe(
|
||||
'proof_missing_recovery_scheduled'
|
||||
);
|
||||
expect(reasonToAuditEvent('proof_missing_recovery_coalesced')).toBe(
|
||||
'proof_missing_recovery_coalesced'
|
||||
);
|
||||
expect(reasonToAuditEvent('proof_missing_recovery_suppressed')).toBe(
|
||||
'proof_missing_recovery_suppressed'
|
||||
);
|
||||
expect(reasonToAuditEvent('proof_missing_recovery_conflict')).toBe(
|
||||
'proof_missing_recovery_conflict'
|
||||
);
|
||||
});
|
||||
});
|
||||
|
|
@ -54,6 +54,33 @@ describe('FileMemberWorkSyncAuditJournal', () => {
|
|||
});
|
||||
});
|
||||
|
||||
it('accepts typed proof-missing recovery audit events', async () => {
|
||||
const journal = new FileMemberWorkSyncAuditJournal(new MemberWorkSyncStorePaths(root));
|
||||
|
||||
await journal.append({
|
||||
timestamp: '2026-04-30T00:00:00.000Z',
|
||||
teamName: 'team-a',
|
||||
memberName: 'bob',
|
||||
event: 'proof_missing_recovery_scheduled',
|
||||
source: 'test',
|
||||
reason: 'protocol_proof_missing',
|
||||
metadata: {
|
||||
originalMessageId: 'message-1',
|
||||
intentKey: 'proof-missing:message-1',
|
||||
},
|
||||
});
|
||||
|
||||
const [line] = (await readFile(journalPath(root), 'utf8')).trim().split('\n');
|
||||
expect(JSON.parse(line)).toMatchObject({
|
||||
event: 'proof_missing_recovery_scheduled',
|
||||
reason: 'protocol_proof_missing',
|
||||
metadata: {
|
||||
originalMessageId: 'message-1',
|
||||
intentKey: 'proof-missing:message-1',
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it('truncates previews and rotates bounded journals', async () => {
|
||||
const journal = new FileMemberWorkSyncAuditJournal(
|
||||
new MemberWorkSyncStorePaths(root),
|
||||
|
|
|
|||
|
|
@ -430,6 +430,95 @@ describe('JsonMemberWorkSyncStore', () => {
|
|||
});
|
||||
});
|
||||
|
||||
it('finds recent recovery outbox rows by logical intent key', async () => {
|
||||
const olderInput = {
|
||||
id: 'member-work-sync:team-a:bob:agenda:v1:older',
|
||||
teamName: 'team-a',
|
||||
memberName: 'bob',
|
||||
agendaFingerprint: 'agenda:v1:older',
|
||||
payloadHash: 'hash-older',
|
||||
payload: makeNudgePayload({ workSyncIntentKey: 'proof-missing:message-1' }),
|
||||
nowIso: '2026-04-29T00:00:00.000Z',
|
||||
};
|
||||
const latestInput = {
|
||||
...olderInput,
|
||||
id: 'member-work-sync:team-a:bob:agenda:v1:latest',
|
||||
agendaFingerprint: 'agenda:v1:latest',
|
||||
payloadHash: 'hash-latest',
|
||||
nowIso: '2026-04-29T00:03:00.000Z',
|
||||
};
|
||||
const unrelatedInput = {
|
||||
...olderInput,
|
||||
id: 'member-work-sync:team-a:bob:agenda:v1:unrelated',
|
||||
agendaFingerprint: 'agenda:v1:unrelated',
|
||||
payloadHash: 'hash-unrelated',
|
||||
payload: makeNudgePayload({ workSyncIntentKey: 'proof-missing:message-2' }),
|
||||
nowIso: '2026-04-29T00:04:00.000Z',
|
||||
};
|
||||
|
||||
await store.ensurePending(olderInput);
|
||||
await store.ensurePending(latestInput);
|
||||
await store.ensurePending(unrelatedInput);
|
||||
|
||||
await expect(
|
||||
store.findRecentRecoveryByIntent({
|
||||
teamName: 'team-a',
|
||||
memberName: 'bob',
|
||||
intentKey: 'proof-missing:message-1',
|
||||
sinceIso: '2026-04-29T00:01:00.000Z',
|
||||
})
|
||||
).resolves.toMatchObject({
|
||||
id: latestInput.id,
|
||||
status: 'pending',
|
||||
payloadHash: 'hash-latest',
|
||||
updatedAt: '2026-04-29T00:03:00.000Z',
|
||||
});
|
||||
});
|
||||
|
||||
it('ignores terminal and stale rows for logical recovery lookup', async () => {
|
||||
const input = {
|
||||
id: 'member-work-sync:team-a:bob:agenda:v1:terminal',
|
||||
teamName: 'team-a',
|
||||
memberName: 'bob',
|
||||
agendaFingerprint: 'agenda:v1:terminal',
|
||||
payloadHash: 'hash-a',
|
||||
payload: makeNudgePayload({ workSyncIntentKey: 'proof-missing:message-1' }),
|
||||
nowIso: '2026-04-29T00:00:00.000Z',
|
||||
};
|
||||
await store.ensurePending(input);
|
||||
const [claimed] = await store.claimDue({
|
||||
teamName: 'team-a',
|
||||
claimedBy: 'dispatcher-a',
|
||||
nowIso: '2026-04-29T00:01:00.000Z',
|
||||
limit: 1,
|
||||
});
|
||||
await store.markFailed({
|
||||
teamName: 'team-a',
|
||||
id: input.id,
|
||||
attemptGeneration: claimed.attemptGeneration,
|
||||
error: 'inbox_payload_conflict',
|
||||
retryable: false,
|
||||
nowIso: '2026-04-29T00:02:00.000Z',
|
||||
});
|
||||
|
||||
await expect(
|
||||
store.findRecentRecoveryByIntent({
|
||||
teamName: 'team-a',
|
||||
memberName: 'bob',
|
||||
intentKey: 'proof-missing:message-1',
|
||||
sinceIso: '2026-04-29T00:00:00.000Z',
|
||||
})
|
||||
).resolves.toBeNull();
|
||||
await expect(
|
||||
store.findRecentRecoveryByIntent({
|
||||
teamName: 'team-a',
|
||||
memberName: 'bob',
|
||||
intentKey: 'proof-missing:message-1',
|
||||
sinceIso: '2026-04-29T00:03:00.000Z',
|
||||
})
|
||||
).resolves.toBeNull();
|
||||
});
|
||||
|
||||
it('claims due outbox items and fences terminal updates by attempt generation', async () => {
|
||||
const input = {
|
||||
id: 'member-work-sync:team-a:bob:agenda:v1:abc',
|
||||
|
|
|
|||
|
|
@ -124,6 +124,32 @@ describe('MemberWorkSyncEventQueue', () => {
|
|||
await queue.stop();
|
||||
});
|
||||
|
||||
it('uses explicit fast timing for proof-missing recovery triggers', async () => {
|
||||
const reconciles: unknown[] = [];
|
||||
const queue = new MemberWorkSyncEventQueue({
|
||||
reconcile: async (request, context) => {
|
||||
reconciles.push({ request, context });
|
||||
},
|
||||
isTeamActive: () => true,
|
||||
});
|
||||
|
||||
queue.enqueue({
|
||||
teamName: 'team-a',
|
||||
memberName: 'bob',
|
||||
triggerReason: 'proof_missing_recovery',
|
||||
});
|
||||
|
||||
await vi.advanceTimersByTimeAsync(4_999);
|
||||
expect(reconciles).toHaveLength(0);
|
||||
|
||||
await vi.advanceTimersByTimeAsync(1);
|
||||
expect(reconciles).toHaveLength(1);
|
||||
expect(reconciles[0]).toMatchObject({
|
||||
context: { triggerReasons: ['proof_missing_recovery'] },
|
||||
});
|
||||
await queue.stop();
|
||||
});
|
||||
|
||||
it('does not let a later quiet-window event delay a queued manual refresh', async () => {
|
||||
const reconciles: unknown[] = [];
|
||||
const queue = new MemberWorkSyncEventQueue({
|
||||
|
|
|
|||
Loading…
Reference in a new issue