@@ -33,6 +33,7 @@ import (
3333 "github.com/coder/coder/v2/coderd/database/dbtestutil"
3434 dbpubsub "github.com/coder/coder/v2/coderd/database/pubsub"
3535 "github.com/coder/coder/v2/coderd/util/slice"
36+ "github.com/coder/coder/v2/coderd/workspacestats"
3637 "github.com/coder/coder/v2/codersdk"
3738 "github.com/coder/coder/v2/codersdk/workspacesdk"
3839 "github.com/coder/coder/v2/codersdk/workspacesdk/agentconnmock"
@@ -1956,6 +1957,199 @@ func TestStartWorkspaceTool_EndToEnd(t *testing.T) {
19561957 require .True (t , foundToolResultInSecondCall , "expected second streamed model call to include start_workspace tool output" )
19571958}
19581959
1960+ func TestHeartbeatBumpsWorkspaceUsage (t * testing.T ) {
1961+ t .Parallel ()
1962+
1963+ db , ps := dbtestutil .NewDB (t )
1964+
1965+ ctx := testutil .Context (t , testutil .WaitLong )
1966+ user , model := seedChatDependencies (ctx , t , db )
1967+ setOpenAIProviderBaseURL (ctx , t , db , chattest .NewOpenAI (t , func (req * chattest.OpenAIRequest ) chattest.OpenAIResponse {
1968+ if ! req .Stream {
1969+ return chattest .OpenAINonStreamingResponse ("ok" )
1970+ }
1971+ // Block until the request context is canceled so the chat
1972+ // stays in a processing state long enough for heartbeats
1973+ // to fire.
1974+ chunks := make (chan chattest.OpenAIChunk )
1975+ go func () {
1976+ defer close (chunks )
1977+ <- req .Context ().Done ()
1978+ }()
1979+ return chattest.OpenAIResponse {StreamingChunks : chunks }
1980+ }))
1981+
1982+ // Create a workspace that will be linked to the chat later,
1983+ // simulating the normal flow where a chat is created first
1984+ // and then creates a workspace via create_workspace.
1985+ org := dbgen .Organization (t , db , database.Organization {})
1986+ tmpl := dbgen .Template (t , db , database.Template {
1987+ OrganizationID : org .ID ,
1988+ CreatedBy : user .ID ,
1989+ })
1990+ ws := dbgen .Workspace (t , db , database.WorkspaceTable {
1991+ OwnerID : user .ID ,
1992+ OrganizationID : org .ID ,
1993+ TemplateID : tmpl .ID ,
1994+ })
1995+
1996+ // Set up a short heartbeat interval and a UsageTracker that
1997+ // flushes frequently so last_used_at gets updated in the DB.
1998+ flushTick := make (chan time.Time )
1999+ flushDone := make (chan int , 1 )
2000+ tracker := workspacestats .NewTracker (db ,
2001+ workspacestats .TrackerWithTickFlush (flushTick , flushDone ),
2002+ workspacestats .TrackerWithLogger (slogtest .Make (t , nil )),
2003+ )
2004+ t .Cleanup (func () { tracker .Close () })
2005+
2006+ logger := slogtest .Make (t , & slogtest.Options {IgnoreErrors : true })
2007+ server := chatd .New (chatd.Config {
2008+ Logger : logger ,
2009+ Database : db ,
2010+ ReplicaID : uuid .New (),
2011+ Pubsub : ps ,
2012+ PendingChatAcquireInterval : 10 * time .Millisecond ,
2013+ InFlightChatStaleAfter : testutil .WaitLong ,
2014+ ChatHeartbeatInterval : 100 * time .Millisecond ,
2015+ UsageTracker : tracker ,
2016+ })
2017+ t .Cleanup (func () {
2018+ require .NoError (t , server .Close ())
2019+ })
2020+
2021+ // Create a chat WITHOUT a workspace, the normal starting state.
2022+ chat , err := server .CreateChat (ctx , chatd.CreateOptions {
2023+ OwnerID : user .ID ,
2024+ Title : "usage-tracking-test" ,
2025+ ModelConfigID : model .ID ,
2026+ InitialUserContent : []codersdk.ChatMessagePart {codersdk .ChatMessageText ("hello" )},
2027+ })
2028+ require .NoError (t , err )
2029+
2030+ // Wait for the chat to start processing and at least one
2031+ // heartbeat to fire.
2032+ testutil .Eventually (ctx , t , func (ctx context.Context ) bool {
2033+ fromDB , listErr := db .GetChatByID (ctx , chat .ID )
2034+ if listErr != nil {
2035+ return false
2036+ }
2037+ return fromDB .Status == database .ChatStatusRunning &&
2038+ fromDB .HeartbeatAt .Valid &&
2039+ fromDB .HeartbeatAt .Time .After (fromDB .CreatedAt )
2040+ }, testutil .IntervalFast ,
2041+ "chat should be running with at least one heartbeat" )
2042+
2043+ // Flush the tracker and verify nothing was tracked yet
2044+ // (no workspace linked).
2045+ testutil .RequireSend (ctx , t , flushTick , time .Now ())
2046+ count := testutil .RequireReceive (ctx , t , flushDone )
2047+ require .Equal (t , 0 , count ,
2048+ "expected no workspaces to be flushed before association" )
2049+
2050+ // Link the workspace to the chat in the DB, simulating what
2051+ // the create_workspace tool does mid-conversation.
2052+ _ , err = db .UpdateChatWorkspace (ctx , database.UpdateChatWorkspaceParams {
2053+ WorkspaceID : uuid.NullUUID {UUID : ws .ID , Valid : true },
2054+ ID : chat .ID ,
2055+ })
2056+ require .NoError (t , err )
2057+
2058+ // The heartbeat re-reads the workspace association from the DB
2059+ // on each tick. Wait for the tracker to pick it up.
2060+ testutil .Eventually (ctx , t , func (ctx context.Context ) bool {
2061+ select {
2062+ case flushTick <- time .Now ():
2063+ case <- ctx .Done ():
2064+ return false
2065+ }
2066+ select {
2067+ case c := <- flushDone :
2068+ return c > 0
2069+ case <- ctx .Done ():
2070+ return false
2071+ }
2072+ }, testutil .IntervalMedium ,
2073+ "expected usage tracker to flush the late-associated workspace" )
2074+
2075+ // Verify the workspace's last_used_at was actually updated.
2076+ updatedWs , err := db .GetWorkspaceByID (ctx , ws .ID )
2077+ require .NoError (t , err )
2078+ require .True (t , updatedWs .LastUsedAt .After (ws .LastUsedAt ),
2079+ "workspace last_used_at should have been bumped" )
2080+ }
2081+
2082+ func TestHeartbeatNoWorkspaceNoBump (t * testing.T ) {
2083+ t .Parallel ()
2084+
2085+ db , ps := dbtestutil .NewDB (t )
2086+
2087+ ctx := testutil .Context (t , testutil .WaitLong )
2088+ user , model := seedChatDependencies (ctx , t , db )
2089+ setOpenAIProviderBaseURL (ctx , t , db , chattest .NewOpenAI (t , func (req * chattest.OpenAIRequest ) chattest.OpenAIResponse {
2090+ if ! req .Stream {
2091+ return chattest .OpenAINonStreamingResponse ("ok" )
2092+ }
2093+ chunks := make (chan chattest.OpenAIChunk )
2094+ go func () {
2095+ defer close (chunks )
2096+ <- req .Context ().Done ()
2097+ }()
2098+ return chattest.OpenAIResponse {StreamingChunks : chunks }
2099+ }))
2100+
2101+ // Set up UsageTracker with manual tick/flush.
2102+ usageTickCh := make (chan time.Time )
2103+ flushCh := make (chan int , 1 )
2104+ tracker := workspacestats .NewTracker (db ,
2105+ workspacestats .TrackerWithTickFlush (usageTickCh , flushCh ),
2106+ workspacestats .TrackerWithLogger (slogtest .Make (t , nil )),
2107+ )
2108+ t .Cleanup (func () { tracker .Close () })
2109+
2110+ logger := slogtest .Make (t , & slogtest.Options {IgnoreErrors : true })
2111+ server := chatd .New (chatd.Config {
2112+ Logger : logger ,
2113+ Database : db ,
2114+ ReplicaID : uuid .New (),
2115+ Pubsub : ps ,
2116+ PendingChatAcquireInterval : 10 * time .Millisecond ,
2117+ InFlightChatStaleAfter : testutil .WaitLong ,
2118+ ChatHeartbeatInterval : 100 * time .Millisecond ,
2119+ })
2120+ t .Cleanup (func () {
2121+ require .NoError (t , server .Close ())
2122+ })
2123+
2124+ // Create a chat WITHOUT linking a workspace.
2125+ chat , err := server .CreateChat (ctx , chatd.CreateOptions {
2126+ OwnerID : user .ID ,
2127+ Title : "no-workspace-test" ,
2128+ ModelConfigID : model .ID ,
2129+ InitialUserContent : []codersdk.ChatMessagePart {codersdk .ChatMessageText ("hello" )},
2130+ })
2131+ require .NoError (t , err )
2132+
2133+ // Wait for the chat to be acquired and at least one heartbeat
2134+ // to fire.
2135+ testutil .Eventually (ctx , t , func (ctx context.Context ) bool {
2136+ fromDB , listErr := db .GetChatByID (ctx , chat .ID )
2137+ if listErr != nil {
2138+ return false
2139+ }
2140+ return fromDB .Status == database .ChatStatusRunning &&
2141+ fromDB .HeartbeatAt .Valid &&
2142+ fromDB .HeartbeatAt .Time .After (fromDB .CreatedAt )
2143+ }, testutil .IntervalFast ,
2144+ "chat should be running with at least one heartbeat" )
2145+
2146+ // Flush the tracker. Since no workspace was linked, count
2147+ // should be 0.
2148+ testutil .RequireSend (ctx , t , usageTickCh , time .Now ())
2149+ count := testutil .RequireReceive (ctx , t , flushCh )
2150+ require .Equal (t , 0 , count , "expected no workspaces to be flushed when chat has no workspace" )
2151+ }
2152+
19592153func newTestServer (
19602154 t * testing.T ,
19612155 db database.Store ,
0 commit comments