mirror of
https://github.com/paperclipai/paperclip.git
synced 2026-11-01 06:42:06 +01:00
fix(server): bound inbound notice authorization passes
Co-Authored-By: Paperclip <noreply@paperclip.ing>
This commit is contained in:
1 parent
87f5c827b0
commit
fee07f3fa8
4 files changed
+197
-34
No files matched your search
@@ -19,12 +19,12 @@ type NoticeOptions = {
|
||||
isShuttingDown: () => boolean;
|
||||
};
|
||||
export type InboundWakeNoticeScope = { companyId: string; issueId?: string };
|
||||
type NoticeCursor = { createdAt: string; id: string };
|
||||
export type InboundWakeNoticeCursor = { createdAt: string; id: string };
|
||||
type NoticeCandidate = { action: typeof chatActions.$inferSelect; cursorCreatedAt: string };
|
||||
|
||||
export function createInboundWakeNotices(db: Db, options: NoticeOptions) {
|
||||
const { authorizeInboundWakeup, enqueueFailedChatRetryPublications, authorizeRetainedChatSourcePublication, isExternalActionAuthorizationChange } = options;
|
||||
let inboundWakeNoticeCursor: NoticeCursor | null = null;
|
||||
let inboundWakeNoticeCursor: InboundWakeNoticeCursor | null = null;
|
||||
async function currentInboundWakeNotice(
|
||||
tx: DbOrTransaction,
|
||||
action: typeof chatActions.$inferSelect,
|
||||
@@ -134,7 +134,7 @@ export function createInboundWakeNotices(db: Db, options: NoticeOptions) {
|
||||
return Boolean(link);
|
||||
}
|
||||
|
||||
async function noticeCandidates(limit: number, cursor: NoticeCursor | null, scope?: InboundWakeNoticeScope): Promise<NoticeCandidate[]> {
|
||||
async function noticeCandidates(limit: number, cursor: InboundWakeNoticeCursor | null, scope?: InboundWakeNoticeScope): Promise<NoticeCandidate[]> {
|
||||
const owner = alias(agentWakeupRequests, "chat_notice_owner");
|
||||
// Keep UUID columns indexable. Match only canonical lowercase UUID text,
|
||||
// preserving the old comparison while malformed payloads match nothing.
|
||||
@@ -212,7 +212,7 @@ export function createInboundWakeNotices(db: Db, options: NoticeOptions) {
|
||||
.limit(Math.max(1, Math.min(limit, 200)));
|
||||
}
|
||||
|
||||
function continuation(candidates: NoticeCandidate[], limit: number): NoticeCursor | null {
|
||||
function continuation(candidates: NoticeCandidate[], limit: number): InboundWakeNoticeCursor | null {
|
||||
const last = candidates.at(-1);
|
||||
return candidates.length === limit && last
|
||||
? { createdAt: last.cursorCreatedAt, id: last.action.id }
|
||||
@@ -325,7 +325,7 @@ export function createInboundWakeNotices(db: Db, options: NoticeOptions) {
|
||||
|
||||
async function loadInboundWakeNoticeScopes(scope?: InboundWakeNoticeScope, signal?: AbortSignal) {
|
||||
const scopes = new Map<string, { companyId: string; issueId: string }>();
|
||||
let cursor: NoticeCursor | null = null;
|
||||
let cursor: InboundWakeNoticeCursor | null = null;
|
||||
do {
|
||||
signal?.throwIfAborted();
|
||||
const candidates = await noticeCandidates(200, cursor, scope);
|
||||
@@ -340,22 +340,16 @@ export function createInboundWakeNotices(db: Db, options: NoticeOptions) {
|
||||
}
|
||||
|
||||
async function reconcileInboundWakeNotices(input: {
|
||||
scope: InboundWakeNoticeScope; limit?: number; signal?: AbortSignal;
|
||||
scope: InboundWakeNoticeScope; limit?: number; cursor?: InboundWakeNoticeCursor | null; signal?: AbortSignal;
|
||||
}) {
|
||||
const limit = Math.max(1, Math.min(input.limit ?? 100, 1000));
|
||||
let inserted = 0;
|
||||
let contended = false;
|
||||
let cursor: NoticeCursor | null = null;
|
||||
do {
|
||||
input.signal?.throwIfAborted();
|
||||
const pageSize = Math.min(200, limit - inserted);
|
||||
const candidates = await noticeCandidates(pageSize, cursor, input.scope);
|
||||
const result = await projectNotices(candidates, input.signal);
|
||||
inserted += result.inserted;
|
||||
contended ||= result.contended;
|
||||
cursor = continuation(candidates, pageSize);
|
||||
} while (cursor && inserted < limit);
|
||||
return { inserted, contended };
|
||||
// Bound authorization work, not successful publications: denied history is
|
||||
// still a candidate check. The worker retains this precise cursor between
|
||||
// passes and resets it when a committed change invalidates the scope.
|
||||
input.signal?.throwIfAborted();
|
||||
const limit = Math.max(1, Math.min(input.limit ?? 100, 200));
|
||||
const candidates = await noticeCandidates(limit, input.cursor ?? null, input.scope);
|
||||
const result = await projectNotices(candidates, input.signal);
|
||||
return { ...result, nextCursor: continuation(candidates, limit) };
|
||||
}
|
||||
|
||||
async function authorizeInboundWakePublication(
|
||||
|
||||
@@ -1,10 +1,13 @@
|
||||
import type { ChatChannelService } from "./chat-channels.js";
|
||||
import type { InboundWakeNoticeScope } from "./chat-channels/inbound/wakeup-notices.js";
|
||||
import type { InboundWakeNoticeCursor, InboundWakeNoticeScope } from "./chat-channels/inbound/wakeup-notices.js";
|
||||
import type { createDeliveryWorkCoordinator } from "./delivery-work-coordinator.js";
|
||||
import { DELIVERY_QUEUES } from "./delivery-work-notifications.js";
|
||||
import { registerScopedDeadlineWork } from "./scoped-deadline-work.js";
|
||||
|
||||
type Entry = { id: string; companyId: string; issueId: string; dueAt: number };
|
||||
type Entry = {
|
||||
id: string; companyId: string; issueId: string; dueAt: number;
|
||||
cursor: InboundWakeNoticeCursor | null; contended: boolean;
|
||||
};
|
||||
const batchSize = 100;
|
||||
|
||||
/** Admission receipts own queued/not-started/removed notices. The channel
|
||||
@@ -14,13 +17,19 @@ export function chatInboundWakeNoticeWork(service: Pick<ChatChannelService,
|
||||
"loadInboundWakeNoticeScopes" | "reconcileInboundWakeNotices">) {
|
||||
async function load(scope?: InboundWakeNoticeScope, signal?: AbortSignal): Promise<Entry[]> {
|
||||
return (await service.loadInboundWakeNoticeScopes(scope, signal)).map(row => ({
|
||||
...row, id: `${row.companyId}:${row.issueId}`, dueAt: 0,
|
||||
...row, id: `${row.companyId}:${row.issueId}`, dueAt: 0, cursor: null, contended: false,
|
||||
}));
|
||||
}
|
||||
async function check(entry: Entry, signal?: AbortSignal): Promise<Entry | null> {
|
||||
const result = await service.reconcileInboundWakeNotices({ scope: entry, limit: batchSize, signal });
|
||||
if (result.inserted === batchSize) return { ...entry, dueAt: Date.now() };
|
||||
return result.contended ? { ...entry, dueAt: Date.now() + 1000 } : null;
|
||||
const result = await service.reconcileInboundWakeNotices({
|
||||
scope: { companyId: entry.companyId, issueId: entry.issueId },
|
||||
limit: batchSize, cursor: entry.cursor, signal,
|
||||
});
|
||||
const contended = entry.contended || result.contended;
|
||||
if (result.nextCursor) return { ...entry, cursor: result.nextCursor, contended, dueAt: Date.now() };
|
||||
// Finish the pass before revisiting contention, so a locked prefix cannot
|
||||
// starve later candidates. Do not forget an earlier page's unresolved lock.
|
||||
return contended ? { ...entry, cursor: null, contended: false, dueAt: Date.now() + 1000 } : null;
|
||||
}
|
||||
function register(coordinator: ReturnType<typeof createDeliveryWorkCoordinator>, canRun: () => boolean) {
|
||||
return registerScopedDeadlineWork(coordinator, {
|
||||
|
||||
Reference in new issue
Block a user