mirror of
https://github.com/paperclipai/paperclip.git
synced 2026-10-30 05:16:50 +01:00
## 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>
503 lines
16 KiB
TypeScript
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>;
|