@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/rooms/remote-peer.ts
CHANGED
|
@@ -3,7 +3,7 @@ import { BasePeer, type DeliveryReceipt } from "../hub/peers.ts";
|
|
|
3
3
|
import type { Envelope, PeerState } from "../hub/envelope.ts";
|
|
4
4
|
import { encodeRoomMessage, parseRemotePeerId, type RoomChatEnvelope } from "./application.ts";
|
|
5
5
|
import { readRoomOutbound, roomBoundary, type RoomBoundaryContext } from "./boundary.ts";
|
|
6
|
-
import { RoomNotSentError } from "./link.ts";
|
|
6
|
+
import { RoomCodecPendingError, RoomCodecUnsupportedError, RoomNotSentError } from "./link.ts";
|
|
7
7
|
|
|
8
8
|
export interface RemotePeerPorts {
|
|
9
9
|
member: string;
|
|
@@ -32,10 +32,12 @@ export class RemotePeer extends BasePeer {
|
|
|
32
32
|
private running = false;
|
|
33
33
|
private online = false;
|
|
34
34
|
private blockedNotSent = false;
|
|
35
|
+
private codecWaiting = false;
|
|
35
36
|
private observed: PeerState = "offline";
|
|
36
37
|
private readonly pending = new Set<string>();
|
|
37
38
|
private readonly waitingForReady = new Set<string>();
|
|
38
39
|
private readonly sending = new Set<string>();
|
|
40
|
+
private readonly codecRechecks = new Map<string, () => boolean>();
|
|
39
41
|
private readonly deadlines = new Map<string, number>();
|
|
40
42
|
constructor(id: string, readonly remoteRoom: string, readonly remoteQueueTtlMs: number, private readonly ports: RemotePeerPorts) {
|
|
41
43
|
super(id);
|
|
@@ -60,6 +62,7 @@ export class RemotePeer extends BasePeer {
|
|
|
60
62
|
// Active journal rows and their sealed room frames survive a sender restart.
|
|
61
63
|
// Stop only fences in-flight callbacks; it is not evidence of an uncertain send.
|
|
62
64
|
this.pending.clear();
|
|
65
|
+
this.codecWaiting = false; this.codecRechecks.clear();
|
|
63
66
|
this.waitingForReady.clear();
|
|
64
67
|
this.deadlines.clear(); this.update();
|
|
65
68
|
}
|
|
@@ -125,6 +128,7 @@ export class RemotePeer extends BasePeer {
|
|
|
125
128
|
}
|
|
126
129
|
/** A link becoming ready is an actual wake-up after a typed not-sent refusal. */
|
|
127
130
|
wake(): void { this.blockedNotSent = false; this.update(); }
|
|
131
|
+
wakeCodec(): void { if (this.codecWaiting) { this.codecWaiting = false; this.wake(); } }
|
|
128
132
|
setRemoteDeadline(envelopeId: string, expiresAt: number): void {
|
|
129
133
|
if (Number.isSafeInteger(expiresAt) && expiresAt > 0 && this.deadlines.size < 200) this.deadlines.set(envelopeId, expiresAt);
|
|
130
134
|
}
|
|
@@ -159,7 +163,11 @@ export class RemotePeer extends BasePeer {
|
|
|
159
163
|
if (this.sending.has(deliveryId)) return;
|
|
160
164
|
this.sending.add(deliveryId);
|
|
161
165
|
try { await this.publishOnce(env, deliveryId, expiresAt, generation, immutable); }
|
|
162
|
-
finally {
|
|
166
|
+
finally {
|
|
167
|
+
this.sending.delete(deliveryId);
|
|
168
|
+
const changed = this.codecRechecks.get(deliveryId); this.codecRechecks.delete(deliveryId);
|
|
169
|
+
if (changed?.()) { this.codecWaiting = false; this.wake(); } // Recheck only after this send lane released, closing observation-before-requeue races.
|
|
170
|
+
}
|
|
163
171
|
}
|
|
164
172
|
private async publishOnce(env: Envelope, deliveryId: string, expiresAt: number, generation: number, immutable?: RoomChatEnvelope): Promise<void> {
|
|
165
173
|
let context: RoomBoundaryContext;
|
|
@@ -216,12 +224,19 @@ export class RemotePeer extends BasePeer {
|
|
|
216
224
|
}
|
|
217
225
|
} catch (error) {
|
|
218
226
|
if (!this.pending.has(deliveryId)) return;
|
|
227
|
+
if (error instanceof RoomCodecUnsupportedError && !immutable) {
|
|
228
|
+
let queued: boolean | undefined;
|
|
229
|
+
try { queued = this.ports.queued?.(deliveryId); } catch { queued = undefined; }
|
|
230
|
+
if (queued === false) { this.receipt({ id: deliveryId, state: "failed_safe", reason: error.message }); return; }
|
|
231
|
+
}
|
|
232
|
+
if (error instanceof RoomCodecPendingError) this.codecWaiting = true;
|
|
219
233
|
if (error instanceof RoomNotSentError) {
|
|
220
234
|
let retained: boolean | undefined;
|
|
221
235
|
try { retained = this.ports.queued?.(deliveryId); } catch { retained = undefined; }
|
|
222
236
|
if (immutable || retained === true) {
|
|
223
|
-
if (!this.ready()) this.waitForReady(deliveryId);
|
|
237
|
+
if (!this.ready() || error instanceof RoomCodecPendingError) this.waitForReady(deliveryId);
|
|
224
238
|
} else this.notSent(deliveryId);
|
|
239
|
+
if (error instanceof RoomCodecPendingError) this.codecRechecks.set(deliveryId, error.changed);
|
|
225
240
|
return;
|
|
226
241
|
}
|
|
227
242
|
if (!trusted()) {
|
package/src/rooms/service.ts
CHANGED
|
@@ -11,7 +11,7 @@ import { roomMembers, RoomWireError } from "./wire.ts";
|
|
|
11
11
|
import { RoomStore, RoomStoreError, RoomApplicationLimitError, type RoomOutgoing } from "./store.ts";
|
|
12
12
|
import { RoomPolicy } from "./policy.ts";
|
|
13
13
|
import { RemotePeer } from "./remote-peer.ts";
|
|
14
|
-
import { parseRemotePeerId, roomReceiptReasonText, type RoomMessage, type RoomReceiptReason, type RoomChatEnvelope } from "./application.ts";
|
|
14
|
+
import { parseRemotePeerId, roomReceiptReasonText, type RoomMessage, type RoomReceiptReason, type RoomChatEnvelope, type RoomCodecFeature } from "./application.ts";
|
|
15
15
|
import { RoomApplicationDeferredError, RoomNotSentError, type RoomLink, type RoomLinkApplication, type RoomLinkContext, type RoomLinkState, type RoomPresencePeer } from "./link.ts";
|
|
16
16
|
import { ROOM_PRESENCE_MISSES, ROOM_REFRESH_MS, roomRefreshPeriod } from "./liveness.ts";
|
|
17
17
|
|
|
@@ -32,7 +32,7 @@ export interface RoomChatOptions {
|
|
|
32
32
|
roster?: (ids: readonly string[]) => void;
|
|
33
33
|
changed?: () => void;
|
|
34
34
|
limits?: () => LimitsConfig;
|
|
35
|
-
notice?: (value: { code: "application_capacity"; room: string; count: number }
|
|
35
|
+
notice?: (value: { code: "application_capacity"; room: string; count: number }) => void;
|
|
36
36
|
}
|
|
37
37
|
export interface RoomMetrics { since: number; outbound: number | null; inbound: number | null; withheld: number | null }
|
|
38
38
|
interface Binding { store: RoomStore; name: string; room: string; link: RoomLink; digest: string; rosterMembers: string; online: boolean; states: Map<string, PeerState>; observedAt: Map<string, number>; observedMembers: Set<string>; tracked?: boolean }
|
|
@@ -106,9 +106,9 @@ export class RoomChat {
|
|
|
106
106
|
const binding = this.bindings.get(context.name); if (binding) this.wakeRemotes(binding);
|
|
107
107
|
},
|
|
108
108
|
capacityChanged: context => { if (context.current()) { const binding = this.bindings.get(context.name); if (binding) this.wakeRemotes(binding); this.capacityChanged(); } },
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
109
|
+
codecsChanged: (context, input) => { if (context.current()) { const binding = this.bindings.get(context.name); if (binding) { this.wakeRemotes(binding, input.member, true); void this.resumeActive(binding, input.member, input.features); } } },
|
|
110
|
+
// The signed sequence is room-wide, while chat recipients are selective.
|
|
111
|
+
// A numeric gap is not evidence of loss and must not become an operator notice.
|
|
112
112
|
};
|
|
113
113
|
}
|
|
114
114
|
private now(): number { return this.options.now?.() ?? Date.now(); }
|
|
@@ -149,8 +149,10 @@ export class RoomChat {
|
|
|
149
149
|
const target = this.targets.get(id);
|
|
150
150
|
return !!target && target.binding.online && this.valid(target.binding);
|
|
151
151
|
}
|
|
152
|
-
private wakeRemotes(binding: Binding): void {
|
|
153
|
-
for (const [id, remote] of this.remotes) if (this.targets.get(id)?.binding === binding
|
|
152
|
+
private wakeRemotes(binding: Binding, member?: string, codecOnly = false): void {
|
|
153
|
+
for (const [id, remote] of this.remotes) if (this.targets.get(id)?.binding === binding && (member === undefined || this.targets.get(id)?.member === member)) {
|
|
154
|
+
if (codecOnly) remote.wakeCodec(); else remote.wake();
|
|
155
|
+
}
|
|
154
156
|
}
|
|
155
157
|
metrics(name: string, room?: string): RoomMetrics | null {
|
|
156
158
|
const binding = this.bindings.get(name);
|
|
@@ -360,7 +362,7 @@ export class RoomChat {
|
|
|
360
362
|
&& this.policy.get(binding.name).includes(peer)
|
|
361
363
|
&& roomMembers(state.current).some(member => member.fingerprint === binding.store.identity.public.fingerprint && member.exposedPeers.includes(peer));
|
|
362
364
|
}
|
|
363
|
-
presence(store: RoomStore, name: string, link: RoomLink, value: { member: string; peers: readonly RoomPresencePeer[]; restored?: true }): void {
|
|
365
|
+
presence(store: RoomStore, name: string, link: RoomLink, value: { member: string; peers: readonly RoomPresencePeer[]; restored?: true; observedAt?: number | null }): void {
|
|
364
366
|
const binding = this.bindings.get(name), state = store.get(name);
|
|
365
367
|
if (!binding || binding.store !== store || binding.link !== link || !state || !this.valid(binding)) return;
|
|
366
368
|
const member = roomMembers(state.current).find(row => row.fingerprint === value.member);
|
|
@@ -371,7 +373,9 @@ export class RoomChat {
|
|
|
371
373
|
const now = this.presenceNow();
|
|
372
374
|
if (value.restored !== true) {
|
|
373
375
|
binding.observedMembers.add(member.fingerprint);
|
|
374
|
-
|
|
376
|
+
const receivedAt = Object.hasOwn(value, "observedAt") ? value.observedAt : now;
|
|
377
|
+
if (typeof receivedAt === "number" && Number.isSafeInteger(receivedAt) && receivedAt >= 0 && now !== undefined && receivedAt <= now) binding.observedAt.set(member.fingerprint, receivedAt);
|
|
378
|
+
else binding.observedAt.delete(member.fingerprint);
|
|
375
379
|
}
|
|
376
380
|
this.refreshTargets();
|
|
377
381
|
this.queueChanged();
|
|
@@ -686,11 +690,18 @@ export class RoomChat {
|
|
|
686
690
|
}
|
|
687
691
|
void this.retryPending(); this.queueChanged();
|
|
688
692
|
}
|
|
689
|
-
private async resumeActive(binding: Binding): Promise<void> {
|
|
693
|
+
private async resumeActive(binding: Binding, codecMember?: string, changedFeatures?: readonly RoomCodecFeature[]): Promise<void> {
|
|
690
694
|
if (!binding.online || !this.valid(binding)) return;
|
|
691
695
|
try {
|
|
692
|
-
const active = binding.store.applicationOutbox(binding.name, true).filter(row =>
|
|
693
|
-
&&
|
|
696
|
+
const active = binding.store.applicationOutbox(binding.name, true).filter(row => {
|
|
697
|
+
if (row.state !== "dispatching" && row.state !== "accepted" || this.personHeldInvocations.get(binding.name)?.has(row.deliveryId)) return false;
|
|
698
|
+
if (codecMember === undefined) return true;
|
|
699
|
+
if (row.recipient !== codecMember) return false;
|
|
700
|
+
let required: RoomCodecFeature | undefined;
|
|
701
|
+
if (row.peer === USER) required = "person_inbox_v1";
|
|
702
|
+
else if (this.options.bus.queueShow(row.deliveryId)?.originals.some(isInvocationKind)) required = "read_invocation_v1";
|
|
703
|
+
return required !== undefined && (changedFeatures === undefined || changedFeatures.includes(required));
|
|
704
|
+
});
|
|
694
705
|
for (const row of active) this.remotes.get(row.remotePeer)?.reservePending(row.deliveryId);
|
|
695
706
|
for (const row of active) this.launchResume(binding, row);
|
|
696
707
|
} catch { /* The next authenticated online edge will retry durable rows. */ }
|
|
@@ -747,6 +758,10 @@ export class RoomChat {
|
|
|
747
758
|
const digest = state?.current.digest;
|
|
748
759
|
const proofCurrent = () => this.storeProofCurrent(binding, row, remote, target, digest);
|
|
749
760
|
if (!target || !state || target.binding !== binding || !proofCurrent()) { remote.deferResume(row.deliveryId); return; }
|
|
761
|
+
let required: RoomCodecFeature | undefined;
|
|
762
|
+
if (isInvocationKind(original)) required = "read_invocation_v1";
|
|
763
|
+
else if (row.peer === USER) required = "person_inbox_v1";
|
|
764
|
+
if (required && !binding.link.codecSupported(row.recipient, required)) { remote.deferResume(row.deliveryId); return; }
|
|
750
765
|
const ready = () => binding.online && this.options.bus.remoteSendAllowed(remote);
|
|
751
766
|
if (!ready()) { remote.waitForReady(row.deliveryId); return; }
|
|
752
767
|
let readinessLost = false;
|
package/src/rooms/status.ts
CHANGED
|
@@ -2,6 +2,7 @@ import { types } from "node:util";
|
|
|
2
2
|
import type { PeerState } from "../hub/envelope.ts";
|
|
3
3
|
import { decodeBase64url, encodeBase64url } from "./crypto.ts";
|
|
4
4
|
import { parseRemotePeerId } from "./application.ts";
|
|
5
|
+
import { ROOM_HEALTH_CAUSES, roomHealthNextStep, type RoomHealthCause } from "./health.ts";
|
|
5
6
|
|
|
6
7
|
const ROOM_LIMIT = 64;
|
|
7
8
|
const REMOTE_PEER_LIMIT = 4096;
|
|
@@ -15,6 +16,13 @@ const EPOCH = /^[1-9][0-9]{0,19}$/;
|
|
|
15
16
|
const LINK_STATES = new Set(["online", "connecting", "offline", "left"]);
|
|
16
17
|
const PEER_STATES = new Set<PeerState>(["idle", "busy", "paused", "offline"]);
|
|
17
18
|
|
|
19
|
+
export interface RoomStatusHealth {
|
|
20
|
+
refused: { invalid_presence: number; wrong_epoch: number; trust_changed: number; stale: number; invalid_frame: number } | null;
|
|
21
|
+
outboxDepth: number | null;
|
|
22
|
+
oldestOutboxAgeMs: number | null;
|
|
23
|
+
lastReceiptAgeMs: number | null;
|
|
24
|
+
}
|
|
25
|
+
|
|
18
26
|
export interface RoomStatusPeer {
|
|
19
27
|
id: string;
|
|
20
28
|
state: PeerState;
|
|
@@ -29,8 +37,15 @@ export interface RoomRelayStats {
|
|
|
29
37
|
withheld: number | null;
|
|
30
38
|
}
|
|
31
39
|
|
|
40
|
+
export interface RoomStatusDiagnostics {
|
|
41
|
+
health?: RoomStatusHealth | null;
|
|
42
|
+
linkCause?: RoomHealthCause | null;
|
|
43
|
+
linkNextStep?: string | null;
|
|
44
|
+
}
|
|
45
|
+
export interface RoomStatusUnavailable { code: RoomHealthCause | null; nextStep: string | null }
|
|
46
|
+
|
|
32
47
|
/** Allowlisted room metadata. Missing member counts and peer lists stay unknown. */
|
|
33
|
-
export interface RoomStatusView {
|
|
48
|
+
export interface RoomStatusView extends RoomStatusDiagnostics {
|
|
34
49
|
name: string;
|
|
35
50
|
room: string;
|
|
36
51
|
owner: string;
|
|
@@ -123,6 +138,46 @@ function relayStats(value: unknown): RoomRelayStats | null {
|
|
|
123
138
|
return { since, outbound, inbound, withheld };
|
|
124
139
|
}
|
|
125
140
|
|
|
141
|
+
/** Counts and ages remain unknown when an older daemon cannot report them. */
|
|
142
|
+
export function projectRoomHealth(value: unknown): RoomStatusHealth | null {
|
|
143
|
+
if (!plainRecord(value)) return null;
|
|
144
|
+
const rawRefused = ownData(value, "refused");
|
|
145
|
+
let refused: RoomStatusHealth["refused"] = null;
|
|
146
|
+
if (plainRecord(rawRefused)) {
|
|
147
|
+
const invalid_presence = relayCount(ownData(rawRefused, "invalid_presence")), wrong_epoch = relayCount(ownData(rawRefused, "wrong_epoch"));
|
|
148
|
+
const trust_changed = relayCount(ownData(rawRefused, "trust_changed")), stale = relayCount(ownData(rawRefused, "stale")), invalid_frame = relayCount(ownData(rawRefused, "invalid_frame"));
|
|
149
|
+
if (invalid_presence !== null && wrong_epoch !== null && trust_changed !== null && stale !== null && invalid_frame !== null)
|
|
150
|
+
refused = { invalid_presence, wrong_epoch, trust_changed, stale, invalid_frame };
|
|
151
|
+
}
|
|
152
|
+
return { refused, outboxDepth: relayCount(ownData(value, "outboxDepth")),
|
|
153
|
+
oldestOutboxAgeMs: relayCount(ownData(value, "oldestOutboxAgeMs")), lastReceiptAgeMs: relayCount(ownData(value, "lastReceiptAgeMs")) };
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
function healthCause(value: unknown): RoomHealthCause | null {
|
|
157
|
+
return typeof value === "string" && ROOM_HEALTH_CAUSES.includes(value as RoomHealthCause) ? value as RoomHealthCause : null;
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
/** Regenerate guidance from closed causes; arbitrary daemon text is never copied. */
|
|
161
|
+
export function projectRoomDiagnostics(value: unknown): RoomStatusDiagnostics {
|
|
162
|
+
if (!plainRecord(value)) return {};
|
|
163
|
+
const cause = healthCause(ownData(value, "linkCause"));
|
|
164
|
+
const result: RoomStatusDiagnostics = {};
|
|
165
|
+
if (ownField(value, "health").present) result.health = projectRoomHealth(ownData(value, "health"));
|
|
166
|
+
if (ownField(value, "linkCause").present) {
|
|
167
|
+
result.linkCause = cause;
|
|
168
|
+
result.linkNextStep = null;
|
|
169
|
+
if (cause !== null) result.linkNextStep = roomHealthNextStep(cause);
|
|
170
|
+
}
|
|
171
|
+
return result;
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
/** Absent or malformed availability metadata stays unknown without evaluating getters. */
|
|
175
|
+
export function projectRoomUnavailable(value: unknown): RoomStatusUnavailable | null {
|
|
176
|
+
if (!plainRecord(value)) return null;
|
|
177
|
+
const code = healthCause(ownData(value, "code"));
|
|
178
|
+
return { code, nextStep: code === null ? null : roomHealthNextStep(code) };
|
|
179
|
+
}
|
|
180
|
+
|
|
126
181
|
/** Project RoomCommands.rows-compatible data to body-free room status metadata. */
|
|
127
182
|
export function projectRoomStatus(value: unknown): RoomStatusView[] {
|
|
128
183
|
try {
|
|
@@ -146,6 +201,7 @@ export function projectRoomStatus(value: unknown): RoomStatusView[] {
|
|
|
146
201
|
memberCount: memberCount(rawMemberCount),
|
|
147
202
|
remotePeers: peers,
|
|
148
203
|
relays: relayStats(ownData(item, "relays")),
|
|
204
|
+
...projectRoomDiagnostics(item),
|
|
149
205
|
});
|
|
150
206
|
}
|
|
151
207
|
return views;
|
package/src/rooms/store.ts
CHANGED
|
@@ -2,6 +2,7 @@
|
|
|
2
2
|
import { chmodSync, closeSync, constants, existsSync, lstatSync, mkdirSync, openSync, readdirSync } from "node:fs";
|
|
3
3
|
import { join } from "node:path";
|
|
4
4
|
import { Database } from "bun:sqlite";
|
|
5
|
+
import type { RoomHealth } from "./health.ts";
|
|
5
6
|
import { realPath } from "../hub/project.ts";
|
|
6
7
|
import { MAX_HOP, USER } from "../hub/envelope.ts";
|
|
7
8
|
import { assertRoomCryptoReady, exportHubIdentityForStorage, generateHubIdentity, importHubIdentity, decodeBase64url, encodeBase64url, sha256, type HubIdentity, type LocalIdentityRecord } from "./crypto.ts";
|
|
@@ -1558,9 +1559,27 @@ export class RoomStore {
|
|
|
1558
1559
|
});
|
|
1559
1560
|
}
|
|
1560
1561
|
refused(name: string): RoomRefusal[] {
|
|
1561
|
-
const state = this.get(name); if (!state) return [];
|
|
1562
|
+
this.now(); named(name); const state = this.rooms.get(name); if (!state) return [];
|
|
1562
1563
|
return this.db.query<RoomRefusal, [string]>("SELECT digest,reason,at FROM refusals WHERE room=? ORDER BY rowid").all(state.current.room);
|
|
1563
1564
|
}
|
|
1565
|
+
/** Metadata-only diagnostics. No durable clock write, sealed frame or plaintext read. */
|
|
1566
|
+
health(name: string): RoomHealth {
|
|
1567
|
+
const now = this.now(); named(name); const state = this.rooms.get(name);
|
|
1568
|
+
if (!state) fail("untrusted");
|
|
1569
|
+
const refused: RoomHealth["refused"] = { invalid_presence: 0, wrong_epoch: 0, trust_changed: 0, stale: 0, invalid_frame: 0 };
|
|
1570
|
+
for (const row of this.db.query<{ reason: RoomRefusalReason; count: number }, [string]>("SELECT reason,COUNT(*) AS count FROM refusals WHERE room=? GROUP BY reason").all(state.current.room)) refused[row.reason] = row.count;
|
|
1571
|
+
const pending = this.db.query<{ depth: number; dated: number; oldest: number | null }, [string, number]>(`SELECT COUNT(*) AS depth, COUNT(COALESCE(r.created_at,a.created_at)) AS dated, MIN(COALESCE(r.created_at,a.created_at)) AS oldest
|
|
1572
|
+
FROM outbox o LEFT JOIN application_attempts t ON t.room=o.room AND t.digest=o.digest
|
|
1573
|
+
LEFT JOIN application_outbox a ON a.room=t.room AND a.delivery_id=t.delivery_id
|
|
1574
|
+
LEFT JOIN receipt_outbox r ON r.room=o.room AND r.receipt_digest=o.digest
|
|
1575
|
+
WHERE o.room=? AND (t.digest IS NULL OR a.state IN ('dispatching','accepted'))
|
|
1576
|
+
AND (r.receipt_digest IS NULL OR r.state='dispatching' AND r.expires_at>?)`).get(state.current.room, now)!;
|
|
1577
|
+
const receipt = this.db.query<{ at: number | null }, [string]>(`SELECT MAX(i.received_at) AS at FROM inbox i
|
|
1578
|
+
JOIN application_outbox a ON a.room=i.room AND a.receipt_digest=i.digest WHERE i.room=?`).get(state.current.room)?.at;
|
|
1579
|
+
return { refused, outboxDepth: pending.depth,
|
|
1580
|
+
oldestOutboxAgeMs: pending.depth > 0 && pending.dated === pending.depth && pending.oldest !== null ? now - pending.oldest : null,
|
|
1581
|
+
lastReceiptAgeMs: receipt != null ? now - receipt : null };
|
|
1582
|
+
}
|
|
1564
1583
|
/** Read-only physical-cap hint for a validated host payload; no body is loaded and no replay/clock row is written. */
|
|
1565
1584
|
ordinaryInboxAvailable(name: string, bytes: number): boolean {
|
|
1566
1585
|
this.now(); named(name);
|
package/src/rooms/wire.ts
CHANGED
|
@@ -23,7 +23,7 @@ const FP = /^[0-9a-f]{64}$/;
|
|
|
23
23
|
export interface RoomMember extends PublicHubIdentity { label: string; exposedPeers: string[] }
|
|
24
24
|
export interface FrameWire {
|
|
25
25
|
v: 1; type: "frame"; room: string; epoch: string; sender: string;
|
|
26
|
-
recipients: string[]; seq: string; ephemeral?: true; nonce: string; ciphertext: string; signature: string;
|
|
26
|
+
recipients: string[]; seq: string; ephemeral?: true; codecFeatures?: true; nonce: string; ciphertext: string; signature: string;
|
|
27
27
|
}
|
|
28
28
|
export interface RosterWire {
|
|
29
29
|
v: 1; type: "roster"; room: string; epoch: string; owner: string;
|
|
@@ -108,9 +108,10 @@ function wrap(x: unknown): WrappedEpochKey {
|
|
|
108
108
|
}
|
|
109
109
|
function parseFrame(x: unknown): FrameWire {
|
|
110
110
|
const ephemeral = typeof x === "object" && x !== null && Object.hasOwn(x, "ephemeral");
|
|
111
|
-
const
|
|
112
|
-
|
|
113
|
-
|
|
111
|
+
const codecFeatures = typeof x === "object" && x !== null && Object.hasOwn(x, "codecFeatures");
|
|
112
|
+
const o = object(x, ["v", "type", "room", "epoch", "sender", "recipients", "seq", "nonce", "ciphertext", "signature", ...(ephemeral ? ["ephemeral"] : []), ...(codecFeatures ? ["codecFeatures"] : [])]); base(o, "frame");
|
|
113
|
+
if (ephemeral && o.ephemeral !== true || codecFeatures && (o.codecFeatures !== true || !ephemeral)) fail();
|
|
114
|
+
return { v: 1, type: "frame", room: b64(o.room, 32), epoch: positiveU64(o.epoch), sender: fingerprint(o.sender), recipients: sorted(array(o.recipients, 64, fingerprint, true), x => x), seq: positiveU64(o.seq), ...(ephemeral ? { ephemeral: true as const } : {}), ...(codecFeatures ? { codecFeatures: true as const } : {}), nonce: b64(o.nonce, 12), ciphertext: ciphertext(o.ciphertext, 65552), signature: b64(o.signature, 64) };
|
|
114
115
|
}
|
|
115
116
|
function parseRoster(x: unknown): RosterWire {
|
|
116
117
|
const o = object(x, ["v", "type", "room", "epoch", "owner", "members", "wraps", "privateNonce", "privateCiphertext", "signature"]); base(o, "roster");
|
|
@@ -231,6 +232,7 @@ export interface VerifiedPublicFrame {
|
|
|
231
232
|
readonly rosterDigest: string;
|
|
232
233
|
readonly recipients: readonly string[];
|
|
233
234
|
readonly ephemeral?: true;
|
|
235
|
+
readonly codecFeatures?: true;
|
|
234
236
|
}
|
|
235
237
|
interface PublicFrameState { wire: Uint8Array }
|
|
236
238
|
const publicFrames = new WeakMap<VerifiedPublicFrame, PublicFrameState>();
|
|
@@ -255,7 +257,7 @@ export async function verifyPublicFrame(input: { wire: Uint8Array; roster: Verif
|
|
|
255
257
|
await verifyFrameSignature(frame, sender);
|
|
256
258
|
const frameDigest = await digest(canonicalBytes(frame));
|
|
257
259
|
const proof = Object.freeze({ room: frame.room, epoch: frame.epoch, sender: frame.sender, seq: frame.seq,
|
|
258
|
-
digest: frameDigest, rosterDigest: roster.digest, ...(frame.ephemeral ? { ephemeral: true as const } : {}), recipients: Object.freeze([...frame.recipients]) });
|
|
260
|
+
digest: frameDigest, rosterDigest: roster.digest, ...(frame.ephemeral ? { ephemeral: true as const } : {}), ...(frame.codecFeatures ? { codecFeatures: true as const } : {}), recipients: Object.freeze([...frame.recipients]) });
|
|
259
261
|
publicFrames.set(proof, { wire });
|
|
260
262
|
return proof;
|
|
261
263
|
}
|
|
@@ -333,10 +335,10 @@ function frameAad(frame: Pick<FrameWire, "room" | "epoch" | "sender" | "seq">):
|
|
|
333
335
|
return enc.encode(`${frame.room}|${frame.epoch}|${frame.sender}|${frame.seq}`);
|
|
334
336
|
}
|
|
335
337
|
export async function sealFrame(input: {
|
|
336
|
-
epoch: VerifiedEpoch; identity: HubIdentity; recipients: string[]; plaintext: Uint8Array; sequence: SequencePort; ephemeral?: true;
|
|
338
|
+
epoch: VerifiedEpoch; identity: HubIdentity; recipients: string[]; plaintext: Uint8Array; sequence: SequencePort; ephemeral?: true; codecFeatures?: true;
|
|
337
339
|
}): Promise<Uint8Array> {
|
|
338
340
|
await assertRoomCryptoReady();
|
|
339
|
-
if (input.ephemeral !== undefined && input.ephemeral !== true) fail();
|
|
341
|
+
if (input.ephemeral !== undefined && input.ephemeral !== true || input.codecFeatures !== undefined && (input.codecFeatures !== true || input.ephemeral !== true)) fail();
|
|
340
342
|
const epoch = input.epoch, identity = input.identity, sequence = input.sequence;
|
|
341
343
|
const current = state(epoch), sender = identity.public.fingerprint;
|
|
342
344
|
const member = current.roster.members.find(m => m.fingerprint === sender);
|
|
@@ -348,7 +350,7 @@ export async function sealFrame(input: {
|
|
|
348
350
|
if (!sequence || typeof sequence.reserve !== "function" || typeof sequence.isCurrent !== "function") fail("state_unavailable");
|
|
349
351
|
const binding = { room: epoch.room, sender, epoch: epoch.epoch, rosterDigest: epoch.digest };
|
|
350
352
|
const seq = positiveU64(await portCall(() => sequence.reserve({ ...binding })));
|
|
351
|
-
const header = { v: 1 as const, type: "frame" as const, room: epoch.room, epoch: epoch.epoch, sender, recipients, seq, ...(input.ephemeral ? { ephemeral: true as const } : {}) };
|
|
353
|
+
const header = { v: 1 as const, type: "frame" as const, room: epoch.room, epoch: epoch.epoch, sender, recipients, seq, ...(input.ephemeral ? { ephemeral: true as const } : {}), ...(input.codecFeatures ? { codecFeatures: true as const } : {}) };
|
|
352
354
|
const nonce = randomBytes(12);
|
|
353
355
|
const encrypted = await encryptEpoch({ key: current.key, purpose: "frame", context: frameContext(epoch, sender), aad: frameAad(header), nonce, plaintext });
|
|
354
356
|
const body = { ...header, nonce: encodeBase64url(nonce), ciphertext: encodeBase64url(encrypted) };
|
package/src/ui/index.html
CHANGED
|
@@ -2677,7 +2677,7 @@ function memorySyncLine(sync) {
|
|
|
2677
2677
|
const cause = causes.includes(sync.cause) ? sync.cause : 'unknown';
|
|
2678
2678
|
if (cause === 'declined' && sync.placementActive === false) {
|
|
2679
2679
|
const declined = Number.isSafeInteger(sync.declinedCount) && sync.declinedCount >= 0 ? String(sync.declinedCount) : 'unknown';
|
|
2680
|
-
return 'machine sync blocked: ' + declined + ' declined rows; per-row placement is not active on this store
|
|
2680
|
+
return 'machine sync blocked: ' + declined + ' declined rows; per-row placement is not active on this store. Activation is person-only: preview with `amem migrate --sync-placement --dry-run` and declare every writer via --writer-root. The hub does not yet supply the fresh placementAttestation required for active-placement sync (#1173). Inspect with `ahub memory declined`.';
|
|
2681
2681
|
}
|
|
2682
2682
|
const count = Number.isSafeInteger(sync.declinedCount) && sync.declinedCount >= 0 ? ' / ' + sync.declinedCount + ' declined' : '';
|
|
2683
2683
|
const ids = cause === 'declined' && sync.declinedIdsKnown === false ? ' / declined ID set unknown' : '';
|