Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
54 changes: 54 additions & 0 deletions .github/workflows/test.yml
Original file line number Diff line number Diff line change
Expand Up @@ -78,9 +78,15 @@ jobs:
with:
bun-version: "1.3.5"

- name: Install ripgrep
run: sudo apt-get update && sudo apt-get install -y ripgrep

- name: Install dependencies
run: bun install

- name: Build daemon bundle
run: bun run --cwd=packages/sandbox build

- name: Start NATS with JetStream
run: |
docker run -d --name nats -p 4222:4222 nats:2.10 -js
Expand Down Expand Up @@ -132,3 +138,51 @@ jobs:

- name: Run knip
run: bun run knip

docker-smoke:
runs-on: ubuntu-latest
steps:
- name: Checkout repository
uses: actions/checkout@v4

- name: Setup Bun
uses: oven-sh/setup-bun@v2
with:
bun-version: "1.3.5"

- name: Install dependencies
run: bun install

- name: Build daemon bundle
run: bun run --cwd=packages/sandbox build

- name: Build sandbox image
run: |
docker build \
-t mesh-sandbox:ci \
-f packages/sandbox/image/Dockerfile \
packages/sandbox

- name: Smoke test
run: |
docker run -d --name sandbox-smoke -p 19999:9000 \
-e DAEMON_TOKEN="$(printf 't%.0s' {1..32})" \
-e DAEMON_BOOT_ID="ci-smoke" \
-e APP_ROOT=/app \
-e PROXY_PORT=9000 \
-e DAEMON_NO_AUTOSTART=1 \
mesh-sandbox:ci
for i in $(seq 1 30); do
if curl -fsS http://localhost:19999/health | grep -q '"bootId":"ci-smoke"'; then
echo "ok"
exit 0
fi
sleep 1
done
echo "smoke test failed — daemon did not return /health with ci-smoke bootId"
docker logs sandbox-smoke
exit 1

- name: Tear down
if: always()
run: docker rm -f sandbox-smoke || true
2 changes: 1 addition & 1 deletion apps/mesh/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -145,7 +145,7 @@
"kysely-pglite": "^0.6.1",
"lucide-react": "^0.468.0",
"marked": "^15.0.6",
"mesh-plugin-user-sandbox": "workspace:*",
"@decocms/sandbox": "workspace:*",
"mesh-plugin-workflows": "workspace:*",
"nanoid": "^5.1.6",
"pg": "^8.16.3",
Expand Down
2 changes: 1 addition & 1 deletion apps/mesh/spec/monitoring-share-plugin.md
Original file line number Diff line number Diff line change
Expand Up @@ -269,7 +269,7 @@ setup: (ctx) => {
|------|--------|
| `packages/bindings/src/core/plugins.ts` | Add `rootRoute` and `registerPublicRoutes` to context |
| `apps/mesh/src/web/index.tsx` | Pass new context props, collect and mount public routes |
| `packages/mesh-plugin-user-sandbox/client/index.ts` | Migrate connect route registration |
| `packages/@decocms/sandbox/client/index.ts` | Migrate connect route registration |
| `apps/mesh/src/web/routes/connect.tsx` | Remove (or keep as fallback) |

### Phase 2 & 3 (Plugin)
Expand Down
7 changes: 1 addition & 6 deletions apps/mesh/src/api/app.ts
Original file line number Diff line number Diff line change
Expand Up @@ -45,7 +45,6 @@ import oauthProxyRoutes, {
} from "./routes/oauth-proxy";
import openaiCompatRoutes from "./routes/openai-compat";
import proxyRoutes from "./routes/proxy";
import { createSandboxDaemonRoutes } from "./routes/sandbox-daemon";
import { createKVRoutes } from "./routes/kv";
import { createTriggerCallbackRoutes } from "./routes/trigger-callback";
import publicConfigRoutes from "./routes/public-config";
Expand Down Expand Up @@ -1361,10 +1360,6 @@ export async function createApp(options: CreateAppOptions = {}) {
});
app.route("/api", decopilotRoutes);

// Daemon control-plane passthrough only — dev-server traffic bypasses
// mesh and hits pods' public URLs directly.
app.route("/", createSandboxDaemonRoutes());

// Stable file redirect endpoint (resolves mesh-storage: URIs to presigned URLs)
app.route("/api", filesRoutes);

Expand Down Expand Up @@ -1591,7 +1586,7 @@ export async function createApp(options: CreateAppOptions = {}) {
const dockerRunner = asDockerRunner(getSharedRunnerIfInit());
if (dockerRunner) {
const { sweepDockerOrphansOnShutdown } = await import(
"mesh-plugin-user-sandbox/runner"
"@decocms/sandbox/runner"
);
await sweepDockerOrphansOnShutdown(dockerRunner);
}
Expand Down
2 changes: 1 addition & 1 deletion apps/mesh/src/api/routes/decopilot/built-in-tools/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -36,7 +36,7 @@ import { createReadResourceTool } from "./resources";
import { createSandboxTool, type VirtualClient } from "./sandbox";
import { createVmTools } from "./vm-tools";
import { getRunnerByKind } from "@/sandbox/lifecycle";
import type { RunnerKind } from "mesh-plugin-user-sandbox/runner";
import type { RunnerKind } from "@decocms/sandbox/runner";
import { createSubtaskTool } from "./subtask";
import { userAskTool } from "./user-ask";
import { proposePlanTool } from "./propose-plan";
Expand Down
25 changes: 14 additions & 11 deletions apps/mesh/src/api/routes/decopilot/built-in-tools/vm-tools/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,13 +2,13 @@
* VM File Tools — runner-agnostic.
*
* Registers the six LLM-visible tools (read/write/edit/grep/glob/bash) on
* top of any `SandboxRunner.proxyDaemonRequest`. Path scheme is Docker's
* canonical `/_daemon/fs/<op>` + `/_daemon/bash`; non-Docker runners
* translate inside `proxyDaemonRequest` (see Freestyle's `translateDaemonPath`).
* top of any `SandboxRunner.proxyDaemonRequest`. All runners speak the
* unified `/_decopilot_vm/*` surface with base64-wrapped JSON bodies
* (Cloudflare WAF bypass; harmless 33% overhead on non-CF paths).
*/

import { tool, zodSchema } from "ai";
import type { SandboxRunner } from "mesh-plugin-user-sandbox/runner";
import type { SandboxRunner } from "@decocms/sandbox/runner";
import { maybeTruncate } from "./common";
import {
BASH_DESCRIPTION,
Expand Down Expand Up @@ -37,10 +37,13 @@ async function daemonRequest(
): Promise<unknown> {
let res: Response;
try {
const b64Body = Buffer.from(JSON.stringify(body), "utf-8").toString(
"base64",
);
res = await runner.proxyDaemonRequest(handle, path, {
method: "POST",
headers: new Headers({ "content-type": "application/json" }),
body: JSON.stringify(body),
body: b64Body,
});
} catch {
throw new Error(
Expand Down Expand Up @@ -98,7 +101,7 @@ export function createVmTools(params: VmToolsParams) {
description: READ_DESCRIPTION,
inputSchema: zodSchema(ReadInputSchema),
execute: async (input) => {
const result = await call("/_daemon/fs/read", input);
const result = await call("/_decopilot_vm/read", input);
return maybeTruncate(result, toolOutputMap);
},
});
Expand All @@ -107,22 +110,22 @@ export function createVmTools(params: VmToolsParams) {
needsApproval: approvalFor(TOOL_APPROVAL.write),
description: WRITE_DESCRIPTION,
inputSchema: zodSchema(WriteInputSchema),
execute: async (input) => call("/_daemon/fs/write", input),
execute: async (input) => call("/_decopilot_vm/write", input),
});

const edit = tool({
needsApproval: approvalFor(TOOL_APPROVAL.edit),
description: EDIT_DESCRIPTION,
inputSchema: zodSchema(EditInputSchema),
execute: async (input) => call("/_daemon/fs/edit", input),
execute: async (input) => call("/_decopilot_vm/edit", input),
});

const grep = tool({
needsApproval: approvalFor(TOOL_APPROVAL.grep),
description: GREP_DESCRIPTION,
inputSchema: zodSchema(GrepInputSchema),
execute: async (input) => {
const result = await call("/_daemon/fs/grep", input);
const result = await call("/_decopilot_vm/grep", input);
return maybeTruncate(result, toolOutputMap);
},
});
Expand All @@ -132,7 +135,7 @@ export function createVmTools(params: VmToolsParams) {
description: GLOB_DESCRIPTION,
inputSchema: zodSchema(GlobInputSchema),
execute: async (input) => {
const result = await call("/_daemon/fs/glob", input);
const result = await call("/_decopilot_vm/glob", input);
return maybeTruncate(result, toolOutputMap);
},
});
Expand All @@ -142,7 +145,7 @@ export function createVmTools(params: VmToolsParams) {
description: BASH_DESCRIPTION,
inputSchema: zodSchema(BashInputSchema),
execute: async (input) => {
const result = await call("/_daemon/bash", input);
const result = await call("/_decopilot_vm/bash", input);
return maybeTruncate(result, toolOutputMap);
},
});
Expand Down
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import type { SandboxRunner } from "mesh-plugin-user-sandbox/runner";
import type { SandboxRunner } from "@decocms/sandbox/runner";

export interface VmToolsParams {
readonly runner: SandboxRunner;
Expand Down
45 changes: 45 additions & 0 deletions apps/mesh/src/api/routes/decopilot/memory.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,45 @@
import { describe, it, expect, beforeAll, afterAll } from "bun:test";
import { createMemory } from "./memory";
import {
buildThreadTestContext,
type ThreadTestEnv,
} from "../../../tools/thread/test-helpers";

describe("createMemory", () => {
let env: ThreadTestEnv;

beforeAll(async () => {
env = await buildThreadTestContext();
});
afterAll(async () => {
await env.close();
});

it("returns Memory when thread exists", async () => {
const thread = await env.ctx.storage.threads.create({
id: "thrd_existing",
organization_id: env.orgId,
title: "ok",
created_by: env.userId,
virtual_mcp_id: "vmcp_x",
});

const memory = await createMemory(env.ctx.storage.threads, {
thread_id: thread.id,
organization_id: env.orgId,
userId: env.userId,
});

expect(memory.thread.id).toBe("thrd_existing");
});

it("throws when thread_id is provided but thread does not exist", async () => {
await expect(
createMemory(env.ctx.storage.threads, {
thread_id: "thrd_does_not_exist",
organization_id: env.orgId,
userId: env.userId,
}),
).rejects.toThrow(/thread.*not.*found/i);
});
});
91 changes: 11 additions & 80 deletions apps/mesh/src/api/routes/decopilot/memory.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,15 +7,13 @@

import type { OrgScopedThreadStorage } from "@/storage/threads";
import type { Thread, ThreadMessage } from "@/storage/types";
import { posthog } from "@/posthog";
import { generatePrefixedId } from "@/shared/utils/generate-id";

/**
* Configuration for creating a Memory instance
*/
export interface MemoryConfig {
/** Thread ID (creates new if not found) */
thread_id?: string | null;
/** Thread ID (required — thread must exist) */
thread_id: string;

/** Organization scope */
organization_id: string;
Expand All @@ -25,19 +23,6 @@ export interface MemoryConfig {

/** Default window size for pruning */
defaultWindowSize?: number;

/** Optional trigger ID for automation-created threads */
triggerId?: string;

/** Virtual MCP ID to associate with the thread */
virtualMcpId?: string;

/**
* Git branch to pin this thread to. Only meaningful for GitHub-linked
* virtualmcps. When set on a brand-new thread, it's persisted on the
* thread row and propagates to VM_START.
*/
branch?: string | null;
}

/**
Expand Down Expand Up @@ -89,77 +74,23 @@ export class Memory {
}

/**
* Create or get a thread, returning a Memory instance
* Get an existing thread by id, returning a Memory instance.
* Throws if the thread does not exist — the route loader is responsible for
* creating threads up-front via COLLECTION_THREADS_CREATE.
*/
export async function createMemory(
storage: OrgScopedThreadStorage,
config: MemoryConfig,
): Promise<Memory> {
const {
thread_id,
organization_id,
userId,
defaultWindowSize,
triggerId,
virtualMcpId,
branch,
} = config;

let thread: Thread;
const { thread_id, defaultWindowSize } = config;

if (!thread_id) {
// Create new thread
thread = await storage.create({
id: generatePrefixedId("thrd"),
organization_id,
created_by: userId,
trigger_id: triggerId ?? null,
virtual_mcp_id: virtualMcpId ?? "",
branch: branch ?? null,
});
posthog.capture({
distinctId: userId,
event: "chat_started",
groups: { organization: organization_id },
properties: {
organization_id,
thread_id: thread.id,
created_via: triggerId ? "automation" : "stream_auto",
trigger_id: triggerId ?? null,
virtual_mcp_id: virtualMcpId || null,
},
});
} else {
// Try to get existing thread scoped to this org
const existing = await storage.get(thread_id);
throw new Error("createMemory: thread_id is required");
}

if (existing) {
thread = existing;
} else {
// Thread not found — create using the client-provided ID so the
// frontend and server stay in sync (avoids a thread-ID switch in
// onFinish which causes a full re-render cascade).
thread = await storage.create({
id: thread_id,
organization_id,
created_by: userId,
trigger_id: triggerId ?? null,
virtual_mcp_id: virtualMcpId ?? "",
branch: branch ?? null,
});
posthog.capture({
distinctId: userId,
event: "chat_started",
groups: { organization: organization_id },
properties: {
organization_id,
thread_id: thread.id,
created_via: triggerId ? "automation" : "stream_client_id",
trigger_id: triggerId ?? null,
virtual_mcp_id: virtualMcpId || null,
},
});
}
const thread = await storage.get(thread_id);
if (!thread) {
throw new Error(`Thread not found: ${thread_id}`);
}

return new Memory({
Expand Down
Loading
Loading