feat: add temporal run engine integration

This commit is contained in:
chungyeong
2026-05-13 08:39:19 +09:00
parent 78ebd5ef78
commit aa3033771a
37 changed files with 7338 additions and 224 deletions

19
apps/worker/package.json Normal file
View 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"
}
}

View 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
View 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
View 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" }
]
}