spine-framework 0.4.3 → 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
|
@@ -192,7 +192,8 @@ export function computeContentHash(raw: string): string {
|
|
|
192
192
|
!trimmed.match(/^\/\*\*?\s*CIL\[DOC-/) &&
|
|
193
193
|
!trimmed.match(/^<!--\s*CIL\[(BEGIN|END)/) &&
|
|
194
194
|
!trimmed.match(/^<!--\s*CIL\[DOC-/) &&
|
|
195
|
-
!trimmed.match(/^#\s*CIL\[(BEGIN|END|DOC-)/)
|
|
195
|
+
!trimmed.match(/^#\s*CIL\[(BEGIN|END|DOC-)/) &&
|
|
196
|
+
!trimmed.match(/^--\s*CIL\[(BEGIN|END|DOC-)/)
|
|
196
197
|
})
|
|
197
198
|
const collapsed: string[] = []
|
|
198
199
|
let lastBlank = false
|
|
@@ -3,7 +3,7 @@
|
|
|
3
3
|
// CIL[DOC-RESPONSIBILITY responsibility="Owns the shared Supabase client exports and common query joins."]
|
|
4
4
|
// CIL[DOC-CONTEXT module="functions/_shared/db" layer="data-access" related_files=".framework/functions/_shared/db.ts"]
|
|
5
5
|
// CIL[DOC-CONTRACT inputs="[]" outputs="adminDb, getUserDb, DbResult, joins, constants" errors="[]" side_effects="[]"]
|
|
6
|
-
// CIL[DOC-MICRO logic_summary="Imports the Supabase client and
|
|
6
|
+
// CIL[DOC-MICRO logic_summary="Imports the Supabase client and exports configured admin/user clients, a result type, and join mappings. WebSocket/ws realtime transport removed — server-side functions never subscribe to Supabase channels." notable_decisions="Falls back to placeholder credentials when environment values are absent. Realtime transport removed to reduce bundle size and eliminate unnecessary module-level initialization."]
|
|
7
7
|
// CIL[DOC-CONFIDENCE pass="second" score="0.90" flags="none"]
|
|
8
8
|
// CIL[DOC-STABILITY stability="stable"]
|
|
9
9
|
// CIL[DOC-AUDIENCE audience="human"]
|
|
@@ -14,7 +14,6 @@
|
|
|
14
14
|
|
|
15
15
|
|
|
16
16
|
import { createClient } from '@supabase/supabase-js'
|
|
17
|
-
import WebSocket from 'ws'
|
|
18
17
|
// CIL[DOC-VECTOR type="context" content="purpose='Supabase client and database relation join module'; No verified external callers; imports: none"]
|
|
19
18
|
// CIL[DOC-VECTOR type="blast_radius" content="No direct caller data from CIL graph; local impact only"]
|
|
20
19
|
// CIL[DOC-VECTOR type="configuration" content="No environment or build configuration dependencies identified"]
|
|
@@ -241,12 +240,11 @@ const dbSchema: string = _env.DB_SCHEMA || 'public'
|
|
|
241
240
|
// CIL[DOC-RESPONSIBILITY responsibility="Exports the admin-authorized Supabase client for backend use."]
|
|
242
241
|
// CIL[DOC-CONTEXT module="functions/_shared/db" layer="data-access" related_files=".framework/functions/_shared/db.ts"]
|
|
243
242
|
// CIL[DOC-CONTRACT inputs="[]" outputs="SupabaseClient adminDb" errors="[]" side_effects="[]"]
|
|
244
|
-
// CIL[DOC-MICRO logic_summary="Creates a createClient instance using supabaseUrl and supabaseServiceKey
|
|
243
|
+
// CIL[DOC-MICRO logic_summary="Creates a createClient instance using supabaseUrl and supabaseServiceKey and sets the schema. No realtime transport: server-side functions never subscribe to Supabase channels — omitting the ws transport shrinks every function bundle and eliminates an unnecessary module-level init." notable_decisions="Realtime transport removed: ws import added ~10KB to every bundle and could attempt a WebSocket handshake on module load. REST/PostgREST queries are unaffected."]
|
|
245
244
|
// CIL[DOC-CONFIDENCE pass="second" score="0.90" flags="none"]
|
|
246
245
|
// CIL[DOC-STABILITY stability="stable"]
|
|
247
246
|
export const adminDb = createClient(supabaseUrl, supabaseServiceKey, {
|
|
248
247
|
db: { schema: dbSchema },
|
|
249
|
-
realtime: { transport: WebSocket as any }
|
|
250
248
|
})
|
|
251
249
|
// CIL[DOC-VECTOR type="context" content="purpose='Admin Supabase client'; No verified external callers; imports: none"]
|
|
252
250
|
// CIL[DOC-VECTOR type="blast_radius" content="No direct caller data from CIL graph; local impact only"]
|
|
@@ -262,14 +260,13 @@ export const adminDb = createClient(supabaseUrl, supabaseServiceKey, {
|
|
|
262
260
|
// CIL[DOC-CONTEXT module="db" layer="functions" related_files=".framework/functions/_shared/db.ts"]
|
|
263
261
|
// CIL[DOC-RESPONSIBILITY responsibility="Creates a Supabase client scoped to an end-user JWT."]
|
|
264
262
|
// CIL[DOC-CONTRACT inputs="jwt: string" outputs="SupabaseClient" errors="[]" side_effects="[]"]
|
|
265
|
-
// CIL[DOC-MICRO logic_summary="Returns a createClient instance using supabaseUrl and supabaseAnonKey, schema,
|
|
263
|
+
// CIL[DOC-MICRO logic_summary="Returns a createClient instance using supabaseUrl and supabaseAnonKey, schema, and an Authorization header." notable_decisions="Uses the anon key plus Bearer token for row-level security. Realtime transport omitted for same reasons as adminDb."]
|
|
266
264
|
// CIL[DOC-CONFIDENCE pass="second" score="0.90" flags="none"]
|
|
267
265
|
// CIL[DOC-STABILITY stability="stable"]
|
|
268
266
|
export function getUserDb(jwt: string) {
|
|
269
267
|
return createClient(supabaseUrl, supabaseAnonKey, {
|
|
270
268
|
db: { schema: dbSchema },
|
|
271
269
|
global: { headers: { Authorization: `Bearer ${jwt}` } },
|
|
272
|
-
realtime: { transport: WebSocket as any }
|
|
273
270
|
})
|
|
274
271
|
}
|
|
275
272
|
// CIL[END stable_id="~/.framework/functions/_shared/db.ts:getUserDb:function"]
|
|
@@ -218,6 +218,43 @@ function getFieldValue(record: any, field: string): string {
|
|
|
218
218
|
}
|
|
219
219
|
return ''
|
|
220
220
|
}
|
|
221
|
+
|
|
222
|
+
const UUID_TOKEN_RE = /\b[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}\b/gi
|
|
223
|
+
const HTML_TAG_RE = /<[^>]*>/g
|
|
224
|
+
const HTML_LOOKS_LIKE_RE = /<\/?[a-z][^>]*>/i
|
|
225
|
+
|
|
226
|
+
// CIL[BEGIN stable_id="~/.framework/functions/_shared/kb.ts:getembeddablefieldvalue:function" type="function" identity="getEmbeddableFieldValue" parent="~/.framework/functions/_shared/kb.ts"]
|
|
227
|
+
// CIL[DOC-MACRO purpose="Normalize a record field into embeddable plain text — strips rich-text HTML and bare UUID tokens"]
|
|
228
|
+
// CIL[DOC-RESPONSIBILITY responsibility="Produce the exact text that feeds both the content-hash lease and the embedder so neither markup nor identifier-only chunks enter the corpus"]
|
|
229
|
+
// CIL[DOC-CONTEXT module="functions/_shared" layer="kb" related_files=".framework/functions/_shared/kb.ts"]
|
|
230
|
+
// CIL[DOC-CONTRACT inputs="record: any, field: string, designSchema: any" outputs="string — normalized text, '' when nothing embeddable remains" errors="[]" side_effects="[]"]
|
|
231
|
+
// CIL[DOC-MICRO logic_summary="rich_text fields (or any value containing tags) run through an HTML-to-text pass; every field then drops bare UUID tokens — reference-id arrays like tag item ids collapse to empty and are skipped" notable_decisions="applied uniformly to all vector fields so the content hash dedupes on the same text the embedder sees"]
|
|
232
|
+
// CIL[DOC-CONFIDENCE pass="first" score="0.85" flags="none"]
|
|
233
|
+
// CIL[DOC-STABILITY stability="internal"]
|
|
234
|
+
function getEmbeddableFieldValue(record: any, field: string, designSchema: any): string {
|
|
235
|
+
let text = getFieldValue(record, field)
|
|
236
|
+
if (!text) return ''
|
|
237
|
+
const dataType = designSchema?.fields?.[field]?.data_type
|
|
238
|
+
if (dataType === 'rich_text' || HTML_LOOKS_LIKE_RE.test(text)) {
|
|
239
|
+
text = text
|
|
240
|
+
.replace(/<\/(p|div|h[1-6]|li|ul|ol|section|article|header|footer|blockquote|tr|table)>/gi, '\n')
|
|
241
|
+
.replace(/<br\s*\/?>/gi, '\n')
|
|
242
|
+
.replace(/<li[^>]*>/gi, '\n- ')
|
|
243
|
+
.replace(HTML_TAG_RE, ' ')
|
|
244
|
+
.replace(/ /gi, ' ')
|
|
245
|
+
.replace(/&/g, '&')
|
|
246
|
+
.replace(/</g, '<')
|
|
247
|
+
.replace(/>/g, '>')
|
|
248
|
+
.replace(/"/g, '"')
|
|
249
|
+
.replace(/�?39;|'/g, "'")
|
|
250
|
+
}
|
|
251
|
+
return text
|
|
252
|
+
.replace(UUID_TOKEN_RE, ' ')
|
|
253
|
+
.replace(/[ \t]+/g, ' ')
|
|
254
|
+
.replace(/\n{3,}/g, '\n\n')
|
|
255
|
+
.trim()
|
|
256
|
+
}
|
|
257
|
+
// CIL[END stable_id="~/.framework/functions/_shared/kb.ts:getembeddablefieldvalue:function"]
|
|
221
258
|
// CIL[DOC-VECTOR type="context" content="purpose='Retrieves a flattened string value from a record or its data object.'; No verified external callers; imports: .framework/functions/_shared/db.ts,.framework/functions/_shared/middleware.ts,.framework/functions/_shared/permissions.ts"]
|
|
222
259
|
// CIL[DOC-VECTOR type="blast_radius" content="No direct caller data from CIL graph; local impact only"]
|
|
223
260
|
// CIL[DOC-VECTOR type="configuration" content="No environment or build configuration dependencies identified"]
|
|
@@ -596,7 +633,7 @@ async function clearEmbeddingsForItem(documentId: string): Promise<number> {
|
|
|
596
633
|
// CIL[DOC-RESPONSIBILITY responsibility="Processes a published KB item, chunks vector fields, and persists embeddings under a content-hash document lease."]
|
|
597
634
|
// CIL[DOC-CONTEXT module="functions/_shared" layer="kb" related_files=".framework/functions/_shared/db.ts,.framework/functions/_shared/kb.ts,.framework/functions/_shared/llm.ts,.framework/functions/_shared/middleware.ts,.framework/functions/_shared/permissions.ts,.framework/migrations/054_kb_ingest_lease.sql"]
|
|
598
635
|
// CIL[DOC-CONTRACT inputs="ctx: CoreContext, body with id: string and optional account_id" outputs="ok: boolean; message: string; count?: number; in_progress?: boolean" errors="id is required; record not found; database errors" side_effects="revokes previous embeddings; inserts rows into embeddings table; acquires/releases a document_leases row; archives+revokes superseded documents"]
|
|
599
|
-
// CIL[DOC-MICRO logic_summary="Fetches the item, validates status, hashes the
|
|
636
|
+
// CIL[DOC-MICRO logic_summary="Fetches the item, validates status, hashes the normalized vector-field values (rich_text HTML stripped, bare UUID tokens dropped), acquires a document lease ('reused' skips unchanged work, 'processing' reports another worker owns it), then inside try/finally clears stale embeddings, batches chunks into one embeddings call, inserts, and runs supersede_keys-driven supersession before releasing the lease token." notable_decisions="Revokes existing embeddings via clearEmbeddingsForItem (not kbRevoke — that would delete the caller's own lease); supersession is driven by functionality.kb.supersede_keys declared data fields instead of a hardcoded type-slug gate; a fenced release (token mismatch) warns without throwing — the work was written but ownership was lost to a superseding acquire."]
|
|
600
637
|
// CIL[DOC-CONFIDENCE pass="second" score="0.90" flags="none"]
|
|
601
638
|
// CIL[DOC-STABILITY stability="internal"]
|
|
602
639
|
// CIL[DOC-NOTE type="contract" content="Lease outcomes: 'reused' returns a live-embeddings count so callers may stamp freshness; 'processing' returns in_progress:true with NO success marker — callers that stamp embedding state must not stamp success or failure for it, the owning worker's completion reconciles the corpus."]
|
|
@@ -660,11 +697,12 @@ export async function kbIngest(ctx: CoreContext, body: { id: string; account_id?
|
|
|
660
697
|
const securityLevel = resolveSecurityLevel(designSchema)
|
|
661
698
|
|
|
662
699
|
// Content-hash lease (document_leases, migration 054): sha256 over the
|
|
663
|
-
// declared vector-field values in order — the same text that
|
|
664
|
-
// embedder
|
|
665
|
-
//
|
|
700
|
+
// declared vector-field values in order — the same NORMALIZED text that
|
|
701
|
+
// feeds the embedder (HTML stripped, bare UUIDs dropped). The lease
|
|
702
|
+
// dedupes unchanged re-embeds and fences concurrent workers (publish
|
|
703
|
+
// trigger + detached ingest both arrive here).
|
|
666
704
|
const contentHash = createHash('sha256')
|
|
667
|
-
.update(vectorFields.map((field: string) =>
|
|
705
|
+
.update(vectorFields.map((field: string) => getEmbeddableFieldValue(record, field, designSchema)).join('\n'))
|
|
668
706
|
.digest('hex')
|
|
669
707
|
|
|
670
708
|
const { data: lease, error: leaseError } = await adminDb.rpc('acquire_document_lease', {
|
|
@@ -703,7 +741,7 @@ export async function kbIngest(ctx: CoreContext, body: { id: string; account_id?
|
|
|
703
741
|
|
|
704
742
|
const toEmbed: Array<{ field: string; chunk: string }> = []
|
|
705
743
|
for (const field of vectorFields) {
|
|
706
|
-
const raw =
|
|
744
|
+
const raw = getEmbeddableFieldValue(record, field, designSchema)
|
|
707
745
|
if (!raw.trim()) continue
|
|
708
746
|
|
|
709
747
|
const isChunked = field === chunkField && raw.length > chunkSize
|
|
@@ -837,10 +875,26 @@ export async function kbIngest(ctx: CoreContext, body: { id: string; account_id?
|
|
|
837
875
|
* @calls clearEmbeddingsForItem, adminDb.from('document_leases').delete()
|
|
838
876
|
* @cil-generated
|
|
839
877
|
*/
|
|
840
|
-
export async function kbRevoke(ctx: CoreContext, body: { id: string }): Promise<{ ok: boolean; message: string; count?: number }> {
|
|
878
|
+
export async function kbRevoke(ctx: CoreContext, body: { id: string }): Promise<{ ok: boolean; message: string; count?: number; skipped?: boolean }> {
|
|
841
879
|
const { id } = body
|
|
842
880
|
if (!id) throw new Error('id is required')
|
|
843
881
|
|
|
882
|
+
// Execution-time fence: a revoke queued by an intermediate
|
|
883
|
+
// item_updated event (e.g. auto_revoke_on_unpublish fired while a
|
|
884
|
+
// publish pipeline was mid-flight) executes long after the trigger
|
|
885
|
+
// condition was true. Re-check the document's CURRENT status — if it
|
|
886
|
+
// is published now, its embeddings are fresh and must be kept.
|
|
887
|
+
// Trigger-time evaluation is always stale by the time work runs.
|
|
888
|
+
const { data: doc } = await adminDb
|
|
889
|
+
.from('items')
|
|
890
|
+
.select('status, data')
|
|
891
|
+
.eq('id', id)
|
|
892
|
+
.maybeSingle()
|
|
893
|
+
const docStatus = doc?.status ?? doc?.data?.status
|
|
894
|
+
if (doc && docStatus === 'published') {
|
|
895
|
+
return { ok: true, skipped: true, message: 'document is published — embeddings retained', count: 0 }
|
|
896
|
+
}
|
|
897
|
+
|
|
844
898
|
const count = await clearEmbeddingsForItem(id)
|
|
845
899
|
|
|
846
900
|
// Clear any stored lease — a 'completed' lease would otherwise force
|
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
// CIL[BEGIN stable_id="~/.framework/functions/_shared/message-sequence.ts:module_header:module_header" type="module_header" identity="module_header" parent="~/.framework/functions/_shared/message-sequence.ts"]
|
|
2
|
+
// CIL[DOC-MACRO purpose="Race-safe message sequence allocation for writers outside the agent runner's per-instance counter"]
|
|
3
|
+
// CIL[DOC-RESPONSIBILITY responsibility="Allocate the next sequence value for a thread and retry the caller's insert when a concurrent write collides on UNIQUE(thread_id, sequence)"]
|
|
4
|
+
// CIL[DOC-CONTEXT module="functions/_shared/message-sequence" layer="data-access" related_files=".framework/functions/_shared/db.ts,.framework/functions/_shared/agent-runner.ts"]
|
|
5
|
+
// CIL[DOC-CONTRACT inputs="threadId: string, insert: (sequence: number) => Promise<T>, maxAttempts?: number" outputs="T — the insert result" errors="rethrows non-duplicate errors and 23505 once attempts are exhausted" side_effects="one sequence read per attempt plus the caller's insert"]
|
|
6
|
+
// CIL[DOC-MICRO logic_summary="Each attempt reads MAX(sequence) fresh then runs the caller's insert; a 23505 means the allocation went stale between read and write, so the loop re-reads and retries" notable_decisions="MAX(sequence)+1 rather than COUNT+1 — sparse or deleted sequences can't cause under-allocation; the insert callback keeps the caller's own create path (admin-data stamping, direct insert) so no shared module reaches into admin-data"]
|
|
7
|
+
// CIL[DOC-LEARNING topic="sequence_allocation" content="Any non-runner writer that computes COUNT(*)+1 then inserts is racy — a concurrent writer on the same thread lands the same sequence and the UNIQUE(thread_id, sequence) constraint throws 23505. All such sites must allocate through insertMessageSequenced (or the runner's allocSequence/persist path). Production evidence: case-analysis finalize 500'd when the detached chain's finalize overlapped the client resumer's kick('finalized')."]
|
|
8
|
+
// CIL[DOC-NOTE type="caution" content="The agent runner has its own per-instance counter (threadSeqCounter + allocSequence) — do not route runner message writes through this helper; this is for custom-app and finalize-path writers that persist one message at a time."]
|
|
9
|
+
// CIL[DOC-AUDIENCE audience="custom app developers writing messages into threads outside runAgent"]
|
|
10
|
+
// CIL[DOC-STABILITY stability="stable"]
|
|
11
|
+
import { adminDb } from './db'
|
|
12
|
+
// CIL[END stable_id="~/.framework/functions/_shared/message-sequence.ts:module_header:module_header"]
|
|
13
|
+
|
|
14
|
+
// CIL[BEGIN stable_id="~/.framework/functions/_shared/message-sequence.ts:isduplicatekeyerror:function" type="function" identity="isDuplicateKeyError" parent="~/.framework/functions/_shared/message-sequence.ts"]
|
|
15
|
+
// CIL[DOC-MACRO purpose="Detect Postgres 23505 unique-violation errors from admin-data throws or supabase-js error objects"]
|
|
16
|
+
// CIL[DOC-CONTRACT inputs="err: any (thrown error or PostgrestError)" outputs="boolean" errors="none" side_effects="none"]
|
|
17
|
+
// CIL[DOC-MICRO logic_summary="Checks error.code === '23505' and falls back to message matching for wrapped errors" notable_decisions="[]"]
|
|
18
|
+
// CIL[DOC-STABILITY stability="stable"]
|
|
19
|
+
function isDuplicateKeyError(err: any): boolean {
|
|
20
|
+
return err?.code === '23505' || /23505|duplicate key/i.test(String(err?.message || err || ''))
|
|
21
|
+
}
|
|
22
|
+
// CIL[END stable_id="~/.framework/functions/_shared/message-sequence.ts:isduplicatekeyerror:function"]
|
|
23
|
+
|
|
24
|
+
// CIL[BEGIN stable_id="~/.framework/functions/_shared/message-sequence.ts:insertmessagesequenced:function" type="function" identity="insertMessageSequenced" parent="~/.framework/functions/_shared/message-sequence.ts"]
|
|
25
|
+
// CIL[DOC-MACRO purpose="Allocate the next message sequence for a thread and run the caller's insert, retrying on unique-violation"]
|
|
26
|
+
// CIL[DOC-RESPONSIBILITY responsibility="Replace the read-count-then-insert pattern (COUNT+1) that collides whenever two writers race on one thread"]
|
|
27
|
+
// CIL[DOC-CONTRACT inputs="threadId, insert: (sequence:number)=>Promise<T>, maxAttempts=3" outputs="insert result" errors="rethrows final error" side_effects="messages read + caller insert"]
|
|
28
|
+
// CIL[DOC-MICRO logic_summary="Reads MAX(sequence) fresh each attempt so a retried insert re-allocates above whatever the racing writer landed" notable_decisions="callback carries the sequence so type stamping/audit stays inside the caller's existing create call"]
|
|
29
|
+
// CIL[DOC-STABILITY stability="stable"]
|
|
30
|
+
export async function insertMessageSequenced<T>(
|
|
31
|
+
threadId: string,
|
|
32
|
+
insert: (sequence: number) => Promise<T>,
|
|
33
|
+
maxAttempts = 3
|
|
34
|
+
): Promise<T> {
|
|
35
|
+
let lastErr: any = null
|
|
36
|
+
for (let attempt = 0; attempt < maxAttempts; attempt++) {
|
|
37
|
+
const { data } = await adminDb
|
|
38
|
+
.from('messages')
|
|
39
|
+
.select('sequence')
|
|
40
|
+
.eq('thread_id', threadId)
|
|
41
|
+
.order('sequence', { ascending: false })
|
|
42
|
+
.limit(1)
|
|
43
|
+
const sequence = (data?.[0]?.sequence ?? 0) + 1
|
|
44
|
+
try {
|
|
45
|
+
return await insert(sequence)
|
|
46
|
+
} catch (err: any) {
|
|
47
|
+
lastErr = err
|
|
48
|
+
if (!isDuplicateKeyError(err)) throw err
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
throw lastErr
|
|
52
|
+
}
|
|
53
|
+
// CIL[END stable_id="~/.framework/functions/_shared/message-sequence.ts:insertmessagesequenced:function"]
|
|
@@ -262,6 +262,47 @@ interface PipelineStage {
|
|
|
262
262
|
* @errorHandling Handles errors at each stage and decides whether to continue based on stage configuration.
|
|
263
263
|
* @cil-generated
|
|
264
264
|
*/
|
|
265
|
+
// CIL[BEGIN stable_id="~/.framework/functions/_shared/pipeline-runner.ts:persistExecutionResult:function" type="function" identity="persistExecutionResult" parent="~/.framework/functions/_shared/pipeline-runner.ts"]
|
|
266
|
+
/**
|
|
267
|
+
* Writes the terminal status of a pipeline execution. The update result
|
|
268
|
+
* is checked and retried once — an unchecked failure is what previously
|
|
269
|
+
* left rows stuck 'running' forever while the audit span still claimed
|
|
270
|
+
* 'pipeline.completed'. A retry failure is logged loudly so the row can
|
|
271
|
+
* be found and reconciled.
|
|
272
|
+
*
|
|
273
|
+
* @inputSpec
|
|
274
|
+
* ctx: CoreContext (required) — context for request-id logging.
|
|
275
|
+
* executionId: string (required) — the pipeline_executions row to update.
|
|
276
|
+
* patch: Record<string, any> (required) — terminal status payload.
|
|
277
|
+
* @outputSpec Promise<void>
|
|
278
|
+
* @sideEffects Updates public.pipeline_executions; logs on failure.
|
|
279
|
+
* @stability stable
|
|
280
|
+
* @calls adminDb.from('pipeline_executions').update
|
|
281
|
+
* @cil-generated
|
|
282
|
+
*/
|
|
283
|
+
async function persistExecutionResult(
|
|
284
|
+
ctx: CoreContext,
|
|
285
|
+
executionId: string,
|
|
286
|
+
patch: Record<string, any>
|
|
287
|
+
): Promise<void> {
|
|
288
|
+
let { error } = await adminDb
|
|
289
|
+
.from('pipeline_executions')
|
|
290
|
+
.update(patch)
|
|
291
|
+
.eq('id', executionId)
|
|
292
|
+
if (error) {
|
|
293
|
+
console.error(`[${ctx.requestId}] pipeline_executions final update failed for ${executionId}: ${error.message} — retrying`)
|
|
294
|
+
const retry = await adminDb
|
|
295
|
+
.from('pipeline_executions')
|
|
296
|
+
.update(patch)
|
|
297
|
+
.eq('id', executionId)
|
|
298
|
+
error = retry.error
|
|
299
|
+
if (error) {
|
|
300
|
+
console.error(`[${ctx.requestId}] pipeline_executions final update retry failed for ${executionId}: ${error.message}`)
|
|
301
|
+
}
|
|
302
|
+
}
|
|
303
|
+
}
|
|
304
|
+
// CIL[END stable_id="~/.framework/functions/_shared/pipeline-runner.ts:persistExecutionResult:function"]
|
|
305
|
+
|
|
265
306
|
// CIL[BEGIN stable_id="~/.framework/functions/_shared/pipeline-runner.ts:runpipeline:function" type="function" identity="runPipeline" parent="~/.framework/functions/_shared/pipeline-runner.ts"]
|
|
266
307
|
// CIL[DOC-MACRO purpose="runPipeline function"]
|
|
267
308
|
// CIL[DOC-RESPONSIBILITY responsibility="Implements the runPipeline function."]
|
|
@@ -270,14 +311,40 @@ interface PipelineStage {
|
|
|
270
311
|
// CIL[DOC-MICRO logic_summary="Implements the runPipeline function." notable_decisions="[]"]
|
|
271
312
|
// CIL[DOC-CONFIDENCE pass="first" score="0.8" flags="none"]
|
|
272
313
|
// CIL[DOC-STABILITY stability="stable"]
|
|
314
|
+
// An execution row whose runner died mid-flight (detached process killed,
|
|
315
|
+
// function timeout) can never write its own terminal status — it wedges
|
|
316
|
+
// 'running' forever. Sweep rows that have not been touched within the
|
|
317
|
+
// staleness window whenever a new pipeline run starts. Fire-and-forget:
|
|
318
|
+
// the sweep is bookkeeping, never on the critical path, and a failed
|
|
319
|
+
// sweep retries on the next run.
|
|
320
|
+
const STALE_EXECUTION_MS = 10 * 60 * 1000
|
|
321
|
+
|
|
322
|
+
export async function sweepStaleExecutions(): Promise<void> {
|
|
323
|
+
const cutoff = new Date(Date.now() - STALE_EXECUTION_MS).toISOString()
|
|
324
|
+
const { error } = await adminDb
|
|
325
|
+
.from('pipeline_executions')
|
|
326
|
+
.update({
|
|
327
|
+
status: 'failed',
|
|
328
|
+
error_message: 'stale execution: runner died before terminal write (swept)',
|
|
329
|
+
completed_at: new Date().toISOString(),
|
|
330
|
+
updated_at: new Date().toISOString()
|
|
331
|
+
})
|
|
332
|
+
.in('status', ['running', 'pending', 'queued'])
|
|
333
|
+
.lt('updated_at', cutoff)
|
|
334
|
+
if (error) console.warn('pipeline_executions stale sweep failed:', error.message)
|
|
335
|
+
}
|
|
336
|
+
|
|
273
337
|
export async function runPipeline(
|
|
274
338
|
pipelineId: string,
|
|
275
339
|
triggerData: any,
|
|
276
|
-
ctx: CoreContext
|
|
340
|
+
ctx: CoreContext,
|
|
341
|
+
opts?: { executionId?: string }
|
|
277
342
|
): Promise<ExecutionResult> {
|
|
278
343
|
return withSpan(ctx, 'pipeline.run', async (ctx) => {
|
|
279
344
|
const startTime = Date.now()
|
|
280
345
|
|
|
346
|
+
void sweepStaleExecutions().catch(() => {})
|
|
347
|
+
|
|
281
348
|
const { data: pipeline, error: pipelineError } = await adminDb
|
|
282
349
|
.from('pipelines')
|
|
283
350
|
.select('*')
|
|
@@ -289,24 +356,76 @@ export async function runPipeline(
|
|
|
289
356
|
throw new Error(`Pipeline not found or inactive: ${pipelineId}`)
|
|
290
357
|
}
|
|
291
358
|
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
|
|
302
|
-
|
|
303
|
-
|
|
359
|
+
// When a pre-created execution ID is supplied (dispatched trigger
|
|
360
|
+
// pipelines), claim that row — transitioning queued → running — rather
|
|
361
|
+
// than inserting a duplicate. The row was created by checkAndFireTriggers
|
|
362
|
+
// with status='queued' immediately before dispatch.
|
|
363
|
+
let executionId: string
|
|
364
|
+
if (opts?.executionId) {
|
|
365
|
+
const { data: claimed, error: claimError } = await adminDb
|
|
366
|
+
.from('pipeline_executions')
|
|
367
|
+
.update({
|
|
368
|
+
status: 'running',
|
|
369
|
+
trigger_data: triggerData || {},
|
|
370
|
+
started_at: new Date().toISOString(),
|
|
371
|
+
updated_at: new Date().toISOString()
|
|
372
|
+
})
|
|
373
|
+
.eq('id', opts.executionId)
|
|
374
|
+
.eq('status', 'queued')
|
|
375
|
+
.select('id')
|
|
304
376
|
|
|
305
|
-
|
|
306
|
-
|
|
307
|
-
|
|
377
|
+
if (claimError) {
|
|
378
|
+
throw new Error(`Failed to claim queued execution ${opts.executionId}: ${claimError.message}`)
|
|
379
|
+
}
|
|
380
|
+
if (claimed && claimed.length > 0) {
|
|
381
|
+
executionId = opts.executionId
|
|
382
|
+
} else {
|
|
383
|
+
// Claimed nothing — either the queued insert never landed (caller's
|
|
384
|
+
// fallback UUID) or a duplicate/swept dispatch arrived. Try inserting
|
|
385
|
+
// the row ourselves with the same ID: succeeds only when the row is
|
|
386
|
+
// genuinely absent, so duplicate deliveries die on the PK conflict
|
|
387
|
+
// instead of running the pipeline twice.
|
|
388
|
+
const { data: execution, error: execError } = await adminDb
|
|
389
|
+
.from('pipeline_executions')
|
|
390
|
+
.insert({
|
|
391
|
+
id: opts.executionId,
|
|
392
|
+
pipeline_id: pipelineId,
|
|
393
|
+
status: 'running',
|
|
394
|
+
trigger_data: triggerData || {},
|
|
395
|
+
account_id: ctx.accountId,
|
|
396
|
+
created_by: isUuid(ctx.principal?.id) ? ctx.principal?.id : null,
|
|
397
|
+
started_at: new Date().toISOString(),
|
|
398
|
+
updated_at: new Date().toISOString()
|
|
399
|
+
})
|
|
400
|
+
.select()
|
|
401
|
+
.single()
|
|
402
|
+
if (execError || !execution) {
|
|
403
|
+
throw new Error(`Failed to create execution: ${execError?.message}`)
|
|
404
|
+
}
|
|
405
|
+
executionId = execution.id
|
|
406
|
+
}
|
|
407
|
+
} else {
|
|
408
|
+
const { data: execution, error: execError } = await adminDb
|
|
409
|
+
.from('pipeline_executions')
|
|
410
|
+
.insert({
|
|
411
|
+
pipeline_id: pipelineId,
|
|
412
|
+
status: 'running',
|
|
413
|
+
trigger_data: triggerData || {},
|
|
414
|
+
account_id: ctx.accountId,
|
|
415
|
+
created_by: isUuid(ctx.principal?.id) ? ctx.principal?.id : null,
|
|
416
|
+
started_at: new Date().toISOString(),
|
|
417
|
+
// Seed updated_at so the stale-execution sweep has a baseline from
|
|
418
|
+
// t=0 — the row is 'running' the moment it exists.
|
|
419
|
+
updated_at: new Date().toISOString()
|
|
420
|
+
})
|
|
421
|
+
.select()
|
|
422
|
+
.single()
|
|
308
423
|
|
|
309
|
-
|
|
424
|
+
if (execError || !execution) {
|
|
425
|
+
throw new Error(`Failed to create execution: ${execError?.message}`)
|
|
426
|
+
}
|
|
427
|
+
executionId = execution.id
|
|
428
|
+
}
|
|
310
429
|
const stageResults: StageResult[] = []
|
|
311
430
|
const stages: PipelineStage[] = pipeline.stages || []
|
|
312
431
|
|
|
@@ -365,15 +484,12 @@ export async function runPipeline(
|
|
|
365
484
|
|
|
366
485
|
const durationMs = Date.now() - startTime
|
|
367
486
|
|
|
368
|
-
await
|
|
369
|
-
|
|
370
|
-
|
|
371
|
-
|
|
372
|
-
|
|
373
|
-
|
|
374
|
-
duration_ms: durationMs
|
|
375
|
-
})
|
|
376
|
-
.eq('id', executionId)
|
|
487
|
+
await persistExecutionResult(ctx, executionId, {
|
|
488
|
+
status: 'completed',
|
|
489
|
+
result: { stages: stageResults },
|
|
490
|
+
completed_at: new Date().toISOString(),
|
|
491
|
+
duration_ms: durationMs
|
|
492
|
+
})
|
|
377
493
|
|
|
378
494
|
await emitAudit(ctx, 'pipeline.completed', {
|
|
379
495
|
type: 'pipeline_execution',
|
|
@@ -402,16 +518,13 @@ export async function runPipeline(
|
|
|
402
518
|
} catch (error: any) {
|
|
403
519
|
const durationMs = Date.now() - startTime
|
|
404
520
|
|
|
405
|
-
await
|
|
406
|
-
|
|
407
|
-
|
|
408
|
-
|
|
409
|
-
|
|
410
|
-
|
|
411
|
-
|
|
412
|
-
duration_ms: durationMs
|
|
413
|
-
})
|
|
414
|
-
.eq('id', executionId)
|
|
521
|
+
await persistExecutionResult(ctx, executionId, {
|
|
522
|
+
status: 'failed',
|
|
523
|
+
result: { stages: stageResults },
|
|
524
|
+
error_message: error.message,
|
|
525
|
+
completed_at: new Date().toISOString(),
|
|
526
|
+
duration_ms: durationMs
|
|
527
|
+
})
|
|
415
528
|
|
|
416
529
|
await emitAudit(ctx, 'pipeline.failed', {
|
|
417
530
|
type: 'pipeline_execution',
|
|
@@ -1192,7 +1305,7 @@ const stageHandlers: Record<string, Function> = {
|
|
|
1192
1305
|
if (!itemId) throw new Error('kb_ingest: could not resolve item_id from trigger data')
|
|
1193
1306
|
|
|
1194
1307
|
const { kbIngest } = await import('./kb')
|
|
1195
|
-
return await kbIngest(ctx, { id: itemId })
|
|
1308
|
+
return await kbIngest(ctx, { id: itemId, account_id: triggerData?.entity?.data?.account_id })
|
|
1196
1309
|
},
|
|
1197
1310
|
|
|
1198
1311
|
'kb_revoke': async (ctx: CoreContext, config: any) => {
|
|
@@ -27,7 +27,7 @@
|
|
|
27
27
|
|
|
28
28
|
import { getUserDb, adminDb } from './db'
|
|
29
29
|
import { createClient } from '@supabase/supabase-js'
|
|
30
|
-
|
|
30
|
+
|
|
31
31
|
import { timingSafeEqual, createHash } from 'crypto'
|
|
32
32
|
// CIL[DOC-VECTOR type="context" content="purpose='Shared principal and authorization module for Spine functions'; No verified external callers; imports: .framework/functions/_shared/db.ts"]
|
|
33
33
|
// CIL[DOC-VECTOR type="blast_radius" content="No direct caller data from CIL graph; local impact only"]
|
|
@@ -96,10 +96,10 @@ const env = (globalThis as any).process?.env || {}
|
|
|
96
96
|
|
|
97
97
|
// CIL[BEGIN stable_id="~/.framework/functions/_shared/principal.ts:supabase:constant" type="constant" identity="supabase" parent="~/.framework/functions/_shared/principal.ts"]
|
|
98
98
|
// CIL[DOC-MACRO purpose="Create a Supabase client configured for function use"]
|
|
99
|
-
// CIL[DOC-RESPONSIBILITY responsibility="Provide an
|
|
99
|
+
// CIL[DOC-RESPONSIBILITY responsibility="Provide an anon-key Supabase client for auth.getUser JWT verification"]
|
|
100
100
|
// CIL[DOC-CONTEXT module="functions/_shared/principal" layer="shared" related_files=".framework/functions/_shared/db.ts,.framework/functions/_shared/principal.ts"]
|
|
101
101
|
// CIL[DOC-CONTRACT inputs="none" outputs="Supabase client" errors="none" side_effects="none"]
|
|
102
|
-
// CIL[DOC-MICRO logic_summary="Instantiate createClient with URL, anon key and auth
|
|
102
|
+
// CIL[DOC-MICRO logic_summary="Instantiate createClient with URL, anon key, and auth options. Realtime transport omitted — this client is used only for supabase.auth.getUser; no realtime channels are opened server-side." notable_decisions="ws realtime transport removed to reduce bundle size and eliminate unnecessary module-level init overhead across every function"]
|
|
103
103
|
// CIL[DOC-CONFIDENCE pass="second" score="0.90" flags="none"]
|
|
104
104
|
// CIL[DOC-STABILITY stability="stable"]
|
|
105
105
|
const supabase = createClient(
|
|
@@ -107,10 +107,7 @@ const supabase = createClient(
|
|
|
107
107
|
env.SUPABASE_ANON_KEY || 'placeholder',
|
|
108
108
|
{
|
|
109
109
|
auth: { autoRefreshToken: false, persistSession: false },
|
|
110
|
-
realtime: { transport: WebSocket as any }
|
|
111
110
|
}
|
|
112
|
-
|
|
113
|
-
|
|
114
111
|
)
|
|
115
112
|
// CIL[DOC-VECTOR type="context" content="purpose='Create a Supabase client configured for function use'; No verified external callers; imports: .framework/functions/_shared/db.ts"]
|
|
116
113
|
// CIL[DOC-VECTOR type="blast_radius" content="No direct caller data from CIL graph; local impact only"]
|
|
@@ -269,6 +266,12 @@ export async function resolvePrincipal(event: any): Promise<Principal> {
|
|
|
269
266
|
if (triggerId) {
|
|
270
267
|
return resolveTriggerPrincipal(triggerId, event)
|
|
271
268
|
}
|
|
269
|
+
|
|
270
|
+
// A bare internal-invoke secret is itself a first-class credential —
|
|
271
|
+
// function-to-function calls without a cron/trigger principal to
|
|
272
|
+
// impersonate (e.g. manual system-cron runs) get the privileged
|
|
273
|
+
// system principal rather than falling through to anonymous.
|
|
274
|
+
return SYSTEM_PRINCIPAL
|
|
272
275
|
}
|
|
273
276
|
|
|
274
277
|
const authHeader = event.headers?.authorization || event.headers?.Authorization
|