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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 2 additions & 1 deletion packages/client/src/promise/generated/types.ts

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

4 changes: 4 additions & 0 deletions packages/client/src/solid/data.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1108,6 +1108,10 @@ export function createData(config: CreateDataInput) {
return
case "session.execution.started":
setSessionActive(event.data.sessionID, "running")
// Revalidate so `time.busy` follows the projected execution start.
if (!store.session.info[event.data.sessionID] && !sync.has(`session:${event.data.sessionID}`)) return
result.session.invalidate(event.data.sessionID)
refresh(() => result.session.sync(event.data.sessionID))
return
case "session.compaction.started":
if (event.data.inputID) removePending(event.data.sessionID, event.data.inputID)
Expand Down
44 changes: 44 additions & 0 deletions packages/client/test/solid-data.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -330,6 +330,50 @@ test("omits inline tool-result file payloads from read and live tool results whe
}
})

test("refreshes a loaded session's execution start when it starts running", async () => {
const listeners = new Set<Parameters<CreateDataInput["event"]["listen"]>[0]>()
const api = OpenCode.make({
baseUrl: "http://opencode.local",
fetch: async (input, init) => {
const request = input instanceof Request ? input : new Request(input, init)
if (!request.url.endsWith("/api/session/ses_refresh")) throw new Error(`Unexpected request: ${request.url}`)
return Response.json({ data: { ...session(2), time: { ...session(2).time, busy: 3 } } })
},
})
const event: CreateDataInput["event"] = {
on:
<Type extends OpenCodeEvent["type"]>(
_type: Type,
_handler: (event: Extract<OpenCodeEvent, { type: Type }>) => void,
) =>
() => {},
listen(handler) {
listeners.add(handler)
return () => listeners.delete(handler)
},
}
const setup = createRoot((dispose) => ({
data: createData({ api: () => api, directory: "/project", event, connection: { status: () => "connected" } }),
dispose,
}))

try {
setup.data.session.remember(session(2))
const started: OpenCodeEvent = {
id: "evt_started",
created: 3,
type: "session.execution.started",
durable: { aggregateID: "ses_refresh", seq: 1, version: 1 },
data: { sessionID: "ses_refresh" },
}
listeners.forEach((listener) => listener({ name: started.type, details: started }))
expect(setup.data.session.status("ses_refresh")).toBe("running")
await wait(() => setup.data.session.get("ses_refresh")?.time.busy === 3)
} finally {
setup.dispose()
}
})

test("revalidates after an event overtakes an active session read", async () => {
let release!: () => void
const gate = new Promise<void>((resolve) => (release = resolve))
Expand Down
14 changes: 12 additions & 2 deletions packages/core/schema.json
Original file line number Diff line number Diff line change
@@ -1,8 +1,8 @@
{
"version": "7",
"dialect": "sqlite",
"id": "dc16dd12-cb76-432f-8108-0c3aadaf54bb",
"prevIds": ["23940db2-8b4e-4a5e-a404-70d785bd788c"],
"id": "e5daa0af-cae7-4057-919a-ebf44dfced0a",
"prevIds": ["dc16dd12-cb76-432f-8108-0c3aadaf54bb"],
"ddl": [
{
"name": "account_state",
Expand Down Expand Up @@ -1672,6 +1672,16 @@
"entityType": "columns",
"table": "session_v2"
},
{
"type": "integer",
"notNull": false,
"autoincrement": false,
"default": null,
"generated": null,
"name": "time_busy",
"entityType": "columns",
"table": "session_v2"
},
{
"type": "integer",
"notNull": false,
Expand Down
2 changes: 2 additions & 0 deletions packages/core/src/database/migration.gen.ts

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
import { Effect } from "effect"
import type { DatabaseMigration } from "../migration.js"

const migration: DatabaseMigration.Migration = {
id: "20261029073502_session_time_busy",
up(tx) {
return Effect.gen(function* () {
yield* tx.run(`ALTER TABLE \`session_v2\` ADD \`time_busy\` integer;`)
})
},
}

export default migration
1 change: 1 addition & 0 deletions packages/core/src/database/schema.gen.ts

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

1 change: 1 addition & 0 deletions packages/core/src/session/info.ts
Original file line number Diff line number Diff line change
Expand Up @@ -52,6 +52,7 @@ export function fromRow(row: typeof SessionTable.$inferSelect): SessionSchema.In
time: {
created: DateTime.makeUnsafe(row.time_created),
updated: DateTime.makeUnsafe(row.time_updated),
busy: row.time_busy === null ? undefined : DateTime.makeUnsafe(row.time_busy),
idle: row.time_idle === null ? undefined : DateTime.makeUnsafe(row.time_idle),
viewed: row.time_viewed === null ? undefined : DateTime.makeUnsafe(row.time_viewed),
archived: row.time_archived === null ? undefined : DateTime.makeUnsafe(row.time_archived),
Expand Down
8 changes: 8 additions & 0 deletions packages/core/src/session/projector.ts
Original file line number Diff line number Diff line change
Expand Up @@ -787,6 +787,14 @@ const layer = Layer.effectDiscard(
delivery: event.data.delivery,
}),
)
yield* bus.project(SessionEvent.Execution.Started, (event) =>
db
.update(SessionTable)
.set({ time_busy: event.created, time_updated: sql`${SessionTable.time_updated}` })
.where(eq(SessionTable.id, event.data.sessionID))
.run()
.pipe(Effect.orDie),
)
yield* bus.project(SessionEvent.Execution.Succeeded, (event) => projectIdle(db, event))
yield* bus.project(SessionEvent.Execution.Failed, (event) => projectIdle(db, event))
yield* bus.project(SessionEvent.Execution.Interrupted, (event) => projectIdle(db, event))
Expand Down
1 change: 1 addition & 0 deletions packages/core/src/session/sql.ts
Original file line number Diff line number Diff line change
Expand Up @@ -63,6 +63,7 @@ export const SessionTable = sqliteTable(
variant?: string
}>(),
...Timestamps,
time_busy: integer(),
time_idle: integer(),
time_viewed: integer(),
idle_outcome: text().$type<NonNullable<Session.Info["outcome"]>>(),
Expand Down
7 changes: 7 additions & 0 deletions packages/core/src/session/transfer.ts
Original file line number Diff line number Diff line change
Expand Up @@ -154,6 +154,13 @@ const layer = Layer.effect(
tokens_cache_write: input.data.info.tokens.cache.write,
time_created: DateTime.toEpochMillis(input.data.info.time.created),
time_updated: importedAt,
time_busy:
input.data.info.time.idle && input.data.info.time.busy
? Math.min(
DateTime.toEpochMillis(input.data.info.time.idle),
DateTime.toEpochMillis(input.data.info.time.busy),
)
: null,
time_idle: input.data.info.time.idle ? DateTime.toEpochMillis(input.data.info.time.idle) : null,
time_viewed:
input.data.info.time.idle && input.data.info.time.viewed
Expand Down
17 changes: 14 additions & 3 deletions packages/core/test/session-create.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1438,6 +1438,7 @@ describe("SessionTransfer", () => {
time: {
...template.time,
updated: DateTime.makeUnsafe(100),
busy: DateTime.makeUnsafe(120),
idle: DateTime.makeUnsafe(200),
viewed: DateTime.makeUnsafe(150),
},
Expand Down Expand Up @@ -1479,6 +1480,7 @@ describe("SessionTransfer", () => {
})
expect(imported.time).toMatchObject({
updated: DateTime.makeUnsafe(1_000),
busy: DateTime.makeUnsafe(120),
idle: DateTime.makeUnsafe(200),
viewed: DateTime.makeUnsafe(150),
})
Expand All @@ -1488,7 +1490,11 @@ describe("SessionTransfer", () => {
])
expect(yield* Bus.latestSequence(db, sessionID)).toBe(2)
const exported = yield* transfer.export({ sessionID })
expect(exported.info.time).toMatchObject({ idle: DateTime.makeUnsafe(200), viewed: DateTime.makeUnsafe(150) })
expect(exported.info.time).toMatchObject({
busy: DateTime.makeUnsafe(120),
idle: DateTime.makeUnsafe(200),
viewed: DateTime.makeUnsafe(150),
})
expect(exported.messages).toEqual(messages)
const sanitized = yield* transfer.export({ sessionID, sanitize: true })
expect(sanitized.info.time).toMatchObject({ idle: DateTime.makeUnsafe(200), viewed: DateTime.makeUnsafe(150) })
Expand Down Expand Up @@ -1527,7 +1533,7 @@ describe("SessionTransfer", () => {
}),
)

it.effect("clamps an imported viewed watermark to its idle transition", () =>
it.effect("clamps imported busy and viewed watermarks to their idle transition", () =>
Effect.gen(function* () {
const session = yield* Session.Service
const transfer = yield* SessionTransfer.Service
Expand All @@ -1540,6 +1546,7 @@ describe("SessionTransfer", () => {
outcome: "succeeded",
time: {
...template.time,
busy: DateTime.makeUnsafe(300),
idle: DateTime.makeUnsafe(200),
viewed: DateTime.makeUnsafe(250),
},
Expand All @@ -1549,7 +1556,11 @@ describe("SessionTransfer", () => {
location,
})

expect(imported.time).toMatchObject({ idle: DateTime.makeUnsafe(200), viewed: DateTime.makeUnsafe(200) })
expect(imported.time).toMatchObject({
busy: DateTime.makeUnsafe(200),
idle: DateTime.makeUnsafe(200),
viewed: DateTime.makeUnsafe(200),
})
expect(imported.outcome).toBe("succeeded")
}),
)
Expand Down
31 changes: 31 additions & 0 deletions packages/core/test/session-projector.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -600,6 +600,37 @@ describe("SessionProjector", () => {
}),
)

it.effect("records each execution's start, including a resume after shutdown", () =>
Effect.gen(function* () {
const db = yield* seedSession({ time_created: 0, time_updated: 0 })
const bus = yield* Bus.Service
const times = () =>
db
.select({ busy: SessionTable.time_busy, idle: SessionTable.time_idle, updated: SessionTable.time_updated })
.from(SessionTable)
.get()
.pipe(Effect.orDie)

yield* TestClock.setTime(10)
yield* bus.publish(SessionEvent.Execution.Started, { sessionID })
expect(yield* times()).toEqual({ busy: 10, idle: null, updated: 0 })

yield* TestClock.setTime(20)
yield* bus.publish(SessionEvent.Execution.Interrupted, { sessionID, reason: "shutdown" })
yield* TestClock.setTime(30)
yield* bus.publish(SessionEvent.Execution.Started, { sessionID })
expect(yield* times()).toEqual({ busy: 30, idle: null, updated: 0 })

yield* TestClock.setTime(40)
yield* bus.publish(SessionEvent.Execution.Succeeded, { sessionID })
expect(yield* times()).toEqual({ busy: 30, idle: 40, updated: 0 })

yield* TestClock.setTime(50)
yield* bus.publish(SessionEvent.Execution.Started, { sessionID })
expect(yield* times()).toEqual({ busy: 50, idle: 40, updated: 0 })
}),
)

it.effect("updates only the newest incomplete assistant projection", () =>
Effect.gen(function* () {
const db = yield* seedSession()
Expand Down
1 change: 1 addition & 0 deletions packages/core/test/v1-migration.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -66,6 +66,7 @@ const session = (
model: null,
time_created: 1,
time_updated: 2,
time_busy: null,
time_idle: null,
time_viewed: null,
idle_outcome: null,
Expand Down
3 changes: 3 additions & 0 deletions packages/protocol/openapi.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

3 changes: 3 additions & 0 deletions packages/schema/src/session.ts
Original file line number Diff line number Diff line change
Expand Up @@ -46,6 +46,9 @@ export const Info = Schema.Struct({
time: Schema.Struct({
created: DateTimeUtcFromMillis,
updated: DateTimeUtcFromMillis,
/** Start of the most recent execution. While it runs, `idle` still holds the previous execution's end. Resuming after a restart starts a new execution. */
busy: DateTimeUtcFromMillis.pipe(optional),
/** End of the most recent completed execution. */
idle: DateTimeUtcFromMillis.pipe(optional),
viewed: DateTimeUtcFromMillis.pipe(optional),
archived: DateTimeUtcFromMillis.pipe(optional),
Expand Down
Loading