pr_01m47d24b0e6n91zwymwxg0vpx/packages/contracts/src/compute.ts

566 lines23,955 bytesCodeBlame
1/**
2 * Compute gating: whether a workspace may start something that costs g1t
3 * real money, and with what caps. Every place compute starts (agent runs,
4 * checks, the merge queue, merge checks and catch-ups in the runner,
5 * workflow jobs, deploy builds and context embeddings) asks the billing
6 * service through `ComputeGate.admit` first, and settles what it reserved
7 * when the work ends.
8 *
9 * The billing service owns the decision (`reserve`); this module only
10 * shapes the call, words the refusal, and decides what happens when billing
11 * cannot be reached:
12 *
13 * - **Free workspaces fail closed.** If billing errors, nothing starts:
14 * a free workspace never gets compute because billing was down.
15 * - **Paid, internal and enterprise workspaces fail open.** If billing
16 * errors, the work starts without a reservation and the error is logged,
17 * so a billing blip never stops paying customers. The plan is read from
18 * `entitlements`, or, when that call fails too, from the last plan seen
19 * for the workspace (kept in the isolate and in the Cache API for a day).
20 * A workspace whose plan is not known is treated as free.
21 * - **Internal and enterprise plans always pass.** A refusal from billing
22 * for one is logged, not shown.
23 *
24 * Estimates and settlements are at what the work costs g1t, before the
25 * margin: billing applies the margin as it does to every other meter.
26 *
27 * Only type imports, so services' unit tests can load it on its own.
28 *
29 * Mirrors `entitlements`, `reserve` and `settle` in the billing service
30 * (`g1t_contracts::billing`), which speak camelCase. Answers are read in
31 * either case, so an older or newer billing never reads as no plan.
32 */
33import type { ServiceBinding } from "./clients";
34import type { RepoPath } from "./repos";
35import type { Result } from "./result";
36
37/** What a workspace pays for. */
38export type ComputePlan = "free" | "paid" | "internal" | "enterprise";
39
40/** What kind of compute is being started. */
41export type ComputeKind = "agent" | "check" | "workflow" | "queue" | "deploy" | "embedding";
42
43/** What pays for a reservation. */
44export type PaidBy = "credit" | "trial" | "oss" | "on_demand";
45
46/** Why billing refused a reservation. */
47export type ComputeRefusalCode = "not_paid" | "trial_used" | "limit" | "paused" | "oss_pool_empty";
48
49/**
50 * Codes the gate itself refuses with, besides billing's: `issue_cap` (an
51 * issue's agents spent their cap), `billing_unavailable` (a free workspace
52 * while billing cannot be reached).
53 */
54export type GateRefusalCode = ComputeRefusalCode | "issue_cap" | "billing_unavailable";
55
56/** The compute side of what a workspace's plan gives it. */
57export type ComputeEntitlements = {
58 plan: ComputePlan;
59 /** Whether its plan includes compute at all. */
60 compute: boolean;
61 /** The one-time trial's usage left. */
62 trialMicrosLeft: number;
63 /** Whether the workspace passed the card check the trial and open-source pool need. */
64 trialVerified: boolean;
65 /** A paid workspace in its first month: tighter caps. */
66 firstMonth: boolean;
67 /** Agent runs at once; zero means no cap. */
68 maxConcurrentAgents: number;
69 /** The longest any sandbox may run, in minutes; zero means no cap. */
70 maxRunMinutes: number;
71 /** The most one agent run may cost; zero means no cap. */
72 runCapMicros: number;
73 /** The most the agents on one issue may cost in all; zero means no cap. */
74 issueCapMicros: number;
75 ceilingMicros: number;
76 exposureMicros: number;
77 /** Why g1t paused the workspace's compute; null when it is not paused. */
78 paused: string | null;
79};
80
81export type Reservation = { id: string; paidBy: PaidBy };
82
83/** Compute the open-source pool can pay for, on public repositories. */
84export const OSS_KINDS: ReadonlySet<ComputeKind> = new Set(["check", "workflow", "queue"]);
85
86const REFUSAL_CODES: ReadonlySet<string> = new Set(["not_paid", "trial_used", "limit", "paused", "oss_pool_empty"]);
87
88// ---- Reading billing's answers ----------------------------------------------
89
90type Raw = Record<string, unknown>;
91
92/** A field in camelCase or snake_case. */
93function field(raw: Raw, camel: string): unknown {
94 if (camel in raw) return raw[camel];
95 const snake = camel.replace(/[A-Z]/g, (letter) => `_${letter.toLowerCase()}`);
96 return raw[snake];
97}
98
99const num = (value: unknown): number => (typeof value === "number" && Number.isFinite(value) ? value : 0);
100
101const PLANS: ReadonlySet<string> = new Set(["free", "paid", "internal", "enterprise"]);
102
103/**
104 * Billing's `entitlements`, as this module reads them. Null when they do
105 * not say the plan (the billing service has not got the compute contract
106 * yet, or answered something else).
107 */
108export function readEntitlements(answer: unknown): ComputeEntitlements | null {
109 if (!answer || typeof answer !== "object") return null;
110 let raw = answer as Raw;
111 // An outcome, if billing wraps it in one.
112 if ("ok" in raw && ("value" in raw || "error" in raw)) {
113 if (raw.ok !== true || !raw.value || typeof raw.value !== "object") return null;
114 raw = raw.value as Raw;
115 }
116 const plan = field(raw, "plan");
117 if (typeof plan !== "string" || !PLANS.has(plan)) return null;
118 const paused = field(raw, "paused");
119 return {
120 plan: plan as ComputePlan,
121 compute: field(raw, "compute") === true,
122 trialMicrosLeft: num(field(raw, "trialMicrosLeft")),
123 trialVerified: field(raw, "trialVerified") === true,
124 firstMonth: field(raw, "firstMonth") === true,
125 maxConcurrentAgents: num(field(raw, "maxConcurrentAgents")),
126 maxRunMinutes: num(field(raw, "maxRunMinutes")),
127 runCapMicros: num(field(raw, "runCapMicros")),
128 issueCapMicros: num(field(raw, "issueCapMicros")),
129 ceilingMicros: num(field(raw, "ceilingMicros")),
130 exposureMicros: num(field(raw, "exposureMicros")),
131 paused: typeof paused === "string" && paused.trim() ? paused.trim() : null,
132 };
133}
134
135/** A reservation from billing's `reserve`, or null if it is not one. */
136export function readReservation(value: unknown): Reservation | null {
137 if (!value || typeof value !== "object") return null;
138 const raw = value as Raw;
139 const id = field(raw, "id");
140 if (typeof id !== "string" || !id) return null;
141 const paidBy = field(raw, "paidBy");
142 return { id, paidBy: (typeof paidBy === "string" ? paidBy : "credit") as PaidBy };
143}
144
145/**
146 * Billing's refusal code, if a failure is a refusal rather than an error.
147 * Accepts the code itself, a `reason` beside a generic code, and
148 * `payment_required`, which billing has used for refusals before.
149 */
150export function refusalCode(failure: { code?: unknown; reason?: unknown } | null | undefined): ComputeRefusalCode | null {
151 if (!failure) return null;
152 for (const candidate of [failure.code, failure.reason]) {
153 if (typeof candidate === "string" && REFUSAL_CODES.has(candidate)) return candidate as ComputeRefusalCode;
154 }
155 return failure.code === "payment_required" ? "not_paid" : null;
156}
157
158// ---- Who can run what -------------------------------------------------------
159
160/** Plans that never wait on billing. */
161export function alwaysPasses(plan: ComputePlan | null | undefined): boolean {
162 return plan === "internal" || plan === "enterprise";
163}
164
165/** What happens when billing cannot be reached: paying plans go on, the rest stop. */
166export function onBillingError(plan: ComputePlan | null | undefined): "allow" | "refuse" {
167 return plan === "paid" || alwaysPasses(plan) ? "allow" : "refuse";
168}
169
170/**
171 * Whether a workspace can start `kind` at all, from its entitlements alone:
172 * the note people see before they try, and the check for work too small to
173 * reserve one by one (a search query's embedding). Billing's `reserve` is
174 * the decision for everything else. Null when it can; the refusal when not.
175 */
176export function localRefusal(
177 ent: ComputeEntitlements,
178 kind: ComputeKind,
179 isPublic: boolean,
180): ComputeRefusalCode | null {
181 if (ent.paused) return "paused";
182 if (ent.plan !== "free") return null;
183 if (ent.compute) return null;
184 if (ent.trialVerified && ent.trialMicrosLeft > 0) return null;
185 if (isPublic && OSS_KINDS.has(kind) && ent.trialVerified) return null;
186 return ent.trialVerified && ent.trialMicrosLeft <= 0 ? "trial_used" : "not_paid";
187}
188
189/** Where a workspace's owners start the plan or the trial. */
190export function billingPath(workspace: string): string {
191 return `/${workspace.toLowerCase()}/-/billing`;
192}
193
194const WHAT: Record<ComputeKind, string> = {
195 agent: "Agents need",
196 check: "Checks run in g1t's sandboxes, which need",
197 workflow: "Workflows run in g1t's sandboxes, which need",
198 queue: "The merge queue runs in g1t's sandboxes, which needs",
199 deploy: "Deployments need",
200 embedding: "Semantic search needs",
201};
202
203/** What people are told when compute is refused: plain, with what to do and where. */
204export function refusalMessage(
205 code: GateRefusalCode,
206 workspace: string,
207 kind: ComputeKind,
208 detail?: string | null,
209): string {
210 const link = billingPath(workspace);
211 switch (code) {
212 case "not_paid": {
213 const oss = OSS_KINDS.has(kind)
214 ? " Public repositories can use g1t's open-source pool instead, after a card check."
215 : "";
216 return `${WHAT[kind]} a paid workspace.${oss} Start the $20 plan or try it with $5 of free usage after a card check: ${link}`;
217 }
218 case "trial_used":
219 return `This workspace has used its $5 of free usage. Start the $20 plan to keep going: ${link}`;
220 case "limit":
221 return `This workspace reached its spend limit for the month, so nothing new starts. An owner can raise it: ${link}#limit`;
222 case "paused":
223 return `g1t paused compute for this workspace${detail ? `: ${detail}` : ""}. Contact support@g1t.sh to have it looked at.`;
224 case "oss_pool_empty":
225 return `g1t's open-source pool is used up for this month, so checks and workflows on public repositories wait until next month. Start the $20 plan to run them now: ${link}`;
226 case "issue_cap":
227 return `${detail ?? "This issue's agents reached its spending cap"}. An owner can raise the cap per issue: ${link}#caps`;
228 case "billing_unavailable":
229 return "g1t could not reach its billing service, so it did not start this. Try again in a minute.";
230 }
231}
232
233/**
234 * The note people see before they try to start `kind`, when their
235 * workspace's plan would refuse it: plain, with where to fix it. Null when
236 * it would go ahead.
237 */
238export function computeNote(
239 ent: ComputeEntitlements,
240 kind: ComputeKind,
241 isPublic: boolean,
242 workspace: string,
243): string | null {
244 const refused = localRefusal(ent, kind, isPublic);
245 if (!refused) return null;
246 const link = billingPath(workspace);
247 if (refused === "paused") return refusalMessage("paused", workspace, kind, ent.paused);
248 if (refused === "trial_used") {
249 return `This workspace has used its free trial. ${WHAT[kind]} a paid workspace: ${link}`;
250 }
251 if (OSS_KINDS.has(kind) && isPublic) {
252 return `${WHAT[kind]} a paid workspace or the free trial. On a public repository, g1t's open-source pool runs them after a card check: ${link}`;
253 }
254 return `${WHAT[kind]} a paid workspace or the free trial: ${link}`;
255}
256
257// ---- Estimates --------------------------------------------------------------
258
259/** The kinds of agent run, as the runner names them. */
260export type AgentRunKind = "implement" | "revise" | "review" | "answer" | "update" | "plan" | "reply";
261
262/**
263 * What the model part of each kind of agent run costs g1t, on average, from
264 * measured runs: implement about $0.094, review $0.07, plan $0.104. The
265 * rest are scaled from those by how much they do.
266 */
267export const MODEL_ESTIMATE_MICROS: Record<AgentRunKind, number> = {
268 implement: 100_000,
269 revise: 100_000,
270 review: 70_000,
271 plan: 100_000,
272 update: 50_000,
273 answer: 30_000,
274 reply: 30_000,
275};
276
277/**
278 * What one second of a sandbox costs g1t, in millionths of a dollar, when
279 * the price book cannot be read: Containers standard-1 with its Durable
280 * Object, rounded up.
281 */
282export const FALLBACK_SANDBOX_MICROS_PER_SECOND = 25;
283
284/** What Workers AI's embedding model costs g1t per token. */
285export const EMBEDDING_MICROS_PER_TOKEN = 0.067;
286
287/** A sandbox for `minutes` at `microsPerSecond`. */
288export function sandboxEstimateMicros(minutes: number, microsPerSecond: number): number {
289 return Math.ceil(Math.max(0, minutes) * 60 * Math.max(0, microsPerSecond));
290}
291
292/**
293 * An agent run: its model's average, unless the workspace's own provider
294 * pays for the model, plus its sandbox for its whole time cap.
295 */
296export function agentEstimateMicros(
297 task: AgentRunKind,
298 minutes: number,
299 microsPerSecond: number,
300 ownModel = false,
301): number {
302 return (ownModel ? 0 : MODEL_ESTIMATE_MICROS[task]) + sandboxEstimateMicros(minutes, microsPerSecond);
303}
304
305/** Embedding `tokens` of text. */
306export function embeddingEstimateMicros(tokens: number): number {
307 return Math.ceil(Math.max(0, tokens) * EMBEDDING_MICROS_PER_TOKEN);
308}
309
310/** What a sandbox that ran `seconds` cost, plus a model's cost in dollars. */
311export function actualMicros(seconds: number, microsPerSecond: number, modelUsd = 0): number {
312 const model = Number.isFinite(modelUsd) && modelUsd > 0 ? modelUsd * 1_000_000 : 0;
313 return Math.ceil(Math.max(0, seconds) * Math.max(0, microsPerSecond) + model);
314}
315
316// ---- Caps ---------------------------------------------------------------------
317
318/** The lower of two caps, where null, zero or less means no cap. */
319export function lowerCap(a: number | null | undefined, b: number | null | undefined): number | null {
320 const caps = [a, b].filter((cap): cap is number => typeof cap === "number" && Number.isFinite(cap) && cap > 0);
321 return caps.length ? Math.min(...caps) : null;
322}
323
324/** A run's time cap: the guardrails' and the plan's, whichever is lower. */
325export function runMinutes(guardMinutes: number, ent: ComputeEntitlements | null): number {
326 return lowerCap(guardMinutes, ent?.maxRunMinutes) ?? guardMinutes;
327}
328
329/** A run's cost cap in dollars: the guardrails' and the plan's, whichever is lower. */
330export function runBudgetUsd(guardBudgetUsd: number | null, ent: ComputeEntitlements | null): number | null {
331 const planCap = ent && ent.runCapMicros > 0 ? ent.runCapMicros / 1_000_000 : null;
332 return lowerCap(guardBudgetUsd, planCap);
333}
334
335// ---- Agents at once ---------------------------------------------------------------
336
337/** How a run waiting for room says so; `isWaiting` recognises it. */
338export const WAITING_PREFIX = "Waiting for a free slot";
339
340/** Whether another agent run fits under the workspace's cap. */
341export function slotFree(activeAgents: number, ent: ComputeEntitlements | null): boolean {
342 if (!ent || ent.maxConcurrentAgents <= 0 || alwaysPasses(ent.plan)) return true;
343 return activeAgents < ent.maxConcurrentAgents;
344}
345
346export function waitingMessage(max: number): string {
347 return `${WAITING_PREFIX}: this workspace runs ${max} ${max === 1 ? "agent" : "agents"} at a time and all are busy. It starts by itself when one finishes.`;
348}
349
350export function isWaiting(message: string | null | undefined): boolean {
351 return typeof message === "string" && message.startsWith(WAITING_PREFIX);
352}
353
354/** Why an issue's agents may not start again: they spent its cap. Null when they may. */
355export function issueCapReached(spentMicros: number, ent: ComputeEntitlements | null, issue: number): string | null {
356 if (!ent || ent.issueCapMicros <= 0 || alwaysPasses(ent.plan)) return null;
357 if (spentMicros < ent.issueCapMicros) return null;
358 const dollars = (micros: number) => `$${(micros / 1_000_000).toFixed(2)}`;
359 return `The agents on #${issue} have spent ${dollars(spentMicros)}, its cap of ${dollars(ent.issueCapMicros)}, so g1t-agent will not start on it again`;
360}
361
362// ---- The gate -------------------------------------------------------------------
363
364/** Where the last plan seen for each workspace is kept, for when billing is down. */
365export interface PlanMemory {
366 get(workspace: string): Promise<ComputePlan | null>;
367 put(workspace: string, plan: ComputePlan): Promise<void>;
368}
369
370/** The part of the Cache API this uses. */
371type PlanCache = {
372 match(key: string): Promise<Response | undefined>;
373 put(key: string, response: Response): Promise<void>;
374};
375
376const PLAN_MEMORY_SECONDS = 24 * 60 * 60;
377const isolatePlans = new Map<string, { plan: ComputePlan; until: number }>();
378
379/**
380 * The last plan seen, in this isolate and in the Cache API (where the
381 * runtime has one), for a day.
382 */
383export const defaultPlanMemory: PlanMemory = {
384 async get(workspace) {
385 const kept = isolatePlans.get(workspace);
386 if (kept && kept.until > Date.now()) return kept.plan;
387 const cache = (globalThis as { caches?: { default?: PlanCache } }).caches?.default;
388 if (!cache) return null;
389 try {
390 const hit = await cache.match(planKey(workspace));
391 const plan = hit ? await hit.text() : null;
392 return plan && PLANS.has(plan) ? (plan as ComputePlan) : null;
393 } catch {
394 return null;
395 }
396 },
397 async put(workspace, plan) {
398 isolatePlans.set(workspace, { plan, until: Date.now() + PLAN_MEMORY_SECONDS * 1000 });
399 const cache = (globalThis as { caches?: { default?: PlanCache } }).caches?.default;
400 if (!cache) return;
401 try {
402 await cache.put(
403 planKey(workspace),
404 new Response(plan, { headers: { "cache-control": `max-age=${PLAN_MEMORY_SECONDS}` } }),
405 );
406 } catch {
407 // Remembering is a convenience; the isolate's copy is enough.
408 }
409 },
410};
411
412function planKey(workspace: string): string {
413 return `https://compute-gate.g1t.internal/plan/${encodeURIComponent(workspace)}`;
414}
415
416export type ReserveRequest = {
417 workspace: string;
418 repo: RepoPath;
419 public: boolean;
420 kind: ComputeKind;
421 estimateMicros: number;
422};
423
424/** The gate's answer: go ahead (with what was reserved, if anything), or why not. */
425export type Admission =
426 | { ok: true; reservation: Reservation | null; entitlements: ComputeEntitlements | null }
427 | { ok: false; code: GateRefusalCode; message: string; entitlements: ComputeEntitlements | null };
428
429const ENTITLEMENTS_SECONDS = 30;
430const PRICE_SECONDS = 10 * 60;
431
432export class ComputeGate {
433 private ents = new Map<string, { value: ComputeEntitlements; until: number }>();
434 private price: { value: number; until: number } | null = null;
435
436 private readonly billing: ServiceBinding;
437 private readonly memory: PlanMemory;
438 private readonly log: (...args: unknown[]) => void;
439
440 // Plain fields, not parameter properties: Node's type stripping, which
441 // the services' tests run under, does not take those.
442 constructor(
443 billing: ServiceBinding,
444 memory: PlanMemory = defaultPlanMemory,
445 log: (...args: unknown[]) => void = console.log,
446 ) {
447 this.billing = billing;
448 this.memory = memory;
449 this.log = log;
450 }
451
452 private async call<T>(method: string, args: object): Promise<T> {
453 const response = await this.billing.fetch(`https://service/rpc/${method}`, {
454 method: "POST",
455 headers: { "content-type": "application/json" },
456 body: JSON.stringify(args),
457 });
458 if (!response.ok) throw new Error(`${method} failed with status ${response.status}`);
459 return (await response.json()) as T;
460 }
461
462 /**
463 * The workspace's entitlements, kept for half a minute. Null when billing
464 * cannot say; never throws.
465 */
466 async entitlements(workspace: string): Promise<ComputeEntitlements | null> {
467 const slug = workspace.toLowerCase();
468 const kept = this.ents.get(slug);
469 if (kept && kept.until > Date.now()) return kept.value;
470 try {
471 const ent = readEntitlements(await this.call<unknown>("entitlements", { workspace: slug }));
472 if (!ent) throw new Error("entitlements did not say the workspace's plan");
473 this.ents.set(slug, { value: ent, until: Date.now() + ENTITLEMENTS_SECONDS * 1000 });
474 await this.memory.put(slug, ent.plan);
475 return ent;
476 } catch (error) {
477 this.log("compute gate: entitlements unavailable", slug, String(error));
478 return null;
479 }
480 }
481
482 /** What one second of a sandbox costs g1t, from the price book. */
483 async microsPerSecond(): Promise<number> {
484 if (this.price && this.price.until > Date.now()) return this.price.value;
485 let value = FALLBACK_SANDBOX_MICROS_PER_SECOND;
486 try {
487 const book = await this.call<{ prices?: { meter: string; costMicros?: number; cost_micros?: number }[] }>("prices", {});
488 const meter = book.prices?.find((price) => price.meter === "sandbox_second");
489 const cost = meter?.costMicros ?? meter?.cost_micros;
490 if (typeof cost === "number" && cost > 0) value = cost;
491 } catch (error) {
492 this.log("compute gate: price book unavailable", String(error));
493 }
494 this.price = { value, until: Date.now() + PRICE_SECONDS * 1000 };
495 return value;
496 }
497
498 /**
499 * Asks billing to reserve what `request` is expected to cost. Refused
500 * when billing refuses, or when billing cannot be reached for a workspace
501 * that is not known to pay (see the module's comment). Never throws.
502 */
503 async admit(request: ReserveRequest, known?: ComputeEntitlements | null): Promise<Admission> {
504 const workspace = request.workspace.toLowerCase();
505 const ent = known === undefined ? await this.entitlements(workspace) : known;
506 const refuse = (code: GateRefusalCode, message?: string | null): Admission => ({
507 ok: false,
508 code,
509 message: message || refusalMessage(code, workspace, request.kind, ent?.paused),
510 entitlements: ent,
511 });
512 if (ent?.paused) return refuse("paused");
513 const plan = ent?.plan ?? (await this.memory.get(workspace));
514 let answer: Result<unknown>;
515 try {
516 answer = await this.call<Result<unknown>>("reserve", {
517 workspace,
518 repo: request.repo,
519 public: request.public,
520 kind: request.kind,
521 estimateMicros: Math.max(0, Math.ceil(request.estimateMicros)),
522 });
523 } catch (error) {
524 return this.unavailable(plan, refuse, ent, request, String(error));
525 }
526 if (answer.ok) {
527 const reservation = readReservation(answer.value);
528 if (!reservation) return this.unavailable(plan, refuse, ent, request, "reserve answered without a reservation");
529 return { ok: true, reservation, entitlements: ent };
530 }
531 const code = refusalCode(answer.error as { code?: unknown; reason?: unknown });
532 if (!code) return this.unavailable(plan, refuse, ent, request, answer.error.message);
533 if (alwaysPasses(plan)) {
534 this.log("compute gate: refusal ignored for", plan, workspace, request.kind, code, answer.error.message);
535 return { ok: true, reservation: null, entitlements: ent };
536 }
537 // Billing's own words when it gave them; they carry the link to act on.
538 return refuse(code, answer.error.message || null);
539 }
540
541 private unavailable(
542 plan: ComputePlan | null,
543 refuse: (code: GateRefusalCode, message?: string | null) => Admission,
544 ent: ComputeEntitlements | null,
545 request: ReserveRequest,
546 why: string,
547 ): Admission {
548 if (onBillingError(plan) === "allow") {
549 this.log("compute gate: billing unavailable; allowed for", plan, request.workspace, request.kind, why);
550 return { ok: true, reservation: null, entitlements: ent };
551 }
552 this.log("compute gate: billing unavailable; refused for", plan ?? "unknown plan", request.workspace, request.kind, why);
553 return refuse("billing_unavailable");
554 }
555
556 /** Settles a reservation at what the work cost. Never throws; a failure is logged. */
557 async settle(reservationId: string, actual: number): Promise<void> {
558 const micros = Math.max(0, Math.ceil(actual));
559 try {
560 const settled = await this.call<Result<unknown>>("settle", { reservationId, actualMicros: micros });
561 if (settled && settled.ok === false) this.log("compute gate: settle refused", reservationId, settled.error.message);
562 } catch (error) {
563 this.log("compute gate: settle failed", reservationId, micros, String(error));
564 }
565 }
566}