Files
PaperClipAI/server/src/__tests__/app-private-hostname-gate.test.ts
T
DottaandPaperclip 5f0da562db fix(server): wake chat lifecycle milestones from committed changes (#17606)
## Thinking Path

> - Paperclip is the open source app people use to manage AI agents for
work.
> - Connected chats show safe notices when a run starts, waits,
finishes, or fails.
> - A recurring scan projected these notices even when no eligible work
existed.
> - Eligibility depends on committed run state, conversation lineage,
and current authority.
> - Existing database rows can reconstruct that work after restart.
> - This PR wakes a scoped worker from those writes and removes
lifecycle polling.
> - Native progress events keep their separate existing owner until the
next migration.

## Linked Issues or Issue Description

**What happened?**

The chat maintenance timer repeatedly scanned run lifecycle state. Empty
and settled instances still queried the database. A run-status event
alone cannot replace the scan: prompt receipts, continuation receipts,
identity changes, and restored authority can make a run eligible later.

**What did you expect?**

Committed changes wake projection. Startup reconstructs eligible work. A
completed pass stops until another relevant change arrives. Failed work
retains recovery without scanning settled history forever.

**Steps to reproduce**

Configure an automatic chat endpoint. Finish its work, or leave it
unused. Observe recurring lifecycle projection queries from the chat
maintenance callback.

Stacked on #17592. Related foundation: #15625. This PR owns lifecycle
milestones only.

## What Changed

- Preserve upstream coalesced Cloud membership writes together with
locked membership restoration and its committed chat notification.

- Signal milestone eligibility from the real Slack account-link
transaction.

- Register the lifecycle worker with the existing delivery coordinator.
Remove lifecycle projection from the periodic chat callback. Keep native
progress in a separately named lane and preserve the explicit combined
API.
- Reconstruct company/issue scopes from the existing selector. Drain
bounded batches with company fairness. Retry failures, retain writes
through drain/resume, and disarm settled work.
- Signal from supported run, presentation, interaction, receipt,
lineage, conversation, recovery, and authority writers. Preserve their
transaction ownership, including nested savepoints and uncertain
settlement. No new table is added.
- Preserve provider authorization, authored-final suppression,
responsible-user continuity, deduplication, the 24-hour selection
window, and raw-output restrictions. Keep provider interpretation in its
owner.
- Preserve PostgreSQL timestamp precision in lifecycle pagination.
Rejected submillisecond candidates must not repeat the same page and
starve later notices.
- Document producer coverage and alternate-writer dispositions in
`doc/plans/2026-10-25-chat-run-milestone-sleep-audit.md`.

## Verification

Published head: `1d056f68c5b1005933b9e12de8745bf1c4cbeeca`, targeting
master after #17592 merged at
`7d75ae0569e41786640cf09647ae8849411d2f22`. A normal merge integrates
the actual landed parent. The entire 77-path milestone patch is
binary-identical to the locally verified preparation across that
ancestry reconciliation; both unrelated master paths are preserved. The
four earlier producer/test integration conflicts retain scoped milestone
and voice notices, current onboarding completion admission, and expanded
identity query/read-scope coverage.

- **330 distinct selected cases pass on the verified unchanged patch:**
305 across 14 complete suites (all five milestone worker suites, run
identity, board claim, Cloud provisioning, auth session routes, Cloud
actor authentication, stale-lock recovery, GitHub credentials, project
Git authorization, and app admission wiring), plus 25 selected real chat
producer cases. The remaining 1,590 chat integration cases were skipped,
not passed.
- The real producer cases cover saved milestone discovery, GitHub and
Slack identity changes, account-link rollback/reconfirmation, ordinary
ingress reactivation, post-close action/delivery receipt settlement, all
ten authority-restoration paths including Cloud synchronization, and
changed native review metadata.
- Worker coverage verifies outer commit, rollback, uncertain-write
recovery, restart, bounded batching/fairness, timestamp precision,
retries, drain/resume, and settled SQL quiet. Authorization and existing
publication deduplication remain with their owning services.
- Server production typechecking, build, and module-boundary checks
pass. The tests above were executed separately. Production typechecking
excludes `src/__tests__` and does not replace those runs. Each database
owner uses its own newly created and app-migrated PostgreSQL fixture.
- [Current-parent acceptance and preserved failure
ledger](https://github.com/paperclipai/paperclip/blob/1d056f68c5b1005933b9e12de8745bf1c4cbeeca/doc/plans/2026-10-25-chat-run-milestone-sleep-audit.md#current-parent-integration-and-acceptance-october-28)
records commands, counts, producer scope, conflict dispositions, and
limits. Earlier combined-stack counts and failures keep their original
revision scope; local acceptance does not erase old CI failures.
- No new migration, queue table, dependency, workflow, or shared
scheduler contract is introduced. Parent migration 0397, bounded consent
recovery, voice cleanup/retention repairs, and runner checkpoint
restoration remain present.
- **Exact-head Greptile is 5/5 and all 55 current checks are clean.** No
Ponytail review is present, which is an explicitly authorized
nonblocker; no failing review is waived. Older reviews and parent checks
are not current passes. Actual failing Ponytail findings must be fixed;
an absent/stopped/paused turn without a verdict is an authorized
nonblocker. Active unfinished Ponytail uses its original request/start
timer, with a 25-minute monitor cutoff respecting the user's 30-minute
maximum.
- No complete local repository matrix, live provider tenant, external
alarm, physical host/database suspension, or fleet savings claim is
made. Native progress remains a separate following slice.

## Risks

- A future eligibility writer must notify the existing projection
boundary. Independent SQL writers or another active server process are
outside this process-local signal contract.
- Authority and lineage remain sensitive. The worker uses the existing
selectors and publication policy; it does not grant execution or
disclosure authority.
- Native progress and other publication maintenance still have recurring
work. This PR removes one reason, not the entire chat maintenance timer.
- Worker quiet does not prove database suspension, host sleep, or
external alarm coverage. Cloud sleep blockers remain unchanged.
- Merge eligibility requires passing current-head CI and Greptile 5/5
after this actual-master reconciliation. Subsequent stack slices retain
their own gates.

## Model Used

OpenAI Codex, GPT-6 family. The exact serving model ID and
context-window size are not exposed. Used repository inspection,
reasoning, shell execution, PostgreSQL tests, browser E2Es and GitHub
tools.

## Checklist

- [x] I have included a thinking path that traces from project context
to this change
- [x] I have specified the model used (with version and capability
details available in this session)
- [x] I have checked ROADMAP.md and confirmed this PR does not duplicate
planned core work
- [x] I have searched GitHub for duplicate or related PRs and linked
them above
- [x] I have either (a) linked existing issues with `Fixes: #` / `Closes
#` / `Refs #` OR (b) described the issue in-PR following the relevant
issue template
- [x] I have not referenced internal/instance-local Paperclip issues or
links (only public GitHub `#NNN` / `github.com/paperclipai/paperclip`
URLs)
- [x] My branch name describes the change (e.g. `docs/...`, `fix/...`)
and contains no internal Paperclip ticket id or instance-derived details
- [x] I have run the documented focused tests locally and they pass
- [x] I have added or updated tests where applicable
- [x] I have updated relevant documentation to reflect my changes
- [x] I have considered and documented any risks above
- [x] All Paperclip CI gates are green
- [x] Greptile is 5/5 with no open P2s, recommendations, or follow-ups
- [x] I will address all Greptile and reviewer comments before
requesting merge

---------

Co-authored-by: Paperclip <noreply@paperclip.ing>
2026-10-28 11:07:54 -05:00

417 lines
17 KiB
TypeScript

import { readFileSync } from "node:fs";
import { describe, expect, it, vi } from "vitest";
import {
createChatReconciliationCoordinator,
shouldEnablePrivateHostnameGuard,
} from "../app.ts";
describe("createChatReconciliationCoordinator", () => {
it.each([false, true])(
"keeps later inbound sweeps independent of GitHub recovery and joins shutdown (rejects: %s)",
async (rejects) => {
let release!: () => void;
const held = new Promise<void>((resolve) => {
release = resolve;
});
const failure = new Error("GitHub recovery unavailable");
const processFailedGitHubWebhookDeliveries = vi.fn(async () => {
await held;
if (rejects) throw failure;
});
const processPendingDeliveries = vi.fn(async () => undefined);
const flushPublications = vi.fn(async () => undefined);
const onError = vi.fn();
const input = {
processFailedGitHubWebhookDeliveries,
processPendingDeliveries,
flushPublications,
projectNativeProgress: async () => 0,
processPendingSlackFileUploadReceipts: async () => undefined,
processPendingSlackSessionSyncs: async () => undefined,
onError,
};
const coordinator = createChatReconciliationCoordinator(input);
let draining: Promise<void> | undefined;
try {
coordinator.reconcile();
await vi.waitFor(() => {
expect(processFailedGitHubWebhookDeliveries).toHaveBeenCalledOnce();
expect(processPendingDeliveries).toHaveBeenCalledTimes(1);
});
// A second sweep must admit work arriving AFTER the first sweep while
// remote GitHub recovery remains held. Same-sweep parallelism is not enough.
coordinator.reconcile();
await vi.waitFor(() => {
expect(processPendingDeliveries).toHaveBeenCalledTimes(2);
expect(flushPublications).toHaveBeenCalledTimes(2);
});
expect(processFailedGitHubWebhookDeliveries).toHaveBeenCalledOnce();
coordinator.stop();
let drained = false;
draining = coordinator.drain().then(() => {
drained = true;
});
await new Promise<void>((resolve) => setImmediate(resolve));
expect(drained).toBe(false);
coordinator.reconcile();
release();
await draining;
expect(drained).toBe(true);
expect(processFailedGitHubWebhookDeliveries).toHaveBeenCalledOnce();
expect(processPendingDeliveries).toHaveBeenCalledTimes(2);
expect(onError).toHaveBeenCalledTimes(rejects ? 1 : 0);
if (rejects)
expect(onError).toHaveBeenCalledWith(
"GitHub webhook recovery",
failure,
);
} finally {
release();
coordinator.stop();
await (draining ?? coordinator.drain());
}
},
);
it("keeps receiver and voice inventory out of periodic chat maintenance", () => {
const source = readFileSync(new URL("../app.ts", import.meta.url), "utf8");
const maintenance = source.slice(source.indexOf("const chatReconciliation ="), source.indexOf("const unsubscribeChatPublicationSignals"));
expect(maintenance).not.toContain("chatChannels.reconcileProviderRuntimes()");
expect(maintenance).not.toContain("chatChannels.voice.");
expect(source).toContain("chatChannels.registerReceiverWork(deliveryWork,");
expect(source).toContain("chatChannels.registerTeamsConsentExpiryWork(deliveryWork,");
const teams = readFileSync(new URL("../services/chat-channels/microsoft-teams/file-delivery.ts", import.meta.url), "utf8");
expect(source).toContain("chatChannels.registerTeamsAttemptRecoveryWork(deliveryWork,");
expect(teams).not.toContain("scheduleTeamsFileMaintenance");
expect(teams).not.toContain("selectDistinct");
const chat = readFileSync(new URL("../services/chat-channels.ts", import.meta.url), "utf8");
expect(chat).not.toContain("scheduleTeamsFileMaintenance");
});
it("separates live voice recovery from the remaining periodic maintenance", () => {
const source = readFileSync(new URL("../app.ts", import.meta.url), "utf8");
expect(source).toContain("chatChannels.voice.registerRecoveryWork(deliveryWork,");
expect(source).toContain("chatChannels.voice.registerReplyWork(deliveryWork,");
expect(source).toContain("chatChannels.voice.inbound.registerCleanupWork(deliveryWork,");
expect(source).toContain("chatChannels.voice.inbound.registerRetentionWork(deliveryWork,");
expect(source).toContain("chatChannels.voice.registerReportWork(deliveryWork,");
expect(source).not.toContain("reconcileVoiceSessions");
expect(source).not.toContain("chatChannels.voice.reconcile()");
});
it("wires GitHub recovery into its independent lane", () => {
const source = readFileSync(new URL("../app.ts", import.meta.url), "utf8");
expect(source).toContain(
"chatChannels.processFailedGitHubWebhookDeliveries()",
);
const service = readFileSync(
new URL("../services/chat-channels.ts", import.meta.url),
"utf8",
);
const ordinary = service.slice(
service.indexOf("async function processPendingDeliveries("),
service.indexOf("async function listResources("),
);
expect(ordinary).not.toContain("processFailedGitHubWebhookDeliveries()");
});
it("wires bounded publication refill to commit workers and isolates periodic maintenance", () => {
const source = readFileSync(new URL("../app.ts", import.meta.url), "utf8");
expect(source).toContain("registerChatDeliveryWork(deliveryWork, chatChannels,");
expect(source).toContain("registerChatActionWork(deliveryWork, chatChannels,");
const maintenance = source.slice(
source.indexOf("const reconcileChatPublicationMaintenance ="),
source.indexOf("const chatReconciliation ="),
);
expect(maintenance).toContain("chatChannels.processPublicationMaintenance()");
expect(maintenance).not.toContain("chatChannels.schedulePendingPublications()");
expect(maintenance).not.toContain("chatChannels.processPendingPublications()");
const workers = readFileSync(new URL("../services/chat-delivery-work.ts", import.meta.url), "utf8");
expect(workers).toContain("run: service.scheduleQueuedPublications");
// The service integration tests hold real publication workers while this
// scheduled method returns; app shutdown must also join those workers.
expect(source).toContain("await chatChannels.shutdown()");
expect(source).not.toContain("chatChannels.processPendingSlackSessionSyncs()");
expect(source).not.toContain("chatChannels.processPendingChatMaintenance()");
const service = readFileSync(new URL("../services/chat-channels.ts", import.meta.url), "utf8");
const actionMaintenance = service.slice(service.indexOf("async function processPendingChatMaintenance("),
service.indexOf("async function processPendingDeliveries("));
expect(actionMaintenance).not.toContain("processFailedChatRunRetries(");
expect(actionMaintenance).not.toContain("processPendingProviderEffects(");
expect(actionMaintenance).not.toContain("processPendingGitHubWebhookIngress(");
});
it.each([false, true])(
"isolates a blocked native progress projector and joins its completion (rejects: %s)",
async (rejects) => {
let release!: () => void;
const held = new Promise<void>((resolve) => {
release = resolve;
});
const failure = new Error("native progress projection failed");
const projectNativeProgress = vi
.fn(async () => 0)
.mockImplementationOnce(async () => {
await held;
if (rejects) throw failure;
return 1;
});
const flushPublications = vi.fn(async () => undefined);
const onError = vi.fn();
const coordinator = createChatReconciliationCoordinator({
projectNativeProgress,
processPendingDeliveries: async () => undefined,
flushPublications,
processPendingSlackFileUploadReceipts: async () => undefined,
processPendingSlackSessionSyncs: async () => undefined,
onError,
});
try {
coordinator.reconcile();
await vi.waitFor(() => {
expect(projectNativeProgress).toHaveBeenCalledTimes(1);
expect(flushPublications).toHaveBeenCalledTimes(1);
});
// A newly committed final/question must dispatch even while a different
// issue's projection is blocked. Repeated signals leave one dirty retry.
coordinator.notifyPublications();
coordinator.notifyPublications();
await vi.waitFor(() =>
expect(flushPublications).toHaveBeenCalledTimes(2),
);
expect(projectNativeProgress).toHaveBeenCalledTimes(1);
let drained = false;
const draining = coordinator.drain().then(() => {
drained = true;
});
await new Promise<void>((resolve) => setImmediate(resolve));
expect(drained).toBe(false);
release();
await draining;
expect(projectNativeProgress).toHaveBeenCalledTimes(2);
expect(onError).toHaveBeenCalledTimes(rejects ? 1 : 0);
if (rejects)
expect(onError).toHaveBeenCalledWith("native progress", failure);
const completedCounts = [
projectNativeProgress.mock.calls.length,
flushPublications.mock.calls.length,
];
await new Promise<void>((resolve) => setImmediate(resolve));
expect([
projectNativeProgress.mock.calls.length,
flushPublications.mock.calls.length,
]).toEqual(completedCounts);
} finally {
release();
coordinator.stop();
await coordinator.drain();
}
},
);
it("joins a blocked native progress projector at shutdown without starting dirty follow-ups", async () => {
let release!: () => void;
const held = new Promise<void>((resolve) => {
release = resolve;
});
const projectNativeProgress = vi.fn(async () => {
await held;
return 1;
});
const flushPublications = vi.fn(async () => undefined);
const coordinator = createChatReconciliationCoordinator({
projectNativeProgress,
flushPublications,
processPendingDeliveries: async () => undefined,
processPendingSlackFileUploadReceipts: async () => undefined,
processPendingSlackSessionSyncs: async () => undefined,
onError: vi.fn(),
});
try {
coordinator.reconcile();
await vi.waitFor(() => {
expect(projectNativeProgress).toHaveBeenCalledOnce();
expect(flushPublications).toHaveBeenCalledOnce();
});
coordinator.notifyPublications();
coordinator.stop();
let drained = false;
const draining = coordinator.drain().then(() => {
drained = true;
});
await new Promise<void>((resolve) => setImmediate(resolve));
expect(drained).toBe(false);
release();
await draining;
expect(projectNativeProgress).toHaveBeenCalledOnce();
expect(flushPublications).toHaveBeenCalledOnce();
} finally {
release();
coordinator.stop();
await coordinator.drain();
}
});
it("keeps slow optional recovery from suppressing later publication sweeps", async () => {
let releaseDelivery!: () => void;
let releaseSlackStatus!: () => void;
const deliveryReleased = new Promise<void>((resolve) => {
releaseDelivery = resolve;
});
const slackStatusReleased = new Promise<void>((resolve) => {
releaseSlackStatus = resolve;
});
const processPendingDeliveries = vi.fn(async () => deliveryReleased);
const flushPublications = vi.fn(async () => undefined);
const processPendingSlackFileUploadReceipts = vi.fn(async () => undefined);
const processPendingSlackSessionSyncs = vi.fn(
async () => slackStatusReleased,
);
const onError = vi.fn();
const coordinator = createChatReconciliationCoordinator({
projectNativeProgress: async () => 0,
processPendingDeliveries,
flushPublications,
processPendingSlackFileUploadReceipts,
processPendingSlackSessionSyncs,
onError,
});
coordinator.reconcile();
await vi.waitFor(() => {
expect(processPendingDeliveries).toHaveBeenCalledTimes(1);
expect(flushPublications).toHaveBeenCalledTimes(1);
expect(processPendingSlackFileUploadReceipts).toHaveBeenCalledTimes(1);
expect(processPendingSlackSessionSyncs).toHaveBeenCalledTimes(1);
});
coordinator.reconcile();
await vi.waitFor(() => {
expect(flushPublications).toHaveBeenCalledTimes(2);
expect(processPendingSlackFileUploadReceipts).toHaveBeenCalledTimes(2);
});
expect(processPendingDeliveries).toHaveBeenCalledTimes(1);
expect(processPendingSlackSessionSyncs).toHaveBeenCalledTimes(1);
releaseDelivery();
releaseSlackStatus();
await coordinator.drain();
expect(onError).not.toHaveBeenCalled();
});
it.each([false, true])(
"joins the independent Slack file receipt lane at shutdown (rejects: %s)",
async (rejects) => {
let releaseReceipt!: () => void;
const receiptReleased = new Promise<void>((resolve) => {
releaseReceipt = resolve;
});
const lookupError = new Error("receipt lookup failed");
const processPendingSlackFileUploadReceipts = vi
.fn(async () => undefined)
.mockImplementationOnce(async () => {
await receiptReleased;
if (rejects) throw lookupError;
});
const processPendingDeliveries = vi.fn(async () => undefined);
const flushPublications = vi.fn(async () => undefined);
const processPendingSlackSessionSyncs = vi.fn(async () => undefined);
const onError = vi.fn();
const coordinator = createChatReconciliationCoordinator({
projectNativeProgress: async () => 0,
processPendingDeliveries,
flushPublications,
processPendingSlackFileUploadReceipts,
processPendingSlackSessionSyncs,
onError,
});
let draining: Promise<void> | undefined;
try {
coordinator.reconcile();
await vi.waitFor(() => {
expect(processPendingSlackFileUploadReceipts).toHaveBeenCalledTimes(
1,
);
expect(flushPublications).toHaveBeenCalledTimes(1);
});
coordinator.reconcile();
await vi.waitFor(() => {
expect(processPendingDeliveries).toHaveBeenCalledTimes(2);
expect(flushPublications).toHaveBeenCalledTimes(2);
expect(processPendingSlackSessionSyncs).toHaveBeenCalledTimes(2);
});
expect(processPendingSlackFileUploadReceipts).toHaveBeenCalledTimes(1);
let drained = false;
draining = coordinator.drain().then(() => {
drained = true;
});
// Flush the promise queue, not a wall-clock delay: the held receipt
// must still be part of shutdown's joined work after other lanes end.
await new Promise<void>((resolve) => setImmediate(resolve));
expect(drained).toBe(false);
expect(onError).not.toHaveBeenCalled();
releaseReceipt();
await draining;
expect(drained).toBe(true);
expect(onError).toHaveBeenCalledTimes(rejects ? 1 : 0);
if (rejects) {
expect(onError).toHaveBeenCalledWith(
"Slack file receipts",
lookupError,
);
}
// A completed or failed lookup releases only its own single-flight
// slot; the next ordinary reconciliation can recover another receipt.
coordinator.reconcile();
await coordinator.drain();
expect(processPendingSlackFileUploadReceipts).toHaveBeenCalledTimes(2);
expect(onError).toHaveBeenCalledTimes(rejects ? 1 : 0);
} finally {
releaseReceipt();
await draining;
await coordinator.drain();
}
},
);
});
describe("shouldEnablePrivateHostnameGuard", () => {
it("enables the hostname guard for local_trusted private deployments", () => {
expect(
shouldEnablePrivateHostnameGuard({
deploymentMode: "local_trusted",
deploymentExposure: "private",
}),
).toBe(true);
});
it("does not enable the hostname guard for local_trusted public deployments", () => {
expect(
shouldEnablePrivateHostnameGuard({
deploymentMode: "local_trusted",
deploymentExposure: "public",
}),
).toBe(false);
});
it("enables the hostname guard for authenticated private deployments", () => {
expect(
shouldEnablePrivateHostnameGuard({
deploymentMode: "authenticated",
deploymentExposure: "private",
}),
).toBe(true);
});
it("does not enable the hostname guard for authenticated public deployments", () => {
expect(
shouldEnablePrivateHostnameGuard({
deploymentMode: "authenticated",
deploymentExposure: "public",
}),
).toBe(false);
});
});