@huynle/hub 0.7.3 → 0.8.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +229 -0
- package/Dockerfile +7 -2
- package/README.md +153 -24
- package/package.json +5 -3
- package/packages/claude-code/package.json +1 -1
- package/packages/claude-code/src/daemon.ts +8 -1
- package/packages/claude-code/src/launcher.ts +29 -9
- package/packages/claude-code/src/profile.ts +173 -0
- package/packages/claude-code/src/sessions.ts +22 -4
- package/packages/cli/dist/hub-cli.js +6474 -3470
- package/packages/cli/package.json +1 -1
- package/packages/cli/src/artifact-jobs-mcp.ts +226 -0
- package/packages/cli/src/artifacts-mcp.ts +160 -0
- package/packages/cli/src/bootstrap/enrollment/enroll.ts +20 -8
- package/packages/cli/src/bootstrap/enrollment/policy.ts +125 -12
- package/packages/cli/src/bootstrap/enrollment/status.ts +3 -2
- package/packages/cli/src/cli.ts +95 -13
- package/packages/cli/src/format.ts +48 -10
- package/packages/cli/src/http.ts +1 -0
- package/packages/cli/src/intents.ts +23 -0
- package/packages/cli/src/login.ts +20 -6
- package/packages/cli/src/mcp.ts +380 -9
- package/packages/cli/src/messaging.ts +3 -1
- package/packages/cli/src/setup/commands.ts +3 -2
- package/packages/cli/src/setup/runtime.ts +3 -2
- package/packages/cli/src/team-intake-mcp.ts +158 -0
- package/packages/cli/src/teams-mcp.ts +403 -0
- package/packages/cli/src/watchlist.ts +135 -0
- package/packages/codex/package.json +1 -1
- package/packages/codex/src/profile.ts +163 -0
- package/packages/codex/src/sessions.ts +12 -0
- package/packages/codex/src/spoke.ts +19 -1
- package/packages/node/package.json +1 -1
- package/packages/node/src/config.ts +96 -2
- package/packages/node/src/opencode-warm.ts +325 -0
- package/packages/node/src/run.ts +27 -1
- package/packages/preset/package.json +1 -1
- package/packages/preset/src/profile.ts +34 -0
- package/packages/preset/src/spoke.ts +4 -0
- package/packages/protocol/package.json +1 -1
- package/packages/protocol/src/artifacts.ts +142 -0
- package/packages/protocol/src/concierge.ts +366 -0
- package/packages/protocol/src/contracts.ts +104 -0
- package/packages/protocol/src/graph.ts +70 -0
- package/packages/protocol/src/hub-workspace.ts +201 -0
- package/packages/protocol/src/index.ts +169 -6
- package/packages/protocol/src/launch.ts +22 -0
- package/packages/protocol/src/profiles.ts +301 -0
- package/packages/protocol/src/teams.ts +204 -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/address.ts +50 -2
- package/packages/sdk/src/artifact-jobs.ts +194 -0
- package/packages/sdk/src/artifacts.ts +187 -0
- package/packages/sdk/src/client.ts +226 -5
- package/packages/sdk/src/concierge.ts +88 -0
- package/packages/sdk/src/errors.ts +3 -1
- package/packages/sdk/src/index.ts +5 -0
- package/packages/sdk/src/team-intake.ts +122 -0
- package/packages/sdk/src/teams.ts +346 -0
- package/packages/sdk/src/types.ts +548 -1
- package/packages/spoke-sdk/package.json +1 -1
- package/packages/spoke-sdk/src/index.ts +1 -0
- package/packages/spoke-sdk/src/profile-apply.ts +245 -0
- package/src/central/actions.ts +7 -0
- package/src/central/agent/acting-routes.ts +2 -1
- package/src/central/agent/acting-switch.ts +2 -1
- package/src/central/agent/acting.ts +215 -10
- package/src/central/agent/app-tokens.ts +3 -2
- package/src/central/agent/audit.ts +17 -0
- package/src/central/agent/body.ts +163 -0
- package/src/central/agent/decisions.ts +69 -2
- package/src/central/agent/delivery.ts +95 -15
- package/src/central/agent/directory-routes.ts +37 -7
- package/src/central/agent/directory.ts +70 -14
- package/src/central/agent/errors.ts +5 -0
- package/src/central/agent/events-routes.ts +21 -4
- package/src/central/agent/events.ts +11 -3
- package/src/central/agent/form-check.ts +85 -0
- package/src/central/agent/grant-requests.ts +2 -1
- package/src/central/agent/grant-routes.ts +4 -3
- package/src/central/agent/grants.ts +29 -2
- package/src/central/agent/idempotency.ts +133 -0
- package/src/central/agent/intent-routes.ts +116 -0
- package/src/central/agent/intents.ts +505 -0
- package/src/central/agent/interaction-graph-routes.ts +212 -0
- package/src/central/agent/interaction-graph.ts +865 -0
- package/src/central/agent/launch-engine.ts +16 -2
- package/src/central/agent/launch-routes.ts +6 -1
- package/src/central/agent/launch-spec.ts +14 -1
- package/src/central/agent/message-routes.ts +2 -1
- package/src/central/agent/messages.ts +27 -2
- package/src/central/agent/names.ts +12 -2
- package/src/central/agent/read-routes.ts +86 -19
- package/src/central/agent/routes.ts +176 -5
- package/src/central/agent/webhook-routes.ts +2 -1
- package/src/central/agent-data.ts +4 -0
- package/src/central/artifacts/access.ts +44 -0
- package/src/central/artifacts/blob-store.ts +113 -0
- package/src/central/artifacts/blob-support.ts +98 -0
- package/src/central/artifacts/events.ts +27 -0
- package/src/central/artifacts/host-caps.ts +162 -0
- package/src/central/artifacts/host.ts +315 -0
- package/src/central/artifacts/idempotency.ts +109 -0
- package/src/central/artifacts/jobs-routes.ts +773 -0
- package/src/central/artifacts/jobs.ts +344 -0
- package/src/central/artifacts/maintenance.ts +103 -0
- package/src/central/artifacts/routes.ts +360 -0
- package/src/central/artifacts/store.ts +776 -0
- package/src/central/artifacts/workspaces.ts +31 -0
- package/src/central/backend/file-blobs.ts +377 -0
- package/src/central/backend/index.ts +12 -3
- package/src/central/backend/memory-blobs.ts +116 -0
- package/src/central/backend/postgres-blobs.ts +513 -0
- package/src/central/backend/postgres-migrate.ts +33 -0
- package/src/central/backend/postgres-state.ts +19 -0
- package/src/central/backend/types.ts +8 -0
- package/src/central/concierge/activity.ts +142 -0
- package/src/central/concierge/arm.ts +63 -0
- package/src/central/concierge/board-render.ts +190 -0
- package/src/central/concierge/board-results.ts +169 -0
- package/src/central/concierge/board-routes.ts +339 -0
- package/src/central/concierge/board.ts +249 -0
- package/src/central/concierge/context.ts +221 -0
- package/src/central/concierge/entitlements.ts +13 -0
- package/src/central/concierge/guard.ts +120 -0
- package/src/central/concierge/inbound.ts +89 -0
- package/src/central/concierge/index.ts +84 -0
- package/src/central/concierge/notes.ts +41 -0
- package/src/central/concierge/notifier.ts +986 -0
- package/src/central/concierge/prompt.ts +130 -0
- package/src/central/concierge/proposals.ts +262 -0
- package/src/central/concierge/push-remap.ts +49 -0
- package/src/central/concierge/routes.ts +62 -0
- package/src/central/concierge/schedule.ts +113 -0
- package/src/central/concierge/service.ts +243 -0
- package/src/central/concierge/session-routes.ts +69 -0
- package/src/central/concierge/settings.ts +217 -0
- package/src/central/concierge/supervisor.ts +740 -0
- package/src/central/concierge/tiers.ts +260 -0
- package/src/central/concierge/watchlist-routes.ts +277 -0
- package/src/central/concierge/watchlist.ts +273 -0
- package/src/central/config.ts +7 -1
- package/src/central/http.ts +633 -50
- package/src/central/mcp-http.ts +10 -21
- package/src/central/profiles/apply.ts +333 -0
- package/src/central/profiles/content.ts +174 -0
- package/src/central/profiles/events.ts +40 -0
- package/src/central/profiles/idempotency.ts +14 -0
- package/src/central/profiles/import.ts +139 -0
- package/src/central/profiles/routes.ts +356 -0
- package/src/central/profiles/store.ts +597 -0
- package/src/central/profiles/usage-ports.ts +120 -0
- package/src/central/profiles/usage.ts +364 -0
- package/src/central/push.ts +7 -0
- package/src/central/registry.ts +43 -6
- package/src/central/store.ts +4 -0
- package/src/central/teams/collections.ts +34 -0
- package/src/central/teams/discovery.ts +346 -0
- package/src/central/teams/draft-edits.ts +46 -0
- package/src/central/teams/events.ts +46 -0
- package/src/central/teams/intake-routes.ts +100 -0
- package/src/central/teams/intake.ts +772 -0
- package/src/central/teams/policy.ts +231 -0
- package/src/central/teams/routes.ts +516 -0
- package/src/central/teams/seats.ts +272 -0
- package/src/central/teams/staffing.ts +888 -0
- package/src/central/teams/store.ts +667 -0
- package/src/central/teams/validation.ts +256 -0
- package/src/central/workspaces/board.ts +408 -0
- package/src/central/workspaces/checkpoints.ts +316 -0
- package/src/central/workspaces/coordinator.ts +841 -0
- package/src/central/workspaces/events.ts +85 -0
- package/src/central/workspaces/lifecycle.ts +93 -0
- package/src/central/workspaces/master.ts +490 -0
- package/src/central/workspaces/members.ts +632 -0
- package/src/central/workspaces/routes.ts +354 -0
- package/src/central/workspaces/sidechats-routes.ts +410 -0
- package/src/central/workspaces/sidechats.ts +482 -0
- package/src/central/workspaces/store.ts +571 -0
- package/src/central/workspaces/supervision.ts +355 -0
- package/src/index.ts +97 -15
- package/src/spoke/concierge-agent.ts +33 -0
- package/src/spoke/deliver.ts +22 -4
- package/src/spoke/fork-revert.ts +142 -0
- package/src/spoke/hub-rpc.ts +41 -5
- package/src/spoke/hub-tool.ts +383 -18
- package/src/spoke/listing.ts +47 -4
- package/src/spoke/profile-apply.ts +298 -0
- package/src/spoke/session-context.ts +176 -0
- package/src/spoke/spoke.ts +319 -20
- package/src/spoke/timeline.ts +6 -1
- package/src/spoke/tracker.ts +23 -1
- package/src/spoke/who-teams.ts +81 -0
- package/src/tui-messaging.ts +32 -18
- package/web/dist/assets/ArtifactPage-BS0gVkfu.js +1 -0
- package/web/dist/assets/ArtifactPane-PLYHNs6s.js +1 -0
- package/web/dist/assets/ArtifactWorkbench-9eTj1CeW.css +1 -0
- package/web/dist/assets/ArtifactWorkbench-LkDyJ5Dv.js +6 -0
- package/web/dist/assets/Library-CtxRqTjo.js +2 -0
- package/web/dist/assets/Library-DYsFj_5c.css +1 -0
- package/web/dist/assets/ProfilesDestination-BYiHKLmc.js +1 -0
- package/web/dist/assets/TeamsPage-BwjsAAUC.js +1 -0
- package/web/dist/assets/Watchtower-C4swrWVs.js +2 -0
- package/web/dist/assets/Watchtower-CTgqHk5g.css +1 -0
- package/web/dist/assets/index-DaH5GUdV.js +31 -0
- package/web/dist/assets/index-WWuKfWxL.css +1 -0
- package/web/dist/assets/live-BUofsQpz.css +1 -0
- package/web/dist/assets/live-YyTjE47g.js +5 -0
- package/web/dist/assets/pane-layout-CKC4I00k.js +1 -0
- package/web/dist/assets/plural-Bspvoqfq.js +1 -0
- package/web/dist/assets/renderer2d-CX5j3NcD.js +1 -0
- package/web/dist/assets/renderer3d-BXIVOvfi.js +5393 -0
- package/web/dist/assets/safe-area-DfPsOdWR.js +1 -0
- package/web/dist/assets/source-CvUHTc3O.js +1 -0
- package/web/dist/assets/ui-C9Igxzjm.js +1 -0
- package/web/dist/index.html +5 -2
- package/web/dist/sw.js +22 -2
- package/web/dist/assets/index-Dm8OmkaO.css +0 -1
- package/web/dist/assets/index-FUJbj_Du.js +0 -20
|
@@ -7,6 +7,7 @@
|
|
|
7
7
|
//
|
|
8
8
|
// Management (create, list, revoke) is for user actors only (allow {}): a session or app token cannot mint or
|
|
9
9
|
// see tokens (escalation guard).
|
|
10
|
+
import { boundedJson } from "./body"
|
|
10
11
|
import { randomBytes, timingSafeEqual } from "node:crypto"
|
|
11
12
|
import type { AppToken } from "../agent-data"
|
|
12
13
|
import { hashToken, type Store } from "../store"
|
|
@@ -19,7 +20,7 @@ import type { AgentRoute } from "./routes"
|
|
|
19
20
|
* acting on sessions (approve, deny, answer, prompt, stop) and launching sessions (phase 4), each of which still
|
|
20
21
|
* needs a grant held by the token (with the act verb, or `launch` covering the repo).
|
|
21
22
|
*/
|
|
22
|
-
export const APP_SCOPES: readonly string[] = ["sessions:read", "sessions:manage", "messages:send", "messages:read", "sessions:act", "sessions:launch", "events:subscribe"]
|
|
23
|
+
export const APP_SCOPES: readonly string[] = ["sessions:read", "sessions:manage", "messages:send", "messages:read", "sessions:act", "sessions:launch", "events:subscribe", "profiles:read", "profiles:manage", "profiles:assign", "workspaces:read", "workspaces:manage", "artifacts:read", "artifacts:write", "artifacts:ai", "teams:submit", "teams:read", "teams:manage"]
|
|
23
24
|
export const DEFAULT_APP_SCOPES: readonly string[] = ["sessions:read"]
|
|
24
25
|
/** What `hub login` (the CLI login device code) asks for unless it asks for more: reading sessions and messaging. */
|
|
25
26
|
export const DEFAULT_CLI_LOGIN_SCOPES: readonly string[] = ["sessions:read", "messages:send", "messages:read"]
|
|
@@ -121,7 +122,7 @@ export function appTokenRoutes(store: Store): AgentRoute[] {
|
|
|
121
122
|
path: PREFIX,
|
|
122
123
|
allow: {},
|
|
123
124
|
async handle({ request, user }) {
|
|
124
|
-
const { record, token } = await issueAppToken(store, user.id, parseCreate(await request
|
|
125
|
+
const { record, token } = await issueAppToken(store, user.id, parseCreate(await boundedJson(request)))
|
|
125
126
|
return Response.json({ ...view(record), token }, { status: 201, headers: NO_STORE })
|
|
126
127
|
},
|
|
127
128
|
},
|
|
@@ -10,6 +10,9 @@
|
|
|
10
10
|
// fails the record.
|
|
11
11
|
// - Admins see only a hub-wide summary in the Store's audit (who: the user ID; kind: agent.<verb> and the actor
|
|
12
12
|
// kind; when), never targets, reasons or text (design §7).
|
|
13
|
+
// - (CON-06) `as: "concierge"`: entries whose actor is one of the user's Concierge sessions (current or past) are
|
|
14
|
+
// marked here, centrally, so every recording route (acting, launch, message, roles) gets it; the console says
|
|
15
|
+
// "by @@concierge". A failed identity read leaves the entry unmarked (it is still recorded).
|
|
13
16
|
import type { Clock, StateStore } from "../backend/types"
|
|
14
17
|
import type { AuditEntry } from "../store"
|
|
15
18
|
import { type Party, sanitizeText } from "./envelope"
|
|
@@ -50,6 +53,8 @@ export interface AuditInput {
|
|
|
50
53
|
/** The agent's reason (`--why`) or the policy's. */
|
|
51
54
|
readonly reason?: string
|
|
52
55
|
readonly outcome: AuditOutcome
|
|
56
|
+
/** (CON-06) Done by one of the user's Concierge sessions (current or past). */
|
|
57
|
+
readonly as?: "concierge"
|
|
53
58
|
}
|
|
54
59
|
|
|
55
60
|
export interface AgentAuditEntry extends AuditInput {
|
|
@@ -80,6 +85,8 @@ export interface AgentAuditDeps {
|
|
|
80
85
|
readonly recorded?: (userID: string, entry: AgentAuditEntry) => void
|
|
81
86
|
/** Ring size (default AGENT_AUDIT_LIMIT). */
|
|
82
87
|
readonly limit?: number
|
|
88
|
+
/** (CON-06) Whether a session is one of the user's Concierge sessions (current or past). */
|
|
89
|
+
readonly concierge?: (userID: string, sessionID: string) => Promise<boolean>
|
|
83
90
|
}
|
|
84
91
|
|
|
85
92
|
const ENTRIES = "agent-audit"
|
|
@@ -122,6 +129,7 @@ export class AgentAudit {
|
|
|
122
129
|
const seq = (await updateVersioned<number>(state, userID, SEQ, SEQ_KEY, (current) => (current ?? 0) + 1))!
|
|
123
130
|
const grant = cleanID(input.grant)
|
|
124
131
|
const reason = cleanReason(input.reason)
|
|
132
|
+
const as = input.as === "concierge" || (input.actor.kind === "session" && (await this.isConcierge(userID, input.actor.sessionID))) ? ("concierge" as const) : undefined
|
|
125
133
|
const entry: AgentAuditEntry = {
|
|
126
134
|
seq,
|
|
127
135
|
at: this.now(),
|
|
@@ -132,6 +140,7 @@ export class AgentAudit {
|
|
|
132
140
|
decision: input.decision,
|
|
133
141
|
...(reason ? { reason } : {}),
|
|
134
142
|
outcome: input.outcome,
|
|
143
|
+
...(as ? { as } : {}),
|
|
135
144
|
}
|
|
136
145
|
await state.put(userID, ENTRIES, keyOf(seq), entry)
|
|
137
146
|
if (seq > this.limit) await state.delete(userID, ENTRIES, keyOf(seq - this.limit))
|
|
@@ -144,6 +153,14 @@ export class AgentAudit {
|
|
|
144
153
|
return entry
|
|
145
154
|
}
|
|
146
155
|
|
|
156
|
+
private async isConcierge(userID: string, sessionID: string): Promise<boolean> {
|
|
157
|
+
try {
|
|
158
|
+
return (await this.deps.concierge?.(userID, sessionID)) === true
|
|
159
|
+
} catch {
|
|
160
|
+
return false
|
|
161
|
+
}
|
|
162
|
+
}
|
|
163
|
+
|
|
147
164
|
/** The user's entries, newest first, a page at a time. */
|
|
148
165
|
async list(userID: string, options: AuditListOptions = {}): Promise<AuditPage> {
|
|
149
166
|
const limit = Math.min(Math.max(Math.floor(options.limit ?? AUDIT_PAGE_DEFAULT), 1), AUDIT_PAGE_MAX)
|
|
@@ -0,0 +1,163 @@
|
|
|
1
|
+
// Bounded request bodies for JSON routes (HWA-28 review of HWA-21/24). The server's maxRequestBodySize is ~41 MiB
|
|
2
|
+
// (artifact blob uploads plus drain headroom; Bun 1.3.5 did not even enforce it for a chunked body the handler keeps
|
|
3
|
+
// up with), so every JSON route keeps its own bounds, in bytes AND time:
|
|
4
|
+
// - a declared content-length over the bound is refused without keeping anything (read and discarded within the
|
|
5
|
+
// drain budget below, else unread with Connection: close);
|
|
6
|
+
// - a streamed (chunked) body is kept only up to the bound; the rest is then read and discarded, but at most
|
|
7
|
+
// BODY_LIMITS.drainBytes more within BODY_LIMITS.drainMs. A body that ends within that budget leaves the
|
|
8
|
+
// kept-alive connection clean (cancelling mid-stream made Bun answer the client's NEXT request on it with a bare
|
|
9
|
+
// 400, quirk cmramwb8). One that doesn't (endless, huge or dripping) is cancelled and the answer carries
|
|
10
|
+
// `Connection: close`, so clients and proxies do not reuse the dirty socket;
|
|
11
|
+
// - time is budgeted by RATE, not a fixed deadline: a body may take graceMs + received / minBytesPerSecond
|
|
12
|
+
// (so at most graceMs + max / minBytesPerSecond for the route's byte limit), and no gap between bytes may exceed
|
|
13
|
+
// idleMs. Slow but steady clients succeed (a 1.4 MB body at 20 KiB/s); a drip cannot hold a handler.
|
|
14
|
+
// - the body is read continuously (nothing but the next read is awaited), so Bun never gathers a flood into one
|
|
15
|
+
// huge chunk while JS waits on something else (a full-speed endless body stays at a few MiB of memory).
|
|
16
|
+
// Over the bound answers 400 invalid "The body is larger than N bytes" (extra.limit) before any validation; too slow
|
|
17
|
+
// answers 400 invalid "The body arrived too slowly …" (Connection: close).
|
|
18
|
+
//
|
|
19
|
+
// Limit of the platform (Bun 1.3.5, measured with raw sockets): after a handler answered, Bun keeps reading — and
|
|
20
|
+
// discarding — whatever a client goes on sending on that socket, even with `Connection: close`, a cancelled body or
|
|
21
|
+
// server.timeout(); the handler and its memory are free, but the socket stays open while bytes flow. That holds for
|
|
22
|
+
// every route, including ones that never read a body. Only a front proxy with a request body limit ends it there.
|
|
23
|
+
// Bun 1.4.4 (the pinned runtime) closes the socket after a `Connection: close` answer instead, and enforces the
|
|
24
|
+
// server's maxRequestBodySize on chunked bodies (see its headroom in ../http.ts).
|
|
25
|
+
import { AgentError } from "./errors"
|
|
26
|
+
|
|
27
|
+
/** The default bound of an agent JSON body (messages, launches, grants, workspaces, …). */
|
|
28
|
+
export const AGENT_JSON_BODY_MAX = 1 << 20
|
|
29
|
+
/** Profiles carry versions of up to 512 KiB (PROFILE_LIMITS.versionBytes), JSON-escaped. */
|
|
30
|
+
export const PROFILE_JSON_BODY_MAX = 4 << 20
|
|
31
|
+
|
|
32
|
+
export interface BodyLimits {
|
|
33
|
+
/** At most this many bytes past the bound are read (and discarded) to keep the connection clean. */
|
|
34
|
+
readonly drainBytes: number
|
|
35
|
+
/** …within this long after the bound was crossed. */
|
|
36
|
+
readonly drainMs: number
|
|
37
|
+
/** Time every body gets before the rate counts. */
|
|
38
|
+
readonly graceMs: number
|
|
39
|
+
/** Each byte received earns 1 / minBytesPerSecond seconds more. */
|
|
40
|
+
readonly minBytesPerSecond: number
|
|
41
|
+
/** The longest gap without bytes. */
|
|
42
|
+
readonly idleMs: number
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
export const BODY_LIMITS: BodyLimits = { drainBytes: 8 << 20, drainMs: 2_000, graceMs: 30_000, minBytesPerSecond: 8 * 1024, idleMs: 30_000 }
|
|
46
|
+
|
|
47
|
+
/** Where a body's time budget ends (ms, same clock as the inputs): the rate budget or the idle limit, whichever first. */
|
|
48
|
+
export function bodyDeadline(progress: { readonly startedAt: number; readonly lastByteAt: number; readonly received: number }, limits: Pick<BodyLimits, "graceMs" | "minBytesPerSecond" | "idleMs">): number {
|
|
49
|
+
const earned = progress.startedAt + limits.graceMs + (progress.received * 1000) / limits.minBytesPerSecond
|
|
50
|
+
return Math.min(earned, progress.lastByteAt + limits.idleMs)
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
export type BoundedBody = { readonly ok: true; readonly bytes: Uint8Array } | { readonly ok: false; readonly reason: "too_large" | "timeout"; readonly close: boolean }
|
|
54
|
+
|
|
55
|
+
const TIMEOUT = Symbol("timeout")
|
|
56
|
+
type Read = Awaited<ReturnType<ReadableStreamDefaultReader<Uint8Array>["read"]>>
|
|
57
|
+
|
|
58
|
+
/** reader.read(), or TIMEOUT once `deadline` (performance.now()) has passed. */
|
|
59
|
+
export async function readBefore(reader: ReadableStreamDefaultReader<Uint8Array>, deadline: number): Promise<Read | typeof TIMEOUT> {
|
|
60
|
+
const left = deadline - performance.now()
|
|
61
|
+
if (left <= 0) return TIMEOUT
|
|
62
|
+
let timer: ReturnType<typeof setTimeout> | undefined
|
|
63
|
+
try {
|
|
64
|
+
return await Promise.race([reader.read(), new Promise<typeof TIMEOUT>((resolve) => (timer = setTimeout(() => resolve(TIMEOUT), left)))])
|
|
65
|
+
} finally {
|
|
66
|
+
clearTimeout(timer)
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
export const isTimeout = (value: unknown): value is typeof TIMEOUT => value === TIMEOUT
|
|
70
|
+
|
|
71
|
+
const cancel = (reader: ReadableStreamDefaultReader<Uint8Array>) => reader.cancel().catch(() => {})
|
|
72
|
+
|
|
73
|
+
/**
|
|
74
|
+
* Discards what is left of a body within the byte and time budget: true when the body ended (the connection is
|
|
75
|
+
* clean), false when the budget ran out (the reader is cancelled: answer with Connection: close).
|
|
76
|
+
*/
|
|
77
|
+
export async function drainBounded(reader: ReadableStreamDefaultReader<Uint8Array>, limits: Partial<BodyLimits> = {}): Promise<boolean> {
|
|
78
|
+
const { drainBytes, drainMs } = { ...BODY_LIMITS, ...limits }
|
|
79
|
+
const deadline = performance.now() + drainMs
|
|
80
|
+
let drained = 0
|
|
81
|
+
try {
|
|
82
|
+
for (;;) {
|
|
83
|
+
const next = await readBefore(reader, deadline)
|
|
84
|
+
if (next === TIMEOUT) break
|
|
85
|
+
if (next.done) return true
|
|
86
|
+
drained += next.value.byteLength
|
|
87
|
+
if (drained > drainBytes) break
|
|
88
|
+
}
|
|
89
|
+
} catch {
|
|
90
|
+
return false // the client went away
|
|
91
|
+
}
|
|
92
|
+
await cancel(reader)
|
|
93
|
+
return false
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
/** The body's bytes, at most `max` of them, within the byte and time limits (see the file comment). */
|
|
97
|
+
export async function readBounded(request: Request, max: number, limits: Partial<BodyLimits> = {}): Promise<BoundedBody> {
|
|
98
|
+
const declared = Number(request.headers.get("content-length") ?? "")
|
|
99
|
+
if (Number.isFinite(declared) && declared > max) {
|
|
100
|
+
// Declared too large: nothing is kept. Bun 1.3.5 does not skip unread declared bytes either (a client reusing the
|
|
101
|
+
// socket got 431 for its next request), so a body within the drain budget is read and discarded; a bigger one
|
|
102
|
+
// is refused unread, with Connection: close.
|
|
103
|
+
if (!request.body) return { ok: false, reason: "too_large", close: false }
|
|
104
|
+
const reader = request.body.getReader()
|
|
105
|
+
const { drainBytes } = { ...BODY_LIMITS, ...limits }
|
|
106
|
+
if (declared - max > drainBytes) {
|
|
107
|
+
await cancel(reader)
|
|
108
|
+
return { ok: false, reason: "too_large", close: true }
|
|
109
|
+
}
|
|
110
|
+
return { ok: false, reason: "too_large", close: !(await drainBounded(reader, { ...limits, drainBytes: declared })) }
|
|
111
|
+
}
|
|
112
|
+
if (!request.body) return { ok: true, bytes: new Uint8Array(0) }
|
|
113
|
+
const budget = { ...BODY_LIMITS, ...limits }
|
|
114
|
+
const startedAt = performance.now()
|
|
115
|
+
let lastByteAt = startedAt
|
|
116
|
+
const reader = request.body.getReader()
|
|
117
|
+
const chunks: Uint8Array[] = []
|
|
118
|
+
let size = 0
|
|
119
|
+
for (;;) {
|
|
120
|
+
let next: Read | typeof TIMEOUT
|
|
121
|
+
try {
|
|
122
|
+
next = await readBefore(reader, bodyDeadline({ startedAt, lastByteAt, received: size }, budget))
|
|
123
|
+
} catch {
|
|
124
|
+
return { ok: false, reason: "timeout", close: true } // the client went away mid-body
|
|
125
|
+
}
|
|
126
|
+
if (next === TIMEOUT) {
|
|
127
|
+
await cancel(reader)
|
|
128
|
+
return { ok: false, reason: "timeout", close: true }
|
|
129
|
+
}
|
|
130
|
+
if (next.done) break
|
|
131
|
+
lastByteAt = performance.now()
|
|
132
|
+
size += next.value.byteLength
|
|
133
|
+
// Past the bound (one oversized chunk included): nothing more is kept, this chunk neither.
|
|
134
|
+
if (size > max) return { ok: false, reason: "too_large", close: !(await drainBounded(reader, limits)) }
|
|
135
|
+
chunks.push(next.value)
|
|
136
|
+
}
|
|
137
|
+
return { ok: true, bytes: Buffer.concat(chunks) }
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
/** Why a body was cut off for time, with the limits in force. */
|
|
141
|
+
export function slowMessage(limits: Partial<BodyLimits> = {}): string {
|
|
142
|
+
const { graceMs, minBytesPerSecond, idleMs } = { ...BODY_LIMITS, ...limits }
|
|
143
|
+
const s = (ms: number) => `${Math.round(ms / 100) / 10} s`
|
|
144
|
+
return `The body arrived too slowly (no bytes for ${s(idleMs)}, or under ${Math.round(minBytesPerSecond / 102.4) / 10} KiB/s after ${s(graceMs)})`
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
/** The AgentError for a refused body (Connection: close when the socket is left dirty). */
|
|
148
|
+
export function bodyError(result: Extract<BoundedBody, { ok: false }>, max: number, limits: Partial<BodyLimits> = {}): AgentError {
|
|
149
|
+
if (result.reason === "timeout") return new AgentError("invalid", slowMessage(limits), { close: true })
|
|
150
|
+
return new AgentError("invalid", `The body is larger than ${max} bytes`, { extra: { limit: max }, ...(result.close ? { close: true } : {}) })
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
/** The body as UTF-8 text of at most `max` bytes ("" without a body); AgentError invalid over the limits. */
|
|
154
|
+
export async function boundedText(request: Request, max: number, limits: Partial<BodyLimits> = {}): Promise<string> {
|
|
155
|
+
const result = await readBounded(request, max, limits)
|
|
156
|
+
if (!result.ok) throw bodyError(result, max, limits)
|
|
157
|
+
return Buffer.from(result.bytes).toString("utf8")
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
/** JSON.parse of a bounded body (a SyntaxError for invalid JSON, as request.json() threw). */
|
|
161
|
+
export async function boundedJson(request: Request, max = AGENT_JSON_BODY_MAX): Promise<unknown> {
|
|
162
|
+
return JSON.parse(await boundedText(request, max))
|
|
163
|
+
}
|
|
@@ -20,6 +20,11 @@
|
|
|
20
20
|
// - close(): a Decision whose subject went away (a permission answered elsewhere) is closed, not answered; the asker
|
|
21
21
|
// hears why.
|
|
22
22
|
// - Every open and change (answer, timeout, escalation, expiry, close) goes to `changed` (the console's 'decision' event).
|
|
23
|
+
// - (CON-06) `via`: an answer is a tap unless it says voice or text. Voice/text answers only a confirm-tier
|
|
24
|
+
// Decision, and only when the hub's `via` check admits it (the Concierge tiers: armed, presence, the user's
|
|
25
|
+
// device); otherwise forbidden "Answer this decision with a tap". Never a model: only the user's device answers.
|
|
26
|
+
// - (CON-06) `as: "concierge"`: set when the asker is one of the user's Concierge sessions (current or past), so
|
|
27
|
+
// the console can say "by @@concierge". The `act` action kind is a Concierge proposal (concierge/proposals.ts).
|
|
23
28
|
import type { Clock, StateStore, Timers } from "../backend/types"
|
|
24
29
|
import type { AgentActor, UserActor } from "./actor"
|
|
25
30
|
import type { Delivery } from "./delivery"
|
|
@@ -37,6 +42,22 @@ export type DecisionState = "open" | "answered" | "expired" | "closed"
|
|
|
37
42
|
export const URGENCIES: readonly Urgency[] = ["low", "normal", "high"]
|
|
38
43
|
export const TIERS: readonly Tier[] = ["confirm", "tap"]
|
|
39
44
|
export const DECISION_STATES: readonly DecisionState[] = ["open", "answered", "expired", "closed"]
|
|
45
|
+
/** (CON-06) How the user answered: tap (the default, absent), or voice/text in the Concierge panel. */
|
|
46
|
+
export type AnswerVia = "tap" | "voice" | "text"
|
|
47
|
+
export const ANSWER_VIAS: readonly AnswerVia[] = ["tap", "voice", "text"]
|
|
48
|
+
/** The refusal of a voice/text answer the hub does not admit. */
|
|
49
|
+
export const TAP_ONLY = "Answer this decision with a tap"
|
|
50
|
+
/** The refusal of a voice/text answer that picks a lasting option (coordinator decision, 93265or7). */
|
|
51
|
+
export const ALWAYS_TAP_ONLY = "Answer 'always' with a tap"
|
|
52
|
+
|
|
53
|
+
/**
|
|
54
|
+
* Whether an option is not once-off: `always` (saves a lasting rule, e.g. an escalated permission approved for
|
|
55
|
+
* good), or approving a grant request (creates a lasting grant). Voice and text pick only once-off options.
|
|
56
|
+
*/
|
|
57
|
+
export function lastingOption(decision: Pick<Decision, "action" | "options">, option: string | undefined): boolean {
|
|
58
|
+
if (option === "always") return true
|
|
59
|
+
return decision.action?.kind === "grant" && !decision.options.some((o) => o.id === option && o.safe)
|
|
60
|
+
}
|
|
40
61
|
|
|
41
62
|
/** Options a Decision offers. */
|
|
42
63
|
export const OPTIONS_MAX = 8
|
|
@@ -67,10 +88,25 @@ export interface DecisionAnswer {
|
|
|
67
88
|
readonly at: number
|
|
68
89
|
/** The device the user answered from. */
|
|
69
90
|
readonly deviceID?: string
|
|
91
|
+
/** (CON-06) voice or text; absent = tap. */
|
|
92
|
+
readonly via?: "voice" | "text"
|
|
70
93
|
}
|
|
71
94
|
|
|
72
95
|
/** 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
|
|
96
|
+
export type DecisionAction = PermissionAction | GrantRequestAction | ActAction
|
|
97
|
+
|
|
98
|
+
/**
|
|
99
|
+
* (CON-06) A Concierge proposal (concierge/proposals.ts): answering `go` runs the act verb on the target AS THE USER
|
|
100
|
+
* through the acting engine (tenancy re-checked); `hold` does nothing.
|
|
101
|
+
*/
|
|
102
|
+
export interface ActAction {
|
|
103
|
+
readonly kind: "act"
|
|
104
|
+
readonly verb: "stop" | "deny" | "prompt" | "answer"
|
|
105
|
+
/** The session ID it was proposed for (resolved when proposed). */
|
|
106
|
+
readonly target: string
|
|
107
|
+
/** The verb's validated input (requestID, formID, answer, text). */
|
|
108
|
+
readonly input: Readonly<Record<string, unknown>>
|
|
109
|
+
}
|
|
74
110
|
|
|
75
111
|
/** An escalated permission request: the answer replies to it as the user. */
|
|
76
112
|
export interface PermissionAction {
|
|
@@ -121,6 +157,8 @@ export interface Decision {
|
|
|
121
157
|
readonly closed?: { readonly at: number; readonly reason: string }
|
|
122
158
|
/** Run (as the answering user) when the user answers; set by the hub only, never by an asker. */
|
|
123
159
|
readonly action?: DecisionAction
|
|
160
|
+
/** (CON-06) Asked by one of the user's Concierge sessions: the console says "by @@concierge". */
|
|
161
|
+
readonly as?: "concierge"
|
|
124
162
|
}
|
|
125
163
|
|
|
126
164
|
/** What the hub (never an asker's request) adds to an ask. */
|
|
@@ -148,8 +186,13 @@ export interface AskInput {
|
|
|
148
186
|
export interface AnswerInput {
|
|
149
187
|
readonly option?: string
|
|
150
188
|
readonly text?: string
|
|
189
|
+
/** (CON-06) Absent = tap. */
|
|
190
|
+
readonly via?: AnswerVia
|
|
151
191
|
}
|
|
152
192
|
|
|
193
|
+
/** (CON-06) Admits a voice/text answer (throws forbidden otherwise). */
|
|
194
|
+
export type ViaCheck = (userID: string, decision: Decision, via: "voice" | "text", by: UserActor) => Promise<void>
|
|
195
|
+
|
|
153
196
|
export type NotifyReason = "opened" | "escalated"
|
|
154
197
|
|
|
155
198
|
/**
|
|
@@ -177,6 +220,10 @@ export interface DecisionsDeps {
|
|
|
177
220
|
readonly changed?: (userID: string, decision: Decision) => void
|
|
178
221
|
/** Runs Decisions' actions (escalations); a Decision with an action cannot be answered without it. */
|
|
179
222
|
readonly act?: DecisionActor
|
|
223
|
+
/** (CON-06) Admits voice/text answers; without it every voice/text answer is refused. */
|
|
224
|
+
readonly via?: ViaCheck
|
|
225
|
+
/** (CON-06) Whether a session is one of the user's Concierge sessions (current or past): the Decision's `as`. */
|
|
226
|
+
readonly concierge?: (userID: string, sessionID: string) => Promise<boolean>
|
|
180
227
|
}
|
|
181
228
|
|
|
182
229
|
const DECISIONS = "decisions"
|
|
@@ -274,6 +321,7 @@ export class Decisions {
|
|
|
274
321
|
const v = validate(input)
|
|
275
322
|
const about = [...new Set(input.about ?? (actor.kind === "session" ? [actor.sessionID] : []))]
|
|
276
323
|
const { message } = await this.deps.delivery.send(userID, actor, "@me", "ask", input.question, { expiresInMs: v.expiresInMs })
|
|
324
|
+
const asConcierge = actor.kind === "session" && (await this.isConcierge(userID, actor.sessionID))
|
|
277
325
|
const decision: Decision = {
|
|
278
326
|
id: this.newID(),
|
|
279
327
|
messageID: message.id,
|
|
@@ -291,6 +339,7 @@ export class Decisions {
|
|
|
291
339
|
at: message.at,
|
|
292
340
|
expiresAt: message.expiresAt ?? message.at + v.expiresInMs,
|
|
293
341
|
...(extra.action ? { action: extra.action } : {}),
|
|
342
|
+
...(asConcierge ? { as: "concierge" as const } : {}),
|
|
294
343
|
}
|
|
295
344
|
const written = await this.deps.state.put(userID, DECISIONS, decision.id, decision, { ifVersion: 0 })
|
|
296
345
|
if (!written.ok) throw new Error("decision id collision")
|
|
@@ -331,9 +380,18 @@ export class Decisions {
|
|
|
331
380
|
if (!current || by.userID !== userID) throw notFound()
|
|
332
381
|
if (current.state !== "open") throw gone()
|
|
333
382
|
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" })
|
|
383
|
+
const via = input.via ?? "tap"
|
|
384
|
+
if (!ANSWER_VIAS.includes(via)) throw invalid(`via is one of ${ANSWER_VIAS.join(", ")}`)
|
|
334
385
|
const choice = this.choice(current, input)
|
|
386
|
+
if (via !== "tap") {
|
|
387
|
+
// Voice/text: confirm tier only, and only what the hub's check admits (never without one).
|
|
388
|
+
if (current.tier !== "confirm" || !this.deps.via) throw new AgentError("forbidden", TAP_ONLY, { hint: "Tap an option" })
|
|
389
|
+
// Only once-off options by voice or text: a lasting one (always, a grant) needs a tap.
|
|
390
|
+
if (lastingOption(current, choice.option)) throw new AgentError("forbidden", ALWAYS_TAP_ONLY, { hint: "Tap the option" })
|
|
391
|
+
await this.deps.via(userID, current, via, by)
|
|
392
|
+
}
|
|
335
393
|
const at = this.now()
|
|
336
|
-
const answer: DecisionAnswer = { ...choice, answeredBy: "user", at, deviceID: by.deviceID }
|
|
394
|
+
const answer: DecisionAnswer = { ...choice, answeredBy: "user", at, deviceID: by.deviceID, ...(via !== "tap" ? { via } : {}) }
|
|
337
395
|
const decision = await this.settle(userID, id, answer)
|
|
338
396
|
if (!decision) throw gone()
|
|
339
397
|
const note = decision.action ? await this.runAction(userID, decision, choice, by) : undefined
|
|
@@ -399,6 +457,15 @@ export class Decisions {
|
|
|
399
457
|
|
|
400
458
|
// ---- internals ----------------------------------------------------------------------------------------
|
|
401
459
|
|
|
460
|
+
/** Whether the session is one of the user's Concierge sessions; a failed read is no label (never fails the ask). */
|
|
461
|
+
private async isConcierge(userID: string, sessionID: string): Promise<boolean> {
|
|
462
|
+
try {
|
|
463
|
+
return (await this.deps.concierge?.(userID, sessionID)) === true
|
|
464
|
+
} catch {
|
|
465
|
+
return false
|
|
466
|
+
}
|
|
467
|
+
}
|
|
468
|
+
|
|
402
469
|
/** Tells the changed listener (its failure never fails the caller). */
|
|
403
470
|
private emit(userID: string, decision: Decision): void {
|
|
404
471
|
try {
|
|
@@ -11,10 +11,15 @@
|
|
|
11
11
|
// - Roles: one holder, the idle one (by real evidence) else the most recently active, else (all offline) the
|
|
12
12
|
// one seen last; the message records the holder generation. sendAll: every holder, a message each. A session
|
|
13
13
|
// never messages itself: it is left out of a role it holds, and an address naming only it is invalid.
|
|
14
|
-
// - Pending: no deliver-capable connection reports the recipient (offline) or every one failed
|
|
14
|
+
// - Pending: no deliver-capable connection reports the recipient (offline) or every one failed (including an
|
|
15
|
+
// OpenCode spoke refusing a routine wake:false msg while the recipient's turn runs, RF-05) → kept for
|
|
15
16
|
// PENDING_MS in the StateStore and delivered oldest first on the user's next sessions frame (onSessions). In a
|
|
16
17
|
// cluster (deps.locks) each (user, session) flush holds a lease (deliver:<user>:<session>) and re-reads the
|
|
17
|
-
// entry under it, so a spoke reconnecting to another pod meanwhile never gets a message twice.
|
|
18
|
+
// entry under it, so a spoke reconnecting to another pod meanwhile never gets a message twice. A flush stops at a
|
|
19
|
+
// session's first failed delivery (its later messages wait, in order, for the next frame: one round trip per busy
|
|
20
|
+
// or unreachable session per frame), and a waking message first flushes only its own session's pending ones.
|
|
21
|
+
// Ordering limit: a waking message to a session that is still busy is admitted (queued after the turn) before the
|
|
22
|
+
// quiet ones pending for it, which follow when the turn ends.
|
|
18
23
|
// OpenCode holds admitted messages itself across forms, interrupts and restarts (P2-T0), so there is no
|
|
19
24
|
// "retained" retry loop on the hub.
|
|
20
25
|
// - Re-wake: an open ask to an idle recipient (Registry.idle: native or hooks evidence) is woken again after
|
|
@@ -37,6 +42,7 @@ import type { DeliverRequest, DeliverResult, DeliveryMode, MessageKind as WireKi
|
|
|
37
42
|
import { isDeliverResult } from "../../protocol"
|
|
38
43
|
import { randomBytes } from "node:crypto"
|
|
39
44
|
import type { Clock, Counters, Locks, StateStore, Timers } from "../backend/types"
|
|
45
|
+
import type { TeamPolicy } from "../teams/policy"
|
|
40
46
|
import type { AgentActor } from "./actor"
|
|
41
47
|
import type { DirectoryEntry, NamedListener, Resolved } from "./directory"
|
|
42
48
|
import { type Party, type PartyInfo, partyFromActor, partyKey, partyLabel, renderEnvelope, renderPrimer } from "./envelope"
|
|
@@ -82,6 +88,19 @@ export interface DeliveryAlert {
|
|
|
82
88
|
readonly text: string
|
|
83
89
|
}
|
|
84
90
|
|
|
91
|
+
/** What a message to a session is, for the inbound policy. */
|
|
92
|
+
export type InboundKind = "msg" | "ask" | "reply" | "decline"
|
|
93
|
+
|
|
94
|
+
/**
|
|
95
|
+
* (CON-04) Who may message a session, beyond the existing rules: `admit` throws (forbidden) to refuse before anything
|
|
96
|
+
* is stored. `original`: the message a reply or decline answers. The hub passes the Concierge inbound rule.
|
|
97
|
+
*/
|
|
98
|
+
export interface InboundPolicy {
|
|
99
|
+
admit(userID: string, from: Party, sessionID: string, kind: InboundKind, original?: Message): Promise<void>
|
|
100
|
+
/** Who a sender may reach at all (the Concierge: only @me, and replies to asks addressed to it); throws to refuse. */
|
|
101
|
+
outbound?(userID: string, from: Party, kind: InboundKind, original?: Message): Promise<void>
|
|
102
|
+
}
|
|
103
|
+
|
|
85
104
|
export interface DeliveryDeps {
|
|
86
105
|
readonly state: StateStore
|
|
87
106
|
readonly counters: Counters
|
|
@@ -104,6 +123,10 @@ export interface DeliveryDeps {
|
|
|
104
123
|
readonly owner?: string
|
|
105
124
|
/** How often the re-wake loop runs (default REWAKE_TICK_MS). */
|
|
106
125
|
readonly tickMs?: number
|
|
126
|
+
/** Team communication policy (HWA-34, ../teams/policy.ts): team addresses and verdicts before anything is stored. */
|
|
127
|
+
readonly teams?: Pick<TeamPolicy, "prepare" | "answer">
|
|
128
|
+
/** (CON-04) Inbound policy per recipient session (the Concierge inbound rule), checked next to team policy. */
|
|
129
|
+
readonly inbound?: InboundPolicy
|
|
107
130
|
}
|
|
108
131
|
|
|
109
132
|
export interface SendOptions {
|
|
@@ -119,6 +142,8 @@ export interface Sent {
|
|
|
119
142
|
readonly message: Message
|
|
120
143
|
/** How it reached the recipient; undefined when the recipient is not a session (stored only). */
|
|
121
144
|
readonly delivery?: DeliveryState
|
|
145
|
+
/** Whether it was sent to wake its recipient (false: wake:false, a subagent, the hop cap or a spent wake budget). */
|
|
146
|
+
readonly woke?: boolean
|
|
122
147
|
}
|
|
123
148
|
|
|
124
149
|
/** A reply or decline as sent, with the message it answered. */
|
|
@@ -202,6 +227,11 @@ export class Delivery {
|
|
|
202
227
|
private readonly newID: () => string
|
|
203
228
|
/** Per user: the flush in progress (flushes run one at a time). */
|
|
204
229
|
private readonly flushing = new Map<string, Promise<void>>()
|
|
230
|
+
/**
|
|
231
|
+
* Pending flush counters since start (no message text): attempts that failed (offline spokes, a busy OpenCode
|
|
232
|
+
* session refusing a routine quiet msg) and messages skipped behind such a failure until the next frame.
|
|
233
|
+
*/
|
|
234
|
+
readonly flushStats = { failed: 0, skipped: 0 }
|
|
205
235
|
/** Per user and session: the primer in flight (one at a time); true once it was admitted. */
|
|
206
236
|
private readonly priming = new Map<string, Promise<boolean>>()
|
|
207
237
|
/** Users seen since start (sent a message, or one of their spokes reported sessions): the loop looks at these. */
|
|
@@ -250,7 +280,7 @@ export class Delivery {
|
|
|
250
280
|
async send(userID: string, actor: AgentActor, addr: string, kind: "msg" | "ask", text: string, options: SendOptions = {}): Promise<Sent> {
|
|
251
281
|
if (isUserAddress(addr)) return this.sendToUser(userID, actor, addr, kind, text, options)
|
|
252
282
|
const from = this.party(userID, actor)
|
|
253
|
-
const resolved = this.
|
|
283
|
+
const resolved = await this.resolveFor(userID, actor, from, addr, kind)
|
|
254
284
|
return this.sendTo(userID, from, addr, resolved, this.pick(resolved), kind, text, options)
|
|
255
285
|
}
|
|
256
286
|
|
|
@@ -258,7 +288,7 @@ export class Delivery {
|
|
|
258
288
|
async sendAll(userID: string, actor: AgentActor, addr: string, kind: "msg" | "ask", text: string, options: SendOptions = {}): Promise<Sent[]> {
|
|
259
289
|
if (isUserAddress(addr)) return [await this.sendToUser(userID, actor, addr, kind, text, options)]
|
|
260
290
|
const from = this.party(userID, actor)
|
|
261
|
-
const resolved = this.
|
|
291
|
+
const resolved = await this.resolveFor(userID, actor, from, addr, kind)
|
|
262
292
|
const sent: Sent[] = []
|
|
263
293
|
for (const entry of resolved.sessions) sent.push(await this.sendTo(userID, from, addr, resolved, entry, kind, text, options))
|
|
264
294
|
return sent
|
|
@@ -266,12 +296,12 @@ export class Delivery {
|
|
|
266
296
|
|
|
267
297
|
/** Replies to a message, from its recipient to its real sender (never via a role), and delivers the reply. */
|
|
268
298
|
reply(userID: string, actor: AgentActor, replyTo: string, text: string, options: SendOptions = {}): Promise<Replied> {
|
|
269
|
-
return this.answer(userID, actor, replyTo, options, (input) => this.messages.reply(userID, { ...input, text }))
|
|
299
|
+
return this.answer(userID, actor, replyTo, options, "reply", (input) => this.messages.reply(userID, { ...input, text }))
|
|
270
300
|
}
|
|
271
301
|
|
|
272
302
|
/** Declines an ask, with a reason, and delivers the decline to the asker. */
|
|
273
303
|
decline(userID: string, actor: AgentActor, replyTo: string, why: string, options: SendOptions = {}): Promise<Replied> {
|
|
274
|
-
return this.answer(userID, actor, replyTo, options, (input) => this.messages.decline(userID, { ...input, why }))
|
|
304
|
+
return this.answer(userID, actor, replyTo, options, "decline", (input) => this.messages.decline(userID, { ...input, why }))
|
|
275
305
|
}
|
|
276
306
|
|
|
277
307
|
/** A human turn in the session (console prompt or utterance, or typed in the harness): its hop chain starts over. */
|
|
@@ -280,9 +310,10 @@ export class Delivery {
|
|
|
280
310
|
}
|
|
281
311
|
|
|
282
312
|
/** Delivers the user's pending messages whose sessions a deliver-capable spoke reports now, oldest first. */
|
|
283
|
-
|
|
313
|
+
/** Delivers the user's pending messages (only `sessionID`'s when given); flushes run one at a time per user. */
|
|
314
|
+
flush(userID: string, sessionID?: string): Promise<void> {
|
|
284
315
|
const previous = this.flushing.get(userID) ?? Promise.resolve()
|
|
285
|
-
const run = previous.then(() => this.flushNow(userID))
|
|
316
|
+
const run = previous.then(() => this.flushNow(userID, sessionID))
|
|
286
317
|
const tracked = run.finally(() => {
|
|
287
318
|
if (this.flushing.get(userID) === tracked) this.flushing.delete(userID)
|
|
288
319
|
})
|
|
@@ -290,6 +321,21 @@ export class Delivery {
|
|
|
290
321
|
return run
|
|
291
322
|
}
|
|
292
323
|
|
|
324
|
+
/**
|
|
325
|
+
* (CON-10) Drops the messages still pending for a session that ended for good (a retired Concierge session): they
|
|
326
|
+
* are never delivered later, and their delivery reads "dropped" (never waiting). Answers how many were dropped.
|
|
327
|
+
*/
|
|
328
|
+
async dropPending(userID: string, sessionID: string): Promise<number> {
|
|
329
|
+
let dropped = 0
|
|
330
|
+
for (const { key, value, version } of await this.deps.state.list<Pending>(userID, PENDING)) {
|
|
331
|
+
if (value.sessionID !== sessionID) continue
|
|
332
|
+
if (!(await this.deps.state.delete(userID, PENDING, key, { ifVersion: version })).ok) continue
|
|
333
|
+
await this.messages.setDelivery(userID, value.messageID, "dropped")
|
|
334
|
+
dropped++
|
|
335
|
+
}
|
|
336
|
+
return dropped
|
|
337
|
+
}
|
|
338
|
+
|
|
293
339
|
/** Registry.onSessions: a spoke reported its sessions (connected, reconnected, or its periodic snapshot). */
|
|
294
340
|
async onSessions(connID: string): Promise<void> {
|
|
295
341
|
const userID = this.deps.registry.connTenant(connID)
|
|
@@ -378,10 +424,12 @@ export class Delivery {
|
|
|
378
424
|
private async sendTo(userID: string, from: Party, addr: string, resolved: Resolved, entry: DirectoryEntry, kind: "msg" | "ask", text: string, options: SendOptions): Promise<Sent> {
|
|
379
425
|
const sessionID = entry.sessionID
|
|
380
426
|
const recipient: Party = { kind: "session", sessionID, ...(entry.name ? { name: entry.name } : {}) }
|
|
427
|
+
await this.deps.inbound?.outbound?.(userID, from, kind)
|
|
428
|
+
await this.deps.inbound?.admit(userID, from, sessionID, kind)
|
|
381
429
|
this.gate(userID, sessionID)
|
|
382
430
|
await this.limiter.admit(userID, from, recipient)
|
|
383
431
|
const hop = await this.hops.next(userID, from)
|
|
384
|
-
const generation = resolved.address.kind === "role" ? roleGeneration(entry, resolved.address) : undefined
|
|
432
|
+
const generation = resolved.address.kind === "role" && !("team" in resolved) ? roleGeneration(entry, resolved.address) : undefined
|
|
385
433
|
const message = await this.messages.send(userID, {
|
|
386
434
|
from,
|
|
387
435
|
to: addr,
|
|
@@ -463,6 +511,13 @@ export class Delivery {
|
|
|
463
511
|
return (await this.request(userID, sessionID, req)) !== undefined
|
|
464
512
|
}
|
|
465
513
|
|
|
514
|
+
/** The address's sessions (team-aware, HWA-34) without the sender, narrowed to those team policy permits. */
|
|
515
|
+
private async resolveFor(userID: string, actor: AgentActor, from: Party, addr: string, kind: "msg" | "ask"): Promise<Resolved> {
|
|
516
|
+
const team = await this.deps.teams?.prepare(userID, from, addr)
|
|
517
|
+
const resolved = this.others(actor, team?.resolved ?? this.deps.directory.resolve(userID, addr))
|
|
518
|
+
return team ? { ...resolved, sessions: await team.admit(resolved.sessions, kind, this.limiter) } : resolved
|
|
519
|
+
}
|
|
520
|
+
|
|
466
521
|
/** The resolved sessions without the sending session itself; invalid when only it is left (no self-messages). */
|
|
467
522
|
private others(actor: AgentActor, resolved: Resolved): Resolved {
|
|
468
523
|
if (actor.kind !== "session") return resolved
|
|
@@ -487,12 +542,18 @@ export class Delivery {
|
|
|
487
542
|
actor: AgentActor,
|
|
488
543
|
replyTo: string,
|
|
489
544
|
options: SendOptions,
|
|
545
|
+
kind: "reply" | "decline",
|
|
490
546
|
store: (input: { from: Party; replyTo: string; hop: number; generation?: number }) => Promise<{ message: Message; original: Message }>,
|
|
491
547
|
): Promise<Replied> {
|
|
492
548
|
const from = this.party(userID, actor)
|
|
493
549
|
const original = await this.messages.get(userID, replyTo)
|
|
494
550
|
const to = original && partyKey(original.recipient) === partyKey(from) ? original.from : undefined
|
|
495
|
-
if (
|
|
551
|
+
if (original) await this.deps.inbound?.outbound?.(userID, from, kind, original)
|
|
552
|
+
if (to?.kind === "session") {
|
|
553
|
+
await this.deps.inbound?.admit(userID, from, to.sessionID, kind, original)
|
|
554
|
+
this.gate(userID, to.sessionID)
|
|
555
|
+
}
|
|
556
|
+
if (to && original) await this.deps.teams?.answer(userID, from, original, to, kind, this.limiter)
|
|
496
557
|
if (to) await this.limiter.admit(userID, from, to)
|
|
497
558
|
const hop = await this.hops.next(userID, from)
|
|
498
559
|
const generation = original && actor.kind === "session" ? this.holderGeneration(userID, actor.sessionID, original) : undefined
|
|
@@ -559,12 +620,15 @@ export class Delivery {
|
|
|
559
620
|
|
|
560
621
|
/** Delivers now, or queues the message as pending when no spoke takes it. */
|
|
561
622
|
private async deliver(userID: string, message: Message, sessionID: string, wake: boolean, urgent: boolean): Promise<Sent> {
|
|
623
|
+
// Messages still pending for this session go first (e.g. a quiet one a busy spoke refused, RF-05): the turn this
|
|
624
|
+
// one starts then carries them, instead of them arriving inside it.
|
|
625
|
+
if (wake && (await this.deps.state.list<Pending>(userID, PENDING)).some((p) => p.value.sessionID === sessionID)) await this.flush(userID, sessionID)
|
|
562
626
|
const delivered = await this.attempt(userID, message, sessionID, wake, urgent)
|
|
563
|
-
if (delivered) return delivered
|
|
627
|
+
if (delivered) return { ...delivered, woke: wake }
|
|
564
628
|
const at = this.now()
|
|
565
629
|
const pending: Pending = { messageID: message.id, sessionID, wake, urgent, at, expiresAt: at + PENDING_MS }
|
|
566
630
|
await this.deps.state.put(userID, PENDING, message.id, pending)
|
|
567
|
-
return { message: (await this.messages.setDelivery(userID, message.id, "pending")) ?? message, delivery: "pending" }
|
|
631
|
+
return { message: (await this.messages.setDelivery(userID, message.id, "pending")) ?? message, delivery: "pending", woke: wake }
|
|
568
632
|
}
|
|
569
633
|
|
|
570
634
|
/** Tries each deliver-capable connection in turn (same message id: the harness dedupes it). */
|
|
@@ -593,12 +657,20 @@ export class Delivery {
|
|
|
593
657
|
return undefined
|
|
594
658
|
}
|
|
595
659
|
|
|
596
|
-
private async flushNow(userID: string): Promise<void> {
|
|
597
|
-
const entries = (await this.deps.state.list<Pending>(userID, PENDING))
|
|
660
|
+
private async flushNow(userID: string, only?: string): Promise<void> {
|
|
661
|
+
const entries = (await this.deps.state.list<Pending>(userID, PENDING))
|
|
662
|
+
.filter((e) => only === undefined || e.value.sessionID === only)
|
|
663
|
+
.sort((a, b) => a.value.at - b.value.at || (a.key < b.key ? -1 : 1))
|
|
598
664
|
/** Cluster mode: the sessions whose lease this flush holds (true) or another process holds (false). */
|
|
599
665
|
const leases = new Map<string, boolean>()
|
|
666
|
+
/** Sessions whose delivery failed in this flush: their later messages wait for the next frame (in order). */
|
|
667
|
+
const failed = new Set<string>()
|
|
600
668
|
try {
|
|
601
669
|
for (const { key, value, version } of entries) {
|
|
670
|
+
if (failed.has(value.sessionID)) {
|
|
671
|
+
this.flushStats.skipped++
|
|
672
|
+
continue
|
|
673
|
+
}
|
|
602
674
|
const message = value.expiresAt > this.now() ? await this.messages.get(userID, value.messageID) : undefined
|
|
603
675
|
// Gone, or an ask that closed (expired) while it waited: nothing to deliver.
|
|
604
676
|
if (!message || (message.kind === "ask" && message.state !== "open")) {
|
|
@@ -610,6 +682,10 @@ export class Delivery {
|
|
|
610
682
|
// Another process may have delivered it before this one took the lease.
|
|
611
683
|
if (this.deps.locks && !(await this.deps.state.get(userID, PENDING, key))) continue
|
|
612
684
|
if (await this.attempt(userID, message, value.sessionID, value.wake, value.urgent)) await this.deps.state.delete(userID, PENDING, key)
|
|
685
|
+
else {
|
|
686
|
+
failed.add(value.sessionID)
|
|
687
|
+
this.flushStats.failed++
|
|
688
|
+
}
|
|
613
689
|
}
|
|
614
690
|
} finally {
|
|
615
691
|
const { locks } = this.deps
|
|
@@ -635,7 +711,11 @@ export class Delivery {
|
|
|
635
711
|
return held
|
|
636
712
|
}
|
|
637
713
|
|
|
638
|
-
/**
|
|
714
|
+
/**
|
|
715
|
+
* `steer`: urgent, or the first msg after a primer to an idle session (see attempt). A wake:false msg keeps
|
|
716
|
+
* "queue" unless urgent: that marks it routine, and the OpenCode spoke (RF-05) admits it steered without resume,
|
|
717
|
+
* refusing it while a turn runs so it stays pending here until the turn ends (never a model call of its own).
|
|
718
|
+
*/
|
|
639
719
|
private deliverRequest(message: Message, sessionID: string, wake: boolean, steer: boolean): DeliverRequest {
|
|
640
720
|
const delivery: DeliveryMode = message.kind === "msg" && !steer ? "queue" : "steer"
|
|
641
721
|
return {
|