Converts gadget-tasks from a duplicated codebase with direct MongoDB access to a headless IDE client that uses gadget-code's REST API and Socket.IO protocol to submit and process task prompts through the existing pipeline. Deleted (no longer needed): - gadget-tasks/src/models/ (5 Mongoose models — duplicated from gadget-code) - gadget-tasks/data/prompts/ (11 prompt templates — duplicated from gadget-code) - gadget-tasks/src/services/executor.ts (629-line AWL loop — duplicated from gadget-drone) - gadget-tasks/src/services/ai.ts (direct AI API calls — drones do this now) - gadget-tasks/src/services/workspace.ts (workspace management — drones do this now) Added: - gadget-tasks/src/services/platform.ts — REST API + Socket.IO headless IDE client with session lock, workspace mode, prompt submission, work order tracking Rewritten: - gadget-tasks/src/gadget-tasks.ts — new startup: user login → auth → drone selection → Socket.IO connect → schedule tasks (no MongoDB) - gadget-tasks/src/services/scheduler.ts — delegates to PlatformService.executeTask() - gadget-tasks/src/config/env.ts — platform.baseUrl config, no mongodb/google gadget-code changes: - Added PATCH /api/v1/projects/:projectId/tasks/:taskId/lastRun route - Added ProjectService.updateTaskLastRun() for atomic task field update Config changes: - GadgetTasksConfig: replaced mongodb with platform.baseUrl, removed google.cse - Dependencies: removed mongoose, @gadget/ai, @gadget/ai-toolbox, dayjs, simple-git, nanoid; added socket.io-client, @inquirer/prompts
139 lines
3.5 KiB
TypeScript
139 lines
3.5 KiB
TypeScript
// src/services/lock.ts
|
|
// Copyright (C) 2026 Rob Colbert <rob.colbert@openplatform.us>
|
|
// Licensed under the Apache License, Version 2.0
|
|
|
|
import os from "node:os";
|
|
import { Redis } from "ioredis";
|
|
import env from "../config/env.ts";
|
|
import { GadgetLog } from "@gadget/api";
|
|
|
|
const log = new GadgetLog({ name: "TaskLock", slug: "task-lock" });
|
|
|
|
const LOCK_KEY = "gadget:tasks:lock";
|
|
const LOCK_TTL_SECONDS = 60;
|
|
const HEARTBEAT_INTERVAL_MS = 30000; // 30 seconds
|
|
|
|
export interface TaskLockData {
|
|
pid: number;
|
|
hostname: string;
|
|
startedAt: number;
|
|
heartbeatAt: number;
|
|
}
|
|
|
|
class TaskLockService {
|
|
private redis: Redis | null = null;
|
|
private heartbeatTimer: ReturnType<typeof setInterval> | null = null;
|
|
|
|
async connect(): Promise<void> {
|
|
this.redis = new Redis({
|
|
host: env.redis.host,
|
|
port: env.redis.port,
|
|
password: env.redis.password || undefined,
|
|
keyPrefix: env.redis.keyPrefix,
|
|
lazyConnect: true,
|
|
});
|
|
await this.redis.connect();
|
|
log.info("connected to Redis", {
|
|
host: env.redis.host,
|
|
port: env.redis.port,
|
|
});
|
|
}
|
|
|
|
/**
|
|
* Attempt to acquire the singleton lock.
|
|
* Returns true if acquired, false if another instance is running.
|
|
*/
|
|
async acquire(): Promise<boolean> {
|
|
if (!this.redis) {
|
|
throw new Error("Redis connection not initialized");
|
|
}
|
|
|
|
const data: TaskLockData = {
|
|
pid: process.pid,
|
|
hostname: os.hostname(),
|
|
startedAt: Date.now(),
|
|
heartbeatAt: Date.now(),
|
|
};
|
|
|
|
// SET gadget:tasks:lock <data> EX 60 NX
|
|
const result = await this.redis.set(
|
|
LOCK_KEY,
|
|
JSON.stringify(data),
|
|
"EX",
|
|
LOCK_TTL_SECONDS,
|
|
"NX",
|
|
);
|
|
|
|
if (result === "OK") {
|
|
this.startHeartbeat();
|
|
return true;
|
|
}
|
|
|
|
// Lock exists — check if stale
|
|
const existing = await this.redis.get(LOCK_KEY);
|
|
if (existing) {
|
|
const info = JSON.parse(existing) as TaskLockData;
|
|
const age = Date.now() - info.heartbeatAt;
|
|
if (age > LOCK_TTL_SECONDS * 1000) {
|
|
// Stale — reclaim
|
|
log.warn("stale lock detected, reclaiming", { existingPid: info.pid });
|
|
await this.redis.del(LOCK_KEY);
|
|
return this.acquire(); // retry
|
|
}
|
|
log.error("gadget-tasks is already running", {
|
|
pid: info.pid,
|
|
hostname: info.hostname,
|
|
startedAt: new Date(info.startedAt).toISOString(),
|
|
});
|
|
return false;
|
|
}
|
|
|
|
// Lock disappeared — retry
|
|
return this.acquire();
|
|
}
|
|
|
|
/**
|
|
* Release the lock on shutdown.
|
|
*/
|
|
async release(): Promise<void> {
|
|
if (this.heartbeatTimer) {
|
|
clearInterval(this.heartbeatTimer);
|
|
this.heartbeatTimer = null;
|
|
}
|
|
if (this.redis) {
|
|
try {
|
|
await this.redis.del(LOCK_KEY);
|
|
} catch {
|
|
// best effort
|
|
}
|
|
try {
|
|
await this.redis.quit();
|
|
} catch {
|
|
// best effort
|
|
}
|
|
this.redis = null;
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Start heartbeat timer to refresh lock TTL.
|
|
*/
|
|
private startHeartbeat(): void {
|
|
this.heartbeatTimer = setInterval(async () => {
|
|
if (!this.redis) return;
|
|
try {
|
|
const existing = await this.redis.get(LOCK_KEY);
|
|
if (existing) {
|
|
const data = JSON.parse(existing) as TaskLockData;
|
|
data.heartbeatAt = Date.now();
|
|
await this.redis.set(LOCK_KEY, JSON.stringify(data), "EX", LOCK_TTL_SECONDS);
|
|
}
|
|
} catch (error) {
|
|
log.error("heartbeat failed", { error });
|
|
}
|
|
}, HEARTBEAT_INTERVAL_MS);
|
|
}
|
|
}
|
|
|
|
export default new TaskLockService();
|