| 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 | |
| 30 | import * as Effect from "effect/Effect"; |
| 31 | import * as Stream from "effect/Stream"; |
| 32 | import { makeImageSource, type ImageSourceLike } from "../ECR/ImageSource.ts"; |
| 33 | import { |
| 34 | flociProvidersUrl, |
| 35 | makeDevWatchProvider, |
| 36 | } from "../Local/DevWatchProvider.ts"; |
| 37 | import { imageSourceTrigger, restartFamilyTasks } from "./EcsDevWatch.ts"; |
| 38 | import { Task, TaskProvider, taskImageInput, type TaskProps } from "./Task.ts"; |
| 39 | |
| 40 | export 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 | ); |