@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
@@ -1,8 +1,8 @@
1
1
  // Messaging on /api/agent (design §3, §8; decisions jswt9p22): thin routes over the delivery engine (./delivery).
2
2
  //
3
3
  // Routes (user actors always; session tokens; app tokens by scope):
4
- // POST /api/agent/msg {to, text, wake?, urgent?, all?} messages:send → 201 {message, delivery?} | {sent: [...]} (all)
5
- // POST /api/agent/ask {to, text, wake?, urgent?, all?, expiresIn?, wait?} messages:send → 201 {message, delivery?, answer?}
4
+ // POST /api/agent/msg {to, text, wake?, urgent?, all?, notAfter?} messages:send → 201 {message, delivery?} | {sent: [...]} (all)
5
+ // POST /api/agent/ask {to, text, wake?, urgent?, all?, expiresIn?, notAfter?, wait?} messages:send → 201 {message, delivery?, answer?}
6
6
  // POST /api/agent/reply {replyTo, text, wake?} messages:send → 201 {message, delivery?, original}
7
7
  // POST /api/agent/decline {replyTo, why} messages:send → 201 {message, delivery?, original}
8
8
  // GET /api/agent/inbox messages:read → {asks, waiting, messages, muted}
@@ -23,9 +23,19 @@
23
23
  // - expiresIn and wait are seconds. wait (0–WAIT_MAX_S) long-polls an ask until it is answered, declined or
24
24
  // expired; longer waits loop on GET /messages/:id?wait=. A message reads for its sender and recipient (and the
25
25
  // user, who owns every party); others get not_found.
26
+ // - notAfter (H2, ./not-after): don't deliver after this time, in epoch milliseconds (an integer, at most 30 days
27
+ // ahead, else invalid). Already passed at submit → 422 expired, and nothing is stored, delivered or pushed. A
28
+ // message pending for an offline session expires at min(24 h, notAfter) (message.changed carries reason
29
+ // not_after | pending_timeout); an ask expires at min(expiresIn, notAfter); @me is checked at submit only.
26
30
  // - Messaging off (UserRecord.agent.messagingPaused): msg, ask, reply and decline answer paused; reading works.
27
31
  // - Every stored message is published on the MessageFeed (GET /api/agent/watch 'message' events, waiters).
32
+ // - Idempotency-Key (H1, pd2e6df8) on msg and ask, per sender (the user, an app token, a session): the same key
33
+ // and body again within IDEMPOTENCY_TTL_MS answer 200 with the original body ({message, delivery?}, {sent} with
34
+ // all, {message, decision, …} for ask @me) and `idempotent-replayed: true`, sending, pushing and opening
35
+ // nothing; another body is 409 conflict, as is a repeat while the first still runs; a malformed key 400 invalid.
36
+ // Records live in the StateStore (MESSAGE_IDEMPOTENCY; ./idempotency), so they survive a restart.
28
37
  import { boundedJson } from "./body"
38
+ import type { Clock, StateStore } from "../backend/types"
29
39
  import { Feed } from "../cluster/feed"
30
40
  import type { Store } from "../store"
31
41
  import type { AgentActor } from "./actor"
@@ -34,14 +44,18 @@ import type { Delivery, SendOptions, Sent } from "./delivery"
34
44
  import type { Directory } from "./directory"
35
45
  import { partyFromActor, partyKey } from "./envelope"
36
46
  import { AgentError } from "./errors"
47
+ import { idempotencyKeyOf, REPLAYED_HEADER, RouteIdempotency } from "./idempotency"
37
48
  import { TEXT_MAX, WHY_MAX, type Message } from "./messages"
38
- import { AGENT_PREFIX, type AgentRoute } from "./routes"
49
+ import { AGENT_PREFIX, type AgentContext, type AgentRoute } from "./routes"
39
50
 
40
51
  /** The longest wait of one request (seconds); clients loop for longer ones. */
41
52
  export const WAIT_MAX_S = 60
42
53
  /** How often a waiting request looks at the message besides the feed (expiry, other hub processes). */
43
54
  export const WAIT_POLL_MS = 1_000
44
55
 
56
+ /** The StateStore collection of msg/ask Idempotency-Key records (./idempotency). */
57
+ export const MESSAGE_IDEMPOTENCY = "message-idempotency"
58
+
45
59
  /** Message events' Bus topic (cluster mode). */
46
60
  export const MESSAGES_TOPIC = "feed.messages"
47
61
 
@@ -61,6 +75,9 @@ export interface MessageDeps {
61
75
  readonly decisions?: Decisions
62
76
  /** Default WAIT_POLL_MS. */
63
77
  readonly pollMs?: number
78
+ /** Where msg/ask keep their Idempotency-Key records; without it a key is checked for shape but not remembered. */
79
+ readonly state?: StateStore
80
+ readonly now?: Clock
64
81
  }
65
82
 
66
83
  const NO_STORE = { "cache-control": "no-store" }
@@ -105,10 +122,12 @@ function waitQuery(raw: string | null): number | undefined {
105
122
  return seconds(/^\d+(\.\d+)?$/.test(raw) ? Number(raw) : Number.NaN, "wait", WAIT_MAX_S)
106
123
  }
107
124
 
108
- const sendOptions = (body: Body): SendOptions => {
125
+ const sendOptions = (body: Body, delivery: Pick<Delivery, "checkNotAfter">): SendOptions => {
109
126
  const wake = flag(body, "wake")
110
127
  const urgent = flag(body, "urgent")
111
- return { ...(wake === undefined ? {} : { wake }), ...(urgent === undefined ? {} : { urgent }) }
128
+ // Checked against the engine's clock here too, so an ask to @me (a Decision, not sent through send()) is refused.
129
+ const notAfter = body.notAfter === undefined ? undefined : delivery.checkNotAfter(body.notAfter)
130
+ return { ...(wake === undefined ? {} : { wake }), ...(urgent === undefined ? {} : { urgent }), ...(notAfter === undefined ? {} : { notAfter }) }
112
131
  }
113
132
 
114
133
  /** The user's address (also the malformed `@me.project`, which the delivery engine refuses). */
@@ -165,6 +184,25 @@ const sentView = ({ message, delivery, woke, wakeSkipped }: Sent) => ({
165
184
  export function messageRoutes(deps: MessageDeps): AgentRoute[] {
166
185
  const { delivery, feed } = deps
167
186
  const pollMs = deps.pollMs ?? WAIT_POLL_MS
187
+ const idempotency = deps.state ? new RouteIdempotency({ state: deps.state, collection: MESSAGE_IDEMPOTENCY, ...(deps.now ? { now: deps.now } : {}) }) : undefined
188
+
189
+ /**
190
+ * A send handler run once per (sender, route, Idempotency-Key, body): a repeat answers 200 with the original body
191
+ * and `idempotent-replayed: true`, and sends nothing. Without the header the handler simply runs.
192
+ */
193
+ const once =
194
+ (handle: (context: AgentContext) => Promise<Response>) =>
195
+ async (context: AgentContext): Promise<Response> => {
196
+ const key = idempotencyKeyOf(context.request)
197
+ if (key === undefined || !idempotency) return handle(context)
198
+ const body = await bodyOf(context.request.clone())
199
+ const route = `POST ${context.url.pathname} ${partyKey(partyFromActor(context.actor))}`
200
+ const response = await idempotency.run(context.user.id, route, key, body, async () => {
201
+ const answer = await handle(context)
202
+ return { status: answer.status, body: await answer.json() }
203
+ })
204
+ return response.headers.get(REPLAYED_HEADER) ? new Response(response.body, { status: 200, headers: response.headers }) : response
205
+ }
168
206
 
169
207
  const paused = (userID: string) => deps.store.agentData(userID).messagingPaused === true
170
208
  const ensureOn = (userID: string) => {
@@ -235,20 +273,20 @@ export function messageRoutes(deps: MessageDeps): AgentRoute[] {
235
273
  method: "POST",
236
274
  path: `${AGENT_PREFIX}/msg`,
237
275
  allow: SEND,
238
- async handle({ request, user, actor }) {
276
+ handle: once(async ({ request, user, actor }) => {
239
277
  const body = await bodyOf(request)
240
278
  const to = string(body, "to")
241
279
  const text = string(body, "text", TEXT_MAX)
242
280
  const all = flag(body, "all")
243
- const sent = await send(user.id, actor, to, "msg", text, sendOptions(body), all)
281
+ const sent = await send(user.id, actor, to, "msg", text, sendOptions(body, delivery), all)
244
282
  return ok(all ? { sent: sent.map(sentView) } : sentView(sent[0]!), 201)
245
- },
283
+ }),
246
284
  },
247
285
  {
248
286
  method: "POST",
249
287
  path: `${AGENT_PREFIX}/ask`,
250
288
  allow: SEND,
251
- async handle({ request, user, actor }) {
289
+ handle: once(async ({ request, user, actor }) => {
252
290
  const body = await bodyOf(request)
253
291
  const to = string(body, "to")
254
292
  const text = string(body, "text", TEXT_MAX)
@@ -256,7 +294,7 @@ export function messageRoutes(deps: MessageDeps): AgentRoute[] {
256
294
  const expiresIn = seconds(body.expiresIn, "expiresIn")
257
295
  const wait = seconds(body.wait, "wait", WAIT_MAX_S)
258
296
  if (all && wait !== undefined) throw new AgentError("invalid", "wait cannot be combined with all")
259
- const options: SendOptions = { ...sendOptions(body), ...(expiresIn === undefined ? {} : { expiresInMs: Math.round(expiresIn * 1000) }) }
297
+ const options: SendOptions = { ...sendOptions(body, delivery), ...(expiresIn === undefined ? {} : { expiresInMs: Math.round(expiresIn * 1000) }) }
260
298
  const decisionAsked = DECISION_FIELDS.some((key) => body[key] !== undefined)
261
299
  if (isMe(to) && deps.decisions) {
262
300
  if (all) throw new AgentError("invalid", "all cannot be combined with @me")
@@ -277,7 +315,7 @@ export function messageRoutes(deps: MessageDeps): AgentRoute[] {
277
315
  const sent = await send(user.id, actor, to, "ask", text, options, all)
278
316
  if (all) return ok({ sent: sent.map(sentView) }, 201)
279
317
  return ok({ ...sentView(sent[0]!), ...(await settled(user.id, sent[0]!.message, wait, request.signal)) }, 201)
280
- },
318
+ }),
281
319
  },
282
320
  {
283
321
  method: "POST",
@@ -2,7 +2,7 @@
2
2
  // the message routes (P2-T6) and the delivery engine (P2-T5) call it.
3
3
  //
4
4
  // - Stored form: {id, kind, from, to, recipient, generation?, text, replyTo?, state, at, hop, expiresAt?,
5
- // delivery?, deliveredAt?, answer?}. `from` is the authenticated sender (partyFromActor); `to` keeps the
5
+ // notAfter?, delivery?, expiredReason?, deliveredAt?, answer?}. `from` is the authenticated sender (partyFromActor); `to` keeps the
6
6
  // address as written (`@@reviewer`), `recipient` the party it reached; text and `to` are sanitised.
7
7
  // - Caps: each party (session, the user, an app) keeps its latest SESSION_MESSAGES; a message no party keeps
8
8
  // is deleted. Per user, at most USER_MESSAGES messages / USER_BYTES; over either, the oldest go (down to
@@ -17,6 +17,7 @@ import { randomBytes } from "node:crypto"
17
17
  import type { Clock, StateStore } from "../backend/types"
18
18
  import { type Party, partyKey, sanitizeLabel, sanitizeText } from "./envelope"
19
19
  import { AgentError } from "./errors"
20
+ import type { ExpiredReason } from "./not-after"
20
21
  import { updateVersioned } from "./versioned"
21
22
 
22
23
  /** Messages each party keeps. */
@@ -66,7 +67,11 @@ export interface Message {
66
67
  readonly hop: number
67
68
  /** Asks only. */
68
69
  readonly expiresAt?: number
70
+ /** Not delivered after this (epoch ms; ./not-after): a pending hold ends then, and an ask expires by then. */
71
+ readonly notAfter?: number
69
72
  readonly delivery?: Delivery
73
+ /** delivery "expired" only: its notAfter passed (not_after), or the 24 h pending hold ran out (pending_timeout). */
74
+ readonly expiredReason?: ExpiredReason
70
75
  /** When it was first delivered (steer, turn or queued). */
71
76
  readonly deliveredAt?: number
72
77
  /** The reply or decline that closed an ask. */
@@ -90,6 +95,8 @@ export interface SendInput {
90
95
  readonly hop?: number
91
96
  /** Asks only; default ASK_EXPIRES_MS. */
92
97
  readonly expiresInMs?: number
98
+ /** Validated by the caller (parseNotAfter): stored; an ask expires by then at the latest. */
99
+ readonly notAfter?: number
93
100
  }
94
101
 
95
102
  export interface AnswerInput {
@@ -224,7 +231,8 @@ export class Messages {
224
231
  state: input.kind === "ask" ? "open" : "sent",
225
232
  at,
226
233
  hop: input.hop ?? 0,
227
- ...(input.kind === "ask" ? { expiresAt: at + expiresIn } : {}),
234
+ ...(input.kind === "ask" ? { expiresAt: Math.min(at + expiresIn, input.notAfter ?? Number.POSITIVE_INFINITY) } : {}),
235
+ ...(input.notAfter === undefined ? {} : { notAfter: input.notAfter }),
228
236
  }
229
237
  await this.store(userID, message)
230
238
  return message
@@ -286,13 +294,18 @@ export class Messages {
286
294
  }
287
295
 
288
296
  /** Records how the message was delivered; undefined when there is no such message. */
289
- async setDelivery(userID: string, id: string, delivery: Delivery): Promise<Message | undefined> {
297
+ async setDelivery(userID: string, id: string, delivery: Delivery, expiredReason?: ExpiredReason): Promise<Message | undefined> {
290
298
  const at = this.now()
291
299
  const delivered = delivery === "steer" || delivery === "turn" || delivery === "queued"
292
300
  const stored = await updateVersioned<StoredMessage>(this.state, userID, MESSAGES, id, (current) =>
293
301
  current && {
294
302
  ...current,
295
- message: { ...current.message, delivery, ...(current.message.deliveredAt === undefined && delivered ? { deliveredAt: at } : {}) },
303
+ message: {
304
+ ...current.message,
305
+ delivery,
306
+ ...(current.message.deliveredAt === undefined && delivered ? { deliveredAt: at } : {}),
307
+ ...(delivery === "expired" && expiredReason ? { expiredReason } : {}),
308
+ },
296
309
  },
297
310
  )
298
311
  return stored && this.view(stored.message)
@@ -0,0 +1,28 @@
1
+ // notAfter (H2, gcy04k15): "don't deliver this message after T", a generic app feature. One validator for every
2
+ // surface that takes it (REST msg/ask through the delivery engine; reusable by other senders).
3
+ //
4
+ // - Shape: epoch milliseconds, a finite integer. REST and the SDK take only that; the CLI (--not-after) and MCP
5
+ // (notAfter) also accept an ISO 8601 date-time and convert it before sending.
6
+ // - At submit: at most NOT_AFTER_MAX_MS ahead (else invalid, 400); already passed (≤ now) → expired (422), and
7
+ // nothing is stored, delivered or pushed.
8
+ // - Afterwards: a pending hold ends at min(at + PENDING_MS, notAfter); an ask expires at min(expiresIn, notAfter).
9
+ // An expired delivery records why (ExpiredReason): not_after (the deadline passed) or pending_timeout (24 h hold).
10
+ import { AgentError } from "./errors"
11
+
12
+ /** The furthest a notAfter may lie ahead of submit time. */
13
+ export const NOT_AFTER_MAX_MS = 30 * 24 * 60 * 60_000
14
+
15
+ /** Why a pending delivery expired: its notAfter passed, or the 24 h pending hold ran out. */
16
+ export type ExpiredReason = "not_after" | "pending_timeout"
17
+
18
+ /**
19
+ * Validates a notAfter at submit time `now` and returns it (undefined when absent). Throws AgentError invalid
20
+ * (not a finite integer, or more than NOT_AFTER_MAX_MS ahead) or expired (already passed: ≤ now).
21
+ */
22
+ export function parseNotAfter(value: unknown, now: number): number | undefined {
23
+ if (value === undefined) return undefined
24
+ if (typeof value !== "number" || !Number.isSafeInteger(value)) throw new AgentError("invalid", "notAfter must be a time in epoch milliseconds (an integer)")
25
+ if (value <= now) throw new AgentError("expired", "notAfter has already passed: nothing was sent")
26
+ if (value > now + NOT_AFTER_MAX_MS) throw new AgentError("invalid", `notAfter must be at most ${NOT_AFTER_MAX_MS / 86_400_000} days from now`)
27
+ return value
28
+ }
@@ -23,6 +23,9 @@
23
23
  // still takes a node slot. Reservations are
24
24
  // launches provisioning or running on a machine that its node does not report (yet); slots taken in the pass
25
25
  // count at once. Reasons go on the launch only when it moves; waiting reasons are returned for the engine.
26
+ // - notAfter (H3): a queued launch whose notAfter has passed is never placed (checked as the pass reaches it and again
27
+ // before each placement step, so a node freeing up in the same pass does not take it); it takes no maxActive room
28
+ // and is returned as expired (left queued or placing) for the engine to end with outcome expired.
26
29
  // - PlacementLoop runs passes: kick(user) when something changed (a launch queued or ended, a node connected or
27
30
  // reported capacity, a service spoke came), coalescing kicks during a pass into one more, plus a sweep over
28
31
  // users() every intervalMs for whatever a kick missed (e.g. another process held the lease). Each placed launch
@@ -231,8 +234,13 @@ export interface PassResult {
231
234
  readonly leased: boolean
232
235
  readonly placed: Placed[]
233
236
  readonly waiting: Waiting[]
237
+ /** (H3) Launches the pass did not place because their notAfter passed (still queued or placing): the engine ends them. */
238
+ readonly expired?: string[]
234
239
  }
235
240
 
241
+ /** (H3) The launch's notAfter has passed at `now`: it must not be placed. */
242
+ export const pastNotAfter = (launch: Pick<Launch, "spec">, now: number): boolean => launch.spec.notAfter !== undefined && launch.spec.notAfter <= now
243
+
236
244
  export const placementLeaseKey = (userID: string) => `placement:${userID}`
237
245
 
238
246
  export class Placement {
@@ -277,9 +285,12 @@ export class Placement {
277
285
  const lease = await locks.acquire(key, owner, this.leaseMs)
278
286
  const placed: Placed[] = []
279
287
  const waiting: Waiting[] = []
280
- if (!lease) return { leased: false, placed, waiting }
288
+ const expired: string[] = []
289
+ /** The pass's result; expired only when the pass found any. */
290
+ const result = (leased: boolean, waits: Waiting[]): PassResult => ({ leased, placed, waiting: waits, ...(expired.length > 0 ? { expired } : {}) })
291
+ if (!lease) return result(false, waiting)
281
292
  const held = async () => (await locks.renew(key, owner, this.leaseMs))?.fence === lease.fence
282
- const lost = (): PassResult => ({ leased: false, placed, waiting: [] })
293
+ const lost = (): PassResult => result(false, [])
283
294
  try {
284
295
  let launches = await store.list(userID)
285
296
  // Under the lease, a launch still placing was left by a holder that died mid-pass.
@@ -289,11 +300,15 @@ export class Placement {
289
300
  }
290
301
  launches = await store.list(userID)
291
302
  const queued = launches.filter((l) => l.state === "queued")
292
- if (queued.length === 0) return { leased: true, placed, waiting }
303
+ if (queued.length === 0) return result(true, waiting)
293
304
  const counted = launches.filter((l) => ACTIVE.includes(l.state) && !isSystemLaunch(l))
294
305
  let active = counted.length
295
306
  const candidates = await this.candidates(userID, launches)
296
307
  for (const launch of queued) {
308
+ if (pastNotAfter(launch, this.now())) {
309
+ expired.push(launch.id)
310
+ continue
311
+ }
297
312
  const system = isSystemLaunch(launch)
298
313
  if (!system && active >= this.caps.maxActive) {
299
314
  waiting.push({ launchID: launch.id, wait: "active", ...this.activeReason(userID, counted, launch) })
@@ -306,9 +321,17 @@ export class Placement {
306
321
  }
307
322
  const name = match.node.name ?? match.node.machineID
308
323
  if (!(await held())) return lost()
324
+ if (pastNotAfter(launch, this.now())) {
325
+ expired.push(launch.id)
326
+ continue
327
+ }
309
328
  const placing = await skipGone(store.transition(userID, launch.id, "placing", { reason: `Placing on ${name}`, from: ["queued"] }))
310
329
  if (!placing) continue
311
330
  if (!(await held())) return lost()
331
+ if (pastNotAfter(launch, this.now())) {
332
+ expired.push(launch.id)
333
+ continue
334
+ }
312
335
  // A stop between the two steps (e.g. the Concierge reaping an orphan) wins: skipped, not an error.
313
336
  const provisioning = await skipGone(
314
337
  store.transition(userID, launch.id, "provisioning", {
@@ -326,7 +349,7 @@ export class Placement {
326
349
  }
327
350
  placed.push({ userID, launch: provisioning, node: match.node, preferred: match.preferred, fence: lease.fence })
328
351
  }
329
- return { leased: true, placed, waiting }
352
+ return result(true, waiting)
330
353
  } finally {
331
354
  await locks.release(key, owner)
332
355
  }
@@ -392,6 +415,8 @@ export interface PlacementLoopDeps {
392
415
  readonly onPlaced: (placed: Placed) => void | Promise<void>
393
416
  /** The launches the pass could not place, with why (only for a pass that held the user's lease). */
394
417
  readonly onWaiting?: (userID: string, waiting: readonly Waiting[]) => void | Promise<void>
418
+ /** (H3) The launches a pass did not place because their notAfter passed (the engine ends them expired). */
419
+ readonly onExpired?: (userID: string, launchIDs: readonly string[]) => void | Promise<void>
395
420
  readonly onError?: (cause: unknown, userID?: string) => void
396
421
  readonly timers?: Timers
397
422
  readonly intervalMs?: number
@@ -484,6 +509,13 @@ export class PlacementLoop {
484
509
  this.onError(cause, userID)
485
510
  }
486
511
  }
512
+ if (result.expired?.length && this.deps.onExpired) {
513
+ try {
514
+ await this.deps.onExpired(userID, result.expired)
515
+ } catch (cause) {
516
+ this.onError(cause, userID)
517
+ }
518
+ }
487
519
  if (!result.leased || !this.deps.onWaiting) return
488
520
  try {
489
521
  await this.deps.onWaiting(userID, result.waiting)
@@ -103,6 +103,8 @@ export interface AgentDeps {
103
103
  readonly delivery?: Delivery
104
104
  /** Decisions (ask @me, P2-T9): with the message routes. */
105
105
  readonly decisions?: Decisions
106
+ /** Where msg/ask remember their Idempotency-Key records (survive a restart with the file store). */
107
+ readonly messageState?: StateStore
106
108
  /** The agent audit (P3-T5): GET /api/agent/audit when given. */
107
109
  readonly audit?: AgentAudit
108
110
  /** Grants (P3-T2): the /api/agent/grants routes when given. */
@@ -206,7 +208,7 @@ export function agentRoutes(deps: AgentDeps): AgentRoute[] {
206
208
  ...(deps.directory ? directoryRoutes({ directory: deps.directory, store: deps.store, ...(deps.agentEvents ? { events: deps.agentEvents } : {}), ...(deps.registry ? { registry: deps.registry } : {}), ...(deps.grants ? { grants: deps.grants } : {}), ...(deps.acting ? { acting: deps.acting } : {}), ...(deps.audit ? { audit: deps.audit } : {}), ...(deps.eventCursorKeys ? { previewKeys: deps.eventCursorKeys } : {}), ...(deps.teamDiscovery ? { discovery: deps.teamDiscovery } : {}), ...intentReads(deps), ...conciergeReads(deps) }) : []),
207
209
  ...(deps.directory && deps.intents ? intentRoutes({ intents: deps.intents, directory: deps.directory, store: deps.store, ...(deps.registry ? { registry: deps.registry } : {}), ...(deps.teamDiscovery ? { discovery: deps.teamDiscovery } : {}) }) : []),
208
210
  ...(deps.directory && deps.agentEvents && deps.eventCursorKeys ? eventsRoutes({ events: deps.agentEvents, keys: deps.eventCursorKeys, directory: deps.directory, ...eventScope(deps) }) : []),
209
- ...(deps.directory && deps.delivery ? messageRoutes({ delivery: deps.delivery, directory: deps.directory, store: deps.store, feed, ...(deps.decisions ? { decisions: deps.decisions } : {}) }) : []),
211
+ ...(deps.directory && deps.delivery ? messageRoutes({ delivery: deps.delivery, directory: deps.directory, store: deps.store, feed, ...(deps.decisions ? { decisions: deps.decisions } : {}), ...(deps.messageState ? { state: deps.messageState } : {}), ...(deps.now ? { now: deps.now } : {}) }) : []),
210
212
  ...(deps.directory && deps.registry ? readRoutes({ store: deps.store, directory: deps.directory, registry: deps.registry, ...(deps.spokes ? { spokes: deps.spokes } : {}), ...launchReads(deps), ...(deps.agentEvents && deps.eventCursorKeys ? { events: { events: deps.agentEvents, keys: deps.eventCursorKeys, directory: deps.directory, ...eventScope(deps) } } : {}), ...(deps.heartbeatMs !== undefined ? { heartbeatMs: deps.heartbeatMs } : {}), ...(deps.now ? { now: deps.now } : {}), ...(deps.draining ? { draining: deps.draining } : {}), ...(deps.teamDiscovery ? { discovery: deps.teamDiscovery } : {}), ...intentReads(deps), ...conciergeReads(deps) }) : []),
211
213
  {
212
214
  method: "GET",
@@ -5,23 +5,28 @@
5
5
  // GET /api/agent/webhooks/:id → {webhook, deliveries} (recent deliveries, oldest first)
6
6
  // DELETE /api/agent/webhooks/:id → 204 (its pending deliveries are dropped)
7
7
  //
8
- // Who: users (all of their registrations, their own and their app tokens'); app tokens with sessions:launch (the
9
- // scope of the only events so far, launch.*), which see and delete only the registrations they made and get only
10
- // the events of launches they started. Sessions cannot register webhooks. Another user's registration, or (apps)
11
- // one the token did not make, is 404 not_found.
8
+ // Who: users (all of their registrations, their own and their app tokens'); app tokens with events:subscribe or
9
+ // teams:submit, which see and delete only the registrations they made. Registering needs, per selected type, its
10
+ // family's scope (webhookRequiredScopes): events:subscribe plus sessions:read / sessions:manage / messages:read /
11
+ // sessions:launch, except that a registration for team.intake alone needs only teams:submit. An app registration gets
12
+ // only the events of what that app caused (its launches, messages it is a party to, intake it submitted). Sessions
13
+ // cannot register webhooks. Another user's registration, or (apps) one the token did not make, is 404 not_found.
12
14
  import { boundedJson } from "./body"
13
15
  import type { Store } from "../store"
14
16
  import type { AgentActor } from "./actor"
15
17
  import { AgentError } from "./errors"
16
18
  import type { AgentRoute } from "./routes"
17
- import { webhookEventScopes, type WebhookOwner, type Webhooks } from "./webhooks"
19
+ import { webhookRequiredScopes, type WebhookOwner, type Webhooks } from "./webhooks"
18
20
 
19
21
  // A literal: routes.ts imports this module.
20
22
  const PATH = "/api/agent/webhooks"
21
23
  const NO_STORE = { "cache-control": "no-store" }
22
- /** Users; apps with sessions:launch. */
24
+ /** The scope app tokens need for webhooks of every family but team intake. */
23
25
  export const WEBHOOK_SCOPE = "events:subscribe"
24
- const ALLOW = { scope: WEBHOOK_SCOPE } as const
26
+ /** Any of these lets an app token use the webhook routes (teams:submit: team.intake registrations only). */
27
+ export const WEBHOOK_APP_SCOPES: readonly string[] = [WEBHOOK_SCOPE, "teams:submit"]
28
+ // Users and apps; sessions never. An app's scopes are checked in each handler (routes take one scope).
29
+ const ALLOW = { app: true } as const
25
30
 
26
31
  export type RouteWebhooks = Pick<Webhooks, "register" | "list" | "get" | "remove" | "deliveries">
27
32
 
@@ -30,10 +35,14 @@ const ownerOf = (actor: AgentActor): WebhookOwner | undefined => (actor.kind ===
30
35
 
31
36
  const notFound = (id: string) => new AgentError("not_found", `No webhook ${id}`)
32
37
 
38
+ const requireAppScope = (actor: AgentActor) => {
39
+ if (actor.kind === "app" && !WEBHOOK_APP_SCOPES.some((scope) => actor.scopes.includes(scope))) throw new AgentError("forbidden", "This credential cannot use this route")
40
+ }
41
+
33
42
  const requireEventScopes = (actor: AgentActor, events: unknown) => {
34
43
  if (actor.kind !== "app") return
35
44
  const patterns = events === undefined ? ["launch.*"] : Array.isArray(events) && events.every((e) => typeof e === "string") ? events : []
36
- const missing = webhookEventScopes(patterns).filter((scope) => !actor.scopes.includes(scope))
45
+ const missing = webhookRequiredScopes(patterns).filter((scope) => !actor.scopes.includes(scope))
37
46
  if (missing.length) throw new AgentError("forbidden", `Webhook events need scope ${missing.join(", ")}`)
38
47
  }
39
48
 
@@ -50,6 +59,7 @@ export function webhookRoutes(webhooks: RouteWebhooks, store?: Pick<Store, "audi
50
59
  path: PATH,
51
60
  allow: ALLOW,
52
61
  async handle({ request, actor, user }) {
62
+ requireAppScope(actor)
53
63
  const input = await bodyOf(request)
54
64
  requireEventScopes(actor, input.events)
55
65
  const result = await webhooks.register(user.id, ownerOf(actor) ?? "user", { url: input.url, events: input.events, label: input.label })
@@ -61,13 +71,14 @@ export function webhookRoutes(webhooks: RouteWebhooks, store?: Pick<Store, "audi
61
71
  method: "GET",
62
72
  path: PATH,
63
73
  allow: ALLOW,
64
- handle: async ({ actor, user }) => Response.json({ webhooks: await webhooks.list(user.id, ownerOf(actor)) }, { headers: NO_STORE }),
74
+ handle: async ({ actor, user }) => (requireAppScope(actor), Response.json({ webhooks: await webhooks.list(user.id, ownerOf(actor)) }, { headers: NO_STORE })),
65
75
  },
66
76
  {
67
77
  method: "GET",
68
78
  path: `${PATH}/:id`,
69
79
  allow: ALLOW,
70
80
  async handle({ params, actor, user }) {
81
+ requireAppScope(actor)
71
82
  const webhook = await webhooks.get(user.id, params.id!, ownerOf(actor))
72
83
  if (!webhook) throw notFound(params.id!)
73
84
  return Response.json({ webhook, deliveries: await webhooks.deliveries(user.id, webhook.id) }, { headers: NO_STORE })
@@ -78,6 +89,7 @@ export function webhookRoutes(webhooks: RouteWebhooks, store?: Pick<Store, "audi
78
89
  path: `${PATH}/:id`,
79
90
  allow: ALLOW,
80
91
  async handle({ params, actor, user }) {
92
+ requireAppScope(actor)
81
93
  if (!(await webhooks.remove(user.id, params.id!, ownerOf(actor)))) throw notFound(params.id!)
82
94
  await store?.audit({ actor: user.id, action: "webhook.delete", target: params.id! })
83
95
  return new Response(null, { status: 204, headers: NO_STORE })
@@ -2,10 +2,13 @@
2
2
  // app tokens register.
3
3
  //
4
4
  // - Registrations per user in the StateStore collection "webhooks" (key: the id `wh_<16 hex>`): url, events
5
- // (exact types or `launch.*`), an optional label, `by` (`user`, or `app:<token id>` for an app token's own) and
6
- // the signing secret `whsec_…`, returned only by register. Users see and manage all of theirs; an app token only
7
- // its own. An app registration gets only events of what that app started (`by` of the event), and is dropped
8
- // once its token is revoked or loses the scope (appAlive).
5
+ // (exact types or `<family>.*`; team.intake exact only), an optional label, `by` (`user`, or `app:<token id>` for
6
+ // an app token's own) and the signing secret `whsec_…`, returned only by register. Users see and manage all of
7
+ // theirs; an app token only its own. An app registration gets only events of what that app caused (`by` of the
8
+ // event: launches it started, intake it submitted; or a message party), and is dropped once its token is revoked
9
+ // or loses a required scope (appAlive, appScopes, webhookRequiredScopes).
10
+ // - The sole source is the canonical journal (publishCanonical from AgentEvents' onAppend); team.intake arrives as
11
+ // the intake service's journal event (src/central/teams/intake.ts), IDs, enums, revisions and timestamps only.
9
12
  // - URLs: https on a public host (publicURLProblem), checked at registration and again, re-resolved, right before
10
13
  // every send (publicFetch: no redirects, timeout, bounded response read). hub.json agents.webhooks.allowPrivate
11
14
  // admits http and private targets (self-hosted receivers).
@@ -32,13 +35,17 @@ import { LAUNCH_STATES, type Launch } from "./launch-store"
32
35
 
33
36
  export const WEBHOOKS = "webhooks"
34
37
  export const WEBHOOK_OUTBOX = "webhook-outbox"
35
- /** Every event type a webhook can receive (phase 4: launch changes). */
38
+ /** Every canonical event type a webhook can receive. */
36
39
  export const CANONICAL_WEBHOOK_EVENT_TYPES: readonly string[] = [
37
40
  "session.added", "session.removed", "session.changed", "session.archived", "session.named",
38
41
  "session.pinned",
39
42
  "blocker.opened", "blocker.closed", "machine.changed", "role.claimed", "role.released",
40
- "message.changed", "launch.changed",
43
+ "message.changed", "launch.changed", "team.intake",
41
44
  ]
45
+ /** Types no family wildcard selects (exact only): `team.*` would silently widen to team events webhooks do not send. */
46
+ const EXACT_ONLY_TYPES: readonly string[] = ["team.intake"]
47
+ /** The one type an app token may register for without events:subscribe (its scope is teams:submit). */
48
+ const INTAKE_TYPE = "team.intake"
42
49
  /** A launch.changed event for a queued launch's new wait kind (not a state step). */
43
50
  export const isWaitChange = (event: Pick<AgentEvent, "type" | "data">): boolean => event.type === "launch.changed" && event.data.wait !== undefined
44
51
 
@@ -190,6 +197,7 @@ function eventsOf(raw: unknown): string[] {
190
197
  throw invalid(`events must be a list of 1 to ${MAX_WEBHOOK_EVENTS} event types`, `e.g. ["launch.*", "message.changed"]`)
191
198
  }
192
199
  for (const pattern of raw) {
200
+ if (pattern.endsWith(".*") && EXACT_ONLY_TYPES.some((type) => matches(pattern, type))) throw invalid(`${pattern} is not a webhook family`, `Use the exact type ${EXACT_ONLY_TYPES.join(", ")}`)
193
201
  if (!WEBHOOK_EVENT_TYPES.some((type) => matches(pattern, type))) throw invalid(`Unknown event type ${pattern}`, "Use a canonical event type or family wildcard")
194
202
  }
195
203
  return [...new Set(raw)]
@@ -199,7 +207,8 @@ export function webhookEventScopes(patterns: readonly string[]): string[] {
199
207
  const scopes = new Set<string>()
200
208
  if (patterns.some((pattern) => LAUNCH_STATES.some((state) => matches(pattern, `launch.${state}`)))) scopes.add("sessions:launch")
201
209
  for (const pattern of patterns) for (const type of CANONICAL_WEBHOOK_EVENT_TYPES) if (matches(pattern, type)) {
202
- if (type === "message.changed") scopes.add("messages:read")
210
+ if (type === INTAKE_TYPE) scopes.add("teams:submit")
211
+ else if (type === "message.changed") scopes.add("messages:read")
203
212
  else if (type === "launch.changed") scopes.add("sessions:launch")
204
213
  else if (["session.archived", "session.named", "session.pinned", "role.claimed", "role.released"].includes(type)) scopes.add("sessions:manage")
205
214
  else scopes.add("sessions:read")
@@ -207,8 +216,17 @@ export function webhookEventScopes(patterns: readonly string[]): string[] {
207
216
  return [...scopes]
208
217
  }
209
218
 
219
+ /**
220
+ * Every scope an app token needs for a registration's patterns: each family's scope, plus events:subscribe unless
221
+ * the patterns select only team.intake (an app with teams:submit gets its own submissions' changes by webhook).
222
+ */
223
+ export function webhookRequiredScopes(patterns: readonly string[]): string[] {
224
+ const onlyIntake = patterns.length > 0 && patterns.every((pattern) => pattern === INTAKE_TYPE)
225
+ return [...(onlyIntake ? [] : ["events:subscribe"]), ...webhookEventScopes(patterns)]
226
+ }
227
+
210
228
  export function canonicalWebhookEvent(source: string, userID: string, event: AgentEvent): WebhookEvent {
211
- return { id: event.id, type: event.type, source, subject: event.subject.launchID ?? event.subject.messageID ?? event.subject.sessionID ?? event.subject.machineID ?? event.id, time: new Date(event.at).toISOString(), user: userID, schemaVersion: 1, data: { subject: event.subject, data: event.data } }
229
+ return { id: event.id, type: event.type, source, subject: event.subject.launchID ?? event.subject.messageID ?? event.subject.intakeID ?? event.subject.sessionID ?? event.subject.machineID ?? event.id, time: new Date(event.at).toISOString(), user: userID, schemaVersion: 1, data: { subject: event.subject, data: event.data } }
212
230
  }
213
231
 
214
232
  const view = ({ secret: _secret, ...rest }: Registration): WebhookView => rest
@@ -454,7 +472,7 @@ export class Webhooks {
454
472
  }
455
473
  if (registration.by !== "user" && this.deps.appScopes) {
456
474
  const scopes = this.deps.appScopes(userID, registration.by.slice("app:".length))
457
- const required = ["events:subscribe", ...webhookEventScopes(registration.events)]
475
+ const required = webhookRequiredScopes(registration.events)
458
476
  if (!scopes || required.some((scope) => !scopes.includes(scope))) {
459
477
  await this.remove(userID, registration.id)
460
478
  return
@@ -242,7 +242,7 @@ export function artifactHost(deps: ArtifactHostDeps): { handle(request: Request,
242
242
  export interface ArtifactHostRouteDeps {
243
243
  readonly caps: ArtifactHostCaps
244
244
  /** Absent: this hub keeps no artifacts (422 unsupported). */
245
- readonly artifacts?: Pick<ArtifactStore, "get" | "getRevision">
245
+ readonly artifacts?: Pick<ArtifactStore, "get" | "getRevision"> & Partial<Pick<ArtifactStore, "markViewed">>
246
246
  }
247
247
 
248
248
  const READ = { session: true, scope: "artifacts:read" } as const
@@ -297,6 +297,8 @@ export function artifactHostRoutes(deps: ArtifactHostRouteDeps): AgentRoute[] {
297
297
  ...(ttlMs !== undefined ? { ttlMs } : {}),
298
298
  ...(actor.kind === "user" ? { deviceID: actor.deviceID } : {}),
299
299
  })
300
+ // A preview's retention counts from its last view (session media §3).
301
+ if (artifact.origin === "preview") await artifacts.markViewed?.(user.id, artifact.id)
300
302
  const base = `${ARTIFACT_HOST_PATH}${token}/`
301
303
  const files = revision.blobs.map((b) => ({ ...(b.path !== undefined ? { path: b.path } : {}), mediaType: b.mediaType, bytes: b.bytes }))
302
304
  return Response.json({ capability, base, url: `${base}${entry}`, files }, { status: 201, headers: NO_STORE })
@@ -1,6 +1,7 @@
1
1
  // Scheduled artifact upkeep (HWA-21 standalone; the cluster schedules it under a leader, HWA-22): for every user,
2
2
  // ArtifactStore.recover (finish crashed deletes and uploads, drop dangling references, collect unreferenced bytes
3
- // untouched for the grace period) and then purgeArchived (only when archive retention is configured). Runs never
3
+ // untouched for the grace period) and then purgeArchived (preview retention always: archive previews not viewed for
4
+ // 24 h, purge them 7 days later; other archived artifacts only when archive retention is configured). Runs never
4
5
  // overlap; a user's failure is logged (without user IDs or content) and the run goes on with the next user.
5
6
  import { realTimers } from "../backend/memory-bus"
6
7
  import type { Timers } from "../backend/types"