@huynle/hub 0.8.0 → 0.8.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +213 -1
- package/README.md +84 -15
- package/package.json +1 -1
- package/packages/claude-code/package.json +1 -1
- package/packages/claude-code/src/paths.ts +3 -3
- package/packages/cli/dist/hub-cli.js +649 -242
- package/packages/cli/package.json +1 -1
- package/packages/cli/src/bootstrap/adapters/node-services/render.ts +2 -2
- package/packages/cli/src/bootstrap/discovery/host.ts +15 -1
- package/packages/cli/src/bootstrap/discovery/probe.ts +1 -1
- package/packages/cli/src/bootstrap/enrollment/enroll.ts +3 -3
- package/packages/cli/src/bootstrap/enrollment/policy.ts +13 -0
- package/packages/cli/src/cli.ts +3 -3
- package/packages/cli/src/http.ts +1 -0
- package/packages/cli/src/launches.ts +9 -3
- package/packages/cli/src/login.ts +2 -2
- package/packages/cli/src/mcp.ts +7 -3
- package/packages/cli/src/messaging.ts +4 -2
- package/packages/cli/src/profiles-mcp.ts +301 -0
- package/packages/cli/src/setup/commands.ts +20 -2
- package/packages/cli/src/spoke-services.ts +1 -1
- package/packages/cli/src/teams-mcp.ts +59 -2
- package/packages/codex/package.json +1 -1
- package/packages/codex/src/paths.ts +3 -3
- package/packages/node/package.json +1 -1
- package/packages/node/src/config.ts +10 -2
- package/packages/node/src/opencode-warm.ts +27 -8
- package/packages/node/src/run.ts +3 -2
- package/packages/preset/package.json +1 -1
- package/packages/protocol/package.json +1 -1
- package/packages/protocol/src/index.ts +7 -1
- package/packages/protocol/src/launch.ts +14 -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/errors.ts +1 -0
- package/packages/sdk/src/index.ts +1 -0
- package/packages/sdk/src/types.ts +22 -2
- package/packages/sdk/src/wake.ts +20 -0
- package/packages/spoke-sdk/package.json +1 -1
- package/src/central/agent/acting.ts +3 -0
- package/src/central/agent/audit.ts +1 -1
- package/src/central/agent/decisions.ts +58 -2
- package/src/central/agent/delivery.ts +101 -27
- package/src/central/agent/errors.ts +2 -0
- package/src/central/agent/escalation.ts +10 -2
- package/src/central/agent/events.ts +7 -1
- package/src/central/agent/launch-engine.ts +173 -30
- package/src/central/agent/launch-routes.ts +20 -9
- package/src/central/agent/launch-spec.ts +26 -2
- package/src/central/agent/launch-store.ts +55 -2
- package/src/central/agent/message-limits.ts +89 -11
- package/src/central/agent/message-routes.ts +8 -3
- package/src/central/agent/messages.ts +3 -2
- package/src/central/agent/placement.ts +93 -18
- package/src/central/agent/read-routes.ts +3 -0
- package/src/central/agent/routes.ts +4 -2
- package/src/central/agent/webhooks.ts +17 -2
- package/src/central/artifacts/jobs-routes.ts +4 -1
- package/src/central/concierge/board-routes.ts +13 -2
- package/src/central/concierge/inbound.ts +4 -7
- package/src/central/concierge/prompt.ts +4 -3
- package/src/central/concierge/proposals.ts +8 -2
- package/src/central/concierge/routes.ts +62 -2
- package/src/central/concierge/session-routes.ts +62 -6
- package/src/central/concierge/supervisor.ts +34 -7
- package/src/central/concierge/watchlist-routes.ts +4 -2
- package/src/central/config.ts +21 -5
- package/src/central/http.ts +38 -15
- package/src/central/main.ts +1 -1
- package/src/central/registry.ts +26 -1
- package/src/central/teams/intake.ts +20 -6
- package/src/central/teams/staffing.ts +22 -0
- package/src/central/workspaces/sidechats-routes.ts +1 -1
- package/src/index.ts +39 -18
- package/src/migrate.ts +109 -3
- package/src/spoke/env-inject.ts +8 -1
- package/src/spoke/hub-tool.ts +24 -10
- package/src/spoke/launchers.ts +6 -4
- package/src/spoke/listing.ts +18 -6
- package/src/spoke/localhub.ts +14 -4
- package/src/spoke/locations.ts +40 -0
- package/src/spoke/session-context.ts +107 -27
- package/src/spoke/spoke.ts +13 -5
- package/web/dist/assets/ArtifactPage-mNzjlP_U.js +1 -0
- package/web/dist/assets/ArtifactPane-CPQcuX7c.js +1 -0
- package/web/dist/assets/ArtifactWorkbench-NoFC5Sb2.js +6 -0
- package/web/dist/assets/Library-LXHsP5Fw.js +2 -0
- package/web/dist/assets/ProfilesDestination-DhVVcAep.js +1 -0
- package/web/dist/assets/TeamsPage-DBrO5hoL.js +1 -0
- package/web/dist/assets/Watchtower-DxaJYQp_.js +2 -0
- package/web/dist/assets/index-BabrNDB3.css +1 -0
- package/web/dist/assets/index-lIOfSYck.js +34 -0
- package/web/dist/assets/live-BDq0AABg.js +5 -0
- package/web/dist/assets/{pane-layout-CKC4I00k.js → pane-layout-DWXfDU-1.js} +1 -1
- package/web/dist/assets/{renderer2d-CX5j3NcD.js → renderer2d-DAr4IHM8.js} +1 -1
- package/web/dist/assets/{renderer3d-BXIVOvfi.js → renderer3d-DWZj595u.js} +1 -1
- package/web/dist/assets/{safe-area-DfPsOdWR.js → safe-area-JUb6OeI3.js} +1 -1
- package/web/dist/assets/{source-CvUHTc3O.js → source-CDU04kYw.js} +1 -1
- package/web/dist/assets/ui-Dh1-78Cl.js +3 -0
- package/web/dist/index.html +4 -4
- package/web/dist/assets/ArtifactPage-BS0gVkfu.js +0 -1
- package/web/dist/assets/ArtifactPane-PLYHNs6s.js +0 -1
- package/web/dist/assets/ArtifactWorkbench-LkDyJ5Dv.js +0 -6
- package/web/dist/assets/Library-CtxRqTjo.js +0 -2
- package/web/dist/assets/ProfilesDestination-BYiHKLmc.js +0 -1
- package/web/dist/assets/TeamsPage-BwjsAAUC.js +0 -1
- package/web/dist/assets/Watchtower-C4swrWVs.js +0 -2
- package/web/dist/assets/index-DaH5GUdV.js +0 -31
- package/web/dist/assets/index-WWuKfWxL.css +0 -1
- package/web/dist/assets/live-YyTjE47g.js +0 -5
- package/web/dist/assets/ui-C9Igxzjm.js +0 -1
|
@@ -5,6 +5,9 @@
|
|
|
5
5
|
// POST /api/agent/concierge/enable user {expectedRevision, timeZone?} → {settings, status}
|
|
6
6
|
// POST /api/agent/concierge/disable user {expectedRevision} → {settings, status}
|
|
7
7
|
// POST /api/agent/concierge/restart user → {status}
|
|
8
|
+
// GET /api/agent/concierge/machines/:id/models user → {models: [{providerID, modelID, name, enabled}]} (FX-04:
|
|
9
|
+
// the Model dropdown; the machine's OpenCode service catalog, read through a session it reports; 404 for a
|
|
10
|
+
// machine that is not the user's, 503 offline without a connected service or when the spoke cannot answer)
|
|
8
11
|
// GET /api/agent/concierge/notes user; the Concierge → {text, revision, updatedAt?}
|
|
9
12
|
// PUT /api/agent/concierge/notes user; the Concierge {text, expectedRevision} → {text, revision, updatedAt}
|
|
10
13
|
//
|
|
@@ -32,14 +35,71 @@ export async function admitConcierge(identity: ConciergeIdentity, actor: AgentAc
|
|
|
32
35
|
throw new AgentError("not_found", "Not found")
|
|
33
36
|
}
|
|
34
37
|
|
|
35
|
-
|
|
36
|
-
|
|
38
|
+
/** (FX-04) One entry of a machine's model list: names only (never provider options or keys). */
|
|
39
|
+
export interface MachineModel {
|
|
40
|
+
readonly providerID: string
|
|
41
|
+
readonly modelID: string
|
|
42
|
+
readonly name: string
|
|
43
|
+
readonly enabled: boolean
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
/** (FX-04) What the models route needs of the registry and the spokes. */
|
|
47
|
+
export interface MachineModelPorts {
|
|
48
|
+
/** The machine's connected OpenCode service connection. */
|
|
49
|
+
readonly serviceFor: (machineID: string) => string | undefined
|
|
50
|
+
readonly reportedSessions: (connID: string) => readonly string[]
|
|
51
|
+
readonly request: (connID: string, req: { readonly op: "models"; readonly sessionID: string }) => Promise<{ readonly models?: readonly unknown[] }>
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
const isObject = (v: unknown): v is Record<string, unknown> => typeof v === "object" && v !== null && !Array.isArray(v)
|
|
55
|
+
|
|
56
|
+
/**
|
|
57
|
+
* (FX-04) The machine's OpenCode model list. The spoke's `models` op is machine-wide but addressed through a session it
|
|
58
|
+
* reports (any: its catalog is the same); a service that reports no session yet cannot be asked (the console falls back
|
|
59
|
+
* to typing the model).
|
|
60
|
+
*/
|
|
61
|
+
export function machineModels(ports: MachineModelPorts): (machineID: string) => Promise<MachineModel[]> {
|
|
62
|
+
return async (machineID) => {
|
|
63
|
+
const connID = ports.serviceFor(machineID)
|
|
64
|
+
if (connID === undefined) throw new AgentError("offline", "No OpenCode service is connected on that machine")
|
|
65
|
+
const sessionID = ports.reportedSessions(connID)[0]
|
|
66
|
+
if (sessionID === undefined) throw new AgentError("offline", "Its OpenCode service reports no session yet, so its model list cannot be read")
|
|
67
|
+
let result: { readonly models?: readonly unknown[] }
|
|
68
|
+
try {
|
|
69
|
+
result = await ports.request(connID, { op: "models", sessionID })
|
|
70
|
+
} catch (cause) {
|
|
71
|
+
throw new AgentError("offline", `Could not read the machine's model list: ${String(cause instanceof Error ? cause.message : cause).slice(0, 200)}`)
|
|
72
|
+
}
|
|
73
|
+
return (result.models ?? []).flatMap((m) =>
|
|
74
|
+
isObject(m) && typeof m.providerID === "string" && typeof m.modelID === "string"
|
|
75
|
+
? [{ providerID: m.providerID, modelID: m.modelID, name: typeof m.name === "string" ? m.name : m.modelID, enabled: m.enabled !== false }]
|
|
76
|
+
: [],
|
|
77
|
+
)
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
export function conciergeRoutes(deps: { readonly service: ConciergeService; readonly identity: ConciergeIdentity; readonly models?: (machineID: string) => Promise<MachineModel[]> }): AgentRoute[] {
|
|
82
|
+
const { service, identity, models } = deps
|
|
37
83
|
return [
|
|
38
84
|
{ method: "GET", path: PATH, allow: {}, handle: async ({ user }) => ok(await service.view(user.id)) },
|
|
39
85
|
{ method: "PUT", path: `${PATH}/settings`, allow: {}, handle: async ({ request, user }) => ok(await service.updateSettings(user.id, await boundedJson(request))) },
|
|
40
86
|
{ method: "POST", path: `${PATH}/enable`, allow: {}, handle: async ({ request, user }) => ok(await service.enable(user.id, await boundedJson(request))) },
|
|
41
87
|
{ method: "POST", path: `${PATH}/disable`, allow: {}, handle: async ({ request, user }) => ok(await service.disable(user.id, await boundedJson(request))) },
|
|
42
88
|
{ method: "POST", path: `${PATH}/restart`, allow: {}, handle: async ({ user }) => ok(await service.restart(user.id)) },
|
|
89
|
+
...(models
|
|
90
|
+
? [
|
|
91
|
+
{
|
|
92
|
+
method: "GET",
|
|
93
|
+
path: `${PATH}/machines/:id/models`,
|
|
94
|
+
allow: {},
|
|
95
|
+
handle: async ({ params, user }) => {
|
|
96
|
+
// Another user's machine and an unknown one answer alike.
|
|
97
|
+
if (!service.machineViews(user.id).some((m) => m.id === params.id)) throw new AgentError("not_found", "No such machine")
|
|
98
|
+
return ok({ models: await models(params.id!) })
|
|
99
|
+
},
|
|
100
|
+
} satisfies AgentRoute,
|
|
101
|
+
]
|
|
102
|
+
: []),
|
|
43
103
|
{
|
|
44
104
|
method: "GET",
|
|
45
105
|
path: `${PATH}/notes`,
|
|
@@ -47,23 +47,79 @@ export const CONCIERGE_SESSION_ROUTES: readonly { readonly method: string; reado
|
|
|
47
47
|
|
|
48
48
|
const key = (method: string, path: string) => `${method} ${path}`
|
|
49
49
|
|
|
50
|
+
/**
|
|
51
|
+
* (FX-07) How long a Concierge identity check waits for the state store (the route gate, the acting guard, the inbound
|
|
52
|
+
* and outbound rules, team staffing): past it the check fails closed with `busy` (503).
|
|
53
|
+
*/
|
|
54
|
+
export const IDENTITY_TIMEOUT_MS = 5_000
|
|
55
|
+
|
|
56
|
+
/** (FX-07) The refusal while the hub's state does not answer: neutral (no Concierge wording), retried by clients. */
|
|
57
|
+
export const stateBusy = () => new AgentError("busy", "Hub state is busy, try again", { extra: { retryAfter: Math.ceil(IDENTITY_TIMEOUT_MS / 1000) } })
|
|
58
|
+
|
|
59
|
+
/** `read`, or a `busy` rejection once `ms` passed (the timer never outlives the answer; a late answer is ignored). */
|
|
60
|
+
function within<T>(read: Promise<T>, ms: number): Promise<T> {
|
|
61
|
+
let timer: ReturnType<typeof setTimeout> | undefined
|
|
62
|
+
const late = new Promise<never>((_, reject) => {
|
|
63
|
+
timer = setTimeout(() => reject(stateBusy()), ms)
|
|
64
|
+
})
|
|
65
|
+
read.catch(() => {}) // a late rejection is not unhandled
|
|
66
|
+
return Promise.race([read, late]).finally(() => clearTimeout(timer))
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
const TIMED = Symbol("concierge.timedIdentity")
|
|
70
|
+
|
|
71
|
+
/**
|
|
72
|
+
* (FX-07) The identity with a time limit on each read: past it, the read rejects `busy`. A user whose read timed out
|
|
73
|
+
* is answered `busy` at once for the next `ms` (no new store read: a stuck store is not asked again per check, so
|
|
74
|
+
* the checks of one request share one wait and reads do not pile up). Wrapping a timed identity returns it as is.
|
|
75
|
+
*/
|
|
76
|
+
export function timedIdentity(identity: ConciergeIdentity, ms: number = IDENTITY_TIMEOUT_MS, now: () => number = Date.now): ConciergeIdentity {
|
|
77
|
+
if ((identity as { [TIMED]?: true })[TIMED]) return identity
|
|
78
|
+
const stuckUntil = new Map<string, number>()
|
|
79
|
+
const timed = {
|
|
80
|
+
[TIMED]: true as const,
|
|
81
|
+
async current(userID: string) {
|
|
82
|
+
const until = stuckUntil.get(userID)
|
|
83
|
+
if (until !== undefined) {
|
|
84
|
+
if (now() < until) throw stateBusy()
|
|
85
|
+
stuckUntil.delete(userID)
|
|
86
|
+
}
|
|
87
|
+
try {
|
|
88
|
+
return await within(identity.current(userID), ms)
|
|
89
|
+
} catch (cause) {
|
|
90
|
+
if (cause instanceof AgentError && cause.code === "busy") stuckUntil.set(userID, now() + ms)
|
|
91
|
+
throw cause
|
|
92
|
+
}
|
|
93
|
+
},
|
|
94
|
+
}
|
|
95
|
+
return timed
|
|
96
|
+
}
|
|
97
|
+
|
|
50
98
|
/**
|
|
51
99
|
* The gate agentApi runs for every session actor before the route's own checks: a Concierge session (current or
|
|
52
|
-
* previous) may call only the allow-list and `extra` (the tiers slot's routes).
|
|
100
|
+
* previous) may call only the allow-list and `extra` (the tiers slot's routes). (FX-07) An identity read that fails or
|
|
101
|
+
* has not answered within `timeoutMs` refuses every other route with a neutral 503 `busy` (fail closed, without
|
|
102
|
+
* telling an ordinary session it is the Concierge); the allow-list still passes.
|
|
53
103
|
*/
|
|
54
|
-
export function conciergeSessionGate(
|
|
104
|
+
export function conciergeSessionGate(
|
|
105
|
+
identity: ConciergeIdentity,
|
|
106
|
+
extra: () => readonly AgentRoute[] = () => [],
|
|
107
|
+
options: { readonly timeoutMs?: number } = {},
|
|
108
|
+
): (actor: AgentActor, route: Pick<AgentRoute, "method" | "path">) => Promise<void> {
|
|
55
109
|
const allowed = new Set(CONCIERGE_SESSION_ROUTES.map((r) => key(r.method, r.path)))
|
|
110
|
+
const timed = timedIdentity(identity, options.timeoutMs ?? IDENTITY_TIMEOUT_MS)
|
|
56
111
|
return async (actor, route) => {
|
|
57
112
|
if (actor.kind !== "session") return
|
|
113
|
+
const listed = () => allowed.has(key(route.method, route.path)) || extra().some((r) => r.method === route.method && r.path === route.path)
|
|
58
114
|
let concierge: boolean
|
|
59
115
|
try {
|
|
60
|
-
const current = await
|
|
116
|
+
const current = await timed.current(actor.userID)
|
|
61
117
|
concierge = current.sessionID === actor.sessionID || current.hiddenSessionIDs.includes(actor.sessionID)
|
|
62
118
|
} catch {
|
|
63
|
-
|
|
119
|
+
if (listed()) return
|
|
120
|
+
throw stateBusy()
|
|
64
121
|
}
|
|
65
|
-
if (!concierge) return
|
|
66
|
-
if (allowed.has(key(route.method, route.path)) || extra().some((r) => r.method === route.method && r.path === route.path)) return
|
|
122
|
+
if (!concierge || listed()) return
|
|
67
123
|
throw new AgentError("forbidden", "The Concierge cannot use this route", { extra: { policy: "concierge" } })
|
|
68
124
|
}
|
|
69
125
|
}
|
|
@@ -126,13 +126,30 @@ export class StatusStore {
|
|
|
126
126
|
}
|
|
127
127
|
}
|
|
128
128
|
|
|
129
|
-
|
|
130
|
-
|
|
129
|
+
export interface IdentityOptions {
|
|
130
|
+
/**
|
|
131
|
+
* (FX-07) The user's sessions a machine reports locked now (its spoke holds them as Concierge sessions: they carry
|
|
132
|
+
* an agent or a context revision). The state record keeps only PAST_SESSIONS_MAX past sessions; an older one its
|
|
133
|
+
* machine still reports is still a Concierge session (and one no machine reports cannot use its tokens).
|
|
134
|
+
*/
|
|
135
|
+
readonly reported?: (userID: string) => Iterable<string>
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
/**
|
|
139
|
+
* The Concierge-only authority read from the state record: the current session and every hidden one (plus, FX-07,
|
|
140
|
+
* those a machine reports locked). A failing report adds nothing; the record's own sessions always count.
|
|
141
|
+
*/
|
|
142
|
+
export function recordIdentity(store: StatusStore, options: IdentityOptions = {}): { current(userID: string): Promise<{ sessionID?: string; hiddenSessionIDs: readonly string[] }> } {
|
|
131
143
|
return {
|
|
132
144
|
current: async (userID) => {
|
|
133
145
|
const r = await store.get(userID)
|
|
134
|
-
const all =
|
|
135
|
-
|
|
146
|
+
const all = new Set([...(r.pastSessionIDs ?? []), ...r.hiddenSessionIDs])
|
|
147
|
+
try {
|
|
148
|
+
if (options.reported) for (const id of options.reported(userID)) all.add(id)
|
|
149
|
+
} catch (cause) {
|
|
150
|
+
console.error(`[hub] concierge locked-session report failed: ${(cause as Error).message}`)
|
|
151
|
+
}
|
|
152
|
+
return { ...(r.sessionID !== undefined ? { sessionID: r.sessionID } : {}), hiddenSessionIDs: [...all] }
|
|
136
153
|
},
|
|
137
154
|
}
|
|
138
155
|
}
|
|
@@ -158,6 +175,9 @@ export function conciergeLaunchSpec(userID: string, settings: ConciergeSettings,
|
|
|
158
175
|
}
|
|
159
176
|
}
|
|
160
177
|
|
|
178
|
+
/** A queued launch's waiting reason as the Concierge's status: "Waiting: …" (kept when it already says Waiting). */
|
|
179
|
+
const waitingText = (reason: string): string => (reason.startsWith("Waiting") ? reason : `Waiting: ${reason}`)
|
|
180
|
+
|
|
161
181
|
/** Whether a launch is a Concierge launch (its label). */
|
|
162
182
|
export const isConciergeLaunch = (launch: Pick<Launch, "spec">): boolean => launch.spec.labels?.["hub/system"] === "concierge"
|
|
163
183
|
|
|
@@ -410,12 +430,19 @@ export class Supervisor {
|
|
|
410
430
|
await this.follow(userID, settings, await this.store.read(userID))
|
|
411
431
|
}
|
|
412
432
|
|
|
413
|
-
/** The recorded launch: running → adopt it; ended → back off; else wait. */
|
|
433
|
+
/** The recorded launch: running → adopt it; ended → back off; else wait (starting says why it waits, FX-02). */
|
|
414
434
|
private async follow(userID: string, settings: ConciergeSettings, snap: RecordSnapshot): Promise<void> {
|
|
415
|
-
const
|
|
416
|
-
const launch = await this.deps.launches.view(userID, r.launchID!)
|
|
435
|
+
const launch = await this.deps.launches.view(userID, snap.record.launchID!)
|
|
417
436
|
if (!launch || launch.state === "ended") return this.ended(userID, snap, launch)
|
|
418
437
|
if (launch.state === "running" && launch.sessionID !== undefined) return this.adopt(userID, snap, launch.sessionID, launch.machineID)
|
|
438
|
+
if (snap.record.state === "starting") {
|
|
439
|
+
const reason = launch.state === "queued" && launch.waiting ? waitingText(launch.waiting.reason) : "Starting"
|
|
440
|
+
if (snap.record.reason !== reason) {
|
|
441
|
+
if (!(await this.write(userID, snap, { ...snap.record, reason }))) return
|
|
442
|
+
snap = await this.store.read(userID)
|
|
443
|
+
}
|
|
444
|
+
}
|
|
445
|
+
const r = snap.record
|
|
419
446
|
if (launch.sessionID !== undefined && !r.hiddenSessionIDs.includes(launch.sessionID)) {
|
|
420
447
|
// Hidden from the console's lists as soon as the session exists, and never auto-named from its title.
|
|
421
448
|
if (await this.write(userID, snap, { ...r, hiddenSessionIDs: addHidden(r.hiddenSessionIDs, launch.sessionID) })) await this.unname(userID, [launch.sessionID])
|
|
@@ -140,8 +140,10 @@ export async function watchFacts(deps: WatchFactDeps, userID: string, hidden: re
|
|
|
140
140
|
}
|
|
141
141
|
const prefs = deps.store.viewPrefs(userID)
|
|
142
142
|
const archived = new Set(prefs.hiddenSessions)
|
|
143
|
-
// (CON-12) Machines that report their sessions now: a session one of them no longer reports went away.
|
|
144
|
-
|
|
143
|
+
// (CON-12) Machines that report their sessions now: a session one of them no longer reports went away. (FX-07) A
|
|
144
|
+
// process reports only once its first sessions frame came (right after hello it lists none yet), so a machine with
|
|
145
|
+
// a process still pending is not reporting: its sessions are offline for that moment, never gone.
|
|
146
|
+
const reporting = new Set(catalog.filter((m) => m.processes.length > 0 && m.processes.every((p) => !p.sessionsPending)).map((m) => m.id))
|
|
145
147
|
for (const record of deps.store.agentData(userID).sessions ?? []) {
|
|
146
148
|
const known = facts.get(record.sessionID)
|
|
147
149
|
if (!known) {
|
package/src/central/config.ts
CHANGED
|
@@ -7,7 +7,7 @@ import path from "node:path"
|
|
|
7
7
|
import { hubEnv } from "../env"
|
|
8
8
|
import { isLoopbackHost } from "../loopback"
|
|
9
9
|
import { formatPattern, parsePattern } from "./agent/policy"
|
|
10
|
-
import { APP_DIR, configBase, legacyPaths, migrateFirst,
|
|
10
|
+
import { APP_DIR, configBase, legacyPaths, migrateFirst, migrateStateDir, resolveHubStateDir, stateLegacyPaths } from "../migrate"
|
|
11
11
|
import { updateSpeech, type StoredSpeech } from "./speech/settings"
|
|
12
12
|
|
|
13
13
|
export const GOOGLE_ISSUER = "https://accounts.google.com"
|
|
@@ -99,6 +99,11 @@ export interface HubLaunches {
|
|
|
99
99
|
readonly maxActive?: number
|
|
100
100
|
/** New launches per clock hour. */
|
|
101
101
|
readonly perHour?: number
|
|
102
|
+
/**
|
|
103
|
+
* Minutes the same node instance may leave a running launch out of its reports (its session gone) before it ends
|
|
104
|
+
* lost (default 5; 0 or false: never).
|
|
105
|
+
*/
|
|
106
|
+
readonly lostAfterMinutes?: number | false
|
|
102
107
|
}
|
|
103
108
|
|
|
104
109
|
/** hub.json `agents`, validated. */
|
|
@@ -133,7 +138,9 @@ const HUB_KEYS = new Set(["host", "port", "publicURL", "stateDir", "speech", "au
|
|
|
133
138
|
const CLUSTER_KEYS = ["database", "valkey"] as const
|
|
134
139
|
const AGENTS_KEYS = new Set(["never", "launches", "webhooks"])
|
|
135
140
|
const WEBHOOK_KEYS = new Set(["allowPrivate"])
|
|
136
|
-
const LAUNCH_CAP_KEYS = ["maxQueued", "maxActive", "perHour"] as const
|
|
141
|
+
const LAUNCH_CAP_KEYS = ["maxQueued", "maxActive", "perHour", "lostAfterMinutes"] as const
|
|
142
|
+
/** The longest lostAfterMinutes (a day): far below setTimeout's 32-bit limit. */
|
|
143
|
+
const LOST_AFTER_MAX = 1440
|
|
137
144
|
const LAUNCH_CAP_MAX = 100_000
|
|
138
145
|
const AUTH_KEYS = new Set(["mode", "issuer", "clientId", "clientSecret", "allowed", "admins"])
|
|
139
146
|
const EMAIL = /^[^\s@,]+@[^\s@,]+\.[^\s@,]+$/
|
|
@@ -322,13 +329,20 @@ function readHubWebhooks(raw: unknown, file: string, warnings: string[], fail: (
|
|
|
322
329
|
function readHubLaunches(raw: unknown, file: string, warnings: string[], fail: (message: string) => never): HubLaunches {
|
|
323
330
|
if (!isObj(raw)) fail("agents.launches must be an object")
|
|
324
331
|
const input = raw as Record<string, unknown>
|
|
325
|
-
const caps: Record<string, number> = {}
|
|
332
|
+
const caps: Record<string, number | false> = {}
|
|
326
333
|
for (const key of Object.keys(input)) {
|
|
327
334
|
if (!(LAUNCH_CAP_KEYS as readonly string[]).includes(key)) {
|
|
328
335
|
warnings.push(`${file}: unknown key "agents.launches.${key}" ignored`)
|
|
329
336
|
continue
|
|
330
337
|
}
|
|
331
338
|
const value = input[key]
|
|
339
|
+
if (key === "lostAfterMinutes") {
|
|
340
|
+
if (value !== false && (typeof value !== "number" || !Number.isInteger(value) || value < 0 || value > LOST_AFTER_MAX)) {
|
|
341
|
+
fail(`agents.launches.lostAfterMinutes must be a whole number from 0 to ${LOST_AFTER_MAX}, or false (0 and false: never)`)
|
|
342
|
+
}
|
|
343
|
+
caps[key] = value as number | false
|
|
344
|
+
continue
|
|
345
|
+
}
|
|
332
346
|
if (typeof value !== "number" || !Number.isInteger(value) || value < 1 || value > LAUNCH_CAP_MAX) {
|
|
333
347
|
fail(`agents.launches.${key} must be a whole number from 1 to ${LAUNCH_CAP_MAX}`)
|
|
334
348
|
}
|
|
@@ -462,7 +476,7 @@ export function centralConfig(rawEnv: Env = process.env, home: string = homedir(
|
|
|
462
476
|
host,
|
|
463
477
|
port,
|
|
464
478
|
publicURL,
|
|
465
|
-
stateDir: env.HUB_STATE_DIR || file.stateDir ||
|
|
479
|
+
stateDir: env.HUB_STATE_DIR || file.stateDir || resolveHubStateDir(env, home).dir,
|
|
466
480
|
apiKey: env.OPENROUTER_API_KEY || undefined,
|
|
467
481
|
sttModel: env.HUB_STT_MODEL || "openai/gpt-4o-mini-transcribe",
|
|
468
482
|
ttsModel: env.HUB_TTS_MODEL || "microsoft/mai-voice-2.1-flash",
|
|
@@ -504,7 +518,9 @@ export function loadCentralConfig(
|
|
|
504
518
|
const config = centralConfig(env, home, hub)
|
|
505
519
|
if (!resolved.HUB_STATE_DIR && !hub?.stateDir) {
|
|
506
520
|
// hub.pid is the spoke's auto-start lock for the legacy location, not state.
|
|
507
|
-
|
|
521
|
+
migrateStateDir(stateLegacyPaths(env, home), config.stateDir, { ...migrate, skip: ["hub.pid"] })
|
|
522
|
+
const resolvedDir = resolveHubStateDir(env, home)
|
|
523
|
+
if (resolvedDir.hint) warnings.push(resolvedDir.hint)
|
|
508
524
|
}
|
|
509
525
|
return { config, warnings, path: file, loaded: hub !== undefined }
|
|
510
526
|
}
|
package/src/central/http.ts
CHANGED
|
@@ -91,9 +91,10 @@ import { conciergeActor, conciergeInbound, conciergeProtects } from "./concierge
|
|
|
91
91
|
import { conciergeModules, type ConciergePorts, type ConciergeTiers } from "./concierge/index"
|
|
92
92
|
import { watchedSessions } from "./concierge/notifier"
|
|
93
93
|
import { NotesStore } from "./concierge/notes"
|
|
94
|
-
import { conciergeSessionGate } from "./concierge/session-routes"
|
|
94
|
+
import { conciergeSessionGate, timedIdentity } from "./concierge/session-routes"
|
|
95
95
|
import { conciergeAwareNotifier, isConciergeSession } from "./concierge/push-remap"
|
|
96
96
|
import { ConciergeService, type MachineFacts } from "./concierge/service"
|
|
97
|
+
import { machineModels } from "./concierge/routes"
|
|
97
98
|
import { SettingsStore } from "./concierge/settings"
|
|
98
99
|
import { CONCIERGE_TAMPERED, isConciergeLaunch, recordIdentity, StatusStore, Supervisor } from "./concierge/supervisor"
|
|
99
100
|
import { CONCIERGE_AGENT } from "../../packages/protocol/src/concierge"
|
|
@@ -409,7 +410,12 @@ export function startCentral(options: CentralOptions): Central {
|
|
|
409
410
|
// CON-04: the Concierge's state record is read early (the Concierge-only authority, hiding, the inbound rule and
|
|
410
411
|
// push remapping all follow it); the service itself is built below, once its parts exist.
|
|
411
412
|
const conciergeStatus = new StatusStore(backends.state, now)
|
|
412
|
-
|
|
413
|
+
// (FX-07) Beyond the past sessions the record keeps: those a machine reports locked (its spoke holds them as Concierge sessions).
|
|
414
|
+
const conciergeIdentity = recordIdentity(conciergeStatus, { reported: (userID) => registry.lockedSessions(userID) })
|
|
415
|
+
// (FX-07) The identity checks on request paths (route gate, manage guard, acting guard, inbound/outbound rules, team
|
|
416
|
+
// staffing, Decision and audit labels) wait at most IDENTITY_TIMEOUT_MS for the store: past it they refuse with busy
|
|
417
|
+
// (503), never hang; one shared breaker answers busy at once for a while after a read timed out.
|
|
418
|
+
const conciergeTimedIdentity = timedIdentity(conciergeIdentity)
|
|
413
419
|
// Pushes about the Concierge's hidden sessions become kind "concierge" (/concierge) or are dropped.
|
|
414
420
|
const notifier = conciergeAwareNotifier(options.notifier ?? push, () => conciergeIdentity)
|
|
415
421
|
/** SSE subscribers, one user each. */
|
|
@@ -796,7 +802,7 @@ export function startCentral(options: CentralOptions): Central {
|
|
|
796
802
|
const delivery = new Delivery({
|
|
797
803
|
teams: teamPolicy,
|
|
798
804
|
// CON-04 inbound rule: only the user, app:concierge and replies to its asks reach a Concierge session.
|
|
799
|
-
inbound: conciergeInbound({ identity:
|
|
805
|
+
inbound: conciergeInbound({ identity: conciergeTimedIdentity, audit: { record: (userID, entry) => audit.record(userID, entry) } }),
|
|
800
806
|
state: backends.state,
|
|
801
807
|
counters: backends.counters,
|
|
802
808
|
registry,
|
|
@@ -814,6 +820,8 @@ export function startCentral(options: CentralOptions): Central {
|
|
|
814
820
|
},
|
|
815
821
|
// Push kind "message": a msg to @me, an ask left unanswered, a message loop (sent even while watched).
|
|
816
822
|
alert: (userID, alert) => pushUser("message", userID, alert.sessionID, `${alert.kind}:${alert.messageID}`),
|
|
823
|
+
// A message pending for 24 h expired undelivered: journaled (message.changed, delivery "expired") for its sender.
|
|
824
|
+
expired: (userID, message) => feed.publish(userID, message),
|
|
817
825
|
...(loopTickMs !== undefined ? { tickMs: loopTickMs } : {}),
|
|
818
826
|
// Cluster mode: the re-wake loop (leader) looks at every user; each pending flush holds a (user, session) lease.
|
|
819
827
|
...(config.cluster ? { users: allUsers, locks: backends.locks, owner: leaseOwner } : {}),
|
|
@@ -851,7 +859,8 @@ export function startCentral(options: CentralOptions): Central {
|
|
|
851
859
|
if (!conciergeTierHooks?.checkVia) throw new AgentError("forbidden", TAP_ONLY)
|
|
852
860
|
await conciergeTierHooks.checkVia(userID, decision, via, by)
|
|
853
861
|
},
|
|
854
|
-
|
|
862
|
+
// (FX-07) Time-limited and fail closed: a timed-out read refuses the ask with busy (no label silently dropped).
|
|
863
|
+
concierge: conciergeProtects(conciergeTimedIdentity),
|
|
855
864
|
...(loopTickMs !== undefined ? { tickMs: loopTickMs } : {}),
|
|
856
865
|
})
|
|
857
866
|
leader("decisions", { onElected: () => decisions.start(), onDeposed: () => decisions.stop() })
|
|
@@ -891,7 +900,8 @@ export function startCentral(options: CentralOptions): Central {
|
|
|
891
900
|
// Cluster mode: launch changes made on any pod reach this pod's agent watches. The console broadcast, closing a
|
|
892
901
|
// launch's connections and webhooks run only on the publishing pod (origin): they reach every pod themselves.
|
|
893
902
|
if (config.cluster) launchFeed.attach({ bus: backends.bus, podID: config.cluster.podID }).catch((cause) => console.error(`[hub] launch feed did not attach: ${(cause as Error).message}`))
|
|
894
|
-
|
|
903
|
+
// The user's live view: flagged when the launch's app token was revoked (launches is set below, before any change).
|
|
904
|
+
launchFeed.subscribe((userID, launch) => broadcast({ type: "launch", launch: launchInfo(launches.live(launch)) }, { tenant: userID }), { origin: true })
|
|
895
905
|
// Profile application (HWA-15), created with the profile store below; a launch naming a profile applies it.
|
|
896
906
|
let profileApplier: ProfileApplier | undefined
|
|
897
907
|
const launches = new LaunchEngine({
|
|
@@ -914,7 +924,15 @@ export function startCentral(options: CentralOptions): Central {
|
|
|
914
924
|
users: () => store.users().map((u) => u.id),
|
|
915
925
|
now,
|
|
916
926
|
onLaunchChanged: (launch) => launchFeed.publish(launch.owner, launch),
|
|
927
|
+
// FX-02: a waiting text change of the same kind: the console's live stream (and the Concierge's status) only,
|
|
928
|
+
// never the journal or webhooks.
|
|
929
|
+
onLaunchWaiting: (launch) => {
|
|
930
|
+
broadcast({ type: "launch", launch: launchInfo(launches.live(launch)) }, { tenant: launch.owner })
|
|
931
|
+
if (isConciergeLaunch(launch)) conciergeSupervisor.kick(launch.owner)
|
|
932
|
+
},
|
|
917
933
|
intents,
|
|
934
|
+
// FX-02: a launch of a revoked app token (`app:<16 hex>` no longer among the user's tokens) is flagged, not stopped.
|
|
935
|
+
launchers: { revoked: (userID, by) => /^app:[0-9a-f]{16}$/.test(by) && !(store.user(userID)?.agent?.appTokens ?? []).some((t) => `app:${t.id}` === by) },
|
|
918
936
|
profiles: { applyAtLaunch: async (userID, sessionID, ref) => (profileApplier ? profileApplier.applyAtLaunch(userID, sessionID, ref) : [`profile ${ref.profileID} v${ref.version} not applied: the hub is starting`]) },
|
|
919
937
|
// Cluster mode: the supervisor runs on the launches leader; other pods kick it and send it their stops.
|
|
920
938
|
...(config.cluster
|
|
@@ -1055,7 +1073,7 @@ export function startCentral(options: CentralOptions): Central {
|
|
|
1055
1073
|
// One credential resolver for every /api request: device cookie, device token, session and app tokens.
|
|
1056
1074
|
// Every stored agent message also goes to the user's console streams (live transcript rows).
|
|
1057
1075
|
feed.subscribe((userID, message) => broadcast({ type: "message", message }, { tenant: userID }), { origin: true })
|
|
1058
|
-
const audit = new AgentAudit({ state: backends.state, now, admin: store, recorded: (userID, entry) => broadcast({ type: "agent", entry }, { tenant: userID }), concierge: (userID, sessionID) => isConciergeSession(
|
|
1076
|
+
const audit = new AgentAudit({ state: backends.state, now, admin: store, recorded: (userID, entry) => broadcast({ type: "agent", entry }, { tenant: userID }), concierge: (userID, sessionID) => isConciergeSession(conciergeTimedIdentity, userID, sessionID) })
|
|
1059
1077
|
const grants = new Grants({ state: backends.state, counters: backends.counters, directory, store, admin: store, now, concierge: async (owner) => (await conciergeIdentity.current(owner)).hiddenSessionIDs })
|
|
1060
1078
|
const acting = new ActingSwitch({ store, never: config.agentsNever, audit })
|
|
1061
1079
|
/** An agent as envelopes name it (a session by its directory name). */
|
|
@@ -1114,11 +1132,11 @@ export function startCentral(options: CentralOptions): Central {
|
|
|
1114
1132
|
const workspaces = { workspaces: workspaceStore, members: workspaceMembers, supervision: workspaceSupervision, lifecycle: workspaceLifecycle }
|
|
1115
1133
|
// Team staffing (HWA-35): seats end on session deletion; launched members are staffed when their launch runs.
|
|
1116
1134
|
const sessionDirectory = (userID: string, sessionID: string) => (registry.sessionTenant(sessionID) === userID ? registry.session(sessionID)?.directory : undefined)
|
|
1117
|
-
const teamStaffing = new TeamStaffingService({ state: backends.state, teams: teamStore, seats: teamSeats, workspaces: workspaceStore, members: workspaceMembers, supervision: workspaceSupervision, presence: teamPresence, sessionDirectory, launches, grants, acting, audit, profiles, events: agentEvents, now })
|
|
1135
|
+
const teamStaffing = new TeamStaffingService({ state: backends.state, teams: teamStore, seats: teamSeats, workspaces: workspaceStore, members: workspaceMembers, supervision: workspaceSupervision, presence: teamPresence, sessionDirectory, launches, grants, acting, audit, profiles, events: agentEvents, now, protects: conciergeProtects(conciergeTimedIdentity) })
|
|
1118
1136
|
registry.onSessionDeleted((sessionID, tenant) => void teamStaffing.sessionEnded(tenant, sessionID).catch((cause) => console.error(`[hub] team session end failed: ${(cause as Error).message}`)))
|
|
1119
1137
|
launchFeed.subscribe((userID, launch) => void teamStaffing.launchChanged(userID, launch).catch((cause) => console.error(`[hub] team staffing launch failed: ${(cause as Error).message}`)), { origin: true })
|
|
1120
1138
|
// Team intake (HWA-36): delivered to an entry seat from the fixed party app:team-intake (never the submitter).
|
|
1121
|
-
teamIntake = new TeamIntakeService({ state: backends.state, teams: teamStore, seats: teamSeats, staffing: teamStaffing, members: workspaceMembers, presence: teamPresence, events: agentEvents, now, deliver: async (userID, sessionID, text) => ({ messageID: (await delivery.send(userID, intakeActor(userID), sessionID, "msg", text)).message.id }) })
|
|
1139
|
+
teamIntake = new TeamIntakeService({ state: backends.state, teams: teamStore, seats: teamSeats, staffing: teamStaffing, members: workspaceMembers, presence: teamPresence, events: agentEvents, now, deliver: async (userID, sessionID, text, options = {}) => ({ messageID: (await delivery.send(userID, intakeActor(userID), sessionID, "msg", text, options.hop === undefined ? {} : { chainHop: options.hop })).message.id }), hopOf: (userID, sessionID) => delivery.hops.next(userID, { kind: "session", sessionID }) })
|
|
1122
1140
|
// Supervisor checkpoints and batched digests (HWA-11): acknowledgments flush before answering; digests reach
|
|
1123
1141
|
// supervisor sessions through Delivery (as coordinatorActor) from the workspace-coordinator leader.
|
|
1124
1142
|
const workspaceCursors = journalCursors(tokenKeys, agentEvents)
|
|
@@ -1278,7 +1296,7 @@ export function startCentral(options: CentralOptions): Central {
|
|
|
1278
1296
|
state: backends.state,
|
|
1279
1297
|
counters: backends.counters,
|
|
1280
1298
|
decisions,
|
|
1281
|
-
acting: (conciergeActing = new Acting({ registry, directory, actions, spokes: { request: (connID, req) => spokes.request(connID, req) }, grants, acting, audit, escalate, maxPromptChars: MAX_PROMPT_CHARS, protect: conciergeProtects(
|
|
1299
|
+
acting: (conciergeActing = new Acting({ registry, directory, actions, spokes: { request: (connID, req) => spokes.request(connID, req) }, grants, acting, audit, escalate, maxPromptChars: MAX_PROMPT_CHARS, protect: conciergeProtects(conciergeTimedIdentity), conciergeActor: conciergeProtects(conciergeTimedIdentity) })),
|
|
1282
1300
|
audit,
|
|
1283
1301
|
protects: conciergeProtects(conciergeIdentity),
|
|
1284
1302
|
published: (userID, message) => feed.publish(userID, message),
|
|
@@ -1365,7 +1383,7 @@ export function startCentral(options: CentralOptions): Central {
|
|
|
1365
1383
|
onDeposed: () => (conciergeSupervisor.stop(), conciergeParts.notifier.stop()),
|
|
1366
1384
|
})
|
|
1367
1385
|
void conciergeLeader.start()
|
|
1368
|
-
const agentDeps = { intents, teamDiscovery, draining: drainSignal.signal, store, sessionTokens, directory, registry, spokes, delivery, decisions, feed, heartbeatMs, now, audit, grants, grantRequests, acting, actions, escalate, launches, webhooks, agentEvents, eventCursorKeys: tokenKeys, maxPromptChars: MAX_PROMPT_CHARS, profiles, workspaces, workspaceCheckpoints, teamIntake, graph: { teams: { teams: teamStore, seats: teamSeats! } }, teams: { teams: teamStore, seats: teamSeats!, staffing: teamStaffing }, artifacts: { ...(artifacts && backends.blobs ? { artifacts, blobs: backends.blobs } : {}), state: backends.state, now }, artifactHost: { caps: artifactHostCaps, ...(artifacts ? { artifacts } : {}) }, concierge: { state: backends.state, activity: conciergeActivity, results: conciergeResults, identity:
|
|
1386
|
+
const agentDeps = { intents, teamDiscovery, draining: drainSignal.signal, store, sessionTokens, directory, registry, spokes, delivery, decisions, feed, heartbeatMs, now, audit, grants, grantRequests, acting, actions, escalate, launches, webhooks, agentEvents, eventCursorKeys: tokenKeys, maxPromptChars: MAX_PROMPT_CHARS, profiles, workspaces, workspaceCheckpoints, teamIntake, graph: { teams: { teams: teamStore, seats: teamSeats! } }, teams: { teams: teamStore, seats: teamSeats!, staffing: teamStaffing }, artifacts: { ...(artifacts && backends.blobs ? { artifacts, blobs: backends.blobs } : {}), state: backends.state, now }, artifactHost: { caps: artifactHostCaps, ...(artifacts ? { artifacts } : {}) }, concierge: { state: backends.state, activity: conciergeActivity, results: conciergeResults, identity: conciergeTimedIdentity, protects: conciergeProtects(conciergeTimedIdentity), service: conciergeService, models: machineModels({ serviceFor: (machineID) => registry.serviceFor(machineID), reportedSessions: (connID) => registry.reportedSessions(connID), request: (connID, req) => spokes.request(connID, req) }), routes: () => conciergeParts.tiers.routes(), armed: conciergeArmed, timeZone: conciergeBoardDeps.timeZone, coverage: conciergeCoverage } }
|
|
1369
1387
|
const resolveActor = createResolver({
|
|
1370
1388
|
cookieName: COOKIE,
|
|
1371
1389
|
findDevice: (token) => deviceOfToken(token),
|
|
@@ -1383,7 +1401,7 @@ export function startCentral(options: CentralOptions): Central {
|
|
|
1383
1401
|
},
|
|
1384
1402
|
routes: [...agentRoutes(agentDeps), ...(options.agent?.routes ?? [])],
|
|
1385
1403
|
// CON-04: a Concierge session's tokens reach only an explicit route allow-list (fail closed).
|
|
1386
|
-
gate: conciergeSessionGate(
|
|
1404
|
+
gate: conciergeSessionGate(conciergeTimedIdentity, () => conciergeParts.tiers.routes()),
|
|
1387
1405
|
})
|
|
1388
1406
|
|
|
1389
1407
|
// POST /mcp (MCP Streamable HTTP): `hub mcp`'s tools as the bearer's caller; their /api/agent calls are dispatched
|
|
@@ -1401,11 +1419,12 @@ export function startCentral(options: CentralOptions): Central {
|
|
|
1401
1419
|
const { pathname } = url
|
|
1402
1420
|
const method = request.method
|
|
1403
1421
|
try {
|
|
1404
|
-
|
|
1405
|
-
if (
|
|
1406
|
-
|
|
1407
|
-
return
|
|
1422
|
+
// HEAD (uptime monitors, `curl -I`) gets GET's status and headers without a body, on the health routes only.
|
|
1423
|
+
if ((method === "GET" || method === "HEAD") && (pathname === "/readyz" || pathname === "/healthz")) {
|
|
1424
|
+
const response = pathname === "/readyz" ? await readyz() : healthz()
|
|
1425
|
+
return method === "HEAD" ? new Response(null, { status: response.status, headers: response.headers }) : response
|
|
1408
1426
|
}
|
|
1427
|
+
if (pathname === "/spoke/ws") return spokes.upgrade(request, server)
|
|
1409
1428
|
// A shared (cluster) store loads its records on first use; the file store is ready at once.
|
|
1410
1429
|
await store.ready()
|
|
1411
1430
|
if (method === "POST" && pathname === "/spoke/enroll") {
|
|
@@ -2472,6 +2491,10 @@ export function startCentral(options: CentralOptions): Central {
|
|
|
2472
2491
|
}
|
|
2473
2492
|
|
|
2474
2493
|
/** /readyz (design §10): 503 while draining or (cluster mode) when the database or Valkey does not answer in time. */
|
|
2494
|
+
function healthz(): Response {
|
|
2495
|
+
return Response.json({ ok: true, version: options.version ?? "0.0.0", spokes: registry.connections(), nodes: nodes.count() })
|
|
2496
|
+
}
|
|
2497
|
+
|
|
2475
2498
|
async function readyz(): Promise<Response> {
|
|
2476
2499
|
if (draining) return Response.json({ ok: false, draining: true }, { status: 503, headers: NO_STORE })
|
|
2477
2500
|
if (config.cluster) {
|
package/src/central/main.ts
CHANGED
|
@@ -68,7 +68,7 @@ Usage: hub-server [--help | --version] (also installed as opencode-hub and ope
|
|
|
68
68
|
Configuration: environment variables over ~/.config/hub/hub.json ($HUB_CONFIG).
|
|
69
69
|
HUB_HOST / HUB_PORT listen address (default 127.0.0.1 / 4097)
|
|
70
70
|
HUB_PUBLIC_URL origin used in links and cookies (default http://<host>:<port>)
|
|
71
|
-
HUB_STATE_DIR machines, devices, keys (default ~/.local/state/hub)
|
|
71
|
+
HUB_STATE_DIR machines, devices, keys (default $XDG_STATE_HOME/hub or ~/.local/state/hub)
|
|
72
72
|
OPENROUTER_API_KEY default speech key; or set speech providers in the web app (Settings → Speech)
|
|
73
73
|
HUB_CONFIG path of hub.json
|
|
74
74
|
|
package/src/central/registry.ts
CHANGED
|
@@ -253,6 +253,8 @@ export interface CatalogProcess {
|
|
|
253
253
|
readonly connected: number
|
|
254
254
|
readonly lastSeen: number
|
|
255
255
|
readonly sessions: CatalogSession[]
|
|
256
|
+
/** (FX-07) Connected, but its first sessions frame has not come yet: an empty `sessions` says nothing then. */
|
|
257
|
+
readonly sessionsPending?: true
|
|
256
258
|
}
|
|
257
259
|
|
|
258
260
|
/** A machine's terminal context: its Herdr workspaces (latest terminals frame of the machine). */
|
|
@@ -279,6 +281,8 @@ interface Conn {
|
|
|
279
281
|
sessions: Map<string, SessionInfo>
|
|
280
282
|
/** The last sessions frame as JSON, to skip unchanged snapshots. */
|
|
281
283
|
sessionsKey: string
|
|
284
|
+
/** (FX-07) No sessions frame yet. */
|
|
285
|
+
sessionsPending?: true
|
|
282
286
|
/** sessionID → activity sequence number of the last event this process sent for it. */
|
|
283
287
|
activity: Map<string, number>
|
|
284
288
|
/**
|
|
@@ -304,6 +308,8 @@ export interface SliceConn {
|
|
|
304
308
|
readonly launchID?: string
|
|
305
309
|
/** The machine's terminal context, when this connection sent the latest one. */
|
|
306
310
|
readonly terminals?: MachineTerminals
|
|
311
|
+
/** (FX-07) No sessions frame yet (absent from older pods: reported). */
|
|
312
|
+
readonly sessionsPending?: true
|
|
307
313
|
}
|
|
308
314
|
|
|
309
315
|
/** A root session's status tree (live: run starts and recommendations included) and activity feed. */
|
|
@@ -598,6 +604,7 @@ export class Registry {
|
|
|
598
604
|
lastSeen: t,
|
|
599
605
|
sessions: new Map(),
|
|
600
606
|
sessionsKey: "[]",
|
|
607
|
+
sessionsPending: true,
|
|
601
608
|
activity: new Map(),
|
|
602
609
|
})
|
|
603
610
|
this.changed(this.tenant(this.conns.get(connID)!))
|
|
@@ -640,7 +647,9 @@ export class Registry {
|
|
|
640
647
|
conn.lastSeen = this.now()
|
|
641
648
|
// Spokes re-send their snapshot periodically; only changes reach the phones.
|
|
642
649
|
const key = JSON.stringify(sessions)
|
|
643
|
-
|
|
650
|
+
const first = conn.sessionsPending === true
|
|
651
|
+
delete conn.sessionsPending
|
|
652
|
+
if (key !== conn.sessionsKey || first) {
|
|
644
653
|
conn.sessionsKey = key
|
|
645
654
|
conn.sessions = new Map(sessions.map((s) => [s.id, s]))
|
|
646
655
|
this.syncBoards()
|
|
@@ -1232,6 +1241,7 @@ export class Registry {
|
|
|
1232
1241
|
features: [...conn.features].sort(),
|
|
1233
1242
|
connected: conn.connected,
|
|
1234
1243
|
lastSeen: conn.lastSeen,
|
|
1244
|
+
...(conn.sessionsPending ? { sessionsPending: true as const } : {}),
|
|
1235
1245
|
sessions: this.visible(conn).map((s) => {
|
|
1236
1246
|
const fields = this.browserFields(s.id)
|
|
1237
1247
|
return {
|
|
@@ -1249,6 +1259,19 @@ export class Registry {
|
|
|
1249
1259
|
return [...machines.values()]
|
|
1250
1260
|
}
|
|
1251
1261
|
|
|
1262
|
+
/**
|
|
1263
|
+
* (FX-07) The tenant's sessions a spoke reports locked now (the Concierge's: `locked`, from the first frame after a
|
|
1264
|
+
* restart, and `agent` / `contextRevision`, sent only for locked sessions), on this pod's connections and other pods'.
|
|
1265
|
+
*/
|
|
1266
|
+
lockedSessions(tenant: string): string[] {
|
|
1267
|
+
const out = new Set<string>()
|
|
1268
|
+
for (const conn of this.all()) {
|
|
1269
|
+
if (this.tenant(conn) !== tenant) continue
|
|
1270
|
+
for (const s of conn.sessions.values()) if (s.locked === true || s.agent !== undefined || s.contextRevision !== undefined) out.add(s.id)
|
|
1271
|
+
}
|
|
1272
|
+
return [...out]
|
|
1273
|
+
}
|
|
1274
|
+
|
|
1252
1275
|
/** The session as reported by any process plus its live status; undefined when nobody reports it. */
|
|
1253
1276
|
session(sessionID: string): SessionView | undefined {
|
|
1254
1277
|
let info: SessionInfo | undefined
|
|
@@ -1893,6 +1916,7 @@ export class Registry {
|
|
|
1893
1916
|
activity: [...conn.activity],
|
|
1894
1917
|
...(conn.launchID !== undefined ? { launchID: conn.launchID } : {}),
|
|
1895
1918
|
...(terminals?.connID === conn.id ? { terminals: terminals.terminals } : {}),
|
|
1919
|
+
...(conn.sessionsPending ? { sessionsPending: true as const } : {}),
|
|
1896
1920
|
}
|
|
1897
1921
|
})
|
|
1898
1922
|
const boards: SliceBoard[] = [...this.boards]
|
|
@@ -1966,6 +1990,7 @@ export class Registry {
|
|
|
1966
1990
|
sessionsKey: "",
|
|
1967
1991
|
activity: new Map(c.activity ?? []),
|
|
1968
1992
|
...(c.launchID !== undefined ? { launchID: c.launchID } : {}),
|
|
1993
|
+
...(c.sessionsPending === true ? { sessionsPending: true as const } : {}),
|
|
1969
1994
|
remote: { pod, tenant },
|
|
1970
1995
|
})
|
|
1971
1996
|
if (c.terminals) terminals.set(c.machine.id, c.terminals)
|