Initial rrweb SGrobot skill package
This commit is contained in:
1
.gitignore
vendored
Normal file
1
.gitignore
vendored
Normal file
@@ -0,0 +1 @@
|
|||||||
|
.DS_Store
|
||||||
59
SKILL.md
Normal file
59
SKILL.md
Normal file
@@ -0,0 +1,59 @@
|
|||||||
|
---
|
||||||
|
name: rrweb-sgrobot-adapter
|
||||||
|
description: Generate SGrobot-compatible PlanTemplate.toml and runtime SKILL.toml artifacts from rrweb semantic traces. Use when adapting recorded browser workflows, rrweb traces, semantic-trace JSON, or browser automation recordings for SGrobot/sgclaw plan_templates and .sgclaw-workspace skills without changing SGrobot core.
|
||||||
|
---
|
||||||
|
|
||||||
|
# RRWeb SGrobot Adapter
|
||||||
|
|
||||||
|
Use this skill to turn one rrweb semantic trace into SGrobot runtime artifacts:
|
||||||
|
|
||||||
|
```text
|
||||||
|
plan_templates/<flow>/PlanTemplate.toml
|
||||||
|
.sgclaw-workspace/skills/<flow>-execute/SKILL.toml
|
||||||
|
.sgclaw-workspace/skills/<flow>-execute/scripts/execute_trace.js
|
||||||
|
.sgclaw-workspace/skills/<flow>-execute/trace.semantic.json
|
||||||
|
```
|
||||||
|
|
||||||
|
Do not treat `SKILL.md` as the SGrobot runtime artifact. In SGrobot:
|
||||||
|
|
||||||
|
- `PlanTemplate.toml` is the user-facing business task orchestration.
|
||||||
|
- `.sgclaw-workspace/skills/**/SKILL.toml` is the executable runtime skill.
|
||||||
|
- `.agents/skills/**/SKILL.md` is only an agent/Codex helper.
|
||||||
|
|
||||||
|
## Workflow
|
||||||
|
|
||||||
|
1. Read the semantic trace JSON and confirm it has `id`, `name`, `startUrl`, and non-empty `steps`.
|
||||||
|
2. Read [references/sgrobot-adapter.md](references/sgrobot-adapter.md) when you need SGrobot mapping details.
|
||||||
|
3. Generate artifacts with:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
node scripts/generate_sgrobot_flow.mjs \
|
||||||
|
--trace examples/basic-trace.json \
|
||||||
|
--out /path/to/sgrobot-architecture-design \
|
||||||
|
--default-mode dry-run
|
||||||
|
```
|
||||||
|
|
||||||
|
Use `--default-mode execute` only when the user explicitly wants the generated skill to mutate the target page. Generated execute-mode plans set `requires_approval = true` when the trace includes mutating actions.
|
||||||
|
|
||||||
|
4. Inspect the generated `PlanTemplate.toml` and `SKILL.toml` before handing off. The plan step `skills = ["..."]` must match the generated `[skill].name`.
|
||||||
|
5. In a live SGrobot repo, verify `/api/debug/plans` sees the plan and `/api/skill` sees the skill before running the digital employee task.
|
||||||
|
|
||||||
|
## Trace Shape
|
||||||
|
|
||||||
|
Minimal trace:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"id": "expense-approval",
|
||||||
|
"name": "费用审批",
|
||||||
|
"description": "打开审批页,填写意见并点击同意。",
|
||||||
|
"startUrl": "https://oa.example.test/expense/123",
|
||||||
|
"steps": [
|
||||||
|
{ "type": "assertText", "text": "费用审批", "label": "确认在审批页" },
|
||||||
|
{ "type": "fill", "selector": "textarea[name='comment']", "value": "同意", "label": "填写审批意见" },
|
||||||
|
{ "type": "click", "selector": "button[data-action='approve']", "label": "点击同意", "risk": "write" }
|
||||||
|
]
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Supported first-version action types are `assertText`, `extractText`, `click`, `fill`, `input`, `type`, `select`, `check`, `uncheck`, and `submit`. Prefer stable CSS selectors; XPath is supported with `xpath=` or leading `//`.
|
||||||
150
docs/superpowers/plans/2026-06-12-sgrobot-adapter.md
Normal file
150
docs/superpowers/plans/2026-06-12-sgrobot-adapter.md
Normal file
@@ -0,0 +1,150 @@
|
|||||||
|
# SGrobot Adapter Implementation Plan
|
||||||
|
|
||||||
|
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
|
||||||
|
|
||||||
|
**Goal:** Build a reusable generator that turns one rrweb semantic trace into SGrobot-compatible `PlanTemplate.toml` and runtime `SKILL.toml` artifacts without changing SGrobot core.
|
||||||
|
|
||||||
|
**Architecture:** Keep this repository as a Codex skill plus deterministic generator. The generator writes one business-specific plan and one business-specific sgBrowser runtime skill per recording, with the semantic trace baked into the generated page script and saved beside it as evidence.
|
||||||
|
|
||||||
|
**Tech Stack:** Node.js ESM, `node:test`, SGrobot `PlanTemplate.toml`, SGrobot runtime `SKILL.toml`, sgBrowser `sgBrowserExcuteJsCodeByDomain`.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Task 1: Generator Contract
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Create: `tests/generate_sgrobot_flow.test.mjs`
|
||||||
|
- Create: `scripts/generate_sgrobot_flow.mjs`
|
||||||
|
- Create: `package.json`
|
||||||
|
|
||||||
|
- [ ] **Step 1: Write the failing tests**
|
||||||
|
|
||||||
|
```js
|
||||||
|
import { test } from "node:test";
|
||||||
|
import assert from "node:assert/strict";
|
||||||
|
import { mkdtemp, readFile, rm } from "node:fs/promises";
|
||||||
|
import { tmpdir } from "node:os";
|
||||||
|
import path from "node:path";
|
||||||
|
import { generateSgRobotArtifacts, writeArtifacts } from "../scripts/generate_sgrobot_flow.mjs";
|
||||||
|
|
||||||
|
const trace = {
|
||||||
|
id: "expense-approval",
|
||||||
|
name: "费用审批",
|
||||||
|
description: "打开审批页,填写意见并点击同意。",
|
||||||
|
startUrl: "https://oa.example.test/expense/123",
|
||||||
|
steps: [
|
||||||
|
{ type: "assertText", text: "费用审批", label: "确认在审批页" },
|
||||||
|
{ type: "fill", selector: "textarea[name='comment']", value: "同意", label: "填写审批意见" },
|
||||||
|
{ type: "click", selector: "button[data-action='approve']", label: "点击同意", risk: "write" }
|
||||||
|
]
|
||||||
|
};
|
||||||
|
|
||||||
|
test("generates SGrobot plan and runtime skill artifacts", () => {
|
||||||
|
const result = generateSgRobotArtifacts(trace, { defaultMode: "execute" });
|
||||||
|
assert.equal(result.summary.planId, "rrweb-expense-approval");
|
||||||
|
assert.equal(result.summary.skillName, "rrweb-expense-approval-execute");
|
||||||
|
assert.equal(result.summary.requiresApproval, true);
|
||||||
|
assert.equal(result.files.get("plan_templates/rrweb-expense-approval/PlanTemplate.toml").includes('skills = ["rrweb-expense-approval-execute"]'), true);
|
||||||
|
assert.equal(result.files.get(".sgclaw-workspace/skills/rrweb-expense-approval-execute/SKILL.toml").includes('command = "sgBrowserExcuteJsCodeByDomain"'), true);
|
||||||
|
assert.equal(result.files.get(".sgclaw-workspace/skills/rrweb-expense-approval-execute/SKILL.toml").includes('expected_domain = "oa.example.test"'), true);
|
||||||
|
assert.equal(result.files.get(".sgclaw-workspace/skills/rrweb-expense-approval-execute/scripts/execute_trace.js").includes('"default_mode": "execute"'), true);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("writes generated artifacts under a SGrobot repository root", async () => {
|
||||||
|
const root = await mkdtemp(path.join(tmpdir(), "rrweb-sgrobot-"));
|
||||||
|
try {
|
||||||
|
const result = generateSgRobotArtifacts(trace);
|
||||||
|
await writeArtifacts(root, result.files);
|
||||||
|
const plan = await readFile(path.join(root, "plan_templates/rrweb-expense-approval/PlanTemplate.toml"), "utf8");
|
||||||
|
const skill = await readFile(path.join(root, ".sgclaw-workspace/skills/rrweb-expense-approval-execute/SKILL.toml"), "utf8");
|
||||||
|
const evidence = await readFile(path.join(root, ".sgclaw-workspace/skills/rrweb-expense-approval-execute/trace.semantic.json"), "utf8");
|
||||||
|
assert.match(plan, /skills = \["browser\.open_url"\]/);
|
||||||
|
assert.match(skill, /script_path = "scripts\/execute_trace.js"/);
|
||||||
|
assert.equal(JSON.parse(evidence).id, "expense-approval");
|
||||||
|
} finally {
|
||||||
|
await rm(root, { recursive: true, force: true });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 2: Run test to verify it fails**
|
||||||
|
|
||||||
|
Run: `node --test tests/generate_sgrobot_flow.test.mjs`
|
||||||
|
|
||||||
|
Expected: FAIL with `Cannot find module '../scripts/generate_sgrobot_flow.mjs'`.
|
||||||
|
|
||||||
|
- [ ] **Step 3: Implement the generator**
|
||||||
|
|
||||||
|
Create `scripts/generate_sgrobot_flow.mjs` exporting `generateSgRobotArtifacts(trace, options)` and `writeArtifacts(root, files)`. The generator must validate `id`, `name`, `startUrl`, and `steps`, derive `expected_domain` from `startUrl`, emit SGrobot paths, and support `defaultMode` of `dry-run` or `execute`.
|
||||||
|
|
||||||
|
- [ ] **Step 4: Add package metadata**
|
||||||
|
|
||||||
|
Create `package.json` with:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"name": "rrweb-sgrobot-skill",
|
||||||
|
"version": "0.1.0",
|
||||||
|
"type": "module",
|
||||||
|
"private": true,
|
||||||
|
"scripts": {
|
||||||
|
"test": "node --test tests/*.test.mjs",
|
||||||
|
"generate:sgrobot": "node scripts/generate_sgrobot_flow.mjs"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 5: Run test to verify it passes**
|
||||||
|
|
||||||
|
Run: `npm test`
|
||||||
|
|
||||||
|
Expected: PASS with both generator tests green.
|
||||||
|
|
||||||
|
### Task 2: Codex Skill Wrapper
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Create: `SKILL.md`
|
||||||
|
- Create: `references/sgrobot-adapter.md`
|
||||||
|
- Create: `examples/basic-trace.json`
|
||||||
|
|
||||||
|
- [ ] **Step 1: Write the skill instructions**
|
||||||
|
|
||||||
|
Create a concise `SKILL.md` that tells Codex to use `scripts/generate_sgrobot_flow.mjs` when adapting rrweb semantic traces for SGrobot. It must state that `.agents/skills/**/SKILL.md` is only an agent helper, while SGrobot runtime artifacts are `PlanTemplate.toml` and `.sgclaw-workspace/skills/**/SKILL.toml`.
|
||||||
|
|
||||||
|
- [ ] **Step 2: Add focused SGrobot mapping reference**
|
||||||
|
|
||||||
|
Create `references/sgrobot-adapter.md` with the SGrobot-specific mapping rules: one generated plan per recording, one generated runtime skill per recording, `browser.open_url` first, generated sgBrowser execution skill second, optional `json.save_result` third, and step-level approval when execute mode includes mutating actions.
|
||||||
|
|
||||||
|
- [ ] **Step 3: Add a realistic example trace**
|
||||||
|
|
||||||
|
Create `examples/basic-trace.json` using the same schema covered by the test: `id`, `name`, `description`, `startUrl`, and `steps`.
|
||||||
|
|
||||||
|
- [ ] **Step 4: Validate the skill folder**
|
||||||
|
|
||||||
|
Run: `python3 /Users/zhaoyilun/.codex/skills/.system/skill-creator/scripts/quick_validate.py /Users/zhaoyilun/Documents/rrweb-skill`
|
||||||
|
|
||||||
|
Expected: PASS.
|
||||||
|
|
||||||
|
### Task 3: Verification Against SGrobot Shape
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Read-only check: `/Users/zhaoyilun/projects/sgrobot-architecture-design/docs/concepts/skill-and-plan-template.md`
|
||||||
|
- Read-only check: `/Users/zhaoyilun/projects/sgrobot-architecture-design/docs/how-to/add-skill.md`
|
||||||
|
|
||||||
|
- [ ] **Step 1: Generate sample output to a temp directory**
|
||||||
|
|
||||||
|
Run: `node scripts/generate_sgrobot_flow.mjs --trace examples/basic-trace.json --out /tmp/rrweb-sgrobot-sample --default-mode dry-run --force`
|
||||||
|
|
||||||
|
Expected: the command prints paths for the plan, skill, runtime script, and trace evidence.
|
||||||
|
|
||||||
|
- [ ] **Step 2: Inspect generated files**
|
||||||
|
|
||||||
|
Run: `find /tmp/rrweb-sgrobot-sample -type f | sort`
|
||||||
|
|
||||||
|
Expected: files exist under `plan_templates/rrweb-expense-approval/` and `.sgclaw-workspace/skills/rrweb-expense-approval-execute/`.
|
||||||
|
|
||||||
|
- [ ] **Step 3: Re-run tests**
|
||||||
|
|
||||||
|
Run: `npm test`
|
||||||
|
|
||||||
|
Expected: PASS.
|
||||||
25
examples/basic-trace.json
Normal file
25
examples/basic-trace.json
Normal file
@@ -0,0 +1,25 @@
|
|||||||
|
{
|
||||||
|
"id": "expense-approval",
|
||||||
|
"name": "费用审批",
|
||||||
|
"description": "打开审批页,填写意见并点击同意。",
|
||||||
|
"startUrl": "https://oa.example.test/expense/123",
|
||||||
|
"steps": [
|
||||||
|
{
|
||||||
|
"type": "assertText",
|
||||||
|
"text": "费用审批",
|
||||||
|
"label": "确认在审批页"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "fill",
|
||||||
|
"selector": "textarea[name='comment']",
|
||||||
|
"value": "同意",
|
||||||
|
"label": "填写审批意见"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "click",
|
||||||
|
"selector": "button[data-action='approve']",
|
||||||
|
"label": "点击同意",
|
||||||
|
"risk": "write"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
210
examples/demo-site/customer-callback.html
Normal file
210
examples/demo-site/customer-callback.html
Normal file
@@ -0,0 +1,210 @@
|
|||||||
|
<!doctype html>
|
||||||
|
<html lang="zh-CN">
|
||||||
|
<head>
|
||||||
|
<meta charset="utf-8" />
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||||
|
<link rel="icon" href="data:," />
|
||||||
|
<title>客户回访工单登记</title>
|
||||||
|
<style>
|
||||||
|
:root {
|
||||||
|
color-scheme: light;
|
||||||
|
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
|
||||||
|
background: #eef2f6;
|
||||||
|
color: #1f2937;
|
||||||
|
}
|
||||||
|
|
||||||
|
body {
|
||||||
|
margin: 0;
|
||||||
|
padding: 32px;
|
||||||
|
}
|
||||||
|
|
||||||
|
main {
|
||||||
|
max-width: 920px;
|
||||||
|
margin: 0 auto;
|
||||||
|
background: #ffffff;
|
||||||
|
border: 1px solid #d7dee8;
|
||||||
|
border-radius: 8px;
|
||||||
|
box-shadow: 0 12px 34px rgba(31, 41, 55, 0.08);
|
||||||
|
}
|
||||||
|
|
||||||
|
header {
|
||||||
|
padding: 24px 28px;
|
||||||
|
border-bottom: 1px solid #e3e8ef;
|
||||||
|
}
|
||||||
|
|
||||||
|
h1 {
|
||||||
|
margin: 0 0 8px;
|
||||||
|
font-size: 24px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.meta {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(3, 1fr);
|
||||||
|
gap: 12px;
|
||||||
|
padding: 20px 28px;
|
||||||
|
background: #f8fafc;
|
||||||
|
border-bottom: 1px solid #e3e8ef;
|
||||||
|
}
|
||||||
|
|
||||||
|
.meta div,
|
||||||
|
label {
|
||||||
|
font-size: 14px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.meta strong {
|
||||||
|
display: block;
|
||||||
|
margin-top: 4px;
|
||||||
|
font-size: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
form {
|
||||||
|
padding: 24px 28px 28px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.field {
|
||||||
|
margin-bottom: 18px;
|
||||||
|
}
|
||||||
|
|
||||||
|
select,
|
||||||
|
textarea {
|
||||||
|
width: 100%;
|
||||||
|
box-sizing: border-box;
|
||||||
|
margin-top: 8px;
|
||||||
|
border: 1px solid #cbd5e1;
|
||||||
|
border-radius: 6px;
|
||||||
|
padding: 10px 12px;
|
||||||
|
font: inherit;
|
||||||
|
}
|
||||||
|
|
||||||
|
textarea {
|
||||||
|
min-height: 96px;
|
||||||
|
resize: vertical;
|
||||||
|
}
|
||||||
|
|
||||||
|
.actions {
|
||||||
|
display: flex;
|
||||||
|
justify-content: flex-end;
|
||||||
|
gap: 12px;
|
||||||
|
margin-top: 24px;
|
||||||
|
}
|
||||||
|
|
||||||
|
button {
|
||||||
|
border: 0;
|
||||||
|
border-radius: 6px;
|
||||||
|
padding: 10px 18px;
|
||||||
|
font: inherit;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
button.secondary {
|
||||||
|
background: #e5e7eb;
|
||||||
|
color: #111827;
|
||||||
|
}
|
||||||
|
|
||||||
|
button.primary {
|
||||||
|
background: #166534;
|
||||||
|
color: #ffffff;
|
||||||
|
}
|
||||||
|
|
||||||
|
#status {
|
||||||
|
margin-top: 16px;
|
||||||
|
color: #166534;
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<main>
|
||||||
|
<header>
|
||||||
|
<h1>客户回访工单登记</h1>
|
||||||
|
<p>低压故障抢修完成后,客服需要登记一次客户回访结果。</p>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<section class="meta" aria-label="工单概要">
|
||||||
|
<div>工单编号<strong>GD-20260612-0421</strong></div>
|
||||||
|
<div>客户姓名<strong>李女士</strong></div>
|
||||||
|
<div>回访任务<strong>待处理</strong></div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<form id="callback-form">
|
||||||
|
<div class="field">
|
||||||
|
<label for="result">回访结果</label>
|
||||||
|
<select id="result" name="callbackResult" data-trace="callback-result">
|
||||||
|
<option value="">请选择</option>
|
||||||
|
<option value="satisfied">客户满意,问题已解决</option>
|
||||||
|
<option value="follow-up">仍需跟进</option>
|
||||||
|
<option value="unreachable">暂未接通</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="field">
|
||||||
|
<label for="remark">回访备注</label>
|
||||||
|
<textarea id="remark" name="remark" data-trace="callback-remark" placeholder="请输入回访备注"></textarea>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="actions">
|
||||||
|
<button type="button" class="secondary" data-action="cancel">取消</button>
|
||||||
|
<button type="submit" class="primary" data-action="save-draft">保存草稿</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div id="status" role="status" aria-live="polite"></div>
|
||||||
|
</form>
|
||||||
|
</main>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
window.__semanticTrace = {
|
||||||
|
id: "customer-callback-draft",
|
||||||
|
name: "客户回访工单登记",
|
||||||
|
description: "客服打开待回访工单,选择客户满意,填写回访备注,并保存草稿。",
|
||||||
|
startUrl: window.location.href,
|
||||||
|
steps: [
|
||||||
|
{ type: "assertText", text: "客户回访工单登记", label: "确认进入客户回访工单页面" }
|
||||||
|
]
|
||||||
|
};
|
||||||
|
|
||||||
|
const trace = window.__semanticTrace.steps;
|
||||||
|
|
||||||
|
function selectorFor(node) {
|
||||||
|
if (node.dataset && node.dataset.trace) {
|
||||||
|
return `[data-trace="${node.dataset.trace}"]`;
|
||||||
|
}
|
||||||
|
if (node.dataset && node.dataset.action) {
|
||||||
|
return `[data-action="${node.dataset.action}"]`;
|
||||||
|
}
|
||||||
|
if (node.id) {
|
||||||
|
return `#${node.id}`;
|
||||||
|
}
|
||||||
|
return node.tagName.toLowerCase();
|
||||||
|
}
|
||||||
|
|
||||||
|
document.querySelector("select").addEventListener("change", (event) => {
|
||||||
|
trace.push({
|
||||||
|
type: "select",
|
||||||
|
selector: selectorFor(event.target),
|
||||||
|
value: event.target.value,
|
||||||
|
label: "选择回访结果"
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
document.querySelector("textarea").addEventListener("change", (event) => {
|
||||||
|
trace.push({
|
||||||
|
type: "fill",
|
||||||
|
selector: selectorFor(event.target),
|
||||||
|
value: event.target.value,
|
||||||
|
label: "填写回访备注"
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
document.getElementById("callback-form").addEventListener("submit", (event) => {
|
||||||
|
event.preventDefault();
|
||||||
|
trace.push({
|
||||||
|
type: "click",
|
||||||
|
selector: '[data-action="save-draft"]',
|
||||||
|
label: "点击保存草稿",
|
||||||
|
risk: "write"
|
||||||
|
});
|
||||||
|
document.getElementById("status").textContent = "草稿已保存,等待班长复核。";
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
31
examples/recordings/customer-callback-trace.json
Normal file
31
examples/recordings/customer-callback-trace.json
Normal file
@@ -0,0 +1,31 @@
|
|||||||
|
{
|
||||||
|
"id": "customer-callback-draft",
|
||||||
|
"name": "客户回访工单登记",
|
||||||
|
"description": "客服打开待回访工单,选择客户满意,填写回访备注,并保存草稿。",
|
||||||
|
"startUrl": "http://127.0.0.1:4187/customer-callback.html",
|
||||||
|
"steps": [
|
||||||
|
{
|
||||||
|
"type": "assertText",
|
||||||
|
"text": "客户回访工单登记",
|
||||||
|
"label": "确认进入客户回访工单页面"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "select",
|
||||||
|
"selector": "[data-trace=\"callback-result\"]",
|
||||||
|
"value": "satisfied",
|
||||||
|
"label": "选择回访结果"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "fill",
|
||||||
|
"selector": "[data-trace=\"callback-remark\"]",
|
||||||
|
"value": "已电话回访客户,现场供电恢复正常,客户确认满意。",
|
||||||
|
"label": "填写回访备注"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "click",
|
||||||
|
"selector": "[data-action=\"save-draft\"]",
|
||||||
|
"label": "点击保存草稿",
|
||||||
|
"risk": "write"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
BIN
output/playwright/customer-callback-after-execute.png
Normal file
BIN
output/playwright/customer-callback-after-execute.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 53 KiB |
@@ -0,0 +1,23 @@
|
|||||||
|
[skill]
|
||||||
|
name = "rrweb-customer-callback-draft-execute"
|
||||||
|
description = "Run the generated rrweb semantic trace for 客户回访工单登记 through sgBrowser."
|
||||||
|
version = "0.1.0"
|
||||||
|
author = "rrweb-skill"
|
||||||
|
tags = ["rrweb", "semantic-trace", "sgbrowser-action", "generated"]
|
||||||
|
|
||||||
|
prompts = [
|
||||||
|
"Use rrweb-customer-callback-draft-execute only for the generated 客户回访工单登记 browser flow.",
|
||||||
|
"Run it after browser.open_url has opened the target page.",
|
||||||
|
"Return the structured JSON evidence emitted by the tool; do not use Playwright, CDP, or generic browser_action for this step."
|
||||||
|
]
|
||||||
|
|
||||||
|
[[tools]]
|
||||||
|
name = "execute_trace"
|
||||||
|
description = "Replay or dry-run the generated 客户回访工单登记 semantic trace in sgBrowser."
|
||||||
|
kind = "sgbrowser_action"
|
||||||
|
command = "sgBrowserExcuteJsCodeByDomain"
|
||||||
|
expected_domain = "127.0.0.1"
|
||||||
|
|
||||||
|
[tools.args]
|
||||||
|
script_path = "scripts/execute_trace.js"
|
||||||
|
mode = "Baked default mode is execute. Runtime may pass mode=execute or mode=dry-run if supported."
|
||||||
@@ -0,0 +1,240 @@
|
|||||||
|
const TRACE = {
|
||||||
|
"id": "customer-callback-draft",
|
||||||
|
"name": "客户回访工单登记",
|
||||||
|
"start_url": "http://127.0.0.1:4187/customer-callback.html",
|
||||||
|
"expected_domain": "127.0.0.1",
|
||||||
|
"default_mode": "execute",
|
||||||
|
"actions": [
|
||||||
|
{
|
||||||
|
"type": "assertText",
|
||||||
|
"text": "客户回访工单登记",
|
||||||
|
"label": "确认进入客户回访工单页面"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "select",
|
||||||
|
"selector": "[data-trace=\"callback-result\"]",
|
||||||
|
"value": "satisfied",
|
||||||
|
"label": "选择回访结果"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "fill",
|
||||||
|
"selector": "[data-trace=\"callback-remark\"]",
|
||||||
|
"value": "已电话回访客户,现场供电恢复正常,客户确认满意。",
|
||||||
|
"label": "填写回访备注"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "click",
|
||||||
|
"selector": "[data-action=\"save-draft\"]",
|
||||||
|
"label": "点击保存草稿",
|
||||||
|
"risk": "write"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
};
|
||||||
|
const input =
|
||||||
|
(typeof args === "object" && args) ||
|
||||||
|
(typeof tool_args === "object" && tool_args) ||
|
||||||
|
(typeof sgclaw_args === "object" && sgclaw_args) ||
|
||||||
|
{};
|
||||||
|
|
||||||
|
function normalizeMode(value) {
|
||||||
|
const raw = String(value || TRACE.default_mode || "dry-run").trim().toLowerCase();
|
||||||
|
return raw === "execute" ? "execute" : "dry-run";
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizeType(value) {
|
||||||
|
return String(value || "").replace(/[\s_-]+/g, "").toLowerCase();
|
||||||
|
}
|
||||||
|
|
||||||
|
function cleanText(value) {
|
||||||
|
return String(value || "").replace(/\s+/g, " ").trim();
|
||||||
|
}
|
||||||
|
|
||||||
|
function selectorList(action) {
|
||||||
|
const selectors = [];
|
||||||
|
if (action.selector) {
|
||||||
|
selectors.push(action.selector);
|
||||||
|
}
|
||||||
|
if (Array.isArray(action.selectors)) {
|
||||||
|
for (const selector of action.selectors) {
|
||||||
|
if (selector) {
|
||||||
|
selectors.push(selector);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return selectors;
|
||||||
|
}
|
||||||
|
|
||||||
|
function findByXPath(xpath) {
|
||||||
|
try {
|
||||||
|
return document.evaluate(xpath, document, null, XPathResult.FIRST_ORDERED_NODE_TYPE, null).singleNodeValue;
|
||||||
|
} catch (_) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function findByText(text) {
|
||||||
|
const needle = cleanText(text);
|
||||||
|
if (!needle) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
const candidates = Array.from(document.querySelectorAll("button,a,input,textarea,select,[role='button'],[role='link'],label"));
|
||||||
|
return candidates.find((node) => cleanText(node.innerText || node.value || node.getAttribute("aria-label") || node.textContent).includes(needle)) || null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function findElement(action) {
|
||||||
|
for (const rawSelector of selectorList(action)) {
|
||||||
|
const selector = String(rawSelector).trim();
|
||||||
|
if (!selector) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (selector.startsWith("xpath=")) {
|
||||||
|
const node = findByXPath(selector.slice("xpath=".length));
|
||||||
|
if (node) {
|
||||||
|
return node;
|
||||||
|
}
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (selector.startsWith("//") || selector.startsWith("(//")) {
|
||||||
|
const node = findByXPath(selector);
|
||||||
|
if (node) {
|
||||||
|
return node;
|
||||||
|
}
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
const node = document.querySelector(selector);
|
||||||
|
if (node) {
|
||||||
|
return node;
|
||||||
|
}
|
||||||
|
} catch (_) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return findByText(action.text || action.label);
|
||||||
|
}
|
||||||
|
|
||||||
|
function describeElement(node) {
|
||||||
|
if (!node) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
tag: String(node.tagName || "").toLowerCase(),
|
||||||
|
id: node.id || "",
|
||||||
|
name: node.getAttribute ? node.getAttribute("name") || "" : "",
|
||||||
|
text: cleanText(node.innerText || node.value || node.textContent).slice(0, 120),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function dispatchFieldEvents(node) {
|
||||||
|
node.dispatchEvent(new Event("input", { bubbles: true }));
|
||||||
|
node.dispatchEvent(new Event("change", { bubbles: true }));
|
||||||
|
}
|
||||||
|
|
||||||
|
function runAction(action, index, shouldExecute) {
|
||||||
|
const type = normalizeType(action.type);
|
||||||
|
const result = {
|
||||||
|
index,
|
||||||
|
type: action.type,
|
||||||
|
label: action.label || "",
|
||||||
|
mode: shouldExecute ? "execute" : "dry-run",
|
||||||
|
ok: true,
|
||||||
|
};
|
||||||
|
|
||||||
|
if (type === "asserttext" || type === "waitfortext") {
|
||||||
|
const needle = cleanText(action.text || action.value);
|
||||||
|
const haystack = cleanText(document.body ? document.body.innerText : document.documentElement.textContent);
|
||||||
|
result.ok = needle ? haystack.includes(needle) : false;
|
||||||
|
result.expected_text = needle;
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (type === "extracttext") {
|
||||||
|
const target = findElement(action) || document.body || document.documentElement;
|
||||||
|
result.text = cleanText(target.innerText || target.textContent).slice(0, Number(action.max_chars || 2000));
|
||||||
|
result.element = describeElement(target);
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
const element = findElement(action);
|
||||||
|
result.element = describeElement(element);
|
||||||
|
if (!element) {
|
||||||
|
result.ok = false;
|
||||||
|
result.error = "target element not found";
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (type === "click" || type === "submit") {
|
||||||
|
if (shouldExecute) {
|
||||||
|
element.scrollIntoView({ block: "center", inline: "center" });
|
||||||
|
if (typeof element.focus === "function") {
|
||||||
|
element.focus();
|
||||||
|
}
|
||||||
|
if (type === "submit" && element.form) {
|
||||||
|
element.form.requestSubmit ? element.form.requestSubmit() : element.form.submit();
|
||||||
|
} else {
|
||||||
|
element.click();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (type === "fill" || type === "input" || type === "type") {
|
||||||
|
result.value = action.value;
|
||||||
|
if (shouldExecute) {
|
||||||
|
element.focus && element.focus();
|
||||||
|
element.value = String(action.value);
|
||||||
|
dispatchFieldEvents(element);
|
||||||
|
}
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (type === "select") {
|
||||||
|
result.value = action.value;
|
||||||
|
if (shouldExecute) {
|
||||||
|
element.value = String(action.value);
|
||||||
|
dispatchFieldEvents(element);
|
||||||
|
}
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (type === "check" || type === "uncheck") {
|
||||||
|
result.checked = type === "check";
|
||||||
|
if (shouldExecute) {
|
||||||
|
element.checked = type === "check";
|
||||||
|
dispatchFieldEvents(element);
|
||||||
|
}
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
result.ok = false;
|
||||||
|
result.error = "unsupported action type";
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
const mode = normalizeMode(input.mode || input.execution_mode || input.executionMode);
|
||||||
|
const shouldExecute = mode === "execute";
|
||||||
|
const actionResults = [];
|
||||||
|
let ok = true;
|
||||||
|
|
||||||
|
for (let index = 0; index < TRACE.actions.length; index += 1) {
|
||||||
|
const actionResult = runAction(TRACE.actions[index], index, shouldExecute);
|
||||||
|
actionResults.push(actionResult);
|
||||||
|
if (!actionResult.ok) {
|
||||||
|
ok = false;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return JSON.stringify({
|
||||||
|
ok,
|
||||||
|
trace_id: TRACE.id,
|
||||||
|
trace_name: TRACE.name,
|
||||||
|
mode,
|
||||||
|
executed: shouldExecute,
|
||||||
|
expected_domain: TRACE.expected_domain,
|
||||||
|
url: window.location.href,
|
||||||
|
title: document.title || "",
|
||||||
|
action_count: TRACE.actions.length,
|
||||||
|
action_results: actionResults,
|
||||||
|
finished_at: new Date().toISOString(),
|
||||||
|
});
|
||||||
@@ -0,0 +1,31 @@
|
|||||||
|
{
|
||||||
|
"id": "customer-callback-draft",
|
||||||
|
"name": "客户回访工单登记",
|
||||||
|
"description": "客服打开待回访工单,选择客户满意,填写回访备注,并保存草稿。",
|
||||||
|
"startUrl": "http://127.0.0.1:4187/customer-callback.html",
|
||||||
|
"steps": [
|
||||||
|
{
|
||||||
|
"type": "assertText",
|
||||||
|
"text": "客户回访工单登记",
|
||||||
|
"label": "确认进入客户回访工单页面"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "select",
|
||||||
|
"selector": "[data-trace=\"callback-result\"]",
|
||||||
|
"value": "satisfied",
|
||||||
|
"label": "选择回访结果"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "fill",
|
||||||
|
"selector": "[data-trace=\"callback-remark\"]",
|
||||||
|
"value": "已电话回访客户,现场供电恢复正常,客户确认满意。",
|
||||||
|
"label": "填写回访备注"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "click",
|
||||||
|
"selector": "[data-action=\"save-draft\"]",
|
||||||
|
"label": "点击保存草稿",
|
||||||
|
"risk": "write"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
@@ -0,0 +1,30 @@
|
|||||||
|
[plan]
|
||||||
|
id = "rrweb-customer-callback-draft"
|
||||||
|
name = "客户回访工单登记"
|
||||||
|
description = "客服打开待回访工单,选择客户满意,填写回访备注,并保存草稿。"
|
||||||
|
version = "1.0.0"
|
||||||
|
|
||||||
|
[plan.trigger]
|
||||||
|
kind = "manual"
|
||||||
|
|
||||||
|
[[plan.steps]]
|
||||||
|
id = "open-target-page"
|
||||||
|
title = "Open target page"
|
||||||
|
description = "Call browser.open_url to open http://127.0.0.1:4187/customer-callback.html."
|
||||||
|
skills = ["browser.open_url"]
|
||||||
|
depends_on = []
|
||||||
|
|
||||||
|
[[plan.steps]]
|
||||||
|
id = "run-recorded-flow"
|
||||||
|
title = "Run recorded browser flow"
|
||||||
|
description = "Execute the generated rrweb semantic trace through sgBrowser on 127.0.0.1. Default mode: execute."
|
||||||
|
skills = ["rrweb-customer-callback-draft-execute"]
|
||||||
|
depends_on = ["open-target-page"]
|
||||||
|
requires_approval = true
|
||||||
|
|
||||||
|
[[plan.steps]]
|
||||||
|
id = "save-result"
|
||||||
|
title = "Save structured result"
|
||||||
|
description = "Save the generated flow result as a structured JSON artifact."
|
||||||
|
skills = ["json.save_result"]
|
||||||
|
depends_on = ["run-recorded-flow"]
|
||||||
10
package.json
Normal file
10
package.json
Normal file
@@ -0,0 +1,10 @@
|
|||||||
|
{
|
||||||
|
"name": "rrweb-sgrobot-skill",
|
||||||
|
"version": "0.1.0",
|
||||||
|
"type": "module",
|
||||||
|
"private": true,
|
||||||
|
"scripts": {
|
||||||
|
"test": "node --test tests/*.test.mjs",
|
||||||
|
"generate:sgrobot": "node scripts/generate_sgrobot_flow.mjs"
|
||||||
|
}
|
||||||
|
}
|
||||||
74
references/sgrobot-adapter.md
Normal file
74
references/sgrobot-adapter.md
Normal file
@@ -0,0 +1,74 @@
|
|||||||
|
# SGrobot Adapter Reference
|
||||||
|
|
||||||
|
## Boundary
|
||||||
|
|
||||||
|
SGrobot uses two runtime artifact families:
|
||||||
|
|
||||||
|
- `plan_templates/<task>/PlanTemplate.toml`: business orchestration, selectable as a digital employee task.
|
||||||
|
- `.sgclaw-workspace/skills/<skill>/SKILL.toml`: atomic runtime capability loaded by Skill Catalog.
|
||||||
|
|
||||||
|
The Codex-facing `SKILL.md` in this repository only teaches an agent how to generate those artifacts.
|
||||||
|
|
||||||
|
## Generated Flow Shape
|
||||||
|
|
||||||
|
For each rrweb semantic trace, generate one business-specific package:
|
||||||
|
|
||||||
|
```text
|
||||||
|
plan_templates/rrweb-<flow>/PlanTemplate.toml
|
||||||
|
.sgclaw-workspace/skills/rrweb-<flow>-execute/SKILL.toml
|
||||||
|
.sgclaw-workspace/skills/rrweb-<flow>-execute/scripts/execute_trace.js
|
||||||
|
.sgclaw-workspace/skills/rrweb-<flow>-execute/trace.semantic.json
|
||||||
|
```
|
||||||
|
|
||||||
|
Do not generate a single universal runtime skill that expects `trace_path` unless SGrobot core gains stable per-step argument passing. Current stable PlanTemplate usage binds a step to a skill name; the runtime resolves that skill's primary tool.
|
||||||
|
|
||||||
|
## PlanTemplate Mapping
|
||||||
|
|
||||||
|
Use three steps:
|
||||||
|
|
||||||
|
1. `open-target-page`: `skills = ["browser.open_url"]`; description includes the trace `startUrl`.
|
||||||
|
2. `run-recorded-flow`: `skills = ["rrweb-<flow>-execute"]`; executes generated page script with sgBrowser.
|
||||||
|
3. `save-result`: `skills = ["json.save_result"]`; saves structured output.
|
||||||
|
|
||||||
|
Set `requires_approval = true` on `run-recorded-flow` when `--default-mode execute` is used and the trace includes mutating actions such as `click`, `fill`, `select`, `check`, `submit`, or `risk = "write"`.
|
||||||
|
|
||||||
|
## Runtime Skill Mapping
|
||||||
|
|
||||||
|
Generated `SKILL.toml` should use:
|
||||||
|
|
||||||
|
```toml
|
||||||
|
[[tools]]
|
||||||
|
name = "execute_trace"
|
||||||
|
kind = "sgbrowser_action"
|
||||||
|
command = "sgBrowserExcuteJsCodeByDomain"
|
||||||
|
expected_domain = "<domain from startUrl>"
|
||||||
|
|
||||||
|
[tools.args]
|
||||||
|
script_path = "scripts/execute_trace.js"
|
||||||
|
```
|
||||||
|
|
||||||
|
Keep `sgBrowserExcuteJsCodeByDomain` and the `sgBrowerserOpenPage` spelling used by the existing SGrobot project. Do not replace this path with Playwright, CDP, or a generic browser action.
|
||||||
|
|
||||||
|
## Runtime Script Rules
|
||||||
|
|
||||||
|
The generated `execute_trace.js` runs inside the target page. It should:
|
||||||
|
|
||||||
|
- Default to `dry-run` unless the generator is called with `--default-mode execute`.
|
||||||
|
- Resolve target elements by stable CSS selector first, XPath second, visible text fallback last.
|
||||||
|
- Return structured JSON with `ok`, `mode`, `trace_id`, `url`, `title`, and per-action results.
|
||||||
|
- Stop on the first failed action and include an error message.
|
||||||
|
|
||||||
|
## Verification
|
||||||
|
|
||||||
|
After generating into a SGrobot repo:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
node scripts/generate_sgrobot_flow.mjs --trace examples/basic-trace.json --out /Users/zhaoyilun/projects/sgrobot-architecture-design --default-mode dry-run
|
||||||
|
```
|
||||||
|
|
||||||
|
Then verify in SGrobot:
|
||||||
|
|
||||||
|
```powershell
|
||||||
|
Invoke-RestMethod 'http://127.0.0.1:42617/api/debug/plans?limit=20' | ConvertTo-Json -Depth 5
|
||||||
|
Invoke-RestMethod 'http://127.0.0.1:42617/api/skill' | ConvertTo-Json -Depth 4
|
||||||
|
```
|
||||||
536
scripts/generate_sgrobot_flow.mjs
Normal file
536
scripts/generate_sgrobot_flow.mjs
Normal file
@@ -0,0 +1,536 @@
|
|||||||
|
#!/usr/bin/env node
|
||||||
|
import { access, mkdir, readFile, writeFile } from "node:fs/promises";
|
||||||
|
import path from "node:path";
|
||||||
|
import { fileURLToPath } from "node:url";
|
||||||
|
|
||||||
|
const MUTATING_TYPES = new Set(["click", "fill", "input", "type", "select", "check", "uncheck", "submit", "press"]);
|
||||||
|
const WRITE_RISKS = new Set(["write", "dangerous", "destructive", "submit", "payment", "approval"]);
|
||||||
|
|
||||||
|
export function generateSgRobotArtifacts(trace, options = {}) {
|
||||||
|
const normalized = normalizeTrace(trace, options);
|
||||||
|
const files = new Map();
|
||||||
|
const skillRoot = `.sgclaw-workspace/skills/${normalized.skillName}`;
|
||||||
|
|
||||||
|
files.set(`plan_templates/${normalized.planId}/PlanTemplate.toml`, renderPlanTemplate(normalized));
|
||||||
|
files.set(`${skillRoot}/SKILL.toml`, renderSkillToml(normalized));
|
||||||
|
files.set(`${skillRoot}/scripts/execute_trace.js`, renderRuntimeScript(normalized));
|
||||||
|
files.set(`${skillRoot}/trace.semantic.json`, `${JSON.stringify(trace, null, 2)}\n`);
|
||||||
|
|
||||||
|
return {
|
||||||
|
summary: {
|
||||||
|
planId: normalized.planId,
|
||||||
|
skillName: normalized.skillName,
|
||||||
|
expectedDomain: normalized.expectedDomain,
|
||||||
|
defaultMode: normalized.defaultMode,
|
||||||
|
requiresApproval: normalized.requiresApproval,
|
||||||
|
actionCount: normalized.actions.length,
|
||||||
|
},
|
||||||
|
files,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function writeArtifacts(root, files, options = {}) {
|
||||||
|
const rootPath = path.resolve(root);
|
||||||
|
const written = [];
|
||||||
|
|
||||||
|
for (const [relativePath, content] of files.entries()) {
|
||||||
|
if (path.isAbsolute(relativePath) || relativePath.includes("..")) {
|
||||||
|
throw new Error(`Refusing unsafe generated path: ${relativePath}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const targetPath = path.resolve(rootPath, relativePath);
|
||||||
|
if (!targetPath.startsWith(`${rootPath}${path.sep}`)) {
|
||||||
|
throw new Error(`Refusing path outside output root: ${relativePath}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!options.force) {
|
||||||
|
try {
|
||||||
|
await access(targetPath);
|
||||||
|
throw new Error(`Refusing to overwrite existing file without --force: ${relativePath}`);
|
||||||
|
} catch (error) {
|
||||||
|
if (error && error.code !== "ENOENT") {
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
await mkdir(path.dirname(targetPath), { recursive: true });
|
||||||
|
await writeFile(targetPath, content, "utf8");
|
||||||
|
written.push(targetPath);
|
||||||
|
}
|
||||||
|
|
||||||
|
return written;
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizeTrace(trace, options) {
|
||||||
|
if (!trace || typeof trace !== "object" || Array.isArray(trace)) {
|
||||||
|
throw new Error("Trace must be a JSON object.");
|
||||||
|
}
|
||||||
|
|
||||||
|
const id = requireString(trace.id, "trace.id");
|
||||||
|
const name = requireString(trace.name, "trace.name");
|
||||||
|
const startUrl = requireString(trace.startUrl || trace.start_url, "trace.startUrl");
|
||||||
|
const start = parseHttpUrl(startUrl, "trace.startUrl");
|
||||||
|
const actions = Array.isArray(trace.steps) ? trace.steps : trace.actions;
|
||||||
|
|
||||||
|
if (!Array.isArray(actions) || actions.length === 0) {
|
||||||
|
throw new Error("Trace must include a non-empty steps array.");
|
||||||
|
}
|
||||||
|
|
||||||
|
const baseId = toKebabId(id);
|
||||||
|
const planId = cleanId(trace.planId || trace.plan_id || `rrweb-${baseId}`, "plan id");
|
||||||
|
const skillName = cleanId(trace.skillName || trace.skill_name || `${planId}-execute`, "skill name");
|
||||||
|
const expectedDomain = String(trace.expectedDomain || trace.expected_domain || start.hostname).trim();
|
||||||
|
const defaultMode = normalizeMode(options.defaultMode || options.default_mode || trace.defaultMode || trace.default_mode || "dry-run");
|
||||||
|
const normalizedActions = actions.map((action, index) => normalizeAction(action, index));
|
||||||
|
const hasMutatingAction = normalizedActions.some(isMutatingAction);
|
||||||
|
const requiresApproval =
|
||||||
|
trace.requiresApproval === true ||
|
||||||
|
trace.requires_approval === true ||
|
||||||
|
(defaultMode === "execute" && hasMutatingAction);
|
||||||
|
|
||||||
|
return {
|
||||||
|
id,
|
||||||
|
name,
|
||||||
|
description: String(trace.description || `${name} generated from rrweb semantic trace.`),
|
||||||
|
startUrl: start.href,
|
||||||
|
planId,
|
||||||
|
skillName,
|
||||||
|
expectedDomain,
|
||||||
|
defaultMode,
|
||||||
|
requiresApproval,
|
||||||
|
actions: normalizedActions,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizeAction(action, index) {
|
||||||
|
if (!action || typeof action !== "object" || Array.isArray(action)) {
|
||||||
|
throw new Error(`Trace step ${index + 1} must be an object.`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const type = requireString(action.type || action.action, `trace.steps[${index}].type`);
|
||||||
|
const normalized = {
|
||||||
|
...action,
|
||||||
|
type,
|
||||||
|
label: String(action.label || action.name || `${type} #${index + 1}`),
|
||||||
|
};
|
||||||
|
|
||||||
|
if (needsTarget(type) && !action.selector && !Array.isArray(action.selectors) && !action.text) {
|
||||||
|
throw new Error(`Trace step ${index + 1} (${type}) needs selector, selectors, or text.`);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (needsValue(type) && action.value === undefined) {
|
||||||
|
throw new Error(`Trace step ${index + 1} (${type}) needs value.`);
|
||||||
|
}
|
||||||
|
|
||||||
|
return normalized;
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderPlanTemplate(trace) {
|
||||||
|
const approvalLine = trace.requiresApproval ? "\nrequires_approval = true" : "";
|
||||||
|
|
||||||
|
return `[plan]
|
||||||
|
id = ${tomlString(trace.planId)}
|
||||||
|
name = ${tomlString(trace.name)}
|
||||||
|
description = ${tomlString(trace.description)}
|
||||||
|
version = "1.0.0"
|
||||||
|
|
||||||
|
[plan.trigger]
|
||||||
|
kind = "manual"
|
||||||
|
|
||||||
|
[[plan.steps]]
|
||||||
|
id = "open-target-page"
|
||||||
|
title = "Open target page"
|
||||||
|
description = ${tomlString(`Call browser.open_url to open ${trace.startUrl}.`)}
|
||||||
|
skills = ["browser.open_url"]
|
||||||
|
depends_on = []
|
||||||
|
|
||||||
|
[[plan.steps]]
|
||||||
|
id = "run-recorded-flow"
|
||||||
|
title = "Run recorded browser flow"
|
||||||
|
description = ${tomlString(`Execute the generated rrweb semantic trace through sgBrowser on ${trace.expectedDomain}. Default mode: ${trace.defaultMode}.`)}
|
||||||
|
skills = [${tomlString(trace.skillName)}]
|
||||||
|
depends_on = ["open-target-page"]${approvalLine}
|
||||||
|
|
||||||
|
[[plan.steps]]
|
||||||
|
id = "save-result"
|
||||||
|
title = "Save structured result"
|
||||||
|
description = "Save the generated flow result as a structured JSON artifact."
|
||||||
|
skills = ["json.save_result"]
|
||||||
|
depends_on = ["run-recorded-flow"]
|
||||||
|
`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderSkillToml(trace) {
|
||||||
|
return `[skill]
|
||||||
|
name = ${tomlString(trace.skillName)}
|
||||||
|
description = ${tomlString(`Run the generated rrweb semantic trace for ${trace.name} through sgBrowser.`)}
|
||||||
|
version = "0.1.0"
|
||||||
|
author = "rrweb-skill"
|
||||||
|
tags = ["rrweb", "semantic-trace", "sgbrowser-action", "generated"]
|
||||||
|
|
||||||
|
prompts = [
|
||||||
|
${tomlString(`Use ${trace.skillName} only for the generated ${trace.name} browser flow.`)},
|
||||||
|
"Run it after browser.open_url has opened the target page.",
|
||||||
|
"Return the structured JSON evidence emitted by the tool; do not use Playwright, CDP, or generic browser_action for this step."
|
||||||
|
]
|
||||||
|
|
||||||
|
[[tools]]
|
||||||
|
name = "execute_trace"
|
||||||
|
description = ${tomlString(`Replay or dry-run the generated ${trace.name} semantic trace in sgBrowser.`)}
|
||||||
|
kind = "sgbrowser_action"
|
||||||
|
command = "sgBrowserExcuteJsCodeByDomain"
|
||||||
|
expected_domain = ${tomlString(trace.expectedDomain)}
|
||||||
|
|
||||||
|
[tools.args]
|
||||||
|
script_path = "scripts/execute_trace.js"
|
||||||
|
mode = ${tomlString(`Baked default mode is ${trace.defaultMode}. Runtime may pass mode=execute or mode=dry-run if supported.`)}
|
||||||
|
`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderRuntimeScript(trace) {
|
||||||
|
const runtimeTrace = {
|
||||||
|
id: trace.id,
|
||||||
|
name: trace.name,
|
||||||
|
start_url: trace.startUrl,
|
||||||
|
expected_domain: trace.expectedDomain,
|
||||||
|
default_mode: trace.defaultMode,
|
||||||
|
actions: trace.actions,
|
||||||
|
};
|
||||||
|
|
||||||
|
return `const TRACE = ${JSON.stringify(runtimeTrace, null, 2)};
|
||||||
|
const input =
|
||||||
|
(typeof args === "object" && args) ||
|
||||||
|
(typeof tool_args === "object" && tool_args) ||
|
||||||
|
(typeof sgclaw_args === "object" && sgclaw_args) ||
|
||||||
|
{};
|
||||||
|
|
||||||
|
function normalizeMode(value) {
|
||||||
|
const raw = String(value || TRACE.default_mode || "dry-run").trim().toLowerCase();
|
||||||
|
return raw === "execute" ? "execute" : "dry-run";
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizeType(value) {
|
||||||
|
return String(value || "").replace(/[\\s_-]+/g, "").toLowerCase();
|
||||||
|
}
|
||||||
|
|
||||||
|
function cleanText(value) {
|
||||||
|
return String(value || "").replace(/\\s+/g, " ").trim();
|
||||||
|
}
|
||||||
|
|
||||||
|
function selectorList(action) {
|
||||||
|
const selectors = [];
|
||||||
|
if (action.selector) {
|
||||||
|
selectors.push(action.selector);
|
||||||
|
}
|
||||||
|
if (Array.isArray(action.selectors)) {
|
||||||
|
for (const selector of action.selectors) {
|
||||||
|
if (selector) {
|
||||||
|
selectors.push(selector);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return selectors;
|
||||||
|
}
|
||||||
|
|
||||||
|
function findByXPath(xpath) {
|
||||||
|
try {
|
||||||
|
return document.evaluate(xpath, document, null, XPathResult.FIRST_ORDERED_NODE_TYPE, null).singleNodeValue;
|
||||||
|
} catch (_) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function findByText(text) {
|
||||||
|
const needle = cleanText(text);
|
||||||
|
if (!needle) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
const candidates = Array.from(document.querySelectorAll("button,a,input,textarea,select,[role='button'],[role='link'],label"));
|
||||||
|
return candidates.find((node) => cleanText(node.innerText || node.value || node.getAttribute("aria-label") || node.textContent).includes(needle)) || null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function findElement(action) {
|
||||||
|
for (const rawSelector of selectorList(action)) {
|
||||||
|
const selector = String(rawSelector).trim();
|
||||||
|
if (!selector) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (selector.startsWith("xpath=")) {
|
||||||
|
const node = findByXPath(selector.slice("xpath=".length));
|
||||||
|
if (node) {
|
||||||
|
return node;
|
||||||
|
}
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (selector.startsWith("//") || selector.startsWith("(//")) {
|
||||||
|
const node = findByXPath(selector);
|
||||||
|
if (node) {
|
||||||
|
return node;
|
||||||
|
}
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
const node = document.querySelector(selector);
|
||||||
|
if (node) {
|
||||||
|
return node;
|
||||||
|
}
|
||||||
|
} catch (_) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return findByText(action.text || action.label);
|
||||||
|
}
|
||||||
|
|
||||||
|
function describeElement(node) {
|
||||||
|
if (!node) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
tag: String(node.tagName || "").toLowerCase(),
|
||||||
|
id: node.id || "",
|
||||||
|
name: node.getAttribute ? node.getAttribute("name") || "" : "",
|
||||||
|
text: cleanText(node.innerText || node.value || node.textContent).slice(0, 120),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function dispatchFieldEvents(node) {
|
||||||
|
node.dispatchEvent(new Event("input", { bubbles: true }));
|
||||||
|
node.dispatchEvent(new Event("change", { bubbles: true }));
|
||||||
|
}
|
||||||
|
|
||||||
|
function runAction(action, index, shouldExecute) {
|
||||||
|
const type = normalizeType(action.type);
|
||||||
|
const result = {
|
||||||
|
index,
|
||||||
|
type: action.type,
|
||||||
|
label: action.label || "",
|
||||||
|
mode: shouldExecute ? "execute" : "dry-run",
|
||||||
|
ok: true,
|
||||||
|
};
|
||||||
|
|
||||||
|
if (type === "asserttext" || type === "waitfortext") {
|
||||||
|
const needle = cleanText(action.text || action.value);
|
||||||
|
const haystack = cleanText(document.body ? document.body.innerText : document.documentElement.textContent);
|
||||||
|
result.ok = needle ? haystack.includes(needle) : false;
|
||||||
|
result.expected_text = needle;
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (type === "extracttext") {
|
||||||
|
const target = findElement(action) || document.body || document.documentElement;
|
||||||
|
result.text = cleanText(target.innerText || target.textContent).slice(0, Number(action.max_chars || 2000));
|
||||||
|
result.element = describeElement(target);
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
const element = findElement(action);
|
||||||
|
result.element = describeElement(element);
|
||||||
|
if (!element) {
|
||||||
|
result.ok = false;
|
||||||
|
result.error = "target element not found";
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (type === "click" || type === "submit") {
|
||||||
|
if (shouldExecute) {
|
||||||
|
element.scrollIntoView({ block: "center", inline: "center" });
|
||||||
|
if (typeof element.focus === "function") {
|
||||||
|
element.focus();
|
||||||
|
}
|
||||||
|
if (type === "submit" && element.form) {
|
||||||
|
element.form.requestSubmit ? element.form.requestSubmit() : element.form.submit();
|
||||||
|
} else {
|
||||||
|
element.click();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (type === "fill" || type === "input" || type === "type") {
|
||||||
|
result.value = action.value;
|
||||||
|
if (shouldExecute) {
|
||||||
|
element.focus && element.focus();
|
||||||
|
element.value = String(action.value);
|
||||||
|
dispatchFieldEvents(element);
|
||||||
|
}
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (type === "select") {
|
||||||
|
result.value = action.value;
|
||||||
|
if (shouldExecute) {
|
||||||
|
element.value = String(action.value);
|
||||||
|
dispatchFieldEvents(element);
|
||||||
|
}
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (type === "check" || type === "uncheck") {
|
||||||
|
result.checked = type === "check";
|
||||||
|
if (shouldExecute) {
|
||||||
|
element.checked = type === "check";
|
||||||
|
dispatchFieldEvents(element);
|
||||||
|
}
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
result.ok = false;
|
||||||
|
result.error = "unsupported action type";
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
const mode = normalizeMode(input.mode || input.execution_mode || input.executionMode);
|
||||||
|
const shouldExecute = mode === "execute";
|
||||||
|
const actionResults = [];
|
||||||
|
let ok = true;
|
||||||
|
|
||||||
|
for (let index = 0; index < TRACE.actions.length; index += 1) {
|
||||||
|
const actionResult = runAction(TRACE.actions[index], index, shouldExecute);
|
||||||
|
actionResults.push(actionResult);
|
||||||
|
if (!actionResult.ok) {
|
||||||
|
ok = false;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return JSON.stringify({
|
||||||
|
ok,
|
||||||
|
trace_id: TRACE.id,
|
||||||
|
trace_name: TRACE.name,
|
||||||
|
mode,
|
||||||
|
executed: shouldExecute,
|
||||||
|
expected_domain: TRACE.expected_domain,
|
||||||
|
url: window.location.href,
|
||||||
|
title: document.title || "",
|
||||||
|
action_count: TRACE.actions.length,
|
||||||
|
action_results: actionResults,
|
||||||
|
finished_at: new Date().toISOString(),
|
||||||
|
});
|
||||||
|
`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function isMutatingAction(action) {
|
||||||
|
const type = String(action.type || "").trim().toLowerCase();
|
||||||
|
const risk = String(action.risk || "").trim().toLowerCase();
|
||||||
|
return MUTATING_TYPES.has(type) || WRITE_RISKS.has(risk);
|
||||||
|
}
|
||||||
|
|
||||||
|
function needsTarget(type) {
|
||||||
|
const normalized = String(type || "").replace(/[\s_-]+/g, "").toLowerCase();
|
||||||
|
return !["asserttext", "waitfortext"].includes(normalized);
|
||||||
|
}
|
||||||
|
|
||||||
|
function needsValue(type) {
|
||||||
|
const normalized = String(type || "").replace(/[\s_-]+/g, "").toLowerCase();
|
||||||
|
return ["fill", "input", "type", "select"].includes(normalized);
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizeMode(value) {
|
||||||
|
const mode = String(value || "").trim().toLowerCase();
|
||||||
|
if (mode === "dry-run" || mode === "dryrun" || mode === "dry_run") {
|
||||||
|
return "dry-run";
|
||||||
|
}
|
||||||
|
if (mode === "execute") {
|
||||||
|
return "execute";
|
||||||
|
}
|
||||||
|
throw new Error(`Unsupported default mode: ${value}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
function cleanId(value, label) {
|
||||||
|
const cleaned = toKebabId(value);
|
||||||
|
if (!cleaned) {
|
||||||
|
throw new Error(`Invalid ${label}: ${value}`);
|
||||||
|
}
|
||||||
|
return cleaned;
|
||||||
|
}
|
||||||
|
|
||||||
|
function toKebabId(value) {
|
||||||
|
return String(value || "")
|
||||||
|
.trim()
|
||||||
|
.replace(/([a-z0-9])([A-Z])/g, "$1-$2")
|
||||||
|
.toLowerCase()
|
||||||
|
.replace(/[^a-z0-9._-]+/g, "-")
|
||||||
|
.replace(/-+/g, "-")
|
||||||
|
.replace(/^-|-$/g, "");
|
||||||
|
}
|
||||||
|
|
||||||
|
function requireString(value, label) {
|
||||||
|
if (typeof value !== "string" || !value.trim()) {
|
||||||
|
throw new Error(`${label} must be a non-empty string.`);
|
||||||
|
}
|
||||||
|
return value.trim();
|
||||||
|
}
|
||||||
|
|
||||||
|
function parseHttpUrl(value, label) {
|
||||||
|
try {
|
||||||
|
const url = new URL(value);
|
||||||
|
if (!["http:", "https:"].includes(url.protocol)) {
|
||||||
|
throw new Error("Only http/https URLs are supported.");
|
||||||
|
}
|
||||||
|
return url;
|
||||||
|
} catch (error) {
|
||||||
|
throw new Error(`${label} must be a valid http/https URL. ${error.message}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function tomlString(value) {
|
||||||
|
return JSON.stringify(String(value));
|
||||||
|
}
|
||||||
|
|
||||||
|
function parseCliArgs(argv) {
|
||||||
|
const args = {
|
||||||
|
defaultMode: "dry-run",
|
||||||
|
force: false,
|
||||||
|
};
|
||||||
|
|
||||||
|
for (let index = 0; index < argv.length; index += 1) {
|
||||||
|
const token = argv[index];
|
||||||
|
if (token === "--trace") {
|
||||||
|
args.trace = argv[++index];
|
||||||
|
} else if (token === "--out") {
|
||||||
|
args.out = argv[++index];
|
||||||
|
} else if (token === "--default-mode" || token === "--mode") {
|
||||||
|
args.defaultMode = argv[++index];
|
||||||
|
} else if (token === "--force") {
|
||||||
|
args.force = true;
|
||||||
|
} else if (token === "--help" || token === "-h") {
|
||||||
|
args.help = true;
|
||||||
|
} else {
|
||||||
|
throw new Error(`Unknown argument: ${token}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return args;
|
||||||
|
}
|
||||||
|
|
||||||
|
function usage() {
|
||||||
|
return `Usage:
|
||||||
|
node scripts/generate_sgrobot_flow.mjs --trace <semantic-trace.json> --out <sgrobot-root> [--default-mode dry-run|execute] [--force]
|
||||||
|
`;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function main() {
|
||||||
|
const cli = parseCliArgs(process.argv.slice(2));
|
||||||
|
if (cli.help) {
|
||||||
|
console.log(usage());
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (!cli.trace || !cli.out) {
|
||||||
|
throw new Error(`${usage()}Both --trace and --out are required.`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const trace = JSON.parse(await readFile(cli.trace, "utf8"));
|
||||||
|
const result = generateSgRobotArtifacts(trace, { defaultMode: cli.defaultMode });
|
||||||
|
const written = await writeArtifacts(cli.out, result.files, { force: cli.force });
|
||||||
|
|
||||||
|
console.log(JSON.stringify({ ...result.summary, written }, null, 2));
|
||||||
|
}
|
||||||
|
|
||||||
|
const isCli = process.argv[1] && path.resolve(process.argv[1]) === fileURLToPath(import.meta.url);
|
||||||
|
if (isCli) {
|
||||||
|
main().catch((error) => {
|
||||||
|
console.error(error.message);
|
||||||
|
process.exitCode = 1;
|
||||||
|
});
|
||||||
|
}
|
||||||
50
scripts/validate_sgrobot_artifacts.mjs
Normal file
50
scripts/validate_sgrobot_artifacts.mjs
Normal file
@@ -0,0 +1,50 @@
|
|||||||
|
#!/usr/bin/env node
|
||||||
|
import { readFile } from "node:fs/promises";
|
||||||
|
import path from "node:path";
|
||||||
|
|
||||||
|
const root = path.resolve(process.argv[2] || ".");
|
||||||
|
const planId = process.argv[3];
|
||||||
|
const skillName = process.argv[4];
|
||||||
|
|
||||||
|
if (!planId || !skillName) {
|
||||||
|
console.error("Usage: node scripts/validate_sgrobot_artifacts.mjs <root> <plan-id> <skill-name>");
|
||||||
|
process.exit(1);
|
||||||
|
}
|
||||||
|
|
||||||
|
const planPath = path.join(root, "plan_templates", planId, "PlanTemplate.toml");
|
||||||
|
const skillPath = path.join(root, ".sgclaw-workspace", "skills", skillName, "SKILL.toml");
|
||||||
|
const scriptPath = path.join(root, ".sgclaw-workspace", "skills", skillName, "scripts", "execute_trace.js");
|
||||||
|
const tracePath = path.join(root, ".sgclaw-workspace", "skills", skillName, "trace.semantic.json");
|
||||||
|
|
||||||
|
const [plan, skill, script, traceText] = await Promise.all([
|
||||||
|
readFile(planPath, "utf8"),
|
||||||
|
readFile(skillPath, "utf8"),
|
||||||
|
readFile(scriptPath, "utf8"),
|
||||||
|
readFile(tracePath, "utf8"),
|
||||||
|
]);
|
||||||
|
|
||||||
|
const trace = JSON.parse(traceText);
|
||||||
|
const checks = [
|
||||||
|
["plan has [plan]", plan.includes("[plan]")],
|
||||||
|
["plan id matches", plan.includes(`id = "${planId}"`)],
|
||||||
|
["plan opens page via browser.open_url", plan.includes('skills = ["browser.open_url"]')],
|
||||||
|
["plan calls generated skill", plan.includes(`skills = ["${skillName}"]`)],
|
||||||
|
["plan saves result", plan.includes('skills = ["json.save_result"]')],
|
||||||
|
["write flow requires approval", plan.includes("requires_approval = true")],
|
||||||
|
["skill name matches", skill.includes(`name = "${skillName}"`)],
|
||||||
|
["skill uses sgBrowser action", skill.includes('kind = "sgbrowser_action"')],
|
||||||
|
["skill uses sgBrowser JS by domain", skill.includes('command = "sgBrowserExcuteJsCodeByDomain"')],
|
||||||
|
["skill references execute script", skill.includes('script_path = "scripts/execute_trace.js"')],
|
||||||
|
["runtime script contains trace", script.includes(`"id": "${trace.id}"`)],
|
||||||
|
["runtime script returns structured JSON", script.includes("action_results")],
|
||||||
|
["trace has realistic steps", Array.isArray(trace.steps) && trace.steps.length >= 4],
|
||||||
|
];
|
||||||
|
|
||||||
|
const failed = checks.filter(([, ok]) => !ok);
|
||||||
|
for (const [name, ok] of checks) {
|
||||||
|
console.log(`${ok ? "ok" : "not ok"} - ${name}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (failed.length > 0) {
|
||||||
|
process.exitCode = 1;
|
||||||
|
}
|
||||||
28
tests/customer_callback_demo.test.mjs
Normal file
28
tests/customer_callback_demo.test.mjs
Normal file
@@ -0,0 +1,28 @@
|
|||||||
|
import { test } from "node:test";
|
||||||
|
import assert from "node:assert/strict";
|
||||||
|
import { readFile } from "node:fs/promises";
|
||||||
|
import { generateSgRobotArtifacts } from "../scripts/generate_sgrobot_flow.mjs";
|
||||||
|
|
||||||
|
test("recorded customer callback flow generates SGrobot-compliant artifacts", async () => {
|
||||||
|
const trace = JSON.parse(await readFile("examples/recordings/customer-callback-trace.json", "utf8"));
|
||||||
|
const result = generateSgRobotArtifacts(trace, { defaultMode: "execute" });
|
||||||
|
|
||||||
|
const plan = result.files.get("plan_templates/rrweb-customer-callback-draft/PlanTemplate.toml");
|
||||||
|
const skill = result.files.get(".sgclaw-workspace/skills/rrweb-customer-callback-draft-execute/SKILL.toml");
|
||||||
|
const script = result.files.get(".sgclaw-workspace/skills/rrweb-customer-callback-draft-execute/scripts/execute_trace.js");
|
||||||
|
const evidence = result.files.get(".sgclaw-workspace/skills/rrweb-customer-callback-draft-execute/trace.semantic.json");
|
||||||
|
|
||||||
|
assert.equal(result.summary.planId, "rrweb-customer-callback-draft");
|
||||||
|
assert.equal(result.summary.skillName, "rrweb-customer-callback-draft-execute");
|
||||||
|
assert.equal(result.summary.requiresApproval, true);
|
||||||
|
assert.match(plan, /skills = \["browser\.open_url"\]/);
|
||||||
|
assert.match(plan, /skills = \["rrweb-customer-callback-draft-execute"\]/);
|
||||||
|
assert.match(plan, /requires_approval = true/);
|
||||||
|
assert.match(skill, /kind = "sgbrowser_action"/);
|
||||||
|
assert.match(skill, /command = "sgBrowserExcuteJsCodeByDomain"/);
|
||||||
|
assert.match(skill, /script_path = "scripts\/execute_trace.js"/);
|
||||||
|
assert.match(script, /"type": "select"/);
|
||||||
|
assert.match(script, /"type": "fill"/);
|
||||||
|
assert.match(script, /"risk": "write"/);
|
||||||
|
assert.equal(JSON.parse(evidence).steps.length, 4);
|
||||||
|
});
|
||||||
56
tests/generate_sgrobot_flow.test.mjs
Normal file
56
tests/generate_sgrobot_flow.test.mjs
Normal file
@@ -0,0 +1,56 @@
|
|||||||
|
import { test } from "node:test";
|
||||||
|
import assert from "node:assert/strict";
|
||||||
|
import { mkdtemp, readFile, rm } from "node:fs/promises";
|
||||||
|
import { tmpdir } from "node:os";
|
||||||
|
import path from "node:path";
|
||||||
|
import { generateSgRobotArtifacts, writeArtifacts } from "../scripts/generate_sgrobot_flow.mjs";
|
||||||
|
|
||||||
|
const trace = {
|
||||||
|
id: "expense-approval",
|
||||||
|
name: "费用审批",
|
||||||
|
description: "打开审批页,填写意见并点击同意。",
|
||||||
|
startUrl: "https://oa.example.test/expense/123",
|
||||||
|
steps: [
|
||||||
|
{ type: "assertText", text: "费用审批", label: "确认在审批页" },
|
||||||
|
{ type: "fill", selector: "textarea[name='comment']", value: "同意", label: "填写审批意见" },
|
||||||
|
{ type: "click", selector: "button[data-action='approve']", label: "点击同意", risk: "write" },
|
||||||
|
],
|
||||||
|
};
|
||||||
|
|
||||||
|
test("generates SGrobot plan and runtime skill artifacts", () => {
|
||||||
|
const result = generateSgRobotArtifacts(trace, { defaultMode: "execute" });
|
||||||
|
|
||||||
|
assert.equal(result.summary.planId, "rrweb-expense-approval");
|
||||||
|
assert.equal(result.summary.skillName, "rrweb-expense-approval-execute");
|
||||||
|
assert.equal(result.summary.requiresApproval, true);
|
||||||
|
|
||||||
|
const plan = result.files.get("plan_templates/rrweb-expense-approval/PlanTemplate.toml");
|
||||||
|
const skill = result.files.get(".sgclaw-workspace/skills/rrweb-expense-approval-execute/SKILL.toml");
|
||||||
|
const script = result.files.get(".sgclaw-workspace/skills/rrweb-expense-approval-execute/scripts/execute_trace.js");
|
||||||
|
|
||||||
|
assert.equal(plan.includes('skills = ["rrweb-expense-approval-execute"]'), true);
|
||||||
|
assert.equal(plan.includes("requires_approval = true"), true);
|
||||||
|
assert.equal(skill.includes('command = "sgBrowserExcuteJsCodeByDomain"'), true);
|
||||||
|
assert.equal(skill.includes('expected_domain = "oa.example.test"'), true);
|
||||||
|
assert.equal(script.includes('"default_mode": "execute"'), true);
|
||||||
|
assert.equal(script.includes('replace(/[\\s_-]+/g, "")'), true);
|
||||||
|
assert.equal(script.includes('replace(/[\\\\s_-]+/g, "")'), false);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("writes generated artifacts under a SGrobot repository root", async () => {
|
||||||
|
const root = await mkdtemp(path.join(tmpdir(), "rrweb-sgrobot-"));
|
||||||
|
try {
|
||||||
|
const result = generateSgRobotArtifacts(trace);
|
||||||
|
await writeArtifacts(root, result.files);
|
||||||
|
|
||||||
|
const plan = await readFile(path.join(root, "plan_templates/rrweb-expense-approval/PlanTemplate.toml"), "utf8");
|
||||||
|
const skill = await readFile(path.join(root, ".sgclaw-workspace/skills/rrweb-expense-approval-execute/SKILL.toml"), "utf8");
|
||||||
|
const evidence = await readFile(path.join(root, ".sgclaw-workspace/skills/rrweb-expense-approval-execute/trace.semantic.json"), "utf8");
|
||||||
|
|
||||||
|
assert.match(plan, /skills = \["browser\.open_url"\]/);
|
||||||
|
assert.match(skill, /script_path = "scripts\/execute_trace.js"/);
|
||||||
|
assert.equal(JSON.parse(evidence).id, "expense-approval");
|
||||||
|
} finally {
|
||||||
|
await rm(root, { recursive: true, force: true });
|
||||||
|
}
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user