1import * as qapps from "@distilled.cloud/aws/qapps";
2import * as Effect from "effect/Effect";
3import * as EffectStream from "effect/Stream";
4import { Unowned } from "../../AdoptPolicy.ts";
5import { isResolved } from "../../Diff.ts";
6import { createPhysicalName } from "../../PhysicalName.ts";
7import * as Provider from "../../Provider.ts";
8import { Resource } from "../../Resource.ts";
9import {
10 createInternalTags,
11 diffTags,
12 hasAlchemyTags,
13 type Tags,
14} from "../../Tags.ts";
15import type { Providers } from "../Providers.ts";
16
17export type AppStatus = qapps.AppStatus;
18export type AppRequiredCapability = qapps.AppRequiredCapability;
19export type CardInput = qapps.CardInput;
20
21/**
22 * Definition of an Amazon Q App — the ordered cards that make up the app's
23 * flow plus an optional initial prompt.
24 */
25export interface QAppDefinition {
26 /**
27 * The cards that make up the Q App. Card ids must be UUIDs; dependencies
28 * between cards are calculated by the service from references in prompts
29 * (e.g. `@card-title`).
30 */
31 cards: qapps.CardInput[];
32 /**
33 * The initial prompt displayed when the Q App is started.
34 */
35 initialPrompt?: string;
36}
37
38export interface QAppProps {
39 /**
40 * The unique identifier of the Amazon Q Business application environment
41 * instance the Q App is created in. Changing it replaces the Q App.
42 */
43 instanceId: string;
44 /**
45 * Title of the Q App.
46 * @default ${app}-${stage}-${id}
47 */
48 title?: string;
49 /**
50 * A description of the Q App.
51 */
52 description?: string;
53 /**
54 * The definition of the Q App — its cards and flow.
55 */
56 appDefinition: QAppDefinition;
57 /**
58 * Tags to associate with the Q App.
59 */
60 tags?: Record<string, string>;
61}
62
63export interface QApp extends Resource<
64 "AWS.QApps.QApp",
65 QAppProps,
66 {
67 /**
68 * Service-assigned unique identifier of the Q App.
69 */
70 appId: string;
71 /**
72 * ARN of the Q App.
73 */
74 appArn: string;
75 /**
76 * The Q Business application environment instance the Q App belongs to.
77 */
78 instanceId: string;
79 /**
80 * The Q App's title.
81 */
82 title: string;
83 /**
84 * The Q App's description.
85 */
86 description: string | undefined;
87 /**
88 * The current version of the Q App definition.
89 */
90 appVersion: number;
91 /**
92 * Lifecycle status of the Q App (`DRAFT`, `PUBLISHED`, `DELETED`).
93 */
94 status: AppStatus;
95 /**
96 * Capabilities end users need to run the Q App (e.g. `FileUpload`).
97 */
98 requiredCapabilities: AppRequiredCapability[] | undefined;
99 /**
100 * Current tags reported for the Q App.
101 */
102 tags: Record<string, string>;
103 },
104 never,
105 Providers
106> {}
107
108/**
109 * An Amazon Q App — a lightweight, purpose-built AI app defined as a flow of
110 * cards (text inputs, file uploads, LLM query cards, plugin cards) running
111 * inside an Amazon Q Business application environment.
112 *
113 * :::caution
114 * Q Apps live inside an Amazon Q Business application (pass its id as
115 * `instanceId`), which itself requires IAM Identity Center. The calling
116 * identity must be a user of that Q Business application.
117 * :::
118 * ### Creating Q Apps
119 * **Example:** Prompt-Driven Q App
120 * ```typescript
121 * import * as AWS from "alchemy/AWS";
122 *
123 * const summarizer = yield* AWS.QApps.QApp("Summarizer", {
124 * instanceId: qbusinessApp.applicationId,
125 * description: "Summarizes pasted text",
126 * appDefinition: {
127 * cards: [
128 * {
129 * textInput: {
130 * id: "11111111-1111-4111-8111-111111111111",
131 * title: "Source Text",
132 * type: "text-input",
133 * },
134 * },
135 * {
136 * qQuery: {
137 * id: "22222222-2222-4222-8222-222222222222",
138 * title: "Summary",
139 * type: "q-query",
140 * prompt: "Summarize the following text: @Source Text",
141 * },
142 * },
143 * ],
144 * },
145 * });
146 * ```
147 *
148 * **Example:** File Upload Q App
149 * ```typescript
150 * const analyzer = yield* AWS.QApps.QApp("DocAnalyzer", {
151 * instanceId: qbusinessApp.applicationId,
152 * appDefinition: {
153 * initialPrompt: "Upload a document to analyze",
154 * cards: [
155 * {
156 * fileUpload: {
157 * id: "33333333-3333-4333-8333-333333333333",
158 * title: "Document",
159 * type: "file-upload",
160 * },
161 * },
162 * {
163 * qQuery: {
164 * id: "44444444-4444-4444-8444-444444444444",
165 * title: "Analysis",
166 * type: "q-query",
167 * prompt: "List the key points of @Document",
168 * },
169 * },
170 * ],
171 * },
172 * });
173 * ```
174 *
175 * @resource
176 */
177export const QApp = Resource<QApp>("AWS.QApps.QApp");
178
179const createTitle = (id: string, props: { title?: string | undefined }) =>
180 props.title
181 ? Effect.succeed(props.title)
182 : createPhysicalName({ id, maxLength: 100 });
183
184const fetchTags = Effect.fn(function* (arn: string) {
185 const response = yield* qapps
186 .listTagsForResource({ resourceARN: arn })
187 .pipe(
188 Effect.catchTag("ResourceNotFoundException", () =>
189 Effect.succeed(undefined),
190 ),
191 );
192 const tags: Record<string, string> = {};
193 for (const [key, value] of Object.entries(response?.tags ?? {})) {
194 if (value !== undefined) tags[key] = value;
195 }
196 return tags;
197});
198
199interface QAppState {
200 attrs: QApp["Attributes"];
201 observed: qapps.GetQAppOutput;
202}
203
204const readQAppById = Effect.fn(function* (instanceId: string, appId: string) {
205 const observed = yield* qapps
206 .getQApp({ instanceId, appId })
207 .pipe(
208 Effect.catchTag("ResourceNotFoundException", () =>
209 Effect.succeed(undefined),
210 ),
211 );
212 if (!observed || observed.status === "DELETED") return undefined;
213 const state: QAppState = {
214 observed,
215 attrs: {
216 appId: observed.appId,
217 appArn: observed.appArn,
218 instanceId,
219 title: observed.title,
220 description: observed.description,
221 appVersion: observed.appVersion,
222 status: observed.status,
223 requiredCapabilities: observed.requiredCapabilities?.slice(),
224 tags: yield* fetchTags(observed.appArn),
225 },
226 };
227 return state;
228});
229
230const findQAppByTitle = Effect.fn(function* (
231 instanceId: string,
232 title: string,
233) {
234 const apps = yield* qapps.listQApps.pages({ instanceId }).pipe(
235 EffectStream.runCollect,
236 Effect.map((chunk) => Array.from(chunk).flatMap((page) => page.apps ?? [])),
237 );
238 const match = apps.find(
239 (app) => app.title === title && app.status !== "DELETED",
240 );
241 if (!match?.appId) return undefined;
242 return yield* readQAppById(instanceId, match.appId);
243});
244
245/**
246 * Structural subset comparison: every defined field of `desired` must be
247 * present (deep-equal) in `observed`. Observed cards carry service-computed
248 * fields (`dependencies`, resolved `outputSource` defaults, …) that the
249 * desired card input never specifies — those must not register as drift.
250 */
251const isSubsetOf = (desired: unknown, observed: unknown): boolean => {
252 if (desired === undefined) return true;
253 if (Array.isArray(desired)) {
254 if (!Array.isArray(observed) || observed.length !== desired.length) {
255 return false;
256 }
257 return desired.every((item, index) => isSubsetOf(item, observed[index]));
258 }
259 if (typeof desired === "object" && desired !== null) {
260 if (typeof observed !== "object" || observed === null) return false;
261 const observedRecord: Record<string, unknown> = { ...observed };
262 return Object.entries(desired).every(([key, value]) =>
263 isSubsetOf(value, observedRecord[key]),
264 );
265 }
266 return desired === observed;
267};
268
269const definitionDrifted = (
270 desired: QAppDefinition,
271 observed: qapps.GetQAppOutput,
272): boolean => {
273 if (
274 desired.initialPrompt !== undefined &&
275 desired.initialPrompt !== observed.initialPrompt
276 ) {
277 return true;
278 }
279 return !isSubsetOf(desired.cards, observed.appDefinition.cards);
280};
281
282export const QAppProvider = () =>
283 Provider.effect(
284 QApp,
285 Effect.gen(function* () {
286 return {
287 stables: ["appId", "appArn", "instanceId"],
288 // Q Apps are keyed by their parent Q Business application environment
289 // instance — enumeration across all instances is not meaningful here.
290 list: () => Effect.succeed([]),
291 read: Effect.fn(function* ({ id, olds, output }) {
292 const instanceId = output?.instanceId ?? olds?.instanceId;
293 if (instanceId === undefined) return undefined;
294 const state = output?.appId
295 ? yield* readQAppById(instanceId, output.appId)
296 : yield* findQAppByTitle(
297 instanceId,
298 yield* createTitle(id, olds ?? {}),
299 );
300 if (!state) return undefined;
301 return (yield* hasAlchemyTags(id, state.attrs.tags as Tags))
302 ? state.attrs
303 : Unowned(state.attrs);
304 }),
305 diff: Effect.fn(function* ({ news, olds }) {
306 if (!isResolved(news)) return;
307 if (olds === undefined) return;
308 // A Q App is bound to its Q Business application environment
309 // instance at creation.
310 if (olds.instanceId !== news.instanceId) {
311 return { action: "replace" } as const;
312 }
313 }),
314 reconcile: Effect.fn(function* ({ id, news, output, session }) {
315 if (!news) {
316 return yield* Effect.fail(new Error("QApps QApp requires props"));
317 }
318 const title = yield* createTitle(id, news);
319 const internalTags = yield* createInternalTags(id);
320 const desiredTags = { ...internalTags, ...news.tags };
321
322 // Observe — prefer the cached id; fall back to a title lookup so a
323 // create whose state failed to persist is adopted, not duplicated.
324 let state = output?.appId
325 ? yield* readQAppById(news.instanceId, output.appId)
326 : yield* findQAppByTitle(news.instanceId, title);
327
328 // Ensure — create if missing.
329 if (state === undefined) {
330 const created = yield* qapps.createQApp({
331 instanceId: news.instanceId,
332 title,
333 description: news.description,
334 appDefinition: {
335 cards: news.appDefinition.cards,
336 initialPrompt: news.appDefinition.initialPrompt,
337 },
338 tags: desiredTags,
339 });
340 yield* session.note(`Created Q App ${title} (${created.appId})`);
341 state = yield* readQAppById(news.instanceId, created.appId);
342 if (state === undefined) {
343 return yield* Effect.fail(
344 new Error(`failed to read created Q App ${title}`),
345 );
346 }
347 }
348
349 // Sync title/description/definition via UpdateQApp — only on drift.
350 const observed = state.observed;
351 const needsUpdate =
352 title !== observed.title ||
353 (news.description ?? "") !== (observed.description ?? "") ||
354 definitionDrifted(news.appDefinition, observed);
355 if (needsUpdate) {
356 yield* qapps.updateQApp({
357 instanceId: news.instanceId,
358 appId: state.attrs.appId,
359 title,
360 description: news.description,
361 appDefinition: {
362 cards: news.appDefinition.cards,
363 initialPrompt: news.appDefinition.initialPrompt,
364 },
365 });
366 yield* session.note(`Updated Q App ${title}`);
367 }
368
369 // Sync tags — diff against observed cloud tags.
370 const { removed, upsert } = diffTags(state.attrs.tags, desiredTags);
371 if (removed.length > 0) {
372 yield* qapps.untagResource({
373 resourceARN: state.attrs.appArn,
374 tagKeys: removed,
375 });
376 }
377 if (upsert.length > 0) {
378 yield* qapps.tagResource({
379 resourceARN: state.attrs.appArn,
380 tags: Object.fromEntries(
381 upsert.map(({ Key, Value }) => [Key, Value]),
382 ),
383 });
384 }
385
386 yield* session.note(state.attrs.appArn);
387
388 const final = yield* readQAppById(news.instanceId, state.attrs.appId);
389 if (!final) {
390 return yield* Effect.fail(
391 new Error(`failed to read reconciled Q App ${title}`),
392 );
393 }
394 return final.attrs;
395 }),
396 delete: Effect.fn(function* ({ output }) {
397 yield* qapps
398 .deleteQApp({
399 instanceId: output.instanceId,
400 appId: output.appId,
401 })
402 .pipe(
403 Effect.catchTag("ResourceNotFoundException", () => Effect.void),
404 );
405 }),
406 };
407 }),
408 );