@gajae-code/coding-agent 0.13.0 → 0.13.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +18 -0
- package/dist/types/config/file-lock.d.ts +2 -0
- package/dist/types/config/keybindings.d.ts +2 -2
- package/dist/types/config/model-registry.d.ts +4 -0
- package/dist/types/config/models-config-schema.d.ts +6 -0
- package/dist/types/config/settings-schema.d.ts +17 -5
- package/dist/types/config/settings.d.ts +10 -1
- package/dist/types/edit/streaming.d.ts +5 -5
- package/dist/types/eval/py/kernel.d.ts +2 -2
- package/dist/types/eval/py/runtime.d.ts +5 -1
- package/dist/types/gjc-runtime/session-state-sidecar.d.ts +3 -0
- package/dist/types/gjc-runtime/tmux-sessions.d.ts +3 -1
- package/dist/types/modes/components/model-selector.d.ts +3 -2
- package/dist/types/runtime/process-lifecycle.d.ts +12 -31
- package/dist/types/sdk/broker/identity.d.ts +2 -1
- package/dist/types/sdk/broker/lifecycle-ledger.d.ts +17 -1
- package/dist/types/sdk/broker/lifecycle.d.ts +11 -6
- package/dist/types/sdk/broker/startup-failure.d.ts +32 -0
- package/dist/types/sdk/bus/chat-daemon-control.d.ts +2 -1
- package/dist/types/sdk/bus/conversation-store.d.ts +1 -0
- package/dist/types/sdk/bus/index.d.ts +4 -0
- package/dist/types/sdk/bus/kind-aware-reconciliation.d.ts +10 -1
- package/dist/types/sdk/bus/reconciliation-store.d.ts +2 -0
- package/dist/types/sdk/bus/telegram-daemon-contract.d.ts +11 -2
- package/dist/types/sdk/bus/telegram-daemon.d.ts +17 -1
- package/dist/types/sdk/host/query/cursor.d.ts +4 -0
- package/dist/types/sdk/host/query/handlers.d.ts +6 -0
- package/dist/types/sdk/host/session-runtime.d.ts +7 -6
- package/dist/types/sdk/host/websocket-transport.d.ts +1 -1
- package/dist/types/sdk/index.d.ts +1 -0
- package/dist/types/sdk/turn-result.d.ts +37 -0
- package/dist/types/session/agent-session.d.ts +8 -11
- package/dist/types/session/blob-store.d.ts +6 -3
- package/dist/types/slash-commands/helpers/fast-status-report.d.ts +12 -3
- package/dist/types/task/executor.d.ts +4 -7
- package/dist/types/tools/index.d.ts +1 -1
- package/dist/types/tools/read.d.ts +1 -1
- package/dist/types/tools/search.d.ts +1 -1
- package/dist/types/utils/edit-mode.d.ts +69 -0
- package/package.json +8 -8
- package/scripts/generate-hotkeys-docs.ts +8 -6
- package/src/commands/sdk.ts +23 -2
- package/src/config/file-lock.ts +25 -5
- package/src/config/keybindings.ts +4 -2
- package/src/config/model-registry.ts +20 -0
- package/src/config/models-config-schema.ts +1 -0
- package/src/config/settings-schema.ts +29 -22
- package/src/config/settings.ts +42 -10
- package/src/coordinator-mcp/server.ts +84 -106
- package/src/edit/index.ts +5 -19
- package/src/edit/renderer.ts +3 -2
- package/src/edit/streaming.ts +28 -15
- package/src/eval/py/executor.ts +108 -10
- package/src/eval/py/kernel.ts +86 -12
- package/src/eval/py/runtime.ts +200 -16
- package/src/exec/bash-executor.ts +36 -37
- package/src/gjc-runtime/gc-runtime.ts +25 -3
- package/src/gjc-runtime/managed-owner-supervisor.ts +27 -1
- package/src/gjc-runtime/session-state-sidecar.ts +110 -4
- package/src/gjc-runtime/tmux-sessions.ts +34 -5
- package/src/internal-urls/docs-index.generated.ts +6 -6
- package/src/modes/acp/acp-agent.ts +10 -2
- package/src/modes/components/model-selector.ts +28 -8
- package/src/modes/controllers/plan-mode-controller.ts +3 -3
- package/src/modes/controllers/selector-controller.ts +4 -2
- package/src/runtime/process-lifecycle.ts +126 -27
- package/src/runtime-mcp/transports/stdio.ts +11 -6
- package/src/sdk/acp/adapter.ts +18 -10
- package/src/sdk/broker/broker.ts +55 -4
- package/src/sdk/broker/ensure.ts +10 -7
- package/src/sdk/broker/identity.ts +10 -0
- package/src/sdk/broker/lifecycle-ledger.ts +56 -6
- package/src/sdk/broker/lifecycle.ts +53 -10
- package/src/sdk/broker/session-index.ts +28 -11
- package/src/sdk/broker/startup-failure.ts +101 -0
- package/src/sdk/broker/transport.ts +1 -0
- package/src/sdk/bus/chat-daemon-control.ts +4 -3
- package/src/sdk/bus/chat-daemon-runtime.ts +8 -1
- package/src/sdk/bus/conversation-store.ts +171 -50
- package/src/sdk/bus/index.ts +101 -47
- package/src/sdk/bus/kind-aware-reconciliation.ts +52 -2
- package/src/sdk/bus/notification-service.ts +3 -3
- package/src/sdk/bus/reconciliation-store.ts +16 -0
- package/src/sdk/bus/telegram-daemon-contract.ts +11 -2
- package/src/sdk/bus/telegram-daemon.ts +131 -29
- package/src/sdk/bus/topic-registry.ts +38 -9
- package/src/sdk/cli/session-cli.ts +20 -7
- package/src/sdk/client/discovery.ts +23 -2
- package/src/sdk/host/query/cursor.ts +4 -0
- package/src/sdk/host/query/handlers.ts +99 -100
- package/src/sdk/host/sdk-surface-parity.test.ts +1 -1
- package/src/sdk/host/session-runtime.test.ts +98 -1
- package/src/sdk/host/session-runtime.ts +57 -15
- package/src/sdk/host/websocket-transport.lifecycle.test.ts +192 -2
- package/src/sdk/host/websocket-transport.ts +54 -30
- package/src/sdk/index.ts +1 -0
- package/src/sdk/mcp/server.ts +19 -6
- package/src/sdk/protocol/operation-inventory.generated.json +1 -1
- package/src/sdk/protocol/operation-registry.ts +8 -12
- package/src/sdk/session.ts +5 -14
- package/src/sdk/turn-result.ts +53 -0
- package/src/session/agent-session.ts +112 -69
- package/src/session/blob-store.ts +91 -21
- package/src/session/session-manager.ts +408 -81
- package/src/session/session-storage.ts +68 -10
- package/src/slash-commands/helpers/fast-status-report.ts +24 -8
- package/src/task/executor.ts +25 -8
- package/src/task/index.ts +0 -2
- package/src/tools/index.ts +1 -1
- package/src/tools/read.ts +12 -11
- package/src/tools/search.ts +7 -7
- package/src/utils/edit-mode.ts +180 -9
- package/src/web/search/providers/insane.ts +55 -1
|
@@ -5,6 +5,8 @@
|
|
|
5
5
|
|
|
6
6
|
import { sanitizePromptFailure } from "../prompt-failure";
|
|
7
7
|
import type { PromptReconciliationStatus, SdkPromptTerminalOutcome, TurnPromptReconciliation } from "../prompt-status";
|
|
8
|
+
import { sanitizeTurnResultContent, type TurnResultContent, type TurnResultPage } from "../turn-result";
|
|
9
|
+
|
|
8
10
|
import {
|
|
9
11
|
PROMPT_RECONCILIATION_ACTIVE_CAPACITY,
|
|
10
12
|
PROMPT_RECONCILIATION_TERMINAL_CAPACITY,
|
|
@@ -17,6 +19,7 @@ export type { ReconciliationKind };
|
|
|
17
19
|
|
|
18
20
|
export interface KindCorrelation extends PromptCorrelation {
|
|
19
21
|
kind: ReconciliationKind;
|
|
22
|
+
content?: TurnResultContent;
|
|
20
23
|
}
|
|
21
24
|
|
|
22
25
|
export interface KindAwareReconciliation {
|
|
@@ -31,7 +34,9 @@ export interface KindAwareReconciliation {
|
|
|
31
34
|
noteTransition(
|
|
32
35
|
kind: ReconciliationKind,
|
|
33
36
|
correlation: PromptCorrelation | undefined,
|
|
34
|
-
frame:
|
|
37
|
+
frame:
|
|
38
|
+
| { type: "agent_start" | "agent_end"; content?: TurnResultContent }
|
|
39
|
+
| { type: "agent_failed"; error: unknown; content?: TurnResultContent },
|
|
35
40
|
): Promise<void>;
|
|
36
41
|
claimPendingOutcome(
|
|
37
42
|
correlation: PromptCorrelation,
|
|
@@ -41,12 +46,17 @@ export interface KindAwareReconciliation {
|
|
|
41
46
|
correlation: PromptCorrelation,
|
|
42
47
|
outcome?: SdkPromptTerminalOutcome,
|
|
43
48
|
recordError?: { code: string; message: string },
|
|
49
|
+
content?: unknown,
|
|
44
50
|
): Promise<void>;
|
|
45
51
|
peekPendingOutcome(correlation: PromptCorrelation): SdkPromptTerminalOutcome | undefined;
|
|
46
52
|
lookup(
|
|
47
53
|
kind: ReconciliationKind,
|
|
48
54
|
selector: { commandId?: string; turnId?: string; clientRef?: string },
|
|
49
55
|
): TurnPromptReconciliation;
|
|
56
|
+
lookupResult(
|
|
57
|
+
kind: ReconciliationKind,
|
|
58
|
+
selector: { commandId?: string; turnId?: string; clientRef?: string },
|
|
59
|
+
): TurnResultPage;
|
|
50
60
|
cleanup(): void;
|
|
51
61
|
activeCount(kind: ReconciliationKind): number;
|
|
52
62
|
/** Hydrate from durable store (call once at session host start). */
|
|
@@ -190,7 +200,9 @@ export function createKindAwareReconciliation(
|
|
|
190
200
|
const noteTransition = async (
|
|
191
201
|
kind: ReconciliationKind,
|
|
192
202
|
correlation: PromptCorrelation | undefined,
|
|
193
|
-
frame:
|
|
203
|
+
frame:
|
|
204
|
+
| { type: "agent_start" | "agent_end"; content?: TurnResultContent }
|
|
205
|
+
| { type: "agent_failed"; error: unknown; content?: TurnResultContent },
|
|
194
206
|
) => {
|
|
195
207
|
if (!correlation) return;
|
|
196
208
|
await queueMutation(candidate => {
|
|
@@ -214,9 +226,13 @@ export function createKindAwareReconciliation(
|
|
|
214
226
|
if (record.status !== "accepted") return { value: undefined, changed: false };
|
|
215
227
|
record.status = "in_flight";
|
|
216
228
|
record.startedAt = now();
|
|
229
|
+
if (frame.content) record.content = sanitizeTurnResultContent(frame.content.text);
|
|
230
|
+
|
|
217
231
|
return { value: undefined, changed: true };
|
|
218
232
|
}
|
|
219
233
|
record.terminalAt = now();
|
|
234
|
+
if (frame.content) record.content = sanitizeTurnResultContent(frame.content.text);
|
|
235
|
+
|
|
220
236
|
if (frame.type === "agent_failed") {
|
|
221
237
|
record.status = "failed";
|
|
222
238
|
record.error = sanitizePromptFailure(frame.error);
|
|
@@ -243,6 +259,7 @@ export function createKindAwareReconciliation(
|
|
|
243
259
|
correlation: PromptCorrelation,
|
|
244
260
|
outcome?: SdkPromptTerminalOutcome,
|
|
245
261
|
recordError?: { code: string; message: string },
|
|
262
|
+
content?: unknown,
|
|
246
263
|
) => {
|
|
247
264
|
await queueMutation(candidate => {
|
|
248
265
|
const record = candidate.get(keyOf("prompt", correlation));
|
|
@@ -254,6 +271,7 @@ export function createKindAwareReconciliation(
|
|
|
254
271
|
record.status = "failed";
|
|
255
272
|
record.error = recordError ?? { code: finalOutcome.code, message: finalOutcome.message };
|
|
256
273
|
} else record.status = "terminal_ok";
|
|
274
|
+
record.content = sanitizeTurnResultContent(content);
|
|
257
275
|
record.outcome = finalOutcome;
|
|
258
276
|
record.pendingOutcome = undefined;
|
|
259
277
|
cleanupRecords(candidate);
|
|
@@ -301,6 +319,37 @@ export function createKindAwareReconciliation(
|
|
|
301
319
|
return { status: "failed", ...terminal, error: record.error ?? sanitizePromptFailure(undefined) };
|
|
302
320
|
};
|
|
303
321
|
|
|
322
|
+
const lookupResult = (
|
|
323
|
+
kind: ReconciliationKind,
|
|
324
|
+
selector: { commandId?: string; turnId?: string; clientRef?: string },
|
|
325
|
+
): TurnResultPage => {
|
|
326
|
+
cleanup();
|
|
327
|
+
const key =
|
|
328
|
+
selector.clientRef !== undefined
|
|
329
|
+
? clientRefIndex.get(refKey(kind, selector.clientRef))
|
|
330
|
+
: selector.commandId !== undefined && selector.turnId !== undefined
|
|
331
|
+
? keyOf(kind, { commandId: selector.commandId, turnId: selector.turnId })
|
|
332
|
+
: undefined;
|
|
333
|
+
const record = key === undefined ? undefined : records.get(key);
|
|
334
|
+
if (!record) return { status: "unknown" };
|
|
335
|
+
const identity = {
|
|
336
|
+
kind: record.kind,
|
|
337
|
+
commandId: record.commandId,
|
|
338
|
+
turnId: record.turnId,
|
|
339
|
+
...(record.clientRef !== undefined ? { clientRef: record.clientRef } : {}),
|
|
340
|
+
acceptedAt: record.acceptedAt,
|
|
341
|
+
};
|
|
342
|
+
if (record.status === "accepted") return { status: "accepted", ...identity };
|
|
343
|
+
if (record.status === "in_flight") return { status: "in_flight", ...identity, startedAt: record.startedAt };
|
|
344
|
+
const terminal = {
|
|
345
|
+
...identity,
|
|
346
|
+
...(record.startedAt !== undefined ? { startedAt: record.startedAt } : {}),
|
|
347
|
+
terminalAt: record.terminalAt as number,
|
|
348
|
+
...(record.content !== undefined ? { content: record.content } : {}),
|
|
349
|
+
};
|
|
350
|
+
if (record.status === "terminal_ok") return { status: "terminal_ok", ...terminal };
|
|
351
|
+
return { status: "failed", ...terminal, error: record.error ?? sanitizePromptFailure(undefined) };
|
|
352
|
+
};
|
|
304
353
|
const hydrateFromStore = async () => {
|
|
305
354
|
if (!store) return;
|
|
306
355
|
const run = async () => {
|
|
@@ -328,6 +377,7 @@ export function createKindAwareReconciliation(
|
|
|
328
377
|
finalizePromptOutcome,
|
|
329
378
|
peekPendingOutcome,
|
|
330
379
|
lookup,
|
|
380
|
+
lookupResult,
|
|
331
381
|
cleanup,
|
|
332
382
|
activeCount,
|
|
333
383
|
hydrateFromStore,
|
|
@@ -883,12 +883,12 @@ export async function checkNotificationHealth(opts: HealthOptions): Promise<Noti
|
|
|
883
883
|
// `acquireDaemonOwnership` then answers `provisional`, which `ensureTelegramDaemonRunning`
|
|
884
884
|
// reports as `blocked_identity`: no transport is ever attached and no
|
|
885
885
|
// notification is ever published, while the owner keeps heartbeating. Reporting
|
|
886
|
-
// that as OK is what makes the outage invisible
|
|
887
|
-
//
|
|
886
|
+
// that as OK is what makes the outage invisible. Recovery is platform-dependent,
|
|
887
|
+
// so health identifies the condition without prescribing an unsupported command.
|
|
888
888
|
checks.push({
|
|
889
889
|
name: "daemon",
|
|
890
890
|
level: "warn",
|
|
891
|
-
detail: `daemon pid ${daemon.pid} serves generation ${daemon.generation ?? "unknown"} but this build attaches only to generation ${daemon.currentGeneration}; it cannot attach a session transport —
|
|
891
|
+
detail: `daemon pid ${daemon.pid} serves generation ${daemon.generation ?? "unknown"} but this build attaches only to generation ${daemon.currentGeneration}; it cannot attach a session transport — inspect daemon lifecycle status before attempting recovery`,
|
|
892
892
|
});
|
|
893
893
|
} else {
|
|
894
894
|
checks.push({ name: "daemon", level: "ok", detail: `daemon pid ${daemon.pid} alive with a fresh heartbeat` });
|
|
@@ -12,6 +12,8 @@
|
|
|
12
12
|
import * as fs from "node:fs/promises";
|
|
13
13
|
import * as path from "node:path";
|
|
14
14
|
import type { PromptReconciliationStatus, SdkPromptTerminalOutcome } from "../prompt-status";
|
|
15
|
+
import { TURN_RESULT_CONTENT_MAX_BYTES, type TurnResultContent } from "../turn-result";
|
|
16
|
+
|
|
15
17
|
import type { PromptCorrelation } from "./prompt-reconciliation";
|
|
16
18
|
|
|
17
19
|
export const RECONCILIATION_STORE_VERSION = 1;
|
|
@@ -32,6 +34,7 @@ export interface DurableReconciliationRecord extends PromptCorrelation {
|
|
|
32
34
|
pendingOutcome?: SdkPromptTerminalOutcome;
|
|
33
35
|
/** Skill-only safe token; never skill args bodies. */
|
|
34
36
|
skillName?: string;
|
|
37
|
+
content?: TurnResultContent;
|
|
35
38
|
}
|
|
36
39
|
|
|
37
40
|
export interface ReconciliationStoreDocument {
|
|
@@ -112,6 +115,19 @@ function isValidRecord(value: unknown): boolean {
|
|
|
112
115
|
if (!isRecord(value.error)) return false;
|
|
113
116
|
if (typeof value.error.code !== "string" || typeof value.error.message !== "string") return false;
|
|
114
117
|
}
|
|
118
|
+
if (value.content !== undefined) {
|
|
119
|
+
if (
|
|
120
|
+
!isRecord(value.content) ||
|
|
121
|
+
value.content.version !== 1 ||
|
|
122
|
+
value.content.type !== "text" ||
|
|
123
|
+
typeof value.content.text !== "string" ||
|
|
124
|
+
typeof value.content.byteLength !== "number" ||
|
|
125
|
+
typeof value.content.truncated !== "boolean" ||
|
|
126
|
+
new TextEncoder().encode(value.content.text).length !== value.content.byteLength ||
|
|
127
|
+
value.content.byteLength > TURN_RESULT_CONTENT_MAX_BYTES
|
|
128
|
+
)
|
|
129
|
+
return false;
|
|
130
|
+
}
|
|
115
131
|
return [outcome, pendingOutcome].every(candidate => {
|
|
116
132
|
if (candidate === undefined) return true;
|
|
117
133
|
if (!isRecord(candidate)) return false;
|
|
@@ -95,9 +95,18 @@ export const NOTIFICATION_PROTOCOL_VERSION = 3;
|
|
|
95
95
|
* the exact grace deadline when a failed archive publication rolls back.
|
|
96
96
|
* Generation 59 publishes the owner's attached OPEN-socket count in the
|
|
97
97
|
* heartbeat sidecar so `gjc notify health` can report a live daemon that is
|
|
98
|
-
* attached to no registered endpoint (#4128).
|
|
98
|
+
* attached to no registered endpoint (#4128). Generation 60 contains transient
|
|
99
|
+
* heartbeat-sidecar publication failures: a Windows EPERM/EACCES/EBUSY on the
|
|
100
|
+
* sidecar rename is retried bounded under the ownership-lock fence and an
|
|
101
|
+
* exhausted publication keeps the daemon alive as `publish_failed` instead of
|
|
102
|
+
* escaping as an uncaught exception, while only a proven state/lock mismatch
|
|
103
|
+
* (`not_owner`) stops the owner (#4200). Generation 61 keeps authenticated
|
|
104
|
+
* same-session transports attached while predecessor archival converges and
|
|
105
|
+
* settles non-forum close responses. Generation 62 rejects replay-gap
|
|
106
|
+
* authority claims that do not match the requested cursor, answer bounds,
|
|
107
|
+
* and retained replay suffix.
|
|
99
108
|
*/
|
|
100
|
-
export const DAEMON_GENERATION =
|
|
109
|
+
export const DAEMON_GENERATION = 62;
|
|
101
110
|
|
|
102
111
|
/**
|
|
103
112
|
* Serving-compatibility boundary for daemon lifecycle requests. Epoch 5
|
|
@@ -531,7 +531,7 @@ function topicArchiveSettled(response: unknown): boolean {
|
|
|
531
531
|
if (result.ok === true && result.result === true) return true;
|
|
532
532
|
if (result.ok !== false || result.error_code !== 400 || typeof result.description !== "string") return false;
|
|
533
533
|
const description = result.description.trim();
|
|
534
|
-
return /^(?:Bad Request: )?(?:TOPIC_NOT_FOUND|THREAD_NOT_FOUND|topic (?:already|is already) closed|message thread (?:not found|is not modified))$/i.test(
|
|
534
|
+
return /^(?:Bad Request: )?(?:TOPIC_NOT_FOUND|THREAD_NOT_FOUND|the chat is not a (?:supergroup )?forum|topic (?:already|is already) closed|message thread (?:not found|is not modified))$/i.test(
|
|
535
535
|
description,
|
|
536
536
|
);
|
|
537
537
|
}
|
|
@@ -1344,6 +1344,30 @@ export async function readOwnerFreshnessSnapshot(input: {
|
|
|
1344
1344
|
};
|
|
1345
1345
|
}
|
|
1346
1346
|
|
|
1347
|
+
/** Outcome of a steady heartbeat sidecar renewal. */
|
|
1348
|
+
export type OwnerHeartbeatSidecarRenewal =
|
|
1349
|
+
/** The sidecar was published under a still-matching ownership lock. */
|
|
1350
|
+
| "renewed"
|
|
1351
|
+
/** This process no longer matches the persisted state/lock; stop renewing. */
|
|
1352
|
+
| "not_owner"
|
|
1353
|
+
/**
|
|
1354
|
+
* Ownership still held, but the sidecar publication failed (e.g. a transient
|
|
1355
|
+
* Windows EPERM/EBUSY while another process holds the destination). The
|
|
1356
|
+
* daemon must stay alive and publish again on the next cycle (#4200); a
|
|
1357
|
+
* persistently failing publication eventually stales the heartbeat for
|
|
1358
|
+
* outside observers, whose takeover then surfaces here as `not_owner`.
|
|
1359
|
+
*/
|
|
1360
|
+
| "publish_failed";
|
|
1361
|
+
|
|
1362
|
+
const HEARTBEAT_SIDECAR_PUBLISH_ATTEMPTS = 4;
|
|
1363
|
+
const HEARTBEAT_SIDECAR_PUBLISH_BACKOFF_MS = 25;
|
|
1364
|
+
|
|
1365
|
+
/** Windows sharing/permission failures from antivirus, indexing, or external file locks. */
|
|
1366
|
+
function isTransientSidecarPublishError(error: unknown): boolean {
|
|
1367
|
+
const code = (error as { code?: unknown } | null)?.code;
|
|
1368
|
+
return code === "EPERM" || code === "EACCES" || code === "EBUSY";
|
|
1369
|
+
}
|
|
1370
|
+
|
|
1347
1371
|
/** Marker-free steady heartbeat renewal. The final lock reread fences a stale writer. */
|
|
1348
1372
|
export async function renewOwnerHeartbeatSidecar(input: {
|
|
1349
1373
|
settings: Settings;
|
|
@@ -1359,7 +1383,7 @@ export async function renewOwnerHeartbeatSidecar(input: {
|
|
|
1359
1383
|
* rather than falsely claiming zero attachments.
|
|
1360
1384
|
*/
|
|
1361
1385
|
attachedEndpoints?: number;
|
|
1362
|
-
}): Promise<
|
|
1386
|
+
}): Promise<OwnerHeartbeatSidecarRenewal> {
|
|
1363
1387
|
const fsImpl = input.fs ?? nodeFs;
|
|
1364
1388
|
const paths = daemonPaths(input.settings.getAgentDir());
|
|
1365
1389
|
const state = await readJson<DaemonState>(fsImpl, paths.state);
|
|
@@ -1376,7 +1400,7 @@ export async function renewOwnerHeartbeatSidecar(input: {
|
|
|
1376
1400
|
state.acquisitionId !== acquisitionId ||
|
|
1377
1401
|
state.stoppedAt !== undefined
|
|
1378
1402
|
)
|
|
1379
|
-
return
|
|
1403
|
+
return "not_owner";
|
|
1380
1404
|
const sidecar: OwnerHeartbeatSidecar = {
|
|
1381
1405
|
pid,
|
|
1382
1406
|
incarnation,
|
|
@@ -1386,22 +1410,48 @@ export async function renewOwnerHeartbeatSidecar(input: {
|
|
|
1386
1410
|
...(validAttachedEndpointCount(input.attachedEndpoints) ? { attachedEndpoints: input.attachedEndpoints } : {}),
|
|
1387
1411
|
};
|
|
1388
1412
|
const tmp = `${paths.heartbeat}.${process.pid}.${Date.now()}.${Math.random().toString(36).slice(2)}.tmp`;
|
|
1389
|
-
|
|
1390
|
-
|
|
1413
|
+
try {
|
|
1414
|
+
await fsImpl.writeFile(tmp, `${JSON.stringify(sidecar, null, 2)}\n`, { mode: 0o600 });
|
|
1415
|
+
await fsImpl.chmod(tmp, 0o600).catch(() => undefined);
|
|
1416
|
+
} catch (error) {
|
|
1417
|
+
// One failed staging write must not terminate the daemon (#4200).
|
|
1418
|
+
await fsImpl.unlink(tmp).catch(() => undefined);
|
|
1419
|
+
logger.warn(`notifications: heartbeat sidecar staging failed: ${sanitizeDiagnostic(String(error))}`);
|
|
1420
|
+
return "publish_failed";
|
|
1421
|
+
}
|
|
1391
1422
|
const lock = await readOwnershipLock(fsImpl, paths.lock);
|
|
1392
1423
|
if (!ownershipLockMatchesState(lock, state)) {
|
|
1393
1424
|
await fsImpl.unlink(tmp).catch(() => undefined);
|
|
1394
|
-
return
|
|
1425
|
+
return "not_owner";
|
|
1395
1426
|
}
|
|
1396
|
-
|
|
1397
|
-
|
|
1398
|
-
|
|
1399
|
-
|
|
1400
|
-
await fsImpl.
|
|
1401
|
-
|
|
1427
|
+
for (let attempt = 1; ; attempt++) {
|
|
1428
|
+
// Revalidate the exact lock after the temporary sidecar exists — and again
|
|
1429
|
+
// before every retry — so a stale writer cannot publish after ownership
|
|
1430
|
+
// moved during its write or its backoff.
|
|
1431
|
+
const finalLock = await readOwnershipLock(fsImpl, paths.lock);
|
|
1432
|
+
if (!ownershipLockMatchesState(finalLock, state)) {
|
|
1433
|
+
await fsImpl.unlink(tmp).catch(() => undefined);
|
|
1434
|
+
return "not_owner";
|
|
1435
|
+
}
|
|
1436
|
+
try {
|
|
1437
|
+
await fsImpl.rename(tmp, paths.heartbeat);
|
|
1438
|
+
return "renewed";
|
|
1439
|
+
} catch (error) {
|
|
1440
|
+
// Windows: a transient external lock on the destination (antivirus,
|
|
1441
|
+
// indexer) surfaces as EPERM on rename and used to escape as an
|
|
1442
|
+
// uncaught exception that killed the whole daemon (#4200). Retry a
|
|
1443
|
+
// bounded number of times under the lock fence, then keep the daemon
|
|
1444
|
+
// alive and let the next heartbeat cycle publish.
|
|
1445
|
+
if (attempt >= HEARTBEAT_SIDECAR_PUBLISH_ATTEMPTS || !isTransientSidecarPublishError(error)) {
|
|
1446
|
+
await fsImpl.unlink(tmp).catch(() => undefined);
|
|
1447
|
+
logger.warn(
|
|
1448
|
+
`notifications: heartbeat sidecar publication failed after ${attempt} attempt(s): ${sanitizeDiagnostic(String(error))}`,
|
|
1449
|
+
);
|
|
1450
|
+
return "publish_failed";
|
|
1451
|
+
}
|
|
1452
|
+
await Bun.sleep(HEARTBEAT_SIDECAR_PUBLISH_BACKOFF_MS * attempt);
|
|
1453
|
+
}
|
|
1402
1454
|
}
|
|
1403
|
-
await fsImpl.rename(tmp, paths.heartbeat);
|
|
1404
|
-
return true;
|
|
1405
1455
|
}
|
|
1406
1456
|
|
|
1407
1457
|
function ownershipLockMatchesStoppedState(
|
|
@@ -3106,17 +3156,21 @@ export async function renewDaemonHeartbeat(input: {
|
|
|
3106
3156
|
// The durable sidecar must exist and match the rebound ownership lock before
|
|
3107
3157
|
// any observer can see ownershipPhase:"ready". A provisional state may be
|
|
3108
3158
|
// fresh, but readiness waiters categorically refuse to attach to it.
|
|
3159
|
+
// The initial proof is strict: only a real publication counts. A
|
|
3160
|
+
// `publish_failed` here has no durable sidecar, so readiness must not be
|
|
3161
|
+
// claimed and the provisional ownership retires exactly as before.
|
|
3109
3162
|
let sidecarRenewed = false;
|
|
3110
3163
|
try {
|
|
3111
|
-
sidecarRenewed =
|
|
3112
|
-
|
|
3113
|
-
|
|
3114
|
-
|
|
3115
|
-
|
|
3116
|
-
|
|
3117
|
-
|
|
3118
|
-
|
|
3119
|
-
|
|
3164
|
+
sidecarRenewed =
|
|
3165
|
+
(await renewOwnerHeartbeatSidecar({
|
|
3166
|
+
settings: input.settings,
|
|
3167
|
+
ownerId: input.ownerId,
|
|
3168
|
+
acquisitionId,
|
|
3169
|
+
fs: fsImpl,
|
|
3170
|
+
now: () => heartbeatAt,
|
|
3171
|
+
pid,
|
|
3172
|
+
pidIncarnation: input.pidIncarnation,
|
|
3173
|
+
})) === "renewed";
|
|
3120
3174
|
} catch {
|
|
3121
3175
|
sidecarRenewed = false;
|
|
3122
3176
|
}
|
|
@@ -4764,6 +4818,8 @@ interface SessionSocket {
|
|
|
4764
4818
|
pingTimer: ReturnType<typeof setInterval> | undefined;
|
|
4765
4819
|
/** Correlation id for the startup replay barrier. */
|
|
4766
4820
|
replayId: string;
|
|
4821
|
+
/** Sequence cursor sent by the current startup replay request. */
|
|
4822
|
+
replaySinceSeq: number;
|
|
4767
4823
|
/** Queues live frames until startup replay is applied. */
|
|
4768
4824
|
replayPending: boolean;
|
|
4769
4825
|
replayQueue: Record<string, unknown>[];
|
|
@@ -6174,8 +6230,12 @@ export class TelegramNotificationDaemon {
|
|
|
6174
6230
|
const endpointKey = endpointGenerationKey(endpoint.url, endpoint.token);
|
|
6175
6231
|
const connected = this.sessions.get(sessionId);
|
|
6176
6232
|
if (connected) {
|
|
6233
|
+
const recoveryRejectedAfterArchive =
|
|
6234
|
+
connected.recoveryLease?.state === "rejected" &&
|
|
6235
|
+
this.topics.get(sessionId)?.authorityState === "inactive";
|
|
6177
6236
|
if (
|
|
6178
6237
|
connected.endpointKey !== endpointKey ||
|
|
6238
|
+
recoveryRejectedAfterArchive ||
|
|
6179
6239
|
(connected.ws.readyState === WebSocket.CONNECTING &&
|
|
6180
6240
|
this.runtime.now() - connected.connectingSince >= CONNECTING_RECONNECT_MS)
|
|
6181
6241
|
)
|
|
@@ -6265,6 +6325,7 @@ export class TelegramNotificationDaemon {
|
|
|
6265
6325
|
awaitingNonce: undefined,
|
|
6266
6326
|
pingTimer: undefined,
|
|
6267
6327
|
replayId: `telegram-startup-replay:${sessionId}`,
|
|
6328
|
+
replaySinceSeq: 0,
|
|
6268
6329
|
replayPending: false,
|
|
6269
6330
|
replayQueue: [],
|
|
6270
6331
|
};
|
|
@@ -6283,6 +6344,7 @@ export class TelegramNotificationDaemon {
|
|
|
6283
6344
|
const persistedTopic = this.topics.get(sessionId);
|
|
6284
6345
|
const replayCursor =
|
|
6285
6346
|
persistedTopic?.endpointDigest === session.endpointDigest ? this.topics.replayCursor(sessionId) : undefined;
|
|
6347
|
+
session.replaySinceSeq = replayCursor?.seq ?? 0;
|
|
6286
6348
|
if (session.ws.readyState === WebSocket.OPEN) {
|
|
6287
6349
|
try {
|
|
6288
6350
|
session.ws.send(
|
|
@@ -9714,7 +9776,7 @@ export class TelegramNotificationDaemon {
|
|
|
9714
9776
|
for (const session of this.sessions.values()) if (session.ws.readyState === WebSocket.OPEN) attached += 1;
|
|
9715
9777
|
return attached;
|
|
9716
9778
|
}
|
|
9717
|
-
private async renewOwnershipHeartbeat(): Promise<
|
|
9779
|
+
private async renewOwnershipHeartbeat(): Promise<OwnerHeartbeatSidecarRenewal> {
|
|
9718
9780
|
return renewOwnerHeartbeatSidecar({
|
|
9719
9781
|
settings: this.opts.settings,
|
|
9720
9782
|
ownerId: this.opts.ownerId,
|
|
@@ -9736,7 +9798,10 @@ export class TelegramNotificationDaemon {
|
|
|
9736
9798
|
if (!this.running) return;
|
|
9737
9799
|
void this.runtime
|
|
9738
9800
|
.runExclusive("telegram-owner-heartbeat", async () => {
|
|
9739
|
-
|
|
9801
|
+
// `publish_failed` keeps the daemon alive: ownership is still held
|
|
9802
|
+
// and the next interval republishes (#4200). Only a proven
|
|
9803
|
+
// ownership loss stops the owner.
|
|
9804
|
+
if ((await this.renewOwnershipHeartbeat()) === "not_owner") this.runtime.requestStop();
|
|
9740
9805
|
})
|
|
9741
9806
|
.catch(err => {
|
|
9742
9807
|
logger.warn(`notifications: ownership heartbeat failed: ${sanitizeDiagnostic(String(err))}`);
|
|
@@ -9944,7 +10009,6 @@ export class TelegramNotificationDaemon {
|
|
|
9944
10009
|
msg.generation >= 1 &&
|
|
9945
10010
|
Number.isSafeInteger(msg.lastSeq) &&
|
|
9946
10011
|
msg.lastSeq >= 0 &&
|
|
9947
|
-
msg.gap === undefined &&
|
|
9948
10012
|
Array.isArray(msg.events) &&
|
|
9949
10013
|
msg.events.every((event: unknown) => event !== null && typeof event === "object" && !Array.isArray(event));
|
|
9950
10014
|
const replayed: Record<string, unknown>[] = replayValid
|
|
@@ -9963,7 +10027,34 @@ export class TelegramNotificationDaemon {
|
|
|
9963
10027
|
frame => typeof frame.sessionId !== "string" || !frame.sessionId.trim(),
|
|
9964
10028
|
);
|
|
9965
10029
|
const identityConflict = new Set(identities).size > 1;
|
|
9966
|
-
|
|
10030
|
+
const retainedAtOrBelowGap =
|
|
10031
|
+
msg.gap !== null &&
|
|
10032
|
+
typeof msg.gap === "object" &&
|
|
10033
|
+
!Array.isArray(msg.gap) &&
|
|
10034
|
+
Number.isSafeInteger(msg.gap.toSeq) &&
|
|
10035
|
+
Array.isArray(msg.events) &&
|
|
10036
|
+
(msg.events as Record<string, unknown>[]).some(
|
|
10037
|
+
event =>
|
|
10038
|
+
Number.isSafeInteger(event.seq) &&
|
|
10039
|
+
(event.seq as number) >= 1 &&
|
|
10040
|
+
(event.seq as number) <= msg.gap.toSeq,
|
|
10041
|
+
);
|
|
10042
|
+
const sequenceGapWithIdentityProof =
|
|
10043
|
+
msg.gap !== null &&
|
|
10044
|
+
typeof msg.gap === "object" &&
|
|
10045
|
+
!Array.isArray(msg.gap) &&
|
|
10046
|
+
msg.gap.kind === "sequence_gap" &&
|
|
10047
|
+
Number.isSafeInteger(msg.gap.fromSeq) &&
|
|
10048
|
+
Number.isSafeInteger(msg.gap.toSeq) &&
|
|
10049
|
+
msg.gap.fromSeq === session.replaySinceSeq + 1 &&
|
|
10050
|
+
msg.gap.fromSeq <= msg.gap.toSeq &&
|
|
10051
|
+
msg.gap.toSeq <= msg.lastSeq &&
|
|
10052
|
+
!retainedAtOrBelowGap &&
|
|
10053
|
+
Array.isArray(msg.gap.resyncQueries) &&
|
|
10054
|
+
msg.gap.resyncQueries.every((query: unknown) => typeof query === "string") &&
|
|
10055
|
+
identityFrames.length > 0;
|
|
10056
|
+
const gapAllowsRecovery = msg.gap === undefined || sequenceGapWithIdentityProof;
|
|
10057
|
+
if (!replayValid || !gapAllowsRecovery || malformedIdentity || identityConflict) {
|
|
9967
10058
|
// A replay result is the admission proof. Never clear its barrier or drain
|
|
9968
10059
|
// queued frames after malformed/conflicting proof; the socket cannot fall
|
|
9969
10060
|
// back to transport-local config rekeying.
|
|
@@ -10005,6 +10096,17 @@ export class TelegramNotificationDaemon {
|
|
|
10005
10096
|
);
|
|
10006
10097
|
if (this.sessions.get(session.sessionId) !== session) return;
|
|
10007
10098
|
if (!recovered) {
|
|
10099
|
+
const authorityState = this.topics.get(replayCandidateSessionId)?.authorityState;
|
|
10100
|
+
if (
|
|
10101
|
+
replayCandidateSessionId === session.sessionId &&
|
|
10102
|
+
(authorityState === "archive_pending" || authorityState === "archive_exhausted")
|
|
10103
|
+
) {
|
|
10104
|
+
// The old topic remains fenced, but the authenticated live transport
|
|
10105
|
+
// must stay attached while archive reconciliation retries.
|
|
10106
|
+
session.replayQueue = [];
|
|
10107
|
+
session.replayPending = false;
|
|
10108
|
+
return;
|
|
10109
|
+
}
|
|
10008
10110
|
if (session.hostGeneration === msg.generation && session.recoveryLease?.state !== "pending")
|
|
10009
10111
|
this.dropSession(session, "recovery_rejected");
|
|
10010
10112
|
return;
|
|
@@ -12389,7 +12491,7 @@ export class TelegramNotificationDaemon {
|
|
|
12389
12491
|
while (this.running) {
|
|
12390
12492
|
if (await this.controlStopRequested()) break;
|
|
12391
12493
|
if (
|
|
12392
|
-
|
|
12494
|
+
(await renewOwnerHeartbeatSidecar({
|
|
12393
12495
|
settings: this.opts.settings,
|
|
12394
12496
|
ownerId: this.opts.ownerId,
|
|
12395
12497
|
acquisitionId: this.opts.ownerId,
|
|
@@ -12398,7 +12500,7 @@ export class TelegramNotificationDaemon {
|
|
|
12398
12500
|
pid: this.opts.pid ?? process.pid,
|
|
12399
12501
|
pidIncarnation: this.opts.pidIncarnation,
|
|
12400
12502
|
attachedEndpoints: this.attachedEndpointCount(),
|
|
12401
|
-
}))
|
|
12503
|
+
})) === "not_owner"
|
|
12402
12504
|
)
|
|
12403
12505
|
break;
|
|
12404
12506
|
await this.runScan();
|
|
@@ -213,7 +213,7 @@ export function emptyTopicRegistryState(): TopicRegistryState {
|
|
|
213
213
|
*/
|
|
214
214
|
export function parseTopicRegistryState(value: unknown): TopicRegistryState | undefined {
|
|
215
215
|
if (!value || typeof value !== "object" || Array.isArray(value)) return undefined;
|
|
216
|
-
|
|
216
|
+
let state = value as TopicRegistryState;
|
|
217
217
|
if (state.version !== undefined && state.version !== 2) throw new Error("unsupported future Telegram topic state");
|
|
218
218
|
if (!state.topics || typeof state.topics !== "object" || Array.isArray(state.topics)) return undefined;
|
|
219
219
|
if (state.version === undefined) {
|
|
@@ -239,6 +239,26 @@ export function parseTopicRegistryState(value: unknown): TopicRegistryState | un
|
|
|
239
239
|
|
|
240
240
|
const isObject = (candidate: unknown): candidate is Record<string, unknown> =>
|
|
241
241
|
!!candidate && typeof candidate === "object" && !Array.isArray(candidate);
|
|
242
|
+
state = {
|
|
243
|
+
...state,
|
|
244
|
+
topics: Object.fromEntries(
|
|
245
|
+
Object.entries(state.topics).map(([sessionId, record]) => [
|
|
246
|
+
sessionId,
|
|
247
|
+
isObject(record) ? { ...record } : record,
|
|
248
|
+
]),
|
|
249
|
+
),
|
|
250
|
+
retiredTopics:
|
|
251
|
+
state.retiredTopics === undefined
|
|
252
|
+
? undefined
|
|
253
|
+
: Object.fromEntries(
|
|
254
|
+
Object.entries(state.retiredTopics).map(([sessionId, records]) => [
|
|
255
|
+
sessionId,
|
|
256
|
+
Array.isArray(records)
|
|
257
|
+
? records.map(record => (isObject(record) ? { ...record } : record))
|
|
258
|
+
: records,
|
|
259
|
+
]),
|
|
260
|
+
),
|
|
261
|
+
};
|
|
242
262
|
const validTimestamp = (candidate: unknown): candidate is number =>
|
|
243
263
|
typeof candidate === "number" && Number.isFinite(candidate) && candidate >= 0;
|
|
244
264
|
const validEpoch = (candidate: unknown): candidate is number =>
|
|
@@ -303,12 +323,19 @@ export function parseTopicRegistryState(value: unknown): TopicRegistryState | un
|
|
|
303
323
|
value => value !== undefined,
|
|
304
324
|
).length;
|
|
305
325
|
if (leaseFieldCount !== 0 && leaseFieldCount !== 3) malformed();
|
|
306
|
-
if (
|
|
307
|
-
raw.
|
|
308
|
-
|
|
309
|
-
|
|
310
|
-
|
|
311
|
-
|
|
326
|
+
if (raw.authorityState === "disconnect_grace") {
|
|
327
|
+
if (raw.disconnectGraceExpiresAt === undefined || raw.orphanedAt === undefined) malformed();
|
|
328
|
+
} else if (raw.disconnectGraceExpiresAt !== undefined) {
|
|
329
|
+
if (
|
|
330
|
+
raw.authorityState !== "archive_pending" &&
|
|
331
|
+
raw.authorityState !== "archive_exhausted" &&
|
|
332
|
+
raw.authorityState !== "inactive"
|
|
333
|
+
)
|
|
334
|
+
malformed();
|
|
335
|
+
// Older archive transitions retained the expired disconnect-grace marker,
|
|
336
|
+
// making the entire shared registry unreadable on the next daemon start.
|
|
337
|
+
delete raw.disconnectGraceExpiresAt;
|
|
338
|
+
}
|
|
312
339
|
const hasBinding = hasAnyBinding(raw);
|
|
313
340
|
const hasArchiveOnlyChatIdentity =
|
|
314
341
|
(raw.authorityState === "archive_pending" ||
|
|
@@ -333,11 +360,13 @@ export function parseTopicRegistryState(value: unknown): TopicRegistryState | un
|
|
|
333
360
|
for (const [sessionId, records] of Object.entries(state.retiredTopics ?? {})) {
|
|
334
361
|
if (!isValidBindingString(sessionId) || !Array.isArray(records)) malformed();
|
|
335
362
|
for (const [index, record] of records.entries()) {
|
|
336
|
-
|
|
363
|
+
const recordKey = `${sessionId}:retired:${index}`;
|
|
364
|
+
const parsed = parseTopicRegistryState({
|
|
337
365
|
version: 2,
|
|
338
366
|
registryGeneration: 0,
|
|
339
|
-
topics: { [
|
|
367
|
+
topics: { [recordKey]: record },
|
|
340
368
|
});
|
|
369
|
+
records[index] = parsed?.topics[recordKey] ?? malformed();
|
|
341
370
|
}
|
|
342
371
|
}
|
|
343
372
|
for (const [sessionId, epoch] of Object.entries(state.fences ?? {}))
|
|
@@ -11,6 +11,7 @@ import {
|
|
|
11
11
|
SdkClient,
|
|
12
12
|
SdkClientError,
|
|
13
13
|
SdkDiscoveryError,
|
|
14
|
+
type SdkSentRecord,
|
|
14
15
|
} from "../client";
|
|
15
16
|
import { PROMPT_CLIENT_REF_MAX_LENGTH } from "../prompt-status.js";
|
|
16
17
|
import { validateAdapterControl } from "../protocol/adapter-validation";
|
|
@@ -962,13 +963,25 @@ async function runRawGlobal(
|
|
|
962
963
|
throw new SdkSessionCliError("invalid_input", "--idempotency-key is required for lifecycle operations.", 2);
|
|
963
964
|
const client = await connectBroker(agentDir);
|
|
964
965
|
try {
|
|
965
|
-
|
|
966
|
-
|
|
967
|
-
|
|
968
|
-
|
|
969
|
-
|
|
970
|
-
|
|
971
|
-
|
|
966
|
+
try {
|
|
967
|
+
const timeoutMs = lifecycleRequestTimeoutMs(operation, input);
|
|
968
|
+
const response = await client.global(operation, input, {
|
|
969
|
+
idempotencyKey,
|
|
970
|
+
...(timeoutMs === undefined ? {} : { timeoutMs }),
|
|
971
|
+
...(args.elevationRequestId === undefined ? {} : { elevationRequestId: args.elevationRequestId }),
|
|
972
|
+
});
|
|
973
|
+
return args.showEndpointCredential ? response : stripSecretFields(response);
|
|
974
|
+
} catch (error) {
|
|
975
|
+
if (
|
|
976
|
+
isLifecycleOperation(operation) &&
|
|
977
|
+
error instanceof SdkClientError &&
|
|
978
|
+
error.code === "uncertain_after_send" &&
|
|
979
|
+
error.details &&
|
|
980
|
+
typeof error.details === "object"
|
|
981
|
+
)
|
|
982
|
+
return stripSecretFields(await client.lookupLifecycle(error.details as SdkSentRecord));
|
|
983
|
+
throw error;
|
|
984
|
+
}
|
|
972
985
|
} finally {
|
|
973
986
|
await client.close();
|
|
974
987
|
}
|
|
@@ -38,13 +38,34 @@ export type SdkSessionEndpointScope = "default" | "chat";
|
|
|
38
38
|
function endpointDirectory(repo: string, scope: SdkSessionEndpointScope = "default"): string {
|
|
39
39
|
return scope === "chat" ? path.join(repo, ".gjc", "state", "chat", "sdk") : path.join(repo, ".gjc", "state", "sdk");
|
|
40
40
|
}
|
|
41
|
+
function isUsableSessionId(sessionId: string): boolean {
|
|
42
|
+
return (
|
|
43
|
+
sessionId.length > 0 &&
|
|
44
|
+
sessionId !== "." &&
|
|
45
|
+
sessionId !== ".." &&
|
|
46
|
+
!sessionId.includes("/") &&
|
|
47
|
+
!sessionId.includes("\\") &&
|
|
48
|
+
!sessionId.includes("\0")
|
|
49
|
+
);
|
|
50
|
+
}
|
|
41
51
|
|
|
42
52
|
function parseEndpoint(sessionId: string, file: string, value: unknown): SdkSessionEndpoint {
|
|
53
|
+
if (!isUsableSessionId(sessionId))
|
|
54
|
+
throw new SdkDiscoveryError(file, "SDK endpoint discovery filename does not contain a usable session id.");
|
|
43
55
|
if (!value || typeof value !== "object")
|
|
44
56
|
throw new SdkDiscoveryError(file, "SDK endpoint discovery record must be an object.");
|
|
45
|
-
const endpoint = value as {
|
|
57
|
+
const endpoint = value as {
|
|
58
|
+
version?: unknown;
|
|
59
|
+
sessionId?: unknown;
|
|
60
|
+
url?: unknown;
|
|
61
|
+
token?: unknown;
|
|
62
|
+
pid?: unknown;
|
|
63
|
+
stale?: unknown;
|
|
64
|
+
};
|
|
46
65
|
if (typeof endpoint.version === "number" && endpoint.version > 1)
|
|
47
66
|
throw new SdkDiscoveryError(file, "Unsupported SDK endpoint discovery state version.");
|
|
67
|
+
if (endpoint.sessionId !== undefined && endpoint.sessionId !== sessionId)
|
|
68
|
+
throw new SdkDiscoveryError(file, "SDK endpoint discovery record session id does not match its filename.");
|
|
48
69
|
if (typeof endpoint.url !== "string" || !endpoint.url)
|
|
49
70
|
throw new SdkDiscoveryError(file, "SDK endpoint discovery record is invalid.");
|
|
50
71
|
const stale = typeof endpoint.stale === "boolean" ? endpoint.stale : undefined;
|
|
@@ -108,7 +129,7 @@ export async function readSdkSessionEndpoint(
|
|
|
108
129
|
sessionId: string,
|
|
109
130
|
scope: SdkSessionEndpointScope = "default",
|
|
110
131
|
): Promise<SdkSessionEndpoint | null> {
|
|
111
|
-
if (!
|
|
132
|
+
if (!isUsableSessionId(sessionId)) return null;
|
|
112
133
|
const file = path.join(endpointDirectory(repo, scope), `${sessionId}.json`);
|
|
113
134
|
try {
|
|
114
135
|
return parseEndpoint(sessionId, file, JSON.parse(await fs.readFile(file, "utf8")));
|