@tangle-network/agent-provider-tangle 0.8.0 → 0.10.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,525 @@
1
+ import { AccountUsageSchema, AgentEnvironmentObservationSchema, SafeEndpointSchema, } from "@tangle-network/agent-interface";
2
+ import { placementInfoFromLoopPlacement, statusFromUnknown } from "./tangle-environment-values.js";
3
+ import { awaitWithSignal } from "./tangle-contract-safety.js";
4
+ import { transportFailureReason } from "./tangle-failure-reason.js";
5
+ export function observationSurfaceSupport(box, client, requestedResources) {
6
+ const resourceUse = typeof box.resourceUsage === "function";
7
+ return {
8
+ identity: true,
9
+ lifecycle: true,
10
+ endpoint: readSandboxMember(() => safeEndpointFromConnection(box.connection)) !== undefined,
11
+ placement: typeof client.describePlacement === "function",
12
+ resources: resourceUse || requestedResources !== undefined,
13
+ resourceUse,
14
+ modelUsage: true,
15
+ computeBilling: readSandboxMember(() => computeBillingFromLease(box)) !== undefined,
16
+ accountUsage: typeof client.usage === "function" &&
17
+ typeof client.subscription === "function",
18
+ };
19
+ }
20
+ /**
21
+ * Account usage and the client-side placement surface are the only observation
22
+ * facts a client can establish before a sandbox exists. The rest rest on one
23
+ * environment's data, so a provider-stage document keeps them at the declared
24
+ * ceiling and each concrete sandbox measures them.
25
+ */
26
+ export function clientObservationSurfaceSupport(client) {
27
+ return {
28
+ identity: true,
29
+ lifecycle: true,
30
+ endpoint: true,
31
+ placement: typeof client.describePlacement === "function",
32
+ resources: true,
33
+ resourceUse: true,
34
+ modelUsage: true,
35
+ computeBilling: true,
36
+ accountUsage: typeof client.usage === "function" &&
37
+ typeof client.subscription === "function",
38
+ };
39
+ }
40
+ /** Read an SDK accessor that can throw before its sandbox is usable. */
41
+ function readSandboxMember(read) {
42
+ try {
43
+ return read();
44
+ }
45
+ catch {
46
+ return undefined;
47
+ }
48
+ }
49
+ /**
50
+ * Read the credential-free network location of the sandbox runtime.
51
+ *
52
+ * Only the scheme, host, and explicit port of the runtime URL are carried.
53
+ * Userinfo, path, and query are dropped, and the bearer beside the URL is
54
+ * never read, so an endpoint payload cannot transport a credential. A port the
55
+ * URL leaves to its scheme default is omitted rather than inferred. The result
56
+ * is held to the contract's own endpoint schema, so a runtime URL the contract
57
+ * refuses reads as no endpoint instead of failing the whole observation.
58
+ */
59
+ export function safeEndpointFromConnection(connection) {
60
+ const runtimeUrl = connection?.runtimeUrl;
61
+ if (typeof runtimeUrl !== "string" || runtimeUrl.length === 0)
62
+ return undefined;
63
+ let parsed;
64
+ try {
65
+ parsed = new URL(runtimeUrl);
66
+ }
67
+ catch {
68
+ return undefined;
69
+ }
70
+ const scheme = parsed.protocol.replace(/:$/, "");
71
+ const host = parsed.hostname;
72
+ if (scheme.length === 0 || host.length === 0)
73
+ return undefined;
74
+ const port = parsed.port === "" ? undefined : Number.parseInt(parsed.port, 10);
75
+ if (port !== undefined && (!Number.isInteger(port) || port < 1 || port > 65_535)) {
76
+ return undefined;
77
+ }
78
+ const endpoint = SafeEndpointSchema.safeParse({
79
+ scheme,
80
+ host,
81
+ ...(port === undefined ? {} : { port }),
82
+ });
83
+ return endpoint.success ? endpoint.data : undefined;
84
+ }
85
+ /**
86
+ * Hold one surface to the schema the contract states for it.
87
+ *
88
+ * An observation is composed of independent surfaces, and every value on them
89
+ * comes from the platform. A value the contract refuses degrades that surface
90
+ * alone, so one unbounded field cannot destroy the lifecycle, endpoint,
91
+ * placement, resource, usage, and billing facts beside it.
92
+ */
93
+ function heldToContract(schema, observation, surface) {
94
+ if (schema.safeParse(observation).success)
95
+ return observation;
96
+ return {
97
+ state: "unavailable",
98
+ reason: `the sandbox reported a ${surface} the observation contract refuses`,
99
+ };
100
+ }
101
+ const observationShape = AgentEnvironmentObservationSchema.shape;
102
+ const placementShape = observationShape.placement.unwrap().shape;
103
+ const resourcesShape = observationShape.resources.unwrap().shape;
104
+ const resourceUseShape = observationShape.resourceUse.unwrap().shape;
105
+ const accountShape = AccountUsageSchema.shape;
106
+ /** Build the normalized, freshness-tagged observation of one environment. */
107
+ export async function observeTangleEnvironment(sources, options) {
108
+ const { box, client, provider, environmentId } = sources;
109
+ options?.signal?.throwIfAborted();
110
+ const refreshFailure = await refreshBeforeObservation(box, options);
111
+ options?.signal?.throwIfAborted();
112
+ const capturedAt = new Date().toISOString();
113
+ // The subject binds a live observation to its replay, so it carries only the
114
+ // identifiers both a create handle and a handle rebuilt by id can produce.
115
+ // The Sandbox client cannot read back the agent backend a create call named,
116
+ // so naming it here would deny that binding for the same sandbox.
117
+ const subject = { provider, environmentId };
118
+ const sample = await readResourceSample(box, options);
119
+ options?.signal?.throwIfAborted();
120
+ const account = await readAccountObservations(client, capturedAt, options);
121
+ options?.signal?.throwIfAborted();
122
+ const observation = {
123
+ subject,
124
+ capturedAt,
125
+ identity: {
126
+ state: "known",
127
+ value: subject,
128
+ provenance: reportedAt(capturedAt, "sandbox-instance"),
129
+ },
130
+ lifecycle: heldToContract(observationShape.lifecycle, freshOrStale(lifecycleFromSandbox(box), reportedAt(capturedAt, "sandbox-instance"), refreshFailure), "lifecycle"),
131
+ endpoint: endpointObservation(box, capturedAt, refreshFailure),
132
+ placement: {
133
+ verified: heldToContract(placementShape.verified, await verifiedPlacement(box, client, capturedAt, options), "placement"),
134
+ },
135
+ resources: {
136
+ ...(sources.requestedResources === undefined
137
+ ? {}
138
+ : { requested: sources.requestedResources }),
139
+ effective: heldToContract(resourcesShape.effective, effectiveResources(box, sample, capturedAt), "compute shape"),
140
+ },
141
+ resourceUse: {
142
+ current: heldToContract(resourceUseShape.current, currentResourceUse(sample), "resource sample"),
143
+ peak: heldToContract(resourceUseShape.peak, peakResourceUse(sample), "resource sample"),
144
+ },
145
+ modelUsage: heldToContract(observationShape.modelUsage, modelUsageObservation(sources.usageLog), "token usage"),
146
+ computeBilling: heldToContract(observationShape.computeBilling, computeBillingObservation(box, capturedAt), "compute cost"),
147
+ accountUsage: account,
148
+ };
149
+ options?.signal?.throwIfAborted();
150
+ return AgentEnvironmentObservationSchema.parse(observation);
151
+ }
152
+ function reportedAt(observedAt, source) {
153
+ return { origin: "reported", observedAt, source };
154
+ }
155
+ /**
156
+ * Tag a value the sandbox reported as stale when the refresh that would have
157
+ * renewed it failed. The value stays visible with the transport's own reason,
158
+ * so a caller reads why it is old instead of reading it as current.
159
+ */
160
+ function freshOrStale(value, provenance, staleReason) {
161
+ return staleReason === undefined
162
+ ? { state: "known", value, provenance }
163
+ : { state: "stale", value, provenance, reason: staleReason };
164
+ }
165
+ async function refreshBeforeObservation(box, options) {
166
+ if (typeof box.refresh !== "function") {
167
+ return "the Sandbox client cannot refresh this environment";
168
+ }
169
+ try {
170
+ await awaitWithSignal(box.refresh(options), options?.signal);
171
+ return undefined;
172
+ }
173
+ catch (error) {
174
+ options?.signal?.throwIfAborted();
175
+ return transportFailureReason("environment refresh", error);
176
+ }
177
+ }
178
+ function lifecycleFromSandbox(box) {
179
+ const scheduledAt = isoTimestamp(readSandboxMember(() => box.expiresAt));
180
+ return {
181
+ status: statusFromUnknown(readSandboxMember(() => box.status)),
182
+ // The platform retires the sandbox at its lifetime bound, so the expiry is
183
+ // a scheduled cleanup. It is unconfirmed until the sandbox actually stops.
184
+ ...(scheduledAt === undefined
185
+ ? {}
186
+ : { cleanup: { policy: "scheduled", scheduledAt } }),
187
+ };
188
+ }
189
+ function endpointObservation(box, capturedAt, refreshFailure) {
190
+ const connection = readSandboxMember(() => box.connection);
191
+ const endpoint = safeEndpointFromConnection(connection);
192
+ if (endpoint === undefined) {
193
+ const stated = typeof connection?.runtimeUrl === "string" && connection.runtimeUrl.length > 0;
194
+ return {
195
+ state: "unavailable",
196
+ reason: stated
197
+ ? "the sandbox reported a runtime URL the observation contract refuses"
198
+ : "the sandbox reports no runtime URL for this environment",
199
+ };
200
+ }
201
+ return heldToContract(observationShape.endpoint, freshOrStale(endpoint, reportedAt(capturedAt, "sandbox-instance"), refreshFailure), "runtime endpoint");
202
+ }
203
+ async function verifiedPlacement(box, client, capturedAt, options) {
204
+ if (typeof client.describePlacement !== "function") {
205
+ return {
206
+ state: "unavailable",
207
+ reason: "the Sandbox client cannot describe placement",
208
+ };
209
+ }
210
+ try {
211
+ const described = await awaitWithSignal(Promise.resolve(client.describePlacement(box)), options?.signal);
212
+ const placement = placementInfoFromLoopPlacement(described, box);
213
+ return {
214
+ state: "known",
215
+ value: {
216
+ kind: placement.kind,
217
+ ...(placement.sandboxId === undefined ? {} : { sandboxId: placement.sandboxId }),
218
+ ...(placement.fleetId === undefined ? {} : { fleetId: placement.fleetId }),
219
+ ...(placement.machineId === undefined ? {} : { machineId: placement.machineId }),
220
+ ...(placement.region === undefined ? {} : { region: placement.region }),
221
+ },
222
+ provenance: reportedAt(capturedAt, "sandbox-placement"),
223
+ };
224
+ }
225
+ catch (error) {
226
+ options?.signal?.throwIfAborted();
227
+ return {
228
+ state: "unavailable",
229
+ reason: transportFailureReason("placement lookup", error),
230
+ };
231
+ }
232
+ }
233
+ async function readResourceSample(box, options) {
234
+ if (typeof box.resourceUsage !== "function") {
235
+ return {
236
+ measured: false,
237
+ reason: "the Sandbox client reports no runtime resource usage",
238
+ };
239
+ }
240
+ try {
241
+ const sample = await awaitWithSignal(box.resourceUsage(), options?.signal);
242
+ if (sample === null || sample === undefined) {
243
+ return {
244
+ measured: false,
245
+ reason: "the sandbox host collected no cgroup statistics",
246
+ };
247
+ }
248
+ const observedAt = isoTimestamp(Number.isFinite(sample.sampledAtMs) ? new Date(sample.sampledAtMs) : undefined);
249
+ if (observedAt === undefined) {
250
+ return {
251
+ measured: false,
252
+ reason: "the sandbox reported a resource sample without a valid sample time",
253
+ };
254
+ }
255
+ return { measured: true, sample, observedAt };
256
+ }
257
+ catch (error) {
258
+ options?.signal?.throwIfAborted();
259
+ return {
260
+ measured: false,
261
+ reason: transportFailureReason("resource usage read", error),
262
+ };
263
+ }
264
+ }
265
+ function effectiveResources(box, sample, capturedAt) {
266
+ const memoryMb = sample.measured
267
+ ? positiveInteger(sample.sample.memoryLimitMb)
268
+ : undefined;
269
+ const accelerator = readSandboxMember(() => box.gpuLease?.accelerator);
270
+ const acceleratorProfile = accelerator !== undefined &&
271
+ typeof accelerator.kind === "string" &&
272
+ accelerator.kind.length > 0 &&
273
+ positiveInteger(accelerator.count) !== undefined
274
+ ? {
275
+ kind: accelerator.kind,
276
+ count: accelerator.count,
277
+ ...(positiveInteger(accelerator.memoryMB) === undefined
278
+ ? {}
279
+ : { memoryMb: accelerator.memoryMB }),
280
+ }
281
+ : undefined;
282
+ if (memoryMb === undefined && acceleratorProfile === undefined) {
283
+ // Sandbox states no effective CPU or disk anywhere, and an unlimited
284
+ // memory cgroup states no ceiling, so there is nothing to report rather
285
+ // than a ceiling of zero.
286
+ return {
287
+ state: "unavailable",
288
+ reason: sample.measured
289
+ ? "the sandbox cgroup reports no memory limit and no accelerator is attached"
290
+ : sample.reason,
291
+ };
292
+ }
293
+ return {
294
+ state: "known",
295
+ value: {
296
+ ...(memoryMb === undefined ? {} : { memoryMb }),
297
+ ...(acceleratorProfile === undefined ? {} : { accelerator: acceleratorProfile }),
298
+ },
299
+ provenance: memoryMb === undefined
300
+ ? reportedAt(capturedAt, "sandbox-gpu-lease")
301
+ : {
302
+ origin: "measured",
303
+ observedAt: sample.measured ? sample.observedAt : capturedAt,
304
+ source: "sandbox-cgroup",
305
+ },
306
+ };
307
+ }
308
+ function currentResourceUse(sample) {
309
+ if (!sample.measured)
310
+ return { state: "unavailable", reason: sample.reason };
311
+ const memoryMb = nonNegativeNumber(sample.sample.memoryCurrentMb);
312
+ if (memoryMb === undefined) {
313
+ return {
314
+ state: "unavailable",
315
+ reason: "the sandbox reported an invalid current memory sample",
316
+ };
317
+ }
318
+ // The cgroup reports cumulative CPU microseconds, which is not a utilization
319
+ // figure, and reports no disk or accelerator use at all. Deriving a rate
320
+ // from one sample would publish an unmeasured number, so only memory is
321
+ // carried.
322
+ return {
323
+ state: "known",
324
+ value: { memoryMb },
325
+ provenance: {
326
+ origin: "measured",
327
+ observedAt: sample.observedAt,
328
+ source: "sandbox-cgroup",
329
+ },
330
+ };
331
+ }
332
+ function peakResourceUse(sample) {
333
+ if (!sample.measured)
334
+ return { state: "unavailable", reason: sample.reason };
335
+ const memoryMb = nonNegativeNumber(sample.sample.memoryPeakMb);
336
+ if (memoryMb === undefined) {
337
+ return {
338
+ state: "unavailable",
339
+ reason: "the sandbox cgroup reports no peak memory high-water mark",
340
+ };
341
+ }
342
+ return {
343
+ state: "known",
344
+ value: { memoryMb },
345
+ provenance: {
346
+ origin: "measured",
347
+ observedAt: sample.observedAt,
348
+ source: "sandbox-cgroup",
349
+ },
350
+ };
351
+ }
352
+ function modelUsageObservation(usageLog) {
353
+ const latest = usageLog.latest();
354
+ if (latest === undefined) {
355
+ return {
356
+ state: "unavailable",
357
+ reason: "no execution measured token usage through this environment handle",
358
+ };
359
+ }
360
+ // The provenance source names the execution the usage belongs to, because
361
+ // Sandbox measures usage per execution and never for the environment.
362
+ return {
363
+ state: "known",
364
+ value: latest.usage,
365
+ provenance: {
366
+ origin: "reported",
367
+ observedAt: latest.observedAt,
368
+ source: latest.executionId,
369
+ },
370
+ };
371
+ }
372
+ /**
373
+ * Compute cost Sandbox charges for this environment.
374
+ *
375
+ * Sandbox prices an attached GPU lease and nothing else: it publishes no
376
+ * per-sandbox container compute cost. A settled lease carries its billed
377
+ * amount; a running lease carries the running estimate, which is reported with
378
+ * an estimated origin so it is never read as a settled charge.
379
+ */
380
+ function computeBillingFromLease(box) {
381
+ const lease = readSandboxMember(() => box.gpuLease);
382
+ if (lease === undefined)
383
+ return undefined;
384
+ const billed = nonNegativeNumber(lease.billing?.customerCostUsd);
385
+ if (billed !== undefined) {
386
+ return { billing: { amount: billed, currency: "USD" }, origin: "reported" };
387
+ }
388
+ const estimated = nonNegativeNumber(lease.estimatedCustomerCostUsd);
389
+ if (estimated !== undefined) {
390
+ return { billing: { amount: estimated, currency: "USD" }, origin: "estimated" };
391
+ }
392
+ return undefined;
393
+ }
394
+ function computeBillingObservation(box, capturedAt) {
395
+ const resolved = computeBillingFromLease(box);
396
+ if (resolved === undefined) {
397
+ return {
398
+ state: "unavailable",
399
+ reason: "Sandbox reports compute cost only for an attached GPU lease, and this environment has none",
400
+ };
401
+ }
402
+ return {
403
+ state: "known",
404
+ value: resolved.billing,
405
+ provenance: {
406
+ origin: resolved.origin,
407
+ observedAt: capturedAt,
408
+ source: "sandbox-gpu-lease",
409
+ },
410
+ };
411
+ }
412
+ async function readAccountObservations(client, capturedAt, options) {
413
+ if (typeof client.usage !== "function" || typeof client.subscription !== "function") {
414
+ const reason = "the Sandbox client exposes no account usage or subscription surface";
415
+ return {
416
+ plan: { state: "unavailable", reason },
417
+ credits: { state: "unavailable", reason },
418
+ quota: { state: "unavailable", reason },
419
+ period: { state: "unavailable", reason },
420
+ };
421
+ }
422
+ let subscriptionFailure;
423
+ let subscription;
424
+ try {
425
+ subscription = await awaitWithSignal(client.subscription(), options?.signal);
426
+ }
427
+ catch (error) {
428
+ options?.signal?.throwIfAborted();
429
+ subscriptionFailure = transportFailureReason("subscription read", error);
430
+ }
431
+ let usageFailure;
432
+ let usage;
433
+ try {
434
+ usage = await awaitWithSignal(client.usage(), options?.signal);
435
+ }
436
+ catch (error) {
437
+ options?.signal?.throwIfAborted();
438
+ usageFailure = transportFailureReason("account usage read", error);
439
+ }
440
+ const provenance = reportedAt(capturedAt, "sandbox-account");
441
+ return {
442
+ plan: heldToContract(accountShape.plan, planObservation(subscription?.plan, subscriptionFailure, provenance), "plan"),
443
+ credits: heldToContract(accountShape.credits, creditsObservation(subscription, subscriptionFailure, provenance), "credit balance"),
444
+ quota: heldToContract(accountShape.quota, quotaObservation(subscription, usage, subscriptionFailure, usageFailure, provenance), "sandbox quota"),
445
+ period: heldToContract(accountShape.period, periodObservation(usage, usageFailure, provenance), "billing period"),
446
+ };
447
+ }
448
+ function planObservation(plan, failure, provenance) {
449
+ if (failure !== undefined)
450
+ return { state: "unavailable", reason: failure };
451
+ if (typeof plan !== "string" || plan.length === 0) {
452
+ return { state: "unavailable", reason: "the account reports no plan" };
453
+ }
454
+ return { state: "known", value: plan, provenance };
455
+ }
456
+ function creditsObservation(subscription, failure, provenance) {
457
+ if (failure !== undefined)
458
+ return { state: "unavailable", reason: failure };
459
+ const remaining = subscription?.creditsAvailableUsd;
460
+ if (typeof remaining !== "number" || !Number.isFinite(remaining)) {
461
+ return { state: "unavailable", reason: "the account reports no credit balance" };
462
+ }
463
+ if (remaining < 0) {
464
+ // An overage plan can hold a negative balance, which the contract's
465
+ // non-negative remaining credit cannot state. Reporting it as zero would
466
+ // hide a debt, so the value is reported as absent with its cause.
467
+ return {
468
+ state: "unavailable",
469
+ reason: "the account credit balance is negative under an overage plan",
470
+ };
471
+ }
472
+ return { state: "known", value: { remaining, unit: "USD" }, provenance };
473
+ }
474
+ function quotaObservation(subscription, usage, subscriptionFailure, usageFailure, provenance) {
475
+ const failure = subscriptionFailure ?? usageFailure;
476
+ if (failure !== undefined)
477
+ return { state: "unavailable", reason: failure };
478
+ const limit = subscription?.maxConcurrentSandboxes;
479
+ const used = usage?.activeSandboxes;
480
+ if (typeof limit !== "number" || !Number.isFinite(limit) || limit <= 0) {
481
+ return {
482
+ state: "unavailable",
483
+ reason: "the account states no concurrent sandbox limit",
484
+ };
485
+ }
486
+ if (typeof used !== "number" || !Number.isFinite(used) || used < 0) {
487
+ return {
488
+ state: "unavailable",
489
+ reason: "the account reports no active sandbox count",
490
+ };
491
+ }
492
+ return {
493
+ state: "known",
494
+ value: { limit, used, remaining: Math.max(0, limit - used), unit: "sandboxes" },
495
+ provenance,
496
+ };
497
+ }
498
+ function periodObservation(usage, failure, provenance) {
499
+ if (failure !== undefined)
500
+ return { state: "unavailable", reason: failure };
501
+ const start = isoTimestamp(usage?.periodStart);
502
+ const end = isoTimestamp(usage?.periodEnd);
503
+ if (start === undefined || end === undefined) {
504
+ return { state: "unavailable", reason: "the account reports no billing period" };
505
+ }
506
+ return { state: "known", value: { start, end }, provenance };
507
+ }
508
+ function isoTimestamp(value) {
509
+ if (value === undefined)
510
+ return undefined;
511
+ const time = value instanceof Date ? value.getTime() : Date.parse(value);
512
+ if (!Number.isFinite(time))
513
+ return undefined;
514
+ return new Date(time).toISOString();
515
+ }
516
+ function positiveInteger(value) {
517
+ return typeof value === "number" && Number.isSafeInteger(value) && value > 0
518
+ ? value
519
+ : undefined;
520
+ }
521
+ function nonNegativeNumber(value) {
522
+ return typeof value === "number" && Number.isFinite(value) && value >= 0
523
+ ? value
524
+ : undefined;
525
+ }
@@ -4,6 +4,7 @@ import { capabilitiesForClient, defaultTangleSandboxCapabilities, } from "./tang
4
4
  import { sandboxInstanceAsEnvironment } from "./tangle-environment.js";
5
5
  import { assertCreateInputShape, assertMappedCreateOptions, assertMappedSecretNames, assertNoInlineSecretValues, sandboxOptionsFromCreateInput } from "./tangle-create-options.js";
6
6
  import { statusFromUnknown } from "./tangle-environment-values.js";
7
+ import { requestedResourceProfile } from "./tangle-resources.js";
7
8
  import { assertBoundedJson, attachCleanupHandle, awaitWithSignal, boundedIdentifier, boundedString, MAX_LIST_RESULTS, } from "./tangle-contract-safety.js";
8
9
  export function createTangleProvider(options) {
9
10
  const providerName = options.name ?? "tangle-sandbox";
@@ -83,7 +84,8 @@ export function createTangleProvider(options) {
83
84
  }
84
85
  try {
85
86
  input.signal?.throwIfAborted();
86
- const environment = await sandboxInstanceAsEnvironment(box, providerName, options.client, declaredCapabilities, input.signal ? { signal: input.signal } : undefined);
87
+ const requestedResources = requestedResourceProfile(input.resources);
88
+ const environment = await sandboxInstanceAsEnvironment(box, providerName, options.client, declaredCapabilities, input.signal ? { signal: input.signal } : undefined, requestedResources === undefined ? undefined : { resources: requestedResources });
87
89
  input.signal?.throwIfAborted();
88
90
  return environment;
89
91
  }
@@ -0,0 +1,22 @@
1
+ import type { CreateSandboxOptions } from "@tangle-network/sandbox";
2
+ import type { ResourceRequest } from "@tangle-network/agent-interface/environment-provider";
3
+ import type { ResourceProfile } from "@tangle-network/agent-interface";
4
+ /**
5
+ * Translate the contract's resource request into the Sandbox request shape.
6
+ *
7
+ * The two shapes name the same quantities differently — `cpu`/`memoryMb`/
8
+ * `diskMb` against `cpuCores`/`memoryMB`/`diskGB` — so a request passed
9
+ * through unchanged reaches the service as unknown fields and provisions a
10
+ * sandbox the caller never asked for. Disk is stated in gibibytes on the wire,
11
+ * and a request that is not a whole number of gibibytes is refused instead of
12
+ * being rounded to a size the caller did not name.
13
+ */
14
+ export declare function sandboxResourcesFromResourceRequest(resources: ResourceRequest | undefined): CreateSandboxOptions["resources"] | undefined;
15
+ /**
16
+ * The compute shape the caller asked for, in the observation's units.
17
+ *
18
+ * This is the request, never a measurement, so it carries no freshness state:
19
+ * the contract holds it beside the freshness-tagged effective profile so the
20
+ * two can be compared.
21
+ */
22
+ export declare function requestedResourceProfile(resources: ResourceRequest | undefined): ResourceProfile | undefined;
@@ -0,0 +1,74 @@
1
+ import { boundedIdentifier } from "./tangle-contract-safety.js";
2
+ /** Megabytes in one gibibyte, the unit Sandbox states disk in. */
3
+ const MB_PER_GIB = 1_024;
4
+ /**
5
+ * Number of accelerator devices a single-class GPU request means. Sandbox
6
+ * documents 1 as the default device count for an accelerator request that
7
+ * states only its class, so a request naming one class is one device.
8
+ */
9
+ const SINGLE_ACCELERATOR_COUNT = 1;
10
+ /**
11
+ * Translate the contract's resource request into the Sandbox request shape.
12
+ *
13
+ * The two shapes name the same quantities differently — `cpu`/`memoryMb`/
14
+ * `diskMb` against `cpuCores`/`memoryMB`/`diskGB` — so a request passed
15
+ * through unchanged reaches the service as unknown fields and provisions a
16
+ * sandbox the caller never asked for. Disk is stated in gibibytes on the wire,
17
+ * and a request that is not a whole number of gibibytes is refused instead of
18
+ * being rounded to a size the caller did not name.
19
+ */
20
+ export function sandboxResourcesFromResourceRequest(resources) {
21
+ if (resources === undefined)
22
+ return undefined;
23
+ const { cpu, memoryMb, diskMb, gpu } = resources;
24
+ if (cpu !== undefined && (!Number.isSafeInteger(cpu) || cpu < 1)) {
25
+ throw new Error("Tangle resource cpu must be a positive safe integer");
26
+ }
27
+ if (memoryMb !== undefined &&
28
+ (!Number.isSafeInteger(memoryMb) || memoryMb < 1)) {
29
+ throw new Error("Tangle resource memoryMb must be a positive safe integer");
30
+ }
31
+ if (diskMb !== undefined) {
32
+ if (!Number.isSafeInteger(diskMb) || diskMb < 1) {
33
+ throw new Error("Tangle resource diskMb must be a positive safe integer");
34
+ }
35
+ if (diskMb % MB_PER_GIB !== 0) {
36
+ throw new Error("Tangle resource diskMb must be a whole number of gibibytes");
37
+ }
38
+ }
39
+ if (gpu !== undefined)
40
+ boundedIdentifier(gpu, "Tangle GPU");
41
+ const mapped = {
42
+ ...(cpu === undefined ? {} : { cpuCores: cpu }),
43
+ ...(memoryMb === undefined ? {} : { memoryMB: memoryMb }),
44
+ ...(diskMb === undefined ? {} : { diskGB: diskMb / MB_PER_GIB }),
45
+ ...(gpu === undefined
46
+ ? {}
47
+ : { accelerator: { kind: gpu, count: SINGLE_ACCELERATOR_COUNT } }),
48
+ };
49
+ return Object.keys(mapped).length === 0 ? undefined : mapped;
50
+ }
51
+ /**
52
+ * The compute shape the caller asked for, in the observation's units.
53
+ *
54
+ * This is the request, never a measurement, so it carries no freshness state:
55
+ * the contract holds it beside the freshness-tagged effective profile so the
56
+ * two can be compared.
57
+ */
58
+ export function requestedResourceProfile(resources) {
59
+ if (resources === undefined)
60
+ return undefined;
61
+ // The Sandbox mapping owns the validation, so a profile is only reported for
62
+ // a request the adapter would actually send.
63
+ sandboxResourcesFromResourceRequest(resources);
64
+ const { cpu, memoryMb, diskMb, gpu } = resources;
65
+ const profile = {
66
+ ...(cpu === undefined ? {} : { cpu }),
67
+ ...(memoryMb === undefined ? {} : { memoryMb }),
68
+ ...(diskMb === undefined ? {} : { diskMb }),
69
+ ...(gpu === undefined
70
+ ? {}
71
+ : { accelerator: { kind: gpu, count: SINGLE_ACCELERATOR_COUNT } }),
72
+ };
73
+ return Object.keys(profile).length === 0 ? undefined : profile;
74
+ }
@@ -0,0 +1,44 @@
1
+ import type { TerminalOutputEvent } from "@tangle-network/agent-interface";
2
+ import type { TerminalReplayWindow } from "@tangle-network/agent-interface";
3
+ /**
4
+ * Ordered terminal frames with a replay cursor.
5
+ *
6
+ * Every frame takes a monotonic ordinal, and an `output` frame also takes a
7
+ * monotonic `seq` that a consumer replays from. `since` is EXCLUSIVE: it names
8
+ * the last sequence the consumer processed, so a reconnect resumes with
9
+ * neither loss nor duplication. A cursor whose successor frames were evicted is
10
+ * refused, because silently resuming after the gap would drop terminal output
11
+ * the consumer believes it received.
12
+ *
13
+ * The buffer is bounded, so the accepted cursors move as frames are evicted.
14
+ * {@link cursors} states the window they moved to: `earliest` is the oldest
15
+ * cursor `read` accepts and delivers every retained frame from, and `latest` is
16
+ * the newest output frame. A read with no cursor starts at `earliest`, so a
17
+ * consumer that holds none is never locked out. A consumer that names an
18
+ * evicted cursor is refused and told which cursor to resume from, because it
19
+ * believes it received the frames the gap would silently drop.
20
+ */
21
+ export declare class TerminalFrameLog {
22
+ private entries;
23
+ private lastOrdinal;
24
+ private outputSeq;
25
+ private evictedOrdinal;
26
+ private evictedOutputSeq;
27
+ private ended;
28
+ private waiters;
29
+ append(event: TerminalOutputEvent): void;
30
+ /** Append decoded PTY text, split so no frame exceeds the contract bound. */
31
+ appendOutput(text: string): void;
32
+ /** No more frames will arrive; readers drain what is retained and return. */
33
+ end(): void;
34
+ /**
35
+ * The cursors this buffer can serve. Reading from `earliest` yields every
36
+ * retained frame and loses no output, because eviction drops output frames in
37
+ * sequence order and `earliest` names the newest one that was dropped.
38
+ */
39
+ get cursors(): TerminalReplayWindow;
40
+ read(since: number | undefined, signal?: AbortSignal): AsyncIterable<TerminalOutputEvent>;
41
+ private ordinalForCursor;
42
+ private waitForFrames;
43
+ private wake;
44
+ }