@manta-eu/client 0.5.0 → 0.6.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,471 @@
1
+ import { ProvActivitySchema } from "@manta-eu/ldo/schemas/prov";
2
+ import { isPresent } from "@manta-eu/lib/present";
3
+ import { owlNothingIri } from "@manta-eu/rdf-lib/namespaces";
4
+ import { createLens, type Options, type Schema } from "ldkit";
5
+ import { ldkit, rdf } from "ldkit/namespaces";
6
+ import { uniq } from "ramda";
7
+ import {
8
+ ActionPlanRunHistoryDocument,
9
+ ExecuteAgentActionGraphDocument,
10
+ type GQLActionPlanRunHistoryQuery,
11
+ type GQLExecuteActionErrorCode,
12
+ type GQLExecuteAgentActionGraphMutation,
13
+ } from "../generated/graphql";
14
+ import type { ActionInput } from "./action-document";
15
+
16
+ /** Sends a GraphQL document to Manta, scoped to the datastore the caller works in. */
17
+ export type ActionGraphql = (
18
+ document: string,
19
+ variables: Record<string, unknown>,
20
+ options?: { signal?: AbortSignal },
21
+ ) => Promise<unknown>;
22
+
23
+ export type ActionDeletion = Omit<ActionInput, "properties">;
24
+
25
+ export type GraphOp =
26
+ | { op: "add"; input: ActionInput }
27
+ | { op: "update"; input: ActionInput }
28
+ | { op: "delete"; input: ActionDeletion };
29
+
30
+ export const adding = (input: ActionInput): GraphOp => ({ op: "add", input });
31
+
32
+ export const updating = (input: ActionInput): GraphOp => ({ op: "update", input });
33
+
34
+ export const deleting = (input: ActionDeletion): GraphOp => ({ op: "delete", input });
35
+
36
+ export type ActionGraphNode = { "@id": string; "@type": string | readonly [string, ...string[]] } & Record<
37
+ string,
38
+ unknown
39
+ >;
40
+
41
+ export type ActionGraphInput = { graph: { "@graph": readonly ActionGraphNode[] } };
42
+
43
+ export type ExecuteActionGraphVariables = { input: ActionGraphInput };
44
+
45
+ /** Manta resolves the `m:` prefix against the realm it runs as, so the key holds in every realm. */
46
+ export const actionShapeKey = "m:actionShape";
47
+
48
+ /** Manta takes at most this many entities in one plan. */
49
+ export const maxGraphNodes = 101;
50
+
51
+ const typesIn = (value: unknown): string[] =>
52
+ Array.isArray(value)
53
+ ? value.filter((type): type is string => typeof type === "string")
54
+ : typeof value === "string"
55
+ ? [value]
56
+ : [];
57
+
58
+ /** `@id` and `@type` come from the op's subject and type, never from whatever its properties hold. */
59
+ export function graphNode(op: GraphOp): ActionGraphNode {
60
+ const shape = { [actionShapeKey]: { "@id": op.input.actionShape } };
61
+ if (op.op === "delete") return { "@id": op.input.subjectIri, "@type": owlNothingIri, ...shape };
62
+
63
+ const { "@id": _id, "@type": held, ...properties } = op.input.properties as Record<string, unknown>;
64
+ const [first = op.input.rdfType, ...rest] = uniq([op.input.rdfType, ...typesIn(held)]);
65
+ return { ...properties, "@id": op.input.subjectIri, "@type": [first, ...rest], ...shape };
66
+ }
67
+
68
+ export const actionGraphInput = (nodes: readonly ActionGraphNode[]): ActionGraphInput => ({
69
+ graph: { "@graph": nodes },
70
+ });
71
+
72
+ export type ActionOutcome =
73
+ | { status: "done"; planGraphIri: string }
74
+ | { status: "pending"; planGraphIri: string; workflowId?: string; message: string }
75
+ | { status: "failed"; message: string; subcode?: number };
76
+
77
+ export type PendingAction = Extract<ActionOutcome, { status: "pending" }>;
78
+
79
+ export type FollowPendingAction = (pending: PendingAction) => Promise<Exclude<ActionOutcome, PendingAction>>;
80
+
81
+ export const rejectedWithoutReason = "Manta rejected the action without saying why.";
82
+
83
+ export const unreachableMessage = "Manta could not be reached.";
84
+
85
+ export const nothingToSendMessage = "Nothing in this change is something Manta can send.";
86
+
87
+ export const skippedMessage = "Not sent, because a change it depends on was not made.";
88
+
89
+ export const twiceMessage = "Not sent, because this change touches the same entity twice in different ways.";
90
+
91
+ export const unconfirmedMessage = "Manta is still sending the change; the channel has not confirmed it yet.";
92
+
93
+ const quotedPattern = (key: string, flags?: string) => new RegExp(`:${key} "((?:[^"\\\\]|\\\\.)*)"`, flags);
94
+
95
+ /** The message a channel's error carries for a person, out of the raw error Manta recorded. */
96
+ export function channelErrorMessage(raw: string): string {
97
+ const topics = uniq([...raw.matchAll(quotedPattern("topic", "g"))].flatMap((match) => match[1] ?? []));
98
+ if (isPresent(topics)) return `Policy: ${topics.join(", ")}.`;
99
+
100
+ const quoted = raw.match(quotedPattern("error_user_msg"))?.[1] ?? raw.match(quotedPattern("message"))?.[1];
101
+ return quoted ? quoted.replace(/\\"/g, '"') : raw;
102
+ }
103
+
104
+ export function channelErrorSubcode(raw: string): number | undefined {
105
+ const subcode = raw.match(/:error_subcode (\d+)/)?.[1];
106
+ return subcode === undefined ? undefined : Number(subcode);
107
+ }
108
+
109
+ const reasonOf = (cause: unknown): string => (cause instanceof Error ? cause.message : unreachableMessage);
110
+
111
+ export type GraphStart =
112
+ | { status: "started"; planGraphIri: string; workflowId?: string }
113
+ | { status: "refused"; message: string; code?: GQLExecuteActionErrorCode | null }
114
+ | { status: "unknown"; message: string };
115
+
116
+ export async function startActionGraph(
117
+ graphql: ActionGraphql,
118
+ nodes: readonly ActionGraphNode[],
119
+ options: { signal?: AbortSignal } = {},
120
+ ): Promise<GraphStart> {
121
+ try {
122
+ const variables: ExecuteActionGraphVariables = { input: actionGraphInput(nodes) };
123
+ const data = (await graphql(ExecuteAgentActionGraphDocument, variables, options)) as
124
+ | GQLExecuteAgentActionGraphMutation
125
+ | undefined;
126
+ const started = data?.executeActionGraph;
127
+ if (started === undefined) return { status: "unknown", message: rejectedWithoutReason };
128
+ if (!started.success) {
129
+ return { status: "refused", message: started.error ?? rejectedWithoutReason, code: started.code };
130
+ }
131
+ if (!isPresent(started.namedGraphIri)) {
132
+ return { status: "unknown", message: started.error ?? rejectedWithoutReason };
133
+ }
134
+ return {
135
+ status: "started",
136
+ planGraphIri: started.namedGraphIri,
137
+ ...(isPresent(started.workflowId) && { workflowId: started.workflowId }),
138
+ };
139
+ } catch (cause) {
140
+ return { status: "unknown", message: reasonOf(cause) };
141
+ }
142
+ }
143
+
144
+ type PlanRun = GQLActionPlanRunHistoryQuery["actionPlanRunHistory"]["runs"][number];
145
+
146
+ async function latestRun(graphql: ActionGraphql, planGraphIri: string, signal: AbortSignal) {
147
+ try {
148
+ const data = (await graphql(
149
+ ActionPlanRunHistoryDocument,
150
+ { planGraphIris: [planGraphIri], limit: 1 },
151
+ { signal },
152
+ )) as GQLActionPlanRunHistoryQuery | undefined;
153
+ return data?.actionPlanRunHistory.runs[0] as PlanRun | undefined;
154
+ } catch {
155
+ return undefined;
156
+ }
157
+ }
158
+
159
+ function waitForPoll(intervalMs: number, signal: AbortSignal): Promise<void> {
160
+ return new Promise((resolve) => {
161
+ if (signal.aborted) return resolve();
162
+ const finish = () => {
163
+ clearTimeout(timer);
164
+ signal.removeEventListener("abort", finish);
165
+ resolve();
166
+ };
167
+ const timer = setTimeout(finish, intervalMs);
168
+ signal.addEventListener("abort", finish, { once: true });
169
+ });
170
+ }
171
+
172
+ export type AwaitRunArgs = {
173
+ planGraphIri: string;
174
+ workflowId?: string;
175
+ pollIntervalMs: number;
176
+ timeoutMs: number;
177
+ pendingMessage: string;
178
+ formatError?: (raw: string) => string;
179
+ signal?: AbortSignal;
180
+ };
181
+
182
+ /** Polls the plan's run until it completes or fails, or reports it pending once `timeoutMs` ends. */
183
+ export async function awaitRun(graphql: ActionGraphql, args: AwaitRunArgs): Promise<ActionOutcome> {
184
+ const { planGraphIri, workflowId, pollIntervalMs, timeoutMs, pendingMessage } = args;
185
+ const formatError = args.formatError ?? channelErrorMessage;
186
+ const deadline = Date.now() + timeoutMs;
187
+ const pending: PendingAction = {
188
+ status: "pending",
189
+ planGraphIri,
190
+ ...(isPresent(workflowId) && { workflowId }),
191
+ message: pendingMessage,
192
+ };
193
+ if (timeoutMs <= 0 || args.signal?.aborted) return pending;
194
+
195
+ const observation = new AbortController();
196
+ const signal = args.signal ? AbortSignal.any([args.signal, observation.signal]) : observation.signal;
197
+ const timer = setTimeout(() => observation.abort(), timeoutMs);
198
+
199
+ try {
200
+ while (Date.now() < deadline && !signal.aborted) {
201
+ const run = await latestRun(graphql, planGraphIri, signal);
202
+ if (signal.aborted) break;
203
+
204
+ if (run?.status === "COMPLETED") return { status: "done", planGraphIri };
205
+ if (run?.status === "FAILED") {
206
+ const subcode = isPresent(run.errorMessage) ? channelErrorSubcode(run.errorMessage) : undefined;
207
+ return {
208
+ status: "failed",
209
+ message: isPresent(run.errorMessage) ? formatError(run.errorMessage) : rejectedWithoutReason,
210
+ ...(subcode !== undefined && { subcode }),
211
+ };
212
+ }
213
+
214
+ await waitForPoll(Math.min(pollIntervalMs, Math.max(0, deadline - Date.now())), signal);
215
+ }
216
+ } finally {
217
+ clearTimeout(timer);
218
+ }
219
+
220
+ return pending;
221
+ }
222
+
223
+ export type EntityOutcome = {
224
+ used: string;
225
+ generated: { iri: string; types: string[] }[];
226
+ errorMessage?: string;
227
+ skippedReason?: string;
228
+ startedAt?: string;
229
+ endedAt?: string;
230
+ };
231
+
232
+ export const PlanOutcomeSchema = {
233
+ "@type": ProvActivitySchema["@type"],
234
+ runPlan: { "@id": ProvActivitySchema.runPlan["@id"], "@type": ldkit.IRI },
235
+ used: { "@id": ProvActivitySchema.used["@id"], "@type": ldkit.IRI, "@optional": true, "@array": true },
236
+ generated: {
237
+ "@id": ProvActivitySchema.generated["@id"],
238
+ "@optional": true,
239
+ "@array": true,
240
+ "@schema": { types: { "@id": rdf.type, "@type": ldkit.IRI, "@optional": true, "@array": true } },
241
+ },
242
+ errorMessage: ProvActivitySchema.errorMessage,
243
+ skippedReason: ProvActivitySchema.skippedReason,
244
+ startedAt: { ...ProvActivitySchema.startedAtTime, "@optional": true },
245
+ endedAt: ProvActivitySchema.endedAtTime,
246
+ } as const satisfies Schema;
247
+
248
+ /** Every entity's own run inside the plan: the subject it used, what it created, or why it failed or was skipped. */
249
+ export async function planOutcomes(planGraphIri: string, readOptions: Options): Promise<EntityOutcome[]> {
250
+ const runs = await createLens(PlanOutcomeSchema, readOptions).find({ where: { runPlan: planGraphIri } });
251
+
252
+ return runs.flatMap((run) =>
253
+ run.used.map((used) => ({
254
+ used,
255
+ generated: run.generated.map((created) => ({ iri: created.$id, types: created.types })),
256
+ ...(isPresent(run.errorMessage) && { errorMessage: run.errorMessage }),
257
+ ...(isPresent(run.skippedReason) && { skippedReason: run.skippedReason }),
258
+ ...(isPresent(run.startedAt) && { startedAt: run.startedAt.toISOString() }),
259
+ ...(isPresent(run.endedAt) && { endedAt: run.endedAt.toISOString() }),
260
+ })),
261
+ );
262
+ }
263
+
264
+ export type OpResult =
265
+ | { status: "done"; planGraphIri: string; created?: string }
266
+ | { status: "pending"; planGraphIri: string; message: string }
267
+ | { status: "failed"; message: string; subcode?: number; skipped?: boolean };
268
+
269
+ /** `results` lines up with the ops sent; `outcome` is the whole change as one. */
270
+ export type GraphRun = { outcome: ActionOutcome; results: OpResult[] };
271
+
272
+ export type GraphRunOptions = {
273
+ graphql: ActionGraphql;
274
+ readOptions: Options;
275
+ pollIntervalMs?: number;
276
+ timeoutMs?: number;
277
+ pendingMessage?: string;
278
+ formatError?: (raw: string) => string;
279
+ signal?: AbortSignal;
280
+ /** Takes over a run still going when `timeoutMs` ends. */
281
+ follow?: FollowPendingAction;
282
+ /** Reads a finished plan's per-entity outcomes; `planOutcomes` over `readOptions` unless given. */
283
+ readOutcomes?: (planGraphIri: string) => Promise<EntityOutcome[]>;
284
+ /** Hears why a finished plan's outcomes could not be read; its ops then stay pending. */
285
+ onError?: (cause: unknown) => void;
286
+ };
287
+
288
+ /** A node may point at one an earlier plan of the same change created; it goes out under the created IRI. */
289
+ export function withCreatedReferences(value: unknown, created: ReadonlyMap<string, string>): unknown {
290
+ if (Array.isArray(value)) return value.map((each) => withCreatedReferences(each, created));
291
+ if (value === null || typeof value !== "object") return value;
292
+ return Object.fromEntries(
293
+ Object.entries(value).map(([key, held]) => [
294
+ key,
295
+ key === "@id" && typeof held === "string" ? (created.get(held) ?? held) : withCreatedReferences(held, created),
296
+ ]),
297
+ );
298
+ }
299
+
300
+ const idsIn = (value: unknown): string[] => {
301
+ if (Array.isArray(value)) return value.flatMap(idsIn);
302
+ if (value === null || typeof value !== "object") return [];
303
+ return Object.entries(value).flatMap(([key, held]) =>
304
+ key === "@id" && typeof held === "string" ? [held] : idsIn(held),
305
+ );
306
+ };
307
+
308
+ /** Every IRI an op's node points at, its own subject included. */
309
+ export const mentions = (op: GraphOp): string[] => idsIn(graphNode(op));
310
+
311
+ const createdBy = (op: GraphOp, outcome: EntityOutcome) =>
312
+ (outcome.generated.find((made) => made.types.includes(op.input.rdfType)) ?? outcome.generated[0])?.iri;
313
+
314
+ /** An op's own outcome, and whether the plan's outcomes could be read at all. */
315
+ export type OutcomeRead = { outcome: EntityOutcome | undefined; known: boolean };
316
+
317
+ /** What one op did, read off its entity's own activity in the plan's run. */
318
+ export function opResult(
319
+ op: GraphOp,
320
+ planGraphIri: string,
321
+ run: ActionOutcome,
322
+ { outcome, known }: OutcomeRead,
323
+ formatError: (raw: string) => string = channelErrorMessage,
324
+ ): OpResult {
325
+ if (run.status === "pending") return run;
326
+ if (isPresent(outcome?.errorMessage)) {
327
+ const subcode = channelErrorSubcode(outcome.errorMessage);
328
+ return { status: "failed", message: formatError(outcome.errorMessage), ...(subcode !== undefined && { subcode }) };
329
+ }
330
+ if (isPresent(outcome?.skippedReason)) return { status: "failed", message: skippedMessage, skipped: true };
331
+ if (run.status === "failed" && outcome === undefined) return run;
332
+ if (!known) return { status: "pending", planGraphIri, message: unconfirmedMessage };
333
+
334
+ if (op.op !== "add") return { status: "done", planGraphIri };
335
+ if (outcome === undefined) return { status: "pending", planGraphIri, message: unconfirmedMessage };
336
+ const created = createdBy(op, outcome);
337
+ return { status: "done", planGraphIri, ...(isPresent(created) && { created }) };
338
+ }
339
+
340
+ async function runChunk(
341
+ ops: readonly GraphOp[],
342
+ created: ReadonlyMap<string, string>,
343
+ options: GraphRunOptions,
344
+ ): Promise<OpResult[]> {
345
+ const { graphql, readOptions, signal, follow, formatError, onError } = options;
346
+ const { pollIntervalMs = 2000, timeoutMs = 120_000, pendingMessage = unconfirmedMessage } = options;
347
+ const { readOutcomes = (planGraphIri: string) => planOutcomes(planGraphIri, readOptions) } = options;
348
+ const nodes = ops.map((op) => withCreatedReferences(graphNode(op), created) as ActionGraphNode);
349
+
350
+ const start = await startActionGraph(graphql, nodes, { signal });
351
+ if (start.status !== "started") return ops.map((): OpResult => ({ status: "failed", message: start.message }));
352
+
353
+ const waited = await awaitRun(graphql, { ...start, pollIntervalMs, timeoutMs, pendingMessage, formatError, signal });
354
+ const run = waited.status === "pending" && follow !== undefined ? await follow(waited) : waited;
355
+ if (run.status === "pending") return ops.map((): OpResult => run);
356
+
357
+ const outcomes = await readOutcomes(start.planGraphIri).catch((cause: unknown) => {
358
+ onError?.(cause);
359
+ return undefined;
360
+ });
361
+ const known = isPresent(outcomes);
362
+ const byUsed = new Map((outcomes ?? []).map((outcome) => [outcome.used, outcome]));
363
+ return ops.map((op, index) =>
364
+ opResult(op, start.planGraphIri, run, { outcome: byUsed.get(nodes[index]?.["@id"] ?? ""), known }, formatError),
365
+ );
366
+ }
367
+
368
+ type Unit = { op: GraphOp; indices: number[] };
369
+
370
+ /** One unit per entity: several updates of it merge into one, later properties winning; any other repeat is refused. */
371
+ function unitsOf(ops: readonly GraphOp[]): { units: Unit[]; refused: number[] } {
372
+ const bySubject = new Map<string, number[]>();
373
+ ops.forEach((op, index) =>
374
+ bySubject.set(op.input.subjectIri, [...(bySubject.get(op.input.subjectIri) ?? []), index]),
375
+ );
376
+
377
+ const units: Unit[] = [];
378
+ const refused: number[] = [];
379
+ for (const indices of bySubject.values()) {
380
+ const same = indices.flatMap((index) => ops[index] ?? []);
381
+ const updates = same.flatMap((op) => (op.op === "update" ? [op] : []));
382
+ const [first] = same;
383
+ if (first !== undefined && same.length === 1) units.push({ op: first, indices });
384
+ else if (first !== undefined && updates.length === same.length) {
385
+ const properties = Object.assign({}, ...updates.map((op) => op.input.properties)) as Record<string, unknown>;
386
+ units.push({ op: updating({ ...first.input, properties }), indices });
387
+ } else refused.push(...indices);
388
+ }
389
+ return { units, refused };
390
+ }
391
+
392
+ /** Each unit after the units it points at, otherwise in the given order, so a split never sends a dependent first. */
393
+ function dependenciesFirst(units: readonly Unit[]): Unit[] {
394
+ const subjects = new Set(units.map((unit) => unit.op.input.subjectIri));
395
+ const needs = new Map(
396
+ units.map((unit) => [
397
+ unit,
398
+ mentions(unit.op).filter((iri) => iri !== unit.op.input.subjectIri && subjects.has(iri)),
399
+ ]),
400
+ );
401
+ const ordered: Unit[] = [];
402
+ const placed = new Set<string>();
403
+ const remaining = [...units];
404
+ while (isPresent(remaining)) {
405
+ const ready = remaining.findIndex((unit) => (needs.get(unit) ?? []).every((iri) => placed.has(iri)));
406
+ const [next] = remaining.splice(Math.max(ready, 0), 1);
407
+ if (next === undefined) break;
408
+ ordered.push(next);
409
+ placed.add(next.op.input.subjectIri);
410
+ }
411
+ return ordered;
412
+ }
413
+
414
+ /** The whole change as one outcome: a failure first, then a run still going, else done. */
415
+ export function graphOutcome(results: readonly OpResult[]): ActionOutcome {
416
+ const failed = results.find((result) => result.status === "failed");
417
+ if (failed !== undefined) return failed;
418
+ const pending = results.find((result) => result.status === "pending");
419
+ if (pending !== undefined) return pending;
420
+ const done = [...results].reverse().find((result) => result.status === "done");
421
+ return done === undefined
422
+ ? { status: "failed", message: nothingToSendMessage }
423
+ : { status: "done", planGraphIri: done.planGraphIri };
424
+ }
425
+
426
+ /**
427
+ * Sends `ops` as one plan, or as few as Manta's per-plan cap allows, and reads back what each did. Past the cap,
428
+ * an entity that points at one an earlier plan did not make is skipped, as Manta skips it inside one plan.
429
+ */
430
+ export async function runActionGraph(ops: readonly GraphOp[], options: GraphRunOptions): Promise<GraphRun> {
431
+ const results: OpResult[] = ops.map(() => ({ status: "failed", message: twiceMessage }));
432
+ const { units, refused } = unitsOf(ops);
433
+ const created = new Map<string, string>();
434
+ const missing = new Set(refused.flatMap((index) => ops[index]?.input.subjectIri ?? []));
435
+ const ordered = dependenciesFirst(units);
436
+
437
+ const settle = (unit: Unit, result: OpResult) => {
438
+ unit.indices.forEach((index) => (results[index] = result));
439
+ const subject = unit.op.input.subjectIri;
440
+ if (result.status === "done" && isPresent(result.created)) created.set(subject, result.created);
441
+ if (unit.op.op === "add" && result.status !== "done") missing.add(subject);
442
+ };
443
+
444
+ for (let at = 0; at < ordered.length; at += maxGraphNodes) {
445
+ const chunk = ordered.slice(at, at + maxGraphNodes);
446
+ // Dependencies come first, so one pass also blocks what points at an addition blocked just before it.
447
+ const blocked: Unit[] = [];
448
+ for (const unit of chunk) {
449
+ if (!mentions(unit.op).some((iri) => missing.has(iri))) continue;
450
+ blocked.push(unit);
451
+ if (unit.op.op === "add") missing.add(unit.op.input.subjectIri);
452
+ }
453
+ const sending = chunk.filter((unit) => !blocked.includes(unit));
454
+ const sent = isPresent(sending)
455
+ ? await runChunk(
456
+ sending.map((unit) => unit.op),
457
+ created,
458
+ options,
459
+ ).catch((cause: unknown) => sending.map((): OpResult => ({ status: "failed", message: reasonOf(cause) })))
460
+ : [];
461
+
462
+ blocked.forEach((unit) => settle(unit, { status: "failed", message: skippedMessage, skipped: true }));
463
+ sending.forEach((unit, index) => settle(unit, sent[index] ?? { status: "failed", message: nothingToSendMessage }));
464
+ }
465
+
466
+ return { outcome: graphOutcome(results), results };
467
+ }
468
+
469
+ /** What happened to the op on `subjectIri`, for a caller that keyed its ops by subject. */
470
+ export const resultFor = (ops: readonly GraphOp[], run: GraphRun, subjectIri: string): OpResult | undefined =>
471
+ run.results[ops.findIndex((op) => op.input.subjectIri === subjectIri)];
@@ -4,6 +4,7 @@ import { isPresent } from "@manta-eu/lib/present";
4
4
  import type { Schema } from "ldkit";
5
5
  import { ExecuteAgentActionAdditionDocument, type GQLExecuteAgentActionAdditionMutation } from "../generated/graphql";
6
6
  import { entityActionInput } from "./action-document";
7
+ import { adding, graphNode, maxGraphNodes, startActionGraph, type ActionGraphNode } from "./action-graph";
7
8
 
8
9
  export type EmailGraphql = (document: string, variables: Record<string, unknown>) => Promise<unknown>;
9
10
 
@@ -58,3 +59,30 @@ export async function startRenderedEmail(
58
59
  return { ok: false, error: cause instanceof Error ? cause.message : String(cause) };
59
60
  }
60
61
  }
62
+
63
+ export function renderedEmailNode(terms: RealmEmailTerms, email: RenderedEmail): ActionGraphNode {
64
+ return graphNode(adding(renderedEmailAddition(terms, email)));
65
+ }
66
+
67
+ /**
68
+ * Sends every email as one plan; each email's own outcome is the plan activity that used its `subjectIri`.
69
+ * A plan holds at most `maxGraphNodes` entities, so a caller with more emails splits them into batches.
70
+ */
71
+ export async function startRenderedEmails(
72
+ graphql: EmailGraphql,
73
+ terms: RealmEmailTerms,
74
+ emails: readonly RenderedEmail[],
75
+ ): Promise<EmailStart> {
76
+ if (emails.length > maxGraphNodes)
77
+ return {
78
+ ok: false,
79
+ error: `One plan sends at most ${maxGraphNodes} emails; split the ${emails.length} into batches.`,
80
+ };
81
+ const started = await startActionGraph(
82
+ graphql,
83
+ emails.map((email) => renderedEmailNode(terms, email)),
84
+ );
85
+ return started.status === "started"
86
+ ? { ok: true, plan: started.planGraphIri }
87
+ : { ok: false, error: started.message };
88
+ }
@@ -157,6 +157,16 @@ export const toolMetadata: ToolMetadata = {
157
157
  description: "Read the contents of a file the user uploaded to this conversation",
158
158
  getHumanName: () => "Reading attachment...",
159
159
  },
160
+ requestBudgetChange: {
161
+ name: "requestBudgetChange",
162
+ category: "action",
163
+ humanName: "Change budget",
164
+ description: "Change a Google or Meta budget within the hotel's budget band, or ask for approval beyond it",
165
+ getHumanName: (input) =>
166
+ input?.campaignName && input.amount !== undefined
167
+ ? `Change the ${input.campaignName} budget to ${input.amount}`
168
+ : "Changing budget...",
169
+ },
160
170
  } satisfies ToolMetadata;
161
171
 
162
172
  export function getToolMetadata<T extends ToolName>(toolName: T): ToolMetadata[T] {
@@ -26,7 +26,8 @@ export type ToolName =
26
26
  | "checkAutoPilotStatus"
27
27
  | "setDailyChangeLimit"
28
28
  | "markAsLowRisk"
29
- | "readUploadedFile";
29
+ | "readUploadedFile"
30
+ | "requestBudgetChange";
30
31
 
31
32
  export type ToolCategory = "knowledge-graph" | "scheduler" | "charts" | "action" | "auto-pilot";
32
33
 
@@ -198,6 +199,14 @@ export type ManageFactsOutput =
198
199
  }
199
200
  | ToolErrorResult;
200
201
 
202
+ export type RequestBudgetChangeInput = {
203
+ humanIntent: string;
204
+ entityIri: string;
205
+ property: string;
206
+ amount: number;
207
+ campaignName: string;
208
+ };
209
+
201
210
  export type ActionPlanStatus = "DRAFT" | "RUNNING" | "COMPLETED" | "FAILED" | "TIMED_OUT";
202
211
 
203
212
  export type ActionPlanShowcaseInput = {
@@ -282,6 +291,7 @@ export type ToolDefinitions = {
282
291
  setDailyChangeLimit: ToolDefinition<SetDailyChangeLimitInput, AutoPilotOutput>;
283
292
  markAsLowRisk: ToolDefinition<AutoPilotInput, AutoPilotOutput>;
284
293
  readUploadedFile: ToolDefinition<ReadUploadedFileInput, ReadUploadedFileOutput>;
294
+ requestBudgetChange: ToolDefinition<RequestBudgetChangeInput, unknown>;
285
295
  };
286
296
 
287
297
  export type ToolMetadata = {