pi-sessions 0.14.3 → 0.16.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/README.md +63 -9
- package/extensions/hosts/contract.ts +51 -0
- package/extensions/hosts/registry.ts +129 -0
- package/extensions/pi-sessions.ts +36 -10
- package/extensions/session-handoff/install.ts +4 -1
- package/extensions/session-handoff/launch/backend.ts +3 -10
- package/extensions/session-handoff/launch-options.ts +17 -2
- package/extensions/session-handoff/launch-target.ts +28 -16
- package/extensions/session-handoff/metadata.ts +2 -0
- package/extensions/session-handoff/query.ts +5 -4
- package/extensions/session-handoff/spawn.ts +2 -1
- package/extensions/session-handoff/tool-schema.ts +10 -2
- package/extensions/session-handoff/tool.ts +1 -0
- package/extensions/session-hooks/install.ts +14 -5
- package/extensions/session-index/install.ts +25 -4
- package/extensions/session-index/panel.ts +9 -0
- package/extensions/session-messaging/message-router.ts +91 -0
- package/extensions/session-messaging/pi/reachable-contract.ts +2 -1
- package/extensions/session-messaging/pi/reachable-tool.ts +24 -4
- package/extensions/session-messaging/pi/tools.ts +9 -1
- package/extensions/shared/session-index/access.ts +42 -5
- package/extensions/shared/session-index/common.ts +1 -0
- package/extensions/shared/session-index/recovery.ts +270 -0
- package/extensions/shared/session-index/schema.ts +9 -1
- package/extensions/shared/session-index/sqlite.ts +20 -10
- package/extensions/{session-search/hooks.ts → shared/session-index/sync.ts} +40 -16
- package/extensions/subagents/install.ts +13 -13
- package/extensions/subagents/settle.ts +1 -1
- package/extensions/subagents/wake.ts +32 -116
- package/package.json +5 -5
- package/extensions/session-search/reindex.ts +0 -97
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import type { Api, Model } from "@earendil-works/pi-ai";
|
|
2
2
|
import { Type } from "typebox";
|
|
3
|
-
import { type HandoffLaunchTarget, SUBAGENT_LAUNCH } from "./launch-target.ts";
|
|
3
|
+
import { type HandoffLaunchTarget, LAUNCH_DIRECTIONS, SUBAGENT_LAUNCH } from "./launch-target.ts";
|
|
4
4
|
import { formatRosterEntry, type HandoffRoster } from "./roster.ts";
|
|
5
5
|
|
|
6
6
|
export function buildHandoffLaunchSchema(targets: readonly HandoffLaunchTarget[]) {
|
|
@@ -46,11 +46,19 @@ export function buildHandoffPromptGuidelines(
|
|
|
46
46
|
roster: HandoffRoster | undefined,
|
|
47
47
|
): string[] {
|
|
48
48
|
const hasSubagent = targets.some((target) => target.value === SUBAGENT_LAUNCH);
|
|
49
|
+
const hasHost = targets.some(
|
|
50
|
+
(target) =>
|
|
51
|
+
target.value !== "subagent" &&
|
|
52
|
+
target.value !== "deferred" &&
|
|
53
|
+
!LAUNCH_DIRECTIONS.some((direction) => direction === target.value),
|
|
54
|
+
);
|
|
49
55
|
const selectable =
|
|
50
56
|
roster?.map(formatRosterEntry) ?? models.map((model) => `${model.provider}/${model.id}`);
|
|
51
57
|
return [
|
|
52
58
|
...(hasSubagent ? SUBAGENT_GUIDELINES : []),
|
|
53
|
-
|
|
59
|
+
hasHost
|
|
60
|
+
? "Use session_handoff host or deferred launches only when the user requests one."
|
|
61
|
+
: LAUNCH_TARGET_GUIDELINE,
|
|
54
62
|
...MODEL_INHERITANCE_GUIDELINES,
|
|
55
63
|
hasSubagent ? SUBAGENT_MODEL_OVERRIDE_GUIDELINE : DEFAULT_MODEL_OVERRIDE_GUIDELINE,
|
|
56
64
|
...(selectable.length > 0
|
|
@@ -108,6 +108,7 @@ export async function executeSessionHandoffTool(
|
|
|
108
108
|
title,
|
|
109
109
|
model,
|
|
110
110
|
approveProjectTrust: target.approveProjectTrust,
|
|
111
|
+
useDefaultSessionDir: target.useDefaultSessionDir ?? false,
|
|
111
112
|
buildBootstrap: (sessionId) =>
|
|
112
113
|
createChildGeneratedHandoffBootstrap({
|
|
113
114
|
sessionId,
|
|
@@ -3,28 +3,37 @@ import {
|
|
|
3
3
|
findPendingHandoffBootstrap,
|
|
4
4
|
getHandoffMetadataFromEntries,
|
|
5
5
|
} from "../session-handoff/metadata.ts";
|
|
6
|
-
import { createSessionHookController } from "../session-search/hooks.ts";
|
|
7
6
|
import type { IndexHandle, SessionLifecycle } from "../shared/composition.ts";
|
|
7
|
+
import { requestIndexRecovery } from "../shared/session-index/recovery.ts";
|
|
8
|
+
import { createSessionHookController } from "../shared/session-index/sync.ts";
|
|
8
9
|
|
|
9
|
-
export function installHooks(
|
|
10
|
+
export function installHooks(
|
|
11
|
+
pi: ExtensionAPI,
|
|
12
|
+
deps: { index: IndexHandle; turnSync: boolean },
|
|
13
|
+
): SessionLifecycle {
|
|
10
14
|
const controller = createSessionHookController({ indexPath: deps.index.path });
|
|
11
15
|
|
|
12
16
|
pi.on("turn_end", async (_event, ctx) => {
|
|
13
|
-
await controller.handleTurnEnd(ctx.sessionManager.getSessionFile());
|
|
17
|
+
if (deps.turnSync) await controller.handleTurnEnd(ctx.sessionManager.getSessionFile());
|
|
14
18
|
});
|
|
15
19
|
|
|
16
20
|
pi.on("session_tree", async (_event, ctx) => {
|
|
17
|
-
await controller.handleSessionTree(ctx.sessionManager.getSessionFile());
|
|
21
|
+
if (deps.turnSync) await controller.handleSessionTree(ctx.sessionManager.getSessionFile());
|
|
18
22
|
});
|
|
19
23
|
|
|
20
24
|
pi.on("session_compact", async (_event, ctx) => {
|
|
21
|
-
await controller.handleSessionCompact(ctx.sessionManager.getSessionFile());
|
|
25
|
+
if (deps.turnSync) await controller.handleSessionCompact(ctx.sessionManager.getSessionFile());
|
|
22
26
|
});
|
|
23
27
|
|
|
24
28
|
return {
|
|
25
29
|
async onSessionStart(event, ctx) {
|
|
26
30
|
const { reason, previousSessionFile } = event;
|
|
27
31
|
const sessionFile = ctx.sessionManager.getSessionFile();
|
|
32
|
+
void requestIndexRecovery(deps.index.path, true)
|
|
33
|
+
.then(() => controller.handleSessionStart(ctx.sessionManager.getSessionFile()))
|
|
34
|
+
.catch((error: unknown) =>
|
|
35
|
+
ctx.ui.notify(`Session index sync failed: ${String(error)}`, "error"),
|
|
36
|
+
);
|
|
28
37
|
|
|
29
38
|
switch (reason) {
|
|
30
39
|
case "new":
|
|
@@ -1,15 +1,24 @@
|
|
|
1
1
|
import type { ExtensionAPI, ExtensionCommandContext } from "@earendil-works/pi-coding-agent";
|
|
2
|
-
import {
|
|
3
|
-
import type { IndexHandle } from "../shared/composition.ts";
|
|
2
|
+
import type { IndexHandle, SessionLifecycle } from "../shared/composition.ts";
|
|
4
3
|
import { isTuiMode } from "../shared/pi-mode.ts";
|
|
5
4
|
import { getIndexStatus } from "../shared/session-index/index.ts";
|
|
5
|
+
import {
|
|
6
|
+
getIndexRecoveryMessage,
|
|
7
|
+
observeIndexRecovery,
|
|
8
|
+
type ReindexResult,
|
|
9
|
+
rebuildSessionIndex,
|
|
10
|
+
} from "../shared/session-index/recovery.ts";
|
|
6
11
|
import type { SessionSettings } from "../shared/settings.ts";
|
|
7
12
|
|
|
8
13
|
import { ReindexLoader } from "./loader.ts";
|
|
9
14
|
import { type SessionIndexAction, SessionIndexPanel } from "./panel.ts";
|
|
10
15
|
|
|
11
|
-
export function installIndex(
|
|
16
|
+
export function installIndex(
|
|
17
|
+
pi: ExtensionAPI,
|
|
18
|
+
deps: { settings: SessionSettings },
|
|
19
|
+
): IndexHandle & SessionLifecycle {
|
|
12
20
|
const indexPath = deps.settings.index.path;
|
|
21
|
+
let lastNotice: string | undefined;
|
|
13
22
|
|
|
14
23
|
pi.registerCommand("session-index", {
|
|
15
24
|
description: "Open the session index control panel",
|
|
@@ -20,6 +29,7 @@ export function installIndex(pi: ExtensionAPI, deps: { settings: SessionSettings
|
|
|
20
29
|
}
|
|
21
30
|
|
|
22
31
|
const status = getIndexStatus(indexPath);
|
|
32
|
+
status.recoveryMessage = getIndexRecoveryMessage(indexPath);
|
|
23
33
|
const action = await ctx.ui.custom<SessionIndexAction>(
|
|
24
34
|
(tui, theme, _keybindings, done) =>
|
|
25
35
|
new SessionIndexPanel(theme, status, done, () => tui.requestRender()),
|
|
@@ -59,7 +69,18 @@ export function installIndex(pi: ExtensionAPI, deps: { settings: SessionSettings
|
|
|
59
69
|
},
|
|
60
70
|
});
|
|
61
71
|
|
|
62
|
-
return {
|
|
72
|
+
return {
|
|
73
|
+
path: indexPath,
|
|
74
|
+
onSessionStart(_event, ctx) {
|
|
75
|
+
observeIndexRecovery(indexPath, (message) => {
|
|
76
|
+
ctx.ui.setStatus("session-index", message);
|
|
77
|
+
if (message && message !== lastNotice && !message.includes("in progress")) {
|
|
78
|
+
ctx.ui.notify(message, "error");
|
|
79
|
+
}
|
|
80
|
+
lastNotice = message;
|
|
81
|
+
});
|
|
82
|
+
},
|
|
83
|
+
};
|
|
63
84
|
}
|
|
64
85
|
|
|
65
86
|
async function runReindexWithLoader(
|
|
@@ -6,6 +6,7 @@ import {
|
|
|
6
6
|
type TuiMouseEventResult,
|
|
7
7
|
truncateToWidth,
|
|
8
8
|
visibleWidth,
|
|
9
|
+
wrapTextWithAnsi,
|
|
9
10
|
} from "@earendil-works/pi-tui";
|
|
10
11
|
import { type LegendHit, LegendPointer, layoutLegend, legendHitAt } from "../shared/legend.ts";
|
|
11
12
|
import type { SessionIndexStatus } from "../shared/session-index/index.ts";
|
|
@@ -73,6 +74,14 @@ export class SessionIndexPanel implements Focusable {
|
|
|
73
74
|
lines.push(
|
|
74
75
|
this.renderRow(innerWidth, ` Last full reindex: ${this.status.lastFullReindexAt ?? "n/a"}`),
|
|
75
76
|
);
|
|
77
|
+
if (this.status.recoveryMessage) {
|
|
78
|
+
for (const line of wrapTextWithAnsi(
|
|
79
|
+
this.status.recoveryMessage,
|
|
80
|
+
Math.max(1, innerWidth - 2),
|
|
81
|
+
)) {
|
|
82
|
+
lines.push(this.renderRow(innerWidth, ` ${this.theme.fg("warning", line)}`));
|
|
83
|
+
}
|
|
84
|
+
}
|
|
76
85
|
lines.push(this.renderRow(innerWidth, ""));
|
|
77
86
|
this.renderAction(lines, innerWidth, "R", "rebuild from disk", () => this.done("reindex"));
|
|
78
87
|
this.renderAction(lines, innerWidth, "Enter / Esc", "close", () => this.done(undefined));
|
|
@@ -0,0 +1,91 @@
|
|
|
1
|
+
import type { SendMessageRequest, SendMessageResult } from "./install.ts";
|
|
2
|
+
|
|
3
|
+
export interface SessionWaker {
|
|
4
|
+
owns(target: string): boolean | Promise<boolean>;
|
|
5
|
+
wake(target: string): Promise<void>;
|
|
6
|
+
restart?(target: string): Promise<void>;
|
|
7
|
+
afterSend?(): Promise<void> | void;
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
export interface WakeMessaging {
|
|
11
|
+
sendMessage(request: SendMessageRequest): Promise<SendMessageResult>;
|
|
12
|
+
listSessions(): Promise<string[]>;
|
|
13
|
+
waitForSession(sessionId: string, timeoutMs: number): Promise<boolean>;
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
export class MessageRouter {
|
|
17
|
+
private readonly waking = new Map<string, Promise<void>>();
|
|
18
|
+
|
|
19
|
+
constructor(
|
|
20
|
+
private readonly messaging: WakeMessaging,
|
|
21
|
+
private readonly wakers: readonly SessionWaker[],
|
|
22
|
+
private readonly getEpoch: () => number,
|
|
23
|
+
private readonly readyTimeoutMs = 30_000,
|
|
24
|
+
) {}
|
|
25
|
+
|
|
26
|
+
async sendMessage(request: SendMessageRequest): Promise<SendMessageResult> {
|
|
27
|
+
const epoch = this.getEpoch();
|
|
28
|
+
let failedLiveSend: SendMessageResult | undefined;
|
|
29
|
+
if (await this.isLive(request.target)) {
|
|
30
|
+
this.requireCurrent(epoch);
|
|
31
|
+
const result = await this.messaging.sendMessage(request);
|
|
32
|
+
if (!isTargetDeparture(result)) return result;
|
|
33
|
+
failedLiveSend = result;
|
|
34
|
+
}
|
|
35
|
+
let owner: SessionWaker | undefined;
|
|
36
|
+
for (const waker of this.wakers) {
|
|
37
|
+
if (await waker.owns(request.target)) {
|
|
38
|
+
owner = waker;
|
|
39
|
+
break;
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
this.requireCurrent(epoch);
|
|
43
|
+
if (!owner) return failedLiveSend ?? this.messaging.sendMessage(request);
|
|
44
|
+
|
|
45
|
+
try {
|
|
46
|
+
for (let attempt = 0; ; attempt += 1) {
|
|
47
|
+
await this.wake(request.target, owner, epoch);
|
|
48
|
+
this.requireCurrent(epoch);
|
|
49
|
+
const result = await this.messaging.sendMessage(request);
|
|
50
|
+
if (attempt === 1 || !isTargetDeparture(result)) return result;
|
|
51
|
+
}
|
|
52
|
+
} finally {
|
|
53
|
+
if (epoch === this.getEpoch()) await owner.afterSend?.();
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
private wake(target: string, waker: SessionWaker, epoch: number): Promise<void> {
|
|
58
|
+
const key = `${epoch}:${target}`;
|
|
59
|
+
const existing = this.waking.get(key);
|
|
60
|
+
if (existing) return existing;
|
|
61
|
+
const wake = this.ensureReady(target, waker, epoch).finally(() => this.waking.delete(key));
|
|
62
|
+
this.waking.set(key, wake);
|
|
63
|
+
return wake;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
private async ensureReady(target: string, waker: SessionWaker, epoch: number): Promise<void> {
|
|
67
|
+
if (await this.isLive(target)) return;
|
|
68
|
+
this.requireCurrent(epoch);
|
|
69
|
+
await waker.wake(target);
|
|
70
|
+
if (await this.messaging.waitForSession(target, this.readyTimeoutMs)) return;
|
|
71
|
+
this.requireCurrent(epoch);
|
|
72
|
+
if (waker.restart) {
|
|
73
|
+
await waker.restart(target);
|
|
74
|
+
if (await this.messaging.waitForSession(target, this.readyTimeoutMs)) return;
|
|
75
|
+
}
|
|
76
|
+
throw new Error(`Session ${target} did not register for messaging.`);
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
private async isLive(target: string): Promise<boolean> {
|
|
80
|
+
return (await this.messaging.listSessions()).includes(target);
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
private requireCurrent(epoch: number): void {
|
|
84
|
+
if (epoch !== this.getEpoch())
|
|
85
|
+
throw new Error("The current session changed while waking a session.");
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
function isTargetDeparture(result: SendMessageResult): boolean {
|
|
90
|
+
return !result.delivered && (result.reason === "no_session" || result.reason === "disconnected");
|
|
91
|
+
}
|
|
@@ -26,7 +26,8 @@ const REACHABLE_USER_SESSION_SCHEMA = Type.Object({
|
|
|
26
26
|
cwd: Type.Optional(Type.String()),
|
|
27
27
|
modifiedAt: Type.Optional(Type.String()),
|
|
28
28
|
relation: Type.Optional(Type.String()),
|
|
29
|
-
|
|
29
|
+
host: Type.Optional(Type.String()),
|
|
30
|
+
state: Type.Union([Type.Literal("live"), Type.Literal("starting"), Type.Literal("dormant")]),
|
|
30
31
|
});
|
|
31
32
|
|
|
32
33
|
const REACHABLE_SUBAGENT_SCHEMA = Type.Object({
|
|
@@ -5,6 +5,7 @@ import {
|
|
|
5
5
|
type ToolDefinition,
|
|
6
6
|
} from "@earendil-works/pi-coding-agent";
|
|
7
7
|
import { Text } from "@earendil-works/pi-tui";
|
|
8
|
+
import type { HostedSession } from "../../hosts/contract.ts";
|
|
8
9
|
import { isSessionStarting } from "../../session-handoff/metadata.ts";
|
|
9
10
|
import { formatError } from "../../shared/errors.ts";
|
|
10
11
|
import { ExpandableContentLayout } from "../../shared/rendering/expandable-content-layout.ts";
|
|
@@ -46,7 +47,9 @@ export interface ReachableSubagentEntry {
|
|
|
46
47
|
|
|
47
48
|
export interface SessionReachableDeps {
|
|
48
49
|
indexPath: string;
|
|
50
|
+
hostedSessions?: boolean;
|
|
49
51
|
listSessions(): Promise<string[]>;
|
|
52
|
+
listHostedSessions?: () => Promise<HostedSession[]>;
|
|
50
53
|
getRelationTo(sessionId: string): string | undefined;
|
|
51
54
|
listSubagents?:
|
|
52
55
|
| ((
|
|
@@ -75,6 +78,11 @@ export function createSessionReachableTool(deps: SessionReachableDeps): ToolDefi
|
|
|
75
78
|
: "List addressable live sessions",
|
|
76
79
|
promptGuidelines: [
|
|
77
80
|
"Before session_send_message, use session_reachable to find the target session id.",
|
|
81
|
+
...(deps.hostedSessions
|
|
82
|
+
? [
|
|
83
|
+
"User sessions include dormant hosted sessions. session_send_message wakes them automatically; the host field identifies their host.",
|
|
84
|
+
]
|
|
85
|
+
: []),
|
|
78
86
|
],
|
|
79
87
|
annotations: { readOnlyHint: true, openWorldHint: false },
|
|
80
88
|
parameters: subagentsAvailable ? SESSION_REACHABLE_PARAMS : SESSION_REACHABLE_USER_PARAMS,
|
|
@@ -129,7 +137,11 @@ async function listReachableUserSessions(
|
|
|
129
137
|
}
|
|
130
138
|
|
|
131
139
|
const parentSessionId = deps.getParentSessionId?.();
|
|
132
|
-
const
|
|
140
|
+
const hostedSessions = new Map(
|
|
141
|
+
((await deps.listHostedSessions?.()) ?? []).map((session) => [session.sessionId, session]),
|
|
142
|
+
);
|
|
143
|
+
const liveIds = new Set(liveSessionIds);
|
|
144
|
+
const targetIds = [...new Set([...liveSessionIds, ...hostedSessions.keys()])].filter(
|
|
133
145
|
(sessionId) => sessionId !== currentSessionId && sessionId !== parentSessionId,
|
|
134
146
|
);
|
|
135
147
|
return withSessionIndex(deps.indexPath, { mode: "read", required: true }, ({ db }) => {
|
|
@@ -140,12 +152,20 @@ async function listReachableUserSessions(
|
|
|
140
152
|
continue;
|
|
141
153
|
}
|
|
142
154
|
const relation = deps.getRelationTo(sessionId);
|
|
155
|
+
const hosted = hostedSessions.get(sessionId);
|
|
156
|
+
const title = hosted?.title ?? row?.sessionName;
|
|
157
|
+
const cwd = hosted?.cwd ?? row?.cwd;
|
|
143
158
|
sessions.push({
|
|
144
159
|
kind: "user",
|
|
145
160
|
sessionId,
|
|
146
|
-
state:
|
|
147
|
-
|
|
148
|
-
|
|
161
|
+
state: !liveIds.has(sessionId)
|
|
162
|
+
? "dormant"
|
|
163
|
+
: row && isStartingSession(row)
|
|
164
|
+
? "starting"
|
|
165
|
+
: "live",
|
|
166
|
+
...(hosted ? { host: hosted.host } : {}),
|
|
167
|
+
...(title ? { title } : {}),
|
|
168
|
+
...(cwd ? { cwd } : {}),
|
|
149
169
|
...(row?.modifiedAt ? { modifiedAt: row.modifiedAt } : {}),
|
|
150
170
|
...(relation === undefined ? {} : { relation }),
|
|
151
171
|
});
|
|
@@ -53,6 +53,7 @@ export type SessionSendMessageRole =
|
|
|
53
53
|
|
|
54
54
|
export interface SessionSendMessageToolOptions {
|
|
55
55
|
role: SessionSendMessageRole;
|
|
56
|
+
hostedSessions?: boolean;
|
|
56
57
|
getCachedRelationTo(sessionId: string | undefined): string | undefined;
|
|
57
58
|
/**
|
|
58
59
|
* Resolved on every call: subagent identity depends on the active branch, so a rewind can
|
|
@@ -119,7 +120,14 @@ export function createSessionSendMessageTool(
|
|
|
119
120
|
label: "Send Message to Session",
|
|
120
121
|
description,
|
|
121
122
|
promptSnippet,
|
|
122
|
-
promptGuidelines
|
|
123
|
+
promptGuidelines: [
|
|
124
|
+
...promptGuidelines,
|
|
125
|
+
...(options.hostedSessions
|
|
126
|
+
? [
|
|
127
|
+
"session_reachable also lists dormant hosted sessions; sending a message wakes them automatically.",
|
|
128
|
+
]
|
|
129
|
+
: []),
|
|
130
|
+
],
|
|
123
131
|
parameters: SEND_MESSAGE_PARAMS,
|
|
124
132
|
renderCall(args, theme, context) {
|
|
125
133
|
const state = context.state as SendMessageRendererState;
|
|
@@ -6,6 +6,13 @@ import {
|
|
|
6
6
|
type SessionIndexDatabase,
|
|
7
7
|
type SessionIndexStatus,
|
|
8
8
|
} from "./common.ts";
|
|
9
|
+
import {
|
|
10
|
+
acquireIndexLock,
|
|
11
|
+
disableOlderIndexClient,
|
|
12
|
+
getIndexRecoveryMessage,
|
|
13
|
+
isIndexClientDisabled,
|
|
14
|
+
requestIndexRecovery,
|
|
15
|
+
} from "./recovery.ts";
|
|
9
16
|
import { getMetadata, openIndexDatabase } from "./schema.ts";
|
|
10
17
|
|
|
11
18
|
export type SessionIndexOpenMode = "read" | "write";
|
|
@@ -67,28 +74,57 @@ function openValidatedSessionIndexInternal(
|
|
|
67
74
|
indexPath: string,
|
|
68
75
|
options: WithSessionIndexOptions,
|
|
69
76
|
): ValidSessionIndex | undefined {
|
|
77
|
+
if (isIndexClientDisabled(indexPath)) return handleUnavailableIndex(indexPath, options);
|
|
70
78
|
if (!existsSync(indexPath)) {
|
|
71
79
|
return handleUnavailableIndex(indexPath, options);
|
|
72
80
|
}
|
|
73
81
|
|
|
74
82
|
let db: SessionIndexDatabase | undefined;
|
|
83
|
+
let release: (() => void) | undefined;
|
|
75
84
|
try {
|
|
85
|
+
if (options.mode === "write") {
|
|
86
|
+
release = acquireIndexLock(indexPath);
|
|
87
|
+
if (!release) return handleUnavailableIndex(indexPath, options);
|
|
88
|
+
}
|
|
76
89
|
db = openIndexDatabase(indexPath, {
|
|
77
90
|
create: false,
|
|
78
|
-
mode:
|
|
91
|
+
mode: "read",
|
|
79
92
|
...(options.timeoutMs === undefined ? {} : { timeoutMs: options.timeoutMs }),
|
|
80
93
|
});
|
|
81
|
-
const
|
|
82
|
-
if (
|
|
94
|
+
const version = Number(getMetadata(db, "schema_version"));
|
|
95
|
+
if (version !== INDEX_SCHEMA_VERSION) {
|
|
83
96
|
const invalidDb = db;
|
|
84
97
|
db = undefined;
|
|
85
98
|
invalidDb.close();
|
|
99
|
+
release?.();
|
|
100
|
+
release = undefined;
|
|
101
|
+
if (version > INDEX_SCHEMA_VERSION) disableOlderIndexClient(indexPath);
|
|
86
102
|
return handleUnavailableIndex(indexPath, options);
|
|
87
103
|
}
|
|
88
|
-
|
|
104
|
+
const status = getIndexStatusFromDb(indexPath, db);
|
|
105
|
+
if (options.mode === "write") {
|
|
106
|
+
db.close();
|
|
107
|
+
db = undefined;
|
|
108
|
+
db = openIndexDatabase(indexPath, {
|
|
109
|
+
create: false,
|
|
110
|
+
mode: "write",
|
|
111
|
+
timeoutMs: options.timeoutMs,
|
|
112
|
+
});
|
|
113
|
+
const close = db.close;
|
|
114
|
+
const unlock = release;
|
|
115
|
+
db.close = () => {
|
|
116
|
+
try {
|
|
117
|
+
close();
|
|
118
|
+
} finally {
|
|
119
|
+
unlock?.();
|
|
120
|
+
}
|
|
121
|
+
};
|
|
122
|
+
release = undefined;
|
|
123
|
+
}
|
|
89
124
|
return { db, status };
|
|
90
125
|
} catch (error) {
|
|
91
126
|
db?.close();
|
|
127
|
+
release?.();
|
|
92
128
|
return handleUnavailableIndex(indexPath, options, error);
|
|
93
129
|
}
|
|
94
130
|
}
|
|
@@ -112,7 +148,7 @@ export function getIndexStatusFromDb(dbPath: string, db: SessionIndexDatabase):
|
|
|
112
148
|
}
|
|
113
149
|
|
|
114
150
|
export function formatRequiredSessionIndexError(indexPath: string): string {
|
|
115
|
-
return
|
|
151
|
+
return getIndexRecoveryMessage(indexPath) ?? "Session indexing in progress; try again shortly.";
|
|
116
152
|
}
|
|
117
153
|
|
|
118
154
|
function handleUnavailableIndex(
|
|
@@ -120,6 +156,7 @@ function handleUnavailableIndex(
|
|
|
120
156
|
options: WithSessionIndexOptions,
|
|
121
157
|
cause?: unknown,
|
|
122
158
|
): undefined {
|
|
159
|
+
if (!isIndexClientDisabled(indexPath)) void requestIndexRecovery(indexPath);
|
|
123
160
|
if (!options.required) {
|
|
124
161
|
return undefined;
|
|
125
162
|
}
|
|
@@ -148,6 +148,7 @@ export interface SessionIndexStatus {
|
|
|
148
148
|
schemaVersion?: number | undefined;
|
|
149
149
|
sessionCount?: number | undefined;
|
|
150
150
|
lastFullReindexAt?: string | undefined;
|
|
151
|
+
recoveryMessage?: string | undefined;
|
|
151
152
|
}
|
|
152
153
|
|
|
153
154
|
export type SessionIndexDatabase = SqliteDatabase;
|