1import * as omics from "@distilled.cloud/aws/omics";
2import type * as Duration from "effect/Duration";
3import * as Effect from "effect/Effect";
4import * as Stream from "effect/Stream";
5import { Unowned } from "../../AdoptPolicy.ts";
6import { isResolved } from "../../Diff.ts";
7import { createPhysicalName } from "../../PhysicalName.ts";
8import * as Provider from "../../Provider.ts";
9import { Resource } from "../../Resource.ts";
10import { createInternalTags, hasAlchemyTags } from "../../Tags.ts";
11import { toWireMinutes } from "../../Util/Duration.ts";
12import type { Providers } from "../Providers.ts";
13import { fetchOmicsTags, syncOmicsTags } from "./internal.ts";
14
15export interface RunGroupProps {
16 /**
17 * A name for the run group. If omitted, a unique name is generated from the
18 * app, stage, and logical ID. Mutable.
19 */
20 name?: string;
21 /**
22 * The maximum number of vCPUs that can run concurrently across all active
23 * runs in the group. Mutable.
24 */
25 maxCpus?: number;
26 /**
27 * The maximum number of concurrent runs for the group. Mutable.
28 */
29 maxRuns?: number;
30 /**
31 * The maximum time for each run in the group, e.g. `"10 hours"` or
32 * `Duration.minutes(600)`. Sent to the API as whole minutes (a bare
33 * number is milliseconds). Mutable.
34 */
35 maxDuration?: Duration.Input;
36 /**
37 * The maximum number of GPUs that can run concurrently across all active
38 * runs in the group. Mutable.
39 */
40 maxGpus?: number;
41 /**
42 * Tags to apply to the run group. Merged with internal Alchemy tags.
43 */
44 tags?: Record<string, string>;
45}
46
47export interface RunGroup extends Resource<
48 "AWS.Omics.RunGroup",
49 RunGroupProps,
50 {
51 /**
52 * ID of the run group.
53 */
54 runGroupId: string;
55 /**
56 * ARN of the run group.
57 */
58 runGroupArn: string;
59 /**
60 * Name of the run group.
61 */
62 name: string;
63 },
64 never,
65 Providers
66> {}
67
68/**
69 * An Amazon HealthOmics run group — a resource-limit envelope for workflow
70 * runs. A run group caps the vCPUs, GPUs, concurrent runs, and per-run
71 * duration for the workflow runs assigned to it.
72 *
73 * A run group name is auto-generated from the app, stage, and logical ID
74 * unless you provide one. All limits are mutable in place.
75 * ### Creating a Run Group
76 * **Example:** Basic Run Group
77 * ```typescript
78 * import * as Omics from "alchemy/AWS/Omics";
79 *
80 * const group = yield* Omics.RunGroup("Batch");
81 * ```
82 *
83 * **Example:** Run Group with Limits
84 * ```typescript
85 * const group = yield* Omics.RunGroup("Batch", {
86 * name: "nightly-batch",
87 * maxCpus: 100,
88 * maxRuns: 10,
89 * maxDuration: "10 hours",
90 * });
91 * ```
92 *
93 * @resource
94 */
95export const RunGroup = Resource<RunGroup>("AWS.Omics.RunGroup");
96
97export const RunGroupProvider = () =>
98 Provider.effect(
99 RunGroup,
100 Effect.gen(function* () {
101 const createName = Effect.fn(function* (
102 id: string,
103 props: { name?: string | undefined },
104 ) {
105 return props.name ?? (yield* createPhysicalName({ id, maxLength: 96 }));
106 });
107
108 return RunGroup.Provider.of({
109 stables: ["runGroupId", "runGroupArn"],
110 list: () =>
111 omics.listRunGroups.items({}).pipe(
112 Stream.map((item) => ({
113 runGroupId: item.id!,
114 runGroupArn: item.arn!,
115 name: item.name ?? "",
116 })),
117 Stream.runCollect,
118 Effect.map((chunk) => Array.from(chunk)),
119 ),
120 read: Effect.fn(function* ({ id, output }) {
121 if (output?.runGroupId === undefined) return undefined;
122 const found = yield* omics
123 .getRunGroup({ id: output.runGroupId })
124 .pipe(
125 Effect.catchTag("ResourceNotFoundException", () =>
126 Effect.succeed(undefined),
127 ),
128 );
129 if (found === undefined || found.id === undefined) return undefined;
130 const attrs = {
131 runGroupId: found.id,
132 runGroupArn: found.arn!,
133 name: found.name ?? "",
134 };
135 const tags = yield* fetchOmicsTags(found.arn!);
136 return (yield* hasAlchemyTags(id, tags)) ? attrs : Unowned(attrs);
137 }),
138 // No replacement path: every prop (name + limits) is mutable via
139 // updateRunGroup. Return undefined to run the default update path.
140 diff: Effect.fn(function* ({ news }) {
141 if (!isResolved(news)) return undefined;
142 }),
143 reconcile: Effect.fn(function* ({ id, news, output, session }) {
144 const name = output?.name ?? (yield* createName(id, news));
145 const maxDurationMinutes = toWireMinutes(news.maxDuration);
146 const internalTags = yield* createInternalTags(id);
147 const desiredTags = { ...news.tags, ...internalTags };
148
149 // OBSERVE — the run group id is a server-generated cache.
150 let group =
151 output?.runGroupId === undefined
152 ? undefined
153 : yield* omics
154 .getRunGroup({ id: output.runGroupId })
155 .pipe(
156 Effect.catchTag("ResourceNotFoundException", () =>
157 Effect.succeed(undefined),
158 ),
159 );
160
161 // ENSURE — create if missing. `requestId` is a client idempotency
162 // token; a stable value derived from the logical id keeps a retried
163 // create from provisioning a duplicate group.
164 if (group === undefined || group.id === undefined) {
165 const created = yield* omics.createRunGroup({
166 name,
167 maxCpus: news.maxCpus,
168 maxRuns: news.maxRuns,
169 maxDuration: maxDurationMinutes,
170 maxGpus: news.maxGpus,
171 requestId: `alchemy-${id}`,
172 tags: desiredTags,
173 });
174 group = yield* omics.getRunGroup({ id: created.id! });
175 } else {
176 // SYNC — apply the delta of mutable settings. Observed cloud state
177 // is the diff baseline.
178 const patch: {
179 name?: string;
180 maxCpus?: number;
181 maxRuns?: number;
182 maxDuration?: number;
183 maxGpus?: number;
184 } = {};
185 if (news.name !== undefined && news.name !== group.name) {
186 patch.name = news.name;
187 }
188 if (news.maxCpus !== undefined && news.maxCpus !== group.maxCpus) {
189 patch.maxCpus = news.maxCpus;
190 }
191 if (news.maxRuns !== undefined && news.maxRuns !== group.maxRuns) {
192 patch.maxRuns = news.maxRuns;
193 }
194 if (
195 maxDurationMinutes !== undefined &&
196 maxDurationMinutes !== group.maxDuration
197 ) {
198 patch.maxDuration = maxDurationMinutes;
199 }
200 if (news.maxGpus !== undefined && news.maxGpus !== group.maxGpus) {
201 patch.maxGpus = news.maxGpus;
202 }
203 if (Object.keys(patch).length > 0) {
204 yield* omics.updateRunGroup({ id: group.id, ...patch });
205 group = yield* omics.getRunGroup({ id: group.id });
206 }
207 }
208
209 // SYNC TAGS — diff against observed cloud tags so adoption converges.
210 yield* syncOmicsTags(group.arn!, desiredTags);
211
212 yield* session.note(group.id!);
213 return {
214 runGroupId: group.id!,
215 runGroupArn: group.arn!,
216 name: group.name ?? name,
217 };
218 }),
219 delete: Effect.fn(function* ({ output }) {
220 yield* omics
221 .deleteRunGroup({ id: output.runGroupId })
222 .pipe(
223 Effect.catchTag("ResourceNotFoundException", () => Effect.void),
224 );
225 }),
226 });
227 }),
228 );