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.
- package/enhancements/0.6/overrides/bundles/control-plane-integrity.json +2 -1
- package/enhancements/0.6/overrides/conflicts.json +22 -0
- package/enhancements/0.6/overrides/patches/scripts/session-context-update-boundary/constants-selector.py +6 -0
- package/enhancements/0.6/overrides/patches/scripts/session-context-update-boundary/helpers-selector.py +150 -0
- package/enhancements/0.6/overrides/patches/scripts/session-context-update-boundary/imports-content.py +4 -0
- package/enhancements/0.6/overrides/patches/scripts/session-context-update-boundary/imports-selector.py +8 -0
- package/enhancements/0.6/overrides/patches/scripts/session-context-update-boundary/output-content.py +7 -0
- package/enhancements/0.6/overrides/patches/scripts/session-context-update-boundary/output-selector.py +13 -0
- package/enhancements/0.6/overrides/patches/scripts/session-context-update-boundary/patch.json +53 -0
- package/enhancements/MANIFEST.json +9 -2
- package/enhancements/common/.common/.claude/skills/craft-rpa/SKILL.md +6 -7
- package/enhancements/common/.common/.claude/skills/craft-rpa/recorder/launch.js +8 -6
- package/enhancements/common/.common/.claude/skills/craft-rpa/recorder/logger.js +3 -1
- package/enhancements/common/.common/.claude/skills/craft-rpa/recorder/test/runtime-layout.test.js +96 -0
- package/enhancements/common/.common/.claude/skills/craft-rpa/scripts/run.sh +72 -36
- package/enhancements/common/.common/.codex/skills/craft-rpa/SKILL.md +6 -7
- package/enhancements/common/.common/.codex/skills/craft-rpa/recorder/launch.js +8 -6
- package/enhancements/common/.common/.codex/skills/craft-rpa/recorder/logger.js +3 -1
- package/enhancements/common/.common/.codex/skills/craft-rpa/recorder/test/runtime-layout.test.js +96 -0
- package/enhancements/common/.common/.codex/skills/craft-rpa/scripts/run.sh +72 -36
- package/package.json +3 -3
- package/src/builtin-plugins/skill-garden/content-adapter.js +126 -10
- package/src/builtin-plugins/skill-garden/provider.js +13 -2
- package/src/lib/patch-conflicts.js +28 -2
- package/src/lib/patch-engine.js +41 -3
- package/src/lib/platform-patch-adapters.js +8 -7
- package/src/lib/trellis-python-command.js +95 -0
|
@@ -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,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,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-
|
|
2
|
+
"syncedAt": "2026-07-29T12:22:03.171Z",
|
|
3
3
|
"syncedFrom": "vendor/skill-garden",
|
|
4
|
-
"sourceCommit": "
|
|
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
|
|
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
|
|
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
|
-
-
|
|
119
|
+
- run.sh 通过显式环境变量把 session/profile/Playwright 模块路径传给 launch.js,避免运行时产物进入 Plugin 管理树
|
|
121
120
|
|
|
122
121
|
**关键性质**:
|
|
123
122
|
|
|
124
123
|
- 每次 `run.sh start` 创建新时间戳目录,**不覆盖**历史
|
|
125
|
-
- `recorder
|
|
126
|
-
- 老版本遗留的 `recorder/session.jsonl`
|
|
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
|
-
* -
|
|
22
|
-
* -
|
|
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
|
-
//
|
|
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.
|
|
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
|
* 启动本地日志收集服务
|
package/enhancements/common/.common/.claude/skills/craft-rpa/recorder/test/runtime-layout.test.js
ADDED
|
@@ -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 内
|
|
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
|
-
|
|
39
|
-
|
|
40
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|