@huynle/hub 0.6.1 → 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,530 @@
|
|
|
1
|
+
// Decisions (design §12.1; exploration iw6kfmxg Security): what an ask to the user (`@me`) opens. No routes: the
|
|
2
|
+
// agent message routes (ask @me) and the console routes (/api/decisions) call it.
|
|
3
|
+
//
|
|
4
|
+
// - Stored form: {id, messageID, from, about, sessionID?, question, options, recommendation?, urgency, tier,
|
|
5
|
+
// deadline?, onTimeout?, state, at, expiresAt, escalatedAt?, answer?}, per user in the StateStore. The ask
|
|
6
|
+
// message (stored by the delivery engine, recipient the user) carries it: it closes when the Decision is
|
|
7
|
+
// answered, and the Decision expires with it.
|
|
8
|
+
// - Only the user answers (never a session or app, never the asker); `tap` Decisions only from a signed-in
|
|
9
|
+
// console (the device cookie, not a device token). The first answer wins (a version-checked write); later
|
|
10
|
+
// ones get `gone`. The answer goes back to the asker as the user's reply to the ask.
|
|
11
|
+
// - Deadlines: at the deadline, `onTimeout` (only an option marked safe) answers it, recorded answeredBy
|
|
12
|
+
// "timeout"; without one the Decision stays open and escalates once (notified again). The loop (start/tick)
|
|
13
|
+
// looks at every user's open Decisions, so deadlines survive restarts.
|
|
14
|
+
// - Text is sanitised (question by the message store; labels, ids and reasons here). Notifications carry only
|
|
15
|
+
// the Decision (the push layer sends a generic body).
|
|
16
|
+
// - Actions (P3-T8, P3-T9): the hub (never an asker) may attach an action (an escalated permission request, a grant
|
|
17
|
+
// request); the user's answer runs it through `act` as that user after the answer wins (its note, e.g. the grant
|
|
18
|
+
// id, joins the asker's reply). Its subject gone → the Decision closes and the answer is gone; it fails → the
|
|
19
|
+
// Decision opens again.
|
|
20
|
+
// - close(): a Decision whose subject went away (a permission answered elsewhere) is closed, not answered; the asker
|
|
21
|
+
// hears why.
|
|
22
|
+
// - Every open and change (answer, timeout, escalation, expiry, close) goes to `changed` (the console's 'decision' event).
|
|
23
|
+
import type { Clock, StateStore, Timers } from "../backend/types"
|
|
24
|
+
import type { AgentActor, UserActor } from "./actor"
|
|
25
|
+
import type { Delivery } from "./delivery"
|
|
26
|
+
import { type Party, partyKey, sanitizeLabel, sanitizeText } from "./envelope"
|
|
27
|
+
import { AgentError } from "./errors"
|
|
28
|
+
import { ASK_EXPIRES_MAX_MS, ASK_EXPIRES_MIN_MS, ASK_EXPIRES_MS, type Message, newMessageID } from "./messages"
|
|
29
|
+
import { updateVersioned } from "./versioned"
|
|
30
|
+
|
|
31
|
+
export type Urgency = "low" | "normal" | "high"
|
|
32
|
+
/** confirm: any of the user's signed-in devices; tap: only a signed-in console (cookie). */
|
|
33
|
+
export type Tier = "confirm" | "tap"
|
|
34
|
+
/** closed: ended without an answer because what it asked about went away (answered elsewhere, ended). */
|
|
35
|
+
export type DecisionState = "open" | "answered" | "expired" | "closed"
|
|
36
|
+
|
|
37
|
+
export const URGENCIES: readonly Urgency[] = ["low", "normal", "high"]
|
|
38
|
+
export const TIERS: readonly Tier[] = ["confirm", "tap"]
|
|
39
|
+
export const DECISION_STATES: readonly DecisionState[] = ["open", "answered", "expired", "closed"]
|
|
40
|
+
|
|
41
|
+
/** Options a Decision offers. */
|
|
42
|
+
export const OPTIONS_MAX = 8
|
|
43
|
+
/** Characters of an option label. */
|
|
44
|
+
export const OPTION_LABEL_MAX = 80
|
|
45
|
+
/** Characters of a recommendation's reason. */
|
|
46
|
+
export const WHY_MAX = 1_000
|
|
47
|
+
/** Characters of the user's answer text (a note with an option, or the free-text answer). */
|
|
48
|
+
export const ANSWER_MAX = 4_000
|
|
49
|
+
/** Decisions each user keeps (the oldest closed ones go first). */
|
|
50
|
+
export const DECISIONS_MAX = 500
|
|
51
|
+
/** How often the loop looks for deadlines. */
|
|
52
|
+
export const DECISION_TICK_MS = 30_000
|
|
53
|
+
|
|
54
|
+
const OPTION_ID = /^[a-z0-9][a-z0-9_-]{0,31}$/
|
|
55
|
+
|
|
56
|
+
export interface DecisionOption {
|
|
57
|
+
readonly id: string
|
|
58
|
+
readonly label: string
|
|
59
|
+
/** onTimeout may pick only a safe option. */
|
|
60
|
+
readonly safe?: true
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
export interface DecisionAnswer {
|
|
64
|
+
readonly option?: string
|
|
65
|
+
readonly text?: string
|
|
66
|
+
readonly answeredBy: "user" | "timeout"
|
|
67
|
+
readonly at: number
|
|
68
|
+
/** The device the user answered from. */
|
|
69
|
+
readonly deviceID?: string
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
/** What answering a Decision does besides replying: an escalated permission request (P3-T8) or a grant request (P3-T9). */
|
|
73
|
+
export type DecisionAction = PermissionAction | GrantRequestAction
|
|
74
|
+
|
|
75
|
+
/** An escalated permission request: the answer replies to it as the user. */
|
|
76
|
+
export interface PermissionAction {
|
|
77
|
+
readonly kind: "permission"
|
|
78
|
+
readonly requestID: string
|
|
79
|
+
/** The tree the request is pending in. */
|
|
80
|
+
readonly rootID: string
|
|
81
|
+
/** The session that asked. */
|
|
82
|
+
readonly sessionID: string
|
|
83
|
+
/** The agent asked to approve it always. */
|
|
84
|
+
readonly always: boolean
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
/** A grant an agent requested (grant-requests.ts): approving creates it as the user. */
|
|
88
|
+
export interface GrantRequestAction {
|
|
89
|
+
readonly kind: "grant"
|
|
90
|
+
/** The requester (partyKey). */
|
|
91
|
+
readonly requester: string
|
|
92
|
+
/** The grant body (validated when requested; validated again, as the user's, on approval). */
|
|
93
|
+
readonly grant: Readonly<Record<string, unknown>>
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
export interface Decision {
|
|
97
|
+
readonly id: string
|
|
98
|
+
/** The ask (to @me) that carries it. */
|
|
99
|
+
readonly messageID: string
|
|
100
|
+
/** The asker (a session or an app). */
|
|
101
|
+
readonly from: Party
|
|
102
|
+
/** Session IDs it concerns (default: the asking session). */
|
|
103
|
+
readonly about: readonly string[]
|
|
104
|
+
/** The session a notification opens (about[0]). */
|
|
105
|
+
readonly sessionID?: string
|
|
106
|
+
readonly question: string
|
|
107
|
+
readonly options: readonly DecisionOption[]
|
|
108
|
+
readonly recommendation?: { readonly option: string; readonly why?: string }
|
|
109
|
+
readonly urgency: Urgency
|
|
110
|
+
readonly tier: Tier
|
|
111
|
+
readonly deadline?: number
|
|
112
|
+
readonly onTimeout?: string
|
|
113
|
+
readonly state: DecisionState
|
|
114
|
+
readonly at: number
|
|
115
|
+
/** When its ask expires (the Decision with it). */
|
|
116
|
+
readonly expiresAt: number
|
|
117
|
+
/** When it passed its deadline without a safe onTimeout (notified again). */
|
|
118
|
+
readonly escalatedAt?: number
|
|
119
|
+
readonly answer?: DecisionAnswer
|
|
120
|
+
/** Why it closed without an answer (state closed). */
|
|
121
|
+
readonly closed?: { readonly at: number; readonly reason: string }
|
|
122
|
+
/** Run (as the answering user) when the user answers; set by the hub only, never by an asker. */
|
|
123
|
+
readonly action?: DecisionAction
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
/** What the hub (never an asker's request) adds to an ask. */
|
|
127
|
+
export interface AskExtra {
|
|
128
|
+
readonly action?: DecisionAction
|
|
129
|
+
/** Open it without the "opened" notification (coalesced pushes). */
|
|
130
|
+
readonly quiet?: boolean
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
export interface AskInput {
|
|
134
|
+
readonly question: string
|
|
135
|
+
readonly options?: readonly { readonly id: string; readonly label: string; readonly safe?: boolean }[]
|
|
136
|
+
readonly recommendation?: { readonly option: string; readonly why?: string }
|
|
137
|
+
readonly urgency?: Urgency
|
|
138
|
+
readonly tier?: Tier
|
|
139
|
+
/** From now. */
|
|
140
|
+
readonly deadlineMs?: number
|
|
141
|
+
readonly onTimeout?: string
|
|
142
|
+
/** Session IDs (resolved by the caller, the user's own). */
|
|
143
|
+
readonly about?: readonly string[]
|
|
144
|
+
/** The ask's lifetime (default: a day, or the deadline plus a day; at most ASK_EXPIRES_MAX_MS). */
|
|
145
|
+
readonly expiresInMs?: number
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
export interface AnswerInput {
|
|
149
|
+
readonly option?: string
|
|
150
|
+
readonly text?: string
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
export type NotifyReason = "opened" | "escalated"
|
|
154
|
+
|
|
155
|
+
/**
|
|
156
|
+
* What running an action came to: done (optionally with a note appended to the asker's reply), or gone when its
|
|
157
|
+
* subject went away first (optionally with the reason the Decision closes with).
|
|
158
|
+
*/
|
|
159
|
+
export type ActOutcome = "done" | "gone" | { readonly outcome: "done"; readonly note: string } | { readonly outcome: "gone"; readonly reason: string }
|
|
160
|
+
|
|
161
|
+
/** Runs a Decision's action for the user's choice. */
|
|
162
|
+
export type DecisionActor = (userID: string, decision: Decision, choice: { readonly option?: string; readonly text?: string }, by: UserActor) => Promise<ActOutcome>
|
|
163
|
+
|
|
164
|
+
export interface DecisionsDeps {
|
|
165
|
+
readonly state: StateStore
|
|
166
|
+
readonly delivery: Pick<Delivery, "send" | "reply">
|
|
167
|
+
readonly now?: Clock
|
|
168
|
+
readonly timers?: Timers
|
|
169
|
+
readonly newID?: () => string
|
|
170
|
+
/** The users whose Decisions the deadline loop looks at. */
|
|
171
|
+
readonly users?: () => Iterable<string>
|
|
172
|
+
/** How often the deadline loop runs (default DECISION_TICK_MS). */
|
|
173
|
+
readonly tickMs?: number
|
|
174
|
+
/** A Decision the user should see (opened, or past its deadline without a safe onTimeout). */
|
|
175
|
+
readonly notify?: (userID: string, decision: Decision, reason: NotifyReason) => void
|
|
176
|
+
/** A Decision opened or changed (answered, timed out, escalated, expired): the console's live event. Never throws through. */
|
|
177
|
+
readonly changed?: (userID: string, decision: Decision) => void
|
|
178
|
+
/** Runs Decisions' actions (escalations); a Decision with an action cannot be answered without it. */
|
|
179
|
+
readonly act?: DecisionActor
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
const DECISIONS = "decisions"
|
|
183
|
+
const BY_MESSAGE = "decision-by-message"
|
|
184
|
+
|
|
185
|
+
const defaultTimers: Timers = {
|
|
186
|
+
set: (fn, ms) => {
|
|
187
|
+
const handle = setTimeout(fn, ms)
|
|
188
|
+
;(handle as { unref?: () => void }).unref?.()
|
|
189
|
+
return handle
|
|
190
|
+
},
|
|
191
|
+
clear: (handle) => clearTimeout(handle as ReturnType<typeof setTimeout>),
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
/** `dec_` + 22 base62 characters from the CSPRNG. */
|
|
195
|
+
export const newDecisionID = (): string => `dec_${newMessageID().slice(4)}`
|
|
196
|
+
|
|
197
|
+
/** The hub replying for the user (timeouts, closes). */
|
|
198
|
+
const HUB_USER = (userID: string): UserActor => ({ kind: "user", userID, deviceID: "hub", via: "cookie" })
|
|
199
|
+
|
|
200
|
+
const invalid = (message: string) => new AgentError("invalid", message)
|
|
201
|
+
const gone = () => new AgentError("gone", "The decision is already answered or expired")
|
|
202
|
+
const notFound = () => new AgentError("not_found", "No such decision")
|
|
203
|
+
|
|
204
|
+
function cleanLabel(value: unknown, max: number, what: string): string {
|
|
205
|
+
if (typeof value !== "string" || value.length > max * 4) throw invalid(`${what} must be a string of at most ${max} characters`)
|
|
206
|
+
const clean = sanitizeLabel(value, max)
|
|
207
|
+
if (!clean) throw invalid(`${what} is empty`)
|
|
208
|
+
return clean
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
function cleanText(value: unknown, max: number, what: string): string {
|
|
212
|
+
if (typeof value !== "string" || value.length > max) throw invalid(`${what} must be a string of at most ${max} characters`)
|
|
213
|
+
const clean = sanitizeText(value).trim()
|
|
214
|
+
if (!clean) throw invalid(`${what} is empty`)
|
|
215
|
+
return clean
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
/** The validated, sanitised parts of a Decision (everything but what the ask message gives). */
|
|
219
|
+
function validate(input: AskInput) {
|
|
220
|
+
const raw = input.options ?? []
|
|
221
|
+
if (!Array.isArray(raw) || raw.length > OPTIONS_MAX) throw invalid(`A decision offers at most ${OPTIONS_MAX} options`)
|
|
222
|
+
const options: DecisionOption[] = []
|
|
223
|
+
for (const option of raw) {
|
|
224
|
+
if (typeof option?.id !== "string" || !OPTION_ID.test(option.id)) throw invalid("Option ids are 1–32 lowercase letters, digits, - or _")
|
|
225
|
+
if (options.some((o) => o.id === option.id)) throw invalid(`Option ${option.id} is given twice`)
|
|
226
|
+
options.push({ id: option.id, label: cleanLabel(option.label, OPTION_LABEL_MAX, "An option label"), ...(option.safe === true ? { safe: true as const } : {}) })
|
|
227
|
+
}
|
|
228
|
+
const has = (id: string) => options.some((o) => o.id === id)
|
|
229
|
+
let recommendation: Decision["recommendation"]
|
|
230
|
+
if (input.recommendation !== undefined) {
|
|
231
|
+
const { option, why } = input.recommendation
|
|
232
|
+
if (typeof option !== "string" || !has(option)) throw invalid("The recommendation must name one of the options")
|
|
233
|
+
recommendation = { option, ...(why === undefined ? {} : { why: cleanLabel(why, WHY_MAX, "The recommendation's reason") }) }
|
|
234
|
+
}
|
|
235
|
+
const urgency = input.urgency ?? "normal"
|
|
236
|
+
if (!URGENCIES.includes(urgency)) throw invalid(`urgency is one of ${URGENCIES.join(", ")}`)
|
|
237
|
+
const tier = input.tier ?? "confirm"
|
|
238
|
+
if (!TIERS.includes(tier)) throw invalid(`tier is one of ${TIERS.join(", ")}`)
|
|
239
|
+
const { deadlineMs, onTimeout } = input
|
|
240
|
+
if (deadlineMs !== undefined && !(Number.isFinite(deadlineMs) && deadlineMs >= ASK_EXPIRES_MIN_MS && deadlineMs <= ASK_EXPIRES_MAX_MS)) {
|
|
241
|
+
throw invalid(`A deadline is 1 minute to ${ASK_EXPIRES_MAX_MS / 86_400_000} days away`)
|
|
242
|
+
}
|
|
243
|
+
if (onTimeout !== undefined) {
|
|
244
|
+
if (deadlineMs === undefined) throw invalid("onTimeout needs a deadline")
|
|
245
|
+
if (!options.some((o) => o.id === onTimeout && o.safe)) throw invalid("onTimeout may pick only an option marked safe")
|
|
246
|
+
}
|
|
247
|
+
const expiresInMs = input.expiresInMs ?? Math.min(ASK_EXPIRES_MAX_MS, Math.max(ASK_EXPIRES_MS, (deadlineMs ?? 0) + ASK_EXPIRES_MS))
|
|
248
|
+
if (deadlineMs !== undefined && deadlineMs > expiresInMs) throw invalid("The deadline is after the ask expires")
|
|
249
|
+
return { options, recommendation, urgency, tier, deadlineMs, onTimeout, expiresInMs }
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
export class Decisions {
|
|
253
|
+
private readonly deps: DecisionsDeps
|
|
254
|
+
private readonly now: Clock
|
|
255
|
+
private readonly timers: Timers
|
|
256
|
+
private readonly newID: () => string
|
|
257
|
+
private timer: unknown
|
|
258
|
+
private running = false
|
|
259
|
+
|
|
260
|
+
constructor(deps: DecisionsDeps) {
|
|
261
|
+
this.deps = deps
|
|
262
|
+
this.now = deps.now ?? Date.now
|
|
263
|
+
this.timers = deps.timers ?? defaultTimers
|
|
264
|
+
this.newID = deps.newID ?? newDecisionID
|
|
265
|
+
}
|
|
266
|
+
|
|
267
|
+
/**
|
|
268
|
+
* Opens a Decision: stores the ask to @me (through the delivery engine: rate limits, sanitising) and the
|
|
269
|
+
* Decision, then notifies. Throws invalid (input; the user as asker), rate_limited, forbidden.
|
|
270
|
+
*/
|
|
271
|
+
async ask(userID: string, actor: AgentActor, input: AskInput, extra: AskExtra = {}): Promise<{ message: Message; decision: Decision }> {
|
|
272
|
+
if (actor.kind === "user") throw invalid("You are @me: the user cannot ask themselves")
|
|
273
|
+
if (typeof input.question !== "string") throw invalid("question must be a string")
|
|
274
|
+
const v = validate(input)
|
|
275
|
+
const about = [...new Set(input.about ?? (actor.kind === "session" ? [actor.sessionID] : []))]
|
|
276
|
+
const { message } = await this.deps.delivery.send(userID, actor, "@me", "ask", input.question, { expiresInMs: v.expiresInMs })
|
|
277
|
+
const decision: Decision = {
|
|
278
|
+
id: this.newID(),
|
|
279
|
+
messageID: message.id,
|
|
280
|
+
from: message.from,
|
|
281
|
+
about,
|
|
282
|
+
...(about[0] ? { sessionID: about[0] } : {}),
|
|
283
|
+
question: message.text,
|
|
284
|
+
options: v.options,
|
|
285
|
+
...(v.recommendation ? { recommendation: v.recommendation } : {}),
|
|
286
|
+
urgency: v.urgency,
|
|
287
|
+
tier: v.tier,
|
|
288
|
+
...(v.deadlineMs === undefined ? {} : { deadline: message.at + v.deadlineMs }),
|
|
289
|
+
...(v.onTimeout === undefined ? {} : { onTimeout: v.onTimeout }),
|
|
290
|
+
state: "open",
|
|
291
|
+
at: message.at,
|
|
292
|
+
expiresAt: message.expiresAt ?? message.at + v.expiresInMs,
|
|
293
|
+
...(extra.action ? { action: extra.action } : {}),
|
|
294
|
+
}
|
|
295
|
+
const written = await this.deps.state.put(userID, DECISIONS, decision.id, decision, { ifVersion: 0 })
|
|
296
|
+
if (!written.ok) throw new Error("decision id collision")
|
|
297
|
+
await this.deps.state.put(userID, BY_MESSAGE, message.id, decision.id)
|
|
298
|
+
await this.trim(userID)
|
|
299
|
+
if (!extra.quiet) this.deps.notify?.(userID, decision, "opened")
|
|
300
|
+
this.emit(userID, decision)
|
|
301
|
+
return { message, decision }
|
|
302
|
+
}
|
|
303
|
+
|
|
304
|
+
async get(userID: string, id: string): Promise<Decision | undefined> {
|
|
305
|
+
const stored = await this.deps.state.get<Decision>(userID, DECISIONS, id)
|
|
306
|
+
return stored && this.view(stored.value)
|
|
307
|
+
}
|
|
308
|
+
|
|
309
|
+
/** The Decision an ask to @me carries. */
|
|
310
|
+
async byMessage(userID: string, messageID: string): Promise<Decision | undefined> {
|
|
311
|
+
const id = (await this.deps.state.get<string>(userID, BY_MESSAGE, messageID))?.value
|
|
312
|
+
return id === undefined ? undefined : this.get(userID, id)
|
|
313
|
+
}
|
|
314
|
+
|
|
315
|
+
/** The user's Decisions, newest first; `from`: only those this party asked. */
|
|
316
|
+
async list(userID: string, filter: { readonly state?: DecisionState; readonly from?: Party } = {}): Promise<Decision[]> {
|
|
317
|
+
const from = filter.from && partyKey(filter.from)
|
|
318
|
+
return (await this.deps.state.list<Decision>(userID, DECISIONS))
|
|
319
|
+
.map((entry) => this.view(entry.value))
|
|
320
|
+
.filter((d) => (filter.state === undefined || d.state === filter.state) && (from === undefined || partyKey(d.from) === from))
|
|
321
|
+
.sort((a, b) => b.at - a.at || (a.id < b.id ? 1 : -1))
|
|
322
|
+
}
|
|
323
|
+
|
|
324
|
+
/**
|
|
325
|
+
* The user answers: an option (with an optional note), or text for a Decision without options. The first
|
|
326
|
+
* answer wins; then the asker gets the user's reply. Throws not_found, gone, forbidden (tap without the
|
|
327
|
+
* console cookie) or invalid.
|
|
328
|
+
*/
|
|
329
|
+
async answer(userID: string, id: string, input: AnswerInput, by: UserActor): Promise<{ decision: Decision; reply?: Message }> {
|
|
330
|
+
const current = await this.get(userID, id)
|
|
331
|
+
if (!current || by.userID !== userID) throw notFound()
|
|
332
|
+
if (current.state !== "open") throw gone()
|
|
333
|
+
if (current.tier === "tap" && by.via !== "cookie") throw new AgentError("forbidden", "This decision needs a tap in the signed-in console", { hint: "Answer it in the console" })
|
|
334
|
+
const choice = this.choice(current, input)
|
|
335
|
+
const at = this.now()
|
|
336
|
+
const answer: DecisionAnswer = { ...choice, answeredBy: "user", at, deviceID: by.deviceID }
|
|
337
|
+
const decision = await this.settle(userID, id, answer)
|
|
338
|
+
if (!decision) throw gone()
|
|
339
|
+
const note = decision.action ? await this.runAction(userID, decision, choice, by) : undefined
|
|
340
|
+
this.emit(userID, decision)
|
|
341
|
+
const option = decision.options.find((o) => o.id === choice.option)
|
|
342
|
+
const said = option ? `Decision ${id}: ${option.label} [${option.id}]${choice.text ? `\n${choice.text}` : ""}` : `Decision ${id}: ${choice.text}`
|
|
343
|
+
const text = note ? `${said}\n${note}` : said
|
|
344
|
+
return { decision, ...(await this.replyTo(userID, decision, text, by)) }
|
|
345
|
+
}
|
|
346
|
+
|
|
347
|
+
/**
|
|
348
|
+
* Closes an open Decision without an answer (what it asked about went away: answered elsewhere, ended) and tells
|
|
349
|
+
* the asker why. Undefined (no change) when it is not open, unknown or another user's.
|
|
350
|
+
*/
|
|
351
|
+
async close(userID: string, id: string, reason: string): Promise<Decision | undefined> {
|
|
352
|
+
const why = sanitizeLabel(reason, WHY_MAX) || "Closed"
|
|
353
|
+
const closed = await updateVersioned<Decision>(this.deps.state, userID, DECISIONS, id, (current) =>
|
|
354
|
+
current && this.view(current).state === "open" ? { ...current, state: "closed", closed: { at: this.now(), reason: why } } : undefined,
|
|
355
|
+
)
|
|
356
|
+
if (!closed) return undefined
|
|
357
|
+
this.emit(userID, closed)
|
|
358
|
+
await this.replyTo(userID, closed, `Decision ${id} closed: ${why}`, HUB_USER(userID))
|
|
359
|
+
return closed
|
|
360
|
+
}
|
|
361
|
+
|
|
362
|
+
/** Replaces an open Decision's recommendation (one of its options); undefined when it is not open. */
|
|
363
|
+
async revise(userID: string, id: string, input: { readonly recommendation: { readonly option: string; readonly why?: string } }): Promise<Decision | undefined> {
|
|
364
|
+
const current = await this.get(userID, id)
|
|
365
|
+
if (!current || current.state !== "open") return undefined
|
|
366
|
+
const { option, why } = input.recommendation
|
|
367
|
+
if (typeof option !== "string" || !current.options.some((o) => o.id === option)) throw invalid("The recommendation must name one of the options")
|
|
368
|
+
const recommendation = { option, ...(why === undefined ? {} : { why: cleanLabel(why, WHY_MAX, "The recommendation's reason") }) }
|
|
369
|
+
const revised = await updateVersioned<Decision>(this.deps.state, userID, DECISIONS, id, (stored) =>
|
|
370
|
+
stored && this.view(stored).state === "open" ? { ...stored, recommendation } : undefined,
|
|
371
|
+
)
|
|
372
|
+
if (revised) this.emit(userID, revised)
|
|
373
|
+
return revised
|
|
374
|
+
}
|
|
375
|
+
|
|
376
|
+
/** Starts the deadline loop (every DECISION_TICK_MS). */
|
|
377
|
+
start(): void {
|
|
378
|
+
if (this.running) return
|
|
379
|
+
this.running = true
|
|
380
|
+
this.schedule()
|
|
381
|
+
}
|
|
382
|
+
|
|
383
|
+
stop(): void {
|
|
384
|
+
this.running = false
|
|
385
|
+
if (this.timer !== undefined) this.timers.clear(this.timer)
|
|
386
|
+
this.timer = undefined
|
|
387
|
+
}
|
|
388
|
+
|
|
389
|
+
/** One pass of the deadline loop over every user (the loop calls it; tests may too). */
|
|
390
|
+
async tick(): Promise<void> {
|
|
391
|
+
for (const userID of this.deps.users?.() ?? []) {
|
|
392
|
+
try {
|
|
393
|
+
await this.due(userID)
|
|
394
|
+
} catch (cause) {
|
|
395
|
+
console.error(`[hub] decision deadlines failed: ${cause instanceof Error ? cause.message : String(cause)}`)
|
|
396
|
+
}
|
|
397
|
+
}
|
|
398
|
+
}
|
|
399
|
+
|
|
400
|
+
// ---- internals ----------------------------------------------------------------------------------------
|
|
401
|
+
|
|
402
|
+
/** Tells the changed listener (its failure never fails the caller). */
|
|
403
|
+
private emit(userID: string, decision: Decision): void {
|
|
404
|
+
try {
|
|
405
|
+
this.deps.changed?.(userID, decision)
|
|
406
|
+
} catch (cause) {
|
|
407
|
+
console.error(`[hub] decision ${decision.id}: change listener failed: ${cause instanceof Error ? cause.message : String(cause)}`)
|
|
408
|
+
}
|
|
409
|
+
}
|
|
410
|
+
|
|
411
|
+
private schedule(): void {
|
|
412
|
+
this.timer = this.timers.set(() => {
|
|
413
|
+
this.timer = undefined
|
|
414
|
+
void this.tick().finally(() => {
|
|
415
|
+
if (this.running && this.timer === undefined) this.schedule()
|
|
416
|
+
})
|
|
417
|
+
}, this.deps.tickMs ?? DECISION_TICK_MS)
|
|
418
|
+
}
|
|
419
|
+
|
|
420
|
+
/** The Decision as of now (an open one past its ask's expiry reads expired). */
|
|
421
|
+
private view(decision: Decision): Decision {
|
|
422
|
+
return decision.state === "open" && this.now() >= decision.expiresAt ? { ...decision, state: "expired" } : decision
|
|
423
|
+
}
|
|
424
|
+
|
|
425
|
+
private choice(decision: Decision, input: AnswerInput): { option?: string; text?: string } {
|
|
426
|
+
const text = input.text === undefined ? undefined : cleanText(input.text, ANSWER_MAX, "The answer text")
|
|
427
|
+
if (!decision.options.length) {
|
|
428
|
+
if (input.option !== undefined) throw invalid("This decision has no options: answer with text")
|
|
429
|
+
if (text === undefined) throw invalid("This decision needs an answer text")
|
|
430
|
+
return { text }
|
|
431
|
+
}
|
|
432
|
+
if (typeof input.option !== "string" || !decision.options.some((o) => o.id === input.option)) {
|
|
433
|
+
throw invalid(`Pick one of the options: ${decision.options.map((o) => o.id).join(", ")}`)
|
|
434
|
+
}
|
|
435
|
+
return { option: input.option, ...(text === undefined ? {} : { text }) }
|
|
436
|
+
}
|
|
437
|
+
|
|
438
|
+
/**
|
|
439
|
+
* Runs the answered Decision's action; answers its note (sanitised) for the asker's reply. Its subject gone: the
|
|
440
|
+
* Decision closes instead (throws gone). It fails: the Decision opens again (the user may retry) and the error
|
|
441
|
+
* goes through.
|
|
442
|
+
*/
|
|
443
|
+
private async runAction(userID: string, decision: Decision, choice: { option?: string; text?: string }, by: UserActor): Promise<string | undefined> {
|
|
444
|
+
let outcome: ActOutcome
|
|
445
|
+
try {
|
|
446
|
+
if (!this.deps.act) throw new Error("This decision's action cannot run on this hub")
|
|
447
|
+
outcome = await this.deps.act(userID, decision, choice, by)
|
|
448
|
+
} catch (cause) {
|
|
449
|
+
await updateVersioned<Decision>(this.deps.state, userID, DECISIONS, decision.id, (current) => {
|
|
450
|
+
if (current?.state !== "answered" || current.answer?.at !== decision.answer?.at) return undefined
|
|
451
|
+
const { answer: _answer, ...rest } = current
|
|
452
|
+
return { ...rest, state: "open" }
|
|
453
|
+
})
|
|
454
|
+
throw cause
|
|
455
|
+
}
|
|
456
|
+
if (outcome === "done") return undefined
|
|
457
|
+
if (typeof outcome === "object" && outcome.outcome === "done") return sanitizeLabel(outcome.note, WHY_MAX) || undefined
|
|
458
|
+
const why = (typeof outcome === "object" && sanitizeLabel(outcome.reason, WHY_MAX)) || "The request was answered elsewhere or ended"
|
|
459
|
+
const closed = await updateVersioned<Decision>(this.deps.state, userID, DECISIONS, decision.id, (current) => {
|
|
460
|
+
if (current?.state !== "answered") return undefined
|
|
461
|
+
const { answer: _answer, ...rest } = current
|
|
462
|
+
return { ...rest, state: "closed", closed: { at: this.now(), reason: why } }
|
|
463
|
+
})
|
|
464
|
+
if (closed) {
|
|
465
|
+
this.emit(userID, closed)
|
|
466
|
+
await this.replyTo(userID, closed, `Decision ${closed.id} closed: ${why}`, HUB_USER(userID))
|
|
467
|
+
}
|
|
468
|
+
throw gone()
|
|
469
|
+
}
|
|
470
|
+
|
|
471
|
+
/** Answers the Decision if it is still open (version-checked); undefined when it is not. */
|
|
472
|
+
private settle(userID: string, id: string, answer: DecisionAnswer): Promise<Decision | undefined> {
|
|
473
|
+
return updateVersioned<Decision>(this.deps.state, userID, DECISIONS, id, (current) =>
|
|
474
|
+
current && this.view(current).state === "open" ? { ...current, state: "answered", answer } : undefined,
|
|
475
|
+
)
|
|
476
|
+
}
|
|
477
|
+
|
|
478
|
+
/** The user's reply to the ask (the asker's answer); a failure is logged, the Decision stays answered. */
|
|
479
|
+
private async replyTo(userID: string, decision: Decision, text: string, by: UserActor): Promise<{ reply?: Message }> {
|
|
480
|
+
try {
|
|
481
|
+
return { reply: (await this.deps.delivery.reply(userID, by, decision.messageID, text)).message }
|
|
482
|
+
} catch (cause) {
|
|
483
|
+
console.error(`[hub] decision ${decision.id}: reply to the asker failed: ${cause instanceof Error ? cause.message : String(cause)}`)
|
|
484
|
+
return {}
|
|
485
|
+
}
|
|
486
|
+
}
|
|
487
|
+
|
|
488
|
+
/** Expires, times out or escalates the user's open Decisions that are due. */
|
|
489
|
+
private async due(userID: string): Promise<void> {
|
|
490
|
+
const t = this.now()
|
|
491
|
+
for (const { value } of await this.deps.state.list<Decision>(userID, DECISIONS)) {
|
|
492
|
+
if (value.state !== "open") continue
|
|
493
|
+
if (t >= value.expiresAt) {
|
|
494
|
+
const expired = await updateVersioned<Decision>(this.deps.state, userID, DECISIONS, value.id, (current) => (current?.state === "open" ? { ...current, state: "expired" } : undefined))
|
|
495
|
+
if (expired) this.emit(userID, expired)
|
|
496
|
+
continue
|
|
497
|
+
}
|
|
498
|
+
if (value.deadline === undefined || t < value.deadline) continue
|
|
499
|
+
const option = value.onTimeout === undefined ? undefined : value.options.find((o) => o.id === value.onTimeout && o.safe)
|
|
500
|
+
if (option) {
|
|
501
|
+
const decision = await this.settle(userID, value.id, { option: option.id, answeredBy: "timeout", at: t })
|
|
502
|
+
if (decision) {
|
|
503
|
+
this.emit(userID, decision)
|
|
504
|
+
await this.replyTo(userID, decision, `Decision ${decision.id}: no answer by the deadline; took the safe option ${option.label} [${option.id}]`, HUB_USER(userID))
|
|
505
|
+
}
|
|
506
|
+
continue
|
|
507
|
+
}
|
|
508
|
+
if (value.escalatedAt !== undefined) continue
|
|
509
|
+
const escalated = await updateVersioned<Decision>(this.deps.state, userID, DECISIONS, value.id, (current) =>
|
|
510
|
+
current?.state === "open" && current.escalatedAt === undefined ? { ...current, escalatedAt: t } : undefined,
|
|
511
|
+
)
|
|
512
|
+
if (escalated) {
|
|
513
|
+
this.deps.notify?.(userID, escalated, "escalated")
|
|
514
|
+
this.emit(userID, escalated)
|
|
515
|
+
}
|
|
516
|
+
}
|
|
517
|
+
}
|
|
518
|
+
|
|
519
|
+
/** Drops the oldest closed Decisions over DECISIONS_MAX. */
|
|
520
|
+
private async trim(userID: string): Promise<void> {
|
|
521
|
+
const all = await this.deps.state.list<Decision>(userID, DECISIONS)
|
|
522
|
+
const excess = all.length - DECISIONS_MAX
|
|
523
|
+
if (excess <= 0) return
|
|
524
|
+
const closed = all.filter((e) => this.view(e.value).state !== "open").sort((a, b) => a.value.at - b.value.at)
|
|
525
|
+
for (const entry of closed.slice(0, excess)) {
|
|
526
|
+
await this.deps.state.delete(userID, DECISIONS, entry.key, { ifVersion: entry.version })
|
|
527
|
+
await this.deps.state.delete(userID, BY_MESSAGE, entry.value.messageID)
|
|
528
|
+
}
|
|
529
|
+
}
|
|
530
|
+
}
|