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.
Files changed (46) hide show
  1. package/.framework/functions/_shared/agent-runner.ts +1000 -81
  2. package/.framework/functions/_shared/cil.ts +2 -1
  3. package/.framework/functions/_shared/db.ts +3 -6
  4. package/.framework/functions/_shared/kb.ts +61 -7
  5. package/.framework/functions/_shared/message-sequence.ts +53 -0
  6. package/.framework/functions/_shared/pipeline-runner.ts +150 -37
  7. package/.framework/functions/_shared/principal.ts +9 -6
  8. package/.framework/functions/_shared/trigger-engine.ts +149 -40
  9. package/.framework/functions/admin-data.ts +9 -3
  10. package/.framework/functions/ai-agents.ts +93 -27
  11. package/.framework/functions/pipeline-runner-background.ts +120 -0
  12. package/.framework/functions/system-cron.ts +10 -1
  13. package/.framework/functions/trigger-scheduler.ts +65 -5
  14. package/.framework/migrations/064_episode_leases.sql +196 -0
  15. package/.framework/migrations/065_execution_tables_updated_at.sql +20 -0
  16. package/.framework/migrations/066_episode_steal_thread_merge.sql +148 -0
  17. package/.framework/migrations/067_episode_lease_stale_steal.sql +93 -0
  18. package/.framework/migrations/068_cron_schedule_timer_rpcs.sql +251 -0
  19. package/.framework/schemas/spine.config.schema.json +62 -0
  20. package/.framework/src/App.tsx +3 -1
  21. package/.framework/src/components/admin/AdminSidebar.tsx +4 -2
  22. package/.framework/src/components/app-shell/GenericAppShell.tsx +3 -1
  23. package/.framework/src/components/auth/TurnstileWidget.tsx +9 -6
  24. package/.framework/src/components/kb/KBArticleEditor.tsx +49 -6
  25. package/.framework/src/components/layout/Layout.tsx +4 -2
  26. package/.framework/src/components/layout/Sidebar.tsx +4 -2
  27. package/.framework/src/hooks/useBranding.ts +47 -0
  28. package/.framework/src/lib/branding.ts +128 -0
  29. package/.framework/src/pages/NoAppPage.tsx +3 -1
  30. package/.framework/src/pages/SetupPage.tsx +5 -2
  31. package/.framework/src/pages/auth/AuthConfirmPage.tsx +3 -1
  32. package/.framework/src/pages/auth/ForgotPasswordPage.tsx +3 -1
  33. package/.framework/src/pages/auth/LoginPage.tsx +4 -2
  34. package/.framework/src/pages/auth/RegisterPage.tsx +3 -1
  35. package/.framework/templates/project.json +1 -0
  36. package/.framework/tests/unit/agent-runner.test.ts +17 -3
  37. package/.framework/tests/unit/episode-lease.test.ts +695 -0
  38. package/.framework/tests/unit/kb-ingest-lease.test.ts +19 -0
  39. package/.framework/tests/unit/pipeline-runner-background.test.ts +120 -0
  40. package/.framework/tests/unit/pipeline-runner.test.ts +153 -1
  41. package/.framework/tests/unit/principal-cache.test.ts +15 -0
  42. package/.framework/tests/unit/trigger-engine.test.ts +278 -91
  43. package/.framework/tests/unit/trigger-scheduler.test.ts +78 -0
  44. package/netlify.toml +5 -0
  45. package/package.json +1 -1
  46. 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 WebSocket transport, then exports configured admin/user clients, a result type, and join mappings." notable_decisions="Falls back to placeholder credentials when environment values are absent."]
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, sets the schema and WebSocket transport." notable_decisions="Uses service role key for elevated privileges and WebSocket for realtime."]
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, WebSocket transport, and an Authorization header." notable_decisions="Uses the anon key plus Bearer token for row-level security."]
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(/&nbsp;/gi, ' ')
245
+ .replace(/&amp;/g, '&')
246
+ .replace(/&lt;/g, '<')
247
+ .replace(/&gt;/g, '>')
248
+ .replace(/&quot;/g, '"')
249
+ .replace(/&#0?39;|&apos;/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 declared vector-field values, 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."]
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 feeds the
664
- // embedder. The lease dedupes unchanged re-embeds and fences concurrent
665
- // workers (publish trigger + detached ingest both arrive here).
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) => getFieldValue(record, field)).join('\n'))
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 = getFieldValue(record, field)
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
- const { data: execution, error: execError } = await adminDb
293
- .from('pipeline_executions')
294
- .insert({
295
- pipeline_id: pipelineId,
296
- status: 'running',
297
- trigger_data: triggerData || {},
298
- account_id: ctx.accountId,
299
- created_by: isUuid(ctx.principal?.id) ? ctx.principal?.id : null,
300
- started_at: new Date().toISOString()
301
- })
302
- .select()
303
- .single()
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
- if (execError || !execution) {
306
- throw new Error(`Failed to create execution: ${execError?.message}`)
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
- const executionId = execution.id
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 adminDb
369
- .from('pipeline_executions')
370
- .update({
371
- status: 'completed',
372
- result: { stages: stageResults },
373
- completed_at: new Date().toISOString(),
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 adminDb
406
- .from('pipeline_executions')
407
- .update({
408
- status: 'failed',
409
- result: { stages: stageResults },
410
- error_message: error.message,
411
- completed_at: new Date().toISOString(),
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
- import WebSocket from 'ws'
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 authenticated Supabase client with realtime transport"]
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/realtime options" notable_decisions="[]"]
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