@huynle/hub 0.6.0 → 0.7.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 +800 -0
- package/Dockerfile +30 -0
- package/README.md +870 -66
- package/package.json +45 -2
- package/packages/claude-code/package.json +12 -0
- package/packages/claude-code/src/bridge.ts +79 -0
- package/packages/claude-code/src/daemon.ts +339 -0
- package/packages/claude-code/src/hook-input.ts +44 -0
- package/packages/claude-code/src/ids.ts +27 -0
- package/packages/claude-code/src/index.ts +18 -0
- package/packages/claude-code/src/launcher.ts +265 -0
- package/packages/claude-code/src/main.ts +127 -0
- package/packages/claude-code/src/mcp.ts +109 -0
- package/packages/claude-code/src/paths.ts +34 -0
- package/packages/claude-code/src/plugin.ts +99 -0
- package/packages/claude-code/src/references.ts +51 -0
- package/packages/claude-code/src/sessions.ts +600 -0
- package/packages/claude-code/src/tools.ts +66 -0
- package/packages/claude-code/src/wire.ts +71 -0
- package/packages/claude-code/src/wrapper.ts +155 -0
- package/packages/cli/dist/hub-cli.js +10162 -0
- package/packages/codex/package.json +12 -0
- package/packages/codex/src/app-server.ts +44 -0
- package/packages/codex/src/ids.ts +28 -0
- package/packages/codex/src/index.ts +11 -0
- package/packages/codex/src/jsonrpc.ts +168 -0
- package/packages/codex/src/main.ts +122 -0
- package/packages/codex/src/paths.ts +65 -0
- package/packages/codex/src/sessions.ts +578 -0
- package/packages/codex/src/spoke.ts +249 -0
- package/packages/codex/src/stdio.ts +101 -0
- package/packages/codex/src/websocket.ts +206 -0
- package/packages/codex/src/wrapper.ts +100 -0
- package/packages/node/package.json +12 -0
- package/packages/node/src/config.ts +184 -0
- package/packages/node/src/connection.ts +283 -0
- package/packages/node/src/daemon.ts +281 -0
- package/packages/node/src/git.ts +107 -0
- package/packages/node/src/harnesses.ts +60 -0
- package/packages/node/src/host.ts +319 -0
- package/packages/node/src/index.ts +17 -0
- package/packages/node/src/main.ts +11 -0
- package/packages/node/src/openshell.ts +73 -0
- package/packages/node/src/run.ts +106 -0
- package/packages/preset/package.json +12 -0
- package/packages/preset/presets/aider.json +8 -0
- package/packages/preset/presets/goose.json +8 -0
- package/packages/preset/src/index.ts +2 -0
- package/packages/preset/src/main.ts +195 -0
- package/packages/preset/src/presets.ts +87 -0
- package/packages/preset/src/spoke.ts +251 -0
- package/packages/preset/src/tmux.ts +160 -0
- package/packages/protocol/package.json +12 -0
- package/packages/protocol/src/harness-events.ts +207 -0
- package/packages/protocol/src/index.ts +1215 -0
- package/packages/protocol/src/launch.ts +172 -0
- package/packages/protocol/src/node.ts +409 -0
- package/packages/protocol/src/preset.ts +144 -0
- package/packages/protocol/src/webhook.ts +62 -0
- package/packages/protocol/src/wire.ts +54 -0
- package/packages/runtime-openshell/image/Dockerfile +36 -0
- package/packages/runtime-openshell/image/README.md +78 -0
- package/packages/runtime-openshell/image/build.ts +203 -0
- package/packages/runtime-openshell/image/entry.sh +163 -0
- package/packages/runtime-openshell/image/image.ts +119 -0
- package/packages/runtime-openshell/image/start.sh +24 -0
- package/packages/runtime-openshell/package.json +12 -0
- package/packages/runtime-openshell/src/cli.ts +584 -0
- package/packages/runtime-openshell/src/index.ts +6 -0
- package/packages/runtime-openshell/src/policy.ts +155 -0
- package/packages/runtime-openshell/src/profiles.ts +181 -0
- package/packages/runtime-openshell/src/runtime.ts +475 -0
- package/packages/runtime-sdk/package.json +12 -0
- package/packages/runtime-sdk/src/index.ts +87 -0
- package/packages/sdk/package.json +12 -0
- package/packages/sdk/src/address.ts +25 -0
- package/packages/sdk/src/client.ts +502 -0
- package/packages/sdk/src/envelope.ts +108 -0
- package/packages/sdk/src/errors.ts +106 -0
- package/packages/sdk/src/index.ts +10 -0
- package/packages/sdk/src/sse.ts +67 -0
- package/packages/sdk/src/types.ts +458 -0
- package/packages/sdk/src/webhook.ts +102 -0
- package/packages/spoke-sdk/package.json +12 -0
- package/packages/spoke-sdk/src/activity.ts +3 -0
- package/packages/spoke-sdk/src/connection.ts +295 -0
- package/packages/spoke-sdk/src/delivery.ts +119 -0
- package/packages/spoke-sdk/src/enroll.ts +79 -0
- package/packages/spoke-sdk/src/harness-hello.ts +79 -0
- package/packages/spoke-sdk/src/harness-spoke.ts +263 -0
- package/packages/spoke-sdk/src/index.ts +12 -0
- package/packages/spoke-sdk/src/prompt-refs.ts +154 -0
- package/packages/spoke-sdk/src/prompt-source.ts +8 -0
- package/packages/spoke-sdk/src/session-ids.ts +49 -0
- package/packages/spoke-sdk/src/session-tokens.ts +78 -0
- package/scripts/hub-claude-code.service +34 -0
- package/scripts/hub-codex-spoke.service +34 -0
- package/scripts/hub-node.service +35 -0
- package/scripts/io.github.huynle.hub-claude-code.plist +53 -0
- package/scripts/io.github.huynle.hub-codex-spoke.plist +53 -0
- package/scripts/io.github.huynle.hub-node.plist +52 -0
- package/src/central/actions.ts +233 -0
- package/src/central/agent/acting-routes.ts +133 -0
- package/src/central/agent/acting-switch.ts +93 -0
- package/src/central/agent/acting.ts +599 -0
- package/src/central/agent/actor.ts +193 -0
- package/src/central/agent/app-tokens.ts +142 -0
- package/src/central/agent/audit.ts +185 -0
- package/src/central/agent/decisions.ts +530 -0
- package/src/central/agent/delivery.ts +678 -0
- package/src/central/agent/directory-routes.ts +185 -0
- package/src/central/agent/directory.ts +473 -0
- package/src/central/agent/envelope.ts +51 -0
- package/src/central/agent/errors.ts +72 -0
- package/src/central/agent/escalation.ts +219 -0
- package/src/central/agent/grant-requests.ts +221 -0
- package/src/central/agent/grant-routes.ts +70 -0
- package/src/central/agent/grants.ts +235 -0
- package/src/central/agent/launch-engine.ts +1025 -0
- package/src/central/agent/launch-routes.ts +252 -0
- package/src/central/agent/launch-spec.ts +289 -0
- package/src/central/agent/launch-store.ts +264 -0
- package/src/central/agent/launch-tokens.ts +206 -0
- package/src/central/agent/message-limits.ts +123 -0
- package/src/central/agent/message-routes.ts +385 -0
- package/src/central/agent/messages.ts +442 -0
- package/src/central/agent/names.ts +86 -0
- package/src/central/agent/placement.ts +419 -0
- package/src/central/agent/policy.ts +378 -0
- package/src/central/agent/read-routes.ts +389 -0
- package/src/central/agent/routes.ts +278 -0
- package/src/central/agent/session-tokens.ts +227 -0
- package/src/central/agent/versioned.ts +26 -0
- package/src/central/agent/watch.ts +221 -0
- package/src/central/agent/webhook-routes.ts +78 -0
- package/src/central/agent/webhooks.ts +449 -0
- package/src/central/agent-data.ts +177 -0
- package/src/central/auth.ts +224 -121
- package/src/central/backend/file-state.ts +240 -0
- package/src/central/backend/index.ts +113 -0
- package/src/central/backend/memory-bus.ts +93 -0
- package/src/central/backend/memory-catalog.ts +90 -0
- package/src/central/backend/memory-counters.ts +102 -0
- package/src/central/backend/memory-events.ts +45 -0
- package/src/central/backend/memory-kv.ts +38 -0
- package/src/central/backend/memory-state.ts +77 -0
- package/src/central/backend/postgres-migrate.ts +100 -0
- package/src/central/backend/postgres-state.ts +197 -0
- package/src/central/backend/types.ts +244 -0
- package/src/central/backend/valkey-bus.ts +425 -0
- package/src/central/backend/valkey-catalog.ts +190 -0
- package/src/central/backend/valkey-client.ts +36 -0
- package/src/central/backend/valkey-counters.ts +38 -0
- package/src/central/backend/valkey-events.ts +168 -0
- package/src/central/backend/valkey-kv.ts +54 -0
- package/src/central/backend/valkey-locks.ts +92 -0
- package/src/central/cluster/directory.ts +141 -0
- package/src/central/cluster/feed.ts +84 -0
- package/src/central/cluster/leader.ts +232 -0
- package/src/central/cluster/live.ts +151 -0
- package/src/central/cluster/recommend.ts +59 -0
- package/src/central/cluster/rpc.ts +150 -0
- package/src/central/cluster/view.ts +232 -0
- package/src/central/config.ts +197 -3
- package/src/central/done.ts +96 -0
- package/src/central/events-log.ts +100 -0
- package/src/central/http.ts +1067 -171
- package/src/central/import.ts +318 -0
- package/src/central/main.ts +211 -12
- package/src/central/nodes.ts +314 -0
- package/src/central/oidc.ts +34 -14
- package/src/central/push-target.ts +118 -18
- package/src/central/push.ts +256 -82
- package/src/central/registry.ts +902 -92
- package/src/central/say-stream.ts +128 -0
- package/src/central/session-token-push.ts +233 -0
- package/src/central/speech/openai.ts +21 -14
- package/src/central/speech/openrouter.ts +27 -10
- package/src/central/speech/provider.ts +54 -0
- package/src/central/speech/service.ts +22 -7
- package/src/central/spoke-ws.ts +522 -44
- package/src/central/store-shared.ts +518 -0
- package/src/central/store.ts +572 -189
- package/src/central/token-keys.ts +165 -0
- package/src/config.ts +49 -1
- package/src/index.ts +165 -12
- package/src/protocol.ts +3 -650
- package/src/rpc.ts +27 -2
- package/src/spoke/client.ts +3 -272
- package/src/spoke/conversation.ts +16 -12
- package/src/spoke/deliver.ts +75 -0
- package/src/spoke/enroll.ts +2 -79
- package/src/spoke/env-inject.ts +160 -0
- package/src/spoke/hub-rpc.ts +115 -0
- package/src/spoke/hub-tool.ts +409 -0
- package/src/spoke/launch-source.ts +22 -0
- package/src/spoke/launchers.ts +68 -0
- package/src/spoke/locations.ts +3 -2
- package/src/spoke/node-roots.ts +61 -0
- package/src/spoke/prompt-refs.ts +116 -0
- package/src/spoke/session-tokens.ts +2 -0
- package/src/spoke/spoke.ts +465 -28
- package/src/spoke/taskboard.ts +243 -0
- package/src/spoke/timeline.ts +184 -6
- package/src/spoke/tracker.ts +79 -4
- package/src/status.ts +203 -10
- package/src/tui-commands.ts +13 -6
- package/src/tui-messaging.ts +93 -0
- package/src/tui.ts +15 -0
- package/web/dist/assets/highlight-y4rJE6Tt.js +5 -0
- package/web/dist/assets/index-D9zMbxSg.css +1 -0
- package/web/dist/assets/index-db1z4duA.js +20 -0
- package/web/dist/assets/ort-wasm-simd-threaded-CxTQ5xH-.wasm +0 -0
- package/web/dist/assets/silero-worker-C8-8vLVS.js +11 -0
- package/web/dist/assets/silero_vad-Dt9-bTDn.onnx +0 -0
- package/web/dist/index.html +9 -2
- package/web/dist/sw.js +79 -15
- package/web/dist/assets/index-DClBvfLL.css +0 -1
- package/web/dist/assets/index-Djo_yF0a.js +0 -12
|
@@ -0,0 +1,264 @@
|
|
|
1
|
+
// Launches per user (design §6.1, §9; decision znbg8xa5), stored in the StateStore collection "launches" under
|
|
2
|
+
// their id. The launch engine (P4-T8) drives them; this module owns the records, the state machine and idempotency.
|
|
3
|
+
// - States: queued → placing → provisioning → running → ended{outcome}. placing may fall back to queued (no node
|
|
4
|
+
// could take it yet); every live state may end; ended is final. Each step records its reason and time (history,
|
|
5
|
+
// bounded: the first step and the latest ones). An illegal step throws LaunchTransitionError (code gone when the
|
|
6
|
+
// launch already ended, else invalid) and changes nothing; writes are version-checked, so of two racing steps
|
|
7
|
+
// only one applies.
|
|
8
|
+
// - provisioning needs the machine, running the session; ended needs an outcome (idle, needs_user, failed,
|
|
9
|
+
// timeout, stopped, lost), and only ended may carry one.
|
|
10
|
+
// - Idempotency: a spec with a key claims it in the StateStore (namespace `launch-keys:<userID>`, owner the launch
|
|
11
|
+
// id) before the launch is stored. The same key with an equivalent body returns the existing launch (even an
|
|
12
|
+
// ended one, until it is pruned); a different body is invalid with a hint. A claim whose launch never appeared
|
|
13
|
+
// (its creator crashed) blocks the key for RESERVATION_MS, then is taken over.
|
|
14
|
+
// - Pruning: ended launches older than endedKeepMs, and all but the newest endedMax ended ones, are removed and
|
|
15
|
+
// their keys released. Live launches are never pruned.
|
|
16
|
+
import { randomBytes } from "node:crypto"
|
|
17
|
+
import type { Clock, StateStore } from "../backend/types"
|
|
18
|
+
import { AgentError } from "./errors"
|
|
19
|
+
import { type LaunchSpec, launchBodyKey } from "./launch-spec"
|
|
20
|
+
import { updateVersioned } from "./versioned"
|
|
21
|
+
|
|
22
|
+
export const LAUNCHES = "launches"
|
|
23
|
+
export const REASON_MAX = 500
|
|
24
|
+
export const HISTORY_MAX = 20
|
|
25
|
+
export const ENDED_KEEP_MS = 7 * 86_400_000
|
|
26
|
+
export const ENDED_MAX = 500
|
|
27
|
+
/** How long a key claim without its launch (a creator that may still be writing) blocks the key. */
|
|
28
|
+
export const RESERVATION_MS = 60_000
|
|
29
|
+
/** Reads of a key holder's launch: its creator may be between the claim and the write, so a miss is re-read. */
|
|
30
|
+
const READS = 5
|
|
31
|
+
/** Claim attempts (a stale claim is taken over between them). */
|
|
32
|
+
const CLAIMS = 3
|
|
33
|
+
|
|
34
|
+
export const LAUNCH_STATES = ["queued", "placing", "provisioning", "running", "ended"] as const
|
|
35
|
+
export type LaunchState = (typeof LAUNCH_STATES)[number]
|
|
36
|
+
export const LAUNCH_OUTCOMES = ["idle", "needs_user", "failed", "timeout", "stopped", "lost"] as const
|
|
37
|
+
export type LaunchOutcome = (typeof LAUNCH_OUTCOMES)[number]
|
|
38
|
+
|
|
39
|
+
/** The states each state may move to. */
|
|
40
|
+
const NEXT: Record<LaunchState, readonly LaunchState[]> = {
|
|
41
|
+
queued: ["placing", "ended"],
|
|
42
|
+
placing: ["provisioning", "queued", "ended"],
|
|
43
|
+
provisioning: ["running", "ended"],
|
|
44
|
+
running: ["ended"],
|
|
45
|
+
ended: [],
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
export interface LaunchStep {
|
|
49
|
+
readonly state: LaunchState
|
|
50
|
+
readonly reason: string
|
|
51
|
+
readonly at: number
|
|
52
|
+
readonly outcome?: LaunchOutcome
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
export interface Launch {
|
|
56
|
+
readonly id: string
|
|
57
|
+
/** The user it belongs to. */
|
|
58
|
+
readonly owner: string
|
|
59
|
+
readonly spec: LaunchSpec
|
|
60
|
+
/** Who launched it, as the caller describes them (e.g. `app:<token id>`, `ses_…`, `user`). */
|
|
61
|
+
readonly by?: string
|
|
62
|
+
readonly state: LaunchState
|
|
63
|
+
readonly reason: string
|
|
64
|
+
/** When it entered its state. */
|
|
65
|
+
readonly at: number
|
|
66
|
+
readonly outcome?: LaunchOutcome
|
|
67
|
+
readonly machineID?: string
|
|
68
|
+
readonly sessionID?: string
|
|
69
|
+
readonly name?: string
|
|
70
|
+
readonly artifacts?: Readonly<Record<string, unknown>>
|
|
71
|
+
readonly created: number
|
|
72
|
+
readonly history: readonly LaunchStep[]
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
export interface TransitionInput {
|
|
76
|
+
readonly reason: string
|
|
77
|
+
readonly outcome?: LaunchOutcome
|
|
78
|
+
readonly machineID?: string
|
|
79
|
+
readonly sessionID?: string
|
|
80
|
+
readonly name?: string
|
|
81
|
+
readonly artifacts?: Readonly<Record<string, unknown>>
|
|
82
|
+
/** Apply only when the launch is in one of these states (else LaunchTransitionError). */
|
|
83
|
+
readonly from?: readonly LaunchState[]
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
export interface LaunchFilter {
|
|
87
|
+
/** Every label must match the spec's. */
|
|
88
|
+
readonly labels?: Readonly<Record<string, string>>
|
|
89
|
+
readonly states?: readonly LaunchState[]
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
export interface LaunchStoreDeps {
|
|
93
|
+
readonly state: StateStore
|
|
94
|
+
readonly now?: Clock
|
|
95
|
+
readonly newID?: () => string
|
|
96
|
+
readonly endedKeepMs?: number
|
|
97
|
+
readonly endedMax?: number
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
/** A step the state machine does not allow (or not from the current state); the launch is unchanged. */
|
|
101
|
+
export class LaunchTransitionError extends AgentError {
|
|
102
|
+
constructor(
|
|
103
|
+
readonly from: LaunchState,
|
|
104
|
+
readonly to: LaunchState,
|
|
105
|
+
) {
|
|
106
|
+
super(from === "ended" ? "gone" : "invalid", from === "ended" ? "The launch already ended" : `A launch cannot go from ${from} to ${to}`)
|
|
107
|
+
this.name = "LaunchTransitionError"
|
|
108
|
+
}
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
/** Control characters become spaces; trimmed and bounded. */
|
|
112
|
+
const cleanReason = (reason: unknown): string => {
|
|
113
|
+
const text = typeof reason === "string" ? reason.replace(/[\u0000-\u001f\u007f-\u009f]+/g, " ").trim().slice(0, REASON_MAX).trim() : ""
|
|
114
|
+
if (!text) throw new Error("A launch transition needs a reason")
|
|
115
|
+
return text
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
/** The first step (how it began) and the latest ones. */
|
|
119
|
+
const bounded = (history: readonly LaunchStep[]): LaunchStep[] =>
|
|
120
|
+
history.length <= HISTORY_MAX ? [...history] : [history[0]!, ...history.slice(history.length - HISTORY_MAX + 1)]
|
|
121
|
+
|
|
122
|
+
const byCreation = (a: Launch, b: Launch) => a.created - b.created || (a.id < b.id ? -1 : a.id > b.id ? 1 : 0)
|
|
123
|
+
|
|
124
|
+
export class LaunchStore {
|
|
125
|
+
private readonly now: Clock
|
|
126
|
+
private readonly newID: () => string
|
|
127
|
+
private readonly endedKeepMs: number
|
|
128
|
+
private readonly endedMax: number
|
|
129
|
+
|
|
130
|
+
constructor(private readonly deps: LaunchStoreDeps) {
|
|
131
|
+
this.now = deps.now ?? Date.now
|
|
132
|
+
this.newID = deps.newID ?? (() => `l_${randomBytes(8).toString("hex")}`)
|
|
133
|
+
this.endedKeepMs = deps.endedKeepMs ?? ENDED_KEEP_MS
|
|
134
|
+
this.endedMax = deps.endedMax ?? ENDED_MAX
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
/**
|
|
138
|
+
* Stores a new queued launch, or (same key, equivalent body) returns the existing one with created false.
|
|
139
|
+
* Throws invalid (key reused with another body) or rate_limited (the key's launch is still being created).
|
|
140
|
+
*/
|
|
141
|
+
async create(userID: string, spec: LaunchSpec, options: { readonly by?: string } = {}): Promise<{ launch: Launch; created: boolean }> {
|
|
142
|
+
const id = this.newID()
|
|
143
|
+
if (spec.key !== undefined) {
|
|
144
|
+
const existing = await this.claimKey(userID, spec.key, id)
|
|
145
|
+
if (existing) {
|
|
146
|
+
if (launchBodyKey(existing.spec) !== launchBodyKey(spec)) {
|
|
147
|
+
throw new AgentError("invalid", `Launch key ${spec.key} was used for a different launch (${existing.id})`, {
|
|
148
|
+
hint: "Use a new key for a different launch, or resend the original body to get that launch",
|
|
149
|
+
})
|
|
150
|
+
}
|
|
151
|
+
return { launch: existing, created: false }
|
|
152
|
+
}
|
|
153
|
+
}
|
|
154
|
+
const at = this.now()
|
|
155
|
+
const launch: Launch = {
|
|
156
|
+
id,
|
|
157
|
+
owner: userID,
|
|
158
|
+
spec,
|
|
159
|
+
...(options.by !== undefined ? { by: options.by } : {}),
|
|
160
|
+
state: "queued",
|
|
161
|
+
reason: "Queued",
|
|
162
|
+
at,
|
|
163
|
+
created: at,
|
|
164
|
+
history: [{ state: "queued", reason: "Queued", at }],
|
|
165
|
+
}
|
|
166
|
+
const written = await this.deps.state.put(userID, LAUNCHES, id, launch, { ifVersion: 0 })
|
|
167
|
+
if (!written.ok) throw new Error(`launch id ${id} already exists`)
|
|
168
|
+
return { launch, created: true }
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
async get(userID: string, id: string): Promise<Launch | undefined> {
|
|
172
|
+
return (await this.deps.state.get<Launch>(userID, LAUNCHES, id))?.value
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
/** The user's launches by creation, filtered. */
|
|
176
|
+
async list(userID: string, filter: LaunchFilter = {}): Promise<Launch[]> {
|
|
177
|
+
const launches = (await this.deps.state.list<Launch>(userID, LAUNCHES)).map((entry) => entry.value)
|
|
178
|
+
return launches
|
|
179
|
+
.filter((launch) => !filter.states || filter.states.includes(launch.state))
|
|
180
|
+
.filter((launch) => !filter.labels || Object.entries(filter.labels).every(([key, value]) => launch.spec.labels[key] === value))
|
|
181
|
+
.sort(byCreation)
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
/** Moves the launch to `to`; throws not_found, LaunchTransitionError, or Error for a malformed step. */
|
|
185
|
+
async transition(userID: string, id: string, to: LaunchState, input: TransitionInput): Promise<Launch> {
|
|
186
|
+
const reason = cleanReason(input.reason)
|
|
187
|
+
const at = this.now()
|
|
188
|
+
const next = await updateVersioned<Launch>(this.deps.state, userID, LAUNCHES, id, (current) => {
|
|
189
|
+
if (!current) throw new AgentError("not_found", `No launch ${id}`)
|
|
190
|
+
if (!NEXT[current.state].includes(to) || (input.from && !input.from.includes(current.state))) throw new LaunchTransitionError(current.state, to)
|
|
191
|
+
checkDetails(to, input)
|
|
192
|
+
const step: LaunchStep = { state: to, reason, at, ...(input.outcome !== undefined ? { outcome: input.outcome } : {}) }
|
|
193
|
+
return {
|
|
194
|
+
...current,
|
|
195
|
+
state: to,
|
|
196
|
+
reason,
|
|
197
|
+
at,
|
|
198
|
+
...(input.outcome !== undefined ? { outcome: input.outcome } : {}),
|
|
199
|
+
...(input.machineID !== undefined ? { machineID: input.machineID } : {}),
|
|
200
|
+
...(input.sessionID !== undefined ? { sessionID: input.sessionID } : {}),
|
|
201
|
+
...(input.name !== undefined ? { name: input.name } : {}),
|
|
202
|
+
...(input.artifacts !== undefined ? { artifacts: input.artifacts } : {}),
|
|
203
|
+
history: bounded([...current.history, step]),
|
|
204
|
+
}
|
|
205
|
+
})
|
|
206
|
+
return next!
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
/** Removes ended launches past endedKeepMs or beyond the newest endedMax; resolves with the removed ids. */
|
|
210
|
+
async prune(userID: string): Promise<string[]> {
|
|
211
|
+
const { state } = this.deps
|
|
212
|
+
const ended = (await state.list<Launch>(userID, LAUNCHES)).filter((entry) => entry.value.state === "ended")
|
|
213
|
+
ended.sort((a, b) => b.value.at - a.value.at || byCreation(b.value, a.value))
|
|
214
|
+
const cutoff = this.now() - this.endedKeepMs
|
|
215
|
+
const removed: string[] = []
|
|
216
|
+
for (const [index, entry] of ended.entries()) {
|
|
217
|
+
if (index < this.endedMax && entry.value.at > cutoff) continue
|
|
218
|
+
const deleted = await state.delete(userID, LAUNCHES, entry.key, { ifVersion: entry.version })
|
|
219
|
+
if (!deleted.ok) continue
|
|
220
|
+
if (entry.value.spec.key !== undefined) await state.release(keyNamespace(userID), entry.value.spec.key, entry.value.id)
|
|
221
|
+
removed.push(entry.value.id)
|
|
222
|
+
}
|
|
223
|
+
return removed
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
// ---- internals ----------------------------------------------------------------------------------------
|
|
227
|
+
|
|
228
|
+
private async launchOf(userID: string, id: string): Promise<Launch | undefined> {
|
|
229
|
+
for (let read = 0; read < READS; read++) {
|
|
230
|
+
const launch = await this.get(userID, id)
|
|
231
|
+
if (launch) return launch
|
|
232
|
+
}
|
|
233
|
+
return undefined
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
/** Claims the key for `id`: undefined when claimed (create it), else the launch that holds it. */
|
|
237
|
+
private async claimKey(userID: string, key: string, id: string): Promise<Launch | undefined> {
|
|
238
|
+
const { state } = this.deps
|
|
239
|
+
const namespace = keyNamespace(userID)
|
|
240
|
+
for (let attempt = 0; attempt < CLAIMS; attempt++) {
|
|
241
|
+
const claimed = await state.claim(namespace, key, id)
|
|
242
|
+
if (claimed.ok) return undefined
|
|
243
|
+
const holder = claimed.conflict
|
|
244
|
+
const existing = await this.launchOf(userID, holder.owner)
|
|
245
|
+
if (existing) return existing
|
|
246
|
+
if (this.now() - holder.at < RESERVATION_MS) {
|
|
247
|
+
throw new AgentError("rate_limited", `Launch key ${key} is being created`, { extra: { retryAfter: 1 } })
|
|
248
|
+
}
|
|
249
|
+
// Its creator crashed between the claim and the write: take the key over.
|
|
250
|
+
await state.release(namespace, key, holder.owner)
|
|
251
|
+
}
|
|
252
|
+
throw new AgentError("rate_limited", `Launch key ${key} is contended`, { extra: { retryAfter: 1 } })
|
|
253
|
+
}
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
const keyNamespace = (userID: string) => `launch-keys:${userID}`
|
|
257
|
+
|
|
258
|
+
function checkDetails(to: LaunchState, input: TransitionInput): void {
|
|
259
|
+
if (to === "ended") {
|
|
260
|
+
if (!LAUNCH_OUTCOMES.includes(input.outcome as LaunchOutcome)) throw new Error(`Ending a launch needs an outcome: ${LAUNCH_OUTCOMES.join(", ")}`)
|
|
261
|
+
} else if (input.outcome !== undefined) throw new Error("Only an ended launch has an outcome")
|
|
262
|
+
if (to === "provisioning" && !input.machineID) throw new Error("Provisioning a launch needs its machine")
|
|
263
|
+
if (to === "running" && !input.sessionID) throw new Error("A running launch needs its session")
|
|
264
|
+
}
|
|
@@ -0,0 +1,206 @@
|
|
|
1
|
+
// Launch-bound tokens (phase 5, decision #6): the credential a harness inside a sandbox (OpenShell) uses to
|
|
2
|
+
// connect back to /spoke/ws for exactly one launch. The node passes it into the sandbox it provisions; it is
|
|
3
|
+
// accepted only while that launch is provisioning or running.
|
|
4
|
+
//
|
|
5
|
+
// Format: `hlt1.<b64url(payload JSON)>.<b64url(HMAC-SHA256(secret of kid, "hlt1." + payload part))>`, where the
|
|
6
|
+
// payload is {uid, mid, lid, exp (ms since the epoch), kid, ue (user epoch), me (machine epoch), aud (the hub's
|
|
7
|
+
// public URL)}. Signed with the same TokenKeys as session tokens (hst1); the prefix is part of the signed text,
|
|
8
|
+
// so neither kind can pass for the other.
|
|
9
|
+
//
|
|
10
|
+
// The verifier depends only on a LaunchLookup (state and machine of a user's launch), not on the launch engine;
|
|
11
|
+
// LaunchStore fits it as `{ launch: (userID, launchID) => launchStore.get(userID, launchID) }`. Holding a valid
|
|
12
|
+
// token is all this module checks: one connection per launch, and keeping launch-bound connections out of
|
|
13
|
+
// serviceFor/exposeTarget/launchService, are /spoke/ws's and the registry's job.
|
|
14
|
+
// Tokens and secrets are never logged and never appear in this object's JSON or inspect output.
|
|
15
|
+
import { createHmac, timingSafeEqual } from "node:crypto"
|
|
16
|
+
import type { Store } from "../store"
|
|
17
|
+
import type { TokenKeys } from "../token-keys"
|
|
18
|
+
import type { LaunchState } from "./launch-store"
|
|
19
|
+
|
|
20
|
+
export const LAUNCH_TOKEN_PREFIX = "hlt1"
|
|
21
|
+
/** How long a launch token is valid. */
|
|
22
|
+
export const LAUNCH_TOKEN_LIFETIME_MS = 24 * 60 * 60 * 1000
|
|
23
|
+
/** Longest launch token accepted (the same bound as session tokens). */
|
|
24
|
+
export const LAUNCH_TOKEN_MAX_CHARS = 4096
|
|
25
|
+
/** The launch states in which a launch token is minted and accepted. */
|
|
26
|
+
export const LAUNCH_TOKEN_STATES: readonly LaunchState[] = ["provisioning", "running"]
|
|
27
|
+
|
|
28
|
+
/** What the tokens need to know about a launch (implemented by the launch store). */
|
|
29
|
+
export interface LaunchView {
|
|
30
|
+
readonly state: LaunchState
|
|
31
|
+
/** The machine (node) it is placed on. */
|
|
32
|
+
readonly machineID?: string
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
/** Looks up a user's launch (undefined when the user has no such launch). */
|
|
36
|
+
export interface LaunchLookup {
|
|
37
|
+
launch(userID: string, launchID: string): Promise<LaunchView | undefined> | LaunchView | undefined
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
export interface LaunchTokensOptions {
|
|
41
|
+
readonly keys: TokenKeys
|
|
42
|
+
readonly store: Store
|
|
43
|
+
readonly launches: LaunchLookup
|
|
44
|
+
/** The hub's public URL: the tokens' audience. */
|
|
45
|
+
readonly publicURL: string
|
|
46
|
+
readonly now?: () => number
|
|
47
|
+
readonly lifetimeMs?: number
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
export interface MintedLaunchToken {
|
|
51
|
+
readonly token: string
|
|
52
|
+
/** Expiry, ms since the epoch. */
|
|
53
|
+
readonly exp: number
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
/** What a valid launch token is bound to. */
|
|
57
|
+
export interface LaunchBinding {
|
|
58
|
+
readonly userID: string
|
|
59
|
+
readonly machineID: string
|
|
60
|
+
readonly launchID: string
|
|
61
|
+
/** The token's expiry, ms since the epoch. */
|
|
62
|
+
readonly exp: number
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
/** The binding of a valid token, "expired" for a genuine token past its expiry, else undefined. */
|
|
66
|
+
export type LaunchVerifyResult = LaunchBinding | "expired" | undefined
|
|
67
|
+
|
|
68
|
+
interface Claims {
|
|
69
|
+
readonly uid: string
|
|
70
|
+
readonly mid: string
|
|
71
|
+
readonly lid: string
|
|
72
|
+
readonly exp: number
|
|
73
|
+
readonly kid: string
|
|
74
|
+
readonly ue: number
|
|
75
|
+
readonly me: number
|
|
76
|
+
readonly aud: string
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
const CLAIM_KEYS = ["uid", "mid", "lid", "exp", "kid", "ue", "me", "aud"].sort().join()
|
|
80
|
+
const B64URL = /^[A-Za-z0-9_-]+$/
|
|
81
|
+
/** HMAC-SHA256 output length. */
|
|
82
|
+
const SIG_BYTES = 32
|
|
83
|
+
|
|
84
|
+
const text = (v: unknown): v is string => typeof v === "string" && v.length > 0
|
|
85
|
+
const count = (v: unknown): v is number => typeof v === "number" && Number.isSafeInteger(v) && v >= 0
|
|
86
|
+
|
|
87
|
+
/** Canonical base64url only (no padding, no other alphabet, no non-zero trailing bits). */
|
|
88
|
+
function decode(part: string): Buffer | undefined {
|
|
89
|
+
if (!B64URL.test(part)) return undefined
|
|
90
|
+
const bytes = Buffer.from(part, "base64url")
|
|
91
|
+
return bytes.toString("base64url") === part ? bytes : undefined
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
function parseClaims(bytes: Buffer): Claims | undefined {
|
|
95
|
+
let raw: unknown
|
|
96
|
+
try {
|
|
97
|
+
raw = JSON.parse(bytes.toString("utf8"))
|
|
98
|
+
} catch {
|
|
99
|
+
return undefined
|
|
100
|
+
}
|
|
101
|
+
if (typeof raw !== "object" || raw === null || Array.isArray(raw)) return undefined
|
|
102
|
+
const c = raw as Record<string, unknown>
|
|
103
|
+
if (Object.keys(c).sort().join() !== CLAIM_KEYS) return undefined
|
|
104
|
+
if (!text(c.uid) || !text(c.mid) || !text(c.lid) || !text(c.kid) || !text(c.aud)) return undefined
|
|
105
|
+
if (!count(c.exp) || !count(c.ue) || !count(c.me)) return undefined
|
|
106
|
+
return c as unknown as Claims
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
const sign = (secret: Buffer, payload: string): Buffer => createHmac("sha256", secret).update(`${LAUNCH_TOKEN_PREFIX}.${payload}`).digest()
|
|
110
|
+
|
|
111
|
+
export class LaunchTokens {
|
|
112
|
+
readonly lifetimeMs: number
|
|
113
|
+
readonly #keys: TokenKeys
|
|
114
|
+
readonly #store: Store
|
|
115
|
+
readonly #launches: LaunchLookup
|
|
116
|
+
readonly #aud: string
|
|
117
|
+
readonly #now: () => number
|
|
118
|
+
|
|
119
|
+
constructor(options: LaunchTokensOptions) {
|
|
120
|
+
this.#keys = options.keys
|
|
121
|
+
this.#store = options.store
|
|
122
|
+
this.#launches = options.launches
|
|
123
|
+
this.#aud = options.publicURL
|
|
124
|
+
this.#now = options.now ?? Date.now
|
|
125
|
+
this.lifetimeMs = options.lifetimeMs ?? LAUNCH_TOKEN_LIFETIME_MS
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
/** The owner of an enrolled, unrevoked machine whose user exists and is enabled. */
|
|
129
|
+
#owner(machineID: string): string | undefined {
|
|
130
|
+
const machine = this.#store.machines().find((m) => m.id === machineID)
|
|
131
|
+
if (!machine || machine.revoked) return undefined
|
|
132
|
+
const user = this.#store.user(machine.ownerID)
|
|
133
|
+
return user && !user.disabled ? user.id : undefined
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
/** Whether the user's launch is provisioning or running on the machine (false when the lookup fails). */
|
|
137
|
+
async #live(userID: string, launchID: string, machineID: string): Promise<boolean> {
|
|
138
|
+
let launch: LaunchView | undefined
|
|
139
|
+
try {
|
|
140
|
+
launch = await this.#launches.launch(userID, launchID)
|
|
141
|
+
} catch {
|
|
142
|
+
return false
|
|
143
|
+
}
|
|
144
|
+
return launch !== undefined && LAUNCH_TOKEN_STATES.includes(launch.state) && launch.machineID === machineID
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
/**
|
|
148
|
+
* A token for a launch of the machine's owner that is provisioning or running on that machine (undefined when
|
|
149
|
+
* the machine is unknown, revoked or its owner disabled, or the launch is someone else's, elsewhere or not live).
|
|
150
|
+
*/
|
|
151
|
+
async mint(machineID: string, launchID: string): Promise<MintedLaunchToken | undefined> {
|
|
152
|
+
const uid = this.#owner(machineID)
|
|
153
|
+
if (!uid || !(await this.#live(uid, launchID, machineID))) return undefined
|
|
154
|
+
const { kid, secret } = this.#keys.active()
|
|
155
|
+
const exp = this.#now() + this.lifetimeMs
|
|
156
|
+
const claims: Claims = {
|
|
157
|
+
uid,
|
|
158
|
+
mid: machineID,
|
|
159
|
+
lid: launchID,
|
|
160
|
+
exp,
|
|
161
|
+
kid,
|
|
162
|
+
ue: this.#store.userEpoch(uid),
|
|
163
|
+
me: this.#store.machineEpoch(machineID),
|
|
164
|
+
aud: this.#aud,
|
|
165
|
+
}
|
|
166
|
+
const payload = Buffer.from(JSON.stringify(claims)).toString("base64url")
|
|
167
|
+
return { token: `${LAUNCH_TOKEN_PREFIX}.${payload}.${sign(secret, payload).toString("base64url")}`, exp }
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
/**
|
|
171
|
+
* What a token is bound to while all of this holds: signature (constant-time) under a known kid, audience,
|
|
172
|
+
* expiry, the user's and machine's current token epochs, the machine exists, is not revoked and belongs to uid,
|
|
173
|
+
* the user exists and is enabled, and uid's launch is provisioning or running on that machine.
|
|
174
|
+
*/
|
|
175
|
+
async verify(token: string): Promise<LaunchVerifyResult> {
|
|
176
|
+
if (typeof token !== "string" || token.length > LAUNCH_TOKEN_MAX_CHARS) return undefined
|
|
177
|
+
const parts = token.split(".")
|
|
178
|
+
if (parts.length !== 3 || parts[0] !== LAUNCH_TOKEN_PREFIX) return undefined
|
|
179
|
+
const [, payload, sigPart] = parts as [string, string, string]
|
|
180
|
+
const body = decode(payload)
|
|
181
|
+
const sig = decode(sigPart)
|
|
182
|
+
if (!body || !sig || sig.length !== SIG_BYTES) return undefined
|
|
183
|
+
const claims = parseClaims(body)
|
|
184
|
+
if (!claims) return undefined
|
|
185
|
+
const secret = this.#keys.key(claims.kid)
|
|
186
|
+
if (!secret) return undefined
|
|
187
|
+
const expected = sign(secret, payload)
|
|
188
|
+
if (expected.length !== sig.length || !timingSafeEqual(expected, sig)) return undefined
|
|
189
|
+
if (claims.aud !== this.#aud) return undefined
|
|
190
|
+
if (this.#now() >= claims.exp) return "expired"
|
|
191
|
+
// A shared store reads the user and machine again first (revocations and epoch bumps on other pods).
|
|
192
|
+
await this.#store.recheck({ users: [claims.uid], machines: [claims.mid] })
|
|
193
|
+
if (claims.ue !== this.#store.userEpoch(claims.uid) || claims.me !== this.#store.machineEpoch(claims.mid)) return undefined
|
|
194
|
+
if (this.#owner(claims.mid) !== claims.uid) return undefined
|
|
195
|
+
if (!(await this.#live(claims.uid, claims.lid, claims.mid))) return undefined
|
|
196
|
+
return { userID: claims.uid, machineID: claims.mid, launchID: claims.lid, exp: claims.exp }
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
toJSON(): { audience: string; lifetimeMs: number } {
|
|
200
|
+
return { audience: this.#aud, lifetimeMs: this.lifetimeMs }
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
[Symbol.for("nodejs.util.inspect.custom")](): string {
|
|
204
|
+
return `LaunchTokens(${this.#aud})`
|
|
205
|
+
}
|
|
206
|
+
}
|
|
@@ -0,0 +1,123 @@
|
|
|
1
|
+
// Loop protection for agent messages (design §3.4), over the backend interfaces with an injected clock.
|
|
2
|
+
//
|
|
3
|
+
// - HopTracker: every message carries a hop. A session's (or app's) depth is the deepest hop that reached it
|
|
4
|
+
// since its last human turn; what it sends carries depth + 1. The user sends hop 0, and a hop-0 message (or a
|
|
5
|
+
// human turn in the session) resets the depth. A hop over HOP_CAP is delivered without waking and the user
|
|
6
|
+
// is alerted (the delivery engine does both).
|
|
7
|
+
// - MessageLimiter: fixed windows aligned to the clock (so retryAfter is exact): messages per sender→recipient
|
|
8
|
+
// pair and per user (refused with rate_limited), and wakes per recipient (exhausted: deliver without waking).
|
|
9
|
+
import type { Clock, Counters, StateStore } from "../backend/types"
|
|
10
|
+
import { type Party, partyKey } from "./envelope"
|
|
11
|
+
import { AgentError } from "./errors"
|
|
12
|
+
import { updateVersioned } from "./versioned"
|
|
13
|
+
|
|
14
|
+
/** Hops a reply chain may take without a human turn. */
|
|
15
|
+
export const HOP_CAP = 8
|
|
16
|
+
/** A depth no message refreshed for this long is forgotten. */
|
|
17
|
+
export const HOP_TTL_MS = 24 * 60 * 60_000
|
|
18
|
+
|
|
19
|
+
export const overHopCap = (hop: number): boolean => hop > HOP_CAP
|
|
20
|
+
|
|
21
|
+
/** StateStore collection: partyKey → HopDepth. */
|
|
22
|
+
const HOPS = "message-hops"
|
|
23
|
+
|
|
24
|
+
interface HopDepth {
|
|
25
|
+
readonly depth: number
|
|
26
|
+
readonly at: number
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
export class HopTracker {
|
|
30
|
+
private readonly state: StateStore
|
|
31
|
+
private readonly now: Clock
|
|
32
|
+
|
|
33
|
+
constructor(deps: { readonly state: StateStore; readonly now?: Clock }) {
|
|
34
|
+
this.state = deps.state
|
|
35
|
+
this.now = deps.now ?? Date.now
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
/** The hop a new message from `from` carries. */
|
|
39
|
+
async next(userID: string, from: Party): Promise<number> {
|
|
40
|
+
return from.kind === "user" ? 0 : (await this.depth(userID, from)) + 1
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
/** The deepest hop that reached the party since its last human turn (0: none, or forgotten). */
|
|
44
|
+
async depth(userID: string, party: Party): Promise<number> {
|
|
45
|
+
return this.live((await this.state.get<HopDepth>(userID, HOPS, partyKey(party)))?.value)
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
/** A message with `hop` reached the party. */
|
|
49
|
+
async received(userID: string, to: Party, hop: number): Promise<void> {
|
|
50
|
+
if (to.kind === "user") return
|
|
51
|
+
const at = this.now()
|
|
52
|
+
await updateVersioned<HopDepth>(this.state, userID, HOPS, partyKey(to), (current) => ({ depth: hop === 0 ? 0 : Math.max(this.live(current), hop), at }))
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
/** A human turn in the session (console prompt, voice, typed in the harness): the chain starts over. */
|
|
56
|
+
async humanTurn(userID: string, sessionID: string): Promise<void> {
|
|
57
|
+
await this.state.delete(userID, HOPS, partyKey({ kind: "session", sessionID }))
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
private live(entry: HopDepth | undefined): number {
|
|
61
|
+
return entry && this.now() - entry.at < HOP_TTL_MS ? entry.depth : 0
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
/** The length of every limiter window. */
|
|
66
|
+
export const WINDOW_MS = 10 * 60_000
|
|
67
|
+
/** Messages per sender→recipient pair per window. */
|
|
68
|
+
export const PAIR_LIMIT = 20
|
|
69
|
+
/** Messages per user per window, across all pairs. */
|
|
70
|
+
export const USER_LIMIT = 300
|
|
71
|
+
/** Wakes per recipient per window. */
|
|
72
|
+
export const WAKE_BUDGET = 30
|
|
73
|
+
|
|
74
|
+
export interface MessageLimits {
|
|
75
|
+
readonly pair: number
|
|
76
|
+
readonly user: number
|
|
77
|
+
readonly wake: number
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
export class MessageLimiter {
|
|
81
|
+
private readonly counters: Counters
|
|
82
|
+
private readonly now: Clock
|
|
83
|
+
private readonly limits: MessageLimits
|
|
84
|
+
|
|
85
|
+
constructor(deps: { readonly counters: Counters; readonly now?: Clock; readonly limits?: Partial<MessageLimits> }) {
|
|
86
|
+
this.counters = deps.counters
|
|
87
|
+
this.now = deps.now ?? Date.now
|
|
88
|
+
this.limits = { pair: PAIR_LIMIT, user: USER_LIMIT, wake: WAKE_BUDGET, ...deps.limits }
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
/** Counts a message from → to; throws rate_limited ({retryAfter, limit}) when a cap is reached (not counted). */
|
|
92
|
+
async admit(userID: string, from: Party, to: Party): Promise<void> {
|
|
93
|
+
const window = this.window()
|
|
94
|
+
const keys = [
|
|
95
|
+
{ limit: "pair" as const, key: `msg:pair:${userID}:${partyKey(from)}:${partyKey(to)}:${window.index}`, max: this.limits.pair },
|
|
96
|
+
{ limit: "user" as const, key: `msg:user:${userID}:${window.index}`, max: this.limits.user },
|
|
97
|
+
]
|
|
98
|
+
for (const { limit, key, max } of keys) {
|
|
99
|
+
if ((await this.counters.get(key)) >= max) {
|
|
100
|
+
throw new AgentError("rate_limited", `Too many messages (${limit} limit)`, {
|
|
101
|
+
hint: "Wait and try again",
|
|
102
|
+
extra: { retryAfter: Math.ceil(window.remaining / 1000), limit },
|
|
103
|
+
})
|
|
104
|
+
}
|
|
105
|
+
}
|
|
106
|
+
for (const { key } of keys) await this.counters.incr(key, window.remaining)
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
/** Uses one of the recipient's wakes; false when the budget is spent (deliver without waking). */
|
|
110
|
+
async wake(userID: string, to: Party): Promise<boolean> {
|
|
111
|
+
const window = this.window()
|
|
112
|
+
const key = `msg:wake:${userID}:${partyKey(to)}:${window.index}`
|
|
113
|
+
if ((await this.counters.get(key)) >= this.limits.wake) return false
|
|
114
|
+
await this.counters.incr(key, window.remaining)
|
|
115
|
+
return true
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
private window(): { index: number; remaining: number } {
|
|
119
|
+
const t = this.now()
|
|
120
|
+
const index = Math.floor(t / WINDOW_MS)
|
|
121
|
+
return { index, remaining: (index + 1) * WINDOW_MS - t }
|
|
122
|
+
}
|
|
123
|
+
}
|