@huynle/hub 0.8.7 → 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.
Files changed (181) hide show
  1. package/CHANGELOG.md +129 -0
  2. package/README.md +69 -12
  3. package/package.json +5 -1
  4. package/packages/claude-code/package.json +1 -1
  5. package/packages/cli/dist/hub-cli.js +278 -35
  6. package/packages/cli/package.json +1 -1
  7. package/packages/cli/src/cli.ts +23 -10
  8. package/packages/cli/src/http.ts +1 -0
  9. package/packages/cli/src/launches.ts +5 -1
  10. package/packages/cli/src/mcp.ts +28 -8
  11. package/packages/cli/src/messaging.ts +14 -0
  12. package/packages/cli/src/team-intake-mcp.ts +26 -2
  13. package/packages/codex/package.json +1 -1
  14. package/packages/node/package.json +1 -1
  15. package/packages/preset/package.json +1 -1
  16. package/packages/protocol/package.json +1 -1
  17. package/packages/protocol/schema/app-delivery/v1.schema.json +677 -0
  18. package/packages/protocol/src/artifacts.ts +43 -0
  19. package/packages/protocol/src/credentials.ts +38 -0
  20. package/packages/protocol/src/index.ts +56 -5
  21. package/packages/protocol/src/launch.ts +7 -2
  22. package/packages/protocol/src/markdown.ts +753 -0
  23. package/packages/protocol/src/media-allowlist.ts +240 -0
  24. package/packages/protocol/src/media.ts +120 -0
  25. package/packages/protocol/src/preview-bundle.ts +221 -0
  26. package/packages/protocol/src/voice-recording.ts +8 -0
  27. package/packages/runtime-openshell/package.json +1 -1
  28. package/packages/runtime-sdk/package.json +1 -1
  29. package/packages/sdk/package.json +1 -1
  30. package/packages/sdk/src/client.ts +17 -3
  31. package/packages/sdk/src/errors.ts +1 -0
  32. package/packages/sdk/src/team-intake.ts +8 -0
  33. package/packages/sdk/src/types.ts +16 -2
  34. package/packages/spoke-sdk/package.json +1 -1
  35. package/src/central/agent/delivery.ts +35 -6
  36. package/src/central/agent/errors.ts +2 -0
  37. package/src/central/agent/events.ts +11 -2
  38. package/src/central/agent/launch-engine.ts +65 -4
  39. package/src/central/agent/launch-routes.ts +8 -0
  40. package/src/central/agent/launch-spec.ts +18 -1
  41. package/src/central/agent/launch-store.ts +2 -2
  42. package/src/central/agent/message-routes.ts +49 -11
  43. package/src/central/agent/messages.ts +17 -4
  44. package/src/central/agent/not-after.ts +28 -0
  45. package/src/central/agent/placement.ts +36 -4
  46. package/src/central/agent/routes.ts +3 -1
  47. package/src/central/agent/webhook-routes.ts +21 -9
  48. package/src/central/agent/webhooks.ts +27 -9
  49. package/src/central/artifacts/host.ts +3 -1
  50. package/src/central/artifacts/maintenance.ts +2 -1
  51. package/src/central/artifacts/preview-bundle.ts +81 -0
  52. package/src/central/artifacts/previews.ts +94 -0
  53. package/src/central/artifacts/routes.ts +12 -1
  54. package/src/central/artifacts/store.ts +198 -8
  55. package/src/central/config.ts +34 -0
  56. package/src/central/fetch-budget.ts +59 -0
  57. package/src/central/fetcher.ts +372 -0
  58. package/src/central/http.ts +277 -22
  59. package/src/central/media-remote.ts +248 -0
  60. package/src/central/media-timeline.ts +317 -0
  61. package/src/central/media-unfurl.ts +119 -0
  62. package/src/central/profiles/content.ts +3 -35
  63. package/src/central/recording/routes.ts +2 -2
  64. package/src/central/recording/schema.ts +3 -1
  65. package/src/central/registry.ts +449 -21
  66. package/src/central/spoke-ws.ts +17 -3
  67. package/src/central/store.ts +21 -0
  68. package/src/central/teams/intake-routes.ts +2 -1
  69. package/src/central/teams/intake.ts +200 -15
  70. package/src/central/unfurl-parse.ts +313 -0
  71. package/src/index.ts +68 -8
  72. package/src/speakable.ts +33 -1
  73. package/src/spoke/conversation.ts +7 -4
  74. package/src/spoke/deliver.ts +19 -0
  75. package/src/spoke/media-bytes.ts +171 -0
  76. package/src/spoke/media-policy.ts +394 -0
  77. package/src/spoke/media.ts +189 -0
  78. package/src/spoke/own-server.ts +30 -0
  79. package/src/spoke/preview-bundle.ts +538 -0
  80. package/src/spoke/spoke.ts +387 -17
  81. package/src/spoke/timeline.ts +59 -5
  82. package/src/spoke/voice-front.ts +335 -0
  83. package/web/dist/assets/24BXRY42-Zfr7jm_p.js +2 -0
  84. package/web/dist/assets/4FHPYW2L-C6Z67098.js +2 -0
  85. package/web/dist/assets/4LWW3ZNH-CUiYeygH.js +1 -0
  86. package/web/dist/assets/5THPTBWS-Ce_hS451.js +1 -0
  87. package/web/dist/assets/5XYWFMKW-CW-BCKiK.js +4 -0
  88. package/web/dist/assets/64PMEAB3-BjpCDGU9.js +8 -0
  89. package/web/dist/assets/6BEWHPQE-BpNa96Ze.js +2 -0
  90. package/web/dist/assets/6PIGVMFT-YXn9Hgnb.js +1 -0
  91. package/web/dist/assets/75JW6PN6-BFpDBuGN.js +2 -0
  92. package/web/dist/assets/7WLXJQOR-D6QVYUu9.js +1 -0
  93. package/web/dist/assets/7XWI72E6-BMuIspEy.js +2 -0
  94. package/web/dist/assets/A4UD2L5Z-BQzcD9dU.js +8 -0
  95. package/web/dist/assets/ArtifactPage-4GoBqSpU.js +1 -0
  96. package/web/dist/assets/ArtifactPane-Dx_0iWwH.js +1 -0
  97. package/web/dist/assets/ArtifactWorkbench-CuHgARMk.js +6 -0
  98. package/web/dist/assets/BGW36YJW-CJMhya8S.js +4 -0
  99. package/web/dist/assets/CGIZI67J-DYDSLME8.js +2 -0
  100. package/web/dist/assets/DKTLEL5E-DciMwFJ1.js +1 -0
  101. package/web/dist/assets/DNNCRC2E-BIeKf5t-.js +5 -0
  102. package/web/dist/assets/Diagram-CZM_R7MG.js +1 -0
  103. package/web/dist/assets/Diagram-vdpmp3xu.css +1 -0
  104. package/web/dist/assets/E67EBMDD--AucIw59.js +5 -0
  105. package/web/dist/assets/EFYWJBJK-CVywe_D5.js +1 -0
  106. package/web/dist/assets/EXE5EH6O-DR1IHCTr.js +4 -0
  107. package/web/dist/assets/FTXIJQGB-BZVuiBn1.js +1 -0
  108. package/web/dist/assets/G3MDBVQH-BZmnMkFw.js +1 -0
  109. package/web/dist/assets/G6GPDDNC-DEz8F449.js +2 -0
  110. package/web/dist/assets/G76NGGFM-CrB2V34K.js +1 -0
  111. package/web/dist/assets/GH6G3TJC-D3RTYEH4.js +2 -0
  112. package/web/dist/assets/HPNUA7RO-B19NeNOJ.js +1 -0
  113. package/web/dist/assets/HQRGC5D7-Dmpzq6Rn.js +4 -0
  114. package/web/dist/assets/IX6Z2TVR-DphRHnek.js +5 -0
  115. package/web/dist/assets/J77NQRCJ-B7AGcxvw.js +3 -0
  116. package/web/dist/assets/JAD36DTZ-B9-dbsS0.js +5 -0
  117. package/web/dist/assets/LF3NJQAX-koL91UZ9.js +2 -0
  118. package/web/dist/assets/LRZCOFJZ-Cdjasfuf.js +6 -0
  119. package/web/dist/assets/Library-TwvrwqSk.js +2 -0
  120. package/web/dist/assets/Lightbox-C8KNKwOA.css +1 -0
  121. package/web/dist/assets/Lightbox-Du56MXGX.js +1 -0
  122. package/web/dist/assets/MediaImage-Ds_JUx3a.js +4 -0
  123. package/web/dist/assets/N5NF2FGY-OuapYTfr.js +2 -0
  124. package/web/dist/assets/N7OWWQHY-BNLd-Q_U.js +2 -0
  125. package/web/dist/assets/NT5JY5WP-1lJM-aun.js +1 -0
  126. package/web/dist/assets/OT7W3RNY-jDL3drzX.js +4 -0
  127. package/web/dist/assets/P67P66DB-CE-opRb8.js +3 -0
  128. package/web/dist/assets/PFLLLM7K-DDJFgzW3.js +2 -0
  129. package/web/dist/assets/PUAMC4MJ-CtBm-WuU.js +12 -0
  130. package/web/dist/assets/PVFCHRLY-CG5JXv60.js +2 -0
  131. package/web/dist/assets/ProfilesDestination-rKNDpLjR.js +1 -0
  132. package/web/dist/assets/QQW2P742-BbcEkpa4.js +1 -0
  133. package/web/dist/assets/RVARI57O-CgA1WoRv.js +1 -0
  134. package/web/dist/assets/S63OEDPG-CKlhpvl2.js +3 -0
  135. package/web/dist/assets/SG5TVYLW-BBtiwXPL.js +2 -0
  136. package/web/dist/assets/TeamsPage-DtJylLrt.js +1 -0
  137. package/web/dist/assets/UGRSUGA2-urZ_hx83.js +5 -0
  138. package/web/dist/assets/V44WA6U7-DYfX3OlM.js +1 -0
  139. package/web/dist/assets/V67O4WWV-xYxJ00QW.js +1 -0
  140. package/web/dist/assets/VBUCM3YD-JHGjuJC_.js +1 -0
  141. package/web/dist/assets/VINGRCPA-DZuJa3SH.js +7 -0
  142. package/web/dist/assets/WL6DNKX4-BHqripdA.js +2 -0
  143. package/web/dist/assets/WQ63CJYP-D6G5frDY.js +2 -0
  144. package/web/dist/assets/Watchtower-Bmgdyfjb.js +2 -0
  145. package/web/dist/assets/XOYAOB3S-L-KeA1EF.js +13 -0
  146. package/web/dist/assets/Y5LQLG3M-dr2-erb8.js +2 -0
  147. package/web/dist/assets/YIK6JTN6--1t-Ud8k.js +2 -0
  148. package/web/dist/assets/artifacts-DU9uRgs1.js +1 -0
  149. package/web/dist/assets/index-C3ci5UtY.css +1 -0
  150. package/web/dist/assets/index-D-xG9nrc.js +36 -0
  151. package/web/dist/assets/lazy-f3CuUNJZ.js +43 -0
  152. package/web/dist/assets/live-7q72Lyj2.js +5 -0
  153. package/web/dist/assets/mermaid.worker-WDVRAh32.js +1 -0
  154. package/web/dist/assets/pane-layout-0RrLHGDr.js +1 -0
  155. package/web/dist/assets/preload-helper-BLdHbfdN.js +1 -0
  156. package/web/dist/assets/{renderer2d-CYag1CnR.js → renderer2d-hfZxM6Xl.js} +1 -1
  157. package/web/dist/assets/{renderer3d-BRB6rgDJ.js → renderer3d-Km9vipTM.js} +1 -1
  158. package/web/dist/assets/{safe-area-fOy0AOuG.js → safe-area-BAbgBiFk.js} +1 -1
  159. package/web/dist/assets/{silero-worker-DhlIHyD_.js → silero-worker-e802lB5T.js} +1 -1
  160. package/web/dist/assets/smart-turn-v3.2-cpu-WpeLRbPA.onnx +0 -0
  161. package/web/dist/assets/smart-turn-worker-DaVJPdvJ.js +11 -0
  162. package/web/dist/assets/source-BWlvaODa.js +1 -0
  163. package/web/dist/assets/toast-Cuu2K0hx.js +1 -0
  164. package/web/dist/assets/ui-CFB4wQfF.js +3 -0
  165. package/web/dist/index.html +8 -6
  166. package/web/src/voice/turn/LICENSE +28 -0
  167. package/web/dist/assets/ArtifactPage-Dq7VW-bU.js +0 -1
  168. package/web/dist/assets/ArtifactPane-DKwbMTX9.js +0 -1
  169. package/web/dist/assets/ArtifactWorkbench-vrgIfncX.js +0 -6
  170. package/web/dist/assets/Library-D6-Go6Cu.js +0 -2
  171. package/web/dist/assets/ProfilesDestination-BOML2Dgf.js +0 -1
  172. package/web/dist/assets/TeamsPage-CNBTE8gB.js +0 -1
  173. package/web/dist/assets/Watchtower-Dor1Ubjy.js +0 -2
  174. package/web/dist/assets/hub-CrbItiwP.js +0 -13
  175. package/web/dist/assets/index-82Ce0IAf.css +0 -1
  176. package/web/dist/assets/index-D9SP3_0z.js +0 -22
  177. package/web/dist/assets/live-BNJgy0vg.js +0 -5
  178. package/web/dist/assets/pane-layout-EdR1kRrG.js +0 -1
  179. package/web/dist/assets/preload-helper-DqrJTq0t.js +0 -1
  180. package/web/dist/assets/source-Wbq35qGp.js +0 -1
  181. package/web/dist/assets/ui-j5FFk90B.js +0 -3
@@ -285,6 +285,16 @@ export interface MsgOptions extends CallOptions {
285
285
  readonly wake?: boolean
286
286
  /** Steer into a running turn. */
287
287
  readonly urgent?: boolean
288
+ /**
289
+ * Idempotency-Key (per sender): a retry with the same key and arguments within 24 h answers the first result again
290
+ * and sends nothing; other arguments under the key are HubError conflict.
291
+ */
292
+ readonly idempotencyKey?: string
293
+ /**
294
+ * Never deliver after this time (epoch ms; at most 30 days ahead). Already passed: the hub answers 422 expired and
295
+ * sends nothing. A message pending for an offline session expires then; an ask expires by then at the latest.
296
+ */
297
+ readonly notAfter?: number
288
298
  }
289
299
 
290
300
  export interface AskOptions extends MsgOptions {
@@ -338,7 +348,11 @@ const whyField = (options: ActOptions) => (options.why !== undefined ? { why: op
338
348
  const signalOf = (options: CallOptions) => (options.signal ? { signal: options.signal } : {})
339
349
  const idempotent = (options: IdempotentOptions) => ({ ...signalOf(options), ...(options.idempotencyKey ? { headers: { "idempotency-key": options.idempotencyKey } } : {}) })
340
350
  /** The wake and urgent fields of a send, when given. */
341
- const sendFields = (options: MsgOptions) => ({ ...(options.wake !== undefined ? { wake: options.wake } : {}), ...(options.urgent !== undefined ? { urgent: options.urgent } : {}) })
351
+ const sendFields = (options: MsgOptions) => ({
352
+ ...(options.wake !== undefined ? { wake: options.wake } : {}),
353
+ ...(options.urgent !== undefined ? { urgent: options.urgent } : {}),
354
+ ...(options.notAfter !== undefined ? { notAfter: options.notAfter } : {}),
355
+ })
342
356
  /** The session and project fields of a role request, when given. */
343
357
  const roleTarget = (options: RoleOptions) => ({ ...(options.session ? { session: options.session } : {}), ...(options.project !== undefined ? { project: options.project } : {}) })
344
358
 
@@ -607,7 +621,7 @@ export class HubClient {
607
621
  /** To every session the address names (each holder of a role). */
608
622
  msg(to: string, text: string, options: MsgOptions & { readonly all: true }): Promise<SentAll>
609
623
  msg(to: string, text: string, options: MsgOptions & { readonly all?: boolean } = {}): Promise<Sent | SentAll> {
610
- return this.call("POST", `${AGENT}/msg`, { body: { to, text, ...sendFields(options), ...(options.all ? { all: true } : {}) }, ...signalOf(options) })
624
+ return this.call("POST", `${AGENT}/msg`, { body: { to, text, ...sendFields(options), ...(options.all ? { all: true } : {}) }, ...idempotent(options) })
611
625
  }
612
626
 
613
627
  /** Asks a session; with wait, polls (≤WAIT_POLL_MAX_S each) until it is answered, declined or expired, or the wait is spent. */
@@ -628,7 +642,7 @@ export class HubClient {
628
642
  ...(options.wait !== undefined ? { wait: first } : {}),
629
643
  ...decisionFields(options),
630
644
  }
631
- const asked = await this.call<Asked | SentAll>("POST", `${AGENT}/ask`, { body, ...signalOf(options) })
645
+ const asked = await this.call<Asked | SentAll>("POST", `${AGENT}/ask`, { body, ...idempotent(options) })
632
646
  if ("sent" in asked) return asked
633
647
  let current: Asked = asked
634
648
  while (current.message.state === "open" && left > 0 && !options.signal?.aborted) {
@@ -16,6 +16,7 @@ export type ErrorCode =
16
16
  | "name_taken"
17
17
  | "conflict"
18
18
  | "gone"
19
+ | "expired"
19
20
  | "unsupported"
20
21
  | "paused"
21
22
  | "rate_limited"
@@ -30,6 +30,8 @@ export interface TeamIntake {
30
30
  readonly labels?: readonly string[]
31
31
  /** Epoch ms. */
32
32
  readonly due?: number
33
+ /** Not delivered after this (epoch ms): still queued then → cancelled with reason "not_after". */
34
+ readonly notAfter?: number
33
35
  readonly criteria?: readonly string[]
34
36
  readonly workspaceID?: string
35
37
  readonly sessionID?: string
@@ -50,6 +52,12 @@ export interface TeamIntakeSubmit {
50
52
  readonly labels?: readonly string[]
51
53
  /** Epoch ms. */
52
54
  readonly due?: number
55
+ /**
56
+ * Never deliver after this (epoch ms; at most 30 days ahead). Already passed: 422 expired, nothing submitted. Still
57
+ * queued (the entry role vacant) when it passes: cancelled, reason "not_after". Once routed, the delivered message
58
+ * carries it.
59
+ */
60
+ readonly notAfter?: number
53
61
  readonly criteria?: readonly string[]
54
62
  /** An external reference (e.g. a Brain task ID). */
55
63
  readonly ref?: string
@@ -271,9 +271,12 @@ export type MessageState = "sent" | "open" | "answered" | "declined" | "expired"
271
271
  /** How a message reached its recipient session. */
272
272
  /**
273
273
  * (CON-10) "dropped": it was pending for a session that ended for good (a retired Concierge session); never delivered.
274
- * "expired": it was pending for 24 h (its recipient stayed offline or busy); never delivered, and its sender was told.
274
+ * "expired": it was pending for 24 h or past its notAfter (its recipient stayed offline or busy); never delivered, and
275
+ * its sender was told.
275
276
  */
276
277
  export type MessageDelivery = "steer" | "turn" | "queued" | "retained" | "pending" | "dropped" | "expired"
278
+ /** Why a pending message expired undelivered (also the message.changed event's data.reason). */
279
+ export type MessageExpiredReason = "not_after" | "pending_timeout"
277
280
 
278
281
  /** A stored agent message. */
279
282
  export interface AgentMessage {
@@ -293,7 +296,11 @@ export interface AgentMessage {
293
296
  readonly at: number
294
297
  readonly hop: number
295
298
  readonly expiresAt?: number
299
+ /** Not delivered after this (epoch ms), when the sender set notAfter. */
300
+ readonly notAfter?: number
296
301
  readonly delivery?: MessageDelivery
302
+ /** delivery "expired" only: its notAfter passed (not_after), or the 24 h pending hold ran out (pending_timeout). */
303
+ readonly expiredReason?: MessageExpiredReason
297
304
  readonly deliveredAt?: number
298
305
  /** The reply or decline that closed an ask. */
299
306
  readonly answer?: { readonly id: string; readonly at: number }
@@ -503,7 +510,8 @@ export interface GrantRequested {
503
510
  // Launches (design §6.1; packages/protocol/src/launch.ts is the wire source of truth, kept in step by the tests).
504
511
 
505
512
  export type LaunchState = "queued" | "placing" | "provisioning" | "running" | "ended"
506
- export type LaunchOutcome = "idle" | "needs_user" | "failed" | "timeout" | "stopped" | "lost"
513
+ /** expired: its notAfter passed while it was still waiting for a machine (queued or placing); it never started. */
514
+ export type LaunchOutcome = "idle" | "needs_user" | "failed" | "timeout" | "stopped" | "lost" | "expired"
507
515
  export type WorkspaceMode = "worktree" | "checkout" | "clone"
508
516
  export type RuntimeKind = "host" | "openshell"
509
517
  export type PlacementMode = "require" | "prefer"
@@ -542,6 +550,12 @@ export interface LaunchSpec {
542
550
  readonly end?: { readonly when?: LaunchEndWhen; readonly timeout?: string; readonly cleanup?: CleanupPolicy }
543
551
  /** Pin and apply this profile version before the prompt (explicit version; apps need profiles:assign). */
544
552
  readonly agentProfile?: { readonly profileID: string; readonly version: number }
553
+ /**
554
+ * Epoch ms: still waiting for a machine (queued or placing) then, the launch is never started and ends with outcome
555
+ * expired. At most 30 days ahead; already passed: the hub answers 422 expired and stores nothing. Once it is
556
+ * provisioning it has no effect (end.timeout bounds running work). The same key and notAfter replays the launch.
557
+ */
558
+ readonly notAfter?: number
545
559
  }
546
560
 
547
561
  export interface LaunchStep {
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@huynle/hub-spoke-sdk",
3
- "version": "0.8.7",
3
+ "version": "0.9.0",
4
4
  "private": true,
5
5
  "description": "Harness-neutral spoke building blocks: hub connection, enrollment, tokens, idempotent deliver, prompt references, HarnessSpoke (internal; shipped inside @huynle/hub, not published on its own).",
6
6
  "license": "MIT",
@@ -16,7 +16,9 @@
16
16
  // OpenCode spoke refusing a routine wake:false msg while the recipient's turn runs, RF-05) → kept for
17
17
  // PENDING_MS in the StateStore and delivered oldest first on the user's next sessions frame (onSessions). One
18
18
  // still pending after PENDING_MS expires (the loop's tick, or a flush that finds it): delivery "expired", and its
19
- // sender is told (a session by a quiet hub notice, the user by an alert, everyone through the journal). In a
19
+ // sender is told (a session by a quiet hub notice, the user by an alert, everyone through the journal). A message
20
+ // with notAfter (./not-after, H2) is held until min(at + PENDING_MS, notAfter) instead, and its expiry records
21
+ // why (Message.expiredReason, journaled as the event's reason): not_after or pending_timeout. In a
20
22
  // cluster (deps.locks) each (user, session) flush holds a lease (deliver:<user>:<session>) and re-reads the
21
23
  // entry under it, so a spoke reconnecting to another pod meanwhile never gets a message twice. A flush stops at a
22
24
  // session's first failed delivery (its later messages wait, in order, for the next frame: one round trip per busy
@@ -54,6 +56,7 @@ import type { AgentActor } from "./actor"
54
56
  import type { DirectoryEntry, NamedListener, Resolved } from "./directory"
55
57
  import { type Party, type PartyInfo, partyFromActor, partyKey, partyLabel, renderEnvelope, renderPrimer } from "./envelope"
56
58
  import { AgentError } from "./errors"
59
+ import { type ExpiredReason, parseNotAfter } from "./not-after"
57
60
  import { HOP_CAP, HopTracker, MessageLimiter, type MessageLimits, overHopCap } from "./message-limits"
58
61
  import { parseAddress } from "./names"
59
62
  import { type Delivery as DeliveryState, type Message, Messages, newMessageID } from "./messages"
@@ -149,6 +152,11 @@ export interface SendOptions {
149
152
  readonly urgent?: boolean
150
153
  /** Asks only. */
151
154
  readonly expiresInMs?: number
155
+ /**
156
+ * Not delivered after this (epoch ms): checked at submit (parseNotAfter: invalid, or expired when already passed,
157
+ * before anything is stored), then it caps the pending hold and an ask's expiry. @me: checked at submit only.
158
+ */
159
+ readonly notAfter?: number
152
160
  /**
153
161
  * Hub-internal, never from a request body: the message continues a chain at least this deep (its hop is the
154
162
  * larger of its own and this). Team intake passes the submitting session's depth + 1.
@@ -222,7 +230,10 @@ interface Pending {
222
230
  readonly wake: boolean
223
231
  readonly urgent: boolean
224
232
  readonly at: number
233
+ /** min(at + PENDING_MS, notAfter). */
225
234
  readonly expiresAt: number
235
+ /** The message's notAfter, when it has one (absent on entries stored before H2: pending_timeout). */
236
+ readonly notAfter?: number
226
237
  }
227
238
 
228
239
  /** How a party appears in deliver meta: `@name`, `ses_…`, `@me`, `app:<tokenID>`. */
@@ -298,6 +309,7 @@ export class Delivery {
298
309
  * ambiguous, unsupported {feature: "deliver"}, rate_limited or forbidden (muted) before anything is stored.
299
310
  */
300
311
  async send(userID: string, actor: AgentActor, addr: string, kind: "msg" | "ask", text: string, options: SendOptions = {}): Promise<Sent> {
312
+ this.checkNotAfter(options.notAfter)
301
313
  if (isUserAddress(addr)) return this.sendToUser(userID, actor, addr, kind, text, options)
302
314
  const from = this.party(userID, actor)
303
315
  const resolved = await this.resolveFor(userID, actor, from, addr, kind)
@@ -306,6 +318,7 @@ export class Delivery {
306
318
 
307
319
  /** Like send, to every session the address names (each role holder gets its own message). */
308
320
  async sendAll(userID: string, actor: AgentActor, addr: string, kind: "msg" | "ask", text: string, options: SendOptions = {}): Promise<Sent[]> {
321
+ this.checkNotAfter(options.notAfter)
309
322
  if (isUserAddress(addr)) return [await this.sendToUser(userID, actor, addr, kind, text, options)]
310
323
  const from = this.party(userID, actor)
311
324
  const resolved = await this.resolveFor(userID, actor, from, addr, kind)
@@ -314,6 +327,11 @@ export class Delivery {
314
327
  return sent
315
328
  }
316
329
 
330
+ /** A notAfter validated against this engine's clock (parseNotAfter): throws invalid or expired. */
331
+ checkNotAfter(value: unknown): number | undefined {
332
+ return parseNotAfter(value, this.now())
333
+ }
334
+
317
335
  /** Replies to a message, from its recipient to its real sender (never via a role), and delivers the reply. */
318
336
  reply(userID: string, actor: AgentActor, replyTo: string, text: string, options: SendOptions = {}): Promise<Replied> {
319
337
  return this.answer(userID, actor, replyTo, options, "reply", (input) => this.messages.reply(userID, { ...input, text }))
@@ -438,6 +456,7 @@ export class Delivery {
438
456
  text,
439
457
  hop,
440
458
  ...(options.expiresInMs === undefined ? {} : { expiresInMs: options.expiresInMs }),
459
+ ...(options.notAfter === undefined ? {} : { notAfter: options.notAfter }),
441
460
  })
442
461
  this.users.add(userID)
443
462
  if (kind === "msg") {
@@ -464,6 +483,7 @@ export class Delivery {
464
483
  hop,
465
484
  ...(generation === undefined ? {} : { generation }),
466
485
  ...(options.expiresInMs === undefined ? {} : { expiresInMs: options.expiresInMs }),
486
+ ...(options.notAfter === undefined ? {} : { notAfter: options.notAfter }),
467
487
  })
468
488
  await this.hops.received(userID, recipient, hop, from)
469
489
  this.users.add(userID)
@@ -658,7 +678,9 @@ export class Delivery {
658
678
  const delivered = await this.attempt(userID, message, sessionID, wake, urgent)
659
679
  if (delivered) return { ...delivered, ...woke }
660
680
  const at = this.now()
661
- const pending: Pending = { messageID: message.id, sessionID, wake, urgent, at, expiresAt: at + PENDING_MS }
681
+ const { notAfter } = message
682
+ const expiresAt = Math.min(at + PENDING_MS, notAfter ?? Number.POSITIVE_INFINITY)
683
+ const pending: Pending = { messageID: message.id, sessionID, wake, urgent, at, expiresAt, ...(notAfter === undefined ? {} : { notAfter }) }
662
684
  await this.deps.state.put(userID, PENDING, message.id, pending)
663
685
  return { message: (await this.messages.setDelivery(userID, message.id, "pending")) ?? message, delivery: "pending", ...woke }
664
686
  }
@@ -736,7 +758,7 @@ export class Delivery {
736
758
  }
737
759
  }
738
760
 
739
- /** The loop's sweep: the user's messages pending past PENDING_MS expire (their senders are told). */
761
+ /** The loop's sweep: the user's messages pending past their expiresAt (PENDING_MS or notAfter) expire (their senders are told). */
740
762
  private async expirePending(userID: string): Promise<void> {
741
763
  const t = this.now()
742
764
  for (const { key, value, version } of await this.deps.state.list<Pending>(userID, PENDING)) {
@@ -745,7 +767,7 @@ export class Delivery {
745
767
  }
746
768
 
747
769
  /**
748
- * A pending message that waited PENDING_MS: removed (once: version-checked), marked "expired", and its sender
770
+ * A pending message that waited PENDING_MS, or past its notAfter (reason not_after): removed (once: version-checked), marked "expired", and its sender
749
771
  * told: a session by a quiet notice (wake:false, never the message text), the user by an alert; the change is
750
772
  * journaled (deps.expired) for every sender, an app included. An ask that already closed is dropped silently.
751
773
  */
@@ -753,10 +775,17 @@ export class Delivery {
753
775
  if (!(await this.deps.state.delete(userID, PENDING, key, { ifVersion: version })).ok) return
754
776
  const current = await this.messages.get(userID, value.messageID)
755
777
  if (!current || (current.kind === "ask" && current.state !== "open" && current.state !== "expired")) return
756
- const message = (await this.messages.setDelivery(userID, current.id, "expired")) ?? current
778
+ const reason: ExpiredReason = value.notAfter !== undefined && value.notAfter <= value.at + PENDING_MS ? "not_after" : "pending_timeout"
779
+ const message = (await this.messages.setDelivery(userID, current.id, "expired", reason)) ?? current
757
780
  const what = `${message.kind} ${message.id} to ${message.to}`
758
781
  // Why it waited: a staged revert holds every delivery (request()), else the session was offline or busy.
759
- const why = this.deps.registry.revertStaged?.(value.sessionID) ? "a revert stayed staged in its session for 24 h" : "its session stayed offline or busy for 24 h"
782
+ const reverted = this.deps.registry.revertStaged?.(value.sessionID) === true
783
+ const why =
784
+ reason === "not_after"
785
+ ? `its notAfter deadline passed while ${reverted ? "a revert stayed staged in its session" : "its session was offline or busy"}`
786
+ : reverted
787
+ ? "a revert stayed staged in its session for 24 h"
788
+ : "its session stayed offline or busy for 24 h"
760
789
  try {
761
790
  this.deps.expired?.(userID, message)
762
791
  } catch (cause) {
@@ -25,6 +25,8 @@ export const AGENT_ERROR_STATUS = {
25
25
  conflict: 409,
26
26
  /** Already answered, expired or ended. */
27
27
  gone: 410,
28
+ /** A message's notAfter had already passed at submit: nothing was stored, delivered or pushed. */
29
+ expired: 422,
28
30
  /** The spoke or node lacks the feature or runtime (`feature`, `harness`). */
29
31
  unsupported: 422,
30
32
  /** A kill switch is on. */
@@ -173,7 +173,7 @@ export class AgentEvents {
173
173
 
174
174
  /** A stored agent message changed (sent, delivered, answered…): never its text. */
175
175
  export function messageEvent(message: Message): EventInput {
176
- const { id, kind, state, from, recipient, replyTo, delivery } = message
176
+ const { id, kind, state, from, recipient, replyTo, delivery, expiredReason } = message
177
177
  return {
178
178
  type: "message.changed",
179
179
  family: "message",
@@ -182,7 +182,16 @@ export function messageEvent(message: Message): EventInput {
182
182
  ...(recipient.kind === "session" ? { sessionID: recipient.sessionID } : {}),
183
183
  ...(from.kind === "session" ? { senderID: from.sessionID } : {}),
184
184
  },
185
- data: { kind, state, from: addressOf(from), to: message.to, ...(replyTo ? { replyTo } : {}), ...(delivery ? { delivery } : {}) },
185
+ data: {
186
+ kind,
187
+ state,
188
+ from: addressOf(from),
189
+ to: message.to,
190
+ ...(replyTo ? { replyTo } : {}),
191
+ ...(delivery ? { delivery } : {}),
192
+ // An expired delivery: which deadline passed (an enum, never text).
193
+ ...(delivery === "expired" && expiredReason ? { reason: expiredReason } : {}),
194
+ },
186
195
  audience: { parties: [partyKey(from), partyKey(recipient)] },
187
196
  }
188
197
  }
@@ -50,6 +50,12 @@
50
50
  // lease (its provisioning runs stop at their next step and the next leader recovers them from the work records).
51
51
  // accept() works on any pod: a pod that does not supervise kicks the leader (deps.supervisor), and a stop of a
52
52
  // provisioning or running launch goes to the leader, where its pipeline or watch lives.
53
+ // - notAfter (H3): a launch still queued or placing once its spec's notAfter passes ends with outcome expired
54
+ // (expire: one version-checked step from queued/placing, so one launch change, journal event and launch.ended
55
+ // webhook). Checked on every tick (END_TICK_MS; the launches are read from the store, so it survives a restart
56
+ // and runs on the cluster's supervisor like the other tick-driven ends) and by placement, which never places such
57
+ // a launch (onExpired). From provisioning on it has no effect (end.timeout bounds running work). accept checks it
58
+ // with parseNotAfter (422 expired, nothing stored) unless the call replays a key (the store compares bodies).
53
59
  // - onLaunchChanged gets every change the engine makes (queued, provisioning, running, ended); placement's own
54
60
  // intermediate steps (placing, re-queued) are not reported.
55
61
  import type { NodeListener, NodeView } from "../nodes"
@@ -63,9 +69,10 @@ import type { Delivery } from "./delivery"
63
69
  import type { Directory } from "./directory"
64
70
  import { AgentError } from "./errors"
65
71
  import { validateLaunchSpec } from "./launch-spec"
72
+ import { parseNotAfter } from "./not-after"
66
73
  import type { CleanupPolicy, LaunchSpec } from "./launch-spec"
67
74
  import { type Launch, type LaunchFilter, type LaunchOutcome, type LaunchState, LaunchStore, LaunchTransitionError } from "./launch-store"
68
- import { Placement, PlacementLoop, type Placed, type PlacementLaunchers, type PlacementNode, type Waiting } from "./placement"
75
+ import { Placement, PlacementLoop, pastNotAfter, type Placed, type PlacementLaunchers, type PlacementNode, type Waiting } from "./placement"
69
76
 
70
77
  /** The NodeRegistry calls the engine needs. */
71
78
  export type EngineNodes = {
@@ -141,6 +148,10 @@ export interface LaunchEngineDeps {
141
148
  readonly onLaunchWaiting?: (launch: Launch) => void
142
149
  /** (FX-02) Revoked launchers: their launches are flagged (view, list, the active wait); without it none is. */
143
150
  readonly launchers?: PlacementLaunchers
151
+ /** How long an idle (or asking) session must stay unchanged before its launch ends (default END_SETTLE_MS; test fixtures). */
152
+ readonly settleMs?: number
153
+ /** How often running launches are looked at again and notAfter is swept (default END_TICK_MS; test fixtures). */
154
+ readonly tickMs?: number
144
155
  }
145
156
 
146
157
  /** Node events of every pod (cluster mode; the same as local ones on a single hub). */
@@ -332,6 +343,11 @@ export class LaunchEngine {
332
343
  /** start() ran and shutdown() did not: this engine runs placement, pipelines and end watchers. */
333
344
  private supervising = false
334
345
 
346
+ /** END_SETTLE_MS, or deps.settleMs. */
347
+ private get settleMs(): number {
348
+ return this.deps.settleMs ?? END_SETTLE_MS
349
+ }
350
+
335
351
  constructor(private readonly deps: LaunchEngineDeps) {
336
352
  this.now = deps.now ?? Date.now
337
353
  this.timers = deps.timers ?? defaultTimers
@@ -361,6 +377,9 @@ export class LaunchEngine {
361
377
  users: async () => [...(await deps.users())],
362
378
  onPlaced: (placed) => void this.provision(placed),
363
379
  onWaiting: (userID, waiting) => this.waited(userID, waiting),
380
+ onExpired: async (userID, ids) => {
381
+ for (const id of ids) await this.expire(userID, id)
382
+ },
364
383
  ...(deps.timers ? { timers: deps.timers } : {}),
365
384
  })
366
385
  }
@@ -374,6 +393,8 @@ export class LaunchEngine {
374
393
  const launches = await this.store.list(userID)
375
394
  const retry = spec.key !== undefined && launches.some((l) => l.spec.key === spec.key)
376
395
  if (!retry) {
396
+ // A key replay may come after the deadline: the store answers it (same body) or refuses it (another body).
397
+ parseNotAfter(spec.notAfter, this.now())
377
398
  if (spec.name !== undefined) this.checkName(userID, spec.name, launches)
378
399
  await this.placement.admit(userID)
379
400
  }
@@ -957,8 +978,9 @@ export class LaunchEngine {
957
978
  this.tickTimer = this.timers.set(() => {
958
979
  for (const watch of this.watches.values()) this.evaluate(watch)
959
980
  for (const node of this.deps.nodes.list()) void this.reconcile(node)
981
+ void this.expireDue()
960
982
  this.scheduleTick()
961
- }, END_TICK_MS)
983
+ }, this.deps.tickMs ?? END_TICK_MS)
962
984
  }
963
985
 
964
986
  /** Looks at the watched launch now (one evaluation at a time per launch; a call during one makes one more). */
@@ -1029,10 +1051,10 @@ export class LaunchEngine {
1029
1051
  watch.settleTimer = this.timers.set(() => {
1030
1052
  watch.settleTimer = undefined
1031
1053
  this.evaluate(watch)
1032
- }, Math.max(0, now + END_SETTLE_MS - this.now()))
1054
+ }, Math.max(0, now + this.settleMs - this.now()))
1033
1055
  return
1034
1056
  }
1035
- if (now - watch.candidate.since < END_SETTLE_MS) return
1057
+ if (now - watch.candidate.since < this.settleMs) return
1036
1058
  await this.end(userID, watch.launchID, outcome, outcome === "idle" ? "Idle: the session finished" : "Needs the user: the session asked a question")
1037
1059
  }
1038
1060
 
@@ -1147,6 +1169,45 @@ export class LaunchEngine {
1147
1169
  }
1148
1170
  }
1149
1171
 
1172
+ // ---- notAfter (H3) ---------------------------------------------------------------------------------------
1173
+
1174
+ /** Ends every user's queued or placing launch whose notAfter has passed (the tick). */
1175
+ private async expireDue(): Promise<void> {
1176
+ try {
1177
+ for (const userID of await this.deps.users()) {
1178
+ const now = this.now()
1179
+ for (const launch of await this.store.list(userID, { states: ["queued", "placing"] })) {
1180
+ if (pastNotAfter(launch, now)) await this.expire(userID, launch.id)
1181
+ }
1182
+ }
1183
+ } catch (cause) {
1184
+ console.error(`[hub] launch notAfter check failed: ${errorText(cause)}`)
1185
+ }
1186
+ }
1187
+
1188
+ /**
1189
+ * Ends a launch still queued or placing whose notAfter has passed, with outcome expired. One version-checked step,
1190
+ * so of racing callers (the tick, placement) only one ends it and reports the change; a launch that moved on
1191
+ * (provisioning: notAfter no longer applies) or ended meanwhile is left alone.
1192
+ */
1193
+ private async expire(userID: string, id: string): Promise<void> {
1194
+ const launch = await this.store.get(userID, id)
1195
+ if (!launch || (launch.state !== "queued" && launch.state !== "placing") || !pastNotAfter(launch, this.now())) return
1196
+ const deadline = new Date(launch.spec.notAfter!).toISOString()
1197
+ try {
1198
+ const ended = await this.store.transition(userID, id, "ended", {
1199
+ reason: `Expired: its notAfter (${deadline}) passed before a machine took it; it was never started`,
1200
+ outcome: "expired",
1201
+ from: ["queued", "placing"],
1202
+ })
1203
+ this.changed(ended)
1204
+ this.kick(userID)
1205
+ } catch (cause) {
1206
+ if (cause instanceof LaunchTransitionError) return
1207
+ throw cause
1208
+ }
1209
+ }
1210
+
1150
1211
  // ---- work records -------------------------------------------------------------------------------------
1151
1212
 
1152
1213
  private async work(userID: string, id: string): Promise<LaunchWork | undefined> {
@@ -14,6 +14,8 @@
14
14
  // → kill switch again → the engine. Apps and sessions see and stop only the launches they started (by); any other
15
15
  // launch, like another user's, is 404 not_found. The engine applies the per-user launch caps (rate_limited).
16
16
  // Every outcome once the body is valid is in the agent audit (verb launch, or launch.stop for DELETE).
17
+ // notAfter (H3): a new launch (not a key replay) whose notAfter has already passed is 422 expired before the acting
18
+ // check, so nothing is launched and no grant action is spent; the engine checks it again (parseNotAfter).
17
19
  import { boundedJson } from "./body"
18
20
  import type { LaunchInfo, LaunchSpec as WireSpec } from "../../protocol"
19
21
  import { Feed } from "../cluster/feed"
@@ -27,6 +29,8 @@ import { AgentError } from "./errors"
27
29
  import type { Grant, Grants } from "./grants"
28
30
  import type { LaunchEngine, LaunchView } from "./launch-engine"
29
31
  import { type LaunchSpec, validateLaunchSpec } from "./launch-spec"
32
+ import { parseNotAfter } from "./not-after"
33
+ import type { Clock } from "../backend/types"
30
34
  import { LAUNCH_STATES, type Launch, type LaunchFilter, type LaunchState, type LaunchStep } from "./launch-store"
31
35
  import type { AgentRoute } from "./routes"
32
36
 
@@ -45,6 +49,8 @@ export interface LaunchRoutesDeps {
45
49
  readonly audit: Pick<AgentAudit, "record">
46
50
  /** Names a session caller in the audit. */
47
51
  readonly directory?: Pick<Directory, "resolveOne">
52
+ /** The clock a new launch's notAfter is checked against (default Date.now). */
53
+ readonly now?: Clock
48
54
  }
49
55
 
50
56
  /** A launch change for its user (the engine's onLaunchChanged). */
@@ -215,6 +221,8 @@ export function launchRoutes(deps: LaunchRoutesDeps): AgentRoute[] {
215
221
  const by = launchedBy(actor)
216
222
  const byLabel = actor.kind === "app" ? actor.label : undefined
217
223
  const held = spec.key === undefined ? undefined : (await launches.list(user.id)).find((l) => l.spec.key === spec.key)
224
+ // A key replay may come after the deadline: the engine answers it with the existing launch.
225
+ if (held === undefined) parseNotAfter(spec.notAfter, (deps.now ?? Date.now)())
218
226
  let launchID: string | undefined
219
227
  const result = await act(user.id, actor, "launch", spec.workspace.repo, {
220
228
  target: () => (launchID !== undefined ? { launchID } : {}),
@@ -8,12 +8,16 @@
8
8
  // - labels are free-form and never interpreted by the hub, but bounded (LABELS_MAX entries, short keys and values).
9
9
  // - Defaults: harness opencode, workspace mode worktree, runtime host, placement mode require, end when idle,
10
10
  // cleanup keep, no timeout.
11
+ // - notAfter (H3): epoch ms; a launch still queued or placing then ends expired (the launch engine). Checked with the
12
+ // shared parseNotAfter (not-after.ts) when the caller gives `now` (400 invalid, 422 expired); without `now` (a key
13
+ // replay may come after the deadline) only its shape is checked. Idempotency compares it like any other field.
11
14
  // LaunchSpec is defined here until packages/protocol carries it (P4-T1); keep the two in step.
12
15
 
13
16
  import { type LaunchAgentProfile, launchAgentProfileProblem } from "../../protocol"
14
17
  import { AgentError } from "./errors"
15
18
  import { intentText } from "./intents"
16
19
  import { isReservedName, parseAddress, validName } from "./names"
20
+ import { parseNotAfter } from "./not-after"
17
21
  import { canonicalProject, parseDuration } from "./policy"
18
22
 
19
23
  export type WorkspaceMode = "worktree" | "checkout" | "clone"
@@ -75,6 +79,8 @@ export interface LaunchSpec {
75
79
  readonly end: LaunchEnd
76
80
  /** (HWA-15) The profile version pinned and applied before the prompt (explicit version; never latest). */
77
81
  readonly agentProfile?: LaunchAgentProfile
82
+ /** (H3) Epoch ms: still queued or placing then, the launch ends expired instead of starting. */
83
+ readonly notAfter?: number
78
84
  }
79
85
 
80
86
  export const KEY_MAX = 200
@@ -89,7 +95,7 @@ export const MODEL_MAX = 200
89
95
  export const LAUNCH_TIMEOUT_MIN_MS = 60_000
90
96
  export const LAUNCH_TIMEOUT_MAX_MS = 30 * 86_400_000
91
97
 
92
- const FIELDS = ["key", "name", "roles", "harness", "agent", "model", "workspace", "runtime", "placement", "prompt", "intent", "labels", "end", "agentProfile"]
98
+ const FIELDS = ["key", "name", "roles", "harness", "agent", "model", "workspace", "runtime", "placement", "prompt", "intent", "labels", "end", "agentProfile", "notAfter"]
93
99
  const WORKSPACE_FIELDS = ["repo", "mode", "branch", "base", "remote"]
94
100
  const RUNTIME_FIELDS = ["kind", "profile"]
95
101
  const PLACEMENT_FIELDS = ["machine", "labels", "mode"]
@@ -112,6 +118,8 @@ export interface LaunchSpecOptions {
112
118
  * points at idempotencyKey (the Idempotency-Key header), not "key (top level)".
113
119
  */
114
120
  readonly staffing?: boolean
121
+ /** The submit time: notAfter is checked against it (expired when passed; invalid when too far). Without it: shape only. */
122
+ readonly now?: number
115
123
  }
116
124
 
117
125
  /** Where a misplaced `key` belongs and the shape hint, for one caller. */
@@ -310,6 +318,7 @@ export function validateLaunchSpec(input: unknown, options: LaunchSpecOptions =
310
318
  const profileProblem = input.agentProfile === undefined ? undefined : launchAgentProfileProblem(input.agentProfile)
311
319
  if (profileProblem) throw invalid(profileProblem)
312
320
  const agentProfile = input.agentProfile === undefined ? undefined : { profileID: (input.agentProfile as LaunchAgentProfile).profileID, version: (input.agentProfile as LaunchAgentProfile).version }
321
+ const notAfter = validNotAfter(input.notAfter, options.now)
313
322
  return {
314
323
  ...(key !== undefined ? { key } : {}),
315
324
  ...(name !== undefined ? { name } : {}),
@@ -325,9 +334,17 @@ export function validateLaunchSpec(input: unknown, options: LaunchSpecOptions =
325
334
  labels,
326
335
  end,
327
336
  ...(agentProfile ? { agentProfile } : {}),
337
+ ...(notAfter !== undefined ? { notAfter } : {}),
328
338
  }
329
339
  }
330
340
 
341
+ /** notAfter against `now` (parseNotAfter: invalid or expired); without `now` only its shape (an epoch-ms integer). */
342
+ function validNotAfter(value: unknown, now: number | undefined): number | undefined {
343
+ if (now !== undefined) return parseNotAfter(value, now)
344
+ // Shape only: parseNotAfter checks the type before the time, so "just before it" accepts any epoch-ms integer.
345
+ return parseNotAfter(value, typeof value === "number" ? value - 1 : 0)
346
+ }
347
+
331
348
  /** JSON with object keys sorted (stable for equal values). */
332
349
  function stable(value: unknown): string {
333
350
  if (Array.isArray(value)) return `[${value.map(stable).join(",")}]`
@@ -6,7 +6,7 @@
6
6
  // launch already ended, else invalid) and changes nothing; writes are version-checked, so of two racing steps
7
7
  // only one applies.
8
8
  // - provisioning needs the machine, running the session; ended needs an outcome (idle, needs_user, failed,
9
- // timeout, stopped, lost), and only ended may carry one.
9
+ // timeout, stopped, lost, expired: its notAfter passed while queued or placing), and only ended may carry one.
10
10
  // - Idempotency: a spec with a key claims it in the StateStore (namespace `launch-keys:<userID>`, owner the launch
11
11
  // id) before the launch is stored. The same key with an equivalent body returns the existing launch (even an
12
12
  // ended one, until it is pruned); a different body is invalid with a hint. A claim whose launch never appeared
@@ -38,7 +38,7 @@ const CLAIMS = 3
38
38
 
39
39
  export const LAUNCH_STATES = ["queued", "placing", "provisioning", "running", "ended"] as const
40
40
  export type LaunchState = (typeof LAUNCH_STATES)[number]
41
- export const LAUNCH_OUTCOMES = ["idle", "needs_user", "failed", "timeout", "stopped", "lost"] as const
41
+ export const LAUNCH_OUTCOMES = ["idle", "needs_user", "failed", "timeout", "stopped", "lost", "expired"] as const
42
42
  export type LaunchOutcome = (typeof LAUNCH_OUTCOMES)[number]
43
43
 
44
44
  /** The states each state may move to. */