chore: kanban template

This commit is contained in:
e2e
2026-06-21 19:23:05 +02:00
commit 758d1c7953
209 changed files with 215578 additions and 0 deletions

View File

@@ -0,0 +1,19 @@
import type { CoreServices, CoreSingletonServices, CoreConfig, CoreUserSession } from '@pikku/core'
import type { Kysely } from 'kysely'
import type { DB } from './types/db.types.js'
import type { TypedSecretService } from '../.pikku/secrets/pikku-secrets.gen.js'
import type { TypedVariablesService } from '../.pikku/variables/pikku-variables.gen.js'
export interface UserSession extends CoreUserSession {
userId: string
}
export interface Config extends CoreConfig {}
export interface SingletonServices extends CoreSingletonServices<Config> {
variables: TypedVariablesService
secrets: TypedSecretService
kysely: Kysely<DB>
}
export interface Services extends CoreServices<SingletonServices> {}

View File

@@ -0,0 +1,6 @@
import { pikkuConfig } from '../.pikku/pikku-types.gen.js'
export const createConfig = pikkuConfig(async () => ({
content: {},
sqliteDb: '.pikku-runtime/dev.db',
}))

View File

@@ -0,0 +1,7 @@
import { pikkuSessionlessFunc } from '#pikku/pikku-types.gen.js'
export const archiveStaleCards = pikkuSessionlessFunc({
func: async () => {
// no-op: intentionally empty, used to exercise the scheduler wireType
},
})

View File

@@ -0,0 +1,76 @@
import { pikkuChannelFunc, pikkuChannelConnectionFunc, pikkuChannelDisconnectionFunc } from '#pikku'
/**
* On WebSocket connect — greet the client with the current card list.
* Subsequent updates are pushed by `processCardEvent` (queue worker)
* via the eventHub.
*/
// NOTE: no generic on pikkuChannelConnectionFunc. A connect handler has no
// input message and no validated output — `channel.send` is fire-and-forget.
// Passing an output-shaped generic here makes the inspector mis-assign it as
// the connect INPUT schema, so the WS handshake (empty openingData) fails
// schema validation and the connection is rejected 403. See pikku inspector
// swap-bug: pikkuChannelConnectionFunc<Out>'s single generic is OUTPUT, but
// generic[0] is read as input.
export const onCardsConnect = pikkuChannelConnectionFunc(async ({ kysely, logger }, _, { channel }) => {
const rows = await kysely.selectFrom('kanbanCard').selectAll().execute()
channel.send({ type: 'hello', count: rows.length })
logger.info(`cards-channel: connected (${rows.length} cards)`)
})
export const onCardsDisconnect = pikkuChannelDisconnectionFunc(
async ({ logger }, _, { channel }) => {
logger.info(`cards-channel: disconnected channel=${channel.channelId}`)
},
)
/**
* Echo any incoming message back to confirm the channel is bidirectional.
* Wired via `onMessageWiring` so pikku JSON-parses the WS frame and
* dispatches by the `action` discriminator. Send `{action:'echo', text:'…'}`
* to receive `{echo:'…'}`.
*/
export const onCardsEcho = pikkuChannelFunc<{ text: string }, { echo: string }>(
async (_services, { text }, { channel }) => {
channel.send({ echo: text })
},
)
/**
* Channel-session round-trip handlers — used by smoke-websocket-deploy.ts to
* verify that `setSession`/`getSession`/`clearSession` are wired up to the
* channel store on the deployed runtime (CF: WebSocketHibernationServer DO).
*
* NOTE (OSS gap): pikku's current SessionService writes to
* `sessionStore[pikkuUserId]`, which is the wrong scope for channels — channel
* session state belongs to the channelId, not the user. This smoke is
* intentionally kept in tree as a regression target that will start passing
* once OSS lands channel-scoped session storage on the ChannelStore (see
* docs/work-log.md for proposed fix).
*
* Handlers RETURN their reply object instead of calling `channel.send`; the
* pikku channel runner forwards the return and stamps the `action`
* discriminator onto it. Calling `channel.send` in addition would emit two
* frames per message and desync any client reading one reply per request.
*/
export const onCardsSetSession = pikkuChannelFunc<
{ userId: string; label: string },
{ set: true; label: string }
>(async (_services, payload, { setSession }) => {
await setSession({ userId: payload.userId })
return { set: true, label: payload.label }
})
export const onCardsGetSession = pikkuChannelFunc<Record<string, never>, { session: unknown }>(
async (_services, _input, { getSession }) => {
const session = await getSession()
return { session: session ?? null }
},
)
export const onCardsClearSession = pikkuChannelFunc<Record<string, never>, { cleared: true }>(
async (_services, _input, { clearSession }) => {
await clearSession()
return { cleared: true }
},
)

View File

@@ -0,0 +1,46 @@
import { z } from 'zod'
import { randomUUID } from 'node:crypto'
import { pikkuSessionlessFunc } from '#pikku'
export const CreateCardInput = z.object({
title: z.string().min(1),
status: z.enum(['todo', 'doing', 'done']).default('todo'),
priority: z.enum(['low', 'medium', 'high']).default('medium').optional(),
})
export const CreateCardOutput = z.object({
cardId: z.string(),
title: z.string(),
status: z.string(),
position: z.number(),
createdAt: z.string(),
})
export const createCard = pikkuSessionlessFunc({
inline: false,
expose: true,
description: 'Create a kanban card.',
input: CreateCardInput,
output: CreateCardOutput,
func: async ({ kysely, logger, queueService }, { title, status }) => {
const cardId = randomUUID()
const createdAt = new Date().toISOString()
const max = await kysely
.selectFrom('kanbanCard')
.select(({ fn }) => fn.max<number>('position').as('max'))
.where('status', '=', status)
.executeTakeFirst()
const position = (max?.max ?? 0) + 1
await kysely
.insertInto('kanbanCard')
.values({ cardId, title, status, position, createdAt })
.execute()
await queueService.add('process-card-event', { cardId, action: 'created', status, createdAt })
logger.info(`createCard ${cardId} (${status})`)
return { cardId, title, status, position, createdAt }
},
})

View File

@@ -0,0 +1,31 @@
import { z } from 'zod'
import { pikkuSessionlessFunc } from '#pikku'
export const EnrichCardInput = z.object({
title: z.string().min(1),
status: z.enum(['todo', 'doing', 'done']).default('todo'),
})
export const EnrichCardOutput = z.object({
title: z.string(),
status: z.string(),
priority: z.enum(['low', 'medium', 'high']),
labels: z.array(z.string()),
})
export const enrichCard = pikkuSessionlessFunc({
inline: false,
input: EnrichCardInput,
output: EnrichCardOutput,
func: async ({ logger }, { title, status }) => {
const lower = title.toLowerCase()
let priority: 'low' | 'medium' | 'high' = 'medium'
if (/\b(urgent|asap|critical|p0|outage)\b/.test(lower)) priority = 'high'
else if (/\b(later|someday|maybe|nice to have)\b/.test(lower)) priority = 'low'
const labels: string[] = []
if (/\b(bug|fix|broken|error)\b/.test(lower)) labels.push('bug')
if (/\b(feat|feature|add)\b/.test(lower)) labels.push('feature')
logger.info(`enrichCard: '${title}' → ${priority}`)
return { title, status, priority, labels }
},
})

View File

@@ -0,0 +1,41 @@
import { z } from 'zod'
import { pikkuSessionlessFunc } from '#pikku'
export const GetCardFileUploadUrlInput = z.object({
cardId: z.string().min(1),
fileName: z.string().min(1),
contentType: z.string().min(1),
})
export const GetCardFileUploadUrlOutput = z.object({
uploadUrl: z.string(),
fileKey: z.string(),
expiresAt: z.string(),
uploadMethod: z.enum(['PUT', 'POST']).optional(),
uploadHeaders: z.record(z.string(), z.string()).optional(),
})
export const getCardFileUploadUrl = pikkuSessionlessFunc({
expose: true,
description: 'Return a presigned upload URL for attaching a file to a card.',
input: GetCardFileUploadUrlInput,
output: GetCardFileUploadUrlOutput,
func: async ({ content }, { cardId, fileName, contentType }) => {
if (!content) {
throw new Error('Content service not configured')
}
const fileKey = `${cardId}/${fileName}`
const { uploadUrl, uploadMethod, uploadHeaders } = await content.getUploadURL({
bucket: 'cards',
fileKey,
contentType,
})
return {
uploadUrl,
fileKey,
expiresAt: new Date(Date.now() + 3_600_000).toISOString(),
uploadMethod,
uploadHeaders,
}
},
})

View File

@@ -0,0 +1,12 @@
import { pikkuAIAgent } from '#pikku/agent/pikku-agent-types.gen.js'
import { ref } from '#pikku/pikku-types.gen.js'
export const kanbanAgent = pikkuAIAgent({
name: 'kanbanAgent',
description: 'Manages the kanban board — create and list cards on behalf of the user',
goal: 'You are a kanban board assistant. You can list existing cards and create new cards. When creating a card you need a title and optionally a status (todo, doing, or done). Default status is "todo".',
model: 'openai/deepseek-v4-flash',
tools: [ref('listCards'), ref('createCard')],
maxSteps: 5,
toolChoice: 'auto',
})

View File

@@ -0,0 +1,32 @@
import { z } from 'zod'
import { pikkuSessionlessFunc } from '#pikku'
export const ListCardsInput = z.object({
status: z.enum(['todo', 'doing', 'done']).optional(),
})
export const ListCardsOutput = z.object({
cards: z.array(
z.object({
cardId: z.string(),
title: z.string(),
status: z.string(),
position: z.number(),
createdAt: z.string(),
}),
),
})
export const listCards = pikkuSessionlessFunc({
expose: true,
description: 'List all kanban cards, optionally filtered by status.',
input: ListCardsInput,
output: ListCardsOutput,
func: async ({ kysely, logger }, input) => {
const status = input?.status
let query = kysely.selectFrom('kanbanCard').selectAll().orderBy('position', 'asc')
if (status) query = query.where('status', '=', status)
const cards = await query.execute()
logger.info(`listCards: ${cards.length} cards`)
return { cards }
},
})

View File

@@ -0,0 +1,17 @@
import { z } from 'zod'
import { pikkuSessionlessFunc } from '#pikku'
export const NotifyCardCreatedInput = z.object({
cardId: z.string(),
title: z.string(),
priority: z.enum(['low', 'medium', 'high']),
labels: z.array(z.string()),
})
export const notifyCardCreated = pikkuSessionlessFunc({
inline: false,
input: NotifyCardCreatedInput,
func: async ({ logger }, { cardId, title, priority }) => {
logger.info(`notify ${priority} card=${cardId} '${title}'`)
},
})

View File

@@ -0,0 +1,37 @@
import { z } from 'zod'
import { pikkuSessionlessFunc } from '#pikku'
import { randomUUID } from 'node:crypto'
export const OnCardEventTriggerInput = z.object({
title: z.string(),
status: z.enum(['todo', 'doing', 'done']).default('todo'),
source: z.string().optional(),
})
export const OnCardEventTriggerOutput = z.object({
cardId: z.string(),
title: z.string(),
status: z.string(),
})
export const onCardEventTrigger = pikkuSessionlessFunc({
input: OnCardEventTriggerInput,
output: OnCardEventTriggerOutput,
remote: true,
func: async ({ kysely, logger }, { title, status, source }) => {
const cardId = randomUUID()
const createdAt = new Date().toISOString()
const max = await kysely
.selectFrom('kanbanCard')
.select(({ fn }) => fn.max<number>('position').as('max'))
.where('status', '=', status)
.executeTakeFirst()
const position = (max?.max ?? 0) + 1
await kysely
.insertInto('kanbanCard')
.values({ cardId, title, status, position, createdAt })
.execute()
logger.info(`onCardEventTrigger: created card ${cardId} from source=${source ?? 'unknown'}`)
return { cardId, title, status }
},
})

View File

@@ -0,0 +1,21 @@
import { z } from 'zod'
import { pikkuSessionlessFunc } from '#pikku'
export const ProcessCardEventInput = z.object({
cardId: z.string(),
action: z.string(),
status: z.string(),
createdAt: z.string(),
})
/**
* Queue worker for `cardEvents`. Just logs the event for now — real
* implementations would fan out to webhooks, refresh search indexes, push
* mobile notifications, etc.
*/
export const processCardEvent = pikkuSessionlessFunc({
input: ProcessCardEventInput,
func: async ({ logger }, { cardId, action, status }) => {
logger.info(`processCardEvent ${action} card=${cardId} status=${status}`)
},
})

View File

@@ -0,0 +1,35 @@
import { z } from 'zod'
import { pikkuSessionlessFunc } from '#pikku'
export const SignCardFileInput = z.object({
cardId: z.string().min(1),
fileName: z.string().min(1),
})
export const SignCardFileOutput = z.object({
signedUrl: z.string(),
expiresAt: z.string(),
})
export const signCardFile = pikkuSessionlessFunc({
expose: true,
description: 'Return a time-limited signed download URL for a card attachment.',
input: SignCardFileInput,
output: SignCardFileOutput,
func: async ({ content }, { cardId, fileName }) => {
if (!content) {
throw new Error('Content service not configured')
}
const fileKey = `${cardId}/${fileName}`
const dateLessThan = new Date(Date.now() + 3_600_000)
const signedUrl = await content.signContentKey({
bucket: 'cards',
contentKey: fileKey,
dateLessThan,
})
return {
signedUrl,
expiresAt: dateLessThan.toISOString(),
}
},
})

View File

@@ -0,0 +1,14 @@
import { addHTTPMiddleware } from '@pikku/core/http'
import { cors } from '@pikku/core/middleware'
// The Better Auth session-bridge middleware (betterAuthSession) is generated and
// registered globally by the pikku CLI from src/wirings/auth.ts — see
// src/scaffold/auth.gen.ts — so it is NOT wired here. This file only configures
// CORS for the browser client.
addHTTPMiddleware('*', [
cors({
origin: true,
credentials: true,
headers: ['Content-Type', 'Authorization', 'X-Auth-Return-Redirect'],
}),
])

View File

@@ -0,0 +1,162 @@
/**
* This file was generated by @pikku/cli@0.12.45
*/
import { MissingServiceError } from '@pikku/core/errors'
import { pikkuSessionlessFunc, defineHTTPRoutes, wireHTTPRoutes } from '../../../../../../../../../../../tmp/ag/.pikku/pikku-types.gen.js'
export const agentCaller = pikkuSessionlessFunc<
{ agentName: string; message: string; threadId: string; resourceId: string },
unknown
>({
tags: ['pikku'],
auth: false,
func: async (_services, data, { rpc }) => {
return await rpc.agent.run(data.agentName as any, {
message: data.message,
threadId: data.threadId,
resourceId: data.resourceId,
})
},
})
export const agentStreamCaller = pikkuSessionlessFunc<
{ agentName: string; message: string; threadId: string; resourceId: string; context?: string },
void
>({
tags: ['pikku'],
auth: false,
func: async (_services, data, { rpc }) => {
await rpc.agent.stream(data.agentName as any, {
message: data.message,
threadId: data.threadId,
resourceId: data.resourceId,
...(data.context ? { context: data.context } : {}),
})
},
})
export const agentApproveCaller = pikkuSessionlessFunc<
{ agentName: string; runId: string; approvals: { toolCallId: string; approved: boolean }[] },
unknown
>({
tags: ['pikku'],
auth: false,
func: async (_services, { runId, approvals, agentName }, { rpc }) => {
return await rpc.agent.approve(runId, approvals, agentName)
},
})
export const agentResumeCaller = pikkuSessionlessFunc<
{ agentName: string; runId: string; toolCallId: string; approved: boolean },
void
>({
tags: ['pikku'],
auth: false,
func: async (_services, data, { rpc }) => {
await rpc.agent.resume(data.runId, {
toolCallId: data.toolCallId,
approved: data.approved,
})
},
})
export const getAgentThreads = pikkuSessionlessFunc<
{ agentName?: string; resourceId?: string; limit?: number; offset?: number },
any[]
>({
tags: ['pikku', 'pikku:agent'],
title: 'Get Agent Threads',
description:
'Returns a list of AI agent threads from storage. Accepts optional filters: agentName, resourceId, limit, and offset for pagination.',
expose: true,
auth: false,
func: async ({ agentRunService }, input) => {
if (!agentRunService) throw new MissingServiceError('agentRunService is not available')
return await agentRunService.listThreads({
agentName: input?.agentName,
resourceId: input?.resourceId,
limit: input?.limit,
offset: input?.offset,
})
},
})
export const getAgentThreadMessages = pikkuSessionlessFunc<
{ threadId: string; resourceId?: string },
any[]
>({
tags: ['pikku', 'pikku:agent'],
title: 'Get Agent Thread Messages',
description:
'Returns all messages for a given AI agent thread, ordered by creation time.',
expose: true,
auth: false,
func: async ({ agentRunService }, input) => {
if (!agentRunService) throw new MissingServiceError('agentRunService is not available')
return await agentRunService.getThreadMessages(input.threadId)
},
})
export const getAgentThreadRuns = pikkuSessionlessFunc<
{ threadId: string; resourceId?: string },
any[]
>({
tags: ['pikku', 'pikku:agent'],
title: 'Get Agent Thread Runs',
description:
'Returns the run history for a given AI agent thread, ordered by creation time.',
expose: true,
auth: false,
func: async ({ agentRunService }, input) => {
if (!agentRunService) throw new MissingServiceError('agentRunService is not available')
return await agentRunService.getThreadRuns(input.threadId)
},
})
export const deleteAgentThread = pikkuSessionlessFunc<
{ threadId: string; resourceId?: string },
{ deleted: boolean }
>({
tags: ['pikku', 'pikku:agent'],
title: 'Delete Agent Thread',
description:
'Deletes an AI agent thread and all of its persisted state.',
expose: true,
auth: false,
func: async ({ agentRunService }, input) => {
if (!agentRunService) throw new MissingServiceError('agentRunService is not available')
const deleted = await agentRunService.deleteThread(input.threadId)
return { deleted }
},
})
export const agentRoutes = defineHTTPRoutes({
auth: false,
tags: ['pikku:public'],
routes: {
agentRun: {
route: '/rpc/agent/:agentName',
method: 'post',
func: agentCaller,
},
agentStream: {
route: '/rpc/agent/:agentName/stream',
method: 'post',
sse: true,
func: agentStreamCaller,
},
agentApprove: {
route: '/rpc/agent/:agentName/approve',
method: 'post',
func: agentApproveCaller,
},
agentResume: {
route: '/rpc/agent/:agentName/resume',
method: 'post',
sse: true,
func: agentResumeCaller,
},
},
})
wireHTTPRoutes({ routes: { agent: agentRoutes } })

View File

@@ -0,0 +1,9 @@
/**
* This file was generated by @pikku/cli@0.12.45
*/
// AUTO-GENERATED by pikku CLI — do not edit
import { addHTTPMiddleware } from '../../../../../../../../../../../tmp/acc/.pikku/pikku-types.gen.js'
import { betterAuthStatelessSession } from '@pikku/better-auth'
addHTTPMiddleware('*', [betterAuthStatelessSession()])

View File

@@ -0,0 +1,17 @@
/**
* This file was generated by @pikku/cli@0.12.45
*/
// AUTO-GENERATED by pikku CLI — do not edit
import { wireSecret } from '@pikku/core/secret'
import { z } from 'zod'
export const BetterAuthSecretSchema = z.string()
wireSecret({
name: 'betterAuthSecret',
displayName: 'Better Auth Secret',
description: 'Signing secret for better-auth sessions',
secretId: 'BETTER_AUTH_SECRET',
schema: BetterAuthSecretSchema,
})

View File

@@ -0,0 +1,21 @@
/**
* This file was generated by @pikku/cli@0.12.45
*/
// AUTO-GENERATED by pikku CLI — do not edit
import { pikkuSessionlessFunc, wireHTTPRoutes } from '../../../../../../../../../../../tmp/acc/.pikku/pikku-types.gen.js'
import { createAuthHandler } from '@pikku/better-auth'
import '../wirings/auth.js'
const authConfigHandler = createAuthHandler()
export const authHandler = pikkuSessionlessFunc({
func: (services: any, data: any, interaction: any) =>
authConfigHandler.func(services, data, interaction),
})
wireHTTPRoutes({
routes: {
getAuthCatchAll: { method: 'get', route: '/api/auth{/*splat}', func: authHandler, auth: false },
postAuthCatchAll: { method: 'post', route: '/api/auth{/*splat}', func: authHandler, auth: false },
},
})

View File

@@ -0,0 +1,114 @@
/**
* This file was generated by @pikku/cli@0.12.45
*/
import { pikkuSessionlessFunc, defineHTTPRoutes, wireHTTPRoutes, ref, wireAddon } from '../../../../../../../../../../../tmp/acc/.pikku/pikku-types.gen.js'
export const pikkuConsoleSetSecret = pikkuSessionlessFunc<{
secretId: string
value: unknown
}, {
success: boolean
}>({
tags: ['pikku'],
description: 'Set the value of a secret',
expose: true,
auth: false,
func: async ({ secrets }, { secretId, value }) => {
await secrets.setSecret(secretId, value)
return { success: true }
},
})
export const pikkuConsoleGetVariable = pikkuSessionlessFunc<
{ variableId: string },
{ exists: boolean; value: unknown | null }
>({
tags: ['pikku'],
description: 'Get the current value of a variable',
expose: true,
auth: false,
func: async ({ variables }, { variableId }) => {
const exists = await variables.has(variableId)
if (!exists) {
return { exists: false, value: null }
}
try {
const value = await variables.get(variableId)
return { exists: true, value }
} catch {
const value = await variables.get(variableId)
return { exists: true, value }
}
},
})
export const pikkuConsoleSetVariable = pikkuSessionlessFunc<
{ variableId: string; value: unknown },
{ success: boolean }
>({
tags: ['pikku'],
description: 'Set the value of a variable',
expose: true,
auth: false,
func: async ({ variables }, { variableId, value }) => {
if (typeof value === 'string') {
await variables.set(variableId, value)
} else {
await variables.set(variableId, value)
}
return { success: true }
},
})
export const pikkuConsoleHasSecret = pikkuSessionlessFunc<
{ secretId: string },
{ exists: boolean }
>({
tags: ['pikku'],
description: 'Check if a secret exists without reading its value',
expose: true,
auth: false,
func: async ({ secrets }, { secretId }) => {
const exists = await secrets.hasSecret(secretId)
return { exists }
},
})
export const pikkuConsoleGetSecret = pikkuSessionlessFunc<
{ secretId: string },
{ exists: boolean; value: unknown | null }
>({
tags: ['pikku'],
description: 'Get the current value of a secret',
expose: true,
auth: false,
func: async ({ secrets }, { secretId }) => {
const exists = await secrets.hasSecret(secretId)
if (!exists) {
return { exists: false, value: null }
}
const value = await secrets.getSecret(secretId)
return { exists: true, value }
},
})
export const consoleRoutes = defineHTTPRoutes({
auth: false,
routes: {
workflowRunStream: {
route: '/workflow-run/:runId/stream',
method: 'get',
sse: true,
func: ref('console:streamWorkflowRun'),
},
functionTestsStream: {
route: '/function-tests/stream',
method: 'get',
sse: true,
func: ref('console:streamFunctionTests'),
},
},
})
wireAddon({ name: 'console', package: '@pikku/addon-console' })
wireHTTPRoutes({ basePath: '', routes: { console: consoleRoutes } })

View File

@@ -0,0 +1,26 @@
/**
* This file was generated by @pikku/cli@0.12.45
*/
/**
* Auto-generated public RPC HTTP endpoint
* Do not edit manually - regenerate with 'npx pikku'
*/
import { pikkuSessionlessFunc, wireHTTP } from '../../../../../../../../../../../tmp/acc/.pikku/pikku-types.gen.js'
export const rpcCaller = pikkuSessionlessFunc<
{ rpcName: string; data?: unknown },
unknown
>({
tags: ['pikku'],
auth: false,
func: async (_services, { rpcName, data }, { rpc }) => {
return await rpc.exposed(rpcName, data)
},
})
wireHTTP({
route: '/rpc/:rpcName',
method: 'post',
auth: false,
func: rpcCaller,
})

View File

@@ -0,0 +1,33 @@
/**
* This file was generated by @pikku/cli@0.12.45
*/
/**
* Auto-generated remote internal RPC queue worker and HTTP endpoint
* Do not edit manually - regenerate with 'npx pikku'
*/
import { pikkuSessionlessFunc, wireHTTP, wireQueueWorker } from '../../../../../../../../../../../tmp/acc/.pikku/pikku-types.gen.js'
import { pikkuRemoteAuthMiddleware } from '@pikku/core/middleware'
export const remoteRPCHandler = pikkuSessionlessFunc<
{ rpcName: string, data?: unknown },
unknown
>({
tags: ['pikku'],
func: async (_services, { rpcName, data }, { rpc }) => {
return await (rpc.invoke as any)(rpcName, data)
},
remote: true,
})
wireQueueWorker({
name: 'pikku-remote-internal-rpc',
func: remoteRPCHandler,
})
wireHTTP({
route: '/remote/rpc/:rpcName',
method: 'post',
auth: false,
middleware: [pikkuRemoteAuthMiddleware],
func: remoteRPCHandler,
})

View File

@@ -0,0 +1,288 @@
/**
* This file was generated by @pikku/cli@0.12.45
*/
/**
* Workflow HTTP catch-all routes
* Do not edit manually - regenerate with 'npx pikku'
*/
import { pikkuSessionlessFunc, wireHTTPRoutes } from '../../../../../../../../../../../tmp/acc/.pikku/pikku-types.gen.js'
import { MissingServiceError } from '@pikku/core/errors'
import type { WorkflowRunStatus } from '@pikku/core/workflow'
function assertWorkflowService(workflowService: unknown): asserts workflowService {
if (!workflowService) throw new MissingServiceError('workflowService is required')
}
function assertWorkflowRunService(workflowRunService: unknown): asserts workflowRunService {
if (!workflowRunService) throw new MissingServiceError('workflowRunService is required')
}
export const workflowStarter = pikkuSessionlessFunc<
{ workflowName: string; data?: unknown },
{ runId: string }
>({
tags: ['pikku'],
auth: false,
// workflowService is destructured (even though we delegate via rpc) so the
// analyzer assigns workflow-state capability to this unit — without it,
// rpc.startWorkflow() runs against a container missing workflowService.
func: async ({ workflowService }, { workflowName, data }, { rpc }) => {
assertWorkflowService(workflowService)
return await rpc.startWorkflow(workflowName as any, (data ?? {}) as any)
},
})
export const workflowRunner = pikkuSessionlessFunc<
{ workflowName: string; data?: unknown },
unknown
>({
tags: ['pikku'],
auth: false,
func: async ({ workflowService }, { workflowName, data }, { rpc }) => {
assertWorkflowService(workflowService)
return await workflowService.runToCompletion(workflowName, data ?? {}, rpc)
},
})
export const workflowStatusChecker = pikkuSessionlessFunc<
{ workflowName: string; runId: string },
WorkflowRunStatus
>({
tags: ['pikku'],
auth: false,
func: async ({ workflowService }, { runId }) => {
assertWorkflowService(workflowService)
const status = await workflowService.getRunStatus(runId)
if (!status) throw new Error(`Run not found: ${runId}`)
return status
},
})
/**
* Minimal workflow status stream — sends step names and statuses only.
* Use this for user-facing frontends where internal details should not be exposed.
*/
export const workflowStatusStream = pikkuSessionlessFunc<
{ workflowName: string; runId: string },
unknown
>({
tags: ['pikku'],
auth: false,
func: async ({ workflowRunService }, { runId }, { channel }) => {
assertWorkflowRunService(workflowRunService)
if (!channel) return
const terminalStatuses = new Set(['completed', 'failed', 'cancelled'])
let lastHash = ''
let initSent = false
const poll = async () => {
const run = await workflowRunService.getRun(runId)
if (!run) {
channel.close()
return false
}
const steps = await workflowRunService.getRunSteps(runId)
if (!initSent && run.deterministic) {
const statusByStep = new Map(
steps.map((s: { stepName: string; status: string }) => [
s.stepName,
s.status,
])
)
channel.send({
type: 'init',
deterministic: true,
steps: (run.plannedSteps ?? []).map(
(s: { stepName: string }) => ({
stepName: s.stepName,
status: statusByStep.get(s.stepName) ?? 'pending',
})
),
})
initSent = true
}
const hash = JSON.stringify({
s: run.status,
steps: steps.map((s: { stepName: string; status: string }) => [s.stepName, s.status]),
})
if (hash !== lastHash) {
lastHash = hash
channel.send({
type: 'update',
status: run.status,
steps: steps.map((s: { stepName: string; status: string }) => ({
stepName: s.stepName,
status: s.status,
})),
})
}
if (terminalStatuses.has(run.status)) {
channel.send({ type: 'done' })
channel.close()
return false
}
return true
}
const shouldContinue = await poll()
if (!shouldContinue) return
await new Promise<void>((resolve) => {
const interval = setInterval(async () => {
const cont = await poll()
if (!cont) {
clearInterval(interval)
resolve()
}
}, 500)
})
},
})
/**
* Full workflow status stream — includes output, error, and child run IDs.
* Use this for admin consoles and internal tooling.
*/
export const workflowStatusStreamFull = pikkuSessionlessFunc<
{ workflowName: string; runId: string },
unknown
>({
tags: ['pikku'],
auth: false,
func: async ({ workflowRunService }, { runId }, { channel }) => {
assertWorkflowRunService(workflowRunService)
if (!channel) return
const terminalStatuses = new Set(['completed', 'failed', 'cancelled'])
let lastHash = ''
let initSent = false
const poll = async () => {
const run = await workflowRunService.getRun(runId)
if (!run) {
channel.close()
return false
}
const steps = await workflowRunService.getRunSteps(runId)
if (!initSent && run.deterministic) {
const statusByStep = new Map(
steps.map((s: { stepName: string; status: string }) => [
s.stepName,
s.status,
])
)
channel.send({
type: 'init',
deterministic: true,
steps: (run.plannedSteps ?? []).map(
(s: { stepName: string }) => ({
stepName: s.stepName,
status: statusByStep.get(s.stepName) ?? 'pending',
})
),
})
initSent = true
}
const hash = JSON.stringify({
s: run.status,
o: run.output,
steps: steps.map((s: { stepName: string; status: string }) => [s.stepName, s.status]),
})
if (hash !== lastHash) {
lastHash = hash
channel.send({
type: 'update',
status: run.status,
output: run.output,
error: run.error,
steps: steps.map((s: { stepName: string; status: string; childRunId?: string }) => ({
stepName: s.stepName,
status: s.status,
...(s.childRunId ? { childRunId: s.childRunId } : {}),
})),
})
}
if (terminalStatuses.has(run.status)) {
channel.send({ type: 'done' })
channel.close()
return false
}
return true
}
const shouldContinue = await poll()
if (!shouldContinue) return
await new Promise<void>((resolve) => {
const interval = setInterval(async () => {
const cont = await poll()
if (!cont) {
clearInterval(interval)
resolve()
}
}, 500)
})
},
})
export const graphStarter = pikkuSessionlessFunc<
{ workflowName: string; nodeId: string; data?: unknown },
{ runId: string }
>({
auth: false,
// See workflowStarter — destructure workflowService so the analyzer
// assigns workflow-state capability to this unit.
func: async ({ workflowService }, { workflowName, nodeId, data }, { rpc }) => {
assertWorkflowService(workflowService)
return await rpc.startWorkflow(workflowName as any, (data ?? {}) as any, { startNode: nodeId })
},
})
wireHTTPRoutes({
auth: false,
routes: {
workflowStart: {
route: '/workflow/:workflowName/start',
method: 'post',
func: workflowStarter,
},
workflowRun: {
route: '/workflow/:workflowName/run',
method: 'post',
func: workflowRunner,
},
workflowStatus: {
route: '/workflow/:workflowName/status/:runId',
method: 'get',
func: workflowStatusChecker,
},
workflowStatusStream: {
route: '/workflow/:workflowName/status/:runId/stream',
method: 'get',
sse: true,
func: workflowStatusStream,
},
workflowStatusStreamFull: {
route: '/workflow/:workflowName/status/:runId/stream/full',
method: 'get',
sse: true,
func: workflowStatusStreamFull,
},
graphStart: {
route: '/workflow/:workflowName/graph/:nodeId',
method: 'post',
func: graphStarter,
},
},
})

View File

@@ -0,0 +1,99 @@
import {
JsonConsoleLogger,
LocalSecretService,
LocalVariablesService,
InMemoryQueueService,
} from '@pikku/core/services'
import { pikkuServices } from '../.pikku/pikku-types.gen.js'
import { TypedSecretService } from '../.pikku/secrets/pikku-secrets.gen.js'
import { TypedVariablesService } from '../.pikku/variables/pikku-variables.gen.js'
import { CFWorkerSchemaService } from '@pikku/schema-cfworker'
import { Kysely, CamelCasePlugin } from 'kysely'
import { LibsqlWebDialect } from '@pikku/kysely-sqlite'
import type { VercelAIAgentRunner } from '@pikku/ai-vercel'
import type { DB } from './types/db.types.js'
export const createSingletonServices = pikkuServices(async (config, existingServices) => {
const variables =
existingServices?.variables ?? new TypedVariablesService(new LocalVariablesService())
const secrets =
existingServices?.secrets ?? new TypedSecretService(new LocalSecretService(variables))
const logger = existingServices?.logger ?? new JsonConsoleLogger()
const schema = existingServices?.schema ?? new CFWorkerSchemaService(logger)
// In CF Workers and containers DATABASE_URL is injected by fabric.
// In local dev (pikku dev) existingServices.kysely is the node:sqlite instance
// set up by the CLI — prefer it over DATABASE_URL so sandbox mode always uses
// the local migrated SQLite rather than the remote Turso URL from the cascade.
// In tests existingServices.kysely is provided by the test support layer.
// node:sqlite / createNodeSqliteKysely is intentionally absent — it is not
// available in CF Workers. See tests/support/services.ts for the dev path.
let kysely: Kysely<DB> | undefined
if (existingServices?.kysely) {
kysely = existingServices.kysely as Kysely<DB>
} else {
const databaseUrl = await variables.get('DATABASE_URL')
if (databaseUrl) {
if (/^postgres(ql)?:\/\//.test(databaseUrl)) {
const [{ PostgresJSDialect }, postgres] = await Promise.all([
import('kysely-postgres-js'),
import('postgres'),
])
kysely = new Kysely<DB>({
dialect: new PostgresJSDialect({
postgres: postgres.default(databaseUrl),
}),
plugins: [new CamelCasePlugin()],
})
} else {
kysely = new Kysely<DB>({
dialect: new LibsqlWebDialect({ url: databaseUrl }),
plugins: [new CamelCasePlugin()],
})
}
} else {
throw new Error(
'kysely service not provided: set DATABASE_URL (Postgres or libsql) or pass kysely via existingServices',
)
}
}
const litellmProxyUrl = process.env.LITELLM_PROXY_URL ?? null
const litellmApiKey = process.env.LITELLM_API_KEY ?? null
let aiAgentRunner: VercelAIAgentRunner | undefined
if (litellmProxyUrl && litellmApiKey) {
// The AI SDKs (~3MB) are stubbed out of non-agent units at bundle time —
// only units with the `ai-model` capability keep them. So import them
// dynamically and guard on the module resolving to a real export; in a
// stubbed unit the import yields `{}` and the runner is simply not built.
const aiVercel = await import('@pikku/ai-vercel')
const aiSdk = await import('@ai-sdk/openai-compatible')
if (aiVercel.VercelAIAgentRunner && aiSdk.createOpenAICompatible) {
const litellmProvider = aiSdk.createOpenAICompatible({
name: 'litellm',
baseURL: litellmProxyUrl,
apiKey: litellmApiKey,
})
aiAgentRunner = new aiVercel.VercelAIAgentRunner({
openai: (modelId: string) => litellmProvider.chatModel(modelId),
anthropic: (modelId: string) => litellmProvider.chatModel(modelId),
google: (modelId: string) => litellmProvider.chatModel(modelId),
deepseek: (modelId: string) => litellmProvider.chatModel(modelId),
})
}
}
const queueService = existingServices?.queueService ?? new InMemoryQueueService()
return {
...(existingServices ?? {}),
config,
variables,
secrets,
logger,
schema,
kysely,
queueService,
...(aiAgentRunner ? { aiAgentRunner } : {}),
}
})

View File

@@ -0,0 +1 @@
export type { DB } from '../../.pikku/db/schema.js'

View File

@@ -0,0 +1,8 @@
import { wireScheduler } from '#pikku/pikku-types.gen.js'
import { archiveStaleCards } from '../functions/archive-stale-cards.function.js'
wireScheduler({
name: 'archiveStaleCards',
schedule: '* * * * *',
func: archiveStaleCards,
})

View File

@@ -0,0 +1,36 @@
import { betterAuth } from 'better-auth'
import { pikkuBetterAuth } from '#pikku'
/**
* Better Auth configuration — email + password sign-in.
*
* `pikkuBetterAuth` has no side effects: the pikku CLI statically inspects this
* single exported `auth` const and generates the catch-all `/api/auth/**` HTTP
* wiring, the session-bridge middleware, and a `wireSecret` for
* `BETTER_AUTH_SECRET` (and one per social provider, if you add any) — so the
* auth routes and secret requirements flow through normal inspection into the
* deploy manifest.
*
* The factory runs once when singleton services are built, pulling the secret
* (and the database) off the injected `services`; the resolved instance is then
* available to every function as `services.auth`. Better Auth is given the app's
* own kysely: the CamelCasePlugin maps Better Auth's camelCase field names onto
* the snake_case columns. The schema itself is not hand-written — run
* `pikku db generate` to emit the migration from this config (see
* db/sqlite/*-better-auth.sql). To offer Google / GitHub / ... add a
* `socialProviders` entry (and a button on the login page) — the CLI wires its
* secret too.
*/
export const auth = pikkuBetterAuth(async ({ kysely, secrets }) => {
const BETTER_AUTH_SECRET = await secrets.getSecret('BETTER_AUTH_SECRET')
return betterAuth({
secret: BETTER_AUTH_SECRET,
database: { db: kysely, type: 'sqlite' },
emailAndPassword: { enabled: true },
// Stateless session: CLI splits out betterAuthStatelessSession so non-auth
// units verify the signed cookie instead of bundling better-auth. pikku #737.
session: { cookieCache: { enabled: true } },
advanced: { database: { generateId: 'uuid' } },
})
})

View File

@@ -0,0 +1,7 @@
import { wireQueueWorker } from '#pikku/queue/pikku-queue-types.gen.js'
import { processCardEvent } from '../functions/process-card-event.function.js'
wireQueueWorker({
name: 'process-card-event',
func: processCardEvent,
})

View File

@@ -0,0 +1,22 @@
import { pikkuWorkflowGraph } from '#pikku/workflow/pikku-workflow-types.gen.js'
export const cardOnboardingWorkflow = pikkuWorkflowGraph({
description: 'Enrich a new card, persist it, then notify',
tags: ['kanban', 'onboarding'],
nodes: { enrich: 'enrichCard', create: 'createCard', notify: 'notifyCardCreated' },
config: {
enrich: { next: 'create' },
create: {
next: 'notify',
input: (ref) => ({ title: ref('enrich', 'title'), status: ref('enrich', 'status') }),
},
notify: {
input: (ref) => ({
cardId: ref('create', 'cardId'),
title: ref('create', 'title'),
priority: ref('enrich', 'priority'),
labels: ref('enrich', 'labels'),
}),
},
},
})

View File

@@ -0,0 +1,28 @@
import { wireChannel } from '#pikku'
import {
onCardsConnect,
onCardsDisconnect,
onCardsEcho,
onCardsSetSession,
onCardsGetSession,
onCardsClearSession,
} from '../functions/cards-channel.function.js'
wireChannel({
name: 'cards',
route: '/cards/live',
auth: false,
onConnect: onCardsConnect,
onDisconnect: onCardsDisconnect,
// Route messages by `action` so pikku JSON-parses the frame + dispatches
// to the right handler. Frames look like `{action:'echo', text:'…'}`.
onMessageWiring: {
action: {
echo: { func: onCardsEcho },
setSession: { func: onCardsSetSession },
getSession: { func: onCardsGetSession },
clearSession: { func: onCardsClearSession },
},
},
tags: ['kanban'],
})

View File

@@ -0,0 +1,48 @@
import { wireCLI, pikkuCLICommand, pikkuCLIRender } from '#pikku'
import { listCards } from '../functions/list-cards.function.js'
import { createCard } from '../functions/create-card.function.js'
const cardsRenderer = pikkuCLIRender<{
cards: Array<{ cardId: string; title: string; status: string; position: number }>
}>((_services, { cards }) => {
if (cards.length === 0) {
console.log('No cards on the board.')
return
}
for (const card of cards) {
console.log(`[${card.status.toUpperCase()}] ${card.position}. ${card.title} (${card.cardId})`)
}
})
const cardRenderer = pikkuCLIRender<{ cardId: string; title: string; status: string }>(
(_services, card) => {
console.log(`Created: [${card.status}] ${card.title} (${card.cardId})`)
},
)
wireCLI({
program: 'kanban',
commands: {
list: pikkuCLICommand({
func: listCards,
description: 'List all kanban cards',
render: cardsRenderer,
}),
add: pikkuCLICommand({
parameters: '<title>',
func: createCard,
description: 'Add a new kanban card',
render: cardRenderer,
options: {
status: {
description: 'Card status (todo, doing, done)',
short: 's',
default: 'todo' as const,
},
},
}),
},
render: pikkuCLIRender((_services, result) => {
console.log(JSON.stringify(result, null, 2))
}),
})

View File

@@ -0,0 +1,25 @@
import { defineHTTPRoutes, wireHTTPRoutes } from '#pikku'
import { listCards } from '../functions/list-cards.function.js'
import { createCard } from '../functions/create-card.function.js'
import { getCardFileUploadUrl } from '../functions/get-card-file-upload-url.function.js'
import { signCardFile } from '../functions/sign-card-file.function.js'
export const kanbanRoutes = defineHTTPRoutes({
auth: false,
routes: {
listCards: { method: 'get', route: '/cards', func: listCards },
createCard: { method: 'post', route: '/cards', func: createCard },
getCardFileUploadUrl: {
method: 'post',
route: '/cards/:cardId/attachments/upload-url',
func: getCardFileUploadUrl,
},
signCardFile: {
method: 'get',
route: '/cards/:cardId/attachments/:fileName/signed-url',
func: signCardFile,
},
},
})
wireHTTPRoutes({ routes: { kanban: kanbanRoutes } })

View File

@@ -0,0 +1,90 @@
import { z } from 'zod'
import {
pikkuMCPToolFunc,
pikkuMCPResourceFunc,
pikkuMCPPromptFunc,
wireMCPResource,
wireMCPPrompt,
} from '#pikku'
import { randomUUID } from 'node:crypto'
export const listCardsTool = pikkuMCPToolFunc({
description: 'List all kanban cards on the board.',
tags: ['kanban', 'cards'],
func: async ({ kysely }) => {
const cards = await kysely
.selectFrom('kanbanCard')
.selectAll()
.orderBy('position', 'asc')
.execute()
return [{ type: 'text' as const, text: JSON.stringify(cards, null, 2) }]
},
})
export const CreateCardToolInput = z.object({
title: z.string().min(1),
status: z.enum(['todo', 'doing', 'done']).default('todo'),
})
export const createCardTool = pikkuMCPToolFunc({
input: CreateCardToolInput,
description: 'Create a new kanban card on the board.',
tags: ['kanban', 'cards'],
func: async ({ kysely, queueService }, { title, status }) => {
const cardId = randomUUID()
const createdAt = new Date().toISOString()
const max = await kysely
.selectFrom('kanbanCard')
.select(({ fn }) => fn.max<number>('position').as('max'))
.where('status', '=', status)
.executeTakeFirst()
const position = (max?.max ?? 0) + 1
await kysely
.insertInto('kanbanCard')
.values({ cardId, title, status, position, createdAt })
.execute()
await queueService!.add('process-card-event', { cardId, action: 'created', status, createdAt })
return [{ type: 'text' as const, text: `Created card ${cardId}: "${title}" (${status})` }]
},
})
const kanbanCardsResource = pikkuMCPResourceFunc<void>(async ({ kysely }) => {
const cards = await kysely
.selectFrom('kanbanCard')
.selectAll()
.orderBy('position', 'asc')
.execute()
return [{ uri: 'kanban://cards', text: JSON.stringify(cards, null, 2) }]
})
const kanbanStatusPrompt = pikkuMCPPromptFunc<void>(async ({ kysely }) => {
const cards = await kysely.selectFrom('kanbanCard').selectAll().execute()
const byStatus = cards.reduce<Record<string, number>>((acc, c) => {
acc[c.status] = (acc[c.status] ?? 0) + 1
return acc
}, {})
return [
{
role: 'user' as const,
content: {
type: 'text' as const,
text: `Board has ${cards.length} cards: ${JSON.stringify(byStatus)}. Summarise the state.`,
},
},
]
})
wireMCPResource({
uri: 'kanban://cards',
title: 'Kanban Board Cards',
description: 'All cards currently on the kanban board',
func: kanbanCardsResource,
tags: ['kanban'],
})
wireMCPPrompt({
name: 'kanbanStatus',
description: 'Get a prompt summarising the current kanban board state',
func: kanbanStatusPrompt,
tags: ['kanban'],
})

View File

@@ -0,0 +1,12 @@
import { z } from 'zod'
import { wireSecret } from '@pikku/core/secret'
export const notificationWebhookSecretSchema = z.string().min(16)
wireSecret({
secretId: 'NOTIFICATION_WEBHOOK_SECRET',
name: 'notificationWebhookSecret',
displayName: 'Notification Webhook Secret',
description: 'Shared secret used to sign outbound webhook payloads for card event notifications.',
schema: notificationWebhookSecretSchema,
})

View File

@@ -0,0 +1,7 @@
import { wireTrigger } from '#pikku'
import { onCardEventTrigger } from '../functions/on-card-event-trigger.function.js'
wireTrigger({
name: 'card-event',
func: onCardEventTrigger,
})

View File

@@ -0,0 +1,12 @@
import { z } from 'zod'
import { wireVariable } from '@pikku/core/variable'
export const maxCardsPerColumnSchema = z.number().int().min(1).max(1000)
wireVariable({
variableId: 'MAX_CARDS_PER_COLUMN',
name: 'maxCardsPerColumn',
displayName: 'Max Cards Per Column',
description: 'Maximum number of cards allowed in a single column before new cards are rejected.',
schema: maxCardsPerColumnSchema,
})