feat(member-work-sync): plan nudges behind readiness
This commit is contained in:
parent
ec7935e593
commit
3552a4ba61
8 changed files with 302 additions and 1 deletions
|
|
@ -35,6 +35,7 @@ Current implementation note:
|
||||||
- Phase 1 does not insert inbox messages, send nudges, mark tasks/messages read, or change `TeamTaskStallMonitor` semantics.
|
- Phase 1 does not insert inbox messages, send nudges, mark tasks/messages read, or change `TeamTaskStallMonitor` semantics.
|
||||||
- Phase 1.5 exposes a machine-readable `phase2Readiness` assessment from shadow metrics. It can say `collecting_shadow_data`, `blocked`, or `shadow_ready`; it still does not dispatch nudges.
|
- Phase 1.5 exposes a machine-readable `phase2Readiness` assessment from shadow metrics. It can say `collecting_shadow_data`, `blocked`, or `shadow_ready`; it still does not dispatch nudges.
|
||||||
- Phase 2 storage foundation is implemented as an inert durable outbox: idempotency key, payload hash conflict checks, claim generation fencing, retry/terminal states. It is not wired to dispatch inbox nudges yet.
|
- Phase 2 storage foundation is implemented as an inert durable outbox: idempotency key, payload hash conflict checks, claim generation fencing, retry/terminal states. It is not wired to dispatch inbox nudges yet.
|
||||||
|
- Reconciler can plan a Phase 2 outbox item only when `phase2Readiness=shadow_ready`; otherwise it records normal shadow status and does nothing. This preserves the anti-spam gate before any dispatcher exists.
|
||||||
- Phase 2 must not start until real shadow metrics confirm that `needs_sync` churn and false positives are acceptably low.
|
- Phase 2 must not start until real shadow metrics confirm that `needs_sync` churn and false positives are acceptably low.
|
||||||
|
|
||||||
Patterns used:
|
Patterns used:
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,56 @@
|
||||||
|
import type { MemberWorkSyncStatus } from '../../contracts';
|
||||||
|
import { buildMemberWorkSyncOutboxEnsureInput } from '../domain';
|
||||||
|
import type { MemberWorkSyncUseCaseDeps } from './ports';
|
||||||
|
|
||||||
|
export interface MemberWorkSyncNudgeOutboxPlanResult {
|
||||||
|
planned: boolean;
|
||||||
|
code:
|
||||||
|
| 'outbox_unavailable'
|
||||||
|
| 'metrics_unavailable'
|
||||||
|
| 'status_not_nudgeable'
|
||||||
|
| 'phase2_not_ready'
|
||||||
|
| 'created'
|
||||||
|
| 'existing'
|
||||||
|
| 'payload_conflict';
|
||||||
|
}
|
||||||
|
|
||||||
|
export class MemberWorkSyncNudgeOutboxPlanner {
|
||||||
|
constructor(private readonly deps: MemberWorkSyncUseCaseDeps) {}
|
||||||
|
|
||||||
|
async plan(status: MemberWorkSyncStatus): Promise<MemberWorkSyncNudgeOutboxPlanResult> {
|
||||||
|
if (!this.deps.outboxStore) {
|
||||||
|
return { planned: false, code: 'outbox_unavailable' };
|
||||||
|
}
|
||||||
|
if (!this.deps.statusStore.readTeamMetrics) {
|
||||||
|
return { planned: false, code: 'metrics_unavailable' };
|
||||||
|
}
|
||||||
|
|
||||||
|
const input = buildMemberWorkSyncOutboxEnsureInput({
|
||||||
|
status,
|
||||||
|
hash: this.deps.hash,
|
||||||
|
nowIso: status.evaluatedAt,
|
||||||
|
});
|
||||||
|
if (!input) {
|
||||||
|
return { planned: false, code: 'status_not_nudgeable' };
|
||||||
|
}
|
||||||
|
|
||||||
|
const metrics = await this.deps.statusStore.readTeamMetrics(status.teamName);
|
||||||
|
if (metrics.phase2Readiness.state !== 'shadow_ready') {
|
||||||
|
return { planned: false, code: 'phase2_not_ready' };
|
||||||
|
}
|
||||||
|
|
||||||
|
const result = await this.deps.outboxStore.ensurePending(input);
|
||||||
|
if (!result.ok) {
|
||||||
|
this.deps.logger?.warn('member work sync nudge outbox payload conflict', {
|
||||||
|
teamName: status.teamName,
|
||||||
|
memberName: status.memberName,
|
||||||
|
outboxId: input.id,
|
||||||
|
existingPayloadHash: result.existingPayloadHash,
|
||||||
|
requestedPayloadHash: result.requestedPayloadHash,
|
||||||
|
});
|
||||||
|
return { planned: false, code: 'payload_conflict' };
|
||||||
|
}
|
||||||
|
|
||||||
|
return { planned: true, code: result.outcome };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -6,6 +6,7 @@ import {
|
||||||
} from '../domain';
|
} from '../domain';
|
||||||
import type { MemberWorkSyncStatus, MemberWorkSyncStatusRequest } from '../../contracts';
|
import type { MemberWorkSyncStatus, MemberWorkSyncStatusRequest } from '../../contracts';
|
||||||
import type { MemberWorkSyncAgendaSourceResult, MemberWorkSyncUseCaseDeps } from './ports';
|
import type { MemberWorkSyncAgendaSourceResult, MemberWorkSyncUseCaseDeps } from './ports';
|
||||||
|
import { MemberWorkSyncNudgeOutboxPlanner } from './MemberWorkSyncNudgeOutboxPlanner';
|
||||||
|
|
||||||
export interface MemberWorkSyncReconcileContext {
|
export interface MemberWorkSyncReconcileContext {
|
||||||
reconciledBy?: 'request' | 'queue';
|
reconciledBy?: 'request' | 'queue';
|
||||||
|
|
@ -33,7 +34,11 @@ export function finalizeMemberWorkSyncAgenda(
|
||||||
}
|
}
|
||||||
|
|
||||||
export class MemberWorkSyncReconciler {
|
export class MemberWorkSyncReconciler {
|
||||||
constructor(private readonly deps: MemberWorkSyncUseCaseDeps) {}
|
private readonly nudgeOutboxPlanner: MemberWorkSyncNudgeOutboxPlanner;
|
||||||
|
|
||||||
|
constructor(private readonly deps: MemberWorkSyncUseCaseDeps) {
|
||||||
|
this.nudgeOutboxPlanner = new MemberWorkSyncNudgeOutboxPlanner(deps);
|
||||||
|
}
|
||||||
|
|
||||||
async execute(
|
async execute(
|
||||||
request: MemberWorkSyncStatusRequest,
|
request: MemberWorkSyncStatusRequest,
|
||||||
|
|
@ -82,8 +87,21 @@ export class MemberWorkSyncReconciler {
|
||||||
});
|
});
|
||||||
|
|
||||||
await this.deps.statusStore.write(status);
|
await this.deps.statusStore.write(status);
|
||||||
|
await this.planNudgeOutbox(status);
|
||||||
return status;
|
return status;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private async planNudgeOutbox(status: MemberWorkSyncStatus): Promise<void> {
|
||||||
|
const result = await this.nudgeOutboxPlanner.plan(status);
|
||||||
|
if (result.code !== 'outbox_unavailable' && result.code !== 'status_not_nudgeable') {
|
||||||
|
this.deps.logger?.debug('member work sync nudge outbox planning result', {
|
||||||
|
teamName: status.teamName,
|
||||||
|
memberName: status.memberName,
|
||||||
|
code: result.code,
|
||||||
|
planned: result.planned,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function attachMemberWorkSyncReportToken(
|
export async function attachMemberWorkSyncReportToken(
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,6 @@
|
||||||
export * from './MemberWorkSyncDiagnosticsReader';
|
export * from './MemberWorkSyncDiagnosticsReader';
|
||||||
export * from './MemberWorkSyncMetricsReader';
|
export * from './MemberWorkSyncMetricsReader';
|
||||||
|
export * from './MemberWorkSyncNudgeOutboxPlanner';
|
||||||
export * from './MemberWorkSyncPendingReportIntentReplayer';
|
export * from './MemberWorkSyncPendingReportIntentReplayer';
|
||||||
export * from './MemberWorkSyncReconciler';
|
export * from './MemberWorkSyncReconciler';
|
||||||
export * from './MemberWorkSyncReporter';
|
export * from './MemberWorkSyncReporter';
|
||||||
|
|
|
||||||
105
src/features/member-work-sync/core/domain/MemberWorkSyncNudge.ts
Normal file
105
src/features/member-work-sync/core/domain/MemberWorkSyncNudge.ts
Normal file
|
|
@ -0,0 +1,105 @@
|
||||||
|
import type {
|
||||||
|
MemberWorkSyncNudgePayload,
|
||||||
|
MemberWorkSyncOutboxEnsureInput,
|
||||||
|
MemberWorkSyncStatus,
|
||||||
|
} from '../../contracts';
|
||||||
|
|
||||||
|
export const MEMBER_WORK_SYNC_NUDGE_ID_PREFIX = 'member-work-sync';
|
||||||
|
|
||||||
|
interface MemberWorkSyncNudgeHash {
|
||||||
|
sha256Hex(value: string): string;
|
||||||
|
}
|
||||||
|
|
||||||
|
function stableJson(value: unknown): string {
|
||||||
|
if (value == null || typeof value !== 'object') {
|
||||||
|
return JSON.stringify(value);
|
||||||
|
}
|
||||||
|
if (Array.isArray(value)) {
|
||||||
|
return `[${value.map(stableJson).join(',')}]`;
|
||||||
|
}
|
||||||
|
|
||||||
|
const record = value as Record<string, unknown>;
|
||||||
|
return `{${Object.keys(record)
|
||||||
|
.sort()
|
||||||
|
.map((key) => `${JSON.stringify(key)}:${stableJson(record[key])}`)
|
||||||
|
.join(',')}}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function buildMemberWorkSyncNudgeId(input: {
|
||||||
|
teamName: string;
|
||||||
|
memberName: string;
|
||||||
|
agendaFingerprint: string;
|
||||||
|
}): string {
|
||||||
|
return [
|
||||||
|
MEMBER_WORK_SYNC_NUDGE_ID_PREFIX,
|
||||||
|
input.teamName,
|
||||||
|
input.memberName.trim().toLowerCase(),
|
||||||
|
input.agendaFingerprint,
|
||||||
|
].join(':');
|
||||||
|
}
|
||||||
|
|
||||||
|
export function buildMemberWorkSyncNudgePayload(status: MemberWorkSyncStatus): MemberWorkSyncNudgePayload {
|
||||||
|
const taskRefs = status.agenda.items.map((item) => ({
|
||||||
|
teamName: status.teamName,
|
||||||
|
taskId: item.taskId,
|
||||||
|
displayId: item.displayId ?? item.taskId.slice(0, 8),
|
||||||
|
}));
|
||||||
|
const preview = status.agenda.items
|
||||||
|
.slice(0, 3)
|
||||||
|
.map((item) => `${item.displayId ?? item.taskId.slice(0, 8)} ${item.subject}`)
|
||||||
|
.join('; ');
|
||||||
|
|
||||||
|
return {
|
||||||
|
from: 'system',
|
||||||
|
to: status.memberName,
|
||||||
|
messageKind: 'member_work_sync_nudge',
|
||||||
|
source: 'member-work-sync',
|
||||||
|
actionMode: 'do',
|
||||||
|
taskRefs,
|
||||||
|
text: [
|
||||||
|
'Work sync check: you have current actionable work assigned.',
|
||||||
|
preview ? `Current agenda: ${preview}.` : '',
|
||||||
|
'Continue concrete task work, report a real blocker with task tools, or call member_work_sync_report for the current fingerprint.',
|
||||||
|
'Do not reply only with acknowledgement.',
|
||||||
|
]
|
||||||
|
.filter(Boolean)
|
||||||
|
.join('\n'),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function buildMemberWorkSyncNudgePayloadHash(
|
||||||
|
hash: MemberWorkSyncNudgeHash,
|
||||||
|
payload: MemberWorkSyncNudgePayload
|
||||||
|
): string {
|
||||||
|
return hash.sha256Hex(stableJson(payload));
|
||||||
|
}
|
||||||
|
|
||||||
|
export function buildMemberWorkSyncOutboxEnsureInput(input: {
|
||||||
|
status: MemberWorkSyncStatus;
|
||||||
|
hash: MemberWorkSyncNudgeHash;
|
||||||
|
nowIso: string;
|
||||||
|
}): MemberWorkSyncOutboxEnsureInput | null {
|
||||||
|
const status = input.status;
|
||||||
|
if (
|
||||||
|
status.state !== 'needs_sync' ||
|
||||||
|
status.shadow?.wouldNudge !== true ||
|
||||||
|
status.agenda.items.length === 0
|
||||||
|
) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const payload = buildMemberWorkSyncNudgePayload(status);
|
||||||
|
return {
|
||||||
|
id: buildMemberWorkSyncNudgeId({
|
||||||
|
teamName: status.teamName,
|
||||||
|
memberName: status.memberName,
|
||||||
|
agendaFingerprint: status.agenda.fingerprint,
|
||||||
|
}),
|
||||||
|
teamName: status.teamName,
|
||||||
|
memberName: status.memberName,
|
||||||
|
agendaFingerprint: status.agenda.fingerprint,
|
||||||
|
payloadHash: buildMemberWorkSyncNudgePayloadHash(input.hash, payload),
|
||||||
|
payload,
|
||||||
|
nowIso: input.nowIso,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
@ -3,5 +3,6 @@ export * from './AgendaFingerprint';
|
||||||
export * from './currentReviewCycle';
|
export * from './currentReviewCycle';
|
||||||
export * from './memberName';
|
export * from './memberName';
|
||||||
export * from './MemberWorkSyncPhase2Readiness';
|
export * from './MemberWorkSyncPhase2Readiness';
|
||||||
|
export * from './MemberWorkSyncNudge';
|
||||||
export * from './MemberWorkSyncReportValidator';
|
export * from './MemberWorkSyncReportValidator';
|
||||||
export * from './SyncDecisionPolicy';
|
export * from './SyncDecisionPolicy';
|
||||||
|
|
|
||||||
|
|
@ -73,6 +73,7 @@ export function createMemberWorkSyncFeature(deps: {
|
||||||
agendaSource,
|
agendaSource,
|
||||||
statusStore: store,
|
statusStore: store,
|
||||||
reportStore: store,
|
reportStore: store,
|
||||||
|
outboxStore: store,
|
||||||
reportToken,
|
reportToken,
|
||||||
...(deps.isTeamActive ? { lifecycle: { isTeamActive: deps.isTeamActive } } : {}),
|
...(deps.isTeamActive ? { lifecycle: { isTeamActive: deps.isTeamActive } } : {}),
|
||||||
logger: deps.logger,
|
logger: deps.logger,
|
||||||
|
|
|
||||||
|
|
@ -5,14 +5,22 @@ import {
|
||||||
MemberWorkSyncPendingReportIntentReplayer,
|
MemberWorkSyncPendingReportIntentReplayer,
|
||||||
MemberWorkSyncReporter,
|
MemberWorkSyncReporter,
|
||||||
type MemberWorkSyncAgendaSourceResult,
|
type MemberWorkSyncAgendaSourceResult,
|
||||||
|
type MemberWorkSyncOutboxStorePort,
|
||||||
type MemberWorkSyncStatusStorePort,
|
type MemberWorkSyncStatusStorePort,
|
||||||
type MemberWorkSyncUseCaseDeps,
|
type MemberWorkSyncUseCaseDeps,
|
||||||
} from '@features/member-work-sync/core/application';
|
} from '@features/member-work-sync/core/application';
|
||||||
import type {
|
import type {
|
||||||
MemberWorkSyncActionableWorkItem,
|
MemberWorkSyncActionableWorkItem,
|
||||||
|
MemberWorkSyncOutboxEnsureInput,
|
||||||
|
MemberWorkSyncOutboxItem,
|
||||||
|
MemberWorkSyncOutboxMarkDeliveredInput,
|
||||||
|
MemberWorkSyncOutboxMarkFailedInput,
|
||||||
|
MemberWorkSyncOutboxMarkSupersededInput,
|
||||||
|
MemberWorkSyncPhase2ReadinessState,
|
||||||
MemberWorkSyncReportIntent,
|
MemberWorkSyncReportIntent,
|
||||||
MemberWorkSyncReportRequest,
|
MemberWorkSyncReportRequest,
|
||||||
MemberWorkSyncStatus,
|
MemberWorkSyncStatus,
|
||||||
|
MemberWorkSyncTeamMetrics,
|
||||||
} from '@features/member-work-sync/contracts';
|
} from '@features/member-work-sync/contracts';
|
||||||
|
|
||||||
const workItem: MemberWorkSyncActionableWorkItem = {
|
const workItem: MemberWorkSyncActionableWorkItem = {
|
||||||
|
|
@ -45,6 +53,7 @@ class InMemoryStatusStore implements MemberWorkSyncStatusStorePort {
|
||||||
readonly writes: MemberWorkSyncStatus[] = [];
|
readonly writes: MemberWorkSyncStatus[] = [];
|
||||||
readonly pendingReports: Array<{ request: MemberWorkSyncReportRequest; reason: string }> = [];
|
readonly pendingReports: Array<{ request: MemberWorkSyncReportRequest; reason: string }> = [];
|
||||||
readonly pendingIntents = new Map<string, MemberWorkSyncReportIntent>();
|
readonly pendingIntents = new Map<string, MemberWorkSyncReportIntent>();
|
||||||
|
phase2ReadinessState: MemberWorkSyncPhase2ReadinessState = 'collecting_shadow_data';
|
||||||
|
|
||||||
async read(): Promise<MemberWorkSyncStatus | null> {
|
async read(): Promise<MemberWorkSyncStatus | null> {
|
||||||
return this.writes.at(-1) ?? null;
|
return this.writes.at(-1) ?? null;
|
||||||
|
|
@ -76,6 +85,74 @@ class InMemoryStatusStore implements MemberWorkSyncStatusStorePort {
|
||||||
this.pendingIntents.set(id, { ...current, ...result });
|
this.pendingIntents.set(id, { ...current, ...result });
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async readTeamMetrics(teamName: string): Promise<MemberWorkSyncTeamMetrics> {
|
||||||
|
return {
|
||||||
|
teamName,
|
||||||
|
generatedAt: '2026-04-29T00:00:00.000Z',
|
||||||
|
memberCount: 1,
|
||||||
|
stateCounts: {
|
||||||
|
caught_up: 0,
|
||||||
|
needs_sync: 1,
|
||||||
|
still_working: 0,
|
||||||
|
blocked: 0,
|
||||||
|
inactive: 0,
|
||||||
|
unknown: 0,
|
||||||
|
},
|
||||||
|
actionableItemCount: this.writes.at(-1)?.agenda.items.length ?? 0,
|
||||||
|
wouldNudgeCount: 1,
|
||||||
|
fingerprintChangeCount: 0,
|
||||||
|
reportAcceptedCount: 0,
|
||||||
|
reportRejectedCount: 0,
|
||||||
|
recentEvents: [],
|
||||||
|
phase2Readiness: {
|
||||||
|
state: this.phase2ReadinessState,
|
||||||
|
reasons: [],
|
||||||
|
thresholds: {
|
||||||
|
minObservedMembers: 1,
|
||||||
|
minStatusEvents: 20,
|
||||||
|
minObservationHours: 1,
|
||||||
|
maxWouldNudgesPerMemberHour: 2,
|
||||||
|
maxFingerprintChangesPerMemberHour: 1,
|
||||||
|
maxReportRejectionRate: 0.2,
|
||||||
|
},
|
||||||
|
rates: {
|
||||||
|
observationHours: 2,
|
||||||
|
statusEventCount: 30,
|
||||||
|
wouldNudgesPerMemberHour: 0.5,
|
||||||
|
fingerprintChangesPerMemberHour: 0,
|
||||||
|
reportRejectionRate: 0,
|
||||||
|
},
|
||||||
|
diagnostics: [],
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
class InMemoryOutboxStore implements MemberWorkSyncOutboxStorePort {
|
||||||
|
readonly ensures: MemberWorkSyncOutboxEnsureInput[] = [];
|
||||||
|
|
||||||
|
async ensurePending(input: MemberWorkSyncOutboxEnsureInput) {
|
||||||
|
this.ensures.push(input);
|
||||||
|
const item: MemberWorkSyncOutboxItem = {
|
||||||
|
...input,
|
||||||
|
status: 'pending',
|
||||||
|
attemptGeneration: 0,
|
||||||
|
createdAt: input.nowIso,
|
||||||
|
updatedAt: input.nowIso,
|
||||||
|
};
|
||||||
|
return { ok: true as const, outcome: 'created' as const, item };
|
||||||
|
}
|
||||||
|
|
||||||
|
async claimDue(): Promise<MemberWorkSyncOutboxItem[]> {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
async markDelivered(_input: MemberWorkSyncOutboxMarkDeliveredInput): Promise<void> {}
|
||||||
|
|
||||||
|
async markSuperseded(_input: MemberWorkSyncOutboxMarkSupersededInput): Promise<void> {}
|
||||||
|
|
||||||
|
async markFailed(_input: MemberWorkSyncOutboxMarkFailedInput): Promise<void> {}
|
||||||
}
|
}
|
||||||
|
|
||||||
function createDeps(options?: {
|
function createDeps(options?: {
|
||||||
|
|
@ -84,6 +161,7 @@ function createDeps(options?: {
|
||||||
inactive?: boolean;
|
inactive?: boolean;
|
||||||
teamActive?: boolean;
|
teamActive?: boolean;
|
||||||
providerId?: 'opencode' | 'codex';
|
providerId?: 'opencode' | 'codex';
|
||||||
|
outboxStore?: MemberWorkSyncOutboxStorePort;
|
||||||
}) {
|
}) {
|
||||||
const clock = new MutableClock();
|
const clock = new MutableClock();
|
||||||
const store = new InMemoryStatusStore();
|
const store = new InMemoryStatusStore();
|
||||||
|
|
@ -110,6 +188,7 @@ function createDeps(options?: {
|
||||||
},
|
},
|
||||||
statusStore: store,
|
statusStore: store,
|
||||||
reportStore: store,
|
reportStore: store,
|
||||||
|
...(options?.outboxStore ? { outboxStore: options.outboxStore } : {}),
|
||||||
reportToken: {
|
reportToken: {
|
||||||
create: async (input) => ({
|
create: async (input) => ({
|
||||||
token: `token:${input.teamName}:${input.memberName}:${input.agendaFingerprint}`,
|
token: `token:${input.teamName}:${input.memberName}:${input.agendaFingerprint}`,
|
||||||
|
|
@ -277,6 +356,45 @@ describe('MemberWorkSync use cases', () => {
|
||||||
expect(changed.state).toBe('needs_sync');
|
expect(changed.state).toBe('needs_sync');
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('does not create outbox nudges until shadow readiness is green', async () => {
|
||||||
|
const outbox = new InMemoryOutboxStore();
|
||||||
|
const { deps } = createDeps({ outboxStore: outbox });
|
||||||
|
|
||||||
|
await new MemberWorkSyncDiagnosticsReader(deps).execute({
|
||||||
|
teamName: 'team-a',
|
||||||
|
memberName: 'bob',
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(outbox.ensures).toEqual([]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('creates one idempotent outbox nudge intent when Phase 2 readiness is green', async () => {
|
||||||
|
const outbox = new InMemoryOutboxStore();
|
||||||
|
const { deps, store } = createDeps({ outboxStore: outbox });
|
||||||
|
store.phase2ReadinessState = 'shadow_ready';
|
||||||
|
|
||||||
|
const status = await new MemberWorkSyncDiagnosticsReader(deps).execute({
|
||||||
|
teamName: 'team-a',
|
||||||
|
memberName: 'bob',
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(outbox.ensures).toHaveLength(1);
|
||||||
|
expect(outbox.ensures[0]).toMatchObject({
|
||||||
|
id: `member-work-sync:team-a:bob:${status.agenda.fingerprint}`,
|
||||||
|
teamName: 'team-a',
|
||||||
|
memberName: 'bob',
|
||||||
|
agendaFingerprint: status.agenda.fingerprint,
|
||||||
|
payload: {
|
||||||
|
from: 'system',
|
||||||
|
to: 'bob',
|
||||||
|
messageKind: 'member_work_sync_nudge',
|
||||||
|
source: 'member-work-sync',
|
||||||
|
actionMode: 'do',
|
||||||
|
taskRefs: [{ teamName: 'team-a', taskId: 'task-1', displayId: '11111111' }],
|
||||||
|
},
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
it('rejects invalid report tokens without recording replayable intents', async () => {
|
it('rejects invalid report tokens without recording replayable intents', async () => {
|
||||||
const { deps, store } = createDeps();
|
const { deps, store } = createDeps();
|
||||||
const reader = new MemberWorkSyncDiagnosticsReader(deps);
|
const reader = new MemberWorkSyncDiagnosticsReader(deps);
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue