deepline 0.3.246 → 0.3.248

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 (67) hide show
  1. package/dist/bundling-sources/sdk/src/client.ts +177 -13
  2. package/dist/bundling-sources/sdk/src/index.ts +8 -0
  3. package/dist/bundling-sources/sdk/src/plays/input-example.ts +40 -0
  4. package/dist/bundling-sources/sdk/src/release.ts +1 -1
  5. package/dist/bundling-sources/sdk/src/runs/overview.ts +457 -0
  6. package/dist/bundling-sources/sdk/src/runs/receipts.ts +23 -0
  7. package/dist/bundling-sources/sdk/src/tool-calls.ts +21 -1
  8. package/dist/bundling-sources/sdk/src/types.ts +16 -1
  9. package/dist/bundling-sources/shared_libs/observability/scheduled-jobs.json +37 -1
  10. package/dist/bundling-sources/shared_libs/observability/scheduled-work-manual.ts +1 -0
  11. package/dist/bundling-sources/shared_libs/play-data-plane/column-names.ts +17 -3
  12. package/dist/bundling-sources/shared_libs/play-data-plane/sheet-contract.ts +40 -8
  13. package/dist/bundling-sources/shared_libs/play-runtime/app-runtime-api.ts +26 -1
  14. package/dist/bundling-sources/shared_libs/play-runtime/context.ts +150 -19
  15. package/dist/bundling-sources/shared_libs/play-runtime/ctx-types.ts +2 -1
  16. package/dist/bundling-sources/shared_libs/play-runtime/daytona-runtime-config.ts +6 -3
  17. package/dist/bundling-sources/shared_libs/play-runtime/daytona-sandbox-janitor.ts +26 -21
  18. package/dist/bundling-sources/shared_libs/play-runtime/daytona-sandbox-reaper.ts +23 -21
  19. package/dist/bundling-sources/shared_libs/play-runtime/deployment/fly-machines.ts +17 -0
  20. package/dist/bundling-sources/shared_libs/play-runtime/log-context.ts +105 -0
  21. package/dist/bundling-sources/shared_libs/play-runtime/output-size-limits.ts +8 -1
  22. package/dist/bundling-sources/shared_libs/play-runtime/protocol.ts +9 -0
  23. package/dist/bundling-sources/shared_libs/play-runtime/run-ledger-event-transport.ts +40 -4
  24. package/dist/bundling-sources/shared_libs/play-runtime/run-ledger.ts +50 -5
  25. package/dist/bundling-sources/shared_libs/play-runtime/run-lifecycle-policy.ts +3 -1
  26. package/dist/bundling-sources/shared_libs/play-runtime/run-log-buffer.ts +28 -1
  27. package/dist/bundling-sources/shared_libs/play-runtime/runner-app/index.ts +14 -1
  28. package/dist/bundling-sources/shared_libs/play-runtime/runner-app/runtime-terminal-push.ts +56 -16
  29. package/dist/bundling-sources/shared_libs/play-runtime/runner-backends/backends/daytona-control-transport.ts +184 -0
  30. package/dist/bundling-sources/shared_libs/play-runtime/runner-backends/backends/daytona-launch-transport.ts +327 -0
  31. package/dist/bundling-sources/shared_libs/play-runtime/runner-backends/backends/daytona-lifecycle.ts +87 -30
  32. package/dist/bundling-sources/shared_libs/play-runtime/runner-backends/backends/daytona-session-execution.ts +24 -13
  33. package/dist/bundling-sources/shared_libs/play-runtime/runner-backends/backends/daytona-start-admission.ts +23 -6
  34. package/dist/bundling-sources/shared_libs/play-runtime/runner-backends/backends/daytona.ts +76 -33
  35. package/dist/bundling-sources/shared_libs/play-runtime/scheduler-backends/absurd-runtime-worker.ts +90 -35
  36. package/dist/bundling-sources/shared_libs/play-runtime/scheduler-backends/postgres-pool.ts +16 -0
  37. package/dist/bundling-sources/shared_libs/play-runtime/scheduler-backends/postgres-progress.ts +62 -21
  38. package/dist/bundling-sources/shared_libs/play-runtime/scheduler-backends/postgres.ts +368 -66
  39. package/dist/bundling-sources/shared_libs/play-runtime/scheduler-backends/receipt-gateway.ts +22 -4
  40. package/dist/bundling-sources/shared_libs/play-runtime/scheduler-backends/run-ledger-outbox-recovery.ts +106 -81
  41. package/dist/bundling-sources/shared_libs/play-runtime/scheduler-backends/runtime-event-search.ts +1347 -0
  42. package/dist/bundling-sources/shared_libs/play-runtime/scheduler-backends/runtime-queue-health.ts +16 -19
  43. package/dist/bundling-sources/shared_libs/play-runtime/scheduler-backends/runtime-receipt-value-storage.ts +14 -6
  44. package/dist/bundling-sources/shared_libs/play-runtime/tool-execute-retry-policy.ts +16 -0
  45. package/dist/bundling-sources/shared_libs/play-runtime/tool-http-errors.ts +40 -0
  46. package/dist/bundling-sources/shared_libs/play-runtime/transient-service-error.ts +3 -2
  47. package/dist/bundling-sources/shared_libs/plays/authoring-contract.ts +5 -2
  48. package/dist/bundling-sources/shared_libs/plays/dataset-summary.ts +11 -7
  49. package/dist/bundling-sources/shared_libs/plays/static-pipeline-storage-codec.ts +5 -2
  50. package/dist/bundling-sources/shared_libs/plays/static-pipeline.ts +11 -2
  51. package/dist/cli/index.js +3504 -2368
  52. package/dist/cli/index.mjs +3188 -2052
  53. package/dist/{compiler-manifest-n8ZtPv2h.d.mts → compiler-manifest-DavmLa8y.d.mts} +1 -0
  54. package/dist/{compiler-manifest-n8ZtPv2h.d.ts → compiler-manifest-DavmLa8y.d.ts} +1 -0
  55. package/dist/index.d.mts +97 -5
  56. package/dist/index.d.ts +97 -5
  57. package/dist/index.js +135 -15
  58. package/dist/index.mjs +135 -15
  59. package/dist/install-integrity.json +9 -2
  60. package/dist/plays/bundle-play-file.d.mts +2 -2
  61. package/dist/plays/bundle-play-file.d.ts +2 -2
  62. package/dist/plays/bundle-play-file.mjs +1 -1
  63. package/dist/release.d.mts +1 -1
  64. package/dist/release.d.ts +1 -1
  65. package/dist/release.js +1 -1
  66. package/dist/release.mjs +1 -1
  67. package/package.json +1 -1
@@ -0,0 +1,1347 @@
1
+ /**
2
+ * The customer-visible, append-only search projection of durable Play facts.
3
+ *
4
+ * Outbox and receipt triggers write small rows in their authoritative SQL
5
+ * transaction. They never deliver work, replay a Play, or copy receipt output.
6
+ * The API may read only through fixed, tenant-scoped queries in this module.
7
+ */
8
+ import { normalizeRuntimeSchedulerSchema } from '../runtime-scheduler-topology';
9
+ import type { PostgresSchedulerQueryClient } from './postgres';
10
+
11
+ const EVENTS_TABLE = 'runtime_observability.events';
12
+ // Migration readiness tracks this projection body independently of scheduler
13
+ // wire and schema versions. Bump when a changed trigger must be reinstalled.
14
+ export const RUNTIME_EVENT_SEARCH_OUTBOX_PROJECTION_MARKER =
15
+ 'runtime-event-search-log-context-v7-terminal-progress-counts-auto-scope-log-mutex-channel-aliases';
16
+ export const RUNTIME_EVENTS_RETENTION_MS = 7 * 24 * 60 * 60 * 1000;
17
+ const MAX_SEARCH_WINDOW_MS = RUNTIME_EVENTS_RETENTION_MS;
18
+ const MAX_SEARCH_RESULTS = 200;
19
+ const MAX_JSONPATH_BYTES = 512;
20
+
21
+ /** Explicit input validation; arbitrary TypeErrors are never public errors. */
22
+ export class RuntimeEventInputError extends TypeError {
23
+ constructor(message: string) {
24
+ super(message);
25
+ this.name = 'RuntimeEventInputError';
26
+ }
27
+ }
28
+
29
+ export type RuntimeSearchEvent = {
30
+ eventId: string;
31
+ occurredAt: string;
32
+ ingestedAt: string;
33
+ runId: string;
34
+ playName: string;
35
+ kind: string;
36
+ level: string | null;
37
+ message: string | null;
38
+ receiptRef: string | null;
39
+ searchDoc: Record<string, unknown>;
40
+ };
41
+
42
+ export type RuntimeSearchCursor = { occurredAt: string; eventId: string };
43
+
44
+ export type RuntimeEventSearchInput = {
45
+ runtimeScope: string;
46
+ orgId: string;
47
+ since: Date;
48
+ until: Date;
49
+ limit: number;
50
+ runId?: string;
51
+ playName?: string;
52
+ kind?: string;
53
+ jsonpath?: string;
54
+ cursor?: RuntimeSearchCursor;
55
+ };
56
+
57
+ export type RuntimeEventReceipt = {
58
+ /** Server-only; never serialize this physical key into a customer response. */
59
+ receiptKey: string;
60
+ status: 'completed' | 'failed' | 'skipped';
61
+ output: unknown | null;
62
+ outputRef: unknown | null;
63
+ error: string | null;
64
+ errorPayload: unknown | null;
65
+ };
66
+
67
+ export type RuntimeRunLogPage = {
68
+ totalLogCount: number;
69
+ logsTruncated: boolean;
70
+ lastStoredSeq: number;
71
+ firstStoredSeq: number | null;
72
+ entries: Array<{ seq: number; line: string }>;
73
+ };
74
+
75
+ function requiredScope(value: string): string {
76
+ const scope = normalizeRuntimeSchedulerSchema(value);
77
+ if (!scope)
78
+ throw new RuntimeEventInputError(
79
+ 'Runtime event search requires a valid runtime scope.',
80
+ );
81
+ return scope;
82
+ }
83
+
84
+ function requiredOrg(value: string): string {
85
+ const org = value?.trim();
86
+ if (!org || org.length > 256) {
87
+ throw new RuntimeEventInputError(
88
+ 'Runtime event search requires an organization.',
89
+ );
90
+ }
91
+ return org;
92
+ }
93
+
94
+ function quoteIdent(value: string): string {
95
+ return `"${value.replaceAll('"', '""')}"`;
96
+ }
97
+
98
+ function quoteLiteral(value: string): string {
99
+ return `'${value.replaceAll("'", "''")}'`;
100
+ }
101
+
102
+ /**
103
+ * Native PostgreSQL JSONPath equality, restricted to flat indexed fields or
104
+ * one scalar key under the bounded `context` object.
105
+ * No recursive paths, filters, methods, regex, comparisons or wildcards can
106
+ * turn an org-wide bounded search into an arbitrary JSON scan.
107
+ */
108
+ export function validateRuntimeEventJsonpath(value: string): string {
109
+ parseRuntimeEventJsonpath(value);
110
+ return value;
111
+ }
112
+
113
+ type RuntimeEventPredicate = {
114
+ path: string;
115
+ literal: string;
116
+ value: string | number | boolean;
117
+ };
118
+
119
+ function parseRuntimeEventJsonpath(value: string): RuntimeEventPredicate[] {
120
+ if (!value || Buffer.byteLength(value, 'utf8') > MAX_JSONPATH_BYTES) {
121
+ throw new RuntimeEventInputError(
122
+ 'Runtime event JSONPath is empty or exceeds 512 bytes.',
123
+ );
124
+ }
125
+ const clause =
126
+ /\$\.(orgId|runId|playName|runtimeScope|producerAttempt|kind|status|source|stepId|stepKind|datasetId|phase|provider|operation|context\.[A-Za-z_][A-Za-z0-9_]{0,63})\s*==\s*("(?:\\.|[^"\\])*"|true|false|-?\d+(?:\.\d+)?)/y;
127
+ let position = 0;
128
+ let clauses = 0;
129
+ const predicates: RuntimeEventPredicate[] = [];
130
+ while (position < value.length) {
131
+ clause.lastIndex = position;
132
+ const match = clause.exec(value);
133
+ if (!match)
134
+ throw new RuntimeEventInputError(
135
+ 'Runtime event JSONPath supports flat equality predicates only.',
136
+ );
137
+ let parsed: unknown;
138
+ try {
139
+ parsed = JSON.parse(match[2]);
140
+ } catch {
141
+ throw new RuntimeEventInputError(
142
+ 'Runtime event JSONPath requires a valid JSON scalar.',
143
+ );
144
+ }
145
+ if (match[2].startsWith('"')) {
146
+ if (typeof parsed !== 'string' || parsed.length > 256) {
147
+ throw new RuntimeEventInputError(
148
+ 'Runtime event JSONPath string value is too long.',
149
+ );
150
+ }
151
+ }
152
+ if (
153
+ (typeof parsed !== 'string' &&
154
+ typeof parsed !== 'number' &&
155
+ typeof parsed !== 'boolean') ||
156
+ (typeof parsed === 'number' && !Number.isFinite(parsed))
157
+ ) {
158
+ throw new RuntimeEventInputError(
159
+ 'Runtime event JSONPath requires a finite scalar.',
160
+ );
161
+ }
162
+ if (
163
+ ['orgId', 'runId', 'playName', 'runtimeScope'].includes(match[1]) &&
164
+ typeof parsed !== 'string'
165
+ ) {
166
+ throw new RuntimeEventInputError(
167
+ 'Runtime event identity predicates require strings.',
168
+ );
169
+ }
170
+ if (
171
+ match[1] === 'producerAttempt' &&
172
+ (typeof parsed !== 'number' ||
173
+ !Number.isSafeInteger(parsed) ||
174
+ parsed < 1)
175
+ ) {
176
+ throw new RuntimeEventInputError(
177
+ 'Runtime event producerAttempt requires a positive integer.',
178
+ );
179
+ }
180
+ predicates.push({ path: match[1], literal: match[2], value: parsed });
181
+ if (
182
+ match[1].startsWith('context.') &&
183
+ ['__proto__', 'constructor', 'prototype'].includes(
184
+ match[1].slice('context.'.length),
185
+ )
186
+ ) {
187
+ throw new RuntimeEventInputError(
188
+ 'Runtime event JSONPath has an unsupported context key.',
189
+ );
190
+ }
191
+ clauses += 1;
192
+ if (clauses > 8)
193
+ throw new RuntimeEventInputError(
194
+ 'Runtime event JSONPath has too many predicates.',
195
+ );
196
+ position = clause.lastIndex;
197
+ if (position === value.length) break;
198
+ const separator = /\s*&&\s*/y;
199
+ separator.lastIndex = position;
200
+ if (!separator.exec(value)) {
201
+ throw new RuntimeEventInputError(
202
+ 'Runtime event JSONPath supports conjunctions only.',
203
+ );
204
+ }
205
+ position = separator.lastIndex;
206
+ if (position === value.length) {
207
+ throw new RuntimeEventInputError(
208
+ 'Runtime event JSONPath ends with a conjunction.',
209
+ );
210
+ }
211
+ }
212
+ return predicates;
213
+ }
214
+
215
+ type EventRow = {
216
+ event_id: string;
217
+ occurred_at: Date | string;
218
+ ingested_at: Date | string;
219
+ run_id: string;
220
+ play_name: string;
221
+ kind: string;
222
+ level: string | null;
223
+ message: string | null;
224
+ receipt_ref: string | null;
225
+ search_doc: Record<string, unknown>;
226
+ };
227
+
228
+ function iso(value: Date | string): string {
229
+ const date = value instanceof Date ? value : new Date(value);
230
+ if (!Number.isFinite(date.getTime()))
231
+ throw new Error('Runtime event has invalid timestamp.');
232
+ return date.toISOString();
233
+ }
234
+
235
+ /** Client must be a dedicated reader in a transaction with SET LOCAL scope. */
236
+ export async function searchRuntimeEvents(
237
+ client: PostgresSchedulerQueryClient,
238
+ input: RuntimeEventSearchInput,
239
+ ): Promise<{
240
+ events: RuntimeSearchEvent[];
241
+ nextCursor: RuntimeSearchCursor | null;
242
+ }> {
243
+ const scope = requiredScope(input.runtimeScope);
244
+ const org = requiredOrg(input.orgId);
245
+ const since = input.since.getTime();
246
+ const until = input.until.getTime();
247
+ if (
248
+ !Number.isFinite(since) ||
249
+ !Number.isFinite(until) ||
250
+ since >= until ||
251
+ until - since > MAX_SEARCH_WINDOW_MS
252
+ ) {
253
+ throw new RuntimeEventInputError(
254
+ 'Runtime event search requires a window of at most seven days.',
255
+ );
256
+ }
257
+ if (
258
+ !Number.isSafeInteger(input.limit) ||
259
+ input.limit < 1 ||
260
+ input.limit > MAX_SEARCH_RESULTS
261
+ ) {
262
+ throw new RuntimeEventInputError(
263
+ 'Runtime event search limit must be between 1 and 200.',
264
+ );
265
+ }
266
+ const params: unknown[] = [
267
+ scope,
268
+ org,
269
+ input.since.toISOString(),
270
+ input.until.toISOString(),
271
+ ];
272
+ const predicates = [
273
+ 'runtime_scope = $1',
274
+ 'org_id = $2',
275
+ 'occurred_at >= $3::timestamptz',
276
+ 'occurred_at < $4::timestamptz',
277
+ "ingested_at >= clock_timestamp() - interval '7 days'",
278
+ ];
279
+ const addExact = (
280
+ column: 'run_id' | 'play_name' | 'kind',
281
+ value: string | undefined,
282
+ ) => {
283
+ if (value === undefined) return;
284
+ if (!value.trim() || value.length > 256)
285
+ throw new RuntimeEventInputError(
286
+ `Invalid runtime event ${column} filter.`,
287
+ );
288
+ params.push(value);
289
+ predicates.push(`${column} = $${params.length}`);
290
+ };
291
+ addExact('run_id', input.runId);
292
+ addExact('play_name', input.playName);
293
+ addExact('kind', input.kind);
294
+ if (input.jsonpath !== undefined) {
295
+ const identityColumns = {
296
+ orgId: 'org_id',
297
+ runId: 'run_id',
298
+ playName: 'play_name',
299
+ runtimeScope: 'runtime_scope',
300
+ } as const;
301
+ const documentPredicates: string[] = [];
302
+ for (const predicate of parseRuntimeEventJsonpath(input.jsonpath)) {
303
+ const column =
304
+ identityColumns[predicate.path as keyof typeof identityColumns];
305
+ if (column) {
306
+ params.push(predicate.value);
307
+ predicates.push(`${column} = $${params.length}`);
308
+ } else {
309
+ documentPredicates.push(`$.${predicate.path} == ${predicate.literal}`);
310
+ }
311
+ }
312
+ if (documentPredicates.length > 0) {
313
+ params.push(documentPredicates.join(' && '));
314
+ predicates.push(`search_doc @@ $${params.length}::jsonpath`);
315
+ }
316
+ }
317
+ if (input.cursor) {
318
+ const cursorAt = new Date(input.cursor.occurredAt);
319
+ if (
320
+ !Number.isFinite(cursorAt.getTime()) ||
321
+ !input.cursor.eventId ||
322
+ input.cursor.eventId.length > 512
323
+ ) {
324
+ throw new RuntimeEventInputError('Invalid runtime event cursor.');
325
+ }
326
+ params.push(cursorAt.toISOString(), input.cursor.eventId);
327
+ predicates.push(
328
+ `(occurred_at, event_id) < ($${params.length - 1}::timestamptz, $${params.length})`,
329
+ );
330
+ }
331
+ params.push(input.limit + 1);
332
+ const result = await client.query<EventRow>(
333
+ `SELECT event_id, occurred_at, ingested_at, run_id, play_name, kind, level,
334
+ coalesce(message, CASE WHEN kind = 'log.appended' THEN
335
+ left(CASE WHEN search_doc->>'source' = 'terminal' THEN
336
+ regexp_replace(log_body,
337
+ '^' || chr(1) || 'prov:[^' || chr(1) || ']*' || chr(1), '')
338
+ ELSE log_body END, 2048)
339
+ END) AS message,
340
+ receipt_ref, search_doc
341
+ FROM ${EVENTS_TABLE}
342
+ WHERE ${predicates.join(' AND ')}
343
+ ORDER BY occurred_at DESC, event_id DESC
344
+ LIMIT $${params.length}::integer`,
345
+ params,
346
+ );
347
+ const hasMore = result.rows.length > input.limit;
348
+ const events = result.rows.slice(0, input.limit).map((row) => ({
349
+ eventId: row.event_id,
350
+ occurredAt: iso(row.occurred_at),
351
+ ingestedAt: iso(row.ingested_at),
352
+ runId: row.run_id,
353
+ playName: row.play_name,
354
+ kind: row.kind,
355
+ level: row.level,
356
+ message: row.message,
357
+ receiptRef: row.receipt_ref,
358
+ searchDoc: {
359
+ ...row.search_doc,
360
+ orgId: org,
361
+ runId: row.run_id,
362
+ playName: row.play_name,
363
+ runtimeScope: scope,
364
+ },
365
+ }));
366
+ const last = events.at(-1);
367
+ return {
368
+ events,
369
+ nextCursor:
370
+ hasMore && last
371
+ ? { occurredAt: last.occurredAt, eventId: last.eventId }
372
+ : null,
373
+ };
374
+ }
375
+
376
+ /**
377
+ * Reads a receipt through a narrow SECURITY DEFINER function. The reader has
378
+ * no direct grant on any scheduler receipt table, and the function checks both
379
+ * the RLS transaction context and the event reference before dynamic SQL.
380
+ */
381
+ export async function getRuntimeEventReceipt(
382
+ client: PostgresSchedulerQueryClient,
383
+ input: {
384
+ runtimeScope: string;
385
+ orgId: string;
386
+ runId: string;
387
+ receiptRef: string;
388
+ },
389
+ ): Promise<RuntimeEventReceipt | null> {
390
+ const scope = requiredScope(input.runtimeScope);
391
+ const org = requiredOrg(input.orgId);
392
+ if (
393
+ !input.runId ||
394
+ input.runId.length > 256 ||
395
+ !/^[0-9a-f]{8}-[0-9a-f-]{27,}$/i.test(input.receiptRef)
396
+ ) {
397
+ throw new RuntimeEventInputError(
398
+ 'Invalid runtime event receipt reference.',
399
+ );
400
+ }
401
+ const result = await client.query<{ receipt: RuntimeEventReceipt | null }>(
402
+ `SELECT runtime_observability.fetch_receipt($1, $2, $3, $4::uuid) AS receipt`,
403
+ [scope, org, input.runId, input.receiptRef],
404
+ );
405
+ return result.rows[0]?.receipt ?? null;
406
+ }
407
+
408
+ /** Read the existing per-run log contract from the tenant-scoped event index. */
409
+ export async function getRuntimeRunLogPage(
410
+ client: PostgresSchedulerQueryClient,
411
+ input: {
412
+ runtimeScope: string;
413
+ orgId: string;
414
+ runId: string;
415
+ afterSeq: number;
416
+ limit: number;
417
+ /** Return the newest bounded window for a stream's initial connection. */
418
+ tail?: boolean;
419
+ },
420
+ ): Promise<RuntimeRunLogPage> {
421
+ const scope = requiredScope(input.runtimeScope);
422
+ const org = requiredOrg(input.orgId);
423
+ if (!input.runId || input.runId.length > 256) {
424
+ throw new RuntimeEventInputError(
425
+ 'Runtime run log read requires a valid run id.',
426
+ );
427
+ }
428
+ if (!Number.isSafeInteger(input.afterSeq) || input.afterSeq < 0) {
429
+ throw new RuntimeEventInputError(
430
+ 'Runtime run log cursor must be a non-negative integer.',
431
+ );
432
+ }
433
+ if (
434
+ !Number.isSafeInteger(input.limit) ||
435
+ input.limit < 1 ||
436
+ input.limit > 1000
437
+ ) {
438
+ throw new RuntimeEventInputError(
439
+ 'Runtime run log page limit must be between 1 and 1000.',
440
+ );
441
+ }
442
+ const tail = input.tail === true;
443
+ const pageWhere = tail ? '' : 'AND log_seq > $4';
444
+ const pageOrder = tail ? 'DESC' : 'ASC';
445
+ const pageLimit = tail ? '$4::integer' : '$5::integer';
446
+ const pageParams = tail
447
+ ? [scope, org, input.runId, input.limit]
448
+ : [scope, org, input.runId, input.afterSeq, input.limit];
449
+ const result = await client.query<{
450
+ total_log_count: string | number | null;
451
+ first_stored_seq: string | number | null;
452
+ last_stored_seq: string | number | null;
453
+ entries: Array<{ seq: string | number; line: string }>;
454
+ }>(
455
+ // Match the event-then-cursor relation lock order used by migrations and
456
+ // log projection. Cursor-first reads can deadlock a schema expansion.
457
+ `WITH retained AS (
458
+ SELECT min(log_seq) AS first_seq, max(log_seq) AS last_seq
459
+ FROM runtime_observability.events
460
+ WHERE runtime_scope = $1 AND org_id = $2 AND run_id = $3
461
+ AND kind = 'log.appended' AND log_seq IS NOT NULL
462
+ AND ingested_at >= clock_timestamp() - interval '7 days'
463
+ ), run_cursor AS (
464
+ SELECT last_seq
465
+ FROM runtime_observability.run_log_cursors
466
+ WHERE runtime_scope = $1 AND org_id = $2 AND run_id = $3
467
+ ), page AS (
468
+ SELECT log_seq AS seq, coalesce(log_body, message) AS line
469
+ FROM runtime_observability.events
470
+ WHERE runtime_scope = $1 AND org_id = $2 AND run_id = $3
471
+ AND kind = 'log.appended' AND log_seq IS NOT NULL
472
+ AND ingested_at >= clock_timestamp() - interval '7 days'
473
+ ${pageWhere}
474
+ ORDER BY log_seq ${pageOrder}
475
+ LIMIT ${pageLimit}
476
+ )
477
+ SELECT (SELECT last_seq FROM run_cursor) AS total_log_count,
478
+ retained.first_seq AS first_stored_seq,
479
+ retained.last_seq AS last_stored_seq,
480
+ coalesce((SELECT jsonb_agg(jsonb_build_object('seq', seq, 'line', line)
481
+ ORDER BY seq) FROM page), '[]'::jsonb) AS entries
482
+ FROM retained`,
483
+ pageParams,
484
+ );
485
+ const row = result.rows[0];
486
+ const totalLogCount = Number(row?.total_log_count ?? 0);
487
+ const firstStoredSeq =
488
+ row?.first_stored_seq == null ? null : Number(row.first_stored_seq);
489
+ const lastStoredSeq =
490
+ row?.last_stored_seq == null ? 0 : Number(row.last_stored_seq);
491
+ if (
492
+ !Number.isSafeInteger(totalLogCount) ||
493
+ !Number.isSafeInteger(lastStoredSeq) ||
494
+ (firstStoredSeq !== null && !Number.isSafeInteger(firstStoredSeq))
495
+ ) {
496
+ throw new Error(
497
+ 'Runtime run log cursor is outside the safe integer range.',
498
+ );
499
+ }
500
+ const rawEntries = Array.isArray(row?.entries) ? row.entries : [];
501
+ const entries = rawEntries.map((entry) => ({
502
+ seq: Number(entry.seq),
503
+ line: entry.line,
504
+ }));
505
+ if (
506
+ entries.some(
507
+ (entry) =>
508
+ !Number.isSafeInteger(entry.seq) || typeof entry.line !== 'string',
509
+ )
510
+ ) {
511
+ throw new Error('Runtime run log page contains an invalid row.');
512
+ }
513
+ return {
514
+ totalLogCount,
515
+ logsTruncated:
516
+ totalLogCount > 0 &&
517
+ (firstStoredSeq !== 1 || lastStoredSeq < totalLogCount),
518
+ lastStoredSeq,
519
+ firstStoredSeq,
520
+ entries,
521
+ };
522
+ }
523
+
524
+ /** Attach only on the explicit migration/bootstrap path, never on a hot read. */
525
+ export async function ensureRuntimeEventSearchSchema(
526
+ client: PostgresSchedulerQueryClient,
527
+ ): Promise<void> {
528
+ await client.query('CREATE SCHEMA IF NOT EXISTS runtime_observability');
529
+ await client.query(`
530
+ CREATE TABLE IF NOT EXISTS runtime_observability.events (
531
+ runtime_scope text NOT NULL,
532
+ org_id text NOT NULL,
533
+ event_id text NOT NULL,
534
+ log_seq bigint,
535
+ occurred_at timestamptz NOT NULL,
536
+ ingested_at timestamptz NOT NULL DEFAULT clock_timestamp(),
537
+ run_id text NOT NULL,
538
+ play_name text NOT NULL,
539
+ kind text NOT NULL,
540
+ level text,
541
+ message text,
542
+ log_body text,
543
+ receipt_ref uuid,
544
+ receipt_key text,
545
+ source_digest text,
546
+ search_doc jsonb NOT NULL DEFAULT '{}'::jsonb,
547
+ PRIMARY KEY (runtime_scope, org_id, event_id),
548
+ CONSTRAINT runtime_events_doc_size CHECK (octet_length(search_doc::text) <= 4096),
549
+ CONSTRAINT runtime_events_message_size CHECK (message IS NULL OR octet_length(message) <= 8192)
550
+ )
551
+ `);
552
+ await client.query(
553
+ `ALTER TABLE runtime_observability.events ADD COLUMN IF NOT EXISTS log_seq bigint`,
554
+ );
555
+ await client.query(
556
+ `ALTER TABLE runtime_observability.events ADD COLUMN IF NOT EXISTS log_body text`,
557
+ );
558
+ // Compact replay evidence survives body expiry. No writer uses it until the
559
+ // reader-first storage expansion has completed its deployment.
560
+ await client.query(
561
+ `ALTER TABLE runtime_observability.events ADD COLUMN IF NOT EXISTS log_text_digest text`,
562
+ );
563
+ await client.query(`CREATE TABLE IF NOT EXISTS runtime_observability.run_log_cursors (
564
+ runtime_scope text NOT NULL,
565
+ org_id text NOT NULL,
566
+ run_id text NOT NULL,
567
+ last_seq bigint NOT NULL CHECK (last_seq >= 1),
568
+ updated_at timestamptz NOT NULL DEFAULT clock_timestamp(),
569
+ PRIMARY KEY (runtime_scope, org_id, run_id)
570
+ )`);
571
+ await client.query(
572
+ `ALTER TABLE runtime_observability.run_log_cursors ENABLE ROW LEVEL SECURITY`,
573
+ );
574
+ await client.query(
575
+ `DROP POLICY IF EXISTS runtime_run_log_read_scope ON runtime_observability.run_log_cursors`,
576
+ );
577
+ await client.query(`CREATE POLICY runtime_run_log_read_scope ON runtime_observability.run_log_cursors
578
+ FOR SELECT TO PUBLIC USING (
579
+ org_id = current_setting('app.org_id', true)
580
+ AND runtime_scope = current_setting('app.runtime_scope', true)
581
+ )`);
582
+ await client.query(
583
+ `REVOKE ALL ON runtime_observability.run_log_cursors FROM PUBLIC`,
584
+ );
585
+ await client.query(`CREATE INDEX IF NOT EXISTS runtime_events_run_log_seq_idx
586
+ ON runtime_observability.events (runtime_scope, org_id, run_id, log_seq)
587
+ WHERE kind = 'log.appended' AND log_seq IS NOT NULL`);
588
+ await client.query(
589
+ `DROP INDEX IF EXISTS runtime_observability.runtime_run_log_cursors_retention_idx`,
590
+ );
591
+ await client.query(`CREATE INDEX IF NOT EXISTS runtime_events_org_time_idx
592
+ ON runtime_observability.events (runtime_scope, org_id, occurred_at DESC, event_id DESC)`);
593
+ await client.query(`CREATE INDEX IF NOT EXISTS runtime_events_run_time_idx
594
+ ON runtime_observability.events (runtime_scope, org_id, run_id, occurred_at DESC, event_id DESC)`);
595
+ await client.query(`CREATE INDEX IF NOT EXISTS runtime_events_doc_idx
596
+ ON runtime_observability.events USING gin (search_doc jsonb_path_ops)`);
597
+ await client.query(`CREATE INDEX IF NOT EXISTS runtime_events_retention_idx
598
+ ON runtime_observability.events (ingested_at, event_id)`);
599
+ await client.query(`CREATE UNIQUE INDEX IF NOT EXISTS runtime_events_receipt_ref_idx
600
+ ON runtime_observability.events (runtime_scope, org_id, receipt_ref)
601
+ WHERE receipt_ref IS NOT NULL`);
602
+ await client.query(
603
+ `ALTER TABLE runtime_observability.events ENABLE ROW LEVEL SECURITY`,
604
+ );
605
+ await client.query(
606
+ `DROP POLICY IF EXISTS runtime_events_read_scope ON runtime_observability.events`,
607
+ );
608
+ await client.query(`CREATE POLICY runtime_events_read_scope ON runtime_observability.events
609
+ FOR SELECT TO PUBLIC USING (
610
+ org_id = current_setting('app.org_id', true)
611
+ AND runtime_scope = current_setting('app.runtime_scope', true)
612
+ )`);
613
+ await client.query(`REVOKE ALL ON SCHEMA runtime_observability FROM PUBLIC`);
614
+ await client.query(`REVOKE ALL ON runtime_observability.events FROM PUBLIC`);
615
+ await client.query(`
616
+ CREATE OR REPLACE FUNCTION runtime_observability.fetch_receipt(
617
+ p_scope text, p_org text, p_run text, p_ref uuid
618
+ ) RETURNS jsonb
619
+ LANGUAGE plpgsql SECURITY DEFINER
620
+ SET search_path = pg_catalog
621
+ AS $function$
622
+ DECLARE answer jsonb; receipt_key_value text;
623
+ BEGIN
624
+ IF p_scope !~ '^[a-z_][a-z0-9_]{0,62}$'
625
+ OR p_org IS NULL OR p_run IS NULL OR p_ref IS NULL
626
+ OR p_org IS DISTINCT FROM current_setting('app.org_id', true)
627
+ OR p_scope IS DISTINCT FROM current_setting('app.runtime_scope', true)
628
+ THEN RETURN NULL; END IF;
629
+ SELECT event.receipt_key INTO receipt_key_value
630
+ FROM runtime_observability.events AS event
631
+ WHERE event.runtime_scope = p_scope AND event.org_id = p_org
632
+ AND event.run_id = p_run AND event.receipt_ref = p_ref
633
+ AND event.ingested_at >= clock_timestamp() - interval '7 days';
634
+ IF receipt_key_value IS NULL THEN RETURN NULL; END IF;
635
+ EXECUTE format(
636
+ 'SELECT jsonb_build_object(''receiptKey'', k, ''status'', status, ''output'', output_json, ''outputRef'', output_ref, ''error'', error, ''errorPayload'', error_payload) FROM %I.receipts WHERE org_id = $1 AND run_id = $2 AND k = $3 AND status IN (''completed'', ''failed'', ''skipped'')',
637
+ p_scope
638
+ ) INTO answer USING p_org, p_run, receipt_key_value;
639
+ RETURN answer;
640
+ END
641
+ $function$
642
+ `);
643
+ await client.query(
644
+ `REVOKE ALL ON FUNCTION runtime_observability.fetch_receipt(text,text,text,uuid) FROM PUBLIC`,
645
+ );
646
+ }
647
+
648
+ /** The exact provisioned PlanetScale reader role; never grant to a guessed role. */
649
+ export async function grantRuntimeEventReader(
650
+ client: PostgresSchedulerQueryClient,
651
+ readerRole: string,
652
+ ): Promise<void> {
653
+ if (!readerRole || !/^[A-Za-z_][A-Za-z0-9_.-]{0,127}$/.test(readerRole)) {
654
+ throw new RuntimeEventInputError(
655
+ 'Invalid provisioned runtime event reader role.',
656
+ );
657
+ }
658
+ const role = quoteIdent(readerRole);
659
+ await client.query(`GRANT USAGE ON SCHEMA runtime_observability TO ${role}`);
660
+ await client.query(`GRANT SELECT (
661
+ runtime_scope, org_id, event_id, occurred_at, ingested_at, run_id,
662
+ log_seq, log_body, play_name, kind, level, message, receipt_ref, search_doc
663
+ ) ON runtime_observability.events TO ${role}`);
664
+ await client.query(`GRANT SELECT (runtime_scope, org_id, run_id, last_seq)
665
+ ON runtime_observability.run_log_cursors TO ${role}`);
666
+ await client.query(
667
+ `GRANT EXECUTE ON FUNCTION runtime_observability.fetch_receipt(text,text,text,uuid) TO ${role}`,
668
+ );
669
+ }
670
+
671
+ export async function hasRuntimeEventReaderGrant(
672
+ client: PostgresSchedulerQueryClient,
673
+ readerRole: string,
674
+ runtimeScope: string,
675
+ ): Promise<boolean> {
676
+ if (!readerRole || !/^[A-Za-z_][A-Za-z0-9_.-]{0,127}$/.test(readerRole)) {
677
+ throw new RuntimeEventInputError(
678
+ 'Invalid provisioned runtime event reader role.',
679
+ );
680
+ }
681
+ const scope = requiredScope(runtimeScope);
682
+ const result = await client.query<{ ready: boolean }>(
683
+ `SELECT coalesce(roles.rolcanlogin AND NOT roles.rolsuper AND NOT roles.rolbypassrls
684
+ AND has_schema_privilege(roles.oid, 'runtime_observability', 'USAGE')
685
+ AND has_column_privilege(roles.oid, 'runtime_observability.events', 'event_id', 'SELECT')
686
+ AND has_column_privilege(roles.oid, 'runtime_observability.events', 'log_seq', 'SELECT')
687
+ AND has_column_privilege(roles.oid, 'runtime_observability.events', 'log_body', 'SELECT')
688
+ AND NOT has_column_privilege(roles.oid, 'runtime_observability.events', 'receipt_key', 'SELECT')
689
+ AND NOT has_table_privilege(roles.oid, 'runtime_observability.events', 'INSERT')
690
+ AND NOT has_any_column_privilege(roles.oid, 'runtime_observability.events', 'INSERT')
691
+ AND NOT has_table_privilege(roles.oid, 'runtime_observability.events', 'UPDATE')
692
+ AND NOT has_any_column_privilege(roles.oid, 'runtime_observability.events', 'UPDATE')
693
+ AND NOT has_table_privilege(roles.oid, 'runtime_observability.events', 'DELETE')
694
+ AND NOT has_table_privilege(roles.oid, 'runtime_observability.events', 'TRUNCATE')
695
+ AND NOT has_table_privilege(roles.oid, 'runtime_observability.events', 'REFERENCES')
696
+ AND NOT has_table_privilege(roles.oid, 'runtime_observability.events', 'TRIGGER')
697
+ AND has_column_privilege(roles.oid, 'runtime_observability.run_log_cursors', 'runtime_scope', 'SELECT')
698
+ AND has_column_privilege(roles.oid, 'runtime_observability.run_log_cursors', 'org_id', 'SELECT')
699
+ AND has_column_privilege(roles.oid, 'runtime_observability.run_log_cursors', 'run_id', 'SELECT')
700
+ AND has_column_privilege(roles.oid, 'runtime_observability.run_log_cursors', 'last_seq', 'SELECT')
701
+ AND NOT has_table_privilege(roles.oid, 'runtime_observability.run_log_cursors', 'INSERT')
702
+ AND NOT has_any_column_privilege(roles.oid, 'runtime_observability.run_log_cursors', 'INSERT')
703
+ AND NOT has_table_privilege(roles.oid, 'runtime_observability.run_log_cursors', 'UPDATE')
704
+ AND NOT has_any_column_privilege(roles.oid, 'runtime_observability.run_log_cursors', 'UPDATE')
705
+ AND NOT has_table_privilege(roles.oid, 'runtime_observability.run_log_cursors', 'DELETE')
706
+ AND NOT has_table_privilege(roles.oid, 'runtime_observability.run_log_cursors', 'TRUNCATE')
707
+ AND NOT has_table_privilege(roles.oid, 'runtime_observability.run_log_cursors', 'REFERENCES')
708
+ AND NOT has_table_privilege(roles.oid, 'runtime_observability.run_log_cursors', 'TRIGGER')
709
+ AND NOT has_schema_privilege(roles.oid, $2, 'USAGE')
710
+ AND NOT has_schema_privilege(roles.oid, $2, 'CREATE')
711
+ AND NOT EXISTS (SELECT 1 FROM pg_auth_members AS membership
712
+ WHERE pg_has_role(roles.oid, membership.roleid, 'MEMBER'))
713
+ AND NOT EXISTS (SELECT 1 FROM pg_class AS events
714
+ JOIN pg_namespace AS event_schema ON event_schema.oid = events.relnamespace
715
+ WHERE event_schema.nspname = 'runtime_observability'
716
+ AND events.relname = 'events'
717
+ AND pg_has_role(roles.oid, events.relowner, 'USAGE'))
718
+ AND NOT EXISTS (SELECT 1 FROM pg_class AS log_cursors
719
+ JOIN pg_namespace AS event_schema ON event_schema.oid = log_cursors.relnamespace
720
+ WHERE event_schema.nspname = 'runtime_observability'
721
+ AND log_cursors.relname = 'run_log_cursors'
722
+ AND pg_has_role(roles.oid, log_cursors.relowner, 'USAGE'))
723
+ AND (SELECT relrowsecurity FROM pg_class
724
+ WHERE oid = 'runtime_observability.events'::regclass)
725
+ AND (SELECT relrowsecurity FROM pg_class
726
+ WHERE oid = 'runtime_observability.run_log_cursors'::regclass)
727
+ AND NOT EXISTS (SELECT 1 FROM pg_class AS scoped_relation
728
+ JOIN pg_namespace AS scoped_schema ON scoped_schema.oid = scoped_relation.relnamespace
729
+ WHERE scoped_schema.nspname = $2
730
+ AND scoped_relation.relkind IN ('r', 'p', 'v', 'm', 'f')
731
+ AND (has_table_privilege(roles.oid, scoped_relation.oid, 'SELECT')
732
+ OR has_any_column_privilege(roles.oid, scoped_relation.oid, 'SELECT')))
733
+ AND NOT pg_has_role(roles.oid,
734
+ (SELECT receipt_function.proowner FROM pg_proc AS receipt_function
735
+ WHERE receipt_function.oid = 'runtime_observability.fetch_receipt(text,text,text,uuid)'::regprocedure),
736
+ 'USAGE')
737
+ AND has_function_privilege(roles.oid, 'runtime_observability.fetch_receipt(text,text,text,uuid)', 'EXECUTE'), false) AS ready
738
+ FROM pg_roles AS roles WHERE roles.rolname = $1`,
739
+ [readerRole, scope],
740
+ );
741
+ return result.rows[0]?.ready === true;
742
+ }
743
+
744
+ /**
745
+ * Triggers are attached to each scheduler namespace by the explicit migration.
746
+ * Search writes are atomic with the already-durable outbox/receipt write. A
747
+ * trigger failure is loud so no authoritative write can silently lose its
748
+ * corresponding search history after enablement.
749
+ */
750
+ export async function attachRuntimeEventSearchTriggers(
751
+ client: PostgresSchedulerQueryClient,
752
+ runtimeScope: string,
753
+ ): Promise<void> {
754
+ const scope = requiredScope(runtimeScope);
755
+ const schema = quoteIdent(scope);
756
+ await client.query(`
757
+ CREATE OR REPLACE FUNCTION ${schema}.search_receipt_event() RETURNS trigger
758
+ LANGUAGE plpgsql SECURITY DEFINER SET search_path = pg_catalog AS $function$
759
+ DECLARE play text; ref uuid;
760
+ BEGIN
761
+ IF NEW.status NOT IN ('completed', 'failed', 'skipped') THEN RETURN NEW; END IF;
762
+ IF TG_OP = 'UPDATE' THEN
763
+ IF NEW.status IS NOT DISTINCT FROM OLD.status
764
+ AND NEW.output_json IS NOT DISTINCT FROM OLD.output_json
765
+ AND NEW.output_ref IS NOT DISTINCT FROM OLD.output_ref
766
+ AND NEW.error IS NOT DISTINCT FROM OLD.error
767
+ THEN RETURN NEW; END IF;
768
+ END IF;
769
+ IF NEW.run_id IS NULL OR NEW.org_id IS NULL THEN
770
+ RAISE EXCEPTION 'Terminal receipt search projection requires run and organization';
771
+ END IF;
772
+ SELECT play_name INTO play FROM ${schema}.runs
773
+ WHERE run_id = NEW.run_id AND org_id = NEW.org_id;
774
+ IF play IS NULL THEN
775
+ RAISE EXCEPTION 'Terminal receipt search projection has no matching run';
776
+ END IF;
777
+ ref := gen_random_uuid();
778
+ INSERT INTO runtime_observability.events (
779
+ runtime_scope, org_id, event_id, occurred_at, run_id, play_name,
780
+ kind, receipt_ref, receipt_key, search_doc
781
+ ) VALUES (
782
+ ${quoteLiteral(scope)}, NEW.org_id, 'receipt:' || ref::text,
783
+ NEW.updated_at, NEW.run_id, play, 'receipt.' || NEW.status,
784
+ ref, NEW.k, jsonb_strip_nulls(jsonb_build_object(
785
+ 'kind', 'receipt.' || NEW.status, 'status', NEW.status,
786
+ 'source', 'receipt', 'producerAttempt', NEW.lease_owner_attempt
787
+ ))
788
+ );
789
+ RETURN NEW;
790
+ END
791
+ $function$
792
+ `);
793
+ await client.query(`DO $create$ BEGIN
794
+ IF NOT EXISTS (SELECT 1 FROM pg_trigger WHERE tgrelid = '${schema}.receipts'::regclass
795
+ AND tgname = 'runtime_receipt_event_v1') THEN
796
+ CREATE TRIGGER runtime_receipt_event_v1 AFTER INSERT OR UPDATE ON ${schema}.receipts
797
+ FOR EACH ROW EXECUTE FUNCTION ${schema}.search_receipt_event();
798
+ END IF;
799
+ END $create$`);
800
+ await client.query(`
801
+ CREATE OR REPLACE FUNCTION ${schema}.search_outbox_event() RETURNS trigger
802
+ LANGUAGE plpgsql SECURITY DEFINER SET search_path = pg_catalog AS $function$
803
+ DECLARE run_org text; play text; item jsonb; line jsonb; event_batch jsonb;
804
+ ordinal bigint; line_ordinal bigint; kind text; at_time timestamptz;
805
+ doc jsonb; line_doc jsonb; line_context jsonb;
806
+ eid text; existing_digest text; digest text; assigned_seq bigint;
807
+ log_channel text; log_position bigint; matched_event_id text; matched_doc jsonb;
808
+ legacy_position_prefix text; log_alias jsonb; envelope_attempt jsonb;
809
+ line_count bigint; fresh_event_ids text[] := ARRAY[]::text[];
810
+ recent_log_keys jsonb := '{}'::jsonb; dedupe_key text;
811
+ terminal_overlap_counts jsonb := '{}'::jsonb;
812
+ recent_log_keys_loaded boolean := false;
813
+ BEGIN
814
+ -- Serialize log ordering without taking scheduler Run/attempt row locks.
815
+ -- Completion writers can own an attempt before updating its Run.
816
+ PERFORM pg_advisory_xact_lock(hashtext(${quoteLiteral(scope)}), hashtext(NEW.run_id));
817
+ SELECT org_id, play_name INTO run_org, play FROM ${schema}.runs WHERE run_id = NEW.run_id;
818
+ IF run_org IS NULL OR play IS NULL THEN
819
+ RAISE EXCEPTION 'Outbox search projection has no matching run';
820
+ END IF;
821
+ IF NEW.event_type = 'run.events' THEN
822
+ event_batch := NEW.payload_json->'events';
823
+ IF jsonb_typeof(event_batch) IS DISTINCT FROM 'array' THEN
824
+ RAISE EXCEPTION 'Search projection requires run.events array';
825
+ END IF;
826
+ IF jsonb_array_length(event_batch) = 0 THEN
827
+ RAISE EXCEPTION 'Search projection requires nonempty run.events array';
828
+ END IF;
829
+ ELSIF NEW.event_type IN ('run.completed', 'run.failed')
830
+ AND NEW.payload_json ? 'terminalProgressEvents' THEN
831
+ event_batch := NEW.payload_json->'terminalProgressEvents';
832
+ IF jsonb_typeof(event_batch) IS DISTINCT FROM 'array' THEN
833
+ RAISE EXCEPTION 'Search projection requires terminalProgressEvents array';
834
+ END IF;
835
+ END IF;
836
+ -- Ordinary terminal SQL already binds the attempt in its counter
837
+ -- segment; compact carriers also name it at the top level.
838
+ envelope_attempt := coalesce(NULLIF(NEW.payload_json->'producerAttempt', 'null'::jsonb),
839
+ NEW.payload_json->'terminalWorkCounterSegment'->'attempt');
840
+ -- The retained envelope has its own position space: it can contain
841
+ -- diagnostics absent from both live delivery and ctx-only replay.
842
+ IF NEW.event_type IN ('run.completed', 'run.failed')
843
+ AND NEW.payload_json ? 'terminalLogLines'
844
+ AND (envelope_attempt #>> '{}') ~ '^[1-9][0-9]{0,5}$' THEN
845
+ event_batch := coalesce(event_batch, '[]'::jsonb) || jsonb_build_array(
846
+ jsonb_strip_nulls(jsonb_build_object(
847
+ 'type', 'log.appended', 'runId', NEW.run_id, 'source', 'terminal',
848
+ 'producerAttempt', envelope_attempt, 'channelOffset', 0,
849
+ 'lines', NEW.payload_json->'terminalLogLines',
850
+ 'lineContexts', NEW.payload_json->'terminalLogContexts'
851
+ ))
852
+ );
853
+ END IF;
854
+ IF event_batch IS NOT NULL THEN
855
+ -- Normalize old positional writers once per batch under the run mutex.
856
+ -- Per-line lookups must remain digest-selective: an OR fallback for
857
+ -- unmarked rows otherwise scans the whole attempt for every new line.
858
+ IF EXISTS (SELECT 1 FROM jsonb_array_elements(event_batch) AS entry
859
+ WHERE entry->>'type' = 'log.appended') THEN
860
+ UPDATE runtime_observability.events AS event SET search_doc = event.search_doc ||
861
+ jsonb_build_object('logContentDigest', event.source_digest) ||
862
+ CASE WHEN NOT (event.search_doc ? 'logPositions')
863
+ AND left(event.event_id, length('log:' || NEW.run_id || ':' || (event.search_doc->>'producerAttempt') || ':')) =
864
+ 'log:' || NEW.run_id || ':' || (event.search_doc->>'producerAttempt') || ':'
865
+ AND substring(event.event_id FROM length('log:' || NEW.run_id || ':' || (event.search_doc->>'producerAttempt') || ':') + 1) ~ '^[0-9]{1,12}$'
866
+ THEN jsonb_build_object('logPositions', jsonb_build_object(
867
+ CASE WHEN event.search_doc->>'source' = 'coordinator' THEN 'replay' ELSE 'worker' END,
868
+ substring(event.event_id FROM length('log:' || NEW.run_id || ':' || (event.search_doc->>'producerAttempt') || ':') + 1)::bigint))
869
+ ELSE '{}'::jsonb END
870
+ WHERE event.runtime_scope = ${quoteLiteral(scope)} AND event.org_id = run_org
871
+ AND event.run_id = NEW.run_id AND event.kind = 'log.appended'
872
+ AND event.source_digest IS NOT NULL AND NOT (event.search_doc ? 'logContentDigest')
873
+ AND (event.search_doc->>'producerAttempt') ~ '^[1-9][0-9]{0,5}$';
874
+ END IF;
875
+ FOR item, ordinal IN SELECT value, ordinality FROM jsonb_array_elements(event_batch) WITH ORDINALITY LOOP
876
+ IF jsonb_typeof(item) IS DISTINCT FROM 'object'
877
+ OR item->>'runId' IS DISTINCT FROM NEW.run_id THEN
878
+ RAISE EXCEPTION 'Search projection event has invalid run identity';
879
+ END IF;
880
+ kind := coalesce(item->>'type', item->>'key');
881
+ IF kind NOT IN (
882
+ 'run.created', 'run.started', 'run.waiting', 'run.resumed',
883
+ 'run.completed', 'run.failed', 'run.cancelled',
884
+ 'step.started', 'step.progress', 'step.completed',
885
+ 'step.failed', 'step.skipped', 'log.appended',
886
+ 'dataset.lifecycle', 'activity.observed'
887
+ ) THEN
888
+ kind := 'runtime.event';
889
+ END IF;
890
+ at_time := CASE WHEN (item->>'occurredAt') ~ '^[0-9]{12,14}$'
891
+ THEN to_timestamp((item->>'occurredAt')::double precision / 1000)
892
+ ELSE NEW.created_at END;
893
+ doc := jsonb_strip_nulls(jsonb_build_object(
894
+ 'kind', kind, 'source', left(item->>'source', 64),
895
+ 'producerAttempt', CASE WHEN (item->>'producerAttempt') ~ '^[0-9]{1,6}$'
896
+ THEN CASE WHEN (item->>'producerAttempt')::integer > 0
897
+ THEN (item->>'producerAttempt')::integer END END,
898
+ 'stepId', left(coalesce(item->>'stepId', item->'observation'->>'stepId'), 256),
899
+ 'stepKind', left(item->>'kind', 64),
900
+ 'status', left(coalesce(item->>'status', item->'observation'->'state'->>'kind'), 64),
901
+ 'datasetId', left(item->>'datasetId', 256),
902
+ 'phase', left(item->>'phase', 64),
903
+ 'provider', CASE WHEN item->'observation'->'target'->>'kind' = 'provider'
904
+ THEN left(item->'observation'->'target'->>'provider', 64) END,
905
+ 'operation', CASE WHEN item->'observation'->'target'->>'kind' = 'provider'
906
+ THEN left(item->'observation'->'target'->>'operation', 256) END
907
+ ));
908
+ IF kind = 'log.appended' AND jsonb_typeof(item->'lines') IS DISTINCT FROM 'array' THEN
909
+ RAISE EXCEPTION 'Search projection requires log.appended lines';
910
+ END IF;
911
+ IF kind = 'log.appended' AND item ? 'lineContexts'
912
+ AND (jsonb_typeof(item->'lineContexts') IS DISTINCT FROM 'array'
913
+ OR jsonb_array_length(item->'lineContexts') <> jsonb_array_length(item->'lines')) THEN
914
+ RAISE EXCEPTION 'Search projection requires lineContexts aligned to log lines';
915
+ END IF;
916
+ IF kind = 'log.appended' THEN
917
+ FOR line, line_ordinal IN SELECT value, ordinality FROM jsonb_array_elements(item->'lines') WITH ORDINALITY LOOP
918
+ IF jsonb_typeof(line) IS DISTINCT FROM 'string' THEN
919
+ RAISE EXCEPTION 'Search projection requires log line strings';
920
+ END IF;
921
+ IF (item->>'source' IS DISTINCT FROM 'coordinator')
922
+ AND NOT coalesce((item->>'channelOffset') ~ '^[0-9]{1,12}$', false) THEN
923
+ IF NOT recent_log_keys_loaded THEN
924
+ SELECT coalesce(jsonb_object_agg(recent.dedupe_key, true), '{}'::jsonb)
925
+ INTO recent_log_keys
926
+ FROM (
927
+ SELECT encode(sha256(convert_to(btrim(event.log_body), 'UTF8')), 'hex') AS dedupe_key
928
+ FROM runtime_observability.events AS event
929
+ WHERE event.runtime_scope = ${quoteLiteral(scope)} AND event.org_id = run_org
930
+ AND event.run_id = NEW.run_id AND event.kind = 'log.appended'
931
+ AND event.log_seq IS NOT NULL AND event.log_body IS NOT NULL
932
+ ORDER BY event.log_seq DESC
933
+ LIMIT 256
934
+ ) AS recent;
935
+ recent_log_keys_loaded := true;
936
+ END IF;
937
+ dedupe_key := encode(
938
+ sha256(convert_to(btrim(line #>> '{}'), 'UTF8')), 'hex'
939
+ );
940
+ IF recent_log_keys ? dedupe_key THEN
941
+ CONTINUE;
942
+ END IF;
943
+ recent_log_keys := recent_log_keys || jsonb_build_object(dedupe_key, true);
944
+ END IF;
945
+ line_context := NULL;
946
+ IF item ? 'lineContexts' THEN
947
+ line_context := item->'lineContexts'->((line_ordinal - 1)::int);
948
+ IF jsonb_typeof(line_context) = 'null' THEN
949
+ line_context := NULL;
950
+ ELSIF jsonb_typeof(line_context) IS DISTINCT FROM 'object' THEN
951
+ RAISE EXCEPTION 'Search projection log context must be an object';
952
+ ELSIF (SELECT count(*) FROM jsonb_object_keys(line_context)) > 8
953
+ -- jsonb::text adds at most one formatting space per key
954
+ -- separator; leave that small allowance above the compact
955
+ -- 2 KiB runtime JSON encoding limit.
956
+ OR octet_length(line_context::text) > 2064 THEN
957
+ RAISE EXCEPTION 'Search projection log context exceeds its object limits';
958
+ END IF;
959
+ IF line_context IS NOT NULL AND EXISTS (
960
+ SELECT 1 FROM jsonb_each(line_context) AS field(key, value)
961
+ WHERE field.key !~ '^[A-Za-z_][A-Za-z0-9_]{0,63}$'
962
+ OR field.key IN ('__proto__', 'constructor', 'prototype')
963
+ OR jsonb_typeof(field.value) NOT IN ('string', 'number', 'boolean')
964
+ OR (jsonb_typeof(field.value) = 'string'
965
+ AND octet_length(field.value #>> '{}') > 256)
966
+ OR (jsonb_typeof(field.value) = 'number'
967
+ AND octet_length(field.value #>> '{}') > 32)
968
+ ) THEN
969
+ RAISE EXCEPTION 'Search projection log context has an unsupported key or value';
970
+ END IF;
971
+ END IF;
972
+ line_doc := CASE WHEN line_context IS NULL THEN doc
973
+ ELSE doc || jsonb_build_object('context', line_context) END;
974
+ eid := CASE WHEN (item->>'channelOffset') ~ '^[0-9]{1,12}$'
975
+ AND (item->>'producerAttempt') ~ '^[0-9]{1,6}$'
976
+ THEN 'log:' || NEW.run_id || ':' || (item->>'producerAttempt') || ':' ||
977
+ ((item->>'channelOffset')::bigint + line_ordinal - 1)::text
978
+ ELSE NEW.event_id || ':' || ordinal || ':' || line_ordinal END;
979
+ digest := CASE WHEN line_context IS NULL
980
+ THEN encode(sha256(convert_to(line #>> '{}', 'UTF8')), 'hex')
981
+ ELSE encode(sha256(convert_to(jsonb_build_array(line #>> '{}', line_context)::text, 'UTF8')), 'hex') END;
982
+ IF (item->>'channelOffset') ~ '^[0-9]{1,12}$'
983
+ AND (item->>'producerAttempt') ~ '^[1-9][0-9]{0,5}$' THEN
984
+ -- These transports retain different buffers. Their offsets
985
+ -- are immutable within a channel, never interchangeable.
986
+ log_channel := CASE item->>'source'
987
+ WHEN 'coordinator' THEN 'replay' WHEN 'terminal' THEN 'envelope'
988
+ ELSE 'worker' END;
989
+ log_position := (item->>'channelOffset')::bigint + line_ordinal - 1;
990
+ legacy_position_prefix := 'log:' || NEW.run_id || ':' || (item->>'producerAttempt') || ':';
991
+ eid := CASE WHEN log_channel = 'worker' THEN legacy_position_prefix
992
+ ELSE 'log-' || log_channel || ':' || NEW.run_id || ':' || (item->>'producerAttempt') || ':' END
993
+ || log_position::text;
994
+ -- Older coordinator replays used the worker-shaped primary
995
+ -- key. Do not overwrite it when a delayed worker owns a
996
+ -- different line at that number; aliases remain authoritative.
997
+ IF log_channel = 'worker' AND EXISTS (
998
+ SELECT 1 FROM runtime_observability.events
999
+ WHERE runtime_scope = ${quoteLiteral(scope)} AND org_id = run_org AND event_id = eid
1000
+ AND search_doc->>'source' = 'coordinator'
1001
+ AND NOT (coalesce(search_doc->'logPositions', '{}'::jsonb) ? 'worker')
1002
+ ) THEN
1003
+ eid := 'log-worker:' || NEW.run_id || ':' || (item->>'producerAttempt') || ':' || log_position::text;
1004
+ END IF;
1005
+ log_alias := jsonb_build_object('producerAttempt', (item->>'producerAttempt')::integer,
1006
+ 'logPositions', jsonb_build_object(log_channel, log_position));
1007
+ matched_event_id := NULL;
1008
+ -- A channel position names one canonical occurrence. Sorting
1009
+ -- here makes cached plans scan the primary key on a miss.
1010
+ SELECT event.event_id, event.source_digest, event.search_doc
1011
+ INTO matched_event_id, existing_digest, matched_doc
1012
+ FROM runtime_observability.events AS event
1013
+ WHERE event.runtime_scope = ${quoteLiteral(scope)} AND event.org_id = run_org
1014
+ AND event.run_id = NEW.run_id AND event.kind = 'log.appended'
1015
+ AND event.search_doc @> log_alias;
1016
+ IF matched_event_id IS NOT NULL THEN
1017
+ IF existing_digest IS DISTINCT FROM digest THEN
1018
+ RAISE EXCEPTION 'Positional log event identity changed its content';
1019
+ END IF;
1020
+ CONTINUE;
1021
+ END IF;
1022
+ -- Bind one equal occurrence from another channel. Aliases
1023
+ -- are checked first, so retries cannot consume another copy.
1024
+ -- Full digests include context; repeated text is not a set.
1025
+ SELECT event.event_id, event.search_doc
1026
+ INTO matched_event_id, matched_doc
1027
+ FROM runtime_observability.events AS event
1028
+ WHERE event.runtime_scope = ${quoteLiteral(scope)} AND event.org_id = run_org
1029
+ AND event.run_id = NEW.run_id AND event.kind = 'log.appended'
1030
+ AND event.search_doc @> jsonb_build_object(
1031
+ 'producerAttempt', (item->>'producerAttempt')::integer, 'logContentDigest', digest)
1032
+ AND event.source_digest = digest
1033
+ AND NOT (coalesce(event.search_doc->'logPositions', '{}'::jsonb) ? log_channel)
1034
+ AND (event.search_doc ? 'logPositions' OR log_channel <>
1035
+ CASE WHEN event.search_doc->>'source' = 'coordinator' THEN 'replay' ELSE 'worker' END)
1036
+ ORDER BY event.log_seq NULLS LAST, event.event_id LIMIT 1;
1037
+ IF matched_event_id IS NOT NULL THEN
1038
+ UPDATE runtime_observability.events SET search_doc = matched_doc ||
1039
+ jsonb_build_object('logContentDigest', digest, 'logPositions',
1040
+ coalesce(matched_doc->'logPositions', '{}'::jsonb) || jsonb_build_object(log_channel, log_position))
1041
+ WHERE runtime_scope = ${quoteLiteral(scope)} AND org_id = run_org AND event_id = matched_event_id;
1042
+ CONTINUE;
1043
+ END IF;
1044
+ line_doc := line_doc || log_alias || jsonb_build_object('logContentDigest', digest);
1045
+ END IF;
1046
+ INSERT INTO runtime_observability.events (
1047
+ runtime_scope, org_id, event_id, occurred_at, run_id, play_name,
1048
+ kind, level, message, log_body, source_digest, search_doc
1049
+ ) VALUES (
1050
+ ${quoteLiteral(scope)}, run_org, eid, at_time, NEW.run_id, play,
1051
+ 'log.appended', NULL, left(line #>> '{}', 2048), line #>> '{}', digest, line_doc
1052
+ ) ON CONFLICT DO NOTHING;
1053
+ IF FOUND THEN
1054
+ fresh_event_ids := array_append(fresh_event_ids, eid);
1055
+ ELSE
1056
+ SELECT source_digest INTO existing_digest FROM runtime_observability.events
1057
+ WHERE runtime_scope = ${quoteLiteral(scope)} AND org_id = run_org
1058
+ AND event_id = eid;
1059
+ IF existing_digest IS DISTINCT FROM digest THEN
1060
+ RAISE EXCEPTION 'Positional log event identity changed its content';
1061
+ END IF;
1062
+ END IF;
1063
+ END LOOP;
1064
+ ELSE
1065
+ INSERT INTO runtime_observability.events (
1066
+ runtime_scope, org_id, event_id, occurred_at, run_id, play_name,
1067
+ kind, search_doc
1068
+ ) VALUES (
1069
+ ${quoteLiteral(scope)}, run_org, NEW.event_id || ':' || ordinal,
1070
+ at_time, NEW.run_id, play, kind, doc
1071
+ );
1072
+ END IF;
1073
+ END LOOP;
1074
+ END IF;
1075
+ IF NEW.event_type IN (
1076
+ 'run.created', 'run.started', 'run.waiting', 'run.resumed',
1077
+ 'run.completed', 'run.failed', 'run.cancelled'
1078
+ ) AND NOT (NEW.event_type IN ('run.waiting', 'run.resumed')
1079
+ AND NEW.payload_json->>'kind' = 'detached_runner') THEN
1080
+ INSERT INTO runtime_observability.events (
1081
+ runtime_scope, org_id, event_id, occurred_at, run_id, play_name,
1082
+ kind, search_doc
1083
+ ) VALUES (
1084
+ ${quoteLiteral(scope)}, run_org, NEW.event_id, NEW.created_at,
1085
+ NEW.run_id, play, NEW.event_type, jsonb_build_object('kind', NEW.event_type, 'source', 'system')
1086
+ );
1087
+ -- Detached runners deliver their retained ctx.log tail in the terminal
1088
+ -- outbox fact rather than a run.events batch. Project it from that
1089
+ -- authoritative write so search and the existing log stream agree.
1090
+ IF NEW.event_type IN ('run.completed', 'run.failed')
1091
+ AND NEW.payload_json ? 'terminalLogContexts'
1092
+ AND NOT (NEW.payload_json ? 'terminalLogLines') THEN
1093
+ RAISE EXCEPTION 'Search projection requires terminalLogLines for terminalLogContexts';
1094
+ END IF;
1095
+ IF NEW.event_type IN ('run.completed', 'run.failed')
1096
+ AND NEW.payload_json ? 'terminalLogLines'
1097
+ AND NOT coalesce((envelope_attempt #>> '{}') ~ '^[1-9][0-9]{0,5}$', false) THEN
1098
+ IF jsonb_typeof(NEW.payload_json->'terminalLogLines') IS DISTINCT FROM 'array' THEN
1099
+ RAISE EXCEPTION 'Search projection requires terminalLogLines array';
1100
+ END IF;
1101
+ IF NEW.payload_json ? 'terminalLogContexts'
1102
+ AND (jsonb_typeof(NEW.payload_json->'terminalLogContexts') IS DISTINCT FROM 'array'
1103
+ OR jsonb_array_length(NEW.payload_json->'terminalLogContexts') <> jsonb_array_length(NEW.payload_json->'terminalLogLines')) THEN
1104
+ RAISE EXCEPTION 'Search projection requires terminalLogContexts aligned to terminalLogLines';
1105
+ END IF;
1106
+ -- Count matching bodies already projected, including progress lines
1107
+ -- inserted earlier in this trigger (which do not have log_seq yet).
1108
+ -- Consume one count per overlap so repeated identical log lines in
1109
+ -- the terminal tail remain distinct events after the overlap ends.
1110
+ SELECT coalesce(
1111
+ jsonb_object_agg(counted.dedupe_key, to_jsonb(counted.occurrences)),
1112
+ '{}'::jsonb
1113
+ )
1114
+ INTO terminal_overlap_counts
1115
+ FROM (
1116
+ SELECT candidate.dedupe_key, count(*) AS occurrences
1117
+ FROM (
1118
+ SELECT encode(sha256(convert_to(btrim(recent.log_body), 'UTF8')), 'hex') AS dedupe_key
1119
+ FROM (
1120
+ SELECT event.log_body
1121
+ FROM runtime_observability.events AS event
1122
+ WHERE event.runtime_scope = ${quoteLiteral(scope)}
1123
+ AND event.org_id = run_org AND event.run_id = NEW.run_id
1124
+ AND event.kind = 'log.appended'
1125
+ AND event.log_seq IS NOT NULL AND event.log_body IS NOT NULL
1126
+ ORDER BY event.log_seq DESC
1127
+ LIMIT 256
1128
+ ) AS recent
1129
+ UNION ALL
1130
+ SELECT encode(sha256(convert_to(btrim(event.log_body), 'UTF8')), 'hex')
1131
+ FROM runtime_observability.events AS event
1132
+ WHERE event.runtime_scope = ${quoteLiteral(scope)}
1133
+ AND event.org_id = run_org AND event.run_id = NEW.run_id
1134
+ AND event.kind = 'log.appended' AND event.log_body IS NOT NULL
1135
+ AND event.event_id = ANY(fresh_event_ids)
1136
+ ) AS candidate
1137
+ GROUP BY candidate.dedupe_key
1138
+ ) AS counted;
1139
+ FOR line, line_ordinal IN
1140
+ SELECT value, ordinality
1141
+ FROM jsonb_array_elements(NEW.payload_json->'terminalLogLines') WITH ORDINALITY
1142
+ LOOP
1143
+ IF jsonb_typeof(line) IS DISTINCT FROM 'string' THEN
1144
+ RAISE EXCEPTION 'Search projection requires terminal log strings';
1145
+ END IF;
1146
+ dedupe_key := encode(
1147
+ sha256(convert_to(btrim(line #>> '{}'), 'UTF8')), 'hex'
1148
+ );
1149
+ IF coalesce((terminal_overlap_counts->>dedupe_key)::bigint, 0) > 0 THEN
1150
+ terminal_overlap_counts := terminal_overlap_counts || jsonb_build_object(
1151
+ dedupe_key,
1152
+ (terminal_overlap_counts->>dedupe_key)::bigint - 1
1153
+ );
1154
+ CONTINUE;
1155
+ END IF;
1156
+ line_context := NULL;
1157
+ IF NEW.payload_json ? 'terminalLogContexts' THEN
1158
+ line_context := NEW.payload_json->'terminalLogContexts'->((line_ordinal - 1)::int);
1159
+ IF jsonb_typeof(line_context) = 'null' THEN
1160
+ line_context := NULL;
1161
+ ELSIF jsonb_typeof(line_context) IS DISTINCT FROM 'object' THEN
1162
+ RAISE EXCEPTION 'Search projection terminal log context must be an object';
1163
+ ELSIF (SELECT count(*) FROM jsonb_object_keys(line_context)) > 8
1164
+ -- Allow jsonb::text's bounded separator spaces above the
1165
+ -- runtime's compact 2 KiB context limit.
1166
+ OR octet_length(line_context::text) > 2064 THEN
1167
+ RAISE EXCEPTION 'Search projection terminal log context exceeds its object limits';
1168
+ END IF;
1169
+ IF line_context IS NOT NULL AND EXISTS (
1170
+ SELECT 1 FROM jsonb_each(line_context) AS field(key, value)
1171
+ WHERE field.key !~ '^[A-Za-z_][A-Za-z0-9_]{0,63}$'
1172
+ OR field.key IN ('__proto__', 'constructor', 'prototype')
1173
+ OR jsonb_typeof(field.value) NOT IN ('string', 'number', 'boolean')
1174
+ OR (jsonb_typeof(field.value) = 'string'
1175
+ AND octet_length(field.value #>> '{}') > 256)
1176
+ OR (jsonb_typeof(field.value) = 'number'
1177
+ AND octet_length(field.value #>> '{}') > 32)
1178
+ ) THEN
1179
+ RAISE EXCEPTION 'Search projection terminal log context has an unsupported key or value';
1180
+ END IF;
1181
+ END IF;
1182
+ line_doc := jsonb_build_object('kind', 'log.appended', 'source', 'terminal');
1183
+ IF line_context IS NOT NULL THEN
1184
+ line_doc := line_doc || jsonb_build_object('context', line_context);
1185
+ END IF;
1186
+ INSERT INTO runtime_observability.events (
1187
+ runtime_scope, org_id, event_id, occurred_at, run_id, play_name,
1188
+ kind, level, message, log_body, source_digest, search_doc
1189
+ ) VALUES (
1190
+ ${quoteLiteral(scope)}, run_org, NEW.event_id || ':log:' || line_ordinal,
1191
+ NEW.created_at, NEW.run_id, play, 'log.appended', NULL,
1192
+ left(regexp_replace(line #>> '{}',
1193
+ '^' || chr(1) || 'prov:[^' || chr(1) || ']*' || chr(1), ''), 2048),
1194
+ line #>> '{}',
1195
+ CASE WHEN line_context IS NULL
1196
+ THEN encode(sha256(convert_to(line #>> '{}', 'UTF8')), 'hex')
1197
+ ELSE encode(sha256(convert_to(jsonb_build_array(line #>> '{}', line_context)::text, 'UTF8')), 'hex') END,
1198
+ line_doc
1199
+ ) ON CONFLICT DO NOTHING;
1200
+ IF FOUND THEN
1201
+ fresh_event_ids := array_append(
1202
+ fresh_event_ids,
1203
+ NEW.event_id || ':log:' || line_ordinal
1204
+ );
1205
+ END IF;
1206
+ END LOOP;
1207
+ END IF;
1208
+ END IF;
1209
+ line_count := cardinality(fresh_event_ids);
1210
+ IF line_count > 0 THEN
1211
+ INSERT INTO runtime_observability.run_log_cursors (
1212
+ runtime_scope, org_id, run_id, last_seq
1213
+ ) VALUES (${quoteLiteral(scope)}, run_org, NEW.run_id, line_count)
1214
+ ON CONFLICT (runtime_scope, org_id, run_id)
1215
+ DO UPDATE SET last_seq = runtime_observability.run_log_cursors.last_seq + EXCLUDED.last_seq,
1216
+ updated_at = clock_timestamp()
1217
+ RETURNING last_seq INTO assigned_seq;
1218
+ UPDATE runtime_observability.events AS event
1219
+ SET log_seq = assigned_seq - line_count + fresh.ordinality
1220
+ FROM unnest(fresh_event_ids) WITH ORDINALITY AS fresh(event_id, ordinality)
1221
+ WHERE event.runtime_scope = ${quoteLiteral(scope)}
1222
+ AND event.org_id = run_org
1223
+ AND event.event_id = fresh.event_id;
1224
+ END IF;
1225
+ RETURN NEW;
1226
+ END
1227
+ $function$
1228
+ `);
1229
+ await client.query(
1230
+ `COMMENT ON FUNCTION ${schema}.search_outbox_event() IS ${quoteLiteral(RUNTIME_EVENT_SEARCH_OUTBOX_PROJECTION_MARKER)}`,
1231
+ );
1232
+ await client.query(`DO $create$ BEGIN
1233
+ IF NOT EXISTS (SELECT 1 FROM pg_trigger WHERE tgrelid = '${schema}.outbox'::regclass
1234
+ AND tgname = 'runtime_outbox_event_v1') THEN
1235
+ CREATE TRIGGER runtime_outbox_event_v1 AFTER INSERT ON ${schema}.outbox
1236
+ FOR EACH ROW EXECUTE FUNCTION ${schema}.search_outbox_event();
1237
+ END IF;
1238
+ END $create$`);
1239
+ }
1240
+
1241
+ export async function hasRuntimeEventSearchShape(
1242
+ client: PostgresSchedulerQueryClient,
1243
+ runtimeScope: string,
1244
+ ): Promise<boolean> {
1245
+ const schema = quoteIdent(requiredScope(runtimeScope));
1246
+ const result = await client.query<{ ready: boolean }>(
1247
+ `SELECT to_regclass('runtime_observability.events') IS NOT NULL
1248
+ AND to_regclass('runtime_observability.run_log_cursors') IS NOT NULL
1249
+ AND EXISTS (SELECT 1 FROM pg_attribute
1250
+ WHERE attrelid = to_regclass('runtime_observability.events')
1251
+ AND attname = 'log_seq' AND NOT attisdropped)
1252
+ AND EXISTS (SELECT 1 FROM pg_attribute
1253
+ WHERE attrelid = to_regclass('runtime_observability.events')
1254
+ AND attname = 'log_body' AND NOT attisdropped)
1255
+ AND EXISTS (SELECT 1 FROM pg_class
1256
+ WHERE oid = to_regclass('runtime_observability.events') AND relrowsecurity)
1257
+ AND EXISTS (SELECT 1 FROM pg_class
1258
+ WHERE oid = to_regclass('runtime_observability.run_log_cursors') AND relrowsecurity)
1259
+ AND EXISTS (SELECT 1 FROM pg_trigger WHERE tgrelid = '${schema}.receipts'::regclass
1260
+ AND tgname = 'runtime_receipt_event_v1' AND NOT tgisinternal)
1261
+ AND EXISTS (SELECT 1 FROM pg_trigger WHERE tgrelid = '${schema}.outbox'::regclass
1262
+ AND tgname = 'runtime_outbox_event_v1' AND NOT tgisinternal)
1263
+ AND EXISTS (SELECT 1 FROM pg_proc AS proc
1264
+ JOIN pg_namespace AS namespace ON namespace.oid = proc.pronamespace
1265
+ WHERE namespace.nspname = $1 AND proc.proname = 'search_outbox_event'
1266
+ AND proc.pronargs = 0
1267
+ AND obj_description(proc.oid, 'pg_proc') = ${quoteLiteral(RUNTIME_EVENT_SEARCH_OUTBOX_PROJECTION_MARKER)})
1268
+ AS ready`,
1269
+ [runtimeScope],
1270
+ );
1271
+ return result.rows[0]?.ready === true;
1272
+ }
1273
+
1274
+ /** One bounded operator-owned TTL pass. Never called by a customer request. */
1275
+ export async function pruneRuntimeEvents(
1276
+ client: PostgresSchedulerQueryClient,
1277
+ input: { before: Date; limit: number },
1278
+ ): Promise<number> {
1279
+ const before = input.before.getTime();
1280
+ if (!Number.isFinite(before) || before > Date.now() - 60 * 60 * 1000) {
1281
+ throw new RuntimeEventInputError(
1282
+ 'Runtime event cleanup cutoff must be at least one hour old.',
1283
+ );
1284
+ }
1285
+ if (
1286
+ !Number.isSafeInteger(input.limit) ||
1287
+ input.limit < 1 ||
1288
+ input.limit > 5_000
1289
+ ) {
1290
+ throw new RuntimeEventInputError(
1291
+ 'Runtime event cleanup limit must be between 1 and 5000.',
1292
+ );
1293
+ }
1294
+ const result = await client.query<{ event_id: string }>(
1295
+ `WITH candidates AS (
1296
+ SELECT ctid FROM runtime_observability.events
1297
+ WHERE ingested_at < $1::timestamptz
1298
+ ORDER BY ingested_at, event_id
1299
+ LIMIT $2::integer FOR UPDATE SKIP LOCKED
1300
+ )
1301
+ DELETE FROM runtime_observability.events AS event USING candidates
1302
+ WHERE event.ctid = candidates.ctid
1303
+ RETURNING event.event_id`,
1304
+ [input.before.toISOString(), input.limit],
1305
+ );
1306
+ // Keep the small per-run high-water marks after log bodies expire. A delayed
1307
+ // accepted batch must not restart that run at sequence 1 and break cursors.
1308
+ return result.rows.length;
1309
+ }
1310
+
1311
+ /** Read-only bounded estimate for the same operator TTL cutoff. */
1312
+ export async function inspectRuntimeEventRetention(
1313
+ client: PostgresSchedulerQueryClient,
1314
+ input: { before: Date },
1315
+ ): Promise<{
1316
+ oldestIngestedAt: string | null;
1317
+ expiredCountAtLeast: number;
1318
+ countCapped: boolean;
1319
+ }> {
1320
+ const before = input.before.getTime();
1321
+ if (!Number.isFinite(before))
1322
+ throw new RuntimeEventInputError('Invalid runtime event retention cutoff.');
1323
+ const result = await client.query<{
1324
+ oldest: Date | string | null;
1325
+ count: number | string;
1326
+ }>(
1327
+ `WITH oldest AS (
1328
+ SELECT ingested_at FROM runtime_observability.events
1329
+ ORDER BY ingested_at, event_id LIMIT 1
1330
+ ), bounded AS (
1331
+ SELECT 1 FROM runtime_observability.events
1332
+ WHERE ingested_at < $1::timestamptz
1333
+ ORDER BY ingested_at, event_id LIMIT 10001
1334
+ )
1335
+ SELECT (SELECT ingested_at FROM oldest) AS oldest,
1336
+ (SELECT count(*) FROM bounded) AS count`,
1337
+ [input.before.toISOString()],
1338
+ );
1339
+ const count = Number(result.rows[0]?.count ?? 0);
1340
+ return {
1341
+ oldestIngestedAt: result.rows[0]?.oldest
1342
+ ? iso(result.rows[0].oldest)
1343
+ : null,
1344
+ expiredCountAtLeast: Math.min(count, 10_000),
1345
+ countCapped: count > 10_000,
1346
+ };
1347
+ }