@huynle/hub 0.7.3 → 0.8.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +229 -0
- package/Dockerfile +7 -2
- package/README.md +153 -24
- package/package.json +5 -3
- package/packages/claude-code/package.json +1 -1
- package/packages/claude-code/src/daemon.ts +8 -1
- package/packages/claude-code/src/launcher.ts +29 -9
- package/packages/claude-code/src/profile.ts +173 -0
- package/packages/claude-code/src/sessions.ts +22 -4
- package/packages/cli/dist/hub-cli.js +6474 -3470
- package/packages/cli/package.json +1 -1
- package/packages/cli/src/artifact-jobs-mcp.ts +226 -0
- package/packages/cli/src/artifacts-mcp.ts +160 -0
- package/packages/cli/src/bootstrap/enrollment/enroll.ts +20 -8
- package/packages/cli/src/bootstrap/enrollment/policy.ts +125 -12
- package/packages/cli/src/bootstrap/enrollment/status.ts +3 -2
- package/packages/cli/src/cli.ts +95 -13
- package/packages/cli/src/format.ts +48 -10
- package/packages/cli/src/http.ts +1 -0
- package/packages/cli/src/intents.ts +23 -0
- package/packages/cli/src/login.ts +20 -6
- package/packages/cli/src/mcp.ts +380 -9
- package/packages/cli/src/messaging.ts +3 -1
- package/packages/cli/src/setup/commands.ts +3 -2
- package/packages/cli/src/setup/runtime.ts +3 -2
- package/packages/cli/src/team-intake-mcp.ts +158 -0
- package/packages/cli/src/teams-mcp.ts +403 -0
- package/packages/cli/src/watchlist.ts +135 -0
- package/packages/codex/package.json +1 -1
- package/packages/codex/src/profile.ts +163 -0
- package/packages/codex/src/sessions.ts +12 -0
- package/packages/codex/src/spoke.ts +19 -1
- package/packages/node/package.json +1 -1
- package/packages/node/src/config.ts +96 -2
- package/packages/node/src/opencode-warm.ts +325 -0
- package/packages/node/src/run.ts +27 -1
- package/packages/preset/package.json +1 -1
- package/packages/preset/src/profile.ts +34 -0
- package/packages/preset/src/spoke.ts +4 -0
- package/packages/protocol/package.json +1 -1
- package/packages/protocol/src/artifacts.ts +142 -0
- package/packages/protocol/src/concierge.ts +366 -0
- package/packages/protocol/src/contracts.ts +104 -0
- package/packages/protocol/src/graph.ts +70 -0
- package/packages/protocol/src/hub-workspace.ts +201 -0
- package/packages/protocol/src/index.ts +169 -6
- package/packages/protocol/src/launch.ts +22 -0
- package/packages/protocol/src/profiles.ts +301 -0
- package/packages/protocol/src/teams.ts +204 -0
- package/packages/runtime-openshell/package.json +1 -1
- package/packages/runtime-sdk/package.json +1 -1
- package/packages/sdk/package.json +1 -1
- package/packages/sdk/src/address.ts +50 -2
- package/packages/sdk/src/artifact-jobs.ts +194 -0
- package/packages/sdk/src/artifacts.ts +187 -0
- package/packages/sdk/src/client.ts +226 -5
- package/packages/sdk/src/concierge.ts +88 -0
- package/packages/sdk/src/errors.ts +3 -1
- package/packages/sdk/src/index.ts +5 -0
- package/packages/sdk/src/team-intake.ts +122 -0
- package/packages/sdk/src/teams.ts +346 -0
- package/packages/sdk/src/types.ts +548 -1
- package/packages/spoke-sdk/package.json +1 -1
- package/packages/spoke-sdk/src/index.ts +1 -0
- package/packages/spoke-sdk/src/profile-apply.ts +245 -0
- package/src/central/actions.ts +7 -0
- package/src/central/agent/acting-routes.ts +2 -1
- package/src/central/agent/acting-switch.ts +2 -1
- package/src/central/agent/acting.ts +215 -10
- package/src/central/agent/app-tokens.ts +3 -2
- package/src/central/agent/audit.ts +17 -0
- package/src/central/agent/body.ts +163 -0
- package/src/central/agent/decisions.ts +69 -2
- package/src/central/agent/delivery.ts +95 -15
- package/src/central/agent/directory-routes.ts +37 -7
- package/src/central/agent/directory.ts +70 -14
- package/src/central/agent/errors.ts +5 -0
- package/src/central/agent/events-routes.ts +21 -4
- package/src/central/agent/events.ts +11 -3
- package/src/central/agent/form-check.ts +85 -0
- package/src/central/agent/grant-requests.ts +2 -1
- package/src/central/agent/grant-routes.ts +4 -3
- package/src/central/agent/grants.ts +29 -2
- package/src/central/agent/idempotency.ts +133 -0
- package/src/central/agent/intent-routes.ts +116 -0
- package/src/central/agent/intents.ts +505 -0
- package/src/central/agent/interaction-graph-routes.ts +212 -0
- package/src/central/agent/interaction-graph.ts +865 -0
- package/src/central/agent/launch-engine.ts +16 -2
- package/src/central/agent/launch-routes.ts +6 -1
- package/src/central/agent/launch-spec.ts +14 -1
- package/src/central/agent/message-routes.ts +2 -1
- package/src/central/agent/messages.ts +27 -2
- package/src/central/agent/names.ts +12 -2
- package/src/central/agent/read-routes.ts +86 -19
- package/src/central/agent/routes.ts +176 -5
- package/src/central/agent/webhook-routes.ts +2 -1
- package/src/central/agent-data.ts +4 -0
- package/src/central/artifacts/access.ts +44 -0
- package/src/central/artifacts/blob-store.ts +113 -0
- package/src/central/artifacts/blob-support.ts +98 -0
- package/src/central/artifacts/events.ts +27 -0
- package/src/central/artifacts/host-caps.ts +162 -0
- package/src/central/artifacts/host.ts +315 -0
- package/src/central/artifacts/idempotency.ts +109 -0
- package/src/central/artifacts/jobs-routes.ts +773 -0
- package/src/central/artifacts/jobs.ts +344 -0
- package/src/central/artifacts/maintenance.ts +103 -0
- package/src/central/artifacts/routes.ts +360 -0
- package/src/central/artifacts/store.ts +776 -0
- package/src/central/artifacts/workspaces.ts +31 -0
- package/src/central/backend/file-blobs.ts +377 -0
- package/src/central/backend/index.ts +12 -3
- package/src/central/backend/memory-blobs.ts +116 -0
- package/src/central/backend/postgres-blobs.ts +513 -0
- package/src/central/backend/postgres-migrate.ts +33 -0
- package/src/central/backend/postgres-state.ts +19 -0
- package/src/central/backend/types.ts +8 -0
- package/src/central/concierge/activity.ts +142 -0
- package/src/central/concierge/arm.ts +63 -0
- package/src/central/concierge/board-render.ts +190 -0
- package/src/central/concierge/board-results.ts +169 -0
- package/src/central/concierge/board-routes.ts +339 -0
- package/src/central/concierge/board.ts +249 -0
- package/src/central/concierge/context.ts +221 -0
- package/src/central/concierge/entitlements.ts +13 -0
- package/src/central/concierge/guard.ts +120 -0
- package/src/central/concierge/inbound.ts +89 -0
- package/src/central/concierge/index.ts +84 -0
- package/src/central/concierge/notes.ts +41 -0
- package/src/central/concierge/notifier.ts +986 -0
- package/src/central/concierge/prompt.ts +130 -0
- package/src/central/concierge/proposals.ts +262 -0
- package/src/central/concierge/push-remap.ts +49 -0
- package/src/central/concierge/routes.ts +62 -0
- package/src/central/concierge/schedule.ts +113 -0
- package/src/central/concierge/service.ts +243 -0
- package/src/central/concierge/session-routes.ts +69 -0
- package/src/central/concierge/settings.ts +217 -0
- package/src/central/concierge/supervisor.ts +740 -0
- package/src/central/concierge/tiers.ts +260 -0
- package/src/central/concierge/watchlist-routes.ts +277 -0
- package/src/central/concierge/watchlist.ts +273 -0
- package/src/central/config.ts +7 -1
- package/src/central/http.ts +633 -50
- package/src/central/mcp-http.ts +10 -21
- package/src/central/profiles/apply.ts +333 -0
- package/src/central/profiles/content.ts +174 -0
- package/src/central/profiles/events.ts +40 -0
- package/src/central/profiles/idempotency.ts +14 -0
- package/src/central/profiles/import.ts +139 -0
- package/src/central/profiles/routes.ts +356 -0
- package/src/central/profiles/store.ts +597 -0
- package/src/central/profiles/usage-ports.ts +120 -0
- package/src/central/profiles/usage.ts +364 -0
- package/src/central/push.ts +7 -0
- package/src/central/registry.ts +43 -6
- package/src/central/store.ts +4 -0
- package/src/central/teams/collections.ts +34 -0
- package/src/central/teams/discovery.ts +346 -0
- package/src/central/teams/draft-edits.ts +46 -0
- package/src/central/teams/events.ts +46 -0
- package/src/central/teams/intake-routes.ts +100 -0
- package/src/central/teams/intake.ts +772 -0
- package/src/central/teams/policy.ts +231 -0
- package/src/central/teams/routes.ts +516 -0
- package/src/central/teams/seats.ts +272 -0
- package/src/central/teams/staffing.ts +888 -0
- package/src/central/teams/store.ts +667 -0
- package/src/central/teams/validation.ts +256 -0
- package/src/central/workspaces/board.ts +408 -0
- package/src/central/workspaces/checkpoints.ts +316 -0
- package/src/central/workspaces/coordinator.ts +841 -0
- package/src/central/workspaces/events.ts +85 -0
- package/src/central/workspaces/lifecycle.ts +93 -0
- package/src/central/workspaces/master.ts +490 -0
- package/src/central/workspaces/members.ts +632 -0
- package/src/central/workspaces/routes.ts +354 -0
- package/src/central/workspaces/sidechats-routes.ts +410 -0
- package/src/central/workspaces/sidechats.ts +482 -0
- package/src/central/workspaces/store.ts +571 -0
- package/src/central/workspaces/supervision.ts +355 -0
- package/src/index.ts +97 -15
- package/src/spoke/concierge-agent.ts +33 -0
- package/src/spoke/deliver.ts +22 -4
- package/src/spoke/fork-revert.ts +142 -0
- package/src/spoke/hub-rpc.ts +41 -5
- package/src/spoke/hub-tool.ts +383 -18
- package/src/spoke/listing.ts +47 -4
- package/src/spoke/profile-apply.ts +298 -0
- package/src/spoke/session-context.ts +176 -0
- package/src/spoke/spoke.ts +319 -20
- package/src/spoke/timeline.ts +6 -1
- package/src/spoke/tracker.ts +23 -1
- package/src/spoke/who-teams.ts +81 -0
- package/src/tui-messaging.ts +32 -18
- package/web/dist/assets/ArtifactPage-BS0gVkfu.js +1 -0
- package/web/dist/assets/ArtifactPane-PLYHNs6s.js +1 -0
- package/web/dist/assets/ArtifactWorkbench-9eTj1CeW.css +1 -0
- package/web/dist/assets/ArtifactWorkbench-LkDyJ5Dv.js +6 -0
- package/web/dist/assets/Library-CtxRqTjo.js +2 -0
- package/web/dist/assets/Library-DYsFj_5c.css +1 -0
- package/web/dist/assets/ProfilesDestination-BYiHKLmc.js +1 -0
- package/web/dist/assets/TeamsPage-BwjsAAUC.js +1 -0
- package/web/dist/assets/Watchtower-C4swrWVs.js +2 -0
- package/web/dist/assets/Watchtower-CTgqHk5g.css +1 -0
- package/web/dist/assets/index-DaH5GUdV.js +31 -0
- package/web/dist/assets/index-WWuKfWxL.css +1 -0
- package/web/dist/assets/live-BUofsQpz.css +1 -0
- package/web/dist/assets/live-YyTjE47g.js +5 -0
- package/web/dist/assets/pane-layout-CKC4I00k.js +1 -0
- package/web/dist/assets/plural-Bspvoqfq.js +1 -0
- package/web/dist/assets/renderer2d-CX5j3NcD.js +1 -0
- package/web/dist/assets/renderer3d-BXIVOvfi.js +5393 -0
- package/web/dist/assets/safe-area-DfPsOdWR.js +1 -0
- package/web/dist/assets/source-CvUHTc3O.js +1 -0
- package/web/dist/assets/ui-C9Igxzjm.js +1 -0
- package/web/dist/index.html +5 -2
- package/web/dist/sw.js +22 -2
- package/web/dist/assets/index-Dm8OmkaO.css +0 -1
- package/web/dist/assets/index-FUJbj_Du.js +0 -20
|
@@ -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
|
+
}
|