@huynle/hub 0.7.4 → 0.8.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 +430 -1
- package/Dockerfile +7 -2
- package/README.md +230 -32
- 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/paths.ts +3 -3
- 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 +6871 -3460
- 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/adapters/node-services/render.ts +2 -2
- package/packages/cli/src/bootstrap/discovery/host.ts +15 -1
- package/packages/cli/src/bootstrap/discovery/probe.ts +1 -1
- package/packages/cli/src/bootstrap/enrollment/enroll.ts +21 -9
- package/packages/cli/src/bootstrap/enrollment/policy.ts +138 -12
- package/packages/cli/src/bootstrap/enrollment/status.ts +3 -2
- package/packages/cli/src/cli.ts +96 -14
- package/packages/cli/src/format.ts +48 -10
- package/packages/cli/src/http.ts +2 -0
- package/packages/cli/src/intents.ts +23 -0
- package/packages/cli/src/launches.ts +9 -3
- package/packages/cli/src/login.ts +20 -6
- package/packages/cli/src/mcp.ts +385 -10
- package/packages/cli/src/messaging.ts +7 -3
- package/packages/cli/src/profiles-mcp.ts +301 -0
- package/packages/cli/src/setup/commands.ts +23 -4
- package/packages/cli/src/setup/runtime.ts +3 -2
- package/packages/cli/src/spoke-services.ts +1 -1
- package/packages/cli/src/team-intake-mcp.ts +158 -0
- package/packages/cli/src/teams-mcp.ts +460 -0
- package/packages/cli/src/watchlist.ts +135 -0
- package/packages/codex/package.json +1 -1
- package/packages/codex/src/paths.ts +3 -3
- 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 +104 -2
- package/packages/node/src/opencode-warm.ts +344 -0
- package/packages/node/src/run.ts +28 -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 +175 -6
- package/packages/protocol/src/launch.ts +36 -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 +4 -1
- package/packages/sdk/src/index.ts +6 -0
- package/packages/sdk/src/team-intake.ts +122 -0
- package/packages/sdk/src/teams.ts +346 -0
- package/packages/sdk/src/types.ts +568 -1
- package/packages/sdk/src/wake.ts +20 -0
- 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 +218 -10
- package/src/central/agent/app-tokens.ts +3 -2
- package/src/central/agent/audit.ts +18 -1
- package/src/central/agent/body.ts +163 -0
- package/src/central/agent/decisions.ts +125 -2
- package/src/central/agent/delivery.ts +194 -40
- package/src/central/agent/directory-routes.ts +37 -7
- package/src/central/agent/directory.ts +70 -14
- package/src/central/agent/errors.ts +7 -0
- package/src/central/agent/escalation.ts +10 -2
- package/src/central/agent/events-routes.ts +21 -4
- package/src/central/agent/events.ts +18 -4
- 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 +189 -32
- package/src/central/agent/launch-routes.ts +26 -10
- package/src/central/agent/launch-spec.ts +40 -3
- package/src/central/agent/launch-store.ts +55 -2
- package/src/central/agent/message-limits.ts +89 -11
- package/src/central/agent/message-routes.ts +10 -4
- package/src/central/agent/messages.ts +28 -2
- package/src/central/agent/names.ts +12 -2
- package/src/central/agent/placement.ts +93 -18
- package/src/central/agent/read-routes.ts +89 -19
- package/src/central/agent/routes.ts +178 -5
- package/src/central/agent/webhook-routes.ts +2 -1
- package/src/central/agent/webhooks.ts +17 -2
- 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 +776 -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 +350 -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 +86 -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 +131 -0
- package/src/central/concierge/proposals.ts +268 -0
- package/src/central/concierge/push-remap.ts +49 -0
- package/src/central/concierge/routes.ts +122 -0
- package/src/central/concierge/schedule.ts +113 -0
- package/src/central/concierge/service.ts +243 -0
- package/src/central/concierge/session-routes.ts +125 -0
- package/src/central/concierge/settings.ts +217 -0
- package/src/central/concierge/supervisor.ts +767 -0
- package/src/central/concierge/tiers.ts +260 -0
- package/src/central/concierge/watchlist-routes.ts +279 -0
- package/src/central/concierge/watchlist.ts +273 -0
- package/src/central/config.ts +28 -6
- package/src/central/http.ts +660 -54
- package/src/central/main.ts +1 -1
- 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 +69 -7
- 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 +786 -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 +910 -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 +126 -23
- package/src/migrate.ts +109 -3
- package/src/spoke/concierge-agent.ts +33 -0
- package/src/spoke/deliver.ts +22 -4
- package/src/spoke/env-inject.ts +8 -1
- package/src/spoke/fork-revert.ts +142 -0
- package/src/spoke/hub-rpc.ts +41 -5
- package/src/spoke/hub-tool.ts +404 -25
- package/src/spoke/launchers.ts +6 -4
- package/src/spoke/listing.ts +65 -10
- package/src/spoke/localhub.ts +14 -4
- package/src/spoke/locations.ts +40 -0
- package/src/spoke/profile-apply.ts +298 -0
- package/src/spoke/session-context.ts +256 -0
- package/src/spoke/spoke.ts +330 -23
- 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-mNzjlP_U.js +1 -0
- package/web/dist/assets/ArtifactPane-CPQcuX7c.js +1 -0
- package/web/dist/assets/ArtifactWorkbench-9eTj1CeW.css +1 -0
- package/web/dist/assets/ArtifactWorkbench-NoFC5Sb2.js +6 -0
- package/web/dist/assets/Library-DYsFj_5c.css +1 -0
- package/web/dist/assets/Library-LXHsP5Fw.js +2 -0
- package/web/dist/assets/ProfilesDestination-DhVVcAep.js +1 -0
- package/web/dist/assets/TeamsPage-DBrO5hoL.js +1 -0
- package/web/dist/assets/Watchtower-CTgqHk5g.css +1 -0
- package/web/dist/assets/Watchtower-DxaJYQp_.js +2 -0
- package/web/dist/assets/index-BabrNDB3.css +1 -0
- package/web/dist/assets/index-lIOfSYck.js +34 -0
- package/web/dist/assets/live-BDq0AABg.js +5 -0
- package/web/dist/assets/live-BUofsQpz.css +1 -0
- package/web/dist/assets/pane-layout-DWXfDU-1.js +1 -0
- package/web/dist/assets/plural-Bspvoqfq.js +1 -0
- package/web/dist/assets/renderer2d-DAr4IHM8.js +1 -0
- package/web/dist/assets/renderer3d-DWZj595u.js +5393 -0
- package/web/dist/assets/safe-area-JUb6OeI3.js +1 -0
- package/web/dist/assets/source-CDU04kYw.js +1 -0
- package/web/dist/assets/ui-Dh1-78Cl.js +3 -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
|
@@ -0,0 +1,221 @@
|
|
|
1
|
+
// CON-04 the Concierge session's per-turn context (contract "Per-turn context"): the plugin's context hook is
|
|
2
|
+
// synchronous, so the hub PUSHES {instructions, board, notes}, tools.deny and lockAgent to the spoke that reports the
|
|
3
|
+
// session (op sessionContext, announced by the OpenCode spoke; the cluster's spokes.request routes it to the pod
|
|
4
|
+
// holding the spoke). The whole set replaces the previous one.
|
|
5
|
+
//
|
|
6
|
+
// - instructions: prompt.ts (versioned constant). board: the grant line, then renderBoard (its header carries "as
|
|
7
|
+
// of <time>", so a context the spoke kept after a failed send shows its age). notes: the Concierge's notes,
|
|
8
|
+
// framed as its own memory. Untrusted session text stays inside the board's «…» framing.
|
|
9
|
+
// - tools.allow: CONCIERGE_TOOLS exactly (hub, read, glob, grep, question): every other tool is removed on every
|
|
10
|
+
// turn, unknown future ones included (fail closed). No subagent/task, execute (Code Mode), web tools, shell, edit,
|
|
11
|
+
// MCP or plugin tools. The Brain connector is not offered in C1 (settings.ts refuses it).
|
|
12
|
+
// - Revisions grow with the clock (max(now, last + 1)), so a restarted hub never sends an older one; a "stale"
|
|
13
|
+
// answer (another pod's clock ran ahead) retries once with a later revision.
|
|
14
|
+
// - Unchanged context is not re-sent to the same connection; a session reported on another connection (a spoke
|
|
15
|
+
// restart or reconnect) gets it again. A failed send keeps the last revision and is retried by the next push.
|
|
16
|
+
// - touch(): board refreshes debounced per user (5 s quiet, 30 s at most; CON-13 `soon`, for a blocker opened or
|
|
17
|
+
// closed: within 1 s, and later plain touches never push it back). beforeWake(): a push awaited at most 2 s
|
|
18
|
+
// before a Concierge wake message (CON-05). Nothing here ever rejects.
|
|
19
|
+
import { CONCIERGE_TOOLS, SESSION_CONTEXT_FEATURE, SESSION_CONTEXT_LIMITS, type SessionContextRequest, type SessionContextResult } from "../../../packages/protocol/src/concierge"
|
|
20
|
+
import type { Clock, Timers } from "../backend/types"
|
|
21
|
+
import type { NotesStore } from "./notes"
|
|
22
|
+
import { conciergeInstructions, grantLine, type GrantLineInput, NOTES_HEADER, RETIRED_INSTRUCTIONS } from "./prompt"
|
|
23
|
+
import type { SettingsStore } from "./settings"
|
|
24
|
+
|
|
25
|
+
export { CONCIERGE_TOOLS } from "../../../packages/protocol/src/concierge"
|
|
26
|
+
export const CONTEXT_TIMING = { quietMs: 5_000, maxMs: 30_000, wakeBudgetMs: 2_000, soonMs: 1_000 } as const
|
|
27
|
+
/** At most one forced re-send (a report without the current revision) per session in this window. */
|
|
28
|
+
export const CONCIERGE_FORCED_PUSH_MS = 2_000
|
|
29
|
+
/** OpenCode's internal agents (title generation, compaction, summaries): never tamper. */
|
|
30
|
+
export const INTERNAL_AGENTS: ReadonlySet<string> = new Set(["title", "compaction", "summary"])
|
|
31
|
+
/** OpenCode's primary agents and the plugin's (an agent outside this list is logged when a Concierge is found on it). */
|
|
32
|
+
export const KNOWN_PRIMARY_AGENTS: ReadonlySet<string> = new Set(["build", "plan", "general", "explore", "voice"])
|
|
33
|
+
const STALE_BUMP_MS = 60_000
|
|
34
|
+
|
|
35
|
+
/** The grant the board header names (Grants.get with its state); undefined when unknown. */
|
|
36
|
+
export interface ContextGrant {
|
|
37
|
+
readonly id: string
|
|
38
|
+
readonly state: "active" | "expired" | "revoked"
|
|
39
|
+
readonly allow: readonly string[]
|
|
40
|
+
readonly scope: { readonly projects: readonly string[] }
|
|
41
|
+
readonly expiresAt: number
|
|
42
|
+
readonly revokedAt?: number
|
|
43
|
+
/** (CON-13) Its permission rules, for the grant line. */
|
|
44
|
+
readonly permissions?: { readonly allow: readonly string[]; readonly never: readonly string[] }
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
export interface ContextDeps {
|
|
48
|
+
readonly registry: { sessionTargets(sessionID: string): string[]; supports(connID: string, feature: typeof SESSION_CONTEXT_FEATURE): boolean; connTenant(connID: string): string | undefined }
|
|
49
|
+
readonly send: (connID: string, req: SessionContextRequest) => Promise<SessionContextResult>
|
|
50
|
+
/** The rendered board of the user (renderBoard). */
|
|
51
|
+
readonly board: (userID: string) => Promise<string>
|
|
52
|
+
readonly notes: Pick<NotesStore, "get">
|
|
53
|
+
readonly settings: Pick<SettingsStore, "get">
|
|
54
|
+
readonly grant: (userID: string, grantID: string) => Promise<ContextGrant | undefined>
|
|
55
|
+
/** (CON-09a) Speech is set up for the user (the instructions mention voice and voice confirm). Absent: false. */
|
|
56
|
+
readonly voice?: (userID: string) => boolean
|
|
57
|
+
readonly now?: Clock
|
|
58
|
+
readonly timers?: Timers
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
const defaultTimers: Timers = {
|
|
62
|
+
set: (fn, ms) => {
|
|
63
|
+
const handle = setTimeout(fn, ms)
|
|
64
|
+
;(handle as { unref?: () => void }).unref?.()
|
|
65
|
+
return handle
|
|
66
|
+
},
|
|
67
|
+
clear: (handle) => clearTimeout(handle as ReturnType<typeof setTimeout>),
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
type Body = Omit<SessionContextRequest, "revision">
|
|
71
|
+
|
|
72
|
+
const cut = (text: string) => (text.length > SESSION_CONTEXT_LIMITS.partChars ? `${text.slice(0, SESSION_CONTEXT_LIMITS.partChars - 1)}…` : text)
|
|
73
|
+
|
|
74
|
+
export class ContextPusher {
|
|
75
|
+
private readonly now: Clock
|
|
76
|
+
private readonly timers: Timers
|
|
77
|
+
private last = 0
|
|
78
|
+
/** Per user + session: what the spoke has (its digest, connection and revision). */
|
|
79
|
+
private readonly sent = new Map<string, { digest: string; connID: string; revision: number }>()
|
|
80
|
+
private readonly debounces = new Map<string, { first: number; due: number; timer: unknown }>()
|
|
81
|
+
|
|
82
|
+
constructor(private readonly deps: ContextDeps) {
|
|
83
|
+
this.now = deps.now ?? Date.now
|
|
84
|
+
this.timers = deps.timers ?? defaultTimers
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
/** The user's connection reporting the session that serves sessionContext (the owner first). */
|
|
88
|
+
private target(userID: string, sessionID: string): string | undefined {
|
|
89
|
+
const { registry } = this.deps
|
|
90
|
+
return registry.sessionTargets(sessionID).find((connID) => registry.connTenant(connID) === userID && registry.supports(connID, SESSION_CONTEXT_FEATURE))
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
private revision(bump = 0): number {
|
|
94
|
+
this.last = Math.max(this.now() + bump, this.last + 1)
|
|
95
|
+
return this.last
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
private async grantInput(userID: string, grantID: string | undefined): Promise<GrantLineInput> {
|
|
99
|
+
if (grantID === undefined) return { state: "none" }
|
|
100
|
+
const grant = await this.deps.grant(userID, grantID)
|
|
101
|
+
if (!grant) return { state: "none" }
|
|
102
|
+
if (grant.state === "active") return { state: "active", id: grant.id, allow: grant.allow, projects: grant.scope.projects, expiresAt: grant.expiresAt, ...(grant.permissions ? { permissions: grant.permissions } : {}) }
|
|
103
|
+
return { state: "ended", id: grant.id, at: grant.revokedAt ?? grant.expiresAt }
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
private async build(userID: string, sessionID: string): Promise<Body> {
|
|
107
|
+
const [settings, notes, board] = await Promise.all([this.deps.settings.get(userID), this.deps.notes.get(userID), this.deps.board(userID)])
|
|
108
|
+
const grant = await this.grantInput(userID, settings.grantID)
|
|
109
|
+
const parts: SessionContextRequest["parts"][number][] = [
|
|
110
|
+
{ key: "instructions", text: cut(conciergeInstructions({ voice: this.deps.voice?.(userID) === true, timeZone: settings.timeZone })) },
|
|
111
|
+
{ key: "board", text: cut(`${grantLine(grant, settings.timeZone)}\n${board}`) },
|
|
112
|
+
...(notes.text ? [{ key: "notes" as const, text: cut(`${NOTES_HEADER}\n${notes.text}`) }] : []),
|
|
113
|
+
]
|
|
114
|
+
return { op: "sessionContext", sessionID, parts, tools: { allow: [...CONCIERGE_TOOLS] }, lockAgent: true }
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
private async send(key: string, connID: string, body: Body, digest: string): Promise<boolean> {
|
|
118
|
+
for (let attempt = 0; attempt < 2; attempt++) {
|
|
119
|
+
const revision = this.revision(attempt ? STALE_BUMP_MS : 0)
|
|
120
|
+
try {
|
|
121
|
+
const result = await this.deps.send(connID, { ...body, revision } as SessionContextRequest)
|
|
122
|
+
this.sent.set(key, { digest, connID, revision: result.revision })
|
|
123
|
+
return true
|
|
124
|
+
} catch (cause) {
|
|
125
|
+
if (attempt === 0 && /stale/i.test((cause as Error)?.message ?? "")) continue
|
|
126
|
+
console.error(`[hub] concierge context push failed: ${(cause as Error)?.message ?? String(cause)}`.slice(0, 300))
|
|
127
|
+
return false
|
|
128
|
+
}
|
|
129
|
+
}
|
|
130
|
+
return false
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
/** Builds and sends the session's context now (force: even when unchanged). True when the spoke has it. */
|
|
134
|
+
async push(userID: string, sessionID: string, options: { readonly force?: boolean } = {}): Promise<boolean> {
|
|
135
|
+
try {
|
|
136
|
+
const connID = this.target(userID, sessionID)
|
|
137
|
+
if (connID === undefined) return false
|
|
138
|
+
const body = await this.build(userID, sessionID)
|
|
139
|
+
const digest = JSON.stringify(body)
|
|
140
|
+
const key = `${userID}\n${sessionID}`
|
|
141
|
+
const known = this.sent.get(key)
|
|
142
|
+
if (!options.force && known && known.digest === digest && known.connID === connID) return true
|
|
143
|
+
return await this.send(key, connID, body, digest)
|
|
144
|
+
} catch (cause) {
|
|
145
|
+
console.error(`[hub] concierge context build failed: ${(cause as Error)?.message ?? String(cause)}`.slice(0, 300))
|
|
146
|
+
return false
|
|
147
|
+
}
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
/** A push awaited at most CONTEXT_TIMING.wakeBudgetMs (before a Concierge wake). */
|
|
151
|
+
beforeWake(userID: string, sessionID: string): Promise<boolean> {
|
|
152
|
+
return new Promise<boolean>((resolve) => {
|
|
153
|
+
const timer = this.timers.set(() => resolve(false), CONTEXT_TIMING.wakeBudgetMs)
|
|
154
|
+
void this.push(userID, sessionID).then((ok) => {
|
|
155
|
+
this.timers.clear(timer)
|
|
156
|
+
resolve(ok)
|
|
157
|
+
})
|
|
158
|
+
})
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
/**
|
|
162
|
+
* Something on the user's board may have changed: a push after 5 s quiet (30 s at most from the first touch).
|
|
163
|
+
* (CON-13) `soon` (a blocker opened or closed: the Concierge must not tell the user about a form already answered)
|
|
164
|
+
* caps it at 1 s from now; no later touch moves a push past a cap already set.
|
|
165
|
+
*/
|
|
166
|
+
touch(userID: string, session: () => Promise<string | undefined>, options: { readonly soon?: boolean } = {}): void {
|
|
167
|
+
const t = this.now()
|
|
168
|
+
const known = this.debounces.get(userID)
|
|
169
|
+
if (known) this.timers.clear(known.timer)
|
|
170
|
+
const first = known?.first ?? t
|
|
171
|
+
const due = Math.min(known?.due ?? first + CONTEXT_TIMING.maxMs, options.soon ? t + CONTEXT_TIMING.soonMs : Infinity)
|
|
172
|
+
const wait = Math.max(0, Math.min(CONTEXT_TIMING.quietMs, due - t))
|
|
173
|
+
const timer = this.timers.set(() => {
|
|
174
|
+
this.debounces.delete(userID)
|
|
175
|
+
void session()
|
|
176
|
+
.then((sessionID) => (sessionID !== undefined ? this.push(userID, sessionID) : false))
|
|
177
|
+
.catch(() => false)
|
|
178
|
+
}, wait)
|
|
179
|
+
this.debounces.set(userID, { first, due, timer })
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
/** The revision the spoke acknowledged last for the session (undefined: none). */
|
|
183
|
+
revisionOf(userID: string, sessionID: string): number | undefined {
|
|
184
|
+
return this.sent.get(`${userID}\n${sessionID}`)?.revision
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
/**
|
|
188
|
+
* (CON-09a) Retires a previous Concierge session: its context becomes RETIRED_INSTRUCTIONS only, no tools, the
|
|
189
|
+
* agent locked. Like push, unchanged is not re-sent to the same connection unless `force` (the spoke reported
|
|
190
|
+
* another revision: it lost it). True when the spoke has it. Never rejects.
|
|
191
|
+
*/
|
|
192
|
+
async retire(userID: string, sessionID: string, options: { readonly force?: boolean } = {}): Promise<boolean> {
|
|
193
|
+
try {
|
|
194
|
+
const connID = this.target(userID, sessionID)
|
|
195
|
+
if (connID === undefined) return false
|
|
196
|
+
const body: Body = { op: "sessionContext", sessionID, parts: [{ key: "instructions", text: RETIRED_INSTRUCTIONS }], tools: { allow: [] }, lockAgent: true }
|
|
197
|
+
const digest = JSON.stringify(body)
|
|
198
|
+
const key = `${userID}\n${sessionID}`
|
|
199
|
+
const known = this.sent.get(key)
|
|
200
|
+
if (!options.force && known && known.digest === digest && known.connID === connID) return true
|
|
201
|
+
return await this.send(key, connID, body, digest)
|
|
202
|
+
} catch (cause) {
|
|
203
|
+
console.error(`[hub] concierge retire failed: ${(cause as Error)?.message ?? String(cause)}`.slice(0, 300))
|
|
204
|
+
return false
|
|
205
|
+
}
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
/** Drops the session's context on its spoke (disable). Never rejects. */
|
|
209
|
+
async clear(userID: string, sessionID: string): Promise<void> {
|
|
210
|
+
this.sent.delete(`${userID}\n${sessionID}`)
|
|
211
|
+
const connID = this.target(userID, sessionID)
|
|
212
|
+
if (connID === undefined) return
|
|
213
|
+
await this.deps.send(connID, { op: "sessionContext", sessionID, revision: this.revision(), parts: [], clear: true }).catch(() => undefined)
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
/** Forgets everything (the hub stops). */
|
|
217
|
+
stop(): void {
|
|
218
|
+
for (const { timer } of this.debounces.values()) this.timers.clear(timer)
|
|
219
|
+
this.debounces.clear()
|
|
220
|
+
}
|
|
221
|
+
}
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
// CON-04 entitlements hook (contract "C2 hook point (not built)"): the service calls check() on enable and before
|
|
2
|
+
// every launch or relaunch, and record() after each launch, wake or digest. C1 has no licensing, metering or
|
|
3
|
+
// hosting: the default always answers ok and records nothing.
|
|
4
|
+
|
|
5
|
+
export interface ConciergeEntitlements {
|
|
6
|
+
check(userID: string): Promise<{ ok: true } | { ok: false; reason: string }>
|
|
7
|
+
record(userID: string, usage: { kind: "launch" | "wake" | "digest"; n: number }): void
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
export const C1_ENTITLEMENTS: ConciergeEntitlements = {
|
|
11
|
+
check: async () => ({ ok: true }),
|
|
12
|
+
record: () => undefined,
|
|
13
|
+
}
|
|
@@ -0,0 +1,120 @@
|
|
|
1
|
+
// CON-04 manage paths that must not touch a Concierge session (current or hidden): its identity, prompt, presence
|
|
2
|
+
// and placement are the hub's. Apps and sessions (the Concierge itself included) get 403 forbidden {policy:
|
|
3
|
+
// "concierge"} on intent, archive and pin. EVERYONE (the user too) is refused on naming (the Concierge holds no
|
|
4
|
+
// @name), profile pinning (its prompt is hub-owned), and on making it a workspace member or master (by sessionID,
|
|
5
|
+
// @name, @@role or launchID), a side-chat source, or a team seat (attach, replace): it is hub-owned. Assignments
|
|
6
|
+
// and supervision name members, which it can never be. Role claims are guarded in directory-routes.ts, grants in
|
|
7
|
+
// grants.ts, messages in inbound.ts, act verbs in acting.ts.
|
|
8
|
+
//
|
|
9
|
+
// guardConciergeRoutes wraps the routes listed in CONCIERGE_GUARDED: before the route runs, it resolves the
|
|
10
|
+
// targets (the `:addr` path parameter, the listed body fields of a bounded clone of the body, or the calling
|
|
11
|
+
// session when a self route names none) for the caller's user and refuses when one is a Concierge session.
|
|
12
|
+
// Unresolvable targets are left to the route (its own not_found / invalid).
|
|
13
|
+
import type { AgentActor } from "../agent/actor"
|
|
14
|
+
import { boundedJson } from "../agent/body"
|
|
15
|
+
import { AgentError } from "../agent/errors"
|
|
16
|
+
import type { AgentContext, AgentRoute } from "../agent/routes"
|
|
17
|
+
import type { ConciergeIdentity } from "./watchlist-routes"
|
|
18
|
+
|
|
19
|
+
export interface GuardRule {
|
|
20
|
+
readonly method: string
|
|
21
|
+
readonly path: string
|
|
22
|
+
/** The user is refused too. */
|
|
23
|
+
readonly everyone?: boolean
|
|
24
|
+
/** The `:addr` path parameter names the target. */
|
|
25
|
+
readonly addr?: boolean
|
|
26
|
+
/** Body fields naming the target (an address or a session ID). */
|
|
27
|
+
readonly body?: readonly string[]
|
|
28
|
+
/** Body fields naming a launch: its session, and any Concierge launch itself, is the target. */
|
|
29
|
+
readonly launch?: readonly string[]
|
|
30
|
+
/** A session caller that names no target acts on itself. */
|
|
31
|
+
readonly self?: boolean
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
const A = "/api/agent"
|
|
35
|
+
export const CONCIERGE_GUARDED: readonly GuardRule[] = [
|
|
36
|
+
{ method: "PUT", path: `${A}/sessions/:addr/intent`, addr: true },
|
|
37
|
+
{ method: "PUT", path: `${A}/sessions/:addr/archive`, addr: true },
|
|
38
|
+
{ method: "PUT", path: `${A}/sessions/:addr/pin`, addr: true },
|
|
39
|
+
{ method: "POST", path: `${A}/name`, everyone: true, body: ["session"], self: true },
|
|
40
|
+
{ method: "PUT", path: `${A}/sessions/:addr/profile`, everyone: true, addr: true },
|
|
41
|
+
{ method: "POST", path: `${A}/workspaces/:id/members`, everyone: true, body: ["sessionID"] },
|
|
42
|
+
{ method: "POST", path: `${A}/workspaces/:id/master`, everyone: true, body: ["sessionID"], launch: ["launchID"] },
|
|
43
|
+
{ method: "POST", path: `${A}/workspaces/:id/side-chats`, everyone: true, body: ["sourceSessionID"] },
|
|
44
|
+
{ method: "PUT", path: `${A}/teams/:id/staffing`, everyone: true, body: ["sessionID"] },
|
|
45
|
+
{ method: "POST", path: `${A}/teams/:id/staffing/:staffing/replace`, everyone: true, body: ["sessionID"] },
|
|
46
|
+
]
|
|
47
|
+
|
|
48
|
+
export const conciergeRefusal = (message = "This does not apply to the Concierge: the hub manages it") => new AgentError("forbidden", message, { extra: { policy: "concierge" } })
|
|
49
|
+
|
|
50
|
+
export interface GuardDeps {
|
|
51
|
+
readonly identity: ConciergeIdentity
|
|
52
|
+
/** The session IDs an address names for the user (empty when it names none). */
|
|
53
|
+
readonly resolve: (userID: string, addr: string) => readonly string[]
|
|
54
|
+
/** A launch of the user: its session and whether it is a Concierge launch (undefined: unknown). */
|
|
55
|
+
readonly launch?: (userID: string, launchID: string) => Promise<{ readonly sessionID?: string; readonly concierge: boolean } | undefined>
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
const CONCIERGE_LAUNCH = "\u0000concierge-launch"
|
|
59
|
+
|
|
60
|
+
async function targets(rule: GuardRule, context: AgentContext, deps: GuardDeps): Promise<string[]> {
|
|
61
|
+
const { user, actor, params, request } = context
|
|
62
|
+
const out: string[] = []
|
|
63
|
+
const add = (addr: unknown) => {
|
|
64
|
+
if (typeof addr !== "string" || !addr) return
|
|
65
|
+
out.push(addr)
|
|
66
|
+
try {
|
|
67
|
+
out.push(...deps.resolve(user.id, addr))
|
|
68
|
+
} catch {
|
|
69
|
+
// Unknown or malformed: the route answers it.
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
if (rule.addr && params.addr !== undefined) add(params.addr)
|
|
73
|
+
let named = false
|
|
74
|
+
if (rule.body || rule.launch) {
|
|
75
|
+
let body: unknown
|
|
76
|
+
try {
|
|
77
|
+
body = await boundedJson(request.clone())
|
|
78
|
+
} catch {
|
|
79
|
+
body = undefined
|
|
80
|
+
}
|
|
81
|
+
if (typeof body === "object" && body !== null) {
|
|
82
|
+
for (const key of rule.body ?? []) {
|
|
83
|
+
const value = (body as Record<string, unknown>)[key]
|
|
84
|
+
if (value !== undefined) named = true
|
|
85
|
+
add(value)
|
|
86
|
+
}
|
|
87
|
+
for (const key of rule.launch ?? []) {
|
|
88
|
+
const value = (body as Record<string, unknown>)[key]
|
|
89
|
+
if (typeof value !== "string" || !deps.launch) continue
|
|
90
|
+
named = true
|
|
91
|
+
const launch = await deps.launch(user.id, value).catch(() => undefined)
|
|
92
|
+
if (launch?.concierge) out.push(CONCIERGE_LAUNCH)
|
|
93
|
+
if (launch?.sessionID !== undefined) out.push(launch.sessionID)
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
if (rule.self && !named && actor.kind === "session") out.push(actor.sessionID)
|
|
98
|
+
return out
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
const applies = (rule: GuardRule, actor: AgentActor) => rule.everyone === true || actor.kind !== "user"
|
|
102
|
+
|
|
103
|
+
/** The routes with the Concierge guard in front of the listed ones. */
|
|
104
|
+
export function guardConciergeRoutes(routes: readonly AgentRoute[], deps: GuardDeps): AgentRoute[] {
|
|
105
|
+
return routes.map((route) => {
|
|
106
|
+
const rule = CONCIERGE_GUARDED.find((r) => r.method === route.method && r.path === route.path)
|
|
107
|
+
if (!rule) return route
|
|
108
|
+
return {
|
|
109
|
+
...route,
|
|
110
|
+
handle: async (context: AgentContext) => {
|
|
111
|
+
if (applies(rule, context.actor)) {
|
|
112
|
+
const hidden = new Set((await deps.identity.current(context.user.id)).hiddenSessionIDs)
|
|
113
|
+
const named = await targets(rule, context, deps)
|
|
114
|
+
if (named.includes(CONCIERGE_LAUNCH) || (hidden.size && named.some((id) => hidden.has(id)))) throw conciergeRefusal()
|
|
115
|
+
}
|
|
116
|
+
return route.handle(context)
|
|
117
|
+
},
|
|
118
|
+
}
|
|
119
|
+
})
|
|
120
|
+
}
|
|
@@ -0,0 +1,86 @@
|
|
|
1
|
+
// CON-04 inbound rule (contract: docs/plans/2026-11-01-concierge-contracts.md, "Inbound rule"). The Concierge
|
|
2
|
+
// holds a grant, so other parties must not steer it: delivery TO any of the user's Concierge sessions (the current
|
|
3
|
+
// one and the hidden previous ones) is admitted only from
|
|
4
|
+
// - the user,
|
|
5
|
+
// - the fixed hub party app:concierge (conciergeActor: the Concierge service's events and digests), and
|
|
6
|
+
// - a reply or decline to an ask the Concierge session itself sent.
|
|
7
|
+
// Anything else is 403 forbidden {policy: "concierge"} before anything is stored, with an agent audit entry
|
|
8
|
+
// (verb msg, decision forbidden; never the text). Outbound (coordinator decision): a Concierge session messages and
|
|
9
|
+
// asks only @me, and replies or declines only asks addressed to it; it reaches other sessions only through its
|
|
10
|
+
// grant-gated act verbs. The acting guard (conciergeProtects) keeps agents from acting
|
|
11
|
+
// on a Concierge session under a grant (prompt, stop, approve…): only the user does. A failed identity read
|
|
12
|
+
// refuses (fails closed).
|
|
13
|
+
import { CONCIERGE_PARTY } from "../../../packages/protocol/src/concierge"
|
|
14
|
+
import type { AppActor } from "../agent/actor"
|
|
15
|
+
import type { AgentAudit } from "../agent/audit"
|
|
16
|
+
import type { InboundKind, InboundPolicy } from "../agent/delivery"
|
|
17
|
+
import type { Party } from "../agent/envelope"
|
|
18
|
+
import { AgentError } from "../agent/errors"
|
|
19
|
+
import type { Message } from "../agent/messages"
|
|
20
|
+
import type { ConciergeIdentity } from "./watchlist-routes"
|
|
21
|
+
|
|
22
|
+
/** The token ID of the fixed hub party app:concierge (never a real token: those are hat_<16 hex>). */
|
|
23
|
+
export const CONCIERGE_TOKEN = CONCIERGE_PARTY.slice("app:".length)
|
|
24
|
+
|
|
25
|
+
/** The Concierge service as a sender: launches and its event and digest messages (never the user). */
|
|
26
|
+
export const conciergeActor = (userID: string): AppActor => ({ kind: "app", userID, tokenID: CONCIERGE_TOKEN, scopes: [], label: "concierge" })
|
|
27
|
+
|
|
28
|
+
export const CONCIERGE_INBOUND_MESSAGE = "The Concierge takes messages only from you and the hub: sessions reach the user with ask @me"
|
|
29
|
+
|
|
30
|
+
const isConcierge = (current: { readonly sessionID?: string; readonly hiddenSessionIDs: readonly string[] }, sessionID: string) =>
|
|
31
|
+
current.sessionID === sessionID || current.hiddenSessionIDs.includes(sessionID)
|
|
32
|
+
|
|
33
|
+
/** Whether `from` may send `kind` to the Concierge session `sessionID` (see the header). */
|
|
34
|
+
export function admitted(from: Party, sessionID: string, kind: InboundKind, original?: Message): boolean {
|
|
35
|
+
if (from.kind === "user") return true
|
|
36
|
+
if (from.kind === "app" && from.tokenID === CONCIERGE_TOKEN) return true
|
|
37
|
+
if ((kind === "reply" || kind === "decline") && original?.kind === "ask" && original.from.kind === "session" && original.from.sessionID === sessionID) return true
|
|
38
|
+
return false
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
export interface ConciergeInboundDeps {
|
|
42
|
+
readonly identity: ConciergeIdentity
|
|
43
|
+
readonly audit?: Pick<AgentAudit, "record">
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
/** The Delivery inbound policy (Delivery deps.inbound). */
|
|
47
|
+
export function conciergeInbound(deps: ConciergeInboundDeps): InboundPolicy {
|
|
48
|
+
const current = async (userID: string) => {
|
|
49
|
+
try {
|
|
50
|
+
return await deps.identity.current(userID)
|
|
51
|
+
} catch (cause) {
|
|
52
|
+
// (FX-07) A timed-out read is `busy` (503, refused); any other failure an internal error (refused too).
|
|
53
|
+
if (cause instanceof AgentError) throw cause
|
|
54
|
+
throw new AgentError("internal", `Could not check the Concierge inbound rule: ${(cause as Error).message}`.slice(0, 200))
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
return {
|
|
58
|
+
async outbound(userID, from, kind, original) {
|
|
59
|
+
if (from.kind !== "session") return
|
|
60
|
+
if (!isConcierge(await current(userID), from.sessionID) || outboundAdmitted(kind, original)) return
|
|
61
|
+
await deps.audit
|
|
62
|
+
?.record(userID, { actor: from, verb: "msg", target: {}, decision: "forbidden", reason: "concierge outbound rule", outcome: "refused" })
|
|
63
|
+
.catch((cause) => console.error(`[hub] concierge outbound audit failed: ${(cause as Error).message}`))
|
|
64
|
+
throw new AgentError("forbidden", "The Concierge messages only you (@me); it acts on sessions only through its grant", { extra: { policy: "concierge" } })
|
|
65
|
+
},
|
|
66
|
+
async admit(userID, from, sessionID, kind, original) {
|
|
67
|
+
if (from.kind === "user") return
|
|
68
|
+
const now = await current(userID)
|
|
69
|
+
if (!isConcierge(now, sessionID) || admitted(from, sessionID, kind, original)) return
|
|
70
|
+
await deps.audit
|
|
71
|
+
?.record(userID, { actor: from, verb: "msg", target: { sessionID }, decision: "forbidden", reason: "concierge inbound rule", outcome: "refused" })
|
|
72
|
+
.catch((cause) => console.error(`[hub] concierge inbound audit failed: ${(cause as Error).message}`))
|
|
73
|
+
throw new AgentError("forbidden", CONCIERGE_INBOUND_MESSAGE, { extra: { policy: "concierge" } })
|
|
74
|
+
},
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
/** The outbound rule's verdict: the Concierge messages only @me (not via Delivery.sendTo) and answers asks to it. */
|
|
79
|
+
export function outboundAdmitted(kind: InboundKind, original?: Message): boolean {
|
|
80
|
+
return (kind === "reply" || kind === "decline") && original?.kind === "ask"
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
/** The acting guard: true for the user's current and previous Concierge sessions (only the user acts on them). */
|
|
84
|
+
export function conciergeProtects(identity: ConciergeIdentity): (userID: string, sessionID: string) => Promise<boolean> {
|
|
85
|
+
return async (userID, sessionID) => isConcierge(await identity.current(userID), sessionID)
|
|
86
|
+
}
|
|
@@ -0,0 +1,84 @@
|
|
|
1
|
+
// CON-04 the Concierge's module slots (contract "Parallel safety of CON-05/06/07"). The service wires these ports;
|
|
2
|
+
// CON-05 (notifier.ts) and CON-06 (tiers.ts) each fill one slot line in CONCIERGE_SLOTS and touch nothing else here.
|
|
3
|
+
// Every slot defaults to a no-op, so the hub runs the same without them.
|
|
4
|
+
import type { ConciergeSettings, ConciergeStatus } from "../../../packages/protocol/src/concierge"
|
|
5
|
+
import type { UserActor } from "../agent/actor"
|
|
6
|
+
import type { ActOutcome, Decision } from "../agent/decisions"
|
|
7
|
+
import type { AgentEvent } from "../agent/events"
|
|
8
|
+
import type { AgentRoute } from "../agent/routes"
|
|
9
|
+
import type { ConciergeEntitlements } from "./entitlements"
|
|
10
|
+
import { ConciergeNotifierImpl, type NotifierJournal } from "./notifier"
|
|
11
|
+
import { conciergeTiers, type TierPorts } from "./tiers"
|
|
12
|
+
import type { ConciergeIdentity } from "./watchlist-routes"
|
|
13
|
+
|
|
14
|
+
/** What the Concierge modules get from the hub. */
|
|
15
|
+
export interface ConciergePorts {
|
|
16
|
+
/** The user's current Concierge session and every hidden one (the Concierge-only authority). */
|
|
17
|
+
readonly identity: ConciergeIdentity
|
|
18
|
+
readonly status: (userID: string) => Promise<ConciergeStatus>
|
|
19
|
+
readonly settings: (userID: string) => Promise<ConciergeSettings>
|
|
20
|
+
/** Pushes the board context to the current session, awaited at most 2 s (before a wake). False: not there. */
|
|
21
|
+
readonly beforeWake: (userID: string) => Promise<boolean>
|
|
22
|
+
/**
|
|
23
|
+
* Sends a message to the user's current Concierge session as app:concierge (Delivery.send; the inbound rule admits
|
|
24
|
+
* it). Throws not_found when there is no current session.
|
|
25
|
+
*/
|
|
26
|
+
readonly send: (userID: string, text: string, options: { readonly wake: boolean; readonly urgent?: boolean }) => Promise<{ readonly messageID: string; readonly woke?: boolean }>
|
|
27
|
+
readonly entitlements: ConciergeEntitlements
|
|
28
|
+
/** Every user of the hub (leader loops). */
|
|
29
|
+
readonly users: () => Promise<Iterable<string>>
|
|
30
|
+
/** (CON-05) The notifier's journal, ledger store, watchlist view, board revision and leadership; absent: inert. */
|
|
31
|
+
readonly journal?: NotifierJournal
|
|
32
|
+
/** (CON-06) What the tiers slot needs from the hub (Decisions, acting, presence, devices, SSE). */
|
|
33
|
+
readonly tiers?: TierPorts
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
/** CON-05: event-driven turns, digests, quiet hours (runs on the `concierge` leader). */
|
|
37
|
+
export interface ConciergeNotifier {
|
|
38
|
+
/** The concierge leader was elected (start) or deposed (stop). */
|
|
39
|
+
start(): void
|
|
40
|
+
stop(): void
|
|
41
|
+
/** Every journal append of the user, on the appending pod. Must never throw (never awaited). */
|
|
42
|
+
observe(userID: string, event: AgentEvent): void
|
|
43
|
+
/** The Concierge was disabled: forget the user's ledger. Must never reject. */
|
|
44
|
+
disabled(userID: string): Promise<void>
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/** CON-06: approval tiers, proposals and arming (its Concierge-only routes). */
|
|
48
|
+
export interface ConciergeTiers {
|
|
49
|
+
routes(): AgentRoute[]
|
|
50
|
+
/** Runs a proposal Decision's `act` action when the user answers (absent: it cannot run on this hub). */
|
|
51
|
+
act?(userID: string, decision: Decision, choice: { readonly option?: string }, by: UserActor): Promise<ActOutcome>
|
|
52
|
+
/** Admits a voice/text answer (throws forbidden "Answer this decision with a tap"); absent: never admitted. */
|
|
53
|
+
checkVia?(userID: string, decision: Decision, via: "voice" | "text", by: UserActor): Promise<void>
|
|
54
|
+
/** A Decision changed (Decisions' changed listener): an answered/closed one is disarmed. Never throws. */
|
|
55
|
+
decisionChanged?(userID: string, decision: Decision): void
|
|
56
|
+
/** The Decision armed now by the current Concierge session, if any (the board's `armed` flag). */
|
|
57
|
+
armedID?(userID: string): Promise<string | undefined>
|
|
58
|
+
/** The hub is stopping: drop timers. */
|
|
59
|
+
stop?(): void
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
export const NO_NOTIFIER: ConciergeNotifier = { start: () => undefined, stop: () => undefined, observe: () => undefined, disabled: async () => undefined }
|
|
63
|
+
export const NO_TIERS: ConciergeTiers = { routes: () => [] }
|
|
64
|
+
|
|
65
|
+
export interface ConciergeSlots {
|
|
66
|
+
readonly notifier?: (ports: ConciergePorts) => ConciergeNotifier
|
|
67
|
+
readonly tiers?: (ports: ConciergePorts) => ConciergeTiers
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
/** The filled slots (one line each). */
|
|
71
|
+
export const CONCIERGE_SLOTS: ConciergeSlots = {
|
|
72
|
+
notifier: (ports) => new ConciergeNotifierImpl(ports), // CON-05
|
|
73
|
+
tiers: (ports) => conciergeTiers(ports), // CON-06
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
export interface ConciergeModules {
|
|
77
|
+
readonly notifier: ConciergeNotifier
|
|
78
|
+
readonly tiers: ConciergeTiers
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
/** The modules for these ports: each slot's own, else its no-op. */
|
|
82
|
+
export function conciergeModules(ports: ConciergePorts, slots: ConciergeSlots = CONCIERGE_SLOTS): ConciergeModules {
|
|
83
|
+
return { notifier: slots.notifier?.(ports) ?? NO_NOTIFIER, tiers: slots.tiers?.(ports) ?? NO_TIERS }
|
|
84
|
+
}
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
// CON-04 Concierge notes: the Concierge's own memory across relaunches (contract "GET/PUT /concierge/notes"). One
|
|
2
|
+
// record per user (collection `concierge`, key `notes`): {text ≤ 32 KiB UTF-8, revision, updatedAt}. Control
|
|
3
|
+
// characters other than newline and tab are dropped. Writes are version-checked (expectedRevision; a stale one is
|
|
4
|
+
// 409 conflict {currentRevision}). The text never reaches the journal, the board or the admin audit.
|
|
5
|
+
import { CONCIERGE_LIMITS } from "../../../packages/protocol/src/concierge"
|
|
6
|
+
import type { Clock, StateStore } from "../backend/types"
|
|
7
|
+
import { AgentError } from "../agent/errors"
|
|
8
|
+
|
|
9
|
+
export const CONCIERGE_COLLECTION = "concierge"
|
|
10
|
+
const KEY = "notes"
|
|
11
|
+
/** C0 controls except tab and newline, DEL and C1 controls. */
|
|
12
|
+
const CONTROL = /[\u0000-\u0008\u000b-\u001f\u007f-\u009f]/g
|
|
13
|
+
|
|
14
|
+
export interface ConciergeNotes {
|
|
15
|
+
readonly text: string
|
|
16
|
+
readonly revision: number
|
|
17
|
+
readonly updatedAt?: number
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
export class NotesStore {
|
|
21
|
+
constructor(
|
|
22
|
+
private readonly state: StateStore,
|
|
23
|
+
private readonly now: Clock = Date.now,
|
|
24
|
+
) {}
|
|
25
|
+
|
|
26
|
+
async get(userID: string): Promise<ConciergeNotes> {
|
|
27
|
+
return (await this.state.get<ConciergeNotes>(userID, CONCIERGE_COLLECTION, KEY))?.value ?? { text: "", revision: 0 }
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
async put(userID: string, raw: string, expectedRevision: number): Promise<ConciergeNotes> {
|
|
31
|
+
const text = raw.replace(CONTROL, "")
|
|
32
|
+
if (Buffer.byteLength(text, "utf8") > CONCIERGE_LIMITS.notesBytes) throw new AgentError("invalid", `Notes are at most ${CONCIERGE_LIMITS.notesBytes} bytes`)
|
|
33
|
+
const stored = await this.state.get<ConciergeNotes>(userID, CONCIERGE_COLLECTION, KEY)
|
|
34
|
+
const current = stored?.value ?? { text: "", revision: 0 }
|
|
35
|
+
if (current.revision !== expectedRevision) throw new AgentError("conflict", "The notes changed meanwhile", { extra: { currentRevision: current.revision } })
|
|
36
|
+
const next: ConciergeNotes = { text, revision: current.revision + 1, updatedAt: this.now() }
|
|
37
|
+
const put = await this.state.put(userID, CONCIERGE_COLLECTION, KEY, next, { ifVersion: stored?.version ?? 0 })
|
|
38
|
+
if (!put.ok) throw new AgentError("conflict", "The notes changed meanwhile", { extra: { currentRevision: (await this.get(userID)).revision } })
|
|
39
|
+
return next
|
|
40
|
+
}
|
|
41
|
+
}
|