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
2 changes: 2 additions & 0 deletions .github/workflows/publish.yml
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,7 @@ jobs:
- image: web
dockerfile: apps/web/Dockerfile
pre-build: false
build-args: NEXT_PUBLIC_AUTH_PROVIDER=nextauth
- image: watcher
dockerfile: apps/watcher/Dockerfile
pre-build: true
Expand Down Expand Up @@ -86,5 +87,6 @@ jobs:
platforms: linux/amd64,linux/arm64
tags: ${{ steps.meta.outputs.tags }}
labels: ${{ steps.meta.outputs.labels }}
build-args: ${{ matrix.build-args || '' }}
cache-from: type=gha
cache-to: type=gha,mode=max
7 changes: 6 additions & 1 deletion apps/watcher/Dockerfile
Original file line number Diff line number Diff line change
Expand Up @@ -24,8 +24,13 @@ RUN pnpm --filter=@clawe/watcher --prod deploy deploy
# Production runner
FROM node:22-slim
WORKDIR /app

ENV NODE_ENV=production
ENV NO_COLOR=1
ENV FORCE_COLOR=0

COPY --from=builder --chown=node:node /app/deploy .

COPY --from=builder /app/deploy .
USER node

CMD ["node", "dist/index.js"]
3 changes: 2 additions & 1 deletion apps/watcher/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -19,7 +19,8 @@
"dependencies": {
"@clawe/backend": "workspace:*",
"@clawe/shared": "workspace:*",
"convex": "^1.21.0"
"convex": "^1.21.0",
"pino": "^10.3.1"
},
"devDependencies": {
"@clawe/eslint-config": "workspace:*",
Expand Down
24 changes: 16 additions & 8 deletions apps/watcher/src/config.spec.ts
Original file line number Diff line number Diff line change
@@ -1,10 +1,16 @@
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";

const mockFatal = vi.fn();
vi.mock("./logger.js", () => ({
logger: { fatal: mockFatal },
}));

describe("config", () => {
const originalEnv = process.env;

beforeEach(() => {
vi.resetModules();
mockFatal.mockClear();
process.env = { ...originalEnv };
});

Expand All @@ -20,18 +26,19 @@ describe("config", () => {
const mockExit = vi
.spyOn(process, "exit")
.mockImplementation(() => undefined as never);
const mockError = vi.spyOn(console, "error").mockImplementation(() => {});

const { validateEnv } = await import("./config.js");
validateEnv();

expect(mockError).toHaveBeenCalledWith(
expect.stringContaining("CONVEX_URL"),
expect(mockFatal).toHaveBeenCalledWith(
expect.objectContaining({
missing: expect.arrayContaining(["CONVEX_URL"]),
}),
expect.any(String),
);
expect(mockExit).toHaveBeenCalledWith(1);

mockExit.mockRestore();
mockError.mockRestore();
});

it("exits when WATCHER_TOKEN is missing", async () => {
Expand All @@ -41,18 +48,19 @@ describe("config", () => {
const mockExit = vi
.spyOn(process, "exit")
.mockImplementation(() => undefined as never);
const mockError = vi.spyOn(console, "error").mockImplementation(() => {});

const { validateEnv } = await import("./config.js");
validateEnv();

expect(mockError).toHaveBeenCalledWith(
expect.stringContaining("WATCHER_TOKEN"),
expect(mockFatal).toHaveBeenCalledWith(
expect.objectContaining({
missing: expect.arrayContaining(["WATCHER_TOKEN"]),
}),
expect.any(String),
);
expect(mockExit).toHaveBeenCalledWith(1);

mockExit.mockRestore();
mockError.mockRestore();
});

it("does not exit when all required vars are set", async () => {
Expand Down
6 changes: 3 additions & 3 deletions apps/watcher/src/config.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,7 @@
// Watcher configuration

import { logger } from "./logger.js";

export const POLL_INTERVAL_MS = 2000; // Check every 2 seconds

// Environment validation
Expand All @@ -8,9 +10,7 @@ export function validateEnv(): void {
const missing = required.filter((key) => !process.env[key]);

if (missing.length > 0) {
console.error(
`Missing required environment variables: ${missing.join(", ")}`,
);
logger.fatal({ missing }, "Missing required environment variables");
process.exit(1);
}
}
Expand Down
69 changes: 31 additions & 38 deletions apps/watcher/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@ import { api } from "@clawe/backend";
import { sessionsSend, type SquadhubConnection } from "@clawe/shared/squadhub";
import { getTimeInZone, DEFAULT_TIMEZONE } from "@clawe/shared/timezone";
import { validateEnv, config, POLL_INTERVAL_MS } from "./config.js";
import { logger } from "./logger.js";

// Validate environment on startup
validateEnv();
Expand Down Expand Up @@ -98,13 +99,11 @@ async function checkRoutinesForTenant(machineToken: string): Promise<void> {
machineToken,
routineId: routine._id,
});
console.log(
`[watcher] ✓ Triggered routine "${routine.title}" → task ${taskId}`,
);
logger.info({ routine: routine.title, taskId }, "Triggered routine");
} catch (err) {
console.error(
`[watcher] Failed to trigger routine "${routine.title}":`,
err instanceof Error ? err.message : err,
logger.error(
{ routine: routine.title, err },
"Failed to trigger routine",
);
}
}
Expand All @@ -120,9 +119,9 @@ async function checkRoutines(): Promise<void> {
try {
await checkRoutinesForTenant(tenant.connection.squadhubToken);
} catch (err) {
console.error(
`[watcher] Error checking routines for tenant ${tenant.id}:`,
err instanceof Error ? err.message : err,
logger.error(
{ tenantId: tenant.id, err },
"Error checking routines for tenant",
);
}
}
Expand Down Expand Up @@ -175,8 +174,9 @@ async function deliverToAgent(
return;
}

console.log(
`[watcher] 📬 ${sessionKey} has ${notifications.length} pending notification(s)`,
logger.info(
{ sessionKey, count: notifications.length },
"Pending notifications",
);

for (const notification of notifications) {
Expand All @@ -194,27 +194,24 @@ async function deliverToAgent(
notificationIds: [notification._id],
});

console.log(
`[watcher] ✅ Delivered to ${sessionKey}: ${notification.content.slice(0, 50)}...`,
logger.info(
{ sessionKey, preview: notification.content.slice(0, 50) },
"Delivered notification",
);
} else {
// Agent might be asleep or session unavailable
console.log(
`[watcher] 💤 ${sessionKey} unavailable: ${result.error?.message ?? "unknown error"}`,
logger.warn(
{ sessionKey, error: result.error?.message ?? "unknown error" },
"Agent unavailable",
);
}
} catch (err) {
// Network error or agent asleep
console.log(
`[watcher] 💤 ${sessionKey} error: ${err instanceof Error ? err.message : "unknown"}`,
);
logger.warn({ sessionKey, err }, "Agent delivery error");
}
}
} catch (err) {
console.error(
`[watcher] Error checking ${sessionKey}:`,
err instanceof Error ? err.message : err,
);
logger.error({ sessionKey, err }, "Error checking agent notifications");
}
}

Expand Down Expand Up @@ -246,10 +243,7 @@ function startRoutineCheckLoop(): void {
try {
await checkRoutines();
} catch (err) {
console.error(
"[watcher] Routine check error:",
err instanceof Error ? err.message : err,
);
logger.error({ err }, "Routine check error");
}
};

Expand All @@ -266,10 +260,7 @@ async function startDeliveryLoop(): Promise<void> {
try {
await deliveryLoop();
} catch (err) {
console.error(
"[watcher] Delivery loop error:",
err instanceof Error ? err.message : err,
);
logger.error({ err }, "Delivery loop error");
}

await sleep(POLL_INTERVAL_MS);
Expand All @@ -280,14 +271,16 @@ async function startDeliveryLoop(): Promise<void> {
* Main entry point
*/
async function main(): Promise<void> {
console.log("[watcher] 🦞 Clawe Watcher starting...");
console.log(`[watcher] Convex: ${config.convexUrl}`);
console.log(`[watcher] Notification poll interval: ${POLL_INTERVAL_MS}ms`);
console.log(
`[watcher] Routine check interval: ${ROUTINE_CHECK_INTERVAL_MS}ms\n`,
logger.info("Clawe Watcher starting...");
logger.info({ convexUrl: config.convexUrl }, "Convex connected");
logger.info(
{
pollIntervalMs: POLL_INTERVAL_MS,
routineCheckIntervalMs: ROUTINE_CHECK_INTERVAL_MS,
},
"Intervals configured",
);

console.log("[watcher] Starting loops...\n");
logger.info("Starting loops...");

// Start routine check loop (every 10 seconds)
startRoutineCheckLoop();
Expand All @@ -298,6 +291,6 @@ async function main(): Promise<void> {

// Start the watcher
main().catch((err) => {
console.error("[watcher] Fatal error:", err);
logger.fatal({ err }, "Fatal error");
process.exit(1);
});
6 changes: 6 additions & 0 deletions apps/watcher/src/logger.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
import pino from "pino";

export const logger = pino({
level: process.env.LOG_LEVEL || "info",
name: "watcher",
});
36 changes: 20 additions & 16 deletions apps/web/Dockerfile
Original file line number Diff line number Diff line change
Expand Up @@ -14,31 +14,35 @@ RUN pnpm install --frozen-lockfile

# Build the app
FROM base AS builder
ENV NEXT_TELEMETRY_DISABLED=1

ARG NEXT_PUBLIC_AUTH_PROVIDER
ENV NEXT_PUBLIC_AUTH_PROVIDER=${NEXT_PUBLIC_AUTH_PROVIDER}

COPY --from=deps /app/ .
COPY --from=pruner /app/out/full/ .

# No build-time env vars needed - Convex URL is configured at runtime
RUN pnpm build --filter=@clawe/web

# Production runner
FROM base AS runner
ENV NODE_ENV=production
FROM node:22-alpine AS runner
WORKDIR /app

RUN addgroup --system --gid 1001 nodejs
RUN adduser --system --uid 1001 nextjs
ENV NODE_ENV=production
ENV NEXT_TELEMETRY_DISABLED=1
ENV NO_COLOR=1
ENV FORCE_COLOR=0
ENV PORT=3000
ENV HOSTNAME="0.0.0.0"
ENV CLAWE_DATA_DIR=/data/clawe

# Create data directory for config storage
RUN mkdir -p /data/clawe && chown nextjs:nodejs /data/clawe
# Create data directory with built-in node user (UID 1000)
RUN mkdir -p /data/clawe && chown node:node /data/clawe

COPY --from=builder /app/apps/web/.next/standalone ./
COPY --from=builder /app/apps/web/.next/static ./apps/web/.next/static
COPY --from=builder /app/apps/web/public ./apps/web/public
COPY --from=builder --chown=node:node /app/apps/web/.next/standalone ./
COPY --from=builder --chown=node:node /app/apps/web/.next/static ./apps/web/.next/static
COPY --from=builder --chown=node:node /app/apps/web/public ./apps/web/public

USER nextjs
USER node

EXPOSE 3000
ENV PORT=3000
ENV HOSTNAME="0.0.0.0"
ENV CLAWE_DATA_DIR=/data/clawe

CMD ["node", "apps/web/server.js"]
2 changes: 2 additions & 0 deletions apps/web/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -47,10 +47,12 @@
"next": "16.1.0",
"next-auth": "5.0.0-beta.30",
"next-themes": "^0.4.6",
"pino": "^10.3.1",
"react": "^19.2.0",
"react-dom": "^19.2.0",
"react-markdown": "^10.1.0",
"rehype-raw": "^7.0.0",
"sharp": "^0.34.5",
"remark-gfm": "^4.0.1",
"sonner": "^2.0.7",
"tippy.js": "^6.3.7",
Expand Down
20 changes: 20 additions & 0 deletions apps/web/src/instrumentation.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
export async function register() {
if (
process.env.NODE_ENV === "production" &&
process.env.NEXT_RUNTIME === "nodejs"
) {
const pino = (await import("pino")).default;
const logger = pino({ level: process.env.LOG_LEVEL || "info" });

globalThis.console.log = (...args: unknown[]) =>
logger.info(args.length === 1 ? args[0] : args);
globalThis.console.info = (...args: unknown[]) =>
logger.info(args.length === 1 ? args[0] : args);
globalThis.console.warn = (...args: unknown[]) =>
logger.warn(args.length === 1 ? args[0] : args);
globalThis.console.error = (...args: unknown[]) =>
logger.error(args.length === 1 ? args[0] : args);
globalThis.console.debug = (...args: unknown[]) =>
logger.debug(args.length === 1 ? args[0] : args);
}
}
1 change: 1 addition & 0 deletions apps/web/src/lib/auth/nextauth-config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -54,6 +54,7 @@ if (process.env.AUTO_LOGIN_EMAIL) {
}

const nextAuth = NextAuth({
trustHost: true,
providers,
session: { strategy: "jwt" },
jwt: {
Expand Down
Loading