Files
PaperClipAI/server/src/services/plugin-job-store.ts
T
DottaandPaperclip 96727b3cfe fix(server): schedule plugin jobs from saved deadlines (#17518)
## Thinking Path

> - Paperclip manages agents and plugin work.
> - Plugins can declare cron jobs with saved deadlines.
> - The existing scheduler queries for due jobs every 30 seconds, even
when none exist.
> - Empty queries keep an otherwise idle database active.
> - This PR uses the shared scheduler and committed job changes to
discover work.
> - Existing job and run rows preserve deadlines and execution history.

## Linked Issues or Issue Description

**What happened?**
The plugin scheduler on the base of this stack runs empty periodic SQL.
Its registration path also advances overdue timestamps. An empty
replacement manifest can leave removed schedules active.

**Expected behavior**
No recurring SQL when no eligible jobs exist. Configured jobs wait for
their saved deadline. Changes reactivate scheduling after commit. Work
remains protected through drain and shutdown.

**Steps to reproduce**
Start the plugin scheduler with no jobs. After startup, observe queries
during a three-minute idle window. The new PostgreSQL regression failed
on the baseline and passes with this change.

**Paperclip version or commit**
Stacked on #17510 at `4d15c08428`. Deployment: source checkout, local
PostgreSQL, controlled plugin RPC. Related: #7905 covers empty-manifest
sync; this migration requires that behavior on both activation paths.
#11571 proposes company-scoped jobs; this PR preserves the existing
instance-scoped job contract.

## What Changed

- Install the concurrency test barrier before due job notifications can
dispatch.

- Make dispatch the sole reservation owner and preserve retained or
concurrently committed results when unregistering a plugin.

- Replace the private interval with the existing shared deadline worker.
- Signal job definition and run changes inside their owning
transactions. No new tables.
- Wake from plugin registration and worker status events. Retain
explicit scheduler wakes received during drain.
- Preserve overdue work and initialize only missing or changed
schedules.
- Recheck claims under a job-row lock. Keep plugin calls outside
transactions and refill capacity on completion.
- Commit completion and schedule advancement together. Retry failed
persistence without repeating the plugin side effect.
- Track admitted calls through shutdown and synchronize empty manifests.

## Verification

This PR now targets master after #17510 merged as
`4e27a82a4265c01f3ab4f05aaecc010a0026de75`. The normal landed-parent
merge preserves the entire verified `df78ca3b5e` tree byte for byte;
only final publication docs change afterwards. The PR still differs in
its original seventeen paths and has no database schema or migration
change.

Primary test revision: `36be0bd73363680d7666d94ff5f1dd28b27f4235`, on
parent `ba80060407`. Final integration of parent `51668b8240` preserves
all plugin/runtime/shutdown sources from that proof and passes all 63
cases in complete plugin, approval-seeding and scoped-deadline suites at
`56ce7c1d2a`, plus server types/build. Only six parent seeding/doc files
changed; this is not a rerun of all eleven suites. Only the original
seventeen plugin-scheduling paths differ from that parent; all other
tracked files, including migration history, match it.

- **247 cases pass in eleven complete suites:** plugin scheduler,
shutdown, warm standby, plugin environment-driver readiness, delivery
coordinator, both retention workers, approval seeding, decision expiry,
scoped deadlines and startup. Server typecheck and production build
pass.
- The new real PostgreSQL lock test failed before the repair: shutdown
remained pending while the completion update waited for a held run row.
After repair, shutdown reports the statement timeout while the lock
remains held; retry after release commits the saved result with exactly
one plugin invocation. All 34 scheduler cases pass.
- Existing regressions preserve real committed schedule changes, saved
deadlines, manual/scheduled reservation, retained success and failure
results during unregister, completion/unregister lock order, independent
progress, rollback/outer commit, quiet configured states, drain/resume
and shutdown cleanup.
- The parent's complete seeding/producer run passed 522 cases at its
stated revision. Earlier duplicate reservations, overwritten retained
results, hourly-clock fixture failures and combined-stack CI/setup
failures remain recorded in `doc/idle-plugin-jobs.md`, its dated audit
and the stack ledger; they are not erased or recounted as this
candidate's tests.
- The completion timeout is transaction-local and defaults to thirty
seconds per PostgreSQL statement. It does not bound every
shutdown/network operation or make plugin side effects safe to replay.
No full local repository-suite or deployed host-sleep claim is made.

Commands: server Vitest for the eleven suites above; server
typecheck/build. Fresh exact-head CI, Greptile and Ponytail reviews
remain required before merge.

## Risks

- This uses process-local notifications. Independent SQL writers and
other replicas need their own wake integration.
- A run left unresolved after process loss is not automatically
replayed: the external effect may have occurred. Existing
lifecycle/operator reconciliation must resolve it. Live-process
settlement failures retry without replay.
- Job definitions remain instance-scoped. This PR does not implement
company-scoped job dispatch.
- Cloud's plugin sleep blockers remain. Internal deadlines do not add
durable external wake coverage or prove deployed sleep.
- The shared deadline helper now retains direct lifecycle wakes through
drain; existing coordinator and decision deadline consumers are tested.

## Model Used

OpenAI Codex, GPT-6-based agent, with repository tools, code execution,
and GitHub access. The exact serving model version and context-window
size are not exposed in this session.

## 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
- [x] 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
- [ ] All Paperclip CI gates are green
- [ ] 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-27 21:07:10 -05:00

503 lines
16 KiB
TypeScript

import { writePluginJobChange, type PluginJobDatabase } from "./plugin-job-work-notifications.js";
/**
* Plugin Job Store — persistence layer for scheduled plugin jobs and their
* execution history.
*
* This service manages the `plugin_jobs` and `plugin_job_runs` tables. It is
* the server-side backing store for the `ctx.jobs` SDK surface exposed to
* plugin workers.
*
* ## Responsibilities
*
* 1. **Sync job declarations** — When a plugin is installed or started, the
* host calls `syncJobDeclarations()` to upsert the manifest's declared jobs
* into the `plugin_jobs` table. Jobs removed from the manifest are marked
* `paused` (not deleted) to preserve history.
*
* 2. **Job CRUD** — List, get, pause, and resume jobs for a given plugin.
*
* 3. **Run lifecycle** — Create job run records, update their status, and
* record results (duration, errors, logs).
*
* 4. **Next-run calculation** — After a run completes the host should call
* `updateNextRunAt()` with the next cron tick so the scheduler knows when
* to fire next.
*
* The capability check (`jobs.schedule`) is enforced upstream by the host
* client factory and manifest validator — this store trusts that the caller
* has already been authorised.
*
* @see PLUGIN_SPEC.md §17 — Scheduled Jobs
* @see PLUGIN_SPEC.md §21.3 — `plugin_jobs` / `plugin_job_runs` tables
*/
import { and, desc, eq, inArray } from "drizzle-orm";
import type { Db } from "@paperclipai/db";
import { plugins, pluginJobs, pluginJobRuns } from "@paperclipai/db";
import type {
PluginJobDeclaration,
PluginJobRunStatus,
PluginJobRunTrigger,
PluginJobRecord,
} from "@paperclipai/shared";
import { notFound } from "../errors.js";
/**
* The statuses used for job *definitions* in the `plugin_jobs` table.
* Aliased from `PluginJobRecord` to keep the store API aligned with
* the domain type (`"active" | "paused" | "failed"`).
*/
type JobDefinitionStatus = PluginJobRecord["status"];
// ---------------------------------------------------------------------------
// Types
// ---------------------------------------------------------------------------
/**
* Input for creating a job run record.
*/
export interface CreateJobRunInput {
/** FK to the plugin_jobs row. */
jobId: string;
/** FK to the plugins row. */
pluginId: string;
/** What triggered this run. */
trigger: PluginJobRunTrigger;
}
/**
* Input for completing (or failing) a job run.
*/
export interface CompleteJobRunInput {
/** Final run status. */
status: PluginJobRunStatus;
/** Error message if the run failed. */
error?: string | null;
/** Run duration in milliseconds. */
durationMs?: number | null;
}
// ---------------------------------------------------------------------------
// Service
// ---------------------------------------------------------------------------
/**
* Create a PluginJobStore backed by the given Drizzle database instance.
*
* @example
* ```ts
* const jobStore = pluginJobStore(db);
*
* // On plugin install/start — sync declared jobs into the DB
* await jobStore.syncJobDeclarations(pluginId, manifest.jobs ?? []);
*
* // Before dispatching a runJob RPC — create a run record
* const run = await jobStore.createRun({ jobId, pluginId, trigger: "schedule" });
*
* // After the RPC completes — record the result
* await jobStore.completeRun(run.id, {
* status: "succeeded",
* durationMs: Date.now() - startedAt,
* });
* ```
*/
export function pluginJobStore(db: PluginJobDatabase) {
// -----------------------------------------------------------------------
// Internal helpers
// -----------------------------------------------------------------------
async function assertPluginExists(db: PluginJobDatabase, pluginId: string): Promise<void> {
const rows = await db
.select({ id: plugins.id })
.from(plugins)
.where(eq(plugins.id, pluginId));
if (rows.length === 0) {
throw notFound(`Plugin not found: ${pluginId}`);
}
}
// -----------------------------------------------------------------------
// Public API
// -----------------------------------------------------------------------
return {
// =====================================================================
// Job declarations (plugin_jobs)
// =====================================================================
/**
* Sync declared jobs from a plugin manifest into the `plugin_jobs` table.
*
* This is called at plugin install and on each worker startup so the DB
* always reflects the manifest's declared jobs:
*
* - **New jobs** are inserted with status `active`.
* - **Existing jobs** have their `schedule` updated if it changed.
* - **Removed jobs** (present in DB but absent from the manifest) are
* set to `paused` so their history is preserved.
*
* The unique constraint `(pluginId, jobKey)` is used for conflict
* resolution.
*
* @param pluginId - UUID of the owning plugin
* @param declarations - Job declarations from the plugin manifest
*/
async syncJobDeclarations(
pluginId: string,
declarations: PluginJobDeclaration[],
): Promise<void> {
return writePluginJobChange(db, async db => {
await assertPluginExists(db, pluginId);
// Fetch existing jobs for this plugin
const existingJobs = await db
.select()
.from(pluginJobs)
.where(eq(pluginJobs.pluginId, pluginId));
const existingByKey = new Map(
existingJobs.map((j) => [j.jobKey, j]),
);
const declaredKeys = new Set<string>();
// Upsert each declared job
for (const decl of declarations) {
declaredKeys.add(decl.jobKey);
const existing = existingByKey.get(decl.jobKey);
const schedule = decl.schedule ?? "";
if (existing) {
// Update schedule if it changed; re-activate if it was paused
const updates: Record<string, unknown> = {
updatedAt: new Date(),
};
if (existing.schedule !== schedule) {
updates.schedule = schedule;
updates.nextRunAt = null;
}
if (existing.status === "paused") {
updates.status = "active";
}
await db
.update(pluginJobs)
.set(updates)
.where(eq(pluginJobs.id, existing.id));
} else {
// Insert new job
await db.insert(pluginJobs).values({
pluginId,
jobKey: decl.jobKey,
schedule,
status: "active",
});
}
}
// Pause jobs that are no longer declared in the manifest
for (const existing of existingJobs) {
if (!declaredKeys.has(existing.jobKey) && existing.status !== "paused") {
await db
.update(pluginJobs)
.set({ status: "paused", updatedAt: new Date() })
.where(eq(pluginJobs.id, existing.id));
}
}
});
},
/**
* List all jobs for a plugin, optionally filtered by status.
*
* @param pluginId - UUID of the owning plugin
* @param status - Optional status filter
*/
async listJobs(
pluginId: string,
status?: JobDefinitionStatus,
): Promise<(typeof pluginJobs.$inferSelect)[]> {
const conditions = [eq(pluginJobs.pluginId, pluginId)];
if (status) {
conditions.push(eq(pluginJobs.status, status));
}
return db
.select()
.from(pluginJobs)
.where(and(...conditions));
},
/**
* Get a single job by its composite key `(pluginId, jobKey)`.
*
* @param pluginId - UUID of the owning plugin
* @param jobKey - Stable job identifier from the manifest
* @returns The job row, or `null` if not found
*/
async getJobByKey(
pluginId: string,
jobKey: string,
): Promise<(typeof pluginJobs.$inferSelect) | null> {
const rows = await db
.select()
.from(pluginJobs)
.where(
and(
eq(pluginJobs.pluginId, pluginId),
eq(pluginJobs.jobKey, jobKey),
),
);
return rows[0] ?? null;
},
/**
* Get a single job by its primary key (UUID).
*
* @param jobId - UUID of the job row
* @returns The job row, or `null` if not found
*/
async getJobById(
jobId: string,
): Promise<(typeof pluginJobs.$inferSelect) | null> {
const rows = await db
.select()
.from(pluginJobs)
.where(eq(pluginJobs.id, jobId));
return rows[0] ?? null;
},
/**
* Fetch a single job by ID, scoped to a specific plugin.
*
* Returns `null` if the job does not exist or does not belong to the
* given plugin — callers should treat both cases as "not found".
*/
async getJobByIdForPlugin(
pluginId: string,
jobId: string,
): Promise<(typeof pluginJobs.$inferSelect) | null> {
const rows = await db
.select()
.from(pluginJobs)
.where(and(eq(pluginJobs.id, jobId), eq(pluginJobs.pluginId, pluginId)));
return rows[0] ?? null;
},
/**
* Update a job's status.
*
* @param jobId - UUID of the job row
* @param status - New status
*/
async updateJobStatus(
jobId: string,
status: JobDefinitionStatus,
): Promise<void> {
return writePluginJobChange(db, async db => {
await db
.update(pluginJobs)
.set({ status, updatedAt: new Date() })
.where(eq(pluginJobs.id, jobId));
});
},
/**
* Update the `lastRunAt` and `nextRunAt` timestamps on a job.
*
* Called by the scheduler after a run completes to advance the
* scheduling pointer.
*
* @param jobId - UUID of the job row
* @param lastRunAt - When the last run started
* @param nextRunAt - When the next run should fire
*/
async updateRunTimestamps(
jobId: string,
lastRunAt: Date,
nextRunAt: Date | null,
): Promise<void> {
return writePluginJobChange(db, async db => {
await db
.update(pluginJobs)
.set({
lastRunAt,
nextRunAt,
updatedAt: new Date(),
})
.where(eq(pluginJobs.id, jobId));
});
},
/**
* Delete all jobs (and cascaded runs) owned by a plugin.
*
* Called during plugin uninstall when `removeData = true`.
*
* @param pluginId - UUID of the owning plugin
*/
async deleteAllJobs(pluginId: string): Promise<void> {
return writePluginJobChange(db, async db => {
await db
.delete(pluginJobs)
.where(eq(pluginJobs.pluginId, pluginId));
});
},
// =====================================================================
// Job runs (plugin_job_runs)
// =====================================================================
/**
* Create a new job run record with status `queued`.
*
* The caller should create the run record *before* dispatching the
* `runJob` RPC to the worker, then update it to `running` once the
* worker begins execution.
*
* @param input - Job run input (jobId, pluginId, trigger)
* @returns The newly created run row
*/
async createRun(
input: CreateJobRunInput,
): Promise<typeof pluginJobRuns.$inferSelect> {
return writePluginJobChange(db, async db => {
const rows = await db
.insert(pluginJobRuns)
.values({
jobId: input.jobId,
pluginId: input.pluginId,
trigger: input.trigger,
status: "queued",
})
.returning();
return rows[0]!;
});
},
/**
* Mark a run as `running` and set its `startedAt` timestamp.
*
* @param runId - UUID of the run row
*/
async markRunning(runId: string): Promise<void> {
return writePluginJobChange(db, async db => {
await db
.update(pluginJobRuns)
.set({
status: "running" as PluginJobRunStatus,
startedAt: new Date(),
})
.where(eq(pluginJobRuns.id, runId));
});
},
/**
* Complete a run — set its final status, error, duration, and
* `finishedAt` timestamp.
*
* @param runId - UUID of the run row
* @param input - Completion details
*/
async completeRun(
runId: string,
input: CompleteJobRunInput,
): Promise<void> {
return writePluginJobChange(db, async db => {
await db
.update(pluginJobRuns)
.set({
status: input.status,
error: input.error ?? null,
durationMs: input.durationMs ?? null,
finishedAt: new Date(),
})
.where(eq(pluginJobRuns.id, runId));
});
},
/**
* Get a run by its primary key.
*
* @param runId - UUID of the run row
* @returns The run row, or `null` if not found
*/
async getRunById(
runId: string,
): Promise<(typeof pluginJobRuns.$inferSelect) | null> {
const rows = await db
.select()
.from(pluginJobRuns)
.where(eq(pluginJobRuns.id, runId));
return rows[0] ?? null;
},
/**
* List runs for a specific job, ordered by creation time descending.
*
* @param jobId - UUID of the job
* @param limit - Maximum number of rows to return (default: 50)
* @param companyIds - Optional tenant scope. `null`/omitted keeps the
* instance-wide view (used by instance administrators); an array limits
* rows to those companies; an empty array returns no rows.
*/
async listRunsByJob(
jobId: string,
limit = 50,
companyIds: string[] | null = null,
): Promise<(typeof pluginJobRuns.$inferSelect)[]> {
if (companyIds && companyIds.length === 0) {
return [];
}
const conditions = [eq(pluginJobRuns.jobId, jobId)];
if (companyIds) {
conditions.push(inArray(pluginJobRuns.companyId, companyIds));
}
return db
.select()
.from(pluginJobRuns)
.where(and(...conditions))
.orderBy(desc(pluginJobRuns.createdAt))
.limit(limit);
},
/**
* List runs for a plugin, optionally filtered by status.
*
* @param pluginId - UUID of the owning plugin
* @param status - Optional status filter
* @param limit - Maximum number of rows to return (default: 50)
* @param companyIds - Optional tenant scope. `null`/omitted keeps the
* instance-wide view (used by instance administrators); an array limits
* rows to those companies; an empty array returns no rows.
*/
async listRunsByPlugin(
pluginId: string,
status?: PluginJobRunStatus,
limit = 50,
companyIds: string[] | null = null,
): Promise<(typeof pluginJobRuns.$inferSelect)[]> {
if (companyIds && companyIds.length === 0) {
return [];
}
const conditions = [eq(pluginJobRuns.pluginId, pluginId)];
if (status) {
conditions.push(eq(pluginJobRuns.status, status));
}
if (companyIds) {
conditions.push(inArray(pluginJobRuns.companyId, companyIds));
}
return db
.select()
.from(pluginJobRuns)
.where(and(...conditions))
.orderBy(desc(pluginJobRuns.createdAt))
.limit(limit);
},
};
}
/** Type alias for the return value of `pluginJobStore()`. */
export type PluginJobStore = ReturnType<typeof pluginJobStore>;