spine-framework 0.4.2 → 0.4.4
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- 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
|
@@ -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"]
|