Skip to content
Open
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
1 change: 0 additions & 1 deletion .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -35,7 +35,6 @@ coverage/
.railway/

# Generated deploy artifacts
Dockerfile
.dockerignore

# Optional: local OpenClaw / Moltbot
Expand Down
18 changes: 18 additions & 0 deletions Dockerfile
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
FROM node:20-slim
WORKDIR /app

COPY package.json package-lock.json* ./
RUN npm ci

COPY tsconfig.json ./
COPY bin/ ./bin/
COPY src/ ./src/

RUN find src/seller/offerings -mindepth 2 -maxdepth 3 -name "package.json" | \
while IFS= read -r pkg; do \
dir=$(dirname "$pkg"); \
echo ">>> Installing deps in $dir"; \
npm install --omit=dev --prefix "$dir" || exit 1; \
done

CMD ["npm", "run", "start"]
52 changes: 42 additions & 10 deletions src/commands/deploy.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@
import * as fs from "fs";
import * as path from "path";
import { execSync } from "child_process";
import { parse as parseDotenv } from "dotenv";
import readline from "readline";
import * as output from "../lib/output.js";
import { readConfig, writeConfig, getActiveAgent, sanitizeAgentName, ROOT } from "../lib/config.js";
Expand Down Expand Up @@ -240,7 +241,39 @@ export async function deploy(): Promise<void> {
output.log(` Offerings: ${offerings.join(", ")}`);
output.log("");

// Deploy (this creates the service on first run)
// Ensure service is valid before deploying; if stale, clear it so railway up creates a new one
let isNewService = false;
if (!railway.isServiceValid()) {
output.log(
" Linked service not found. Clearing stale link — Railway will create a new service on deploy..."
);
railway.clearLinkedService();
isNewService = true;
}

// Collect env vars to sync
const config = readConfig();
const dotenvVars: Record<string, string> = fs.existsSync(path.resolve(ROOT, ".env"))
? parseDotenv(fs.readFileSync(path.resolve(ROOT, ".env")))
: {};
const envVars: Record<string, string> = { ...dotenvVars };
if (config.LITE_AGENT_API_KEY) envVars["LITE_AGENT_API_KEY"] = config.LITE_AGENT_API_KEY;

if (!isNewService && Object.keys(envVars).length > 0) {
try {
railway.setVariables(envVars);
output.success(`Set ${Object.keys(envVars).length} env var(s) on Railway`);
} catch {
output.warn(
`Could not set env vars. Set them manually:\n` +
Object.keys(envVars)
.map((k) => ` acp serve deploy railway env set ${k}=<value>`)
.join("\n")
);
}
}

// Deploy
output.log(" Deploying to Railway...\n");
railway.up();

Expand All @@ -257,18 +290,17 @@ export async function deploy(): Promise<void> {
}
}

// Set API key on the service
const config = readConfig();
const apiKey = config.LITE_AGENT_API_KEY;
if (apiKey) {
// For new services, set env vars after deploy once the service exists
if (isNewService && Object.keys(envVars).length > 0) {
try {
railway.setVariable("LITE_AGENT_API_KEY", apiKey);
output.success("Set LITE_AGENT_API_KEY on Railway");
railway.setVariables(envVars);
output.success(`Set ${Object.keys(envVars).length} env var(s) on Railway`);
} catch {
output.warn(
"Could not set LITE_AGENT_API_KEY. Set it manually:\n" +
" acp serve deploy railway env set LITE_AGENT_API_KEY=" +
apiKey
`Could not set env vars. Set them manually:\n` +
Object.keys(envVars)
.map((k) => ` acp serve deploy railway env set ${k}=<value>`)
.join("\n")
);
}
}
Expand Down
16 changes: 14 additions & 2 deletions src/deploy/docker.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,17 +6,29 @@
export function generateDockerfile(): string {
return `FROM node:20-slim
WORKDIR /app

COPY package.json package-lock.json* ./
RUN npm install --production=false
RUN npm ci

COPY tsconfig.json ./
COPY bin/ ./bin/
COPY src/ ./src/
CMD ["npx", "tsx", "src/seller/runtime/seller.ts"]

RUN find src/seller/offerings -mindepth 2 -maxdepth 3 -name "package.json" | \
while IFS= read -r pkg; do \
dir=$(dirname "$pkg"); \
echo ">>> Installing deps in $dir"; \
npm install --omit=dev --prefix "$dir" || exit 1; \
done

CMD ["npm", "run", "start"]

`;
}

export function generateDockerignore(): string {
return `node_modules
src/seller/offerings/**/node_modules
dist
build
logs
Expand Down
31 changes: 31 additions & 0 deletions src/deploy/railway.ts
Original file line number Diff line number Diff line change
Expand Up @@ -151,6 +151,27 @@ export function hasLinkedService(): boolean {
}
}

/** Returns true if the linked service exists and is reachable in the project. */
export function isServiceValid(): boolean {
try {
const status = execSync("railway status", {
...EXEC_OPTS,
stdio: ["pipe", "pipe", "pipe"],
}).trim();
return !status.includes("Service: None") && !status.includes("not found in project");
} catch {
return false;
}
}

/** Clear the linked service so the next `railway up` creates a fresh one. */
export function clearLinkedService(): void {
const global = readGlobalConfig();
if (!global?.projects?.[ROOT]) return;
global.projects[ROOT].service = null;
writeGlobalConfig(global);
}

// -- Variables --

export function setVariable(key: string, value: string): void {
Expand All @@ -160,6 +181,16 @@ export function setVariable(key: string, value: string): void {
});
}

export function setVariables(vars: Record<string, string>): void {
const pairs = Object.entries(vars)
.map(([k, v]) => `${k}="${v}"`)
.join(" ");
execSync(`railway variables set ${pairs}`, {
...EXEC_OPTS,
stdio: ["pipe", "pipe", "pipe"],
});
}

export function deleteVariable(key: string): void {
execSync(`railway variables delete ${key}`, {
...EXEC_OPTS,
Expand Down
14 changes: 11 additions & 3 deletions src/seller/runtime/offeringTypes.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@
// =============================================================================

/** Optional token-transfer instruction returned by an offering handler. */
import type { AcpJobEventData } from "./types.js";
export interface TransferInstruction {
/** Token contract address (e.g. ERC-20 CA). */
ca: string;
Expand Down Expand Up @@ -42,10 +43,17 @@ export type ValidationResult = boolean | { valid: boolean; reason?: string };
export interface OfferingHandlers {
executeJob: (request: Record<string, any>) => Promise<ExecuteJobResult>;
validateRequirements?: (
request: Record<string, any>
request: Record<string, any>,
data: AcpJobEventData
) => ValidationResult | Promise<ValidationResult>;
requestPayment?: (request: Record<string, any>) => string | Promise<string>;
requestAdditionalFunds?: (request: Record<string, any>) =>
requestPayment?: (
request: Record<string, any>,
data: AcpJobEventData
) => string | Promise<string>;
requestAdditionalFunds?: (
request: Record<string, any>,
data: AcpJobEventData
) =>
| {
content?: string;
amount: number;
Expand Down
6 changes: 3 additions & 3 deletions src/seller/runtime/seller.ts
Original file line number Diff line number Diff line change
Expand Up @@ -112,7 +112,7 @@ async function handleNewTask(data: AcpJobEventData): Promise<void> {
const { config, handlers } = await loadOffering(offeringName, agentDirName);

if (handlers.validateRequirements) {
const validationResult = await handlers.validateRequirements(requirements);
const validationResult = await handlers.validateRequirements(requirements, data);

let isValid: boolean;
let reason: string | undefined;
Expand Down Expand Up @@ -145,11 +145,11 @@ async function handleNewTask(data: AcpJobEventData): Promise<void> {

const funds =
config.requiredFunds && handlers.requestAdditionalFunds
? await handlers.requestAdditionalFunds(requirements)
? await handlers.requestAdditionalFunds(requirements, data)
: undefined;

const paymentReason = handlers.requestPayment
? await handlers.requestPayment(requirements)
? await handlers.requestPayment(requirements, data)
: (funds?.content ?? "Request accepted");

await requestPayment(jobId, {
Expand Down