@gajae-code/coding-agent 0.12.11 → 0.12.13
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 +68 -0
- package/dist/types/cli/marketplace-hint.d.ts +28 -0
- package/dist/types/cli/notify-cli.d.ts +42 -1
- package/dist/types/cli/update-cli.d.ts +9 -3
- package/dist/types/cli.d.ts +10 -0
- package/dist/types/commands/notify.d.ts +6 -0
- package/dist/types/config/file-lock-gc.d.ts +13 -1
- package/dist/types/config/file-lock.d.ts +3 -0
- package/dist/types/config/model-registry.d.ts +6 -0
- package/dist/types/config/models-config-schema.d.ts +99 -0
- package/dist/types/coordinator/contract.d.ts +1 -1
- package/dist/types/coordinator-mcp/question-state.d.ts +3 -1
- package/dist/types/extensibility/extensions/index.d.ts +1 -0
- package/dist/types/extensibility/extensions/ouroboros-ooo-bridge.d.ts +13 -0
- package/dist/types/extensibility/extensions/prefix-command-bridge.d.ts +0 -1
- package/dist/types/extensibility/extensions/types.d.ts +2 -0
- package/dist/types/gjc-runtime/deep-interview-state.d.ts +14 -0
- package/dist/types/gjc-runtime/gc-runtime.d.ts +11 -0
- package/dist/types/main.d.ts +6 -2
- package/dist/types/modes/acp/acp-agent.d.ts +24 -1
- package/dist/types/modes/components/session-selector.d.ts +1 -1
- package/dist/types/modes/utils/host-status.d.ts +23 -0
- package/dist/types/runtime-mcp/types.d.ts +2 -0
- package/dist/types/sdk/broker/lifecycle.d.ts +12 -0
- package/dist/types/sdk/bus/chat-daemon-cli.d.ts +3 -0
- package/dist/types/sdk/bus/chat-daemon-command-channel.d.ts +244 -0
- package/dist/types/sdk/bus/chat-daemon-control.d.ts +11 -1
- package/dist/types/sdk/bus/chat-daemon-runtime.d.ts +7 -0
- package/dist/types/sdk/bus/control-plane-frames.d.ts +43 -0
- package/dist/types/sdk/bus/conversation-store.d.ts +18 -0
- package/dist/types/sdk/bus/existing-thread-readiness.d.ts +71 -0
- package/dist/types/sdk/bus/html-format.d.ts +10 -0
- package/dist/types/sdk/bus/notification-service.d.ts +10 -0
- package/dist/types/sdk/bus/slack-daemon.d.ts +27 -0
- package/dist/types/sdk/bus/slack-live-provider.d.ts +11 -0
- package/dist/types/sdk/bus/slack-provider.d.ts +14 -0
- package/dist/types/sdk/bus/slack-thread-binding.d.ts +140 -0
- package/dist/types/sdk/bus/telegram-daemon-cli.d.ts +2 -0
- package/dist/types/sdk/bus/telegram-daemon-contract.d.ts +28 -7
- package/dist/types/sdk/bus/telegram-daemon.d.ts +119 -17
- package/dist/types/sdk/bus/telegram-reference.d.ts +20 -1
- package/dist/types/sdk/bus/topic-registry.d.ts +130 -85
- package/dist/types/sdk/host/host.d.ts +52 -0
- package/dist/types/sdk/lifecycle-session.d.ts +6 -0
- package/dist/types/sdk/session-activation.d.ts +41 -0
- package/dist/types/sdk/session.d.ts +18 -0
- package/dist/types/sdk/startup-capability.d.ts +8 -1
- package/dist/types/session/agent-session.d.ts +7 -2
- package/dist/types/session/artifacts.d.ts +2 -2
- package/dist/types/session/session-manager.d.ts +40 -6
- package/dist/types/tools/index.d.ts +15 -0
- package/dist/types/utils/npm-registry.d.ts +71 -0
- package/examples/extensions/README.md +38 -4
- package/examples/extensions/ooo-bridge.ts +15 -0
- package/package.json +8 -8
- package/scripts/generate-sdk-operation-inventory.ts +4 -0
- package/src/async/job-manager.ts +9 -2
- package/src/cli/fast-help.ts +1 -0
- package/src/cli/marketplace-hint.ts +39 -0
- package/src/cli/notify-cli.ts +226 -2
- package/src/cli/plugin-cli.ts +10 -0
- package/src/cli/update-cli.ts +90 -23
- package/src/cli.ts +23 -0
- package/src/commands/notify.ts +27 -3
- package/src/commands/sdk.ts +1 -0
- package/src/config/file-lock-gc.ts +65 -26
- package/src/config/file-lock.ts +61 -16
- package/src/config/model-profiles.ts +46 -30
- package/src/config/model-registry.ts +17 -5
- package/src/config/models-config-schema.ts +9 -4
- package/src/coordinator/contract.ts +1 -0
- package/src/coordinator-mcp/question-state.ts +2 -0
- package/src/coordinator-mcp/server.ts +358 -7
- package/src/defaults/gjc/skills/deep-interview/SKILL.md +1 -1
- package/src/defaults/gjc/skills/ultragoal/SKILL.md +2 -0
- package/src/extensibility/extensions/index.ts +1 -0
- package/src/extensibility/extensions/ouroboros-ooo-bridge.ts +235 -0
- package/src/extensibility/extensions/prefix-command-bridge.ts +4 -9
- package/src/extensibility/extensions/runner.ts +15 -2
- package/src/extensibility/extensions/types.ts +2 -0
- package/src/gjc-runtime/deep-interview-recorder.ts +9 -5
- package/src/gjc-runtime/deep-interview-state.ts +27 -3
- package/src/gjc-runtime/gc-render.ts +8 -1
- package/src/gjc-runtime/gc-runtime.ts +15 -1
- package/src/gjc-runtime/launch-tmux.ts +53 -1
- package/src/internal-urls/docs-index.generated.ts +14 -13
- package/src/main.ts +214 -70
- package/src/modes/DESIGN.md +8 -5
- package/src/modes/acp/acp-agent.ts +123 -7
- package/src/modes/components/footer.ts +10 -18
- package/src/modes/components/model-selector.ts +52 -5
- package/src/modes/components/session-selector.ts +26 -3
- package/src/modes/components/status-line/gh.ts +23 -2
- package/src/modes/controllers/event-controller.ts +3 -0
- package/src/modes/controllers/extension-ui-controller.ts +2 -0
- package/src/modes/controllers/input-controller.ts +11 -0
- package/src/modes/controllers/selector-controller.ts +8 -2
- package/src/modes/interactive-mode.ts +7 -1
- package/src/modes/print-mode.ts +10 -0
- package/src/modes/utils/host-status.ts +38 -0
- package/src/prompts/system/project-prompt.md +19 -0
- package/src/runtime-mcp/types.ts +2 -0
- package/src/sdk/broker/lifecycle.ts +60 -3
- package/src/sdk/bus/chat-daemon-cli.ts +42 -0
- package/src/sdk/bus/chat-daemon-command-channel.ts +921 -0
- package/src/sdk/bus/chat-daemon-control.ts +16 -3
- package/src/sdk/bus/chat-daemon-runtime.ts +257 -21
- package/src/sdk/bus/control-plane-frames.ts +51 -0
- package/src/sdk/bus/conversation-store.ts +29 -1
- package/src/sdk/bus/existing-thread-readiness.ts +155 -0
- package/src/sdk/bus/html-format.ts +19 -3
- package/src/sdk/bus/index.ts +91 -12
- package/src/sdk/bus/notification-service.ts +28 -1
- package/src/sdk/bus/rich-render.ts +3 -2
- package/src/sdk/bus/slack-daemon.ts +99 -4
- package/src/sdk/bus/slack-live-provider.ts +32 -2
- package/src/sdk/bus/slack-provider.ts +11 -0
- package/src/sdk/bus/slack-thread-binding.ts +641 -0
- package/src/sdk/bus/telegram-daemon-cli.ts +11 -0
- package/src/sdk/bus/telegram-daemon-contract.ts +28 -7
- package/src/sdk/bus/telegram-daemon.ts +2007 -509
- package/src/sdk/bus/telegram-reference.ts +114 -15
- package/src/sdk/bus/topic-registry.ts +674 -181
- package/src/sdk/host/host.ts +180 -1
- package/src/sdk/lifecycle-session.ts +8 -2
- package/src/sdk/protocol/operation-inventory.generated.json +22 -0
- package/src/sdk/session-activation.ts +127 -0
- package/src/sdk/session.ts +144 -29
- package/src/sdk/startup-capability.ts +10 -1
- package/src/session/agent-session.ts +220 -13
- package/src/session/artifacts.ts +56 -13
- package/src/session/internal/managed-session-storage.ts +228 -7
- package/src/session/session-manager.ts +225 -60
- package/src/setup/provider-presets.json +3 -3
- package/src/task/index.ts +92 -44
- package/src/thinking-metadata.ts +2 -1
- package/src/tools/ask.ts +10 -0
- package/src/tools/index.ts +15 -0
- package/src/tools/todo-write.ts +4 -4
- package/src/utils/npm-registry.ts +660 -0
|
@@ -1,12 +1,12 @@
|
|
|
1
|
+
import { randomUUID } from "node:crypto";
|
|
1
2
|
/**
|
|
2
3
|
* Per-session forum-topic registry for the threaded session surface.
|
|
3
4
|
*
|
|
4
|
-
* Each GJC session owns one active Telegram forum topic
|
|
5
|
-
*
|
|
6
|
-
*
|
|
7
|
-
*
|
|
8
|
-
* already been pinned
|
|
9
|
-
* reconnects.
|
|
5
|
+
* Each GJC session owns one active Telegram forum topic. Remote archive closes
|
|
6
|
+
* daemon-created topics without deleting their durable records; rotated
|
|
7
|
+
* successors move inactive records into retained history before creating a new
|
|
8
|
+
* active authority. The registry also tracks whether the one-time identity
|
|
9
|
+
* header has already been pinned.
|
|
10
10
|
*
|
|
11
11
|
* State is a plain serialisable map persisted beside the daemon state files;
|
|
12
12
|
* topic creation is injected so this module is pure and unit-testable without a
|
|
@@ -14,9 +14,24 @@
|
|
|
14
14
|
*/
|
|
15
15
|
|
|
16
16
|
/** Persisted record for one session's topic. */
|
|
17
|
+
export type TopicLifecycleState =
|
|
18
|
+
| "active"
|
|
19
|
+
| "disconnect_grace"
|
|
20
|
+
| "archive_pending"
|
|
21
|
+
| "archive_exhausted"
|
|
22
|
+
| "inactive"
|
|
23
|
+
| "legacy_quarantined"
|
|
24
|
+
/** Read-only input compatibility; normalized to archive_pending during load. */
|
|
25
|
+
| "delete_pending";
|
|
26
|
+
|
|
27
|
+
/** Persisted record for one immutable session identity's topic. */
|
|
17
28
|
export interface TopicRecord {
|
|
18
29
|
/** Telegram forum topic id (message_thread_id). */
|
|
19
30
|
topicId: string;
|
|
31
|
+
/** Whether Telegram created this topic for the daemon or it was explicitly adopted from a user. */
|
|
32
|
+
topicOrigin: "daemon_created" | "user_created";
|
|
33
|
+
/** Immutable UUID record identity; never derive authority from a title or PID. */
|
|
34
|
+
sessionUuid?: string;
|
|
20
35
|
/** Whether the one-time identity header has been sent/pinned. */
|
|
21
36
|
identitySent: boolean;
|
|
22
37
|
/** Creation timestamp (ms epoch). */
|
|
@@ -41,10 +56,8 @@ export interface TopicRecord {
|
|
|
41
56
|
authorityEpoch?: number;
|
|
42
57
|
/** Immutable authority epoch held when this remote topic create began. */
|
|
43
58
|
creationLeaseEpoch?: number;
|
|
44
|
-
/**
|
|
45
|
-
authorityState?:
|
|
46
|
-
/** Provenance for topics created by the user and adopted by GJC. Missing means daemon-created. */
|
|
47
|
-
topicOrigin?: "user_created";
|
|
59
|
+
/** Durable non-destructive lifecycle state. */
|
|
60
|
+
authorityState?: TopicLifecycleState;
|
|
48
61
|
/** Telegram chat and endpoint authority last proven to use this topic. */
|
|
49
62
|
chatId?: string;
|
|
50
63
|
/** Canonical endpoint tuple (URL + token) that currently holds the lease. */
|
|
@@ -55,18 +68,70 @@ export interface TopicRecord {
|
|
|
55
68
|
endpointGeneration?: number;
|
|
56
69
|
/** Monotonic authenticated endpoint handoffs; legacy bindings begin at zero. */
|
|
57
70
|
endpointIncarnation?: number;
|
|
71
|
+
/** Shared-authority lease owner (installation UUID), heartbeat, and expiry. */
|
|
72
|
+
leaseOwner?: string;
|
|
73
|
+
leaseHeartbeatAt?: number;
|
|
74
|
+
leaseExpiresAt?: number;
|
|
75
|
+
/** Durable archive initiator; a live foreign owner cannot be displaced. */
|
|
76
|
+
archiveHostId?: string;
|
|
77
|
+
/** Authority epoch captured by the archive initiator when it published the fence. */
|
|
78
|
+
archiveLeaseEpoch?: number;
|
|
79
|
+
disconnectGraceExpiresAt?: number;
|
|
58
80
|
/** True when persisted binding fields were present but malformed; recovery must fail closed. */
|
|
59
81
|
bindingMalformed?: true;
|
|
60
82
|
}
|
|
83
|
+
/** Durable claim published before invoking createForumTopic. */
|
|
84
|
+
export interface TopicCreateClaim {
|
|
85
|
+
sessionId: string;
|
|
86
|
+
hostId?: string;
|
|
87
|
+
leaseOwner?: string;
|
|
88
|
+
authorityEpoch: number;
|
|
89
|
+
createdAt: number;
|
|
90
|
+
binding?: TopicEndpointBinding;
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
export interface ArchiveJob {
|
|
94
|
+
sessionId: string;
|
|
95
|
+
topicId: string;
|
|
96
|
+
/** Number of archive calls that have returned an ambiguous outcome. */
|
|
97
|
+
attempt: number;
|
|
98
|
+
/** First ambiguous result; bounds retry lifetime. */
|
|
99
|
+
firstAttemptAt?: number;
|
|
100
|
+
/** Compatibility read for pre-journal snapshots; normalized to `attempt`. */
|
|
101
|
+
retryCount?: number;
|
|
102
|
+
backoffMs: number;
|
|
103
|
+
nextAttemptAt: number;
|
|
104
|
+
safeDiagnostic?: string;
|
|
105
|
+
}
|
|
61
106
|
|
|
62
107
|
/** Serialisable shape persisted to disk. */
|
|
63
108
|
export interface TopicRegistryState {
|
|
109
|
+
/** Writer format. Missing is the quarantined legacy format; future versions fail closed. */
|
|
110
|
+
version?: 2;
|
|
111
|
+
/** Monotonically increasing snapshot generation used by shared CAS stores. */
|
|
112
|
+
registryGeneration?: number;
|
|
64
113
|
/** sessionId -> record. */
|
|
65
114
|
topics: Record<string, TopicRecord>;
|
|
66
|
-
/** Durable
|
|
115
|
+
/** Durable lifecycle epochs retained after an archive starts. */
|
|
67
116
|
fences?: Record<string, number>;
|
|
68
117
|
/** Closed transport endpoint leases; unchanged endpoint discovery remains fenced across restart. */
|
|
69
118
|
closedEndpoints?: Record<string, TopicEndpointBinding>;
|
|
119
|
+
/** Persistent host identity used to distinguish concurrent installations. */
|
|
120
|
+
installationHostId?: string;
|
|
121
|
+
/** Bounded durable archive work; no topic record is physically deleted. */
|
|
122
|
+
archiveJobs?: Record<string, ArchiveJob>;
|
|
123
|
+
/** Durable create claims. A claim fences concurrent creators before remote I/O. */
|
|
124
|
+
createClaims?: Record<string, TopicCreateClaim>;
|
|
125
|
+
/** Retained inactive predecessors keyed by logical session id. */
|
|
126
|
+
retiredTopics?: Record<string, TopicRecord[]>;
|
|
127
|
+
}
|
|
128
|
+
/**
|
|
129
|
+
* Shared registry authority. Filesystem atomic rename is sufficient for a single
|
|
130
|
+
* installation, but it cannot serialize two hosts sharing a state volume.
|
|
131
|
+
*/
|
|
132
|
+
export interface TopicRegistryCasAuthority {
|
|
133
|
+
read(): Promise<TopicRegistryState | undefined>;
|
|
134
|
+
compareAndSet(expectedGeneration: number, next: TopicRegistryState): Promise<boolean>;
|
|
70
135
|
}
|
|
71
136
|
|
|
72
137
|
/** Authenticated runtime binding for a durable topic lease. */
|
|
@@ -84,7 +149,7 @@ export type TopicEndpointAuthority =
|
|
|
84
149
|
| { state: "ambiguous" };
|
|
85
150
|
|
|
86
151
|
/** Conditional rollback token for a delete fence publication. */
|
|
87
|
-
export interface
|
|
152
|
+
export interface TopicArchiveAuthoritySnapshot {
|
|
88
153
|
sessionId: string;
|
|
89
154
|
topicId?: string;
|
|
90
155
|
authorityEpoch?: number;
|
|
@@ -94,18 +159,6 @@ export interface TopicDeleteAuthoritySnapshot {
|
|
|
94
159
|
record?: TopicRecord;
|
|
95
160
|
}
|
|
96
161
|
|
|
97
|
-
/**
|
|
98
|
-
* Proof that a definite delete was settled in memory and still owes its durable
|
|
99
|
-
* commit. Handed back only by a settlement that actually happened, so a refused
|
|
100
|
-
* settlement structurally cannot be followed by a rollback.
|
|
101
|
-
*/
|
|
102
|
-
export interface TopicSettledDelete {
|
|
103
|
-
sessionId: string;
|
|
104
|
-
topicId: string;
|
|
105
|
-
/** Authority epoch proven current when the record was removed. */
|
|
106
|
-
settledEpoch: number;
|
|
107
|
-
}
|
|
108
|
-
|
|
109
162
|
function isValidBindingString(value: unknown): value is string {
|
|
110
163
|
return typeof value === "string" && value.trim().length > 0;
|
|
111
164
|
}
|
|
@@ -147,19 +200,179 @@ function isValidTopicId(value: unknown): value is string {
|
|
|
147
200
|
typeof value === "string" && /^[1-9]\d*$/.test(value) && Number.isSafeInteger(Number(value)) && Number(value) > 0
|
|
148
201
|
);
|
|
149
202
|
}
|
|
150
|
-
|
|
151
|
-
/**
|
|
152
|
-
* Monotonic epoch successor that saturates instead of leaving the safe-integer
|
|
153
|
-
* range. Past `Number.MAX_SAFE_INTEGER` two distinct generations collapse onto
|
|
154
|
-
* the same IEEE-754 double, which would let a stale settlement clear a newer
|
|
155
|
-
* fence; settlement refuses the saturated value instead of overflowing past it.
|
|
156
|
-
*/
|
|
157
203
|
function nextAuthorityEpoch(current: number): number {
|
|
158
204
|
return current >= Number.MAX_SAFE_INTEGER ? Number.MAX_SAFE_INTEGER : current + 1;
|
|
159
205
|
}
|
|
160
206
|
|
|
161
207
|
export function emptyTopicRegistryState(): TopicRegistryState {
|
|
162
|
-
return { topics: {} };
|
|
208
|
+
return { version: 2, topics: {} };
|
|
209
|
+
}
|
|
210
|
+
/**
|
|
211
|
+
* Reject snapshots written by a newer daemon. Missing versions are preserved as
|
|
212
|
+
* evidence but quarantined: legacy records must never route or mutate remotely.
|
|
213
|
+
*/
|
|
214
|
+
export function parseTopicRegistryState(value: unknown): TopicRegistryState | undefined {
|
|
215
|
+
if (!value || typeof value !== "object" || Array.isArray(value)) return undefined;
|
|
216
|
+
const state = value as TopicRegistryState;
|
|
217
|
+
if (state.version !== undefined && state.version !== 2) throw new Error("unsupported future Telegram topic state");
|
|
218
|
+
if (!state.topics || typeof state.topics !== "object" || Array.isArray(state.topics)) return undefined;
|
|
219
|
+
if (state.version === undefined) {
|
|
220
|
+
if (state.registryGeneration !== undefined) throw new Error("malformed Telegram topic state");
|
|
221
|
+
return parseTopicRegistryState({
|
|
222
|
+
...state,
|
|
223
|
+
version: 2,
|
|
224
|
+
registryGeneration: 0,
|
|
225
|
+
topics: Object.fromEntries(
|
|
226
|
+
Object.entries(state.topics).map(([sessionId, record]) => [
|
|
227
|
+
sessionId,
|
|
228
|
+
record && typeof record === "object"
|
|
229
|
+
? {
|
|
230
|
+
...record,
|
|
231
|
+
topicOrigin: (record as TopicRecord).topicOrigin ?? "daemon_created",
|
|
232
|
+
authorityState: "legacy_quarantined",
|
|
233
|
+
}
|
|
234
|
+
: record,
|
|
235
|
+
]),
|
|
236
|
+
),
|
|
237
|
+
});
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
const isObject = (candidate: unknown): candidate is Record<string, unknown> =>
|
|
241
|
+
!!candidate && typeof candidate === "object" && !Array.isArray(candidate);
|
|
242
|
+
const validTimestamp = (candidate: unknown): candidate is number =>
|
|
243
|
+
typeof candidate === "number" && Number.isFinite(candidate) && candidate >= 0;
|
|
244
|
+
const validEpoch = (candidate: unknown): candidate is number =>
|
|
245
|
+
typeof candidate === "number" && Number.isSafeInteger(candidate) && candidate >= 0;
|
|
246
|
+
const validOptionalString = (candidate: unknown): boolean =>
|
|
247
|
+
candidate === undefined || isValidBindingString(candidate);
|
|
248
|
+
const validBinding = (candidate: unknown): candidate is TopicEndpointBinding =>
|
|
249
|
+
isObject(candidate) &&
|
|
250
|
+
isValidBindingString(candidate.chatId) &&
|
|
251
|
+
isValidBindingString(candidate.endpointKey) &&
|
|
252
|
+
isValidBindingString(candidate.endpointDigest) &&
|
|
253
|
+
(candidate.endpointGeneration === undefined || isValidBindingGeneration(candidate.endpointGeneration));
|
|
254
|
+
const malformed = (): never => {
|
|
255
|
+
throw new Error("malformed Telegram topic state");
|
|
256
|
+
};
|
|
257
|
+
|
|
258
|
+
if (
|
|
259
|
+
(state.registryGeneration !== undefined && !validEpoch(state.registryGeneration)) ||
|
|
260
|
+
!isObject(state.topics) ||
|
|
261
|
+
[state.fences, state.closedEndpoints, state.archiveJobs, state.createClaims, state.retiredTopics].some(
|
|
262
|
+
nested => nested !== undefined && !isObject(nested),
|
|
263
|
+
)
|
|
264
|
+
)
|
|
265
|
+
malformed();
|
|
266
|
+
for (const [sessionId, raw] of Object.entries(state.topics)) {
|
|
267
|
+
if (!isValidBindingString(sessionId) || !isObject(raw) || !isValidTopicId(raw.topicId)) malformed();
|
|
268
|
+
if (typeof raw.identitySent !== "boolean" || !validTimestamp(raw.createdAt)) malformed();
|
|
269
|
+
if (
|
|
270
|
+
!validOptionalString(raw.sessionUuid) ||
|
|
271
|
+
(raw.orphanedAt !== undefined && !validTimestamp(raw.orphanedAt)) ||
|
|
272
|
+
(raw.name !== undefined && typeof raw.name !== "string") ||
|
|
273
|
+
(raw.topicOrigin !== "daemon_created" && raw.topicOrigin !== "user_created") ||
|
|
274
|
+
(raw.nameOwner !== undefined && raw.nameOwner !== "user") ||
|
|
275
|
+
(raw.nameReconcilePending !== undefined && typeof raw.nameReconcilePending !== "boolean") ||
|
|
276
|
+
(raw.userNameUpdateId !== undefined && !validEpoch(raw.userNameUpdateId)) ||
|
|
277
|
+
!validOptionalString(raw.identityKey) ||
|
|
278
|
+
(raw.replayGeneration !== undefined &&
|
|
279
|
+
(!Number.isSafeInteger(raw.replayGeneration) || raw.replayGeneration < 1)) ||
|
|
280
|
+
(raw.replaySeq !== undefined && !validEpoch(raw.replaySeq)) ||
|
|
281
|
+
(raw.authorityEpoch !== undefined && !validEpoch(raw.authorityEpoch)) ||
|
|
282
|
+
(raw.creationLeaseEpoch !== undefined && !validEpoch(raw.creationLeaseEpoch)) ||
|
|
283
|
+
(raw.authorityState !== undefined &&
|
|
284
|
+
![
|
|
285
|
+
"active",
|
|
286
|
+
"disconnect_grace",
|
|
287
|
+
"archive_pending",
|
|
288
|
+
"archive_exhausted",
|
|
289
|
+
"inactive",
|
|
290
|
+
"legacy_quarantined",
|
|
291
|
+
"delete_pending",
|
|
292
|
+
].includes(raw.authorityState as string)) ||
|
|
293
|
+
!validOptionalString(raw.leaseOwner) ||
|
|
294
|
+
(raw.leaseHeartbeatAt !== undefined && !validTimestamp(raw.leaseHeartbeatAt)) ||
|
|
295
|
+
(raw.leaseExpiresAt !== undefined && !validTimestamp(raw.leaseExpiresAt)) ||
|
|
296
|
+
!validOptionalString(raw.archiveHostId) ||
|
|
297
|
+
(raw.archiveLeaseEpoch !== undefined && !validEpoch(raw.archiveLeaseEpoch)) ||
|
|
298
|
+
(raw.disconnectGraceExpiresAt !== undefined && !validTimestamp(raw.disconnectGraceExpiresAt)) ||
|
|
299
|
+
(raw.bindingMalformed !== undefined && raw.bindingMalformed !== true)
|
|
300
|
+
)
|
|
301
|
+
malformed();
|
|
302
|
+
const leaseFieldCount = [raw.leaseOwner, raw.leaseHeartbeatAt, raw.leaseExpiresAt].filter(
|
|
303
|
+
value => value !== undefined,
|
|
304
|
+
).length;
|
|
305
|
+
if (leaseFieldCount !== 0 && leaseFieldCount !== 3) malformed();
|
|
306
|
+
if (
|
|
307
|
+
raw.authorityState === "disconnect_grace"
|
|
308
|
+
? raw.disconnectGraceExpiresAt === undefined || raw.orphanedAt === undefined
|
|
309
|
+
: raw.disconnectGraceExpiresAt !== undefined
|
|
310
|
+
)
|
|
311
|
+
malformed();
|
|
312
|
+
const hasBinding = hasAnyBinding(raw);
|
|
313
|
+
const hasArchiveOnlyChatIdentity =
|
|
314
|
+
(raw.authorityState === "archive_pending" ||
|
|
315
|
+
raw.authorityState === "archive_exhausted" ||
|
|
316
|
+
raw.authorityState === "inactive") &&
|
|
317
|
+
isValidBindingString(raw.chatId) &&
|
|
318
|
+
raw.endpointKey === undefined &&
|
|
319
|
+
raw.endpointDigest === undefined &&
|
|
320
|
+
raw.endpointGeneration === undefined &&
|
|
321
|
+
raw.endpointIncarnation === undefined;
|
|
322
|
+
if (
|
|
323
|
+
hasBinding &&
|
|
324
|
+
!hasArchiveOnlyChatIdentity &&
|
|
325
|
+
(!isValidBindingString(raw.chatId) ||
|
|
326
|
+
!isValidBindingString(raw.endpointKey) ||
|
|
327
|
+
!isValidBindingString(raw.endpointDigest) ||
|
|
328
|
+
(raw.endpointGeneration !== undefined && !isValidBindingGeneration(raw.endpointGeneration)) ||
|
|
329
|
+
(raw.endpointIncarnation !== undefined && !isValidBindingGeneration(raw.endpointIncarnation)))
|
|
330
|
+
)
|
|
331
|
+
malformed();
|
|
332
|
+
}
|
|
333
|
+
for (const [sessionId, records] of Object.entries(state.retiredTopics ?? {})) {
|
|
334
|
+
if (!isValidBindingString(sessionId) || !Array.isArray(records)) malformed();
|
|
335
|
+
for (const [index, record] of records.entries()) {
|
|
336
|
+
parseTopicRegistryState({
|
|
337
|
+
version: 2,
|
|
338
|
+
registryGeneration: 0,
|
|
339
|
+
topics: { [`${sessionId}:retired:${index}`]: record },
|
|
340
|
+
});
|
|
341
|
+
}
|
|
342
|
+
}
|
|
343
|
+
for (const [sessionId, epoch] of Object.entries(state.fences ?? {}))
|
|
344
|
+
if (!isValidBindingString(sessionId) || !validEpoch(epoch)) malformed();
|
|
345
|
+
for (const [sessionId, claim] of Object.entries(state.createClaims ?? {})) {
|
|
346
|
+
if (
|
|
347
|
+
!isValidBindingString(sessionId) ||
|
|
348
|
+
!isObject(claim) ||
|
|
349
|
+
claim.sessionId !== sessionId ||
|
|
350
|
+
!validEpoch(claim.authorityEpoch) ||
|
|
351
|
+
!validTimestamp(claim.createdAt) ||
|
|
352
|
+
!validOptionalString(claim.hostId) ||
|
|
353
|
+
!validOptionalString(claim.leaseOwner) ||
|
|
354
|
+
(claim.binding !== undefined && !validBinding(claim.binding))
|
|
355
|
+
)
|
|
356
|
+
malformed();
|
|
357
|
+
}
|
|
358
|
+
for (const [sessionId, job] of Object.entries(state.archiveJobs ?? {})) {
|
|
359
|
+
if (
|
|
360
|
+
!isValidBindingString(sessionId) ||
|
|
361
|
+
!isObject(job) ||
|
|
362
|
+
job.sessionId !== sessionId ||
|
|
363
|
+
!isValidTopicId(job.topicId) ||
|
|
364
|
+
!validEpoch(job.attempt) ||
|
|
365
|
+
!validEpoch(job.backoffMs) ||
|
|
366
|
+
!validTimestamp(job.nextAttemptAt) ||
|
|
367
|
+
(job.firstAttemptAt !== undefined && !validTimestamp(job.firstAttemptAt)) ||
|
|
368
|
+
(job.retryCount !== undefined && !validEpoch(job.retryCount)) ||
|
|
369
|
+
(job.safeDiagnostic !== undefined && typeof job.safeDiagnostic !== "string")
|
|
370
|
+
)
|
|
371
|
+
malformed();
|
|
372
|
+
}
|
|
373
|
+
for (const [sessionId, binding] of Object.entries(state.closedEndpoints ?? {}))
|
|
374
|
+
if (!isValidBindingString(sessionId) || !validBinding(binding)) malformed();
|
|
375
|
+
return state;
|
|
163
376
|
}
|
|
164
377
|
|
|
165
378
|
/**
|
|
@@ -183,8 +396,14 @@ export class TopicRegistry {
|
|
|
183
396
|
private readonly creatingBindings = new Map<string, TopicEndpointBinding>();
|
|
184
397
|
/** Monotonic authority epochs, including deletion fences for absent records. */
|
|
185
398
|
private readonly epochs = new Map<string, number>();
|
|
186
|
-
/**
|
|
187
|
-
readonly
|
|
399
|
+
/** Archive work is retained and retryable; records are never physically removed. */
|
|
400
|
+
private readonly archiveJobs = new Map<string, ArchiveJob>();
|
|
401
|
+
/** Durable pre-create claims; remote creation is forbidden until published. */
|
|
402
|
+
private readonly createClaims = new Map<string, TopicCreateClaim>();
|
|
403
|
+
/** Inactive predecessor evidence retained across successor generations. */
|
|
404
|
+
private readonly retiredTopics = new Map<string, TopicRecord[]>();
|
|
405
|
+
/** Generation of the last loaded/published snapshot. */
|
|
406
|
+
private registryGeneration = 0;
|
|
188
407
|
|
|
189
408
|
constructor(state: TopicRegistryState = emptyTopicRegistryState()) {
|
|
190
409
|
this.topics = new Map();
|
|
@@ -197,15 +416,76 @@ export class TopicRegistry {
|
|
|
197
416
|
this.byTopic.clear();
|
|
198
417
|
this.#ambiguousTopicIds.clear();
|
|
199
418
|
this.epochs.clear();
|
|
200
|
-
this
|
|
419
|
+
this.archiveJobs.clear();
|
|
420
|
+
this.createClaims.clear();
|
|
421
|
+
this.retiredTopics.clear();
|
|
201
422
|
this.load(state);
|
|
202
423
|
}
|
|
203
424
|
|
|
204
425
|
/** Merge serialized state and normalize authority fields from older releases. */
|
|
205
426
|
load(state: TopicRegistryState): void {
|
|
427
|
+
for (const [sessionId, records] of Object.entries(state.retiredTopics ?? {}))
|
|
428
|
+
if (Array.isArray(records))
|
|
429
|
+
this.retiredTopics.set(
|
|
430
|
+
sessionId,
|
|
431
|
+
records.map(record => ({ ...record })),
|
|
432
|
+
);
|
|
433
|
+
for (const [sessionId, job] of Object.entries(state.archiveJobs ?? {})) {
|
|
434
|
+
const attempt =
|
|
435
|
+
job && Number.isSafeInteger(job.attempt) && job.attempt >= 0
|
|
436
|
+
? job.attempt
|
|
437
|
+
: job &&
|
|
438
|
+
typeof job.retryCount === "number" &&
|
|
439
|
+
Number.isSafeInteger(job.retryCount) &&
|
|
440
|
+
job.retryCount >= 0
|
|
441
|
+
? job.retryCount
|
|
442
|
+
: undefined;
|
|
443
|
+
if (
|
|
444
|
+
job &&
|
|
445
|
+
job.sessionId === sessionId &&
|
|
446
|
+
isValidTopicId(job.topicId) &&
|
|
447
|
+
attempt !== undefined &&
|
|
448
|
+
Number.isFinite(job.nextAttemptAt)
|
|
449
|
+
)
|
|
450
|
+
this.archiveJobs.set(sessionId, {
|
|
451
|
+
sessionId,
|
|
452
|
+
topicId: job.topicId,
|
|
453
|
+
attempt: Math.min(8, attempt),
|
|
454
|
+
firstAttemptAt:
|
|
455
|
+
typeof job.firstAttemptAt === "number" && Number.isFinite(job.firstAttemptAt)
|
|
456
|
+
? job.firstAttemptAt
|
|
457
|
+
: job.nextAttemptAt,
|
|
458
|
+
backoffMs:
|
|
459
|
+
Number.isSafeInteger(job.backoffMs) && job.backoffMs >= 0
|
|
460
|
+
? Math.min(60_000, job.backoffMs)
|
|
461
|
+
: Math.min(60_000, 250 * 2 ** Math.min(8, attempt)),
|
|
462
|
+
nextAttemptAt: job.nextAttemptAt,
|
|
463
|
+
...(typeof job.safeDiagnostic === "string" ? { safeDiagnostic: job.safeDiagnostic.slice(0, 256) } : {}),
|
|
464
|
+
});
|
|
465
|
+
}
|
|
466
|
+
if (Number.isSafeInteger(state.registryGeneration) && (state.registryGeneration ?? -1) >= 0)
|
|
467
|
+
this.registryGeneration = Math.max(this.registryGeneration, state.registryGeneration!);
|
|
206
468
|
for (const [sessionId, epoch] of Object.entries(state.fences ?? {})) {
|
|
207
469
|
if (Number.isSafeInteger(epoch) && epoch >= 0) this.epochs.set(sessionId, epoch);
|
|
208
470
|
}
|
|
471
|
+
for (const [sessionId, claim] of Object.entries(state.createClaims ?? {})) {
|
|
472
|
+
if (
|
|
473
|
+
claim &&
|
|
474
|
+
claim.sessionId === sessionId &&
|
|
475
|
+
Number.isSafeInteger(claim.authorityEpoch) &&
|
|
476
|
+
claim.authorityEpoch >= 0 &&
|
|
477
|
+
Number.isFinite(claim.createdAt) &&
|
|
478
|
+
(claim.binding === undefined || hasValidBinding(claim.binding))
|
|
479
|
+
)
|
|
480
|
+
this.createClaims.set(sessionId, {
|
|
481
|
+
sessionId,
|
|
482
|
+
authorityEpoch: claim.authorityEpoch,
|
|
483
|
+
createdAt: claim.createdAt,
|
|
484
|
+
...(isValidBindingString(claim.hostId) ? { hostId: claim.hostId } : {}),
|
|
485
|
+
...(isValidBindingString(claim.leaseOwner) ? { leaseOwner: claim.leaseOwner } : {}),
|
|
486
|
+
...(claim.binding ? { binding: claim.binding } : {}),
|
|
487
|
+
});
|
|
488
|
+
}
|
|
209
489
|
|
|
210
490
|
for (const [sessionId, raw] of Object.entries(state.topics ?? {})) {
|
|
211
491
|
if (!raw || !isValidTopicId(raw.topicId)) continue;
|
|
@@ -217,6 +497,7 @@ export class TopicRegistry {
|
|
|
217
497
|
(typeof raw.userNameUpdateId === "number" &&
|
|
218
498
|
Number.isSafeInteger(raw.userNameUpdateId) &&
|
|
219
499
|
raw.userNameUpdateId >= 0));
|
|
500
|
+
const legacyDeletePending = (raw as { authorityState?: unknown }).authorityState === "delete_pending";
|
|
220
501
|
const hasValidReplayCursor =
|
|
221
502
|
typeof raw.replayGeneration === "number" &&
|
|
222
503
|
Number.isSafeInteger(raw.replayGeneration) &&
|
|
@@ -239,6 +520,9 @@ export class TopicRegistry {
|
|
|
239
520
|
const fenceSupersedesRecord = fenceEpoch > rawAuthorityEpoch;
|
|
240
521
|
const record: TopicRecord = {
|
|
241
522
|
topicId: raw.topicId,
|
|
523
|
+
topicOrigin: raw.topicOrigin === "user_created" ? "user_created" : "daemon_created",
|
|
524
|
+
sessionUuid:
|
|
525
|
+
typeof raw.sessionUuid === "string" && raw.sessionUuid.length > 0 ? raw.sessionUuid : randomUUID(),
|
|
242
526
|
identitySent: raw.identitySent === true,
|
|
243
527
|
createdAt: typeof raw.createdAt === "number" ? raw.createdAt : 0,
|
|
244
528
|
...(typeof raw.name === "string" ? { name: raw.name } : {}),
|
|
@@ -258,10 +542,26 @@ export class TopicRegistry {
|
|
|
258
542
|
: {}),
|
|
259
543
|
...(hasValidReplayCursor ? { replayGeneration: raw.replayGeneration, replaySeq: raw.replaySeq } : {}),
|
|
260
544
|
authorityEpoch: Math.max(rawAuthorityEpoch, fenceEpoch),
|
|
261
|
-
...(raw.authorityState === "
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
545
|
+
...(raw.authorityState === "disconnect_grace" ||
|
|
546
|
+
raw.authorityState === "archive_pending" ||
|
|
547
|
+
raw.authorityState === "archive_exhausted" ||
|
|
548
|
+
raw.authorityState === "inactive" ||
|
|
549
|
+
raw.authorityState === "legacy_quarantined" ||
|
|
550
|
+
legacyDeletePending ||
|
|
551
|
+
fenceSupersedesRecord
|
|
552
|
+
? {
|
|
553
|
+
authorityState:
|
|
554
|
+
raw.authorityState === "inactive"
|
|
555
|
+
? ("inactive" as const)
|
|
556
|
+
: raw.authorityState === "legacy_quarantined"
|
|
557
|
+
? ("legacy_quarantined" as const)
|
|
558
|
+
: raw.authorityState === "archive_exhausted"
|
|
559
|
+
? ("archive_exhausted" as const)
|
|
560
|
+
: raw.authorityState === "disconnect_grace" && !fenceSupersedesRecord
|
|
561
|
+
? ("disconnect_grace" as const)
|
|
562
|
+
: ("archive_pending" as const),
|
|
563
|
+
}
|
|
564
|
+
: { authorityState: "active" as const }),
|
|
265
565
|
...(isValidBindingString(raw.chatId) ? { chatId: raw.chatId } : {}),
|
|
266
566
|
...(isValidBindingString(raw.endpointKey) ? { endpointKey: raw.endpointKey } : {}),
|
|
267
567
|
...(isValidBindingString(raw.endpointDigest) ? { endpointDigest: raw.endpointDigest } : {}),
|
|
@@ -269,6 +569,18 @@ export class TopicRegistry {
|
|
|
269
569
|
...(isValidBindingGeneration(raw.endpointIncarnation)
|
|
270
570
|
? { endpointIncarnation: raw.endpointIncarnation }
|
|
271
571
|
: {}),
|
|
572
|
+
...(isValidBindingString(raw.leaseOwner) ? { leaseOwner: raw.leaseOwner } : {}),
|
|
573
|
+
...(typeof raw.leaseHeartbeatAt === "number" && Number.isFinite(raw.leaseHeartbeatAt)
|
|
574
|
+
? { leaseHeartbeatAt: raw.leaseHeartbeatAt }
|
|
575
|
+
: {}),
|
|
576
|
+
...(typeof raw.leaseExpiresAt === "number" && Number.isFinite(raw.leaseExpiresAt)
|
|
577
|
+
? { leaseExpiresAt: raw.leaseExpiresAt }
|
|
578
|
+
: {}),
|
|
579
|
+
...(isValidBindingString(raw.archiveHostId) ? { archiveHostId: raw.archiveHostId } : {}),
|
|
580
|
+
...(isValidBindingGeneration(raw.archiveLeaseEpoch) ? { archiveLeaseEpoch: raw.archiveLeaseEpoch } : {}),
|
|
581
|
+
...(typeof raw.disconnectGraceExpiresAt === "number" && Number.isFinite(raw.disconnectGraceExpiresAt)
|
|
582
|
+
? { disconnectGraceExpiresAt: raw.disconnectGraceExpiresAt }
|
|
583
|
+
: {}),
|
|
272
584
|
...(bindingMalformed ? { bindingMalformed: true as const } : {}),
|
|
273
585
|
};
|
|
274
586
|
this.epochs.set(sessionId, Math.max(fenceEpoch, record.authorityEpoch ?? 0));
|
|
@@ -284,14 +596,10 @@ export class TopicRegistry {
|
|
|
284
596
|
private rebuildInboundRoutes(): void {
|
|
285
597
|
this.byTopic.clear();
|
|
286
598
|
this.#ambiguousTopicIds.clear();
|
|
287
|
-
// A settled-but-not-yet-durable clear keeps its topic id quarantined: the
|
|
288
|
-
// clear is not authoritative until persisted, so no colliding survivor may
|
|
289
|
-
// become routable and no settled id may become adoptable during that write.
|
|
290
|
-
for (const settling of this.#settling.values()) this.#ambiguousTopicIds.add(settling.topicId);
|
|
291
599
|
const activeByTopic = new Map<string, string>();
|
|
292
600
|
|
|
293
601
|
for (const [sessionId, record] of this.topics) {
|
|
294
|
-
if (record.authorityState
|
|
602
|
+
if (record.authorityState !== "active" || record.bindingMalformed) {
|
|
295
603
|
this.#ambiguousTopicIds.add(record.topicId);
|
|
296
604
|
continue;
|
|
297
605
|
}
|
|
@@ -307,13 +615,6 @@ export class TopicRegistry {
|
|
|
307
615
|
}
|
|
308
616
|
}
|
|
309
617
|
|
|
310
|
-
/** Advance and publish the session authority epoch, saturating at the safe-integer bound. */
|
|
311
|
-
#advanceAuthorityEpoch(sessionId: string, base: number): number {
|
|
312
|
-
const epoch = nextAuthorityEpoch(base);
|
|
313
|
-
this.epochs.set(sessionId, epoch);
|
|
314
|
-
return epoch;
|
|
315
|
-
}
|
|
316
|
-
|
|
317
618
|
/** Resolve the owning session for a topic id (for fail-closed inbound routing). */
|
|
318
619
|
sessionForTopic(topicId: string): string | undefined {
|
|
319
620
|
return this.byTopic.get(topicId);
|
|
@@ -324,17 +625,50 @@ export class TopicRegistry {
|
|
|
324
625
|
return [...this.topics.keys()];
|
|
325
626
|
}
|
|
326
627
|
|
|
327
|
-
/** Persisted remote deletes that must be reconciled before recovery can proceed. */
|
|
328
|
-
deletePendingSessionIds(): string[] {
|
|
329
|
-
return [...this.topics].flatMap(([sessionId, record]) =>
|
|
330
|
-
record.authorityState === "delete_pending" ? [sessionId] : [],
|
|
331
|
-
);
|
|
332
|
-
}
|
|
333
|
-
|
|
334
628
|
/** The existing topic record for a session, if any. */
|
|
335
629
|
get(sessionId: string): TopicRecord | undefined {
|
|
336
630
|
return this.topics.get(sessionId);
|
|
337
631
|
}
|
|
632
|
+
/** Durable claims restored from disk require explicit authoritative reconciliation. */
|
|
633
|
+
pendingCreateClaims(): TopicCreateClaim[] {
|
|
634
|
+
return [...this.createClaims.values()].map(claim => ({
|
|
635
|
+
...claim,
|
|
636
|
+
...(claim.binding ? { binding: { ...claim.binding } } : {}),
|
|
637
|
+
}));
|
|
638
|
+
}
|
|
639
|
+
|
|
640
|
+
/**
|
|
641
|
+
* Resolve a durable pre-create claim only with authoritative topic evidence.
|
|
642
|
+
* An absent or malformed topic deliberately leaves the claim in place, fencing
|
|
643
|
+
* subsequent creators after a crash.
|
|
644
|
+
*/
|
|
645
|
+
reconcileCreateClaim(sessionId: string, topic?: TopicRecord): boolean {
|
|
646
|
+
const claim = this.createClaims.get(sessionId);
|
|
647
|
+
if (!claim || !topic || !isValidTopicId(topic.topicId) || topic.authorityState !== "active") return false;
|
|
648
|
+
if (topic.authorityEpoch !== claim.authorityEpoch) return false;
|
|
649
|
+
if (
|
|
650
|
+
claim.binding &&
|
|
651
|
+
(!hasCompleteBinding(topic) ||
|
|
652
|
+
!hasValidBinding(claim.binding) ||
|
|
653
|
+
topic.chatId !== claim.binding.chatId ||
|
|
654
|
+
topic.endpointKey !== claim.binding.endpointKey ||
|
|
655
|
+
topic.endpointDigest !== claim.binding.endpointDigest ||
|
|
656
|
+
topic.endpointGeneration !== claim.binding.endpointGeneration)
|
|
657
|
+
)
|
|
658
|
+
return false;
|
|
659
|
+
this.topics.set(sessionId, { ...topic });
|
|
660
|
+
this.createClaims.delete(sessionId);
|
|
661
|
+
this.rebuildInboundRoutes();
|
|
662
|
+
return true;
|
|
663
|
+
}
|
|
664
|
+
/** Clear a claim only when the local create attempt proved no remote topic was accepted. */
|
|
665
|
+
abandonCreateClaim(sessionId: string, authorityEpoch: number): boolean {
|
|
666
|
+
const claim = this.createClaims.get(sessionId);
|
|
667
|
+
if (!claim || claim.authorityEpoch !== authorityEpoch) return false;
|
|
668
|
+
this.createClaims.delete(sessionId);
|
|
669
|
+
this.creatingBindings.delete(sessionId);
|
|
670
|
+
return true;
|
|
671
|
+
}
|
|
338
672
|
|
|
339
673
|
/** Current immutable authority epoch for a creation lease. */
|
|
340
674
|
authorityEpoch(sessionId: string): number {
|
|
@@ -344,7 +678,7 @@ export class TopicRegistry {
|
|
|
344
678
|
/** Whether this session has an active, unambiguous topic authority. */
|
|
345
679
|
isActiveUnambiguous(sessionId: string): boolean {
|
|
346
680
|
const record = this.topics.get(sessionId);
|
|
347
|
-
return record?.authorityState
|
|
681
|
+
return record?.authorityState === "active" && this.byTopic.get(record?.topicId ?? "") === sessionId;
|
|
348
682
|
}
|
|
349
683
|
/**
|
|
350
684
|
* Pure read-only availability check for user-topic adoption. Rejects invalid
|
|
@@ -381,8 +715,13 @@ export class TopicRegistry {
|
|
|
381
715
|
const creating = [...this.creatingBindings].filter(
|
|
382
716
|
([sessionId, claim]) => canClaim(claim) && !excludesClaimant(sessionId),
|
|
383
717
|
);
|
|
384
|
-
|
|
385
|
-
|
|
718
|
+
const durableClaims = [...this.createClaims].filter(
|
|
719
|
+
([sessionId, claim]) => claim.binding !== undefined && canClaim(claim.binding) && !excludesClaimant(sessionId),
|
|
720
|
+
);
|
|
721
|
+
if (committed.length === 0 && staged.length === 0 && creating.length === 0 && durableClaims.length === 0)
|
|
722
|
+
return { state: "none" };
|
|
723
|
+
if (committed.length !== 1 || staged.length !== 0 || creating.length !== 0 || durableClaims.length !== 0)
|
|
724
|
+
return { state: "ambiguous" };
|
|
386
725
|
const [sessionId, record] = committed[0]!;
|
|
387
726
|
return record.chatId === binding.chatId &&
|
|
388
727
|
record.endpointKey === binding.endpointKey &&
|
|
@@ -411,6 +750,30 @@ export class TopicRegistry {
|
|
|
411
750
|
);
|
|
412
751
|
}
|
|
413
752
|
|
|
753
|
+
/**
|
|
754
|
+
* Retire a remotely settled inactive topic only when an authenticated
|
|
755
|
+
* successor proves a different endpoint authority. The inactive predecessor
|
|
756
|
+
* is serialized into retained history before the active slot is released.
|
|
757
|
+
*/
|
|
758
|
+
retireInactiveEndpointForSuccessor(sessionId: string, binding: TopicEndpointBinding): boolean {
|
|
759
|
+
const record = this.topics.get(sessionId);
|
|
760
|
+
if (
|
|
761
|
+
record?.authorityState !== "inactive" ||
|
|
762
|
+
record.bindingMalformed ||
|
|
763
|
+
!hasCompleteBinding(record) ||
|
|
764
|
+
!hasValidBinding(binding) ||
|
|
765
|
+
(record.endpointKey === binding.endpointKey && record.endpointDigest === binding.endpointDigest)
|
|
766
|
+
)
|
|
767
|
+
return false;
|
|
768
|
+
const history = this.retiredTopics.get(sessionId) ?? [];
|
|
769
|
+
history.push({ ...record });
|
|
770
|
+
this.retiredTopics.set(sessionId, history);
|
|
771
|
+
this.topics.delete(sessionId);
|
|
772
|
+
this.byTopic.delete(record.topicId);
|
|
773
|
+
this.archiveJobs.delete(sessionId);
|
|
774
|
+
this.createClaims.delete(sessionId);
|
|
775
|
+
return true;
|
|
776
|
+
}
|
|
414
777
|
/**
|
|
415
778
|
* Rebind an existing topic to an authenticated successor endpoint. The exact
|
|
416
779
|
* logical session id is proved by replay before this method is called. A
|
|
@@ -426,11 +789,10 @@ export class TopicRegistry {
|
|
|
426
789
|
): "bound" | "unchanged" | "rejected" {
|
|
427
790
|
const record = this.topics.get(sessionId);
|
|
428
791
|
if (
|
|
429
|
-
|
|
430
|
-
record.authorityState === "delete_pending" ||
|
|
792
|
+
(record?.authorityState !== "active" && record?.authorityState !== "disconnect_grace") ||
|
|
431
793
|
record.bindingMalformed ||
|
|
432
794
|
!hasValidBinding(binding) ||
|
|
433
|
-
!this.isActiveUnambiguous(sessionId)
|
|
795
|
+
(record.authorityState === "active" && !this.isActiveUnambiguous(sessionId))
|
|
434
796
|
)
|
|
435
797
|
return "rejected";
|
|
436
798
|
if (hasAnyBinding(record) && !hasCompleteBinding(record)) return "rejected";
|
|
@@ -460,12 +822,18 @@ export class TopicRegistry {
|
|
|
460
822
|
record.endpointKey !== binding.endpointKey ||
|
|
461
823
|
record.endpointDigest !== binding.endpointDigest ||
|
|
462
824
|
record.endpointGeneration !== binding.endpointGeneration;
|
|
463
|
-
if (!changed) return "unchanged";
|
|
825
|
+
if (!changed && record.authorityState === "active") return "unchanged";
|
|
464
826
|
record.chatId = binding.chatId;
|
|
465
827
|
record.endpointKey = binding.endpointKey;
|
|
466
828
|
record.endpointDigest = binding.endpointDigest;
|
|
467
829
|
record.endpointGeneration = binding.endpointGeneration;
|
|
468
830
|
if (!sameEndpoint) record.endpointIncarnation = (record.endpointIncarnation ?? 0) + 1;
|
|
831
|
+
if (record.authorityState === "disconnect_grace") {
|
|
832
|
+
record.authorityState = "active";
|
|
833
|
+
delete record.orphanedAt;
|
|
834
|
+
delete record.disconnectGraceExpiresAt;
|
|
835
|
+
this.rebuildInboundRoutes();
|
|
836
|
+
}
|
|
469
837
|
return "bound";
|
|
470
838
|
}
|
|
471
839
|
|
|
@@ -512,29 +880,44 @@ export class TopicRegistry {
|
|
|
512
880
|
topicOrigin?: TopicRecord["topicOrigin"],
|
|
513
881
|
): Promise<TopicRecord> {
|
|
514
882
|
const existing = this.topics.get(sessionId);
|
|
515
|
-
if (existing
|
|
883
|
+
if (existing && existing.authorityState !== "active") throw new Error("topic authority is archive-fenced");
|
|
516
884
|
if (existing?.bindingMalformed) throw new Error("topic authority binding is quarantined");
|
|
517
885
|
if (existing) return existing;
|
|
518
886
|
const pending = this.inflight.get(sessionId);
|
|
519
887
|
if (pending) return pending;
|
|
888
|
+
// A claim loaded after a crash proves a create may have reached Telegram.
|
|
889
|
+
// It must not be replaced or retried until explicit authoritative recovery.
|
|
890
|
+
if (this.createClaims.has(sessionId)) throw new Error("topic create claim requires reconciliation");
|
|
520
891
|
const epoch = this.epochs.get(sessionId) ?? 0;
|
|
521
|
-
if (epoch >= Number.MAX_SAFE_INTEGER) throw new Error("topic authority epoch exhausted");
|
|
892
|
+
if (epoch >= Number.MAX_SAFE_INTEGER) throw new Error("topic authority epoch is exhausted");
|
|
522
893
|
// Publish the compatible endpoint claim before invoking `create`: the callback
|
|
523
894
|
// may immediately begin a remote create and identity-less recovery must never
|
|
524
895
|
// observe a false absence during that await.
|
|
525
896
|
if (binding) this.creatingBindings.set(sessionId, binding);
|
|
526
897
|
this.transientClaimants.set(sessionId, transientClaimant);
|
|
898
|
+
this.createClaims.set(sessionId, {
|
|
899
|
+
sessionId,
|
|
900
|
+
authorityEpoch: epoch,
|
|
901
|
+
createdAt: now(),
|
|
902
|
+
...(binding ? { binding } : {}),
|
|
903
|
+
});
|
|
527
904
|
const promise = (async () => {
|
|
905
|
+
// Persist this ambiguity before createForumTopic; a crash must fence,
|
|
906
|
+
// rather than silently permit, a duplicate remote topic.
|
|
907
|
+
await commit?.();
|
|
528
908
|
const topicId = await create();
|
|
529
909
|
if (!isValidTopicId(topicId)) throw new Error("createForumTopic: invalid message_thread_id");
|
|
530
910
|
const revoked = (this.epochs.get(sessionId) ?? 0) !== epoch;
|
|
531
911
|
const record: TopicRecord = {
|
|
532
912
|
topicId,
|
|
913
|
+
topicOrigin: topicOrigin ?? "daemon_created",
|
|
914
|
+
sessionUuid: randomUUID(),
|
|
533
915
|
name,
|
|
534
916
|
identitySent: false,
|
|
535
917
|
createdAt: now(),
|
|
536
918
|
authorityEpoch: revoked ? (this.epochs.get(sessionId) ?? 0) : epoch,
|
|
537
919
|
creationLeaseEpoch: epoch,
|
|
920
|
+
authorityState: revoked ? "archive_pending" : "active",
|
|
538
921
|
...(binding
|
|
539
922
|
? {
|
|
540
923
|
chatId: binding.chatId,
|
|
@@ -546,14 +929,13 @@ export class TopicRegistry {
|
|
|
546
929
|
: { endpointGeneration: binding.endpointGeneration }),
|
|
547
930
|
}
|
|
548
931
|
: {}),
|
|
549
|
-
...(revoked ? { authorityState: "delete_pending" as const } : {}),
|
|
550
|
-
...(topicOrigin === "user_created" ? { topicOrigin: "user_created" as const } : {}),
|
|
551
932
|
};
|
|
552
933
|
if (revoked) {
|
|
553
934
|
this.topics.set(sessionId, record);
|
|
554
935
|
throw new Error("topic authority was revoked during creation");
|
|
555
936
|
}
|
|
556
937
|
this.staged.set(sessionId, record);
|
|
938
|
+
this.createClaims.delete(sessionId);
|
|
557
939
|
try {
|
|
558
940
|
await commit?.();
|
|
559
941
|
} catch (error) {
|
|
@@ -563,7 +945,7 @@ export class TopicRegistry {
|
|
|
563
945
|
this.staged.delete(sessionId);
|
|
564
946
|
if ((this.epochs.get(sessionId) ?? 0) !== epoch) {
|
|
565
947
|
record.authorityEpoch = this.epochs.get(sessionId) ?? 0;
|
|
566
|
-
record.authorityState = "
|
|
948
|
+
record.authorityState = "archive_pending";
|
|
567
949
|
this.topics.set(sessionId, record);
|
|
568
950
|
throw new Error("topic authority was revoked during creation");
|
|
569
951
|
}
|
|
@@ -584,6 +966,7 @@ export class TopicRegistry {
|
|
|
584
966
|
this.inflight.delete(sessionId);
|
|
585
967
|
this.creatingBindings.delete(sessionId);
|
|
586
968
|
this.transientClaimants.delete(sessionId);
|
|
969
|
+
if (this.topics.has(sessionId)) this.createClaims.delete(sessionId);
|
|
587
970
|
}
|
|
588
971
|
}
|
|
589
972
|
|
|
@@ -592,6 +975,68 @@ export class TopicRegistry {
|
|
|
592
975
|
const record = this.topics.get(sessionId);
|
|
593
976
|
if (record) record.identitySent = true;
|
|
594
977
|
}
|
|
978
|
+
/** Generation used as the compare value for a shared CAS publication. */
|
|
979
|
+
registryVersion(): number {
|
|
980
|
+
return this.registryGeneration;
|
|
981
|
+
}
|
|
982
|
+
|
|
983
|
+
/** Advance only after a successful shared compare-and-set publication. */
|
|
984
|
+
markRegistryPublished(generation: number): void {
|
|
985
|
+
if (!Number.isSafeInteger(generation) || generation < this.registryGeneration)
|
|
986
|
+
throw new Error("invalid topic registry generation");
|
|
987
|
+
this.registryGeneration = generation;
|
|
988
|
+
}
|
|
989
|
+
|
|
990
|
+
/**
|
|
991
|
+
* Acquire or renew a host lease. Another unexpired host is never displaced;
|
|
992
|
+
* a disconnected owner may resume the exact topic during its grace window.
|
|
993
|
+
*/
|
|
994
|
+
acquireLease(sessionId: string, hostId: string, now: number, ttlMs: number, graceMs: number): boolean {
|
|
995
|
+
const record = this.topics.get(sessionId);
|
|
996
|
+
if (
|
|
997
|
+
!record ||
|
|
998
|
+
!isValidBindingString(hostId) ||
|
|
999
|
+
!Number.isFinite(now) ||
|
|
1000
|
+
ttlMs <= 0 ||
|
|
1001
|
+
graceMs < 0 ||
|
|
1002
|
+
record.authorityState === "archive_pending" ||
|
|
1003
|
+
record.authorityState === "archive_exhausted" ||
|
|
1004
|
+
record.authorityState === "inactive" ||
|
|
1005
|
+
record.authorityState === "legacy_quarantined"
|
|
1006
|
+
)
|
|
1007
|
+
return false;
|
|
1008
|
+
if (record.leaseOwner !== undefined && record.leaseOwner !== hostId && (record.leaseExpiresAt ?? 0) > now)
|
|
1009
|
+
return false;
|
|
1010
|
+
if (
|
|
1011
|
+
record.authorityState === "disconnect_grace" &&
|
|
1012
|
+
record.disconnectGraceExpiresAt !== undefined &&
|
|
1013
|
+
record.disconnectGraceExpiresAt < now
|
|
1014
|
+
)
|
|
1015
|
+
return false;
|
|
1016
|
+
record.leaseOwner = hostId;
|
|
1017
|
+
record.leaseHeartbeatAt = now;
|
|
1018
|
+
record.leaseExpiresAt = now + ttlMs;
|
|
1019
|
+
if (record.authorityState === "disconnect_grace") {
|
|
1020
|
+
record.authorityState = "active";
|
|
1021
|
+
delete record.orphanedAt;
|
|
1022
|
+
delete record.disconnectGraceExpiresAt;
|
|
1023
|
+
this.rebuildInboundRoutes();
|
|
1024
|
+
}
|
|
1025
|
+
return true;
|
|
1026
|
+
}
|
|
1027
|
+
|
|
1028
|
+
/** Record a disconnect without losing the topic identity needed for a grace resume. */
|
|
1029
|
+
releaseLeaseToGrace(sessionId: string, hostId: string, now: number, graceMs: number): boolean {
|
|
1030
|
+
const record = this.topics.get(sessionId);
|
|
1031
|
+
if (!record || record.leaseOwner !== hostId || record.authorityState !== "active" || graceMs < 0) return false;
|
|
1032
|
+
record.authorityState = "disconnect_grace";
|
|
1033
|
+
record.orphanedAt = now;
|
|
1034
|
+
record.leaseHeartbeatAt = now;
|
|
1035
|
+
record.leaseExpiresAt = now;
|
|
1036
|
+
record.disconnectGraceExpiresAt = now + graceMs;
|
|
1037
|
+
this.rebuildInboundRoutes();
|
|
1038
|
+
return true;
|
|
1039
|
+
}
|
|
595
1040
|
|
|
596
1041
|
/** Whether the identity header still needs sending for this session. */
|
|
597
1042
|
needsIdentity(sessionId: string): boolean {
|
|
@@ -609,15 +1054,20 @@ export class TopicRegistry {
|
|
|
609
1054
|
/** Start the orphan grace clock on the first positive liveness-loss observation. */
|
|
610
1055
|
markOrphaned(sessionId: string, now: number): boolean {
|
|
611
1056
|
const record = this.topics.get(sessionId);
|
|
612
|
-
if (
|
|
1057
|
+
if (record?.authorityState !== "active" || record.orphanedAt !== undefined) return false;
|
|
613
1058
|
record.orphanedAt = now;
|
|
1059
|
+
record.authorityState = "disconnect_grace";
|
|
1060
|
+
record.disconnectGraceExpiresAt = now + 30_000;
|
|
1061
|
+
this.rebuildInboundRoutes();
|
|
614
1062
|
return true;
|
|
615
1063
|
}
|
|
616
1064
|
|
|
617
1065
|
/** Clear a prior orphan observation after the endpoint is positively live again. */
|
|
618
1066
|
clearOrphaned(sessionId: string): boolean {
|
|
619
1067
|
const record = this.topics.get(sessionId);
|
|
620
|
-
if (
|
|
1068
|
+
if (record?.authorityState !== "disconnect_grace" || record.orphanedAt === undefined) return false;
|
|
1069
|
+
record.authorityState = "active";
|
|
1070
|
+
delete record.disconnectGraceExpiresAt;
|
|
621
1071
|
delete record.orphanedAt;
|
|
622
1072
|
return true;
|
|
623
1073
|
}
|
|
@@ -697,8 +1147,8 @@ export class TopicRegistry {
|
|
|
697
1147
|
record.nameReconcilePending = false;
|
|
698
1148
|
}
|
|
699
1149
|
|
|
700
|
-
/** Capture only authority fields that a failed
|
|
701
|
-
|
|
1150
|
+
/** Capture only authority fields that a failed archive-fence publication may restore. */
|
|
1151
|
+
captureArchiveAuthority(sessionId: string): TopicArchiveAuthoritySnapshot {
|
|
702
1152
|
const record = this.topics.get(sessionId);
|
|
703
1153
|
return {
|
|
704
1154
|
sessionId,
|
|
@@ -710,8 +1160,8 @@ export class TopicRegistry {
|
|
|
710
1160
|
};
|
|
711
1161
|
}
|
|
712
1162
|
|
|
713
|
-
/** Restore a failed
|
|
714
|
-
|
|
1163
|
+
/** Restore a failed archive fence only while its exact authority mutation remains current. */
|
|
1164
|
+
restoreArchiveAuthority(snapshot: TopicArchiveAuthoritySnapshot): boolean {
|
|
715
1165
|
const record = this.topics.get(snapshot.sessionId);
|
|
716
1166
|
const authorityBase = Math.max(snapshot.fenceEpoch ?? 0, snapshot.authorityEpoch ?? 0);
|
|
717
1167
|
if (authorityBase >= Number.MAX_SAFE_INTEGER) return false;
|
|
@@ -722,7 +1172,7 @@ export class TopicRegistry {
|
|
|
722
1172
|
} else if (
|
|
723
1173
|
!record ||
|
|
724
1174
|
record.topicId !== snapshot.topicId ||
|
|
725
|
-
record.authorityState !== "
|
|
1175
|
+
record.authorityState !== "archive_pending" ||
|
|
726
1176
|
record.authorityEpoch !== deleteEpoch
|
|
727
1177
|
) {
|
|
728
1178
|
return false;
|
|
@@ -736,17 +1186,13 @@ export class TopicRegistry {
|
|
|
736
1186
|
return true;
|
|
737
1187
|
}
|
|
738
1188
|
|
|
739
|
-
/**
|
|
740
|
-
|
|
741
|
-
*
|
|
742
|
-
* Settlement rebuilds derived routes, which can make a surviving colliding
|
|
743
|
-
* record routable. Rebuild again on successful restoration so the reinstated
|
|
744
|
-
* fence re-quarantines the topic id instead of leaving inbound routing open to
|
|
745
|
-
* the collision partner despite the restored fence.
|
|
746
|
-
*/
|
|
747
|
-
restoreDeleteFence(snapshot: TopicDeleteAuthoritySnapshot): boolean {
|
|
1189
|
+
/** Restore the exact archive fence after a failed compensation publication. */
|
|
1190
|
+
restoreArchiveFence(snapshot: TopicArchiveAuthoritySnapshot): boolean {
|
|
748
1191
|
const record = this.topics.get(snapshot.sessionId);
|
|
749
|
-
const
|
|
1192
|
+
const authorityBase = Math.max(snapshot.fenceEpoch ?? 0, snapshot.authorityEpoch ?? 0);
|
|
1193
|
+
if (authorityBase >= Number.MAX_SAFE_INTEGER) return false;
|
|
1194
|
+
const deleteEpoch = nextAuthorityEpoch(authorityBase);
|
|
1195
|
+
if (this.epochs.get(snapshot.sessionId) !== deleteEpoch) return false;
|
|
750
1196
|
if (snapshot.topicId === undefined) {
|
|
751
1197
|
if (record) return false;
|
|
752
1198
|
} else if (!record) {
|
|
@@ -754,32 +1200,74 @@ export class TopicRegistry {
|
|
|
754
1200
|
this.topics.set(snapshot.sessionId, {
|
|
755
1201
|
...snapshot.record,
|
|
756
1202
|
authorityEpoch: deleteEpoch,
|
|
757
|
-
authorityState: "
|
|
1203
|
+
authorityState: "archive_pending",
|
|
758
1204
|
});
|
|
759
1205
|
} else if (record.topicId !== snapshot.topicId) {
|
|
760
1206
|
return false;
|
|
761
1207
|
} else {
|
|
762
1208
|
record.authorityEpoch = deleteEpoch;
|
|
763
|
-
record.authorityState = "
|
|
1209
|
+
record.authorityState = "archive_pending";
|
|
1210
|
+
if (this.byTopic.get(record.topicId) === snapshot.sessionId) this.byTopic.delete(record.topicId);
|
|
764
1211
|
}
|
|
765
1212
|
this.epochs.set(snapshot.sessionId, deleteEpoch);
|
|
766
|
-
this.rebuildInboundRoutes();
|
|
767
1213
|
return true;
|
|
768
1214
|
}
|
|
769
1215
|
|
|
770
|
-
/** Fence new work before the remote
|
|
771
|
-
|
|
1216
|
+
/** Fence new work before the remote archive starts, including an absent in-flight create. */
|
|
1217
|
+
beginArchive(sessionId: string, hostId?: string, now = Date.now()): TopicRecord | undefined {
|
|
772
1218
|
const record = this.topics.get(sessionId);
|
|
773
|
-
|
|
774
|
-
|
|
775
|
-
|
|
776
|
-
|
|
1219
|
+
if (
|
|
1220
|
+
record?.topicOrigin === "user_created" ||
|
|
1221
|
+
((record?.archiveHostId !== undefined || record?.leaseOwner !== undefined) &&
|
|
1222
|
+
record.archiveHostId !== hostId &&
|
|
1223
|
+
record.leaseOwner !== hostId &&
|
|
1224
|
+
(record.leaseExpiresAt ?? 0) > now)
|
|
1225
|
+
)
|
|
1226
|
+
return undefined;
|
|
1227
|
+
const authorityBase = Math.max(this.epochs.get(sessionId) ?? 0, record?.authorityEpoch ?? 0);
|
|
1228
|
+
if (authorityBase >= Number.MAX_SAFE_INTEGER) {
|
|
1229
|
+
this.epochs.set(sessionId, Number.MAX_SAFE_INTEGER);
|
|
1230
|
+
if (record) {
|
|
1231
|
+
record.authorityEpoch = Number.MAX_SAFE_INTEGER;
|
|
1232
|
+
record.authorityState = "archive_exhausted";
|
|
1233
|
+
if (this.byTopic.get(record.topicId) === sessionId) this.byTopic.delete(record.topicId);
|
|
1234
|
+
}
|
|
1235
|
+
return undefined;
|
|
1236
|
+
}
|
|
1237
|
+
const epoch = nextAuthorityEpoch(authorityBase);
|
|
1238
|
+
this.epochs.set(sessionId, epoch);
|
|
777
1239
|
if (!record) return undefined;
|
|
778
1240
|
record.authorityEpoch = epoch;
|
|
779
|
-
record.authorityState = "
|
|
1241
|
+
record.authorityState = "archive_pending";
|
|
1242
|
+
if (hostId) record.archiveHostId = hostId;
|
|
1243
|
+
record.archiveLeaseEpoch = epoch;
|
|
780
1244
|
if (this.byTopic.get(record.topicId) === sessionId) this.byTopic.delete(record.topicId);
|
|
781
1245
|
return record;
|
|
782
1246
|
}
|
|
1247
|
+
/** Verify the durable archive initiator immediately before remote dispatch. */
|
|
1248
|
+
archiveAuthorityAllows(sessionId: string, hostId: string, pairedChatId: string, now: number): boolean;
|
|
1249
|
+
/** @deprecated Production dispatch must provide the current paired chat id. */
|
|
1250
|
+
archiveAuthorityAllows(sessionId: string, hostId: string, now: number): boolean;
|
|
1251
|
+
archiveAuthorityAllows(
|
|
1252
|
+
sessionId: string,
|
|
1253
|
+
hostId: string,
|
|
1254
|
+
pairedChatIdOrNow: string | number,
|
|
1255
|
+
suppliedNow?: number,
|
|
1256
|
+
): boolean {
|
|
1257
|
+
const record = this.topics.get(sessionId);
|
|
1258
|
+
const pairedChatId = typeof pairedChatIdOrNow === "string" ? pairedChatIdOrNow : record?.chatId;
|
|
1259
|
+
const now = typeof pairedChatIdOrNow === "number" ? pairedChatIdOrNow : suppliedNow;
|
|
1260
|
+
return (
|
|
1261
|
+
record?.topicOrigin === "daemon_created" &&
|
|
1262
|
+
record.chatId === pairedChatId &&
|
|
1263
|
+
typeof now === "number" &&
|
|
1264
|
+
record.authorityState === "archive_pending" &&
|
|
1265
|
+
record.archiveHostId === hostId &&
|
|
1266
|
+
record.archiveLeaseEpoch === record.authorityEpoch &&
|
|
1267
|
+
(record.authorityEpoch ?? Number.MAX_SAFE_INTEGER) < Number.MAX_SAFE_INTEGER &&
|
|
1268
|
+
(record.leaseOwner === undefined || record.leaseOwner === hostId || (record.leaseExpiresAt ?? 0) <= now)
|
|
1269
|
+
);
|
|
1270
|
+
}
|
|
783
1271
|
|
|
784
1272
|
/** Retain an accepted create as deletion-fenced before remote compensation can begin. */
|
|
785
1273
|
fenceAcceptedCreate(
|
|
@@ -788,15 +1276,19 @@ export class TopicRegistry {
|
|
|
788
1276
|
now: () => number = Date.now,
|
|
789
1277
|
name?: string,
|
|
790
1278
|
binding?: TopicEndpointBinding,
|
|
1279
|
+
topicOrigin?: TopicRecord["topicOrigin"],
|
|
1280
|
+
archiveChatId?: string,
|
|
791
1281
|
): TopicRecord {
|
|
792
1282
|
const epoch = Math.max(this.epochs.get(sessionId) ?? 0, this.topics.get(sessionId)?.authorityEpoch ?? 0);
|
|
793
1283
|
const record: TopicRecord = {
|
|
794
1284
|
topicId,
|
|
1285
|
+
topicOrigin: topicOrigin ?? this.topics.get(sessionId)?.topicOrigin ?? "daemon_created",
|
|
1286
|
+
sessionUuid: randomUUID(),
|
|
795
1287
|
name,
|
|
796
1288
|
identitySent: false,
|
|
797
1289
|
createdAt: now(),
|
|
798
1290
|
authorityEpoch: epoch,
|
|
799
|
-
authorityState: "
|
|
1291
|
+
authorityState: "archive_pending",
|
|
800
1292
|
...(binding
|
|
801
1293
|
? {
|
|
802
1294
|
chatId: binding.chatId,
|
|
@@ -807,7 +1299,9 @@ export class TopicRegistry {
|
|
|
807
1299
|
? {}
|
|
808
1300
|
: { endpointGeneration: binding.endpointGeneration }),
|
|
809
1301
|
}
|
|
810
|
-
:
|
|
1302
|
+
: archiveChatId
|
|
1303
|
+
? { chatId: archiveChatId }
|
|
1304
|
+
: {}),
|
|
811
1305
|
};
|
|
812
1306
|
this.topics.set(sessionId, record);
|
|
813
1307
|
if (this.byTopic.get(topicId) === sessionId) this.byTopic.delete(topicId);
|
|
@@ -820,9 +1314,12 @@ export class TopicRegistry {
|
|
|
820
1314
|
sessionId: string,
|
|
821
1315
|
topicId: string,
|
|
822
1316
|
creationLeaseEpoch: number,
|
|
1317
|
+
hostId: string,
|
|
823
1318
|
now: () => number = Date.now,
|
|
824
1319
|
name?: string,
|
|
825
1320
|
binding?: TopicEndpointBinding,
|
|
1321
|
+
topicOrigin?: TopicRecord["topicOrigin"],
|
|
1322
|
+
archiveChatId?: string,
|
|
826
1323
|
): TopicRecord | undefined {
|
|
827
1324
|
const record = this.topics.get(sessionId);
|
|
828
1325
|
const matchesBinding =
|
|
@@ -836,9 +1333,12 @@ export class TopicRegistry {
|
|
|
836
1333
|
: (this.epochs.get(sessionId) ?? 0) !== creationLeaseEpoch
|
|
837
1334
|
)
|
|
838
1335
|
return undefined;
|
|
839
|
-
this.
|
|
840
|
-
|
|
1336
|
+
const archiveFence = this.beginArchive(sessionId, hostId, now());
|
|
1337
|
+
if (record && !archiveFence) return undefined;
|
|
1338
|
+
const fenced = this.fenceAcceptedCreate(sessionId, topicId, now, name, binding, topicOrigin, archiveChatId);
|
|
841
1339
|
fenced.creationLeaseEpoch = creationLeaseEpoch;
|
|
1340
|
+
fenced.archiveHostId = hostId;
|
|
1341
|
+
fenced.archiveLeaseEpoch = fenced.authorityEpoch;
|
|
842
1342
|
return fenced;
|
|
843
1343
|
}
|
|
844
1344
|
|
|
@@ -848,99 +1348,92 @@ export class TopicRegistry {
|
|
|
848
1348
|
}
|
|
849
1349
|
|
|
850
1350
|
/**
|
|
851
|
-
*
|
|
852
|
-
*
|
|
853
|
-
*
|
|
854
|
-
* `dispatchedAuthorityEpoch` is the authority epoch the caller held when it
|
|
855
|
-
* dispatched the remote delete. Settlement requires that epoch to still equal
|
|
856
|
-
* both the record's own authority epoch and the registry's current epoch for
|
|
857
|
-
* the session, so a held earlier delete can never settle a newer fence: if a
|
|
858
|
-
* scan or close-started delete re-fenced the same session/topic after this
|
|
859
|
-
* delete was dispatched, the stale definite result is refused and the newer
|
|
860
|
-
* `delete_pending` record plus its topic-id quarantine stay intact.
|
|
861
|
-
*
|
|
862
|
-
* An epoch outside the safe-integer range cannot identify a generation, and at
|
|
863
|
-
* `Number.MAX_SAFE_INTEGER` epoch advancement has saturated so distinct
|
|
864
|
-
* generations are no longer distinguishable. Both fail closed: settlement can
|
|
865
|
-
* no longer be proven fresh, so the fence is kept.
|
|
866
|
-
*
|
|
867
|
-
* Derived routing tables are deliberately NOT rebuilt here. Until the cleared
|
|
868
|
-
* snapshot is durably persisted the clear is not authoritative, so publishing
|
|
869
|
-
* routes now would make a colliding survivor routable (and the settled id
|
|
870
|
-
* adoptable) during the held write, and a failed persist would re-quarantine
|
|
871
|
-
* too late. Publish with {@link commitSettledDelete} once the persist
|
|
872
|
-
* succeeds, or undo with {@link rollbackSettledDelete} when it fails.
|
|
1351
|
+
* Retain a topic record after a definite remote archive only while the exact
|
|
1352
|
+
* dispatched authority epoch is still current.
|
|
873
1353
|
*/
|
|
874
|
-
|
|
875
|
-
if (
|
|
876
|
-
|
|
1354
|
+
settleArchive(sessionId: string, topicId: string, dispatchedAuthorityEpoch: number): boolean {
|
|
1355
|
+
if (
|
|
1356
|
+
!Number.isSafeInteger(dispatchedAuthorityEpoch) ||
|
|
1357
|
+
dispatchedAuthorityEpoch < 0 ||
|
|
1358
|
+
dispatchedAuthorityEpoch >= Number.MAX_SAFE_INTEGER
|
|
1359
|
+
)
|
|
1360
|
+
return false;
|
|
877
1361
|
const record = this.topics.get(sessionId);
|
|
878
|
-
if (
|
|
879
|
-
|
|
880
|
-
|
|
881
|
-
|
|
882
|
-
|
|
883
|
-
|
|
884
|
-
|
|
885
|
-
this.#ambiguousTopicIds.add(topicId);
|
|
886
|
-
if (this.byTopic.get(topicId) === sessionId) this.byTopic.delete(topicId);
|
|
887
|
-
return { sessionId, topicId, settledEpoch: dispatchedAuthorityEpoch };
|
|
888
|
-
}
|
|
889
|
-
|
|
890
|
-
/**
|
|
891
|
-
* Phase 2: publish derived routing tables once the cleared state is durable.
|
|
892
|
-
*
|
|
893
|
-
* Only here does the settled topic id lose its quarantine, so a surviving
|
|
894
|
-
* colliding record becomes routable and a settled id becomes adoptable without
|
|
895
|
-
* waiting for a daemon restart.
|
|
896
|
-
*/
|
|
897
|
-
commitSettledDelete(settled: TopicSettledDelete): boolean {
|
|
898
|
-
const pending = this.#settling.get(settled.sessionId);
|
|
899
|
-
if (!pending || pending.topicId !== settled.topicId || pending.settledEpoch !== settled.settledEpoch)
|
|
1362
|
+
if (
|
|
1363
|
+
!record ||
|
|
1364
|
+
record.topicId !== topicId ||
|
|
1365
|
+
record.authorityState !== "archive_pending" ||
|
|
1366
|
+
record.authorityEpoch !== dispatchedAuthorityEpoch ||
|
|
1367
|
+
this.authorityEpoch(sessionId) !== dispatchedAuthorityEpoch
|
|
1368
|
+
)
|
|
900
1369
|
return false;
|
|
901
|
-
|
|
902
|
-
this.
|
|
1370
|
+
record.authorityState = "inactive";
|
|
1371
|
+
if (this.byTopic.get(record.topicId) === sessionId) this.byTopic.delete(record.topicId);
|
|
1372
|
+
this.archiveJobs.delete(sessionId);
|
|
903
1373
|
return true;
|
|
904
1374
|
}
|
|
905
1375
|
|
|
906
|
-
/**
|
|
907
|
-
|
|
908
|
-
|
|
909
|
-
|
|
910
|
-
|
|
911
|
-
|
|
912
|
-
|
|
913
|
-
|
|
914
|
-
|
|
915
|
-
|
|
916
|
-
|
|
917
|
-
|
|
918
|
-
|
|
919
|
-
|
|
920
|
-
|
|
921
|
-
|
|
922
|
-
this.#settling.delete(settled.sessionId);
|
|
923
|
-
this.topics.set(settled.sessionId, {
|
|
924
|
-
...pending.record,
|
|
925
|
-
authorityEpoch: settled.settledEpoch,
|
|
926
|
-
authorityState: "delete_pending",
|
|
1376
|
+
/** Durable archive jobs that are eligible for a retry at `now`. */
|
|
1377
|
+
archivePendingSessionIds(now = Date.now()): string[] {
|
|
1378
|
+
return [...this.topics].flatMap(([sessionId, record]) => {
|
|
1379
|
+
if (record.authorityState === "archive_exhausted") {
|
|
1380
|
+
const currentEpoch = Math.max(this.epochs.get(sessionId) ?? 0, record.authorityEpoch ?? 0);
|
|
1381
|
+
if (currentEpoch < Number.MAX_SAFE_INTEGER) {
|
|
1382
|
+
const epoch = Math.min(currentEpoch + 1, Number.MAX_SAFE_INTEGER - 1);
|
|
1383
|
+
record.authorityEpoch = epoch;
|
|
1384
|
+
record.archiveLeaseEpoch = epoch;
|
|
1385
|
+
record.authorityState = "archive_pending";
|
|
1386
|
+
}
|
|
1387
|
+
}
|
|
1388
|
+
return (record.authorityState === "archive_pending" || record.authorityState === "archive_exhausted") &&
|
|
1389
|
+
(this.archiveJobs.get(sessionId)?.nextAttemptAt ?? 0) <= now
|
|
1390
|
+
? [sessionId]
|
|
1391
|
+
: [];
|
|
927
1392
|
});
|
|
928
|
-
|
|
929
|
-
|
|
930
|
-
|
|
1393
|
+
}
|
|
1394
|
+
/** Durable/manual recovery candidates that exceeded the automatic archive retry budget. */
|
|
1395
|
+
archiveExhaustedSessionIds(): string[] {
|
|
1396
|
+
return [...this.topics].flatMap(([sessionId, record]) =>
|
|
1397
|
+
record.authorityState === "archive_exhausted" ? [sessionId] : [],
|
|
1398
|
+
);
|
|
931
1399
|
}
|
|
932
1400
|
|
|
933
|
-
/**
|
|
934
|
-
|
|
1401
|
+
/** Persist an indefinitely discoverable retry after an ambiguous archive result. */
|
|
1402
|
+
scheduleArchiveRetry(sessionId: string, now: number, diagnostic?: string): ArchiveJob | undefined {
|
|
935
1403
|
const record = this.topics.get(sessionId);
|
|
936
|
-
if (
|
|
937
|
-
|
|
938
|
-
|
|
939
|
-
|
|
1404
|
+
if (record?.authorityState !== "archive_pending" && record?.authorityState !== "archive_exhausted")
|
|
1405
|
+
return undefined;
|
|
1406
|
+
const previous = this.archiveJobs.get(sessionId);
|
|
1407
|
+
const firstAttemptAt = previous?.firstAttemptAt ?? now;
|
|
1408
|
+
const attempt = (previous?.attempt ?? 0) + 1;
|
|
1409
|
+
const exhausted = attempt > 8 || now - firstAttemptAt > 24 * 60 * 60 * 1000;
|
|
1410
|
+
const effectiveAttempt = Math.min(attempt, 8);
|
|
1411
|
+
const backoffMs = exhausted ? 1_000 : Math.min(60_000, 250 * 2 ** effectiveAttempt);
|
|
1412
|
+
const job = {
|
|
1413
|
+
sessionId,
|
|
1414
|
+
topicId: record.topicId,
|
|
1415
|
+
attempt: effectiveAttempt,
|
|
1416
|
+
firstAttemptAt,
|
|
1417
|
+
backoffMs,
|
|
1418
|
+
nextAttemptAt: now + backoffMs,
|
|
1419
|
+
...(diagnostic ? { safeDiagnostic: diagnostic.slice(0, 256) } : {}),
|
|
1420
|
+
...(exhausted ? { safeDiagnostic: "archive retry remains discoverable after retry budget" } : {}),
|
|
1421
|
+
};
|
|
1422
|
+
record.authorityState = "archive_pending";
|
|
1423
|
+
this.archiveJobs.set(sessionId, job);
|
|
1424
|
+
return job;
|
|
940
1425
|
}
|
|
941
1426
|
|
|
942
1427
|
/** Serialise active records plus unpublished staged creates for atomic commit. */
|
|
943
1428
|
serialize(): TopicRegistryState {
|
|
944
|
-
return {
|
|
1429
|
+
return {
|
|
1430
|
+
version: 2,
|
|
1431
|
+
registryGeneration: this.registryGeneration,
|
|
1432
|
+
topics: Object.fromEntries([...this.topics, ...this.staged]),
|
|
1433
|
+
fences: Object.fromEntries(this.epochs),
|
|
1434
|
+
archiveJobs: Object.fromEntries(this.archiveJobs),
|
|
1435
|
+
createClaims: Object.fromEntries(this.createClaims),
|
|
1436
|
+
retiredTopics: Object.fromEntries(this.retiredTopics),
|
|
1437
|
+
};
|
|
945
1438
|
}
|
|
946
1439
|
}
|