mirror of
https://github.com/paperclipai/paperclip.git
synced 2026-10-21 16:27:18 +02:00
## Thinking Path > - Paperclip manages AI agents and their work. > - Plugins keep external resources aligned with Paperclip projects and agents. > - The durable resource journal stores changes, but its plugin interface requires polling. > - Project deletion has no journal entry, so consumers must scan for missing projects. > - This PR delivers committed journal events through a native plugin callback and records deletion. > - Plugins can recover unfinished work without periodic change scans. ## Linked Issues or Issue Description Related: #15343 introduced the durable inbox. #15631 added required agent lifecycle participation. This change keeps both contracts and adds automatic delivery for the resource inbox. **What happened?** Plugins must schedule reads of the durable inbox. A deleted project has no durable deletion event. Consumers must scan saved bindings to detect its removal. **Expected behavior** Core should deliver committed resource events and replay unfinished work. Empty queues should have no polling timer. Project removal should leave a durable event. **Steps to reproduce** 1. Install a plugin that consumes project lifecycle events. 2. Create or update a project. The plugin must call `listLifecycle` to discover it. 3. Delete the project. The journal has no deletion entry. ## What Changed - Add `resourceLifecycle` to the manifest and `onResourceLifecycle` to the SDK. Require `events.subscribe` and a configured, enabled company scope. - Reuse transaction-aware work signals and the delivery coordinator. Wake after commit, on startup and plugin activation, and after configuration changes. - Acknowledge only a matching successful result. Retry unfinished or interrupted work. Retain deferred events without an idle retry timer. - Wake again after successful worker configuration application, and retain failed scans through idle drain (both Apex findings fixed). - Keep ordering per resource and deliver past failed or deferred resources. Keep the manual inbox methods for existing consumers. - Record project deletion in the removal transaction. Extend the journal constraint with a generated migration. ## Verification - Apex follow-up: 129 focused worker, delivery, coordinator, and application tests passed; server typecheck passed. Regression coverage verifies configuration-application races and failed scans across idle drain. - Focused lifecycle, transaction coordinator, manifest, and SDK dispatch suites: 105 tests passed, including single-connection saves, redacted failure diagnostics, wakeups retained during idle drain, and per-event retry deadlines. - Complete SDK suite plus delivery database tests: 128 tests passed. - Shared, database, and SDK typechecks passed. Direct server `tsc --noEmit` passed against rebuilt SDK declarations. - Full local `pnpm test:run` finished with 19,491 passed, 148 skipped, and 6 failed tests across 5 files (plus two suite setup errors). This run overlapped branch updates and is not final-head evidence: failures included missing Cargo, local Git default-branch assumptions, and stale in-process lifecycle modules. Final Apex-fix commit `75d028788` passed all CI gates, including the full test shards, and received Apex 5/5 with no unresolved comments. - `pnpm -r typecheck` and `pnpm build` were attempted. Both stopped because Cargo is unavailable locally. CI supplies the Rust toolchain. - Local installation used the repository's documented lockfile fallback because master has dependencies missing from its lockfile. No dependency changes are included here. ## Risks - Delivery is at least once. Plugins must re-read current state and make external effects idempotent. - Signals are process-local, like the other delivery queues. Startup recovers committed rows. Independent database writers or multiple active server processes require an explicit ownership and wake protocol. - Apply migration `0340_open_warlock.sql` before running code that records project deletion. Earlier deletions cannot be reconstructed. - Automatic delivery selects plugins with company configuration rows. `deferred` waits for a later startup, configuration change, or other work notification. - This does not replace required agent lifecycle participation or establish that external mounts are unused. ## Model Used OpenAI Codex (GPT-6), with tool-assisted code editing, code inspection, and test execution. ## 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) - [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 - [ ] I have run 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>
123 lines
6.2 KiB
TypeScript
123 lines
6.2 KiB
TypeScript
import { and, asc, eq, isNull, or } from "drizzle-orm";
|
|
import { pluginCompanySettings, pluginConfig, plugins, type Db } from "@paperclipai/db";
|
|
import { logger } from "../middleware/logger.js";
|
|
import { pluginLifecycleInbox } from "./plugin-lifecycle-inbox.js";
|
|
import { DELIVERY_QUEUES } from "./delivery-work-notifications.js";
|
|
import type { createDeliveryWorkCoordinator } from "./delivery-work-coordinator.js";
|
|
import type { PluginWorkerManager } from "./plugin-worker-manager.js";
|
|
import type { PluginLifecycleManager } from "./plugin-lifecycle.js";
|
|
|
|
type Coordinator = ReturnType<typeof createDeliveryWorkCoordinator>;
|
|
|
|
/** The journal is the queue. Notifications only wake its single local dispatcher. */
|
|
export function registerPluginLifecycleDelivery(
|
|
coordinator: Coordinator,
|
|
db: Db,
|
|
manager: Pick<PluginWorkerManager, "call" | "isRunning" | "onConfigApplied">,
|
|
lifecycle: Pick<PluginLifecycleManager, "on" | "off">,
|
|
) {
|
|
// Startup and every wake remain pending until an admitted run inspects the inbox.
|
|
// The coordinator may reconcile signals while idle drain prevents dispatch.
|
|
let pending = true;
|
|
const retryAt = new Map<string, number>();
|
|
const retryMs = 30_000;
|
|
const delivery = coordinator.register(DELIVERY_QUEUES.pluginLifecycle, {
|
|
retryMs,
|
|
hasPending: async () => pending,
|
|
onSettled: () => { pending = true; },
|
|
nextRunAt() {
|
|
if (!pending) return null;
|
|
let next: number | null = null;
|
|
for (const at of retryAt.values()) {
|
|
// An expired deadline during idle drain must not schedule a busy loop.
|
|
if (at > Date.now() && (next === null || at < next)) next = at;
|
|
}
|
|
return next;
|
|
},
|
|
async run(signal) {
|
|
pending = false;
|
|
try {
|
|
for (const [key, at] of retryAt) if (at <= Date.now()) retryAt.delete(key);
|
|
const configured = await db.select({ plugin: plugins, companyId: pluginConfig.companyId })
|
|
.from(plugins).innerJoin(pluginConfig, eq(pluginConfig.pluginId, plugins.id))
|
|
.where(eq(plugins.status, "ready"))
|
|
.orderBy(asc(plugins.installOrder), asc(pluginConfig.companyId));
|
|
for (const { plugin, companyId } of configured) {
|
|
const types = plugin.manifestJson.resourceLifecycle;
|
|
if (!types?.length || !plugin.manifestJson.capabilities.includes("events.subscribe")) continue;
|
|
const [disabled] = await db.select({ id: pluginCompanySettings.id }).from(pluginCompanySettings)
|
|
.where(and(eq(pluginCompanySettings.pluginId, plugin.id), eq(pluginCompanySettings.companyId, companyId), eq(pluginCompanySettings.enabled, false)));
|
|
if (disabled) continue;
|
|
const inbox = pluginLifecycleInbox(db, plugin.id);
|
|
let afterId: string | undefined;
|
|
// Visit independent resources past a failed or deferred head.
|
|
for (;;) {
|
|
if (signal.aborted) return;
|
|
const events = await inbox.list(companyId, 100, afterId, types);
|
|
for (const event of events) {
|
|
if (signal.aborted) return;
|
|
const retryKey = `${plugin.id}:${event.id}`;
|
|
if (retryAt.has(retryKey)) { pending = true; continue; }
|
|
if (!manager.isRunning(plugin.id)) { pending = true; break; }
|
|
let stage = "eligibility";
|
|
try {
|
|
// Recheck enablement for each invocation; a disable during a batch must stop delivery.
|
|
const [enabled] = await db.select({ manifest: plugins.manifestJson }).from(plugins)
|
|
.leftJoin(pluginCompanySettings, and(eq(pluginCompanySettings.pluginId, plugins.id), eq(pluginCompanySettings.companyId, companyId)))
|
|
.where(and(eq(plugins.id, plugin.id), eq(plugins.status, "ready"),
|
|
or(isNull(pluginCompanySettings.enabled), eq(pluginCompanySettings.enabled, true))));
|
|
if (!enabled?.manifest.resourceLifecycle?.includes(event.resourceType) || !enabled.manifest.capabilities.includes("events.subscribe")) break;
|
|
stage = "callback";
|
|
const result = await manager.call(plugin.id, "resourceLifecycle", event, 30_000);
|
|
stage = "result";
|
|
if (result?.eventId !== event.id || !["acknowledged", "retry", "deferred"].includes(result.status)) {
|
|
throw new Error("Invalid resource lifecycle result");
|
|
}
|
|
if (signal.aborted) return;
|
|
if (result.status === "acknowledged") {
|
|
stage = "acknowledgment";
|
|
await inbox.acknowledge(companyId, event.id);
|
|
// An acknowledgment exposes the next event for this resource. Drain immediately.
|
|
wake();
|
|
} else if (result.status === "retry") {
|
|
retryAt.set(retryKey, Date.now() + retryMs);
|
|
pending = true;
|
|
}
|
|
} catch (error) {
|
|
retryAt.set(retryKey, Date.now() + retryMs);
|
|
// Provider error messages/data may contain secrets; retain only routing
|
|
// identifiers, the failed stage, and the protocol's numeric error code.
|
|
const code = (error as { code?: unknown } | null)?.code;
|
|
logger.warn({ pluginId: plugin.id, companyId, eventId: event.id, stage,
|
|
...(typeof code === "number" ? { rpcCode: code } : {}) }, "Resource lifecycle delivery failed; retry scheduled");
|
|
// Failed RPCs and uncertain acknowledgments leave durable work for replay.
|
|
pending = true;
|
|
}
|
|
}
|
|
if (events.length < 100) break;
|
|
afterId = events.at(-1)!.id;
|
|
}
|
|
}
|
|
} catch (error) {
|
|
pending = true; // A failed scan must survive a retry skipped during idle drain.
|
|
throw error;
|
|
}
|
|
},
|
|
});
|
|
function wake() { pending = true; delivery.wake(); }
|
|
const unsubscribeConfig = manager.onConfigApplied(wake);
|
|
lifecycle.on("plugin.loaded", wake);
|
|
lifecycle.on("plugin.enabled", wake);
|
|
lifecycle.on("plugin.worker_started", wake);
|
|
return {
|
|
...delivery,
|
|
wake,
|
|
dispose() {
|
|
unsubscribeConfig();
|
|
lifecycle.off("plugin.loaded", wake);
|
|
lifecycle.off("plugin.enabled", wake);
|
|
lifecycle.off("plugin.worker_started", wake);
|
|
},
|
|
};
|
|
}
|