@huynle/hub 0.7.3 → 0.8.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (221) hide show
  1. package/CHANGELOG.md +229 -0
  2. package/Dockerfile +7 -2
  3. package/README.md +153 -24
  4. package/package.json +5 -3
  5. package/packages/claude-code/package.json +1 -1
  6. package/packages/claude-code/src/daemon.ts +8 -1
  7. package/packages/claude-code/src/launcher.ts +29 -9
  8. package/packages/claude-code/src/profile.ts +173 -0
  9. package/packages/claude-code/src/sessions.ts +22 -4
  10. package/packages/cli/dist/hub-cli.js +6474 -3470
  11. package/packages/cli/package.json +1 -1
  12. package/packages/cli/src/artifact-jobs-mcp.ts +226 -0
  13. package/packages/cli/src/artifacts-mcp.ts +160 -0
  14. package/packages/cli/src/bootstrap/enrollment/enroll.ts +20 -8
  15. package/packages/cli/src/bootstrap/enrollment/policy.ts +125 -12
  16. package/packages/cli/src/bootstrap/enrollment/status.ts +3 -2
  17. package/packages/cli/src/cli.ts +95 -13
  18. package/packages/cli/src/format.ts +48 -10
  19. package/packages/cli/src/http.ts +1 -0
  20. package/packages/cli/src/intents.ts +23 -0
  21. package/packages/cli/src/login.ts +20 -6
  22. package/packages/cli/src/mcp.ts +380 -9
  23. package/packages/cli/src/messaging.ts +3 -1
  24. package/packages/cli/src/setup/commands.ts +3 -2
  25. package/packages/cli/src/setup/runtime.ts +3 -2
  26. package/packages/cli/src/team-intake-mcp.ts +158 -0
  27. package/packages/cli/src/teams-mcp.ts +403 -0
  28. package/packages/cli/src/watchlist.ts +135 -0
  29. package/packages/codex/package.json +1 -1
  30. package/packages/codex/src/profile.ts +163 -0
  31. package/packages/codex/src/sessions.ts +12 -0
  32. package/packages/codex/src/spoke.ts +19 -1
  33. package/packages/node/package.json +1 -1
  34. package/packages/node/src/config.ts +96 -2
  35. package/packages/node/src/opencode-warm.ts +325 -0
  36. package/packages/node/src/run.ts +27 -1
  37. package/packages/preset/package.json +1 -1
  38. package/packages/preset/src/profile.ts +34 -0
  39. package/packages/preset/src/spoke.ts +4 -0
  40. package/packages/protocol/package.json +1 -1
  41. package/packages/protocol/src/artifacts.ts +142 -0
  42. package/packages/protocol/src/concierge.ts +366 -0
  43. package/packages/protocol/src/contracts.ts +104 -0
  44. package/packages/protocol/src/graph.ts +70 -0
  45. package/packages/protocol/src/hub-workspace.ts +201 -0
  46. package/packages/protocol/src/index.ts +169 -6
  47. package/packages/protocol/src/launch.ts +22 -0
  48. package/packages/protocol/src/profiles.ts +301 -0
  49. package/packages/protocol/src/teams.ts +204 -0
  50. package/packages/runtime-openshell/package.json +1 -1
  51. package/packages/runtime-sdk/package.json +1 -1
  52. package/packages/sdk/package.json +1 -1
  53. package/packages/sdk/src/address.ts +50 -2
  54. package/packages/sdk/src/artifact-jobs.ts +194 -0
  55. package/packages/sdk/src/artifacts.ts +187 -0
  56. package/packages/sdk/src/client.ts +226 -5
  57. package/packages/sdk/src/concierge.ts +88 -0
  58. package/packages/sdk/src/errors.ts +3 -1
  59. package/packages/sdk/src/index.ts +5 -0
  60. package/packages/sdk/src/team-intake.ts +122 -0
  61. package/packages/sdk/src/teams.ts +346 -0
  62. package/packages/sdk/src/types.ts +548 -1
  63. package/packages/spoke-sdk/package.json +1 -1
  64. package/packages/spoke-sdk/src/index.ts +1 -0
  65. package/packages/spoke-sdk/src/profile-apply.ts +245 -0
  66. package/src/central/actions.ts +7 -0
  67. package/src/central/agent/acting-routes.ts +2 -1
  68. package/src/central/agent/acting-switch.ts +2 -1
  69. package/src/central/agent/acting.ts +215 -10
  70. package/src/central/agent/app-tokens.ts +3 -2
  71. package/src/central/agent/audit.ts +17 -0
  72. package/src/central/agent/body.ts +163 -0
  73. package/src/central/agent/decisions.ts +69 -2
  74. package/src/central/agent/delivery.ts +95 -15
  75. package/src/central/agent/directory-routes.ts +37 -7
  76. package/src/central/agent/directory.ts +70 -14
  77. package/src/central/agent/errors.ts +5 -0
  78. package/src/central/agent/events-routes.ts +21 -4
  79. package/src/central/agent/events.ts +11 -3
  80. package/src/central/agent/form-check.ts +85 -0
  81. package/src/central/agent/grant-requests.ts +2 -1
  82. package/src/central/agent/grant-routes.ts +4 -3
  83. package/src/central/agent/grants.ts +29 -2
  84. package/src/central/agent/idempotency.ts +133 -0
  85. package/src/central/agent/intent-routes.ts +116 -0
  86. package/src/central/agent/intents.ts +505 -0
  87. package/src/central/agent/interaction-graph-routes.ts +212 -0
  88. package/src/central/agent/interaction-graph.ts +865 -0
  89. package/src/central/agent/launch-engine.ts +16 -2
  90. package/src/central/agent/launch-routes.ts +6 -1
  91. package/src/central/agent/launch-spec.ts +14 -1
  92. package/src/central/agent/message-routes.ts +2 -1
  93. package/src/central/agent/messages.ts +27 -2
  94. package/src/central/agent/names.ts +12 -2
  95. package/src/central/agent/read-routes.ts +86 -19
  96. package/src/central/agent/routes.ts +176 -5
  97. package/src/central/agent/webhook-routes.ts +2 -1
  98. package/src/central/agent-data.ts +4 -0
  99. package/src/central/artifacts/access.ts +44 -0
  100. package/src/central/artifacts/blob-store.ts +113 -0
  101. package/src/central/artifacts/blob-support.ts +98 -0
  102. package/src/central/artifacts/events.ts +27 -0
  103. package/src/central/artifacts/host-caps.ts +162 -0
  104. package/src/central/artifacts/host.ts +315 -0
  105. package/src/central/artifacts/idempotency.ts +109 -0
  106. package/src/central/artifacts/jobs-routes.ts +773 -0
  107. package/src/central/artifacts/jobs.ts +344 -0
  108. package/src/central/artifacts/maintenance.ts +103 -0
  109. package/src/central/artifacts/routes.ts +360 -0
  110. package/src/central/artifacts/store.ts +776 -0
  111. package/src/central/artifacts/workspaces.ts +31 -0
  112. package/src/central/backend/file-blobs.ts +377 -0
  113. package/src/central/backend/index.ts +12 -3
  114. package/src/central/backend/memory-blobs.ts +116 -0
  115. package/src/central/backend/postgres-blobs.ts +513 -0
  116. package/src/central/backend/postgres-migrate.ts +33 -0
  117. package/src/central/backend/postgres-state.ts +19 -0
  118. package/src/central/backend/types.ts +8 -0
  119. package/src/central/concierge/activity.ts +142 -0
  120. package/src/central/concierge/arm.ts +63 -0
  121. package/src/central/concierge/board-render.ts +190 -0
  122. package/src/central/concierge/board-results.ts +169 -0
  123. package/src/central/concierge/board-routes.ts +339 -0
  124. package/src/central/concierge/board.ts +249 -0
  125. package/src/central/concierge/context.ts +221 -0
  126. package/src/central/concierge/entitlements.ts +13 -0
  127. package/src/central/concierge/guard.ts +120 -0
  128. package/src/central/concierge/inbound.ts +89 -0
  129. package/src/central/concierge/index.ts +84 -0
  130. package/src/central/concierge/notes.ts +41 -0
  131. package/src/central/concierge/notifier.ts +986 -0
  132. package/src/central/concierge/prompt.ts +130 -0
  133. package/src/central/concierge/proposals.ts +262 -0
  134. package/src/central/concierge/push-remap.ts +49 -0
  135. package/src/central/concierge/routes.ts +62 -0
  136. package/src/central/concierge/schedule.ts +113 -0
  137. package/src/central/concierge/service.ts +243 -0
  138. package/src/central/concierge/session-routes.ts +69 -0
  139. package/src/central/concierge/settings.ts +217 -0
  140. package/src/central/concierge/supervisor.ts +740 -0
  141. package/src/central/concierge/tiers.ts +260 -0
  142. package/src/central/concierge/watchlist-routes.ts +277 -0
  143. package/src/central/concierge/watchlist.ts +273 -0
  144. package/src/central/config.ts +7 -1
  145. package/src/central/http.ts +633 -50
  146. package/src/central/mcp-http.ts +10 -21
  147. package/src/central/profiles/apply.ts +333 -0
  148. package/src/central/profiles/content.ts +174 -0
  149. package/src/central/profiles/events.ts +40 -0
  150. package/src/central/profiles/idempotency.ts +14 -0
  151. package/src/central/profiles/import.ts +139 -0
  152. package/src/central/profiles/routes.ts +356 -0
  153. package/src/central/profiles/store.ts +597 -0
  154. package/src/central/profiles/usage-ports.ts +120 -0
  155. package/src/central/profiles/usage.ts +364 -0
  156. package/src/central/push.ts +7 -0
  157. package/src/central/registry.ts +43 -6
  158. package/src/central/store.ts +4 -0
  159. package/src/central/teams/collections.ts +34 -0
  160. package/src/central/teams/discovery.ts +346 -0
  161. package/src/central/teams/draft-edits.ts +46 -0
  162. package/src/central/teams/events.ts +46 -0
  163. package/src/central/teams/intake-routes.ts +100 -0
  164. package/src/central/teams/intake.ts +772 -0
  165. package/src/central/teams/policy.ts +231 -0
  166. package/src/central/teams/routes.ts +516 -0
  167. package/src/central/teams/seats.ts +272 -0
  168. package/src/central/teams/staffing.ts +888 -0
  169. package/src/central/teams/store.ts +667 -0
  170. package/src/central/teams/validation.ts +256 -0
  171. package/src/central/workspaces/board.ts +408 -0
  172. package/src/central/workspaces/checkpoints.ts +316 -0
  173. package/src/central/workspaces/coordinator.ts +841 -0
  174. package/src/central/workspaces/events.ts +85 -0
  175. package/src/central/workspaces/lifecycle.ts +93 -0
  176. package/src/central/workspaces/master.ts +490 -0
  177. package/src/central/workspaces/members.ts +632 -0
  178. package/src/central/workspaces/routes.ts +354 -0
  179. package/src/central/workspaces/sidechats-routes.ts +410 -0
  180. package/src/central/workspaces/sidechats.ts +482 -0
  181. package/src/central/workspaces/store.ts +571 -0
  182. package/src/central/workspaces/supervision.ts +355 -0
  183. package/src/index.ts +97 -15
  184. package/src/spoke/concierge-agent.ts +33 -0
  185. package/src/spoke/deliver.ts +22 -4
  186. package/src/spoke/fork-revert.ts +142 -0
  187. package/src/spoke/hub-rpc.ts +41 -5
  188. package/src/spoke/hub-tool.ts +383 -18
  189. package/src/spoke/listing.ts +47 -4
  190. package/src/spoke/profile-apply.ts +298 -0
  191. package/src/spoke/session-context.ts +176 -0
  192. package/src/spoke/spoke.ts +319 -20
  193. package/src/spoke/timeline.ts +6 -1
  194. package/src/spoke/tracker.ts +23 -1
  195. package/src/spoke/who-teams.ts +81 -0
  196. package/src/tui-messaging.ts +32 -18
  197. package/web/dist/assets/ArtifactPage-BS0gVkfu.js +1 -0
  198. package/web/dist/assets/ArtifactPane-PLYHNs6s.js +1 -0
  199. package/web/dist/assets/ArtifactWorkbench-9eTj1CeW.css +1 -0
  200. package/web/dist/assets/ArtifactWorkbench-LkDyJ5Dv.js +6 -0
  201. package/web/dist/assets/Library-CtxRqTjo.js +2 -0
  202. package/web/dist/assets/Library-DYsFj_5c.css +1 -0
  203. package/web/dist/assets/ProfilesDestination-BYiHKLmc.js +1 -0
  204. package/web/dist/assets/TeamsPage-BwjsAAUC.js +1 -0
  205. package/web/dist/assets/Watchtower-C4swrWVs.js +2 -0
  206. package/web/dist/assets/Watchtower-CTgqHk5g.css +1 -0
  207. package/web/dist/assets/index-DaH5GUdV.js +31 -0
  208. package/web/dist/assets/index-WWuKfWxL.css +1 -0
  209. package/web/dist/assets/live-BUofsQpz.css +1 -0
  210. package/web/dist/assets/live-YyTjE47g.js +5 -0
  211. package/web/dist/assets/pane-layout-CKC4I00k.js +1 -0
  212. package/web/dist/assets/plural-Bspvoqfq.js +1 -0
  213. package/web/dist/assets/renderer2d-CX5j3NcD.js +1 -0
  214. package/web/dist/assets/renderer3d-BXIVOvfi.js +5393 -0
  215. package/web/dist/assets/safe-area-DfPsOdWR.js +1 -0
  216. package/web/dist/assets/source-CvUHTc3O.js +1 -0
  217. package/web/dist/assets/ui-C9Igxzjm.js +1 -0
  218. package/web/dist/index.html +5 -2
  219. package/web/dist/sw.js +22 -2
  220. package/web/dist/assets/index-Dm8OmkaO.css +0 -1
  221. package/web/dist/assets/index-FUJbj_Du.js +0 -20
@@ -0,0 +1,740 @@
1
+ // CON-04 Concierge supervisor (contract "Supervise / relaunch", "State record", "Disable"): one hub-launched,
2
+ // hub-owned OpenCode session per user, on the machine the user picked in Settings → Concierge.
3
+ //
4
+ // State record: collection `concierge`, key `state`, per user: the public ConciergeStatus plus launchSeq (the launch
5
+ // key counter), pendingSeq (a launch being accepted), attempts (relaunch times, last hour), backoffIndex,
6
+ // runningSince, fresh (the next launch follows an enable or a Restart, not a failure) and lastLaunchID. Every write
7
+ // is a compare-and-set on the record's version (StatusStore.cas): a deposed leader, a second pod or a racing route
8
+ // handler loses the race instead of launching twice, and the launch key (concierge:<user hash>:<seq>) is written
9
+ // BEFORE LaunchEngine.accept, so a retried accept of the same seq is idempotent.
10
+ //
11
+ // One pass (reconcile, serialized per user in this process; never rejects):
12
+ // settings disabled → finish a disable whose record is not disabled yet (a crash between the settings and record
13
+ // writes, or one left stopping; failed waits for the user), release stray @@concierge holders, stop live launches
14
+ // failed → nothing (Restart, or disable + enable)
15
+ // a launch recorded → follow it: running → claim @@concierge (managed, exclusive; other holders released first),
16
+ // hide the session, push its context; ended for any reason → backoff (CONCIERGE_LIMITS.relaunchBackoffMs, reset
17
+ // after healthyAfterMs running) or failed past relaunchesPerHour; queued/provisioning → keep waiting
18
+ // a launch being accepted → accept the same key again (idempotent)
19
+ // backoff not due → nothing
20
+ // otherwise → entitlements.check, the machine's eligibility (ineligible → waiting_machine with its reason), then
21
+ // a new launch: in process, `by: "app:concierge"`, no prompt, no name, no roles (the user's standing setting is
22
+ // the authority; audited as concierge.launch / concierge.relaunch)
23
+ // Live Concierge launches (label hub/system=concierge) other than the recorded one are stopped (orphans of a lost
24
+ // race or a restart), and every live one while the Concierge is disabled. Each relaunch is a new session; the old
25
+ // ID stays in hiddenSessionIDs (at most 20, oldest dropped). (CON-09a) A host session outlives its stopped launch on
26
+ // the machine's OpenCode, so every previous session (Restart, a relaunch, and each other hidden one when a new one is
27
+ // adopted) is retired: its context replaced by a locked, powerless one (no board, notes or tools).
28
+ import { createHash } from "node:crypto"
29
+ import { CONCIERGE_AGENT, CONCIERGE_LIMITS, CONCIERGE_PARTY, CONCIERGE_ROLE, type ConciergeSettings, type ConciergeStatus } from "../../../packages/protocol/src/concierge"
30
+ import type { Clock, StateStore, Timers } from "../backend/types"
31
+ import type { Directory } from "../agent/directory"
32
+ import { AgentError } from "../agent/errors"
33
+ import type { Grants } from "../agent/grants"
34
+ import type { LaunchEngine } from "../agent/launch-engine"
35
+ import type { Launch } from "../agent/launch-store"
36
+ import type { ConciergeEntitlements } from "./entitlements"
37
+ import { CONCIERGE_COLLECTION } from "./notes"
38
+ import type { SettingsStore } from "./settings"
39
+
40
+ const KEY = "state"
41
+ const HOUR_MS = 3_600_000
42
+ export const SUPERVISOR_TICK_MS = 5_000
43
+ /** Why a Concierge found on another agent is suspended (Settings shows it). */
44
+ export const CONCIERGE_TAMPERED = "The Concierge's agent was changed outside the hub"
45
+ /** The label every Concierge launch carries (never interpreted by the hub elsewhere). */
46
+ export const CONCIERGE_LABEL = { "hub/system": "concierge" } as const
47
+
48
+ export interface ConciergeRecord extends ConciergeStatus {
49
+ readonly launchSeq: number
50
+ readonly pendingSeq?: number
51
+ readonly attempts: readonly number[]
52
+ readonly backoffIndex: number
53
+ readonly runningSince?: number
54
+ readonly fresh?: boolean
55
+ readonly lastLaunchID?: string
56
+ readonly settingsRevision?: number
57
+ /**
58
+ * Every Concierge session this user ever had (at most PAST_SESSIONS_MAX, oldest dropped), beyond the 20 the status
59
+ * shows as hidden: a past Concierge session never becomes an ordinary session (identity, guards, gate).
60
+ */
61
+ readonly pastSessionIDs?: readonly string[]
62
+ }
63
+
64
+ export const PAST_SESSIONS_MAX = 500
65
+
66
+ const DEFAULT_RECORD: ConciergeRecord = { state: "disabled", hiddenSessionIDs: [], generation: 0, since: 0, launchSeq: 0, attempts: [], backoffIndex: 0 }
67
+
68
+ /** The public status of a record (no internals, no undefined fields). */
69
+ export function statusOf(r: ConciergeRecord): ConciergeStatus {
70
+ return {
71
+ state: r.state,
72
+ ...(r.reason !== undefined ? { reason: r.reason.slice(0, 200) } : {}),
73
+ ...(r.sessionID !== undefined ? { sessionID: r.sessionID } : {}),
74
+ hiddenSessionIDs: [...r.hiddenSessionIDs],
75
+ ...(r.launchID !== undefined ? { launchID: r.launchID } : {}),
76
+ ...(r.machineID !== undefined ? { machineID: r.machineID } : {}),
77
+ generation: r.generation,
78
+ since: r.since,
79
+ ...(r.nextAttemptAt !== undefined ? { nextAttemptAt: r.nextAttemptAt } : {}),
80
+ }
81
+ }
82
+
83
+ /** Drops undefined fields (records are stored as JSON). */
84
+ const clean = <T extends object>(value: T): T => Object.fromEntries(Object.entries(value).filter(([, v]) => v !== undefined)) as T
85
+
86
+ export interface RecordSnapshot {
87
+ readonly record: ConciergeRecord
88
+ readonly version: number
89
+ }
90
+
91
+ /** The per-user state record, written only by compare-and-set. */
92
+ export class StatusStore {
93
+ constructor(
94
+ private readonly state: StateStore,
95
+ private readonly now: Clock = Date.now,
96
+ ) {}
97
+
98
+ async read(userID: string): Promise<RecordSnapshot> {
99
+ const stored = await this.state.get<ConciergeRecord>(userID, CONCIERGE_COLLECTION, KEY)
100
+ return { record: stored?.value ?? DEFAULT_RECORD, version: stored?.version ?? 0 }
101
+ }
102
+
103
+ async get(userID: string): Promise<ConciergeRecord> {
104
+ return (await this.read(userID)).record
105
+ }
106
+
107
+ /** Writes `next` when the record is still at `snap`'s version; the past sessions only ever grow (bounded). */
108
+ async cas(userID: string, snap: RecordSnapshot, next: ConciergeRecord): Promise<boolean> {
109
+ const past = [...new Set([...(snap.record.pastSessionIDs ?? []), ...snap.record.hiddenSessionIDs, ...next.hiddenSessionIDs, ...(next.pastSessionIDs ?? []), ...(next.sessionID !== undefined ? [next.sessionID] : [])])].slice(-PAST_SESSIONS_MAX)
110
+ return (await this.state.put(userID, CONCIERGE_COLLECTION, KEY, clean({ ...next, pastSessionIDs: past }), { ifVersion: snap.version })).ok
111
+ }
112
+
113
+ /** Read-modify-write, retried on a lost race. */
114
+ async update(userID: string, change: (current: ConciergeRecord) => ConciergeRecord): Promise<ConciergeRecord> {
115
+ for (let attempt = 0; attempt < 16; attempt++) {
116
+ const snap = await this.read(userID)
117
+ const next = clean(change(snap.record))
118
+ if (await this.cas(userID, snap, next)) return next
119
+ }
120
+ throw new AgentError("conflict", "The Concierge state kept changing; retry")
121
+ }
122
+
123
+ /** The clock (records' `since`). */
124
+ time(): number {
125
+ return this.now()
126
+ }
127
+ }
128
+
129
+ /** The Concierge-only authority read from the state record: the current session and every hidden one. */
130
+ export function recordIdentity(store: StatusStore): { current(userID: string): Promise<{ sessionID?: string; hiddenSessionIDs: readonly string[] }> } {
131
+ return {
132
+ current: async (userID) => {
133
+ const r = await store.get(userID)
134
+ const all = [...new Set([...(r.pastSessionIDs ?? []), ...r.hiddenSessionIDs])]
135
+ return { ...(r.sessionID !== undefined ? { sessionID: r.sessionID } : {}), hiddenSessionIDs: all }
136
+ },
137
+ }
138
+ }
139
+
140
+ /** A Concierge launch key: one per launch attempt of the user. */
141
+ export function conciergeLaunchKey(userID: string, seq: number): string {
142
+ return `concierge:${createHash("sha256").update(userID).digest("hex").slice(0, 16)}:${seq}`
143
+ }
144
+
145
+ /** The launch spec of attempt `seq` (contract "Launch"). */
146
+ export function conciergeLaunchSpec(userID: string, settings: ConciergeSettings, seq: number): Record<string, unknown> {
147
+ return {
148
+ key: conciergeLaunchKey(userID, seq),
149
+ roles: [],
150
+ harness: "opencode",
151
+ agent: CONCIERGE_AGENT,
152
+ ...(settings.model !== undefined ? { model: settings.model } : {}),
153
+ workspace: { repo: settings.directory, mode: "checkout" },
154
+ runtime: { kind: "host" },
155
+ placement: { machine: settings.machineID, mode: "require" },
156
+ labels: { ...CONCIERGE_LABEL },
157
+ end: { when: "never", cleanup: "keep" },
158
+ }
159
+ }
160
+
161
+ /** Whether a launch is a Concierge launch (its label). */
162
+ export const isConciergeLaunch = (launch: Pick<Launch, "spec">): boolean => launch.spec.labels?.["hub/system"] === "concierge"
163
+
164
+ export const addHidden = (hidden: readonly string[], sessionID: string): string[] =>
165
+ hidden.includes(sessionID) ? [...hidden] : [...hidden, sessionID].slice(-CONCIERGE_LIMITS.hiddenHistory)
166
+
167
+ export interface Eligibility {
168
+ readonly eligible: boolean
169
+ readonly online: boolean
170
+ readonly reason?: string
171
+ }
172
+
173
+ export interface SupervisorDeps {
174
+ readonly state: StateStore
175
+ readonly settings: SettingsStore
176
+ readonly launches: Pick<LaunchEngine, "accept" | "view" | "stop"> & Partial<Pick<LaunchEngine, "list">>
177
+ /** (CON-09a) `unname`: Concierge sessions hold no @name (none derived from their titles). */
178
+ readonly directory: Pick<Directory, "resolve" | "claimRole" | "releaseRole"> & Partial<Pick<Directory, "unname">>
179
+ readonly grants: Pick<Grants, "revoke">
180
+ /** Whether the user's machine can host the Concierge now (a node, a launch- and sessionContext-capable spoke). */
181
+ readonly eligibility: (userID: string, machineID: string) => Eligibility
182
+ readonly entitlements: ConciergeEntitlements
183
+ /** The session context (context.ts): pushed once running, cleared on disable. Never rejects in production. */
184
+ readonly context: {
185
+ push(userID: string, sessionID: string): Promise<void>
186
+ clear(userID: string, sessionID: string): Promise<void>
187
+ /**
188
+ * (CON-09a) A previous Concierge session: locked (the concierge agent kept) and powerless (no board, notes or
189
+ * tools), so a session left running on its machine can do nothing. Never rejects in production.
190
+ */
191
+ retire?(userID: string, sessionID: string): Promise<void>
192
+ }
193
+ /** Every change of the public status (console SSE, journal). */
194
+ readonly onStatus?: (userID: string, status: ConciergeStatus) => void
195
+ /** A session became the Concierge: detach it from workspaces and teams (it is hub-owned). Never rejects in production. */
196
+ readonly onAdopted?: (userID: string, sessionID: string) => Promise<void>
197
+ /** The Concierge was suspended (an alert for the user). */
198
+ readonly onSuspended?: (userID: string, sessionID: string) => void
199
+ /** The admin audit: concierge.launch | relaunch | restart | disable | suspended. */
200
+ readonly audit?: (userID: string, action: string) => Promise<void>
201
+ readonly users: () => Promise<Iterable<string>>
202
+ readonly now?: Clock
203
+ readonly timers?: Timers
204
+ readonly tickMs?: number
205
+ }
206
+
207
+ const defaultTimers: Timers = {
208
+ set: (fn, ms) => {
209
+ const handle = setTimeout(fn, ms)
210
+ ;(handle as { unref?: () => void }).unref?.()
211
+ return handle
212
+ },
213
+ clear: (handle) => clearTimeout(handle as ReturnType<typeof setTimeout>),
214
+ }
215
+
216
+ const errorText = (cause: unknown) => (cause instanceof Error ? cause.message : String(cause)).slice(0, 160)
217
+ const ignorable = (cause: unknown) => cause instanceof AgentError && (cause.code === "gone" || cause.code === "not_found")
218
+
219
+ export class Supervisor {
220
+ readonly store: StatusStore
221
+ private readonly now: Clock
222
+ private readonly timers: Timers
223
+ private readonly chains = new Map<string, Promise<void>>()
224
+ private running = false
225
+ private timer: unknown
226
+
227
+ constructor(private readonly deps: SupervisorDeps) {
228
+ this.now = deps.now ?? Date.now
229
+ this.timers = deps.timers ?? defaultTimers
230
+ this.store = new StatusStore(deps.state, this.now)
231
+ }
232
+
233
+ // ---- identity and status ----
234
+
235
+ /** The user's current Concierge session (state record) and every hidden one: the Concierge-only authority. */
236
+ current(userID: string): Promise<{ sessionID?: string; hiddenSessionIDs: readonly string[] }> {
237
+ return recordIdentity(this.store).current(userID)
238
+ }
239
+
240
+ async status(userID: string): Promise<ConciergeStatus> {
241
+ return statusOf(await this.store.get(userID))
242
+ }
243
+
244
+ // ---- leader loop ----
245
+
246
+ /** Leader elected: a pass over every user now, then every tickMs. */
247
+ start(): void {
248
+ if (this.running) return
249
+ this.running = true
250
+ void this.tick()
251
+ }
252
+
253
+ /** Deposed (or shutting down): no more passes from this process. */
254
+ stop(): void {
255
+ this.running = false
256
+ if (this.timer !== undefined) this.timers.clear(this.timer)
257
+ this.timer = undefined
258
+ }
259
+
260
+ get leading(): boolean {
261
+ return this.running
262
+ }
263
+
264
+ /** Something about the user changed (a launch, a node, a spoke): a pass now when this process supervises. */
265
+ kick(userID: string): void {
266
+ if (this.running) void this.reconcile(userID)
267
+ }
268
+
269
+ private async tick(): Promise<void> {
270
+ try {
271
+ for (const userID of await this.deps.users()) {
272
+ if (!this.running) break
273
+ await this.reconcile(userID)
274
+ }
275
+ } catch (cause) {
276
+ console.error(`[hub] concierge supervisor pass failed: ${errorText(cause)}`)
277
+ } finally {
278
+ if (this.running) this.timer = this.timers.set(() => void (this.running ? this.tick() : undefined), this.deps.tickMs ?? SUPERVISOR_TICK_MS)
279
+ }
280
+ }
281
+
282
+ /** One pass for the user (serialized per user in this process). Never rejects. */
283
+ reconcile(userID: string): Promise<void> {
284
+ const previous = this.chains.get(userID) ?? Promise.resolve()
285
+ const run = previous.then(() => this.pass(userID)).catch((cause) => console.error(`[hub] concierge pass failed: ${errorText(cause)}`))
286
+ this.chains.set(userID, run)
287
+ void run.finally(() => {
288
+ if (this.chains.get(userID) === run) this.chains.delete(userID)
289
+ })
290
+ return run
291
+ }
292
+
293
+ // ---- writes ----
294
+
295
+ private async write(userID: string, snap: RecordSnapshot, next: ConciergeRecord): Promise<boolean> {
296
+ const ok = await this.store.cas(userID, snap, next)
297
+ if (ok) this.changed(userID, snap.record, next)
298
+ return ok
299
+ }
300
+
301
+ private changed(userID: string, before: ConciergeRecord, after: ConciergeRecord): void {
302
+ const status = statusOf(after)
303
+ if (JSON.stringify(statusOf(before)) === JSON.stringify(status)) return
304
+ try {
305
+ this.deps.onStatus?.(userID, status)
306
+ } catch (cause) {
307
+ console.error(`[hub] concierge status listener failed: ${errorText(cause)}`)
308
+ }
309
+ }
310
+
311
+ private async audit(userID: string, action: string): Promise<void> {
312
+ await this.deps.audit?.(userID, action).catch((cause) => console.error(`[hub] concierge audit failed: ${errorText(cause)}`))
313
+ }
314
+
315
+ /** A change through update() with the status listener. */
316
+ private async mutate(userID: string, change: (r: ConciergeRecord) => ConciergeRecord): Promise<ConciergeRecord> {
317
+ let before = DEFAULT_RECORD
318
+ const after = await this.store.update(userID, (r) => ((before = r), change(r)))
319
+ this.changed(userID, before, after)
320
+ return after
321
+ }
322
+
323
+ // ---- the pass ----
324
+
325
+ private async pass(userID: string): Promise<void> {
326
+ const settings = await this.deps.settings.get(userID)
327
+ const snap = await this.store.read(userID)
328
+ const r = snap.record
329
+ if (!settings.enabled) {
330
+ // Disabled settings with a record that never got there (a crash between the two writes, or a disable left
331
+ // stopping): finish the disable. failed stays failed (the user retries).
332
+ if (r.state !== "disabled" && r.state !== "failed") await this.disable(userID)
333
+ else await this.releaseHolders(userID)
334
+ // A launch accepted while disabling (a racing or deposed pass) is stopped.
335
+ await this.reap(userID)
336
+ return
337
+ }
338
+ if (r.state === "failed") return
339
+ if (r.state === "disabled" || r.state === "stopping") {
340
+ // Enabled, but the record never left disabled (a crash between the two writes): start fresh.
341
+ await this.write(userID, snap, { ...r, state: "starting", reason: "Starting", since: this.now(), fresh: true })
342
+ return this.pass(userID)
343
+ }
344
+ if (r.launchID !== undefined) await this.follow(userID, settings, snap)
345
+ else if (r.pendingSeq !== undefined) await this.accept(userID, settings, r.pendingSeq)
346
+ else if (!(r.state === "backoff" && (r.nextAttemptAt ?? 0) > this.now())) await this.begin(userID, settings, snap)
347
+ await this.reap(userID)
348
+ }
349
+
350
+ /** A new launch attempt (see the header). */
351
+ private async begin(userID: string, settings: ConciergeSettings, snap: RecordSnapshot): Promise<void> {
352
+ const r = snap.record
353
+ const t = this.now()
354
+ const since = (state: string) => (r.state === state ? r.since : t)
355
+ const entitled = await this.deps.entitlements.check(userID)
356
+ if (!entitled.ok) {
357
+ await this.write(userID, snap, { ...r, state: "failed", reason: entitled.reason, since: t, nextAttemptAt: undefined })
358
+ return
359
+ }
360
+ if (settings.machineID === undefined || settings.directory === undefined) {
361
+ await this.write(userID, snap, { ...r, state: "failed", reason: "Pick a machine and a directory in Settings → Concierge", since: t, nextAttemptAt: undefined })
362
+ return
363
+ }
364
+ const machine = this.deps.eligibility(userID, settings.machineID)
365
+ if (!machine.eligible) {
366
+ const reason = machine.reason ?? "Waiting for the machine"
367
+ if (r.state !== "waiting_machine" || r.reason !== reason || r.machineID !== settings.machineID) {
368
+ await this.write(userID, snap, { ...r, state: "waiting_machine", reason, machineID: settings.machineID, since: since("waiting_machine"), nextAttemptAt: undefined })
369
+ }
370
+ return
371
+ }
372
+ const relaunch = r.fresh !== true
373
+ const recent = r.attempts.filter((at) => t - at < HOUR_MS)
374
+ if (relaunch && recent.length >= CONCIERGE_LIMITS.relaunchesPerHour) return this.fail(userID, snap, recent.length)
375
+ const seq = r.launchSeq + 1
376
+ const next: ConciergeRecord = {
377
+ ...r,
378
+ state: "starting",
379
+ reason: "Starting",
380
+ since: since("starting"),
381
+ machineID: settings.machineID,
382
+ launchSeq: seq,
383
+ pendingSeq: seq,
384
+ attempts: relaunch ? [...recent, t] : recent,
385
+ fresh: undefined,
386
+ nextAttemptAt: undefined,
387
+ settingsRevision: settings.revision,
388
+ }
389
+ if (!(await this.write(userID, snap, next))) return
390
+ await this.audit(userID, relaunch ? "concierge.relaunch" : "concierge.launch")
391
+ await this.accept(userID, settings, seq)
392
+ }
393
+
394
+ /** Accepts launch attempt `seq` (idempotent per key) and records its ID. */
395
+ private async accept(userID: string, settings: ConciergeSettings, seq: number): Promise<void> {
396
+ let launch: Launch
397
+ try {
398
+ const accepted = await this.deps.launches.accept(userID, conciergeLaunchSpec(userID, settings, seq), { by: CONCIERGE_PARTY })
399
+ launch = accepted.launch
400
+ if (accepted.created) this.deps.entitlements.record(userID, { kind: "launch", n: 1 })
401
+ } catch (cause) {
402
+ const snap = await this.store.read(userID)
403
+ if (snap.record.pendingSeq !== seq) return
404
+ await this.write(userID, snap, this.backoff(snap.record, `The launch was refused: ${errorText(cause)}`))
405
+ return
406
+ }
407
+ const snap = await this.store.read(userID)
408
+ if (snap.record.pendingSeq !== seq || snap.record.launchID !== undefined) return
409
+ if (!(await this.write(userID, snap, { ...snap.record, launchID: launch.id, pendingSeq: undefined }))) return
410
+ await this.follow(userID, settings, await this.store.read(userID))
411
+ }
412
+
413
+ /** The recorded launch: running → adopt it; ended → back off; else wait. */
414
+ private async follow(userID: string, settings: ConciergeSettings, snap: RecordSnapshot): Promise<void> {
415
+ const r = snap.record
416
+ const launch = await this.deps.launches.view(userID, r.launchID!)
417
+ if (!launch || launch.state === "ended") return this.ended(userID, snap, launch)
418
+ if (launch.state === "running" && launch.sessionID !== undefined) return this.adopt(userID, snap, launch.sessionID, launch.machineID)
419
+ if (launch.sessionID !== undefined && !r.hiddenSessionIDs.includes(launch.sessionID)) {
420
+ // Hidden from the console's lists as soon as the session exists, and never auto-named from its title.
421
+ if (await this.write(userID, snap, { ...r, hiddenSessionIDs: addHidden(r.hiddenSessionIDs, launch.sessionID) })) await this.unname(userID, [launch.sessionID])
422
+ }
423
+ void settings
424
+ }
425
+
426
+ private async holds(userID: string, sessionID: string): Promise<boolean> {
427
+ try {
428
+ return this.deps.directory.resolve(userID, `@@${CONCIERGE_ROLE}`).sessions.some((s) => s.sessionID === sessionID)
429
+ } catch {
430
+ return false
431
+ }
432
+ }
433
+
434
+ /** Releases @@concierge from every holder but `keep` (idempotent). */
435
+ private async releaseHolders(userID: string, keep?: string): Promise<void> {
436
+ let holders: string[]
437
+ try {
438
+ holders = this.deps.directory.resolve(userID, `@@${CONCIERGE_ROLE}`).sessions.map((s) => s.sessionID)
439
+ } catch {
440
+ return
441
+ }
442
+ for (const id of holders) {
443
+ if (id === keep) continue
444
+ await this.deps.directory.releaseRole(userID, id, CONCIERGE_ROLE, undefined, { managed: true }).catch((cause) => {
445
+ if (!ignorable(cause)) throw cause
446
+ })
447
+ }
448
+ }
449
+
450
+ /** Drops (and stops deriving) the @names of Concierge sessions. */
451
+ private async unname(userID: string, sessionIDs: readonly string[]): Promise<void> {
452
+ await this.deps.directory.unname?.(userID, sessionIDs).catch((cause) => console.error(`[hub] concierge unname failed: ${errorText(cause)}`))
453
+ }
454
+
455
+ /** Retires previous Concierge sessions (see SupervisorDeps.context.retire). */
456
+ private async retire(userID: string, sessionIDs: readonly (string | undefined)[]): Promise<void> {
457
+ for (const id of new Set(sessionIDs)) {
458
+ if (id === undefined) continue
459
+ await this.deps.context.retire?.(userID, id).catch((cause) => console.error(`[hub] concierge retire failed: ${errorText(cause)}`))
460
+ }
461
+ }
462
+
463
+ private async release(userID: string, sessionID: string | undefined): Promise<void> {
464
+ if (sessionID === undefined) return
465
+ await this.deps.directory.releaseRole(userID, sessionID, CONCIERGE_ROLE, undefined, { managed: true }).catch((cause) => {
466
+ if (!ignorable(cause)) console.error(`[hub] concierge role release failed: ${errorText(cause)}`)
467
+ })
468
+ }
469
+
470
+ /** The launch runs: the session becomes the current Concierge (role, hidden, context). */
471
+ private async adopt(userID: string, snap: RecordSnapshot, sessionID: string, machineID: string | undefined): Promise<void> {
472
+ const r = snap.record
473
+ if (r.state === "running" && r.sessionID === sessionID) {
474
+ if (await this.holds(userID, sessionID)) return
475
+ // The role dropped (offline past ROLE_OFFLINE_MS): claim it again (a new generation).
476
+ }
477
+ let generation: number
478
+ try {
479
+ await this.releaseHolders(userID, sessionID)
480
+ generation = (await this.deps.directory.claimRole(userID, sessionID, CONCIERGE_ROLE, { byUser: true, exclusive: true, managed: true })).generation
481
+ } catch (cause) {
482
+ const reason = `Waiting for the session: ${errorText(cause)}`
483
+ if (r.reason !== reason) await this.write(userID, snap, { ...r, reason })
484
+ return
485
+ }
486
+ const t = this.now()
487
+ const same = r.state === "running" && r.sessionID === sessionID
488
+ const next: ConciergeRecord = {
489
+ ...r,
490
+ state: "running",
491
+ reason: undefined,
492
+ sessionID,
493
+ ...(machineID !== undefined ? { machineID } : {}),
494
+ generation,
495
+ since: same ? r.since : t,
496
+ runningSince: same ? r.runningSince : t,
497
+ hiddenSessionIDs: addHidden(r.hiddenSessionIDs, sessionID),
498
+ }
499
+ if (!(await this.write(userID, snap, next))) return
500
+ if (same) return
501
+ // The current and every past Concierge session: no @name (an auto name derived from a title, e.g. @greeting).
502
+ await this.unname(userID, [...new Set([...(r.pastSessionIDs ?? []), ...next.hiddenSessionIDs])])
503
+ await this.deps.onAdopted?.(userID, sessionID).catch((cause) => console.error(`[hub] concierge detach failed: ${errorText(cause)}`))
504
+ await this.deps.context.push(userID, sessionID).catch((cause) => console.error(`[hub] concierge context push failed: ${errorText(cause)}`))
505
+ // Every earlier Concierge session a spoke may still run (a lost race, a restart, a relaunch) is retired.
506
+ await this.retire(userID, next.hiddenSessionIDs.filter((id) => id !== sessionID))
507
+ }
508
+
509
+ /** The backoff record after a failed or ended launch. */
510
+ private backoff(r: ConciergeRecord, reason: string): ConciergeRecord {
511
+ const t = this.now()
512
+ const healthy = r.runningSince !== undefined && t - r.runningSince >= CONCIERGE_LIMITS.healthyAfterMs
513
+ const index = healthy ? 0 : r.backoffIndex
514
+ const delays = CONCIERGE_LIMITS.relaunchBackoffMs
515
+ const delay = delays[Math.min(index, delays.length - 1)]!
516
+ return {
517
+ ...r,
518
+ state: "backoff",
519
+ reason,
520
+ since: t,
521
+ launchID: undefined,
522
+ pendingSeq: undefined,
523
+ lastLaunchID: r.launchID ?? r.lastLaunchID,
524
+ sessionID: undefined,
525
+ runningSince: undefined,
526
+ nextAttemptAt: t + delay,
527
+ backoffIndex: index + 1,
528
+ }
529
+ }
530
+
531
+ private async fail(userID: string, snap: RecordSnapshot, relaunches: number): Promise<void> {
532
+ const r = snap.record
533
+ await this.write(userID, snap, {
534
+ ...r,
535
+ state: "failed",
536
+ reason: `Relaunched ${relaunches} times in the last hour: Restart it in Settings → Concierge`,
537
+ since: this.now(),
538
+ launchID: undefined,
539
+ pendingSeq: undefined,
540
+ lastLaunchID: r.launchID ?? r.lastLaunchID,
541
+ sessionID: undefined,
542
+ runningSince: undefined,
543
+ nextAttemptAt: undefined,
544
+ })
545
+ }
546
+
547
+ /** The launch ended for any reason but a service stop (those clear launchID first): relaunch with backoff. */
548
+ private async ended(userID: string, snap: RecordSnapshot, launch: Launch | undefined): Promise<void> {
549
+ const r = snap.record
550
+ const t = this.now()
551
+ const recent = r.attempts.filter((at) => t - at < HOUR_MS)
552
+ const ok = recent.length >= CONCIERGE_LIMITS.relaunchesPerHour ? (await this.fail(userID, snap, recent.length), true) : await this.write(userID, snap, this.backoff(r, `The Concierge session ended (${launch?.outcome ?? "gone"}); relaunching`))
553
+ if (ok) {
554
+ await this.release(userID, r.sessionID)
555
+ await this.retire(userID, [r.sessionID])
556
+ }
557
+ }
558
+
559
+ /** Stops live Concierge launches other than the recorded one (orphans). */
560
+ private async reap(userID: string): Promise<void> {
561
+ if (!this.deps.launches.list) return
562
+ const r = await this.store.get(userID)
563
+ const live = await this.deps.launches.list(userID, { states: ["queued", "placing", "provisioning", "running"] })
564
+ const disabled = r.state === "disabled" || r.state === "failed"
565
+ for (const launch of live) {
566
+ if (!isConciergeLaunch(launch)) continue
567
+ if (!disabled && launch.id === r.launchID) continue
568
+ if (!disabled && r.pendingSeq !== undefined && launch.spec.key === conciergeLaunchKey(userID, r.pendingSeq)) continue
569
+ await this.deps.launches.stop(userID, launch.id, "Not the current Concierge").catch((cause) => {
570
+ if (!ignorable(cause)) console.error(`[hub] concierge orphan stop failed: ${errorText(cause)}`)
571
+ })
572
+ }
573
+ }
574
+
575
+ // ---- user operations (the service calls them) ----
576
+
577
+ /**
578
+ * Disable, after the settings say disabled: stopping, then revoke the grant, stop the launch(es), release
579
+ * @@concierge, clear the context; then disabled. Each step is idempotent; a failed one leaves failed with a
580
+ * reason (retry by disabling again). Notes and watchlist are kept.
581
+ */
582
+ async disable(userID: string): Promise<ConciergeStatus> {
583
+ const before = await this.store.get(userID)
584
+ if (before.state === "disabled") {
585
+ await this.releaseHolders(userID).catch(() => undefined)
586
+ return statusOf(before)
587
+ }
588
+ const stopping = await this.mutate(userID, (r) => ({ ...r, state: "stopping", reason: "Stopping", since: this.now(), nextAttemptAt: undefined }))
589
+ const problems: string[] = []
590
+ const step = async (fn: () => Promise<unknown>) => {
591
+ try {
592
+ await fn()
593
+ } catch (cause) {
594
+ if (!ignorable(cause)) problems.push(errorText(cause))
595
+ }
596
+ }
597
+ await step(async () => {
598
+ const settings = await this.deps.settings.get(userID)
599
+ if (settings.grantID === undefined) return
600
+ await this.deps.grants.revoke(userID, settings.grantID).catch((cause) => {
601
+ if (!ignorable(cause)) throw cause
602
+ })
603
+ await this.deps.settings.update(userID, (s) => {
604
+ const { grantID: _gone, ...rest } = s
605
+ return rest
606
+ })
607
+ })
608
+ await step(async () => {
609
+ if (stopping.launchID !== undefined) await this.deps.launches.stop(userID, stopping.launchID, "The Concierge was disabled").catch((cause) => {
610
+ if (!ignorable(cause)) throw cause
611
+ })
612
+ if (!this.deps.launches.list) return
613
+ for (const launch of await this.deps.launches.list(userID, { states: ["queued", "placing", "provisioning", "running"] })) {
614
+ if (!isConciergeLaunch(launch)) continue
615
+ await this.deps.launches.stop(userID, launch.id, "The Concierge was disabled").catch((cause) => {
616
+ if (!ignorable(cause)) throw cause
617
+ })
618
+ }
619
+ })
620
+ await step(() => this.releaseHolders(userID))
621
+ if (stopping.sessionID !== undefined) await this.deps.context.clear(userID, stopping.sessionID).catch(() => undefined)
622
+ const t = this.now()
623
+ const done = await this.mutate(userID, (r) =>
624
+ problems.length
625
+ ? { ...r, state: "failed", reason: `Disable did not finish: ${problems[0]}; disable again`, since: t }
626
+ : {
627
+ ...r,
628
+ state: "disabled",
629
+ reason: undefined,
630
+ since: t,
631
+ sessionID: undefined,
632
+ launchID: undefined,
633
+ pendingSeq: undefined,
634
+ lastLaunchID: r.launchID ?? r.lastLaunchID,
635
+ nextAttemptAt: undefined,
636
+ runningSince: undefined,
637
+ attempts: [],
638
+ backoffIndex: 0,
639
+ fresh: undefined,
640
+ hiddenSessionIDs: r.sessionID !== undefined ? addHidden(r.hiddenSessionIDs, r.sessionID) : r.hiddenSessionIDs,
641
+ },
642
+ )
643
+ if (!problems.length) await this.audit(userID, "concierge.disable")
644
+ return statusOf(done)
645
+ }
646
+
647
+ /** Enable (the settings already say enabled): a fresh start. */
648
+ async enable(userID: string): Promise<ConciergeStatus> {
649
+ const next = await this.mutate(userID, (r) =>
650
+ r.state === "disabled" || r.state === "failed" || r.state === "stopping"
651
+ ? { ...r, state: "starting", reason: "Starting", since: this.now(), fresh: true, attempts: [], backoffIndex: 0, nextAttemptAt: undefined, launchID: undefined, pendingSeq: undefined, sessionID: undefined }
652
+ : r,
653
+ )
654
+ this.kick(userID)
655
+ return statusOf(next)
656
+ }
657
+
658
+ /**
659
+ * Suspends the Concierge at once (the spoke reports its session on another agent, or anything else that means the
660
+ * hub no longer controls it): the grant is revoked, the session loses @@concierge and the Concierge-only authority
661
+ * (it moves to the hidden history), its launch is stopped, and the state is failed with `reason`. Nothing relaunches
662
+ * it: only the user's Restart (or disable + enable). Idempotent for the same session.
663
+ */
664
+ async suspend(userID: string, sessionID: string, reason: string): Promise<ConciergeStatus> {
665
+ const before = await this.store.get(userID)
666
+ if (before.sessionID !== sessionID) return statusOf(before)
667
+ const t = this.now()
668
+ const after = await this.mutate(userID, (r) =>
669
+ r.sessionID !== sessionID
670
+ ? r
671
+ : {
672
+ ...r,
673
+ state: "failed",
674
+ reason,
675
+ since: t,
676
+ sessionID: undefined,
677
+ launchID: undefined,
678
+ pendingSeq: undefined,
679
+ lastLaunchID: r.launchID ?? r.lastLaunchID,
680
+ runningSince: undefined,
681
+ nextAttemptAt: undefined,
682
+ hiddenSessionIDs: addHidden(r.hiddenSessionIDs, sessionID),
683
+ },
684
+ )
685
+ const settings = await this.deps.settings.get(userID)
686
+ if (settings.grantID !== undefined) {
687
+ await this.deps.grants.revoke(userID, settings.grantID).catch((cause) => {
688
+ if (!ignorable(cause)) console.error(`[hub] concierge suspend: grant revoke failed: ${errorText(cause)}`)
689
+ })
690
+ await this.deps.settings.update(userID, (s) => {
691
+ const { grantID: _gone, ...rest } = s
692
+ return rest
693
+ })
694
+ }
695
+ await this.releaseHolders(userID).catch(() => undefined)
696
+ if (before.launchID !== undefined) await this.deps.launches.stop(userID, before.launchID, reason).catch(() => undefined)
697
+ await this.deps.context.clear(userID, sessionID).catch(() => undefined)
698
+ await this.audit(userID, "concierge.suspended")
699
+ try {
700
+ this.deps.onSuspended?.(userID, sessionID)
701
+ } catch (cause) {
702
+ console.error(`[hub] concierge suspend listener failed: ${errorText(cause)}`)
703
+ }
704
+ return statusOf(after)
705
+ }
706
+
707
+ /** Restart: ends the current launch and launches again at once (no backoff, no relaunch count). */
708
+ async restart(userID: string): Promise<ConciergeStatus> {
709
+ let old: ConciergeRecord = DEFAULT_RECORD
710
+ const next = await this.mutate(userID, (r) => {
711
+ old = r
712
+ return {
713
+ ...r,
714
+ state: "starting",
715
+ reason: "Restarting",
716
+ since: this.now(),
717
+ launchID: undefined,
718
+ pendingSeq: undefined,
719
+ lastLaunchID: r.launchID ?? r.lastLaunchID,
720
+ sessionID: undefined,
721
+ runningSince: undefined,
722
+ nextAttemptAt: undefined,
723
+ backoffIndex: 0,
724
+ attempts: [],
725
+ fresh: true,
726
+ hiddenSessionIDs: r.sessionID !== undefined ? addHidden(r.hiddenSessionIDs, r.sessionID) : r.hiddenSessionIDs,
727
+ }
728
+ })
729
+ if (old.launchID !== undefined) {
730
+ await this.deps.launches.stop(userID, old.launchID, "The Concierge restarted").catch((cause) => {
731
+ if (!ignorable(cause)) console.error(`[hub] concierge restart stop failed: ${errorText(cause)}`)
732
+ })
733
+ }
734
+ await this.release(userID, old.sessionID)
735
+ await this.retire(userID, [old.sessionID])
736
+ await this.audit(userID, "concierge.restart")
737
+ this.kick(userID)
738
+ return statusOf(next)
739
+ }
740
+ }