@rizom/brain 0.2.0-alpha.404 → 0.2.0-alpha.405

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (31) hide show
  1. package/dist/brain.js +227 -227
  2. package/dist/chunks/{app-afye8rgv.js → app-3z7kg4n4.js} +2 -2
  3. package/dist/chunks/{blog-post-adapter-wxf0f0rf.js → blog-post-adapter-vn4bvezs.js} +2 -2
  4. package/dist/chunks/{index-97j4t44j.js → index-03cfb3s4.js} +2 -2
  5. package/dist/chunks/{index-hp3ma2a0.js → index-aft95ek4.js} +26 -26
  6. package/dist/chunks/{index-hp3ma2a0.js.map → index-aft95ek4.js.map} +3 -3
  7. package/dist/chunks/{index-02vqvq05.js → index-e0rx633y.js} +3 -3
  8. package/dist/chunks/{index-02vqvq05.js.map → index-e0rx633y.js.map} +1 -1
  9. package/dist/chunks/{index-wqbddw4c.js → index-pkcefcbr.js} +2 -2
  10. package/dist/chunks/{index-122t0n9j.js → index-t1p3qrrm.js} +2 -2
  11. package/dist/chunks/{index-7q5j58p2.js → index-zghffskf.js} +3 -3
  12. package/dist/chunks/linkedin-template-mra3dywt.js +5 -0
  13. package/dist/chunks/{rss-datasource-ftkdarkw.js → rss-datasource-meq425kx.js} +2 -2
  14. package/dist/entities.js +1 -1
  15. package/dist/index.js +1 -1
  16. package/dist/interfaces.js +1 -1
  17. package/dist/model.js +38 -38
  18. package/dist/model.js.map +1 -1
  19. package/dist/plugins.js +1 -1
  20. package/dist/services.js +1 -1
  21. package/dist/ui/studio-chunks/main-s1ykse8z.js.map +1 -1
  22. package/package.json +1 -1
  23. package/dist/chunks/linkedin-template-jj13p2ah.js +0 -5
  24. /package/dist/chunks/{app-afye8rgv.js.map → app-3z7kg4n4.js.map} +0 -0
  25. /package/dist/chunks/{blog-post-adapter-wxf0f0rf.js.map → blog-post-adapter-vn4bvezs.js.map} +0 -0
  26. /package/dist/chunks/{index-97j4t44j.js.map → index-03cfb3s4.js.map} +0 -0
  27. /package/dist/chunks/{index-wqbddw4c.js.map → index-pkcefcbr.js.map} +0 -0
  28. /package/dist/chunks/{index-122t0n9j.js.map → index-t1p3qrrm.js.map} +0 -0
  29. /package/dist/chunks/{index-7q5j58p2.js.map → index-zghffskf.js.map} +0 -0
  30. /package/dist/chunks/{linkedin-template-jj13p2ah.js.map → linkedin-template-mra3dywt.js.map} +0 -0
  31. /package/dist/chunks/{rss-datasource-ftkdarkw.js.map → rss-datasource-meq425kx.js.map} +0 -0
package/dist/plugins.js CHANGED
@@ -1,5 +1,5 @@
1
1
  // @bun
2
- import{Ag as u,Bd as t,Cd as o,Cg as h,Dd as s,Ed as r,Eg as R,Fd as i,vg as p,wg as m,xg as c,yg as l,zg as g}from"./chunks/index-hp3ma2a0.js";import"./chunks/index-3rzkj0kw.js";import"./chunks/index-3w748142.js";import{ji as e,ni as n,oi as a}from"./chunks/index-18h53jey.js";import"./chunks/index-j1kv20z9.js";export{a as AgentResponseSchema,i as AnchorProfileSchema,m as AppInfoSchema,h as BaseMessageSchema,r as BrainCharacterSchema,p as ChatContextSchema,o as ConversationSchema,t as ExtensionMetadataSchema,R as MessageResponseSchema,s as MessageSchema,e as PendingConfirmationSchema,c as RuntimeHealthCheckSchema,l as RuntimeQueueSignalsSchema,u as RuntimeReadinessSchema,g as RuntimeResourceSignalsSchema,n as ToolResultDataSchema};
2
+ import{Ag as u,Bd as t,Cd as o,Cg as h,Dd as s,Ed as r,Eg as R,Fd as i,vg as p,wg as m,xg as c,yg as l,zg as g}from"./chunks/index-aft95ek4.js";import"./chunks/index-3rzkj0kw.js";import"./chunks/index-3w748142.js";import{ji as e,ni as n,oi as a}from"./chunks/index-18h53jey.js";import"./chunks/index-j1kv20z9.js";export{a as AgentResponseSchema,i as AnchorProfileSchema,m as AppInfoSchema,h as BaseMessageSchema,r as BrainCharacterSchema,p as ChatContextSchema,o as ConversationSchema,t as ExtensionMetadataSchema,R as MessageResponseSchema,s as MessageSchema,e as PendingConfirmationSchema,c as RuntimeHealthCheckSchema,l as RuntimeQueueSignalsSchema,u as RuntimeReadinessSchema,g as RuntimeResourceSignalsSchema,n as ToolResultDataSchema};
3
3
 
4
4
  //# debugId=ED7856721CA5881564756E2164756E21
5
5
  //# sourceMappingURL=plugins.js.map
package/dist/services.js CHANGED
@@ -1,5 +1,5 @@
1
1
  // @bun
2
- import{Of as o,Pf as t,Qf as n,Sf as r,We as i,Wf as a,Xf as c,Yf as p,Zf as s}from"./chunks/index-hp3ma2a0.js";import"./chunks/index-3rzkj0kw.js";import"./chunks/index-3w748142.js";import{Yh as e}from"./chunks/index-18h53jey.js";import"./chunks/index-j1kv20z9.js";export{i as contentGenerationResultSchema,p as defineAccountSettings,o as defineDashboardWidget,n as defineEntityCatalog,a as defineJob,s as defineServicePlugin,t as defineStudioWorkspace,c as defineTool,r as defineWorkspaceAction,e as z};
2
+ import{Of as o,Pf as t,Qf as n,Sf as r,We as i,Wf as a,Xf as c,Yf as p,Zf as s}from"./chunks/index-aft95ek4.js";import"./chunks/index-3rzkj0kw.js";import"./chunks/index-3w748142.js";import{Yh as e}from"./chunks/index-18h53jey.js";import"./chunks/index-j1kv20z9.js";export{i as contentGenerationResultSchema,p as defineAccountSettings,o as defineDashboardWidget,n as defineEntityCatalog,a as defineJob,s as defineServicePlugin,t as defineStudioWorkspace,c as defineTool,r as defineWorkspaceAction,e as z};
3
3
 
4
4
  //# debugId=FF874852AD2B368064756E2164756E21
5
5
  //# sourceMappingURL=services.js.map
@@ -277,7 +277,7 @@
277
277
  "import {\n sqliteTable,\n text,\n integer,\n index,\n type SQLiteTableWithColumns,\n} from \"drizzle-orm/sqlite-core\";\nimport { createId } from \"@brains/utils/id\";\nimport type {\n SqliteIntegerColumn,\n SqliteJsonColumn,\n SqliteTextColumn,\n} from \"@brains/db\";\nimport type { JobContext } from \"./types\";\nimport type { ProgressNotification } from \"@brains/utils/progress\";\n\ntype JobQueueStatus = \"pending\" | \"processing\" | \"completed\" | \"failed\";\n\ntype JobQueueTextColumn<\n TName extends string,\n TNotNull extends boolean,\n THasDefault extends boolean = false,\n TPrimaryKey extends boolean = false,\n THasRuntimeDefault extends boolean = false,\n TData = string,\n TEnumValues extends [string, ...string[]] = [string, ...string[]],\n> = SqliteTextColumn<\n \"job_queue\",\n TName,\n TNotNull,\n THasDefault,\n TPrimaryKey,\n THasRuntimeDefault,\n TData,\n TEnumValues\n>;\n\ntype JobQueueIntegerColumn<\n TName extends string,\n TNotNull extends boolean,\n THasDefault extends boolean = false,\n THasRuntimeDefault extends boolean = false,\n> = SqliteIntegerColumn<\n \"job_queue\",\n TName,\n TNotNull,\n THasDefault,\n THasRuntimeDefault\n>;\n\ntype JobQueueJsonColumn<\n TName extends string,\n TData,\n TNotNull extends boolean,\n TExtraConfig extends object = Record<string, never>,\n> = SqliteJsonColumn<\"job_queue\", TName, TData, TNotNull, TExtraConfig>;\n\ntype JobWorkerSessionsTable = SQLiteTableWithColumns<{\n name: \"job_worker_sessions\";\n schema: undefined;\n columns: {\n slotId: SqliteTextColumn<\n \"job_worker_sessions\",\n \"slotId\",\n true,\n false,\n true\n >;\n sessionId: SqliteTextColumn<\"job_worker_sessions\", \"sessionId\", true>;\n startedAt: SqliteIntegerColumn<\"job_worker_sessions\", \"startedAt\", true>;\n heartbeatAt: SqliteIntegerColumn<\n \"job_worker_sessions\",\n \"heartbeatAt\",\n true\n >;\n expiresAt: SqliteIntegerColumn<\n \"job_worker_sessions\",\n \"expiresAt\",\n true,\n true\n >;\n };\n dialect: \"sqlite\";\n}>;\n\ntype JobQueueTable = SQLiteTableWithColumns<{\n name: \"job_queue\";\n schema: undefined;\n columns: {\n id: JobQueueTextColumn<\"id\", true, true, true, true>;\n type: JobQueueTextColumn<\"type\", true>;\n data: JobQueueTextColumn<\"data\", true>;\n result: JobQueueJsonColumn<\"result\", unknown, false>;\n progress: JobQueueJsonColumn<\n \"progress\",\n ProgressNotification,\n false,\n { $type: ProgressNotification }\n >;\n source: JobQueueTextColumn<\"source\", false>;\n metadata: JobQueueJsonColumn<\n \"metadata\",\n JobContext,\n true,\n { $type: JobContext }\n >;\n status: JobQueueTextColumn<\n \"status\",\n true,\n true,\n false,\n false,\n JobQueueStatus,\n [\"pending\", \"processing\", \"completed\", \"failed\"]\n >;\n priority: JobQueueIntegerColumn<\"priority\", true, true>;\n retryCount: JobQueueIntegerColumn<\"retryCount\", true, true>;\n maxRetries: JobQueueIntegerColumn<\"maxRetries\", true, true>;\n lastError: JobQueueTextColumn<\"lastError\", false>;\n createdAt: JobQueueIntegerColumn<\"createdAt\", true, true, true>;\n scheduledFor: JobQueueIntegerColumn<\"scheduledFor\", true, true, true>;\n startedAt: JobQueueIntegerColumn<\"startedAt\", false>;\n completedAt: JobQueueIntegerColumn<\"completedAt\", false>;\n attemptId: JobQueueTextColumn<\"attemptId\", false>;\n workerSlotId: JobQueueTextColumn<\"workerSlotId\", false>;\n workerSessionId: JobQueueTextColumn<\"workerSessionId\", false>;\n leaseExpiresAt: JobQueueIntegerColumn<\"leaseExpiresAt\", false>;\n attemptHeartbeatAt: JobQueueIntegerColumn<\"attemptHeartbeatAt\", false>;\n runtimeUpdatedAt: JobQueueIntegerColumn<\"runtimeUpdatedAt\", false>;\n };\n dialect: \"sqlite\";\n}>;\n\n/**\n * Generic job queue table for async background processing\n * Supports different job types with discriminated unions\n */\n// Internal use only - DO NOT re-export from package index\n// Exporting this table causes TypeScript type explosion in consuming packages\nexport const jobQueue: JobQueueTable = sqliteTable(\n \"job_queue\",\n {\n // Queue item ID (unique job ID)\n id: text(\"id\")\n .primaryKey()\n .$defaultFn(() => createId()),\n\n // Job type for handler dispatch\n type: text(\"type\").notNull(),\n\n // Job data (JSON string - type-specific payload)\n data: text(\"data\").notNull(),\n\n // Job result (JSON - type-specific result after completion)\n result: text(\"result\", { mode: \"json\" }),\n\n // Latest durable progress snapshot for cross-process observers.\n progress: text(\"progress\", { mode: \"json\" }).$type<ProgressNotification>(),\n\n // Job source (who created this job)\n source: text(\"source\"),\n\n // Job metadata (additional context for progress events)\n metadata: text(\"metadata\", { mode: \"json\" }).$type<JobContext>().notNull(),\n\n // Queue metadata\n status: text(\"status\", {\n enum: [\"pending\", \"processing\", \"completed\", \"failed\"],\n })\n .notNull()\n .default(\"pending\"),\n priority: integer(\"priority\").notNull().default(0),\n retryCount: integer(\"retryCount\").notNull().default(0),\n maxRetries: integer(\"maxRetries\").notNull().default(3),\n lastError: text(\"lastError\"),\n\n // Timestamps\n createdAt: integer(\"createdAt\")\n .notNull()\n .$defaultFn(() => Date.now()),\n scheduledFor: integer(\"scheduledFor\")\n .notNull()\n .$defaultFn(() => Date.now()),\n startedAt: integer(\"startedAt\"),\n completedAt: integer(\"completedAt\"),\n\n // Processing-attempt ownership and fencing.\n attemptId: text(\"attemptId\"),\n workerSlotId: text(\"workerSlotId\"),\n workerSessionId: text(\"workerSessionId\"),\n leaseExpiresAt: integer(\"leaseExpiresAt\"),\n attemptHeartbeatAt: integer(\"attemptHeartbeatAt\"),\n runtimeUpdatedAt: integer(\"runtimeUpdatedAt\"),\n },\n (table) => ({\n // Index for efficient queue operations (ready to process)\n queueReadyIdx: index(\"idx_job_queue_ready\").on(\n table.status,\n table.priority,\n table.scheduledFor,\n ),\n // Index for job type filtering\n jobTypeIdx: index(\"idx_job_queue_type\").on(table.type, table.status),\n // Cover durable progress polling by (updatedAt, jobId).\n runtimeUpdatesIdx: index(\"idx_job_queue_runtime_updates\").on(\n table.runtimeUpdatedAt,\n table.id,\n ),\n // Index for source filtering\n jobSourceIdx: index(\"idx_job_queue_source\").on(table.source),\n }),\n);\n\n/** One live session per stable worker slot. */\nexport const jobWorkerSessions: JobWorkerSessionsTable = sqliteTable(\n \"job_worker_sessions\",\n {\n slotId: text(\"slotId\").primaryKey(),\n sessionId: text(\"sessionId\").notNull().unique(),\n startedAt: integer(\"startedAt\").notNull(),\n heartbeatAt: integer(\"heartbeatAt\").notNull(),\n expiresAt: integer(\"expiresAt\").notNull().default(0),\n },\n (table) => ({\n heartbeatIdx: index(\"idx_job_worker_sessions_heartbeat\").on(\n table.heartbeatAt,\n ),\n expiresIdx: index(\"idx_job_worker_sessions_expires\").on(table.expiresAt),\n }),\n);\n\n/**\n * Type exports\n */\nexport type InsertJobQueue = typeof jobQueue.$inferInsert;\nexport type JobQueue = typeof jobQueue.$inferSelect;\nexport type JobWorkerSession = typeof jobWorkerSessions.$inferSelect;\n\nexport type JobStatus = JobQueue[\"status\"];\n",
278
278
  "/**\n * Serialization primitives for async critical sections.\n *\n * Both queues admit operations strictly in submission order, one at a time,\n * and keep serving after a rejected operation.\n */\n\n/** Runs async operations one at a time in submission order. */\nexport class SerialQueue {\n private tail: Promise<void> = Promise.resolve();\n\n /**\n * Run `operation` after every previously submitted operation has settled.\n *\n * With a `signal`: aborting before the operation is admitted rejects with\n * the abort reason and the queued slot self-cancels when its turn arrives;\n * aborting after admission has no effect.\n */\n public run<T>(\n operation: () => Promise<T> | T,\n signal?: AbortSignal,\n ): Promise<T> {\n if (signal?.aborted) return Promise.reject(signal.reason);\n\n const previous = this.tail;\n let release = (): void => {};\n this.tail = new Promise<void>((resolve) => {\n release = resolve;\n });\n\n let admitted = false;\n const turn = previous.then(async () => {\n try {\n admitted = true;\n signal?.throwIfAborted();\n return await operation();\n } finally {\n release();\n }\n });\n return waitForTurn(turn, () => admitted, signal);\n }\n\n /** Resolves once every operation submitted so far has settled. */\n public async idle(): Promise<void> {\n await this.tail;\n }\n}\n\n/**\n * Independent serial queues keyed by string, created on first use and\n * discarded once a key's last operation settles.\n */\nexport class KeyedSerialQueue {\n private readonly tails = new Map<string, Promise<void>>();\n\n /** Run `operation` after every prior operation submitted for `key`. */\n public async run<T>(\n key: string,\n operation: () => Promise<T> | T,\n ): Promise<T> {\n const previous = this.tails.get(key);\n let release = (): void => {};\n const current = new Promise<void>((resolve) => {\n release = resolve;\n });\n this.tails.set(key, current);\n await previous;\n try {\n return await operation();\n } finally {\n release();\n if (this.tails.get(key) === current) {\n this.tails.delete(key);\n }\n }\n }\n}\n\nfunction waitForTurn<T>(\n turn: Promise<T>,\n isAdmitted: () => boolean,\n signal?: AbortSignal,\n): Promise<T> {\n if (!signal) return turn;\n\n return new Promise<T>((resolve, reject) => {\n const onAbort = (): void => {\n if (!isAdmitted()) reject(signal.reason);\n };\n signal.addEventListener(\"abort\", onAbort, { once: true });\n\n void turn.then(resolve, reject).finally(() => {\n signal.removeEventListener(\"abort\", onAbort);\n });\n });\n}\n\n/**\n * Coalesces concurrent callers onto one run of the work.\n *\n * The opposite of the queues above: they admit every call and run each one in\n * turn, while this admits the first and hands everyone else its result. Use it\n * where a second identical request must not become a second effect — creating\n * one invitation per idempotency key, recovering an interrupted delivery once.\n *\n * The slot is released once the work settles, so a later call starts fresh\n * rather than replaying a stale result. A rejection is reported to every\n * caller that joined, and also releases.\n */\nexport class SingleFlight<T> {\n private active: Promise<T> | undefined;\n\n public run(operation: () => Promise<T>): Promise<T> {\n if (this.active) return this.active;\n\n const current = operation().finally(() => {\n // Only the run that still owns the slot may clear it. Without this a\n // finishing call could evict a later one that has already taken over.\n if (this.active === current) this.active = undefined;\n });\n this.active = current;\n return current;\n }\n}\n\n/** {@link SingleFlight}, one flight per key. */\nexport class KeyedSingleFlight<T> {\n private readonly active = new Map<string, Promise<T>>();\n\n public run(key: string, operation: () => Promise<T>): Promise<T> {\n const existing = this.active.get(key);\n if (existing) return existing;\n\n const current = operation().finally(() => {\n if (this.active.get(key) === current) this.active.delete(key);\n });\n this.active.set(key, current);\n return current;\n }\n}\n",
279
279
  "import { z } from \"@brains/utils/zod\";\nimport type { DeduplicationStrategy } from \"./schema/types\";\nimport { JOB_STATUS } from \"./schemas\";\n\nexport interface DeduplicationCandidate {\n id: string;\n status: \"pending\" | \"processing\";\n createdAt: number;\n metadata: unknown;\n}\n\nconst deduplicatedJobMetadataSchema = z.looseObject({\n deduplicationKey: z.string().optional(),\n});\n\n/**\n * Applies queue deduplication policy to active jobs of a single type.\n */\nexport class JobDeduplicator {\n public findDuplicate(\n activeJobs: DeduplicationCandidate[],\n strategy?: DeduplicationStrategy,\n deduplicationKey?: string,\n ): DeduplicationCandidate | null {\n if (!strategy || strategy === \"none\") {\n return null;\n }\n\n const matchingJobs = this.filterByDeduplicationKey(\n activeJobs,\n deduplicationKey,\n ).sort(\n (left, right) =>\n right.createdAt - left.createdAt || right.id.localeCompare(left.id),\n );\n const pending =\n matchingJobs.find((job) => job.status === JOB_STATUS.PENDING) ?? null;\n\n if (strategy === \"skip\" || strategy === \"replace\") {\n return pending;\n }\n\n return pending ?? matchingJobs[0] ?? null;\n }\n\n private filterByDeduplicationKey(\n jobs: DeduplicationCandidate[],\n deduplicationKey?: string,\n ): DeduplicationCandidate[] {\n if (!deduplicationKey) {\n return jobs;\n }\n\n return jobs.filter((job) => {\n const metadata = deduplicatedJobMetadataSchema.safeParse(job.metadata);\n return (\n metadata.success && metadata.data.deduplicationKey === deduplicationKey\n );\n });\n }\n}\n",
280
- "import {\n and,\n asc,\n desc,\n eq,\n exists,\n gt,\n inArray,\n isNull,\n lte,\n notExists,\n or,\n sql,\n type SQL,\n} from \"drizzle-orm\";\nimport { jobQueue, jobWorkerSessions } from \"./schema/job-queue\";\nimport type { InsertJobQueue, JobQueue } from \"./schema/job-queue\";\nimport { getErrorMessage } from \"@brains/utils/error\";\nimport type { Logger } from \"@brains/utils/logger\";\nimport { KeyedSerialQueue } from \"@brains/utils/serial-queue\";\nimport { JOB_STATUS } from \"./schemas\";\nimport type { LibSQLDatabase } from \"drizzle-orm/libsql\";\nimport type { Row, Transaction } from \"@libsql/client\";\nimport {\n DEFAULT_WORKER_SESSION_TIMEOUT_MS,\n type JobInfo,\n type JobQueueDiagnostics,\n type JobQueueStats,\n type JobRuntimeUpdate,\n type JobRuntimeUpdateCursor,\n} from \"./types\";\nimport type { ProgressNotification } from \"@brains/utils/progress\";\nimport type { DeduplicationStrategy } from \"./schema/types\";\nimport {\n JobDeduplicator,\n type DeduplicationCandidate,\n} from \"./job-deduplicator\";\n\nexport interface JobAttemptClaim {\n now: number;\n attemptId: string;\n workerSlotId: string;\n workerSessionId: string;\n leaseDurationMs: number;\n executableTypes: readonly string[];\n}\n\nexport interface RetireUnownedActiveJobRequest {\n jobId: string;\n expectedType: string;\n reason: string;\n now: number;\n}\n\nexport type EnqueueDecision =\n | { kind: \"inserted\"; jobId: string }\n | { kind: \"skipped\"; jobId: string }\n | { kind: \"coalesced\"; jobId: string }\n | { kind: \"replaced\"; jobId: string; replacedJobId: string };\n\nexport interface AtomicJobData {\n id: string;\n type: string;\n data: string;\n status: \"pending\";\n priority: number;\n retryCount: number;\n maxRetries: number;\n lastError: string | null;\n createdAt: number;\n scheduledFor: number;\n startedAt: number | null;\n completedAt: number | null;\n source: string | null;\n metadata: JobInfo[\"metadata\"];\n result: unknown;\n}\n\nexport interface AtomicEnqueueRequest {\n jobData: AtomicJobData;\n strategy?: DeduplicationStrategy | undefined;\n deduplicationKey?: string | undefined;\n beforeInsert?: (() => Promise<void>) | undefined;\n onInsertRollback?: (() => void) | undefined;\n}\n\nexport interface JobQueueWriteTransactionClient {\n transaction(mode: \"write\"): Promise<Transaction>;\n}\n\n// App-level retries stand in for SQLite's busy_timeout (which would block the\n// event loop on local libSQL), so the retry budget is time, not attempts: it\n// must absorb a slow winner's entire write-transaction stream under real\n// cross-process contention. Any fixed attempt cap re-becomes a flake on a\n// starved runner.\nconst WRITE_RETRY_BUDGET_MS = 2_000;\nconst WRITE_RETRY_BASE_DELAY_MS = 5;\nconst WRITE_RETRY_MAX_DELAY_MS = 40;\n\n// Local libSQL begins a write transaction synchronously and can block the event\n// loop on busy_timeout when two clients in one process share a file. This turn\n// queue prevents that self-deadlock; the explicit database write transaction is\n// still the correctness boundary across processes.\nconst writeTransactions = new KeyedSerialQueue();\n\n/**\n * Database-backed job queue operations.\n * Keeps persistence details out of JobQueueService orchestration logic.\n */\nexport class JobQueueRepository {\n private db: LibSQLDatabase<Record<string, unknown>>;\n private transactionClient: JobQueueWriteTransactionClient;\n private databaseUrl: string;\n private logger: Logger;\n private deduplicator = new JobDeduplicator();\n private writeRetryBudgetMs: number;\n\n constructor(\n db: LibSQLDatabase<Record<string, unknown>>,\n transactionClient: JobQueueWriteTransactionClient,\n databaseUrl: string,\n logger: Logger,\n options?: { writeRetryBudgetMs?: number },\n ) {\n this.db = db;\n this.transactionClient = transactionClient;\n this.databaseUrl = databaseUrl;\n this.logger = logger.child(\"JobQueueRepository\");\n this.writeRetryBudgetMs =\n options?.writeRetryBudgetMs ?? WRITE_RETRY_BUDGET_MS;\n }\n\n public async insert(jobData: InsertJobQueue): Promise<void> {\n await this.db.insert(jobQueue).values(jobData);\n }\n\n public async enqueueAtomic(\n request: AtomicEnqueueRequest,\n ): Promise<EnqueueDecision> {\n return writeTransactions.run(this.databaseUrl, async () => {\n const deadline = Date.now() + this.writeRetryBudgetMs;\n return this.attemptEnqueue(request, deadline, 1);\n });\n }\n\n /**\n * One complete transaction per step. A failed COMMIT can leave libSQL with a\n * statement in progress, so a conflict retries the whole transaction rather\n * than reusing a poisoned one; the previous one is always closed first.\n */\n private async attemptEnqueue(\n request: AtomicEnqueueRequest,\n deadline: number,\n commitAttempt: number,\n ): Promise<EnqueueDecision> {\n const outcome = await this.runEnqueueTransaction(\n request,\n deadline,\n commitAttempt,\n );\n if (outcome.retry)\n return this.attemptEnqueue(request, deadline, commitAttempt + 1);\n return outcome.decision;\n }\n\n private async runEnqueueTransaction(\n request: AtomicEnqueueRequest,\n deadline: number,\n commitAttempt: number,\n ): Promise<{ retry: true } | { retry: false; decision: EnqueueDecision }> {\n const transaction = await this.acquireWriteTransaction(request, deadline);\n const insertPreparation = { prepared: false };\n let commitStarted = false;\n\n try {\n const decision = await this.decideEnqueue(transaction, request, () => {\n insertPreparation.prepared = true;\n });\n commitStarted = true;\n await transaction.commit();\n return { retry: false, decision };\n } catch (error) {\n const retryCommit =\n commitStarted &&\n this.isSerializationConflict(error) &&\n !transaction.closed;\n let rollbackSucceeded = true;\n\n if (!transaction.closed) {\n try {\n await transaction.rollback();\n } catch (rollbackError) {\n rollbackSucceeded = false;\n this.logger.error(\"Failed to roll back atomic enqueue\", {\n type: request.jobData.type,\n rollbackError,\n });\n }\n }\n if (insertPreparation.prepared) request.onInsertRollback?.();\n if (!retryCommit || !rollbackSucceeded) throw error;\n\n await this.waitForConflictRetry(\n \"commit\",\n request,\n deadline,\n commitAttempt,\n error,\n );\n return { retry: true };\n } finally {\n transaction.close();\n }\n }\n\n private async decideEnqueue(\n transaction: Transaction,\n request: AtomicEnqueueRequest,\n markInsertPrepared: () => void,\n ): Promise<EnqueueDecision> {\n const { jobData, strategy, deduplicationKey, beforeInsert } = request;\n const effectiveStrategy = strategy ?? \"none\";\n const prepareInsert = async (): Promise<void> => {\n await beforeInsert?.();\n markInsertPrepared();\n };\n const insert = async (): Promise<void> => {\n await prepareInsert();\n await this.insertJob(transaction, jobData);\n };\n\n if (effectiveStrategy === \"none\") {\n await insert();\n return { kind: \"inserted\", jobId: jobData.id };\n }\n\n const activeJobs = await this.getActiveDeduplicationCandidates(\n transaction,\n jobData.type,\n );\n const duplicate = this.deduplicator.findDuplicate(\n activeJobs,\n effectiveStrategy,\n deduplicationKey,\n );\n\n if (!duplicate) {\n await insert();\n return { kind: \"inserted\", jobId: jobData.id };\n }\n\n if (effectiveStrategy === \"skip\") {\n return { kind: \"skipped\", jobId: duplicate.id };\n }\n\n if (effectiveStrategy === \"coalesce\") {\n const updated = await transaction.execute({\n sql: \"UPDATE `job_queue` SET `scheduledFor` = ? WHERE `id` = ? AND `status` = ?\",\n args: [Date.now(), duplicate.id, duplicate.status],\n });\n if (updated.rowsAffected !== 1) {\n throw new Error(`Atomic coalesce lost selected job ${duplicate.id}`);\n }\n return { kind: \"coalesced\", jobId: duplicate.id };\n }\n\n await prepareInsert();\n const now = Date.now();\n const replaced = await transaction.execute({\n sql: `UPDATE \\`job_queue\\`\n SET \\`status\\` = ?,\n \\`lastError\\` = ?,\n \\`completedAt\\` = ?,\n \\`runtimeUpdatedAt\\` = max(?, coalesce((SELECT max(\\`runtimeUpdatedAt\\`) FROM \\`job_queue\\`), 0) + 1)\n WHERE \\`id\\` = ? AND \\`status\\` = ?`,\n args: [\n JOB_STATUS.FAILED,\n \"Replaced by newer job\",\n now,\n now,\n duplicate.id,\n JOB_STATUS.PENDING,\n ],\n });\n if (replaced.rowsAffected !== 1) {\n throw new Error(`Atomic replace lost selected job ${duplicate.id}`);\n }\n await this.insertJob(transaction, jobData);\n return {\n kind: \"replaced\",\n jobId: jobData.id,\n replacedJobId: duplicate.id,\n };\n }\n\n private async getActiveDeduplicationCandidates(\n transaction: Transaction,\n type: string,\n ): Promise<DeduplicationCandidate[]> {\n const result = await transaction.execute({\n sql: `SELECT \\`id\\`, \\`status\\`, \\`createdAt\\`, \\`metadata\\`\n FROM \\`job_queue\\`\n WHERE \\`type\\` = ? AND \\`status\\` IN (?, ?)`,\n args: [type, JOB_STATUS.PENDING, JOB_STATUS.PROCESSING],\n });\n return result.rows.map((row) => this.parseDeduplicationCandidate(row));\n }\n\n private parseDeduplicationCandidate(row: Row): DeduplicationCandidate {\n const id = row[\"id\"];\n const status = row[\"status\"];\n const createdAt = row[\"createdAt\"];\n const rawMetadata = row[\"metadata\"];\n if (\n typeof id !== \"string\" ||\n (status !== JOB_STATUS.PENDING && status !== JOB_STATUS.PROCESSING) ||\n (typeof createdAt !== \"number\" && typeof createdAt !== \"bigint\") ||\n typeof rawMetadata !== \"string\"\n ) {\n throw new Error(\"Invalid active job row returned during atomic enqueue\");\n }\n\n const metadata: unknown = JSON.parse(rawMetadata);\n return {\n id,\n status,\n createdAt: Number(createdAt),\n metadata,\n };\n }\n\n private async insertJob(\n transaction: Transaction,\n jobData: AtomicJobData,\n ): Promise<void> {\n const inserted = await transaction.execute({\n sql: `INSERT INTO \\`job_queue\\` (\n \\`id\\`, \\`type\\`, \\`data\\`, \\`result\\`, \\`source\\`, \\`metadata\\`,\n \\`status\\`, \\`priority\\`, \\`retryCount\\`, \\`maxRetries\\`,\n \\`lastError\\`, \\`createdAt\\`, \\`scheduledFor\\`, \\`startedAt\\`,\n \\`completedAt\\`\n ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,\n args: [\n jobData.id,\n jobData.type,\n jobData.data,\n this.serializeJson(jobData.result),\n jobData.source,\n JSON.stringify(jobData.metadata),\n jobData.status,\n jobData.priority,\n jobData.retryCount,\n jobData.maxRetries,\n jobData.lastError,\n jobData.createdAt,\n jobData.scheduledFor,\n jobData.startedAt,\n jobData.completedAt,\n ],\n });\n if (inserted.rowsAffected !== 1) {\n throw new Error(`Atomic enqueue did not insert job ${jobData.id}`);\n }\n }\n\n private serializeJson(value: unknown): string | null {\n if (value === undefined || value === null) return null;\n return JSON.stringify(value);\n }\n\n private acquireWriteTransaction(\n request: AtomicEnqueueRequest,\n deadline: number,\n ): Promise<Transaction> {\n return this.retryOnConflict(\n \"acquire\",\n request,\n () => this.transactionClient.transaction(\"write\"),\n (error) => this.isSerializationConflict(error),\n deadline,\n );\n }\n\n private async waitForConflictRetry(\n phase: \"acquire\" | \"commit\",\n request: AtomicEnqueueRequest,\n deadline: number,\n attempt: number,\n cause: unknown,\n ): Promise<void> {\n const backoff = Math.min(\n WRITE_RETRY_BASE_DELAY_MS * 2 ** (attempt - 1),\n WRITE_RETRY_MAX_DELAY_MS,\n );\n // Jitter half the window so contending processes fall out of lockstep.\n const delay = backoff / 2 + Math.random() * (backoff / 2);\n if (Date.now() + delay >= deadline) {\n throw this.transactionConflictError(phase, request, attempt, cause);\n }\n await new Promise((resolve) => setTimeout(resolve, delay));\n }\n\n private async retryOnConflict<T>(\n phase: \"acquire\" | \"commit\",\n request: AtomicEnqueueRequest,\n operation: () => Promise<T>,\n isRetryable: (error: unknown) => boolean,\n deadline = Date.now() + this.writeRetryBudgetMs,\n attempt = 1,\n ): Promise<T> {\n try {\n return await operation();\n } catch (error) {\n if (!isRetryable(error)) throw error;\n await this.waitForConflictRetry(phase, request, deadline, attempt, error);\n return this.retryOnConflict(\n phase,\n request,\n operation,\n isRetryable,\n deadline,\n attempt + 1,\n );\n }\n }\n\n private transactionConflictError(\n phase: \"acquire\" | \"commit\",\n request: AtomicEnqueueRequest,\n attempts: number,\n cause: unknown,\n ): Error {\n const strategy = request.strategy ?? \"none\";\n const keyPresence = request.deduplicationKey ? \"present\" : \"absent\";\n return new Error(\n `Failed to ${phase} atomic enqueue transaction for type \"${request.jobData.type}\" within ${this.writeRetryBudgetMs}ms after ${attempts} attempts (strategy: ${strategy}, key: ${keyPresence})`,\n { cause },\n );\n }\n\n private isSerializationConflict(error: unknown): boolean {\n for (let current = error; current !== undefined;) {\n const code =\n typeof current === \"object\" && current !== null && \"code\" in current\n ? String(current.code)\n : \"\";\n const message = getErrorMessage(current, \"\");\n if (\n /SQLITE_(?:BUSY|LOCKED)/u.test(code) ||\n /SQLITE_(?:BUSY|LOCKED)|database is locked|transaction (?:busy|conflict)/iu.test(\n message,\n )\n ) {\n return true;\n }\n current = current instanceof Error ? current.cause : undefined;\n }\n return false;\n }\n\n /** Atomically register a new session, superseding the slot's prior session. */\n public async startWorkerSession(\n workerSlotId: string,\n workerSessionId: string,\n now: number = Date.now(),\n workerSessionTimeoutMs: number = DEFAULT_WORKER_SESSION_TIMEOUT_MS,\n ): Promise<void> {\n const expiresAt = now + workerSessionTimeoutMs;\n await this.db\n .insert(jobWorkerSessions)\n .values({\n slotId: workerSlotId,\n sessionId: workerSessionId,\n startedAt: now,\n heartbeatAt: now,\n expiresAt,\n })\n .onConflictDoUpdate({\n target: jobWorkerSessions.slotId,\n set: {\n sessionId: workerSessionId,\n startedAt: now,\n heartbeatAt: now,\n expiresAt,\n },\n });\n }\n\n /** Update liveness only if this session still owns the stable slot. */\n public async heartbeatWorkerSession(\n workerSlotId: string,\n workerSessionId: string,\n now: number = Date.now(),\n workerSessionTimeoutMs: number = DEFAULT_WORKER_SESSION_TIMEOUT_MS,\n ): Promise<boolean> {\n const result = await this.db\n .update(jobWorkerSessions)\n .set({\n heartbeatAt: now,\n expiresAt: now + workerSessionTimeoutMs,\n })\n .where(\n and(\n eq(jobWorkerSessions.slotId, workerSlotId),\n eq(jobWorkerSessions.sessionId, workerSessionId),\n ),\n )\n .returning({ slotId: jobWorkerSessions.slotId });\n return result.length === 1;\n }\n\n /** Remove a normally stopped session without touching a replacement. */\n public async endWorkerSession(\n workerSlotId: string,\n workerSessionId: string,\n ): Promise<boolean> {\n const result = await this.db\n .delete(jobWorkerSessions)\n .where(\n and(\n eq(jobWorkerSessions.slotId, workerSlotId),\n eq(jobWorkerSessions.sessionId, workerSessionId),\n ),\n )\n .returning({ slotId: jobWorkerSessions.slotId });\n return result.length === 1;\n }\n\n /** Mark a job completed only if the supplied attempt still owns it. */\n public async complete(\n jobId: string,\n result: unknown,\n attemptId?: string,\n ): Promise<boolean> {\n const now = Date.now();\n const updated = await this.db\n .update(jobQueue)\n .set({\n status: JOB_STATUS.COMPLETED,\n result,\n lastError: null,\n completedAt: now,\n runtimeUpdatedAt: this.nextRuntimeUpdatedAt(now),\n })\n .where(this.attemptWriteGuard(jobId, attemptId))\n .returning({ id: jobQueue.id });\n\n const applied = updated.length === 1;\n if (applied) {\n this.logger.debug(\"Job completed\", {\n jobId,\n hasResult: result !== undefined,\n });\n }\n return applied;\n }\n\n /** Update job data only if the supplied attempt still owns it. */\n public async update(\n jobId: string,\n data: unknown,\n attemptId?: string,\n ): Promise<boolean> {\n const updated = await this.db\n .update(jobQueue)\n .set({ data: JSON.stringify(data) })\n .where(this.attemptWriteGuard(jobId, attemptId))\n .returning({ id: jobQueue.id });\n\n const applied = updated.length === 1;\n if (applied) this.logger.debug(\"Job data updated\", { jobId });\n return applied;\n }\n\n /** Record progress only while the attempt token remains current. */\n public async recordAttemptProgress(\n jobId: string,\n attemptId: string,\n progress: ProgressNotification,\n now: number = Date.now(),\n ): Promise<boolean> {\n const updated = await this.db\n .update(jobQueue)\n .set({\n attemptHeartbeatAt: now,\n runtimeUpdatedAt: this.nextRuntimeUpdatedAt(now),\n progress,\n })\n .where(this.attemptWriteGuard(jobId, attemptId))\n .returning({ id: jobQueue.id });\n return updated.length === 1;\n }\n\n /** Renew the attempt heartbeat and lease under the fencing token. */\n public async renewAttemptLease(\n jobId: string,\n attemptId: string,\n now: number,\n leaseDurationMs: number,\n ): Promise<boolean> {\n const updated = await this.db\n .update(jobQueue)\n .set({\n attemptHeartbeatAt: now,\n leaseExpiresAt: now + leaseDurationMs,\n })\n .where(this.attemptWriteGuard(jobId, attemptId))\n .returning({ id: jobQueue.id });\n return updated.length === 1;\n }\n\n /** Mark a current attempt failed and apply bounded retry policy. */\n public async fail(\n jobId: string,\n error: Error,\n attemptId?: string,\n now: number = Date.now(),\n ): Promise<boolean> {\n const current = await this.db\n .select({\n retryCount: jobQueue.retryCount,\n maxRetries: jobQueue.maxRetries,\n type: jobQueue.type,\n scheduledFor: jobQueue.scheduledFor,\n startedAt: jobQueue.startedAt,\n runtimeUpdatedAt: jobQueue.runtimeUpdatedAt,\n })\n .from(jobQueue)\n .where(this.attemptWriteGuard(jobId, attemptId))\n .limit(1);\n const job = current[0];\n if (!job) return false;\n\n const canRetry = job.retryCount < job.maxRetries;\n const nextRetryCount = canRetry ? job.retryCount + 1 : job.retryCount;\n const backoffMs = Math.min(1000 * 2 ** job.retryCount, 60_000);\n const scheduledFor = canRetry ? now + backoffMs : job.scheduledFor;\n const updated = await this.db\n .update(jobQueue)\n .set({\n status: canRetry ? JOB_STATUS.PENDING : JOB_STATUS.FAILED,\n retryCount: nextRetryCount,\n lastError: error.message,\n scheduledFor,\n completedAt: canRetry ? null : now,\n runtimeUpdatedAt: canRetry\n ? job.runtimeUpdatedAt\n : this.nextRuntimeUpdatedAt(now),\n startedAt: canRetry ? null : job.startedAt,\n attemptId: null,\n workerSlotId: null,\n workerSessionId: null,\n leaseExpiresAt: null,\n attemptHeartbeatAt: null,\n })\n .where(\n and(\n this.attemptWriteGuard(jobId, attemptId),\n eq(jobQueue.retryCount, job.retryCount),\n ),\n )\n .returning({ id: jobQueue.id });\n if (updated.length !== 1) return false;\n\n if (canRetry) {\n this.logger.debug(\"Job scheduled for retry\", {\n jobId,\n retryCount: nextRetryCount,\n backoffMs,\n scheduledFor: new Date(scheduledFor).toISOString(),\n });\n } else {\n this.logger.error(\"Job failed after max retries\", {\n jobId,\n type: job.type,\n retryCount: job.retryCount,\n error: error.message,\n });\n }\n return true;\n }\n\n /**\n * Terminally retire an exact legacy job only when no attempt can still own or\n * mutate it. This is intentionally stricter than fail(): retired handlers\n * cannot consume retries or run terminal callbacks.\n */\n public async retireUnownedActiveJob(\n request: RetireUnownedActiveJobRequest,\n ): Promise<JobInfo | null> {\n const reason = request.reason.trim();\n if (reason.length === 0) {\n throw new Error(\"Retired job reason must not be empty\");\n }\n\n const updated = await this.db\n .update(jobQueue)\n .set({\n status: JOB_STATUS.FAILED,\n lastError: reason,\n completedAt: request.now,\n runtimeUpdatedAt: this.nextRuntimeUpdatedAt(request.now),\n })\n .where(\n and(\n eq(jobQueue.id, request.jobId),\n eq(jobQueue.type, request.expectedType),\n or(\n eq(jobQueue.status, JOB_STATUS.PENDING),\n eq(jobQueue.status, JOB_STATUS.PROCESSING),\n ),\n isNull(jobQueue.attemptId),\n isNull(jobQueue.workerSlotId),\n isNull(jobQueue.workerSessionId),\n isNull(jobQueue.leaseExpiresAt),\n isNull(jobQueue.attemptHeartbeatAt),\n isNull(jobQueue.progress),\n isNull(jobQueue.result),\n ),\n )\n .returning();\n const retired = updated[0] ?? null;\n if (retired) {\n this.logger.warn(\"Retired unowned legacy job\", {\n jobId: retired.id,\n type: retired.type,\n reason,\n });\n }\n return retired;\n }\n\n public async getStatus(jobId: string): Promise<JobInfo | null> {\n const jobs = await this.db\n .select()\n .from(jobQueue)\n .where(eq(jobQueue.id, jobId))\n .limit(1);\n\n return jobs[0] ?? null;\n }\n\n public async getJobsByRootJobId(rootJobId: string): Promise<JobInfo[]> {\n return this.db\n .select()\n .from(jobQueue)\n .where(\n sql`json_extract(${jobQueue.metadata}, '$.rootJobId') = ${rootJobId}`,\n )\n .orderBy(asc(jobQueue.createdAt), asc(jobQueue.id));\n }\n\n public async getStatusByEntityId(entityId: string): Promise<JobInfo | null> {\n const jobs = await this.db\n .select()\n .from(jobQueue)\n .where(sql`json_extract(${jobQueue.data}, '$.id') = ${entityId}`)\n .orderBy(desc(jobQueue.createdAt))\n .limit(1);\n\n return jobs[0] ?? null;\n }\n\n public async getStats(): Promise<JobQueueStats> {\n const stats = await this.db\n .select({\n status: jobQueue.status,\n count: sql<number>`count(*)`,\n })\n .from(jobQueue)\n .groupBy(jobQueue.status);\n\n const result: JobQueueStats = {\n pending: 0,\n processing: 0,\n failed: 0,\n completed: 0,\n total: 0,\n };\n\n for (const row of stats) {\n const count = Number(row.count);\n result[row.status] = count;\n result.total += count;\n }\n\n return result;\n }\n\n public async getDiagnostics(\n now: number = Date.now(),\n ): Promise<JobQueueDiagnostics> {\n const [byTypeRows, ageRows, workerSessionRows] = await Promise.all([\n this.db\n .select({\n type: jobQueue.type,\n status: jobQueue.status,\n count: sql<number>`count(*)`,\n })\n .from(jobQueue)\n .groupBy(jobQueue.type, jobQueue.status),\n this.db\n .select({\n oldestPendingAt: sql<\n number | null\n >`min(case when ${jobQueue.status} = ${JOB_STATUS.PENDING} then ${jobQueue.createdAt} end)`,\n duePending: sql<number>`coalesce(sum(case when ${jobQueue.status} = ${JOB_STATUS.PENDING} and ${jobQueue.scheduledFor} <= ${now} then 1 else 0 end), 0)`,\n oldestDuePendingAt: sql<\n number | null\n >`min(case when ${jobQueue.status} = ${JOB_STATUS.PENDING} and ${jobQueue.scheduledFor} <= ${now} then ${jobQueue.scheduledFor} end)`,\n latestClaimAt: sql<number | null>`max(${jobQueue.startedAt})`,\n oldestProcessingAt: sql<\n number | null\n >`min(case when ${jobQueue.status} = ${JOB_STATUS.PROCESSING} then ${jobQueue.startedAt} end)`,\n staleLeaseCount: sql<number>`coalesce(sum(case when ${jobQueue.status} = ${JOB_STATUS.PROCESSING} and ${jobQueue.leaseExpiresAt} is not null and ${jobQueue.leaseExpiresAt} <= ${now} then 1 else 0 end), 0)`,\n })\n .from(jobQueue),\n this.db\n .select({\n total: sql<number>`count(*)`,\n active: sql<number>`coalesce(sum(case when ${jobWorkerSessions.expiresAt} > ${now} then 1 else 0 end), 0)`,\n stale: sql<number>`coalesce(sum(case when ${jobWorkerSessions.expiresAt} <= ${now} then 1 else 0 end), 0)`,\n latestHeartbeatAt: sql<\n number | null\n >`max(${jobWorkerSessions.heartbeatAt})`,\n })\n .from(jobWorkerSessions),\n ]);\n\n const totals: JobQueueDiagnostics[\"totals\"] = {\n pending: 0,\n processing: 0,\n completed: 0,\n failed: 0,\n };\n const byType = byTypeRows.map((row) => {\n const count = Number(row.count);\n totals[row.status] += count;\n return { type: row.type, status: row.status, count };\n });\n const ages = ageRows[0];\n const workerSessions = workerSessionRows[0];\n const ageSince = (timestamp: number | null | undefined): number | null =>\n timestamp === null || timestamp === undefined\n ? null\n : Math.max(0, now - Number(timestamp));\n\n return {\n totals,\n byType,\n oldestPendingAgeMs: ageSince(ages?.oldestPendingAt),\n duePending: Number(ages?.duePending ?? 0),\n oldestDuePendingAgeMs: ageSince(ages?.oldestDuePendingAt),\n latestClaimAgeMs: ageSince(ages?.latestClaimAt),\n oldestProcessingAgeMs: ageSince(ages?.oldestProcessingAt),\n staleLeaseCount: Number(ages?.staleLeaseCount ?? 0),\n workerSessions: {\n total: Number(workerSessions?.total ?? 0),\n active: Number(workerSessions?.active ?? 0),\n stale: Number(workerSessions?.stale ?? 0),\n latestHeartbeatAgeMs: ageSince(workerSessions?.latestHeartbeatAt),\n },\n };\n }\n\n public async getRuntimeUpdates(\n cursor: JobRuntimeUpdateCursor,\n limit: number,\n ): Promise<JobRuntimeUpdate[]> {\n const rows = await this.db\n .select()\n .from(jobQueue)\n .where(\n sql`(${jobQueue.runtimeUpdatedAt}, ${jobQueue.id}) > (${cursor.updatedAt}, ${cursor.jobId})`,\n )\n .orderBy(asc(jobQueue.runtimeUpdatedAt), asc(jobQueue.id))\n .limit(limit);\n\n return rows.map((job) => ({\n job,\n cursor: {\n updatedAt: job.runtimeUpdatedAt ?? 0,\n jobId: job.id,\n },\n }));\n }\n\n public async cleanup(olderThanMs: number): Promise<number> {\n const cutoff = Date.now() - olderThanMs;\n\n const result = await this.db\n .delete(jobQueue)\n .where(\n and(\n or(\n eq(jobQueue.status, JOB_STATUS.COMPLETED),\n eq(jobQueue.status, JOB_STATUS.FAILED),\n ),\n lte(jobQueue.completedAt, cutoff),\n ),\n );\n\n return result.rowsAffected;\n }\n\n public async getActiveJobs(types?: string[]): Promise<JobInfo[]> {\n const activeStatusFilter = or(\n eq(jobQueue.status, JOB_STATUS.PENDING),\n eq(jobQueue.status, JOB_STATUS.PROCESSING),\n );\n\n const whereClause =\n types && types.length > 0\n ? and(activeStatusFilter, inArray(jobQueue.type, types))\n : activeStatusFilter;\n\n return this.db\n .select()\n .from(jobQueue)\n .where(whereClause)\n .orderBy(desc(jobQueue.createdAt));\n }\n\n public async getRecentJobs(types?: string[], limit = 20): Promise<JobInfo[]> {\n const whereClause =\n types && types.length > 0 ? inArray(jobQueue.type, types) : undefined;\n\n return this.db\n .select()\n .from(jobQueue)\n .where(whereClause)\n .orderBy(desc(jobQueue.createdAt))\n .limit(limit);\n }\n\n public async getFailedJobs(types?: string[]): Promise<JobInfo[]> {\n const failedStatusFilter = eq(jobQueue.status, JOB_STATUS.FAILED);\n const whereClause =\n types && types.length > 0\n ? and(failedStatusFilter, inArray(jobQueue.type, types))\n : failedStatusFilter;\n\n return this.db\n .select()\n .from(jobQueue)\n .where(whereClause)\n .orderBy(desc(jobQueue.createdAt));\n }\n\n /**\n * Atomically claim the highest-priority executable pending or reclaimable job.\n * A superseded session is reclaimable immediately. Another slot's attempt\n * requires both an expired lease and an expired owner-session heartbeat.\n */\n public async claimNextReady(\n claim: JobAttemptClaim,\n ): Promise<JobQueue | null> {\n const {\n now,\n attemptId,\n workerSlotId,\n workerSessionId,\n leaseDurationMs,\n executableTypes,\n } = claim;\n\n const claimingSession = this.db\n .select({ slotId: jobWorkerSessions.slotId })\n .from(jobWorkerSessions)\n .where(\n and(\n eq(jobWorkerSessions.slotId, workerSlotId),\n eq(jobWorkerSessions.sessionId, workerSessionId),\n gt(jobWorkerSessions.expiresAt, now),\n ),\n );\n const matchingOwnerSession = this.db\n .select({ slotId: jobWorkerSessions.slotId })\n .from(jobWorkerSessions)\n .where(\n and(\n eq(jobWorkerSessions.slotId, jobQueue.workerSlotId),\n eq(jobWorkerSessions.sessionId, jobQueue.workerSessionId),\n ),\n );\n const staleOwnerSession = this.db\n .select({ slotId: jobWorkerSessions.slotId })\n .from(jobWorkerSessions)\n .where(\n and(\n eq(jobWorkerSessions.slotId, jobQueue.workerSlotId),\n eq(jobWorkerSessions.sessionId, jobQueue.workerSessionId),\n lte(jobWorkerSessions.expiresAt, now),\n ),\n );\n\n const processingReclaimable = and(\n eq(jobQueue.status, JOB_STATUS.PROCESSING),\n or(\n isNull(jobQueue.attemptId),\n notExists(matchingOwnerSession),\n and(lte(jobQueue.leaseExpiresAt, now), exists(staleOwnerSession)),\n ),\n );\n const terminalReclaim = sql`${jobQueue.status} = ${JOB_STATUS.PROCESSING} AND ${jobQueue.retryCount} + 1 > ${jobQueue.maxRetries}`;\n\n const candidate = this.db\n .select({ id: jobQueue.id })\n .from(jobQueue)\n .where(\n and(\n inArray(jobQueue.type, executableTypes),\n or(\n and(\n eq(jobQueue.status, JOB_STATUS.PENDING),\n lte(jobQueue.scheduledFor, now),\n ),\n processingReclaimable,\n ),\n ),\n )\n .orderBy(asc(jobQueue.priority), asc(jobQueue.createdAt))\n .limit(1);\n\n const result = await this.db\n .update(jobQueue)\n .set({\n status: sql`CASE WHEN ${terminalReclaim} THEN ${JOB_STATUS.FAILED} ELSE ${JOB_STATUS.PROCESSING} END`,\n retryCount: sql`CASE WHEN ${jobQueue.status} = ${JOB_STATUS.PROCESSING} THEN ${jobQueue.retryCount} + 1 ELSE ${jobQueue.retryCount} END`,\n lastError: sql`CASE WHEN ${jobQueue.status} = ${JOB_STATUS.PROCESSING} THEN 'Attempt lease expired' ELSE ${jobQueue.lastError} END`,\n startedAt: sql`CASE WHEN ${terminalReclaim} THEN ${jobQueue.startedAt} ELSE ${now} END`,\n completedAt: sql`CASE WHEN ${terminalReclaim} THEN ${now} ELSE NULL END`,\n attemptId: sql`CASE WHEN ${terminalReclaim} THEN ${jobQueue.attemptId} ELSE ${attemptId} END`,\n workerSlotId: sql`CASE WHEN ${terminalReclaim} THEN ${jobQueue.workerSlotId} ELSE ${workerSlotId} END`,\n workerSessionId: sql`CASE WHEN ${terminalReclaim} THEN ${jobQueue.workerSessionId} ELSE ${workerSessionId} END`,\n leaseExpiresAt: sql`CASE WHEN ${terminalReclaim} THEN ${jobQueue.leaseExpiresAt} ELSE ${now + leaseDurationMs} END`,\n attemptHeartbeatAt: sql`CASE WHEN ${terminalReclaim} THEN ${jobQueue.attemptHeartbeatAt} ELSE ${now} END`,\n runtimeUpdatedAt: sql`CASE WHEN ${terminalReclaim} THEN ${this.nextRuntimeUpdatedAt(now)} ELSE ${jobQueue.runtimeUpdatedAt} END`,\n })\n .where(and(inArray(jobQueue.id, candidate), exists(claimingSession)))\n .returning();\n\n const claimed = result[0];\n if (claimed?.status !== JOB_STATUS.PROCESSING) return null;\n\n this.logger.debug(\"Job claimed\", {\n jobId: claimed.id,\n attemptId: claimed.attemptId,\n workerSlotId,\n workerSessionId,\n });\n return claimed;\n }\n\n /** Allocate a globally increasing cursor value within the serialized write. */\n private nextRuntimeUpdatedAt(now: number): SQL<number> {\n return sql<number>`max(${now}, coalesce((select max(${jobQueue.runtimeUpdatedAt}) from ${jobQueue}), 0) + 1)`;\n }\n\n private attemptWriteGuard(\n jobId: string,\n attemptId?: string,\n ): SQL | undefined {\n return attemptId\n ? and(\n eq(jobQueue.id, jobId),\n eq(jobQueue.status, JOB_STATUS.PROCESSING),\n eq(jobQueue.attemptId, attemptId),\n )\n : and(\n eq(jobQueue.id, jobId),\n isNull(jobQueue.attemptId),\n or(\n eq(jobQueue.status, JOB_STATUS.PENDING),\n eq(jobQueue.status, JOB_STATUS.PROCESSING),\n ),\n );\n }\n}\n",
280
+ "import {\n and,\n asc,\n desc,\n eq,\n exists,\n gt,\n inArray,\n isNull,\n lte,\n notExists,\n or,\n sql,\n type SQL,\n} from \"drizzle-orm\";\nimport { jobQueue, jobWorkerSessions } from \"./schema/job-queue\";\nimport type { InsertJobQueue, JobQueue } from \"./schema/job-queue\";\nimport { getErrorMessage } from \"@brains/utils/error\";\nimport type { Logger } from \"@brains/utils/logger\";\nimport { KeyedSerialQueue } from \"@brains/utils/serial-queue\";\nimport { JOB_STATUS } from \"./schemas\";\nimport type { LibSQLDatabase } from \"drizzle-orm/libsql\";\nimport type { Row, Transaction } from \"@libsql/client\";\nimport {\n DEFAULT_WORKER_SESSION_TIMEOUT_MS,\n type JobInfo,\n type JobQueueDiagnostics,\n type JobQueueStats,\n type JobRuntimeUpdate,\n type JobRuntimeUpdateCursor,\n} from \"./types\";\nimport type { ProgressNotification } from \"@brains/utils/progress\";\nimport type { DeduplicationStrategy } from \"./schema/types\";\nimport {\n JobDeduplicator,\n type DeduplicationCandidate,\n} from \"./job-deduplicator\";\n\nexport interface JobAttemptClaim {\n now: number;\n attemptId: string;\n workerSlotId: string;\n workerSessionId: string;\n leaseDurationMs: number;\n executableTypes: readonly string[];\n}\n\nexport interface RetireUnownedActiveJobRequest {\n jobId: string;\n expectedType: string;\n reason: string;\n now: number;\n}\n\nexport type EnqueueDecision =\n | { kind: \"inserted\"; jobId: string }\n | { kind: \"skipped\"; jobId: string }\n | { kind: \"coalesced\"; jobId: string }\n | { kind: \"replaced\"; jobId: string; replacedJobId: string };\n\nexport interface AtomicJobData {\n id: string;\n type: string;\n data: string;\n status: \"pending\";\n priority: number;\n retryCount: number;\n maxRetries: number;\n lastError: string | null;\n createdAt: number;\n scheduledFor: number;\n startedAt: number | null;\n completedAt: number | null;\n source: string | null;\n metadata: JobInfo[\"metadata\"];\n result: unknown;\n}\n\nexport interface AtomicEnqueueRequest {\n jobData: AtomicJobData;\n strategy?: DeduplicationStrategy | undefined;\n deduplicationKey?: string | undefined;\n beforeInsert?: (() => Promise<void>) | undefined;\n onInsertRollback?: (() => void) | undefined;\n}\n\nexport interface JobQueueWriteTransactionClient {\n transaction(mode: \"write\"): Promise<Transaction>;\n}\n\n// App-level retries stand in for SQLite's busy_timeout (which would block the\n// event loop on local libSQL), so the retry budget is time, not attempts: it\n// must absorb a slow winner's entire write-transaction stream under real\n// cross-process contention. Any fixed attempt cap re-becomes a flake on a\n// starved runner.\nconst WRITE_RETRY_BUDGET_MS = 2_000;\nconst WRITE_RETRY_BASE_DELAY_MS = 5;\nconst WRITE_RETRY_MAX_DELAY_MS = 40;\n\n// Local libSQL begins a write transaction synchronously and can block the event\n// loop on busy_timeout when two clients in one process share a file. This turn\n// queue prevents that self-deadlock; the explicit database write transaction is\n// still the correctness boundary across processes.\nconst writeTransactions = new KeyedSerialQueue();\n\n/**\n * Database-backed job queue operations.\n * Keeps persistence details out of JobQueueService orchestration logic.\n */\nexport class JobQueueRepository {\n private db: LibSQLDatabase<Record<string, unknown>>;\n private transactionClient: JobQueueWriteTransactionClient;\n private databaseUrl: string;\n private logger: Logger;\n private deduplicator = new JobDeduplicator();\n private writeRetryBudgetMs: number;\n\n constructor(\n db: LibSQLDatabase<Record<string, unknown>>,\n transactionClient: JobQueueWriteTransactionClient,\n databaseUrl: string,\n logger: Logger,\n options?: { writeRetryBudgetMs?: number },\n ) {\n this.db = db;\n this.transactionClient = transactionClient;\n this.databaseUrl = databaseUrl;\n this.logger = logger.child(\"JobQueueRepository\");\n this.writeRetryBudgetMs =\n options?.writeRetryBudgetMs ?? WRITE_RETRY_BUDGET_MS;\n }\n\n public async insert(jobData: InsertJobQueue): Promise<void> {\n await this.write(\"insert\", async () =>\n this.db.insert(jobQueue).values(jobData),\n );\n }\n\n public async enqueueAtomic(\n request: AtomicEnqueueRequest,\n ): Promise<EnqueueDecision> {\n return writeTransactions.run(this.databaseUrl, async () => {\n const deadline = Date.now() + this.writeRetryBudgetMs;\n return this.attemptEnqueue(request, deadline, 1);\n });\n }\n\n /**\n * One complete transaction per step. A failed COMMIT can leave libSQL with a\n * statement in progress, so a conflict retries the whole transaction rather\n * than reusing a poisoned one; the previous one is always closed first.\n */\n private async attemptEnqueue(\n request: AtomicEnqueueRequest,\n deadline: number,\n commitAttempt: number,\n ): Promise<EnqueueDecision> {\n const outcome = await this.runEnqueueTransaction(\n request,\n deadline,\n commitAttempt,\n );\n if (outcome.retry)\n return this.attemptEnqueue(request, deadline, commitAttempt + 1);\n return outcome.decision;\n }\n\n private async runEnqueueTransaction(\n request: AtomicEnqueueRequest,\n deadline: number,\n commitAttempt: number,\n ): Promise<{ retry: true } | { retry: false; decision: EnqueueDecision }> {\n const transaction = await this.acquireWriteTransaction(request, deadline);\n const insertPreparation = { prepared: false };\n let commitStarted = false;\n\n try {\n const decision = await this.decideEnqueue(transaction, request, () => {\n insertPreparation.prepared = true;\n });\n commitStarted = true;\n await transaction.commit();\n return { retry: false, decision };\n } catch (error) {\n const retryCommit =\n commitStarted &&\n this.isSerializationConflict(error) &&\n !transaction.closed;\n let rollbackSucceeded = true;\n\n if (!transaction.closed) {\n try {\n await transaction.rollback();\n } catch (rollbackError) {\n rollbackSucceeded = false;\n this.logger.error(\"Failed to roll back atomic enqueue\", {\n type: request.jobData.type,\n rollbackError,\n });\n }\n }\n if (insertPreparation.prepared) request.onInsertRollback?.();\n if (!retryCommit || !rollbackSucceeded) throw error;\n\n await this.waitForConflictRetry(\n \"commit\",\n request,\n deadline,\n commitAttempt,\n error,\n );\n return { retry: true };\n } finally {\n transaction.close();\n }\n }\n\n private async decideEnqueue(\n transaction: Transaction,\n request: AtomicEnqueueRequest,\n markInsertPrepared: () => void,\n ): Promise<EnqueueDecision> {\n const { jobData, strategy, deduplicationKey, beforeInsert } = request;\n const effectiveStrategy = strategy ?? \"none\";\n const prepareInsert = async (): Promise<void> => {\n await beforeInsert?.();\n markInsertPrepared();\n };\n const insert = async (): Promise<void> => {\n await prepareInsert();\n await this.insertJob(transaction, jobData);\n };\n\n if (effectiveStrategy === \"none\") {\n await insert();\n return { kind: \"inserted\", jobId: jobData.id };\n }\n\n const activeJobs = await this.getActiveDeduplicationCandidates(\n transaction,\n jobData.type,\n );\n const duplicate = this.deduplicator.findDuplicate(\n activeJobs,\n effectiveStrategy,\n deduplicationKey,\n );\n\n if (!duplicate) {\n await insert();\n return { kind: \"inserted\", jobId: jobData.id };\n }\n\n if (effectiveStrategy === \"skip\") {\n return { kind: \"skipped\", jobId: duplicate.id };\n }\n\n if (effectiveStrategy === \"coalesce\") {\n const updated = await transaction.execute({\n sql: \"UPDATE `job_queue` SET `scheduledFor` = ? WHERE `id` = ? AND `status` = ?\",\n args: [Date.now(), duplicate.id, duplicate.status],\n });\n if (updated.rowsAffected !== 1) {\n throw new Error(`Atomic coalesce lost selected job ${duplicate.id}`);\n }\n return { kind: \"coalesced\", jobId: duplicate.id };\n }\n\n await prepareInsert();\n const now = Date.now();\n const replaced = await transaction.execute({\n sql: `UPDATE \\`job_queue\\`\n SET \\`status\\` = ?,\n \\`lastError\\` = ?,\n \\`completedAt\\` = ?,\n \\`runtimeUpdatedAt\\` = max(?, coalesce((SELECT max(\\`runtimeUpdatedAt\\`) FROM \\`job_queue\\`), 0) + 1)\n WHERE \\`id\\` = ? AND \\`status\\` = ?`,\n args: [\n JOB_STATUS.FAILED,\n \"Replaced by newer job\",\n now,\n now,\n duplicate.id,\n JOB_STATUS.PENDING,\n ],\n });\n if (replaced.rowsAffected !== 1) {\n throw new Error(`Atomic replace lost selected job ${duplicate.id}`);\n }\n await this.insertJob(transaction, jobData);\n return {\n kind: \"replaced\",\n jobId: jobData.id,\n replacedJobId: duplicate.id,\n };\n }\n\n private async getActiveDeduplicationCandidates(\n transaction: Transaction,\n type: string,\n ): Promise<DeduplicationCandidate[]> {\n const result = await transaction.execute({\n sql: `SELECT \\`id\\`, \\`status\\`, \\`createdAt\\`, \\`metadata\\`\n FROM \\`job_queue\\`\n WHERE \\`type\\` = ? AND \\`status\\` IN (?, ?)`,\n args: [type, JOB_STATUS.PENDING, JOB_STATUS.PROCESSING],\n });\n return result.rows.map((row) => this.parseDeduplicationCandidate(row));\n }\n\n private parseDeduplicationCandidate(row: Row): DeduplicationCandidate {\n const id = row[\"id\"];\n const status = row[\"status\"];\n const createdAt = row[\"createdAt\"];\n const rawMetadata = row[\"metadata\"];\n if (\n typeof id !== \"string\" ||\n (status !== JOB_STATUS.PENDING && status !== JOB_STATUS.PROCESSING) ||\n (typeof createdAt !== \"number\" && typeof createdAt !== \"bigint\") ||\n typeof rawMetadata !== \"string\"\n ) {\n throw new Error(\"Invalid active job row returned during atomic enqueue\");\n }\n\n const metadata: unknown = JSON.parse(rawMetadata);\n return {\n id,\n status,\n createdAt: Number(createdAt),\n metadata,\n };\n }\n\n private async insertJob(\n transaction: Transaction,\n jobData: AtomicJobData,\n ): Promise<void> {\n const inserted = await transaction.execute({\n sql: `INSERT INTO \\`job_queue\\` (\n \\`id\\`, \\`type\\`, \\`data\\`, \\`result\\`, \\`source\\`, \\`metadata\\`,\n \\`status\\`, \\`priority\\`, \\`retryCount\\`, \\`maxRetries\\`,\n \\`lastError\\`, \\`createdAt\\`, \\`scheduledFor\\`, \\`startedAt\\`,\n \\`completedAt\\`\n ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,\n args: [\n jobData.id,\n jobData.type,\n jobData.data,\n this.serializeJson(jobData.result),\n jobData.source,\n JSON.stringify(jobData.metadata),\n jobData.status,\n jobData.priority,\n jobData.retryCount,\n jobData.maxRetries,\n jobData.lastError,\n jobData.createdAt,\n jobData.scheduledFor,\n jobData.startedAt,\n jobData.completedAt,\n ],\n });\n if (inserted.rowsAffected !== 1) {\n throw new Error(`Atomic enqueue did not insert job ${jobData.id}`);\n }\n }\n\n private serializeJson(value: unknown): string | null {\n if (value === undefined || value === null) return null;\n return JSON.stringify(value);\n }\n\n private acquireWriteTransaction(\n request: AtomicEnqueueRequest,\n deadline: number,\n ): Promise<Transaction> {\n return this.retryOnConflict(\n \"acquire\",\n request,\n () => this.transactionClient.transaction(\"write\"),\n (error) => this.isSerializationConflict(error),\n deadline,\n );\n }\n\n private async waitForConflictRetry(\n phase: \"acquire\" | \"commit\" | \"write\",\n request: AtomicEnqueueRequest | string,\n deadline: number,\n attempt: number,\n cause: unknown,\n ): Promise<void> {\n const backoff = Math.min(\n WRITE_RETRY_BASE_DELAY_MS * 2 ** (attempt - 1),\n WRITE_RETRY_MAX_DELAY_MS,\n );\n // Jitter half the window so contending processes fall out of lockstep.\n const delay = backoff / 2 + Math.random() * (backoff / 2);\n if (Date.now() + delay >= deadline) {\n throw this.transactionConflictError(phase, request, attempt, cause);\n }\n await new Promise((resolve) => setTimeout(resolve, delay));\n }\n\n private async retryOnConflict<T>(\n phase: \"acquire\" | \"commit\" | \"write\",\n request: AtomicEnqueueRequest | string,\n operation: () => Promise<T>,\n isRetryable: (error: unknown) => boolean,\n deadline = Date.now() + this.writeRetryBudgetMs,\n attempt = 1,\n ): Promise<T> {\n try {\n return await operation();\n } catch (error) {\n if (!isRetryable(error)) throw error;\n await this.waitForConflictRetry(phase, request, deadline, attempt, error);\n return this.retryOnConflict(\n phase,\n request,\n operation,\n isRetryable,\n deadline,\n attempt + 1,\n );\n }\n }\n\n private transactionConflictError(\n phase: \"acquire\" | \"commit\" | \"write\",\n request: AtomicEnqueueRequest | string,\n attempts: number,\n cause: unknown,\n ): Error {\n if (typeof request === \"string\") {\n return new Error(\n `Failed job queue write \"${request}\" within ${this.writeRetryBudgetMs}ms after ${attempts} attempts`,\n { cause },\n );\n }\n const strategy = request.strategy ?? \"none\";\n const keyPresence = request.deduplicationKey ? \"present\" : \"absent\";\n return new Error(\n `Failed to ${phase} atomic enqueue transaction for type \"${request.jobData.type}\" within ${this.writeRetryBudgetMs}ms after ${attempts} attempts (strategy: ${strategy}, key: ${keyPresence})`,\n { cause },\n );\n }\n\n /** Retry only the rejected database statement, never a handler or its callbacks. */\n private write<T>(name: string, operation: () => Promise<T>): Promise<T> {\n // These writes can contend with enqueue transactions on another connection\n // even through the same client. Async backoff lets that transaction finish;\n // joining its serial turn could deadlock an enqueue preparation callback.\n return this.retryOnConflict(\"write\", name, operation, (error) => {\n const seen = new Set<unknown>();\n for (let current = error; current !== undefined && !seen.has(current);) {\n seen.add(current);\n if (\n typeof current === \"object\" &&\n current !== null &&\n \"code\" in current &&\n /^SQLITE_(?:BUSY|LOCKED)(?:_|$)/u.test(String(current.code))\n )\n return true;\n // Do not mistake payload text in a Drizzle query error for a lock error.\n current = current instanceof Error ? current.cause : undefined;\n }\n return false;\n });\n }\n\n private isSerializationConflict(error: unknown): boolean {\n for (let current = error; current !== undefined;) {\n const code =\n typeof current === \"object\" && current !== null && \"code\" in current\n ? String(current.code)\n : \"\";\n const message = getErrorMessage(current, \"\");\n if (\n /SQLITE_(?:BUSY|LOCKED)/u.test(code) ||\n /SQLITE_(?:BUSY|LOCKED)|database is locked|transaction (?:busy|conflict)/iu.test(\n message,\n )\n ) {\n return true;\n }\n current = current instanceof Error ? current.cause : undefined;\n }\n return false;\n }\n\n /** Atomically register a new session, superseding the slot's prior session. */\n public async startWorkerSession(\n workerSlotId: string,\n workerSessionId: string,\n now: number = Date.now(),\n workerSessionTimeoutMs: number = DEFAULT_WORKER_SESSION_TIMEOUT_MS,\n ): Promise<void> {\n const expiresAt = now + workerSessionTimeoutMs;\n await this.write(\"start worker session\", async () =>\n this.db\n .insert(jobWorkerSessions)\n .values({\n slotId: workerSlotId,\n sessionId: workerSessionId,\n startedAt: now,\n heartbeatAt: now,\n expiresAt,\n })\n .onConflictDoUpdate({\n target: jobWorkerSessions.slotId,\n set: {\n sessionId: workerSessionId,\n startedAt: now,\n heartbeatAt: now,\n expiresAt,\n },\n }),\n );\n }\n\n /** Update liveness only if this session still owns the stable slot. */\n public async heartbeatWorkerSession(\n workerSlotId: string,\n workerSessionId: string,\n now: number = Date.now(),\n workerSessionTimeoutMs: number = DEFAULT_WORKER_SESSION_TIMEOUT_MS,\n ): Promise<boolean> {\n const result = await this.write(\"heartbeat worker session\", async () =>\n this.db\n .update(jobWorkerSessions)\n .set({\n heartbeatAt: now,\n expiresAt: now + workerSessionTimeoutMs,\n })\n .where(\n and(\n eq(jobWorkerSessions.slotId, workerSlotId),\n eq(jobWorkerSessions.sessionId, workerSessionId),\n ),\n )\n .returning({ slotId: jobWorkerSessions.slotId }),\n );\n return result.length === 1;\n }\n\n /** Remove a normally stopped session without touching a replacement. */\n public async endWorkerSession(\n workerSlotId: string,\n workerSessionId: string,\n ): Promise<boolean> {\n const result = await this.write(\"end worker session\", async () =>\n this.db\n .delete(jobWorkerSessions)\n .where(\n and(\n eq(jobWorkerSessions.slotId, workerSlotId),\n eq(jobWorkerSessions.sessionId, workerSessionId),\n ),\n )\n .returning({ slotId: jobWorkerSessions.slotId }),\n );\n return result.length === 1;\n }\n\n /** Mark a job completed only if the supplied attempt still owns it. */\n public async complete(\n jobId: string,\n result: unknown,\n attemptId?: string,\n ): Promise<boolean> {\n const now = Date.now();\n const updated = await this.write(\"complete\", async () =>\n this.db\n .update(jobQueue)\n .set({\n status: JOB_STATUS.COMPLETED,\n result,\n lastError: null,\n completedAt: now,\n runtimeUpdatedAt: this.nextRuntimeUpdatedAt(now),\n })\n .where(this.attemptWriteGuard(jobId, attemptId))\n .returning({ id: jobQueue.id }),\n );\n\n const applied = updated.length === 1;\n if (applied) {\n this.logger.debug(\"Job completed\", {\n jobId,\n hasResult: result !== undefined,\n });\n }\n return applied;\n }\n\n /** Update job data only if the supplied attempt still owns it. */\n public async update(\n jobId: string,\n data: unknown,\n attemptId?: string,\n ): Promise<boolean> {\n const updated = await this.write(\"update\", async () =>\n this.db\n .update(jobQueue)\n .set({ data: JSON.stringify(data) })\n .where(this.attemptWriteGuard(jobId, attemptId))\n .returning({ id: jobQueue.id }),\n );\n\n const applied = updated.length === 1;\n if (applied) this.logger.debug(\"Job data updated\", { jobId });\n return applied;\n }\n\n /** Record progress only while the attempt token remains current. */\n public async recordAttemptProgress(\n jobId: string,\n attemptId: string,\n progress: ProgressNotification,\n now: number = Date.now(),\n ): Promise<boolean> {\n const updated = await this.write(\"record progress\", async () =>\n this.db\n .update(jobQueue)\n .set({\n attemptHeartbeatAt: now,\n runtimeUpdatedAt: this.nextRuntimeUpdatedAt(now),\n progress,\n })\n .where(this.attemptWriteGuard(jobId, attemptId))\n .returning({ id: jobQueue.id }),\n );\n return updated.length === 1;\n }\n\n /** Renew the attempt heartbeat and lease under the fencing token. */\n public async renewAttemptLease(\n jobId: string,\n attemptId: string,\n now: number,\n leaseDurationMs: number,\n ): Promise<boolean> {\n const updated = await this.write(\"renew attempt lease\", async () =>\n this.db\n .update(jobQueue)\n .set({\n attemptHeartbeatAt: now,\n leaseExpiresAt: now + leaseDurationMs,\n })\n .where(this.attemptWriteGuard(jobId, attemptId))\n .returning({ id: jobQueue.id }),\n );\n return updated.length === 1;\n }\n\n /** Mark a current attempt failed and apply bounded retry policy. */\n public async fail(\n jobId: string,\n error: Error,\n attemptId?: string,\n now: number = Date.now(),\n ): Promise<boolean> {\n const current = await this.db\n .select({\n retryCount: jobQueue.retryCount,\n maxRetries: jobQueue.maxRetries,\n type: jobQueue.type,\n scheduledFor: jobQueue.scheduledFor,\n startedAt: jobQueue.startedAt,\n runtimeUpdatedAt: jobQueue.runtimeUpdatedAt,\n })\n .from(jobQueue)\n .where(this.attemptWriteGuard(jobId, attemptId))\n .limit(1);\n const job = current[0];\n if (!job) return false;\n\n const canRetry = job.retryCount < job.maxRetries;\n const nextRetryCount = canRetry ? job.retryCount + 1 : job.retryCount;\n const backoffMs = Math.min(1000 * 2 ** job.retryCount, 60_000);\n const scheduledFor = canRetry ? now + backoffMs : job.scheduledFor;\n const updated = await this.write(\"fail\", async () =>\n this.db\n .update(jobQueue)\n .set({\n status: canRetry ? JOB_STATUS.PENDING : JOB_STATUS.FAILED,\n retryCount: nextRetryCount,\n lastError: error.message,\n scheduledFor,\n completedAt: canRetry ? null : now,\n runtimeUpdatedAt: canRetry\n ? job.runtimeUpdatedAt\n : this.nextRuntimeUpdatedAt(now),\n startedAt: canRetry ? null : job.startedAt,\n attemptId: null,\n workerSlotId: null,\n workerSessionId: null,\n leaseExpiresAt: null,\n attemptHeartbeatAt: null,\n })\n .where(\n and(\n this.attemptWriteGuard(jobId, attemptId),\n eq(jobQueue.retryCount, job.retryCount),\n ),\n )\n .returning({ id: jobQueue.id }),\n );\n if (updated.length !== 1) return false;\n\n if (canRetry) {\n this.logger.debug(\"Job scheduled for retry\", {\n jobId,\n retryCount: nextRetryCount,\n backoffMs,\n scheduledFor: new Date(scheduledFor).toISOString(),\n });\n } else {\n this.logger.error(\"Job failed after max retries\", {\n jobId,\n type: job.type,\n retryCount: job.retryCount,\n error: error.message,\n });\n }\n return true;\n }\n\n /**\n * Terminally retire an exact legacy job only when no attempt can still own or\n * mutate it. This is intentionally stricter than fail(): retired handlers\n * cannot consume retries or run terminal callbacks.\n */\n public async retireUnownedActiveJob(\n request: RetireUnownedActiveJobRequest,\n ): Promise<JobInfo | null> {\n const reason = request.reason.trim();\n if (reason.length === 0) {\n throw new Error(\"Retired job reason must not be empty\");\n }\n\n const updated = await this.write(\"retire unowned job\", async () =>\n this.db\n .update(jobQueue)\n .set({\n status: JOB_STATUS.FAILED,\n lastError: reason,\n completedAt: request.now,\n runtimeUpdatedAt: this.nextRuntimeUpdatedAt(request.now),\n })\n .where(\n and(\n eq(jobQueue.id, request.jobId),\n eq(jobQueue.type, request.expectedType),\n or(\n eq(jobQueue.status, JOB_STATUS.PENDING),\n eq(jobQueue.status, JOB_STATUS.PROCESSING),\n ),\n isNull(jobQueue.attemptId),\n isNull(jobQueue.workerSlotId),\n isNull(jobQueue.workerSessionId),\n isNull(jobQueue.leaseExpiresAt),\n isNull(jobQueue.attemptHeartbeatAt),\n isNull(jobQueue.progress),\n isNull(jobQueue.result),\n ),\n )\n .returning(),\n );\n const retired = updated[0] ?? null;\n if (retired) {\n this.logger.warn(\"Retired unowned legacy job\", {\n jobId: retired.id,\n type: retired.type,\n reason,\n });\n }\n return retired;\n }\n\n public async getStatus(jobId: string): Promise<JobInfo | null> {\n const jobs = await this.db\n .select()\n .from(jobQueue)\n .where(eq(jobQueue.id, jobId))\n .limit(1);\n\n return jobs[0] ?? null;\n }\n\n public async getJobsByRootJobId(rootJobId: string): Promise<JobInfo[]> {\n return this.db\n .select()\n .from(jobQueue)\n .where(\n sql`json_extract(${jobQueue.metadata}, '$.rootJobId') = ${rootJobId}`,\n )\n .orderBy(asc(jobQueue.createdAt), asc(jobQueue.id));\n }\n\n public async getStatusByEntityId(entityId: string): Promise<JobInfo | null> {\n const jobs = await this.db\n .select()\n .from(jobQueue)\n .where(sql`json_extract(${jobQueue.data}, '$.id') = ${entityId}`)\n .orderBy(desc(jobQueue.createdAt))\n .limit(1);\n\n return jobs[0] ?? null;\n }\n\n public async getStats(): Promise<JobQueueStats> {\n const stats = await this.db\n .select({\n status: jobQueue.status,\n count: sql<number>`count(*)`,\n })\n .from(jobQueue)\n .groupBy(jobQueue.status);\n\n const result: JobQueueStats = {\n pending: 0,\n processing: 0,\n failed: 0,\n completed: 0,\n total: 0,\n };\n\n for (const row of stats) {\n const count = Number(row.count);\n result[row.status] = count;\n result.total += count;\n }\n\n return result;\n }\n\n public async getDiagnostics(\n now: number = Date.now(),\n ): Promise<JobQueueDiagnostics> {\n const [byTypeRows, ageRows, workerSessionRows] = await Promise.all([\n this.db\n .select({\n type: jobQueue.type,\n status: jobQueue.status,\n count: sql<number>`count(*)`,\n })\n .from(jobQueue)\n .groupBy(jobQueue.type, jobQueue.status),\n this.db\n .select({\n oldestPendingAt: sql<\n number | null\n >`min(case when ${jobQueue.status} = ${JOB_STATUS.PENDING} then ${jobQueue.createdAt} end)`,\n duePending: sql<number>`coalesce(sum(case when ${jobQueue.status} = ${JOB_STATUS.PENDING} and ${jobQueue.scheduledFor} <= ${now} then 1 else 0 end), 0)`,\n oldestDuePendingAt: sql<\n number | null\n >`min(case when ${jobQueue.status} = ${JOB_STATUS.PENDING} and ${jobQueue.scheduledFor} <= ${now} then ${jobQueue.scheduledFor} end)`,\n latestClaimAt: sql<number | null>`max(${jobQueue.startedAt})`,\n oldestProcessingAt: sql<\n number | null\n >`min(case when ${jobQueue.status} = ${JOB_STATUS.PROCESSING} then ${jobQueue.startedAt} end)`,\n staleLeaseCount: sql<number>`coalesce(sum(case when ${jobQueue.status} = ${JOB_STATUS.PROCESSING} and ${jobQueue.leaseExpiresAt} is not null and ${jobQueue.leaseExpiresAt} <= ${now} then 1 else 0 end), 0)`,\n })\n .from(jobQueue),\n this.db\n .select({\n total: sql<number>`count(*)`,\n active: sql<number>`coalesce(sum(case when ${jobWorkerSessions.expiresAt} > ${now} then 1 else 0 end), 0)`,\n stale: sql<number>`coalesce(sum(case when ${jobWorkerSessions.expiresAt} <= ${now} then 1 else 0 end), 0)`,\n latestHeartbeatAt: sql<\n number | null\n >`max(${jobWorkerSessions.heartbeatAt})`,\n })\n .from(jobWorkerSessions),\n ]);\n\n const totals: JobQueueDiagnostics[\"totals\"] = {\n pending: 0,\n processing: 0,\n completed: 0,\n failed: 0,\n };\n const byType = byTypeRows.map((row) => {\n const count = Number(row.count);\n totals[row.status] += count;\n return { type: row.type, status: row.status, count };\n });\n const ages = ageRows[0];\n const workerSessions = workerSessionRows[0];\n const ageSince = (timestamp: number | null | undefined): number | null =>\n timestamp === null || timestamp === undefined\n ? null\n : Math.max(0, now - Number(timestamp));\n\n return {\n totals,\n byType,\n oldestPendingAgeMs: ageSince(ages?.oldestPendingAt),\n duePending: Number(ages?.duePending ?? 0),\n oldestDuePendingAgeMs: ageSince(ages?.oldestDuePendingAt),\n latestClaimAgeMs: ageSince(ages?.latestClaimAt),\n oldestProcessingAgeMs: ageSince(ages?.oldestProcessingAt),\n staleLeaseCount: Number(ages?.staleLeaseCount ?? 0),\n workerSessions: {\n total: Number(workerSessions?.total ?? 0),\n active: Number(workerSessions?.active ?? 0),\n stale: Number(workerSessions?.stale ?? 0),\n latestHeartbeatAgeMs: ageSince(workerSessions?.latestHeartbeatAt),\n },\n };\n }\n\n public async getRuntimeUpdates(\n cursor: JobRuntimeUpdateCursor,\n limit: number,\n ): Promise<JobRuntimeUpdate[]> {\n const rows = await this.db\n .select()\n .from(jobQueue)\n .where(\n sql`(${jobQueue.runtimeUpdatedAt}, ${jobQueue.id}) > (${cursor.updatedAt}, ${cursor.jobId})`,\n )\n .orderBy(asc(jobQueue.runtimeUpdatedAt), asc(jobQueue.id))\n .limit(limit);\n\n return rows.map((job) => ({\n job,\n cursor: {\n updatedAt: job.runtimeUpdatedAt ?? 0,\n jobId: job.id,\n },\n }));\n }\n\n public async cleanup(olderThanMs: number): Promise<number> {\n const cutoff = Date.now() - olderThanMs;\n\n const result = await this.write(\"cleanup\", async () =>\n this.db\n .delete(jobQueue)\n .where(\n and(\n or(\n eq(jobQueue.status, JOB_STATUS.COMPLETED),\n eq(jobQueue.status, JOB_STATUS.FAILED),\n ),\n lte(jobQueue.completedAt, cutoff),\n ),\n ),\n );\n\n return result.rowsAffected;\n }\n\n public async getActiveJobs(types?: string[]): Promise<JobInfo[]> {\n const activeStatusFilter = or(\n eq(jobQueue.status, JOB_STATUS.PENDING),\n eq(jobQueue.status, JOB_STATUS.PROCESSING),\n );\n\n const whereClause =\n types && types.length > 0\n ? and(activeStatusFilter, inArray(jobQueue.type, types))\n : activeStatusFilter;\n\n return this.db\n .select()\n .from(jobQueue)\n .where(whereClause)\n .orderBy(desc(jobQueue.createdAt));\n }\n\n public async getRecentJobs(types?: string[], limit = 20): Promise<JobInfo[]> {\n const whereClause =\n types && types.length > 0 ? inArray(jobQueue.type, types) : undefined;\n\n return this.db\n .select()\n .from(jobQueue)\n .where(whereClause)\n .orderBy(desc(jobQueue.createdAt))\n .limit(limit);\n }\n\n public async getFailedJobs(types?: string[]): Promise<JobInfo[]> {\n const failedStatusFilter = eq(jobQueue.status, JOB_STATUS.FAILED);\n const whereClause =\n types && types.length > 0\n ? and(failedStatusFilter, inArray(jobQueue.type, types))\n : failedStatusFilter;\n\n return this.db\n .select()\n .from(jobQueue)\n .where(whereClause)\n .orderBy(desc(jobQueue.createdAt));\n }\n\n /**\n * Atomically claim the highest-priority executable pending or reclaimable job.\n * A superseded session is reclaimable immediately. Another slot's attempt\n * requires both an expired lease and an expired owner-session heartbeat.\n */\n public async claimNextReady(\n claim: JobAttemptClaim,\n ): Promise<JobQueue | null> {\n const {\n now,\n attemptId,\n workerSlotId,\n workerSessionId,\n leaseDurationMs,\n executableTypes,\n } = claim;\n\n const claimingSession = this.db\n .select({ slotId: jobWorkerSessions.slotId })\n .from(jobWorkerSessions)\n .where(\n and(\n eq(jobWorkerSessions.slotId, workerSlotId),\n eq(jobWorkerSessions.sessionId, workerSessionId),\n gt(jobWorkerSessions.expiresAt, now),\n ),\n );\n const matchingOwnerSession = this.db\n .select({ slotId: jobWorkerSessions.slotId })\n .from(jobWorkerSessions)\n .where(\n and(\n eq(jobWorkerSessions.slotId, jobQueue.workerSlotId),\n eq(jobWorkerSessions.sessionId, jobQueue.workerSessionId),\n ),\n );\n const staleOwnerSession = this.db\n .select({ slotId: jobWorkerSessions.slotId })\n .from(jobWorkerSessions)\n .where(\n and(\n eq(jobWorkerSessions.slotId, jobQueue.workerSlotId),\n eq(jobWorkerSessions.sessionId, jobQueue.workerSessionId),\n lte(jobWorkerSessions.expiresAt, now),\n ),\n );\n\n const processingReclaimable = and(\n eq(jobQueue.status, JOB_STATUS.PROCESSING),\n or(\n isNull(jobQueue.attemptId),\n notExists(matchingOwnerSession),\n and(lte(jobQueue.leaseExpiresAt, now), exists(staleOwnerSession)),\n ),\n );\n const terminalReclaim = sql`${jobQueue.status} = ${JOB_STATUS.PROCESSING} AND ${jobQueue.retryCount} + 1 > ${jobQueue.maxRetries}`;\n\n const candidate = this.db\n .select({ id: jobQueue.id })\n .from(jobQueue)\n .where(\n and(\n inArray(jobQueue.type, executableTypes),\n or(\n and(\n eq(jobQueue.status, JOB_STATUS.PENDING),\n lte(jobQueue.scheduledFor, now),\n ),\n processingReclaimable,\n ),\n ),\n )\n .orderBy(asc(jobQueue.priority), asc(jobQueue.createdAt))\n .limit(1);\n\n const result = await this.write(\"claim\", async () =>\n this.db\n .update(jobQueue)\n .set({\n status: sql`CASE WHEN ${terminalReclaim} THEN ${JOB_STATUS.FAILED} ELSE ${JOB_STATUS.PROCESSING} END`,\n retryCount: sql`CASE WHEN ${jobQueue.status} = ${JOB_STATUS.PROCESSING} THEN ${jobQueue.retryCount} + 1 ELSE ${jobQueue.retryCount} END`,\n lastError: sql`CASE WHEN ${jobQueue.status} = ${JOB_STATUS.PROCESSING} THEN 'Attempt lease expired' ELSE ${jobQueue.lastError} END`,\n startedAt: sql`CASE WHEN ${terminalReclaim} THEN ${jobQueue.startedAt} ELSE ${now} END`,\n completedAt: sql`CASE WHEN ${terminalReclaim} THEN ${now} ELSE NULL END`,\n attemptId: sql`CASE WHEN ${terminalReclaim} THEN ${jobQueue.attemptId} ELSE ${attemptId} END`,\n workerSlotId: sql`CASE WHEN ${terminalReclaim} THEN ${jobQueue.workerSlotId} ELSE ${workerSlotId} END`,\n workerSessionId: sql`CASE WHEN ${terminalReclaim} THEN ${jobQueue.workerSessionId} ELSE ${workerSessionId} END`,\n leaseExpiresAt: sql`CASE WHEN ${terminalReclaim} THEN ${jobQueue.leaseExpiresAt} ELSE ${now + leaseDurationMs} END`,\n attemptHeartbeatAt: sql`CASE WHEN ${terminalReclaim} THEN ${jobQueue.attemptHeartbeatAt} ELSE ${now} END`,\n runtimeUpdatedAt: sql`CASE WHEN ${terminalReclaim} THEN ${this.nextRuntimeUpdatedAt(now)} ELSE ${jobQueue.runtimeUpdatedAt} END`,\n })\n .where(and(inArray(jobQueue.id, candidate), exists(claimingSession)))\n .returning(),\n );\n\n const claimed = result[0];\n if (claimed?.status !== JOB_STATUS.PROCESSING) return null;\n\n this.logger.debug(\"Job claimed\", {\n jobId: claimed.id,\n attemptId: claimed.attemptId,\n workerSlotId,\n workerSessionId,\n });\n return claimed;\n }\n\n /** Allocate a globally increasing cursor value within the serialized write. */\n private nextRuntimeUpdatedAt(now: number): SQL<number> {\n return sql<number>`max(${now}, coalesce((select max(${jobQueue.runtimeUpdatedAt}) from ${jobQueue}), 0) + 1)`;\n }\n\n private attemptWriteGuard(\n jobId: string,\n attemptId?: string,\n ): SQL | undefined {\n return attemptId\n ? and(\n eq(jobQueue.id, jobId),\n eq(jobQueue.status, JOB_STATUS.PROCESSING),\n eq(jobQueue.attemptId, attemptId),\n )\n : and(\n eq(jobQueue.id, jobId),\n isNull(jobQueue.attemptId),\n or(\n eq(jobQueue.status, JOB_STATUS.PENDING),\n eq(jobQueue.status, JOB_STATUS.PROCESSING),\n ),\n );\n }\n}\n",
281
281
  "import {\n OperationProvenanceSchema,\n type OperationProvenance,\n} from \"@brains/contracts\";\nimport { AsyncLocalStorage } from \"node:async_hooks\";\n\nexport interface OperationScope {\n readonly provenance: OperationProvenance;\n readonly operationId: string;\n}\n\n/** App-scoped asynchronous context for causal provenance inheritance. */\nexport class OperationContext {\n private readonly storage = new AsyncLocalStorage<OperationScope>();\n\n public static createFresh(): OperationContext {\n return new OperationContext();\n }\n\n private constructor() {}\n\n public current(): OperationScope | undefined {\n return this.storage.getStore();\n }\n\n public run<T>(\n provenance: OperationProvenance,\n operationId: string,\n operation: () => T,\n ): T {\n if (operationId.length === 0) {\n throw new Error(\"Operation ID must not be empty\");\n }\n const parsed = OperationProvenanceSchema.parse(provenance);\n const frozenProvenance = freezeProvenance(parsed);\n return this.storage.run(\n Object.freeze({ provenance: frozenProvenance, operationId }),\n operation,\n );\n }\n}\n\nfunction freezeProvenance(\n provenance: OperationProvenance,\n): OperationProvenance {\n Object.freeze(provenance.projectionLineage);\n if (provenance.sourceEntity) Object.freeze(provenance.sourceEntity);\n return Object.freeze(provenance);\n}\n",
282
282
  "import { JOB_CHANNELS } from \"@brains/contracts\";\nimport type { Logger } from \"@brains/utils/logger\";\nimport {\n CallbackProgressReporter,\n type ProgressReporter,\n} from \"@brains/utils/progress\";\nimport { z } from \"@brains/utils/zod\";\nimport type {\n IJobProgressMonitor,\n ProgressNotification,\n} from \"@brains/utils/progress\";\nimport type { IMessageBus } from \"@brains/messaging-service\";\nimport type { IBatchJobManager, IJobQueueService, JobInfo } from \"./types\";\nimport { JobContextSchema, type JobContext } from \"./schema/types\";\nimport type { BatchJobStatus } from \"./batch-schemas\";\nimport type { JobProgressEvent } from \"./schemas\";\n\nconst jobResultRecordSchema = z.preprocess(\n (value) => {\n if (typeof value !== \"string\") return value;\n return JSON.parse(value);\n },\n z.record(z.string(), z.unknown()),\n);\n\nexport type { JobProgressEvent } from \"./schemas\";\nexport type JobProgressMonitorMode =\n \"combined\" | \"durable-reader\" | \"durable-writer\";\n\n/**\n * Simplified service that emits job and batch progress events\n *\n * This service provides a simple event-driven approach to progress monitoring\n * without complex polling or state tracking.\n */\nexport class JobProgressMonitor implements IJobProgressMonitor {\n private jobQueueService: IJobQueueService;\n private messageBus: IMessageBus;\n private batchJobManager: IBatchJobManager;\n private logger: Logger;\n private readonly mode: JobProgressMonitorMode;\n private pollTimer: ReturnType<typeof setInterval> | undefined;\n private pollCursor = { updatedAt: 0, jobId: \"\" };\n private polling = false;\n public static createFresh(\n jobQueueService: IJobQueueService,\n messageBus: IMessageBus,\n batchJobManager: IBatchJobManager,\n logger: Logger,\n mode: JobProgressMonitorMode = \"combined\",\n ): JobProgressMonitor {\n return new JobProgressMonitor(\n jobQueueService,\n messageBus,\n batchJobManager,\n logger,\n mode,\n );\n }\n\n private constructor(\n jobQueueService: IJobQueueService,\n messageBus: IMessageBus,\n batchJobManager: IBatchJobManager,\n logger: Logger,\n mode: JobProgressMonitorMode,\n ) {\n this.jobQueueService = jobQueueService;\n this.messageBus = messageBus;\n this.batchJobManager = batchJobManager;\n this.logger = logger;\n this.mode = mode;\n }\n\n public start(): void {\n if (this.mode === \"durable-reader\" && !this.pollTimer) {\n this.pollCursor = { updatedAt: Date.now(), jobId: \"\" };\n this.pollTimer = setInterval(() => {\n void this.pollDurableUpdates();\n }, 250);\n this.pollTimer.unref();\n this.logger.debug(\"Job progress monitor ready (durable polling mode)\");\n return;\n }\n this.logger.debug(\"Job progress monitor ready (event-driven mode)\");\n }\n\n public stop(): void {\n if (this.pollTimer) clearInterval(this.pollTimer);\n this.pollTimer = undefined;\n this.logger.debug(\"Job progress monitor stopped\");\n }\n\n /** Flush one bounded durable update page sequence. */\n public async pollDurableUpdates(): Promise<void> {\n if (this.polling) return;\n this.polling = true;\n try {\n const updates = await this.jobQueueService.getRuntimeUpdates(\n this.pollCursor,\n 100,\n );\n for (const update of updates) {\n this.pollCursor = update.cursor;\n if (update.job.status === \"processing\" && update.job.progress) {\n await this.publishJobProgress(update.job, update.job.progress);\n } else if (\n update.job.status === \"completed\" ||\n update.job.status === \"failed\"\n ) {\n await this.handleJobStatusChange(\n update.job.id,\n update.job.status,\n update.job.metadata,\n );\n }\n }\n } catch (error) {\n this.logger.error(\"Failed to poll durable job progress\", { error });\n } finally {\n this.polling = false;\n }\n }\n\n /**\n * Create a ProgressReporter for a specific job\n */\n public createProgressReporter(\n jobId: string,\n attemptId?: string,\n ): ProgressReporter {\n const reporter = CallbackProgressReporter.from(async (notification) => {\n await this.emitJobProgress(jobId, notification, attemptId);\n });\n\n if (!reporter) {\n // This should never happen since we always provide a callback\n throw new Error(\"Failed to create ProgressReporter\");\n }\n\n return reporter;\n }\n\n /**\n * Emit batch progress event\n */\n public async emitBatchProgress(\n batchId: string,\n status: BatchJobStatus,\n metadata: JobContext,\n ): Promise<void> {\n try {\n const batchDetails: JobProgressEvent[\"batchDetails\"] = {\n totalOperations: status.totalOperations,\n completedOperations: status.completedOperations,\n failedOperations: status.failedOperations,\n currentOperation: status.currentOperation ?? \"Processing batch...\",\n errors: status.errors,\n };\n\n const event: JobProgressEvent = {\n id: batchId,\n type: \"batch\",\n status: status.status,\n metadata,\n batchDetails,\n };\n\n // Calculate overall batch progress\n if (status.totalOperations > 0) {\n event.progress = {\n current: status.completedOperations,\n total: status.totalOperations,\n percentage: Math.round(\n (status.completedOperations / status.totalOperations) * 100,\n ),\n };\n }\n\n await this.messageBus.send({\n type: JOB_CHANNELS.progress,\n payload: event,\n sender: \"job-progress-monitor\",\n broadcast: true,\n });\n\n this.logger.debug(\"Emitted batch progress update\", {\n batchId,\n status: status.status,\n progress: `${status.completedOperations}/${status.totalOperations}`,\n });\n } catch (error) {\n this.logger.error(\"Error emitting batch progress update\", {\n batchId,\n error,\n });\n }\n }\n\n private isBatchChild(jobId: string, rootJobId: string | undefined): boolean {\n return !!rootJobId && rootJobId !== jobId;\n }\n\n /** A durable-writer only records progress; its reader publishes events. */\n private get publishesEvents(): boolean {\n return this.mode !== \"durable-writer\";\n }\n\n private async broadcastEvent(event: JobProgressEvent): Promise<void> {\n await this.messageBus.send({\n type: JOB_CHANNELS.progress,\n payload: event,\n sender: \"job-progress-monitor\",\n broadcast: true,\n });\n }\n\n private async emitJobProgress(\n jobId: string,\n progress: ProgressNotification,\n attemptId?: string,\n ): Promise<void> {\n try {\n if (\n attemptId &&\n !(await this.jobQueueService.recordAttemptProgress(\n jobId,\n attemptId,\n progress,\n ))\n ) {\n this.logger.debug(\"Discarding progress from obsolete job attempt\", {\n jobId,\n attemptId,\n });\n return;\n }\n\n if (!this.publishesEvents) return;\n\n const job = await this.jobQueueService.getStatus(jobId);\n if (!job) {\n this.logger.warn(\"Job not found for progress update\", { jobId });\n return;\n }\n await this.publishJobProgress(job, progress);\n } catch (error) {\n this.logger.error(\"Error emitting job progress\", { jobId, error });\n }\n }\n\n private async publishJobProgress(\n job: JobInfo,\n progress: ProgressNotification,\n ): Promise<void> {\n if (job.metadata.silent) return;\n if (this.isBatchChild(job.id, job.metadata.rootJobId)) return;\n\n const total = progress.total ?? 0;\n const event: JobProgressEvent = {\n id: job.id,\n type: \"job\",\n status: \"processing\",\n metadata: job.metadata,\n message: progress.message,\n };\n if (total > 0) {\n event.progress = {\n current: progress.progress,\n total,\n percentage: Math.round((progress.progress / total) * 100),\n };\n }\n await this.broadcastEvent(event);\n }\n\n public async emitJobCompletion(jobId: string): Promise<void> {\n await this.emitJobStatusEvent(jobId, \"completed\");\n }\n\n public async emitJobFailure(jobId: string): Promise<void> {\n await this.emitJobStatusEvent(jobId, \"failed\");\n }\n\n private async emitJobStatusEvent(\n jobId: string,\n status: \"completed\" | \"failed\",\n ): Promise<void> {\n if (!this.publishesEvents) return;\n try {\n const job = await this.jobQueueService.getStatus(jobId);\n if (!job) {\n this.logger.warn(`Cannot emit ${status} for unknown job`, { jobId });\n return;\n }\n\n if (job.metadata.silent) {\n return;\n }\n\n if (this.isBatchChild(jobId, job.metadata.rootJobId)) {\n this.logger.debug(\n `Skipping individual job ${status} for batch operation`,\n { jobId, rootJobId: job.metadata.rootJobId },\n );\n return;\n }\n\n const event: JobProgressEvent = {\n id: jobId,\n type: \"job\",\n status,\n message: this.extractStatusMessage(job, status),\n metadata: job.metadata,\n jobDetails: {\n jobType: job.type,\n priority: job.priority,\n retryCount: job.retryCount,\n },\n };\n\n await this.broadcastEvent(event);\n this.logger.debug(`Emitted job ${status} event`, { jobId });\n } catch (error) {\n this.logger.error(`Error emitting job ${status} event`, {\n jobId,\n error,\n });\n }\n }\n\n private extractStatusMessage(\n job: JobInfo,\n status: \"completed\" | \"failed\",\n ): string | undefined {\n if (status === \"failed\") {\n return job.lastError ?? undefined;\n }\n\n if (!job.result) {\n return undefined;\n }\n\n try {\n const result = jobResultRecordSchema.parse(job.result);\n const message = result[\"message\"];\n if (message) {\n return String(message);\n }\n const routesBuilt = result[\"routesBuilt\"];\n if (routesBuilt !== undefined) {\n return `${routesBuilt} routes built`;\n }\n } catch {\n // Ignore parsing errors\n }\n\n return undefined;\n }\n\n /**\n * Get monitoring statistics\n */\n public getStats(): {\n isRunning: boolean;\n } {\n return {\n isRunning: true, // Always running in event-driven mode\n };\n }\n\n /**\n * Handle job status changes - emits individual job events and batch progress if applicable\n * This is the main entry point for job completion/failure notifications\n */\n public async handleJobStatusChange(\n jobId: string,\n status: \"completed\" | \"failed\",\n metadata?: Record<string, unknown>,\n ): Promise<void> {\n if (!this.publishesEvents) return;\n const parsedMetadata = metadata\n ? JobContextSchema.safeParse(metadata)\n : undefined;\n const jobMetadata = parsedMetadata?.success\n ? parsedMetadata.data\n : undefined;\n\n if (jobMetadata?.silent) {\n return;\n }\n\n try {\n await this.emitJobStatusEvent(jobId, status);\n\n if (jobMetadata && this.isBatchChild(jobId, jobMetadata.rootJobId)) {\n try {\n const rootJobId = jobMetadata.rootJobId;\n const batchStatus =\n await this.batchJobManager.getBatchStatus(rootJobId);\n if (batchStatus) {\n const batchMetadata = batchStatus.metadata ?? jobMetadata;\n await this.emitBatchProgress(rootJobId, batchStatus, batchMetadata);\n }\n } catch (error) {\n this.logger.warn(\"Failed to emit batch progress\", {\n jobId,\n rootJobId: jobMetadata.rootJobId,\n error,\n });\n }\n }\n } catch (error) {\n this.logger.error(\"Failed to handle job status change\", {\n jobId,\n status,\n error,\n });\n }\n }\n}\n",
283
283
  "import type { Logger } from \"@brains/utils/logger\";\nimport type { ProgressReporter } from \"@brains/utils/progress\";\nimport { z } from \"@brains/utils/zod\";\nimport type { JobHandler } from \"./types\";\n\nconst logDataSchema = z.record(z.string(), z.unknown());\n\nexport type JobDataSchema<T> = z.ZodType<T, unknown>;\n\n/**\n * Configuration options for BaseJobHandler\n */\nexport interface BaseJobHandlerConfig<TInput> {\n /**\n * The Zod schema used to validate job input data.\n * Optional if you override validateAndParse().\n */\n schema?: JobDataSchema<TInput>;\n /** The name of the job type (used in log messages) */\n jobTypeName: string;\n /** Optional per-type execution deadline in milliseconds. */\n executionTimeoutMs?: number;\n}\n\n/**\n * Progress step helper for cleaner progress reporting\n */\nexport interface ProgressStep {\n progress: number;\n total?: number;\n message: string;\n}\n\n/**\n * Abstract base class for job handlers that provides common boilerplate\n *\n * This class implements the standard validateAndParse() and onError() methods\n * that are duplicated across all job handlers. Subclasses only need to:\n * 1. Pass a schema and job type name to the constructor\n * 2. Implement the abstract process() method\n *\n * @template TJobType - The job type string\n * @template TInput - The input data type (inferred from schema)\n * @template TOutput - The output data type\n *\n * @example\n * ```typescript\n * export class MyJobHandler extends BaseJobHandler<\"my-job\", MyJobData, MyResult> {\n * constructor(logger: Logger, private context: ServicePluginContext) {\n * super(logger, {\n * schema: myJobSchema,\n * jobTypeName: \"my-job\",\n * });\n * }\n *\n * async process(data: MyJobData, jobId: string, progressReporter: ProgressReporter): Promise<MyResult> {\n * await this.reportProgress(progressReporter, { progress: 0, message: \"Starting\" });\n * // ... implementation\n * return result;\n * }\n * }\n * ```\n */\nexport abstract class BaseJobHandler<\n TJobType extends string = string,\n TInput = unknown,\n TOutput = unknown,\n> implements JobHandler<TJobType, TInput, TOutput> {\n protected readonly logger: Logger;\n protected readonly schema: JobDataSchema<TInput> | undefined;\n protected readonly jobTypeName: string;\n public readonly executionTimeoutMs: number | undefined;\n\n /**\n * Create a new BaseJobHandler\n *\n * @param logger - Logger instance for the handler\n * @param config - Configuration including schema and job type name\n */\n constructor(logger: Logger, config: BaseJobHandlerConfig<TInput>) {\n this.logger = logger;\n this.schema = config.schema;\n this.jobTypeName = config.jobTypeName;\n this.executionTimeoutMs = config.executionTimeoutMs;\n }\n\n /**\n * Process a job - must be implemented by subclasses\n *\n * @param data - The validated job input data\n * @param jobId - Unique identifier for this job\n * @param progressReporter - Progress reporter for granular updates\n */\n abstract process(\n data: TInput,\n jobId: string,\n progressReporter: ProgressReporter,\n signal: AbortSignal,\n ): Promise<TOutput>;\n\n /**\n * Validate and parse job data using the configured Zod schema\n *\n * Override this method if you need custom validation logic,\n * such as cleaning up undefined optional properties.\n *\n * @param data - Raw job data to validate\n * @returns Parsed data if valid, null if invalid\n */\n validateAndParse(data: unknown): TInput | null {\n if (!this.schema) {\n throw new Error(\n `${this.jobTypeName}: No schema provided. Override validateAndParse() or provide a schema.`,\n );\n }\n\n const parsed = this.schema.safeParse(data);\n if (!parsed.success) {\n this.logger.warn(`Invalid ${this.jobTypeName} job data`, {\n data,\n validationError: parsed.error.issues,\n });\n return null;\n }\n\n this.logger.debug(`${this.jobTypeName} job data validation successful`, {\n data: this.summarizeDataForLog(parsed.data),\n });\n\n return parsed.data;\n }\n\n /**\n * Handle job errors with standard logging\n *\n * Override this method if you need custom error handling logic,\n * such as cleanup operations or notifications.\n *\n * @param error - The error that occurred\n * @param data - The job input data\n * @param jobId - The job identifier\n * @param _progressReporter - The progress reporter (unused in default implementation)\n */\n async onError(\n error: Error,\n data: TInput,\n jobId: string,\n _progressReporter: ProgressReporter,\n _signal: AbortSignal,\n ): Promise<void> {\n this.logger.error(`${this.jobTypeName} job error handler triggered`, {\n jobId,\n errorMessage: error.message,\n errorStack: error.stack,\n data: this.summarizeDataForLog(data),\n });\n }\n\n /**\n * Helper method for reporting progress with a cleaner API\n *\n * @param reporter - The progress reporter\n * @param step - The progress step to report\n */\n protected async reportProgress(\n reporter: ProgressReporter,\n step: ProgressStep,\n ): Promise<void> {\n await reporter.report({\n progress: step.progress,\n total: step.total ?? 100,\n message: step.message,\n });\n }\n\n /**\n * Summarize data for logging - override to customize what gets logged\n *\n * This method is called by validateAndParse and onError to create\n * a summary of the data for log messages. Override to include\n * relevant fields without logging sensitive data.\n *\n * @param data - The job data to summarize\n * @returns An object suitable for logging\n */\n protected summarizeDataForLog(data: TInput): Record<string, unknown> {\n const parsed = logDataSchema.safeParse(data);\n return parsed.success ? parsed.data : {};\n }\n}\n",
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@rizom/brain",
3
- "version": "0.2.0-alpha.404",
3
+ "version": "0.2.0-alpha.405",
4
4
  "description": "Brain runtime + CLI — scaffold, run, and manage AI brain instances",
5
5
  "keywords": [
6
6
  "brain",
@@ -1,5 +0,0 @@
1
- // @bun
2
- import{I as a,J as b}from"./index-kt2v9nw1.js";import"./index-hp3ma2a0.js";import"./index-3rzkj0kw.js";import"./index-3w748142.js";import"./index-18h53jey.js";import"./index-j1kv20z9.js";export{a as linkedinPostSchema,b as linkedinTemplate};
3
-
4
- //# debugId=4DB900F63898B70564756E2164756E21
5
- //# sourceMappingURL=linkedin-template-jj13p2ah.js.map