feat: add temporal run engine integration
This commit is contained in:
19
apps/worker/package.json
Normal file
19
apps/worker/package.json
Normal file
@@ -0,0 +1,19 @@
|
||||
{
|
||||
"name": "@devflow/worker",
|
||||
"version": "0.0.0",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"build": "tsup src/index.ts --format esm --clean --external @temporalio/worker --external @temporalio/client --external @temporalio/workflow",
|
||||
"typecheck": "tsc -p ../../tsconfig.typecheck.json --noEmit",
|
||||
"test": "cd ../.. && vitest run --project apps/worker"
|
||||
},
|
||||
"dependencies": {
|
||||
"@devflow/core": "workspace:*",
|
||||
"@devflow/db": "workspace:*",
|
||||
"@devflow/session": "workspace:*",
|
||||
"@devflow/workflows": "workspace:*",
|
||||
"@temporalio/client": "^1.17.1",
|
||||
"@temporalio/worker": "^1.17.1"
|
||||
}
|
||||
}
|
||||
275
apps/worker/src/index.test.ts
Normal file
275
apps/worker/src/index.test.ts
Normal file
@@ -0,0 +1,275 @@
|
||||
import { randomUUID } from "node:crypto";
|
||||
import { mkdtempSync, realpathSync, rmSync } from "node:fs";
|
||||
import { tmpdir } from "node:os";
|
||||
import { join } from "node:path";
|
||||
|
||||
import { DevflowError } from "@devflow/core";
|
||||
import {
|
||||
type DbClient,
|
||||
createDbClient,
|
||||
runEvents,
|
||||
runs,
|
||||
tuiSessions,
|
||||
workflowTemplates,
|
||||
} from "@devflow/db";
|
||||
import { FakeSessionAdapter, type SessionAdapter, type SessionHandle } from "@devflow/session";
|
||||
import { eq, inArray } from "drizzle-orm";
|
||||
import { afterEach, describe, expect, it } from "vitest";
|
||||
|
||||
import { startWorker } from "./index.js";
|
||||
|
||||
const databaseUrl =
|
||||
process.env.DATABASE_URL ?? "postgres://devflow:devflow@127.0.0.1:55432/devflow";
|
||||
|
||||
class ResumeTrackingAdapter extends FakeSessionAdapter {
|
||||
resumeAttempts = 0;
|
||||
|
||||
override async resume(handle: SessionHandle): Promise<SessionHandle> {
|
||||
this.resumeAttempts += 1;
|
||||
return super.resume(handle);
|
||||
}
|
||||
}
|
||||
|
||||
describe("startWorker", () => {
|
||||
let client: DbClient | undefined;
|
||||
const runIds: string[] = [];
|
||||
const templateIds: string[] = [];
|
||||
const tempRoots: string[] = [];
|
||||
|
||||
afterEach(async () => {
|
||||
if (client !== undefined) {
|
||||
if (runIds.length > 0) {
|
||||
await client.db.delete(runs).where(inArray(runs.id, [...runIds]));
|
||||
}
|
||||
if (templateIds.length > 0) {
|
||||
await client.db
|
||||
.delete(workflowTemplates)
|
||||
.where(inArray(workflowTemplates.id, [...templateIds]));
|
||||
}
|
||||
await client.close();
|
||||
client = undefined;
|
||||
}
|
||||
for (const root of tempRoots.splice(0)) {
|
||||
rmSync(root, { recursive: true, force: true });
|
||||
}
|
||||
runIds.length = 0;
|
||||
templateIds.length = 0;
|
||||
});
|
||||
|
||||
it("initializes SessionManager recovery before accepting Temporal work", async () => {
|
||||
client = createDbClient(databaseUrl);
|
||||
const templateId = randomUUID();
|
||||
const runId = randomUUID();
|
||||
const sessionId = randomUUID();
|
||||
const repoPath = realpathSync(mkdtempSync(join(tmpdir(), "devflow-worker-repo-")));
|
||||
const worktreeRoot = realpathSync(mkdtempSync(join(tmpdir(), "devflow-worker-worktree-")));
|
||||
tempRoots.push(repoPath, worktreeRoot);
|
||||
templateIds.push(templateId);
|
||||
runIds.push(runId);
|
||||
|
||||
await client.db.insert(workflowTemplates).values({
|
||||
id: templateId,
|
||||
name: `worker-recovery-${templateId}`,
|
||||
version: 1,
|
||||
hash: "f".repeat(64),
|
||||
definition: { name: "worker-recovery", version: 1, roles: [], phases: [] },
|
||||
});
|
||||
await client.db.insert(runs).values({
|
||||
id: runId,
|
||||
templateId,
|
||||
templateHash: "f".repeat(64),
|
||||
state: "executing",
|
||||
repoPath,
|
||||
baseBranch: "main",
|
||||
worktreeRoot,
|
||||
});
|
||||
await client.db.insert(tuiSessions).values({
|
||||
id: sessionId,
|
||||
runId,
|
||||
roleId: "spec_writer",
|
||||
backend: "fake",
|
||||
cwd: worktreeRoot,
|
||||
state: "BOOTSTRAPPING",
|
||||
});
|
||||
|
||||
const adapter = new ResumeTrackingAdapter({
|
||||
sessionIdFactory: () => sessionId,
|
||||
writeDelayMs: 0,
|
||||
});
|
||||
await adapter.start({
|
||||
runId,
|
||||
roleId: "spec_writer",
|
||||
backend: "fake",
|
||||
cwd: worktreeRoot,
|
||||
});
|
||||
|
||||
const worker = await startWorkerWhenLockFree({
|
||||
config: {
|
||||
DATABASE_URL: databaseUrl,
|
||||
LOG_LEVEL: "info",
|
||||
TEMPORAL_ADDRESS: "localhost:7233",
|
||||
WORKSPACE_ROOT: worktreeRoot,
|
||||
MAX_CONCURRENT_RUNS: 4,
|
||||
backends: [{ id: "fake", enabled: true }],
|
||||
},
|
||||
dbClient: client,
|
||||
recoveryRunIds: [runId],
|
||||
sessionAdapter: adapter,
|
||||
connectionFactory: async () => fakeConnection(),
|
||||
workerFactory: async () => fakeWorker(),
|
||||
});
|
||||
|
||||
try {
|
||||
expect(worker.recovery).toEqual({ failedSessionIds: [], recoveredSessionIds: [sessionId] });
|
||||
expect(adapter.resumeAttempts).toBe(1);
|
||||
const [session] = await client.db
|
||||
.select({ state: tuiSessions.state })
|
||||
.from(tuiSessions)
|
||||
.where(eq(tuiSessions.id, sessionId));
|
||||
expect(session).toEqual({ state: "READY" });
|
||||
const events = await client.db
|
||||
.select({ type: runEvents.type })
|
||||
.from(runEvents)
|
||||
.where(eq(runEvents.runId, runId))
|
||||
.orderBy(runEvents.seq);
|
||||
expect(events.map((event) => event.type)).toEqual(["session.created", "session.ready"]);
|
||||
} finally {
|
||||
await worker.shutdown();
|
||||
}
|
||||
});
|
||||
|
||||
it("releases acquired resources when SessionManager startup fails", async () => {
|
||||
client = createDbClient(databaseUrl);
|
||||
const adapter: SessionAdapter = new FakeSessionAdapter();
|
||||
const first = await startWorkerWhenLockFree({
|
||||
config: {
|
||||
DATABASE_URL: databaseUrl,
|
||||
LOG_LEVEL: "info",
|
||||
TEMPORAL_ADDRESS: "localhost:7233",
|
||||
WORKSPACE_ROOT: realpathSync(mkdtempSync(join(tmpdir(), "devflow-worker-workspace-"))),
|
||||
MAX_CONCURRENT_RUNS: 4,
|
||||
backends: [{ id: "fake", enabled: true }],
|
||||
},
|
||||
dbClient: client,
|
||||
recoveryRunIds: [],
|
||||
sessionAdapter: adapter,
|
||||
connectionFactory: async () => fakeConnection(),
|
||||
workerFactory: async () => fakeWorker(),
|
||||
});
|
||||
try {
|
||||
await expect(
|
||||
startWorker({
|
||||
config: {
|
||||
DATABASE_URL: databaseUrl,
|
||||
LOG_LEVEL: "info",
|
||||
TEMPORAL_ADDRESS: "localhost:7233",
|
||||
WORKSPACE_ROOT: realpathSync(mkdtempSync(join(tmpdir(), "devflow-worker-workspace-"))),
|
||||
MAX_CONCURRENT_RUNS: 4,
|
||||
backends: [{ id: "fake", enabled: true }],
|
||||
},
|
||||
dbClient: client,
|
||||
recoveryRunIds: [],
|
||||
connectionFactory: async () => fakeConnection(),
|
||||
workerFactory: async () => fakeWorker(),
|
||||
}),
|
||||
).rejects.toMatchObject({ code: "session_manager_already_running" });
|
||||
} finally {
|
||||
await first.shutdown();
|
||||
}
|
||||
});
|
||||
|
||||
it("drains SessionManager resources when the Temporal worker run loop stops", async () => {
|
||||
client = createDbClient(databaseUrl);
|
||||
const workspaceRoot = realpathSync(mkdtempSync(join(tmpdir(), "devflow-worker-run-")));
|
||||
tempRoots.push(workspaceRoot);
|
||||
const connection = countingConnection();
|
||||
const runtime = countingWorker();
|
||||
const worker = await startWorkerWhenLockFree({
|
||||
config: {
|
||||
DATABASE_URL: databaseUrl,
|
||||
LOG_LEVEL: "info",
|
||||
TEMPORAL_ADDRESS: "localhost:7233",
|
||||
WORKSPACE_ROOT: workspaceRoot,
|
||||
MAX_CONCURRENT_RUNS: 4,
|
||||
backends: [{ id: "fake", enabled: true }],
|
||||
},
|
||||
dbClient: client,
|
||||
recoveryRunIds: [],
|
||||
connectionFactory: async () => connection,
|
||||
workerFactory: async () => runtime,
|
||||
});
|
||||
|
||||
await worker.run();
|
||||
expect(runtime.runs).toBe(1);
|
||||
expect(runtime.shutdowns).toBe(1);
|
||||
expect(connection.closes).toBe(1);
|
||||
|
||||
const next = await startWorkerWhenLockFree({
|
||||
config: {
|
||||
DATABASE_URL: databaseUrl,
|
||||
LOG_LEVEL: "info",
|
||||
TEMPORAL_ADDRESS: "localhost:7233",
|
||||
WORKSPACE_ROOT: workspaceRoot,
|
||||
MAX_CONCURRENT_RUNS: 4,
|
||||
backends: [{ id: "fake", enabled: true }],
|
||||
},
|
||||
dbClient: client,
|
||||
recoveryRunIds: [],
|
||||
connectionFactory: async () => fakeConnection(),
|
||||
workerFactory: async () => fakeWorker(),
|
||||
});
|
||||
await next.shutdown();
|
||||
});
|
||||
});
|
||||
|
||||
function fakeConnection() {
|
||||
return {
|
||||
close: async () => undefined,
|
||||
};
|
||||
}
|
||||
|
||||
function fakeWorker() {
|
||||
return {
|
||||
run: async () => undefined,
|
||||
shutdown: () => undefined,
|
||||
};
|
||||
}
|
||||
|
||||
function countingConnection() {
|
||||
return {
|
||||
closes: 0,
|
||||
async close() {
|
||||
this.closes += 1;
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function countingWorker() {
|
||||
return {
|
||||
runs: 0,
|
||||
shutdowns: 0,
|
||||
async run() {
|
||||
this.runs += 1;
|
||||
},
|
||||
shutdown() {
|
||||
this.shutdowns += 1;
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
async function startWorkerWhenLockFree(options: Parameters<typeof startWorker>[0]) {
|
||||
const deadline = Date.now() + 6_000;
|
||||
let lastError: unknown;
|
||||
while (Date.now() < deadline) {
|
||||
try {
|
||||
return await startWorker(options);
|
||||
} catch (error) {
|
||||
lastError = error;
|
||||
if (!(error instanceof DevflowError) || error.code !== "session_manager_already_running") {
|
||||
throw error;
|
||||
}
|
||||
await new Promise((resolveWait) => setTimeout(resolveWait, 50));
|
||||
}
|
||||
}
|
||||
throw lastError;
|
||||
}
|
||||
127
apps/worker/src/index.ts
Normal file
127
apps/worker/src/index.ts
Normal file
@@ -0,0 +1,127 @@
|
||||
import { fileURLToPath } from "node:url";
|
||||
|
||||
import { type Config, DevflowError, getConfig } from "@devflow/core";
|
||||
import { type DbClient, createDbClient } from "@devflow/db";
|
||||
import { FakeSessionAdapter, type SessionAdapter, SessionManager } from "@devflow/session";
|
||||
import { NativeConnection, Worker } from "@temporalio/worker";
|
||||
|
||||
import { createDevflowActivities, temporalTaskQueue } from "@devflow/workflows";
|
||||
|
||||
interface WorkerConnection {
|
||||
close(): Promise<void>;
|
||||
}
|
||||
|
||||
interface WorkerRuntime {
|
||||
run(): Promise<void>;
|
||||
shutdown(): void | Promise<void>;
|
||||
}
|
||||
|
||||
export interface StartWorkerOptions {
|
||||
config?: Config;
|
||||
dbClient?: DbClient;
|
||||
sessionAdapter?: SessionAdapter;
|
||||
recoveryRunIds?: readonly string[];
|
||||
temporalAddress?: string;
|
||||
taskQueue?: string;
|
||||
connectionFactory?: (options: { address: string }) => Promise<WorkerConnection>;
|
||||
workerFactory?: (options: Parameters<typeof Worker.create>[0]) => Promise<WorkerRuntime>;
|
||||
}
|
||||
|
||||
export async function startWorker(options: StartWorkerOptions = {}) {
|
||||
const config = options.config ?? getConfig();
|
||||
const ownedClient = options.dbClient === undefined;
|
||||
const dbClient = options.dbClient ?? createDbClient(config.DATABASE_URL);
|
||||
const sessionManager = new SessionManager({
|
||||
dbClient,
|
||||
adapter: options.sessionAdapter ?? new FakeSessionAdapter(),
|
||||
...(options.recoveryRunIds === undefined ? {} : { recoveryRunIds: options.recoveryRunIds }),
|
||||
});
|
||||
let connection: WorkerConnection | undefined;
|
||||
let worker: WorkerRuntime | undefined;
|
||||
|
||||
try {
|
||||
const recovery = await sessionManager.initialize();
|
||||
connection = await (options.connectionFactory ?? NativeConnection.connect)({
|
||||
address: options.temporalAddress ?? config.TEMPORAL_ADDRESS,
|
||||
});
|
||||
worker = await (options.workerFactory ?? Worker.create)({
|
||||
activities: createDevflowActivities({
|
||||
db: dbClient.db,
|
||||
sessions: sessionManager,
|
||||
workspaceRoot: config.WORKSPACE_ROOT,
|
||||
availableBackends: config.backends,
|
||||
maxConcurrentRuns: config.MAX_CONCURRENT_RUNS,
|
||||
}),
|
||||
connection: connection as NativeConnection,
|
||||
namespace: "devflow",
|
||||
taskQueue: options.taskQueue ?? temporalTaskQueue,
|
||||
workflowsPath: fileURLToPath(
|
||||
new URL("../../../packages/workflows/src/workflow.ts", import.meta.url),
|
||||
),
|
||||
});
|
||||
|
||||
const startedWorker = worker;
|
||||
const startedConnection = connection;
|
||||
if (startedWorker === undefined || startedConnection === undefined) {
|
||||
throw new DevflowError("Temporal worker failed to initialize", {
|
||||
class: "fatal",
|
||||
code: "internal_state_corruption",
|
||||
});
|
||||
}
|
||||
let shutdownPromise: Promise<void> | undefined;
|
||||
const shutdown = () => {
|
||||
shutdownPromise ??= (async () => {
|
||||
await Promise.resolve(startedWorker.shutdown());
|
||||
await sessionManager.shutdown();
|
||||
await startedConnection.close();
|
||||
if (ownedClient) {
|
||||
await dbClient.close();
|
||||
}
|
||||
})();
|
||||
return shutdownPromise;
|
||||
};
|
||||
return {
|
||||
recovery,
|
||||
async run() {
|
||||
try {
|
||||
await startedWorker.run();
|
||||
} finally {
|
||||
await shutdown();
|
||||
}
|
||||
},
|
||||
shutdown,
|
||||
};
|
||||
} catch (error) {
|
||||
if (worker !== undefined) {
|
||||
await Promise.resolve(worker.shutdown()).catch(() => undefined);
|
||||
}
|
||||
if (connection !== undefined) {
|
||||
await connection.close().catch(() => undefined);
|
||||
}
|
||||
await sessionManager.shutdown().catch(() => undefined);
|
||||
if (ownedClient) {
|
||||
await dbClient.close().catch(() => undefined);
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
if (process.argv[1] === fileURLToPath(import.meta.url)) {
|
||||
startWorker()
|
||||
.then(async (worker) => {
|
||||
const requestShutdown = () => {
|
||||
void worker.shutdown().catch((error: unknown) => {
|
||||
console.error(error);
|
||||
process.exitCode = 2;
|
||||
});
|
||||
};
|
||||
process.once("SIGINT", requestShutdown);
|
||||
process.once("SIGTERM", requestShutdown);
|
||||
await worker.run();
|
||||
})
|
||||
.catch((error: unknown) => {
|
||||
console.error(error);
|
||||
process.exitCode =
|
||||
error instanceof DevflowError && error.code === "session_manager_already_running" ? 3 : 2;
|
||||
});
|
||||
}
|
||||
15
apps/worker/tsconfig.json
Normal file
15
apps/worker/tsconfig.json
Normal file
@@ -0,0 +1,15 @@
|
||||
{
|
||||
"extends": "../../tsconfig.base.json",
|
||||
"compilerOptions": {
|
||||
"rootDir": "src",
|
||||
"outDir": "dist",
|
||||
"types": ["node", "vitest"]
|
||||
},
|
||||
"include": ["src/**/*.ts"],
|
||||
"references": [
|
||||
{ "path": "../../packages/core" },
|
||||
{ "path": "../../packages/db" },
|
||||
{ "path": "../../packages/session" },
|
||||
{ "path": "../../packages/workflows" }
|
||||
]
|
||||
}
|
||||
Reference in New Issue
Block a user