Repository navigation
Expand file tree
/
Copy pathstatus.ts
More file actions
211 lines (194 loc) · 7.66 KB
/
Copy pathstatus.ts
File metadata and controls
211 lines (194 loc) · 7.66 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
// altimate_change start — makeRuntime for the restored Promise wrapper (bottom of file)
import { makeRuntime } from "@/effect/run-service"
// altimate_change end
import { LayerNode } from "@opencode-ai/core/effect/layer-node"
import { InstanceState } from "@/effect/instance-state"
import { SessionID } from "./schema"
import { NonNegativeInt } from "@opencode-ai/core/schema"
import { Effect, Layer, Context, Schema } from "effect"
import { EventV2Bridge } from "@/event-v2-bridge"
import { EventV2 } from "@opencode-ai/core/event"
import { Bus } from "@/bus"
import { BusEvent } from "@/bus/bus-event"
import z from "zod"
export const Info = Schema.Union([
Schema.Struct({
type: Schema.Literal("idle"),
}),
Schema.Struct({
type: Schema.Literal("retry"),
attempt: NonNegativeInt,
message: Schema.String,
action: Schema.optional(
Schema.Struct({
reason: Schema.String,
provider: Schema.String,
title: Schema.String,
message: Schema.String,
label: Schema.String,
link: Schema.optional(Schema.String),
}),
),
next: NonNegativeInt,
}),
Schema.Struct({
type: Schema.Literal("busy"),
}),
]).annotate({ identifier: "SessionStatus" })
export type Info = Schema.Schema.Type<typeof Info>
export const Event = {
Status: EventV2.define({
type: "session.status",
schema: {
sessionID: SessionID,
status: Info,
},
}),
// deprecated
Idle: EventV2.define({
type: "session.idle",
schema: {
sessionID: SessionID,
},
}),
// altimate_change start (AI-7519) — session.phase carries the currently-active bootstrap or per-turn
// sub-step name so the TUI can render an honest "Loading config..." / "Discovering tools..." label
// during the invisible pre-first-visible-response window (target <10s to first visible response).
Phase: EventV2.define({
type: "session.phase",
schema: {
sessionID: SessionID,
// Sub-span name from the traceSpan wrapper — e.g. "bootstrap.resolve-tools". The TUI maps
// this to a human label; unknown phases fall back to "Thinking...".
phase: Schema.String,
// true when the phase opens, false when it closes. TUI clears the label on close if it
// matches the currently-rendered phase.
active: Schema.Boolean,
},
}),
// altimate_change end
}
// altimate_change start - mirror status events onto the legacy Bus SSE stream
// consumed by `altimate-code run`.
const LegacyEvent = {
Status: BusEvent.define(
"session.status",
z.object({
sessionID: SessionID.zod,
status: z.any(),
}),
),
Idle: BusEvent.define(
"session.idle",
z.object({
sessionID: SessionID.zod,
}),
),
// altimate_change start (AI-7519) — legacy SSE mirror for the phase event
Phase: BusEvent.define(
"session.phase",
z.object({
sessionID: SessionID.zod,
phase: z.string(),
active: z.boolean(),
}),
),
// altimate_change end
}
// altimate_change end
export interface Interface {
readonly get: (sessionID: SessionID) => Effect.Effect<Info>
readonly list: () => Effect.Effect<Map<SessionID, Info>>
readonly set: (sessionID: SessionID, status: Info) => Effect.Effect<void>
// altimate_change start (AI-7519) — publish a session.phase event through
// the EventV2 bus so V2 subscribers see phase transitions alongside the
// legacy bus mirror.
readonly publishPhase: (sessionID: SessionID, phase: string, active: boolean) => Effect.Effect<void>
// altimate_change end
}
export class Service extends Context.Service<Service, Interface>()("@opencode/SessionStatus") {}
export const layer = Layer.effect(
Service,
Effect.gen(function* () {
const events = yield* EventV2Bridge.Service
const state = yield* InstanceState.make(
Effect.fn("SessionStatus.state")(() => Effect.succeed(new Map<SessionID, Info>())),
)
const get = Effect.fn("SessionStatus.get")(function* (sessionID: SessionID) {
const data = yield* InstanceState.get(state)
return data.get(sessionID) ?? { type: "idle" as const }
})
const list = Effect.fn("SessionStatus.list")(function* () {
return new Map(yield* InstanceState.get(state))
})
const set = Effect.fn("SessionStatus.set")(function* (sessionID: SessionID, status: Info) {
const data = yield* InstanceState.get(state)
yield* events.publish(Event.Status, { sessionID, status })
if (status.type === "idle") {
yield* events.publish(Event.Idle, { sessionID })
data.delete(sessionID)
return
}
data.set(sessionID, status)
})
// altimate_change start (AI-7519) — V2 publish for the session.phase event.
// Complements the LegacyEvent.Phase publish that happens in the imperative
// wrapper below so both V1 (SSE mirror) and V2 subscribers see phases.
const publishPhase = Effect.fn("SessionStatus.publishPhase")(function* (
sessionID: SessionID,
phase: string,
active: boolean,
) {
yield* events.publish(Event.Phase, { sessionID, phase, active })
})
// altimate_change end
return Service.of({ get, list, set, publishPhase })
}),
)
// altimate_change start — Layer.suspend defers facade refs past circular module-init
export const defaultLayer = Layer.suspend(() => layer.pipe(Layer.provide(EventV2Bridge.defaultLayer)))
// altimate_change end
// altimate_change start — thunk LayerNode deps defers facade refs past circular module-init
export const node = LayerNode.make(layer, () => [EventV2Bridge.node])
// altimate_change end
// altimate_change start — restore the imperative Promise wrapper upstream removed in the
// Effect-only migration; the session prompt loop sets status synchronously.
const { runPromise: runStatus } = makeRuntime(Service, defaultLayer as Layer.Layer<Service>)
export async function set(sessionID: SessionID, status: Info) {
const result = await runStatus((s) => s.set(sessionID, status))
// altimate_change start - keep legacy /event subscribers in sync with EventV2 status.
await Bus.publish(LegacyEvent.Status, { sessionID, status })
if (status.type === "idle") await Bus.publish(LegacyEvent.Idle, { sessionID })
// altimate_change end
return result
}
export async function get(sessionID: SessionID) {
return runStatus((s) => s.get(sessionID))
}
export async function list() {
return runStatus((s) => s.list())
}
// altimate_change end
// altimate_change start (AI-7519) — publish a session.phase event. Fired by SessionPrompt.traceSpan
// on entry (active=true) and exit (active=false); the TUI subscribes to render an honest label like
// "Discovering warehouse tools..." during the pre-first-visible-response window. Publishes on both
// the EventV2 bus (for V2 subscribers) and the LegacyEvent.Phase bus (for the SSE mirror the TUI
// consumes). Best-effort: any failure here must not affect the traced operation itself. The two
// publishes are intentionally sequential (V2 first, legacy second) — an earlier attempt to
// parallelise them via `Promise.allSettled` produced intermittent e2e failures where the label
// wouldn't render, likely because the first ManagedRuntime warm-up races with the immediate
// legacy Bus.publish. Sequential is reliable + the cost is negligible on the hot path.
export async function publishPhase(sessionID: SessionID, phase: string, active: boolean) {
try {
await runStatus((s) => s.publishPhase(sessionID, phase, active))
} catch {
// never surface phase-publish failures back to the caller
}
try {
await Bus.publish(LegacyEvent.Phase, { sessionID, phase, active })
} catch {
// never surface phase-publish failures back to the caller
}
}
// altimate_change end
export * as SessionStatus from "./status"