@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,986 @@
|
|
|
1
|
+
// CON-05 event-driven Concierge turns, digests and quiet hours (contract: docs/plans/2026-11-01-concierge-contracts.md
|
|
2
|
+
// "Event-driven turns, digests, quiet hours (CON-05)"). It follows the workspace coordinator's batching model
|
|
3
|
+
// (src/central/workspaces/coordinator.ts, RP-07 #10, RF-05) as its own module, for one recipient per user: the
|
|
4
|
+
// user's CURRENT Concierge session, reached only through the Concierge port `send` (Delivery.send as app:concierge,
|
|
5
|
+
// admitted by the CON-04 inbound rule). Nothing is read through the Concierge's token.
|
|
6
|
+
//
|
|
7
|
+
// What the Concierge hears: the user's journal (AgentEvents.since, cursor in the ledger), filtered to the watchlist
|
|
8
|
+
// (watched roots; subagents roll up into their root) and classed:
|
|
9
|
+
// - priority: a blocker opened, a session in error, an ask to @me (a Decision) about a watched session, a launch
|
|
10
|
+
// ended failed/lost/needs_user/timeout → the next pass, a wake.
|
|
11
|
+
// - notable: a turn done (working → idle), a launch ended idle (goal reached) → a wake after 30 s quiet (2 min max).
|
|
12
|
+
// - routine: presence, resolved blockers, → working, machines, intents, pins → wake:false (admitted for its next
|
|
13
|
+
// turn, never starting one; RF-05) after 2 min quiet (15 min max).
|
|
14
|
+
// Routine and notable lines held when a wake goes out go along with it. Wakes are queued, never steered (`urgent` is
|
|
15
|
+
// never set): a steer would cut into a live voice turn.
|
|
16
|
+
//
|
|
17
|
+
// Lines carry hub IDs, enums and counts only (`! ses_… blocker:permission per_…`). Event values that are not a known
|
|
18
|
+
// enum, or an ID matching its own kind's pattern (ses_…, msg_…, dec_…, mch_…, l_<hex>, a harness blocker ID), are
|
|
19
|
+
// replaced ("other") or left out (an unmatched subject drops the line), never echoed: no titles, intents, prompts, message bodies,
|
|
20
|
+
// blocker summaries, Decision text or machine names, and no @names either (the directory derives them from titles, so
|
|
21
|
+
// they are title text). The Concierge reads details, names included, on its board, where untrusted text is framed. Each message starts `Concierge update b<seq> (attempt n) · board r<rev>`.
|
|
22
|
+
//
|
|
23
|
+
// Self-trigger guard: events about any of the Concierge's sessions (current and every past one, identity), messages
|
|
24
|
+
// from or to it (app:concierge, @@concierge, its sessions), and launches by app:concierge or any of its sessions are
|
|
25
|
+
// ignored, so its own
|
|
26
|
+
// turns and our deliveries never wake it.
|
|
27
|
+
//
|
|
28
|
+
// Cooldown (fold): a priority reason (same root + same blocker kind or state, or the same Decision/launch) it was
|
|
29
|
+
// woken about within foldMs (15 min) is routine. Budgets per user and rolling hour: 12 priority, 12 notable, 30
|
|
30
|
+
// routine; over budget priority falls to notable (its timing), notable to routine, routine waits. Each wake (and
|
|
31
|
+
// digest) is recorded through ConciergeEntitlements.record.
|
|
32
|
+
//
|
|
33
|
+
// Quiet hours (settings.quietHours, in their own zone): only priority lines wake, and only with quietAllowsUrgent;
|
|
34
|
+
// everything else is held and goes out as one wake when they end (digest class: no hourly budget). Digests
|
|
35
|
+
// (settings.digests, settings.timeZone; schedule.ts): one wake per entry per local day, `Concierge digest (<HH:MM>
|
|
36
|
+
// <zone>) …: <counts since the last digest>`, carrying what is held; a digest due in quiet hours goes out when they
|
|
37
|
+
// end. Quiet hours hold retries and stale-claim resends too: a pending batch that is not priority-with-
|
|
38
|
+
// quietAllowsUrgent is put back (its lines, snapshot and digest), and the release says it carries that batch again
|
|
39
|
+
// ("Includes the updates of b<n>, which may already have arrived"). Idle costs nothing: no watched activity and no digest → no send, no context push.
|
|
40
|
+
//
|
|
41
|
+
// Not running: while the state is not `running` nothing is sent; lines are held up to bufferLines (200), beyond
|
|
42
|
+
// that the next batch is a snapshot ("board r<rev> is current; N changes were missed"). A journal reset (cursor no
|
|
43
|
+
// longer covered) is a priority snapshot (quiet hours apply) that cannot count: "some changes were missed; read your
|
|
44
|
+
// board". Disabled: nothing is sent;
|
|
45
|
+
// the ledger is deleted (disabled(), and any pass that finds the Concierge disabled). A relaunched Concierge (a new
|
|
46
|
+
// session) learns again: the ledger's delivered facts and folds start over.
|
|
47
|
+
//
|
|
48
|
+
// Durability and leadership: one ledger per user in the StateStore (collection `concierge-notifier`, key `ledger`:
|
|
49
|
+
// cursor, held lines, delivered facts, alerts, pending batch, attempts, budget window, digest keys). Passes run on
|
|
50
|
+
// the `concierge` leader; isLeader is checked before each user and each send; every write is version-checked. A
|
|
51
|
+
// batch is stored before it is sent and claimed first; a claim unrecorded after claimGraceMs is resent with the SAME
|
|
52
|
+
// batch ID (at-least-once: the text says the attempt and asks to skip a batch already handled); refusals that store
|
|
53
|
+
// nothing are retried; after maxAttempts the batch is abandoned and the next one is a snapshot. A journal position
|
|
54
|
+
// the log no longer covers (expired, standalone restart) is a reset: the next batch is a snapshot.
|
|
55
|
+
//
|
|
56
|
+
// observe(): every journal append on this pod; cheap (a Set lookup) and never throws: on the leader a priority-type
|
|
57
|
+
// append (a blocker, an ask, a launch, a session in error) schedules one early pass (observeDelayMs) instead of
|
|
58
|
+
// waiting for the tick, and a turn done schedules one when its 30 s quiet window ends. After each pass, held notable
|
|
59
|
+
// lines arm a pass for when they come due, so a lone turn done wakes ~30 s after it, not on the tick after that.
|
|
60
|
+
//
|
|
61
|
+
// Timing (CON-09a): a wake's quiet and max windows count from its own lines' event times (Line.t), so routine churn
|
|
62
|
+
// of other watched sessions (which goes along) never holds a turn done back; and a turn done is always news, even
|
|
63
|
+
// when the last state told was idle (the routine working line in between was replaced while held, so a second short
|
|
64
|
+
// turn of a session used to be dropped). A line replacing held ones of its subject keeps the earliest undelivered turn
|
|
65
|
+
// done (Line.t0), so a session flipping faster than the quiet window still wakes once its max window ends (a routine
|
|
66
|
+
// line carrying it is then that turn done). Passes are armed only for a FUTURE due: lines held by quiet hours or a
|
|
67
|
+
// spent budget wait for the tick (no 0 ms timer loop).
|
|
68
|
+
import { CONCIERGE_PARTY, CONCIERGE_ROLE, type ConciergeSettings } from "../../../packages/protocol/src/concierge"
|
|
69
|
+
import { AgentError } from "../agent/errors"
|
|
70
|
+
import type { AgentEvent, AgentEvents, EventRecord, EventsSince } from "../agent/events"
|
|
71
|
+
import { updateVersioned } from "../agent/versioned"
|
|
72
|
+
import { realTimers } from "../backend/memory-bus"
|
|
73
|
+
import type { Clock, StateStore, Timers } from "../backend/types"
|
|
74
|
+
import type { ConciergeNotifier, ConciergePorts } from "./index"
|
|
75
|
+
import { localStamp } from "./board-render"
|
|
76
|
+
import { DIGEST_GRACE_MS, dueDigests, inQuietHours, zoneOrUTC } from "./schedule"
|
|
77
|
+
import { resolveWatchlist, rootsOf } from "./watchlist"
|
|
78
|
+
import { type WatchFactDeps, watchFacts, WatchlistStore } from "./watchlist-routes"
|
|
79
|
+
|
|
80
|
+
export const NOTIFIER_COLLECTION = "concierge-notifier"
|
|
81
|
+
const KEY = "ledger"
|
|
82
|
+
/** The leader's pass interval (digests have minute resolution). */
|
|
83
|
+
export const NOTIFIER_TICK_MS = 30_000
|
|
84
|
+
const HOUR_MS = 3_600_000
|
|
85
|
+
const DAY_MS = 86_400_000
|
|
86
|
+
|
|
87
|
+
export type LineClass = "priority" | "notable" | "routine"
|
|
88
|
+
|
|
89
|
+
export interface NotifierLimits {
|
|
90
|
+
readonly priorityPerHour: number
|
|
91
|
+
readonly notablePerHour: number
|
|
92
|
+
readonly routinePerHour: number
|
|
93
|
+
readonly notableQuietMs: number
|
|
94
|
+
readonly notableMaxMs: number
|
|
95
|
+
readonly routineQuietMs: number
|
|
96
|
+
readonly routineMaxMs: number
|
|
97
|
+
/** A priority reason woken about this recently is routine (RF-05). */
|
|
98
|
+
readonly foldMs: number
|
|
99
|
+
/** Delivered facts and alerts kept. */
|
|
100
|
+
readonly factsMax: number
|
|
101
|
+
/** Lines held; beyond it the next batch is a snapshot. */
|
|
102
|
+
readonly bufferLines: number
|
|
103
|
+
readonly linesPerBatch: number
|
|
104
|
+
readonly maxAttempts: number
|
|
105
|
+
readonly retryMs: readonly number[]
|
|
106
|
+
readonly sendTimeoutMs: number
|
|
107
|
+
readonly claimGraceMs: number
|
|
108
|
+
/** The board push before a wake (ConciergePorts.beforeWake) is awaited at most this long. */
|
|
109
|
+
readonly beforeWakeMs: number
|
|
110
|
+
/** A priority-type append on the leader runs a pass this soon. */
|
|
111
|
+
readonly observeDelayMs: number
|
|
112
|
+
readonly digestGraceMs: number
|
|
113
|
+
readonly historyMax: number
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
export const NOTIFIER_LIMITS: NotifierLimits = {
|
|
117
|
+
priorityPerHour: 12,
|
|
118
|
+
notablePerHour: 12,
|
|
119
|
+
routinePerHour: 30,
|
|
120
|
+
notableQuietMs: 30_000,
|
|
121
|
+
notableMaxMs: 2 * 60_000,
|
|
122
|
+
routineQuietMs: 2 * 60_000,
|
|
123
|
+
routineMaxMs: 15 * 60_000,
|
|
124
|
+
foldMs: 15 * 60_000,
|
|
125
|
+
factsMax: 400,
|
|
126
|
+
bufferLines: 200,
|
|
127
|
+
linesPerBatch: 40,
|
|
128
|
+
maxAttempts: 6,
|
|
129
|
+
retryMs: [30_000, 2 * 60_000, 10 * 60_000, 30 * 60_000],
|
|
130
|
+
sendTimeoutMs: 60_000,
|
|
131
|
+
claimGraceMs: 2 * 60_000,
|
|
132
|
+
beforeWakeMs: 2_000,
|
|
133
|
+
observeDelayMs: 1_000,
|
|
134
|
+
digestGraceMs: DIGEST_GRACE_MS,
|
|
135
|
+
historyMax: 20,
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
/** What the notifier knows about the watchlist at a pass: watched roots and each session's root. */
|
|
139
|
+
export interface WatchedView {
|
|
140
|
+
readonly watched: ReadonlySet<string>
|
|
141
|
+
readonly roots: ReadonlyMap<string, string>
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
/** The notifier's own hub ports (journal, ledger store, watchlist, board revision, leadership, clock). */
|
|
145
|
+
export interface NotifierJournal {
|
|
146
|
+
readonly events: Pick<AgentEvents, "since" | "latest">
|
|
147
|
+
readonly state: StateStore
|
|
148
|
+
readonly watched: (userID: string, hidden: readonly string[]) => Promise<WatchedView>
|
|
149
|
+
/** The board revision the Concierge has (after beforeWake); absent or failing: "r?". */
|
|
150
|
+
readonly boardRevision?: (userID: string, hidden: readonly string[]) => Promise<number>
|
|
151
|
+
/** The Decision an ask to @me opened (its ID and what it is about). */
|
|
152
|
+
readonly decisionOf?: (userID: string, messageID: string) => Promise<{ readonly id: string; readonly about: readonly string[] } | undefined>
|
|
153
|
+
readonly isLeader?: () => boolean
|
|
154
|
+
readonly owner?: string
|
|
155
|
+
readonly now?: Clock
|
|
156
|
+
readonly timers?: Timers
|
|
157
|
+
readonly tickMs?: number
|
|
158
|
+
readonly limits?: Partial<NotifierLimits>
|
|
159
|
+
readonly log?: (message: string) => void
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
// ---- Classing ---------------------------------------------------------------------------------------------
|
|
163
|
+
|
|
164
|
+
export interface Line {
|
|
165
|
+
readonly c: LineClass
|
|
166
|
+
readonly text: string
|
|
167
|
+
/** A state line's subject (later lines replace it while held) and the state it reports. */
|
|
168
|
+
readonly k?: string
|
|
169
|
+
readonly f?: string
|
|
170
|
+
/** Reported once: dropped when the Concierge already has this fact (an open Decision, an ended launch). */
|
|
171
|
+
readonly o?: true
|
|
172
|
+
/** Priority reasons (fold keys). */
|
|
173
|
+
readonly b?: readonly string[]
|
|
174
|
+
/** When its event happened (the journal's time): a wake's quiet and max windows count from its own lines. */
|
|
175
|
+
readonly t?: number
|
|
176
|
+
/**
|
|
177
|
+
* When the subject's first notable (or priority) line still held was: a state line that replaced held ones keeps
|
|
178
|
+
* it, so a session flipping faster than the quiet window still wakes within the max window.
|
|
179
|
+
*/
|
|
180
|
+
readonly t0?: number
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
export interface ClassifyContext {
|
|
184
|
+
/** The Concierge's sessions, current and past. */
|
|
185
|
+
readonly hidden: ReadonlySet<string>
|
|
186
|
+
readonly view: WatchedView
|
|
187
|
+
/** For an ask to @me: the Decision it opened. */
|
|
188
|
+
readonly decision?: { readonly id: string; readonly about: readonly string[] }
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
/** Hub and harness IDs by kind: a value that does not match its own kind's pattern never reaches a line. */
|
|
192
|
+
const IDS = {
|
|
193
|
+
session: /^ses_[A-Za-z0-9]{1,64}$/,
|
|
194
|
+
message: /^msg_[A-Za-z0-9]{1,40}$/,
|
|
195
|
+
decision: /^dec_[A-Za-z0-9]{1,40}$/,
|
|
196
|
+
machine: /^mch_[A-Za-z0-9]{1,64}$/,
|
|
197
|
+
launch: /^l_[0-9a-f]{16}$/,
|
|
198
|
+
/** Harness blocker IDs (OpenCode per_…, que_…): a short lowercase prefix. */
|
|
199
|
+
blocker: /^[a-z]{2,4}_[A-Za-z0-9]{1,64}$/,
|
|
200
|
+
} as const
|
|
201
|
+
const BLOCKER_KINDS: ReadonlySet<unknown> = new Set(["permission", "question", "form"])
|
|
202
|
+
const STATES: ReadonlySet<unknown> = new Set(["idle", "working", "blocked", "error", "offline"])
|
|
203
|
+
const INTENT_SOURCES: ReadonlySet<unknown> = new Set(["user", "launch", "self", "prompt", "title"])
|
|
204
|
+
const LAUNCH_PRIORITY: ReadonlySet<unknown> = new Set(["failed", "lost", "needs_user", "timeout"])
|
|
205
|
+
const CONCIERGE_ADDRESS = `@@${CONCIERGE_ROLE}`
|
|
206
|
+
|
|
207
|
+
const idOf = (kind: keyof typeof IDS, v: unknown): string | undefined => (typeof v === "string" && IDS[kind].test(v) ? v : undefined)
|
|
208
|
+
const enumOf = (v: unknown, known: ReadonlySet<unknown>): string => (known.has(v) ? (v as string) : "other")
|
|
209
|
+
const MARK: Record<LineClass, string> = { priority: "!", notable: "+", routine: "-" }
|
|
210
|
+
|
|
211
|
+
/** Whether the record is the Concierge's own doing or about it (any of its sessions, its party, its role). */
|
|
212
|
+
function selfTouched(record: Pick<EventRecord, "event" | "audience">, hidden: ReadonlySet<string>): boolean {
|
|
213
|
+
const s = record.event.subject ?? {}
|
|
214
|
+
const d = record.event.data ?? {}
|
|
215
|
+
if ((s.sessionID && hidden.has(s.sessionID)) || (s.senderID && hidden.has(s.senderID))) return true
|
|
216
|
+
const audience = record.audience
|
|
217
|
+
const by = audience?.by
|
|
218
|
+
// Launches by the Concierge's party or by any of its sessions (however the launcher is written).
|
|
219
|
+
if (by === CONCIERGE_PARTY || (by !== undefined && (hidden.has(by) || (by.startsWith("ses:") && hidden.has(by.slice(4)))))) return true
|
|
220
|
+
for (const party of audience?.parties ?? []) if (party === CONCIERGE_PARTY || (party.startsWith("ses:") && hidden.has(party.slice(4)))) return true
|
|
221
|
+
for (const v of [d.from, d.to]) {
|
|
222
|
+
if (typeof v !== "string") continue
|
|
223
|
+
if (v === CONCIERGE_PARTY || v.startsWith(CONCIERGE_ADDRESS) || hidden.has(v)) return true
|
|
224
|
+
}
|
|
225
|
+
return false
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
/** A watched session as lines name it (`<root>[ (sub <id>)]`), or undefined when not watched. */
|
|
229
|
+
function whoOf(sessionID: unknown, ctx: ClassifyContext): { root: string; id: string; who: string } | undefined {
|
|
230
|
+
const id = idOf("session", sessionID)
|
|
231
|
+
if (!id) return undefined
|
|
232
|
+
const root = ctx.view.roots.get(id) ?? id
|
|
233
|
+
if (!IDS.session.test(root) || ctx.hidden.has(root) || !ctx.view.watched.has(root)) return undefined
|
|
234
|
+
return { root, id, who: `${root}${id !== root ? ` (sub ${id})` : ""}` }
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
const line = (c: LineClass, text: string, extra: Omit<Line, "c" | "text"> = {}): Line => ({ c, text: `${MARK[c]} ${text}`, ...extra })
|
|
238
|
+
|
|
239
|
+
function classify(record: Pick<EventRecord, "event" | "family" | "audience">, ctx: ClassifyContext): Line | undefined {
|
|
240
|
+
const event = record?.event
|
|
241
|
+
if (!event || typeof event.type !== "string" || !event.subject || !event.data) return undefined
|
|
242
|
+
if (selfTouched(record, ctx.hidden)) return undefined
|
|
243
|
+
const s = event.subject
|
|
244
|
+
const d = event.data
|
|
245
|
+
switch (event.type) {
|
|
246
|
+
case "machine.changed": {
|
|
247
|
+
const id = idOf("machine", s.machineID)
|
|
248
|
+
const online = d.online !== false
|
|
249
|
+
return id ? line("routine", `machine ${id} ${online ? "online" : "offline"}`, { k: `m:${id}`, f: online ? "online" : "offline" }) : undefined
|
|
250
|
+
}
|
|
251
|
+
case "message.changed": {
|
|
252
|
+
if (d.kind !== "ask" || d.state !== "open" || d.to !== "@me") return undefined
|
|
253
|
+
const messageID = idOf("message", s.messageID)
|
|
254
|
+
if (!messageID) return undefined
|
|
255
|
+
const about = ctx.decision ? ctx.decision.about : s.senderID ? [s.senderID] : []
|
|
256
|
+
const target = about.map((id) => whoOf(id, ctx)).find((w) => w !== undefined)
|
|
257
|
+
if (!target) return undefined
|
|
258
|
+
const id = idOf("decision", ctx.decision?.id) ?? messageID
|
|
259
|
+
return line("priority", `${target.who} decision ${id}`, { k: `d:${messageID}`, f: "open", o: true, b: [`${target.root} decision:${id}`] })
|
|
260
|
+
}
|
|
261
|
+
case "launch.changed": {
|
|
262
|
+
if (d.state !== "ended") return undefined
|
|
263
|
+
const target = whoOf(s.sessionID, ctx)
|
|
264
|
+
const launchID = idOf("launch", s.launchID)
|
|
265
|
+
if (!target || !launchID) return undefined
|
|
266
|
+
const outcome = d.outcome
|
|
267
|
+
const once = { k: `l:${launchID}`, f: String(outcome), o: true as const }
|
|
268
|
+
if (LAUNCH_PRIORITY.has(outcome)) return line("priority", `${target.who} launch ${launchID} ended:${outcome}`, { ...once, b: [`${target.root} launch:${outcome}`] })
|
|
269
|
+
if (outcome === "idle") return line("notable", `${target.who} launch ${launchID} ended:idle`, once)
|
|
270
|
+
return undefined
|
|
271
|
+
}
|
|
272
|
+
}
|
|
273
|
+
const target = whoOf(s.sessionID, ctx)
|
|
274
|
+
if (!target) return undefined
|
|
275
|
+
const { who, root, id } = target
|
|
276
|
+
switch (event.type) {
|
|
277
|
+
case "blocker.opened": {
|
|
278
|
+
const kind = enumOf(d.kind, BLOCKER_KINDS)
|
|
279
|
+
const blockerID = idOf("blocker", d.blockerID)
|
|
280
|
+
// (CON-13) The fold key names the request: a NEW permission or form on a session woken about minutes ago is
|
|
281
|
+
// news (it waited ~60–75 s for the Concierge's next turn as routine); only the same request again folds.
|
|
282
|
+
return line("priority", `${who} blocker:${kind}${blockerID ? ` ${blockerID}` : ""}`, { b: [`${root} blocker:${kind}${blockerID ? `:${blockerID}` : ""}`] })
|
|
283
|
+
}
|
|
284
|
+
case "blocker.closed": {
|
|
285
|
+
const blockerID = idOf("blocker", d.blockerID)
|
|
286
|
+
return line("routine", `${who} blocker_closed${blockerID ? ` ${blockerID}` : ""}`)
|
|
287
|
+
}
|
|
288
|
+
case "session.changed": {
|
|
289
|
+
const state = enumOf(d.state, STATES)
|
|
290
|
+
const k = `s:${id}`
|
|
291
|
+
if (state === "error") return line("priority", `${who} state:error`, { k, f: state, b: [`${root} state:error`] })
|
|
292
|
+
if (state === "idle" && d.prev === "working") return line("notable", `${who} turn_done`, { k, f: state })
|
|
293
|
+
return line("routine", `${who} state:${state}`, { k, f: state })
|
|
294
|
+
}
|
|
295
|
+
case "session.added":
|
|
296
|
+
case "session.removed": {
|
|
297
|
+
const presence = event.type === "session.added" ? "online" : "offline"
|
|
298
|
+
return line("routine", `${who} ${presence}`, { k: `p:${id}`, f: presence })
|
|
299
|
+
}
|
|
300
|
+
case "session.intent": {
|
|
301
|
+
const revision = Number.isSafeInteger(d.revision) ? ` r${d.revision}` : ""
|
|
302
|
+
const source = enumOf(d.source, INTENT_SOURCES)
|
|
303
|
+
// (CON-11) Source "title": no intent is left (the user cleared it), so the session shows its title again. That
|
|
304
|
+
// is not a title change: say so (the Concierge read "intent:title" as "its title changed").
|
|
305
|
+
if (source === "title") return line("routine", `${who} intent_cleared${revision}`)
|
|
306
|
+
return line("routine", `${who} intent:${source}${revision}`)
|
|
307
|
+
}
|
|
308
|
+
case "session.pinned": {
|
|
309
|
+
const pinned = d.pinned === true ? "pinned" : "unpinned"
|
|
310
|
+
return line("routine", `${who} ${pinned}`, { k: `pin:${id}`, f: pinned })
|
|
311
|
+
}
|
|
312
|
+
default:
|
|
313
|
+
return undefined
|
|
314
|
+
}
|
|
315
|
+
}
|
|
316
|
+
|
|
317
|
+
/** The line a journal record is for the Concierge, or undefined (not watched, its own doing, not news). Never throws. */
|
|
318
|
+
export function classifyEvent(record: Pick<EventRecord, "event" | "family" | "audience">, ctx: ClassifyContext): Line | undefined {
|
|
319
|
+
try {
|
|
320
|
+
return classify(record, ctx)
|
|
321
|
+
} catch {
|
|
322
|
+
return undefined
|
|
323
|
+
}
|
|
324
|
+
}
|
|
325
|
+
|
|
326
|
+
/** Journal types the notifier reads (others are skipped before any lookup). */
|
|
327
|
+
const RELEVANT: ReadonlySet<string> = new Set([
|
|
328
|
+
"blocker.opened",
|
|
329
|
+
"blocker.closed",
|
|
330
|
+
"session.changed",
|
|
331
|
+
"session.added",
|
|
332
|
+
"session.removed",
|
|
333
|
+
"session.intent",
|
|
334
|
+
"session.pinned",
|
|
335
|
+
"machine.changed",
|
|
336
|
+
"message.changed",
|
|
337
|
+
"launch.changed",
|
|
338
|
+
])
|
|
339
|
+
/** A routine state line carrying an undelivered turn done past the max window: notable, saying so. */
|
|
340
|
+
function overdueTurn(l: Line): Line {
|
|
341
|
+
const body = l.text.slice(2)
|
|
342
|
+
const m = / state:([a-z]+)$/.exec(body)
|
|
343
|
+
return { ...l, c: "notable", text: `${MARK.notable} ${m ? `${body.slice(0, m.index)} turn_done (now ${m[1]})` : body}` }
|
|
344
|
+
}
|
|
345
|
+
|
|
346
|
+
/** Types that may be priority: on the leader, an append of one runs an early pass. */
|
|
347
|
+
const EARLY: ReadonlySet<string> = new Set(["blocker.opened", "message.changed", "launch.changed"])
|
|
348
|
+
|
|
349
|
+
/** Whether an append may be a priority line (an early pass): EARLY types, and a session going into error. */
|
|
350
|
+
const early = (event: AgentEvent): boolean => EARLY.has(event.type) || (event.type === "session.changed" && event.data?.state === "error")
|
|
351
|
+
/** Whether an append may be a notable line (a turn done): a pass when its quiet window ends. */
|
|
352
|
+
const notable = (event: AgentEvent): boolean => event.type === "session.changed" && event.data?.state === "idle" && event.data?.prev === "working"
|
|
353
|
+
|
|
354
|
+
// ---- Watchlist port (hub wiring) --------------------------------------------------------------------------
|
|
355
|
+
|
|
356
|
+
export type WatchedDeps = WatchFactDeps & { readonly state: StateStore }
|
|
357
|
+
|
|
358
|
+
/** The watched view for a pass: the resolved watchlist (CON-01) and every session's root. */
|
|
359
|
+
export async function watchedSessions(deps: WatchedDeps, userID: string, hidden: readonly string[]): Promise<WatchedView> {
|
|
360
|
+
const [config, facts] = await Promise.all([new WatchlistStore(deps.state).get(userID), watchFacts(deps, userID, hidden)])
|
|
361
|
+
return { watched: new Set(resolveWatchlist(config, facts).entries.map((e) => e.sessionID)), roots: rootsOf(facts.sessions) }
|
|
362
|
+
}
|
|
363
|
+
|
|
364
|
+
// ---- Ledger -----------------------------------------------------------------------------------------------
|
|
365
|
+
|
|
366
|
+
type Counts = { priority: number; notable: number; routine: number }
|
|
367
|
+
type SnapshotReason = "reset" | "overflow" | "abandoned"
|
|
368
|
+
type BatchKind = "delta" | "snapshot" | "release" | "digest"
|
|
369
|
+
/** The budget a batch uses: an hourly class, or "digest" (digests and quiet-hours releases: none). */
|
|
370
|
+
type Budget = LineClass | "digest"
|
|
371
|
+
|
|
372
|
+
export interface NotifierBatch {
|
|
373
|
+
readonly id: string
|
|
374
|
+
readonly seq: number
|
|
375
|
+
readonly kind: BatchKind
|
|
376
|
+
readonly budget: Budget
|
|
377
|
+
readonly wake: boolean
|
|
378
|
+
readonly lines: readonly string[]
|
|
379
|
+
readonly more: number
|
|
380
|
+
/** Lines listed per class (the header). */
|
|
381
|
+
readonly counts: Counts
|
|
382
|
+
readonly facts?: Readonly<Record<string, string>>
|
|
383
|
+
readonly alerts?: readonly string[]
|
|
384
|
+
/** A snapshot (or a digest/release carrying one): why, and how many changes were missed. */
|
|
385
|
+
readonly snapshot?: { readonly reason: SnapshotReason; readonly missed: number; readonly unknown?: true }
|
|
386
|
+
readonly digest?: { readonly key?: string; readonly label: string; readonly zone: string; readonly since: number; readonly counts: Counts }
|
|
387
|
+
/** The lines it lists (so a batch deferred by quiet hours can be held again). */
|
|
388
|
+
readonly held?: readonly Line[]
|
|
389
|
+
/** Earlier batches whose lines it carries again (deferred after a send that may have landed). */
|
|
390
|
+
readonly supersedes?: readonly string[]
|
|
391
|
+
readonly cutAt: number
|
|
392
|
+
readonly state: "ready" | "sending" | "retry"
|
|
393
|
+
readonly attempts: number
|
|
394
|
+
readonly nextAt?: number
|
|
395
|
+
readonly claimedBy?: string
|
|
396
|
+
readonly claimedAt?: number
|
|
397
|
+
readonly last?: "uncertain" | "refused"
|
|
398
|
+
}
|
|
399
|
+
|
|
400
|
+
export interface NotifierLedger {
|
|
401
|
+
readonly v: 1
|
|
402
|
+
/** The Concierge session the delivered facts were told to. */
|
|
403
|
+
readonly sessionID?: string
|
|
404
|
+
/** Journal position scanned up to. */
|
|
405
|
+
readonly position: string
|
|
406
|
+
readonly buffer: readonly Line[]
|
|
407
|
+
readonly firstAt?: number
|
|
408
|
+
readonly lastAt?: number
|
|
409
|
+
/** `unknown`: how many were missed is not known (a journal reset). */
|
|
410
|
+
readonly snapshot?: { readonly reason: SnapshotReason; readonly missed: number; readonly unknown?: true; readonly urgent?: true }
|
|
411
|
+
/** Batches deferred by quiet hours after a send attempt (the next batch says it carries their lines). */
|
|
412
|
+
readonly supersedes?: readonly string[]
|
|
413
|
+
/** Lines were held by quiet hours: their end releases one wake. */
|
|
414
|
+
readonly quietHeld?: true
|
|
415
|
+
readonly batch?: NotifierBatch
|
|
416
|
+
readonly window: readonly { readonly at: number; readonly budget: LineClass }[]
|
|
417
|
+
readonly seq: number
|
|
418
|
+
readonly facts: Readonly<Record<string, string>>
|
|
419
|
+
readonly alerts: Readonly<Record<string, number>>
|
|
420
|
+
/** Digest keys (digest:<date>:<i>) → when they came due. */
|
|
421
|
+
readonly digests: Readonly<Record<string, number>>
|
|
422
|
+
readonly owedDigest?: { readonly key: string; readonly label: string; readonly at: number }
|
|
423
|
+
/** Classed lines since the last digest. */
|
|
424
|
+
readonly counts: Counts
|
|
425
|
+
readonly countsSince: number
|
|
426
|
+
readonly history: readonly { readonly id: string; readonly kind: BatchKind; readonly at: number; readonly attempts: number; readonly outcome: "delivered" | "abandoned"; readonly messageID?: string }[]
|
|
427
|
+
}
|
|
428
|
+
|
|
429
|
+
const ZERO: Counts = { priority: 0, notable: 0, routine: 0 }
|
|
430
|
+
/** Refusals that store nothing: retrying cannot duplicate. Anything else may have stored the message. */
|
|
431
|
+
const REFUSED: ReadonlySet<string> = new Set(["rate_limited", "not_found", "unsupported", "forbidden", "invalid", "gone", "conflict"])
|
|
432
|
+
|
|
433
|
+
const without = <T extends object, K extends keyof T>(value: T, ...keys: K[]): Omit<T, K> => {
|
|
434
|
+
const out = { ...value }
|
|
435
|
+
for (const key of keys) delete out[key]
|
|
436
|
+
return out
|
|
437
|
+
}
|
|
438
|
+
|
|
439
|
+
/** Keeps the newest `max` entries of a record (insertion order). */
|
|
440
|
+
function bounded<V>(record: Record<string, V>, max: number): Record<string, V> {
|
|
441
|
+
const keys = Object.keys(record)
|
|
442
|
+
for (const key of keys.slice(0, Math.max(0, keys.length - max))) delete record[key]
|
|
443
|
+
return record
|
|
444
|
+
}
|
|
445
|
+
|
|
446
|
+
function stamp(at: number): string {
|
|
447
|
+
const d = new Date(at)
|
|
448
|
+
const pad = (n: number) => String(n).padStart(2, "0")
|
|
449
|
+
return `${d.getUTCFullYear()}-${pad(d.getUTCMonth() + 1)}-${pad(d.getUTCDate())} ${pad(d.getUTCHours())}:${pad(d.getUTCMinutes())}Z`
|
|
450
|
+
}
|
|
451
|
+
|
|
452
|
+
const countsText = (c: Counts, all = false) =>
|
|
453
|
+
(["priority", "notable", "routine"] as const)
|
|
454
|
+
.filter((k) => all || c[k] > 0)
|
|
455
|
+
.map((k) => `${c[k]} ${k}`)
|
|
456
|
+
.join(", ")
|
|
457
|
+
|
|
458
|
+
const SNAPSHOT_WHY: Record<SnapshotReason, string> = {
|
|
459
|
+
reset: "the event journal no longer covers the last position",
|
|
460
|
+
overflow: "too many updates were held",
|
|
461
|
+
abandoned: "an earlier update could not be delivered",
|
|
462
|
+
}
|
|
463
|
+
|
|
464
|
+
/** One wake/queued message's text: IDs, enums and counts only. */
|
|
465
|
+
export function renderBatch(batch: NotifierBatch, revision: number | undefined): string {
|
|
466
|
+
const board = `board r${revision ?? "?"}`
|
|
467
|
+
const attempt = `b${batch.seq} (attempt ${batch.attempts})`
|
|
468
|
+
const auto = "automated by the hub, not the user"
|
|
469
|
+
const missed = batch.snapshot
|
|
470
|
+
? batch.snapshot.unknown
|
|
471
|
+
? `${board} is current; some changes were missed (${SNAPSHOT_WHY[batch.snapshot.reason]}); read your board`
|
|
472
|
+
: `${board} is current; ${batch.snapshot.missed} changes were missed (${SNAPSHOT_WHY[batch.snapshot.reason]})`
|
|
473
|
+
: undefined
|
|
474
|
+
const again = batch.supersedes?.length ? `Includes the updates of ${batch.supersedes.join(", ")}, which may already have arrived: skip what you already handled.` : undefined
|
|
475
|
+
let head: string
|
|
476
|
+
if (batch.kind === "digest") {
|
|
477
|
+
const d = batch.digest!
|
|
478
|
+
// (CON-10) "since" in the digest's zone (the user's), never a UTC "Z" time.
|
|
479
|
+
head = `Concierge digest (${d.label} ${d.zone}) ${attempt} · ${board} · since ${localStamp(d.since, d.zone) ?? stamp(d.since)}: ${countsText(d.counts, true)}`
|
|
480
|
+
} else if (batch.kind === "release") {
|
|
481
|
+
head = `Concierge update ${attempt} · ${board} · quiet hours ended: ${batch.lines.length + batch.more} held updates · ${auto}`
|
|
482
|
+
} else if (batch.kind === "snapshot") {
|
|
483
|
+
head = `Concierge update ${attempt} · ${missed} · ${auto}`
|
|
484
|
+
} else {
|
|
485
|
+
head = `Concierge update ${attempt} · ${board} · ${countsText(batch.counts)} · ${auto}`
|
|
486
|
+
}
|
|
487
|
+
const footer = [
|
|
488
|
+
`IDs and states only: details are on your board (hub concierge_board). If you already handled b${batch.seq}, skip it.`,
|
|
489
|
+
...(batch.wake ? [] : ["Routine: queued for your next turn; no turn was started for it."]),
|
|
490
|
+
...(batch.kind === "digest" ? ["Summarize for the user if it helps; msg @me only when something needs them."] : []),
|
|
491
|
+
].join(" ")
|
|
492
|
+
return [
|
|
493
|
+
head,
|
|
494
|
+
...(missed && batch.kind !== "snapshot" ? [missed] : []),
|
|
495
|
+
...(again ? [again] : []),
|
|
496
|
+
...batch.lines,
|
|
497
|
+
...(batch.more ? [`… ${batch.more} more updates not listed`] : []),
|
|
498
|
+
footer,
|
|
499
|
+
].join("\n")
|
|
500
|
+
}
|
|
501
|
+
|
|
502
|
+
// ---- The notifier -----------------------------------------------------------------------------------------
|
|
503
|
+
|
|
504
|
+
/** The run-on-the-leader notifier (CONCIERGE_SLOTS.notifier). Inert without `ports.journal`. */
|
|
505
|
+
export class ConciergeNotifierImpl implements ConciergeNotifier {
|
|
506
|
+
readonly limits: NotifierLimits
|
|
507
|
+
private readonly ports: ConciergePorts
|
|
508
|
+
private readonly journal: NotifierJournal | undefined
|
|
509
|
+
private readonly now: Clock
|
|
510
|
+
private readonly timers: Timers
|
|
511
|
+
private readonly owner: string
|
|
512
|
+
private readonly log: (message: string) => void
|
|
513
|
+
private running: Promise<void> | undefined
|
|
514
|
+
private timer: unknown
|
|
515
|
+
private soon: unknown
|
|
516
|
+
/** A pass when held notable lines come due (their quiet or max window ends), sooner than the next tick. */
|
|
517
|
+
private due: unknown
|
|
518
|
+
private dueAt: number | undefined
|
|
519
|
+
private started = false
|
|
520
|
+
private stopped = false
|
|
521
|
+
|
|
522
|
+
constructor(ports: ConciergePorts) {
|
|
523
|
+
this.ports = ports
|
|
524
|
+
this.journal = ports.journal
|
|
525
|
+
this.limits = { ...NOTIFIER_LIMITS, ...this.journal?.limits }
|
|
526
|
+
this.now = this.journal?.now ?? Date.now
|
|
527
|
+
this.timers = this.journal?.timers ?? realTimers
|
|
528
|
+
this.owner = this.journal?.owner ?? `concierge-notifier-${process.pid}`
|
|
529
|
+
this.log = this.journal?.log ?? ((message) => console.error(message))
|
|
530
|
+
}
|
|
531
|
+
|
|
532
|
+
start(): void {
|
|
533
|
+
this.stopped = false
|
|
534
|
+
if (this.started || !this.journal) return
|
|
535
|
+
this.started = true
|
|
536
|
+
this.schedule()
|
|
537
|
+
}
|
|
538
|
+
|
|
539
|
+
stop(): void {
|
|
540
|
+
this.stopped = true
|
|
541
|
+
this.started = false
|
|
542
|
+
if (this.timer !== undefined) this.timers.clear(this.timer)
|
|
543
|
+
if (this.soon !== undefined) this.timers.clear(this.soon)
|
|
544
|
+
if (this.due !== undefined) this.timers.clear(this.due)
|
|
545
|
+
this.timer = this.soon = this.due = this.dueAt = undefined
|
|
546
|
+
}
|
|
547
|
+
|
|
548
|
+
observe(userID: string, event: AgentEvent): void {
|
|
549
|
+
try {
|
|
550
|
+
if (!this.started || typeof userID !== "string" || typeof event?.type !== "string") return
|
|
551
|
+
if (early(event)) {
|
|
552
|
+
if (this.soon !== undefined) return
|
|
553
|
+
this.soon = this.timers.set(() => {
|
|
554
|
+
this.soon = undefined
|
|
555
|
+
this.passSoon()
|
|
556
|
+
}, this.limits.observeDelayMs)
|
|
557
|
+
} else if (notable(event)) this.armDue(this.now() + this.limits.notableQuietMs + this.limits.observeDelayMs)
|
|
558
|
+
} catch {
|
|
559
|
+
// Never throws (called on every journal append, never awaited).
|
|
560
|
+
}
|
|
561
|
+
}
|
|
562
|
+
|
|
563
|
+
async disabled(userID: string): Promise<void> {
|
|
564
|
+
try {
|
|
565
|
+
await this.journal?.state.delete(userID, NOTIFIER_COLLECTION, KEY)
|
|
566
|
+
} catch (cause) {
|
|
567
|
+
this.log(`[hub] concierge notifier: ledger delete failed: ${(cause as Error).message}`)
|
|
568
|
+
}
|
|
569
|
+
}
|
|
570
|
+
|
|
571
|
+
/** One pass over every user (passes never overlap). */
|
|
572
|
+
tick(): Promise<void> {
|
|
573
|
+
if (!this.journal) return Promise.resolve()
|
|
574
|
+
this.running ??= this.pass().finally(() => {
|
|
575
|
+
this.running = undefined
|
|
576
|
+
})
|
|
577
|
+
return this.running
|
|
578
|
+
}
|
|
579
|
+
|
|
580
|
+
/** The user's ledger (tests, diagnostics). */
|
|
581
|
+
async ledger(userID: string): Promise<NotifierLedger | undefined> {
|
|
582
|
+
return (await this.journal?.state.get<NotifierLedger>(userID, NOTIFIER_COLLECTION, KEY))?.value
|
|
583
|
+
}
|
|
584
|
+
|
|
585
|
+
// ---------- internals ----------
|
|
586
|
+
|
|
587
|
+
/** A pass now, after the one running (if any). */
|
|
588
|
+
private passSoon(): void {
|
|
589
|
+
const after = this.running ? this.running.then(() => this.tick()) : this.tick()
|
|
590
|
+
void after.catch(() => undefined)
|
|
591
|
+
}
|
|
592
|
+
|
|
593
|
+
/** One pass at `at` (on the leader loop only), unless one is already due by then. */
|
|
594
|
+
private armDue(at: number): void {
|
|
595
|
+
if (!this.started || at <= this.now() || (this.dueAt !== undefined && this.dueAt <= at)) return
|
|
596
|
+
if (this.due !== undefined) this.timers.clear(this.due)
|
|
597
|
+
this.dueAt = at
|
|
598
|
+
this.due = this.timers.set(() => {
|
|
599
|
+
this.due = this.dueAt = undefined
|
|
600
|
+
this.passSoon()
|
|
601
|
+
}, Math.max(0, at - this.now()))
|
|
602
|
+
}
|
|
603
|
+
|
|
604
|
+
/** When held notable (or demoted priority) lines come due: their quiet window ends, or their max one. */
|
|
605
|
+
private notableDue(ledger: NotifierLedger): number | undefined {
|
|
606
|
+
const timed = ledger.buffer.filter((l) => l.c !== "routine")
|
|
607
|
+
const carried = ledger.buffer.flatMap((l) => (l.c === "routine" && l.t0 !== undefined ? [l.t0] : []))
|
|
608
|
+
if (!timed.length && !carried.length) return undefined
|
|
609
|
+
const firsts = [...timed.map((l) => l.t0 ?? l.t ?? ledger.firstAt ?? this.now()), ...carried]
|
|
610
|
+
const max = Math.min(...firsts) + this.limits.notableMaxMs
|
|
611
|
+
if (!timed.length) return max
|
|
612
|
+
const times = timed.map((l) => l.t ?? ledger.lastAt ?? this.now())
|
|
613
|
+
return Math.min(Math.max(...times) + this.limits.notableQuietMs, max)
|
|
614
|
+
}
|
|
615
|
+
|
|
616
|
+
private leading(): boolean {
|
|
617
|
+
return !this.stopped && (this.journal?.isLeader?.() ?? true)
|
|
618
|
+
}
|
|
619
|
+
|
|
620
|
+
private schedule(): void {
|
|
621
|
+
this.timer = this.timers.set(() => {
|
|
622
|
+
this.timer = undefined
|
|
623
|
+
void this.tick().finally(() => {
|
|
624
|
+
if (this.started) this.schedule()
|
|
625
|
+
})
|
|
626
|
+
}, this.journal?.tickMs ?? NOTIFIER_TICK_MS)
|
|
627
|
+
}
|
|
628
|
+
|
|
629
|
+
private async pass(): Promise<void> {
|
|
630
|
+
if (!this.leading()) return
|
|
631
|
+
let users: string[]
|
|
632
|
+
try {
|
|
633
|
+
users = [...(await this.ports.users())]
|
|
634
|
+
} catch (cause) {
|
|
635
|
+
this.log(`[hub] concierge notifier: users failed: ${(cause as Error).message}`)
|
|
636
|
+
return
|
|
637
|
+
}
|
|
638
|
+
for (const userID of users) {
|
|
639
|
+
if (!this.leading()) return
|
|
640
|
+
try {
|
|
641
|
+
await this.user(userID)
|
|
642
|
+
} catch (cause) {
|
|
643
|
+
this.log(`[hub] concierge notifier: pass failed: ${(cause as Error).message}`)
|
|
644
|
+
}
|
|
645
|
+
}
|
|
646
|
+
}
|
|
647
|
+
|
|
648
|
+
private fresh(position: string, t: number, sessionID: string | undefined): NotifierLedger {
|
|
649
|
+
return { v: 1, ...(sessionID ? { sessionID } : {}), position, buffer: [], window: [], seq: 0, facts: {}, alerts: {}, digests: {}, counts: { ...ZERO }, countsSince: t, history: [] }
|
|
650
|
+
}
|
|
651
|
+
|
|
652
|
+
private async user(userID: string): Promise<void> {
|
|
653
|
+
const journal = this.journal!
|
|
654
|
+
const [settings, status] = await Promise.all([this.ports.settings(userID), this.ports.status(userID)])
|
|
655
|
+
if (!settings.enabled || status.state === "disabled") {
|
|
656
|
+
// Nothing to a disabled Concierge, and nothing kept for it.
|
|
657
|
+
if (await journal.state.get(userID, NOTIFIER_COLLECTION, KEY)) await journal.state.delete(userID, NOTIFIER_COLLECTION, KEY)
|
|
658
|
+
return
|
|
659
|
+
}
|
|
660
|
+
const identity = await this.ports.identity.current(userID)
|
|
661
|
+
const t = this.now()
|
|
662
|
+
const latest = await journal.events.latest(userID)
|
|
663
|
+
const stored = await journal.state.get<NotifierLedger>(userID, NOTIFIER_COLLECTION, KEY)
|
|
664
|
+
let ledger = stored?.value ?? this.fresh(latest, t, identity.sessionID)
|
|
665
|
+
// Someone may be sending it right now (another pod before failover, or a pass in flight).
|
|
666
|
+
if (ledger.batch?.state === "sending" && (ledger.batch.claimedAt ?? 0) + this.limits.claimGraceMs > t) return
|
|
667
|
+
const running = status.state === "running" && identity.sessionID !== undefined
|
|
668
|
+
// A relaunched Concierge is a new session: it knows nothing the old one was told.
|
|
669
|
+
if (running && ledger.sessionID !== identity.sessionID) ledger = { ...ledger, sessionID: identity.sessionID, facts: {}, alerts: {} }
|
|
670
|
+
if (ledger.position !== latest) ledger = await this.scan(userID, ledger, identity.hiddenSessionIDs, t)
|
|
671
|
+
ledger = this.housekeeping(ledger, t)
|
|
672
|
+
ledger = this.digestsDue(ledger, settings, t)
|
|
673
|
+
// Quiet hours also hold retries and stale-claim resends: only a priority batch with quietAllowsUrgent goes now;
|
|
674
|
+
// any other is held again and goes with the release (or the digest) when they end.
|
|
675
|
+
if (ledger.batch && inQuietHours(settings.quietHours, t) && !(ledger.batch.budget === "priority" && settings.quietAllowsUrgent)) ledger = this.defer(ledger, ledger.batch)
|
|
676
|
+
if (!ledger.batch && running) ledger = this.cut(ledger, settings, t)
|
|
677
|
+
// Held notable lines: a pass when they come due, not on the next tick after that. Only a FUTURE due: held lines
|
|
678
|
+
// already due were just declined by cut (quiet hours, a spent budget), which only time or the tick changes.
|
|
679
|
+
if (running && !ledger.batch) {
|
|
680
|
+
const due = this.notableDue(ledger)
|
|
681
|
+
if (due !== undefined && due > t) this.armDue(due)
|
|
682
|
+
}
|
|
683
|
+
|
|
684
|
+
const batch = ledger.batch
|
|
685
|
+
const sendable = running && batch !== undefined && (batch.state === "ready" || batch.state === "sending" || (batch.state === "retry" && (batch.nextAt ?? 0) <= t))
|
|
686
|
+
let claimed: NotifierBatch | undefined
|
|
687
|
+
if (sendable) {
|
|
688
|
+
claimed = { ...batch, state: "sending", attempts: batch.attempts + 1, claimedBy: this.owner, claimedAt: t, ...(batch.state === "sending" ? { last: "uncertain" as const } : {}) }
|
|
689
|
+
ledger = { ...ledger, batch: claimed }
|
|
690
|
+
}
|
|
691
|
+
if (!this.leading()) return
|
|
692
|
+
if (!claimed && stored && JSON.stringify(stored.value) === JSON.stringify(ledger)) return
|
|
693
|
+
const put = await journal.state.put(userID, NOTIFIER_COLLECTION, KEY, ledger, { ifVersion: stored?.version ?? 0 })
|
|
694
|
+
if (!put.ok || !claimed) return
|
|
695
|
+
await this.attempt(userID, identity.hiddenSessionIDs, claimed)
|
|
696
|
+
}
|
|
697
|
+
|
|
698
|
+
/** Reads the journal after the ledger's position into its buffer (or owes a snapshot). */
|
|
699
|
+
private async scan(userID: string, ledger: NotifierLedger, hiddenIDs: readonly string[], t: number): Promise<NotifierLedger> {
|
|
700
|
+
const journal = this.journal!
|
|
701
|
+
const result: EventsSince = await journal.events.since(userID, ledger.position)
|
|
702
|
+
if ("reset" in result) {
|
|
703
|
+
const position = await journal.events.latest(userID)
|
|
704
|
+
// How much the journal dropped is unknown: a priority snapshot (quiet hours still apply) that says so.
|
|
705
|
+
const missed = (ledger.snapshot?.missed ?? 0) + ledger.buffer.length
|
|
706
|
+
return { ...without(ledger, "firstAt", "lastAt"), position, buffer: [], snapshot: { reason: "reset", missed, unknown: true, urgent: true } }
|
|
707
|
+
}
|
|
708
|
+
if (!result.records.length) return ledger
|
|
709
|
+
const hidden = new Set(hiddenIDs)
|
|
710
|
+
let view: WatchedView | undefined
|
|
711
|
+
let { snapshot, firstAt, lastAt } = ledger
|
|
712
|
+
let buffer: Line[] = [...ledger.buffer]
|
|
713
|
+
const counts = { ...ledger.counts }
|
|
714
|
+
for (const record of result.records) {
|
|
715
|
+
if (!RELEVANT.has(record.event.type)) continue
|
|
716
|
+
view ??= await journal.watched(userID, hiddenIDs)
|
|
717
|
+
let decision: ClassifyContext["decision"]
|
|
718
|
+
if (record.event.type === "message.changed" && record.event.data.kind === "ask" && record.event.data.to === "@me" && typeof record.event.subject.messageID === "string") {
|
|
719
|
+
decision = await journal.decisionOf?.(userID, record.event.subject.messageID).catch(() => undefined)
|
|
720
|
+
}
|
|
721
|
+
const raw = classifyEvent(record, { hidden, view, ...(decision ? { decision } : {}) })
|
|
722
|
+
if (!raw) continue
|
|
723
|
+
counts[raw.c]++
|
|
724
|
+
const held = this.fold(raw, ledger.alerts, t)
|
|
725
|
+
if (snapshot) {
|
|
726
|
+
snapshot = { ...snapshot, missed: snapshot.missed + 1, ...(held.c === "priority" ? { urgent: true as const } : {}) }
|
|
727
|
+
continue
|
|
728
|
+
}
|
|
729
|
+
if (held.o && buffer.some((l) => l.k === held.k && l.f === held.f)) continue
|
|
730
|
+
// A state line replaces the routine one held for the same subject, never a held priority line; it keeps the
|
|
731
|
+
// earliest notable time of what it replaced (the max window).
|
|
732
|
+
let t0: number | undefined
|
|
733
|
+
if (held.k !== undefined) {
|
|
734
|
+
for (const l of buffer) {
|
|
735
|
+
if (l.k !== held.k || l.c === "priority") continue
|
|
736
|
+
const first = l.t0 ?? (l.c !== "routine" ? l.t : undefined)
|
|
737
|
+
if (first !== undefined && (t0 === undefined || first < t0)) t0 = first
|
|
738
|
+
}
|
|
739
|
+
buffer = buffer.filter((l) => l.k !== held.k || l.c === "priority")
|
|
740
|
+
}
|
|
741
|
+
if (buffer.length >= this.limits.bufferLines) {
|
|
742
|
+
const urgent = held.c === "priority" || buffer.some((l) => l.c === "priority")
|
|
743
|
+
snapshot = { reason: "overflow", missed: buffer.length + 1, ...(urgent ? { urgent: true as const } : {}) }
|
|
744
|
+
buffer = []
|
|
745
|
+
firstAt = lastAt = undefined
|
|
746
|
+
continue
|
|
747
|
+
}
|
|
748
|
+
buffer.push({ ...held, t: record.event.at, ...(t0 !== undefined ? { t0 } : {}) })
|
|
749
|
+
firstAt ??= record.event.at
|
|
750
|
+
lastAt = record.event.at
|
|
751
|
+
}
|
|
752
|
+
const base = without(ledger, "firstAt", "lastAt", "snapshot")
|
|
753
|
+
return { ...base, position: result.records.at(-1)!.position, buffer, counts, ...(snapshot ? { snapshot } : {}), ...(buffer.length && firstAt !== undefined ? { firstAt, lastAt: lastAt! } : {}) }
|
|
754
|
+
}
|
|
755
|
+
|
|
756
|
+
/** A pending batch held again (quiet hours): its lines, snapshot and digest go back to the ledger, marked quiet-held. */
|
|
757
|
+
private defer(ledger: NotifierLedger, batch: NotifierBatch): NotifierLedger {
|
|
758
|
+
let buffer: Line[] = [...(batch.held ?? []), ...ledger.buffer]
|
|
759
|
+
let snapshot = ledger.snapshot
|
|
760
|
+
if (batch.snapshot) {
|
|
761
|
+
const unknown = batch.snapshot.unknown || snapshot?.unknown
|
|
762
|
+
const urgent = batch.budget === "priority" || snapshot?.urgent
|
|
763
|
+
snapshot = { reason: batch.snapshot.reason, missed: batch.snapshot.missed + (snapshot?.missed ?? 0), ...(unknown ? { unknown: true as const } : {}), ...(urgent ? { urgent: true as const } : {}) }
|
|
764
|
+
}
|
|
765
|
+
if (buffer.length > this.limits.bufferLines) {
|
|
766
|
+
const urgent = snapshot?.urgent || buffer.some((l) => l.c === "priority")
|
|
767
|
+
snapshot = { reason: "overflow", missed: (snapshot?.missed ?? 0) + buffer.length, ...(snapshot?.unknown ? { unknown: true as const } : {}), ...(urgent ? { urgent: true as const } : {}) }
|
|
768
|
+
buffer = []
|
|
769
|
+
}
|
|
770
|
+
const supersedes = batch.attempts > 0 ? [...(ledger.supersedes ?? []), batch.id].slice(-10) : ledger.supersedes
|
|
771
|
+
const d = batch.digest
|
|
772
|
+
const digest = d
|
|
773
|
+
? {
|
|
774
|
+
owedDigest: ledger.owedDigest ?? { key: d.key ?? `digest:${batch.id}`, label: d.label, at: batch.cutAt },
|
|
775
|
+
counts: { priority: d.counts.priority + ledger.counts.priority, notable: d.counts.notable + ledger.counts.notable, routine: d.counts.routine + ledger.counts.routine },
|
|
776
|
+
countsSince: d.since,
|
|
777
|
+
}
|
|
778
|
+
: {}
|
|
779
|
+
const at = { firstAt: ledger.firstAt ?? batch.cutAt, lastAt: ledger.lastAt ?? batch.cutAt }
|
|
780
|
+
return {
|
|
781
|
+
...without(ledger, "batch", "snapshot", "firstAt", "lastAt"),
|
|
782
|
+
buffer,
|
|
783
|
+
...(buffer.length ? at : {}),
|
|
784
|
+
...(snapshot ? { snapshot } : {}),
|
|
785
|
+
...(supersedes?.length ? { supersedes } : {}),
|
|
786
|
+
...digest,
|
|
787
|
+
quietHeld: true,
|
|
788
|
+
}
|
|
789
|
+
}
|
|
790
|
+
|
|
791
|
+
/** A priority line about a reason it was woken about within foldMs: routine instead (RF-05). */
|
|
792
|
+
private fold(line: Line, alerts: Readonly<Record<string, number>>, t: number): Line {
|
|
793
|
+
if (line.c !== "priority" || !line.b?.length) return line
|
|
794
|
+
const known = line.b.every((key) => alerts[key] !== undefined && t - alerts[key]! < this.limits.foldMs)
|
|
795
|
+
return known ? { ...without(line, "b"), c: "routine", text: `-${line.text.slice(1)}` } : line
|
|
796
|
+
}
|
|
797
|
+
|
|
798
|
+
private housekeeping(ledger: NotifierLedger, t: number): NotifierLedger {
|
|
799
|
+
const alerts: Record<string, number> = {}
|
|
800
|
+
for (const [key, at] of Object.entries(ledger.alerts)) if (t - at < this.limits.foldMs) alerts[key] = at
|
|
801
|
+
const digests: Record<string, number> = {}
|
|
802
|
+
for (const [key, at] of Object.entries(ledger.digests)) if (t - at < 8 * DAY_MS) digests[key] = at
|
|
803
|
+
return { ...ledger, window: ledger.window.filter((w) => w.at > t - HOUR_MS), alerts, digests }
|
|
804
|
+
}
|
|
805
|
+
|
|
806
|
+
/** Marks each due digest (once per key) and owes the latest. */
|
|
807
|
+
private digestsDue(ledger: NotifierLedger, settings: ConciergeSettings, t: number): NotifierLedger {
|
|
808
|
+
let next = ledger
|
|
809
|
+
for (const due of dueDigests(settings.digests ?? [], settings.timeZone, t, this.limits.digestGraceMs)) {
|
|
810
|
+
if (next.digests[due.key] !== undefined) continue
|
|
811
|
+
next = { ...next, digests: { ...next.digests, [due.key]: t }, owedDigest: { key: due.key, label: due.label, at: due.occurredAt } }
|
|
812
|
+
}
|
|
813
|
+
return next
|
|
814
|
+
}
|
|
815
|
+
|
|
816
|
+
/** What the Concierge does not know yet: a state line repeating the last delivered state is dropped. */
|
|
817
|
+
private news(ledger: NotifierLedger): Line[] {
|
|
818
|
+
const known = ledger.facts
|
|
819
|
+
const urgentSubjects = new Set(ledger.buffer.flatMap((l) => (l.c === "priority" && l.k !== undefined ? [l.k] : [])))
|
|
820
|
+
return ledger.buffer.filter((l) => {
|
|
821
|
+
if (l.k === undefined) return true
|
|
822
|
+
if (l.o) return known[l.k] !== l.f
|
|
823
|
+
// It carries an undelivered turn done (t0): news even when its state is the one told last.
|
|
824
|
+
if (l.t0 !== undefined) return true
|
|
825
|
+
// A turn done is news even when the last state told was idle: the working in between was never told (a routine
|
|
826
|
+
// line it replaced while held).
|
|
827
|
+
return l.c === "priority" || l.c === "notable" || urgentSubjects.has(l.k) || known[l.k] !== l.f
|
|
828
|
+
})
|
|
829
|
+
}
|
|
830
|
+
|
|
831
|
+
/** Turns held lines, an owed snapshot or digest into the next batch, when due. */
|
|
832
|
+
private cut(ledger: NotifierLedger, settings: ConciergeSettings, t: number): NotifierLedger {
|
|
833
|
+
const L = this.limits
|
|
834
|
+
const used = (b: LineClass) => ledger.window.filter((w) => w.budget === b).length
|
|
835
|
+
const room: Record<LineClass, boolean> = { priority: used("priority") < L.priorityPerHour, notable: used("notable") < L.notablePerHour, routine: used("routine") < L.routinePerHour }
|
|
836
|
+
const quiet = inQuietHours(settings.quietHours, t)
|
|
837
|
+
// A state line that replaced an undelivered turn done (t0) past the max window is that turn done: notable.
|
|
838
|
+
const news = this.news(ledger).map((l) => (l.c === "routine" && l.t0 !== undefined && t - l.t0 >= L.notableMaxMs ? overdueTurn(l) : l))
|
|
839
|
+
const rest = { ...ledger, buffer: news, ...(news.length ? {} : { firstAt: undefined, lastAt: undefined }) } as NotifierLedger
|
|
840
|
+
const clean = (l: NotifierLedger) => (l.buffer.length ? l : (without(l, "firstAt", "lastAt") as NotifierLedger))
|
|
841
|
+
|
|
842
|
+
// A digest (not in quiet hours): everything held goes with it, quiet-hours releases included.
|
|
843
|
+
if (ledger.owedDigest && !quiet) {
|
|
844
|
+
const digest = { key: ledger.owedDigest.key, label: ledger.owedDigest.label, zone: zoneOrUTC(settings.timeZone), since: ledger.countsSince, counts: ledger.counts }
|
|
845
|
+
const next = this.compose({ ...rest, buffer: [] }, news, { kind: "digest", budget: "digest", wake: true, digest, ...(ledger.snapshot ? { snapshot: ledger.snapshot } : {}) }, t)
|
|
846
|
+
return clean({ ...without(next, "owedDigest", "quietHeld", "snapshot"), counts: { ...ZERO }, countsSince: t })
|
|
847
|
+
}
|
|
848
|
+
if (quiet) {
|
|
849
|
+
const priority = news.filter((l) => l.c === "priority")
|
|
850
|
+
const urgent = priority.length > 0 || ledger.snapshot?.urgent === true
|
|
851
|
+
const holding = news.length > 0 || ledger.snapshot !== undefined || ledger.owedDigest !== undefined
|
|
852
|
+
if (settings.quietAllowsUrgent && urgent && room.priority) {
|
|
853
|
+
const others = news.filter((l) => l.c !== "priority")
|
|
854
|
+
const next = this.compose({ ...rest, buffer: others }, priority, { kind: ledger.snapshot?.urgent ? "snapshot" : "delta", budget: "priority", wake: true, ...(ledger.snapshot?.urgent ? { snapshot: ledger.snapshot } : {}) }, t)
|
|
855
|
+
const cleared: NotifierLedger = ledger.snapshot?.urgent ? without(next, "snapshot") : next
|
|
856
|
+
return clean(others.length || cleared.snapshot || cleared.owedDigest ? { ...cleared, quietHeld: true } : cleared)
|
|
857
|
+
}
|
|
858
|
+
return clean(holding ? { ...rest, quietHeld: true } : rest)
|
|
859
|
+
}
|
|
860
|
+
if (ledger.quietHeld) {
|
|
861
|
+
if (!news.length && !ledger.snapshot) return clean(without(rest, "quietHeld"))
|
|
862
|
+
return clean(without(this.compose({ ...rest, buffer: [] }, news, { kind: "release", budget: "digest", wake: true, ...(ledger.snapshot ? { snapshot: ledger.snapshot } : {}) }, t), "quietHeld", "snapshot"))
|
|
863
|
+
}
|
|
864
|
+
if (ledger.snapshot) {
|
|
865
|
+
const urgent = ledger.snapshot.urgent === true
|
|
866
|
+
const budget: LineClass | undefined = urgent ? (room.priority ? "priority" : room.notable ? "notable" : room.routine ? "routine" : undefined) : room.routine ? "routine" : undefined
|
|
867
|
+
if (!budget) return clean(rest)
|
|
868
|
+
return clean(without(this.compose({ ...rest, buffer: [] }, [], { kind: "snapshot", budget, wake: budget !== "routine", snapshot: ledger.snapshot }, t), "snapshot"))
|
|
869
|
+
}
|
|
870
|
+
if (!news.length) return clean(rest)
|
|
871
|
+
const top: LineClass = news.some((l) => l.c === "priority") ? "priority" : news.some((l) => l.c === "notable") ? "notable" : "routine"
|
|
872
|
+
const order: LineClass[] = top === "priority" ? ["priority", "notable", "routine"] : top === "notable" ? ["notable", "routine"] : ["routine"]
|
|
873
|
+
const budget = order.find((b) => room[b])
|
|
874
|
+
if (!budget) return clean(rest)
|
|
875
|
+
if (budget !== "priority") {
|
|
876
|
+
const quietMs = budget === "notable" ? L.notableQuietMs : L.routineQuietMs
|
|
877
|
+
const maxMs = budget === "notable" ? L.notableMaxMs : L.routineMaxMs
|
|
878
|
+
// A wake's windows count from its own (notable and demoted priority) lines: routine churn of other sessions,
|
|
879
|
+
// which goes along, never holds it back.
|
|
880
|
+
const timed = budget === "notable" ? news.filter((l) => l.c !== "routine") : []
|
|
881
|
+
const last = timed.length ? Math.max(...timed.map((l) => l.t ?? ledger.lastAt ?? t)) : (ledger.lastAt ?? t)
|
|
882
|
+
const first = timed.length ? Math.min(...timed.map((l) => l.t0 ?? l.t ?? ledger.firstAt ?? t)) : (ledger.firstAt ?? t)
|
|
883
|
+
const quietEnough = t - last >= quietMs
|
|
884
|
+
const late = t - first >= maxMs
|
|
885
|
+
if (!quietEnough && !late) return clean(rest)
|
|
886
|
+
}
|
|
887
|
+
return clean(this.compose({ ...rest, buffer: [] }, news, { kind: "delta", budget, wake: budget !== "routine" }, t))
|
|
888
|
+
}
|
|
889
|
+
|
|
890
|
+
/** A batch of `lines` (priority first), with the ledger's owed snapshot noted; `ledger.buffer` is what stays. */
|
|
891
|
+
private compose(
|
|
892
|
+
ledger: NotifierLedger,
|
|
893
|
+
lines: readonly Line[],
|
|
894
|
+
options: { kind: BatchKind; budget: Budget; wake: boolean; digest?: NotifierBatch["digest"]; snapshot?: NotifierLedger["snapshot"] },
|
|
895
|
+
t: number,
|
|
896
|
+
): NotifierLedger {
|
|
897
|
+
const rank: Record<LineClass, number> = { priority: 0, notable: 1, routine: 2 }
|
|
898
|
+
const ordered = [...lines].sort((a, b) => rank[a.c] - rank[b.c])
|
|
899
|
+
const listed = ordered.slice(0, this.limits.linesPerBatch)
|
|
900
|
+
const counts = { ...ZERO }
|
|
901
|
+
for (const l of listed) counts[l.c]++
|
|
902
|
+
const facts = Object.fromEntries(listed.flatMap((l) => (l.k !== undefined && l.f !== undefined ? [[l.k, l.f] as const] : [])))
|
|
903
|
+
const alerts = options.wake ? [...new Set(listed.flatMap((l) => (l.c === "priority" ? (l.b ?? []) : [])))] : []
|
|
904
|
+
const seq = ledger.seq + 1
|
|
905
|
+
const batch: NotifierBatch = {
|
|
906
|
+
id: `b${seq}`,
|
|
907
|
+
seq,
|
|
908
|
+
kind: options.kind,
|
|
909
|
+
budget: options.budget,
|
|
910
|
+
wake: options.wake,
|
|
911
|
+
lines: listed.map((l) => l.text),
|
|
912
|
+
more: ordered.length - listed.length,
|
|
913
|
+
counts,
|
|
914
|
+
facts,
|
|
915
|
+
...(alerts.length ? { alerts } : {}),
|
|
916
|
+
...(options.snapshot ? { snapshot: { reason: options.snapshot.reason, missed: options.snapshot.missed, ...(options.snapshot.unknown ? { unknown: true as const } : {}) } } : {}),
|
|
917
|
+
held: listed,
|
|
918
|
+
...(ledger.supersedes?.length ? { supersedes: ledger.supersedes } : {}),
|
|
919
|
+
...(options.digest ? { digest: options.digest } : {}),
|
|
920
|
+
cutAt: t,
|
|
921
|
+
state: "ready",
|
|
922
|
+
attempts: 0,
|
|
923
|
+
}
|
|
924
|
+
return { ...without(ledger, "supersedes"), seq, batch }
|
|
925
|
+
}
|
|
926
|
+
|
|
927
|
+
/** Sends a claimed batch and records the outcome (only if the ledger still holds that batch). */
|
|
928
|
+
private async attempt(userID: string, hidden: readonly string[], batch: NotifierBatch): Promise<void> {
|
|
929
|
+
if (!this.leading()) return // the claim stays: after claimGraceMs the next leader resends it
|
|
930
|
+
const journal = this.journal!
|
|
931
|
+
// The board is on the session before a wake (2 s budget); a failed push keeps the last one (its age shows).
|
|
932
|
+
if (batch.wake) await this.within(this.ports.beforeWake(userID), this.limits.beforeWakeMs).catch(() => false)
|
|
933
|
+
const revision = journal.boardRevision ? await this.within(journal.boardRevision(userID, hidden), this.limits.beforeWakeMs).catch(() => undefined) : undefined
|
|
934
|
+
const text = renderBatch(batch, revision)
|
|
935
|
+
let result: { messageID: string; woke?: boolean } | undefined
|
|
936
|
+
let failure: "uncertain" | "refused" | undefined
|
|
937
|
+
try {
|
|
938
|
+
if (!this.leading()) return
|
|
939
|
+
// Queued, never steered: no `urgent`.
|
|
940
|
+
result = await this.within(this.ports.send(userID, text, { wake: batch.wake }), this.limits.sendTimeoutMs)
|
|
941
|
+
} catch (cause) {
|
|
942
|
+
failure = cause instanceof AgentError && REFUSED.has(cause.code) ? "refused" : "uncertain"
|
|
943
|
+
this.log(`[hub] concierge notifier: ${batch.id} attempt ${batch.attempts} ${failure}: ${(cause as Error).message}`)
|
|
944
|
+
}
|
|
945
|
+
const t = this.now()
|
|
946
|
+
const L = this.limits
|
|
947
|
+
const recorded = await updateVersioned<NotifierLedger>(journal.state, userID, NOTIFIER_COLLECTION, KEY, (current) => {
|
|
948
|
+
if (!current?.batch || current.batch.id !== batch.id) return undefined // revoked (disabled) meanwhile
|
|
949
|
+
const window = failure === "refused" || batch.budget === "digest" ? current.window : [...current.window, { at: t, budget: batch.budget }]
|
|
950
|
+
if (!failure) {
|
|
951
|
+
const facts = bounded({ ...(batch.kind === "snapshot" ? {} : current.facts), ...batch.facts }, L.factsMax)
|
|
952
|
+
const woke = batch.wake && result?.woke !== false
|
|
953
|
+
const alerts: Record<string, number> = { ...current.alerts }
|
|
954
|
+
if (woke) for (const key of batch.alerts ?? []) alerts[key] = t
|
|
955
|
+
const history = [...current.history, { id: batch.id, kind: batch.kind, at: t, attempts: batch.attempts, outcome: "delivered" as const, messageID: result!.messageID }].slice(-L.historyMax)
|
|
956
|
+
return { ...without(current, "batch"), window, facts, alerts: bounded(alerts, L.factsMax), history }
|
|
957
|
+
}
|
|
958
|
+
if (batch.attempts >= L.maxAttempts) {
|
|
959
|
+
const urgent = batch.wake || current.buffer.some((l) => l.c === "priority")
|
|
960
|
+
const history = [...current.history, { id: batch.id, kind: batch.kind, at: t, attempts: batch.attempts, outcome: "abandoned" as const }].slice(-L.historyMax)
|
|
961
|
+
const missed = batch.lines.length + batch.more + current.buffer.length + (current.snapshot?.missed ?? 0)
|
|
962
|
+
const unknown = current.snapshot?.unknown || batch.snapshot?.unknown
|
|
963
|
+
return { ...without(current, "batch", "firstAt", "lastAt"), window, buffer: [], facts: {}, snapshot: { reason: "abandoned" as const, missed, ...(unknown ? { unknown: true as const } : {}), ...(urgent ? { urgent: true as const } : {}) }, history }
|
|
964
|
+
}
|
|
965
|
+
const wait = L.retryMs[Math.min(batch.attempts - 1, L.retryMs.length - 1)] ?? 60_000
|
|
966
|
+
return { ...current, window, batch: { ...without(batch, "claimedBy", "claimedAt"), ...(failure ? { last: failure } : {}), state: "retry", nextAt: t + wait } }
|
|
967
|
+
})
|
|
968
|
+
if (!failure && recorded) {
|
|
969
|
+
try {
|
|
970
|
+
if (batch.kind === "digest") this.ports.entitlements.record(userID, { kind: "digest", n: 1 })
|
|
971
|
+
else if (batch.wake && result?.woke !== false) this.ports.entitlements.record(userID, { kind: "wake", n: 1 })
|
|
972
|
+
} catch (cause) {
|
|
973
|
+
this.log(`[hub] concierge notifier: entitlements failed: ${(cause as Error).message}`)
|
|
974
|
+
}
|
|
975
|
+
}
|
|
976
|
+
}
|
|
977
|
+
|
|
978
|
+
/** The promise, or a rejection after `ms` (it may still settle: the caller treats that as uncertain). */
|
|
979
|
+
private within<T>(promise: Promise<T>, ms: number): Promise<T> {
|
|
980
|
+
let handle: unknown
|
|
981
|
+
const timeout = new Promise<never>((_, reject) => {
|
|
982
|
+
handle = this.timers.set(() => reject(new Error(`no answer within ${ms} ms`)), ms)
|
|
983
|
+
})
|
|
984
|
+
return Promise.race([promise, timeout]).finally(() => this.timers.clear(handle))
|
|
985
|
+
}
|
|
986
|
+
}
|