Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
@@ -0,0 +1,36 @@
// @ts-nocheck

// Only the argv parsing is under test here; the audit itself is covered in
// __tests__/unit/services/attentionItemService.test.js. Stub the modules the
// script pulls in at import time so this needs no database.
jest.mock('mongoose', () => ({ connect: jest.fn(), disconnect: jest.fn() }));
jest.mock('../../../services/attentionItemService', () => ({ auditRepliedMentionAttention: jest.fn() }));

const { parseArgs } = require('../../../scripts/audit-replied-attention-stamps');

describe('audit-replied-attention-stamps argv', () => {
it('reads --apply and an ISO bound', () => {
const { apply, resolvedBefore } = parseArgs(['node', 'script', '--apply', '--resolved-before=2026-09-06T12:42:00Z']);
expect(apply).toBe(true);
expect(resolvedBefore.toISOString()).toBe('2026-09-06T12:42:00.000Z');
});

it('defaults to a dry run over every replied stamp when no flags are given', () => {
expect(parseArgs(['node', 'script'])).toEqual({ apply: false });
});

// The whole point of the flag is to NARROW the scan to the cutover window.
// Falling through as `undefined` on a bad value widens it to every replied
// stamp ever written — under --apply, an unbounded reopen from a typo.
it.each(['--resolved-before=garbage', '--resolved-before=', '--resolved-before=2026-13-45'])(
'throws rather than silently widening the scan: %s',
(flag) => {
expect(() => parseArgs(['node', 'script', flag])).toThrow(/not a date/);
},
);

// A test for `split('=').slice(1).join('=')` was written here and deleted:
// no valid date contains an `=`, so truncating at the first one changes
// nothing a test can observe. It passed against both implementations, which
// makes it a claim about nothing. The join stays as defensive code.
});
73 changes: 73 additions & 0 deletions backend/__tests__/unit/services/attentionItemService.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -215,6 +215,79 @@ describe('attentionItemService', () => {
expect(mockMongoMessageExists).not.toHaveBeenCalled();
});

it('reopens a replied stamp the source contradicts, and keeps the one it corroborates', async () => {
const rows = [
{
_id: 'attention-false', recipientUserId: 'sam', podId: 'pod-1', kind: 'mention',
source: { type: 'message', id: '42' }, messageId: '42', threadRootId: '40',
sourceCreatedAt: new Date('2026-09-05T09:00:00.000Z'),
},
{
_id: 'attention-true', recipientUserId: 'sam', podId: 'pod-1', kind: 'mention',
source: { type: 'message', id: '43' }, messageId: '43', threadRootId: '43',
sourceCreatedAt: new Date('2026-09-05T09:30:00.000Z'),
},
];
mockFind.mockReturnValue({ sort: () => ({ lean: async () => rows }) });
mockPgMessageHasReplyByUserAfter.mockImplementation(async (podId, userId, after, target) => target.messageId === '43');

const result = await AttentionItemService.auditRepliedMentionAttention({ apply: true });

expect(result).toEqual({ scanned: 2, kept: 1, contradicted: 1, reopened: 1, undecidable: 0, unavailable: 0 });
expect(mockUpdateOne).toHaveBeenCalledTimes(1);
expect(mockUpdateOne).toHaveBeenCalledWith(
{ _id: 'attention-false', kind: 'mention', status: 'resolved', resolvedBy: 'replied' },
{ $set: { status: 'open' }, $unset: { resolvedAt: '', resolvedBy: '' } },
);
});

it('changes nothing without --apply, and still reports what it would reopen', async () => {
mockFind.mockReturnValue({ sort: () => ({ lean: async () => [{
_id: 'attention-false', recipientUserId: 'sam', podId: 'pod-1', kind: 'mention',
source: { type: 'message', id: '42' }, messageId: '42',
sourceCreatedAt: new Date('2026-09-05T09:00:00.000Z'),
}] }) });
mockPgMessageHasReplyByUserAfter.mockResolvedValue(false);

const result = await AttentionItemService.auditRepliedMentionAttention();

expect(result).toMatchObject({ scanned: 1, contradicted: 1, reopened: 0 });
expect(mockUpdateOne).not.toHaveBeenCalled();
});

it('counts a Mongo-sourced stamp as undecidable rather than reopening it', async () => {
mockFind.mockReturnValue({ sort: () => ({ lean: async () => [{
_id: 'attention-mongo', recipientUserId: 'sam', podId: 'pod-1', kind: 'mention',
source: { type: 'message', id: '507f191e810c19729de860ea' },
sourceCreatedAt: new Date('2026-09-05T09:00:00.000Z'),
}] }) });

const result = await AttentionItemService.auditRepliedMentionAttention({ apply: true });

expect(result).toMatchObject({ scanned: 1, kept: 0, contradicted: 0, reopened: 0, undecidable: 1 });
expect(mockUpdateOne).not.toHaveBeenCalled();
expect(mockPgMessageHasReplyByUserAfter).not.toHaveBeenCalled();
});

it('reads only replied stamps, and honours the resolvedBefore bound', async () => {
mockFind.mockReturnValue({ sort: () => ({ lean: async () => [] }) });
const before = new Date('2026-09-06T00:00:00.000Z');

await AttentionItemService.auditRepliedMentionAttention({ resolvedBefore: before });

expect(mockFind).toHaveBeenCalledWith({
kind: 'mention', status: 'resolved', resolvedBy: 'replied', resolvedAt: { $lt: before },
});
});

it('omits the resolvedAt bound entirely when none is given', async () => {
mockFind.mockReturnValue({ sort: () => ({ lean: async () => [] }) });

await AttentionItemService.auditRepliedMentionAttention();

expect(mockFind).toHaveBeenCalledWith({ kind: 'mention', status: 'resolved', resolvedBy: 'replied' });
});

it('materializes a blocked board row once for each current human recipient', async () => {
mockPodFindById.mockReturnValue(chain({ _id: 'pod-1', name: 'Ship room', createdBy: 'owner', members: [{ userId: 'sam' }] }));
mockUserFind.mockReturnValue(chain([
Expand Down
1 change: 1 addition & 0 deletions backend/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,7 @@
"bootstrap:clawd-bot": "node scripts/bootstrap-clawd-bot.js",
"backfill:attention-items": "ts-node scripts/backfill-attention-items.ts",
"sweep:resolved-mention-attention": "ts-node scripts/sweep-resolved-mention-attention.ts",
"audit:replied-attention-stamps": "ts-node scripts/audit-replied-attention-stamps.ts",
"tsc:check": "tsc --noEmit -p tsconfig.typescheck.json",
"tsc:check:all": "tsc --noEmit"
},
Expand Down
64 changes: 64 additions & 0 deletions backend/scripts/audit-replied-attention-stamps.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,64 @@
/**
* Audit mention AttentionItems stamped `resolvedBy: 'replied'`. The broad
* pre-#1573 resolver closed every open mention a recipient held in a pod as
* soon as they posted anything there, so some of those stamps assert a reply
* nobody wrote. This re-reads each row's source and reopens only the rows the
* source contradicts; rows whose source cannot answer the question are
* counted and left alone.
*
* Dry run by default:
* npm run audit:replied-attention-stamps
* Bound the population to stamps written before the narrow resolver deployed:
* npm run audit:replied-attention-stamps -- --resolved-before=2026-09-06T00:00:00Z
* Apply the source-backed reopens:
* npm run audit:replied-attention-stamps -- --apply
*/
/* eslint-disable no-console */
const mongoose = require('mongoose');
const { auditRepliedMentionAttention } = require('../services/attentionItemService');

type PgPool = { end: () => Promise<void> };

/**
* Read the flags out of argv. Exported so the throw below is testable without
* a database: an unparsable bound must NOT fall through as `undefined`. It
* would widen the audit from the cutover window to every replied stamp ever
* written, and under `--apply` that is an unbounded reopen from a typo.
*/
export const parseArgs = (argv: string[]): { apply: boolean; resolvedBefore?: Date } => {
const apply = argv.includes('--apply');
const flag = argv.find((arg) => arg.startsWith('--resolved-before='));
if (!flag) return { apply };
const raw = flag.split('=').slice(1).join('=');
const parsed = new Date(raw);
if (!raw.trim() || Number.isNaN(parsed.getTime())) {
throw new Error(`--resolved-before is not a date: ${flag}`);
}
return { apply, resolvedBefore: parsed };
};

export const main = async (): Promise<void> => {
if (!process.env.MONGO_URI) throw new Error('MONGO_URI is required');
const { apply: APPLY, resolvedBefore } = parseArgs(process.argv);
// The audit reads legacy PostgreSQL mention sources as well as Mongo ones.
// Close that pool like the sweep does so the one-shot process exits after
// printing its measured result.
// eslint-disable-next-line global-require
const { pool } = require('../config/db-pg') as { pool: PgPool | null };
await mongoose.connect(process.env.MONGO_URI);
try {
const result = await auditRepliedMentionAttention({ apply: APPLY, resolvedBefore });
console.log(JSON.stringify({ ...result, apply: APPLY, resolvedBefore: resolvedBefore?.toISOString() || null }));
if (!APPLY) console.log('DRY RUN — no AttentionItems changed. Re-run with --apply after review.');
} finally {
await mongoose.disconnect();
if (pool) await pool.end();
}
};

if (require.main === module) {
main().catch((error) => {
console.error('replied-stamp audit failed:', error);
process.exit(1);
});
}
57 changes: 55 additions & 2 deletions backend/services/attentionItemService.ts
Original file line number Diff line number Diff line change
Expand Up @@ -236,6 +236,59 @@ export const sweepResolvedMentionAttention = async ({ apply = false }: { apply?:
return { scanned: rows.length, eligible, resolved, unavailable };
};

/**
* Audit rows already stamped `resolvedBy: 'replied'`. Before the narrow
* resolver landed, any post by the recipient in the pod closed every open
* mention they held there, so a row can assert a reply that was never
* written. This re-reads each row's source with the same evidence the sweep
* uses and reopens only the ones the source contradicts.
*
* Three outcomes, not two. `undecidable` exists because Mongo-fallback
* sources persist no reply or thread edges: absence of evidence there is not
* evidence the recipient stayed silent, and reopening those would re-raise
* attention a user has already dealt with. They are counted and left alone.
*/
export const auditRepliedMentionAttention = async (
{ apply = false, resolvedBefore }: { apply?: boolean; resolvedBefore?: Date } = {},
) => {
const before = validDate(resolvedBefore);
const rows = await AttentionItem.find({
kind: 'mention',
status: 'resolved',
resolvedBy: 'replied',
...(before ? { resolvedAt: { $lt: before } } : {}),
}).sort({ createdAt: 1 }).lean();
let kept = 0;
let contradicted = 0;
let reopened = 0;
let undecidable = 0;
let unavailable = 0;
for (const row of rows) {
try {
const sourceId = String(row?.source?.id || '');
// Only a PostgreSQL source carries the reply and thread edges this
// audit needs; see recipientRepliedAfterMention.
if (!/^\d+$/.test(sourceId)) { undecidable += 1; continue; }
const sourceCreatedAt = await sourceTimeForMention(row);
if (!sourceCreatedAt) { undecidable += 1; continue; }
if (await recipientRepliedAfterMention(row, sourceCreatedAt)) { kept += 1; continue; }
contradicted += 1;
if (!apply) continue;
// Repeat the stamp in the filter so a row an operator acknowledged
// between the read and the write is not reopened underneath them.
const result = await AttentionItem.updateOne(
{ _id: row._id, kind: 'mention', status: 'resolved', resolvedBy: 'replied' },
{ $set: { status: 'open' }, $unset: { resolvedAt: '', resolvedBy: '' } },
);
reopened += Number(result.modifiedCount || 0);
} catch (error) {
unavailable += 1;
console.warn('[attention] replied-stamp audit skipped an unreadable source:', (error as Error).message);
}
}
return { scanned: rows.length, kept, contradicted, reopened, undecidable, unavailable };
};

export const recordApproval = async (approval: any): Promise<void> => {
try {
const podId = approval?.podId;
Expand Down Expand Up @@ -375,6 +428,6 @@ export const acknowledgeMention = async (recipientUserId: unknown, attentionItem
return result.modifiedCount === 1 ? { success: true } : { success: false, error: 'Attention item not found' };
};

export default { recordMentionedUsers, resolveMentionAttentionForReply, sweepResolvedMentionAttention, recordApproval, recordDecision, recordTaskAttention, resolveTaskAttention, resolve, resolveMany, getOpenQueue, acknowledgeMention };
export default { recordMentionedUsers, resolveMentionAttentionForReply, sweepResolvedMentionAttention, auditRepliedMentionAttention, recordApproval, recordDecision, recordTaskAttention, resolveTaskAttention, resolve, resolveMany, getOpenQueue, acknowledgeMention };
// eslint-disable-next-line @typescript-eslint/no-require-imports
module.exports = { recordMentionedUsers, resolveMentionAttentionForReply, sweepResolvedMentionAttention, recordApproval, recordDecision, recordTaskAttention, resolveTaskAttention, resolve, resolveMany, getOpenQueue, acknowledgeMention, TASK_HANDOFF_RE };
module.exports = { recordMentionedUsers, resolveMentionAttentionForReply, sweepResolvedMentionAttention, auditRepliedMentionAttention, recordApproval, recordDecision, recordTaskAttention, resolveTaskAttention, resolve, resolveMany, getOpenQueue, acknowledgeMention, TASK_HANDOFF_RE };
Loading