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 {
|
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:')) {
|
if (reason.startsWith('member_busy:')) {
|
||||||
return 'member_busy';
|
return 'member_busy';
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -94,7 +94,11 @@ export type MemberWorkSyncAuditEventName =
|
||||||
| 'member_busy'
|
| 'member_busy'
|
||||||
| 'team_inactive'
|
| 'team_inactive'
|
||||||
| 'index_repaired'
|
| '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 {
|
export interface MemberWorkSyncAuditEvent {
|
||||||
timestamp: string;
|
timestamp: string;
|
||||||
|
|
@ -161,6 +165,18 @@ export interface MemberWorkSyncOutboxStorePort {
|
||||||
memberName: string;
|
memberName: string;
|
||||||
reviewRequestEventIds: string[];
|
reviewRequestEventIds: string[];
|
||||||
}): Promise<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 {
|
export interface MemberWorkSyncInboxNudgePort {
|
||||||
|
|
|
||||||
|
|
@ -980,6 +980,46 @@ export class JsonMemberWorkSyncStore
|
||||||
return [...delivered].sort();
|
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> {
|
private async readLegacyStatusFile(teamName: string): Promise<LegacyStatusFile> {
|
||||||
return readJsonFile(
|
return readJsonFile(
|
||||||
this.paths.getLegacyStatusPath(teamName),
|
this.paths.getLegacyStatusPath(teamName),
|
||||||
|
|
|
||||||
|
|
@ -14,6 +14,7 @@ export type MemberWorkSyncTriggerReason =
|
||||||
| 'tool_finished'
|
| 'tool_finished'
|
||||||
| 'runtime_activity'
|
| 'runtime_activity'
|
||||||
| 'turn_settled'
|
| 'turn_settled'
|
||||||
|
| 'proof_missing_recovery'
|
||||||
| 'manual_refresh';
|
| 'manual_refresh';
|
||||||
|
|
||||||
export interface MemberWorkSyncQueueDiagnostics {
|
export interface MemberWorkSyncQueueDiagnostics {
|
||||||
|
|
@ -461,6 +462,8 @@ function defaultRunAfterMs(reason: MemberWorkSyncTriggerReason): number {
|
||||||
switch (reason) {
|
switch (reason) {
|
||||||
case 'manual_refresh':
|
case 'manual_refresh':
|
||||||
return 0;
|
return 0;
|
||||||
|
case 'proof_missing_recovery':
|
||||||
|
return 5_000;
|
||||||
case 'turn_settled':
|
case 'turn_settled':
|
||||||
case 'tool_finished':
|
case 'tool_finished':
|
||||||
return 5_000;
|
return 5_000;
|
||||||
|
|
@ -479,6 +482,8 @@ function defaultMaxCoalesceWaitMs(reason: MemberWorkSyncTriggerReason): number {
|
||||||
switch (reason) {
|
switch (reason) {
|
||||||
case 'manual_refresh':
|
case 'manual_refresh':
|
||||||
return 0;
|
return 0;
|
||||||
|
case 'proof_missing_recovery':
|
||||||
|
return 30_000;
|
||||||
case 'turn_settled':
|
case 'turn_settled':
|
||||||
case 'tool_finished':
|
case 'tool_finished':
|
||||||
return 30_000;
|
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 () => {
|
it('truncates previews and rotates bounded journals', async () => {
|
||||||
const journal = new FileMemberWorkSyncAuditJournal(
|
const journal = new FileMemberWorkSyncAuditJournal(
|
||||||
new MemberWorkSyncStorePaths(root),
|
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 () => {
|
it('claims due outbox items and fences terminal updates by attempt generation', async () => {
|
||||||
const input = {
|
const input = {
|
||||||
id: 'member-work-sync:team-a:bob:agenda:v1:abc',
|
id: 'member-work-sync:team-a:bob:agenda:v1:abc',
|
||||||
|
|
|
||||||
|
|
@ -124,6 +124,32 @@ describe('MemberWorkSyncEventQueue', () => {
|
||||||
await queue.stop();
|
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 () => {
|
it('does not let a later quiet-window event delay a queued manual refresh', async () => {
|
||||||
const reconciles: unknown[] = [];
|
const reconciles: unknown[] = [];
|
||||||
const queue = new MemberWorkSyncEventQueue({
|
const queue = new MemberWorkSyncEventQueue({
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue