Vendor OpenClaw source as Adolf fork baseline
Some checks failed
ClawSweeper Dispatch / dispatch (push) Has been cancelled
CodeQL / Security High (actions) (push) Has been cancelled
CodeQL / Security High (channel-runtime-boundary) (push) Has been cancelled
CodeQL / Security High (core-auth-secrets) (push) Has been cancelled
CodeQL / Security High (mcp-process-tool-boundary) (push) Has been cancelled
CodeQL / Security High (network-ssrf-boundary) (push) Has been cancelled
CodeQL / Security High (plugin-trust-boundary) (push) Has been cancelled
CodeQL / Security High (process-exec-boundary) (push) Has been cancelled
Docs Sync Publish Repo / sync-publish-repo (push) Has been cancelled
Docs / docs (push) Has been cancelled
OpenClaw Stable Main Closeout / Resolve stable release closeout inputs (push) Has been cancelled
OpenClaw Stable Main Closeout / Verify stable main closeout (push) Has been cancelled
Workflow Sanity / no-tabs (push) Has been cancelled
Workflow Sanity / actionlint (push) Has been cancelled
Workflow Sanity / generated-doc-baselines (push) Has been cancelled
CI / runner-admission (push) Has been cancelled
CI / preflight (push) Has been cancelled
CI / security-fast (push) Has been cancelled
CI / pnpm-store-warmup (push) Has been cancelled
CI / build-artifacts (push) Has been cancelled
CI / native-i18n (push) Has been cancelled
CI / ${{ matrix.check_name }} (push) Has been cancelled
CI / ${{ matrix.checkName }} (push) Has been cancelled
CI / checks-node-compat-node22 (push) Has been cancelled
CI / check-bundled-channel-config-metadata (push) Has been cancelled
CI / check-dependencies (push) Has been cancelled
CI / check-guards (push) Has been cancelled
CI / check-lint (push) Has been cancelled
CI / check-prod-types (push) Has been cancelled
CI / check-shrinkwrap (push) Has been cancelled
CI / check-test-types (push) Has been cancelled
CI / check-additional-boundaries-a (push) Has been cancelled
CI / check-additional-boundaries-bcd (push) Has been cancelled
CI / check-additional-extension-bundled (push) Has been cancelled
CI / check-additional-extension-channels (push) Has been cancelled
CI / check-additional-extension-package-boundary (push) Has been cancelled
CI / check-additional-runtime-topology-architecture (push) Has been cancelled
CI / check-session-accessor-boundary (push) Has been cancelled
CI / check-session-transcript-reader-boundary (push) Has been cancelled
CI / check-docs (push) Has been cancelled
CI / skills-python (push) Has been cancelled
CI / macos-swift (push) Has been cancelled
CI / ios-build (push) Has been cancelled
CI / ci-timings-summary (push) Has been cancelled
Native App Locale Refresh / Refresh native fa (push) Has been cancelled
Native App Locale Refresh / Refresh native fr (push) Has been cancelled
Native App Locale Refresh / Refresh native hi (push) Has been cancelled
Native App Locale Refresh / Refresh native id (push) Has been cancelled
Native App Locale Refresh / Refresh native it (push) Has been cancelled
Native App Locale Refresh / Refresh native ja-JP (push) Has been cancelled
Control UI Locale Refresh / plan (push) Has been cancelled
Control UI Locale Refresh / Refresh ${{ matrix.locale }} (push) Has been cancelled
Control UI Locale Refresh / Commit control UI locale refresh (push) Has been cancelled
Live Media Runner Image / Build live media runner image (push) Has been cancelled
Native App Locale Refresh / Refresh native ar (push) Has been cancelled
Native App Locale Refresh / Refresh native de (push) Has been cancelled
Native App Locale Refresh / Refresh native es (push) Has been cancelled
Native App Locale Refresh / Refresh native ko (push) Has been cancelled
Native App Locale Refresh / Refresh native nl (push) Has been cancelled
Native App Locale Refresh / Refresh native pl (push) Has been cancelled
Native App Locale Refresh / Refresh native pt-BR (push) Has been cancelled
Native App Locale Refresh / Refresh native ru (push) Has been cancelled
Native App Locale Refresh / Refresh native sv (push) Has been cancelled
Native App Locale Refresh / Refresh native th (push) Has been cancelled
Native App Locale Refresh / Refresh native tr (push) Has been cancelled
Native App Locale Refresh / Refresh native uk (push) Has been cancelled
Native App Locale Refresh / Refresh native vi (push) Has been cancelled
Native App Locale Refresh / Refresh native zh-CN (push) Has been cancelled
Native App Locale Refresh / Refresh native zh-TW (push) Has been cancelled
Native App Locale Refresh / Commit native locale refresh (push) Has been cancelled
Plugin Init Scaffold Validation / Validate provider scaffold (push) Has been cancelled
Plugin NPM Release / preview_plugins_npm (push) Has been cancelled
Plugin NPM Release / Validate release publish approval (push) Has been cancelled
Plugin NPM Release / preview_plugin_pack (push) Has been cancelled
Plugin NPM Release / publish_plugins_npm (push) Has been cancelled
Sandbox Common Smoke / sandbox-common-smoke (push) Has been cancelled
Website Installer Sync / static (push) Has been cancelled
Website Installer Sync / linux-docker (push) Has been cancelled
Website Installer Sync / macos-installer (push) Has been cancelled
Website Installer Sync / windows-installer (push) Has been cancelled
Website Installer Sync / sync-website (push) Has been cancelled
Some checks failed
ClawSweeper Dispatch / dispatch (push) Has been cancelled
CodeQL / Security High (actions) (push) Has been cancelled
CodeQL / Security High (channel-runtime-boundary) (push) Has been cancelled
CodeQL / Security High (core-auth-secrets) (push) Has been cancelled
CodeQL / Security High (mcp-process-tool-boundary) (push) Has been cancelled
CodeQL / Security High (network-ssrf-boundary) (push) Has been cancelled
CodeQL / Security High (plugin-trust-boundary) (push) Has been cancelled
CodeQL / Security High (process-exec-boundary) (push) Has been cancelled
Docs Sync Publish Repo / sync-publish-repo (push) Has been cancelled
Docs / docs (push) Has been cancelled
OpenClaw Stable Main Closeout / Resolve stable release closeout inputs (push) Has been cancelled
OpenClaw Stable Main Closeout / Verify stable main closeout (push) Has been cancelled
Workflow Sanity / no-tabs (push) Has been cancelled
Workflow Sanity / actionlint (push) Has been cancelled
Workflow Sanity / generated-doc-baselines (push) Has been cancelled
CI / runner-admission (push) Has been cancelled
CI / preflight (push) Has been cancelled
CI / security-fast (push) Has been cancelled
CI / pnpm-store-warmup (push) Has been cancelled
CI / build-artifacts (push) Has been cancelled
CI / native-i18n (push) Has been cancelled
CI / ${{ matrix.check_name }} (push) Has been cancelled
CI / ${{ matrix.checkName }} (push) Has been cancelled
CI / checks-node-compat-node22 (push) Has been cancelled
CI / check-bundled-channel-config-metadata (push) Has been cancelled
CI / check-dependencies (push) Has been cancelled
CI / check-guards (push) Has been cancelled
CI / check-lint (push) Has been cancelled
CI / check-prod-types (push) Has been cancelled
CI / check-shrinkwrap (push) Has been cancelled
CI / check-test-types (push) Has been cancelled
CI / check-additional-boundaries-a (push) Has been cancelled
CI / check-additional-boundaries-bcd (push) Has been cancelled
CI / check-additional-extension-bundled (push) Has been cancelled
CI / check-additional-extension-channels (push) Has been cancelled
CI / check-additional-extension-package-boundary (push) Has been cancelled
CI / check-additional-runtime-topology-architecture (push) Has been cancelled
CI / check-session-accessor-boundary (push) Has been cancelled
CI / check-session-transcript-reader-boundary (push) Has been cancelled
CI / check-docs (push) Has been cancelled
CI / skills-python (push) Has been cancelled
CI / macos-swift (push) Has been cancelled
CI / ios-build (push) Has been cancelled
CI / ci-timings-summary (push) Has been cancelled
Native App Locale Refresh / Refresh native fa (push) Has been cancelled
Native App Locale Refresh / Refresh native fr (push) Has been cancelled
Native App Locale Refresh / Refresh native hi (push) Has been cancelled
Native App Locale Refresh / Refresh native id (push) Has been cancelled
Native App Locale Refresh / Refresh native it (push) Has been cancelled
Native App Locale Refresh / Refresh native ja-JP (push) Has been cancelled
Control UI Locale Refresh / plan (push) Has been cancelled
Control UI Locale Refresh / Refresh ${{ matrix.locale }} (push) Has been cancelled
Control UI Locale Refresh / Commit control UI locale refresh (push) Has been cancelled
Live Media Runner Image / Build live media runner image (push) Has been cancelled
Native App Locale Refresh / Refresh native ar (push) Has been cancelled
Native App Locale Refresh / Refresh native de (push) Has been cancelled
Native App Locale Refresh / Refresh native es (push) Has been cancelled
Native App Locale Refresh / Refresh native ko (push) Has been cancelled
Native App Locale Refresh / Refresh native nl (push) Has been cancelled
Native App Locale Refresh / Refresh native pl (push) Has been cancelled
Native App Locale Refresh / Refresh native pt-BR (push) Has been cancelled
Native App Locale Refresh / Refresh native ru (push) Has been cancelled
Native App Locale Refresh / Refresh native sv (push) Has been cancelled
Native App Locale Refresh / Refresh native th (push) Has been cancelled
Native App Locale Refresh / Refresh native tr (push) Has been cancelled
Native App Locale Refresh / Refresh native uk (push) Has been cancelled
Native App Locale Refresh / Refresh native vi (push) Has been cancelled
Native App Locale Refresh / Refresh native zh-CN (push) Has been cancelled
Native App Locale Refresh / Refresh native zh-TW (push) Has been cancelled
Native App Locale Refresh / Commit native locale refresh (push) Has been cancelled
Plugin Init Scaffold Validation / Validate provider scaffold (push) Has been cancelled
Plugin NPM Release / preview_plugins_npm (push) Has been cancelled
Plugin NPM Release / Validate release publish approval (push) Has been cancelled
Plugin NPM Release / preview_plugin_pack (push) Has been cancelled
Plugin NPM Release / publish_plugins_npm (push) Has been cancelled
Sandbox Common Smoke / sandbox-common-smoke (push) Has been cancelled
Website Installer Sync / static (push) Has been cancelled
Website Installer Sync / linux-docker (push) Has been cancelled
Website Installer Sync / macos-installer (push) Has been cancelled
Website Installer Sync / windows-installer (push) Has been cancelled
Website Installer Sync / sync-website (push) Has been cancelled
Adolf is a fork/vendored clone of github.com/openclaw/openclaw (v2026.6.11), free to diverge. Tree copied sans upstream .git; upstream remote added for future syncs. Node pinned to 24 (.nvmrc); engines already require >=22.19. Preserves docs/ARCHITECTURE.md. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01LeqyaxJF2nbRXJtae2kNB2
This commit is contained in:
153
extensions/lobster/src/lobster-ajv-cache.ts
Normal file
153
extensions/lobster/src/lobster-ajv-cache.ts
Normal file
@@ -0,0 +1,153 @@
|
||||
// Lobster plugin module implements lobster ajv cache behavior.
|
||||
import { createHash } from "node:crypto";
|
||||
import { createRequire } from "node:module";
|
||||
import { pathToFileURL } from "node:url";
|
||||
|
||||
const installedSymbol = Symbol.for("openclaw.lobster.ajv-compile-cache.installed");
|
||||
const cacheSymbol = Symbol.for("openclaw.lobster.ajv-compile-cache.entries");
|
||||
const maxEntries = 512;
|
||||
|
||||
type ValidateFunction = (value: unknown) => boolean;
|
||||
type AjvInstance = {
|
||||
compile: (schema: unknown) => ValidateFunction;
|
||||
removeSchema: (schemaKeyRef?: unknown) => AjvInstance;
|
||||
};
|
||||
type AjvConstructor = {
|
||||
new (opts?: object): AjvInstance;
|
||||
prototype: AjvInstance;
|
||||
};
|
||||
type AjvWithCompileCache = AjvInstance & {
|
||||
[cacheSymbol]?: Map<string, CompileCacheEntry>;
|
||||
};
|
||||
type AjvPrototypePatch = AjvInstance & {
|
||||
[installedSymbol]?: boolean;
|
||||
};
|
||||
type CompileCacheEntry = {
|
||||
schema: unknown;
|
||||
validate: ValidateFunction;
|
||||
};
|
||||
|
||||
function stableJsonStringify(value: unknown, seen = new WeakSet<object>()): string {
|
||||
if (value === null || typeof value !== "object") {
|
||||
return JSON.stringify(value);
|
||||
}
|
||||
if (seen.has(value)) {
|
||||
throw new TypeError("Cannot cache cyclic JSON schema");
|
||||
}
|
||||
seen.add(value);
|
||||
if (Array.isArray(value)) {
|
||||
const items = value.map((entry) => stableJsonStringify(entry, seen));
|
||||
seen.delete(value);
|
||||
return `[${items.join(",")}]`;
|
||||
}
|
||||
const record = value as Record<string, unknown>;
|
||||
const keys = Object.keys(record).toSorted();
|
||||
const properties = keys
|
||||
.filter((key) => record[key] !== undefined)
|
||||
.map((key) => `${JSON.stringify(key)}:${stableJsonStringify(record[key], seen)}`);
|
||||
seen.delete(value);
|
||||
return `{${properties.join(",")}}`;
|
||||
}
|
||||
|
||||
function compileCacheKey(schema: unknown): string | null {
|
||||
try {
|
||||
return createHash("sha256").update(stableJsonStringify(schema)).digest("hex");
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
function readCompileCache(instance: AjvWithCompileCache): Map<string, CompileCacheEntry> {
|
||||
let cache = instance[cacheSymbol];
|
||||
if (!cache) {
|
||||
cache = new Map<string, CompileCacheEntry>();
|
||||
Object.defineProperty(instance, cacheSymbol, {
|
||||
value: cache,
|
||||
configurable: true,
|
||||
});
|
||||
}
|
||||
return cache;
|
||||
}
|
||||
|
||||
function rememberCompiledValidator(params: {
|
||||
cache: Map<string, CompileCacheEntry>;
|
||||
instance: AjvWithCompileCache;
|
||||
key: string;
|
||||
removeSchema: AjvInstance["removeSchema"];
|
||||
schema: unknown;
|
||||
validate: ValidateFunction;
|
||||
}) {
|
||||
const { cache, instance, key, removeSchema, schema, validate } = params;
|
||||
if (!cache.has(key) && cache.size >= maxEntries) {
|
||||
const oldest = cache.keys().next().value;
|
||||
if (oldest !== undefined) {
|
||||
const evicted = cache.get(oldest);
|
||||
cache.delete(oldest);
|
||||
if (evicted) {
|
||||
removeSchema.call(instance, evicted.schema);
|
||||
}
|
||||
}
|
||||
}
|
||||
cache.set(key, { schema, validate });
|
||||
}
|
||||
|
||||
async function resolveLobsterAjvConstructor(packageEntryPath: string): Promise<AjvConstructor> {
|
||||
const lobsterRequire = createRequire(packageEntryPath);
|
||||
const ajvPath = lobsterRequire.resolve("ajv");
|
||||
const ajvModule = (await import(pathToFileURL(ajvPath).href)) as { default?: unknown };
|
||||
return (ajvModule.default ?? ajvModule) as AjvConstructor;
|
||||
}
|
||||
|
||||
export async function installLobsterAjvCompileCache(packageEntryPath: string) {
|
||||
let AjvCtor: AjvConstructor;
|
||||
try {
|
||||
AjvCtor = await resolveLobsterAjvConstructor(packageEntryPath);
|
||||
} catch {
|
||||
return;
|
||||
}
|
||||
const proto = AjvCtor.prototype as AjvPrototypePatch;
|
||||
if (proto[installedSymbol]) {
|
||||
return;
|
||||
}
|
||||
|
||||
const originalCompile = proto.compile;
|
||||
const originalRemoveSchema = proto.removeSchema;
|
||||
|
||||
Object.defineProperty(proto, installedSymbol, {
|
||||
value: true,
|
||||
configurable: true,
|
||||
});
|
||||
|
||||
proto.compile = function compileWithContentCache(
|
||||
this: AjvWithCompileCache,
|
||||
schema: unknown,
|
||||
): ValidateFunction {
|
||||
const key = compileCacheKey(schema);
|
||||
if (!key) {
|
||||
return originalCompile.call(this, schema);
|
||||
}
|
||||
const cache = readCompileCache(this);
|
||||
const cached = cache.get(key);
|
||||
if (cached) {
|
||||
return cached.validate;
|
||||
}
|
||||
const validate = originalCompile.call(this, schema);
|
||||
rememberCompiledValidator({
|
||||
cache,
|
||||
instance: this,
|
||||
key,
|
||||
removeSchema: originalRemoveSchema,
|
||||
schema,
|
||||
validate,
|
||||
});
|
||||
return validate;
|
||||
};
|
||||
|
||||
proto.removeSchema = function removeSchemaAndClearContentCache(
|
||||
this: AjvWithCompileCache,
|
||||
schemaKeyRef?: unknown,
|
||||
) {
|
||||
this[cacheSymbol]?.clear();
|
||||
return originalRemoveSchema.call(this, schemaKeyRef);
|
||||
};
|
||||
}
|
||||
61
extensions/lobster/src/lobster-core.d.ts
vendored
Normal file
61
extensions/lobster/src/lobster-core.d.ts
vendored
Normal file
@@ -0,0 +1,61 @@
|
||||
// Lobster type declarations define plugin contracts.
|
||||
declare module "@clawdbot/lobster/core" {
|
||||
type LobsterApprovalRequest = {
|
||||
type: "approval_request";
|
||||
prompt: string;
|
||||
items: unknown[];
|
||||
resumeToken?: string;
|
||||
approvalId?: string;
|
||||
} | null;
|
||||
|
||||
type LobsterToolContext = {
|
||||
cwd?: string;
|
||||
env?: Record<string, string | undefined>;
|
||||
stdin?: NodeJS.ReadableStream;
|
||||
stdout?: NodeJS.WritableStream;
|
||||
stderr?: NodeJS.WritableStream;
|
||||
signal?: AbortSignal;
|
||||
registry?: unknown;
|
||||
llmAdapters?: Record<string, unknown>;
|
||||
};
|
||||
|
||||
type LobsterToolEnvelope =
|
||||
| {
|
||||
protocolVersion: 1;
|
||||
ok: true;
|
||||
status: "ok" | "needs_approval" | "needs_input" | "cancelled";
|
||||
output: unknown[];
|
||||
requiresApproval: LobsterApprovalRequest;
|
||||
requiresInput?: {
|
||||
prompt: string;
|
||||
schema?: unknown;
|
||||
items?: unknown[];
|
||||
resumeToken?: string;
|
||||
approvalId?: string;
|
||||
} | null;
|
||||
}
|
||||
| {
|
||||
protocolVersion: 1;
|
||||
ok: false;
|
||||
error: {
|
||||
type: string;
|
||||
message: string;
|
||||
};
|
||||
};
|
||||
|
||||
export function runToolRequest(params: {
|
||||
pipeline?: string;
|
||||
filePath?: string;
|
||||
args?: Record<string, unknown>;
|
||||
ctx?: LobsterToolContext;
|
||||
}): Promise<LobsterToolEnvelope>;
|
||||
|
||||
export function resumeToolRequest(params: {
|
||||
token?: string;
|
||||
approvalId?: string;
|
||||
approved?: boolean;
|
||||
response?: unknown;
|
||||
cancel?: boolean;
|
||||
ctx?: LobsterToolContext;
|
||||
}): Promise<LobsterToolEnvelope>;
|
||||
}
|
||||
741
extensions/lobster/src/lobster-runner.test.ts
Normal file
741
extensions/lobster/src/lobster-runner.test.ts
Normal file
@@ -0,0 +1,741 @@
|
||||
// Lobster tests cover lobster runner plugin behavior.
|
||||
import fs from "node:fs/promises";
|
||||
import { createRequire } from "node:module";
|
||||
import os from "node:os";
|
||||
import path from "node:path";
|
||||
import { pathToFileURL } from "node:url";
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
import {
|
||||
createEmbeddedLobsterRunner,
|
||||
loadEmbeddedToolRuntimeFromPackage,
|
||||
resolveLobsterCwd,
|
||||
} from "./lobster-runner.js";
|
||||
|
||||
const requireForTest = createRequire(import.meta.url);
|
||||
const ajvInternalCacheKey = "_cache";
|
||||
|
||||
type AjvInstance = {
|
||||
compile: (schema: unknown) => unknown;
|
||||
};
|
||||
type AjvConstructor = new (opts?: object) => AjvInstance;
|
||||
|
||||
function readAjvInternalCacheSize(ajv: unknown): number {
|
||||
return (ajv as Record<string, { size: number } | undefined>)[ajvInternalCacheKey]?.size ?? 0;
|
||||
}
|
||||
|
||||
async function importLobsterAjvConstructor(): Promise<AjvConstructor> {
|
||||
const lobsterEntry = requireForTest.resolve("@clawdbot/lobster");
|
||||
const lobsterRequire = createRequire(lobsterEntry);
|
||||
const ajvPath = lobsterRequire.resolve("ajv");
|
||||
const ajvModule = (await import(pathToFileURL(ajvPath).href)) as { default?: unknown };
|
||||
return ajvModule.default as AjvConstructor;
|
||||
}
|
||||
|
||||
function createRepeatedResponseSchema() {
|
||||
return {
|
||||
type: "object",
|
||||
properties: {
|
||||
ok: { type: "boolean" },
|
||||
output: {
|
||||
type: "array",
|
||||
items: { type: "object" },
|
||||
},
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function createUniqueResponseSchema(index: number) {
|
||||
return {
|
||||
...createRepeatedResponseSchema(),
|
||||
properties: {
|
||||
...createRepeatedResponseSchema().properties,
|
||||
[`unique_${index}`]: { type: "string" },
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function requireRecord(value: unknown, label: string): Record<string, unknown> {
|
||||
if (value === null || typeof value !== "object" || Array.isArray(value)) {
|
||||
throw new Error(`expected ${label} to be a record`);
|
||||
}
|
||||
return value as Record<string, unknown>;
|
||||
}
|
||||
|
||||
function requireFirstCallParam(calls: ReadonlyArray<readonly unknown[]>, label: string) {
|
||||
const call = calls[0];
|
||||
if (!call) {
|
||||
throw new Error(`expected ${label} call`);
|
||||
}
|
||||
return call[0];
|
||||
}
|
||||
|
||||
function expectToolContext(value: unknown, expected: { cwd?: string; mode: "tool" }) {
|
||||
const ctx = requireRecord(value, "tool context");
|
||||
if (expected.cwd !== undefined) {
|
||||
expect(ctx.cwd).toBe(expected.cwd);
|
||||
}
|
||||
expect(ctx.mode).toBe(expected.mode);
|
||||
expect(ctx.signal).toBeInstanceOf(AbortSignal);
|
||||
}
|
||||
|
||||
describe("resolveLobsterCwd", () => {
|
||||
it("defaults to the current working directory", () => {
|
||||
expect(resolveLobsterCwd(undefined)).toBe(process.cwd());
|
||||
});
|
||||
|
||||
it("keeps relative paths inside the repo root", () => {
|
||||
expect(resolveLobsterCwd("extensions/lobster")).toBe(
|
||||
path.resolve(process.cwd(), "extensions/lobster"),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe("createEmbeddedLobsterRunner", () => {
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
it("runs inline pipelines through the embedded runtime", async () => {
|
||||
const runtime = {
|
||||
runToolRequest: vi.fn().mockResolvedValue({
|
||||
ok: true,
|
||||
protocolVersion: 1,
|
||||
status: "ok",
|
||||
output: [{ hello: "world" }],
|
||||
requiresApproval: null,
|
||||
}),
|
||||
resumeToolRequest: vi.fn(),
|
||||
};
|
||||
|
||||
const runner = createEmbeddedLobsterRunner({
|
||||
loadRuntime: vi.fn().mockResolvedValue(runtime),
|
||||
});
|
||||
|
||||
const envelope = await runner.run({
|
||||
action: "run",
|
||||
pipeline: "exec --json=true echo hi",
|
||||
cwd: process.cwd(),
|
||||
timeoutMs: 2000,
|
||||
maxStdoutBytes: 4096,
|
||||
});
|
||||
|
||||
expect(runtime.runToolRequest).toHaveBeenCalledTimes(1);
|
||||
const request = requireRecord(
|
||||
requireFirstCallParam(runtime.runToolRequest.mock.calls, "run tool request"),
|
||||
"run tool request",
|
||||
);
|
||||
expect(request.pipeline).toBe("exec --json=true echo hi");
|
||||
expectToolContext(request.ctx, { cwd: process.cwd(), mode: "tool" });
|
||||
expect(envelope).toEqual({
|
||||
ok: true,
|
||||
status: "ok",
|
||||
output: [{ hello: "world" }],
|
||||
requiresApproval: null,
|
||||
});
|
||||
});
|
||||
|
||||
it.each([
|
||||
"exec --json=true cat data.json",
|
||||
"exec --json=true cat config.yaml",
|
||||
"exec --json=true cat flow.lobster",
|
||||
"exec --json=true cat /tmp/missing.json",
|
||||
"http.fetch https://example.test/workflows/flow.lobster",
|
||||
"exec --json=true echo nested/path",
|
||||
])("keeps inline pipeline with file-like args as a pipeline: %s", async (pipeline) => {
|
||||
const runtime = {
|
||||
runToolRequest: vi.fn().mockResolvedValue({
|
||||
ok: true,
|
||||
protocolVersion: 1,
|
||||
status: "ok",
|
||||
output: [],
|
||||
requiresApproval: null,
|
||||
}),
|
||||
resumeToolRequest: vi.fn(),
|
||||
};
|
||||
|
||||
const runner = createEmbeddedLobsterRunner({
|
||||
loadRuntime: vi.fn().mockResolvedValue(runtime),
|
||||
});
|
||||
|
||||
await runner.run({
|
||||
action: "run",
|
||||
pipeline,
|
||||
cwd: process.cwd(),
|
||||
timeoutMs: 2000,
|
||||
maxStdoutBytes: 4096,
|
||||
});
|
||||
|
||||
expect(runtime.runToolRequest).toHaveBeenCalledOnce();
|
||||
const request = requireRecord(
|
||||
requireFirstCallParam(runtime.runToolRequest.mock.calls, "inline run tool request"),
|
||||
"inline run tool request",
|
||||
);
|
||||
expect(request.pipeline).toBe(pipeline);
|
||||
expect(request.filePath).toBeUndefined();
|
||||
});
|
||||
|
||||
it("detects workflow files and parses argsJson", async () => {
|
||||
const tempDir = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-lobster-runner-"));
|
||||
const workflowPath = path.join(tempDir, "workflow.lobster");
|
||||
await fs.writeFile(workflowPath, "steps: []\n", "utf8");
|
||||
|
||||
try {
|
||||
const runtime = {
|
||||
runToolRequest: vi.fn().mockResolvedValue({
|
||||
ok: true,
|
||||
protocolVersion: 1,
|
||||
status: "ok",
|
||||
output: [],
|
||||
requiresApproval: null,
|
||||
}),
|
||||
resumeToolRequest: vi.fn(),
|
||||
};
|
||||
|
||||
const runner = createEmbeddedLobsterRunner({
|
||||
loadRuntime: vi.fn().mockResolvedValue(runtime),
|
||||
});
|
||||
|
||||
await runner.run({
|
||||
action: "run",
|
||||
pipeline: "workflow.lobster",
|
||||
argsJson: '{"limit":3}',
|
||||
cwd: tempDir,
|
||||
timeoutMs: 2000,
|
||||
maxStdoutBytes: 4096,
|
||||
});
|
||||
|
||||
expect(runtime.runToolRequest).toHaveBeenCalledOnce();
|
||||
const request = requireRecord(
|
||||
requireFirstCallParam(runtime.runToolRequest.mock.calls, "workflow run tool request"),
|
||||
"workflow run tool request",
|
||||
);
|
||||
expect(request.filePath).toBe(workflowPath);
|
||||
expect(request.args).toEqual({ limit: 3 });
|
||||
expectToolContext(request.ctx, { cwd: tempDir, mode: "tool" });
|
||||
} finally {
|
||||
await fs.rm(tempDir, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
it("detects existing workflow file paths that contain spaces", async () => {
|
||||
const tempDir = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-lobster-runner-"));
|
||||
const workflowPath = path.join(tempDir, "daily inbox.lobster");
|
||||
await fs.writeFile(workflowPath, "steps: []\n", "utf8");
|
||||
|
||||
try {
|
||||
const runtime = {
|
||||
runToolRequest: vi.fn().mockResolvedValue({
|
||||
ok: true,
|
||||
protocolVersion: 1,
|
||||
status: "ok",
|
||||
output: [],
|
||||
requiresApproval: null,
|
||||
}),
|
||||
resumeToolRequest: vi.fn(),
|
||||
};
|
||||
|
||||
const runner = createEmbeddedLobsterRunner({
|
||||
loadRuntime: vi.fn().mockResolvedValue(runtime),
|
||||
});
|
||||
|
||||
await runner.run({
|
||||
action: "run",
|
||||
pipeline: "daily inbox.lobster",
|
||||
cwd: tempDir,
|
||||
timeoutMs: 2000,
|
||||
maxStdoutBytes: 4096,
|
||||
});
|
||||
|
||||
expect(runtime.runToolRequest).toHaveBeenCalledOnce();
|
||||
const request = requireRecord(
|
||||
requireFirstCallParam(runtime.runToolRequest.mock.calls, "workflow file with spaces"),
|
||||
"workflow file with spaces",
|
||||
);
|
||||
expect(request.filePath).toBe(workflowPath);
|
||||
expect(request.pipeline).toBeUndefined();
|
||||
} finally {
|
||||
await fs.rm(tempDir, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
it.each([
|
||||
["missing.lobster", "missing.lobster"],
|
||||
["nested/missing.yaml", path.join("nested", "missing.yaml")],
|
||||
])("surfaces missing workflow path errors for %s", async (pipeline, expectedRelativePath) => {
|
||||
const tempDir = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-lobster-runner-"));
|
||||
|
||||
try {
|
||||
const runtime = {
|
||||
runToolRequest: vi.fn(),
|
||||
resumeToolRequest: vi.fn(),
|
||||
};
|
||||
const runner = createEmbeddedLobsterRunner({
|
||||
loadRuntime: vi.fn().mockResolvedValue(runtime),
|
||||
});
|
||||
|
||||
await expect(
|
||||
runner.run({
|
||||
action: "run",
|
||||
pipeline,
|
||||
cwd: tempDir,
|
||||
timeoutMs: 2000,
|
||||
maxStdoutBytes: 4096,
|
||||
}),
|
||||
).rejects.toMatchObject({
|
||||
code: "ENOENT",
|
||||
path: path.join(tempDir, expectedRelativePath),
|
||||
});
|
||||
expect(runtime.runToolRequest).not.toHaveBeenCalled();
|
||||
} finally {
|
||||
await fs.rm(tempDir, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
it("returns a parse error when workflow args are invalid JSON", async () => {
|
||||
const tempDir = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-lobster-runner-"));
|
||||
const workflowPath = path.join(tempDir, "workflow.lobster");
|
||||
await fs.writeFile(workflowPath, "steps: []\n", "utf8");
|
||||
|
||||
try {
|
||||
const runtime = {
|
||||
runToolRequest: vi.fn(),
|
||||
resumeToolRequest: vi.fn(),
|
||||
};
|
||||
const runner = createEmbeddedLobsterRunner({
|
||||
loadRuntime: vi.fn().mockResolvedValue(runtime),
|
||||
});
|
||||
|
||||
await expect(
|
||||
runner.run({
|
||||
action: "run",
|
||||
pipeline: "workflow.lobster",
|
||||
argsJson: "{bad",
|
||||
cwd: tempDir,
|
||||
timeoutMs: 2000,
|
||||
maxStdoutBytes: 4096,
|
||||
}),
|
||||
).rejects.toThrow("run --args-json must be valid JSON");
|
||||
expect(runtime.runToolRequest).not.toHaveBeenCalled();
|
||||
} finally {
|
||||
await fs.rm(tempDir, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
it("throws when the embedded runtime returns an error envelope", async () => {
|
||||
const runtime = {
|
||||
runToolRequest: vi.fn().mockResolvedValue({
|
||||
ok: false,
|
||||
protocolVersion: 1,
|
||||
error: {
|
||||
type: "runtime_error",
|
||||
message: "boom",
|
||||
},
|
||||
}),
|
||||
resumeToolRequest: vi.fn(),
|
||||
};
|
||||
|
||||
const runner = createEmbeddedLobsterRunner({
|
||||
loadRuntime: vi.fn().mockResolvedValue(runtime),
|
||||
});
|
||||
|
||||
await expect(
|
||||
runner.run({
|
||||
action: "run",
|
||||
pipeline: "exec --json=true echo hi",
|
||||
cwd: process.cwd(),
|
||||
timeoutMs: 2000,
|
||||
maxStdoutBytes: 4096,
|
||||
}),
|
||||
).rejects.toThrow("boom");
|
||||
});
|
||||
|
||||
it("fails closed when the embedded runtime requests unsupported input", async () => {
|
||||
const runtime = {
|
||||
runToolRequest: vi.fn().mockResolvedValue({
|
||||
ok: true,
|
||||
protocolVersion: 1,
|
||||
status: "needs_input",
|
||||
output: [],
|
||||
requiresApproval: null,
|
||||
requiresInput: {
|
||||
prompt: "Need more data",
|
||||
schema: { type: "string" },
|
||||
},
|
||||
}),
|
||||
resumeToolRequest: vi.fn(),
|
||||
};
|
||||
|
||||
const runner = createEmbeddedLobsterRunner({
|
||||
loadRuntime: vi.fn().mockResolvedValue(runtime),
|
||||
});
|
||||
|
||||
await expect(
|
||||
runner.run({
|
||||
action: "run",
|
||||
pipeline: "exec --json=true echo hi",
|
||||
cwd: process.cwd(),
|
||||
timeoutMs: 2000,
|
||||
maxStdoutBytes: 4096,
|
||||
}),
|
||||
).rejects.toThrow("Lobster input requests are not supported by the OpenClaw Lobster tool yet");
|
||||
});
|
||||
|
||||
it("routes resume through the embedded runtime", async () => {
|
||||
const runtime = {
|
||||
runToolRequest: vi.fn(),
|
||||
resumeToolRequest: vi.fn().mockResolvedValue({
|
||||
ok: true,
|
||||
protocolVersion: 1,
|
||||
status: "cancelled",
|
||||
output: [],
|
||||
requiresApproval: null,
|
||||
}),
|
||||
};
|
||||
|
||||
const runner = createEmbeddedLobsterRunner({
|
||||
loadRuntime: vi.fn().mockResolvedValue(runtime),
|
||||
});
|
||||
|
||||
const envelope = await runner.run({
|
||||
action: "resume",
|
||||
token: "resume-token",
|
||||
approve: false,
|
||||
cwd: process.cwd(),
|
||||
timeoutMs: 2000,
|
||||
maxStdoutBytes: 4096,
|
||||
});
|
||||
|
||||
expect(runtime.resumeToolRequest).toHaveBeenCalledOnce();
|
||||
const request = requireRecord(
|
||||
requireFirstCallParam(runtime.resumeToolRequest.mock.calls, "resume tool request"),
|
||||
"resume tool request",
|
||||
);
|
||||
expect(request.token).toBe("resume-token");
|
||||
expect(request.approved).toBe(false);
|
||||
expectToolContext(request.ctx, { cwd: process.cwd(), mode: "tool" });
|
||||
expect(envelope).toEqual({
|
||||
ok: true,
|
||||
status: "cancelled",
|
||||
output: [],
|
||||
requiresApproval: null,
|
||||
});
|
||||
});
|
||||
|
||||
it("forwards approvalId through resume when token is absent", async () => {
|
||||
const runtime = {
|
||||
runToolRequest: vi.fn(),
|
||||
resumeToolRequest: vi.fn().mockResolvedValue({
|
||||
ok: true,
|
||||
protocolVersion: 1,
|
||||
status: "ok",
|
||||
output: [],
|
||||
requiresApproval: null,
|
||||
}),
|
||||
};
|
||||
|
||||
const runner = createEmbeddedLobsterRunner({
|
||||
loadRuntime: vi.fn().mockResolvedValue(runtime),
|
||||
});
|
||||
|
||||
await runner.run({
|
||||
action: "resume",
|
||||
approvalId: "dbc98d05",
|
||||
approve: true,
|
||||
cwd: process.cwd(),
|
||||
timeoutMs: 2000,
|
||||
maxStdoutBytes: 4096,
|
||||
});
|
||||
|
||||
expect(runtime.resumeToolRequest).toHaveBeenCalledOnce();
|
||||
const request = requireRecord(
|
||||
requireFirstCallParam(runtime.resumeToolRequest.mock.calls, "approval resume tool request"),
|
||||
"approval resume tool request",
|
||||
);
|
||||
expect(request.approvalId).toBe("dbc98d05");
|
||||
expect(request.approved).toBe(true);
|
||||
expectToolContext(request.ctx, { mode: "tool" });
|
||||
});
|
||||
|
||||
it("passes approvalId through the normalized needs_approval envelope", async () => {
|
||||
const runtime = {
|
||||
runToolRequest: vi.fn().mockResolvedValue({
|
||||
ok: true,
|
||||
protocolVersion: 1,
|
||||
status: "needs_approval",
|
||||
output: [],
|
||||
requiresApproval: {
|
||||
type: "approval_request",
|
||||
prompt: "ok?",
|
||||
items: [],
|
||||
resumeToken: "eyJ...",
|
||||
approvalId: "dbc98d05",
|
||||
},
|
||||
}),
|
||||
resumeToolRequest: vi.fn(),
|
||||
};
|
||||
|
||||
const runner = createEmbeddedLobsterRunner({
|
||||
loadRuntime: vi.fn().mockResolvedValue(runtime),
|
||||
});
|
||||
|
||||
const envelope = await runner.run({
|
||||
action: "run",
|
||||
pipeline: "exec --json=true echo hi",
|
||||
cwd: process.cwd(),
|
||||
timeoutMs: 2000,
|
||||
maxStdoutBytes: 4096,
|
||||
});
|
||||
|
||||
expect(envelope).toEqual({
|
||||
ok: true,
|
||||
status: "needs_approval",
|
||||
output: [],
|
||||
requiresApproval: {
|
||||
type: "approval_request",
|
||||
prompt: "ok?",
|
||||
items: [],
|
||||
resumeToken: "eyJ...",
|
||||
approvalId: "dbc98d05",
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it("loads the embedded runtime once per runner", async () => {
|
||||
const runtime = {
|
||||
runToolRequest: vi.fn().mockResolvedValue({
|
||||
ok: true,
|
||||
protocolVersion: 1,
|
||||
status: "ok",
|
||||
output: [],
|
||||
requiresApproval: null,
|
||||
}),
|
||||
resumeToolRequest: vi.fn().mockResolvedValue({
|
||||
ok: true,
|
||||
protocolVersion: 1,
|
||||
status: "cancelled",
|
||||
output: [],
|
||||
requiresApproval: null,
|
||||
}),
|
||||
};
|
||||
const loadRuntime = vi.fn().mockResolvedValue(runtime);
|
||||
|
||||
const runner = createEmbeddedLobsterRunner({ loadRuntime });
|
||||
|
||||
await runner.run({
|
||||
action: "run",
|
||||
pipeline: "exec --json=true echo hi",
|
||||
cwd: process.cwd(),
|
||||
timeoutMs: 2000,
|
||||
maxStdoutBytes: 4096,
|
||||
});
|
||||
await runner.run({
|
||||
action: "resume",
|
||||
token: "resume-token",
|
||||
approve: false,
|
||||
cwd: process.cwd(),
|
||||
timeoutMs: 2000,
|
||||
maxStdoutBytes: 4096,
|
||||
});
|
||||
|
||||
expect(loadRuntime).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("falls back to the installed package core file when the core export is unavailable", async () => {
|
||||
const tempDir = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-lobster-package-"));
|
||||
const packageRoot = path.join(tempDir, "node_modules", "@clawdbot", "lobster");
|
||||
const packageEntryPath = path.join(packageRoot, "dist", "src", "sdk", "index.js");
|
||||
const packageCorePath = path.join(packageRoot, "dist", "src", "core", "index.js");
|
||||
|
||||
try {
|
||||
await fs.mkdir(path.dirname(packageEntryPath), { recursive: true });
|
||||
await fs.mkdir(path.dirname(packageCorePath), { recursive: true });
|
||||
await fs.writeFile(
|
||||
path.join(packageRoot, "package.json"),
|
||||
JSON.stringify({
|
||||
name: "@clawdbot/lobster",
|
||||
type: "module",
|
||||
main: "./dist/src/sdk/index.js",
|
||||
}),
|
||||
"utf8",
|
||||
);
|
||||
await fs.writeFile(packageEntryPath, "export {};\n", "utf8");
|
||||
await fs.writeFile(
|
||||
packageCorePath,
|
||||
[
|
||||
"export async function runToolRequest() {",
|
||||
" return { ok: true, status: 'ok', output: [{ source: 'fallback' }], requiresApproval: null };",
|
||||
"}",
|
||||
"export async function resumeToolRequest() {",
|
||||
" return { ok: true, status: 'cancelled', output: [], requiresApproval: null };",
|
||||
"}",
|
||||
"",
|
||||
].join("\n"),
|
||||
"utf8",
|
||||
);
|
||||
|
||||
const runtime = await loadEmbeddedToolRuntimeFromPackage({
|
||||
importModule: async (specifier) => {
|
||||
if (specifier === "@clawdbot/lobster/core") {
|
||||
throw new Error("package export missing");
|
||||
}
|
||||
return (await import(`${specifier}?t=${Date.now()}`)) as object;
|
||||
},
|
||||
resolvePackageEntry: () => packageEntryPath,
|
||||
});
|
||||
|
||||
await expect(runtime.runToolRequest({ pipeline: "commands.list" })).resolves.toEqual({
|
||||
ok: true,
|
||||
status: "ok",
|
||||
output: [{ source: "fallback" }],
|
||||
requiresApproval: null,
|
||||
});
|
||||
} finally {
|
||||
await fs.rm(tempDir, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
it("installs an Ajv content cache before loading the embedded runtime", async () => {
|
||||
const AjvCtor = await importLobsterAjvConstructor();
|
||||
const ajv = new AjvCtor({ allErrors: true, strict: false, addUsedSchema: false });
|
||||
const before = readAjvInternalCacheSize(ajv);
|
||||
|
||||
await loadEmbeddedToolRuntimeFromPackage({
|
||||
importModule: async () => ({
|
||||
runToolRequest: vi.fn(),
|
||||
resumeToolRequest: vi.fn(),
|
||||
}),
|
||||
});
|
||||
|
||||
const first = ajv.compile(createRepeatedResponseSchema());
|
||||
const second = ajv.compile(createRepeatedResponseSchema());
|
||||
const afterRepeated = readAjvInternalCacheSize(ajv);
|
||||
|
||||
expect(second).toBe(first);
|
||||
expect(afterRepeated - before).toBe(1);
|
||||
|
||||
for (let index = 0; index < 520; index += 1) {
|
||||
ajv.compile(createUniqueResponseSchema(index));
|
||||
}
|
||||
|
||||
expect(readAjvInternalCacheSize(ajv)).toBeLessThanOrEqual(before + 512);
|
||||
});
|
||||
|
||||
it("deduplicates content-identical schema compilation in the installed Lobster runtime", async () => {
|
||||
await loadEmbeddedToolRuntimeFromPackage();
|
||||
|
||||
const corePath = requireForTest.resolve("@clawdbot/lobster/core");
|
||||
const validationPath = path.join(path.dirname(path.dirname(corePath)), "validation.js");
|
||||
const validationModule = (await import(pathToFileURL(validationPath).href)) as {
|
||||
sharedAjv: AjvInstance;
|
||||
};
|
||||
const before = readAjvInternalCacheSize(validationModule.sharedAjv);
|
||||
|
||||
const first = validationModule.sharedAjv.compile(createRepeatedResponseSchema());
|
||||
for (let index = 0; index < 1000; index += 1) {
|
||||
validationModule.sharedAjv.compile(createRepeatedResponseSchema());
|
||||
}
|
||||
const second = validationModule.sharedAjv.compile(createRepeatedResponseSchema());
|
||||
|
||||
expect(second).toBe(first);
|
||||
expect(readAjvInternalCacheSize(validationModule.sharedAjv) - before).toBe(1);
|
||||
});
|
||||
|
||||
it("requires a pipeline for run", async () => {
|
||||
const runner = createEmbeddedLobsterRunner({
|
||||
loadRuntime: vi.fn().mockResolvedValue({
|
||||
runToolRequest: vi.fn(),
|
||||
resumeToolRequest: vi.fn(),
|
||||
}),
|
||||
});
|
||||
|
||||
await expect(
|
||||
runner.run({
|
||||
action: "run",
|
||||
cwd: process.cwd(),
|
||||
timeoutMs: 2000,
|
||||
maxStdoutBytes: 4096,
|
||||
}),
|
||||
).rejects.toThrow(/pipeline required/);
|
||||
});
|
||||
|
||||
it("requires token and approve for resume", async () => {
|
||||
const runner = createEmbeddedLobsterRunner({
|
||||
loadRuntime: vi.fn().mockResolvedValue({
|
||||
runToolRequest: vi.fn(),
|
||||
resumeToolRequest: vi.fn(),
|
||||
}),
|
||||
});
|
||||
|
||||
await expect(
|
||||
runner.run({
|
||||
action: "resume",
|
||||
approve: true,
|
||||
cwd: process.cwd(),
|
||||
timeoutMs: 2000,
|
||||
maxStdoutBytes: 4096,
|
||||
}),
|
||||
).rejects.toThrow(/token or approvalId required/);
|
||||
|
||||
await expect(
|
||||
runner.run({
|
||||
action: "resume",
|
||||
token: "resume-token",
|
||||
cwd: process.cwd(),
|
||||
timeoutMs: 2000,
|
||||
maxStdoutBytes: 4096,
|
||||
}),
|
||||
).rejects.toThrow(/approve required/);
|
||||
});
|
||||
|
||||
it("aborts long-running embedded work", async () => {
|
||||
const runtime = {
|
||||
runToolRequest: vi.fn(
|
||||
async ({ ctx }: { ctx?: { signal?: AbortSignal } }) =>
|
||||
await new Promise((resolve, reject) => {
|
||||
const timeout = setTimeout(
|
||||
() => resolve({ ok: true, status: "ok", output: [], requiresApproval: null }),
|
||||
500,
|
||||
);
|
||||
ctx?.signal?.addEventListener("abort", () => {
|
||||
clearTimeout(timeout);
|
||||
reject(
|
||||
toLintErrorObject(
|
||||
ctx.signal?.reason ?? new Error("aborted"),
|
||||
"Non-Error rejection",
|
||||
),
|
||||
);
|
||||
});
|
||||
}),
|
||||
),
|
||||
resumeToolRequest: vi.fn(),
|
||||
};
|
||||
|
||||
const runner = createEmbeddedLobsterRunner({
|
||||
loadRuntime: vi.fn().mockResolvedValue(runtime),
|
||||
});
|
||||
|
||||
await expect(
|
||||
runner.run({
|
||||
action: "run",
|
||||
pipeline: "exec --json=true echo hi",
|
||||
cwd: process.cwd(),
|
||||
timeoutMs: 200,
|
||||
maxStdoutBytes: 4096,
|
||||
}),
|
||||
).rejects.toThrow(/timed out|aborted/);
|
||||
});
|
||||
});
|
||||
|
||||
function toLintErrorObject(value: unknown, fallbackMessage: string): Error {
|
||||
if (value instanceof Error) {
|
||||
return value;
|
||||
}
|
||||
if (typeof value === "string") {
|
||||
return new Error(value);
|
||||
}
|
||||
const error = new Error(fallbackMessage, { cause: value });
|
||||
if ((typeof value === "object" && value !== null) || typeof value === "function") {
|
||||
Object.assign(error, value);
|
||||
}
|
||||
return error;
|
||||
}
|
||||
429
extensions/lobster/src/lobster-runner.ts
Normal file
429
extensions/lobster/src/lobster-runner.ts
Normal file
@@ -0,0 +1,429 @@
|
||||
// Lobster plugin module implements lobster runner behavior.
|
||||
import { readFileSync } from "node:fs";
|
||||
import { stat } from "node:fs/promises";
|
||||
import { createRequire } from "node:module";
|
||||
import path from "node:path";
|
||||
import { Readable, Writable } from "node:stream";
|
||||
import { pathToFileURL } from "node:url";
|
||||
import { installLobsterAjvCompileCache } from "./lobster-ajv-cache.js";
|
||||
|
||||
export type LobsterEnvelope =
|
||||
| {
|
||||
ok: true;
|
||||
status: "ok" | "needs_approval" | "cancelled";
|
||||
output: unknown[];
|
||||
requiresApproval: null | {
|
||||
type: "approval_request";
|
||||
prompt: string;
|
||||
items: unknown[];
|
||||
resumeToken?: string;
|
||||
approvalId?: string;
|
||||
};
|
||||
}
|
||||
| {
|
||||
ok: false;
|
||||
error: { type?: string; message: string };
|
||||
};
|
||||
|
||||
export type LobsterRunnerParams = {
|
||||
action: "run" | "resume";
|
||||
pipeline?: string;
|
||||
argsJson?: string;
|
||||
token?: string;
|
||||
approvalId?: string;
|
||||
approve?: boolean;
|
||||
cwd: string;
|
||||
timeoutMs: number;
|
||||
maxStdoutBytes: number;
|
||||
};
|
||||
|
||||
export type LobsterRunner = {
|
||||
run: (params: LobsterRunnerParams) => Promise<LobsterEnvelope>;
|
||||
};
|
||||
|
||||
type EmbeddedToolContext = {
|
||||
cwd?: string;
|
||||
env?: Record<string, string | undefined>;
|
||||
mode?: "tool" | "human" | "sdk";
|
||||
stdin?: NodeJS.ReadableStream;
|
||||
stdout?: NodeJS.WritableStream;
|
||||
stderr?: NodeJS.WritableStream;
|
||||
signal?: AbortSignal;
|
||||
registry?: unknown;
|
||||
llmAdapters?: Record<string, unknown>;
|
||||
};
|
||||
|
||||
type EmbeddedToolEnvelope = {
|
||||
protocolVersion?: number;
|
||||
ok: boolean;
|
||||
status?: "ok" | "needs_approval" | "needs_input" | "cancelled";
|
||||
output?: unknown[];
|
||||
requiresApproval?: {
|
||||
type?: "approval_request";
|
||||
prompt: string;
|
||||
items: unknown[];
|
||||
preview?: string;
|
||||
resumeToken?: string;
|
||||
approvalId?: string;
|
||||
} | null;
|
||||
requiresInput?: {
|
||||
prompt: string;
|
||||
schema?: unknown;
|
||||
items?: unknown[];
|
||||
resumeToken?: string;
|
||||
approvalId?: string;
|
||||
} | null;
|
||||
error?: {
|
||||
type?: string;
|
||||
message: string;
|
||||
};
|
||||
};
|
||||
|
||||
type EmbeddedToolRuntime = {
|
||||
runToolRequest: (params: {
|
||||
pipeline?: string;
|
||||
filePath?: string;
|
||||
args?: Record<string, unknown>;
|
||||
ctx?: EmbeddedToolContext;
|
||||
}) => Promise<EmbeddedToolEnvelope>;
|
||||
resumeToolRequest: (params: {
|
||||
token?: string;
|
||||
approvalId?: string;
|
||||
approved?: boolean;
|
||||
response?: unknown;
|
||||
cancel?: boolean;
|
||||
ctx?: EmbeddedToolContext;
|
||||
}) => Promise<EmbeddedToolEnvelope>;
|
||||
};
|
||||
|
||||
type LoadEmbeddedToolRuntime = () => Promise<EmbeddedToolRuntime>;
|
||||
|
||||
type LoadEmbeddedToolRuntimeFromPackageOptions = {
|
||||
importModule?: (specifier: string) => Promise<Partial<EmbeddedToolRuntime>>;
|
||||
resolvePackageEntry?: (specifier: string) => string;
|
||||
};
|
||||
|
||||
const lobsterRequire = createRequire(import.meta.url);
|
||||
const workflowExts = new Set([".lobster", ".yaml", ".yml", ".json"]);
|
||||
|
||||
function toEmbeddedToolRuntime(
|
||||
moduleExports: Partial<EmbeddedToolRuntime>,
|
||||
source: string,
|
||||
): EmbeddedToolRuntime {
|
||||
const { runToolRequest, resumeToolRequest } = moduleExports;
|
||||
if (typeof runToolRequest === "function" && typeof resumeToolRequest === "function") {
|
||||
return { runToolRequest, resumeToolRequest };
|
||||
}
|
||||
throw new Error(`${source} does not export Lobster embedded runtime functions`);
|
||||
}
|
||||
|
||||
function findLobsterPackageRoot(resolvedEntryPath: string): string {
|
||||
let dir = path.dirname(resolvedEntryPath);
|
||||
while (true) {
|
||||
const packageJsonPath = path.join(dir, "package.json");
|
||||
try {
|
||||
const parsed = JSON.parse(readFileSync(packageJsonPath, "utf8")) as { name?: string };
|
||||
if (parsed.name === "@clawdbot/lobster") {
|
||||
return dir;
|
||||
}
|
||||
} catch {
|
||||
// Keep walking until the installed package root is found.
|
||||
}
|
||||
|
||||
const parent = path.dirname(dir);
|
||||
if (parent === dir) {
|
||||
throw new Error(`Could not locate @clawdbot/lobster package root from ${resolvedEntryPath}`);
|
||||
}
|
||||
dir = parent;
|
||||
}
|
||||
}
|
||||
|
||||
function normalizeForCwdSandbox(p: string): string {
|
||||
const normalized = path.normalize(p);
|
||||
return process.platform === "win32" ? normalized.toLowerCase() : normalized;
|
||||
}
|
||||
|
||||
export function resolveLobsterCwd(cwdRaw: unknown): string {
|
||||
if (typeof cwdRaw !== "string" || !cwdRaw.trim()) {
|
||||
return process.cwd();
|
||||
}
|
||||
const cwd = cwdRaw.trim();
|
||||
if (path.isAbsolute(cwd)) {
|
||||
throw new Error("cwd must be a relative path");
|
||||
}
|
||||
const base = process.cwd();
|
||||
const resolved = path.resolve(base, cwd);
|
||||
|
||||
const rel = path.relative(normalizeForCwdSandbox(base), normalizeForCwdSandbox(resolved));
|
||||
if (rel === "" || rel === ".") {
|
||||
return resolved;
|
||||
}
|
||||
if (rel.startsWith("..") || path.isAbsolute(rel)) {
|
||||
throw new Error("cwd must stay within the gateway working directory");
|
||||
}
|
||||
return resolved;
|
||||
}
|
||||
|
||||
function createLimitedSink(maxBytes: number, label: "stdout" | "stderr") {
|
||||
let bytes = 0;
|
||||
return new Writable({
|
||||
write(chunk, _encoding, callback) {
|
||||
bytes += Buffer.byteLength(String(chunk), "utf8");
|
||||
if (bytes > maxBytes) {
|
||||
callback(new Error(`lobster ${label} exceeded maxStdoutBytes`));
|
||||
return;
|
||||
}
|
||||
callback();
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
function normalizeEnvelope(envelope: EmbeddedToolEnvelope): LobsterEnvelope {
|
||||
if (envelope.ok) {
|
||||
if (envelope.status === "needs_input") {
|
||||
return {
|
||||
ok: false,
|
||||
error: {
|
||||
type: "unsupported_status",
|
||||
message: "Lobster input requests are not supported by the OpenClaw Lobster tool yet",
|
||||
},
|
||||
};
|
||||
}
|
||||
return {
|
||||
ok: true,
|
||||
status: envelope.status ?? "ok",
|
||||
output: Array.isArray(envelope.output) ? envelope.output : [],
|
||||
requiresApproval: envelope.requiresApproval
|
||||
? {
|
||||
type: "approval_request",
|
||||
prompt: envelope.requiresApproval.prompt,
|
||||
items: envelope.requiresApproval.items,
|
||||
...(envelope.requiresApproval.resumeToken
|
||||
? { resumeToken: envelope.requiresApproval.resumeToken }
|
||||
: {}),
|
||||
...(envelope.requiresApproval.approvalId
|
||||
? { approvalId: envelope.requiresApproval.approvalId }
|
||||
: {}),
|
||||
}
|
||||
: null,
|
||||
};
|
||||
}
|
||||
return {
|
||||
ok: false,
|
||||
error: {
|
||||
type: envelope.error?.type,
|
||||
message: envelope.error?.message ?? "lobster runtime failed",
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function throwOnErrorEnvelope(envelope: LobsterEnvelope): Extract<LobsterEnvelope, { ok: true }> {
|
||||
if (envelope.ok) {
|
||||
return envelope;
|
||||
}
|
||||
throw new Error(envelope.error.message);
|
||||
}
|
||||
|
||||
async function resolveWorkflowFile(candidate: string, cwd: string) {
|
||||
const resolved = path.isAbsolute(candidate) ? candidate : path.resolve(cwd, candidate);
|
||||
const fileStat = await stat(resolved);
|
||||
if (!fileStat.isFile()) {
|
||||
throw new Error("Workflow path is not a file");
|
||||
}
|
||||
const ext = path.extname(resolved).toLowerCase();
|
||||
if (!workflowExts.has(ext)) {
|
||||
throw new Error("Workflow file must end in .lobster, .yaml, .yml, or .json");
|
||||
}
|
||||
return resolved;
|
||||
}
|
||||
|
||||
function isMissingPathError(error: unknown) {
|
||||
return (
|
||||
typeof error === "object" &&
|
||||
error !== null &&
|
||||
"code" in error &&
|
||||
(error as { code?: unknown }).code === "ENOENT"
|
||||
);
|
||||
}
|
||||
|
||||
function hasWorkflowFileExtension(candidate: string) {
|
||||
return workflowExts.has(path.extname(candidate).toLowerCase());
|
||||
}
|
||||
|
||||
async function detectWorkflowFile(candidate: string, cwd: string) {
|
||||
const trimmed = candidate.trim();
|
||||
if (!trimmed || trimmed.includes("|") || !hasWorkflowFileExtension(trimmed)) {
|
||||
return null;
|
||||
}
|
||||
if (!/\s/.test(trimmed)) {
|
||||
return await resolveWorkflowFile(trimmed, cwd);
|
||||
}
|
||||
try {
|
||||
return await resolveWorkflowFile(trimmed, cwd);
|
||||
} catch (error) {
|
||||
if (isMissingPathError(error)) {
|
||||
return null;
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
function parseWorkflowArgs(argsJson: string) {
|
||||
return JSON.parse(argsJson) as Record<string, unknown>;
|
||||
}
|
||||
|
||||
function createEmbeddedToolContext(
|
||||
params: LobsterRunnerParams,
|
||||
signal?: AbortSignal,
|
||||
): EmbeddedToolContext {
|
||||
const env = { ...process.env } as Record<string, string | undefined>;
|
||||
return {
|
||||
cwd: params.cwd,
|
||||
env,
|
||||
mode: "tool",
|
||||
stdin: Readable.from([]),
|
||||
stdout: createLimitedSink(Math.max(1024, params.maxStdoutBytes), "stdout"),
|
||||
stderr: createLimitedSink(Math.max(1024, params.maxStdoutBytes), "stderr"),
|
||||
signal,
|
||||
};
|
||||
}
|
||||
|
||||
async function withTimeout<T>(
|
||||
timeoutMs: number,
|
||||
fn: (signal?: AbortSignal) => Promise<T>,
|
||||
): Promise<T> {
|
||||
const timeout = Math.max(200, timeoutMs);
|
||||
const controller = new AbortController();
|
||||
return await new Promise<T>((resolve, reject) => {
|
||||
const onTimeout = () => {
|
||||
const error = new Error("lobster runtime timed out");
|
||||
controller.abort(error);
|
||||
reject(error);
|
||||
};
|
||||
|
||||
const timer = setTimeout(onTimeout, timeout);
|
||||
void fn(controller.signal).then(
|
||||
(value) => {
|
||||
clearTimeout(timer);
|
||||
resolve(value);
|
||||
},
|
||||
(error: unknown) => {
|
||||
clearTimeout(timer);
|
||||
reject(toLintErrorObject(error, "Non-Error rejection"));
|
||||
},
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
export async function loadEmbeddedToolRuntimeFromPackage(
|
||||
options: LoadEmbeddedToolRuntimeFromPackageOptions = {},
|
||||
): Promise<EmbeddedToolRuntime> {
|
||||
const importModule =
|
||||
options.importModule ??
|
||||
(async (specifier: string) => (await import(specifier)) as Partial<EmbeddedToolRuntime>);
|
||||
const resolvePackageEntry =
|
||||
options.resolvePackageEntry ?? ((specifier: string) => lobsterRequire.resolve(specifier));
|
||||
const packageEntryPath = resolvePackageEntry("@clawdbot/lobster");
|
||||
await installLobsterAjvCompileCache(packageEntryPath);
|
||||
|
||||
let coreLoadError: unknown;
|
||||
try {
|
||||
const coreSpecifier = ["@clawdbot", "lobster", "core"].join("/");
|
||||
return toEmbeddedToolRuntime(await importModule(coreSpecifier), "@clawdbot/lobster/core");
|
||||
} catch (error) {
|
||||
coreLoadError = error;
|
||||
}
|
||||
|
||||
let fallbackLoadError: unknown;
|
||||
try {
|
||||
const packageRoot = findLobsterPackageRoot(packageEntryPath);
|
||||
const coreRuntimeUrl = pathToFileURL(path.join(packageRoot, "dist/src/core/index.js")).href;
|
||||
return toEmbeddedToolRuntime(await importModule(coreRuntimeUrl), coreRuntimeUrl);
|
||||
} catch (error) {
|
||||
fallbackLoadError = error;
|
||||
}
|
||||
|
||||
throw new Error("Failed to load the Lobster embedded runtime", {
|
||||
cause: new AggregateError(
|
||||
[coreLoadError, fallbackLoadError],
|
||||
"Both Lobster embedded runtime load paths failed",
|
||||
),
|
||||
});
|
||||
}
|
||||
|
||||
export function createEmbeddedLobsterRunner(options?: {
|
||||
loadRuntime?: LoadEmbeddedToolRuntime;
|
||||
}): LobsterRunner {
|
||||
const loadRuntime = options?.loadRuntime ?? loadEmbeddedToolRuntimeFromPackage;
|
||||
let runtimePromise: Promise<EmbeddedToolRuntime> | undefined;
|
||||
return {
|
||||
async run(params) {
|
||||
runtimePromise ??= loadRuntime();
|
||||
const runtime = await runtimePromise;
|
||||
return await withTimeout(params.timeoutMs, async (signal) => {
|
||||
const ctx = createEmbeddedToolContext(params, signal);
|
||||
|
||||
if (params.action === "run") {
|
||||
const pipeline = params.pipeline?.trim() ?? "";
|
||||
if (!pipeline) {
|
||||
throw new Error("pipeline required");
|
||||
}
|
||||
|
||||
const filePath = await detectWorkflowFile(pipeline, params.cwd);
|
||||
if (filePath) {
|
||||
const parsedArgsJson = params.argsJson?.trim() ?? "";
|
||||
let args: Record<string, unknown> | undefined;
|
||||
if (parsedArgsJson) {
|
||||
try {
|
||||
args = parseWorkflowArgs(parsedArgsJson);
|
||||
} catch {
|
||||
throw new Error("run --args-json must be valid JSON");
|
||||
}
|
||||
}
|
||||
return throwOnErrorEnvelope(
|
||||
normalizeEnvelope(await runtime.runToolRequest({ filePath, args, ctx })),
|
||||
);
|
||||
}
|
||||
|
||||
return throwOnErrorEnvelope(
|
||||
normalizeEnvelope(await runtime.runToolRequest({ pipeline, ctx })),
|
||||
);
|
||||
}
|
||||
|
||||
const token = params.token?.trim() ?? "";
|
||||
const approvalId = params.approvalId?.trim() ?? "";
|
||||
if (!token && !approvalId) {
|
||||
throw new Error("token or approvalId required");
|
||||
}
|
||||
if (typeof params.approve !== "boolean") {
|
||||
throw new Error("approve required");
|
||||
}
|
||||
|
||||
return throwOnErrorEnvelope(
|
||||
normalizeEnvelope(
|
||||
await runtime.resumeToolRequest({
|
||||
...(token ? { token } : {}),
|
||||
...(approvalId ? { approvalId } : {}),
|
||||
approved: params.approve,
|
||||
ctx,
|
||||
}),
|
||||
),
|
||||
);
|
||||
});
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function toLintErrorObject(value: unknown, fallbackMessage: string): Error {
|
||||
if (value instanceof Error) {
|
||||
return value;
|
||||
}
|
||||
if (typeof value === "string") {
|
||||
return new Error(value);
|
||||
}
|
||||
const error = new Error(fallbackMessage, { cause: value });
|
||||
if ((typeof value === "object" && value !== null) || typeof value === "function") {
|
||||
Object.assign(error, value);
|
||||
}
|
||||
return error;
|
||||
}
|
||||
228
extensions/lobster/src/lobster-taskflow.test.ts
Normal file
228
extensions/lobster/src/lobster-taskflow.test.ts
Normal file
@@ -0,0 +1,228 @@
|
||||
// Lobster tests cover lobster taskflow plugin behavior.
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import type { LobsterRunner } from "./lobster-runner.js";
|
||||
import { resumeManagedLobsterFlow, runManagedLobsterFlow } from "./lobster-taskflow.js";
|
||||
import { createFakeTaskFlow } from "./taskflow-test-helpers.js";
|
||||
|
||||
function expectManagedFlowFailure(
|
||||
result: Awaited<ReturnType<typeof runManagedLobsterFlow | typeof resumeManagedLobsterFlow>>,
|
||||
) {
|
||||
expect(result.ok).toBe(false);
|
||||
if (result.ok) {
|
||||
throw new Error("Expected managed Lobster flow to fail");
|
||||
}
|
||||
return result;
|
||||
}
|
||||
function createRunner(result: Awaited<ReturnType<LobsterRunner["run"]>>): LobsterRunner {
|
||||
return {
|
||||
run: vi.fn().mockResolvedValue(result),
|
||||
};
|
||||
}
|
||||
|
||||
function createRunFlowParams(
|
||||
taskFlow: ReturnType<typeof createFakeTaskFlow>,
|
||||
runner: LobsterRunner,
|
||||
): Parameters<typeof runManagedLobsterFlow>[0] {
|
||||
return {
|
||||
taskFlow,
|
||||
runner,
|
||||
runnerParams: {
|
||||
action: "run",
|
||||
pipeline: "noop",
|
||||
cwd: process.cwd(),
|
||||
timeoutMs: 1000,
|
||||
maxStdoutBytes: 4096,
|
||||
},
|
||||
controllerId: "tests/lobster",
|
||||
goal: "Run Lobster workflow",
|
||||
};
|
||||
}
|
||||
|
||||
function createResumeFlowParams(
|
||||
taskFlow: ReturnType<typeof createFakeTaskFlow>,
|
||||
runner: LobsterRunner,
|
||||
): Parameters<typeof resumeManagedLobsterFlow>[0] {
|
||||
return {
|
||||
taskFlow,
|
||||
runner,
|
||||
flowId: "flow-1",
|
||||
expectedRevision: 4,
|
||||
runnerParams: {
|
||||
action: "resume",
|
||||
token: "resume-1",
|
||||
approve: true,
|
||||
cwd: process.cwd(),
|
||||
timeoutMs: 1000,
|
||||
maxStdoutBytes: 4096,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
describe("runManagedLobsterFlow", () => {
|
||||
it("creates a flow and finishes it when Lobster succeeds", async () => {
|
||||
const taskFlow = createFakeTaskFlow();
|
||||
const runner = createRunner({
|
||||
ok: true,
|
||||
status: "ok",
|
||||
output: [{ id: "result-1" }],
|
||||
requiresApproval: null,
|
||||
});
|
||||
|
||||
const result = await runManagedLobsterFlow(createRunFlowParams(taskFlow, runner));
|
||||
|
||||
expect(result.ok).toBe(true);
|
||||
expect(taskFlow.createManaged).toHaveBeenCalledWith({
|
||||
controllerId: "tests/lobster",
|
||||
goal: "Run Lobster workflow",
|
||||
currentStep: "run_lobster",
|
||||
});
|
||||
expect(taskFlow.finish).toHaveBeenCalledWith({
|
||||
flowId: "flow-1",
|
||||
expectedRevision: 1,
|
||||
});
|
||||
});
|
||||
|
||||
it("moves the flow to waiting when Lobster requests approval", async () => {
|
||||
const taskFlow = createFakeTaskFlow();
|
||||
const createdAt = new Date("2026-04-05T21:00:00.000Z");
|
||||
const runner = createRunner({
|
||||
ok: true,
|
||||
status: "needs_approval",
|
||||
output: [],
|
||||
requiresApproval: {
|
||||
type: "approval_request",
|
||||
prompt: "Approve this?",
|
||||
items: [{ id: "item-1", createdAt, count: 2n, skip: undefined }],
|
||||
resumeToken: "resume-1",
|
||||
},
|
||||
});
|
||||
|
||||
const result = await runManagedLobsterFlow(createRunFlowParams(taskFlow, runner));
|
||||
|
||||
expect(result.ok).toBe(true);
|
||||
expect(taskFlow.setWaiting).toHaveBeenCalledWith({
|
||||
flowId: "flow-1",
|
||||
expectedRevision: 1,
|
||||
currentStep: "await_lobster_approval",
|
||||
waitJson: {
|
||||
kind: "lobster_approval",
|
||||
prompt: "Approve this?",
|
||||
items: [{ id: "item-1", createdAt: createdAt.toISOString(), count: "2" }],
|
||||
resumeToken: "resume-1",
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it("fails the flow when Lobster returns an error envelope", async () => {
|
||||
const taskFlow = createFakeTaskFlow();
|
||||
const runner = createRunner({
|
||||
ok: false,
|
||||
error: {
|
||||
type: "runtime_error",
|
||||
message: "boom",
|
||||
},
|
||||
});
|
||||
|
||||
const result = expectManagedFlowFailure(
|
||||
await runManagedLobsterFlow(createRunFlowParams(taskFlow, runner)),
|
||||
);
|
||||
expect(result.error.message).toBe("boom");
|
||||
expect(taskFlow.fail).toHaveBeenCalledWith({
|
||||
flowId: "flow-1",
|
||||
expectedRevision: 1,
|
||||
});
|
||||
});
|
||||
|
||||
it("fails the flow when the runner throws", async () => {
|
||||
const taskFlow = createFakeTaskFlow();
|
||||
const runner: LobsterRunner = {
|
||||
run: vi.fn().mockRejectedValue(new Error("crashed")),
|
||||
};
|
||||
|
||||
const result = expectManagedFlowFailure(
|
||||
await runManagedLobsterFlow(createRunFlowParams(taskFlow, runner)),
|
||||
);
|
||||
expect(result.error.message).toBe("crashed");
|
||||
expect(taskFlow.fail).toHaveBeenCalledWith({
|
||||
flowId: "flow-1",
|
||||
expectedRevision: 1,
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("resumeManagedLobsterFlow", () => {
|
||||
it("resumes the flow and finishes it on success", async () => {
|
||||
const taskFlow = createFakeTaskFlow();
|
||||
const runner = createRunner({
|
||||
ok: true,
|
||||
status: "ok",
|
||||
output: [],
|
||||
requiresApproval: null,
|
||||
});
|
||||
|
||||
const result = await resumeManagedLobsterFlow(createResumeFlowParams(taskFlow, runner));
|
||||
|
||||
expect(result.ok).toBe(true);
|
||||
expect(taskFlow.resume).toHaveBeenCalledWith({
|
||||
flowId: "flow-1",
|
||||
expectedRevision: 4,
|
||||
status: "running",
|
||||
currentStep: "resume_lobster",
|
||||
});
|
||||
expect(taskFlow.finish).toHaveBeenCalledWith({
|
||||
flowId: "flow-1",
|
||||
expectedRevision: 5,
|
||||
});
|
||||
});
|
||||
|
||||
it("returns a mutation error when taskFlow resume is rejected", async () => {
|
||||
const taskFlow = createFakeTaskFlow({
|
||||
resume: vi.fn().mockReturnValue({
|
||||
applied: false,
|
||||
code: "revision_conflict",
|
||||
}),
|
||||
});
|
||||
const runner = createRunner({
|
||||
ok: true,
|
||||
status: "ok",
|
||||
output: [],
|
||||
requiresApproval: null,
|
||||
});
|
||||
|
||||
const result = expectManagedFlowFailure(
|
||||
await resumeManagedLobsterFlow(createResumeFlowParams(taskFlow, runner)),
|
||||
);
|
||||
expect(result.error.message).toMatch(/revision_conflict/);
|
||||
expect(runner.run).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("returns to waiting when the resumed Lobster run needs approval again", async () => {
|
||||
const taskFlow = createFakeTaskFlow();
|
||||
const runner = createRunner({
|
||||
ok: true,
|
||||
status: "needs_approval",
|
||||
output: [],
|
||||
requiresApproval: {
|
||||
type: "approval_request",
|
||||
prompt: "Approve this too?",
|
||||
items: [{ id: "item-2" }],
|
||||
resumeToken: "resume-2",
|
||||
},
|
||||
});
|
||||
|
||||
const result = await resumeManagedLobsterFlow(createResumeFlowParams(taskFlow, runner));
|
||||
|
||||
expect(result.ok).toBe(true);
|
||||
expect(taskFlow.setWaiting).toHaveBeenCalledWith({
|
||||
flowId: "flow-1",
|
||||
expectedRevision: 5,
|
||||
currentStep: "await_lobster_approval",
|
||||
waitJson: {
|
||||
kind: "lobster_approval",
|
||||
prompt: "Approve this too?",
|
||||
items: [{ id: "item-2" }],
|
||||
resumeToken: "resume-2",
|
||||
},
|
||||
});
|
||||
});
|
||||
});
|
||||
289
extensions/lobster/src/lobster-taskflow.ts
Normal file
289
extensions/lobster/src/lobster-taskflow.ts
Normal file
@@ -0,0 +1,289 @@
|
||||
// Lobster plugin module implements lobster taskflow behavior.
|
||||
import type { OpenClawPluginApi } from "../runtime-api.js";
|
||||
import type { LobsterEnvelope, LobsterRunner, LobsterRunnerParams } from "./lobster-runner.js";
|
||||
|
||||
type JsonLike =
|
||||
| null
|
||||
| boolean
|
||||
| number
|
||||
| string
|
||||
| JsonLike[]
|
||||
| {
|
||||
[key: string]: JsonLike;
|
||||
};
|
||||
|
||||
type BoundTaskFlow = ReturnType<
|
||||
NonNullable<OpenClawPluginApi["runtime"]>["tasks"]["managedFlows"]["bindSession"]
|
||||
>;
|
||||
|
||||
type FlowRecord = NonNullable<ReturnType<BoundTaskFlow["tryCreateManaged"]>>;
|
||||
type MutationResult = ReturnType<BoundTaskFlow["setWaiting"]>;
|
||||
|
||||
type LobsterApprovalWaitState = {
|
||||
kind: "lobster_approval";
|
||||
prompt: string;
|
||||
items: JsonLike[];
|
||||
resumeToken?: string;
|
||||
approvalId?: string;
|
||||
};
|
||||
|
||||
type RunManagedLobsterFlowParams = {
|
||||
taskFlow: BoundTaskFlow;
|
||||
runner: LobsterRunner;
|
||||
runnerParams: LobsterRunnerParams;
|
||||
controllerId: string;
|
||||
goal: string;
|
||||
stateJson?: JsonLike;
|
||||
currentStep?: string;
|
||||
waitingStep?: string;
|
||||
};
|
||||
|
||||
type ResumeManagedLobsterFlowParams = {
|
||||
taskFlow: BoundTaskFlow;
|
||||
runner: LobsterRunner;
|
||||
runnerParams: LobsterRunnerParams & {
|
||||
action: "resume";
|
||||
approve: boolean;
|
||||
} & ({ token: string } | { approvalId: string });
|
||||
flowId: string;
|
||||
expectedRevision: number;
|
||||
currentStep?: string;
|
||||
waitingStep?: string;
|
||||
};
|
||||
|
||||
export type ManagedLobsterFlowResult =
|
||||
| {
|
||||
ok: true;
|
||||
envelope: LobsterEnvelope;
|
||||
flow: FlowRecord;
|
||||
mutation: MutationResult;
|
||||
}
|
||||
| {
|
||||
ok: false;
|
||||
flow?: FlowRecord;
|
||||
mutation?: MutationResult;
|
||||
error: Error;
|
||||
};
|
||||
|
||||
function toJsonLike(value: unknown, seen = new WeakSet<object>()): JsonLike {
|
||||
if (value === null) {
|
||||
return null;
|
||||
}
|
||||
switch (typeof value) {
|
||||
case "boolean":
|
||||
case "string":
|
||||
return value;
|
||||
case "number":
|
||||
return Number.isFinite(value) ? value : String(value);
|
||||
case "bigint":
|
||||
return value.toString();
|
||||
case "undefined":
|
||||
case "function":
|
||||
case "symbol":
|
||||
return null;
|
||||
case "object": {
|
||||
if (value instanceof Date) {
|
||||
return value.toISOString();
|
||||
}
|
||||
if (Array.isArray(value)) {
|
||||
return value.map((item) => toJsonLike(item, seen));
|
||||
}
|
||||
if (seen.has(value)) {
|
||||
return "[Circular]";
|
||||
}
|
||||
seen.add(value);
|
||||
const jsonObject: Record<string, JsonLike> = {};
|
||||
for (const [key, entry] of Object.entries(value)) {
|
||||
if (entry === undefined || typeof entry === "function" || typeof entry === "symbol") {
|
||||
continue;
|
||||
}
|
||||
jsonObject[key] = toJsonLike(entry, seen);
|
||||
}
|
||||
seen.delete(value);
|
||||
return jsonObject;
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function buildApprovalWaitState(envelope: Extract<LobsterEnvelope, { ok: true }>): JsonLike {
|
||||
if (!envelope.requiresApproval) {
|
||||
return {
|
||||
kind: "lobster_approval",
|
||||
prompt: "",
|
||||
items: [],
|
||||
} satisfies LobsterApprovalWaitState;
|
||||
}
|
||||
return {
|
||||
kind: "lobster_approval",
|
||||
prompt: envelope.requiresApproval.prompt,
|
||||
items: envelope.requiresApproval.items.map((item) => toJsonLike(item)),
|
||||
...(envelope.requiresApproval.resumeToken
|
||||
? { resumeToken: envelope.requiresApproval.resumeToken }
|
||||
: {}),
|
||||
...(envelope.requiresApproval.approvalId
|
||||
? { approvalId: envelope.requiresApproval.approvalId }
|
||||
: {}),
|
||||
} satisfies LobsterApprovalWaitState;
|
||||
}
|
||||
|
||||
function applyEnvelopeToFlow(params: {
|
||||
taskFlow: BoundTaskFlow;
|
||||
flow: FlowRecord;
|
||||
envelope: LobsterEnvelope;
|
||||
waitingStep: string;
|
||||
}): MutationResult {
|
||||
const { taskFlow, flow, envelope, waitingStep } = params;
|
||||
|
||||
if (!envelope.ok) {
|
||||
return taskFlow.fail({
|
||||
flowId: flow.flowId,
|
||||
expectedRevision: flow.revision,
|
||||
});
|
||||
}
|
||||
|
||||
if (envelope.status === "needs_approval") {
|
||||
return taskFlow.setWaiting({
|
||||
flowId: flow.flowId,
|
||||
expectedRevision: flow.revision,
|
||||
currentStep: waitingStep,
|
||||
waitJson: buildApprovalWaitState(envelope),
|
||||
});
|
||||
}
|
||||
|
||||
return taskFlow.finish({
|
||||
flowId: flow.flowId,
|
||||
expectedRevision: flow.revision,
|
||||
});
|
||||
}
|
||||
|
||||
function buildEnvelopeError(envelope: Extract<LobsterEnvelope, { ok: false }>) {
|
||||
return new Error(envelope.error.message);
|
||||
}
|
||||
|
||||
export async function runManagedLobsterFlow(
|
||||
params: RunManagedLobsterFlowParams,
|
||||
): Promise<ManagedLobsterFlowResult> {
|
||||
const createFlowParams = {
|
||||
controllerId: params.controllerId,
|
||||
goal: params.goal,
|
||||
currentStep: params.currentStep ?? "run_lobster",
|
||||
...(params.stateJson !== undefined ? { stateJson: params.stateJson } : {}),
|
||||
};
|
||||
const flow = params.taskFlow.tryCreateManaged
|
||||
? params.taskFlow.tryCreateManaged(createFlowParams)
|
||||
: params.taskFlow.createManaged(createFlowParams);
|
||||
if (!flow) {
|
||||
return {
|
||||
ok: false,
|
||||
error: new Error("TaskFlow persistence failed."),
|
||||
};
|
||||
}
|
||||
|
||||
try {
|
||||
const envelope = await params.runner.run(params.runnerParams);
|
||||
const mutation = applyEnvelopeToFlow({
|
||||
taskFlow: params.taskFlow,
|
||||
flow,
|
||||
envelope,
|
||||
waitingStep: params.waitingStep ?? "await_lobster_approval",
|
||||
});
|
||||
if (!envelope.ok) {
|
||||
return {
|
||||
ok: false,
|
||||
flow,
|
||||
mutation,
|
||||
error: buildEnvelopeError(envelope),
|
||||
};
|
||||
}
|
||||
return {
|
||||
ok: true,
|
||||
envelope,
|
||||
flow,
|
||||
mutation,
|
||||
};
|
||||
} catch (error) {
|
||||
const err = error instanceof Error ? error : new Error(String(error));
|
||||
try {
|
||||
const mutation = params.taskFlow.fail({
|
||||
flowId: flow.flowId,
|
||||
expectedRevision: flow.revision,
|
||||
});
|
||||
return {
|
||||
ok: false,
|
||||
flow,
|
||||
mutation,
|
||||
error: err,
|
||||
};
|
||||
} catch {
|
||||
return {
|
||||
ok: false,
|
||||
flow,
|
||||
error: err,
|
||||
};
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export async function resumeManagedLobsterFlow(
|
||||
params: ResumeManagedLobsterFlowParams,
|
||||
): Promise<ManagedLobsterFlowResult> {
|
||||
const resumed = params.taskFlow.resume({
|
||||
flowId: params.flowId,
|
||||
expectedRevision: params.expectedRevision,
|
||||
status: "running",
|
||||
currentStep: params.currentStep ?? "resume_lobster",
|
||||
});
|
||||
|
||||
if (!resumed.applied) {
|
||||
return {
|
||||
ok: false,
|
||||
mutation: resumed,
|
||||
error: new Error(`TaskFlow resume failed: ${resumed.code}`),
|
||||
};
|
||||
}
|
||||
|
||||
try {
|
||||
const envelope = await params.runner.run(params.runnerParams);
|
||||
const mutation = applyEnvelopeToFlow({
|
||||
taskFlow: params.taskFlow,
|
||||
flow: resumed.flow,
|
||||
envelope,
|
||||
waitingStep: params.waitingStep ?? "await_lobster_approval",
|
||||
});
|
||||
if (!envelope.ok) {
|
||||
return {
|
||||
ok: false,
|
||||
flow: resumed.flow,
|
||||
mutation,
|
||||
error: buildEnvelopeError(envelope),
|
||||
};
|
||||
}
|
||||
return {
|
||||
ok: true,
|
||||
envelope,
|
||||
flow: resumed.flow,
|
||||
mutation,
|
||||
};
|
||||
} catch (error) {
|
||||
const err = error instanceof Error ? error : new Error(String(error));
|
||||
try {
|
||||
const mutation = params.taskFlow.fail({
|
||||
flowId: params.flowId,
|
||||
expectedRevision: resumed.flow.revision,
|
||||
});
|
||||
return {
|
||||
ok: false,
|
||||
flow: resumed.flow,
|
||||
mutation,
|
||||
error: err,
|
||||
};
|
||||
} catch {
|
||||
return {
|
||||
ok: false,
|
||||
flow: resumed.flow,
|
||||
error: err,
|
||||
};
|
||||
}
|
||||
}
|
||||
}
|
||||
430
extensions/lobster/src/lobster-tool.test.ts
Normal file
430
extensions/lobster/src/lobster-tool.test.ts
Normal file
@@ -0,0 +1,430 @@
|
||||
// Lobster tests cover lobster tool plugin behavior.
|
||||
import { createTestPluginApi } from "openclaw/plugin-sdk/plugin-test-api";
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import type { OpenClawPluginApi, OpenClawPluginToolContext } from "../runtime-api.js";
|
||||
import { createLobsterTool } from "./lobster-tool.js";
|
||||
import { createFakeTaskFlow } from "./taskflow-test-helpers.js";
|
||||
|
||||
function fakeApi(overrides: Partial<OpenClawPluginApi> = {}): OpenClawPluginApi {
|
||||
return createTestPluginApi({
|
||||
id: "lobster",
|
||||
name: "lobster",
|
||||
source: "test",
|
||||
runtime: { version: "test" } as any,
|
||||
resolvePath: (p) => p,
|
||||
...overrides,
|
||||
});
|
||||
}
|
||||
|
||||
function fakeCtx(overrides: Partial<OpenClawPluginToolContext> = {}): OpenClawPluginToolContext {
|
||||
return {
|
||||
config: {},
|
||||
workspaceDir: "/tmp",
|
||||
agentDir: "/tmp",
|
||||
agentId: "main",
|
||||
sessionKey: "main",
|
||||
messageChannel: undefined,
|
||||
agentAccountId: undefined,
|
||||
sandboxed: false,
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
function requireRecord(value: unknown, label: string): Record<string, unknown> {
|
||||
if (value === null || typeof value !== "object" || Array.isArray(value)) {
|
||||
throw new Error(`expected ${label} to be a record`);
|
||||
}
|
||||
return value as Record<string, unknown>;
|
||||
}
|
||||
|
||||
describe("lobster plugin tool", () => {
|
||||
it("returns the Lobster envelope in details", async () => {
|
||||
const runner = {
|
||||
run: vi.fn().mockResolvedValue({
|
||||
ok: true,
|
||||
status: "ok",
|
||||
output: [{ hello: "world" }],
|
||||
requiresApproval: null,
|
||||
}),
|
||||
};
|
||||
|
||||
const tool = createLobsterTool(fakeApi(), { runner });
|
||||
const res = await tool.execute("call1", {
|
||||
action: "run",
|
||||
pipeline: "noop",
|
||||
timeoutMs: 1000,
|
||||
});
|
||||
|
||||
expect(runner.run).toHaveBeenCalledWith({
|
||||
action: "run",
|
||||
pipeline: "noop",
|
||||
cwd: process.cwd(),
|
||||
timeoutMs: 1000,
|
||||
maxStdoutBytes: 512_000,
|
||||
});
|
||||
const details = requireRecord(res.details, "lobster tool details");
|
||||
expect(details.ok).toBe(true);
|
||||
expect(details.status).toBe("ok");
|
||||
expect(details.output).toEqual([{ hello: "world" }]);
|
||||
expect(details.requiresApproval).toBeNull();
|
||||
});
|
||||
|
||||
it("supports approval envelopes without changing the tool contract", async () => {
|
||||
const runner = {
|
||||
run: vi.fn().mockResolvedValue({
|
||||
ok: true,
|
||||
status: "needs_approval",
|
||||
output: [],
|
||||
requiresApproval: {
|
||||
type: "approval_request",
|
||||
prompt: "Send these alerts?",
|
||||
items: [{ id: "alert-1" }],
|
||||
resumeToken: "resume-token-1",
|
||||
},
|
||||
}),
|
||||
};
|
||||
|
||||
const tool = createLobsterTool(fakeApi(), { runner });
|
||||
const res = await tool.execute("call-injected-runner", {
|
||||
action: "run",
|
||||
pipeline: "noop",
|
||||
argsJson: '{"since_hours":1}',
|
||||
timeoutMs: 1500,
|
||||
maxStdoutBytes: 4096,
|
||||
});
|
||||
|
||||
expect(runner.run).toHaveBeenCalledWith({
|
||||
action: "run",
|
||||
pipeline: "noop",
|
||||
argsJson: '{"since_hours":1}',
|
||||
cwd: process.cwd(),
|
||||
timeoutMs: 1500,
|
||||
maxStdoutBytes: 4096,
|
||||
});
|
||||
const details = requireRecord(res.details, "approval lobster tool details");
|
||||
expect(details.ok).toBe(true);
|
||||
expect(details.status).toBe("needs_approval");
|
||||
const approval = requireRecord(details.requiresApproval, "approval request");
|
||||
expect(approval.type).toBe("approval_request");
|
||||
expect(approval.prompt).toBe("Send these alerts?");
|
||||
expect(approval.resumeToken).toBe("resume-token-1");
|
||||
});
|
||||
|
||||
it("normalizes numeric string run limits before invoking the runner", async () => {
|
||||
const runner = {
|
||||
run: vi.fn().mockResolvedValue({
|
||||
ok: true,
|
||||
status: "ok",
|
||||
output: [],
|
||||
requiresApproval: null,
|
||||
}),
|
||||
};
|
||||
|
||||
const tool = createLobsterTool(fakeApi(), { runner });
|
||||
await tool.execute("call-string-limits", {
|
||||
action: "run",
|
||||
pipeline: "noop",
|
||||
timeoutMs: "1500",
|
||||
maxStdoutBytes: "4096",
|
||||
});
|
||||
|
||||
expect(runner.run).toHaveBeenCalledWith({
|
||||
action: "run",
|
||||
pipeline: "noop",
|
||||
cwd: process.cwd(),
|
||||
timeoutMs: 1500,
|
||||
maxStdoutBytes: 4096,
|
||||
});
|
||||
});
|
||||
|
||||
it("rejects malformed numeric run limits before invoking the runner", async () => {
|
||||
const runner = { run: vi.fn() };
|
||||
const tool = createLobsterTool(fakeApi(), { runner });
|
||||
|
||||
await expect(
|
||||
tool.execute("call-bad-timeout", {
|
||||
action: "run",
|
||||
pipeline: "noop",
|
||||
timeoutMs: "1500.5",
|
||||
}),
|
||||
).rejects.toThrow("timeoutMs must be a positive integer");
|
||||
await expect(
|
||||
tool.execute("call-bad-stdout", {
|
||||
action: "run",
|
||||
pipeline: "noop",
|
||||
maxStdoutBytes: 0,
|
||||
}),
|
||||
).rejects.toThrow("maxStdoutBytes must be a positive integer");
|
||||
expect(runner.run).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("throws when the runner returns an error envelope", async () => {
|
||||
const tool = createLobsterTool(fakeApi(), {
|
||||
runner: {
|
||||
run: vi.fn().mockResolvedValue({
|
||||
ok: false,
|
||||
error: {
|
||||
type: "runtime_error",
|
||||
message: "boom",
|
||||
},
|
||||
}),
|
||||
},
|
||||
});
|
||||
|
||||
await expect(
|
||||
tool.execute("call-runner-error", {
|
||||
action: "run",
|
||||
pipeline: "noop",
|
||||
}),
|
||||
).rejects.toThrow("boom");
|
||||
});
|
||||
|
||||
it("can run through managed TaskFlow mode", async () => {
|
||||
const runner = {
|
||||
run: vi.fn().mockResolvedValue({
|
||||
ok: true,
|
||||
status: "needs_approval",
|
||||
output: [],
|
||||
requiresApproval: {
|
||||
type: "approval_request",
|
||||
prompt: "Approve this?",
|
||||
items: [{ id: "item-1" }],
|
||||
resumeToken: "resume-1",
|
||||
approvalId: "approval-1",
|
||||
},
|
||||
}),
|
||||
};
|
||||
const taskFlow = createFakeTaskFlow();
|
||||
|
||||
const tool = createLobsterTool(fakeApi(), { runner, taskFlow });
|
||||
const res = await tool.execute("call-managed-run", {
|
||||
action: "run",
|
||||
pipeline: "noop",
|
||||
flowControllerId: "tests/lobster",
|
||||
flowGoal: "Run Lobster workflow",
|
||||
flowStateJson: '{"lane":"email"}',
|
||||
flowCurrentStep: "run_lobster",
|
||||
flowWaitingStep: "await_review",
|
||||
});
|
||||
|
||||
expect(taskFlow.createManaged).toHaveBeenCalledWith({
|
||||
controllerId: "tests/lobster",
|
||||
goal: "Run Lobster workflow",
|
||||
currentStep: "run_lobster",
|
||||
stateJson: { lane: "email" },
|
||||
});
|
||||
expect(taskFlow.setWaiting).toHaveBeenCalledWith({
|
||||
flowId: "flow-1",
|
||||
expectedRevision: 1,
|
||||
currentStep: "await_review",
|
||||
waitJson: {
|
||||
kind: "lobster_approval",
|
||||
prompt: "Approve this?",
|
||||
items: [{ id: "item-1" }],
|
||||
resumeToken: "resume-1",
|
||||
approvalId: "approval-1",
|
||||
},
|
||||
});
|
||||
const details = requireRecord(res.details, "managed run lobster tool details");
|
||||
expect(details.ok).toBe(true);
|
||||
expect(details.status).toBe("needs_approval");
|
||||
const flow = requireRecord(details.flow, "managed run flow details");
|
||||
expect(flow.flowId).toBe("flow-1");
|
||||
const mutation = requireRecord(details.mutation, "managed run mutation details");
|
||||
expect(mutation.applied).toBe(true);
|
||||
});
|
||||
|
||||
it("rejects managed TaskFlow params when no bound taskFlow runtime is available", async () => {
|
||||
const tool = createLobsterTool(fakeApi(), {
|
||||
runner: { run: vi.fn() },
|
||||
});
|
||||
|
||||
await expect(
|
||||
tool.execute("call-missing-taskflow", {
|
||||
action: "run",
|
||||
pipeline: "noop",
|
||||
flowControllerId: "tests/lobster",
|
||||
flowGoal: "Run Lobster workflow",
|
||||
}),
|
||||
).rejects.toThrow(/Managed TaskFlow run mode requires a bound taskFlow runtime/);
|
||||
});
|
||||
|
||||
it("rejects invalid flowStateJson in managed TaskFlow mode", async () => {
|
||||
const tool = createLobsterTool(fakeApi(), {
|
||||
runner: { run: vi.fn() },
|
||||
taskFlow: createFakeTaskFlow(),
|
||||
});
|
||||
|
||||
await expect(
|
||||
tool.execute("call-invalid-flow-json", {
|
||||
action: "run",
|
||||
pipeline: "noop",
|
||||
flowControllerId: "tests/lobster",
|
||||
flowGoal: "Run Lobster workflow",
|
||||
flowStateJson: "{bad",
|
||||
}),
|
||||
).rejects.toThrow(/flowStateJson must be valid JSON/);
|
||||
});
|
||||
|
||||
it("can resume managed TaskFlow mode with only approvalId", async () => {
|
||||
const runner = {
|
||||
run: vi.fn().mockResolvedValue({
|
||||
ok: true,
|
||||
status: "ok",
|
||||
output: [],
|
||||
requiresApproval: null,
|
||||
}),
|
||||
};
|
||||
const taskFlow = createFakeTaskFlow();
|
||||
const tool = createLobsterTool(fakeApi(), { runner, taskFlow });
|
||||
|
||||
const res = await tool.execute("call-managed-resume-approval-id", {
|
||||
action: "resume",
|
||||
approvalId: "approval-1",
|
||||
approve: true,
|
||||
flowId: "flow-1",
|
||||
flowExpectedRevision: 1,
|
||||
flowCurrentStep: "resume_lobster",
|
||||
});
|
||||
|
||||
expect(taskFlow.resume).toHaveBeenCalledWith({
|
||||
flowId: "flow-1",
|
||||
expectedRevision: 1,
|
||||
status: "running",
|
||||
currentStep: "resume_lobster",
|
||||
});
|
||||
expect(runner.run).toHaveBeenCalledWith({
|
||||
action: "resume",
|
||||
approvalId: "approval-1",
|
||||
approve: true,
|
||||
cwd: process.cwd(),
|
||||
timeoutMs: 20_000,
|
||||
maxStdoutBytes: 512_000,
|
||||
});
|
||||
const details = requireRecord(res.details, "managed resume lobster tool details");
|
||||
expect(details.ok).toBe(true);
|
||||
expect(details.status).toBe("ok");
|
||||
const mutation = requireRecord(details.mutation, "managed resume mutation details");
|
||||
expect(mutation.applied).toBe(true);
|
||||
});
|
||||
|
||||
it("normalizes numeric string flowExpectedRevision before managed resume", async () => {
|
||||
const runner = {
|
||||
run: vi.fn().mockResolvedValue({
|
||||
ok: true,
|
||||
status: "ok",
|
||||
output: [],
|
||||
requiresApproval: null,
|
||||
}),
|
||||
};
|
||||
const taskFlow = createFakeTaskFlow();
|
||||
const tool = createLobsterTool(fakeApi(), { runner, taskFlow });
|
||||
|
||||
await tool.execute("call-managed-resume-string-revision", {
|
||||
action: "resume",
|
||||
approvalId: "approval-1",
|
||||
approve: true,
|
||||
flowId: "flow-1",
|
||||
flowExpectedRevision: "1",
|
||||
flowCurrentStep: "resume_lobster",
|
||||
});
|
||||
|
||||
expect(taskFlow.resume).toHaveBeenCalledWith({
|
||||
flowId: "flow-1",
|
||||
expectedRevision: 1,
|
||||
status: "running",
|
||||
currentStep: "resume_lobster",
|
||||
});
|
||||
});
|
||||
|
||||
it("rejects managed TaskFlow resume mode without a token or approvalId", async () => {
|
||||
const tool = createLobsterTool(fakeApi(), {
|
||||
runner: { run: vi.fn() },
|
||||
taskFlow: createFakeTaskFlow(),
|
||||
});
|
||||
|
||||
await expect(
|
||||
tool.execute("call-missing-resume-token", {
|
||||
action: "resume",
|
||||
flowId: "flow-1",
|
||||
flowExpectedRevision: 1,
|
||||
approve: true,
|
||||
}),
|
||||
).rejects.toThrow(/token or approvalId required when using managed TaskFlow resume mode/);
|
||||
});
|
||||
|
||||
it("rejects managed TaskFlow resume mode without approve", async () => {
|
||||
const tool = createLobsterTool(fakeApi(), {
|
||||
runner: { run: vi.fn() },
|
||||
taskFlow: createFakeTaskFlow(),
|
||||
});
|
||||
|
||||
await expect(
|
||||
tool.execute("call-missing-resume-approve", {
|
||||
action: "resume",
|
||||
token: "resume-token",
|
||||
flowId: "flow-1",
|
||||
flowExpectedRevision: 1,
|
||||
}),
|
||||
).rejects.toThrow(/approve required when using managed TaskFlow resume mode/);
|
||||
});
|
||||
|
||||
it("requires action", async () => {
|
||||
const tool = createLobsterTool(fakeApi(), {
|
||||
runner: { run: vi.fn() },
|
||||
});
|
||||
await expect(tool.execute("call-action-missing", {})).rejects.toThrow(/action required/);
|
||||
});
|
||||
|
||||
it("rejects unknown action", async () => {
|
||||
const tool = createLobsterTool(fakeApi(), {
|
||||
runner: { run: vi.fn() },
|
||||
});
|
||||
await expect(
|
||||
tool.execute("call-action-unknown", {
|
||||
action: "explode",
|
||||
}),
|
||||
).rejects.toThrow(/Unknown action/);
|
||||
});
|
||||
|
||||
it("rejects absolute cwd", async () => {
|
||||
const tool = createLobsterTool(fakeApi(), {
|
||||
runner: { run: vi.fn() },
|
||||
});
|
||||
await expect(
|
||||
tool.execute("call-absolute-cwd", {
|
||||
action: "run",
|
||||
pipeline: "noop",
|
||||
cwd: "/tmp",
|
||||
}),
|
||||
).rejects.toThrow(/cwd must be a relative path/);
|
||||
});
|
||||
|
||||
it("rejects cwd that escapes the gateway working directory", async () => {
|
||||
const tool = createLobsterTool(fakeApi(), {
|
||||
runner: { run: vi.fn() },
|
||||
});
|
||||
await expect(
|
||||
tool.execute("call-escape-cwd", {
|
||||
action: "run",
|
||||
pipeline: "noop",
|
||||
cwd: "../../etc",
|
||||
}),
|
||||
).rejects.toThrow(/must stay within/);
|
||||
});
|
||||
|
||||
it("can be gated off in sandboxed contexts", () => {
|
||||
const api = fakeApi();
|
||||
const factoryTool = (ctx: OpenClawPluginToolContext) => {
|
||||
if (ctx.sandboxed) {
|
||||
return null;
|
||||
}
|
||||
return createLobsterTool(api, {
|
||||
runner: { run: vi.fn() },
|
||||
});
|
||||
};
|
||||
|
||||
expect(factoryTool(fakeCtx({ sandboxed: true }))).toBeNull();
|
||||
expect(factoryTool(fakeCtx({ sandboxed: false }))?.name).toBe("lobster");
|
||||
});
|
||||
});
|
||||
319
extensions/lobster/src/lobster-tool.ts
Normal file
319
extensions/lobster/src/lobster-tool.ts
Normal file
@@ -0,0 +1,319 @@
|
||||
// Lobster plugin module implements lobster tool behavior.
|
||||
import {
|
||||
optionalNonNegativeIntegerSchema,
|
||||
optionalPositiveIntegerSchema,
|
||||
} from "openclaw/plugin-sdk/channel-actions";
|
||||
import {
|
||||
readNonNegativeIntegerParam,
|
||||
readPositiveIntegerParam,
|
||||
} from "openclaw/plugin-sdk/param-readers";
|
||||
import { jsonResult } from "openclaw/plugin-sdk/tool-results";
|
||||
import { Type } from "typebox";
|
||||
import type { OpenClawPluginApi } from "../runtime-api.js";
|
||||
import {
|
||||
createEmbeddedLobsterRunner,
|
||||
resolveLobsterCwd,
|
||||
type LobsterRunner,
|
||||
type LobsterRunnerParams,
|
||||
} from "./lobster-runner.js";
|
||||
import {
|
||||
type ManagedLobsterFlowResult,
|
||||
resumeManagedLobsterFlow,
|
||||
runManagedLobsterFlow,
|
||||
} from "./lobster-taskflow.js";
|
||||
|
||||
type BoundTaskFlow = ReturnType<
|
||||
NonNullable<OpenClawPluginApi["runtime"]>["tasks"]["managedFlows"]["bindSession"]
|
||||
>;
|
||||
|
||||
type JsonLike =
|
||||
| null
|
||||
| boolean
|
||||
| number
|
||||
| string
|
||||
| JsonLike[]
|
||||
| {
|
||||
[key: string]: JsonLike;
|
||||
};
|
||||
|
||||
type LobsterToolOptions = {
|
||||
runner?: LobsterRunner;
|
||||
taskFlow?: BoundTaskFlow;
|
||||
};
|
||||
|
||||
type ManagedFlowRunParams = {
|
||||
controllerId: string;
|
||||
goal: string;
|
||||
currentStep?: string;
|
||||
waitingStep?: string;
|
||||
stateJson?: JsonLike;
|
||||
};
|
||||
|
||||
type ManagedFlowResumeParams = {
|
||||
flowId: string;
|
||||
expectedRevision: number;
|
||||
currentStep?: string;
|
||||
waitingStep?: string;
|
||||
};
|
||||
|
||||
type ManagedFlowSuccessResult = {
|
||||
ok: true;
|
||||
envelope: unknown;
|
||||
flow: unknown;
|
||||
mutation: unknown;
|
||||
};
|
||||
|
||||
function readOptionalTrimmedString(value: unknown, fieldName: string): string | undefined {
|
||||
if (value === undefined) {
|
||||
return undefined;
|
||||
}
|
||||
if (typeof value !== "string") {
|
||||
throw new Error(`${fieldName} must be a string`);
|
||||
}
|
||||
const trimmed = value.trim();
|
||||
return trimmed ? trimmed : undefined;
|
||||
}
|
||||
|
||||
function readOptionalNumber(value: unknown, fieldName: string): number | undefined {
|
||||
return readNonNegativeIntegerParam({ [fieldName]: value }, fieldName, {
|
||||
message: `${fieldName} must be a non-negative integer`,
|
||||
});
|
||||
}
|
||||
|
||||
function readOptionalBoolean(value: unknown, fieldName: string): boolean | undefined {
|
||||
if (value === undefined) {
|
||||
return undefined;
|
||||
}
|
||||
if (typeof value !== "boolean") {
|
||||
throw new Error(`${fieldName} must be a boolean`);
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
function parseOptionalFlowStateJson(value: unknown): JsonLike | undefined {
|
||||
if (value === undefined) {
|
||||
return undefined;
|
||||
}
|
||||
if (typeof value !== "string") {
|
||||
throw new Error("flowStateJson must be a JSON string");
|
||||
}
|
||||
try {
|
||||
return JSON.parse(value) as JsonLike;
|
||||
} catch {
|
||||
throw new Error("flowStateJson must be valid JSON");
|
||||
}
|
||||
}
|
||||
|
||||
function parseRunFlowParams(params: Record<string, unknown>): ManagedFlowRunParams | null {
|
||||
const controllerId = readOptionalTrimmedString(params.flowControllerId, "flowControllerId");
|
||||
const goal = readOptionalTrimmedString(params.flowGoal, "flowGoal");
|
||||
const currentStep = readOptionalTrimmedString(params.flowCurrentStep, "flowCurrentStep");
|
||||
const waitingStep = readOptionalTrimmedString(params.flowWaitingStep, "flowWaitingStep");
|
||||
const stateJson = parseOptionalFlowStateJson(params.flowStateJson);
|
||||
const resumeFlowId = readOptionalTrimmedString(params.flowId, "flowId");
|
||||
const resumeRevision = readOptionalNumber(params.flowExpectedRevision, "flowExpectedRevision");
|
||||
|
||||
const hasRunFields =
|
||||
controllerId !== undefined ||
|
||||
goal !== undefined ||
|
||||
currentStep !== undefined ||
|
||||
waitingStep !== undefined ||
|
||||
stateJson !== undefined;
|
||||
|
||||
if (!hasRunFields) {
|
||||
return null;
|
||||
}
|
||||
if (resumeFlowId !== undefined || resumeRevision !== undefined) {
|
||||
throw new Error("run action does not accept flowId or flowExpectedRevision");
|
||||
}
|
||||
if (!controllerId) {
|
||||
throw new Error("flowControllerId required when using managed TaskFlow run mode");
|
||||
}
|
||||
if (!goal) {
|
||||
throw new Error("flowGoal required when using managed TaskFlow run mode");
|
||||
}
|
||||
return {
|
||||
controllerId,
|
||||
goal,
|
||||
...(currentStep ? { currentStep } : {}),
|
||||
...(waitingStep ? { waitingStep } : {}),
|
||||
...(stateJson !== undefined ? { stateJson } : {}),
|
||||
};
|
||||
}
|
||||
|
||||
function parseResumeFlowParams(params: Record<string, unknown>): ManagedFlowResumeParams | null {
|
||||
const flowId = readOptionalTrimmedString(params.flowId, "flowId");
|
||||
const expectedRevision = readOptionalNumber(params.flowExpectedRevision, "flowExpectedRevision");
|
||||
const currentStep = readOptionalTrimmedString(params.flowCurrentStep, "flowCurrentStep");
|
||||
const waitingStep = readOptionalTrimmedString(params.flowWaitingStep, "flowWaitingStep");
|
||||
const token = readOptionalTrimmedString(params.token, "token");
|
||||
const approvalId = readOptionalTrimmedString(params.approvalId, "approvalId");
|
||||
const approve = readOptionalBoolean(params.approve, "approve");
|
||||
const runControllerId = readOptionalTrimmedString(params.flowControllerId, "flowControllerId");
|
||||
const runGoal = readOptionalTrimmedString(params.flowGoal, "flowGoal");
|
||||
const stateJson = params.flowStateJson;
|
||||
|
||||
const hasResumeFields =
|
||||
flowId !== undefined ||
|
||||
expectedRevision !== undefined ||
|
||||
currentStep !== undefined ||
|
||||
waitingStep !== undefined;
|
||||
|
||||
if (!hasResumeFields) {
|
||||
return null;
|
||||
}
|
||||
if (runControllerId !== undefined || runGoal !== undefined || stateJson !== undefined) {
|
||||
throw new Error("resume action does not accept flowControllerId, flowGoal, or flowStateJson");
|
||||
}
|
||||
if (!flowId) {
|
||||
throw new Error("flowId required when using managed TaskFlow resume mode");
|
||||
}
|
||||
if (expectedRevision === undefined) {
|
||||
throw new Error("flowExpectedRevision required when using managed TaskFlow resume mode");
|
||||
}
|
||||
if (!token && !approvalId) {
|
||||
throw new Error("token or approvalId required when using managed TaskFlow resume mode");
|
||||
}
|
||||
if (approve === undefined) {
|
||||
throw new Error("approve required when using managed TaskFlow resume mode");
|
||||
}
|
||||
return {
|
||||
flowId,
|
||||
expectedRevision,
|
||||
...(currentStep ? { currentStep } : {}),
|
||||
...(waitingStep ? { waitingStep } : {}),
|
||||
};
|
||||
}
|
||||
|
||||
function formatManagedFlowResult(result: ManagedFlowSuccessResult) {
|
||||
const envelope =
|
||||
result.envelope && typeof result.envelope === "object" && !Array.isArray(result.envelope)
|
||||
? result.envelope
|
||||
: { envelope: result.envelope };
|
||||
const details = {
|
||||
...envelope,
|
||||
flow: result.flow,
|
||||
mutation: result.mutation,
|
||||
};
|
||||
return jsonResult(details);
|
||||
}
|
||||
|
||||
function requireTaskFlowRuntime(taskFlow: BoundTaskFlow | undefined, action: "run" | "resume") {
|
||||
if (!taskFlow) {
|
||||
throw new Error(`Managed TaskFlow ${action} mode requires a bound taskFlow runtime`);
|
||||
}
|
||||
return taskFlow;
|
||||
}
|
||||
|
||||
function resolveManagedFlowToolResult(result: ManagedLobsterFlowResult) {
|
||||
if (!result.ok) {
|
||||
throw result.error;
|
||||
}
|
||||
return formatManagedFlowResult(result);
|
||||
}
|
||||
|
||||
export function createLobsterTool(api: OpenClawPluginApi, options?: LobsterToolOptions) {
|
||||
const runner = options?.runner ?? createEmbeddedLobsterRunner();
|
||||
return {
|
||||
name: "lobster",
|
||||
label: "Lobster Workflow",
|
||||
description:
|
||||
"Run Lobster pipelines as a local-first workflow runtime (typed JSON envelope + resumable approvals).",
|
||||
parameters: Type.Object({
|
||||
// NOTE: Prefer string enums in tool schemas; some providers reject unions/anyOf.
|
||||
action: Type.Unsafe<"run" | "resume">({ type: "string", enum: ["run", "resume"] }),
|
||||
pipeline: Type.Optional(Type.String()),
|
||||
argsJson: Type.Optional(Type.String()),
|
||||
token: Type.Optional(Type.String()),
|
||||
approvalId: Type.Optional(Type.String()),
|
||||
approve: Type.Optional(Type.Boolean()),
|
||||
cwd: Type.Optional(
|
||||
Type.String({
|
||||
description:
|
||||
"Relative working directory (optional). Must stay within the gateway working directory.",
|
||||
}),
|
||||
),
|
||||
timeoutMs: optionalPositiveIntegerSchema(),
|
||||
maxStdoutBytes: optionalPositiveIntegerSchema(),
|
||||
flowControllerId: Type.Optional(Type.String()),
|
||||
flowGoal: Type.Optional(Type.String()),
|
||||
flowStateJson: Type.Optional(Type.String()),
|
||||
flowId: Type.Optional(Type.String()),
|
||||
flowExpectedRevision: optionalNonNegativeIntegerSchema(),
|
||||
flowCurrentStep: Type.Optional(Type.String()),
|
||||
flowWaitingStep: Type.Optional(Type.String()),
|
||||
}),
|
||||
async execute(_id: string, params: Record<string, unknown>) {
|
||||
const action = typeof params.action === "string" ? params.action.trim() : "";
|
||||
if (!action) {
|
||||
throw new Error("action required");
|
||||
}
|
||||
if (action !== "run" && action !== "resume") {
|
||||
throw new Error(`Unknown action: ${action}`);
|
||||
}
|
||||
|
||||
const cwd = resolveLobsterCwd(params.cwd);
|
||||
const timeoutMs = readPositiveIntegerParam(params, "timeoutMs") ?? 20_000;
|
||||
const maxStdoutBytes = readPositiveIntegerParam(params, "maxStdoutBytes") ?? 512_000;
|
||||
|
||||
if (api.runtime?.version && api.logger?.debug) {
|
||||
api.logger.debug(`lobster plugin runtime=${api.runtime.version}`);
|
||||
}
|
||||
|
||||
const runnerParams: LobsterRunnerParams = {
|
||||
action,
|
||||
...(typeof params.pipeline === "string" ? { pipeline: params.pipeline } : {}),
|
||||
...(typeof params.argsJson === "string" ? { argsJson: params.argsJson } : {}),
|
||||
...(typeof params.token === "string" ? { token: params.token } : {}),
|
||||
...(typeof params.approvalId === "string" ? { approvalId: params.approvalId } : {}),
|
||||
...(typeof params.approve === "boolean" ? { approve: params.approve } : {}),
|
||||
cwd,
|
||||
timeoutMs,
|
||||
maxStdoutBytes,
|
||||
};
|
||||
|
||||
const taskFlow = options?.taskFlow;
|
||||
if (action === "run") {
|
||||
const flowParams = parseRunFlowParams(params);
|
||||
if (flowParams) {
|
||||
return resolveManagedFlowToolResult(
|
||||
await runManagedLobsterFlow({
|
||||
taskFlow: requireTaskFlowRuntime(taskFlow, "run"),
|
||||
runner,
|
||||
runnerParams,
|
||||
controllerId: flowParams.controllerId,
|
||||
goal: flowParams.goal,
|
||||
...(flowParams.stateJson !== undefined ? { stateJson: flowParams.stateJson } : {}),
|
||||
...(flowParams.currentStep ? { currentStep: flowParams.currentStep } : {}),
|
||||
...(flowParams.waitingStep ? { waitingStep: flowParams.waitingStep } : {}),
|
||||
}),
|
||||
);
|
||||
}
|
||||
} else {
|
||||
const flowParams = parseResumeFlowParams(params);
|
||||
if (flowParams) {
|
||||
return resolveManagedFlowToolResult(
|
||||
await resumeManagedLobsterFlow({
|
||||
taskFlow: requireTaskFlowRuntime(taskFlow, "resume"),
|
||||
runner,
|
||||
runnerParams: runnerParams as LobsterRunnerParams & {
|
||||
action: "resume";
|
||||
approve: boolean;
|
||||
} & ({ token: string } | { approvalId: string }),
|
||||
flowId: flowParams.flowId,
|
||||
expectedRevision: flowParams.expectedRevision,
|
||||
...(flowParams.currentStep ? { currentStep: flowParams.currentStep } : {}),
|
||||
...(flowParams.waitingStep ? { waitingStep: flowParams.waitingStep } : {}),
|
||||
}),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
const envelope = await runner.run(runnerParams);
|
||||
if (!envelope.ok) {
|
||||
throw new Error(envelope.error.message);
|
||||
}
|
||||
return jsonResult(envelope);
|
||||
},
|
||||
};
|
||||
}
|
||||
51
extensions/lobster/src/taskflow-test-helpers.ts
Normal file
51
extensions/lobster/src/taskflow-test-helpers.ts
Normal file
@@ -0,0 +1,51 @@
|
||||
// Lobster helper module supports taskflow test helpers behavior.
|
||||
import { vi } from "vitest";
|
||||
import type { OpenClawPluginApi } from "../runtime-api.js";
|
||||
|
||||
type BoundTaskFlow = ReturnType<
|
||||
NonNullable<OpenClawPluginApi["runtime"]>["tasks"]["managedFlows"]["bindSession"]
|
||||
>;
|
||||
|
||||
export function createFakeTaskFlow(overrides?: Partial<BoundTaskFlow>): BoundTaskFlow {
|
||||
const baseFlow = {
|
||||
flowId: "flow-1",
|
||||
revision: 1,
|
||||
syncMode: "managed" as const,
|
||||
controllerId: "tests/lobster",
|
||||
ownerKey: "agent:main:main",
|
||||
status: "running" as const,
|
||||
goal: "Run Lobster workflow",
|
||||
};
|
||||
const createManaged = vi.fn().mockReturnValue(baseFlow);
|
||||
|
||||
return {
|
||||
sessionKey: "agent:main:main",
|
||||
createManaged,
|
||||
tryCreateManaged: vi.fn((params) => createManaged(params)),
|
||||
get: vi.fn(),
|
||||
list: vi.fn().mockReturnValue([]),
|
||||
findLatest: vi.fn(),
|
||||
resolve: vi.fn(),
|
||||
getTaskSummary: vi.fn(),
|
||||
setWaiting: vi.fn().mockImplementation((input) => ({
|
||||
applied: true,
|
||||
flow: { ...baseFlow, revision: input.expectedRevision + 1, status: "waiting" as const },
|
||||
})),
|
||||
resume: vi.fn().mockImplementation((input) => ({
|
||||
applied: true,
|
||||
flow: { ...baseFlow, revision: input.expectedRevision + 1, status: "running" as const },
|
||||
})),
|
||||
finish: vi.fn().mockImplementation((input) => ({
|
||||
applied: true,
|
||||
flow: { ...baseFlow, revision: input.expectedRevision + 1, status: "completed" as const },
|
||||
})),
|
||||
fail: vi.fn().mockImplementation((input) => ({
|
||||
applied: true,
|
||||
flow: { ...baseFlow, revision: input.expectedRevision + 1, status: "failed" as const },
|
||||
})),
|
||||
requestCancel: vi.fn(),
|
||||
cancel: vi.fn(),
|
||||
runTask: vi.fn(),
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
Reference in New Issue
Block a user