@4fu/dsh-arbor-host 0.0.0-stage → 2026.1110.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 +21 -0
- package/README.md +234 -2
- package/index.d.ts +383 -0
- package/index.js +898 -0
- package/package.json +58 -4
- package/send.d.ts +82 -0
- package/send.js +206 -0
package/index.js
ADDED
|
@@ -0,0 +1,898 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The one Arbor host a DSH process runs.
|
|
3
|
+
*
|
|
4
|
+
* Arbor's point is one journal, one note store and one board across many
|
|
5
|
+
* sources, so a DSH process mounts exactly one `Arbor` host and every DSH user
|
|
6
|
+
* session is one source (one conversation target) of it. This row owns that
|
|
7
|
+
* host:
|
|
8
|
+
*
|
|
9
|
+
* - the durable storage under `DSH_HOME/arbor/` — `@4fu/arbor-journal-sqlite`
|
|
10
|
+
* for the journal and `@4fu/arbor-notes-sqlite` for the note store, both on
|
|
11
|
+
* `node:sqlite`, so nothing native has to build and an arbor window survives
|
|
12
|
+
* a DSH restart (the host rebuilds every running window from the journal in
|
|
13
|
+
* `Arbor.start`);
|
|
14
|
+
* - the DSH-backed `AgentBackend` (`@4fu/dsh-arbor-llm`), which runs each
|
|
15
|
+
* window's turns through `ctx.llm` and each tool call through
|
|
16
|
+
* `ctx.tools.execute`;
|
|
17
|
+
* - the two prompt plugins (`@4fu/dsh-arbor-prompt`): a trusted `<harness>`
|
|
18
|
+
* section this bridge writes, and a `<dsh>` data block for third-party
|
|
19
|
+
* material;
|
|
20
|
+
* - the deployment's persona, read out of DSH's own assembly once and merged
|
|
21
|
+
* into arbor's `<identity>` section, because it is the deployment's
|
|
22
|
+
* configuration rather than a section of its own;
|
|
23
|
+
* - the environment (`@4fu/dsh-arbor-env`): arbor's own under
|
|
24
|
+
* `DSH_HOME/arbor/env`, with DSH's skills and MCP servers mounted read-only
|
|
25
|
+
* beside them;
|
|
26
|
+
* - the workspace (`@4fu/dsh-arbor-workspace`): arbor's own, over the shared
|
|
27
|
+
* folder under `DSH_HOME/arbor` — ovnfs when its daemon starts, the plain
|
|
28
|
+
* directory workspace otherwise. For arbor sessions there is no DSH sandbox:
|
|
29
|
+
* arbor's tools run on the host the way they do in standalone arbor, and the
|
|
30
|
+
* only protection is arbor's own secret scrub;
|
|
31
|
+
* - the project bridge (`@4fu/dsh-arbor-workspace`): the shared folder's
|
|
32
|
+
* project subfolders are registered in DSH's own `workspaceRegistry`, so
|
|
33
|
+
* arbor's projects are the DSH project list a user picks from;
|
|
34
|
+
* - the `send` executor (`./send.ts`): a window's `send` lands on the DSH
|
|
35
|
+
* session log as an assistant message, which is the only surface the user
|
|
36
|
+
* reads;
|
|
37
|
+
* - the settings, whose window block is left to arbor's own defaults: a window
|
|
38
|
+
* stays open 300 s after its run ends and the next input for the session
|
|
39
|
+
* opens the next window of its chain.
|
|
40
|
+
*
|
|
41
|
+
* The judge is off on purpose in B1: every judge decision is model-based, and
|
|
42
|
+
* a model-based judge cannot run through the DSH backend yet. With the judge
|
|
43
|
+
* off, `host.dispatch` is deterministic — one window per session target,
|
|
44
|
+
* opened on the session's first turn — which is the routing policy B1 ships.
|
|
45
|
+
*
|
|
46
|
+
* There is no `workspaces.retentionMs` here: ovnfs checkouts are a window's own
|
|
47
|
+
* work, and a chain that ends leaves them where they are until a retention
|
|
48
|
+
* period is set, exactly as arbor's own defaults do. Nothing here is reclaimed
|
|
49
|
+
* by default, and the shared folder's projects are never removed — they are the
|
|
50
|
+
* user's work, not arbor's to delete.
|
|
51
|
+
*/
|
|
52
|
+
import { mkdir } from 'node:fs/promises';
|
|
53
|
+
import { join } from 'node:path';
|
|
54
|
+
import { Service } from '@deepseek-ai/cordis';
|
|
55
|
+
import z from '@deepseek-ai/schemastery';
|
|
56
|
+
import { MAX_PAGE_LIMIT } from '@4fu/arbor-interfaces';
|
|
57
|
+
import { Arbor } from '@4fu/arbor-core';
|
|
58
|
+
import { SqliteJournal } from '@4fu/arbor-journal-sqlite';
|
|
59
|
+
import { SqliteNoteStore } from '@4fu/arbor-notes-sqlite';
|
|
60
|
+
import { DirectoryEnvironment } from '@4fu/arbor-environment-dir';
|
|
61
|
+
import { resolveDshHome } from '@deepseek-ai/dsh-home-paths';
|
|
62
|
+
import { planProjectionDefinition } from '@deepseek-ai/dsh-plan-mode';
|
|
63
|
+
import { createDshBackend } from '@4fu/dsh-arbor-llm';
|
|
64
|
+
import { dshPromptPlugins, interpolatePersona, readDeploymentPersona, } from '@4fu/dsh-arbor-prompt';
|
|
65
|
+
import { DshEnvironment, dshSkillSource } from '@4fu/dsh-arbor-env';
|
|
66
|
+
import { openArborWorkspace, arborSharedFolder, DshProjectBridge } from '@4fu/dsh-arbor-workspace';
|
|
67
|
+
import { DshSendExecutor } from "./send.js";
|
|
68
|
+
export { DshSendExecutor, DSH_TARGET_PREFIX, DSH_USER_TARGET } from "./send.js";
|
|
69
|
+
/**
|
|
70
|
+
* The settings this deployment runs arbor with.
|
|
71
|
+
*
|
|
72
|
+
* The identity is this bridge's own sentence, in Chinese like every other
|
|
73
|
+
* model-facing line dsh-arbor writes, and it says only what is true and not
|
|
74
|
+
* already said: `<harness>` states that the window runs inside DeepSeek
|
|
75
|
+
* Harness and that the user sees only what `send` writes, and arbor's own
|
|
76
|
+
* `<agent>` rules state that a window is disposable and that its chain
|
|
77
|
+
* continues. Nothing here repeats either, and nothing here claims one DSH
|
|
78
|
+
* session is one arbor window — several windows serve one source and each ends
|
|
79
|
+
* on its own.
|
|
80
|
+
*
|
|
81
|
+
* `generalRules` is deliberately absent: arbor's own defaults are the seven
|
|
82
|
+
* rules every window needs, in Chinese, and B0's English replacements dropped
|
|
83
|
+
* them and restated two of them wrongly.
|
|
84
|
+
*
|
|
85
|
+
* The window block is deliberately absent too: arbor's own defaults apply
|
|
86
|
+
* (300 s idle, 200 000 context tokens, 4 000 reserved), so a window that goes
|
|
87
|
+
* quiet ends and the session's next turn opens the next window of its chain.
|
|
88
|
+
*
|
|
89
|
+
* The workspaces block is deliberately absent as well: the workspace is arbor's
|
|
90
|
+
* own over the shared folder, ovnfs checkouts are a window's own work, and the
|
|
91
|
+
* shared folder's projects are never reclaimed — so with no retention period
|
|
92
|
+
* nothing is removed.
|
|
93
|
+
*/
|
|
94
|
+
export function defaultSettings(extra) {
|
|
95
|
+
return {
|
|
96
|
+
identity: '你是 dsh-arbor 部署里的一个 arbor 窗口:DeepSeek Harness(DSH)是外壳,arbor 是里面的 agent 框架,你替 DSH 的用户干活。',
|
|
97
|
+
judge: {
|
|
98
|
+
modes: {
|
|
99
|
+
filter: 'off',
|
|
100
|
+
route: 'off',
|
|
101
|
+
model_tier: 'off',
|
|
102
|
+
notice: 'off',
|
|
103
|
+
broadcast: 'off',
|
|
104
|
+
active_set: 'off',
|
|
105
|
+
},
|
|
106
|
+
},
|
|
107
|
+
...extra,
|
|
108
|
+
};
|
|
109
|
+
}
|
|
110
|
+
/** The target prefix that names the DSH session a window belongs to. */
|
|
111
|
+
export const SESSION_TARGET_PREFIX = 'dsh:session:';
|
|
112
|
+
/** The conversation target one DSH user session is, for arbor's dispatch. */
|
|
113
|
+
export function sessionTarget(sessionId) {
|
|
114
|
+
return `${SESSION_TARGET_PREFIX}${sessionId}`;
|
|
115
|
+
}
|
|
116
|
+
/** The DSH session a conversation target names, or undefined for any other target. */
|
|
117
|
+
export function sessionOfTarget(target) {
|
|
118
|
+
return target.startsWith(SESSION_TARGET_PREFIX)
|
|
119
|
+
? target.slice(SESSION_TARGET_PREFIX.length)
|
|
120
|
+
: undefined;
|
|
121
|
+
}
|
|
122
|
+
/**
|
|
123
|
+
* The plugin that records which DSH session each window serves.
|
|
124
|
+
*
|
|
125
|
+
* Arbor's `windowStart` hook runs before the `window_start` event is appended
|
|
126
|
+
* and before the window's prompt epoch is rendered, and it carries the window
|
|
127
|
+
* ref together with the task's targets — the only moment at which a window can
|
|
128
|
+
* be attributed to a session before its prefix is built. A prompt section that
|
|
129
|
+
* answers per session (the external-project pointers, the project line) is
|
|
130
|
+
* rendered for a window, and a window's prefix is frozen when it opens, so the
|
|
131
|
+
* attribution has to exist by then: reading it later would put those lines in a
|
|
132
|
+
* delta of the window's opening instead of in the prefix it opens with.
|
|
133
|
+
*
|
|
134
|
+
* The `windowContinue` hook is the same moment for a continuation window, which
|
|
135
|
+
* carries no targets of its own — a continuation continues its predecessor's
|
|
136
|
+
* work, so it serves the session that window served. Without it a window the
|
|
137
|
+
* host continues (the hard limit, an idle handover, a restart's recovery) opens
|
|
138
|
+
* with a prefix that names no session and loses both per-session lines.
|
|
139
|
+
*
|
|
140
|
+
* The hooks observe and change nothing, so a window they cannot attribute — one
|
|
141
|
+
* dispatched from outside a DSH session — opens exactly as it would without them.
|
|
142
|
+
*
|
|
143
|
+
* @param note - called with the window and the session it serves.
|
|
144
|
+
* @param sessionOf - the session a window already recorded serves, for a
|
|
145
|
+
* continuation to inherit.
|
|
146
|
+
*/
|
|
147
|
+
export function windowSessionPlugin(note, sessionOf) {
|
|
148
|
+
return {
|
|
149
|
+
name: 'dsh-arbor-window-session',
|
|
150
|
+
hooks: {
|
|
151
|
+
windowStart: (context) => {
|
|
152
|
+
const label = context.task.label;
|
|
153
|
+
if (label !== undefined && label !== '') {
|
|
154
|
+
note(context.window, label);
|
|
155
|
+
}
|
|
156
|
+
else {
|
|
157
|
+
// An unlabeled task from an SDK or a remote integration still carries
|
|
158
|
+
// its session target; the label is the deployment's own attachment.
|
|
159
|
+
for (const target of context.task.targets) {
|
|
160
|
+
const sessionId = sessionOfTarget(target);
|
|
161
|
+
if (sessionId !== undefined)
|
|
162
|
+
note(context.window, sessionId);
|
|
163
|
+
}
|
|
164
|
+
}
|
|
165
|
+
return { allow: true, context };
|
|
166
|
+
},
|
|
167
|
+
windowContinue: (context) => {
|
|
168
|
+
const inherited = sessionOf(context.previous);
|
|
169
|
+
if (inherited !== undefined)
|
|
170
|
+
note(context.window, inherited);
|
|
171
|
+
return { allow: true, context };
|
|
172
|
+
},
|
|
173
|
+
},
|
|
174
|
+
};
|
|
175
|
+
}
|
|
176
|
+
/** The window a `window_start` continues, when it continues one. */
|
|
177
|
+
function continuedWindow(body) {
|
|
178
|
+
if (body === null)
|
|
179
|
+
return undefined;
|
|
180
|
+
try {
|
|
181
|
+
const parsed = JSON.parse(body);
|
|
182
|
+
const previous = parsed?.continuation;
|
|
183
|
+
return typeof previous === 'string' ? previous : undefined;
|
|
184
|
+
}
|
|
185
|
+
catch {
|
|
186
|
+
return undefined;
|
|
187
|
+
}
|
|
188
|
+
}
|
|
189
|
+
/** The opaque label a `window_start` carries, or the empty string. */
|
|
190
|
+
function readLabel(event) {
|
|
191
|
+
if (event.body === null)
|
|
192
|
+
return '';
|
|
193
|
+
try {
|
|
194
|
+
const parsed = JSON.parse(event.body);
|
|
195
|
+
const label = parsed?.label;
|
|
196
|
+
return typeof label === 'string' ? label : '';
|
|
197
|
+
}
|
|
198
|
+
catch {
|
|
199
|
+
return '';
|
|
200
|
+
}
|
|
201
|
+
}
|
|
202
|
+
/** The one Arbor host per DSH process, and the storage it runs on. */
|
|
203
|
+
export class ArborHostService extends Service {
|
|
204
|
+
// What the host and the pieces it mounts read: the backend's LLM and tools,
|
|
205
|
+
// the registry that names the session whose turn is running, DSH's prompt
|
|
206
|
+
// assembly and DSH's skill registry for `/env`. The workspace is arbor's own
|
|
207
|
+
// (ovnfs or the plain directory over the shared folder), so it needs neither
|
|
208
|
+
// DSH's filesystem/shell seams nor the sandbox policy — those stay mounted for
|
|
209
|
+
// ordinary sessions on the embedded AgentLoop, and arbor's windows simply do
|
|
210
|
+
// not route through them.
|
|
211
|
+
static inject = [
|
|
212
|
+
'llm', 'tools', 'agents', 'systemPrompt', 'sessionProjections', 'skills',
|
|
213
|
+
];
|
|
214
|
+
static Config = z.object({});
|
|
215
|
+
config;
|
|
216
|
+
state;
|
|
217
|
+
/**
|
|
218
|
+
* Which DSH session each window serves, written by the `windowStart` hook
|
|
219
|
+
* before the window's prefix is built. Arbor's own `WindowRef` carries no
|
|
220
|
+
* session identity, and a per-session prompt section is rendered for a window,
|
|
221
|
+
* so this is the mapping that section reads.
|
|
222
|
+
*/
|
|
223
|
+
windowSessions = new Map();
|
|
224
|
+
/**
|
|
225
|
+
* The stored cwd of a session whose agent is not live, read once. A session's
|
|
226
|
+
* stored header never moves, so the answer is remembered; the live projection
|
|
227
|
+
* takes over the moment the session is back.
|
|
228
|
+
*/
|
|
229
|
+
storedCwds = new Map();
|
|
230
|
+
/** Raised whenever a DSH prompt provider changes, so both sections follow. */
|
|
231
|
+
promptChanges = 0;
|
|
232
|
+
/** Plan mode's projection, so the `<harness>` section can read it per step. */
|
|
233
|
+
planProjection;
|
|
234
|
+
/** The deployment's persona, read once when the host is built. */
|
|
235
|
+
persona;
|
|
236
|
+
/** The last provider and model a live session rendered under, for the fallback. */
|
|
237
|
+
lastProvider;
|
|
238
|
+
lastModel;
|
|
239
|
+
/** The warnings the bridge raised while the host was built, for diagnostics. */
|
|
240
|
+
warnings = [];
|
|
241
|
+
constructor(ctx, config = {}) {
|
|
242
|
+
super(ctx, 'arborHost');
|
|
243
|
+
this.config = config;
|
|
244
|
+
}
|
|
245
|
+
async [Service.init]() {
|
|
246
|
+
const ctx = this.ctx;
|
|
247
|
+
const root = this.config.root ?? join(resolveDshHome(), 'arbor');
|
|
248
|
+
await mkdir(root, { recursive: true });
|
|
249
|
+
const journal = new SqliteJournal({ path: join(root, 'journal.sqlite') });
|
|
250
|
+
const notes = new SqliteNoteStore({ path: join(root, 'notes.sqlite'), journal });
|
|
251
|
+
// The deployment's persona belongs in arbor's <identity>, so it is read out
|
|
252
|
+
// of DSH's own assembly before the host is built. A persona a plugin
|
|
253
|
+
// registers after this point is not carried; the section it registers is
|
|
254
|
+
// still decided by the table and reported through `warnings`.
|
|
255
|
+
const persona = await readDeploymentPersona(ctx.systemPrompt);
|
|
256
|
+
this.persona = persona;
|
|
257
|
+
for (const warning of persona.warnings) {
|
|
258
|
+
this.warnings.push(warning);
|
|
259
|
+
ctx.logger.warn(warning);
|
|
260
|
+
}
|
|
261
|
+
const settings = defaultSettings(this.config.settings);
|
|
262
|
+
// `identity` is a getter rather than a string because DSH's persona carries
|
|
263
|
+
// `{{model}}` and `{{provider}}`, which are per-request values there: the
|
|
264
|
+
// loop re-resolves them on every request, and arbor's prefix is frozen per
|
|
265
|
+
// epoch, so the only faithful resolution is the one for the session whose
|
|
266
|
+
// turn is rendering. Arbor reads `settings.identity` at each render
|
|
267
|
+
// (`renderSystemPrompt`), so the getter is evaluated per epoch.
|
|
268
|
+
const identity = settings.identity ?? '';
|
|
269
|
+
Object.defineProperty(settings, 'identity', {
|
|
270
|
+
enumerable: true,
|
|
271
|
+
configurable: true,
|
|
272
|
+
get: () => [identity, ...this.personaLines()].filter(part => part !== '').join('\n\n'),
|
|
273
|
+
});
|
|
274
|
+
// The backend is built before the host, because the host is built with it;
|
|
275
|
+
// the renderer is therefore read lazily, and only once a delivery has to be
|
|
276
|
+
// rendered — which is long after the host exists.
|
|
277
|
+
let host;
|
|
278
|
+
const backend = createDshBackend({
|
|
279
|
+
llm: ctx.llm,
|
|
280
|
+
tools: ctx.tools,
|
|
281
|
+
// One backend serves every session's windows, so the agent a tool call
|
|
282
|
+
// runs as is resolved per call: the session whose turn is executing it,
|
|
283
|
+
// which is the initiating agent of that turn (see @4fu/dsh-arbor-tools).
|
|
284
|
+
agents: ctx.agents,
|
|
285
|
+
renderer: () => {
|
|
286
|
+
if (host === undefined)
|
|
287
|
+
throw new Error('arbor host: the renderer was read before the host existed');
|
|
288
|
+
return host.renderer;
|
|
289
|
+
},
|
|
290
|
+
});
|
|
291
|
+
// Arbor's own workspace over the shared folder: ovnfs when its daemon
|
|
292
|
+
// starts, the plain-directory workspace otherwise. For arbor sessions there
|
|
293
|
+
// is no DSH sandbox — arbor's file tools run on the host the way they do in
|
|
294
|
+
// standalone arbor, and arbor's own secret scrub is the only protection on
|
|
295
|
+
// what reaches the model. The `<workspace>` section and the `workspace.*`
|
|
296
|
+
// module functions are the backend's own, with no sandbox line and no
|
|
297
|
+
// `sandbox_escalate`; the external-project pointers are rendered inside that
|
|
298
|
+
// same section, so "where you work" and "where the user's own directories
|
|
299
|
+
// are" are answered in one place.
|
|
300
|
+
// The DSH project bridge: the shared folder's project subfolders become the
|
|
301
|
+
// DSH project list. `workspaceRegistry` is a web-profile service, and it
|
|
302
|
+
// activates AFTER this host does — DSH's own activation order is
|
|
303
|
+
// service-availability driven and this host declares no dependency on it —
|
|
304
|
+
// so it is injected rather than read: on a deployment with no registry
|
|
305
|
+
// (headless, acp, sdk) the child never activates and the bridge stays inert,
|
|
306
|
+
// and on the web profile it attaches the registry and keeps the list live.
|
|
307
|
+
// Reading it once here instead left the bridge permanently inert on the web
|
|
308
|
+
// profile, which is why arbor's projects never reached the picker.
|
|
309
|
+
const projects = new DshProjectBridge({
|
|
310
|
+
sharedFolder: arborSharedFolder(root),
|
|
311
|
+
warn: message => ctx.logger.warn(message),
|
|
312
|
+
});
|
|
313
|
+
this.ctx.inject(['workspaceRegistry'], (child) => {
|
|
314
|
+
child.effect(() => projects.attach(child.get('workspaceRegistry')), 'arborHost.projects()');
|
|
315
|
+
});
|
|
316
|
+
const arborWorkspace = await openArborWorkspace({
|
|
317
|
+
root,
|
|
318
|
+
journal,
|
|
319
|
+
warn: message => ctx.logger.warn(message),
|
|
320
|
+
pointers: {
|
|
321
|
+
// The section is rendered for one window, and the window names the
|
|
322
|
+
// session whose pointers it answers with. The attribution is the
|
|
323
|
+
// `windowStart` hook's, which runs before the window's prefix is built,
|
|
324
|
+
// so the pointer is in the prefix the window opens with rather than in a
|
|
325
|
+
// delta of its own opening. A window nothing attributes (one dispatched
|
|
326
|
+
// from outside a DSH session) names no session, and an unnamed session
|
|
327
|
+
// has no pointers rather than another session's.
|
|
328
|
+
list: (window) => projects.listPointers(window === undefined ? undefined : this.sessionOfWindow(window)),
|
|
329
|
+
// The project the session was opened on, read from the session's own cwd
|
|
330
|
+
// through the window that serves it: the project is a fact about where
|
|
331
|
+
// the user pointed the session, and the cwd is where the driver resolved
|
|
332
|
+
// that to.
|
|
333
|
+
project: (window) => this.projectOfWindow(window),
|
|
334
|
+
revision: () => this.workspaceRevision(),
|
|
335
|
+
},
|
|
336
|
+
});
|
|
337
|
+
// `/env`: arbor's own environment under DSH_HOME/arbor, with DSH's skills
|
|
338
|
+
// and MCP servers mounted read-only beside it.
|
|
339
|
+
const environment = new DshEnvironment({
|
|
340
|
+
inner: new DirectoryEnvironment({ root, supervisor: undefined }),
|
|
341
|
+
skills: dshSkillSource(ctx.skills, listener => ctx.on('skills/change', listener)),
|
|
342
|
+
toolNames: () => ctx.tools.definitions().map(tool => tool.name),
|
|
343
|
+
cwd: () => ctx.agents.currentInitiator()?.session.header.cwd ?? arborWorkspace.sharedFolder,
|
|
344
|
+
seedSettings: settings,
|
|
345
|
+
});
|
|
346
|
+
const sendExecutor = new DshSendExecutor({ journal, agents: ctx.agents });
|
|
347
|
+
host = new Arbor({
|
|
348
|
+
journal,
|
|
349
|
+
notes,
|
|
350
|
+
backend,
|
|
351
|
+
workspace: arborWorkspace.workspace,
|
|
352
|
+
environment,
|
|
353
|
+
plugins: [
|
|
354
|
+
...dshPromptPlugins({
|
|
355
|
+
systemPrompt: ctx.systemPrompt,
|
|
356
|
+
// The assembly runs in the scope of the session whose turn is rendering
|
|
357
|
+
// the section, which is what lets DSH's own `plan:policy` say whether
|
|
358
|
+
// plan mode is active. Arbor rebuilds an epoch whenever a section's
|
|
359
|
+
// revision moves, and the revision covers this session's state, so two
|
|
360
|
+
// sessions of different states never share one epoch.
|
|
361
|
+
agent: () => ctx.agents.currentInitiator(),
|
|
362
|
+
// A fresh signal per assembly: a section is re-rendered by the host's
|
|
363
|
+
// own watch loop, after the turn that opened the window has ended, and
|
|
364
|
+
// DSH's working-directory context provider does filesystem I/O under
|
|
365
|
+
// this signal — a finished turn's would abort it.
|
|
366
|
+
signal: new AbortController().signal,
|
|
367
|
+
changeCounter: () => this.promptChanges,
|
|
368
|
+
state: () => this.harnessState(),
|
|
369
|
+
onWarning: (message) => {
|
|
370
|
+
this.warnings.push(message);
|
|
371
|
+
ctx.logger.warn(message);
|
|
372
|
+
},
|
|
373
|
+
}),
|
|
374
|
+
windowSessionPlugin((window, sessionId) => { this.windowSessions.set(window, sessionId); }, (window) => this.windowSessions.get(window)),
|
|
375
|
+
],
|
|
376
|
+
settings,
|
|
377
|
+
executors: [sendExecutor],
|
|
378
|
+
});
|
|
379
|
+
this.state = {
|
|
380
|
+
root,
|
|
381
|
+
journal,
|
|
382
|
+
notes,
|
|
383
|
+
backend,
|
|
384
|
+
host,
|
|
385
|
+
settings,
|
|
386
|
+
workspace: arborWorkspace.workspace,
|
|
387
|
+
environment,
|
|
388
|
+
sharedFolder: arborWorkspace.sharedFolder,
|
|
389
|
+
workspaceBackend: arborWorkspace.backend,
|
|
390
|
+
projects,
|
|
391
|
+
sendExecutor,
|
|
392
|
+
};
|
|
393
|
+
// Plan mode's projection is registered here rather than only in the plan
|
|
394
|
+
// row, because the <harness> section reads it once per step. Registration
|
|
395
|
+
// is refcounted per key, so both see one fold of the same session log.
|
|
396
|
+
this.planProjection = await ctx.sessionProjections.register(planProjectionDefinition);
|
|
397
|
+
// Register the shared folder's projects in DSH's registry and keep the list
|
|
398
|
+
// live, so a project a window creates reaches the UI without a restart. With
|
|
399
|
+
// no registry attached yet — the web profile's activates after this host —
|
|
400
|
+
// both are no-ops here and the injected child above does them instead.
|
|
401
|
+
await projects.sync();
|
|
402
|
+
projects.start();
|
|
403
|
+
// Which session every recorded window serves, rebuilt from the journal
|
|
404
|
+
// before the host starts: the recovery below continues every window the
|
|
405
|
+
// journal still holds open, a continuation carries no targets of its own, and
|
|
406
|
+
// the map the hooks would have written died with the process that wrote it.
|
|
407
|
+
// Reading it here is what keeps the per-session prompt lines — the project
|
|
408
|
+
// line and the pointers — in the prefix a recovered window opens with.
|
|
409
|
+
await this.rebuildWindowSessions();
|
|
410
|
+
// Rebuild every window the journal still holds open: a window a previous
|
|
411
|
+
// process stopped is continued here, the way the hard limit continues one.
|
|
412
|
+
await host.start();
|
|
413
|
+
// Every DSH prompt-provider change moves the `<dsh>` section's revision, so
|
|
414
|
+
// arbor re-renders it and hands the changed lines to live windows.
|
|
415
|
+
ctx.on('system-prompt/change', () => { this.promptChanges += 1; });
|
|
416
|
+
ctx.effect(() => async () => {
|
|
417
|
+
projects.stop();
|
|
418
|
+
// Stopping the host records `host_restart` for every live window, so the
|
|
419
|
+
// next start continues each of them; the stores close after it.
|
|
420
|
+
await host.close().catch(() => { });
|
|
421
|
+
await journal.close().catch(() => { });
|
|
422
|
+
await notes.close().catch(() => { });
|
|
423
|
+
}, 'arborHost.close()');
|
|
424
|
+
}
|
|
425
|
+
/**
|
|
426
|
+
* The dynamic state the `<harness>` section renders, read for the session
|
|
427
|
+
* whose turn is running.
|
|
428
|
+
*
|
|
429
|
+
* A section is frozen per epoch and shared by every window of it, so a
|
|
430
|
+
* session's own policy is the one whose turn is rendering — the same rule
|
|
431
|
+
* arbor's `<workspace>` section follows for its root. A window a restart
|
|
432
|
+
* continued renders outside any turn and therefore without these lines until
|
|
433
|
+
* its session's next turn moves the revision and delivers them as a delta.
|
|
434
|
+
*/
|
|
435
|
+
harnessState() {
|
|
436
|
+
const ctx = this.ctx;
|
|
437
|
+
const agent = ctx.agents.currentInitiator();
|
|
438
|
+
const session = agent?.session;
|
|
439
|
+
// The approval policy: the session's own override when it has one, else the
|
|
440
|
+
// deployment's configured default. `effectivePolicy` is DSH-private, and
|
|
441
|
+
// `overrideOf` plus the config is the public half of the same read.
|
|
442
|
+
const approvalService = ctx.get('approval');
|
|
443
|
+
const approval = session === undefined
|
|
444
|
+
? undefined
|
|
445
|
+
: approvalService?.overrideOf(session) ?? approvalService?.config.policy;
|
|
446
|
+
const planActive = session === undefined ? false : this.planReader(session).active === true;
|
|
447
|
+
return {
|
|
448
|
+
approval,
|
|
449
|
+
planActive,
|
|
450
|
+
exposedTools: this.state?.backend.externalToolNames() ?? [],
|
|
451
|
+
};
|
|
452
|
+
}
|
|
453
|
+
/** The plan-mode state of one session, from the host's own registration. */
|
|
454
|
+
planReader(session) {
|
|
455
|
+
const handle = this.planProjection;
|
|
456
|
+
if (handle === undefined)
|
|
457
|
+
return { active: false };
|
|
458
|
+
return handle.readerFor(session)();
|
|
459
|
+
}
|
|
460
|
+
/**
|
|
461
|
+
* Note that a DSH prompt input changed without a `system-prompt/change`.
|
|
462
|
+
*
|
|
463
|
+
* The driver calls this after it installs the arbor tool restriction in a
|
|
464
|
+
* session's scope: a `tool:*` section's text reads whether its tool is
|
|
465
|
+
* registered, and the restriction moves that without a registration change.
|
|
466
|
+
*/
|
|
467
|
+
noteDshPromptChange() {
|
|
468
|
+
this.promptChanges += 1;
|
|
469
|
+
}
|
|
470
|
+
/**
|
|
471
|
+
* The deployment's persona lines, with DSH's prompt variables resolved
|
|
472
|
+
* against the session whose turn is rendering.
|
|
473
|
+
*
|
|
474
|
+
* A window a restart continued renders outside any turn, so the last values
|
|
475
|
+
* a live session gave are the fallback: the persona then names the model that
|
|
476
|
+
* session was running rather than carrying a placeholder.
|
|
477
|
+
*/
|
|
478
|
+
personaLines() {
|
|
479
|
+
const agent = this.ctx.agents.currentInitiator();
|
|
480
|
+
const selection = agent?.model.current();
|
|
481
|
+
const provider = selection?.provider ?? agent?.options.provider;
|
|
482
|
+
const model = selection?.model ?? agent?.options.model;
|
|
483
|
+
if (provider !== undefined)
|
|
484
|
+
this.lastProvider = provider;
|
|
485
|
+
if (model !== undefined)
|
|
486
|
+
this.lastModel = model;
|
|
487
|
+
const resolved = {
|
|
488
|
+
...this.lastProvider === undefined ? {} : { provider: this.lastProvider },
|
|
489
|
+
...this.lastModel === undefined ? {} : { model: this.lastModel },
|
|
490
|
+
};
|
|
491
|
+
return [this.persona?.prefix ?? '', this.persona?.suffix ?? '']
|
|
492
|
+
.map(text => interpolatePersona(text, resolved))
|
|
493
|
+
.filter(text => text !== '');
|
|
494
|
+
}
|
|
495
|
+
requireState() {
|
|
496
|
+
if (this.state === undefined) {
|
|
497
|
+
throw new Error('arbor host is not started (the @4fu/dsh-arbor-host row has not activated)');
|
|
498
|
+
}
|
|
499
|
+
return this.state;
|
|
500
|
+
}
|
|
501
|
+
/** The one Arbor host of this process. */
|
|
502
|
+
get host() {
|
|
503
|
+
return this.requireState().host;
|
|
504
|
+
}
|
|
505
|
+
/** The durable journal every window of this process records into. */
|
|
506
|
+
get journal() {
|
|
507
|
+
return this.requireState().journal;
|
|
508
|
+
}
|
|
509
|
+
/** The durable note store beside the journal. */
|
|
510
|
+
get notes() {
|
|
511
|
+
return this.requireState().notes;
|
|
512
|
+
}
|
|
513
|
+
/** The settings this host runs arbor with. */
|
|
514
|
+
get settings() {
|
|
515
|
+
return this.requireState().settings;
|
|
516
|
+
}
|
|
517
|
+
/**
|
|
518
|
+
* Arbor's own workspace over the shared folder: the implementation every
|
|
519
|
+
* window's files go through, and the one a read-only surface asks for the
|
|
520
|
+
* chain's checkouts, keeps and open work.
|
|
521
|
+
*/
|
|
522
|
+
get workspace() {
|
|
523
|
+
return this.requireState().workspace;
|
|
524
|
+
}
|
|
525
|
+
/** The environment every window reads at `/env`, DSH's items mounted beside arbor's. */
|
|
526
|
+
get environment() {
|
|
527
|
+
return this.requireState().environment;
|
|
528
|
+
}
|
|
529
|
+
/** The arbor shared folder: the projects' home and ovnfs's source. */
|
|
530
|
+
get sharedFolder() {
|
|
531
|
+
return this.requireState().sharedFolder;
|
|
532
|
+
}
|
|
533
|
+
/** Which workspace backend arbor settled on: `ovnfs` or `dir`. */
|
|
534
|
+
get workspaceBackend() {
|
|
535
|
+
return this.requireState().workspaceBackend;
|
|
536
|
+
}
|
|
537
|
+
/** The DSH project bridge over the shared folder. */
|
|
538
|
+
get projects() {
|
|
539
|
+
return this.requireState().projects;
|
|
540
|
+
}
|
|
541
|
+
/**
|
|
542
|
+
* The DSH session whose work this window runs, or undefined while nothing
|
|
543
|
+
* attributes it. A window a restart continued before its session came back
|
|
544
|
+
* answers undefined until the session resumes and its next turn opens the
|
|
545
|
+
* window's own chain entry; the pointer section then reads no pointers rather
|
|
546
|
+
* than another session's.
|
|
547
|
+
*/
|
|
548
|
+
sessionOfWindow(window) {
|
|
549
|
+
return this.windowSessions.get(window);
|
|
550
|
+
}
|
|
551
|
+
/**
|
|
552
|
+
* The project of the shared folder the named window's session works in, or
|
|
553
|
+
* undefined when it works in none.
|
|
554
|
+
*
|
|
555
|
+
* The project is read from the session's own current directory, which the
|
|
556
|
+
* driver resolves into the shared folder for every arbor session — created
|
|
557
|
+
* under arbor, re-routed onto it, or resumed after a restart — so a session the
|
|
558
|
+
* user opened on a project and a session that picked arbor afterwards answer
|
|
559
|
+
* the same way.
|
|
560
|
+
*/
|
|
561
|
+
async projectOfWindow(window) {
|
|
562
|
+
if (window === undefined)
|
|
563
|
+
return undefined;
|
|
564
|
+
const sessionId = this.windowSessions.get(window);
|
|
565
|
+
if (sessionId === undefined)
|
|
566
|
+
return undefined;
|
|
567
|
+
return this.requireState().projects.projectOf(await this.directoryOfSession(sessionId));
|
|
568
|
+
}
|
|
569
|
+
/**
|
|
570
|
+
* The directory one session works in, the way DSH answers it.
|
|
571
|
+
*
|
|
572
|
+
* The `workingDirectory` projection is DSH's own answer for a session's
|
|
573
|
+
* current directory — the cwd display, the session listing and the skill
|
|
574
|
+
* catalog all read it — while the session header carries the directory the
|
|
575
|
+
* session was created with and never moves. A session whose directory changed
|
|
576
|
+
* therefore names the new one, which is what the project line has to say: the
|
|
577
|
+
* alternative is a prompt that names a project the UI no longer shows.
|
|
578
|
+
*
|
|
579
|
+
* A window recovered before its session resumed has no live agent to read —
|
|
580
|
+
* the host continues it at boot, long before the user opens the session again
|
|
581
|
+
* — and its prefix is frozen then. The stored session header is the durable
|
|
582
|
+
* answer for that moment, the same one the driver resolves a resume from, so
|
|
583
|
+
* the recovered window opens naming the project it was opened on. It is read
|
|
584
|
+
* once per session and remembered: a session's stored header never moves, and
|
|
585
|
+
* the live answer takes over the moment the session is back.
|
|
586
|
+
*/
|
|
587
|
+
/**
|
|
588
|
+
* The directory one session works in, the way the `<workspace>` section reads
|
|
589
|
+
* it: DSH's `workingDirectory` projection when the session is live, the
|
|
590
|
+
* stored header otherwise. A read-only surface that names the session's
|
|
591
|
+
* project asks this rather than re-deriving it.
|
|
592
|
+
*/
|
|
593
|
+
directoryOf(sessionId) {
|
|
594
|
+
return this.directoryOfSession(sessionId);
|
|
595
|
+
}
|
|
596
|
+
async directoryOfSession(sessionId) {
|
|
597
|
+
const session = this.ctx.agents.get(sessionId)?.session;
|
|
598
|
+
if (session !== undefined) {
|
|
599
|
+
const projected = this.ctx.sessionProjections
|
|
600
|
+
.snapshot(session, ['workingDirectory']).values.workingDirectory;
|
|
601
|
+
return projected ?? session.header.cwd;
|
|
602
|
+
}
|
|
603
|
+
if (!this.storedCwds.has(sessionId)) {
|
|
604
|
+
this.storedCwds.set(sessionId, await this.storedCwdOf(sessionId));
|
|
605
|
+
}
|
|
606
|
+
return this.storedCwds.get(sessionId);
|
|
607
|
+
}
|
|
608
|
+
/** The cwd a persisted session was created with, or undefined without a store. */
|
|
609
|
+
async storedCwdOf(sessionId) {
|
|
610
|
+
const persistence = this.ctx.get('sessionPersistence');
|
|
611
|
+
if (persistence === undefined)
|
|
612
|
+
return undefined;
|
|
613
|
+
try {
|
|
614
|
+
const handle = await persistence.open(sessionId, 'read');
|
|
615
|
+
try {
|
|
616
|
+
return handle.header.cwd;
|
|
617
|
+
}
|
|
618
|
+
finally {
|
|
619
|
+
await handle.close();
|
|
620
|
+
}
|
|
621
|
+
}
|
|
622
|
+
catch {
|
|
623
|
+
return undefined;
|
|
624
|
+
}
|
|
625
|
+
}
|
|
626
|
+
/**
|
|
627
|
+
* A revision that moves whenever anything the `<workspace>` section names
|
|
628
|
+
* moves: any session's pointers, or any session's project. The host asks for it
|
|
629
|
+
* once per step, so a window whose session picks another project before its
|
|
630
|
+
* first turn is handed a delta of only the project line, and a session that
|
|
631
|
+
* gains or loses a pointer is handed a delta of only that line.
|
|
632
|
+
*/
|
|
633
|
+
async workspaceRevision() {
|
|
634
|
+
const projects = this.requireState().projects;
|
|
635
|
+
const pointers = await projects.pointerRevision();
|
|
636
|
+
const parts = [];
|
|
637
|
+
for (const sessionId of this.sessionProjects()) {
|
|
638
|
+
const project = projects.projectOf(await this.directoryOfSession(sessionId));
|
|
639
|
+
parts.push(`${sessionId}=${project?.path ?? ''}`);
|
|
640
|
+
}
|
|
641
|
+
return `${pointers}#${parts.sort().join(';')}`;
|
|
642
|
+
}
|
|
643
|
+
/**
|
|
644
|
+
* Every session the `<workspace>` section answers for: the live ones, plus the
|
|
645
|
+
* ones a recovered window still names — a window the host continued at boot
|
|
646
|
+
* belongs to a session whose agent is not back yet, and its project is part of
|
|
647
|
+
* what the section says.
|
|
648
|
+
*/
|
|
649
|
+
sessionProjects() {
|
|
650
|
+
const ids = new Set(this.ctx.agents.list().map(agent => agent.session.id));
|
|
651
|
+
for (const sessionId of this.windowSessions.values())
|
|
652
|
+
ids.add(sessionId);
|
|
653
|
+
return [...ids];
|
|
654
|
+
}
|
|
655
|
+
/**
|
|
656
|
+
* Where one arbor session works: a project folder inside the shared folder.
|
|
657
|
+
*
|
|
658
|
+
* The driver calls this for an arbor session's requested cwd and sets the
|
|
659
|
+
* session's cwd to the answer, so the DSH session, its cwd display and the
|
|
660
|
+
* project picker all agree on the shared folder. A directory outside the
|
|
661
|
+
* shared folder is not copied; it joins the shared folder only through
|
|
662
|
+
* {@link adoptArborProject}.
|
|
663
|
+
*/
|
|
664
|
+
resolveArborCwd(requested) {
|
|
665
|
+
return this.requireState().projects.resolveProject(requested);
|
|
666
|
+
}
|
|
667
|
+
/**
|
|
668
|
+
* Whether the user added this directory through DSH's own add-workspace
|
|
669
|
+
* action, outside the shared folder.
|
|
670
|
+
*
|
|
671
|
+
* This is what separates a directory the user means arbor to name from a cwd a
|
|
672
|
+
* session merely inherited: the add-workspace action is the one path in DSH
|
|
673
|
+
* that registers a directory of the user's choosing, so it is the only one
|
|
674
|
+
* that may turn that directory into a pointer. The driver asks before it
|
|
675
|
+
* records one.
|
|
676
|
+
*/
|
|
677
|
+
isAddedArborDirectory(path) {
|
|
678
|
+
return this.requireState().projects.isAddedDirectory(path);
|
|
679
|
+
}
|
|
680
|
+
/**
|
|
681
|
+
* Record an existing directory as an external pointer for one session: no
|
|
682
|
+
* copy, no link, no adoption, never a registered project. The owner's decision
|
|
683
|
+
* for an existing DSH project with its own path — the session works in arbor's
|
|
684
|
+
* own directory (the shared folder) and the user's original directory is named
|
|
685
|
+
* in arbor's prompt as a pointer, so the model reaches it directly. The driver
|
|
686
|
+
* calls it for a session that became an arbor session while naming an added
|
|
687
|
+
* directory; the model calls it for one it was asked about.
|
|
688
|
+
*/
|
|
689
|
+
pointerArborProject(path, sessionId) {
|
|
690
|
+
return this.requireState().projects.addPointer(sessionId, path);
|
|
691
|
+
}
|
|
692
|
+
/**
|
|
693
|
+
* Make one arbor session a member of the workspace that owns the directory it
|
|
694
|
+
* works in, so DSH's own hero composer has a workspace to name and stays live.
|
|
695
|
+
*
|
|
696
|
+
* The driver calls this for every arbor session it creates or re-routes, with
|
|
697
|
+
* the cwd it resolved for it: an arbor session works in arbor's own directory,
|
|
698
|
+
* and DSH filters a workspace's member list by the session's stored cwd, so
|
|
699
|
+
* the session has to be a member of the workspace whose path is that
|
|
700
|
+
* directory. See {@link DshProjectBridge.attachWorkspace} for why the composer
|
|
701
|
+
* is inert without it.
|
|
702
|
+
*/
|
|
703
|
+
attachArborWorkspace(sessionId, cwd) {
|
|
704
|
+
return this.requireState().projects.attachWorkspace(sessionId, cwd);
|
|
705
|
+
}
|
|
706
|
+
/** Drop a recorded external pointer by name for one session. */
|
|
707
|
+
removeArborPointer(name, sessionId) {
|
|
708
|
+
this.requireState().projects.removePointer(sessionId, name);
|
|
709
|
+
}
|
|
710
|
+
/**
|
|
711
|
+
* Every external pointer one session recorded whose directory still exists,
|
|
712
|
+
* true at read. This is what that session's `<workspace>` prompt section
|
|
713
|
+
* names, and only that session's.
|
|
714
|
+
*/
|
|
715
|
+
listArborPointers(sessionId) {
|
|
716
|
+
return this.requireState().projects.listPointers(sessionId);
|
|
717
|
+
}
|
|
718
|
+
/** Every project registered from the shared folder, as DSH lists them. */
|
|
719
|
+
listArborProjects() {
|
|
720
|
+
return this.requireState().projects.list();
|
|
721
|
+
}
|
|
722
|
+
/** The backend every window of this process runs through. */
|
|
723
|
+
get backend() {
|
|
724
|
+
return this.requireState().backend;
|
|
725
|
+
}
|
|
726
|
+
/** Write the sends a step delivered, after all its tool results are on the log. */
|
|
727
|
+
flushSends() {
|
|
728
|
+
this.requireState().sendExecutor.flush();
|
|
729
|
+
}
|
|
730
|
+
/** The directory the journal and the note store live in. */
|
|
731
|
+
get dataRoot() {
|
|
732
|
+
return this.requireState().root;
|
|
733
|
+
}
|
|
734
|
+
/**
|
|
735
|
+
* Every window that serves this session, oldest first.
|
|
736
|
+
*
|
|
737
|
+
* A window carries the session's target in its own `window_start`, and a
|
|
738
|
+
* continuation carries its predecessor instead; a chain therefore starts at a
|
|
739
|
+
* target-carrying window and every continuation leads back to one. A session
|
|
740
|
+
* can have several such roots — one per turn after an idle window ended — and
|
|
741
|
+
* each root's continuations belong to the same session. The journal's start
|
|
742
|
+
* order is what orders them all, and it is what the relay reads the chain's
|
|
743
|
+
* tool calls back out of on resume.
|
|
744
|
+
*/
|
|
745
|
+
async windowChainOf(sessionId) {
|
|
746
|
+
// A window carries the label its task named, and arbor carries that label
|
|
747
|
+
// onto the window's continuations and the journal's record of both. The
|
|
748
|
+
// label is the embedding's own session id, so it names this session's
|
|
749
|
+
// windows directly - every root a turn after an idle end opened, and every
|
|
750
|
+
// continuation of each - without walking targets or a process map.
|
|
751
|
+
const labeled = [];
|
|
752
|
+
const { journal } = this.requireState();
|
|
753
|
+
for (let offset = 0;; offset += MAX_PAGE_LIMIT) {
|
|
754
|
+
const page = await journal.list({ kinds: ['window_start'], limit: MAX_PAGE_LIMIT, offset });
|
|
755
|
+
for (const event of page.items) {
|
|
756
|
+
if (event.window === null)
|
|
757
|
+
continue;
|
|
758
|
+
if (readLabel(event) === sessionId)
|
|
759
|
+
labeled.push({ window: event.window, seq: event.seq });
|
|
760
|
+
}
|
|
761
|
+
if (page.items.length < MAX_PAGE_LIMIT)
|
|
762
|
+
break;
|
|
763
|
+
}
|
|
764
|
+
if (labeled.length > 0) {
|
|
765
|
+
return labeled.sort((left, right) => left.seq - right.seq).map(entry => entry.window);
|
|
766
|
+
}
|
|
767
|
+
// A journal written before arbor carried labels has no direct answer; the
|
|
768
|
+
// target walk it used is kept so an upgraded DSH_HOME stays readable.
|
|
769
|
+
return this.windowChainByTarget(sessionId);
|
|
770
|
+
}
|
|
771
|
+
/**
|
|
772
|
+
* The pre-label chain walk: the session's target names its first windows and
|
|
773
|
+
* each continuation names its predecessor. Only a journal with no labels
|
|
774
|
+
* reaches this; a current one answers by label.
|
|
775
|
+
*/
|
|
776
|
+
async windowChainByTarget(sessionId) {
|
|
777
|
+
const target = sessionTarget(sessionId);
|
|
778
|
+
const { journal } = this.requireState();
|
|
779
|
+
/** When each `window_start` was appended, in the journal's own order. */
|
|
780
|
+
const startedAt = new Map();
|
|
781
|
+
/** The window each `window_start` names as its predecessor. */
|
|
782
|
+
const previousOf = new Map();
|
|
783
|
+
/** The windows whose own start carries this session's target. */
|
|
784
|
+
const roots = new Set();
|
|
785
|
+
for (let offset = 0;; offset += MAX_PAGE_LIMIT) {
|
|
786
|
+
const page = await journal.list({ kinds: ['window_start'], limit: MAX_PAGE_LIMIT, offset });
|
|
787
|
+
for (const event of page.items) {
|
|
788
|
+
if (event.window === null)
|
|
789
|
+
continue;
|
|
790
|
+
startedAt.set(event.window, event.seq);
|
|
791
|
+
if (event.targets.includes(target))
|
|
792
|
+
roots.add(event.window);
|
|
793
|
+
const previous = continuedWindow(event.body);
|
|
794
|
+
if (previous !== undefined)
|
|
795
|
+
previousOf.set(event.window, previous);
|
|
796
|
+
}
|
|
797
|
+
if (page.items.length < MAX_PAGE_LIMIT)
|
|
798
|
+
break;
|
|
799
|
+
}
|
|
800
|
+
const rootOf = (window) => {
|
|
801
|
+
const seen = new Set();
|
|
802
|
+
let current = window;
|
|
803
|
+
while (current !== undefined && !seen.has(current)) {
|
|
804
|
+
if (roots.has(current))
|
|
805
|
+
return current;
|
|
806
|
+
seen.add(current);
|
|
807
|
+
current = previousOf.get(current);
|
|
808
|
+
}
|
|
809
|
+
return undefined;
|
|
810
|
+
};
|
|
811
|
+
return [...startedAt.keys()]
|
|
812
|
+
.filter(window => rootOf(window) !== undefined)
|
|
813
|
+
.sort((left, right) => (startedAt.get(left) ?? 0) - (startedAt.get(right) ?? 0));
|
|
814
|
+
}
|
|
815
|
+
/**
|
|
816
|
+
* Rebuild which session every recorded window serves, from the journal.
|
|
817
|
+
*
|
|
818
|
+
* The two hooks attribute a window the moment it opens — `windowStart` from
|
|
819
|
+
* the task's targets, `windowContinue` from the window it continues — and that
|
|
820
|
+
* map is process memory: it died with the process that wrote it. The journal
|
|
821
|
+
* is the durable record of the same fact, because every `window_start` carries
|
|
822
|
+
* the session's target and a continuation names its predecessor in its body.
|
|
823
|
+
* So the attribution is rebuilt here, before the host starts, so that a window
|
|
824
|
+
* the recovery continues opens with a prefix that still names its session.
|
|
825
|
+
*
|
|
826
|
+
* A window no chain leads back to a target — one dispatched from outside a DSH
|
|
827
|
+
* session — is left unattributed, which is the answer the hooks give it too.
|
|
828
|
+
*/
|
|
829
|
+
async rebuildWindowSessions() {
|
|
830
|
+
const { journal } = this.requireState();
|
|
831
|
+
const previous = new Map();
|
|
832
|
+
const sessions = new Map();
|
|
833
|
+
for (let offset = 0;; offset += MAX_PAGE_LIMIT) {
|
|
834
|
+
const page = await journal.list({ kinds: ['window_start'], limit: MAX_PAGE_LIMIT, offset });
|
|
835
|
+
for (const event of page.items) {
|
|
836
|
+
if (event.window === null)
|
|
837
|
+
continue;
|
|
838
|
+
const label = readLabel(event);
|
|
839
|
+
if (label !== '') {
|
|
840
|
+
// The embedding's own label, which arbor carried onto the window's
|
|
841
|
+
// start and its continuations: no target matching is needed.
|
|
842
|
+
sessions.set(event.window, label);
|
|
843
|
+
}
|
|
844
|
+
else {
|
|
845
|
+
// A journal written before labels carries the target instead.
|
|
846
|
+
for (const target of event.targets) {
|
|
847
|
+
const sessionId = sessionOfTarget(target);
|
|
848
|
+
if (sessionId !== undefined)
|
|
849
|
+
sessions.set(event.window, sessionId);
|
|
850
|
+
}
|
|
851
|
+
}
|
|
852
|
+
const before = continuedWindow(event.body);
|
|
853
|
+
if (before !== undefined)
|
|
854
|
+
previous.set(event.window, before);
|
|
855
|
+
}
|
|
856
|
+
if (page.items.length < MAX_PAGE_LIMIT)
|
|
857
|
+
break;
|
|
858
|
+
}
|
|
859
|
+
// A continuation of a window whose start named no label inherits its
|
|
860
|
+
// predecessor's attribution, however many continuations deep it runs.
|
|
861
|
+
for (const window of previous.keys()) {
|
|
862
|
+
if (sessions.has(window))
|
|
863
|
+
continue;
|
|
864
|
+
let current = previous.get(window);
|
|
865
|
+
const seen = new Set([window]);
|
|
866
|
+
while (current !== undefined && !seen.has(current)) {
|
|
867
|
+
seen.add(current);
|
|
868
|
+
const found = sessions.get(current);
|
|
869
|
+
if (found !== undefined) {
|
|
870
|
+
sessions.set(window, found);
|
|
871
|
+
break;
|
|
872
|
+
}
|
|
873
|
+
current = previous.get(current);
|
|
874
|
+
}
|
|
875
|
+
}
|
|
876
|
+
for (const [window, sessionId] of sessions)
|
|
877
|
+
this.windowSessions.set(window, sessionId);
|
|
878
|
+
}
|
|
879
|
+
/**
|
|
880
|
+
* The window this session's work is running in, when it has a live one.
|
|
881
|
+
*
|
|
882
|
+
* The journal is the record of which window belongs to which session: every
|
|
883
|
+
* `window_start` carries the label its task named, arbor carries that label
|
|
884
|
+
* onto the window's continuations, and a window a restart stopped is continued
|
|
885
|
+
* under a new ref with the same label. So the newest labeled start names the
|
|
886
|
+
* session's window, and it is the live one unless the chain has ended.
|
|
887
|
+
*/
|
|
888
|
+
async windowFor(sessionId) {
|
|
889
|
+
const { host } = this.requireState();
|
|
890
|
+
const newest = (await this.windowChainOf(sessionId)).at(-1);
|
|
891
|
+
if (newest === undefined)
|
|
892
|
+
return undefined;
|
|
893
|
+
return host.live().some(record => record.window === newest && record.state !== 'ended')
|
|
894
|
+
? newest
|
|
895
|
+
: undefined;
|
|
896
|
+
}
|
|
897
|
+
}
|
|
898
|
+
export default ArborHostService;
|