@co0ontty/wand 4.66.0 → 4.67.0-beta.gf935003
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +1 -0
- package/dist/build-info.json +4 -4
- package/dist/git-worktree.js +10 -1
- package/dist/server-file-routes.js +5 -1
- package/dist/server-session-routes.js +2 -1
- package/dist/server-settings-routes.js +29 -23
- package/dist/server-workspace-routes.js +18 -5
- package/dist/server.js +2 -2
- package/dist/session-registry.js +2 -6
- package/dist/storage.d.ts +2 -0
- package/dist/storage.js +12 -7
- package/dist/structured-pi-adapter.d.ts +6 -1
- package/dist/structured-pi-adapter.js +31 -4
- package/dist/structured-runner.d.ts +2 -0
- package/dist/structured-session-manager.d.ts +6 -1
- package/dist/structured-session-manager.js +115 -34
- package/dist/terminal-daemon-client.d.ts +1 -1
- package/dist/terminal-daemon-client.js +18 -13
- package/dist/types.d.ts +2 -0
- package/dist/wand-task-sync.js +28 -4
- package/dist/web-ui/content/scripts.js +3572 -3569
- package/dist/web-ui/content/styles.css +1 -1
- package/dist/web-ui/content/tailwind.css +20 -434
- package/dist/web-ui/embedded-assets.js +2 -2
- package/package.json +3 -2
package/README.md
CHANGED
package/dist/build-info.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
|
-
"commit": "
|
|
3
|
-
"builtAt": "2026-09-
|
|
4
|
-
"version": "4.
|
|
5
|
-
"channel": "
|
|
2
|
+
"commit": "f935003c09101e33e46544770329311baa98c26e",
|
|
3
|
+
"builtAt": "2026-09-17T01:35:10.525Z",
|
|
4
|
+
"version": "4.67.0-beta.gf935003",
|
|
5
|
+
"channel": "beta"
|
|
6
6
|
}
|
package/dist/git-worktree.js
CHANGED
|
@@ -409,7 +409,16 @@ export async function mergeSessionWorktreeAsync(options) {
|
|
|
409
409
|
}
|
|
410
410
|
export function prepareSessionWorktree(options) {
|
|
411
411
|
const resolvedCwd = path.resolve(options.cwd);
|
|
412
|
-
|
|
412
|
+
let repoRoot;
|
|
413
|
+
try {
|
|
414
|
+
repoRoot = runGit(["rev-parse", "--show-toplevel"], resolvedCwd);
|
|
415
|
+
}
|
|
416
|
+
catch (error) {
|
|
417
|
+
if (/not a git repository/i.test(getGitErrorMessage(error))) {
|
|
418
|
+
throw new Error("当前目录不是 Git 仓库。请选择 Git 仓库,或关闭“独立 worktree 隔离”后重试。");
|
|
419
|
+
}
|
|
420
|
+
throw error;
|
|
421
|
+
}
|
|
413
422
|
if (!repoRoot || !existsSync(repoRoot)) {
|
|
414
423
|
throw new Error("当前目录不在 git 仓库中,无法启用 worktree 模式。");
|
|
415
424
|
}
|
|
@@ -182,7 +182,11 @@ export function registerFileRoutes(app, deps) {
|
|
|
182
182
|
return;
|
|
183
183
|
}
|
|
184
184
|
try {
|
|
185
|
-
const fileStat = await
|
|
185
|
+
const fileStat = await lstat(resolvedPath);
|
|
186
|
+
if (fileStat.isSymbolicLink()) {
|
|
187
|
+
res.status(400).json({ error: "无法保存符号链接,请打开目标文件后编辑。" });
|
|
188
|
+
return;
|
|
189
|
+
}
|
|
186
190
|
if (fileStat.isDirectory()) {
|
|
187
191
|
res.status(400).json({ error: "目标是目录,无法写入。" });
|
|
188
192
|
return;
|
|
@@ -401,7 +401,8 @@ export function registerSessionRoutes(app, processes, structured, storage, defau
|
|
|
401
401
|
}
|
|
402
402
|
try {
|
|
403
403
|
const tree = currentDirectoryTree();
|
|
404
|
-
|
|
404
|
+
const hasWorkspace = storage.listWorkspaces().some((workspace) => workspace.kind !== "global" && normalizeSessionDirectory(workspace.cwd) === directoryPath);
|
|
405
|
+
if (!directoryTreeContainsPath(tree.roots, directoryPath) && !hasWorkspace) {
|
|
405
406
|
res.status(404).json({ error: "未找到该会话目录。" });
|
|
406
407
|
return;
|
|
407
408
|
}
|
|
@@ -105,30 +105,36 @@ export function registerSettingsRoutes(app, deps) {
|
|
|
105
105
|
});
|
|
106
106
|
}));
|
|
107
107
|
app.get("/api/settings/env-preview", (req, res, next) => {
|
|
108
|
-
if (req.query.reveal === "1")
|
|
109
|
-
return requireAdmin(req, res, next);
|
|
110
|
-
next();
|
|
111
|
-
}, (req, res) => {
|
|
112
|
-
const inheritEnv = config.inheritEnv !== false;
|
|
113
|
-
const env = buildChildEnv(inheritEnv, {
|
|
114
|
-
WAND_MODE: "<runtime>",
|
|
115
|
-
WAND_AUTO_CONFIRM: "<runtime>",
|
|
116
|
-
WAND_AUTO_EDIT: "<runtime>",
|
|
117
|
-
});
|
|
118
108
|
const reveal = req.query.reveal === "1" || req.query.reveal === "true";
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
109
|
+
if (reveal) {
|
|
110
|
+
return requireAdmin(req, res, (error) => {
|
|
111
|
+
if (error)
|
|
112
|
+
return next(error);
|
|
113
|
+
sendEnvironmentPreview(true);
|
|
114
|
+
});
|
|
115
|
+
}
|
|
116
|
+
sendEnvironmentPreview(false);
|
|
117
|
+
function sendEnvironmentPreview(reveal) {
|
|
118
|
+
const inheritEnv = config.inheritEnv !== false;
|
|
119
|
+
const env = buildChildEnv(inheritEnv, {
|
|
120
|
+
WAND_MODE: "<runtime>",
|
|
121
|
+
WAND_AUTO_CONFIRM: "<runtime>",
|
|
122
|
+
WAND_AUTO_EDIT: "<runtime>",
|
|
123
|
+
});
|
|
124
|
+
const sensitivePattern = /(KEY|TOKEN|SECRET|PASSWORD|AUTH|CREDENTIAL|COOKIE|SESSION)/i;
|
|
125
|
+
const entries = Object.keys(env).sort().map((name) => {
|
|
126
|
+
const raw = env[name] ?? "";
|
|
127
|
+
const sensitive = sensitivePattern.test(name);
|
|
128
|
+
const placeholder = raw.startsWith("<") && raw.endsWith(">");
|
|
129
|
+
return {
|
|
130
|
+
name,
|
|
131
|
+
value: sensitive && !reveal && !placeholder ? "***" : raw,
|
|
132
|
+
length: raw.length,
|
|
133
|
+
sensitive,
|
|
134
|
+
};
|
|
135
|
+
});
|
|
136
|
+
res.json({ inheritEnv, total: entries.length, reveal, entries });
|
|
137
|
+
}
|
|
132
138
|
});
|
|
133
139
|
app.post("/api/settings/system-ai/import", requireAdmin, (_req, res) => {
|
|
134
140
|
const imported = discoverCliSystemAiConfigs();
|
|
@@ -485,8 +485,20 @@ export function registerWorkspaceRoutes(app, storage, sessions) {
|
|
|
485
485
|
groups.set(id, created);
|
|
486
486
|
return created;
|
|
487
487
|
};
|
|
488
|
+
const tasksByWorkspace = new Map(workspaces.map((workspace) => [
|
|
489
|
+
workspace.id, storage.listWorkspaceTasks(workspace.id),
|
|
490
|
+
]));
|
|
491
|
+
const sessionsByTask = new Map();
|
|
492
|
+
const taskSessions = (taskId) => {
|
|
493
|
+
let snapshots = sessionsByTask.get(taskId);
|
|
494
|
+
if (!snapshots) {
|
|
495
|
+
snapshots = storage.listSessionsByWorkspaceTask(taskId);
|
|
496
|
+
sessionsByTask.set(taskId, snapshots);
|
|
497
|
+
}
|
|
498
|
+
return snapshots;
|
|
499
|
+
};
|
|
488
500
|
const appendTask = (target, workspace, task) => {
|
|
489
|
-
const allSessions =
|
|
501
|
+
const allSessions = taskSessions(task.id);
|
|
490
502
|
const sessions = allSessions
|
|
491
503
|
.slice(0, sessionLimit ?? undefined)
|
|
492
504
|
.map((session) => ({
|
|
@@ -508,7 +520,7 @@ export function registerWorkspaceRoutes(app, storage, sessions) {
|
|
|
508
520
|
if (!base)
|
|
509
521
|
continue;
|
|
510
522
|
const global = isGlobalWorkspace(workspace);
|
|
511
|
-
for (const task of
|
|
523
|
+
for (const task of tasksByWorkspace.get(workspace.id).slice(0, taskLimit ?? undefined)) {
|
|
512
524
|
const taskCwd = taskRuntimeCwd(task, workspace);
|
|
513
525
|
const target = global && taskCwd
|
|
514
526
|
&& normalizeProjectCwd(taskCwd) !== normalizeProjectCwd(workspace.cwd)
|
|
@@ -518,9 +530,10 @@ export function registerWorkspaceRoutes(app, storage, sessions) {
|
|
|
518
530
|
}
|
|
519
531
|
}
|
|
520
532
|
const taskBoundSessionIds = new Set();
|
|
521
|
-
|
|
522
|
-
|
|
523
|
-
|
|
533
|
+
// Membership must include tasks/sessions hidden by the response limits.
|
|
534
|
+
for (const tasks of tasksByWorkspace.values()) {
|
|
535
|
+
for (const task of tasks) {
|
|
536
|
+
for (const session of taskSessions(task.id))
|
|
524
537
|
taskBoundSessionIds.add(session.id);
|
|
525
538
|
}
|
|
526
539
|
}
|
package/dist/server.js
CHANGED
|
@@ -319,8 +319,8 @@ export async function startServer(config, configPath, options = {}) {
|
|
|
319
319
|
const terminalHost = options.terminalHost ?? await createTerminalHost(configPath);
|
|
320
320
|
const processes = new ProcessManager(config, storage, configDir, terminalHost);
|
|
321
321
|
const structuredLogger = new SessionLogger(configDir, config.shortcutLogMaxBytes);
|
|
322
|
-
//
|
|
323
|
-
//
|
|
322
|
+
// Production startup requires the daemon-backed host so CLI runs outlive web
|
|
323
|
+
// restarts. In-process hosts are retained only for explicit test injection.
|
|
324
324
|
const structuredExecHost = terminalHost instanceof TerminalDaemonClient ? terminalHost : undefined;
|
|
325
325
|
const structuredSessions = new StructuredSessionManager(storage, config, structuredLogger, undefined, {}, structuredExecHost);
|
|
326
326
|
const sessionRegistry = new SessionRegistry(processes, structuredSessions, storage);
|
package/dist/session-registry.js
CHANGED
|
@@ -1,9 +1,5 @@
|
|
|
1
1
|
import { cleanupWorktreeSync } from "./git-worktree.js";
|
|
2
2
|
import { inferProviderFromCommand } from "./session-provider.js";
|
|
3
|
-
function slimSnapshot(snapshot) {
|
|
4
|
-
const { output: _output, messages: _messages, ...slim } = snapshot;
|
|
5
|
-
return { ...slim, output: "" };
|
|
6
|
-
}
|
|
7
3
|
/** 读取 `hidden_claude_session_ids`;坏数据 / 空值按空集合处理,不抛错。 */
|
|
8
4
|
export function readHiddenSessionIds(storage) {
|
|
9
5
|
const raw = storage.getConfigValue("hidden_claude_session_ids");
|
|
@@ -82,9 +78,9 @@ export class SessionRegistry {
|
|
|
82
78
|
if (!byId.has(snapshot.id))
|
|
83
79
|
byId.set(snapshot.id, snapshot);
|
|
84
80
|
}
|
|
85
|
-
for (const snapshot of this.storage.
|
|
81
|
+
for (const snapshot of this.storage.loadSessionsSlim()) {
|
|
86
82
|
if (!byId.has(snapshot.id))
|
|
87
|
-
byId.set(snapshot.id,
|
|
83
|
+
byId.set(snapshot.id, snapshot);
|
|
88
84
|
}
|
|
89
85
|
return Array.from(byId.values()).sort((a, b) => b.startedAt.localeCompare(a.startedAt));
|
|
90
86
|
}
|
package/dist/storage.d.ts
CHANGED
|
@@ -229,6 +229,8 @@ export declare class WandStorage {
|
|
|
229
229
|
getSession(id: string): SessionSnapshot | null;
|
|
230
230
|
getLatestSessionByClaudeSessionId(claudeSessionId: string): SessionSnapshot | null;
|
|
231
231
|
loadSessions(): SessionSnapshot[];
|
|
232
|
+
/** List durable metadata without reading or parsing output/messages payloads. */
|
|
233
|
+
loadSessionsSlim(): SessionSnapshot[];
|
|
232
234
|
private mapSessionRow;
|
|
233
235
|
deleteSession(id: string): void;
|
|
234
236
|
}
|
package/dist/storage.js
CHANGED
|
@@ -251,8 +251,8 @@ function mapWorktreeMergeFields(row) {
|
|
|
251
251
|
worktreeMergeInfo: parseWorktreeMergeInfo(row.worktree_merge_info) ?? null,
|
|
252
252
|
};
|
|
253
253
|
}
|
|
254
|
-
function sessionSelectFields() {
|
|
255
|
-
return `id, session_source, automation_id, provider, session_kind, runner, command, cwd, mode, status, exit_code, started_at, ended_at, output, pty_output_seq, archived, archived_at, claude_session_id, messages, queued_messages, queued_message_skills, structured_state
|
|
254
|
+
function sessionSelectFields(slim = false) {
|
|
255
|
+
return `id, session_source, automation_id, provider, session_kind, runner, command, cwd, mode, status, exit_code, started_at, ended_at, ${slim ? "'' AS output" : "output"}, pty_output_seq, archived, archived_at, claude_session_id, ${slim ? "NULL AS messages" : "messages"}, queued_messages, queued_message_skills, structured_state
|
|
256
256
|
, resumed_from_session_id, auto_recovered, worktree_enabled, worktree_info, worktree_merge_status, worktree_merge_info, title, description, session_options, workspace_id, workspace_task_id`;
|
|
257
257
|
}
|
|
258
258
|
function mapWorkspaceKind(raw) {
|
|
@@ -1149,11 +1149,11 @@ export class WandStorage {
|
|
|
1149
1149
|
COALESCE(MAX(ended_at), '') AS ended,
|
|
1150
1150
|
COALESCE(MAX(title), '') AS title
|
|
1151
1151
|
FROM command_sessions`).get();
|
|
1152
|
-
|
|
1153
|
-
|
|
1154
|
-
|
|
1155
|
-
|
|
1156
|
-
FROM workspace_tasks`).
|
|
1152
|
+
// Per-task metadata catches edits even when another task owns the maximum
|
|
1153
|
+
// timestamp/revision. Layout payloads are represented by their own revision.
|
|
1154
|
+
const tasks = this.db.prepare(`SELECT id, workspace_id, name, status, cwd, worktree_json, milestone_id,
|
|
1155
|
+
created_at, last_opened_at, layout_revision
|
|
1156
|
+
FROM workspace_tasks ORDER BY id`).all();
|
|
1157
1157
|
const workspaces = this.db.prepare(`SELECT COUNT(*) AS count, COALESCE(MAX(last_opened_at), '') AS opened FROM workspaces`).get();
|
|
1158
1158
|
// 名称也进指纹:改名(含工作区/项目名与目录自定义名)后客户端才肯重拉任务列表。
|
|
1159
1159
|
const names = this.db.prepare(`SELECT
|
|
@@ -1725,6 +1725,11 @@ export class WandStorage {
|
|
|
1725
1725
|
.all();
|
|
1726
1726
|
return rows.map((row) => this.mapSessionRow(row));
|
|
1727
1727
|
}
|
|
1728
|
+
/** List durable metadata without reading or parsing output/messages payloads. */
|
|
1729
|
+
loadSessionsSlim() {
|
|
1730
|
+
const rows = this.db.prepare(`SELECT ${sessionSelectFields(true)} FROM command_sessions ORDER BY started_at DESC`).all();
|
|
1731
|
+
return rows.map((row) => this.mapSessionRow(row));
|
|
1732
|
+
}
|
|
1728
1733
|
mapSessionRow(row) {
|
|
1729
1734
|
return mapSessionCore(row);
|
|
1730
1735
|
}
|
|
@@ -2,12 +2,17 @@ import { spawn } from "node:child_process";
|
|
|
2
2
|
import type { StructuredExecHost } from "./structured-exec-host.js";
|
|
3
3
|
import type { StructuredRunnerAdapter, StructuredRunnerContext, StructuredRunnerExecution, StructuredRunnerObserver, StructuredRunnerTurnState } from "./structured-runner.js";
|
|
4
4
|
import type { SessionSnapshot } from "./types.js";
|
|
5
|
+
type PiTurnState = StructuredRunnerTurnState & {
|
|
6
|
+
pendingSessionId?: string;
|
|
7
|
+
};
|
|
8
|
+
export declare function isMissingPiSession(error: string | null | undefined, sessionId: string | null | undefined): boolean;
|
|
5
9
|
export declare function buildPiArgs(session: SessionSnapshot, prompt: string): string[];
|
|
6
10
|
export declare function piToolName(name: string): string;
|
|
7
|
-
export declare function applyPiEvent(state:
|
|
11
|
+
export declare function applyPiEvent(state: PiTurnState, event: Record<string, unknown>): string | null;
|
|
8
12
|
export declare class PiRunner implements StructuredRunnerAdapter {
|
|
9
13
|
private readonly spawnProcess;
|
|
10
14
|
private readonly execHost?;
|
|
11
15
|
constructor(spawnProcess?: typeof spawn, execHost?: StructuredExecHost | undefined);
|
|
12
16
|
start(context: StructuredRunnerContext, observer: StructuredRunnerObserver): StructuredRunnerExecution;
|
|
13
17
|
}
|
|
18
|
+
export {};
|
|
@@ -2,6 +2,9 @@ import { spawn } from "node:child_process";
|
|
|
2
2
|
import { startStructuredCli } from "./structured-exec-pump.js";
|
|
3
3
|
import { asRecord } from "./structured-content.js";
|
|
4
4
|
import { thinkingEffortToPiLevel } from "./structured-provider-common.js";
|
|
5
|
+
export function isMissingPiSession(error, sessionId) {
|
|
6
|
+
return !!sessionId && !!error?.includes(`No session found matching '${sessionId}'`);
|
|
7
|
+
}
|
|
5
8
|
function textContent(value) {
|
|
6
9
|
if (typeof value === "string")
|
|
7
10
|
return value;
|
|
@@ -88,9 +91,15 @@ function applyPiAssistantMessage(state, message) {
|
|
|
88
91
|
return null;
|
|
89
92
|
}
|
|
90
93
|
export function applyPiEvent(state, event) {
|
|
94
|
+
// Pi announces the ID before it writes a session file. The file is only
|
|
95
|
+
// created when an assistant message is saved, so this ID is not resumable yet.
|
|
91
96
|
if (event.type === "session" && typeof event.id === "string")
|
|
92
|
-
state.
|
|
97
|
+
state.pendingSessionId = event.id;
|
|
98
|
+
if (event.type === "turn_start" || event.type === "message_start" || event.type === "tool_execution_start") {
|
|
99
|
+
state.phase = "responding";
|
|
100
|
+
}
|
|
93
101
|
if (event.type === "message_update") {
|
|
102
|
+
state.phase = "responding";
|
|
94
103
|
const update = asRecord(event.assistantMessageEvent);
|
|
95
104
|
const delta = typeof update?.delta === "string" ? update.delta : "";
|
|
96
105
|
if (update?.type === "text_delta" && delta) {
|
|
@@ -136,13 +145,25 @@ export function applyPiEvent(state, event) {
|
|
|
136
145
|
}
|
|
137
146
|
if (event.type === "message_end" || event.type === "turn_end") {
|
|
138
147
|
const message = asRecord(event.message);
|
|
139
|
-
if (message?.role === "assistant")
|
|
140
|
-
|
|
148
|
+
if (message?.role === "assistant") {
|
|
149
|
+
if (state.pendingSessionId)
|
|
150
|
+
state.sessionId = state.pendingSessionId;
|
|
151
|
+
const error = applyPiAssistantMessage(state, message);
|
|
152
|
+
if (event.type === "turn_end") {
|
|
153
|
+
const content = Array.isArray(message.content) ? message.content : [];
|
|
154
|
+
const hasToolCall = content.some((part) => asRecord(part)?.type === "toolCall");
|
|
155
|
+
if (!hasToolCall && message.stopReason !== "toolUse")
|
|
156
|
+
state.phase = "background";
|
|
157
|
+
}
|
|
158
|
+
return error;
|
|
159
|
+
}
|
|
141
160
|
}
|
|
142
161
|
if (event.type === "agent_end" && Array.isArray(event.messages)) {
|
|
143
162
|
for (const raw of event.messages) {
|
|
144
163
|
const message = asRecord(raw);
|
|
145
164
|
if (message?.role === "assistant") {
|
|
165
|
+
if (state.pendingSessionId)
|
|
166
|
+
state.sessionId = state.pendingSessionId;
|
|
146
167
|
const error = applyPiAssistantMessage(state, message);
|
|
147
168
|
if (error)
|
|
148
169
|
return error;
|
|
@@ -160,7 +181,13 @@ export class PiRunner {
|
|
|
160
181
|
}
|
|
161
182
|
start(context, observer) {
|
|
162
183
|
const args = buildPiArgs(context.session, context.prompt);
|
|
163
|
-
const state = {
|
|
184
|
+
const state = {
|
|
185
|
+
blocks: [],
|
|
186
|
+
result: "",
|
|
187
|
+
sessionId: context.session.claudeSessionId,
|
|
188
|
+
model: context.session.selectedModel ?? undefined,
|
|
189
|
+
phase: "responding",
|
|
190
|
+
};
|
|
164
191
|
let primaryError = null;
|
|
165
192
|
return startStructuredCli({
|
|
166
193
|
sessionId: context.session.id,
|
|
@@ -5,6 +5,8 @@ export interface StructuredRunnerTurnState {
|
|
|
5
5
|
sessionId: string | null;
|
|
6
6
|
model?: string;
|
|
7
7
|
usage?: ConversationTurn["usage"];
|
|
8
|
+
/** Provider turn phase when the CLI stays alive after its visible reply. */
|
|
9
|
+
phase?: "responding" | "background";
|
|
8
10
|
}
|
|
9
11
|
export interface StructuredRunnerContext {
|
|
10
12
|
session: SessionSnapshot;
|
|
@@ -101,14 +101,19 @@ export declare class StructuredSessionManager {
|
|
|
101
101
|
private readonly piRunner;
|
|
102
102
|
/** Structured CLI runs that were mid-flight when the previous web process died. */
|
|
103
103
|
private pendingRecoveryIds;
|
|
104
|
+
private detachedRecoveryPromise;
|
|
105
|
+
private detachedRecoveryRetryTimer;
|
|
106
|
+
private detachedRecoveryFailureLogged;
|
|
104
107
|
private disposed;
|
|
105
108
|
constructor(storage: WandStorage, config: WandConfig, logger?: SessionLogger | null, sdkQueryFactory?: typeof sdkQuery, runners?: StructuredSessionManagerRunners, execHost?: StructuredExecHost | undefined);
|
|
106
109
|
private archiveExpiredSessions;
|
|
107
110
|
setEventEmitter(emitEvent: (event: ProcessEvent) => void): void;
|
|
108
111
|
/** Stop every runner and flush terminal state before storage is closed. */
|
|
109
112
|
dispose(): void;
|
|
110
|
-
/** Called once after startup wiring;
|
|
113
|
+
/** Called once after startup wiring; transient daemon failures retry automatically. */
|
|
111
114
|
recoverDetachedRuns(): Promise<void>;
|
|
115
|
+
private recoverDetachedRunsOnce;
|
|
116
|
+
private scheduleDetachedRecovery;
|
|
112
117
|
private resumeDetachedRun;
|
|
113
118
|
private finalizeRecoveredRun;
|
|
114
119
|
private trackStreamEmitTimer;
|