melchizedek-agents 0.9.6 → 0.16.0

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 (246) hide show
  1. package/.env.example +272 -0
  2. package/AGENT_SETUP.md +39 -23
  3. package/CHANGELOG.md +623 -0
  4. package/DOCUMENTATION.md +429 -109
  5. package/QUICKSTART.md +92 -18
  6. package/README.md +45 -5
  7. package/config/agents/README.md +46 -1
  8. package/config/agents/examples/ares.yaml +117 -40
  9. package/config/agents/examples/assistant.yaml +159 -0
  10. package/config/agents/examples/augustin.yaml +154 -51
  11. package/config/agents/examples/cartographers.yaml +91 -23
  12. package/config/agents/examples/claude.yaml +45 -6
  13. package/config/agents/examples/council.yaml +23 -8
  14. package/config/agents/examples/critic.yaml +57 -21
  15. package/config/agents/examples/delegation.yaml +76 -9
  16. package/config/agents/examples/hierarchical.yaml +55 -8
  17. package/config/agents/examples/image_production.yaml +54 -16
  18. package/config/agents/examples/librarian.yaml +70 -14
  19. package/config/agents/examples/model_zoo.yaml +105 -29
  20. package/config/agents/examples/patient_advocate.yaml +102 -34
  21. package/config/agents/examples/research.yaml +250 -0
  22. package/config/agents/examples/scribe.yaml +242 -0
  23. package/config/agents/examples/scriptorium.yaml +87 -24
  24. package/config/agents/examples/style_council.yaml +31 -9
  25. package/config/agents/examples/syndicate.yaml +29 -10
  26. package/config/agents/examples/tutor.yaml +10 -3
  27. package/config/agents/syndicateSchema.yaml +44 -9
  28. package/config/agents/templates/README.md +58 -0
  29. package/config/agents/templates/account_memory.yaml +153 -0
  30. package/config/agents/templates/case_desk.yaml +188 -0
  31. package/config/agents/templates/conversational.yaml +110 -0
  32. package/config/agents/templates/draft_review.yaml +130 -0
  33. package/config/agents/templates/intake_extractor.yaml +152 -0
  34. package/config/agents/templates/research_brief.yaml +169 -0
  35. package/config/agents/templates/research_desk.yaml +152 -0
  36. package/config/agents/templates/review_panel.yaml +188 -0
  37. package/config/agents/templates/support_triage.yaml +273 -0
  38. package/config/agents/templates/systems_operator.yaml +157 -0
  39. package/db/hardening.sql +202 -0
  40. package/db/memory_v2.sql +77 -0
  41. package/db/migrations/0001_base.sql +167 -0
  42. package/db/migrations/0002_erase_scope.sql +172 -0
  43. package/db/migrations/0003_postgres_storage.sql +58 -0
  44. package/db/migrations/0004_usage.sql +94 -0
  45. package/db/migrations/0005_agent_registry.sql +200 -0
  46. package/db/migrations/0006_task_leases.sql +25 -0
  47. package/db/migrations/0007_memory_commit.sql +134 -0
  48. package/db/migrations/0008_memory_retention.sql +37 -0
  49. package/db/migrations/0009_task_queue.sql +51 -0
  50. package/db/migrations/0010_erase_complete.sql +180 -0
  51. package/db/telemetry.sql +401 -0
  52. package/demo/README.md +77 -0
  53. package/demo/a2a_demo.mjs +95 -0
  54. package/dist/lib/a2a/app.d.ts +253 -0
  55. package/dist/lib/a2a/app.js +751 -0
  56. package/dist/lib/a2a/executor.d.ts +167 -0
  57. package/dist/lib/a2a/executor.js +548 -0
  58. package/dist/lib/a2a/identity.d.ts +122 -0
  59. package/dist/lib/a2a/identity.js +254 -0
  60. package/dist/lib/a2a/policy.d.ts +110 -0
  61. package/dist/lib/a2a/policy.js +178 -0
  62. package/dist/lib/a2a/redisLimits.d.ts +26 -0
  63. package/dist/lib/a2a/redisLimits.js +31 -0
  64. package/dist/lib/a2a/remoteAgent.d.ts +91 -0
  65. package/dist/lib/a2a/remoteAgent.js +268 -0
  66. package/dist/lib/a2a/turnLock.d.ts +28 -0
  67. package/dist/lib/a2a/turnLock.js +76 -0
  68. package/dist/lib/compile.d.ts +11 -1
  69. package/dist/lib/compile.js +109 -8
  70. package/dist/lib/config.d.ts +7 -7
  71. package/dist/lib/config.js +7 -7
  72. package/dist/lib/doctor.d.ts +145 -0
  73. package/dist/lib/doctor.js +517 -0
  74. package/dist/lib/grounding.d.ts +2 -2
  75. package/dist/lib/grounding.js +2 -2
  76. package/dist/lib/guards/index.d.ts +37 -0
  77. package/dist/lib/guards/index.js +66 -0
  78. package/dist/lib/guards/science.d.ts +117 -0
  79. package/dist/lib/guards/science.js +444 -0
  80. package/dist/lib/index.d.ts +37 -2
  81. package/dist/lib/index.js +35 -4
  82. package/dist/lib/loadEnv.d.ts +23 -11
  83. package/dist/lib/loadEnv.js +77 -29
  84. package/dist/lib/loadSyndicate.d.ts +97 -5
  85. package/dist/lib/loadSyndicate.js +176 -76
  86. package/dist/lib/memory/erase.d.ts +24 -0
  87. package/dist/lib/memory/erase.js +47 -0
  88. package/dist/lib/memory/namespace.d.ts +37 -0
  89. package/dist/lib/memory/namespace.js +74 -0
  90. package/dist/lib/memory/providers.d.ts +99 -0
  91. package/dist/lib/memory/providers.js +240 -0
  92. package/dist/lib/memory/store.d.ts +74 -0
  93. package/dist/lib/memory/store.js +108 -0
  94. package/dist/lib/memory/supabaseMemoryService.d.ts +69 -9
  95. package/dist/lib/memory/supabaseMemoryService.js +239 -125
  96. package/dist/lib/models/capabilities.d.ts +103 -0
  97. package/dist/lib/models/capabilities.js +291 -0
  98. package/dist/lib/models/claudeLlm.d.ts +10 -1
  99. package/dist/lib/models/claudeLlm.js +57 -27
  100. package/dist/lib/models/endpoints.d.ts +100 -0
  101. package/dist/lib/models/endpoints.js +282 -0
  102. package/dist/lib/models/gateway.d.ts +107 -0
  103. package/dist/lib/models/gateway.js +157 -0
  104. package/dist/lib/models/gatewayLlm.d.ts +57 -0
  105. package/dist/lib/models/gatewayLlm.js +105 -0
  106. package/dist/lib/models/gptLlm.d.ts +51 -2
  107. package/dist/lib/models/gptLlm.js +169 -26
  108. package/dist/lib/models/grokLlm.d.ts +19 -10
  109. package/dist/lib/models/grokLlm.js +29 -14
  110. package/dist/lib/models/ollamaLlm.d.ts +26 -0
  111. package/dist/lib/models/ollamaLlm.js +43 -1
  112. package/dist/lib/models/openAiCompatibleLlm.d.ts +67 -2
  113. package/dist/lib/models/openAiCompatibleLlm.js +200 -37
  114. package/dist/lib/models/providerMap.js +9 -2
  115. package/dist/lib/models/registry.d.ts +42 -5
  116. package/dist/lib/models/registry.js +140 -29
  117. package/dist/lib/models/retry.d.ts +146 -0
  118. package/dist/lib/models/retry.js +305 -0
  119. package/dist/lib/models/schemaNormalize.d.ts +20 -0
  120. package/dist/lib/models/schemaNormalize.js +43 -0
  121. package/dist/lib/net/addressGuard.d.ts +60 -0
  122. package/dist/lib/net/addressGuard.js +216 -0
  123. package/dist/lib/observability/embeddings.d.ts +11 -2
  124. package/dist/lib/observability/embeddings.js +18 -14
  125. package/dist/lib/observability/metrics.d.ts +36 -0
  126. package/dist/lib/observability/metrics.js +86 -0
  127. package/dist/lib/observability/redact.d.ts +29 -0
  128. package/dist/lib/observability/redact.js +102 -0
  129. package/dist/lib/observability/supabaseSpanExporter.d.ts +2 -0
  130. package/dist/lib/observability/supabaseSpanExporter.js +22 -6
  131. package/dist/lib/observability/tracer.d.ts +10 -0
  132. package/dist/lib/observability/tracer.js +70 -3
  133. package/dist/lib/persistence/supabaseProvider.d.ts +11 -0
  134. package/dist/lib/persistence/supabaseProvider.js +32 -6
  135. package/dist/lib/registry.d.ts +87 -0
  136. package/dist/lib/registry.js +146 -0
  137. package/dist/lib/runtime/approvals.d.ts +44 -0
  138. package/dist/lib/runtime/approvals.js +67 -0
  139. package/dist/lib/runtime/syndicateTurn.d.ts +233 -0
  140. package/dist/lib/runtime/syndicateTurn.js +597 -0
  141. package/dist/lib/runtime/turnControl.d.ts +90 -0
  142. package/dist/lib/runtime/turnControl.js +131 -0
  143. package/dist/lib/session/supabaseSessionService.js +1 -1
  144. package/dist/lib/session/transcript.d.ts +9 -1
  145. package/dist/lib/session/transcript.js +15 -7
  146. package/dist/lib/skills.d.ts +50 -0
  147. package/dist/lib/skills.js +138 -0
  148. package/dist/lib/storage/postgres/index.d.ts +98 -0
  149. package/dist/lib/storage/postgres/index.js +146 -0
  150. package/dist/lib/storage/postgres/memoryStore.d.ts +11 -0
  151. package/dist/lib/storage/postgres/memoryStore.js +82 -0
  152. package/dist/lib/storage/postgres/sessionService.d.ts +51 -0
  153. package/dist/lib/storage/postgres/sessionService.js +225 -0
  154. package/dist/lib/storage/postgres/taskQueue.d.ts +22 -0
  155. package/dist/lib/storage/postgres/taskQueue.js +101 -0
  156. package/dist/lib/storage/postgres/taskStore.d.ts +55 -0
  157. package/dist/lib/storage/postgres/taskStore.js +168 -0
  158. package/dist/lib/storage/schema.d.ts +27 -0
  159. package/dist/lib/storage/schema.js +50 -0
  160. package/dist/lib/storage/schemaVersion.d.ts +25 -0
  161. package/dist/lib/storage/schemaVersion.js +46 -0
  162. package/dist/lib/syndicateSchema.d.ts +275 -0
  163. package/dist/lib/syndicateSchema.js +534 -0
  164. package/dist/lib/toolRegistry.d.ts +15 -0
  165. package/dist/lib/toolRegistry.js +59 -10
  166. package/dist/lib/tools/collectionsSearchTool.js +1 -1
  167. package/dist/lib/tools/mcpServe.d.ts +45 -0
  168. package/dist/lib/tools/mcpServe.js +88 -0
  169. package/dist/lib/tools/mcpToolFactory.d.ts +2 -0
  170. package/dist/lib/tools/mcpToolFactory.js +41 -21
  171. package/dist/lib/tools/science/corrections.d.ts +42 -0
  172. package/dist/lib/tools/science/corrections.js +115 -0
  173. package/dist/lib/tools/science/europepmc.d.ts +51 -0
  174. package/dist/lib/tools/science/europepmc.js +127 -0
  175. package/dist/lib/tools/science/http.d.ts +31 -0
  176. package/dist/lib/tools/science/http.js +104 -0
  177. package/dist/lib/tools/science/openalex.d.ts +30 -0
  178. package/dist/lib/tools/science/openalex.js +81 -0
  179. package/dist/lib/tools/science/trials.d.ts +58 -0
  180. package/dist/lib/tools/science/trials.js +203 -0
  181. package/dist/lib/tools/science/types.d.ts +69 -0
  182. package/dist/lib/tools/science/types.js +48 -0
  183. package/dist/lib/tools/scienceTools.d.ts +100 -0
  184. package/dist/lib/tools/scienceTools.js +277 -0
  185. package/dist/lib/tools/taskTools.d.ts +204 -0
  186. package/dist/lib/tools/taskTools.js +374 -0
  187. package/dist/lib/tools/toolContract.d.ts +17 -4
  188. package/dist/lib/tools/toolContract.js +13 -5
  189. package/dist/lib/tools/webExtractTool.d.ts +13 -10
  190. package/dist/lib/tools/webExtractTool.js +23 -55
  191. package/dist/lib/tools/webSearchTool.d.ts +2 -1
  192. package/dist/lib/tools/webSearchTool.js +4 -3
  193. package/dist/lib/tools/wikiTools.d.ts +2 -2
  194. package/dist/lib/tools/wikiTools.js +12 -12
  195. package/dist/lib/tools/xApiSearchTool.d.ts +194 -0
  196. package/dist/lib/tools/xApiSearchTool.js +583 -0
  197. package/dist/lib/tools/xSearchTool.js +1 -1
  198. package/dist/lib/wiki/agentRun.js +1 -1
  199. package/dist/lib/wiki/builder.js +21 -6
  200. package/dist/lib/wiki/dive.js +2 -2
  201. package/dist/lib/wiki/entities.d.ts +2 -2
  202. package/dist/lib/wiki/entities.js +7 -7
  203. package/dist/lib/wiki/extract.js +5 -1
  204. package/dist/lib/wiki/fill.js +5 -5
  205. package/dist/lib/wiki/format.d.ts +7 -5
  206. package/dist/lib/wiki/format.js +2 -2
  207. package/dist/lib/wiki/graph.js +2 -2
  208. package/dist/lib/wiki/lint.js +2 -2
  209. package/dist/lib/wiki/markdown.d.ts +1 -1
  210. package/dist/lib/wiki/markdown.js +6 -1
  211. package/dist/lib/wiki/pipeline.js +2 -2
  212. package/dist/lib/wiki/vault.js +2 -2
  213. package/dist/scripts/a2a_server.d.ts +17 -11
  214. package/dist/scripts/a2a_server.js +405 -1028
  215. package/dist/scripts/assistant_worker.d.ts +33 -0
  216. package/dist/scripts/assistant_worker.js +193 -0
  217. package/dist/scripts/db.d.ts +26 -0
  218. package/dist/scripts/db.js +179 -0
  219. package/dist/scripts/doctor.d.ts +23 -0
  220. package/dist/scripts/doctor.js +90 -0
  221. package/dist/scripts/init.d.ts +43 -0
  222. package/dist/scripts/init.js +152 -0
  223. package/dist/scripts/registry.d.ts +25 -0
  224. package/dist/scripts/registry.js +182 -0
  225. package/dist/scripts/skills_install.d.ts +7 -0
  226. package/dist/scripts/skills_install.js +137 -0
  227. package/dist/scripts/syndicate_chat.js +159 -276
  228. package/package.json +90 -16
  229. package/skills/README.md +74 -0
  230. package/skills/briefs/README.md +16 -0
  231. package/skills/briefs/_shared.md +16 -0
  232. package/skills/briefs/melchizedek-author.md +60 -0
  233. package/skills/briefs/melchizedek-memory.md +47 -0
  234. package/skills/briefs/melchizedek-models.md +47 -0
  235. package/skills/briefs/melchizedek-scribe.md +37 -0
  236. package/skills/briefs/melchizedek-serve.md +56 -0
  237. package/skills/briefs/melchizedek.md +69 -0
  238. package/skills/briefs/skills-readme.md +33 -0
  239. package/skills/melchizedek/SKILL.md +159 -0
  240. package/skills/melchizedek-author/SKILL.md +99 -0
  241. package/skills/melchizedek-author/templates/minimal.yaml +60 -0
  242. package/skills/melchizedek-memory/SKILL.md +138 -0
  243. package/skills/melchizedek-models/SKILL.md +137 -0
  244. package/skills/melchizedek-scribe/SKILL.md +72 -0
  245. package/skills/melchizedek-scribe/templates/brief.md +27 -0
  246. package/skills/melchizedek-serve/SKILL.md +119 -0
@@ -0,0 +1,146 @@
1
+ /**
2
+ * lib/storage/postgres/index.ts — every durable store on one Postgres
3
+ * connection (ADR 0021).
4
+ *
5
+ * const storage = postgresStorage({ connectionString: process.env.DATABASE_URL });
6
+ * createA2AApp({ ..., storage });
7
+ *
8
+ * Works on any Postgres with pgvector: Supabase through its connection
9
+ * string, RDS, Cloud SQL, AlloyDB, on-premises. Apply the schema first
10
+ * (`npm run db -- apply`, db/migrations/). With this storage plugged in, A2A
11
+ * tasks and conversations are shared by every instance, so the server can run
12
+ * more than one.
13
+ */
14
+ import pg from 'pg';
15
+ import { SupabaseVectorMemoryService } from '../../memory/supabaseMemoryService.js';
16
+ import { ERASE_STORES } from '../../memory/erase.js';
17
+ import { postgresMemoryStore } from './memoryStore.js';
18
+ import { PostgresSessionService } from './sessionService.js';
19
+ import { PostgresTaskStore, reapExpiredTasks, renewTaskLeases } from './taskStore.js';
20
+ import { postgresTaskBackend } from './taskQueue.js';
21
+ import { searchPathOption } from '../schema.js';
22
+ import { createHash, randomUUID } from 'node:crypto';
23
+ import { hostname } from 'node:os';
24
+ export { PostgresSessionService } from './sessionService.js';
25
+ export { PostgresTaskStore } from './taskStore.js';
26
+ export { postgresMemoryStore } from './memoryStore.js';
27
+ /**
28
+ * A pool emits 'error' when an idle connection dies (a database restart, a
29
+ * failover, an administrator's terminate); unhandled, that event crashes the
30
+ * process. The pool drops the dead client and opens a fresh one on the next
31
+ * query, so the right response is to log it and carry on.
32
+ */
33
+ function survivesIdleErrors(p, label) {
34
+ p.on('error', (err) => console.warn(`[storage] ${label}: an idle Postgres connection failed (${err.message}); it will be replaced.`));
35
+ return p;
36
+ }
37
+ /**
38
+ * The advisory-lock id for a turn-lock key: the first 64 bits of its SHA-256,
39
+ * signed. Hashed here rather than with Postgres' hashtext, because a key is
40
+ * joined with NUL separators (lib/a2a/turnLock.ts) and Postgres text cannot
41
+ * hold a NUL byte. The same key gives the same id on every instance.
42
+ */
43
+ export function advisoryLockId(key) {
44
+ return BigInt.asIntN(64, BigInt(`0x${createHash('sha256').update(key).digest('hex').slice(0, 16)}`)).toString();
45
+ }
46
+ export function postgresStorage(options) {
47
+ const owned = !options.pool;
48
+ const pool = options.pool ??
49
+ survivesIdleErrors(new pg.Pool({
50
+ connectionString: options.connectionString,
51
+ ...options.poolConfig,
52
+ ...(searchPathOption(options.schema ?? 'public') ? { options: searchPathOption(options.schema ?? 'public') } : {}),
53
+ }), 'pool');
54
+ if (!options.pool && !options.connectionString && !options.poolConfig?.host) {
55
+ throw new Error('postgresStorage needs a connectionString (e.g. DATABASE_URL) or a pool');
56
+ }
57
+ const sessionService = new PostgresSessionService(pool, { ttlDays: options.ttlDays });
58
+ const lease = { instanceId: `${hostname()}:${process.pid}:${randomUUID().slice(0, 8)}`, ttlMs: options.taskLeaseMs ?? 60_000 };
59
+ const memoryService = options.memory
60
+ ? new SupabaseVectorMemoryService({ apiKey: options.memory.apiKey ?? '', extractor: options.memory.extractor, embedder: options.memory.embedder }, postgresMemoryStore(pool))
61
+ : undefined;
62
+ // Turn locks hold a connection for a whole turn: their own small pool.
63
+ let lockPool;
64
+ const locks = () => (lockPool ??= survivesIdleErrors(new pg.Pool({
65
+ ...(options.pool ? options.pool.options : { connectionString: options.connectionString, ...options.poolConfig }),
66
+ max: options.lockPoolMax ?? 20,
67
+ }), 'turn-lock pool'));
68
+ const turnLock = async (key, { waitMs, signal }) => {
69
+ const lockId = advisoryLockId(key);
70
+ const client = await locks().connect();
71
+ const deadline = Date.now() + Math.max(0, waitMs);
72
+ try {
73
+ for (;;) {
74
+ const r = await client.query('SELECT pg_try_advisory_lock($1::bigint) AS ok', [lockId]);
75
+ if (r.rows[0]?.ok)
76
+ break;
77
+ if (Date.now() >= deadline || signal?.aborted) {
78
+ client.release();
79
+ return null;
80
+ }
81
+ await new Promise((res) => setTimeout(res, Math.min(200, Math.max(10, deadline - Date.now()))));
82
+ }
83
+ }
84
+ catch (err) {
85
+ client.release(err);
86
+ throw err;
87
+ }
88
+ let released = false;
89
+ const release = async () => {
90
+ if (released)
91
+ return;
92
+ released = true;
93
+ try {
94
+ await client.query('SELECT pg_advisory_unlock($1::bigint)', [lockId]);
95
+ client.release();
96
+ }
97
+ catch (err) {
98
+ // Dropping the connection releases a session-level lock too.
99
+ client.release(err);
100
+ }
101
+ };
102
+ return release;
103
+ };
104
+ return {
105
+ pool,
106
+ sessionService,
107
+ turnLock,
108
+ ...(memoryService ? { memoryService } : {}),
109
+ taskStore: (agentId) => new PostgresTaskStore(pool, agentId, { ttlDays: options.ttlDays, ownerResolver: options.taskOwner, lease }),
110
+ leases: { ...lease, renew: () => renewTaskLeases(pool, lease), reap: () => reapExpiredTasks(pool) },
111
+ taskQueue: postgresTaskBackend(pool),
112
+ async schemaVersion() {
113
+ try {
114
+ const r = await pool.query('SELECT max(version) AS v FROM melchizedek_schema_version');
115
+ return r.rows[0]?.v == null ? null : Number(r.rows[0].v);
116
+ }
117
+ catch (err) {
118
+ // No version table at all: the migrations were never applied.
119
+ if (err.code === '42P01')
120
+ return null;
121
+ throw err;
122
+ }
123
+ },
124
+ async erase(scopeKey, eraseOptions = {}) {
125
+ if (!scopeKey?.trim())
126
+ throw new Error('erase: a scope key is required');
127
+ const r = await pool.query('SELECT store, deleted FROM melchizedek_erase_scope($1, $2, $3)', [
128
+ scopeKey,
129
+ eraseOptions.namespace ?? null,
130
+ eraseOptions.includeNested ?? false,
131
+ ]);
132
+ const counts = Object.fromEntries(ERASE_STORES.map((s) => [s, 0]));
133
+ for (const row of r.rows) {
134
+ if (ERASE_STORES.includes(row.store))
135
+ counts[row.store] = Number(row.deleted);
136
+ }
137
+ return counts;
138
+ },
139
+ async close() {
140
+ if (lockPool)
141
+ await lockPool.end();
142
+ if (owned)
143
+ await pool.end();
144
+ },
145
+ };
146
+ }
@@ -0,0 +1,11 @@
1
+ /**
2
+ * lib/storage/postgres/memoryStore.ts — memory facts on a direct Postgres
3
+ * connection: the MemoryStore (lib/memory/store.ts) the memory service runs
4
+ * on, over the same table and `match_memory_facts` function the Supabase
5
+ * store uses.
6
+ */
7
+ import type { Pool } from 'pg';
8
+ import type { MemoryStore } from '../../memory/store.ts';
9
+ /** pgvector's text form: '[0.1,0.2,…]'. */
10
+ export declare function vectorLiteral(v: number[]): string;
11
+ export declare function postgresMemoryStore(pool: Pool): MemoryStore;
@@ -0,0 +1,82 @@
1
+ /**
2
+ * lib/storage/postgres/memoryStore.ts — memory facts on a direct Postgres
3
+ * connection: the MemoryStore (lib/memory/store.ts) the memory service runs
4
+ * on, over the same table and `match_memory_facts` function the Supabase
5
+ * store uses.
6
+ */
7
+ import { commitRows } from '../../memory/store.js';
8
+ /** pgvector's text form: '[0.1,0.2,…]'. */
9
+ export function vectorLiteral(v) {
10
+ return `[${v.map((x) => (Number.isFinite(x) ? x : 0)).join(',')}]`;
11
+ }
12
+ export function postgresMemoryStore(pool) {
13
+ return {
14
+ async existingFacts(userKey, facts) {
15
+ if (facts.length === 0)
16
+ return new Set();
17
+ const r = await pool.query('SELECT fact FROM adk_memory_facts WHERE user_key = $1 AND fact = ANY($2::text[])', [
18
+ userKey,
19
+ facts,
20
+ ]);
21
+ return new Set(r.rows.map((row) => row.fact));
22
+ },
23
+ async match(userKey, embedding, count) {
24
+ const r = await pool.query('SELECT * FROM match_memory_facts($1::vector, $2, $3)', [
25
+ vectorLiteral(embedding),
26
+ userKey,
27
+ count,
28
+ ]);
29
+ return r.rows.map((row) => ({
30
+ ...row,
31
+ id: String(row.id),
32
+ fact_date: row.fact_date instanceof Date ? row.fact_date.toISOString().slice(0, 10) : row.fact_date,
33
+ created_at: row.created_at instanceof Date ? row.created_at.toISOString() : row.created_at,
34
+ similarity: Number(row.similarity),
35
+ }));
36
+ },
37
+ async insert(rows) {
38
+ if (rows.length === 0)
39
+ return [];
40
+ const values = [];
41
+ const tuples = rows.map((row, i) => {
42
+ const b = i * 8;
43
+ values.push(row.user_key, row.fact, vectorLiteral(row.embedding), row.tag, row.fact_date, row.source, row.status, row.keys);
44
+ return `($${b + 1}, $${b + 2}, $${b + 3}::vector, $${b + 4}, $${b + 5}::date, $${b + 6}, $${b + 7}, $${b + 8}::text[])`;
45
+ });
46
+ const r = await pool.query(`INSERT INTO adk_memory_facts (user_key, fact, embedding, tag, fact_date, source, status, keys)
47
+ VALUES ${tuples.join(', ')}
48
+ RETURNING id, fact`, values);
49
+ return r.rows.map((row) => ({ id: String(row.id), fact: row.fact }));
50
+ },
51
+ async retire(userKey, id, supersededBy) {
52
+ await pool.query(`UPDATE adk_memory_facts SET status = 'superseded', superseded_by = $3, updated_at = now()
53
+ WHERE id = $1 AND user_key = $2`, [id, userKey, supersededBy]);
54
+ },
55
+ async deleteUser(userKey) {
56
+ const r = await pool.query('DELETE FROM adk_memory_facts WHERE user_key = $1', [userKey]);
57
+ return r.rowCount ?? 0;
58
+ },
59
+ async commit(userKey, marker, rows, retire) {
60
+ const r = await pool.query('SELECT new_id, new_fact FROM melchizedek_memory_commit($1, $2, $3, $4::jsonb, $5::jsonb)', [
61
+ userKey,
62
+ marker?.sessionId ?? null,
63
+ marker?.events ?? null,
64
+ JSON.stringify(commitRows(rows)),
65
+ JSON.stringify(retire.map((x) => ({ id: x.id, by_fact: x.byFact }))),
66
+ ]);
67
+ return r.rows.map((row) => ({ id: String(row.new_id), fact: row.new_fact }));
68
+ },
69
+ async ingestedEvents(userKey, sessionId) {
70
+ const r = await pool.query('SELECT events_ingested FROM melchizedek_memory_ingest WHERE user_key = $1 AND session_id = $2', [userKey, sessionId]);
71
+ return r.rows[0]?.events_ingested ?? 0;
72
+ },
73
+ async embeddingDimensions() {
74
+ const r = await pool.query('SELECT melchizedek_memory_dimensions() AS d');
75
+ return r.rows[0]?.d == null ? null : Number(r.rows[0].d);
76
+ },
77
+ async pruneNamespace(namespace, days) {
78
+ const r = await pool.query('SELECT melchizedek_prune_memory($1, $2) AS n', [namespace, days]);
79
+ return Number(r.rows[0]?.n ?? 0);
80
+ },
81
+ };
82
+ }
@@ -0,0 +1,51 @@
1
+ /**
2
+ * lib/storage/postgres/sessionService.ts — ADK sessions on a direct Postgres
3
+ * connection (ADR 0021).
4
+ *
5
+ * WHY it differs from the Supabase session service:
6
+ * That one re-uploads a conversation's WHOLE events array on every event.
7
+ * Two turns on one conversation each hold a private copy, and whichever
8
+ * writes last erases the other's events; the bytes written grow with the
9
+ * square of the conversation's length. Here each event is one row in
10
+ * adk_session_events, appended under a row lock on its session, and state
11
+ * changes are merged into the stored state rather than overwriting it.
12
+ *
13
+ * Events are stored as ADK produced them. The Supabase service trims thought
14
+ * signatures and large tool payloads because it rewrites everything on every
15
+ * event; appending each event once removes that cost, and a DELEGATE
16
+ * conversation that is replayed to the model gets back exactly what it sent.
17
+ *
18
+ * Rows share adk_sessions with the Supabase service (same id scheme:
19
+ * '<appName>:<userId>:<sessionId>'), so erase and the session prune cover
20
+ * both. Tables: db/migrations/0001_base.sql and 0003_postgres_storage.sql.
21
+ */
22
+ import { BaseSessionService } from '@google/adk';
23
+ import type { CreateSessionRequest, DeleteSessionRequest, Event, GetSessionRequest, ListSessionsRequest, ListSessionsResponse, Session } from '@google/adk';
24
+ import type { Pool } from 'pg';
25
+ export interface PostgresSessionOptions {
26
+ /** Days a conversation is kept after its last event (expire_at). Default 7. */
27
+ ttlDays?: number;
28
+ }
29
+ /** The persisted part of a state delta: ADK never stores `temp:` keys. */
30
+ export declare function persistedDelta(delta: Record<string, unknown> | undefined): Record<string, unknown>;
31
+ export declare class PostgresSessionService extends BaseSessionService {
32
+ private readonly ttlMs;
33
+ private readonly pool;
34
+ constructor(pool: Pool, options?: PostgresSessionOptions);
35
+ private expireAt;
36
+ createSession(request: CreateSessionRequest): Promise<Session>;
37
+ /** Copy a legacy JSON history into rows, once, under the conversation's row lock. */
38
+ private importLegacyEvents;
39
+ getSession(request: GetSessionRequest): Promise<Session | undefined>;
40
+ /**
41
+ * Same paging contract as the Supabase service: `limit` with `page`
42
+ * (1-based, wins) or `offset` (0-based), optional order by last update,
43
+ * and the real total alongside the page. Events are omitted.
44
+ */
45
+ listSessions(request: ListSessionsRequest): Promise<ListSessionsResponse>;
46
+ deleteSession(request: DeleteSessionRequest): Promise<void>;
47
+ appendEvent(request: {
48
+ session: Session;
49
+ event: Event;
50
+ }): Promise<Event>;
51
+ }
@@ -0,0 +1,225 @@
1
+ /**
2
+ * lib/storage/postgres/sessionService.ts — ADK sessions on a direct Postgres
3
+ * connection (ADR 0021).
4
+ *
5
+ * WHY it differs from the Supabase session service:
6
+ * That one re-uploads a conversation's WHOLE events array on every event.
7
+ * Two turns on one conversation each hold a private copy, and whichever
8
+ * writes last erases the other's events; the bytes written grow with the
9
+ * square of the conversation's length. Here each event is one row in
10
+ * adk_session_events, appended under a row lock on its session, and state
11
+ * changes are merged into the stored state rather than overwriting it.
12
+ *
13
+ * Events are stored as ADK produced them. The Supabase service trims thought
14
+ * signatures and large tool payloads because it rewrites everything on every
15
+ * event; appending each event once removes that cost, and a DELEGATE
16
+ * conversation that is replayed to the model gets back exactly what it sent.
17
+ *
18
+ * Rows share adk_sessions with the Supabase service (same id scheme:
19
+ * '<appName>:<userId>:<sessionId>'), so erase and the session prune cover
20
+ * both. Tables: db/migrations/0001_base.sql and 0003_postgres_storage.sql.
21
+ */
22
+ import { randomUUID } from 'node:crypto';
23
+ import { BaseSessionService, createSession } from '@google/adk';
24
+ function dbId(appName, userId, sessionId) {
25
+ return `${appName}:${userId}:${sessionId}`;
26
+ }
27
+ /** The persisted part of a state delta: ADK never stores `temp:` keys. */
28
+ export function persistedDelta(delta) {
29
+ const out = {};
30
+ for (const [k, v] of Object.entries(delta ?? {})) {
31
+ if (!k.startsWith('temp:'))
32
+ out[k] = v;
33
+ }
34
+ return out;
35
+ }
36
+ /**
37
+ * A conversation written by the Supabase session service keeps its events as
38
+ * a JSON array on adk_sessions.events; this adapter keeps one row per event.
39
+ * Moving a deployment from one to the other (setting DATABASE_URL) must not
40
+ * cut every conversation off from its history, nor shift the event counts
41
+ * memory ingestion has recorded, so the first read or append of such a
42
+ * conversation copies its array into rows, in order. It runs only while the
43
+ * conversation has no rows, and leaves the array in place, so moving back is
44
+ * still possible.
45
+ */
46
+ const IMPORT_LEGACY_EVENTS = `
47
+ INSERT INTO adk_session_events (session_id, seq, ts, event)
48
+ SELECT s.id, e.ord,
49
+ CASE WHEN jsonb_typeof(e.elem->'timestamp') = 'number' THEN (e.elem->>'timestamp')::double precision END,
50
+ e.elem
51
+ FROM adk_sessions s,
52
+ jsonb_array_elements(coalesce(s.events, '[]'::jsonb)) WITH ORDINALITY AS e(elem, ord)
53
+ WHERE s.id = $1
54
+ AND NOT EXISTS (SELECT 1 FROM adk_session_events WHERE session_id = $1)`;
55
+ export class PostgresSessionService extends BaseSessionService {
56
+ ttlMs;
57
+ pool;
58
+ constructor(pool, options = {}) {
59
+ super();
60
+ this.pool = pool;
61
+ this.ttlMs = (options.ttlDays ?? 7) * 24 * 60 * 60 * 1000;
62
+ }
63
+ expireAt() {
64
+ return new Date(Date.now() + this.ttlMs).toISOString();
65
+ }
66
+ async createSession(request) {
67
+ const sessionId = request.sessionId || randomUUID();
68
+ const id = dbId(request.appName, request.userId, sessionId);
69
+ const now = Date.now();
70
+ // A concurrent create for the same id keeps the row that is already
71
+ // there, events and all, instead of resetting it.
72
+ const inserted = await this.pool.query(`INSERT INTO adk_sessions (id, app_name, user_id, state, events, last_update_time, expire_at)
73
+ VALUES ($1, $2, $3, $4::jsonb, '[]'::jsonb, $5, $6)
74
+ ON CONFLICT (id) DO NOTHING`, [id, request.appName, request.userId, JSON.stringify(request.state ?? {}), now, this.expireAt()]);
75
+ if (inserted.rowCount === 0) {
76
+ const existing = await this.getSession({ appName: request.appName, userId: request.userId, sessionId });
77
+ if (existing)
78
+ return existing;
79
+ }
80
+ return createSession({
81
+ id: sessionId,
82
+ appName: request.appName,
83
+ userId: request.userId,
84
+ state: request.state ?? {},
85
+ events: [],
86
+ lastUpdateTime: now,
87
+ });
88
+ }
89
+ /** Copy a legacy JSON history into rows, once, under the conversation's row lock. */
90
+ async importLegacyEvents(id) {
91
+ const client = await this.pool.connect();
92
+ try {
93
+ await client.query('BEGIN');
94
+ await client.query('SELECT 1 FROM adk_sessions WHERE id = $1 FOR UPDATE', [id]);
95
+ await client.query(IMPORT_LEGACY_EVENTS, [id]);
96
+ await client.query('COMMIT');
97
+ }
98
+ catch (err) {
99
+ await client.query('ROLLBACK').catch(() => { });
100
+ throw err;
101
+ }
102
+ finally {
103
+ client.release();
104
+ }
105
+ }
106
+ async getSession(request) {
107
+ const id = dbId(request.appName, request.userId, request.sessionId);
108
+ const row = await this.pool.query(`SELECT app_name, user_id, state, last_update_time,
109
+ (jsonb_array_length(coalesce(events, '[]'::jsonb)) > 0
110
+ AND NOT EXISTS (SELECT 1 FROM adk_session_events WHERE session_id = $1)) AS legacy
111
+ FROM adk_sessions WHERE id = $1`, [id]);
112
+ if (row.rowCount === 0)
113
+ return undefined;
114
+ if (row.rows[0].legacy)
115
+ await this.importLegacyEvents(id);
116
+ const cfg = request.config ?? {};
117
+ const params = [id];
118
+ let where = 'session_id = $1';
119
+ if (cfg.afterTimestamp) {
120
+ params.push(cfg.afterTimestamp);
121
+ where += ` AND ts > $${params.length}`;
122
+ }
123
+ // The newest N, returned oldest first.
124
+ const events = cfg.numRecentEvents
125
+ ? await this.pool.query(`SELECT event FROM (SELECT event, seq FROM adk_session_events WHERE ${where} ORDER BY seq DESC LIMIT ${Math.max(0, Math.floor(cfg.numRecentEvents))}) recent ORDER BY seq ASC`, params)
126
+ : await this.pool.query(`SELECT event FROM adk_session_events WHERE ${where} ORDER BY seq ASC`, params);
127
+ const r = row.rows[0];
128
+ return {
129
+ id: request.sessionId,
130
+ appName: r.app_name,
131
+ userId: r.user_id,
132
+ state: r.state ?? {},
133
+ events: events.rows.map((e) => e.event),
134
+ lastUpdateTime: Number(r.last_update_time ?? 0),
135
+ };
136
+ }
137
+ /**
138
+ * Same paging contract as the Supabase service: `limit` with `page`
139
+ * (1-based, wins) or `offset` (0-based), optional order by last update,
140
+ * and the real total alongside the page. Events are omitted.
141
+ */
142
+ async listSessions(request) {
143
+ const { limit, order } = request;
144
+ const offset = limit !== undefined && request.page !== undefined
145
+ ? (Math.max(1, request.page) - 1) * limit
146
+ : (request.offset ?? 0);
147
+ const params = [request.appName];
148
+ let where = 'app_name = $1';
149
+ if (request.userId !== undefined) {
150
+ params.push(request.userId);
151
+ where += ` AND user_id = $${params.length}`;
152
+ }
153
+ const total = await this.pool.query(`SELECT count(*)::int AS n FROM adk_sessions WHERE ${where}`, params);
154
+ const orderBy = order ? `ORDER BY last_update_time ${order === 'asc' ? 'ASC' : 'DESC'}, id` : 'ORDER BY id';
155
+ const window = `${limit !== undefined ? `LIMIT ${Math.max(0, Math.floor(limit))}` : ''} OFFSET ${Math.max(0, Math.floor(offset))}`;
156
+ const rows = await this.pool.query(`SELECT id, app_name, user_id, state, last_update_time FROM adk_sessions WHERE ${where} ${orderBy} ${window}`, params);
157
+ const sessions = rows.rows.map((row) => ({
158
+ id: String(row.id).slice(String(row.app_name).length + String(row.user_id).length + 2),
159
+ appName: row.app_name,
160
+ userId: row.user_id,
161
+ state: row.state ?? {},
162
+ events: [],
163
+ lastUpdateTime: Number(row.last_update_time ?? 0),
164
+ }));
165
+ const totalItems = total.rows[0].n;
166
+ return {
167
+ sessions,
168
+ page: limit ? Math.floor(offset / limit) + 1 : 1,
169
+ limit: limit ?? totalItems,
170
+ totalItems,
171
+ totalPages: limit ? Math.max(1, Math.ceil(totalItems / limit)) : 1,
172
+ };
173
+ }
174
+ async deleteSession(request) {
175
+ // Events go with it (ON DELETE CASCADE).
176
+ await this.pool.query('DELETE FROM adk_sessions WHERE id = $1', [
177
+ dbId(request.appName, request.userId, request.sessionId),
178
+ ]);
179
+ }
180
+ async appendEvent(request) {
181
+ const { session, event } = request;
182
+ // Streaming fragments are appended whole once complete.
183
+ if (event.partial)
184
+ return event;
185
+ // ADK's own merge: applies the state delta to the live session, strips
186
+ // temp: keys, pushes the event. Must run (see the Supabase service).
187
+ await super.appendEvent({ session, event });
188
+ session.lastUpdateTime = Date.now();
189
+ const id = dbId(session.appName, session.userId, session.id);
190
+ const delta = persistedDelta(event.actions?.stateDelta);
191
+ const stored = JSON.parse(JSON.stringify(event));
192
+ const client = await this.pool.connect();
193
+ try {
194
+ await client.query('BEGIN');
195
+ // The row lock serializes appends to one conversation across every
196
+ // process; a missing row (created elsewhere, or pruned) is recreated.
197
+ const locked = await client.query('SELECT 1 FROM adk_sessions WHERE id = $1 FOR UPDATE', [id]);
198
+ if (locked.rowCount === 0) {
199
+ await client.query(`INSERT INTO adk_sessions (id, app_name, user_id, state, events, last_update_time, expire_at)
200
+ VALUES ($1, $2, $3, '{}'::jsonb, '[]'::jsonb, $4, $5)
201
+ ON CONFLICT (id) DO NOTHING`, [id, session.appName, session.userId, session.lastUpdateTime, this.expireAt()]);
202
+ await client.query('SELECT 1 FROM adk_sessions WHERE id = $1 FOR UPDATE', [id]);
203
+ }
204
+ // A conversation the Supabase service wrote: its history first.
205
+ await client.query(IMPORT_LEGACY_EVENTS, [id]);
206
+ await client.query(`INSERT INTO adk_session_events (session_id, seq, ts, event)
207
+ VALUES ($1, (SELECT coalesce(max(seq), 0) + 1 FROM adk_session_events WHERE session_id = $1), $2, $3::jsonb)`, [id, typeof event.timestamp === 'number' ? event.timestamp : null, JSON.stringify(stored)]);
208
+ await client.query(`UPDATE adk_sessions
209
+ SET state = coalesce(state, '{}'::jsonb) || $2::jsonb,
210
+ last_update_time = $3,
211
+ expire_at = $4,
212
+ updated_at = now()
213
+ WHERE id = $1`, [id, JSON.stringify(delta), session.lastUpdateTime, this.expireAt()]);
214
+ await client.query('COMMIT');
215
+ }
216
+ catch (err) {
217
+ await client.query('ROLLBACK').catch(() => undefined);
218
+ throw new Error(`Failed to append event: ${err instanceof Error ? err.message : String(err)}`);
219
+ }
220
+ finally {
221
+ client.release();
222
+ }
223
+ return event;
224
+ }
225
+ }
@@ -0,0 +1,22 @@
1
+ /**
2
+ * lib/storage/postgres/taskQueue.ts — the task tools' TaskBackend on
3
+ * Postgres (migration 0009, ADR 0021).
4
+ *
5
+ * Every rule stays in lib/tools/taskTools.ts and runs on a TaskStore
6
+ * snapshot; this file only loads one owner's snapshot and writes the change
7
+ * back, atomically:
8
+ *
9
+ * mutate locks the owner's counter row, then that owner's task rows
10
+ * (FOR UPDATE), runs the change, and writes only what changed.
11
+ * A worker's claim and a mutate on the same job therefore never
12
+ * interleave: the claim skips a row a mutate holds, and a mutate
13
+ * waits for a claim in flight, then reads its result.
14
+ * claim takes the oldest queued background job of ANY owner with
15
+ * FOR UPDATE SKIP LOCKED, so two workers never take one job, and
16
+ * leases it to the worker until the lease runs out.
17
+ * recover finds running jobs whose lease ran out (the worker died) and
18
+ * applies the same interrupted-job rule the file store uses.
19
+ */
20
+ import type { Pool } from 'pg';
21
+ import type { TaskBackend } from '../../tools/taskTools.ts';
22
+ export declare function postgresTaskBackend(pool: Pool): TaskBackend;
@@ -0,0 +1,101 @@
1
+ import { applyFinish, applyInterrupted } from '../../tools/taskTools.js';
2
+ const seqOf = (id) => Number(id.slice(1)) || 0;
3
+ async function loadOwner(db, owner, lock) {
4
+ const head = await db.query(`SELECT next_id FROM melchizedek_task_owners WHERE owner = $1${lock ? ' FOR UPDATE' : ''}`, [owner]);
5
+ const rows = await db.query(`SELECT record FROM melchizedek_tasks WHERE owner = $1 ORDER BY seq${lock ? ' FOR UPDATE' : ''}`, [owner]);
6
+ return { version: 1, next_id: head.rows[0]?.next_id ?? 1, tasks: rows.rows.map((r) => r.record) };
7
+ }
8
+ async function inTransaction(pool, run) {
9
+ const client = await pool.connect();
10
+ try {
11
+ await client.query('BEGIN');
12
+ const out = await run(client);
13
+ await client.query('COMMIT');
14
+ return out;
15
+ }
16
+ catch (err) {
17
+ await client.query('ROLLBACK').catch(() => { });
18
+ throw err;
19
+ }
20
+ finally {
21
+ client.release();
22
+ }
23
+ }
24
+ export function postgresTaskBackend(pool) {
25
+ return {
26
+ read: (owner) => loadOwner(pool, owner, false),
27
+ async mutate(owner, change) {
28
+ return inTransaction(pool, async (client) => {
29
+ await client.query('INSERT INTO melchizedek_task_owners (owner) VALUES ($1) ON CONFLICT (owner) DO NOTHING', [owner]);
30
+ const store = await loadOwner(client, owner, true);
31
+ const before = new Map(store.tasks.map((t) => [t.id, JSON.stringify(t)]));
32
+ const out = change(store);
33
+ const after = new Set(store.tasks.map((t) => t.id));
34
+ for (const id of before.keys()) {
35
+ if (!after.has(id))
36
+ await client.query('DELETE FROM melchizedek_tasks WHERE owner = $1 AND id = $2', [owner, id]);
37
+ }
38
+ for (const t of store.tasks) {
39
+ if (before.get(t.id) === JSON.stringify(t))
40
+ continue;
41
+ // Leaving `running` ends the job's lease; staying in it keeps it.
42
+ await client.query(`INSERT INTO melchizedek_tasks (owner, id, seq, kind, status, record, updated_at)
43
+ VALUES ($1, $2, $3, $4, $5, $6::jsonb, NOW())
44
+ ON CONFLICT (owner, id) DO UPDATE
45
+ SET kind = EXCLUDED.kind, status = EXCLUDED.status, record = EXCLUDED.record, updated_at = NOW(),
46
+ lease_owner = CASE WHEN EXCLUDED.status = 'running' THEN melchizedek_tasks.lease_owner END,
47
+ lease_until = CASE WHEN EXCLUDED.status = 'running' THEN melchizedek_tasks.lease_until END`, [owner, t.id, seqOf(t.id), t.kind, t.status, JSON.stringify(t)]);
48
+ }
49
+ await client.query('UPDATE melchizedek_task_owners SET next_id = $2 WHERE owner = $1', [owner, store.next_id]);
50
+ return out;
51
+ });
52
+ },
53
+ async claimNext(worker) {
54
+ const stamp = new Date().toISOString();
55
+ const r = await pool.query(`WITH next AS (
56
+ SELECT owner, id FROM melchizedek_tasks
57
+ WHERE kind = 'background' AND status = 'queued'
58
+ ORDER BY seq, owner LIMIT 1 FOR UPDATE SKIP LOCKED
59
+ )
60
+ UPDATE melchizedek_tasks t
61
+ SET status = 'running',
62
+ lease_owner = $1,
63
+ lease_until = NOW() + ($2::int * interval '1 millisecond'),
64
+ updated_at = NOW(),
65
+ record = t.record || jsonb_build_object(
66
+ 'status', 'running',
67
+ 'attempts', COALESCE((t.record->>'attempts')::int, 0) + 1,
68
+ 'started_at', $3::text,
69
+ 'updated_at', $3::text)
70
+ FROM next
71
+ WHERE t.owner = next.owner AND t.id = next.id
72
+ RETURNING t.owner, t.record`, [worker.workerId, worker.leaseMs, stamp]);
73
+ const row = r.rows[0];
74
+ return row ? { ...row.record, owner: row.owner } : null;
75
+ },
76
+ async renew(worker, job) {
77
+ await pool.query(`UPDATE melchizedek_tasks SET lease_until = NOW() + ($4::int * interval '1 millisecond')
78
+ WHERE owner = $1 AND id = $2 AND lease_owner = $3 AND status = 'running'`, [job.owner, job.id, worker.workerId, worker.leaseMs]);
79
+ },
80
+ async finish(job, outcome) {
81
+ await this.mutate(job.owner, (store) => applyFinish(store, job.id, outcome));
82
+ },
83
+ async recover() {
84
+ return inTransaction(pool, async (client) => {
85
+ const r = await client.query(`SELECT owner, id, record FROM melchizedek_tasks
86
+ WHERE status = 'running' AND (lease_until IS NULL OR lease_until < NOW())
87
+ FOR UPDATE SKIP LOCKED`);
88
+ const requeued = [];
89
+ const failed = [];
90
+ for (const row of r.rows) {
91
+ const t = row.record;
92
+ const outcome = applyInterrupted(t);
93
+ (outcome === 'failed' ? failed : requeued).push(`${row.owner || '(shared)'}:${t.id}`);
94
+ await client.query(`UPDATE melchizedek_tasks SET status = $3, record = $4::jsonb, updated_at = NOW(), lease_owner = NULL, lease_until = NULL
95
+ WHERE owner = $1 AND id = $2`, [row.owner, row.id, t.status, JSON.stringify(t)]);
96
+ }
97
+ return { requeued, failed };
98
+ });
99
+ },
100
+ };
101
+ }