@staix/agent-hub 0.32.0 → 0.34.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 +33 -0
- package/README.md +2 -2
- package/docs/design/bus.md +68 -2
- package/docs/dogfood.md +3 -1
- package/docs/memory.md +9 -3
- package/docs/operations.md +124 -20
- package/docs/quickstart.md +32 -2
- package/docs/security.md +28 -9
- package/docs/specs/2026-09-19-agent-hub-design.md +36 -6
- package/docs/specs/2026-09-20-upgrade-recovery-design.md +7 -3
- package/docs/verification/2026-11-06-0.33.0.md +222 -0
- package/docs/verification/2026-11-06-claude-implementer-2.1.319.md +57 -0
- package/docs/verification/2026-11-06-codex-0167-reviewer-isolation.md +56 -0
- package/docs/verification/2026-11-06-codex-0167-reviewer-tool-call.md +66 -0
- package/docs/verification/2026-11-06-kimi-251-reviewer-compat.md +88 -0
- package/docs/verification/2026-11-06-orca-1.4.250-cli-contract.md +50 -0
- package/docs/verification/2026-11-06-qwen-0268-acp.md +75 -0
- package/docs/verification/2026-11-06-qwen-0268-dual-output.md +44 -0
- package/docs/verification/2026-11-07-0.34.0.md +71 -0
- package/docs/verification/2026-11-07-compat-receipt-audit-1248.md +256 -0
- package/docs/verification/claude-shell-wrapper-native.py +1 -1
- package/docs/verified.json +11 -11
- package/package.json +2 -2
- package/plugins/agent-hub/.claude-plugin/plugin.json +1 -1
- package/plugins/agent-hub/server.js +528 -206
- package/src/adapters/compat.ts +11 -11
- package/src/adapters/definitions/native-cli-claude-setup.ts +14 -5
- package/src/broker/protocol.ts +5 -0
- package/src/broker/server.ts +54 -4
- package/src/broker/store.ts +6 -3
- package/src/cli/autonomy-homes.ts +12 -3
- package/src/cli/claude-homes.ts +5 -5
- package/src/cli/claude-profile-hook.ts +38 -9
- package/src/cli/install-paths.ts +71 -0
- package/src/cli/main.ts +20 -1
- package/src/cli/output.ts +5 -1
- package/src/cli/room.ts +17 -5
- package/src/cli/runtime-version-warning.ts +8 -6
- package/src/cli/setup-homes.ts +13 -10
- package/src/cli/status-lines.ts +8 -1
- package/src/cli/terminal-recovery.ts +96 -18
- package/src/cli/upgrade-interactive.ts +29 -6
- package/src/cli/upgrade-output.ts +3 -2
- package/src/cli/upgrade-runtime.ts +34 -4
- package/src/cli/upgrade.ts +3 -1
- package/src/hub/bus.ts +7 -0
- package/src/hub/daemon.ts +13 -4
- package/src/hub/handoff.ts +1 -1
- package/src/rooms/application.ts +15 -0
- package/src/rooms/commands.ts +76 -16
- package/src/rooms/health.ts +32 -0
- package/src/rooms/link.ts +267 -43
- package/src/rooms/remote-peer.ts +18 -3
- package/src/rooms/service.ts +27 -12
- package/src/rooms/status.ts +57 -1
- package/src/rooms/store.ts +20 -1
- package/src/rooms/wire.ts +10 -8
- package/src/ui/index.html +1 -1
package/src/hub/bus.ts
CHANGED
|
@@ -266,6 +266,8 @@ export class Bus {
|
|
|
266
266
|
private condensing = 0;
|
|
267
267
|
private closed = false;
|
|
268
268
|
storageError?: string;
|
|
269
|
+
/** Verified durable-write recovery edge; observation never changes settlement. */
|
|
270
|
+
onStorageRecovered?: () => void;
|
|
269
271
|
|
|
270
272
|
constructor(opts: Partial<BusOptions> = {}) {
|
|
271
273
|
this.opts = { ...DEFAULT_BUS, ...opts };
|
|
@@ -423,6 +425,7 @@ export class Bus {
|
|
|
423
425
|
private persist(): void {
|
|
424
426
|
if (!this.journal) return;
|
|
425
427
|
const tracking = this.trackingQueuePressure();
|
|
428
|
+
const wasUnavailable = this.storageError !== undefined;
|
|
426
429
|
try {
|
|
427
430
|
this.journal.transaction(() => {
|
|
428
431
|
for (const outcome of this.pendingOutcomes) this.journal!.createDelivery(outcome);
|
|
@@ -430,6 +433,10 @@ export class Bus {
|
|
|
430
433
|
});
|
|
431
434
|
this.pendingOutcomes.length = 0;
|
|
432
435
|
this.storageError = undefined;
|
|
436
|
+
if (wasUnavailable) queueMicrotask(() => {
|
|
437
|
+
if (this.closed || this.storageError) return;
|
|
438
|
+
try { this.onStorageRecovered?.(); } catch { /* Recovery observers cannot undo the durable write. */ }
|
|
439
|
+
});
|
|
433
440
|
}
|
|
434
441
|
catch { this.storageError = "delivery journal unavailable"; throw new Error("delivery journal unavailable"); }
|
|
435
442
|
}
|
package/src/hub/daemon.ts
CHANGED
|
@@ -5693,7 +5693,7 @@ export async function startDaemon(opts: DaemonOptions) {
|
|
|
5693
5693
|
screen: opts.piiScreen ?? (text => screenPii(text, { ...(config.mlx.enabled === false ? {} : { device: () => ensureMlx(config.mlx) }), onCampus, omni, fixedModel: () => activeRouting().local.fixed_model })),
|
|
5694
5694
|
policyVersion: () => configRevision,
|
|
5695
5695
|
limits: () => config.limits,
|
|
5696
|
-
notice: value => notify(
|
|
5696
|
+
notice: value => notify(`room ${value.room}: application capacity reached (${value.count}); resolve incoming uncertainty with ahub room resolve`),
|
|
5697
5697
|
invocationAuthorization: (env, identity) => roomInvocations.authorization(env, identity), invocation: input => roomInvocations.receive(input),
|
|
5698
5698
|
roster: ids => { remoteRoster.clear(); for (const id of ids) remoteRoster.add(id); },
|
|
5699
5699
|
changed: () => { if (ready && !stopping) writeStatus(); },
|
|
@@ -5702,9 +5702,14 @@ export async function startDaemon(opts: DaemonOptions) {
|
|
|
5702
5702
|
remoteQueueChanged = () => roomChat.queueChanged();
|
|
5703
5703
|
const roomCommands = new RoomCommands({ stateDir: opts.stateDir, cwd: opts.cwd,
|
|
5704
5704
|
presence: () => [...bus.peers].filter(([id]) => !id.includes("@") && membershipAllowed(id)).map(([id, peer]) => ({ id, state: peer.state })), chat: roomChat,
|
|
5705
|
+
changed: () => { if (ready && !stopping) writeStatus(); },
|
|
5705
5706
|
});
|
|
5706
5707
|
// Room crypto/state failure disables only this optional outbound service, never local work.
|
|
5707
|
-
void
|
|
5708
|
+
bus.onStorageRecovered = () => { if (!stopping) void roomChat.retryPending().catch(() => undefined); };
|
|
5709
|
+
void roomCommands.start().catch(() => {
|
|
5710
|
+
const failure = roomCommands.unavailable();
|
|
5711
|
+
log(`rooms unavailable (${failure?.code ?? "unavailable"}); ${failure?.nextStep ?? "check room storage and restart the hub"}`);
|
|
5712
|
+
});
|
|
5708
5713
|
let roomInboxExpiryFailed = false;
|
|
5709
5714
|
const roomInboxExpiry = setInterval(() => {
|
|
5710
5715
|
if (stopping || recoveryActive() || bus.isRecoveryHeld) return;
|
|
@@ -6021,7 +6026,7 @@ export async function startDaemon(opts: DaemonOptions) {
|
|
|
6021
6026
|
...(crashLines.length ? { crash: crashLines } : {}),
|
|
6022
6027
|
projectId,
|
|
6023
6028
|
instanceId,
|
|
6024
|
-
|
|
6029
|
+
...roomCommands.statusSnapshot(),
|
|
6025
6030
|
version: VERSION,
|
|
6026
6031
|
pluginBundle: SERVER_JS,
|
|
6027
6032
|
pendingRestart: allPendingRestart(),
|
|
@@ -10794,7 +10799,11 @@ export async function startDaemon(opts: DaemonOptions) {
|
|
|
10794
10799
|
if (msg.op === "list" || msg.op === "status") {
|
|
10795
10800
|
if (msg.input !== undefined && (!msg.input || typeof msg.input !== "object" || Array.isArray(msg.input)
|
|
10796
10801
|
|| Object.keys(msg.input).some(key => key !== "name") || (msg.input.name !== undefined && typeof msg.input.name !== "string"))) return roomFailure();
|
|
10797
|
-
void roomCommands.rows(msg.input?.name, msg.op === "status").then(rooms => reply({ ok: true, rooms }),
|
|
10802
|
+
void roomCommands.rows(msg.input?.name, msg.op === "status").then(rooms => reply({ ok: true, rooms }), error => {
|
|
10803
|
+
const roomsUnavailable = roomCommands.unavailable();
|
|
10804
|
+
if (roomsUnavailable) reply({ ok: false, code: "rooms_unavailable", roomsUnavailable });
|
|
10805
|
+
else roomFailure(error);
|
|
10806
|
+
});
|
|
10798
10807
|
return;
|
|
10799
10808
|
}
|
|
10800
10809
|
if (["inbox_list", "inbox_show", "inbox_done", "inbox_discard"].includes(msg.op)) {
|
package/src/hub/handoff.ts
CHANGED
|
@@ -130,7 +130,7 @@ export function resolveTarget(name: string, projects: readonly RegisteredProject
|
|
|
130
130
|
export function handoffConnectFailure(error: unknown, target: { id: string; root: string }): { code: HandoffRefusalCode; reach: "unreachable"; cause: string; message: string } {
|
|
131
131
|
const code = typeof (error as { code?: unknown })?.code === "number" || typeof (error as { code?: unknown })?.code === "string" ? (error as { code: number | string }).code : undefined;
|
|
132
132
|
if (code === 4426) return { code: "older_target", reach: "unreachable", cause: "wire version differs",
|
|
133
|
-
message: `the hub of ${target.id} speaks another wire version than this one: upgrade whichever is older (\`ahub upgrade\` in ${target.root}, and the global CLI
|
|
133
|
+
message: `the hub of ${target.id} speaks another wire version than this one: upgrade whichever is older (\`ahub upgrade\` in ${target.root}, and the global CLI the way it was installed; \`ahub doctor\` names the exact step), then hand the task off again` };
|
|
134
134
|
if (code === 4404) return { code: "mismatch", reach: "unreachable", cause: "another hub or instance answered",
|
|
135
135
|
message: `the hub answering for ${target.root} is not the instance this project's registry names (it was restarted, or another project took its port): run \`ahub projects\` to see what is registered, and \`ahub up\` in ${target.root} if it is not running` };
|
|
136
136
|
if (code === "AHUB_CONNECT_TIMEOUT") return { code: "not_running", reach: "unreachable", cause: "no answer in time",
|
package/src/rooms/application.ts
CHANGED
|
@@ -4,6 +4,21 @@ import { decodeBase64url } from "./crypto.ts";
|
|
|
4
4
|
import { canonicalBytes, ROOM_WIRE_LIMITS } from "./wire.ts";
|
|
5
5
|
|
|
6
6
|
export const ROOM_APPLICATION_MAX_BYTES = ROOM_WIRE_LIMITS.plaintext;
|
|
7
|
+
/** Compatibility evidence only: never a permission, privacy or capability grant. */
|
|
8
|
+
export const ROOM_CODEC_FEATURES = ["chat_v1", "person_inbox_v1", "read_invocation_v1"] as const;
|
|
9
|
+
export type RoomCodecFeature = typeof ROOM_CODEC_FEATURES[number];
|
|
10
|
+
export const ROOM_CODEC_FEATURE_LIMIT = 16, ROOM_CODEC_FEATURE_NAME_LIMIT = 64;
|
|
11
|
+
export function roomCodecFeatures(value: unknown): readonly string[] {
|
|
12
|
+
if (!Array.isArray(value) || value.length > ROOM_CODEC_FEATURE_LIMIT
|
|
13
|
+
|| value.some((item, index) => typeof item !== "string" || item.length > ROOM_CODEC_FEATURE_NAME_LIMIT
|
|
14
|
+
|| !/^[a-z][a-z0-9_]*$/.test(item) || index > 0 && value[index - 1] >= item)) fail();
|
|
15
|
+
return [...value] as string[];
|
|
16
|
+
}
|
|
17
|
+
export function roomRequiredCodecFeature(message: RoomMessage): RoomCodecFeature | undefined {
|
|
18
|
+
if (message.type !== "chat") return undefined;
|
|
19
|
+
if (message.envelope.kind === "invocation" || message.envelope.kind === "invocation_result") return "read_invocation_v1";
|
|
20
|
+
return message.envelope.to === "user" ? "person_inbox_v1" : undefined;
|
|
21
|
+
}
|
|
7
22
|
export interface RoomChatEnvelope {
|
|
8
23
|
id: string; trace: string; hop: number; from: string; to: string;
|
|
9
24
|
kind: "chat" | "status" | "invocation" | "invocation_result"; priority: Priority; body: string; ts: number;
|
package/src/rooms/commands.ts
CHANGED
|
@@ -7,6 +7,7 @@ import { RoomStore, type RoomState, type RoomPersonInboxMeta, type RoomPersonInb
|
|
|
7
7
|
import { RoomLink } from "./link.ts";
|
|
8
8
|
import { exposedPeers, roomName, RoomPolicy } from "./policy.ts";
|
|
9
9
|
import type { RoomChat } from "./service.ts";
|
|
10
|
+
import { roomHealthCause, roomHealthNextStep, type RoomHealthCause } from "./health.ts";
|
|
10
11
|
import { projectRoomStatus, type RoomStatusView } from "./status.ts";
|
|
11
12
|
|
|
12
13
|
export interface RoomCommandResult { kind?: string; artifact?: string; name?: string; room?: string; owner?: string; member?: string }
|
|
@@ -50,26 +51,64 @@ export class RoomCommands {
|
|
|
50
51
|
private pending?: Promise<RoomStore>;
|
|
51
52
|
private store?: RoomStore;
|
|
52
53
|
private stopped = false;
|
|
54
|
+
private openFailure?: RoomHealthCause;
|
|
55
|
+
private openRetry?: ReturnType<typeof setTimeout>;
|
|
56
|
+
private openAttempts = 0;
|
|
53
57
|
private stopWork?: Promise<void>;
|
|
54
58
|
private readonly links = new Map<string, RoomLink>();
|
|
55
59
|
private readonly policy: RoomPolicy;
|
|
56
|
-
constructor(private readonly options: { stateDir: string; cwd: string; clock?: () => number; refreshMs?: number; presence?: () => readonly {id: string; state: string}[]; chat?: RoomChat }) { this.policy = new RoomPolicy(options.cwd); }
|
|
60
|
+
constructor(private readonly options: { stateDir: string; cwd: string; clock?: () => number; refreshMs?: number; presence?: () => readonly {id: string; state: string}[]; chat?: RoomChat; openStore?: typeof RoomStore.open; retryMs?: number; changed?: () => void }) { this.policy = new RoomPolicy(options.cwd); }
|
|
61
|
+
private changedAvailability(): void {
|
|
62
|
+
try { this.options.changed?.(); } catch { /* Diagnostics cannot change store admission. */ }
|
|
63
|
+
}
|
|
64
|
+
private retryOpen(): void {
|
|
65
|
+
if (this.stopped || this.openRetry) return;
|
|
66
|
+
const delay = Math.min(30_000, (this.options.retryMs ?? 1000) * 2 ** Math.min(this.openAttempts++, 5));
|
|
67
|
+
this.openRetry = setTimeout(() => {
|
|
68
|
+
this.openRetry = undefined;
|
|
69
|
+
if (!this.stopped) void this.start().catch(() => undefined);
|
|
70
|
+
}, delay);
|
|
71
|
+
this.openRetry.unref?.();
|
|
72
|
+
}
|
|
57
73
|
private async open(): Promise<RoomStore> {
|
|
58
74
|
if (this.stopped) refuse("stopped");
|
|
59
|
-
|
|
75
|
+
if (this.store) return this.store;
|
|
76
|
+
if (this.openRetry) throw new Error(`rooms unavailable (${this.openFailure ?? "unavailable"}); ${roomHealthNextStep(this.openFailure ?? "unavailable")}`);
|
|
77
|
+
this.pending ??= (this.options.openStore ?? RoomStore.open)({ stateDir: join(this.options.stateDir, "rooms"), ...(this.options.clock ? { clock: this.options.clock } : {}) }).then(store => {
|
|
60
78
|
if (this.stopped) { store.close(); return refuse("stopped"); }
|
|
61
|
-
this.store = store;
|
|
79
|
+
this.store = store; this.openFailure = undefined; this.openAttempts = 0;
|
|
80
|
+
this.changedAvailability(); return store;
|
|
81
|
+
}).catch(error => {
|
|
82
|
+
this.pending = undefined;
|
|
83
|
+
if (!this.stopped) {
|
|
84
|
+
const cause = roomHealthCause(error), changed = this.openFailure !== cause;
|
|
85
|
+
this.openFailure = cause; this.retryOpen();
|
|
86
|
+
if (changed) this.changedAvailability();
|
|
87
|
+
}
|
|
88
|
+
throw error;
|
|
62
89
|
});
|
|
63
90
|
return this.pending;
|
|
64
91
|
}
|
|
65
92
|
async start(): Promise<void> {
|
|
66
|
-
if (!existsSync(join(this.options.stateDir, "rooms"))) return;
|
|
67
|
-
|
|
68
|
-
|
|
93
|
+
if (this.stopped || !existsSync(join(this.options.stateDir, "rooms"))) return;
|
|
94
|
+
try {
|
|
95
|
+
const store = await this.open();
|
|
96
|
+
if (!this.stopped) for (const state of store.list()) if (!state.left) this.link(state.name);
|
|
97
|
+
const recovered = this.openFailure !== undefined;
|
|
98
|
+
this.openFailure = undefined; this.openAttempts = 0;
|
|
99
|
+
if (recovered) this.changedAvailability();
|
|
100
|
+
} catch (error) {
|
|
101
|
+
if (!this.stopped && !this.openRetry) {
|
|
102
|
+
const cause = roomHealthCause(error), changed = this.openFailure !== cause;
|
|
103
|
+
this.openFailure = cause; this.retryOpen();
|
|
104
|
+
if (changed) this.changedAvailability();
|
|
105
|
+
}
|
|
106
|
+
throw error;
|
|
107
|
+
}
|
|
69
108
|
}
|
|
70
109
|
stop(): Promise<void> {
|
|
71
110
|
if (this.stopWork) return this.stopWork;
|
|
72
|
-
this.stopped = true; clearTimeout(this.presenceTimer); this.options.chat?.stop();
|
|
111
|
+
this.stopped = true; clearTimeout(this.presenceTimer); clearTimeout(this.openRetry); this.options.chat?.stop();
|
|
73
112
|
const stops = [...this.links.values()].map(link => link.stop()); this.links.clear();
|
|
74
113
|
this.stopWork = Promise.allSettled([...(this.pending ? [this.pending] : []), ...stops]).then(() => { this.store?.close(); });
|
|
75
114
|
return this.stopWork;
|
|
@@ -81,7 +120,7 @@ export class RoomCommands {
|
|
|
81
120
|
const store = this.store;
|
|
82
121
|
const created: RoomLink = new RoomLink({ store, name, application: this.options.chat?.application, refreshMs: this.options.refreshMs, now: this.options.clock,
|
|
83
122
|
onPresence: value => this.options.chat?.presence(store, name, created, value),
|
|
84
|
-
onState: state => this.options.chat?.linkState(store, name, created, state), presence: () => {
|
|
123
|
+
onState: state => { this.options.chat?.linkState(store, name, created, state); this.changedAvailability(); }, presence: () => {
|
|
85
124
|
const allowed = new Set(this.policy.get(name));
|
|
86
125
|
return (this.options.presence?.() ?? []).filter(row => allowed.has(row.id));
|
|
87
126
|
} });
|
|
@@ -166,21 +205,42 @@ export class RoomCommands {
|
|
|
166
205
|
async rows(name?: string, details = name !== undefined): Promise<Record<string, unknown>[]> {
|
|
167
206
|
if (!this.store && !existsSync(join(this.options.stateDir, "rooms"))) return [];
|
|
168
207
|
const store = await this.open(); if (name !== undefined) roomName(name);
|
|
169
|
-
return store.list().filter(row => name === undefined || row.name === name).map(row =>
|
|
208
|
+
return store.list().filter(row => name === undefined || row.name === name).map(row => {
|
|
209
|
+
const link = this.links.get(row.name)?.status();
|
|
210
|
+
const result: Record<string, unknown> = { name: row.name, room: row.current.room, owner: this.owner(row), member: store.identity.public.fingerprint, epoch: row.current.epoch,
|
|
211
|
+
state: row.left ? "left" : link?.state ?? "offline" };
|
|
212
|
+
if (details) {
|
|
213
|
+
result.linkCause = null;
|
|
214
|
+
if (!row.left) result.linkCause = link?.cause ?? null;
|
|
215
|
+
result.health = store.health(row.name);
|
|
216
|
+
if (this.options.chat) {
|
|
217
|
+
result.deliveries = this.options.chat.rows(row.name);
|
|
218
|
+
result.remotePeers = this.options.chat.peers(row.name);
|
|
219
|
+
}
|
|
220
|
+
}
|
|
221
|
+
return result;
|
|
222
|
+
});
|
|
170
223
|
}
|
|
171
|
-
/**
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
if (
|
|
224
|
+
/** One metadata snapshot binds unavailable diagnostics to the reads that failed. */
|
|
225
|
+
statusSnapshot(): { rooms: RoomStatusView[] | null; roomsOpening?: true; roomsUnavailable?: { code: RoomHealthCause; nextStep: string } } {
|
|
226
|
+
const unavailable = (code: RoomHealthCause) => ({ rooms: null, roomsUnavailable: { code, nextStep: roomHealthNextStep(code) } });
|
|
227
|
+
if (this.stopped) return { rooms: null };
|
|
228
|
+
if (this.openFailure) return unavailable(this.openFailure);
|
|
229
|
+
if (!this.store) return this.pending ? { rooms: null, roomsOpening: true } : { rooms: [] };
|
|
175
230
|
try {
|
|
176
|
-
return projectRoomStatus(this.store.statusMetadata().map(row => ({
|
|
231
|
+
return { rooms: projectRoomStatus(this.store.statusMetadata().map(row => ({
|
|
177
232
|
name: row.name, room: row.room, owner: row.owner, member: row.member, epoch: row.epoch,
|
|
178
233
|
state: row.left ? "left" : this.links.get(row.name)?.connectionState ?? "offline", members: row.members,
|
|
234
|
+
linkCause: row.left ? null : this.links.get(row.name)?.connectionCause ?? null, health: this.store!.health(row.name),
|
|
179
235
|
remotePeers: this.options.chat?.peersForStatus(row.name) ?? null,
|
|
180
236
|
relays: this.options.chat?.metrics(row.name, row.room) ?? null,
|
|
181
|
-
})));
|
|
182
|
-
} catch { return
|
|
237
|
+
}))) };
|
|
238
|
+
} catch (error) { return unavailable(roomHealthCause(error)); }
|
|
183
239
|
}
|
|
240
|
+
/** Closed cause metadata only, never raw errors; unavailable is distinct from no rooms. */
|
|
241
|
+
unavailable(): { code: RoomHealthCause; nextStep: string } | undefined { return this.statusSnapshot().roomsUnavailable; }
|
|
242
|
+
/** Polling uses loaded public metadata and aggregate counts; never opens storage or loads payloads. */
|
|
243
|
+
statusView(): RoomStatusView[] | null { return this.statusSnapshot().rooms; }
|
|
184
244
|
async preview(action: unknown, raw: unknown, check: () => boolean): Promise<RoomPreview> {
|
|
185
245
|
if (typeof action !== "string") refuse("invalid_operation");
|
|
186
246
|
await assertRoomCryptoReady(); this.current(check);
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
import { RoomStoreError } from "./store.ts";
|
|
2
|
+
|
|
3
|
+
/** Closed diagnostics only; never carry a filesystem error, path or message body. */
|
|
4
|
+
export const ROOM_HEALTH_CAUSES = ["invalid", "busy", "corrupt", "unsupported", "limit", "untrusted", "state_changed", "clock_rollback", "closed", "inbox_list_limit", "unavailable", "connecting", "transport_closed", "backpressure", "broker_refused", "codec_unsupported", "codec_pending"] as const;
|
|
5
|
+
export type RoomHealthCause = typeof ROOM_HEALTH_CAUSES[number];
|
|
6
|
+
export function roomHealthCause(error: unknown): RoomHealthCause {
|
|
7
|
+
return error instanceof RoomStoreError && ROOM_HEALTH_CAUSES.includes(error.code) ? error.code : "unavailable";
|
|
8
|
+
}
|
|
9
|
+
export function roomHealthNextStep(code: RoomHealthCause): string {
|
|
10
|
+
switch (code) {
|
|
11
|
+
case "clock_rollback": return "Correct the system clock to the last recorded room time; room links retry automatically.";
|
|
12
|
+
case "busy": return "Release the competing room store writer; the hub retries automatically.";
|
|
13
|
+
case "corrupt": case "invalid": case "untrusted": return "Inspect local room state and restore a trusted backup before retrying; do not delete replay records.";
|
|
14
|
+
case "unsupported": return "Upgrade agent-hub to a version supporting this room store, then restart the hub.";
|
|
15
|
+
case "limit": case "inbox_list_limit": return "Inspect ahub room status and ahub room inbox; resolve pending work before retrying.";
|
|
16
|
+
case "connecting": return "Wait for the broker connection; inspect ahub room status if it stays connecting.";
|
|
17
|
+
case "backpressure": return "Wait for the broker queue to drain; stored frames retry automatically.";
|
|
18
|
+
case "codec_pending": return "Wait for the member's signed codec advertisement; queued messages keep their original expiry. Inspect ahub room status if waiting continues.";
|
|
19
|
+
case "codec_unsupported": return "Upgrade both hubs and the broker, reconnect the room, then retry after signed codec support is available.";
|
|
20
|
+
case "broker_refused": return "Check broker availability and the signed room membership, then retry.";
|
|
21
|
+
case "transport_closed": return "Check the broker address and network connection; the link retries automatically.";
|
|
22
|
+
case "closed": return "Restart the hub to reopen room storage.";
|
|
23
|
+
case "state_changed": return "Read ahub room status again before retrying the operation.";
|
|
24
|
+
default: return "Check room storage permissions and broker availability; the hub retries store open automatically.";
|
|
25
|
+
}
|
|
26
|
+
}
|
|
27
|
+
export interface RoomHealth {
|
|
28
|
+
refused: Partial<Record<"invalid_presence" | "wrong_epoch" | "trust_changed" | "stale" | "invalid_frame", number>>;
|
|
29
|
+
outboxDepth: number;
|
|
30
|
+
oldestOutboxAgeMs: number | null;
|
|
31
|
+
lastReceiptAgeMs: number | null;
|
|
32
|
+
}
|