@klarkxy/dsh-session-manager 0.3.2 → 0.3.3
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 +4 -1
- package/README.zh-CN.md +4 -1
- package/lib/client.inner.cjs +996 -100
- package/lib/client.inner.cjs.map +1 -1
- package/lib/client.js +996 -100
- package/lib/index.js +757 -8
- package/lib/index.js.map +1 -1
- package/package.json +2 -2
package/lib/index.js
CHANGED
|
@@ -7,9 +7,9 @@ import { deriveEventMessage, foldSurface, isAppendSurfaceEvent } from "@deepseek
|
|
|
7
7
|
import { BlockAssembler, ReasoningEffortId, createUserMessage } from "@deepseek-ai/dsh-llm";
|
|
8
8
|
import { lstat, mkdir, realpath } from "node:fs/promises";
|
|
9
9
|
import { join } from "node:path";
|
|
10
|
-
import { createModelSettingsChanges } from "@klarkxy/dsh-plugin-kit/model-settings-changes";
|
|
11
10
|
import { defineDomain, domainTable } from "@deepseek-ai/dsh-storage-domain";
|
|
12
11
|
import { z } from "zod";
|
|
12
|
+
import { createModelSettingsChanges } from "@klarkxy/dsh-plugin-kit/model-settings-changes";
|
|
13
13
|
//#region src/contracts.ts
|
|
14
14
|
/** Shared names and bounds for cross-session discovery. */
|
|
15
15
|
const PLUGIN_NAME = "@klarkxy/dsh-session-manager";
|
|
@@ -2157,7 +2157,7 @@ function registerHostRpc(ctx, channel, call, options = {}) {
|
|
|
2157
2157
|
}
|
|
2158
2158
|
//#endregion
|
|
2159
2159
|
//#region src/welcome-back.ts
|
|
2160
|
-
const UUID$
|
|
2160
|
+
const UUID$2 = /^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i;
|
|
2161
2161
|
/** Assistant-side durable activity: the moment the session last responded or worked. */
|
|
2162
2162
|
const RESPONSE_EVENTS = /* @__PURE__ */ new Set([
|
|
2163
2163
|
"assistant/message",
|
|
@@ -2317,7 +2317,7 @@ function isBusyFailure(error) {
|
|
|
2317
2317
|
}
|
|
2318
2318
|
async function compactSession(services, request, signal) {
|
|
2319
2319
|
const sessionId = requiredSessionId(request?.sessionId);
|
|
2320
|
-
if (typeof request?.requestId !== "string" || !UUID$
|
|
2320
|
+
if (typeof request?.requestId !== "string" || !UUID$2.test(request.requestId)) throw new PresenceError("session-manager/invalid-request", "requestId must be a UUID");
|
|
2321
2321
|
const controller = services.sessionController;
|
|
2322
2322
|
const compaction = services.compaction;
|
|
2323
2323
|
if (controller === void 0 || compaction === void 0) throw new PresenceError("session-manager/unavailable", "this runtime cannot compact a session");
|
|
@@ -2466,11 +2466,17 @@ function presenceServices(ctx, sessionQuery) {
|
|
|
2466
2466
|
const agents = optional("agents");
|
|
2467
2467
|
const sessionController = optional("sessionController");
|
|
2468
2468
|
const compaction = optional("compaction");
|
|
2469
|
+
const workspaceController = optional("workspaceController");
|
|
2470
|
+
const attachments = optional("attachments");
|
|
2471
|
+
const fileUploads = optional("fileUploads");
|
|
2469
2472
|
return {
|
|
2470
2473
|
sessionQuery,
|
|
2471
2474
|
...agents === void 0 ? {} : { agents },
|
|
2472
2475
|
...sessionController === void 0 ? {} : { sessionController },
|
|
2473
|
-
...compaction === void 0 ? {} : { compaction }
|
|
2476
|
+
...compaction === void 0 ? {} : { compaction },
|
|
2477
|
+
...workspaceController === void 0 ? {} : { workspaceController },
|
|
2478
|
+
...attachments === void 0 ? {} : { attachments },
|
|
2479
|
+
...fileUploads === void 0 ? {} : { fileUploads }
|
|
2474
2480
|
};
|
|
2475
2481
|
}
|
|
2476
2482
|
//#endregion
|
|
@@ -2560,12 +2566,12 @@ async function writePresenceConfig(root, config, patch, store, signal) {
|
|
|
2560
2566
|
}
|
|
2561
2567
|
//#endregion
|
|
2562
2568
|
//#region src/temporary-directory.ts
|
|
2563
|
-
const UUID = /^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i;
|
|
2569
|
+
const UUID$1 = /^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i;
|
|
2564
2570
|
/** Allocate only beneath the active profile. Replaying a request retains its directory.
|
|
2565
2571
|
* No user paths, session formats, free-session metadata or deletion are involved.
|
|
2566
2572
|
*/
|
|
2567
2573
|
async function allocateTemporaryDirectory(profileDir, requestId, signal) {
|
|
2568
|
-
if (typeof requestId !== "string" || !UUID.test(requestId)) throw new Error("Invalid temporary workspace request ID");
|
|
2574
|
+
if (typeof requestId !== "string" || !UUID$1.test(requestId)) throw new Error("Invalid temporary workspace request ID");
|
|
2569
2575
|
signal.throwIfAborted();
|
|
2570
2576
|
let directory = await realpath(profileDir);
|
|
2571
2577
|
for (const part of [
|
|
@@ -2609,13 +2615,682 @@ async function runTemporaryAllocate(profileDir, payload, signal) {
|
|
|
2609
2615
|
}
|
|
2610
2616
|
}
|
|
2611
2617
|
//#endregion
|
|
2618
|
+
//#region src/edit-prepare.ts
|
|
2619
|
+
function unavailable(message) {
|
|
2620
|
+
return new PresenceError("session-manager/unavailable", message);
|
|
2621
|
+
}
|
|
2622
|
+
function promptContent$1(event) {
|
|
2623
|
+
const content = event.data.content.map((block) => {
|
|
2624
|
+
if (block.type === "text") return {
|
|
2625
|
+
type: "text",
|
|
2626
|
+
text: block.text
|
|
2627
|
+
};
|
|
2628
|
+
if (block.type === "image") return {
|
|
2629
|
+
type: "image",
|
|
2630
|
+
attachmentId: block.attachment.attachmentId
|
|
2631
|
+
};
|
|
2632
|
+
if (block.type === "file") return {
|
|
2633
|
+
type: "file",
|
|
2634
|
+
...block.attachment
|
|
2635
|
+
};
|
|
2636
|
+
throw unavailable("This prompt contains an unsupported content block");
|
|
2637
|
+
});
|
|
2638
|
+
const text = event.data.content.map((block) => block.type === "text" ? block.text : "").join("");
|
|
2639
|
+
if (text.trim() === "" && !content.some((block) => block.type !== "text")) throw unavailable("The selected prompt has no content to resend");
|
|
2640
|
+
return {
|
|
2641
|
+
text,
|
|
2642
|
+
content
|
|
2643
|
+
};
|
|
2644
|
+
}
|
|
2645
|
+
/** Resolve one exact human prompt on the active branch, with its original attachments. */
|
|
2646
|
+
async function prepareEdit(sessionQuery, request, signal) {
|
|
2647
|
+
if (typeof request?.sessionId !== "string" || request.sessionId.trim() === "") throw new PresenceError("session-manager/invalid-request", "sessionId must be a non-empty string");
|
|
2648
|
+
if (!Number.isSafeInteger(request.promptSeq) || request.promptSeq < 0) throw new PresenceError("session-manager/invalid-request", "promptSeq must be a non-negative integer");
|
|
2649
|
+
const observation = await sessionQuery.observeSession(SessionId(request.sessionId), {
|
|
2650
|
+
signal,
|
|
2651
|
+
projectionMode: "none"
|
|
2652
|
+
});
|
|
2653
|
+
try {
|
|
2654
|
+
if (isSubagentSession({ header: observation.header })) throw unavailable("Subagent prompts cannot be edited here");
|
|
2655
|
+
const events = activeEventsOf(observation);
|
|
2656
|
+
if (events === void 0) throw unavailable("The active conversation branch cannot be inspected");
|
|
2657
|
+
const prompt = events.find((event) => event.type === "user/message" && event.seq === request.promptSeq && event.data.source.kind === "user");
|
|
2658
|
+
if (prompt === void 0) throw unavailable("The selected prompt is unavailable");
|
|
2659
|
+
const { text, content } = promptContent$1(prompt);
|
|
2660
|
+
const modified = events.some((event) => event.type === "workspace/changes" && event.seq > prompt.seq);
|
|
2661
|
+
return {
|
|
2662
|
+
sessionId: request.sessionId,
|
|
2663
|
+
promptSeq: prompt.seq,
|
|
2664
|
+
text,
|
|
2665
|
+
...content.some((block) => block.type !== "text") ? { content } : {},
|
|
2666
|
+
modified
|
|
2667
|
+
};
|
|
2668
|
+
} finally {
|
|
2669
|
+
observation[Symbol.dispose]();
|
|
2670
|
+
}
|
|
2671
|
+
}
|
|
2672
|
+
//#endregion
|
|
2673
|
+
//#region src/retry-journal.ts
|
|
2674
|
+
const retryRecordSchema = z.object({
|
|
2675
|
+
requestId: z.string().uuid(),
|
|
2676
|
+
source: z.string().min(1),
|
|
2677
|
+
messageId: z.string().min(1),
|
|
2678
|
+
text: z.string().optional(),
|
|
2679
|
+
expectedRevision: z.number().int().nonnegative(),
|
|
2680
|
+
expectedInputRevision: z.string(),
|
|
2681
|
+
forkSeq: z.number().int().nonnegative(),
|
|
2682
|
+
title: z.string().optional(),
|
|
2683
|
+
forkStarted: z.boolean(),
|
|
2684
|
+
childId: z.string().min(1).optional(),
|
|
2685
|
+
renamed: z.boolean().optional(),
|
|
2686
|
+
prompted: z.boolean(),
|
|
2687
|
+
result: z.object({
|
|
2688
|
+
sessionId: z.string().min(1),
|
|
2689
|
+
sourceSessionId: z.string().min(1),
|
|
2690
|
+
archived: z.boolean()
|
|
2691
|
+
}).strict().optional()
|
|
2692
|
+
}).strict().refine((row) => (!row.childId || row.forkStarted) && (!row.prompted || Boolean(row.childId)) && (!row.result || row.prompted && row.result.sessionId === row.childId && row.result.sourceSessionId === row.source));
|
|
2693
|
+
/** Separate from preferences: unreadable retry evidence must never become an empty ledger. */
|
|
2694
|
+
const retryDomain = defineDomain({
|
|
2695
|
+
name: "dsh_session_manager_retries",
|
|
2696
|
+
version: 1,
|
|
2697
|
+
layout: "per-record",
|
|
2698
|
+
tables: { requests: domainTable(retryRecordSchema) }
|
|
2699
|
+
});
|
|
2700
|
+
var RetryJournalError = class extends Error {
|
|
2701
|
+
code;
|
|
2702
|
+
constructor(code, message) {
|
|
2703
|
+
super(message);
|
|
2704
|
+
this.code = code;
|
|
2705
|
+
}
|
|
2706
|
+
};
|
|
2707
|
+
/** Owns one profile's request identities and drains active commits before releasing storage. */
|
|
2708
|
+
var RetryJournal = class {
|
|
2709
|
+
domain;
|
|
2710
|
+
records = /* @__PURE__ */ new Map();
|
|
2711
|
+
running = /* @__PURE__ */ new Map();
|
|
2712
|
+
runningIds = /* @__PURE__ */ new Set();
|
|
2713
|
+
closing;
|
|
2714
|
+
closed = false;
|
|
2715
|
+
constructor(domain) {
|
|
2716
|
+
this.domain = domain;
|
|
2717
|
+
for (const [key, value] of domain.table("requests").entries()) {
|
|
2718
|
+
const row = retryRecordSchema.parse(value);
|
|
2719
|
+
if (key !== row.requestId) throw new Error("Retry record identity mismatch");
|
|
2720
|
+
this.records.set(row.requestId, row);
|
|
2721
|
+
}
|
|
2722
|
+
}
|
|
2723
|
+
get(requestId) {
|
|
2724
|
+
return this.records.get(requestId);
|
|
2725
|
+
}
|
|
2726
|
+
async put(row) {
|
|
2727
|
+
const stored = retryRecordSchema.parse(row);
|
|
2728
|
+
await this.domain.table("requests").put(row.requestId, stored);
|
|
2729
|
+
this.records.set(row.requestId, row);
|
|
2730
|
+
}
|
|
2731
|
+
run(source, requestId, action) {
|
|
2732
|
+
if (this.closed) return Promise.reject(new RetryJournalError("session-manager/unavailable", "retry storage is closing"));
|
|
2733
|
+
if (this.runningIds.has(requestId)) return Promise.reject(new RetryJournalError("session/retry-pending", "this retry is still running"));
|
|
2734
|
+
if (this.running.has(source)) return Promise.reject(new RetryJournalError("session-manager/busy", "another retry is running"));
|
|
2735
|
+
for (const row of this.records.values()) if (row.source === source && row.requestId !== requestId && row.forkStarted && !row.result) return Promise.reject(new RetryJournalError("session/retry-uncertain", "an earlier resend must be resolved first"));
|
|
2736
|
+
const work = Promise.resolve().then(action).finally(() => {
|
|
2737
|
+
this.running.delete(source);
|
|
2738
|
+
this.runningIds.delete(requestId);
|
|
2739
|
+
});
|
|
2740
|
+
this.running.set(source, work);
|
|
2741
|
+
this.runningIds.add(requestId);
|
|
2742
|
+
return work;
|
|
2743
|
+
}
|
|
2744
|
+
close() {
|
|
2745
|
+
this.closed = true;
|
|
2746
|
+
return this.closing ??= Promise.allSettled([...this.running.values()]).then(() => this.domain.close());
|
|
2747
|
+
}
|
|
2748
|
+
};
|
|
2749
|
+
//#endregion
|
|
2750
|
+
//#region src/edit-file.ts
|
|
2751
|
+
/** Reuse the host's streaming intake; only a file in the exact source prompt may be copied. */
|
|
2752
|
+
async function stageEditFile(query, services, request, signal) {
|
|
2753
|
+
if (![request.sessionId, request.attachmentId].every((value) => typeof value === "string" && value !== "") || !Number.isSafeInteger(request.promptSeq) || request.promptSeq < 0) throw new PresenceError("session-manager/invalid-request", "Invalid attachment request");
|
|
2754
|
+
const observation = await query.observeSession(SessionId(request.sessionId), {
|
|
2755
|
+
signal,
|
|
2756
|
+
projectionMode: "none"
|
|
2757
|
+
});
|
|
2758
|
+
try {
|
|
2759
|
+
const part = (activeEventsOf(observation)?.find((event) => event.seq === request.promptSeq && event.type === "user/message" && event.data.source.kind === "user"))?.data.content.find((part) => part.type === "file" && part.attachment.attachmentId === request.attachmentId);
|
|
2760
|
+
if (part?.type !== "file" || !services.attachments || !services.fileUploads) throw new PresenceError("session-manager/unavailable", "The selected file is unavailable");
|
|
2761
|
+
return await services.fileUploads.uploadStream({
|
|
2762
|
+
sessionId: request.sessionId,
|
|
2763
|
+
name: part.attachment.name,
|
|
2764
|
+
data: services.attachments.readFileStream(part.attachment, signal),
|
|
2765
|
+
signal
|
|
2766
|
+
});
|
|
2767
|
+
} finally {
|
|
2768
|
+
observation[Symbol.dispose]();
|
|
2769
|
+
}
|
|
2770
|
+
}
|
|
2771
|
+
//#endregion
|
|
2772
|
+
//#region src/retry-branch.ts
|
|
2773
|
+
/**
|
|
2774
|
+
* Content digest of one prompt. Not a hash: the token only has to change when
|
|
2775
|
+
* the client edited between preview and commit, so the JSON of the durable
|
|
2776
|
+
* content is the honest cheapest representation.
|
|
2777
|
+
*/
|
|
2778
|
+
function inputRevisionOf(event) {
|
|
2779
|
+
return JSON.stringify(event.data.content);
|
|
2780
|
+
}
|
|
2781
|
+
/**
|
|
2782
|
+
* An edit of a turn's first input must not inherit its empty turn/step shell.
|
|
2783
|
+
* Find the last balanced, drained prefix before that shell, including inbox
|
|
2784
|
+
* splices: cutting just before turn/start can resurrect the original queued
|
|
2785
|
+
* prompt. A later input in the same turn keeps the real work before it.
|
|
2786
|
+
*/
|
|
2787
|
+
function editForkBoundary(events, promptSeq) {
|
|
2788
|
+
const pending = {
|
|
2789
|
+
"next-turn": 0,
|
|
2790
|
+
"next-step": 0
|
|
2791
|
+
};
|
|
2792
|
+
let open = false;
|
|
2793
|
+
let hasContent = false;
|
|
2794
|
+
let balanced;
|
|
2795
|
+
let previous;
|
|
2796
|
+
for (const event of events) {
|
|
2797
|
+
if (event.seq >= promptSeq) break;
|
|
2798
|
+
const type = event.type;
|
|
2799
|
+
if (type === "agent/inbox/spliced") {
|
|
2800
|
+
const splice = event.data;
|
|
2801
|
+
const count = pending[splice.target];
|
|
2802
|
+
const removed = splice.removedCount ?? 0;
|
|
2803
|
+
if (count === void 0 || !Array.isArray(splice.inserted) || !Number.isSafeInteger(splice.start) || splice.start < 0 || splice.start > count || !Number.isSafeInteger(removed) || removed < 0 || splice.start + removed > count) return void 0;
|
|
2804
|
+
pending[splice.target] += splice.inserted.length - removed;
|
|
2805
|
+
}
|
|
2806
|
+
if (type === "turn/start") {
|
|
2807
|
+
open = true;
|
|
2808
|
+
hasContent = false;
|
|
2809
|
+
} else if (type === "turn/end") {
|
|
2810
|
+
open = false;
|
|
2811
|
+
hasContent = false;
|
|
2812
|
+
} else if (type === "user/message" || type === "assistant/message" || type === "tool/call" || type === "tool/result" || type === "command/run" || type === "job/registered") hasContent = true;
|
|
2813
|
+
previous = event.seq;
|
|
2814
|
+
if (!open && pending["next-turn"] === 0 && pending["next-step"] === 0) balanced = event.seq;
|
|
2815
|
+
}
|
|
2816
|
+
if (open && !hasContent) return balanced;
|
|
2817
|
+
return pending["next-turn"] === 0 && pending["next-step"] === 0 ? previous : void 0;
|
|
2818
|
+
}
|
|
2819
|
+
/**
|
|
2820
|
+
* Whether an already-observed prompt is still on the active surface: its seq
|
|
2821
|
+
* must not appear in any later event's `sourceEventSeqs`.
|
|
2822
|
+
*/
|
|
2823
|
+
function isOnActiveSurface(events, promptSeq) {
|
|
2824
|
+
for (const event of events) {
|
|
2825
|
+
if (event.seq <= promptSeq) continue;
|
|
2826
|
+
const seqs = event.sourceEventSeqs;
|
|
2827
|
+
if (Array.isArray(seqs) && seqs.includes(promptSeq)) return false;
|
|
2828
|
+
}
|
|
2829
|
+
return true;
|
|
2830
|
+
}
|
|
2831
|
+
/**
|
|
2832
|
+
* Later model-side work the user must explicitly keep (`allowModified`).
|
|
2833
|
+
* `command/run` and `job/*` are not in the stock SessionEventMap but a restored
|
|
2834
|
+
* log from a newer host can carry them, so the scan compares plain strings.
|
|
2835
|
+
*/
|
|
2836
|
+
const MODIFIED_EVIDENCE = /* @__PURE__ */ new Set([
|
|
2837
|
+
"tool/call",
|
|
2838
|
+
"tool/result",
|
|
2839
|
+
"command/run",
|
|
2840
|
+
"job/registered"
|
|
2841
|
+
]);
|
|
2842
|
+
function modificationEvidence(events, promptSeq) {
|
|
2843
|
+
const seen = /* @__PURE__ */ new Set();
|
|
2844
|
+
for (const event of events) {
|
|
2845
|
+
const type = event.type;
|
|
2846
|
+
if (event.seq > promptSeq && MODIFIED_EVIDENCE.has(type)) seen.add(type);
|
|
2847
|
+
}
|
|
2848
|
+
return [...seen];
|
|
2849
|
+
}
|
|
2850
|
+
/**
|
|
2851
|
+
* A live job started and not yet settled blocks retry like a running turn.
|
|
2852
|
+
* Job events are absent from the stock event union; the scan reads `jobId`
|
|
2853
|
+
* defensively so a log restored from a newer host still reports live work.
|
|
2854
|
+
*/
|
|
2855
|
+
function hasLiveJobs(events) {
|
|
2856
|
+
const live = /* @__PURE__ */ new Set();
|
|
2857
|
+
for (const event of events) {
|
|
2858
|
+
const type = event.type;
|
|
2859
|
+
if (type !== "job/registered" && type !== "job/settled") continue;
|
|
2860
|
+
const jobId = event.data.jobId;
|
|
2861
|
+
if (typeof jobId !== "string") continue;
|
|
2862
|
+
if (type === "job/registered") live.add(jobId);
|
|
2863
|
+
else live.delete(jobId);
|
|
2864
|
+
}
|
|
2865
|
+
return live.size > 0;
|
|
2866
|
+
}
|
|
2867
|
+
//#endregion
|
|
2868
|
+
//#region src/retry-content.ts
|
|
2869
|
+
/**
|
|
2870
|
+
* The editor exposes joined text, while the host stores an ordered block list.
|
|
2871
|
+
* Apply the smallest single text replacement to that list. Unchanged text on
|
|
2872
|
+
* either side of an attachment stays on its own side; attachments never move.
|
|
2873
|
+
* Code points avoid splitting an emoji at a block boundary during replacement.
|
|
2874
|
+
*/
|
|
2875
|
+
function editedPromptBlocks(content, text) {
|
|
2876
|
+
const before = content.flatMap((block) => block.type === "text" ? Array.from(block.text) : []);
|
|
2877
|
+
if (text === void 0 || before.join("") === text) return content;
|
|
2878
|
+
const after = Array.from(text);
|
|
2879
|
+
let prefix = 0;
|
|
2880
|
+
while (prefix < Math.min(before.length, after.length) && before[prefix] === after[prefix]) prefix++;
|
|
2881
|
+
let suffix = 0;
|
|
2882
|
+
while (suffix < Math.min(before.length, after.length) - prefix && before[before.length - suffix - 1] === after[after.length - suffix - 1]) suffix++;
|
|
2883
|
+
const end = before.length - suffix;
|
|
2884
|
+
const replacement = after.slice(prefix, after.length - suffix).join("");
|
|
2885
|
+
let offset = 0;
|
|
2886
|
+
let inserted = false;
|
|
2887
|
+
const blocks = content.map((block) => {
|
|
2888
|
+
if (block.type !== "text") return block;
|
|
2889
|
+
const chars = Array.from(block.text);
|
|
2890
|
+
const start = offset;
|
|
2891
|
+
offset += chars.length;
|
|
2892
|
+
const left = chars.slice(0, Math.max(0, prefix - start)).join("");
|
|
2893
|
+
const right = chars.slice(Math.max(0, end - start)).join("");
|
|
2894
|
+
const insertHere = !inserted && offset >= prefix;
|
|
2895
|
+
if (insertHere) inserted = true;
|
|
2896
|
+
return {
|
|
2897
|
+
type: "text",
|
|
2898
|
+
text: left + (insertHere ? replacement : "") + right
|
|
2899
|
+
};
|
|
2900
|
+
});
|
|
2901
|
+
if (!inserted && text !== "") blocks.push({
|
|
2902
|
+
type: "text",
|
|
2903
|
+
text
|
|
2904
|
+
});
|
|
2905
|
+
return blocks;
|
|
2906
|
+
}
|
|
2907
|
+
//#endregion
|
|
2908
|
+
//#region src/retry-service.ts
|
|
2909
|
+
/**
|
|
2910
|
+
* Host-side edit-and-resend engine. A commit never writes to the source log:
|
|
2911
|
+
* it forks the retained conversation prefix, queues the
|
|
2912
|
+
* edited prompt on the fork, mirrors the title/sidebar position/pin, and
|
|
2913
|
+
* archives the source so the branch looks like the same chat.
|
|
2914
|
+
*
|
|
2915
|
+
* Why a fork and not a same-session `surfaceOp` replace: a replace bumps
|
|
2916
|
+
* `surface.contentGeneration`, which starts a new request series and
|
|
2917
|
+
* reshuffles the system/tool projections — the provider's prompt prefix
|
|
2918
|
+
* cache can be disrupted. Retaining the unchanged model-visible prefix gives
|
|
2919
|
+
* the provider the same content to reuse; actual cache hits remain provider-dependent.
|
|
2920
|
+
*
|
|
2921
|
+
* The profile's durable journal records intent before creating a fork and
|
|
2922
|
+
* the child identity before prompting it. Replays resume that same child.
|
|
2923
|
+
* The host cannot create a fork idempotently: an interrupted fork with no
|
|
2924
|
+
* saved child identity is therefore refused, never guessed or repeated.
|
|
2925
|
+
*/
|
|
2926
|
+
var RetryError = class extends Error {
|
|
2927
|
+
code;
|
|
2928
|
+
name = "RetryError";
|
|
2929
|
+
constructor(code, message) {
|
|
2930
|
+
super(message);
|
|
2931
|
+
this.code = code;
|
|
2932
|
+
}
|
|
2933
|
+
};
|
|
2934
|
+
const UUID = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
|
|
2935
|
+
/** Retry needs a session controller that can fork and prompt; capabilities are honest about it. */
|
|
2936
|
+
function retryCapabilities(services) {
|
|
2937
|
+
const controller = services.sessionController;
|
|
2938
|
+
const available = services.retryJournal !== void 0 && controller !== void 0 && typeof controller.fork === "function" && typeof controller.prompt === "function";
|
|
2939
|
+
return {
|
|
2940
|
+
retry: available,
|
|
2941
|
+
retryModified: available,
|
|
2942
|
+
retryContent: false
|
|
2943
|
+
};
|
|
2944
|
+
}
|
|
2945
|
+
function requireSessionId(value) {
|
|
2946
|
+
if (typeof value !== "string" || value.trim() === "") throw new RetryError("session-manager/invalid-request", "sessionId must be a non-empty string");
|
|
2947
|
+
return value;
|
|
2948
|
+
}
|
|
2949
|
+
function findPrompt(events, messageId) {
|
|
2950
|
+
return events.find((event) => event.type === "user/message" && event.data.id === messageId && event.data.source.kind === "user");
|
|
2951
|
+
}
|
|
2952
|
+
async function observe(services, sessionId, signal) {
|
|
2953
|
+
const observation = await services.sessionQuery.observeSession(SessionId(sessionId), {
|
|
2954
|
+
signal,
|
|
2955
|
+
projectionMode: "none"
|
|
2956
|
+
});
|
|
2957
|
+
const events = activeEventsOf(observation);
|
|
2958
|
+
if (events === void 0) {
|
|
2959
|
+
observation[Symbol.dispose]();
|
|
2960
|
+
throw new RetryError("session-manager/unavailable", "the active conversation branch cannot be inspected");
|
|
2961
|
+
}
|
|
2962
|
+
return {
|
|
2963
|
+
observation,
|
|
2964
|
+
events
|
|
2965
|
+
};
|
|
2966
|
+
}
|
|
2967
|
+
async function previewRetry(services, request, signal) {
|
|
2968
|
+
const sessionId = requireSessionId(request?.sessionId);
|
|
2969
|
+
if (typeof request.messageId !== "string" || request.messageId === "") throw new RetryError("session-manager/invalid-request", "messageId must be a non-empty string");
|
|
2970
|
+
const { observation, events } = await observe(services, sessionId, signal);
|
|
2971
|
+
try {
|
|
2972
|
+
const base = {
|
|
2973
|
+
sessionId,
|
|
2974
|
+
messageId: request.messageId
|
|
2975
|
+
};
|
|
2976
|
+
if (isSubagentSession({ header: observation.header })) return {
|
|
2977
|
+
...base,
|
|
2978
|
+
promptSeq: -1,
|
|
2979
|
+
branchRevision: 0,
|
|
2980
|
+
inputRevision: "",
|
|
2981
|
+
eligible: false,
|
|
2982
|
+
modified: false,
|
|
2983
|
+
reason: "subagent"
|
|
2984
|
+
};
|
|
2985
|
+
if (services.agents?.get(sessionId)?.status === "running" || hasLiveJobs(events)) return {
|
|
2986
|
+
...base,
|
|
2987
|
+
promptSeq: -1,
|
|
2988
|
+
branchRevision: 0,
|
|
2989
|
+
inputRevision: "",
|
|
2990
|
+
eligible: false,
|
|
2991
|
+
modified: false,
|
|
2992
|
+
reason: "busy"
|
|
2993
|
+
};
|
|
2994
|
+
const prompt = findPrompt(events, request.messageId);
|
|
2995
|
+
if (prompt === void 0 || !isOnActiveSurface(events, prompt.seq)) return {
|
|
2996
|
+
...base,
|
|
2997
|
+
promptSeq: -1,
|
|
2998
|
+
branchRevision: 0,
|
|
2999
|
+
inputRevision: "",
|
|
3000
|
+
eligible: false,
|
|
3001
|
+
modified: false,
|
|
3002
|
+
reason: "prompt-unavailable"
|
|
3003
|
+
};
|
|
3004
|
+
const modified = modificationEvidence(events, prompt.seq).length > 0;
|
|
3005
|
+
return {
|
|
3006
|
+
...base,
|
|
3007
|
+
promptSeq: prompt.seq,
|
|
3008
|
+
branchRevision: events[events.length - 1]?.seq ?? 0,
|
|
3009
|
+
inputRevision: inputRevisionOf(prompt),
|
|
3010
|
+
eligible: !modified,
|
|
3011
|
+
modified,
|
|
3012
|
+
...modified ? { reason: "modified" } : {}
|
|
3013
|
+
};
|
|
3014
|
+
} finally {
|
|
3015
|
+
observation[Symbol.dispose]();
|
|
3016
|
+
}
|
|
3017
|
+
}
|
|
3018
|
+
/**
|
|
3019
|
+
* Latest `session/title` text at or before `throughSeq`. Title events are
|
|
3020
|
+
* log-only and merge-augmented, so the scan reads plain strings — the same
|
|
3021
|
+
* fold the title projection applies, without importing the title service.
|
|
3022
|
+
*/
|
|
3023
|
+
function foldedTitle(events, throughSeq) {
|
|
3024
|
+
let title;
|
|
3025
|
+
for (const event of events) {
|
|
3026
|
+
if (event.seq > throughSeq) break;
|
|
3027
|
+
if (event.type !== "session/title") continue;
|
|
3028
|
+
const value = event.data.title;
|
|
3029
|
+
if (typeof value === "string" && value.trim() !== "") title = value;
|
|
3030
|
+
}
|
|
3031
|
+
return title;
|
|
3032
|
+
}
|
|
3033
|
+
/**
|
|
3034
|
+
* The title the child still needs after the cut. A title event inside the
|
|
3035
|
+
* inherited prefix already lands on the fork; a title beyond that prefix
|
|
3036
|
+
* has to be re-applied — before the prompt, so an explicit
|
|
3037
|
+
* user-source title pins it ahead of any automatic generation.
|
|
3038
|
+
*/
|
|
3039
|
+
function titleToApply(events, forkSeq) {
|
|
3040
|
+
const title = foldedTitle(events, Number.MAX_SAFE_INTEGER);
|
|
3041
|
+
if (title === void 0 || foldedTitle(events, forkSeq) === title) return void 0;
|
|
3042
|
+
return title;
|
|
3043
|
+
}
|
|
3044
|
+
/**
|
|
3045
|
+
* Canonical request equality: replay is valid only when every stored field of
|
|
3046
|
+
* the original request matches — same target, same edit text, same preview
|
|
3047
|
+
* tokens. `content` is rejected at validation so it never reaches this point.
|
|
3048
|
+
*/
|
|
3049
|
+
function assertSameRequest(entry, request) {
|
|
3050
|
+
if (entry.source !== request.sessionId || entry.messageId !== request.messageId || entry.text !== request.text || entry.expectedRevision !== request.expectedRevision || entry.expectedInputRevision !== request.expectedInputRevision) throw new RetryError("session/retry-conflict", "the same requestId carries a different retry body");
|
|
3051
|
+
}
|
|
3052
|
+
/**
|
|
3053
|
+
* Re-check every preview guarantee on the live log, then record the resume
|
|
3054
|
+
* state a fork commit needs. The source log is never mutated, so this is the
|
|
3055
|
+
* only place the observed head matters: a stale check rejects before any
|
|
3056
|
+
* stage lands.
|
|
3057
|
+
*/
|
|
3058
|
+
async function validateCommit(services, request, sessionId, signal) {
|
|
3059
|
+
const { observation, events } = await observe(services, sessionId, signal);
|
|
3060
|
+
try {
|
|
3061
|
+
signal.throwIfAborted();
|
|
3062
|
+
if (isSubagentSession({ header: observation.header })) throw new RetryError("session/retry-blocked", "subagent sessions cannot be edited and resent");
|
|
3063
|
+
if (services.agents?.get(sessionId)?.status === "running" || hasLiveJobs(events)) throw new RetryError("session-manager/busy", "the session is busy");
|
|
3064
|
+
const prompt = findPrompt(events, request.messageId);
|
|
3065
|
+
if (prompt === void 0) throw new RetryError("session/retry-input", "the prompt is no longer in the session");
|
|
3066
|
+
if (!isOnActiveSurface(events, prompt.seq)) throw new RetryError("session/retry-revision", "the prompt is no longer on the active surface");
|
|
3067
|
+
if ((events[events.length - 1]?.seq ?? -1) !== request.expectedRevision) throw new RetryError("session/retry-revision", "the session changed after the preview");
|
|
3068
|
+
if (inputRevisionOf(prompt) !== request.expectedInputRevision) throw new RetryError("session/retry-input", "the prompt changed after the preview");
|
|
3069
|
+
if (request.allowModified !== true) throw new RetryError("session/retry-input", "resend requires explicit confirmation");
|
|
3070
|
+
if (!editedPromptBlocks(prompt.data.content, request.text).some((block) => block.type !== "text" || block.text !== "")) throw new RetryError("session/retry-input", "the resent prompt would be empty");
|
|
3071
|
+
const forkSeq = editForkBoundary(events, prompt.seq);
|
|
3072
|
+
if (forkSeq === void 0) throw new RetryError("session/retry-blocked", "the prompt has no drained conversation prefix to fork from");
|
|
3073
|
+
return {
|
|
3074
|
+
requestId: request.requestId,
|
|
3075
|
+
source: sessionId,
|
|
3076
|
+
messageId: request.messageId,
|
|
3077
|
+
text: request.text,
|
|
3078
|
+
expectedRevision: request.expectedRevision,
|
|
3079
|
+
expectedInputRevision: request.expectedInputRevision,
|
|
3080
|
+
forkSeq,
|
|
3081
|
+
title: titleToApply(events, forkSeq),
|
|
3082
|
+
forkStarted: false,
|
|
3083
|
+
prompted: false
|
|
3084
|
+
};
|
|
3085
|
+
} finally {
|
|
3086
|
+
observation[Symbol.dispose]();
|
|
3087
|
+
}
|
|
3088
|
+
}
|
|
3089
|
+
/**
|
|
3090
|
+
* The prompt the child receives keeps the original block order. Image
|
|
3091
|
+
* bytes are re-read from the source log first; file
|
|
3092
|
+
* receipts are bound to the target agent, so original files are staged
|
|
3093
|
+
* against the child — never the source — after every image resolved.
|
|
3094
|
+
*/
|
|
3095
|
+
async function promptContent(services, controller, entry, signal) {
|
|
3096
|
+
const { observation, events } = await observe(services, entry.source, signal);
|
|
3097
|
+
let blocks;
|
|
3098
|
+
try {
|
|
3099
|
+
const prompt = findPrompt(events, entry.messageId);
|
|
3100
|
+
if (!prompt || inputRevisionOf(prompt) !== entry.expectedInputRevision) throw new RetryError("session/retry-input", "the original prompt can no longer be read");
|
|
3101
|
+
blocks = editedPromptBlocks(prompt.data.content, entry.text);
|
|
3102
|
+
} finally {
|
|
3103
|
+
observation[Symbol.dispose]();
|
|
3104
|
+
}
|
|
3105
|
+
const parts = [];
|
|
3106
|
+
const files = [];
|
|
3107
|
+
for (const block of blocks) if (block.type === "text") parts.push(block);
|
|
3108
|
+
else if (block.type === "image") {
|
|
3109
|
+
if (typeof controller.attachment !== "function") throw new RetryError("session-manager/unavailable", "this runtime cannot read the original image");
|
|
3110
|
+
const image = await controller.attachment({
|
|
3111
|
+
sessionId: entry.source,
|
|
3112
|
+
attachmentId: block.attachment.attachmentId
|
|
3113
|
+
});
|
|
3114
|
+
if (typeof image?.attachment?.mediaType !== "string" || typeof image.data !== "string" || image.data === "") throw new RetryError("session/retry-failed", "the original image could not be read");
|
|
3115
|
+
parts.push({
|
|
3116
|
+
type: "image",
|
|
3117
|
+
mediaType: image.attachment.mediaType,
|
|
3118
|
+
data: image.data,
|
|
3119
|
+
...typeof image.attachment.name === "string" && image.attachment.name !== "" ? { name: image.attachment.name } : {}
|
|
3120
|
+
});
|
|
3121
|
+
} else if (block.type === "file") {
|
|
3122
|
+
files.push({
|
|
3123
|
+
index: parts.length,
|
|
3124
|
+
attachment: block.attachment
|
|
3125
|
+
});
|
|
3126
|
+
parts.push(void 0);
|
|
3127
|
+
}
|
|
3128
|
+
for (const file of files) {
|
|
3129
|
+
if (services.fileUploads === void 0 || services.attachments === void 0) throw new RetryError("session-manager/unavailable", "this runtime cannot copy the original file");
|
|
3130
|
+
const receiptId = (await services.fileUploads.uploadStream({
|
|
3131
|
+
sessionId: entry.childId,
|
|
3132
|
+
name: file.attachment.name,
|
|
3133
|
+
data: services.attachments.readFileStream(file.attachment, signal),
|
|
3134
|
+
signal
|
|
3135
|
+
}))?.receiptId;
|
|
3136
|
+
if (typeof receiptId !== "string" || receiptId === "") throw new RetryError("session/retry-failed", "the original file could not be staged");
|
|
3137
|
+
parts[file.index] = {
|
|
3138
|
+
type: "file",
|
|
3139
|
+
receiptId
|
|
3140
|
+
};
|
|
3141
|
+
}
|
|
3142
|
+
return parts;
|
|
3143
|
+
}
|
|
3144
|
+
/** The source's workspace and pin state, read from the controller's opening baseline. */
|
|
3145
|
+
async function workspacePlacement(workspace, sourceId, signal) {
|
|
3146
|
+
if (typeof workspace.follow !== "function") return { pinned: false };
|
|
3147
|
+
for await (const frame of workspace.follow(signal)) {
|
|
3148
|
+
if (frame?.type !== "baseline") return { pinned: false };
|
|
3149
|
+
const owner = (Array.isArray(frame.value?.items) ? frame.value.items : []).find((item) => Array.isArray(item?.sessionIds) && item.sessionIds.includes(sourceId));
|
|
3150
|
+
const pinned = Array.isArray(frame.value?.pinnedSessionIds) && frame.value.pinnedSessionIds.includes(sourceId);
|
|
3151
|
+
return {
|
|
3152
|
+
...typeof owner?.workspaceId === "string" && owner.workspaceId !== "" ? { workspaceId: owner.workspaceId } : {},
|
|
3153
|
+
pinned
|
|
3154
|
+
};
|
|
3155
|
+
}
|
|
3156
|
+
return { pinned: false };
|
|
3157
|
+
}
|
|
3158
|
+
/**
|
|
3159
|
+
* Make the fork look like the same chat: the sidebar slot just before the
|
|
3160
|
+
* source, the source's pin, then the source archived. Every step is
|
|
3161
|
+
* best-effort — the conversation itself is already correct without them.
|
|
3162
|
+
*/
|
|
3163
|
+
async function finalizeCommit(services, entry, signal) {
|
|
3164
|
+
const workspace = services.workspaceController;
|
|
3165
|
+
let archived = false;
|
|
3166
|
+
if (workspace !== void 0) {
|
|
3167
|
+
let placement = { pinned: false };
|
|
3168
|
+
try {
|
|
3169
|
+
placement = await workspacePlacement(workspace, entry.source, signal);
|
|
3170
|
+
} catch {}
|
|
3171
|
+
if (placement.workspaceId !== void 0 && typeof workspace.insertSessionBefore === "function") try {
|
|
3172
|
+
await workspace.insertSessionBefore({
|
|
3173
|
+
workspaceId: placement.workspaceId,
|
|
3174
|
+
sessionId: entry.childId,
|
|
3175
|
+
beforeSessionId: entry.source
|
|
3176
|
+
});
|
|
3177
|
+
} catch {}
|
|
3178
|
+
if (placement.pinned && typeof workspace.pinSession === "function") try {
|
|
3179
|
+
await workspace.pinSession({ sessionId: entry.childId });
|
|
3180
|
+
} catch {}
|
|
3181
|
+
if (typeof workspace.archiveSession === "function") try {
|
|
3182
|
+
await workspace.archiveSession({ sessionId: entry.source });
|
|
3183
|
+
archived = true;
|
|
3184
|
+
} catch {}
|
|
3185
|
+
}
|
|
3186
|
+
return {
|
|
3187
|
+
sessionId: entry.childId,
|
|
3188
|
+
sourceSessionId: entry.source,
|
|
3189
|
+
archived
|
|
3190
|
+
};
|
|
3191
|
+
}
|
|
3192
|
+
function requireController(services) {
|
|
3193
|
+
const controller = services.sessionController;
|
|
3194
|
+
if (controller === void 0 || typeof controller.fork !== "function" || typeof controller.prompt !== "function") throw new RetryError("session-manager/unavailable", "this runtime cannot fork a session");
|
|
3195
|
+
return controller;
|
|
3196
|
+
}
|
|
3197
|
+
/**
|
|
3198
|
+
* Resume recorded stages. A failed save retains the live child identity;
|
|
3199
|
+
* after restart, an intent with no child fails closed because fork has no
|
|
3200
|
+
* request-id contract. No stage proceeds until its prerequisite is durable.
|
|
3201
|
+
*/
|
|
3202
|
+
async function runCommit(services, journal, entry, signal) {
|
|
3203
|
+
try {
|
|
3204
|
+
const controller = requireController(services);
|
|
3205
|
+
if (entry.childId === void 0) {
|
|
3206
|
+
if (entry.forkStarted) throw new RetryError("session/retry-uncertain", "the previous fork outcome is unknown; inspect the session list");
|
|
3207
|
+
entry.forkStarted = true;
|
|
3208
|
+
await journal.put(entry);
|
|
3209
|
+
const childId = (await controller.fork({
|
|
3210
|
+
sessionId: entry.source,
|
|
3211
|
+
atSeq: entry.forkSeq
|
|
3212
|
+
}))?.sessionId;
|
|
3213
|
+
if (typeof childId !== "string" || childId === "") throw new RetryError("session/retry-failed", "the fork returned no session");
|
|
3214
|
+
entry.childId = childId;
|
|
3215
|
+
}
|
|
3216
|
+
await journal.put(entry);
|
|
3217
|
+
const forkedEntry = entry;
|
|
3218
|
+
if (forkedEntry.title !== void 0 && forkedEntry.renamed !== true && typeof controller.rename === "function") {
|
|
3219
|
+
await controller.rename({
|
|
3220
|
+
sessionId: forkedEntry.childId,
|
|
3221
|
+
title: forkedEntry.title
|
|
3222
|
+
});
|
|
3223
|
+
forkedEntry.renamed = true;
|
|
3224
|
+
await journal.put(entry);
|
|
3225
|
+
}
|
|
3226
|
+
if (forkedEntry.prompted !== true) {
|
|
3227
|
+
const content = await promptContent(services, controller, forkedEntry, signal);
|
|
3228
|
+
await controller.prompt({
|
|
3229
|
+
requestId: forkedEntry.requestId,
|
|
3230
|
+
sessionId: forkedEntry.childId,
|
|
3231
|
+
mode: "queue",
|
|
3232
|
+
content
|
|
3233
|
+
}, signal);
|
|
3234
|
+
forkedEntry.prompted = true;
|
|
3235
|
+
await journal.put(entry);
|
|
3236
|
+
}
|
|
3237
|
+
forkedEntry.result = await finalizeCommit(services, forkedEntry, signal);
|
|
3238
|
+
await journal.put(entry);
|
|
3239
|
+
return forkedEntry.result;
|
|
3240
|
+
} catch (error) {
|
|
3241
|
+
if (entry.forkStarted && !entry.childId && journal.get(entry.requestId)) throw new RetryError("session/retry-uncertain", "the previous fork outcome is unknown; inspect the session list");
|
|
3242
|
+
if (entry.childId) throw new RetryError("session/retry-pending", "resend is incomplete; retry the same request");
|
|
3243
|
+
throw commitFailure(error, signal);
|
|
3244
|
+
}
|
|
3245
|
+
}
|
|
3246
|
+
/** Host RemoteError codes worth surfacing verbatim; anything else is a generic commit failure. */
|
|
3247
|
+
function commitFailure(error, signal) {
|
|
3248
|
+
if (error instanceof RetryError || error instanceof RetryJournalError || error instanceof PresenceError) return error;
|
|
3249
|
+
if (signal.aborted) return error;
|
|
3250
|
+
if (error instanceof Error && error.name === "AbortError") return error;
|
|
3251
|
+
const code = error?.code;
|
|
3252
|
+
if (code === "session/not-found") return new RetryError("session-manager/not-found", "the session is not available");
|
|
3253
|
+
if (code === "session/agent-busy" || code === "session/writer-held") return new RetryError("session-manager/busy", "the session is busy");
|
|
3254
|
+
if (code === "session/fork-unavailable" || code === "session/migration-required") return new RetryError("session/retry-blocked", "the session cannot be forked here");
|
|
3255
|
+
return new RetryError("session/retry-failed", "the retry commit failed");
|
|
3256
|
+
}
|
|
3257
|
+
async function commitRetry(services, request, signal) {
|
|
3258
|
+
const sessionId = requireSessionId(request?.sessionId);
|
|
3259
|
+
if (typeof request.messageId !== "string" || request.messageId === "") throw new RetryError("session-manager/invalid-request", "messageId must be a non-empty string");
|
|
3260
|
+
if (!Number.isSafeInteger(request.expectedRevision) || request.expectedRevision < 0) throw new RetryError("session-manager/invalid-request", "expectedRevision must be a non-negative integer");
|
|
3261
|
+
if (typeof request.expectedInputRevision !== "string") throw new RetryError("session-manager/invalid-request", "expectedInputRevision must be a string");
|
|
3262
|
+
if (!UUID.test(request.requestId)) throw new RetryError("session-manager/invalid-request", "requestId must be a UUID");
|
|
3263
|
+
if (request.content !== void 0) throw new RetryError("session/retry-input", "content replacement is not supported; edit the text only");
|
|
3264
|
+
if (request.text !== void 0 && typeof request.text !== "string") throw new RetryError("session-manager/invalid-request", "text must be a string");
|
|
3265
|
+
if (request.allowModified !== true) throw new RetryError("session/retry-input", "resend requires explicit confirmation");
|
|
3266
|
+
requireController(services);
|
|
3267
|
+
const journal = services.retryJournal;
|
|
3268
|
+
if (!journal) throw new RetryError("session-manager/unavailable", "durable retry storage is unavailable");
|
|
3269
|
+
return journal.run(sessionId, request.requestId, async () => {
|
|
3270
|
+
const existing = journal.get(request.requestId);
|
|
3271
|
+
if (existing !== void 0) {
|
|
3272
|
+
assertSameRequest(existing, request);
|
|
3273
|
+
if (existing.result !== void 0) {
|
|
3274
|
+
try {
|
|
3275
|
+
await journal.put(existing);
|
|
3276
|
+
} catch {
|
|
3277
|
+
throw new RetryError("session/retry-pending", "resend completed but its receipt could not be saved");
|
|
3278
|
+
}
|
|
3279
|
+
return existing.result;
|
|
3280
|
+
}
|
|
3281
|
+
}
|
|
3282
|
+
const entry = existing ?? await validateCommit(services, request, sessionId, signal);
|
|
3283
|
+
return runCommit(services, journal, entry, signal);
|
|
3284
|
+
});
|
|
3285
|
+
}
|
|
3286
|
+
//#endregion
|
|
2612
3287
|
//#region src/rpc.ts
|
|
2613
3288
|
const PROFILE_ERROR = "Session Manager requires the active profile directory";
|
|
2614
3289
|
function isAbort(error) {
|
|
2615
3290
|
return error instanceof Error && error.name === "AbortError";
|
|
2616
3291
|
}
|
|
2617
3292
|
function failure(error) {
|
|
2618
|
-
if (error instanceof PresenceError) return {
|
|
3293
|
+
if (error instanceof PresenceError || error instanceof RetryError || error instanceof RetryJournalError) return {
|
|
2619
3294
|
ok: false,
|
|
2620
3295
|
error: {
|
|
2621
3296
|
code: error.code,
|
|
@@ -2653,6 +3328,21 @@ function registerSessionManagerRpc(root, transport, config, models, modelStore)
|
|
|
2653
3328
|
const profileDir = profile.dir;
|
|
2654
3329
|
const suggestions = createSuggestionCache();
|
|
2655
3330
|
const lifetime = new AbortController();
|
|
3331
|
+
let journal;
|
|
3332
|
+
const retryJournal = async () => {
|
|
3333
|
+
lifetime.signal.throwIfAborted();
|
|
3334
|
+
const storage = root.get("storageDomain");
|
|
3335
|
+
if (!storage) return void 0;
|
|
3336
|
+
return journal ??= (async () => {
|
|
3337
|
+
const domain = await storage.open(retryDomain);
|
|
3338
|
+
try {
|
|
3339
|
+
return new RetryJournal(domain);
|
|
3340
|
+
} catch (error) {
|
|
3341
|
+
await domain.close();
|
|
3342
|
+
throw error;
|
|
3343
|
+
}
|
|
3344
|
+
})();
|
|
3345
|
+
};
|
|
2656
3346
|
const unregister = registerHostRpc(transport, TEMP_WORKSPACE_CHANNEL, async (endpoint, payload, signal) => {
|
|
2657
3347
|
const merged = AbortSignal.any([signal, lifetime.signal]);
|
|
2658
3348
|
try {
|
|
@@ -2679,6 +3369,64 @@ function registerSessionManagerRpc(root, transport, config, models, modelStore)
|
|
|
2679
3369
|
value: await suggestContinuation(services, config, models(), suggestions, payload, merged)
|
|
2680
3370
|
};
|
|
2681
3371
|
}
|
|
3372
|
+
if (endpoint === "edit.prepare") {
|
|
3373
|
+
if (!payloadKeys(payload, ["sessionId", "promptSeq"])) throw new PresenceError("session-manager/invalid-request", "invalid edit request");
|
|
3374
|
+
return {
|
|
3375
|
+
ok: true,
|
|
3376
|
+
value: await prepareEdit(sessionQuery, payload, merged)
|
|
3377
|
+
};
|
|
3378
|
+
}
|
|
3379
|
+
if (endpoint === "edit.file") {
|
|
3380
|
+
if (!payloadKeys(payload, [
|
|
3381
|
+
"sessionId",
|
|
3382
|
+
"promptSeq",
|
|
3383
|
+
"attachmentId"
|
|
3384
|
+
])) throw new PresenceError("session-manager/invalid-request", "invalid attachment request");
|
|
3385
|
+
const fileServices = {
|
|
3386
|
+
attachments: root.get("attachments"),
|
|
3387
|
+
fileUploads: root.get("fileUploads")
|
|
3388
|
+
};
|
|
3389
|
+
return {
|
|
3390
|
+
ok: true,
|
|
3391
|
+
value: await stageEditFile(sessionQuery, fileServices, payload, merged)
|
|
3392
|
+
};
|
|
3393
|
+
}
|
|
3394
|
+
if (endpoint === "retry.capabilities") {
|
|
3395
|
+
if (!payloadKeys(payload, [])) throw new PresenceError("session-manager/invalid-request", "invalid retry capabilities request");
|
|
3396
|
+
return {
|
|
3397
|
+
ok: true,
|
|
3398
|
+
value: retryCapabilities({
|
|
3399
|
+
...services,
|
|
3400
|
+
retryJournal: await retryJournal()
|
|
3401
|
+
})
|
|
3402
|
+
};
|
|
3403
|
+
}
|
|
3404
|
+
if (endpoint === "retry.preview") {
|
|
3405
|
+
if (!payloadKeys(payload, ["sessionId", "messageId"])) throw new PresenceError("session-manager/invalid-request", "invalid retry preview request");
|
|
3406
|
+
return {
|
|
3407
|
+
ok: true,
|
|
3408
|
+
value: await previewRetry(services, payload, merged)
|
|
3409
|
+
};
|
|
3410
|
+
}
|
|
3411
|
+
if (endpoint === "retry.commit") {
|
|
3412
|
+
if (!payloadKeys(payload, [
|
|
3413
|
+
"sessionId",
|
|
3414
|
+
"messageId",
|
|
3415
|
+
"expectedRevision",
|
|
3416
|
+
"expectedInputRevision",
|
|
3417
|
+
"requestId",
|
|
3418
|
+
"text",
|
|
3419
|
+
"content",
|
|
3420
|
+
"allowModified"
|
|
3421
|
+
])) throw new PresenceError("session-manager/invalid-request", "invalid retry commit request");
|
|
3422
|
+
return {
|
|
3423
|
+
ok: true,
|
|
3424
|
+
value: await commitRetry({
|
|
3425
|
+
...services,
|
|
3426
|
+
retryJournal: await retryJournal()
|
|
3427
|
+
}, payload, merged)
|
|
3428
|
+
};
|
|
3429
|
+
}
|
|
2682
3430
|
if (endpoint === "config.read") {
|
|
2683
3431
|
if (!payloadKeys(payload, [])) throw new PresenceError("session-manager/invalid-request", "invalid config request");
|
|
2684
3432
|
return {
|
|
@@ -2698,10 +3446,11 @@ function registerSessionManagerRpc(root, transport, config, models, modelStore)
|
|
|
2698
3446
|
return failure(error);
|
|
2699
3447
|
}
|
|
2700
3448
|
}, { requirePayload: true });
|
|
2701
|
-
return () => {
|
|
3449
|
+
return async () => {
|
|
2702
3450
|
lifetime.abort();
|
|
2703
3451
|
suggestions.clear();
|
|
2704
3452
|
unregister();
|
|
3453
|
+
await journal?.then((value) => value.close(), () => {});
|
|
2705
3454
|
};
|
|
2706
3455
|
}
|
|
2707
3456
|
//#endregion
|