1import * as Context from "effect/Context";
2import * as Effect from "effect/Effect";
3import type { NonInteractiveTerminal } from "./Interaction.ts";
4import type { Plan } from "./Plan.ts";
5import type { ProviderMode } from "./ProviderMode.ts";
6
7// The engine's reporting contract: the events apply/sync emit while they
8// converge a plan, and the session/renderer interfaces a presentation layer
9// (the bundled CLI, a custom renderer, tests) implements to observe them.
10// Deliberately presentation-free — nothing in here knows about terminals.
11
12export type ApplyStatus =
13 | "attaching"
14 | "post-attach"
15 | "pending"
16 | "pre-creating"
17 | "creating"
18 | "creating replacement"
19 | "created"
20 | "updating"
21 | "updated"
22 | "adopting"
23 | "adopted"
24 | "deleting"
25 | "deleted"
26 | "orphaning"
27 | "orphaned"
28 | "replacing"
29 | "replaced"
30 // Action lifecycle (see {@link Action})
31 | "running"
32 | "ran"
33 | "skipped"
34 | "fail";
35
36export type ApplyEvent = ResourceAnnotated | ResourceStatusChanged;
37
38/**
39 * How append-only renderers surface a note (the interactive view treats every
40 * kind the same): `"status"` is transient spinner text that plain mode folds
41 * into the settle line's suffix, `"output"` is verbatim tool output streamed
42 * line by line, and `undefined` is a one-shot informational note.
43 */
44export type NoteKind = "status" | "output";
45
46export interface ResourceAnnotated {
47 _tag: "apply.resource.note";
48 /** Fully-qualified resource or action name used to target its plan row. */
49 fqn: string;
50 id: string;
51 message: string;
52 kind?: NoteKind;
53}
54
55export interface ResourceStatusChanged {
56 _tag: "apply.resource.status";
57 /** Fully-qualified resource or action name used to target its plan row. */
58 fqn: string;
59 id: string; // resource id (e.g. "messages", "api")
60 type: string; // resource type (e.g. "AWS::Lambda::Function", "Cloudflare::Worker")
61 status: ApplyStatus;
62 message?: string; // optional details
63 /**
64 * The {@link ProviderMode} this node's provider was resolved for.
65 * `undefined` for mode-agnostic providers (a single implementation serves
66 * both dev and deploy) and for actions — renderers show nothing special.
67 */
68 providerMode?: ProviderMode;
69 /**
70 * Set only on mode-switch replacements (local ⇄ live): the mode the OLD
71 * generation was created with, so renderers can annotate the transition
72 * (e.g. `local → live`). Always differs from `providerMode` when set.
73 */
74 fromProviderMode?: ProviderMode;
75}
76
77/** One binding row on a planned resource. */
78export interface PlannedBinding {
79 readonly sid: string;
80 readonly action: "create" | "update" | "delete" | "noop";
81}
82
83/** Serializable view of one resource row in a plan. */
84export interface PlannedResource {
85 readonly fqn: string;
86 readonly logicalId: string;
87 readonly resourceType: string;
88 /** The action the plan decided for this resource. */
89 readonly action:
90 | "create"
91 | "update"
92 | "adopted"
93 | "replace"
94 | "delete"
95 | "orphaned"
96 | "noop";
97 /** Binding rows the node carries; empty when the resource has none. */
98 readonly bindings: ReadonlyArray<PlannedBinding>;
99 /** Mode the node's provider resolved for; absent for mode-agnostic providers. */
100 readonly providerMode?: ProviderMode;
101 /** On mode-switch replacements: the mode the old generation was created with. */
102 readonly fromProviderMode?: ProviderMode;
103}
104
105/** Serializable view of one stack-action row in a plan. */
106export interface PlannedAction {
107 readonly fqn: string;
108 readonly logicalId: string;
109 readonly actionType: string;
110 readonly action: "run" | "delete" | "noop";
111}
112
113/**
114 * Emitted as a resource's diff begins. Diffs run with unbounded concurrency
115 * and may read the cloud (refresh, adoption), so a renderer that only sees
116 * completions freezes on the last *finished* node while the slow tail is
117 * still working — pairing starts with {@link ResourcePlanned} lets it show
118 * what is actually in flight.
119 */
120export interface ResourceDiffStarted {
121 readonly _tag: "plan.resource.started";
122 readonly fqn: string;
123 readonly logicalId: string;
124 readonly resourceType: string;
125 readonly total: number;
126}
127
128/** Emitted as the planner finishes diffing one resource. */
129export interface ResourcePlanned extends PlannedResource {
130 readonly _tag: "plan.resource.completed";
131 /** How many of `total` resources have been diffed so far. */
132 readonly completed: number;
133 readonly total: number;
134}
135
136/** Emitted as the planner decides one stack action. */
137export interface ActionPlanned extends PlannedAction {
138 readonly _tag: "plan.action.completed";
139 readonly completed: number;
140 readonly total: number;
141}
142
143/** Phases a stack plan moves through before it is ready. */
144export type PlanningPhase =
145 | "importing-module"
146 | "resolving-services"
147 | "loading-state"
148 | "computing-plan"
149 | "plan-ready";
150
151/**
152 * Emitted as the planning pipeline crosses a work boundary, by whoever owns
153 * that boundary: the session emits `importing-module`/`resolving-services`
154 * around the module import and service build, the engine emits
155 * `loading-state` (before the state service resolves — which may bootstrap
156 * a remote store — and the persisted rows are read) and `computing-plan`
157 * (once diffing begins), and the route emits `plan-ready`. Carries no
158 * display text — wording is the renderer's job.
159 */
160export interface PlanPhaseChanged {
161 readonly _tag: "plan.phase";
162 readonly phase: PlanningPhase;
163}
164
165/** A per-node planning event (one resource or action diffed). */
166export type PlanNodeEvent =
167 | ResourceDiffStarted
168 | ResourcePlanned
169 | ActionPlanned;
170
171export type PlanEvent = PlanPhaseChanged | PlanNodeEvent;
172
173/**
174 * Emitted around a state-store bootstrap or upgrade (e.g. deploying the
175 * Cloudflare state-store worker on first use). Bootstrap happens lazily
176 * inside "loading state", can take many seconds, and would otherwise be
177 * invisible to renderers and traces.
178 */
179export interface StateBootstrapStarted {
180 readonly _tag: "state.bootstrap.started";
181 /** The store being bootstrapped (e.g. the worker script name). */
182 readonly store: string;
183}
184
185export interface StateBootstrapCompleted {
186 readonly _tag: "state.bootstrap.completed";
187 readonly store: string;
188}
189
190export type StateEvent = StateBootstrapStarted | StateBootstrapCompleted;
191
192export interface NukeScanStarted {
193 readonly _tag: "nuke.scan.started";
194 readonly total: number;
195}
196
197export interface NukeProviderScanStarted {
198 readonly _tag: "nuke.scan.provider.started";
199 readonly provider: string;
200}
201
202export interface NukePassStarted {
203 readonly _tag: "nuke.pass.started";
204 readonly pass: number;
205}
206
207/** Emitted as a nuke scan finishes enumerating one provider's resources. */
208export interface NukeProviderScanned {
209 readonly _tag: "nuke.scan.provider.completed";
210 readonly provider: string;
211 /** Resources found for this provider (0 when the listing failed). */
212 readonly resources: number;
213 /** Listing failure, reported immediately when this provider settles. */
214 readonly error?: string;
215}
216
217export interface NukeResourceDeleted {
218 readonly _tag: "nuke.resource.deleted";
219 readonly provider: string;
220 /** Display name of the deleted resource. */
221 readonly resource: string;
222}
223
224/** Emitted when a nuke deletion attempt fails (the run keeps going). */
225export interface NukeResourceFailed {
226 readonly _tag: "nuke.resource.failed";
227 readonly provider: string;
228 readonly resource: string;
229 readonly message: string;
230}
231
232export type NukeEvent =
233 | NukeScanStarted
234 | NukeProviderScanStarted
235 | NukePassStarted
236 | NukeProviderScanned
237 | NukeResourceDeleted
238 | NukeResourceFailed;
239
240export interface ProviderConfigureStarted {
241 readonly _tag: "provider.configure.started";
242 readonly provider: string;
243}
244
245export interface ProviderConfigureCompleted {
246 readonly _tag: "provider.configure.completed";
247 readonly provider: string;
248}
249
250export interface ProviderRefreshStarted {
251 readonly _tag: "provider.refresh.started";
252 readonly provider: string;
253}
254
255export interface ProviderRefreshCompleted {
256 readonly _tag: "provider.refresh.completed";
257 readonly provider: string;
258}
259
260export type ProviderEvent =
261 | ProviderConfigureStarted
262 | ProviderConfigureCompleted
263 | ProviderRefreshStarted
264 | ProviderRefreshCompleted;
265
266/**
267 * Every progress event the engine and the Alchemist routes can report — one
268 * flat union, one `_tag` discriminator, tags namespaced `domain.subject.verb`.
269 */
270export type ProgressEvent =
271 | PlanEvent
272 | ApplyEvent
273 | StateEvent
274 | NukeEvent
275 | ProviderEvent;
276
277export type ProgressReporter = (event: ProgressEvent) => Effect.Effect<void>;
278
279/**
280 * The one ambient progress channel. Everything long-running — the planner's
281 * phase/node events, state-store bootstrap, nuke scans and deletions,
282 * provider configure/refresh — reports through it. Defaults to a no-op, so
283 * a caller that only wants the result provides nothing; a renderer provides
284 * its own handler for the events it wants to observe.
285 */
286export const Progress = Context.Reference<ProgressReporter>(
287 "alchemy/Progress",
288 { defaultValue: (): ProgressReporter => () => Effect.void },
289);
290
291export interface PlanStatusSession {
292 emit: (event: ApplyEvent) => Effect.Effect<void>;
293 done: (outcome: "success" | "failure") => Effect.Effect<void>;
294 /** Replace the dev widget's stack output view without writing scrollback. */
295 setOutput?: (value: unknown) => Effect.Effect<void>;
296 /**
297 * Tear down the session's live presentation without settling it. Dev keeps
298 * its widget mounted after `done`; an interrupted generation (reload,
299 * Ctrl+C) must remove it so the next generation does not stack another.
300 */
301 close?: Effect.Effect<void>;
302}
303
304/** A session that drops everything — the default when no renderer is ambient. */
305export const noopSession: PlanStatusSession = {
306 emit: () => Effect.void,
307 done: () => Effect.void,
308};
309
310export interface PlanningStatusSession {
311 update: (
312 label: string,
313 detail?: string,
314 options?: {
315 /**
316 * Animate the status glyph. Pass `false` for phases whose work runs
317 * synchronously on the main thread (e.g. module import) — a spinner
318 * cannot animate while the event loop is blocked, so it freezes
319 * mid-frame and reads as hung; a static row doesn't promise motion.
320 */
321 readonly spinning?: boolean;
322 },
323 ) => Effect.Effect<void>;
324 succeed: (message?: string) => Effect.Effect<void>;
325 fail: (message?: string) => Effect.Effect<void>;
326 close: Effect.Effect<void>;
327}
328
329export interface ScopedPlanStatusSession extends PlanStatusSession {
330 note: (
331 note: string,
332 options?: { readonly kind?: NoteKind },
333 ) => Effect.Effect<void>;
334}
335
336export interface PlanDisplayOptions {
337 /** Show declared resource inputs as structured YAML. */
338 detailed?: boolean;
339 /** Stage displayed in terminal lifecycle updates. */
340 stage?: string;
341 /** Keep the dev plan mounted as a collapsible widget below static logs. */
342 dev?: boolean;
343}
344
345export interface CLIService {
346 startPlanningSession: (
347 label: string,
348 detail?: string,
349 title?: string,
350 ) => Effect.Effect<PlanningStatusSession>;
351 approvePlan: <P extends Plan>(
352 plan: P,
353 options?: PlanDisplayOptions,
354 ) => Effect.Effect<boolean, NonInteractiveTerminal>;
355 displayPlan: <P extends Plan>(
356 plan: P,
357 options?: PlanDisplayOptions,
358 ) => Effect.Effect<void>;
359 startApplySession: <P extends Plan>(
360 plan: P,
361 options?: PlanDisplayOptions,
362 ) => Effect.Effect<PlanStatusSession>;
363}
364
365export class Cli extends Context.Service<Cli, CLIService>()("CLI") {}