chore: kanban template
This commit is contained in:
19
packages/functions/src/application-types.d.ts
vendored
Normal file
19
packages/functions/src/application-types.d.ts
vendored
Normal 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> {}
|
||||
6
packages/functions/src/config.ts
Normal file
6
packages/functions/src/config.ts
Normal file
@@ -0,0 +1,6 @@
|
||||
import { pikkuConfig } from '../.pikku/pikku-types.gen.js'
|
||||
|
||||
export const createConfig = pikkuConfig(async () => ({
|
||||
content: {},
|
||||
sqliteDb: '.pikku-runtime/dev.db',
|
||||
}))
|
||||
@@ -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
|
||||
},
|
||||
})
|
||||
76
packages/functions/src/functions/cards-channel.function.ts
Normal file
76
packages/functions/src/functions/cards-channel.function.ts
Normal 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 }
|
||||
},
|
||||
)
|
||||
46
packages/functions/src/functions/create-card.function.ts
Normal file
46
packages/functions/src/functions/create-card.function.ts
Normal 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 }
|
||||
},
|
||||
})
|
||||
31
packages/functions/src/functions/enrich-card.function.ts
Normal file
31
packages/functions/src/functions/enrich-card.function.ts
Normal 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 }
|
||||
},
|
||||
})
|
||||
@@ -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,
|
||||
}
|
||||
},
|
||||
})
|
||||
12
packages/functions/src/functions/kanban.agent.ts
Normal file
12
packages/functions/src/functions/kanban.agent.ts
Normal 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',
|
||||
})
|
||||
32
packages/functions/src/functions/list-cards.function.ts
Normal file
32
packages/functions/src/functions/list-cards.function.ts
Normal 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 }
|
||||
},
|
||||
})
|
||||
@@ -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}'`)
|
||||
},
|
||||
})
|
||||
@@ -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 }
|
||||
},
|
||||
})
|
||||
@@ -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}`)
|
||||
},
|
||||
})
|
||||
35
packages/functions/src/functions/sign-card-file.function.ts
Normal file
35
packages/functions/src/functions/sign-card-file.function.ts
Normal 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(),
|
||||
}
|
||||
},
|
||||
})
|
||||
14
packages/functions/src/middleware/cors.middleware.ts
Normal file
14
packages/functions/src/middleware/cors.middleware.ts
Normal 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'],
|
||||
}),
|
||||
])
|
||||
162
packages/functions/src/scaffold/agent.gen.ts
Normal file
162
packages/functions/src/scaffold/agent.gen.ts
Normal 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 } })
|
||||
9
packages/functions/src/scaffold/auth-middleware.gen.ts
Normal file
9
packages/functions/src/scaffold/auth-middleware.gen.ts
Normal 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()])
|
||||
17
packages/functions/src/scaffold/auth-secrets.gen.ts
Normal file
17
packages/functions/src/scaffold/auth-secrets.gen.ts
Normal 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,
|
||||
})
|
||||
21
packages/functions/src/scaffold/auth.gen.ts
Normal file
21
packages/functions/src/scaffold/auth.gen.ts
Normal 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 },
|
||||
},
|
||||
})
|
||||
114
packages/functions/src/scaffold/console.gen.ts
Normal file
114
packages/functions/src/scaffold/console.gen.ts
Normal 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 } })
|
||||
26
packages/functions/src/scaffold/rpc-public.gen.ts
Normal file
26
packages/functions/src/scaffold/rpc-public.gen.ts
Normal 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,
|
||||
})
|
||||
33
packages/functions/src/scaffold/rpc-remote.gen.ts
Normal file
33
packages/functions/src/scaffold/rpc-remote.gen.ts
Normal 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,
|
||||
})
|
||||
288
packages/functions/src/scaffold/workflow-routes.gen.ts
Normal file
288
packages/functions/src/scaffold/workflow-routes.gen.ts
Normal 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,
|
||||
},
|
||||
},
|
||||
})
|
||||
99
packages/functions/src/services.ts
Normal file
99
packages/functions/src/services.ts
Normal 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 } : {}),
|
||||
}
|
||||
})
|
||||
1
packages/functions/src/types/db.types.ts
Normal file
1
packages/functions/src/types/db.types.ts
Normal file
@@ -0,0 +1 @@
|
||||
export type { DB } from '../../.pikku/db/schema.js'
|
||||
@@ -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,
|
||||
})
|
||||
36
packages/functions/src/wirings/auth.ts
Normal file
36
packages/functions/src/wirings/auth.ts
Normal 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' } },
|
||||
})
|
||||
})
|
||||
7
packages/functions/src/wirings/card-events.queue.ts
Normal file
7
packages/functions/src/wirings/card-events.queue.ts
Normal 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,
|
||||
})
|
||||
22
packages/functions/src/wirings/card-onboarding.workflow.ts
Normal file
22
packages/functions/src/wirings/card-onboarding.workflow.ts
Normal 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'),
|
||||
}),
|
||||
},
|
||||
},
|
||||
})
|
||||
28
packages/functions/src/wirings/cards.channel.ts
Normal file
28
packages/functions/src/wirings/cards.channel.ts
Normal 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'],
|
||||
})
|
||||
48
packages/functions/src/wirings/kanban.cli.ts
Normal file
48
packages/functions/src/wirings/kanban.cli.ts
Normal 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))
|
||||
}),
|
||||
})
|
||||
25
packages/functions/src/wirings/kanban.http.ts
Normal file
25
packages/functions/src/wirings/kanban.http.ts
Normal 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 } })
|
||||
90
packages/functions/src/wirings/kanban.mcp.ts
Normal file
90
packages/functions/src/wirings/kanban.mcp.ts
Normal 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'],
|
||||
})
|
||||
12
packages/functions/src/wirings/kanban.secret.ts
Normal file
12
packages/functions/src/wirings/kanban.secret.ts
Normal 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,
|
||||
})
|
||||
7
packages/functions/src/wirings/kanban.trigger.ts
Normal file
7
packages/functions/src/wirings/kanban.trigger.ts
Normal 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,
|
||||
})
|
||||
12
packages/functions/src/wirings/kanban.variable.ts
Normal file
12
packages/functions/src/wirings/kanban.variable.ts
Normal 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,
|
||||
})
|
||||
Reference in New Issue
Block a user