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
@@ -93,6 +93,13 @@ function resolveQuery(q: QueryLog, opts: any): any {
93
93
  if (q.table === 'items') {
94
94
  if (has('update')) return { data: null, error: null }
95
95
  if (has('or')) return { data: opts.staleItems ?? [], error: null }
96
+ // Per-id point reads: a superseded doc has already been archived by
97
+ // the time kbRevoke's execution-time status fence re-reads it.
98
+ const idEq = q.ops.find(([n, a]) => n === 'eq' && a?.[0] === 'id')?.[1]?.[1]
99
+ if (idEq && idEq !== ITEM_ID) {
100
+ const stale = (opts.staleItems ?? []).find((s: any) => s.id === idEq)
101
+ if (stale) return { data: { ...stale, status: 'archived' }, error: null }
102
+ }
96
103
  return { data: opts.record ?? null, error: null }
97
104
  }
98
105
  if (q.table === 'types') {
@@ -273,6 +280,18 @@ describe('kbRevoke — lease clearing', () => {
273
280
  expect(leaseDelete.ops.find(([n]) => n === 'eq')?.[1]).toEqual(['document_id', ITEM_ID])
274
281
  })
275
282
 
283
+ it('skips revocation when the document is currently published — stale queued work fence', async () => {
284
+ const queries = setupDb({ record: makeRecord({ status: 'published' }) })
285
+ const rpcCalls = setupRpc({})
286
+
287
+ const res = await kbRevoke(ctx, { id: ITEM_ID })
288
+
289
+ expect(res).toMatchObject({ ok: true, skipped: true, count: 0 })
290
+ expect(rpcNamed(rpcCalls, 'revoke_embeddings_for_item')).toHaveLength(0)
291
+ expect(queriesWithOp(queries, 'embeddings', 'delete')).toHaveLength(0)
292
+ expect(queriesWithOp(queries, 'document_leases', 'delete')).toHaveLength(0)
293
+ })
294
+
276
295
  it('non-published ingest routes through kbRevoke and clears the lease', async () => {
277
296
  const queries = setupDb({ record: makeRecord({ status: 'draft' }) })
278
297
  setupRpc({})
@@ -0,0 +1,120 @@
1
+ // CIL[BEGIN stable_id="~/.framework/tests/unit/pipeline-runner-background.test.ts:module_header:module_header" type="module_header" identity="module_header" parent="none"]
2
+ // CIL[DOC-MACRO purpose="Unit tests for the pipeline-runner-background dispatch function"]
3
+ // CIL[DOC-RESPONSIBILITY responsibility="Verify auth gating, payload validation, and correct runPipeline invocation"]
4
+ // CIL[DOC-CONTEXT module="tests/unit/pipeline-runner-background" layer="test-unit" related_files=".framework/functions/pipeline-runner-background.ts,.framework/functions/_shared/pipeline-runner.ts,.framework/functions/_shared/db.ts"]
5
+ // CIL[DOC-CONTRACT inputs="none" outputs="test assertions" errors="none" side_effects="Mocks pipeline-runner and db modules"]
6
+ // CIL[DOC-MICRO logic_summary="Stubs INTERNAL_INVOKE_SECRET, mocks runPipeline and sweepStaleExecutions, exercises the raw handler function" notable_decisions="[]"]
7
+ // CIL[DOC-CONFIDENCE pass="first" score="0.9" flags="none"]
8
+ // CIL[DOC-STABILITY stability="stable"]
9
+ import { describe, it, expect, vi, beforeEach } from 'vitest'
10
+
11
+ vi.mock('../../functions/_shared/db.ts', () => ({
12
+ adminDb: { from: vi.fn(), rpc: vi.fn() }
13
+ }))
14
+
15
+ vi.mock('../../functions/_shared/pipeline-runner.ts', () => ({
16
+ runPipeline: vi.fn(),
17
+ sweepStaleExecutions: vi.fn().mockResolvedValue(undefined)
18
+ }))
19
+
20
+ vi.mock('../../functions/_shared/audit.ts', () => ({
21
+ emitAudit: vi.fn().mockResolvedValue(undefined),
22
+ emitSpan: vi.fn().mockResolvedValue(undefined),
23
+ emitLog: vi.fn().mockResolvedValue(undefined)
24
+ }))
25
+
26
+ import { handler } from '../../functions/pipeline-runner-background.ts'
27
+ import { runPipeline } from '../../functions/_shared/pipeline-runner.ts'
28
+ // CIL[END stable_id="~/.framework/tests/unit/pipeline-runner-background.test.ts:module_header:module_header"]
29
+
30
+ function makeEvent(body: any, secret = 'test-secret'): any {
31
+ return {
32
+ httpMethod: 'POST',
33
+ path: '/.netlify/functions/pipeline-runner-background',
34
+ headers: { 'x-internal-secret': secret },
35
+ body: JSON.stringify(body),
36
+ queryStringParameters: {}
37
+ }
38
+ }
39
+
40
+ describe('pipeline-runner-background handler', () => {
41
+ beforeEach(() => {
42
+ vi.clearAllMocks()
43
+ vi.stubEnv('INTERNAL_INVOKE_SECRET', 'test-secret')
44
+ vi.mocked(runPipeline).mockResolvedValue({
45
+ executionId: 'exec-1',
46
+ pipelineId: 'pipe-1',
47
+ status: 'completed',
48
+ stages: [],
49
+ durationMs: 5
50
+ })
51
+ })
52
+
53
+ it('returns 403 when the internal secret is wrong', async () => {
54
+ const res = await handler(makeEvent({ pipeline_id: 'pipe-1' }, 'wrong-secret'), {})
55
+ expect(res.statusCode).toBe(403)
56
+ expect(runPipeline).not.toHaveBeenCalled()
57
+ })
58
+
59
+ it('returns 403 when the internal secret is absent', async () => {
60
+ const event = makeEvent({ pipeline_id: 'pipe-1' })
61
+ delete event.headers['x-internal-secret']
62
+ const res = await handler(event, {})
63
+ expect(res.statusCode).toBe(403)
64
+ expect(runPipeline).not.toHaveBeenCalled()
65
+ })
66
+
67
+ it('returns 400 when pipeline_id is missing from the body', async () => {
68
+ const res = await handler(makeEvent({ trigger_data: {}, account_id: 'acct-1' }), {})
69
+ expect(res.statusCode).toBe(400)
70
+ expect(runPipeline).not.toHaveBeenCalled()
71
+ })
72
+
73
+ it('calls runPipeline with the dispatched pipeline_id and execution_id', async () => {
74
+ const payload = {
75
+ pipeline_id: 'pipe-42',
76
+ trigger_data: { event: 'item_created', entity_id: 'item-9' },
77
+ account_id: 'acct-7',
78
+ execution_id: 'exec-queued-99',
79
+ trigger_id: 'trig-3'
80
+ }
81
+ const res = await handler(makeEvent(payload), {})
82
+
83
+ expect(runPipeline).toHaveBeenCalledOnce()
84
+ const [calledPipelineId, calledTriggerData, calledCtx, calledOpts] =
85
+ vi.mocked(runPipeline).mock.calls[0]
86
+ expect(calledPipelineId).toBe('pipe-42')
87
+ expect(calledTriggerData).toMatchObject({ event: 'item_created', entity_id: 'item-9' })
88
+ expect(calledOpts?.executionId).toBe('exec-queued-99')
89
+ expect(calledCtx.accountId).toBe('acct-7')
90
+
91
+ const body = JSON.parse(res.body)
92
+ expect(body.ok).toBe(true)
93
+ expect(body.execution_id).toBe('exec-1')
94
+ expect(body.status).toBe('completed')
95
+ })
96
+
97
+ it('returns ok:false when the pipeline itself reports failure', async () => {
98
+ vi.mocked(runPipeline).mockResolvedValue({
99
+ executionId: 'exec-1',
100
+ pipelineId: 'pipe-1',
101
+ status: 'failed',
102
+ stages: [],
103
+ durationMs: 5,
104
+ error: 'stage exploded'
105
+ })
106
+ const res = await handler(makeEvent({ pipeline_id: 'pipe-1', execution_id: 'exec-1' }), {})
107
+ const body = JSON.parse(res.body)
108
+ expect(body.ok).toBe(false)
109
+ expect(body.status).toBe('failed')
110
+ })
111
+
112
+ it('returns 500 when runPipeline throws', async () => {
113
+ vi.mocked(runPipeline).mockRejectedValue(new Error('Pipeline not found'))
114
+ const res = await handler(makeEvent({ pipeline_id: 'pipe-1', execution_id: 'exec-1' }), {})
115
+ expect(res.statusCode).toBe(500)
116
+ const body = JSON.parse(res.body)
117
+ expect(body.error).toBe('Pipeline not found')
118
+ expect(body.execution_id).toBe('exec-1')
119
+ })
120
+ })
@@ -30,7 +30,7 @@ vi.mock('../../functions/ai-interactions.ts', () => ({
30
30
 
31
31
  import { adminDb } from '../../functions/_shared/db.ts'
32
32
  import { emitAudit } from '../../functions/_shared/audit.ts'
33
- import { runPipeline } from '../../functions/_shared/pipeline-runner.ts'
33
+ import { runPipeline, sweepStaleExecutions } from '../../functions/_shared/pipeline-runner.ts'
34
34
  // CIL[DOC-VECTOR type="context" content="purpose='Test setup and imports for pipeline runner tests'; No verified external callers; imports: .framework/functions/_shared/middleware.ts,.framework/functions/_shared/db.ts,.framework/functions/_shared/audit.ts"]
35
35
  // CIL[DOC-VECTOR type="blast_radius" content="No direct caller data from CIL graph; local impact only"]
36
36
  // CIL[DOC-VECTOR type="configuration" content="No environment or build configuration dependencies identified"]
@@ -276,6 +276,105 @@ describe('runPipeline — lifecycle', () => {
276
276
  expect.objectContaining({ pipeline_id: 'pipe-1' })
277
277
  )
278
278
  })
279
+
280
+ it('claims a pre-created queued execution row when opts.executionId is supplied', async () => {
281
+ mockPipeline([])
282
+ const PRE_CREATED_ID = 'exec-pre-created-1'
283
+ const updateSpy = vi.fn().mockReturnThis()
284
+ const eqSpy = vi.fn().mockReturnThis()
285
+ const claimSelectSpy = vi.fn().mockResolvedValue({ data: [{ id: PRE_CREATED_ID }], error: null })
286
+ const claimChain = { update: updateSpy, eq: eqSpy, select: claimSelectSpy, insert: vi.fn().mockReturnThis() }
287
+ vi.mocked(adminDb.from).mockImplementation((table: string) => {
288
+ if (table === 'pipelines') {
289
+ return {
290
+ select: vi.fn().mockReturnThis(),
291
+ eq: vi.fn().mockReturnThis(),
292
+ single: vi.fn().mockResolvedValue({ data: { id: 'pipe-1', stages: [], is_active: true }, error: null })
293
+ } as any
294
+ }
295
+ if (table === 'pipeline_executions') return claimChain as any
296
+ return {
297
+ select: vi.fn().mockReturnThis(),
298
+ insert: vi.fn().mockReturnThis(),
299
+ update: vi.fn().mockReturnThis(),
300
+ eq: vi.fn().mockReturnThis(),
301
+ single: vi.fn().mockResolvedValue({ data: null, error: null })
302
+ } as any
303
+ })
304
+
305
+ const result = await runPipeline('pipe-1', { x: 1 }, makeCtx(), { executionId: PRE_CREATED_ID })
306
+ expect(result.executionId).toBe(PRE_CREATED_ID)
307
+ expect(result.status).toBe('completed')
308
+ // The claim path must call update, not insert.
309
+ expect(updateSpy).toHaveBeenCalledWith(
310
+ expect.objectContaining({ status: 'running' })
311
+ )
312
+ expect(eqSpy).toHaveBeenCalledWith('id', PRE_CREATED_ID)
313
+ expect(eqSpy).toHaveBeenCalledWith('status', 'queued')
314
+ // and it must NOT have inserted a fresh row
315
+ expect(claimChain.insert).not.toHaveBeenCalled()
316
+ })
317
+
318
+ it('inserts with the same id when the queued row is absent (fallback path)', async () => {
319
+ const PRE_CREATED_ID = 'exec-missing-1'
320
+ const insertSpy = vi.fn().mockReturnThis()
321
+ const singleSpy = vi.fn().mockResolvedValue({ data: { id: PRE_CREATED_ID }, error: null })
322
+ vi.mocked(adminDb.from).mockImplementation((table: string) => {
323
+ if (table === 'pipelines') {
324
+ return {
325
+ select: vi.fn().mockReturnThis(),
326
+ eq: vi.fn().mockReturnThis(),
327
+ single: vi.fn().mockResolvedValue({ data: { id: 'pipe-1', stages: [], is_active: true }, error: null })
328
+ } as any
329
+ }
330
+ if (table === 'pipeline_executions') {
331
+ const chain: any = {
332
+ update: vi.fn().mockReturnThis(),
333
+ eq: vi.fn().mockReturnThis(),
334
+ select: vi.fn().mockResolvedValue({ data: [], error: null }), // claim finds no rows
335
+ insert: insertSpy,
336
+ single: singleSpy
337
+ }
338
+ // insert().select().single() — select must chain to an object with single
339
+ insertSpy.mockImplementation(() => ({
340
+ select: vi.fn().mockImplementation(() => ({ single: singleSpy }))
341
+ }))
342
+ return chain
343
+ }
344
+ return {
345
+ select: vi.fn().mockReturnThis(),
346
+ update: vi.fn().mockReturnThis(),
347
+ eq: vi.fn().mockReturnThis(),
348
+ single: vi.fn().mockResolvedValue({ data: null, error: null })
349
+ } as any
350
+ })
351
+
352
+ const result = await runPipeline('pipe-1', {}, makeCtx(), { executionId: PRE_CREATED_ID })
353
+ expect(result.executionId).toBe(PRE_CREATED_ID)
354
+ expect(insertSpy).toHaveBeenCalledWith(
355
+ expect.objectContaining({ id: PRE_CREATED_ID, status: 'running' })
356
+ )
357
+ })
358
+
359
+ it('throws when claiming a queued execution row fails', async () => {
360
+ vi.mocked(adminDb.from).mockImplementation((table: string) => {
361
+ if (table === 'pipelines') {
362
+ return {
363
+ select: vi.fn().mockReturnThis(),
364
+ eq: vi.fn().mockReturnThis(),
365
+ single: vi.fn().mockResolvedValue({ data: { id: 'pipe-1', stages: [], is_active: true }, error: null })
366
+ } as any
367
+ }
368
+ return {
369
+ update: vi.fn().mockReturnThis(),
370
+ eq: vi.fn().mockReturnThis(),
371
+ select: vi.fn().mockResolvedValue({ data: null, error: { message: 'row not found' } })
372
+ } as any
373
+ })
374
+ await expect(
375
+ runPipeline('pipe-1', {}, makeCtx(), { executionId: 'missing-exec' })
376
+ ).rejects.toThrow('Failed to claim queued execution missing-exec')
377
+ })
279
378
  })
280
379
  // CIL[DOC-VECTOR type="context" content="purpose='Test runPipeline lifecycle and error handling'; No verified external callers; imports: .framework/functions/_shared/middleware.ts,.framework/functions/_shared/db.ts,.framework/functions/_shared/audit.ts"]
281
380
  // CIL[DOC-VECTOR type="blast_radius" content="No direct caller data from CIL graph; local impact only"]
@@ -920,3 +1019,56 @@ describe('send_email — recipient resolution + outbound send', () => {
920
1019
  expect(result.stages[0].output.skipped).toMatch(/not configured/)
921
1020
  })
922
1021
  })
1022
+
1023
+ // CIL[BEGIN stable_id="~/.framework/tests/unit/pipeline-runner.test.ts:describe_sweepStaleExecutions:block" type="block" identity="describe_sweepStaleExecutions" parent="none"]
1024
+ // CIL[DOC-MACRO purpose="Test stale execution sweeper"]
1025
+ // CIL[DOC-RESPONSIBILITY responsibility="Verify sweepStaleExecutions marks stale rows failed and leaves fresh rows alone"]
1026
+ // CIL[DOC-CONTEXT module="tests/unit/pipeline-runner" layer="test-unit" related_files=".framework/functions/_shared/db.ts,.framework/functions/_shared/pipeline-runner.ts"]
1027
+ // CIL[DOC-CONTRACT inputs="none" outputs="test assertions" errors="none" side_effects="Tests sweepStaleExecutions"]
1028
+ // CIL[DOC-MICRO logic_summary="Mocks adminDb.from('pipeline_executions').update chain and verifies the correct statuses and cutoff are targeted" notable_decisions="[]"]
1029
+ // CIL[DOC-CONFIDENCE pass="first" score="0.9" flags="none"]
1030
+ // CIL[DOC-STABILITY stability="stable"]
1031
+ describe('sweepStaleExecutions', () => {
1032
+ beforeEach(() => vi.clearAllMocks())
1033
+
1034
+ it('marks running/pending/queued rows older than the stale window as failed', async () => {
1035
+ const updateSpy = vi.fn().mockReturnThis()
1036
+ const inSpy = vi.fn().mockReturnThis()
1037
+ const ltSpy = vi.fn().mockResolvedValue({ error: null })
1038
+ vi.mocked(adminDb.from).mockReturnValue({
1039
+ update: updateSpy,
1040
+ in: inSpy,
1041
+ lt: ltSpy
1042
+ } as any)
1043
+
1044
+ await sweepStaleExecutions()
1045
+
1046
+ expect(adminDb.from).toHaveBeenCalledWith('pipeline_executions')
1047
+ expect(updateSpy).toHaveBeenCalledWith(
1048
+ expect.objectContaining({ status: 'failed' })
1049
+ )
1050
+ expect(inSpy).toHaveBeenCalledWith('status', ['running', 'pending', 'queued'])
1051
+ // lt cutoff must be ~10 minutes ago
1052
+ const cutoffArg = ltSpy.mock.calls[0][1] as string
1053
+ const cutoffMs = new Date(cutoffArg).getTime()
1054
+ const expectedMs = Date.now() - 10 * 60 * 1000
1055
+ expect(Math.abs(cutoffMs - expectedMs)).toBeLessThan(5000)
1056
+ })
1057
+
1058
+ it('logs a warning but does not throw when the sweep update fails', async () => {
1059
+ const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {})
1060
+ vi.mocked(adminDb.from).mockReturnValue({
1061
+ update: vi.fn().mockReturnThis(),
1062
+ in: vi.fn().mockReturnThis(),
1063
+ lt: vi.fn().mockResolvedValue({ error: { message: 'DB gone' } })
1064
+ } as any)
1065
+
1066
+ await expect(sweepStaleExecutions()).resolves.toBeUndefined()
1067
+ expect(warnSpy).toHaveBeenCalledWith(
1068
+ expect.stringContaining('pipeline_executions stale sweep failed'),
1069
+ 'DB gone'
1070
+ )
1071
+ warnSpy.mockRestore()
1072
+ })
1073
+ })
1074
+ // CIL[END stable_id="~/.framework/tests/unit/pipeline-runner.test.ts:describe_sweepStaleExecutions:block"]
@@ -135,4 +135,19 @@ describe('resolvePrincipal human cache — mid-registration guard', () => {
135
135
  expect(mockPeopleSingle).toHaveBeenCalledTimes(1)
136
136
  })
137
137
  })
138
+
139
+ describe('internal invoke secret', () => {
140
+ it('resolves a bare x-internal-secret to the system principal', async () => {
141
+ vi.stubEnv('INTERNAL_INVOKE_SECRET', 'test-secret')
142
+ const p = await resolvePrincipal({ headers: { 'x-internal-secret': 'test-secret' } })
143
+ expect(p.id).toBe('system')
144
+ expect(p.machineType).toBe('internal')
145
+ })
146
+
147
+ it('does not trust a wrong secret', async () => {
148
+ vi.stubEnv('INTERNAL_INVOKE_SECRET', 'test-secret')
149
+ const p = await resolvePrincipal({ headers: { 'x-internal-secret': 'wrong' } })
150
+ expect(p.id).toBe('anonymous')
151
+ })
152
+ })
138
153
  // CIL[END stable_id="~/.framework/tests/unit/principal-cache.test.ts:module_header:module_header"]