1/** @effect-diagnostics anyUnknownInErrorContext:off */
2
3/**
4 * The `alchemy dev` provider for `AWS.ECS.Task`: deploys the task
5 * definition (image, roles, log group, ECR repository) into the floci
6 * emulator and hot-swaps running tasks on file change.
7 *
8 * Built on the shared dev-watch skeleton
9 * ([DevWatchProvider](../Local/DevWatchProvider.ts)); this file supplies
10 * only the Task-specific parts:
11 *
12 * - **Watch triggers** by image source ({@link imageSourceTrigger}):
13 * `main` re-uses the deploy's exact rolldown module graph via
14 * `Bundle.watch`; `context` fs-watches the Docker build context;
15 * registry `image` refs have nothing local to watch.
16 * - **Swap mechanism** — every trigger re-runs the LIVE reconcile against
17 * the emulator: the image pipeline is content-addressed, so a real change
18 * builds and pushes a NEW `<repositoryUri>:<hash>` tag and registers a
19 * new task definition revision (an unchanged hash is a cheap no-op).
20 * - **Task restart** lives in the skeleton's `onReconciled` hook — NOT in
21 * the watch loop — so it also fires for engine-driven reconciles.
22 * Prop-only changes (an inline `dockerfile` edit, a new env var) never
23 * produce a file event: before the hook, they registered a new revision
24 * and running tasks kept serving the old one until the next source edit.
25 * RUNNING standalone tasks of the family (launched via `RunTask`) are
26 * stopped and re-run on the new revision — service-managed tasks belong
27 * to [FlociServiceProvider](./FlociServiceProvider.ts).
28 */
29
30import * as Effect from "effect/Effect";
31import * as Stream from "effect/Stream";
32import { makeImageSource, type ImageSourceLike } from "../ECR/ImageSource.ts";
33import {
34 flociProvidersUrl,
35 makeDevWatchProvider,
36} from "../Local/DevWatchProvider.ts";
37import { imageSourceTrigger, restartFamilyTasks } from "./EcsDevWatch.ts";
38import { Task, TaskProvider, taskImageInput, type TaskProps } from "./Task.ts";
39
40export const FlociTaskProvider = () =>
41 makeDevWatchProvider<Task, TaskProps, Task["Attributes"]>(
42 Task,
43 flociProvidersUrl(),
44 {
45 liveProvider: () => TaskProvider(),
46 // The restart surface of the watch loop: everything that changes WHAT
47 // the watcher builds or WHERE it publishes/runs.
48 watchConfigOf: (news, attrs) => {
49 const source = news as ImageSourceLike;
50 return {
51 family: attrs.taskFamily,
52 repositoryName: attrs.repositoryName,
53 main: source.main,
54 handler: source.handler,
55 build: source.build,
56 isExternal: news.isExternal,
57 context: source.context,
58 dockerfile: source.dockerfile,
59 image: source.image,
60 port: news.port,
61 runtimePlatform: news.runtimePlatform,
62 };
63 },
64 // Mirrors the live diff's replacement rule: the family (taskName, or
65 // the id-derived generated name) is the identity.
66 replaceOn: ({ olds, news }) =>
67 Effect.sync(() =>
68 (olds.taskName ?? null) !== (news.taskName ?? null)
69 ? { action: "replace" as const }
70 : undefined,
71 ),
72 // Fires on every reconcile — watcher-triggered AND engine-driven
73 // (prop changes never produce a file event, so restarting only from
74 // the watch loop left running tasks on the old revision).
75 onReconciled: ({ id, previous, attrs }) =>
76 Effect.gen(function* () {
77 if (previous?.taskDefinitionArn === attrs.taskDefinitionArn) return;
78 const startedAt = Date.now();
79 const restarted = yield* restartFamilyTasks({
80 family: attrs.taskFamily,
81 nextTaskDefinitionArn: attrs.taskDefinitionArn,
82 serviceManaged: false,
83 });
84 yield* Effect.logInfo(
85 `[alchemy dev] ${attrs.taskFamily}: task definition swapped (${restarted} task(s) restarted) in ${Date.now() - startedAt}ms`,
86 );
87 }).pipe(
88 Effect.catchCause((cause) =>
89 Effect.logWarning(
90 `[alchemy dev] ${id}: task restart failed`,
91 cause,
92 ),
93 ),
94 ),
95 startWatch: (ctx) =>
96 Effect.gen(function* () {
97 const imageSource = yield* makeImageSource;
98 const trigger = yield* imageSourceTrigger({
99 id: ctx.id,
100 source: ctx.news as ImageSourceLike,
101 isExternal: ctx.news.isExternal,
102 });
103 yield* trigger.pipe(
104 // The reconcile registers the new revision; the `onReconciled`
105 // hook (shared with engine-driven updates) restarts the tasks.
106 Stream.runForEach(() =>
107 Effect.gen(function* () {
108 const current = yield* ctx.currentAttrs;
109 const hash = yield* imageSource.hash(taskImageInput(ctx.news));
110 // Bundle.watch emits its initial build too. Re-registering
111 // unchanged content would delete the ARN just returned by
112 // deploy before callers can launch a task with it.
113 if (hash !== undefined && hash === current.code.hash) return;
114 yield* ctx.rerunReconcile;
115 }).pipe(
116 Effect.catchCause((cause) =>
117 Effect.logWarning(
118 `[alchemy dev] ${ctx.id}: image swap failed`,
119 cause,
120 ),
121 ),
122 ),
123 ),
124 );
125 }),
126 },
127 );