dsh-plugin-message-edit 1.0.0 → 1.1.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/LICENSE +24 -24
- package/README.en.md +18 -3
- package/README.md +14 -3
- package/cordis.patch.yml +7 -7
- package/docs/ARCHITECTURE.md +167 -155
- package/docs/DEVELOPMENT.md +143 -71
- package/docs/TREE_DATA_MODEL.md +96 -96
- package/lib/client.js +1666 -1642
- package/lib/index.js +832 -823
- package/lib/session-record.js +52 -0
- package/lib/tree-logic.js +343 -343
- package/package.json +22 -3
- package/plugin.client.js +1666 -1642
package/lib/index.js
CHANGED
|
@@ -1,830 +1,839 @@
|
|
|
1
|
-
// dsh-plugin-message-tree — host half.
|
|
2
|
-
//
|
|
3
|
-
// Owns the /message-tree HTTP route. POST builds a version branch: a new
|
|
4
|
-
// session seeded with every event BEFORE the edited turn (true rewind, unlike
|
|
5
|
-
// the client-side session fork which cuts after a turn), a durable
|
|
6
|
-
// `message-tree/version` marker naming what changed, and the edited prompt
|
|
7
|
-
// queued for a fresh answer. GET projects the whole version tree for the
|
|
8
|
-
// client's ‹ › rings and tree view.
|
|
9
|
-
//
|
|
10
|
-
// The branch-transaction shape is derived from dsh-message-edit
|
|
11
|
-
// (MIT © Moeblack) — simplified to ChatGPT semantics: user-message edits and
|
|
12
|
-
// turn retries only, always truncating downstream.
|
|
13
|
-
|
|
14
|
-
import { attachParentId, ancestorChainFromLog, collectFamily } from './tree-logic.js';
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
//
|
|
18
|
-
//
|
|
19
|
-
//
|
|
20
|
-
//
|
|
21
|
-
//
|
|
22
|
-
//
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
const
|
|
30
|
-
|
|
31
|
-
'
|
|
32
|
-
'
|
|
33
|
-
'
|
|
34
|
-
'
|
|
35
|
-
|
|
36
|
-
//
|
|
37
|
-
//
|
|
38
|
-
//
|
|
39
|
-
//
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
if (
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
current
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
}
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
}
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
}
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
if (turn
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
}
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
}
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
case '
|
|
138
|
-
|
|
139
|
-
}
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
const
|
|
146
|
-
const
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
1
|
+
// dsh-plugin-message-tree — host half.
|
|
2
|
+
//
|
|
3
|
+
// Owns the /message-tree HTTP route. POST builds a version branch: a new
|
|
4
|
+
// session seeded with every event BEFORE the edited turn (true rewind, unlike
|
|
5
|
+
// the client-side session fork which cuts after a turn), a durable
|
|
6
|
+
// `message-tree/version` marker naming what changed, and the edited prompt
|
|
7
|
+
// queued for a fresh answer. GET projects the whole version tree for the
|
|
8
|
+
// client's ‹ › rings and tree view.
|
|
9
|
+
//
|
|
10
|
+
// The branch-transaction shape is derived from dsh-message-edit
|
|
11
|
+
// (MIT © Moeblack) — simplified to ChatGPT semantics: user-message edits and
|
|
12
|
+
// turn retries only, always truncating downstream.
|
|
13
|
+
|
|
14
|
+
import { attachParentId, ancestorChainFromLog, collectFamily } from './tree-logic.js';
|
|
15
|
+
import { sessionEventCount, sessionRecord, branchSeedOptions, readSessionRecord } from './session-record.js';
|
|
16
|
+
|
|
17
|
+
// The package is named dsh-plugin-message-edit, but the route, the cordis id
|
|
18
|
+
// and the durable event type below deliberately keep the `message-tree`
|
|
19
|
+
// spelling. Moeblack's dsh-message-edit already owns `/message-edit`, the
|
|
20
|
+
// cordis id `message-edit` and the event type `message-edit/version`; reusing
|
|
21
|
+
// those would collide whenever both plugins are installed. Keeping ours
|
|
22
|
+
// distinct also preserves the version links already written into existing
|
|
23
|
+
// session logs, which name this type verbatim.
|
|
24
|
+
/** Same-origin endpoint owned by this plugin's host half. */
|
|
25
|
+
const MESSAGE_TREE_PATH = '/message-tree';
|
|
26
|
+
/** Durable version-marker schema. */
|
|
27
|
+
const MESSAGE_TREE_SCHEMA = 1;
|
|
28
|
+
|
|
29
|
+
const name = 'message-tree';
|
|
30
|
+
const inject = [
|
|
31
|
+
'sessions',
|
|
32
|
+
'agents',
|
|
33
|
+
'sessionPersistence',
|
|
34
|
+
'sessionQuery',
|
|
35
|
+
'webServer',
|
|
36
|
+
// Used three ways: to flag archived versions in the tree payload (the app
|
|
37
|
+
// cannot navigate to an archived session, so the client unarchives before
|
|
38
|
+
// opening), to unarchive on demand, and to attach a new version to the same
|
|
39
|
+
// sidebar workspace group as its parent. Plain string: cordis uses array
|
|
40
|
+
// inject entries as service names directly.
|
|
41
|
+
'workspaceRegistry',
|
|
42
|
+
];
|
|
43
|
+
|
|
44
|
+
/* ------------------------------------------------------------ log reading -- */
|
|
45
|
+
|
|
46
|
+
/** Fold complete turn brackets; an open tail is deliberately absent. */
|
|
47
|
+
function closedTurns(events) {
|
|
48
|
+
const result = [];
|
|
49
|
+
let current;
|
|
50
|
+
for (const event of events) {
|
|
51
|
+
if (event.type === 'turn/start') {
|
|
52
|
+
current = { turn: event.data.turn, startSeq: event.seq };
|
|
53
|
+
continue;
|
|
54
|
+
}
|
|
55
|
+
if (current === undefined) continue;
|
|
56
|
+
if (event.type === 'user/message' && current.user === undefined && event.data.source.kind === 'user') {
|
|
57
|
+
current.user = event;
|
|
58
|
+
continue;
|
|
59
|
+
}
|
|
60
|
+
if (event.type === 'turn/end' && event.data.turn === current.turn) {
|
|
61
|
+
result.push({ ...current, endSeq: event.seq });
|
|
62
|
+
current = undefined;
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
return result;
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
function userText(message) {
|
|
69
|
+
return message.content.filter((block) => block.type === 'text').map((block) => block.text).join('\n');
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
function cloneUser(message, content = structuredClone(message.content)) {
|
|
73
|
+
return Object.freeze({
|
|
74
|
+
id: crypto.randomUUID(),
|
|
75
|
+
role: 'user',
|
|
76
|
+
content: Object.freeze(content),
|
|
77
|
+
source: Object.freeze({ kind: 'user' }),
|
|
78
|
+
});
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
function replaceTextBlock(content, blockIndex, text) {
|
|
82
|
+
const block = content[blockIndex];
|
|
83
|
+
if (block?.type !== 'text') throw new Error('所选内容块不是可编辑文本。');
|
|
84
|
+
return content.map((candidate, index) => index === blockIndex ? { ...candidate, text } : structuredClone(candidate));
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
/* ---------------------------------------------------------------- planning -- */
|
|
88
|
+
|
|
89
|
+
function pairVersionEffect(sourceSessionId, effect) {
|
|
90
|
+
return {
|
|
91
|
+
schemaVersion: MESSAGE_TREE_SCHEMA,
|
|
92
|
+
effect: { ...effect, id: crypto.randomUUID() },
|
|
93
|
+
inverse: { kind: 'restore-version', sessionId: sourceSessionId },
|
|
94
|
+
};
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
/** Edit a user message: rewind to before its turn, queue the edited prompt. */
|
|
98
|
+
function editPlan(operation, turns) {
|
|
99
|
+
const turn = turns.find((candidate) => operation.eventSeq > candidate.startSeq && operation.eventSeq < candidate.endSeq);
|
|
100
|
+
if (turn === undefined) throw new Error('所选消息不属于已落定回合。');
|
|
101
|
+
if (turn.user === undefined || turn.user.seq !== operation.eventSeq) throw new Error('所选消息不是用户消息。');
|
|
102
|
+
const before = turn.user.data.content[operation.blockIndex];
|
|
103
|
+
if (before?.type !== 'text') throw new Error('所选用户消息块不是文本。');
|
|
104
|
+
const edited = cloneUser(turn.user.data, replaceTextBlock(turn.user.data.content, operation.blockIndex, operation.text));
|
|
105
|
+
return {
|
|
106
|
+
boundary: turn.startSeq - 1,
|
|
107
|
+
version: pairVersionEffect(operation.sessionId, {
|
|
108
|
+
operation: 'edit',
|
|
109
|
+
targetTurn: turn.turn,
|
|
110
|
+
targetEventSeq: turn.user.seq,
|
|
111
|
+
before: before.text,
|
|
112
|
+
after: operation.text,
|
|
113
|
+
}),
|
|
114
|
+
queuedUsers: [edited],
|
|
115
|
+
};
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
/** Regenerate a turn: rewind to before it, queue the original prompt again. */
|
|
119
|
+
function retryPlan(sessionId, turnNumber, turns) {
|
|
120
|
+
const turn = turns.find((candidate) => candidate.turn === turnNumber);
|
|
121
|
+
if (turn?.user === undefined) throw new Error('所选回合没有可重放的用户输入。');
|
|
122
|
+
return {
|
|
123
|
+
boundary: turn.startSeq - 1,
|
|
124
|
+
version: pairVersionEffect(sessionId, {
|
|
125
|
+
operation: 'retry',
|
|
126
|
+
targetTurn: turn.turn,
|
|
127
|
+
targetEventSeq: turn.user.seq,
|
|
128
|
+
before: userText(turn.user.data),
|
|
129
|
+
}),
|
|
130
|
+
queuedUsers: [cloneUser(turn.user.data)],
|
|
131
|
+
};
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
function planOperation(operation, events) {
|
|
135
|
+
const turns = closedTurns(events);
|
|
136
|
+
switch (operation.action) {
|
|
137
|
+
case 'edit': return editPlan(operation, turns);
|
|
138
|
+
case 'retry': return retryPlan(operation.sessionId, operation.turn, turns);
|
|
139
|
+
}
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
/* -------------------------------------------------------- branch creation -- */
|
|
143
|
+
|
|
144
|
+
function agentOptions(events, fallback) {
|
|
145
|
+
const config = events.findLast((event) => event.type === 'request/header')?.data.header.config;
|
|
146
|
+
const provider = config?.provider ?? fallback?.provider;
|
|
147
|
+
const model = config?.model ?? fallback?.model;
|
|
148
|
+
if (provider === undefined || provider.length === 0 || model === undefined || model.length === 0) {
|
|
149
|
+
throw new Error('无法从会话历史解析模型路由。');
|
|
150
|
+
}
|
|
150
151
|
const maxTokens = config?.maxTokens ?? fallback?.maxTokens;
|
|
151
|
-
|
|
152
|
-
}
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
await
|
|
169
|
-
}
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
if (boundary
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
}
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
events.
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
//
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
152
|
+
const reasoningEffort = config?.reasoningEffort ?? fallback?.reasoningEffort;
|
|
153
|
+
return { provider, model, ...maxTokens === undefined ? {} : { maxTokens },
|
|
154
|
+
...reasoningEffort === undefined ? {} : { reasoningEffort } };
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
async function withSourceAgent(ctx, sessionId, operation) {
|
|
158
|
+
let handle;
|
|
159
|
+
let agent = ctx.agents.get(sessionId);
|
|
160
|
+
if (agent === undefined) {
|
|
161
|
+
const snapshot = await readSessionRecord(ctx, sessionId);
|
|
162
|
+
handle = await ctx.agents.resume({
|
|
163
|
+
resumeSessionId: sessionId,
|
|
164
|
+
agentOptions: agentOptions(snapshot.events),
|
|
165
|
+
});
|
|
166
|
+
agent = handle.agent;
|
|
167
|
+
}
|
|
168
|
+
try {
|
|
169
|
+
return await agent.runMaintenance(async () => operation(agent));
|
|
170
|
+
} finally {
|
|
171
|
+
await handle?.dispose();
|
|
172
|
+
}
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
function inheritedSeed(source, boundary) {
|
|
176
|
+
if (boundary === -1) return [];
|
|
177
|
+
const boundaryEvent = source.events[boundary];
|
|
178
|
+
if (boundary < 0 || boundaryEvent === undefined || boundaryEvent.seq !== boundary) {
|
|
179
|
+
throw new Error('分支边界不是连续会话事件。');
|
|
180
|
+
}
|
|
181
|
+
return source.events.slice(0, boundary + 1);
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
function versionSeed(source, plan, childId) {
|
|
185
|
+
const events = inheritedSeed(source, plan.boundary);
|
|
186
|
+
const inheritedLength = events.length;
|
|
187
|
+
events.push({
|
|
188
|
+
type: 'message-tree/version',
|
|
189
|
+
seq: events.length,
|
|
190
|
+
time: Date.now(),
|
|
191
|
+
// Ownership is explicit: modern DSH requires the entire constructor seed
|
|
192
|
+
// to be inherited, so log position alone cannot identify our own marker.
|
|
193
|
+
data: { ...plan.version, sessionId: childId },
|
|
194
|
+
// Plugin event types live outside the harness vocabulary; without this
|
|
195
|
+
// marker the read path refuses to interpret the whole session log.
|
|
196
|
+
ignorable: true,
|
|
197
|
+
});
|
|
198
|
+
return { events, inheritedLength: typeof source.header.isSeeded === 'boolean' ? events.length : inheritedLength };
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
function sessionPreset(session) {
|
|
202
|
+
for (let index = session.events.length - 1; index >= 0; index -= 1) {
|
|
203
|
+
const event = session.events[index];
|
|
204
|
+
if (event?.type === 'agent-preset/selected') return event.data.agentPreset;
|
|
205
|
+
}
|
|
206
|
+
return session.header.agentPreset;
|
|
207
|
+
}
|
|
208
|
+
|
|
204
209
|
async function loadSessionRecord(ctx, sessionId) {
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
nodes.
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
const
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
async function createVersionAgent(ctx, source, childId, plan, options) {
|
|
253
|
-
const seed = versionSeed(source, plan);
|
|
254
|
-
const presets = ctx.get('agentPresets');
|
|
255
|
-
const presetId = sessionPreset(source);
|
|
256
|
-
let agentPreset;
|
|
257
|
-
let setup;
|
|
210
|
+
return readSessionRecord(ctx, sessionId);
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
function sessionRecordId(session) {
|
|
214
|
+
return session.header?.id ?? session.id;
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
function sessionTargetTurn(session) {
|
|
218
|
+
return ownVersionEvent(session)?.effect.targetTurn;
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
/**
|
|
222
|
+
* Repeated edits of the same message must hang off the original, not off
|
|
223
|
+
* the previous edit. Walk the parent chain and stop at the first session
|
|
224
|
+
* that is not itself a version of `targetTurn`.
|
|
225
|
+
*/
|
|
226
|
+
async function resolveAttachSession(ctx, sourceSession, targetTurn) {
|
|
227
|
+
const nodes = new Map();
|
|
228
|
+
let session = sourceSession;
|
|
229
|
+
const seen = new Set();
|
|
230
|
+
while (session) {
|
|
231
|
+
const id = sessionRecordId(session);
|
|
232
|
+
if (id === undefined || seen.has(id)) break;
|
|
233
|
+
seen.add(id);
|
|
234
|
+
nodes.set(id, {
|
|
235
|
+
targetTurn: sessionTargetTurn(session),
|
|
236
|
+
parentSessionId: session.header.parentSession,
|
|
237
|
+
session,
|
|
238
|
+
});
|
|
239
|
+
const parentId = session.header.parentSession;
|
|
240
|
+
if (parentId === undefined || nodes.has(parentId)) break;
|
|
241
|
+
session = await loadSessionRecord(ctx, parentId);
|
|
242
|
+
}
|
|
243
|
+
const byId = new Map([...nodes].map(([id, node]) => [id, {
|
|
244
|
+
targetTurn: node.targetTurn,
|
|
245
|
+
parentSessionId: node.parentSessionId,
|
|
246
|
+
}]));
|
|
247
|
+
const attachId = attachParentId(byId, sessionRecordId(sourceSession), targetTurn);
|
|
248
|
+
return nodes.get(attachId)?.session ?? sourceSession;
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
async function createVersionAgent(ctx, source, childId, plan, options) {
|
|
252
|
+
const seed = versionSeed(source, plan, childId);
|
|
253
|
+
const presets = ctx.get('agentPresets');
|
|
254
|
+
const presetId = sessionPreset(source);
|
|
255
|
+
let agentPreset;
|
|
258
256
|
if (presets !== undefined && presetId !== undefined) {
|
|
259
257
|
const resolved = (await presets.resolve(presetId)).id;
|
|
260
258
|
agentPreset = resolved;
|
|
261
|
-
setup = async (agentCtx) => { await presets.mount(agentCtx, resolved); };
|
|
262
|
-
}
|
|
263
|
-
const child = await ctx.agents.create({
|
|
264
|
-
sessionId: childId,
|
|
265
|
-
seed: seed.events,
|
|
266
|
-
meta: {
|
|
267
|
-
...source.header.cwd === undefined ? {} : { cwd: source.header.cwd },
|
|
268
|
-
parentSession: source.id ?? source.header.id,
|
|
269
|
-
seedLength: seed.inheritedLength,
|
|
270
|
-
// NOTE: deliberately NOT `origin: 'subagent'`, and deliberately not
|
|
271
|
-
// hidden from the sidebar at all.
|
|
272
|
-
//
|
|
273
|
-
// Two hiding approaches are known-bad:
|
|
274
|
-
//
|
|
275
|
-
// - `origin: 'subagent'` keeps versions out of the sidebar (the
|
|
276
|
-
// workspace list filters on `origin !== 'subagent'`), but the API
|
|
277
|
-
// proxy fences the same field: `hasApiRemoteSubagentOwner` treats
|
|
278
|
-
// such a session as owned by subagent routing and refuses both
|
|
279
|
-
// `session.cancel` and model selection with
|
|
280
|
-
// agent-busy: session "..." is owned by subagent routing
|
|
281
|
-
// so every edited or retried message became impossible to stop.
|
|
282
|
-
// The schema accepts no third `origin` value to hide behind.
|
|
283
|
-
//
|
|
284
|
-
// - Archiving the child (`workspaceRegistry.archiveSession`) hides it
|
|
285
|
-
// without fencing it, but the app cannot NAVIGATE to an archived
|
|
286
|
-
// session: opening one bounces to the workspace picker, so edit,
|
|
287
|
-
// retry and version switching all dead-ended on the home screen.
|
|
288
|
-
//
|
|
289
|
-
// Versions therefore appear in the sidebar as ordinary sessions. That
|
|
290
|
-
// is cosmetic; stopping, model switching and navigation all work.
|
|
291
|
-
...agentPreset === undefined ? {} : { agentPreset },
|
|
292
|
-
},
|
|
293
|
-
agentOptions: options,
|
|
294
|
-
...setup === undefined ? {} : { setup },
|
|
295
|
-
});
|
|
296
|
-
try {
|
|
297
|
-
await ctx.sessions.flush(child.agent.session);
|
|
298
|
-
return child;
|
|
299
|
-
} catch (error) {
|
|
300
|
-
await child.dispose();
|
|
301
|
-
throw error;
|
|
302
|
-
}
|
|
303
|
-
}
|
|
304
|
-
|
|
305
|
-
async function recoverOperation(inverses) {
|
|
306
|
-
const failures = [];
|
|
307
|
-
for (const inverse of inverses.reverse()) {
|
|
308
|
-
try {
|
|
309
|
-
await inverse();
|
|
310
|
-
} catch (error) {
|
|
311
|
-
failures.push(error);
|
|
312
|
-
}
|
|
313
|
-
}
|
|
314
|
-
if (failures.length > 0) throw new AggregateError(failures, '版本操作恢复失败。');
|
|
315
|
-
}
|
|
316
|
-
|
|
317
|
-
/**
|
|
318
|
-
* Stop a still-running turn on `sessionId` so an edit can fork away from it.
|
|
319
|
-
*
|
|
320
|
-
* Two reasons this is needed. Editing forks from the source session but does
|
|
321
|
-
* not stop it, so the superseded turn keeps streaming and keeps spending
|
|
322
|
-
* tokens. And `runMaintenance` throws outright unless the agent is idle, which
|
|
323
|
-
* is why editing was blocked while a turn was live at all.
|
|
324
|
-
*
|
|
325
|
-
* Cancelling the parent is also what stops its subagents: a subagent session is
|
|
326
|
-
* fenced from the ordinary cancel path ("owned by subagent routing"), but the
|
|
327
|
-
* subtree is owned by the parent's fiber and unwinds with it.
|
|
328
|
-
*
|
|
329
|
-
* `cancel` only signals the abort; `whenIdle` waits for the phase to actually
|
|
330
|
-
* settle, without which the `runMaintenance` that follows can still throw.
|
|
331
|
-
*/
|
|
332
|
-
async function stopRunningTurn(ctx, sessionId) {
|
|
333
|
-
const agent = ctx.agents.get(sessionId);
|
|
334
|
-
if (agent === undefined) return false;
|
|
335
|
-
try {
|
|
336
|
-
agent.cancel({ kind: 'user' });
|
|
337
|
-
await agent.whenIdle();
|
|
338
|
-
return true;
|
|
339
|
-
} catch (error) {
|
|
340
|
-
// An agent that was already finishing is not an error for our purposes:
|
|
341
|
-
// the goal is only that it is no longer running.
|
|
342
|
-
return false;
|
|
343
|
-
}
|
|
344
|
-
}
|
|
345
|
-
|
|
346
|
-
/** Archived session ids, as a Set; empty when the registry cannot say. */
|
|
347
|
-
function archivedSessionIdSet(ctx) {
|
|
348
|
-
try {
|
|
349
|
-
const ids = ctx.get('workspaceRegistry')?.archivedSessionIds;
|
|
350
|
-
return new Set(Array.isArray(ids) ? ids : []);
|
|
351
|
-
} catch (error) {
|
|
352
|
-
return new Set();
|
|
353
|
-
}
|
|
354
|
-
}
|
|
355
|
-
|
|
356
|
-
/**
|
|
357
|
-
* Unarchive one session so the app can navigate to it.
|
|
358
|
-
*
|
|
359
|
-
* The app cannot open an archived session — it bounces to the workspace
|
|
360
|
-
* picker — and archiving versions to declutter the sidebar is a natural thing
|
|
361
|
-
* to do, so paging the ring onto one must unarchive it first. This dsh build
|
|
362
|
-
* has no unarchive API anywhere (the registry's archiveSession only adds), so
|
|
363
|
-
* this mirrors archiveSession's own state discipline: same operation queue,
|
|
364
|
-
* same durable state write. The API proxy watches this state and broadcasts
|
|
365
|
-
* host/archived-sessions-changed, so the sidebar updates live.
|
|
366
|
-
*/
|
|
367
|
-
async function activateVersion(ctx, sessionId) {
|
|
368
|
-
const registry = ctx.get('workspaceRegistry');
|
|
369
|
-
if (registry === undefined) throw new Error('workspaceRegistry 不可用,无法取消归档。');
|
|
370
|
-
if (!registry.archivedSessionIds.includes(sessionId)) return { unarchived: false };
|
|
371
|
-
if (typeof registry.enqueueOperation !== 'function'
|
|
372
|
-
|| typeof registry.requireState !== 'function'
|
|
373
|
-
|| typeof registry.setState !== 'function') {
|
|
374
|
-
throw new Error('此 dsh 版本未提供取消归档的途径。');
|
|
375
259
|
}
|
|
376
|
-
|
|
377
|
-
|
|
378
|
-
|
|
379
|
-
|
|
380
|
-
|
|
381
|
-
|
|
382
|
-
});
|
|
383
|
-
});
|
|
384
|
-
return { unarchived: true };
|
|
385
|
-
}
|
|
386
|
-
|
|
387
|
-
/**
|
|
388
|
-
* Keep a version in the same sidebar workspace group as its tree parent.
|
|
389
|
-
* Versions inherit the parent's cwd but were never attached to its workspace,
|
|
390
|
-
* so they showed up as stray ungrouped rows. Failure is cosmetic — the
|
|
391
|
-
* version works either way — so it never fails the edit.
|
|
392
|
-
*/
|
|
393
|
-
async function attachToParentWorkspace(ctx, parentId, childId) {
|
|
394
|
-
try {
|
|
395
|
-
const registry = ctx.get('workspaceRegistry');
|
|
396
|
-
const workspace = registry?.list().find((w) => w.sessionIds.includes(parentId));
|
|
397
|
-
if (workspace !== undefined) await workspace.attachSession(childId);
|
|
398
|
-
} catch (error) {}
|
|
399
|
-
}
|
|
400
|
-
|
|
401
|
-
/** All message-tree/version events of one session's full log, in seq order. */
|
|
402
|
-
async function versionMarkers(ctx, sessionId) {
|
|
403
|
-
const { events } = await ctx.sessionQuery.readSession(sessionId);
|
|
404
|
-
return events.filter((event) => event.type === 'message-tree/version');
|
|
405
|
-
}
|
|
406
|
-
|
|
407
|
-
/**
|
|
408
|
-
* Assemble one conversation family, bridging sessions the user has deleted.
|
|
409
|
-
*
|
|
410
|
-
* dsh's traceSession stops at the first missing parent, so deleting one
|
|
411
|
-
* version used to fragment the family: siblings of a deleted original lost
|
|
412
|
-
* their ‹k/N› counters entirely, and everything below a deleted chain link
|
|
413
|
-
* vanished from the Versions tree. But every version's seed inherits its
|
|
414
|
-
* ancestors' `message-tree/version` markers, so a deleted ancestor's identity,
|
|
415
|
-
* parent and target turn all survive in its descendants' logs. This walks
|
|
416
|
-
* surviving headers where possible and recovers the rest from those markers,
|
|
417
|
-
* emitting ghost entries for the deleted sessions so the tree stays whole.
|
|
418
|
-
*
|
|
419
|
-
* Families never span working directories (a version inherits its source's
|
|
420
|
-
* cwd), so orphan logs outside the target's cwd are never read.
|
|
421
|
-
*
|
|
422
|
-
* @returns { rootId, flat: [{ entry, depth }], recordsById } where each entry
|
|
423
|
-
* is { id, parentId?, createdAt, ghost, marker? }.
|
|
424
|
-
*/
|
|
425
|
-
async function assembleFamily(ctx, sessionId) {
|
|
426
|
-
const records = await ctx.sessionQuery.listSessions();
|
|
427
|
-
const recordsById = new Map(records.map((record) => [record.header.id, record]));
|
|
428
|
-
const target = recordsById.get(sessionId);
|
|
429
|
-
if (target === undefined) throw new Error(`session "${sessionId}" not found`);
|
|
430
|
-
|
|
431
|
-
const ghostInfo = new Map();
|
|
432
|
-
const absorbChain = (chain) => {
|
|
433
|
-
for (let i = 0; i < chain.length; i++) {
|
|
434
|
-
const link = chain[i];
|
|
435
|
-
if (recordsById.has(link.sessionId)) continue;
|
|
436
|
-
const info = ghostInfo.get(link.sessionId) ?? {};
|
|
437
|
-
if (link.marker !== undefined) info.marker = link.marker;
|
|
438
|
-
if (i + 1 < chain.length) info.parentId = chain[i + 1].sessionId;
|
|
439
|
-
ghostInfo.set(link.sessionId, info);
|
|
440
|
-
}
|
|
260
|
+
const setup = async (agentCtx, agent) => {
|
|
261
|
+
// Rewinding before turn/start also rewinds before the durable inbox claim.
|
|
262
|
+
// Clear BOTH inherited queues before publication can start the agent. The
|
|
263
|
+
// original input and any queued follow-ups must not run in the new branch.
|
|
264
|
+
agent?.inbox?.clear();
|
|
265
|
+
if (agentPreset !== undefined) await presets.mount(agentCtx, agentPreset);
|
|
441
266
|
};
|
|
442
|
-
|
|
443
|
-
|
|
444
|
-
|
|
445
|
-
|
|
446
|
-
|
|
447
|
-
|
|
448
|
-
|
|
449
|
-
|
|
450
|
-
|
|
451
|
-
|
|
452
|
-
|
|
453
|
-
|
|
454
|
-
|
|
455
|
-
|
|
456
|
-
|
|
457
|
-
|
|
458
|
-
|
|
459
|
-
|
|
460
|
-
|
|
461
|
-
|
|
462
|
-
|
|
463
|
-
|
|
464
|
-
|
|
465
|
-
|
|
466
|
-
|
|
467
|
-
|
|
468
|
-
|
|
469
|
-
|
|
470
|
-
|
|
471
|
-
|
|
472
|
-
|
|
473
|
-
}
|
|
474
|
-
|
|
475
|
-
|
|
476
|
-
}
|
|
477
|
-
|
|
478
|
-
|
|
479
|
-
|
|
480
|
-
|
|
481
|
-
|
|
482
|
-
|
|
483
|
-
|
|
484
|
-
|
|
485
|
-
|
|
486
|
-
|
|
487
|
-
|
|
488
|
-
for (const
|
|
489
|
-
|
|
490
|
-
|
|
491
|
-
|
|
492
|
-
|
|
493
|
-
|
|
494
|
-
|
|
495
|
-
|
|
496
|
-
|
|
497
|
-
|
|
498
|
-
|
|
499
|
-
|
|
500
|
-
|
|
501
|
-
*
|
|
502
|
-
*
|
|
503
|
-
|
|
504
|
-
|
|
505
|
-
|
|
506
|
-
|
|
507
|
-
|
|
508
|
-
|
|
509
|
-
|
|
510
|
-
|
|
511
|
-
|
|
512
|
-
|
|
513
|
-
|
|
514
|
-
|
|
515
|
-
|
|
516
|
-
|
|
517
|
-
|
|
518
|
-
|
|
519
|
-
|
|
520
|
-
|
|
521
|
-
|
|
522
|
-
|
|
523
|
-
|
|
524
|
-
|
|
525
|
-
|
|
526
|
-
|
|
527
|
-
|
|
528
|
-
|
|
529
|
-
|
|
530
|
-
|
|
531
|
-
ids
|
|
532
|
-
}
|
|
533
|
-
|
|
534
|
-
|
|
535
|
-
|
|
536
|
-
|
|
537
|
-
|
|
538
|
-
|
|
539
|
-
|
|
540
|
-
|
|
541
|
-
|
|
542
|
-
|
|
543
|
-
|
|
544
|
-
|
|
545
|
-
|
|
546
|
-
|
|
547
|
-
|
|
548
|
-
|
|
549
|
-
|
|
550
|
-
|
|
551
|
-
|
|
552
|
-
|
|
553
|
-
|
|
554
|
-
|
|
555
|
-
|
|
556
|
-
|
|
557
|
-
|
|
558
|
-
|
|
559
|
-
|
|
560
|
-
|
|
561
|
-
|
|
562
|
-
|
|
563
|
-
}
|
|
564
|
-
|
|
565
|
-
|
|
566
|
-
|
|
567
|
-
|
|
568
|
-
|
|
569
|
-
|
|
570
|
-
|
|
571
|
-
|
|
572
|
-
|
|
573
|
-
|
|
574
|
-
|
|
575
|
-
|
|
576
|
-
|
|
577
|
-
|
|
578
|
-
|
|
579
|
-
|
|
580
|
-
|
|
581
|
-
|
|
582
|
-
|
|
583
|
-
|
|
584
|
-
|
|
585
|
-
|
|
586
|
-
|
|
587
|
-
|
|
588
|
-
|
|
589
|
-
|
|
590
|
-
|
|
591
|
-
|
|
592
|
-
|
|
593
|
-
|
|
594
|
-
|
|
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
|
-
|
|
624
|
-
|
|
625
|
-
|
|
626
|
-
|
|
627
|
-
|
|
628
|
-
|
|
629
|
-
|
|
630
|
-
|
|
631
|
-
|
|
632
|
-
|
|
633
|
-
|
|
634
|
-
|
|
635
|
-
|
|
636
|
-
|
|
637
|
-
|
|
638
|
-
|
|
639
|
-
|
|
640
|
-
|
|
641
|
-
|
|
642
|
-
|
|
643
|
-
|
|
644
|
-
|
|
645
|
-
|
|
646
|
-
|
|
647
|
-
const
|
|
648
|
-
|
|
649
|
-
|
|
650
|
-
|
|
651
|
-
|
|
652
|
-
|
|
653
|
-
|
|
654
|
-
|
|
655
|
-
|
|
656
|
-
|
|
657
|
-
|
|
658
|
-
|
|
659
|
-
|
|
660
|
-
|
|
661
|
-
|
|
662
|
-
|
|
663
|
-
|
|
664
|
-
|
|
665
|
-
|
|
666
|
-
|
|
667
|
-
|
|
668
|
-
|
|
669
|
-
|
|
670
|
-
|
|
671
|
-
|
|
672
|
-
|
|
673
|
-
|
|
674
|
-
|
|
675
|
-
|
|
676
|
-
|
|
677
|
-
|
|
678
|
-
|
|
679
|
-
|
|
680
|
-
|
|
681
|
-
|
|
682
|
-
|
|
683
|
-
|
|
684
|
-
|
|
685
|
-
|
|
686
|
-
|
|
687
|
-
|
|
688
|
-
|
|
689
|
-
|
|
690
|
-
|
|
691
|
-
|
|
692
|
-
|
|
693
|
-
|
|
694
|
-
|
|
695
|
-
|
|
696
|
-
|
|
697
|
-
|
|
698
|
-
|
|
699
|
-
|
|
700
|
-
|
|
701
|
-
|
|
702
|
-
|
|
703
|
-
|
|
704
|
-
|
|
705
|
-
|
|
706
|
-
|
|
707
|
-
|
|
708
|
-
|
|
709
|
-
|
|
710
|
-
|
|
711
|
-
|
|
712
|
-
|
|
713
|
-
|
|
714
|
-
|
|
715
|
-
|
|
716
|
-
|
|
717
|
-
|
|
718
|
-
|
|
719
|
-
|
|
720
|
-
|
|
721
|
-
|
|
722
|
-
|
|
723
|
-
|
|
724
|
-
|
|
725
|
-
|
|
726
|
-
|
|
727
|
-
|
|
728
|
-
|
|
729
|
-
|
|
730
|
-
|
|
731
|
-
|
|
732
|
-
|
|
733
|
-
|
|
734
|
-
|
|
735
|
-
|
|
736
|
-
|
|
737
|
-
|
|
738
|
-
|
|
739
|
-
|
|
740
|
-
|
|
741
|
-
|
|
742
|
-
|
|
743
|
-
|
|
744
|
-
|
|
745
|
-
|
|
746
|
-
|
|
747
|
-
|
|
748
|
-
|
|
749
|
-
|
|
750
|
-
|
|
751
|
-
|
|
752
|
-
|
|
753
|
-
|
|
754
|
-
|
|
755
|
-
|
|
756
|
-
|
|
757
|
-
|
|
758
|
-
|
|
759
|
-
|
|
760
|
-
|
|
761
|
-
|
|
762
|
-
|
|
763
|
-
|
|
764
|
-
|
|
765
|
-
|
|
766
|
-
}
|
|
767
|
-
|
|
768
|
-
|
|
769
|
-
|
|
770
|
-
return
|
|
771
|
-
|
|
772
|
-
|
|
773
|
-
|
|
774
|
-
|
|
775
|
-
|
|
776
|
-
|
|
777
|
-
|
|
778
|
-
|
|
779
|
-
|
|
780
|
-
|
|
781
|
-
|
|
782
|
-
|
|
783
|
-
}
|
|
784
|
-
|
|
785
|
-
|
|
786
|
-
}
|
|
787
|
-
|
|
788
|
-
|
|
789
|
-
|
|
790
|
-
|
|
791
|
-
|
|
792
|
-
|
|
793
|
-
|
|
794
|
-
|
|
795
|
-
|
|
796
|
-
|
|
797
|
-
|
|
798
|
-
|
|
799
|
-
|
|
800
|
-
|
|
801
|
-
|
|
802
|
-
|
|
803
|
-
|
|
804
|
-
|
|
805
|
-
|
|
806
|
-
|
|
807
|
-
|
|
808
|
-
|
|
809
|
-
|
|
810
|
-
|
|
811
|
-
|
|
812
|
-
|
|
813
|
-
}
|
|
814
|
-
|
|
815
|
-
|
|
816
|
-
|
|
817
|
-
|
|
818
|
-
|
|
819
|
-
|
|
820
|
-
|
|
821
|
-
|
|
822
|
-
|
|
823
|
-
|
|
824
|
-
|
|
825
|
-
|
|
826
|
-
|
|
827
|
-
|
|
828
|
-
|
|
829
|
-
|
|
830
|
-
|
|
267
|
+
const seedOptions = branchSeedOptions(source, seed.inheritedLength);
|
|
268
|
+
const child = await ctx.agents.create({
|
|
269
|
+
sessionId: childId,
|
|
270
|
+
seed: seed.events,
|
|
271
|
+
...seedOptions,
|
|
272
|
+
meta: {
|
|
273
|
+
...source.header.cwd === undefined ? {} : { cwd: source.header.cwd },
|
|
274
|
+
parentSession: source.id ?? source.header.id,
|
|
275
|
+
...seedOptions.meta,
|
|
276
|
+
// NOTE: deliberately NOT `origin: 'subagent'`, and deliberately not
|
|
277
|
+
// hidden from the sidebar at all.
|
|
278
|
+
//
|
|
279
|
+
// Two hiding approaches are known-bad:
|
|
280
|
+
//
|
|
281
|
+
// - `origin: 'subagent'` keeps versions out of the sidebar (the
|
|
282
|
+
// workspace list filters on `origin !== 'subagent'`), but the API
|
|
283
|
+
// proxy fences the same field: `hasApiRemoteSubagentOwner` treats
|
|
284
|
+
// such a session as owned by subagent routing and refuses both
|
|
285
|
+
// `session.cancel` and model selection with
|
|
286
|
+
// agent-busy: session "..." is owned by subagent routing
|
|
287
|
+
// so every edited or retried message became impossible to stop.
|
|
288
|
+
// The schema accepts no third `origin` value to hide behind.
|
|
289
|
+
//
|
|
290
|
+
// - Archiving the child (`workspaceRegistry.archiveSession`) hides it
|
|
291
|
+
// without fencing it, but the app cannot NAVIGATE to an archived
|
|
292
|
+
// session: opening one bounces to the workspace picker, so edit,
|
|
293
|
+
// retry and version switching all dead-ended on the home screen.
|
|
294
|
+
//
|
|
295
|
+
// Versions therefore appear in the sidebar as ordinary sessions. That
|
|
296
|
+
// is cosmetic; stopping, model switching and navigation all work.
|
|
297
|
+
...agentPreset === undefined ? {} : { agentPreset },
|
|
298
|
+
},
|
|
299
|
+
agentOptions: options,
|
|
300
|
+
setup,
|
|
301
|
+
});
|
|
302
|
+
try {
|
|
303
|
+
await ctx.sessions.flush(child.agent.session);
|
|
304
|
+
return child;
|
|
305
|
+
} catch (error) {
|
|
306
|
+
await child.dispose();
|
|
307
|
+
throw error;
|
|
308
|
+
}
|
|
309
|
+
}
|
|
310
|
+
|
|
311
|
+
async function recoverOperation(inverses) {
|
|
312
|
+
const failures = [];
|
|
313
|
+
for (const inverse of inverses.reverse()) {
|
|
314
|
+
try {
|
|
315
|
+
await inverse();
|
|
316
|
+
} catch (error) {
|
|
317
|
+
failures.push(error);
|
|
318
|
+
}
|
|
319
|
+
}
|
|
320
|
+
if (failures.length > 0) throw new AggregateError(failures, '版本操作恢复失败。');
|
|
321
|
+
}
|
|
322
|
+
|
|
323
|
+
/**
|
|
324
|
+
* Stop a still-running turn on `sessionId` so an edit can fork away from it.
|
|
325
|
+
*
|
|
326
|
+
* Two reasons this is needed. Editing forks from the source session but does
|
|
327
|
+
* not stop it, so the superseded turn keeps streaming and keeps spending
|
|
328
|
+
* tokens. And `runMaintenance` throws outright unless the agent is idle, which
|
|
329
|
+
* is why editing was blocked while a turn was live at all.
|
|
330
|
+
*
|
|
331
|
+
* Cancelling the parent is also what stops its subagents: a subagent session is
|
|
332
|
+
* fenced from the ordinary cancel path ("owned by subagent routing"), but the
|
|
333
|
+
* subtree is owned by the parent's fiber and unwinds with it.
|
|
334
|
+
*
|
|
335
|
+
* `cancel` only signals the abort; `whenIdle` waits for the phase to actually
|
|
336
|
+
* settle, without which the `runMaintenance` that follows can still throw.
|
|
337
|
+
*/
|
|
338
|
+
async function stopRunningTurn(ctx, sessionId) {
|
|
339
|
+
const agent = ctx.agents.get(sessionId);
|
|
340
|
+
if (agent === undefined) return false;
|
|
341
|
+
try {
|
|
342
|
+
agent.cancel({ kind: 'user' });
|
|
343
|
+
await agent.whenIdle();
|
|
344
|
+
return true;
|
|
345
|
+
} catch (error) {
|
|
346
|
+
// An agent that was already finishing is not an error for our purposes:
|
|
347
|
+
// the goal is only that it is no longer running.
|
|
348
|
+
return false;
|
|
349
|
+
}
|
|
350
|
+
}
|
|
351
|
+
|
|
352
|
+
/** Archived session ids, as a Set; empty when the registry cannot say. */
|
|
353
|
+
function archivedSessionIdSet(ctx) {
|
|
354
|
+
try {
|
|
355
|
+
const ids = ctx.get('workspaceRegistry')?.archivedSessionIds;
|
|
356
|
+
return new Set(Array.isArray(ids) ? ids : []);
|
|
357
|
+
} catch (error) {
|
|
358
|
+
return new Set();
|
|
359
|
+
}
|
|
360
|
+
}
|
|
361
|
+
|
|
362
|
+
/**
|
|
363
|
+
* Unarchive one session so the app can navigate to it.
|
|
364
|
+
*
|
|
365
|
+
* The app cannot open an archived session — it bounces to the workspace
|
|
366
|
+
* picker — and archiving versions to declutter the sidebar is a natural thing
|
|
367
|
+
* to do, so paging the ring onto one must unarchive it first. This dsh build
|
|
368
|
+
* has no unarchive API anywhere (the registry's archiveSession only adds), so
|
|
369
|
+
* this mirrors archiveSession's own state discipline: same operation queue,
|
|
370
|
+
* same durable state write. The API proxy watches this state and broadcasts
|
|
371
|
+
* host/archived-sessions-changed, so the sidebar updates live.
|
|
372
|
+
*/
|
|
373
|
+
async function activateVersion(ctx, sessionId) {
|
|
374
|
+
const registry = ctx.get('workspaceRegistry');
|
|
375
|
+
if (registry === undefined) throw new Error('workspaceRegistry 不可用,无法取消归档。');
|
|
376
|
+
if (!registry.archivedSessionIds.includes(sessionId)) return { unarchived: false };
|
|
377
|
+
if (typeof registry.enqueueOperation !== 'function'
|
|
378
|
+
|| typeof registry.requireState !== 'function'
|
|
379
|
+
|| typeof registry.setState !== 'function') {
|
|
380
|
+
throw new Error('此 dsh 版本未提供取消归档的途径。');
|
|
381
|
+
}
|
|
382
|
+
await registry.enqueueOperation(async () => {
|
|
383
|
+
const state = registry.requireState();
|
|
384
|
+
if (!state.archivedSessionIds.includes(sessionId)) return;
|
|
385
|
+
await registry.setState({
|
|
386
|
+
...state,
|
|
387
|
+
archivedSessionIds: state.archivedSessionIds.filter((id) => id !== sessionId),
|
|
388
|
+
});
|
|
389
|
+
});
|
|
390
|
+
return { unarchived: true };
|
|
391
|
+
}
|
|
392
|
+
|
|
393
|
+
/**
|
|
394
|
+
* Keep a version in the same sidebar workspace group as its tree parent.
|
|
395
|
+
* Versions inherit the parent's cwd but were never attached to its workspace,
|
|
396
|
+
* so they showed up as stray ungrouped rows. Failure is cosmetic — the
|
|
397
|
+
* version works either way — so it never fails the edit.
|
|
398
|
+
*/
|
|
399
|
+
async function attachToParentWorkspace(ctx, parentId, childId) {
|
|
400
|
+
try {
|
|
401
|
+
const registry = ctx.get('workspaceRegistry');
|
|
402
|
+
const workspace = registry?.list().find((w) => w.sessionIds.includes(parentId));
|
|
403
|
+
if (workspace !== undefined) await workspace.attachSession(childId);
|
|
404
|
+
} catch (error) {}
|
|
405
|
+
}
|
|
406
|
+
|
|
407
|
+
/** All message-tree/version events of one session's full log, in seq order. */
|
|
408
|
+
async function versionMarkers(ctx, sessionId) {
|
|
409
|
+
const { events } = await loadSessionRecord(ctx, sessionId);
|
|
410
|
+
return events.filter((event) => event.type === 'message-tree/version');
|
|
411
|
+
}
|
|
412
|
+
|
|
413
|
+
/**
|
|
414
|
+
* Assemble one conversation family, bridging sessions the user has deleted.
|
|
415
|
+
*
|
|
416
|
+
* dsh's traceSession stops at the first missing parent, so deleting one
|
|
417
|
+
* version used to fragment the family: siblings of a deleted original lost
|
|
418
|
+
* their ‹k/N› counters entirely, and everything below a deleted chain link
|
|
419
|
+
* vanished from the Versions tree. But every version's seed inherits its
|
|
420
|
+
* ancestors' `message-tree/version` markers, so a deleted ancestor's identity,
|
|
421
|
+
* parent and target turn all survive in its descendants' logs. This walks
|
|
422
|
+
* surviving headers where possible and recovers the rest from those markers,
|
|
423
|
+
* emitting ghost entries for the deleted sessions so the tree stays whole.
|
|
424
|
+
*
|
|
425
|
+
* Families never span working directories (a version inherits its source's
|
|
426
|
+
* cwd), so orphan logs outside the target's cwd are never read.
|
|
427
|
+
*
|
|
428
|
+
* @returns { rootId, flat: [{ entry, depth }], recordsById } where each entry
|
|
429
|
+
* is { id, parentId?, createdAt, ghost, marker? }.
|
|
430
|
+
*/
|
|
431
|
+
async function assembleFamily(ctx, sessionId) {
|
|
432
|
+
const records = await ctx.sessionQuery.listSessions();
|
|
433
|
+
const recordsById = new Map(records.map((record) => [record.header.id, record]));
|
|
434
|
+
const target = recordsById.get(sessionId);
|
|
435
|
+
if (target === undefined) throw new Error(`session "${sessionId}" not found`);
|
|
436
|
+
|
|
437
|
+
const ghostInfo = new Map();
|
|
438
|
+
const absorbChain = (chain) => {
|
|
439
|
+
for (let i = 0; i < chain.length; i++) {
|
|
440
|
+
const link = chain[i];
|
|
441
|
+
if (recordsById.has(link.sessionId)) continue;
|
|
442
|
+
const info = ghostInfo.get(link.sessionId) ?? {};
|
|
443
|
+
if (link.marker !== undefined) info.marker = link.marker;
|
|
444
|
+
if (i + 1 < chain.length) info.parentId = chain[i + 1].sessionId;
|
|
445
|
+
ghostInfo.set(link.sessionId, info);
|
|
446
|
+
}
|
|
447
|
+
};
|
|
448
|
+
|
|
449
|
+
// The target's root: surviving headers first, the log bridge at a hole.
|
|
450
|
+
let rootId;
|
|
451
|
+
{
|
|
452
|
+
const seen = new Set();
|
|
453
|
+
let cursor = target.header;
|
|
454
|
+
while (cursor.parentSession !== undefined && !seen.has(cursor.id)) {
|
|
455
|
+
seen.add(cursor.id);
|
|
456
|
+
const parent = recordsById.get(cursor.parentSession);
|
|
457
|
+
if (parent === undefined) break;
|
|
458
|
+
cursor = parent.header;
|
|
459
|
+
}
|
|
460
|
+
rootId = cursor.id;
|
|
461
|
+
if (cursor.parentSession !== undefined) {
|
|
462
|
+
const chain = ancestorChainFromLog(cursor.parentSession, await versionMarkers(ctx, cursor.id));
|
|
463
|
+
absorbChain(chain);
|
|
464
|
+
if (chain.length > 0) rootId = chain[chain.length - 1].sessionId;
|
|
465
|
+
}
|
|
466
|
+
}
|
|
467
|
+
|
|
468
|
+
// Other orphans in the same cwd may belong to this family through their own
|
|
469
|
+
// holes; each orphan's log names its full ancestry, connecting it or ruling
|
|
470
|
+
// it out. Orphans are rare (they only exist where something was deleted),
|
|
471
|
+
// so the full-log reads here are few.
|
|
472
|
+
for (const record of records) {
|
|
473
|
+
const parentId = record.header.parentSession;
|
|
474
|
+
if (parentId === undefined || recordsById.has(parentId)) continue;
|
|
475
|
+
if (record.header.cwd !== target.header.cwd) continue;
|
|
476
|
+
if (record.header.id === sessionId) continue;
|
|
477
|
+
try {
|
|
478
|
+
absorbChain(ancestorChainFromLog(parentId, await versionMarkers(ctx, record.header.id)));
|
|
479
|
+
} catch (error) {
|
|
480
|
+
// An unreadable orphan stays an island; the family is still assembled.
|
|
481
|
+
}
|
|
482
|
+
}
|
|
483
|
+
|
|
484
|
+
const entries = [];
|
|
485
|
+
for (const record of records) {
|
|
486
|
+
if (record.header.cwd !== target.header.cwd && record.header.id !== sessionId) continue;
|
|
487
|
+
entries.push({
|
|
488
|
+
id: record.header.id,
|
|
489
|
+
...record.header.parentSession === undefined ? {} : { parentId: record.header.parentSession },
|
|
490
|
+
createdAt: record.header.createdAt,
|
|
491
|
+
ghost: false,
|
|
492
|
+
});
|
|
493
|
+
}
|
|
494
|
+
for (const [id, info] of ghostInfo) {
|
|
495
|
+
entries.push({
|
|
496
|
+
id,
|
|
497
|
+
...info.parentId === undefined ? {} : { parentId: info.parentId },
|
|
498
|
+
createdAt: info.marker?.time ?? 0,
|
|
499
|
+
ghost: true,
|
|
500
|
+
...info.marker === undefined ? {} : { marker: info.marker },
|
|
501
|
+
});
|
|
502
|
+
}
|
|
503
|
+
return { rootId, flat: collectFamily(rootId, entries), recordsById };
|
|
504
|
+
}
|
|
505
|
+
|
|
506
|
+
/**
|
|
507
|
+
* Every surviving session in this conversation's version family — the root
|
|
508
|
+
* and all descendants, bridged across deleted members, ghosts excluded.
|
|
509
|
+
*/
|
|
510
|
+
async function familySessionIds(ctx, sessionId) {
|
|
511
|
+
const { flat } = await assembleFamily(ctx, sessionId);
|
|
512
|
+
const ids = new Set([sessionId]);
|
|
513
|
+
for (const { entry } of flat) {
|
|
514
|
+
if (!entry.ghost) ids.add(entry.id);
|
|
515
|
+
}
|
|
516
|
+
return [...ids];
|
|
517
|
+
}
|
|
518
|
+
|
|
519
|
+
/**
|
|
520
|
+
* Stop every still-running turn in this conversation's family before branching.
|
|
521
|
+
*
|
|
522
|
+
* Editing must stop the reply it supersedes — that is what every chat UI does,
|
|
523
|
+
* and leaving it running silently spends tokens on an answer nobody will read.
|
|
524
|
+
* It is not enough to cancel only the session being edited: versions are
|
|
525
|
+
* separate sessions, so a sibling branch started earlier can still be
|
|
526
|
+
* streaming while you edit a different one. Those are exactly the runs that are
|
|
527
|
+
* hard to notice and hard to stop by hand.
|
|
528
|
+
*
|
|
529
|
+
* Falls back to the source session alone if the family cannot be traced, so a
|
|
530
|
+
* lookup failure still stops the obvious one rather than nothing.
|
|
531
|
+
*/
|
|
532
|
+
async function stopFamilyTurns(ctx, sessionId) {
|
|
533
|
+
let ids;
|
|
534
|
+
try {
|
|
535
|
+
ids = await familySessionIds(ctx, sessionId);
|
|
536
|
+
} catch (error) {
|
|
537
|
+
ids = [sessionId];
|
|
538
|
+
}
|
|
539
|
+
let stopped = 0;
|
|
540
|
+
for (const id of ids) {
|
|
541
|
+
if (await stopRunningTurn(ctx, id)) stopped += 1;
|
|
542
|
+
}
|
|
543
|
+
return stopped;
|
|
544
|
+
}
|
|
545
|
+
|
|
546
|
+
async function runOperation(ctx, operation) {
|
|
547
|
+
const sourceId = sessionIdOf(operation.sessionId);
|
|
548
|
+
if (operation.stopPrevious === true) await stopFamilyTurns(ctx, sourceId);
|
|
549
|
+
return withSourceAgent(ctx, sourceId, async (source) => {
|
|
550
|
+
const childId = sessionIdOf(`session-${crypto.randomUUID()}`);
|
|
551
|
+
const inverses = [];
|
|
552
|
+
try {
|
|
553
|
+
const record = sessionRecord(source.session);
|
|
554
|
+
const events = record.events;
|
|
555
|
+
const plan = planOperation(operation, events);
|
|
556
|
+
const options = agentOptions(events, source.options);
|
|
557
|
+
const attach = await resolveAttachSession(ctx, record, plan.version.effect.targetTurn);
|
|
558
|
+
if (sessionRecordId(attach) !== record.id) {
|
|
559
|
+
const turn = closedTurns(attach.events).find((candidate) => candidate.turn === plan.version.effect.targetTurn);
|
|
560
|
+
if (turn === undefined) throw new Error('无法在父会话上定位同一回合。');
|
|
561
|
+
plan.boundary = turn.startSeq - 1;
|
|
562
|
+
plan.version.inverse.sessionId = sessionRecordId(attach);
|
|
563
|
+
}
|
|
564
|
+
const child = await createVersionAgent(ctx, attach, childId, plan, options);
|
|
565
|
+
inverses.push(() => child.dispose());
|
|
566
|
+
for (const message of plan.queuedUsers) child.agent.followup(message);
|
|
567
|
+
inverses.length = 0;
|
|
568
|
+
await attachToParentWorkspace(ctx, sessionRecordId(attach), childId);
|
|
569
|
+
return { sessionId: childId, queuedTurns: plan.queuedUsers.length };
|
|
570
|
+
} catch (error) {
|
|
571
|
+
try {
|
|
572
|
+
await recoverOperation(inverses);
|
|
573
|
+
} catch (recoveryError) {
|
|
574
|
+
throw new AggregateError([error, recoveryError], '版本操作及其恢复均失败。');
|
|
575
|
+
}
|
|
576
|
+
throw error;
|
|
577
|
+
}
|
|
578
|
+
});
|
|
579
|
+
}
|
|
580
|
+
|
|
581
|
+
/* ------------------------------------------------------- tree projection -- */
|
|
582
|
+
|
|
583
|
+
function ownVersionEvent({ header, events, inheritedEventCount: inherited }) {
|
|
584
|
+
const ownEvents = events.filter((event) => event.type === 'message-tree/version'
|
|
585
|
+
&& (event.data.sessionId === header.id
|
|
586
|
+
|| (event.data.sessionId === undefined && event.seq >= inherited)));
|
|
587
|
+
if (ownEvents.length === 0) return undefined;
|
|
588
|
+
const event = ownEvents[0];
|
|
589
|
+
const version = event.data;
|
|
590
|
+
const parent = header.parentSession;
|
|
591
|
+
if (version.schemaVersion !== MESSAGE_TREE_SCHEMA) throw new Error(`会话 ${header.id} 使用不支持的版本效果结构。`);
|
|
592
|
+
if (version.inverse.kind !== 'restore-version' || parent === undefined || version.inverse.sessionId !== parent) {
|
|
593
|
+
throw new Error(`会话 ${header.id} 的版本效果与逆不匹配。`);
|
|
594
|
+
}
|
|
595
|
+
return { effect: version.effect, time: event.time };
|
|
596
|
+
}
|
|
597
|
+
|
|
598
|
+
const TREE_READ_CONCURRENCY = 4;
|
|
599
|
+
async function mapConcurrent(items, worker) {
|
|
600
|
+
const results = new Array(items.length);
|
|
601
|
+
let cursor = 0;
|
|
602
|
+
const run = async () => {
|
|
603
|
+
for (;;) {
|
|
604
|
+
const index = cursor;
|
|
605
|
+
cursor += 1;
|
|
606
|
+
if (index >= items.length) return;
|
|
607
|
+
results[index] = await worker(items[index]);
|
|
608
|
+
}
|
|
609
|
+
};
|
|
610
|
+
const workers = Math.min(TREE_READ_CONCURRENCY, items.length);
|
|
611
|
+
await Promise.all(Array.from({ length: workers }, () => run()));
|
|
612
|
+
return results;
|
|
613
|
+
}
|
|
614
|
+
|
|
615
|
+
/** Extract all user turns from a session's event stream. */
|
|
616
|
+
function extractTurns(events) {
|
|
617
|
+
if (!Array.isArray(events)) return [];
|
|
618
|
+
const result = [];
|
|
619
|
+
let current;
|
|
620
|
+
for (const event of events) {
|
|
621
|
+
if (event.type === 'turn/start') {
|
|
622
|
+
current = { turn: event.data.turn, startSeq: event.seq, time: event.time };
|
|
623
|
+
continue;
|
|
624
|
+
}
|
|
625
|
+
if (current === undefined) continue;
|
|
626
|
+
if (event.type === 'user/message' && current.user === undefined && event.data?.source?.kind === 'user') {
|
|
627
|
+
current.user = event;
|
|
628
|
+
current.text = userText(event.data);
|
|
629
|
+
current.time = event.time ?? current.time;
|
|
630
|
+
continue;
|
|
631
|
+
}
|
|
632
|
+
if (event.type === 'turn/end' && event.data.turn === current.turn) {
|
|
633
|
+
result.push({ turn: current.turn, text: current.text ?? '', time: current.time ?? event.time });
|
|
634
|
+
current = undefined;
|
|
635
|
+
}
|
|
636
|
+
}
|
|
637
|
+
if (current && current.user) {
|
|
638
|
+
result.push({ turn: current.turn, text: current.text ?? '', time: current.time ?? Date.now() });
|
|
639
|
+
}
|
|
640
|
+
return result;
|
|
641
|
+
}
|
|
642
|
+
|
|
643
|
+
const SESSION_CACHE_MAX = 500;
|
|
644
|
+
const sessionParsedCaches = new WeakMap();
|
|
645
|
+
|
|
646
|
+
async function sessionParsedData(ctx, record) {
|
|
647
|
+
if (!record || !record.header) return { turns: [], effect: undefined, time: undefined };
|
|
648
|
+
const id = record.header.id;
|
|
649
|
+
let sessionParsedCache = sessionParsedCaches.get(ctx);
|
|
650
|
+
if (!sessionParsedCache) sessionParsedCaches.set(ctx, sessionParsedCache = new Map());
|
|
651
|
+
const live = ctx.sessions.get(id);
|
|
652
|
+
// Header creation timestamps never change when a cold log grows. Read a
|
|
653
|
+
// cold snapshot before caching by length; live logs expose a cheap count.
|
|
654
|
+
const snapshot = live === undefined ? await loadSessionRecord(ctx, id) : undefined;
|
|
655
|
+
const key = live === undefined ? snapshot.events.length : sessionEventCount(live);
|
|
656
|
+
const cached = sessionParsedCache.get(id);
|
|
657
|
+
if (cached !== undefined && cached.key === key && cached.live?.deref() === live) {
|
|
658
|
+
return cached;
|
|
659
|
+
}
|
|
660
|
+
|
|
661
|
+
const source = snapshot ?? sessionRecord(live);
|
|
662
|
+
const events = source.events;
|
|
663
|
+
const turns = extractTurns(events);
|
|
664
|
+
let effect;
|
|
665
|
+
let time;
|
|
666
|
+
try {
|
|
667
|
+
const version = ownVersionEvent(source);
|
|
668
|
+
effect = version?.effect;
|
|
669
|
+
time = version?.time;
|
|
670
|
+
} catch (error) {
|
|
671
|
+
effect = undefined;
|
|
672
|
+
}
|
|
673
|
+
|
|
674
|
+
// Cache identity without keeping a disposed session's entire log alive.
|
|
675
|
+
const entry = { key, live: live === undefined ? undefined : new WeakRef(live), turns, effect, time };
|
|
676
|
+
if (sessionParsedCache.size >= SESSION_CACHE_MAX) {
|
|
677
|
+
const firstKey = sessionParsedCache.keys().next().value;
|
|
678
|
+
sessionParsedCache.delete(firstKey);
|
|
679
|
+
}
|
|
680
|
+
sessionParsedCache.set(id, entry);
|
|
681
|
+
return entry;
|
|
682
|
+
}
|
|
683
|
+
|
|
684
|
+
async function tree(ctx, sessionId) {
|
|
685
|
+
const { flat, recordsById } = await assembleFamily(ctx, sessionId);
|
|
686
|
+
const archived = archivedSessionIdSet(ctx);
|
|
687
|
+
const parsedLogs = await mapConcurrent(flat, async ({ entry }) => {
|
|
688
|
+
if (entry.ghost) return null;
|
|
689
|
+
const record = recordsById.get(entry.id);
|
|
690
|
+
return record === undefined ? null : sessionParsedData(ctx, record);
|
|
691
|
+
});
|
|
692
|
+
const parentOf = new Map(flat.map(({ entry }) => [entry.id, entry.parentId]));
|
|
693
|
+
const currentPath = new Set();
|
|
694
|
+
let pathId = sessionId;
|
|
695
|
+
while (pathId !== undefined && !currentPath.has(pathId)) {
|
|
696
|
+
currentPath.add(pathId);
|
|
697
|
+
pathId = parentOf.get(pathId);
|
|
698
|
+
}
|
|
699
|
+
const versions = flat.map(({ entry, depth }, index) => {
|
|
700
|
+
let effect;
|
|
701
|
+
let time;
|
|
702
|
+
let turns = [];
|
|
703
|
+
if (entry.ghost) {
|
|
704
|
+
effect = entry.marker?.data?.effect;
|
|
705
|
+
time = entry.marker?.time;
|
|
706
|
+
} else {
|
|
707
|
+
const parsed = parsedLogs[index];
|
|
708
|
+
turns = parsed?.turns ?? [];
|
|
709
|
+
effect = parsed?.effect;
|
|
710
|
+
time = parsed?.time;
|
|
711
|
+
}
|
|
712
|
+
const record = entry.ghost ? undefined : recordsById.get(entry.id);
|
|
713
|
+
return {
|
|
714
|
+
sessionId: entry.id,
|
|
715
|
+
...entry.parentId === undefined ? {} : { parentSessionId: entry.parentId },
|
|
716
|
+
createdAt: time ?? record?.header.createdAt ?? entry.createdAt,
|
|
717
|
+
depth,
|
|
718
|
+
current: entry.id === sessionId,
|
|
719
|
+
onCurrentPath: currentPath.has(entry.id),
|
|
720
|
+
...entry.ghost ? { deleted: true } : {},
|
|
721
|
+
...archived.has(entry.id) ? { archived: true } : {},
|
|
722
|
+
...effect === undefined ? {} : {
|
|
723
|
+
operation: effect.operation,
|
|
724
|
+
targetTurn: effect.targetTurn,
|
|
725
|
+
targetEventSeq: effect.targetEventSeq,
|
|
726
|
+
...effect.before === undefined ? {} : { before: effect.before },
|
|
727
|
+
...effect.after === undefined ? {} : { after: effect.after },
|
|
728
|
+
},
|
|
729
|
+
turns,
|
|
730
|
+
};
|
|
731
|
+
});
|
|
732
|
+
return { sessionId, versions };
|
|
733
|
+
}
|
|
734
|
+
|
|
735
|
+
/* --------------------------------------------------------- HTTP plumbing -- */
|
|
736
|
+
|
|
737
|
+
function objectValue(value) {
|
|
738
|
+
if (typeof value !== 'object' || value === null || Array.isArray(value)) throw new TypeError('请求体必须是 JSON 对象。');
|
|
739
|
+
return value;
|
|
740
|
+
}
|
|
741
|
+
|
|
742
|
+
function sessionIdOf(value) {
|
|
743
|
+
if (typeof value !== 'string' || value.length === 0) throw new TypeError('sessionId 必须是非空字符串。');
|
|
744
|
+
return value;
|
|
745
|
+
}
|
|
746
|
+
|
|
747
|
+
function integerOf(value, label) {
|
|
748
|
+
if (!Number.isSafeInteger(value) || value < 0) throw new TypeError(`${label} 必须是非负安全整数。`);
|
|
749
|
+
return value;
|
|
750
|
+
}
|
|
751
|
+
|
|
752
|
+
function decodeOperation(value) {
|
|
753
|
+
const record = objectValue(value);
|
|
754
|
+
const sessionId = sessionIdOf(record['sessionId']);
|
|
755
|
+
switch (record['action']) {
|
|
756
|
+
case 'edit':
|
|
757
|
+
if (typeof record['text'] !== 'string' || record['text'].trim().length === 0) throw new TypeError('text 必须是非空字符串。');
|
|
758
|
+
return {
|
|
759
|
+
action: 'edit',
|
|
760
|
+
sessionId,
|
|
761
|
+
eventSeq: integerOf(record['eventSeq'], 'eventSeq'),
|
|
762
|
+
blockIndex: integerOf(record['blockIndex'], 'blockIndex'),
|
|
763
|
+
text: record['text'],
|
|
764
|
+
stopPrevious: record['stopPrevious'] === true,
|
|
765
|
+
};
|
|
766
|
+
case 'retry':
|
|
767
|
+
return {
|
|
768
|
+
action: 'retry',
|
|
769
|
+
sessionId,
|
|
770
|
+
turn: integerOf(record['turn'], 'turn'),
|
|
771
|
+
stopPrevious: record['stopPrevious'] === true,
|
|
772
|
+
};
|
|
773
|
+
default:
|
|
774
|
+
throw new TypeError('action 必须是 edit 或 retry。');
|
|
775
|
+
}
|
|
776
|
+
}
|
|
777
|
+
|
|
778
|
+
function requestJson(request) {
|
|
779
|
+
return new Promise((resolve, reject) => {
|
|
780
|
+
const decoder = new TextDecoder();
|
|
781
|
+
let text = '';
|
|
782
|
+
request.on('data', (chunk) => {
|
|
783
|
+
text += typeof chunk === 'string' ? chunk : decoder.decode(chunk, { stream: true });
|
|
784
|
+
});
|
|
785
|
+
request.on('end', () => {
|
|
786
|
+
try {
|
|
787
|
+
text += decoder.decode();
|
|
788
|
+
resolve(JSON.parse(text));
|
|
789
|
+
} catch (error) {
|
|
790
|
+
reject(error);
|
|
791
|
+
}
|
|
792
|
+
});
|
|
793
|
+
request.on('error', reject);
|
|
794
|
+
});
|
|
795
|
+
}
|
|
796
|
+
|
|
797
|
+
function respondJson(response, status, value) {
|
|
798
|
+
response.writeHead(status, {
|
|
799
|
+
'content-type': 'application/json; charset=utf-8',
|
|
800
|
+
'cache-control': 'no-store',
|
|
801
|
+
});
|
|
802
|
+
response.end(JSON.stringify(value));
|
|
803
|
+
}
|
|
804
|
+
|
|
805
|
+
async function handleRoute(ctx, request, response) {
|
|
806
|
+
try {
|
|
807
|
+
if (request.method === 'GET') {
|
|
808
|
+
const url = new URL(request.url ?? MESSAGE_TREE_PATH, 'http://message-tree.local');
|
|
809
|
+
respondJson(response, 200, await tree(ctx, sessionIdOf(url.searchParams.get('sessionId'))));
|
|
810
|
+
return;
|
|
811
|
+
}
|
|
812
|
+
if (request.method === 'POST') {
|
|
813
|
+
const body = await requestJson(request);
|
|
814
|
+
// activate = make an archived version navigable again. Kept apart from
|
|
815
|
+
// decodeOperation: it creates nothing, it only clears the archive flag.
|
|
816
|
+
if (body !== null && typeof body === 'object' && body.action === 'activate') {
|
|
817
|
+
respondJson(response, 200, await activateVersion(ctx, sessionIdOf(body.sessionId)));
|
|
818
|
+
return;
|
|
819
|
+
}
|
|
820
|
+
respondJson(response, 200, await runOperation(ctx, decodeOperation(body)));
|
|
821
|
+
return;
|
|
822
|
+
}
|
|
823
|
+
response.writeHead(405);
|
|
824
|
+
response.end();
|
|
825
|
+
} catch (error) {
|
|
826
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
827
|
+
respondJson(response, error instanceof TypeError ? 400 : 409, { error: message });
|
|
828
|
+
}
|
|
829
|
+
}
|
|
830
|
+
|
|
831
|
+
function apply(ctx) {
|
|
832
|
+
ctx.effect(() => ctx.webServer.register({
|
|
833
|
+
kind: 'exact',
|
|
834
|
+
path: MESSAGE_TREE_PATH,
|
|
835
|
+
handler: (request, response) => handleRoute(ctx, request, response),
|
|
836
|
+
}), 'message-tree: HTTP route');
|
|
837
|
+
}
|
|
838
|
+
|
|
839
|
+
export { MESSAGE_TREE_PATH, MESSAGE_TREE_SCHEMA, apply, inject, name };
|