@gajae-code/coding-agent 0.12.21 → 0.13.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +67 -2
- package/dist/types/cli/args.d.ts +5 -2
- package/dist/types/cli/root-flags.d.ts +246 -0
- package/dist/types/cli.d.ts +11 -19
- package/dist/types/commands/daemon.d.ts +0 -30
- package/dist/types/commands/launch.d.ts +11 -19
- package/dist/types/commands/quick-lane.d.ts +32 -0
- package/dist/types/commands/sdk.d.ts +21 -15
- package/dist/types/config/file-lock-gc.d.ts +2 -0
- package/dist/types/config/model-equivalence.d.ts +20 -0
- package/dist/types/config/model-profile-activation.d.ts +62 -17
- package/dist/types/config/model-profiles.d.ts +10 -0
- package/dist/types/config/model-registry.d.ts +68 -1
- package/dist/types/config/model-resolver.d.ts +15 -4
- package/dist/types/config/models-config-schema.d.ts +7 -2
- package/dist/types/config/provider-selection-policy.d.ts +62 -0
- package/dist/types/config/settings-schema.d.ts +36 -78
- package/dist/types/config/settings.d.ts +2 -0
- package/dist/types/edit/renderer.d.ts +5 -2
- package/dist/types/edit/streaming.d.ts +13 -0
- package/dist/types/extensibility/custom-tools/types.d.ts +2 -0
- package/dist/types/extensibility/extensions/compact-handler.d.ts +2 -1
- package/dist/types/extensibility/extensions/types.d.ts +3 -0
- package/dist/types/gjc-runtime/gc-runtime.d.ts +19 -5
- package/dist/types/internal-urls/local-protocol.d.ts +1 -0
- package/dist/types/modes/components/model-selector.d.ts +0 -2
- package/dist/types/modes/components/provider-order-context.d.ts +45 -0
- package/dist/types/modes/components/provider-order-editor.d.ts +24 -0
- package/dist/types/modes/components/settings-defs.d.ts +5 -1
- package/dist/types/modes/components/settings-selector.d.ts +2 -0
- package/dist/types/quick-lane/classify.d.ts +40 -0
- package/dist/types/runtime/stt-settings-setup.d.ts +18 -0
- package/dist/types/sdk/broker/broker.d.ts +56 -2
- package/dist/types/sdk/broker/operation-receipt-ledger.d.ts +25 -0
- package/dist/types/sdk/broker/session-index.d.ts +83 -2
- package/dist/types/sdk/broker/state-version.d.ts +1 -1
- package/dist/types/sdk/bus/notification-service.d.ts +6 -0
- package/dist/types/sdk/bus/prompt-reconciliation.d.ts +8 -1
- package/dist/types/sdk/bus/telegram-daemon-contract.d.ts +4 -1
- package/dist/types/sdk/bus/telegram-daemon.d.ts +26 -0
- package/dist/types/sdk/cli/rows.d.ts +98 -0
- package/dist/types/sdk/cli/session-cli.d.ts +35 -3
- package/dist/types/sdk/elevation/allowlist.d.ts +20 -0
- package/dist/types/sdk/elevation/audit.d.ts +93 -0
- package/dist/types/sdk/elevation/capability.d.ts +3 -0
- package/dist/types/sdk/elevation/digest.d.ts +72 -0
- package/dist/types/sdk/elevation/dispatch-receipt.d.ts +85 -0
- package/dist/types/sdk/elevation/grant-ledger.d.ts +163 -0
- package/dist/types/sdk/elevation/owner.d.ts +48 -0
- package/dist/types/sdk/guides/cache.d.ts +98 -0
- package/dist/types/sdk/guides/catalog.d.ts +156 -0
- package/dist/types/sdk/guides/cli.d.ts +39 -0
- package/dist/types/sdk/guides/manifest.d.ts +66 -0
- package/dist/types/sdk/guides/verify.d.ts +66 -0
- package/dist/types/sdk/host/control/dispatch.d.ts +8 -0
- package/dist/types/sdk/host/control/index.d.ts +1 -1
- package/dist/types/sdk/host/control/operations.d.ts +3 -2
- package/dist/types/sdk/host/query/cursor.d.ts +10 -0
- package/dist/types/sdk/host/query/handlers.d.ts +21 -0
- package/dist/types/sdk/host/session-runtime.d.ts +9 -0
- package/dist/types/sdk/mcp/server.d.ts +2 -2
- package/dist/types/sdk/prompt-status.d.ts +11 -0
- package/dist/types/sdk/session.d.ts +4 -0
- package/dist/types/sdk/transport/index.d.ts +1 -0
- package/dist/types/sdk/transport/relay.d.ts +1 -0
- package/dist/types/sdk/transport/serve-cli.d.ts +20 -1
- package/dist/types/session/agent-session.d.ts +32 -18
- package/dist/types/session/blob-store.d.ts +13 -7
- package/dist/types/session/fallback-chain-controller.d.ts +15 -0
- package/dist/types/session/internal/managed-session-storage.d.ts +25 -1
- package/dist/types/session/internal/session-memory-sidecar.d.ts +5 -5
- package/dist/types/session/session-manager.d.ts +139 -10
- package/dist/types/session/session-storage.d.ts +48 -0
- package/dist/types/setup/provider-onboarding.d.ts +7 -1
- package/dist/types/slash-commands/builtin-registry.d.ts +14 -0
- package/dist/types/stt/setup.d.ts +2 -0
- package/dist/types/task/executor.d.ts +10 -2
- package/dist/types/tools/browser/runtime-diagnostics.d.ts +65 -0
- package/dist/types/tools/browser/tab-protocol.d.ts +4 -0
- package/dist/types/tools/browser/tab-supervisor.d.ts +2 -0
- package/dist/types/tools/browser/tab-worker.d.ts +2 -0
- package/dist/types/tools/browser.d.ts +2 -0
- package/dist/types/tools/image-gen.d.ts +14 -18
- package/dist/types/tools/index.d.ts +2 -0
- package/dist/types/tools/resource-gc.d.ts +2 -0
- package/dist/types/utils/pasted-image-path.d.ts +6 -0
- package/package.json +9 -9
- package/scripts/generate-sdk-operation-inventory.ts +8 -0
- package/scripts/run-test-manifest.ts +61 -21
- package/scripts/verify-gjc-sdk-canonicalization.ts +9 -4
- package/src/cli/args.ts +62 -9
- package/src/cli/completion-cli.ts +1 -0
- package/src/cli/fast-help.ts +1 -0
- package/src/cli/root-flags.ts +100 -0
- package/src/cli/setup-cli.ts +6 -2
- package/src/cli.ts +42 -65
- package/src/commands/acp.ts +1 -1
- package/src/commands/daemon.ts +2 -31
- package/src/commands/gc.ts +1 -1
- package/src/commands/launch.ts +8 -129
- package/src/commands/quick-lane.ts +51 -0
- package/src/commands/sdk.ts +200 -26
- package/src/commands/setup.ts +4 -2
- package/src/config/file-lock-gc.ts +4 -1
- package/src/config/model-equivalence.ts +76 -7
- package/src/config/model-profile-activation.ts +1115 -265
- package/src/config/model-profiles.ts +134 -2
- package/src/config/model-registry.ts +352 -50
- package/src/config/model-resolver.ts +289 -38
- package/src/config/models-config-schema.ts +11 -4
- package/src/config/provider-selection-policy.ts +146 -0
- package/src/config/settings-schema.ts +50 -77
- package/src/config/settings.ts +40 -0
- package/src/coordinator-mcp/server.ts +32 -2
- package/src/edit/renderer.ts +175 -223
- package/src/edit/streaming.ts +108 -1
- package/src/export/html/index.ts +57 -7
- package/src/extensibility/custom-tools/types.ts +2 -0
- package/src/extensibility/extensions/compact-handler.ts +3 -2
- package/src/extensibility/extensions/runner.ts +6 -0
- package/src/extensibility/extensions/types.ts +3 -0
- package/src/gjc-runtime/gc-runtime.ts +379 -20
- package/src/gjc-runtime/launch-worktree.ts +5 -1
- package/src/gjc-runtime/team-runtime.ts +59 -2
- package/src/gjc-runtime/tmux-owner-isolation.ts +19 -0
- package/src/gjc-runtime/ultragoal-receipt-freshness.ts +16 -1
- package/src/internal-urls/docs-index.generated.ts +17 -13
- package/src/internal-urls/local-protocol.ts +1 -0
- package/src/main.ts +40 -3
- package/src/memories/index.ts +4 -2
- package/src/modes/acp/acp-agent.ts +235 -91
- package/src/modes/components/model-selector.ts +206 -42
- package/src/modes/components/provider-order-context.ts +197 -0
- package/src/modes/components/provider-order-editor.ts +441 -0
- package/src/modes/components/settings-defs.ts +21 -1
- package/src/modes/components/settings-selector.ts +45 -2
- package/src/modes/components/tool-execution.ts +47 -15
- package/src/modes/controllers/command-controller.ts +6 -3
- package/src/modes/controllers/extension-ui-controller.ts +2 -0
- package/src/modes/controllers/input-controller.ts +73 -2
- package/src/modes/controllers/selector-controller.ts +260 -153
- package/src/modes/prompt-suggestion-controller.ts +1 -1
- package/src/modes/runtime-init.ts +1 -0
- package/src/modes/utils/ui-helpers.ts +18 -0
- package/src/prompts/tools/browser.md +1 -0
- package/src/quick-lane/classify.ts +257 -0
- package/src/rlm/index.ts +1 -1
- package/src/runtime/stt-settings-setup.ts +57 -0
- package/src/sdk/acp/adapter.ts +2 -1
- package/src/sdk/broker/broker.ts +780 -12
- package/src/sdk/broker/lifecycle.ts +70 -29
- package/src/sdk/broker/operation-receipt-ledger.ts +144 -0
- package/src/sdk/broker/session-index.ts +463 -48
- package/src/sdk/broker/state-version.ts +13 -9
- package/src/sdk/broker/transport.ts +14 -1
- package/src/sdk/bus/index.ts +34 -13
- package/src/sdk/bus/kind-aware-reconciliation.ts +21 -2
- package/src/sdk/bus/notification-service.ts +47 -1
- package/src/sdk/bus/prompt-reconciliation.ts +22 -3
- package/src/sdk/bus/telegram-daemon-contract.ts +4 -1
- package/src/sdk/bus/telegram-daemon.ts +54 -3
- package/src/sdk/cli/rows.ts +247 -0
- package/src/sdk/cli/session-cli.ts +930 -79
- package/src/sdk/elevation/allowlist.ts +54 -0
- package/src/sdk/elevation/audit.ts +208 -0
- package/src/sdk/elevation/capability.ts +41 -0
- package/src/sdk/elevation/digest.ts +129 -0
- package/src/sdk/elevation/dispatch-receipt.ts +184 -0
- package/src/sdk/elevation/grant-ledger.ts +1142 -0
- package/src/sdk/elevation/owner.ts +109 -0
- package/src/sdk/guides/cache.ts +363 -0
- package/src/sdk/guides/catalog.ts +554 -0
- package/src/sdk/guides/cli.ts +235 -0
- package/src/sdk/guides/manifest.ts +152 -0
- package/src/sdk/guides/verify.ts +178 -0
- package/src/sdk/host/control/dispatch.ts +49 -1
- package/src/sdk/host/control/index.ts +1 -0
- package/src/sdk/host/control/operations.ts +8 -1
- package/src/sdk/host/query/cursor.ts +47 -3
- package/src/sdk/host/query/handlers.ts +155 -2
- package/src/sdk/host/sdk-surface-parity.test.ts +1 -0
- package/src/sdk/host/session-runtime.test.ts +391 -1
- package/src/sdk/host/session-runtime.ts +308 -73
- package/src/sdk/host/surface-policy.ts +3 -0
- package/src/sdk/host/websocket-transport.ts +7 -1
- package/src/sdk/mcp/server.ts +88 -15
- package/src/sdk/prompt-status.ts +8 -0
- package/src/sdk/protocol/operation-inventory.generated.json +62 -0
- package/src/sdk/protocol/operation-registry.ts +3 -2
- package/src/sdk/session.ts +87 -39
- package/src/sdk/transport/index.ts +1 -0
- package/src/sdk/transport/relay.ts +3 -0
- package/src/sdk/transport/serve-cli.ts +111 -24
- package/src/secrets/obfuscator.ts +92 -12
- package/src/session/agent-session.ts +322 -93
- package/src/session/blob-store.ts +60 -8
- package/src/session/fallback-chain-controller.ts +35 -0
- package/src/session/internal/managed-session-scope.ts +25 -3
- package/src/session/internal/managed-session-storage.ts +574 -73
- package/src/session/internal/session-memory-sidecar.ts +19 -29
- package/src/session/session-manager.ts +2166 -743
- package/src/session/session-storage.ts +715 -48
- package/src/setup/provider-onboarding.ts +19 -3
- package/src/setup/provider-presets.json +3 -1
- package/src/slash-commands/builtin-registry.ts +85 -3
- package/src/stt/downloader.ts +2 -3
- package/src/stt/setup.ts +29 -4
- package/src/task/executor.ts +22 -18
- package/src/task/index.ts +24 -1
- package/src/tools/archive-reader.ts +11 -2
- package/src/tools/browser/runtime-diagnostics.ts +249 -0
- package/src/tools/browser/tab-protocol.ts +4 -0
- package/src/tools/browser/tab-supervisor.ts +5 -0
- package/src/tools/browser/tab-worker.ts +36 -1
- package/src/tools/browser.ts +7 -0
- package/src/tools/image-gen.ts +110 -166
- package/src/tools/index.ts +2 -0
- package/src/tools/read.ts +1 -1
- package/src/tools/resource-gc.ts +6 -1
- package/src/tools/tool-catalog.generated.ts +5 -1
- package/src/utils/pasted-image-path.ts +93 -0
- package/src/web/search/index.ts +3 -3
|
@@ -2,6 +2,7 @@ import { createHash, randomUUID } from "node:crypto";
|
|
|
2
2
|
import * as fs from "node:fs/promises";
|
|
3
3
|
import path from "node:path";
|
|
4
4
|
import { withFileLock } from "../../config/file-lock";
|
|
5
|
+
import { processIncarnation } from "./process-incarnation";
|
|
5
6
|
import {
|
|
6
7
|
assertSupportedSnapshotVersion,
|
|
7
8
|
assertSupportedStateVersion,
|
|
@@ -17,7 +18,29 @@ export type SessionIndexEventType =
|
|
|
17
18
|
| "lifecycle_started"
|
|
18
19
|
| "lifecycle_terminal"
|
|
19
20
|
| "session_closed"
|
|
21
|
+
| "session_deleted"
|
|
20
22
|
| "record_reconciled";
|
|
23
|
+
|
|
24
|
+
export type SessionActivityState = "active" | "idle";
|
|
25
|
+
/** Coalesced broker-owned heartbeat checkpoint (C2): state plus the observation time. */
|
|
26
|
+
export interface SessionActivity {
|
|
27
|
+
state: SessionActivityState;
|
|
28
|
+
at: number;
|
|
29
|
+
}
|
|
30
|
+
/** Events persisted without an OS process incarnation (v1/v2 era) are legacy provenance. */
|
|
31
|
+
export type SessionIdentityProvenance = "composite" | "legacy";
|
|
32
|
+
export type SessionTombstoneRule = "retain" | "expire";
|
|
33
|
+
/**
|
|
34
|
+
* Injected retention policy (C3). The broker schedules compaction independently of
|
|
35
|
+
* rotation; settings apply at the next scheduled compaction. `clock` drives both
|
|
36
|
+
* retention expiry and heartbeat-freshness liveness reads.
|
|
37
|
+
*/
|
|
38
|
+
export interface RetentionPolicy {
|
|
39
|
+
clock?: () => number;
|
|
40
|
+
maxAgeMs?: number;
|
|
41
|
+
maxRows?: number;
|
|
42
|
+
tombstoneRule?: SessionTombstoneRule;
|
|
43
|
+
}
|
|
21
44
|
export interface SessionIndexEvent {
|
|
22
45
|
version: typeof SDK_STATE_VERSION;
|
|
23
46
|
indexSeq: number;
|
|
@@ -26,9 +49,20 @@ export interface SessionIndexEvent {
|
|
|
26
49
|
locator: { repo: string; stateRoot: string };
|
|
27
50
|
endpointGeneration: number;
|
|
28
51
|
pid: number;
|
|
52
|
+
/**
|
|
53
|
+
* OS start incarnation of `pid`, published by the host that owns that pid. A
|
|
54
|
+
* pid is reusable, so teardown needs this binding to prove the pid is still
|
|
55
|
+
* the same process; keeping it here, in broker-owned storage, is what lets
|
|
56
|
+
* that proof outlive the session's own workspace.
|
|
57
|
+
*/
|
|
58
|
+
processIncarnation?: string;
|
|
29
59
|
endpointMtimeMs?: number;
|
|
30
60
|
lifecycleRequestId?: string;
|
|
31
61
|
terminalUncertain?: boolean;
|
|
62
|
+
/** OS process incarnation (C1); absent on legacy v1/v2 events. */
|
|
63
|
+
hostIncarnation?: string;
|
|
64
|
+
/** Present on host_heartbeat checkpoints (C2). */
|
|
65
|
+
activity?: SessionActivity;
|
|
32
66
|
ts: number;
|
|
33
67
|
checksum: string;
|
|
34
68
|
}
|
|
@@ -37,11 +71,22 @@ export interface IndexedSession {
|
|
|
37
71
|
locator: { repo: string; stateRoot: string };
|
|
38
72
|
endpointGeneration: number;
|
|
39
73
|
pid: number;
|
|
74
|
+
/** OS start incarnation of `pid` as published by its own host at registration. */
|
|
75
|
+
processIncarnation?: string;
|
|
40
76
|
endpointMtimeMs?: number;
|
|
41
77
|
live: boolean;
|
|
42
78
|
indexSeq: number;
|
|
43
79
|
lifecycleRequestId?: string;
|
|
44
80
|
terminalUncertain?: boolean;
|
|
81
|
+
hostIncarnation?: string;
|
|
82
|
+
identityProvenance: SessionIdentityProvenance;
|
|
83
|
+
activity?: SessionActivity;
|
|
84
|
+
/** Wall-clock timestamp of the latest admitted heartbeat, when one exists. */
|
|
85
|
+
lastHeartbeatAt?: number;
|
|
86
|
+
/** True when the same sessionId maps to more than one stateRoot (cross-repo duplicate). */
|
|
87
|
+
ambiguous: boolean;
|
|
88
|
+
/** True when the identity's latest event is host_unregistered or session_closed (DR-1: stopped rows are retained). */
|
|
89
|
+
terminal: boolean;
|
|
45
90
|
}
|
|
46
91
|
export interface SessionList {
|
|
47
92
|
indexSeq: number;
|
|
@@ -69,13 +114,243 @@ interface SessionIndexScan {
|
|
|
69
114
|
logContents: Buffer | undefined;
|
|
70
115
|
unsupportedError?: UnsupportedStateVersionError;
|
|
71
116
|
}
|
|
117
|
+
|
|
118
|
+
/** Admission-fence rejection codes recorded in the durable index audit (C5/C4). */
|
|
119
|
+
export type SessionIndexAuditCode = "rejected_superseded_incarnation" | "rejected_after_tombstone";
|
|
120
|
+
export interface SessionIndexAuditRecord {
|
|
121
|
+
version: typeof SDK_STATE_VERSION;
|
|
122
|
+
code: SessionIndexAuditCode;
|
|
123
|
+
/** indexSeq of the rejected event (unique per record; used for idempotent dedupe). */
|
|
124
|
+
indexSeq: number;
|
|
125
|
+
sessionId: string;
|
|
126
|
+
endpointGeneration: number;
|
|
127
|
+
stateRoot: string;
|
|
128
|
+
hostIncarnation?: string;
|
|
129
|
+
supersededByIncarnation?: string;
|
|
130
|
+
/** indexSeq of the superseding registration, or of the tombstone for post-delete rejections. */
|
|
131
|
+
supersededByIndexSeq: number;
|
|
132
|
+
ts: number;
|
|
133
|
+
}
|
|
134
|
+
|
|
72
135
|
const canonical = (event: Omit<SessionIndexEvent, "checksum">) => JSON.stringify(event);
|
|
73
136
|
export const sessionIndexChecksum = (event: Omit<SessionIndexEvent, "checksum">) =>
|
|
74
137
|
createHash("sha256").update(canonical(event)).digest("hex");
|
|
75
138
|
const dirFor = (agentDir: string) => path.join(agentDir, "sdk", "sessions");
|
|
76
139
|
const logFor = (agentDir: string) => path.join(dirFor(agentDir), "index.jsonl");
|
|
77
140
|
const snapshotFor = (agentDir: string) => path.join(dirFor(agentDir), "index.snapshot.json");
|
|
141
|
+
const auditFor = (agentDir: string) => path.join(dirFor(agentDir), "index-audit.jsonl");
|
|
78
142
|
const ROTATE_BYTES = 4 * 1024 * 1024;
|
|
143
|
+
/** Coalesced heartbeat checkpoint rate cap (C2): at most one per session per minute. */
|
|
144
|
+
export const SESSION_HEARTBEAT_INTERVAL_MS = 60_000;
|
|
145
|
+
export const DEFAULT_SESSION_RETENTION_MAX_AGE_MS = 30 * 24 * 60 * 60 * 1000;
|
|
146
|
+
export const DEFAULT_SESSION_RETENTION_MAX_ROWS = 25_000;
|
|
147
|
+
|
|
148
|
+
/** Identity tuple: (sessionId, generation, stateRoot). Registration authority is per tuple. */
|
|
149
|
+
const tupleKey = (event: SessionIndexEvent) =>
|
|
150
|
+
`${event.sessionId}\u0000${event.endpointGeneration}\u0000${event.locator.stateRoot}`;
|
|
151
|
+
/** Composite identity (C1): (sessionId, generation, process incarnation, stateRoot). */
|
|
152
|
+
const effectiveIncarnation = (event: SessionIndexEvent) => event.hostIncarnation ?? event.processIncarnation;
|
|
153
|
+
const identityKey = (event: SessionIndexEvent) => `${tupleKey(event)}\u0000${effectiveIncarnation(event) ?? ""}`;
|
|
154
|
+
|
|
155
|
+
interface ResolvedRetentionPolicy {
|
|
156
|
+
clock: () => number;
|
|
157
|
+
maxAgeMs: number;
|
|
158
|
+
maxRows: number;
|
|
159
|
+
tombstoneRule: SessionTombstoneRule;
|
|
160
|
+
}
|
|
161
|
+
const resolvePolicy = (policy: RetentionPolicy): ResolvedRetentionPolicy => ({
|
|
162
|
+
clock: policy.clock ?? Date.now,
|
|
163
|
+
maxAgeMs: policy.maxAgeMs ?? DEFAULT_SESSION_RETENTION_MAX_AGE_MS,
|
|
164
|
+
maxRows: policy.maxRows ?? DEFAULT_SESSION_RETENTION_MAX_ROWS,
|
|
165
|
+
tombstoneRule: policy.tombstoneRule ?? "retain",
|
|
166
|
+
});
|
|
167
|
+
|
|
168
|
+
interface RejectedEvent {
|
|
169
|
+
code: SessionIndexAuditCode;
|
|
170
|
+
event: SessionIndexEvent;
|
|
171
|
+
supersededByIncarnation: string | undefined;
|
|
172
|
+
supersededByIndexSeq: number;
|
|
173
|
+
}
|
|
174
|
+
interface Admission {
|
|
175
|
+
admitted: SessionIndexEvent[];
|
|
176
|
+
rejected: RejectedEvent[];
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
/**
|
|
180
|
+
* Broker admission fence (C5), applied before reduction over the total order of
|
|
181
|
+
* checksum-chained indexSeq. Rule A: per (sessionId, generation, stateRoot) tuple the
|
|
182
|
+
* latest host_registered event is the incarnation authority; every event of a different
|
|
183
|
+
* incarnation is rejected (`rejected_superseded_incarnation`). This is a whole-log
|
|
184
|
+
* function so replay and snapshot replay re-derive the same admission order
|
|
185
|
+
* (supersession survives compaction). Rule B: after an admitted `session_deleted`
|
|
186
|
+
* tombstone, non-registration events of identities anchored to pre-delete registrations
|
|
187
|
+
* are rejected (`rejected_after_tombstone`), so a deleted session cannot be resurrected
|
|
188
|
+
* by stale old-host events, while registrations after the tombstone lift it.
|
|
189
|
+
*/
|
|
190
|
+
function admitEvents(events: SessionIndexEvent[]): Admission {
|
|
191
|
+
const authoritative = new Map<string, { incarnation: string | undefined; indexSeq: number }>();
|
|
192
|
+
for (const event of events) {
|
|
193
|
+
if (event.type !== "host_registered") continue;
|
|
194
|
+
const key = tupleKey(event);
|
|
195
|
+
const current = authoritative.get(key);
|
|
196
|
+
if (current === undefined || event.indexSeq > current.indexSeq) {
|
|
197
|
+
authoritative.set(key, { incarnation: event.hostIncarnation, indexSeq: event.indexSeq });
|
|
198
|
+
}
|
|
199
|
+
}
|
|
200
|
+
const admitted: SessionIndexEvent[] = [];
|
|
201
|
+
const rejected: RejectedEvent[] = [];
|
|
202
|
+
for (const event of events) {
|
|
203
|
+
const authority = authoritative.get(tupleKey(event));
|
|
204
|
+
if (
|
|
205
|
+
authority !== undefined &&
|
|
206
|
+
authority.incarnation !== undefined &&
|
|
207
|
+
event.hostIncarnation !== undefined &&
|
|
208
|
+
event.hostIncarnation !== authority.incarnation
|
|
209
|
+
) {
|
|
210
|
+
rejected.push({
|
|
211
|
+
code: "rejected_superseded_incarnation",
|
|
212
|
+
event,
|
|
213
|
+
supersededByIncarnation: authority.incarnation,
|
|
214
|
+
supersededByIndexSeq: authority.indexSeq,
|
|
215
|
+
});
|
|
216
|
+
continue;
|
|
217
|
+
}
|
|
218
|
+
admitted.push(event);
|
|
219
|
+
}
|
|
220
|
+
const tombstoneSeq = new Map<string, number>();
|
|
221
|
+
for (const event of admitted) {
|
|
222
|
+
if (event.type !== "session_deleted") continue;
|
|
223
|
+
const previous = tombstoneSeq.get(event.sessionId);
|
|
224
|
+
if (previous === undefined || event.indexSeq > previous) tombstoneSeq.set(event.sessionId, event.indexSeq);
|
|
225
|
+
}
|
|
226
|
+
if (tombstoneSeq.size === 0) return { admitted, rejected };
|
|
227
|
+
const anchorSeqByIdentity = new Map<string, number>();
|
|
228
|
+
const postTombstone: SessionIndexEvent[] = [];
|
|
229
|
+
for (const event of admitted) {
|
|
230
|
+
const key = identityKey(event);
|
|
231
|
+
const tombstone = tombstoneSeq.get(event.sessionId);
|
|
232
|
+
if (event.type === "host_registered") anchorSeqByIdentity.set(key, event.indexSeq);
|
|
233
|
+
if (tombstone === undefined || event.indexSeq <= tombstone) {
|
|
234
|
+
postTombstone.push(event);
|
|
235
|
+
continue;
|
|
236
|
+
}
|
|
237
|
+
if (event.type === "host_registered") {
|
|
238
|
+
postTombstone.push(event);
|
|
239
|
+
continue;
|
|
240
|
+
}
|
|
241
|
+
const anchor = anchorSeqByIdentity.get(key);
|
|
242
|
+
if (anchor === undefined || anchor <= tombstone) {
|
|
243
|
+
rejected.push({
|
|
244
|
+
code: "rejected_after_tombstone",
|
|
245
|
+
event,
|
|
246
|
+
supersededByIncarnation: undefined,
|
|
247
|
+
supersededByIndexSeq: tombstone,
|
|
248
|
+
});
|
|
249
|
+
continue;
|
|
250
|
+
}
|
|
251
|
+
postTombstone.push(event);
|
|
252
|
+
}
|
|
253
|
+
return { admitted: postTombstone, rejected };
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
/** Pure rejection ledger derived from the event stream (C5 audit, idempotent by indexSeq). */
|
|
257
|
+
function auditRecords(events: SessionIndexEvent[], ts: number): SessionIndexAuditRecord[] {
|
|
258
|
+
const { rejected } = admitEvents(events);
|
|
259
|
+
return rejected.map(rejection => ({
|
|
260
|
+
version: SDK_STATE_VERSION,
|
|
261
|
+
code: rejection.code,
|
|
262
|
+
indexSeq: rejection.event.indexSeq,
|
|
263
|
+
sessionId: rejection.event.sessionId,
|
|
264
|
+
endpointGeneration: rejection.event.endpointGeneration,
|
|
265
|
+
stateRoot: rejection.event.locator.stateRoot,
|
|
266
|
+
...(rejection.event.hostIncarnation !== undefined ? { hostIncarnation: rejection.event.hostIncarnation } : {}),
|
|
267
|
+
...(rejection.supersededByIncarnation !== undefined
|
|
268
|
+
? { supersededByIncarnation: rejection.supersededByIncarnation }
|
|
269
|
+
: {}),
|
|
270
|
+
supersededByIndexSeq: rejection.supersededByIndexSeq,
|
|
271
|
+
ts,
|
|
272
|
+
}));
|
|
273
|
+
}
|
|
274
|
+
|
|
275
|
+
/**
|
|
276
|
+
* Total-order projection (C5/C6): operate only on admitted events keyed by composite
|
|
277
|
+
* identity; the cross-identity winner per sessionId is the admitted event with the
|
|
278
|
+
* highest (generation, indexSeq); only `session_deleted` hides a row (DR-1 retains
|
|
279
|
+
* stopped/terminal credential-free rows so inspect/offline tail can work). Heartbeats
|
|
280
|
+
* inherit locator/endpoint metadata from their identity's prior event. Liveness (C2)
|
|
281
|
+
* requires a heartbeat observed within 2x the checkpoint interval AND a live host:
|
|
282
|
+
* a missing heartbeat (e.g. after a broker restart) is unknown, never fresh forever.
|
|
283
|
+
*/
|
|
284
|
+
function reduceEvents(events: SessionIndexEvent[], now: number): IndexedSession[] {
|
|
285
|
+
const { admitted } = admitEvents(events);
|
|
286
|
+
const latestByIdentity = new Map<string, SessionIndexEvent>();
|
|
287
|
+
const latestHeartbeatByIdentity = new Map<string, SessionIndexEvent>();
|
|
288
|
+
for (const event of admitted) {
|
|
289
|
+
const key = identityKey(event);
|
|
290
|
+
if (event.type === "host_heartbeat") {
|
|
291
|
+
latestHeartbeatByIdentity.set(key, event);
|
|
292
|
+
continue;
|
|
293
|
+
}
|
|
294
|
+
const previous = latestByIdentity.get(key);
|
|
295
|
+
if (previous === undefined || event.indexSeq > previous.indexSeq) latestByIdentity.set(key, event);
|
|
296
|
+
}
|
|
297
|
+
const winner = new Map<string, { gen: number; seq: number; identity: string }>();
|
|
298
|
+
const roots = new Map<string, Set<string>>();
|
|
299
|
+
for (const event of admitted) {
|
|
300
|
+
let group = roots.get(event.sessionId);
|
|
301
|
+
if (group === undefined) {
|
|
302
|
+
group = new Set();
|
|
303
|
+
roots.set(event.sessionId, group);
|
|
304
|
+
}
|
|
305
|
+
group.add(event.locator.stateRoot);
|
|
306
|
+
if (event.type === "host_heartbeat") continue;
|
|
307
|
+
const current = winner.get(event.sessionId);
|
|
308
|
+
if (
|
|
309
|
+
current === undefined ||
|
|
310
|
+
event.endpointGeneration > current.gen ||
|
|
311
|
+
(event.endpointGeneration === current.gen && event.indexSeq > current.seq)
|
|
312
|
+
) {
|
|
313
|
+
winner.set(event.sessionId, {
|
|
314
|
+
gen: event.endpointGeneration,
|
|
315
|
+
seq: event.indexSeq,
|
|
316
|
+
identity: identityKey(event),
|
|
317
|
+
});
|
|
318
|
+
}
|
|
319
|
+
}
|
|
320
|
+
const sessions: IndexedSession[] = [];
|
|
321
|
+
for (const [sessionId, chosen] of winner) {
|
|
322
|
+
const latest = latestByIdentity.get(chosen.identity);
|
|
323
|
+
if (latest === undefined) continue;
|
|
324
|
+
const terminal = latest.type === "host_unregistered" || latest.type === "session_closed";
|
|
325
|
+
if (latest.type === "session_deleted") continue;
|
|
326
|
+
const heartbeat = latestHeartbeatByIdentity.get(chosen.identity);
|
|
327
|
+
const pidAlive = alive(latest.pid);
|
|
328
|
+
const heartbeatFresh = heartbeat !== undefined && now - heartbeat.ts < 2 * SESSION_HEARTBEAT_INTERVAL_MS;
|
|
329
|
+
const recordedIncarnation = effectiveIncarnation(latest);
|
|
330
|
+
const currentIncarnation = recordedIncarnation === undefined ? undefined : processIncarnation(latest.pid);
|
|
331
|
+
const incarnationMatches = currentIncarnation !== undefined && currentIncarnation === recordedIncarnation;
|
|
332
|
+
sessions.push({
|
|
333
|
+
sessionId,
|
|
334
|
+
locator: latest.locator,
|
|
335
|
+
endpointGeneration: latest.endpointGeneration,
|
|
336
|
+
pid: latest.pid,
|
|
337
|
+
processIncarnation: latest.processIncarnation,
|
|
338
|
+
endpointMtimeMs: latest.endpointMtimeMs,
|
|
339
|
+
lifecycleRequestId: latest.lifecycleRequestId,
|
|
340
|
+
terminalUncertain: latest.type === "lifecycle_terminal" || latest.terminalUncertain === true,
|
|
341
|
+
indexSeq: latest.indexSeq,
|
|
342
|
+
hostIncarnation: latest.hostIncarnation,
|
|
343
|
+
identityProvenance: recordedIncarnation === undefined ? "legacy" : "composite",
|
|
344
|
+
activity: heartbeat?.activity,
|
|
345
|
+
lastHeartbeatAt: heartbeat?.ts,
|
|
346
|
+
ambiguous: (roots.get(sessionId)?.size ?? 0) > 1,
|
|
347
|
+
terminal,
|
|
348
|
+
live: !terminal && pidAlive && heartbeatFresh && incarnationMatches,
|
|
349
|
+
});
|
|
350
|
+
}
|
|
351
|
+
return sessions;
|
|
352
|
+
}
|
|
353
|
+
|
|
79
354
|
function isValidSnapshot(snapshot: unknown): snapshot is { indexSeq: number; events: SessionIndexEvent[] } {
|
|
80
355
|
if (!snapshot || typeof snapshot !== "object") return false;
|
|
81
356
|
const { indexSeq, events } = snapshot as { indexSeq?: unknown; events?: unknown };
|
|
@@ -96,36 +371,74 @@ function isValidSnapshot(snapshot: unknown): snapshot is { indexSeq: number; eve
|
|
|
96
371
|
return previous === indexSeq;
|
|
97
372
|
}
|
|
98
373
|
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
374
|
+
/**
|
|
375
|
+
* Compact the event history for a snapshot without renumbering: clients hold indexSeq
|
|
376
|
+
* across calls, so retained events keep their original indexSeq and checksum. Stopped
|
|
377
|
+
* and terminal identities are retained (DR-1: only `session_deleted` hides a row), so
|
|
378
|
+
* inspect/offline tail keep working across compaction; superseded heartbeats collapse
|
|
379
|
+
* to the latest per surviving composite identity, the global-max indexSeq always stays
|
|
380
|
+
* as the chain anchor, then the injected retention policy applies per session
|
|
381
|
+
* (whole-session eviction keeps the projection deterministic: a dropped session
|
|
382
|
+
* contributes no events to re-derive). Tombstone rule "retain" exempts deleted sessions
|
|
383
|
+
* from age/row eviction (C4 audit evidence retained); "expire" evicts them like any
|
|
384
|
+
* other session.
|
|
385
|
+
*/
|
|
386
|
+
function compactEvents(events: SessionIndexEvent[], policy: ResolvedRetentionPolicy): SessionIndexEvent[] {
|
|
104
387
|
if (events.length === 0) return events;
|
|
105
388
|
const maxIndexSeq = events[events.length - 1]!.indexSeq;
|
|
106
|
-
const
|
|
107
|
-
|
|
108
|
-
const deadTerminal = new Set<string>();
|
|
109
|
-
for (const [sessionId, latest] of latestBySession) {
|
|
110
|
-
const terminal = latest.type === "host_unregistered" || latest.type === "session_closed";
|
|
111
|
-
if (terminal && !alive(latest.pid)) deadTerminal.add(sessionId);
|
|
112
|
-
}
|
|
113
|
-
const latestHeartbeatSeq = new Map<string, number>();
|
|
389
|
+
const latestByIdentity = new Map<string, SessionIndexEvent>();
|
|
390
|
+
const latestHeartbeatByIdentity = new Map<string, number>();
|
|
114
391
|
for (const event of events) {
|
|
115
|
-
|
|
116
|
-
|
|
392
|
+
const key = identityKey(event);
|
|
393
|
+
const previous = latestByIdentity.get(key);
|
|
394
|
+
if (previous === undefined || event.indexSeq > previous.indexSeq) latestByIdentity.set(key, event);
|
|
395
|
+
if (event.type === "host_heartbeat") {
|
|
396
|
+
const current = latestHeartbeatByIdentity.get(key);
|
|
397
|
+
if (current === undefined || event.indexSeq > current) latestHeartbeatByIdentity.set(key, event.indexSeq);
|
|
117
398
|
}
|
|
118
399
|
}
|
|
400
|
+
const now = policy.clock();
|
|
401
|
+
const sessionLatest = new Map<string, SessionIndexEvent>();
|
|
402
|
+
for (const event of events) {
|
|
403
|
+
const previous = sessionLatest.get(event.sessionId);
|
|
404
|
+
if (previous === undefined || event.indexSeq > previous.indexSeq) sessionLatest.set(event.sessionId, event);
|
|
405
|
+
}
|
|
406
|
+
const expiredSessions = new Set<string>();
|
|
407
|
+
for (const [sessionId, latest] of sessionLatest) {
|
|
408
|
+
if (latest.indexSeq === maxIndexSeq) continue;
|
|
409
|
+
const deleted = latest.type === "session_deleted";
|
|
410
|
+
if (policy.tombstoneRule === "retain" && deleted) continue;
|
|
411
|
+
if (latest.ts < now - policy.maxAgeMs) expiredSessions.add(sessionId);
|
|
412
|
+
}
|
|
119
413
|
const kept: SessionIndexEvent[] = [];
|
|
120
414
|
for (const event of events) {
|
|
121
415
|
if (event.indexSeq === maxIndexSeq) {
|
|
122
416
|
kept.push(event);
|
|
123
417
|
continue;
|
|
124
418
|
}
|
|
125
|
-
if (
|
|
126
|
-
|
|
419
|
+
if (event.type === "host_heartbeat" && latestHeartbeatByIdentity.get(identityKey(event)) !== event.indexSeq)
|
|
420
|
+
continue;
|
|
421
|
+
if (expiredSessions.has(event.sessionId)) continue;
|
|
127
422
|
kept.push(event);
|
|
128
423
|
}
|
|
424
|
+
if (policy.maxRows >= 1 && kept.length > policy.maxRows) {
|
|
425
|
+
const keptLatest = new Map<string, SessionIndexEvent>();
|
|
426
|
+
for (const event of kept) {
|
|
427
|
+
const previous = keptLatest.get(event.sessionId);
|
|
428
|
+
if (previous === undefined || event.indexSeq > previous.indexSeq) keptLatest.set(event.sessionId, event);
|
|
429
|
+
}
|
|
430
|
+
const anchorSession = kept.find(event => event.indexSeq === maxIndexSeq)?.sessionId;
|
|
431
|
+
const candidates = [...keptLatest.entries()]
|
|
432
|
+
.filter(([sessionId]) => sessionId !== anchorSession)
|
|
433
|
+
.filter(([, latest]) => !(policy.tombstoneRule === "retain" && latest.type === "session_deleted"))
|
|
434
|
+
.sort((a, b) => a[1].indexSeq - b[1].indexSeq);
|
|
435
|
+
let result = kept;
|
|
436
|
+
for (const [sessionId] of candidates) {
|
|
437
|
+
if (result.length <= policy.maxRows) break;
|
|
438
|
+
result = result.filter(event => event.sessionId !== sessionId);
|
|
439
|
+
}
|
|
440
|
+
return result;
|
|
441
|
+
}
|
|
129
442
|
return kept;
|
|
130
443
|
}
|
|
131
444
|
|
|
@@ -211,12 +524,16 @@ export class SessionIndex {
|
|
|
211
524
|
static #operations = new Map<string, Promise<void>>();
|
|
212
525
|
static #openGroups = new Map<string, SessionIndexOpenGroup>();
|
|
213
526
|
#agentDir: string;
|
|
527
|
+
#policy: ResolvedRetentionPolicy;
|
|
214
528
|
#events: SessionIndexEvent[] = [];
|
|
215
529
|
#warnings: string[] = [];
|
|
216
530
|
#logOffset = 0;
|
|
217
531
|
#corruptSuffix = false;
|
|
218
|
-
|
|
532
|
+
/** indexSeqs already recorded in the durable audit; null until lazily seeded. */
|
|
533
|
+
#auditedSeq: Set<number> | null = null;
|
|
534
|
+
constructor(agentDir: string, policy: RetentionPolicy = {}) {
|
|
219
535
|
this.#agentDir = agentDir;
|
|
536
|
+
this.#policy = resolvePolicy(policy);
|
|
220
537
|
}
|
|
221
538
|
static #enqueue<T>(indexPath: string, operation: () => Promise<T>): Promise<T> {
|
|
222
539
|
const previous = SessionIndex.#operations.get(indexPath) ?? Promise.resolve();
|
|
@@ -265,6 +582,33 @@ export class SessionIndex {
|
|
|
265
582
|
this.#corruptSuffix = scan.diagnosis.status === "corrupt";
|
|
266
583
|
if (scan.diagnosis.reason === "invalid snapshot") this.#warnings.push("Invalid session index snapshot");
|
|
267
584
|
if (this.#corruptSuffix) this.#warnings.push("Corrupt session index entry; replay truncated");
|
|
585
|
+
await this.#writeAuditUnderLock();
|
|
586
|
+
}
|
|
587
|
+
/** Seed the audit dedupe set once, then append records for newly-rejected events. */
|
|
588
|
+
async #writeAuditUnderLock(): Promise<void> {
|
|
589
|
+
if (this.#auditedSeq === null) {
|
|
590
|
+
this.#auditedSeq = new Set();
|
|
591
|
+
try {
|
|
592
|
+
const contents = await fs.readFile(auditFor(this.#agentDir), "utf8");
|
|
593
|
+
for (const line of contents.split("\n")) {
|
|
594
|
+
if (!line) continue;
|
|
595
|
+
try {
|
|
596
|
+
const record = JSON.parse(line) as Partial<SessionIndexAuditRecord>;
|
|
597
|
+
if (typeof record.indexSeq === "number") this.#auditedSeq.add(record.indexSeq);
|
|
598
|
+
} catch {
|
|
599
|
+
// Best-effort dedupe seed; a corrupt audit row never blocks the index.
|
|
600
|
+
}
|
|
601
|
+
}
|
|
602
|
+
} catch (error) {
|
|
603
|
+
if ((error as NodeJS.ErrnoException).code !== "ENOENT") throw error;
|
|
604
|
+
}
|
|
605
|
+
}
|
|
606
|
+
const pending = auditRecords(this.#events, this.#policy.clock()).filter(
|
|
607
|
+
record => !this.#auditedSeq!.has(record.indexSeq),
|
|
608
|
+
);
|
|
609
|
+
if (pending.length === 0) return;
|
|
610
|
+
await appendSync(auditFor(this.#agentDir), pending.map(record => JSON.stringify(record)).join("\n"));
|
|
611
|
+
for (const record of pending) this.#auditedSeq.add(record.indexSeq);
|
|
268
612
|
}
|
|
269
613
|
async #scan(): Promise<SessionIndexScan> {
|
|
270
614
|
let snapshotContents: Buffer | undefined;
|
|
@@ -513,6 +857,7 @@ export class SessionIndex {
|
|
|
513
857
|
}
|
|
514
858
|
async #refreshUnderLock(): Promise<void> {
|
|
515
859
|
await this.#tailUnderLock();
|
|
860
|
+
await this.#writeAuditUnderLock();
|
|
516
861
|
}
|
|
517
862
|
get indexSeq(): number {
|
|
518
863
|
return this.#events.at(-1)?.indexSeq ?? 0;
|
|
@@ -536,6 +881,11 @@ export class SessionIndex {
|
|
|
536
881
|
indexSeq: this.indexSeq + 1,
|
|
537
882
|
ts: input.ts ?? Date.now(),
|
|
538
883
|
};
|
|
884
|
+
// Use the registration's durable process identity when supplied; otherwise
|
|
885
|
+
// derive one from the OS. Legacy records remain explicitly unbound.
|
|
886
|
+
if (unsigned.hostIncarnation === undefined && Number.isSafeInteger(unsigned.pid) && unsigned.pid > 0) {
|
|
887
|
+
unsigned.hostIncarnation = unsigned.processIncarnation ?? processIncarnation(unsigned.pid);
|
|
888
|
+
}
|
|
539
889
|
const event: SessionIndexEvent = { ...unsigned, checksum: sessionIndexChecksum(unsigned) };
|
|
540
890
|
await appendSync(logFor(this.#agentDir), JSON.stringify(event));
|
|
541
891
|
await this.#refreshUnderLock();
|
|
@@ -544,6 +894,24 @@ export class SessionIndex {
|
|
|
544
894
|
});
|
|
545
895
|
});
|
|
546
896
|
}
|
|
897
|
+
|
|
898
|
+
/** Hold the canonical index lock across an authority-sensitive operation. */
|
|
899
|
+
async withLocked<T>(callback: () => Promise<T>): Promise<T> {
|
|
900
|
+
const indexPath = path.resolve(logFor(this.#agentDir));
|
|
901
|
+
return await SessionIndex.#enqueue(indexPath, async () => {
|
|
902
|
+
try {
|
|
903
|
+
await fs.stat(dirFor(this.#agentDir));
|
|
904
|
+
} catch (error) {
|
|
905
|
+
if ((error as NodeJS.ErrnoException).code === "ENOENT") return await callback();
|
|
906
|
+
throw error;
|
|
907
|
+
}
|
|
908
|
+
return await withFileLock(logFor(this.#agentDir), async () => {
|
|
909
|
+
await this.#replayUnderLock();
|
|
910
|
+
if (this.#corruptSuffix) throw new Error("Cannot use corrupt session index for artifact reclamation");
|
|
911
|
+
return await callback();
|
|
912
|
+
});
|
|
913
|
+
});
|
|
914
|
+
}
|
|
547
915
|
async snapshot(): Promise<void> {
|
|
548
916
|
const indexPath = path.resolve(logFor(this.#agentDir));
|
|
549
917
|
await SessionIndex.#enqueue(indexPath, () =>
|
|
@@ -566,7 +934,7 @@ export class SessionIndex {
|
|
|
566
934
|
JSON.stringify({
|
|
567
935
|
version: SESSION_INDEX_SNAPSHOT_VERSION,
|
|
568
936
|
indexSeq: this.indexSeq,
|
|
569
|
-
events: compactEvents(this.#events),
|
|
937
|
+
events: compactEvents(this.#events, this.#policy),
|
|
570
938
|
}),
|
|
571
939
|
{
|
|
572
940
|
mode: 0o600,
|
|
@@ -581,6 +949,19 @@ export class SessionIndex {
|
|
|
581
949
|
await fs.rename(tmp, file);
|
|
582
950
|
await syncDirectory(file);
|
|
583
951
|
}
|
|
952
|
+
/**
|
|
953
|
+
* Broker-scheduled compaction (C3), independent of rotation size: applies the
|
|
954
|
+
* injected retention policy to a fresh snapshot and truncates the log.
|
|
955
|
+
*/
|
|
956
|
+
async compact(): Promise<void> {
|
|
957
|
+
const indexPath = path.resolve(logFor(this.#agentDir));
|
|
958
|
+
await SessionIndex.#enqueue(indexPath, () =>
|
|
959
|
+
withFileLock(logFor(this.#agentDir), async () => {
|
|
960
|
+
await this.#replayUnderLock();
|
|
961
|
+
await this.#rotate();
|
|
962
|
+
}),
|
|
963
|
+
);
|
|
964
|
+
}
|
|
584
965
|
async #rotate(): Promise<void> {
|
|
585
966
|
await this.#snapshotUnderLock();
|
|
586
967
|
const file = logFor(this.#agentDir);
|
|
@@ -592,35 +973,64 @@ export class SessionIndex {
|
|
|
592
973
|
}
|
|
593
974
|
|
|
594
975
|
listSessions(): SessionList {
|
|
595
|
-
|
|
596
|
-
|
|
597
|
-
|
|
598
|
-
|
|
599
|
-
|
|
600
|
-
|
|
601
|
-
|
|
602
|
-
|
|
603
|
-
|
|
604
|
-
|
|
605
|
-
|
|
606
|
-
|
|
607
|
-
|
|
608
|
-
|
|
609
|
-
|
|
610
|
-
|
|
611
|
-
|
|
612
|
-
|
|
613
|
-
|
|
614
|
-
|
|
615
|
-
|
|
616
|
-
|
|
617
|
-
|
|
618
|
-
|
|
619
|
-
|
|
620
|
-
|
|
621
|
-
|
|
622
|
-
|
|
623
|
-
|
|
976
|
+
return {
|
|
977
|
+
indexSeq: this.indexSeq,
|
|
978
|
+
sessions: reduceEvents(this.#events, this.#policy.clock()),
|
|
979
|
+
warnings: this.#warnings,
|
|
980
|
+
};
|
|
981
|
+
}
|
|
982
|
+
|
|
983
|
+
/**
|
|
984
|
+
* Production coalesced heartbeat checkpoint pass (C2): appends one
|
|
985
|
+
* `host_heartbeat` per session at most once per {@link SESSION_HEARTBEAT_INTERVAL_MS}.
|
|
986
|
+
* The pass observes liveness the same way the projection does — the host process
|
|
987
|
+
* must be alive and, for composite identities, still carry the recorded OS process
|
|
988
|
+
* incarnation (a reused PID is never checkpointed). Stopped/terminal rows and rows
|
|
989
|
+
* whose heartbeat is still fresh are skipped. After a broker restart, sessions whose
|
|
990
|
+
* host survived are re-observed as live on the first pass; sessions whose host died
|
|
991
|
+
* while the broker was down keep their stale or missing heartbeat and read as
|
|
992
|
+
* unknown/not-live (never fresh forever). Returns the number of checkpoints written.
|
|
993
|
+
*/
|
|
994
|
+
async checkpointLiveHeartbeats(now = Date.now()): Promise<number> {
|
|
995
|
+
const indexPath = path.resolve(logFor(this.#agentDir));
|
|
996
|
+
return await SessionIndex.#enqueue(indexPath, async () => {
|
|
997
|
+
await fs.mkdir(dirFor(this.#agentDir), { recursive: true, mode: 0o700 });
|
|
998
|
+
return await withFileLock(logFor(this.#agentDir), async () => {
|
|
999
|
+
await this.#replayUnderLock();
|
|
1000
|
+
if (this.#corruptSuffix) return 0;
|
|
1001
|
+
const events: SessionIndexEvent[] = [];
|
|
1002
|
+
const rows = reduceEvents(this.#events, now);
|
|
1003
|
+
for (const row of rows) {
|
|
1004
|
+
if (row.terminal) continue;
|
|
1005
|
+
if (row.lastHeartbeatAt !== undefined && now - row.lastHeartbeatAt < SESSION_HEARTBEAT_INTERVAL_MS)
|
|
1006
|
+
continue;
|
|
1007
|
+
if (!alive(row.pid)) continue;
|
|
1008
|
+
const recordedIncarnation = row.hostIncarnation ?? row.processIncarnation;
|
|
1009
|
+
if (recordedIncarnation === undefined) continue;
|
|
1010
|
+
const current = processIncarnation(row.pid);
|
|
1011
|
+
if (current === undefined || current !== recordedIncarnation) continue;
|
|
1012
|
+
const unsigned: Omit<SessionIndexEvent, "checksum"> = {
|
|
1013
|
+
version: SDK_STATE_VERSION,
|
|
1014
|
+
indexSeq: this.indexSeq + events.length + 1,
|
|
1015
|
+
type: "host_heartbeat",
|
|
1016
|
+
sessionId: row.sessionId,
|
|
1017
|
+
locator: row.locator,
|
|
1018
|
+
endpointGeneration: row.endpointGeneration,
|
|
1019
|
+
pid: row.pid,
|
|
1020
|
+
...(row.processIncarnation === undefined ? {} : { processIncarnation: row.processIncarnation }),
|
|
1021
|
+
...(row.hostIncarnation === undefined ? {} : { hostIncarnation: row.hostIncarnation }),
|
|
1022
|
+
activity: { state: "active", at: now },
|
|
1023
|
+
ts: now,
|
|
1024
|
+
};
|
|
1025
|
+
events.push({ ...unsigned, checksum: sessionIndexChecksum(unsigned) });
|
|
1026
|
+
}
|
|
1027
|
+
if (events.length === 0) return 0;
|
|
1028
|
+
for (const event of events) await appendSync(logFor(this.#agentDir), JSON.stringify(event));
|
|
1029
|
+
await this.#refreshUnderLock();
|
|
1030
|
+
if ((await fs.stat(logFor(this.#agentDir))).size >= ROTATE_BYTES) await this.#rotate();
|
|
1031
|
+
return events.length;
|
|
1032
|
+
});
|
|
1033
|
+
});
|
|
624
1034
|
}
|
|
625
1035
|
|
|
626
1036
|
hostUnregisteredAfter(
|
|
@@ -667,11 +1077,16 @@ export class SessionIndex {
|
|
|
667
1077
|
locator: event.locator,
|
|
668
1078
|
endpointGeneration: event.endpointGeneration,
|
|
669
1079
|
pid: event.pid,
|
|
1080
|
+
processIncarnation: event.processIncarnation,
|
|
670
1081
|
endpointMtimeMs: event.endpointMtimeMs,
|
|
671
1082
|
lifecycleRequestId: event.lifecycleRequestId,
|
|
672
1083
|
terminalUncertain: false,
|
|
673
1084
|
indexSeq: event.indexSeq,
|
|
1085
|
+
hostIncarnation: event.hostIncarnation,
|
|
1086
|
+
identityProvenance: event.hostIncarnation === undefined ? "legacy" : "composite",
|
|
1087
|
+
ambiguous: false,
|
|
674
1088
|
live: alive(event.pid),
|
|
1089
|
+
terminal: false,
|
|
675
1090
|
}
|
|
676
1091
|
: undefined;
|
|
677
1092
|
}
|