@gajae-code/coding-agent 0.11.6 → 0.11.7
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 +37 -0
- package/dist/types/capability/index.d.ts +10 -20
- package/dist/types/capability/types.d.ts +3 -0
- package/dist/types/commands/daemon.d.ts +2 -2
- package/dist/types/commands/sdk.d.ts +20 -2
- package/dist/types/config/keybindings.d.ts +1 -1
- package/dist/types/config/settings-schema.d.ts +56 -6
- package/dist/types/eval/py/runner-artifact.d.ts +8 -0
- package/dist/types/gjc-runtime/ultragoal-guard.d.ts +5 -5
- package/dist/types/gjc-runtime/ultragoal-receipt-freshness.d.ts +20 -0
- package/dist/types/gjc-runtime/ultragoal-runtime.d.ts +19 -5
- package/dist/types/main.d.ts +24 -1
- package/dist/types/modes/components/btw-panel.d.ts +1 -0
- package/dist/types/modes/components/model-selector.d.ts +2 -0
- package/dist/types/modes/components/tool-status-header.d.ts +1 -0
- package/dist/types/modes/controllers/btw-controller.d.ts +5 -0
- package/dist/types/modes/interactive-mode.d.ts +2 -0
- package/dist/types/modes/types.d.ts +1 -0
- package/dist/types/runtime-mcp/content-limits.d.ts +7 -0
- package/dist/types/sdk/bus/chat-daemon-control.d.ts +2 -1
- package/dist/types/sdk/bus/index.d.ts +1 -1
- package/dist/types/sdk/bus/lifecycle-commands.d.ts +5 -4
- package/dist/types/sdk/bus/notification-service.d.ts +34 -0
- package/dist/types/sdk/bus/telegram-daemon-contract.d.ts +8 -3
- package/dist/types/sdk/bus/telegram-daemon.d.ts +61 -3
- package/dist/types/sdk/bus/topic-registry.d.ts +88 -2
- package/dist/types/sdk/client/discovery.d.ts +2 -0
- package/dist/types/sdk/client/liveness.d.ts +7 -0
- package/dist/types/sdk/transport/auth-preface.d.ts +9 -0
- package/dist/types/sdk/transport/index.d.ts +12 -0
- package/dist/types/sdk/transport/relay.d.ts +43 -0
- package/dist/types/sdk/transport/serve-cli.d.ts +4 -0
- package/dist/types/sdk/transport/socket.d.ts +7 -0
- package/dist/types/sdk/transport/stdio.d.ts +3 -0
- package/dist/types/session/agent-session.d.ts +28 -5
- package/dist/types/session/btw-contract.d.ts +16 -0
- package/dist/types/session/internal/managed-session-scope.d.ts +21 -7
- package/dist/types/session/internal/managed-session-storage.d.ts +10 -8
- package/dist/types/session/session-manager.d.ts +25 -4
- package/dist/types/setup/credential-import.d.ts +5 -1
- package/dist/types/setup/provider-onboarding.d.ts +2 -0
- package/dist/types/ssh/utils.d.ts +1 -0
- package/dist/types/task/discovery.d.ts +3 -1
- package/dist/types/tools/fetch.d.ts +5 -8
- package/dist/types/tools/image-gen.d.ts +16 -0
- package/dist/types/tools/skill-discovery.d.ts +7 -0
- package/dist/types/tools/sqlite-reader.d.ts +7 -0
- package/dist/types/utils/shell-snapshot.d.ts +12 -0
- package/dist/types/web/insane/bridge.d.ts +1 -0
- package/dist/types/web/insane/url-guard.d.ts +17 -0
- package/package.json +8 -8
- package/scripts/generate-sdk-operation-inventory.ts +2 -0
- package/src/capability/index.ts +50 -42
- package/src/capability/types.ts +3 -0
- package/src/cli/setup-cli.ts +10 -4
- package/src/cli.ts +34 -9
- package/src/commands/daemon.ts +22 -5
- package/src/commands/sdk.ts +42 -9
- package/src/config/keybindings.ts +1 -1
- package/src/config/model-profiles.ts +18 -0
- package/src/config/settings-schema.ts +57 -6
- package/src/defaults/gjc/skills/ultragoal/SKILL.md +42 -1
- package/src/discovery/ssh.ts +12 -1
- package/src/eval/py/kernel.ts +1 -21
- package/src/eval/py/runner-artifact.ts +84 -0
- package/src/extensibility/plugins/marketplace/fetcher.ts +191 -7
- package/src/gjc-runtime/ultragoal-guard.ts +88 -10
- package/src/gjc-runtime/ultragoal-receipt-freshness.ts +103 -0
- package/src/gjc-runtime/ultragoal-runtime.ts +275 -12
- package/src/gjc-runtime/workflow-manifest.generated.json +58 -0
- package/src/gjc-runtime/workflow-manifest.ts +23 -0
- package/src/internal-urls/docs-index.generated.ts +8 -7
- package/src/internal-urls/local-protocol.ts +32 -6
- package/src/main.ts +86 -27
- package/src/modes/action-registry.ts +1 -1
- package/src/modes/components/btw-panel.ts +82 -16
- package/src/modes/components/model-selector.ts +31 -3
- package/src/modes/components/tool-status-header.ts +6 -1
- package/src/modes/controllers/btw-controller.ts +119 -36
- package/src/modes/controllers/input-controller.ts +31 -0
- package/src/modes/controllers/selector-controller.ts +106 -12
- package/src/modes/interactive-mode.ts +16 -7
- package/src/modes/types.ts +1 -0
- package/src/prompts/system/btw-user.md +3 -8
- package/src/prompts/tools/skill-discovery.md +1 -0
- package/src/runtime-mcp/client.ts +41 -55
- package/src/runtime-mcp/content-limits.ts +71 -0
- package/src/runtime-mcp/json-rpc.ts +23 -9
- package/src/runtime-mcp/transports/http.ts +50 -16
- package/src/sdk/bus/chat-daemon-control.ts +4 -3
- package/src/sdk/bus/index.ts +4 -5
- package/src/sdk/bus/lifecycle-commands.ts +16 -7
- package/src/sdk/bus/notification-service.ts +41 -18
- package/src/sdk/bus/telegram-daemon-contract.ts +8 -3
- package/src/sdk/bus/telegram-daemon-control.ts +2 -2
- package/src/sdk/bus/telegram-daemon.ts +1663 -375
- package/src/sdk/bus/topic-registry.ts +451 -12
- package/src/sdk/client/discovery.ts +19 -3
- package/src/sdk/client/liveness.ts +49 -0
- package/src/sdk/protocol/operation-inventory.generated.json +11 -0
- package/src/sdk/session.ts +29 -5
- package/src/sdk/transport/auth-preface.ts +82 -0
- package/src/sdk/transport/index.ts +18 -0
- package/src/sdk/transport/relay.ts +252 -0
- package/src/sdk/transport/serve-cli.ts +105 -0
- package/src/sdk/transport/socket.ts +197 -0
- package/src/sdk/transport/stdio.ts +17 -0
- package/src/session/agent-session.ts +240 -149
- package/src/session/blob-store.ts +19 -6
- package/src/session/btw-contract.ts +59 -0
- package/src/session/internal/managed-session-scope.ts +313 -57
- package/src/session/internal/managed-session-storage.ts +146 -35
- package/src/session/session-manager.ts +319 -85
- package/src/setup/credential-import.ts +28 -3
- package/src/setup/provider-onboarding.ts +30 -1
- package/src/setup/provider-presets.json +2 -1
- package/src/slash-commands/builtin-registry.ts +1 -1
- package/src/ssh/utils.ts +14 -0
- package/src/task/discovery.ts +8 -2
- package/src/tools/ask.ts +1 -1
- package/src/tools/fetch.ts +13 -131
- package/src/tools/image-gen.ts +301 -20
- package/src/tools/read.ts +2 -0
- package/src/tools/skill-discovery.ts +39 -3
- package/src/tools/sqlite-reader.ts +63 -5
- package/src/utils/pasted-image-path.ts +15 -6
- package/src/utils/shell-snapshot.ts +173 -63
- package/src/web/insane/bridge.ts +5 -1
- package/src/web/insane/url-guard.ts +138 -27
- package/src/web/scrapers/types.ts +19 -49
- package/src/web/scrapers/utils.ts +12 -26
|
@@ -39,8 +39,22 @@ export interface TopicRecord {
|
|
|
39
39
|
replaySeq?: number;
|
|
40
40
|
/** Serialized authority epoch; a late create may commit only in its starting epoch. */
|
|
41
41
|
authorityEpoch?: number;
|
|
42
|
+
/** Immutable authority epoch held when this remote topic create began. */
|
|
43
|
+
creationLeaseEpoch?: number;
|
|
42
44
|
/** An uncertain delete fences future creation and inbound routing. */
|
|
43
45
|
authorityState?: "active" | "delete_pending";
|
|
46
|
+
/** Telegram chat and endpoint authority last proven to use this topic. */
|
|
47
|
+
chatId?: string;
|
|
48
|
+
/** Canonical endpoint tuple (URL + token) that currently holds the lease. */
|
|
49
|
+
endpointKey?: string;
|
|
50
|
+
/** Authenticated endpoint authority digest, excluding transport presentation. */
|
|
51
|
+
endpointDigest?: string;
|
|
52
|
+
/** SDK event generation associated with the current endpoint lease. */
|
|
53
|
+
endpointGeneration?: number;
|
|
54
|
+
/** Monotonic authenticated endpoint handoffs; legacy bindings begin at zero. */
|
|
55
|
+
endpointIncarnation?: number;
|
|
56
|
+
/** True when persisted binding fields were present but malformed; recovery must fail closed. */
|
|
57
|
+
bindingMalformed?: true;
|
|
44
58
|
}
|
|
45
59
|
|
|
46
60
|
/** Serialisable shape persisted to disk. */
|
|
@@ -49,6 +63,69 @@ export interface TopicRegistryState {
|
|
|
49
63
|
topics: Record<string, TopicRecord>;
|
|
50
64
|
/** Durable deletion epochs retained after a definite delete. */
|
|
51
65
|
fences?: Record<string, number>;
|
|
66
|
+
/** Closed transport endpoint leases; unchanged endpoint discovery remains fenced across restart. */
|
|
67
|
+
closedEndpoints?: Record<string, TopicEndpointBinding>;
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
/** Authenticated runtime binding for a durable topic lease. */
|
|
71
|
+
export interface TopicEndpointBinding {
|
|
72
|
+
chatId: string;
|
|
73
|
+
endpointKey: string;
|
|
74
|
+
endpointDigest: string;
|
|
75
|
+
endpointGeneration?: number;
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
/** Discriminated durable endpoint authority for identity-less replay admission. */
|
|
79
|
+
export type TopicEndpointAuthority =
|
|
80
|
+
| { state: "none" }
|
|
81
|
+
| { state: "unique"; sessionId: string }
|
|
82
|
+
| { state: "ambiguous" };
|
|
83
|
+
|
|
84
|
+
/** Conditional rollback token for a delete fence publication. */
|
|
85
|
+
export interface TopicDeleteAuthoritySnapshot {
|
|
86
|
+
sessionId: string;
|
|
87
|
+
topicId?: string;
|
|
88
|
+
authorityEpoch?: number;
|
|
89
|
+
authorityState?: TopicRecord["authorityState"];
|
|
90
|
+
fenceEpoch?: number;
|
|
91
|
+
/** Exact fenced record, retained to restore an in-memory tombstone after a failed clear publication. */
|
|
92
|
+
record?: TopicRecord;
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
function isValidBindingString(value: unknown): value is string {
|
|
96
|
+
return typeof value === "string" && value.trim().length > 0;
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
function isValidBindingGeneration(value: unknown): value is number {
|
|
100
|
+
return Number.isSafeInteger(value) && (value as number) >= 0;
|
|
101
|
+
}
|
|
102
|
+
function hasAnyBinding(record: TopicRecord): boolean {
|
|
103
|
+
return (
|
|
104
|
+
record.chatId !== undefined ||
|
|
105
|
+
record.endpointKey !== undefined ||
|
|
106
|
+
record.endpointDigest !== undefined ||
|
|
107
|
+
record.endpointGeneration !== undefined ||
|
|
108
|
+
record.endpointIncarnation !== undefined
|
|
109
|
+
);
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
function hasCompleteBinding(record: TopicRecord): boolean {
|
|
113
|
+
return (
|
|
114
|
+
isValidBindingString(record.chatId) &&
|
|
115
|
+
isValidBindingString(record.endpointKey) &&
|
|
116
|
+
isValidBindingString(record.endpointDigest) &&
|
|
117
|
+
(record.endpointGeneration === undefined || isValidBindingGeneration(record.endpointGeneration)) &&
|
|
118
|
+
(record.endpointIncarnation === undefined || isValidBindingGeneration(record.endpointIncarnation))
|
|
119
|
+
);
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
function hasValidBinding(binding: TopicEndpointBinding): boolean {
|
|
123
|
+
return (
|
|
124
|
+
isValidBindingString(binding.chatId) &&
|
|
125
|
+
isValidBindingString(binding.endpointKey) &&
|
|
126
|
+
isValidBindingString(binding.endpointDigest) &&
|
|
127
|
+
(binding.endpointGeneration === undefined || isValidBindingGeneration(binding.endpointGeneration))
|
|
128
|
+
);
|
|
52
129
|
}
|
|
53
130
|
|
|
54
131
|
function isValidTopicId(value: unknown): value is string {
|
|
@@ -74,6 +151,12 @@ export class TopicRegistry {
|
|
|
74
151
|
readonly #ambiguousTopicIds = new Set<string>();
|
|
75
152
|
/** In-flight create promises, keyed by session, to dedupe concurrent creates. */
|
|
76
153
|
private readonly inflight = new Map<string, Promise<TopicRecord>>();
|
|
154
|
+
/** Newly-created records being durably published; never routable until committed. */
|
|
155
|
+
private readonly staged = new Map<string, TopicRecord>();
|
|
156
|
+
/** Socket-specific provenance for transient endpoint claims. */
|
|
157
|
+
private readonly transientClaimants = new Map<string, object | undefined>();
|
|
158
|
+
/** Endpoint claims registered before a remote topic create can publish a record. */
|
|
159
|
+
private readonly creatingBindings = new Map<string, TopicEndpointBinding>();
|
|
77
160
|
/** Monotonic authority epochs, including deletion fences for absent records. */
|
|
78
161
|
private readonly epochs = new Map<string, number>();
|
|
79
162
|
|
|
@@ -82,6 +165,15 @@ export class TopicRegistry {
|
|
|
82
165
|
this.load(state);
|
|
83
166
|
}
|
|
84
167
|
|
|
168
|
+
/** Replace all runtime state after a successfully persisted staged publication. */
|
|
169
|
+
replace(state: TopicRegistryState): void {
|
|
170
|
+
this.topics.clear();
|
|
171
|
+
this.byTopic.clear();
|
|
172
|
+
this.#ambiguousTopicIds.clear();
|
|
173
|
+
this.epochs.clear();
|
|
174
|
+
this.load(state);
|
|
175
|
+
}
|
|
176
|
+
|
|
85
177
|
/** Merge serialized state and normalize authority fields from older releases. */
|
|
86
178
|
load(state: TopicRegistryState): void {
|
|
87
179
|
for (const [sessionId, epoch] of Object.entries(state.fences ?? {})) {
|
|
@@ -94,9 +186,10 @@ export class TopicRegistry {
|
|
|
94
186
|
raw.nameOwner === "user" &&
|
|
95
187
|
typeof raw.name === "string" &&
|
|
96
188
|
raw.name.trim().length > 0 &&
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
189
|
+
(raw.userNameUpdateId === undefined ||
|
|
190
|
+
(typeof raw.userNameUpdateId === "number" &&
|
|
191
|
+
Number.isSafeInteger(raw.userNameUpdateId) &&
|
|
192
|
+
raw.userNameUpdateId >= 0));
|
|
100
193
|
const hasValidReplayCursor =
|
|
101
194
|
typeof raw.replayGeneration === "number" &&
|
|
102
195
|
Number.isSafeInteger(raw.replayGeneration) &&
|
|
@@ -104,6 +197,19 @@ export class TopicRegistry {
|
|
|
104
197
|
typeof raw.replaySeq === "number" &&
|
|
105
198
|
Number.isSafeInteger(raw.replaySeq) &&
|
|
106
199
|
raw.replaySeq >= 0;
|
|
200
|
+
const legacyUnbound = !hasAnyBinding(raw);
|
|
201
|
+
const bindingMalformed = raw.bindingMalformed === true || (!legacyUnbound && !hasCompleteBinding(raw));
|
|
202
|
+
const candidateAuthorityEpoch = raw.authorityEpoch;
|
|
203
|
+
const rawAuthorityEpoch: number =
|
|
204
|
+
typeof candidateAuthorityEpoch === "number" &&
|
|
205
|
+
Number.isSafeInteger(candidateAuthorityEpoch) &&
|
|
206
|
+
candidateAuthorityEpoch >= 0
|
|
207
|
+
? candidateAuthorityEpoch
|
|
208
|
+
: 0;
|
|
209
|
+
// A fence is the durable authority source. A mixed snapshot can contain an
|
|
210
|
+
// older active record alongside a newer fence; never rebuild its inbound route.
|
|
211
|
+
const fenceEpoch = this.epochs.get(sessionId) ?? 0;
|
|
212
|
+
const fenceSupersedesRecord = fenceEpoch > rawAuthorityEpoch;
|
|
107
213
|
const record: TopicRecord = {
|
|
108
214
|
topicId: raw.topicId,
|
|
109
215
|
identitySent: raw.identitySent === true,
|
|
@@ -114,15 +220,33 @@ export class TopicRegistry {
|
|
|
114
220
|
: {}),
|
|
115
221
|
...(hasValidUserAuthority ? { nameOwner: "user" as const } : {}),
|
|
116
222
|
...(hasValidUserAuthority && raw.nameReconcilePending === true ? { nameReconcilePending: true } : {}),
|
|
117
|
-
...(hasValidUserAuthority
|
|
223
|
+
...(hasValidUserAuthority && typeof raw.userNameUpdateId === "number"
|
|
224
|
+
? { userNameUpdateId: raw.userNameUpdateId }
|
|
225
|
+
: {}),
|
|
118
226
|
...(typeof raw.identityKey === "string" ? { identityKey: raw.identityKey } : {}),
|
|
227
|
+
...(typeof raw.creationLeaseEpoch === "number" &&
|
|
228
|
+
Number.isSafeInteger(raw.creationLeaseEpoch) &&
|
|
229
|
+
raw.creationLeaseEpoch >= 0
|
|
230
|
+
? { creationLeaseEpoch: raw.creationLeaseEpoch }
|
|
231
|
+
: {}),
|
|
119
232
|
...(hasValidReplayCursor ? { replayGeneration: raw.replayGeneration, replaySeq: raw.replaySeq } : {}),
|
|
120
|
-
|
|
121
|
-
|
|
233
|
+
authorityEpoch: Math.max(rawAuthorityEpoch, fenceEpoch),
|
|
234
|
+
...(raw.authorityState === "delete_pending" || fenceSupersedesRecord
|
|
235
|
+
? { authorityState: "delete_pending" as const }
|
|
236
|
+
: {}),
|
|
237
|
+
...(isValidBindingString(raw.chatId) ? { chatId: raw.chatId } : {}),
|
|
238
|
+
...(isValidBindingString(raw.endpointKey) ? { endpointKey: raw.endpointKey } : {}),
|
|
239
|
+
...(isValidBindingString(raw.endpointDigest) ? { endpointDigest: raw.endpointDigest } : {}),
|
|
240
|
+
...(isValidBindingGeneration(raw.endpointGeneration) ? { endpointGeneration: raw.endpointGeneration } : {}),
|
|
241
|
+
...(isValidBindingGeneration(raw.endpointIncarnation)
|
|
242
|
+
? { endpointIncarnation: raw.endpointIncarnation }
|
|
122
243
|
: {}),
|
|
123
|
-
...(
|
|
244
|
+
...(bindingMalformed ? { bindingMalformed: true as const } : {}),
|
|
124
245
|
};
|
|
125
|
-
this.epochs.set(sessionId, Math.max(
|
|
246
|
+
this.epochs.set(sessionId, Math.max(fenceEpoch, record.authorityEpoch ?? 0));
|
|
247
|
+
// Pre-generation-17 records have no endpoint authority. Retire them locally:
|
|
248
|
+
// their unknown remote topic must neither be rebound nor deleted cross-chat.
|
|
249
|
+
if (legacyUnbound) continue;
|
|
126
250
|
|
|
127
251
|
this.topics.set(sessionId, record);
|
|
128
252
|
}
|
|
@@ -135,7 +259,7 @@ export class TopicRegistry {
|
|
|
135
259
|
const activeByTopic = new Map<string, string>();
|
|
136
260
|
|
|
137
261
|
for (const [sessionId, record] of this.topics) {
|
|
138
|
-
if (record.authorityState === "delete_pending") {
|
|
262
|
+
if (record.authorityState === "delete_pending" || record.bindingMalformed) {
|
|
139
263
|
this.#ambiguousTopicIds.add(record.topicId);
|
|
140
264
|
continue;
|
|
141
265
|
}
|
|
@@ -161,11 +285,164 @@ export class TopicRegistry {
|
|
|
161
285
|
return [...this.topics.keys()];
|
|
162
286
|
}
|
|
163
287
|
|
|
288
|
+
/** Persisted remote deletes that must be reconciled before recovery can proceed. */
|
|
289
|
+
deletePendingSessionIds(): string[] {
|
|
290
|
+
return [...this.topics].flatMap(([sessionId, record]) =>
|
|
291
|
+
record.authorityState === "delete_pending" ? [sessionId] : [],
|
|
292
|
+
);
|
|
293
|
+
}
|
|
294
|
+
|
|
164
295
|
/** The existing topic record for a session, if any. */
|
|
165
296
|
get(sessionId: string): TopicRecord | undefined {
|
|
166
297
|
return this.topics.get(sessionId);
|
|
167
298
|
}
|
|
168
299
|
|
|
300
|
+
/** Current immutable authority epoch for a creation lease. */
|
|
301
|
+
authorityEpoch(sessionId: string): number {
|
|
302
|
+
return Math.max(this.epochs.get(sessionId) ?? 0, this.topics.get(sessionId)?.authorityEpoch ?? 0);
|
|
303
|
+
}
|
|
304
|
+
|
|
305
|
+
/** Whether this session has an active, unambiguous topic authority. */
|
|
306
|
+
isActiveUnambiguous(sessionId: string): boolean {
|
|
307
|
+
const record = this.topics.get(sessionId);
|
|
308
|
+
return record?.authorityState !== "delete_pending" && this.byTopic.get(record?.topicId ?? "") === sessionId;
|
|
309
|
+
}
|
|
310
|
+
|
|
311
|
+
/**
|
|
312
|
+
* Resolve endpoint authority for identity-less replay. An endpoint may bootstrap
|
|
313
|
+
* only when no committed, staged, or pre-create claim can own it; malformed
|
|
314
|
+
* partial bindings and deletion fences deliberately fail closed.
|
|
315
|
+
*/
|
|
316
|
+
endpointAuthority(binding: TopicEndpointBinding, excludedTransientClaimant?: object): TopicEndpointAuthority {
|
|
317
|
+
if (!hasValidBinding(binding)) return { state: "ambiguous" };
|
|
318
|
+
const canClaim = (record: Pick<TopicRecord, "chatId" | "endpointKey" | "endpointDigest">): boolean =>
|
|
319
|
+
(record.chatId === undefined || record.chatId === binding.chatId) &&
|
|
320
|
+
(record.endpointKey === undefined || record.endpointKey === binding.endpointKey) &&
|
|
321
|
+
(record.endpointDigest === undefined || record.endpointDigest === binding.endpointDigest);
|
|
322
|
+
const committed = [...this.topics].filter(([, record]) => canClaim(record));
|
|
323
|
+
const excludesClaimant = (sessionId: string): boolean =>
|
|
324
|
+
excludedTransientClaimant !== undefined &&
|
|
325
|
+
this.transientClaimants.get(sessionId) === excludedTransientClaimant;
|
|
326
|
+
const staged = [...this.staged].filter(([sessionId, record]) => canClaim(record) && !excludesClaimant(sessionId));
|
|
327
|
+
const creating = [...this.creatingBindings].filter(
|
|
328
|
+
([sessionId, claim]) => canClaim(claim) && !excludesClaimant(sessionId),
|
|
329
|
+
);
|
|
330
|
+
if (committed.length === 0 && staged.length === 0 && creating.length === 0) return { state: "none" };
|
|
331
|
+
if (committed.length !== 1 || staged.length !== 0 || creating.length !== 0) return { state: "ambiguous" };
|
|
332
|
+
const [sessionId, record] = committed[0]!;
|
|
333
|
+
return record.chatId === binding.chatId &&
|
|
334
|
+
record.endpointKey === binding.endpointKey &&
|
|
335
|
+
record.endpointDigest === binding.endpointDigest &&
|
|
336
|
+
this.isActiveUnambiguous(sessionId) &&
|
|
337
|
+
!record.bindingMalformed
|
|
338
|
+
? { state: "unique", sessionId }
|
|
339
|
+
: { state: "ambiguous" };
|
|
340
|
+
}
|
|
341
|
+
|
|
342
|
+
/** Resolve a uniquely bound logical owner for callers that only need the owner. */
|
|
343
|
+
uniqueSessionForEndpoint(binding: TopicEndpointBinding): string | undefined {
|
|
344
|
+
const authority = this.endpointAuthority(binding);
|
|
345
|
+
return authority.state === "unique" ? authority.sessionId : undefined;
|
|
346
|
+
}
|
|
347
|
+
|
|
348
|
+
/** Whether this exact session owns the complete durable endpoint binding. */
|
|
349
|
+
matchesEndpoint(sessionId: string, binding: TopicEndpointBinding): boolean {
|
|
350
|
+
const record = this.topics.get(sessionId);
|
|
351
|
+
return (
|
|
352
|
+
this.isActiveUnambiguous(sessionId) &&
|
|
353
|
+
!record?.bindingMalformed &&
|
|
354
|
+
record?.chatId === binding.chatId &&
|
|
355
|
+
record.endpointKey === binding.endpointKey &&
|
|
356
|
+
record.endpointDigest === binding.endpointDigest
|
|
357
|
+
);
|
|
358
|
+
}
|
|
359
|
+
|
|
360
|
+
/**
|
|
361
|
+
* Rebind an existing topic to an authenticated successor endpoint. The exact
|
|
362
|
+
* logical session id is proved by replay before this method is called. A
|
|
363
|
+
* rotated credential may replace only an inactive incumbent; concurrent
|
|
364
|
+
* incumbents, cross-chat records, malformed evidence, collisions, and delete
|
|
365
|
+
* fences remain fail-closed.
|
|
366
|
+
*/
|
|
367
|
+
bindEndpoint(
|
|
368
|
+
sessionId: string,
|
|
369
|
+
binding: TopicEndpointBinding,
|
|
370
|
+
activeEndpointKeys: ReadonlySet<string> = new Set(),
|
|
371
|
+
allowEndpointRotation = false,
|
|
372
|
+
): "bound" | "unchanged" | "rejected" {
|
|
373
|
+
const record = this.topics.get(sessionId);
|
|
374
|
+
if (
|
|
375
|
+
!record ||
|
|
376
|
+
record.authorityState === "delete_pending" ||
|
|
377
|
+
record.bindingMalformed ||
|
|
378
|
+
!hasValidBinding(binding) ||
|
|
379
|
+
!this.isActiveUnambiguous(sessionId)
|
|
380
|
+
)
|
|
381
|
+
return "rejected";
|
|
382
|
+
if (hasAnyBinding(record) && !hasCompleteBinding(record)) return "rejected";
|
|
383
|
+
// A topic id without chat affinity may belong to any prior paired chat.
|
|
384
|
+
// Do not bind it to the current chat merely because a resumed endpoint
|
|
385
|
+
// authenticated its logical session id.
|
|
386
|
+
if (record.chatId === undefined || record.chatId !== binding.chatId) return "rejected";
|
|
387
|
+
|
|
388
|
+
const sameEndpoint =
|
|
389
|
+
record.endpointKey === binding.endpointKey && record.endpointDigest === binding.endpointDigest;
|
|
390
|
+
if (
|
|
391
|
+
sameEndpoint &&
|
|
392
|
+
record.endpointGeneration !== undefined &&
|
|
393
|
+
binding.endpointGeneration !== undefined &&
|
|
394
|
+
binding.endpointGeneration < record.endpointGeneration
|
|
395
|
+
)
|
|
396
|
+
return "rejected";
|
|
397
|
+
if (
|
|
398
|
+
!sameEndpoint &&
|
|
399
|
+
hasAnyBinding(record) &&
|
|
400
|
+
(!allowEndpointRotation || (record.endpointKey !== undefined && activeEndpointKeys.has(record.endpointKey)))
|
|
401
|
+
)
|
|
402
|
+
return "rejected";
|
|
403
|
+
|
|
404
|
+
const changed =
|
|
405
|
+
record.chatId !== binding.chatId ||
|
|
406
|
+
record.endpointKey !== binding.endpointKey ||
|
|
407
|
+
record.endpointDigest !== binding.endpointDigest ||
|
|
408
|
+
record.endpointGeneration !== binding.endpointGeneration;
|
|
409
|
+
if (!changed) return "unchanged";
|
|
410
|
+
record.chatId = binding.chatId;
|
|
411
|
+
record.endpointKey = binding.endpointKey;
|
|
412
|
+
record.endpointDigest = binding.endpointDigest;
|
|
413
|
+
record.endpointGeneration = binding.endpointGeneration;
|
|
414
|
+
if (!sameEndpoint) record.endpointIncarnation = (record.endpointIncarnation ?? 0) + 1;
|
|
415
|
+
return "bound";
|
|
416
|
+
}
|
|
417
|
+
|
|
418
|
+
/** Undo a failed durable endpoint migration without disturbing concurrent metadata writers. */
|
|
419
|
+
restoreEndpointBinding(
|
|
420
|
+
sessionId: string,
|
|
421
|
+
expected: TopicEndpointBinding,
|
|
422
|
+
previous: Pick<
|
|
423
|
+
TopicRecord,
|
|
424
|
+
"chatId" | "endpointKey" | "endpointDigest" | "endpointGeneration" | "endpointIncarnation"
|
|
425
|
+
>,
|
|
426
|
+
): boolean {
|
|
427
|
+
const record = this.topics.get(sessionId);
|
|
428
|
+
if (
|
|
429
|
+
!record ||
|
|
430
|
+
record.chatId !== expected.chatId ||
|
|
431
|
+
record.endpointKey !== expected.endpointKey ||
|
|
432
|
+
record.endpointDigest !== expected.endpointDigest ||
|
|
433
|
+
record.endpointGeneration !== expected.endpointGeneration
|
|
434
|
+
)
|
|
435
|
+
return false;
|
|
436
|
+
record.chatId = previous.chatId;
|
|
437
|
+
record.endpointKey = previous.endpointKey;
|
|
438
|
+
record.endpointDigest = previous.endpointDigest;
|
|
439
|
+
if (previous.endpointGeneration === undefined) delete record.endpointGeneration;
|
|
440
|
+
else record.endpointGeneration = previous.endpointGeneration;
|
|
441
|
+
if (previous.endpointIncarnation === undefined) delete record.endpointIncarnation;
|
|
442
|
+
else record.endpointIncarnation = previous.endpointIncarnation;
|
|
443
|
+
return true;
|
|
444
|
+
}
|
|
445
|
+
|
|
169
446
|
/**
|
|
170
447
|
* Return the existing active topic for `sessionId`, or create one via
|
|
171
448
|
* `create` (called only on first use).
|
|
@@ -175,13 +452,22 @@ export class TopicRegistry {
|
|
|
175
452
|
create: () => Promise<unknown>,
|
|
176
453
|
now: () => number = Date.now,
|
|
177
454
|
name?: string,
|
|
455
|
+
binding?: TopicEndpointBinding,
|
|
456
|
+
commit?: () => Promise<void>,
|
|
457
|
+
transientClaimant?: object,
|
|
178
458
|
): Promise<TopicRecord> {
|
|
179
459
|
const existing = this.topics.get(sessionId);
|
|
180
460
|
if (existing?.authorityState === "delete_pending") throw new Error("topic authority is deletion-fenced");
|
|
461
|
+
if (existing?.bindingMalformed) throw new Error("topic authority binding is quarantined");
|
|
181
462
|
if (existing) return existing;
|
|
182
463
|
const pending = this.inflight.get(sessionId);
|
|
183
464
|
if (pending) return pending;
|
|
184
465
|
const epoch = this.epochs.get(sessionId) ?? 0;
|
|
466
|
+
// Publish the compatible endpoint claim before invoking `create`: the callback
|
|
467
|
+
// may immediately begin a remote create and identity-less recovery must never
|
|
468
|
+
// observe a false absence during that await.
|
|
469
|
+
if (binding) this.creatingBindings.set(sessionId, binding);
|
|
470
|
+
this.transientClaimants.set(sessionId, transientClaimant);
|
|
185
471
|
const promise = (async () => {
|
|
186
472
|
const topicId = await create();
|
|
187
473
|
if (!isValidTopicId(topicId)) throw new Error("createForumTopic: invalid message_thread_id");
|
|
@@ -192,10 +478,39 @@ export class TopicRegistry {
|
|
|
192
478
|
identitySent: false,
|
|
193
479
|
createdAt: now(),
|
|
194
480
|
authorityEpoch: revoked ? (this.epochs.get(sessionId) ?? 0) : epoch,
|
|
481
|
+
creationLeaseEpoch: epoch,
|
|
482
|
+
...(binding
|
|
483
|
+
? {
|
|
484
|
+
chatId: binding.chatId,
|
|
485
|
+
endpointKey: binding.endpointKey,
|
|
486
|
+
endpointDigest: binding.endpointDigest,
|
|
487
|
+
endpointIncarnation: 0,
|
|
488
|
+
...(binding.endpointGeneration === undefined
|
|
489
|
+
? {}
|
|
490
|
+
: { endpointGeneration: binding.endpointGeneration }),
|
|
491
|
+
}
|
|
492
|
+
: {}),
|
|
195
493
|
...(revoked ? { authorityState: "delete_pending" as const } : {}),
|
|
196
494
|
};
|
|
495
|
+
if (revoked) {
|
|
496
|
+
this.topics.set(sessionId, record);
|
|
497
|
+
throw new Error("topic authority was revoked during creation");
|
|
498
|
+
}
|
|
499
|
+
this.staged.set(sessionId, record);
|
|
500
|
+
try {
|
|
501
|
+
await commit?.();
|
|
502
|
+
} catch (error) {
|
|
503
|
+
this.staged.delete(sessionId);
|
|
504
|
+
throw error;
|
|
505
|
+
}
|
|
506
|
+
this.staged.delete(sessionId);
|
|
507
|
+
if ((this.epochs.get(sessionId) ?? 0) !== epoch) {
|
|
508
|
+
record.authorityEpoch = this.epochs.get(sessionId) ?? 0;
|
|
509
|
+
record.authorityState = "delete_pending";
|
|
510
|
+
this.topics.set(sessionId, record);
|
|
511
|
+
throw new Error("topic authority was revoked during creation");
|
|
512
|
+
}
|
|
197
513
|
this.topics.set(sessionId, record);
|
|
198
|
-
if (revoked) throw new Error("topic authority was revoked during creation");
|
|
199
514
|
if (this.#ambiguousTopicIds.has(topicId)) return record;
|
|
200
515
|
if (this.byTopic.has(topicId)) {
|
|
201
516
|
this.byTopic.delete(topicId);
|
|
@@ -210,6 +525,8 @@ export class TopicRegistry {
|
|
|
210
525
|
return await promise;
|
|
211
526
|
} finally {
|
|
212
527
|
this.inflight.delete(sessionId);
|
|
528
|
+
this.creatingBindings.delete(sessionId);
|
|
529
|
+
this.transientClaimants.delete(sessionId);
|
|
213
530
|
}
|
|
214
531
|
}
|
|
215
532
|
|
|
@@ -323,6 +640,67 @@ export class TopicRegistry {
|
|
|
323
640
|
record.nameReconcilePending = false;
|
|
324
641
|
}
|
|
325
642
|
|
|
643
|
+
/** Capture only authority fields that a failed delete publication may restore. */
|
|
644
|
+
captureDeleteAuthority(sessionId: string): TopicDeleteAuthoritySnapshot {
|
|
645
|
+
const record = this.topics.get(sessionId);
|
|
646
|
+
return {
|
|
647
|
+
sessionId,
|
|
648
|
+
topicId: record?.topicId,
|
|
649
|
+
authorityEpoch: record?.authorityEpoch,
|
|
650
|
+
authorityState: record?.authorityState,
|
|
651
|
+
fenceEpoch: this.epochs.get(sessionId),
|
|
652
|
+
...(record ? { record: { ...record } } : {}),
|
|
653
|
+
};
|
|
654
|
+
}
|
|
655
|
+
|
|
656
|
+
/** Restore a failed delete fence only while its exact authority mutation remains current. */
|
|
657
|
+
restoreDeleteAuthority(snapshot: TopicDeleteAuthoritySnapshot): boolean {
|
|
658
|
+
const record = this.topics.get(snapshot.sessionId);
|
|
659
|
+
const deleteEpoch = Math.max(snapshot.fenceEpoch ?? 0, snapshot.authorityEpoch ?? 0) + 1;
|
|
660
|
+
if (this.epochs.get(snapshot.sessionId) !== deleteEpoch) return false;
|
|
661
|
+
if (snapshot.topicId === undefined) {
|
|
662
|
+
if (record) return false;
|
|
663
|
+
} else if (
|
|
664
|
+
!record ||
|
|
665
|
+
record.topicId !== snapshot.topicId ||
|
|
666
|
+
record.authorityState !== "delete_pending" ||
|
|
667
|
+
record.authorityEpoch !== deleteEpoch
|
|
668
|
+
) {
|
|
669
|
+
return false;
|
|
670
|
+
} else {
|
|
671
|
+
record.authorityEpoch = snapshot.authorityEpoch;
|
|
672
|
+
record.authorityState = snapshot.authorityState;
|
|
673
|
+
this.rebuildInboundRoutes();
|
|
674
|
+
}
|
|
675
|
+
if (snapshot.fenceEpoch === undefined) this.epochs.delete(snapshot.sessionId);
|
|
676
|
+
else this.epochs.set(snapshot.sessionId, snapshot.fenceEpoch);
|
|
677
|
+
return true;
|
|
678
|
+
}
|
|
679
|
+
|
|
680
|
+
/** Restore the exact delete fence after a failed compensation publication. */
|
|
681
|
+
restoreDeleteFence(snapshot: TopicDeleteAuthoritySnapshot): boolean {
|
|
682
|
+
const record = this.topics.get(snapshot.sessionId);
|
|
683
|
+
const deleteEpoch = Math.max(snapshot.fenceEpoch ?? 0, snapshot.authorityEpoch ?? 0) + 1;
|
|
684
|
+
if (snapshot.topicId === undefined) {
|
|
685
|
+
if (record) return false;
|
|
686
|
+
} else if (!record) {
|
|
687
|
+
if (!snapshot.record) return false;
|
|
688
|
+
this.topics.set(snapshot.sessionId, {
|
|
689
|
+
...snapshot.record,
|
|
690
|
+
authorityEpoch: deleteEpoch,
|
|
691
|
+
authorityState: "delete_pending",
|
|
692
|
+
});
|
|
693
|
+
} else if (record.topicId !== snapshot.topicId) {
|
|
694
|
+
return false;
|
|
695
|
+
} else {
|
|
696
|
+
record.authorityEpoch = deleteEpoch;
|
|
697
|
+
record.authorityState = "delete_pending";
|
|
698
|
+
if (this.byTopic.get(record.topicId) === snapshot.sessionId) this.byTopic.delete(record.topicId);
|
|
699
|
+
}
|
|
700
|
+
this.epochs.set(snapshot.sessionId, deleteEpoch);
|
|
701
|
+
return true;
|
|
702
|
+
}
|
|
703
|
+
|
|
326
704
|
/** Fence new work before the remote delete starts, including an absent in-flight create. */
|
|
327
705
|
beginDelete(sessionId: string): TopicRecord | undefined {
|
|
328
706
|
const record = this.topics.get(sessionId);
|
|
@@ -335,6 +713,67 @@ export class TopicRegistry {
|
|
|
335
713
|
return record;
|
|
336
714
|
}
|
|
337
715
|
|
|
716
|
+
/** Retain an accepted create as deletion-fenced before remote compensation can begin. */
|
|
717
|
+
fenceAcceptedCreate(
|
|
718
|
+
sessionId: string,
|
|
719
|
+
topicId: string,
|
|
720
|
+
now: () => number = Date.now,
|
|
721
|
+
name?: string,
|
|
722
|
+
binding?: TopicEndpointBinding,
|
|
723
|
+
): TopicRecord {
|
|
724
|
+
const epoch = Math.max(this.epochs.get(sessionId) ?? 0, this.topics.get(sessionId)?.authorityEpoch ?? 0);
|
|
725
|
+
const record: TopicRecord = {
|
|
726
|
+
topicId,
|
|
727
|
+
name,
|
|
728
|
+
identitySent: false,
|
|
729
|
+
createdAt: now(),
|
|
730
|
+
authorityEpoch: epoch,
|
|
731
|
+
authorityState: "delete_pending",
|
|
732
|
+
...(binding
|
|
733
|
+
? {
|
|
734
|
+
chatId: binding.chatId,
|
|
735
|
+
endpointKey: binding.endpointKey,
|
|
736
|
+
endpointDigest: binding.endpointDigest,
|
|
737
|
+
endpointIncarnation: 0,
|
|
738
|
+
...(binding.endpointGeneration === undefined
|
|
739
|
+
? {}
|
|
740
|
+
: { endpointGeneration: binding.endpointGeneration }),
|
|
741
|
+
}
|
|
742
|
+
: {}),
|
|
743
|
+
};
|
|
744
|
+
this.topics.set(sessionId, record);
|
|
745
|
+
if (this.byTopic.get(topicId) === sessionId) this.byTopic.delete(topicId);
|
|
746
|
+
this.#ambiguousTopicIds.add(topicId);
|
|
747
|
+
return record;
|
|
748
|
+
}
|
|
749
|
+
|
|
750
|
+
/** Fence an accepted create only when its exact creator lease still owns the record. */
|
|
751
|
+
fenceAcceptedCreateForLease(
|
|
752
|
+
sessionId: string,
|
|
753
|
+
topicId: string,
|
|
754
|
+
creationLeaseEpoch: number,
|
|
755
|
+
now: () => number = Date.now,
|
|
756
|
+
name?: string,
|
|
757
|
+
binding?: TopicEndpointBinding,
|
|
758
|
+
): TopicRecord | undefined {
|
|
759
|
+
const record = this.topics.get(sessionId);
|
|
760
|
+
const matchesBinding =
|
|
761
|
+
record?.chatId === binding?.chatId &&
|
|
762
|
+
record?.endpointKey === binding?.endpointKey &&
|
|
763
|
+
record?.endpointDigest === binding?.endpointDigest &&
|
|
764
|
+
record?.endpointGeneration === binding?.endpointGeneration;
|
|
765
|
+
if (
|
|
766
|
+
record
|
|
767
|
+
? record.topicId !== topicId || record.creationLeaseEpoch !== creationLeaseEpoch || !matchesBinding
|
|
768
|
+
: (this.epochs.get(sessionId) ?? 0) !== creationLeaseEpoch
|
|
769
|
+
)
|
|
770
|
+
return undefined;
|
|
771
|
+
this.beginDelete(sessionId);
|
|
772
|
+
const fenced = this.fenceAcceptedCreate(sessionId, topicId, now, name, binding);
|
|
773
|
+
fenced.creationLeaseEpoch = creationLeaseEpoch;
|
|
774
|
+
return fenced;
|
|
775
|
+
}
|
|
776
|
+
|
|
338
777
|
/** Wait for a revoked create to settle before admitting a later lifecycle epoch. */
|
|
339
778
|
async awaitInflight(sessionId: string): Promise<void> {
|
|
340
779
|
await this.inflight.get(sessionId)?.catch(() => undefined);
|
|
@@ -357,8 +796,8 @@ export class TopicRegistry {
|
|
|
357
796
|
return this.topics.delete(sessionId);
|
|
358
797
|
}
|
|
359
798
|
|
|
360
|
-
/** Serialise
|
|
799
|
+
/** Serialise active records plus unpublished staged creates for atomic commit. */
|
|
361
800
|
serialize(): TopicRegistryState {
|
|
362
|
-
return { topics: Object.fromEntries(this.topics), fences: Object.fromEntries(this.epochs) };
|
|
801
|
+
return { topics: Object.fromEntries([...this.topics, ...this.staged]), fences: Object.fromEntries(this.epochs) };
|
|
363
802
|
}
|
|
364
803
|
}
|
|
@@ -6,6 +6,8 @@ export interface SdkSessionEndpoint {
|
|
|
6
6
|
sessionId: string;
|
|
7
7
|
url: string;
|
|
8
8
|
token: string;
|
|
9
|
+
pid?: number;
|
|
10
|
+
stale?: boolean;
|
|
9
11
|
path: string;
|
|
10
12
|
}
|
|
11
13
|
|
|
@@ -38,12 +40,26 @@ function endpointDirectory(repo: string): string {
|
|
|
38
40
|
function parseEndpoint(sessionId: string, file: string, value: unknown): SdkSessionEndpoint {
|
|
39
41
|
if (!value || typeof value !== "object")
|
|
40
42
|
throw new SdkDiscoveryError(file, "SDK endpoint discovery record must be an object.");
|
|
41
|
-
const endpoint = value as { version?: unknown; url?: unknown; token?: unknown };
|
|
43
|
+
const endpoint = value as { version?: unknown; url?: unknown; token?: unknown; pid?: unknown; stale?: unknown };
|
|
42
44
|
if (typeof endpoint.version === "number" && endpoint.version > 1)
|
|
43
45
|
throw new SdkDiscoveryError(file, "Unsupported SDK endpoint discovery state version.");
|
|
44
|
-
if (typeof endpoint.url !== "string" ||
|
|
46
|
+
if (typeof endpoint.url !== "string" || !endpoint.url)
|
|
45
47
|
throw new SdkDiscoveryError(file, "SDK endpoint discovery record is invalid.");
|
|
46
|
-
|
|
48
|
+
const stale = typeof endpoint.stale === "boolean" ? endpoint.stale : undefined;
|
|
49
|
+
const pid =
|
|
50
|
+
typeof endpoint.pid === "number" && Number.isInteger(endpoint.pid) && endpoint.pid > 0 ? endpoint.pid : undefined;
|
|
51
|
+
if (endpoint.token !== undefined && typeof endpoint.token !== "string")
|
|
52
|
+
throw new SdkDiscoveryError(file, "SDK endpoint discovery record is invalid.");
|
|
53
|
+
const token = endpoint.token ?? "";
|
|
54
|
+
if (!token && stale !== true) throw new SdkDiscoveryError(file, "SDK endpoint discovery record is invalid.");
|
|
55
|
+
return {
|
|
56
|
+
sessionId,
|
|
57
|
+
url: endpoint.url,
|
|
58
|
+
token,
|
|
59
|
+
...(pid === undefined ? {} : { pid }),
|
|
60
|
+
...(stale === undefined ? {} : { stale }),
|
|
61
|
+
path: file,
|
|
62
|
+
};
|
|
47
63
|
}
|
|
48
64
|
|
|
49
65
|
function discoveryError(file: string, error: unknown): SdkDiscoveryError {
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
import type { SdkSessionEndpoint } from "./discovery";
|
|
2
|
+
|
|
3
|
+
export type SdkEndpointLiveness = "live" | "stale" | "dead" | "unknown";
|
|
4
|
+
|
|
5
|
+
export interface SdkEndpointSelectionError {
|
|
6
|
+
code:
|
|
7
|
+
| "endpoint_stale"
|
|
8
|
+
| "endpoint_dead"
|
|
9
|
+
| "endpoint_unknown"
|
|
10
|
+
| "no_live_endpoint"
|
|
11
|
+
| "multiple_live_endpoints"
|
|
12
|
+
| "not_found";
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
export function classifyEndpoint(endpoint: SdkSessionEndpoint): SdkEndpointLiveness {
|
|
16
|
+
if (endpoint.stale === true) return "stale";
|
|
17
|
+
if (!Number.isInteger(endpoint.pid) || endpoint.pid === undefined || endpoint.pid <= 0) return "unknown";
|
|
18
|
+
try {
|
|
19
|
+
process.kill(endpoint.pid, 0);
|
|
20
|
+
return "live";
|
|
21
|
+
} catch (error) {
|
|
22
|
+
const code = (error as NodeJS.ErrnoException).code;
|
|
23
|
+
if (code === "ESRCH") return "dead";
|
|
24
|
+
return code === "EPERM" ? "live" : "unknown";
|
|
25
|
+
}
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
export function selectLiveEndpoint(
|
|
29
|
+
records: readonly SdkSessionEndpoint[],
|
|
30
|
+
explicitSessionId?: string,
|
|
31
|
+
): SdkSessionEndpoint | SdkEndpointSelectionError {
|
|
32
|
+
if (explicitSessionId !== undefined) {
|
|
33
|
+
const endpoint = records.find(record => record.sessionId === explicitSessionId);
|
|
34
|
+
if (!endpoint) return { code: "not_found" };
|
|
35
|
+
switch (classifyEndpoint(endpoint)) {
|
|
36
|
+
case "live":
|
|
37
|
+
return endpoint;
|
|
38
|
+
case "stale":
|
|
39
|
+
return { code: "endpoint_stale" };
|
|
40
|
+
case "dead":
|
|
41
|
+
return { code: "endpoint_dead" };
|
|
42
|
+
case "unknown":
|
|
43
|
+
return { code: "endpoint_unknown" };
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
const live = records.filter(record => classifyEndpoint(record) === "live");
|
|
47
|
+
if (live.length === 1) return live[0]!;
|
|
48
|
+
return { code: live.length === 0 ? "no_live_endpoint" : "multiple_live_endpoints" };
|
|
49
|
+
}
|
|
@@ -4430,6 +4430,17 @@
|
|
|
4430
4430
|
"testIds": "not_applicable"
|
|
4431
4431
|
}
|
|
4432
4432
|
},
|
|
4433
|
+
{
|
|
4434
|
+
"sourceId": "agent_session:createBtwConversationScope",
|
|
4435
|
+
"sourceFile": "packages/coding-agent/src/session/agent-session.ts",
|
|
4436
|
+
"sourceKind": "agent_session",
|
|
4437
|
+
"decision": "exclude",
|
|
4438
|
+
"rationale": "internal privacy-scoped side-chat snapshot factory, not a user-facing SDK control seam",
|
|
4439
|
+
"exclusionMetadata": {
|
|
4440
|
+
"adapterMappings": "not_applicable",
|
|
4441
|
+
"testIds": "not_applicable"
|
|
4442
|
+
}
|
|
4443
|
+
},
|
|
4433
4444
|
{
|
|
4434
4445
|
"sourceId": "agent_session:runEphemeralTurn",
|
|
4435
4446
|
"sourceFile": "packages/coding-agent/src/session/agent-session.ts",
|