1import * as datalabeling from "@distilled.cloud/gcp/datalabeling_v1beta1";
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 encodeOwnership,
13 findOwned,
14 hasOwnershipMarker,
15 ignoreGone,
16 listAllFeedbackMessages,
17 MAX_FEEDBACK_BODY_LENGTH,
18 noRetryLayer,
19 ownedByAlchemy,
20 parseOwnership,
21 parseResourceName,
22 replaceOnIdentity,
23 retryDelete,
24 retryTransient,
25 sameText,
26 waitForVisible,
27 waitUntilGone,
28} from "./internal.ts";
29import { resourceNameFromOperation, waitForOperation } from "./operations.ts";
30
31export type DatasetsAnnotatedDatasetsFeedbackThreadsFeedbackMessageProps = {
32 /**
33 * Parent feedback thread resource name
34 * `projects/{project}/datasets/{dataset}/annotatedDatasets/{annotatedDataset}/feedbackThreads/{feedbackThread}`.
35 * Immutable — changing it replaces the message.
36 */
37 parent: string;
38 /**
39 * Feedback message id (the last path segment). Server-assigned on
40 * create. Immutable — changing it replaces the message.
41 */
42 feedbackMessageId?: string;
43 /**
44 * String content of the feedback. Maximum 10,000 characters. Messages
45 * have no labels field, so Alchemy stamps ownership into this field.
46 * Immutable — changing it replaces the message.
47 */
48 body?: string;
49 /**
50 * Image payload for this feedback, if the comment is an image.
51 * Immutable — changing it replaces the message.
52 */
53 image?: string;
54};
55
56export type DatasetsAnnotatedDatasetsFeedbackThreadsFeedbackMessage = Resource<
57 "GCP.DataLabeling.DatasetsAnnotatedDatasetsFeedbackThreadsFeedbackMessage",
58 DatasetsAnnotatedDatasetsFeedbackThreadsFeedbackMessageProps,
59 {
60 /**
61 * Full resource name
62 * `.../feedbackThreads/{thread}/feedbackMessages/{message}`.
63 */
64 name: string;
65 /** Feedback message id (last path segment). */
66 feedbackMessageId: string;
67 /** Parent feedback thread resource name. */
68 parent: string;
69 /** Project id. */
70 project: string;
71 /** Dataset id. */
72 datasetId: string;
73 /** User body with the Alchemy ownership prefix stripped. */
74 body: string | undefined;
75 /** Image payload, if any. */
76 image: string | undefined;
77 /** RFC3339 creation timestamp. */
78 createTime: string | undefined;
79 },
80 never,
81 Providers
82>;
83
84/**
85 * A Data Labeling feedback message on a labeling-task feedback thread.
86 *
87 * Create is a long-running operation. Message ids are server-assigned.
88 * Parent thread, body, and image are immutable — changing them replaces
89 * the message. There is no labels API, so Alchemy stamps ownership into
90 * `body` so `list` / nuke can find them.
91 *
92 * ### Creating a Feedback Message
93 * **Example:** Text comment on a thread
94 * ```typescript
95 * const message = yield* GCP.DataLabeling.DatasetsAnnotatedDatasetsFeedbackThreadsFeedbackMessage(
96 * "Note",
97 * {
98 * parent: threadName,
99 * body: "please relabel the occluded boxes",
100 * },
101 * );
102 * ```
103 *
104 * **Example:** Ownership-only body
105 * ```typescript
106 * const message = yield* GCP.DataLabeling.DatasetsAnnotatedDatasetsFeedbackThreadsFeedbackMessage(
107 * "Note",
108 * {
109 * parent: threadName,
110 * },
111 * );
112 * ```
113 *
114 * @resource
115 * @category DataLabeling
116 */
117export const DatasetsAnnotatedDatasetsFeedbackThreadsFeedbackMessage =
118 Resource<DatasetsAnnotatedDatasetsFeedbackThreadsFeedbackMessage>(
119 "GCP.DataLabeling.DatasetsAnnotatedDatasetsFeedbackThreadsFeedbackMessage",
120 );
121
122export class DatasetsAnnotatedDatasetsFeedbackThreadsFeedbackMessageNotResolved extends Data.TaggedError(
123 "GCP.DataLabeling.DatasetsAnnotatedDatasetsFeedbackThreadsFeedbackMessageNotResolved",
124)<{
125 name: string;
126}> {}
127
128const resourceNameOf = (parent: string, feedbackMessageId: string) =>
129 `${parent}/feedbackMessages/${feedbackMessageId}`;
130
131const expandParent = (parent: string, project: string) =>
132 parent.includes("/")
133 ? parent.replace(/\/+$/, "")
134 : `projects/${project}/datasets/_/annotatedDatasets/_/feedbackThreads/${parent}`;
135
136const toAttrs = (
137 message: datalabeling.GoogleCloudDatalabelingV1beta1FeedbackMessage,
138 project: string,
139) => {
140 const name = message.name ?? "";
141 const parsed = parseResourceName(
142 name.replace(/\/feedbackMessage\//, "/feedbackMessages/"),
143 "feedbackMessages",
144 );
145 const fallback = parseResourceName(name, "feedbackMessage");
146 const collection = parsed.id.length > 0 ? parsed : fallback;
147 return {
148 name,
149 feedbackMessageId: collection.id,
150 parent: collection.parent,
151 project: collection.project || project,
152 datasetId: collection.datasetId,
153 body: parseOwnership(message.body).text,
154 image: message.image,
155 createTime: message.createTime,
156 };
157};
158
159const getByName = (name: string) =>
160 name.length === 0
161 ? Effect.succeed(undefined)
162 : datalabeling
163 .getProjectsDatasetsAnnotatedDatasetsFeedbackThreadsFeedbackMessages({
164 name,
165 })
166 .pipe(
167 Effect.provide(noRetryLayer),
168 Effect.catchTag("NotFound", () => Effect.succeed(undefined)),
169 Effect.catchTag("BadGateway", () => Effect.succeed(undefined)),
170 );
171
172const findByOwnership = (id: string, project: string) =>
173 Effect.gen(function* () {
174 const rows = yield* listAllFeedbackMessages(project);
175 return yield* findOwned(id, rows, (row) => row.body);
176 });
177
178export const DatasetsAnnotatedDatasetsFeedbackThreadsFeedbackMessageProvider =
179 () =>
180 Provider.succeed(DatasetsAnnotatedDatasetsFeedbackThreadsFeedbackMessage, {
181 stables: [
182 "name",
183 "feedbackMessageId",
184 "parent",
185 "project",
186 "datasetId",
187 "createTime",
188 ],
189
190 diff: Effect.fn(function* ({ news, olds, output }) {
191 if (!isResolved(news)) return undefined;
192 const extra =
193 (olds !== undefined && !sameText(news.body, output?.body)) ||
194 (output !== undefined && !sameText(news.image, output.image));
195 return replaceOnIdentity({
196 previousId: olds?.feedbackMessageId ?? output?.feedbackMessageId,
197 nextId: news.feedbackMessageId,
198 previousParent: olds?.parent ?? output?.parent,
199 nextParent: news.parent,
200 extra,
201 });
202 }),
203
204 read: Effect.fn(function* ({ id, olds, output }) {
205 const env = yield* GcpEnvironment.current;
206 const parent =
207 olds?.parent !== undefined
208 ? expandParent(olds.parent, env.project)
209 : (output?.parent ?? "");
210 const feedbackMessageId =
211 olds?.feedbackMessageId ??
212 output?.feedbackMessageId ??
213 (output?.name
214 ? parseResourceName(
215 output.name.replace(
216 /\/feedbackMessage\//,
217 "/feedbackMessages/",
218 ),
219 "feedbackMessages",
220 ).id
221 : "");
222 const name =
223 output?.name ??
224 (parent.length > 0 && feedbackMessageId.length > 0
225 ? resourceNameOf(parent, feedbackMessageId)
226 : "");
227 const existing =
228 (yield* getByName(name)) ?? (yield* findByOwnership(id, env.project));
229 if (existing === undefined) return undefined;
230 const attrs = toAttrs(existing, env.project);
231 return (yield* ownedByAlchemy(id, existing.body))
232 ? attrs
233 : Unowned(attrs);
234 }),
235
236 list: () =>
237 Effect.gen(function* () {
238 const env = yield* GcpEnvironment.current;
239 const rows = yield* listAllFeedbackMessages(env.project);
240 return rows
241 .filter((row) => hasOwnershipMarker(row.body))
242 .map((row) => toAttrs(row, env.project));
243 }),
244
245 reconcile: Effect.fn(function* ({ id, news, output }) {
246 const env = yield* GcpEnvironment.current;
247 const parent = expandParent(news.parent, env.project);
248 const feedbackMessageId =
249 news.feedbackMessageId ?? output?.feedbackMessageId;
250 const name =
251 output?.name ??
252 (feedbackMessageId !== undefined
253 ? resourceNameOf(parent, feedbackMessageId)
254 : "");
255 const ownership = yield* createInternalLabels(id);
256 const body = encodeOwnership(
257 ownership,
258 news.body,
259 MAX_FEEDBACK_BODY_LENGTH,
260 );
261
262 let current =
263 (yield* getByName(name)) ?? (yield* findByOwnership(id, env.project));
264
265 if (current === undefined) {
266 const created = yield* retryTransient(
267 datalabeling.createProjectsDatasetsAnnotatedDatasetsFeedbackThreadsFeedbackMessages(
268 {
269 parent,
270 body: {
271 body,
272 image: news.image,
273 requesterFeedbackMetadata: {},
274 },
275 },
276 ),
277 ).pipe(Effect.catchTag("Conflict", () => Effect.succeed(undefined)));
278 if (created !== undefined) {
279 const done = yield* waitForOperation(created);
280 const createdName = resourceNameFromOperation(done);
281 if (createdName !== undefined) {
282 current = yield* waitForVisible(getByName(createdName));
283 }
284 }
285 if (current === undefined) {
286 current = yield* findByOwnership(id, env.project);
287 }
288 }
289
290 if (current === undefined) {
291 return yield* new DatasetsAnnotatedDatasetsFeedbackThreadsFeedbackMessageNotResolved(
292 { name: name || parent },
293 );
294 }
295
296 return toAttrs(current, env.project);
297 }),
298
299 delete: Effect.fn(function* ({ output }) {
300 if (!output.name) return;
301 yield* ignoreGone(
302 retryDelete(
303 datalabeling.deleteProjectsDatasetsAnnotatedDatasetsFeedbackThreadsFeedbackMessages(
304 { name: output.name },
305 ),
306 ),
307 );
308 yield* waitUntilGone(getByName(output.name));
309 }),
310 });