flower-trellis 0.5.2-beta.1 → 0.5.3

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.
Files changed (27) hide show
  1. package/enhancements/0.6/overrides/bundles/control-plane-integrity.json +2 -1
  2. package/enhancements/0.6/overrides/conflicts.json +22 -0
  3. package/enhancements/0.6/overrides/patches/scripts/session-context-update-boundary/constants-selector.py +6 -0
  4. package/enhancements/0.6/overrides/patches/scripts/session-context-update-boundary/helpers-selector.py +150 -0
  5. package/enhancements/0.6/overrides/patches/scripts/session-context-update-boundary/imports-content.py +4 -0
  6. package/enhancements/0.6/overrides/patches/scripts/session-context-update-boundary/imports-selector.py +8 -0
  7. package/enhancements/0.6/overrides/patches/scripts/session-context-update-boundary/output-content.py +7 -0
  8. package/enhancements/0.6/overrides/patches/scripts/session-context-update-boundary/output-selector.py +13 -0
  9. package/enhancements/0.6/overrides/patches/scripts/session-context-update-boundary/patch.json +53 -0
  10. package/enhancements/MANIFEST.json +9 -2
  11. package/enhancements/common/.common/.claude/skills/craft-rpa/SKILL.md +6 -7
  12. package/enhancements/common/.common/.claude/skills/craft-rpa/recorder/launch.js +8 -6
  13. package/enhancements/common/.common/.claude/skills/craft-rpa/recorder/logger.js +3 -1
  14. package/enhancements/common/.common/.claude/skills/craft-rpa/recorder/test/runtime-layout.test.js +96 -0
  15. package/enhancements/common/.common/.claude/skills/craft-rpa/scripts/run.sh +72 -36
  16. package/enhancements/common/.common/.codex/skills/craft-rpa/SKILL.md +6 -7
  17. package/enhancements/common/.common/.codex/skills/craft-rpa/recorder/launch.js +8 -6
  18. package/enhancements/common/.common/.codex/skills/craft-rpa/recorder/logger.js +3 -1
  19. package/enhancements/common/.common/.codex/skills/craft-rpa/recorder/test/runtime-layout.test.js +96 -0
  20. package/enhancements/common/.common/.codex/skills/craft-rpa/scripts/run.sh +72 -36
  21. package/package.json +3 -3
  22. package/src/builtin-plugins/skill-garden/content-adapter.js +126 -10
  23. package/src/builtin-plugins/skill-garden/provider.js +13 -2
  24. package/src/lib/patch-conflicts.js +28 -2
  25. package/src/lib/patch-engine.js +41 -3
  26. package/src/lib/platform-patch-adapters.js +8 -7
  27. package/src/lib/trellis-python-command.js +95 -0
@@ -5,6 +5,7 @@
5
5
  "installMode": "full-only",
6
6
  "patches": [
7
7
  "scripts/task-store-write-integrity",
8
- "scripts/runtime-state-integrity"
8
+ "scripts/runtime-state-integrity",
9
+ "scripts/session-context-update-boundary"
9
10
  ]
10
11
  }
@@ -1101,6 +1101,28 @@
1101
1101
  },
1102
1102
  "owner": "trellis-route",
1103
1103
  "reason": "Claude workflow meta 必须与 shared route owner 模型一致。"
1104
+ },
1105
+ {
1106
+ "id": "session-context-no-legacy-update-check",
1107
+ "severity": "error",
1108
+ "target": ".trellis/scripts/common/session_context.py",
1109
+ "whenOperations": [
1110
+ "session-context-update-imports",
1111
+ "session-context-update-constants",
1112
+ "session-context-update-helpers",
1113
+ "session-context-update-output"
1114
+ ],
1115
+ "assertion": {
1116
+ "type": "absent-literal",
1117
+ "values": [
1118
+ "def _get_update_hint(",
1119
+ "def _mark_update_check_attempted(",
1120
+ "def _update_marker_path(",
1121
+ "[\"trellis\", \"--version\"]"
1122
+ ]
1123
+ },
1124
+ "owner": "flower-update-hook",
1125
+ "reason": "默认 Session Context 只负责项目上下文;更新检查必须统一由 Flower SessionStart Hook 进入。"
1104
1126
  }
1105
1127
  ]
1106
1128
  }
@@ -0,0 +1,6 @@
1
+ _PACKAGE_NAME = "@mindfoldhq/trellis"
2
+ _UPDATE_CHECK_TIMEOUT_SECONDS = 1.0
3
+ _VERSION_RE = re.compile(
4
+ r"^\s*(\d+)(?:\.(\d+))?(?:\.(\d+))?(?:-([0-9A-Za-z.-]+))?\s*$"
5
+ )
6
+ _VERSION_TOKEN_RE = re.compile(r"\b\d+(?:\.\d+){1,2}(?:-[0-9A-Za-z.-]+)?\b")
@@ -0,0 +1,150 @@
1
+ def _read_project_version(repo_root: Path) -> str | None:
2
+ try:
3
+ version = (repo_root / DIR_WORKFLOW / ".version").read_text(
4
+ encoding="utf-8"
5
+ ).strip()
6
+ except OSError:
7
+ return None
8
+ return version or None
9
+
10
+
11
+ def _fetch_trellis_version_output() -> str | None:
12
+ try:
13
+ result = subprocess.run(
14
+ ["trellis", "--version"],
15
+ capture_output=True,
16
+ text=True,
17
+ encoding="utf-8",
18
+ errors="replace",
19
+ timeout=_UPDATE_CHECK_TIMEOUT_SECONDS,
20
+ )
21
+ except (OSError, subprocess.SubprocessError, TimeoutError):
22
+ return None
23
+
24
+ if result.returncode != 0:
25
+ return None
26
+ output = f"{result.stdout}\n{result.stderr}".strip()
27
+ return output or None
28
+
29
+
30
+ def _extract_available_update_version(output: str) -> str | None:
31
+ update_match = re.search(
32
+ r"Trellis update available:\s*"
33
+ r"(?P<current>\S+)\s*(?:→|->)\s*(?P<latest>\S+)",
34
+ output,
35
+ )
36
+ if update_match:
37
+ return update_match.group("latest").strip()
38
+ candidates = _VERSION_TOKEN_RE.findall(output)
39
+ return candidates[-1] if candidates else None
40
+
41
+
42
+ def _resolve_available_update_version() -> str | None:
43
+ output = _fetch_trellis_version_output()
44
+ if not output:
45
+ return None
46
+ return _extract_available_update_version(output)
47
+
48
+
49
+ def _parse_version(version: str) -> tuple[tuple[int, int, int], tuple[str, ...] | None] | None:
50
+ match = _VERSION_RE.match(version)
51
+ if not match:
52
+ return None
53
+ major, minor, patch, prerelease = match.groups()
54
+ numbers = (int(major), int(minor or "0"), int(patch or "0"))
55
+ prerelease_parts = tuple(prerelease.split(".")) if prerelease else None
56
+ return numbers, prerelease_parts
57
+
58
+
59
+ def _compare_prerelease(
60
+ left: tuple[str, ...] | None,
61
+ right: tuple[str, ...] | None,
62
+ ) -> int:
63
+ if left is None and right is None:
64
+ return 0
65
+ if left is None:
66
+ return 1
67
+ if right is None:
68
+ return -1
69
+
70
+ for left_part, right_part in zip(left, right):
71
+ if left_part == right_part:
72
+ continue
73
+ left_numeric = left_part.isdigit()
74
+ right_numeric = right_part.isdigit()
75
+ if left_numeric and right_numeric:
76
+ left_int = int(left_part)
77
+ right_int = int(right_part)
78
+ return (left_int > right_int) - (left_int < right_int)
79
+ if left_numeric:
80
+ return -1
81
+ if right_numeric:
82
+ return 1
83
+ return (left_part > right_part) - (left_part < right_part)
84
+
85
+ return (len(left) > len(right)) - (len(left) < len(right))
86
+
87
+
88
+ def _compare_versions(left: str, right: str) -> int | None:
89
+ parsed_left = _parse_version(left)
90
+ parsed_right = _parse_version(right)
91
+ if parsed_left is None or parsed_right is None:
92
+ return None
93
+
94
+ left_numbers, left_prerelease = parsed_left
95
+ right_numbers, right_prerelease = parsed_right
96
+ if left_numbers != right_numbers:
97
+ return (left_numbers > right_numbers) - (left_numbers < right_numbers)
98
+ return _compare_prerelease(left_prerelease, right_prerelease)
99
+
100
+
101
+ def _update_marker_path(repo_root: Path) -> Path:
102
+ context_key = resolve_context_key()
103
+ if not context_key:
104
+ terminal_key = os.environ.get("TERM_SESSION_ID", "").strip()
105
+ context_key = terminal_key or f"ppid-{os.getppid()}"
106
+ safe_key = re.sub(r"[^A-Za-z0-9._-]+", "_", context_key).strip("._-")
107
+ if not safe_key:
108
+ safe_key = "session"
109
+ return (
110
+ repo_root
111
+ / DIR_WORKFLOW
112
+ / ".runtime"
113
+ / f"update-check-{safe_key[:160]}.marker"
114
+ )
115
+
116
+
117
+ def _mark_update_check_attempted(repo_root: Path) -> bool:
118
+ marker_path = _update_marker_path(repo_root)
119
+ if marker_path.exists():
120
+ return False
121
+ try:
122
+ marker_path.parent.mkdir(parents=True, exist_ok=True)
123
+ marker_path.write_text("checked\n", encoding="utf-8")
124
+ except OSError:
125
+ pass
126
+ return True
127
+
128
+
129
+ def _get_update_hint(repo_root: Path) -> str | None:
130
+ marker_path = _update_marker_path(repo_root)
131
+ if marker_path.exists():
132
+ return None
133
+
134
+ current_version = _read_project_version(repo_root)
135
+ if not current_version:
136
+ return None
137
+
138
+ latest_version = _resolve_available_update_version()
139
+ if not latest_version:
140
+ return None
141
+
142
+ _mark_update_check_attempted(repo_root)
143
+ comparison = _compare_versions(current_version, latest_version)
144
+ if comparison is None or comparison >= 0:
145
+ return None
146
+
147
+ return (
148
+ f"Trellis update available: {current_version} -> {latest_version}, "
149
+ "run trellis upgrade"
150
+ )
@@ -0,0 +1,4 @@
1
+ import json
2
+ from pathlib import Path
3
+
4
+ from .config import get_git_packages
@@ -0,0 +1,8 @@
1
+ import json
2
+ import os
3
+ import re
4
+ import subprocess
5
+ from pathlib import Path
6
+
7
+ from .active_task import resolve_context_key
8
+ from .config import get_git_packages
@@ -0,0 +1,7 @@
1
+ def output_text(repo_root: Path | None = None) -> None:
2
+ """Output context in text format.
3
+
4
+ Args:
5
+ repo_root: Repository root path. Defaults to auto-detected.
6
+ """
7
+ print(get_context_text(repo_root))
@@ -0,0 +1,13 @@
1
+ def output_text(repo_root: Path | None = None) -> None:
2
+ """Output context in text format.
3
+
4
+ Args:
5
+ repo_root: Repository root path. Defaults to auto-detected.
6
+ """
7
+ if repo_root is None:
8
+ repo_root = get_repo_root()
9
+ update_hint = _get_update_hint(repo_root)
10
+ if update_hint:
11
+ print(update_hint)
12
+ print("")
13
+ print(get_context_text(repo_root))
@@ -0,0 +1,53 @@
1
+ {
2
+ "schemaVersion": 2,
3
+ "id": "session-context-update-boundary",
4
+ "purpose": "control_plane_integrity",
5
+ "operations": [
6
+ {
7
+ "id": "session-context-update-imports",
8
+ "operation": "replace",
9
+ "targets": [{
10
+ "kind": "file",
11
+ "path": ".trellis/scripts/common/session_context.py",
12
+ "missing": "error",
13
+ "markerStyle": "hash"
14
+ }],
15
+ "selector": { "type": "literal", "source": "imports-selector.py" },
16
+ "content": { "source": "imports-content.py" }
17
+ },
18
+ {
19
+ "id": "session-context-update-constants",
20
+ "operation": "remove",
21
+ "targets": [{
22
+ "kind": "file",
23
+ "path": ".trellis/scripts/common/session_context.py",
24
+ "missing": "error",
25
+ "markerStyle": "hash"
26
+ }],
27
+ "selector": { "type": "literal", "source": "constants-selector.py" }
28
+ },
29
+ {
30
+ "id": "session-context-update-helpers",
31
+ "operation": "remove",
32
+ "targets": [{
33
+ "kind": "file",
34
+ "path": ".trellis/scripts/common/session_context.py",
35
+ "missing": "error",
36
+ "markerStyle": "hash"
37
+ }],
38
+ "selector": { "type": "literal", "source": "helpers-selector.py" }
39
+ },
40
+ {
41
+ "id": "session-context-update-output",
42
+ "operation": "replace",
43
+ "targets": [{
44
+ "kind": "file",
45
+ "path": ".trellis/scripts/common/session_context.py",
46
+ "missing": "error",
47
+ "markerStyle": "hash"
48
+ }],
49
+ "selector": { "type": "literal", "source": "output-selector.py" },
50
+ "content": { "source": "output-content.py" }
51
+ }
52
+ ]
53
+ }
@@ -1,7 +1,7 @@
1
1
  {
2
- "syncedAt": "2026-07-29T10:02:05.260Z",
2
+ "syncedAt": "2026-07-29T12:22:03.171Z",
3
3
  "syncedFrom": "vendor/skill-garden",
4
- "sourceCommit": "1812258cdd48d99b97e8dbab41f5203134e411df",
4
+ "sourceCommit": "1916ef11ba3cb40bead9ad827957224af850bd4f",
5
5
  "common": {
6
6
  "codexSkills": [
7
7
  "craft-rpa",
@@ -161,6 +161,13 @@
161
161
  "scripts/runtime-state-integrity/active-task-set-content.py",
162
162
  "scripts/runtime-state-integrity/active-task-set-selector.py",
163
163
  "scripts/runtime-state-integrity/patch.json",
164
+ "scripts/session-context-update-boundary/constants-selector.py",
165
+ "scripts/session-context-update-boundary/helpers-selector.py",
166
+ "scripts/session-context-update-boundary/imports-content.py",
167
+ "scripts/session-context-update-boundary/imports-selector.py",
168
+ "scripts/session-context-update-boundary/output-content.py",
169
+ "scripts/session-context-update-boundary/output-selector.py",
170
+ "scripts/session-context-update-boundary/patch.json",
164
171
  "scripts/task-start-brief-gate/degraded-content.py",
165
172
  "scripts/task-start-brief-gate/degraded-selector.py",
166
173
  "scripts/task-start-brief-gate/guard-content.py",
@@ -70,7 +70,7 @@ bash "$SKILL_DIR/scripts/run.sh" craft [--session <ts>] [OUT]
70
70
  # 转 jsonl → trace.md;--session 默认 = 当前/最新;OUT 默认 ./trace.md
71
71
  ```
72
72
 
73
- 运行时状态 / sessions / profile 全部在 `$(pwd)/.craft-rpa/`(可用 `CRAFT_RPA_HOME` env 覆盖到任意路径)。首次 `start` 自动 `npm install`(跳浏览器下载,~10s)。
73
+ 运行时状态 / sessions / profile / 依赖全部在 `$(pwd)/.craft-rpa/`(可用 `CRAFT_RPA_HOME` env 覆盖到任意路径)。首次 `start` 自动安装依赖(跳浏览器下载,~10s),受管 skill 目录只保留静态代码资产。
74
74
 
75
75
  **AI 行为约定**(看到下列触发就跑对应子命令,不要手敲底层 cd / nohup):
76
76
 
@@ -106,24 +106,23 @@ bash "$SKILL_DIR/scripts/run.sh" craft [--session <ts>] [OUT]
106
106
  │ ├── 2026-05-18_14-22-15/
107
107
  │ └── legacy-2026-05-17_... ← 老版本遗留 / 升级时自动归档
108
108
  ├── profile/ ← Chrome 持久 profile(登录态,项目独立)
109
+ ├── runtime/recorder/ ← package manifest + node_modules,可重建
109
110
  ├── .launch.pid / .launch.log ← 进程管理 + 日志
110
111
  └── .current-session ← 最近一次 start 的会话 ts
111
112
 
112
- .claude/skills/craft-rpa/recorder/ ← skill 内仅代码资产;start 时建两个软链 → 数据根:
113
- ├── session.jsonl → <repo>/.craft-rpa/sessions/<latest>/session.jsonl
114
- └── profile → <repo>/.craft-rpa/profile
113
+ .claude/skills/craft-rpa/recorder/ ← skill 内仅静态代码资产,不写 profile/session/node_modules
115
114
  ```
116
115
 
117
116
  **为什么这样**:
118
117
  - 录制 jsonl 是**项目业务数据**,跟着仓库走(每个仓库独立 session 池,不串)
119
118
  - skill 代码可装 `~/.claude/skills/craft-rpa/` 全局,所有仓库共用同一份代码
120
- - launch.js / logger.js 用 `__dirname` 解析 session.jsonl / profile,通过 recorder/ 内软链自动落到项目根 —— **录制器代码不用改**
119
+ - run.sh 通过显式环境变量把 session/profile/Playwright 模块路径传给 launch.js,避免运行时产物进入 Plugin 管理树
121
120
 
122
121
  **关键性质**:
123
122
 
124
123
  - 每次 `run.sh start` 创建新时间戳目录,**不覆盖**历史
125
- - `recorder/session.jsonl` / `recorder/profile` 始终是软链,随当前会话切换目标
126
- - 老版本遗留的 `recorder/session.jsonl`(普通文件)在新版第一次 start 时自动归档到 `sessions/legacy-<ts>/`;`recorder/profile/`(普通目录)归档到 `.craft-rpa/profile-legacy-<ts>/`
124
+ - 新版不会在 `recorder/` 创建软链或 `node_modules`;依赖位于 `.craft-rpa/runtime/recorder/`
125
+ - 老版本遗留的 `recorder/session.jsonl` / `recorder/profile` 软链只删除链接本身,真实目标数据保留;普通文件/目录仍自动归档
127
126
  - `run.sh craft` 默认转最新;`--session <ts>` 可转任意历史
128
127
  - 删历史:手动 `rm -rf .craft-rpa/sessions/<ts>/`,run.sh 不管删
129
128
 
@@ -18,19 +18,21 @@
18
18
  * 没有系统 Chrome 时改成 false,并执行 npx playwright install chromium
19
19
  *
20
20
  * 数据存放:
21
- * - profile/ : 用户数据目录(cookie / localStorage / 登录态等),下次启动自动复用
22
- * - session.jsonl : 事件日志(由内置 logger 写入)
21
+ * - CRAFT_RPA_PROFILE_DIR : 用户数据目录(cookie / localStorage / 登录态等)
22
+ * - CRAFT_RPA_SESSION_FILE : 事件日志(由内置 logger 写入)
23
23
  */
24
- const { chromium } = require('playwright');
25
24
  const path = require('path');
26
25
  const fs = require('fs');
26
+ const PLAYWRIGHT_MODULE = process.env.CRAFT_RPA_PLAYWRIGHT_MODULE || 'playwright';
27
+ const { chromium } = require(PLAYWRIGHT_MODULE);
27
28
  const { startLogger } = require('./logger');
28
29
 
29
30
  // 启动参数:第一个 CLI 参数作为起始 URL,没传就开空白页
30
31
  const START_URL = process.argv[2] || 'about:blank';
31
32
 
32
- // profile 目录放在脚本同级 ./profile/,与日常 Chrome 完全隔离
33
- const PROFILE_DIR = path.join(__dirname, 'profile');
33
+ // run.sh 显式传入运行时目录;直接执行时保留原有默认值。
34
+ const PROFILE_DIR = path.resolve(process.env.CRAFT_RPA_PROFILE_DIR || path.join(__dirname, 'profile'));
35
+ const SESSION_LOG = path.resolve(process.env.CRAFT_RPA_SESSION_FILE || path.join(__dirname, 'session.jsonl'));
34
36
 
35
37
  // 注入脚本路径
36
38
  const INJECT_SCRIPT = path.join(__dirname, 'inject.js');
@@ -164,7 +166,7 @@ if (!fs.existsSync(INJECT_SCRIPT)) {
164
166
 
165
167
  // 先启动日志 + 控制服务,再启动浏览器
166
168
  // 浏览器一打开页面,注入脚本立刻就能 POST 成功,不会丢首批事件
167
- const loggerServer = startLogger({ browserController });
169
+ const loggerServer = startLogger({ browserController, logFile: SESSION_LOG });
168
170
 
169
171
  console.log(`[launch] profile dir : ${PROFILE_DIR}`);
170
172
  console.log(`[launch] start url : ${START_URL}`);
@@ -16,7 +16,9 @@ const fs = require('fs');
16
16
  const path = require('path');
17
17
 
18
18
  const DEFAULT_PORT = 7777;
19
- const DEFAULT_LOG_FILE = path.join(__dirname, 'session.jsonl');
19
+ const DEFAULT_LOG_FILE = path.resolve(
20
+ process.env.CRAFT_RPA_SESSION_FILE || path.join(__dirname, 'session.jsonl'),
21
+ );
20
22
 
21
23
  /**
22
24
  * 启动本地日志收集服务
@@ -0,0 +1,96 @@
1
+ const assert = require('assert');
2
+ const fs = require('fs');
3
+ const os = require('os');
4
+ const path = require('path');
5
+ const { spawnSync } = require('child_process');
6
+ const test = require('node:test');
7
+
8
+ /**
9
+ * 写入可执行的测试替身。
10
+ *
11
+ * @param {string} target 文件路径。
12
+ * @param {string} content 脚本内容。
13
+ * @return {void}
14
+ */
15
+ function writeExecutable(target, content) {
16
+ fs.writeFileSync(target, content, { mode: 0o755 });
17
+ }
18
+
19
+ test('run.sh 将旧运行时产物迁出 Plugin 管理树', { skip: process.platform === 'win32' }, t => {
20
+ const tempRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'craft-rpa-runtime-layout-'));
21
+ const sourceSkill = path.resolve(__dirname, '..', '..');
22
+ const skillRoot = path.join(tempRoot, 'skill');
23
+ const recorderRoot = path.join(skillRoot, 'recorder');
24
+ const projectRoot = path.join(tempRoot, 'project');
25
+ const dataRoot = path.join(projectRoot, '.craft-rpa');
26
+ const fakeBin = path.join(tempRoot, 'bin');
27
+ let launchPid = null;
28
+
29
+ t.after(() => {
30
+ if (launchPid !== null) {
31
+ try { process.kill(launchPid, 'SIGTERM'); } catch (error) { /* 测试进程可能已结束。 */ }
32
+ }
33
+ fs.rmSync(tempRoot, { recursive: true, force: true });
34
+ });
35
+
36
+ fs.cpSync(sourceSkill, skillRoot, { recursive: true });
37
+ fs.mkdirSync(projectRoot, { recursive: true });
38
+ fs.mkdirSync(fakeBin, { recursive: true });
39
+
40
+ const profileTarget = path.join(dataRoot, 'profile');
41
+ const sessionTarget = path.join(dataRoot, 'sessions', 'old', 'session.jsonl');
42
+ fs.mkdirSync(profileTarget, { recursive: true });
43
+ fs.mkdirSync(path.dirname(sessionTarget), { recursive: true });
44
+ fs.writeFileSync(path.join(profileTarget, 'preserved.txt'), 'profile\n');
45
+ fs.writeFileSync(sessionTarget, '{"kind":"legacy"}\n');
46
+ fs.symlinkSync(profileTarget, path.join(recorderRoot, 'profile'), 'dir');
47
+ fs.symlinkSync(sessionTarget, path.join(recorderRoot, 'session.jsonl'));
48
+ fs.mkdirSync(path.join(recorderRoot, 'node_modules', 'playwright'), { recursive: true });
49
+ fs.writeFileSync(path.join(recorderRoot, 'node_modules', 'playwright', 'package.json'), '{}\n');
50
+
51
+ writeExecutable(path.join(fakeBin, 'npm'), `#!/usr/bin/env bash
52
+ mkdir -p "$PWD/node_modules/playwright"
53
+ printf '{}\\n' > "$PWD/node_modules/playwright/package.json"
54
+ `);
55
+ writeExecutable(path.join(fakeBin, 'node'), `#!/usr/bin/env bash
56
+ printf '%s\\n' "$CRAFT_RPA_SESSION_FILE" > "$CRAFT_RPA_HOME/fake-session-file"
57
+ printf '%s\\n' "$CRAFT_RPA_PROFILE_DIR" > "$CRAFT_RPA_HOME/fake-profile-dir"
58
+ printf '%s\\n' "$CRAFT_RPA_PLAYWRIGHT_MODULE" > "$CRAFT_RPA_HOME/fake-playwright-module"
59
+ while true; do /bin/sleep 1; done
60
+ `);
61
+
62
+ const result = spawnSync('bash', [path.join(skillRoot, 'scripts', 'run.sh'), 'start', 'about:blank'], {
63
+ cwd: projectRoot,
64
+ env: {
65
+ ...process.env,
66
+ CRAFT_RPA_HOME: dataRoot,
67
+ PATH: `${fakeBin}${path.delimiter}${process.env.PATH}`,
68
+ },
69
+ encoding: 'utf8',
70
+ timeout: 10000,
71
+ });
72
+
73
+ assert.equal(result.status, 0, `${result.stdout}\n${result.stderr}`);
74
+ launchPid = Number.parseInt(fs.readFileSync(path.join(dataRoot, '.launch.pid'), 'utf8'), 10);
75
+ assert.equal(Number.isInteger(launchPid), true);
76
+ assert.equal(fs.existsSync(path.join(recorderRoot, 'profile')), false);
77
+ assert.equal(fs.existsSync(path.join(recorderRoot, 'session.jsonl')), false);
78
+ assert.equal(fs.existsSync(path.join(recorderRoot, 'node_modules')), false);
79
+ assert.equal(fs.readFileSync(path.join(profileTarget, 'preserved.txt'), 'utf8'), 'profile\n');
80
+ assert.equal(fs.readFileSync(sessionTarget, 'utf8'), '{"kind":"legacy"}\n');
81
+
82
+ const runtimeRoot = path.join(dataRoot, 'runtime', 'recorder');
83
+ assert.equal(fs.existsSync(path.join(runtimeRoot, 'node_modules', 'playwright', 'package.json')), true);
84
+ assert.equal(
85
+ fs.readFileSync(path.join(dataRoot, 'fake-profile-dir'), 'utf8').trim(),
86
+ profileTarget,
87
+ );
88
+ assert.equal(
89
+ fs.readFileSync(path.join(dataRoot, 'fake-playwright-module'), 'utf8').trim(),
90
+ path.join(runtimeRoot, 'node_modules', 'playwright'),
91
+ );
92
+ const sessionFile = fs.readFileSync(path.join(dataRoot, 'fake-session-file'), 'utf8').trim();
93
+ assert.equal(sessionFile.startsWith(path.join(dataRoot, 'sessions')), true);
94
+ assert.equal(sessionFile.endsWith(path.join('', 'session.jsonl')), true);
95
+ assert.equal(fs.existsSync(sessionFile), true);
96
+ });
@@ -5,13 +5,11 @@
5
5
  # ${CRAFT_RPA_HOME:-<cwd>/.craft-rpa}/
6
6
  # ├── sessions/<YYYY-MM-DD_HH-MM-SS>/session.jsonl 每次 start 新建,不覆盖
7
7
  # ├── profile/ Chrome 持久 profile(登录态)
8
+ # ├── runtime/recorder/node_modules/ 可重建运行时依赖
8
9
  # ├── .launch.{pid,log} 进程管理 + 日志
9
10
  # └── .current-session 最近一次 start 的 ts
10
11
  #
11
- # skill 内 .claude/skills/craft-rpa/recorder/ 启动时建两个软链 → 数据根:
12
- # ├── session.jsonl → $CRAFT_RPA_HOME/sessions/<ts>/session.jsonl
13
- # └── profile → $CRAFT_RPA_HOME/profile
14
- # launch.js 用 __dirname 解析 session.jsonl / profile,通过软链落到项目根 .craft-rpa/
12
+ # skill 内 recorder/ 只保留静态代码资产;运行时路径通过环境变量显式传给 launch.js。
15
13
  #
16
14
  # 用法:
17
15
  # bash run.sh start [URL]
@@ -34,13 +32,73 @@ PROFILE_DIR="$CRAFT_RPA_HOME/profile"
34
32
  PID_FILE="$CRAFT_RPA_HOME/.launch.pid"
35
33
  LOG_FILE="$CRAFT_RPA_HOME/.launch.log"
36
34
  CURRENT_FILE="$CRAFT_RPA_HOME/.current-session"
37
-
38
- # skill 内 launch.js 通过 __dirname 看到的入口(软链 → 数据根)
39
- SESSION_LINK="$RECORDER_DIR/session.jsonl"
40
- PROFILE_LINK="$RECORDER_DIR/profile"
35
+ RUNTIME_DIR="$CRAFT_RPA_HOME/runtime/recorder"
36
+ RUNTIME_MODULES_DIR="$RUNTIME_DIR/node_modules"
37
+ LEGACY_SESSION_PATH="$RECORDER_DIR/session.jsonl"
38
+ LEGACY_PROFILE_PATH="$RECORDER_DIR/profile"
39
+ LEGACY_MODULES_DIR="$RECORDER_DIR/node_modules"
41
40
 
42
41
  DASHBOARD_URL="http://localhost:7777/dashboard"
43
42
 
43
+ prepare_runtime_dependencies() {
44
+ mkdir -p "$RUNTIME_DIR"
45
+ RUNTIME_CHANGED=false
46
+ for MANIFEST in package.json package-lock.json; do
47
+ if [ ! -f "$RECORDER_DIR/$MANIFEST" ]; then
48
+ continue
49
+ fi
50
+ if [ ! -f "$RUNTIME_DIR/$MANIFEST" ] || ! cmp -s "$RECORDER_DIR/$MANIFEST" "$RUNTIME_DIR/$MANIFEST"; then
51
+ cp "$RECORDER_DIR/$MANIFEST" "$RUNTIME_DIR/$MANIFEST"
52
+ RUNTIME_CHANGED=true
53
+ fi
54
+ done
55
+ if [ "$RUNTIME_CHANGED" = true ] || [ ! -d "$RUNTIME_MODULES_DIR/playwright" ]; then
56
+ echo "[craft-rpa] installing deps under $RUNTIME_DIR (one-time, ~30-60s) ..."
57
+ if [ -f "$RUNTIME_DIR/package-lock.json" ]; then
58
+ (cd "$RUNTIME_DIR" && PLAYWRIGHT_SKIP_BROWSER_DOWNLOAD=1 npm ci --no-audit --no-fund)
59
+ else
60
+ (cd "$RUNTIME_DIR" && PLAYWRIGHT_SKIP_BROWSER_DOWNLOAD=1 npm install --no-audit --no-fund)
61
+ fi
62
+ fi
63
+ }
64
+
65
+ cleanup_legacy_recorder_runtime() {
66
+ if [ -L "$LEGACY_SESSION_PATH" ]; then
67
+ # 只删除旧软链本身,真实 session 目标继续留在数据根。
68
+ rm -f "$LEGACY_SESSION_PATH"
69
+ echo "[craft-rpa] removed legacy recorder/session.jsonl symlink"
70
+ elif [ -e "$LEGACY_SESSION_PATH" ]; then
71
+ if [ -s "$LEGACY_SESSION_PATH" ]; then
72
+ LEGACY_TS=$(date +"%Y-%m-%d_%H-%M-%S")
73
+ LEGACY_DIR="$SESSIONS_DIR/legacy-$LEGACY_TS"
74
+ mkdir -p "$LEGACY_DIR"
75
+ mv "$LEGACY_SESSION_PATH" "$LEGACY_DIR/session.jsonl"
76
+ echo "[craft-rpa] migrated legacy recorder/session.jsonl → sessions/legacy-$LEGACY_TS/"
77
+ else
78
+ rm -f "$LEGACY_SESSION_PATH"
79
+ fi
80
+ fi
81
+
82
+ if [ -L "$LEGACY_PROFILE_PATH" ]; then
83
+ # 只删除旧软链本身,真实 profile 目标继续留在数据根。
84
+ rm -f "$LEGACY_PROFILE_PATH"
85
+ echo "[craft-rpa] removed legacy recorder/profile symlink"
86
+ elif [ -e "$LEGACY_PROFILE_PATH" ]; then
87
+ LEGACY_TS=$(date +"%Y-%m-%d_%H-%M-%S")
88
+ mv "$LEGACY_PROFILE_PATH" "$CRAFT_RPA_HOME/profile-legacy-$LEGACY_TS"
89
+ echo "[craft-rpa] migrated legacy recorder/profile → .craft-rpa/profile-legacy-$LEGACY_TS"
90
+ fi
91
+
92
+ if [ -L "$LEGACY_MODULES_DIR" ]; then
93
+ rm -f "$LEGACY_MODULES_DIR"
94
+ echo "[craft-rpa] removed legacy recorder/node_modules symlink"
95
+ elif [ -e "$LEGACY_MODULES_DIR" ]; then
96
+ # node_modules 只是可重建缓存,精确删除受管目录中的旧副本,避免 Plugin 重放再次扫描它。
97
+ rm -rf "$LEGACY_MODULES_DIR"
98
+ echo "[craft-rpa] removed legacy recorder/node_modules cache"
99
+ fi
100
+ }
101
+
44
102
  cmd="${1:-status}"
45
103
 
46
104
  case "$cmd" in
@@ -60,44 +118,22 @@ case "$cmd" in
60
118
  exit 0
61
119
  fi
62
120
 
63
- if [ ! -d "$RECORDER_DIR/node_modules" ]; then
64
- echo "[craft-rpa] installing deps (one-time, ~30-60s) ..."
65
- (cd "$RECORDER_DIR" && PLAYWRIGHT_SKIP_BROWSER_DOWNLOAD=1 npm install --no-audit --no-fund)
66
- fi
67
-
68
121
  mkdir -p "$CRAFT_RPA_HOME" "$SESSIONS_DIR" "$PROFILE_DIR"
69
-
70
- # 旧 session.jsonl 是普通文件(老版本遗留)→ 归档,避免 ln 覆盖丢数据
71
- if [ -e "$SESSION_LINK" ] && [ ! -L "$SESSION_LINK" ]; then
72
- if [ -s "$SESSION_LINK" ]; then
73
- LEGACY_TS=$(date +"%Y-%m-%d_%H-%M-%S")
74
- LEGACY_DIR="$SESSIONS_DIR/legacy-$LEGACY_TS"
75
- mkdir -p "$LEGACY_DIR"
76
- mv "$SESSION_LINK" "$LEGACY_DIR/session.jsonl"
77
- echo "[craft-rpa] migrated legacy recorder/session.jsonl → sessions/legacy-$LEGACY_TS/"
78
- else
79
- rm -f "$SESSION_LINK"
80
- fi
81
- fi
82
- # 旧 profile 是普通目录(老版本遗留)→ 归档到数据根
83
- if [ -e "$PROFILE_LINK" ] && [ ! -L "$PROFILE_LINK" ]; then
84
- LEGACY_TS=$(date +"%Y-%m-%d_%H-%M-%S")
85
- mv "$PROFILE_LINK" "$CRAFT_RPA_HOME/profile-legacy-$LEGACY_TS"
86
- echo "[craft-rpa] migrated legacy recorder/profile → .craft-rpa/profile-legacy-$LEGACY_TS"
87
- fi
122
+ cleanup_legacy_recorder_runtime
123
+ prepare_runtime_dependencies
88
124
 
89
125
  TS=$(date +"%Y-%m-%d_%H-%M-%S")
90
126
  NEW_SESSION_DIR="$SESSIONS_DIR/$TS"
91
127
  mkdir -p "$NEW_SESSION_DIR"
92
128
  : > "$NEW_SESSION_DIR/session.jsonl"
93
129
 
94
- # 建两个软链:launch.js / logger.js 通过 __dirname 读到的就是项目根的数据
95
- ln -sfn "$NEW_SESSION_DIR/session.jsonl" "$SESSION_LINK"
96
- ln -sfn "$PROFILE_DIR" "$PROFILE_LINK"
97
130
  echo "$TS" > "$CURRENT_FILE"
98
131
 
99
132
  cd "$RECORDER_DIR"
100
- nohup node launch.js "$URL" > "$LOG_FILE" 2>&1 &
133
+ CRAFT_RPA_SESSION_FILE="$NEW_SESSION_DIR/session.jsonl" \
134
+ CRAFT_RPA_PROFILE_DIR="$PROFILE_DIR" \
135
+ CRAFT_RPA_PLAYWRIGHT_MODULE="$RUNTIME_MODULES_DIR/playwright" \
136
+ nohup node launch.js "$URL" > "$LOG_FILE" 2>&1 &
101
137
  PID=$!
102
138
  echo "$PID" > "$PID_FILE"
103
139
  sleep 1