1- import type { Machine , MachineWithAgents , UsageInfo } from "@agent-kanban/shared" ;
2- import { MACHINE_STALE_TIMEOUT_MS } from "@agent-kanban/shared" ;
1+ import type { AgentRuntime , Machine , MachineRuntime , MachineRuntimeStatus , MachineWithAgents , UsageInfo } from "@agent-kanban/shared" ;
2+ import { AGENT_RUNTIMES , MACHINE_STALE_TIMEOUT_MS , normalizeRuntime , RUNTIME_LABELS } from "@agent-kanban/shared" ;
33import { type D1 , newId , parseJsonFields } from "./db" ;
44
55export interface CreateMachineInfo {
66 name : string ;
77 os : string ;
88 version : string ;
9- runtimes : string [ ] ;
9+ runtimes : MachineRuntime [ ] ;
1010 device_id : string ;
1111}
1212
1313export interface HeartbeatInfo {
1414 version ?: string ;
15- runtimes ?: string [ ] ;
15+ runtimes ?: MachineRuntime [ ] ;
1616 usage_info ?: UsageInfo | null ;
1717}
1818
@@ -24,7 +24,7 @@ export async function upsertMachine(db: D1, ownerId: string, info: CreateMachine
2424 . prepare ( `INSERT INTO machines (id, owner_id, device_id, name, os, version, runtimes, status, created_at)
2525 VALUES (?, ?, ?, ?, ?, ?, ?, 'offline', ?)
2626 ON CONFLICT(owner_id, device_id) DO UPDATE SET name = excluded.name, os = excluded.os, version = excluded.version, runtimes = excluded.runtimes` )
27- . bind ( id , ownerId , info . device_id , info . name , info . os , info . version , JSON . stringify ( info . runtimes ) , now )
27+ . bind ( id , ownerId , info . device_id , info . name , info . os , info . version , JSON . stringify ( normalizeMachineRuntimes ( info . runtimes , now ) ) , now )
2828 . run ( ) ;
2929 const row = await db . prepare ( "SELECT * FROM machines WHERE owner_id = ? AND device_id = ?" ) . bind ( ownerId , info . device_id ) . first < Machine > ( ) ;
3030 return parseMachine ( row ! ) ;
@@ -46,7 +46,7 @@ export async function updateMachine(db: D1, machineId: string, ownerId: string,
4646 }
4747 if ( info . runtimes ) {
4848 sets . push ( "runtimes = ?" ) ;
49- binds . push ( JSON . stringify ( info . runtimes ) ) ;
49+ binds . push ( JSON . stringify ( normalizeMachineRuntimes ( info . runtimes , now ) ) ) ;
5050 }
5151 if ( info . usage_info ) {
5252 sets . push ( "usage_info = ?" ) ;
@@ -129,7 +129,100 @@ export async function listAllMachines(db: D1): Promise<AdminMachine[]> {
129129 return result . results . map ( parseMachine ) ;
130130}
131131
132- const parseMachine = < T extends Machine > ( row : T ) => parseJsonFields ( row , [ "runtimes" , "usage_info" ] ) ;
132+ function parseMachine < T extends Machine > ( row : T ) : T {
133+ const parsed = parseJsonFields ( row , [ "runtimes" , "usage_info" ] ) ;
134+ parsed . runtimes = normalizeMachineRuntimes ( parsed . runtimes ?? [ ] , parsed . last_heartbeat_at ?? parsed . created_at ) ;
135+ return parsed ;
136+ }
137+
138+ const RUNTIME_BY_LABEL = Object . fromEntries ( Object . entries ( RUNTIME_LABELS ) . map ( ( [ runtime , label ] ) => [ label , runtime ] ) ) as Record <
139+ string ,
140+ AgentRuntime
141+ > ;
142+
143+ export function runtimeMatchValues ( runtime : string ) : string [ ] {
144+ const normalized = normalizeRuntime ( runtime ) ;
145+ const canonical = ( RUNTIME_BY_LABEL [ normalized ] ?? normalized ) as AgentRuntime ;
146+ const label = RUNTIME_LABELS [ canonical ] ;
147+ return label && label !== canonical ? [ canonical , label ] : [ canonical ] ;
148+ }
149+
150+ export function runtimeReadyPredicateSql ( runtimeExpr : string ) : string {
151+ return `
152+ (
153+ (
154+ rt.type = 'text'
155+ AND (rt.value = ${ runtimeExpr } OR rt.value = ${ runtimeLabelCaseSql ( runtimeExpr ) } )
156+ )
157+ OR (
158+ json_extract(rt.value, '$.status') = 'ready'
159+ AND json_extract(rt.value, '$.name') = ${ runtimeExpr }
160+ )
161+ )
162+ ` ;
163+ }
164+
165+ function runtimeLabelCaseSql ( runtimeExpr : string ) : string {
166+ const cases = Object . entries ( RUNTIME_LABELS )
167+ . map ( ( [ runtime , label ] ) => `WHEN '${ runtime } ' THEN '${ label . replace ( / ' / g, "''" ) } '` )
168+ . join ( " " ) ;
169+ return `CASE ${ runtimeExpr } ${ cases } END` ;
170+ }
171+
172+ const RUNTIME_STATUSES : readonly MachineRuntimeStatus [ ] = [ "missing" , "unauthorized" , "unhealthy" , "limited" , "ready" ] ;
173+
174+ export function normalizeMachineRuntimes ( runtimes : MachineRuntime [ ] | string [ ] , checkedAt : string ) : MachineRuntime [ ] {
175+ return runtimes . map ( ( runtime ) => {
176+ if ( typeof runtime === "string" ) {
177+ return { name : normalizeMachineRuntimeName ( runtime ) , status : "ready" , checked_at : checkedAt } ;
178+ }
179+ const name = normalizeMachineRuntimeName ( runtime . name ) ;
180+ if ( ! RUNTIME_STATUSES . includes ( runtime . status ) ) {
181+ throw new Error ( `Invalid runtime status "${ runtime . status } "` ) ;
182+ }
183+ return {
184+ name,
185+ status : runtime . status ,
186+ ...( runtime . detail ? { detail : runtime . detail } : { } ) ,
187+ ...( runtime . reset_at ? { reset_at : runtime . reset_at } : { } ) ,
188+ checked_at : runtime . checked_at || checkedAt ,
189+ } ;
190+ } ) ;
191+ }
192+
193+ function normalizeMachineRuntimeName ( runtime : string ) : AgentRuntime {
194+ const normalized = normalizeRuntime ( runtime ) ;
195+ const canonical = RUNTIME_BY_LABEL [ normalized ] ?? normalized ;
196+ if ( ! AGENT_RUNTIMES . includes ( canonical as AgentRuntime ) ) {
197+ throw new Error ( `Invalid runtime "${ runtime } "` ) ;
198+ }
199+ return canonical as AgentRuntime ;
200+ }
201+
202+ export async function isRuntimeAvailable ( db : D1 , ownerId : string , runtime : string ) : Promise < boolean > {
203+ const cutoff = new Date ( Date . now ( ) - MACHINE_STALE_TIMEOUT_MS ) . toISOString ( ) ;
204+ const values = runtimeMatchValues ( runtime ) ;
205+ const placeholders = values . map ( ( ) => "?" ) . join ( ", " ) ;
206+ const row = await db
207+ . prepare ( `
208+ SELECT 1
209+ FROM machines m, json_each(m.runtimes) rt
210+ WHERE m.owner_id = ?
211+ AND m.status = 'online'
212+ AND m.last_heartbeat_at >= ?
213+ AND (
214+ (rt.type = 'text' AND rt.value IN (${ placeholders } ))
215+ OR (
216+ json_extract(rt.value, '$.status') = 'ready'
217+ AND json_extract(rt.value, '$.name') IN (${ placeholders } )
218+ )
219+ )
220+ LIMIT 1
221+ ` )
222+ . bind ( ownerId , cutoff , ...values , ...values )
223+ . first ( ) ;
224+ return ! ! row ;
225+ }
133226
134227export async function detectStaleMachines ( db : D1 ) : Promise < void > {
135228 const cutoff = new Date ( Date . now ( ) - MACHINE_STALE_TIMEOUT_MS ) . toISOString ( ) ;
0 commit comments