1import * as Cause from "effect/Cause";
2import * as Effect from "effect/Effect";
3import * as Exit from "effect/Exit";
4import * as Option from "effect/Option";
5import * as Scope from "effect/Scope";
6import { Progress } from "../../Alchemist/Progress.ts";
7import type { Plan } from "../../Plan.ts";
8import * as Report from "../../Report.ts";
9import { CliKit } from "../CliKit/CliKit.ts";
10
11export const renderPlanning =
12 (options: {
13 operation: string;
14 stage: string;
15 computingLabel?: string;
16 readyLabel?: string;
17 }) =>
18 <A, E, R>(effect: Effect.Effect<A, E, R>) =>
19 Effect.gen(function* () {
20 const phaseLabel: Record<Report.PlanningPhase, string> = {
21 "importing-module": "Importing stack module",
22 "resolving-services": "Resolving stack services",
23 "loading-state": "Loading stack state",
24 "computing-plan": options.computingLabel ?? "Computing plan",
25 "plan-ready": options.readyLabel ?? "Plan ready",
26 };
27 const cli = yield* Report.Cli;
28 const planning = yield* cli.startPlanningSession(
29 phaseLabel["importing-module"],
30 options.stage,
31 `${options.operation} · ${options.stage}`,
32 );
33 const interactive = (yield* CliKit).terminal.input;
34 const inFlight = new Set<string>();
35 let done = 0;
36 let total = 0;
37 const showCounter = (fallback: string) =>
38 Effect.suspend(() => {
39 const current: string = inFlight.values().next().value ?? fallback;
40 const rest = inFlight.size > 1 ? ` +${inFlight.size - 1}` : "";
41 return planning.update(
42 phaseLabel["computing-plan"],
43 `${done}/${total} · ${current}${rest}`,
44 );
45 });
46 return yield* effect.pipe(
47 Effect.provideService(Progress, (event) => {
48 switch (event._tag) {
49 case "plan.phase":
50 return event.phase === "plan-ready"
51 ? planning.succeed(phaseLabel["plan-ready"])
52 : planning.update(phaseLabel[event.phase], options.stage, {
53 spinning: event.phase !== "importing-module",
54 });
55 case "state.bootstrap.started":
56 return planning.update(
57 `Bootstrapping state store '${event.store}'`,
58 options.stage,
59 );
60 case "state.bootstrap.completed":
61 return planning.update(
62 phaseLabel["loading-state"],
63 options.stage,
64 );
65 case "plan.resource.started":
66 if (!interactive) return Effect.void;
67 inFlight.add(event.fqn);
68 total = Math.max(total, event.total);
69 return showCounter(event.fqn);
70 case "plan.resource.completed":
71 case "plan.action.completed":
72 if (!interactive) return Effect.void;
73 inFlight.delete(event.fqn);
74 done = event.completed;
75 total = event.total;
76 return showCounter(event.fqn);
77 default:
78 return Effect.void;
79 }
80 }),
81 Effect.tapError(() => planning.fail("Planning failed")),
82 Effect.ensuring(planning.close),
83 );
84 });
85
86export const renderApply =
87 (plan: Plan, options?: Report.PlanDisplayOptions) =>
88 <A, E, R>(effect: Effect.Effect<A, E, R>) =>
89 Effect.gen(function* () {
90 const cli = yield* Report.Cli;
91 const session = yield* cli.startApplySession(plan, options);
92 // Dev keeps the widget mounted after apply settles and parks in the
93 // ambient scope; bind the widget's teardown to that scope so a reload
94 // or Ctrl+C removes it instead of stacking it under the next one.
95 if (options?.dev && session.close !== undefined) {
96 const scope = yield* Effect.serviceOption(Scope.Scope);
97 if (Option.isSome(scope)) {
98 yield* Scope.addFinalizer(scope.value, session.close);
99 }
100 }
101 return yield* effect.pipe(
102 Effect.provideService(Progress, (event) =>
103 event._tag === "apply.resource.status" ||
104 event._tag === "apply.resource.note"
105 ? session.emit(event)
106 : Effect.void,
107 ),
108 Effect.tap((value) =>
109 options?.dev && session.setOutput !== undefined
110 ? session.setOutput(value)
111 : Effect.void,
112 ),
113 Effect.onExit((exit) =>
114 Exit.isSuccess(exit)
115 ? session.done("success")
116 : Cause.hasInterruptsOnly(exit.cause)
117 ? Effect.void
118 : session.done("failure"),
119 ),
120 );
121 });