Merge pull request #252 from paperclipai/dotta
Dotta updates - sorry it's so large
This commit is contained in:
@@ -22,6 +22,7 @@ const workspacePaths = [
|
|||||||
"packages/adapters/claude-local",
|
"packages/adapters/claude-local",
|
||||||
"packages/adapters/codex-local",
|
"packages/adapters/codex-local",
|
||||||
"packages/adapters/openclaw",
|
"packages/adapters/openclaw",
|
||||||
|
"packages/adapters/openclaw-gateway",
|
||||||
];
|
];
|
||||||
|
|
||||||
// Workspace packages that should NOT be bundled — they'll be published
|
// Workspace packages that should NOT be bundled — they'll be published
|
||||||
|
|||||||
@@ -40,6 +40,7 @@
|
|||||||
"@paperclipai/adapter-opencode-local": "workspace:*",
|
"@paperclipai/adapter-opencode-local": "workspace:*",
|
||||||
"@paperclipai/adapter-pi-local": "workspace:*",
|
"@paperclipai/adapter-pi-local": "workspace:*",
|
||||||
"@paperclipai/adapter-openclaw": "workspace:*",
|
"@paperclipai/adapter-openclaw": "workspace:*",
|
||||||
|
"@paperclipai/adapter-openclaw-gateway": "workspace:*",
|
||||||
"@paperclipai/adapter-utils": "workspace:*",
|
"@paperclipai/adapter-utils": "workspace:*",
|
||||||
"@paperclipai/db": "workspace:*",
|
"@paperclipai/db": "workspace:*",
|
||||||
"@paperclipai/server": "workspace:*",
|
"@paperclipai/server": "workspace:*",
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import { printCursorStreamEvent } from "@paperclipai/adapter-cursor-local/cli";
|
|||||||
import { printOpenCodeStreamEvent } from "@paperclipai/adapter-opencode-local/cli";
|
import { printOpenCodeStreamEvent } from "@paperclipai/adapter-opencode-local/cli";
|
||||||
import { printPiStreamEvent } from "@paperclipai/adapter-pi-local/cli";
|
import { printPiStreamEvent } from "@paperclipai/adapter-pi-local/cli";
|
||||||
import { printOpenClawStreamEvent } from "@paperclipai/adapter-openclaw/cli";
|
import { printOpenClawStreamEvent } from "@paperclipai/adapter-openclaw/cli";
|
||||||
|
import { printOpenClawGatewayStreamEvent } from "@paperclipai/adapter-openclaw-gateway/cli";
|
||||||
import { processCLIAdapter } from "./process/index.js";
|
import { processCLIAdapter } from "./process/index.js";
|
||||||
import { httpCLIAdapter } from "./http/index.js";
|
import { httpCLIAdapter } from "./http/index.js";
|
||||||
|
|
||||||
@@ -38,8 +39,23 @@ const openclawCLIAdapter: CLIAdapterModule = {
|
|||||||
formatStdoutEvent: printOpenClawStreamEvent,
|
formatStdoutEvent: printOpenClawStreamEvent,
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const openclawGatewayCLIAdapter: CLIAdapterModule = {
|
||||||
|
type: "openclaw_gateway",
|
||||||
|
formatStdoutEvent: printOpenClawGatewayStreamEvent,
|
||||||
|
};
|
||||||
|
|
||||||
const adaptersByType = new Map<string, CLIAdapterModule>(
|
const adaptersByType = new Map<string, CLIAdapterModule>(
|
||||||
[claudeLocalCLIAdapter, codexLocalCLIAdapter, openCodeLocalCLIAdapter, piLocalCLIAdapter, cursorLocalCLIAdapter, openclawCLIAdapter, processCLIAdapter, httpCLIAdapter].map((a) => [a.type, a]),
|
[
|
||||||
|
claudeLocalCLIAdapter,
|
||||||
|
codexLocalCLIAdapter,
|
||||||
|
openCodeLocalCLIAdapter,
|
||||||
|
piLocalCLIAdapter,
|
||||||
|
cursorLocalCLIAdapter,
|
||||||
|
openclawCLIAdapter,
|
||||||
|
openclawGatewayCLIAdapter,
|
||||||
|
processCLIAdapter,
|
||||||
|
httpCLIAdapter,
|
||||||
|
].map((a) => [a.type, a]),
|
||||||
);
|
);
|
||||||
|
|
||||||
export function getCLIAdapter(type: string): CLIAdapterModule {
|
export function getCLIAdapter(type: string): CLIAdapterModule {
|
||||||
|
|||||||
@@ -1,5 +1,9 @@
|
|||||||
import { Command } from "commander";
|
import { Command } from "commander";
|
||||||
import type { Agent } from "@paperclipai/shared";
|
import type { Agent } from "@paperclipai/shared";
|
||||||
|
import fs from "node:fs/promises";
|
||||||
|
import os from "node:os";
|
||||||
|
import path from "node:path";
|
||||||
|
import { fileURLToPath } from "node:url";
|
||||||
import {
|
import {
|
||||||
addCommonClientOptions,
|
addCommonClientOptions,
|
||||||
formatInlineRecord,
|
formatInlineRecord,
|
||||||
@@ -13,6 +17,107 @@ interface AgentListOptions extends BaseClientOptions {
|
|||||||
companyId?: string;
|
companyId?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
interface AgentLocalCliOptions extends BaseClientOptions {
|
||||||
|
companyId?: string;
|
||||||
|
keyName?: string;
|
||||||
|
installSkills?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface CreatedAgentKey {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
token: string;
|
||||||
|
createdAt: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface SkillsInstallSummary {
|
||||||
|
tool: "codex" | "claude";
|
||||||
|
target: string;
|
||||||
|
linked: string[];
|
||||||
|
skipped: string[];
|
||||||
|
failed: Array<{ name: string; error: string }>;
|
||||||
|
}
|
||||||
|
|
||||||
|
const __moduleDir = path.dirname(fileURLToPath(import.meta.url));
|
||||||
|
const PAPERCLIP_SKILLS_CANDIDATES = [
|
||||||
|
path.resolve(__moduleDir, "../../../../../skills"), // dev: cli/src/commands/client -> repo root/skills
|
||||||
|
path.resolve(process.cwd(), "skills"),
|
||||||
|
];
|
||||||
|
|
||||||
|
function codexSkillsHome(): string {
|
||||||
|
const fromEnv = process.env.CODEX_HOME?.trim();
|
||||||
|
const base = fromEnv && fromEnv.length > 0 ? fromEnv : path.join(os.homedir(), ".codex");
|
||||||
|
return path.join(base, "skills");
|
||||||
|
}
|
||||||
|
|
||||||
|
function claudeSkillsHome(): string {
|
||||||
|
const fromEnv = process.env.CLAUDE_HOME?.trim();
|
||||||
|
const base = fromEnv && fromEnv.length > 0 ? fromEnv : path.join(os.homedir(), ".claude");
|
||||||
|
return path.join(base, "skills");
|
||||||
|
}
|
||||||
|
|
||||||
|
async function resolvePaperclipSkillsDir(): Promise<string | null> {
|
||||||
|
for (const candidate of PAPERCLIP_SKILLS_CANDIDATES) {
|
||||||
|
const isDir = await fs.stat(candidate).then((s) => s.isDirectory()).catch(() => false);
|
||||||
|
if (isDir) return candidate;
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function installSkillsForTarget(
|
||||||
|
sourceSkillsDir: string,
|
||||||
|
targetSkillsDir: string,
|
||||||
|
tool: "codex" | "claude",
|
||||||
|
): Promise<SkillsInstallSummary> {
|
||||||
|
const summary: SkillsInstallSummary = {
|
||||||
|
tool,
|
||||||
|
target: targetSkillsDir,
|
||||||
|
linked: [],
|
||||||
|
skipped: [],
|
||||||
|
failed: [],
|
||||||
|
};
|
||||||
|
|
||||||
|
await fs.mkdir(targetSkillsDir, { recursive: true });
|
||||||
|
const entries = await fs.readdir(sourceSkillsDir, { withFileTypes: true });
|
||||||
|
for (const entry of entries) {
|
||||||
|
if (!entry.isDirectory()) continue;
|
||||||
|
const source = path.join(sourceSkillsDir, entry.name);
|
||||||
|
const target = path.join(targetSkillsDir, entry.name);
|
||||||
|
const existing = await fs.lstat(target).catch(() => null);
|
||||||
|
if (existing) {
|
||||||
|
summary.skipped.push(entry.name);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
await fs.symlink(source, target);
|
||||||
|
summary.linked.push(entry.name);
|
||||||
|
} catch (err) {
|
||||||
|
summary.failed.push({
|
||||||
|
name: entry.name,
|
||||||
|
error: err instanceof Error ? err.message : String(err),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return summary;
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildAgentEnvExports(input: {
|
||||||
|
apiBase: string;
|
||||||
|
companyId: string;
|
||||||
|
agentId: string;
|
||||||
|
apiKey: string;
|
||||||
|
}): string {
|
||||||
|
const escaped = (value: string) => value.replace(/'/g, "'\"'\"'");
|
||||||
|
return [
|
||||||
|
`export PAPERCLIP_API_URL='${escaped(input.apiBase)}'`,
|
||||||
|
`export PAPERCLIP_COMPANY_ID='${escaped(input.companyId)}'`,
|
||||||
|
`export PAPERCLIP_AGENT_ID='${escaped(input.agentId)}'`,
|
||||||
|
`export PAPERCLIP_API_KEY='${escaped(input.apiKey)}'`,
|
||||||
|
].join("\n");
|
||||||
|
}
|
||||||
|
|
||||||
export function registerAgentCommands(program: Command): void {
|
export function registerAgentCommands(program: Command): void {
|
||||||
const agent = program.command("agent").description("Agent operations");
|
const agent = program.command("agent").description("Agent operations");
|
||||||
|
|
||||||
@@ -71,4 +176,96 @@ export function registerAgentCommands(program: Command): void {
|
|||||||
}
|
}
|
||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
|
|
||||||
|
addCommonClientOptions(
|
||||||
|
agent
|
||||||
|
.command("local-cli")
|
||||||
|
.description(
|
||||||
|
"Create an agent API key, install local Paperclip skills for Codex/Claude, and print shell exports",
|
||||||
|
)
|
||||||
|
.argument("<agentRef>", "Agent ID or shortname/url-key")
|
||||||
|
.requiredOption("-C, --company-id <id>", "Company ID")
|
||||||
|
.option("--key-name <name>", "API key label", "local-cli")
|
||||||
|
.option(
|
||||||
|
"--no-install-skills",
|
||||||
|
"Skip installing Paperclip skills into ~/.codex/skills and ~/.claude/skills",
|
||||||
|
)
|
||||||
|
.action(async (agentRef: string, opts: AgentLocalCliOptions) => {
|
||||||
|
try {
|
||||||
|
const ctx = resolveCommandContext(opts, { requireCompany: true });
|
||||||
|
const query = new URLSearchParams({ companyId: ctx.companyId ?? "" });
|
||||||
|
const agentRow = await ctx.api.get<Agent>(
|
||||||
|
`/api/agents/${encodeURIComponent(agentRef)}?${query.toString()}`,
|
||||||
|
);
|
||||||
|
|
||||||
|
const now = new Date().toISOString().replaceAll(":", "-");
|
||||||
|
const keyName = opts.keyName?.trim() ? opts.keyName.trim() : `local-cli-${now}`;
|
||||||
|
const key = await ctx.api.post<CreatedAgentKey>(`/api/agents/${agentRow.id}/keys`, { name: keyName });
|
||||||
|
|
||||||
|
const installSummaries: SkillsInstallSummary[] = [];
|
||||||
|
if (opts.installSkills !== false) {
|
||||||
|
const skillsDir = await resolvePaperclipSkillsDir();
|
||||||
|
if (!skillsDir) {
|
||||||
|
throw new Error(
|
||||||
|
"Could not locate local Paperclip skills directory. Expected ./skills in the repo checkout.",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
installSummaries.push(
|
||||||
|
await installSkillsForTarget(skillsDir, codexSkillsHome(), "codex"),
|
||||||
|
await installSkillsForTarget(skillsDir, claudeSkillsHome(), "claude"),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const exportsText = buildAgentEnvExports({
|
||||||
|
apiBase: ctx.api.apiBase,
|
||||||
|
companyId: agentRow.companyId,
|
||||||
|
agentId: agentRow.id,
|
||||||
|
apiKey: key.token,
|
||||||
|
});
|
||||||
|
|
||||||
|
if (ctx.json) {
|
||||||
|
printOutput(
|
||||||
|
{
|
||||||
|
agent: {
|
||||||
|
id: agentRow.id,
|
||||||
|
name: agentRow.name,
|
||||||
|
urlKey: agentRow.urlKey,
|
||||||
|
companyId: agentRow.companyId,
|
||||||
|
},
|
||||||
|
key: {
|
||||||
|
id: key.id,
|
||||||
|
name: key.name,
|
||||||
|
createdAt: key.createdAt,
|
||||||
|
token: key.token,
|
||||||
|
},
|
||||||
|
skills: installSummaries,
|
||||||
|
exports: exportsText,
|
||||||
|
},
|
||||||
|
{ json: true },
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log(`Agent: ${agentRow.name} (${agentRow.id})`);
|
||||||
|
console.log(`API key created: ${key.name} (${key.id})`);
|
||||||
|
if (installSummaries.length > 0) {
|
||||||
|
for (const summary of installSummaries) {
|
||||||
|
console.log(
|
||||||
|
`${summary.tool}: linked=${summary.linked.length} skipped=${summary.skipped.length} failed=${summary.failed.length} target=${summary.target}`,
|
||||||
|
);
|
||||||
|
for (const failed of summary.failed) {
|
||||||
|
console.log(` failed ${failed.name}: ${failed.error}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
console.log("");
|
||||||
|
console.log("# Run this in your shell before launching codex/claude:");
|
||||||
|
console.log(exportsText);
|
||||||
|
} catch (err) {
|
||||||
|
handleCommandError(err);
|
||||||
|
}
|
||||||
|
}),
|
||||||
|
{ includeCompany: false },
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
14
doc/CLI.md
14
doc/CLI.md
@@ -116,6 +116,20 @@ pnpm paperclipai issue release <issue-id>
|
|||||||
```sh
|
```sh
|
||||||
pnpm paperclipai agent list --company-id <company-id>
|
pnpm paperclipai agent list --company-id <company-id>
|
||||||
pnpm paperclipai agent get <agent-id>
|
pnpm paperclipai agent get <agent-id>
|
||||||
|
pnpm paperclipai agent local-cli <agent-id-or-shortname> --company-id <company-id>
|
||||||
|
```
|
||||||
|
|
||||||
|
`agent local-cli` is the quickest way to run local Claude/Codex manually as a Paperclip agent:
|
||||||
|
|
||||||
|
- creates a new long-lived agent API key
|
||||||
|
- installs missing Paperclip skills into `~/.codex/skills` and `~/.claude/skills`
|
||||||
|
- prints `export ...` lines for `PAPERCLIP_API_URL`, `PAPERCLIP_COMPANY_ID`, `PAPERCLIP_AGENT_ID`, and `PAPERCLIP_API_KEY`
|
||||||
|
|
||||||
|
Example for shortname-based local setup:
|
||||||
|
|
||||||
|
```sh
|
||||||
|
pnpm paperclipai agent local-cli codexcoder --company-id <company-id>
|
||||||
|
pnpm paperclipai agent local-cli claudecoder --company-id <company-id>
|
||||||
```
|
```
|
||||||
|
|
||||||
## Approval Commands
|
## Approval Commands
|
||||||
|
|||||||
55
doc/OPENCLAW_ONBOARDING.md
Normal file
55
doc/OPENCLAW_ONBOARDING.md
Normal file
@@ -0,0 +1,55 @@
|
|||||||
|
Use this exact checklist.
|
||||||
|
|
||||||
|
1. Start Paperclip in auth mode.
|
||||||
|
```bash
|
||||||
|
cd <paperclip-repo-root>
|
||||||
|
pnpm dev --tailscale-auth
|
||||||
|
```
|
||||||
|
Then verify:
|
||||||
|
```bash
|
||||||
|
curl -sS http://127.0.0.1:3100/api/health | jq
|
||||||
|
```
|
||||||
|
|
||||||
|
2. Start a clean/stock OpenClaw Docker.
|
||||||
|
```bash
|
||||||
|
OPENCLAW_RESET_STATE=1 OPENCLAW_BUILD=1 ./scripts/smoke/openclaw-docker-ui.sh
|
||||||
|
```
|
||||||
|
Open the printed `Dashboard URL` (includes `#token=...`) in your browser.
|
||||||
|
|
||||||
|
3. In Paperclip UI, go to `http://127.0.0.1:3100/CLA/company/settings`.
|
||||||
|
|
||||||
|
4. Use the agent snippet flow.
|
||||||
|
- Copy the snippet from company settings.
|
||||||
|
- Paste it into OpenClaw main chat as one message.
|
||||||
|
- If it stalls, send one follow-up: `How is onboarding going? Continue setup now.`
|
||||||
|
|
||||||
|
5. Approve the join request in Paperclip UI, then confirm the OpenClaw agent appears in CLA agents.
|
||||||
|
|
||||||
|
6. Case A (manual issue test).
|
||||||
|
- Create an issue assigned to the OpenClaw agent.
|
||||||
|
- Put instructions: “post comment `OPENCLAW_CASE_A_OK_<timestamp>` and mark done.”
|
||||||
|
- Verify in UI: issue status becomes `done` and comment exists.
|
||||||
|
|
||||||
|
7. Case B (message tool test).
|
||||||
|
- Create another issue assigned to OpenClaw.
|
||||||
|
- Instructions: “send `OPENCLAW_CASE_B_OK_<timestamp>` to main webchat via message tool, then comment same marker on issue, then mark done.”
|
||||||
|
- Verify both:
|
||||||
|
- marker comment on issue
|
||||||
|
- marker text appears in OpenClaw main chat
|
||||||
|
|
||||||
|
8. Case C (new session memory/skills test).
|
||||||
|
- In OpenClaw, start `/new` session.
|
||||||
|
- Ask it to create a new CLA issue in Paperclip with unique title `OPENCLAW_CASE_C_CREATED_<timestamp>`.
|
||||||
|
- Verify in Paperclip UI that new issue exists.
|
||||||
|
|
||||||
|
9. Watch logs during test (optional but helpful):
|
||||||
|
```bash
|
||||||
|
docker compose -f /tmp/openclaw-docker/docker-compose.yml -f /tmp/openclaw-docker/.paperclip-openclaw.override.yml logs -f openclaw-gateway
|
||||||
|
```
|
||||||
|
|
||||||
|
10. Expected pass criteria.
|
||||||
|
- Case A: `done` + marker comment.
|
||||||
|
- Case B: `done` + marker comment + main-chat message visible.
|
||||||
|
- Case C: original task done and new issue created from `/new` session.
|
||||||
|
|
||||||
|
If you want, I can also give you a single “observer mode” command that runs the stock smoke harness while you watch the same steps live in UI.
|
||||||
@@ -47,6 +47,14 @@ If resume fails with an unknown session error, the adapter automatically retries
|
|||||||
|
|
||||||
The adapter creates a temporary directory with symlinks to Paperclip skills and passes it via `--add-dir`. This makes skills discoverable without polluting the agent's working directory.
|
The adapter creates a temporary directory with symlinks to Paperclip skills and passes it via `--add-dir`. This makes skills discoverable without polluting the agent's working directory.
|
||||||
|
|
||||||
|
For manual local CLI usage outside heartbeat runs (for example running as `claudecoder` directly), use:
|
||||||
|
|
||||||
|
```sh
|
||||||
|
pnpm paperclipai agent local-cli claudecoder --company-id <company-id>
|
||||||
|
```
|
||||||
|
|
||||||
|
This installs Paperclip skills in `~/.claude/skills`, creates an agent API key, and prints shell exports to run as that agent.
|
||||||
|
|
||||||
## Environment Test
|
## Environment Test
|
||||||
|
|
||||||
Use the "Test Environment" button in the UI to validate the adapter config. It checks:
|
Use the "Test Environment" button in the UI to validate the adapter config. It checks:
|
||||||
|
|||||||
@@ -30,6 +30,14 @@ Codex uses `previous_response_id` for session continuity. The adapter serializes
|
|||||||
|
|
||||||
The adapter symlinks Paperclip skills into the global Codex skills directory (`~/.codex/skills`). Existing user skills are not overwritten.
|
The adapter symlinks Paperclip skills into the global Codex skills directory (`~/.codex/skills`). Existing user skills are not overwritten.
|
||||||
|
|
||||||
|
For manual local CLI usage outside heartbeat runs (for example running as `codexcoder` directly), use:
|
||||||
|
|
||||||
|
```sh
|
||||||
|
pnpm paperclipai agent local-cli codexcoder --company-id <company-id>
|
||||||
|
```
|
||||||
|
|
||||||
|
This installs any missing skills, creates an agent API key, and prints shell exports to run as that agent.
|
||||||
|
|
||||||
## Environment Test
|
## Environment Test
|
||||||
|
|
||||||
The environment test checks:
|
The environment test checks:
|
||||||
|
|||||||
71
packages/adapters/openclaw-gateway/README.md
Normal file
71
packages/adapters/openclaw-gateway/README.md
Normal file
@@ -0,0 +1,71 @@
|
|||||||
|
# OpenClaw Gateway Adapter
|
||||||
|
|
||||||
|
This document describes how `@paperclipai/adapter-openclaw-gateway` invokes OpenClaw over the Gateway protocol.
|
||||||
|
|
||||||
|
## Transport
|
||||||
|
|
||||||
|
This adapter always uses WebSocket gateway transport.
|
||||||
|
|
||||||
|
- URL must be `ws://` or `wss://`
|
||||||
|
- Connect flow follows gateway protocol:
|
||||||
|
1. receive `connect.challenge`
|
||||||
|
2. send `req connect` (protocol/client/auth/device payload)
|
||||||
|
3. send `req agent`
|
||||||
|
4. wait for completion via `req agent.wait`
|
||||||
|
5. stream `event agent` frames into Paperclip logs/transcript parsing
|
||||||
|
|
||||||
|
## Auth Modes
|
||||||
|
|
||||||
|
Gateway credentials can be provided in any of these ways:
|
||||||
|
|
||||||
|
- `authToken` / `token` in adapter config
|
||||||
|
- `headers.x-openclaw-token`
|
||||||
|
- `headers.x-openclaw-auth` (legacy)
|
||||||
|
- `password` (shared password mode)
|
||||||
|
|
||||||
|
When a token is present and `authorization` header is missing, the adapter derives `Authorization: Bearer <token>`.
|
||||||
|
|
||||||
|
## Device Auth
|
||||||
|
|
||||||
|
By default the adapter sends a signed `device` payload in `connect` params.
|
||||||
|
|
||||||
|
- set `disableDeviceAuth=true` to omit device signing
|
||||||
|
- set `devicePrivateKeyPem` to pin a stable signing key
|
||||||
|
- without `devicePrivateKeyPem`, the adapter generates an ephemeral Ed25519 keypair per run
|
||||||
|
|
||||||
|
## Session Strategy
|
||||||
|
|
||||||
|
The adapter supports the same session routing model as HTTP OpenClaw mode:
|
||||||
|
|
||||||
|
- `sessionKeyStrategy=fixed|issue|run`
|
||||||
|
- `sessionKey` is used when strategy is `fixed`
|
||||||
|
|
||||||
|
Resolved session key is sent as `agent.sessionKey`.
|
||||||
|
|
||||||
|
## Payload Mapping
|
||||||
|
|
||||||
|
The agent request is built as:
|
||||||
|
|
||||||
|
- required fields:
|
||||||
|
- `message` (wake text plus optional `payloadTemplate.message`/`payloadTemplate.text` prefix)
|
||||||
|
- `idempotencyKey` (Paperclip `runId`)
|
||||||
|
- `sessionKey` (resolved strategy)
|
||||||
|
- optional additions:
|
||||||
|
- all `payloadTemplate` fields merged in
|
||||||
|
- `agentId` from config if set and not already in template
|
||||||
|
|
||||||
|
## Timeouts
|
||||||
|
|
||||||
|
- `timeoutSec` controls adapter-level request budget
|
||||||
|
- `waitTimeoutMs` controls `agent.wait.timeoutMs`
|
||||||
|
|
||||||
|
If `agent.wait` returns `timeout`, adapter returns `openclaw_gateway_wait_timeout`.
|
||||||
|
|
||||||
|
## Log Format
|
||||||
|
|
||||||
|
Structured gateway event logs use:
|
||||||
|
|
||||||
|
- `[openclaw-gateway] ...` for lifecycle/system logs
|
||||||
|
- `[openclaw-gateway:event] run=<id> stream=<stream> data=<json>` for `event agent` frames
|
||||||
|
|
||||||
|
UI/CLI parsers consume these lines to render transcript updates.
|
||||||
@@ -0,0 +1,351 @@
|
|||||||
|
# OpenClaw Gateway Onboarding and Test Plan
|
||||||
|
|
||||||
|
## Objective
|
||||||
|
Define a reliable, repeatable onboarding and E2E test workflow for OpenClaw integration in authenticated/private Paperclip dev mode (`pnpm dev --tailscale-auth`) with a strong UX path for users and a scriptable path for Codex.
|
||||||
|
|
||||||
|
This plan covers:
|
||||||
|
- Current onboarding flow behavior and gaps.
|
||||||
|
- Recommended UX for `openclaw` (HTTP `sse/webhook`) and `openclaw_gateway` (WebSocket gateway protocol).
|
||||||
|
- A concrete automation plan for Codex to run cleanup, onboarding, and E2E validation against the `CLA` company.
|
||||||
|
|
||||||
|
## Hard Requirements (Testing Contract)
|
||||||
|
These are mandatory for onboarding and smoke testing:
|
||||||
|
|
||||||
|
1. **Stock/clean OpenClaw boot every run**
|
||||||
|
- Use a fresh, unmodified OpenClaw Docker image path each test cycle.
|
||||||
|
- Do not rely on persistent/manual in-UI tweaks from prior runs.
|
||||||
|
- Recreate runtime state each run so results represent first-time user experience.
|
||||||
|
|
||||||
|
2. **One-command/prompt setup inside OpenClaw**
|
||||||
|
- OpenClaw should be bootstrapped by one primary instruction/prompt (copy/paste-able).
|
||||||
|
- If a kick is needed, allow at most one follow-up message (for example: “how is it going?”).
|
||||||
|
- Required OpenClaw configuration (transport enablement, auth loading, skill usage) must be embedded in prompt instructions, not manual hidden steps.
|
||||||
|
|
||||||
|
3. **Two-lane validation is required**
|
||||||
|
- Lane A (stock pass lane): unmodified/clean OpenClaw image and config flow. This lane is the release gate.
|
||||||
|
- Lane B (instrumentation lane): temporary test instrumentation is allowed only to diagnose failures; it cannot be the final passing path.
|
||||||
|
|
||||||
|
## Execution Findings (2026-03-07)
|
||||||
|
Observed from running `scripts/smoke/openclaw-gateway-e2e.sh` against `CLA` in authenticated/private dev mode:
|
||||||
|
|
||||||
|
1. **Baseline failure (before wake-text fix)**
|
||||||
|
- Stock lane had run-level success but failed functional assertions:
|
||||||
|
- connectivity run `64a72d8b-f5b3-4f62-9147-1c60932f50ad` succeeded
|
||||||
|
- case A run `fd29e361-a6bd-4bc6-9270-36ef96e3bd8e` succeeded
|
||||||
|
- issue `CLA-6` (`dad7b967-29d2-4317-8c9d-425b4421e098`) stayed `todo` with `0` comments
|
||||||
|
- Root symptom: OpenClaw reported missing concrete heartbeat procedure and guessed non-existent `/api/*heartbeat` endpoints.
|
||||||
|
|
||||||
|
2. **Post-fix validation (stock-clean lane passes)**
|
||||||
|
- After updating adapter wake text to include explicit Paperclip API workflow steps and explicit endpoint bans:
|
||||||
|
- connectivity run `c297e2d0-020b-4b30-95d3-a4c04e1373bb`: `succeeded`
|
||||||
|
- case A run `baac403e-8d86-48e5-b7d5-239c4755ce7e`: `succeeded`, issue `CLA-7` done with marker
|
||||||
|
- case B run `521fc8ad-2f5a-4bd8-9ddd-c491401c9158`: `succeeded`, issue `CLA-8` done with marker
|
||||||
|
- case C run `a03d86b6-91a8-48b4-8813-758f6bf11aec`: `succeeded`, issue `CLA-9` done, created issue `CLA-10`
|
||||||
|
- Stock release-gate lane now passes scripted checks.
|
||||||
|
|
||||||
|
3. **Instrumentation lane note**
|
||||||
|
- Prompt-augmented diagnostics lane previously timed out (`7537e5d2-a76a-44c5-bf9f-57f1b21f5fc3`) with missing tool runtime utilities (`jq`, `python`) inside the stock container.
|
||||||
|
- Keep this lane for diagnostics only; stock lane remains the acceptance gate.
|
||||||
|
|
||||||
|
## External Protocol Constraints
|
||||||
|
OpenClaw docs to anchor behavior:
|
||||||
|
- Webhook mode requires `hooks.enabled=true` and exposes `/hooks/wake` + `/hooks/agent`: https://docs.openclaw.ai/automation/webhook
|
||||||
|
- Gateway protocol is WebSocket challenge/response plus request/event frames: https://docs.openclaw.ai/gateway/protocol
|
||||||
|
- OpenResponses HTTP endpoint is separate (`gateway.http.endpoints.responses.enabled=true`): https://docs.openclaw.ai/openapi/responses
|
||||||
|
|
||||||
|
Implication:
|
||||||
|
- `webhook` transport should target `/hooks/*` and requires hook server enablement.
|
||||||
|
- `sse` transport should target `/v1/responses`.
|
||||||
|
- `openclaw_gateway` should use `ws://` or `wss://` and should not depend on `/v1/responses` or `/hooks/*`.
|
||||||
|
|
||||||
|
## Current Implementation Map (What Exists)
|
||||||
|
|
||||||
|
### Invite + onboarding pipeline
|
||||||
|
- Invite create: `POST /api/companies/:companyId/invites`
|
||||||
|
- Invite onboarding manifest: `GET /api/invites/:token/onboarding`
|
||||||
|
- Agent-readable text: `GET /api/invites/:token/onboarding.txt`
|
||||||
|
- Accept join: `POST /api/invites/:token/accept`
|
||||||
|
- Approve join: `POST /api/companies/:companyId/join-requests/:requestId/approve`
|
||||||
|
- Claim key: `POST /api/join-requests/:requestId/claim-api-key`
|
||||||
|
|
||||||
|
### Adapter state
|
||||||
|
- `openclaw` adapter supports `sse|webhook` and has remap/fallback behavior for webhook mode.
|
||||||
|
- `openclaw_gateway` adapter is implemented and working for direct gateway invocation (`connect -> agent -> agent.wait`).
|
||||||
|
|
||||||
|
### Existing smoke foundation
|
||||||
|
- `scripts/smoke/openclaw-docker-ui.sh` builds/starts OpenClaw Docker and polls readiness on `http://127.0.0.1:18789/`.
|
||||||
|
- Current local OpenClaw smoke config commonly enables `gateway.http.endpoints.responses.enabled=true`, but not hooks (`gateway.hooks`).
|
||||||
|
|
||||||
|
## Deep Code Findings (Gaps)
|
||||||
|
|
||||||
|
### 1) Onboarding content is still OpenClaw-HTTP specific
|
||||||
|
`server/src/routes/access.ts` hardcodes onboarding to:
|
||||||
|
- `recommendedAdapterType: "openclaw"`
|
||||||
|
- Required `agentDefaultsPayload.headers.x-openclaw-auth`
|
||||||
|
- HTTP callback URL guidance and `/v1/responses` examples.
|
||||||
|
|
||||||
|
There is no adapter-specific onboarding manifest/text for `openclaw_gateway`.
|
||||||
|
|
||||||
|
### 2) Company settings snippet is OpenClaw HTTP-first
|
||||||
|
`ui/src/pages/CompanySettings.tsx` generates one snippet that:
|
||||||
|
- Assumes OpenClaw HTTP callback setup.
|
||||||
|
- Instructs enabling `gateway.http.endpoints.responses.enabled=true`.
|
||||||
|
- Does not provide a dedicated gateway onboarding path.
|
||||||
|
|
||||||
|
### 3) Invite landing “agent join” UX is not wired for OpenClaw adapters
|
||||||
|
`ui/src/pages/InviteLanding.tsx` shows `openclaw` and `openclaw_gateway` as disabled (“Coming soon”) in join UI.
|
||||||
|
|
||||||
|
### 4) Join normalization/replay logic only special-cases `adapterType === "openclaw"`
|
||||||
|
`server/src/routes/access.ts` helper paths (`buildJoinDefaultsPayloadForAccept`, replay, normalization diagnostics) are OpenClaw-HTTP specific.
|
||||||
|
No equivalent normalization/diagnostics for gateway defaults.
|
||||||
|
|
||||||
|
### 5) Webhook confusion is expected in current setup
|
||||||
|
For `openclaw` + `streamTransport=webhook`:
|
||||||
|
- Adapter may remap `/v1/responses -> /hooks/agent`.
|
||||||
|
- If `/hooks/agent` returns `404`, it falls back to `/v1/responses`.
|
||||||
|
|
||||||
|
If OpenClaw hooks are disabled, users still see successful `/v1/responses` runs even with webhook selected.
|
||||||
|
|
||||||
|
### 6) Auth/testing ergonomics mismatch in tailscale-auth dev mode
|
||||||
|
- Runtime can be `authenticated/private` via env overrides (`pnpm dev --tailscale-auth`).
|
||||||
|
- CLI bootstrap/admin helpers read config file (`config.json`), which may still say `local_trusted`.
|
||||||
|
- Board setup actions require session cookies; CLI `--api-key` cannot replace board session for invite/approval routes.
|
||||||
|
|
||||||
|
### 7) Gateway adapter lacks hire-approved callback parity
|
||||||
|
`openclaw` has `onHireApproved`; `openclaw_gateway` currently does not.
|
||||||
|
Not a blocker for core routing, but creates inconsistent onboarding feedback behavior.
|
||||||
|
|
||||||
|
## UX Intention (Target Experience)
|
||||||
|
|
||||||
|
### Product goal
|
||||||
|
Users should pick one clear onboarding path:
|
||||||
|
- `Invite OpenClaw (HTTP)` for existing webhook/SSE installs.
|
||||||
|
- `Invite OpenClaw Gateway` for gateway-native installs.
|
||||||
|
|
||||||
|
### UX design requirements
|
||||||
|
- One-click invite action per mode in `/CLA/company/settings` (or equivalent company settings route).
|
||||||
|
- Mode-specific generated snippet and mode-specific onboarding text.
|
||||||
|
- Clear compatibility checks before user copies anything.
|
||||||
|
|
||||||
|
### Proposed UX structure
|
||||||
|
1. Add invite buttons:
|
||||||
|
- `Invite OpenClaw (SSE/Webhook)`
|
||||||
|
- `Invite OpenClaw Gateway`
|
||||||
|
|
||||||
|
2. For HTTP invite:
|
||||||
|
- Require transport choice (`sse` or `webhook`).
|
||||||
|
- Validate endpoint expectations:
|
||||||
|
- `sse` with `/v1/responses`.
|
||||||
|
- `webhook` with `/hooks/*` and hooks enablement guidance.
|
||||||
|
|
||||||
|
3. For Gateway invite:
|
||||||
|
- Ask only for `ws://`/`wss://` and token source guidance.
|
||||||
|
- No callback URL/paperclipApiUrl complexity in onboarding.
|
||||||
|
|
||||||
|
4. Always show:
|
||||||
|
- Preflight diagnostics.
|
||||||
|
- Copy-ready command/snippet.
|
||||||
|
- Expected next steps (join -> approve -> claim -> skill install).
|
||||||
|
|
||||||
|
## Why Gateway Improves Onboarding
|
||||||
|
Compared to webhook/SSE onboarding:
|
||||||
|
- Fewer network assumptions: Paperclip dials outbound WebSocket to OpenClaw; avoids callback reachability pitfalls.
|
||||||
|
- Less transport ambiguity: no `/v1/responses` vs `/hooks/*` fallback confusion.
|
||||||
|
- Better run observability: gateway event frames stream lifecycle/delta events in one protocol.
|
||||||
|
|
||||||
|
Tradeoff:
|
||||||
|
- Requires stable WS endpoint and gateway token handling.
|
||||||
|
|
||||||
|
## Codex-Executable E2E Workflow
|
||||||
|
|
||||||
|
## Scope
|
||||||
|
Run this full flow per test cycle against company `CLA`:
|
||||||
|
1. Assign task to OpenClaw agent -> agent executes -> task closes.
|
||||||
|
2. Task asks OpenClaw to send message to user main chat via message tool -> message appears in main chat.
|
||||||
|
3. OpenClaw in a fresh/new session can still create a Paperclip task.
|
||||||
|
4. Use one primary OpenClaw bootstrap prompt (plus optional single follow-up ping) to perform setup.
|
||||||
|
|
||||||
|
## 0) Cleanup Before Each Run
|
||||||
|
Use deterministic reset to avoid stale agents/runs/state.
|
||||||
|
|
||||||
|
1. OpenClaw Docker cleanup:
|
||||||
|
```bash
|
||||||
|
# stop/remove OpenClaw compose services
|
||||||
|
OPENCLAW_DOCKER_DIR=/tmp/openclaw-docker
|
||||||
|
if [ -d "$OPENCLAW_DOCKER_DIR" ]; then
|
||||||
|
docker compose -f "$OPENCLAW_DOCKER_DIR/docker-compose.yml" down --remove-orphans || true
|
||||||
|
fi
|
||||||
|
|
||||||
|
# remove old image (as requested)
|
||||||
|
docker image rm openclaw:local || true
|
||||||
|
```
|
||||||
|
|
||||||
|
2. Recreate OpenClaw cleanly:
|
||||||
|
```bash
|
||||||
|
OPENCLAW_RESET_STATE=1 OPENCLAW_BUILD=1 ./scripts/smoke/openclaw-docker-ui.sh
|
||||||
|
```
|
||||||
|
This must remain a stock/clean image boot path, with no hidden manual state carried from prior runs.
|
||||||
|
|
||||||
|
3. Remove prior CLA OpenClaw agents:
|
||||||
|
- List `CLA` agents via API.
|
||||||
|
- Terminate/delete agents with `adapterType in ("openclaw", "openclaw_gateway")` before new onboarding.
|
||||||
|
|
||||||
|
4. Reject/clear stale pending join requests for CLA (optional but recommended).
|
||||||
|
|
||||||
|
## 1) Start Paperclip in Required Mode
|
||||||
|
```bash
|
||||||
|
pnpm dev --tailscale-auth
|
||||||
|
```
|
||||||
|
Verify:
|
||||||
|
```bash
|
||||||
|
curl -fsS http://127.0.0.1:3100/api/health
|
||||||
|
# expect deploymentMode=authenticated, deploymentExposure=private
|
||||||
|
```
|
||||||
|
|
||||||
|
## 2) Acquire Board Session for Automation
|
||||||
|
Board operations (create invite, approve join, terminate agents) require board session cookie.
|
||||||
|
|
||||||
|
Short-term practical options:
|
||||||
|
1. Preferred immediate path: reuse an existing signed-in board browser cookie and export as `PAPERCLIP_COOKIE`.
|
||||||
|
2. Scripted fallback: sign-up/sign-in via `/api/auth/*`, then use a dedicated admin promotion/bootstrap utility for dev (recommended to add as a small internal script).
|
||||||
|
|
||||||
|
Note:
|
||||||
|
- CLI `--api-key` is for agent auth and is not enough for board-only routes in this flow.
|
||||||
|
|
||||||
|
## 3) Resolve CLA Company ID
|
||||||
|
With board cookie:
|
||||||
|
```bash
|
||||||
|
curl -sS -H "Cookie: $PAPERCLIP_COOKIE" http://127.0.0.1:3100/api/companies
|
||||||
|
```
|
||||||
|
Pick company where identifier/code is `CLA` and store `CLA_COMPANY_ID`.
|
||||||
|
|
||||||
|
## 4) Preflight OpenClaw Endpoint Capability
|
||||||
|
From host (using current OpenClaw token):
|
||||||
|
- For HTTP SSE mode: confirm `/v1/responses` behavior.
|
||||||
|
- For HTTP webhook mode: confirm `/hooks/agent` exists; if 404, hooks are disabled.
|
||||||
|
- For gateway mode: confirm WS challenge appears from `ws://127.0.0.1:18789`.
|
||||||
|
|
||||||
|
Expected in current docker smoke config:
|
||||||
|
- `/hooks/agent` likely `404` unless hooks explicitly enabled.
|
||||||
|
- WS gateway protocol works.
|
||||||
|
|
||||||
|
## 5) Gateway Join Flow (Primary Path)
|
||||||
|
|
||||||
|
1. Create agent-only invite in CLA:
|
||||||
|
```bash
|
||||||
|
POST /api/companies/$CLA_COMPANY_ID/invites
|
||||||
|
{ "allowedJoinTypes": "agent" }
|
||||||
|
```
|
||||||
|
|
||||||
|
2. Submit join request with gateway defaults:
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"requestType": "agent",
|
||||||
|
"agentName": "OpenClaw Gateway",
|
||||||
|
"adapterType": "openclaw_gateway",
|
||||||
|
"capabilities": "OpenClaw gateway agent",
|
||||||
|
"agentDefaultsPayload": {
|
||||||
|
"url": "ws://127.0.0.1:18789",
|
||||||
|
"headers": { "x-openclaw-token": "<gateway-token>" },
|
||||||
|
"role": "operator",
|
||||||
|
"scopes": ["operator.admin"],
|
||||||
|
"sessionKeyStrategy": "fixed",
|
||||||
|
"sessionKey": "paperclip",
|
||||||
|
"waitTimeoutMs": 120000
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
3. Approve join request.
|
||||||
|
4. Claim API key with `claimSecret`.
|
||||||
|
5. Save claimed token to OpenClaw expected file path (`~/.openclaw/workspace/paperclip-claimed-api-key.json`) and ensure `PAPERCLIP_API_KEY` + `PAPERCLIP_API_URL` are available for OpenClaw skill execution context.
|
||||||
|
- Write compatibility JSON keys (`token` and `apiKey`) to avoid runtime parser mismatch.
|
||||||
|
6. Ensure Paperclip skill is installed for OpenClaw runtime.
|
||||||
|
7. Send one bootstrap prompt to OpenClaw containing all setup instructions needed for this run (auth file usage, heartbeat procedure, required tools). If needed, send one follow-up nudge only.
|
||||||
|
|
||||||
|
## 6) E2E Validation Cases
|
||||||
|
|
||||||
|
### Case A: Assigned task execution/closure
|
||||||
|
1. Create issue in CLA assigned to joined OpenClaw agent.
|
||||||
|
2. Poll issue + heartbeat runs until terminal.
|
||||||
|
3. Pass criteria:
|
||||||
|
- At least one run invoked for that agent/issue.
|
||||||
|
- Run status `succeeded`.
|
||||||
|
- Issue reaches `done` (or documented expected terminal state if policy differs).
|
||||||
|
|
||||||
|
### Case B: Message tool to main chat
|
||||||
|
1. Create issue instructing OpenClaw: “send a message to the user’s main chat session in webchat using message tool”.
|
||||||
|
2. Trigger/poll run completion.
|
||||||
|
3. Validate output:
|
||||||
|
- Automated minimum: run log/transcript confirms tool invocation success.
|
||||||
|
- UX-level validation: message visibly appears in main chat UI.
|
||||||
|
|
||||||
|
Current recommendation:
|
||||||
|
- Keep this checkpoint as manual/assisted until a browser automation harness is added for OpenClaw control UI verification.
|
||||||
|
|
||||||
|
### Case C: Fresh session still creates Paperclip task
|
||||||
|
1. Force fresh-session behavior for test:
|
||||||
|
- set agent `sessionKeyStrategy` to `run` (or explicitly rotate session key).
|
||||||
|
2. Create issue asking agent to create a new Paperclip task.
|
||||||
|
3. Pass criteria:
|
||||||
|
- New issue appears in CLA with expected title/body.
|
||||||
|
- Agent succeeds without re-onboarding.
|
||||||
|
|
||||||
|
## 7) Observability and Assertions
|
||||||
|
Use these APIs for deterministic assertions:
|
||||||
|
- `GET /api/companies/:companyId/heartbeat-runs?agentId=...`
|
||||||
|
- `GET /api/heartbeat-runs/:runId/events`
|
||||||
|
- `GET /api/heartbeat-runs/:runId/log`
|
||||||
|
- `GET /api/issues/:id`
|
||||||
|
- `GET /api/companies/:companyId/issues?q=...`
|
||||||
|
|
||||||
|
Include explicit timeout budgets per poll loop and hard failure reasons in output.
|
||||||
|
|
||||||
|
## 8) Automation Artifact
|
||||||
|
Implemented smoke harness:
|
||||||
|
- `scripts/smoke/openclaw-gateway-e2e.sh`
|
||||||
|
|
||||||
|
Responsibilities:
|
||||||
|
- OpenClaw docker cleanup/rebuild/start.
|
||||||
|
- Paperclip health/auth preflight.
|
||||||
|
- CLA company resolution.
|
||||||
|
- Old OpenClaw agent cleanup.
|
||||||
|
- Invite/join/approve/claim orchestration.
|
||||||
|
- E2E case execution + assertions.
|
||||||
|
- Final summary with run IDs, issue IDs, agent ID.
|
||||||
|
|
||||||
|
## 9) Required Product/Code Changes to Support This Plan Cleanly
|
||||||
|
|
||||||
|
### Access/onboarding backend
|
||||||
|
- Make onboarding manifest/text adapter-aware (`openclaw` vs `openclaw_gateway`).
|
||||||
|
- Add gateway-specific required fields and examples.
|
||||||
|
- Add gateway-specific diagnostics (WS URL/token/role/scopes/device-auth hints).
|
||||||
|
|
||||||
|
### Company settings UX
|
||||||
|
- Replace single generic snippet with mode-specific invite actions.
|
||||||
|
- Add “Invite OpenClaw Gateway” path with concise copy/paste onboarding.
|
||||||
|
|
||||||
|
### Invite landing UX
|
||||||
|
- Enable OpenClaw adapter options when invite allows agent join.
|
||||||
|
- Allow `agentDefaultsPayload` entry for advanced joins where needed.
|
||||||
|
|
||||||
|
### Adapter parity
|
||||||
|
- Consider `onHireApproved` support for `openclaw_gateway` for consistency.
|
||||||
|
|
||||||
|
### Test coverage
|
||||||
|
- Add integration tests for adapter-aware onboarding manifest generation.
|
||||||
|
- Add route tests for gateway join/approve/claim path.
|
||||||
|
- Add smoke test target for gateway E2E flow.
|
||||||
|
|
||||||
|
## 10) Execution Order
|
||||||
|
1. Implement onboarding manifest/text split by adapter mode.
|
||||||
|
2. Add company settings invite UX split (HTTP vs Gateway).
|
||||||
|
3. Add gateway E2E smoke script.
|
||||||
|
4. Run full CLA workflow in authenticated/private mode.
|
||||||
|
5. Iterate on message-tool verification automation.
|
||||||
|
|
||||||
|
## Acceptance Criteria
|
||||||
|
- No webhook-mode ambiguity: webhook path does not silently appear as SSE success without explicit compatibility signal.
|
||||||
|
- Gateway onboarding is first-class and copy/pasteable from company settings.
|
||||||
|
- Codex can run end-to-end onboarding and validation against CLA with repeatable cleanup.
|
||||||
|
- All three validation cases are documented with pass/fail criteria and reproducible evidence paths.
|
||||||
52
packages/adapters/openclaw-gateway/package.json
Normal file
52
packages/adapters/openclaw-gateway/package.json
Normal file
@@ -0,0 +1,52 @@
|
|||||||
|
{
|
||||||
|
"name": "@paperclipai/adapter-openclaw-gateway",
|
||||||
|
"version": "0.2.7",
|
||||||
|
"type": "module",
|
||||||
|
"exports": {
|
||||||
|
".": "./src/index.ts",
|
||||||
|
"./server": "./src/server/index.ts",
|
||||||
|
"./ui": "./src/ui/index.ts",
|
||||||
|
"./cli": "./src/cli/index.ts"
|
||||||
|
},
|
||||||
|
"publishConfig": {
|
||||||
|
"access": "public",
|
||||||
|
"exports": {
|
||||||
|
".": {
|
||||||
|
"types": "./dist/index.d.ts",
|
||||||
|
"import": "./dist/index.js"
|
||||||
|
},
|
||||||
|
"./server": {
|
||||||
|
"types": "./dist/server/index.d.ts",
|
||||||
|
"import": "./dist/server/index.js"
|
||||||
|
},
|
||||||
|
"./ui": {
|
||||||
|
"types": "./dist/ui/index.d.ts",
|
||||||
|
"import": "./dist/ui/index.js"
|
||||||
|
},
|
||||||
|
"./cli": {
|
||||||
|
"types": "./dist/cli/index.d.ts",
|
||||||
|
"import": "./dist/cli/index.js"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"main": "./dist/index.js",
|
||||||
|
"types": "./dist/index.d.ts"
|
||||||
|
},
|
||||||
|
"files": [
|
||||||
|
"dist"
|
||||||
|
],
|
||||||
|
"scripts": {
|
||||||
|
"build": "tsc",
|
||||||
|
"clean": "rm -rf dist",
|
||||||
|
"typecheck": "tsc --noEmit"
|
||||||
|
},
|
||||||
|
"dependencies": {
|
||||||
|
"@paperclipai/adapter-utils": "workspace:*",
|
||||||
|
"picocolors": "^1.1.1",
|
||||||
|
"ws": "^8.19.0"
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"@types/node": "^24.6.0",
|
||||||
|
"@types/ws": "^8.18.1",
|
||||||
|
"typescript": "^5.7.3"
|
||||||
|
}
|
||||||
|
}
|
||||||
23
packages/adapters/openclaw-gateway/src/cli/format-event.ts
Normal file
23
packages/adapters/openclaw-gateway/src/cli/format-event.ts
Normal file
@@ -0,0 +1,23 @@
|
|||||||
|
import pc from "picocolors";
|
||||||
|
|
||||||
|
export function printOpenClawGatewayStreamEvent(raw: string, debug: boolean): void {
|
||||||
|
const line = raw.trim();
|
||||||
|
if (!line) return;
|
||||||
|
|
||||||
|
if (!debug) {
|
||||||
|
console.log(line);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (line.startsWith("[openclaw-gateway:event]")) {
|
||||||
|
console.log(pc.cyan(line));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (line.startsWith("[openclaw-gateway]")) {
|
||||||
|
console.log(pc.blue(line));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log(pc.gray(line));
|
||||||
|
}
|
||||||
1
packages/adapters/openclaw-gateway/src/cli/index.ts
Normal file
1
packages/adapters/openclaw-gateway/src/cli/index.ts
Normal file
@@ -0,0 +1 @@
|
|||||||
|
export { printOpenClawGatewayStreamEvent } from "./format-event.js";
|
||||||
41
packages/adapters/openclaw-gateway/src/index.ts
Normal file
41
packages/adapters/openclaw-gateway/src/index.ts
Normal file
@@ -0,0 +1,41 @@
|
|||||||
|
export const type = "openclaw_gateway";
|
||||||
|
export const label = "OpenClaw Gateway";
|
||||||
|
|
||||||
|
export const models: { id: string; label: string }[] = [];
|
||||||
|
|
||||||
|
export const agentConfigurationDoc = `# openclaw_gateway agent configuration
|
||||||
|
|
||||||
|
Adapter: openclaw_gateway
|
||||||
|
|
||||||
|
Use when:
|
||||||
|
- You want Paperclip to invoke OpenClaw over the Gateway WebSocket protocol.
|
||||||
|
- You want native gateway auth/connect semantics instead of HTTP /v1/responses or /hooks/*.
|
||||||
|
|
||||||
|
Don't use when:
|
||||||
|
- You only expose OpenClaw HTTP endpoints (use openclaw adapter with sse/webhook transport).
|
||||||
|
- Your deployment does not permit outbound WebSocket access from the Paperclip server.
|
||||||
|
|
||||||
|
Core fields:
|
||||||
|
- url (string, required): OpenClaw gateway WebSocket URL (ws:// or wss://)
|
||||||
|
- headers (object, optional): handshake headers; supports x-openclaw-token / x-openclaw-auth
|
||||||
|
- authToken (string, optional): shared gateway token override
|
||||||
|
- password (string, optional): gateway shared password, if configured
|
||||||
|
|
||||||
|
Gateway connect identity fields:
|
||||||
|
- clientId (string, optional): gateway client id (default gateway-client)
|
||||||
|
- clientMode (string, optional): gateway client mode (default backend)
|
||||||
|
- clientVersion (string, optional): client version string
|
||||||
|
- role (string, optional): gateway role (default operator)
|
||||||
|
- scopes (string[] | comma string, optional): gateway scopes (default ["operator.admin"])
|
||||||
|
- disableDeviceAuth (boolean, optional): disable signed device payload in connect params (default false)
|
||||||
|
|
||||||
|
Request behavior fields:
|
||||||
|
- payloadTemplate (object, optional): additional fields merged into gateway agent params
|
||||||
|
- timeoutSec (number, optional): adapter timeout in seconds (default 120)
|
||||||
|
- waitTimeoutMs (number, optional): agent.wait timeout override (default timeoutSec * 1000)
|
||||||
|
- paperclipApiUrl (string, optional): absolute Paperclip base URL advertised in wake text
|
||||||
|
|
||||||
|
Session routing fields:
|
||||||
|
- sessionKeyStrategy (string, optional): fixed (default), issue, or run
|
||||||
|
- sessionKey (string, optional): fixed session key when strategy=fixed (default paperclip)
|
||||||
|
`;
|
||||||
1091
packages/adapters/openclaw-gateway/src/server/execute.ts
Normal file
1091
packages/adapters/openclaw-gateway/src/server/execute.ts
Normal file
File diff suppressed because it is too large
Load Diff
2
packages/adapters/openclaw-gateway/src/server/index.ts
Normal file
2
packages/adapters/openclaw-gateway/src/server/index.ts
Normal file
@@ -0,0 +1,2 @@
|
|||||||
|
export { execute } from "./execute.js";
|
||||||
|
export { testEnvironment } from "./test.js";
|
||||||
317
packages/adapters/openclaw-gateway/src/server/test.ts
Normal file
317
packages/adapters/openclaw-gateway/src/server/test.ts
Normal file
@@ -0,0 +1,317 @@
|
|||||||
|
import type {
|
||||||
|
AdapterEnvironmentCheck,
|
||||||
|
AdapterEnvironmentTestContext,
|
||||||
|
AdapterEnvironmentTestResult,
|
||||||
|
} from "@paperclipai/adapter-utils";
|
||||||
|
import { asString, parseObject } from "@paperclipai/adapter-utils/server-utils";
|
||||||
|
import { randomUUID } from "node:crypto";
|
||||||
|
import { WebSocket } from "ws";
|
||||||
|
|
||||||
|
function summarizeStatus(checks: AdapterEnvironmentCheck[]): AdapterEnvironmentTestResult["status"] {
|
||||||
|
if (checks.some((check) => check.level === "error")) return "fail";
|
||||||
|
if (checks.some((check) => check.level === "warn")) return "warn";
|
||||||
|
return "pass";
|
||||||
|
}
|
||||||
|
|
||||||
|
function nonEmpty(value: unknown): string | null {
|
||||||
|
return typeof value === "string" && value.trim().length > 0 ? value.trim() : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function isLoopbackHost(hostname: string): boolean {
|
||||||
|
const value = hostname.trim().toLowerCase();
|
||||||
|
return value === "localhost" || value === "127.0.0.1" || value === "::1";
|
||||||
|
}
|
||||||
|
|
||||||
|
function toStringRecord(value: unknown): Record<string, string> {
|
||||||
|
const parsed = parseObject(value);
|
||||||
|
const out: Record<string, string> = {};
|
||||||
|
for (const [key, entry] of Object.entries(parsed)) {
|
||||||
|
if (typeof entry === "string") out[key] = entry;
|
||||||
|
}
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
|
function toStringArray(value: unknown): string[] {
|
||||||
|
if (Array.isArray(value)) {
|
||||||
|
return value
|
||||||
|
.filter((entry): entry is string => typeof entry === "string")
|
||||||
|
.map((entry) => entry.trim())
|
||||||
|
.filter(Boolean);
|
||||||
|
}
|
||||||
|
if (typeof value === "string") {
|
||||||
|
return value
|
||||||
|
.split(",")
|
||||||
|
.map((entry) => entry.trim())
|
||||||
|
.filter(Boolean);
|
||||||
|
}
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
function headerMapGetIgnoreCase(headers: Record<string, string>, key: string): string | null {
|
||||||
|
const match = Object.entries(headers).find(([entryKey]) => entryKey.toLowerCase() === key.toLowerCase());
|
||||||
|
return match ? match[1] : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function tokenFromAuthHeader(rawHeader: string | null): string | null {
|
||||||
|
if (!rawHeader) return null;
|
||||||
|
const trimmed = rawHeader.trim();
|
||||||
|
if (!trimmed) return null;
|
||||||
|
const match = trimmed.match(/^bearer\s+(.+)$/i);
|
||||||
|
return match ? nonEmpty(match[1]) : trimmed;
|
||||||
|
}
|
||||||
|
|
||||||
|
function resolveAuthToken(config: Record<string, unknown>, headers: Record<string, string>): string | null {
|
||||||
|
const explicit = nonEmpty(config.authToken) ?? nonEmpty(config.token);
|
||||||
|
if (explicit) return explicit;
|
||||||
|
|
||||||
|
const tokenHeader = headerMapGetIgnoreCase(headers, "x-openclaw-token");
|
||||||
|
if (nonEmpty(tokenHeader)) return nonEmpty(tokenHeader);
|
||||||
|
|
||||||
|
const authHeader =
|
||||||
|
headerMapGetIgnoreCase(headers, "x-openclaw-auth") ??
|
||||||
|
headerMapGetIgnoreCase(headers, "authorization");
|
||||||
|
return tokenFromAuthHeader(authHeader);
|
||||||
|
}
|
||||||
|
|
||||||
|
function asRecord(value: unknown): Record<string, unknown> | null {
|
||||||
|
if (typeof value !== "object" || value === null || Array.isArray(value)) return null;
|
||||||
|
return value as Record<string, unknown>;
|
||||||
|
}
|
||||||
|
|
||||||
|
function rawDataToString(data: unknown): string {
|
||||||
|
if (typeof data === "string") return data;
|
||||||
|
if (Buffer.isBuffer(data)) return data.toString("utf8");
|
||||||
|
if (data instanceof ArrayBuffer) return Buffer.from(data).toString("utf8");
|
||||||
|
if (Array.isArray(data)) {
|
||||||
|
return Buffer.concat(
|
||||||
|
data.map((entry) => (Buffer.isBuffer(entry) ? entry : Buffer.from(String(entry), "utf8"))),
|
||||||
|
).toString("utf8");
|
||||||
|
}
|
||||||
|
return String(data ?? "");
|
||||||
|
}
|
||||||
|
|
||||||
|
async function probeGateway(input: {
|
||||||
|
url: string;
|
||||||
|
headers: Record<string, string>;
|
||||||
|
authToken: string | null;
|
||||||
|
role: string;
|
||||||
|
scopes: string[];
|
||||||
|
timeoutMs: number;
|
||||||
|
}): Promise<"ok" | "challenge_only" | "failed"> {
|
||||||
|
return await new Promise((resolve) => {
|
||||||
|
const ws = new WebSocket(input.url, { headers: input.headers, maxPayload: 2 * 1024 * 1024 });
|
||||||
|
const timeout = setTimeout(() => {
|
||||||
|
try {
|
||||||
|
ws.close();
|
||||||
|
} catch {
|
||||||
|
// ignore
|
||||||
|
}
|
||||||
|
resolve("failed");
|
||||||
|
}, input.timeoutMs);
|
||||||
|
|
||||||
|
let completed = false;
|
||||||
|
|
||||||
|
const finish = (status: "ok" | "challenge_only" | "failed") => {
|
||||||
|
if (completed) return;
|
||||||
|
completed = true;
|
||||||
|
clearTimeout(timeout);
|
||||||
|
try {
|
||||||
|
ws.close();
|
||||||
|
} catch {
|
||||||
|
// ignore
|
||||||
|
}
|
||||||
|
resolve(status);
|
||||||
|
};
|
||||||
|
|
||||||
|
ws.on("message", (raw) => {
|
||||||
|
let parsed: unknown;
|
||||||
|
try {
|
||||||
|
parsed = JSON.parse(rawDataToString(raw));
|
||||||
|
} catch {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const event = asRecord(parsed);
|
||||||
|
if (event?.type === "event" && event.event === "connect.challenge") {
|
||||||
|
const nonce = nonEmpty(asRecord(event.payload)?.nonce);
|
||||||
|
if (!nonce) {
|
||||||
|
finish("failed");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const connectId = randomUUID();
|
||||||
|
ws.send(
|
||||||
|
JSON.stringify({
|
||||||
|
type: "req",
|
||||||
|
id: connectId,
|
||||||
|
method: "connect",
|
||||||
|
params: {
|
||||||
|
minProtocol: 3,
|
||||||
|
maxProtocol: 3,
|
||||||
|
client: {
|
||||||
|
id: "gateway-client",
|
||||||
|
version: "paperclip-probe",
|
||||||
|
platform: process.platform,
|
||||||
|
mode: "probe",
|
||||||
|
},
|
||||||
|
role: input.role,
|
||||||
|
scopes: input.scopes,
|
||||||
|
...(input.authToken
|
||||||
|
? {
|
||||||
|
auth: {
|
||||||
|
token: input.authToken,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
: {}),
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (event?.type === "res") {
|
||||||
|
if (event.ok === true) {
|
||||||
|
finish("ok");
|
||||||
|
} else {
|
||||||
|
finish("challenge_only");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
ws.on("error", () => {
|
||||||
|
finish("failed");
|
||||||
|
});
|
||||||
|
|
||||||
|
ws.on("close", () => {
|
||||||
|
if (!completed) finish("failed");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function testEnvironment(
|
||||||
|
ctx: AdapterEnvironmentTestContext,
|
||||||
|
): Promise<AdapterEnvironmentTestResult> {
|
||||||
|
const checks: AdapterEnvironmentCheck[] = [];
|
||||||
|
const config = parseObject(ctx.config);
|
||||||
|
const urlValue = asString(config.url, "").trim();
|
||||||
|
|
||||||
|
if (!urlValue) {
|
||||||
|
checks.push({
|
||||||
|
code: "openclaw_gateway_url_missing",
|
||||||
|
level: "error",
|
||||||
|
message: "OpenClaw gateway adapter requires a WebSocket URL.",
|
||||||
|
hint: "Set adapterConfig.url to ws://host:port (or wss://).",
|
||||||
|
});
|
||||||
|
return {
|
||||||
|
adapterType: ctx.adapterType,
|
||||||
|
status: summarizeStatus(checks),
|
||||||
|
checks,
|
||||||
|
testedAt: new Date().toISOString(),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
let url: URL | null = null;
|
||||||
|
try {
|
||||||
|
url = new URL(urlValue);
|
||||||
|
} catch {
|
||||||
|
checks.push({
|
||||||
|
code: "openclaw_gateway_url_invalid",
|
||||||
|
level: "error",
|
||||||
|
message: `Invalid URL: ${urlValue}`,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
if (url && url.protocol !== "ws:" && url.protocol !== "wss:") {
|
||||||
|
checks.push({
|
||||||
|
code: "openclaw_gateway_url_protocol_invalid",
|
||||||
|
level: "error",
|
||||||
|
message: `Unsupported URL protocol: ${url.protocol}`,
|
||||||
|
hint: "Use ws:// or wss://.",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
if (url) {
|
||||||
|
checks.push({
|
||||||
|
code: "openclaw_gateway_url_valid",
|
||||||
|
level: "info",
|
||||||
|
message: `Configured gateway URL: ${url.toString()}`,
|
||||||
|
});
|
||||||
|
|
||||||
|
if (url.protocol === "ws:" && !isLoopbackHost(url.hostname)) {
|
||||||
|
checks.push({
|
||||||
|
code: "openclaw_gateway_plaintext_remote_ws",
|
||||||
|
level: "warn",
|
||||||
|
message: "Gateway URL uses plaintext ws:// on a non-loopback host.",
|
||||||
|
hint: "Prefer wss:// for remote gateways.",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const headers = toStringRecord(config.headers);
|
||||||
|
const authToken = resolveAuthToken(config, headers);
|
||||||
|
const password = nonEmpty(config.password);
|
||||||
|
const role = nonEmpty(config.role) ?? "operator";
|
||||||
|
const scopes = toStringArray(config.scopes);
|
||||||
|
|
||||||
|
if (authToken || password) {
|
||||||
|
checks.push({
|
||||||
|
code: "openclaw_gateway_auth_present",
|
||||||
|
level: "info",
|
||||||
|
message: "Gateway credentials are configured.",
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
checks.push({
|
||||||
|
code: "openclaw_gateway_auth_missing",
|
||||||
|
level: "warn",
|
||||||
|
message: "No gateway credentials detected in adapter config.",
|
||||||
|
hint: "Set authToken/password or headers.x-openclaw-token for authenticated gateways.",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
if (url && (url.protocol === "ws:" || url.protocol === "wss:")) {
|
||||||
|
try {
|
||||||
|
const probeResult = await probeGateway({
|
||||||
|
url: url.toString(),
|
||||||
|
headers,
|
||||||
|
authToken,
|
||||||
|
role,
|
||||||
|
scopes: scopes.length > 0 ? scopes : ["operator.admin"],
|
||||||
|
timeoutMs: 3_000,
|
||||||
|
});
|
||||||
|
|
||||||
|
if (probeResult === "ok") {
|
||||||
|
checks.push({
|
||||||
|
code: "openclaw_gateway_probe_ok",
|
||||||
|
level: "info",
|
||||||
|
message: "Gateway connect probe succeeded.",
|
||||||
|
});
|
||||||
|
} else if (probeResult === "challenge_only") {
|
||||||
|
checks.push({
|
||||||
|
code: "openclaw_gateway_probe_challenge_only",
|
||||||
|
level: "warn",
|
||||||
|
message: "Gateway challenge was received, but connect probe was rejected.",
|
||||||
|
hint: "Check gateway credentials, scopes, role, and device-auth requirements.",
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
checks.push({
|
||||||
|
code: "openclaw_gateway_probe_failed",
|
||||||
|
level: "warn",
|
||||||
|
message: "Gateway probe failed.",
|
||||||
|
hint: "Verify network reachability and gateway URL from the Paperclip server host.",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
checks.push({
|
||||||
|
code: "openclaw_gateway_probe_error",
|
||||||
|
level: "warn",
|
||||||
|
message: err instanceof Error ? err.message : "Gateway probe failed",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
adapterType: ctx.adapterType,
|
||||||
|
status: summarizeStatus(checks),
|
||||||
|
checks,
|
||||||
|
testedAt: new Date().toISOString(),
|
||||||
|
};
|
||||||
|
}
|
||||||
16
packages/adapters/openclaw-gateway/src/shared/stream.ts
Normal file
16
packages/adapters/openclaw-gateway/src/shared/stream.ts
Normal file
@@ -0,0 +1,16 @@
|
|||||||
|
export function normalizeOpenClawGatewayStreamLine(rawLine: string): {
|
||||||
|
stream: "stdout" | "stderr" | null;
|
||||||
|
line: string;
|
||||||
|
} {
|
||||||
|
const trimmed = rawLine.trim();
|
||||||
|
if (!trimmed) return { stream: null, line: "" };
|
||||||
|
|
||||||
|
const prefixed = trimmed.match(/^(stdout|stderr)\s*[:=]?\s*(.*)$/i);
|
||||||
|
if (!prefixed) {
|
||||||
|
return { stream: null, line: trimmed };
|
||||||
|
}
|
||||||
|
|
||||||
|
const stream = prefixed[1]?.toLowerCase() === "stderr" ? "stderr" : "stdout";
|
||||||
|
const line = (prefixed[2] ?? "").trim();
|
||||||
|
return { stream, line };
|
||||||
|
}
|
||||||
13
packages/adapters/openclaw-gateway/src/ui/build-config.ts
Normal file
13
packages/adapters/openclaw-gateway/src/ui/build-config.ts
Normal file
@@ -0,0 +1,13 @@
|
|||||||
|
import type { CreateConfigValues } from "@paperclipai/adapter-utils";
|
||||||
|
|
||||||
|
export function buildOpenClawGatewayConfig(v: CreateConfigValues): Record<string, unknown> {
|
||||||
|
const ac: Record<string, unknown> = {};
|
||||||
|
if (v.url) ac.url = v.url;
|
||||||
|
ac.timeoutSec = 120;
|
||||||
|
ac.waitTimeoutMs = 120000;
|
||||||
|
ac.sessionKeyStrategy = "fixed";
|
||||||
|
ac.sessionKey = "paperclip";
|
||||||
|
ac.role = "operator";
|
||||||
|
ac.scopes = ["operator.admin"];
|
||||||
|
return ac;
|
||||||
|
}
|
||||||
2
packages/adapters/openclaw-gateway/src/ui/index.ts
Normal file
2
packages/adapters/openclaw-gateway/src/ui/index.ts
Normal file
@@ -0,0 +1,2 @@
|
|||||||
|
export { parseOpenClawGatewayStdoutLine } from "./parse-stdout.js";
|
||||||
|
export { buildOpenClawGatewayConfig } from "./build-config.js";
|
||||||
75
packages/adapters/openclaw-gateway/src/ui/parse-stdout.ts
Normal file
75
packages/adapters/openclaw-gateway/src/ui/parse-stdout.ts
Normal file
@@ -0,0 +1,75 @@
|
|||||||
|
import type { TranscriptEntry } from "@paperclipai/adapter-utils";
|
||||||
|
import { normalizeOpenClawGatewayStreamLine } from "../shared/stream.js";
|
||||||
|
|
||||||
|
function safeJsonParse(text: string): unknown {
|
||||||
|
try {
|
||||||
|
return JSON.parse(text);
|
||||||
|
} catch {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function asRecord(value: unknown): Record<string, unknown> | null {
|
||||||
|
if (typeof value !== "object" || value === null || Array.isArray(value)) return null;
|
||||||
|
return value as Record<string, unknown>;
|
||||||
|
}
|
||||||
|
|
||||||
|
function asString(value: unknown): string {
|
||||||
|
return typeof value === "string" ? value : "";
|
||||||
|
}
|
||||||
|
|
||||||
|
function parseAgentEventLine(line: string, ts: string): TranscriptEntry[] {
|
||||||
|
const match = line.match(/^\[openclaw-gateway:event\]\s+run=([^\s]+)\s+stream=([^\s]+)\s+data=(.*)$/s);
|
||||||
|
if (!match) return [{ kind: "stdout", ts, text: line }];
|
||||||
|
|
||||||
|
const stream = asString(match[2]).toLowerCase();
|
||||||
|
const data = asRecord(safeJsonParse(asString(match[3]).trim()));
|
||||||
|
|
||||||
|
if (stream === "assistant") {
|
||||||
|
const delta = asString(data?.delta);
|
||||||
|
if (delta.length > 0) {
|
||||||
|
return [{ kind: "assistant", ts, text: delta, delta: true }];
|
||||||
|
}
|
||||||
|
|
||||||
|
const text = asString(data?.text);
|
||||||
|
if (text.length > 0) {
|
||||||
|
return [{ kind: "assistant", ts, text }];
|
||||||
|
}
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
if (stream === "error") {
|
||||||
|
const message = asString(data?.error) || asString(data?.message);
|
||||||
|
return message ? [{ kind: "stderr", ts, text: message }] : [];
|
||||||
|
}
|
||||||
|
|
||||||
|
if (stream === "lifecycle") {
|
||||||
|
const phase = asString(data?.phase).toLowerCase();
|
||||||
|
const message = asString(data?.error) || asString(data?.message);
|
||||||
|
if ((phase === "error" || phase === "failed" || phase === "cancelled") && message) {
|
||||||
|
return [{ kind: "stderr", ts, text: message }];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
export function parseOpenClawGatewayStdoutLine(line: string, ts: string): TranscriptEntry[] {
|
||||||
|
const normalized = normalizeOpenClawGatewayStreamLine(line);
|
||||||
|
if (normalized.stream === "stderr") {
|
||||||
|
return [{ kind: "stderr", ts, text: normalized.line }];
|
||||||
|
}
|
||||||
|
|
||||||
|
const trimmed = normalized.line.trim();
|
||||||
|
if (!trimmed) return [];
|
||||||
|
|
||||||
|
if (trimmed.startsWith("[openclaw-gateway:event]")) {
|
||||||
|
return parseAgentEventLine(trimmed, ts);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (trimmed.startsWith("[openclaw-gateway]")) {
|
||||||
|
return [{ kind: "system", ts, text: trimmed.replace(/^\[openclaw-gateway\]\s*/, "") }];
|
||||||
|
}
|
||||||
|
|
||||||
|
return [{ kind: "stdout", ts, text: normalized.line }];
|
||||||
|
}
|
||||||
8
packages/adapters/openclaw-gateway/tsconfig.json
Normal file
8
packages/adapters/openclaw-gateway/tsconfig.json
Normal file
@@ -0,0 +1,8 @@
|
|||||||
|
{
|
||||||
|
"extends": "../../../tsconfig.json",
|
||||||
|
"compilerOptions": {
|
||||||
|
"outDir": "dist",
|
||||||
|
"rootDir": "src"
|
||||||
|
},
|
||||||
|
"include": ["src"]
|
||||||
|
}
|
||||||
@@ -290,15 +290,21 @@ export function buildWakeText(payload: WakePayload, paperclipEnv: Record<string,
|
|||||||
envLines.push(`${key}=${value}`);
|
envLines.push(`${key}=${value}`);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const issueIdHint = payload.taskId ?? payload.issueId ?? "";
|
||||||
|
const apiBaseHint = paperclipEnv.PAPERCLIP_API_URL ?? "<set PAPERCLIP_API_URL>";
|
||||||
|
|
||||||
const lines = [
|
const lines = [
|
||||||
"Paperclip wake event for a cloud adapter.",
|
"Paperclip wake event for a cloud adapter.",
|
||||||
"",
|
"",
|
||||||
|
"Run this procedure now. Do not guess undocumented endpoints and do not ask for additional heartbeat docs.",
|
||||||
|
"",
|
||||||
"Set these values in your run context:",
|
"Set these values in your run context:",
|
||||||
...envLines,
|
...envLines,
|
||||||
`PAPERCLIP_API_KEY=<token from ${claimedApiKeyPath}>`,
|
`PAPERCLIP_API_KEY=<token from ${claimedApiKeyPath}>`,
|
||||||
"",
|
"",
|
||||||
`Load PAPERCLIP_API_KEY from ${claimedApiKeyPath} (the token you saved after claim-api-key).`,
|
`Load PAPERCLIP_API_KEY from ${claimedApiKeyPath} (the token you saved after claim-api-key).`,
|
||||||
"",
|
"",
|
||||||
|
`api_base=${apiBaseHint}`,
|
||||||
`task_id=${payload.taskId ?? ""}`,
|
`task_id=${payload.taskId ?? ""}`,
|
||||||
`issue_id=${payload.issueId ?? ""}`,
|
`issue_id=${payload.issueId ?? ""}`,
|
||||||
`wake_reason=${payload.wakeReason ?? ""}`,
|
`wake_reason=${payload.wakeReason ?? ""}`,
|
||||||
@@ -306,9 +312,34 @@ export function buildWakeText(payload: WakePayload, paperclipEnv: Record<string,
|
|||||||
`approval_id=${payload.approvalId ?? ""}`,
|
`approval_id=${payload.approvalId ?? ""}`,
|
||||||
`approval_status=${payload.approvalStatus ?? ""}`,
|
`approval_status=${payload.approvalStatus ?? ""}`,
|
||||||
`linked_issue_ids=${payload.issueIds.join(",")}`,
|
`linked_issue_ids=${payload.issueIds.join(",")}`,
|
||||||
|
"",
|
||||||
|
"HTTP rules:",
|
||||||
|
"- Use Authorization: Bearer $PAPERCLIP_API_KEY on every API call.",
|
||||||
|
"- Use X-Paperclip-Run-Id: $PAPERCLIP_RUN_ID on every mutating API call.",
|
||||||
|
"- Use only /api endpoints listed below.",
|
||||||
|
"- Do NOT call guessed endpoints like /api/cloud-adapter/*, /api/cloud-adapters/*, /api/adapters/cloud/*, or /api/heartbeat.",
|
||||||
|
"",
|
||||||
|
"Workflow:",
|
||||||
|
"1) GET /api/agents/me",
|
||||||
|
`2) Determine issueId: PAPERCLIP_TASK_ID if present, otherwise issue_id (${issueIdHint}).`,
|
||||||
|
"3) If issueId exists:",
|
||||||
|
" - POST /api/issues/{issueId}/checkout with {\"agentId\":\"$PAPERCLIP_AGENT_ID\",\"expectedStatuses\":[\"todo\",\"backlog\",\"blocked\"]}",
|
||||||
|
" - GET /api/issues/{issueId}",
|
||||||
|
" - GET /api/issues/{issueId}/comments",
|
||||||
|
" - Execute the issue instructions exactly.",
|
||||||
|
" - If instructions require a comment, POST /api/issues/{issueId}/comments with {\"body\":\"...\"}.",
|
||||||
|
" - PATCH /api/issues/{issueId} with {\"status\":\"done\",\"comment\":\"what changed and why\"}.",
|
||||||
|
"4) If issueId does not exist:",
|
||||||
|
" - GET /api/companies/$PAPERCLIP_COMPANY_ID/issues?assigneeAgentId=$PAPERCLIP_AGENT_ID&status=todo,in_progress,blocked",
|
||||||
|
" - Pick in_progress first, then todo, then blocked, then execute step 3.",
|
||||||
|
"",
|
||||||
|
"Useful endpoints for issue work:",
|
||||||
|
"- POST /api/issues/{issueId}/comments",
|
||||||
|
"- PATCH /api/issues/{issueId}",
|
||||||
|
"- POST /api/companies/{companyId}/issues (when asked to create a new issue)",
|
||||||
|
"",
|
||||||
|
"Complete the workflow in this run.",
|
||||||
];
|
];
|
||||||
|
|
||||||
lines.push("", "Run your Paperclip heartbeat procedure now.");
|
|
||||||
return lines.join("\n");
|
return lines.join("\n");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -30,6 +30,7 @@ export const AGENT_ADAPTER_TYPES = [
|
|||||||
"pi_local",
|
"pi_local",
|
||||||
"cursor",
|
"cursor",
|
||||||
"openclaw",
|
"openclaw",
|
||||||
|
"openclaw_gateway",
|
||||||
] as const;
|
] as const;
|
||||||
export type AgentAdapterType = (typeof AGENT_ADAPTER_TYPES)[number];
|
export type AgentAdapterType = (typeof AGENT_ADAPTER_TYPES)[number];
|
||||||
|
|
||||||
|
|||||||
958
pnpm-lock.yaml
generated
958
pnpm-lock.yaml
generated
File diff suppressed because it is too large
Load Diff
@@ -56,6 +56,7 @@ require_cmd grep
|
|||||||
|
|
||||||
OPENCLAW_REPO_URL="${OPENCLAW_REPO_URL:-https://github.com/openclaw/openclaw.git}"
|
OPENCLAW_REPO_URL="${OPENCLAW_REPO_URL:-https://github.com/openclaw/openclaw.git}"
|
||||||
OPENCLAW_DOCKER_DIR="${OPENCLAW_DOCKER_DIR:-/tmp/openclaw-docker}"
|
OPENCLAW_DOCKER_DIR="${OPENCLAW_DOCKER_DIR:-/tmp/openclaw-docker}"
|
||||||
|
OPENCLAW_REPO_REF="${OPENCLAW_REPO_REF:-v2026.3.2}"
|
||||||
OPENCLAW_IMAGE="${OPENCLAW_IMAGE:-openclaw:local}"
|
OPENCLAW_IMAGE="${OPENCLAW_IMAGE:-openclaw:local}"
|
||||||
OPENCLAW_TMP_DIR="${OPENCLAW_TMP_DIR:-${TMPDIR:-/tmp}}"
|
OPENCLAW_TMP_DIR="${OPENCLAW_TMP_DIR:-${TMPDIR:-/tmp}}"
|
||||||
OPENCLAW_TMP_DIR="${OPENCLAW_TMP_DIR%/}"
|
OPENCLAW_TMP_DIR="${OPENCLAW_TMP_DIR%/}"
|
||||||
@@ -101,14 +102,23 @@ fi
|
|||||||
|
|
||||||
log "preparing OpenClaw repo at $OPENCLAW_DOCKER_DIR"
|
log "preparing OpenClaw repo at $OPENCLAW_DOCKER_DIR"
|
||||||
if [[ -d "$OPENCLAW_DOCKER_DIR/.git" ]]; then
|
if [[ -d "$OPENCLAW_DOCKER_DIR/.git" ]]; then
|
||||||
git -C "$OPENCLAW_DOCKER_DIR" fetch --quiet origin || true
|
git -C "$OPENCLAW_DOCKER_DIR" fetch --quiet --tags origin || true
|
||||||
git -C "$OPENCLAW_DOCKER_DIR" checkout --quiet main || true
|
|
||||||
git -C "$OPENCLAW_DOCKER_DIR" pull --ff-only --quiet origin main || true
|
|
||||||
else
|
else
|
||||||
rm -rf "$OPENCLAW_DOCKER_DIR"
|
rm -rf "$OPENCLAW_DOCKER_DIR"
|
||||||
git clone "$OPENCLAW_REPO_URL" "$OPENCLAW_DOCKER_DIR"
|
git clone "$OPENCLAW_REPO_URL" "$OPENCLAW_DOCKER_DIR"
|
||||||
|
git -C "$OPENCLAW_DOCKER_DIR" fetch --quiet --tags origin || true
|
||||||
fi
|
fi
|
||||||
|
|
||||||
|
resolved_openclaw_ref=""
|
||||||
|
if git -C "$OPENCLAW_DOCKER_DIR" rev-parse --verify --quiet "origin/$OPENCLAW_REPO_REF" >/dev/null; then
|
||||||
|
resolved_openclaw_ref="origin/$OPENCLAW_REPO_REF"
|
||||||
|
elif git -C "$OPENCLAW_DOCKER_DIR" rev-parse --verify --quiet "$OPENCLAW_REPO_REF" >/dev/null; then
|
||||||
|
resolved_openclaw_ref="$OPENCLAW_REPO_REF"
|
||||||
|
fi
|
||||||
|
[[ -n "$resolved_openclaw_ref" ]] || fail "unable to resolve OPENCLAW_REPO_REF=$OPENCLAW_REPO_REF in $OPENCLAW_DOCKER_DIR"
|
||||||
|
git -C "$OPENCLAW_DOCKER_DIR" checkout --quiet "$resolved_openclaw_ref"
|
||||||
|
log "using OpenClaw ref $resolved_openclaw_ref ($(git -C "$OPENCLAW_DOCKER_DIR" rev-parse --short HEAD))"
|
||||||
|
|
||||||
if [[ "$OPENCLAW_BUILD" == "1" ]]; then
|
if [[ "$OPENCLAW_BUILD" == "1" ]]; then
|
||||||
log "building Docker image $OPENCLAW_IMAGE"
|
log "building Docker image $OPENCLAW_IMAGE"
|
||||||
docker build -t "$OPENCLAW_IMAGE" -f "$OPENCLAW_DOCKER_DIR/Dockerfile" "$OPENCLAW_DOCKER_DIR"
|
docker build -t "$OPENCLAW_IMAGE" -f "$OPENCLAW_DOCKER_DIR/Dockerfile" "$OPENCLAW_DOCKER_DIR"
|
||||||
|
|||||||
868
scripts/smoke/openclaw-gateway-e2e.sh
Executable file
868
scripts/smoke/openclaw-gateway-e2e.sh
Executable file
@@ -0,0 +1,868 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
log() {
|
||||||
|
echo "[openclaw-gateway-e2e] $*"
|
||||||
|
}
|
||||||
|
|
||||||
|
warn() {
|
||||||
|
echo "[openclaw-gateway-e2e] WARN: $*" >&2
|
||||||
|
}
|
||||||
|
|
||||||
|
fail() {
|
||||||
|
echo "[openclaw-gateway-e2e] ERROR: $*" >&2
|
||||||
|
exit 1
|
||||||
|
}
|
||||||
|
|
||||||
|
require_cmd() {
|
||||||
|
local cmd="$1"
|
||||||
|
command -v "$cmd" >/dev/null 2>&1 || fail "missing required command: $cmd"
|
||||||
|
}
|
||||||
|
|
||||||
|
require_cmd curl
|
||||||
|
require_cmd jq
|
||||||
|
require_cmd docker
|
||||||
|
require_cmd node
|
||||||
|
require_cmd shasum
|
||||||
|
|
||||||
|
PAPERCLIP_API_URL="${PAPERCLIP_API_URL:-http://127.0.0.1:3100}"
|
||||||
|
API_BASE="${PAPERCLIP_API_URL%/}/api"
|
||||||
|
|
||||||
|
COMPANY_SELECTOR="${COMPANY_SELECTOR:-CLA}"
|
||||||
|
OPENCLAW_AGENT_NAME="${OPENCLAW_AGENT_NAME:-OpenClaw Gateway Smoke Agent}"
|
||||||
|
OPENCLAW_GATEWAY_URL="${OPENCLAW_GATEWAY_URL:-ws://127.0.0.1:18789}"
|
||||||
|
OPENCLAW_GATEWAY_TOKEN="${OPENCLAW_GATEWAY_TOKEN:-}"
|
||||||
|
OPENCLAW_TMP_DIR="${OPENCLAW_TMP_DIR:-${TMPDIR:-/tmp}}"
|
||||||
|
OPENCLAW_TMP_DIR="${OPENCLAW_TMP_DIR%/}"
|
||||||
|
OPENCLAW_TMP_DIR="${OPENCLAW_TMP_DIR:-/tmp}"
|
||||||
|
OPENCLAW_CONFIG_DIR="${OPENCLAW_CONFIG_DIR:-${OPENCLAW_TMP_DIR}/openclaw-paperclip-smoke}"
|
||||||
|
OPENCLAW_WORKSPACE_DIR="${OPENCLAW_WORKSPACE_DIR:-${OPENCLAW_CONFIG_DIR}/workspace}"
|
||||||
|
OPENCLAW_CONTAINER_NAME="${OPENCLAW_CONTAINER_NAME:-openclaw-docker-openclaw-gateway-1}"
|
||||||
|
OPENCLAW_IMAGE="${OPENCLAW_IMAGE:-openclaw:local}"
|
||||||
|
OPENCLAW_DOCKER_DIR="${OPENCLAW_DOCKER_DIR:-/tmp/openclaw-docker}"
|
||||||
|
OPENCLAW_RESET_DOCKER="${OPENCLAW_RESET_DOCKER:-1}"
|
||||||
|
OPENCLAW_BUILD="${OPENCLAW_BUILD:-1}"
|
||||||
|
OPENCLAW_WAIT_SECONDS="${OPENCLAW_WAIT_SECONDS:-60}"
|
||||||
|
OPENCLAW_RESET_STATE="${OPENCLAW_RESET_STATE:-1}"
|
||||||
|
|
||||||
|
PAPERCLIP_API_URL_FOR_OPENCLAW="${PAPERCLIP_API_URL_FOR_OPENCLAW:-http://host.docker.internal:3100}"
|
||||||
|
CASE_TIMEOUT_SEC="${CASE_TIMEOUT_SEC:-420}"
|
||||||
|
RUN_TIMEOUT_SEC="${RUN_TIMEOUT_SEC:-300}"
|
||||||
|
STRICT_CASES="${STRICT_CASES:-1}"
|
||||||
|
AUTO_INSTALL_SKILL="${AUTO_INSTALL_SKILL:-1}"
|
||||||
|
OPENCLAW_DIAG_DIR="${OPENCLAW_DIAG_DIR:-/tmp/openclaw-gateway-e2e-diag-$(date +%Y%m%d-%H%M%S)}"
|
||||||
|
OPENCLAW_ADAPTER_TIMEOUT_SEC="${OPENCLAW_ADAPTER_TIMEOUT_SEC:-120}"
|
||||||
|
OPENCLAW_ADAPTER_WAIT_TIMEOUT_MS="${OPENCLAW_ADAPTER_WAIT_TIMEOUT_MS:-120000}"
|
||||||
|
PAYLOAD_TEMPLATE_MESSAGE_APPEND="${PAYLOAD_TEMPLATE_MESSAGE_APPEND:-}"
|
||||||
|
|
||||||
|
AUTH_HEADERS=()
|
||||||
|
if [[ -n "${PAPERCLIP_AUTH_HEADER:-}" ]]; then
|
||||||
|
AUTH_HEADERS+=( -H "Authorization: ${PAPERCLIP_AUTH_HEADER}" )
|
||||||
|
fi
|
||||||
|
if [[ -n "${PAPERCLIP_COOKIE:-}" ]]; then
|
||||||
|
AUTH_HEADERS+=( -H "Cookie: ${PAPERCLIP_COOKIE}" )
|
||||||
|
PAPERCLIP_BROWSER_ORIGIN="${PAPERCLIP_BROWSER_ORIGIN:-${PAPERCLIP_API_URL%/}}"
|
||||||
|
AUTH_HEADERS+=( -H "Origin: ${PAPERCLIP_BROWSER_ORIGIN}" -H "Referer: ${PAPERCLIP_BROWSER_ORIGIN}/" )
|
||||||
|
fi
|
||||||
|
|
||||||
|
RESPONSE_CODE=""
|
||||||
|
RESPONSE_BODY=""
|
||||||
|
COMPANY_ID=""
|
||||||
|
AGENT_ID=""
|
||||||
|
AGENT_API_KEY=""
|
||||||
|
JOIN_REQUEST_ID=""
|
||||||
|
INVITE_ID=""
|
||||||
|
RUN_ID=""
|
||||||
|
|
||||||
|
CASE_A_ISSUE_ID=""
|
||||||
|
CASE_B_ISSUE_ID=""
|
||||||
|
CASE_C_ISSUE_ID=""
|
||||||
|
CASE_C_CREATED_ISSUE_ID=""
|
||||||
|
|
||||||
|
api_request() {
|
||||||
|
local method="$1"
|
||||||
|
local path="$2"
|
||||||
|
local data="${3-}"
|
||||||
|
local tmp
|
||||||
|
tmp="$(mktemp)"
|
||||||
|
|
||||||
|
local url
|
||||||
|
if [[ "$path" == http://* || "$path" == https://* ]]; then
|
||||||
|
url="$path"
|
||||||
|
elif [[ "$path" == /api/* ]]; then
|
||||||
|
url="${PAPERCLIP_API_URL%/}${path}"
|
||||||
|
else
|
||||||
|
url="${API_BASE}${path}"
|
||||||
|
fi
|
||||||
|
|
||||||
|
if [[ -n "$data" ]]; then
|
||||||
|
if (( ${#AUTH_HEADERS[@]} > 0 )); then
|
||||||
|
RESPONSE_CODE="$(curl -sS -o "$tmp" -w "%{http_code}" -X "$method" "${AUTH_HEADERS[@]}" -H "Content-Type: application/json" "$url" --data "$data")"
|
||||||
|
else
|
||||||
|
RESPONSE_CODE="$(curl -sS -o "$tmp" -w "%{http_code}" -X "$method" -H "Content-Type: application/json" "$url" --data "$data")"
|
||||||
|
fi
|
||||||
|
else
|
||||||
|
if (( ${#AUTH_HEADERS[@]} > 0 )); then
|
||||||
|
RESPONSE_CODE="$(curl -sS -o "$tmp" -w "%{http_code}" -X "$method" "${AUTH_HEADERS[@]}" "$url")"
|
||||||
|
else
|
||||||
|
RESPONSE_CODE="$(curl -sS -o "$tmp" -w "%{http_code}" -X "$method" "$url")"
|
||||||
|
fi
|
||||||
|
fi
|
||||||
|
|
||||||
|
RESPONSE_BODY="$(cat "$tmp")"
|
||||||
|
rm -f "$tmp"
|
||||||
|
}
|
||||||
|
|
||||||
|
capture_run_diagnostics() {
|
||||||
|
local run_id="$1"
|
||||||
|
local label="${2:-run}"
|
||||||
|
[[ -n "$run_id" ]] || return 0
|
||||||
|
|
||||||
|
mkdir -p "$OPENCLAW_DIAG_DIR"
|
||||||
|
|
||||||
|
api_request "GET" "/heartbeat-runs/${run_id}/events?limit=1000"
|
||||||
|
if [[ "$RESPONSE_CODE" == "200" ]]; then
|
||||||
|
printf "%s\n" "$RESPONSE_BODY" > "${OPENCLAW_DIAG_DIR}/${label}-${run_id}-events.json"
|
||||||
|
else
|
||||||
|
warn "could not fetch events for run ${run_id} (HTTP ${RESPONSE_CODE})"
|
||||||
|
fi
|
||||||
|
|
||||||
|
api_request "GET" "/heartbeat-runs/${run_id}/log?limitBytes=524288"
|
||||||
|
if [[ "$RESPONSE_CODE" == "200" ]]; then
|
||||||
|
printf "%s\n" "$RESPONSE_BODY" > "${OPENCLAW_DIAG_DIR}/${label}-${run_id}-log.json"
|
||||||
|
jq -r '.content // ""' <<<"$RESPONSE_BODY" > "${OPENCLAW_DIAG_DIR}/${label}-${run_id}-log.txt" 2>/dev/null || true
|
||||||
|
else
|
||||||
|
warn "could not fetch log for run ${run_id} (HTTP ${RESPONSE_CODE})"
|
||||||
|
fi
|
||||||
|
}
|
||||||
|
|
||||||
|
capture_issue_diagnostics() {
|
||||||
|
local issue_id="$1"
|
||||||
|
local label="${2:-issue}"
|
||||||
|
[[ -n "$issue_id" ]] || return 0
|
||||||
|
mkdir -p "$OPENCLAW_DIAG_DIR"
|
||||||
|
|
||||||
|
api_request "GET" "/issues/${issue_id}"
|
||||||
|
if [[ "$RESPONSE_CODE" == "200" ]]; then
|
||||||
|
printf "%s\n" "$RESPONSE_BODY" > "${OPENCLAW_DIAG_DIR}/${label}-${issue_id}.json"
|
||||||
|
fi
|
||||||
|
|
||||||
|
api_request "GET" "/issues/${issue_id}/comments"
|
||||||
|
if [[ "$RESPONSE_CODE" == "200" ]]; then
|
||||||
|
printf "%s\n" "$RESPONSE_BODY" > "${OPENCLAW_DIAG_DIR}/${label}-${issue_id}-comments.json"
|
||||||
|
fi
|
||||||
|
}
|
||||||
|
|
||||||
|
capture_openclaw_container_logs() {
|
||||||
|
mkdir -p "$OPENCLAW_DIAG_DIR"
|
||||||
|
local container
|
||||||
|
container="$(detect_openclaw_container || true)"
|
||||||
|
if [[ -z "$container" ]]; then
|
||||||
|
warn "could not detect OpenClaw container for diagnostics"
|
||||||
|
return 0
|
||||||
|
fi
|
||||||
|
docker logs --tail=1200 "$container" > "${OPENCLAW_DIAG_DIR}/openclaw-container.log" 2>&1 || true
|
||||||
|
}
|
||||||
|
|
||||||
|
assert_status() {
|
||||||
|
local expected="$1"
|
||||||
|
if [[ "$RESPONSE_CODE" != "$expected" ]]; then
|
||||||
|
echo "$RESPONSE_BODY" >&2
|
||||||
|
fail "expected HTTP ${expected}, got ${RESPONSE_CODE}"
|
||||||
|
fi
|
||||||
|
}
|
||||||
|
|
||||||
|
require_board_auth() {
|
||||||
|
if [[ ${#AUTH_HEADERS[@]} -eq 0 ]]; then
|
||||||
|
fail "board auth required. Set PAPERCLIP_COOKIE or PAPERCLIP_AUTH_HEADER."
|
||||||
|
fi
|
||||||
|
api_request "GET" "/companies"
|
||||||
|
if [[ "$RESPONSE_CODE" != "200" ]]; then
|
||||||
|
echo "$RESPONSE_BODY" >&2
|
||||||
|
fail "board auth invalid for /api/companies (HTTP ${RESPONSE_CODE})"
|
||||||
|
fi
|
||||||
|
}
|
||||||
|
|
||||||
|
maybe_cleanup_openclaw_docker() {
|
||||||
|
if [[ "$OPENCLAW_RESET_DOCKER" != "1" ]]; then
|
||||||
|
log "OPENCLAW_RESET_DOCKER=${OPENCLAW_RESET_DOCKER}; skipping docker cleanup"
|
||||||
|
return
|
||||||
|
fi
|
||||||
|
|
||||||
|
log "cleaning OpenClaw docker state"
|
||||||
|
if [[ -d "$OPENCLAW_DOCKER_DIR" ]]; then
|
||||||
|
docker compose -f "$OPENCLAW_DOCKER_DIR/docker-compose.yml" down --remove-orphans >/dev/null 2>&1 || true
|
||||||
|
fi
|
||||||
|
if docker ps -a --format '{{.Names}}' | grep -qx "$OPENCLAW_CONTAINER_NAME"; then
|
||||||
|
docker rm -f "$OPENCLAW_CONTAINER_NAME" >/dev/null 2>&1 || true
|
||||||
|
fi
|
||||||
|
docker image rm "$OPENCLAW_IMAGE" >/dev/null 2>&1 || true
|
||||||
|
}
|
||||||
|
|
||||||
|
start_openclaw_docker() {
|
||||||
|
log "starting clean OpenClaw docker"
|
||||||
|
OPENCLAW_CONFIG_DIR="$OPENCLAW_CONFIG_DIR" OPENCLAW_WORKSPACE_DIR="$OPENCLAW_WORKSPACE_DIR" \
|
||||||
|
OPENCLAW_RESET_STATE="$OPENCLAW_RESET_STATE" OPENCLAW_BUILD="$OPENCLAW_BUILD" OPENCLAW_WAIT_SECONDS="$OPENCLAW_WAIT_SECONDS" \
|
||||||
|
./scripts/smoke/openclaw-docker-ui.sh
|
||||||
|
}
|
||||||
|
|
||||||
|
wait_http_ready() {
|
||||||
|
local url="$1"
|
||||||
|
local timeout_sec="$2"
|
||||||
|
local started_at now code
|
||||||
|
started_at="$(date +%s)"
|
||||||
|
while true; do
|
||||||
|
code="$(curl -sS -o /dev/null -w "%{http_code}" "$url" || true)"
|
||||||
|
if [[ "$code" == "200" ]]; then
|
||||||
|
return 0
|
||||||
|
fi
|
||||||
|
now="$(date +%s)"
|
||||||
|
if (( now - started_at >= timeout_sec )); then
|
||||||
|
return 1
|
||||||
|
fi
|
||||||
|
sleep 1
|
||||||
|
done
|
||||||
|
}
|
||||||
|
|
||||||
|
detect_openclaw_container() {
|
||||||
|
if docker ps --format '{{.Names}}' | grep -qx "$OPENCLAW_CONTAINER_NAME"; then
|
||||||
|
echo "$OPENCLAW_CONTAINER_NAME"
|
||||||
|
return 0
|
||||||
|
fi
|
||||||
|
|
||||||
|
local detected
|
||||||
|
detected="$(docker ps --format '{{.Names}}' | grep 'openclaw-gateway' | head -n1 || true)"
|
||||||
|
if [[ -n "$detected" ]]; then
|
||||||
|
echo "$detected"
|
||||||
|
return 0
|
||||||
|
fi
|
||||||
|
|
||||||
|
return 1
|
||||||
|
}
|
||||||
|
|
||||||
|
detect_gateway_token() {
|
||||||
|
if [[ -n "$OPENCLAW_GATEWAY_TOKEN" ]]; then
|
||||||
|
echo "$OPENCLAW_GATEWAY_TOKEN"
|
||||||
|
return 0
|
||||||
|
fi
|
||||||
|
|
||||||
|
local config_path
|
||||||
|
config_path="${OPENCLAW_CONFIG_DIR%/}/openclaw.json"
|
||||||
|
if [[ -f "$config_path" ]]; then
|
||||||
|
local token
|
||||||
|
token="$(jq -r '.gateway.auth.token // empty' "$config_path")"
|
||||||
|
if [[ -n "$token" ]]; then
|
||||||
|
echo "$token"
|
||||||
|
return 0
|
||||||
|
fi
|
||||||
|
fi
|
||||||
|
|
||||||
|
local container
|
||||||
|
container="$(detect_openclaw_container || true)"
|
||||||
|
if [[ -n "$container" ]]; then
|
||||||
|
local token_from_container
|
||||||
|
token_from_container="$(docker exec "$container" sh -lc "node -e 'const fs=require(\"fs\");const c=JSON.parse(fs.readFileSync(\"/home/node/.openclaw/openclaw.json\",\"utf8\"));process.stdout.write(c.gateway?.auth?.token||\"\");'" 2>/dev/null || true)"
|
||||||
|
if [[ -n "$token_from_container" ]]; then
|
||||||
|
echo "$token_from_container"
|
||||||
|
return 0
|
||||||
|
fi
|
||||||
|
fi
|
||||||
|
|
||||||
|
return 1
|
||||||
|
}
|
||||||
|
|
||||||
|
hash_prefix() {
|
||||||
|
local value="$1"
|
||||||
|
printf "%s" "$value" | shasum -a 256 | awk '{print $1}' | cut -c1-12
|
||||||
|
}
|
||||||
|
|
||||||
|
probe_gateway_ws() {
|
||||||
|
local url="$1"
|
||||||
|
local token="$2"
|
||||||
|
|
||||||
|
node - "$url" "$token" <<'NODE'
|
||||||
|
const WebSocket = require("ws");
|
||||||
|
const url = process.argv[2];
|
||||||
|
const token = process.argv[3];
|
||||||
|
|
||||||
|
const ws = new WebSocket(url, { headers: { Authorization: `Bearer ${token}` } });
|
||||||
|
const timeout = setTimeout(() => {
|
||||||
|
console.error("gateway probe timed out");
|
||||||
|
process.exit(2);
|
||||||
|
}, 8000);
|
||||||
|
|
||||||
|
ws.on("message", (raw) => {
|
||||||
|
try {
|
||||||
|
const message = JSON.parse(String(raw));
|
||||||
|
if (message?.type === "event" && message?.event === "connect.challenge") {
|
||||||
|
clearTimeout(timeout);
|
||||||
|
ws.close();
|
||||||
|
process.exit(0);
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
// ignore
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
ws.on("error", (err) => {
|
||||||
|
clearTimeout(timeout);
|
||||||
|
console.error(err?.message || String(err));
|
||||||
|
process.exit(1);
|
||||||
|
});
|
||||||
|
NODE
|
||||||
|
}
|
||||||
|
|
||||||
|
resolve_company_id() {
|
||||||
|
api_request "GET" "/companies"
|
||||||
|
assert_status "200"
|
||||||
|
|
||||||
|
local selector
|
||||||
|
selector="$(printf "%s" "$COMPANY_SELECTOR" | tr '[:lower:]' '[:upper:]')"
|
||||||
|
|
||||||
|
COMPANY_ID="$(jq -r --arg sel "$selector" '
|
||||||
|
map(select(
|
||||||
|
((.id // "") | ascii_upcase) == $sel or
|
||||||
|
((.name // "") | ascii_upcase) == $sel or
|
||||||
|
((.issuePrefix // "") | ascii_upcase) == $sel
|
||||||
|
))
|
||||||
|
| .[0].id // empty
|
||||||
|
' <<<"$RESPONSE_BODY")"
|
||||||
|
|
||||||
|
if [[ -z "$COMPANY_ID" ]]; then
|
||||||
|
local available
|
||||||
|
available="$(jq -r '.[] | "- id=\(.id) issuePrefix=\(.issuePrefix // "") name=\(.name // "")"' <<<"$RESPONSE_BODY")"
|
||||||
|
echo "$available" >&2
|
||||||
|
fail "could not find company for selector '${COMPANY_SELECTOR}'"
|
||||||
|
fi
|
||||||
|
|
||||||
|
log "resolved company ${COMPANY_ID} from selector ${COMPANY_SELECTOR}"
|
||||||
|
}
|
||||||
|
|
||||||
|
cleanup_openclaw_agents() {
|
||||||
|
api_request "GET" "/companies/${COMPANY_ID}/agents"
|
||||||
|
assert_status "200"
|
||||||
|
|
||||||
|
local ids
|
||||||
|
ids="$(jq -r '.[] | select((.adapterType == "openclaw" or .adapterType == "openclaw_gateway")) | .id' <<<"$RESPONSE_BODY")"
|
||||||
|
if [[ -z "$ids" ]]; then
|
||||||
|
log "no prior OpenClaw agents to cleanup"
|
||||||
|
return
|
||||||
|
fi
|
||||||
|
|
||||||
|
while IFS= read -r id; do
|
||||||
|
[[ -n "$id" ]] || continue
|
||||||
|
log "terminating prior OpenClaw agent ${id}"
|
||||||
|
api_request "POST" "/agents/${id}/terminate" "{}"
|
||||||
|
if [[ "$RESPONSE_CODE" != "200" && "$RESPONSE_CODE" != "404" ]]; then
|
||||||
|
warn "terminate ${id} returned HTTP ${RESPONSE_CODE}"
|
||||||
|
fi
|
||||||
|
|
||||||
|
api_request "DELETE" "/agents/${id}"
|
||||||
|
if [[ "$RESPONSE_CODE" != "200" && "$RESPONSE_CODE" != "404" ]]; then
|
||||||
|
warn "delete ${id} returned HTTP ${RESPONSE_CODE}"
|
||||||
|
fi
|
||||||
|
done <<<"$ids"
|
||||||
|
}
|
||||||
|
|
||||||
|
cleanup_pending_join_requests() {
|
||||||
|
api_request "GET" "/companies/${COMPANY_ID}/join-requests?status=pending_approval"
|
||||||
|
if [[ "$RESPONSE_CODE" != "200" ]]; then
|
||||||
|
warn "join-request cleanup skipped (HTTP ${RESPONSE_CODE})"
|
||||||
|
return
|
||||||
|
fi
|
||||||
|
|
||||||
|
local ids
|
||||||
|
ids="$(jq -r '.[] | select((.adapterType == "openclaw" or .adapterType == "openclaw_gateway")) | .id' <<<"$RESPONSE_BODY")"
|
||||||
|
if [[ -z "$ids" ]]; then
|
||||||
|
return
|
||||||
|
fi
|
||||||
|
|
||||||
|
while IFS= read -r request_id; do
|
||||||
|
[[ -n "$request_id" ]] || continue
|
||||||
|
log "rejecting stale pending join request ${request_id}"
|
||||||
|
api_request "POST" "/companies/${COMPANY_ID}/join-requests/${request_id}/reject" "{}"
|
||||||
|
if [[ "$RESPONSE_CODE" != "200" && "$RESPONSE_CODE" != "404" && "$RESPONSE_CODE" != "409" ]]; then
|
||||||
|
warn "reject ${request_id} returned HTTP ${RESPONSE_CODE}"
|
||||||
|
fi
|
||||||
|
done <<<"$ids"
|
||||||
|
}
|
||||||
|
|
||||||
|
create_and_approve_gateway_join() {
|
||||||
|
local gateway_token="$1"
|
||||||
|
|
||||||
|
local invite_payload
|
||||||
|
invite_payload="$(jq -nc '{allowedJoinTypes:"agent"}')"
|
||||||
|
api_request "POST" "/companies/${COMPANY_ID}/invites" "$invite_payload"
|
||||||
|
assert_status "201"
|
||||||
|
|
||||||
|
local invite_token
|
||||||
|
invite_token="$(jq -r '.token // empty' <<<"$RESPONSE_BODY")"
|
||||||
|
INVITE_ID="$(jq -r '.id // empty' <<<"$RESPONSE_BODY")"
|
||||||
|
[[ -n "$invite_token" && -n "$INVITE_ID" ]] || fail "invite creation missing token/id"
|
||||||
|
|
||||||
|
local join_payload
|
||||||
|
join_payload="$(jq -nc \
|
||||||
|
--arg name "$OPENCLAW_AGENT_NAME" \
|
||||||
|
--arg url "$OPENCLAW_GATEWAY_URL" \
|
||||||
|
--arg token "$gateway_token" \
|
||||||
|
--arg paperclipApiUrl "$PAPERCLIP_API_URL_FOR_OPENCLAW" \
|
||||||
|
--argjson timeoutSec "$OPENCLAW_ADAPTER_TIMEOUT_SEC" \
|
||||||
|
--argjson waitTimeoutMs "$OPENCLAW_ADAPTER_WAIT_TIMEOUT_MS" \
|
||||||
|
'{
|
||||||
|
requestType: "agent",
|
||||||
|
agentName: $name,
|
||||||
|
adapterType: "openclaw_gateway",
|
||||||
|
capabilities: "OpenClaw gateway smoke harness",
|
||||||
|
agentDefaultsPayload: {
|
||||||
|
url: $url,
|
||||||
|
headers: { "x-openclaw-token": $token },
|
||||||
|
role: "operator",
|
||||||
|
scopes: ["operator.admin"],
|
||||||
|
disableDeviceAuth: true,
|
||||||
|
sessionKeyStrategy: "fixed",
|
||||||
|
sessionKey: "paperclip",
|
||||||
|
timeoutSec: $timeoutSec,
|
||||||
|
waitTimeoutMs: $waitTimeoutMs,
|
||||||
|
paperclipApiUrl: $paperclipApiUrl
|
||||||
|
}
|
||||||
|
}')"
|
||||||
|
|
||||||
|
api_request "POST" "/invites/${invite_token}/accept" "$join_payload"
|
||||||
|
assert_status "202"
|
||||||
|
|
||||||
|
JOIN_REQUEST_ID="$(jq -r '.id // empty' <<<"$RESPONSE_BODY")"
|
||||||
|
local claim_secret
|
||||||
|
claim_secret="$(jq -r '.claimSecret // empty' <<<"$RESPONSE_BODY")"
|
||||||
|
local claim_path
|
||||||
|
claim_path="$(jq -r '.claimApiKeyPath // empty' <<<"$RESPONSE_BODY")"
|
||||||
|
[[ -n "$JOIN_REQUEST_ID" && -n "$claim_secret" && -n "$claim_path" ]] || fail "join accept missing claim metadata"
|
||||||
|
|
||||||
|
log "approving join request ${JOIN_REQUEST_ID}"
|
||||||
|
api_request "POST" "/companies/${COMPANY_ID}/join-requests/${JOIN_REQUEST_ID}/approve" "{}"
|
||||||
|
assert_status "200"
|
||||||
|
|
||||||
|
AGENT_ID="$(jq -r '.createdAgentId // empty' <<<"$RESPONSE_BODY")"
|
||||||
|
[[ -n "$AGENT_ID" ]] || fail "join approval missing createdAgentId"
|
||||||
|
|
||||||
|
log "claiming one-time agent API key"
|
||||||
|
local claim_payload
|
||||||
|
claim_payload="$(jq -nc --arg secret "$claim_secret" '{claimSecret:$secret}')"
|
||||||
|
api_request "POST" "$claim_path" "$claim_payload"
|
||||||
|
assert_status "201"
|
||||||
|
|
||||||
|
AGENT_API_KEY="$(jq -r '.token // empty' <<<"$RESPONSE_BODY")"
|
||||||
|
[[ -n "$AGENT_API_KEY" ]] || fail "claim response missing token"
|
||||||
|
|
||||||
|
persist_claimed_key_artifacts "$RESPONSE_BODY"
|
||||||
|
inject_agent_api_key_payload_template
|
||||||
|
}
|
||||||
|
|
||||||
|
persist_claimed_key_artifacts() {
|
||||||
|
local claim_json="$1"
|
||||||
|
local workspace_dir="${OPENCLAW_CONFIG_DIR%/}/workspace"
|
||||||
|
local skill_dir="${OPENCLAW_CONFIG_DIR%/}/skills/paperclip"
|
||||||
|
local claimed_file="${workspace_dir}/paperclip-claimed-api-key.json"
|
||||||
|
local claimed_raw_file="${workspace_dir}/paperclip-claimed-api-key.raw.json"
|
||||||
|
|
||||||
|
mkdir -p "$workspace_dir" "$skill_dir"
|
||||||
|
local token
|
||||||
|
token="$(jq -r '.token // .apiKey // empty' <<<"$claim_json")"
|
||||||
|
[[ -n "$token" ]] || fail "claim response missing token/apiKey"
|
||||||
|
|
||||||
|
printf "%s\n" "$claim_json" > "$claimed_raw_file"
|
||||||
|
chmod 600 "$claimed_raw_file"
|
||||||
|
|
||||||
|
jq -nc --arg token "$token" '{ token: $token, apiKey: $token }' > "$claimed_file"
|
||||||
|
# Keep this readable for OpenClaw runtime users across sandbox/container contexts.
|
||||||
|
chmod 644 "$claimed_file"
|
||||||
|
|
||||||
|
local container
|
||||||
|
container="$(detect_openclaw_container || true)"
|
||||||
|
if [[ -n "$container" ]]; then
|
||||||
|
docker exec "$container" sh -lc "mkdir -p /home/node/.openclaw/workspace" >/dev/null 2>&1 || true
|
||||||
|
docker cp "$claimed_file" "${container}:/home/node/.openclaw/workspace/paperclip-claimed-api-key.json" >/dev/null 2>&1 || true
|
||||||
|
docker exec "$container" sh -lc "chmod 644 /home/node/.openclaw/workspace/paperclip-claimed-api-key.json" >/dev/null 2>&1 || true
|
||||||
|
fi
|
||||||
|
|
||||||
|
if [[ "$AUTO_INSTALL_SKILL" == "1" ]]; then
|
||||||
|
api_request "GET" "/skills/paperclip"
|
||||||
|
assert_status "200"
|
||||||
|
{
|
||||||
|
echo "PAPERCLIP_API_URL=${PAPERCLIP_API_URL_FOR_OPENCLAW}"
|
||||||
|
echo
|
||||||
|
printf "%s\n" "$RESPONSE_BODY"
|
||||||
|
} > "${skill_dir}/SKILL.md"
|
||||||
|
chmod 600 "${skill_dir}/SKILL.md"
|
||||||
|
fi
|
||||||
|
|
||||||
|
log "wrote claimed key artifacts to ${claimed_file} and ${claimed_raw_file}"
|
||||||
|
}
|
||||||
|
|
||||||
|
inject_agent_api_key_payload_template() {
|
||||||
|
[[ -n "$AGENT_ID" ]] || fail "inject_agent_api_key_payload_template requires AGENT_ID"
|
||||||
|
[[ -n "$AGENT_API_KEY" ]] || fail "inject_agent_api_key_payload_template requires AGENT_API_KEY"
|
||||||
|
|
||||||
|
api_request "GET" "/agents/${AGENT_ID}"
|
||||||
|
assert_status "200"
|
||||||
|
|
||||||
|
local base_message
|
||||||
|
base_message="Set PAPERCLIP_API_KEY=${AGENT_API_KEY} in your run context before running Paperclip heartbeat steps."
|
||||||
|
if [[ -n "$PAYLOAD_TEMPLATE_MESSAGE_APPEND" ]]; then
|
||||||
|
base_message="${base_message}\n\n${PAYLOAD_TEMPLATE_MESSAGE_APPEND}"
|
||||||
|
fi
|
||||||
|
|
||||||
|
local patch_payload
|
||||||
|
patch_payload="$(jq -c --arg message "$base_message" '
|
||||||
|
{adapterConfig: ((.adapterConfig // {}) + {
|
||||||
|
payloadTemplate: (((.adapterConfig // {}).payloadTemplate // {}) + {
|
||||||
|
message: $message
|
||||||
|
})
|
||||||
|
})}
|
||||||
|
' <<<"$RESPONSE_BODY")"
|
||||||
|
|
||||||
|
api_request "PATCH" "/agents/${AGENT_ID}" "$patch_payload"
|
||||||
|
assert_status "200"
|
||||||
|
}
|
||||||
|
|
||||||
|
trigger_wakeup() {
|
||||||
|
local reason="$1"
|
||||||
|
local issue_id="${2:-}"
|
||||||
|
|
||||||
|
local payload
|
||||||
|
if [[ -n "$issue_id" ]]; then
|
||||||
|
payload="$(jq -nc --arg issueId "$issue_id" --arg reason "$reason" '{source:"on_demand",triggerDetail:"manual",reason:$reason,payload:{issueId:$issueId,taskId:$issueId}}')"
|
||||||
|
else
|
||||||
|
payload="$(jq -nc --arg reason "$reason" '{source:"on_demand",triggerDetail:"manual",reason:$reason}')"
|
||||||
|
fi
|
||||||
|
|
||||||
|
api_request "POST" "/agents/${AGENT_ID}/wakeup" "$payload"
|
||||||
|
if [[ "$RESPONSE_CODE" != "202" ]]; then
|
||||||
|
echo "$RESPONSE_BODY" >&2
|
||||||
|
fail "wakeup failed (HTTP ${RESPONSE_CODE})"
|
||||||
|
fi
|
||||||
|
|
||||||
|
RUN_ID="$(jq -r '.id // empty' <<<"$RESPONSE_BODY")"
|
||||||
|
if [[ -z "$RUN_ID" ]]; then
|
||||||
|
warn "wakeup response did not include run id; body: ${RESPONSE_BODY}"
|
||||||
|
fi
|
||||||
|
}
|
||||||
|
|
||||||
|
get_run_status() {
|
||||||
|
local run_id="$1"
|
||||||
|
api_request "GET" "/companies/${COMPANY_ID}/heartbeat-runs?agentId=${AGENT_ID}&limit=200"
|
||||||
|
if [[ "$RESPONSE_CODE" != "200" ]]; then
|
||||||
|
echo ""
|
||||||
|
return 0
|
||||||
|
fi
|
||||||
|
jq -r --arg runId "$run_id" '.[] | select(.id == $runId) | .status' <<<"$RESPONSE_BODY" | head -n1
|
||||||
|
}
|
||||||
|
|
||||||
|
wait_for_run_terminal() {
|
||||||
|
local run_id="$1"
|
||||||
|
local timeout_sec="$2"
|
||||||
|
local started now status
|
||||||
|
|
||||||
|
[[ -n "$run_id" ]] || fail "wait_for_run_terminal requires run id"
|
||||||
|
started="$(date +%s)"
|
||||||
|
|
||||||
|
while true; do
|
||||||
|
status="$(get_run_status "$run_id")"
|
||||||
|
if [[ "$status" == "succeeded" || "$status" == "failed" || "$status" == "timed_out" || "$status" == "cancelled" ]]; then
|
||||||
|
if [[ "$status" != "succeeded" ]]; then
|
||||||
|
capture_run_diagnostics "$run_id" "run-nonsuccess"
|
||||||
|
capture_openclaw_container_logs
|
||||||
|
fi
|
||||||
|
echo "$status"
|
||||||
|
return 0
|
||||||
|
fi
|
||||||
|
|
||||||
|
now="$(date +%s)"
|
||||||
|
if (( now - started >= timeout_sec )); then
|
||||||
|
capture_run_diagnostics "$run_id" "run-timeout"
|
||||||
|
capture_openclaw_container_logs
|
||||||
|
echo "timeout"
|
||||||
|
return 0
|
||||||
|
fi
|
||||||
|
sleep 2
|
||||||
|
done
|
||||||
|
}
|
||||||
|
|
||||||
|
get_issue_status() {
|
||||||
|
local issue_id="$1"
|
||||||
|
api_request "GET" "/issues/${issue_id}"
|
||||||
|
if [[ "$RESPONSE_CODE" != "200" ]]; then
|
||||||
|
echo ""
|
||||||
|
return 0
|
||||||
|
fi
|
||||||
|
jq -r '.status // empty' <<<"$RESPONSE_BODY"
|
||||||
|
}
|
||||||
|
|
||||||
|
wait_for_issue_terminal() {
|
||||||
|
local issue_id="$1"
|
||||||
|
local timeout_sec="$2"
|
||||||
|
local started now status
|
||||||
|
started="$(date +%s)"
|
||||||
|
|
||||||
|
while true; do
|
||||||
|
status="$(get_issue_status "$issue_id")"
|
||||||
|
if [[ "$status" == "done" || "$status" == "blocked" || "$status" == "cancelled" ]]; then
|
||||||
|
echo "$status"
|
||||||
|
return 0
|
||||||
|
fi
|
||||||
|
|
||||||
|
now="$(date +%s)"
|
||||||
|
if (( now - started >= timeout_sec )); then
|
||||||
|
echo "timeout"
|
||||||
|
return 0
|
||||||
|
fi
|
||||||
|
sleep 3
|
||||||
|
done
|
||||||
|
}
|
||||||
|
|
||||||
|
issue_comments_contain() {
|
||||||
|
local issue_id="$1"
|
||||||
|
local marker="$2"
|
||||||
|
api_request "GET" "/issues/${issue_id}/comments"
|
||||||
|
if [[ "$RESPONSE_CODE" != "200" ]]; then
|
||||||
|
echo "false"
|
||||||
|
return 0
|
||||||
|
fi
|
||||||
|
jq -r --arg marker "$marker" '[.[] | (.body // "") | contains($marker)] | any' <<<"$RESPONSE_BODY"
|
||||||
|
}
|
||||||
|
|
||||||
|
create_issue_for_case() {
|
||||||
|
local title="$1"
|
||||||
|
local description="$2"
|
||||||
|
local priority="${3:-high}"
|
||||||
|
|
||||||
|
local payload
|
||||||
|
payload="$(jq -nc \
|
||||||
|
--arg title "$title" \
|
||||||
|
--arg description "$description" \
|
||||||
|
--arg assignee "$AGENT_ID" \
|
||||||
|
--arg priority "$priority" \
|
||||||
|
'{title:$title,description:$description,status:"todo",priority:$priority,assigneeAgentId:$assignee}')"
|
||||||
|
|
||||||
|
api_request "POST" "/companies/${COMPANY_ID}/issues" "$payload"
|
||||||
|
assert_status "201"
|
||||||
|
|
||||||
|
local issue_id issue_identifier
|
||||||
|
issue_id="$(jq -r '.id // empty' <<<"$RESPONSE_BODY")"
|
||||||
|
issue_identifier="$(jq -r '.identifier // empty' <<<"$RESPONSE_BODY")"
|
||||||
|
[[ -n "$issue_id" ]] || fail "issue create missing id"
|
||||||
|
|
||||||
|
echo "${issue_id}|${issue_identifier}"
|
||||||
|
}
|
||||||
|
|
||||||
|
patch_agent_session_strategy_run() {
|
||||||
|
api_request "GET" "/agents/${AGENT_ID}"
|
||||||
|
assert_status "200"
|
||||||
|
|
||||||
|
local patch_payload
|
||||||
|
patch_payload="$(jq -c '{adapterConfig: ((.adapterConfig // {}) + {sessionKeyStrategy:"run"})}' <<<"$RESPONSE_BODY")"
|
||||||
|
api_request "PATCH" "/agents/${AGENT_ID}" "$patch_payload"
|
||||||
|
assert_status "200"
|
||||||
|
}
|
||||||
|
|
||||||
|
find_issue_by_query() {
|
||||||
|
local query="$1"
|
||||||
|
local encoded_query
|
||||||
|
encoded_query="$(jq -rn --arg q "$query" '$q|@uri')"
|
||||||
|
api_request "GET" "/companies/${COMPANY_ID}/issues?q=${encoded_query}"
|
||||||
|
if [[ "$RESPONSE_CODE" != "200" ]]; then
|
||||||
|
echo ""
|
||||||
|
return 0
|
||||||
|
fi
|
||||||
|
jq -r '.[] | .id' <<<"$RESPONSE_BODY" | head -n1
|
||||||
|
}
|
||||||
|
|
||||||
|
run_case_a() {
|
||||||
|
local marker="OPENCLAW_CASE_A_OK_$(date +%s)"
|
||||||
|
local description
|
||||||
|
description="Case A validation.\n\n1) Read this issue.\n2) Post a comment containing exactly: ${marker}\n3) Mark this issue done."
|
||||||
|
|
||||||
|
local created
|
||||||
|
created="$(create_issue_for_case "[OpenClaw Gateway Smoke] Case A" "$description")"
|
||||||
|
CASE_A_ISSUE_ID="${created%%|*}"
|
||||||
|
local case_identifier="${created##*|}"
|
||||||
|
|
||||||
|
log "case A issue ${CASE_A_ISSUE_ID} (${case_identifier})"
|
||||||
|
trigger_wakeup "openclaw_gateway_smoke_case_a" "$CASE_A_ISSUE_ID"
|
||||||
|
|
||||||
|
local run_status issue_status marker_found
|
||||||
|
if [[ -n "$RUN_ID" ]]; then
|
||||||
|
run_status="$(wait_for_run_terminal "$RUN_ID" "$RUN_TIMEOUT_SEC")"
|
||||||
|
log "case A run ${RUN_ID} status=${run_status}"
|
||||||
|
else
|
||||||
|
run_status="unknown"
|
||||||
|
fi
|
||||||
|
|
||||||
|
issue_status="$(wait_for_issue_terminal "$CASE_A_ISSUE_ID" "$CASE_TIMEOUT_SEC")"
|
||||||
|
marker_found="$(issue_comments_contain "$CASE_A_ISSUE_ID" "$marker")"
|
||||||
|
log "case A issue_status=${issue_status} marker_found=${marker_found}"
|
||||||
|
|
||||||
|
if [[ "$issue_status" != "done" || "$marker_found" != "true" ]]; then
|
||||||
|
capture_issue_diagnostics "$CASE_A_ISSUE_ID" "case-a"
|
||||||
|
if [[ -n "$RUN_ID" ]]; then
|
||||||
|
capture_run_diagnostics "$RUN_ID" "case-a"
|
||||||
|
fi
|
||||||
|
capture_openclaw_container_logs
|
||||||
|
fi
|
||||||
|
|
||||||
|
if [[ "$STRICT_CASES" == "1" ]]; then
|
||||||
|
[[ "$run_status" == "succeeded" ]] || fail "case A run did not succeed"
|
||||||
|
[[ "$issue_status" == "done" ]] || fail "case A issue did not reach done"
|
||||||
|
[[ "$marker_found" == "true" ]] || fail "case A marker not found in comments"
|
||||||
|
fi
|
||||||
|
}
|
||||||
|
|
||||||
|
run_case_b() {
|
||||||
|
local marker="OPENCLAW_CASE_B_OK_$(date +%s)"
|
||||||
|
local message_text="${marker}"
|
||||||
|
local description
|
||||||
|
description="Case B validation.\n\nUse the message tool to send this exact text to the user's main chat session in webchat:\n${message_text}\n\nAfter sending, post a Paperclip issue comment containing exactly: ${marker}\nThen mark this issue done."
|
||||||
|
|
||||||
|
local created
|
||||||
|
created="$(create_issue_for_case "[OpenClaw Gateway Smoke] Case B" "$description")"
|
||||||
|
CASE_B_ISSUE_ID="${created%%|*}"
|
||||||
|
local case_identifier="${created##*|}"
|
||||||
|
|
||||||
|
log "case B issue ${CASE_B_ISSUE_ID} (${case_identifier})"
|
||||||
|
trigger_wakeup "openclaw_gateway_smoke_case_b" "$CASE_B_ISSUE_ID"
|
||||||
|
|
||||||
|
local run_status issue_status marker_found
|
||||||
|
if [[ -n "$RUN_ID" ]]; then
|
||||||
|
run_status="$(wait_for_run_terminal "$RUN_ID" "$RUN_TIMEOUT_SEC")"
|
||||||
|
log "case B run ${RUN_ID} status=${run_status}"
|
||||||
|
else
|
||||||
|
run_status="unknown"
|
||||||
|
fi
|
||||||
|
|
||||||
|
issue_status="$(wait_for_issue_terminal "$CASE_B_ISSUE_ID" "$CASE_TIMEOUT_SEC")"
|
||||||
|
marker_found="$(issue_comments_contain "$CASE_B_ISSUE_ID" "$marker")"
|
||||||
|
log "case B issue_status=${issue_status} marker_found=${marker_found}"
|
||||||
|
|
||||||
|
if [[ "$issue_status" != "done" || "$marker_found" != "true" ]]; then
|
||||||
|
capture_issue_diagnostics "$CASE_B_ISSUE_ID" "case-b"
|
||||||
|
if [[ -n "$RUN_ID" ]]; then
|
||||||
|
capture_run_diagnostics "$RUN_ID" "case-b"
|
||||||
|
fi
|
||||||
|
capture_openclaw_container_logs
|
||||||
|
fi
|
||||||
|
|
||||||
|
warn "case B requires manual UX confirmation in OpenClaw main webchat: message '${message_text}' appears in main chat"
|
||||||
|
|
||||||
|
if [[ "$STRICT_CASES" == "1" ]]; then
|
||||||
|
[[ "$run_status" == "succeeded" ]] || fail "case B run did not succeed"
|
||||||
|
[[ "$issue_status" == "done" ]] || fail "case B issue did not reach done"
|
||||||
|
[[ "$marker_found" == "true" ]] || fail "case B marker not found in comments"
|
||||||
|
fi
|
||||||
|
}
|
||||||
|
|
||||||
|
run_case_c() {
|
||||||
|
patch_agent_session_strategy_run
|
||||||
|
|
||||||
|
local marker="OPENCLAW_CASE_C_CREATED_$(date +%s)"
|
||||||
|
local ack_marker="OPENCLAW_CASE_C_ACK_$(date +%s)"
|
||||||
|
local description
|
||||||
|
description="Case C validation.\n\nTreat this run as a fresh/new session.\nCreate a NEW Paperclip issue in this same company with title exactly:\n${marker}\nUse description: 'created by case C smoke'.\n\nThen post a comment on this issue containing exactly: ${ack_marker}\nThen mark this issue done."
|
||||||
|
|
||||||
|
local created
|
||||||
|
created="$(create_issue_for_case "[OpenClaw Gateway Smoke] Case C" "$description")"
|
||||||
|
CASE_C_ISSUE_ID="${created%%|*}"
|
||||||
|
local case_identifier="${created##*|}"
|
||||||
|
|
||||||
|
log "case C issue ${CASE_C_ISSUE_ID} (${case_identifier})"
|
||||||
|
trigger_wakeup "openclaw_gateway_smoke_case_c" "$CASE_C_ISSUE_ID"
|
||||||
|
|
||||||
|
local run_status issue_status marker_found created_issue
|
||||||
|
if [[ -n "$RUN_ID" ]]; then
|
||||||
|
run_status="$(wait_for_run_terminal "$RUN_ID" "$RUN_TIMEOUT_SEC")"
|
||||||
|
log "case C run ${RUN_ID} status=${run_status}"
|
||||||
|
else
|
||||||
|
run_status="unknown"
|
||||||
|
fi
|
||||||
|
|
||||||
|
issue_status="$(wait_for_issue_terminal "$CASE_C_ISSUE_ID" "$CASE_TIMEOUT_SEC")"
|
||||||
|
marker_found="$(issue_comments_contain "$CASE_C_ISSUE_ID" "$ack_marker")"
|
||||||
|
created_issue="$(find_issue_by_query "$marker")"
|
||||||
|
if [[ "$created_issue" == "$CASE_C_ISSUE_ID" ]]; then
|
||||||
|
created_issue=""
|
||||||
|
fi
|
||||||
|
CASE_C_CREATED_ISSUE_ID="$created_issue"
|
||||||
|
log "case C issue_status=${issue_status} marker_found=${marker_found} created_issue_id=${CASE_C_CREATED_ISSUE_ID:-none}"
|
||||||
|
|
||||||
|
if [[ "$issue_status" != "done" || "$marker_found" != "true" || -z "$CASE_C_CREATED_ISSUE_ID" ]]; then
|
||||||
|
capture_issue_diagnostics "$CASE_C_ISSUE_ID" "case-c"
|
||||||
|
if [[ -n "$CASE_C_CREATED_ISSUE_ID" ]]; then
|
||||||
|
capture_issue_diagnostics "$CASE_C_CREATED_ISSUE_ID" "case-c-created"
|
||||||
|
fi
|
||||||
|
if [[ -n "$RUN_ID" ]]; then
|
||||||
|
capture_run_diagnostics "$RUN_ID" "case-c"
|
||||||
|
fi
|
||||||
|
capture_openclaw_container_logs
|
||||||
|
fi
|
||||||
|
|
||||||
|
if [[ "$STRICT_CASES" == "1" ]]; then
|
||||||
|
[[ "$run_status" == "succeeded" ]] || fail "case C run did not succeed"
|
||||||
|
[[ "$issue_status" == "done" ]] || fail "case C issue did not reach done"
|
||||||
|
[[ "$marker_found" == "true" ]] || fail "case C ack marker not found in comments"
|
||||||
|
[[ -n "$CASE_C_CREATED_ISSUE_ID" ]] || fail "case C did not create the expected new issue"
|
||||||
|
fi
|
||||||
|
}
|
||||||
|
|
||||||
|
main() {
|
||||||
|
log "starting OpenClaw gateway E2E smoke"
|
||||||
|
mkdir -p "$OPENCLAW_DIAG_DIR"
|
||||||
|
log "diagnostics dir: ${OPENCLAW_DIAG_DIR}"
|
||||||
|
|
||||||
|
wait_http_ready "${PAPERCLIP_API_URL%/}/api/health" 15 || fail "Paperclip API health endpoint not reachable"
|
||||||
|
api_request "GET" "/health"
|
||||||
|
assert_status "200"
|
||||||
|
log "paperclip health deploymentMode=$(jq -r '.deploymentMode // "unknown"' <<<"$RESPONSE_BODY") exposure=$(jq -r '.deploymentExposure // "unknown"' <<<"$RESPONSE_BODY")"
|
||||||
|
|
||||||
|
require_board_auth
|
||||||
|
resolve_company_id
|
||||||
|
cleanup_openclaw_agents
|
||||||
|
cleanup_pending_join_requests
|
||||||
|
|
||||||
|
maybe_cleanup_openclaw_docker
|
||||||
|
start_openclaw_docker
|
||||||
|
wait_http_ready "http://127.0.0.1:18789/" "$OPENCLAW_WAIT_SECONDS" || fail "OpenClaw HTTP health not reachable"
|
||||||
|
|
||||||
|
local gateway_token
|
||||||
|
gateway_token="$(detect_gateway_token || true)"
|
||||||
|
[[ -n "$gateway_token" ]] || fail "could not resolve OpenClaw gateway token"
|
||||||
|
log "resolved gateway token (sha256 prefix $(hash_prefix "$gateway_token"))"
|
||||||
|
|
||||||
|
log "probing gateway websocket challenge at ${OPENCLAW_GATEWAY_URL}"
|
||||||
|
probe_gateway_ws "$OPENCLAW_GATEWAY_URL" "$gateway_token"
|
||||||
|
|
||||||
|
create_and_approve_gateway_join "$gateway_token"
|
||||||
|
log "joined/approved agent ${AGENT_ID} invite=${INVITE_ID} joinRequest=${JOIN_REQUEST_ID}"
|
||||||
|
|
||||||
|
trigger_wakeup "openclaw_gateway_smoke_connectivity"
|
||||||
|
if [[ -n "$RUN_ID" ]]; then
|
||||||
|
local connect_status
|
||||||
|
connect_status="$(wait_for_run_terminal "$RUN_ID" "$RUN_TIMEOUT_SEC")"
|
||||||
|
[[ "$connect_status" == "succeeded" ]] || fail "connectivity wake run failed: ${connect_status}"
|
||||||
|
log "connectivity wake run ${RUN_ID} succeeded"
|
||||||
|
fi
|
||||||
|
|
||||||
|
run_case_a
|
||||||
|
run_case_b
|
||||||
|
run_case_c
|
||||||
|
|
||||||
|
log "success"
|
||||||
|
log "companyId=${COMPANY_ID}"
|
||||||
|
log "agentId=${AGENT_ID}"
|
||||||
|
log "inviteId=${INVITE_ID}"
|
||||||
|
log "joinRequestId=${JOIN_REQUEST_ID}"
|
||||||
|
log "caseA_issueId=${CASE_A_ISSUE_ID}"
|
||||||
|
log "caseB_issueId=${CASE_B_ISSUE_ID}"
|
||||||
|
log "caseC_issueId=${CASE_C_ISSUE_ID}"
|
||||||
|
log "caseC_createdIssueId=${CASE_C_CREATED_ISSUE_ID:-none}"
|
||||||
|
log "agentApiKeyPrefix=${AGENT_API_KEY:0:12}..."
|
||||||
|
}
|
||||||
|
|
||||||
|
main "$@"
|
||||||
@@ -37,6 +37,7 @@
|
|||||||
"@paperclipai/adapter-opencode-local": "workspace:*",
|
"@paperclipai/adapter-opencode-local": "workspace:*",
|
||||||
"@paperclipai/adapter-pi-local": "workspace:*",
|
"@paperclipai/adapter-pi-local": "workspace:*",
|
||||||
"@paperclipai/adapter-openclaw": "workspace:*",
|
"@paperclipai/adapter-openclaw": "workspace:*",
|
||||||
|
"@paperclipai/adapter-openclaw-gateway": "workspace:*",
|
||||||
"@paperclipai/adapter-utils": "workspace:*",
|
"@paperclipai/adapter-utils": "workspace:*",
|
||||||
"@paperclipai/db": "workspace:*",
|
"@paperclipai/db": "workspace:*",
|
||||||
"@paperclipai/shared": "workspace:*",
|
"@paperclipai/shared": "workspace:*",
|
||||||
|
|||||||
53
server/src/__tests__/error-handler.test.ts
Normal file
53
server/src/__tests__/error-handler.test.ts
Normal file
@@ -0,0 +1,53 @@
|
|||||||
|
import type { NextFunction, Request, Response } from "express";
|
||||||
|
import { describe, expect, it, vi } from "vitest";
|
||||||
|
import { HttpError } from "../errors.js";
|
||||||
|
import { errorHandler } from "../middleware/error-handler.js";
|
||||||
|
|
||||||
|
function makeReq(): Request {
|
||||||
|
return {
|
||||||
|
method: "GET",
|
||||||
|
originalUrl: "/api/test",
|
||||||
|
body: { a: 1 },
|
||||||
|
params: { id: "123" },
|
||||||
|
query: { q: "x" },
|
||||||
|
} as unknown as Request;
|
||||||
|
}
|
||||||
|
|
||||||
|
function makeRes(): Response {
|
||||||
|
const res = {
|
||||||
|
status: vi.fn(),
|
||||||
|
json: vi.fn(),
|
||||||
|
} as unknown as Response;
|
||||||
|
(res.status as unknown as ReturnType<typeof vi.fn>).mockReturnValue(res);
|
||||||
|
return res;
|
||||||
|
}
|
||||||
|
|
||||||
|
describe("errorHandler", () => {
|
||||||
|
it("attaches the original Error to res.err for 500s", () => {
|
||||||
|
const req = makeReq();
|
||||||
|
const res = makeRes() as any;
|
||||||
|
const next = vi.fn() as unknown as NextFunction;
|
||||||
|
const err = new Error("boom");
|
||||||
|
|
||||||
|
errorHandler(err, req, res, next);
|
||||||
|
|
||||||
|
expect(res.status).toHaveBeenCalledWith(500);
|
||||||
|
expect(res.json).toHaveBeenCalledWith({ error: "Internal server error" });
|
||||||
|
expect(res.err).toBe(err);
|
||||||
|
expect(res.__errorContext?.error?.message).toBe("boom");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("attaches HttpError instances for 500 responses", () => {
|
||||||
|
const req = makeReq();
|
||||||
|
const res = makeRes() as any;
|
||||||
|
const next = vi.fn() as unknown as NextFunction;
|
||||||
|
const err = new HttpError(500, "db exploded");
|
||||||
|
|
||||||
|
errorHandler(err, req, res, next);
|
||||||
|
|
||||||
|
expect(res.status).toHaveBeenCalledWith(500);
|
||||||
|
expect(res.json).toHaveBeenCalledWith({ error: "db exploded" });
|
||||||
|
expect(res.err).toBe(err);
|
||||||
|
expect(res.__errorContext?.error?.message).toBe("db exploded");
|
||||||
|
});
|
||||||
|
});
|
||||||
254
server/src/__tests__/openclaw-gateway-adapter.test.ts
Normal file
254
server/src/__tests__/openclaw-gateway-adapter.test.ts
Normal file
@@ -0,0 +1,254 @@
|
|||||||
|
import { afterEach, describe, expect, it } from "vitest";
|
||||||
|
import { createServer } from "node:http";
|
||||||
|
import { WebSocketServer } from "ws";
|
||||||
|
import { execute, testEnvironment } from "@paperclipai/adapter-openclaw-gateway/server";
|
||||||
|
import { parseOpenClawGatewayStdoutLine } from "@paperclipai/adapter-openclaw-gateway/ui";
|
||||||
|
import type { AdapterExecutionContext } from "@paperclipai/adapter-utils";
|
||||||
|
|
||||||
|
function buildContext(
|
||||||
|
config: Record<string, unknown>,
|
||||||
|
overrides?: Partial<AdapterExecutionContext>,
|
||||||
|
): AdapterExecutionContext {
|
||||||
|
return {
|
||||||
|
runId: "run-123",
|
||||||
|
agent: {
|
||||||
|
id: "agent-123",
|
||||||
|
companyId: "company-123",
|
||||||
|
name: "OpenClaw Gateway Agent",
|
||||||
|
adapterType: "openclaw_gateway",
|
||||||
|
adapterConfig: {},
|
||||||
|
},
|
||||||
|
runtime: {
|
||||||
|
sessionId: null,
|
||||||
|
sessionParams: null,
|
||||||
|
sessionDisplayId: null,
|
||||||
|
taskKey: null,
|
||||||
|
},
|
||||||
|
config,
|
||||||
|
context: {
|
||||||
|
taskId: "task-123",
|
||||||
|
issueId: "issue-123",
|
||||||
|
wakeReason: "issue_assigned",
|
||||||
|
issueIds: ["issue-123"],
|
||||||
|
},
|
||||||
|
onLog: async () => {},
|
||||||
|
...overrides,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
async function createMockGatewayServer() {
|
||||||
|
const server = createServer();
|
||||||
|
const wss = new WebSocketServer({ server });
|
||||||
|
|
||||||
|
let agentPayload: Record<string, unknown> | null = null;
|
||||||
|
|
||||||
|
wss.on("connection", (socket) => {
|
||||||
|
socket.send(
|
||||||
|
JSON.stringify({
|
||||||
|
type: "event",
|
||||||
|
event: "connect.challenge",
|
||||||
|
payload: { nonce: "nonce-123" },
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
|
socket.on("message", (raw) => {
|
||||||
|
const text = Buffer.isBuffer(raw) ? raw.toString("utf8") : String(raw);
|
||||||
|
const frame = JSON.parse(text) as {
|
||||||
|
type: string;
|
||||||
|
id: string;
|
||||||
|
method: string;
|
||||||
|
params?: Record<string, unknown>;
|
||||||
|
};
|
||||||
|
|
||||||
|
if (frame.type !== "req") return;
|
||||||
|
|
||||||
|
if (frame.method === "connect") {
|
||||||
|
socket.send(
|
||||||
|
JSON.stringify({
|
||||||
|
type: "res",
|
||||||
|
id: frame.id,
|
||||||
|
ok: true,
|
||||||
|
payload: {
|
||||||
|
type: "hello-ok",
|
||||||
|
protocol: 3,
|
||||||
|
server: { version: "test", connId: "conn-1" },
|
||||||
|
features: { methods: ["connect", "agent", "agent.wait"], events: ["agent"] },
|
||||||
|
snapshot: { version: 1, ts: Date.now() },
|
||||||
|
policy: { maxPayload: 1_000_000, maxBufferedBytes: 1_000_000, tickIntervalMs: 30_000 },
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (frame.method === "agent") {
|
||||||
|
agentPayload = frame.params ?? null;
|
||||||
|
const runId =
|
||||||
|
typeof frame.params?.idempotencyKey === "string"
|
||||||
|
? frame.params.idempotencyKey
|
||||||
|
: "run-123";
|
||||||
|
|
||||||
|
socket.send(
|
||||||
|
JSON.stringify({
|
||||||
|
type: "res",
|
||||||
|
id: frame.id,
|
||||||
|
ok: true,
|
||||||
|
payload: {
|
||||||
|
runId,
|
||||||
|
status: "accepted",
|
||||||
|
acceptedAt: Date.now(),
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
|
socket.send(
|
||||||
|
JSON.stringify({
|
||||||
|
type: "event",
|
||||||
|
event: "agent",
|
||||||
|
payload: {
|
||||||
|
runId,
|
||||||
|
seq: 1,
|
||||||
|
stream: "assistant",
|
||||||
|
ts: Date.now(),
|
||||||
|
data: { delta: "cha" },
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
socket.send(
|
||||||
|
JSON.stringify({
|
||||||
|
type: "event",
|
||||||
|
event: "agent",
|
||||||
|
payload: {
|
||||||
|
runId,
|
||||||
|
seq: 2,
|
||||||
|
stream: "assistant",
|
||||||
|
ts: Date.now(),
|
||||||
|
data: { delta: "chacha" },
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (frame.method === "agent.wait") {
|
||||||
|
socket.send(
|
||||||
|
JSON.stringify({
|
||||||
|
type: "res",
|
||||||
|
id: frame.id,
|
||||||
|
ok: true,
|
||||||
|
payload: {
|
||||||
|
runId: frame.params?.runId,
|
||||||
|
status: "ok",
|
||||||
|
startedAt: 1,
|
||||||
|
endedAt: 2,
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
await new Promise<void>((resolve) => {
|
||||||
|
server.listen(0, "127.0.0.1", () => resolve());
|
||||||
|
});
|
||||||
|
|
||||||
|
const address = server.address();
|
||||||
|
if (!address || typeof address === "string") {
|
||||||
|
throw new Error("Failed to resolve test server address");
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
url: `ws://127.0.0.1:${address.port}`,
|
||||||
|
getAgentPayload: () => agentPayload,
|
||||||
|
close: async () => {
|
||||||
|
await new Promise<void>((resolve) => wss.close(() => resolve()));
|
||||||
|
await new Promise<void>((resolve) => server.close(() => resolve()));
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
// no global mocks
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("openclaw gateway ui stdout parser", () => {
|
||||||
|
it("parses assistant deltas from gateway event lines", () => {
|
||||||
|
const ts = "2026-03-06T15:00:00.000Z";
|
||||||
|
const line =
|
||||||
|
'[openclaw-gateway:event] run=run-1 stream=assistant data={"delta":"hello"}';
|
||||||
|
|
||||||
|
expect(parseOpenClawGatewayStdoutLine(line, ts)).toEqual([
|
||||||
|
{
|
||||||
|
kind: "assistant",
|
||||||
|
ts,
|
||||||
|
text: "hello",
|
||||||
|
delta: true,
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("openclaw gateway adapter execute", () => {
|
||||||
|
it("runs connect -> agent -> agent.wait and forwards wake payload", async () => {
|
||||||
|
const gateway = await createMockGatewayServer();
|
||||||
|
const logs: string[] = [];
|
||||||
|
|
||||||
|
try {
|
||||||
|
const result = await execute(
|
||||||
|
buildContext(
|
||||||
|
{
|
||||||
|
url: gateway.url,
|
||||||
|
headers: {
|
||||||
|
"x-openclaw-token": "gateway-token",
|
||||||
|
},
|
||||||
|
payloadTemplate: {
|
||||||
|
message: "wake now",
|
||||||
|
},
|
||||||
|
waitTimeoutMs: 2000,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
onLog: async (_stream, chunk) => {
|
||||||
|
logs.push(chunk);
|
||||||
|
},
|
||||||
|
},
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(result.exitCode).toBe(0);
|
||||||
|
expect(result.timedOut).toBe(false);
|
||||||
|
expect(result.summary).toContain("chachacha");
|
||||||
|
expect(result.provider).toBe("openclaw");
|
||||||
|
|
||||||
|
const payload = gateway.getAgentPayload();
|
||||||
|
expect(payload).toBeTruthy();
|
||||||
|
expect(payload?.idempotencyKey).toBe("run-123");
|
||||||
|
expect(payload?.sessionKey).toBe("paperclip");
|
||||||
|
expect(String(payload?.message ?? "")).toContain("wake now");
|
||||||
|
expect(String(payload?.message ?? "")).toContain("PAPERCLIP_RUN_ID=run-123");
|
||||||
|
expect(String(payload?.message ?? "")).toContain("PAPERCLIP_TASK_ID=task-123");
|
||||||
|
|
||||||
|
expect(logs.some((entry) => entry.includes("[openclaw-gateway:event] run=run-123 stream=assistant"))).toBe(true);
|
||||||
|
} finally {
|
||||||
|
await gateway.close();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it("fails fast when url is missing", async () => {
|
||||||
|
const result = await execute(buildContext({}));
|
||||||
|
expect(result.exitCode).toBe(1);
|
||||||
|
expect(result.errorCode).toBe("openclaw_gateway_url_missing");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("openclaw gateway testEnvironment", () => {
|
||||||
|
it("reports missing url as failure", async () => {
|
||||||
|
const result = await testEnvironment({
|
||||||
|
companyId: "company-123",
|
||||||
|
adapterType: "openclaw_gateway",
|
||||||
|
config: {},
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(result.status).toBe("fail");
|
||||||
|
expect(result.checks.some((check) => check.code === "openclaw_gateway_url_missing")).toBe(true);
|
||||||
|
});
|
||||||
|
});
|
||||||
45
server/src/__tests__/project-shortname-resolution.test.ts
Normal file
45
server/src/__tests__/project-shortname-resolution.test.ts
Normal file
@@ -0,0 +1,45 @@
|
|||||||
|
import { describe, expect, it } from "vitest";
|
||||||
|
import { resolveProjectNameForUniqueShortname } from "../services/projects.ts";
|
||||||
|
|
||||||
|
describe("resolveProjectNameForUniqueShortname", () => {
|
||||||
|
it("keeps name when shortname is not used", () => {
|
||||||
|
const resolved = resolveProjectNameForUniqueShortname("Platform", [
|
||||||
|
{ id: "p1", name: "Growth" },
|
||||||
|
]);
|
||||||
|
expect(resolved).toBe("Platform");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("appends numeric suffix when shortname collides", () => {
|
||||||
|
const resolved = resolveProjectNameForUniqueShortname("Growth Team", [
|
||||||
|
{ id: "p1", name: "growth-team" },
|
||||||
|
]);
|
||||||
|
expect(resolved).toBe("Growth Team 2");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("increments suffix until unique", () => {
|
||||||
|
const resolved = resolveProjectNameForUniqueShortname("Growth Team", [
|
||||||
|
{ id: "p1", name: "growth-team" },
|
||||||
|
{ id: "p2", name: "growth-team-2" },
|
||||||
|
]);
|
||||||
|
expect(resolved).toBe("Growth Team 3");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("ignores excluded project id", () => {
|
||||||
|
const resolved = resolveProjectNameForUniqueShortname(
|
||||||
|
"Growth Team",
|
||||||
|
[
|
||||||
|
{ id: "p1", name: "growth-team" },
|
||||||
|
{ id: "p2", name: "platform" },
|
||||||
|
],
|
||||||
|
{ excludeProjectId: "p1" },
|
||||||
|
);
|
||||||
|
expect(resolved).toBe("Growth Team");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("keeps non-normalizable names unchanged", () => {
|
||||||
|
const resolved = resolveProjectNameForUniqueShortname("!!!", [
|
||||||
|
{ id: "p1", name: "growth" },
|
||||||
|
]);
|
||||||
|
expect(resolved).toBe("!!!");
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -35,6 +35,14 @@ import {
|
|||||||
agentConfigurationDoc as openclawAgentConfigurationDoc,
|
agentConfigurationDoc as openclawAgentConfigurationDoc,
|
||||||
models as openclawModels,
|
models as openclawModels,
|
||||||
} from "@paperclipai/adapter-openclaw";
|
} from "@paperclipai/adapter-openclaw";
|
||||||
|
import {
|
||||||
|
execute as openclawGatewayExecute,
|
||||||
|
testEnvironment as openclawGatewayTestEnvironment,
|
||||||
|
} from "@paperclipai/adapter-openclaw-gateway/server";
|
||||||
|
import {
|
||||||
|
agentConfigurationDoc as openclawGatewayAgentConfigurationDoc,
|
||||||
|
models as openclawGatewayModels,
|
||||||
|
} from "@paperclipai/adapter-openclaw-gateway";
|
||||||
import { listCodexModels } from "./codex-models.js";
|
import { listCodexModels } from "./codex-models.js";
|
||||||
import { listCursorModels } from "./cursor-models.js";
|
import { listCursorModels } from "./cursor-models.js";
|
||||||
import {
|
import {
|
||||||
@@ -91,6 +99,15 @@ const openclawAdapter: ServerAdapterModule = {
|
|||||||
agentConfigurationDoc: openclawAgentConfigurationDoc,
|
agentConfigurationDoc: openclawAgentConfigurationDoc,
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const openclawGatewayAdapter: ServerAdapterModule = {
|
||||||
|
type: "openclaw_gateway",
|
||||||
|
execute: openclawGatewayExecute,
|
||||||
|
testEnvironment: openclawGatewayTestEnvironment,
|
||||||
|
models: openclawGatewayModels,
|
||||||
|
supportsLocalAgentJwt: false,
|
||||||
|
agentConfigurationDoc: openclawGatewayAgentConfigurationDoc,
|
||||||
|
};
|
||||||
|
|
||||||
const openCodeLocalAdapter: ServerAdapterModule = {
|
const openCodeLocalAdapter: ServerAdapterModule = {
|
||||||
type: "opencode_local",
|
type: "opencode_local",
|
||||||
execute: openCodeExecute,
|
execute: openCodeExecute,
|
||||||
@@ -114,7 +131,17 @@ const piLocalAdapter: ServerAdapterModule = {
|
|||||||
};
|
};
|
||||||
|
|
||||||
const adaptersByType = new Map<string, ServerAdapterModule>(
|
const adaptersByType = new Map<string, ServerAdapterModule>(
|
||||||
[claudeLocalAdapter, codexLocalAdapter, openCodeLocalAdapter, piLocalAdapter, cursorLocalAdapter, openclawAdapter, processAdapter, httpAdapter].map((a) => [a.type, a]),
|
[
|
||||||
|
claudeLocalAdapter,
|
||||||
|
codexLocalAdapter,
|
||||||
|
openCodeLocalAdapter,
|
||||||
|
piLocalAdapter,
|
||||||
|
cursorLocalAdapter,
|
||||||
|
openclawAdapter,
|
||||||
|
openclawGatewayAdapter,
|
||||||
|
processAdapter,
|
||||||
|
httpAdapter,
|
||||||
|
].map((a) => [a.type, a]),
|
||||||
);
|
);
|
||||||
|
|
||||||
export function getServerAdapter(type: string): ServerAdapterModule {
|
export function getServerAdapter(type: string): ServerAdapterModule {
|
||||||
|
|||||||
@@ -121,6 +121,9 @@ export async function createApp(
|
|||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
app.use("/api", api);
|
app.use("/api", api);
|
||||||
|
app.use("/api", (_req, res) => {
|
||||||
|
res.status(404).json({ error: "API route not found" });
|
||||||
|
});
|
||||||
|
|
||||||
const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
||||||
if (opts.uiMode === "static") {
|
if (opts.uiMode === "static") {
|
||||||
|
|||||||
@@ -11,6 +11,25 @@ export interface ErrorContext {
|
|||||||
reqQuery?: unknown;
|
reqQuery?: unknown;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function attachErrorContext(
|
||||||
|
req: Request,
|
||||||
|
res: Response,
|
||||||
|
payload: ErrorContext["error"],
|
||||||
|
rawError?: Error,
|
||||||
|
) {
|
||||||
|
(res as any).__errorContext = {
|
||||||
|
error: payload,
|
||||||
|
method: req.method,
|
||||||
|
url: req.originalUrl,
|
||||||
|
reqBody: req.body,
|
||||||
|
reqParams: req.params,
|
||||||
|
reqQuery: req.query,
|
||||||
|
} satisfies ErrorContext;
|
||||||
|
if (rawError) {
|
||||||
|
(res as any).err = rawError;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
export function errorHandler(
|
export function errorHandler(
|
||||||
err: unknown,
|
err: unknown,
|
||||||
req: Request,
|
req: Request,
|
||||||
@@ -19,14 +38,12 @@ export function errorHandler(
|
|||||||
) {
|
) {
|
||||||
if (err instanceof HttpError) {
|
if (err instanceof HttpError) {
|
||||||
if (err.status >= 500) {
|
if (err.status >= 500) {
|
||||||
(res as any).__errorContext = {
|
attachErrorContext(
|
||||||
error: { message: err.message, stack: err.stack, name: err.name, details: err.details },
|
req,
|
||||||
method: req.method,
|
res,
|
||||||
url: req.originalUrl,
|
{ message: err.message, stack: err.stack, name: err.name, details: err.details },
|
||||||
reqBody: req.body,
|
err,
|
||||||
reqParams: req.params,
|
);
|
||||||
reqQuery: req.query,
|
|
||||||
} satisfies ErrorContext;
|
|
||||||
}
|
}
|
||||||
res.status(err.status).json({
|
res.status(err.status).json({
|
||||||
error: err.message,
|
error: err.message,
|
||||||
@@ -40,16 +57,15 @@ export function errorHandler(
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
(res as any).__errorContext = {
|
const rootError = err instanceof Error ? err : new Error(String(err));
|
||||||
error: err instanceof Error
|
attachErrorContext(
|
||||||
|
req,
|
||||||
|
res,
|
||||||
|
err instanceof Error
|
||||||
? { message: err.message, stack: err.stack, name: err.name }
|
? { message: err.message, stack: err.stack, name: err.name }
|
||||||
: { message: String(err), raw: err },
|
: { message: String(err), raw: err, stack: rootError.stack, name: rootError.name },
|
||||||
method: req.method,
|
rootError,
|
||||||
url: req.originalUrl,
|
);
|
||||||
reqBody: req.body,
|
|
||||||
reqParams: req.params,
|
|
||||||
reqQuery: req.query,
|
|
||||||
} satisfies ErrorContext;
|
|
||||||
|
|
||||||
res.status(500).json({ error: "Internal server error" });
|
res.status(500).json({ error: "Internal server error" });
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -62,7 +62,7 @@ export const httpLogger = pinoHttp({
|
|||||||
const ctx = (res as any).__errorContext;
|
const ctx = (res as any).__errorContext;
|
||||||
if (ctx) {
|
if (ctx) {
|
||||||
return {
|
return {
|
||||||
err: ctx.error,
|
errorContext: ctx.error,
|
||||||
reqBody: ctx.reqBody,
|
reqBody: ctx.reqBody,
|
||||||
reqParams: ctx.reqParams,
|
reqParams: ctx.reqParams,
|
||||||
reqQuery: ctx.reqQuery,
|
reqQuery: ctx.reqQuery,
|
||||||
|
|||||||
@@ -184,6 +184,13 @@ export function issueRoutes(db: Db, storage: StorageService) {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Common malformed path when companyId is empty in "/api/companies/{companyId}/issues".
|
||||||
|
router.get("/issues", (_req, res) => {
|
||||||
|
res.status(400).json({
|
||||||
|
error: "Missing companyId in path. Use /api/companies/{companyId}/issues.",
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
router.get("/companies/:companyId/issues", async (req, res) => {
|
router.get("/companies/:companyId/issues", async (req, res) => {
|
||||||
const companyId = req.params.companyId as string;
|
const companyId = req.params.companyId as string;
|
||||||
assertCompanyAccess(req, companyId);
|
assertCompanyAccess(req, companyId);
|
||||||
@@ -522,6 +529,7 @@ export function issueRoutes(db: Db, storage: StorageService) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const actor = getActorInfo(req);
|
const actor = getActorInfo(req);
|
||||||
|
const hasFieldChanges = Object.keys(previous).length > 0;
|
||||||
await logActivity(db, {
|
await logActivity(db, {
|
||||||
companyId: issue.companyId,
|
companyId: issue.companyId,
|
||||||
actorType: actor.actorType,
|
actorType: actor.actorType,
|
||||||
@@ -531,7 +539,12 @@ export function issueRoutes(db: Db, storage: StorageService) {
|
|||||||
action: "issue.updated",
|
action: "issue.updated",
|
||||||
entityType: "issue",
|
entityType: "issue",
|
||||||
entityId: issue.id,
|
entityId: issue.id,
|
||||||
details: { ...updateFields, identifier: issue.identifier, _previous: Object.keys(previous).length > 0 ? previous : undefined },
|
details: {
|
||||||
|
...updateFields,
|
||||||
|
identifier: issue.identifier,
|
||||||
|
...(commentBody ? { source: "comment" } : {}),
|
||||||
|
_previous: hasFieldChanges ? previous : undefined,
|
||||||
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
let comment = null;
|
let comment = null;
|
||||||
@@ -555,6 +568,7 @@ export function issueRoutes(db: Db, storage: StorageService) {
|
|||||||
bodySnippet: comment.body.slice(0, 120),
|
bodySnippet: comment.body.slice(0, 120),
|
||||||
identifier: issue.identifier,
|
identifier: issue.identifier,
|
||||||
issueTitle: issue.title,
|
issueTitle: issue.title,
|
||||||
|
...(hasFieldChanges ? { updated: true } : {}),
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -87,6 +87,14 @@ const ADAPTER_DEFAULT_RULES_BY_TYPE: Record<string, Array<{ path: string[]; valu
|
|||||||
{ path: ["method"], value: "POST" },
|
{ path: ["method"], value: "POST" },
|
||||||
{ path: ["timeoutSec"], value: 30 },
|
{ path: ["timeoutSec"], value: 30 },
|
||||||
],
|
],
|
||||||
|
openclaw_gateway: [
|
||||||
|
{ path: ["timeoutSec"], value: 120 },
|
||||||
|
{ path: ["waitTimeoutMs"], value: 120000 },
|
||||||
|
{ path: ["sessionKeyStrategy"], value: "fixed" },
|
||||||
|
{ path: ["sessionKey"], value: "paperclip" },
|
||||||
|
{ path: ["role"], value: "operator" },
|
||||||
|
{ path: ["scopes"], value: ["operator.admin"] },
|
||||||
|
],
|
||||||
};
|
};
|
||||||
|
|
||||||
function isPlainRecord(value: unknown): value is Record<string, unknown> {
|
function isPlainRecord(value: unknown): value is Record<string, unknown> {
|
||||||
|
|||||||
@@ -31,6 +31,15 @@ interface ProjectWithGoals extends ProjectRow {
|
|||||||
primaryWorkspace: ProjectWorkspace | null;
|
primaryWorkspace: ProjectWorkspace | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
interface ProjectShortnameRow {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface ResolveProjectNameOptions {
|
||||||
|
excludeProjectId?: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
/** Batch-load goal refs for a set of projects. */
|
/** Batch-load goal refs for a set of projects. */
|
||||||
async function attachGoals(db: Db, rows: ProjectRow[]): Promise<ProjectWithGoals[]> {
|
async function attachGoals(db: Db, rows: ProjectRow[]): Promise<ProjectWithGoals[]> {
|
||||||
if (rows.length === 0) return [];
|
if (rows.length === 0) return [];
|
||||||
@@ -192,6 +201,34 @@ function deriveWorkspaceName(input: {
|
|||||||
return "Workspace";
|
return "Workspace";
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function resolveProjectNameForUniqueShortname(
|
||||||
|
requestedName: string,
|
||||||
|
existingProjects: ProjectShortnameRow[],
|
||||||
|
options?: ResolveProjectNameOptions,
|
||||||
|
): string {
|
||||||
|
const requestedShortname = normalizeProjectUrlKey(requestedName);
|
||||||
|
if (!requestedShortname) return requestedName;
|
||||||
|
|
||||||
|
const usedShortnames = new Set(
|
||||||
|
existingProjects
|
||||||
|
.filter((project) => !(options?.excludeProjectId && project.id === options.excludeProjectId))
|
||||||
|
.map((project) => normalizeProjectUrlKey(project.name))
|
||||||
|
.filter((value): value is string => value !== null),
|
||||||
|
);
|
||||||
|
if (!usedShortnames.has(requestedShortname)) return requestedName;
|
||||||
|
|
||||||
|
for (let suffix = 2; suffix < 10_000; suffix += 1) {
|
||||||
|
const candidateName = `${requestedName} ${suffix}`;
|
||||||
|
const candidateShortname = normalizeProjectUrlKey(candidateName);
|
||||||
|
if (candidateShortname && !usedShortnames.has(candidateShortname)) {
|
||||||
|
return candidateName;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Fallback guard for pathological naming collisions.
|
||||||
|
return `${requestedName} ${Date.now()}`;
|
||||||
|
}
|
||||||
|
|
||||||
async function ensureSinglePrimaryWorkspace(
|
async function ensureSinglePrimaryWorkspace(
|
||||||
dbOrTx: any,
|
dbOrTx: any,
|
||||||
input: {
|
input: {
|
||||||
@@ -271,6 +308,12 @@ export function projectService(db: Db) {
|
|||||||
projectData.color = nextColor;
|
projectData.color = nextColor;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const existingProjects = await db
|
||||||
|
.select({ id: projects.id, name: projects.name })
|
||||||
|
.from(projects)
|
||||||
|
.where(eq(projects.companyId, companyId));
|
||||||
|
projectData.name = resolveProjectNameForUniqueShortname(projectData.name, existingProjects);
|
||||||
|
|
||||||
// Also write goalId to the legacy column (first goal or null)
|
// Also write goalId to the legacy column (first goal or null)
|
||||||
const legacyGoalId = ids && ids.length > 0 ? ids[0] : projectData.goalId ?? null;
|
const legacyGoalId = ids && ids.length > 0 ? ids[0] : projectData.goalId ?? null;
|
||||||
|
|
||||||
@@ -295,6 +338,26 @@ export function projectService(db: Db) {
|
|||||||
): Promise<ProjectWithGoals | null> => {
|
): Promise<ProjectWithGoals | null> => {
|
||||||
const { goalIds: inputGoalIds, ...projectData } = data;
|
const { goalIds: inputGoalIds, ...projectData } = data;
|
||||||
const ids = resolveGoalIds({ goalIds: inputGoalIds, goalId: projectData.goalId });
|
const ids = resolveGoalIds({ goalIds: inputGoalIds, goalId: projectData.goalId });
|
||||||
|
const existingProject = await db
|
||||||
|
.select({ id: projects.id, companyId: projects.companyId, name: projects.name })
|
||||||
|
.from(projects)
|
||||||
|
.where(eq(projects.id, id))
|
||||||
|
.then((rows) => rows[0] ?? null);
|
||||||
|
if (!existingProject) return null;
|
||||||
|
|
||||||
|
if (projectData.name !== undefined) {
|
||||||
|
const existingShortname = normalizeProjectUrlKey(existingProject.name);
|
||||||
|
const nextShortname = normalizeProjectUrlKey(projectData.name);
|
||||||
|
if (existingShortname !== nextShortname) {
|
||||||
|
const existingProjects = await db
|
||||||
|
.select({ id: projects.id, name: projects.name })
|
||||||
|
.from(projects)
|
||||||
|
.where(eq(projects.companyId, existingProject.companyId));
|
||||||
|
projectData.name = resolveProjectNameForUniqueShortname(projectData.name, existingProjects, {
|
||||||
|
excludeProjectId: id,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// Keep legacy goalId column in sync
|
// Keep legacy goalId column in sync
|
||||||
const updates: Partial<typeof projects.$inferInsert> = {
|
const updates: Partial<typeof projects.$inferInsert> = {
|
||||||
|
|||||||
@@ -16,6 +16,8 @@ You run in **heartbeats** — short execution windows triggered by Paperclip. Ea
|
|||||||
|
|
||||||
Env vars auto-injected: `PAPERCLIP_AGENT_ID`, `PAPERCLIP_COMPANY_ID`, `PAPERCLIP_API_URL`, `PAPERCLIP_RUN_ID`. Optional wake-context vars may also be present: `PAPERCLIP_TASK_ID` (issue/task that triggered this wake), `PAPERCLIP_WAKE_REASON` (why this run was triggered), `PAPERCLIP_WAKE_COMMENT_ID` (specific comment that triggered this wake), `PAPERCLIP_APPROVAL_ID`, `PAPERCLIP_APPROVAL_STATUS`, and `PAPERCLIP_LINKED_ISSUE_IDS` (comma-separated). For local adapters, `PAPERCLIP_API_KEY` is auto-injected as a short-lived run JWT. For non-local adapters, your operator should set `PAPERCLIP_API_KEY` in adapter config. All requests use `Authorization: Bearer $PAPERCLIP_API_KEY`. All endpoints under `/api`, all JSON. Never hard-code the API URL.
|
Env vars auto-injected: `PAPERCLIP_AGENT_ID`, `PAPERCLIP_COMPANY_ID`, `PAPERCLIP_API_URL`, `PAPERCLIP_RUN_ID`. Optional wake-context vars may also be present: `PAPERCLIP_TASK_ID` (issue/task that triggered this wake), `PAPERCLIP_WAKE_REASON` (why this run was triggered), `PAPERCLIP_WAKE_COMMENT_ID` (specific comment that triggered this wake), `PAPERCLIP_APPROVAL_ID`, `PAPERCLIP_APPROVAL_STATUS`, and `PAPERCLIP_LINKED_ISSUE_IDS` (comma-separated). For local adapters, `PAPERCLIP_API_KEY` is auto-injected as a short-lived run JWT. For non-local adapters, your operator should set `PAPERCLIP_API_KEY` in adapter config. All requests use `Authorization: Bearer $PAPERCLIP_API_KEY`. All endpoints under `/api`, all JSON. Never hard-code the API URL.
|
||||||
|
|
||||||
|
Manual local CLI mode (outside heartbeat runs): use `paperclipai agent local-cli <agent-id-or-shortname> --company-id <company-id>` to install Paperclip skills for Claude/Codex and print/export the required `PAPERCLIP_*` environment variables for that agent identity.
|
||||||
|
|
||||||
**Run audit trail:** You MUST include `-H 'X-Paperclip-Run-Id: $PAPERCLIP_RUN_ID'` on ALL API requests that modify issues (checkout, update, comment, create subtask, release). This links your actions to the current heartbeat run for traceability.
|
**Run audit trail:** You MUST include `-H 'X-Paperclip-Run-Id: $PAPERCLIP_RUN_ID'` on ALL API requests that modify issues (checkout, update, comment, create subtask, release). This links your actions to the current heartbeat run for traceability.
|
||||||
|
|
||||||
## The Heartbeat Procedure
|
## The Heartbeat Procedure
|
||||||
@@ -222,6 +224,43 @@ GET /api/companies/{companyId}/issues?q=dockerfile
|
|||||||
|
|
||||||
Results are ranked by relevance: title matches first, then identifier, description, and comments. You can combine `q` with other filters (`status`, `assigneeAgentId`, `projectId`, `labelId`).
|
Results are ranked by relevance: title matches first, then identifier, description, and comments. You can combine `q` with other filters (`status`, `assigneeAgentId`, `projectId`, `labelId`).
|
||||||
|
|
||||||
|
## Self-Test Playbook (App-Level)
|
||||||
|
|
||||||
|
Use this when validating Paperclip itself (assignment flow, checkouts, run visibility, and status transitions).
|
||||||
|
|
||||||
|
1. Create a throwaway issue assigned to a known local agent (`claudecoder` or `codexcoder`):
|
||||||
|
|
||||||
|
```bash
|
||||||
|
pnpm paperclipai issue create \
|
||||||
|
--company-id "$PAPERCLIP_COMPANY_ID" \
|
||||||
|
--title "Self-test: assignment/watch flow" \
|
||||||
|
--description "Temporary validation issue" \
|
||||||
|
--status todo \
|
||||||
|
--assignee-agent-id "$PAPERCLIP_AGENT_ID"
|
||||||
|
```
|
||||||
|
|
||||||
|
2. Trigger and watch a heartbeat for that assignee:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
pnpm paperclipai heartbeat run --agent-id "$PAPERCLIP_AGENT_ID"
|
||||||
|
```
|
||||||
|
|
||||||
|
3. Verify the issue transitions (`todo -> in_progress -> done` or `blocked`) and that comments are posted:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
pnpm paperclipai issue get <issue-id-or-identifier>
|
||||||
|
```
|
||||||
|
|
||||||
|
4. Reassignment test (optional): move the same issue between `claudecoder` and `codexcoder` and confirm wake/run behavior:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
pnpm paperclipai issue update <issue-id> --assignee-agent-id <other-agent-id> --status todo
|
||||||
|
```
|
||||||
|
|
||||||
|
5. Cleanup: mark temporary issues done/cancelled with a clear note.
|
||||||
|
|
||||||
|
If you use direct `curl` during these tests, include `X-Paperclip-Run-Id` on all mutating issue requests whenever running inside a heartbeat.
|
||||||
|
|
||||||
## Full Reference
|
## Full Reference
|
||||||
|
|
||||||
For detailed API tables, JSON response schemas, worked examples (IC and Manager heartbeats), governance/approvals, cross-team delegation rules, error codes, issue lifecycle diagram, and the common mistakes table, read: `skills/paperclip/references/api-reference.md`
|
For detailed API tables, JSON response schemas, worked examples (IC and Manager heartbeats), governance/approvals, cross-team delegation rules, error codes, issue lifecycle diagram, and the common mistakes table, read: `skills/paperclip/references/api-reference.md`
|
||||||
|
|||||||
@@ -20,6 +20,7 @@
|
|||||||
"@paperclipai/adapter-opencode-local": "workspace:*",
|
"@paperclipai/adapter-opencode-local": "workspace:*",
|
||||||
"@paperclipai/adapter-pi-local": "workspace:*",
|
"@paperclipai/adapter-pi-local": "workspace:*",
|
||||||
"@paperclipai/adapter-openclaw": "workspace:*",
|
"@paperclipai/adapter-openclaw": "workspace:*",
|
||||||
|
"@paperclipai/adapter-openclaw-gateway": "workspace:*",
|
||||||
"@paperclipai/adapter-utils": "workspace:*",
|
"@paperclipai/adapter-utils": "workspace:*",
|
||||||
"@paperclipai/shared": "workspace:*",
|
"@paperclipai/shared": "workspace:*",
|
||||||
"@radix-ui/react-slot": "^1.2.4",
|
"@radix-ui/react-slot": "^1.2.4",
|
||||||
@@ -29,6 +30,7 @@
|
|||||||
"clsx": "^2.1.1",
|
"clsx": "^2.1.1",
|
||||||
"cmdk": "^1.1.1",
|
"cmdk": "^1.1.1",
|
||||||
"lucide-react": "^0.574.0",
|
"lucide-react": "^0.574.0",
|
||||||
|
"mermaid": "^11.12.0",
|
||||||
"radix-ui": "^1.4.3",
|
"radix-ui": "^1.4.3",
|
||||||
"react": "^19.0.0",
|
"react": "^19.0.0",
|
||||||
"react-dom": "^19.0.0",
|
"react-dom": "^19.0.0",
|
||||||
|
|||||||
@@ -24,6 +24,7 @@ import { Inbox } from "./pages/Inbox";
|
|||||||
import { CompanySettings } from "./pages/CompanySettings";
|
import { CompanySettings } from "./pages/CompanySettings";
|
||||||
import { DesignGuide } from "./pages/DesignGuide";
|
import { DesignGuide } from "./pages/DesignGuide";
|
||||||
import { OrgChart } from "./pages/OrgChart";
|
import { OrgChart } from "./pages/OrgChart";
|
||||||
|
import { NewAgent } from "./pages/NewAgent";
|
||||||
import { AuthPage } from "./pages/Auth";
|
import { AuthPage } from "./pages/Auth";
|
||||||
import { BoardClaimPage } from "./pages/BoardClaim";
|
import { BoardClaimPage } from "./pages/BoardClaim";
|
||||||
import { InviteLandingPage } from "./pages/InviteLanding";
|
import { InviteLandingPage } from "./pages/InviteLanding";
|
||||||
@@ -101,6 +102,7 @@ function boardRoutes() {
|
|||||||
<Route path="agents/active" element={<Agents />} />
|
<Route path="agents/active" element={<Agents />} />
|
||||||
<Route path="agents/paused" element={<Agents />} />
|
<Route path="agents/paused" element={<Agents />} />
|
||||||
<Route path="agents/error" element={<Agents />} />
|
<Route path="agents/error" element={<Agents />} />
|
||||||
|
<Route path="agents/new" element={<NewAgent />} />
|
||||||
<Route path="agents/:agentId" element={<AgentDetail />} />
|
<Route path="agents/:agentId" element={<AgentDetail />} />
|
||||||
<Route path="agents/:agentId/:tab" element={<AgentDetail />} />
|
<Route path="agents/:agentId/:tab" element={<AgentDetail />} />
|
||||||
<Route path="agents/:agentId/runs/:runId" element={<AgentDetail />} />
|
<Route path="agents/:agentId/runs/:runId" element={<AgentDetail />} />
|
||||||
@@ -214,6 +216,7 @@ export function App() {
|
|||||||
<Route path="issues" element={<UnprefixedBoardRedirect />} />
|
<Route path="issues" element={<UnprefixedBoardRedirect />} />
|
||||||
<Route path="issues/:issueId" element={<UnprefixedBoardRedirect />} />
|
<Route path="issues/:issueId" element={<UnprefixedBoardRedirect />} />
|
||||||
<Route path="agents" element={<UnprefixedBoardRedirect />} />
|
<Route path="agents" element={<UnprefixedBoardRedirect />} />
|
||||||
|
<Route path="agents/new" element={<UnprefixedBoardRedirect />} />
|
||||||
<Route path="agents/:agentId" element={<UnprefixedBoardRedirect />} />
|
<Route path="agents/:agentId" element={<UnprefixedBoardRedirect />} />
|
||||||
<Route path="agents/:agentId/:tab" element={<UnprefixedBoardRedirect />} />
|
<Route path="agents/:agentId/:tab" element={<UnprefixedBoardRedirect />} />
|
||||||
<Route path="agents/:agentId/runs/:runId" element={<UnprefixedBoardRedirect />} />
|
<Route path="agents/:agentId/runs/:runId" element={<UnprefixedBoardRedirect />} />
|
||||||
|
|||||||
221
ui/src/adapters/openclaw-gateway/config-fields.tsx
Normal file
221
ui/src/adapters/openclaw-gateway/config-fields.tsx
Normal file
@@ -0,0 +1,221 @@
|
|||||||
|
import { useState } from "react";
|
||||||
|
import { Eye, EyeOff } from "lucide-react";
|
||||||
|
import type { AdapterConfigFieldsProps } from "../types";
|
||||||
|
import {
|
||||||
|
Field,
|
||||||
|
DraftInput,
|
||||||
|
help,
|
||||||
|
} from "../../components/agent-config-primitives";
|
||||||
|
|
||||||
|
const inputClass =
|
||||||
|
"w-full rounded-md border border-border px-2.5 py-1.5 bg-transparent outline-none text-sm font-mono placeholder:text-muted-foreground/40";
|
||||||
|
|
||||||
|
function SecretField({
|
||||||
|
label,
|
||||||
|
value,
|
||||||
|
onCommit,
|
||||||
|
placeholder,
|
||||||
|
}: {
|
||||||
|
label: string;
|
||||||
|
value: string;
|
||||||
|
onCommit: (v: string) => void;
|
||||||
|
placeholder?: string;
|
||||||
|
}) {
|
||||||
|
const [visible, setVisible] = useState(false);
|
||||||
|
return (
|
||||||
|
<Field label={label}>
|
||||||
|
<div className="relative">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => setVisible((v) => !v)}
|
||||||
|
className="absolute left-2 top-1/2 -translate-y-1/2 text-muted-foreground/50 hover:text-muted-foreground transition-colors"
|
||||||
|
>
|
||||||
|
{visible ? <Eye className="h-3.5 w-3.5" /> : <EyeOff className="h-3.5 w-3.5" />}
|
||||||
|
</button>
|
||||||
|
<DraftInput
|
||||||
|
value={value}
|
||||||
|
onCommit={onCommit}
|
||||||
|
immediate
|
||||||
|
type={visible ? "text" : "password"}
|
||||||
|
className={inputClass + " pl-8"}
|
||||||
|
placeholder={placeholder}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</Field>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function parseScopes(value: unknown): string {
|
||||||
|
if (Array.isArray(value)) {
|
||||||
|
return value.filter((entry): entry is string => typeof entry === "string").join(", ");
|
||||||
|
}
|
||||||
|
return typeof value === "string" ? value : "";
|
||||||
|
}
|
||||||
|
|
||||||
|
export function OpenClawGatewayConfigFields({
|
||||||
|
isCreate,
|
||||||
|
values,
|
||||||
|
set,
|
||||||
|
config,
|
||||||
|
eff,
|
||||||
|
mark,
|
||||||
|
}: AdapterConfigFieldsProps) {
|
||||||
|
const configuredHeaders =
|
||||||
|
config.headers && typeof config.headers === "object" && !Array.isArray(config.headers)
|
||||||
|
? (config.headers as Record<string, unknown>)
|
||||||
|
: {};
|
||||||
|
const effectiveHeaders =
|
||||||
|
(eff("adapterConfig", "headers", configuredHeaders) as Record<string, unknown>) ?? {};
|
||||||
|
|
||||||
|
const effectiveGatewayToken = typeof effectiveHeaders["x-openclaw-token"] === "string"
|
||||||
|
? String(effectiveHeaders["x-openclaw-token"])
|
||||||
|
: typeof effectiveHeaders["x-openclaw-auth"] === "string"
|
||||||
|
? String(effectiveHeaders["x-openclaw-auth"])
|
||||||
|
: "";
|
||||||
|
|
||||||
|
const commitGatewayToken = (rawValue: string) => {
|
||||||
|
const nextValue = rawValue.trim();
|
||||||
|
const nextHeaders: Record<string, unknown> = { ...effectiveHeaders };
|
||||||
|
if (nextValue) {
|
||||||
|
nextHeaders["x-openclaw-token"] = nextValue;
|
||||||
|
delete nextHeaders["x-openclaw-auth"];
|
||||||
|
} else {
|
||||||
|
delete nextHeaders["x-openclaw-token"];
|
||||||
|
delete nextHeaders["x-openclaw-auth"];
|
||||||
|
}
|
||||||
|
mark("adapterConfig", "headers", Object.keys(nextHeaders).length > 0 ? nextHeaders : undefined);
|
||||||
|
};
|
||||||
|
|
||||||
|
const sessionStrategy = eff(
|
||||||
|
"adapterConfig",
|
||||||
|
"sessionKeyStrategy",
|
||||||
|
String(config.sessionKeyStrategy ?? "fixed"),
|
||||||
|
);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<Field label="Gateway URL" hint={help.webhookUrl}>
|
||||||
|
<DraftInput
|
||||||
|
value={
|
||||||
|
isCreate
|
||||||
|
? values!.url
|
||||||
|
: eff("adapterConfig", "url", String(config.url ?? ""))
|
||||||
|
}
|
||||||
|
onCommit={(v) =>
|
||||||
|
isCreate
|
||||||
|
? set!({ url: v })
|
||||||
|
: mark("adapterConfig", "url", v || undefined)
|
||||||
|
}
|
||||||
|
immediate
|
||||||
|
className={inputClass}
|
||||||
|
placeholder="ws://127.0.0.1:18789"
|
||||||
|
/>
|
||||||
|
</Field>
|
||||||
|
|
||||||
|
{!isCreate && (
|
||||||
|
<>
|
||||||
|
<Field label="Paperclip API URL override">
|
||||||
|
<DraftInput
|
||||||
|
value={
|
||||||
|
eff(
|
||||||
|
"adapterConfig",
|
||||||
|
"paperclipApiUrl",
|
||||||
|
String(config.paperclipApiUrl ?? ""),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
onCommit={(v) => mark("adapterConfig", "paperclipApiUrl", v || undefined)}
|
||||||
|
immediate
|
||||||
|
className={inputClass}
|
||||||
|
placeholder="https://paperclip.example"
|
||||||
|
/>
|
||||||
|
</Field>
|
||||||
|
|
||||||
|
<Field label="Session strategy">
|
||||||
|
<select
|
||||||
|
value={sessionStrategy}
|
||||||
|
onChange={(e) => mark("adapterConfig", "sessionKeyStrategy", e.target.value)}
|
||||||
|
className={inputClass}
|
||||||
|
>
|
||||||
|
<option value="fixed">Fixed</option>
|
||||||
|
<option value="issue">Per issue</option>
|
||||||
|
<option value="run">Per run</option>
|
||||||
|
</select>
|
||||||
|
</Field>
|
||||||
|
|
||||||
|
{sessionStrategy === "fixed" && (
|
||||||
|
<Field label="Session key">
|
||||||
|
<DraftInput
|
||||||
|
value={eff("adapterConfig", "sessionKey", String(config.sessionKey ?? "paperclip"))}
|
||||||
|
onCommit={(v) => mark("adapterConfig", "sessionKey", v || undefined)}
|
||||||
|
immediate
|
||||||
|
className={inputClass}
|
||||||
|
placeholder="paperclip"
|
||||||
|
/>
|
||||||
|
</Field>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<SecretField
|
||||||
|
label="Gateway auth token (x-openclaw-token)"
|
||||||
|
value={effectiveGatewayToken}
|
||||||
|
onCommit={commitGatewayToken}
|
||||||
|
placeholder="OpenClaw gateway token"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<Field label="Role">
|
||||||
|
<DraftInput
|
||||||
|
value={eff("adapterConfig", "role", String(config.role ?? "operator"))}
|
||||||
|
onCommit={(v) => mark("adapterConfig", "role", v || undefined)}
|
||||||
|
immediate
|
||||||
|
className={inputClass}
|
||||||
|
placeholder="operator"
|
||||||
|
/>
|
||||||
|
</Field>
|
||||||
|
|
||||||
|
<Field label="Scopes (comma-separated)">
|
||||||
|
<DraftInput
|
||||||
|
value={eff("adapterConfig", "scopes", parseScopes(config.scopes ?? ["operator.admin"]))}
|
||||||
|
onCommit={(v) => {
|
||||||
|
const parsed = v
|
||||||
|
.split(",")
|
||||||
|
.map((entry) => entry.trim())
|
||||||
|
.filter(Boolean);
|
||||||
|
mark("adapterConfig", "scopes", parsed.length > 0 ? parsed : undefined);
|
||||||
|
}}
|
||||||
|
immediate
|
||||||
|
className={inputClass}
|
||||||
|
placeholder="operator.admin"
|
||||||
|
/>
|
||||||
|
</Field>
|
||||||
|
|
||||||
|
<Field label="Wait timeout (ms)">
|
||||||
|
<DraftInput
|
||||||
|
value={eff("adapterConfig", "waitTimeoutMs", String(config.waitTimeoutMs ?? "120000"))}
|
||||||
|
onCommit={(v) => {
|
||||||
|
const parsed = Number.parseInt(v.trim(), 10);
|
||||||
|
mark(
|
||||||
|
"adapterConfig",
|
||||||
|
"waitTimeoutMs",
|
||||||
|
Number.isFinite(parsed) && parsed > 0 ? parsed : undefined,
|
||||||
|
);
|
||||||
|
}}
|
||||||
|
immediate
|
||||||
|
className={inputClass}
|
||||||
|
placeholder="120000"
|
||||||
|
/>
|
||||||
|
</Field>
|
||||||
|
|
||||||
|
<Field label="Disable device auth">
|
||||||
|
<select
|
||||||
|
value={String(eff("adapterConfig", "disableDeviceAuth", Boolean(config.disableDeviceAuth ?? false)))}
|
||||||
|
onChange={(e) => mark("adapterConfig", "disableDeviceAuth", e.target.value === "true")}
|
||||||
|
className={inputClass}
|
||||||
|
>
|
||||||
|
<option value="false">No (recommended)</option>
|
||||||
|
<option value="true">Yes</option>
|
||||||
|
</select>
|
||||||
|
</Field>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
12
ui/src/adapters/openclaw-gateway/index.ts
Normal file
12
ui/src/adapters/openclaw-gateway/index.ts
Normal file
@@ -0,0 +1,12 @@
|
|||||||
|
import type { UIAdapterModule } from "../types";
|
||||||
|
import { parseOpenClawGatewayStdoutLine } from "@paperclipai/adapter-openclaw-gateway/ui";
|
||||||
|
import { buildOpenClawGatewayConfig } from "@paperclipai/adapter-openclaw-gateway/ui";
|
||||||
|
import { OpenClawGatewayConfigFields } from "./config-fields";
|
||||||
|
|
||||||
|
export const openClawGatewayUIAdapter: UIAdapterModule = {
|
||||||
|
type: "openclaw_gateway",
|
||||||
|
label: "OpenClaw Gateway",
|
||||||
|
parseStdoutLine: parseOpenClawGatewayStdoutLine,
|
||||||
|
ConfigFields: OpenClawGatewayConfigFields,
|
||||||
|
buildAdapterConfig: buildOpenClawGatewayConfig,
|
||||||
|
};
|
||||||
@@ -5,11 +5,22 @@ import { cursorLocalUIAdapter } from "./cursor";
|
|||||||
import { openCodeLocalUIAdapter } from "./opencode-local";
|
import { openCodeLocalUIAdapter } from "./opencode-local";
|
||||||
import { piLocalUIAdapter } from "./pi-local";
|
import { piLocalUIAdapter } from "./pi-local";
|
||||||
import { openClawUIAdapter } from "./openclaw";
|
import { openClawUIAdapter } from "./openclaw";
|
||||||
|
import { openClawGatewayUIAdapter } from "./openclaw-gateway";
|
||||||
import { processUIAdapter } from "./process";
|
import { processUIAdapter } from "./process";
|
||||||
import { httpUIAdapter } from "./http";
|
import { httpUIAdapter } from "./http";
|
||||||
|
|
||||||
const adaptersByType = new Map<string, UIAdapterModule>(
|
const adaptersByType = new Map<string, UIAdapterModule>(
|
||||||
[claudeLocalUIAdapter, codexLocalUIAdapter, openCodeLocalUIAdapter, piLocalUIAdapter, cursorLocalUIAdapter, openClawUIAdapter, processUIAdapter, httpUIAdapter].map((a) => [a.type, a]),
|
[
|
||||||
|
claudeLocalUIAdapter,
|
||||||
|
codexLocalUIAdapter,
|
||||||
|
openCodeLocalUIAdapter,
|
||||||
|
piLocalUIAdapter,
|
||||||
|
cursorLocalUIAdapter,
|
||||||
|
openClawUIAdapter,
|
||||||
|
openClawGatewayUIAdapter,
|
||||||
|
processUIAdapter,
|
||||||
|
httpUIAdapter,
|
||||||
|
].map((a) => [a.type, a]),
|
||||||
);
|
);
|
||||||
|
|
||||||
export function getUIAdapter(type: string): UIAdapterModule {
|
export function getUIAdapter(type: string): UIAdapterModule {
|
||||||
|
|||||||
@@ -461,7 +461,7 @@ function AgentRunCard({
|
|||||||
<Link
|
<Link
|
||||||
to={`/issues/${issue?.identifier ?? run.issueId}`}
|
to={`/issues/${issue?.identifier ?? run.issueId}`}
|
||||||
className={cn(
|
className={cn(
|
||||||
"hover:underline min-w-0 truncate",
|
"hover:underline min-w-0 line-clamp-2 min-h-[2rem]",
|
||||||
isActive ? "text-blue-600 hover:text-blue-500 dark:text-blue-400 dark:hover:text-blue-300" : "text-muted-foreground hover:text-foreground",
|
isActive ? "text-blue-600 hover:text-blue-500 dark:text-blue-400 dark:hover:text-blue-300" : "text-muted-foreground hover:text-foreground",
|
||||||
)}
|
)}
|
||||||
title={issue?.title ? `${issue?.identifier ?? run.issueId.slice(0, 8)} - ${issue.title}` : issue?.identifier ?? run.issueId.slice(0, 8)}
|
title={issue?.title ? `${issue?.identifier ?? run.issueId.slice(0, 8)} - ${issue.title}` : issue?.identifier ?? run.issueId.slice(0, 8)}
|
||||||
|
|||||||
@@ -19,6 +19,7 @@ const adapterLabels: Record<string, string> = {
|
|||||||
codex_local: "Codex (local)",
|
codex_local: "Codex (local)",
|
||||||
opencode_local: "OpenCode (local)",
|
opencode_local: "OpenCode (local)",
|
||||||
openclaw: "OpenClaw",
|
openclaw: "OpenClaw",
|
||||||
|
openclaw_gateway: "OpenClaw Gateway",
|
||||||
cursor: "Cursor (local)",
|
cursor: "Cursor (local)",
|
||||||
process: "Process",
|
process: "Process",
|
||||||
http: "HTTP",
|
http: "HTTP",
|
||||||
|
|||||||
@@ -2,7 +2,7 @@ import { memo, useEffect, useMemo, useRef, useState, type ChangeEvent } from "re
|
|||||||
import { Link, useLocation } from "react-router-dom";
|
import { Link, useLocation } from "react-router-dom";
|
||||||
import type { IssueComment, Agent } from "@paperclipai/shared";
|
import type { IssueComment, Agent } from "@paperclipai/shared";
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
import { Paperclip } from "lucide-react";
|
import { Check, Copy, Paperclip } from "lucide-react";
|
||||||
import { Identity } from "./Identity";
|
import { Identity } from "./Identity";
|
||||||
import { InlineEntitySelector, type InlineEntityOption } from "./InlineEntitySelector";
|
import { InlineEntitySelector, type InlineEntityOption } from "./InlineEntitySelector";
|
||||||
import { MarkdownBody } from "./MarkdownBody";
|
import { MarkdownBody } from "./MarkdownBody";
|
||||||
@@ -92,6 +92,25 @@ function parseReassignment(target: string): CommentReassignment | null {
|
|||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function CopyMarkdownButton({ text }: { text: string }) {
|
||||||
|
const [copied, setCopied] = useState(false);
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="text-muted-foreground hover:text-foreground transition-colors"
|
||||||
|
title="Copy as markdown"
|
||||||
|
onClick={() => {
|
||||||
|
navigator.clipboard.writeText(text).then(() => {
|
||||||
|
setCopied(true);
|
||||||
|
setTimeout(() => setCopied(false), 2000);
|
||||||
|
});
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{copied ? <Check className="h-3 w-3" /> : <Copy className="h-3 w-3" />}
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
type TimelineItem =
|
type TimelineItem =
|
||||||
| { kind: "comment"; id: string; createdAtMs: number; comment: CommentWithRunMeta }
|
| { kind: "comment"; id: string; createdAtMs: number; comment: CommentWithRunMeta }
|
||||||
| { kind: "run"; id: string; createdAtMs: number; run: LinkedRunItem };
|
| { kind: "run"; id: string; createdAtMs: number; run: LinkedRunItem };
|
||||||
@@ -160,12 +179,15 @@ const TimelineList = memo(function TimelineList({
|
|||||||
) : (
|
) : (
|
||||||
<Identity name="You" size="sm" />
|
<Identity name="You" size="sm" />
|
||||||
)}
|
)}
|
||||||
<a
|
<span className="flex items-center gap-1.5">
|
||||||
href={`#comment-${comment.id}`}
|
<a
|
||||||
className="text-xs text-muted-foreground hover:text-foreground hover:underline transition-colors"
|
href={`#comment-${comment.id}`}
|
||||||
>
|
className="text-xs text-muted-foreground hover:text-foreground hover:underline transition-colors"
|
||||||
{formatDateTime(comment.createdAt)}
|
>
|
||||||
</a>
|
{formatDateTime(comment.createdAt)}
|
||||||
|
</a>
|
||||||
|
<CopyMarkdownButton text={comment.body} />
|
||||||
|
</span>
|
||||||
</div>
|
</div>
|
||||||
<MarkdownBody className="text-sm">{comment.body}</MarkdownBody>
|
<MarkdownBody className="text-sm">{comment.body}</MarkdownBody>
|
||||||
{comment.runId && (
|
{comment.runId && (
|
||||||
|
|||||||
@@ -157,7 +157,7 @@ function parseStdoutChunk(
|
|||||||
if (!trimmed) continue;
|
if (!trimmed) continue;
|
||||||
const parsed = adapter.parseStdoutLine(trimmed, ts);
|
const parsed = adapter.parseStdoutLine(trimmed, ts);
|
||||||
if (parsed.length === 0) {
|
if (parsed.length === 0) {
|
||||||
if (run.adapterType === "openclaw") {
|
if (run.adapterType === "openclaw" || run.adapterType === "openclaw_gateway") {
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
const fallback = createFeedItem(run, ts, trimmed, "info", nextIdRef.current++);
|
const fallback = createFeedItem(run, ts, trimmed, "info", nextIdRef.current++);
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import type { CSSProperties } from "react";
|
import { isValidElement, useEffect, useId, useState, type CSSProperties, type ReactNode } from "react";
|
||||||
import Markdown from "react-markdown";
|
import Markdown from "react-markdown";
|
||||||
import remarkGfm from "remark-gfm";
|
import remarkGfm from "remark-gfm";
|
||||||
import { parseProjectMentionHref } from "@paperclipai/shared";
|
import { parseProjectMentionHref } from "@paperclipai/shared";
|
||||||
@@ -10,6 +10,30 @@ interface MarkdownBodyProps {
|
|||||||
className?: string;
|
className?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
let mermaidLoaderPromise: Promise<typeof import("mermaid").default> | null = null;
|
||||||
|
|
||||||
|
function loadMermaid() {
|
||||||
|
if (!mermaidLoaderPromise) {
|
||||||
|
mermaidLoaderPromise = import("mermaid").then((module) => module.default);
|
||||||
|
}
|
||||||
|
return mermaidLoaderPromise;
|
||||||
|
}
|
||||||
|
|
||||||
|
function flattenText(value: ReactNode): string {
|
||||||
|
if (value == null) return "";
|
||||||
|
if (typeof value === "string" || typeof value === "number") return String(value);
|
||||||
|
if (Array.isArray(value)) return value.map((item) => flattenText(item)).join("");
|
||||||
|
return "";
|
||||||
|
}
|
||||||
|
|
||||||
|
function extractMermaidSource(children: ReactNode): string | null {
|
||||||
|
if (!isValidElement(children)) return null;
|
||||||
|
const childProps = children.props as { className?: unknown; children?: ReactNode };
|
||||||
|
if (typeof childProps.className !== "string") return null;
|
||||||
|
if (!/\blanguage-mermaid\b/i.test(childProps.className)) return null;
|
||||||
|
return flattenText(childProps.children).replace(/\n$/, "");
|
||||||
|
}
|
||||||
|
|
||||||
function hexToRgb(hex: string): { r: number; g: number; b: number } | null {
|
function hexToRgb(hex: string): { r: number; g: number; b: number } | null {
|
||||||
const match = /^#([0-9a-f]{6})$/i.exec(hex.trim());
|
const match = /^#([0-9a-f]{6})$/i.exec(hex.trim());
|
||||||
if (!match) return null;
|
if (!match) return null;
|
||||||
@@ -33,6 +57,61 @@ function mentionChipStyle(color: string | null): CSSProperties | undefined {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function MermaidDiagramBlock({ source, darkMode }: { source: string; darkMode: boolean }) {
|
||||||
|
const renderId = useId().replace(/[^a-zA-Z0-9_-]/g, "");
|
||||||
|
const [svg, setSvg] = useState<string | null>(null);
|
||||||
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
let active = true;
|
||||||
|
setSvg(null);
|
||||||
|
setError(null);
|
||||||
|
|
||||||
|
loadMermaid()
|
||||||
|
.then(async (mermaid) => {
|
||||||
|
mermaid.initialize({
|
||||||
|
startOnLoad: false,
|
||||||
|
securityLevel: "strict",
|
||||||
|
theme: darkMode ? "dark" : "default",
|
||||||
|
fontFamily: "inherit",
|
||||||
|
suppressErrorRendering: true,
|
||||||
|
});
|
||||||
|
const rendered = await mermaid.render(`paperclip-mermaid-${renderId}`, source);
|
||||||
|
if (!active) return;
|
||||||
|
setSvg(rendered.svg);
|
||||||
|
})
|
||||||
|
.catch((err) => {
|
||||||
|
if (!active) return;
|
||||||
|
const message =
|
||||||
|
err instanceof Error && err.message
|
||||||
|
? err.message
|
||||||
|
: "Failed to render Mermaid diagram.";
|
||||||
|
setError(message);
|
||||||
|
});
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
active = false;
|
||||||
|
};
|
||||||
|
}, [darkMode, renderId, source]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="paperclip-mermaid">
|
||||||
|
{svg ? (
|
||||||
|
<div dangerouslySetInnerHTML={{ __html: svg }} />
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<p className={cn("paperclip-mermaid-status", error && "paperclip-mermaid-status-error")}>
|
||||||
|
{error ? `Unable to render Mermaid diagram: ${error}` : "Rendering Mermaid diagram..."}
|
||||||
|
</p>
|
||||||
|
<pre className="paperclip-mermaid-source">
|
||||||
|
<code className="language-mermaid">{source}</code>
|
||||||
|
</pre>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
export function MarkdownBody({ children, className }: MarkdownBodyProps) {
|
export function MarkdownBody({ children, className }: MarkdownBodyProps) {
|
||||||
const { theme } = useTheme();
|
const { theme } = useTheme();
|
||||||
return (
|
return (
|
||||||
@@ -46,6 +125,13 @@ export function MarkdownBody({ children, className }: MarkdownBodyProps) {
|
|||||||
<Markdown
|
<Markdown
|
||||||
remarkPlugins={[remarkGfm]}
|
remarkPlugins={[remarkGfm]}
|
||||||
components={{
|
components={{
|
||||||
|
pre: ({ node: _node, children: preChildren, ...preProps }) => {
|
||||||
|
const mermaidSource = extractMermaidSource(preChildren);
|
||||||
|
if (mermaidSource) {
|
||||||
|
return <MermaidDiagramBlock source={mermaidSource} darkMode={theme === "dark"} />;
|
||||||
|
}
|
||||||
|
return <pre {...preProps}>{preChildren}</pre>;
|
||||||
|
},
|
||||||
a: ({ href, children: linkChildren }) => {
|
a: ({ href, children: linkChildren }) => {
|
||||||
const parsed = href ? parseProjectMentionHref(href) : null;
|
const parsed = href ? parseProjectMentionHref(href) : null;
|
||||||
if (parsed) {
|
if (parsed) {
|
||||||
|
|||||||
@@ -1,53 +1,20 @@
|
|||||||
import { useState, useEffect } from "react";
|
import { useQuery } from "@tanstack/react-query";
|
||||||
import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query";
|
|
||||||
import { useNavigate } from "@/lib/router";
|
import { useNavigate } from "@/lib/router";
|
||||||
import { useDialog } from "../context/DialogContext";
|
import { useDialog } from "../context/DialogContext";
|
||||||
import { useCompany } from "../context/CompanyContext";
|
import { useCompany } from "../context/CompanyContext";
|
||||||
import { agentsApi } from "../api/agents";
|
import { agentsApi } from "../api/agents";
|
||||||
import { queryKeys } from "../lib/queryKeys";
|
import { queryKeys } from "../lib/queryKeys";
|
||||||
import { AGENT_ROLES } from "@paperclipai/shared";
|
|
||||||
import {
|
import {
|
||||||
Dialog,
|
Dialog,
|
||||||
DialogContent,
|
DialogContent,
|
||||||
} from "@/components/ui/dialog";
|
} from "@/components/ui/dialog";
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
import {
|
import { Bot, Sparkles } from "lucide-react";
|
||||||
Popover,
|
|
||||||
PopoverContent,
|
|
||||||
PopoverTrigger,
|
|
||||||
} from "@/components/ui/popover";
|
|
||||||
import {
|
|
||||||
Minimize2,
|
|
||||||
Maximize2,
|
|
||||||
Shield,
|
|
||||||
User,
|
|
||||||
} from "lucide-react";
|
|
||||||
import { cn, agentUrl } from "../lib/utils";
|
|
||||||
import { roleLabels } from "./agent-config-primitives";
|
|
||||||
import { AgentConfigForm, type CreateConfigValues } from "./AgentConfigForm";
|
|
||||||
import { defaultCreateValues } from "./agent-config-defaults";
|
|
||||||
import { getUIAdapter } from "../adapters";
|
|
||||||
import { AgentIcon } from "./AgentIconPicker";
|
|
||||||
|
|
||||||
export function NewAgentDialog() {
|
export function NewAgentDialog() {
|
||||||
const { newAgentOpen, closeNewAgent } = useDialog();
|
const { newAgentOpen, closeNewAgent, openNewIssue } = useDialog();
|
||||||
const { selectedCompanyId, selectedCompany } = useCompany();
|
const { selectedCompanyId } = useCompany();
|
||||||
const queryClient = useQueryClient();
|
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
const [expanded, setExpanded] = useState(true);
|
|
||||||
|
|
||||||
// Identity
|
|
||||||
const [name, setName] = useState("");
|
|
||||||
const [title, setTitle] = useState("");
|
|
||||||
const [role, setRole] = useState("general");
|
|
||||||
const [reportsTo, setReportsTo] = useState("");
|
|
||||||
|
|
||||||
// Config values (managed by AgentConfigForm)
|
|
||||||
const [configValues, setConfigValues] = useState<CreateConfigValues>(defaultCreateValues);
|
|
||||||
|
|
||||||
// Popover states
|
|
||||||
const [roleOpen, setRoleOpen] = useState(false);
|
|
||||||
const [reportsToOpen, setReportsToOpen] = useState(false);
|
|
||||||
|
|
||||||
const { data: agents } = useQuery({
|
const { data: agents } = useQuery({
|
||||||
queryKey: queryKeys.agents.list(selectedCompanyId!),
|
queryKey: queryKeys.agents.list(selectedCompanyId!),
|
||||||
@@ -55,287 +22,74 @@ export function NewAgentDialog() {
|
|||||||
enabled: !!selectedCompanyId && newAgentOpen,
|
enabled: !!selectedCompanyId && newAgentOpen,
|
||||||
});
|
});
|
||||||
|
|
||||||
const {
|
const ceoAgent = (agents ?? []).find((a) => a.role === "ceo");
|
||||||
data: adapterModels,
|
|
||||||
error: adapterModelsError,
|
|
||||||
isLoading: adapterModelsLoading,
|
|
||||||
isFetching: adapterModelsFetching,
|
|
||||||
} = useQuery({
|
|
||||||
queryKey:
|
|
||||||
selectedCompanyId
|
|
||||||
? queryKeys.agents.adapterModels(selectedCompanyId, configValues.adapterType)
|
|
||||||
: ["agents", "none", "adapter-models", configValues.adapterType],
|
|
||||||
queryFn: () => agentsApi.adapterModels(selectedCompanyId!, configValues.adapterType),
|
|
||||||
enabled: Boolean(selectedCompanyId) && newAgentOpen,
|
|
||||||
});
|
|
||||||
|
|
||||||
const isFirstAgent = !agents || agents.length === 0;
|
function handleAskCeo() {
|
||||||
const effectiveRole = isFirstAgent ? "ceo" : role;
|
closeNewAgent();
|
||||||
const [formError, setFormError] = useState<string | null>(null);
|
openNewIssue({
|
||||||
|
assigneeAgentId: ceoAgent?.id,
|
||||||
// Auto-fill for CEO
|
title: "Create a new agent",
|
||||||
useEffect(() => {
|
description: "(type in what kind of agent you want here)",
|
||||||
if (newAgentOpen && isFirstAgent) {
|
|
||||||
if (!name) setName("CEO");
|
|
||||||
if (!title) setTitle("CEO");
|
|
||||||
}
|
|
||||||
}, [newAgentOpen, isFirstAgent]); // eslint-disable-line react-hooks/exhaustive-deps
|
|
||||||
|
|
||||||
const createAgent = useMutation({
|
|
||||||
mutationFn: (data: Record<string, unknown>) =>
|
|
||||||
agentsApi.hire(selectedCompanyId!, data),
|
|
||||||
onSuccess: (result) => {
|
|
||||||
queryClient.invalidateQueries({ queryKey: queryKeys.agents.list(selectedCompanyId!) });
|
|
||||||
queryClient.invalidateQueries({ queryKey: queryKeys.approvals.list(selectedCompanyId!) });
|
|
||||||
reset();
|
|
||||||
closeNewAgent();
|
|
||||||
navigate(agentUrl(result.agent));
|
|
||||||
},
|
|
||||||
onError: (error) => {
|
|
||||||
setFormError(error instanceof Error ? error.message : "Failed to create agent");
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
function reset() {
|
|
||||||
setName("");
|
|
||||||
setTitle("");
|
|
||||||
setRole("general");
|
|
||||||
setReportsTo("");
|
|
||||||
setConfigValues(defaultCreateValues);
|
|
||||||
setExpanded(true);
|
|
||||||
setFormError(null);
|
|
||||||
}
|
|
||||||
|
|
||||||
function buildAdapterConfig() {
|
|
||||||
const adapter = getUIAdapter(configValues.adapterType);
|
|
||||||
return adapter.buildAdapterConfig(configValues);
|
|
||||||
}
|
|
||||||
|
|
||||||
function handleSubmit() {
|
|
||||||
if (!selectedCompanyId || !name.trim()) return;
|
|
||||||
setFormError(null);
|
|
||||||
if (configValues.adapterType === "opencode_local") {
|
|
||||||
const selectedModel = configValues.model.trim();
|
|
||||||
if (!selectedModel) {
|
|
||||||
setFormError("OpenCode requires an explicit model in provider/model format.");
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
if (adapterModelsError) {
|
|
||||||
setFormError(
|
|
||||||
adapterModelsError instanceof Error
|
|
||||||
? adapterModelsError.message
|
|
||||||
: "Failed to load OpenCode models.",
|
|
||||||
);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
if (adapterModelsLoading || adapterModelsFetching) {
|
|
||||||
setFormError("OpenCode models are still loading. Please wait and try again.");
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
const discovered = adapterModels ?? [];
|
|
||||||
if (!discovered.some((entry) => entry.id === selectedModel)) {
|
|
||||||
setFormError(
|
|
||||||
discovered.length === 0
|
|
||||||
? "No OpenCode models discovered. Run `opencode models` and authenticate providers."
|
|
||||||
: `Configured OpenCode model is unavailable: ${selectedModel}`,
|
|
||||||
);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
createAgent.mutate({
|
|
||||||
name: name.trim(),
|
|
||||||
role: effectiveRole,
|
|
||||||
...(title.trim() ? { title: title.trim() } : {}),
|
|
||||||
...(reportsTo ? { reportsTo } : {}),
|
|
||||||
adapterType: configValues.adapterType,
|
|
||||||
adapterConfig: buildAdapterConfig(),
|
|
||||||
runtimeConfig: {
|
|
||||||
heartbeat: {
|
|
||||||
enabled: configValues.heartbeatEnabled,
|
|
||||||
intervalSec: configValues.intervalSec,
|
|
||||||
wakeOnDemand: true,
|
|
||||||
cooldownSec: 10,
|
|
||||||
maxConcurrentRuns: 1,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
budgetMonthlyCents: 0,
|
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
function handleKeyDown(e: React.KeyboardEvent) {
|
function handleAdvancedConfig() {
|
||||||
if (e.key === "Enter" && (e.metaKey || e.ctrlKey)) {
|
closeNewAgent();
|
||||||
e.preventDefault();
|
navigate("/agents/new");
|
||||||
handleSubmit();
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const currentReportsTo = (agents ?? []).find((a) => a.id === reportsTo);
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Dialog
|
<Dialog
|
||||||
open={newAgentOpen}
|
open={newAgentOpen}
|
||||||
onOpenChange={(open) => {
|
onOpenChange={(open) => {
|
||||||
if (!open) { reset(); closeNewAgent(); }
|
if (!open) closeNewAgent();
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<DialogContent
|
<DialogContent
|
||||||
showCloseButton={false}
|
showCloseButton={false}
|
||||||
className={cn("p-0 gap-0 overflow-hidden", expanded ? "sm:max-w-2xl" : "sm:max-w-lg")}
|
className="sm:max-w-md p-0 gap-0 overflow-hidden"
|
||||||
onKeyDown={handleKeyDown}
|
|
||||||
>
|
>
|
||||||
{/* Header */}
|
{/* Header */}
|
||||||
<div className="flex items-center justify-between px-4 py-2.5 border-b border-border">
|
<div className="flex items-center justify-between px-4 py-2.5 border-b border-border">
|
||||||
<div className="flex items-center gap-2 text-sm text-muted-foreground">
|
<span className="text-sm text-muted-foreground">Add a new agent</span>
|
||||||
{selectedCompany && (
|
|
||||||
<span className="bg-muted px-1.5 py-0.5 rounded text-xs font-medium">
|
|
||||||
{selectedCompany.name.slice(0, 3).toUpperCase()}
|
|
||||||
</span>
|
|
||||||
)}
|
|
||||||
<span className="text-muted-foreground/60">›</span>
|
|
||||||
<span>New agent</span>
|
|
||||||
</div>
|
|
||||||
<div className="flex items-center gap-1">
|
|
||||||
<Button variant="ghost" size="icon-xs" className="text-muted-foreground" onClick={() => setExpanded(!expanded)}>
|
|
||||||
{expanded ? <Minimize2 className="h-3.5 w-3.5" /> : <Maximize2 className="h-3.5 w-3.5" />}
|
|
||||||
</Button>
|
|
||||||
<Button variant="ghost" size="icon-xs" className="text-muted-foreground" onClick={() => { reset(); closeNewAgent(); }}>
|
|
||||||
<span className="text-lg leading-none">×</span>
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="overflow-y-auto max-h-[70vh]">
|
|
||||||
{/* Name */}
|
|
||||||
<div className="px-4 pt-4 pb-2 shrink-0">
|
|
||||||
<input
|
|
||||||
className="w-full text-lg font-semibold bg-transparent outline-none placeholder:text-muted-foreground/50"
|
|
||||||
placeholder="Agent name"
|
|
||||||
value={name}
|
|
||||||
onChange={(e) => setName(e.target.value)}
|
|
||||||
autoFocus
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Title */}
|
|
||||||
<div className="px-4 pb-2">
|
|
||||||
<input
|
|
||||||
className="w-full bg-transparent outline-none text-sm text-muted-foreground placeholder:text-muted-foreground/40"
|
|
||||||
placeholder="Title (e.g. VP of Engineering)"
|
|
||||||
value={title}
|
|
||||||
onChange={(e) => setTitle(e.target.value)}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Property chips: Role + Reports To */}
|
|
||||||
<div className="flex items-center gap-1.5 px-4 py-2 border-t border-border flex-wrap">
|
|
||||||
{/* Role */}
|
|
||||||
<Popover open={roleOpen} onOpenChange={setRoleOpen}>
|
|
||||||
<PopoverTrigger asChild>
|
|
||||||
<button
|
|
||||||
className={cn(
|
|
||||||
"inline-flex items-center gap-1.5 rounded-md border border-border px-2 py-1 text-xs hover:bg-accent/50 transition-colors",
|
|
||||||
isFirstAgent && "opacity-60 cursor-not-allowed"
|
|
||||||
)}
|
|
||||||
disabled={isFirstAgent}
|
|
||||||
>
|
|
||||||
<Shield className="h-3 w-3 text-muted-foreground" />
|
|
||||||
{roleLabels[effectiveRole] ?? effectiveRole}
|
|
||||||
</button>
|
|
||||||
</PopoverTrigger>
|
|
||||||
<PopoverContent className="w-36 p-1" align="start">
|
|
||||||
{AGENT_ROLES.map((r) => (
|
|
||||||
<button
|
|
||||||
key={r}
|
|
||||||
className={cn(
|
|
||||||
"flex items-center gap-2 w-full px-2 py-1.5 text-xs rounded hover:bg-accent/50",
|
|
||||||
r === role && "bg-accent"
|
|
||||||
)}
|
|
||||||
onClick={() => { setRole(r); setRoleOpen(false); }}
|
|
||||||
>
|
|
||||||
{roleLabels[r] ?? r}
|
|
||||||
</button>
|
|
||||||
))}
|
|
||||||
</PopoverContent>
|
|
||||||
</Popover>
|
|
||||||
|
|
||||||
{/* Reports To */}
|
|
||||||
<Popover open={reportsToOpen} onOpenChange={setReportsToOpen}>
|
|
||||||
<PopoverTrigger asChild>
|
|
||||||
<button
|
|
||||||
className={cn(
|
|
||||||
"inline-flex items-center gap-1.5 rounded-md border border-border px-2 py-1 text-xs hover:bg-accent/50 transition-colors",
|
|
||||||
isFirstAgent && "opacity-60 cursor-not-allowed"
|
|
||||||
)}
|
|
||||||
disabled={isFirstAgent}
|
|
||||||
>
|
|
||||||
{currentReportsTo ? (
|
|
||||||
<>
|
|
||||||
<AgentIcon icon={currentReportsTo.icon} className="h-3 w-3 text-muted-foreground" />
|
|
||||||
{`Reports to ${currentReportsTo.name}`}
|
|
||||||
</>
|
|
||||||
) : (
|
|
||||||
<>
|
|
||||||
<User className="h-3 w-3 text-muted-foreground" />
|
|
||||||
{isFirstAgent ? "Reports to: N/A (CEO)" : "Reports to..."}
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
</button>
|
|
||||||
</PopoverTrigger>
|
|
||||||
<PopoverContent className="w-48 p-1" align="start">
|
|
||||||
<button
|
|
||||||
className={cn(
|
|
||||||
"flex items-center gap-2 w-full px-2 py-1.5 text-xs rounded hover:bg-accent/50",
|
|
||||||
!reportsTo && "bg-accent"
|
|
||||||
)}
|
|
||||||
onClick={() => { setReportsTo(""); setReportsToOpen(false); }}
|
|
||||||
>
|
|
||||||
No manager
|
|
||||||
</button>
|
|
||||||
{(agents ?? []).map((a) => (
|
|
||||||
<button
|
|
||||||
key={a.id}
|
|
||||||
className={cn(
|
|
||||||
"flex items-center gap-2 w-full px-2 py-1.5 text-xs rounded hover:bg-accent/50 truncate",
|
|
||||||
a.id === reportsTo && "bg-accent"
|
|
||||||
)}
|
|
||||||
onClick={() => { setReportsTo(a.id); setReportsToOpen(false); }}
|
|
||||||
>
|
|
||||||
<AgentIcon icon={a.icon} className="shrink-0 h-3 w-3 text-muted-foreground" />
|
|
||||||
{a.name}
|
|
||||||
<span className="text-muted-foreground ml-auto">{roleLabels[a.role] ?? a.role}</span>
|
|
||||||
</button>
|
|
||||||
))}
|
|
||||||
</PopoverContent>
|
|
||||||
</Popover>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Shared config form (adapter + heartbeat) */}
|
|
||||||
<AgentConfigForm
|
|
||||||
mode="create"
|
|
||||||
values={configValues}
|
|
||||||
onChange={(patch) => setConfigValues((prev) => ({ ...prev, ...patch }))}
|
|
||||||
adapterModels={adapterModels}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Footer */}
|
|
||||||
<div className="flex items-center justify-between px-4 py-2.5 border-t border-border">
|
|
||||||
<span className="text-xs text-muted-foreground">
|
|
||||||
{isFirstAgent ? "This will be the CEO" : ""}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
{formError && (
|
|
||||||
<div className="px-4 pb-2 text-xs text-destructive">{formError}</div>
|
|
||||||
)}
|
|
||||||
<div className="flex items-center justify-end px-4 pb-3">
|
|
||||||
<Button
|
<Button
|
||||||
size="sm"
|
variant="ghost"
|
||||||
disabled={!name.trim() || createAgent.isPending}
|
size="icon-xs"
|
||||||
onClick={handleSubmit}
|
className="text-muted-foreground"
|
||||||
|
onClick={closeNewAgent}
|
||||||
>
|
>
|
||||||
{createAgent.isPending ? "Creating…" : "Create agent"}
|
<span className="text-lg leading-none">×</span>
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<div className="p-6 space-y-6">
|
||||||
|
{/* Recommendation */}
|
||||||
|
<div className="text-center space-y-3">
|
||||||
|
<div className="mx-auto flex h-12 w-12 items-center justify-center rounded-full bg-accent">
|
||||||
|
<Sparkles className="h-6 w-6 text-foreground" />
|
||||||
|
</div>
|
||||||
|
<p className="text-sm text-muted-foreground">
|
||||||
|
We recommend letting your CEO handle agent setup — they know the
|
||||||
|
org structure and can configure reporting, permissions, and
|
||||||
|
adapters.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Button className="w-full" size="lg" onClick={handleAskCeo}>
|
||||||
|
<Bot className="h-4 w-4 mr-2" />
|
||||||
|
Ask the CEO to create a new agent
|
||||||
|
</Button>
|
||||||
|
|
||||||
|
{/* Advanced link */}
|
||||||
|
<div className="text-center">
|
||||||
|
<button
|
||||||
|
className="text-xs text-muted-foreground hover:text-foreground underline underline-offset-2 transition-colors"
|
||||||
|
onClick={handleAdvancedConfig}
|
||||||
|
>
|
||||||
|
I want advanced configuration myself
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
</DialogContent>
|
</DialogContent>
|
||||||
</Dialog>
|
</Dialog>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -2,7 +2,6 @@ import { useState, useEffect, useRef, useCallback, useMemo, type ChangeEvent } f
|
|||||||
import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query";
|
import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query";
|
||||||
import { useDialog } from "../context/DialogContext";
|
import { useDialog } from "../context/DialogContext";
|
||||||
import { useCompany } from "../context/CompanyContext";
|
import { useCompany } from "../context/CompanyContext";
|
||||||
import { useToast } from "../context/ToastContext";
|
|
||||||
import { issuesApi } from "../api/issues";
|
import { issuesApi } from "../api/issues";
|
||||||
import { projectsApi } from "../api/projects";
|
import { projectsApi } from "../api/projects";
|
||||||
import { agentsApi } from "../api/agents";
|
import { agentsApi } from "../api/agents";
|
||||||
@@ -170,7 +169,6 @@ const priorities = [
|
|||||||
export function NewIssueDialog() {
|
export function NewIssueDialog() {
|
||||||
const { newIssueOpen, newIssueDefaults, closeNewIssue } = useDialog();
|
const { newIssueOpen, newIssueDefaults, closeNewIssue } = useDialog();
|
||||||
const { companies, selectedCompanyId, selectedCompany } = useCompany();
|
const { companies, selectedCompanyId, selectedCompany } = useCompany();
|
||||||
const { pushToast } = useToast();
|
|
||||||
const queryClient = useQueryClient();
|
const queryClient = useQueryClient();
|
||||||
const [title, setTitle] = useState("");
|
const [title, setTitle] = useState("");
|
||||||
const [description, setDescription] = useState("");
|
const [description, setDescription] = useState("");
|
||||||
@@ -262,19 +260,12 @@ export function NewIssueDialog() {
|
|||||||
const createIssue = useMutation({
|
const createIssue = useMutation({
|
||||||
mutationFn: ({ companyId, ...data }: { companyId: string } & Record<string, unknown>) =>
|
mutationFn: ({ companyId, ...data }: { companyId: string } & Record<string, unknown>) =>
|
||||||
issuesApi.create(companyId, data),
|
issuesApi.create(companyId, data),
|
||||||
onSuccess: (issue) => {
|
onSuccess: () => {
|
||||||
queryClient.invalidateQueries({ queryKey: queryKeys.issues.list(effectiveCompanyId!) });
|
queryClient.invalidateQueries({ queryKey: queryKeys.issues.list(effectiveCompanyId!) });
|
||||||
if (draftTimer.current) clearTimeout(draftTimer.current);
|
if (draftTimer.current) clearTimeout(draftTimer.current);
|
||||||
clearDraft();
|
clearDraft();
|
||||||
reset();
|
reset();
|
||||||
closeNewIssue();
|
closeNewIssue();
|
||||||
pushToast({
|
|
||||||
dedupeKey: `activity:issue.created:${issue.id}`,
|
|
||||||
title: `${issue.identifier ?? "Issue"} created`,
|
|
||||||
body: issue.title,
|
|
||||||
tone: "success",
|
|
||||||
action: { label: `View ${issue.identifier ?? "issue"}`, href: `/issues/${issue.identifier ?? issue.id}` },
|
|
||||||
});
|
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -332,7 +323,18 @@ export function NewIssueDialog() {
|
|||||||
setDialogCompanyId(selectedCompanyId);
|
setDialogCompanyId(selectedCompanyId);
|
||||||
|
|
||||||
const draft = loadDraft();
|
const draft = loadDraft();
|
||||||
if (draft && draft.title.trim()) {
|
if (newIssueDefaults.title) {
|
||||||
|
setTitle(newIssueDefaults.title);
|
||||||
|
setDescription(newIssueDefaults.description ?? "");
|
||||||
|
setStatus(newIssueDefaults.status ?? "todo");
|
||||||
|
setPriority(newIssueDefaults.priority ?? "");
|
||||||
|
setProjectId(newIssueDefaults.projectId ?? "");
|
||||||
|
setAssigneeId(newIssueDefaults.assigneeAgentId ?? "");
|
||||||
|
setAssigneeModelOverride("");
|
||||||
|
setAssigneeThinkingEffort("");
|
||||||
|
setAssigneeChrome(false);
|
||||||
|
setAssigneeUseProjectWorkspace(true);
|
||||||
|
} else if (draft && draft.title.trim()) {
|
||||||
setTitle(draft.title);
|
setTitle(draft.title);
|
||||||
setDescription(draft.description);
|
setDescription(draft.description);
|
||||||
setStatus(draft.status || "todo");
|
setStatus(draft.status || "todo");
|
||||||
|
|||||||
@@ -57,7 +57,8 @@ type AdapterType =
|
|||||||
| "cursor"
|
| "cursor"
|
||||||
| "process"
|
| "process"
|
||||||
| "http"
|
| "http"
|
||||||
| "openclaw";
|
| "openclaw"
|
||||||
|
| "openclaw_gateway";
|
||||||
|
|
||||||
const DEFAULT_TASK_DESCRIPTION = `Setup yourself as the CEO. Use the ceo persona found here: [https://github.com/paperclipai/companies/blob/main/default/ceo/AGENTS.md](https://github.com/paperclipai/companies/blob/main/default/ceo/AGENTS.md)
|
const DEFAULT_TASK_DESCRIPTION = `Setup yourself as the CEO. Use the ceo persona found here: [https://github.com/paperclipai/companies/blob/main/default/ceo/AGENTS.md](https://github.com/paperclipai/companies/blob/main/default/ceo/AGENTS.md)
|
||||||
|
|
||||||
@@ -679,6 +680,12 @@ export function OnboardingWizard() {
|
|||||||
desc: "Notify OpenClaw webhook",
|
desc: "Notify OpenClaw webhook",
|
||||||
comingSoon: true
|
comingSoon: true
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
value: "openclaw_gateway" as const,
|
||||||
|
label: "OpenClaw Gateway",
|
||||||
|
icon: Bot,
|
||||||
|
desc: "Invoke OpenClaw via gateway protocol"
|
||||||
|
},
|
||||||
{
|
{
|
||||||
value: "cursor" as const,
|
value: "cursor" as const,
|
||||||
label: "Cursor",
|
label: "Cursor",
|
||||||
@@ -981,14 +988,14 @@ export function OnboardingWizard() {
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{(adapterType === "http" || adapterType === "openclaw") && (
|
{(adapterType === "http" || adapterType === "openclaw" || adapterType === "openclaw_gateway") && (
|
||||||
<div>
|
<div>
|
||||||
<label className="text-xs text-muted-foreground mb-1 block">
|
<label className="text-xs text-muted-foreground mb-1 block">
|
||||||
Webhook URL
|
{adapterType === "openclaw_gateway" ? "Gateway URL" : "Webhook URL"}
|
||||||
</label>
|
</label>
|
||||||
<input
|
<input
|
||||||
className="w-full rounded-md border border-border bg-transparent px-3 py-2 text-sm font-mono outline-none focus:ring-1 focus:ring-ring placeholder:text-muted-foreground/50"
|
className="w-full rounded-md border border-border bg-transparent px-3 py-2 text-sm font-mono outline-none focus:ring-1 focus:ring-ring placeholder:text-muted-foreground/50"
|
||||||
placeholder="https://..."
|
placeholder={adapterType === "openclaw_gateway" ? "ws://127.0.0.1:18789" : "https://..."}
|
||||||
value={url}
|
value={url}
|
||||||
onChange={(e) => setUrl(e.target.value)}
|
onChange={(e) => setUrl(e.target.value)}
|
||||||
/>
|
/>
|
||||||
|
|||||||
@@ -1,8 +1,9 @@
|
|||||||
import { useMemo, useState } from "react";
|
import { useMemo, useState } from "react";
|
||||||
import { NavLink, useLocation } from "@/lib/router";
|
import { NavLink, useLocation } from "@/lib/router";
|
||||||
import { useQuery } from "@tanstack/react-query";
|
import { useQuery } from "@tanstack/react-query";
|
||||||
import { ChevronRight } from "lucide-react";
|
import { ChevronRight, Plus } from "lucide-react";
|
||||||
import { useCompany } from "../context/CompanyContext";
|
import { useCompany } from "../context/CompanyContext";
|
||||||
|
import { useDialog } from "../context/DialogContext";
|
||||||
import { useSidebar } from "../context/SidebarContext";
|
import { useSidebar } from "../context/SidebarContext";
|
||||||
import { agentsApi } from "../api/agents";
|
import { agentsApi } from "../api/agents";
|
||||||
import { heartbeatsApi } from "../api/heartbeats";
|
import { heartbeatsApi } from "../api/heartbeats";
|
||||||
@@ -40,6 +41,7 @@ function sortByHierarchy(agents: Agent[]): Agent[] {
|
|||||||
export function SidebarAgents() {
|
export function SidebarAgents() {
|
||||||
const [open, setOpen] = useState(true);
|
const [open, setOpen] = useState(true);
|
||||||
const { selectedCompanyId } = useCompany();
|
const { selectedCompanyId } = useCompany();
|
||||||
|
const { openNewAgent } = useDialog();
|
||||||
const { isMobile, setSidebarOpen } = useSidebar();
|
const { isMobile, setSidebarOpen } = useSidebar();
|
||||||
const location = useLocation();
|
const location = useLocation();
|
||||||
|
|
||||||
@@ -89,6 +91,16 @@ export function SidebarAgents() {
|
|||||||
Agents
|
Agents
|
||||||
</span>
|
</span>
|
||||||
</CollapsibleTrigger>
|
</CollapsibleTrigger>
|
||||||
|
<button
|
||||||
|
onClick={(e) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
openNewAgent();
|
||||||
|
}}
|
||||||
|
className="flex items-center justify-center h-4 w-4 rounded text-muted-foreground/60 hover:text-foreground hover:bg-accent/50 transition-colors"
|
||||||
|
aria-label="New agent"
|
||||||
|
>
|
||||||
|
<Plus className="h-3 w-3" />
|
||||||
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|||||||
@@ -23,7 +23,7 @@ export const help: Record<string, string> = {
|
|||||||
role: "Organizational role. Determines position and capabilities.",
|
role: "Organizational role. Determines position and capabilities.",
|
||||||
reportsTo: "The agent this one reports to in the org hierarchy.",
|
reportsTo: "The agent this one reports to in the org hierarchy.",
|
||||||
capabilities: "Describes what this agent can do. Shown in the org chart and used for task routing.",
|
capabilities: "Describes what this agent can do. Shown in the org chart and used for task routing.",
|
||||||
adapterType: "How this agent runs: local CLI (Claude/Codex/OpenCode), OpenClaw webhook, spawned process, or generic HTTP webhook.",
|
adapterType: "How this agent runs: local CLI (Claude/Codex/OpenCode), OpenClaw (HTTP hooks or Gateway protocol), spawned process, or generic HTTP webhook.",
|
||||||
cwd: "Default working directory fallback for local adapters. Use an absolute path on the machine running Paperclip.",
|
cwd: "Default working directory fallback for local adapters. Use an absolute path on the machine running Paperclip.",
|
||||||
promptTemplate: "The prompt sent to the agent on each heartbeat. Supports {{ agent.id }}, {{ agent.name }}, {{ agent.role }} variables.",
|
promptTemplate: "The prompt sent to the agent on each heartbeat. Supports {{ agent.id }}, {{ agent.name }}, {{ agent.role }} variables.",
|
||||||
model: "Override the default model used by the adapter.",
|
model: "Override the default model used by the adapter.",
|
||||||
@@ -54,6 +54,7 @@ export const adapterLabels: Record<string, string> = {
|
|||||||
codex_local: "Codex (local)",
|
codex_local: "Codex (local)",
|
||||||
opencode_local: "OpenCode (local)",
|
opencode_local: "OpenCode (local)",
|
||||||
openclaw: "OpenClaw",
|
openclaw: "OpenClaw",
|
||||||
|
openclaw_gateway: "OpenClaw Gateway",
|
||||||
cursor: "Cursor (local)",
|
cursor: "Cursor (local)",
|
||||||
process: "Process",
|
process: "Process",
|
||||||
http: "HTTP",
|
http: "HTTP",
|
||||||
|
|||||||
@@ -5,6 +5,8 @@ interface NewIssueDefaults {
|
|||||||
priority?: string;
|
priority?: string;
|
||||||
projectId?: string;
|
projectId?: string;
|
||||||
assigneeAgentId?: string;
|
assigneeAgentId?: string;
|
||||||
|
title?: string;
|
||||||
|
description?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
interface NewGoalDefaults {
|
interface NewGoalDefaults {
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import { useEffect, useRef, type ReactNode } from "react";
|
import { useEffect, useRef, type ReactNode } from "react";
|
||||||
import { useQueryClient, type QueryClient } from "@tanstack/react-query";
|
import { useQuery, useQueryClient, type QueryClient } from "@tanstack/react-query";
|
||||||
import type { Agent, Issue, LiveEvent } from "@paperclipai/shared";
|
import type { Agent, Issue, LiveEvent } from "@paperclipai/shared";
|
||||||
|
import { authApi } from "../api/auth";
|
||||||
import { useCompany } from "./CompanyContext";
|
import { useCompany } from "./CompanyContext";
|
||||||
import type { ToastInput } from "./ToastContext";
|
import type { ToastInput } from "./ToastContext";
|
||||||
import { useToast } from "./ToastContext";
|
import { useToast } from "./ToastContext";
|
||||||
@@ -152,6 +153,7 @@ function buildActivityToast(
|
|||||||
queryClient: QueryClient,
|
queryClient: QueryClient,
|
||||||
companyId: string,
|
companyId: string,
|
||||||
payload: Record<string, unknown>,
|
payload: Record<string, unknown>,
|
||||||
|
currentActor: { userId: string | null; agentId: string | null },
|
||||||
): ToastInput | null {
|
): ToastInput | null {
|
||||||
const entityType = readString(payload.entityType);
|
const entityType = readString(payload.entityType);
|
||||||
const entityId = readString(payload.entityId);
|
const entityId = readString(payload.entityId);
|
||||||
@@ -166,6 +168,10 @@ function buildActivityToast(
|
|||||||
|
|
||||||
const issue = resolveIssueToastContext(queryClient, companyId, entityId, details);
|
const issue = resolveIssueToastContext(queryClient, companyId, entityId, details);
|
||||||
const actor = resolveActorLabel(queryClient, companyId, actorType, actorId);
|
const actor = resolveActorLabel(queryClient, companyId, actorType, actorId);
|
||||||
|
const isSelfActivity =
|
||||||
|
(actorType === "user" && !!currentActor.userId && actorId === currentActor.userId) ||
|
||||||
|
(actorType === "agent" && !!currentActor.agentId && actorId === currentActor.agentId);
|
||||||
|
if (isSelfActivity) return null;
|
||||||
|
|
||||||
if (action === "issue.created") {
|
if (action === "issue.created") {
|
||||||
return {
|
return {
|
||||||
@@ -178,8 +184,8 @@ function buildActivityToast(
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (action === "issue.updated") {
|
if (action === "issue.updated") {
|
||||||
if (details?.reopened === true && readString(details.source) === "comment") {
|
if (readString(details?.source) === "comment") {
|
||||||
// Reopen-via-comment emits a paired comment event; show one combined toast on the comment event.
|
// Comment-driven updates emit a paired comment event; show one combined toast on the comment event.
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
const changeDesc = describeIssueUpdate(details);
|
const changeDesc = describeIssueUpdate(details);
|
||||||
@@ -202,13 +208,18 @@ function buildActivityToast(
|
|||||||
const commentId = readString(details?.commentId);
|
const commentId = readString(details?.commentId);
|
||||||
const bodySnippet = readString(details?.bodySnippet);
|
const bodySnippet = readString(details?.bodySnippet);
|
||||||
const reopened = details?.reopened === true;
|
const reopened = details?.reopened === true;
|
||||||
|
const updated = details?.updated === true;
|
||||||
const reopenedFrom = readString(details?.reopenedFrom);
|
const reopenedFrom = readString(details?.reopenedFrom);
|
||||||
const reopenedLabel = reopened
|
const reopenedLabel = reopened
|
||||||
? reopenedFrom
|
? reopenedFrom
|
||||||
? `reopened from ${reopenedFrom.replace(/_/g, " ")}`
|
? `reopened from ${reopenedFrom.replace(/_/g, " ")}`
|
||||||
: "reopened"
|
: "reopened"
|
||||||
: null;
|
: null;
|
||||||
const title = reopened ? `${actor} reopened and commented on ${issue.ref}` : `${actor} commented on ${issue.ref}`;
|
const title = reopened
|
||||||
|
? `${actor} reopened and commented on ${issue.ref}`
|
||||||
|
: updated
|
||||||
|
? `${actor} commented and updated ${issue.ref}`
|
||||||
|
: `${actor} commented on ${issue.ref}`;
|
||||||
const body = bodySnippet
|
const body = bodySnippet
|
||||||
? reopenedLabel
|
? reopenedLabel
|
||||||
? `${reopenedLabel} - ${bodySnippet.replace(/^#+\s*/m, "").replace(/\n/g, " ")}`
|
? `${reopenedLabel} - ${bodySnippet.replace(/^#+\s*/m, "").replace(/\n/g, " ")}`
|
||||||
@@ -448,6 +459,7 @@ function handleLiveEvent(
|
|||||||
event: LiveEvent,
|
event: LiveEvent,
|
||||||
pushToast: (toast: ToastInput) => string | null,
|
pushToast: (toast: ToastInput) => string | null,
|
||||||
gate: ToastGate,
|
gate: ToastGate,
|
||||||
|
currentActor: { userId: string | null; agentId: string | null },
|
||||||
) {
|
) {
|
||||||
if (event.companyId !== expectedCompanyId) return;
|
if (event.companyId !== expectedCompanyId) return;
|
||||||
|
|
||||||
@@ -485,7 +497,7 @@ function handleLiveEvent(
|
|||||||
invalidateActivityQueries(queryClient, expectedCompanyId, payload);
|
invalidateActivityQueries(queryClient, expectedCompanyId, payload);
|
||||||
const action = readString(payload.action);
|
const action = readString(payload.action);
|
||||||
const toast =
|
const toast =
|
||||||
buildActivityToast(queryClient, expectedCompanyId, payload) ??
|
buildActivityToast(queryClient, expectedCompanyId, payload, currentActor) ??
|
||||||
buildJoinRequestToast(payload);
|
buildJoinRequestToast(payload);
|
||||||
if (toast) gatedPushToast(gate, pushToast, `activity:${action ?? "unknown"}`, toast);
|
if (toast) gatedPushToast(gate, pushToast, `activity:${action ?? "unknown"}`, toast);
|
||||||
}
|
}
|
||||||
@@ -496,6 +508,12 @@ export function LiveUpdatesProvider({ children }: { children: ReactNode }) {
|
|||||||
const queryClient = useQueryClient();
|
const queryClient = useQueryClient();
|
||||||
const { pushToast } = useToast();
|
const { pushToast } = useToast();
|
||||||
const gateRef = useRef<ToastGate>({ cooldownHits: new Map(), suppressUntil: 0 });
|
const gateRef = useRef<ToastGate>({ cooldownHits: new Map(), suppressUntil: 0 });
|
||||||
|
const { data: session } = useQuery({
|
||||||
|
queryKey: queryKeys.auth.session,
|
||||||
|
queryFn: () => authApi.getSession(),
|
||||||
|
retry: false,
|
||||||
|
});
|
||||||
|
const currentUserId = session?.user?.id ?? session?.session?.userId ?? null;
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!selectedCompanyId) return;
|
if (!selectedCompanyId) return;
|
||||||
@@ -541,7 +559,10 @@ export function LiveUpdatesProvider({ children }: { children: ReactNode }) {
|
|||||||
|
|
||||||
try {
|
try {
|
||||||
const parsed = JSON.parse(raw) as LiveEvent;
|
const parsed = JSON.parse(raw) as LiveEvent;
|
||||||
handleLiveEvent(queryClient, selectedCompanyId, parsed, pushToast, gateRef.current);
|
handleLiveEvent(queryClient, selectedCompanyId, parsed, pushToast, gateRef.current, {
|
||||||
|
userId: currentUserId,
|
||||||
|
agentId: null,
|
||||||
|
});
|
||||||
} catch {
|
} catch {
|
||||||
// Ignore non-JSON payloads.
|
// Ignore non-JSON payloads.
|
||||||
}
|
}
|
||||||
@@ -570,7 +591,7 @@ export function LiveUpdatesProvider({ children }: { children: ReactNode }) {
|
|||||||
socket.close(1000, "provider_unmount");
|
socket.close(1000, "provider_unmount");
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
}, [queryClient, selectedCompanyId, pushToast]);
|
}, [queryClient, selectedCompanyId, pushToast, currentUserId]);
|
||||||
|
|
||||||
return <>{children}</>;
|
return <>{children}</>;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -426,6 +426,40 @@
|
|||||||
font-weight: 500;
|
font-weight: 500;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.paperclip-mermaid {
|
||||||
|
margin: 0.5rem 0;
|
||||||
|
padding: 0.45rem 0.55rem;
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-radius: calc(var(--radius) - 3px);
|
||||||
|
background-color: color-mix(in oklab, var(--accent) 35%, transparent);
|
||||||
|
overflow-x: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.paperclip-mermaid svg {
|
||||||
|
display: block;
|
||||||
|
width: max-content;
|
||||||
|
max-width: none;
|
||||||
|
min-width: 100%;
|
||||||
|
height: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.paperclip-mermaid-status {
|
||||||
|
margin: 0 0 0.45rem;
|
||||||
|
font-size: 0.75rem;
|
||||||
|
color: var(--muted-foreground);
|
||||||
|
}
|
||||||
|
|
||||||
|
.paperclip-mermaid-status-error {
|
||||||
|
color: var(--destructive);
|
||||||
|
}
|
||||||
|
|
||||||
|
.paperclip-mermaid-source {
|
||||||
|
margin: 0;
|
||||||
|
padding: 0;
|
||||||
|
border: 0;
|
||||||
|
background: transparent;
|
||||||
|
}
|
||||||
|
|
||||||
/* Project mention chips rendered inside MarkdownBody */
|
/* Project mention chips rendered inside MarkdownBody */
|
||||||
a.paperclip-project-mention-chip {
|
a.paperclip-project-mention-chip {
|
||||||
display: inline-flex;
|
display: inline-flex;
|
||||||
|
|||||||
@@ -26,6 +26,7 @@ const adapterLabels: Record<string, string> = {
|
|||||||
opencode_local: "OpenCode",
|
opencode_local: "OpenCode",
|
||||||
cursor: "Cursor",
|
cursor: "Cursor",
|
||||||
openclaw: "OpenClaw",
|
openclaw: "OpenClaw",
|
||||||
|
openclaw_gateway: "OpenClaw Gateway",
|
||||||
process: "Process",
|
process: "Process",
|
||||||
http: "HTTP",
|
http: "HTTP",
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -20,6 +20,7 @@ const adapterLabels: Record<string, string> = {
|
|||||||
codex_local: "Codex (local)",
|
codex_local: "Codex (local)",
|
||||||
opencode_local: "OpenCode (local)",
|
opencode_local: "OpenCode (local)",
|
||||||
openclaw: "OpenClaw",
|
openclaw: "OpenClaw",
|
||||||
|
openclaw_gateway: "OpenClaw Gateway",
|
||||||
cursor: "Cursor (local)",
|
cursor: "Cursor (local)",
|
||||||
process: "Process",
|
process: "Process",
|
||||||
http: "HTTP",
|
http: "HTTP",
|
||||||
|
|||||||
@@ -8,7 +8,6 @@ import { agentsApi } from "../api/agents";
|
|||||||
import { authApi } from "../api/auth";
|
import { authApi } from "../api/auth";
|
||||||
import { projectsApi } from "../api/projects";
|
import { projectsApi } from "../api/projects";
|
||||||
import { useCompany } from "../context/CompanyContext";
|
import { useCompany } from "../context/CompanyContext";
|
||||||
import { useToast } from "../context/ToastContext";
|
|
||||||
import { usePanel } from "../context/PanelContext";
|
import { usePanel } from "../context/PanelContext";
|
||||||
import { useBreadcrumbs } from "../context/BreadcrumbContext";
|
import { useBreadcrumbs } from "../context/BreadcrumbContext";
|
||||||
import { queryKeys } from "../lib/queryKeys";
|
import { queryKeys } from "../lib/queryKeys";
|
||||||
@@ -146,7 +145,6 @@ function ActorIdentity({ evt, agentMap }: { evt: ActivityEvent; agentMap: Map<st
|
|||||||
export function IssueDetail() {
|
export function IssueDetail() {
|
||||||
const { issueId } = useParams<{ issueId: string }>();
|
const { issueId } = useParams<{ issueId: string }>();
|
||||||
const { selectedCompanyId } = useCompany();
|
const { selectedCompanyId } = useCompany();
|
||||||
const { pushToast } = useToast();
|
|
||||||
const { openPanel, closePanel, panelVisible, setPanelVisible } = usePanel();
|
const { openPanel, closePanel, panelVisible, setPanelVisible } = usePanel();
|
||||||
const { setBreadcrumbs } = useBreadcrumbs();
|
const { setBreadcrumbs } = useBreadcrumbs();
|
||||||
const queryClient = useQueryClient();
|
const queryClient = useQueryClient();
|
||||||
@@ -403,33 +401,17 @@ export function IssueDetail() {
|
|||||||
|
|
||||||
const updateIssue = useMutation({
|
const updateIssue = useMutation({
|
||||||
mutationFn: (data: Record<string, unknown>) => issuesApi.update(issueId!, data),
|
mutationFn: (data: Record<string, unknown>) => issuesApi.update(issueId!, data),
|
||||||
onSuccess: (updated) => {
|
onSuccess: () => {
|
||||||
invalidateIssue();
|
invalidateIssue();
|
||||||
const issueRef = updated.identifier ?? `Issue ${updated.id.slice(0, 8)}`;
|
|
||||||
pushToast({
|
|
||||||
dedupeKey: `activity:issue.updated:${updated.id}`,
|
|
||||||
title: `${issueRef} updated`,
|
|
||||||
body: truncate(updated.title, 96),
|
|
||||||
tone: "success",
|
|
||||||
action: { label: `View ${issueRef}`, href: `/issues/${updated.identifier ?? updated.id}` },
|
|
||||||
});
|
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
const addComment = useMutation({
|
const addComment = useMutation({
|
||||||
mutationFn: ({ body, reopen }: { body: string; reopen?: boolean }) =>
|
mutationFn: ({ body, reopen }: { body: string; reopen?: boolean }) =>
|
||||||
issuesApi.addComment(issueId!, body, reopen),
|
issuesApi.addComment(issueId!, body, reopen),
|
||||||
onSuccess: (comment) => {
|
onSuccess: () => {
|
||||||
invalidateIssue();
|
invalidateIssue();
|
||||||
queryClient.invalidateQueries({ queryKey: queryKeys.issues.comments(issueId!) });
|
queryClient.invalidateQueries({ queryKey: queryKeys.issues.comments(issueId!) });
|
||||||
const issueRef = issue?.identifier ?? (issueId ? `Issue ${issueId.slice(0, 8)}` : "Issue");
|
|
||||||
pushToast({
|
|
||||||
dedupeKey: `activity:issue.comment_added:${issueId}:${comment.id}`,
|
|
||||||
title: `Comment posted on ${issueRef}`,
|
|
||||||
body: issue?.title ? truncate(issue.title, 96) : undefined,
|
|
||||||
tone: "success",
|
|
||||||
action: issueId ? { label: `View ${issueRef}`, href: `/issues/${issue?.identifier ?? issueId}` } : undefined,
|
|
||||||
});
|
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -449,17 +431,9 @@ export function IssueDetail() {
|
|||||||
assigneeUserId: reassignment.assigneeUserId,
|
assigneeUserId: reassignment.assigneeUserId,
|
||||||
...(reopen ? { status: "todo" } : {}),
|
...(reopen ? { status: "todo" } : {}),
|
||||||
}),
|
}),
|
||||||
onSuccess: (updated) => {
|
onSuccess: () => {
|
||||||
invalidateIssue();
|
invalidateIssue();
|
||||||
queryClient.invalidateQueries({ queryKey: queryKeys.issues.comments(issueId!) });
|
queryClient.invalidateQueries({ queryKey: queryKeys.issues.comments(issueId!) });
|
||||||
const issueRef = updated.identifier ?? (issueId ? `Issue ${issueId.slice(0, 8)}` : "Issue");
|
|
||||||
pushToast({
|
|
||||||
dedupeKey: `activity:issue.reassigned:${updated.id}`,
|
|
||||||
title: `${issueRef} reassigned`,
|
|
||||||
body: issue?.title ? truncate(issue.title, 96) : undefined,
|
|
||||||
tone: "success",
|
|
||||||
action: issueId ? { label: `View ${issueRef}`, href: `/issues/${issue?.identifier ?? issueId}` } : undefined,
|
|
||||||
});
|
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
289
ui/src/pages/NewAgent.tsx
Normal file
289
ui/src/pages/NewAgent.tsx
Normal file
@@ -0,0 +1,289 @@
|
|||||||
|
import { useState, useEffect } from "react";
|
||||||
|
import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query";
|
||||||
|
import { useNavigate } from "@/lib/router";
|
||||||
|
import { useCompany } from "../context/CompanyContext";
|
||||||
|
import { useBreadcrumbs } from "../context/BreadcrumbContext";
|
||||||
|
import { agentsApi } from "../api/agents";
|
||||||
|
import { queryKeys } from "../lib/queryKeys";
|
||||||
|
import { AGENT_ROLES } from "@paperclipai/shared";
|
||||||
|
import { Button } from "@/components/ui/button";
|
||||||
|
import {
|
||||||
|
Popover,
|
||||||
|
PopoverContent,
|
||||||
|
PopoverTrigger,
|
||||||
|
} from "@/components/ui/popover";
|
||||||
|
import { Shield, User } from "lucide-react";
|
||||||
|
import { cn, agentUrl } from "../lib/utils";
|
||||||
|
import { roleLabels } from "../components/agent-config-primitives";
|
||||||
|
import { AgentConfigForm, type CreateConfigValues } from "../components/AgentConfigForm";
|
||||||
|
import { defaultCreateValues } from "../components/agent-config-defaults";
|
||||||
|
import { getUIAdapter } from "../adapters";
|
||||||
|
import { AgentIcon } from "../components/AgentIconPicker";
|
||||||
|
|
||||||
|
export function NewAgent() {
|
||||||
|
const { selectedCompanyId, selectedCompany } = useCompany();
|
||||||
|
const { setBreadcrumbs } = useBreadcrumbs();
|
||||||
|
const queryClient = useQueryClient();
|
||||||
|
const navigate = useNavigate();
|
||||||
|
|
||||||
|
const [name, setName] = useState("");
|
||||||
|
const [title, setTitle] = useState("");
|
||||||
|
const [role, setRole] = useState("general");
|
||||||
|
const [reportsTo, setReportsTo] = useState("");
|
||||||
|
const [configValues, setConfigValues] = useState<CreateConfigValues>(defaultCreateValues);
|
||||||
|
const [roleOpen, setRoleOpen] = useState(false);
|
||||||
|
const [reportsToOpen, setReportsToOpen] = useState(false);
|
||||||
|
const [formError, setFormError] = useState<string | null>(null);
|
||||||
|
|
||||||
|
const { data: agents } = useQuery({
|
||||||
|
queryKey: queryKeys.agents.list(selectedCompanyId!),
|
||||||
|
queryFn: () => agentsApi.list(selectedCompanyId!),
|
||||||
|
enabled: !!selectedCompanyId,
|
||||||
|
});
|
||||||
|
|
||||||
|
const {
|
||||||
|
data: adapterModels,
|
||||||
|
error: adapterModelsError,
|
||||||
|
isLoading: adapterModelsLoading,
|
||||||
|
isFetching: adapterModelsFetching,
|
||||||
|
} = useQuery({
|
||||||
|
queryKey: selectedCompanyId
|
||||||
|
? queryKeys.agents.adapterModels(selectedCompanyId, configValues.adapterType)
|
||||||
|
: ["agents", "none", "adapter-models", configValues.adapterType],
|
||||||
|
queryFn: () => agentsApi.adapterModels(selectedCompanyId!, configValues.adapterType),
|
||||||
|
enabled: Boolean(selectedCompanyId),
|
||||||
|
});
|
||||||
|
|
||||||
|
const isFirstAgent = !agents || agents.length === 0;
|
||||||
|
const effectiveRole = isFirstAgent ? "ceo" : role;
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
setBreadcrumbs([
|
||||||
|
{ label: "Agents", href: "/agents" },
|
||||||
|
{ label: "New Agent" },
|
||||||
|
]);
|
||||||
|
}, [setBreadcrumbs]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (isFirstAgent) {
|
||||||
|
if (!name) setName("CEO");
|
||||||
|
if (!title) setTitle("CEO");
|
||||||
|
}
|
||||||
|
}, [isFirstAgent]); // eslint-disable-line react-hooks/exhaustive-deps
|
||||||
|
|
||||||
|
const createAgent = useMutation({
|
||||||
|
mutationFn: (data: Record<string, unknown>) =>
|
||||||
|
agentsApi.hire(selectedCompanyId!, data),
|
||||||
|
onSuccess: (result) => {
|
||||||
|
queryClient.invalidateQueries({ queryKey: queryKeys.agents.list(selectedCompanyId!) });
|
||||||
|
queryClient.invalidateQueries({ queryKey: queryKeys.approvals.list(selectedCompanyId!) });
|
||||||
|
navigate(agentUrl(result.agent));
|
||||||
|
},
|
||||||
|
onError: (error) => {
|
||||||
|
setFormError(error instanceof Error ? error.message : "Failed to create agent");
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
function buildAdapterConfig() {
|
||||||
|
const adapter = getUIAdapter(configValues.adapterType);
|
||||||
|
return adapter.buildAdapterConfig(configValues);
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleSubmit() {
|
||||||
|
if (!selectedCompanyId || !name.trim()) return;
|
||||||
|
setFormError(null);
|
||||||
|
if (configValues.adapterType === "opencode_local") {
|
||||||
|
const selectedModel = configValues.model.trim();
|
||||||
|
if (!selectedModel) {
|
||||||
|
setFormError("OpenCode requires an explicit model in provider/model format.");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (adapterModelsError) {
|
||||||
|
setFormError(
|
||||||
|
adapterModelsError instanceof Error
|
||||||
|
? adapterModelsError.message
|
||||||
|
: "Failed to load OpenCode models.",
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (adapterModelsLoading || adapterModelsFetching) {
|
||||||
|
setFormError("OpenCode models are still loading. Please wait and try again.");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const discovered = adapterModels ?? [];
|
||||||
|
if (!discovered.some((entry) => entry.id === selectedModel)) {
|
||||||
|
setFormError(
|
||||||
|
discovered.length === 0
|
||||||
|
? "No OpenCode models discovered. Run `opencode models` and authenticate providers."
|
||||||
|
: `Configured OpenCode model is unavailable: ${selectedModel}`,
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
createAgent.mutate({
|
||||||
|
name: name.trim(),
|
||||||
|
role: effectiveRole,
|
||||||
|
...(title.trim() ? { title: title.trim() } : {}),
|
||||||
|
...(reportsTo ? { reportsTo } : {}),
|
||||||
|
adapterType: configValues.adapterType,
|
||||||
|
adapterConfig: buildAdapterConfig(),
|
||||||
|
runtimeConfig: {
|
||||||
|
heartbeat: {
|
||||||
|
enabled: configValues.heartbeatEnabled,
|
||||||
|
intervalSec: configValues.intervalSec,
|
||||||
|
wakeOnDemand: true,
|
||||||
|
cooldownSec: 10,
|
||||||
|
maxConcurrentRuns: 1,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
budgetMonthlyCents: 0,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const currentReportsTo = (agents ?? []).find((a) => a.id === reportsTo);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="mx-auto max-w-2xl space-y-6">
|
||||||
|
<div>
|
||||||
|
<h1 className="text-lg font-semibold">New Agent</h1>
|
||||||
|
<p className="text-sm text-muted-foreground mt-1">
|
||||||
|
Advanced agent configuration
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="border border-border">
|
||||||
|
{/* Name */}
|
||||||
|
<div className="px-4 pt-4 pb-2">
|
||||||
|
<input
|
||||||
|
className="w-full text-lg font-semibold bg-transparent outline-none placeholder:text-muted-foreground/50"
|
||||||
|
placeholder="Agent name"
|
||||||
|
value={name}
|
||||||
|
onChange={(e) => setName(e.target.value)}
|
||||||
|
autoFocus
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Title */}
|
||||||
|
<div className="px-4 pb-2">
|
||||||
|
<input
|
||||||
|
className="w-full bg-transparent outline-none text-sm text-muted-foreground placeholder:text-muted-foreground/40"
|
||||||
|
placeholder="Title (e.g. VP of Engineering)"
|
||||||
|
value={title}
|
||||||
|
onChange={(e) => setTitle(e.target.value)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Property chips: Role + Reports To */}
|
||||||
|
<div className="flex items-center gap-1.5 px-4 py-2 border-t border-border flex-wrap">
|
||||||
|
<Popover open={roleOpen} onOpenChange={setRoleOpen}>
|
||||||
|
<PopoverTrigger asChild>
|
||||||
|
<button
|
||||||
|
className={cn(
|
||||||
|
"inline-flex items-center gap-1.5 rounded-md border border-border px-2 py-1 text-xs hover:bg-accent/50 transition-colors",
|
||||||
|
isFirstAgent && "opacity-60 cursor-not-allowed"
|
||||||
|
)}
|
||||||
|
disabled={isFirstAgent}
|
||||||
|
>
|
||||||
|
<Shield className="h-3 w-3 text-muted-foreground" />
|
||||||
|
{roleLabels[effectiveRole] ?? effectiveRole}
|
||||||
|
</button>
|
||||||
|
</PopoverTrigger>
|
||||||
|
<PopoverContent className="w-36 p-1" align="start">
|
||||||
|
{AGENT_ROLES.map((r) => (
|
||||||
|
<button
|
||||||
|
key={r}
|
||||||
|
className={cn(
|
||||||
|
"flex items-center gap-2 w-full px-2 py-1.5 text-xs rounded hover:bg-accent/50",
|
||||||
|
r === role && "bg-accent"
|
||||||
|
)}
|
||||||
|
onClick={() => { setRole(r); setRoleOpen(false); }}
|
||||||
|
>
|
||||||
|
{roleLabels[r] ?? r}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</PopoverContent>
|
||||||
|
</Popover>
|
||||||
|
|
||||||
|
<Popover open={reportsToOpen} onOpenChange={setReportsToOpen}>
|
||||||
|
<PopoverTrigger asChild>
|
||||||
|
<button
|
||||||
|
className={cn(
|
||||||
|
"inline-flex items-center gap-1.5 rounded-md border border-border px-2 py-1 text-xs hover:bg-accent/50 transition-colors",
|
||||||
|
isFirstAgent && "opacity-60 cursor-not-allowed"
|
||||||
|
)}
|
||||||
|
disabled={isFirstAgent}
|
||||||
|
>
|
||||||
|
{currentReportsTo ? (
|
||||||
|
<>
|
||||||
|
<AgentIcon icon={currentReportsTo.icon} className="h-3 w-3 text-muted-foreground" />
|
||||||
|
{`Reports to ${currentReportsTo.name}`}
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<User className="h-3 w-3 text-muted-foreground" />
|
||||||
|
{isFirstAgent ? "Reports to: N/A (CEO)" : "Reports to..."}
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</button>
|
||||||
|
</PopoverTrigger>
|
||||||
|
<PopoverContent className="w-48 p-1" align="start">
|
||||||
|
<button
|
||||||
|
className={cn(
|
||||||
|
"flex items-center gap-2 w-full px-2 py-1.5 text-xs rounded hover:bg-accent/50",
|
||||||
|
!reportsTo && "bg-accent"
|
||||||
|
)}
|
||||||
|
onClick={() => { setReportsTo(""); setReportsToOpen(false); }}
|
||||||
|
>
|
||||||
|
No manager
|
||||||
|
</button>
|
||||||
|
{(agents ?? []).map((a) => (
|
||||||
|
<button
|
||||||
|
key={a.id}
|
||||||
|
className={cn(
|
||||||
|
"flex items-center gap-2 w-full px-2 py-1.5 text-xs rounded hover:bg-accent/50 truncate",
|
||||||
|
a.id === reportsTo && "bg-accent"
|
||||||
|
)}
|
||||||
|
onClick={() => { setReportsTo(a.id); setReportsToOpen(false); }}
|
||||||
|
>
|
||||||
|
<AgentIcon icon={a.icon} className="shrink-0 h-3 w-3 text-muted-foreground" />
|
||||||
|
{a.name}
|
||||||
|
<span className="text-muted-foreground ml-auto">{roleLabels[a.role] ?? a.role}</span>
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</PopoverContent>
|
||||||
|
</Popover>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Shared config form */}
|
||||||
|
<AgentConfigForm
|
||||||
|
mode="create"
|
||||||
|
values={configValues}
|
||||||
|
onChange={(patch) => setConfigValues((prev) => ({ ...prev, ...patch }))}
|
||||||
|
adapterModels={adapterModels}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{/* Footer */}
|
||||||
|
<div className="border-t border-border px-4 py-3">
|
||||||
|
{isFirstAgent && (
|
||||||
|
<p className="text-xs text-muted-foreground mb-2">This will be the CEO</p>
|
||||||
|
)}
|
||||||
|
{formError && (
|
||||||
|
<p className="text-xs text-destructive mb-2">{formError}</p>
|
||||||
|
)}
|
||||||
|
<div className="flex items-center justify-end gap-2">
|
||||||
|
<Button variant="outline" size="sm" onClick={() => navigate("/agents")}>
|
||||||
|
Cancel
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
size="sm"
|
||||||
|
disabled={!name.trim() || createAgent.isPending}
|
||||||
|
onClick={handleSubmit}
|
||||||
|
>
|
||||||
|
{createAgent.isPending ? "Creating…" : "Create agent"}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -121,6 +121,7 @@ const adapterLabels: Record<string, string> = {
|
|||||||
opencode_local: "OpenCode",
|
opencode_local: "OpenCode",
|
||||||
cursor: "Cursor",
|
cursor: "Cursor",
|
||||||
openclaw: "OpenClaw",
|
openclaw: "OpenClaw",
|
||||||
|
openclaw_gateway: "OpenClaw Gateway",
|
||||||
process: "Process",
|
process: "Process",
|
||||||
http: "HTTP",
|
http: "HTTP",
|
||||||
};
|
};
|
||||||
|
|||||||
Reference in New Issue
Block a user