@runuai/host 0.9.170 → 0.9.171

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.
@@ -65,6 +65,14 @@
65
65
  * transition the authority does. Should saying so fail, the
66
66
  * runner is pending again, withdraws what it advertised, and says so later.
67
67
  *
68
+ * What stops the switch is said to the cloud as well as logged (`status`,
69
+ * on every capability frame; `onStatusChange` when it changes): the latest
70
+ * refusal or blocker, who gave it (the cloud's answer, or this runner), each
71
+ * blocker apart and every Compose project the retirement could not prove
72
+ * Uai's. A transient one (the container runtime still preparing after a
73
+ * reconnect, an unanswered ask) never replaces a substantive one: an
74
+ * operator reading Connections sees what to fix, not what fixes itself.
75
+ *
68
76
  * The record holds no secret: `{"schemaVersion":1,"step":"local",
69
77
  * "stopped":[{"taskId":"…","runEpoch":3}]}`. One the runner cannot read
70
78
  * counts as `local`, never as `legacy`: a damaged record may stand for a
@@ -88,6 +96,14 @@ import {
88
96
  import { dirname } from "node:path";
89
97
 
90
98
  import type { CredentialsActivationPhase } from "../src/cloud-credentials";
99
+ import {
100
+ MAX_CREDENTIALS_SWITCH_BLOCKER_CHARS,
101
+ MAX_CREDENTIALS_SWITCH_BLOCKERS,
102
+ MAX_CREDENTIALS_SWITCH_DETAIL_CHARS,
103
+ MAX_CREDENTIALS_SWITCH_PROJECTS,
104
+ type CredentialsSwitchCapability,
105
+ type CredentialsSwitchStuck,
106
+ } from "../src/protocol";
91
107
  import type { CredentialsMode } from "./cloud-credential-host";
92
108
  import type { RetirementPlan } from "./legacy-runtime-retirement";
93
109
  import { isSafeHostTaskId } from "./task-identity";
@@ -167,6 +183,12 @@ export interface ActivationAuthorityDeps<Retirement = RetirementPlan> {
167
183
  /** Ask the runner's supervisor for a restart. */
168
184
  restart: (why: string) => void;
169
185
  log: (line: string) => void;
186
+ /** `status()` changed: advertise it again. */
187
+ onStatusChange?: () => void;
188
+ /** What `status()` says of a blocker, made fit to leave the machine (the
189
+ * home directory as `~`). */
190
+ redact?: (text: string) => string;
191
+ now?: () => number;
170
192
  }
171
193
 
172
194
  export class CredentialsActivationAuthority<Retirement = RetirementPlan> {
@@ -191,6 +213,14 @@ export class CredentialsActivationAuthority<Retirement = RetirementPlan> {
191
213
  #lastNote: string | null = null;
192
214
  /** Leaving the runner's own credentials failed in this process. */
193
215
  #stuck = false;
216
+ /** What stops the switch now, as `status()` says it. */
217
+ #blocker: CredentialsSwitchStuck | null = null;
218
+ /** Every Compose project the current blocker names, past the status's
219
+ * bound too: what the log lists, and what tells one blocker from another
220
+ * when only names past that bound changed. */
221
+ #blockerProjects = "";
222
+ /** `status()` as last told to `onStatusChange`'s caller. */
223
+ #published: string | null = null;
194
224
 
195
225
  constructor(deps: ActivationAuthorityDeps<Retirement>) {
196
226
  this.#deps = deps;
@@ -218,6 +248,13 @@ export class CredentialsActivationAuthority<Retirement = RetirementPlan> {
218
248
  return this.#record?.step ?? null;
219
249
  }
220
250
 
251
+ /** Where the switch stands, for the capability frame (see the header). */
252
+ status(): CredentialsSwitchCapability {
253
+ const state = this.mode();
254
+ const stuck = state === "active" ? null : this.#blocker;
255
+ return stuck === null ? { state } : { state, stuck: { ...stuck } };
256
+ }
257
+
221
258
  mode(): CredentialsMode {
222
259
  if (this.#record === null) {
223
260
  return this.#initiallyPending ? "pending" : "legacy";
@@ -249,9 +286,12 @@ export class CredentialsActivationAuthority<Retirement = RetirementPlan> {
249
286
  advance(): Promise<void> {
250
287
  if (this.#advancing === null) {
251
288
  this.#advancing = this.#advanceOnce()
252
- .catch((error: unknown) => this.#note(`activation stopped: ${message(error)}; it tries again later`))
289
+ .catch((error: unknown) =>
290
+ this.#block("runner", error, `activation stopped: ${message(error)}; it tries again later`),
291
+ )
253
292
  .finally(() => {
254
293
  this.#advancing = null;
294
+ this.#publish();
255
295
  });
256
296
  }
257
297
  return this.#advancing;
@@ -266,10 +306,13 @@ export class CredentialsActivationAuthority<Retirement = RetirementPlan> {
266
306
  }
267
307
  let plan: Retirement | null = null;
268
308
  if (this.#record === null) {
269
- if (!this.#deps.eligible()) return;
270
- const prepared = await this.#deps.ask("prepare");
309
+ if (!this.#deps.eligible()) {
310
+ this.#blocker = null;
311
+ return;
312
+ }
313
+ const prepared = await this.#ask("prepare");
271
314
  if (!prepared.ok) {
272
- this.#note(`the cloud did not agree to cloud credentials yet: ${prepared.error}`);
315
+ this.#block("cloud", prepared.error, `the cloud did not agree to cloud credentials yet: ${prepared.error}`);
273
316
  return;
274
317
  }
275
318
  const paused = this.#deps.pauseLegacyStarts();
@@ -287,7 +330,9 @@ export class CredentialsActivationAuthority<Retirement = RetirementPlan> {
287
330
  this.#record ??= readActivationRecord(this.#deps.recordPath);
288
331
  if (this.#record === null) {
289
332
  paused.resume();
290
- this.#note(
333
+ this.#block(
334
+ "runner",
335
+ error,
291
336
  this.#initiallyPending
292
337
  ? `the v1-required task machine stays pending, with no v0 fallback: ${message(error)}`
293
338
  : `the runner stays on its own credentials, nothing changed: ${message(error)}`,
@@ -295,6 +340,7 @@ export class CredentialsActivationAuthority<Retirement = RetirementPlan> {
295
340
  return;
296
341
  }
297
342
  }
343
+ this.#blocker = null;
298
344
  this.#deps.log("[credentials] switching to cloud credentials: leaving the runner's own");
299
345
  }
300
346
  if (!this.#synced()) return;
@@ -319,11 +365,16 @@ export class CredentialsActivationAuthority<Retirement = RetirementPlan> {
319
365
  plan ??= await this.#deps.planRetirement();
320
366
  await this.#retire(plan);
321
367
  await this.#deps.claimFloor();
322
- const committed = await this.#deps.ask("commit");
368
+ const committed = await this.#ask("commit");
323
369
  if (!committed.ok) {
324
- this.#note(`the cloud did not record this runner's switch yet: ${committed.error}; it runs no task meanwhile`);
370
+ this.#block(
371
+ "cloud",
372
+ committed.error,
373
+ `the cloud did not record this runner's switch yet: ${committed.error}; it runs no task meanwhile`,
374
+ );
325
375
  return;
326
376
  }
377
+ this.#blocker = null;
327
378
  this.#write({ ...this.#record, step: "cloud" });
328
379
  }
329
380
  if (this.#record?.step === "cloud") {
@@ -350,7 +401,9 @@ export class CredentialsActivationAuthority<Retirement = RetirementPlan> {
350
401
  this.#write(record);
351
402
  }
352
403
  } catch (error) {
353
- this.#note(
404
+ this.#block(
405
+ "runner",
406
+ error,
354
407
  `the activation record is not known to be on disk yet (${message(error)}): the switch waits, running no task, ` +
355
408
  "until it is",
356
409
  );
@@ -373,7 +426,9 @@ export class CredentialsActivationAuthority<Retirement = RetirementPlan> {
373
426
  try {
374
427
  await this.#deps.sweep();
375
428
  } catch (error) {
376
- this.#note(
429
+ this.#block(
430
+ "runner",
431
+ error,
377
432
  `the recovery sweep failed (${message(error)}): the runner stays pending, running no task, and sweeps again later`,
378
433
  );
379
434
  return;
@@ -397,10 +452,15 @@ export class CredentialsActivationAuthority<Retirement = RetirementPlan> {
397
452
  }
398
453
  if (failure === null) {
399
454
  this.#announced = true;
455
+ this.#blocker = null;
400
456
  this.#deps.log("[credentials] tasks run on cloud credentials");
401
457
  return;
402
458
  }
403
- this.#note(`saying the runner is active failed (${message(failure)}): it is pending again, and tries again later`);
459
+ this.#block(
460
+ "runner",
461
+ failure,
462
+ `saying the runner is active failed (${message(failure)}): it is pending again, and tries again later`,
463
+ );
404
464
  try {
405
465
  this.#deps.onActiveWithdrawn();
406
466
  } catch (error) {
@@ -441,6 +501,67 @@ export class CredentialsActivationAuthority<Retirement = RetirementPlan> {
441
501
  this.#durable = true;
442
502
  }
443
503
 
504
+ /** One phase asked of the cloud. An ask that could not be made or was not
505
+ * answered says nothing about the switch: transient. */
506
+ async #ask(phase: CredentialsActivationPhase): Promise<ActivationAnswer> {
507
+ try {
508
+ return await this.#deps.ask(phase);
509
+ } catch (error) {
510
+ throw new TransientActivationError(message(error));
511
+ }
512
+ }
513
+
514
+ /** A refusal or blocker: logged, and what `status()` says unless it is
515
+ * transient and a substantive one is already said (see the header). */
516
+ #block(by: CredentialsSwitchStuck["by"], why: unknown, line: string): void {
517
+ this.#note(line);
518
+ const transient = isTransient(why);
519
+ if (transient && this.#blocker !== null && this.#blocker.transient !== true) return;
520
+ const redact = this.#deps.redact ?? ((text: string) => text);
521
+ const detail = bounded(redact(oneLine(typeof why === "string" ? why : message(why))), MAX_CREDENTIALS_SWITCH_DETAIL_CHARS);
522
+ const next: CredentialsSwitchStuck = { by, detail, since: (this.#deps.now ?? Date.now)() };
523
+ const structured = why as { blockers?: unknown; unprovenProjects?: unknown } | null;
524
+ const blockers = stringList(structured?.blockers);
525
+ if (blockers.length > 0) {
526
+ next.blockers = blockers
527
+ .slice(0, MAX_CREDENTIALS_SWITCH_BLOCKERS)
528
+ .map((blocker) => bounded(redact(oneLine(blocker)), MAX_CREDENTIALS_SWITCH_BLOCKER_CHARS));
529
+ }
530
+ const projects = stringList(structured?.unprovenProjects);
531
+ if (projects.length > 0) {
532
+ next.composeProjects = { names: projects.slice(0, MAX_CREDENTIALS_SWITCH_PROJECTS), total: projects.length };
533
+ }
534
+ if (transient) next.transient = true;
535
+ const previous = this.#blocker;
536
+ const allProjects = projects.join(" ");
537
+ // The same blocker again keeps when it was first seen. "The same" counts
538
+ // every project, not only the ones the status has room for.
539
+ if (previous !== null && sameBlocker(previous, next) && allProjects === this.#blockerProjects) return;
540
+ this.#blocker = next;
541
+ this.#blockerProjects = allProjects;
542
+ // The status names at most MAX_CREDENTIALS_SWITCH_PROJECTS, and the
543
+ // line above is cut short: the log names every one, once per blocker,
544
+ // for the operator Connections sends here for the rest.
545
+ if (projects.length > 0) {
546
+ this.#deps.log(
547
+ `[credentials] Compose projects nothing proves Uai's (${projects.length}), each to remove ` +
548
+ `(docker compose -p <name> down) or rename: ${allProjects}`,
549
+ );
550
+ }
551
+ }
552
+
553
+ /** `onStatusChange` when `status()` says something new. */
554
+ #publish(): void {
555
+ const now = JSON.stringify(this.status());
556
+ if (now === this.#published) return;
557
+ this.#published = now;
558
+ try {
559
+ this.#deps.onStatusChange?.();
560
+ } catch (error) {
561
+ this.#note(`advertising the switch's status failed: ${message(error)}`);
562
+ }
563
+ }
564
+
444
565
  /** The same note twice in a row is logged once: a refusal is asked about
445
566
  * again every few minutes. */
446
567
  #note(line: string): void {
@@ -450,6 +571,33 @@ export class CredentialsActivationAuthority<Retirement = RetirementPlan> {
450
571
  }
451
572
  }
452
573
 
574
+ class TransientActivationError extends Error {
575
+ readonly transient = true;
576
+ }
577
+
578
+ function isTransient(why: unknown): boolean {
579
+ return why !== null && typeof why === "object" && (why as { transient?: unknown }).transient === true;
580
+ }
581
+
582
+ function sameBlocker(a: CredentialsSwitchStuck, b: CredentialsSwitchStuck): boolean {
583
+ const { since: _a, ...left } = a;
584
+ const { since: _b, ...right } = b;
585
+ return JSON.stringify(left) === JSON.stringify(right);
586
+ }
587
+
588
+ function stringList(value: unknown): string[] {
589
+ return Array.isArray(value) ? value.filter((item): item is string => typeof item === "string" && item !== "") : [];
590
+ }
591
+
592
+ function oneLine(text: string): string {
593
+ return text.replace(/\s*\n\s*(?:- )?/g, "; ").trim();
594
+ }
595
+
596
+ function bounded(text: string, max: number): string {
597
+ const nonEmpty = text === "" ? "unknown" : text;
598
+ return nonEmpty.length <= max ? nonEmpty : `${nonEmpty.slice(0, max - 1)}…`;
599
+ }
600
+
453
601
  /** The record; null when there is none. One the runner cannot read is
454
602
  * `local` with nothing owed (see the header). */
455
603
  export function readActivationRecord(path: string): ActivationRecord | null {
@@ -22,7 +22,12 @@
22
22
  * isolation), and which the operator removes or renames. A row with no
23
23
  * runtime (a task that went down keeps its row, its runtime fields
24
24
  * cleared) neither blocks nor vouches for anything. The plan records the
25
- * engine it was made against.
25
+ * engine it was made against. Its rejection is a `RetirementBlockedError`:
26
+ * each blocker apart, and every unproven project named, so the cloud can
27
+ * show the operator all of them with the command that clears each
28
+ * (ADR-154 §13 build notes, 2026-10-27). Such a project is never removed
29
+ * here: a Compose label proves neither who made a stack nor that nothing
30
+ * uses it now (another runner may share this Docker and workspace root).
26
31
  * - `retireLegacyTaskRuntimes` carries a plan out, only after the runner has
27
32
  * committed to the switch. Each task's check, Stop and removal share its
28
33
  * lifecycle slot: before its assignment closes and again before anything
@@ -117,6 +122,26 @@ export interface RetirementDeps extends RetirementPlanDeps {
117
122
  log: (line: string) => void;
118
123
  }
119
124
 
125
+ /**
126
+ * The plan's refusal: every blocker apart (`blockers`), and apart from them
127
+ * the `task-*` Compose projects nothing proves Uai's (all of them, for the
128
+ * operator to remove or rename). The message names both, as the log has.
129
+ * `transient`: nothing is wrong yet, the runner only cannot look now (its
130
+ * container runtime is still preparing); the next attempt looks again.
131
+ */
132
+ export class RetirementBlockedError extends Error {
133
+ readonly blockers: string[];
134
+ readonly unprovenProjects: string[];
135
+ readonly transient: boolean;
136
+ constructor(message: string, options: { blockers?: string[]; unprovenProjects?: string[]; transient?: boolean } = {}) {
137
+ super(message);
138
+ this.name = "RetirementBlockedError";
139
+ this.blockers = options.blockers ?? [];
140
+ this.unprovenProjects = options.unprovenProjects ?? [];
141
+ this.transient = options.transient === true;
142
+ }
143
+ }
144
+
120
145
  export interface RetirementReport {
121
146
  /** Tasks whose runtime is gone (or was already). */
122
147
  retired: string[];
@@ -136,8 +161,9 @@ export async function planLegacyTaskRuntimeRetirement(deps: RetirementPlanDeps):
136
161
  const blockers: string[] = [];
137
162
  const snapshot = deps.snapshot();
138
163
  if (snapshot.runtimeProblem !== null || snapshot.machine === null) {
139
- throw new Error(
164
+ throw new RetirementBlockedError(
140
165
  `task runtimes cannot be retired now: the container runtime is not ready (${snapshot.runtimeProblem ?? "no engine is pinned"})`,
166
+ { transient: true },
141
167
  );
142
168
  }
143
169
  if (!snapshot.fence.ok) blockers.push(snapshot.fence.detail ?? "the task rows do not match the pinned container runtime");
@@ -183,6 +209,7 @@ export async function planLegacyTaskRuntimeRetirement(deps: RetirementPlanDeps):
183
209
  }
184
210
 
185
211
  const appleOrphans: string[] = [];
212
+ const unprovenProjects: string[] = [];
186
213
  try {
187
214
  if (deps.apple) {
188
215
  const named = new Set(rows.map((task) => `task-${task.taskId}-app`));
@@ -193,18 +220,26 @@ export async function planLegacyTaskRuntimeRetirement(deps: RetirementPlanDeps):
193
220
  const named = new Set(rows.map((task) => `task-${task.taskId}`));
194
221
  for (const project of await listTaskProjects(deps.docker)) {
195
222
  if (named.has(project)) continue;
196
- blockers.push(
197
- `Compose project ${project} has containers but no task on this runner: nothing proves it Uai's, so it is not removed. ` +
198
- `If it was a task's, remove it (docker compose -p ${project} down); otherwise rename it`,
199
- );
223
+ unprovenProjects.push(project);
200
224
  }
201
225
  }
202
226
  } catch (error) {
203
227
  blockers.push(`the task runtime inventory failed: ${message(error)}`);
204
228
  }
205
229
 
206
- if (blockers.length > 0) {
207
- throw new Error(`task runtimes cannot all be retired: ${blockers.join("; ").slice(0, 1500)}`);
230
+ if (blockers.length > 0 || unprovenProjects.length > 0) {
231
+ const all = [
232
+ ...blockers,
233
+ ...unprovenProjects.map(
234
+ (project) =>
235
+ `Compose project ${project} has containers but no task on this runner: nothing proves it Uai's, so it is not removed. ` +
236
+ `If it was a task's, remove it (docker compose -p ${project} down); otherwise rename it`,
237
+ ),
238
+ ];
239
+ throw new RetirementBlockedError(`task runtimes cannot all be retired: ${all.join("; ").slice(0, 1500)}`, {
240
+ blockers,
241
+ unprovenProjects,
242
+ });
208
243
  }
209
244
  return { machine: snapshot.machine, removals, appleOrphans };
210
245
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@runuai/host",
3
- "version": "0.9.170",
3
+ "version": "0.9.171",
4
4
  "description": "Uai host \u2014 runs ephemeral AI tasks in containers on a machine you control.",
5
5
  "license": "MIT",
6
6
  "author": "Uai Tech <team@runuai.com>",
package/src/main.ts CHANGED
@@ -784,6 +784,9 @@ const credentialsActivation = new CredentialsActivationAuthority<CredentialsReti
784
784
  requestManagedHostRestart();
785
785
  },
786
786
  log: (line) => console.warn(line),
787
+ // What stops the switch reaches the cloud too, not only this log.
788
+ onStatusChange: () => sendCapabilities(),
789
+ redact: redactHomeDirectory,
787
790
  });
788
791
  setCredentialsModeSource(() => credentialsActivation.mode());
789
792
  /** ADR-154 §13: the steady/operator cadence. Active runners report their
@@ -1581,9 +1584,18 @@ function buildCapabilities(): HostCapabilities {
1581
1584
  engineAccounts: engineAccountSummaries(),
1582
1585
  engines: engineCatalogCapabilities(),
1583
1586
  githubUsers: connectedUserIds(),
1587
+ // ADR-154 §13: where the switch to cloud credentials stands, and why.
1588
+ credentialsSwitch: credentialsActivation.status(),
1584
1589
  };
1585
1590
  }
1586
1591
 
1592
+ /** A path under the home directory, as `~/…`: a status line leaves the
1593
+ * machine, and the account name in a path is not the cloud's to keep. */
1594
+ function redactHomeDirectory(text: string): string {
1595
+ const home = homedir();
1596
+ return home.length > 1 ? text.split(home).join("~") : text;
1597
+ }
1598
+
1587
1599
  // engineCliStatuses probes binaries asynchronously; capabilities build
1588
1600
  // synchronously. The snapshot starts empty (cliInstalled: false) and each
1589
1601
  // build kicks a refresh that re-advertises only when the answer changed.
package/src/protocol.ts CHANGED
@@ -4140,6 +4140,99 @@ export interface HostCapabilities {
4140
4140
  // the per-host gh-connected state shown on the host detail page. Re-advertised
4141
4141
  // whenever a token is added/removed. Optional: older hosts omit it.
4142
4142
  githubUsers?: string[];
4143
+ /** ADR-154 §13: where this runner's switch to cloud credentials stands, and
4144
+ * what stops it. Optional: hosts before 0.9.171 omit it, and the cloud
4145
+ * then says only that the runner has not switched. */
4146
+ credentialsSwitch?: CredentialsSwitchCapability;
4147
+ }
4148
+
4149
+ /**
4150
+ * ADR-154 §13: the runner's switch to cloud credentials, as its capability
4151
+ * frame reports it. `state` is the runner's credentials mode. `stuck` is the
4152
+ * latest substantive refusal or blocker while the switch has not finished:
4153
+ * `by: "cloud"` the cloud's answer, `by: "runner"` something on the machine.
4154
+ * A transient one (the container runtime still preparing after a reconnect,
4155
+ * the bridge down) never replaces a substantive one; it shows only when
4156
+ * there is nothing else to say. Bounded and secret-free: the runner's own log
4157
+ * words (task ids, Compose project names, folder paths with the home
4158
+ * directory as `~`), never a credential.
4159
+ */
4160
+ export interface CredentialsSwitchCapability {
4161
+ state: "legacy" | "pending" | "active";
4162
+ stuck?: CredentialsSwitchStuck;
4163
+ }
4164
+
4165
+ export interface CredentialsSwitchStuck {
4166
+ by: "cloud" | "runner";
4167
+ /** The refusal or blocker in one line. */
4168
+ detail: string;
4169
+ /** Each blocker on its own, when the runner found several. */
4170
+ blockers?: string[];
4171
+ /** The `task-*` Compose projects nothing proves Uai's, which the operator
4172
+ * removes (`docker compose -p <name> down`) or renames: the first
4173
+ * `MAX_CREDENTIALS_SWITCH_PROJECTS` names, and how many there are. */
4174
+ composeProjects?: { names: string[]; total: number };
4175
+ /** Nothing is wrong yet: the runner could not look (see above). */
4176
+ transient?: boolean;
4177
+ /** Epoch ms this refusal or blocker was first seen, unchanged since. */
4178
+ since: number;
4179
+ }
4180
+
4181
+ export const MAX_CREDENTIALS_SWITCH_DETAIL_CHARS = 600;
4182
+ export const MAX_CREDENTIALS_SWITCH_BLOCKERS = 20;
4183
+ export const MAX_CREDENTIALS_SWITCH_BLOCKER_CHARS = 300;
4184
+ export const MAX_CREDENTIALS_SWITCH_PROJECTS = 100;
4185
+
4186
+ /** The capability, exact and bounded; undefined when it is not one. The
4187
+ * cloud drops a malformed one alone, never the frame around it. */
4188
+ export function parseCredentialsSwitchCapability(value: unknown): CredentialsSwitchCapability | undefined {
4189
+ if (!isPlainRecord(value)) return undefined;
4190
+ const { state, stuck } = value;
4191
+ if (state !== "legacy" && state !== "pending" && state !== "active") return undefined;
4192
+ if (Object.keys(value).some((key) => key !== "state" && key !== "stuck")) return undefined;
4193
+ if (stuck === undefined) return { state };
4194
+ if (!isPlainRecord(stuck)) return undefined;
4195
+ const allowed = new Set(["by", "detail", "blockers", "composeProjects", "transient", "since"]);
4196
+ if (Object.keys(stuck).some((key) => !allowed.has(key))) return undefined;
4197
+ const boundedText = (text: unknown, max: number): text is string =>
4198
+ typeof text === "string" && text.length >= 1 && text.length <= max;
4199
+ if (stuck.by !== "cloud" && stuck.by !== "runner") return undefined;
4200
+ if (!boundedText(stuck.detail, MAX_CREDENTIALS_SWITCH_DETAIL_CHARS)) return undefined;
4201
+ if (typeof stuck.since !== "number" || !Number.isSafeInteger(stuck.since) || stuck.since < 0) return undefined;
4202
+ if (stuck.transient !== undefined && typeof stuck.transient !== "boolean") return undefined;
4203
+ const out: CredentialsSwitchStuck = { by: stuck.by, detail: stuck.detail, since: stuck.since };
4204
+ if (stuck.blockers !== undefined) {
4205
+ if (
4206
+ !Array.isArray(stuck.blockers) ||
4207
+ stuck.blockers.length > MAX_CREDENTIALS_SWITCH_BLOCKERS ||
4208
+ !stuck.blockers.every((blocker) => boundedText(blocker, MAX_CREDENTIALS_SWITCH_BLOCKER_CHARS))
4209
+ ) {
4210
+ return undefined;
4211
+ }
4212
+ out.blockers = [...stuck.blockers];
4213
+ }
4214
+ if (stuck.composeProjects !== undefined) {
4215
+ const projects = stuck.composeProjects;
4216
+ if (!isPlainRecord(projects) || Object.keys(projects).some((key) => key !== "names" && key !== "total")) return undefined;
4217
+ const { names, total } = projects;
4218
+ if (
4219
+ !Array.isArray(names) ||
4220
+ names.length > MAX_CREDENTIALS_SWITCH_PROJECTS ||
4221
+ !names.every((name) => typeof name === "string" && /^task-[a-z0-9][a-z0-9_-]{0,127}$/.test(name)) ||
4222
+ typeof total !== "number" ||
4223
+ !Number.isSafeInteger(total) ||
4224
+ total < names.length
4225
+ ) {
4226
+ return undefined;
4227
+ }
4228
+ out.composeProjects = { names: [...names] as string[], total };
4229
+ }
4230
+ if (stuck.transient === true) out.transient = true;
4231
+ return { state, stuck: out };
4232
+ }
4233
+
4234
+ function isPlainRecord(value: unknown): value is Record<string, unknown> {
4235
+ return value !== null && typeof value === "object" && !Array.isArray(value);
4143
4236
  }
4144
4237
 
4145
4238
  /** One transcript projection the cloud asks the host to append to (ADR-083). */