mirror of
https://github.com/paperclipai/paperclip.git
synced 2026-10-21 16:27:18 +02:00
Create background job runner and memory refresh jobs
Co-Authored-By: Paperclip <noreply@paperclip.ing>
This commit is contained in:
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
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -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),
|
||||
}),
|
||||
);
|
||||
@@ -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(),
|
||||
|
||||
@@ -253,6 +253,8 @@ export type {
|
||||
MemoryProviderQueryInput,
|
||||
MemoryProviderQueryOutput,
|
||||
MemoryOperation,
|
||||
MemoryHookPolicy,
|
||||
MemoryHookPolicyMap,
|
||||
MemoryProviderCapabilities,
|
||||
MemoryProviderConfigFieldInput,
|
||||
MemoryProviderConfigFieldOption,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
@@ -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>;
|
||||
@@ -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,
|
||||
|
||||
@@ -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",
|
||||
}),
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -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"],
|
||||
},
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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));
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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>;
|
||||
@@ -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";
|
||||
|
||||
+959
-106
File diff suppressed because it is too large.
Load diff
@@ -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`,
|
||||
{},
|
||||
),
|
||||
};
|
||||
@@ -18,3 +18,4 @@ export { sidebarBadgesApi } from "./sidebarBadges";
|
||||
export { inboxDismissalsApi } from "./inboxDismissals";
|
||||
export { companySkillsApi } from "./companySkills";
|
||||
export { memoryApi } from "./memory";
|
||||
export { backgroundJobsApi } from "./backgroundJobs";
|
||||
@@ -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,
|
||||
),
|
||||
};
|
||||
Reference in new issue
Block a user