@huynle/hub 0.8.6 → 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 (184) hide show
  1. package/CHANGELOG.md +139 -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/bootstrap/adapters/opencode/README.md +4 -2
  8. package/packages/cli/src/bootstrap/release/harness-pins.json +22 -0
  9. package/packages/cli/src/bootstrap/release/harness-pins.pending.json +1 -20
  10. package/packages/cli/src/cli.ts +23 -10
  11. package/packages/cli/src/http.ts +1 -0
  12. package/packages/cli/src/launches.ts +5 -1
  13. package/packages/cli/src/mcp.ts +28 -8
  14. package/packages/cli/src/messaging.ts +14 -0
  15. package/packages/cli/src/team-intake-mcp.ts +26 -2
  16. package/packages/codex/package.json +1 -1
  17. package/packages/node/package.json +1 -1
  18. package/packages/preset/package.json +1 -1
  19. package/packages/protocol/package.json +1 -1
  20. package/packages/protocol/schema/app-delivery/v1.schema.json +677 -0
  21. package/packages/protocol/src/artifacts.ts +43 -0
  22. package/packages/protocol/src/credentials.ts +38 -0
  23. package/packages/protocol/src/index.ts +56 -5
  24. package/packages/protocol/src/launch.ts +7 -2
  25. package/packages/protocol/src/markdown.ts +753 -0
  26. package/packages/protocol/src/media-allowlist.ts +240 -0
  27. package/packages/protocol/src/media.ts +120 -0
  28. package/packages/protocol/src/preview-bundle.ts +221 -0
  29. package/packages/protocol/src/voice-recording.ts +8 -0
  30. package/packages/runtime-openshell/package.json +1 -1
  31. package/packages/runtime-sdk/package.json +1 -1
  32. package/packages/sdk/package.json +1 -1
  33. package/packages/sdk/src/client.ts +17 -3
  34. package/packages/sdk/src/errors.ts +1 -0
  35. package/packages/sdk/src/team-intake.ts +8 -0
  36. package/packages/sdk/src/types.ts +16 -2
  37. package/packages/spoke-sdk/package.json +1 -1
  38. package/src/central/agent/delivery.ts +35 -6
  39. package/src/central/agent/errors.ts +2 -0
  40. package/src/central/agent/events.ts +11 -2
  41. package/src/central/agent/launch-engine.ts +65 -4
  42. package/src/central/agent/launch-routes.ts +8 -0
  43. package/src/central/agent/launch-spec.ts +18 -1
  44. package/src/central/agent/launch-store.ts +2 -2
  45. package/src/central/agent/message-routes.ts +49 -11
  46. package/src/central/agent/messages.ts +17 -4
  47. package/src/central/agent/not-after.ts +28 -0
  48. package/src/central/agent/placement.ts +36 -4
  49. package/src/central/agent/routes.ts +3 -1
  50. package/src/central/agent/webhook-routes.ts +21 -9
  51. package/src/central/agent/webhooks.ts +27 -9
  52. package/src/central/artifacts/host.ts +3 -1
  53. package/src/central/artifacts/maintenance.ts +2 -1
  54. package/src/central/artifacts/preview-bundle.ts +81 -0
  55. package/src/central/artifacts/previews.ts +94 -0
  56. package/src/central/artifacts/routes.ts +12 -1
  57. package/src/central/artifacts/store.ts +198 -8
  58. package/src/central/config.ts +34 -0
  59. package/src/central/fetch-budget.ts +59 -0
  60. package/src/central/fetcher.ts +372 -0
  61. package/src/central/http.ts +277 -22
  62. package/src/central/media-remote.ts +248 -0
  63. package/src/central/media-timeline.ts +317 -0
  64. package/src/central/media-unfurl.ts +119 -0
  65. package/src/central/profiles/content.ts +3 -35
  66. package/src/central/recording/routes.ts +2 -2
  67. package/src/central/recording/schema.ts +3 -1
  68. package/src/central/registry.ts +449 -21
  69. package/src/central/spoke-ws.ts +17 -3
  70. package/src/central/store.ts +21 -0
  71. package/src/central/teams/intake-routes.ts +2 -1
  72. package/src/central/teams/intake.ts +200 -15
  73. package/src/central/unfurl-parse.ts +313 -0
  74. package/src/index.ts +68 -8
  75. package/src/speakable.ts +33 -1
  76. package/src/spoke/conversation.ts +7 -4
  77. package/src/spoke/deliver.ts +19 -0
  78. package/src/spoke/media-bytes.ts +171 -0
  79. package/src/spoke/media-policy.ts +394 -0
  80. package/src/spoke/media.ts +189 -0
  81. package/src/spoke/own-server.ts +30 -0
  82. package/src/spoke/preview-bundle.ts +538 -0
  83. package/src/spoke/spoke.ts +387 -17
  84. package/src/spoke/timeline.ts +59 -5
  85. package/src/spoke/voice-front.ts +335 -0
  86. package/web/dist/assets/24BXRY42-Zfr7jm_p.js +2 -0
  87. package/web/dist/assets/4FHPYW2L-C6Z67098.js +2 -0
  88. package/web/dist/assets/4LWW3ZNH-CUiYeygH.js +1 -0
  89. package/web/dist/assets/5THPTBWS-Ce_hS451.js +1 -0
  90. package/web/dist/assets/5XYWFMKW-CW-BCKiK.js +4 -0
  91. package/web/dist/assets/64PMEAB3-BjpCDGU9.js +8 -0
  92. package/web/dist/assets/6BEWHPQE-BpNa96Ze.js +2 -0
  93. package/web/dist/assets/6PIGVMFT-YXn9Hgnb.js +1 -0
  94. package/web/dist/assets/75JW6PN6-BFpDBuGN.js +2 -0
  95. package/web/dist/assets/7WLXJQOR-D6QVYUu9.js +1 -0
  96. package/web/dist/assets/7XWI72E6-BMuIspEy.js +2 -0
  97. package/web/dist/assets/A4UD2L5Z-BQzcD9dU.js +8 -0
  98. package/web/dist/assets/ArtifactPage-4GoBqSpU.js +1 -0
  99. package/web/dist/assets/ArtifactPane-Dx_0iWwH.js +1 -0
  100. package/web/dist/assets/ArtifactWorkbench-CuHgARMk.js +6 -0
  101. package/web/dist/assets/BGW36YJW-CJMhya8S.js +4 -0
  102. package/web/dist/assets/CGIZI67J-DYDSLME8.js +2 -0
  103. package/web/dist/assets/DKTLEL5E-DciMwFJ1.js +1 -0
  104. package/web/dist/assets/DNNCRC2E-BIeKf5t-.js +5 -0
  105. package/web/dist/assets/Diagram-CZM_R7MG.js +1 -0
  106. package/web/dist/assets/Diagram-vdpmp3xu.css +1 -0
  107. package/web/dist/assets/E67EBMDD--AucIw59.js +5 -0
  108. package/web/dist/assets/EFYWJBJK-CVywe_D5.js +1 -0
  109. package/web/dist/assets/EXE5EH6O-DR1IHCTr.js +4 -0
  110. package/web/dist/assets/FTXIJQGB-BZVuiBn1.js +1 -0
  111. package/web/dist/assets/G3MDBVQH-BZmnMkFw.js +1 -0
  112. package/web/dist/assets/G6GPDDNC-DEz8F449.js +2 -0
  113. package/web/dist/assets/G76NGGFM-CrB2V34K.js +1 -0
  114. package/web/dist/assets/GH6G3TJC-D3RTYEH4.js +2 -0
  115. package/web/dist/assets/HPNUA7RO-B19NeNOJ.js +1 -0
  116. package/web/dist/assets/HQRGC5D7-Dmpzq6Rn.js +4 -0
  117. package/web/dist/assets/IX6Z2TVR-DphRHnek.js +5 -0
  118. package/web/dist/assets/J77NQRCJ-B7AGcxvw.js +3 -0
  119. package/web/dist/assets/JAD36DTZ-B9-dbsS0.js +5 -0
  120. package/web/dist/assets/LF3NJQAX-koL91UZ9.js +2 -0
  121. package/web/dist/assets/LRZCOFJZ-Cdjasfuf.js +6 -0
  122. package/web/dist/assets/Library-TwvrwqSk.js +2 -0
  123. package/web/dist/assets/Lightbox-C8KNKwOA.css +1 -0
  124. package/web/dist/assets/Lightbox-Du56MXGX.js +1 -0
  125. package/web/dist/assets/MediaImage-Ds_JUx3a.js +4 -0
  126. package/web/dist/assets/N5NF2FGY-OuapYTfr.js +2 -0
  127. package/web/dist/assets/N7OWWQHY-BNLd-Q_U.js +2 -0
  128. package/web/dist/assets/NT5JY5WP-1lJM-aun.js +1 -0
  129. package/web/dist/assets/OT7W3RNY-jDL3drzX.js +4 -0
  130. package/web/dist/assets/P67P66DB-CE-opRb8.js +3 -0
  131. package/web/dist/assets/PFLLLM7K-DDJFgzW3.js +2 -0
  132. package/web/dist/assets/PUAMC4MJ-CtBm-WuU.js +12 -0
  133. package/web/dist/assets/PVFCHRLY-CG5JXv60.js +2 -0
  134. package/web/dist/assets/ProfilesDestination-rKNDpLjR.js +1 -0
  135. package/web/dist/assets/QQW2P742-BbcEkpa4.js +1 -0
  136. package/web/dist/assets/RVARI57O-CgA1WoRv.js +1 -0
  137. package/web/dist/assets/S63OEDPG-CKlhpvl2.js +3 -0
  138. package/web/dist/assets/SG5TVYLW-BBtiwXPL.js +2 -0
  139. package/web/dist/assets/TeamsPage-DtJylLrt.js +1 -0
  140. package/web/dist/assets/UGRSUGA2-urZ_hx83.js +5 -0
  141. package/web/dist/assets/V44WA6U7-DYfX3OlM.js +1 -0
  142. package/web/dist/assets/V67O4WWV-xYxJ00QW.js +1 -0
  143. package/web/dist/assets/VBUCM3YD-JHGjuJC_.js +1 -0
  144. package/web/dist/assets/VINGRCPA-DZuJa3SH.js +7 -0
  145. package/web/dist/assets/WL6DNKX4-BHqripdA.js +2 -0
  146. package/web/dist/assets/WQ63CJYP-D6G5frDY.js +2 -0
  147. package/web/dist/assets/Watchtower-Bmgdyfjb.js +2 -0
  148. package/web/dist/assets/XOYAOB3S-L-KeA1EF.js +13 -0
  149. package/web/dist/assets/Y5LQLG3M-dr2-erb8.js +2 -0
  150. package/web/dist/assets/YIK6JTN6--1t-Ud8k.js +2 -0
  151. package/web/dist/assets/artifacts-DU9uRgs1.js +1 -0
  152. package/web/dist/assets/index-C3ci5UtY.css +1 -0
  153. package/web/dist/assets/index-D-xG9nrc.js +36 -0
  154. package/web/dist/assets/lazy-f3CuUNJZ.js +43 -0
  155. package/web/dist/assets/live-7q72Lyj2.js +5 -0
  156. package/web/dist/assets/mermaid.worker-WDVRAh32.js +1 -0
  157. package/web/dist/assets/pane-layout-0RrLHGDr.js +1 -0
  158. package/web/dist/assets/preload-helper-BLdHbfdN.js +1 -0
  159. package/web/dist/assets/{renderer2d-CYag1CnR.js → renderer2d-hfZxM6Xl.js} +1 -1
  160. package/web/dist/assets/{renderer3d-BRB6rgDJ.js → renderer3d-Km9vipTM.js} +1 -1
  161. package/web/dist/assets/{safe-area-fOy0AOuG.js → safe-area-BAbgBiFk.js} +1 -1
  162. package/web/dist/assets/{silero-worker-DhlIHyD_.js → silero-worker-e802lB5T.js} +1 -1
  163. package/web/dist/assets/smart-turn-v3.2-cpu-WpeLRbPA.onnx +0 -0
  164. package/web/dist/assets/smart-turn-worker-DaVJPdvJ.js +11 -0
  165. package/web/dist/assets/source-BWlvaODa.js +1 -0
  166. package/web/dist/assets/toast-Cuu2K0hx.js +1 -0
  167. package/web/dist/assets/ui-CFB4wQfF.js +3 -0
  168. package/web/dist/index.html +8 -6
  169. package/web/src/voice/turn/LICENSE +28 -0
  170. package/web/dist/assets/ArtifactPage-Dq7VW-bU.js +0 -1
  171. package/web/dist/assets/ArtifactPane-DKwbMTX9.js +0 -1
  172. package/web/dist/assets/ArtifactWorkbench-vrgIfncX.js +0 -6
  173. package/web/dist/assets/Library-D6-Go6Cu.js +0 -2
  174. package/web/dist/assets/ProfilesDestination-BOML2Dgf.js +0 -1
  175. package/web/dist/assets/TeamsPage-CNBTE8gB.js +0 -1
  176. package/web/dist/assets/Watchtower-Dor1Ubjy.js +0 -2
  177. package/web/dist/assets/hub-CrbItiwP.js +0 -13
  178. package/web/dist/assets/index-82Ce0IAf.css +0 -1
  179. package/web/dist/assets/index-D9SP3_0z.js +0 -22
  180. package/web/dist/assets/live-BNJgy0vg.js +0 -5
  181. package/web/dist/assets/pane-layout-EdR1kRrG.js +0 -1
  182. package/web/dist/assets/preload-helper-DqrJTq0t.js +0 -1
  183. package/web/dist/assets/source-Wbq35qGp.js +0 -1
  184. package/web/dist/assets/ui-j5FFk90B.js +0 -3
@@ -51,7 +51,7 @@ import type { CloseNotice, PodRpc, RpcCall } from "./cluster/rpc"
51
51
  import type { Auth } from "./auth"
52
52
  import type { NodeRegistry } from "./nodes"
53
53
  import type { NotificationKind, PushExtra } from "./push"
54
- import type { Registry } from "./registry"
54
+ import type { FrontAction, Registry } from "./registry"
55
55
  import { newID, type Store } from "./store"
56
56
 
57
57
  /** Push notifications (implemented by Push in push.ts) to `ownerID`'s devices. Returns the number of devices notified. */
@@ -237,6 +237,8 @@ export class SpokeHub {
237
237
 
238
238
  constructor(private readonly options: SpokeHubOptions) {
239
239
  const staleMs = options.staleMs ?? 60_000
240
+ // (voice front P1) The registry decides about fronts; this sends it (re-link a known front, delete a stale one).
241
+ options.registry.subscribeFrontActions((action) => this.frontAction(action))
240
242
  this.started = this.startCluster()
241
243
  this.heartbeat = setInterval(() => {
242
244
  const now = Date.now()
@@ -732,10 +734,22 @@ export class SpokeHub {
732
734
  }
733
735
  }
734
736
 
737
+ /** (voice front P1) Re-links a conversation's front through another process, or deletes a stale front. */
738
+ private frontAction(action: FrontAction): void {
739
+ const { registry } = this.options
740
+ const failed = (cause: unknown) => console.error("[hub] voice front request failed:", cause instanceof Error ? cause.message : cause)
741
+ if (action.kind === "remove") return void this.request(action.connID, { op: "removeFront", sessionID: action.sessionID }).catch(failed)
742
+ this.request(action.connID, { op: "conversation", sessionID: action.mainID, on: true, front: true, frontID: action.frontID }).then((result) => registry.frontAnswer(action.mainID, action.connID, result.front?.sessionID), failed)
743
+ }
744
+
735
745
  /** Tells a (re)connected owner about sessions the phone has in conversation mode. */
736
746
  private resendConversations(connID: string): void {
737
- for (const sessionID of this.options.registry.conversationResends(connID)) {
738
- this.request(connID, { op: "conversation", sessionID, on: true }).catch((cause) => {
747
+ const { registry } = this.options
748
+ for (const sessionID of registry.conversationResends(connID)) {
749
+ // (voice front P1) A front asked for is asked for again (only from a spoke that serves it); the answer names it.
750
+ const front = registry.resendFront(sessionID, connID)
751
+ const frontID = front ? registry.resendFrontID(sessionID) : undefined
752
+ this.request(connID, { op: "conversation", sessionID, on: true, ...(front ? { front: true } : {}), ...(frontID ? { frontID } : {}) }).then((result) => registry.frontAnswer(sessionID, connID, front ? result.front?.sessionID : undefined), (cause) => {
739
753
  console.error("[hub] re-sending conversation mode failed:", cause instanceof Error ? cause.message : cause)
740
754
  })
741
755
  }
@@ -37,6 +37,8 @@ export interface UserRecord {
37
37
  speech?: StoredSpeech
38
38
  /** The phone's session view: hidden and pinned sessions and workspaces (synced across devices). */
39
39
  viewPrefs?: ViewPrefs
40
+ /** (RF2) Hosts and `*.suffix`es whose web images the console loads at once (normalised; absent = none). */
41
+ mediaAllowlist?: string[]
40
42
  /** Session ID → per-session Voice and complex-worker model overrides, oldest first. */
41
43
  modelPrefs?: Record<string, SessionModelPrefs>
42
44
  /** Session ID → when the user last read it (ms), least recently marked first. */
@@ -903,6 +905,25 @@ export class Store {
903
905
  })
904
906
  }
905
907
 
908
+ // ---------- per-user media allowlist (RF2) ----------
909
+
910
+ /** A copy of the user's media allowlist (empty when unset or unknown). */
911
+ mediaAllowlist(id: string): string[] {
912
+ const stored = this.user(id)?.mediaAllowlist
913
+ return Array.isArray(stored) ? stored.filter((entry): entry is string => typeof entry === "string") : []
914
+ }
915
+
916
+ /** Replaces the allowlist (callers validate and normalise: parseMediaAllowlist); returns the result. */
917
+ setMediaAllowlist(id: string, allowlist: readonly string[]): Promise<string[]> {
918
+ return this.mutate((tx) => {
919
+ const user = this.user(id)
920
+ if (!user) return []
921
+ user.mediaAllowlist = [...allowlist]
922
+ tx.touch("users", id)
923
+ return this.mediaAllowlist(id)
924
+ })
925
+ }
926
+
906
927
  // ---------- per-user view preferences ----------
907
928
 
908
929
  viewPrefs(id: string): ViewPrefs {
@@ -1,7 +1,8 @@
1
1
  // Team intake routes on /api/agent (HWA-36; contract: Teams `POST /teams/:id/intake`, scope teams:submit). The rest
2
2
  // of the teams surface (definitions, staffing, roster, teams:read|manage) is HWA-37's.
3
3
  //
4
- // POST /teams/:id/intake {title, brief?, priority?, labels?, due?, criteria?, ref?} Idempotency-Key
4
+ // POST /teams/:id/intake {title, brief?, priority?, labels?, due?, notAfter?, criteria?, ref?} Idempotency-Key
5
+ // (notAfter, epoch ms: 400 invalid, 422 expired when already passed and not a replay; queued past it → cancelled)
5
6
  // GET /teams/:id/intake?status&limit&cursor → {items, cursor?}
6
7
  // GET /teams/:id/intake/:intake → {intake}
7
8
  // POST /teams/:id/intake/:intake/status {status, expectedRevision, reason?} → {intake, assignment?}
@@ -43,6 +43,16 @@
43
43
  // Delivery is at-least-once: a routing claim (claimMs) keeps two drains from delivering the same item; a crash
44
44
  // after delivery and before the routed write re-delivers after the claim expires (the assignment is reused).
45
45
  // Events (`team.intake`, family manage) carry IDs, enums and revisions only: never title, brief, labels or criteria.
46
+ //
47
+ // notAfter (H4, hl3hd4iw; the generic "not after T" of ../agent/not-after): an optional deadline (epoch ms) checked
48
+ // at submit by parseNotAfter (invalid, or expired when already passed: nothing is stored; an Idempotency-Key replay
49
+ // of the original body still returns the original). An item still `queued` when it passes is `cancelled` with
50
+ // reason "not_after" (its brief dropped, a leftover assignment cancelled; `team.intake` once, reasonCode not_after),
51
+ // never delivered late. The check runs first in every drain (staffing and presence events, submit, reads) and in
52
+ // sweep(): the team-intake leader runs it at start (so a restart re-arms from stored items), at the earliest stored
53
+ // deadline it knows of and at least every sweepMs (deadlines submitted on another pod). An item claimed by a route in
54
+ // flight is left to that route: Delivery refuses a send past notAfter (expired), which cancels the item at once.
55
+ // Once routed, the team owns it: the message inherits notAfter (Delivery ends a pending hold at the deadline).
46
56
  import { createHash, randomBytes } from "node:crypto"
47
57
  import {
48
58
  ID_PREFIXES,
@@ -66,8 +76,10 @@ import { sanitizeLabel } from "../agent/envelope"
66
76
  import { AgentError } from "../agent/errors"
67
77
  import type { AgentEvents, EventInput } from "../agent/events"
68
78
  import { canonicalBody } from "../agent/idempotency"
79
+ import { parseNotAfter } from "../agent/not-after"
69
80
  import { updateVersioned } from "../agent/versioned"
70
- import type { Clock, StateStore } from "../backend/types"
81
+ import { realTimers } from "../backend/memory-bus"
82
+ import type { Clock, StateStore, Timers } from "../backend/types"
71
83
  import { assignmentChangedEvent } from "../workspaces/events"
72
84
  import type { MembershipStore, SessionPresence, SessionPresenceLookup, StoredAssignment } from "../workspaces/members"
73
85
  import type { TeamSeatStore } from "./seats"
@@ -107,6 +119,8 @@ export interface IntakeLimits {
107
119
  readonly keysPerTeam: number
108
120
  /** How long a routing claim holds before another drain may retry the item. */
109
121
  readonly claimMs: number
122
+ /** The longest the deadline sweep waits between passes (deadlines it has not seen yet: submitted on another pod). */
123
+ readonly sweepMs: number
110
124
  }
111
125
 
112
126
  export const INTAKE_LIMITS: IntakeLimits = {
@@ -120,6 +134,7 @@ export const INTAKE_LIMITS: IntakeLimits = {
120
134
  criterionMax: 200,
121
135
  keysPerTeam: 1_000,
122
136
  claimMs: 60_000,
137
+ sweepMs: 60_000,
123
138
  }
124
139
 
125
140
  /** A TeamIntake as stored and read: the contract record plus additive fields (HWA-36). */
@@ -128,6 +143,8 @@ export interface StoredIntake extends TeamIntake {
128
143
  readonly labels?: readonly string[]
129
144
  /** Due time (epoch ms). */
130
145
  readonly due?: number
146
+ /** Not delivered after this (epoch ms): still queued then → cancelled, reason "not_after". */
147
+ readonly notAfter?: number
131
148
  readonly criteria?: readonly string[]
132
149
  readonly workspaceID?: string
133
150
  /** The entry seat's session it was delivered to. */
@@ -166,9 +183,15 @@ interface IntakeDoc {
166
183
 
167
184
  /**
168
185
  * Delivers one task text to a session (production: Delivery.send as intakeActor); `hop`: the chain it continues (a
169
- * submitting session's depth + 1; production passes it as SendOptions.chainHop).
186
+ * submitting session's depth + 1; production passes it as SendOptions.chainHop); `notAfter`: the item's deadline
187
+ * (SendOptions.notAfter: refused as expired once passed, and a pending hold ends then).
170
188
  */
171
- export type IntakeDeliver = (userID: string, sessionID: string, text: string, options?: { readonly hop?: number }) => Promise<{ readonly messageID: string }>
189
+ export type IntakeDeliver = (
190
+ userID: string,
191
+ sessionID: string,
192
+ text: string,
193
+ options?: { readonly hop?: number; readonly notAfter?: number },
194
+ ) => Promise<{ readonly messageID: string }>
172
195
 
173
196
  /** The hop a message from the session would carry now (production: Delivery.hops.next). */
174
197
  export type IntakeHopOf = (userID: string, sessionID: string) => Promise<number>
@@ -188,6 +211,9 @@ export interface TeamIntakeDeps {
188
211
  readonly now?: Clock
189
212
  readonly newID?: (kind: IdKind) => string
190
213
  readonly limits?: Partial<IntakeLimits>
214
+ /** Every user with intake (the deadline sweep looks at all of them; absent: the sweep does nothing). */
215
+ readonly users?: () => Iterable<string> | Promise<Iterable<string>>
216
+ readonly timers?: Timers
191
217
  }
192
218
 
193
219
  export interface IntakeListQuery {
@@ -211,7 +237,9 @@ export interface IntakeSummary {
211
237
  readonly oldestQueuedAt?: number
212
238
  }
213
239
 
214
- const SUBMIT_FIELDS = new Set(["title", "brief", "priority", "labels", "due", "criteria", "ref"])
240
+ const SUBMIT_FIELDS = new Set(["title", "brief", "priority", "labels", "due", "notAfter", "criteria", "ref"])
241
+ /** reasonCode of the event (and `reason` of the item) when an item's notAfter passed while it was queued. */
242
+ export const NOT_AFTER_REASON = "not_after"
215
243
  const STATUS_FIELDS = new Set(["status", "expectedRevision", "reason"])
216
244
  const CLOSED: ReadonlySet<IntakeStatus> = new Set(["done", "cancelled", "failed"])
217
245
  const OPEN_ASSIGNMENT: ReadonlySet<string> = new Set(["open", "in_progress", "blocked"])
@@ -258,7 +286,13 @@ function view(row: IntakeRow): StoredIntake {
258
286
  return rest
259
287
  }
260
288
 
261
- const intakeEvent = (row: StoredIntake): EventInput => ({
289
+ /**
290
+ * The journal (and webhook) event of an intake change. Data: enums, revision and timestamps; `reasonCode` is the last
291
+ * routing failure's error code, or "not_after" when the item was cancelled because its deadline passed (never the
292
+ * free-text reason). An app's submission names the app as `audience.by`, so that app token's webhooks get its own
293
+ * submissions' changes.
294
+ */
295
+ const intakeEvent = (row: StoredIntake, reasonCode: string | undefined = row.lastError): EventInput => ({
262
296
  type: "team.intake",
263
297
  family: "manage",
264
298
  subject: {
@@ -268,7 +302,19 @@ const intakeEvent = (row: StoredIntake): EventInput => ({
268
302
  ...(row.workspaceID !== undefined ? { workspaceID: row.workspaceID } : {}),
269
303
  ...(row.sessionID !== undefined ? { sessionID: row.sessionID } : {}),
270
304
  },
271
- data: { status: row.status, revision: row.revision, source: row.source.kind, priority: row.priority, ...(row.progress !== undefined ? { progress: row.progress } : {}) },
305
+ data: {
306
+ status: row.status,
307
+ revision: row.revision,
308
+ source: row.source.kind,
309
+ priority: row.priority,
310
+ ...(row.progress !== undefined ? { progress: row.progress } : {}),
311
+ ...(reasonCode !== undefined ? { reasonCode } : {}),
312
+ createdAt: row.createdAt,
313
+ updatedAt: row.updatedAt,
314
+ ...(row.routedAt !== undefined ? { routedAt: row.routedAt } : {}),
315
+ ...(row.closedAt !== undefined ? { closedAt: row.closedAt } : {}),
316
+ },
317
+ ...(row.source.kind === "app" ? { audience: { by: `app:${row.source.tokenID}` } } : {}),
272
318
  })
273
319
 
274
320
  interface Body {
@@ -277,6 +323,7 @@ interface Body {
277
323
  readonly priority: IntakePriority
278
324
  readonly labels?: readonly string[]
279
325
  readonly due?: number
326
+ readonly notAfter?: number
280
327
  readonly criteria?: readonly string[]
281
328
  readonly ref?: string
282
329
  }
@@ -296,6 +343,12 @@ export class TeamIntakeService {
296
343
  private readonly owner = `intake-${process.pid}-${randomBytes(4).toString("hex")}`
297
344
  /** Per user and team: the drain in progress (one at a time in this process). */
298
345
  private readonly draining = new Map<string, Promise<number>>()
346
+ private readonly timers: Timers
347
+ private sweeping: Promise<number> | undefined
348
+ private started = false
349
+ private timer: unknown
350
+ /** When the armed sweep fires (epoch ms). */
351
+ private armedAt: number | undefined
299
352
 
300
353
  constructor(private readonly deps: TeamIntakeDeps) {
301
354
  this.state = deps.state
@@ -303,6 +356,7 @@ export class TeamIntakeService {
303
356
  this.newID = deps.newID ?? ((kind) => `${ID_PREFIXES[kind]}_${randomBytes(12).toString("hex")}`)
304
357
  this.report = deps.report ?? ((cause) => console.error(`[hub] team intake: ${message(cause)}`))
305
358
  this.limits = { ...INTAKE_LIMITS, ...deps.limits }
359
+ this.timers = deps.timers ?? realTimers
306
360
  }
307
361
 
308
362
  // ---------- submit ----------
@@ -310,7 +364,7 @@ export class TeamIntakeService {
310
364
  /** Submits work to a team; routes it at once when an entry seat is staffed, else it waits queued. */
311
365
  async submit(userID: string, actor: AgentActor, teamID: string, input: unknown, options: { readonly idempotencyKey?: string } = {}): Promise<{ intake: StoredIntake; created: boolean }> {
312
366
  if (actor.kind === "app" && !actor.scopes.includes("teams:submit")) throw new AgentError("forbidden", "Submitting to a team needs scope teams:submit")
313
- const body = this.body(input)
367
+ const { body, expired } = this.body(input)
314
368
  const key = options.idempotencyKey
315
369
  if (key !== undefined && !IDEMPOTENCY_KEY.test(key)) throw new AgentError("invalid", "Idempotency-Key must be 1–200 of A-Z a-z 0-9 _ . : -")
316
370
  const team = await this.deps.teams.require(userID, teamID)
@@ -322,7 +376,7 @@ export class TeamIntakeService {
322
376
  const slot = key === undefined ? undefined : `${partyOf(actor)}|${key}`
323
377
  const id = this.newID("intake")
324
378
  const hop = actor.kind === "session" && this.deps.hopOf ? await this.deps.hopOf(userID, actor.sessionID) : undefined
325
- if (body.brief !== undefined) await this.state.put(userID, TEAM_INTAKE_BRIEFS, id, { v: 1, teamID, brief: body.brief })
379
+ if (body.brief !== undefined && !expired) await this.state.put(userID, TEAM_INTAKE_BRIEFS, id, { v: 1, teamID, brief: body.brief })
326
380
  let result: { row: IntakeRow; created: boolean } | undefined
327
381
  try {
328
382
  await this.writeDoc(userID, teamID, (doc) => {
@@ -337,6 +391,8 @@ export class TeamIntakeService {
337
391
  return undefined
338
392
  }
339
393
  }
394
+ // A deadline already passed: refused (nothing stored), unless it is a replay of the original (above).
395
+ if (expired) throw expired
340
396
  const recent = doc.items.filter((i) => at - i.createdAt < 60_000)
341
397
  if (recent.length >= this.limits.submitsPerMinute) {
342
398
  const oldest = Math.min(...recent.map((i) => i.createdAt))
@@ -355,10 +411,12 @@ export class TeamIntakeService {
355
411
  throw cause
356
412
  }
357
413
  if (!result!.created) {
358
- if (body.brief !== undefined) await this.state.delete(userID, TEAM_INTAKE_BRIEFS, id).catch(this.report)
359
- return { intake: view(await this.reconciled(userID, result!.row)), created: false }
414
+ if (body.brief !== undefined && !expired) await this.state.delete(userID, TEAM_INTAKE_BRIEFS, id).catch(this.report)
415
+ await this.serialized(userID, teamID, () => this.expireDue(userID, teamID)).catch(this.report)
416
+ return { intake: view(await this.reconciled(userID, (await this.row(userID, teamID, result!.row.id)) ?? result!.row)), created: false }
360
417
  }
361
418
  await this.emit(userID, intakeEvent(result!.row))
419
+ if (result!.row.notAfter !== undefined) this.arm(result!.row.notAfter)
362
420
  await this.drainSafely(userID, teamID)
363
421
  return { intake: view((await this.row(userID, teamID, id)) ?? result!.row), created: true }
364
422
  }
@@ -470,9 +528,14 @@ export class TeamIntakeService {
470
528
 
471
529
  /** Routes the team's queued intake, oldest first, while an entry seat is reachable. Returns how many it routed. */
472
530
  drain(userID: string, teamID: string): Promise<number> {
531
+ return this.serialized(userID, teamID, () => this.drainNow(userID, teamID))
532
+ }
533
+
534
+ /** Runs `work` after the team's drain or expiry in progress in this process (one at a time per team). */
535
+ private serialized(userID: string, teamID: string, work: () => Promise<number>): Promise<number> {
473
536
  const key = `${userID}\u0000${teamID}`
474
537
  const previous = this.draining.get(key) ?? Promise.resolve(0)
475
- const run = previous.catch(() => 0).then(() => this.drainNow(userID, teamID))
538
+ const run = previous.catch(() => 0).then(work)
476
539
  this.draining.set(key, run)
477
540
  void run.finally(() => {
478
541
  if (this.draining.get(key) === run) this.draining.delete(key)
@@ -497,11 +560,113 @@ export class TeamIntakeService {
497
560
  }
498
561
  }
499
562
 
563
+ // ---------- deadlines (notAfter) ----------
564
+
565
+ /** Runs the deadline sweep now, then at the earliest deadline it finds and at least every sweepMs, until stop(). */
566
+ start(): void {
567
+ if (this.started) return
568
+ this.started = true
569
+ void this.sweep().catch(this.report)
570
+ }
571
+
572
+ stop(): void {
573
+ this.started = false
574
+ if (this.timer !== undefined) this.timers.clear(this.timer)
575
+ this.timer = undefined
576
+ this.armedAt = undefined
577
+ }
578
+
579
+ /**
580
+ * Cancels every user's queued intake whose notAfter has passed (one pass at a time; a call during a pass joins it)
581
+ * and re-arms for the earliest deadline still ahead. Resolves with how many it cancelled.
582
+ */
583
+ sweep(): Promise<number> {
584
+ this.sweeping ??= this.sweepNow().finally(() => {
585
+ this.sweeping = undefined
586
+ })
587
+ return this.sweeping
588
+ }
589
+
590
+ private async sweepNow(): Promise<number> {
591
+ let cancelled = 0
592
+ let next = Number.POSITIVE_INFINITY
593
+ try {
594
+ for (const userID of this.deps.users ? await this.deps.users() : []) {
595
+ for (const { key: teamID, value } of await this.state.list(userID, TEAM_INTAKE)) {
596
+ try {
597
+ const at = this.now()
598
+ const deadlines = readDoc(value, teamID).items.flatMap((i) => (i.status === "queued" && i.notAfter !== undefined ? [i.notAfter] : []))
599
+ if (deadlines.some((d) => d <= at)) cancelled += await this.serialized(userID, teamID, () => this.expireDue(userID, teamID))
600
+ for (const d of deadlines) if (d > at) next = Math.min(next, d)
601
+ } catch (cause) {
602
+ this.report(cause)
603
+ }
604
+ }
605
+ }
606
+ } finally {
607
+ this.arm(next)
608
+ }
609
+ return cancelled
610
+ }
611
+
612
+ /** Arms the sweep for `at` (capped at sweepMs from now) unless it is already armed no later. */
613
+ private arm(at: number): void {
614
+ if (!this.started) return
615
+ const t = this.now()
616
+ const when = Math.min(at, t + this.limits.sweepMs)
617
+ if (this.timer !== undefined && this.armedAt !== undefined && this.armedAt <= when) return
618
+ if (this.timer !== undefined) this.timers.clear(this.timer)
619
+ this.armedAt = when
620
+ this.timer = this.timers.set(() => {
621
+ this.timer = undefined
622
+ this.armedAt = undefined
623
+ void this.sweep().catch(this.report)
624
+ }, Math.max(0, when - t))
625
+ }
626
+
627
+ /**
628
+ * Cancels the team's queued items whose notAfter has passed (reason not_after): one version-checked write, then per
629
+ * item the brief dropped, a leftover assignment (a failed route) cancelled and one `team.intake` event. Items a live
630
+ * routing claim holds are left to that route; `only` (the route itself, refused by Delivery as expired) expires that
631
+ * one item, claim and all.
632
+ */
633
+ private async expireDue(userID: string, teamID: string, only?: string): Promise<number> {
634
+ const at = this.now()
635
+ let expired: IntakeRow[] = []
636
+ await this.writeDoc(userID, teamID, (doc) => {
637
+ expired = []
638
+ const items = doc.items.map((row) => {
639
+ if (row.status !== "queued" || row.notAfter === undefined) return row
640
+ // The route refused by Delivery (its clock says the deadline passed) expires its own item, claim and all.
641
+ if (only !== undefined ? row.id !== only : row.notAfter > at || (row.claim !== undefined && at - row.claim.at < this.limits.claimMs)) return row
642
+ const { claim: _c, progress: _p, ...rest } = row
643
+ const next: IntakeRow = { ...rest, status: "cancelled", reason: NOT_AFTER_REASON, closedAt: at, revision: row.revision + 1, updatedAt: at }
644
+ expired.push(next)
645
+ return next
646
+ })
647
+ return expired.length === 0 ? undefined : { ...doc, items: this.retained(items) }
648
+ })
649
+ for (const row of expired) {
650
+ await this.state.delete(userID, TEAM_INTAKE_BRIEFS, row.id).catch(this.report)
651
+ if (row.assignmentID !== undefined && row.workspaceID !== undefined) {
652
+ const assignment = await this.deps.members.assignment(userID, row.workspaceID, row.assignmentID).catch(() => undefined)
653
+ if (assignment && OPEN_ASSIGNMENT.has(assignment.status)) {
654
+ const closed = await this.deps.members.setStatus(userID, row.workspaceID, row.assignmentID, "cancelled").catch((cause) => void this.report(cause))
655
+ if (closed) await this.emit(userID, assignmentChangedEvent(closed, "status"))
656
+ }
657
+ }
658
+ await this.emit(userID, intakeEvent(row, NOT_AFTER_REASON))
659
+ }
660
+ return expired.length
661
+ }
662
+
500
663
  private async drainSafely(userID: string, teamID: string): Promise<void> {
501
664
  await this.drain(userID, teamID).catch(this.report)
502
665
  }
503
666
 
504
667
  private async drainNow(userID: string, teamID: string): Promise<number> {
668
+ // Past their notAfter: cancelled before anything is routed (also while the team is paused or archived).
669
+ await this.expireDue(userID, teamID)
505
670
  const team = await this.deps.teams.get(userID, teamID)
506
671
  if (!team || team.state !== "active" || team.paused) return 0
507
672
  const queued = readDoc((await this.state.get(userID, TEAM_INTAKE, teamID))?.value, teamID)
@@ -543,6 +708,7 @@ export class TeamIntakeService {
543
708
  await this.writeDoc(userID, team.id, (doc) => {
544
709
  const row = doc.items.find((i) => i.id === intakeID)
545
710
  if (!row || row.status !== "queued") return undefined
711
+ if (row.notAfter !== undefined && row.notAfter <= at) return undefined
546
712
  if (row.claim && row.claim.owner !== this.owner && at - row.claim.at < this.limits.claimMs) return undefined
547
713
  claimed = { ...row, claim: { owner: this.owner, at } }
548
714
  return { ...doc, items: doc.items.map((i) => (i.id === intakeID ? claimed! : i)) }
@@ -554,7 +720,10 @@ export class TeamIntakeService {
554
720
  await this.mutate(userID, team.id, intakeID, (row) => ({ ...row, assignmentID: assignment.id, workspaceID: target.workspaceID }), false)
555
721
  const brief = (await this.state.get<{ brief?: unknown }>(userID, TEAM_INTAKE_BRIEFS, intakeID))?.value?.brief
556
722
  const text = this.text(team, claimed, assignment.id, target, typeof brief === "string" ? brief : undefined)
557
- const sent = await this.deps.deliver(userID, target.sessionID, text, claimed.hop === undefined ? {} : { hop: claimed.hop })
723
+ const sent = await this.deps.deliver(userID, target.sessionID, text, {
724
+ ...(claimed.hop === undefined ? {} : { hop: claimed.hop }),
725
+ ...(claimed.notAfter === undefined ? {} : { notAfter: claimed.notAfter }),
726
+ })
558
727
  let cancelled = false
559
728
  let routed: IntakeRow | undefined
560
729
  await this.writeDoc(userID, team.id, (doc) => {
@@ -581,6 +750,11 @@ export class TeamIntakeService {
581
750
  return "routed"
582
751
  } catch (cause) {
583
752
  const code = cause instanceof AgentError ? cause.code : "internal"
753
+ // Delivery refused it: the deadline passed while it was being routed. Cancelled now, never retried.
754
+ if (code === "expired" && claimed.notAfter !== undefined) {
755
+ await this.expireDue(userID, team.id, intakeID).catch(this.report)
756
+ return "skipped"
757
+ }
584
758
  this.report(cause)
585
759
  await this.mutate(userID, team.id, intakeID, (row) => {
586
760
  const { claim: _c, ...rest } = row
@@ -616,6 +790,7 @@ export class TeamIntakeService {
616
790
  const from = row.source.kind === "user" ? "the user" : row.source.kind === "app" ? `app ${row.source.tokenID}` : `session ${row.source.sessionID}`
617
791
  const facts = [`intake ${row.id}`, `revision ${row.revision + 1}`, `assignment ${assignmentID}`, `workspace ${target.workspaceID}`, `priority ${row.priority}`]
618
792
  if (row.due !== undefined) facts.push(`due ${new Date(row.due).toISOString()}`)
793
+ if (row.notAfter !== undefined) facts.push(`not after ${new Date(row.notAfter).toISOString()}`)
619
794
  if (row.ref !== undefined) facts.push(`ref ${sanitizeLabel(row.ref, this.limits.refMax)}`)
620
795
  const lines = [
621
796
  `[hub] team intake for #${team.name} (team ${team.id}, entry role ${team.entryRole}), submitted by ${from}`,
@@ -665,10 +840,11 @@ export class TeamIntakeService {
665
840
 
666
841
  // ---------- internals ----------
667
842
 
668
- private body(input: unknown): Body {
843
+ /** The checked submission; `expired`: its notAfter has already passed (refused unless it replays the original). */
844
+ private body(input: unknown): { body: Body; expired?: AgentError } {
669
845
  if (!isObj(input)) throw new AgentError("invalid", "body must be an object")
670
846
  unknownField(input, SUBMIT_FIELDS)
671
- const { title, brief, priority, labels, due, criteria, ref } = input
847
+ const { title, brief, priority, labels, due, notAfter, criteria, ref } = input
672
848
  const l = this.limits
673
849
  if (!isLabel(title)) throw new AgentError("invalid", "title must be 1–120 characters of text")
674
850
  if (brief !== undefined && !isText(brief, l.briefMax)) throw new AgentError("invalid", `brief must be at most ${l.briefMax} characters of text`)
@@ -679,15 +855,24 @@ export class TeamIntakeService {
679
855
  if (criteria !== undefined && (!Array.isArray(criteria) || criteria.length > l.criteriaPerIntake || !criteria.every((c) => isLabel(c, l.criterionMax))))
680
856
  throw new AgentError("invalid", `criteria must be a list of at most ${l.criteriaPerIntake} lines of 1–${l.criterionMax} characters`)
681
857
  if (ref !== undefined && !isLabel(ref, l.refMax)) throw new AgentError("invalid", `ref must be 1–${l.refMax} characters of text`)
682
- return {
858
+ let expired: AgentError | undefined
859
+ try {
860
+ parseNotAfter(notAfter, this.now())
861
+ } catch (cause) {
862
+ if (!(cause instanceof AgentError) || cause.code !== "expired") throw cause
863
+ expired = new AgentError("expired", "notAfter has already passed: nothing was submitted")
864
+ }
865
+ const body: Body = {
683
866
  title: title as string,
684
867
  ...(brief !== undefined ? { brief: brief as string } : {}),
685
868
  priority: (priority as IntakePriority | undefined) ?? "normal",
686
869
  ...(labels !== undefined ? { labels: [...(labels as string[])] } : {}),
687
870
  ...(due !== undefined ? { due: due as number } : {}),
871
+ ...(notAfter !== undefined ? { notAfter: notAfter as number } : {}),
688
872
  ...(criteria !== undefined ? { criteria: [...(criteria as string[])] } : {}),
689
873
  ...(ref !== undefined ? { ref: ref as string } : {}),
690
874
  }
875
+ return expired ? { body, expired } : { body }
691
876
  }
692
877
 
693
878
  /** Reflects an assignment changed through the workspace routes (closed, or another progress) on a routed item. */