Create background job runner and memory refresh jobs

Co-Authored-By: Paperclip <noreply@paperclip.ing>
This commit is contained in:
DottaandPaperclip committed 2026-04-19 20:42:01 -05:00
1 parent 22b4d8a176
commit 29f7355291
30 files changed
+18677 -107

No files matched your search

@@ -0,0 +1,104 @@
CREATE TABLE "background_job_cost_events" (
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
"company_id" uuid NOT NULL,
"run_id" uuid NOT NULL,
"cost_event_id" uuid NOT NULL,
"created_at" timestamp with time zone DEFAULT now() NOT NULL
);
--> statement-breakpoint
CREATE TABLE "background_job_events" (
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
"company_id" uuid NOT NULL,
"run_id" uuid NOT NULL,
"event_type" text NOT NULL,
"level" text DEFAULT 'info' NOT NULL,
"message" text,
"progress_percent" integer,
"total_items" integer,
"processed_items" integer,
"succeeded_items" integer,
"failed_items" integer,
"skipped_items" integer,
"current_item" text,
"details" jsonb,
"created_at" timestamp with time zone DEFAULT now() NOT NULL
);
--> statement-breakpoint
CREATE TABLE "background_job_runs" (
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
"company_id" uuid NOT NULL,
"job_id" uuid,
"job_key" text NOT NULL,
"job_type" text NOT NULL,
"trigger" text DEFAULT 'manual' NOT NULL,
"status" text DEFAULT 'queued' NOT NULL,
"requested_by_actor_type" text DEFAULT 'system' NOT NULL,
"requested_by_actor_id" text DEFAULT 'system' NOT NULL,
"requested_by_agent_id" uuid,
"requested_by_user_id" text,
"source_issue_id" uuid,
"source_project_id" uuid,
"source_agent_id" uuid,
"heartbeat_run_id" uuid,
"total_items" integer,
"processed_items" integer DEFAULT 0 NOT NULL,
"succeeded_items" integer DEFAULT 0 NOT NULL,
"failed_items" integer DEFAULT 0 NOT NULL,
"skipped_items" integer DEFAULT 0 NOT NULL,
"progress_percent" integer,
"current_item" text,
"cancellation_requested_at" timestamp with time zone,
"cancelled_at" timestamp with time zone,
"error" text,
"result" jsonb,
"started_at" timestamp with time zone,
"finished_at" timestamp with time zone,
"duration_ms" integer,
"created_at" timestamp with time zone DEFAULT now() NOT NULL,
"updated_at" timestamp with time zone DEFAULT now() NOT NULL
);
--> statement-breakpoint
CREATE TABLE "background_jobs" (
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
"company_id" uuid NOT NULL,
"key" text NOT NULL,
"job_type" text NOT NULL,
"display_name" text NOT NULL,
"description" text,
"backend_kind" text DEFAULT 'server_worker' NOT NULL,
"status" text DEFAULT 'active' NOT NULL,
"config" jsonb DEFAULT '{}'::jsonb NOT NULL,
"created_by_agent_id" uuid,
"created_by_user_id" text,
"source_issue_id" uuid,
"source_project_id" uuid,
"created_at" timestamp with time zone DEFAULT now() NOT NULL,
"updated_at" timestamp with time zone DEFAULT now() NOT NULL
);
--> statement-breakpoint
ALTER TABLE "background_job_cost_events" ADD CONSTRAINT "background_job_cost_events_company_id_companies_id_fk" FOREIGN KEY ("company_id") REFERENCES "public"."companies"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "background_job_cost_events" ADD CONSTRAINT "background_job_cost_events_run_id_background_job_runs_id_fk" FOREIGN KEY ("run_id") REFERENCES "public"."background_job_runs"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "background_job_cost_events" ADD CONSTRAINT "background_job_cost_events_cost_event_id_cost_events_id_fk" FOREIGN KEY ("cost_event_id") REFERENCES "public"."cost_events"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "background_job_events" ADD CONSTRAINT "background_job_events_company_id_companies_id_fk" FOREIGN KEY ("company_id") REFERENCES "public"."companies"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "background_job_events" ADD CONSTRAINT "background_job_events_run_id_background_job_runs_id_fk" FOREIGN KEY ("run_id") REFERENCES "public"."background_job_runs"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "background_job_runs" ADD CONSTRAINT "background_job_runs_company_id_companies_id_fk" FOREIGN KEY ("company_id") REFERENCES "public"."companies"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "background_job_runs" ADD CONSTRAINT "background_job_runs_job_id_background_jobs_id_fk" FOREIGN KEY ("job_id") REFERENCES "public"."background_jobs"("id") ON DELETE set null ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "background_job_runs" ADD CONSTRAINT "background_job_runs_requested_by_agent_id_agents_id_fk" FOREIGN KEY ("requested_by_agent_id") REFERENCES "public"."agents"("id") ON DELETE set null ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "background_job_runs" ADD CONSTRAINT "background_job_runs_source_issue_id_issues_id_fk" FOREIGN KEY ("source_issue_id") REFERENCES "public"."issues"("id") ON DELETE set null ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "background_job_runs" ADD CONSTRAINT "background_job_runs_source_project_id_projects_id_fk" FOREIGN KEY ("source_project_id") REFERENCES "public"."projects"("id") ON DELETE set null ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "background_job_runs" ADD CONSTRAINT "background_job_runs_source_agent_id_agents_id_fk" FOREIGN KEY ("source_agent_id") REFERENCES "public"."agents"("id") ON DELETE set null ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "background_job_runs" ADD CONSTRAINT "background_job_runs_heartbeat_run_id_heartbeat_runs_id_fk" FOREIGN KEY ("heartbeat_run_id") REFERENCES "public"."heartbeat_runs"("id") ON DELETE set null ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "background_jobs" ADD CONSTRAINT "background_jobs_company_id_companies_id_fk" FOREIGN KEY ("company_id") REFERENCES "public"."companies"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "background_jobs" ADD CONSTRAINT "background_jobs_created_by_agent_id_agents_id_fk" FOREIGN KEY ("created_by_agent_id") REFERENCES "public"."agents"("id") ON DELETE set null ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "background_jobs" ADD CONSTRAINT "background_jobs_source_issue_id_issues_id_fk" FOREIGN KEY ("source_issue_id") REFERENCES "public"."issues"("id") ON DELETE set null ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "background_jobs" ADD CONSTRAINT "background_jobs_source_project_id_projects_id_fk" FOREIGN KEY ("source_project_id") REFERENCES "public"."projects"("id") ON DELETE set null ON UPDATE no action;--> statement-breakpoint
CREATE INDEX "background_job_cost_events_run_idx" ON "background_job_cost_events" USING btree ("run_id");--> statement-breakpoint
CREATE UNIQUE INDEX "background_job_cost_events_cost_event_uq" ON "background_job_cost_events" USING btree ("cost_event_id");--> statement-breakpoint
CREATE INDEX "background_job_events_run_created_idx" ON "background_job_events" USING btree ("run_id","created_at");--> statement-breakpoint
CREATE INDEX "background_job_events_company_created_idx" ON "background_job_events" USING btree ("company_id","created_at");--> statement-breakpoint
CREATE INDEX "background_job_runs_company_created_idx" ON "background_job_runs" USING btree ("company_id","created_at");--> statement-breakpoint
CREATE INDEX "background_job_runs_company_type_status_idx" ON "background_job_runs" USING btree ("company_id","job_type","status");--> statement-breakpoint
CREATE INDEX "background_job_runs_company_issue_created_idx" ON "background_job_runs" USING btree ("company_id","source_issue_id","created_at");--> statement-breakpoint
CREATE INDEX "background_job_runs_job_created_idx" ON "background_job_runs" USING btree ("job_id","created_at");--> statement-breakpoint
CREATE INDEX "background_jobs_company_type_status_idx" ON "background_jobs" USING btree ("company_id","job_type","status");--> statement-breakpoint
CREATE UNIQUE INDEX "background_jobs_company_key_uq" ON "background_jobs" USING btree ("company_id","key");
File diff suppressed because it is too large. Load diff
@@ -414,6 +414,13 @@
"when": 1776647797670,
"tag": "0058_lovely_eternals",
"breakpoints": true
},
{
"idx": 59,
"version": "7",
"when": 1776648937778,
"tag": "0059_lazy_nick_fury",
"breakpoints": true
}
]
}
+135
View File
@@ -0,0 +1,135 @@
import { pgTable, uuid, text, timestamp, integer, jsonb, index, uniqueIndex } from "drizzle-orm/pg-core";
import { companies } from "./companies.js";
import { agents } from "./agents.js";
import { issues } from "./issues.js";
import { projects } from "./projects.js";
import { heartbeatRuns } from "./heartbeat_runs.js";
import { costEvents } from "./cost_events.js";
import type {
BackgroundJobBackendKind,
BackgroundJobEventLevel,
BackgroundJobEventType,
BackgroundJobRunStatus,
BackgroundJobRunTrigger,
BackgroundJobStatus,
} from "@paperclipai/shared";
export const backgroundJobs = pgTable(
"background_jobs",
{
id: uuid("id").primaryKey().defaultRandom(),
companyId: uuid("company_id").notNull().references(() => companies.id, { onDelete: "cascade" }),
key: text("key").notNull(),
jobType: text("job_type").notNull(),
displayName: text("display_name").notNull(),
description: text("description"),
backendKind: text("backend_kind").$type<BackgroundJobBackendKind>().notNull().default("server_worker"),
status: text("status").$type<BackgroundJobStatus>().notNull().default("active"),
config: jsonb("config").$type<Record<string, unknown>>().notNull().default({}),
createdByAgentId: uuid("created_by_agent_id").references(() => agents.id, { onDelete: "set null" }),
createdByUserId: text("created_by_user_id"),
sourceIssueId: uuid("source_issue_id").references(() => issues.id, { onDelete: "set null" }),
sourceProjectId: uuid("source_project_id").references(() => projects.id, { onDelete: "set null" }),
createdAt: timestamp("created_at", { withTimezone: true }).notNull().defaultNow(),
updatedAt: timestamp("updated_at", { withTimezone: true }).notNull().defaultNow(),
},
(table) => ({
companyTypeStatusIdx: index("background_jobs_company_type_status_idx").on(
table.companyId,
table.jobType,
table.status,
),
companyKeyUq: uniqueIndex("background_jobs_company_key_uq").on(table.companyId, table.key),
}),
);
export const backgroundJobRuns = pgTable(
"background_job_runs",
{
id: uuid("id").primaryKey().defaultRandom(),
companyId: uuid("company_id").notNull().references(() => companies.id, { onDelete: "cascade" }),
jobId: uuid("job_id").references(() => backgroundJobs.id, { onDelete: "set null" }),
jobKey: text("job_key").notNull(),
jobType: text("job_type").notNull(),
trigger: text("trigger").$type<BackgroundJobRunTrigger>().notNull().default("manual"),
status: text("status").$type<BackgroundJobRunStatus>().notNull().default("queued"),
requestedByActorType: text("requested_by_actor_type").notNull().default("system"),
requestedByActorId: text("requested_by_actor_id").notNull().default("system"),
requestedByAgentId: uuid("requested_by_agent_id").references(() => agents.id, { onDelete: "set null" }),
requestedByUserId: text("requested_by_user_id"),
sourceIssueId: uuid("source_issue_id").references(() => issues.id, { onDelete: "set null" }),
sourceProjectId: uuid("source_project_id").references(() => projects.id, { onDelete: "set null" }),
sourceAgentId: uuid("source_agent_id").references(() => agents.id, { onDelete: "set null" }),
heartbeatRunId: uuid("heartbeat_run_id").references(() => heartbeatRuns.id, { onDelete: "set null" }),
totalItems: integer("total_items"),
processedItems: integer("processed_items").notNull().default(0),
succeededItems: integer("succeeded_items").notNull().default(0),
failedItems: integer("failed_items").notNull().default(0),
skippedItems: integer("skipped_items").notNull().default(0),
progressPercent: integer("progress_percent"),
currentItem: text("current_item"),
cancellationRequestedAt: timestamp("cancellation_requested_at", { withTimezone: true }),
cancelledAt: timestamp("cancelled_at", { withTimezone: true }),
error: text("error"),
result: jsonb("result").$type<Record<string, unknown> | null>(),
startedAt: timestamp("started_at", { withTimezone: true }),
finishedAt: timestamp("finished_at", { withTimezone: true }),
durationMs: integer("duration_ms"),
createdAt: timestamp("created_at", { withTimezone: true }).notNull().defaultNow(),
updatedAt: timestamp("updated_at", { withTimezone: true }).notNull().defaultNow(),
},
(table) => ({
companyCreatedIdx: index("background_job_runs_company_created_idx").on(table.companyId, table.createdAt),
companyTypeStatusIdx: index("background_job_runs_company_type_status_idx").on(
table.companyId,
table.jobType,
table.status,
),
companyIssueCreatedIdx: index("background_job_runs_company_issue_created_idx").on(
table.companyId,
table.sourceIssueId,
table.createdAt,
),
jobCreatedIdx: index("background_job_runs_job_created_idx").on(table.jobId, table.createdAt),
}),
);
export const backgroundJobEvents = pgTable(
"background_job_events",
{
id: uuid("id").primaryKey().defaultRandom(),
companyId: uuid("company_id").notNull().references(() => companies.id, { onDelete: "cascade" }),
runId: uuid("run_id").notNull().references(() => backgroundJobRuns.id, { onDelete: "cascade" }),
eventType: text("event_type").$type<BackgroundJobEventType>().notNull(),
level: text("level").$type<BackgroundJobEventLevel>().notNull().default("info"),
message: text("message"),
progressPercent: integer("progress_percent"),
totalItems: integer("total_items"),
processedItems: integer("processed_items"),
succeededItems: integer("succeeded_items"),
failedItems: integer("failed_items"),
skippedItems: integer("skipped_items"),
currentItem: text("current_item"),
details: jsonb("details").$type<Record<string, unknown> | null>(),
createdAt: timestamp("created_at", { withTimezone: true }).notNull().defaultNow(),
},
(table) => ({
runCreatedIdx: index("background_job_events_run_created_idx").on(table.runId, table.createdAt),
companyCreatedIdx: index("background_job_events_company_created_idx").on(table.companyId, table.createdAt),
}),
);
export const backgroundJobCostEvents = pgTable(
"background_job_cost_events",
{
id: uuid("id").primaryKey().defaultRandom(),
companyId: uuid("company_id").notNull().references(() => companies.id, { onDelete: "cascade" }),
runId: uuid("run_id").notNull().references(() => backgroundJobRuns.id, { onDelete: "cascade" }),
costEventId: uuid("cost_event_id").notNull().references(() => costEvents.id, { onDelete: "cascade" }),
createdAt: timestamp("created_at", { withTimezone: true }).notNull().defaultNow(),
},
(table) => ({
runIdx: index("background_job_cost_events_run_idx").on(table.runId),
costEventUq: uniqueIndex("background_job_cost_events_cost_event_uq").on(table.costEventId),
}),
);
+6
View File
@@ -58,6 +58,12 @@ export { memoryBindingTargets } from "./memory_binding_targets.js";
export { memoryOperations } from "./memory_operations.js";
export { memoryExtractionJobs } from "./memory_extraction_jobs.js";
export { memoryLocalRecords } from "./memory_local_records.js";
export {
backgroundJobs,
backgroundJobRuns,
backgroundJobEvents,
backgroundJobCostEvents,
} from "./background_jobs.js";
export { plugins } from "./plugins.js";
export { pluginConfig } from "./plugin_config.js";
export { pluginCompanySettings } from "./plugin_company_settings.js";
@@ -135,7 +135,7 @@ function buildRecord(
retentionPolicy: input.retentionPolicy ?? null,
expiresAt: normalizeDate(input.expiresAt),
retentionState: "active",
reviewState: "pending",
reviewState: input.reviewState ?? "pending",
reviewedAt: null,
reviewedBy: null,
reviewNote: null,
@@ -171,6 +171,16 @@ export function buildQmdMemoryConfigMetadata(dataDir = resolveQmdMemoryDataDir()
topK: 5,
autoIndexOnWrite: true,
qmdBinaryPath: null,
hookPolicies: {
issue_comment_capture: {
enabled: false,
extractionMode: "paperclip_managed",
runMode: "sync",
harness: "server_worker",
sensitivityLabel: "internal",
reviewState: "accepted",
},
},
},
pathSuggestions: [
{
@@ -56,6 +56,11 @@ const manifest: PaperclipPluginManifestV1 = {
description: "Optional absolute path to qmd. Leave empty to use qmd from PATH.",
default: null,
},
hookPolicies: {
type: "object",
title: "Hook policies",
description: "Optional per-hook Paperclip extraction policy overrides.",
},
},
},
configMetadata: buildQmdMemoryConfigMetadata(),
+2
View File
@@ -253,6 +253,8 @@ export type {
MemoryProviderQueryInput,
MemoryProviderQueryOutput,
MemoryOperation,
MemoryHookPolicy,
MemoryHookPolicyMap,
MemoryProviderCapabilities,
MemoryProviderConfigFieldInput,
MemoryProviderConfigFieldOption,
+4
View File
@@ -25,6 +25,8 @@ import type {
PluginLauncherDeclaration,
Company,
MemoryOperation,
MemoryHookPolicy,
MemoryHookPolicyMap,
MemoryProviderCapabilities,
MemoryProviderConfigFieldInput,
MemoryProviderConfigFieldOption,
@@ -92,6 +94,8 @@ export type {
MemoryProviderQueryOutput,
MemoryForgetResult,
MemoryOperation,
MemoryHookPolicy,
MemoryHookPolicyMap,
MemoryProviderCapabilities,
MemoryProviderConfigFieldInput,
MemoryProviderConfigFieldOption,
+68
View File
@@ -421,6 +421,23 @@ export const MEMORY_HOOK_KINDS = [
] as const;
export type MemoryHookKind = (typeof MEMORY_HOOK_KINDS)[number];
export const MEMORY_HOOK_EXTRACTION_MODES = [
"raw_capture",
"provider_managed",
"paperclip_managed",
] as const;
export type MemoryHookExtractionMode = (typeof MEMORY_HOOK_EXTRACTION_MODES)[number];
export const MEMORY_HOOK_RUN_MODES = ["sync", "async"] as const;
export type MemoryHookRunMode = (typeof MEMORY_HOOK_RUN_MODES)[number];
export const MEMORY_EXTRACTION_HARNESSES = [
"server_worker",
"plugin_worker",
"agent",
] as const;
export type MemoryExtractionHarness = (typeof MEMORY_EXTRACTION_HARNESSES)[number];
export const MEMORY_SOURCE_KINDS = [
"issue_comment",
"issue_document",
@@ -456,6 +473,57 @@ export type MemoryRetentionState = (typeof MEMORY_RETENTION_STATES)[number];
export const MEMORY_REVIEW_STATES = ["pending", "accepted", "rejected"] as const;
export type MemoryReviewState = (typeof MEMORY_REVIEW_STATES)[number];
export const BACKGROUND_JOB_BACKEND_KINDS = [
"server_worker",
"plugin_worker",
"agent_run",
] as const;
export type BackgroundJobBackendKind = (typeof BACKGROUND_JOB_BACKEND_KINDS)[number];
export const BACKGROUND_JOB_STATUSES = [
"active",
"paused",
"archived",
] as const;
export type BackgroundJobStatus = (typeof BACKGROUND_JOB_STATUSES)[number];
export const BACKGROUND_JOB_RUN_STATUSES = [
"queued",
"running",
"succeeded",
"failed",
"cancelled",
] as const;
export type BackgroundJobRunStatus = (typeof BACKGROUND_JOB_RUN_STATUSES)[number];
export const BACKGROUND_JOB_RUN_TRIGGERS = [
"manual",
"schedule",
"hook",
"retry",
] as const;
export type BackgroundJobRunTrigger = (typeof BACKGROUND_JOB_RUN_TRIGGERS)[number];
export const BACKGROUND_JOB_EVENT_TYPES = [
"queued",
"started",
"progress",
"log",
"checkpoint",
"completed",
"failed",
"cancelled",
] as const;
export type BackgroundJobEventType = (typeof BACKGROUND_JOB_EVENT_TYPES)[number];
export const BACKGROUND_JOB_EVENT_LEVELS = [
"debug",
"info",
"warn",
"error",
] as const;
export type BackgroundJobEventLevel = (typeof BACKGROUND_JOB_EVENT_LEVELS)[number];
// ---------------------------------------------------------------------------
// Plugin System — see doc/plugins/PLUGIN_SPEC.md for the full specification
// ---------------------------------------------------------------------------
+44
View File
@@ -66,6 +66,9 @@ export {
MEMORY_OPERATION_STATUSES,
MEMORY_TRIGGER_KINDS,
MEMORY_HOOK_KINDS,
MEMORY_HOOK_EXTRACTION_MODES,
MEMORY_HOOK_RUN_MODES,
MEMORY_EXTRACTION_HARNESSES,
MEMORY_SOURCE_KINDS,
MEMORY_EXTRACTION_JOB_STATUSES,
MEMORY_SCOPE_TYPES,
@@ -73,6 +76,12 @@ export {
MEMORY_SENSITIVITY_LABELS,
MEMORY_RETENTION_STATES,
MEMORY_REVIEW_STATES,
BACKGROUND_JOB_BACKEND_KINDS,
BACKGROUND_JOB_STATUSES,
BACKGROUND_JOB_RUN_STATUSES,
BACKGROUND_JOB_RUN_TRIGGERS,
BACKGROUND_JOB_EVENT_TYPES,
BACKGROUND_JOB_EVENT_LEVELS,
PLUGIN_API_VERSION,
PLUGIN_STATUSES,
PLUGIN_CATEGORIES,
@@ -152,6 +161,9 @@ export {
type MemoryOperationStatus,
type MemoryTriggerKind,
type MemoryHookKind,
type MemoryHookExtractionMode,
type MemoryHookRunMode,
type MemoryExtractionHarness,
type MemorySourceKind,
type MemoryExtractionJobStatus,
type MemoryScopeType,
@@ -159,6 +171,12 @@ export {
type MemorySensitivityLabel,
type MemoryRetentionState,
type MemoryReviewState,
type BackgroundJobBackendKind,
type BackgroundJobStatus,
type BackgroundJobRunStatus,
type BackgroundJobRunTrigger,
type BackgroundJobEventType,
type BackgroundJobEventLevel,
type PluginStatus,
type PluginCategory,
type PluginCapability,
@@ -306,6 +324,8 @@ export type {
MemoryProviderConfigPathSuggestion,
MemoryProviderConfigMetadata,
MemoryProviderDescriptor,
MemoryHookPolicy,
MemoryHookPolicyMap,
MemoryUsage,
MemoryGovernedScope,
MemoryPrincipalRef,
@@ -319,6 +339,8 @@ export type {
MemoryRecord,
MemoryOperation,
MemoryExtractionJob,
MemoryRefreshJobSourceCounts,
MemoryRefreshJobResult,
MemoryQueryResult,
MemoryCaptureResult,
MemoryForgetResult,
@@ -333,6 +355,10 @@ export type {
MemoryProviderCaptureOutput,
MemoryProviderForgetInput,
MemoryProviderForgetOutput,
BackgroundJob,
BackgroundJobRun,
BackgroundJobEvent,
BackgroundJobCostEvent,
BudgetPolicy,
BudgetPolicySummary,
BudgetIncident,
@@ -615,6 +641,8 @@ export {
memoryProviderHealthCheckSchema,
memoryProviderConfigPathSuggestionSchema,
memoryProviderConfigMetadataSchema,
memoryHookPolicySchema,
memoryHookPoliciesSchema,
createMemoryBindingSchema,
updateMemoryBindingSchema,
setCompanyMemoryBindingSchema,
@@ -627,9 +655,17 @@ export {
memoryCorrectSchema,
memoryReviewSchema,
memoryRetentionSweepSchema,
memoryRefreshJobSchema,
memoryListRecordsQuerySchema,
memoryListOperationsQuerySchema,
memoryListExtractionJobsQuerySchema,
createBackgroundJobSchema,
listBackgroundJobsQuerySchema,
createBackgroundJobRunSchema,
updateBackgroundJobRunProgressSchema,
createBackgroundJobEventSchema,
completeBackgroundJobRunSchema,
listBackgroundJobRunsQuerySchema,
memoryBindingTargetTypeSchema,
createAssetImageMetadataSchema,
createCompanyInviteSchema,
@@ -662,9 +698,17 @@ export {
type MemoryCorrect,
type MemoryReview,
type MemoryRetentionSweep,
type MemoryRefreshJob,
type MemoryListRecordsQuery,
type MemoryListOperationsQuery,
type MemoryListExtractionJobsQuery,
type CreateBackgroundJob,
type ListBackgroundJobsQuery,
type CreateBackgroundJobRun,
type UpdateBackgroundJobRunProgress,
type CreateBackgroundJobEvent,
type CompleteBackgroundJobRun,
type ListBackgroundJobRunsQuery,
type CreateAssetImageMetadata,
type CreateCompanyInvite,
type CreateOpenClawInvitePrompt,
@@ -0,0 +1,86 @@
import type {
BackgroundJobBackendKind,
BackgroundJobEventLevel,
BackgroundJobEventType,
BackgroundJobRunStatus,
BackgroundJobRunTrigger,
BackgroundJobStatus,
} from "../constants.js";
export interface BackgroundJob {
id: string;
companyId: string;
key: string;
jobType: string;
displayName: string;
description: string | null;
backendKind: BackgroundJobBackendKind;
status: BackgroundJobStatus;
config: Record<string, unknown>;
createdByAgentId: string | null;
createdByUserId: string | null;
sourceIssueId: string | null;
sourceProjectId: string | null;
createdAt: Date;
updatedAt: Date;
}
export interface BackgroundJobRun {
id: string;
companyId: string;
jobId: string | null;
jobKey: string;
jobType: string;
trigger: BackgroundJobRunTrigger;
status: BackgroundJobRunStatus;
requestedByActorType: "agent" | "user" | "system";
requestedByActorId: string;
requestedByAgentId: string | null;
requestedByUserId: string | null;
sourceIssueId: string | null;
sourceProjectId: string | null;
sourceAgentId: string | null;
heartbeatRunId: string | null;
totalItems: number | null;
processedItems: number;
succeededItems: number;
failedItems: number;
skippedItems: number;
progressPercent: number | null;
currentItem: string | null;
cancellationRequestedAt: Date | null;
cancelledAt: Date | null;
error: string | null;
result: Record<string, unknown> | null;
startedAt: Date | null;
finishedAt: Date | null;
durationMs: number | null;
createdAt: Date;
updatedAt: Date;
}
export interface BackgroundJobEvent {
id: string;
companyId: string;
runId: string;
eventType: BackgroundJobEventType;
level: BackgroundJobEventLevel;
message: string | null;
progressPercent: number | null;
totalItems: number | null;
processedItems: number | null;
succeededItems: number | null;
failedItems: number | null;
skippedItems: number | null;
currentItem: string | null;
details: Record<string, unknown> | null;
createdAt: Date;
}
export interface BackgroundJobCostEvent {
id: string;
companyId: string;
runId: string;
costEventId: string;
createdAt: Date;
}
+10
View File
@@ -129,6 +129,8 @@ export type {
MemoryProviderConfigPathSuggestion,
MemoryProviderConfigMetadata,
MemoryProviderDescriptor,
MemoryHookPolicy,
MemoryHookPolicyMap,
MemoryUsage,
MemoryGovernedScope,
MemoryPrincipalRef,
@@ -142,6 +144,8 @@ export type {
MemoryRecord,
MemoryOperation,
MemoryExtractionJob,
MemoryRefreshJobSourceCounts,
MemoryRefreshJobResult,
MemoryQueryResult,
MemoryCaptureResult,
MemoryForgetResult,
@@ -158,6 +162,12 @@ export type {
MemoryProviderForgetInput,
MemoryProviderForgetOutput,
} from "./memory.js";
export type {
BackgroundJob,
BackgroundJobRun,
BackgroundJobEvent,
BackgroundJobCostEvent,
} from "./background-job.js";
export type {
BudgetPolicy,
BudgetPolicySummary,
+35
View File
@@ -1,7 +1,10 @@
import type {
MemoryBindingTargetType,
MemoryExtractionHarness,
MemoryExtractionJobStatus,
MemoryHookExtractionMode,
MemoryHookKind,
MemoryHookRunMode,
MemoryOperationStatus,
MemoryOperationType,
MemoryPrincipalType,
@@ -13,6 +16,7 @@ import type {
MemorySourceKind,
MemoryTriggerKind,
} from "../constants.js";
import type { BackgroundJob, BackgroundJobRun } from "./background-job.js";
export interface MemoryProviderCapabilities {
browse: boolean;
@@ -85,6 +89,21 @@ export interface MemoryProviderDescriptor {
configMetadata: MemoryProviderConfigMetadata | null;
}
export interface MemoryHookPolicy {
enabled: boolean;
extractionMode: MemoryHookExtractionMode;
runMode: MemoryHookRunMode;
harness: MemoryExtractionHarness;
sensitivityLabel: MemorySensitivityLabel;
reviewState: MemoryReviewState;
retentionPolicy?: Record<string, unknown> | null;
modelProvider?: string | null;
model?: string | null;
config?: Record<string, unknown> | null;
}
export type MemoryHookPolicyMap = Partial<Record<MemoryHookKind, Partial<MemoryHookPolicy>>>;
export interface MemoryUsage {
provider: string;
model: string | null;
@@ -262,6 +281,21 @@ export interface MemoryExtractionJob {
updatedAt: Date;
}
export interface MemoryRefreshJobSourceCounts {
issue: number;
issue_comment: number;
issue_document: number;
run: number;
}
export interface MemoryRefreshJobResult {
job: BackgroundJob;
run: BackgroundJobRun;
dryRun: boolean;
sourceCounts: MemoryRefreshJobSourceCounts;
recordCount: number;
}
export interface MemoryQueryResult {
operation: MemoryOperation;
records: MemoryRecord[];
@@ -310,6 +344,7 @@ export interface MemoryProviderCaptureInput {
content: string;
summary?: string | null;
metadata?: Record<string, unknown>;
reviewState?: MemoryReviewState;
}
export interface MemoryProviderCaptureOutput {
@@ -0,0 +1,104 @@
import { z } from "zod";
import {
BACKGROUND_JOB_BACKEND_KINDS,
BACKGROUND_JOB_EVENT_LEVELS,
BACKGROUND_JOB_EVENT_TYPES,
BACKGROUND_JOB_RUN_STATUSES,
BACKGROUND_JOB_RUN_TRIGGERS,
BACKGROUND_JOB_STATUSES,
} from "../constants.js";
export const createBackgroundJobSchema = z
.object({
key: z.string().trim().min(1).max(128).regex(/^[a-z0-9][a-z0-9_.:-]*$/),
jobType: z.string().trim().min(1).max(128),
displayName: z.string().trim().min(1).max(200),
description: z.string().trim().max(2000).nullable().optional(),
backendKind: z.enum(BACKGROUND_JOB_BACKEND_KINDS).optional().default("server_worker"),
status: z.enum(BACKGROUND_JOB_STATUSES).optional().default("active"),
config: z.record(z.unknown()).optional().default({}),
sourceIssueId: z.string().uuid().nullable().optional(),
sourceProjectId: z.string().uuid().nullable().optional(),
})
.strict();
export const listBackgroundJobsQuerySchema = z
.object({
jobType: z.string().trim().min(1).max(128).optional(),
status: z.enum(BACKGROUND_JOB_STATUSES).optional(),
limit: z.coerce.number().int().positive().max(200).optional().default(50),
})
.strict();
export const createBackgroundJobRunSchema = z
.object({
jobId: z.string().uuid().nullable().optional(),
jobKey: z.string().trim().min(1).max(128).optional(),
jobType: z.string().trim().min(1).max(128).optional(),
trigger: z.enum(BACKGROUND_JOB_RUN_TRIGGERS).optional().default("manual"),
sourceIssueId: z.string().uuid().nullable().optional(),
sourceProjectId: z.string().uuid().nullable().optional(),
sourceAgentId: z.string().uuid().nullable().optional(),
heartbeatRunId: z.string().uuid().nullable().optional(),
totalItems: z.number().int().nonnegative().nullable().optional(),
config: z.record(z.unknown()).optional(),
})
.strict()
.refine((value) => value.jobId || (value.jobKey && value.jobType), {
message: "Provide jobId or both jobKey and jobType",
});
export const updateBackgroundJobRunProgressSchema = z
.object({
totalItems: z.number().int().nonnegative().nullable().optional(),
processedItems: z.number().int().nonnegative().optional(),
succeededItems: z.number().int().nonnegative().optional(),
failedItems: z.number().int().nonnegative().optional(),
skippedItems: z.number().int().nonnegative().optional(),
progressPercent: z.number().min(0).max(100).nullable().optional(),
currentItem: z.string().trim().max(500).nullable().optional(),
})
.strict();
export const createBackgroundJobEventSchema = z
.object({
eventType: z.enum(BACKGROUND_JOB_EVENT_TYPES),
level: z.enum(BACKGROUND_JOB_EVENT_LEVELS).optional().default("info"),
message: z.string().trim().max(4000).nullable().optional(),
progressPercent: z.number().min(0).max(100).nullable().optional(),
totalItems: z.number().int().nonnegative().nullable().optional(),
processedItems: z.number().int().nonnegative().nullable().optional(),
succeededItems: z.number().int().nonnegative().nullable().optional(),
failedItems: z.number().int().nonnegative().nullable().optional(),
skippedItems: z.number().int().nonnegative().nullable().optional(),
currentItem: z.string().trim().max(500).nullable().optional(),
details: z.record(z.unknown()).nullable().optional(),
})
.strict();
export const completeBackgroundJobRunSchema = z
.object({
status: z.enum(["succeeded", "failed", "cancelled"]),
error: z.string().trim().max(4000).nullable().optional(),
result: z.record(z.unknown()).nullable().optional(),
})
.strict();
export const listBackgroundJobRunsQuerySchema = z
.object({
jobId: z.string().uuid().optional(),
jobType: z.string().trim().min(1).max(128).optional(),
status: z.enum(BACKGROUND_JOB_RUN_STATUSES).optional(),
sourceIssueId: z.string().uuid().optional(),
sourceProjectId: z.string().uuid().optional(),
limit: z.coerce.number().int().positive().max(200).optional().default(50),
})
.strict();
export type CreateBackgroundJob = z.infer<typeof createBackgroundJobSchema>;
export type ListBackgroundJobsQuery = z.infer<typeof listBackgroundJobsQuerySchema>;
export type CreateBackgroundJobRun = z.infer<typeof createBackgroundJobRunSchema>;
export type UpdateBackgroundJobRunProgress = z.infer<typeof updateBackgroundJobRunProgressSchema>;
export type CreateBackgroundJobEvent = z.infer<typeof createBackgroundJobEventSchema>;
export type CompleteBackgroundJobRun = z.infer<typeof completeBackgroundJobRunSchema>;
export type ListBackgroundJobRunsQuery = z.infer<typeof listBackgroundJobRunsQuerySchema>;
+23
View File
@@ -246,6 +246,8 @@ export {
memoryProviderHealthCheckSchema,
memoryProviderConfigPathSuggestionSchema,
memoryProviderConfigMetadataSchema,
memoryHookPolicySchema,
memoryHookPoliciesSchema,
createMemoryBindingSchema,
updateMemoryBindingSchema,
setCompanyMemoryBindingSchema,
@@ -261,12 +263,15 @@ export {
memoryListRecordsQuerySchema,
memoryListOperationsQuerySchema,
memoryListExtractionJobsQuerySchema,
memoryRefreshJobSchema,
memoryBindingTargetTypeSchema,
type MemoryGovernedScopeInput,
type MemoryPrincipalRefInput,
type MemoryCitationInput,
type MemoryScopeInput,
type MemorySourceRefInput,
type MemoryHookPolicyInput,
type MemoryHookPoliciesInput,
type CreateMemoryBinding,
type UpdateMemoryBinding,
type SetCompanyMemoryBinding,
@@ -282,8 +287,26 @@ export {
type MemoryListRecordsQuery,
type MemoryListOperationsQuery,
type MemoryListExtractionJobsQuery,
type MemoryRefreshJob,
} from "./memory.js";
export {
createBackgroundJobSchema,
listBackgroundJobsQuerySchema,
createBackgroundJobRunSchema,
updateBackgroundJobRunProgressSchema,
createBackgroundJobEventSchema,
completeBackgroundJobRunSchema,
listBackgroundJobRunsQuerySchema,
type CreateBackgroundJob,
type ListBackgroundJobsQuery,
type CreateBackgroundJobRun,
type UpdateBackgroundJobRunProgress,
type CreateBackgroundJobEvent,
type CompleteBackgroundJobRun,
type ListBackgroundJobRunsQuery,
} from "./background-job.js";
export {
createFinanceEventSchema,
type CreateFinanceEvent,
+47
View File
@@ -1,7 +1,10 @@
import { z } from "zod";
import {
MEMORY_BINDING_TARGET_TYPES,
MEMORY_EXTRACTION_HARNESSES,
MEMORY_HOOK_EXTRACTION_MODES,
MEMORY_HOOK_KINDS,
MEMORY_HOOK_RUN_MODES,
MEMORY_OPERATION_STATUSES,
MEMORY_OPERATION_TYPES,
MEMORY_PRINCIPAL_TYPES,
@@ -127,6 +130,26 @@ export const memoryProviderConfigMetadataSchema = z
})
.strict();
export const memoryHookPolicySchema = z
.object({
enabled: z.boolean().optional(),
extractionMode: z.enum(MEMORY_HOOK_EXTRACTION_MODES).optional(),
runMode: z.enum(MEMORY_HOOK_RUN_MODES).optional(),
harness: z.enum(MEMORY_EXTRACTION_HARNESSES).optional(),
sensitivityLabel: z.enum(MEMORY_SENSITIVITY_LABELS).optional(),
reviewState: z.enum(MEMORY_REVIEW_STATES).optional(),
retentionPolicy: z.record(z.unknown()).nullable().optional(),
modelProvider: z.string().trim().min(1).max(128).nullable().optional(),
model: z.string().trim().min(1).max(200).nullable().optional(),
config: z.record(z.unknown()).nullable().optional(),
})
.strict();
export const memoryHookPoliciesSchema = z
.object(Object.fromEntries(MEMORY_HOOK_KINDS.map((kind) => [kind, memoryHookPolicySchema.optional()])))
.partial()
.strict();
export const createMemoryBindingSchema = z
.object({
key: z.string().trim().min(1).max(64).regex(/^[a-z0-9][a-z0-9_-]*$/, "Binding key must be lowercase letters, numbers, _ or -"),
@@ -300,6 +323,27 @@ export const memoryListExtractionJobsQuerySchema = z
})
.strict();
export const memoryRefreshJobSchema = z
.object({
bindingKey: z.string().trim().min(1).max(64).optional(),
scope: memoryScopeSchema.optional().default({}),
sourceKinds: z
.array(z.enum(["issue", "issue_comment", "issue_document", "run"]))
.min(1)
.max(4)
.optional()
.default(["issue", "issue_comment", "issue_document"]),
issueIds: z.array(z.string().uuid()).max(500).optional(),
projectId: z.string().uuid().nullable().optional(),
agentId: z.string().uuid().nullable().optional(),
runIds: z.array(z.string().uuid()).max(500).optional(),
since: z.coerce.date().nullable().optional(),
until: z.coerce.date().nullable().optional(),
dryRun: z.boolean().optional().default(false),
limit: z.number().int().positive().max(5000).optional().default(500),
})
.strict();
export const memoryBindingTargetTypeSchema = z.enum(MEMORY_BINDING_TARGET_TYPES);
export type MemoryScopeInput = z.infer<typeof memoryScopeSchema>;
@@ -307,6 +351,8 @@ export type MemoryGovernedScopeInput = z.infer<typeof memoryGovernedScopeSchema>
export type MemoryPrincipalRefInput = z.infer<typeof memoryPrincipalRefSchema>;
export type MemoryCitationInput = z.infer<typeof memoryCitationSchema>;
export type MemorySourceRefInput = z.infer<typeof memorySourceRefSchema>;
export type MemoryHookPolicyInput = z.infer<typeof memoryHookPolicySchema>;
export type MemoryHookPoliciesInput = z.infer<typeof memoryHookPoliciesSchema>;
export type CreateMemoryBinding = z.infer<typeof createMemoryBindingSchema>;
export type UpdateMemoryBinding = z.infer<typeof updateMemoryBindingSchema>;
export type SetCompanyMemoryBinding = z.infer<typeof setCompanyMemoryBindingSchema>;
@@ -322,3 +368,4 @@ export type MemoryRetentionSweep = z.infer<typeof memoryRetentionSweepSchema>;
export type MemoryListRecordsQuery = z.infer<typeof memoryListRecordsQuerySchema>;
export type MemoryListOperationsQuery = z.infer<typeof memoryListOperationsQuerySchema>;
export type MemoryListExtractionJobsQuery = z.infer<typeof memoryListExtractionJobsQuerySchema>;
export type MemoryRefreshJob = z.infer<typeof memoryRefreshJobSchema>;
@@ -0,0 +1,175 @@
import express from "express";
import request from "supertest";
import { beforeEach, describe, expect, it, vi } from "vitest";
import { errorHandler } from "../middleware/index.js";
import { backgroundJobRoutes } from "../routes/background-jobs.js";
const companyA = "11111111-1111-4111-8111-111111111111";
const companyB = "22222222-2222-4222-8222-222222222222";
const jobId = "33333333-3333-4333-8333-333333333333";
const runId = "44444444-4444-4444-8444-444444444444";
const mockBackgroundJobService = vi.hoisted(() => ({
createOrUpdateJob: vi.fn(),
listJobs: vi.fn(),
getJob: vi.fn(),
createRun: vi.fn(),
listRuns: vi.fn(),
getRun: vi.fn(),
listRunEvents: vi.fn(),
requestCancelRun: vi.fn(),
}));
const mockLogActivity = vi.hoisted(() => vi.fn());
vi.mock("../services/index.js", () => ({
backgroundJobService: () => mockBackgroundJobService,
logActivity: mockLogActivity,
}));
function createApp(actor: Record<string, unknown>) {
const app = express();
app.use(express.json());
app.use((req, _res, next) => {
(req as any).actor = actor;
next();
});
app.use("/api", backgroundJobRoutes({} as any));
app.use(errorHandler);
return app;
}
describe("background job routes", () => {
beforeEach(() => {
vi.clearAllMocks();
mockLogActivity.mockResolvedValue(undefined);
mockBackgroundJobService.createOrUpdateJob.mockResolvedValue({
id: jobId,
companyId: companyA,
key: "memory.refresh",
jobType: "memory_refresh",
displayName: "Memory refresh",
backendKind: "server_worker",
status: "active",
config: {},
});
mockBackgroundJobService.createRun.mockResolvedValue({
id: runId,
companyId: companyA,
jobId,
jobKey: "memory.refresh",
jobType: "memory_refresh",
trigger: "manual",
status: "queued",
});
mockBackgroundJobService.requestCancelRun.mockResolvedValue({
id: runId,
companyId: companyA,
jobId,
jobKey: "memory.refresh",
jobType: "memory_refresh",
trigger: "manual",
status: "running",
cancellationRequestedAt: new Date("2026-04-20T00:00:00.000Z"),
});
});
it("blocks board users outside the company from listing jobs", async () => {
const app = createApp({
type: "board",
userId: "board-user",
source: "session",
companyIds: [companyB],
isInstanceAdmin: false,
});
const res = await request(app)
.get(`/api/companies/${companyA}/background-jobs`)
.set("Origin", "http://localhost:3100");
expect(res.status).toBe(403);
expect(mockBackgroundJobService.listJobs).not.toHaveBeenCalled();
});
it("creates a generic background job definition and logs activity", async () => {
const app = createApp({
type: "board",
userId: "board-user",
source: "session",
companyIds: [companyA],
isInstanceAdmin: false,
});
const res = await request(app)
.post(`/api/companies/${companyA}/background-jobs`)
.set("Origin", "http://localhost:3100")
.send({
key: "memory.refresh",
jobType: "memory_refresh",
displayName: "Memory refresh",
backendKind: "server_worker",
});
expect(res.status).toBe(201);
expect(mockBackgroundJobService.createOrUpdateJob).toHaveBeenCalledWith(
companyA,
expect.objectContaining({ key: "memory.refresh", jobType: "memory_refresh" }),
expect.objectContaining({ actorType: "user", userId: "board-user" }),
);
expect(mockLogActivity).toHaveBeenCalledWith(
expect.anything(),
expect.objectContaining({
companyId: companyA,
action: "background_job.upserted",
entityId: jobId,
}),
);
});
it("creates a generic background job run", async () => {
const app = createApp({
type: "board",
userId: "board-user",
source: "session",
companyIds: [companyA],
isInstanceAdmin: false,
});
const res = await request(app)
.post(`/api/companies/${companyA}/background-job-runs`)
.set("Origin", "http://localhost:3100")
.send({ jobId, trigger: "manual", totalItems: 3 });
expect(res.status).toBe(201);
expect(mockBackgroundJobService.createRun).toHaveBeenCalledWith(
companyA,
{ jobId, trigger: "manual", totalItems: 3 },
expect.objectContaining({ actorType: "user", userId: "board-user" }),
);
});
it("requests cancellation for a run", async () => {
const app = createApp({
type: "board",
userId: "board-user",
source: "session",
companyIds: [companyA],
isInstanceAdmin: false,
});
const res = await request(app)
.post(`/api/companies/${companyA}/background-job-runs/${runId}/cancel`)
.set("Origin", "http://localhost:3100")
.send({});
expect(res.status).toBe(200);
expect(mockBackgroundJobService.requestCancelRun).toHaveBeenCalledWith(companyA, runId);
expect(mockLogActivity).toHaveBeenCalledWith(
expect.anything(),
expect.objectContaining({
action: "background_job_run.cancel_requested",
entityId: runId,
}),
);
});
});
@@ -30,6 +30,7 @@ const mockMemoryService = vi.hoisted(() => ({
getRecord: vi.fn(),
listOperations: vi.fn(),
listExtractionJobs: vi.fn(),
startRefreshJob: vi.fn(),
}));
const mockAgentService = vi.hoisted(() => ({
@@ -300,4 +301,66 @@ describe("memory routes", () => {
expect(res.status).toBe(403);
expect(mockMemoryService.setProjectOverride).not.toHaveBeenCalled();
});
it("starts memory refresh jobs through the memory service and logs activity", async () => {
mockMemoryService.startRefreshJob.mockResolvedValue({
job: {
id: "99999999-9999-4999-8999-999999999999",
companyId: companyA,
key: "memory.refresh",
jobType: "memory_refresh",
},
run: {
id: "aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa",
companyId: companyA,
jobKey: "memory.refresh",
jobType: "memory_refresh",
status: "queued",
},
dryRun: false,
sourceCounts: {
issue: 1,
issue_comment: 2,
issue_document: 1,
run: 0,
},
recordCount: 0,
});
const app = createApp({
type: "board",
userId: "board-user",
source: "session",
companyIds: [companyA],
isInstanceAdmin: false,
});
const res = await request(app)
.post(`/api/companies/${companyA}/memory/refresh-jobs`)
.set("Origin", "http://localhost:3100")
.send({
sourceKinds: ["issue", "issue_comment", "issue_document"],
issueIds: ["bbbbbbbb-bbbb-4bbb-8bbb-bbbbbbbbbbbb"],
dryRun: false,
});
expect(res.status).toBe(202);
expect(mockMemoryService.startRefreshJob).toHaveBeenCalledWith(
companyA,
expect.objectContaining({
sourceKinds: ["issue", "issue_comment", "issue_document"],
issueIds: ["bbbbbbbb-bbbb-4bbb-8bbb-bbbbbbbbbbbb"],
dryRun: false,
}),
expect.objectContaining({ actorType: "user", userId: "board-user" }),
);
expect(mockLogActivity).toHaveBeenCalledWith(
expect.anything(),
expect.objectContaining({
companyId: companyA,
action: "memory.refresh_job_started",
entityType: "background_job_run",
entityId: "aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa",
}),
);
});
});
+258
View File
@@ -38,6 +38,137 @@ function makeBinding(id: string, key: string, providerKey = "local_basic") {
};
}
function makeMemoryRecordRow(overrides: Record<string, unknown> = {}) {
const now = new Date("2026-04-01T00:00:00.000Z");
return {
id: "44444444-4444-4444-8444-444444444444",
companyId: "aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa",
bindingId: "bbbbbbbb-bbbb-4bbb-8bbb-bbbbbbbbbbbb",
providerKey: "local_basic",
scopeAgentId: null,
scopeProjectId: null,
scopeIssueId: "55555555-5555-4555-8555-555555555555",
scopeRunId: null,
scopeSubjectId: null,
scopeType: "org",
scopeId: "aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa",
scopeWorkspaceId: null,
scopeTeamId: null,
sourceKind: "issue_comment",
sourceIssueId: "55555555-5555-4555-8555-555555555555",
sourceCommentId: "66666666-6666-4666-8666-666666666666",
sourceDocumentKey: null,
sourceRunId: null,
sourceActivityId: null,
sourceExternalRef: null,
ownerType: "user",
ownerId: "board-user",
createdByActorType: "user",
createdByActorId: "board-user",
sensitivityLabel: "internal",
retentionPolicy: null,
expiresAt: null,
retentionState: "active",
reviewState: "accepted",
reviewedAt: null,
reviewedByActorType: null,
reviewedByActorId: null,
reviewNote: null,
citationJson: null,
supersedesRecordId: null,
supersededByRecordId: null,
revokedAt: null,
revokedByActorType: null,
revokedByActorId: null,
revocationReason: null,
title: "Issue comment",
content: "Remember that the launch checklist lives in the issue document.",
summary: "Remember that the launch checklist lives in the issue document.",
metadata: {},
createdByOperationId: "77777777-7777-4777-8777-777777777777",
deletedAt: null,
createdAt: now,
updatedAt: now,
...overrides,
};
}
function makeOperationRow(overrides: Record<string, unknown> = {}) {
const now = new Date("2026-04-01T00:00:00.000Z");
return {
id: "77777777-7777-4777-8777-777777777777",
companyId: "aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa",
bindingId: "bbbbbbbb-bbbb-4bbb-8bbb-bbbbbbbbbbbb",
providerKey: "local_basic",
operationType: "upsert",
triggerKind: "hook",
hookKind: "issue_comment_capture",
status: "succeeded",
actorType: "user",
actorId: "board-user",
agentId: null,
userId: "board-user",
scopeAgentId: null,
scopeProjectId: null,
scopeIssueId: "55555555-5555-4555-8555-555555555555",
scopeRunId: null,
scopeSubjectId: null,
scopeType: "org",
scopeId: "aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa",
scopeWorkspaceId: null,
scopeTeamId: null,
maxSensitivityLabel: null,
sourceKind: "issue_comment",
sourceIssueId: "55555555-5555-4555-8555-555555555555",
sourceCommentId: "66666666-6666-4666-8666-666666666666",
sourceDocumentKey: null,
sourceRunId: null,
sourceActivityId: null,
sourceExternalRef: null,
queryText: null,
recordCount: 1,
requestJson: null,
resultJson: null,
policyDecisionJson: null,
revocationSelectorJson: null,
retentionActionJson: null,
usageJson: [],
error: null,
costEventId: null,
financeEventId: null,
occurredAt: now,
createdAt: now,
...overrides,
};
}
function makeExtractionJobRow(overrides: Record<string, unknown> = {}) {
const now = new Date("2026-04-01T00:00:00.000Z");
return {
id: "88888888-8888-4888-8888-888888888888",
companyId: "aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa",
bindingId: "bbbbbbbb-bbbb-4bbb-8bbb-bbbbbbbbbbbb",
providerKey: "local_basic",
operationId: null,
status: "running",
providerJobId: null,
sourceKind: "issue_comment",
sourceIssueId: "55555555-5555-4555-8555-555555555555",
sourceCommentId: "66666666-6666-4666-8666-666666666666",
sourceDocumentKey: null,
sourceRunId: null,
sourceActivityId: null,
sourceExternalRef: null,
resultJson: null,
error: null,
startedAt: now,
completedAt: null,
createdAt: now,
updatedAt: now,
...overrides,
};
}
describe("memoryService.forget", () => {
it("rejects record sets that span multiple bindings", async () => {
const rows = [
@@ -180,3 +311,130 @@ describe("memoryService.resolveBinding", () => {
).rejects.toThrow("Memory scope project does not belong to company");
});
});
describe("memoryService hook policies", () => {
it("runs issue comment capture through an extraction job when policy requests Paperclip-managed extraction", async () => {
const binding = {
...makeBinding("bbbbbbbb-bbbb-4bbb-8bbb-bbbbbbbbbbbb", "company"),
config: {
enableIssueCommentCapture: true,
hookPolicies: {
issue_comment_capture: {
enabled: true,
extractionMode: "paperclip_managed",
runMode: "sync",
harness: "server_worker",
sensitivityLabel: "internal",
reviewState: "accepted",
},
},
},
};
const target = {
id: "target-company",
companyId: binding.companyId,
bindingId: binding.id,
targetType: "company",
targetId: binding.companyId,
createdAt: binding.createdAt,
updatedAt: binding.updatedAt,
};
const selectResults = [
[{ target, binding }],
[{ target, binding }],
];
const insertResults = [
[makeExtractionJobRow()],
[makeMemoryRecordRow()],
[makeOperationRow()],
];
const updateResults = [
[
makeExtractionJobRow({
status: "succeeded",
operationId: "77777777-7777-4777-8777-777777777777",
completedAt: new Date("2026-04-01T00:00:01.000Z"),
}),
],
];
const insertedValues: unknown[] = [];
const updatedValues: unknown[] = [];
const selectThenable = () => ({
from: vi.fn().mockReturnThis(),
innerJoin: vi.fn().mockReturnThis(),
where: vi.fn().mockReturnThis(),
orderBy: vi.fn().mockReturnThis(),
limit: vi.fn().mockReturnThis(),
then: vi.fn((resolve: (rows: unknown[]) => unknown) => Promise.resolve(resolve(selectResults.shift() ?? []))),
});
const db = {
select: vi.fn(() => selectThenable()),
insert: vi.fn(() => ({
values: vi.fn((value: unknown) => {
insertedValues.push(value);
return {
returning: vi.fn().mockResolvedValue(insertResults.shift() ?? []),
};
}),
})),
update: vi.fn(() => ({
set: vi.fn((value: unknown) => {
updatedValues.push(value);
return {
where: vi.fn().mockReturnValue({
returning: vi.fn().mockResolvedValue(updateResults.shift() ?? []),
}),
};
}),
})),
} as any;
const result = await memoryService(db).captureIssueComment({
companyId: binding.companyId,
issueId: "55555555-5555-4555-8555-555555555555",
commentId: "66666666-6666-4666-8666-666666666666",
body: "Remember that the launch checklist lives in the issue document.",
actor: {
actorType: "user",
actorId: "board-user",
agentId: null,
userId: "board-user",
runId: null,
},
});
expect(result?.operation.hookKind).toBe("issue_comment_capture");
expect(db.insert).toHaveBeenCalledTimes(3);
expect(insertedValues[0]).toMatchObject({
companyId: binding.companyId,
bindingId: binding.id,
status: "running",
sourceKind: "issue_comment",
resultJson: {
hookKind: "issue_comment_capture",
policy: {
extractionMode: "paperclip_managed",
harness: "server_worker",
},
},
});
expect(insertedValues[1]).toMatchObject({
reviewState: "accepted",
metadata: {
extraction: {
mode: "paperclip_managed",
harness: "server_worker",
jobId: "88888888-8888-4888-8888-888888888888",
},
},
});
expect(updatedValues[0]).toMatchObject({
status: "succeeded",
operationId: "77777777-7777-4777-8777-777777777777",
resultJson: {
hookKind: "issue_comment_capture",
recordIds: ["44444444-4444-4444-8444-444444444444"],
},
});
});
});
+2
View File
@@ -27,6 +27,7 @@ import { sidebarBadgeRoutes } from "./routes/sidebar-badges.js";
import { inboxDismissalRoutes } from "./routes/inbox-dismissals.js";
import { instanceSettingsRoutes } from "./routes/instance-settings.js";
import { memoryRoutes } from "./routes/memory.js";
import { backgroundJobRoutes } from "./routes/background-jobs.js";
import { llmRoutes } from "./routes/llms.js";
import { assetRoutes } from "./routes/assets.js";
import { accessRoutes } from "./routes/access.js";
@@ -226,6 +227,7 @@ export async function createApp(
api.use(approvalRoutes(db));
api.use(secretRoutes(db));
api.use(costRoutes(db));
api.use(backgroundJobRoutes(db));
api.use(memoryRoutes(db, { pluginMemoryProviders: memoryProviderDispatcher }));
api.use(activityRoutes(db));
api.use(dashboardRoutes(db));
+2
View File
@@ -39,6 +39,7 @@ export function getActorInfo(req: Request) {
actorType: "agent" as const,
actorId: req.actor.agentId ?? "unknown-agent",
agentId: req.actor.agentId ?? null,
userId: null,
runId: req.actor.runId ?? null,
};
}
@@ -47,6 +48,7 @@ export function getActorInfo(req: Request) {
actorType: "user" as const,
actorId: req.actor.userId ?? "board",
agentId: null,
userId: req.actor.userId ?? null,
runId: req.actor.runId ?? null,
};
}
+127
View File
@@ -0,0 +1,127 @@
import { Router } from "express";
import type { Db } from "@paperclipai/db";
import {
createBackgroundJobRunSchema,
createBackgroundJobSchema,
listBackgroundJobRunsQuerySchema,
listBackgroundJobsQuerySchema,
} from "@paperclipai/shared";
import { validate } from "../middleware/validate.js";
import { backgroundJobService, logActivity } from "../services/index.js";
import { assertBoard, assertCompanyAccess, getActorInfo } from "./authz.js";
export function backgroundJobRoutes(db: Db) {
const router = Router();
const jobs = backgroundJobService(db);
router.get("/companies/:companyId/background-jobs", async (req, res) => {
const companyId = req.params.companyId as string;
assertCompanyAccess(req, companyId);
assertBoard(req);
const parsed = listBackgroundJobsQuerySchema.parse(req.query);
res.json(await jobs.listJobs(companyId, parsed));
});
router.post("/companies/:companyId/background-jobs", validate(createBackgroundJobSchema), async (req, res) => {
const companyId = req.params.companyId as string;
assertCompanyAccess(req, companyId);
assertBoard(req);
const actor = getActorInfo(req);
const job = await jobs.createOrUpdateJob(companyId, req.body, {
actorType: actor.actorType,
actorId: actor.actorId,
agentId: actor.agentId,
userId: actor.userId,
});
await logActivity(db, {
companyId,
actorType: actor.actorType,
actorId: actor.actorId,
agentId: actor.agentId,
action: "background_job.upserted",
entityType: "background_job",
entityId: job.id,
details: {
key: job.key,
jobType: job.jobType,
backendKind: job.backendKind,
},
});
res.status(201).json(job);
});
router.get("/companies/:companyId/background-job-runs", async (req, res) => {
const companyId = req.params.companyId as string;
assertCompanyAccess(req, companyId);
assertBoard(req);
const parsed = listBackgroundJobRunsQuerySchema.parse(req.query);
res.json(await jobs.listRuns(companyId, parsed));
});
router.post("/companies/:companyId/background-job-runs", validate(createBackgroundJobRunSchema), async (req, res) => {
const companyId = req.params.companyId as string;
assertCompanyAccess(req, companyId);
assertBoard(req);
const actor = getActorInfo(req);
const run = await jobs.createRun(companyId, req.body, {
actorType: actor.actorType,
actorId: actor.actorId,
agentId: actor.agentId,
userId: actor.userId,
});
await logActivity(db, {
companyId,
actorType: actor.actorType,
actorId: actor.actorId,
agentId: actor.agentId,
action: "background_job_run.created",
entityType: "background_job_run",
entityId: run.id,
details: {
jobKey: run.jobKey,
jobType: run.jobType,
trigger: run.trigger,
},
});
res.status(201).json(run);
});
router.get("/companies/:companyId/background-job-runs/:runId", async (req, res) => {
const companyId = req.params.companyId as string;
assertCompanyAccess(req, companyId);
assertBoard(req);
res.json(await jobs.getRun(companyId, req.params.runId as string));
});
router.get("/companies/:companyId/background-job-runs/:runId/events", async (req, res) => {
const companyId = req.params.companyId as string;
assertCompanyAccess(req, companyId);
assertBoard(req);
const limit = Number(req.query.limit ?? 200);
res.json(await jobs.listRunEvents(companyId, req.params.runId as string, Number.isFinite(limit) ? limit : 200));
});
router.post("/companies/:companyId/background-job-runs/:runId/cancel", async (req, res) => {
const companyId = req.params.companyId as string;
assertCompanyAccess(req, companyId);
assertBoard(req);
const actor = getActorInfo(req);
const run = await jobs.requestCancelRun(companyId, req.params.runId as string);
await logActivity(db, {
companyId,
actorType: actor.actorType,
actorId: actor.actorId,
agentId: actor.agentId,
action: "background_job_run.cancel_requested",
entityType: "background_job_run",
entityId: run.id,
details: {
jobKey: run.jobKey,
jobType: run.jobType,
},
});
res.json(run);
});
return router;
}
+24
View File
@@ -8,6 +8,7 @@ import {
memoryListOperationsQuerySchema,
memoryListRecordsQuerySchema,
memoryQuerySchema,
memoryRefreshJobSchema,
memoryRetentionSweepSchema,
memoryReviewSchema,
memoryRevokeSchema,
@@ -383,5 +384,28 @@ export function memoryRoutes(
res.json(await memory.listExtractionJobs(companyId, parsed));
});
router.post("/companies/:companyId/memory/refresh-jobs", validate(memoryRefreshJobSchema), async (req, res) => {
const companyId = req.params.companyId as string;
assertCompanyAccess(req, companyId);
assertBoard(req);
const result = await memory.startRefreshJob(companyId, req.body, actorInfoFromReq(req));
const actor = getActorInfo(req);
await logActivity(db, {
companyId,
actorType: actor.actorType,
actorId: actor.actorId,
agentId: actor.agentId,
action: "memory.refresh_job_started",
entityType: "background_job_run",
entityId: result.run.id,
details: {
jobId: result.job.id,
dryRun: result.dryRun,
sourceCounts: result.sourceCounts,
},
});
res.status(202).json(result);
});
return router;
}
+494
View File
@@ -0,0 +1,494 @@
import { and, desc, eq } from "drizzle-orm";
import type { Db } from "@paperclipai/db";
import {
backgroundJobCostEvents,
backgroundJobEvents,
backgroundJobRuns,
backgroundJobs,
} from "@paperclipai/db";
import type {
BackgroundJob,
BackgroundJobCostEvent,
BackgroundJobEvent,
BackgroundJobRun,
} from "@paperclipai/shared";
import {
completeBackgroundJobRunSchema,
createBackgroundJobEventSchema,
createBackgroundJobRunSchema,
createBackgroundJobSchema,
listBackgroundJobRunsQuerySchema,
listBackgroundJobsQuerySchema,
updateBackgroundJobRunProgressSchema,
type CompleteBackgroundJobRun,
type CreateBackgroundJob,
type CreateBackgroundJobEvent,
type CreateBackgroundJobRun,
type ListBackgroundJobRunsQuery,
type ListBackgroundJobsQuery,
type UpdateBackgroundJobRunProgress,
} from "@paperclipai/shared";
import { conflict, notFound, unprocessable } from "../errors.js";
export interface BackgroundJobActor {
actorType: "agent" | "user" | "system";
actorId: string;
agentId?: string | null;
userId?: string | null;
}
type JobRow = typeof backgroundJobs.$inferSelect;
type RunRow = typeof backgroundJobRuns.$inferSelect;
type EventRow = typeof backgroundJobEvents.$inferSelect;
type CostEventRow = typeof backgroundJobCostEvents.$inferSelect;
function mapJob(row: JobRow): BackgroundJob {
return {
id: row.id,
companyId: row.companyId,
key: row.key,
jobType: row.jobType,
displayName: row.displayName,
description: row.description ?? null,
backendKind: row.backendKind,
status: row.status,
config: row.config ?? {},
createdByAgentId: row.createdByAgentId ?? null,
createdByUserId: row.createdByUserId ?? null,
sourceIssueId: row.sourceIssueId ?? null,
sourceProjectId: row.sourceProjectId ?? null,
createdAt: row.createdAt,
updatedAt: row.updatedAt,
};
}
function mapRun(row: RunRow): BackgroundJobRun {
return {
id: row.id,
companyId: row.companyId,
jobId: row.jobId ?? null,
jobKey: row.jobKey,
jobType: row.jobType,
trigger: row.trigger,
status: row.status,
requestedByActorType: row.requestedByActorType as BackgroundJobRun["requestedByActorType"],
requestedByActorId: row.requestedByActorId,
requestedByAgentId: row.requestedByAgentId ?? null,
requestedByUserId: row.requestedByUserId ?? null,
sourceIssueId: row.sourceIssueId ?? null,
sourceProjectId: row.sourceProjectId ?? null,
sourceAgentId: row.sourceAgentId ?? null,
heartbeatRunId: row.heartbeatRunId ?? null,
totalItems: row.totalItems ?? null,
processedItems: row.processedItems,
succeededItems: row.succeededItems,
failedItems: row.failedItems,
skippedItems: row.skippedItems,
progressPercent: row.progressPercent ?? null,
currentItem: row.currentItem ?? null,
cancellationRequestedAt: row.cancellationRequestedAt ?? null,
cancelledAt: row.cancelledAt ?? null,
error: row.error ?? null,
result:
typeof row.result === "object" && row.result !== null && !Array.isArray(row.result)
? (row.result as Record<string, unknown>)
: null,
startedAt: row.startedAt ?? null,
finishedAt: row.finishedAt ?? null,
durationMs: row.durationMs ?? null,
createdAt: row.createdAt,
updatedAt: row.updatedAt,
};
}
function mapEvent(row: EventRow): BackgroundJobEvent {
return {
id: row.id,
companyId: row.companyId,
runId: row.runId,
eventType: row.eventType,
level: row.level,
message: row.message ?? null,
progressPercent: row.progressPercent ?? null,
totalItems: row.totalItems ?? null,
processedItems: row.processedItems ?? null,
succeededItems: row.succeededItems ?? null,
failedItems: row.failedItems ?? null,
skippedItems: row.skippedItems ?? null,
currentItem: row.currentItem ?? null,
details:
typeof row.details === "object" && row.details !== null && !Array.isArray(row.details)
? (row.details as Record<string, unknown>)
: null,
createdAt: row.createdAt,
};
}
function mapCostEvent(row: CostEventRow): BackgroundJobCostEvent {
return {
id: row.id,
companyId: row.companyId,
runId: row.runId,
costEventId: row.costEventId,
createdAt: row.createdAt,
};
}
function calculateProgressPercent(processedItems: number, totalItems: number | null | undefined) {
if (!totalItems || totalItems <= 0) return null;
return Math.max(0, Math.min(100, Math.round((processedItems / totalItems) * 100)));
}
export function backgroundJobService(db: Db) {
async function getRunRow(runId: string): Promise<RunRow> {
const run = await db
.select()
.from(backgroundJobRuns)
.where(eq(backgroundJobRuns.id, runId))
.then((rows) => rows[0] ?? null);
if (!run) throw notFound("Background job run not found");
return run;
}
return {
createOrUpdateJob: async (
companyId: string,
data: CreateBackgroundJob,
actor: BackgroundJobActor,
): Promise<BackgroundJob> => {
const parsed = createBackgroundJobSchema.parse(data);
const existing = await db
.select()
.from(backgroundJobs)
.where(and(eq(backgroundJobs.companyId, companyId), eq(backgroundJobs.key, parsed.key)))
.then((rows) => rows[0] ?? null);
if (existing) {
const [row] = await db
.update(backgroundJobs)
.set({
jobType: parsed.jobType,
displayName: parsed.displayName,
description: parsed.description ?? null,
backendKind: parsed.backendKind,
status: parsed.status,
config: parsed.config,
sourceIssueId: parsed.sourceIssueId ?? null,
sourceProjectId: parsed.sourceProjectId ?? null,
updatedAt: new Date(),
})
.where(eq(backgroundJobs.id, existing.id))
.returning();
return mapJob(row);
}
const [row] = await db
.insert(backgroundJobs)
.values({
companyId,
key: parsed.key,
jobType: parsed.jobType,
displayName: parsed.displayName,
description: parsed.description ?? null,
backendKind: parsed.backendKind,
status: parsed.status,
config: parsed.config,
createdByAgentId: actor.agentId ?? null,
createdByUserId: actor.userId ?? null,
sourceIssueId: parsed.sourceIssueId ?? null,
sourceProjectId: parsed.sourceProjectId ?? null,
})
.returning();
return mapJob(row);
},
listJobs: async (companyId: string, query: ListBackgroundJobsQuery): Promise<BackgroundJob[]> => {
const parsed = listBackgroundJobsQuerySchema.parse(query);
const conditions = [eq(backgroundJobs.companyId, companyId)];
if (parsed.jobType) conditions.push(eq(backgroundJobs.jobType, parsed.jobType));
if (parsed.status) conditions.push(eq(backgroundJobs.status, parsed.status));
const rows = await db
.select()
.from(backgroundJobs)
.where(and(...conditions))
.orderBy(desc(backgroundJobs.createdAt))
.limit(parsed.limit);
return rows.map(mapJob);
},
getJob: async (companyId: string, jobId: string): Promise<BackgroundJob> => {
const row = await db
.select()
.from(backgroundJobs)
.where(and(eq(backgroundJobs.companyId, companyId), eq(backgroundJobs.id, jobId)))
.then((rows) => rows[0] ?? null);
if (!row) throw notFound("Background job not found");
return mapJob(row);
},
createRun: async (
companyId: string,
data: CreateBackgroundJobRun,
actor: BackgroundJobActor,
): Promise<BackgroundJobRun> => {
const parsed = createBackgroundJobRunSchema.parse(data);
let job: JobRow | null = null;
if (parsed.jobId) {
job = await db
.select()
.from(backgroundJobs)
.where(and(eq(backgroundJobs.companyId, companyId), eq(backgroundJobs.id, parsed.jobId)))
.then((rows) => rows[0] ?? null);
if (!job) throw notFound("Background job not found");
}
if (job?.status === "archived") throw conflict("Archived background jobs cannot be run");
if (job?.status === "paused") throw conflict("Paused background jobs cannot be run");
const jobKey = job?.key ?? parsed.jobKey;
const jobType = job?.jobType ?? parsed.jobType;
if (!jobKey || !jobType) {
throw unprocessable("Background job run requires job key and type");
}
const [row] = await db
.insert(backgroundJobRuns)
.values({
companyId,
jobId: job?.id ?? null,
jobKey,
jobType,
trigger: parsed.trigger,
requestedByActorType: actor.actorType,
requestedByActorId: actor.actorId,
requestedByAgentId: actor.agentId ?? null,
requestedByUserId: actor.userId ?? null,
sourceIssueId: parsed.sourceIssueId ?? job?.sourceIssueId ?? null,
sourceProjectId: parsed.sourceProjectId ?? job?.sourceProjectId ?? null,
sourceAgentId: parsed.sourceAgentId ?? null,
heartbeatRunId: parsed.heartbeatRunId ?? null,
totalItems: parsed.totalItems ?? null,
progressPercent: calculateProgressPercent(0, parsed.totalItems ?? null),
})
.returning();
await db.insert(backgroundJobEvents).values({
companyId,
runId: row.id,
eventType: "queued",
level: "info",
message: "Job run queued",
totalItems: parsed.totalItems ?? null,
processedItems: 0,
progressPercent: calculateProgressPercent(0, parsed.totalItems ?? null),
});
return mapRun(row);
},
startRun: async (runId: string): Promise<BackgroundJobRun> => {
const current = await getRunRow(runId);
if (current.status !== "queued") throw conflict("Only queued background job runs can be started");
const now = new Date();
const [row] = await db
.update(backgroundJobRuns)
.set({ status: "running", startedAt: now, updatedAt: now })
.where(eq(backgroundJobRuns.id, runId))
.returning();
await db.insert(backgroundJobEvents).values({
companyId: row.companyId,
runId,
eventType: "started",
level: "info",
message: "Job run started",
totalItems: row.totalItems ?? null,
processedItems: row.processedItems,
progressPercent: row.progressPercent ?? null,
});
return mapRun(row);
},
updateRunProgress: async (
runId: string,
data: UpdateBackgroundJobRunProgress,
): Promise<BackgroundJobRun> => {
const parsed = updateBackgroundJobRunProgressSchema.parse(data);
const current = await getRunRow(runId);
if (current.status !== "running") throw conflict("Only running background job runs can report progress");
const nextProcessed = parsed.processedItems ?? current.processedItems;
const nextTotal = parsed.totalItems === undefined ? current.totalItems : parsed.totalItems;
const progressPercent =
parsed.progressPercent === undefined
? calculateProgressPercent(nextProcessed, nextTotal)
: parsed.progressPercent;
const [row] = await db
.update(backgroundJobRuns)
.set({
totalItems: nextTotal ?? null,
processedItems: nextProcessed,
succeededItems: parsed.succeededItems ?? current.succeededItems,
failedItems: parsed.failedItems ?? current.failedItems,
skippedItems: parsed.skippedItems ?? current.skippedItems,
progressPercent,
currentItem: parsed.currentItem === undefined ? current.currentItem : parsed.currentItem,
updatedAt: new Date(),
})
.where(eq(backgroundJobRuns.id, runId))
.returning();
await db.insert(backgroundJobEvents).values({
companyId: row.companyId,
runId,
eventType: "progress",
level: "info",
message: row.currentItem,
progressPercent: row.progressPercent ?? null,
totalItems: row.totalItems ?? null,
processedItems: row.processedItems,
succeededItems: row.succeededItems,
failedItems: row.failedItems,
skippedItems: row.skippedItems,
currentItem: row.currentItem ?? null,
});
return mapRun(row);
},
appendEvent: async (runId: string, data: CreateBackgroundJobEvent): Promise<BackgroundJobEvent> => {
const run = await getRunRow(runId);
const parsed = createBackgroundJobEventSchema.parse(data);
const [row] = await db
.insert(backgroundJobEvents)
.values({
companyId: run.companyId,
runId,
eventType: parsed.eventType,
level: parsed.level,
message: parsed.message ?? null,
progressPercent: parsed.progressPercent ?? null,
totalItems: parsed.totalItems ?? null,
processedItems: parsed.processedItems ?? null,
succeededItems: parsed.succeededItems ?? null,
failedItems: parsed.failedItems ?? null,
skippedItems: parsed.skippedItems ?? null,
currentItem: parsed.currentItem ?? null,
details: parsed.details ?? null,
})
.returning();
return mapEvent(row);
},
completeRun: async (runId: string, data: CompleteBackgroundJobRun): Promise<BackgroundJobRun> => {
const parsed = completeBackgroundJobRunSchema.parse(data);
const current = await getRunRow(runId);
if (!["queued", "running"].includes(current.status)) {
throw conflict("Background job run is already terminal");
}
const finishedAt = new Date();
const durationMs = current.startedAt ? finishedAt.getTime() - current.startedAt.getTime() : null;
const [row] = await db
.update(backgroundJobRuns)
.set({
status: parsed.status,
error: parsed.error ?? null,
result: parsed.result ?? null,
progressPercent: parsed.status === "succeeded" ? 100 : current.progressPercent,
cancelledAt: parsed.status === "cancelled" ? finishedAt : current.cancelledAt,
finishedAt,
durationMs,
updatedAt: finishedAt,
})
.where(eq(backgroundJobRuns.id, runId))
.returning();
await db.insert(backgroundJobEvents).values({
companyId: row.companyId,
runId,
eventType: parsed.status === "succeeded" ? "completed" : parsed.status,
level: parsed.status === "failed" ? "error" : "info",
message: parsed.error ?? `Job run ${parsed.status}`,
progressPercent: row.progressPercent ?? null,
totalItems: row.totalItems ?? null,
processedItems: row.processedItems,
succeededItems: row.succeededItems,
failedItems: row.failedItems,
skippedItems: row.skippedItems,
details: parsed.result ?? null,
});
return mapRun(row);
},
requestCancelRun: async (companyId: string, runId: string): Promise<BackgroundJobRun> => {
const current = await getRunRow(runId);
if (current.companyId !== companyId) throw notFound("Background job run not found");
if (!["queued", "running"].includes(current.status)) return mapRun(current);
const now = new Date();
const [row] = await db
.update(backgroundJobRuns)
.set({ cancellationRequestedAt: now, updatedAt: now })
.where(eq(backgroundJobRuns.id, runId))
.returning();
await db.insert(backgroundJobEvents).values({
companyId,
runId,
eventType: "log",
level: "warn",
message: "Cancellation requested",
});
return mapRun(row);
},
isCancellationRequested: async (runId: string): Promise<boolean> => {
const run = await getRunRow(runId);
return Boolean(run.cancellationRequestedAt);
},
linkCostEvent: async (companyId: string, runId: string, costEventId: string): Promise<BackgroundJobCostEvent> => {
const run = await getRunRow(runId);
if (run.companyId !== companyId) throw notFound("Background job run not found");
const [row] = await db
.insert(backgroundJobCostEvents)
.values({ companyId, runId, costEventId })
.onConflictDoNothing()
.returning();
return row ? mapCostEvent(row) : {
id: "",
companyId,
runId,
costEventId,
createdAt: new Date(),
};
},
listRuns: async (companyId: string, query: ListBackgroundJobRunsQuery): Promise<BackgroundJobRun[]> => {
const parsed = listBackgroundJobRunsQuerySchema.parse(query);
const conditions = [eq(backgroundJobRuns.companyId, companyId)];
if (parsed.jobId) conditions.push(eq(backgroundJobRuns.jobId, parsed.jobId));
if (parsed.jobType) conditions.push(eq(backgroundJobRuns.jobType, parsed.jobType));
if (parsed.status) conditions.push(eq(backgroundJobRuns.status, parsed.status));
if (parsed.sourceIssueId) conditions.push(eq(backgroundJobRuns.sourceIssueId, parsed.sourceIssueId));
if (parsed.sourceProjectId) conditions.push(eq(backgroundJobRuns.sourceProjectId, parsed.sourceProjectId));
const rows = await db
.select()
.from(backgroundJobRuns)
.where(and(...conditions))
.orderBy(desc(backgroundJobRuns.createdAt))
.limit(parsed.limit);
return rows.map(mapRun);
},
getRun: async (companyId: string, runId: string): Promise<BackgroundJobRun> => {
const row = await getRunRow(runId);
if (row.companyId !== companyId) throw notFound("Background job run not found");
return mapRun(row);
},
listRunEvents: async (companyId: string, runId: string, limit = 200): Promise<BackgroundJobEvent[]> => {
const run = await getRunRow(runId);
if (run.companyId !== companyId) throw notFound("Background job run not found");
const rows = await db
.select()
.from(backgroundJobEvents)
.where(eq(backgroundJobEvents.runId, runId))
.orderBy(desc(backgroundJobEvents.createdAt))
.limit(Math.min(Math.max(limit, 1), 500));
return rows.reverse().map(mapEvent);
},
};
}
export type BackgroundJobService = ReturnType<typeof backgroundJobService>;
+1
View File
@@ -21,6 +21,7 @@ export { dashboardService } from "./dashboard.js";
export { sidebarBadgeService } from "./sidebar-badges.js";
export { inboxDismissalService } from "./inbox-dismissals.js";
export { memoryService } from "./memory.js";
export { backgroundJobService } from "./background-jobs.js";
export { accessService } from "./access.js";
export { boardAuthService } from "./board-auth.js";
export { instanceSettingsService } from "./instance-settings.js";
File diff suppressed because it is too large. Load diff
+48
View File
@@ -0,0 +1,48 @@
import type {
BackgroundJob,
BackgroundJobEvent,
BackgroundJobRun,
CreateBackgroundJob,
CreateBackgroundJobRun,
ListBackgroundJobRunsQuery,
ListBackgroundJobsQuery,
} from "@paperclipai/shared";
import { api } from "./client";
function buildQueryString(filters?: Record<string, string | number | boolean | Date | undefined>) {
const params = new URLSearchParams();
for (const [key, value] of Object.entries(filters ?? {})) {
if (value === undefined) continue;
params.set(key, value instanceof Date ? value.toISOString() : String(value));
}
const qs = params.toString();
return qs ? `?${qs}` : "";
}
export const backgroundJobsApi = {
listJobs: (companyId: string, filters?: Partial<ListBackgroundJobsQuery>) =>
api.get<BackgroundJob[]>(
`/companies/${encodeURIComponent(companyId)}/background-jobs${buildQueryString(filters)}`,
),
createJob: (companyId: string, data: CreateBackgroundJob) =>
api.post<BackgroundJob>(`/companies/${encodeURIComponent(companyId)}/background-jobs`, data),
listRuns: (companyId: string, filters?: Partial<ListBackgroundJobRunsQuery>) =>
api.get<BackgroundJobRun[]>(
`/companies/${encodeURIComponent(companyId)}/background-job-runs${buildQueryString(filters)}`,
),
createRun: (companyId: string, data: CreateBackgroundJobRun) =>
api.post<BackgroundJobRun>(`/companies/${encodeURIComponent(companyId)}/background-job-runs`, data),
getRun: (companyId: string, runId: string) =>
api.get<BackgroundJobRun>(
`/companies/${encodeURIComponent(companyId)}/background-job-runs/${encodeURIComponent(runId)}`,
),
listRunEvents: (companyId: string, runId: string, limit?: number) =>
api.get<BackgroundJobEvent[]>(
`/companies/${encodeURIComponent(companyId)}/background-job-runs/${encodeURIComponent(runId)}/events${buildQueryString({ limit })}`,
),
cancelRun: (companyId: string, runId: string) =>
api.post<BackgroundJobRun>(
`/companies/${encodeURIComponent(companyId)}/background-job-runs/${encodeURIComponent(runId)}/cancel`,
{},
),
};
+1
View File
@@ -18,3 +18,4 @@ export { sidebarBadgesApi } from "./sidebarBadges";
export { inboxDismissalsApi } from "./inboxDismissals";
export { companySkillsApi } from "./companySkills";
export { memoryApi } from "./memory";
export { backgroundJobsApi } from "./backgroundJobs";
+7
View File
@@ -9,6 +9,7 @@ import type {
MemoryOperation,
MemoryProviderDescriptor,
MemoryRecord,
MemoryRefreshJobResult,
MemoryRetentionSweep,
MemoryRetentionSweepResult,
MemoryReview,
@@ -20,6 +21,7 @@ import type {
SetCompanyMemoryBinding,
SetProjectMemoryBinding,
UpdateMemoryBinding,
MemoryRefreshJob,
} from "@paperclipai/shared";
import { api } from "./client";
@@ -79,4 +81,9 @@ export const memoryApi = {
`/companies/${encodeURIComponent(companyId)}/memory/retention/sweep`,
data,
),
startRefreshJob: (companyId: string, data: MemoryRefreshJob) =>
api.post<MemoryRefreshJobResult>(
`/companies/${encodeURIComponent(companyId)}/memory/refresh-jobs`,
data,
),
};