@team-semicolon/semicolony-cli 4.18.51 → 4.18.53

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.
@@ -0,0 +1,1406 @@
1
+ -- @schema-retarget: off
2
+ -- Durable, credential-free dispatch identities for Hive cron typed read actions.
3
+ -- The migration runner supplies the transaction boundary; do not add one here.
4
+
5
+ DO $$
6
+ DECLARE
7
+ role_state record;
8
+ BEGIN
9
+ SELECT rolcanlogin, rolsuper, rolcreatedb, rolcreaterole,
10
+ rolinherit, rolreplication, rolbypassrls
11
+ INTO role_state
12
+ FROM pg_roles
13
+ WHERE rolname = 'sc_provider';
14
+
15
+ IF NOT FOUND THEN
16
+ RAISE EXCEPTION 'sc_provider role missing; migration 135 required';
17
+ END IF;
18
+
19
+ IF NOT role_state.rolcanlogin
20
+ OR role_state.rolsuper
21
+ OR role_state.rolcreatedb
22
+ OR role_state.rolcreaterole
23
+ OR role_state.rolinherit
24
+ OR role_state.rolreplication
25
+ OR role_state.rolbypassrls THEN
26
+ RAISE EXCEPTION 'sc_provider role attributes are unsafe';
27
+ END IF;
28
+ END $$;
29
+
30
+ CREATE FUNCTION semicolony.hive_cron_resource_scopes_are_canonical(p_scopes text[])
31
+ RETURNS boolean
32
+ LANGUAGE sql
33
+ IMMUTABLE
34
+ SECURITY DEFINER
35
+ SET search_path = pg_catalog
36
+ SET TimeZone = 'UTC'
37
+ AS $$
38
+ SELECT COALESCE(
39
+ p_scopes IS NOT NULL
40
+ AND pg_catalog.cardinality(p_scopes) BETWEEN 1 AND 32
41
+ AND pg_catalog.array_position(p_scopes, NULL) IS NULL
42
+ AND p_scopes = ARRAY(
43
+ SELECT scope
44
+ FROM pg_catalog.unnest(p_scopes) AS scope
45
+ ORDER BY scope COLLATE "C"
46
+ )
47
+ AND pg_catalog.cardinality(p_scopes) = (
48
+ SELECT pg_catalog.count(DISTINCT scope COLLATE "C")::integer
49
+ FROM pg_catalog.unnest(p_scopes) AS scope
50
+ )
51
+ AND NOT EXISTS (
52
+ SELECT 1
53
+ FROM pg_catalog.unnest(p_scopes) AS scope
54
+ WHERE (scope COLLATE "C") !~ '^[!-~]+$'
55
+ OR pg_catalog.char_length(scope) NOT BETWEEN 1 AND 256
56
+ OR pg_catalog.strpos(scope, '*') > 0
57
+ ),
58
+ false
59
+ );
60
+ $$;
61
+
62
+ REVOKE ALL ON FUNCTION semicolony.hive_cron_resource_scopes_are_canonical(text[])
63
+ FROM PUBLIC, sc_app, sc_provider;
64
+
65
+ CREATE TABLE semicolony.cron_read_action_dispatch_outbox (
66
+ bot_id TEXT NOT NULL CHECK (bot_id = 'semi'),
67
+ job_id TEXT NOT NULL CHECK (
68
+ (job_id COLLATE "C") ~ '^[A-Za-z0-9][A-Za-z0-9._:-]{0,127}$'
69
+ ),
70
+ runtime_source TEXT NOT NULL CHECK (runtime_source = 'hive-cron-v2'),
71
+ scheduled_for TIMESTAMPTZ NOT NULL,
72
+ idempotency_key TEXT NOT NULL,
73
+ capability_template_ref TEXT NOT NULL,
74
+ template_updated_at TIMESTAMPTZ NOT NULL,
75
+ permission TEXT NOT NULL CHECK (permission = 'read'),
76
+ resource_scopes TEXT[] NOT NULL CHECK (
77
+ semicolony.hive_cron_resource_scopes_are_canonical(resource_scopes)
78
+ ),
79
+ destination TEXT NOT NULL CHECK (
80
+ pg_catalog.char_length(destination) BETWEEN 1 AND 256
81
+ ),
82
+ timeout_seconds INTEGER NOT NULL CHECK (timeout_seconds BETWEEN 1 AND 3600),
83
+ expected_outcome_digest TEXT NOT NULL CHECK (
84
+ expected_outcome_digest ~ '^sha256:[a-f0-9]{64}$'
85
+ ),
86
+ source_hash TEXT NOT NULL CHECK (source_hash ~ '^sha256:[a-f0-9]{64}$'),
87
+ action_kind TEXT NOT NULL CHECK (
88
+ pg_catalog.char_length(action_kind) BETWEEN 1 AND 256
89
+ ),
90
+ action_version INTEGER NOT NULL CHECK (action_version > 0),
91
+ action_contract_digest TEXT NOT NULL CHECK (
92
+ action_contract_digest ~ '^[a-f0-9]{64}$'
93
+ ),
94
+ trusted_config_revision INTEGER NOT NULL CHECK (trusted_config_revision > 0),
95
+ trusted_config_ref TEXT NOT NULL,
96
+ trusted_config_digest TEXT NOT NULL CHECK (
97
+ trusted_config_digest ~ '^sha256:[a-f0-9]{64}$'
98
+ ),
99
+ occurrence_claim_id TEXT NOT NULL CHECK (
100
+ occurrence_claim_id ~ '^scheduled-[a-f0-9]{64}$'
101
+ ),
102
+ status TEXT NOT NULL DEFAULT 'pending'
103
+ CHECK (status IN ('pending', 'claimed', 'completed', 'failed', 'blocked')),
104
+ claim_id TEXT CHECK (
105
+ claim_id IS NULL
106
+ OR (claim_id COLLATE "C") ~ '^[A-Za-z0-9][A-Za-z0-9._:-]{0,127}$'
107
+ ),
108
+ claimed_at TIMESTAMPTZ,
109
+ claim_expires_at TIMESTAMPTZ,
110
+ attempt_count INTEGER NOT NULL DEFAULT 0 CHECK (attempt_count BETWEEN 0 AND 3),
111
+ next_attempt_at TIMESTAMPTZ,
112
+ result_code TEXT CHECK (
113
+ result_code IS NULL
114
+ OR result_code IN (
115
+ 'completed',
116
+ 'released_failed',
117
+ 'retry_exhausted',
118
+ 'ambiguous_authority_state',
119
+ 'stale_binding'
120
+ )
121
+ ),
122
+ resolved_at TIMESTAMPTZ,
123
+ created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
124
+ updated_at TIMESTAMPTZ NOT NULL DEFAULT now(),
125
+ PRIMARY KEY (idempotency_key),
126
+ UNIQUE (job_id, scheduled_for),
127
+ UNIQUE (claim_id),
128
+ UNIQUE (occurrence_claim_id),
129
+ FOREIGN KEY (bot_id, job_id, runtime_source)
130
+ REFERENCES semicolony.cron_capability_templates (bot_id, job_id, runtime_source),
131
+ FOREIGN KEY (bot_id, job_id, runtime_source, trusted_config_revision)
132
+ REFERENCES semicolony.cron_read_action_trusted_config_revisions
133
+ (bot_id, job_id, runtime_source, config_revision),
134
+ CHECK (idempotency_key = 'cron:' || job_id || ':' ||
135
+ to_char(scheduled_for AT TIME ZONE 'UTC', 'YYYY-MM-DD"T"HH24:MI:SS.MS"Z"')),
136
+ CHECK (occurrence_claim_id = 'scheduled-' || pg_catalog.encode(
137
+ pg_catalog.sha256(pg_catalog.convert_to(idempotency_key, 'UTF8')),
138
+ 'hex'
139
+ )),
140
+ CHECK (capability_template_ref = 'semi:' || job_id || ':hive-cron-v2'),
141
+ CHECK (trusted_config_ref = 'semi:' || job_id || ':hive-cron-v2:config:r' ||
142
+ trusted_config_revision::text),
143
+ CHECK (
144
+ (
145
+ status = 'pending'
146
+ AND claim_id IS NULL
147
+ AND claimed_at IS NULL
148
+ AND claim_expires_at IS NULL
149
+ AND next_attempt_at IS NOT NULL
150
+ AND result_code IS NULL
151
+ AND resolved_at IS NULL
152
+ )
153
+ OR
154
+ (
155
+ status = 'claimed'
156
+ AND claim_id IS NOT NULL
157
+ AND claimed_at IS NOT NULL
158
+ AND claim_expires_at IS NOT NULL
159
+ AND claim_expires_at > claimed_at
160
+ AND next_attempt_at IS NULL
161
+ AND result_code IS NULL
162
+ AND resolved_at IS NULL
163
+ )
164
+ OR
165
+ (
166
+ status IN ('completed', 'failed', 'blocked')
167
+ AND claim_id IS NULL
168
+ AND claimed_at IS NULL
169
+ AND claim_expires_at IS NULL
170
+ AND next_attempt_at IS NULL
171
+ AND result_code IS NOT NULL
172
+ AND resolved_at IS NOT NULL
173
+ AND (
174
+ (status = 'completed' AND result_code = 'completed')
175
+ OR
176
+ (status = 'failed' AND result_code IN ('released_failed', 'retry_exhausted', 'stale_binding'))
177
+ OR
178
+ (status = 'blocked' AND result_code = 'ambiguous_authority_state')
179
+ )
180
+ )
181
+ )
182
+ );
183
+
184
+ CREATE INDEX idx_cron_read_action_dispatch_pending
185
+ ON semicolony.cron_read_action_dispatch_outbox
186
+ (next_attempt_at, scheduled_for, idempotency_key)
187
+ WHERE status = 'pending';
188
+
189
+ CREATE INDEX idx_cron_read_action_dispatch_abandoned
190
+ ON semicolony.cron_read_action_dispatch_outbox (claim_expires_at, idempotency_key)
191
+ WHERE status = 'claimed';
192
+
193
+ CREATE FUNCTION semicolony.hive_cron_protect_terminal_dispatch()
194
+ RETURNS trigger
195
+ LANGUAGE plpgsql
196
+ SECURITY DEFINER
197
+ SET search_path = pg_catalog
198
+ SET TimeZone = 'UTC'
199
+ AS $$
200
+ BEGIN
201
+ IF session_user <> 'sc_provider'
202
+ OR public.is_provider() IS DISTINCT FROM true THEN
203
+ RAISE EXCEPTION 'provider_mode_required' USING ERRCODE = '42501';
204
+ END IF;
205
+ IF OLD.status IN ('completed', 'failed', 'blocked') THEN
206
+ RAISE EXCEPTION 'terminal_dispatch_immutable' USING ERRCODE = '55000';
207
+ END IF;
208
+ IF TG_OP = 'DELETE' THEN
209
+ RETURN OLD;
210
+ END IF;
211
+ RETURN NEW;
212
+ END;
213
+ $$;
214
+
215
+ CREATE TRIGGER protect_terminal_cron_read_action_dispatch
216
+ BEFORE UPDATE OR DELETE ON semicolony.cron_read_action_dispatch_outbox
217
+ FOR EACH ROW EXECUTE FUNCTION semicolony.hive_cron_protect_terminal_dispatch();
218
+
219
+ CREATE FUNCTION semicolony.hive_cron_derive_next_run(
220
+ p_schedule jsonb,
221
+ p_observed_at timestamptz
222
+ )
223
+ RETURNS timestamptz
224
+ LANGUAGE plpgsql
225
+ STABLE
226
+ SECURITY DEFINER
227
+ SET search_path = pg_catalog
228
+ SET TimeZone = 'UTC'
229
+ AS $$
230
+ DECLARE
231
+ schedule_keys text[];
232
+ cron_expression text;
233
+ fields text[];
234
+ minute_field text;
235
+ hour_field text;
236
+ day_of_week_field text;
237
+ minute_value integer;
238
+ minute_step integer;
239
+ hour_value integer;
240
+ hour_step integer;
241
+ day_of_week_value integer;
242
+ candidate timestamptz;
243
+ local_candidate timestamp;
244
+ BEGIN
245
+ IF p_schedule IS NULL
246
+ OR p_observed_at IS NULL
247
+ OR pg_catalog.jsonb_typeof(p_schedule) <> 'object' THEN
248
+ RAISE EXCEPTION 'unsupported_hive_cron_schedule' USING ERRCODE = '22023';
249
+ END IF;
250
+
251
+ SELECT pg_catalog.array_agg(key ORDER BY key)
252
+ INTO schedule_keys
253
+ FROM pg_catalog.jsonb_object_keys(p_schedule) AS key;
254
+ IF schedule_keys IS DISTINCT FROM ARRAY['cron', 'tz']::text[]
255
+ OR pg_catalog.jsonb_typeof(p_schedule->'cron') <> 'string'
256
+ OR pg_catalog.jsonb_typeof(p_schedule->'tz') <> 'string'
257
+ OR p_schedule->>'tz' <> 'Asia/Seoul' THEN
258
+ RAISE EXCEPTION 'unsupported_hive_cron_schedule' USING ERRCODE = '22023';
259
+ END IF;
260
+
261
+ cron_expression := p_schedule->>'cron';
262
+ fields := pg_catalog.string_to_array(cron_expression, ' ');
263
+ IF pg_catalog.cardinality(fields) <> 5
264
+ OR pg_catalog.array_to_string(fields, ' ') <> cron_expression THEN
265
+ RAISE EXCEPTION 'unsupported_hive_cron_schedule' USING ERRCODE = '22023';
266
+ END IF;
267
+
268
+ minute_field := fields[1];
269
+ hour_field := fields[2];
270
+ day_of_week_field := fields[5];
271
+
272
+ IF (minute_field COLLATE "C") ~ '^(0|[1-9]|[1-5][0-9])$' THEN
273
+ minute_value := minute_field::integer;
274
+ ELSIF (minute_field COLLATE "C") ~ '^\*/([1-9]|[1-5][0-9])$' THEN
275
+ minute_step := pg_catalog.substr(minute_field, 3)::integer;
276
+ ELSE
277
+ RAISE EXCEPTION 'unsupported_hive_cron_schedule' USING ERRCODE = '22023';
278
+ END IF;
279
+
280
+ IF hour_field = '*' THEN
281
+ NULL;
282
+ ELSIF (hour_field COLLATE "C") ~ '^(0|[1-9]|1[0-9]|2[0-3])$' THEN
283
+ hour_value := hour_field::integer;
284
+ ELSIF (hour_field COLLATE "C") ~ '^\*/([1-9]|1[0-9]|2[0-3])$' THEN
285
+ hour_step := pg_catalog.substr(hour_field, 3)::integer;
286
+ ELSE
287
+ RAISE EXCEPTION 'unsupported_hive_cron_schedule' USING ERRCODE = '22023';
288
+ END IF;
289
+
290
+ IF fields[3] <> '*' OR fields[4] <> '*' THEN
291
+ RAISE EXCEPTION 'unsupported_hive_cron_schedule' USING ERRCODE = '22023';
292
+ END IF;
293
+
294
+ IF day_of_week_field = '*' THEN
295
+ NULL;
296
+ ELSIF (day_of_week_field COLLATE "C") ~ '^[0-7]$' THEN
297
+ day_of_week_value := day_of_week_field::integer;
298
+ IF day_of_week_value = 7 THEN
299
+ day_of_week_value := 0;
300
+ END IF;
301
+ ELSE
302
+ RAISE EXCEPTION 'unsupported_hive_cron_schedule' USING ERRCODE = '22023';
303
+ END IF;
304
+
305
+ FOR candidate IN
306
+ SELECT series
307
+ FROM pg_catalog.generate_series(
308
+ pg_catalog.date_trunc('minute', p_observed_at) + interval '1 minute',
309
+ pg_catalog.date_trunc('minute', p_observed_at) + interval '8 days',
310
+ interval '1 minute'
311
+ ) AS series
312
+ LOOP
313
+ local_candidate := candidate AT TIME ZONE 'Asia/Seoul';
314
+ IF (minute_value IS NULL OR pg_catalog.date_part('minute', local_candidate)::integer = minute_value)
315
+ AND (minute_step IS NULL OR
316
+ pg_catalog.mod(pg_catalog.date_part('minute', local_candidate)::integer, minute_step) = 0)
317
+ AND (hour_value IS NULL OR pg_catalog.date_part('hour', local_candidate)::integer = hour_value)
318
+ AND (hour_step IS NULL OR
319
+ pg_catalog.mod(pg_catalog.date_part('hour', local_candidate)::integer, hour_step) = 0)
320
+ AND (day_of_week_value IS NULL OR
321
+ pg_catalog.date_part('dow', local_candidate)::integer = day_of_week_value) THEN
322
+ RETURN candidate;
323
+ END IF;
324
+ END LOOP;
325
+
326
+ RAISE EXCEPTION 'unsupported_hive_cron_schedule' USING ERRCODE = '22023';
327
+ END;
328
+ $$;
329
+
330
+ REVOKE ALL ON FUNCTION semicolony.hive_cron_derive_next_run(jsonb, timestamptz)
331
+ FROM PUBLIC, sc_app, sc_provider;
332
+
333
+ CREATE FUNCTION semicolony.hive_cron_preview_due_dispatches(
334
+ p_now timestamptz,
335
+ p_limit integer
336
+ )
337
+ RETURNS TABLE (
338
+ job_id text,
339
+ schedule jsonb,
340
+ scheduled_for timestamptz,
341
+ observed_at timestamptz,
342
+ idempotency_key text,
343
+ capability_template_ref text,
344
+ template_updated_at timestamptz,
345
+ permission text,
346
+ resource_scopes text[],
347
+ destination text,
348
+ timeout_seconds integer,
349
+ expected_outcome_digest text,
350
+ source_hash text,
351
+ action_kind text,
352
+ action_version integer,
353
+ action_contract_digest text,
354
+ trusted_config_ref text,
355
+ trusted_config_revision integer,
356
+ trusted_config_digest text
357
+ )
358
+ LANGUAGE plpgsql
359
+ SECURITY DEFINER
360
+ SET search_path = pg_catalog
361
+ SET TimeZone = 'UTC'
362
+ AS $$
363
+ DECLARE
364
+ db_now timestamptz;
365
+ BEGIN
366
+ IF session_user <> 'sc_provider'
367
+ OR public.is_provider() IS DISTINCT FROM true THEN
368
+ RAISE EXCEPTION 'provider_mode_required' USING ERRCODE = '42501';
369
+ END IF;
370
+ db_now := pg_catalog.clock_timestamp();
371
+ IF p_now IS NULL
372
+ OR p_now < db_now - interval '30 seconds'
373
+ OR p_now > db_now + interval '30 seconds'
374
+ OR p_limit IS NULL OR p_limit < 1 OR p_limit > 20 THEN
375
+ RAISE EXCEPTION 'invalid_dispatch_preview_input' USING ERRCODE = '22023';
376
+ END IF;
377
+
378
+ RETURN QUERY
379
+ WITH candidates AS MATERIALIZED (
380
+ SELECT job.job_id,
381
+ job.schedule,
382
+ job.next_run AT TIME ZONE 'UTC' AS scheduled_for,
383
+ db_now AS observed_at,
384
+ 'cron:' || job.job_id || ':' ||
385
+ to_char(job.next_run AT TIME ZONE 'UTC', 'YYYY-MM-DD"T"HH24:MI:SS.MS"Z"') AS idempotency_key,
386
+ 'semi:' || job.job_id || ':hive-cron-v2' AS capability_template_ref,
387
+ template.updated_at AS template_updated_at,
388
+ template.permission,
389
+ canonical.resource_scopes,
390
+ template.destination,
391
+ template.timeout_seconds,
392
+ template.expected_outcome_digest,
393
+ template.source_hash,
394
+ template.action_kind,
395
+ template.action_version,
396
+ template.action_contract_digest,
397
+ 'semi:' || job.job_id || ':hive-cron-v2:config:r' ||
398
+ config.config_revision::text AS trusted_config_ref,
399
+ config.config_revision AS trusted_config_revision,
400
+ config.config_digest AS trusted_config_digest
401
+ FROM semicolony.bot_cron_jobs AS job
402
+ JOIN semicolony.cron_capability_templates AS template
403
+ ON template.bot_id = job.bot_id
404
+ AND template.job_id = job.job_id
405
+ AND template.runtime_source = job.runtime_source
406
+ JOIN LATERAL (
407
+ SELECT pg_catalog.array_agg(scope ORDER BY scope COLLATE "C") AS resource_scopes
408
+ FROM (
409
+ SELECT DISTINCT scope
410
+ FROM pg_catalog.unnest(template.resource_scopes) AS scope
411
+ ) AS unique_scope
412
+ ) AS canonical ON canonical.resource_scopes IS NOT NULL
413
+ JOIN LATERAL (
414
+ SELECT revision.config_revision,
415
+ revision.config_digest,
416
+ revision.action_kind,
417
+ revision.action_version,
418
+ revision.action_contract_digest
419
+ FROM semicolony.cron_read_action_trusted_config_revisions AS revision
420
+ WHERE revision.bot_id = template.bot_id
421
+ AND revision.job_id = template.job_id
422
+ AND revision.runtime_source = template.runtime_source
423
+ ORDER BY revision.config_revision DESC
424
+ LIMIT 1
425
+ ) AS config ON true
426
+ WHERE job.bot_id = 'semi'
427
+ AND job.runtime_source = 'hive-cron-v2'
428
+ AND job.enabled = true
429
+ AND job.next_run IS NOT NULL
430
+ AND job.next_run AT TIME ZONE 'UTC' <= db_now
431
+ AND template.permission = 'read'
432
+ AND pg_catalog.char_length(template.destination) BETWEEN 1 AND 256
433
+ AND template.timeout_seconds BETWEEN 1 AND 3600
434
+ AND template.expected_outcome_digest ~ '^sha256:[a-f0-9]{64}$'
435
+ AND template.source_hash ~ '^sha256:[a-f0-9]{64}$'
436
+ AND pg_catalog.char_length(template.action_kind) BETWEEN 1 AND 256
437
+ AND template.action_version > 0
438
+ AND template.action_contract_digest ~ '^[a-f0-9]{64}$'
439
+ AND pg_catalog.cardinality(template.resource_scopes) BETWEEN 1 AND 32
440
+ AND pg_catalog.array_position(template.resource_scopes, NULL) IS NULL
441
+ AND pg_catalog.cardinality(template.resource_scopes) =
442
+ pg_catalog.cardinality(canonical.resource_scopes)
443
+ AND NOT EXISTS (
444
+ SELECT 1
445
+ FROM pg_catalog.unnest(template.resource_scopes) AS scope
446
+ WHERE (scope COLLATE "C") !~ '^[!-~]+$'
447
+ OR pg_catalog.char_length(scope) NOT BETWEEN 1 AND 256
448
+ OR pg_catalog.strpos(scope, '*') > 0
449
+ )
450
+ AND template.action_kind IS NOT NULL
451
+ AND template.action_version IS NOT NULL
452
+ AND template.action_contract_digest IS NOT NULL
453
+ AND config.action_kind = template.action_kind
454
+ AND config.action_version = template.action_version
455
+ AND config.action_contract_digest = template.action_contract_digest
456
+ ORDER BY job.next_run AT TIME ZONE 'UTC', job.job_id
457
+ LIMIT p_limit
458
+ ), validated AS MATERIALIZED (
459
+ SELECT candidate.*,
460
+ semicolony.hive_cron_derive_next_run(candidate.schedule, db_now) AS derived_next_run
461
+ FROM candidates AS candidate
462
+ )
463
+ SELECT validated.job_id,
464
+ validated.schedule,
465
+ validated.scheduled_for,
466
+ validated.observed_at,
467
+ validated.idempotency_key,
468
+ validated.capability_template_ref,
469
+ validated.template_updated_at,
470
+ validated.permission,
471
+ validated.resource_scopes,
472
+ validated.destination,
473
+ validated.timeout_seconds,
474
+ validated.expected_outcome_digest,
475
+ validated.source_hash,
476
+ validated.action_kind,
477
+ validated.action_version,
478
+ validated.action_contract_digest,
479
+ validated.trusted_config_ref,
480
+ validated.trusted_config_revision,
481
+ validated.trusted_config_digest
482
+ FROM validated
483
+ WHERE validated.derived_next_run > db_now
484
+ ORDER BY validated.scheduled_for, validated.job_id;
485
+ END;
486
+ $$;
487
+
488
+ CREATE FUNCTION semicolony.hive_cron_claim_dispatch_batch(
489
+ p_now timestamptz,
490
+ p_batch jsonb
491
+ )
492
+ RETURNS TABLE (
493
+ bot_id text,
494
+ job_id text,
495
+ runtime_source text,
496
+ scheduled_for timestamptz,
497
+ idempotency_key text,
498
+ capability_template_ref text,
499
+ template_updated_at timestamptz,
500
+ permission text,
501
+ resource_scopes text[],
502
+ destination text,
503
+ timeout_seconds integer,
504
+ expected_outcome_digest text,
505
+ source_hash text,
506
+ action_kind text,
507
+ action_version integer,
508
+ action_contract_digest text,
509
+ trusted_config_ref text,
510
+ trusted_config_revision integer,
511
+ trusted_config_digest text,
512
+ occurrence_claim_id text,
513
+ status text,
514
+ claim_id text,
515
+ claimed_at timestamptz,
516
+ claim_expires_at timestamptz,
517
+ attempt_count integer,
518
+ next_attempt_at timestamptz,
519
+ result_code text,
520
+ resolved_at timestamptz,
521
+ created_at timestamptz
522
+ )
523
+ LANGUAGE plpgsql
524
+ SECURITY DEFINER
525
+ SET search_path = pg_catalog
526
+ SET TimeZone = 'UTC'
527
+ AS $$
528
+ DECLARE
529
+ item jsonb;
530
+ item_keys text[];
531
+ expected_keys constant text[] := ARRAY[
532
+ 'actionContractDigest', 'actionKind', 'actionVersion', 'capabilityTemplateRef',
533
+ 'destination', 'expectedOutcomeDigest', 'idempotencyKey', 'jobId',
534
+ 'observedAt', 'occurrenceClaimId', 'permission', 'resourceScopes', 'schedule', 'scheduledFor',
535
+ 'sourceHash', 'templateUpdatedAt', 'timeoutSeconds', 'trustedConfigDigest',
536
+ 'trustedConfigRef', 'trustedConfigRevision'
537
+ ];
538
+ due record;
539
+ input_scheduled_for timestamptz;
540
+ input_observed_at timestamptz;
541
+ expected_scheduled_for text;
542
+ changed integer;
543
+ db_now timestamptz;
544
+ BEGIN
545
+ IF session_user <> 'sc_provider'
546
+ OR public.is_provider() IS DISTINCT FROM true THEN
547
+ RAISE EXCEPTION 'provider_mode_required' USING ERRCODE = '42501';
548
+ END IF;
549
+ db_now := pg_catalog.clock_timestamp();
550
+ IF p_now IS NULL
551
+ OR p_now < db_now - interval '30 seconds'
552
+ OR p_now > db_now + interval '30 seconds'
553
+ OR p_batch IS NULL
554
+ OR pg_catalog.jsonb_typeof(p_batch) <> 'array'
555
+ OR pg_catalog.octet_length(p_batch::text) > 262144
556
+ OR pg_catalog.jsonb_array_length(p_batch) < 1
557
+ OR pg_catalog.jsonb_array_length(p_batch) > 20 THEN
558
+ RAISE EXCEPTION 'invalid_dispatch_batch' USING ERRCODE = '22023';
559
+ END IF;
560
+
561
+ FOR item IN SELECT value FROM pg_catalog.jsonb_array_elements(p_batch)
562
+ LOOP
563
+ IF pg_catalog.jsonb_typeof(item) <> 'object' THEN
564
+ RAISE EXCEPTION 'invalid_dispatch_batch' USING ERRCODE = '22023';
565
+ END IF;
566
+ SELECT pg_catalog.array_agg(key ORDER BY key)
567
+ INTO item_keys
568
+ FROM pg_catalog.jsonb_object_keys(item) AS key;
569
+ IF item_keys IS DISTINCT FROM expected_keys
570
+ OR pg_catalog.jsonb_typeof(item->'jobId') <> 'string'
571
+ OR pg_catalog.jsonb_typeof(item->'scheduledFor') <> 'string'
572
+ OR pg_catalog.jsonb_typeof(item->'observedAt') <> 'string'
573
+ OR pg_catalog.jsonb_typeof(item->'schedule') <> 'object'
574
+ OR pg_catalog.jsonb_typeof(item->'idempotencyKey') <> 'string'
575
+ OR pg_catalog.jsonb_typeof(item->'capabilityTemplateRef') <> 'string'
576
+ OR pg_catalog.jsonb_typeof(item->'templateUpdatedAt') <> 'string'
577
+ OR pg_catalog.jsonb_typeof(item->'permission') <> 'string'
578
+ OR pg_catalog.jsonb_typeof(item->'resourceScopes') <> 'array'
579
+ OR pg_catalog.jsonb_typeof(item->'destination') <> 'string'
580
+ OR pg_catalog.jsonb_typeof(item->'timeoutSeconds') <> 'number'
581
+ OR pg_catalog.jsonb_typeof(item->'expectedOutcomeDigest') <> 'string'
582
+ OR pg_catalog.jsonb_typeof(item->'sourceHash') <> 'string'
583
+ OR pg_catalog.jsonb_typeof(item->'actionKind') <> 'string'
584
+ OR pg_catalog.jsonb_typeof(item->'actionVersion') <> 'number'
585
+ OR pg_catalog.jsonb_typeof(item->'actionContractDigest') <> 'string'
586
+ OR pg_catalog.jsonb_typeof(item->'trustedConfigRef') <> 'string'
587
+ OR pg_catalog.jsonb_typeof(item->'trustedConfigRevision') <> 'number'
588
+ OR pg_catalog.jsonb_typeof(item->'trustedConfigDigest') <> 'string'
589
+ OR pg_catalog.jsonb_typeof(item->'occurrenceClaimId') <> 'string'
590
+ OR ((item->>'jobId') COLLATE "C") !~ '^[A-Za-z0-9][A-Za-z0-9._:-]{0,127}$'
591
+ OR ((item->>'scheduledFor') COLLATE "C") !~
592
+ '^[0-9]{4}-[0-9]{2}-[0-9]{2}T[0-9]{2}:[0-9]{2}:[0-9]{2}\.[0-9]{3}Z$'
593
+ OR ((item->>'observedAt') COLLATE "C") !~
594
+ '^[0-9]{4}-[0-9]{2}-[0-9]{2}T[0-9]{2}:[0-9]{2}:[0-9]{2}\.[0-9]{3}Z$'
595
+ OR ((item->>'templateUpdatedAt') COLLATE "C") !~
596
+ '^[0-9]{4}-[0-9]{2}-[0-9]{2}T[0-9]{2}:[0-9]{2}:[0-9]{2}\.[0-9]{3}Z$'
597
+ OR (item->>'permission') <> 'read'
598
+ OR pg_catalog.jsonb_array_length(item->'resourceScopes') < 1
599
+ OR pg_catalog.jsonb_array_length(item->'resourceScopes') > 32
600
+ OR pg_catalog.char_length(item->>'destination') NOT BETWEEN 1 AND 256
601
+ OR (item->>'timeoutSeconds') !~ '^[1-9][0-9]*$'
602
+ OR ((item->>'expectedOutcomeDigest')) !~ '^sha256:[a-f0-9]{64}$'
603
+ OR ((item->>'sourceHash')) !~ '^sha256:[a-f0-9]{64}$'
604
+ OR pg_catalog.char_length(item->>'actionKind') NOT BETWEEN 1 AND 256
605
+ OR (item->>'actionVersion') !~ '^[1-9][0-9]*$'
606
+ OR ((item->>'actionContractDigest')) !~ '^[a-f0-9]{64}$'
607
+ OR ((item->>'trustedConfigDigest')) !~ '^sha256:[a-f0-9]{64}$' THEN
608
+ RAISE EXCEPTION 'invalid_dispatch_batch' USING ERRCODE = '22023';
609
+ END IF;
610
+
611
+ input_scheduled_for := (item->>'scheduledFor')::timestamptz;
612
+ input_observed_at := (item->>'observedAt')::timestamptz;
613
+ IF input_scheduled_for > input_observed_at
614
+ OR input_observed_at < db_now - interval '30 seconds'
615
+ OR input_observed_at > db_now THEN
616
+ RAISE EXCEPTION 'invalid_dispatch_batch' USING ERRCODE = '22023';
617
+ END IF;
618
+
619
+ SELECT job.next_run AT TIME ZONE 'UTC' AS scheduled_for_utc,
620
+ job.schedule AS schedule,
621
+ NULL::timestamptz AS next_run,
622
+ template.updated_at AS template_updated_at,
623
+ template.permission,
624
+ canonical.resource_scopes,
625
+ template.destination,
626
+ template.timeout_seconds,
627
+ template.expected_outcome_digest,
628
+ template.source_hash,
629
+ template.action_kind,
630
+ template.action_version,
631
+ template.action_contract_digest,
632
+ config.config_revision,
633
+ config.config_digest
634
+ INTO due
635
+ FROM semicolony.bot_cron_jobs AS job
636
+ JOIN semicolony.cron_capability_templates AS template
637
+ ON template.bot_id = job.bot_id
638
+ AND template.job_id = job.job_id
639
+ AND template.runtime_source = job.runtime_source
640
+ JOIN LATERAL (
641
+ SELECT pg_catalog.array_agg(scope ORDER BY scope COLLATE "C") AS resource_scopes
642
+ FROM (
643
+ SELECT DISTINCT scope
644
+ FROM pg_catalog.unnest(template.resource_scopes) AS scope
645
+ ) AS unique_scope
646
+ ) AS canonical ON canonical.resource_scopes IS NOT NULL
647
+ JOIN LATERAL (
648
+ SELECT revision.config_revision,
649
+ revision.config_digest,
650
+ revision.action_kind,
651
+ revision.action_version,
652
+ revision.action_contract_digest
653
+ FROM semicolony.cron_read_action_trusted_config_revisions AS revision
654
+ WHERE revision.bot_id = template.bot_id
655
+ AND revision.job_id = template.job_id
656
+ AND revision.runtime_source = template.runtime_source
657
+ ORDER BY revision.config_revision DESC
658
+ LIMIT 1
659
+ ) AS config ON true
660
+ WHERE job.bot_id = 'semi'
661
+ AND job.job_id = (item->>'jobId')
662
+ AND job.runtime_source = 'hive-cron-v2'
663
+ AND job.enabled = true
664
+ AND job.next_run IS NOT NULL
665
+ AND job.next_run AT TIME ZONE 'UTC' = input_scheduled_for
666
+ AND job.next_run AT TIME ZONE 'UTC' <= db_now
667
+ AND job.schedule = item->'schedule'
668
+ AND template.permission = 'read'
669
+ AND pg_catalog.char_length(template.destination) BETWEEN 1 AND 256
670
+ AND template.timeout_seconds BETWEEN 1 AND 3600
671
+ AND template.expected_outcome_digest ~ '^sha256:[a-f0-9]{64}$'
672
+ AND template.source_hash ~ '^sha256:[a-f0-9]{64}$'
673
+ AND pg_catalog.char_length(template.action_kind) BETWEEN 1 AND 256
674
+ AND template.action_version > 0
675
+ AND template.action_contract_digest ~ '^[a-f0-9]{64}$'
676
+ AND pg_catalog.cardinality(template.resource_scopes) BETWEEN 1 AND 32
677
+ AND pg_catalog.array_position(template.resource_scopes, NULL) IS NULL
678
+ AND pg_catalog.cardinality(template.resource_scopes) =
679
+ pg_catalog.cardinality(canonical.resource_scopes)
680
+ AND NOT EXISTS (
681
+ SELECT 1
682
+ FROM pg_catalog.unnest(template.resource_scopes) AS scope
683
+ WHERE (scope COLLATE "C") !~ '^[!-~]+$'
684
+ OR pg_catalog.char_length(scope) NOT BETWEEN 1 AND 256
685
+ OR pg_catalog.strpos(scope, '*') > 0
686
+ )
687
+ AND template.action_kind IS NOT NULL
688
+ AND template.action_version IS NOT NULL
689
+ AND template.action_contract_digest IS NOT NULL
690
+ AND config.action_kind = template.action_kind
691
+ AND config.action_version = template.action_version
692
+ AND config.action_contract_digest = template.action_contract_digest
693
+ FOR UPDATE OF job SKIP LOCKED;
694
+
695
+ IF NOT FOUND THEN
696
+ RAISE EXCEPTION 'dispatch_batch_stale_or_locked' USING ERRCODE = '40001';
697
+ END IF;
698
+
699
+ due.next_run := semicolony.hive_cron_derive_next_run(due.schedule, db_now);
700
+ IF due.next_run <= db_now THEN
701
+ RAISE EXCEPTION 'unsupported_hive_cron_schedule' USING ERRCODE = '22023';
702
+ END IF;
703
+
704
+ expected_scheduled_for := to_char(
705
+ input_scheduled_for AT TIME ZONE 'UTC',
706
+ 'YYYY-MM-DD"T"HH24:MI:SS.MS"Z"'
707
+ );
708
+ IF (item->>'scheduledFor') <> expected_scheduled_for
709
+ OR (item->>'idempotencyKey') <>
710
+ ('cron:' || (item->>'jobId') || ':' || expected_scheduled_for)
711
+ OR (item->>'capabilityTemplateRef') <>
712
+ ('semi:' || (item->>'jobId') || ':hive-cron-v2')
713
+ OR (item->>'templateUpdatedAt') <> to_char(
714
+ due.template_updated_at AT TIME ZONE 'UTC',
715
+ 'YYYY-MM-DD"T"HH24:MI:SS.MS"Z"'
716
+ )
717
+ OR (item->>'permission') <> due.permission
718
+ OR item->'resourceScopes' IS DISTINCT FROM pg_catalog.to_jsonb(due.resource_scopes)
719
+ OR (item->>'destination') <> due.destination
720
+ OR (item->>'timeoutSeconds')::integer <> due.timeout_seconds
721
+ OR (item->>'expectedOutcomeDigest') <> due.expected_outcome_digest
722
+ OR (item->>'sourceHash') <> due.source_hash
723
+ OR (item->>'actionKind') <> due.action_kind
724
+ OR (item->>'actionVersion')::integer <> due.action_version
725
+ OR (item->>'actionContractDigest') <> due.action_contract_digest
726
+ OR (item->>'trustedConfigRef') <>
727
+ ('semi:' || (item->>'jobId') || ':hive-cron-v2:config:r' || due.config_revision::text)
728
+ OR (item->>'trustedConfigRevision') !~ '^[1-9][0-9]*$'
729
+ OR (item->>'trustedConfigRevision')::integer <> due.config_revision
730
+ OR (item->>'trustedConfigDigest') <> due.config_digest THEN
731
+ RAISE EXCEPTION 'dispatch_batch_stale_or_locked' USING ERRCODE = '40001';
732
+ END IF;
733
+ IF ((item->>'occurrenceClaimId') COLLATE "C") !~ '^scheduled-[a-f0-9]{64}$'
734
+ OR (item->>'occurrenceClaimId') <> 'scheduled-' || pg_catalog.encode(
735
+ pg_catalog.sha256(pg_catalog.convert_to(item->>'idempotencyKey', 'UTF8')),
736
+ 'hex'
737
+ ) THEN
738
+ RAISE EXCEPTION 'invalid_dispatch_batch' USING ERRCODE = '22023';
739
+ END IF;
740
+
741
+ INSERT INTO semicolony.cron_read_action_dispatch_outbox (
742
+ bot_id, job_id, runtime_source, scheduled_for, idempotency_key,
743
+ capability_template_ref, template_updated_at, permission, resource_scopes,
744
+ destination, timeout_seconds, expected_outcome_digest, source_hash,
745
+ action_kind, action_version, action_contract_digest,
746
+ trusted_config_ref, trusted_config_revision, trusted_config_digest, occurrence_claim_id,
747
+ status, attempt_count, next_attempt_at, created_at, updated_at
748
+ ) VALUES (
749
+ 'semi', (item->>'jobId'), 'hive-cron-v2', input_scheduled_for,
750
+ (item->>'idempotencyKey'), (item->>'capabilityTemplateRef'),
751
+ due.template_updated_at, due.permission, due.resource_scopes,
752
+ due.destination, due.timeout_seconds, due.expected_outcome_digest, due.source_hash,
753
+ due.action_kind, due.action_version, due.action_contract_digest,
754
+ (item->>'trustedConfigRef'), (item->>'trustedConfigRevision')::integer,
755
+ (item->>'trustedConfigDigest'), (item->>'occurrenceClaimId'),
756
+ 'pending', 0, db_now, db_now, db_now
757
+ )
758
+ RETURNING
759
+ cron_read_action_dispatch_outbox.bot_id,
760
+ cron_read_action_dispatch_outbox.job_id,
761
+ cron_read_action_dispatch_outbox.runtime_source,
762
+ cron_read_action_dispatch_outbox.scheduled_for,
763
+ cron_read_action_dispatch_outbox.idempotency_key,
764
+ cron_read_action_dispatch_outbox.capability_template_ref,
765
+ cron_read_action_dispatch_outbox.template_updated_at,
766
+ cron_read_action_dispatch_outbox.permission,
767
+ cron_read_action_dispatch_outbox.resource_scopes,
768
+ cron_read_action_dispatch_outbox.destination,
769
+ cron_read_action_dispatch_outbox.timeout_seconds,
770
+ cron_read_action_dispatch_outbox.expected_outcome_digest,
771
+ cron_read_action_dispatch_outbox.source_hash,
772
+ cron_read_action_dispatch_outbox.action_kind,
773
+ cron_read_action_dispatch_outbox.action_version,
774
+ cron_read_action_dispatch_outbox.action_contract_digest,
775
+ cron_read_action_dispatch_outbox.trusted_config_ref,
776
+ cron_read_action_dispatch_outbox.trusted_config_revision,
777
+ cron_read_action_dispatch_outbox.trusted_config_digest,
778
+ cron_read_action_dispatch_outbox.occurrence_claim_id,
779
+ cron_read_action_dispatch_outbox.status,
780
+ cron_read_action_dispatch_outbox.claim_id,
781
+ cron_read_action_dispatch_outbox.claimed_at,
782
+ cron_read_action_dispatch_outbox.claim_expires_at,
783
+ cron_read_action_dispatch_outbox.attempt_count,
784
+ cron_read_action_dispatch_outbox.next_attempt_at,
785
+ cron_read_action_dispatch_outbox.result_code,
786
+ cron_read_action_dispatch_outbox.resolved_at,
787
+ cron_read_action_dispatch_outbox.created_at
788
+ INTO bot_id, job_id, runtime_source, scheduled_for, idempotency_key,
789
+ capability_template_ref, template_updated_at, permission, resource_scopes,
790
+ destination, timeout_seconds, expected_outcome_digest, source_hash,
791
+ action_kind, action_version, action_contract_digest,
792
+ trusted_config_ref, trusted_config_revision, trusted_config_digest, occurrence_claim_id,
793
+ status, claim_id, claimed_at, claim_expires_at, attempt_count,
794
+ next_attempt_at, result_code, resolved_at, created_at;
795
+
796
+ UPDATE semicolony.bot_cron_jobs AS job
797
+ SET next_run = due.next_run AT TIME ZONE 'UTC'
798
+ WHERE job.bot_id = 'semi'
799
+ AND job.job_id = (item->>'jobId')
800
+ AND job.runtime_source = 'hive-cron-v2'
801
+ AND job.next_run AT TIME ZONE 'UTC' = input_scheduled_for;
802
+ GET DIAGNOSTICS changed = ROW_COUNT;
803
+ IF changed <> 1 THEN
804
+ RAISE EXCEPTION 'dispatch_batch_stale_or_locked' USING ERRCODE = '40001';
805
+ END IF;
806
+
807
+ RETURN NEXT;
808
+ END LOOP;
809
+ END;
810
+ $$;
811
+
812
+ CREATE FUNCTION semicolony.hive_cron_take_dispatch(
813
+ p_now timestamptz,
814
+ p_claim_id text,
815
+ p_lease_seconds integer
816
+ )
817
+ RETURNS TABLE (
818
+ bot_id text,
819
+ job_id text,
820
+ runtime_source text,
821
+ scheduled_for timestamptz,
822
+ idempotency_key text,
823
+ capability_template_ref text,
824
+ template_updated_at timestamptz,
825
+ permission text,
826
+ resource_scopes text[],
827
+ destination text,
828
+ timeout_seconds integer,
829
+ expected_outcome_digest text,
830
+ source_hash text,
831
+ action_kind text,
832
+ action_version integer,
833
+ action_contract_digest text,
834
+ trusted_config_ref text,
835
+ trusted_config_revision integer,
836
+ trusted_config_digest text,
837
+ occurrence_claim_id text,
838
+ status text,
839
+ claim_id text,
840
+ claimed_at timestamptz,
841
+ claim_expires_at timestamptz,
842
+ attempt_count integer,
843
+ next_attempt_at timestamptz,
844
+ result_code text,
845
+ resolved_at timestamptz,
846
+ created_at timestamptz
847
+ )
848
+ LANGUAGE plpgsql
849
+ SECURITY DEFINER
850
+ SET search_path = pg_catalog
851
+ SET TimeZone = 'UTC'
852
+ AS $$
853
+ DECLARE
854
+ db_now timestamptz;
855
+ BEGIN
856
+ IF session_user <> 'sc_provider'
857
+ OR public.is_provider() IS DISTINCT FROM true THEN
858
+ RAISE EXCEPTION 'provider_mode_required' USING ERRCODE = '42501';
859
+ END IF;
860
+ db_now := pg_catalog.clock_timestamp();
861
+ IF p_now IS NULL
862
+ OR p_now < db_now - interval '30 seconds'
863
+ OR p_now > db_now + interval '30 seconds'
864
+ OR p_claim_id IS NULL
865
+ OR (p_claim_id COLLATE "C") !~ '^[A-Za-z0-9][A-Za-z0-9._:-]{0,127}$'
866
+ OR p_lease_seconds IS NULL
867
+ OR p_lease_seconds < 1
868
+ OR p_lease_seconds > 3600 THEN
869
+ RAISE EXCEPTION 'invalid_dispatch_take_input' USING ERRCODE = '22023';
870
+ END IF;
871
+
872
+ RETURN QUERY
873
+ WITH candidate AS (
874
+ SELECT dispatch.idempotency_key
875
+ FROM semicolony.cron_read_action_dispatch_outbox AS dispatch
876
+ WHERE dispatch.status = 'pending'
877
+ AND dispatch.next_attempt_at <= db_now
878
+ AND dispatch.attempt_count < 3
879
+ ORDER BY dispatch.next_attempt_at, dispatch.scheduled_for, dispatch.idempotency_key
880
+ FOR UPDATE SKIP LOCKED
881
+ LIMIT 1
882
+ )
883
+ UPDATE semicolony.cron_read_action_dispatch_outbox AS dispatch
884
+ SET status = 'claimed',
885
+ claim_id = p_claim_id,
886
+ claimed_at = db_now,
887
+ claim_expires_at = db_now + pg_catalog.make_interval(secs => p_lease_seconds),
888
+ attempt_count = dispatch.attempt_count + 1,
889
+ next_attempt_at = NULL,
890
+ updated_at = db_now
891
+ FROM candidate
892
+ WHERE dispatch.idempotency_key = candidate.idempotency_key
893
+ RETURNING dispatch.bot_id,
894
+ dispatch.job_id,
895
+ dispatch.runtime_source,
896
+ dispatch.scheduled_for,
897
+ dispatch.idempotency_key,
898
+ dispatch.capability_template_ref,
899
+ dispatch.template_updated_at,
900
+ dispatch.permission,
901
+ dispatch.resource_scopes,
902
+ dispatch.destination,
903
+ dispatch.timeout_seconds,
904
+ dispatch.expected_outcome_digest,
905
+ dispatch.source_hash,
906
+ dispatch.action_kind,
907
+ dispatch.action_version,
908
+ dispatch.action_contract_digest,
909
+ dispatch.trusted_config_ref,
910
+ dispatch.trusted_config_revision,
911
+ dispatch.trusted_config_digest,
912
+ dispatch.occurrence_claim_id,
913
+ dispatch.status,
914
+ dispatch.claim_id,
915
+ dispatch.claimed_at,
916
+ dispatch.claim_expires_at,
917
+ dispatch.attempt_count,
918
+ dispatch.next_attempt_at,
919
+ dispatch.result_code,
920
+ dispatch.resolved_at,
921
+ dispatch.created_at;
922
+ END;
923
+ $$;
924
+
925
+ CREATE FUNCTION semicolony.hive_cron_list_abandoned_dispatches(
926
+ p_now timestamptz,
927
+ p_limit integer
928
+ )
929
+ RETURNS TABLE (
930
+ bot_id text,
931
+ job_id text,
932
+ runtime_source text,
933
+ scheduled_for timestamptz,
934
+ idempotency_key text,
935
+ capability_template_ref text,
936
+ template_updated_at timestamptz,
937
+ permission text,
938
+ resource_scopes text[],
939
+ destination text,
940
+ timeout_seconds integer,
941
+ expected_outcome_digest text,
942
+ source_hash text,
943
+ action_kind text,
944
+ action_version integer,
945
+ action_contract_digest text,
946
+ trusted_config_ref text,
947
+ trusted_config_revision integer,
948
+ trusted_config_digest text,
949
+ occurrence_claim_id text,
950
+ status text,
951
+ claim_id text,
952
+ claimed_at timestamptz,
953
+ claim_expires_at timestamptz,
954
+ attempt_count integer,
955
+ next_attempt_at timestamptz,
956
+ result_code text,
957
+ resolved_at timestamptz,
958
+ created_at timestamptz
959
+ )
960
+ LANGUAGE plpgsql
961
+ SECURITY DEFINER
962
+ SET search_path = pg_catalog
963
+ SET TimeZone = 'UTC'
964
+ AS $$
965
+ DECLARE
966
+ db_now timestamptz;
967
+ BEGIN
968
+ IF session_user <> 'sc_provider'
969
+ OR public.is_provider() IS DISTINCT FROM true THEN
970
+ RAISE EXCEPTION 'provider_mode_required' USING ERRCODE = '42501';
971
+ END IF;
972
+ db_now := pg_catalog.clock_timestamp();
973
+ IF p_now IS NULL
974
+ OR p_now < db_now - interval '30 seconds'
975
+ OR p_now > db_now + interval '30 seconds'
976
+ OR p_limit IS NULL OR p_limit < 1 OR p_limit > 20 THEN
977
+ RAISE EXCEPTION 'invalid_dispatch_list_input' USING ERRCODE = '22023';
978
+ END IF;
979
+
980
+ RETURN QUERY
981
+ SELECT dispatch.bot_id,
982
+ dispatch.job_id,
983
+ dispatch.runtime_source,
984
+ dispatch.scheduled_for,
985
+ dispatch.idempotency_key,
986
+ dispatch.capability_template_ref,
987
+ dispatch.template_updated_at,
988
+ dispatch.permission,
989
+ dispatch.resource_scopes,
990
+ dispatch.destination,
991
+ dispatch.timeout_seconds,
992
+ dispatch.expected_outcome_digest,
993
+ dispatch.source_hash,
994
+ dispatch.action_kind,
995
+ dispatch.action_version,
996
+ dispatch.action_contract_digest,
997
+ dispatch.trusted_config_ref,
998
+ dispatch.trusted_config_revision,
999
+ dispatch.trusted_config_digest,
1000
+ dispatch.occurrence_claim_id,
1001
+ dispatch.status,
1002
+ dispatch.claim_id,
1003
+ dispatch.claimed_at,
1004
+ dispatch.claim_expires_at,
1005
+ dispatch.attempt_count,
1006
+ dispatch.next_attempt_at,
1007
+ dispatch.result_code,
1008
+ dispatch.resolved_at,
1009
+ dispatch.created_at
1010
+ FROM semicolony.cron_read_action_dispatch_outbox AS dispatch
1011
+ WHERE dispatch.status = 'claimed'
1012
+ AND dispatch.claim_expires_at <= db_now
1013
+ ORDER BY dispatch.claim_expires_at, dispatch.idempotency_key
1014
+ LIMIT p_limit;
1015
+ END;
1016
+ $$;
1017
+
1018
+ CREATE FUNCTION semicolony.hive_cron_resolve_dispatch(
1019
+ p_job_id text,
1020
+ p_idempotency_key text,
1021
+ p_claim_id text,
1022
+ p_observed_at timestamptz
1023
+ )
1024
+ RETURNS TABLE (outcome text)
1025
+ LANGUAGE plpgsql
1026
+ SECURITY DEFINER
1027
+ SET search_path = pg_catalog
1028
+ SET TimeZone = 'UTC'
1029
+ AS $$
1030
+ DECLARE
1031
+ dispatch record;
1032
+ reconciliation record;
1033
+ db_now timestamptz;
1034
+ BEGIN
1035
+ IF session_user <> 'sc_provider'
1036
+ OR public.is_provider() IS DISTINCT FROM true THEN
1037
+ RAISE EXCEPTION 'provider_mode_required' USING ERRCODE = '42501';
1038
+ END IF;
1039
+ db_now := pg_catalog.clock_timestamp();
1040
+ IF p_job_id IS NULL
1041
+ OR (p_job_id COLLATE "C") !~ '^[A-Za-z0-9][A-Za-z0-9._:-]{0,127}$'
1042
+ OR p_idempotency_key IS NULL
1043
+ OR p_idempotency_key <> 'cron:' || p_job_id || ':' || pg_catalog.right(p_idempotency_key, 24)
1044
+ OR pg_catalog.right(p_idempotency_key, 24) !~
1045
+ '^[0-9]{4}-[0-9]{2}-[0-9]{2}T[0-9]{2}:[0-9]{2}:[0-9]{2}\.[0-9]{3}Z$'
1046
+ OR p_claim_id IS NULL
1047
+ OR (p_claim_id COLLATE "C") !~ '^[A-Za-z0-9][A-Za-z0-9._:-]{0,127}$'
1048
+ OR p_observed_at IS NULL
1049
+ OR p_observed_at < db_now - interval '30 seconds'
1050
+ OR p_observed_at > db_now + interval '30 seconds' THEN
1051
+ RAISE EXCEPTION 'invalid_dispatch_resolution' USING ERRCODE = '22023';
1052
+ END IF;
1053
+
1054
+ SELECT current_dispatch.status,
1055
+ current_dispatch.claim_id,
1056
+ current_dispatch.claimed_at,
1057
+ current_dispatch.claim_expires_at,
1058
+ current_dispatch.attempt_count,
1059
+ current_dispatch.occurrence_claim_id
1060
+ INTO dispatch
1061
+ FROM semicolony.cron_read_action_dispatch_outbox AS current_dispatch
1062
+ WHERE current_dispatch.job_id = p_job_id
1063
+ AND current_dispatch.idempotency_key = p_idempotency_key
1064
+ FOR UPDATE;
1065
+ IF NOT FOUND THEN
1066
+ outcome := 'not_found';
1067
+ RETURN NEXT;
1068
+ RETURN;
1069
+ END IF;
1070
+ IF dispatch.status <> 'claimed' OR dispatch.claim_id <> p_claim_id THEN
1071
+ outcome := 'claim_mismatch';
1072
+ RETURN NEXT;
1073
+ RETURN;
1074
+ END IF;
1075
+ SELECT *
1076
+ INTO reconciliation
1077
+ FROM semicolony.hive_cron_reconcile(p_job_id, p_idempotency_key);
1078
+
1079
+ IF NOT FOUND THEN
1080
+ UPDATE semicolony.cron_read_action_dispatch_outbox AS current_dispatch
1081
+ SET status = 'blocked',
1082
+ claim_id = NULL,
1083
+ claimed_at = NULL,
1084
+ claim_expires_at = NULL,
1085
+ result_code = 'ambiguous_authority_state',
1086
+ resolved_at = db_now,
1087
+ updated_at = db_now
1088
+ WHERE current_dispatch.idempotency_key = p_idempotency_key;
1089
+ outcome := 'blocked';
1090
+ RETURN NEXT;
1091
+ RETURN;
1092
+ END IF;
1093
+
1094
+ IF reconciliation.lease_count = 0
1095
+ AND reconciliation.outbox_count = 0
1096
+ AND reconciliation.evidence_count = 0
1097
+ AND reconciliation.event_count = 0
1098
+ AND dispatch.claim_expires_at > db_now THEN
1099
+ outcome := 'claim_active';
1100
+ ELSIF reconciliation.lease_count = 0
1101
+ AND reconciliation.outbox_count = 0
1102
+ AND reconciliation.evidence_count = 0
1103
+ AND reconciliation.event_count = 0
1104
+ AND dispatch.attempt_count < 3 THEN
1105
+ UPDATE semicolony.cron_read_action_dispatch_outbox AS current_dispatch
1106
+ SET status = 'pending',
1107
+ claim_id = NULL,
1108
+ claimed_at = NULL,
1109
+ claim_expires_at = NULL,
1110
+ next_attempt_at = db_now,
1111
+ updated_at = db_now
1112
+ WHERE current_dispatch.idempotency_key = p_idempotency_key;
1113
+ outcome := 'safe_to_retry_pre_effect';
1114
+ ELSIF reconciliation.lease_count = 0
1115
+ AND reconciliation.outbox_count = 0
1116
+ AND reconciliation.evidence_count = 0
1117
+ AND reconciliation.event_count = 0 THEN
1118
+ UPDATE semicolony.cron_read_action_dispatch_outbox AS current_dispatch
1119
+ SET status = 'failed',
1120
+ claim_id = NULL,
1121
+ claimed_at = NULL,
1122
+ claim_expires_at = NULL,
1123
+ result_code = 'retry_exhausted',
1124
+ resolved_at = db_now,
1125
+ updated_at = db_now
1126
+ WHERE current_dispatch.idempotency_key = p_idempotency_key;
1127
+ outcome := 'failed';
1128
+ ELSIF reconciliation.lease_count = 1
1129
+ AND reconciliation.lease_claim_id = dispatch.occurrence_claim_id
1130
+ AND reconciliation.lease_state = 'completed'
1131
+ AND reconciliation.lease_completed_at IS NOT NULL
1132
+ AND reconciliation.evidence_count = 1
1133
+ AND reconciliation.evidence_claim_id = dispatch.occurrence_claim_id
1134
+ AND reconciliation.evidence_stage = 'scheduled_primary'
1135
+ AND reconciliation.evidence_run_status = 'success'
1136
+ AND reconciliation.evidence_completed_at = reconciliation.lease_completed_at
1137
+ AND reconciliation.event_count = 1
1138
+ AND reconciliation.event_commitment_id = reconciliation.evidence_commitment_id
1139
+ AND reconciliation.event_type = 'cron_run_recorded'
1140
+ AND reconciliation.event_run_status = reconciliation.evidence_run_status
1141
+ AND reconciliation.event_output_digest = reconciliation.evidence_output_digest
1142
+ AND reconciliation.event_occurred_at = reconciliation.evidence_completed_at
1143
+ AND (
1144
+ (
1145
+ reconciliation.evidence_relay_required = 'true'::jsonb
1146
+ AND reconciliation.evidence_relay_outcome = 'delivered'
1147
+ AND reconciliation.evidence_relay_request_digest = reconciliation.outbox_request_digest
1148
+ AND reconciliation.outbox_count = 1
1149
+ AND reconciliation.outbox_state = 'completed'
1150
+ AND reconciliation.outbox_outcome_status = 'delivered'
1151
+ AND reconciliation.outbox_error_code IS NULL
1152
+ AND reconciliation.outbox_completed_at IS NOT NULL
1153
+ )
1154
+ OR
1155
+ (
1156
+ reconciliation.evidence_relay_required = 'false'::jsonb
1157
+ AND reconciliation.evidence_relay_outcome = 'suppressed'
1158
+ AND reconciliation.evidence_relay_request_digest IS NULL
1159
+ AND reconciliation.outbox_count = 0
1160
+ )
1161
+ ) THEN
1162
+ UPDATE semicolony.cron_read_action_dispatch_outbox AS current_dispatch
1163
+ SET status = 'completed',
1164
+ claim_id = NULL,
1165
+ claimed_at = NULL,
1166
+ claim_expires_at = NULL,
1167
+ result_code = 'completed',
1168
+ resolved_at = db_now,
1169
+ updated_at = db_now
1170
+ WHERE current_dispatch.idempotency_key = p_idempotency_key;
1171
+ outcome := 'completed';
1172
+ ELSIF reconciliation.lease_count = 1
1173
+ AND reconciliation.lease_claim_id = dispatch.occurrence_claim_id
1174
+ AND reconciliation.lease_state = 'released_failed'
1175
+ AND reconciliation.lease_completed_at IS NOT NULL
1176
+ AND reconciliation.outbox_count = 0
1177
+ AND reconciliation.evidence_count = 0
1178
+ AND reconciliation.event_count = 0 THEN
1179
+ UPDATE semicolony.cron_read_action_dispatch_outbox AS current_dispatch
1180
+ SET status = 'failed',
1181
+ claim_id = NULL,
1182
+ claimed_at = NULL,
1183
+ claim_expires_at = NULL,
1184
+ result_code = 'released_failed',
1185
+ resolved_at = db_now,
1186
+ updated_at = db_now
1187
+ WHERE current_dispatch.idempotency_key = p_idempotency_key;
1188
+ outcome := 'failed';
1189
+ ELSE
1190
+ UPDATE semicolony.cron_read_action_dispatch_outbox AS current_dispatch
1191
+ SET status = 'blocked',
1192
+ claim_id = NULL,
1193
+ claimed_at = NULL,
1194
+ claim_expires_at = NULL,
1195
+ result_code = 'ambiguous_authority_state',
1196
+ resolved_at = db_now,
1197
+ updated_at = db_now
1198
+ WHERE current_dispatch.idempotency_key = p_idempotency_key;
1199
+ outcome := 'blocked';
1200
+ END IF;
1201
+ RETURN NEXT;
1202
+ END;
1203
+ $$;
1204
+
1205
+ CREATE FUNCTION semicolony.hive_cron_mark_dispatch_skipped_stale(
1206
+ p_job_id text,
1207
+ p_idempotency_key text,
1208
+ p_claim_id text,
1209
+ p_observed_at timestamptz
1210
+ )
1211
+ RETURNS TABLE (outcome text)
1212
+ LANGUAGE plpgsql
1213
+ SECURITY DEFINER
1214
+ SET search_path = pg_catalog
1215
+ SET TimeZone = 'UTC'
1216
+ AS $$
1217
+ DECLARE
1218
+ dispatch record;
1219
+ current_binding record;
1220
+ db_now timestamptz;
1221
+ BEGIN
1222
+ IF session_user <> 'sc_provider'
1223
+ OR public.is_provider() IS DISTINCT FROM true THEN
1224
+ RAISE EXCEPTION 'provider_mode_required' USING ERRCODE = '42501';
1225
+ END IF;
1226
+ db_now := pg_catalog.clock_timestamp();
1227
+ IF p_job_id IS NULL
1228
+ OR (p_job_id COLLATE "C") !~ '^[A-Za-z0-9][A-Za-z0-9._:-]{0,127}$'
1229
+ OR p_idempotency_key IS NULL
1230
+ OR p_idempotency_key <> 'cron:' || p_job_id || ':' || pg_catalog.right(p_idempotency_key, 24)
1231
+ OR pg_catalog.right(p_idempotency_key, 24) !~
1232
+ '^[0-9]{4}-[0-9]{2}-[0-9]{2}T[0-9]{2}:[0-9]{2}:[0-9]{2}\.[0-9]{3}Z$'
1233
+ OR p_claim_id IS NULL
1234
+ OR (p_claim_id COLLATE "C") !~ '^[A-Za-z0-9][A-Za-z0-9._:-]{0,127}$'
1235
+ OR p_observed_at IS NULL
1236
+ OR p_observed_at < db_now - interval '30 seconds'
1237
+ OR p_observed_at > db_now + interval '30 seconds' THEN
1238
+ RAISE EXCEPTION 'invalid_dispatch_stale_resolution' USING ERRCODE = '22023';
1239
+ END IF;
1240
+
1241
+ SELECT current_dispatch.job_id,
1242
+ current_dispatch.status,
1243
+ current_dispatch.claim_id,
1244
+ current_dispatch.claimed_at,
1245
+ current_dispatch.capability_template_ref,
1246
+ current_dispatch.template_updated_at,
1247
+ current_dispatch.permission,
1248
+ current_dispatch.resource_scopes,
1249
+ current_dispatch.destination,
1250
+ current_dispatch.timeout_seconds,
1251
+ current_dispatch.expected_outcome_digest,
1252
+ current_dispatch.source_hash,
1253
+ current_dispatch.action_kind,
1254
+ current_dispatch.action_version,
1255
+ current_dispatch.action_contract_digest,
1256
+ current_dispatch.trusted_config_ref,
1257
+ current_dispatch.trusted_config_revision,
1258
+ current_dispatch.trusted_config_digest
1259
+ INTO dispatch
1260
+ FROM semicolony.cron_read_action_dispatch_outbox AS current_dispatch
1261
+ WHERE current_dispatch.job_id = p_job_id
1262
+ AND current_dispatch.idempotency_key = p_idempotency_key
1263
+ FOR UPDATE;
1264
+ IF NOT FOUND THEN
1265
+ outcome := 'not_found';
1266
+ RETURN NEXT;
1267
+ RETURN;
1268
+ END IF;
1269
+ IF dispatch.status <> 'claimed' OR dispatch.claim_id <> p_claim_id THEN
1270
+ outcome := 'claim_mismatch';
1271
+ RETURN NEXT;
1272
+ RETURN;
1273
+ END IF;
1274
+ SELECT job.enabled,
1275
+ job.runtime_source,
1276
+ template.updated_at AS template_updated_at,
1277
+ template.permission,
1278
+ template.resource_scopes AS raw_resource_scopes,
1279
+ canonical.resource_scopes,
1280
+ template.destination,
1281
+ template.timeout_seconds,
1282
+ template.expected_outcome_digest,
1283
+ template.source_hash,
1284
+ template.action_kind,
1285
+ template.action_version,
1286
+ template.action_contract_digest,
1287
+ config.config_revision,
1288
+ config.config_digest,
1289
+ config.action_kind AS config_action_kind,
1290
+ config.action_version AS config_action_version,
1291
+ config.action_contract_digest AS config_action_contract_digest
1292
+ INTO current_binding
1293
+ FROM semicolony.bot_cron_jobs AS job
1294
+ LEFT JOIN semicolony.cron_capability_templates AS template
1295
+ ON template.bot_id = job.bot_id
1296
+ AND template.job_id = job.job_id
1297
+ AND template.runtime_source = 'hive-cron-v2'
1298
+ LEFT JOIN LATERAL (
1299
+ SELECT pg_catalog.array_agg(scope ORDER BY scope COLLATE "C") AS resource_scopes
1300
+ FROM (
1301
+ SELECT DISTINCT scope
1302
+ FROM pg_catalog.unnest(template.resource_scopes) AS scope
1303
+ ) AS unique_scope
1304
+ ) AS canonical ON true
1305
+ LEFT JOIN LATERAL (
1306
+ SELECT revision.config_revision,
1307
+ revision.config_digest,
1308
+ revision.action_kind,
1309
+ revision.action_version,
1310
+ revision.action_contract_digest
1311
+ FROM semicolony.cron_read_action_trusted_config_revisions AS revision
1312
+ WHERE revision.bot_id = 'semi'
1313
+ AND revision.job_id = job.job_id
1314
+ AND revision.runtime_source = 'hive-cron-v2'
1315
+ ORDER BY revision.config_revision DESC
1316
+ LIMIT 1
1317
+ ) AS config ON true
1318
+ WHERE job.bot_id = 'semi'
1319
+ AND job.job_id = p_job_id;
1320
+
1321
+ IF FOUND
1322
+ AND current_binding.enabled = true
1323
+ AND current_binding.runtime_source = 'hive-cron-v2'
1324
+ AND current_binding.action_kind IS NOT NULL
1325
+ AND current_binding.action_version IS NOT NULL
1326
+ AND current_binding.action_contract_digest IS NOT NULL
1327
+ AND pg_catalog.cardinality(current_binding.raw_resource_scopes) BETWEEN 1 AND 32
1328
+ AND pg_catalog.array_position(current_binding.raw_resource_scopes, NULL) IS NULL
1329
+ AND pg_catalog.cardinality(current_binding.raw_resource_scopes) =
1330
+ pg_catalog.cardinality(current_binding.resource_scopes)
1331
+ AND NOT EXISTS (
1332
+ SELECT 1
1333
+ FROM pg_catalog.unnest(current_binding.raw_resource_scopes) AS scope
1334
+ WHERE (scope COLLATE "C") !~ '^[!-~]+$'
1335
+ OR pg_catalog.char_length(scope) NOT BETWEEN 1 AND 256
1336
+ OR pg_catalog.strpos(scope, '*') > 0
1337
+ )
1338
+ AND current_binding.config_revision IS NOT NULL
1339
+ AND current_binding.config_action_kind = current_binding.action_kind
1340
+ AND current_binding.config_action_version = current_binding.action_version
1341
+ AND current_binding.config_action_contract_digest = current_binding.action_contract_digest
1342
+ AND dispatch.capability_template_ref = 'semi:' || p_job_id || ':hive-cron-v2'
1343
+ AND dispatch.template_updated_at = current_binding.template_updated_at
1344
+ AND dispatch.permission = current_binding.permission
1345
+ AND dispatch.resource_scopes = current_binding.resource_scopes
1346
+ AND dispatch.destination = current_binding.destination
1347
+ AND dispatch.timeout_seconds = current_binding.timeout_seconds
1348
+ AND dispatch.expected_outcome_digest = current_binding.expected_outcome_digest
1349
+ AND dispatch.source_hash = current_binding.source_hash
1350
+ AND dispatch.action_kind = current_binding.action_kind
1351
+ AND dispatch.action_version = current_binding.action_version
1352
+ AND dispatch.action_contract_digest = current_binding.action_contract_digest
1353
+ AND dispatch.trusted_config_ref =
1354
+ 'semi:' || p_job_id || ':hive-cron-v2:config:r' || current_binding.config_revision::text
1355
+ AND dispatch.trusted_config_revision = current_binding.config_revision
1356
+ AND dispatch.trusted_config_digest = current_binding.config_digest THEN
1357
+ outcome := 'not_stale';
1358
+ RETURN NEXT;
1359
+ RETURN;
1360
+ END IF;
1361
+
1362
+ UPDATE semicolony.cron_read_action_dispatch_outbox AS current_dispatch
1363
+ SET status = 'failed',
1364
+ claim_id = NULL,
1365
+ claimed_at = NULL,
1366
+ claim_expires_at = NULL,
1367
+ result_code = 'stale_binding',
1368
+ resolved_at = db_now,
1369
+ updated_at = db_now
1370
+ WHERE current_dispatch.idempotency_key = p_idempotency_key;
1371
+ outcome := 'failed';
1372
+ RETURN NEXT;
1373
+ END;
1374
+ $$;
1375
+
1376
+ REVOKE ALL PRIVILEGES ON TABLE
1377
+ semicolony.cron_read_action_dispatch_outbox
1378
+ FROM PUBLIC, sc_app, sc_provider;
1379
+
1380
+ REVOKE ALL ON FUNCTION semicolony.hive_cron_protect_terminal_dispatch()
1381
+ FROM PUBLIC, sc_app, sc_provider;
1382
+ REVOKE ALL ON FUNCTION semicolony.hive_cron_preview_due_dispatches(timestamptz, integer)
1383
+ FROM PUBLIC, sc_app, sc_provider;
1384
+ REVOKE ALL ON FUNCTION semicolony.hive_cron_claim_dispatch_batch(timestamptz, jsonb)
1385
+ FROM PUBLIC, sc_app, sc_provider;
1386
+ REVOKE ALL ON FUNCTION semicolony.hive_cron_take_dispatch(timestamptz, text, integer)
1387
+ FROM PUBLIC, sc_app, sc_provider;
1388
+ REVOKE ALL ON FUNCTION semicolony.hive_cron_list_abandoned_dispatches(timestamptz, integer)
1389
+ FROM PUBLIC, sc_app, sc_provider;
1390
+ REVOKE ALL ON FUNCTION semicolony.hive_cron_resolve_dispatch(text, text, text, timestamptz)
1391
+ FROM PUBLIC, sc_app, sc_provider;
1392
+ REVOKE ALL ON FUNCTION semicolony.hive_cron_mark_dispatch_skipped_stale(text, text, text, timestamptz)
1393
+ FROM PUBLIC, sc_app, sc_provider;
1394
+
1395
+ GRANT EXECUTE ON FUNCTION semicolony.hive_cron_preview_due_dispatches(timestamptz, integer)
1396
+ TO sc_provider;
1397
+ GRANT EXECUTE ON FUNCTION semicolony.hive_cron_claim_dispatch_batch(timestamptz, jsonb)
1398
+ TO sc_provider;
1399
+ GRANT EXECUTE ON FUNCTION semicolony.hive_cron_take_dispatch(timestamptz, text, integer)
1400
+ TO sc_provider;
1401
+ GRANT EXECUTE ON FUNCTION semicolony.hive_cron_list_abandoned_dispatches(timestamptz, integer)
1402
+ TO sc_provider;
1403
+ GRANT EXECUTE ON FUNCTION semicolony.hive_cron_resolve_dispatch(text, text, text, timestamptz)
1404
+ TO sc_provider;
1405
+ GRANT EXECUTE ON FUNCTION semicolony.hive_cron_mark_dispatch_skipped_stale(text, text, text, timestamptz)
1406
+ TO sc_provider;