Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion .github/workflows/_verify.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@
# - pull-request.yaml, where it gates every pull request (including forks;
# see the security notes there - this workflow uses no secrets and only
# ever needs `contents: read`)
# - publish-ghcr-platform.yaml, where it gates image builds on `next`
# - publish-ghcr-platform.yaml, where it gates image publication on `next`
# pushes, which land directly without a pull request

name: Verify
Expand Down
31 changes: 19 additions & 12 deletions .github/workflows/publish-ghcr-platform.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -71,11 +71,14 @@ jobs:
fi

# @note `next` receives direct pushes, so nothing has vetted the code yet -
# run the quality gate before spending build minutes on it. It runs on
# every next push, not just build-relevant ones, so the promotion pull
# request always finds a verify check on the head SHA. `main` only moves
# through pull requests that already passed the same gate, so the job is
# skipped there and the build starts immediately.
# the quality gate runs alongside the image build and blocks publication,
# not the build itself: build only pushes untagged per-architecture digests,
# so nothing a consumer can reach exists until publish tags them. Running
# both in parallel halves the wall-clock at the cost of build minutes spent
# on a red gate. It runs on every next push, not just build-relevant ones,
# so the promotion pull request always finds a verify check on the head
# SHA. `main` only moves through pull requests that already passed the same
# gate, so the job is skipped there.
verify:
if: >-
(github.repository == 'chatbotkit/platform' || startsWith(github.repository, 'chatbotkit/platform-')) &&
Expand All @@ -86,14 +89,12 @@ jobs:
build:
needs:
- changes
- verify
# @note !cancelled() + accepting the skipped verify keeps main builds
# running while a failed gate on next still blocks them
# @note deliberately not gated on verify: the two run in parallel and the
# gate is applied at publish, the only job that makes an image reachable
if: >-
!cancelled() &&
(github.repository == 'chatbotkit/platform' || startsWith(github.repository, 'chatbotkit/platform-')) &&
needs.changes.outputs.build == 'true' &&
(needs.verify.result == 'success' || needs.verify.result == 'skipped') &&
github.actor != 'github-actions[bot]' &&
(github.ref == 'refs/heads/main' || github.ref == 'refs/heads/next')
name: Build ${{ matrix.flavor.name }} (${{ matrix.architecture.name }})
Expand Down Expand Up @@ -278,16 +279,22 @@ jobs:
retention-days: 1

publish:
# @note !cancelled() is required: the implicit success() looks at the
# whole needs chain, and verify is skipped on main
# @note this is where the quality gate bites: a failed verify on next
# leaves the build's untagged digests unreachable in the registry and
# publishes nothing. !cancelled() + accepting the skipped verify is
# required: the implicit success() looks at the whole needs chain, and
# verify is skipped on main
if: >-
!cancelled() &&
(github.repository == 'chatbotkit/platform' || startsWith(github.repository, 'chatbotkit/platform-')) &&
needs.build.result == 'success' &&
(needs.verify.result == 'success' || needs.verify.result == 'skipped') &&
github.actor != 'github-actions[bot]' &&
(github.ref == 'refs/heads/main' || github.ref == 'refs/heads/next')
name: Publish ${{ matrix.flavor.name }}
needs: build
needs:
- build
- verify
runs-on: ${{ matrix.flavor.runner }}
strategy:
fail-fast: false
Expand Down
14 changes: 14 additions & 0 deletions docker/Dockerfile
Original file line number Diff line number Diff line change
Expand Up @@ -173,10 +173,24 @@ RUN pnpm --filter @chatbotkit-dev/db deploy --legacy /initializer

FROM node:24.20.0-bookworm-slim AS initializer

# @note Prisma picks its schema-engine binary by the libssl it detects. The
# deployer stage has openssl (a ca-certificates dependency) and so bundles the
# openssl-3.0.x engine; without libssl here the CLI assumes 1.1.x and tries to
# download it at first boot, which fails on hosts without outbound DNS
RUN apt-get update \
&& apt-get install -y --no-install-recommends openssl \
&& rm -rf /var/lib/apt/lists/*

WORKDIR /app

COPY --from=initializer-deployer /initializer ./

# @note the engine must come from the image, never from the network: resolve
# it with the download mirror unreachable so a bundled/detected engine mismatch
# fails the build here rather than at first boot. Provider-agnostic on purpose
# - the db module behind this stage need not be SQLite
RUN PRISMA_ENGINES_MIRROR=http://127.0.0.1:9 npx prisma --version

# @note the Garage provisioning script rides along so the distribution stack
# (docker/distro/*) can run it from this image instead of bind-mounting the
# checkout - a published Compose OCI artifact cannot carry bind mounts
Expand Down
20 changes: 18 additions & 2 deletions packages/sandbox/src/mount.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,8 @@
// 64KB `pread` slices, a truncating `writeFile` before the real one - is what
// the caches exist for.

import { jest } from '@jest/globals'

import { AgentOs } from '@rivet-dev/agentos-core'

import { createStorageDriver } from './mount.ts'
Expand All @@ -20,6 +22,15 @@ const PERMISSIONS = {
network: 'allow',
}

// @note generous because the runtime, not the driver, sets the pace: at this
// version a stalled pipe holds a command for its ten-second blocking-read
// limit and a process started meanwhile wedges the VM, so one slow step on a
// CI runner cascades into the tests after it. @todo once
// rivet-dev/agentos#1959 is fixed in a pinned release, bring this back to the
// default and put the `| sort` back into the nested-directories test.

jest.setTimeout(120_000)

let store
let vm

Expand Down Expand Up @@ -85,9 +96,14 @@ describe('reading', () => {
})

it('walks nested directories', async () => {
const { out } = await sh('find /space -type f | sort')
// @note no `| sort`: a pipe between two guest commands stalls for the
// runtime's blocking-read limit at this version (rivet-dev/agentos#1959),
// and a second process started while it stalls wedges the VM. See the
// timeout note at the top for when to restore it.

const { out } = await sh('find /space -type f')

expect(out.trim().split('\n')).toEqual([
expect(out.trim().split('\n').sort()).toEqual([
'/space/hello.txt',
'/space/sub/a.txt',
'/space/sub/deeper/b.txt',
Expand Down
3 changes: 1 addition & 2 deletions platform/components/Theme.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -21,10 +21,9 @@ export default function Theme({ themes = availableThemes, theme, children }) {
<ThemeProvider
attribute="class"
themes={themes}
// defaultTheme="light"
defaultTheme="system"
forcedTheme={effectiveForcedTheme}
enableSystem={false}
enableSystem={true}
enableColorScheme={true}
disableTransitionOnChange={true}
>
Expand Down
4 changes: 2 additions & 2 deletions platform/components/Theme.utest.js
Original file line number Diff line number Diff line change
Expand Up @@ -87,13 +87,13 @@ describe('Theme', () => {
expect(props.attribute).toBe('class')
})

it('should disable system theme detection', () => {
it('should enable system theme detection', () => {
render(<Theme>Content</Theme>)

const provider = screen.getByTestId('theme-provider')
const props = JSON.parse(provider.getAttribute('data-props'))

expect(props.enableSystem).toBe(false)
expect(props.enableSystem).toBe(true)
})

it('should enable color scheme', () => {
Expand Down
8 changes: 6 additions & 2 deletions platform/hooks/useTheme.ts
Original file line number Diff line number Diff line change
@@ -1,12 +1,16 @@
import { useTheme as useThemeBase } from 'next-themes'

export default function useTheme() {
const { theme, forcedTheme, ...rest } = useThemeBase()
const { theme, forcedTheme, resolvedTheme, ...rest } = useThemeBase()

return {
...rest,

theme: forcedTheme || theme,
// @note resolvedTheme maps "system" to light or dark; consumers compare
// against those two values only
theme: forcedTheme || resolvedTheme || theme,

resolvedTheme,

forcedTheme,
}
Expand Down
30 changes: 28 additions & 2 deletions platform/hooks/useTheme.utest.js
Original file line number Diff line number Diff line change
Expand Up @@ -91,7 +91,7 @@ describe('useTheme', () => {
expect(result.current.forcedTheme).toBeNull()
})

it('should handle system theme correctly', () => {
it('should resolve system theme to the system preference', () => {
useThemeBase.mockReturnValue({
theme: 'system',
forcedTheme: undefined,
Expand All @@ -101,9 +101,35 @@ describe('useTheme', () => {

const { result } = renderHook(() => useTheme())

expect(result.current.theme).toBe('system')
expect(result.current.theme).toBe('dark')
expect(result.current.resolvedTheme).toBe('dark')
})

it('should prefer forcedTheme over resolvedTheme', () => {
useThemeBase.mockReturnValue({
theme: 'system',
forcedTheme: 'light',
setTheme: jest.fn(),
resolvedTheme: 'dark',
})

const { result } = renderHook(() => useTheme())

expect(result.current.theme).toBe('light')
})

it('should fall back to theme before hydration when resolvedTheme is undefined', () => {
useThemeBase.mockReturnValue({
theme: 'dark',
forcedTheme: undefined,
setTheme: jest.fn(),
resolvedTheme: undefined,
})

const { result } = renderHook(() => useTheme())

expect(result.current.theme).toBe('dark')
})
})

describe('edge cases', () => {
Expand Down
43 changes: 40 additions & 3 deletions platform/lib/defer.ts
Original file line number Diff line number Diff line change
Expand Up @@ -22,7 +22,7 @@ import { AsyncLocalStorage } from 'async_hooks'
* in-tree so a deployment that never runs on Vercel does not carry the
* vendor package for one optional chained call.
*/
function waitUntil(promise: Promise<unknown>): void {
function getWaitUntil(): ((p: Promise<unknown>) => void) | undefined {
const context = (
globalThis as {
[key: symbol]: {
Expand All @@ -31,7 +31,11 @@ function waitUntil(promise: Promise<unknown>): void {
}
)[Symbol.for('@vercel/request-context')]

context?.get?.()?.waitUntil?.(promise)
return context?.get?.()?.waitUntil
}

function waitUntil(promise: Promise<unknown>): void {
getWaitUntil()?.(promise)
}

interface Store {
Expand Down Expand Up @@ -183,7 +187,6 @@ export async function defer(
if (store) {
// @note add the promise to the list of deferred promises

// eslint-disable-next-line @typescript-eslint/no-floating-promises
store.deferred = store.deferred || []

store.deferred.push(promise.catch(captureError))
Expand All @@ -194,4 +197,38 @@ export async function defer(
}
}

/**
* Runs work after the response is sent rather than before it. `defer` holds a
* plain (non-streaming) response until its promises settle - see `beforeClose`
* - so a webhook that must ack within a deadline cannot put its publish there.
* When the runtime cannot keep the function alive past the response the work
* is awaited in place, exactly as `defer` would do.
*/
export async function deferPastResponse(
fn: () => Promise<unknown>
): Promise<void> {
const keepAlive = getWaitUntil()

if (!keepAlive) {
await defer(async () => {
await fn()
})

return
}

// @note a fresh store, so anything `fn` itself defers settles on this promise
// and not on the request store, whose `beforeClose` would hold the response

keepAlive(
als
.run({ deferred: [] }, async () => {
await fn()

await awaitDeferred()
})
.catch(captureError)
)
}

export default defer
89 changes: 88 additions & 1 deletion platform/lib/defer.utest.js
Original file line number Diff line number Diff line change
@@ -1,5 +1,10 @@
import debug, { createSpan, warn } from '@/lib/debug'
import { awaitDeferred, defer, runInDeferred } from '@/lib/defer'
import {
awaitDeferred,
defer,
deferPastResponse,
runInDeferred,
} from '@/lib/defer'
import { captureError, captureException } from '@/lib/error'

// @note the runtime hook `defer` uses to keep work alive past the response:
Expand Down Expand Up @@ -779,3 +784,85 @@ describe('defer module', () => {
})
})
})

describe('deferPastResponse', () => {
beforeEach(() => {
jest.clearAllMocks()
})

it('hands the work to waitUntil and resolves before the work settles', async () => {
let finish
const work = jest.fn(
() =>
new Promise((resolve) => {
finish = resolve
})
)

await deferPastResponse(work)

expect(work).toHaveBeenCalledTimes(1)
expect(waitUntil).toHaveBeenCalledTimes(1)

finish()

await waitUntil.mock.calls[0][0]
})

it('does not hold a runInDeferred response for work the handed function defers', async () => {
let finish
const inner = new Promise((resolve) => {
finish = resolve
})

const wrapped = runInDeferred(async () => {
await deferPastResponse(async () => {
await defer(inner)
})

return new Response('ok', { status: 200 })
})

// @note would hang if `inner` had landed on the request store
const result = await wrapped()

expect(result.status).toBe(200)

finish()

await Promise.all(waitUntil.mock.calls.map(([promise]) => promise))
})

it('captures a failure of the handed work', async () => {
const error = new Error('publish failed')

await deferPastResponse(() => Promise.reject(error))

await waitUntil.mock.calls[0][0]

expect(captureError).toHaveBeenCalledWith(error)
})

it('awaits the work in place when the runtime has no waitUntil', async () => {
const context = global[Symbol.for('@vercel/request-context')]

delete global[Symbol.for('@vercel/request-context')]

try {
const order = []

await deferPastResponse(async () => {
await new Promise((resolve) => setTimeout(resolve, 5))

order.push('work')
})

order.push('returned')

expect(order).toEqual(['work', 'returned'])
expect(waitUntil).not.toHaveBeenCalled()
} finally {
global[Symbol.for('@vercel/request-context')] = context
}
})
})
Loading