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
|
@@ -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="
|
|
207
|
-
// CIL[DOC-RESPONSIBILITY responsibility="
|
|
208
|
-
// CIL[DOC-CONTEXT module="trigger-engine" layer="
|
|
209
|
-
// CIL[DOC-CONTRACT inputs="
|
|
210
|
-
// CIL[DOC-MICRO logic_summary="
|
|
211
|
-
// CIL[DOC-CONFIDENCE pass="
|
|
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
|
-
*
|
|
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
|
|
218
|
-
* - entityType: string
|
|
219
|
-
* - entityId: string
|
|
220
|
-
* - entityData: any
|
|
221
|
-
* - ctx: CoreContext
|
|
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
|
|
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
|
-
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
|
|
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
|
-
.
|
|
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:
|
|
313
|
-
execution_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
|
-
|
|
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
|
-
|
|
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,
|
|
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:
|
|
485
|
-
//
|
|
486
|
-
//
|
|
487
|
-
//
|
|
488
|
-
//
|
|
489
|
-
//
|
|
490
|
-
|
|
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
|
-
|
|
510
|
-
|
|
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',
|
|
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
|
|
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,
|
|
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()
|