@huynle/hub 0.8.7 → 0.9.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +152 -0
- package/README.md +75 -16
- package/package.json +5 -1
- package/packages/claude-code/package.json +1 -1
- package/packages/claude-code/src/wrapper.ts +2 -2
- package/packages/cli/dist/hub-cli.js +1413 -523
- package/packages/cli/package.json +1 -1
- package/packages/cli/src/bootstrap/PHASE3-CONTRACT.md +112 -0
- package/packages/cli/src/bootstrap/adapters/node-services/control.ts +164 -21
- package/packages/cli/src/bootstrap/adapters/node-services/plan.ts +24 -7
- package/packages/cli/src/bootstrap/adapters/node-services/render.ts +116 -9
- package/packages/cli/src/bootstrap/adapters/node-services/validate.ts +8 -2
- package/packages/cli/src/bootstrap/contracts/actions.ts +8 -6
- package/packages/cli/src/bootstrap/contracts/discovery.ts +4 -3
- package/packages/cli/src/bootstrap/contracts/exec.ts +4 -2
- package/packages/cli/src/bootstrap/contracts/paths.ts +2 -1
- package/packages/cli/src/bootstrap/contracts/platform.ts +5 -5
- package/packages/cli/src/bootstrap/discovery/discover.ts +7 -2
- package/packages/cli/src/bootstrap/discovery/host.ts +2 -0
- package/packages/cli/src/bootstrap/preview/plan.ts +5 -1
- package/packages/cli/src/bootstrap/profiles/desired.ts +16 -1
- package/packages/cli/src/bootstrap/profiles/history.ts +2 -1
- package/packages/cli/src/bootstrap/profiles/upgrade.ts +2 -2
- package/packages/cli/src/bootstrap/release/assets.ts +4 -1
- package/packages/cli/src/cli.ts +23 -10
- package/packages/cli/src/http.ts +1 -0
- package/packages/cli/src/launches.ts +5 -1
- package/packages/cli/src/mcp.ts +28 -8
- package/packages/cli/src/messaging.ts +14 -0
- package/packages/cli/src/node.ts +94 -12
- package/packages/cli/src/setup/commands.ts +27 -7
- package/packages/cli/src/setup/runtime.ts +31 -4
- package/packages/cli/src/team-intake-mcp.ts +26 -2
- package/packages/codex/package.json +1 -1
- package/packages/codex/src/wrapper.ts +2 -2
- package/packages/node/package.json +1 -1
- package/packages/node/src/config.ts +49 -17
- package/packages/node/src/git.ts +16 -2
- package/packages/node/src/harnesses.ts +54 -9
- package/packages/node/src/host.ts +61 -15
- package/packages/node/src/opencode-service.ts +111 -0
- package/packages/node/src/opencode-warm.ts +11 -5
- package/packages/node/src/run.ts +50 -5
- package/packages/preset/package.json +1 -1
- package/packages/protocol/package.json +1 -1
- package/packages/protocol/schema/app-delivery/v1.schema.json +677 -0
- package/packages/protocol/src/artifacts.ts +43 -0
- package/packages/protocol/src/credentials.ts +38 -0
- package/packages/protocol/src/index.ts +59 -6
- package/packages/protocol/src/launch.ts +46 -6
- package/packages/protocol/src/markdown.ts +753 -0
- package/packages/protocol/src/media-allowlist.ts +240 -0
- package/packages/protocol/src/media.ts +120 -0
- package/packages/protocol/src/preview-bundle.ts +221 -0
- package/packages/protocol/src/voice-recording.ts +8 -0
- package/packages/runtime-openshell/package.json +1 -1
- package/packages/runtime-sdk/package.json +1 -1
- package/packages/sdk/package.json +1 -1
- package/packages/sdk/src/client.ts +17 -3
- package/packages/sdk/src/errors.ts +1 -0
- package/packages/sdk/src/team-intake.ts +8 -0
- package/packages/sdk/src/types.ts +16 -2
- package/packages/spoke-sdk/package.json +1 -1
- package/src/central/agent/delivery.ts +35 -6
- package/src/central/agent/errors.ts +2 -0
- package/src/central/agent/events.ts +11 -2
- package/src/central/agent/launch-engine.ts +65 -4
- package/src/central/agent/launch-routes.ts +8 -0
- package/src/central/agent/launch-spec.ts +22 -3
- package/src/central/agent/launch-store.ts +2 -2
- package/src/central/agent/message-routes.ts +49 -11
- package/src/central/agent/messages.ts +17 -4
- package/src/central/agent/not-after.ts +28 -0
- package/src/central/agent/placement.ts +36 -4
- package/src/central/agent/routes.ts +3 -1
- package/src/central/agent/webhook-routes.ts +21 -9
- package/src/central/agent/webhooks.ts +27 -9
- package/src/central/artifacts/host.ts +3 -1
- package/src/central/artifacts/maintenance.ts +2 -1
- package/src/central/artifacts/preview-bundle.ts +81 -0
- package/src/central/artifacts/previews.ts +94 -0
- package/src/central/artifacts/routes.ts +12 -1
- package/src/central/artifacts/store.ts +198 -8
- package/src/central/config.ts +34 -0
- package/src/central/fetch-budget.ts +59 -0
- package/src/central/fetcher.ts +372 -0
- package/src/central/http.ts +277 -22
- package/src/central/install/CONTRACT.md +4 -4
- package/src/central/install/install.ps1 +37 -5
- package/src/central/install/render.ts +13 -8
- package/src/central/media-remote.ts +248 -0
- package/src/central/media-timeline.ts +317 -0
- package/src/central/media-unfurl.ts +119 -0
- package/src/central/profiles/content.ts +3 -35
- package/src/central/recording/routes.ts +2 -2
- package/src/central/recording/schema.ts +3 -1
- package/src/central/registry.ts +449 -21
- package/src/central/spoke-ws.ts +17 -3
- package/src/central/store.ts +21 -0
- package/src/central/teams/intake-routes.ts +2 -1
- package/src/central/teams/intake.ts +200 -15
- package/src/central/unfurl-parse.ts +313 -0
- package/src/index.ts +68 -8
- package/src/speakable.ts +33 -1
- package/src/spoke/conversation.ts +7 -4
- package/src/spoke/deliver.ts +19 -0
- package/src/spoke/media-bytes.ts +171 -0
- package/src/spoke/media-policy.ts +394 -0
- package/src/spoke/media.ts +189 -0
- package/src/spoke/own-server.ts +30 -0
- package/src/spoke/preview-bundle.ts +538 -0
- package/src/spoke/spoke.ts +387 -17
- package/src/spoke/timeline.ts +59 -5
- package/src/spoke/voice-front.ts +335 -0
- package/web/dist/assets/24BXRY42-Zfr7jm_p.js +2 -0
- package/web/dist/assets/4FHPYW2L-C6Z67098.js +2 -0
- package/web/dist/assets/4LWW3ZNH-CUiYeygH.js +1 -0
- package/web/dist/assets/5THPTBWS-Ce_hS451.js +1 -0
- package/web/dist/assets/5XYWFMKW-CW-BCKiK.js +4 -0
- package/web/dist/assets/64PMEAB3-BjpCDGU9.js +8 -0
- package/web/dist/assets/6BEWHPQE-BpNa96Ze.js +2 -0
- package/web/dist/assets/6PIGVMFT-YXn9Hgnb.js +1 -0
- package/web/dist/assets/75JW6PN6-BFpDBuGN.js +2 -0
- package/web/dist/assets/7WLXJQOR-D6QVYUu9.js +1 -0
- package/web/dist/assets/7XWI72E6-BMuIspEy.js +2 -0
- package/web/dist/assets/A4UD2L5Z-BQzcD9dU.js +8 -0
- package/web/dist/assets/ArtifactPage-BKISN-Qj.js +1 -0
- package/web/dist/assets/ArtifactPane-CKOiAUNH.js +1 -0
- package/web/dist/assets/ArtifactWorkbench-d7R1o-rs.js +6 -0
- package/web/dist/assets/BGW36YJW-CJMhya8S.js +4 -0
- package/web/dist/assets/CGIZI67J-DYDSLME8.js +2 -0
- package/web/dist/assets/DKTLEL5E-DciMwFJ1.js +1 -0
- package/web/dist/assets/DNNCRC2E-BIeKf5t-.js +5 -0
- package/web/dist/assets/Diagram-azw2B_QH.js +1 -0
- package/web/dist/assets/Diagram-vdpmp3xu.css +1 -0
- package/web/dist/assets/E67EBMDD--AucIw59.js +5 -0
- package/web/dist/assets/EFYWJBJK-CVywe_D5.js +1 -0
- package/web/dist/assets/EXE5EH6O-DR1IHCTr.js +4 -0
- package/web/dist/assets/FTXIJQGB-BZVuiBn1.js +1 -0
- package/web/dist/assets/G3MDBVQH-BZmnMkFw.js +1 -0
- package/web/dist/assets/G6GPDDNC-DEz8F449.js +2 -0
- package/web/dist/assets/G76NGGFM-CrB2V34K.js +1 -0
- package/web/dist/assets/GH6G3TJC-D3RTYEH4.js +2 -0
- package/web/dist/assets/HPNUA7RO-B19NeNOJ.js +1 -0
- package/web/dist/assets/HQRGC5D7-Dmpzq6Rn.js +4 -0
- package/web/dist/assets/IX6Z2TVR-DphRHnek.js +5 -0
- package/web/dist/assets/J77NQRCJ-B7AGcxvw.js +3 -0
- package/web/dist/assets/JAD36DTZ-B9-dbsS0.js +5 -0
- package/web/dist/assets/LF3NJQAX-koL91UZ9.js +2 -0
- package/web/dist/assets/LRZCOFJZ-Cdjasfuf.js +6 -0
- package/web/dist/assets/Library-CcjZXsfF.js +2 -0
- package/web/dist/assets/Lightbox-C8KNKwOA.css +1 -0
- package/web/dist/assets/Lightbox-Du56MXGX.js +1 -0
- package/web/dist/assets/MediaImage-Ds_JUx3a.js +4 -0
- package/web/dist/assets/N5NF2FGY-OuapYTfr.js +2 -0
- package/web/dist/assets/N7OWWQHY-BNLd-Q_U.js +2 -0
- package/web/dist/assets/NT5JY5WP-1lJM-aun.js +1 -0
- package/web/dist/assets/OT7W3RNY-jDL3drzX.js +4 -0
- package/web/dist/assets/P67P66DB-CE-opRb8.js +3 -0
- package/web/dist/assets/PFLLLM7K-DDJFgzW3.js +2 -0
- package/web/dist/assets/PUAMC4MJ-CtBm-WuU.js +12 -0
- package/web/dist/assets/PVFCHRLY-CG5JXv60.js +2 -0
- package/web/dist/assets/ProfilesDestination-DdzbzmeK.js +1 -0
- package/web/dist/assets/QQW2P742-BbcEkpa4.js +1 -0
- package/web/dist/assets/RVARI57O-CgA1WoRv.js +1 -0
- package/web/dist/assets/S63OEDPG-CKlhpvl2.js +3 -0
- package/web/dist/assets/SG5TVYLW-BBtiwXPL.js +2 -0
- package/web/dist/assets/TeamsPage-CDyjrK-I.js +1 -0
- package/web/dist/assets/UGRSUGA2-urZ_hx83.js +5 -0
- package/web/dist/assets/V44WA6U7-DYfX3OlM.js +1 -0
- package/web/dist/assets/V67O4WWV-xYxJ00QW.js +1 -0
- package/web/dist/assets/VBUCM3YD-JHGjuJC_.js +1 -0
- package/web/dist/assets/VINGRCPA-DZuJa3SH.js +7 -0
- package/web/dist/assets/WL6DNKX4-BHqripdA.js +2 -0
- package/web/dist/assets/WQ63CJYP-D6G5frDY.js +2 -0
- package/web/dist/assets/Watchtower-DYwoUWME.js +2 -0
- package/web/dist/assets/XOYAOB3S-L-KeA1EF.js +13 -0
- package/web/dist/assets/Y5LQLG3M-dr2-erb8.js +2 -0
- package/web/dist/assets/YIK6JTN6--1t-Ud8k.js +2 -0
- package/web/dist/assets/artifacts-DU9uRgs1.js +1 -0
- package/web/dist/assets/index-C3ci5UtY.css +1 -0
- package/web/dist/assets/index-LNMZCqgE.js +36 -0
- package/web/dist/assets/lazy-f3CuUNJZ.js +43 -0
- package/web/dist/assets/live-ycZ_aQZO.js +5 -0
- package/web/dist/assets/mermaid.worker-WDVRAh32.js +1 -0
- package/web/dist/assets/pane-layout-0RrLHGDr.js +1 -0
- package/web/dist/assets/preload-helper-BLdHbfdN.js +1 -0
- package/web/dist/assets/{renderer2d-CYag1CnR.js → renderer2d-BIwbgRoX.js} +1 -1
- package/web/dist/assets/{renderer3d-BRB6rgDJ.js → renderer3d-C1a7eYGQ.js} +1 -1
- package/web/dist/assets/{safe-area-fOy0AOuG.js → safe-area-DHhPy61Q.js} +1 -1
- package/web/dist/assets/{silero-worker-DhlIHyD_.js → silero-worker-e802lB5T.js} +1 -1
- package/web/dist/assets/smart-turn-v3.2-cpu-WpeLRbPA.onnx +0 -0
- package/web/dist/assets/smart-turn-worker-DaVJPdvJ.js +11 -0
- package/web/dist/assets/source-BWlvaODa.js +1 -0
- package/web/dist/assets/toast-Cuu2K0hx.js +1 -0
- package/web/dist/assets/ui-CFB4wQfF.js +3 -0
- package/web/dist/index.html +8 -6
- package/web/src/voice/turn/LICENSE +28 -0
- package/web/dist/assets/ArtifactPage-Dq7VW-bU.js +0 -1
- package/web/dist/assets/ArtifactPane-DKwbMTX9.js +0 -1
- package/web/dist/assets/ArtifactWorkbench-vrgIfncX.js +0 -6
- package/web/dist/assets/Library-D6-Go6Cu.js +0 -2
- package/web/dist/assets/ProfilesDestination-BOML2Dgf.js +0 -1
- package/web/dist/assets/TeamsPage-CNBTE8gB.js +0 -1
- package/web/dist/assets/Watchtower-Dor1Ubjy.js +0 -2
- package/web/dist/assets/hub-CrbItiwP.js +0 -13
- package/web/dist/assets/index-82Ce0IAf.css +0 -1
- package/web/dist/assets/index-D9SP3_0z.js +0 -22
- package/web/dist/assets/live-BNJgy0vg.js +0 -5
- package/web/dist/assets/pane-layout-EdR1kRrG.js +0 -1
- package/web/dist/assets/preload-helper-DqrJTq0t.js +0 -1
- package/web/dist/assets/source-Wbq35qGp.js +0 -1
- 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?}
|
|
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
|
-
|
|
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
|
|
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
|
|
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: {
|
|
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
|
-
|
|
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 => (
|
|
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
|
|
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
|
|
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
|
|
9
|
-
//
|
|
10
|
-
//
|
|
11
|
-
//
|
|
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 {
|
|
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
|
-
/**
|
|
24
|
+
/** The scope app tokens need for webhooks of every family but team intake. */
|
|
23
25
|
export const WEBHOOK_SCOPE = "events:subscribe"
|
|
24
|
-
|
|
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 =
|
|
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
|
|
6
|
-
// the signing secret `whsec_…`, returned only by register. Users see and manage all of
|
|
7
|
-
// its own. An app registration gets only events of what that app
|
|
8
|
-
//
|
|
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
|
|
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 ===
|
|
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 =
|
|
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 (
|
|
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"
|