1import * as dialogflow from "@distilled.cloud/gcp/dialogflow_v3";
2import * as Data from "effect/Data";
3import * as Effect from "effect/Effect";
4import * as Stream from "effect/Stream";
5import { Unowned } from "../../AdoptPolicy.ts";
6import { isResolved } from "../../Diff.ts";
7import * as Provider from "../../Provider.ts";
8import { Resource } from "../../Resource.ts";
9import { GcpEnvironment } from "../Environment.ts";
10import type { Providers } from "../Providers.ts";
11import {
12 collectionParent,
13 encodeOwnershipLine,
14 hasOwnershipMarker,
15 internalLabels,
16 lastSegment,
17 locationOf,
18 namedAgents,
19 ownedByAlchemy,
20 parentOf,
21 parseOwnership,
22 projectOf,
23 toResourceId,
24 retryQuota,
25} from "./internal.ts";
26
27export type AgentsToolsVersionProps = {
28 /**
29 * Parent tool resource name
30 * `projects/{project}/locations/{location}/agents/{agent}/tools/{tool}`.
31 * Immutable — changing it replaces the version.
32 */
33 tool: string;
34 /**
35 * Version id (the `{version}` segment). Server-assigned when omitted.
36 * Immutable — changing it replaces the version.
37 */
38 versionId?: string;
39 /**
40 * Human-readable name. Tool versions have no labels field, so Alchemy
41 * stamps ownership into this field. Versions are immutable snapshots —
42 * changing the display name after create is a no-op.
43 */
44 displayName?: string;
45};
46
47export type AgentsToolsVersion = Resource<
48 "GCP.Dialogflow.AgentsToolsVersion",
49 AgentsToolsVersionProps,
50 {
51 /** Full resource name `.../tools/{tool}/versions/{version}`. */
52 name: string;
53 /** Version id (last path segment). */
54 versionId: string;
55 /** Parent tool resource name. */
56 tool: string;
57 /** Location id. */
58 location: string;
59 /** Project id. */
60 project: string;
61 /** User display name with the Alchemy ownership prefix stripped. */
62 displayName: string | undefined;
63 /** RFC3339 create timestamp. */
64 createTime: string | undefined;
65 /** RFC3339 update timestamp. */
66 updateTime: string | undefined;
67 },
68 never,
69 Providers
70>;
71
72/**
73 * A Dialogflow CX tool version (immutable snapshot of a tool).
74 *
75 * Versions have no labels field and no update RPC — Alchemy stamps
76 * ownership into `displayName` so `list` / nuke can find them. Reconcile
77 * is observe-ensure: if the snapshot is missing it is created; later
78 * display-name edits are ignored.
79 *
80 * ### Creating a Tool Version
81 * **Example:** Snapshot
82 * ```typescript
83 * const version = yield* GCP.Dialogflow.AgentsToolsVersion("v1", {
84 * tool: tool.name,
85 * displayName: "initial",
86 * });
87 * ```
88 *
89 * @resource
90 * @category Dialogflow
91 */
92export const AgentsToolsVersion = Resource<AgentsToolsVersion>(
93 "GCP.Dialogflow.AgentsToolsVersion",
94);
95
96export class AgentsToolsVersionNotResolved extends Data.TaggedError(
97 "GCP.Dialogflow.AgentsToolsVersionNotResolved",
98)<{
99 name: string;
100}> {}
101
102const resourceName = (tool: string, versionId: string) =>
103 `${tool}/versions/${versionId}`;
104
105const toAttrs = (
106 version: dialogflow.GoogleCloudDialogflowCxV3ToolVersion,
107 project: string,
108 toolHint?: string,
109) => {
110 const name = version.name ?? "";
111 return {
112 name,
113 versionId: lastSegment(name),
114 tool: name.includes("/versions/")
115 ? collectionParent(name, "tools")
116 : (toolHint ?? parentOf(name)),
117 location: locationOf(name),
118 project: projectOf(name) || project,
119 displayName: parseOwnership(version.displayName).text,
120 createTime: version.createTime,
121 updateTime: version.updateTime,
122 };
123};
124
125const getByName = (name: string) =>
126 name.length === 0
127 ? Effect.succeed(undefined)
128 : dialogflow
129 .getProjectsLocationsAgentsToolsVersions({ name })
130 .pipe(Effect.catchTag("NotFound", () => Effect.succeed(undefined)));
131
132const listAt = (parent: string, project: string) =>
133 dialogflow.listProjectsLocationsAgentsToolsVersions
134 .pages({ parent, pageSize: 100 })
135 .pipe(
136 Stream.flatMap((page) => Stream.fromIterable(page.toolVersions ?? [])),
137 Stream.filter((version) => hasOwnershipMarker(version.displayName)),
138 Stream.map((version) => toAttrs(version, project, parent)),
139 Stream.runCollect,
140 Effect.map((chunk) => Array.from(chunk)),
141 Effect.catchTag("NotFound", () => Effect.succeed([])),
142 );
143
144const listTools = (agent: string) =>
145 dialogflow.listProjectsLocationsAgentsTools
146 .pages({ parent: agent, pageSize: 100 })
147 .pipe(
148 Stream.flatMap((page) => Stream.fromIterable(page.tools ?? [])),
149 Stream.runCollect,
150 Effect.map((chunk) => Array.from(chunk)),
151 Effect.catchTag("NotFound", () => Effect.succeed([])),
152 );
153
154const findByDisplayName = (parent: string, displayName: string) =>
155 dialogflow.listProjectsLocationsAgentsToolsVersions
156 .pages({ parent, pageSize: 100 })
157 .pipe(
158 Stream.flatMap((page) => Stream.fromIterable(page.toolVersions ?? [])),
159 Stream.filter((version) => version.displayName === displayName),
160 Stream.runHead,
161 Effect.map((option) =>
162 option._tag === "Some" ? option.value : undefined,
163 ),
164 Effect.catchTag("NotFound", () => Effect.succeed(undefined)),
165 );
166
167export const AgentsToolsVersionProvider = () =>
168 Provider.succeed(AgentsToolsVersion, {
169 stables: ["name", "versionId", "tool", "location", "project", "createTime"],
170
171 diff: Effect.fn(function* ({ news, olds, output }) {
172 if (!isResolved(news)) return undefined;
173 const previousParent = olds?.tool ?? output?.tool;
174 if (previousParent !== undefined && news.tool !== previousParent) {
175 return { action: "replace" as const, deleteFirst: false };
176 }
177 const previousId = olds?.versionId ?? output?.versionId;
178 if (
179 previousId !== undefined &&
180 news.versionId !== undefined &&
181 news.versionId !== previousId
182 ) {
183 return { action: "replace" as const, deleteFirst: false };
184 }
185 return undefined;
186 }),
187
188 read: Effect.fn(function* ({ id, olds, output }) {
189 const env = yield* GcpEnvironment.current;
190 const tool = olds?.tool ?? output?.tool;
191 const name =
192 // Version ids are assigned by the server, so only a recorded name can
193 // be fetched directly (a guessed id is rejected as malformed).
194 output?.name ?? "";
195 let existing = yield* getByName(name);
196 if (existing === undefined && tool !== undefined) {
197 const ownership = yield* internalLabels(id);
198 existing = yield* findByDisplayName(
199 tool,
200 encodeOwnershipLine(ownership, olds?.displayName),
201 );
202 }
203 if (existing === undefined) return undefined;
204 const attrs = toAttrs(existing, env.project, tool);
205 return (yield* ownedByAlchemy(id, existing.displayName))
206 ? attrs
207 : Unowned(attrs);
208 }),
209
210 list: () =>
211 Effect.gen(function* () {
212 const env = yield* GcpEnvironment.current;
213 const agents = yield* namedAgents(env.project);
214 const pages = yield* Effect.forEach(
215 agents,
216 (agent) =>
217 Effect.gen(function* () {
218 const tools = yield* listTools(agent.name);
219 const versions = yield* Effect.forEach(
220 tools,
221 (tool) =>
222 tool.name
223 ? listAt(tool.name, env.project)
224 : Effect.succeed([]),
225 { concurrency: 4 },
226 );
227 return versions.flat();
228 }),
229 { concurrency: 2 },
230 );
231 return pages.flat();
232 }),
233
234 reconcile: Effect.fn(function* ({ id, news, output }) {
235 const env = yield* GcpEnvironment.current;
236 const tool = news.tool;
237 const versionId = yield* toResourceId(
238 id,
239 news.versionId,
240 output?.versionId,
241 );
242 const name = output?.name ?? resourceName(tool, versionId);
243 const ownership = yield* internalLabels(id);
244 const displayName = encodeOwnershipLine(ownership, news.displayName);
245
246 let current = yield* getByName(output?.name ?? "");
247 if (current === undefined) {
248 current = yield* findByDisplayName(tool, displayName);
249 }
250
251 if (current === undefined) {
252 const created = yield* dialogflow
253 .createProjectsLocationsAgentsToolsVersions({
254 parent: tool,
255 body: { displayName },
256 })
257 .pipe(
258 Effect.catchTag("Conflict", () =>
259 findByDisplayName(tool, displayName),
260 ),
261 );
262 current = created ?? undefined;
263 }
264
265 if (current === undefined) {
266 return yield* new AgentsToolsVersionNotResolved({ name });
267 }
268
269 return toAttrs(current, env.project, tool);
270 }, retryQuota),
271
272 delete: Effect.fn(function* ({ output }) {
273 yield* dialogflow
274 .deleteProjectsLocationsAgentsToolsVersions({
275 name: output.name,
276 force: true,
277 })
278 .pipe(Effect.catchTag("NotFound", () => Effect.void));
279 }, retryQuota),
280 });