1import * as ces from "@distilled.cloud/gcp/ces_v1";
2import * as Data from "effect/Data";
3import * as Effect from "effect/Effect";
4import { Unowned } from "../../AdoptPolicy.ts";
5import { isResolved } from "../../Diff.ts";
6import * as Provider from "../../Provider.ts";
7import { Resource } from "../../Resource.ts";
8import { GcpEnvironment } from "../Environment.ts";
9import { createInternalLabels } from "../Labels.ts";
10import type { Providers } from "../Providers.ts";
11import {
12 collectPages,
13 encodeOwnership,
14 expandApp,
15 forEachApp,
16 hasOwnershipMarker,
17 normalizeLocation,
18 ownedByAlchemy,
19 parseOwnership,
20 parseResourceName,
21 replaceOnIdentity,
22 retryTransient,
23 sameJson,
24 sameText,
25 toPhysicalId,
26 updateMaskOf,
27 waitUntilGone,
28} from "./internal.ts";
29
30export type AppsExampleProps = {
31 /**
32 * Parent CES app. Full name
33 * `projects/{project}/locations/{location}/apps/{app}` or the app id
34 * (combined with `location`). Immutable — changing it replaces the
35 * example.
36 */
37 app: string;
38 /**
39 * Region used when `app` is a bare id.
40 * @default the stack's GCP region (`GCP.Region`, profile region, `us-central1`)
41 */
42 location?: string;
43 /**
44 * Example id. If omitted, a unique name is generated. Immutable —
45 * changing it replaces the example.
46 */
47 exampleId?: string;
48 /**
49 * Human-readable name. Required by the API; Alchemy falls back to the
50 * generated example id.
51 */
52 displayName?: string;
53 /**
54 * Human-readable description. Examples have no labels field, so
55 * Alchemy stamps ownership into this field.
56 */
57 description?: string;
58 /**
59 * Entry agent resource name. When omitted, the root agent handles the
60 * conversation.
61 */
62 entryAgent?: string;
63 /**
64 * Conversation messages that make up the example.
65 */
66 messages?: ces.MessageList;
67};
68
69export type AppsExample = Resource<
70 "GCP.CES.AppsExample",
71 AppsExampleProps,
72 {
73 /** Full resource name `.../apps/{app}/examples/{example}`. */
74 name: string;
75 /** Example id (last path segment). */
76 exampleId: string;
77 /** Parent app resource name. */
78 app: string;
79 /** Location id. */
80 location: string;
81 /** Project id. */
82 project: string;
83 /** Display name. */
84 displayName: string | undefined;
85 /** User description with the Alchemy ownership prefix stripped. */
86 description: string | undefined;
87 /** Entry agent resource name. */
88 entryAgent: string | undefined;
89 /** Conversation messages. */
90 messages: ces.MessageList | undefined;
91 /** Whether the example is invalid. */
92 invalid: boolean | undefined;
93 /** RFC3339 creation timestamp. */
94 createTime: string | undefined;
95 /** RFC3339 last-update timestamp. */
96 updateTime: string | undefined;
97 /** Server-assigned etag. */
98 etag: string | undefined;
99 },
100 never,
101 Providers
102>;
103
104/**
105 * A Customer Engagement Suite few-shot example conversation inside an
106 * app.
107 *
108 * Examples have no labels field — Alchemy stamps ownership into
109 * `description` so `list` / nuke can find them. Parent app, location,
110 * and example id are immutable.
111 *
112 * ### Creating an Example
113 * **Example:** Greeting transcript
114 * ```typescript
115 * const example = yield* GCP.CES.AppsExample("Hello", {
116 * app: app.name,
117 * displayName: "hello",
118 * messages: [
119 * { role: "user", chunks: [{ text: "Hi" }] },
120 * { role: "agent", chunks: [{ text: "Hello! How can I help?" }] },
121 * ],
122 * });
123 * ```
124 *
125 * @resource
126 * @category CES
127 */
128export const AppsExample = Resource<AppsExample>("GCP.CES.AppsExample");
129
130export class AppsExampleNotResolved extends Data.TaggedError(
131 "GCP.CES.AppsExampleNotResolved",
132)<{
133 name: string;
134}> {}
135
136const resourceName = (app: string, exampleId: string) =>
137 `${app}/examples/${exampleId}`;
138
139const toAttrs = (
140 example: ces.Example,
141 project: string,
142 region: string,
143 appHint?: string,
144) => {
145 const name = example.name ?? "";
146 const parsed = parseResourceName(name, "examples", region);
147 return {
148 name,
149 exampleId: parsed.id,
150 app: name.includes("/examples/") ? parsed.app : (appHint ?? parsed.parent),
151 location: parsed.location,
152 project: parsed.project || project,
153 displayName: example.displayName,
154 description: parseOwnership(example.description).text,
155 entryAgent: example.entryAgent,
156 messages: example.messages,
157 invalid: example.invalid,
158 createTime: example.createTime,
159 updateTime: example.updateTime,
160 etag: example.etag,
161 };
162};
163
164const getByName = (name: string) =>
165 name.length === 0
166 ? Effect.succeed(undefined)
167 : ces
168 .getProjectsLocationsAppsExamples({ name })
169 .pipe(Effect.catchTag("NotFound", () => Effect.succeed(undefined)));
170
171const listAt = (parent: string, project: string, region: string) =>
172 collectPages(
173 ces.listProjectsLocationsAppsExamples.pages({ parent, pageSize: 100 }),
174 (page) => page.examples,
175 ).pipe(
176 Effect.map((examples) =>
177 examples
178 .filter((example) => hasOwnershipMarker(example.description))
179 .map((example) => toAttrs(example, project, region, parent)),
180 ),
181 );
182
183export const AppsExampleProvider = () =>
184 Provider.succeed(AppsExample, {
185 stables: ["name", "exampleId", "app", "location", "project", "createTime"],
186
187 diff: Effect.fn(function* ({ news, olds, output }) {
188 if (!isResolved(news)) return undefined;
189 return replaceOnIdentity({
190 previousId: olds?.exampleId ?? output?.exampleId,
191 nextId: news.exampleId,
192 previousParent: olds?.app ?? output?.app,
193 nextParent: news.app,
194 });
195 }),
196
197 read: Effect.fn(function* ({ id, olds, output }) {
198 const env = yield* GcpEnvironment.current;
199 const location = normalizeLocation(
200 olds?.location ?? output?.location,
201 env.region,
202 );
203 const app = olds?.app
204 ? expandApp(olds.app, env.project, location)
205 : output?.app;
206 const exampleId = yield* toPhysicalId(
207 id,
208 olds?.exampleId,
209 output?.exampleId,
210 );
211 const name =
212 output?.name ?? (app !== undefined ? resourceName(app, exampleId) : "");
213 const existing = yield* getByName(name);
214 if (existing === undefined) return undefined;
215 const attrs = toAttrs(existing, env.project, env.region, app);
216 return (yield* ownedByAlchemy(id, existing.description))
217 ? attrs
218 : Unowned(attrs);
219 }),
220
221 list: () =>
222 Effect.gen(function* () {
223 const env = yield* GcpEnvironment.current;
224 return yield* forEachApp(
225 env.project,
226 (parent) => listAt(parent, env.project, env.region),
227 env.region,
228 );
229 }),
230
231 reconcile: Effect.fn(function* ({ id, news, output }) {
232 const env = yield* GcpEnvironment.current;
233 const location = normalizeLocation(
234 news.location ?? output?.location,
235 env.region,
236 );
237 const app = expandApp(news.app, env.project, location);
238 const exampleId = yield* toPhysicalId(
239 id,
240 news.exampleId,
241 output?.exampleId,
242 );
243 const name = output?.name ?? resourceName(app, exampleId);
244 const ownership = yield* createInternalLabels(id);
245 const description = encodeOwnership(ownership, news.description);
246 const displayName = news.displayName ?? exampleId;
247
248 let current = yield* getByName(name);
249
250 if (current === undefined) {
251 const created = yield* retryTransient(
252 ces.createProjectsLocationsAppsExamples({
253 parent: app,
254 exampleId,
255 body: {
256 displayName,
257 description,
258 entryAgent: news.entryAgent,
259 messages: news.messages,
260 },
261 }),
262 ).pipe(Effect.catchTag("Conflict", () => getByName(name)));
263 current = created ?? undefined;
264 }
265
266 if (current === undefined) {
267 return yield* new AppsExampleNotResolved({ name });
268 }
269
270 const currentName = current.name ?? name;
271 const displayChanged = !sameText(current.displayName, displayName);
272 const descriptionChanged = !sameText(current.description, description);
273 const entryChanged = !sameText(current.entryAgent, news.entryAgent);
274 const messagesChanged = !sameJson(current.messages, news.messages);
275
276 if (
277 displayChanged ||
278 descriptionChanged ||
279 entryChanged ||
280 messagesChanged
281 ) {
282 current = yield* retryTransient(
283 ces.patchProjectsLocationsAppsExamples({
284 name: currentName,
285 updateMask: updateMaskOf(
286 displayChanged ? "display_name" : undefined,
287 descriptionChanged ? "description" : undefined,
288 entryChanged ? "entry_agent" : undefined,
289 messagesChanged ? "messages" : undefined,
290 ),
291 body: {
292 displayName,
293 description,
294 entryAgent: news.entryAgent,
295 messages: news.messages,
296 },
297 }),
298 );
299 }
300
301 return toAttrs(current, env.project, env.region, app);
302 }),
303
304 delete: Effect.fn(function* ({ output }) {
305 if (!output.name) return;
306 yield* retryTransient(
307 ces.deleteProjectsLocationsAppsExamples({ name: output.name }),
308 ).pipe(Effect.catchTag("NotFound", () => Effect.void));
309 yield* waitUntilGone(getByName(output.name));
310 }),
311 });