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.
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
@@ -37,7 +37,6 @@
37
37
  import { CoreContext } from './middleware'
38
38
  import { adminDb } from './db'
39
39
  import { emitAudit } from './audit'
40
- import { runPipeline } from './pipeline-runner'
41
40
  // CIL[END stable_id="~/.framework/functions/_shared/trigger-engine.ts:module_header:module_header"]
42
41
  // CIL[DOC-VECTOR type="context" content="purpose='This module serves as the trigger engine for processing events within the system, coordinating various actions based on incoming triggers.'; No verified external callers" status="ready"]
43
42
  // CIL[DOC-VECTOR type="blast_radius" content="No direct caller data from CIL graph; local impact only" status="ready"]
@@ -203,43 +202,102 @@ interface Trigger {
203
202
  // CIL[DOC-VECTOR type="implementation_pattern" content="" status="pending_human_input"]
204
203
  // CIL[DOC-VECTOR type="user_journey" content="" status="pending_human_input"]
205
204
 
206
- // CIL[DOC-MACRO purpose="This function checks for active triggers based on the specified event type and fires them if their conditions are met."]
207
- // CIL[DOC-RESPONSIBILITY responsibility="This function checks for active triggers based on the specified event type and fires them if their conditions are met. It exists to facilitate the execution of defined actions in response to specific events within the system."]
208
- // CIL[DOC-CONTEXT module="trigger-engine" layer="app-specific" related_files=".framework/functions/_shared/middleware,.framework/functions/_shared/db,.framework/functions/_shared/audit,.framework/functions/_shared/pipeline-runner"]
209
- // CIL[DOC-CONTRACT inputs="- eventType: EventType (required) — the type of event that triggers the action.\\n- entityType: string (required) — the type of entity associated with the event.\\n- entityId: string (required) — the unique identifier of the entity.\\n- entityData: any (required) — the data associated with the entity.\\n- ctx: CoreContext (required) — the context for the current operation." outputs="Promise<void>" errors="none" side_effects="Database writes to update trigger last triggered time and count, RPC calls to increment trigger count, and audit log emissions. dependencies="adminDb.from('triggers'), adminDb.from('types'), runPipeline, emitAudit, adminDb.from('triggers').update"]
210
- // CIL[DOC-MICRO logic_summary="This function checks for active triggers based on the specified event type and fires them if their conditions are met. It exists to facilitate the execution of defined actions in response to specific events within the system." notable_decisions="Logs errors to the console and emits audit logs for failures."]
211
- // CIL[DOC-CONFIDENCE pass="second" score="0.90" flags="none"]
205
+ // CIL[DOC-MACRO purpose="Dispatch a trigger pipeline to the background pipeline-runner function, returning immediately without awaiting execution"]
206
+ // CIL[DOC-RESPONSIBILITY responsibility="POST the payload to /.netlify/functions/pipeline-runner-background with the internal invoke secret so the pipeline runs in an isolated process with its own connection budget"]
207
+ // CIL[DOC-CONTEXT module="trigger-engine" layer="shared-core" related_files=".framework/functions/pipeline-runner-background.ts"]
208
+ // CIL[DOC-CONTRACT inputs="pipelineId, triggerData, ctx, opts { executionId, triggerId }" outputs="Promise<void>" errors="Logs dispatch failure to console; caller's queued execution row is left for the sweeper" side_effects="HTTP POST to internal function"]
209
+ // CIL[DOC-MICRO logic_summary="Builds the dispatch URL from SITE_URL/URL env (falling back to localhost for netlify dev), sends X-Internal-Secret for machine auth, and awaits only the 202 ack bounded by a 5s AbortSignal timeout — the caller never waits for pipeline completion" notable_decisions="Does NOT await runPipeline — that was the connection-pool starvation bug; moving execution to a separate function invocation is the fix. The 5s timeout bounds the caller when Netlify's queue ack stalls; an aborted dispatch may still deliver server-side, and the pre-created 'queued' row is the sweeper's safety net either way"]
210
+ // CIL[DOC-CONFIDENCE pass="first" score="0.9" flags="none"]
211
+ // CIL[DOC-STABILITY stability="stable"]
212
+ /**
213
+ * POST a pipeline dispatch to the internal background function.
214
+ * Returns immediately; the pipeline runs in a separate process.
215
+ * Any dispatch failure leaves the pre-created execution row 'queued' —
216
+ * the sweeper in pipeline-runner.ts marks it 'failed' after 10 minutes.
217
+ */
218
+ export async function dispatchTriggerPipeline(
219
+ pipelineId: string,
220
+ triggerData: any,
221
+ ctx: CoreContext,
222
+ opts: { executionId: string; triggerId: string }
223
+ ): Promise<void> {
224
+ const secret = (globalThis as any).process?.env?.INTERNAL_INVOKE_SECRET
225
+ if (!secret) {
226
+ console.error(`[${ctx.requestId}] INTERNAL_INVOKE_SECRET not set — cannot dispatch trigger pipeline ${pipelineId}`)
227
+ return
228
+ }
229
+
230
+ const siteUrl = (globalThis as any).process?.env?.SITE_URL ||
231
+ (globalThis as any).process?.env?.URL ||
232
+ 'http://localhost:8888'
233
+ const url = `${siteUrl.replace(/\/$/, '')}/.netlify/functions/pipeline-runner-background`
234
+
235
+ try {
236
+ const response = await fetch(url, {
237
+ method: 'POST',
238
+ headers: {
239
+ 'Content-Type': 'application/json',
240
+ 'X-Internal-Secret': secret,
241
+ },
242
+ body: JSON.stringify({
243
+ pipeline_id: pipelineId,
244
+ trigger_data: triggerData,
245
+ account_id: ctx.accountId,
246
+ execution_id: opts.executionId,
247
+ trigger_id: opts.triggerId,
248
+ }),
249
+ // Bound the caller's wait: the background function normally answers
250
+ // 202 in well under a second, but a stuck connection (deploy rollout,
251
+ // slow Netlify queue ack) once held a caller for ~34s. Aborting only
252
+ // releases this caller — if Netlify already received the POST the
253
+ // queued invocation still runs; if not, the pre-created 'queued'
254
+ // execution row is left for the sweeper.
255
+ signal: AbortSignal.timeout(5000),
256
+ })
257
+
258
+ if (!response.ok) {
259
+ const errText = await response.text().catch(() => `HTTP ${response.status}`)
260
+ console.error(`[${ctx.requestId}] trigger dispatch failed for pipeline ${pipelineId} (execution ${opts.executionId}): HTTP ${response.status} — ${errText}`)
261
+ }
262
+ } catch (err: any) {
263
+ const timedOut = err?.name === 'AbortError' || err?.name === 'TimeoutError'
264
+ console.error(`[${ctx.requestId}] trigger dispatch ${timedOut ? 'timed out after 5s' : 'error'} for pipeline ${pipelineId} (execution ${opts.executionId}):`, err.message || err)
265
+ }
266
+ }
267
+ // CIL[END stable_id="~/.framework/functions/_shared/trigger-engine.ts:dispatchtriggerpipeline:function"]
268
+
269
+
270
+ // CIL[DOC-MACRO purpose="Check for active triggers matching the event type, pre-create a queued pipeline_executions row, and dispatch to the background function"]
271
+ // CIL[DOC-RESPONSIBILITY responsibility="Evaluate trigger conditions, reserve a queued execution row, dispatch to pipeline-runner-background, record trigger fire, and emit audit — returns without waiting for pipeline completion"]
272
+ // CIL[DOC-CONTEXT module="trigger-engine" layer="shared-core" related_files=".framework/functions/_shared/middleware,.framework/functions/_shared/db,.framework/functions/_shared/audit,.framework/functions/pipeline-runner-background.ts"]
273
+ // CIL[DOC-CONTRACT inputs="eventType, entityType, entityId, entityData, ctx, previousEntityData? (pre-update record snapshot for transition checks)" outputs="Promise<void>" errors="Logs errors; emits trigger.failed audit" side_effects="Inserts pipeline_executions row (status: queued), RPC record_trigger_fire, emits trigger.fired audit"]
274
+ // CIL[DOC-MICRO logic_summary="Queries active triggers for the event type, resolves type_slug when needed for trigger filters, evaluates each trigger's conditions, then for matches: INSERTs a queued pipeline_executions row so the execution ID exists before dispatch, calls dispatchTriggerPipeline (which returns immediately), records the trigger fire, and emits the trigger.fired audit with execution_status='queued'" notable_decisions="Pre-creating the execution row is the key to preserving execution_id in the audit trail without awaiting pipeline completion; 'queued' rows left orphaned are swept by the stale-execution reaper"]
275
+ // CIL[DOC-CONFIDENCE pass="first" score="0.9" flags="none"]
212
276
  // CIL[DOC-STABILITY stability="stable"]
213
277
  /**
214
- * This function checks for active triggers based on the specified event type and fires them if their conditions are met. It exists to facilitate the execution of defined actions in response to specific events within the system.
278
+ * Check for active triggers matching the event type and dispatch each to the
279
+ * background pipeline-runner function. Returns immediately — pipeline
280
+ * completion is tracked in pipeline_executions independently.
215
281
  *
216
282
  * @inputSpec
217
- * - eventType: EventType (required) — the type of event that triggers the action.
218
- * - entityType: string (required) — the type of entity associated with the event.
219
- * - entityId: string (required) — the unique identifier of the entity.
220
- * - entityData: any (required) — the data associated with the entity.
221
- * - ctx: CoreContext (required) — the context for the current operation.
283
+ * - eventType: EventType — the event that occurred.
284
+ * - entityType: string — the entity type (plural runtime table name).
285
+ * - entityId: string — the affected entity ID.
286
+ * - entityData: any — the entity snapshot at the time of the event.
287
+ * - ctx: CoreContext — request context (account scope, request ID, principal).
222
288
  * @outputSpec Promise<void>
223
- * @sideEffects Database writes to update trigger last triggered time and count, RPC calls to increment trigger count, and audit log emissions.
289
+ * @sideEffects INSERTs pipeline_executions rows (status: queued),
290
+ * RPC record_trigger_fire per matching trigger,
291
+ * emits trigger.fired / trigger.failed audit events.
224
292
  * @stability stable
225
- * @calls adminDb.from('triggers'), adminDb.from('types'), runPipeline, emitAudit, adminDb.from('triggers').update
226
- * @errorHandling Logs errors to the console and emits audit logs for failures.
227
- * @cil-generated
228
293
  */
229
- // CIL[BEGIN stable_id="~/.framework/functions/_shared/trigger-engine.ts:checkandfiretriggers:function" type="function" identity="checkAndFireTriggers" parent="~/.framework/functions/_shared/trigger-engine.ts"]
230
- // CIL[DOC-MACRO purpose="checkAndFireTriggers function"]
231
- // CIL[DOC-RESPONSIBILITY responsibility="Implements the checkAndFireTriggers function."]
232
- // CIL[DOC-CONTEXT module=".framework/functions/_shared/trigger-engine.ts" layer="app-specific" related_files=""]
233
- // CIL[DOC-CONTRACT inputs="none" outputs="none" errors="none" side_effects="none"]
234
- // CIL[DOC-MICRO logic_summary="Implements the checkAndFireTriggers function." notable_decisions="[]"]
235
- // CIL[DOC-CONFIDENCE pass="first" score="0.8" flags="none"]
236
- // CIL[DOC-STABILITY stability="stable"]
237
294
  export async function checkAndFireTriggers(
238
295
  eventType: EventType,
239
296
  entityType: string,
240
297
  entityId: string,
241
298
  entityData: any,
242
- ctx: CoreContext
299
+ ctx: CoreContext,
300
+ previousEntityData?: any
243
301
  ): Promise<void> {
244
302
  const { data: triggers, error } = await adminDb
245
303
  .from('triggers')
@@ -270,11 +328,30 @@ export async function checkAndFireTriggers(
270
328
  for (const trigger of triggers) {
271
329
  try {
272
330
  const shouldFire = evaluateTriggerConditions(trigger, entityType, entityData)
273
-
331
+
274
332
  if (!shouldFire) {
275
333
  continue
276
334
  }
277
335
 
336
+ // Transition-only triggers (config.fire_on_transition): fire only when
337
+ // the record moves INTO the matching state. Without this, every
338
+ // update to an already-matching record re-fires the pipeline — e.g.
339
+ // each write to a published KB article re-dispatched auto_embed (3×
340
+ // per publish) and revoke fired on articles that never left
341
+ // 'published'. When no previous snapshot is available the trigger
342
+ // fires normally (conservative: matches pre-flag behavior).
343
+ if (trigger.config?.fire_on_transition && previousEntityData) {
344
+ const prevEntityData = {
345
+ ...previousEntityData,
346
+ // Same record → same type; the slug was resolved on the new
347
+ // snapshot only, so carry it over for config.type_slug checks.
348
+ type_slug: previousEntityData.type_slug ?? entityData?.type_slug
349
+ }
350
+ if (evaluateTriggerConditions(trigger, entityType, prevEntityData)) {
351
+ continue
352
+ }
353
+ }
354
+
278
355
  const triggerData = {
279
356
  event: eventType,
280
357
  entity_id: entityId,
@@ -290,15 +367,46 @@ export async function checkAndFireTriggers(
290
367
  fired_at: new Date().toISOString()
291
368
  }
292
369
 
293
- const result = await runPipeline(trigger.pipeline_id, triggerData, ctx)
294
-
295
- await adminDb
296
- .from('triggers')
297
- .update({
298
- last_triggered: new Date().toISOString(),
299
- trigger_count: adminDb.rpc('increment_trigger_count', { p_trigger_id: trigger.id })
370
+ // Reserve the execution row before dispatch so the audit trail has a
371
+ // stable execution_id even though the pipeline has not started yet.
372
+ // The background function claims this row (queued → running) via
373
+ // runPipeline's opts.executionId parameter. If dispatch fails, this
374
+ // row stays 'queued' and the sweeper marks it 'failed' after 10 min.
375
+ const { data: execRow } = await adminDb
376
+ .from('pipeline_executions')
377
+ .insert({
378
+ pipeline_id: trigger.pipeline_id,
379
+ status: 'queued',
380
+ trigger_data: triggerData,
381
+ account_id: ctx.accountId,
382
+ // Same guard as runPipeline's own insert: created_by is uuid-typed,
383
+ // non-UUID principals (machine ids like 'system', 'timer:*') → null.
384
+ created_by: ctx.principal?.id &&
385
+ /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i.test(ctx.principal.id)
386
+ ? ctx.principal.id : null,
387
+ started_at: new Date().toISOString(),
388
+ updated_at: new Date().toISOString()
300
389
  })
301
- .eq('id', trigger.id)
390
+ .select('id')
391
+ .single()
392
+
393
+ const executionId = execRow?.id ?? crypto.randomUUID()
394
+
395
+ await dispatchTriggerPipeline(trigger.pipeline_id, triggerData, ctx, {
396
+ executionId,
397
+ triggerId: trigger.id,
398
+ })
399
+
400
+ // Atomic bookkeeping: the previous update embedded an
401
+ // adminDb.rpc() Promise as a column value, so the whole write
402
+ // failed silently — trigger_count stayed 0 and last_triggered
403
+ // stayed null on every fire.
404
+ const { error: fireError } = await adminDb.rpc('record_trigger_fire', {
405
+ p_trigger_id: trigger.id
406
+ })
407
+ if (fireError) {
408
+ console.error(`[${ctx.requestId}] record_trigger_fire failed for ${trigger.id}:`, fireError.message)
409
+ }
302
410
 
303
411
  await emitAudit(ctx, 'trigger.fired', {
304
412
  type: 'trigger',
@@ -309,13 +417,13 @@ export async function checkAndFireTriggers(
309
417
  entity_type: entityType,
310
418
  entity_id: entityId,
311
419
  pipeline_id: trigger.pipeline_id,
312
- execution_id: result.executionId,
313
- execution_status: result.status
420
+ execution_id: executionId,
421
+ execution_status: 'queued'
314
422
  })
315
423
 
316
424
  } catch (error: any) {
317
425
  console.error(`[${ctx.requestId}] Trigger ${trigger.id} failed:`, error)
318
-
426
+
319
427
  await emitAudit(ctx, 'trigger.failed', {
320
428
  type: 'trigger',
321
429
  id: trigger.id,
@@ -602,10 +710,11 @@ export async function fireUpdateTriggers(
602
710
  entityType: string,
603
711
  entityId: string,
604
712
  entityData: any,
605
- ctx: CoreContext
713
+ ctx: CoreContext,
714
+ previousEntityData?: any
606
715
  ): Promise<void> {
607
716
  const eventType = `${eventNameFor(entityType)}_updated` as EventType
608
- await checkAndFireTriggers(eventType, entityType, entityId, entityData, ctx)
717
+ await checkAndFireTriggers(eventType, entityType, entityId, entityData, ctx, previousEntityData)
609
718
  }
610
719
  // CIL[END stable_id="~/.framework/functions/_shared/trigger-engine.ts:fireupdatetriggers:function"]
611
720
  // CIL[DOC-VECTOR type="context" content="purpose='Triggers an update event for a specified entity type and ID, allowing for associated actions to be executed.'; No verified external callers" status="ready"]
@@ -688,7 +688,11 @@ export const create = createHandler(async (ctx, body) => {
688
688
 
689
689
  const { data, error: err } = await ctx.db.from(entity).insert(raw).select().single()
690
690
  if (err) throw err
691
- fireCreateTriggers(entity, data.id, raw, ctx).catch(console.error)
691
+ // Await the trigger check: the heavy work already offloads to
692
+ // pipeline-runner-background, so this only adds eval + queue insert
693
+ // + dispatch. Detached post-response work is CPU-frozen on Netlify
694
+ // and silently dropped trigger fires in production.
695
+ await fireCreateTriggers(entity, data.id, raw, ctx).catch(console.error)
692
696
  return await sanitizeRecordData(ctx, data, entity)
693
697
  }
694
698
 
@@ -808,7 +812,7 @@ export const create = createHandler(async (ctx, body) => {
808
812
  if (err) throw err
809
813
 
810
814
  const entityData = { ...dataToInsert, id: data.id }
811
- fireCreateTriggers(entity, data.id, entityData, ctx).catch(console.error)
815
+ await fireCreateTriggers(entity, data.id, entityData, ctx).catch(console.error)
812
816
 
813
817
  return await sanitizeRecordData(ctx, data, entity)
814
818
  }
@@ -915,7 +919,9 @@ export const update = createHandler(async (ctx, body) => {
915
919
  if (err) throw err
916
920
 
917
921
  const entityData = { ...data, ...dataToUpdate }
918
- fireUpdateTriggers(entity, id, entityData, ctx).catch(console.error)
922
+ // Await the trigger check — see note in the create path. Detached
923
+ // post-response work is CPU-frozen on Netlify and drops trigger fires.
924
+ await fireUpdateTriggers(entity, id, entityData, ctx, existing).catch(console.error)
919
925
 
920
926
  return await sanitizeRecordData(ctx, data, entity)
921
927
  }
@@ -30,7 +30,7 @@ import { adminDb } from './_shared/db'
30
30
  import { emitLog } from './_shared/audit'
31
31
  import { isSystemAdmin } from './_shared/principal'
32
32
  import { PermissionEngine, sanitizeRecordData } from './_shared/permissions'
33
- import { runAgent, kickAgentEpisode } from './_shared/agent-runner'
33
+ import { runAgent, claimAgentEpisode, acquireEpisodeLease } from './_shared/agent-runner'
34
34
  import { checkRateLimit } from './_shared/rate-limit'
35
35
  import { detectInjection, emitSecurityEvent, getAppSecurityConfig } from './_shared/security'
36
36
  // CIL[END stable_id="~/.framework/functions/ai-agents.ts:module_header:module_header"]
@@ -479,20 +479,27 @@ export const run = createHandler(async (ctx, body) => {
479
479
  }
480
480
  }
481
481
 
482
+ // Episode mode runs inline up to this budget — the first turns execute
483
+ // at full request CPU, then the client's step loop continues the
484
+ // episode under the same lease. Sized to leave headroom under the
485
+ // platform's synchronous request limit (~26s) even with a slow turn.
486
+ const EPISODE_INLINE_BUDGET_MS = 8000
487
+
482
488
  const runMode = (mode as 'step' | 'complete' | 'episode') || 'complete'
483
489
 
484
- // Episode single-flight: the in-flight driver stamps driver_id +
485
- // driver_heartbeat_at into research_state. A different driver asking to
486
- // step/kick while the lease is fresh gets 'running' instead of a second
487
- // interleaved write pass. The owner itself is never refused (its own
488
- // staleness check is finer-grained), and a lease expires after
489
- // DRIVER_LEASE_MS so a crashed driver can't wedge the thread.
490
- const DRIVER_LEASE_MS = 60000
490
+ // Episode single-flight: arbitration is the atomic episode_leases RPC
491
+ // (migration 063) — a claim INSERTs/locks the thread's lease row in one
492
+ // statement, so two entry paths can never both pass. The same driver
493
+ // re-acquiring renews; a different live driver is refused 'running';
494
+ // an expired lease is taken over so a crashed driver can't wedge the
495
+ // thread. When the RPC is absent (un-migrated deployment) the legacy
496
+ // non-atomic research_state check below runs instead.
491
497
  const existingState = (data as any)?.data?.research_state
492
498
  const heartbeatAt = Date.parse(existingState?.driver_heartbeat_at || '')
493
499
  const leaseHolder = existingState?.driver_id
494
500
  const callerToken = (body?.driver_token as string) || null
495
501
  const callerId = callerToken || ctx.requestId
502
+ const DRIVER_LEASE_MS = 60000
496
503
  const leaseLive =
497
504
  !!leaseHolder &&
498
505
  leaseHolder !== callerId &&
@@ -501,32 +508,91 @@ export const run = createHandler(async (ctx, body) => {
501
508
  Number.isFinite(heartbeatAt) &&
502
509
  Date.now() - heartbeatAt < DRIVER_LEASE_MS
503
510
 
504
- if ((runMode === 'step' || runMode === 'complete') && leaseLive) {
505
- return { ok: true, thread_id, status: 'running', driver_id: leaseHolder }
506
- }
507
-
508
511
  if (runMode === 'episode') {
509
- if (leaseLive) {
510
- return { ok: true, thread_id, status: 'running', detached: true, already_running: true }
512
+ // Episode mode claims the lease and runs the first turns INLINE
513
+ // (deadline-bounded complete mode) — detached 'void runAgent' work is
514
+ // CPU-throttled after the response flush on serverless platforms
515
+ // (~37s dead window observed before its first persisted message),
516
+ // which is exactly the stall the caller's quiet-window then "rescues"
517
+ // by double-driving. stealDetached lets this request take over from
518
+ // a 'detached:' background kick instantly; the fenced driver aborts
519
+ // at its next heartbeat without side effects.
520
+ const kick = await claimAgentEpisode(thread_id, user_message || '', ctx, callerId, (data as any)?.data, { stealDetached: true })
521
+ if (!kick.claimed && kick.already_done) {
522
+ // Replay of an already-answered question — return the episode's
523
+ // existing final rather than 'running' so callers don't poll for
524
+ // work that isn't coming.
525
+ const { data: lastOutbound } = await adminDb
526
+ .from('messages')
527
+ .select('id, content, data')
528
+ .eq('thread_id', thread_id)
529
+ .eq('direction', 'outbound')
530
+ .order('created_at', { ascending: false })
531
+ .limit(1)
532
+ const final = Array.isArray(lastOutbound) ? lastOutbound[0] : lastOutbound
533
+ return {
534
+ ok: true,
535
+ thread_id,
536
+ status: 'complete',
537
+ already_done: true,
538
+ driver_id: kick.driver_id,
539
+ message_id: final?.id || null,
540
+ content: final?.content || '',
541
+ message_type: final?.data?.message_type || null,
542
+ data: final?.data || null,
543
+ agent_id: final?.data?.agent_id || (data as any)?.data?.agent_id || null,
544
+ prompt_config_id: final?.data?.prompt_config_id || (data as any)?.data?.prompt_config_id || null,
545
+ }
511
546
  }
512
- // Debit agent_step only when a new driver will actually start work —
513
- // lease-refused requests and ticket-creation kicks already debited or
514
- // started nothing, and counting them would double-charge each episode.
515
- await checkRateLimit(ctx, 'agent_step')
516
- // Claim the lease before detaching so a step request landing during
517
- // resolveAgentConfig still sees a live driver. Shared implementation lives
518
- // in kickAgentEpisode (also used by ticket-creation handlers).
519
- const leaseId = callerToken || ctx.requestId
520
- const kick = await kickAgentEpisode(thread_id, user_message || '', ctx, leaseId, (data as any)?.data)
521
547
  if (!kick.claimed) {
522
- return { ok: true, thread_id, status: 'running', detached: true, already_running: true }
548
+ return { ok: true, thread_id, status: 'running', already_running: true, driver_id: kick.driver_id }
549
+ }
550
+ // Debit agent_step only after a claim — refused requests start
551
+ // nothing and must not double-charge the episode.
552
+ await checkRateLimit(ctx, 'agent_step')
553
+ const message = await runAgent(thread_id, user_message || '', ctx, 'complete', kick.driver_id || callerToken || undefined, kick.lease_token, { deadlineMs: EPISODE_INLINE_BUDGET_MS })
554
+
555
+ const { data: updatedThread } = await adminDb
556
+ .from('threads')
557
+ .select('data')
558
+ .eq('id', thread_id)
559
+ .single()
560
+ const state = updatedThread?.data?.research_state || {}
561
+ return {
562
+ ok: true,
563
+ thread_id,
564
+ detached: false,
565
+ message_id: message?.id || null,
566
+ status: state.status || (message ? 'complete' : 'failed'),
567
+ turn_count: state.turn_count ?? 0,
568
+ content: message?.content || '',
569
+ message_type: message?.data?.message_type || null,
570
+ data: message?.data || null,
571
+ agent_id: message?.data?.agent_id || updatedThread?.data?.agent_id || null,
572
+ prompt_config_id: message?.data?.prompt_config_id || updatedThread?.data?.prompt_config_id || null,
523
573
  }
524
- return { ok: true, thread_id, status: 'running', detached: true }
525
574
  }
526
575
 
527
- // step/complete modes debit here — same rule: only when work will start.
576
+ // step/complete: claim the episode lease for this driver. 'held' means
577
+ // another live driver owns the episode — the caller watches persisted
578
+ // state instead of interleaving writes. A claimed lease survives a
579
+ // nonterminal step return so the same driver's next call renews it.
580
+ // stealDetached lets a request driver take over from a 'detached:'
581
+ // background kick without waiting out the TTL.
582
+ const claim = await acquireEpisodeLease(thread_id, ctx.accountId, callerId, undefined, true)
583
+ let leaseToken: string | undefined
584
+ if (claim?.outcome === 'held') {
585
+ return { ok: true, thread_id, status: 'running', driver_id: claim.holder_driver_id }
586
+ }
587
+ if (claim === null && leaseLive) {
588
+ return { ok: true, thread_id, status: 'running', driver_id: leaseHolder }
589
+ }
590
+ if (claim?.outcome === 'claimed') leaseToken = claim.lease_token
591
+
592
+ // step/complete modes debit here — only when work will start, so
593
+ // lease-refused requests never double-charge an episode.
528
594
  await checkRateLimit(ctx, 'agent_step')
529
- const message = await runAgent(thread_id, user_message || '', ctx, runMode, callerToken || undefined)
595
+ const message = await runAgent(thread_id, user_message || '', ctx, runMode, callerId, leaseToken)
530
596
 
531
597
  const { data: updatedThread } = await adminDb
532
598
  .from('threads')
@@ -0,0 +1,120 @@
1
+ // CIL[BEGIN stable_id="~/.framework/functions/pipeline-runner-background.ts:module_header:module_header" type="module_header" identity="module_header" parent="~/.framework/functions/pipeline-runner-background.ts"]
2
+ // CIL[DOC-MACRO purpose="Background entry point for event-triggered pipeline executions — receives dispatched payloads from checkAndFireTriggers and runs the pipeline in an isolated process with its own connection budget"]
3
+ // CIL[DOC-RESPONSIBILITY responsibility="Authenticate internal dispatch calls via INTERNAL_INVOKE_SECRET, reconstruct a machine RequestContext, and invoke runPipeline with the pre-created execution_id so the queued row transitions to running/completed rather than being duplicated"]
4
+ // CIL[DOC-CONTEXT module="pipeline-runner-background" layer="functions" related_files=".framework/functions/_shared/trigger-engine.ts,.framework/functions/_shared/pipeline-runner.ts,.framework/functions/_shared/principal.ts,.framework/functions/_shared/middleware.ts"]
5
+ // CIL[DOC-CONTRACT inputs="POST body { pipeline_id, trigger_data, account_id, execution_id, trigger_id } with X-Internal-Secret header" outputs="{ ok: true, execution_id } | { error }" errors="403 on missing/wrong secret, 400 on missing pipeline_id, 500 on pipeline failure" side_effects="Runs the named pipeline; updates pipeline_executions row from queued→running→terminal"]
6
+ // CIL[DOC-MICRO logic_summary="Validates the shared internal secret (timing-safe, same as principal.isInternalInvokeAuthorized), builds a SYSTEM_PRINCIPAL RequestContext carrying the original account scope, then calls runPipeline with the caller's pre-created execution_id. Status 'queued' → 'running' is handled inside runPipeline via the new opts.executionId parameter." notable_decisions="Does NOT use createHandler — it would resolve the secret to anonymous and 401 before reaching the handler. Auth is checked inline and SYSTEM_PRINCIPAL provides the machine context directly."]
7
+ // CIL[DOC-CONFIDENCE pass="first" score="0.9" flags="none"]
8
+ // CIL[DOC-STABILITY stability="stable"]
9
+ // CIL[DOC-AUDIENCE audience="framework"]
10
+
11
+ import { createHash, timingSafeEqual } from 'node:crypto'
12
+ import { runPipeline, sweepStaleExecutions } from './_shared/pipeline-runner'
13
+ import { SYSTEM_PRINCIPAL } from './_shared/principal'
14
+ import { adminDb } from './_shared/db'
15
+ // CIL[END stable_id="~/.framework/functions/pipeline-runner-background.ts:module_header:module_header"]
16
+
17
+
18
+ // CIL[BEGIN stable_id="~/.framework/functions/pipeline-runner-background.ts:isdispatchauthorized:function" type="function" identity="isDispatchAuthorized" parent="~/.framework/functions/pipeline-runner-background.ts"]
19
+ // CIL[DOC-MACRO purpose="Verify the X-Internal-Secret header using timing-safe comparison (mirrors principal.isInternalInvokeAuthorized)"]
20
+ // CIL[DOC-RESPONSIBILITY responsibility="Return true only when the request carries a valid INTERNAL_INVOKE_SECRET"]
21
+ // CIL[DOC-CONTEXT module="pipeline-runner-background" layer="functions" related_files=".framework/functions/_shared/principal.ts"]
22
+ // CIL[DOC-CONTRACT inputs="event headers" outputs="boolean" errors="none" side_effects="none"]
23
+ // CIL[DOC-MICRO logic_summary="SHA-256 both sides before timingSafeEqual so early-exit on length mismatch cannot leak the expected secret length via response timing — identical pattern to principal.isInternalInvokeAuthorized" notable_decisions="duplicated here rather than imported because isInternalInvokeAuthorized is unexported from principal.ts"]
24
+ // CIL[DOC-CONFIDENCE pass="first" score="0.9" flags="none"]
25
+ // CIL[DOC-STABILITY stability="stable"]
26
+ function isDispatchAuthorized(event: any): boolean {
27
+ const expected = (globalThis as any).process?.env?.INTERNAL_INVOKE_SECRET
28
+ if (!expected) return false
29
+
30
+ const provided = event.headers?.['x-internal-secret'] || event.headers?.['X-Internal-Secret']
31
+ if (!provided || typeof provided !== 'string') return false
32
+
33
+ const expectedHash = createHash('sha256').update(expected).digest()
34
+ const providedHash = createHash('sha256').update(provided).digest()
35
+
36
+ try {
37
+ return timingSafeEqual(expectedHash, providedHash)
38
+ } catch {
39
+ return false
40
+ }
41
+ }
42
+ // CIL[END stable_id="~/.framework/functions/pipeline-runner-background.ts:isdispatchauthorized:function"]
43
+
44
+
45
+ // CIL[BEGIN stable_id="~/.framework/functions/pipeline-runner-background.ts:handler:constant" type="constant" identity="handler" parent="~/.framework/functions/pipeline-runner-background.ts"]
46
+ // CIL[DOC-MACRO purpose="Netlify function handler for dispatched pipeline executions"]
47
+ // CIL[DOC-RESPONSIBILITY responsibility="Authenticate, sweep stale executions, then runPipeline with the caller's pre-created execution_id"]
48
+ // CIL[DOC-CONTEXT module="pipeline-runner-background" layer="functions" related_files=".framework/functions/_shared/pipeline-runner.ts"]
49
+ // CIL[DOC-CONTRACT inputs="POST body { pipeline_id, trigger_data, account_id, execution_id, trigger_id }" outputs="{ ok, execution_id, status } | { error }" errors="403 unauthorized, 400 missing pipeline_id" side_effects="Pipeline runs; pipeline_executions transitions queued→running→terminal"]
50
+ // CIL[DOC-MICRO logic_summary="Reject without INTERNAL_INVOKE_SECRET; build SYSTEM_PRINCIPAL ctx scoped to the dispatching account; runPipeline with opts.executionId so the pre-created row is claimed rather than duplicated; return the final ExecutionResult" notable_decisions="sweepStaleExecutions is called opportunistically on entry — same fire-and-forget pattern as runPipeline itself, but on a fresh process it costs nothing"]
51
+ // CIL[DOC-CONFIDENCE pass="first" score="0.9" flags="none"]
52
+ // CIL[DOC-STABILITY stability="stable"]
53
+ export const handler = async (event: any, _context: any) => {
54
+ const requestId = crypto.randomUUID()
55
+
56
+ if (!isDispatchAuthorized(event)) {
57
+ return {
58
+ statusCode: 403,
59
+ body: JSON.stringify({ error: 'Forbidden — invalid internal secret' }),
60
+ }
61
+ }
62
+
63
+ let body: any = {}
64
+ try {
65
+ body = typeof event.body === 'string' ? JSON.parse(event.body || '{}') : (event.body || {})
66
+ } catch {
67
+ return { statusCode: 400, body: JSON.stringify({ error: 'Invalid JSON body' }) }
68
+ }
69
+
70
+ const { pipeline_id, trigger_data, account_id, execution_id, trigger_id } = body
71
+
72
+ if (!pipeline_id) {
73
+ return { statusCode: 400, body: JSON.stringify({ error: 'pipeline_id is required' }) }
74
+ }
75
+
76
+ // Opportunistic sweep — picks up any executions orphaned by a prior dead
77
+ // invocation before this one starts its own work.
78
+ void sweepStaleExecutions().catch(() => {})
79
+
80
+ const ctx = {
81
+ requestId,
82
+ principal: {
83
+ ...SYSTEM_PRINCIPAL,
84
+ provenance: {
85
+ ...SYSTEM_PRINCIPAL.provenance,
86
+ sourceType: 'trigger' as const,
87
+ invokedAt: new Date().toISOString(),
88
+ triggerId: trigger_id || null,
89
+ },
90
+ },
91
+ db: adminDb,
92
+ accountId: account_id || null,
93
+ appId: null,
94
+ query: {},
95
+ requestPath: '/.netlify/functions/pipeline-runner-background',
96
+ headers: event.headers || {},
97
+ }
98
+
99
+ try {
100
+ const result = await runPipeline(pipeline_id, trigger_data || {}, ctx as any, {
101
+ executionId: execution_id,
102
+ })
103
+
104
+ return {
105
+ statusCode: 200,
106
+ body: JSON.stringify({
107
+ ok: result.status === 'completed',
108
+ execution_id: result.executionId,
109
+ status: result.status,
110
+ }),
111
+ }
112
+ } catch (err: any) {
113
+ console.error(`[${requestId}] pipeline-runner-background failed for pipeline ${pipeline_id}:`, err)
114
+ return {
115
+ statusCode: 500,
116
+ body: JSON.stringify({ error: err.message || 'Pipeline execution failed', execution_id }),
117
+ }
118
+ }
119
+ }
120
+ // CIL[END stable_id="~/.framework/functions/pipeline-runner-background.ts:handler:constant"]
@@ -10,7 +10,7 @@
10
10
  import { createHandler, RequestContext } from './_shared/middleware'
11
11
  import { adminDb } from './_shared/db'
12
12
  import { emitAudit } from './_shared/audit'
13
- import { runPipeline } from './_shared/pipeline-runner'
13
+ import { runPipeline, sweepStaleExecutions } from './_shared/pipeline-runner'
14
14
  import { retryPendingDeliveries } from './_shared/retry-deliveries'
15
15
  // CIL[DOC-VECTOR type="context" content="purpose='Imports shared middleware, database, audit, pipeline and retry helpers for the system-cron Netlify function.'; No verified external callers; imports: .framework/functions/_shared/middleware.ts,.framework/functions/_shared/db.ts,.framework/functions/_shared/audit.ts"]
16
16
  // CIL[DOC-VECTOR type="blast_radius" content="No direct caller data from CIL graph; local impact only"]
@@ -77,6 +77,15 @@ export const handler = createHandler(async (ctx: RequestContext) => {
77
77
  durationMs: number
78
78
  }> = []
79
79
 
80
+ // Reap executions whose runner died mid-flight (detached process killed,
81
+ // function timeout, dispatched trigger never started). Runs every minute
82
+ // regardless of whether schedules/timers are due this tick.
83
+ try {
84
+ await sweepStaleExecutions()
85
+ } catch (sweepError) {
86
+ console.warn('[system-cron] stale-execution sweep failed:', sweepError)
87
+ }
88
+
80
89
  try {
81
90
  const { data: dueSchedules, error: schedulesError } = await adminDb.rpc('get_due_schedules', {
82
91
  p_now: new Date().toISOString()