spine-framework 0.4.2 → 0.4.4
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.
- package/.framework/functions/_shared/agent-runner.ts +1000 -81
- package/.framework/functions/_shared/cil.ts +2 -1
- package/.framework/functions/_shared/db.ts +3 -6
- package/.framework/functions/_shared/kb.ts +61 -7
- package/.framework/functions/_shared/message-sequence.ts +53 -0
- package/.framework/functions/_shared/pipeline-runner.ts +150 -37
- package/.framework/functions/_shared/principal.ts +9 -6
- package/.framework/functions/_shared/trigger-engine.ts +149 -40
- package/.framework/functions/admin-data.ts +9 -3
- package/.framework/functions/ai-agents.ts +93 -27
- package/.framework/functions/pipeline-runner-background.ts +120 -0
- package/.framework/functions/system-cron.ts +10 -1
- package/.framework/functions/trigger-scheduler.ts +65 -5
- package/.framework/migrations/064_episode_leases.sql +196 -0
- package/.framework/migrations/065_execution_tables_updated_at.sql +20 -0
- package/.framework/migrations/066_episode_steal_thread_merge.sql +148 -0
- package/.framework/migrations/067_episode_lease_stale_steal.sql +93 -0
- package/.framework/migrations/068_cron_schedule_timer_rpcs.sql +251 -0
- package/.framework/schemas/spine.config.schema.json +62 -0
- package/.framework/src/App.tsx +3 -1
- package/.framework/src/components/admin/AdminSidebar.tsx +4 -2
- package/.framework/src/components/app-shell/GenericAppShell.tsx +3 -1
- package/.framework/src/components/auth/TurnstileWidget.tsx +9 -6
- package/.framework/src/components/kb/KBArticleEditor.tsx +49 -6
- package/.framework/src/components/layout/Layout.tsx +4 -2
- package/.framework/src/components/layout/Sidebar.tsx +4 -2
- package/.framework/src/hooks/useBranding.ts +47 -0
- package/.framework/src/lib/branding.ts +128 -0
- package/.framework/src/pages/NoAppPage.tsx +3 -1
- package/.framework/src/pages/SetupPage.tsx +5 -2
- package/.framework/src/pages/auth/AuthConfirmPage.tsx +3 -1
- package/.framework/src/pages/auth/ForgotPasswordPage.tsx +3 -1
- package/.framework/src/pages/auth/LoginPage.tsx +4 -2
- package/.framework/src/pages/auth/RegisterPage.tsx +3 -1
- package/.framework/templates/project.json +1 -0
- package/.framework/tests/unit/agent-runner.test.ts +17 -3
- package/.framework/tests/unit/episode-lease.test.ts +695 -0
- package/.framework/tests/unit/kb-ingest-lease.test.ts +19 -0
- package/.framework/tests/unit/pipeline-runner-background.test.ts +120 -0
- package/.framework/tests/unit/pipeline-runner.test.ts +153 -1
- package/.framework/tests/unit/principal-cache.test.ts +15 -0
- package/.framework/tests/unit/trigger-engine.test.ts +278 -91
- package/.framework/tests/unit/trigger-scheduler.test.ts +78 -0
- package/netlify.toml +5 -0
- package/package.json +1 -1
- package/templates/project/package-lock.json +2 -2
|
@@ -1,10 +1,70 @@
|
|
|
1
1
|
// CIL[BEGIN stable_id="~/.framework/functions/trigger-scheduler.ts:module_header:module_header" type="module_header" identity="module_header" parent="~/.framework/functions/trigger-scheduler.ts"]
|
|
2
2
|
// CIL[DOC-MACRO purpose="Scheduled entry point that invokes the shared system-cron runner so Netlify's every-minute trigger dispatches due scheduled actions."]
|
|
3
|
-
// CIL[DOC-RESPONSIBILITY responsibility="
|
|
4
|
-
// CIL[DOC-CONTEXT module="trigger-scheduler" layer="functions" related_files=".framework/functions/system-cron.ts, netlify.toml"]
|
|
5
|
-
// CIL[DOC-CONTRACT inputs="Netlify scheduled invocation" outputs="system-cron sweep result" errors="
|
|
6
|
-
// CIL[DOC-MICRO logic_summary="
|
|
3
|
+
// CIL[DOC-RESPONSIBILITY responsibility="Detect Netlify Clockwork scheduled invocations (which carry no credentials) and run system-cron under the system principal; forward everything else to system-cron's normal middleware auth."]
|
|
4
|
+
// CIL[DOC-CONTEXT module="trigger-scheduler" layer="functions" related_files=".framework/functions/system-cron.ts, .framework/functions/_shared/principal.ts, .framework/functions/_shared/db.ts, netlify.toml"]
|
|
5
|
+
// CIL[DOC-CONTRACT inputs="Netlify scheduled invocation (x-nf-event/x-netlify-event: schedule OR Netlify Clockwork UA, + {\"next_run\"} body) or authenticated HTTP request" outputs="system-cron sweep result" errors="401 for unauthenticated HTTP requests" side_effects="runs due schedule actions across accounts"]
|
|
6
|
+
// CIL[DOC-MICRO logic_summary="Netlify's scheduler invokes this function directly. The header name differs between environments: CLI emulation sends X-NF-Event; production sends x-netlify-event (canonical constant in @netlify/serverless-functions-api). Both are accepted, as is the Netlify Clockwork user-agent, so detection is robust across all invocation paths. The body must contain next_run as a string for both headers. For that path a RequestContext is fabricated with SYSTEM_PRINCIPAL and passed straight into system-cron's handler. Non-scheduled requests fall through to system-cron's createHandler." notable_decisions="x-netlify-event is the production canonical header (NFEvent in @netlify/serverless-functions-api); x-nf-event is the CLI emulation header. Both must be accepted or every scheduled tick 401s in production. Netlify Clockwork UA is a third indicator used only when neither header is present."]
|
|
7
7
|
// CIL[DOC-STABILITY stable]
|
|
8
8
|
// CIL[DOC-AUDIENCE audience="framework maintainers"]
|
|
9
|
-
|
|
9
|
+
import { handler as cronHandler } from './system-cron'
|
|
10
|
+
import { SYSTEM_PRINCIPAL } from './_shared/principal'
|
|
11
|
+
import { adminDb } from './_shared/db'
|
|
12
|
+
|
|
13
|
+
// Netlify Clockwork invocation detection. Require BOTH a recognised indicator
|
|
14
|
+
// AND the {"next_run": "..."} body shape so a bare spoofed header or UA alone
|
|
15
|
+
// cannot authenticate.
|
|
16
|
+
//
|
|
17
|
+
// Header variants (both must be handled):
|
|
18
|
+
// - x-nf-event: schedule — Netlify CLI emulation (ntl dev / functions:invoke)
|
|
19
|
+
// - x-netlify-event: schedule — production canonical header
|
|
20
|
+
// (@netlify/serverless-functions-api NFEvent = "x-netlify-event")
|
|
21
|
+
//
|
|
22
|
+
// Fallback: user-agent "Netlify Clockwork" is accepted when neither header is
|
|
23
|
+
// present, to guard against future header renames.
|
|
24
|
+
function isNetlifyScheduledInvocation(event: any): boolean {
|
|
25
|
+
const headers = event.headers || {}
|
|
26
|
+
|
|
27
|
+
const nfEvent =
|
|
28
|
+
headers['x-netlify-event'] || headers['X-Netlify-Event'] ||
|
|
29
|
+
headers['x-nf-event'] || headers['X-NF-Event']
|
|
30
|
+
|
|
31
|
+
const ua = headers['user-agent'] || headers['User-Agent'] || ''
|
|
32
|
+
|
|
33
|
+
const hasScheduleIndicator =
|
|
34
|
+
nfEvent === 'schedule' || ua.includes('Netlify Clockwork')
|
|
35
|
+
|
|
36
|
+
if (!hasScheduleIndicator) return false
|
|
37
|
+
|
|
38
|
+
try {
|
|
39
|
+
const body = typeof event.body === 'string' ? JSON.parse(event.body) : event.body
|
|
40
|
+
return typeof body?.next_run === 'string'
|
|
41
|
+
} catch {
|
|
42
|
+
return false
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
export const handler = async (event: any, context: any) => {
|
|
47
|
+
if (!isNetlifyScheduledInvocation(event)) {
|
|
48
|
+
// Not a Clockwork invocation — run the normal middleware auth path.
|
|
49
|
+
// x-internal-secret resolves to SYSTEM_PRINCIPAL; ?api_key= is checked
|
|
50
|
+
// inside the cron handler; anything else 401s.
|
|
51
|
+
return cronHandler(event, context)
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
// Fabricate a RequestContext carrying the system principal. createHandler
|
|
55
|
+
// short-circuits when event.requestId + event.principal are present, so
|
|
56
|
+
// this flows straight into the cron handler without re-resolving auth.
|
|
57
|
+
const ctx = {
|
|
58
|
+
requestId: crypto.randomUUID(),
|
|
59
|
+
principal: SYSTEM_PRINCIPAL,
|
|
60
|
+
db: adminDb,
|
|
61
|
+
accountId: null,
|
|
62
|
+
appId: null,
|
|
63
|
+
query: {},
|
|
64
|
+
requestPath: event.path || '/.netlify/functions/trigger-scheduler',
|
|
65
|
+
headers: {},
|
|
66
|
+
source: 'netlify-scheduler',
|
|
67
|
+
}
|
|
68
|
+
return cronHandler(ctx, context)
|
|
69
|
+
}
|
|
10
70
|
// CIL[END stable_id="~/.framework/functions/trigger-scheduler.ts:module_header:module_header"]
|
|
@@ -0,0 +1,196 @@
|
|
|
1
|
+
-- CIL[BEGIN stable_id="~/.framework/migrations/064_episode_leases.sql:episode_leases:file" type="file" identity="episode_leases" parent="~/.framework/migrations/064_episode_leases.sql"]
|
|
2
|
+
-- CIL[DOC-MACRO purpose="Atomic single-flight lease for agent episodes: episode_leases table plus service-role-only acquire/heartbeat/release RPCs. Replaces the check-then-write driver_id claim in agent-runner so concurrent kicks can never both drive a thread. Also adds record_trigger_fire, the atomic trigger bookkeeping RPC that replaces trigger-engine's rpc-Promise-in-UPDATE bug."]
|
|
3
|
+
-- CIL[DOC-RESPONSIBILITY responsibility="Create public.episode_leases (one row per in-flight episode keyed by thread_id), provide acquire_episode_lease/heartbeat_episode_lease/release_episode_lease as the single atomic writers, and fence writes by lease_token."]
|
|
4
|
+
-- CIL[DOC-CONTEXT module="migrations" layer="database" related_files=".framework/functions/_shared/agent-runner.ts,.framework/functions/ai-agents.ts,.framework/migrations/054_kb_ingest_lease.sql"]
|
|
5
|
+
-- CIL[DOC-CONTRACT inputs="acquire: p_thread_id/p_account_id/p_driver_id/p_ttl_seconds; heartbeat: p_thread_id/p_lease_token/p_ttl_seconds; release: p_thread_id/p_lease_token" outputs="acquire jsonb {outcome: claimed|held, lease_token?, holder_driver_id?}; heartbeat boolean; release boolean" errors="none — all branches return" side_effects="Upserts/updates/deletes episode_leases; agent-runner uses the lease to serialize drivers and fence takeover writes"]
|
|
6
|
+
-- CIL[DOC-MICRO logic_summary="acquire_episode_lease INSERTs ON CONFLICT DO NOTHING; on conflict it SELECTs FOR UPDATE and renews when the caller is the same driver or the lease expired (takeover, fresh token), else returns 'held' with the live holder's driver_id. heartbeat/release are token-gated — a displaced driver's heartbeat fails, which is what fences its pending writes." notable_decisions="Lease is keyed by thread (not episode) because a thread can only ever have one live episode; driver_id is text to carry client-generated session tokens; same-driver re-acquire renews so step-mode drivers persist across HTTP requests; RLS grants service_role FOR ALL and EXECUTE goes to service_role only, matching 049/052/054's service-only convention"]
|
|
7
|
+
-- CIL[DOC-CONFIDENCE pass="first" score="0.9" flags="none"]
|
|
8
|
+
-- CIL[DOC-STABILITY stability="stable"]
|
|
9
|
+
|
|
10
|
+
-- ── episode_leases ─────────────────────────────────────────────────
|
|
11
|
+
-- One row per in-flight agent episode (threads.id). Entry paths
|
|
12
|
+
-- (ticket-creation kicks, episode-mode POSTs, step/complete POSTs)
|
|
13
|
+
-- acquire before running; the holder heartbeats per turn and releases
|
|
14
|
+
-- on terminal state. A live lease refuses competing drivers atomically —
|
|
15
|
+
-- this replaces the read-then-write research_state claim that allowed
|
|
16
|
+
-- two drivers to race within the same window.
|
|
17
|
+
CREATE TABLE IF NOT EXISTS public.episode_leases (
|
|
18
|
+
thread_id uuid PRIMARY KEY,
|
|
19
|
+
account_id uuid NOT NULL,
|
|
20
|
+
driver_id text NOT NULL,
|
|
21
|
+
lease_token uuid NOT NULL,
|
|
22
|
+
expires_at timestamptz NOT NULL,
|
|
23
|
+
created_at timestamptz DEFAULT now(),
|
|
24
|
+
updated_at timestamptz DEFAULT now()
|
|
25
|
+
);
|
|
26
|
+
|
|
27
|
+
ALTER TABLE public.episode_leases ENABLE ROW LEVEL SECURITY;
|
|
28
|
+
|
|
29
|
+
-- Service-role only: all reads/writes flow through _shared/agent-runner.ts
|
|
30
|
+
-- (adminDb) and the three RPCs below.
|
|
31
|
+
DROP POLICY IF EXISTS episode_leases_service_role ON public.episode_leases;
|
|
32
|
+
CREATE POLICY episode_leases_service_role ON public.episode_leases
|
|
33
|
+
FOR ALL TO service_role USING (true) WITH CHECK (true);
|
|
34
|
+
|
|
35
|
+
-- ── acquire_episode_lease ──────────────────────────────────────────
|
|
36
|
+
-- Atomic claim. Returns {outcome, lease_token?, holder_driver_id?}:
|
|
37
|
+
-- claimed — caller owns the episode; must heartbeat/release with the token
|
|
38
|
+
-- held — a live driver owns the thread; caller watches or retries later
|
|
39
|
+
-- The same driver re-acquiring renews (new token, fresh TTL) so a client
|
|
40
|
+
-- step loop can hold the lease across HTTP requests.
|
|
41
|
+
CREATE OR REPLACE FUNCTION public.acquire_episode_lease(
|
|
42
|
+
p_thread_id uuid,
|
|
43
|
+
p_account_id uuid,
|
|
44
|
+
p_driver_id text,
|
|
45
|
+
p_ttl_seconds integer DEFAULT 90
|
|
46
|
+
)
|
|
47
|
+
RETURNS jsonb
|
|
48
|
+
LANGUAGE plpgsql
|
|
49
|
+
SECURITY DEFINER
|
|
50
|
+
SET search_path = public
|
|
51
|
+
AS $$
|
|
52
|
+
DECLARE
|
|
53
|
+
v_lease public.episode_leases%ROWTYPE;
|
|
54
|
+
v_new_token uuid := gen_random_uuid();
|
|
55
|
+
BEGIN
|
|
56
|
+
-- Fast path: claim the empty slot. The thread_id PK serializes
|
|
57
|
+
-- concurrent first-writers — the loser's INSERT is a no-op.
|
|
58
|
+
INSERT INTO public.episode_leases (thread_id, account_id, driver_id, lease_token, expires_at)
|
|
59
|
+
VALUES (
|
|
60
|
+
p_thread_id,
|
|
61
|
+
p_account_id,
|
|
62
|
+
p_driver_id,
|
|
63
|
+
v_new_token,
|
|
64
|
+
now() + make_interval(secs => p_ttl_seconds)
|
|
65
|
+
)
|
|
66
|
+
ON CONFLICT (thread_id) DO NOTHING;
|
|
67
|
+
|
|
68
|
+
IF FOUND THEN
|
|
69
|
+
RETURN jsonb_build_object('outcome', 'claimed', 'lease_token', v_new_token);
|
|
70
|
+
END IF;
|
|
71
|
+
|
|
72
|
+
-- Conflict path: lock the row so a concurrent release/takeover cannot
|
|
73
|
+
-- interleave between our read and our verdict.
|
|
74
|
+
SELECT * INTO v_lease
|
|
75
|
+
FROM public.episode_leases
|
|
76
|
+
WHERE thread_id = p_thread_id
|
|
77
|
+
FOR UPDATE;
|
|
78
|
+
|
|
79
|
+
-- A live different driver owns the episode — refuse, surfacing the
|
|
80
|
+
-- holder so the caller can attribute the 'running' status.
|
|
81
|
+
IF v_lease.driver_id <> p_driver_id AND v_lease.expires_at > now() THEN
|
|
82
|
+
RETURN jsonb_build_object(
|
|
83
|
+
'outcome', 'held',
|
|
84
|
+
'holder_driver_id', v_lease.driver_id
|
|
85
|
+
);
|
|
86
|
+
END IF;
|
|
87
|
+
|
|
88
|
+
-- Same driver (renew) or expired lease (takeover): fresh token + TTL.
|
|
89
|
+
-- A displaced driver's later heartbeat/release fails the token check,
|
|
90
|
+
-- which is what fences its pending writes.
|
|
91
|
+
UPDATE public.episode_leases SET
|
|
92
|
+
driver_id = p_driver_id,
|
|
93
|
+
lease_token = v_new_token,
|
|
94
|
+
account_id = p_account_id,
|
|
95
|
+
expires_at = now() + make_interval(secs => p_ttl_seconds),
|
|
96
|
+
updated_at = now()
|
|
97
|
+
WHERE thread_id = p_thread_id;
|
|
98
|
+
|
|
99
|
+
RETURN jsonb_build_object('outcome', 'claimed', 'lease_token', v_new_token);
|
|
100
|
+
END;
|
|
101
|
+
$$;
|
|
102
|
+
|
|
103
|
+
REVOKE ALL ON FUNCTION public.acquire_episode_lease(uuid, uuid, text, integer) FROM PUBLIC;
|
|
104
|
+
REVOKE ALL ON FUNCTION public.acquire_episode_lease(uuid, uuid, text, integer) FROM anon;
|
|
105
|
+
REVOKE ALL ON FUNCTION public.acquire_episode_lease(uuid, uuid, text, integer) FROM authenticated;
|
|
106
|
+
GRANT EXECUTE ON FUNCTION public.acquire_episode_lease(uuid, uuid, text, integer) TO service_role;
|
|
107
|
+
|
|
108
|
+
-- ── heartbeat_episode_lease ────────────────────────────────────────
|
|
109
|
+
-- Token-gated TTL extension. Returns false when the lease_token no
|
|
110
|
+
-- longer matches (an expired-lease takeover displaced this driver) —
|
|
111
|
+
-- the caller must treat that as a fence and stop writing, not as an
|
|
112
|
+
-- error to retry.
|
|
113
|
+
CREATE OR REPLACE FUNCTION public.heartbeat_episode_lease(
|
|
114
|
+
p_thread_id uuid,
|
|
115
|
+
p_lease_token uuid,
|
|
116
|
+
p_ttl_seconds integer DEFAULT 90
|
|
117
|
+
)
|
|
118
|
+
RETURNS boolean
|
|
119
|
+
LANGUAGE plpgsql
|
|
120
|
+
SECURITY DEFINER
|
|
121
|
+
SET search_path = public
|
|
122
|
+
AS $$
|
|
123
|
+
BEGIN
|
|
124
|
+
UPDATE public.episode_leases SET
|
|
125
|
+
expires_at = now() + make_interval(secs => p_ttl_seconds),
|
|
126
|
+
updated_at = now()
|
|
127
|
+
WHERE thread_id = p_thread_id
|
|
128
|
+
AND lease_token = p_lease_token;
|
|
129
|
+
|
|
130
|
+
RETURN FOUND;
|
|
131
|
+
END;
|
|
132
|
+
$$;
|
|
133
|
+
|
|
134
|
+
REVOKE ALL ON FUNCTION public.heartbeat_episode_lease(uuid, uuid, integer) FROM PUBLIC;
|
|
135
|
+
REVOKE ALL ON FUNCTION public.heartbeat_episode_lease(uuid, uuid, integer) FROM anon;
|
|
136
|
+
REVOKE ALL ON FUNCTION public.heartbeat_episode_lease(uuid, uuid, integer) FROM authenticated;
|
|
137
|
+
GRANT EXECUTE ON FUNCTION public.heartbeat_episode_lease(uuid, uuid, integer) TO service_role;
|
|
138
|
+
|
|
139
|
+
-- ── release_episode_lease ──────────────────────────────────────────
|
|
140
|
+
-- Token-gated release on terminal state. Returns false when the token
|
|
141
|
+
-- no longer matches (a takeover superseded this driver) — warn-only,
|
|
142
|
+
-- never an error.
|
|
143
|
+
CREATE OR REPLACE FUNCTION public.release_episode_lease(
|
|
144
|
+
p_thread_id uuid,
|
|
145
|
+
p_lease_token uuid
|
|
146
|
+
)
|
|
147
|
+
RETURNS boolean
|
|
148
|
+
LANGUAGE plpgsql
|
|
149
|
+
SECURITY DEFINER
|
|
150
|
+
SET search_path = public
|
|
151
|
+
AS $$
|
|
152
|
+
BEGIN
|
|
153
|
+
DELETE FROM public.episode_leases
|
|
154
|
+
WHERE thread_id = p_thread_id
|
|
155
|
+
AND lease_token = p_lease_token;
|
|
156
|
+
|
|
157
|
+
RETURN FOUND;
|
|
158
|
+
END;
|
|
159
|
+
$$;
|
|
160
|
+
|
|
161
|
+
REVOKE ALL ON FUNCTION public.release_episode_lease(uuid, uuid) FROM PUBLIC;
|
|
162
|
+
REVOKE ALL ON FUNCTION public.release_episode_lease(uuid, uuid) FROM anon;
|
|
163
|
+
REVOKE ALL ON FUNCTION public.release_episode_lease(uuid, uuid) FROM authenticated;
|
|
164
|
+
GRANT EXECUTE ON FUNCTION public.release_episode_lease(uuid, uuid) TO service_role;
|
|
165
|
+
|
|
166
|
+
-- ── record_trigger_fire ────────────────────────────────────────────
|
|
167
|
+
-- Atomic trigger bookkeeping. trigger-engine previously passed an
|
|
168
|
+
-- adminDb.rpc() Promise as a column value inside an UPDATE payload —
|
|
169
|
+
-- the update failed silently, so trigger_count stayed 0 and
|
|
170
|
+
-- last_triggered stayed null on every fire.
|
|
171
|
+
CREATE OR REPLACE FUNCTION public.record_trigger_fire(
|
|
172
|
+
p_trigger_id uuid
|
|
173
|
+
)
|
|
174
|
+
RETURNS void
|
|
175
|
+
LANGUAGE sql
|
|
176
|
+
SECURITY DEFINER
|
|
177
|
+
SET search_path = public
|
|
178
|
+
AS $$
|
|
179
|
+
UPDATE public.triggers SET
|
|
180
|
+
trigger_count = COALESCE(trigger_count, 0) + 1,
|
|
181
|
+
last_triggered = now()
|
|
182
|
+
WHERE id = p_trigger_id;
|
|
183
|
+
$$;
|
|
184
|
+
|
|
185
|
+
REVOKE ALL ON FUNCTION public.record_trigger_fire(uuid) FROM PUBLIC;
|
|
186
|
+
REVOKE ALL ON FUNCTION public.record_trigger_fire(uuid) FROM anon;
|
|
187
|
+
REVOKE ALL ON FUNCTION public.record_trigger_fire(uuid) FROM authenticated;
|
|
188
|
+
GRANT EXECUTE ON FUNCTION public.record_trigger_fire(uuid) TO service_role;
|
|
189
|
+
|
|
190
|
+
-- CIL[DOC-VECTOR type="context" content="purpose='Atomic single-flight lease for agent episodes keyed by thread_id, plus atomic record_trigger_fire bookkeeping.'; Callers: _shared/agent-runner.ts (kickAgentEpisode, runAgentStep heartbeat, runAgent release), functions/ai-agents.ts (run handler), _shared/trigger-engine.ts (record_trigger_fire); imports: none" status="ready"]
|
|
191
|
+
-- CIL[DOC-VECTOR type="blast_radius" content="Any agent episode entry path now serializes drivers atomically; callers that do not claim proceed unchanged when the RPCs are absent" status="ready"]
|
|
192
|
+
-- CIL[DOC-VECTOR type="configuration" content="Runs after thread/message tables exist; consumed by _shared/agent-runner.ts via adminDb.rpc" status="ready"]
|
|
193
|
+
-- CIL[DOC-VECTOR type="constraint" content="episode_leases and all three RPCs are service-role only; heartbeat and release are fenced by lease_token; same-driver acquire renews; expired leases are taken over with a fresh token" status="ready"]
|
|
194
|
+
-- CIL[DOC-VECTOR type="implementation_pattern" content="" status="pending_human_input"]
|
|
195
|
+
-- CIL[DOC-VECTOR type="user_journey" content="" status="pending_human_input"]
|
|
196
|
+
-- CIL[END stable_id="~/.framework/migrations/064_episode_leases.sql:episode_leases:file"]
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
-- CIL[BEGIN stable_id="~/.framework/migrations/065_execution_tables_updated_at.sql:execution_tables_updated_at:file" type="file" identity="execution_tables_updated_at" parent="~/.framework/migrations/065_execution_tables_updated_at.sql"]
|
|
2
|
+
-- CIL[DOC-MACRO purpose="Add the missing updated_at column to tables that carry the set_*_updated_at trigger but were created without the column — every UPDATE on them fails with 'record NEW has no field updated_at', which is what wedged pipeline_executions rows in 'running' forever."]
|
|
3
|
+
-- CIL[DOC-RESPONSIBILITY responsibility="Backfill updated_at on pipeline_executions (required for terminal bookkeeping writes) and on logs/observability (defensive — same broken trigger, though those tables are append-only today)."]
|
|
4
|
+
-- CIL[DOC-CONTEXT module="migrations" layer="database" related_files=".framework/migrations/001_core_schema.sql,.framework/functions/_shared/pipeline-runner.ts"]
|
|
5
|
+
-- CIL[DOC-CONTRACT inputs="none" outputs="three tables gain updated_at timestamptz DEFAULT now()" errors="none — idempotent ADD COLUMN IF NOT EXISTS" side_effects="Enables previously-failing UPDATEs on pipeline_executions; trigger set_*_updated_at now has a column to stamp"]
|
|
6
|
+
-- CIL[DOC-MICRO logic_summary="001_core_schema attached updated_at_column() triggers to tables whose CREATE TABLE omitted updated_at; the trigger raises on every UPDATE, so pipeline-runner's terminal status writes silently failed (unchecked .update()). Adding the column makes the trigger a no-op stamp instead of an error." notable_decisions="Column added to all three affected tables for uniformity rather than dropping the triggers on append-only logs/observability — keeps the bookkeeping convention consistent if either table ever becomes updateable."]
|
|
7
|
+
-- CIL[DOC-CONFIDENCE pass="first" score="0.9" flags="none"]
|
|
8
|
+
-- CIL[DOC-STABILITY stability="stable"]
|
|
9
|
+
|
|
10
|
+
ALTER TABLE public.pipeline_executions ADD COLUMN IF NOT EXISTS updated_at timestamptz DEFAULT now();
|
|
11
|
+
ALTER TABLE public.logs ADD COLUMN IF NOT EXISTS updated_at timestamptz DEFAULT now();
|
|
12
|
+
ALTER TABLE public.observability ADD COLUMN IF NOT EXISTS updated_at timestamptz DEFAULT now();
|
|
13
|
+
|
|
14
|
+
-- CIL[DOC-VECTOR type="context" content="purpose='Fix broken updated_at triggers on execution/log tables.'; Callers: _shared/pipeline-runner.ts terminal bookkeeping; imports: none" status="ready"]
|
|
15
|
+
-- CIL[DOC-VECTOR type="blast_radius" content="pipeline_executions UPDATEs begin succeeding immediately; rows stuck 'running' from the silent failure remain and must be repaired separately" status="ready"]
|
|
16
|
+
-- CIL[DOC-VECTOR type="configuration" content="Applies to any deployment created from 001_core_schema before this fix" status="ready"]
|
|
17
|
+
-- CIL[DOC-VECTOR type="constraint" content="Idempotent; safe to re-run; does not repair historical stuck rows — that is a separate bookkeeping UPDATE" status="ready"]
|
|
18
|
+
-- CIL[DOC-VECTOR type="implementation_pattern" content="" status="pending_human_input"]
|
|
19
|
+
-- CIL[DOC-VECTOR type="user_journey" content="" status="pending_human_input"]
|
|
20
|
+
-- CIL[END stable_id="~/.framework/migrations/065_execution_tables_updated_at.sql:execution_tables_updated_at:file"]
|
|
@@ -0,0 +1,148 @@
|
|
|
1
|
+
-- CIL[BEGIN stable_id="~/.framework/migrations/066_episode_steal_thread_merge.sql:episode_steal_thread_merge:file" type="file" identity="episode_steal_thread_merge" parent="~/.framework/migrations/066_episode_steal_thread_merge.sql"]
|
|
2
|
+
-- CIL[DOC-MACRO purpose="Episode-lease steal + fenced thread-data merge: request-bound drivers can immediately take over a thread whose lease is held by a 'detached:'-prefixed background driver, and threads.data writers get an atomic shallow-merge RPC with an optional lease-token fence so terminal research_state checkpoints cannot be clobbered by stale read-modify-write blobs."]
|
|
3
|
+
-- CIL[DOC-RESPONSIBILITY responsibility="Extend acquire_episode_lease with p_steal_detached so live 'detached:' lease holders yield instantly to request-bound drivers, and add merge_thread_data as the single atomic writer for threads.data patches."]
|
|
4
|
+
-- CIL[DOC-CONTEXT module="migrations" layer="database" related_files=".framework/functions/_shared/agent-runner.ts,.framework/functions/ai-agents.ts,.framework/migrations/064_episode_leases.sql"]
|
|
5
|
+
-- CIL[DOC-CONTRACT inputs="acquire: +p_steal_detached boolean; merge_thread_data: p_thread_id/p_patch/p_lease_token" outputs="acquire jsonb {outcome: claimed|held, lease_token?, holder_driver_id?}; merge_thread_data boolean" errors="none — all branches return" side_effects="Updates episode_leases / threads"]
|
|
6
|
+
-- CIL[DOC-MICRO logic_summary="acquire_episode_lease keeps the 064 fast-path INSERT plus FOR UPDATE conflict path; the 'held' verdict is skipped when p_steal_detached and the holder is 'detached:%' — detached background drivers exist to yield. merge_thread_data does data = coalesce(data,'{}'::jsonb) || p_patch with WHERE-fencing: when p_lease_token is provided the write only lands if no differently-tokened lease holds the thread, so a displaced driver's stale checkpoint is dropped." notable_decisions="Detached steal is safer than client-side driver_id adoption: the server decides who yields, and live (non-detached) holders are still never preempted — only TTL expiry displaces them; jsonb || is a shallow merge so siblings written concurrently (escalation_status, research_state) no longer clobber each other; the lease-token fence makes a loser's terminal write a silent no-op rather than a resurrected mid-episode state"]
|
|
7
|
+
-- CIL[DOC-CONFIDENCE pass="first" score="0.9" flags="none"]
|
|
8
|
+
-- CIL[DOC-STABILITY stability="stable"]
|
|
9
|
+
|
|
10
|
+
-- ── acquire_episode_lease: detached-holder steal ────────────────────
|
|
11
|
+
-- 'detached:' drivers are spawned background work (ticket-creation
|
|
12
|
+
-- kicks, trigger-fired episodes) — they exist so an episode survives a
|
|
13
|
+
-- dead client, not to block one. On serverless platforms detached work
|
|
14
|
+
-- is CPU-throttled after the response flush (observed ~37s dead window
|
|
15
|
+
-- before its first persisted message), so a request-bound driver must
|
|
16
|
+
-- be able to take over immediately instead of watching a stalled
|
|
17
|
+
-- driver or waiting out the TTL. Live non-detached holders are never
|
|
18
|
+
-- preempted — only TTL expiry displaces them, exactly as in 064.
|
|
19
|
+
DROP FUNCTION IF EXISTS public.acquire_episode_lease(uuid, uuid, text, integer);
|
|
20
|
+
CREATE FUNCTION public.acquire_episode_lease(
|
|
21
|
+
p_thread_id uuid,
|
|
22
|
+
p_account_id uuid,
|
|
23
|
+
p_driver_id text,
|
|
24
|
+
p_ttl_seconds integer DEFAULT 90,
|
|
25
|
+
p_steal_detached boolean DEFAULT false
|
|
26
|
+
)
|
|
27
|
+
RETURNS jsonb
|
|
28
|
+
LANGUAGE plpgsql
|
|
29
|
+
SECURITY DEFINER
|
|
30
|
+
SET search_path = public
|
|
31
|
+
AS $$
|
|
32
|
+
DECLARE
|
|
33
|
+
v_lease public.episode_leases%ROWTYPE;
|
|
34
|
+
v_new_token uuid := gen_random_uuid();
|
|
35
|
+
BEGIN
|
|
36
|
+
-- Fast path: claim the empty slot. The thread_id PK serializes
|
|
37
|
+
-- concurrent first-writers — the loser's INSERT is a no-op.
|
|
38
|
+
INSERT INTO public.episode_leases (thread_id, account_id, driver_id, lease_token, expires_at)
|
|
39
|
+
VALUES (
|
|
40
|
+
p_thread_id,
|
|
41
|
+
p_account_id,
|
|
42
|
+
p_driver_id,
|
|
43
|
+
v_new_token,
|
|
44
|
+
now() + make_interval(secs => p_ttl_seconds)
|
|
45
|
+
)
|
|
46
|
+
ON CONFLICT (thread_id) DO NOTHING;
|
|
47
|
+
|
|
48
|
+
IF FOUND THEN
|
|
49
|
+
RETURN jsonb_build_object('outcome', 'claimed', 'lease_token', v_new_token);
|
|
50
|
+
END IF;
|
|
51
|
+
|
|
52
|
+
-- Conflict path: lock the row so a concurrent release/takeover cannot
|
|
53
|
+
-- interleave between our read and our verdict.
|
|
54
|
+
SELECT * INTO v_lease
|
|
55
|
+
FROM public.episode_leases
|
|
56
|
+
WHERE thread_id = p_thread_id
|
|
57
|
+
FOR UPDATE;
|
|
58
|
+
|
|
59
|
+
-- A live different driver owns the episode — refuse, unless the holder
|
|
60
|
+
-- is a detached background driver and the caller asked to steal it.
|
|
61
|
+
-- Detached drivers are a liveness fallback, not a real owner: a live
|
|
62
|
+
-- request driver always wins over one. The detached driver's next
|
|
63
|
+
-- heartbeat fails the token check and it aborts cleanly, so the steal
|
|
64
|
+
-- never produces duplicate side effects.
|
|
65
|
+
IF v_lease.driver_id <> p_driver_id
|
|
66
|
+
AND v_lease.expires_at > now()
|
|
67
|
+
AND NOT (p_steal_detached AND v_lease.driver_id LIKE 'detached:%') THEN
|
|
68
|
+
RETURN jsonb_build_object(
|
|
69
|
+
'outcome', 'held',
|
|
70
|
+
'holder_driver_id', v_lease.driver_id
|
|
71
|
+
);
|
|
72
|
+
END IF;
|
|
73
|
+
|
|
74
|
+
-- Same driver (renew), expired lease (takeover), or detached steal:
|
|
75
|
+
-- fresh token + TTL. A displaced driver's later heartbeat/release
|
|
76
|
+
-- fails the token check, which is what fences its pending writes.
|
|
77
|
+
UPDATE public.episode_leases SET
|
|
78
|
+
driver_id = p_driver_id,
|
|
79
|
+
lease_token = v_new_token,
|
|
80
|
+
account_id = p_account_id,
|
|
81
|
+
expires_at = now() + make_interval(secs => p_ttl_seconds),
|
|
82
|
+
updated_at = now()
|
|
83
|
+
WHERE thread_id = p_thread_id;
|
|
84
|
+
|
|
85
|
+
RETURN jsonb_build_object('outcome', 'claimed', 'lease_token', v_new_token);
|
|
86
|
+
END;
|
|
87
|
+
$$;
|
|
88
|
+
|
|
89
|
+
REVOKE ALL ON FUNCTION public.acquire_episode_lease(uuid, uuid, text, integer, boolean) FROM PUBLIC;
|
|
90
|
+
REVOKE ALL ON FUNCTION public.acquire_episode_lease(uuid, uuid, text, integer, boolean) FROM anon;
|
|
91
|
+
REVOKE ALL ON FUNCTION public.acquire_episode_lease(uuid, uuid, text, integer, boolean) FROM authenticated;
|
|
92
|
+
GRANT EXECUTE ON FUNCTION public.acquire_episode_lease(uuid, uuid, text, integer, boolean) TO service_role;
|
|
93
|
+
|
|
94
|
+
-- ── merge_thread_data ──────────────────────────────────────────────
|
|
95
|
+
-- Atomic shallow merge into threads.data. The previous pattern —
|
|
96
|
+
-- read threads.data, merge in memory, write the whole blob back —
|
|
97
|
+
-- loses any key written between the read and the write; during the
|
|
98
|
+
-- guided demo a concurrent threads.data write resurrected a stale
|
|
99
|
+
-- research_state over the terminal 'complete' checkpoint, leaving the
|
|
100
|
+
-- thread showing planning/turn 0 after the final answer landed.
|
|
101
|
+
-- jsonb || merges keys atomically so sibling writes never clobber.
|
|
102
|
+
--
|
|
103
|
+
-- Lease fence: when p_lease_token is provided, the write only lands
|
|
104
|
+
-- while no differently-tokened episode_lease holds the thread — a
|
|
105
|
+
-- displaced driver's stale checkpoint becomes a no-op instead of
|
|
106
|
+
-- resurrecting mid-episode state over the takeover owner's terminal
|
|
107
|
+
-- write. Pass NULL (or omit) for writers with no lease identity; the
|
|
108
|
+
-- merge is still atomic, just unfenced.
|
|
109
|
+
--
|
|
110
|
+
-- Returns true when the row was written, false when fenced or the
|
|
111
|
+
-- thread does not exist.
|
|
112
|
+
CREATE OR REPLACE FUNCTION public.merge_thread_data(
|
|
113
|
+
p_thread_id uuid,
|
|
114
|
+
p_patch jsonb,
|
|
115
|
+
p_lease_token uuid DEFAULT null
|
|
116
|
+
)
|
|
117
|
+
RETURNS boolean
|
|
118
|
+
LANGUAGE plpgsql
|
|
119
|
+
SECURITY DEFINER
|
|
120
|
+
SET search_path = public
|
|
121
|
+
AS $$
|
|
122
|
+
BEGIN
|
|
123
|
+
UPDATE public.threads t SET
|
|
124
|
+
data = coalesce(t.data, '{}'::jsonb) || p_patch,
|
|
125
|
+
updated_at = now()
|
|
126
|
+
WHERE t.id = p_thread_id
|
|
127
|
+
AND (p_lease_token IS NULL OR NOT EXISTS (
|
|
128
|
+
SELECT 1 FROM public.episode_leases l
|
|
129
|
+
WHERE l.thread_id = p_thread_id
|
|
130
|
+
AND l.lease_token IS DISTINCT FROM p_lease_token
|
|
131
|
+
));
|
|
132
|
+
|
|
133
|
+
RETURN FOUND;
|
|
134
|
+
END;
|
|
135
|
+
$$;
|
|
136
|
+
|
|
137
|
+
REVOKE ALL ON FUNCTION public.merge_thread_data(uuid, jsonb, uuid) FROM PUBLIC;
|
|
138
|
+
REVOKE ALL ON FUNCTION public.merge_thread_data(uuid, jsonb, uuid) FROM anon;
|
|
139
|
+
REVOKE ALL ON FUNCTION public.merge_thread_data(uuid, jsonb, uuid) FROM authenticated;
|
|
140
|
+
GRANT EXECUTE ON FUNCTION public.merge_thread_data(uuid, jsonb, uuid) TO service_role;
|
|
141
|
+
|
|
142
|
+
-- CIL[DOC-VECTOR type="context" content="purpose='Detached-driver lease steal plus atomic fenced threads.data merge.'; Callers: _shared/agent-runner.ts (acquireEpisodeLease, mergeThreadData, setResearchState, handleEscalation, claimAgentEpisode/kickAgentEpisode), functions/ai-agents.ts (run handler); imports: none" status="ready"]
|
|
143
|
+
-- CIL[DOC-VECTOR type="blast_radius" content="Episode entry paths can preempt detached fallback drivers instantly; all threads.data writers through merge_thread_data gain atomicity and optional lease fencing" status="ready"]
|
|
144
|
+
-- CIL[DOC-VECTOR type="configuration" content="Runs after 064_episode_leases.sql; consumed by _shared/agent-runner.ts via adminDb.rpc" status="ready"]
|
|
145
|
+
-- CIL[DOC-VECTOR type="constraint" content="service-role only; 'detached:%' driver_id prefix is a contract with kickAgentEpisode; merge_thread_data is a shallow merge — nested objects in p_patch replace wholesale" status="ready"]
|
|
146
|
+
-- CIL[DOC-VECTOR type="implementation_pattern" content="" status="pending_human_input"]
|
|
147
|
+
-- CIL[DOC-VECTOR type="user_journey" content="" status="pending_human_input"]
|
|
148
|
+
-- CIL[END stable_id="~/.framework/migrations/066_episode_steal_thread_merge.sql:episode_steal_thread_merge:file"]
|
|
@@ -0,0 +1,93 @@
|
|
|
1
|
+
-- Migration 067: stale-gated detached-driver steal
|
|
2
|
+
--
|
|
3
|
+
-- 066 let a request-bound driver steal any live 'detached:' lease
|
|
4
|
+
-- unconditionally. That guaranteed a mid-turn displacement on every
|
|
5
|
+
-- guided run (triage kick claims first, the client's episode POST steals
|
|
6
|
+
-- seconds later), fencing a healthy driver and leaving research_state
|
|
7
|
+
-- mid-flight — the residual duplicate-narration class. Steal is a
|
|
8
|
+
-- recovery path for STALLED background work, so it now requires the
|
|
9
|
+
-- holder's heartbeat (updated_at, renewed by heartbeat_episode_lease)
|
|
10
|
+
-- to be older than p_stale_seconds. Healthy detached drivers are
|
|
11
|
+
-- refused 'held' like any other live driver.
|
|
12
|
+
|
|
13
|
+
-- CIL[BEGIN stable_id="~/.framework/migrations/067_episode_lease_stale_steal.sql:module:migration" type="migration" identity="episode-lease-stale-steal" parent="~/.framework/migrations/067_episode_lease_stale_steal.sql"]
|
|
14
|
+
-- CIL[DOC-MACRO purpose="Gate p_steal_detached on the holder's heartbeat age so request drivers only displace stalled background work"]
|
|
15
|
+
-- CIL[DOC-RESPONSIBILITY responsibility="Redefine acquire_episode_lease with a p_stale_seconds parameter; detach-prefix holders are stealable only when updated_at exceeds the staleness window"]
|
|
16
|
+
-- CIL[DOC-CONTEXT module="episode-concurrency" layer="schema" related_files=".framework/functions/_shared/agent-runner.ts,.framework/functions/ai-agents.ts"]
|
|
17
|
+
-- CIL[DOC-CONTRACT inputs="p_thread_id uuid, p_account_id uuid, p_driver_id text, p_ttl_seconds int default 90, p_steal_detached bool default false, p_stale_seconds int default 30" outputs="jsonb verdict {outcome: claimed|held, lease_token?, holder_driver_id?}" errors="none — fail-open verdict" side_effects="inserts or updates episode_leases"]
|
|
18
|
+
-- CIL[DOC-MICRO logic_summary="Identical to 066 except the steal branch additionally requires v_lease.updated_at to be older than p_stale_seconds; heartbeat renewals keep a healthy detached driver unstealable" notable_decisions="30s default bounds takeover latency well under the 90s TTL while tolerating slow single turns between heartbeats"]
|
|
19
|
+
-- CIL[DOC-CONFIDENCE pass="first" score="0.92" flags="none"]
|
|
20
|
+
-- CIL[DOC-STABILITY stability="stable"]
|
|
21
|
+
-- CIL[DOC-AUDIENCE audience="system-admin"]
|
|
22
|
+
-- CIL[DOC-VECTOR type="context" content="Third lease RPC revision: 063 atomic claim, 066 detached steal, 067 stale-gated steal" status="ready"]
|
|
23
|
+
-- CIL[END stable_id="~/.framework/migrations/067_episode_lease_stale_steal.sql:module:migration"]
|
|
24
|
+
|
|
25
|
+
DROP FUNCTION IF EXISTS public.acquire_episode_lease(uuid, uuid, text, integer, boolean);
|
|
26
|
+
|
|
27
|
+
CREATE FUNCTION public.acquire_episode_lease(
|
|
28
|
+
p_thread_id uuid,
|
|
29
|
+
p_account_id uuid,
|
|
30
|
+
p_driver_id text,
|
|
31
|
+
p_ttl_seconds integer DEFAULT 90,
|
|
32
|
+
p_steal_detached boolean DEFAULT false,
|
|
33
|
+
p_stale_seconds integer DEFAULT 30
|
|
34
|
+
)
|
|
35
|
+
RETURNS jsonb
|
|
36
|
+
LANGUAGE plpgsql
|
|
37
|
+
SECURITY DEFINER
|
|
38
|
+
SET search_path = public
|
|
39
|
+
AS $$
|
|
40
|
+
DECLARE
|
|
41
|
+
v_lease public.episode_leases%ROWTYPE;
|
|
42
|
+
v_new_token uuid := gen_random_uuid();
|
|
43
|
+
BEGIN
|
|
44
|
+
INSERT INTO public.episode_leases (thread_id, account_id, driver_id, lease_token, expires_at)
|
|
45
|
+
VALUES (
|
|
46
|
+
p_thread_id,
|
|
47
|
+
p_account_id,
|
|
48
|
+
p_driver_id,
|
|
49
|
+
v_new_token,
|
|
50
|
+
now() + make_interval(secs => p_ttl_seconds)
|
|
51
|
+
)
|
|
52
|
+
ON CONFLICT (thread_id) DO NOTHING;
|
|
53
|
+
|
|
54
|
+
IF FOUND THEN
|
|
55
|
+
RETURN jsonb_build_object('outcome', 'claimed', 'lease_token', v_new_token);
|
|
56
|
+
END IF;
|
|
57
|
+
|
|
58
|
+
SELECT * INTO v_lease
|
|
59
|
+
FROM public.episode_leases
|
|
60
|
+
WHERE thread_id = p_thread_id
|
|
61
|
+
FOR UPDATE;
|
|
62
|
+
|
|
63
|
+
-- Held: a different live driver owns the episode. Steal is allowed
|
|
64
|
+
-- only for a 'detached:'-prefixed holder whose heartbeat has gone
|
|
65
|
+
-- stale — a healthy background driver is refused like anyone else so
|
|
66
|
+
-- request claims stop displacing mid-turn work.
|
|
67
|
+
IF v_lease.driver_id <> p_driver_id
|
|
68
|
+
AND v_lease.expires_at > now()
|
|
69
|
+
AND NOT (p_steal_detached
|
|
70
|
+
AND v_lease.driver_id LIKE 'detached:%'
|
|
71
|
+
AND v_lease.updated_at < now() - make_interval(secs => p_stale_seconds)) THEN
|
|
72
|
+
RETURN jsonb_build_object(
|
|
73
|
+
'outcome', 'held',
|
|
74
|
+
'holder_driver_id', v_lease.driver_id
|
|
75
|
+
);
|
|
76
|
+
END IF;
|
|
77
|
+
|
|
78
|
+
UPDATE public.episode_leases SET
|
|
79
|
+
driver_id = p_driver_id,
|
|
80
|
+
lease_token = v_new_token,
|
|
81
|
+
account_id = p_account_id,
|
|
82
|
+
expires_at = now() + make_interval(secs => p_ttl_seconds),
|
|
83
|
+
updated_at = now()
|
|
84
|
+
WHERE thread_id = p_thread_id;
|
|
85
|
+
|
|
86
|
+
RETURN jsonb_build_object('outcome', 'claimed', 'lease_token', v_new_token);
|
|
87
|
+
END;
|
|
88
|
+
$$;
|
|
89
|
+
|
|
90
|
+
REVOKE ALL ON FUNCTION public.acquire_episode_lease(uuid, uuid, text, integer, boolean, integer) FROM PUBLIC;
|
|
91
|
+
REVOKE ALL ON FUNCTION public.acquire_episode_lease(uuid, uuid, text, integer, boolean, integer) FROM anon;
|
|
92
|
+
REVOKE ALL ON FUNCTION public.acquire_episode_lease(uuid, uuid, text, integer, boolean, integer) FROM authenticated;
|
|
93
|
+
GRANT EXECUTE ON FUNCTION public.acquire_episode_lease(uuid, uuid, text, integer, boolean, integer) TO service_role;
|