velaclaw-dev 0.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (63) hide show
  1. package/.gitignore +14 -0
  2. package/ARCHITECTURE.md +143 -0
  3. package/README.dev.md +208 -0
  4. package/README.local-before-remote-sync.md +224 -0
  5. package/README.md +211 -0
  6. package/README.public.md +115 -0
  7. package/RELEASING.md +162 -0
  8. package/TESTING.md +195 -0
  9. package/dist/cli.js +213 -0
  10. package/dist/data.js +2988 -0
  11. package/dist/server.js +1020 -0
  12. package/dist/ui.js +1486 -0
  13. package/members/LAUNCH_CHECKLIST.md +13 -0
  14. package/members/README.md +17 -0
  15. package/members/member-template/README.md +9 -0
  16. package/members/member-template/private-docs/README.md +3 -0
  17. package/members/member-template/private-memory/README.md +3 -0
  18. package/members/member-template/private-skills/README.md +4 -0
  19. package/members/member-template/private-tools/README.md +4 -0
  20. package/members/member-template/runtime/config/README.md +3 -0
  21. package/members/member-template/runtime/config/local-plugins/member-quota-guard/index.js +123 -0
  22. package/members/member-template/runtime/config/local-plugins/member-quota-guard/openclaw.plugin.json +19 -0
  23. package/members/member-template/runtime/config/local-plugins/member-quota-guard/package.json +10 -0
  24. package/members/member-template/runtime/config/local-plugins/member-runtime-upgrader/index.js +97 -0
  25. package/members/member-template/runtime/config/local-plugins/member-runtime-upgrader/openclaw.plugin.json +21 -0
  26. package/members/member-template/runtime/config/local-plugins/member-runtime-upgrader/package.json +10 -0
  27. package/members/member-template/runtime/config/local-plugins/shared-asset-injector/index.js +548 -0
  28. package/members/member-template/runtime/config/local-plugins/shared-asset-injector/openclaw.plugin.json +33 -0
  29. package/members/member-template/runtime/config/local-plugins/shared-asset-injector/package.json +10 -0
  30. package/members/member-template/runtime/config/openclaw.json +104 -0
  31. package/members/member-template/runtime/docker-compose.yml +53 -0
  32. package/members/member-template/runtime/logs/README.md +3 -0
  33. package/members/member-template/runtime/secrets/.gitkeep +1 -0
  34. package/members/member-template/runtime/secrets/README.md +3 -0
  35. package/members/member-template/runtime/workspace/.gitkeep +1 -0
  36. package/members/member-template/runtime/workspace/README.md +3 -0
  37. package/package.json +57 -0
  38. package/pic/banner.jpg +0 -0
  39. package/provision-member.md +87 -0
  40. package/scripts/shared-asset-stack-test.mjs +369 -0
  41. package/scripts/shared-skill-combo-test.mjs +282 -0
  42. package/scripts/team-load-test.mjs +358 -0
  43. package/scripts/verify-install.mjs +44 -0
  44. package/services/litellm/config.yaml +35 -0
  45. package/services/litellm/docker-compose.yml +36 -0
  46. package/services/litellm/litellm.env.example +13 -0
  47. package/shared-snapshots/README.md +16 -0
  48. package/shared-snapshots/docs/README.md +3 -0
  49. package/shared-snapshots/memory/README.md +3 -0
  50. package/shared-snapshots/skills/README.md +3 -0
  51. package/shared-snapshots/tools/README.md +4 -0
  52. package/shared-snapshots/workflows/README.md +3 -0
  53. package/team-assets/README.md +11 -0
  54. package/team-assets/policies/README.md +7 -0
  55. package/team-assets/policies/asset-visibility.md +24 -0
  56. package/team-assets/policies/high-risk-action-approval.md +18 -0
  57. package/team-assets/policies/promotion-rules.md +25 -0
  58. package/team-assets/policies/tool-binding-rules.md +26 -0
  59. package/team-assets/shared-docs/README.md +3 -0
  60. package/team-assets/shared-memory/README.md +8 -0
  61. package/team-assets/shared-skills/README.md +8 -0
  62. package/team-assets/shared-tools/README.md +8 -0
  63. package/team-assets/shared-workflows/README.md +9 -0
@@ -0,0 +1,282 @@
1
+ import { execFile } from "node:child_process";
2
+ import fs from "node:fs/promises";
3
+ import path from "node:path";
4
+ import { promisify } from "node:util";
5
+ import { fileURLToPath } from "node:url";
6
+ import {
7
+ acceptInvitationByCode,
8
+ createInvitationForTeam,
9
+ createTeam,
10
+ createTeamAssetProposal,
11
+ getTeamMemberWorkspaceById,
12
+ getTeamAssetServerManifestBySlug,
13
+ runMemberRuntimeActionForTeam
14
+ } from "../dist/data.js";
15
+
16
+ const execFileAsync = promisify(execFile);
17
+ const ROOT = process.env.VELACLAW_ROOT
18
+ ? path.resolve(process.env.VELACLAW_ROOT)
19
+ : path.resolve(path.dirname(fileURLToPath(import.meta.url)), "..");
20
+ const TMP_ROOT = "/tmp";
21
+ const AWESOME_REPO = "https://github.com/VoltAgent/awesome-openclaw-skills";
22
+ const OFFICIAL_SKILLS_REPO = "https://github.com/openclaw/skills";
23
+ const AWESOME_DIR = path.join(TMP_ROOT, "awesome-openclaw-skills");
24
+ const SKILLS_DIR = path.join(TMP_ROOT, "openclaw-skills");
25
+
26
+ const SELECTED_SKILLS = [
27
+ {
28
+ slug: "second-level-thinking",
29
+ sourcePath: "skills/0xezreal/second-level-thinking/SKILL.md",
30
+ sourceUrl: `${OFFICIAL_SKILLS_REPO}/tree/main/skills/0xezreal/second-level-thinking`
31
+ },
32
+ {
33
+ slug: "merge-drafts",
34
+ sourcePath: "skills/0xcjl/merge-drafts/SKILL.md",
35
+ sourceUrl: `${OFFICIAL_SKILLS_REPO}/tree/main/skills/0xcjl/merge-drafts`
36
+ },
37
+ {
38
+ slug: "humanizer-cn",
39
+ sourcePath: "skills/0xcjl/humanizer-cn/SKILL.md",
40
+ sourceUrl: `${OFFICIAL_SKILLS_REPO}/tree/main/skills/0xcjl/humanizer-cn`
41
+ }
42
+ ];
43
+
44
+ const COMBO_PROMPT = [
45
+ "请合并下面两份关于一家电力基础设施公司的投资中文草稿,使用 second-level thinking 质疑市场共识,并对最终中文稿做去除 AI 痕迹的人性化处理。",
46
+ "",
47
+ "草稿A:市场普遍认为该公司将直接受益于 AI 数据中心扩张,因此未来三年收入会高速增长,估值仍然合理。",
48
+ "",
49
+ "草稿B:这个投资逻辑也许过于线性,因为电力审批、资本开支和供应链可能拖慢兑现速度,但公司仍有区域垄断优势。",
50
+ "",
51
+ "输出一版合并后的最终中文稿,不要只列提纲。"
52
+ ].join("\n");
53
+
54
+ async function run(command, args, options = {}) {
55
+ const result = await execFileAsync(command, args, {
56
+ cwd: options.cwd || ROOT,
57
+ maxBuffer: 1024 * 1024 * 16
58
+ });
59
+ return {
60
+ stdout: String(result.stdout || ""),
61
+ stderr: String(result.stderr || "")
62
+ };
63
+ }
64
+
65
+ async function ensureRepo(dirPath, remoteUrl) {
66
+ try {
67
+ await fs.access(dirPath);
68
+ } catch {
69
+ await run("git", ["clone", "--depth", "1", remoteUrl, dirPath], { cwd: TMP_ROOT });
70
+ return;
71
+ }
72
+
73
+ await run("git", ["-C", dirPath, "fetch", "--depth", "1", "origin", "main"]);
74
+ await run("git", ["-C", dirPath, "reset", "--hard", "origin/main"]);
75
+ }
76
+
77
+ function parseSkillName(content, fallback) {
78
+ const match = content.match(/^name:\s*(.+)$/m);
79
+ return match?.[1]?.trim() || fallback;
80
+ }
81
+
82
+ async function ensureReadTool(configPath) {
83
+ const raw = JSON.parse(await fs.readFile(configPath, "utf8"));
84
+ raw.tools ??= {};
85
+ raw.tools.alsoAllow = Array.from(new Set([...(Array.isArray(raw.tools.alsoAllow) ? raw.tools.alsoAllow : []), "read"]));
86
+ raw.tools.fs = {
87
+ ...(typeof raw.tools.fs === "object" && raw.tools.fs ? raw.tools.fs : {}),
88
+ workspaceOnly: true
89
+ };
90
+ await fs.writeFile(configPath, `${JSON.stringify(raw, null, 2)}\n`, "utf8");
91
+ }
92
+
93
+ function composeProjectNameForMember(teamSlug, memberId) {
94
+ const base = `member-${teamSlug}-${memberId}`
95
+ .toLowerCase()
96
+ .replace(/[^a-z0-9_-]+/g, "-")
97
+ .replace(/^-+|-+$/g, "");
98
+ if (base.length <= 48) {
99
+ return base;
100
+ }
101
+ return `${base.slice(0, 39)}-${memberId.slice(-8)}`;
102
+ }
103
+
104
+ async function waitForRuntimeHealthy(teamSlug, memberId, timeoutMs = 90000) {
105
+ const started = Date.now();
106
+ while (Date.now() - started < timeoutMs) {
107
+ const raw = await fs.readFile(path.join(ROOT, "state", "team.json"), "utf8").catch(() => null);
108
+ if (!raw) {
109
+ await new Promise((resolve) => setTimeout(resolve, 2000));
110
+ continue;
111
+ }
112
+ const { stdout } = await run("curl", ["-sS", `http://127.0.0.1:4318/api/teams/${teamSlug}/members/${memberId}/runtime`]);
113
+ const payload = JSON.parse(stdout);
114
+ if (payload?.runtime?.container?.health === "healthy") {
115
+ return payload.runtime;
116
+ }
117
+ await new Promise((resolve) => setTimeout(resolve, 2000));
118
+ }
119
+ throw new Error(`runtime did not become healthy for ${memberId}`);
120
+ }
121
+
122
+ async function findSessionId(sessionsPath, sessionKeySuffix, timeoutMs = 30000) {
123
+ const started = Date.now();
124
+ while (Date.now() - started < timeoutMs) {
125
+ const raw = JSON.parse(await fs.readFile(sessionsPath, "utf8"));
126
+ for (const [key, value] of Object.entries(raw)) {
127
+ if (key.includes(sessionKeySuffix)) {
128
+ return value.sessionId;
129
+ }
130
+ }
131
+ await new Promise((resolve) => setTimeout(resolve, 1000));
132
+ }
133
+ throw new Error(`session not found for ${sessionKeySuffix}`);
134
+ }
135
+
136
+ async function main() {
137
+ await fs.mkdir(path.join(ROOT, "artifacts"), { recursive: true });
138
+ await ensureRepo(AWESOME_DIR, AWESOME_REPO);
139
+ await ensureRepo(SKILLS_DIR, OFFICIAL_SKILLS_REPO);
140
+
141
+ const awesomeFiles = await fs.readdir(path.join(AWESOME_DIR, "categories"));
142
+ const awesomeCorpus = [
143
+ await fs.readFile(path.join(AWESOME_DIR, "README.md"), "utf8"),
144
+ ...(await Promise.all(awesomeFiles.map((file) => fs.readFile(path.join(AWESOME_DIR, "categories", file), "utf8"))))
145
+ ].join("\n");
146
+ const teamSuffix = Date.now();
147
+ const team = await createTeam({
148
+ name: `Skill Combo Test ${teamSuffix}`,
149
+ slug: `skill-combo-test-${teamSuffix}`,
150
+ description: "Shared skill combination regression test",
151
+ managerLabel: "Test Manager"
152
+ });
153
+
154
+ const importedSkills = [];
155
+ for (const skill of SELECTED_SKILLS) {
156
+ const content = await fs.readFile(path.join(SKILLS_DIR, skill.sourcePath), "utf8");
157
+ const title = parseSkillName(content, skill.slug);
158
+ const result = await createTeamAssetProposal({
159
+ teamSlug: team.slug,
160
+ category: "shared-skills",
161
+ title,
162
+ content,
163
+ submittedByLabel: "manager",
164
+ sourceZone: "collab"
165
+ });
166
+ importedSkills.push({
167
+ slug: skill.slug,
168
+ title,
169
+ assetId: result.asset.id,
170
+ status: result.asset.status,
171
+ sourceUrl: skill.sourceUrl,
172
+ listedInAwesome: awesomeCorpus.includes(skill.slug)
173
+ });
174
+ }
175
+
176
+ const invitation = await createInvitationForTeam(team.slug, {
177
+ inviteeLabel: "Skill Combo Probe",
178
+ memberId: "skill-combo-probe@team.local",
179
+ memberEmail: "skill-combo-probe@team.local",
180
+ role: "member",
181
+ createdBy: "shared-skill-combo-test"
182
+ });
183
+ const accepted = await acceptInvitationByCode(invitation.code, {
184
+ identityName: "Skill Combo Probe"
185
+ });
186
+
187
+ const memberId = accepted.policy.memberId;
188
+ const memberRoot = path.join(ROOT, "members", team.slug, memberId, "runtime");
189
+ const configPath = path.join(memberRoot, "config", "openclaw.json");
190
+ await ensureReadTool(configPath);
191
+ await run("sudo", ["-n", "docker", "compose", "-p", composeProjectNameForMember(team.slug, memberId), "-f", path.join(memberRoot, "docker-compose.yml"), "up", "-d"], {
192
+ cwd: memberRoot
193
+ });
194
+
195
+ const runtime = await waitForRuntimeHealthy(team.slug, memberId);
196
+ const workspace = await getTeamMemberWorkspaceById(team.slug, memberId);
197
+ const manifest = await getTeamAssetServerManifestBySlug(team.slug);
198
+ const workspaceSkillFiles = (await fs.readdir(path.join(memberRoot, "workspace", "skills"))).sort();
199
+
200
+ const containerName = runtime.containerName;
201
+ const sessionKeySuffix = "combo-skill-run";
202
+ const { stdout: agentOutput } = await run(
203
+ "sudo",
204
+ [
205
+ "-n",
206
+ "docker",
207
+ "exec",
208
+ containerName,
209
+ "sh",
210
+ "-lc",
211
+ `cd /app && openclaw agent --session-id ${sessionKeySuffix} --message ${JSON.stringify(COMBO_PROMPT)} --json`
212
+ ]
213
+ );
214
+ const agentResult = JSON.parse(agentOutput);
215
+
216
+ const sessionsPath = path.join(memberRoot, "config", "agents", "main", "sessions", "sessions.json");
217
+ const sessionId = await findSessionId(sessionsPath, sessionKeySuffix);
218
+ const sessions = JSON.parse(await fs.readFile(sessionsPath, "utf8"));
219
+ const sessionEntry = sessions[`agent:main:explicit:${sessionKeySuffix}`];
220
+ const jsonlPath = path.join(memberRoot, "config", "agents", "main", "sessions", `${sessionId}.jsonl`);
221
+ const jsonlLines = (await fs.readFile(jsonlPath, "utf8"))
222
+ .trim()
223
+ .split("\n")
224
+ .filter(Boolean)
225
+ .map((line) => JSON.parse(line));
226
+
227
+ const assistantToolCalls = jsonlLines
228
+ .filter((entry) => entry.type === "message" && entry.message?.role === "assistant")
229
+ .flatMap((entry) => entry.message.content || [])
230
+ .filter((content) => content.type === "toolCall")
231
+ .map((content) => ({
232
+ name: content.name,
233
+ arguments: content.arguments
234
+ }));
235
+
236
+ const finalAssistantText = agentResult?.result?.payloads?.map((entry) => entry.text).filter(Boolean).join("\n\n") || "";
237
+ const skillsSnapshot = sessionEntry?.skillsSnapshot || {};
238
+ const loadedSkills = (skillsSnapshot.skills || []).map((entry) => entry.name);
239
+ const loadedSharedSkills = loadedSkills.filter((name) =>
240
+ ["humanizer-cn", "merge-drafts", "second-level-thinking"].includes(name)
241
+ );
242
+
243
+ const report = {
244
+ createdAt: new Date().toISOString(),
245
+ sourceRepos: {
246
+ awesome: AWESOME_REPO,
247
+ officialSkills: OFFICIAL_SKILLS_REPO
248
+ },
249
+ team: team,
250
+ member: {
251
+ memberId,
252
+ runtime
253
+ },
254
+ importedSkills,
255
+ workspaceSkillFiles,
256
+ manifestItems: manifest.items.map((item) => ({ title: item.title, kind: item.kind })),
257
+ session: {
258
+ sessionId,
259
+ sessionKey: `agent:main:explicit:${sessionKeySuffix}`,
260
+ loadedSkills,
261
+ loadedSharedSkills,
262
+ assistantToolCalls,
263
+ finalAssistantText
264
+ },
265
+ checks: {
266
+ importedSkillsPublished: importedSkills.every((skill) => skill.status === "published"),
267
+ workspaceHasThreeSharedSkills: workspaceSkillFiles.filter((name) => name.startsWith("team-shared-")).length >= 3,
268
+ skillsSnapshotIncludesSharedSkills: loadedSharedSkills.length === 3,
269
+ usesReadToolForSharedSkills: assistantToolCalls.some((call) => call.name === "read"),
270
+ finalAssistantTextPresent: Boolean(finalAssistantText.trim())
271
+ }
272
+ };
273
+
274
+ const artifactPath = path.join(ROOT, "artifacts", `shared-skill-combo-test-${teamSuffix}.json`);
275
+ await fs.writeFile(artifactPath, `${JSON.stringify(report, null, 2)}\n`, "utf8");
276
+ console.log(JSON.stringify({ artifactPath, report }, null, 2));
277
+ }
278
+
279
+ main().catch((error) => {
280
+ console.error(error instanceof Error ? error.stack || error.message : String(error));
281
+ process.exitCode = 1;
282
+ });
@@ -0,0 +1,358 @@
1
+ import { execFile } from "node:child_process";
2
+ import fs from "node:fs/promises";
3
+ import path from "node:path";
4
+ import { performance } from "node:perf_hooks";
5
+ import { promisify } from "node:util";
6
+ import { fileURLToPath } from "node:url";
7
+
8
+ const execFileAsync = promisify(execFile);
9
+
10
+ const ROOT = process.env.VELACLAW_ROOT
11
+ ? path.resolve(process.env.VELACLAW_ROOT)
12
+ : path.resolve(path.dirname(fileURLToPath(import.meta.url)), "..");
13
+ const BASE_URL = process.env.VELACLAW_BASE_URL || "http://127.0.0.1:4318";
14
+ const TEAM_SIZE = Number(process.env.TEAM_SIZE || "100");
15
+ const CONCURRENCY = Number(process.env.CONCURRENCY || "10");
16
+ const RUN_STAMP = new Date().toISOString().replace(/[-:.TZ]/g, "").slice(0, 14);
17
+ const TEAM_SLUG = process.env.TEAM_SLUG || `loadtest-${TEAM_SIZE}-${RUN_STAMP}`;
18
+ const TEAM_NAME = process.env.TEAM_NAME || `Load Test ${RUN_STAMP}`;
19
+ const REPORT_PATH = path.join(ROOT, "artifacts", `${TEAM_SLUG}.json`);
20
+
21
+ function assert(condition, message) {
22
+ if (!condition) {
23
+ throw new Error(message);
24
+ }
25
+ }
26
+
27
+ function memberId(index) {
28
+ return `lt-${String(index).padStart(3, "0")}`;
29
+ }
30
+
31
+ function fakeUserId(index) {
32
+ return `9${String(500000000 + index).padStart(9, "0")}`;
33
+ }
34
+
35
+ function nowIso() {
36
+ return new Date().toISOString();
37
+ }
38
+
39
+ async function parseBody(response) {
40
+ const text = await response.text();
41
+ const type = response.headers.get("content-type") || "";
42
+ if (type.includes("application/json")) {
43
+ try {
44
+ return JSON.parse(text);
45
+ } catch {
46
+ return text;
47
+ }
48
+ }
49
+ return text;
50
+ }
51
+
52
+ async function request(pathname, { method = "GET", headers = {}, body } = {}) {
53
+ const response = await fetch(`${BASE_URL}${pathname}`, {
54
+ method,
55
+ headers,
56
+ body
57
+ });
58
+ const parsed = await parseBody(response);
59
+ if (!response.ok) {
60
+ throw new Error(`${method} ${pathname} failed: ${response.status} ${typeof parsed === "string" ? parsed : JSON.stringify(parsed)}`);
61
+ }
62
+ return parsed;
63
+ }
64
+
65
+ async function postJson(pathname, payload) {
66
+ return request(pathname, {
67
+ method: "POST",
68
+ headers: { "content-type": "application/json" },
69
+ body: JSON.stringify(payload)
70
+ });
71
+ }
72
+
73
+ async function postForm(pathname, payload) {
74
+ return request(pathname, {
75
+ method: "POST",
76
+ headers: { "content-type": "application/x-www-form-urlencoded" },
77
+ body: new URLSearchParams(payload).toString()
78
+ });
79
+ }
80
+
81
+ async function mapLimit(items, limit, worker) {
82
+ const results = new Array(items.length);
83
+ let cursor = 0;
84
+
85
+ async function run() {
86
+ while (true) {
87
+ const index = cursor;
88
+ cursor += 1;
89
+ if (index >= items.length) return;
90
+ try {
91
+ results[index] = await worker(items[index], index);
92
+ } catch (error) {
93
+ results[index] = {
94
+ ok: false,
95
+ error: error instanceof Error ? error.message : String(error)
96
+ };
97
+ }
98
+ }
99
+ }
100
+
101
+ await Promise.all(Array.from({ length: Math.min(limit, items.length) }, () => run()));
102
+ return results;
103
+ }
104
+
105
+ async function writeReport(report) {
106
+ await fs.mkdir(path.dirname(REPORT_PATH), { recursive: true });
107
+ await fs.writeFile(REPORT_PATH, `${JSON.stringify(report, null, 2)}\n`);
108
+ }
109
+
110
+ async function runDockerReadChecks(teamSlug) {
111
+ const teamCurrent = path.join(ROOT, "teams", teamSlug, "assets", "current");
112
+ const member1PrivateSkills = path.join(ROOT, "members", teamSlug, "lt-001", "private-skills");
113
+ const member2PrivateSkills = path.join(ROOT, "members", teamSlug, "lt-002", "private-skills");
114
+
115
+ const readSharedAndWritePrivate = await execFileAsync(
116
+ "sudo",
117
+ [
118
+ "-n",
119
+ "docker",
120
+ "run",
121
+ "--rm",
122
+ "-v",
123
+ `${teamCurrent}:/mnt/team-shared:ro`,
124
+ "-v",
125
+ `${member1PrivateSkills}:/mnt/private-skills:rw`,
126
+ "postgres:16-alpine",
127
+ "sh",
128
+ "-lc",
129
+ [
130
+ "set -eu",
131
+ "ls -1 /mnt/team-shared/shared-skills",
132
+ "grep -q 'Shared Skill Alpha' /mnt/team-shared/shared-skills/shared-skill-alpha.md",
133
+ "if touch /mnt/team-shared/shared-skills/should-not-write 2>/dev/null; then echo shared_write=unexpected; exit 21; else echo shared_write=blocked; fi",
134
+ "printf 'runtime-private-check\\n' > /mnt/private-skills/runtime-private-check.txt",
135
+ "test -f /mnt/private-skills/runtime-private-check.txt",
136
+ "echo private_write=ok"
137
+ ].join("; ")
138
+ ],
139
+ { maxBuffer: 1024 * 1024 * 4 }
140
+ );
141
+
142
+ const isolatePrivate = await execFileAsync(
143
+ "sudo",
144
+ [
145
+ "-n",
146
+ "docker",
147
+ "run",
148
+ "--rm",
149
+ "-v",
150
+ `${member2PrivateSkills}:/mnt/private-skills:rw`,
151
+ "postgres:16-alpine",
152
+ "sh",
153
+ "-lc",
154
+ [
155
+ "set -eu",
156
+ "test -f /mnt/private-skills/lt-002-private-skill.md",
157
+ "if test -f /mnt/private-skills/lt-001-private-skill.md; then echo leak; exit 22; else echo isolated=ok; fi"
158
+ ].join("; ")
159
+ ],
160
+ { maxBuffer: 1024 * 1024 * 4 }
161
+ );
162
+
163
+ return {
164
+ sharedReadPrivateWrite: readSharedAndWritePrivate.stdout.trim(),
165
+ privateIsolation: isolatePrivate.stdout.trim()
166
+ };
167
+ }
168
+
169
+ async function main() {
170
+ const report = {
171
+ startedAt: nowIso(),
172
+ baseUrl: BASE_URL,
173
+ team: {
174
+ slug: TEAM_SLUG,
175
+ name: TEAM_NAME,
176
+ size: TEAM_SIZE
177
+ },
178
+ timingsMs: {},
179
+ inviteStats: {},
180
+ acceptStats: {},
181
+ assetChecks: {},
182
+ runtimeChecks: {},
183
+ notes: []
184
+ };
185
+
186
+ const createTeamStarted = performance.now();
187
+ const createdTeam = await postJson("/api/teams", {
188
+ name: TEAM_NAME,
189
+ slug: TEAM_SLUG,
190
+ description: `Automated ${TEAM_SIZE}-member load test`,
191
+ managerLabel: "Load Test Manager"
192
+ });
193
+ report.timingsMs.createTeam = Math.round(performance.now() - createTeamStarted);
194
+ report.team.profile = createdTeam.profile;
195
+
196
+ const inviteInputs = Array.from({ length: TEAM_SIZE }, (_, offset) => {
197
+ const index = offset + 1;
198
+ return {
199
+ memberId: memberId(index),
200
+ inviteeLabel: `Load Test Member ${index}`,
201
+ role: index === 1 ? "publisher" : index === 2 ? "member" : "member",
202
+ note: "automated-load-test",
203
+ dailyMessages: 80 + index,
204
+ monthlyMessages: 2000 + index,
205
+ maxSubagents: index === 1 ? 2 : 1,
206
+ maxThinking: index === 1 ? "medium" : "low"
207
+ };
208
+ });
209
+
210
+ const inviteStarted = performance.now();
211
+ const invitationResults = await mapLimit(inviteInputs, CONCURRENCY, async (payload) => {
212
+ const result = await postJson(`/api/teams/${TEAM_SLUG}/invitations`, payload);
213
+ return {
214
+ ok: true,
215
+ memberId: payload.memberId,
216
+ role: payload.role,
217
+ code: result.invitation.code
218
+ };
219
+ });
220
+ report.timingsMs.createInvitations = Math.round(performance.now() - inviteStarted);
221
+ report.inviteStats = {
222
+ total: invitationResults.length,
223
+ ok: invitationResults.filter((entry) => entry?.ok).length,
224
+ failed: invitationResults.filter((entry) => !entry?.ok).slice(0, 5)
225
+ };
226
+ assert(report.inviteStats.ok === TEAM_SIZE, `expected ${TEAM_SIZE} invitations, got ${report.inviteStats.ok}`);
227
+
228
+ const acceptStarted = performance.now();
229
+ const acceptResults = await mapLimit(invitationResults, CONCURRENCY, async (entry, index) => {
230
+ const payload = {
231
+ telegramUserId: fakeUserId(index + 1),
232
+ identityName: `LoadTest-${entry.memberId}`
233
+ };
234
+ const html = await postForm(`/invite/${entry.code}/accept`, payload);
235
+ return {
236
+ ok: typeof html === "string" && html.includes("is now staged"),
237
+ memberId: entry.memberId
238
+ };
239
+ });
240
+ report.timingsMs.acceptInvitations = Math.round(performance.now() - acceptStarted);
241
+ report.acceptStats = {
242
+ total: acceptResults.length,
243
+ ok: acceptResults.filter((entry) => entry?.ok).length,
244
+ failed: acceptResults.filter((entry) => !entry?.ok).slice(0, 5)
245
+ };
246
+ assert(report.acceptStats.ok === TEAM_SIZE, `expected ${TEAM_SIZE} accepted members, got ${report.acceptStats.ok}`);
247
+
248
+ const overviewStarted = performance.now();
249
+ const overview = await request(`/api/teams/${TEAM_SLUG}`);
250
+ report.timingsMs.fetchOverview = Math.round(performance.now() - overviewStarted);
251
+ report.overviewSummary = overview.summary;
252
+ assert(overview.summary.memberCount === TEAM_SIZE, `team overview memberCount=${overview.summary.memberCount}`);
253
+
254
+ const publishedPortSet = new Set(
255
+ overview.members
256
+ .map((member) => member.runtime?.publishedPort)
257
+ .filter((value) => Number.isInteger(value))
258
+ );
259
+ report.runtimeChecks.portUniqueness = {
260
+ expected: TEAM_SIZE,
261
+ actualUnique: publishedPortSet.size
262
+ };
263
+ assert(publishedPortSet.size === TEAM_SIZE, `expected ${TEAM_SIZE} unique ports, got ${publishedPortSet.size}`);
264
+
265
+ const managerAsset = await postJson(`/api/teams/${TEAM_SLUG}/assets/proposals`, {
266
+ category: "shared-skills",
267
+ title: "Shared Skill Alpha",
268
+ content: "# Shared Skill Alpha\n\nUse this shared skill for load test verification.\n",
269
+ submittedByLabel: "manager",
270
+ note: "manager direct publish"
271
+ });
272
+ const publisherAsset = await postJson(`/api/teams/${TEAM_SLUG}/assets/proposals`, {
273
+ category: "shared-skills",
274
+ title: "Shared Skill Beta",
275
+ content: "# Shared Skill Beta\n\nPublished by a trusted publisher member.\n",
276
+ submittedByMemberId: "lt-001",
277
+ note: "publisher direct publish"
278
+ });
279
+ const memberAsset = await postJson(`/api/teams/${TEAM_SLUG}/assets/proposals`, {
280
+ category: "shared-skills",
281
+ title: "Shared Skill Gamma",
282
+ content: "# Shared Skill Gamma\n\nSubmitted by a normal member and should require approval.\n",
283
+ submittedByMemberId: "lt-002",
284
+ note: "member requires approval"
285
+ });
286
+ assert(memberAsset.asset.status === "pending_approval", `expected pending approval asset, got ${memberAsset.asset.status}`);
287
+ const approvedMemberAsset = await postJson(`/api/teams/${TEAM_SLUG}/assets/${memberAsset.asset.id}/approve`, {
288
+ approvedByMemberId: "manager"
289
+ });
290
+
291
+ const sharedSkillDir = path.join(ROOT, "teams", TEAM_SLUG, "assets", "current", "shared-skills");
292
+ const sharedSkillFiles = (await fs.readdir(sharedSkillDir)).sort();
293
+ report.assetChecks = {
294
+ managerAssetStatus: managerAsset.asset.status,
295
+ publisherAssetStatus: publisherAsset.asset.status,
296
+ memberAssetInitialStatus: memberAsset.asset.status,
297
+ memberAssetAfterApprove: approvedMemberAsset.asset.status,
298
+ sharedSkillFiles
299
+ };
300
+
301
+ await fs.writeFile(
302
+ path.join(ROOT, "members", TEAM_SLUG, "lt-001", "private-skills", "lt-001-private-skill.md"),
303
+ "# LT-001 Private Skill\n\nOnly member lt-001 should see this file.\n"
304
+ );
305
+ await fs.writeFile(
306
+ path.join(ROOT, "members", TEAM_SLUG, "lt-002", "private-skills", "lt-002-private-skill.md"),
307
+ "# LT-002 Private Skill\n\nOnly member lt-002 should see this file.\n"
308
+ );
309
+
310
+ const memberOneAssets = await request(`/api/teams/${TEAM_SLUG}/members/lt-001/assets`);
311
+ const memberTwoAssets = await request(`/api/teams/${TEAM_SLUG}/members/lt-002/assets`);
312
+ const memberOnePrivateSkills = memberOneAssets.buckets.find((bucket) => bucket.key === "private-skills");
313
+ const memberTwoPrivateSkills = memberTwoAssets.buckets.find((bucket) => bucket.key === "private-skills");
314
+
315
+ report.assetChecks.memberOnePrivateSkillFiles = memberOnePrivateSkills?.files.map((file) => file.name) ?? [];
316
+ report.assetChecks.memberTwoPrivateSkillFiles = memberTwoPrivateSkills?.files.map((file) => file.name) ?? [];
317
+
318
+ const sampleComposePath = path.join(ROOT, "members", TEAM_SLUG, "lt-001", "runtime", "docker-compose.yml");
319
+ const sampleCompose = await fs.readFile(sampleComposePath, "utf8");
320
+ report.runtimeChecks.composeHasPrivateMounts = [
321
+ "../../private-memory:/home/node/.openclaw/workspace/private-memory:rw",
322
+ "../../private-skills:/home/node/.openclaw/workspace/private-skills:rw",
323
+ "../../private-tools:/home/node/.openclaw/workspace/private-tools:rw",
324
+ "../../private-docs:/home/node/.openclaw/workspace/private-docs:rw",
325
+ `/home/node/.openclaw/workspace/team-shared:ro`
326
+ ].every((segment) => sampleCompose.includes(segment));
327
+ assert(report.runtimeChecks.composeHasPrivateMounts, "sample compose file is missing expected shared/private mounts");
328
+
329
+ report.runtimeChecks.dockerMountProbe = await runDockerReadChecks(TEAM_SLUG);
330
+
331
+ const finalOverview = await request(`/api/teams/${TEAM_SLUG}`);
332
+ report.finalOverviewSummary = finalOverview.summary;
333
+ report.finishedAt = nowIso();
334
+
335
+ await writeReport(report);
336
+ console.log(JSON.stringify(report, null, 2));
337
+ }
338
+
339
+ main().catch(async (error) => {
340
+ const failureReport = {
341
+ startedAt: nowIso(),
342
+ baseUrl: BASE_URL,
343
+ team: {
344
+ slug: TEAM_SLUG,
345
+ name: TEAM_NAME,
346
+ size: TEAM_SIZE
347
+ },
348
+ failedAt: nowIso(),
349
+ error: error instanceof Error ? error.message : String(error)
350
+ };
351
+ try {
352
+ await writeReport(failureReport);
353
+ } catch {
354
+ // ignore secondary failure while reporting
355
+ }
356
+ console.error(JSON.stringify(failureReport, null, 2));
357
+ process.exitCode = 1;
358
+ });
@@ -0,0 +1,44 @@
1
+ import path from "node:path";
2
+ import { fileURLToPath } from "node:url";
3
+
4
+ const ROOT = process.env.VELACLAW_ROOT
5
+ ? path.resolve(process.env.VELACLAW_ROOT)
6
+ : path.resolve(path.dirname(fileURLToPath(import.meta.url)), "..");
7
+ const BASE_URL = process.env.VELACLAW_BASE_URL || "http://127.0.0.1:4318";
8
+
9
+ async function fetchJson(pathname) {
10
+ const response = await fetch(`${BASE_URL}${pathname}`);
11
+ const text = await response.text();
12
+ let payload;
13
+ try {
14
+ payload = text ? JSON.parse(text) : null;
15
+ } catch {
16
+ payload = text;
17
+ }
18
+ if (!response.ok) {
19
+ throw new Error(`${pathname} -> ${response.status} ${typeof payload === "string" ? payload : JSON.stringify(payload)}`);
20
+ }
21
+ return payload;
22
+ }
23
+
24
+ async function main() {
25
+ const health = await fetchJson("/health");
26
+ const teams = await fetchJson("/api/teams");
27
+ console.log(
28
+ JSON.stringify(
29
+ {
30
+ root: ROOT,
31
+ baseUrl: BASE_URL,
32
+ health,
33
+ teamCount: Array.isArray(teams?.teams) ? teams.teams.length : 0
34
+ },
35
+ null,
36
+ 2
37
+ )
38
+ );
39
+ }
40
+
41
+ main().catch((error) => {
42
+ console.error(error instanceof Error ? error.message : String(error));
43
+ process.exitCode = 1;
44
+ });