unfazed 0.8.0 → 0.9.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.
package/dist/unfazed.d.ts CHANGED
@@ -1,4 +1,5 @@
1
1
  import { UnfazedClient, type ClientOptions, type RequestOptions } from "./client.js";
2
+ import { UnfazedOIDC } from "./oidc.js";
2
3
  import type { AppErrorGroupFieldsFragment, CreateDeploymentInput, DeploymentEventType, DeploymentFieldsFragment, FileCommitFieldsFragment, ListDeploymentsInput, RunFieldsFragment, RunStatus, ServiceMode, ServiceRuntime, SdkAppCommitQuery, SdkAppCommitsQuery, SdkAppErrorGroupQuery, SdkAppErrorsQuery, SdkCreateWorkspaceMutation, SdkDeleteAppMutation, SdkDeploymentEventsQuery, SdkEventTypesQuery, SdkPollEventsQuery, SdkRestoreVersionMutation, SdkRunHarnessesQuery, SdkWorkspaceLiveAppsQuery } from "./generated/graphql.js";
3
4
  import { type WaitOptions } from "./wait.js";
4
5
  /** A deployment as the workflow layer returns it. */
@@ -171,9 +172,12 @@ export interface RestoreVersionOptions {
171
172
  commitMessage?: string;
172
173
  /**
173
174
  * When the restore deploys (`deploys: true`, an app that deploys every
174
- * push), wait for that deploy: resolve once the import of the new commit
175
- * finished with every deployment it made, throw {@link UnfazedDeployError}
176
- * when it failed.
175
+ * push), wait for that deploy: the platform's wait by commit
176
+ * ({@link Unfazed.waitForDeployment} with `gitCommit`, `alias` and `after`
177
+ * from the answer) resolves once it is READY; {@link UnfazedDeployError}
178
+ * when it or the import of the new commit failed. A link with unfazed.json
179
+ * `apps[]` (no `deployAlias`) deploys once per app: then the wait follows
180
+ * the import until every deployment it made is done.
177
181
  */
178
182
  wait?: boolean;
179
183
  /** Timeout/backoff for `wait`; default 10 minutes. */
@@ -185,10 +189,29 @@ export type RestoreVersionResult = SdkRestoreVersionMutation["restoreAppVersion"
185
189
  /**
186
190
  * With `wait` and `deploys`: the deployments the restore's import made (one
187
191
  * per alias it deploys), READY. Absent without `wait` or when nothing
188
- * deploys.
192
+ * deploys. Without `wait`, wait later with `waitForDeployment({ workspaceId:
193
+ * workspaceID, gitCommit: sha, alias: deployAlias, after: waitAfter })`.
189
194
  */
190
195
  deployments?: Deployment[];
191
196
  };
197
+ /**
198
+ * The deploy a git import makes of a commit, for
199
+ * {@link Unfazed.waitForDeployment}: a restore, a push or a sync deploys
200
+ * before any deployment id exists, so the commit names it.
201
+ */
202
+ export interface CommitDeploymentTarget {
203
+ workspaceId: string;
204
+ /** Full 40-character sha. */
205
+ gitCommit: string;
206
+ /** The app that deploys it; required when several apps (unfazed.json `apps[]`) deployed the commit. */
207
+ alias?: string | null;
208
+ /**
209
+ * Count only deployments begun after this point of the platform's
210
+ * deployment order (a restore's `waitAfter`), so an earlier deploy of the
211
+ * same commit never answers.
212
+ */
213
+ after?: number | null;
214
+ }
192
215
  /** A service app's error group (MCP `list_errors`). Title, type, culprit and level are untrusted app text. */
193
216
  export type ErrorGroup = AppErrorGroupFieldsFragment;
194
217
  /** One page of error groups ({@link Unfazed.listErrors}). */
@@ -246,8 +269,14 @@ export interface DeploymentEvent {
246
269
  severity: SdkDeploymentEventsQuery["deploymentEvents"][number]["severity"];
247
270
  message: string;
248
271
  vmID: string | null;
249
- /** The event's fields; timing events carry their phases as `*_ms` strings ({@link parseDeploymentTimings}). */
272
+ /** The event's fields; timing events carry their phases as `*_ms` strings. */
250
273
  metadata: Record<string, string> | null;
274
+ /**
275
+ * What a timing event (`deployment.build_finished`, `deployment.phases`)
276
+ * measured, as the platform parsed it; null for any other event.
277
+ * {@link parseDeploymentTimings} prefers it to `metadata`.
278
+ */
279
+ timings: PlatformTimings | null;
251
280
  createdAt: string;
252
281
  }
253
282
  /**
@@ -549,9 +578,19 @@ export interface UnfazedOptions extends ClientOptions {
549
578
  */
550
579
  export declare class Unfazed {
551
580
  #private;
552
- /** The underlying typed GraphQL client. Use it for un-wrapped operations. */
553
- readonly client: UnfazedClient;
581
+ /**
582
+ * Workload identity tokens for the app this code runs in
583
+ * (`oidc.token(audience)`, `oidc.fetchWithToken(audience)`). They need only
584
+ * the app's `UNFZ_STATE_TOKEN`, so they work in an `Unfazed` made without
585
+ * an API token or a login.
586
+ */
587
+ readonly oidc: UnfazedOIDC;
554
588
  constructor(options?: UnfazedOptions);
589
+ /**
590
+ * The underlying typed GraphQL client. Use it for un-wrapped operations.
591
+ * Throws {@link UnfazedAuthError} when there is no token and no login.
592
+ */
593
+ get client(): UnfazedClient;
555
594
  /** Who this token authenticates as, plus usage and resource limits. */
556
595
  whoami(options?: RequestOptions): Promise<{
557
596
  kind: import("./index.js").CurrentActorKind;
@@ -862,12 +901,27 @@ export declare class Unfazed {
862
901
  * the account already holds its cap of waits in progress (16), it polls
863
902
  * instead (`waitOptions.mode: "poll"` forces that), every 0.5 s backing
864
903
  * off to 3 s by default.
904
+ *
905
+ * Pass a {@link CommitDeploymentTarget} instead of an id to wait for the
906
+ * deploy a git import makes of a commit — a restore, a push or a sync
907
+ * deploys before any id exists. The wait covers the import too, and
908
+ * {@link UnfazedDeployError} carries the import's error when it failed on
909
+ * the commit.
910
+ *
911
+ * ```ts
912
+ * const back = await unfazed.restoreVersion({ workspaceId: ws.id, sha });
913
+ * await unfazed.waitForDeployment({
914
+ * workspaceId: back.workspaceID, gitCommit: back.sha, alias: back.deployAlias, after: back.waitAfter,
915
+ * });
916
+ * ```
865
917
  */
866
- waitForDeployment(deploymentId: string, waitOptions?: WaitOptions): Promise<Deployment>;
918
+ waitForDeployment(target: string | CommitDeploymentTarget, waitOptions?: WaitOptions): Promise<Deployment>;
867
919
  /**
868
- * The platform's measured phases of a deploy ({@link PlatformTimings}),
869
- * from its timing events. Null when it recorded none (a deploy that is
870
- * still building, or one from before timings existed).
920
+ * The platform's measured phases of a deploy ({@link PlatformTimings}), as
921
+ * it parses them from all of the deployment's timing events
922
+ * (`Deployment.timings`). Null when it recorded none (a deploy that is
923
+ * still building, or one from before timings existed), or for a deployment
924
+ * you cannot see.
871
925
  */
872
926
  getDeploymentTimings(deploymentId: string, options?: RequestOptions): Promise<PlatformTimings | null>;
873
927
  /** Reads one deployment by id. Null when absent. */
@@ -1162,9 +1216,12 @@ export declare class Unfazed {
1162
1216
  * When the app deploys every push (`deploys: true`), the new commit is
1163
1217
  * imported and deployed like a push. That deploy is queued behind the
1164
1218
  * restore, so the platform answers before it exists; with `wait: true` the
1165
- * SDK waits for the import of the new commit and resolves with the
1166
- * deployments it made (READY), or throws {@link UnfazedDeployError} with
1167
- * the import's error.
1219
+ * SDK waits for it by commit on the platform ({@link Unfazed.waitForDeployment}
1220
+ * with `gitCommit: sha`, `alias: deployAlias`, `after: waitAfter`) and
1221
+ * resolves with it READY, or throws {@link UnfazedDeployError} with its
1222
+ * build error or the import's. A link with unfazed.json `apps[]` names no
1223
+ * `deployAlias` and deploys once per app: then it follows the import until
1224
+ * every deployment it made is done.
1168
1225
  *
1169
1226
  * ```ts
1170
1227
  * const page = await unfazed.listCommits(ws.id);
@@ -1406,10 +1463,18 @@ export declare class Unfazed {
1406
1463
  truncated: boolean;
1407
1464
  }>;
1408
1465
  }
1466
+ /** An event {@link parseDeploymentTimings} reads: `timings` when the platform parsed it, else `metadata`. */
1467
+ export type TimingEvent = Pick<DeploymentEvent, "type" | "metadata"> & {
1468
+ timings?: PlatformTimings | null;
1469
+ };
1409
1470
  /**
1410
- * A deploy's phases from its events: `deployment.phases` (written when it went
1411
- * READY: build, promotion and its split, total) over `deployment.build_finished`
1412
- * (the build VM's own phases). Every `*_ms` field becomes a number. Null when
1413
- * neither event is there.
1471
+ * A deploy's phases from its events, as the platform reads them: the latest
1472
+ * `deployment.phases` (written when it went READY: build, promotion and its
1473
+ * split, total) over the latest `deployment.build_finished` (the build VM's
1474
+ * own phases). It prefers the `timings` the platform parsed into each event
1475
+ * ({@link Unfazed.getEvents} reads them); events without them (an older
1476
+ * platform, events built by hand) are parsed from their `*_ms` metadata the
1477
+ * same way. Durations are whole, non-negative milliseconds: anything else is
1478
+ * dropped, as the platform drops it. Null when neither event is there.
1414
1479
  */
1415
- export declare function parseDeploymentTimings(events: readonly Pick<DeploymentEvent, "type" | "metadata">[]): PlatformTimings | null;
1480
+ export declare function parseDeploymentTimings(events: readonly TimingEvent[]): PlatformTimings | null;
package/dist/unfazed.js CHANGED
@@ -1,5 +1,6 @@
1
1
  import { UnfazedClient } from "./client.js";
2
- import { DEPLOYMENT_NOT_FOUND, UnfazedConflictError, UnfazedDeployError, UnfazedError, UnfazedGraphQLError, UnfazedNotFoundError, UnfazedRunError, UnfazedTimeoutError, UnfazedUnconfirmedAppsError, UnfazedWriteError, WORKSPACE_NOT_FOUND, isAppNotFound, isDuplicateWorkspaceName, isNotFoundSentinel as isNotFound, isUnconfirmedAppsRefusal, isTooManyWaits, isUnsupportedField, looksLikeCASConflict, } from "./errors.js";
2
+ import { DEPLOYMENT_NOT_FOUND, UnfazedAuthError, UnfazedConflictError, UnfazedDeployError, UnfazedError, UnfazedGraphQLError, UnfazedNotFoundError, UnfazedRunError, UnfazedTimeoutError, UnfazedUnconfirmedAppsError, UnfazedWriteError, WORKSPACE_NOT_FOUND, isAppNotFound, isDuplicateWorkspaceName, isNotFoundSentinel as isNotFound, isUnconfirmedAppsRefusal, isTooManyWaits, isUnsupportedField, looksLikeCASConflict, } from "./errors.js";
3
+ import { UnfazedOIDC } from "./oidc.js";
3
4
  import * as ops from "./operations.js";
4
5
  import { pollWithBackoff, resolveWaitOptions } from "./wait.js";
5
6
  /**
@@ -20,11 +21,51 @@ import { pollWithBackoff, resolveWaitOptions } from "./wait.js";
20
21
  * ```
21
22
  */
22
23
  export class Unfazed {
23
- /** The underlying typed GraphQL client. Use it for un-wrapped operations. */
24
- client;
24
+ #client;
25
+ /** Why there is no client: no token and no stored login. */
26
+ #noClient;
27
+ /**
28
+ * Workload identity tokens for the app this code runs in
29
+ * (`oidc.token(audience)`, `oidc.fetchWithToken(audience)`). They need only
30
+ * the app's `UNFZ_STATE_TOKEN`, so they work in an `Unfazed` made without
31
+ * an API token or a login.
32
+ */
33
+ oidc;
25
34
  constructor(options = {}) {
26
35
  const { client, ...clientOptions } = options;
27
- this.client = client ?? new UnfazedClient(clientOptions);
36
+ let made = client;
37
+ if (!made) {
38
+ try {
39
+ made = new UnfazedClient(clientOptions);
40
+ }
41
+ catch (error) {
42
+ // An app has its state token but no API token: `oidc` still works,
43
+ // and the first call that needs the API says how to sign in.
44
+ if (!(error instanceof UnfazedAuthError))
45
+ throw error;
46
+ this.#noClient = error;
47
+ }
48
+ }
49
+ this.#client = made;
50
+ // Tokens come from the platform the client talks to: its endpoint already
51
+ // resolved `endpoint`, `baseUrl`, `UNFZ_API_URL` and the stored login.
52
+ const endpoint = made?.endpoint ?? options.endpoint;
53
+ this.oidc = new UnfazedOIDC({
54
+ ...(endpoint ? { endpoint } : {}),
55
+ ...(!made && options.baseUrl ? { baseUrl: options.baseUrl } : {}),
56
+ ...(options.env ? { env: options.env } : {}),
57
+ ...(options.fetch ? { fetch: options.fetch } : {}),
58
+ ...(options.userAgent !== undefined ? { userAgent: options.userAgent } : {}),
59
+ });
60
+ }
61
+ /**
62
+ * The underlying typed GraphQL client. Use it for un-wrapped operations.
63
+ * Throws {@link UnfazedAuthError} when there is no token and no login.
64
+ */
65
+ get client() {
66
+ if (this.#client)
67
+ return this.#client;
68
+ throw new UnfazedAuthError(this.#noClient?.message ?? "unfazed: no API client", { cause: this.#noClient });
28
69
  }
29
70
  /* ---------------------------------------------------------------- identity */
30
71
  /** Who this token authenticates as, plus usage and resource limits. */
@@ -464,9 +505,24 @@ export class Unfazed {
464
505
  * the account already holds its cap of waits in progress (16), it polls
465
506
  * instead (`waitOptions.mode: "poll"` forces that), every 0.5 s backing
466
507
  * off to 3 s by default.
508
+ *
509
+ * Pass a {@link CommitDeploymentTarget} instead of an id to wait for the
510
+ * deploy a git import makes of a commit — a restore, a push or a sync
511
+ * deploys before any id exists. The wait covers the import too, and
512
+ * {@link UnfazedDeployError} carries the import's error when it failed on
513
+ * the commit.
514
+ *
515
+ * ```ts
516
+ * const back = await unfazed.restoreVersion({ workspaceId: ws.id, sha });
517
+ * await unfazed.waitForDeployment({
518
+ * workspaceId: back.workspaceID, gitCommit: back.sha, alias: back.deployAlias, after: back.waitAfter,
519
+ * });
520
+ * ```
467
521
  */
468
- async waitForDeployment(deploymentId, waitOptions = {}) {
469
- return (await this.#waitTerminal(deploymentId, waitOptions)).deployment;
522
+ async waitForDeployment(target, waitOptions = {}) {
523
+ if (typeof target !== "string")
524
+ return (await this.#commitWait(target, waitOptions)).deployment;
525
+ return (await this.#waitTerminal(target, waitOptions)).deployment;
470
526
  }
471
527
  async #waitTerminal(deploymentId, waitOptions) {
472
528
  const now = waitOptions.now ?? (() => Date.now());
@@ -518,7 +574,11 @@ export class Unfazed {
518
574
  }
519
575
  throw e;
520
576
  }
577
+ // By id the platform always answers the deployment; null is only for a
578
+ // wait by commit before the import made it.
521
579
  const d = answer.deployment;
580
+ if (!d)
581
+ throw new UnfazedNotFoundError(`unfazed: deployment ${deploymentId} not found`);
522
582
  last = d;
523
583
  if (answer.timedOut) {
524
584
  landing = d.status === "READY";
@@ -529,6 +589,61 @@ export class Unfazed {
529
589
  throw new UnfazedDeployError("FAILED", deploymentId, d, `unfazed: deployment ${deploymentId} ${d.status}${d.error ? `: ${d.error}` : ""}`);
530
590
  }
531
591
  }
592
+ /**
593
+ * The server-side wait by commit: for the import to make the commit's
594
+ * deployment, then for it, one budget for both. There is nothing cheaper to
595
+ * poll, so a refusal past the platform's cap on waits in progress backs off
596
+ * (0.5 s up to 3 s) and asks again.
597
+ */
598
+ async #commitWait(target, waitOptions) {
599
+ const resolved = resolveWaitOptions(waitOptions);
600
+ const deadline = resolved.now() + resolved.timeoutMs;
601
+ const commit = target.gitCommit.slice(0, 7);
602
+ const what = `the deploy of ${commit}${target.alias ? ` to ${target.alias}` : ""}`;
603
+ let last = null;
604
+ let status = "IMPORTING";
605
+ let backoff = 500;
606
+ for (let requests = 1;; requests++) {
607
+ if (resolved.signal?.aborted)
608
+ throw new UnfazedTimeoutError("unfazed: wait aborted");
609
+ const remaining = deadline - resolved.now();
610
+ if (requests > 1 && remaining <= 0) {
611
+ throw new UnfazedDeployError("TIMEOUT", last?.id ?? "", last, `unfazed: ${what} still ${status} after ${resolved.timeoutMs}ms`);
612
+ }
613
+ const seconds = Math.max(1, Math.min(SERVER_WAIT_SECONDS, Math.ceil(remaining / 1000)));
614
+ let answer;
615
+ try {
616
+ answer = (await this.client.query(ops.WaitForCommitDeploymentDocument, {
617
+ workspaceID: target.workspaceId,
618
+ gitCommit: target.gitCommit,
619
+ alias: target.alias ?? null,
620
+ after: target.after ?? null,
621
+ timeoutSeconds: seconds,
622
+ }, { ...(resolved.signal ? { signal: resolved.signal } : {}), timeoutMs: (seconds + 30) * 1000 })).waitForDeployment;
623
+ }
624
+ catch (e) {
625
+ if (!isTooManyWaits(e))
626
+ throw e;
627
+ await resolved.sleep(Math.max(0, Math.min(backoff, deadline - resolved.now())), resolved.signal);
628
+ backoff = Math.min(backoff * 1.5, 3_000);
629
+ continue;
630
+ }
631
+ const d = answer.deployment;
632
+ status = answer.status;
633
+ if (d)
634
+ last = d;
635
+ if (answer.timedOut)
636
+ continue;
637
+ if (!d) {
638
+ // Not timed out and no deployment: the import failed on the commit
639
+ // and nothing imports any more, so no deployment of it is coming.
640
+ throw new UnfazedDeployError("FAILED", "", null, `unfazed: the import of ${commit} failed${answer.importError ? `: ${answer.importError}` : ""}`);
641
+ }
642
+ if (d.status === "READY")
643
+ return { deployment: d, requests };
644
+ throw new UnfazedDeployError("FAILED", d.id, d, `unfazed: deployment ${d.id} (${what}) ${d.status}${d.error ? `: ${d.error}` : ""}`);
645
+ }
646
+ }
532
647
  async #pollWait(deploymentId, waitOptions) {
533
648
  const resolved = resolveWaitOptions({ initialIntervalMs: 500, maxIntervalMs: 3_000, ...waitOptions });
534
649
  const requestOptions = resolved.signal ? { signal: resolved.signal } : {};
@@ -557,12 +672,16 @@ export class Unfazed {
557
672
  return { deployment: full, requests: requests + 1, mode: "poll" };
558
673
  }
559
674
  /**
560
- * The platform's measured phases of a deploy ({@link PlatformTimings}),
561
- * from its timing events. Null when it recorded none (a deploy that is
562
- * still building, or one from before timings existed).
675
+ * The platform's measured phases of a deploy ({@link PlatformTimings}), as
676
+ * it parses them from all of the deployment's timing events
677
+ * (`Deployment.timings`). Null when it recorded none (a deploy that is
678
+ * still building, or one from before timings existed), or for a deployment
679
+ * you cannot see.
563
680
  */
564
681
  async getDeploymentTimings(deploymentId, options) {
565
- return parseDeploymentTimings(await this.getEvents(deploymentId, 100, options));
682
+ const data = await this.client.query(ops.DeploymentTimingsDocument, { id: deploymentId }, options);
683
+ const timings = data.deployment?.timings;
684
+ return timings ? toPlatformTimings(timings) : null;
566
685
  }
567
686
  /**
568
687
  * The timings of a deploy that just went READY. `deployment.phases` is
@@ -964,9 +1083,12 @@ export class Unfazed {
964
1083
  * When the app deploys every push (`deploys: true`), the new commit is
965
1084
  * imported and deployed like a push. That deploy is queued behind the
966
1085
  * restore, so the platform answers before it exists; with `wait: true` the
967
- * SDK waits for the import of the new commit and resolves with the
968
- * deployments it made (READY), or throws {@link UnfazedDeployError} with
969
- * the import's error.
1086
+ * SDK waits for it by commit on the platform ({@link Unfazed.waitForDeployment}
1087
+ * with `gitCommit: sha`, `alias: deployAlias`, `after: waitAfter`) and
1088
+ * resolves with it READY, or throws {@link UnfazedDeployError} with its
1089
+ * build error or the import's. A link with unfazed.json `apps[]` names no
1090
+ * `deployAlias` and deploys once per app: then it follows the import until
1091
+ * every deployment it made is done.
970
1092
  *
971
1093
  * ```ts
972
1094
  * const page = await unfazed.listCommits(ws.id);
@@ -985,18 +1107,19 @@ export class Unfazed {
985
1107
  const restored = data.restoreAppVersion;
986
1108
  if (!options.wait || !restored.deploys)
987
1109
  return restored;
988
- return {
989
- ...restored,
990
- deployments: await this.#waitForImport(options.workspaceId, restored.sha, {
991
- ...options.waitOptions,
992
- ...(options.signal ? { signal: options.signal } : {}),
993
- }),
994
- };
1110
+ const waitOptions = { ...options.waitOptions, ...(options.signal ? { signal: options.signal } : {}) };
1111
+ if (restored.deployAlias) {
1112
+ const { deployment } = await this.#commitWait({ workspaceId: restored.workspaceID, gitCommit: restored.sha, alias: restored.deployAlias, after: restored.waitAfter }, waitOptions);
1113
+ return { ...restored, deployments: [deployment] };
1114
+ }
1115
+ return { ...restored, deployments: await this.#waitForImport(restored.workspaceID, restored.sha, waitOptions) };
995
1116
  }
996
1117
  /**
997
1118
  * Waits until the app's git link has imported `sha` (READY: its deploys
998
1119
  * are done) and returns the deployments of that commit; throws on a FAILED
999
- * import of it.
1120
+ * import of it. Only for a restore whose link names no alias (unfazed.json
1121
+ * `apps[]`): the wait by commit waits for one app's deploy, and which apps
1122
+ * a commit deploys is known only once its import is done.
1000
1123
  */
1001
1124
  async #waitForImport(workspaceId, sha, waitOptions) {
1002
1125
  const resolved = resolveWaitOptions({ initialIntervalMs: 500, maxIntervalMs: 3_000, ...waitOptions });
@@ -1443,51 +1566,123 @@ function toDeploymentEvent(event) {
1443
1566
  message: event.message,
1444
1567
  vmID: event.vmID ?? null,
1445
1568
  metadata: event.metadata ?? null,
1569
+ timings: event.timings ? toPlatformTimings(event.timings) : null,
1446
1570
  createdAt: event.createdAt,
1447
1571
  };
1448
1572
  }
1573
+ const BUILD_FINISHED_EVENT = "deployment.build_finished";
1574
+ const DEPLOY_PHASES_EVENT = "deployment.phases";
1449
1575
  /**
1450
- * A deploy's phases from its events: `deployment.phases` (written when it went
1451
- * READY: build, promotion and its split, total) over `deployment.build_finished`
1452
- * (the build VM's own phases). Every `*_ms` field becomes a number. Null when
1453
- * neither event is there.
1576
+ * A duration as the platform accepts one: a whole, non-negative number of
1577
+ * milliseconds (a number, or a string of digits as metadata carries it). A
1578
+ * fraction or anything else is not a duration; a negative one is a clock gone
1579
+ * backwards. Both are dropped, as the platform drops them.
1454
1580
  */
1455
- export function parseDeploymentTimings(events) {
1456
- const latest = (type) => [...events].reverse().find((e) => e.type === type)?.metadata ?? null;
1457
- const build = latest("deployment.build_finished");
1458
- const ready = latest("deployment.phases");
1459
- if (!build && !ready)
1581
+ function durationMs(value) {
1582
+ let n;
1583
+ if (typeof value === "number")
1584
+ n = value;
1585
+ else if (typeof value === "string" && /^[+-]?\d+$/.test(value.trim()))
1586
+ n = Number(value.trim());
1587
+ else
1460
1588
  return null;
1461
- const merged = { ...build, ...ready };
1462
- const ms = (name) => {
1463
- const value = merged[`${name}_ms`];
1464
- if (value === undefined)
1465
- return null;
1466
- const n = Number(value);
1467
- return Number.isFinite(n) ? n : null;
1468
- };
1589
+ return Number.isSafeInteger(n) && n >= 0 ? n + 0 : null; // + 0: -0 is 0
1590
+ }
1591
+ /** The platform's structured timings, with every duration checked like the metadata's. */
1592
+ function toPlatformTimings(t) {
1469
1593
  const phases = {};
1470
- for (const [key, value] of Object.entries(merged)) {
1471
- if (!key.endsWith("_ms"))
1472
- continue;
1473
- const name = key.slice(0, -3);
1474
- if (name === "total" || name === "build" || name === "promote")
1475
- continue;
1476
- const n = Number(value);
1477
- if (Number.isFinite(n))
1478
- phases[name] = n;
1594
+ if (t.phases && typeof t.phases === "object") {
1595
+ for (const [name, value] of Object.entries(t.phases)) {
1596
+ const ms = durationMs(value);
1597
+ if (ms !== null)
1598
+ phases[name] = ms;
1599
+ }
1479
1600
  }
1480
- const memory = Number(merged["build_memory_mib"]);
1601
+ const memory = durationMs(t.buildMemoryMiB);
1481
1602
  return {
1482
- totalMs: ready ? ms("total") : null,
1483
- buildMs: ms("build"),
1484
- promoteMs: ms("promote"),
1603
+ totalMs: durationMs(t.totalMs),
1604
+ buildMs: durationMs(t.buildMs),
1605
+ promoteMs: durationMs(t.promoteMs),
1485
1606
  phases,
1486
- buildMode: merged["build_mode"] ?? null,
1487
- artifactTransport: merged["artifact_transport"] ?? null,
1488
- buildMemoryMiB: Number.isFinite(memory) && merged["build_memory_mib"] !== undefined ? memory : null,
1607
+ buildMode: t.buildMode || null,
1608
+ artifactTransport: t.artifactTransport || null,
1609
+ buildMemoryMiB: memory ? memory : null,
1489
1610
  };
1490
1611
  }
1612
+ /**
1613
+ * Timing-event metadata read exactly as the platform reads it
1614
+ * (internal/usecases/deploy_timings.go): every `<name>_ms` that is a whole,
1615
+ * non-negative number; `total` only from the READY summary.
1616
+ */
1617
+ function timingsFromMetadata(metadata, ready) {
1618
+ const out = {
1619
+ totalMs: null,
1620
+ buildMs: null,
1621
+ promoteMs: null,
1622
+ phases: {},
1623
+ buildMode: metadata["build_mode"] || null,
1624
+ artifactTransport: metadata["artifact_transport"] || null,
1625
+ buildMemoryMiB: null,
1626
+ };
1627
+ for (const [key, raw] of Object.entries(metadata)) {
1628
+ if (!key.endsWith("_ms") || key === "_ms")
1629
+ continue;
1630
+ const ms = durationMs(raw);
1631
+ if (ms === null)
1632
+ continue;
1633
+ const name = key.slice(0, -3);
1634
+ if (name === "total") {
1635
+ if (ready)
1636
+ out.totalMs = ms;
1637
+ }
1638
+ else if (name === "build")
1639
+ out.buildMs = ms;
1640
+ else if (name === "promote")
1641
+ out.promoteMs = ms;
1642
+ else
1643
+ out.phases[name] = ms;
1644
+ }
1645
+ const memory = durationMs(metadata["build_memory_mib"]);
1646
+ if (memory)
1647
+ out.buildMemoryMiB = memory;
1648
+ return out;
1649
+ }
1650
+ /**
1651
+ * A deploy's phases from its events, as the platform reads them: the latest
1652
+ * `deployment.phases` (written when it went READY: build, promotion and its
1653
+ * split, total) over the latest `deployment.build_finished` (the build VM's
1654
+ * own phases). It prefers the `timings` the platform parsed into each event
1655
+ * ({@link Unfazed.getEvents} reads them); events without them (an older
1656
+ * platform, events built by hand) are parsed from their `*_ms` metadata the
1657
+ * same way. Durations are whole, non-negative milliseconds: anything else is
1658
+ * dropped, as the platform drops it. Null when neither event is there.
1659
+ */
1660
+ export function parseDeploymentTimings(events) {
1661
+ const latest = (type) => {
1662
+ const event = [...events].reverse().find((e) => e.type === type);
1663
+ return event && (event.timings || event.metadata) ? event : null;
1664
+ };
1665
+ const build = latest(BUILD_FINISHED_EVENT);
1666
+ const ready = latest(DEPLOY_PHASES_EVENT);
1667
+ if (!build && !ready)
1668
+ return null;
1669
+ if ((!build || build.timings) && (!ready || ready.timings)) {
1670
+ const b = build?.timings ? toPlatformTimings(build.timings) : null;
1671
+ const r = ready?.timings ? toPlatformTimings(ready.timings) : null;
1672
+ return {
1673
+ totalMs: r?.totalMs ?? null,
1674
+ buildMs: r?.buildMs ?? b?.buildMs ?? null,
1675
+ promoteMs: r?.promoteMs ?? b?.promoteMs ?? null,
1676
+ phases: { ...b?.phases, ...r?.phases },
1677
+ buildMode: r?.buildMode ?? b?.buildMode ?? null,
1678
+ artifactTransport: r?.artifactTransport ?? b?.artifactTransport ?? null,
1679
+ buildMemoryMiB: r?.buildMemoryMiB ?? b?.buildMemoryMiB ?? null,
1680
+ };
1681
+ }
1682
+ // The platform merges the two events' metadata, the READY summary's values
1683
+ // over the build's, and parses the result.
1684
+ return timingsFromMetadata({ ...build?.metadata, ...ready?.metadata }, ready !== null);
1685
+ }
1491
1686
  function errorGroupInput(ref) {
1492
1687
  return { workspaceID: ref.workspaceId, alias: ref.alias, groupID: ref.groupId };
1493
1688
  }