dsh-deeppilot 0.9.2 → 0.9.4

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/lib/index.d.ts CHANGED
@@ -59,7 +59,7 @@ type DispatchOutcome =
59
59
  {
60
60
  kind: 'mismatch';
61
61
  } |
62
- /** 文件损坏 / 目录不可造,journal 已降级。 */
62
+ /** 构造期解析失败:journal 已永久降级,本次进程不再投递。 */
63
63
  {
64
64
  kind: 'unavailable';
65
65
  } |
@@ -81,7 +81,10 @@ type DispatchOutcome =
81
81
  {
82
82
  kind: 'crashed';
83
83
  } |
84
- /** 预留条目时写盘失败。 */
84
+ /**
85
+ * 预留条目时落盘失败——上游**没有**被调用,但无从判断 rename 是否已经
86
+ * 生效(save() 可能在 rename 之后才抛),因此条目留在内存里按 unknown 处理。
87
+ */
85
88
  {
86
89
  kind: 'reserve-failed';
87
90
  } |
@@ -125,12 +128,26 @@ interface DispatchJournalOptions<TRequest = unknown, TResult = unknown> {
125
128
  * mutation 否(重复方立刻拿到 unknown → E_INTERNAL)。两者都由对等快照钉住。
126
129
  */
127
130
  joinInFlight?: boolean;
131
+ /**
132
+ * 落盘失败的诊断输出。由调用方注入它已有的 logger;缺省即静默。
133
+ * 只打阶段与 errno,绝不打条目内容或消息体(plugin 的日志禁令)。
134
+ */
135
+ log?: (message: string) => void;
136
+ /**
137
+ * 目录 fsync 里「对 fd 调用 fsync」这一步的覆盖点,缺省即 `fsyncSync`。
138
+ *
139
+ * 存在的唯一理由是可测:Linux CI 上复现不出 Windows 的 EPERM。注入点刻意
140
+ * 停在 fd 这一层而不是替换整个目录 fsync——容忍 EPERM 的判断必须留在真实
141
+ * 路径里被测到,换掉整函数就等于把待测逻辑一起换掉了。
142
+ */
143
+ fsyncFileDescriptor?: (fd: number) => void;
128
144
  }
129
145
  /** Durable at-most-once dispatch. Unknown outcomes are never automatically retried. */
130
146
  declare class DispatchJournal<TRequest = unknown, TResult = unknown> {
131
147
  private readonly options;
132
148
  private entries;
133
149
  private inFlight;
150
+ /** 只由构造期的解析失败置位,见下方 catch;运行期落盘失败不碰它。 */
134
151
  private healthy;
135
152
  private readonly maxFileBytes;
136
153
  private readonly capacity;
@@ -141,6 +158,15 @@ declare class DispatchJournal<TRequest = unknown, TResult = unknown> {
141
158
  /** id 的纪元前缀是否还在保留窗口内(未来 5 分钟以上同样算非法)。 */
142
159
  private expired;
143
160
  private save;
161
+ /**
162
+ * 落盘一次,失败只报不降级。
163
+ *
164
+ * 与 `healthy` 的分工:`healthy` 只由构造期的解析失败置位,运行期的磁盘错误
165
+ * 不再拉黑整个进程——此前一次 save() 失败就让本进程余下的所有投递永久返回
166
+ * unknown,直到 DSH 重启(issue #20 / #26)。现在只影响当次投递,下一次
167
+ * dispatch 会重新尝试。
168
+ */
169
+ private persist;
144
170
  /** 查询既有回执;`request` 交给 codec 的 matches 判定(缺省即不提供 lookup)。 */
145
171
  lookup(deviceId: string, id: string, request?: unknown): TResult | undefined;
146
172
  /**
@@ -797,6 +823,8 @@ type ScheduleBridgeResult<T> = {
797
823
  declare class HostBridge {
798
824
  private readonly apiProxy;
799
825
  private readonly historyBufferMax;
826
+ /** 三个 journal 的落盘失败诊断输出;缺省即静默。不打任何消息体。 */
827
+ private readonly log?;
800
828
  readonly id: number;
801
829
  private summaries;
802
830
  private activeTools;
@@ -820,7 +848,9 @@ declare class HostBridge {
820
848
  }, DeliveryReceipt>;
821
849
  readonly scheduleMutations: DispatchJournal<unknown, MutationDispatchResult<unknown>>;
822
850
  readonly forkMutations: DispatchJournal<unknown, MutationDispatchResult<unknown>>;
823
- constructor(apiProxy: ApiProxyLike, historyBufferMax?: number, deliveryJournalPath?: string, scheduleJournalPath?: string, forkJournalPath?: string);
851
+ constructor(apiProxy: ApiProxyLike, historyBufferMax?: number, deliveryJournalPath?: string, scheduleJournalPath?: string, forkJournalPath?: string,
852
+ /** 三个 journal 的落盘失败诊断输出;缺省即静默。不打任何消息体。 */
853
+ log?: ((message: string) => void) | undefined);
824
854
  private pushOutlet;
825
855
  private widgetFingerprint;
826
856
  /**
package/lib/index.js CHANGED
@@ -47,6 +47,21 @@ const ERROR_CODES = {
47
47
  const WIRE_ERROR_FALLBACK = "E_INTERNAL";
48
48
  const DOMAIN_TABLES = {
49
49
  session: {
50
+ "session/not-found": "E_NOT_FOUND",
51
+ "workspace/not-found": "E_NOT_FOUND",
52
+ "session/agent-busy": "E_BUSY",
53
+ "session/writer-held": "E_BUSY",
54
+ "session/conflict": "E_BUSY",
55
+ "workspace/session-active": "E_BUSY",
56
+ "gateway/bad-request": "E_PROTOCOL",
57
+ "session/title-invalid": "E_PROTOCOL",
58
+ "session/attachment-invalid": "E_PROTOCOL",
59
+ "session/invalid-time-zone": "E_PROTOCOL",
60
+ "workspace/invalid-path": "E_PROTOCOL",
61
+ "workspace/name-conflict": "E_PROTOCOL",
62
+ "workspace/move-invalid": "E_PROTOCOL",
63
+ "workspace-file/not-found": "E_NOT_FOUND",
64
+ "directory-picker/unavailable": "E_UNSUPPORTED",
50
65
  "session-not-found": "E_NOT_FOUND",
51
66
  "workspace-not-found": "E_NOT_FOUND",
52
67
  "agent-busy": "E_BUSY",
@@ -60,6 +75,11 @@ const DOMAIN_TABLES = {
60
75
  "directory-picker-unavailable": "E_UNSUPPORTED"
61
76
  },
62
77
  model: {
78
+ "session/not-found": "E_NOT_FOUND",
79
+ "session/agent-busy": "E_BUSY",
80
+ "session/writer-held": "E_BUSY",
81
+ "session/conflict": "E_BUSY",
82
+ "session/model-unavailable": "E_NOT_FOUND",
63
83
  "session-not-found": "E_NOT_FOUND",
64
84
  "agent-busy": "E_BUSY",
65
85
  "session-conflict": "E_BUSY",
@@ -80,18 +100,55 @@ const DOMAIN_TABLES = {
80
100
  "subagent_session": "E_PROTOCOL"
81
101
  }
82
102
  };
103
+ /**
104
+ * 剥掉 `namespace/` 前缀:`session/agent-busy` -> `agent-busy`。
105
+ *
106
+ * DSH 的 API 层现在抛的是带命名空间的码(`session/agent-busy`、
107
+ * `workspace/not-found`、`gateway/internal`……),而下面的表一开始只有扁平写法。
108
+ * 两边对不上时整表失配、一律落进 `E_INTERNAL`,于是 `E_BUSY` 永远出不来——
109
+ * 而 `E_BUSY` 正是客户端决定「该不该重试」的那个码(issue #26)。
110
+ */
111
+ function canonicalHostCode(code) {
112
+ const slash = code.lastIndexOf("/");
113
+ return slash === -1 ? code : code.slice(slash + 1);
114
+ }
115
+ /** 查表顺序:原样命中优先,其次剥掉命名空间后命中,最后才是兜底。 */
116
+ function lookupCode(domain, code) {
117
+ const table = DOMAIN_TABLES[domain];
118
+ return table[code] ?? table[canonicalHostCode(code)] ?? "E_INTERNAL";
119
+ }
83
120
  /** 一个 Host 失败翻译成 Bridge 的失败结果:直接携带 wire 错误码。 */
84
121
  function wireErrorOf(domain, error) {
122
+ const code = lookupCode(domain, error.code);
85
123
  return {
86
124
  ok: false,
87
- code: DOMAIN_TABLES[domain][error.code] ?? "E_INTERNAL",
88
- message: error.message ?? error.code
125
+ code,
126
+ message: describeFailure(error, code)
89
127
  };
90
128
  }
129
+ /**
130
+ * 组装给手机看的那句话。
131
+ *
132
+ * DSH 把 prompt 准入的**任意**内部失败包成 `session/agent-busy`,真实原因只留在
133
+ * `details.reason`(`@deepseek-ai/dsh-api-session-controller` 的 `types/commands.js`
134
+ * 里那一句 `throw new RemoteError('session/agent-busy', 'prompt rejected', { reason: … })`)。
135
+ * 插件原先只取 `message`,于是手机端永远只显示笼统的「发送未确认」。
136
+ *
137
+ * 只在落到 `E_INTERNAL` 时追加 reason:这类码按定义对客户端没有可执行含义,
138
+ * 多这一句不改变「该不该重试」的判断,却能让用户和排查者看到真正的原因。已映射
139
+ * 的码本身已经可执行,保持原样不动。
140
+ */
141
+ function describeFailure(error, wireCode) {
142
+ const base = error.message ?? error.code;
143
+ if (wireCode !== "E_INTERNAL") return base;
144
+ const reason = error.details?.reason;
145
+ if (typeof reason !== "string" || reason.length === 0) return base;
146
+ return `${base} (${error.code}): ${reason}`;
147
+ }
91
148
  /** 只有一个 Host code(没有附带 message)时取它的 wire 码。 */
92
149
  function wireCodeFor(domain, hostCode) {
93
150
  if (hostCode === void 0) return WIRE_ERROR_FALLBACK;
94
- return DOMAIN_TABLES[domain][hostCode] ?? "E_INTERNAL";
151
+ return lookupCode(domain, hostCode);
95
152
  }
96
153
  function pendingResponseErrorCode(reason) {
97
154
  switch (reason) {
@@ -4720,6 +4777,11 @@ function createRemoteTransport(deps) {
4720
4777
  * 核心拥有:key 派生、原子写盘(temp + fsync + rename + 目录 fsync)、加载与
4721
4778
  * 逐字段校验、保留清扫、容量、healthy 降级、在途去重、opener 单例。
4722
4779
  *
4780
+ * healthy 的唯一来源是**构造期的解析失败**(文件损坏,续写会写坏格式);
4781
+ * 运行期的落盘失败只影响当次投递,下次投递重新尝试落盘。两者曾经共用同一个
4782
+ * 标志位,一次瞬时磁盘错误就让整个进程的所有投递永久短路到 DSH 重启
4783
+ * (issue #20 / #26)。
4784
+ *
4723
4785
  * codec 拥有:一次投递的全部「方言」。它同时是两个 adapter——prompt 回执与
4724
4786
  * mutation 结果——因此这条缝是真的,不是假想的。
4725
4787
  */
@@ -4727,11 +4789,51 @@ const DEFAULT_CAPACITY = 1e4;
4727
4789
  const DEFAULT_RETENTION_MS = 6048e5;
4728
4790
  /** 比保留窗口多宽限 5 分钟:边界附近的条目不会被反复删了又写。 */
4729
4791
  const SWEEP_GRACE_MS = 3e5;
4792
+ /**
4793
+ * 目录 fsync 的三个「平台不支持」码。
4794
+ *
4795
+ * Windows 上 `openSync(dir, 'r')` 成功但 `fsyncSync(dirfd)` 必定抛错(社区实测
4796
+ * 报 EPERM,issue #20 / #26);EISDIR / EINVAL 是同一现象的其他平台变体。
4797
+ * 这三个码在本调用点都不代表数据损坏:文件内容已在 rename 之前 fsync 过,且
4798
+ * rename 在同一目录内是原子的,跳过的只是目录项的额外耐久性保证。
4799
+ */
4800
+ const TOLERATED_DIR_FSYNC_CODES = /* @__PURE__ */ new Set([
4801
+ "EPERM",
4802
+ "EISDIR",
4803
+ "EINVAL"
4804
+ ]);
4805
+ /** errno 优先,取不到就退回错误本身的字符串(诊断用,不参与控制流)。 */
4806
+ function errnoOf(error) {
4807
+ const code = error?.code;
4808
+ return typeof code === "string" ? code : String(error);
4809
+ }
4810
+ /** 这类错误是否属于「该平台不支持目录 fsync」而非真的写坏了。 */
4811
+ function isToleratedDirectoryFsyncError(error) {
4812
+ return TOLERATED_DIR_FSYNC_CODES.has(errnoOf(error));
4813
+ }
4814
+ /**
4815
+ * 目录 fsync:把 rename 造成的目录项变更推给磁盘。
4816
+ *
4817
+ * 按 errno 而非 `process.platform` 判断:WSL / Wine 之类环境下平台名并不可靠,
4818
+ * 而 errno 才是真正决定「这个平台支不支持」的信号(issue #26 给的两种建议里,
4819
+ * 按 errno 的那种才是对的)。其余 errno(EIO、ENOSPC……)照旧抛出。
4820
+ */
4821
+ function fsyncDirectory(dirPath, fsync = fsyncSync) {
4822
+ const fd = openSync(dirPath, "r");
4823
+ try {
4824
+ fsync(fd);
4825
+ } catch (error) {
4826
+ if (!isToleratedDirectoryFsyncError(error)) throw error;
4827
+ } finally {
4828
+ closeSync(fd);
4829
+ }
4830
+ }
4730
4831
  /** Durable at-most-once dispatch. Unknown outcomes are never automatically retried. */
4731
4832
  var DispatchJournal = class {
4732
4833
  options;
4733
4834
  entries = Object.create(null);
4734
4835
  inFlight = /* @__PURE__ */ new Map();
4836
+ /** 只由构造期的解析失败置位,见下方 catch;运行期落盘失败不碰它。 */
4735
4837
  healthy = true;
4736
4838
  maxFileBytes;
4737
4839
  capacity;
@@ -4782,11 +4884,23 @@ var DispatchJournal = class {
4782
4884
  closeSync(fd);
4783
4885
  }
4784
4886
  renameSync(temp, path);
4785
- const dir = openSync(dirname(path), "r");
4887
+ fsyncDirectory(dirname(path), this.options.fsyncFileDescriptor);
4888
+ }
4889
+ /**
4890
+ * 落盘一次,失败只报不降级。
4891
+ *
4892
+ * 与 `healthy` 的分工:`healthy` 只由构造期的解析失败置位,运行期的磁盘错误
4893
+ * 不再拉黑整个进程——此前一次 save() 失败就让本进程余下的所有投递永久返回
4894
+ * unknown,直到 DSH 重启(issue #20 / #26)。现在只影响当次投递,下一次
4895
+ * dispatch 会重新尝试。
4896
+ */
4897
+ persist() {
4786
4898
  try {
4787
- fsyncSync(dir);
4788
- } finally {
4789
- closeSync(dir);
4899
+ this.save();
4900
+ return true;
4901
+ } catch (error) {
4902
+ this.options.log?.("journal 落盘失败,本次投递按未确认处理,下次投递会重新尝试:" + errnoOf(error));
4903
+ return false;
4790
4904
  }
4791
4905
  }
4792
4906
  /** 查询既有回执;`request` 交给 codec 的 matches 判定(缺省即不提供 lookup)。 */
@@ -4831,12 +4945,7 @@ var DispatchJournal = class {
4831
4945
  ...codec.reserve(request, id)
4832
4946
  };
4833
4947
  this.entries[key] = entry;
4834
- try {
4835
- this.save();
4836
- } catch {
4837
- this.healthy = false;
4838
- return codec.resultOf({ kind: "reserve-failed" }, id);
4839
- }
4948
+ if (!this.persist()) return codec.resultOf({ kind: "reserve-failed" }, id);
4840
4949
  const run = (async () => {
4841
4950
  let ran;
4842
4951
  try {
@@ -4846,11 +4955,7 @@ var DispatchJournal = class {
4846
4955
  } catch {
4847
4956
  return codec.resultOf({ kind: "crashed" }, id);
4848
4957
  }
4849
- try {
4850
- this.save();
4851
- } catch {
4852
- this.healthy = false;
4853
- }
4958
+ this.persist();
4854
4959
  return codec.resultOf({
4855
4960
  kind: "ran",
4856
4961
  entry,
@@ -5163,6 +5268,7 @@ let BRIDGE_SEQ = 0;
5163
5268
  var HostBridge = class {
5164
5269
  apiProxy;
5165
5270
  historyBufferMax;
5271
+ log;
5166
5272
  id = ++BRIDGE_SEQ;
5167
5273
  summaries = /* @__PURE__ */ new Map();
5168
5274
  activeTools = /* @__PURE__ */ new Map();
@@ -5183,26 +5289,30 @@ var HostBridge = class {
5183
5289
  promptDeliveries;
5184
5290
  scheduleMutations;
5185
5291
  forkMutations;
5186
- constructor(apiProxy, historyBufferMax = MAX_RING_DEFAULT, deliveryJournalPath, scheduleJournalPath, forkJournalPath) {
5292
+ constructor(apiProxy, historyBufferMax = MAX_RING_DEFAULT, deliveryJournalPath, scheduleJournalPath, forkJournalPath, log) {
5187
5293
  this.apiProxy = apiProxy;
5188
5294
  this.historyBufferMax = historyBufferMax;
5295
+ this.log = log;
5189
5296
  this.promptDeliveries = openDispatchJournal({
5190
5297
  path: deliveryJournalPath,
5191
5298
  codec: promptDeliveryCodec,
5192
5299
  joinInFlight: true,
5193
- maxFileBytes: 8388608
5300
+ maxFileBytes: 8388608,
5301
+ log: this.log
5194
5302
  });
5195
5303
  this.scheduleMutations = openDispatchJournal({
5196
5304
  path: scheduleJournalPath,
5197
5305
  codec: scheduleMutationCodec(false),
5198
5306
  joinInFlight: false,
5199
- maxFileBytes: 4194304
5307
+ maxFileBytes: 4194304,
5308
+ log: this.log
5200
5309
  });
5201
5310
  this.forkMutations = openDispatchJournal({
5202
5311
  path: forkJournalPath,
5203
5312
  codec: scheduleMutationCodec(true),
5204
5313
  joinInFlight: false,
5205
- maxFileBytes: 4194304
5314
+ maxFileBytes: 4194304,
5315
+ log: this.log
5206
5316
  });
5207
5317
  }
5208
5318
  pushOutlet;
@@ -6956,12 +7066,12 @@ var DshApiProxy = class {
6956
7066
  * Resolve the optional Schedule service lazily, on every read.
6957
7067
  *
6958
7068
  * The bridge is built from a `ctx.inject` on sessionController / connection
6959
- * / typertGateway, and `schedule` is deliberately NOT in that list: it only
6960
- * exists when the user enables the optional Automation tasks bundle. On a
6961
- * DSH 0.2.0 host those three services become ready well before
6962
- * ScheduleService finishes its own async init, so a value captured here in
6963
- * the constructor stayed `undefined` for the bridge's whole lifetime and
6964
- * welcome advertised `schedules=false` even with the bundle mounted.
7069
+ * / typertGateway, and `schedule` is deliberately NOT in that list: it is a
7070
+ * capability the host composition may or may not mount. On a DSH 0.2.0 host
7071
+ * those three services become ready well before ScheduleService finishes its
7072
+ * own async init, so a value captured in the constructor stayed `undefined`
7073
+ * for the bridge's whole lifetime and welcome advertised `schedules=false`
7074
+ * even with the service mounted.
6965
7075
  *
6966
7076
  * Once resolved the controller is kept: services are not torn down and
6967
7077
  * rebuilt underneath a live bridge, so re-resolving per call would be pure
@@ -7072,7 +7182,7 @@ var DshApiProxy = class {
7072
7182
  rename: async (request) => this.call(() => this.session.rename(request.payload)),
7073
7183
  cancel: async (request) => this.call(() => this.session.cancel(request.payload)),
7074
7184
  attachment: async (request) => this.call(() => this.session.attachment(request.payload)),
7075
- projections: async (request) => this.call(() => this.session.projections(request.payload))
7185
+ projections: async (request) => this.call(() => this.session.projections(request.payload, new AbortController().signal))
7076
7186
  };
7077
7187
  workspace = {
7078
7188
  list: async () => this.call(async () => {
@@ -7841,16 +7951,49 @@ function applyReportRemote(ctx, snapshot, pairingStarter, deviceRevoker, deviceR
7841
7951
  }
7842
7952
  //#endregion
7843
7953
  //#region src/local-address.ts
7844
- function isPrivateIPv4(address) {
7954
+ function octetsOf(address) {
7845
7955
  const octets = address.split(".").map(Number);
7846
- if (octets.length !== 4 || octets.some((value) => !Number.isInteger(value) || value < 0 || value > 255)) return false;
7847
- const [a, b] = octets;
7956
+ if (octets.length !== 4 || octets.some((value) => !Number.isInteger(value) || value < 0 || value > 255)) return null;
7957
+ return octets;
7958
+ }
7959
+ /** RFC1918 私有地址。名字保持诚实:它**不**包含 CGNAT 段,见 isSharedAddressSpaceIPv4。 */
7960
+ function isPrivateIPv4(address) {
7961
+ const parsed = octetsOf(address);
7962
+ if (parsed === null) return false;
7963
+ const [a, b] = parsed;
7848
7964
  return a === 10 || a === 172 && b >= 16 && b <= 31 || a === 192 && b === 168;
7849
7965
  }
7966
+ /**
7967
+ * RFC 6598 共享地址空间(100.64.0.0/10)。
7968
+ *
7969
+ * Tailscale 与 Headscale 都从这个段里给节点分配地址(Headscale 的
7970
+ * `prefixes.v4` 默认就是它),因此它在实践中是私有的,只是不是 RFC1918——
7971
+ * 这正是 `isPrivateIPv4` 会把它拒掉的原因。把它单列成一个谓词而不是塞进
7972
+ * `isPrivateIPv4`,是为了不谎报那个名字(issue #24)。
7973
+ */
7974
+ function isSharedAddressSpaceIPv4(address) {
7975
+ const parsed = octetsOf(address);
7976
+ if (parsed === null) return false;
7977
+ const [a, b] = parsed;
7978
+ return a === 100 && b >= 64 && b <= 127;
7979
+ }
7980
+ /**
7981
+ * 可以交给手机的私有地址候选:RFC1918 加 CGNAT 段。
7982
+ *
7983
+ * 候选多于一个时,配对二维码页才会渲染地址选择器——只有一个候选时用户没有
7984
+ * 任何办法指定别的地址。把 tailnet 地址放进来,选择器才会出现,物理局域网
7985
+ * 离开之后用户仍可切到 tailnet 那个。
7986
+ *
7987
+ * 排序**不动**:物理 en* 接口仍然排在隧道接口之前,默认拿到的还是局域网地址,
7988
+ * 改变的只是「多了一个可选项」这件事本身。
7989
+ */
7990
+ function isRoutablePrivateIPv4(address) {
7991
+ return isPrivateIPv4(address) || isSharedAddressSpaceIPv4(address);
7992
+ }
7850
7993
  /** Private IPv4 candidates, preferring physical en* interfaces over tunnels. */
7851
7994
  function localLANIPv4Addresses() {
7852
7995
  const candidates = [];
7853
- for (const [name, entries] of Object.entries(networkInterfaces())) for (const entry of entries ?? []) if (entry.family === "IPv4" && !entry.internal && isPrivateIPv4(entry.address)) candidates.push({
7996
+ for (const [name, entries] of Object.entries(networkInterfaces())) for (const entry of entries ?? []) if (entry.family === "IPv4" && !entry.internal && isRoutablePrivateIPv4(entry.address)) candidates.push({
7854
7997
  name,
7855
7998
  address: entry.address
7856
7999
  });
@@ -8785,7 +8928,7 @@ function apply(ctx, options) {
8785
8928
  log("DSH session bridge unavailable: " + String(error));
8786
8929
  return;
8787
8930
  }
8788
- const bridge = new HostBridge(proxy, cfg.historyBufferMax, join(dataDir, "prompt-deliveries-v1.json"), join(dataDir, "schedule-mutations-v1.json"), join(dataDir, "fork-mutations-v1.json"));
8931
+ const bridge = new HostBridge(proxy, cfg.historyBufferMax, join(dataDir, "prompt-deliveries-v1.json"), join(dataDir, "schedule-mutations-v1.json"), join(dataDir, "fork-mutations-v1.json"), log);
8789
8932
  bridge.setPushOutlet(pushGateway);
8790
8933
  state.bridge = bridge;
8791
8934
  bridge.start();