dsh-crate-web 0.1.2-preview.2 → 0.1.2-preview.5

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 CHANGED
@@ -1,11 +1,11 @@
1
1
  # DSH Crate — Preview
2
2
 
3
- DSH Crate 为 DeepSeek Harness 的设置页提供环境包导出、检查、恢复、验证和历史记录。正式 npm 包名是 `dsh-crate-web`;本候选为 `0.1.2-preview.2`,使用 `preview` 标签,保留既有 `latest`。
3
+ DSH Crate 为 DeepSeek Harness 的设置页提供环境包导出、检查、恢复、验证和历史记录。正式 npm 包名是 `dsh-crate-web`;本候选为 `0.1.2-preview.5`,使用 `preview` 标签,保留既有 `latest`。
4
4
 
5
5
  可通过官方插件入口安装明确版本:
6
6
 
7
7
  ```powershell
8
- dsh plugin --profile web add dsh-crate-web@0.1.2-preview.2
8
+ dsh plugin --profile web add dsh-crate-web@0.1.2-preview.5
9
9
  ```
10
10
 
11
11
  重启后打开 Settings → DSH Crate。离线环境可从同版本 tgz 安装。Windows x64 随包保留 Python 3.14.6,无需另外安装系统 Python。其他系统未完成本轮桌面验收。
@@ -18,6 +18,10 @@ Import 默认恢复到新 Profile,先执行 Preflight 和计划检查。覆盖
18
18
 
19
19
  已知限制:Windows 上 Import 偶发在临时 Profile 改为正式目录名时返回 WinError 5。界面会提示文件可能仍被占用或权限不足,并保留完整原始错误与回滚结果。请关闭正在使用目标目录的文件预览、编辑器或终端,核实权限与残留状态后手动重新导入;程序不会自动重试。历史故障的实际占用者尚未定位,当前提示不代表根因已修复。
20
20
 
21
- 本版本按维护者确认作为 Preview 发布:上述偶发问题、公共包身份下尚未完成的完整桌面复测,以及 Inspector 首启耗时未完全归因,均保留为已知限制。后续修复会升新版本发布,不覆盖本版本,也不把现有 UNTESTED 或 FAIL 改写为通过。
21
+ 本版本继续保留 Preview 限制:上述偶发问题、公共包身份下尚未完成的完整桌面复测,以及 Inspector 首启耗时未完全归因,均保留为已知限制。后续修复会升新版本发布,不覆盖本版本,也不把现有 UNTESTED 或 FAIL 改写为通过。
22
22
 
23
23
  工作流先扫描提交与实际 tgz,再让发布任务复用同一 tgz 并核对 SHA-256。新代码必须升 prerelease 版本;同版本不同内容会停止。现有 npm Trusted Publisher 对应本仓库和 `publish-dsh-crate-web.yml`,无需把长期 npm Token 写入仓库。
24
+
25
+ 宿主依赖:DSH 模块与 React 由目标 DSH 宿主提供,npm 将这些 peer 标记为 optional,避免在空 Profile 中自动安装另一套旧 DSH。这不表示插件可以脱离 DSH 运行,也不跳过实际宿主服务检查;客户端 UI 组件仍须由 DSH 模块加载器提供。发布流程对最终 tgz 执行普通干净安装,不使用 legacy-peer-deps 或 force。官方 CLI 与真实桌面的覆盖以对应版本的验证报告为准。
26
+
27
+ 本候选修复了两个实际入口问题:无系统 npm 时,Export 可以复用桌面提供的 pnpm,禁用打包脚本与钩子并保留源清单;默认 Verify 根据完整的运行插件清单判断 Inspector 端点是否适用,清单不完整或已启用的 Inspector 缺监听时仍失败。Windows x64 / 原装 DSH 0.2.1-rc.5 下,前一候选的页面导出、下载、检查和新 Profile 恢复真实通过;本候选使用同一配置、仅更新 Crate 制品的诊断包,真实通过恢复、默认 Verify 的组合、启动、页面就绪、空白会话动作和清理。会话动作遵循官方行为,复用了已有空白会话,没有制造新的会话文件。默认 Verify 总结果仍为 UNTESTED:零模型,没有覆盖模型、原生工具、计划重启和其后的插件测试;本轮也没有覆盖启用 Inspector 的真实运行或历史 Worker 取消故障。诊断包不是本候选重新完成的页面 Export,不能将两轮证据合称为完整候选验收。
@@ -1 +1 @@
1
- __version__ = "0.1.2-preview.2"
1
+ __version__ = "0.1.2-preview.5"
@@ -8,7 +8,7 @@ import os
8
8
  from pathlib import Path
9
9
  from typing import Sequence
10
10
 
11
- from .errors import DshPackError, PackImportError
11
+ from .errors import DshPackError, ExportError, PackImportError
12
12
  from .export import EMBEDDED, REFERENCE_ONLY, ExportOptions, PluginExportOptions, export_profile
13
13
  from .importer import ImportOptions, create_import_plan, delete_profile, import_pack
14
14
  from .preflight import PreflightContext, inspect_pack, render_text
@@ -250,7 +250,7 @@ def _run_export(args: argparse.Namespace) -> int:
250
250
  return 0
251
251
  result = export_profile(args.profile, args.output, options=options)
252
252
  except (DshPackError, OSError, ValueError, json.JSONDecodeError) as error:
253
- payload = {"status": "failed", "error": {"message": str(error)}}
253
+ payload = {"status": "failed", "error": error.as_dict() if isinstance(error, ExportError) else {"message": str(error)}}
254
254
  if args.json:
255
255
  _emit(payload, args.command)
256
256
  else:
@@ -154,6 +154,9 @@ class DesktopVerifyAdapter:
154
154
 
155
155
  def _surface(self):
156
156
  value = self._rpc('surface')
157
+ inspector = value.get('inspector')
158
+ if not isinstance(inspector, dict) or inspector.get('inventoryComplete') is not True or type(inspector.get('applicable')) is not bool:
159
+ raise RuntimeError('Inspector applicability not confirmed: runtime inventory missing or incomplete')
157
160
  self.endpoints[f'web-{self.generation}'] = value['webPort']
158
161
  table = tcp_rows()
159
162
  with self.lock:
@@ -164,6 +167,9 @@ class DesktopVerifyAdapter:
164
167
  for name, port in self.endpoints.items():
165
168
  if not name.endswith('-' + str(self.generation)):
166
169
  continue
170
+ if name.startswith('inspector-') and not inspector['applicable']:
171
+ evidence.append({'name': name, 'port': port, 'status': 'NOT_APPLICABLE', 'applicability': inspector})
172
+ continue
167
173
  listeners = [row for row in table if row['localPort'] == port and row['state'] == 'LISTEN']
168
174
  if not listeners or not all(row['pid'] in pids for row in listeners):
169
175
  raise RuntimeError('Owned endpoint listener not confirmed: ' + name)
@@ -28,6 +28,20 @@ class IntegrityError(DshPackError):
28
28
  class ExportError(DshPackError):
29
29
  """A Profile cannot be exported into a valid DSH Pack."""
30
30
 
31
+ def __init__(self, message: str, *, stage: str | None = None, code: str | None = None, details: dict | None = None) -> None:
32
+ super().__init__(message)
33
+ self.stage, self.code, self.details = stage, code, details
34
+
35
+ def as_dict(self) -> dict:
36
+ result = {"message": str(self)}
37
+ if self.stage is not None:
38
+ result["stage"] = self.stage
39
+ if self.code is not None:
40
+ result["code"] = self.code
41
+ if self.details is not None:
42
+ result["details"] = self.details
43
+ return result
44
+
31
45
 
32
46
  class PackImportError(DshPackError):
33
47
  """A Pack cannot be prepared as a new Profile."""
@@ -492,12 +492,22 @@ def _with_bundle_composition(profile_files: dict[str, bytes], bundles: Sequence[
492
492
  profile_files["package.json"] = (json.dumps(manifest, ensure_ascii=False, indent=2) + "\n").encode("utf-8")
493
493
 
494
494
 
495
- def _npm_command(configured: str | None) -> str:
495
+ def _npm_command(configured: str | None) -> str | tuple[str, ...]:
496
496
  if configured:
497
497
  return configured
498
- if os.name == "nt":
499
- return shutil.which("npm.cmd") or shutil.which("npm") or "npm.cmd"
500
- return shutil.which("npm") or "npm"
498
+ available = (shutil.which("npm.cmd") if os.name == "nt" else None) or shutil.which("npm")
499
+ if available:
500
+ return available
501
+ value = os.environ.get("DSH_PACK_PNPM_COMMAND")
502
+ if value:
503
+ try:
504
+ argv = json.loads(value)
505
+ except ValueError:
506
+ argv = None
507
+ if not isinstance(argv, list) or not argv or not all(isinstance(item, str) and item and "\x00" not in item for item in argv):
508
+ raise ExportError("DSH_PACK_PNPM_COMMAND must be a nonempty JSON argv array", stage="package-artifact", code="EXPORT_PACK_COMMAND_INVALID")
509
+ return tuple(argv)
510
+ return "npm.cmd" if os.name == "nt" else "npm"
501
511
 
502
512
 
503
513
  def _bundle_patch_path(package_dir: Path, package_name: str, patch: str) -> str:
@@ -613,13 +623,14 @@ def _validate_tarball(content: bytes, package_name: str, required_file: str | Se
613
623
  def _npm_pack(
614
624
  package_dir: Path,
615
625
  package_name: str,
616
- npm_command: str,
626
+ npm_command: str | tuple[str, ...],
617
627
  *,
618
628
  required_file: str | Sequence[str] | None = None,
619
629
  ) -> bytes:
620
630
  with tempfile.TemporaryDirectory(prefix="dsh-pack-npm-") as directory:
621
631
  cache_directory = Path(directory) / "npm-cache"
622
632
  cache_directory.mkdir()
633
+ is_pnpm = isinstance(npm_command, tuple)
623
634
  command = [
624
635
  npm_command,
625
636
  "pack",
@@ -630,24 +641,56 @@ def _npm_pack(
630
641
  "--cache",
631
642
  str(cache_directory),
632
643
  ]
633
- completed = subprocess.run(
634
- command,
635
- cwd=package_dir,
636
- capture_output=True,
637
- text=True,
638
- check=False,
639
- timeout=120,
640
- )
644
+ environment = None
645
+ if is_pnpm:
646
+ command = [*npm_command, "pack", "--pack-destination", directory,
647
+ "--skip-manifest-obfuscation", "--config.ignore-scripts=true",
648
+ "--config.ignore-pnpmfile=true", "--config.offline=true",
649
+ "--config.pm-on-fail=ignore", "--config.runtime-on-fail=ignore"]
650
+ environment = dict(os.environ, ELECTRON_RUN_AS_NODE="1")
651
+ try:
652
+ completed = subprocess.run(
653
+ command, cwd=package_dir, capture_output=True, text=True,
654
+ check=False, timeout=120, **({"env": environment} if environment is not None else {}),
655
+ )
656
+ except (OSError, subprocess.TimeoutExpired) as error:
657
+ raise ExportError(
658
+ f"Cannot pack {package_name}: {error}. Install npm or use the desktop-provided pnpm entry.",
659
+ stage="package-artifact", code="EXPORT_PACK_START_FAILED",
660
+ details={"package": package_name, "command": command, "cwd": str(package_dir), "originalError": str(error)},
661
+ ) from error
641
662
  if completed.returncode != 0:
642
663
  raise ExportError(
643
664
  f"npm pack failed for {package_name} with exit code {completed.returncode}: "
644
- f"{completed.stderr.strip() or completed.stdout.strip()}"
665
+ f"{completed.stderr.strip() or completed.stdout.strip()}",
666
+ stage="package-artifact", code="EXPORT_PACK_FAILED",
667
+ details={"package": package_name, "command": command, "cwd": str(package_dir),
668
+ "exitCode": completed.returncode, "stdout": completed.stdout, "stderr": completed.stderr},
645
669
  )
646
670
  tarballs = sorted(Path(directory).glob("*.tgz"))
647
671
  if len(tarballs) != 1:
648
672
  raise ExportError(f"npm pack produced {len(tarballs)} tarballs for {package_name}")
649
673
  content = tarballs[0].read_bytes()
650
674
  _validate_tarball(content, package_name, required_file)
675
+ if is_pnpm:
676
+ # pnpm strips fields even with --skip-manifest-obfuscation. Preserve the
677
+ # installed manifest exactly; the artifact is a snapshot, not a publish.
678
+ import io
679
+ manifest = (package_dir / "package.json").read_bytes()
680
+ output = io.BytesIO()
681
+ with tarfile.open(fileobj=io.BytesIO(content), mode="r:gz") as source:
682
+ manifests = [member for member in source.getmembers() if member.name == "package/package.json"]
683
+ if len(manifests) != 1 or not manifests[0].isfile():
684
+ raise ExportError(f"pnpm pack artifact has no unique regular package manifest: {package_name}")
685
+ with tarfile.open(fileobj=output, mode="w:gz") as target:
686
+ for member in source.getmembers():
687
+ if member.name == "package/package.json":
688
+ member.size = len(manifest)
689
+ target.addfile(member, io.BytesIO(manifest))
690
+ else:
691
+ target.addfile(member, source.extractfile(member) if member.isfile() else None)
692
+ content = output.getvalue()
693
+ _validate_tarball(content, package_name, required_file)
651
694
  return content
652
695
 
653
696
 
@@ -663,7 +706,7 @@ def _plugin_lock_entry(
663
706
  specifier: str,
664
707
  bundle_order: int | None,
665
708
  options: PluginExportOptions,
666
- npm_command: str,
709
+ npm_command: str | tuple[str, ...],
667
710
  ) -> tuple[dict[str, Any], dict[str, bytes]]:
668
711
  package_dir, package_manifest = _installed_package(profile_dir, package_name, bundle=bundle_order is not None)
669
712
  installed_name = _string(package_manifest.get("name", package_name), f"{package_name}.package.json.name")
package/lib/index.js CHANGED
@@ -1,3 +1,4 @@
1
+ import { readRuntimeInventory } from "../runtime-inventory.mjs";
1
2
  import { createHash, randomUUID } from "node:crypto";
2
3
  import { access, lstat, mkdir, readFile, readdir, realpath, rename, rm, stat, unlink, writeFile } from "node:fs/promises";
3
4
  import { dirname, isAbsolute, join, relative, resolve } from "node:path";
@@ -951,33 +952,7 @@ const SAFE_PLUGIN_NAME = /^@?[A-Za-z0-9._-]+(?:\/[A-Za-z0-9._-]+)?$/;
951
952
  const SAFE_OPERATION_ID = /^[0-9a-f-]{36}$/i;
952
953
  const CRATE_PACKAGE_NAME = "dsh-crate-web";
953
954
  /** Cordis FiberState enum values; mirrors dsh-host-plugin-inventory phases. */
954
- const RUNTIME_FIBER_PHASE = {
955
- 0: "pending",
956
- 1: "loading",
957
- 2: "active",
958
- 3: "failed",
959
- 4: null,
960
- 5: "unloading"
961
- };
962
- /** Read-only projection of the currently running Loader's non-group entries. */
963
- function listRuntimePlugins(ctx) {
964
- const loader = ctx.loader;
965
- if (!loader || typeof loader.entries !== "function") return [];
966
- const rows = [];
967
- try {
968
- for (const entry of loader.entries()) {
969
- if (entry.options.group) continue;
970
- if (entry.options.name.startsWith("cordis:")) continue;
971
- rows.push({
972
- id: entry.id,
973
- name: entry.options.name,
974
- enabled: entry.disabled !== true,
975
- phase: entry.fiber ? RUNTIME_FIBER_PHASE[entry.fiber.state] ?? null : null
976
- });
977
- }
978
- } catch {}
979
- return rows;
980
- }
955
+
981
956
  function json(res, status, body) {
982
957
  const payload = JSON.stringify(body);
983
958
  res.writeHead(status, {
@@ -1594,7 +1569,7 @@ async function createProfile(home, body, workspace) {
1594
1569
  history: item
1595
1570
  };
1596
1571
  }
1597
- async function handleApi(home, body, runtimePlugins = []) {
1572
+ async function handleApi(home, body, runtimePlugins = [], runtimeInventoryComplete = false) {
1598
1573
  const action = body.action;
1599
1574
  const workspace = await ensureWorkspace(home);
1600
1575
  if (action === "profiles") {
@@ -1608,6 +1583,7 @@ async function handleApi(home, body, runtimePlugins = []) {
1608
1583
  status: "ok",
1609
1584
  profiles: await listProfiles(home),
1610
1585
  runtimePlugins: classifiedPlugins,
1586
+ runtimeInventoryComplete,
1611
1587
  runtime
1612
1588
  };
1613
1589
  }
@@ -2137,7 +2113,8 @@ function apply(ctx, config = {}) {
2137
2113
  return;
2138
2114
  }
2139
2115
  try {
2140
- json(res, 200, await handleApi(home, await readRequest(req), listRuntimePlugins(ctx)));
2116
+ const inventory = readRuntimeInventory(ctx);
2117
+ json(res, 200, await handleApi(home, await readRequest(req), inventory.rows, inventory.complete));
2141
2118
  } catch (error) {
2142
2119
  json(res, 400, {
2143
2120
  status: "failed",
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "dsh-crate-web",
3
3
  "description": "DSH Web Settings surface for DSH Crate export, import, inspect, and history",
4
- "version": "0.1.2-preview.2",
4
+ "version": "0.1.2-preview.5",
5
5
  "type": "module",
6
6
  "main": "lib/index.js",
7
7
  "types": "lib/index.d.ts",
@@ -29,7 +29,8 @@
29
29
  "skills/dsh-crate-troubleshooting/SKILL.md",
30
30
  "skills/dsh-crate-troubleshooting/compatibility.json",
31
31
  "skills/dsh-crate-troubleshooting/reference/*.md",
32
- "THIRD-PARTY-LICENSE.txt"
32
+ "THIRD-PARTY-LICENSE.txt",
33
+ "runtime-inventory.mjs"
33
34
  ],
34
35
  "publishConfig": {
35
36
  "access": "public",
@@ -60,7 +61,8 @@
60
61
  "@deepseek-ai/dsh-client-ui-settings": "*",
61
62
  "@deepseek-ai/dsh-client-ui-slots": "*",
62
63
  "@deepseek-ai/dsh-host-webserver": "*",
63
- "react": "^18.2.0"
64
+ "react": "^18.2.0",
65
+ "@deepseek-ai/dsh-client-ui-primitives": "*"
64
66
  },
65
67
  "keywords": [
66
68
  "dsh",
@@ -77,5 +79,34 @@
77
79
  "repository": {
78
80
  "type": "git",
79
81
  "url": "git+https://github.com/joezhou172/dsh-crate-web.git"
82
+ },
83
+ "peerDependenciesMeta": {
84
+ "@deepseek-ai/dsh-client-connection": {
85
+ "optional": true
86
+ },
87
+ "@deepseek-ai/cordis": {
88
+ "optional": true
89
+ },
90
+ "@deepseek-ai/dsh-client-locale": {
91
+ "optional": true
92
+ },
93
+ "@deepseek-ai/dsh-client-runtime": {
94
+ "optional": true
95
+ },
96
+ "@deepseek-ai/dsh-client-ui-settings": {
97
+ "optional": true
98
+ },
99
+ "@deepseek-ai/dsh-client-ui-slots": {
100
+ "optional": true
101
+ },
102
+ "@deepseek-ai/dsh-host-webserver": {
103
+ "optional": true
104
+ },
105
+ "react": {
106
+ "optional": true
107
+ },
108
+ "@deepseek-ai/dsh-client-ui-primitives": {
109
+ "optional": true
110
+ }
80
111
  }
81
112
  }
@@ -0,0 +1,31 @@
1
+ /** Read Loader metadata only; a partial enumeration never proves absence. */
2
+ const phases = { 0: 'pending', 1: 'loading', 2: 'active', 3: 'failed', 4: null, 5: 'unloading' }
3
+ export function readRuntimeInventory(ctx) {
4
+ const rows = []
5
+ const loader = ctx?.loader
6
+ if (!loader || typeof loader.entries !== 'function') return { rows, complete: false }
7
+ try {
8
+ for (const entry of loader.entries()) {
9
+ if (entry.options.group) continue
10
+ if (typeof entry.options.name !== 'string' || typeof entry.id !== 'string') throw new Error('invalid Loader row')
11
+ if (entry.options.name.startsWith('cordis:')) continue
12
+ rows.push({ id: entry.id, name: entry.options.name, enabled: entry.disabled !== true,
13
+ phase: entry.fiber ? (phases[entry.fiber.state] ?? null) : null })
14
+ }
15
+ return { rows, complete: true }
16
+ } catch {
17
+ return { rows, complete: false }
18
+ }
19
+ }
20
+
21
+ export function inspectorApplicability(rows, complete) {
22
+ if (complete !== true || !Array.isArray(rows) || !rows.length || rows.some(row => !row ||
23
+ typeof row.id !== 'string' || typeof row.name !== 'string' || typeof row.enabled !== 'boolean')) {
24
+ throw new Error('Inspector applicability not confirmed: runtime inventory missing or incomplete')
25
+ }
26
+ const matches = rows.filter(row => row.name === '@deepseek-ai/dsh-experimental-inspector')
27
+ const applicable = matches.some(row => row.enabled)
28
+ return { inventoryComplete: true, inventoryCount: rows.length, applicable,
29
+ reason: applicable ? 'enabled' : matches.length ? 'disabled' : 'not-declared',
30
+ plugins: matches.map(({ id, name, enabled, phase }) => ({ id, name, enabled, phase })) }
31
+ }
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "dsh-crate-troubleshooting",
3
- "skillVersion": "0.1.2-preview.2",
3
+ "skillVersion": "0.1.2-preview.5",
4
4
  "compatibility": {
5
5
  "dshCrate": ">=0.1.0 <0.1.3",
6
6
  "diagnosticSchema": [
@@ -1,6 +1,6 @@
1
1
  # DSH Crate Diagnostic Codes
2
2
 
3
- > Generated from `core/dsh_pack/diagnostics.py` (schema v1, producer `dsh-crate`, crate 0.1.2-preview.2). Do not edit by hand.
3
+ > Generated from `core/dsh_pack/diagnostics.py` (schema v1, producer `dsh-crate`, crate 0.1.2-preview.5). Do not edit by hand.
4
4
 
5
5
  Total codes: **79**. Stages: **16**.
6
6
 
@@ -7,6 +7,7 @@ import { open, mkdir, readdir, lstat, readFile, writeFile } from 'node:fs/promis
7
7
  import { createServer } from 'node:net'
8
8
  import { dirname, isAbsolute, join, relative, resolve } from 'node:path'
9
9
  import { pathToFileURL } from 'node:url'
10
+ import { inspectorApplicability } from './runtime-inventory.mjs'
10
11
 
11
12
  const sleep = ms => new Promise(resolve => setTimeout(resolve, ms))
12
13
  const inside = (root, path) => { const part = relative(resolve(root), resolve(path)); return part === '' || (!part.startsWith('..') && !isAbsolute(part)) }
@@ -61,7 +62,9 @@ export function validateSurface(value, home, webPort) {
61
62
  throw new Error('Verification backend identity does not match its isolated desktop')
62
63
  }
63
64
  if (!Number.isInteger(webPort) || webPort < 1 || webPort > 65535) throw new Error('Owned backend Web port missing from startup log')
64
- return { ...value, webPort, authenticationContext: 'official owned desktop protocol and authenticated profiles route; credentials not exported' }
65
+ const inspector = inspectorApplicability(value.runtimePlugins, value.runtimeInventoryComplete)
66
+ const { runtimePlugins, runtimeInventoryComplete, ...surface } = value
67
+ return { ...surface, inspector, webPort, authenticationContext: 'official owned desktop protocol and authenticated profiles route; credentials not exported' }
65
68
  }
66
69
 
67
70
  class Cdp {
@@ -247,7 +250,8 @@ export class DesktopController {
247
250
  let result; try { result = await backend.json() } catch { result = {} }
248
251
  return { accountMenu, httpStatus: response.status, origin: location.origin, protocol: location.protocol, hostname: location.hostname,
249
252
  backendHttpStatus: backend.status, backendStatus: result.status, runtimePid: result.runtime?.pid,
250
- runtimeHome: result.runtime?.dshHome, runtimeProfile: result.runtime?.currentProfile };
253
+ runtimeHome: result.runtime?.dshHome, runtimeProfile: result.runtime?.currentProfile,
254
+ runtimePlugins: result.runtimePlugins, runtimeInventoryComplete: result.runtimeInventoryComplete };
251
255
  })()`)
252
256
  const log = join(this.config.job, `desktop-${this.generation}.stdout.log`)
253
257
  if ((await lstat(log)).size > 1024 * 1024) throw new Error('Owned desktop startup log exceeded observation limit')