Repository: briefercloud/briefer (language: python)

Issue:
give table info direct to ai api via payload



Discussion / hints:

Fix patch:
diff --git a/apps/api/package.json b/apps/api/package.json
index 6077e2d5..44c5497b 100644
--- a/apps/api/package.json
+++ b/apps/api/package.json
@@ -5,9 +5,9 @@
   "type": "module",
   "scripts": {
     "start": "node ./dist/index.js",
-    "dev": "nodemon",
+    "dev": "cross-env PORT=${PORT:-8081} nodemon",
     "build": "tsc",
-    "test": "jest"
+    "test": "jest --passWithNoTests"
   },
   "dependencies": {
     "@briefer/database": "*",
@@ -21,6 +21,7 @@
     "@sindresorhus/slugify": "^2.2.1",
     "@slack/oauth": "^3.0.0",
     "@slack/web-api": "^7.0.4",
+    "@socket.io/postgres-adapter": "^0.4.0",
     "@tiptap/core": "^2.1.15",
     "aggregate-error": "^5.0.0",
     "aws-sdk": "^2.1618.0",
@@ -77,11 +78,11 @@
     "@types/cors": "^2.8.17",
     "@types/d3-format": "^3.0.4",
     "@types/deep-diff": "^1.0.5",
-    "@types/mssql": "^9.1.5",
     "@types/express": "^4.17.21",
     "@types/jest": "^29.5.12",
     "@types/jsonwebtoken": "^9.0.5",
     "@types/lodash.debounce": "^4.0.9",
+    "@types/mssql": "^9.1.5",
     "@types/multer": "^1.4.10",
     "@types/node": "^20.9.0",
     "@types/node-fetch": "^2.6.9",
@@ -90,6 +91,7 @@
     "@types/split2": "^4.2.3",
     "@types/uuid": "^9.0.7",
     "@types/ws": "^8.5.9",
+    "cross-env": "^7.0.3",
     "deep-diff": "^1.0.2",
     "jest": "^29.7.0",
     "nodemon": "^3.0.1",
diff --git a/apps/api/src/ai-api.ts b/apps/api/src/ai-api.ts
index 89d2148f..31e99efc 100644
--- a/apps/api/src/ai-api.ts
+++ b/apps/api/src/ai-api.ts
@@ -1,4 +1,4 @@
-import axios from 'axios'
+import axios, { CanceledError } from 'axios'
 import split2 from 'split2'
 import { config } from './config/index.js'
 import { z } from 'zod'
@@ -23,7 +23,7 @@ export async function sqlEditStreamed(
   abortController: AbortController
 }> {
   const abortController = new AbortController()
-  const response = await axios.post(
+  const responseP = axios.post(
     `${config().AI_API_URL}/v1/stream/sql/edit`,
     {
       query,
@@ -48,27 +48,37 @@ export async function sqlEditStreamed(
   return {
     abortController,
     promise: new Promise(async (resolve, reject) => {
-      let success = false
-      let error: Error | null = null
-      response.data
-        .pipe(split2(JSON.parse))
-        .on('data', (obj: any) => {
-          const parse = z.object({ sql: z.string() }).safeParse(obj)
-          if (parse.success) {
-            onSQL(parse.data.sql)
-            success = true
-          } else {
-            error = parse.error
-          }
-        })
-        .on('error', reject)
-        .on('finish', () => {
-          if (!success) {
-            reject(error ?? new Error('Got no data'))
-          } else {
-            resolve()
-          }
-        })
+      try {
+        const response = await responseP
+
+        let success = false
+        let error: Error | null = null
+        response.data
+          .pipe(split2(JSON.parse))
+          .on('data', (obj: any) => {
+            const parse = z.object({ sql: z.string() }).safeParse(obj)
+            if (parse.success) {
+              onSQL(parse.data.sql)
+              success = true
+            } else {
+              error = parse.error
+            }
+          })
+          .on('error', reject)
+          .on('finish', () => {
+            if (!success) {
+              reject(error ?? new Error('Got no data'))
+            } else {
+              resolve()
+            }
+          })
+      } catch (e) {
+        if (e instanceof CanceledError) {
+          resolve()
+          return
+        }
+        reject(e)
+      }
     }),
   }
 }
@@ -117,14 +127,18 @@ export async function pythonEditStreamed(
   onSource: (source: string) => void,
   modelId: string | null,
   openaiApiKey: string | null
-): Promise<void> {
+): Promise<{
+  promise: Promise<void>
+  abortController: AbortController
+}> {
   const allowedLibraries = (config().PYTHON_ALLOWED_LIBRARIES ?? '')
     .split(',')
     .map((s) => s.trim())
 
   const variables = dataframesToPython(dataFrames)
 
-  const response = await axios.post(
+  const abortController = new AbortController()
+  const responseP = axios.post(
     `${config().AI_API_URL}/v1/stream/python/edit`,
     {
       source,
@@ -145,27 +159,39 @@ export async function pythonEditStreamed(
     }
   )
 
-  return new Promise(async (resolve, reject) => {
-    let success = false
-    let error: Error | null = null
-    response.data
-      .pipe(split2(JSON.parse))
-      .on('data', (obj: any) => {
-        const parse = z.object({ source: z.string() }).safeParse(obj)
-        if (parse.success) {
-          onSource(parse.data.source)
-          success = true
-        } else {
-          error = parse.error
-        }
-      })
-      .on('error', reject)
-      .on('finish', () => {
-        if (!success) {
-          reject(error ?? new Error('Got no data'))
-        } else {
+  return {
+    abortController,
+    promise: new Promise<void>(async (resolve, reject) => {
+      try {
+        const response = await responseP
+        let success = false
+        let error: Error | null = null
+        response.data
+          .pipe(split2(JSON.parse))
+          .on('data', (obj: any) => {
+            const parse = z.object({ source: z.string() }).safeParse(obj)
+            if (parse.success) {
+              onSource(parse.data.source)
+              success = true
+            } else {
+              error = parse.error
+            }
+          })
+          .on('error', reject)
+          .on('finish', () => {
+            if (!success) {
+              reject(error ?? new Error('Got no data'))
+            } else {
+              resolve()
+            }
+          })
+      } catch (e) {
+        if (e instanceof CanceledError) {
           resolve()
+          return
         }
-      })
-  })
+        reject(e)
+      }
+    }),
+  }
 }
diff --git a/apps/api/src/events/index.ts b/apps/api/src/events/index.ts
index 33447d24..35d677b8 100644
--- a/apps/api/src/events/index.ts
+++ b/apps/api/src/events/index.ts
@@ -1,9 +1,5 @@
-import * as Y from 'yjs'
-import { ApiUser } from '@briefer/database'
-
-interface AIEvents {
+export interface AIEvents {
   aiUsage: (
-    ctx: EventContext,
     type: 'sql' | 'python',
     action: 'edit' | 'fix',
     modelId: string | null
@@ -11,23 +7,23 @@ interface AIEvents {
 }
 
 export interface PythonEvents extends AIEvents {
-  pythonRun: (ctx: EventContext) => void
+  pythonRun: () => void
 }
 
 export interface SQLEvents extends AIEvents {
-  sqlRun: (ctx: EventContext) => void
+  sqlRun: () => void
 }
 
 export interface VisEvents {
-  visUpdate: (ctx: EventContext, chartType: string) => void
+  visUpdate: (chartType: string) => void
 }
 
 export interface WritebackEvents extends AIEvents {
-  writeback: (ctx: EventContext) => void
+  writeback: () => void
 }
 
 export interface NotebookBlockEvents {
-  blockAdd: (ctx: EventContext, blockType: string) => void
+  blockAdd: (blockType: string) => void
 }
 export interface NotebookEvents
   extends PythonEvents,
@@ -35,18 +31,3 @@ export interface NotebookEvents
     VisEvents,
     WritebackEvents,
     NotebookBlockEvents {}
-
-export class EventContext {
-  constructor(
-    public readonly user: ApiUser | null,
-    public readonly scheduleId: string | null = null
-  ) {}
-
-  static fromYTransaction(tr: Y.Transaction): EventContext {
-    const user = tr.origin && 'user' in tr.origin ? tr.origin.user : null
-    const scheduleId =
-      tr.origin && 'scheduleId' in tr.origin ? tr.origin.scheduleId : null
-
-    return new EventContext(user, scheduleId)
-  }
-}
diff --git a/apps/api/src/events/schedule.ts b/apps/api/src/events/schedule.ts
index 444ed83a..6ea93d6f 100644
--- a/apps/api/src/events/schedule.ts
+++ b/apps/api/src/events/schedule.ts
@@ -1,7 +1,7 @@
 import { NotebookEvents } from './index.js'
 
 export class ScheduleNotebookEvents implements NotebookEvents {
-  public constructor() {}
+  public constructor(private readonly scheduleId: string) {}
 
   public pythonRun() {}
 
diff --git a/apps/api/src/events/user.ts b/apps/api/src/events/user.ts
index ac87c3d4..63b4af83 100644
--- a/apps/api/src/events/user.ts
+++ b/apps/api/src/events/user.ts
@@ -1,29 +1,20 @@
-import { logger } from '../logger.js'
-import { EventContext, NotebookEvents } from './index.js'
+import { ApiUser } from '@briefer/database'
+import { NotebookEvents } from './index.js'
 import { capturePythonRun, captureSQLRun } from './posthog.js'
 
 export class UserNotebookEvents implements NotebookEvents {
   public constructor(
     private readonly workspaceId: string,
-    private readonly documentId: string
+    private readonly documentId: string,
+    private readonly user: ApiUser
   ) {}
 
-  public pythonRun(ctx: EventContext) {
-    const user = this.getUser(ctx, 'pythonRun')
-    if (!user) {
-      return
-    }
-
-    capturePythonRun(user, this.workspaceId, this.documentId)
+  public pythonRun() {
+    capturePythonRun(this.user, this.workspaceId, this.documentId)
   }
 
-  public sqlRun(ctx: EventContext) {
-    const user = this.getUser(ctx, 'sqlRun')
-    if (!user) {
-      return
-    }
-
-    captureSQLRun(user, this.workspaceId, this.documentId)
+  public sqlRun() {
+    captureSQLRun(this.user, this.workspaceId, this.documentId)
   }
 
   public visUpdate() {}
@@ -33,24 +24,4 @@ export class UserNotebookEvents implements NotebookEvents {
   public aiUsage() {}
 
   public blockAdd() {}
-
-  private getUser(
-    ctx: EventContext,
-    eventName: string,
-    logProps?: Record<string, any>
-  ) {
-    if (!ctx.user) {
-      logger().error(
-        {
-          workspaceId: this.workspaceId,
-          documentId: this.documentId,
-          ...(logProps ?? {}),
-        },
-        `User not provided to UserNotebookEvents.${eventName}`
-      )
-      return null
-    }
-
-    return ctx.user
-  }
 }
diff --git a/apps/api/src/index.ts b/apps/api/src/index.ts
index 7106bca2..e09b22f7 100644
--- a/apps/api/src/index.ts
+++ b/apps/api/src/index.ts
@@ -52,7 +52,7 @@ async function main() {
   const server = http.createServer(app)
 
   let shutdownFunctions: (() => Promise<void> | void)[] = []
-  const socketServer = createSocketServer(server)
+  const socketServer = await createSocketServer(server)
   shutdownFunctions.push(() => socketServer.shutdown())
 
   const stopSchedules = await runSchedule(socketServer.io)
@@ -122,8 +122,9 @@ async function main() {
     res.end(res.sentry + '\n')
   })
 
-  server.listen(8080, () => {
-    logger().info('Server is running on port 8080')
+  const port = process.env['PORT'] || 8080
+  server.listen(port, () => {
+    logger().info(`Server is running on port ${port}`)
   })
 
   const jupyterManager = getJupyterManager()
diff --git a/apps/api/src/jupyter/manager.ts b/apps/api/src/jupyter/manager.ts
index a093be8b..b6078916 100644
--- a/apps/api/src/jupyter/manager.ts
+++ b/apps/api/src/jupyter/manager.ts
@@ -224,7 +224,7 @@ export class JupyterManager implements IJupyterManager {
     const result = await this.jupyterExtension.deleteFile(
       await this.getFilepath(fileName)
     )
-    if (result._tag === 'error') {
+    if (result._tag === 'error' && result.reason !== 'not-found') {
       throw new Error(`Failed to delete file: ${result.reason}`)
     }
   }
diff --git a/apps/api/src/lock.ts b/apps/api/src/lock.ts
index 939b8db4..ca853eaa 100644
--- a/apps/api/src/lock.ts
+++ b/apps/api/src/lock.ts
@@ -1,101 +1,160 @@
-import prisma from '@briefer/database'
 import { v4 as uuidv4 } from 'uuid'
-import { z } from 'zod'
+import prisma from '@briefer/database'
 import { logger } from './logger.js'
+import { z } from 'zod'
+import { exhaustiveCheck } from '@briefer/types'
+
+const EXPIRATION_TIME = 1000 * 5 // 5 seconds
+const MAX_RETRY_TIMEOUT = 1000 * 2 // 2 seconds
+const DEFAULT_ACQUIRE_TIMEOUT = 1000 * 10 // 10 seconds
+
+class AlreadyAcquiredError extends Error {
+  constructor(public readonly lockName: string) {
+    super(`Lock ${lockName} is already acquired.`)
+    this.name = 'AlreadyAcquiredError'
+  }
+}
+
+export class AcquireLockTimeoutError extends Error {
+  constructor(
+    public readonly name: string,
+    public readonly ownerId: string,
+    public readonly startTime: number,
+    public readonly acquireTimeout: number,
+    public readonly attempt: number
+  ) {
+    super(
+      `Failed to acquire lock ${name} with ownerId ${ownerId} after ${acquireTimeout}ms and ${attempt} attempts.`
+    )
+    this.name = 'AcquireLockTimeoutError'
+  }
+}
 
-const INTERVAL_MS = 5000
 export async function acquireLock<T>(
   name: string,
   cb: () => Promise<T>,
-  expirationTimeMs: number = 30000
+  { acquireTimeout = DEFAULT_ACQUIRE_TIMEOUT }: { acquireTimeout?: number } = {}
 ): Promise<T> {
-  const now = new Date()
-  const expiresAt = new Date(now.getTime() + expirationTimeMs)
+  const startTime = Date.now()
   const ownerId = uuidv4()
 
-  let interval: NodeJS.Timeout | null = null
-  try {
-    let attempt = 1
-    while (true) {
-      logger().debug({ name, ownerId, attempt }, 'attempting to acquire lock')
-      try {
-        await prisma().lock.upsert({
-          where: {
+  const inner = async (attempt: number): Promise<T> => {
+    if (Date.now() - startTime > acquireTimeout) {
+      throw new AcquireLockTimeoutError(
+        name,
+        ownerId,
+        startTime,
+        acquireTimeout,
+        attempt
+      )
+    }
+
+    logger().trace({ name, ownerId, attempt }, 'Acquiring lock')
+    try {
+      const lock = await prisma().lock.findFirst({
+        where: {
+          name,
+        },
+      })
+      if (!lock) {
+        // this is safe because if someone else creates the lock in the meantime
+        // this will raise a unique constraint error that we catch below to retry
+        await prisma().lock.create({
+          data: {
             name,
-            OR: [
-              { isLocked: false },
-              {
-                expiresAt: {
-                  lte: now,
-                },
-              },
-            ],
-          },
-          update: {
             isLocked: true,
-            acquiredAt: now,
-            expiresAt,
             ownerId,
+            expiresAt: new Date(Date.now() + EXPIRATION_TIME),
+            acquiredAt: new Date(),
           },
-          create: {
-            name,
+        })
+      } else if (!lock.isLocked || lock.expiresAt < new Date()) {
+        // this is safe because if someone else updates the lock in the meantime
+        // this will fail to find the lock to update because expiresAt will be changed
+        // that will raise a not found error that we catch below to retry
+        await prisma().lock.update({
+          where: {
+            id: lock.id,
+            expiresAt: lock.expiresAt,
+          },
+          data: {
             isLocked: true,
-            acquiredAt: now,
-            expiresAt: expiresAt,
             ownerId,
+            expiresAt: new Date(Date.now() + EXPIRATION_TIME),
+            acquiredAt: new Date(),
           },
         })
-        logger().debug({ name, ownerId }, 'lock acquired')
-
-        interval = setInterval(async () => {
-          logger().debug({ name, ownerId }, 'incrementing lock expiration time')
-          await prisma().lock.update({
-            where: {
-              name,
-              ownerId,
-            },
-            data: {
-              expiresAt: new Date(new Date().getTime() + INTERVAL_MS),
-            },
-          })
-        }, INTERVAL_MS)
-
-        const r = await cb()
-        clearInterval(interval)
-        return r
-      } catch (err) {
-        // catch unique constraint violation
-        if (z.object({ code: z.literal('P2002') }).safeParse(err).success) {
-          logger().debug({ name, ownerId }, 'lock already acquired, retrying')
-          await new Promise((resolve) => setTimeout(resolve, 200))
-          attempt++
-          continue
+      } else {
+        // lock is already acquired
+        throw new AlreadyAcquiredError(name)
+      }
+    } catch (err) {
+      let code = ''
+      if (err instanceof AlreadyAcquiredError) {
+        code = 'AlreadyAcquiredError'
+      } else {
+        const parsed = z
+          .object({ code: z.union([z.literal('P2002'), z.literal('P2025')]) })
+          .safeParse(err)
+        if (parsed.success) {
+          switch (parsed.data.code) {
+            case 'P2002':
+              code = 'UniqueConstraintError'
+              break
+            case 'P2025':
+              code = 'NotFound'
+              break
+            default:
+              exhaustiveCheck(parsed.data.code)
+          }
         }
+      }
 
-        if (!interval) {
-          logger().error(
-            {
-              name,
-              ownerId,
-              err,
-            },
-            'error acquiring lock'
-          )
-        }
-        throw err
+      if (code !== '') {
+        const timeout = Math.min(MAX_RETRY_TIMEOUT, Math.pow(2, attempt) * 100)
+        logger().trace(
+          { name, ownerId, attempt, code, timeout },
+          'Lock is already acquired. Retrying.'
+        )
+        await new Promise((resolve) => setTimeout(resolve, timeout))
+        return inner(attempt + 1)
       }
+
+      logger().error({ name, ownerId, err }, 'Failed to acquire lock')
+      throw err
     }
-  } finally {
-    logger().debug({ name, ownerId }, 'releasing lock')
-    if (interval) {
-      clearInterval(interval)
+
+    const extendExpirationInterval = setInterval(async () => {
+      await prisma().lock.updateMany({
+        where: {
+          name,
+          ownerId,
+        },
+        data: {
+          expiresAt: new Date(Date.now() + EXPIRATION_TIME),
+        },
+      })
+    }, EXPIRATION_TIME / 3)
+
+    logger().debug({ name, ownerId }, 'Lock acquired')
+
+    try {
+      return await cb()
+    } finally {
+      logger().trace({ name, ownerId }, 'Releasing lock')
+      clearInterval(extendExpirationInterval)
+      await prisma().lock.updateMany({
+        where: {
+          name,
+          ownerId,
+        },
+        data: {
+          isLocked: false,
+        },
+      })
+      logger().debug({ name, ownerId }, 'Lock released')
     }
-    await prisma().lock.deleteMany({
-      where: {
-        name,
-        ownerId,
-      },
-    })
-    logger().debug({ name, ownerId }, 'lock released')
   }
+
+  return inner(0)
 }
diff --git a/apps/api/src/python/query/index.ts b/apps/api/src/python/query/index.ts
index c1dddb1b..eb7238ff 100644
--- a/apps/api/src/python/query/index.ts
+++ b/apps/api/src/python/query/index.ts
@@ -251,6 +251,13 @@ export async function makeQuery(
   abortFns.push(abortQuery)
 
   const resultPromise = queryPromise.then(async (): Promise<RunQueryResult> => {
+    if (aborted) {
+      return {
+        type: 'abort-error',
+        message: 'Query aborted',
+      }
+    }
+
     if (error) {
       throw error
     }
@@ -317,8 +324,10 @@ del _briefer_read_query`
 
   const abortFunction = async () => {
     aborted = true
-    await Promise.all(abortFns.map((fn) => fn()))
-    await getJupyterManager().deleteFile(workspaceId, flagFilePath)
+    await Promise.all([
+      getJupyterManager().deleteFile(workspaceId, flagFilePath),
+      ...abortFns.map((fn) => fn()),
+    ])
 
     await queryPromise
   }
diff --git a/apps/api/src/schedule/index.ts b/apps/api/src/schedule/index.ts
index a9b4bae5..ffc4691a 100644
--- a/apps/api/src/schedule/index.ts
+++ b/apps/api/src/schedule/index.ts
@@ -1,4 +1,3 @@
-import * as Y from 'yjs'
 import {
   prisma,
   Document,
@@ -10,12 +9,10 @@ import { CronJob } from 'cron'
 import { logger } from '../logger.js'
 import { IOServer } from '../websocket/index.js'
 import * as yjs from '../yjs/v2/index.js'
-import { RunAllExecutor } from '../yjs/v2/executors/run-all.js'
-import PQueue from 'p-queue'
-import { AppPersistor, DocumentPersistor } from '../yjs/v2/persistors.js'
-import { YBlock } from '@briefer/editor'
-import { ScheduleNotebookEvents } from '../events/schedule.js'
+import { AppPersistor } from '../yjs/v2/persistors.js'
+import { ExecutionQueue } from '@briefer/editor'
 import { updateAppState } from '../yjs/v2/documents.js'
+import { acquireLock } from '../lock.js'
 
 function convertToCron(schedule: ExecutionSchedule): string {
   switch (schedule.type) {
@@ -187,33 +184,57 @@ export async function runSchedule(socketServer: IOServer) {
       counters.deleted++
     }
 
-    logger().info(
-      {
-        created: counters.created,
-        updated: counters.updated,
-        deleted: counters.deleted,
-        unchanged: counters.unchanged,
-        module: 'schedule',
-      },
-      'Updated schedules'
-    )
+    if (counters.created > 0 || counters.updated > 0 || counters.deleted > 0) {
+      logger().info(
+        {
+          created: counters.created,
+          updated: counters.updated,
+          deleted: counters.deleted,
+          unchanged: counters.unchanged,
+          module: 'schedule',
+        },
+        'Updated schedules'
+      )
+    }
   }
 
   let stop = false
   const loop = new Promise<void>(async (resolve) => {
-    while (true) {
-      try {
-        await updateSchedule()
-      } catch (err) {
-        logger().error({ err, module: 'schedule' }, 'Failed to update schedule')
-      }
+    logger().trace('Acquiring lock to be the schedule executor')
+    await acquireLock(
+      'schedule-executor',
+      async () => {
+        if (stop) {
+          logger().trace(
+            'Schedule executor lock acquired but server is shutting down'
+          )
+          return
+        }
 
-      if (stop) {
-        break
-      }
+        logger().trace('Schedule executor lock acquired')
+        while (true) {
+          if (stop) {
+            break
+          }
 
-      await new Promise((resolve) => setTimeout(resolve, 5000))
-    }
+          try {
+            await updateSchedule()
+          } catch (err) {
+            logger().error(
+              { err, module: 'schedule' },
+              'Failed to update schedule'
+            )
+          }
+
+          if (stop) {
+            break
+          }
+
+          await new Promise((resolve) => setTimeout(resolve, 5000))
+        }
+      },
+      { acquireTimeout: Infinity }
+    )
 
     resolve()
   })
@@ -271,56 +292,34 @@ async function executeNotebook(
   scheduleId: string,
   socketServer: IOServer,
   doc: Document,
-  app?: YjsAppDocument
-): Promise<YBlock | null> {
-  const events = new ScheduleNotebookEvents()
-
-  while (true) {
+  app: YjsAppDocument
+): Promise<void> {
+  let emptyLayout = true
+  while (emptyLayout) {
     const id = yjs.getDocId(doc.id, app ? { id: app.id, userId: null } : null)
-    const failedBlock = await yjs.getYDocForUpdate(
+    emptyLayout = await yjs.getYDocForUpdate(
       id,
       socketServer,
       doc.id,
       doc.workspaceId,
       async (ydoc) => {
-        const runAll = ydoc.runAll
-        runAll.setAttribute('status', 'schedule-running')
-
         if (ydoc.layout.length === 0) {
-          return undefined
+          return true
         }
 
-        const executor = RunAllExecutor.make(
-          doc.workspaceId,
-          doc.id,
-          ydoc.blocks,
-          ydoc.layout,
-          ydoc.runAll,
-          ydoc.dataframes,
-          new PQueue({ concurrency: 1 }),
-          events
-        )
-        try {
-          const tr = new Y.Transaction(ydoc.ydoc, { scheduleId }, true)
-          const failedBlock = await executor.run(tr)
-          runAll.setAttribute('status', 'idle')
-          await ydoc.persist(false)
-
-          if (app) {
-            await updateAppState(ydoc, app, socketServer)
-          }
-
-          return failedBlock
-        } catch (e) {
-          runAll.setAttribute('status', 'idle')
-          throw e
-        }
+        const executionQueue = ExecutionQueue.fromYjs(ydoc.ydoc)
+        const batch = executionQueue.enqueueRunAll(ydoc.layout, ydoc.blocks, {
+          _tag: 'schedule',
+          scheduleId,
+        })
+        await batch.waitForCompletion()
+        await updateAppState(ydoc, app, socketServer)
+        return false
       },
-      app
-        ? new AppPersistor(app.id, null) // user is null when running a schedule
-        : new DocumentPersistor(doc.id)
+      new AppPersistor(app.id, null) // user is null when running a schedule
     )
-    if (failedBlock === undefined) {
+
+    if (emptyLayout) {
       logger().error(
         {
           documentId: doc.id,
@@ -331,9 +330,6 @@ async function executeNotebook(
         'doc had empty layout, retrying'
       )
       await new Promise((resolve) => setTimeout(resolve, 1000))
-      continue
     }
-
-    return failedBlock
   }
 }
diff --git a/apps/api/src/websocket/index.ts b/apps/api/src/websocket/index.ts
index 3a1e99ec..12be33c3 100644
--- a/apps/api/src/websocket/index.ts
+++ b/apps/api/src/websocket/index.ts
@@ -1,3 +1,4 @@
+import { createAdapter } from '@socket.io/postgres-adapter'
 import http from 'http'
 import { config } from '../config/index.js'
 import { sessionFromCookies } from '../auth/token.js'
@@ -8,6 +9,7 @@ import {
   ApiDocument,
   APIReusableComponent,
   EnvironmentStatus,
+  getPGInstance,
 } from '@briefer/database'
 import {
   Comment,
@@ -107,11 +109,21 @@ type Server = {
   shutdown: () => Promise<void>
 }
 
-export function createSocketServer(server: http.Server): Server {
+export async function createSocketServer(server: http.Server): Promise<Server> {
   const io: IOServer = new BaseServer(server, {
     cors: { credentials: true, origin: config().FRONTEND_URL },
   })
 
+  const { pool } = await getPGInstance()
+  io.adapter(
+    createAdapter(pool, {
+      tableName: 'socket_io_attachments',
+      errorHandler: (err) => {
+        logger().error({ err }, 'Error in @socket.io/postgres-adapter adapter')
+      },
+    })
+  )
+
   io.use(async (socket: Socket, next) => {
     try {
       const cookiesHeader = socket.handshake.headers.cookie
@@ -212,6 +224,10 @@ export function createSocketServer(server: http.Server): Server {
     })
   })
 
+  io.on('error', (err) => {
+    logger().error({ err }, 'Socket server error occurred')
+  })
+
   return {
     io: io,
     shutdown: async () => {
diff --git a/apps/api/src/yjs/v2/documents.ts b/apps/api/src/yjs/v2/documents.ts
index 9ee47bb9..68457aca 100644
--- a/apps/api/src/yjs/v2/documents.ts
+++ b/apps/api/src/yjs/v2/documents.ts
@@ -183,13 +183,11 @@ export async function updateAppState(
   app: YjsAppDocument,
   socketServer: IOServer
 ) {
-  const state = Y.encodeStateAsUpdate(ydoc.ydoc)
-
+  const state = Buffer.from(Y.encodeStateAsUpdate(ydoc.ydoc))
   const usersApps = await prisma().userYjsAppDocument.findMany({
     where: { yjsAppDocumentId: app.id },
     select: { userId: true },
   })
-
   await Promise.all(
     usersApps.map(async (userApp) =>
       getYDocForUpdate(
diff --git a/apps/api/src/yjs/v2/executor/ai/index.ts b/apps/api/src/yjs/v2/executor/ai/index.ts
new file mode 100644
index 00000000..8a949cda
--- /dev/null
+++ b/apps/api/src/yjs/v2/executor/ai/index.ts
@@ -0,0 +1,302 @@
+import { v4 as uuidv4 } from 'uuid'
+import * as Y from 'yjs'
+import PQueue from 'p-queue'
+import {
+  AI_TASK_PING_TIMEOUT,
+  AITaskItem,
+  AITasks,
+  getBaseAttributes,
+  isPythonBlock,
+  isSQLBlock,
+  YBlock,
+} from '@briefer/editor'
+import { WSSharedDocV2 } from '../../index.js'
+import { logger } from '../../../../logger.js'
+import { exhaustiveCheck } from '@briefer/types'
+import { AIPythonExecutor, IPythonAIExecutor } from './python.js'
+import { AISQLExecutor, ISQLAIExecutor } from './sql.js'
+import { ApiUser, getUserById } from '@briefer/database'
+import { unknownUser } from '../executor.js'
+import { UserNotebookEvents } from '../../../../events/user.js'
+import { acquireLock } from '../../../../lock.js'
+
+class UnexpectedBlockTypeError extends Error {
+  constructor(
+    public readonly blockId: string,
+    public readonly expectedType: string,
+    public readonly actualType: string
+  ) {
+    super(
+      `Block ${blockId} is expected to be of type ${expectedType}, but it is of type ${actualType}`
+    )
+  }
+}
+
+class BlockNotFoundError extends Error {
+  constructor(public readonly blockId: string) {
+    super(`Block ${blockId} not found`)
+  }
+}
+
+export class AIExecutor {
+  private readonly id = uuidv4()
+  private isRunning = false
+  private readonly pQueue = new PQueue({ concurrency: 4 })
+  private timeout: NodeJS.Timeout | null = null
+
+  private constructor(
+    private readonly docId: string,
+    private readonly workspaceId: string,
+    private readonly documentId: string,
+    private readonly tasks: AITasks,
+    private readonly blocks: Y.Map<YBlock>,
+    private readonly pythonAIExecutor: IPythonAIExecutor,
+    private readonly sqlAIExecutor: ISQLAIExecutor
+  ) {}
+
+  public start() {
+    this.isRunning = true
+    this.execute()
+  }
+
+  public isIdle(): boolean {
+    return this.pQueue.size === 0
+  }
+
+  public async stop(): Promise<void> {
+    this.isRunning = false
+
+    if (this.timeout) {
+      clearTimeout(this.timeout)
+    }
+
+    await this.pQueue.onIdle()
+  }
+
+  private async execute() {
+    try {
+      logger().debug(
+        {
+          id: this.id,
+          docId: this.docId,
+          workspaceId: this.workspaceId,
+          documentId: this.documentId,
+        },
+        'Acquiring AI tasks lock'
+      )
+
+      await acquireLock(
+        `ai-tasks:${this.docId}`,
+        () =>
+          new Promise<void>(async (resolve, reject) => {
+            if (!this.isRunning) {
+              logger().debug(
+                {
+                  port: process.env['PORT'],
+                  id: this.id,
+                  docId: this.docId,
+                  workspaceId: this.workspaceId,
+                  documentId: this.documentId,
+                },
+                'AI tasks lock acquired but executor is not running anymore. Exiting.'
+              )
+              resolve()
+              return
+            }
+
+            logger().debug(
+              {
+                id: this.id,
+                docId: this.docId,
+                workspaceId: this.workspaceId,
+                documentId: this.documentId,
+              },
+              'AI tasks lock acquired. Executing AI tasks'
+            )
+
+            const tick = async () => {
+              try {
+                if (!this.isRunning) {
+                  logger().debug(
+                    {
+                      port: process.env['PORT'],
+                      id: this.id,
+                      docId: this.docId,
+                      workspaceId: this.workspaceId,
+                      documentId: this.documentId,
+                    },
+                    'AI Tasks is not running. Stopping consumer loop.'
+                  )
+                  resolve()
+                  return
+                }
+
+                await this.pQueue.onSizeLessThan(this.pQueue.concurrency)
+
+                const next = this.tasks.next()
+                let timeout = 500
+                if (next) {
+                  this.pQueue.add(() => this.executeItem(next))
+                  timeout = 0
+                }
+
+                if (this.isRunning) {
+                  this.timeout = setTimeout(() => tick(), timeout)
+                }
+              } catch (err) {
+                reject(err)
+              }
+            }
+
+            tick()
+          }),
+        { acquireTimeout: Infinity }
+      )
+    } catch (err) {
+      logger().error(
+        {
+          id: this.id,
+          docId: this.docId,
+          workspaceId: this.workspaceId,
+          documentId: this.documentId,
+          err,
+        },
+        'Unexpected error while executing AI tasks. Retrying in 2 seconds'
+      )
+      setTimeout(() => this.execute(), 2000)
+    }
+  }
+
+  private async executeItem(task: AITaskItem) {
+    const clearPing = this.startPinging(task)
+
+    try {
+      const metadata = task.getMetadata()
+      logger().trace(
+        {
+          workspaceId: this.workspaceId,
+          documentId: this.documentId,
+          taskType: metadata._tag,
+          tasksCount: this.tasks.size,
+        },
+        'Executing AI task'
+      )
+
+      const block = this.blocks.get(task.getBlockId())
+      if (!block) {
+        throw new BlockNotFoundError(task.getBlockId())
+      }
+
+      const events = await (async () => {
+        let user: ApiUser | null = null
+        const userId = task.getUserId()
+        if (userId) {
+          user = await getUserById(userId)
+        }
+
+        if (!user) {
+          user = unknownUser()
+        }
+
+        return new UserNotebookEvents(this.workspaceId, this.documentId, user)
+      })()
+
+      switch (metadata._tag) {
+        case 'noop':
+          task.setCompleted('success')
+          break
+        case 'edit-python':
+        case 'fix-python': {
+          if (!isPythonBlock(block)) {
+            throw new UnexpectedBlockTypeError(
+              task.getBlockId(),
+              'python',
+              getBaseAttributes(block).type
+            )
+          }
+
+          switch (metadata._tag) {
+            case 'edit-python':
+              await this.pythonAIExecutor.editWithAI(
+                task,
+                block,
+                metadata,
+                events
+              )
+              break
+            case 'fix-python':
+              await this.pythonAIExecutor.fixWithAI(
+                task,
+                block,
+                metadata,
+                events
+              )
+              break
+            default:
+              exhaustiveCheck(metadata)
+          }
+          break
+        }
+        case 'edit-sql':
+        case 'fix-sql': {
+          if (!isSQLBlock(block)) {
+            throw new UnexpectedBlockTypeError(
+              task.getBlockId(),
+              'sql',
+              getBaseAttributes(block).type
+            )
+          }
+
+          switch (metadata._tag) {
+            case 'edit-sql':
+              await this.sqlAIExecutor.editWithAI(task, block, metadata, events)
+              break
+            case 'fix-sql':
+              await this.sqlAIExecutor.fixWithAI(task, block, metadata, events)
+              break
+            default:
+              exhaustiveCheck(metadata)
+          }
+          break
+        }
+        default:
+          exhaustiveCheck(metadata)
+      }
+    } catch (err) {
+      logger().error(
+        {
+          workspaceId: this.workspaceId,
+          documentId: this.documentId,
+          blockId: task.getBlockId(),
+          err,
+        },
+        'Error executing AI task'
+      )
+      task.setCompleted('error')
+    } finally {
+      clearPing()
+    }
+  }
+
+  private startPinging(task: AITaskItem): () => void {
+    const interval = setInterval(() => {
+      task.ping()
+    }, AI_TASK_PING_TIMEOUT / 5)
+
+    return () => {
+      clearInterval(interval)
+    }
+  }
+
+  public static fromWSSharedV2(doc: WSSharedDocV2): AIExecutor {
+    return new AIExecutor(
+      doc.id,
+      doc.workspaceId,
+      doc.documentId,
+      AITasks.fromYjs(doc.ydoc),
+      doc.blocks,
+      AIPythonExecutor.fromWSSharedDocV2(doc),
+      AISQLExecutor.fromWSSharedDocV2(doc)
+    )
+  }
+}
diff --git a/apps/api/src/yjs/v2/executor/ai/python.ts b/apps/api/src/yjs/v2/executor/ai/python.ts
new file mode 100644
index 00000000..11860f29
--- /dev/null
+++ b/apps/api/src/yjs/v2/executor/ai/python.ts
@@ -0,0 +1,236 @@
+import debounce from 'lodash.debounce'
+import * as Y from 'yjs'
+import { getWorkspaceWithSecrets } from '@briefer/database'
+import { DataFrame, PythonErrorOutput } from '@briefer/types'
+import { pythonEditStreamed } from '../../../../ai-api.js'
+import {
+  AITaskItem,
+  AITaskItemEditPythonMetadata,
+  AITaskItemFixPythonMetadata,
+  closePythonEditWithAIPrompt,
+  getPythonBlockEditWithAIPrompt,
+  getPythonBlockResult,
+  getPythonSource,
+  PythonBlock,
+  updatePythonAISuggestions,
+} from '@briefer/editor'
+import { WSSharedDocV2 } from '../../index.js'
+import { AIEvents } from '../../../../events/index.js'
+import { logger } from '../../../../logger.js'
+import { CanceledError } from 'axios'
+
+async function editWithAI(
+  workspaceId: string,
+  source: string,
+  instructions: string,
+  dataFrames: DataFrame[],
+  event: (modelId: string | null) => void,
+  onSource: (source: string) => void
+) {
+  const workspace = workspaceId
+    ? await getWorkspaceWithSecrets(workspaceId)
+    : null
+
+  event(workspace?.assistantModel ?? null)
+
+  return pythonEditStreamed(
+    source,
+    instructions,
+    dataFrames,
+    onSource,
+    workspace?.assistantModel ?? null,
+    workspace?.secrets?.openAiApiKey ?? null
+  )
+}
+
+export interface IPythonAIExecutor {
+  editWithAI(
+    taskItem: AITaskItem,
+    block: Y.XmlElement<PythonBlock>,
+    metadata: AITaskItemEditPythonMetadata,
+    events: AIEvents
+  ): Promise<void>
+  fixWithAI(
+    taskItem: AITaskItem,
+    block: Y.XmlElement<PythonBlock>,
+    metadata: AITaskItemFixPythonMetadata,
+    events: AIEvents
+  ): Promise<void>
+}
+
+export class AIPythonExecutor implements IPythonAIExecutor {
+  constructor(
+    private readonly workspaceId: string,
+    private readonly documentId: string,
+    private readonly dataframes: Y.Map<DataFrame>,
+    private readonly effects: { editWithAI: typeof editWithAI }
+  ) {}
+
+  public async editWithAI(
+    taskItem: AITaskItem,
+    block: Y.XmlElement<PythonBlock>,
+    _metadata: AITaskItemEditPythonMetadata,
+    events: AIEvents
+  ): Promise<void> {
+    let cleanup: () => void = () => {}
+    let aborted = false
+    try {
+      cleanup = taskItem.observeStatus((status) => {
+        if (status._tag === 'aborting') {
+          aborted = true
+        }
+      })
+
+      const instructions = getPythonBlockEditWithAIPrompt(block).toJSON()
+      if (!instructions) {
+        taskItem.setCompleted('error')
+        return
+      }
+
+      const callback = debounce((suggestions) => {
+        updatePythonAISuggestions(block, suggestions)
+      }, 50)
+
+      const source = getPythonSource(block).toJSON()
+      const { promise, abortController } = await this.effects.editWithAI(
+        this.workspaceId,
+        source,
+        instructions,
+        Array.from(this.dataframes.values()),
+        (modelId) => {
+          events.aiUsage('python', 'edit', modelId)
+        },
+        callback
+      )
+
+      if (aborted) {
+        abortController.abort()
+        taskItem.setCompleted('aborted')
+        cleanup()
+        return
+      }
+
+      cleanup = taskItem.observeStatus((status) => {
+        if (status._tag === 'aborting') {
+          abortController.abort()
+          aborted = true
+        }
+      })
+
+      await promise
+      callback.flush()
+      closePythonEditWithAIPrompt(block, true)
+      taskItem.setCompleted(aborted ? 'aborted' : 'success')
+    } catch (err) {
+      if (err instanceof CanceledError) {
+        taskItem.setCompleted('aborted')
+        return
+      }
+
+      logger().error(
+        {
+          workspaceId: this.workspaceId,
+          documentId: this.documentId,
+          blockId: taskItem.getUserId(),
+          err,
+        },
+        'Error editing python block with AI'
+      )
+      taskItem.setCompleted('error')
+    } finally {
+      cleanup()
+    }
+  }
+
+  public async fixWithAI(
+    taskItem: AITaskItem,
+    block: Y.XmlElement<PythonBlock>,
+    _metadata: AITaskItemFixPythonMetadata,
+    events: AIEvents
+  ): Promise<void> {
+    let cleanup: () => void = () => {}
+    let aborted = false
+    try {
+      cleanup = taskItem.observeStatus((status) => {
+        if (status._tag === 'aborting') {
+          aborted = true
+        }
+      })
+      const error = getPythonBlockResult(block).find(
+        (r): r is PythonErrorOutput => r.type === 'error'
+      )
+      if (!error) {
+        taskItem.setCompleted('error')
+        return
+      }
+
+      const instructions = `Fix the Python code, this is the error: ${JSON.stringify(
+        {
+          ...error,
+          traceback: error.traceback.slice(0, 2),
+        }
+      )}`
+      const source = getPythonSource(block).toJSON()
+
+      const callback = debounce((suggestions) => {
+        updatePythonAISuggestions(block, suggestions)
+      }, 50)
+
+      const { promise, abortController } = await this.effects.editWithAI(
+        this.workspaceId,
+        source,
+        instructions,
+        Array.from(this.dataframes.values()),
+        (modelId) => {
+          events.aiUsage('python', 'fix', modelId)
+        },
+        callback
+      )
+
+      if (aborted) {
+        abortController.abort()
+        taskItem.setCompleted('aborted')
+        return
+      }
+
+      cleanup()
+      cleanup = taskItem.observeStatus((status) => {
+        if (status._tag === 'aborting') {
+          abortController.abort()
+          aborted = true
+        }
+      })
+
+      await promise
+      callback.flush()
+      taskItem.setCompleted(aborted ? 'aborted' : 'success')
+    } catch (err) {
+      if (err instanceof CanceledError) {
+        taskItem.setCompleted('aborted')
+        return
+      }
+
+      logger().error(
+        {
+          workspaceId: this.workspaceId,
+          documentId: this.documentId,
+          blockId: taskItem.getUserId(),
+          err,
+        },
+        'Error fixing python block with AI'
+      )
+      taskItem.setCompleted('error')
+    } finally {
+      cleanup()
+    }
+  }
+
+  public static fromWSSharedDocV2(doc: WSSharedDocV2): AIPythonExecutor {
+    return new AIPythonExecutor(
+      doc.workspaceId,
+      doc.documentId,
+      doc.dataframes,
+      { editWithAI }
+    )
+  }
+}
diff --git a/apps/api/src/yjs/v2/executor/ai/sql.ts b/apps/api/src/yjs/v2/executor/ai/sql.ts
new file mode 100644
index 00000000..77915719
--- /dev/null
+++ b/apps/api/src/yjs/v2/executor/ai/sql.ts
@@ -0,0 +1,339 @@
+import debounce from 'lodash.debounce'
+import {
+  AITaskItem,
+  AITaskItemEditSQLMetadata,
+  AITaskItemFixSQLMetadata,
+  SQLBlock,
+  YBlock,
+  closeSQLEditWithAIPrompt,
+  getSQLAttributes,
+  updateSQLAISuggestions,
+} from '@briefer/editor'
+import * as Y from 'yjs'
+import {
+  listDataSources,
+  getWorkspaceWithSecrets,
+  DataSource,
+} from '@briefer/database'
+import { logger } from '../../../../logger.js'
+import { sqlEditStreamed } from '../../../../ai-api.js'
+import { AIEvents } from '../../../../events/index.js'
+import { WSSharedDocV2 } from '../../index.js'
+import { CanceledError } from 'axios'
+import {
+  fetchDataSourceStructureFromCache,
+  listSchemaTables,
+} from '../../../../datasources/structure.js'
+import { DataSourceStructureStateV3 } from '@briefer/types'
+
+async function editWithAI(
+  workspaceId: string,
+  datasourceId:
+    | {
+        type: 'db'
+        id: string
+      }
+    | { type: 'duckdb' },
+  source: string,
+  instructions: string,
+  event: (modelId: string | null) => void,
+  onSuggestions: (suggestions: string) => void
+) {
+  const workspace = workspaceId
+    ? await getWorkspaceWithSecrets(workspaceId)
+    : null
+
+  const assistantModelId = workspace?.assistantModel ?? null
+
+  if (datasourceId.type === 'duckdb') {
+    event(assistantModelId)
+
+    return sqlEditStreamed(
+      source,
+      instructions,
+      'DuckDB',
+      onSuggestions,
+      // TODO: what should be the schema when duckdb?
+      null,
+      assistantModelId,
+      workspace?.secrets?.openAiApiKey ?? null
+    )
+  }
+
+  const dataSources = await listDataSources(workspaceId)
+  const dataSource = dataSources.find((ds) => ds.data.id === datasourceId.id)
+  if (!dataSource) {
+    throw new Error(`Datasource with id ${datasourceId} not found`)
+  }
+
+  const structure = await fetchDataSourceStructureFromCache(
+    dataSource.data.id,
+    dataSource.type
+  )
+  const tableInfo = await tableInfoFromStructure(dataSource, structure)
+
+  event(assistantModelId)
+
+  const dialect: string = (() => {
+    switch (dataSource.type) {
+      case 'psql':
+        return 'postgresql'
+      case 'redshift':
+        return 'redshift'
+      case 'trino':
+        return 'trino'
+      case 'bigquery':
+        return 'bigquery'
+      case 'athena':
+        return 'awsathena'
+      case 'oracle':
+        return 'oracle'
+      case 'mysql':
+        return 'mysql'
+      case 'sqlserver':
+        return 'mssql'
+      case 'snowflake':
+        return 'snowflake'
+      case 'databrickssql':
+        return 'databricks'
+    }
+  })()
+
+  return sqlEditStreamed(
+    source,
+    instructions,
+    dialect,
+    onSuggestions,
+    tableInfo,
+    assistantModelId,
+    workspace?.secrets?.openAiApiKey ?? null
+  )
+}
+
+async function tableInfoFromStructure(
+  config: DataSource,
+  structure: DataSourceStructureStateV3 | null
+): Promise<string | null> {
+  if (!structure) {
+    return null
+  }
+
+  let result = ''
+  for await (const schemaTable of listSchemaTables([{ config, structure }])) {
+    result += `${schemaTable.schemaName}.${schemaTable.tableName}\n`
+    for (const columns of schemaTable.table.columns) {
+      result += `${columns.name} ${columns.type}\n`
+    }
+    result += '\n'
+  }
+
+  return result.trim()
+}
+
+export interface ISQLAIExecutor {
+  editWithAI(
+    taskItem: AITaskItem,
+    block: Y.XmlElement<SQLBlock>,
+    metadata: AITaskItemEditSQLMetadata,
+    events: AIEvents
+  ): Promise<void>
+  fixWithAI(
+    taskItem: AITaskItem,
+    block: Y.XmlElement<SQLBlock>,
+    metadata: AITaskItemFixSQLMetadata,
+    events: AIEvents
+  ): Promise<void>
+}
+
+export class AISQLExecutor implements ISQLAIExecutor {
+  constructor(
+    private readonly workspaceId: string,
+    private readonly documentId: string,
+    private readonly blocks: Y.Map<YBlock>,
+    private readonly effects: { editWithAI: typeof editWithAI }
+  ) {}
+
+  public async editWithAI(
+    taskItem: AITaskItem,
+    block: Y.XmlElement<SQLBlock>,
+    _metadata: AITaskItemEditSQLMetadata,
+    events: AIEvents
+  ): Promise<void> {
+    let cleanup: () => void = () => {}
+    let aborted = false
+    try {
+      cleanup = taskItem.observeStatus((status) => {
+        if (status._tag === 'aborting') {
+          aborted = true
+        }
+      })
+
+      const {
+        dataSourceId: datasourceId,
+        isFileDataSource,
+        source,
+        editWithAIPrompt,
+      } = getSQLAttributes(block, this.blocks)
+
+      const instructions = editWithAIPrompt?.toJSON() ?? ''
+
+      if ((!datasourceId && !isFileDataSource) || !instructions) {
+        taskItem.setCompleted('error')
+        return
+      }
+
+      const callback = debounce((suggestions) => {
+        if (aborted) {
+          return
+        }
+
+        updateSQLAISuggestions(block, suggestions)
+      }, 50)
+
+      const { promise, abortController } = await this.effects.editWithAI(
+        this.workspaceId,
+        datasourceId ? { type: 'db', id: datasourceId } : { type: 'duckdb' },
+        source?.toJSON() ?? '',
+        instructions,
+        (modelId) => {
+          events.aiUsage('sql', 'edit', modelId)
+        },
+        callback
+      )
+
+      if (aborted) {
+        abortController.abort()
+        taskItem.setCompleted('aborted')
+        cleanup()
+        return
+      }
+
+      cleanup = taskItem.observeStatus((status) => {
+        if (status._tag === 'aborting') {
+          abortController.abort()
+          aborted = true
+        }
+      })
+
+      await promise
+      callback.flush()
+      closeSQLEditWithAIPrompt(block, true)
+      taskItem.setCompleted(aborted ? 'aborted' : 'success')
+    } catch (err) {
+      if (err instanceof CanceledError) {
+        taskItem.setCompleted('aborted')
+        return
+      }
+
+      logger().error(
+        {
+          workspaceId: this.workspaceId,
+          documentId: this.documentId,
+          blockId: taskItem.getUserId(),
+          err,
+        },
+        'Error editing SQL block with AI'
+      )
+      taskItem.setCompleted('error')
+    } finally {
+      cleanup()
+    }
+  }
+
+  public async fixWithAI(
+    taskItem: AITaskItem,
+    block: Y.XmlElement<SQLBlock>,
+    _metadata: AITaskItemFixSQLMetadata,
+    events: AIEvents
+  ): Promise<void> {
+    let cleanup: () => void = () => {}
+    let aborted = false
+    try {
+      cleanup = taskItem.observeStatus((status) => {
+        if (status._tag === 'aborting') {
+          aborted = true
+        }
+      })
+
+      const { dataSourceId, isFileDataSource } = getSQLAttributes(
+        block,
+        this.blocks
+      )
+      if (!dataSourceId && !isFileDataSource) {
+        taskItem.setCompleted('error')
+        return
+      }
+
+      const error = block.getAttribute('result')
+      if (!error || error.type !== 'syntax-error') {
+        taskItem.setCompleted('error')
+        return
+      }
+
+      const instructions = `Fix the query, this is the error: ${error.message}`
+
+      const source = block.getAttribute('source')?.toJSON() ?? ''
+
+      const callback = debounce((suggestions) => {
+        if (aborted) {
+          return
+        }
+
+        updateSQLAISuggestions(block, suggestions)
+      }, 50)
+
+      const { promise, abortController } = await this.effects.editWithAI(
+        this.workspaceId,
+        dataSourceId ? { type: 'db', id: dataSourceId } : { type: 'duckdb' },
+        source,
+        instructions,
+        (modelId) => {
+          events.aiUsage('sql', 'fix', modelId)
+        },
+        callback
+      )
+
+      if (aborted) {
+        abortController.abort()
+        taskItem.setCompleted('aborted')
+        return
+      }
+
+      cleanup()
+      cleanup = taskItem.observeStatus((status) => {
+        if (status._tag === 'aborting') {
+          abortController.abort()
+          aborted = true
+        }
+      })
+
+      await promise
+      callback.flush()
+      taskItem.setCompleted(aborted ? 'aborted' : 'success')
+    } catch (err) {
+      if (err instanceof CanceledError) {
+        taskItem.setCompleted('aborted')
+        return
+      }
+
+      logger().error(
+        {
+          workspaceId: this.workspaceId,
+          documentId: this.documentId,
+          blockId: taskItem.getUserId(),
+          err,
+        },
+        'Error fixing SQL block with AI'
+      )
+      taskItem.setCompleted('error')
+    } finally {
+      cleanup()
+    }
+  }
+
+  public static fromWSSharedDocV2(doc: WSSharedDocV2) {
+    return new AISQLExecutor(doc.workspaceId, doc.documentId, doc.blocks, {
+      editWithAI,
+    })
+  }
+}
diff --git a/apps/api/src/yjs/v2/executor/date-input.ts b/apps/api/src/yjs/v2/executor/date-input.ts
new file mode 100644
index 00000000..caa8a775
--- /dev/null
+++ b/apps/api/src/yjs/v2/executor/date-input.ts
@@ -0,0 +1,114 @@
+import {
+  DateInputBlock,
+  ExecutionQueueItem,
+  ExecutionQueueItemDateInputMetadata,
+  YBlock,
+  getDateInputAttributes,
+} from '@briefer/editor'
+import * as Y from 'yjs'
+import { logger } from '../../../logger.js'
+import { setDateTimeVariable } from '../../../python/input.js'
+import { WSSharedDocV2 } from '../index.js'
+
+export type DateInputEffects = {
+  setDateTimeVariable: typeof setDateTimeVariable
+}
+
+export interface IDateInputExecutor {
+  save(
+    executionItem: ExecutionQueueItem,
+    block: Y.XmlElement<DateInputBlock>,
+    metadata: ExecutionQueueItemDateInputMetadata
+  ): Promise<void>
+}
+
+export class DateInputExecutor implements IDateInputExecutor {
+  constructor(
+    private readonly sessionId: string,
+    private readonly workspaceId: string,
+    private readonly documentId: string,
+    private readonly blocks: Y.Map<YBlock>,
+    private readonly effects: DateInputEffects = {
+      setDateTimeVariable,
+    }
+  ) {}
+
+  public async save(
+    executionItem: ExecutionQueueItem,
+    block: Y.XmlElement<DateInputBlock>,
+    _metadata: ExecutionQueueItemDateInputMetadata
+  ) {
+    const {
+      id: blockId,
+      variable,
+      value,
+    } = getDateInputAttributes(block, this.blocks)
+
+    const dfNameRegex = /^[a-zA-Z_][a-zA-Z0-9_]*$/
+    if (!dfNameRegex.test(variable)) {
+      block.setAttribute('error', 'invalid-variable')
+      executionItem.setCompleted('error')
+      return
+    }
+
+    logger().trace(
+      {
+        sessionId: this.sessionId,
+        workspaceId: this.workspaceId,
+        documentId: this.documentId,
+        blockId,
+        variable,
+        value,
+      },
+      'Saving date input variable'
+    )
+
+    try {
+      await this.effects.setDateTimeVariable(
+        this.workspaceId,
+        this.sessionId,
+        variable,
+        value
+      )
+      logger().trace(
+        {
+          sessionId: this.sessionId,
+          workspaceId: this.workspaceId,
+          documentId: this.documentId,
+          blockId,
+          variable,
+          value,
+        },
+        'Saved date input variable'
+      )
+      executionItem.setCompleted('success')
+    } catch (err) {
+      logger().error(
+        {
+          sessionId: this.sessionId,
+          workspaceId: this.workspaceId,
+          documentId: this.documentId,
+          blockId,
+          variable,
+          value,
+          err,
+        },
+        'Failed to save date input variable'
+      )
+      block.setAttribute('error', 'unexpected-error')
+      executionItem.setCompleted('error')
+    }
+  }
+
+  public static fromWSSharedDocV2(doc: WSSharedDocV2) {
+    return new DateInputExecutor(
+      doc.id,
+      doc.workspaceId,
+      doc.documentId,
+      doc.blocks,
+      {
+        setDateTimeVariable,
+      }
+    )
+  }
+}
diff --git a/apps/api/src/yjs/v2/executor/dropdown-input.ts b/apps/api/src/yjs/v2/executor/dropdown-input.ts
new file mode 100644
index 00000000..8ce03d03
--- /dev/null
+++ b/apps/api/src/yjs/v2/executor/dropdown-input.ts
@@ -0,0 +1,230 @@
+import {
+  DropdownInputBlock,
+  YBlock,
+  getDropdownInputAttributes,
+  updateDropdownInputValue,
+  updateDropdownInputVariable,
+  updateDropdownInputBlockExecutedAt,
+  ExecutionQueueItemDropdownInputRenameVariableMetadata,
+  ExecutionQueueItem,
+  ExecutionQueueItemDropdownInputSaveValueMetadata,
+} from '@briefer/editor'
+import * as Y from 'yjs'
+import { logger } from '../../../logger.js'
+import { setVariable } from '../../../python/input.js'
+import { WSSharedDocV2 } from '../index.js'
+
+export type DropdownInputEffects = {
+  setVariable: typeof setVariable
+}
+
+export interface IDropdownInputExecutor {
+  saveValue(
+    executionItem: ExecutionQueueItem,
+    block: Y.XmlElement<DropdownInputBlock>,
+    metadata: ExecutionQueueItemDropdownInputSaveValueMetadata
+  ): Promise<void>
+  renameVariable(
+    executionItem: ExecutionQueueItem,
+    block: Y.XmlElement<DropdownInputBlock>,
+    metadata: ExecutionQueueItemDropdownInputRenameVariableMetadata
+  ): Promise<void>
+}
+
+export class DropdownInputExecutor implements IDropdownInputExecutor {
+  constructor(
+    private readonly sessionId: string,
+    private readonly workspaceId: string,
+    private readonly documentId: string,
+    private readonly blocks: Y.Map<YBlock>,
+    private readonly effects: DropdownInputEffects = {
+      setVariable,
+    }
+  ) {}
+
+  public async renameVariable(
+    executionItem: ExecutionQueueItem,
+    block: Y.XmlElement<DropdownInputBlock>,
+    _metadata: ExecutionQueueItemDropdownInputRenameVariableMetadata
+  ) {
+    const attrs = getDropdownInputAttributes(block, this.blocks)
+
+    const { newValue: newVariableName } = attrs.variable
+    const { value } = attrs.value
+
+    const dfNameRegex = /^[a-zA-Z_][a-zA-Z0-9_]*$/
+    if (!dfNameRegex.test(newVariableName)) {
+      updateDropdownInputVariable(block, this.blocks, {
+        error: 'invalid-variable-name',
+      })
+      executionItem.setCompleted('error')
+      return
+    }
+
+    try {
+      logger().trace(
+        {
+          sessionId: this.sessionId,
+          workspaceId: this.workspaceId,
+          documentId: this.documentId,
+          blockId: attrs.id,
+          variableName: newVariableName,
+        },
+        'Saving dropdown input variable'
+      )
+
+      if (value === null) {
+        updateDropdownInputValue(block, {
+          error: 'invalid-value',
+        })
+        executionItem.setCompleted('error')
+        return
+      }
+
+      let aborted = false
+      let cleanup = executionItem.observeStatus((status) => {
+        if (status._tag === 'aborting') {
+          aborted = true
+        }
+      })
+
+      const { promise, abort } = await this.effects.setVariable(
+        this.workspaceId,
+        this.sessionId,
+        newVariableName,
+        value
+      )
+
+      if (aborted) {
+        await abort()
+      }
+
+      let abortP = Promise.resolve(aborted)
+      cleanup()
+      cleanup = executionItem.observeStatus((status) => {
+        if (status._tag === 'aborting') {
+          abortP = abort().then(() => true)
+        }
+      })
+
+      await promise
+      aborted = await abortP
+      cleanup()
+
+      if (!aborted) {
+        updateDropdownInputVariable(block, this.blocks, {
+          value: newVariableName,
+          error: null,
+        })
+        logger().trace(
+          {
+            sessionId: this.sessionId,
+            workspaceId: this.workspaceId,
+            documentId: this.documentId,
+            blockId: attrs.id,
+            variableName: newVariableName,
+          },
+          'Saved dropdown input variable'
+        )
+      }
+      executionItem.setCompleted(aborted ? 'aborted' : 'success')
+    } catch (err) {
+      logger().error(
+        {
+          sessionId: this.sessionId,
+          workspaceId: this.workspaceId,
+          documentId: this.documentId,
+          blockId: executionItem.getBlockId(),
+          error: err,
+        },
+        'Failed to rename text input variable'
+      )
+
+      updateDropdownInputVariable(block, this.blocks, {
+        error: 'unexpected-error',
+      })
+      executionItem.setCompleted('error')
+    }
+  }
+
+  public async saveValue(
+    executionItem: ExecutionQueueItem,
+    block: Y.XmlElement<DropdownInputBlock>,
+    _metadata: ExecutionQueueItemDropdownInputSaveValueMetadata
+  ) {
+    const attrs = getDropdownInputAttributes(block, this.blocks)
+
+    const { value: variableName } = attrs.variable
+    const { newValue } = attrs.value
+
+    if (newValue === null) {
+      updateDropdownInputValue(block, {
+        error: 'invalid-value',
+      })
+      executionItem.setCompleted('error')
+      return
+    }
+
+    try {
+      logger().trace(
+        {
+          sessionId: this.sessionId,
+          workspaceId: this.workspaceId,
+          documentId: this.documentId,
+          blockId: block.getAttribute('id'),
+          variableName,
+          newValue,
+        },
+        'Saving dropdown input value'
+      )
+
+      await this.effects
+        .setVariable(this.workspaceId, this.sessionId, variableName, newValue)
+        .then(({ promise }) => promise)
+      updateDropdownInputValue(block, {
+        value: newValue,
+        error: null,
+      })
+      updateDropdownInputBlockExecutedAt(block, new Date())
+      logger().trace(
+        {
+          sessionId: this.sessionId,
+          workspaceId: this.workspaceId,
+          documentId: this.documentId,
+          blockId: attrs.id,
+          variableName,
+          newValue,
+        },
+        'Saved dropdown input value'
+      )
+      executionItem.setCompleted('success')
+    } catch (err) {
+      logger().error(
+        {
+          sessionId: this.sessionId,
+          workspaceId: this.workspaceId,
+          documentId: this.documentId,
+          blockId: executionItem.getBlockId(),
+          error: err,
+        },
+        'Failed to save dropdown input value'
+      )
+      updateDropdownInputValue(block, {
+        error: 'unexpected-error',
+      })
+      executionItem.setCompleted('error')
+    }
+  }
+
+  public static fromWSSharedDocV2(doc: WSSharedDocV2) {
+    return new DropdownInputExecutor(
+      doc.id,
+      doc.workspaceId,
+      doc.documentId,
+      doc.blocks,
+      {
+        setVariable,
+      }
+    )
+  }
+}
diff --git a/apps/api/src/yjs/v2/executor/executor.ts b/apps/api/src/yjs/v2/executor/executor.ts
new file mode 100644
index 00000000..2206bb4b
--- /dev/null
+++ b/apps/api/src/yjs/v2/executor/executor.ts
@@ -0,0 +1,660 @@
+import { v4 as uuidv4 } from 'uuid'
+import * as Y from 'yjs'
+import { WSSharedDocV2 } from '../index.js'
+import {
+  ExecutionQueue,
+  ExecutionQueueBatch,
+  ExecutionQueueItem,
+  isPythonBlock,
+  isSQLBlock,
+  PythonBlock,
+  SQLBlock,
+  YBlock,
+  ExecutionQueueItemPythonMetadata,
+  ExecutionQueueItemSQLMetadata,
+  isVisualizationBlock,
+  ExecutionQueueItemVisualizationMetadata,
+  VisualizationBlock,
+  ExecutionQueueItemSQLRenameDataframeMetadata,
+  ExecutionQueueItemTextInputSaveValueMetadata,
+  InputBlock,
+  ExecutionQueueItemTextInputRenameVariableMetadata,
+  DateInputBlock,
+  ExecutionQueueItemDateInputMetadata,
+  isDateInputBlock,
+  ExecutionQueueItemDropdownInputSaveValueMetadata,
+  ExecutionQueueItemDropdownInputRenameVariableMetadata,
+  isDropdownInputBlock,
+  DropdownInputBlock,
+  ExecutionQueueItemPivotTableMetadata,
+  PivotTableBlock,
+  ExecutionQueueItemPivotTableLoadPageMetadata,
+  isPivotTableBlock,
+  WritebackBlock,
+  ExecutionQueueItemWritebackMetadata,
+  isWritebackBlock,
+  isTextInputBlock,
+} from '@briefer/editor'
+import { IPythonExecutor, PythonExecutor } from './python.js'
+import { logger } from '../../../logger.js'
+import { UserNotebookEvents } from '../../../events/user.js'
+import { ISQLExecutor, SQLExecutor } from './sql.js'
+import { config } from '../../../config/index.js'
+import {
+  IVisualizationExecutor,
+  VisualizationExecutor,
+} from './visualization.js'
+import { ITextInputExecutor, TextInputExecutor } from './text-input.js'
+import { DateInputExecutor, IDateInputExecutor } from './date-input.js'
+import {
+  DropdownInputExecutor,
+  IDropdownInputExecutor,
+} from './dropdown-input.js'
+import { IPivotTableExecutor, PivotTableExecutor } from './pivot-table.js'
+import { IWritebackExecutor, WritebackExecutor } from './writeback.js'
+import { ScheduleNotebookEvents } from '../../../events/schedule.js'
+import { ApiUser, getUserById } from '@briefer/database'
+import { acquireLock } from '../../../lock.js'
+import { exhaustiveCheck } from '@briefer/types'
+
+export function unknownUser(): ApiUser {
+  return {
+    id: 'unknown',
+    name: 'unknown',
+    email: 'unknown',
+    picture: null,
+    createdAt: new Date(),
+    updatedAt: new Date(),
+  }
+}
+
+export class Executor {
+  private readonly id = uuidv4()
+  private isRunning: boolean = false
+  private currentExecution: Promise<void> | null = null
+  private readonly queue: ExecutionQueue
+
+  private constructor(
+    private readonly docId: string,
+    private readonly pythonExecutor: IPythonExecutor,
+    private readonly sqlExecutor: ISQLExecutor,
+    private readonly visExecutor: IVisualizationExecutor,
+    private readonly textInputExecutor: ITextInputExecutor,
+    private readonly dropdownInputExecutor: IDropdownInputExecutor,
+    private readonly dateInputExecutor: IDateInputExecutor,
+    private readonly pivotTableExecutor: IPivotTableExecutor,
+    private readonly writebackExecutor: IWritebackExecutor,
+    private readonly workspaceId: string,
+    private readonly documentId: string,
+    private readonly blocks: Y.Map<YBlock>,
+    ydoc: Y.Doc
+  ) {
+    this.queue = ExecutionQueue.fromYjs(ydoc)
+  }
+
+  public start() {
+    this.isRunning = true
+    this.execute()
+  }
+
+  public isIdle() {
+    return this.currentExecution === null
+  }
+
+  public async stop(): Promise<void> {
+    this.isRunning = false
+    await this.currentExecution
+  }
+
+  private async execute() {
+    try {
+      logger().debug(
+        {
+          port: process.env['PORT'],
+          id: this.id,
+          docId: this.docId,
+          workspaceId: this.workspaceId,
+          documentId: this.documentId,
+        },
+        'Acquiring executor lock'
+      )
+
+      await acquireLock(
+        `executor:${this.docId}`,
+        () =>
+          new Promise<void>(async (resolve, reject) => {
+            if (!this.isRunning) {
+              logger().debug(
+                {
+                  port: process.env['PORT'],
+                  id: this.id,
+                  docId: this.docId,
+                  workspaceId: this.workspaceId,
+                  documentId: this.documentId,
+                },
+                'Executor lock acquired but executor is not running anymore. Exiting.'
+              )
+              resolve()
+              return
+            }
+
+            logger().debug(
+              {
+                port: process.env['PORT'],
+                id: this.id,
+                docId: this.docId,
+                workspaceId: this.workspaceId,
+                documentId: this.documentId,
+              },
+              'Executor lock acquired. Executing queue'
+            )
+
+            const tick = async () => {
+              try {
+                if (!this.isRunning) {
+                  logger().debug(
+                    {
+                      port: process.env['PORT'],
+                      id: this.id,
+                      docId: this.docId,
+                      workspaceId: this.workspaceId,
+                      documentId: this.documentId,
+                    },
+                    'Executor is not running. Stopping consumer loop.'
+                  )
+                  resolve()
+                  return
+                }
+
+                const currentBatch = this.queue.getCurrentBatch()
+                if (!currentBatch) {
+                  setTimeout(() => tick(), 500)
+                  return
+                }
+
+                logger().trace(
+                  {
+                    port: process.env['PORT'],
+                    id: this.id,
+                    docId: this.docId,
+                    workspaceId: this.workspaceId,
+                    documentId: this.documentId,
+                    queueLength: this.queue.length,
+                  },
+                  'Making execution queue progress'
+                )
+
+                this.currentExecution = this.makeBatchProgress(currentBatch)
+                await this.currentExecution
+                this.currentExecution = null
+                if (this.isRunning) {
+                  setTimeout(() => tick(), 0)
+                }
+              } catch (err) {
+                reject(err)
+              }
+            }
+
+            tick()
+          }),
+        { acquireTimeout: Infinity }
+      )
+    } catch (err) {
+      logger().error(
+        {
+          port: process.env['PORT'],
+          id: this.id,
+          docId: this.docId,
+          workspaceId: this.workspaceId,
+          documentId: this.documentId,
+          err,
+        },
+        'Unexpected error while executing queue. Retrying in 2 seconds'
+      )
+      setTimeout(() => this.execute(), 2000)
+    }
+  }
+
+  private async makeBatchProgress(batch: ExecutionQueueBatch) {
+    logger().trace(
+      {
+        port: process.env['PORT'],
+        id: this.id,
+        docId: this.docId,
+        workspaceId: this.workspaceId,
+        documentId: this.documentId,
+        batchStatus: batch.status,
+        batchRemaining: batch.remaining,
+        batchLength: batch.length,
+      },
+      'Making batch progress'
+    )
+
+    const current = batch.getCurrent()
+    if (!current) {
+      logger().trace(
+        {
+          port: process.env['PORT'],
+          id: this.id,
+          docId: this.docId,
+          workspaceId: this.workspaceId,
+          documentId: this.documentId,
+        },
+        'Got to the end of current batch, advancing queue to next batch'
+      )
+      this.queue.advance()
+      return
+    }
+
+    const status = current.getStatus()
+
+    switch (status._tag) {
+      // TODO: when we get a running execution item, instead of just executing again,
+      // we should actually try to find out what happened to the previous execution
+      case 'running':
+      case 'enqueued':
+        await this.executeItem(batch, current)
+        break
+      case 'completed':
+        break
+      case 'unknown':
+      case 'aborting':
+        // TODO: when getting here we should try to abort one more time
+        current.setCompleted('aborted')
+        break
+      default:
+        exhaustiveCheck(status)
+    }
+
+    if (current.getCompleteStatus() === null) {
+      logger().error(
+        {
+          port: process.env['PORT'],
+          id: this.id,
+          docId: this.docId,
+          workspaceId: this.workspaceId,
+          documentId: this.documentId,
+          blockId: current.getBlockId(),
+        },
+        'Execution item did not complete after calling executeItem, advancing batch anyways to prevent infinite loop'
+      )
+      current.setCompleted('error')
+    }
+  }
+
+  private async executeItem(
+    batch: ExecutionQueueBatch,
+    item: ExecutionQueueItem
+  ): Promise<void> {
+    logger().trace(
+      {
+        port: process.env['PORT'],
+        id: this.id,
+        docId: this.docId,
+        workspaceId: this.workspaceId,
+        documentId: this.documentId,
+        blockId: item.getBlockId(),
+      },
+      'Executing item'
+    )
+
+    item.setRunning()
+    const data = this.getExecutionItemData(item)
+    if (!data) {
+      item.setCompleted('error')
+      return
+    }
+
+    const events = await (async () => {
+      const scheduleId = batch.getScheduleId()
+      if (scheduleId) {
+        return new ScheduleNotebookEvents(scheduleId)
+      }
+
+      let user: ApiUser | null = null
+      const userId = item.getUserId()
+      if (userId) {
+        user = await getUserById(userId)
+      }
+
+      if (!user) {
+        user = unknownUser()
+      }
+
+      return new UserNotebookEvents(this.workspaceId, this.documentId, user)
+    })()
+
+    switch (data._tag) {
+      case 'python': {
+        await this.pythonExecutor.run(item, data.block, data.metadata, events)
+        break
+      }
+      case 'sql':
+        await this.sqlExecutor.run(item, data.block, data.metadata, events)
+        break
+      case 'sql-rename-dataframe':
+        await this.sqlExecutor.renameDataframe(
+          item,
+          data.block,
+          data.metadata,
+          events
+        )
+        break
+      case 'visualization':
+        await this.visExecutor.run(item, data.block, data.metadata, events)
+        break
+      case 'text-input-save-value':
+        await this.textInputExecutor.saveValue(item, data.block, data.metadata)
+        break
+      case 'text-input-rename-variable':
+        await this.textInputExecutor.renameVariable(
+          item,
+          data.block,
+          data.metadata
+        )
+        break
+      case 'dropdown-input-save-value':
+        await this.dropdownInputExecutor.saveValue(
+          item,
+          data.block,
+          data.metadata
+        )
+        break
+      case 'dropdown-input-rename-variable':
+        await this.dropdownInputExecutor.renameVariable(
+          item,
+          data.block,
+          data.metadata
+        )
+        break
+      case 'date-input':
+        await this.dateInputExecutor.save(item, data.block, data.metadata)
+        break
+      case 'pivot-table':
+        await this.pivotTableExecutor.run(item, data.block, data.metadata)
+        break
+      case 'pivot-table-load-page':
+        await this.pivotTableExecutor.loadPage(item, data.block, data.metadata)
+        break
+      case 'writeback':
+        await this.writebackExecutor.run(
+          item,
+          data.block,
+          data.metadata,
+          events
+        )
+        break
+      default:
+        exhaustiveCheck(data)
+    }
+  }
+
+  private getExecutionItemData(
+    item: ExecutionQueueItem
+  ): ExecutionItemData | null {
+    const metadata = item.getMetadata()
+    const block = this.blocks.get(item.getBlockId())
+    if (!block) {
+      logger().error(
+        {
+          port: process.env['PORT'],
+          id: this.id,
+          docId: this.docId,
+          workspaceId: this.workspaceId,
+          documentId: this.documentId,
+          blockId: item.getBlockId(),
+        },
+        'Failed to find block for execution item'
+      )
+      return null
+    }
+
+    switch (metadata._tag) {
+      case 'python': {
+        if (!isPythonBlock(block)) {
+          logger().error(
+            {
+              port: process.env['PORT'],
+              id: this.id,
+              docId: this.docId,
+              workspaceId: this.workspaceId,
+              documentId: this.documentId,
+              blockId: item.getBlockId(),
+            },
+            'Got wrong block type for python execution'
+          )
+          return null
+        }
+
+        return { _tag: 'python', metadata, block }
+      }
+      case 'sql':
+      case 'sql-rename-dataframe': {
+        if (!isSQLBlock(block)) {
+          logger().error(
+            {
+              port: process.env['PORT'],
+              id: this.id,
+              docId: this.docId,
+              workspaceId: this.workspaceId,
+              documentId: this.documentId,
+              blockId: item.getBlockId(),
+            },
+            'Got wrong block type for sql execution'
+          )
+          return null
+        }
+
+        switch (metadata._tag) {
+          case 'sql':
+            return { _tag: 'sql', metadata, block }
+          case 'sql-rename-dataframe':
+            return { _tag: 'sql-rename-dataframe', metadata, block }
+        }
+      }
+      case 'visualization': {
+        if (!isVisualizationBlock(block)) {
+          logger().error(
+            {
+              port: process.env['PORT'],
+              id: this.id,
+              docId: this.docId,
+              workspaceId: this.workspaceId,
+              documentId: this.documentId,
+              blockId: item.getBlockId(),
+            },
+            'Got wrong block type for visualization execution'
+          )
+          return null
+        }
+
+        return { _tag: 'visualization', metadata, block }
+      }
+      case 'text-input-save-value':
+      case 'text-input-rename-variable': {
+        if (!isTextInputBlock(block)) {
+          logger().error(
+            {
+              port: process.env['PORT'],
+              id: this.id,
+              docId: this.docId,
+              workspaceId: this.workspaceId,
+              documentId: this.documentId,
+              blockId: item.getBlockId(),
+            },
+            'Got wrong block type for input execution'
+          )
+          return null
+        }
+
+        switch (metadata._tag) {
+          case 'text-input-save-value':
+            return { _tag: 'text-input-save-value', metadata, block }
+          case 'text-input-rename-variable':
+            return { _tag: 'text-input-rename-variable', metadata, block }
+        }
+      }
+      case 'dropdown-input-save-value':
+      case 'dropdown-input-rename-variable': {
+        if (!isDropdownInputBlock(block)) {
+          logger().error(
+            {
+              port: process.env['PORT'],
+              id: this.id,
+              docId: this.docId,
+              workspaceId: this.workspaceId,
+              documentId: this.documentId,
+              blockId: item.getBlockId(),
+            },
+            'Got wrong block type for input execution'
+          )
+          return null
+        }
+
+        switch (metadata._tag) {
+          case 'dropdown-input-save-value':
+            return { _tag: 'dropdown-input-save-value', metadata, block }
+          case 'dropdown-input-rename-variable':
+            return { _tag: 'dropdown-input-rename-variable', metadata, block }
+        }
+      }
+      case 'date-input': {
+        if (!isDateInputBlock(block)) {
+          logger().error(
+            {
+              port: process.env['PORT'],
+              id: this.id,
+              docId: this.docId,
+              workspaceId: this.workspaceId,
+              documentId: this.documentId,
+              blockId: item.getBlockId(),
+            },
+            'Got wrong block type for input execution'
+          )
+          return null
+        }
+
+        return { _tag: 'date-input', metadata, block }
+      }
+
+      case 'pivot-table':
+      case 'pivot-table-load-page': {
+        if (!isPivotTableBlock(block)) {
+          logger().error(
+            {
+              workspaceId: this.workspaceId,
+              documentId: this.documentId,
+              blockId: item.getBlockId(),
+            },
+            'Got wrong block type for pivot table execution'
+          )
+          return null
+        }
+
+        switch (metadata._tag) {
+          case 'pivot-table':
+            return { _tag: 'pivot-table', metadata, block }
+          case 'pivot-table-load-page':
+            return { _tag: 'pivot-table-load-page', metadata, block }
+        }
+      }
+      case 'writeback': {
+        if (!isWritebackBlock(block)) {
+          logger().error(
+            {
+              workspaceId: this.workspaceId,
+              documentId: this.documentId,
+              blockId: item.getBlockId(),
+            },
+            'Got wrong block type for writeback execution'
+          )
+          return null
+        }
+
+        return { _tag: 'writeback', metadata, block }
+      }
+      case 'noop':
+        return null
+    }
+  }
+
+  public static fromWSSharedDocV2(doc: WSSharedDocV2): Executor {
+    return new Executor(
+      doc.id,
+      PythonExecutor.fromWSSharedDocV2(doc),
+      SQLExecutor.fromWSSharedDocV2(doc, config().DATASOURCES_ENCRYPTION_KEY),
+      VisualizationExecutor.fromWSSharedDocV2(doc),
+      TextInputExecutor.fromWSSharedDocV2(doc),
+      DropdownInputExecutor.fromWSSharedDocV2(doc),
+      DateInputExecutor.fromWSSharedDocV2(doc),
+      PivotTableExecutor.fromWSSharedDocV2(doc),
+      WritebackExecutor.fromWSSharedDocV2(doc),
+      doc.workspaceId,
+      doc.documentId,
+      doc.blocks,
+      doc.ydoc
+    )
+  }
+}
+
+type ExecutionItemData =
+  | {
+      _tag: 'python'
+      metadata: ExecutionQueueItemPythonMetadata
+      block: Y.XmlElement<PythonBlock>
+    }
+  | {
+      _tag: 'sql'
+      metadata: ExecutionQueueItemSQLMetadata
+      block: Y.XmlElement<SQLBlock>
+    }
+  | {
+      _tag: 'sql-rename-dataframe'
+      metadata: ExecutionQueueItemSQLRenameDataframeMetadata
+      block: Y.XmlElement<SQLBlock>
+    }
+  | {
+      _tag: 'visualization'
+      metadata: ExecutionQueueItemVisualizationMetadata
+      block: Y.XmlElement<VisualizationBlock>
+    }
+  | {
+      _tag: 'text-input-save-value'
+      metadata: ExecutionQueueItemTextInputSaveValueMetadata
+      block: Y.XmlElement<InputBlock>
+    }
+  | {
+      _tag: 'text-input-rename-variable'
+      metadata: ExecutionQueueItemTextInputRenameVariableMetadata
+      block: Y.XmlElement<InputBlock>
+    }
+  | {
+      _tag: 'dropdown-input-save-value'
+      metadata: ExecutionQueueItemDropdownInputSaveValueMetadata
+      block: Y.XmlElement<DropdownInputBlock>
+    }
+  | {
+      _tag: 'dropdown-input-rename-variable'
+      metadata: ExecutionQueueItemDropdownInputRenameVariableMetadata
+      block: Y.XmlElement<DropdownInputBlock>
+    }
+  | {
+      _tag: 'date-input'
+      metadata: ExecutionQueueItemDateInputMetadata
+      block: Y.XmlElement<DateInputBlock>
+    }
+  | {
+      _tag: 'pivot-table'
+      metadata: ExecutionQueueItemPivotTableMetadata
+      block: Y.XmlElement<PivotTableBlock>
+    }
+  | {
+      _tag: 'pivot-table-load-page'
+      metadata: ExecutionQueueItemPivotTableLoadPageMetadata
+      block: Y.XmlElement<PivotTableBlock>
+    }
+  | {
+      _tag: 'writeback'
+      metadata: ExecutionQueueItemWritebackMetadata
+      block: Y.XmlElement<WritebackBlock>
+    }
diff --git a/apps/api/src/yjs/v2/executors/index.ts b/apps/api/src/yjs/v2/executor/index.ts
similarity index 98%
rename from apps/api/src/yjs/v2/executors/index.ts
rename to apps/api/src/yjs/v2/executor/index.ts
index ce41f880..8c23ca2b 100644
--- a/apps/api/src/yjs/v2/executors/index.ts
+++ b/apps/api/src/yjs/v2/executor/index.ts
@@ -2,6 +2,8 @@ import { DataFrame } from '@briefer/types'
 import { equals } from 'ramda'
 import * as Y from 'yjs'
 
+export * from './executor.js'
+
 export function updateDataframes(
   oldDataframes: Y.Map<DataFrame>,
   newDataframes: DataFrame[],
diff --git a/apps/api/src/yjs/v2/executor/pivot-table.ts b/apps/api/src/yjs/v2/executor/pivot-table.ts
new file mode 100644
index 00000000..8b758b3e
--- /dev/null
+++ b/apps/api/src/yjs/v2/executor/pivot-table.ts
@@ -0,0 +1,213 @@
+import {
+  ExecutionQueueItem,
+  ExecutionQueueItemPivotTableLoadPageMetadata,
+  ExecutionQueueItemPivotTableMetadata,
+  PivotTableBlock,
+  YBlock,
+  getPivotTableAttributes,
+} from '@briefer/editor'
+import * as Y from 'yjs'
+import { logger } from '../../../logger.js'
+import { DataFrame } from '@briefer/types'
+import { createPivotTable } from '../../../python/pivot-table.js'
+import { WSSharedDocV2 } from '../index.js'
+
+export type PivotTableEffects = {
+  createPivotTable: typeof createPivotTable
+}
+
+export interface IPivotTableExecutor {
+  run(
+    executionItem: ExecutionQueueItem,
+    block: Y.XmlElement<PivotTableBlock>,
+    metadata: ExecutionQueueItemPivotTableMetadata
+  ): Promise<void>
+  loadPage(
+    executionItem: ExecutionQueueItem,
+    block: Y.XmlElement<PivotTableBlock>,
+    metadata: ExecutionQueueItemPivotTableLoadPageMetadata
+  ): Promise<void>
+}
+
+export class PivotTableExecutor implements IPivotTableExecutor {
+  constructor(
+    private readonly sessionId: string,
+    private readonly workspaceId: string,
+    private readonly documentId: string,
+    private readonly dataframes: Y.Map<DataFrame>,
+    private readonly blocks: Y.Map<YBlock>,
+    private readonly effects: PivotTableEffects
+  ) {}
+
+  public async run(
+    executionItem: ExecutionQueueItem,
+    block: Y.XmlElement<PivotTableBlock>,
+    _metadata: ExecutionQueueItemPivotTableMetadata
+  ) {
+    return this._run(executionItem, block, 'create')
+  }
+
+  public async loadPage(
+    executionItem: ExecutionQueueItem,
+    block: Y.XmlElement<PivotTableBlock>,
+    _metadata: ExecutionQueueItemPivotTableLoadPageMetadata
+  ) {
+    return this._run(executionItem, block, 'read')
+  }
+
+  private async _run(
+    executionItem: ExecutionQueueItem,
+    block: Y.XmlElement<PivotTableBlock>,
+    operation: 'create' | 'read'
+  ) {
+    try {
+      logger().trace(
+        {
+          sessionId: this.sessionId,
+          workspaceId: this.workspaceId,
+          documentId: this.documentId,
+          blockId: block.getAttribute('id'),
+        },
+        'running pivot table block'
+      )
+
+      const dataframeName = block.getAttribute('dataframeName')
+      if (!dataframeName) {
+        executionItem.setCompleted('error')
+        return
+      }
+
+      const dataframe = this.dataframes.get(dataframeName)
+      if (!dataframe) {
+        executionItem.setCompleted('error')
+        return
+      }
+
+      const attrs = getPivotTableAttributes(block, this.blocks)
+
+      if (operation === 'create') {
+        const rows = attrs.rows
+          .map((row) => row.column)
+          .filter((row) => row !== null)
+        const cols = attrs.columns
+          .map((col) => col.column)
+          .filter((col) => col !== null)
+        const metrics = attrs.metrics
+          .map((metric) => metric.column)
+          .filter((metric) => metric !== null)
+
+        if (rows.length === 0 || cols.length === 0 || metrics.length === 0) {
+          block.setAttribute('updatedAt', new Date().toISOString())
+          block.setAttribute('error', null)
+          executionItem.setCompleted('success')
+          return
+        }
+
+        const all = [...rows, ...cols, ...metrics]
+
+        for (const column of all) {
+          if (
+            column &&
+            dataframe.columns.findIndex(
+              (c) => c.name.toString() === column.name.toString()
+            ) === -1
+          ) {
+            block.setAttribute('updatedAt', new Date().toISOString())
+            block.setAttribute('error', null)
+            executionItem.setCompleted('success')
+            return
+          }
+        }
+      }
+
+      let aborted = false
+      let cleanup = executionItem.observeStatus((status) => {
+        if (status._tag === 'aborting') {
+          aborted = true
+        }
+      })
+      const { promise, abort } = await this.effects.createPivotTable(
+        this.workspaceId,
+        this.sessionId,
+        dataframe,
+        attrs.rows,
+        attrs.columns,
+        attrs.metrics,
+        attrs.sort,
+        attrs.variable.value,
+        attrs.page,
+        operation
+      )
+
+      if (aborted) {
+        await abort()
+      }
+
+      let abortP = Promise.resolve(aborted)
+      cleanup()
+      cleanup = executionItem.observeStatus((status) => {
+        if (status._tag === 'aborting') {
+          abortP = abort().then(() => true)
+        }
+      })
+
+      const result = await promise
+      aborted = await abortP
+      cleanup()
+
+      if (aborted) {
+        executionItem.setCompleted('aborted')
+        return
+      }
+
+      if (!result.success) {
+        if (result.reason !== 'aborted') {
+          block.setAttribute('error', result.reason)
+        }
+        executionItem.setCompleted(
+          result.reason === 'aborted' ? 'aborted' : 'error'
+        )
+      } else {
+        block.setAttribute('updatedAt', new Date().toISOString())
+        block.setAttribute('error', null)
+        block.setAttribute('result', result.result)
+        block.setAttribute('page', result.result.page)
+        executionItem.setCompleted('success')
+      }
+
+      logger().trace(
+        {
+          sessionId: this.sessionId,
+          workspaceId: this.workspaceId,
+          documentId: this.documentId,
+          blockId: block.getAttribute('id'),
+        },
+        'pivot table block run completed'
+      )
+    } catch (err) {
+      logger().error(
+        {
+          workspaceId: this.workspaceId,
+          documentId: this.documentId,
+          blockId: block.getAttribute('id'),
+          err,
+        },
+        'Failed to run pivot table block'
+      )
+
+      block.setAttribute('error', 'unknown')
+      executionItem.setCompleted('error')
+    }
+  }
+
+  public static fromWSSharedDocV2(doc: WSSharedDocV2) {
+    return new PivotTableExecutor(
+      doc.id,
+      doc.workspaceId,
+      doc.documentId,
+      doc.dataframes,
+      doc.blocks,
+      { createPivotTable }
+    )
+  }
+}
diff --git a/apps/api/src/yjs/v2/executor/python.ts b/apps/api/src/yjs/v2/executor/python.ts
new file mode 100644
index 00000000..18e93102
--- /dev/null
+++ b/apps/api/src/yjs/v2/executor/python.ts
@@ -0,0 +1,154 @@
+import * as Y from 'yjs'
+import {
+  ExecutionQueueItem,
+  getPythonAttributes,
+  PythonBlock,
+  YBlock,
+} from '@briefer/editor'
+import { executeCode as executePython } from '../../../python/index.js'
+import { logger } from '../../../logger.js'
+import { ExecutionQueueItemPythonMetadata } from '@briefer/editor/types/execution/item.js'
+import { listDataFrames } from '../../../python/query/index.js'
+import { updateDataframes } from './index.js'
+import { DataFrame } from '@briefer/types'
+import { PythonEvents } from '../../../events/index.js'
+import { WSSharedDocV2 } from '../index.js'
+
+export type PythonEffects = {
+  executePython: typeof executePython
+  listDataFrames: typeof listDataFrames
+}
+
+export interface IPythonExecutor {
+  run(
+    executionItem: ExecutionQueueItem,
+    block: Y.XmlElement<PythonBlock>,
+    metadata: ExecutionQueueItemPythonMetadata,
+    events: PythonEvents
+  ): Promise<void>
+}
+
+export class PythonExecutor implements IPythonExecutor {
+  constructor(
+    private readonly sessionId: string,
+    private readonly workspaceId: string,
+    private readonly documentId: string,
+    private dataframes: Y.Map<DataFrame>,
+    private readonly blocks: Y.Map<YBlock>,
+    private readonly effects: PythonEffects
+  ) {}
+
+  public async run(
+    executionItem: ExecutionQueueItem,
+    block: Y.XmlElement<PythonBlock>,
+    metadata: ExecutionQueueItemPythonMetadata,
+    events: PythonEvents
+  ): Promise<void> {
+    events.pythonRun()
+    block.setAttribute('result', [])
+
+    try {
+      block.setAttribute('startQueryTime', new Date().toISOString())
+
+      logger().trace(
+        {
+          sessionId: this.sessionId,
+          workspaceId: this.workspaceId,
+          documentId: this.documentId,
+          blockId: block.getAttribute('id'),
+        },
+        'executing python block'
+      )
+
+      const { aiSuggestions, source, id: blockId } = getPythonAttributes(block)
+
+      const actualSource =
+        (metadata.isSuggestion ? aiSuggestions : source)?.toJSON() ?? ''
+
+      let errored = false
+      const { promise, abort } = await this.effects.executePython(
+        this.workspaceId,
+        this.sessionId,
+        actualSource,
+        (outputs) => {
+          const prevOutputs = block.getAttribute('result') ?? []
+          block.setAttribute('result', prevOutputs.concat(outputs))
+          if (!errored) {
+            for (const output of outputs) {
+              if (output.type === 'error') {
+                errored = true
+                break
+              }
+            }
+          }
+        },
+        { storeHistory: true }
+      )
+      let abortP = Promise.resolve(false)
+      const cleanup = executionItem.observeStatus((status) => {
+        if (status._tag === 'aborting') {
+          abortP = abort().then(() => true)
+        }
+      })
+      await promise
+      const aborted = await abortP
+      if (aborted) {
+        executionItem.setCompleted('aborted')
+        cleanup()
+        return
+      }
+
+      await this.updateDataFrames(blockId)
+
+      block.setAttribute('lastQuery', block.getAttribute('source')!.toJSON())
+      block.setAttribute('lastQueryTime', new Date().toISOString())
+      logger().trace(
+        {
+          sessionId: this.sessionId,
+          workspaceId: this.workspaceId,
+          documentId: this.documentId,
+          blockId: block.getAttribute('id'),
+        },
+        'python block executed'
+      )
+      executionItem.setCompleted(errored ? 'error' : 'success')
+    } catch (err) {
+      logger().error(
+        {
+          sessionId: this.sessionId,
+          workspaceId: this.workspaceId,
+          documentId: this.documentId,
+          blockId: block.getAttribute('id'),
+          err,
+        },
+        'Error while executing python block'
+      )
+      executionItem.setCompleted('error')
+    }
+  }
+
+  private async updateDataFrames(blockId: string) {
+    const newDataframes = await this.effects.listDataFrames(
+      this.workspaceId,
+      this.sessionId
+    )
+
+    const blocks = new Set(Array.from(this.blocks.keys()))
+
+    updateDataframes(this.dataframes, newDataframes, blockId, blocks)
+  }
+
+  public static fromWSSharedDocV2(doc: WSSharedDocV2) {
+    return new PythonExecutor(
+      doc.id,
+      doc.workspaceId,
+      doc.documentId,
+      doc.dataframes,
+      doc.blocks,
+      {
+        executePython,
+        listDataFrames,
+      }
+    )
+  }
+}
diff --git a/apps/api/src/yjs/v2/executor/sql.ts b/apps/api/src/yjs/v2/executor/sql.ts
new file mode 100644
index 00000000..4a9ace81
--- /dev/null
+++ b/apps/api/src/yjs/v2/executor/sql.ts
@@ -0,0 +1,360 @@
+import {
+  ExecutionQueueItem,
+  ExecutionQueueItemSQLMetadata,
+  ExecutionQueueItemSQLRenameDataframeMetadata,
+  SQLBlock,
+  YBlock,
+  getSQLAttributes,
+} from '@briefer/editor'
+import * as Y from 'yjs'
+import prisma, { listDataSources } from '@briefer/database'
+import {
+  listDataFrames,
+  makeSQLQuery,
+  renameDataFrame,
+} from '../../../python/query/index.js'
+import { logger } from '../../../logger.js'
+import { DataFrame, RunQueryResult } from '@briefer/types'
+import { SQLEvents } from '../../../events/index.js'
+import { WSSharedDocV2 } from '../index.js'
+import { updateDataframes } from './index.js'
+
+export type SQLEffects = {
+  makeSQLQuery: typeof makeSQLQuery
+  listDataSources: typeof listDataSources
+  renameDataFrame: typeof renameDataFrame
+  listDataFrames: typeof listDataFrames
+  documentHasRunSQLSelectionEnabled: (id: string) => Promise<boolean>
+}
+
+export interface ISQLExecutor {
+  run(
+    executionItem: ExecutionQueueItem,
+    block: Y.XmlElement<SQLBlock>,
+    metadata: ExecutionQueueItemSQLMetadata,
+    events: SQLEvents
+  ): Promise<void>
+  renameDataframe(
+    executionItem: ExecutionQueueItem,
+    block: Y.XmlElement<SQLBlock>,
+    metadata: ExecutionQueueItemSQLRenameDataframeMetadata,
+    events: SQLEvents
+  ): Promise<void>
+}
+
+export class SQLExecutor implements ISQLExecutor {
+  constructor(
+    private readonly sessionId: string,
+    private readonly workspaceId: string,
+    private readonly documentId: string,
+    private readonly dataSourcesEncryptionKey: string,
+    private readonly dataframes: Y.Map<DataFrame>,
+    private readonly blocks: Y.Map<YBlock>,
+    private readonly effects: SQLEffects
+  ) {}
+
+  public async run(
+    executionItem: ExecutionQueueItem,
+    block: Y.XmlElement<SQLBlock>,
+    metadata: ExecutionQueueItemSQLMetadata,
+    events: SQLEvents
+  ) {
+    events.sqlRun()
+
+    try {
+      block.setAttribute('startQueryTime', new Date().toISOString())
+
+      logger().trace(
+        {
+          workspaceId: this.workspaceId,
+          documentId: this.documentId,
+          blockId: block.getAttribute('id'),
+        },
+        'executing query'
+      )
+
+      let aborted = false
+      let cleanup = executionItem.observeStatus((status) => {
+        if (status._tag === 'aborting') {
+          aborted = true
+        }
+      })
+      const {
+        id: blockId,
+        aiSuggestions,
+        source,
+        configuration,
+        dataSourceId,
+        dataframeName,
+        isFileDataSource,
+      } = getSQLAttributes(block, this.blocks)
+
+      if ((!dataSourceId && !isFileDataSource) || !dataframeName) {
+        executionItem.setCompleted('error')
+        cleanup()
+        return
+      }
+
+      const datasource = (
+        await this.effects.listDataSources(this.workspaceId)
+      ).find((ds) => ds.data.id === dataSourceId)
+
+      if (aborted) {
+        executionItem.setCompleted('aborted')
+        block.setAttribute('result', {
+          type: 'abort-error',
+          message: 'Query aborted',
+        })
+        cleanup()
+        return
+      }
+
+      if (!datasource && !isFileDataSource) {
+        // the selected datasource was deleted
+        // recover this block state by removing the datasourceId
+        block.removeAttribute('dataSourceId')
+        executionItem.setCompleted('error')
+        cleanup()
+        return
+      }
+
+      block.removeAttribute('result')
+
+      let actualSource =
+        (metadata.isSuggestion ? aiSuggestions : source)?.toJSON().trim() ?? ''
+      if (metadata.selectedCode) {
+        const hasRunSQLSelection =
+          await this.effects.documentHasRunSQLSelectionEnabled(this.documentId)
+        if (hasRunSQLSelection) {
+          actualSource = metadata.selectedCode
+        }
+      }
+
+      let resultType: RunQueryResult['type'] | 'empty-query' = 'empty-query'
+      if (actualSource !== '') {
+        const [promise, abort] = await this.effects.makeSQLQuery(
+          this.workspaceId,
+          this.sessionId,
+          blockId,
+          dataframeName.value,
+          datasource ?? 'duckdb',
+          this.dataSourcesEncryptionKey,
+          actualSource,
+          (result) => {
+            block.setAttribute('result', result)
+          },
+          configuration
+        )
+        cleanup()
+
+        if (aborted) {
+          executionItem.setCompleted('aborted')
+          await abort()
+          await promise
+          block.setAttribute('result', {
+            type: 'abort-error',
+            message: 'Query aborted',
+          })
+          return
+        }
+
+        let abortP = Promise.resolve(false)
+        cleanup = executionItem.observeStatus((status) => {
+          if (status._tag === 'aborting') {
+            abortP = abort().then(() => true)
+          }
+        })
+
+        const result = await promise
+        aborted = await abortP
+        if (aborted) {
+          executionItem.setCompleted('aborted')
+          cleanup()
+          block.setAttribute('result', {
+            type: 'abort-error',
+            message: 'Query aborted',
+          })
+          return
+        }
+
+        block.setAttribute('lastQuery', actualSource)
+        block.setAttribute('lastQueryTime', new Date().toISOString())
+        if (result.type === 'python-error') {
+          logger().error(
+            {
+              sessionId: this.sessionId,
+              workspaceId: this.workspaceId,
+              documentId: this.documentId,
+              blockId: block.getAttribute('id'),
+              err: result,
+            },
+            'got a python error while running sql query'
+          )
+          block.setAttribute('result', {
+            ...result,
+            traceback: [],
+          })
+        }
+        block.setAttribute('result', result)
+        if (result.type === 'success') {
+          const df = {
+            id: blockId,
+            name: dataframeName.value,
+            columns: result.columns,
+            blockId,
+            updatedAt: new Date().toISOString(),
+          }
+          this.dataframes.set(dataframeName.value, df)
+        } else if (result.type === 'syntax-error') {
+          logger().warn(
+            {
+              sessionId: this.sessionId,
+              workspaceId: this.workspaceId,
+              documentId: this.documentId,
+              blockId: block.getAttribute('id'),
+              err: result,
+            },
+            'got a syntax error while running sql query'
+          )
+        }
+
+        resultType = result.type
+      }
+
+      executionItem.setCompleted(
+        resultType === 'success'
+          ? 'success'
+          : resultType === 'abort-error'
+          ? 'aborted'
+          : 'error'
+      )
+
+      logger().trace(
+        {
+          sessionId: this.sessionId,
+          workspaceId: this.workspaceId,
+          documentId: this.documentId,
+          blockId: block.getAttribute('id'),
+          result: resultType,
+        },
+        'sql block executed'
+      )
+    } catch (err) {
+      logger().error(
+        {
+          sessionId: this.sessionId,
+          workspaceId: this.workspaceId,
+          documentId: this.documentId,
+          blockId: block.getAttribute('id'),
+          err,
+        },
+        'Error while executin sql block'
+      )
+      executionItem.setCompleted('error')
+    }
+  }
+
+  public async renameDataframe(
+    executionItem: ExecutionQueueItem,
+    block: Y.XmlElement<SQLBlock>,
+    _metadata: ExecutionQueueItemSQLRenameDataframeMetadata
+  ) {
+    const {
+      id: blockId,
+      dataframeName,
+      result,
+    } = getSQLAttributes(block, this.blocks)
+    const dfNameRegex = /^[a-zA-Z_][a-zA-Z0-9_]*$/
+    if (!dfNameRegex.test(dataframeName.newValue)) {
+      block.setAttribute('dataframeName', {
+        ...dataframeName,
+        error: 'invalid-name',
+      })
+      executionItem.setCompleted('error')
+      return
+    }
+
+    if (result?.type !== 'success') {
+      block.setAttribute('dataframeName', {
+        ...dataframeName,
+        value: dataframeName.newValue,
+      })
+      executionItem.setCompleted(
+        result?.type === 'abort-error' ? 'aborted' : 'error'
+      )
+      return
+    }
+
+    logger().trace(
+      {
+        sessionId: this.sessionId,
+        workspaceId: this.workspaceId,
+        documentId: this.documentId,
+        blockId: block.getAttribute('id'),
+        dataframeName,
+      },
+      'renaming dataframe'
+    )
+
+    try {
+      await this.effects.renameDataFrame(
+        this.workspaceId,
+        this.sessionId,
+        dataframeName.value,
+        dataframeName.newValue
+      )
+
+      const dataframes = await this.effects.listDataFrames(
+        this.workspaceId,
+        this.sessionId
+      )
+
+      const blocks = new Set(Array.from(this.blocks.keys()))
+      updateDataframes(this.dataframes, dataframes, blockId, blocks)
+      block.setAttribute('dataframeName', {
+        ...dataframeName,
+        value: dataframeName.newValue,
+        error: undefined,
+      })
+      executionItem.setCompleted('success')
+    } catch (err) {
+      logger().error(
+        {
+          workspaceId: this.workspaceId,
+          documentId: this.documentId,
+          blockId,
+          err,
+        },
+        'Error while renaming dataframe'
+      )
+      executionItem.setCompleted('error')
+    }
+  }
+
+  public static fromWSSharedDocV2(
+    doc: WSSharedDocV2,
+    dataSourcesEncryptionKey: string
+  ) {
+    return new SQLExecutor(
+      doc.id,
+      doc.workspaceId,
+      doc.documentId,
+      dataSourcesEncryptionKey,
+      doc.dataframes,
+      doc.blocks,
+      {
+        makeSQLQuery,
+        listDataSources,
+        renameDataFrame,
+        listDataFrames,
+        documentHasRunSQLSelectionEnabled: (id: string) =>
+          prisma()
+            .document.findFirst({
+              where: { id },
+              select: { runSQLSelection: true },
+            })
+            .then((doc) => doc?.runSQLSelection ?? false),
+      }
+    )
+  }
+}
diff --git a/apps/api/src/yjs/v2/executor/text-input.ts b/apps/api/src/yjs/v2/executor/text-input.ts
new file mode 100644
index 00000000..e5679e9f
--- /dev/null
+++ b/apps/api/src/yjs/v2/executor/text-input.ts
@@ -0,0 +1,210 @@
+import {
+  InputBlock,
+  YBlock,
+  getInputAttributes,
+  updateInputValue,
+  updateInputVariable,
+  updateInputBlockExecutedAt,
+  ExecutionQueueItem,
+  ExecutionQueueItemTextInputSaveValueMetadata,
+  ExecutionQueueItemTextInputRenameVariableMetadata,
+} from '@briefer/editor'
+import * as Y from 'yjs'
+import { setVariable } from '../../../python/input.js'
+import { logger } from '../../../logger.js'
+import { WSSharedDocV2 } from '../index.js'
+
+export type InputEffects = {
+  setVariable: typeof setVariable
+}
+
+export interface ITextInputExecutor {
+  saveValue(
+    executionItem: ExecutionQueueItem,
+    block: Y.XmlElement<InputBlock>,
+    metadata: ExecutionQueueItemTextInputSaveValueMetadata
+  ): Promise<void>
+  renameVariable(
+    executionItem: ExecutionQueueItem,
+    block: Y.XmlElement<InputBlock>,
+    metadata: ExecutionQueueItemTextInputRenameVariableMetadata
+  ): Promise<void>
+}
+
+export class TextInputExecutor implements ITextInputExecutor {
+  constructor(
+    private readonly sessionId: string,
+    private readonly workspaceId: string,
+    private readonly documentId: string,
+    private readonly blocks: Y.Map<YBlock>,
+    private readonly effects: InputEffects = {
+      setVariable,
+    }
+  ) {}
+
+  public async renameVariable(
+    executionItem: ExecutionQueueItem,
+    block: Y.XmlElement<InputBlock>,
+    _metadata: ExecutionQueueItemTextInputRenameVariableMetadata
+  ) {
+    try {
+      const attrs = getInputAttributes(block, this.blocks)
+
+      const { newValue: newVariableName } = attrs.variable
+      const { value } = attrs.value
+
+      const dfNameRegex = /^[a-zA-Z_][a-zA-Z0-9_]*$/
+      if (!dfNameRegex.test(newVariableName)) {
+        updateInputVariable(block, this.blocks, {
+          error: 'invalid-variable-name',
+        })
+        executionItem.setCompleted('error')
+        return
+      }
+
+      logger().trace(
+        {
+          sessionId: this.sessionId,
+          workspaceId: this.workspaceId,
+          documentId: this.documentId,
+          blockId: attrs.id,
+          variableName: newVariableName,
+        },
+        'Saving input variable'
+      )
+
+      let aborted = false
+      let cleanup = executionItem.observeStatus((status) => {
+        if (status._tag === 'aborting') {
+          aborted = true
+        }
+      })
+
+      const { promise, abort } = await this.effects.setVariable(
+        this.workspaceId,
+        this.sessionId,
+        newVariableName,
+        value
+      )
+
+      if (aborted) {
+        await abort()
+      }
+
+      let abortP = Promise.resolve(aborted)
+      cleanup()
+      cleanup = executionItem.observeStatus((status) => {
+        if (status._tag === 'aborting') {
+          abortP = abort().then(() => true)
+        }
+      })
+
+      await promise
+      aborted = await abortP
+      cleanup()
+
+      if (!aborted) {
+        updateInputVariable(block, this.blocks, {
+          value: newVariableName,
+          error: null,
+        })
+        logger().trace(
+          {
+            sessionId: this.sessionId,
+            workspaceId: this.workspaceId,
+            documentId: this.documentId,
+            blockId: attrs.id,
+            variableName: newVariableName,
+          },
+          'Saved input variable'
+        )
+      }
+      executionItem.setCompleted(aborted ? 'aborted' : 'success')
+    } catch (err) {
+      logger().error(
+        {
+          workspaceId: this.workspaceId,
+          documentId: this.documentId,
+          blockId: executionItem.getBlockId(),
+          error: err,
+        },
+        'Failed to rename text input variable'
+      )
+
+      updateInputVariable(block, this.blocks, {
+        error: 'unexpected-error',
+      })
+      executionItem.setCompleted('error')
+    }
+  }
+
+  public async saveValue(
+    executionItem: ExecutionQueueItem,
+    block: Y.XmlElement<InputBlock>,
+    _metadata: ExecutionQueueItemTextInputSaveValueMetadata
+  ) {
+    try {
+      const attrs = getInputAttributes(block, this.blocks)
+      const { value: variableName } = attrs.variable
+      const { newValue } = attrs.value
+
+      logger().trace(
+        {
+          sessionId: this.sessionId,
+          workspaceId: this.workspaceId,
+          documentId: this.documentId,
+          blockId: block.getAttribute('id'),
+          variableName,
+        },
+        'Saving input value'
+      )
+
+      await this.effects
+        .setVariable(this.workspaceId, this.sessionId, variableName, newValue)
+        .then(({ promise }) => promise)
+      updateInputValue(block, {
+        value: newValue,
+        error: null,
+      })
+      updateInputBlockExecutedAt(block, new Date())
+      logger().trace(
+        {
+          workspaceId: this.workspaceId,
+          documentId: this.documentId,
+          blockId: attrs.id,
+          variableName,
+        },
+        'Saved input value'
+      )
+      executionItem.setCompleted('success')
+    } catch (err) {
+      logger().error(
+        {
+          sessionId: this.sessionId,
+          workspaceId: this.workspaceId,
+          documentId: this.documentId,
+          blockId: executionItem.getBlockId(),
+          error: err,
+        },
+        'Failed to save text input value'
+      )
+
+      updateInputValue(block, {
+        error: 'unexpected-error',
+      })
+      executionItem.setCompleted('error')
+    }
+  }
+
+  public static fromWSSharedDocV2(doc: WSSharedDocV2) {
+    return new TextInputExecutor(
+      doc.id,
+      doc.workspaceId,
+      doc.documentId,
+      doc.blocks,
+      {
+        setVariable,
+      }
+    )
+  }
+}
diff --git a/apps/api/src/yjs/v2/executor/visualization.ts b/apps/api/src/yjs/v2/executor/visualization.ts
new file mode 100644
index 00000000..b0ec6669
--- /dev/null
+++ b/apps/api/src/yjs/v2/executor/visualization.ts
@@ -0,0 +1,215 @@
+import {
+  ExecutionQueueItem,
+  ExecutionQueueItemVisualizationMetadata,
+  VisualizationBlock,
+  getVisualizationAttributes,
+} from '@briefer/editor'
+import * as Y from 'yjs'
+import { logger } from '../../../logger.js'
+import {
+  DataFrame,
+  isInvalidVisualizationFilter,
+  parseOrElse,
+} from '@briefer/types'
+import { createVisualization } from '../../../python/visualizations.js'
+import { z } from 'zod'
+import { VisEvents } from '../../../events/index.js'
+import { WSSharedDocV2 } from '../index.js'
+
+export type VisualizationEffects = {
+  createVisualization: typeof createVisualization
+}
+
+export interface IVisualizationExecutor {
+  run(
+    executionItem: ExecutionQueueItem,
+    block: Y.XmlElement<VisualizationBlock>,
+    metadata: ExecutionQueueItemVisualizationMetadata,
+    events: VisEvents
+  ): Promise<void>
+}
+
+export class VisualizationExecutor implements IVisualizationExecutor {
+  constructor(
+    private readonly sessionId: string,
+    private readonly workspaceId: string,
+    private readonly documentId: string,
+    private readonly dataframes: Y.Map<DataFrame>,
+    private readonly effects: VisualizationEffects
+  ) {}
+
+  public async run(
+    executionItem: ExecutionQueueItem,
+    block: Y.XmlElement<VisualizationBlock>,
+    _metadata: ExecutionQueueItemVisualizationMetadata,
+    events: VisEvents
+  ) {
+    block.removeAttribute('result')
+    try {
+      logger().trace(
+        {
+          sessionId: this.sessionId,
+          workspaceId: this.workspaceId,
+          documentId: this.documentId,
+          blockId: block.getAttribute('id'),
+        },
+        'running visualization block'
+      )
+
+      const {
+        chartType,
+        xAxis,
+        xAxisName,
+        xAxisGroupFunction,
+        xAxisSort,
+        yAxes,
+        histogramBin,
+        histogramFormat,
+        showDataLabels,
+        numberValuesFormat,
+        filters,
+        dataframeName,
+      } = getVisualizationAttributes(block)
+      if (!dataframeName) {
+        executionItem.setCompleted('error')
+        return
+      }
+      const dataframe = this.dataframes.get(dataframeName)
+
+      const hasAValidYAxis = yAxes.some((yAxis) =>
+        yAxis.series.some((s) => s.column !== null)
+      )
+
+      if (
+        !dataframe ||
+        (!xAxis && chartType !== 'number' && chartType !== 'trend') ||
+        (!hasAValidYAxis && chartType !== 'histogram')
+      ) {
+        executionItem.setCompleted('error')
+        return
+      }
+
+      const validFilters = filters.filter(
+        (f) =>
+          dataframe.columns.some((c) => c.name === f.column?.name) &&
+          !isInvalidVisualizationFilter(f, dataframe)
+      )
+
+      let aborted = false
+      let cleanup = executionItem.observeStatus((status) => {
+        if (status._tag === 'aborting') {
+          aborted = true
+        }
+      })
+
+      events.visUpdate(chartType)
+      const { promise, abort } = await this.effects.createVisualization(
+        this.workspaceId,
+        this.sessionId,
+        dataframe,
+        chartType,
+        xAxis,
+        xAxisName,
+        xAxisGroupFunction,
+        xAxisSort,
+        yAxes,
+        histogramFormat,
+        histogramBin,
+        showDataLabels,
+        numberValuesFormat,
+        validFilters
+      )
+
+      if (aborted) {
+        await abort()
+      }
+
+      let abortP = Promise.resolve(aborted)
+      cleanup()
+      cleanup = executionItem.observeStatus((status) => {
+        if (status._tag === 'aborting') {
+          abortP = abort().then(() => true)
+        }
+      })
+
+      const result = await promise
+      aborted = await abortP
+      cleanup()
+
+      if (Object.keys(result.filterResults).length > 0) {
+        const nextFilters = filters.map((f) => {
+          const next = result.filterResults[f.id]
+          if (next) {
+            return next
+          }
+
+          return f
+        })
+        block.setAttribute('filters', nextFilters)
+      }
+
+      if (aborted) {
+        executionItem.setCompleted('aborted')
+        block.setAttribute('spec', null)
+        return
+      }
+
+      if (!result.success) {
+        if (result.reason === 'aborted') {
+          executionItem.setCompleted('aborted')
+        } else {
+          block.setAttribute('error', result.reason)
+          executionItem.setCompleted('error')
+        }
+
+        block.setAttribute('spec', null)
+        return
+      }
+
+      block.setAttribute('spec', result.spec)
+      const capped = parseOrElse(
+        z.object({ usermeta: z.object({ capped: z.boolean() }) }),
+        result.spec,
+        { usermeta: { capped: false } }
+      ).usermeta.capped
+      block.setAttribute('tooManyDataPointsHidden', !capped)
+      block.setAttribute('updatedAt', new Date().toISOString())
+      block.setAttribute('error', null)
+      executionItem.setCompleted('success')
+
+      logger().trace(
+        {
+          workspaceId: this.workspaceId,
+          documentId: this.documentId,
+          blockId: block.getAttribute('id'),
+        },
+        'visualization block run completed'
+      )
+    } catch (err) {
+      logger().error(
+        {
+          sessionId: this.sessionId,
+          workspaceId: this.workspaceId,
+          documentId: this.documentId,
+          blockId: block.getAttribute('id'),
+          err,
+        },
+        'Failed to run visualization block'
+      )
+
+      block.setAttribute('error', 'unknown')
+      block.setAttribute('spec', null)
+      executionItem.setCompleted('error')
+    }
+  }
+
+  public static fromWSSharedDocV2(doc: WSSharedDocV2) {
+    return new VisualizationExecutor(
+      doc.id,
+      doc.workspaceId,
+      doc.documentId,
+      doc.dataframes,
+      { createVisualization }
+    )
+  }
+}
diff --git a/apps/api/src/yjs/v2/executor/writeback.ts b/apps/api/src/yjs/v2/executor/writeback.ts
new file mode 100644
index 00000000..b4fa5c31
--- /dev/null
+++ b/apps/api/src/yjs/v2/executor/writeback.ts
@@ -0,0 +1,165 @@
+import {
+  ExecutionQueueItem,
+  ExecutionQueueItemWritebackMetadata,
+  WritebackBlock,
+  getWritebackAttributes,
+} from '@briefer/editor'
+
+import * as Y from 'yjs'
+import { logger } from '../../../logger.js'
+import { WritebackEvents } from '../../../events/index.js'
+import { writeback } from '../../../python/writeback/index.js'
+import { listDataSources } from '@briefer/database'
+import { config } from '../../../config/index.js'
+import { WSSharedDocV2 } from '../index.js'
+
+export type WritebackEffects = {
+  writeback: typeof writeback
+}
+
+export interface IWritebackExecutor {
+  run(
+    executionItem: ExecutionQueueItem,
+    block: Y.XmlElement<WritebackBlock>,
+    metadata: ExecutionQueueItemWritebackMetadata,
+    events: WritebackEvents
+  ): Promise<void>
+}
+
+export class WritebackExecutor implements IWritebackExecutor {
+  constructor(
+    private readonly sessionId: string,
+    private readonly workspaceId: string,
+    private readonly documentId: string,
+    private readonly effects: WritebackEffects
+  ) {}
+
+  public async run(
+    executionItem: ExecutionQueueItem,
+    block: Y.XmlElement<WritebackBlock>,
+    _metadata: ExecutionQueueItemWritebackMetadata,
+    events: WritebackEvents
+  ) {
+    events.writeback()
+
+    block.setAttribute('result', null)
+
+    try {
+      const executedAt = new Date()
+      logger().trace(
+        {
+          sessionId: this.sessionId,
+          workspaceId: this.workspaceId,
+          documentId: this.documentId,
+          blockId: block.getAttribute('id'),
+        },
+        'executing writeback block'
+      )
+
+      const attrs = getWritebackAttributes(block)
+
+      let aborted = false
+      let cleanup = executionItem.observeStatus((status) => {
+        if (status._tag === 'aborting') {
+          aborted = true
+        }
+      })
+
+      const dataSources = await listDataSources(this.workspaceId)
+
+      if (aborted) {
+        cleanup()
+        executionItem.setCompleted('aborted')
+        return
+      }
+
+      const dataSource = dataSources.find(
+        (ds) => ds.data.id === attrs.dataSourceId
+      )
+      if (!dataSource) {
+        block.setAttribute('result', {
+          _tag: 'error',
+          step: 'validation',
+          reason: 'datasource-not-found',
+          executedAt: executedAt.toISOString(),
+        })
+        executionItem.setCompleted('error')
+        cleanup()
+        return
+      }
+
+      if (!attrs.dataframeName) {
+        block.setAttribute('result', {
+          _tag: 'error',
+          step: 'validation',
+          reason: 'dataframe-not-found',
+          executedAt: executedAt.toISOString(),
+        })
+        executionItem.setCompleted('error')
+        cleanup()
+        return
+      }
+
+      const tableName = attrs.tableName.toJSON()
+      const { promise, abort } = await this.effects.writeback(
+        this.workspaceId,
+        this.sessionId,
+        attrs.dataframeName,
+        dataSource,
+        tableName,
+        attrs.overwriteTable,
+        attrs.onConflict,
+        attrs.onConflictColumns,
+        config().DATASOURCES_ENCRYPTION_KEY
+      )
+
+      if (aborted) {
+        await abort()
+      }
+
+      let abortP = Promise.resolve(aborted)
+      cleanup()
+      cleanup = executionItem.observeStatus((status) => {
+        if (status._tag === 'aborting') {
+          abortP = abort().then(() => true)
+        }
+      })
+
+      const result = await promise
+      aborted = await abortP
+      cleanup()
+
+      block.setAttribute('result', result)
+      executionItem.setCompleted(
+        aborted ? 'aborted' : result._tag === 'success' ? 'success' : 'error'
+      )
+      logger().trace(
+        {
+          sessionId: this.sessionId,
+          workspaceId: this.workspaceId,
+          documentId: this.documentId,
+          blockId: executionItem.getBlockId(),
+          result: result._tag,
+        },
+        'writeback block executed'
+      )
+    } catch (err) {
+      logger().error(
+        {
+          workspaceId: this.workspaceId,
+          documentId: this.documentId,
+          blockId: executionItem.getBlockId(),
+          err,
+        },
+        'writeback block error'
+      )
+      executionItem.setCompleted('error')
+    }
+  }
+
+  public static fromWSSharedDocV2(doc: WSSharedDocV2) {
+    return new WritebackExecutor(doc.id, doc.workspaceId, doc.documentId, {
+      writeback,
+    })
+  }
+}
diff --git a/apps/api/src/yjs/v2/executors/blocks/date-input.ts b/apps/api/src/yjs/v2/executors/blocks/date-input.ts
deleted file mode 100644
index 0c4ffc45..00000000
--- a/apps/api/src/yjs/v2/executors/blocks/date-input.ts
+++ /dev/null
@@ -1,123 +0,0 @@
-import { DateInputBlock, YBlock, getDateInputAttributes } from '@briefer/editor'
-import PQueue from 'p-queue'
-import * as Y from 'yjs'
-import { logger } from '../../../../logger.js'
-import { setDateTimeVariable } from '../../../../python/input.js'
-
-export type DateInputEffects = {
-  setDateTimeVariable: typeof setDateTimeVariable
-}
-
-export interface IDateInputExecutor {
-  isIdle(): boolean
-  save(block: Y.XmlElement<DateInputBlock>): Promise<boolean>
-}
-
-export class DateInputExecutor implements IDateInputExecutor {
-  private workspaceId: string
-  private documentId: string
-  private executionQueue: PQueue
-  private blocks: Y.Map<YBlock>
-  private effects: DateInputEffects
-
-  constructor(
-    workspaceId: string,
-    documentId: string,
-    blocks: Y.Map<YBlock>,
-    executionQueue: PQueue,
-    effects: DateInputEffects = {
-      setDateTimeVariable,
-    }
-  ) {
-    this.workspaceId = workspaceId
-    this.documentId = documentId
-    this.blocks = blocks
-    this.executionQueue = executionQueue
-    this.effects = effects
-  }
-
-  public isIdle() {
-    return this.executionQueue.size === 0 && this.executionQueue.pending === 0
-  }
-
-  public async save(block: Y.XmlElement<DateInputBlock>) {
-    const {
-      id: blockId,
-      variable,
-      value,
-    } = getDateInputAttributes(block, this.blocks)
-
-    const dfNameRegex = /^[a-zA-Z_][a-zA-Z0-9_]*$/
-    if (!dfNameRegex.test(variable)) {
-      block.setAttribute('status', 'invalid-variable')
-      return false
-    }
-
-    logger().trace(
-      {
-        workspaceId: this.workspaceId,
-        documentId: this.documentId,
-        blockId,
-        variable,
-        value,
-      },
-      'Adding date input variable to execution queue'
-    )
-
-    const success = await this.executionQueue.add(async () => {
-      logger().trace(
-        {
-          workspaceId: this.workspaceId,
-          documentId: this.documentId,
-          blockId,
-          variable,
-          value,
-        },
-        'Saving date input variable'
-      )
-
-      try {
-        await this.effects.setDateTimeVariable(
-          this.workspaceId,
-          this.documentId,
-          variable,
-          value
-        )
-        return true
-      } catch {
-        block.setAttribute('status', 'unexpected-error')
-        return false
-      } finally {
-        logger().trace(
-          {
-            workspaceId: this.workspaceId,
-            documentId: this.documentId,
-            blockId,
-            variable,
-            value,
-          },
-          'Saved date input variable'
-        )
-      }
-    })
-
-    return success ?? false
-  }
-
-  public static make(
-    workspaceId: string,
-    documentId: string,
-    blocks: Y.Map<YBlock>,
-    executionQueue: PQueue
-  ) {
-    return new DateInputExecutor(
-      workspaceId,
-      documentId,
-      blocks,
-      executionQueue,
-      {
-        setDateTimeVariable,
-      }
-    )
-  }
-}
diff --git a/apps/api/src/yjs/v2/executors/blocks/dropdown-input.ts b/apps/api/src/yjs/v2/executors/blocks/dropdown-input.ts
deleted file mode 100644
index f7b0d62f..00000000
--- a/apps/api/src/yjs/v2/executors/blocks/dropdown-input.ts
+++ /dev/null
@@ -1,224 +0,0 @@
-import {
-  DropdownInputBlock,
-  YBlock,
-  getDropdownInputAttributes,
-  updateDropdownInputValue,
-  updateDropdownInputVariable,
-  updateDropdownInputBlockExecutedAt,
-} from '@briefer/editor'
-import PQueue from 'p-queue'
-import * as Y from 'yjs'
-import { logger } from '../../../../logger.js'
-import { setVariable } from '../../../../python/input.js'
-
-export type DropdownInputEffects = {
-  setVariable: typeof setVariable
-}
-
-export interface IDropdownInputExecutor {
-  isIdle(): boolean
-  saveVariable(block: Y.XmlElement<DropdownInputBlock>): Promise<void>
-  saveValue(block: Y.XmlElement<DropdownInputBlock>): Promise<void>
-  abortSaveValue(block: Y.XmlElement<DropdownInputBlock>): Promise<void>
-}
-
-type Running = {
-  abortController: AbortController
-  abort?: () => Promise<void>
-}
-
-export class DropdownInputExecutor implements IDropdownInputExecutor {
-  private workspaceId: string
-  private documentId: string
-  private executionQueue: PQueue
-  private blocks: Y.Map<YBlock>
-  private effects: DropdownInputEffects
-  private running: Map<Y.XmlElement<DropdownInputBlock>, Running> = new Map()
-
-  constructor(
-    workspaceId: string,
-    documentId: string,
-    blocks: Y.Map<YBlock>,
-    executionQueue: PQueue,
-    effects: DropdownInputEffects = {
-      setVariable,
-    }
-  ) {
-    this.workspaceId = workspaceId
-    this.documentId = documentId
-    this.blocks = blocks
-    this.executionQueue = executionQueue
-    this.effects = effects
-  }
-
-  public isIdle() {
-    return this.executionQueue.size === 0 && this.executionQueue.pending === 0
-  }
-
-  public async saveVariable(block: Y.XmlElement<DropdownInputBlock>) {
-    const attrs = getDropdownInputAttributes(block, this.blocks)
-
-    const { newValue: newVariableName } = attrs.variable
-    const { value } = attrs.value
-
-    const dfNameRegex = /^[a-zA-Z_][a-zA-Z0-9_]*$/
-    if (!dfNameRegex.test(newVariableName)) {
-      updateDropdownInputVariable(block, this.blocks, {
-        error: 'invalid-variable-name',
-      })
-      return
-    }
-
-    logger().trace(
-      {
-        workspaceId: this.workspaceId,
-        documentId: this.documentId,
-        blockId: attrs.id,
-        variableName: newVariableName,
-      },
-      'Adding dropdown input variable to execution queue'
-    )
-
-    const running: Running = { abortController: new AbortController() }
-    this.running.set(block, running)
-    try {
-      await this.executionQueue.add(
-        async ({ signal }) => {
-          logger().trace(
-            {
-              workspaceId: this.workspaceId,
-              documentId: this.documentId,
-              blockId: attrs.id,
-              variableName: newVariableName,
-            },
-            'Saving dropdown input variable'
-          )
-
-          if (value === null) {
-            updateDropdownInputValue(block, {
-              error: 'invalid-value',
-            })
-            return
-          }
-
-          const { promise, abort } = await this.effects.setVariable(
-            this.workspaceId,
-            this.documentId,
-            newVariableName,
-            value
-          )
-
-          if (!signal?.aborted) {
-            await promise
-            updateDropdownInputVariable(block, this.blocks, {
-              value: newVariableName,
-              error: null,
-            })
-            logger().trace(
-              {
-                workspaceId: this.workspaceId,
-                documentId: this.documentId,
-                blockId: attrs.id,
-                variableName: newVariableName,
-              },
-              'Saved dropdown input variable'
-            )
-          } else {
-            await abort()
-          }
-        },
-        { signal: running.abortController.signal }
-      )
-    } catch (e) {
-      if (e instanceof DOMException && e.name === 'AbortError') {
-        return
-      }
-
-      throw e
-    }
-  }
-
-  public async saveValue(block: Y.XmlElement<DropdownInputBlock>) {
-    const attrs = getDropdownInputAttributes(block, this.blocks)
-
-    const { value: variableName } = attrs.variable
-    const { newValue } = attrs.value
-
-    if (newValue === null) {
-      updateDropdownInputValue(block, {
-        error: 'invalid-value',
-      })
-      return
-    }
-
-    logger().trace(
-      {
-        workspaceId: this.workspaceId,
-        documentId: this.documentId,
-        blockId: block.getAttribute('id'),
-        variableName,
-        newValue,
-      },
-      'Adding dropdown input value to execution queue'
-    )
-
-    await this.executionQueue.add(async () => {
-      logger().trace(
-        {
-          workspaceId: this.workspaceId,
-          documentId: this.documentId,
-          blockId: block.getAttribute('id'),
-          variableName,
-          newValue,
-        },
-        'Saving dropdown input value'
-      )
-
-      await this.effects
-        .setVariable(this.workspaceId, this.documentId, variableName, newValue)
-        .then(({ promise }) => promise)
-      updateDropdownInputValue(block, {
-        value: newValue,
-        error: null,
-      })
-      updateDropdownInputBlockExecutedAt(block, new Date())
-      logger().trace(
-        {
-          workspaceId: this.workspaceId,
-          documentId: this.documentId,
-          blockId: attrs.id,
-          variableName,
-          newValue,
-        },
-        'Saved dropdown input value'
-      )
-    })
-  }
-
-  public async abortSaveValue(block: Y.XmlElement<DropdownInputBlock>) {
-    const running = this.running.get(block)
-    if (!running) {
-      return
-    }
-
-    running.abortController.abort()
-    await running.abort?.()
-  }
-
-  public static make(
-    workspaceId: string,
-    documentId: string,
-    blocks: Y.Map<YBlock>,
-    executionQueue: PQueue
-  ) {
-    return new DropdownInputExecutor(
-      workspaceId,
-      documentId,
-      blocks,
-      executionQueue,
-      {
-        setVariable,
-      }
-    )
-  }
-}
diff --git a/apps/api/src/yjs/v2/executors/blocks/file-upload.ts b/apps/api/src/yjs/v2/executors/blocks/file-upload.ts
deleted file mode 100644
index 47f411cf..00000000
--- a/apps/api/src/yjs/v2/executors/blocks/file-upload.ts
+++ /dev/null
@@ -1,34 +0,0 @@
-import PQueue from 'p-queue'
-import { getJupyterManager } from '../../../../jupyter/index.js'
-
-export interface IFileUploadExecutor {
-  isIdle(): boolean
-  deleteFile(fileName: string): Promise<void>
-}
-
-export class FileUploadExecutor implements IFileUploadExecutor {
-  private workspaceId: string
-  private executionQueue: PQueue
-
-  constructor(workspaceId: string, executionQueue: PQueue) {
-    this.workspaceId = workspaceId
-    this.executionQueue = executionQueue
-  }
-
-  public isIdle() {
-    return this.executionQueue.size === 0 && this.executionQueue.pending === 0
-  }
-
-  public async deleteFile(fileName: string) {
-    await this.executionQueue.add(() => this._deleteFile(fileName))
-  }
-
-  private async _deleteFile(fileName: string) {
-    const jupyterManager = getJupyterManager()
-    await jupyterManager.deleteFile(this.workspaceId, fileName)
-  }
-
-  public static make(workspaceId: string) {
-    return new FileUploadExecutor(workspaceId, new PQueue({ concurrency: 3 }))
-  }
-}
diff --git a/apps/api/src/yjs/v2/executors/blocks/input.ts b/apps/api/src/yjs/v2/executors/blocks/input.ts
deleted file mode 100644
index b28c1e9b..00000000
--- a/apps/api/src/yjs/v2/executors/blocks/input.ts
+++ /dev/null
@@ -1,201 +0,0 @@
-import {
-  InputBlock,
-  YBlock,
-  getInputAttributes,
-  updateInputValue,
-  updateInputVariable,
-  updateInputBlockExecutedAt,
-} from '@briefer/editor'
-import PQueue from 'p-queue'
-import * as Y from 'yjs'
-import { logger } from '../../../../logger.js'
-import { setVariable } from '../../../../python/input.js'
-
-export type InputEffects = {
-  setVariable: typeof setVariable
-}
-
-export interface IInputExecutor {
-  isIdle(): boolean
-  saveVariable(block: Y.XmlElement<InputBlock>): Promise<void>
-  saveValue(block: Y.XmlElement<InputBlock>): Promise<void>
-  abortSaveValue(block: Y.XmlElement<InputBlock>): Promise<void>
-}
-
-type Running = {
-  abortController: AbortController
-  abort?: () => Promise<void>
-}
-
-export class InputExecutor implements IInputExecutor {
-  private workspaceId: string
-  private documentId: string
-  private executionQueue: PQueue
-  private blocks: Y.Map<YBlock>
-  private effects: InputEffects
-  private running: Map<Y.XmlElement<InputBlock>, Running> = new Map()
-
-  constructor(
-    workspaceId: string,
-    documentId: string,
-    blocks: Y.Map<YBlock>,
-    executionQueue: PQueue,
-    effects: InputEffects = {
-      setVariable,
-    }
-  ) {
-    this.workspaceId = workspaceId
-    this.documentId = documentId
-    this.blocks = blocks
-    this.executionQueue = executionQueue
-    this.effects = effects
-  }
-
-  public isIdle() {
-    return this.executionQueue.size === 0 && this.executionQueue.pending === 0
-  }
-
-  public async saveVariable(block: Y.XmlElement<InputBlock>) {
-    const attrs = getInputAttributes(block, this.blocks)
-
-    const { newValue: newVariableName } = attrs.variable
-    const { value } = attrs.value
-
-    const dfNameRegex = /^[a-zA-Z_][a-zA-Z0-9_]*$/
-    if (!dfNameRegex.test(newVariableName)) {
-      updateInputVariable(block, this.blocks, {
-        error: 'invalid-variable-name',
-      })
-      return
-    }
-
-    logger().trace(
-      {
-        workspaceId: this.workspaceId,
-        documentId: this.documentId,
-        blockId: attrs.id,
-        variableName: newVariableName,
-      },
-      'Adding input variable to execution queue'
-    )
-
-    const running: Running = { abortController: new AbortController() }
-    this.running.set(block, running)
-    try {
-      await this.executionQueue.add(
-        async ({ signal }) => {
-          logger().trace(
-            {
-              workspaceId: this.workspaceId,
-              documentId: this.documentId,
-              blockId: attrs.id,
-              variableName: newVariableName,
-            },
-            'Saving input variable'
-          )
-
-          const { promise, abort } = await this.effects.setVariable(
-            this.workspaceId,
-            this.documentId,
-            newVariableName,
-            value
-          )
-
-          if (!signal?.aborted) {
-            await promise
-            updateInputVariable(block, this.blocks, {
-              value: newVariableName,
-              error: null,
-            })
-            logger().trace(
-              {
-                workspaceId: this.workspaceId,
-                documentId: this.documentId,
-                blockId: attrs.id,
-                variableName: newVariableName,
-              },
-              'Saved input variable'
-            )
-          } else {
-            await abort()
-          }
-        },
-        { signal: running.abortController.signal }
-      )
-    } catch (e) {
-      if (e instanceof DOMException && e.name === 'AbortError') {
-        return
-      }
-
-      throw e
-    }
-  }
-
-  public async saveValue(block: Y.XmlElement<InputBlock>) {
-    const attrs = getInputAttributes(block, this.blocks)
-
-    const { value: variableName } = attrs.variable
-    const { newValue } = attrs.value
-
-    logger().trace(
-      {
-        workspaceId: this.workspaceId,
-        documentId: this.documentId,
-        blockId: block.getAttribute('id'),
-        variableName,
-      },
-      'Adding input value to execution queue'
-    )
-
-    await this.executionQueue.add(async () => {
-      logger().trace(
-        {
-          workspaceId: this.workspaceId,
-          documentId: this.documentId,
-          blockId: block.getAttribute('id'),
-          variableName,
-        },
-        'Saving input value'
-      )
-
-      await this.effects
-        .setVariable(this.workspaceId, this.documentId, variableName, newValue)
-        .then(({ promise }) => promise)
-      updateInputValue(block, {
-        value: newValue,
-        error: null,
-      })
-      updateInputBlockExecutedAt(block, new Date())
-      logger().trace(
-        {
-          workspaceId: this.workspaceId,
-          documentId: this.documentId,
-          blockId: attrs.id,
-          variableName,
-        },
-        'Saved input value'
-      )
-    })
-  }
-
-  public async abortSaveValue(block: Y.XmlElement<InputBlock>) {
-    const running = this.running.get(block)
-    if (!running) {
-      return
-    }
-
-    running.abortController.abort()
-    await running.abort?.()
-  }
-
-  public static make(
-    workspaceId: string,
-    documentId: string,
-    blocks: Y.Map<YBlock>,
-    executionQueue: PQueue
-  ) {
-    return new InputExecutor(workspaceId, documentId, blocks, executionQueue, {
-      setVariable,
-    })
-  }
-}
diff --git a/apps/api/src/yjs/v2/executors/blocks/pivot-table.ts b/apps/api/src/yjs/v2/executors/blocks/pivot-table.ts
deleted file mode 100644
index 467613b0..00000000
--- a/apps/api/src/yjs/v2/executors/blocks/pivot-table.ts
+++ /dev/null
@@ -1,260 +0,0 @@
-import {
-  PivotTableBlock,
-  YBlock,
-  getPivotTableAttributes,
-} from '@briefer/editor'
-import PQueue from 'p-queue'
-import * as Y from 'yjs'
-import { logger } from '../../../../logger.js'
-import { DataFrame } from '@briefer/types'
-import { createPivotTable } from '../../../../python/pivot-table.js'
-
-export type PivotTableEffects = {
-  createPivotTable: typeof createPivotTable
-}
-
-type Running = {
-  abortController: AbortController
-  abort?: () => Promise<void>
-}
-
-export interface IPivotTableExecutor {
-  isIdle(): boolean
-  run(block: Y.XmlElement<PivotTableBlock>, tr: Y.Transaction): Promise<void>
-  loadPage(
-    block: Y.XmlElement<PivotTableBlock>,
-    tr: Y.Transaction
-  ): Promise<void>
-  abort(block: Y.XmlElement<PivotTableBlock>): Promise<void>
-}
-
-export class PivotTableExecutor implements IPivotTableExecutor {
-  private workspaceId: string
-  private documentId: string
-  private dataframes: Y.Map<DataFrame>
-  private blocks: Y.Map<YBlock>
-  private executionQueue: PQueue
-  private running = new Map<Y.XmlElement<PivotTableBlock>, Set<Running>>()
-  private effects: PivotTableEffects
-
-  constructor(
-    workspaceId: string,
-    documentId: string,
-    dataframes: Y.Map<DataFrame>,
-    blocks: Y.Map<YBlock>,
-    executionQueue: PQueue,
-    effects: PivotTableEffects
-  ) {
-    this.workspaceId = workspaceId
-    this.documentId = documentId
-    this.dataframes = dataframes
-    this.blocks = blocks
-    this.executionQueue = executionQueue
-    this.effects = effects
-  }
-
-  public isIdle() {
-    return this.executionQueue.size === 0 && this.executionQueue.pending === 0
-  }
-
-  public async abort(block: Y.XmlElement<PivotTableBlock>) {
-    const run = this.running.get(block)
-    if (!run) {
-      return
-    }
-
-    logger().trace(
-      {
-        workspaceId: this.workspaceId,
-        documentId: this.documentId,
-        blockId: block.getAttribute('id'),
-      },
-      'aborting pivot table block run'
-    )
-
-    await Promise.all(
-      Array.from(run).map(async (r) => {
-        r.abortController.abort()
-
-        await r.abort?.()
-      })
-    )
-
-    logger().trace(
-      {
-        workspaceId: this.workspaceId,
-        documentId: this.documentId,
-        blockId: block.getAttribute('id'),
-      },
-      'pivot table block run aborted'
-    )
-  }
-
-  public async run(block: Y.XmlElement<PivotTableBlock>, tr: Y.Transaction) {
-    return this._run(block, tr, 'create')
-  }
-
-  public async loadPage(
-    block: Y.XmlElement<PivotTableBlock>,
-    tr: Y.Transaction
-  ) {
-    return this._run(block, tr, 'read')
-  }
-
-  private async _run(
-    block: Y.XmlElement<PivotTableBlock>,
-    _tr: Y.Transaction,
-    operation: 'create' | 'read'
-  ) {
-    const abortController = new AbortController()
-    const running: Running = { abortController }
-
-    const runs = this.running.get(block) ?? new Set()
-    runs.add(running)
-    this.running.set(block, runs)
-
-    try {
-      logger().trace(
-        {
-          workspaceId: this.workspaceId,
-          documentId: this.documentId,
-          blockId: block.getAttribute('id'),
-          queueSize: this.executionQueue.size,
-        },
-        'enqueuing pivot table block run'
-      )
-
-      await this.executionQueue.add(
-        async ({ signal }) => {
-          logger().trace(
-            {
-              workspaceId: this.workspaceId,
-              documentId: this.documentId,
-              blockId: block.getAttribute('id'),
-            },
-            'running pivot table block'
-          )
-
-          const dataframeName = block.getAttribute('dataframeName')
-          if (!dataframeName) {
-            return
-          }
-
-          const dataframe = this.dataframes.get(dataframeName)
-          if (!dataframe) {
-            return
-          }
-
-          const attrs = getPivotTableAttributes(block, this.blocks)
-
-          if (operation === 'create') {
-            const rows = attrs.rows
-              .map((row) => row.column)
-              .filter((row) => row !== null)
-            const cols = attrs.columns
-              .map((col) => col.column)
-              .filter((col) => col !== null)
-            const metrics = attrs.metrics
-              .map((metric) => metric.column)
-              .filter((metric) => metric !== null)
-
-            if (
-              rows.length === 0 ||
-              cols.length === 0 ||
-              metrics.length === 0
-            ) {
-              block.setAttribute('updatedAt', new Date().toISOString())
-              block.setAttribute('error', null)
-              return
-            }
-
-            const all = [...rows, ...cols, ...metrics]
-
-            for (const column of all) {
-              if (
-                column &&
-                dataframe.columns.findIndex(
-                  (c) => c.name.toString() === column.name.toString()
-                ) === -1
-              ) {
-                block.setAttribute('updatedAt', new Date().toISOString())
-                block.setAttribute('error', null)
-                return
-              }
-            }
-          }
-
-          const { promise, abort } = await this.effects.createPivotTable(
-            this.workspaceId,
-            this.documentId,
-            dataframe,
-            attrs.rows,
-            attrs.columns,
-            attrs.metrics,
-            attrs.sort,
-            attrs.variable.value,
-            attrs.page,
-            operation
-          )
-
-          running.abort = abort
-          if (signal?.aborted) {
-            await abort()
-          }
-
-          const result = await promise
-          if (!result.success) {
-            if (result.reason !== 'aborted') {
-              block.setAttribute('error', result.reason)
-            }
-            return
-          }
-
-          block.setAttribute('updatedAt', new Date().toISOString())
-          block.setAttribute('error', null)
-          block.setAttribute('result', result.result)
-          block.setAttribute('page', result.result.page)
-
-          logger().trace(
-            {
-              workspaceId: this.workspaceId,
-              documentId: this.documentId,
-              blockId: block.getAttribute('id'),
-            },
-            'pivot table block run completed'
-          )
-        },
-        { signal: abortController.signal }
-      )
-    } catch (e) {
-      if (e instanceof DOMException && e.name === 'AbortError') {
-        return
-      }
-
-      block.setAttribute('error', 'unknown')
-      throw e
-    } finally {
-      runs.delete(running)
-      if (runs.size === 0) {
-        this.running.delete(block)
-      }
-    }
-  }
-
-  public static make(
-    workspaceId: string,
-    documentId: string,
-    dataframes: Y.Map<DataFrame>,
-    blocks: Y.Map<YBlock>,
-    executionQueue: PQueue
-  ) {
-    return new PivotTableExecutor(
-      workspaceId,
-      documentId,
-      dataframes,
-      blocks,
-      executionQueue,
-      { createPivotTable }
-    )
-  }
-}
diff --git a/apps/api/src/yjs/v2/executors/blocks/python.ts b/apps/api/src/yjs/v2/executors/blocks/python.ts
deleted file mode 100644
index ee7effcf..00000000
--- a/apps/api/src/yjs/v2/executors/blocks/python.ts
+++ /dev/null
@@ -1,316 +0,0 @@
-import debounce from 'lodash.debounce'
-import {
-  PythonBlock,
-  YBlock,
-  closePythonEditWithAIPrompt,
-  getPythonAttributes,
-  getPythonBlockEditWithAIPrompt,
-  getPythonBlockResult,
-  getPythonSource,
-  updatePythonAISuggestions,
-} from '@briefer/editor'
-import PQueue from 'p-queue'
-import * as Y from 'yjs'
-import { updateDataframes } from '../index.js'
-import { logger } from '../../../../logger.js'
-import { executeCode as executePython } from '../../../../python/index.js'
-import { DataFrame, PythonErrorOutput } from '@briefer/types'
-import { listDataFrames } from '../../../../python/query/index.js'
-import { pythonEditStreamed } from '../../../../ai-api.js'
-import { prisma, getWorkspaceWithSecrets } from '@briefer/database'
-import { EventContext, PythonEvents } from '../../../../events/index.js'
-
-async function editWithAI(
-  workspaceId: string,
-  source: string,
-  instructions: string,
-  dataFrames: DataFrame[],
-  event: (modelId: string | null) => void,
-  onSource: (source: string) => void
-) {
-  const workspace = workspaceId
-    ? await getWorkspaceWithSecrets(workspaceId)
-    : null
-
-  event(workspace?.assistantModel ?? null)
-
-  await pythonEditStreamed(
-    source,
-    instructions,
-    dataFrames,
-    onSource,
-    workspace?.assistantModel ?? null,
-    workspace?.secrets?.openAiApiKey ?? null
-  )
-}
-
-export type PythonEffects = {
-  executePython: typeof executePython
-  listDataFrames: typeof listDataFrames
-  editWithAI: typeof editWithAI
-}
-
-type RunninCode = {
-  abortController: AbortController
-  abort?: () => Promise<void>
-}
-
-export interface IPythonExecutor {
-  run(
-    block: Y.XmlElement<PythonBlock>,
-    tr: Y.Transaction,
-    isSuggestion: boolean
-  ): Promise<void>
-  abort(block: Y.XmlElement<PythonBlock>, tr: Y.Transaction): Promise<void>
-  isIdle(): boolean
-  editWithAI(block: Y.XmlElement<PythonBlock>, tr: Y.Transaction): Promise<void>
-  fixWithAI(block: Y.XmlElement<PythonBlock>, tr: Y.Transaction): Promise<void>
-}
-
-export class PythonExecutor implements IPythonExecutor {
-  private workspaceId: string
-  private documentId: string
-  private executionQueue: PQueue
-  private runningCode = new Map<Y.XmlElement<PythonBlock>, RunninCode>()
-  private dataframes: Y.Map<DataFrame>
-  private blocks: Y.Map<YBlock>
-  private effects: PythonEffects
-  private events: PythonEvents
-
-  constructor(
-    workspaceId: string,
-    documentId: string,
-    dataframes: Y.Map<DataFrame>,
-    blocks: Y.Map<YBlock>,
-    executionQueue: PQueue,
-    effects: PythonEffects,
-    events: PythonEvents
-  ) {
-    this.workspaceId = workspaceId
-    this.documentId = documentId
-    this.dataframes = dataframes
-    this.blocks = blocks
-    this.executionQueue = executionQueue
-    this.effects = effects
-    this.events = events
-  }
-
-  public isIdle() {
-    return this.executionQueue.size === 0 && this.executionQueue.pending === 0
-  }
-
-  public async run(
-    block: Y.XmlElement<PythonBlock>,
-    tr: Y.Transaction,
-    isSuggestion: boolean
-  ) {
-    this.events.pythonRun(EventContext.fromYTransaction(tr))
-
-    const abortController = new AbortController()
-    const runningCode: RunninCode = { abortController }
-    this.runningCode.set(block, runningCode)
-    block.setAttribute('result', [])
-
-    try {
-      logger().trace(
-        {
-          workspaceId: this.workspaceId,
-          documentId: this.documentId,
-          blockId: block.getAttribute('id'),
-          queeueSize: this.executionQueue.size,
-        },
-        'enqueueing python block execution'
-      )
-
-      await this.executionQueue.add(
-        async ({ signal }) => {
-          block.setAttribute('startQueryTime', new Date().toISOString())
-
-          logger().trace(
-            {
-              workspaceId: this.workspaceId,
-              documentId: this.documentId,
-              blockId: block.getAttribute('id'),
-            },
-            'executing python block'
-          )
-
-          const {
-            aiSuggestions,
-            source,
-            id: blockId,
-          } = getPythonAttributes(block)
-
-          const actualSource =
-            (isSuggestion ? aiSuggestions : source)?.toJSON() ?? ''
-
-          const { promise, abort } = await this.effects.executePython(
-            this.workspaceId,
-            this.documentId,
-            actualSource,
-            (outputs) => {
-              const prevOutputs = block.getAttribute('result') ?? []
-              block.setAttribute('result', prevOutputs.concat(outputs))
-            },
-            { storeHistory: true }
-          )
-          runningCode.abort = abort
-          if (signal?.aborted) {
-            await abort()
-          }
-
-          await promise
-          await this.updateDataFrames(blockId)
-
-          block.setAttribute('status', 'idle')
-          block.setAttribute(
-            'lastQuery',
-            block.getAttribute('source')!.toJSON()
-          )
-          block.setAttribute('lastQueryTime', new Date().toISOString())
-          logger().trace(
-            {
-              workspaceId: this.workspaceId,
-              documentId: this.documentId,
-              blockId: block.getAttribute('id'),
-            },
-            'python block executed'
-          )
-        },
-        { signal: abortController.signal }
-      )
-    } catch (e) {
-      if (e instanceof DOMException && e.name === 'AbortError') {
-        return
-      }
-
-      throw e
-    }
-  }
-
-  public async abort(block: Y.XmlElement<PythonBlock>) {
-    logger().trace(
-      {
-        workspaceId: this.workspaceId,
-        documentId: this.documentId,
-        blockId: block.getAttribute('id'),
-      },
-      'aborting python block execution'
-    )
-
-    const running = this.runningCode.get(block)
-    if (!running) {
-      block.setAttribute('status', 'idle')
-      return
-    }
-
-    running.abortController.abort()
-    await running.abort?.()
-
-    logger().trace(
-      {
-        workspaceId: this.workspaceId,
-        documentId: this.documentId,
-        blockId: block.getAttribute('id'),
-      },
-      'python block execution aborted'
-    )
-  }
-
-  public async editWithAI(block: Y.XmlElement<PythonBlock>, tr: Y.Transaction) {
-    const instructions = getPythonBlockEditWithAIPrompt(block).toJSON()
-    if (!instructions) {
-      return
-    }
-
-    const source = getPythonSource(block).toJSON()
-
-    await this.effects.editWithAI(
-      this.workspaceId,
-      source,
-      instructions,
-      Array.from(this.dataframes.values()),
-      (modelId) => {
-        this.events.aiUsage(
-          EventContext.fromYTransaction(tr),
-          'python',
-          'edit',
-          modelId
-        )
-      },
-      debounce((suggestions) => {
-        updatePythonAISuggestions(block, suggestions)
-      }, 50)
-    )
-    closePythonEditWithAIPrompt(block, true)
-  }
-
-  public async fixWithAI(block: Y.XmlElement<PythonBlock>, tr: Y.Transaction) {
-    const error = getPythonBlockResult(block).find(
-      (r): r is PythonErrorOutput => r.type === 'error'
-    )
-    if (!error) {
-      return
-    }
-
-    const instructions = `Fix the Python code, this is the error: ${JSON.stringify(
-      {
-        ...error,
-        traceback: error.traceback.slice(0, 2),
-      }
-    )}`
-    const source = getPythonSource(block).toJSON()
-
-    await this.effects.editWithAI(
-      this.workspaceId,
-      source,
-      instructions,
-      Array.from(this.dataframes.values()),
-      (modelId) => {
-        this.events.aiUsage(
-          EventContext.fromYTransaction(tr),
-          'python',
-          'fix',
-          modelId
-        )
-      },
-      debounce((suggestions) => {
-        updatePythonAISuggestions(block, suggestions)
-      }, 50)
-    )
-  }
-
-  private async updateDataFrames(blockId: string) {
-    const newDataframes = await this.effects.listDataFrames(
-      this.workspaceId,
-      this.documentId
-    )
-
-    const blocks = new Set(Array.from(this.blocks.keys()))
-
-    updateDataframes(this.dataframes, newDataframes, blockId, blocks)
-  }
-
-  public static make(
-    workspaceId: string,
-    documentId: string,
-    dataframes: Y.Map<DataFrame>,
-    blocks: Y.Map<YBlock>,
-    executionQueue: PQueue,
-    events: PythonEvents
-  ) {
-    return new PythonExecutor(
-      workspaceId,
-      documentId,
-      dataframes,
-      blocks,
-      executionQueue,
-      {
-        executePython,
-        listDataFrames,
-        editWithAI,
-      },
-      events
-    )
-  }
-}
diff --git a/apps/api/src/yjs/v2/executors/blocks/sql.ts b/apps/api/src/yjs/v2/executors/blocks/sql.ts
deleted file mode 100644
index 0941999e..00000000
--- a/apps/api/src/yjs/v2/executors/blocks/sql.ts
+++ /dev/null
@@ -1,632 +0,0 @@
-import debounce from 'lodash.debounce'
-import {
-  SQLBlock,
-  YBlock,
-  closeSQLEditWithAIPrompt,
-  getBaseAttributes,
-  getSQLAttributes,
-  updateSQLAISuggestions,
-} from '@briefer/editor'
-import PQueue from 'p-queue'
-import * as Y from 'yjs'
-import prisma, {
-  listDataSources,
-  getWorkspaceWithSecrets,
-  DataSource,
-} from '@briefer/database'
-import {
-  listDataFrames,
-  makeSQLQuery,
-  renameDataFrame,
-} from '../../../../python/query/index.js'
-import { updateDataframes } from '../index.js'
-import { logger } from '../../../../logger.js'
-import {
-  DataFrame,
-  DataSourceStructureStateV3,
-  RunQueryResult,
-} from '@briefer/types'
-import { sqlEditStreamed } from '../../../../ai-api.js'
-import { EventContext, SQLEvents } from '../../../../events/index.js'
-import {
-  IJupyterManager,
-  getJupyterManager,
-} from '../../../../jupyter/index.js'
-import {
-  fetchDataSourceStructureFromCache,
-  listSchemaTables,
-} from '../../../../datasources/structure.js'
-
-async function editWithAI(
-  workspaceId: string,
-  datasourceId:
-    | {
-        type: 'db'
-        id: string
-      }
-    | { type: 'duckdb' },
-  source: string,
-  instructions: string,
-  event: (modelId: string | null) => void,
-  onSuggestions: (suggestions: string) => void
-) {
-  const workspace = workspaceId
-    ? await getWorkspaceWithSecrets(workspaceId)
-    : null
-
-  const assistantModelId = workspace?.assistantModel ?? null
-
-  if (datasourceId.type === 'duckdb') {
-    event(assistantModelId)
-
-    return sqlEditStreamed(
-      source,
-      instructions,
-      'DuckDB',
-      onSuggestions,
-      // TODO: what should be the schema when duckdb?
-      null,
-      assistantModelId,
-      workspace?.secrets?.openAiApiKey ?? null
-    )
-  }
-
-  const dataSources = await listDataSources(workspaceId)
-  const dataSource = dataSources.find((ds) => ds.data.id === datasourceId.id)
-  if (!dataSource) {
-    throw new Error(`Datasource with id ${datasourceId} not found`)
-  }
-
-  const structure = await fetchDataSourceStructureFromCache(
-    dataSource.data.id,
-    dataSource.type
-  )
-  const tableInfo = await tableInfoFromStructure(dataSource, structure)
-
-  event(assistantModelId)
-
-  const dialect: string = (() => {
-    switch (dataSource.type) {
-      case 'psql':
-        return 'postgresql'
-      case 'redshift':
-        return 'redshift'
-      case 'trino':
-        return 'trino'
-      case 'bigquery':
-        return 'bigquery'
-      case 'athena':
-        return 'awsathena'
-      case 'oracle':
-        return 'oracle'
-      case 'mysql':
-        return 'mysql'
-      case 'sqlserver':
-        return 'mssql'
-      case 'snowflake':
-        return 'snowflake'
-      case 'databrickssql':
-        return 'databricks'
-    }
-  })()
-
-  return sqlEditStreamed(
-    source,
-    instructions,
-    dialect,
-    onSuggestions,
-    tableInfo,
-    assistantModelId,
-    workspace?.secrets?.openAiApiKey ?? null
-  )
-}
-
-async function tableInfoFromStructure(
-  config: DataSource,
-  structure: DataSourceStructureStateV3 | null
-): Promise<string | null> {
-  if (!structure) {
-    return null
-  }
-
-  let result = ''
-  for await (const schemaTable of listSchemaTables([{ config, structure }])) {
-    result += `${schemaTable.schemaName}.${schemaTable.tableName}\n`
-    for (const columns of schemaTable.table.columns) {
-      result += `${columns.name} ${columns.type}\n`
-    }
-    result += '\n'
-  }
-
-  return result.trim()
-}
-
-export type SQLEffects = {
-  makeSQLQuery: typeof makeSQLQuery
-  renameDataFrame: typeof renameDataFrame
-  listDataSources: typeof listDataSources
-  listDataFrames: typeof listDataFrames
-  editWithAI: typeof editWithAI
-  documentHasRunSQLSelectionEnabled: (id: string) => Promise<boolean>
-}
-
-type RunninQuery = {
-  abortController: AbortController
-  abort?: () => Promise<void>
-}
-
-export interface ISQLExecutor {
-  isIdle(): boolean
-  runQuery(
-    block: Y.XmlElement<SQLBlock>,
-    tr: Y.Transaction,
-    isSuggestion: boolean,
-    isRunAll: boolean
-  ): Promise<void>
-  abortQuery(block: Y.XmlElement<SQLBlock>): Promise<void>
-  renameDataFrame(block: Y.XmlElement<SQLBlock>): Promise<void>
-  editWithAI(block: Y.XmlElement<SQLBlock>, tr: Y.Transaction): Promise<void>
-  fixWithAI(block: Y.XmlElement<SQLBlock>, tr: Y.Transaction): Promise<void>
-}
-
-export class SQLExecutor implements ISQLExecutor {
-  private workspaceId: string
-  private documentId: string
-  private dataSourcesEncryptionKey: string
-  private executionQueue: PQueue
-  private dataframes: Y.Map<DataFrame>
-  private blocks: Y.Map<YBlock>
-  private runningQueries = new Map<Y.XmlElement<SQLBlock>, RunninQuery>()
-  private jupyterManager: IJupyterManager
-  private effects: SQLEffects
-  private events: SQLEvents
-
-  constructor(
-    workspaceId: string,
-    documentId: string,
-    dataSourcesEncryptionKey: string,
-    dataframes: Y.Map<DataFrame>,
-    blocks: Y.Map<YBlock>,
-    executionQueue: PQueue,
-    jupyterManager: IJupyterManager,
-    effects: SQLEffects,
-    events: SQLEvents
-  ) {
-    this.workspaceId = workspaceId
-    this.documentId = documentId
-    this.dataSourcesEncryptionKey = dataSourcesEncryptionKey
-    this.dataframes = dataframes
-    this.blocks = blocks
-    this.executionQueue = executionQueue
-    this.jupyterManager = jupyterManager
-    this.effects = effects
-    this.events = events
-  }
-
-  public isIdle() {
-    return this.executionQueue.size === 0 && this.executionQueue.pending === 0
-  }
-
-  public async runQuery(
-    block: Y.XmlElement<SQLBlock>,
-    tr: Y.Transaction,
-    isSuggestion: boolean,
-    isRunAll: boolean
-  ) {
-    this.events.sqlRun(EventContext.fromYTransaction(tr))
-    const abortController = new AbortController()
-    const runningQuery: RunninQuery = { abortController }
-    this.runningQueries.set(block, runningQuery)
-
-    try {
-      logger().trace(
-        {
-          workspaceId: this.workspaceId,
-          documentId: this.documentId,
-          blockId: block.getAttribute('id'),
-          queueSize: this.executionQueue.size,
-        },
-        'enqueuing query execution'
-      )
-
-      await this.executionQueue.add(
-        async ({ signal }) => {
-          block.setAttribute('startQueryTime', new Date().toISOString())
-
-          logger().trace(
-            {
-              workspaceId: this.workspaceId,
-              documentId: this.documentId,
-              blockId: block.getAttribute('id'),
-            },
-            'executing query'
-          )
-
-          const {
-            id: blockId,
-            aiSuggestions,
-            source,
-            configuration,
-            dataSourceId,
-            dataframeName,
-            isFileDataSource,
-            selectedCode,
-          } = getSQLAttributes(block, this.blocks)
-          if ((!dataSourceId && !isFileDataSource) || !dataframeName) {
-            return
-          }
-
-          const datasource = (
-            await this.effects.listDataSources(this.workspaceId)
-          ).find((ds) => ds.data.id === dataSourceId)
-          if (signal?.aborted) {
-            block.setAttribute('result', {
-              type: 'abort-error',
-              message: 'Query aborted',
-            })
-            return
-          }
-
-          if (!datasource && !isFileDataSource) {
-            // the selected datasource was deleted
-            // recover this block state by removing the datasourceId
-            block.removeAttribute('dataSourceId')
-            return
-          }
-
-          block.removeAttribute('result')
-
-          let actualSource =
-            (isSuggestion ? aiSuggestions : source)?.toJSON().trim() ?? ''
-          if (!isRunAll && selectedCode) {
-            const hasRunSQLSelection =
-              await this.effects.documentHasRunSQLSelectionEnabled(
-                this.documentId
-              )
-            if (hasRunSQLSelection) {
-              actualSource = selectedCode.trim()
-            }
-          }
-
-          let resultType: RunQueryResult['type'] | 'empty-query' = 'empty-query'
-          if (actualSource !== '') {
-            const [promise, abort] = await this.effects.makeSQLQuery(
-              this.workspaceId,
-              this.documentId,
-              blockId,
-              dataframeName.value,
-              datasource ?? 'duckdb',
-              this.dataSourcesEncryptionKey,
-              actualSource,
-              (result) => {
-                block.setAttribute('result', result)
-              },
-              configuration
-            )
-            runningQuery.abort = abort
-            if (signal?.aborted) {
-              await abort()
-            }
-
-            const result = await promise
-            block.setAttribute('lastQuery', actualSource)
-            block.setAttribute('lastQueryTime', new Date().toISOString())
-            if (result.type === 'python-error') {
-              logger().error(
-                {
-                  workspaceId: this.workspaceId,
-                  documentId: this.documentId,
-                  blockId: block.getAttribute('id'),
-                  err: result,
-                },
-                'got a python error while running sql query'
-              )
-              block.setAttribute('result', {
-                ...result,
-                traceback: [],
-              })
-            }
-            block.setAttribute('result', result)
-            if (result.type === 'success') {
-              const df = {
-                id: blockId,
-                name: dataframeName.value,
-                columns: result.columns,
-                blockId,
-                updatedAt: new Date().toISOString(),
-              }
-              this.dataframes.set(dataframeName.value, df)
-            } else if (result.type === 'syntax-error') {
-              logger().warn(
-                {
-                  workspaceId: this.workspaceId,
-                  documentId: this.documentId,
-                  blockId: block.getAttribute('id'),
-                  err: result,
-                },
-                'got a syntax error while running sql query'
-              )
-            }
-
-            resultType = result.type
-          }
-
-          logger().trace(
-            {
-              workspaceId: this.workspaceId,
-              documentId: this.documentId,
-              blockId: block.getAttribute('id'),
-              result: resultType,
-            },
-            'query executed'
-          )
-        },
-        { signal: abortController.signal }
-      )
-    } catch (e) {
-      if (e instanceof DOMException && e.name === 'AbortError') {
-        block.setAttribute('result', {
-          type: 'abort-error',
-          message: 'Query aborted',
-        })
-        return
-      }
-
-      throw e
-    }
-  }
-
-  public async abortQuery(block: Y.XmlElement<SQLBlock>) {
-    logger().trace(
-      {
-        workspaceId: this.workspaceId,
-        documentId: this.documentId,
-        blockId: block.getAttribute('id'),
-      },
-      'aborting query'
-    )
-
-    const query = this.runningQueries.get(block)
-    if (!query) {
-      return
-    }
-
-    query.abortController.abort()
-    await query.abort?.()
-
-    logger().trace(
-      {
-        workspaceId: this.workspaceId,
-        documentId: this.documentId,
-        blockId: block.getAttribute('id'),
-      },
-      'query aborted'
-    )
-  }
-
-  public async renameDataFrame(block: Y.XmlElement<SQLBlock>) {
-    const dataframeName = block.getAttribute('dataframeName')
-    if (!dataframeName) {
-      return
-    }
-
-    const dfNameRegex = /^[a-zA-Z_][a-zA-Z0-9_]*$/
-    if (!dfNameRegex.test(dataframeName.newValue)) {
-      block.setAttribute('dataframeName', {
-        ...dataframeName,
-        error: 'invalid-name',
-      })
-      return
-    }
-
-    logger().trace(
-      {
-        workspaceId: this.workspaceId,
-        documentId: this.documentId,
-        blockId: block.getAttribute('id'),
-        dataframeName,
-      },
-      'renaming dataframe'
-    )
-
-    const result = block.getAttribute('result')
-    const isQueryRunning = block.getAttribute('status') !== 'idle'
-    if (result?.type !== 'success' || isQueryRunning) {
-      block.setAttribute('dataframeName', {
-        ...dataframeName,
-        value: dataframeName.newValue,
-      })
-      return
-    }
-
-    if (!(await this.jupyterManager.isRunning(this.workspaceId))) {
-      block.setAttribute('dataframeName', {
-        ...dataframeName,
-        value: dataframeName.newValue,
-      })
-      return
-    }
-
-    logger().trace(
-      {
-        workspaceId: this.workspaceId,
-        documentId: this.documentId,
-        blockId: block.getAttribute('id'),
-        dataframeName,
-        queueSize: this.executionQueue.size,
-      },
-      'enqueuing dataframe rename'
-    )
-
-    await this.executionQueue.add(async () => {
-      logger().trace(
-        {
-          workspaceId: this.workspaceId,
-          documentId: this.documentId,
-          blockId: block.getAttribute('id'),
-          dataframeName,
-        },
-        'renaming dataframe in jupyter'
-      )
-
-      await this.effects.renameDataFrame(
-        this.workspaceId,
-        this.documentId,
-        dataframeName.value,
-        dataframeName.newValue
-      )
-
-      const dataframes = await this.effects.listDataFrames(
-        this.workspaceId,
-        this.documentId
-      )
-      const { id: blockId } = getBaseAttributes(block)
-      const blocks = new Set(Array.from(this.blocks.keys()))
-      updateDataframes(this.dataframes, dataframes, blockId, blocks)
-
-      block.setAttribute('dataframeName', {
-        ...dataframeName,
-        value: dataframeName.newValue,
-        error: undefined,
-      })
-    })
-
-    logger().trace(
-      {
-        workspaceId: this.workspaceId,
-        documentId: this.documentId,
-        blockId: block.getAttribute('id'),
-        dataframeName,
-      },
-      'dataframe renamed in jupyter'
-    )
-  }
-
-  public async editWithAI(block: Y.XmlElement<SQLBlock>, tr: Y.Transaction) {
-    // TODO: this should make isIdle return true while it is running
-
-    const {
-      dataSourceId: datasourceId,
-      isFileDataSource,
-      source,
-      editWithAIPrompt,
-    } = getSQLAttributes(block, this.blocks)
-
-    const instructions = editWithAIPrompt?.toJSON() ?? ''
-
-    if ((!datasourceId && !isFileDataSource) || !instructions) {
-      return
-    }
-
-    const callback = debounce((suggestions) => {
-      const status = block.getAttribute('status')
-      if (status !== 'edit-with-ai-running') {
-        abortController.abort()
-      } else {
-        updateSQLAISuggestions(block, suggestions)
-      }
-    }, 50)
-    const { promise, abortController } = await this.effects.editWithAI(
-      this.workspaceId,
-      datasourceId ? { type: 'db', id: datasourceId } : { type: 'duckdb' },
-      source?.toJSON() ?? '',
-      instructions,
-      (modelId) => {
-        this.events.aiUsage(
-          EventContext.fromYTransaction(tr),
-          'sql',
-          'edit',
-          modelId
-        )
-      },
-      callback
-    )
-
-    await promise
-    callback.flush()
-    closeSQLEditWithAIPrompt(block, true)
-  }
-
-  public async fixWithAI(block: Y.XmlElement<SQLBlock>, tr: Y.Transaction) {
-    // TODO: this should make isIdle return true while it is running
-    const { dataSourceId, isFileDataSource } = getSQLAttributes(
-      block,
-      this.blocks
-    )
-    if (!dataSourceId && !isFileDataSource) {
-      return
-    }
-
-    const error = block.getAttribute('result')
-    if (!error || error.type !== 'syntax-error') {
-      return
-    }
-
-    const instructions = `Fix the query, this is the error: ${error.message}`
-
-    const source = block.getAttribute('source')?.toJSON() ?? ''
-
-    const callback = debounce((suggestions) => {
-      const status = block.getAttribute('status')
-      if (status !== 'fix-with-ai-running') {
-        abortController.abort()
-      } else {
-        updateSQLAISuggestions(block, suggestions)
-      }
-    }, 50)
-
-    const { promise, abortController } = await this.effects.editWithAI(
-      this.workspaceId,
-      dataSourceId ? { type: 'db', id: dataSourceId } : { type: 'duckdb' },
-      source,
-      instructions,
-      (modelId) => {
-        this.events.aiUsage(
-          EventContext.fromYTransaction(tr),
-          'sql',
-          'fix',
-          modelId
-        )
-      },
-      callback
-    )
-
-    await promise
-    callback.flush()
-  }
-
-  public static make(
-    workspaceId: string,
-    documentId: string,
-    dataSourcesEncryptionKey: string,
-    dataframes: Y.Map<DataFrame>,
-    blocks: Y.Map<YBlock>,
-    executionQueue: PQueue,
-    events: SQLEvents
-  ) {
-    return new SQLExecutor(
-      workspaceId,
-      documentId,
-      dataSourcesEncryptionKey,
-      dataframes,
-      blocks,
-      executionQueue,
-      getJupyterManager(),
-      {
-        makeSQLQuery,
-        renameDataFrame,
-        listDataSources,
-        listDataFrames,
-        editWithAI,
-        documentHasRunSQLSelectionEnabled: (id: string) =>
-          prisma()
-            .document.findFirst({
-              where: { id },
-              select: { runSQLSelection: true },
-            })
-            .then((doc) => doc?.runSQLSelection ?? false),
-      },
-      events
-    )
-  }
-}
diff --git a/apps/api/src/yjs/v2/executors/blocks/visualization.ts b/apps/api/src/yjs/v2/executors/blocks/visualization.ts
deleted file mode 100644
index 833fb4f8..00000000
--- a/apps/api/src/yjs/v2/executors/blocks/visualization.ts
+++ /dev/null
@@ -1,259 +0,0 @@
-import { VisualizationBlock, getVisualizationAttributes } from '@briefer/editor'
-import PQueue from 'p-queue'
-import * as Y from 'yjs'
-import { logger } from '../../../../logger.js'
-import {
-  DataFrame,
-  isInvalidVisualizationFilter,
-  parseOrElse,
-} from '@briefer/types'
-import { createVisualization } from '../../../../python/visualizations.js'
-import { z } from 'zod'
-import { EventContext, VisEvents } from '../../../../events/index.js'
-
-export type VisualizationEffects = {
-  createVisualization: typeof createVisualization
-}
-
-type Running = {
-  abortController: AbortController
-  abort?: () => Promise<void>
-}
-
-export interface IVisualizationExecutor {
-  isIdle(): boolean
-  run(block: Y.XmlElement<VisualizationBlock>, tr: Y.Transaction): Promise<void>
-  abort(block: Y.XmlElement<VisualizationBlock>): Promise<void>
-}
-
-export class VisualizationExecutor implements IVisualizationExecutor {
-  private workspaceId: string
-  private documentId: string
-  private dataframes: Y.Map<DataFrame>
-  private executionQueue: PQueue
-  private running = new Map<Y.XmlElement<VisualizationBlock>, Set<Running>>()
-  private effects: VisualizationEffects
-  private events: VisEvents
-
-  constructor(
-    workspaceId: string,
-    documentId: string,
-    dataframes: Y.Map<DataFrame>,
-    executionQueue: PQueue,
-    effects: VisualizationEffects,
-    events: VisEvents
-  ) {
-    this.workspaceId = workspaceId
-    this.documentId = documentId
-    this.dataframes = dataframes
-    this.executionQueue = executionQueue
-    this.effects = effects
-    this.events = events
-  }
-
-  public isIdle() {
-    return this.executionQueue.size === 0 && this.executionQueue.pending === 0
-  }
-
-  public async abort(block: Y.XmlElement<VisualizationBlock>) {
-    const run = this.running.get(block)
-    if (!run) {
-      return
-    }
-
-    logger().trace(
-      {
-        workspaceId: this.workspaceId,
-        documentId: this.documentId,
-        blockId: block.getAttribute('id'),
-      },
-      'aborting visualization block run'
-    )
-
-    await Promise.all(
-      Array.from(run).map(async (r) => {
-        r.abortController.abort()
-
-        await r.abort?.()
-      })
-    )
-
-    logger().trace(
-      {
-        workspaceId: this.workspaceId,
-        documentId: this.documentId,
-        blockId: block.getAttribute('id'),
-      },
-      'visualization block run aborted'
-    )
-  }
-
-  public async run(block: Y.XmlElement<VisualizationBlock>, tr: Y.Transaction) {
-    const abortController = new AbortController()
-    const running: Running = { abortController }
-
-    const runs = this.running.get(block) ?? new Set()
-    runs.add(running)
-    this.running.set(block, runs)
-
-    block.removeAttribute('result')
-
-    try {
-      logger().trace(
-        {
-          workspaceId: this.workspaceId,
-          documentId: this.documentId,
-          blockId: block.getAttribute('id'),
-          queueSize: this.executionQueue.size,
-        },
-        'enqueuing visualization block run'
-      )
-
-      await this.executionQueue.add(
-        async ({ signal }) => {
-          logger().trace(
-            {
-              workspaceId: this.workspaceId,
-              documentId: this.documentId,
-              blockId: block.getAttribute('id'),
-            },
-            'running visualization block'
-          )
-
-          const dataframeName = block.getAttribute('dataframeName')
-          if (!dataframeName) {
-            return
-          }
-
-          const dataframe = this.dataframes.get(dataframeName)
-          const {
-            chartType,
-            xAxis,
-            xAxisName,
-            xAxisGroupFunction,
-            xAxisSort,
-            yAxes,
-            histogramBin,
-            histogramFormat,
-            showDataLabels,
-            numberValuesFormat,
-            filters,
-          } = getVisualizationAttributes(block)
-
-          const hasAValidYAxis = yAxes.some((yAxis) =>
-            yAxis.series.some((s) => s.column !== null)
-          )
-
-          if (
-            !dataframe ||
-            (!xAxis && chartType !== 'number' && chartType !== 'trend') ||
-            (!hasAValidYAxis && chartType !== 'histogram')
-          ) {
-            return
-          }
-
-          const validFilters = filters.filter(
-            (f) =>
-              dataframe.columns.some((c) => c.name === f.column?.name) &&
-              !isInvalidVisualizationFilter(f, dataframe)
-          )
-
-          this.events.visUpdate(EventContext.fromYTransaction(tr), chartType)
-          const { promise, abort } = await this.effects.createVisualization(
-            this.workspaceId,
-            this.documentId,
-            dataframe,
-            chartType,
-            xAxis,
-            xAxisName,
-            xAxisGroupFunction,
-            xAxisSort,
-            yAxes,
-            histogramFormat,
-            histogramBin,
-            showDataLabels,
-            numberValuesFormat,
-            validFilters
-          )
-
-          running.abort = abort
-          if (signal?.aborted) {
-            await abort()
-          }
-
-          const result = await promise
-          if (Object.keys(result.filterResults).length > 0) {
-            const nextFilters = filters.map((f) => {
-              const next = result.filterResults[f.id]
-              if (next) {
-                return next
-              }
-
-              return f
-            })
-            block.setAttribute('filters', nextFilters)
-          }
-
-          if (!result.success) {
-            if (result.reason !== 'aborted') {
-              block.setAttribute('error', result.reason)
-            }
-            block.setAttribute('spec', null)
-            return
-          }
-
-          block.setAttribute('spec', result.spec)
-          const capped = parseOrElse(
-            z.object({ usermeta: z.object({ capped: z.boolean() }) }),
-            result.spec,
-            { usermeta: { capped: false } }
-          ).usermeta.capped
-          block.setAttribute('tooManyDataPointsHidden', !capped)
-          block.setAttribute('updatedAt', new Date().toISOString())
-          block.setAttribute('error', null)
-
-          logger().trace(
-            {
-              workspaceId: this.workspaceId,
-              documentId: this.documentId,
-              blockId: block.getAttribute('id'),
-            },
-            'visualization block run completed'
-          )
-        },
-        { signal: abortController.signal }
-      )
-    } catch (e) {
-      if (e instanceof DOMException && e.name === 'AbortError') {
-        block.setAttribute('spec', null)
-        return
-      }
-
-      block.setAttribute('error', 'unknown')
-      block.setAttribute('spec', null)
-      throw e
-    } finally {
-      runs.delete(running)
-      if (runs.size === 0) {
-        this.running.delete(block)
-      }
-    }
-  }
-
-  public static make(
-    workspaceId: string,
-    documentId: string,
-    dataframes: Y.Map<DataFrame>,
-    executionQueue: PQueue,
-    events: VisEvents
-  ) {
-    return new VisualizationExecutor(
-      workspaceId,
-      documentId,
-      dataframes,
-      executionQueue,
-      { createVisualization },
-      events
-    )
-  }
-}
diff --git a/apps/api/src/yjs/v2/executors/blocks/writeback.ts b/apps/api/src/yjs/v2/executors/blocks/writeback.ts
deleted file mode 100644
index 7f712d32..00000000
--- a/apps/api/src/yjs/v2/executors/blocks/writeback.ts
+++ /dev/null
@@ -1,197 +0,0 @@
-import { WritebackBlock, getWritebackAttributes } from '@briefer/editor'
-
-import PQueue from 'p-queue'
-import * as Y from 'yjs'
-import { logger } from '../../../../logger.js'
-import { EventContext, WritebackEvents } from '../../../../events/index.js'
-import { writeback } from '../../../../python/writeback/index.js'
-import { listDataSources } from '@briefer/database'
-import { config } from '../../../../config/index.js'
-
-export type WritebackEffects = {
-  writeback: typeof writeback
-}
-
-type RunningCode = {
-  abortController: AbortController
-  abort?: () => Promise<void>
-}
-
-export interface IWritebackExecutor {
-  run(block: Y.XmlElement<WritebackBlock>, tr: Y.Transaction): Promise<void>
-  abort(block: Y.XmlElement<WritebackBlock>): Promise<void>
-  isIdle(): boolean
-}
-
-export class WritebackExecutor implements IWritebackExecutor {
-  private workspaceId: string
-  private documentId: string
-  private executionQueue: PQueue
-  private runningCode = new Map<Y.XmlElement<WritebackBlock>, RunningCode>()
-  private effects: WritebackEffects
-  private events: WritebackEvents
-
-  constructor(
-    workspaceId: string,
-    documentId: string,
-    executionQueue: PQueue,
-    effects: WritebackEffects,
-    events: WritebackEvents
-  ) {
-    this.workspaceId = workspaceId
-    this.documentId = documentId
-    this.executionQueue = executionQueue
-    this.effects = effects
-    this.events = events
-  }
-
-  public isIdle() {
-    return this.executionQueue.size === 0 && this.executionQueue.pending === 0
-  }
-
-  public async run(block: Y.XmlElement<WritebackBlock>, tr: Y.Transaction) {
-    this.events.writeback(EventContext.fromYTransaction(tr))
-
-    const abortController = new AbortController()
-    const runningCode: RunningCode = { abortController }
-    this.runningCode.set(block, runningCode)
-    block.setAttribute('result', null)
-
-    try {
-      logger().trace(
-        {
-          workspaceId: this.workspaceId,
-          documentId: this.documentId,
-          blockId: block.getAttribute('id'),
-          queeueSize: this.executionQueue.size,
-        },
-        'enqueueing writeback block execution'
-      )
-
-      await this.executionQueue.add(
-        async ({ signal }) => {
-          const executedAt = new Date()
-          logger().trace(
-            {
-              workspaceId: this.workspaceId,
-              documentId: this.documentId,
-              blockId: block.getAttribute('id'),
-            },
-            'executing writeback block'
-          )
-
-          const attrs = getWritebackAttributes(block)
-
-          const dataSources = await listDataSources(this.workspaceId)
-          const dataSource = dataSources.find(
-            (ds) => ds.data.id === attrs.dataSourceId
-          )
-          if (!dataSource) {
-            block.setAttribute('status', 'idle')
-            block.setAttribute('result', {
-              _tag: 'error',
-              step: 'validation',
-              reason: 'datasource-not-found',
-              executedAt: executedAt.toISOString(),
-            })
-            return
-          }
-
-          if (!attrs.dataframeName) {
-            block.setAttribute('status', 'idle')
-            block.setAttribute('result', {
-              _tag: 'error',
-              step: 'validation',
-              reason: 'dataframe-not-found',
-              executedAt: executedAt.toISOString(),
-            })
-            return
-          }
-
-          const tableName = attrs.tableName.toJSON()
-          const { promise, abort } = await this.effects.writeback(
-            this.workspaceId,
-            this.documentId,
-            attrs.dataframeName,
-            dataSource,
-            tableName,
-            attrs.overwriteTable,
-            attrs.onConflict,
-            attrs.onConflictColumns,
-            config().DATASOURCES_ENCRYPTION_KEY
-          )
-          runningCode.abort = abort
-          if (signal?.aborted) {
-            await abort()
-          }
-
-          const result = await promise
-
-          block.setAttribute('status', 'idle')
-          block.setAttribute('result', result)
-          logger().trace(
-            {
-              workspaceId: this.workspaceId,
-              documentId: this.documentId,
-              blockId: block.getAttribute('id'),
-            },
-            'writeback block executed'
-          )
-        },
-        { signal: abortController.signal }
-      )
-    } catch (e) {
-      if (e instanceof DOMException && e.name === 'AbortError') {
-        return
-      }
-
-      throw e
-    }
-  }
-
-  public async abort(block: Y.XmlElement<WritebackBlock>) {
-    logger().trace(
-      {
-        workspaceId: this.workspaceId,
-        documentId: this.documentId,
-        blockId: block.getAttribute('id'),
-      },
-      'aborting writeback block execution'
-    )
-
-    const running = this.runningCode.get(block)
-    if (!running) {
-      block.setAttribute('status', 'idle')
-      return
-    }
-
-    running.abortController.abort()
-    await running.abort?.()
-
-    logger().trace(
-      {
-        workspaceId: this.workspaceId,
-        documentId: this.documentId,
-        blockId: block.getAttribute('id'),
-      },
-      'writeback block execution aborted'
-    )
-  }
-
-  public static make(
-    workspaceId: string,
-    documentId: string,
-    executionQueue: PQueue,
-    events: WritebackEvents
-  ) {
-    return new WritebackExecutor(
-      workspaceId,
-      documentId,
-      executionQueue,
-      {
-        writeback,
-      },
-      events
-    )
-  }
-}
diff --git a/apps/api/src/yjs/v2/executors/run-all.ts b/apps/api/src/yjs/v2/executors/run-all.ts
deleted file mode 100644
index d66c6485..00000000
--- a/apps/api/src/yjs/v2/executors/run-all.ts
+++ /dev/null
@@ -1,498 +0,0 @@
-import * as Y from 'yjs'
-import PQueue from 'p-queue'
-import { logger } from '../../../logger.js'
-import {
-  YBlock,
-  YBlockGroup,
-  YRunAll,
-  getResultStatus,
-  isExecutableBlock,
-  switchBlockType,
-  updateDropdownInputValue,
-  updateInputValue,
-} from '@briefer/editor'
-import { ISQLExecutor, SQLExecutor } from './blocks/sql.js'
-import { IPythonExecutor, PythonExecutor } from './blocks/python.js'
-import {
-  IVisualizationExecutor,
-  VisualizationExecutor,
-} from './blocks/visualization.js'
-import { IInputExecutor, InputExecutor } from './blocks/input.js'
-import { DataFrame } from '@briefer/types'
-import { config } from '../../../config/index.js'
-import {
-  DropdownInputExecutor,
-  IDropdownInputExecutor,
-} from './blocks/dropdown-input.js'
-import { NotebookEvents } from '../../../events/index.js'
-import { IWritebackExecutor, WritebackExecutor } from './blocks/writeback.js'
-import { IDateInputExecutor, DateInputExecutor } from './blocks/date-input.js'
-import {
-  IPivotTableExecutor,
-  PivotTableExecutor,
-} from './blocks/pivot-table.js'
-
-export interface IRunAllExecutor {
-  isIdle(): boolean
-  run: (tr: Y.Transaction) => Promise<YBlock | null>
-  abort: (tr: Y.Transaction) => Promise<void>
-  getTotal(): number
-}
-type Executors = {
-  sql: ISQLExecutor
-  python: IPythonExecutor
-  visualization: IVisualizationExecutor
-  input: IInputExecutor
-  dropdownInput: IDropdownInputExecutor
-  writeback: IWritebackExecutor
-  dateInput: IDateInputExecutor
-  pivotTable: IPivotTableExecutor
-}
-export class RunAllExecutor implements IRunAllExecutor {
-  private workspaceId: string
-  private documentId: string
-  private executionQueue: PQueue
-  private abortController: AbortController | null = null
-  private blocks: Y.Map<YBlock>
-  private layout: Y.Array<YBlockGroup>
-  private state: YRunAll
-  private executors: Executors
-  private currentBlock: YBlock | null = null
-
-  constructor(
-    workspaceId: string,
-    documentId: string,
-    blocks: Y.Map<YBlock>,
-    layout: Y.Array<YBlockGroup>,
-    runAll: YRunAll,
-    executionQueue: PQueue,
-    executors: Executors
-  ) {
-    this.workspaceId = workspaceId
-    this.documentId = documentId
-    this.blocks = blocks
-    this.layout = layout
-    this.state = runAll
-    this.executionQueue = executionQueue
-    this.executors = executors
-  }
-
-  public isIdle() {
-    return this.executionQueue.size === 0 && this.executionQueue.pending === 0
-  }
-
-  public async run(tr: Y.Transaction): Promise<YBlock | null> {
-    await this.abort(tr)
-    this.currentBlock = null
-    this.abortController = new AbortController()
-
-    let currentBlock: YBlock | null = null
-    try {
-      logger().trace(
-        {
-          workspaceId: this.workspaceId,
-          documentId: this.documentId,
-          queueSize: this.executionQueue.size,
-        },
-        'enqueueing run all execution'
-      )
-      await this.executionQueue.add(
-        async ({ signal }) => {
-          logger().trace(
-            {
-              workspaceId: this.workspaceId,
-              documentId: this.documentId,
-              blocks: this.blocks.size,
-              layout: this.layout.length,
-            },
-            'executing run all'
-          )
-
-          const queue = this.computeQueue()
-
-          this.state.doc!.transact(() => {
-            this.state.setAttribute('total', queue.length)
-            this.state.setAttribute('remaining', queue.length)
-
-            queue.forEach((block) => {
-              this.setBlockToRunAllEnqueued(block)
-            })
-          })
-
-          logger().trace(
-            {
-              workspaceId: this.workspaceId,
-              documentId: this.documentId,
-              queueSize: queue.length,
-            },
-            'created run all queue'
-          )
-
-          currentBlock = queue.shift() ?? null
-          while (currentBlock) {
-            this.state.setAttribute('remaining', queue.length)
-            logger().trace(
-              {
-                workspaceId: this.workspaceId,
-                documentId: this.documentId,
-                blockId: currentBlock.getAttribute('id'),
-                blockType: currentBlock.getAttribute('type'),
-              },
-              'running queue block'
-            )
-
-            if (signal?.aborted) {
-              logger().trace(
-                {
-                  workspaceId: this.workspaceId,
-                  documentId: this.documentId,
-                  blockId: currentBlock.getAttribute('id'),
-                  blockType: currentBlock.getAttribute('type'),
-                },
-                'run all aborted, setting block to idle'
-              )
-              this.setBlockToIdle(currentBlock)
-              break
-            }
-
-            try {
-              this.setBlockToRunAllRunning(currentBlock)
-              logger().trace(
-                {
-                  workspaceId: this.workspaceId,
-                  documentId: this.documentId,
-                  blockId: currentBlock.getAttribute('id'),
-                  blockType: currentBlock.getAttribute('type'),
-                },
-                'start running block'
-              )
-              await this.runBlock(currentBlock, tr)
-              const resultStatus = getResultStatus(currentBlock, this.blocks)
-              logger().trace(
-                {
-                  workspaceId: this.workspaceId,
-                  documentId: this.documentId,
-                  blockId: currentBlock.getAttribute('id'),
-                  blockType: currentBlock.getAttribute('type'),
-                  resultStatus,
-                },
-                'finished running block'
-              )
-              this.setBlockToIdle(currentBlock)
-              if (resultStatus === 'error') {
-                break
-              }
-            } catch (e) {
-              this.setBlockToIdle(currentBlock)
-              queue.forEach((block) => this.setBlockToIdle(block))
-              throw e
-            }
-
-            currentBlock = queue.shift() ?? null
-          }
-
-          queue.forEach((block) => this.setBlockToIdle(block))
-        },
-        { signal: this.abortController.signal }
-      )
-
-      return currentBlock
-    } catch (e) {
-      if (e instanceof DOMException && e.name === 'AbortError') {
-        logger().trace(
-          {
-            workspaceId: this.workspaceId,
-            documentId: this.documentId,
-          },
-          'run all aborted'
-        )
-        return currentBlock
-      }
-
-      throw e
-    } finally {
-      logger().trace(
-        {
-          workspaceId: this.workspaceId,
-          documentId: this.documentId,
-          queueSize: this.executionQueue.size,
-        },
-        'finished run all execution'
-      )
-      this.abortController = null
-      this.currentBlock = null
-    }
-  }
-
-  public async abort(tr: Y.Transaction) {
-    this.abortController?.abort()
-    await this.abortCurrentBlock(tr)
-    await this.executionQueue.onIdle()
-  }
-
-  public getTotal() {
-    const queue = this.computeQueue()
-    return queue.length
-  }
-
-  private computeQueue() {
-    const queue: YBlock[] = []
-
-    this.layout.forEach((group) => {
-      const tabs = group.getAttribute('tabs')
-      if (!tabs) {
-        return
-      }
-
-      tabs.forEach((tab) => {
-        const blockId = tab.getAttribute('id')
-        if (!blockId) {
-          return
-        }
-
-        const block = this.blocks.get(blockId)
-        if (!block) {
-          return
-        }
-
-        if (isExecutableBlock(block)) {
-          queue.push(block)
-        }
-      })
-    })
-
-    return queue
-  }
-
-  private async abortCurrentBlock(tr: Y.Transaction) {
-    if (!this.currentBlock) {
-      return
-    }
-
-    await switchBlockType(this.currentBlock, {
-      onRichText: () => Promise.resolve(),
-      onPython: (block) => this.executors.python.abort(block, tr),
-      onSQL: (block) => this.executors.sql.abortQuery(block),
-      onVisualization: (block) => this.executors.visualization.abort(block),
-      onInput: async (block) => this.executors.input.abortSaveValue(block),
-      onDropdownInput: (block) =>
-        this.executors.dropdownInput.abortSaveValue(block),
-      onWriteback: (block) => this.executors.writeback.abort(block),
-      onDateInput: () => Promise.resolve(),
-      onFileUpload: () => Promise.resolve(),
-      onDashboardHeader: () => Promise.resolve(),
-      onPivotTable: () =>
-        this.executors.pivotTable.abort(this.currentBlock as Y.XmlElement),
-    })
-  }
-
-  private setBlockToRunAllEnqueued(block: YBlock) {
-    switchBlockType(block, {
-      onRichText: () => {},
-      onPython: (block) => {
-        block.setAttribute('status', 'run-all-enqueued')
-      },
-      onSQL: (block) => {
-        block.setAttribute('status', 'run-all-enqueued')
-      },
-      onVisualization: (block) => {
-        block.setAttribute('status', 'run-all-enqueued')
-      },
-      onInput: (block) => {
-        updateInputValue(block, {
-          status: 'run-all-enqueued',
-        })
-      },
-      onWriteback: (block) => {
-        block.setAttribute('status', 'run-all-enqueued')
-      },
-      onDropdownInput: (block) => {
-        updateDropdownInputValue(block, {
-          status: 'run-all-enqueued',
-        })
-      },
-      onDateInput: (block) => {
-        block.setAttribute('status', 'run-all-enqueued')
-      },
-      onFileUpload: () => {},
-      onDashboardHeader: () => {},
-      onPivotTable: (block) => {
-        block.setAttribute('status', 'run-all-enqueued')
-      },
-    })
-  }
-
-  private setBlockToRunAllRunning(block: YBlock) {
-    switchBlockType(block, {
-      onRichText: () => {},
-      onPython: (block) => {
-        block.setAttribute('status', 'run-all-running')
-      },
-      onSQL: (block) => {
-        block.setAttribute('status', 'run-all-running')
-      },
-      onVisualization: (block) => {
-        block.setAttribute('status', 'run-all-running')
-      },
-      onInput: (block) => {
-        updateInputValue(block, {
-          status: 'run-all-running',
-        })
-      },
-      onDropdownInput: (block) => {
-        updateDropdownInputValue(block, {
-          status: 'run-all-running',
-        })
-      },
-      onDateInput: (block) => {
-        block.setAttribute('status', 'run-all-running')
-      },
-      onFileUpload: () => {},
-      onDashboardHeader: () => {},
-      onWriteback: (block) => {
-        block.setAttribute('status', 'run-all-running')
-      },
-      onPivotTable: (block) => {
-        block.setAttribute('status', 'run-all-running')
-      },
-    })
-  }
-
-  private setBlockToIdle(block: YBlock) {
-    switchBlockType(block, {
-      onRichText: () => {},
-      onPython: (block) => {
-        block.setAttribute('status', 'idle')
-      },
-      onSQL: (block) => {
-        block.setAttribute('status', 'idle')
-      },
-      onVisualization: (block) => {
-        block.setAttribute('status', 'idle')
-      },
-      onInput: (block) => {
-        updateInputValue(block, {
-          status: 'idle',
-        })
-      },
-      onDropdownInput: (block) => {
-        updateDropdownInputValue(block, {
-          status: 'idle',
-        })
-      },
-      onDateInput: (block) => {
-        block.setAttribute('status', 'idle')
-      },
-      onFileUpload: () => {},
-      onDashboardHeader: () => {},
-      onWriteback: (block) => {
-        block.setAttribute('status', 'idle')
-      },
-      onPivotTable: (block) => {
-        block.setAttribute('status', 'idle')
-      },
-    })
-  }
-
-  private async runBlock(block: YBlock, tr: Y.Transaction) {
-    this.currentBlock = block
-
-    await switchBlockType(block, {
-      onRichText: () => Promise.resolve(),
-      onPython: (block) => this.executors.python.run(block, tr, false),
-      onSQL: (block) => this.executors.sql.runQuery(block, tr, false, true),
-      onVisualization: (block) => this.executors.visualization.run(block, tr),
-      onInput: (block) => this.executors.input.saveValue(block),
-      onDropdownInput: (block) => this.executors.dropdownInput.saveValue(block),
-      onDateInput: async (block) => {
-        await this.executors.dateInput.save(block)
-      },
-      onFileUpload: () => Promise.resolve(),
-      onDashboardHeader: () => Promise.resolve(),
-      onWriteback: (block) => this.executors.writeback.run(block, tr),
-      onPivotTable: () =>
-        this.executors.pivotTable.run(this.currentBlock as Y.XmlElement, tr),
-    })
-  }
-
-  public static make(
-    workspaceId: string,
-    documentId: string,
-    blocks: Y.Map<YBlock>,
-    layout: Y.Array<YBlockGroup>,
-    state: YRunAll,
-    dataframes: Y.Map<DataFrame>,
-    mainExecutionQueue: PQueue,
-    events: NotebookEvents
-  ) {
-    const runAllExecutionQueue = new PQueue({ concurrency: 1 })
-
-    const executors = {
-      sql: SQLExecutor.make(
-        workspaceId,
-        documentId,
-        config().DATASOURCES_ENCRYPTION_KEY,
-        dataframes,
-        blocks,
-        runAllExecutionQueue,
-        events
-      ),
-      python: PythonExecutor.make(
-        workspaceId,
-        documentId,
-        dataframes,
-        blocks,
-        runAllExecutionQueue,
-        events
-      ),
-      visualization: VisualizationExecutor.make(
-        workspaceId,
-        documentId,
-        dataframes,
-        runAllExecutionQueue,
-        events
-      ),
-      input: InputExecutor.make(
-        workspaceId,
-        documentId,
-        blocks,
-        runAllExecutionQueue
-      ),
-      dropdownInput: DropdownInputExecutor.make(
-        workspaceId,
-        documentId,
-        blocks,
-        runAllExecutionQueue
-      ),
-      writeback: WritebackExecutor.make(
-        workspaceId,
-        documentId,
-        runAllExecutionQueue,
-        events
-      ),
-      dateInput: new DateInputExecutor(
-        workspaceId,
-        documentId,
-        blocks,
-        runAllExecutionQueue
-      ),
-      pivotTable: PivotTableExecutor.make(
-        workspaceId,
-        documentId,
-        dataframes,
-        blocks,
-        runAllExecutionQueue
-      ),
-    }
-
-    return new RunAllExecutor(
-      workspaceId,
-      documentId,
-      blocks,
-      layout,
-      state,
-      mainExecutionQueue,
-      executors
-    )
-  }
-}
diff --git a/apps/api/src/yjs/v2/index.ts b/apps/api/src/yjs/v2/index.ts
index be153b68..32ca6882 100644
--- a/apps/api/src/yjs/v2/index.ts
+++ b/apps/api/src/yjs/v2/index.ts
@@ -1,7 +1,7 @@
 // adapted from: https://github.com/yjs/y-websocket/tree/master/bin
 
+import { v4 as uuidv4 } from 'uuid'
 import { LRUCache } from 'lru-cache'
-import pAll from 'p-all'
 import * as Y from 'yjs'
 import qs from 'querystring'
 import cookie from 'cookie'
@@ -18,6 +18,8 @@ import prisma, {
   Document,
   ApiUser,
   getDocument,
+  subscribe,
+  publish,
 } from '@briefer/database'
 import { decoding, encoding } from 'lib0'
 import { logger } from '../../logger.js'
@@ -32,7 +34,6 @@ import {
 import PQueue from 'p-queue'
 import { IOServer } from '../../websocket/index.js'
 import { broadcastDocument } from '../../websocket/workspace/documents.js'
-import { MainObserver } from './observers/index.js'
 import {
   AppPersistor,
   DocumentPersistor,
@@ -44,11 +45,12 @@ import {
   getDashboard,
   getDataframes,
   getLayout,
-  getRunAll,
 } from '@briefer/editor'
-import { UserNotebookEvents } from '../../events/user.js'
-import { uuidSchema } from '@briefer/types'
+import { jsonString, uuidSchema } from '@briefer/types'
 import { z } from 'zod'
+import { Executor } from './executor/index.js'
+import { AIExecutor } from './executor/ai/index.js'
+import { acquireLock } from '../../lock.js'
 
 type Role = UserWorkspaceRole
 
@@ -248,20 +250,16 @@ export function setupYJSSocketServerV2(
             { docsCount: docs.size },
             '[shutdown] Waiting for YJS docs to close'
           )
-          await pAll(
-            Array.from(docs.values()).map((doc) => async () => {
-              await doc.persist(false)
-              if (doc.conns.size > 0) {
-                doc.conns.forEach((_, conn) => {
-                  closeConn(doc, conn)
-                })
-              } else if (doc.canCollect()) {
-                doc.destroy()
-                docs.delete(doc.id)
-              }
-            }),
-            { concurrency: 10 }
-          )
+          for (const doc of docs.values()) {
+            if (doc.conns.size > 0) {
+              doc.conns.forEach((_, conn) => {
+                closeConn(doc, conn)
+              })
+            } else if (doc.canCollect()) {
+              await doc.destroy()
+              docs.delete(doc.id)
+            }
+          }
           await new Promise((resolve) => setTimeout(resolve, 200))
         }
 
@@ -280,7 +278,7 @@ export function setupYJSSocketServerV2(
 const wsReadyStateConnecting = 0
 const wsReadyStateOpen = 1
 
-const DOCUMENT_COLLECTION_INTERVAL = 1000 * 5 // 5 seconds
+const DOCUMENT_COLLECTION_INTERVAL = 1000 * 20 // 20 seconds
 export const docs = new Map<string, WSSharedDocV2>()
 
 export const docsCache = new LRUCache<string, WSSharedDocV2>({
@@ -313,33 +311,13 @@ function startDocumentCollection() {
         logger().trace({ docId }, 'Checking if doc can be collected')
         if (doc.canCollect()) {
           logger().trace({ docId }, 'Doc can be collected')
-          queue.add(async () => {
-            if (!doc.canCollect()) {
-              logger().trace(
-                { docId },
-                'Doc was referenced again, collectiong aborted'
-              )
-              return
-            }
-
-            logger().trace({ docId }, 'Persisting doc state')
-            await doc.persist(false)
-            if (!doc.canCollect()) {
-              logger().trace(
-                { docId },
-                'Doc was referenced again while persisting, collection aborted'
-              )
-              return
-            }
-
-            docs.delete(docId)
-            if (!docsCache.has(docId)) {
-              // only destroy if the doc is not in cache
-              doc.destroy()
-            }
-            logger().trace({ docId }, 'Doc collected')
-            collected++
-          })
+          docs.delete(docId)
+          if (!docsCache.has(docId)) {
+            // only destroy if the doc is not in cache
+            doc.destroy()
+          }
+          logger().trace({ docId }, 'Doc collected')
+          collected++
         }
       }
 
@@ -395,10 +373,13 @@ export class WSSharedDocV2 {
 
   private socketServer: IOServer
   private title: string = ''
-  private observer: MainObserver
+  private executor: Executor
+  private aiExecutor: AIExecutor
   private persistor: Persistor
   private serialUpdatesQueue: PQueue = new PQueue({ concurrency: 1 })
   private byteLength: number = 0
+  private publisherId: string
+  private subscription?: () => Promise<void>
 
   private constructor(
     id: string,
@@ -420,29 +401,123 @@ export class WSSharedDocV2 {
     this.byteLength = loadStateResult.byteLength
     this.awareness = this.configAwareness()
 
-    const events = new UserNotebookEvents(this.workspaceId, this.documentId)
-    this.observer = MainObserver.make(
-      this.workspaceId,
-      this.documentId,
-      this,
-      events
-    )
+    this.executor = Executor.fromWSSharedDocV2(this)
+    this.aiExecutor = AIExecutor.fromWSSharedV2(this)
+    this.publisherId = uuidv4()
+  }
+
+  private getPubSubChannel() {
+    return `yjs-updates-${this.documentId}`
   }
 
-  public init() {
+  public async init() {
+    this.subscription = await subscribe(
+      this.getPubSubChannel(),
+      this.onSubMessage
+    )
+
     this.ydoc.on('update', this.updateHandler)
     this.awareness.on('update', this.awarenessHandler)
-    this.observer.start()
+    this.executor.start()
+    this.aiExecutor.start()
   }
 
-  public async replaceState(state: Uint8Array | Buffer): Promise<void> {
-    await this.serialUpdatesQueue.add(async () => {
-      const result = await this.persistor.replaceState(
-        state instanceof Buffer ? state : Buffer.from(state)
+  private onSubMessage = async (message?: string) => {
+    if (!message) {
+      logger().error(
+        {
+          id: this.id,
+          documentId: this.documentId,
+          workspaceId: this.workspaceId,
+        },
+        'Received empty sub message'
       )
+      return
+    }
 
-      this.reset(result.ydoc, result.clock, result.byteLength)
+    const parsedMessage = jsonString
+      .pipe(
+        z.object({
+          id: z.string().min(1),
+          publisherId: uuidSchema,
+          message: uuidSchema,
+          clock: z.number().int(),
+        })
+      )
+      .safeParse(message)
+    if (!parsedMessage.success) {
+      logger().error(
+        {
+          id: this.id,
+          documentId: this.documentId,
+          workspaceId: this.workspaceId,
+          err: parsedMessage.error,
+        },
+        'Received invalid sub message'
+      )
+      return
+    }
+
+    if (
+      parsedMessage.data.publisherId === this.publisherId ||
+      parsedMessage.data.id !== this.id
+    ) {
+      return
+    }
+
+    logger().trace(
+      {
+        id: this.id,
+        documentId: this.documentId,
+        workspaceId: this.workspaceId,
+      },
+      'Handling foreign sub message'
+    )
+
+    const update = await prisma().yjsUpdate.findUnique({
+      where: {
+        id: parsedMessage.data.message,
+      },
     })
+    if (!update) {
+      logger().error(
+        {
+          id: this.id,
+          documentId: this.documentId,
+          workspaceId: this.workspaceId,
+          updateId: parsedMessage.data.message,
+        },
+        'Could not find foreign update in database'
+      )
+      return
+    }
+
+    if (this.clock === parsedMessage.data.clock) {
+      Y.applyUpdate(this.ydoc, update.update)
+    } else if (parsedMessage.data.clock > this.clock) {
+      // we have a previous version of the document, we need to reset
+      const newDoc = new Y.Doc()
+      Y.applyUpdate(newDoc, update.update)
+      this.reset(newDoc, parsedMessage.data.clock, update.update.byteLength)
+    } else {
+      // we have a newer version of the document, ignore the update
+    }
+  }
+
+  public async replaceState(state: Buffer) {
+    const result = await this.persistor.replaceState(this.id, state)
+    this.reset(result.ydoc, result.clock, result.byteLength)
+    const updateId = await this.persistor.persistUpdate(this, state)
+    await publish(
+      this.getPubSubChannel(),
+      JSON.stringify({
+        id: this.id,
+        publisherId: this.publisherId,
+        message: updateId,
+        clock: this.clock,
+      })
+    )
+    broadcastDocument(this.socketServer, this.workspaceId, this.documentId)
   }
 
   private reset(newYDoc: Y.Doc, newClock: number, newByteLength: number) {
@@ -452,21 +527,23 @@ export class WSSharedDocV2 {
     this.awareness.off('update', this.awarenessHandler)
     this.awareness.destroy()
 
+    this.executor.stop()
+    this.aiExecutor.stop()
+
     this.ydoc = newYDoc
     this.clock = newClock
     this.byteLength = newByteLength
-    this.observer = MainObserver.make(
-      this.workspaceId,
-      this.documentId,
-      this,
-      new UserNotebookEvents(this.workspaceId, this.documentId)
-    )
 
     this.title = this.getTitleFromDoc()
 
     this.awareness = this.configAwareness()
     this.ydoc.on('update', this.updateHandler)
-    this.observer.start()
+
+    this.executor = Executor.fromWSSharedDocV2(this)
+    this.executor.start()
+
+    this.aiExecutor = AIExecutor.fromWSSharedV2(this)
+    this.aiExecutor.start()
   }
 
   private awarenessHandler = (
@@ -530,30 +607,18 @@ export class WSSharedDocV2 {
     return getLayout(this.ydoc)
   }
 
-  public get runAll() {
-    return getRunAll(this.ydoc)
-  }
-
-  public destroy() {
-    this.observer.stop()
-    this.ydoc.destroy()
+  public get executionQueue() {
+    return this.ydoc.getArray('executionQueue')
   }
 
-  public async persist(cleanHistory: boolean) {
-    await this.serialUpdatesQueue.add(async () => {
-      if (cleanHistory) {
-        const result = await this.persistor.cleanHistory(this)
+  public async destroy() {
+    await Promise.all([
+      this.subscription?.(),
+      this.executor.stop(),
+      this.aiExecutor.stop(),
+    ])
 
-        this.reset(result.ydoc, result.clock, result.byteLength)
-        await broadcastDocument(
-          this.socketServer,
-          this.workspaceId,
-          this.documentId
-        )
-      }
-
-      this.byteLength = await this.persistor.persist(this)
-    })
+    this.ydoc.destroy()
   }
 
   private get refs() {
@@ -561,7 +626,7 @@ export class WSSharedDocV2 {
   }
 
   public canCollect() {
-    return this.refs === 0 && this.observer.isIdle()
+    return this.refs === 0 && this.executor.isIdle() && this.aiExecutor.isIdle()
   }
 
   public canWrite(
@@ -579,13 +644,56 @@ export class WSSharedDocV2 {
     return this.byteLength
   }
 
-  private updateHandler = async (update: Uint8Array) => {
+  private updateHandler = async (
+    update: Uint8Array,
+    _arg1: any,
+    _arg2: any,
+    tr: Y.Transaction
+  ) => {
     const encoder = encoding.createEncoder()
     encoding.writeVarUint(encoder, messageSync)
     syncProtocol.writeUpdate(encoder, update)
     const message = encoding.toUint8Array(encoder)
     this.conns.forEach((_, conn) => send(this, conn, message))
 
+    // only call this when the update originates from this connection
+    if (tr.local || (tr.origin && 'user' in tr.origin)) {
+      const pubsubChannel = this.getPubSubChannel()
+      try {
+        const updateId = await this.persistor.persistUpdate(this, update)
+        await publish(
+          pubsubChannel,
+          JSON.stringify({
+            id: this.id,
+            publisherId: this.publisherId,
+            message: updateId,
+            clock: this.clock,
+          })
+        )
+        logger().trace(
+          {
+            id: this.id,
+            documentId: this.documentId,
+            workspaceId: this.workspaceId,
+            updateId,
+          },
+          'Published Yjs update'
+        )
+      } catch (err) {
+        logger().error(
+          {
+            id: this.id,
+            documentId: this.documentId,
+            workspaceId: this.workspaceId,
+            pubsubChannel,
+            err,
+          },
+          'Failed to persist Yjs update'
+        )
+        throw err
+      }
+    }
+
     const [titleChanged] = await Promise.all([this.handleTitleUpdate()])
 
     if (titleChanged) {
@@ -722,7 +830,7 @@ export class WSSharedDocV2 {
     persistor: Persistor,
     tx?: PrismaTransaction
   ): Promise<WSSharedDocV2> {
-    const loadStateResult = await persistor.load(tx)
+    const loadStateResult = await persistor.load(id, tx)
     const doc = new WSSharedDocV2(
       id,
       documentId,
@@ -731,7 +839,7 @@ export class WSSharedDocV2 {
       loadStateResult,
       persistor
     )
-    doc.init()
+    await doc.init()
     return doc
   }
 }
diff --git a/apps/api/src/yjs/v2/observers/blocks/date-input.ts b/apps/api/src/yjs/v2/observers/blocks/date-input.ts
deleted file mode 100644
index 7ead086c..00000000
--- a/apps/api/src/yjs/v2/observers/blocks/date-input.ts
+++ /dev/null
@@ -1,170 +0,0 @@
-import { DateInputBlock, YBlock, getDateInputAttributes } from '@briefer/editor'
-import PQueue from 'p-queue'
-import * as Y from 'yjs'
-import { IBlockObserver } from './index.js'
-import { logger } from '../../../../logger.js'
-import {
-  IDateInputExecutor,
-  DateInputExecutor,
-} from '../../executors/blocks/date-input.js'
-
-export interface IDateInputObserver extends IBlockObserver<DateInputBlock> {}
-
-export class DateInputObserver implements IDateInputObserver {
-  private workspaceId: string
-  private documentId: string
-  private blocks: Y.Map<YBlock>
-  private executor: IDateInputExecutor
-
-  constructor(
-    workspaceId: string,
-    documentId: string,
-    blocks: Y.Map<YBlock>,
-    executor: IDateInputExecutor
-  ) {
-    this.workspaceId = workspaceId
-    this.documentId = documentId
-    this.blocks = blocks
-    this.executor = executor
-  }
-
-  public isIdle() {
-    return this.executor.isIdle()
-  }
-
-  public handleInitialBlockState(block: Y.XmlElement<DateInputBlock>) {
-    logger().trace(
-      {
-        workspaceId: this.workspaceId,
-        documentId: this.documentId,
-        blockId: block.getAttribute('id'),
-      },
-      'handling initial date input block state'
-    )
-
-    const status = getDateInputAttributes(block, this.blocks).status
-    if (status !== 'idle') {
-      block.setAttribute('status', 'idle')
-    }
-
-    this.executor.save(block)
-  }
-
-  public async handleBlockEvent(
-    block: Y.XmlElement<DateInputBlock>,
-    action: string,
-    oldValue: any,
-    key: string
-  ) {
-    if (action !== 'update') {
-      logger().trace(
-        {
-          workspaceId: this.workspaceId,
-          documentId: this.documentId,
-          blockId: block.getAttribute('id'),
-          action,
-          key,
-        },
-        'Ignoring date input block event'
-      )
-      return
-    }
-
-    logger().trace(
-      {
-        workspaceId: this.workspaceId,
-        documentId: this.documentId,
-        blockId: block.getAttribute('id'),
-        action,
-        key,
-      },
-      'Handling date input block event'
-    )
-
-    if (key === 'status') {
-      await this.handleBlockStatusChange(block)
-    }
-
-    logger().trace(
-      {
-        workspaceId: this.workspaceId,
-        documentId: this.documentId,
-        blockId: block.getAttribute('id'),
-      },
-      'handled date input block event'
-    )
-  }
-
-  private async handleBlockStatusChange(block: Y.XmlElement<DateInputBlock>) {
-    const attrs = getDateInputAttributes(block, this.blocks)
-
-    logger().trace(
-      {
-        workspaceId: this.workspaceId,
-        documentId: this.documentId,
-        blockId: attrs.id,
-        status: attrs.status,
-      },
-      'Handling date input block status'
-    )
-
-    try {
-      switch (attrs.status) {
-        case 'idle':
-          break
-        case 'run-requested':
-          block.setAttribute('status', 'running')
-          break
-        case 'running': {
-          const success = await this.executor.save(block)
-          if (success) {
-            block.setAttribute('status', 'idle')
-          }
-          break
-        }
-        case 'run-all-running':
-        case 'run-all-enqueued':
-          // handled by run all observer
-          break
-      }
-    } catch (err) {
-      logger().error(
-        {
-          workspaceId: this.workspaceId,
-          documentId: this.documentId,
-          blockId: attrs.id,
-          status: attrs.status,
-          err,
-        },
-        'Error while handling block status change'
-      )
-      block.setAttribute('status', 'idle')
-    }
-
-    logger().trace(
-      {
-        workspaceId: this.workspaceId,
-        documentId: this.documentId,
-        blockId: attrs.id,
-        status: attrs.status,
-      },
-      'finished handling date input block status change'
-    )
-  }
-
-  public static make(
-    workspaceId: string,
-    documentId: string,
-    blocks: Y.Map<YBlock>,
-    executionQueue: PQueue
-  ) {
-    const executor = DateInputExecutor.make(
-      workspaceId,
-      documentId,
-      blocks,
-      executionQueue
-    )
-
-    return new DateInputObserver(workspaceId, documentId, blocks, executor)
-  }
-}
diff --git a/apps/api/src/yjs/v2/observers/blocks/dropdown-input.ts b/apps/api/src/yjs/v2/observers/blocks/dropdown-input.ts
deleted file mode 100644
index 9116be62..00000000
--- a/apps/api/src/yjs/v2/observers/blocks/dropdown-input.ts
+++ /dev/null
@@ -1,259 +0,0 @@
-import {
-  DropdownInputBlock,
-  YBlock,
-  getDropdownInputAttributes,
-  updateDropdownInputValue,
-  updateDropdownInputVariable,
-} from '@briefer/editor'
-import PQueue from 'p-queue'
-import * as Y from 'yjs'
-import { IBlockObserver } from './index.js'
-import { logger } from '../../../../logger.js'
-import {
-  IDropdownInputExecutor,
-  DropdownInputExecutor,
-} from '../../executors/blocks/dropdown-input.js'
-
-export interface IDropdownInputObserver
-  extends IBlockObserver<DropdownInputBlock> {}
-
-export class DropdownInputObserver implements IDropdownInputObserver {
-  private workspaceId: string
-  private documentId: string
-  private blocks: Y.Map<YBlock>
-  private executor: IDropdownInputExecutor
-
-  constructor(
-    workspaceId: string,
-    documentId: string,
-    blocks: Y.Map<YBlock>,
-    executor: IDropdownInputExecutor
-  ) {
-    this.workspaceId = workspaceId
-    this.documentId = documentId
-    this.blocks = blocks
-    this.executor = executor
-  }
-
-  public isIdle() {
-    return this.executor.isIdle()
-  }
-
-  public handleInitialBlockState(block: Y.XmlElement<DropdownInputBlock>) {
-    logger().trace(
-      {
-        workspaceId: this.workspaceId,
-        documentId: this.documentId,
-        blockId: block.getAttribute('id'),
-      },
-      'handling initial dropdown input block state'
-    )
-
-    const value = block.getAttribute('value')
-    if (value && value.status !== 'idle') {
-      block.setAttribute('value', {
-        ...value,
-        status: 'idle',
-      })
-    }
-
-    const variable = block.getAttribute('variable')
-    if (variable && variable.status !== 'idle') {
-      block.setAttribute('variable', {
-        ...variable,
-        status: 'idle',
-      })
-    }
-  }
-
-  public async handleBlockEvent(
-    block: Y.XmlElement<DropdownInputBlock>,
-    action: string,
-    oldValue: any,
-    key: string
-  ) {
-    if (action !== 'update') {
-      logger().trace(
-        {
-          workspaceId: this.workspaceId,
-          documentId: this.documentId,
-          blockId: block.getAttribute('id'),
-          action,
-          key,
-        },
-        'Ignoring dropdown input block event'
-      )
-      return
-    }
-
-    logger().trace(
-      {
-        workspaceId: this.workspaceId,
-        documentId: this.documentId,
-        blockId: block.getAttribute('id'),
-        action,
-        key,
-      },
-      'Handling dropdown input block event'
-    )
-
-    if (key === 'value') {
-      await this.handleBlockValueChange(block, oldValue)
-    } else if (key === 'variable') {
-      await this.handleBlockVariableChange(block, oldValue)
-    }
-
-    logger().trace(
-      {
-        workspaceId: this.workspaceId,
-        documentId: this.documentId,
-        blockId: block.getAttribute('id'),
-      },
-      'handled dropdown input block event'
-    )
-  }
-
-  private async handleBlockValueChange(
-    block: Y.XmlElement<DropdownInputBlock>,
-    oldValue: DropdownInputBlock['value']
-  ) {
-    const attrs = getDropdownInputAttributes(block, this.blocks)
-    if (attrs.value.status === 'idle') {
-      return
-    }
-
-    if (attrs.value.status === oldValue.status) {
-      return
-    }
-
-    logger().trace(
-      {
-        workspaceId: this.workspaceId,
-        documentId: this.documentId,
-        blockId: attrs.id,
-        status: attrs.value.status,
-      },
-      'Handling dropdown input block value status'
-    )
-
-    if (attrs.value.status === 'save-requested') {
-      updateDropdownInputValue(block, { status: 'saving' })
-    } else if (attrs.value.status === 'saving') {
-      await this.saveDropdownInputValue(block)
-    }
-
-    logger().trace(
-      {
-        workspaceId: this.workspaceId,
-        documentId: this.documentId,
-        blockId: block.getAttribute('id'),
-        status: attrs.value.status,
-      },
-      'Handled dropdown input block value status'
-    )
-  }
-
-  private async saveDropdownInputVariable(
-    block: Y.XmlElement<DropdownInputBlock>
-  ) {
-    try {
-      await this.executor.saveVariable(block)
-      updateDropdownInputVariable(block, this.blocks, {
-        status: 'idle',
-      })
-    } catch (error) {
-      logger().error(
-        {
-          workspaceId: this.workspaceId,
-          documentId: this.documentId,
-          blockId: block.getAttribute('id'),
-          error,
-        },
-        'Failed to save variable value'
-      )
-      updateDropdownInputVariable(block, this.blocks, {
-        status: 'idle',
-        error: 'unexpected-error',
-      })
-    }
-  }
-
-  private async handleBlockVariableChange(
-    block: Y.XmlElement<DropdownInputBlock>,
-    oldValue: DropdownInputBlock['variable']
-  ) {
-    const attrs = getDropdownInputAttributes(block, this.blocks)
-    if (attrs.variable.status === 'idle') {
-      return
-    }
-
-    if (attrs.variable.status === oldValue.status) {
-      return
-    }
-
-    logger().trace(
-      {
-        workspaceId: this.workspaceId,
-        documentId: this.documentId,
-        blockId: attrs.id,
-        status: attrs.variable.status,
-      },
-      'Handling dropdown input block variable status'
-    )
-
-    if (attrs.variable.status === 'save-requested') {
-      updateDropdownInputVariable(block, this.blocks, { status: 'saving' })
-    } else if (attrs.variable.status === 'saving') {
-      await this.saveDropdownInputVariable(block)
-    }
-
-    logger().trace(
-      {
-        workspaceId: this.workspaceId,
-        documentId: this.documentId,
-        blockId: block.getAttribute('id'),
-        status: attrs.variable.status,
-      },
-      'Handled dropdown input block variable status'
-    )
-  }
-
-  private async saveDropdownInputValue(
-    block: Y.XmlElement<DropdownInputBlock>
-  ) {
-    try {
-      await this.executor.saveValue(block)
-      updateDropdownInputValue(block, { status: 'idle' })
-    } catch (error) {
-      logger().error(
-        {
-          workspaceId: this.workspaceId,
-          documentId: this.documentId,
-          blockId: block.getAttribute('id'),
-          error,
-        },
-        'Failed to save dropdown input value'
-      )
-      updateDropdownInputValue(block, {
-        status: 'idle',
-        error: 'unexpected-error',
-      })
-    }
-  }
-
-  public static make(
-    workspaceId: string,
-    documentId: string,
-    blocks: Y.Map<YBlock>,
-    executionQueue: PQueue
-  ) {
-    const executor = DropdownInputExecutor.make(
-      workspaceId,
-      documentId,
-      blocks,
-      executionQueue
-    )
-
-    return new DropdownInputObserver(workspaceId, documentId, blocks, executor)
-  }
-}
diff --git a/apps/api/src/yjs/v2/observers/blocks/file-upload.ts b/apps/api/src/yjs/v2/observers/blocks/file-upload.ts
deleted file mode 100644
index c972ed6b..00000000
--- a/apps/api/src/yjs/v2/observers/blocks/file-upload.ts
+++ /dev/null
@@ -1,165 +0,0 @@
-import {
-  FileUploadBlock,
-  UploadedFile,
-  removeUploadedFile,
-  updateUploadedFile,
-} from '@briefer/editor'
-import * as Y from 'yjs'
-import { IBlockObserver } from './index.js'
-import {
-  IFileUploadExecutor,
-  FileUploadExecutor,
-} from '../../executors/blocks/file-upload.js'
-import { logger } from '../../../../logger.js'
-import { equals } from 'ramda'
-
-export interface IFileUploadObserver extends IBlockObserver<FileUploadBlock> {}
-
-export class FileUploadObserver implements IFileUploadObserver {
-  private workspaceId: string
-  private documentId: string
-  private executor: IFileUploadExecutor
-
-  constructor(
-    workspaceId: string,
-    documentId: string,
-    executor: IFileUploadExecutor
-  ) {
-    this.workspaceId = workspaceId
-    this.documentId = documentId
-    this.executor = executor
-  }
-
-  public isIdle() {
-    return this.executor.isIdle()
-  }
-
-  public handleInitialBlockState = (block: Y.XmlElement<FileUploadBlock>) => {
-    logger().trace(
-      {
-        workspaceId: this.workspaceId,
-        documentId: this.documentId,
-        blockId: block.getAttribute('id'),
-      },
-      'handling initial file upload block state'
-    )
-
-    const uploadedFiles = block.getAttribute('uploadedFiles')
-    if (!uploadedFiles) {
-      return
-    }
-
-    const files = uploadedFiles.map((file): UploadedFile => {
-      return {
-        name: file.name,
-        size: file.size,
-        type: file.type,
-        status: 'idle',
-        error: file.error,
-      }
-    })
-    if (!equals(files, uploadedFiles)) {
-      block.setAttribute('uploadedFiles', files)
-    }
-  }
-
-  public async handleBlockEvent(
-    block: Y.XmlElement<FileUploadBlock>,
-    action: string,
-    oldValue: any,
-    key: string
-  ): Promise<void> {
-    if (key !== 'uploadedFiles' || action !== 'update') {
-      logger().trace(
-        {
-          workspaceId: this.workspaceId,
-          documentId: this.documentId,
-          blockId: block.getAttribute('id'),
-          action,
-          key,
-        },
-        'ignoring fileupload block event'
-      )
-      return
-    }
-
-    logger().trace(
-      {
-        workspaceId: this.workspaceId,
-        documentId: this.documentId,
-        blockId: block.getAttribute('id'),
-        action,
-        key,
-      },
-      'handling file upload block event'
-    )
-    const uploadedFiles = block.getAttribute('uploadedFiles') ?? []
-    await this.handleUploadedFilesChange(block, oldValue, uploadedFiles)
-  }
-
-  private async handleUploadedFilesChange(
-    block: Y.XmlElement<FileUploadBlock>,
-    prev: UploadedFile[],
-    next: UploadedFile[]
-  ) {
-    const prevByName = prev.reduce((acc, file) => {
-      acc[file.name] = file
-      return acc
-    }, {} as Record<string, UploadedFile>)
-
-    await Promise.all(
-      next.map(async (n) => {
-        const p = prevByName[n.name]
-        if (!p) {
-          return n
-        }
-
-        return this.handleUploadedFileChange(block, p, n)
-      })
-    )
-  }
-
-  private async handleUploadedFileChange(
-    block: Y.XmlElement<FileUploadBlock>,
-    prev: UploadedFile,
-    next: UploadedFile
-  ): Promise<boolean> {
-    switch (next.status) {
-      case 'idle':
-        return false
-      case 'delete-requested':
-        switch (prev.status) {
-          case 'idle':
-          case 'delete-requested':
-            updateUploadedFile(block, next.name, { status: 'deleting' })
-            return true
-          case 'deleting':
-            return false
-        }
-      case 'deleting':
-        await this.deleteFile(block, next)
-        return true
-    }
-  }
-
-  private async deleteFile(
-    block: Y.XmlElement<FileUploadBlock>,
-    f: UploadedFile
-  ) {
-    try {
-      await this.executor.deleteFile(f.name)
-      removeUploadedFile(block, f.name)
-    } catch (error) {
-      updateUploadedFile(block, f.name, {
-        status: 'idle',
-        error: 'delete-unexpected-error',
-      })
-    }
-  }
-
-  public static make(workspaceId: string, documentId: string) {
-    const executor = FileUploadExecutor.make(workspaceId)
-
-    return new FileUploadObserver(workspaceId, documentId, executor)
-  }
-}
diff --git a/apps/api/src/yjs/v2/observers/blocks/index.ts b/apps/api/src/yjs/v2/observers/blocks/index.ts
deleted file mode 100644
index c4bff27a..00000000
--- a/apps/api/src/yjs/v2/observers/blocks/index.ts
+++ /dev/null
@@ -1,391 +0,0 @@
-import { YBlock, getBaseAttributes, switchBlockType } from '@briefer/editor'
-import { ISQLObserver, SQLObserver } from './sql.js'
-import { IPythonObserver, PythonObserver } from './python.js'
-import {
-  IVisualizationObserver,
-  VisualizationObserver,
-} from './visualization.js'
-import { logger } from '../../../../logger.js'
-import { IInputObserver, InputObserver } from './input.js'
-import {
-  IDropdownInputObserver,
-  DropdownInputObserver,
-} from './dropdown-input.js'
-import { DataFrame } from '@briefer/types'
-import * as Y from 'yjs'
-import { ValueTypes } from 'yjs/dist/src/internals'
-import PQueue from 'p-queue'
-import { FileUploadObserver, IFileUploadObserver } from './file-upload.js'
-import {
-  EventContext,
-  NotebookBlockEvents,
-  NotebookEvents,
-} from '../../../../events/index.js'
-import { IWritebackObserver, WritebackObserver } from './writeback.js'
-import { DateInputObserver, IDateInputObserver } from './date-input.js'
-import { IPivotTableObserver, PivotTableObserver } from './pivot-table.js'
-
-export interface IBlockObserver<T extends { [key: string]: ValueTypes }> {
-  isIdle: () => boolean
-  handleInitialBlockState(block: Y.XmlElement<T>): void
-  handleBlockEvent(
-    block: Y.XmlElement<T>,
-    action: string,
-    oldValue: any,
-    key: string,
-    tr: Y.Transaction
-  ): Promise<void>
-}
-
-export interface IBlocksObserver {
-  start(): void
-  stop(): void
-  isIdle(): boolean
-}
-
-export class BlocksObserver implements IBlocksObserver {
-  private readonly workspaceId: string
-  private readonly documentId: string
-  private readonly blocks: Y.Map<YBlock>
-  private readonly sqlBlocksObserver: ISQLObserver
-  private readonly pythonBlocksObserver: IPythonObserver
-  private readonly visualizationBlockObserver: IVisualizationObserver
-  private readonly inputBlocksObserver: IInputObserver
-  private readonly dropdownInputBlocksObserver: IDropdownInputObserver
-  private readonly dateInputBlocksObserver: IDateInputObserver
-  private readonly fileUploadBlocksObserver: IFileUploadObserver
-  private readonly writebackBlocksObserver: IWritebackObserver
-  private readonly pivotTableBlocksObserver: IPivotTableObserver
-  private readonly events: NotebookBlockEvents
-
-  constructor(
-    workspaceId: string,
-    documentId: string,
-    blocks: Y.Map<YBlock>,
-    sqlBlocksObserver: ISQLObserver,
-    pythonBlocksObserver: IPythonObserver,
-    visualizationBlocksObserver: IVisualizationObserver,
-    inputBlocksObserver: IInputObserver,
-    dropdownInputBlocksObserver: IDropdownInputObserver,
-    dateInputBlocksObserver: IDateInputObserver,
-    fileUploadBlocksObserver: IFileUploadObserver,
-    writebackBlocksObserver: IWritebackObserver,
-    pivotTableBlocksObserver: IPivotTableObserver,
-    events: NotebookBlockEvents
-  ) {
-    this.workspaceId = workspaceId
-    this.documentId = documentId
-    this.blocks = blocks
-    this.sqlBlocksObserver = sqlBlocksObserver
-    this.pythonBlocksObserver = pythonBlocksObserver
-    this.visualizationBlockObserver = visualizationBlocksObserver
-    this.inputBlocksObserver = inputBlocksObserver
-    this.dropdownInputBlocksObserver = dropdownInputBlocksObserver
-    this.dateInputBlocksObserver = dateInputBlocksObserver
-    this.fileUploadBlocksObserver = fileUploadBlocksObserver
-    this.writebackBlocksObserver = writebackBlocksObserver
-    this.pivotTableBlocksObserver = pivotTableBlocksObserver
-    this.events = events
-  }
-
-  public isIdle() {
-    return (
-      this.sqlBlocksObserver.isIdle() &&
-      this.pythonBlocksObserver.isIdle() &&
-      this.visualizationBlockObserver.isIdle() &&
-      this.inputBlocksObserver.isIdle() &&
-      this.dropdownInputBlocksObserver.isIdle() &&
-      this.dateInputBlocksObserver.isIdle() &&
-      this.fileUploadBlocksObserver.isIdle() &&
-      this.writebackBlocksObserver.isIdle() &&
-      this.pivotTableBlocksObserver.isIdle()
-    )
-  }
-
-  public start() {
-    logger().trace(
-      {
-        workspaceId: this.workspaceId,
-        documentId: this.documentId,
-      },
-      'starting blocks observer'
-    )
-
-    this.blocks.forEach((block) => {
-      try {
-        block.observe(this.onBlockEvent)
-        this.handleInitialBlockState(block)
-      } catch (err) {
-        logger().error(
-          {
-            workspaceId: this.workspaceId,
-            documentId: this.documentId,
-            blockId: block.getAttribute('id'),
-            error: err,
-          },
-          'Failed to observe block'
-        )
-      }
-    })
-
-    this.blocks.observe(this.onBlocksEvent)
-    logger().trace(
-      {
-        workspaceId: this.workspaceId,
-        documentId: this.documentId,
-      },
-      'blocks observer started'
-    )
-  }
-
-  public stop() {
-    logger().trace(
-      {
-        workspaceId: this.workspaceId,
-        documentId: this.documentId,
-      },
-      'stopping blocks observer'
-    )
-
-    this.blocks.forEach((block) => {
-      block.unobserve(this.onBlockEvent)
-    })
-
-    this.blocks.unobserve(this.onBlocksEvent)
-    logger().trace(
-      {
-        workspaceId: this.workspaceId,
-        documentId: this.documentId,
-      },
-      'blocks observer stopped'
-    )
-  }
-
-  private handleInitialBlockState(block: YBlock) {
-    logger().trace(
-      {
-        workspaceId: this.workspaceId,
-        documentId: this.documentId,
-        blockId: block.getAttribute('id'),
-      },
-      'handling initial block state'
-    )
-
-    switchBlockType(block, {
-      onPython: (b) => this.pythonBlocksObserver.handleInitialBlockState(b),
-      onSQL: (b) => this.sqlBlocksObserver.handleInitialBlockState(b),
-      onVisualization: (b) =>
-        this.visualizationBlockObserver.handleInitialBlockState(b),
-      onWriteback: (b) =>
-        this.writebackBlocksObserver.handleInitialBlockState(b),
-      onInput: (b) => this.inputBlocksObserver.handleInitialBlockState(b),
-      onDropdownInput: (b) =>
-        this.dropdownInputBlocksObserver.handleInitialBlockState(b),
-      onDateInput: (b) =>
-        this.dateInputBlocksObserver.handleInitialBlockState(b),
-      onRichText: () => {},
-      onFileUpload: (b) =>
-        this.fileUploadBlocksObserver.handleInitialBlockState(b),
-      onDashboardHeader: () => {},
-      onPivotTable: (b) => {
-        this.pivotTableBlocksObserver.handleInitialBlockState(b)
-      },
-    })
-  }
-
-  private onBlockEvent = (event: Y.YXmlEvent, tr: Y.Transaction) => {
-    tr.doc.transact(() => {
-      event.changes.keys.forEach(({ action, oldValue }, key) => {
-        const block = event.target
-
-        if (block instanceof Y.XmlElement) {
-          logger().trace(
-            {
-              workspaceId: this.workspaceId,
-              documentId: this.documentId,
-              blockId: block.getAttribute('id'),
-              action,
-              key,
-            },
-            'handling block event'
-          )
-
-          switchBlockType(block, {
-            onPython: (b) =>
-              this.pythonBlocksObserver.handleBlockEvent(
-                b,
-                action,
-                oldValue,
-                key,
-                tr
-              ),
-            onSQL: (b) =>
-              this.sqlBlocksObserver.handleBlockEvent(
-                b,
-                action,
-                oldValue,
-                key,
-                tr
-              ),
-            onVisualization: (b) =>
-              this.visualizationBlockObserver.handleBlockEvent(
-                b,
-                action,
-                oldValue,
-                key,
-                tr
-              ),
-            onWriteback: (b) =>
-              this.writebackBlocksObserver.handleBlockEvent(
-                b,
-                action,
-                oldValue,
-                key,
-                tr
-              ),
-            onInput: (b) =>
-              this.inputBlocksObserver.handleBlockEvent(
-                b,
-                action,
-                oldValue,
-                key,
-                tr
-              ),
-            onDropdownInput: (b) =>
-              this.dropdownInputBlocksObserver.handleBlockEvent(
-                b,
-                action,
-                oldValue,
-                key,
-                tr
-              ),
-            onDateInput: (b) =>
-              this.dateInputBlocksObserver.handleBlockEvent(
-                b,
-                action,
-                oldValue,
-                key,
-                tr
-              ),
-            onRichText: () => {},
-            onFileUpload: (b) =>
-              this.fileUploadBlocksObserver.handleBlockEvent(
-                b,
-                action,
-                oldValue,
-                key,
-                tr
-              ),
-            onDashboardHeader: () => {},
-            onPivotTable: (b) =>
-              this.pivotTableBlocksObserver.handleBlockEvent(
-                b,
-                action,
-                oldValue,
-                key,
-                tr
-              ),
-          })
-        }
-      })
-    }, tr.origin)
-  }
-
-  private onBlocksEvent = (event: Y.YMapEvent<YBlock>, tr: Y.Transaction) => {
-    event.changes.keys.forEach(({ action }, key) => {
-      if (action === 'add') {
-        const block = event.target.get(key)
-        if (block) {
-          this.events.blockAdd(
-            EventContext.fromYTransaction(tr),
-            getBaseAttributes(block).type
-          )
-          this.handleInitialBlockState(block)
-          block.observe(this.onBlockEvent)
-        }
-      }
-    })
-  }
-
-  public static make(
-    workspaceId: string,
-    documentId: string,
-    blocks: Y.Map<YBlock>,
-    dataframes: Y.Map<DataFrame>,
-    executionQueue: PQueue,
-    events: NotebookEvents
-  ): BlocksObserver {
-    const sqlObserver = SQLObserver.make(
-      workspaceId,
-      documentId,
-      dataframes,
-      blocks,
-      executionQueue,
-      events
-    )
-    const pythonObserver = PythonObserver.make(
-      workspaceId,
-      documentId,
-      dataframes,
-      blocks,
-      executionQueue,
-      events
-    )
-    const visualizationObserver = VisualizationObserver.make(
-      workspaceId,
-      documentId,
-      dataframes,
-      executionQueue,
-      events
-    )
-    const inputObserver = InputObserver.make(
-      workspaceId,
-      documentId,
-      blocks,
-      executionQueue
-    )
-    const dropdownInputObserver = DropdownInputObserver.make(
-      workspaceId,
-      documentId,
-      blocks,
-      executionQueue
-    )
-    const dateInputObserver = DateInputObserver.make(
-      workspaceId,
-      documentId,
-      blocks,
-      executionQueue
-    )
-    const fileUploadObserver = FileUploadObserver.make(workspaceId, documentId)
-    const writebackObserver = WritebackObserver.make(
-      workspaceId,
-      documentId,
-      executionQueue,
-      events
-    )
-
-    const pivotTableObserver = PivotTableObserver.make(
-      workspaceId,
-      documentId,
-      dataframes,
-      blocks,
-      executionQueue
-    )
-
-    return new BlocksObserver(
-      workspaceId,
-      documentId,
-      blocks,
-      sqlObserver,
-      pythonObserver,
-      visualizationObserver,
-      inputObserver,
-      dropdownInputObserver,
-      dateInputObserver,
-      fileUploadObserver,
-      writebackObserver,
-      pivotTableObserver,
-      events
-    )
-  }
-}
diff --git a/apps/api/src/yjs/v2/observers/blocks/input.ts b/apps/api/src/yjs/v2/observers/blocks/input.ts
deleted file mode 100644
index 59fe61f0..00000000
--- a/apps/api/src/yjs/v2/observers/blocks/input.ts
+++ /dev/null
@@ -1,255 +0,0 @@
-import {
-  InputBlock,
-  YBlock,
-  getInputAttributes,
-  updateInputValue,
-  updateInputVariable,
-} from '@briefer/editor'
-import PQueue from 'p-queue'
-import * as Y from 'yjs'
-import { IBlockObserver } from './index.js'
-import { logger } from '../../../../logger.js'
-import { setVariable } from '../../../../python/input.js'
-import { IInputExecutor, InputExecutor } from '../../executors/blocks/input.js'
-
-export type InputEffects = {
-  setVariable: typeof setVariable
-}
-
-export interface IInputObserver extends IBlockObserver<InputBlock> {}
-
-export class InputObserver implements IInputObserver {
-  private workspaceId: string
-  private documentId: string
-  private blocks: Y.Map<YBlock>
-  private executor: IInputExecutor
-
-  constructor(
-    workspaceId: string,
-    documentId: string,
-    blocks: Y.Map<YBlock>,
-    executor: IInputExecutor
-  ) {
-    this.workspaceId = workspaceId
-    this.documentId = documentId
-    this.blocks = blocks
-    this.executor = executor
-  }
-
-  public isIdle() {
-    return this.executor.isIdle()
-  }
-
-  public handleInitialBlockState(block: Y.XmlElement<InputBlock>) {
-    logger().trace(
-      {
-        workspaceId: this.workspaceId,
-        documentId: this.documentId,
-        blockId: block.getAttribute('id'),
-      },
-      'handling initial input block state'
-    )
-
-    const value = block.getAttribute('value')
-    if (value && value.status !== 'idle') {
-      block.setAttribute('value', {
-        ...value,
-        status: 'idle',
-      })
-    }
-
-    const variable = block.getAttribute('variable')
-    if (variable && variable.status !== 'idle') {
-      block.setAttribute('variable', {
-        ...variable,
-        status: 'idle',
-      })
-    }
-
-    this.executor.saveValue(block)
-  }
-
-  public async handleBlockEvent(
-    block: Y.XmlElement<InputBlock>,
-    action: string,
-    oldValue: any,
-    key: string
-  ) {
-    if (action !== 'update') {
-      logger().trace(
-        {
-          workspaceId: this.workspaceId,
-          documentId: this.documentId,
-          blockId: block.getAttribute('id'),
-          action,
-          key,
-        },
-        'Ignoring input block event'
-      )
-      return
-    }
-
-    logger().trace(
-      {
-        workspaceId: this.workspaceId,
-        documentId: this.documentId,
-        blockId: block.getAttribute('id'),
-        action,
-        key,
-      },
-      'Handling input block event'
-    )
-
-    if (key === 'value') {
-      await this.handleBlockValueChange(block, oldValue)
-    } else if (key === 'variable') {
-      await this.handleBlockVariableChange(block, oldValue)
-    }
-
-    logger().trace(
-      {
-        workspaceId: this.workspaceId,
-        documentId: this.documentId,
-        blockId: block.getAttribute('id'),
-      },
-      'handled input block event'
-    )
-  }
-
-  private async handleBlockValueChange(
-    block: Y.XmlElement<InputBlock>,
-    oldValue: InputBlock['value']
-  ) {
-    const attrs = getInputAttributes(block, this.blocks)
-    if (attrs.value.status === 'idle') {
-      return
-    }
-
-    if (attrs.value.status === oldValue.status) {
-      return
-    }
-
-    logger().trace(
-      {
-        workspaceId: this.workspaceId,
-        documentId: this.documentId,
-        blockId: attrs.id,
-        status: attrs.value.status,
-      },
-      'Handling input block value status'
-    )
-
-    if (attrs.value.status === 'save-requested') {
-      updateInputValue(block, { status: 'saving' })
-    } else if (attrs.value.status === 'saving') {
-      await this.saveInputValue(block)
-    }
-
-    logger().trace(
-      {
-        workspaceId: this.workspaceId,
-        documentId: this.documentId,
-        blockId: block.getAttribute('id'),
-        status: attrs.value.status,
-      },
-      'Handled input block value status'
-    )
-  }
-
-  private async saveInputVariable(block: Y.XmlElement<InputBlock>) {
-    try {
-      await this.executor.saveVariable(block)
-      updateInputVariable(block, this.blocks, {
-        status: 'idle',
-      })
-    } catch (error) {
-      logger().error(
-        {
-          workspaceId: this.workspaceId,
-          documentId: this.documentId,
-          blockId: block.getAttribute('id'),
-          error,
-        },
-        'Failed to save variable value'
-      )
-      updateInputVariable(block, this.blocks, {
-        status: 'idle',
-        error: 'unexpected-error',
-      })
-    }
-  }
-
-  private async handleBlockVariableChange(
-    block: Y.XmlElement<InputBlock>,
-    oldValue: InputBlock['variable']
-  ) {
-    const attrs = getInputAttributes(block, this.blocks)
-    if (attrs.variable.status === 'idle') {
-      return
-    }
-
-    if (attrs.variable.status === oldValue.status) {
-      return
-    }
-
-    logger().trace(
-      {
-        workspaceId: this.workspaceId,
-        documentId: this.documentId,
-        blockId: attrs.id,
-        status: attrs.variable.status,
-      },
-      'Handling input block variable status'
-    )
-
-    if (attrs.variable.status === 'save-requested') {
-      updateInputVariable(block, this.blocks, { status: 'saving' })
-    } else if (attrs.variable.status === 'saving') {
-      await this.saveInputVariable(block)
-    }
-
-    logger().trace(
-      {
-        workspaceId: this.workspaceId,
-        documentId: this.documentId,
-        blockId: block.getAttribute('id'),
-        status: attrs.variable.status,
-      },
-      'Handled input block variable status'
-    )
-  }
-
-  private async saveInputValue(block: Y.XmlElement<InputBlock>) {
-    try {
-      await this.executor.saveValue(block)
-      updateInputValue(block, { status: 'idle', error: null })
-    } catch (error) {
-      logger().error(
-        {
-          workspaceId: this.workspaceId,
-          documentId: this.documentId,
-          blockId: block.getAttribute('id'),
-          error,
-        },
-        'Failed to save input value'
-      )
-      updateInputValue(block, { status: 'idle', error: 'unexpected-error' })
-    }
-  }
-
-  public static make(
-    workspaceId: string,
-    documentId: string,
-    blocks: Y.Map<YBlock>,
-    executionQueue: PQueue
-  ) {
-    const executor = InputExecutor.make(
-      workspaceId,
-      documentId,
-      blocks,
-      executionQueue
-    )
-
-    return new InputObserver(workspaceId, documentId, blocks, executor)
-  }
-}
diff --git a/apps/api/src/yjs/v2/observers/blocks/pivot-table.ts b/apps/api/src/yjs/v2/observers/blocks/pivot-table.ts
deleted file mode 100644
index b772e9ca..00000000
--- a/apps/api/src/yjs/v2/observers/blocks/pivot-table.ts
+++ /dev/null
@@ -1,189 +0,0 @@
-import { PivotTableBlock, YBlock } from '@briefer/editor'
-import * as Y from 'yjs'
-import { IBlockObserver } from './index.js'
-import { logger } from '../../../../logger.js'
-import { DataFrame } from '@briefer/types'
-import PQueue from 'p-queue'
-import {
-  IPivotTableExecutor,
-  PivotTableExecutor,
-} from '../../executors/blocks/pivot-table.js'
-
-export interface IPivotTableObserver extends IBlockObserver<PivotTableBlock> {}
-
-export class PivotTableObserver implements IPivotTableObserver {
-  private workspaceId: string
-  private documentId: string
-  private executor: IPivotTableExecutor
-
-  constructor(
-    workspaceId: string,
-    documentId: string,
-    executor: IPivotTableExecutor
-  ) {
-    this.workspaceId = workspaceId
-    this.documentId = documentId
-    this.executor = executor
-  }
-
-  public isIdle() {
-    return this.executor.isIdle()
-  }
-
-  public handleInitialBlockState(block: Y.XmlElement<PivotTableBlock>) {
-    block.setAttribute('status', 'idle')
-  }
-
-  public async handleBlockEvent(
-    block: Y.XmlElement<PivotTableBlock>,
-    action: string,
-    _oldValue: any,
-    key: string,
-    tr: Y.Transaction
-  ) {
-    if (action !== 'update') {
-      logger().trace(
-        {
-          workspaceId: this.workspaceId,
-          documentId: this.documentId,
-          blockId: block.getAttribute('id'),
-          action,
-          key,
-        },
-        'Ignoring pivot table block event'
-      )
-      return
-    }
-
-    logger().trace(
-      {
-        workspaceId: this.workspaceId,
-        documentId: this.documentId,
-        blockId: block.getAttribute('id'),
-        action,
-        key,
-      },
-      'Handling pivot table block event'
-    )
-
-    try {
-      if (key === 'status') {
-        await this.handleBlockStatusChange(block, tr)
-      }
-    } catch (error) {
-      logger().error(
-        {
-          workspaceId: this.workspaceId,
-          documentId: this.documentId,
-          blockId: block.getAttribute('id'),
-          action,
-          key,
-          error,
-        },
-        'Error while handling pivot table block event'
-      )
-    }
-
-    logger().trace(
-      {
-        workspaceId: this.workspaceId,
-        documentId: this.documentId,
-        blockId: block.getAttribute('id'),
-        action,
-        key,
-      },
-      'PivotTable block event handled'
-    )
-  }
-
-  private async handleBlockStatusChange(
-    block: Y.XmlElement<PivotTableBlock>,
-    tr: Y.Transaction
-  ) {
-    const blockId = block.getAttribute('id')
-    const status = block.getAttribute('status')
-
-    logger().trace(
-      {
-        workspaceId: this.workspaceId,
-        documentId: this.documentId,
-        blockId,
-        status,
-      },
-      'Handling pivot table block status change'
-    )
-
-    try {
-      switch (status) {
-        case 'run-requested':
-          await this.executor.abort(block)
-          block.setAttribute('status', 'running')
-          break
-        case 'running':
-          await this.executor.run(block, tr)
-          if (this.executor.isIdle()) {
-            block.setAttribute('status', 'idle')
-          }
-          break
-        case 'aborting':
-          await this.executor.abort(block)
-          block.setAttribute('status', 'idle')
-          break
-        case 'abort-requested':
-          block.setAttribute('status', 'aborting')
-          break
-        case 'page-requested':
-          await this.executor.abort(block)
-          block.setAttribute('status', 'loading-page')
-          break
-        case 'loading-page':
-          await this.executor.loadPage(block, tr)
-          if (this.executor.isIdle()) {
-            block.setAttribute('status', 'idle')
-          }
-          break
-      }
-    } catch (err) {
-      logger().error(
-        {
-          workspaceId: this.workspaceId,
-          documentId: this.documentId,
-          blockId,
-          status,
-          err,
-        },
-        'Error while handling block status change'
-      )
-
-      block.setAttribute('status', 'idle')
-      block.setAttribute('error', 'unknown')
-    }
-
-    logger().trace(
-      {
-        workspaceId: this.workspaceId,
-        documentId: this.documentId,
-        blockId,
-        status: block.getAttribute('status'),
-      },
-      'Finished handling pivot table block status change'
-    )
-  }
-
-  public static make(
-    workspaceId: string,
-    documentId: string,
-    dataframes: Y.Map<DataFrame>,
-    blocks: Y.Map<YBlock>,
-    executionQueue: PQueue
-  ): IPivotTableObserver {
-    const executor = PivotTableExecutor.make(
-      workspaceId,
-      documentId,
-      dataframes,
-      blocks,
-      executionQueue
-    )
-    return new PivotTableObserver(workspaceId, documentId, executor)
-  }
-}
diff --git a/apps/api/src/yjs/v2/observers/blocks/python.ts b/apps/api/src/yjs/v2/observers/blocks/python.ts
deleted file mode 100644
index 17bcf104..00000000
--- a/apps/api/src/yjs/v2/observers/blocks/python.ts
+++ /dev/null
@@ -1,199 +0,0 @@
-import { PythonBlock, YBlock } from '@briefer/editor'
-import * as Y from 'yjs'
-import { IBlockObserver } from './index.js'
-import { logger } from '../../../../logger.js'
-import {
-  IPythonExecutor,
-  PythonExecutor,
-} from '../../executors/blocks/python.js'
-import { DataFrame } from '@briefer/types'
-import PQueue from 'p-queue'
-import { PythonEvents } from '../../../../events/index.js'
-
-export interface IPythonObserver extends IBlockObserver<PythonBlock> {}
-
-export class PythonObserver implements IPythonObserver {
-  private workspaceId: string
-  private documentId: string
-  private executor: IPythonExecutor
-
-  constructor(
-    workspaceId: string,
-    documentId: string,
-    executor: IPythonExecutor
-  ) {
-    this.workspaceId = workspaceId
-    this.documentId = documentId
-    this.executor = executor
-  }
-
-  public isIdle() {
-    return this.executor.isIdle()
-  }
-
-  public handleInitialBlockState(block: Y.XmlElement<PythonBlock>) {
-    const status = block.getAttribute('status')
-
-    logger().trace(
-      {
-        workspaceId: this.workspaceId,
-        documentId: this.documentId,
-        blockId: block.getAttribute('id'),
-        status,
-      },
-      'handling initial python block state'
-    )
-
-    if (status !== 'idle') {
-      block.setAttribute('status', 'idle')
-    }
-  }
-
-  public async handleBlockEvent(
-    block: Y.XmlElement<PythonBlock>,
-    action: string,
-    _oldValue: any,
-    key: string,
-    tr: Y.Transaction
-  ) {
-    if (action !== 'update') {
-      logger().trace(
-        {
-          workspaceId: this.workspaceId,
-          documentId: this.documentId,
-          blockId: block.getAttribute('id'),
-          action,
-          key,
-        },
-        'ignoring python block event'
-      )
-      return
-    }
-
-    logger().trace(
-      {
-        workspaceId: this.workspaceId,
-        documentId: this.documentId,
-        blockId: block.getAttribute('id'),
-        action,
-        key,
-      },
-      'handling python block event'
-    )
-
-    if (key === 'status') {
-      await this.handleBlockStatusChange(block, tr)
-    }
-
-    logger().trace(
-      {
-        workspaceId: this.workspaceId,
-        documentId: this.documentId,
-        blockId: block.getAttribute('id'),
-      },
-      'python block event handled'
-    )
-  }
-
-  private async handleBlockStatusChange(
-    block: Y.XmlElement<PythonBlock>,
-    tr: Y.Transaction
-  ) {
-    const blockId = block.getAttribute('id')
-    const status = block.getAttribute('status')
-
-    logger().trace(
-      {
-        workspaceId: this.workspaceId,
-        documentId: this.documentId,
-        blockId,
-        status,
-      },
-      'Handling python block status change'
-    )
-
-    try {
-      switch (status) {
-        case 'run-requested':
-          block.setAttribute('status', 'running')
-          break
-        case 'try-suggestion-requested':
-          block.setAttribute('status', 'running-suggestion')
-          break
-        case 'abort-requested':
-          block.setAttribute('status', 'aborting')
-          break
-        case 'aborting':
-          await this.executor.abort(block, tr)
-          block.setAttribute('status', 'idle')
-          break
-        case 'running':
-          await this.executor.run(block, tr, false)
-          block.setAttribute('status', 'idle')
-          break
-        case 'running-suggestion':
-          await this.executor.run(block, tr, true)
-          block.setAttribute('status', 'idle')
-          break
-        case 'edit-with-ai-requested':
-          block.setAttribute('status', 'edit-with-ai-running')
-          break
-        case 'edit-with-ai-running':
-          await this.executor.editWithAI(block, tr)
-          block.setAttribute('status', 'idle')
-          break
-        case 'fix-with-ai-requested':
-          block.setAttribute('status', 'fix-with-ai-running')
-          break
-        case 'fix-with-ai-running':
-          await this.executor.fixWithAI(block, tr)
-          block.setAttribute('status', 'idle')
-          break
-      }
-    } catch (err) {
-      logger().error(
-        {
-          workspaceId: this.workspaceId,
-          documentId: this.documentId,
-          blockId,
-          status,
-          err,
-        },
-        'Error while handling block status change'
-      )
-
-      // TODO: introduce an unexpetected error result
-      block.setAttribute('status', 'idle')
-    }
-
-    logger().trace(
-      {
-        workspaceId: this.workspaceId,
-        documentId: this.documentId,
-        blockId,
-        status,
-      },
-      'Finished handling python block status change'
-    )
-  }
-
-  public static make(
-    workspaceId: string,
-    documentId: string,
-    dataframes: Y.Map<DataFrame>,
-    blocks: Y.Map<YBlock>,
-    executionQueue: PQueue,
-    events: PythonEvents
-  ) {
-    const executor = PythonExecutor.make(
-      workspaceId,
-      documentId,
-      dataframes,
-      blocks,
-      executionQueue,
-      events
-    )
-
-    return new PythonObserver(workspaceId, documentId, executor)
-  }
-}
diff --git a/apps/api/src/yjs/v2/observers/blocks/sql.ts b/apps/api/src/yjs/v2/observers/blocks/sql.ts
deleted file mode 100644
index 50831ac0..00000000
--- a/apps/api/src/yjs/v2/observers/blocks/sql.ts
+++ /dev/null
@@ -1,288 +0,0 @@
-import { DataframeName, SQLBlock, YBlock } from '@briefer/editor'
-import * as Y from 'yjs'
-import { IBlockObserver } from './index.js'
-import { logger } from '../../../../logger.js'
-import { ISQLExecutor, SQLExecutor } from '../../executors/blocks/sql.js'
-import { DataFrame } from '@briefer/types'
-import PQueue from 'p-queue'
-import { config } from '../../../../config/index.js'
-import { SQLEvents } from '../../../../events/index.js'
-
-export interface ISQLObserver extends IBlockObserver<SQLBlock> {}
-
-export class SQLObserver implements ISQLObserver {
-  private workspaceId: string
-  private documentId: string
-  private executor: ISQLExecutor
-
-  constructor(workspaceId: string, documentId: string, executor: ISQLExecutor) {
-    this.workspaceId = workspaceId
-    this.documentId = documentId
-    this.executor = executor
-  }
-
-  public isIdle() {
-    return this.executor.isIdle()
-  }
-
-  public handleInitialBlockState(block: Y.XmlElement<SQLBlock>) {
-    const status = block.getAttribute('status')
-
-    logger().trace(
-      {
-        workspaceId: this.workspaceId,
-        documentId: this.documentId,
-        blockId: block.getAttribute('id'),
-        status,
-      },
-      'handling initial sql block state'
-    )
-
-    if (status !== 'idle') {
-      block.setAttribute('status', 'idle')
-    }
-
-    const dataframeName = block.getAttribute('dataframeName')
-    if (dataframeName && dataframeName?.status !== 'idle') {
-      block.setAttribute('dataframeName', {
-        ...dataframeName,
-        status: 'idle',
-      })
-    }
-  }
-
-  public async handleBlockEvent(
-    block: Y.XmlElement<SQLBlock>,
-    action: string,
-    oldValue: any,
-    key: string,
-    tr: Y.Transaction
-  ) {
-    if (action !== 'update') {
-      logger().trace(
-        {
-          workspaceId: this.workspaceId,
-          documentId: this.documentId,
-          blockId: block.getAttribute('id'),
-          action,
-          key,
-        },
-        'ignoring sql block event'
-      )
-      return
-    }
-
-    logger().trace(
-      {
-        workspaceId: this.workspaceId,
-        documentId: this.documentId,
-        blockId: block.getAttribute('id'),
-        action,
-        key,
-      },
-      'Handling sql block event'
-    )
-    try {
-      if (key === 'status') {
-        await this.handleBlockStatusChange(block, tr)
-      } else if (key === 'dataframeName') {
-        await this.handleSQLBlockDataframeNameChange(block, oldValue)
-      }
-    } catch (err) {
-      logger().error(
-        {
-          workspaceId: this.workspaceId,
-          documentId: this.documentId,
-          blockId: block.getAttribute('id'),
-          action,
-          key,
-          err,
-        },
-        'Error while handling sql block event'
-      )
-    }
-
-    logger().trace(
-      {
-        workspaceId: this.workspaceId,
-        documentId: this.documentId,
-        blockId: block.getAttribute('id'),
-        action,
-        key,
-      },
-      'Finished handling sql block event'
-    )
-  }
-
-  private async handleBlockStatusChange(
-    block: Y.XmlElement<SQLBlock>,
-    tr: Y.Transaction
-  ) {
-    const blockId = block.getAttribute('id')
-    const status = block.getAttribute('status')
-
-    logger().trace(
-      {
-        workspaceId: this.workspaceId,
-        documentId: this.documentId,
-        blockId,
-        status,
-      },
-      'handling block status change'
-    )
-
-    try {
-      switch (status) {
-        case 'run-requested':
-          block.setAttribute('status', 'running')
-          break
-        case 'try-suggestion-requested':
-          block.setAttribute('status', 'running-suggestion')
-          break
-        case 'abort-requested':
-          block.setAttribute('status', 'aborting')
-          break
-        case 'aborting':
-          await this.executor.abortQuery(block)
-          block.setAttribute('status', 'idle')
-          break
-        case 'running':
-          await this.executor.runQuery(block, tr, false, false)
-          block.setAttribute('status', 'idle')
-          break
-        case 'running-suggestion':
-          await this.executor.runQuery(block, tr, true, false)
-          block.setAttribute('status', 'idle')
-          break
-        case 'idle':
-          break
-        case 'edit-with-ai-requested':
-          block.setAttribute('status', 'edit-with-ai-running')
-          break
-        case 'edit-with-ai-running':
-          await this.executor.editWithAI(block, tr)
-          block.setAttribute('status', 'idle')
-          break
-        case 'fix-with-ai-requested':
-          block.setAttribute('status', 'fix-with-ai-running')
-          break
-        case 'fix-with-ai-running':
-          await this.executor.fixWithAI(block, tr)
-          block.setAttribute('status', 'idle')
-          break
-      }
-    } catch (err) {
-      logger().error(
-        {
-          workspaceId: this.workspaceId,
-          documentId: this.documentId,
-          blockId,
-          status,
-          err,
-        },
-        'Error while handling block status change'
-      )
-
-      block.setAttribute('status', 'idle')
-    }
-
-    logger().trace(
-      {
-        workspaceId: this.workspaceId,
-        documentId: this.documentId,
-        blockId,
-        status,
-      },
-      'finished handling block status change'
-    )
-  }
-
-  private async handleSQLBlockDataframeNameChange(
-    block: Y.XmlElement<SQLBlock>,
-    oldValue: DataframeName
-  ) {
-    const dataframeName = block.getAttribute('dataframeName')
-    if (!dataframeName) {
-      return
-    }
-
-    if (dataframeName.status === oldValue.status) {
-      return
-    }
-
-    logger().trace(
-      {
-        workspaceId: this.workspaceId,
-        documentId: this.documentId,
-        blockId: block.getAttribute('id'),
-        dataframeName,
-      },
-      'handling dataframe name change'
-    )
-
-    try {
-      if (dataframeName.status === 'loading') {
-        block.setAttribute('dataframeName', {
-          ...dataframeName,
-          status: 'running',
-        })
-      } else if (dataframeName.status === 'running') {
-        await this.executor.renameDataFrame(block)
-        const newDfName = block.getAttribute('dataframeName')
-        block.setAttribute('dataframeName', {
-          ...(newDfName ?? dataframeName),
-          status: 'idle',
-        })
-      }
-    } catch (err) {
-      logger().error(
-        {
-          workspaceId: this.workspaceId,
-          documentId: this.documentId,
-          blockId: block.getAttribute('id'),
-          dataframeName,
-          err,
-        },
-        'Error while handling dataframe name change'
-      )
-
-      const newDfName = block.getAttribute('dataframeName')
-      block.setAttribute('dataframeName', {
-        ...(newDfName ?? dataframeName),
-        status: 'idle',
-        error: 'unexpected',
-      })
-    }
-
-    logger().trace(
-      {
-        workspaceId: this.workspaceId,
-        documentId: this.documentId,
-        blockId: block.getAttribute('id'),
-        dataframeName,
-      },
-      'finished handling dataframe name change'
-    )
-  }
-
-  public static make(
-    workspaceId: string,
-    documentId: string,
-    dataframes: Y.Map<DataFrame>,
-    blocks: Y.Map<YBlock>,
-    executionQueue: PQueue,
-    events: SQLEvents
-  ) {
-    const executor = SQLExecutor.make(
-      workspaceId,
-      documentId,
-      config().DATASOURCES_ENCRYPTION_KEY,
-      dataframes,
-      blocks,
-      executionQueue,
-      events
-    )
-
-    return new SQLObserver(workspaceId, documentId, executor)
-  }
-}
diff --git a/apps/api/src/yjs/v2/observers/blocks/visualization.ts b/apps/api/src/yjs/v2/observers/blocks/visualization.ts
deleted file mode 100644
index 73af2cd8..00000000
--- a/apps/api/src/yjs/v2/observers/blocks/visualization.ts
+++ /dev/null
@@ -1,196 +0,0 @@
-import { VisualizationBlock } from '@briefer/editor'
-import PQueue from 'p-queue'
-import * as Y from 'yjs'
-import { IBlockObserver } from './index.js'
-import { logger } from '../../../../logger.js'
-import { DataFrame } from '@briefer/types'
-import {
-  IVisualizationExecutor,
-  VisualizationExecutor,
-} from '../../executors/blocks/visualization.js'
-import { VisEvents } from '../../../../events/index.js'
-
-export interface IVisualizationObserver
-  extends IBlockObserver<VisualizationBlock> {}
-
-export class VisualizationObserver implements IVisualizationObserver {
-  private workspaceId: string
-  private documentId: string
-  private executor: IVisualizationExecutor
-
-  constructor(
-    workspaceId: string,
-    documentId: string,
-    executor: IVisualizationExecutor
-  ) {
-    this.workspaceId = workspaceId
-    this.documentId = documentId
-    this.executor = executor
-  }
-
-  public isIdle() {
-    return this.executor.isIdle()
-  }
-
-  public handleInitialBlockState(block: Y.XmlElement<VisualizationBlock>) {
-    const status = block.getAttribute('status')
-
-    logger().trace(
-      {
-        workspaceId: this.workspaceId,
-        documentId: this.documentId,
-        blockId: block.getAttribute('id'),
-        status,
-      },
-      'handling initial visualization block state'
-    )
-
-    if (status !== 'idle') {
-      block.setAttribute('status', 'idle')
-    }
-  }
-
-  public async handleBlockEvent(
-    block: Y.XmlElement<VisualizationBlock>,
-    action: string,
-    _oldValue: any,
-    key: string,
-    tr: Y.Transaction
-  ) {
-    if (action !== 'update') {
-      logger().trace(
-        {
-          workspaceId: this.workspaceId,
-          documentId: this.documentId,
-          blockId: block.getAttribute('id'),
-          action,
-          key,
-        },
-        'Ignoring visualization block event'
-      )
-      return
-    }
-
-    logger().trace(
-      {
-        workspaceId: this.workspaceId,
-        documentId: this.documentId,
-        blockId: block.getAttribute('id'),
-        action,
-        key,
-      },
-      'Handling visualization block event'
-    )
-
-    try {
-      if (key === 'status') {
-        await this.handleBlockStatusChange(block, tr)
-      }
-    } catch (error) {
-      logger().error(
-        {
-          workspaceId: this.workspaceId,
-          documentId: this.documentId,
-          blockId: block.getAttribute('id'),
-          action,
-          key,
-          error,
-        },
-        'Error while handling visualization block event'
-      )
-    }
-
-    logger().trace(
-      {
-        workspaceId: this.workspaceId,
-        documentId: this.documentId,
-        blockId: block.getAttribute('id'),
-        action,
-        key,
-      },
-      'Visualization block event handled'
-    )
-  }
-
-  private async handleBlockStatusChange(
-    block: Y.XmlElement<VisualizationBlock>,
-    tr: Y.Transaction
-  ) {
-    const blockId = block.getAttribute('id')
-    const status = block.getAttribute('status')
-
-    logger().trace(
-      {
-        workspaceId: this.workspaceId,
-        documentId: this.documentId,
-        blockId,
-        status,
-      },
-      'Handling visualization block status change'
-    )
-
-    try {
-      switch (status) {
-        case 'run-requested':
-          await this.executor.abort(block)
-          block.setAttribute('status', 'running')
-          break
-        case 'running':
-          await this.executor.run(block, tr)
-          if (this.executor.isIdle()) {
-            block.setAttribute('status', 'idle')
-          }
-          break
-        case 'aborting':
-          await this.executor.abort(block)
-          block.setAttribute('status', 'idle')
-          break
-        case 'abort-requested':
-          block.setAttribute('status', 'aborting')
-          break
-      }
-    } catch (err) {
-      logger().error(
-        {
-          workspaceId: this.workspaceId,
-          documentId: this.documentId,
-          blockId,
-          status,
-          err,
-        },
-        'Error while handling block status change'
-      )
-
-      // TODO: introduce an unexpetected error result
-      block.setAttribute('status', 'idle')
-    }
-
-    logger().trace(
-      {
-        workspaceId: this.workspaceId,
-        documentId: this.documentId,
-        blockId,
-        status: block.getAttribute('status'),
-      },
-      'Finished handling visualization block status change'
-    )
-  }
-
-  public static make(
-    workspaceId: string,
-    documentId: string,
-    dataframes: Y.Map<DataFrame>,
-    executionQueue: PQueue,
-    events: VisEvents
-  ): IVisualizationObserver {
-    const executor = VisualizationExecutor.make(
-      workspaceId,
-      documentId,
-      dataframes,
-      executionQueue,
-      events
-    )
-
-    return new VisualizationObserver(workspaceId, documentId, executor)
-  }
-}
diff --git a/apps/api/src/yjs/v2/observers/blocks/writeback.ts b/apps/api/src/yjs/v2/observers/blocks/writeback.ts
deleted file mode 100644
index d77c7d6a..00000000
--- a/apps/api/src/yjs/v2/observers/blocks/writeback.ts
+++ /dev/null
@@ -1,173 +0,0 @@
-import { WritebackBlock } from '@briefer/editor'
-import * as Y from 'yjs'
-import { IBlockObserver } from './index.js'
-import { logger } from '../../../../logger.js'
-import {
-  IWritebackExecutor,
-  WritebackExecutor,
-} from '../../executors/blocks/writeback.js'
-import PQueue from 'p-queue'
-import { WritebackEvents } from '../../../../events/index.js'
-
-export interface IWritebackObserver extends IBlockObserver<WritebackBlock> {}
-
-export class WritebackObserver implements IWritebackObserver {
-  private workspaceId: string
-  private documentId: string
-  private executor: IWritebackExecutor
-
-  constructor(
-    workspaceId: string,
-    documentId: string,
-    executor: IWritebackExecutor
-  ) {
-    this.workspaceId = workspaceId
-    this.documentId = documentId
-    this.executor = executor
-  }
-
-  public isIdle() {
-    return this.executor.isIdle()
-  }
-
-  public handleInitialBlockState(block: Y.XmlElement<WritebackBlock>) {
-    const status = block.getAttribute('status')
-
-    logger().trace(
-      {
-        workspaceId: this.workspaceId,
-        documentId: this.documentId,
-        blockId: block.getAttribute('id'),
-        status,
-      },
-      'handling initial writeback block state'
-    )
-
-    if (status !== 'idle') {
-      block.setAttribute('status', 'idle')
-    }
-  }
-
-  public async handleBlockEvent(
-    block: Y.XmlElement<WritebackBlock>,
-    action: string,
-    _oldValue: any,
-    key: string,
-    tr: Y.Transaction
-  ) {
-    if (action !== 'update') {
-      logger().trace(
-        {
-          workspaceId: this.workspaceId,
-          documentId: this.documentId,
-          blockId: block.getAttribute('id'),
-          action,
-          key,
-        },
-        'ignoring writeback block event'
-      )
-      return
-    }
-
-    logger().trace(
-      {
-        workspaceId: this.workspaceId,
-        documentId: this.documentId,
-        blockId: block.getAttribute('id'),
-        action,
-        key,
-      },
-      'handling writeback block event'
-    )
-
-    if (key === 'status') {
-      await this.handleBlockStatusChange(block, tr)
-    }
-
-    logger().trace(
-      {
-        workspaceId: this.workspaceId,
-        documentId: this.documentId,
-        blockId: block.getAttribute('id'),
-      },
-      'writeback block event handled'
-    )
-  }
-
-  private async handleBlockStatusChange(
-    block: Y.XmlElement<WritebackBlock>,
-    tr: Y.Transaction
-  ) {
-    const blockId = block.getAttribute('id')
-    const status = block.getAttribute('status')
-
-    logger().trace(
-      {
-        workspaceId: this.workspaceId,
-        documentId: this.documentId,
-        blockId,
-        status,
-      },
-      'Handling writeback block status change'
-    )
-
-    try {
-      switch (status) {
-        case 'run-requested':
-          block.setAttribute('status', 'running')
-          break
-        case 'abort-requested':
-          block.setAttribute('status', 'aborting')
-          break
-        case 'aborting':
-          await this.executor.abort(block)
-          block.setAttribute('status', 'idle')
-          break
-        case 'running':
-          await this.executor.run(block, tr)
-          block.setAttribute('status', 'idle')
-          break
-      }
-    } catch (err) {
-      logger().error(
-        {
-          workspaceId: this.workspaceId,
-          documentId: this.documentId,
-          blockId,
-          status,
-          err,
-        },
-        'Error while handling block status change'
-      )
-
-      // TODO: introduce an unexpetected error result
-      block.setAttribute('status', 'idle')
-    }
-
-    logger().trace(
-      {
-        workspaceId: this.workspaceId,
-        documentId: this.documentId,
-        blockId,
-        status,
-      },
-      'Finished handling writeback block status change'
-    )
-  }
-
-  public static make(
-    workspaceId: string,
-    documentId: string,
-    executionQueue: PQueue,
-    events: WritebackEvents
-  ) {
-    const executor = WritebackExecutor.make(
-      workspaceId,
-      documentId,
-      executionQueue,
-      events
-    )
-
-    return new WritebackObserver(workspaceId, documentId, executor)
-  }
-}
diff --git a/apps/api/src/yjs/v2/observers/index.ts b/apps/api/src/yjs/v2/observers/index.ts
deleted file mode 100644
index e77ca639..00000000
--- a/apps/api/src/yjs/v2/observers/index.ts
+++ /dev/null
@@ -1,109 +0,0 @@
-import { logger } from '../../../logger.js'
-import { BlocksObserver, IBlocksObserver } from './blocks/index.js'
-import { IRunAllObserver, RunAllObserver } from './run-all.js'
-import PQueue from 'p-queue'
-import { WSSharedDocV2 } from '../index.js'
-import { NotebookEvents } from '../../../events/index.js'
-
-export class MainObserver {
-  private readonly workspaceId: string
-  private readonly documentId: string
-  private readonly blocksObserver: IBlocksObserver
-  private readonly runAllObserver: IRunAllObserver
-
-  constructor(
-    workspaceId: string,
-    documentId: string,
-    blocksObserver: IBlocksObserver,
-    runAllObserver: IRunAllObserver
-  ) {
-    this.workspaceId = workspaceId
-    this.documentId = documentId
-    this.blocksObserver = blocksObserver
-    this.runAllObserver = runAllObserver
-  }
-
-  public isIdle() {
-    return this.blocksObserver.isIdle() && this.runAllObserver.isIdle()
-  }
-
-  public start() {
-    logger().trace(
-      {
-        workspaceId: this.workspaceId,
-        documentId: this.documentId,
-      },
-      'starting main observer'
-    )
-
-    this.blocksObserver.start()
-    this.runAllObserver.start()
-
-    logger().trace(
-      {
-        workspaceId: this.workspaceId,
-        documentId: this.documentId,
-      },
-      'main observer started'
-    )
-  }
-
-  public stop() {
-    logger().trace(
-      {
-        workspaceId: this.workspaceId,
-        documentId: this.documentId,
-      },
-      'stopping main observer'
-    )
-
-    this.blocksObserver.stop()
-    this.runAllObserver.stop()
-
-    logger().trace(
-      {
-        workspaceId: this.workspaceId,
-        documentId: this.documentId,
-      },
-      'main observer stopped'
-    )
-  }
-
-  public static make(
-    workspaceId: string,
-    documentId: string,
-    doc: WSSharedDocV2,
-    events: NotebookEvents
-  ): MainObserver {
-    const dataframes = doc.dataframes
-    const blocks = doc.blocks
-    const executionQueue = new PQueue({ concurrency: 1 })
-
-    const blocksObserver = BlocksObserver.make(
-      workspaceId,
-      documentId,
-      blocks,
-      dataframes,
-      executionQueue,
-      events
-    )
-
-    const runAllObserver = RunAllObserver.make(
-      workspaceId,
-      documentId,
-      doc.runAll,
-      blocks,
-      doc.layout,
-      dataframes,
-      executionQueue,
-      events
-    )
-
-    return new MainObserver(
-      workspaceId,
-      documentId,
-      blocksObserver,
-      runAllObserver
-    )
-  }
-}
diff --git a/apps/api/src/yjs/v2/observers/run-all.ts b/apps/api/src/yjs/v2/observers/run-all.ts
deleted file mode 100644
index de2bfd0c..00000000
--- a/apps/api/src/yjs/v2/observers/run-all.ts
+++ /dev/null
@@ -1,208 +0,0 @@
-import * as Y from 'yjs'
-import {
-  YBlock,
-  YBlockGroup,
-  YRunAll,
-  getRunAllAttributes,
-  isRunAll,
-} from '@briefer/editor'
-import { IRunAllExecutor, RunAllExecutor } from '../executors/run-all.js'
-import { DataFrame } from '@briefer/types'
-import PQueue from 'p-queue'
-import { logger } from '../../../logger.js'
-import { NotebookEvents } from '../../../events/index.js'
-
-export interface IRunAllObserver {
-  start(): void
-  stop(): void
-  isIdle(): boolean
-}
-
-export class RunAllObserver implements IRunAllObserver {
-  private workspaceId: string
-  private documentId: string
-  private state: YRunAll
-  private executor: IRunAllExecutor
-
-  constructor(
-    workspaceId: string,
-    documentId: string,
-    state: YRunAll,
-    executor: IRunAllExecutor
-  ) {
-    this.workspaceId = workspaceId
-    this.documentId = documentId
-    this.state = state
-    this.executor = executor
-  }
-
-  public isIdle() {
-    return this.executor.isIdle()
-  }
-
-  public start() {
-    logger().trace(
-      {
-        workspaceId: this.workspaceId,
-        documentId: this.documentId,
-      },
-      'starting run all observer'
-    )
-
-    this.handleInitialState()
-    this.state.observe(this.onStateChange)
-
-    logger().trace(
-      {
-        workspaceId: this.workspaceId,
-        documentId: this.documentId,
-      },
-      'run all observer started'
-    )
-  }
-
-  public stop() {
-    logger().trace(
-      {
-        workspaceId: this.workspaceId,
-        documentId: this.documentId,
-      },
-      'stopping run all observer'
-    )
-
-    this.state.unobserve(this.onStateChange)
-
-    logger().trace(
-      {
-        workspaceId: this.workspaceId,
-        documentId: this.documentId,
-      },
-      'run all observer stopped'
-    )
-  }
-
-  private handleInitialState() {
-    const { status } = getRunAllAttributes(this.state)
-
-    logger().trace(
-      {
-        workspaceId: this.workspaceId,
-        documentId: this.documentId,
-        status,
-      },
-      'handling initial run all state'
-    )
-
-    if (status !== 'idle') {
-      this.state.setAttribute('status', 'idle')
-    }
-  }
-
-  private onStateChange = (event: Y.YXmlEvent, tr: Y.Transaction) => {
-    tr.doc.transact(() => {
-      const el = event.target
-      if (!(el instanceof Y.XmlElement) || !isRunAll(el)) {
-        return
-      }
-
-      event.changes.keys.forEach(({ action }, key) => {
-        if (action !== 'update') {
-          logger().trace(
-            {
-              workspaceId: this.workspaceId,
-              documentId: this.documentId,
-              action,
-              key,
-            },
-            'ignoring run all block event'
-          )
-          return
-        }
-
-        if (key === 'status') {
-          this.handleStatusChange(el, tr)
-        }
-      })
-    }, tr.origin)
-  }
-
-  private async handleStatusChange(el: YRunAll, tr: Y.Transaction) {
-    const { status } = getRunAllAttributes(el)
-
-    logger().trace(
-      {
-        workspaceId: this.workspaceId,
-        documentId: this.documentId,
-        status,
-      },
-      'handling run all status change'
-    )
-
-    try {
-      if (status === 'run-requested') {
-        el.doc!.transact(() => {
-          el.setAttribute('status', 'running')
-          const total = this.executor.getTotal()
-          el.setAttribute('total', total)
-          el.setAttribute('remaining', total)
-        })
-      } else if (status === 'abort-requested') {
-        el.setAttribute('status', 'aborting')
-      } else if (status === 'aborting') {
-        await this.executor.abort(tr)
-        el.setAttribute('status', 'idle')
-      } else if (status === 'running') {
-        await this.executor.run(tr)
-        el.doc!.transact(() => {
-          el.setAttribute('status', 'idle')
-          el.setAttribute('remaining', getRunAllAttributes(el).total)
-        })
-      }
-    } catch (err) {
-      logger().error(
-        {
-          workspaceId: this.workspaceId,
-          documentId: this.documentId,
-          status,
-          err,
-        },
-        'Error while handling run all status change event'
-      )
-
-      el.setAttribute('status', 'idle')
-    }
-
-    logger().trace(
-      {
-        workspaceId: this.workspaceId,
-        documentId: this.documentId,
-        status,
-      },
-      'finished handling run all status change'
-    )
-  }
-
-  public static make(
-    workspaceId: string,
-    documentId: string,
-    state: YRunAll,
-    blocks: Y.Map<YBlock>,
-    layout: Y.Array<YBlockGroup>,
-    dataframes: Y.Map<DataFrame>,
-    executionQueue: PQueue,
-    events: NotebookEvents
-  ): RunAllObserver {
-    const executor = RunAllExecutor.make(
-      workspaceId,
-      documentId,
-      blocks,
-      layout,
-      state,
-      dataframes,
-      executionQueue,
-      events
-    )
-
-    return new RunAllObserver(workspaceId, documentId, state, executor)
-  }
-}
diff --git a/apps/api/src/yjs/v2/persistors.ts b/apps/api/src/yjs/v2/persistors.ts
index a961b103..e3941b1b 100644
--- a/apps/api/src/yjs/v2/persistors.ts
+++ b/apps/api/src/yjs/v2/persistors.ts
@@ -1,5 +1,10 @@
 import * as Y from 'yjs'
-import prisma, { PrismaTransaction } from '@briefer/database'
+import prisma, {
+  PrismaTransaction,
+  UserYjsAppDocument,
+  YjsAppDocument,
+  YjsUpdate,
+} from '@briefer/database'
 import { TransactionOrigin, WSSharedDocV2 } from './index.js'
 import { logger } from '../../logger.js'
 import { decoding } from 'lib0'
@@ -18,20 +23,13 @@ import {
   switchBlockType,
   DashboardHeaderBlock,
   getBaseAttributes,
-  getInputAttributes,
-  isInputBlock,
   DropdownInputBlock,
-  getDropdownInputAttributes,
-  isDropdownInputBlock,
   DateInputBlock,
-  isDateInputBlock,
   getDateInputAttributes,
   PivotTableBlock,
-  isTextInputBlock,
 } from '@briefer/editor'
 import { equals, omit } from 'ramda'
 import { uuidv4 } from 'lib0/random.js'
-import { getYDocWithoutHistory } from './documents.js'
 import { WritebackBlock } from '@briefer/editor/types/blocks/writeback.js'
 import { acquireLock } from '../../lock.js'
 
@@ -40,20 +38,15 @@ export type LoadStateResult = {
   clock: number
   byteLength: number
 }
-type CleanHistoryResult = LoadStateResult
 export interface Persistor {
-  load: (tx?: PrismaTransaction) => Promise<LoadStateResult>
-  persist: (ydoc: WSSharedDocV2, tx?: PrismaTransaction) => Promise<number>
-  cleanHistory: (
-    ydoc: WSSharedDocV2,
-    tx?: PrismaTransaction
-  ) => Promise<CleanHistoryResult>
+  load: (id: string, tx?: PrismaTransaction) => Promise<LoadStateResult>
+  persistUpdate: (ydoc: WSSharedDocV2, update: Uint8Array) => Promise<string>
   canWrite: (
     decoder: decoding.Decoder,
     doc: WSSharedDocV2,
     transactionOrigin: TransactionOrigin
   ) => boolean
-  replaceState: (newState: Buffer) => Promise<LoadStateResult>
+  replaceState: (id: string, newState: Buffer) => Promise<LoadStateResult>
 }
 
 export class DocumentPersistor implements Persistor {
@@ -63,93 +56,111 @@ export class DocumentPersistor implements Persistor {
     Y.applyUpdate(ydoc, update)
   }
 
-  public async load(tx?: PrismaTransaction) {
+  public async load(id: string, tx?: PrismaTransaction) {
     try {
-      return acquireLock(`document:${this.documentId}`, () => this._load(tx))
-    } catch (err) {
-      logger().error(
-        { documentId: this.documentId, err },
-        'Failed to load Yjs document state'
-      )
-      throw err
-    }
-  }
+      return acquireLock(`document-persistor:${id}`, async () => {
+        const ydoc = new Y.Doc()
+        const dbDoc = await (tx ?? prisma()).yjsDocument.findUnique({
+          where: { documentId: this.documentId },
+          include: {
+            yjsUpdates: {
+              orderBy: { createdAt: 'asc' },
+            },
+          },
+        })
 
-  private async _load(tx?: PrismaTransaction) {
-    const ydoc = new Y.Doc()
-    const dbDoc = await (tx ?? prisma()).yjsDocument.findUnique({
-      where: { documentId: this.documentId },
-    })
+        if (!dbDoc) {
+          return {
+            ydoc,
+            clock: 0,
+            byteLength: Y.encodeStateAsUpdate(ydoc).length,
+          }
+        }
 
-    if (!dbDoc) {
-      return {
-        ydoc,
-        clock: 0,
-        byteLength: Y.encodeStateAsUpdate(ydoc).length,
-      }
-    }
+        const updates = dbDoc.yjsUpdates.filter(
+          (update) => update.clock === dbDoc.clock
+        )
+        logger().trace(
+          {
+            documentId: this.documentId,
+            clock: dbDoc.clock,
+            updates: updates.length,
+          },
+          'Applying updates to Yjs document'
+        )
 
-    this.applyUpdate(ydoc, dbDoc.state)
+        this.applyUpdate(ydoc, dbDoc.state)
+        for (const update of updates) {
+          this.applyUpdate(ydoc, update.update)
+        }
+
+        if (updates.length > 100) {
+          const cleanUpdates = async (tx: PrismaTransaction) => {
+            await tx.yjsUpdate.deleteMany({
+              where: {
+                id: { in: updates.map((update) => update.id) },
+              },
+            })
+
+            await tx.yjsDocument.update({
+              where: { documentId: this.documentId },
+              data: {
+                state: Buffer.from(Y.encodeStateAsUpdate(ydoc)),
+              },
+            })
+          }
 
-    return {
-      ydoc,
-      clock: dbDoc.clock,
-      byteLength: dbDoc.state.length,
-    }
-  }
+          if (tx) {
+            await cleanUpdates(tx)
+          } else {
+            await prisma().$transaction(cleanUpdates)
+          }
+        }
 
-  public async persist(ydoc: WSSharedDocV2, tx?: PrismaTransaction) {
-    try {
-      return acquireLock(`document:${this.documentId}`, () =>
-        this._persist(ydoc, tx)
-      )
+        return {
+          ydoc,
+          clock: dbDoc.clock,
+          byteLength: dbDoc.state.length,
+        }
+      })
     } catch (err) {
       logger().error(
         { documentId: this.documentId, err },
-        'Failed to persist Yjs document'
+        'Failed to load Yjs document state'
       )
       throw err
     }
   }
 
-  private async _persist(ydoc: WSSharedDocV2, tx?: PrismaTransaction) {
-    const save = async (state: Buffer, tx: PrismaTransaction) => {
-      const doc = await tx.document.findUnique({
-        where: { id: this.documentId },
-      })
-      if (!doc) {
-        logger().trace(
-          { documentId: this.documentId },
-          'Document was deleted, not persisting state'
-        )
-        return
-      }
-
-      await tx.yjsDocument.upsert({
+  public async persistUpdate(doc: WSSharedDocV2, update: Uint8Array) {
+    return acquireLock(`document-persistor:${doc.id}`, async () => {
+      let yjsDoc = await prisma().yjsDocument.findUnique({
+        select: { id: true, clock: true },
         where: { documentId: this.documentId },
-        create: {
-          documentId: this.documentId,
-          state,
-        },
-        update: {
-          state,
-        },
-        select: {
-          id: true,
-        },
       })
+      if (!yjsDoc) {
+        yjsDoc = await prisma().yjsDocument.upsert({
+          where: { documentId: this.documentId },
+          create: {
+            documentId: this.documentId,
+            state: Buffer.from(Y.encodeStateAsUpdate(doc.ydoc)),
+          },
+          update: {},
+          select: { id: true, clock: true },
+        })
+      }
 
-      await tx.document.update({
-        where: { id: this.documentId },
+      const { id } = await prisma().yjsUpdate.create({
         data: {
-          isSyncedWithYjs: true,
+          yjsDocumentId: yjsDoc.id,
+          update: Buffer.from(update),
+          clock: yjsDoc.clock,
         },
+        select: { id: true },
       })
-    }
 
-    const state = Buffer.from(Y.encodeStateAsUpdate(ydoc.ydoc))
-    await save(state, tx ?? prisma())
-    return state.length
+      return id
+    })
   }
 
   public canWrite(
@@ -166,57 +177,27 @@ export class DocumentPersistor implements Persistor {
     }
   }
 
-  public async cleanHistory(wsdoc: WSSharedDocV2, tx?: PrismaTransaction) {
-    return acquireLock(`document:${this.documentId}`, () =>
-      this._cleanHistory(wsdoc, tx)
-    )
-  }
-
-  private async _cleanHistory(wsdoc: WSSharedDocV2, tx?: PrismaTransaction) {
-    const ydoc = getYDocWithoutHistory(wsdoc)
-    const state = Y.encodeStateAsUpdate(ydoc)
-    const buffer = Buffer.from(state)
-
-    const yjsAppDoc = await (tx ?? prisma()).yjsDocument.upsert({
-      where: { documentId: this.documentId },
-      create: {
-        documentId: this.documentId,
-        state: buffer,
-      },
-      update: {
-        clock: {
-          increment: 1,
+  public async replaceState(id: string, newState: Buffer) {
+    return acquireLock(`document-persistor:${id}`, async () => {
+      const clock = await prisma().yjsDocument.update({
+        where: { documentId: this.documentId },
+        data: {
+          state: newState,
+          clock: {
+            increment: 1,
+          },
         },
-        state: buffer,
-      },
-    })
+      })
 
-    return {
-      ydoc,
-      clock: yjsAppDoc.clock,
-      byteLength: state.byteLength,
-    }
-  }
+      const ydoc = new Y.Doc()
+      this.applyUpdate(ydoc, newState)
 
-  public async replaceState(newState: Buffer) {
-    const clock = await prisma().yjsDocument.update({
-      where: { documentId: this.documentId },
-      data: {
-        state: newState,
-        clock: {
-          increment: 1,
-        },
-      },
+      return {
+        ydoc,
+        clock: clock.clock,
+        byteLength: newState.length,
+      }
     })
-
-    const ydoc = new Y.Doc()
-    this.applyUpdate(ydoc, newState)
-
-    return {
-      ydoc,
-      clock: clock.clock,
-      byteLength: newState.length,
-    }
   }
 }
 
@@ -231,90 +212,172 @@ export class AppPersistor implements Persistor {
     Y.applyUpdate(ydoc, update)
   }
 
-  public async load(tx?: PrismaTransaction | undefined) {
+  public async load(id: string, tx?: PrismaTransaction | undefined) {
     try {
-      return acquireLock(`app:${this.yjsAppDocumentId}`, () => this._load(tx))
-    } catch (err) {
-      logger().error(
-        { yjsAppDocumentId: this.yjsAppDocumentId, userId: this.userId, err },
-        'Failed to load Yjs app document state'
-      )
-      throw err
-    }
-  }
-
-  private async _load(tx?: PrismaTransaction | undefined) {
-    const bind = async (tx: PrismaTransaction) => {
-      const yjsAppDoc = await tx.yjsAppDocument.findFirstOrThrow({
-        where: {
-          id: this.yjsAppDocumentId,
-        },
-        orderBy: {
-          createdAt: 'desc',
-        },
-        include: {
-          userYjsAppDocuments: {
+      return acquireLock(`app-persistor:${id}`, async () => {
+        const bind = async (tx: PrismaTransaction) => {
+          const yjsAppDoc = await tx.yjsAppDocument.findFirstOrThrow({
             where: {
-              // use a new uuid when there is no user
-              // since uuid is always unique, nothing
-              // will be returned, which means we will
-              // be manipulating the published state
-              userId: this.userId ?? uuidv4(),
+              id: this.yjsAppDocumentId,
             },
-          },
-        },
-      })
+            orderBy: {
+              createdAt: 'desc',
+            },
+            include: {
+              yjsUpdates: {
+                orderBy: { createdAt: 'asc' },
+              },
+              userYjsAppDocuments: {
+                where: {
+                  // use a new uuid when there is no user
+                  // since uuid is always unique, nothing
+                  // will be returned, which means we will
+                  // be manipulating the published state
+                  userId: this.userId ?? uuidv4(),
+                },
+                include: {
+                  yjsUpdates: {
+                    orderBy: { createdAt: 'asc' },
+                  },
+                },
+              },
+            },
+          })
+
+          const cleanUpdates = async (
+            ydoc: Y.Doc,
+            doc:
+              | { _tag: 'user'; userYjsAppDocument: UserYjsAppDocument }
+              | { _tag: 'app'; yjsAppDocument: YjsAppDocument },
+            updates: YjsUpdate[],
+            tx: PrismaTransaction
+          ) => {
+            await tx.yjsUpdate.deleteMany({
+              where: {
+                id: { in: updates.map((update) => update.id) },
+              },
+            })
+            if (doc._tag === 'user') {
+              await tx.userYjsAppDocument.update({
+                where: {
+                  yjsAppDocumentId_userId: {
+                    yjsAppDocumentId: this.yjsAppDocumentId,
+                    userId: doc.userYjsAppDocument.userId,
+                  },
+                },
+                data: {
+                  state: Buffer.from(Y.encodeStateAsUpdate(ydoc)),
+                },
+              })
+            } else {
+              await tx.yjsAppDocument.update({
+                where: { id: this.yjsAppDocumentId },
+                data: {
+                  state: Buffer.from(Y.encodeStateAsUpdate(ydoc)),
+                },
+              })
+            }
+          }
 
-      const ydoc = new Y.Doc()
-      const userYjsAppDoc = yjsAppDoc.userYjsAppDocuments[0]
-      let byteLength = userYjsAppDoc?.state.length ?? yjsAppDoc.state.length
-      let clock = userYjsAppDoc?.clock ?? yjsAppDoc.clock
-      if (!userYjsAppDoc || !this.userId) {
-        // no user or user never opened the app before. bind to the published state
-        this.applyUpdate(ydoc, yjsAppDoc.state)
-      } else {
-        // bind to the user's state
-        this.applyUpdate(ydoc, userYjsAppDoc.state)
-      }
+          const ydoc = new Y.Doc()
+          const userYjsAppDoc = yjsAppDoc.userYjsAppDocuments[0]
+          let byteLength = userYjsAppDoc?.state.length ?? yjsAppDoc.state.length
+          let clock = userYjsAppDoc?.clock ?? yjsAppDoc.clock
+          if (!userYjsAppDoc || !this.userId) {
+            // no user or user never opened the app before. bind to the published state
+            const updates = yjsAppDoc.yjsUpdates.filter(
+              (update) => update.clock === yjsAppDoc.clock
+            )
+            this.applyUpdate(ydoc, yjsAppDoc.state)
+            for (const update of updates) {
+              this.applyUpdate(ydoc, update.update)
+            }
 
-      return {
-        ydoc,
-        clock,
-        byteLength,
-      }
-    }
+            if (updates.length > 100) {
+              if (tx) {
+                await cleanUpdates(
+                  ydoc,
+                  {
+                    _tag: 'app',
+                    yjsAppDocument: yjsAppDoc,
+                  },
+                  updates,
+                  tx
+                )
+              } else {
+                await prisma().$transaction((tx) =>
+                  cleanUpdates(
+                    ydoc,
+                    { _tag: 'app', yjsAppDocument: yjsAppDoc },
+                    updates,
+                    tx
+                  )
+                )
+              }
+            }
+          } else {
+            // bind to the user's state
+            const updates = userYjsAppDoc.yjsUpdates.filter(
+              (update) => update.clock === userYjsAppDoc.clock
+            )
+            this.applyUpdate(ydoc, userYjsAppDoc.state)
+            for (const update of updates) {
+              this.applyUpdate(ydoc, update.update)
+            }
 
-    if (tx) {
-      return await bind(tx)
-    }
+            if (updates.length > 100) {
+              if (tx) {
+                await cleanUpdates(
+                  ydoc,
+                  {
+                    _tag: 'user',
+                    userYjsAppDocument: userYjsAppDoc,
+                  },
+                  updates,
+                  tx
+                )
+              } else {
+                await prisma().$transaction((tx) =>
+                  cleanUpdates(
+                    ydoc,
+                    { _tag: 'user', userYjsAppDocument: userYjsAppDoc },
+                    updates,
+                    tx
+                  )
+                )
+              }
+            }
+          }
 
-    return await prisma().$transaction(bind, {
-      maxWait: 31000,
-      timeout: 30000,
-    })
-  }
+          return {
+            ydoc,
+            clock,
+            byteLength,
+          }
+        }
 
-  public async persist(ydoc: WSSharedDocV2, tx?: PrismaTransaction) {
-    try {
-      return acquireLock(`app:${this.yjsAppDocumentId}`, () =>
-        this._persist(ydoc, tx)
-      )
+        if (tx) {
+          return await bind(tx)
+        }
+
+        return await prisma().$transaction(bind, {
+          maxWait: 31000,
+          timeout: 30000,
+        })
+      })
     } catch (err) {
       logger().error(
         { yjsAppDocumentId: this.yjsAppDocumentId, userId: this.userId, err },
-        'Failed to persist Yjs app document'
+        'Failed to load Yjs app document state'
       )
       throw err
     }
   }
 
-  private async _persist(ydoc: WSSharedDocV2, tx?: PrismaTransaction) {
-    const upsert = async (tx: PrismaTransaction) => {
-      const state = Buffer.from(Y.encodeStateAsUpdate(ydoc.ydoc))
+  public async persistUpdate(doc: WSSharedDocV2, update: Uint8Array) {
+    return acquireLock(`app-persistor:${doc.id}`, async () => {
       if (this.userId) {
-        const userChangedState = await this.userChangedState(ydoc, tx)
-        // update this user specific state
-        await tx.userYjsAppDocument.upsert({
+        await prisma().userYjsAppDocument.upsert({
           where: {
             yjsAppDocumentId_userId: {
               yjsAppDocumentId: this.yjsAppDocumentId,
@@ -322,112 +385,69 @@ export class AppPersistor implements Persistor {
             },
           },
           create: {
-            userId: this.userId,
             yjsAppDocumentId: this.yjsAppDocumentId,
-            userChangedState,
-            state,
-          },
-          update: {
-            state,
-            userChangedState,
-          },
-        })
-      } else {
-        // no user means we are manipulating the published state
-        await tx.yjsAppDocument.update({
-          where: {
-            id: this.yjsAppDocumentId,
-          },
-          data: {
-            state,
+            userId: this.userId,
+            state: Buffer.from(Y.encodeStateAsUpdate(doc.ydoc)),
+            clock: doc.clock,
           },
+          update: {},
         })
 
-        // update all user states that have not changed
-        // to be in sync with the published state
-        await tx.userYjsAppDocument.updateMany({
-          where: {
-            yjsAppDocumentId: this.yjsAppDocumentId,
-            userChangedState: false,
-          },
+        const { id } = await prisma().yjsUpdate.create({
           data: {
-            state,
+            userYjsAppDocumentUserId: this.userId,
+            userYjsAppDocumentYjsAppDocumentId: this.yjsAppDocumentId,
+            update: Buffer.from(update),
+            clock: doc.clock,
           },
+          select: { id: true },
         })
+        return id
       }
 
-      return state.length
-    }
-
-    return await upsert(tx ?? prisma())
-  }
-
-  public async cleanHistory(wsdoc: WSSharedDocV2, tx?: PrismaTransaction) {
-    return acquireLock(`app:${this.yjsAppDocumentId}`, () =>
-      this._cleanHistory(wsdoc, tx)
-    )
-  }
-
-  private async _cleanHistory(wsdoc: WSSharedDocV2, tx?: PrismaTransaction) {
-    const ydoc = getYDocWithoutHistory(wsdoc)
-
-    if (this.userId) {
-      const yjsAppDoc = await (tx ?? prisma()).userYjsAppDocument.update({
-        where: {
-          yjsAppDocumentId_userId: {
-            yjsAppDocumentId: this.yjsAppDocumentId,
-            userId: this.userId,
-          },
-        },
+      const { id } = await prisma().yjsUpdate.create({
         data: {
-          clock: {
-            increment: 1,
-          },
+          yjsAppDocumentId: this.yjsAppDocumentId,
+          update: Buffer.from(update),
+          clock: doc.clock,
         },
+        select: { id: true },
       })
-
-      return {
-        ydoc,
-        clock: yjsAppDoc.clock,
-        byteLength: Y.encodeStateAsUpdate(ydoc).length,
-      }
-    }
-
-    const yjsAppDoc = await (tx ?? prisma()).yjsAppDocument.update({
-      where: { id: this.yjsAppDocumentId },
-      data: {
-        clock: {
-          increment: 1,
-        },
-      },
+      return id
     })
-
-    return {
-      ydoc,
-      clock: yjsAppDoc.clock,
-      byteLength: Y.encodeStateAsUpdate(ydoc).length,
-    }
-  }
-
-  public async replaceState(newState: Buffer) {
-    return acquireLock(`app:${this.yjsAppDocumentId}`, () =>
-      this._replaceState(newState)
-    )
   }
 
-  private async _replaceState(newState: Buffer) {
-    const ydoc = new Y.Doc()
+  public async replaceState(id: string, newState: Buffer) {
+    return acquireLock(`app-persistor:${id}`, async () => {
+      const ydoc = new Y.Doc()
 
-    this.applyUpdate(ydoc, newState)
+      this.applyUpdate(ydoc, newState)
 
-    if (this.userId) {
-      const clock = await prisma().userYjsAppDocument.update({
-        where: {
-          yjsAppDocumentId_userId: {
-            yjsAppDocumentId: this.yjsAppDocumentId,
-            userId: this.userId,
+      if (this.userId) {
+        const clock = await prisma().userYjsAppDocument.update({
+          where: {
+            yjsAppDocumentId_userId: {
+              yjsAppDocumentId: this.yjsAppDocumentId,
+              userId: this.userId,
+            },
           },
-        },
+          data: {
+            state: newState,
+            clock: {
+              increment: 1,
+            },
+          },
+        })
+
+        return {
+          ydoc,
+          clock: clock.clock,
+          byteLength: newState.length,
+        }
+      }
+
+      const clock = await prisma().yjsAppDocument.update({
+        where: { id: this.yjsAppDocumentId },
         data: {
           state: newState,
           clock: {
@@ -441,23 +461,7 @@ export class AppPersistor implements Persistor {
         clock: clock.clock,
         byteLength: newState.length,
       }
-    }
-
-    const clock = await prisma().yjsAppDocument.update({
-      where: { id: this.yjsAppDocumentId },
-      data: {
-        state: newState,
-        clock: {
-          increment: 1,
-        },
-      },
     })
-
-    return {
-      ydoc,
-      clock: clock.clock,
-      byteLength: newState.length,
-    }
   }
 
   public canWrite(
@@ -484,201 +488,6 @@ export class AppPersistor implements Persistor {
     return areBlocksAcceptable
   }
 
-  private async getPublishedDoc(tx: PrismaTransaction): Promise<Y.Doc> {
-    const yjsAppDoc = await tx.yjsAppDocument.findFirstOrThrow({
-      where: {
-        id: this.yjsAppDocumentId,
-      },
-      orderBy: {
-        createdAt: 'desc',
-      },
-    })
-
-    const doc = new Y.Doc()
-    this.applyUpdate(doc, yjsAppDoc.state)
-    return doc
-  }
-
-  private async userChangedState(
-    currentDoc: WSSharedDocV2,
-    tx: PrismaTransaction
-  ): Promise<boolean> {
-    // did the user interact with the app?
-    const currentBlocks = Array.from(currentDoc.blocks.values())
-    let publishedDoc: Y.Doc | null = null
-    for (const block of currentBlocks) {
-      const interacted = await switchBlockType(block, {
-        onRichText: async () => false,
-        onSQL: async () => false,
-        onPython: async () => false,
-        onVisualization: async () => false,
-        onWriteback: async () => false,
-        onFileUpload: async () => false,
-        onDashboardHeader: async () => false,
-        onPivotTable: async () => false,
-
-        // interactive blocks
-        onInput: async (block) => {
-          publishedDoc ??= await this.getPublishedDoc(tx)
-
-          const blockId = getBaseAttributes(block).id
-          const publishedBlocks = getBlocks(publishedDoc)
-          const publishedBlock = publishedBlocks.get(blockId)
-          if (!publishedBlock) {
-            throw new Error(`Block(${blockId}) not found in published state`)
-          }
-
-          if (!isTextInputBlock(publishedBlock)) {
-            return true
-          }
-
-          return this.userChangedInputBlock(
-            block,
-            getBlocks(currentDoc.ydoc),
-            publishedBlock,
-            publishedBlocks
-          )
-        },
-        onDropdownInput: async (block) => {
-          publishedDoc ??= await this.getPublishedDoc(tx)
-
-          const blockId = getBaseAttributes(block).id
-          const publishedBlocks = getBlocks(publishedDoc)
-          const publishedBlock = publishedBlocks.get(blockId)
-          if (!publishedBlock) {
-            throw new Error(`Block(${blockId}) not found in published state`)
-          }
-
-          if (!isDropdownInputBlock(publishedBlock)) {
-            return true
-          }
-
-          return this.userChangedDropdownInputBlock(
-            block,
-            getBlocks(currentDoc.ydoc),
-            publishedBlock,
-            publishedBlocks
-          )
-        },
-        onDateInput: async (block) => {
-          publishedDoc ??= await this.getPublishedDoc(tx)
-
-          const blockId = getBaseAttributes(block).id
-          const publishedBlocks = getBlocks(publishedDoc)
-          const publishedBlock = publishedBlocks.get(blockId)
-          if (!publishedBlock) {
-            throw new Error(`Block(${blockId}) not found in published state`)
-          }
-
-          if (!isDateInputBlock(publishedBlock)) {
-            return true
-          }
-
-          return this.userChangedDateInputBlock(
-            block,
-            getBlocks(currentDoc.ydoc),
-            publishedBlock,
-            publishedBlocks
-          )
-        },
-      })
-
-      if (interacted) {
-        return true
-      }
-    }
-
-    return false
-  }
-
-  private async userChangedInputBlock(
-    block: Y.XmlElement<InputBlock>,
-    blocks: Y.Map<YBlock>,
-    publishedBlock: Y.XmlElement<InputBlock>,
-    publishedBlocks: Y.Map<YBlock>
-  ): Promise<boolean> {
-    const attrs = getInputAttributes(block, blocks)
-    const publishedAttrs = getInputAttributes(publishedBlock, publishedBlocks)
-
-    return !equals(attrs.value, publishedAttrs.value)
-  }
-
-  private async userChangedDropdownInputBlock(
-    block: Y.XmlElement<DropdownInputBlock>,
-    blocks: Y.Map<YBlock>,
-    publishedBlock: Y.XmlElement<DropdownInputBlock>,
-    publishedBlocks: Y.Map<YBlock>
-  ): Promise<boolean> {
-    const attrs = getDropdownInputAttributes(block, blocks)
-    const publishedAttrs = getDropdownInputAttributes(
-      publishedBlock,
-      publishedBlocks
-    )
-
-    return !equals(attrs.value, publishedAttrs.value)
-  }
-
-  private async userChangedDateInputBlock(
-    block: Y.XmlElement<DateInputBlock>,
-    blocks: Y.Map<YBlock>,
-    publishedBlock: Y.XmlElement<DateInputBlock>,
-    publishedBlocks: Y.Map<YBlock>
-  ): Promise<boolean> {
-    const attrs = getDateInputAttributes(block, blocks)
-    const publishedAttrs = getDateInputAttributes(
-      publishedBlock,
-      publishedBlocks
-    )
-
-    if (compareText(attrs.label, publishedAttrs.label) !== 0) {
-      return true
-    }
-
-    if (compareText(attrs.newValue, publishedAttrs.newValue) !== 0) {
-      return true
-    }
-
-    if (compareText(attrs.newVariable, publishedAttrs.newVariable) !== 0) {
-      return true
-    }
-
-    // this code is written like this so that it wont compile
-    // if new attributes are added.
-    // that ensures that we will remember to update this
-    // method when new attributes are introduced
-    type ToCompare = Omit<
-      DateInputBlock,
-      // label is checked above
-      | 'label'
-
-      // newVariable is checked above
-      | 'newVariable'
-
-      // newValue is checked above
-      | 'newValue'
-    >
-    const currentToCompare: ToCompare = {
-      ...getBaseAttributes(block),
-      status: attrs.status,
-      variable: attrs.variable,
-      value: attrs.value,
-      executedAt: attrs.executedAt,
-      configOpen: attrs.configOpen,
-      dateType: attrs.dateType,
-    }
-    const publishedToCompare: ToCompare = {
-      ...getBaseAttributes(publishedBlock),
-      status: publishedAttrs.status,
-      variable: publishedAttrs.variable,
-      value: publishedAttrs.value,
-      executedAt: publishedAttrs.executedAt,
-      configOpen: publishedAttrs.configOpen,
-      dateType: publishedAttrs.dateType,
-    }
-
-    return !equals(currentToCompare, publishedToCompare)
-  }
-
   private areBlocksAcceptable(
     prevBlocks: Y.Map<YBlock>,
     nextBlocks: Y.Map<YBlock>
@@ -934,8 +743,8 @@ export class AppPersistor implements Persistor {
     }
 
     return equals(
-      omit(['status', 'source', 'editWithAIPrompt'], prevAttributes),
-      omit(['status', 'source', 'editWithAIPrompt'], nextAttributes)
+      omit(['source', 'editWithAIPrompt'], prevAttributes),
+      omit(['source', 'editWithAIPrompt'], nextAttributes)
     )
   }
 
@@ -960,8 +769,8 @@ export class AppPersistor implements Persistor {
     }
 
     return equals(
-      omit(['status', 'source', 'editWithAIPrompt'], prevAttributes),
-      omit(['status', 'source', 'editWithAIPrompt'], nextAttributes)
+      omit(['source', 'editWithAIPrompt'], prevAttributes),
+      omit(['source', 'editWithAIPrompt'], nextAttributes)
     )
   }
 
@@ -972,10 +781,7 @@ export class AppPersistor implements Persistor {
     const prevAttributes = prevBlock.getAttributes()
     const nextAttributes = nextBlock.getAttributes()
 
-    return equals(
-      omit(['status'], prevAttributes),
-      omit(['status'], nextAttributes)
-    )
+    return equals(prevAttributes, nextAttributes)
   }
 
   private isInputBlockAcceptable(
@@ -1059,12 +865,14 @@ export class AppPersistor implements Persistor {
       executedAt: prevAttributes.executedAt,
       configOpen: prevAttributes.configOpen,
       dateType: prevAttributes.dateType,
+      error: prevAttributes.error,
     }
     const nextToCompare: ToCompare = {
       ...getBaseAttributes(nextBlock),
       executedAt: nextAttributes.executedAt,
       configOpen: nextAttributes.configOpen,
       dateType: nextAttributes.dateType,
+      error: nextAttributes.error,
     }
 
     return equals(prevToCompare, nextToCompare)
@@ -1146,8 +954,8 @@ export class AppPersistor implements Persistor {
     }
 
     return equals(
-      omit(['status', 'tableName'], prevAttributes),
-      omit(['status', 'tableName'], nextAttributes)
+      omit(['tableName'], prevAttributes),
+      omit(['tableName'], nextAttributes)
     )
   }
 
@@ -1159,8 +967,8 @@ export class AppPersistor implements Persistor {
     const nextAttributes = nextBlock.getAttributes()
 
     return equals(
-      omit(['status', 'page', 'sort'], prevAttributes),
-      omit(['status', 'page', 'sort'], nextAttributes)
+      omit(['page', 'sort'], prevAttributes),
+      omit(['page', 'sort'], nextAttributes)
     )
   }
 }
diff --git a/apps/api/src/yjs/v2/update.ts b/apps/api/src/yjs/v2/update.ts
new file mode 100644
index 00000000..e69de29b
diff --git a/apps/web/src/components/Dashboard/DashboardControls.tsx b/apps/web/src/components/Dashboard/DashboardControls.tsx
index 5ff11b94..26911ea6 100644
--- a/apps/web/src/components/Dashboard/DashboardControls.tsx
+++ b/apps/web/src/components/Dashboard/DashboardControls.tsx
@@ -1,6 +1,8 @@
 import * as Y from 'yjs'
 import {
+  AITasks,
   BlockType,
+  ExecutionQueue,
   YBlock,
   YBlockGroup,
   addDashboardItemToYDashboard,
@@ -45,6 +47,9 @@ interface Props {
   yDoc: Y.Doc
   onDragStart: (draggingBlock: DraggingBlock) => void
   onAddBlock: (blockId: string) => void
+  userId: string | null
+  executionQueue: ExecutionQueue
+  aiTasks: AITasks
 }
 function DashboardControls(props: Props) {
   const { state: dataframes } = useYDocState(props.yDoc, getDataframes)
@@ -201,6 +206,9 @@ function DashboardControls(props: Props) {
             blocks={blocks.value}
             layout={layout.value}
             onDragStart={props.onDragStart}
+            userId={props.userId}
+            executionQueue={props.executionQueue}
+            aiTasks={props.aiTasks}
           />
         </ScrollBar>
       </div>
@@ -216,6 +224,9 @@ interface BlocksListProps {
   blocks: Y.Map<YBlock>
   layout: Y.Array<YBlockGroup>
   onDragStart: (draggingBlock: DraggingBlock) => void
+  userId: string | null
+  executionQueue: ExecutionQueue
+  aiTasks: AITasks
 }
 function BlocksList(props: BlocksListProps) {
   return (
@@ -232,6 +243,9 @@ function BlocksList(props: BlocksListProps) {
             blocks={props.blocks}
             layout={props.layout}
             onDragStart={props.onDragStart}
+            userId={props.userId}
+            executionQueue={props.executionQueue}
+            aiTasks={props.aiTasks}
           />
         )
       })}
@@ -247,6 +261,9 @@ interface BlockListItemProps {
   blocks: Y.Map<YBlock>
   layout: Y.Array<YBlockGroup>
   onDragStart: (draggingBlock: DraggingBlock) => void
+  userId: string | null
+  executionQueue: ExecutionQueue
+  aiTasks: AITasks
 }
 function BlockListItem(props: BlockListItemProps) {
   const { id, type } = getBaseAttributes(props.block)
@@ -304,8 +321,6 @@ function BlockListItem(props: BlockListItemProps) {
               dataSources={props.dataSources}
               isEditable={false}
               dragPreview={null}
-              onRun={() => {}}
-              onTry={() => {}}
               dashboardMode="editing"
               isPublicMode={false}
               hasMultipleTabs={false}
@@ -313,6 +328,9 @@ function BlockListItem(props: BlockListItemProps) {
               onToggleIsBlockHiddenInPublished={() => {}}
               onSchemaExplorer={() => {}}
               insertBelow={() => {}}
+              userId={props.userId}
+              executionQueue={props.executionQueue}
+              aiTasks={props.aiTasks}
             />
           </div>
         ),
@@ -323,14 +341,15 @@ function BlockListItem(props: BlockListItemProps) {
             blocks={props.blocks}
             isEditable={false}
             dragPreview={null}
-            onTry={() => {}}
-            onRun={() => {}}
             isPDF={false}
             dashboardPlace="controls"
             isPublicMode={false}
             hasMultipleTabs={false}
             isBlockHiddenInPublished={false}
             onToggleIsBlockHiddenInPublished={() => {}}
+            userId={props.userId}
+            executionQueue={props.executionQueue}
+            aiTasks={props.aiTasks}
           />
         ),
         onVisualization: (block) => (
@@ -339,10 +358,10 @@ function BlockListItem(props: BlockListItemProps) {
               document={props.document}
               dataframes={props.dataframes}
               block={block}
+              blocks={props.blocks}
               dragPreview={null}
               isEditable={false}
               onAddGroupedBlock={() => {}}
-              onRun={() => {}}
               isDashboard={true}
               renderer="svg"
               isPublicMode={false}
@@ -351,6 +370,8 @@ function BlockListItem(props: BlockListItemProps) {
               onToggleIsBlockHiddenInPublished={() => {}}
               isCursorWithin={false}
               isCursorInserting={false}
+              userId={props.userId}
+              executionQueue={props.executionQueue}
             />
           </div>
         ),
@@ -362,10 +383,12 @@ function BlockListItem(props: BlockListItemProps) {
             belongsToMultiTabGroup={false}
             isEditable={false}
             isApp={true}
-            onRun={() => {}}
             isDashboard={true}
             isCursorWithin={false}
             isCursorInserting={false}
+            userId={props.userId}
+            workspaceId={props.document.workspaceId}
+            executionQueue={props.executionQueue}
           />
         ),
         onDropdownInput: (block) => (
@@ -377,10 +400,12 @@ function BlockListItem(props: BlockListItemProps) {
             isEditable={false}
             isApp={true}
             isDashboard={true}
-            onRun={() => {}}
             dataframes={props.dataframes}
             isCursorWithin={false}
             isCursorInserting={false}
+            userId={props.userId}
+            workspaceId={props.document.workspaceId}
+            executionQueue={props.executionQueue}
           />
         ),
         onFileUpload: () => null,
@@ -388,14 +413,16 @@ function BlockListItem(props: BlockListItemProps) {
           <DateInputBlock
             block={block}
             blocks={props.blocks}
+            workspaceId={props.document.workspaceId}
             dragPreview={null}
             belongsToMultiTabGroup={false}
             isEditable={false}
             isApp={true}
             isDashboard={true}
-            onRun={() => {}}
             isCursorWithin={false}
             isCursorInserting={false}
+            userId={props.userId}
+            executionQueue={props.executionQueue}
           />
         ),
         onPivotTable: (block) => (
@@ -408,13 +435,14 @@ function BlockListItem(props: BlockListItemProps) {
               dragPreview={null}
               isEditable={false}
               onAddGroupedBlock={() => {}}
-              onRun={() => {}}
               dashboardMode="editing"
               hasMultipleTabs={false}
               isBlockHiddenInPublished={false}
               onToggleIsBlockHiddenInPublished={() => {}}
               isCursorWithin={false}
               isCursorInserting={false}
+              userId={props.userId}
+              executionQueue={props.executionQueue}
             />
           </div>
         ),
diff --git a/apps/web/src/components/Dashboard/DashboardView.tsx b/apps/web/src/components/Dashboard/DashboardView.tsx
index 3831e315..0f0dbd75 100644
--- a/apps/web/src/components/Dashboard/DashboardView.tsx
+++ b/apps/web/src/components/Dashboard/DashboardView.tsx
@@ -19,6 +19,8 @@ import {
   mergeGridLayoutIntoYDashboard,
   BlockType,
   removeDashboardBlock,
+  ExecutionQueue,
+  AITasks,
 } from '@briefer/editor'
 import GridElement from './GridElement'
 import Title from '../v2Editor/Title'
@@ -122,8 +124,10 @@ interface InnerProps {
   dataSources: APIDataSources
   document: ApiDocument
   draggingBlock: DraggingBlock | null
-
+  userId: string | null
   isEditing: boolean
+  executionQueue: ExecutionQueue
+  aiTasks: AITasks
 
   // We use this to trigger actions when a new block is added
   latestBlockId: string | null
@@ -206,6 +210,9 @@ function DashboardViewInner(props: InnerProps) {
                 item={item}
                 isEditingDashboard={props.isEditing}
                 latestBlockId={props.latestBlockId}
+                userId={props.userId}
+                executionQueue={props.executionQueue}
+                aiTasks={props.aiTasks}
               />
             </WrapperCard>
           </div>
@@ -220,6 +227,8 @@ function DashboardViewInner(props: InnerProps) {
       props.dataSources,
       props.isEditing,
       layout,
+      props.userId,
+      props.executionQueue,
     ]
   )
 
@@ -320,6 +329,9 @@ interface Props {
   noBottomPadding?: boolean
   latestBlockId: string | null
   userRole: string
+  userId: string | null
+  executionQueue: ExecutionQueue
+  aiTasks: AITasks
 }
 export default function DashboardView(props: Props) {
   return (
diff --git a/apps/web/src/components/Dashboard/GridElement.tsx b/apps/web/src/components/Dashboard/GridElement.tsx
index fff6402c..6e86ff90 100644
--- a/apps/web/src/components/Dashboard/GridElement.tsx
+++ b/apps/web/src/components/Dashboard/GridElement.tsx
@@ -1,13 +1,12 @@
 import * as Y from 'yjs'
 import {
+  AITasks,
   BlockType,
-  DateInputBlock as DateInputBlockT,
+  ExecutionQueue,
   YBlock,
   getBlocks,
   getDataframes,
   getLayout,
-  requestDateInputRun,
-  requestRun,
   switchBlockType,
 } from '@briefer/editor'
 import { useCallback, useEffect, useState } from 'react'
@@ -27,7 +26,6 @@ import clsx from 'clsx'
 import DashboardHeader from '../v2Editor/customBlocks/dashboardHeader'
 import DateInputBlock from '../v2Editor/customBlocks/dateInput'
 import PivotTableBlock from '../v2Editor/customBlocks/pivotTable'
-import { useEnvironmentStatus } from '@/hooks/useEnvironmentStatus'
 
 interface Props {
   item: GridLayout.Layout
@@ -38,6 +36,9 @@ interface Props {
   dataSources: APIDataSources
   isEditingDashboard: boolean
   latestBlockId: string | null
+  userId: string | null
+  executionQueue: ExecutionQueue
+  aiTasks: AITasks
 }
 
 const NO_TITLE_BLOCKS = [
@@ -52,9 +53,6 @@ function GridElement(props: Props) {
   const { state: blocks } = useYDocState(props.yDoc, getBlocks)
   const { state: dataframes } = useYDocState(props.yDoc, getDataframes)
   const { state: yLayout } = useYDocState(props.yDoc, getLayout)
-  const { startedAt: environmentStartedAt } = useEnvironmentStatus(
-    props.document.workspaceId
-  )
 
   // set editing when adding a new block to the dashboard
   useEffect(() => {
@@ -65,27 +63,6 @@ function GridElement(props: Props) {
 
   const [isEditingBlock, setIsEditingBlock] = useState(false)
 
-  const onRun = useCallback(
-    <B extends YBlock>(block: B, customCallback?: (block: B) => void) => {
-      requestRun(
-        block,
-        blocks.value,
-        layout.value,
-        environmentStartedAt,
-        false,
-        customCallback
-      )
-    },
-    [blocks.value, layout.value, environmentStartedAt]
-  )
-
-  const onSaveDateInput = useCallback(
-    (block: Y.XmlElement<DateInputBlockT>) => {
-      requestDateInputRun(block, blocks.value)
-    },
-    [blocks.value]
-  )
-
   const renderItem = useCallback(
     (block: YBlock, item: GridLayout.Layout) =>
       switchBlockType(block, {
@@ -109,8 +86,6 @@ function GridElement(props: Props) {
             dataSources={props.dataSources}
             isEditable={false}
             dragPreview={null}
-            onRun={() => {}}
-            onTry={() => {}}
             isPublicMode={false}
             dashboardMode={props.isEditingDashboard ? 'editing' : 'live'}
             hasMultipleTabs={false}
@@ -118,6 +93,9 @@ function GridElement(props: Props) {
             onToggleIsBlockHiddenInPublished={() => {}}
             onSchemaExplorer={() => {}}
             insertBelow={() => {}}
+            userId={props.userId}
+            executionQueue={props.executionQueue}
+            aiTasks={props.aiTasks}
           />
         ),
         onPython: (block) => (
@@ -128,14 +106,15 @@ function GridElement(props: Props) {
             blocks={blocks.value}
             isEditable={false}
             dragPreview={null}
-            onRun={() => {}}
-            onTry={() => {}}
             isPDF={false}
             dashboardPlace="view"
             isPublicMode={false}
             hasMultipleTabs={false}
             isBlockHiddenInPublished={false}
             onToggleIsBlockHiddenInPublished={() => {}}
+            userId={props.userId}
+            executionQueue={props.executionQueue}
+            aiTasks={props.aiTasks}
           />
         ),
         onVisualization: (block) => (
@@ -143,10 +122,10 @@ function GridElement(props: Props) {
             document={props.document}
             dataframes={dataframes.value}
             block={block}
+            blocks={blocks.value}
             dragPreview={null}
             isEditable={false}
             onAddGroupedBlock={() => {}}
-            onRun={() => {}}
             isDashboard={true}
             isPublicMode={false}
             hasMultipleTabs={false}
@@ -154,6 +133,8 @@ function GridElement(props: Props) {
             onToggleIsBlockHiddenInPublished={() => {}}
             isCursorWithin={false}
             isCursorInserting={false}
+            userId={props.userId}
+            executionQueue={props.executionQueue}
           />
         ),
         onPivotTable: (block) => (
@@ -165,13 +146,14 @@ function GridElement(props: Props) {
             dragPreview={null}
             isEditable={false}
             onAddGroupedBlock={() => {}}
-            onRun={onRun}
             dashboardMode={props.isEditingDashboard ? 'editing' : 'live'}
             hasMultipleTabs={false}
             isBlockHiddenInPublished={false}
             onToggleIsBlockHiddenInPublished={() => {}}
             isCursorWithin={false}
             isCursorInserting={false}
+            userId={props.userId}
+            executionQueue={props.executionQueue}
           />
         ),
         onInput: (block) => (
@@ -182,10 +164,12 @@ function GridElement(props: Props) {
             belongsToMultiTabGroup={false}
             isEditable={!props.isEditingDashboard}
             isApp={true}
-            onRun={() => {}}
             isDashboard={true}
             isCursorWithin={false}
             isCursorInserting={false}
+            userId={props.userId}
+            workspaceId={props.document.workspaceId}
+            executionQueue={props.executionQueue}
           />
         ),
         onDropdownInput: (block) => (
@@ -196,11 +180,13 @@ function GridElement(props: Props) {
             belongsToMultiTabGroup={false}
             isEditable={!props.isEditingDashboard}
             isApp={true}
-            onRun={() => {}}
             dataframes={dataframes.value}
             isDashboard={true}
             isCursorWithin={false}
             isCursorInserting={false}
+            userId={props.userId}
+            workspaceId={props.document.workspaceId}
+            executionQueue={props.executionQueue}
           />
         ),
         onDateInput: (block) => (
@@ -211,10 +197,12 @@ function GridElement(props: Props) {
             belongsToMultiTabGroup={false}
             isEditable={!props.isEditingDashboard}
             isApp={true}
-            onRun={onSaveDateInput}
             isDashboard={true}
             isCursorWithin={false}
             isCursorInserting={false}
+            userId={props.userId}
+            workspaceId={props.document.workspaceId}
+            executionQueue={props.executionQueue}
           />
         ),
         onDashboardHeader: (block) => (
@@ -235,8 +223,8 @@ function GridElement(props: Props) {
       yLayout,
       props.isEditingDashboard,
       isEditingBlock,
-      onRun,
-      onSaveDateInput,
+      props.userId,
+      props.executionQueue,
     ]
   )
 
diff --git a/apps/web/src/components/Dashboard/index.tsx b/apps/web/src/components/Dashboard/index.tsx
index 36adcd0e..5157b1c6 100644
--- a/apps/web/src/components/Dashboard/index.tsx
+++ b/apps/web/src/components/Dashboard/index.tsx
@@ -10,7 +10,7 @@ import { ApiDocument, UserWorkspaceRole } from '@briefer/database'
 import DashboardView from './DashboardView'
 import DashboardControls from './DashboardControls'
 import { useDataSources } from '@/hooks/useDatasources'
-import { BlockType } from '@briefer/editor'
+import { AITasks, BlockType, ExecutionQueue } from '@briefer/editor'
 import { useRouter } from 'next/router'
 import Link from 'next/link'
 import DashboardSkeleton from './DashboardSkeleton'
@@ -63,6 +63,15 @@ export default function Dashboard(props: Props) {
     null
   )
 
+  const executionQueue = useMemo(
+    () =>
+      ExecutionQueue.fromYjs(yDoc, {
+        skipDependencyCheck: !props.document.runUnexecutedBlocks,
+      }),
+    [yDoc]
+  )
+  const aiTasks = useMemo(() => AITasks.fromYjs(yDoc), [yDoc])
+
   const router = useRouter()
   const socket = useWebsocket()
 
@@ -233,6 +242,8 @@ export default function Dashboard(props: Props) {
                 {...props}
                 isEditing={props.isEditing}
                 yDoc={yDoc}
+                executionQueue={executionQueue}
+                aiTasks={aiTasks}
               />
             </SQLExtensionProvider>
           )}
@@ -248,7 +259,13 @@ export default function Dashboard(props: Props) {
         </div>
 
         {!props.isEditing && (
-          <RunAllV2 disabled={false} yDoc={yDoc} primary={true} />
+          <RunAllV2
+            disabled={false}
+            yDoc={yDoc}
+            primary={true}
+            userId={props.userId}
+            executionQueue={executionQueue}
+          />
         )}
         <Comments
           workspaceId={props.document.workspaceId}
@@ -278,7 +295,9 @@ export default function Dashboard(props: Props) {
               workspaceId={props.document.workspaceId}
               visible={selectedSidebar === 'files'}
               onHide={onHideSidebar}
+              userId={props.userId}
               yDoc={yDoc}
+              executionQueue={executionQueue}
             />
           </>
         )}
@@ -293,7 +312,13 @@ export type DraggingBlock = {
   width: number
   height: number
 }
-function DashboardContent(props: Props & { yDoc: Y.Doc }) {
+function DashboardContent(
+  props: Props & {
+    yDoc: Y.Doc
+    executionQueue: ExecutionQueue
+    aiTasks: AITasks
+  }
+) {
   const [{ datasources: dataSources }] = useDataSources(
     props.document.workspaceId
   )
@@ -322,6 +347,9 @@ function DashboardContent(props: Props & { yDoc: Y.Doc }) {
         latestBlockId={latestBlockId}
         isEditing={props.isEditing}
         userRole={props.role}
+        userId={props.userId}
+        executionQueue={props.executionQueue}
+        aiTasks={props.aiTasks}
       />
       {props.isEditing && (
         <DashboardControls
@@ -330,6 +358,9 @@ function DashboardContent(props: Props & { yDoc: Y.Doc }) {
           yDoc={props.yDoc}
           onDragStart={onDragStart}
           onAddBlock={onAddBlock}
+          userId={props.userId}
+          executionQueue={props.executionQueue}
+          aiTasks={props.aiTasks}
         />
       )}
     </div>
diff --git a/apps/web/src/components/Files.tsx b/apps/web/src/components/Files.tsx
index c8bf535a..eae8abc0 100644
--- a/apps/web/src/components/Files.tsx
+++ b/apps/web/src/components/Files.tsx
@@ -18,10 +18,10 @@ import clsx from 'clsx'
 import { useEnvironmentStatus } from '@/hooks/useEnvironmentStatus'
 import {
   BlockType,
+  ExecutionQueue,
   addBlockGroup,
   getBlocks,
   getLayout,
-  requestRun,
 } from '@briefer/editor'
 import {
   FolderIcon,
@@ -36,7 +36,9 @@ interface Props {
   workspaceId: string
   visible: boolean
   onHide: () => void
+  userId: string | null
   yDoc?: Y.Doc
+  executionQueue?: ExecutionQueue
 }
 export default function Files(props: Props) {
   const { startedAt: environmentStartedAt } = useEnvironmentStatus(
@@ -45,7 +47,7 @@ export default function Files(props: Props) {
 
   const onUseInPython = useCallback(
     (file: BrieferFile) => {
-      if (!props.yDoc) {
+      if (!props.yDoc || !props.executionQueue) {
         return
       }
 
@@ -86,14 +88,22 @@ file`
         return
       }
 
-      requestRun(pythonBlock, blocks, layout, environmentStartedAt, true)
+      props.executionQueue.enqueueBlock(
+        pythonBlock,
+        props.userId,
+        environmentStartedAt,
+        {
+          _tag: 'python',
+          isSuggestion: false,
+        }
+      )
     },
-    [props.yDoc, environmentStartedAt]
+    [props.yDoc, props.executionQueue, environmentStartedAt]
   )
 
   const onUseInSQL = useCallback(
     (file: BrieferFile) => {
-      if (!props.yDoc) {
+      if (!props.yDoc || !props.executionQueue) {
         return
       }
 
@@ -131,9 +141,18 @@ file`
         return
       }
 
-      requestRun(sqlBlock, blocks, layout, environmentStartedAt, true)
+      props.executionQueue.enqueueBlock(
+        sqlBlock,
+        props.userId,
+        environmentStartedAt,
+        {
+          _tag: 'sql',
+          isSuggestion: false,
+          selectedCode: null,
+        }
+      )
     },
-    [props.yDoc, environmentStartedAt]
+    [props.yDoc, props.executionQueue, environmentStartedAt]
   )
 
   const [
diff --git a/apps/web/src/components/PrivateDocumentPage.tsx b/apps/web/src/components/PrivateDocumentPage.tsx
index 64003b53..57ea48de 100644
--- a/apps/web/src/components/PrivateDocumentPage.tsx
+++ b/apps/web/src/components/PrivateDocumentPage.tsx
@@ -32,6 +32,7 @@ import ShortcutsModal from './ShortcutsModal'
 import { NEXT_PUBLIC_PUBLIC_URL } from '@/utils/env'
 import ReusableComponents from './ReusableComponents'
 import PageSettingsPanel from './PageSettingsPanel'
+import { AITasks, ExecutionQueue } from '@briefer/editor'
 
 // this is needed because this component only works with the browser
 const V2Editor = dynamic(() => import('@/components/v2Editor'), {
@@ -207,6 +208,15 @@ function PrivateDocumentPageInner(
     null
   )
 
+  const executionQueue = useMemo(
+    () =>
+      ExecutionQueue.fromYjs(yDoc, {
+        skipDependencyCheck: !props.document.runUnexecutedBlocks,
+      }),
+    [yDoc, props.document.runUnexecutedBlocks]
+  )
+  const aiTasks = useMemo(() => AITasks.fromYjs(yDoc), [yDoc])
+
   const onPublish = useCallback(async () => {
     if (props.publishing) {
       return
@@ -348,13 +358,21 @@ function PrivateDocumentPageInner(
           role={props.user.roles[props.workspaceId]}
           isFullScreen={isFullScreen}
           yDoc={yDoc}
+          executionQueue={executionQueue}
+          aiTasks={aiTasks}
           provider={provider}
           isSyncing={syncing}
           onOpenFiles={onToggleFiles}
           onSchemaExplorer={onToggleSchemaExplorerSQLBlock}
         >
           {!isViewer && (
-            <RunAllV2 disabled={false} yDoc={yDoc} primary={props.isApp} />
+            <RunAllV2
+              disabled={false}
+              yDoc={yDoc}
+              primary={props.isApp}
+              userId={props.user.id}
+              executionQueue={executionQueue}
+            />
           )}
         </V2Editor>
 
@@ -404,7 +422,9 @@ function PrivateDocumentPageInner(
               workspaceId={props.workspaceId}
               visible={selectedSidebar?._tag === 'files'}
               onHide={onHideSidebar}
+              userId={props.user.id}
               yDoc={yDoc}
+              executionQueue={executionQueue}
             />
             <ReusableComponents
               workspaceId={props.workspaceId}
diff --git a/apps/web/src/components/ReusableComponents.tsx b/apps/web/src/components/ReusableComponents.tsx
index a3b72c48..7a6d10e6 100644
--- a/apps/web/src/components/ReusableComponents.tsx
+++ b/apps/web/src/components/ReusableComponents.tsx
@@ -9,7 +9,10 @@ import clsx from 'clsx'
 import { SaveIcon } from 'lucide-react'
 import { useState } from 'react'
 import { useReusableComponents } from '@/hooks/useReusableComponents'
-import { APIReusableComponent, ReusableComponentType } from '@briefer/database'
+import type {
+  APIReusableComponent,
+  ReusableComponentType,
+} from '@briefer/database'
 import { addComponentToDocument, decodeComponentState } from '@briefer/editor'
 import ScrollBar from './ScrollBar'
 
@@ -115,9 +118,9 @@ export default function ReusableComponents(props: Props) {
 
 function showType(type: ReusableComponentType): string {
   switch (type) {
-    case ReusableComponentType.sql:
+    case 'sql':
       return 'SQL'
-    case ReusableComponentType.python:
+    case 'python':
       return 'Python'
   }
 }
diff --git a/apps/web/src/components/RunAllV2.tsx b/apps/web/src/components/RunAllV2.tsx
index 04b5be8a..69189c1a 100644
--- a/apps/web/src/components/RunAllV2.tsx
+++ b/apps/web/src/components/RunAllV2.tsx
@@ -1,93 +1,49 @@
 import * as Y from 'yjs'
 import React from 'react'
 import { PlayIcon, StopIcon } from '@heroicons/react/20/solid'
-import { useYDocState } from '@/hooks/useYDoc'
-import {
-  YRunAll,
-  getBlocks,
-  getLayout,
-  getResultStatus,
-  getRunAll,
-  getRunAllAttributes,
-  isRunAllLoading,
-} from '@briefer/editor'
+import { ExecutionQueue, isExecutionStatusLoading } from '@briefer/editor'
 import { useCallback } from 'react'
 import clsx from 'clsx'
-import usePreviousEffect from '@/hooks/usePreviousEffect'
+import useRunAll from '@/hooks/useRunAll'
 import useEditorAwareness from '@/hooks/useEditorAwareness'
+import usePreviousEffect from '@/hooks/usePreviousEffect'
 
 interface Props {
   disabled: boolean
   yDoc: Y.Doc
+  executionQueue: ExecutionQueue
   primary: boolean
+  userId: string
 }
 export default function RunAllV2(props: Props) {
-  const { state } = useYDocState<YRunAll>(props.yDoc, getRunAll)
-  const { state: layout } = useYDocState(props.yDoc, getLayout)
-  const { state: blocks } = useYDocState(props.yDoc, getBlocks)
-
-  const run = useCallback(() => {
-    state.value.setAttribute('status', 'run-requested')
-  }, [state])
-
-  const abort = useCallback(() => {
-    state.value.setAttribute('status', 'abort-requested')
-  }, [state])
+  const [{ total, remaining, status, failedBlockId }, { run, abort }] =
+    useRunAll(props.yDoc, props.executionQueue, props.userId)
 
   const onClick = useCallback(() => {
-    if (isRunAllLoading(state.value)) {
+    if (status !== 'idle') {
       abort()
     } else {
       run()
     }
-  }, [state, run, abort])
+  }, [status, run, abort])
 
-  const {
-    total,
-    remaining,
-    status: docRunStatus,
-  } = getRunAllAttributes(state.value)
   const current = total - remaining
 
-  const loading = isRunAllLoading(state.value)
-  const [, editorAPI] = useEditorAwareness()
+  const loading = isExecutionStatusLoading(status)
 
+  const [, editorAPI] = useEditorAwareness()
   usePreviousEffect(
     (prevStatus) => {
-      if (docRunStatus !== 'idle') {
+      if (status !== 'idle') {
         return
       }
 
-      if (prevStatus === 'running') {
-        let failedBlockId: string | null = null
-        for (const blockGroup of layout.value.toArray()) {
-          for (const tab of blockGroup.getAttribute('tabs')?.toArray() ?? []) {
-            const blockId = tab.getAttribute('id')
-            if (!blockId) {
-              continue
-            }
-
-            const block = blocks.value.get(blockId)
-            if (!block) {
-              continue
-            }
-
-            const status = getResultStatus(block, blocks.value)
-            if (status === 'error') {
-              blockGroup.setAttribute('current', tab.clone())
-              failedBlockId = blockId
-              break
-            }
-          }
-        }
-
-        if (failedBlockId) {
-          editorAPI.focus(failedBlockId, { scrollIntoView: true })
-        }
+      if (prevStatus === 'running' && failedBlockId) {
+        editorAPI.focus(failedBlockId, { scrollIntoView: true })
       }
     },
-    docRunStatus,
-    [docRunStatus, layout, blocks, editorAPI]
+    status,
+    [status, failedBlockId, editorAPI]
   )
 
   return (
@@ -96,32 +52,23 @@ export default function RunAllV2(props: Props) {
       className={clsx(
         {
           'bg-gray-200 cursor-not-allowed':
-            props.disabled ||
-            docRunStatus === 'abort-requested' ||
-            docRunStatus === 'aborting' ||
-            docRunStatus === 'schedule-running',
+            props.disabled || status === 'aborting',
           'bg-primary-200 hover:bg-primary-300':
             !props.disabled && !loading && props.primary,
           'bg-white hover:bg-gray-100 ring-1 ring-gray-200 text-gray-500':
             !props.disabled && !loading && !props.primary,
           'bg-red-200 hover:bg-red-300':
-            !props.disabled &&
-            loading &&
-            (docRunStatus === 'run-requested' || docRunStatus === 'running'),
+            !props.disabled && loading && status !== 'aborting',
         },
         'flex items-center rounded-sm px-3 py-1 text-sm gap-x-1.5 fixed mt-3 right-8 z-10'
       )}
       onClick={onClick}
-      disabled={props.disabled || docRunStatus === 'schedule-running'}
+      disabled={props.disabled}
     >
       {loading ? (
         <>
           <StopIcon className="h-4 w-4" />
-          {docRunStatus === 'run-requested' || docRunStatus === 'running'
-            ? `Running (${current}/${total})`
-            : docRunStatus === 'schedule-running'
-            ? `Running schedule (${current}/${total})`
-            : 'Stopping'}
+          {status === 'aborting' ? 'Stopping' : `Running (${current}/${total})`}
         </>
       ) : (
         <>
diff --git a/apps/web/src/components/UsersList.tsx b/apps/web/src/components/UsersList.tsx
index ca50fbc3..571d6156 100644
--- a/apps/web/src/components/UsersList.tsx
+++ b/apps/web/src/components/UsersList.tsx
@@ -1,4 +1,4 @@
-import { UserWorkspaceRole, WorkspaceUser } from '@briefer/database'
+import type { UserWorkspaceRole, WorkspaceUser } from '@briefer/database'
 import { Menu, Transition } from '@headlessui/react'
 import { EllipsisVerticalIcon } from '@heroicons/react/24/solid'
 import clsx from 'clsx'
@@ -105,15 +105,15 @@ interface UserItemProps {
 }
 function UserItem(props: UserItemProps) {
   const onMakeAdmin = useCallback(() => {
-    props.onChangeRole(props.user.id, UserWorkspaceRole.admin)
+    props.onChangeRole(props.user.id, 'admin')
   }, [props.onChangeRole, props.user.id])
 
   const onMakeEditor = useCallback(() => {
-    props.onChangeRole(props.user.id, UserWorkspaceRole.editor)
+    props.onChangeRole(props.user.id, 'editor')
   }, [props.onChangeRole, props.user.id])
 
   const onMakeViewer = useCallback(() => {
-    props.onChangeRole(props.user.id, UserWorkspaceRole.viewer)
+    props.onChangeRole(props.user.id, 'viewer')
   }, [props.onChangeRole, props.user.id])
 
   const onRemoveUser = useCallback(() => {
@@ -144,17 +144,13 @@ function UserItem(props: UserItemProps) {
   }, [props.user.role])
 
   const promotions = useMemo(() => {
-    return [
-      UserWorkspaceRole.admin,
-      UserWorkspaceRole.editor,
-      UserWorkspaceRole.viewer,
-    ]
+    return ['admin', 'editor', 'viewer']
       .filter((r) => r !== props.user.role)
       .map((role) => {
         const onClick =
-          role === UserWorkspaceRole.admin
+          role === 'admin'
             ? onMakeAdmin
-            : role === UserWorkspaceRole.editor
+            : role === 'editor'
               ? onMakeEditor
               : onMakeViewer
 
@@ -204,13 +200,11 @@ function UserItem(props: UserItemProps) {
           <Menu.Button
             as="span"
             className={clsx(
-              props.isCurrentUser || props.role !== UserWorkspaceRole.admin
+              props.isCurrentUser || props.role !== 'admin'
                 ? 'hover:cursor-not-allowed'
                 : 'hover:cursor-pointer hover:text-gray-900'
             )}
-            disabled={
-              props.isCurrentUser || props.role !== UserWorkspaceRole.admin
-            }
+            disabled={props.isCurrentUser || props.role !== 'admin'}
           >
             <EllipsisVerticalIcon
               className="h-5 w-5 text-gray-400 "
@@ -218,7 +212,7 @@ function UserItem(props: UserItemProps) {
             />
             <span className="sr-only">Options for {props.user.name}</span>
 
-            {!props.isCurrentUser && props.role === UserWorkspaceRole.admin && (
+            {!props.isCurrentUser && props.role === 'admin' && (
               <Transition
                 as={Fragment}
                 enter="transition ease-out duration-100"
diff --git a/apps/web/src/components/forms/user.tsx b/apps/web/src/components/forms/user.tsx
index 8a665f9f..20a51b1f 100644
--- a/apps/web/src/components/forms/user.tsx
+++ b/apps/web/src/components/forms/user.tsx
@@ -1,4 +1,3 @@
-import { UserWorkspaceRole } from '@briefer/database'
 import { useCallback } from 'react'
 import { useForm } from 'react-hook-form'
 import { z } from 'zod'
@@ -9,7 +8,7 @@ import Spin from '../Spin'
 export const UserFormValues = z.object({
   name: z.string(),
   email: z.string(),
-  role: z.nativeEnum(UserWorkspaceRole),
+  role: z.enum(['viewer', 'editor', 'admin']),
 })
 
 export type UserFormValues = z.infer<typeof UserFormValues>
@@ -118,9 +117,9 @@ export default function UserForm({ workspaceId, onSubmit }: UserFormProps) {
                   required
                   className="block w-full rounded-md border-0 py-3 text-gray-900 shadow-sm ring-1 ring-inset ring-gray-300 placeholder:text-gray-400 focus:ring-2 focus:ring-inset focus:ring-ceramic-200/70 sm:text-md sm:leading-6"
                 >
-                  <option value={UserWorkspaceRole.viewer}>Viewer</option>
-                  <option value={UserWorkspaceRole.editor}>Editor</option>
-                  <option value={UserWorkspaceRole.admin}>Admin</option>
+                  <option value={'viewer'}>Viewer</option>
+                  <option value={'editor'}>Editor</option>
+                  <option value={'admin'}>Admin</option>
                 </select>
                 <FormError msg={formState.errors.role?.message} />
               </div>
diff --git a/apps/web/src/components/v2Editor/EditWithAIForm.tsx b/apps/web/src/components/v2Editor/EditWithAIForm.tsx
index ad47612d..8278cebb 100644
--- a/apps/web/src/components/v2Editor/EditWithAIForm.tsx
+++ b/apps/web/src/components/v2Editor/EditWithAIForm.tsx
@@ -107,6 +107,7 @@ function EditWithAIForm(props: Props) {
         )}
       >
         <button
+          type="button"
           className="text-gray-400 hover:text-gray-500"
           onClick={props.onClose}
         >
diff --git a/apps/web/src/components/v2Editor/ExecIndicator.tsx b/apps/web/src/components/v2Editor/ExecIndicator.tsx
index 2b882c22..d6d7bdc4 100644
--- a/apps/web/src/components/v2Editor/ExecIndicator.tsx
+++ b/apps/web/src/components/v2Editor/ExecIndicator.tsx
@@ -1,27 +1,48 @@
-import { ExecStatus } from '@briefer/editor'
+import * as Y from 'yjs'
 import {
   CheckIcon,
   ClockIcon,
   ExclamationTriangleIcon,
 } from '@heroicons/react/24/outline'
 import Spin from '../Spin'
+import {
+  ExecutionQueue,
+  getResultStatus,
+  TabRef,
+  YBlock,
+} from '@briefer/editor'
+import { useBlockExecutions } from '@/hooks/useBlockExecution'
+import { head } from 'ramda'
 
-type ExecIndicatorProps = {
-  execStatus: ExecStatus
+interface Props {
+  tabRef: TabRef
+  blocks: Y.Map<YBlock>
+  executionQueue: ExecutionQueue
 }
+function ExecIndicator(props: Props) {
+  const block = props.blocks.get(props.tabRef.blockId)
+  const result = block ? getResultStatus(block, props.blocks) : 'idle'
+  const executions = useBlockExecutions(props.executionQueue, block)
+  const execution = head(executions)
+  const status = execution?.item.getStatus()._tag ?? 'idle'
 
-function ExecIndicator(props: ExecIndicatorProps) {
-  switch (props.execStatus) {
-    case 'error':
-      return <ExclamationTriangleIcon className="h-3 w-3 text-red-500" />
-    case 'success':
-      return <CheckIcon className="h-3 w-3 text-green-500" />
-    case 'loading':
-      return <Spin />
+  switch (status) {
     case 'enqueued':
       return <ClockIcon className="h-3 w-3" />
+    case 'aborting':
+    case 'running':
+      return <Spin />
     case 'idle':
-      return null
+    case 'completed':
+    case 'unknown':
+      switch (result) {
+        case 'idle':
+          return null
+        case 'error':
+          return <ExclamationTriangleIcon className="h-3 w-3 text-red-500" />
+        case 'success':
+          return <CheckIcon className="h-3 w-3 text-green-500" />
+      }
   }
 }
 
diff --git a/apps/web/src/components/v2Editor/ExecTooltip.tsx b/apps/web/src/components/v2Editor/ExecTooltip.tsx
index dff99caa..5b4b47b4 100644
--- a/apps/web/src/components/v2Editor/ExecTooltip.tsx
+++ b/apps/web/src/components/v2Editor/ExecTooltip.tsx
@@ -1,11 +1,10 @@
 import type { EnvironmentStatus } from '@briefer/database'
-import { ExecStatus } from '@briefer/editor'
 
 interface Props {
   envStatus: EnvironmentStatus
   envLoading: boolean
-  execStatus: ExecStatus
-  status: string | undefined
+  execStatus: 'enqueued' | 'running'
+  runningAll: boolean
 }
 export function SQLExecTooltip(props: Props) {
   return (
@@ -66,33 +65,34 @@ function Tooltip(props: { title: string; message: string }) {
 interface ExecTooltipProps extends Props {
   envStartingMessage: string
 }
-function ExecTooltip(props: ExecTooltipProps) {
-  if (props.execStatus === 'enqueued') {
-    return (
-      <Tooltip
-        title="This block is enqueued."
-        message="It will run once the previous blocks finish executing."
-      />
-    )
-  }
+function ExecTooltip(props: ExecTooltipProps): JSX.Element | null {
+  switch (props.execStatus) {
+    case 'enqueued':
+      return (
+        <Tooltip
+          title="This block is enqueued."
+          message="It will run once the previous blocks finish executing."
+        />
+      )
+    case 'running':
+      if (props.envStatus !== 'Running' && !props.envLoading) {
+        return (
+          <Tooltip
+            title="Your environment is starting"
+            message={props.envStartingMessage}
+          />
+        )
+      }
 
-  if (props.envStatus !== 'Running' && !props.envLoading) {
-    return (
-      <Tooltip
-        title="Your environment is starting"
-        message={props.envStartingMessage}
-      />
-    )
-  }
+      if (props.runningAll) {
+        return (
+          <Tooltip
+            title="This block is running."
+            message="When running entire documents, you cannot stop individual blocks."
+          />
+        )
+      }
 
-  if (props.status === 'run-all-running') {
-    return (
-      <Tooltip
-        title="This block is running."
-        message="When running entire documents, you cannot stop individual blocks."
-      />
-    )
+      return null
   }
-
-  return null
 }
diff --git a/apps/web/src/components/v2Editor/customBlocks/dateInput/index.tsx b/apps/web/src/components/v2Editor/customBlocks/dateInput/index.tsx
index dcdd2706..65346c31 100644
--- a/apps/web/src/components/v2Editor/customBlocks/dateInput/index.tsx
+++ b/apps/web/src/components/v2Editor/customBlocks/dateInput/index.tsx
@@ -1,21 +1,28 @@
 import * as Y from 'yjs'
 import { ConnectDragPreview } from 'react-dnd'
 import {
+  ExecutionQueue,
   YBlock,
+  YBlockGroup,
   getDateInputAttributes,
   getDateInputBlockExecStatus,
+  isExecutionStatusLoading,
+  requestDateInputRun,
   updateDateInputBlockDateType,
   updateDateInputBlockTimeZone,
   updateYText,
   type DateInputBlock,
 } from '@briefer/editor'
 import clsx from 'clsx'
-import { useCallback, useEffect, useRef } from 'react'
+import { useCallback } from 'react'
 import { Cog6ToothIcon, ExclamationCircleIcon } from '@heroicons/react/24/solid'
 import { ClockIcon } from '@heroicons/react/20/solid'
 import Spin from '@/components/Spin'
 import DateInputBlockInput from './DateInputBlockInput'
 import DateSettings from './DateSettings'
+import { useBlockExecutions } from '@/hooks/useBlockExecution'
+import { head } from 'ramda'
+import { useEnvironmentStatus } from '@/hooks/useEnvironmentStatus'
 
 function invalidVariableErrorMessage(
   status: 'invalid-variable' | 'invalid-variable-and-value' | 'unexpected-error'
@@ -49,11 +56,12 @@ interface Props {
   isEditable: boolean
   isApp: boolean
   isDashboard: boolean
-  onRun: (block: Y.XmlElement<DateInputBlock>) => void
   isCursorWithin: boolean
   isCursorInserting: boolean
+  workspaceId: string
+  userId: string | null
+  executionQueue: ExecutionQueue
 }
-
 function DateInput(props: Props) {
   const blockId = props.block.getAttribute('id')
   const attrs = getDateInputAttributes(props.block, props.blocks)
@@ -71,6 +79,29 @@ function DateInput(props: Props) {
     )
   }, [props.block])
 
+  const executions = useBlockExecutions(
+    props.executionQueue,
+    props.block,
+    'date-input'
+  )
+  const status = head(executions)?.item.getStatus() ?? {
+    _tag: 'idle',
+  }
+
+  const { startedAt: environmentStartedAt } = useEnvironmentStatus(
+    props.workspaceId
+  )
+
+  const onRun = useCallback(() => {
+    requestDateInputRun(
+      props.executionQueue,
+      props.block,
+      props.blocks,
+      props.userId,
+      environmentStartedAt
+    )
+  }, [props.block, props.blocks, props.executionQueue, props.userId])
+
   const onChangeVariable: React.ChangeEventHandler<HTMLInputElement> =
     useCallback(
       (e) => {
@@ -79,31 +110,25 @@ function DateInput(props: Props) {
       [attrs.newVariable, props.block]
     )
 
-  const onBlurVariable: React.FocusEventHandler<HTMLInputElement> =
-    useCallback(() => {
-      props.onRun(props.block)
-    }, [props.block, props.onRun])
-
-  const onSave = useCallback(() => {
-    props.onRun(props.block)
-  }, [props.block, props.onRun])
-
-  const execStatus = getDateInputBlockExecStatus(props.block)
+  const execStatus = getDateInputBlockExecStatus(
+    props.block,
+    props.executionQueue
+  )
 
   const onChangeDateType = useCallback(
     (type: 'date' | 'datetime') => {
       updateDateInputBlockDateType(props.block, props.blocks, type)
-      props.onRun(props.block)
+      onRun()
     },
-    [props.block, props.onRun]
+    [props.block, onRun]
   )
 
   const onChangeTimeZone = useCallback(
     (timezone: string) => {
       updateDateInputBlockTimeZone(props.block, props.blocks, timezone)
-      props.onRun(props.block)
+      onRun()
     },
-    [props.block, props.blocks, props.onRun]
+    [props.block, props.blocks, onRun]
   )
 
   return (
@@ -150,35 +175,34 @@ function DateInput(props: Props) {
                       'ring-0 px-1 py-0.5 rounded-md text-ceramic-500 bg-ceramic-100 text-xs font-medium min-w-12 min-h-4 focus:ring-0 border-0 block text-right',
                       {
                         'text-red-500 bg-red-100':
-                          attrs.status === 'invalid-variable' ||
-                          attrs.status === 'invalid-variable-and-value' ||
-                          attrs.status === 'unexpected-error',
+                          attrs.error === 'invalid-variable' ||
+                          attrs.error === 'invalid-variable-and-value' ||
+                          attrs.error === 'unexpected-error',
                         'text-ceramic-500 bg-ceramic-100':
                           execStatus === 'idle',
-                        'text-gray-300 bg-gray-100': execStatus === 'loading',
+                        'text-gray-300 bg-gray-100': isExecutionStatusLoading(
+                          status._tag
+                        ),
                       }
                     )}
                     type="text"
                     value={attrs.newVariable.toString()}
                     onChange={onChangeVariable}
-                    onBlur={onBlurVariable}
-                    disabled={
-                      execStatus === 'loading' || execStatus === 'enqueued'
-                    }
+                    onBlur={onRun}
+                    disabled={isExecutionStatusLoading(status._tag)}
                   />
                   <div className="absolute inset-y-0 pl-1 flex items-center group z-10">
-                    {execStatus !== 'idle' &&
-                      (execStatus === 'loading' ? (
+                    {isExecutionStatusLoading(status._tag) &&
+                      (status._tag === 'running' ||
+                      status._tag === 'aborting' ? (
                         <Spin />
-                      ) : execStatus === 'enqueued' ? (
+                      ) : status._tag === 'enqueued' ? (
                         <ClockIcon className="w-4 h-4 text-gray-300" />
-                      ) : attrs.status === 'invalid-variable' ||
-                        attrs.status === 'invalid-variable-and-value' ||
-                        attrs.status === 'unexpected-error' ? (
+                      ) : attrs.error && attrs.error !== 'invalid-value' ? (
                         <>
                           <button
-                            disabled={attrs.status !== 'invalid-variable'}
-                            onClick={onSave}
+                            disabled={attrs.error !== 'invalid-variable'}
+                            onClick={onRun}
                           >
                             <ExclamationCircleIcon
                               className="h-3 w-3 text-red-300"
@@ -187,7 +211,7 @@ function DateInput(props: Props) {
                           </button>
                           <div className="font-sans pointer-events-none absolute -top-2 left-1/2 -translate-y-full -translate-x-1/2 opacity-0 transition-opacity group-hover:opacity-100 bg-hunter-950 text-white text-xs p-2 rounded-md flex flex-col gap-y-1 w-72">
                             <span className="inline-flex gap-x-1 items-center text-gray-400 text-center">
-                              {invalidVariableErrorMessage(attrs.status)}
+                              {invalidVariableErrorMessage(attrs.error)}
                             </span>
                           </div>
                         </>
@@ -204,16 +228,10 @@ function DateInput(props: Props) {
             value={attrs.value}
             dateType={attrs.dateType}
             newValue={attrs.newValue}
-            onSave={onSave}
-            error={
-              attrs.status === 'invalid-value' ||
-              attrs.status === 'invalid-variable-and-value' ||
-              attrs.status === 'unexpected-error'
-                ? attrs.status
-                : null
-            }
-            isSaving={execStatus === 'loading'}
-            isEnqueued={execStatus === 'enqueued'}
+            onSave={onRun}
+            error={attrs.error !== 'invalid-variable' ? attrs.error : null}
+            isSaving={status._tag === 'running' || status._tag === 'aborting'}
+            isEnqueued={status._tag === 'enqueued'}
             isEditable={props.isEditable || props.isApp}
             belongsToMultiTabGroup={props.belongsToMultiTabGroup}
             isCursorWithin={props.isCursorWithin}
@@ -226,7 +244,7 @@ function DateInput(props: Props) {
               onChangeDateType={onChangeDateType}
               timezone={attrs.value.timezone}
               onChangeTimeZone={onChangeTimeZone}
-              disabled={execStatus === 'loading' || execStatus === 'enqueued'}
+              disabled={isExecutionStatusLoading(status._tag)}
             />
           )}
         </div>
diff --git a/apps/web/src/components/v2Editor/customBlocks/dropdownInput/dropdownSettings.tsx b/apps/web/src/components/v2Editor/customBlocks/dropdownInput/dropdownSettings.tsx
index 67ec272c..30ca5a31 100644
--- a/apps/web/src/components/v2Editor/customBlocks/dropdownInput/dropdownSettings.tsx
+++ b/apps/web/src/components/v2Editor/customBlocks/dropdownInput/dropdownSettings.tsx
@@ -23,7 +23,6 @@ type Props = {
   onRun: (block: Y.XmlElement<DropdownInputBlock>) => void
   dataframes: Y.Map<DataFrame>
 }
-
 export default function DropdownSettings(props: Props) {
   const attrs = getDropdownInputAttributes(props.block, props.blocks)
 
diff --git a/apps/web/src/components/v2Editor/customBlocks/dropdownInput/index.tsx b/apps/web/src/components/v2Editor/customBlocks/dropdownInput/index.tsx
index 86ab5c3a..303b0ab9 100644
--- a/apps/web/src/components/v2Editor/customBlocks/dropdownInput/index.tsx
+++ b/apps/web/src/components/v2Editor/customBlocks/dropdownInput/index.tsx
@@ -6,12 +6,11 @@ import {
   getDropdownInputAttributes,
   updateDropdownInputLabel,
   updateDropdownInputValue,
-  getDropdownInputVariableExecStatus,
-  getDropdownInputValueExecStatus,
   dropdownInputToggleConfigOpen,
-  dropdownInputRequestSaveValue,
   updateDropdownInputVariable,
-  dropdownInputRequestSaveVariable,
+  ExecutionQueue,
+  isExecutionStatusLoading,
+  YBlockGroup,
 } from '@briefer/editor'
 import clsx from 'clsx'
 import { useCallback, useEffect, useRef, useState } from 'react'
@@ -24,6 +23,9 @@ import useEditorAwareness from '@/hooks/useEditorAwareness'
 import { Combobox } from '@headlessui/react'
 import { CheckIcon, ChevronDownIcon } from '@heroicons/react/20/solid'
 import ReactDOM from 'react-dom'
+import { useBlockExecutions } from '@/hooks/useBlockExecution'
+import { head } from 'ramda'
+import { useEnvironmentStatus } from '@/hooks/useEnvironmentStatus'
 
 function errorMessage(
   error: DropdownInputBlock['variable']['error'],
@@ -71,9 +73,11 @@ interface Props {
   isApp: boolean
   isDashboard: boolean
   dataframes: Y.Map<DataFrame>
-  onRun: (block: Y.XmlElement<DropdownInputBlock>) => void
   isCursorWithin: boolean
   isCursorInserting: boolean
+  userId: string | null
+  workspaceId: string
+  executionQueue: ExecutionQueue
 }
 function DropdownInputBlock(props: Props) {
   const attrs = getDropdownInputAttributes(props.block, props.blocks)
@@ -95,26 +99,28 @@ function DropdownInputBlock(props: Props) {
     [props.block]
   )
 
+  const { startedAt: environmentStartedAt } = useEnvironmentStatus(
+    props.workspaceId
+  )
+
   const onRetryValue = useCallback(() => {
-    dropdownInputRequestSaveValue(props.block)
-  }, [props.block])
+    updateDropdownInputValue(props.block, {
+      error: null,
+    })
+    props.executionQueue.enqueueBlock(
+      props.block,
+      props.userId,
+      environmentStartedAt,
+      {
+        _tag: 'dropdown-input-save-value',
+      }
+    )
+  }, [props.block, props.userId, props.executionQueue, environmentStartedAt])
 
   const toggleConfigOpen = useCallback(() => {
     dropdownInputToggleConfigOpen(props.block)
   }, [props.block])
 
-  const isLoadingDropdownInputValue =
-    getDropdownInputValueExecStatus(props.block) !== 'idle'
-
-  const dropdownInputValueExecStatus = getDropdownInputValueExecStatus(
-    props.block
-  )
-
-  const dropdownInputVariableExecStatus = getDropdownInputVariableExecStatus(
-    props.block,
-    props.blocks
-  )
-
   const onChangeVariable = useCallback(
     (e: React.ChangeEvent<HTMLInputElement>) => {
       updateDropdownInputVariable(props.block, props.blocks, {
@@ -129,17 +135,34 @@ function DropdownInputBlock(props: Props) {
       if (attrs.variable.newValue !== attrs.variable.value) {
         updateDropdownInputVariable(props.block, props.blocks, {
           newValue: e.target.value.trim(),
-          status: 'save-requested',
           error: null,
         })
+        props.executionQueue.enqueueBlock(
+          props.block,
+          props.userId,
+          environmentStartedAt,
+          {
+            _tag: 'dropdown-input-rename-variable',
+          }
+        )
       }
     },
-    [props.block, props.blocks, attrs]
+    [props.block, props.userId, props.executionQueue]
   )
 
   const onRetryVariable = useCallback(() => {
-    dropdownInputRequestSaveVariable(props.block)
-  }, [props.block])
+    updateDropdownInputVariable(props.block, props.blocks, {
+      error: null,
+    })
+    props.executionQueue.enqueueBlock(
+      props.block,
+      props.userId,
+      environmentStartedAt,
+      {
+        _tag: 'dropdown-input-rename-variable',
+      }
+    )
+  }, [props.block, props.userId, props.executionQueue, environmentStartedAt])
 
   const selectRef = useRef<HTMLUListElement>(null)
 
@@ -165,6 +188,48 @@ function DropdownInputBlock(props: Props) {
 
   const dropdownWrapperRef = useRef<HTMLInputElement>(null)
 
+  const variableExecutions = useBlockExecutions(
+    props.executionQueue,
+    props.block,
+    'dropdown-input-rename-variable'
+  )
+  const variableStatus =
+    head(variableExecutions)?.item.getStatus()._tag ?? 'idle'
+
+  const valueExecutions = useBlockExecutions(
+    props.executionQueue,
+    props.block,
+    'dropdown-input-save-value'
+  )
+  const valueStatus = head(valueExecutions)?.item.getStatus()._tag ?? 'idle'
+
+  const onSelectValue = useCallback(
+    (value: string) => {
+      setSelected(value)
+      updateDropdownInputValue(props.block, { newValue: value })
+      props.executionQueue.enqueueBlock(
+        props.block,
+        props.userId,
+        environmentStartedAt,
+        {
+          _tag: 'dropdown-input-save-value',
+        }
+      )
+    },
+    [props.block, props.userId, props.executionQueue, environmentStartedAt]
+  )
+
+  const onRun = useCallback(() => {
+    props.executionQueue.enqueueBlock(
+      props.block,
+      props.userId,
+      environmentStartedAt,
+      {
+        _tag: 'dropdown-input-save-value',
+      }
+    )
+  }, [props.block, props.userId, props.executionQueue, environmentStartedAt])
+
   return (
     <div
       className={clsx(
@@ -208,27 +273,28 @@ function DropdownInputBlock(props: Props) {
                       {
                         'text-red-500 bg-red-100':
                           attrs.variable.error &&
-                          dropdownInputVariableExecStatus === 'idle',
+                          !isExecutionStatusLoading(variableStatus),
                         'text-ceramic-500 bg-ceramic-100':
                           !attrs.variable.error &&
-                          dropdownInputVariableExecStatus === 'idle',
+                          !isExecutionStatusLoading(variableStatus),
                         'text-gray-300 bg-gray-100':
-                          dropdownInputVariableExecStatus === 'loading',
+                          isExecutionStatusLoading(variableStatus),
                       }
                     )}
                     type="text"
                     value={attrs.variable.newValue}
                     onChange={onChangeVariable}
                     onBlur={onBlurVariable}
-                    disabled={dropdownInputVariableExecStatus !== 'idle'}
+                    disabled={isExecutionStatusLoading(variableStatus)}
                     ref={dropdownWrapperRef}
                   />
                   <div className="absolute inset-y-0 pl-1 flex items-center group z-10">
                     {(attrs.variable.error ||
-                      dropdownInputVariableExecStatus !== 'idle') &&
-                      (dropdownInputVariableExecStatus === 'loading' ? (
+                      isExecutionStatusLoading(variableStatus)) &&
+                      (variableStatus === 'running' ||
+                      variableStatus === 'aborting' ? (
                         <Spin />
-                      ) : dropdownInputVariableExecStatus === 'enqueued' ? (
+                      ) : variableStatus === 'enqueued' ? (
                         <ClockIcon className="w-4 h-4 text-gray-300" />
                       ) : attrs.variable.error ? (
                         <>
@@ -261,13 +327,9 @@ function DropdownInputBlock(props: Props) {
           <div className="relative">
             <Combobox
               value={selected}
-              onChange={(value) => {
-                setSelected(value)
-                updateDropdownInputValue(props.block, { newValue: value })
-                props.onRun(props.block)
-              }}
+              onChange={onSelectValue}
               disabled={
-                dropdownInputValueExecStatus !== 'idle' ||
+                isExecutionStatusLoading(valueStatus) ||
                 (!props.isEditable && !props.isApp)
               }
             >
@@ -286,7 +348,8 @@ function DropdownInputBlock(props: Props) {
                         !props.belongsToMultiTabGroup
                         ? 'ring-primary-400'
                         : 'ring-gray-200',
-                      (isLoadingDropdownInputValue || attrs.value.error) &&
+                      (isExecutionStatusLoading(valueStatus) ||
+                        attrs.value.error) &&
                         'bg-none' // this removes the caret
                     )}
                     displayValue={(value: string) => value}
@@ -360,10 +423,10 @@ function DropdownInputBlock(props: Props) {
               )}
             </Combobox>
             <div className="absolute inset-y-0 right-0 flex items-center pr-2 group">
-              {(attrs.value.error || dropdownInputValueExecStatus !== 'idle') &&
-                (dropdownInputValueExecStatus === 'loading' ? (
+              {(attrs.value.error || isExecutionStatusLoading(valueStatus)) &&
+                (valueStatus === 'running' || valueStatus === 'aborting' ? (
                   <Spin />
-                ) : dropdownInputValueExecStatus === 'enqueued' ? (
+                ) : valueStatus === 'enqueued' ? (
                   <ClockIcon className="w-4 h-4 text-gray-300" />
                 ) : attrs.value.error ? (
                   <>
@@ -389,7 +452,7 @@ function DropdownInputBlock(props: Props) {
               block={props.block}
               blocks={props.blocks}
               dataframes={props.dataframes}
-              onRun={props.onRun}
+              onRun={onRun}
             />
           )}
         </div>
diff --git a/apps/web/src/components/v2Editor/customBlocks/input/index.tsx b/apps/web/src/components/v2Editor/customBlocks/input/index.tsx
index daae3a77..fcee064e 100644
--- a/apps/web/src/components/v2Editor/customBlocks/input/index.tsx
+++ b/apps/web/src/components/v2Editor/customBlocks/input/index.tsx
@@ -6,9 +6,9 @@ import {
   getInputAttributes,
   updateInputLabel,
   updateInputValue,
-  getInputVariableExecStatus,
-  getInputValueExecStatus,
   updateInputVariable,
+  ExecutionQueue,
+  isExecutionStatusLoading,
 } from '@briefer/editor'
 import clsx from 'clsx'
 import { useCallback, useEffect, useRef } from 'react'
@@ -16,6 +16,9 @@ import Spin from '@/components/Spin'
 import { ConnectDragPreview } from 'react-dnd'
 import { ClockIcon } from '@heroicons/react/20/solid'
 import useEditorAwareness from '@/hooks/useEditorAwareness'
+import { useBlockExecutions } from '@/hooks/useBlockExecution'
+import { head } from 'ramda'
+import { useEnvironmentStatus } from '@/hooks/useEnvironmentStatus'
 
 function errorMessage(
   error: InputBlock['variable']['error'],
@@ -61,9 +64,11 @@ interface Props {
   isEditable: boolean
   isApp: boolean
   isDashboard: boolean
-  onRun: (block: Y.XmlElement<InputBlock>) => void
   isCursorWithin: boolean
   isCursorInserting: boolean
+  userId: string | null
+  workspaceId: string
+  executionQueue: ExecutionQueue
 }
 function InputBlock(props: Props) {
   const attrs = getInputAttributes(props.block, props.blocks)
@@ -92,35 +97,72 @@ function InputBlock(props: Props) {
     [props.block, props.blocks]
   )
 
+  const { startedAt: environmentStartedAt } = useEnvironmentStatus(
+    props.workspaceId
+  )
   const onBlurVariable: React.FocusEventHandler<HTMLInputElement> = useCallback(
     (e) => {
       if (attrs.variable.newValue !== attrs.variable.value) {
         updateInputVariable(props.block, props.blocks, {
           newValue: e.target.value.trim(),
-          status: 'save-requested',
           error: null,
         })
+        props.executionQueue.enqueueBlock(
+          props.block,
+          props.userId,
+          environmentStartedAt,
+          {
+            _tag: 'text-input-rename-variable',
+          }
+        )
       }
     },
-    [props.block, props.blocks, attrs]
+    [props.block, props.userId, environmentStartedAt]
   )
 
-  const onRetryValue = useCallback(() => {
-    props.onRun(props.block)
-  }, [props.block, props.onRun])
+  const onSaveValue = useCallback(() => {
+    props.executionQueue.enqueueBlock(
+      props.block,
+      props.userId,
+      environmentStartedAt,
+      {
+        _tag: 'text-input-save-value',
+      }
+    )
+  }, [props.block, props.userId, environmentStartedAt])
 
-  const onRetryVariable = useCallback(() => {
+  const onRunValue = useCallback(() => {
     updateInputVariable(props.block, props.blocks, {
-      status: 'save-requested',
       error: null,
     })
-  }, [props.block, props.blocks])
 
-  const inputVariableExecStatus = getInputVariableExecStatus(
+    props.executionQueue.enqueueBlock(
+      props.block,
+      props.userId,
+      environmentStartedAt,
+      {
+        _tag: 'text-input-rename-variable',
+      }
+    )
+  }, [props.block, props.userId, environmentStartedAt])
+
+  const variableExecutions = useBlockExecutions(
+    props.executionQueue,
+    props.block,
+    'text-input-rename-variable'
+  )
+  const variableStatus = head(variableExecutions)?.item.getStatus() ?? {
+    _tag: 'idle',
+  }
+
+  const valueExecutions = useBlockExecutions(
+    props.executionQueue,
     props.block,
-    props.blocks
+    'text-input-save-value'
   )
-  const inputValueExecStatus = getInputValueExecStatus(props.block)
+  const valueStatus = head(valueExecutions)?.item.getStatus() ?? {
+    _tag: 'idle',
+  }
 
   const selectRef = useRef<HTMLInputElement>(null)
   useEffect(() => {
@@ -136,11 +178,11 @@ function InputBlock(props: Props) {
 
   const onBlur = useCallback(() => {
     if (attrs.value.newValue !== attrs.value.value) {
-      props.onRun(props.block)
+      onSaveValue()
     }
 
     editorAPI.blur()
-  }, [props.block, props.onRun, attrs.value, editorAPI.blur])
+  }, [attrs.value.newValue, attrs.value.value, onSaveValue, editorAPI.blur])
 
   const unfocusOnEscape = useCallback(
     (e: React.KeyboardEvent<HTMLInputElement>) => {
@@ -192,30 +234,37 @@ function InputBlock(props: Props) {
                 'ring-0 px-1 py-0.5 rounded-md text-ceramic-500 bg-ceramic-100 text-xs font-medium min-w-12 min-h-4 focus:ring-0 border-0 block text-right',
                 {
                   'text-red-500 bg-red-100':
-                    attrs.variable.error && inputVariableExecStatus === 'idle',
+                    attrs.variable.error &&
+                    (variableStatus._tag === 'idle' ||
+                      variableStatus._tag === 'completed'),
                   'text-ceramic-500 bg-ceramic-100':
-                    !attrs.variable.error && inputVariableExecStatus === 'idle',
+                    !attrs.variable.error &&
+                    (variableStatus._tag === 'idle' ||
+                      variableStatus._tag === 'completed'),
                   'text-gray-300 bg-gray-100':
-                    inputVariableExecStatus === 'loading',
+                    variableStatus._tag === 'running' ||
+                    variableStatus._tag === 'enqueued' ||
+                    variableStatus._tag === 'aborting',
                 }
               )}
               type="text"
               value={attrs.variable.newValue}
               onChange={onChangeVariable}
               onBlur={onBlurVariable}
-              disabled={inputVariableExecStatus !== 'idle'}
+              disabled={isExecutionStatusLoading(variableStatus._tag)}
             />
             <div className="absolute inset-y-0 flex items-center pl-1 group z-10">
-              {(attrs.variable.error || inputVariableExecStatus !== 'idle') &&
-                (inputVariableExecStatus === 'loading' ? (
-                  <Spin />
-                ) : inputVariableExecStatus === 'enqueued' ? (
+              {(attrs.variable.error ||
+                isExecutionStatusLoading(variableStatus._tag)) &&
+                (variableStatus._tag === 'enqueued' ? (
                   <ClockIcon className="w-4 h-4 text-gray-300" />
+                ) : isExecutionStatusLoading(variableStatus._tag) ? (
+                  <Spin />
                 ) : attrs.variable.error ? (
                   <>
                     <button
                       disabled={attrs.variable.error === null}
-                      onClick={onRetryVariable}
+                      onClick={onRunValue}
                     >
                       <ExclamationCircleIcon
                         className="h-3 w-3 text-red-300"
@@ -253,21 +302,22 @@ function InputBlock(props: Props) {
             value={attrs.value.newValue}
             onChange={onChangeValue}
             disabled={
-              inputValueExecStatus !== 'idle' ||
+              isExecutionStatusLoading(valueStatus._tag) ||
               (!props.isEditable && !props.isApp)
             }
           />
           <div className="absolute inset-y-0 right-0 flex items-center pr-3 group">
-            {(attrs.value.error || inputValueExecStatus !== 'idle') &&
-              (inputValueExecStatus === 'loading' ? (
-                <Spin />
-              ) : inputValueExecStatus === 'enqueued' ? (
+            {(attrs.value.error ||
+              isExecutionStatusLoading(valueStatus._tag)) &&
+              (valueStatus._tag === 'enqueued' ? (
                 <ClockIcon className="w-4 h-4 text-gray-300" />
+              ) : isExecutionStatusLoading(valueStatus._tag) ? (
+                <Spin />
               ) : attrs.value.error ? (
                 <>
                   <button
                     disabled={attrs.value.error === null}
-                    onClick={onRetryValue}
+                    onClick={onSaveValue}
                   >
                     <ExclamationCircleIcon
                       className="h-4 w-4 text-red-300"
diff --git a/apps/web/src/components/v2Editor/customBlocks/pivotTable/index.tsx b/apps/web/src/components/v2Editor/customBlocks/pivotTable/index.tsx
index d9528bd0..d78d4130 100644
--- a/apps/web/src/components/v2Editor/customBlocks/pivotTable/index.tsx
+++ b/apps/web/src/components/v2Editor/customBlocks/pivotTable/index.tsx
@@ -3,6 +3,7 @@ import * as Y from 'yjs'
 import { DataFrame, PivotTableSort } from '@briefer/types'
 import {
   BlockType,
+  ExecutionQueue,
   PivotTableColumn,
   PivotTableMetric,
   PivotTableRow,
@@ -11,20 +12,22 @@ import {
   getDataframe,
   getPivotTableAttributes,
   getPivotTableBlockExecStatus,
+  isExecutionStatusLoading,
   type PivotTableBlock,
 } from '@briefer/editor'
 import clsx from 'clsx'
-import { useCallback, useEffect, useMemo, useState } from 'react'
+import { useCallback, useEffect, useState } from 'react'
 import HeaderSelect from '@/components/HeaderSelect'
 import PivotTableControls from './PivotTableControls'
 import PivotTableView from './PivotTableView'
-import { equals } from 'ramda'
+import { equals, head } from 'ramda'
 import HiddenInPublishedButton from '../../HiddenInPublishedButton'
 import { ArrowPathIcon, ClockIcon, StopIcon } from '@heroicons/react/20/solid'
 import { useEnvironmentStatus } from '@/hooks/useEnvironmentStatus'
 import { PivotTableExecTooltip } from '../../ExecTooltip'
 import LargeSpinner from '@/components/LargeSpinner'
 import useEditorAwareness from '@/hooks/useEditorAwareness'
+import { useBlockExecutions } from '@/hooks/useBlockExecution'
 
 interface Props {
   workspaceId: string
@@ -33,10 +36,6 @@ interface Props {
   blocks: Y.Map<YBlock>
   dragPreview: ConnectDragPreview | null
   isEditable: boolean
-  onRun: (
-    block: Y.XmlElement<PivotTableBlock>,
-    customCallback?: (block: Y.XmlElement<PivotTableBlock>) => void
-  ) => void
   onAddGroupedBlock: (
     blockId: string,
     blockType: BlockType,
@@ -48,6 +47,8 @@ interface Props {
   dashboardMode: 'live' | 'editing' | 'none'
   isCursorWithin: boolean
   isCursorInserting: boolean
+  userId: string | null
+  executionQueue: ExecutionQueue
 }
 function PivotTableBlock(props: Props) {
   const attrs = getPivotTableAttributes(props.block, props.blocks)
@@ -128,12 +129,32 @@ function PivotTableBlock(props: Props) {
       props.block.unobserve(observe)
     }
   }, [props.block, dataframe])
+
+  const {
+    status: envStatus,
+    loading: envLoading,
+    startedAt: environmentStartedAt,
+  } = useEnvironmentStatus(props.workspaceId)
+
   useEffect(() => {
     if (isDirty) {
-      props.onRun(props.block)
+      props.executionQueue.enqueueBlock(
+        props.block,
+        props.userId,
+        environmentStartedAt,
+        {
+          _tag: 'pivot-table',
+        }
+      )
       setIsDirty(false)
     }
-  }, [isDirty, props.block, props.onRun])
+  }, [
+    isDirty,
+    props.block,
+    props.executionQueue,
+    props.userId,
+    environmentStartedAt,
+  ])
 
   const onChangeTitle = useCallback(
     (e: React.ChangeEvent<HTMLInputElement>) => {
@@ -189,8 +210,13 @@ function PivotTableBlock(props: Props) {
   const onPrevPage = useCallback(() => {
     const run = () => {
       props.block.setAttribute('page', attrs.page - 1)
-      props.onRun(props.block, (b) =>
-        b.setAttribute('status', 'page-requested')
+      props.executionQueue.enqueueBlock(
+        props.block,
+        props.userId,
+        environmentStartedAt,
+        {
+          _tag: 'pivot-table-load-page',
+        }
       )
     }
     if (props.block.doc) {
@@ -198,13 +224,24 @@ function PivotTableBlock(props: Props) {
     } else {
       run()
     }
-  }, [props.block, attrs.page, props.onRun])
+  }, [
+    props.block,
+    attrs.page,
+    props.executionQueue,
+    props.userId,
+    environmentStartedAt,
+  ])
 
   const onNextPage = useCallback(() => {
     const run = () => {
       props.block.setAttribute('page', attrs.page + 1)
-      props.onRun(props.block, (b) =>
-        b.setAttribute('status', 'page-requested')
+      props.executionQueue.enqueueBlock(
+        props.block,
+        props.userId,
+        environmentStartedAt,
+        {
+          _tag: 'pivot-table-load-page',
+        }
       )
     }
     if (props.block.doc) {
@@ -212,14 +249,19 @@ function PivotTableBlock(props: Props) {
     } else {
       run()
     }
-  }, [props.block, attrs.page, props.onRun])
+  }, [props.block, attrs.page, props.executionQueue, props.userId])
 
   const setPage = useCallback(
     (page: number) => {
       const run = () => {
         props.block.setAttribute('page', page + 1)
-        props.onRun(props.block, (b) =>
-          b.setAttribute('status', 'page-requested')
+        props.executionQueue.enqueueBlock(
+          props.block,
+          props.userId,
+          environmentStartedAt,
+          {
+            _tag: 'pivot-table-load-page',
+          }
         )
       }
 
@@ -229,72 +271,83 @@ function PivotTableBlock(props: Props) {
         run()
       }
     },
-    [props.block, props.onRun]
+    [props.block, props.executionQueue, props.userId]
   )
 
-  const loadingTable: boolean = useMemo(() => {
-    switch (attrs.status) {
-      case 'page-requested':
-      case 'loading-page':
-      case 'idle':
-        return false
-      case 'run-requested':
-      case 'running':
-      case 'abort-requested':
-      case 'aborting':
-      case 'run-all-enqueued':
-      case 'run-all-running':
-        return true
-    }
-  }, [attrs.status])
-
-  const loadingPage: boolean = useMemo(() => {
-    switch (attrs.status) {
-      case 'page-requested':
-      case 'loading-page':
-        return true
-      case 'idle':
-      case 'run-requested':
-      case 'running':
-      case 'abort-requested':
-      case 'aborting':
-      case 'run-all-enqueued':
-      case 'run-all-running':
-        return false
-    }
-  }, [attrs.status])
+  const executions = useBlockExecutions(
+    props.executionQueue,
+    props.block,
+    'pivot-table'
+  )
+  const execution = head(executions)
+  const status = execution?.item.getStatus()._tag ?? 'idle'
+
+  const pageExecutions = useBlockExecutions(
+    props.executionQueue,
+    props.block,
+    'pivot-table-load-page'
+  )
+  const pageExecution = head(pageExecutions)
+  const pageStatus = pageExecution?.item.getStatus()._tag ?? 'idle'
+
+  const loadingTable = isExecutionStatusLoading(pageStatus)
+  const loadingPage = isExecutionStatusLoading(status)
 
   const isEditable =
     props.isEditable &&
-    attrs.status !== 'run-all-enqueued' &&
-    attrs.status !== 'run-all-running'
+    execution?.batch.isRunAll() !== true &&
+    pageExecution?.batch.isRunAll() !== true
 
-  const execStatus = getPivotTableBlockExecStatus(props.block)
+  // TODO: should introduce a useBlockExecutionStatus hook
+  const execStatus = getPivotTableBlockExecStatus(
+    props.block,
+    props.executionQueue
+  )
 
   const onRunAbort = useCallback(() => {
-    if (attrs.status === 'running') {
-      props.block.setAttribute('status', 'abort-requested')
+    if (execution || pageExecution) {
+      if (execution) {
+        execution.item.setAborting()
+      }
+      if (pageExecution) {
+        pageExecution.item.setAborting()
+      }
     } else {
-      props.onRun(props.block)
+      props.executionQueue.enqueueBlock(
+        props.block,
+        props.userId,
+        environmentStartedAt,
+        {
+          _tag: 'pivot-table',
+        }
+      )
     }
-  }, [props.block, props.onRun, attrs.status])
+  }, [
+    execution,
+    pageExecution,
+    props.executionQueue,
+    props.block,
+    props.userId,
+    environmentStartedAt,
+  ])
 
   const onToggleIsBlockHiddenInPublished = useCallback(() => {
     props.onToggleIsBlockHiddenInPublished(attrs.id)
   }, [props.onToggleIsBlockHiddenInPublished, attrs.id])
 
-  const { status: envStatus, loading: envLoading } = useEnvironmentStatus(
-    props.workspaceId
-  )
-
   const onSort = useCallback(
     (sort: PivotTableSort | null) => {
       props.block.setAttribute('sort', sort)
-      props.onRun(props.block, (b) =>
-        b.setAttribute('status', 'page-requested')
+      props.executionQueue.enqueueBlock(
+        props.block,
+        props.userId,
+        environmentStartedAt,
+        {
+          _tag: 'pivot-table',
+        }
       )
     },
-    [props.block, props.onRun]
+    [props.block, props.executionQueue, props.userId, props.executionQueue]
   )
 
   const [, editorAPI] = useEditorAwareness()
@@ -306,7 +359,7 @@ function PivotTableBlock(props: Props) {
     if (!attrs.result) {
       return (
         <div className="flex items-center justify-center h-full">
-          {attrs.status !== 'idle' ? (
+          {isExecutionStatusLoading(execStatus) ? (
             <LargeSpinner color="#b8f229" />
           ) : (
             <div className="text-gray-500">No query results</div>
@@ -432,12 +485,17 @@ function PivotTableBlock(props: Props) {
           className={clsx(
             {
               'bg-gray-200 cursor-not-allowed':
-                attrs.status !== 'idle' && attrs.status !== 'running',
-              'bg-red-200':
-                attrs.status === 'running' && envStatus === 'Running',
+                execStatus !== 'idle' &&
+                execStatus !== 'running' &&
+                execStatus !== 'completed' &&
+                execStatus !== 'unknown',
+              'bg-red-200': execStatus === 'running' && envStatus === 'Running',
               'bg-yellow-300':
-                attrs.status === 'running' && envStatus !== 'Running',
-              'bg-primary-200': attrs.status === 'idle',
+                execStatus === 'running' && envStatus !== 'Running',
+              'bg-primary-200':
+                execStatus === 'idle' ||
+                execStatus === 'completed' ||
+                execStatus === 'unknown',
             },
             'rounded-sm h-6 min-w-6 flex items-center justify-center relative group'
           )}
@@ -445,10 +503,13 @@ function PivotTableBlock(props: Props) {
           disabled={
             !dataframe ||
             !isEditable ||
-            (attrs.status !== 'idle' && attrs.status !== 'running')
+            (execStatus !== 'idle' &&
+              execStatus !== 'running' &&
+              execStatus !== 'completed' &&
+              execStatus !== 'unknown')
           }
         >
-          {attrs.status !== 'idle' ? (
+          {isExecutionStatusLoading(execStatus) ? (
             <div>
               {execStatus === 'enqueued' ? (
                 <ClockIcon className="w-3 h-3 text-gray-500" />
@@ -458,8 +519,12 @@ function PivotTableBlock(props: Props) {
               <PivotTableExecTooltip
                 envStatus={envStatus}
                 envLoading={envLoading}
-                execStatus={execStatus}
-                status={attrs.status}
+                execStatus={execStatus === 'enqueued' ? 'enqueued' : 'running'}
+                runningAll={
+                  execution?.batch.isRunAll() ??
+                  pageExecution?.batch.isRunAll() ??
+                  false
+                }
               />
             </div>
           ) : (
diff --git a/apps/web/src/components/v2Editor/customBlocks/python/index.tsx b/apps/web/src/components/v2Editor/customBlocks/python/index.tsx
index 118ceb0d..506ea9b0 100644
--- a/apps/web/src/components/v2Editor/customBlocks/python/index.tsx
+++ b/apps/web/src/components/v2Editor/customBlocks/python/index.tsx
@@ -10,27 +10,24 @@ import * as Y from 'yjs'
 import {
   type PythonBlock,
   setTitle,
-  getPythonBlockExecStatus,
-  execStatusIsDisabled,
   getPythonAISuggestions,
   isPythonBlockEditWithAIPromptOpen,
-  isPythonBlockAIEditing,
   getPythonBlockEditWithAIPrompt,
-  requestPythonFixWithAI,
-  requestPythonEditWithAI,
   closePythonEditWithAIPrompt,
-  isFixingPythonWithAI,
   togglePythonEditWithAIPromptOpen,
   getBaseAttributes,
   getPythonAttributes,
   createComponentState,
   YBlock,
   updateYText,
+  ExecutionQueue,
+  isExecutionStatusLoading,
+  AITasks,
 } from '@briefer/editor'
 import clsx from 'clsx'
 import type { ApiDocument, ApiWorkspace } from '@briefer/database'
 import { useEnvironmentStatus } from '@/hooks/useEnvironmentStatus'
-import { useCallback, useMemo } from 'react'
+import { useCallback, useEffect, useMemo } from 'react'
 import {
   ExecutingPythonText,
   LoadingEnvText,
@@ -49,6 +46,10 @@ import useProperties from '@/hooks/useProperties'
 import { SaveReusableComponentButton } from '@/components/ReusableComponents'
 import { useReusableComponents } from '@/hooks/useReusableComponents'
 import { CodeEditor } from '../../CodeEditor'
+import { exhaustiveCheck } from '@briefer/types'
+import { useBlockExecutions } from '@/hooks/useBlockExecution'
+import { head } from 'ramda'
+import { useAITasks } from '@/hooks/useAITasks'
 
 interface Props {
   document: ApiDocument
@@ -56,8 +57,6 @@ interface Props {
   blocks: Y.Map<YBlock>
   isEditable: boolean
   dragPreview: ConnectDragPreview | null
-  onRun: (block: Y.XmlElement<PythonBlock>) => void
-  onTry: (block: Y.XmlElement<PythonBlock>) => void
   isPublicMode: boolean
   isPDF: boolean
   dashboardPlace: 'controls' | 'view' | null
@@ -65,6 +64,9 @@ interface Props {
   isBlockHiddenInPublished: boolean
   onToggleIsBlockHiddenInPublished: (blockId: string) => void
   insertBelow?: () => void
+  executionQueue: ExecutionQueue
+  aiTasks: AITasks
+  userId: string | null
 }
 function PythonBlock(props: Props) {
   const properties = useProperties()
@@ -81,9 +83,11 @@ function PythonBlock(props: Props) {
     )
   }, [currentWorkspace, properties.data])
 
-  const { status: envStatus, loading: envLoading } = useEnvironmentStatus(
-    props.document.workspaceId
-  )
+  const {
+    status: envStatus,
+    loading: envLoading,
+    startedAt: environmentStartedAt,
+  } = useEnvironmentStatus(props.document.workspaceId)
 
   const toggleResultHidden = useCallback(() => {
     props.block.doc?.transact(() => {
@@ -99,26 +103,72 @@ function PythonBlock(props: Props) {
     })
   }, [props.block])
 
-  const status = props.block.getAttribute('status')
+  const executions = useBlockExecutions(
+    props.executionQueue,
+    props.block,
+    'python'
+  )
+  useEffect(() => {
+    const interval = setInterval(() => {
+      console.log(JSON.stringify(props.executionQueue.toJSON(), null, 2))
+    }, 1000)
+
+    return () => clearInterval(interval)
+  }, [props.executionQueue])
+  const execution = head(executions) ?? null
+  const status = execution?.item.getStatus()._tag ?? 'idle'
+
+  const editAITasks = useAITasks(props.aiTasks, props.block, 'edit-python')
+  const fixAITasks = useAITasks(props.aiTasks, props.block, 'fix-python')
+  const aiTask = useMemo(
+    () => head(editAITasks.concat(fixAITasks)) ?? null,
+    [editAITasks, fixAITasks]
+  )
 
+  const { id: blockId, componentId } = getPythonAttributes(props.block)
   const onRun = useCallback(() => {
-    props.onRun(props.block)
-  }, [props.block, props.onRun])
+    props.executionQueue.enqueueBlock(
+      blockId,
+      props.userId,
+      environmentStartedAt,
+      {
+        _tag: 'python',
+        isSuggestion: false,
+      }
+    )
+  }, [props.executionQueue, blockId, props.userId])
 
   const onTry = useCallback(() => {
-    props.onTry(props.block)
-  }, [props.block, props.onTry])
+    props.executionQueue.enqueueBlock(
+      blockId,
+      props.userId,
+      environmentStartedAt,
+      {
+        _tag: 'python',
+        isSuggestion: true,
+      }
+    )
+  }, [props.executionQueue, blockId, props.userId, environmentStartedAt])
 
   const onRunAbort = useCallback(() => {
-    if (status === 'running' || status === 'running-suggestion') {
-      props.block.setAttribute('status', 'abort-requested')
-    } else {
-      onRun()
+    switch (status) {
+      case 'enqueued':
+      case 'running':
+        execution?.item.setAborting()
+        break
+      case 'idle':
+      case 'completed':
+      case 'unknown':
+        onRun()
+        break
+      case 'aborting':
+        break
+      default:
+        exhaustiveCheck(status)
     }
-  }, [status, props.block, onRun])
+  }, [status, execution, onRun])
 
-  const execStatus = getPythonBlockExecStatus(props.block)
-  const statusIsDisabled = execStatusIsDisabled(execStatus)
+  const statusIsDisabled = isExecutionStatusLoading(status)
 
   const onToggleEditWithAIPromptOpen = useCallback(() => {
     if (!hasOaiKey) {
@@ -128,7 +178,6 @@ function PythonBlock(props: Props) {
     togglePythonEditWithAIPromptOpen(props.block)
   }, [props.block, hasOaiKey])
 
-  const { id: blockId, componentId } = getPythonAttributes(props.block)
   const [
     { data: components },
     { create: createReusableComponent, update: updateReusableComponent },
@@ -143,24 +192,27 @@ function PythonBlock(props: Props) {
   const startQueryTime = props.block.getAttribute('startQueryTime')
   const lastQueryTime = props.block.getAttribute('lastQueryTime')
 
-  const queryStatusText = useMemo(() => {
-    if (status === 'running' || status === 'running-suggestion') {
-      if (envStatus === 'Starting') {
-        return <LoadingEnvText />
-      } else {
+  const queryStatusText: JSX.Element | null = useMemo(() => {
+    switch (status) {
+      case 'idle':
+      case 'completed':
+        if (source?.toJSON() === lastQuery && lastQueryTime) {
+          return <PythonSucceededText lastExecutionTime={lastQueryTime} />
+        }
+        return null
+      case 'running':
+      case 'enqueued':
+      case 'aborting':
+        if (envStatus === 'Starting') {
+          return <LoadingEnvText />
+        }
+
         return (
           <ExecutingPythonText startExecutionTime={startQueryTime ?? null} />
         )
-      }
-    }
-
-    if (status === 'idle') {
-      if (source?.toJSON() === lastQuery && lastQueryTime) {
-        return <PythonSucceededText lastExecutionTime={lastQueryTime} />
-      }
+      case 'unknown':
+        return null
     }
-
-    return null
   }, [
     status,
     startQueryTime,
@@ -184,18 +236,30 @@ function PythonBlock(props: Props) {
   const results = props.block.getAttribute('result') ?? []
   const aiSuggestions = getPythonAISuggestions(props.block)
   const editWithAIPrompt = getPythonBlockEditWithAIPrompt(props.block)
-  const isAIEditing = isPythonBlockAIEditing(props.block)
+
+  const isAIEditing =
+    aiTask?.getMetadata()._tag === 'edit-python'
+      ? isExecutionStatusLoading(aiTask.getStatus()._tag)
+      : false
+  const isAIFixing =
+    aiTask?.getMetadata()._tag === 'fix-python'
+      ? isExecutionStatusLoading(aiTask.getStatus()._tag)
+      : false
 
   const [editorState, editorAPI] = useEditorAwareness()
 
   const onCloseEditWithAIPrompt = useCallback(() => {
+    if (aiTask?.getMetadata()._tag === 'edit-sql') {
+      aiTask.setAborting()
+    }
+
     closePythonEditWithAIPrompt(props.block, false)
     editorAPI.insert(blockId, { scrollIntoView: false })
-  }, [props.block, editorAPI.insert])
+  }, [props.block, editorAPI.insert, blockId, aiTask])
 
   const onSubmitEditWithAI = useCallback(() => {
-    requestPythonEditWithAI(props.block)
-  }, [props.block])
+    props.aiTasks.enqueue(blockId, props.userId, { _tag: 'edit-python' })
+  }, [props.aiTasks, blockId, props.userId])
 
   const onAcceptAISuggestion = useCallback(() => {
     if (aiSuggestions) {
@@ -214,14 +278,15 @@ function PythonBlock(props: Props) {
       return
     }
 
-    requestPythonFixWithAI(props.block)
-  }, [props.block, hasOaiKey])
+    if (aiTask?.getMetadata()._tag === 'fix-python') {
+      aiTask.setAborting()
+    } else {
+      props.aiTasks.enqueue(blockId, props.userId, { _tag: 'fix-python' })
+    }
+  }, [props.aiTasks, blockId, props.userId, hasOaiKey, aiTask])
 
   const diffButtonsVisible =
-    aiSuggestions !== null &&
-    (status === 'idle' ||
-      status === 'running-suggestion' ||
-      status === 'try-suggestion-requested')
+    !props.isPublicMode && aiSuggestions !== null && status === 'idle'
 
   const onToggleIsBlockHiddenInPublished = useCallback(() => {
     props.onToggleIsBlockHiddenInPublished(blockId)
@@ -277,7 +342,7 @@ function PythonBlock(props: Props) {
       <PythonOutputs
         className="flex flex-col h-full ph-no-capture"
         outputs={results}
-        isFixWithAILoading={isFixingPythonWithAI(props.block)}
+        isFixWithAILoading={isAIFixing}
         onFixWithAI={onFixWithAI}
         isPDF={props.isPDF}
         isDashboardView={props.dashboardPlace === 'view'}
@@ -389,7 +454,7 @@ function PythonBlock(props: Props) {
                 {aiSuggestions === null &&
                   !props.isPublicMode &&
                   props.isEditable &&
-                  !isFixingPythonWithAI(props.block) && (
+                  !isAIFixing && (
                     <button
                       disabled={!props.isEditable}
                       onClick={onToggleEditWithAIPromptOpen}
@@ -466,7 +531,7 @@ function PythonBlock(props: Props) {
           >
             <PythonOutputs
               outputs={results}
-              isFixWithAILoading={isFixingPythonWithAI(props.block)}
+              isFixWithAILoading={isAIFixing}
               onFixWithAI={onFixWithAI}
               canFixWithAI={hasOaiKey}
               isPDF={props.isPDF}
@@ -501,7 +566,7 @@ function PythonBlock(props: Props) {
         >
           {status !== 'idle' ? (
             <div>
-              {execStatus === 'enqueued' ? (
+              {status === 'enqueued' ? (
                 <ClockIcon className="w-3 h-3 text-gray-500" />
               ) : (
                 <StopIcon className="w-3 h-3 text-gray-500" />
@@ -509,8 +574,8 @@ function PythonBlock(props: Props) {
               <PythonExecTooltip
                 envStatus={envStatus}
                 envLoading={envLoading}
-                execStatus={execStatus}
-                status={status}
+                execStatus={status === 'enqueued' ? 'enqueued' : 'running'}
+                runningAll={execution?.batch.isRunAll() ?? false}
               />
             </div>
           ) : (
diff --git a/apps/web/src/components/v2Editor/customBlocks/sql/DataframeNameInput.tsx b/apps/web/src/components/v2Editor/customBlocks/sql/DataframeNameInput.tsx
index 8c363b01..45409c20 100644
--- a/apps/web/src/components/v2Editor/customBlocks/sql/DataframeNameInput.tsx
+++ b/apps/web/src/components/v2Editor/customBlocks/sql/DataframeNameInput.tsx
@@ -3,9 +3,11 @@ import {
   QuestionMarkCircleIcon,
 } from '@heroicons/react/24/solid'
 import * as Y from 'yjs'
-import { SQLBlock } from '@briefer/editor'
+import { ExecutionQueue, SQLBlock } from '@briefer/editor'
 import { useCallback } from 'react'
 import clsx from 'clsx'
+import { useBlockExecutions } from '@/hooks/useBlockExecution'
+import { head } from 'ramda'
 
 function queryNameErrorMessage(
   err: SQLBlock['dataframeName']['error']
@@ -33,8 +35,17 @@ function queryNameErrorMessage(
 interface Props {
   block: Y.XmlElement<SQLBlock>
   disabled?: boolean
+  userId: string | null
+  environmentStartedAt: string | null
+  executionQueue: ExecutionQueue
 }
 function DataframeNameInput(props: Props) {
+  const executions = useBlockExecutions(
+    props.executionQueue,
+    props.block,
+    'sql-rename-dataframe'
+  )
+  const execution = head(executions)
   const dataframeName = props.block.getAttribute('dataframeName')
 
   const onChange = useCallback(
@@ -56,11 +67,24 @@ function DataframeNameInput(props: Props) {
       return
     }
 
-    props.block.setAttribute('dataframeName', {
-      ...dataframeName,
-      status: 'loading',
-    })
-  }, [props.block, dataframeName])
+    for (const { item } of executions) {
+      item.setAborting()
+    }
+    props.executionQueue.enqueueBlock(
+      props.block,
+      props.userId,
+      props.environmentStartedAt,
+      {
+        _tag: 'sql-rename-dataframe',
+      }
+    )
+  }, [
+    props.block,
+    dataframeName,
+    props.executionQueue,
+    props.userId,
+    props.environmentStartedAt,
+  ])
 
   const onRetry = useCallback(() => {
     if (!dataframeName) {
@@ -69,10 +93,28 @@ function DataframeNameInput(props: Props) {
 
     props.block.setAttribute('dataframeName', {
       ...dataframeName,
-      status: 'loading',
       error: undefined,
     })
-  }, [props.block, dataframeName])
+    for (const { item } of executions) {
+      item.setAborting()
+    }
+    props.executionQueue.enqueueBlock(
+      props.block,
+      props.userId,
+      props.environmentStartedAt,
+      {
+        _tag: 'sql-rename-dataframe',
+      }
+    )
+  }, [
+    dataframeName,
+    props.block,
+    props.executionQueue,
+    props.environmentStartedAt,
+    props.userId,
+  ])
+
+  const status = execution?.item.getStatus() ?? { _tag: 'idle' }
 
   if (!dataframeName) {
     return <div>Missing dataframe name in block</div>
@@ -91,7 +133,7 @@ function DataframeNameInput(props: Props) {
         placeholder="Dataframe name"
         value={dataframeName.newValue}
         onChange={onChange}
-        disabled={props.disabled || dataframeName.status !== 'idle'}
+        disabled={props.disabled || status._tag !== 'idle'}
         onBlur={onBlur}
       />
       <div className="absolute inset-y-0 right-0 flex items-center pr-1.5 group">
diff --git a/apps/web/src/components/v2Editor/customBlocks/sql/index.tsx b/apps/web/src/components/v2Editor/customBlocks/sql/index.tsx
index 28449ae9..c6f09bc0 100644
--- a/apps/web/src/components/v2Editor/customBlocks/sql/index.tsx
+++ b/apps/web/src/components/v2Editor/customBlocks/sql/index.tsx
@@ -9,21 +9,15 @@ import {
   BookOpenIcon,
 } from '@heroicons/react/20/solid'
 import { CopyToClipboard } from 'react-copy-to-clipboard'
-import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
+import { useCallback, useEffect, useMemo, useState } from 'react'
 import * as Y from 'yjs'
 import {
   type SQLBlock,
   setTitle,
-  getSQLBlockExecStatus,
-  execStatusIsDisabled,
   getSQLSource,
-  isSQLBlockAIEditing,
   toggleSQLEditWithAIPromptOpen,
-  requestSQLEditWithAI,
   isSQLBlockEditWithAIPromptOpen,
   closeSQLEditWithAIPrompt,
-  isFixingSQLWithAI,
-  requestSQLFixWithAI,
   updateYText,
   YBlockGroup,
   YBlock,
@@ -31,6 +25,9 @@ import {
   addGroupedBlock,
   getSQLAttributes,
   createComponentState,
+  ExecutionQueue,
+  AITasks,
+  isExecutionStatusLoading,
 } from '@briefer/editor'
 import SQLResult from './SQLResult'
 import type {
@@ -62,7 +59,10 @@ import { SaveReusableComponentButton } from '@/components/ReusableComponents'
 import { useReusableComponents } from '@/hooks/useReusableComponents'
 import { CodeEditor } from '../../CodeEditor'
 import SQLQueryConfigurationButton from './SQLQueryConfigurationButton'
-import { SQLQueryConfiguration } from '@briefer/types'
+import { exhaustiveCheck, SQLQueryConfiguration } from '@briefer/types'
+import { useBlockExecutions } from '@/hooks/useBlockExecution'
+import { head } from 'ramda'
+import { useAITasks } from '@/hooks/useAITasks'
 
 const NO_DS_TEXT = `-- No data sources connected. Please add one using the "data sources" menu on the bottom left
 -- Alternatively, you can upload files using the file upload block and query them using DuckDB as a data source.`
@@ -76,16 +76,16 @@ interface Props {
   isEditable: boolean
   isPublicMode: boolean
   dragPreview: ConnectDragPreview | null
-  onRun: (block: Y.XmlElement<SQLBlock>) => void
-  onTry: (block: Y.XmlElement<SQLBlock>) => void
   dashboardMode: 'live' | 'editing' | 'none'
   hasMultipleTabs: boolean
   isBlockHiddenInPublished: boolean
   onToggleIsBlockHiddenInPublished: (blockId: string) => void
   onSchemaExplorer: (dataSourceId: string | null) => void
   insertBelow: () => void
+  executionQueue: ExecutionQueue
+  userId: string | null
+  aiTasks: AITasks
 }
-
 function SQLBlock(props: Props) {
   const properties = useProperties()
   const [workspaces] = useWorkspaces()
@@ -115,25 +115,87 @@ function SQLBlock(props: Props) {
     })
   }, [props.block])
 
-  const onSQLSelectionChanged = useCallback(
-    (selectedCode: string | null) => {
-      props.block.setAttribute('selectedCode', selectedCode)
-    },
-    [props.block]
+  const [selectedCode, setSelectedCode] = useState<string | null>(null)
+  const onSQLSelectionChanged = useCallback((selectedCode: string | null) => {
+    setSelectedCode(selectedCode)
+  }, [])
+
+  const {
+    dataframeName,
+    id: blockId,
+    title,
+    result,
+    isCodeHidden,
+    isResultHidden,
+    editWithAIPrompt,
+    aiSuggestions,
+    dataSourceId,
+    isFileDataSource,
+    componentId,
+  } = getSQLAttributes(props.block, props.blocks)
+
+  const { startedAt: environmentStartedAt } = useEnvironmentStatus(
+    props.document.workspaceId
   )
 
   const onRun = useCallback(() => {
-    props.onRun(props.block)
-  }, [props.onRun, props.block])
+    props.executionQueue.enqueueBlock(
+      blockId,
+      props.userId,
+      environmentStartedAt,
+      {
+        _tag: 'sql',
+        isSuggestion: false,
+        selectedCode,
+      }
+    )
+  }, [
+    props.executionQueue,
+    blockId,
+    props.userId,
+    environmentStartedAt,
+    selectedCode,
+  ])
 
   const onTry = useCallback(() => {
-    props.onTry(props.block)
-  }, [props.onTry, props.block])
+    props.executionQueue.enqueueBlock(
+      blockId,
+      props.userId,
+      environmentStartedAt,
+      {
+        _tag: 'sql',
+        isSuggestion: true,
+        selectedCode,
+      }
+    )
+  }, [
+    props.executionQueue,
+    blockId,
+    props.userId,
+    environmentStartedAt,
+    selectedCode,
+  ])
 
-  const status = props.block.getAttribute('status')
+  const executions = useBlockExecutions(
+    props.executionQueue,
+    props.block,
+    'sql'
+  )
+  const execution = head(executions) ?? null
+  const status = execution?.item.getStatus() ?? { _tag: 'idle' }
 
-  const execStatus = getSQLBlockExecStatus(props.block)
-  const statusIsDisabled = execStatusIsDisabled(execStatus)
+  const statusIsDisabled: boolean = (() => {
+    switch (status._tag) {
+      case 'idle':
+      case 'completed':
+      case 'unknown':
+        return false
+      case 'running':
+      case 'enqueued':
+      case 'aborting':
+        return false
+    }
+  })()
 
   const onToggleEditWithAIPromptOpen = useCallback(() => {
     if (!hasOaiKey) {
@@ -150,20 +212,6 @@ function SQLBlock(props: Props) {
     }
   }, [props.dataSources, props.block])
 
-  const {
-    dataframeName,
-    id: blockId,
-    title,
-    result,
-    isCodeHidden,
-    isResultHidden,
-    editWithAIPrompt,
-    aiSuggestions,
-    dataSourceId,
-    isFileDataSource,
-    componentId,
-  } = getSQLAttributes(props.block, props.blocks)
-
   const dataSource = useMemo(
     () => props.dataSources.find((d) => d.config.data.id === dataSourceId),
     [props.dataSources, dataSourceId]
@@ -178,12 +226,32 @@ function SQLBlock(props: Props) {
     [components, componentId]
   )
 
+  const editAITasks = useAITasks(props.aiTasks, props.block, 'edit-sql')
+  const fixAITasks = useAITasks(props.aiTasks, props.block, 'fix-sql')
+  const aiTask = useMemo(
+    () => head(editAITasks.concat(fixAITasks)) ?? null,
+    [editAITasks, fixAITasks]
+  )
+
+  const isAIEditing =
+    aiTask?.getMetadata()._tag === 'edit-sql'
+      ? isExecutionStatusLoading(aiTask.getStatus()._tag)
+      : false
+  const isAIFixing =
+    aiTask?.getMetadata()._tag === 'fix-sql'
+      ? isExecutionStatusLoading(aiTask.getStatus()._tag)
+      : false
+
   const [editorState, editorAPI] = useEditorAwareness()
 
   const onCloseEditWithAIPrompt = useCallback(() => {
+    if (aiTask?.getMetadata()._tag === 'edit-sql') {
+      aiTask.setAborting()
+    }
+
     closeSQLEditWithAIPrompt(props.block, false)
     editorAPI.insert(blockId, { scrollIntoView: false })
-  }, [props.block, editorAPI.insert])
+  }, [props.block, editorAPI.insert, blockId, aiTask])
 
   const onChangeDataSource = useCallback(
     (df: { value: string; type: DataSourceType | 'duckdb' }) => {
@@ -210,50 +278,59 @@ function SQLBlock(props: Props) {
   )
 
   const onRunAbort = useCallback(() => {
-    if (status === 'running') {
-      props.block.setAttribute('status', 'abort-requested')
-    } else {
-      onRun()
+    switch (status._tag) {
+      case 'enqueued':
+      case 'running':
+        execution?.item.setAborting()
+        break
+      case 'idle':
+      case 'completed':
+      case 'unknown':
+        onRun()
+        break
+      case 'aborting':
+        break
+      default:
+        exhaustiveCheck(status)
     }
-  }, [status, props.block, onRun])
+  }, [status, execution, onRun])
 
   const { source, configuration } = getSQLAttributes(props.block, props.blocks)
   const lastQuery = props.block.getAttribute('lastQuery')
   const startQueryTime = props.block.getAttribute('startQueryTime')
   const lastQueryTime = props.block.getAttribute('lastQueryTime')
   const queryStatusText = useMemo(() => {
-    switch (execStatus) {
+    switch (status._tag) {
       case 'idle':
-      case 'error':
-      case 'success': {
+      case 'completed': {
         if (source?.toJSON() === lastQuery && lastQueryTime) {
           return <QuerySucceededText lastExecutionTime={lastQueryTime} />
         }
 
         return null
       }
+      case 'running':
       case 'enqueued':
-      case 'loading':
+      case 'aborting':
         if (envStatus === 'Starting') {
           return <LoadingEnvText />
-        } else {
-          return (
-            <LoadingQueryText startExecutionTime={startQueryTime ?? null} />
-          )
         }
+        return <LoadingQueryText startExecutionTime={startQueryTime ?? null} />
+      case 'unknown':
+        return null
     }
   }, [
-    execStatus,
-    source.toJSON(),
+    status,
+    startQueryTime,
     lastQuery,
     lastQueryTime,
-    startQueryTime,
+    source.toJSON(),
     envStatus,
   ])
 
   const onSubmitEditWithAI = useCallback(() => {
-    requestSQLEditWithAI(props.block)
-  }, [props.block])
+    props.aiTasks.enqueue(blockId, props.userId, { _tag: 'edit-sql' })
+  }, [props.aiTasks, blockId, props.userId])
 
   const onAcceptAISuggestion = useCallback(() => {
     if (aiSuggestions) {
@@ -272,15 +349,12 @@ function SQLBlock(props: Props) {
       return
     }
 
-    const status = props.block.getAttribute('status')
-    if (status === 'fix-with-ai-running') {
-      props.block.setAttribute('status', 'idle')
+    if (aiTask?.getMetadata()._tag === 'fix-sql') {
+      aiTask.setAborting()
     } else {
-      requestSQLFixWithAI(props.block)
+      props.aiTasks.enqueue(blockId, props.userId, { _tag: 'fix-sql' })
     }
-  }, [props.block, hasOaiKey])
-
-  const isAIEditing = isSQLBlockAIEditing(props.block)
+  }, [props.aiTasks, blockId, props.userId, hasOaiKey, aiTask])
 
   const [copied, setCopied] = useState(false)
   useEffect(() => {
@@ -293,11 +367,7 @@ function SQLBlock(props: Props) {
   }, [copied, setCopied])
 
   const diffButtonsVisible =
-    !props.isPublicMode &&
-    aiSuggestions !== null &&
-    (status === 'idle' ||
-      status === 'running-suggestion' ||
-      status === 'try-suggestion-requested')
+    !props.isPublicMode && aiSuggestions !== null && status._tag === 'idle'
 
   const router = useRouter()
   const onAddDataSource = useCallback(() => {
@@ -381,7 +451,7 @@ function SQLBlock(props: Props) {
     if (!result) {
       return (
         <div className="flex items-center justify-center h-full">
-          {status !== 'idle' ? (
+          {status._tag !== 'idle' ? (
             <LargeSpinner color="#b8f229" />
           ) : (
             <div className="text-gray-500">No query results</div>
@@ -400,7 +470,7 @@ function SQLBlock(props: Props) {
         dataframeName={dataframeName?.value ?? ''}
         isResultHidden={isResultHidden ?? false}
         toggleResultHidden={toggleResultHidden}
-        isFixingWithAI={isFixingSQLWithAI(props.block)}
+        isFixingWithAI={isAIFixing}
         onFixWithAI={onFixWithAI}
         dashboardMode={props.dashboardMode}
         canFixWithAI={hasOaiKey}
@@ -475,6 +545,9 @@ function SQLBlock(props: Props) {
                 <DataframeNameInput
                   disabled={!props.isEditable || statusIsDisabled}
                   block={props.block}
+                  environmentStartedAt={environmentStartedAt}
+                  userId={props.userId}
+                  executionQueue={props.executionQueue}
                 />
                 <HeaderSelect
                   hidden={props.isPublicMode}
@@ -544,7 +617,7 @@ function SQLBlock(props: Props) {
           </div>
           <ApproveDiffButons
             visible={diffButtonsVisible}
-            canTry={status === 'idle'}
+            canTry={status._tag === 'idle'}
             onTry={onTry}
             onAccept={onAcceptAISuggestion}
             onReject={onRejectAISuggestion}
@@ -570,7 +643,7 @@ function SQLBlock(props: Props) {
                 {!props.isPublicMode &&
                   aiSuggestions === null &&
                   props.isEditable &&
-                  !isFixingSQLWithAI(props.block) && (
+                  !isAIFixing && (
                     <button
                       disabled={!props.isEditable}
                       onClick={onToggleEditWithAIPromptOpen}
@@ -625,7 +698,7 @@ function SQLBlock(props: Props) {
             dataframeName={dataframeName?.value ?? ''}
             isResultHidden={isResultHidden ?? false}
             toggleResultHidden={toggleResultHidden}
-            isFixingWithAI={isFixingSQLWithAI(props.block)}
+            isFixingWithAI={isAIFixing}
             onFixWithAI={onFixWithAI}
             dashboardMode={props.dashboardMode}
             canFixWithAI={hasOaiKey}
@@ -641,21 +714,23 @@ function SQLBlock(props: Props) {
       >
         <button
           onClick={onRunAbort}
-          disabled={status !== 'idle' && status !== 'running'}
+          disabled={status._tag !== 'idle' && status._tag !== 'running'}
           className={clsx(
             {
               'bg-gray-200 cursor-not-allowed':
-                status !== 'idle' && status !== 'running',
-              'bg-red-200': status === 'running' && envStatus === 'Running',
-              'bg-yellow-300': status === 'running' && envStatus !== 'Running',
-              'bg-primary-200': status === 'idle',
+                status._tag !== 'idle' && status._tag !== 'running',
+              'bg-red-200':
+                status._tag === 'running' && envStatus === 'Running',
+              'bg-yellow-300':
+                status._tag === 'running' && envStatus !== 'Running',
+              'bg-primary-200': status._tag === 'idle',
             },
             'rounded-sm h-6 min-w-6 flex items-center justify-center relative group'
           )}
         >
-          {status !== 'idle' ? (
+          {status._tag !== 'idle' ? (
             <div>
-              {execStatus === 'enqueued' ? (
+              {status._tag === 'enqueued' ? (
                 <ClockIcon className="w-3 h-3 text-gray-500" />
               ) : (
                 <StopIcon className="w-3 h-3 text-gray-500" />
@@ -663,8 +738,8 @@ function SQLBlock(props: Props) {
               <SQLExecTooltip
                 envStatus={envStatus}
                 envLoading={envLoading}
-                execStatus={execStatus}
-                status={status}
+                execStatus={status._tag === 'enqueued' ? 'enqueued' : 'running'}
+                runningAll={execution?.batch.isRunAll() ?? false}
               />
             </div>
           ) : props.dataSources.size > 0 || headerSelectValue === 'duckdb' ? (
diff --git a/apps/web/src/components/v2Editor/customBlocks/visualization/index.tsx b/apps/web/src/components/v2Editor/customBlocks/visualization/index.tsx
index 80dcb6c9..939e5d8e 100644
--- a/apps/web/src/components/v2Editor/customBlocks/visualization/index.tsx
+++ b/apps/web/src/components/v2Editor/customBlocks/visualization/index.tsx
@@ -1,15 +1,18 @@
 import { v4 as uuidv4 } from 'uuid'
+
 import { VisualizationSpec } from 'react-vega'
 import { ArrowPathIcon, ClockIcon, StopIcon } from '@heroicons/react/20/solid'
 import * as Y from 'yjs'
 import {
   type VisualizationBlock,
-  getVisualizationBlockExecStatus,
   BlockType,
   isVisualizationBlock,
-  execStatusIsDisabled,
   getVisualizationAttributes,
   getDataframe,
+  ExecutionQueue,
+  isExecutionStatusLoading,
+  YBlockGroup,
+  YBlock,
 } from '@briefer/editor'
 import { ApiDocument } from '@briefer/database'
 import { FunnelIcon } from '@heroicons/react/24/outline'
@@ -28,17 +31,19 @@ import {
   isInvalidVisualizationFilter,
   NumpyDateTypes,
   YAxis,
+  exhaustiveCheck,
 } from '@briefer/types'
 import VisualizationControls from './VisualizationControls'
 import VisualizationView from './VisualizationView'
 import { ConnectDragPreview } from 'react-dnd'
-import { equals } from 'ramda'
+import { equals, head } from 'ramda'
 import { useEnvironmentStatus } from '@/hooks/useEnvironmentStatus'
 import { VisualizationExecTooltip } from '../../ExecTooltip'
 import useFullScreenDocument from '@/hooks/useFullScreenDocument'
 import HiddenInPublishedButton from '../../HiddenInPublishedButton'
 import useEditorAwareness from '@/hooks/useEditorAwareness'
 import { downloadFile } from '@/utils/file'
+import { useBlockExecutions } from '@/hooks/useBlockExecution'
 
 function didChangeFilters(
   oldFilters: VisualizationFilter[],
@@ -72,6 +77,7 @@ interface Props {
   document: ApiDocument
   dataframes: Y.Map<DataFrame>
   block: Y.XmlElement<VisualizationBlock>
+  blocks: Y.Map<YBlock>
   dragPreview: ConnectDragPreview | null
   isEditable: boolean
   isPublicMode: boolean
@@ -80,7 +86,6 @@ interface Props {
     blockType: BlockType,
     position: 'before' | 'after'
   ) => void
-  onRun: (block: Y.XmlElement<VisualizationBlock>) => void
   isDashboard: boolean
   renderer?: 'canvas' | 'svg'
   hasMultipleTabs: boolean
@@ -88,6 +93,8 @@ interface Props {
   onToggleIsBlockHiddenInPublished: (blockId: string) => void
   isCursorWithin: boolean
   isCursorInserting: boolean
+  executionQueue: ExecutionQueue
+  userId: string | null
 }
 function VisualizationBlock(props: Props) {
   const dataframe = getDataframe(props.block, props.dataframes)
@@ -112,7 +119,6 @@ function VisualizationBlock(props: Props) {
     title,
     xAxis,
     xAxisName,
-    status,
     filters,
     controlsHidden,
     chartType,
@@ -155,18 +161,55 @@ function VisualizationBlock(props: Props) {
     [props.block]
   )
 
-  const isEditable =
-    props.isEditable &&
-    status !== 'run-all-enqueued' &&
-    status !== 'run-all-running'
-  const execStatus = getVisualizationBlockExecStatus(props.block)
+  const executions = useBlockExecutions(
+    props.executionQueue,
+    props.block,
+    'visualization'
+  )
+  const execution = head(executions) ?? null
+  const status = execution?.item.getStatus()._tag ?? 'idle'
+
+  const {
+    status: envStatus,
+    loading: envLoading,
+    startedAt: environmentStartedAt,
+  } = useEnvironmentStatus(props.document.workspaceId)
+
+  const onRun = useCallback(() => {
+    executions.forEach((e) => e.item.setAborting())
+    props.executionQueue.enqueueBlock(
+      blockId,
+      props.userId,
+      environmentStartedAt,
+      {
+        _tag: 'visualization',
+      }
+    )
+  }, [
+    executions,
+    blockId,
+    props.executionQueue,
+    environmentStartedAt,
+    props.userId,
+  ])
+
   const onRunAbort = useCallback(() => {
-    if (status === 'running') {
-      props.block.setAttribute('status', 'abort-requested')
-    } else {
-      props.onRun(props.block)
+    switch (status) {
+      case 'enqueued':
+      case 'running':
+        execution?.item.setAborting()
+        break
+      case 'idle':
+      case 'unknown':
+      case 'completed':
+        onRun()
+        break
+      case 'aborting':
+        break
+      default:
+        exhaustiveCheck(status)
     }
-  }, [props.block, props.onRun, status])
+  }, [status, execution, onRun])
 
   const onAddFilter = useCallback(() => {
     const newFilter: VisualizationFilter = {
@@ -375,14 +418,10 @@ function VisualizationBlock(props: Props) {
 
   useEffect(() => {
     if (isDirty) {
-      props.onRun(props.block)
+      onRun()
       setIsDirty(false)
     }
-  }, [isDirty, props.block, props.onRun])
-
-  const { status: envStatus, loading: envLoading } = useEnvironmentStatus(
-    props.document.workspaceId
-  )
+  }, [isDirty, props.block, onRun])
 
   const [isFullscreen] = useFullScreenDocument(props.document.id)
 
@@ -404,26 +443,27 @@ function VisualizationBlock(props: Props) {
     [props.block]
   )
 
-  useEffect(() => {
-    if (status === 'running' || status === 'run-requested') {
-      // 30 seconds timeout
-      const timeout = setTimeout(() => {
-        const status = props.block.getAttribute('status')
-        if (status === 'running') {
-          props.block.setAttribute('status', 'run-requested')
-        } else if (status === 'run-requested') {
-          props.block.setAttribute('status', 'idle')
-          requestAnimationFrame(() => {
-            props.block.setAttribute('status', 'run-requested')
-          })
-        }
-      }, 1000 * 30)
-
-      return () => {
-        clearTimeout(timeout)
-      }
-    }
-  }, [props.block, status])
+  // TODO
+  // useEffect(() => {
+  //   if (status === 'running' || status === 'run-requested') {
+  //     // 30 seconds timeout
+  //     const timeout = setTimeout(() => {
+  //       const status = props.block.getAttribute('status')
+  //       if (status === 'running') {
+  //         props.block.setAttribute('status', 'run-requested')
+  //       } else if (status === 'run-requested') {
+  //         props.block.setAttribute('status', 'idle')
+  //         requestAnimationFrame(() => {
+  //           props.block.setAttribute('status', 'run-requested')
+  //         })
+  //       }
+  //     }, 1000 * 30)
+
+  //     return () => {
+  //       clearTimeout(timeout)
+  //     }
+  //   }
+  // }, [props.block, status])
 
   const onToggleIsBlockHiddenInPublished = useCallback(() => {
     props.onToggleIsBlockHiddenInPublished(blockId)
@@ -434,6 +474,8 @@ function VisualizationBlock(props: Props) {
     editorAPI.insert(blockId, { scrollIntoView: false })
   }, [blockId, editorAPI.insert])
 
+  const viewLoading = isExecutionStatusLoading(status)
+
   if (props.isDashboard) {
     return (
       <VisualizationView
@@ -442,7 +484,7 @@ function VisualizationBlock(props: Props) {
         spec={spec}
         tooManyDataPointsHidden={tooManyDataPointsHidden}
         onHideTooManyDataPointsWarning={onHideTooManyDataPointsWarning}
-        loading={execStatus === 'loading'}
+        loading={viewLoading}
         error={error}
         dataframe={dataframe}
         onNewSQL={onNewSQL}
@@ -453,7 +495,7 @@ function VisualizationBlock(props: Props) {
         onToggleHidden={onToggleHidden}
         onExportToPNG={onExportToPNG}
         isDashboard={props.isDashboard}
-        isEditable={isEditable}
+        isEditable={props.isEditable}
       />
     )
   }
@@ -480,7 +522,7 @@ function VisualizationBlock(props: Props) {
             <div className="flex gap-x-4 h-full w-full">
               <input
                 type="text"
-                disabled={!isEditable}
+                disabled={!props.isEditable}
                 className={clsx(
                   'font-sans bg-transparent pl-1 ring-gray-200 focus:ring-gray-400 block w-full rounded-md border-0 text-gray-500 disabled:ring-0 hover:ring-1 focus:ring-1 ring-inset placeholder:text-gray-400 focus:ring-inset h-full py-0 text-xs h-full'
                 )}
@@ -495,7 +537,7 @@ function VisualizationBlock(props: Props) {
                     props.isPublicMode ? 'hidden' : 'inline-block'
                   )}
                   onClick={onAddFilter}
-                  disabled={!isEditable}
+                  disabled={!props.isEditable}
                 >
                   <FunnelIcon className="h-3 w-3" />
                   <span>Add filter</span>
@@ -506,7 +548,7 @@ function VisualizationBlock(props: Props) {
                   options={dataframeOptions}
                   onAdd={onNewSQL}
                   onAddLabel="New query"
-                  disabled={!isEditable}
+                  disabled={!props.isEditable}
                 />
               </div>
             </div>
@@ -536,7 +578,7 @@ function VisualizationBlock(props: Props) {
                   ) ||
                     isInvalidVisualizationFilter(filter, dataframe)))
               }
-              disabled={!isEditable}
+              disabled={!props.isEditable}
             />
           ))}
         </div>
@@ -564,7 +606,7 @@ function VisualizationBlock(props: Props) {
             onChangeNumberValuesFormat={onChangeNumberValuesFormat}
             showDataLabels={showDataLabels}
             onChangeShowDataLabels={onChangeShowDataLabels}
-            isEditable={isEditable}
+            isEditable={props.isEditable}
           />
           <VisualizationView
             title={title}
@@ -572,7 +614,7 @@ function VisualizationBlock(props: Props) {
             spec={spec}
             tooManyDataPointsHidden={tooManyDataPointsHidden}
             onHideTooManyDataPointsWarning={onHideTooManyDataPointsWarning}
-            loading={execStatus === 'loading'}
+            loading={viewLoading}
             error={error}
             dataframe={dataframe}
             onNewSQL={onNewSQL}
@@ -583,7 +625,7 @@ function VisualizationBlock(props: Props) {
             onToggleHidden={onToggleHidden}
             onExportToPNG={onExportToPNG}
             isDashboard={props.isDashboard}
-            isEditable={isEditable}
+            isEditable={props.isEditable}
           />
         </div>
       </div>
@@ -591,7 +633,7 @@ function VisualizationBlock(props: Props) {
       <div
         className={clsx(
           'absolute transition-opacity opacity-0 group-hover/block:opacity-100 right-0 translate-x-full pl-1.5 top-0 flex flex-col gap-y-1',
-          execStatusIsDisabled(execStatus) ? 'opacity-100' : 'opacity-0',
+          viewLoading ? 'opacity-100' : 'opacity-0',
           {
             hidden: !props.isEditable,
           }
@@ -613,13 +655,13 @@ function VisualizationBlock(props: Props) {
             !dataframe ||
             (!xAxis && chartType !== 'number' && chartType !== 'trend') ||
             (!hasAValidYAxis && chartType !== 'histogram') ||
-            !isEditable ||
+            !props.isEditable ||
             (status !== 'idle' && status !== 'running')
           }
         >
           {status !== 'idle' ? (
             <div>
-              {execStatus === 'enqueued' ? (
+              {status === 'enqueued' ? (
                 <ClockIcon className="w-3 h-3 text-gray-500" />
               ) : (
                 <StopIcon className="w-3 h-3 text-gray-500" />
@@ -627,8 +669,8 @@ function VisualizationBlock(props: Props) {
               <VisualizationExecTooltip
                 envStatus={envStatus}
                 envLoading={envLoading}
-                execStatus={execStatus}
-                status={status}
+                execStatus={status === 'enqueued' ? 'enqueued' : 'running'}
+                runningAll={execution?.batch.isRunAll() ?? false}
               />
             </div>
           ) : (
diff --git a/apps/web/src/components/v2Editor/customBlocks/writeback/WritebackResult/index.tsx b/apps/web/src/components/v2Editor/customBlocks/writeback/WritebackResult/index.tsx
index 9b2e7983..3789416b 100644
--- a/apps/web/src/components/v2Editor/customBlocks/writeback/WritebackResult/index.tsx
+++ b/apps/web/src/components/v2Editor/customBlocks/writeback/WritebackResult/index.tsx
@@ -1,18 +1,18 @@
 import { DataSource } from '@briefer/database'
-import { ExecStatus } from '@briefer/editor'
 import WritebackErrorResult from './WritebackErrorResult'
 import WritebackSuccessResult from './WritebackSuccessResult'
 import { DatabaseZapIcon } from 'lucide-react'
 import LargeSpinner from '@/components/LargeSpinner'
 import { WriteBackResult } from '@briefer/types'
+import { ExecutionStatus, isExecutionStatusLoading } from '@briefer/editor'
 
 interface Props {
-  status: ExecStatus
+  status: ExecutionStatus
   result: WriteBackResult | null
   dataSources: DataSource[]
 }
 function WritebackResult(props: Props) {
-  if (props.status === 'loading' || props.status === 'enqueued') {
+  if (isExecutionStatusLoading(props.status)) {
     return <Loading />
   }
 
diff --git a/apps/web/src/components/v2Editor/customBlocks/writeback/index.tsx b/apps/web/src/components/v2Editor/customBlocks/writeback/index.tsx
index 35b663be..ab4d23ad 100644
--- a/apps/web/src/components/v2Editor/customBlocks/writeback/index.tsx
+++ b/apps/web/src/components/v2Editor/customBlocks/writeback/index.tsx
@@ -1,8 +1,10 @@
 import { APIDataSources } from '@/hooks/useDatasources'
 import {
   execStatusIsDisabled,
+  ExecutionQueue,
   getWritebackAttributes,
   getWritebackBlockExecStatus,
+  getWritebackBlockResultStatus,
   type WritebackBlock,
 } from '@briefer/editor'
 import {
@@ -25,6 +27,8 @@ import { WritebackExecTooltip } from '../../ExecTooltip'
 import { XCircleIcon } from 'lucide-react'
 import { CheckCircleIcon } from '@heroicons/react/24/outline'
 import useEditorAwareness from '@/hooks/useEditorAwareness'
+import { useBlockExecutions } from '@/hooks/useBlockExecution'
+import { head } from 'ramda'
 
 interface Props {
   workspaceId: string
@@ -38,16 +42,19 @@ interface Props {
   onToggleIsBlockHiddenInPublished: (blockId: string) => void
   isCursorWithin: boolean
   isCursorInserting: boolean
+  userId: string | null
+  executionQueue: ExecutionQueue
 }
 function WritebackBlock(props: Props) {
-  const { status: envStatus, loading: envLoading } = useEnvironmentStatus(
-    props.workspaceId
-  )
+  const {
+    status: envStatus,
+    loading: envLoading,
+    startedAt: environmentStartedAt,
+  } = useEnvironmentStatus(props.workspaceId)
 
   const {
     id: blockId,
     title,
-    status,
     tableName,
     isCollapsed,
     dataframeName,
@@ -100,15 +107,40 @@ function WritebackBlock(props: Props) {
     props.onToggleIsBlockHiddenInPublished(blockId)
   }, [props.onToggleIsBlockHiddenInPublished, blockId])
 
-  const execStatus = getWritebackBlockExecStatus(props.block)
+  const execStatus = getWritebackBlockExecStatus(
+    props.block,
+    props.executionQueue
+  )
+  const resultStatus = getWritebackBlockResultStatus(props.block)
   const statusIsDisabled = execStatusIsDisabled(execStatus)
 
+  const executions = useBlockExecutions(
+    props.executionQueue,
+    props.block,
+    'writeback'
+  )
+  const execution = head(executions)
+  const status = execution?.item.getStatus()._tag ?? 'idle'
   const onRunAbort = useCallback(() => {
-    props.block.setAttribute(
-      'status',
-      status === 'idle' ? 'running' : 'aborting'
-    )
-  }, [props.block])
+    if (execution) {
+      execution.item.setAborting()
+    } else {
+      props.executionQueue.enqueueBlock(
+        blockId,
+        props.userId,
+        environmentStartedAt,
+        {
+          _tag: 'writeback',
+        }
+      )
+    }
+  }, [
+    blockId,
+    environmentStartedAt,
+    execution,
+    props.executionQueue,
+    props.userId,
+  ])
 
   const onChangeOnConflictColumns = useCallback(
     (value: string[]) => {
@@ -166,7 +198,7 @@ function WritebackBlock(props: Props) {
                 disabled={!props.isEditable}
               />
               {isCollapsed &&
-                (execStatus === 'success' ? (
+                (resultStatus === 'success' ? (
                   <CheckCircleIcon className="w-4 h-4 text-green-600" />
                 ) : (
                   <XCircleIcon className="w-4 h-4 text-red-500" />
@@ -219,10 +251,13 @@ function WritebackBlock(props: Props) {
           className={clsx(
             {
               'bg-gray-200 cursor-not-allowed':
-                status !== 'idle' && status !== 'running',
+                status === 'enqueued' || status === 'aborting',
               'bg-red-200': status === 'running' && envStatus === 'Running',
               'bg-yellow-300': status === 'running' && envStatus !== 'Running',
-              'bg-primary-200': status === 'idle',
+              'bg-primary-200':
+                status === 'idle' ||
+                status === 'completed' ||
+                status === 'unknown',
             },
             'rounded-sm h-6 min-w-6 flex items-center justify-center relative group'
           )}
@@ -237,8 +272,8 @@ function WritebackBlock(props: Props) {
               <WritebackExecTooltip
                 envStatus={envStatus}
                 envLoading={envLoading}
-                execStatus={execStatus}
-                status={status}
+                execStatus={execStatus === 'enqueued' ? 'enqueued' : 'running'}
+                runningAll={execution?.batch.isRunAll() ?? false}
               />
             </div>
           ) : (
diff --git a/apps/web/src/components/v2Editor/index.tsx b/apps/web/src/components/v2Editor/index.tsx
index af9c1e6a..aff599b8 100644
--- a/apps/web/src/components/v2Editor/index.tsx
+++ b/apps/web/src/components/v2Editor/index.tsx
@@ -29,15 +29,11 @@ import {
   groupBlocks,
   BlockType,
   addGroupedBlock,
-  getExecStatus,
-  requestRun,
-  execStatusIsDisabled,
   switchBlockType,
   getPrettyTitle,
   duplicateTab,
   duplicateBlockGroup,
   removeBlock,
-  requestTrySuggestion,
   addBlockGroupAfterBlock,
   getBaseAttributes,
   TabRef as TabRefT,
@@ -55,6 +51,8 @@ import {
   RemoveBlockDashboardConflictResult,
   getNextBlockIdAfterDelete,
   isRunnableBlock,
+  ExecutionQueue,
+  AITasks,
 } from '@briefer/editor'
 import EnvBar from '../EnvBar'
 import PlusButton from './PlusButton'
@@ -211,6 +209,7 @@ export function getTabIcon(
 
 interface TabProps {
   tabRef: TabRefT
+  blocks: Y.Map<YBlock>
   onSwitchActiveTab: (tabId: string) => void
   onReorderTab: (
     blockGroupId: string,
@@ -226,6 +225,7 @@ interface TabProps {
   ) => boolean
   isFirst?: boolean
   isDraggable: boolean
+  executionQueue: ExecutionQueue
 }
 function Tab(props: TabProps) {
   const buttonRef = useRef<HTMLButtonElement>(null)
@@ -373,7 +373,11 @@ function Tab(props: TabProps) {
             </span>
           )}
         </div>
-        <ExecIndicator execStatus={props.tabRef.execStatus} />
+        <ExecIndicator
+          tabRef={props.tabRef}
+          blocks={props.blocks}
+          executionQueue={props.executionQueue}
+        />
       </button>
       {draggingSide === 'right' && isOver && canDrop && (
         <div className={`bg-ceramic-100`} style={{ width: `${dragSize}px` }} />
@@ -411,6 +415,9 @@ const DraggableTabbedBlock = (props: {
   isPDF: boolean
   onSchemaExplorer: (dataSourceId: string | null) => void
   insertBelow: () => void
+  userId: string | null
+  executionQueue: ExecutionQueue
+  aiTasks: AITasks
 }) => {
   const { state: layout } = useYDocState<Y.Array<YBlockGroup>>(
     props.yDoc,
@@ -483,37 +490,6 @@ const DraggableTabbedBlock = (props: {
     [props.onAddGroupedBlock, props.id]
   )
 
-  const onRun = useCallback(
-    <B extends YBlock>(block: B, customCallback?: (block: B) => void) => {
-      requestRun(
-        block,
-        blocks.value,
-        layout.value,
-        environmentStartedAt,
-        !props.document.runUnexecutedBlocks,
-        customCallback
-      )
-    },
-    [
-      blocks.value,
-      layout.value,
-      props.document.runUnexecutedBlocks,
-      environmentStartedAt?.toISOString(),
-    ]
-  )
-
-  const onTry = useCallback(
-    (block: YBlock) => {
-      requestTrySuggestion(
-        block,
-        blocks.value,
-        layout.value,
-        environmentStartedAt
-      )
-    },
-    [blocks.value, layout.value, environmentStartedAt]
-  )
-
   const onFileUploadBlockPythonUsage = useCallback(
     (block: Y.XmlElement<FileUploadBlock>, filename: string, type: string) => {
       const fileExtension =
@@ -551,15 +527,14 @@ file`
         return
       }
 
-      requestRun(
+      props.executionQueue.enqueueBlock(
         pythonBlock,
-        blocks.value,
-        layout.value,
+        props.userId,
         environmentStartedAt,
-        true
+        { _tag: 'python', isSuggestion: false }
       )
     },
-    [blocks, layout, environmentStartedAt]
+    [layout, blocks, props.executionQueue, props.userId, environmentStartedAt]
   )
 
   const onFileUploadBlockQueryUsage = useCallback(
@@ -595,15 +570,14 @@ file`
         return
       }
 
-      requestRun(
+      props.executionQueue.enqueueBlock(
         sqlBlock,
-        blocks.value,
-        layout.value,
+        props.userId,
         environmentStartedAt,
-        true
+        { _tag: 'sql', isSuggestion: false, selectedCode: null }
       )
     },
-    []
+    [layout, blocks, props.executionQueue, props.userId, environmentStartedAt]
   )
 
   const onToggleIsBlockHiddenInPublished = useCallback(
@@ -641,8 +615,6 @@ file`
         isPublicViewer={props.isPublicViewer}
         document={props.document}
         dataSources={props.dataSources}
-        onRun={onRun}
-        onTry={onTry}
         onToggleIsBlockHiddenInPublished={onToggleIsBlockHiddenInPublished}
         onSchemaExplorer={props.onSchemaExplorer}
         insertBelow={props.insertBelow}
@@ -654,6 +626,9 @@ file`
         onFileUploadBlockQueryUsage={onFileUploadBlockQueryUsage}
         currentBlockId={currentBlockId}
         dragPreview={dragPreview}
+        userId={props.userId}
+        executionQueue={props.executionQueue}
+        aiTasks={props.aiTasks}
       />
     ))
   }, [
@@ -665,8 +640,6 @@ file`
     props.isPublicViewer,
     props.document,
     props.dataSources,
-    onRun,
-    onTry,
     onToggleIsBlockHiddenInPublished,
     props.onSchemaExplorer,
     props.insertBelow,
@@ -678,6 +651,8 @@ file`
     onFileUploadBlockQueryUsage,
     currentBlockId,
     dragPreview,
+    props.userId,
+    props.executionQueue,
   ])
 
   const onSwitchActiveTab = useCallback(
@@ -724,63 +699,50 @@ file`
   }, [currentBlockId, props.id, props.onRemoveBlock])
 
   const runAllTabs = useCallback(() => {
-    tabRefs.forEach((tab, i) => {
-      const block = blocks.value.get(tab.blockId)
-      if (!block) {
-        return
-      }
-
-      if (!execStatusIsDisabled(getExecStatus(block, blocks.value))) {
-        requestRun(
-          block,
-          blocks.value,
-          layout.value,
-          environmentStartedAt,
-          i > 0
-        )
-      }
-    })
-  }, [blocks, layout, tabRefs, environmentStartedAt])
+    props.executionQueue.enqueueBlockGroup(props.yDoc, props.id, props.userId)
+  }, [props.executionQueue, props.yDoc, props.id])
 
   const runBelowBlock = useCallback(() => {
-    const currentBlockGroupIndex = layout.value.toArray().findIndex((bg) => {
-      return bg.getAttribute('id') === props.id
-    })
-    const currentTabIndex = tabRefs.findIndex((tab) => tab.isCurrent)
+    const blockGroup = getBlockGroup(layout.value, props.id)
+    if (!blockGroup) {
+      return
+    }
 
-    if (currentBlockGroupIndex === -1 || currentTabIndex === -1) {
+    const current = blockGroup.getAttribute('current')
+    if (!current) {
       return
     }
 
-    const blocksBelow = layout.value.toArray().slice(currentBlockGroupIndex)
-    for (const blockGroup of blocksBelow) {
-      const currBlockGroupId = blockGroup.getAttribute('id')
-      const tabs = getTabsFromBlockGroup(blockGroup, blocks.value)
+    const currentBlockId = current.getAttribute('id')
+    if (!currentBlockId) {
+      return
+    }
 
-      tabs.forEach((tab, i) => {
-        const tabBlock = blocks.value.get(tab.blockId)
-        if (
-          !tabBlock ||
-          (i < currentTabIndex && currBlockGroupId === props.id)
-        ) {
-          return
-        }
+    const block = blocks.value.get(currentBlockId)
+    if (!block) {
+      return
+    }
 
-        if (!execStatusIsDisabled(getExecStatus(tabBlock, blocks.value))) {
-          requestRun(
-            tabBlock,
-            blocks.value,
-            layout.value,
-            environmentStartedAt,
-            // we must skip dependency checks because running any blocks above
-            // is counterintuitive given the user's intention is to run the
-            // blocks below
-            true
-          )
-        }
-      })
+    const metadata =
+      props.executionQueue.getExecutionQueueMetadataForBlock(block)
+    if (!metadata) {
+      return
     }
-  }, [blocks, layout, tabRefs, environmentStartedAt])
+
+    props.executionQueue.enqueueBlockOnwards(
+      currentBlockId,
+      props.userId,
+      environmentStartedAt,
+      metadata
+    )
+  }, [
+    layout,
+    blocks,
+    props.executionQueue,
+    props.id,
+    props.userId,
+    environmentStartedAt,
+  ])
 
   const popupContainerRef = useRef<HTMLDivElement>(null)
 
@@ -891,6 +853,8 @@ file`
                   onReorderTab={onReorderTab}
                   onCheckCanReorderTab={onCheckCanReorderTab}
                   isDraggable={hasMultipleTabs && !props.isApp}
+                  blocks={blocks.value}
+                  executionQueue={props.executionQueue}
                 />
               ))}
               {isScrollable && !isScrolledAllTheWayRight && (
@@ -983,6 +947,9 @@ const V2EditorRow = (props: {
   writebackEnabled: boolean
   onSchemaExplorer: (dataSourceId: string | null) => void
   insertBelow: () => void
+  userId: string | null
+  executionQueue: ExecutionQueue
+  aiTasks: AITasks
 }) => {
   return (
     <div>
@@ -1015,6 +982,9 @@ const V2EditorRow = (props: {
         isPDF={props.isPDF}
         onSchemaExplorer={props.onSchemaExplorer}
         insertBelow={props.insertBelow}
+        userId={props.userId}
+        executionQueue={props.executionQueue}
+        aiTasks={props.aiTasks}
       />
       <Dropzone
         index={props.index + 1}
@@ -1046,6 +1016,8 @@ interface Props {
   onOpenFiles: () => void
   onSchemaExplorer: (dataSourceId: string | null) => void
   scrollViewRef: React.RefObject<HTMLDivElement>
+  executionQueue: ExecutionQueue
+  aiTasks: AITasks
 }
 const Editor = (props: Props) => {
   const { state: layout } = useYDocState<Y.Array<YBlockGroup>>(
@@ -1422,6 +1394,9 @@ const Editor = (props: Props) => {
           writebackEnabled={true}
           onSchemaExplorer={props.onSchemaExplorer}
           insertBelow={insertBelow}
+          userId={props.userId}
+          executionQueue={props.executionQueue}
+          aiTasks={props.aiTasks}
         />
       )
     })
@@ -1440,6 +1415,7 @@ const Editor = (props: Props) => {
     props.isPDF,
     props.isApp,
     props.onSchemaExplorer,
+    props.userId,
   ])
 
   const addBlockToBottom = useCallback(
@@ -1567,8 +1543,6 @@ interface TabRefProps {
   isPublicViewer: boolean
   document: ApiDocument
   dataSources: APIDataSources
-  onRun: (block: YBlock) => void
-  onTry: (block: YBlock) => void
   onToggleIsBlockHiddenInPublished: (blockId: string) => void
   onSchemaExplorer: (dataSourceId: string | null) => void
   insertBelow: () => void
@@ -1591,6 +1565,9 @@ interface TabRefProps {
   ) => void
   currentBlockId: string | undefined
   dragPreview: ConnectDragPreview | null
+  userId: string | null
+  executionQueue: ExecutionQueue
+  aiTasks: AITasks
 }
 function TabRef(props: TabRefProps) {
   const [editorState] = useEditorAwareness()
@@ -1624,8 +1601,6 @@ function TabRef(props: TabRefProps) {
         document={props.document}
         dataSources={props.dataSources}
         dragPreview={props.hasMultipleTabs ? null : props.dragPreview}
-        onRun={props.onRun}
-        onTry={props.onTry}
         dashboardMode="none"
         hasMultipleTabs={props.hasMultipleTabs}
         isBlockHiddenInPublished={props.tab.isHiddenInPublished}
@@ -1634,6 +1609,9 @@ function TabRef(props: TabRefProps) {
         }
         onSchemaExplorer={props.onSchemaExplorer}
         insertBelow={props.insertBelow}
+        userId={props.userId}
+        executionQueue={props.executionQueue}
+        aiTasks={props.aiTasks}
       />
     ),
     onPython: (block) => (
@@ -1644,8 +1622,6 @@ function TabRef(props: TabRefProps) {
         isEditable={props.isEditable}
         document={props.document}
         dragPreview={props.hasMultipleTabs ? null : props.dragPreview}
-        onRun={props.onRun}
-        onTry={props.onTry}
         isPDF={props.isPDF}
         dashboardPlace={null}
         hasMultipleTabs={props.hasMultipleTabs}
@@ -1654,6 +1630,9 @@ function TabRef(props: TabRefProps) {
           props.onToggleIsBlockHiddenInPublished
         }
         insertBelow={props.insertBelow}
+        userId={props.userId}
+        executionQueue={props.executionQueue}
+        aiTasks={props.aiTasks}
       />
     ),
     onVisualization: (block) => (
@@ -1663,9 +1642,9 @@ function TabRef(props: TabRefProps) {
         document={props.document}
         onAddGroupedBlock={props.addGroupedBlock}
         block={block}
+        blocks={props.blocks}
         dataframes={props.dataframes}
         dragPreview={props.hasMultipleTabs ? null : props.dragPreview}
-        onRun={props.onRun}
         isDashboard={false}
         hasMultipleTabs={props.hasMultipleTabs}
         isBlockHiddenInPublished={props.tab.isHiddenInPublished}
@@ -1674,6 +1653,8 @@ function TabRef(props: TabRefProps) {
         }
         isCursorWithin={isCursorWithin}
         isCursorInserting={isCursorInserting}
+        userId={props.userId}
+        executionQueue={props.executionQueue}
       />
     ),
     onInput: (block) => (
@@ -1684,10 +1665,12 @@ function TabRef(props: TabRefProps) {
         belongsToMultiTabGroup={props.hasMultipleTabs}
         isEditable={props.isEditable}
         isApp={props.isApp}
-        onRun={props.onRun}
         isDashboard={false}
         isCursorWithin={isCursorWithin}
         isCursorInserting={isCursorInserting}
+        userId={props.userId}
+        workspaceId={props.document.workspaceId}
+        executionQueue={props.executionQueue}
       />
     ),
     onDropdownInput: (block) => (
@@ -1699,10 +1682,12 @@ function TabRef(props: TabRefProps) {
         isEditable={props.isEditable}
         isApp={props.isApp}
         dataframes={props.dataframes}
-        onRun={props.onRun}
         isDashboard={false}
         isCursorWithin={isCursorWithin}
         isCursorInserting={isCursorInserting}
+        userId={props.userId}
+        workspaceId={props.document.workspaceId}
+        executionQueue={props.executionQueue}
       />
     ),
     onDateInput: (block) => (
@@ -1713,10 +1698,12 @@ function TabRef(props: TabRefProps) {
         belongsToMultiTabGroup={props.hasMultipleTabs}
         isEditable={props.isEditable}
         isApp={props.isApp}
-        onRun={props.onRun}
         isDashboard={false}
         isCursorWithin={isCursorWithin}
         isCursorInserting={isCursorInserting}
+        userId={props.userId}
+        workspaceId={props.document.workspaceId}
+        executionQueue={props.executionQueue}
       />
     ),
     onFileUpload: (block) => (
@@ -1758,6 +1745,8 @@ function TabRef(props: TabRefProps) {
         }
         isCursorWithin={isCursorWithin}
         isCursorInserting={isCursorInserting}
+        userId={props.userId}
+        executionQueue={props.executionQueue}
       />
     ),
     onPivotTable: (block) => (
@@ -1767,7 +1756,6 @@ function TabRef(props: TabRefProps) {
         blocks={props.blocks}
         hasMultipleTabs={props.hasMultipleTabs}
         isEditable={props.isEditable}
-        onRun={props.onRun}
         onAddGroupedBlock={props.addGroupedBlock}
         dragPreview={props.dragPreview}
         dataframes={props.dataframes}
@@ -1778,6 +1766,8 @@ function TabRef(props: TabRefProps) {
         dashboardMode="none"
         isCursorWithin={isCursorWithin}
         isCursorInserting={isCursorInserting}
+        userId={props.userId}
+        executionQueue={props.executionQueue}
       />
     ),
   })
diff --git a/apps/web/src/hooks/useAITasks.ts b/apps/web/src/hooks/useAITasks.ts
new file mode 100644
index 00000000..72100f9f
--- /dev/null
+++ b/apps/web/src/hooks/useAITasks.ts
@@ -0,0 +1,27 @@
+import {
+  AITaskItem,
+  AITaskItemMetadataWithoutNoop,
+  AITasks,
+  getBaseAttributes,
+  YBlock,
+} from '@briefer/editor'
+import { useEffect, useState } from 'react'
+
+export function useAITasks(
+  aiTasks: AITasks,
+  block?: YBlock,
+  tag?: AITaskItemMetadataWithoutNoop['_tag']
+): AITaskItem[] {
+  const blockId = block ? getBaseAttributes(block).id : ''
+  const [tasks, setTasks] = useState(() => aiTasks.getBlockTasks(blockId, tag))
+
+  useEffect(() => {
+    const clean = aiTasks.observe(() => {
+      setTasks(aiTasks.getBlockTasks(blockId, tag))
+    })
+
+    return clean
+  }, [aiTasks, blockId, tag])
+
+  return tasks
+}
diff --git a/apps/web/src/hooks/useBlockExecution.ts b/apps/web/src/hooks/useBlockExecution.ts
new file mode 100644
index 00000000..c3bfa8f3
--- /dev/null
+++ b/apps/web/src/hooks/useBlockExecution.ts
@@ -0,0 +1,29 @@
+import {
+  Execution,
+  ExecutionQueue,
+  ExecutionQueueItemMetadataWithoutNoop,
+  getBaseAttributes,
+  YBlock,
+} from '@briefer/editor'
+import { useEffect, useState } from 'react'
+
+export function useBlockExecutions(
+  queue: ExecutionQueue,
+  block?: YBlock,
+  tag?: ExecutionQueueItemMetadataWithoutNoop['_tag']
+): Execution[] {
+  const blockId = block ? getBaseAttributes(block).id : ''
+  const [executions, setExecutions] = useState(
+    queue.getBlockExecutions(blockId, tag)
+  )
+
+  useEffect(() => {
+    const clean = queue.observe(() => {
+      setExecutions(queue.getBlockExecutions(blockId, tag))
+    })
+
+    return clean
+  }, [queue, blockId, tag])
+
+  return executions
+}
diff --git a/apps/web/src/hooks/useEnvironmentStatus.tsx b/apps/web/src/hooks/useEnvironmentStatus.tsx
index 7d1a11bc..6a4cb83f 100644
--- a/apps/web/src/hooks/useEnvironmentStatus.tsx
+++ b/apps/web/src/hooks/useEnvironmentStatus.tsx
@@ -22,7 +22,7 @@ export type StateItem = {
   loading: boolean
   error: string | null
   status: EnvironmentStatus
-  startedAt: Date | null
+  startedAt: string | null
 }
 type State = Map<string, StateItem>
 
@@ -62,7 +62,7 @@ export function EnvironmentStatusProvider(props: Props) {
           loading: false,
           error: null,
           status: data.status,
-          startedAt: data.startedAt ? new Date(data.startedAt) : null,
+          startedAt: data.startedAt ? data.startedAt : null,
         })
       )
     }
@@ -139,7 +139,7 @@ type UseEnvironmentStatus = {
   loading: boolean
   error: string | null
   restart: () => void
-  startedAt: Date | null
+  startedAt: string | null
 }
 export function useEnvironmentStatus(
   workspaceId: string
diff --git a/apps/web/src/hooks/useRunAll.ts b/apps/web/src/hooks/useRunAll.ts
new file mode 100644
index 00000000..3f866178
--- /dev/null
+++ b/apps/web/src/hooks/useRunAll.ts
@@ -0,0 +1,89 @@
+import * as Y from 'yjs'
+import { useYDocState } from './useYDoc'
+import {
+  ExecutionQueue,
+  ExecutionStatus,
+  getBlocks,
+  getLayout,
+} from '@briefer/editor'
+import { useCallback, useEffect, useMemo, useState } from 'react'
+import { head } from 'ramda'
+
+type State = {
+  status: ExecutionStatus
+  total: number
+  remaining: number
+  failedBlockId: string | null
+}
+
+type UseRunAll = [
+  State,
+  {
+    run: () => void
+    abort: () => void
+  }
+]
+function useRunAll(
+  yDoc: Y.Doc,
+  executionQueue: ExecutionQueue,
+  userId: string
+): UseRunAll {
+  const { state: layout } = useYDocState(yDoc, getLayout)
+  const { state: blocks } = useYDocState(yDoc, getBlocks)
+  const [state, setState] = useState<State>({
+    status: 'idle',
+    total: 0,
+    remaining: 0,
+    failedBlockId: null,
+  })
+
+  useEffect(() => {
+    const cleanup = executionQueue.observe(() => {
+      const batches = executionQueue.getRunAllBatches()
+      const batch = head(batches)
+      if (!batch) {
+        setState((prev) => ({
+          ...prev,
+          status: 'idle',
+          total: 0,
+          remaining: 0,
+        }))
+        return
+      }
+
+      setState((prev) => ({
+        ...prev,
+        status: batch.status,
+        total: batch.length,
+        remaining: batch.remaining,
+      }))
+    })
+
+    return cleanup
+  }, [executionQueue])
+
+  const run = useCallback(() => {
+    const batch = executionQueue.enqueueRunAll(layout.value, blocks.value, {
+      _tag: 'user',
+      userId,
+    })
+    batch.waitForCompletion().then((failedBlockId) => {
+      setState({
+        status: batch.status,
+        total: batch.length,
+        remaining: batch.remaining,
+        failedBlockId,
+      })
+    })
+  }, [layout, blocks, userId, executionQueue])
+
+  const abort = useCallback(() => {
+    executionQueue.getRunAllBatches().forEach((batch) => {
+      batch.abort()
+    })
+  }, [executionQueue])
+
+  return useMemo(() => [state, { run, abort }], [state, run, abort])
+}
+
+export default useRunAll
diff --git a/apps/web/src/pages/workspaces/[workspaceId]/data-sources/new/index.tsx b/apps/web/src/pages/workspaces/[workspaceId]/data-sources/new/index.tsx
index c1421b8b..9841a1f0 100644
--- a/apps/web/src/pages/workspaces/[workspaceId]/data-sources/new/index.tsx
+++ b/apps/web/src/pages/workspaces/[workspaceId]/data-sources/new/index.tsx
@@ -5,6 +5,7 @@ import { CircleStackIcon, Cog8ToothIcon } from '@heroicons/react/24/outline'
 import Link from 'next/link'
 import { useStringQuery } from '@/hooks/useQueryArgs'
 import Files from '@/components/Files'
+import { useSession } from '@/hooks/useAuth'
 
 const pagePath = (workspaceId: string) => [
   { name: 'Configurations', icon: Cog8ToothIcon, href: '#', current: false },
@@ -47,6 +48,7 @@ export default function DataSourcesPage() {
   const onToggleFiles = useCallback(() => {
     setFilesOpen((prev) => !prev)
   }, [])
+  const session = useSession()
 
   return (
     <Layout pagePath={pagePath(workspaceId)}>
@@ -123,6 +125,7 @@ export default function DataSourcesPage() {
         workspaceId={workspaceId}
         visible={filesOpen}
         onHide={() => setFilesOpen(false)}
+        userId={session.data?.id ?? null}
       />
     </Layout>
   )
diff --git a/apps/web/src/pages/workspaces/[workspaceId]/environments/current/variables.tsx b/apps/web/src/pages/workspaces/[workspaceId]/environments/current/variables.tsx
index 82dffe63..faf7c164 100644
--- a/apps/web/src/pages/workspaces/[workspaceId]/environments/current/variables.tsx
+++ b/apps/web/src/pages/workspaces/[workspaceId]/environments/current/variables.tsx
@@ -241,6 +241,7 @@ export default function EnvirontVariablesPage() {
           workspaceId={workspaceId}
           visible={filesOpen}
           onHide={() => setFilesOpen(false)}
+          userId={session.data?.id ?? null}
         />
         <EnvBar
           isViewer={session.data?.roles[workspaceId] === 'viewer'}
diff --git a/apps/web/src/pages/workspaces/[workspaceId]/settings/index.tsx b/apps/web/src/pages/workspaces/[workspaceId]/settings/index.tsx
index 93fc778a..697a859c 100644
--- a/apps/web/src/pages/workspaces/[workspaceId]/settings/index.tsx
+++ b/apps/web/src/pages/workspaces/[workspaceId]/settings/index.tsx
@@ -8,7 +8,7 @@ import React, { useMemo, useState } from 'react'
 import Layout from '@/components/Layout'
 import { useStringQuery } from '@/hooks/useQueryArgs'
 import { useSession } from '@/hooks/useAuth'
-import { ApiWorkspace, UserWorkspaceRole } from '@briefer/database'
+import type { ApiWorkspace } from '@briefer/database'
 import clsx from 'clsx'
 import { useWorkspaces } from '@/hooks/useWorkspaces'
 import { useUsers } from '@/hooks/useUsers'
@@ -50,7 +50,7 @@ export default function SettingsPage() {
     newOpenAIKey: '',
   })
 
-  const isAdmin = session.data?.roles[workspaceId] === UserWorkspaceRole.admin
+  const isAdmin = session.data?.roles[workspaceId] === 'admin'
   const [workspaces, { updateSettings }] = useWorkspaces()
   const currentWorkspace: ApiWorkspace | undefined = useMemo(
     () => workspaces.data.find((w) => w.id === workspaceId),
diff --git a/apps/web/src/pages/workspaces/[workspaceId]/users/index.tsx b/apps/web/src/pages/workspaces/[workspaceId]/users/index.tsx
index 743d1959..fc2f122c 100644
--- a/apps/web/src/pages/workspaces/[workspaceId]/users/index.tsx
+++ b/apps/web/src/pages/workspaces/[workspaceId]/users/index.tsx
@@ -8,7 +8,7 @@ import Layout from '@/components/Layout'
 import { useUsers } from '@/hooks/useUsers'
 import { useStringQuery } from '@/hooks/useQueryArgs'
 import { useSession } from '@/hooks/useAuth'
-import { UserWorkspaceRole } from '@briefer/database'
+import type { UserWorkspaceRole } from '@briefer/database'
 import clsx from 'clsx'
 import { Tooltip } from '@/components/Tooltips'
 import { PasswordDialog } from './new'
@@ -30,7 +30,7 @@ export default function UsersPage() {
   const session = useSession()
   const router = useRouter()
 
-  const isAdmin = session.data?.roles[workspaceId] === UserWorkspaceRole.admin
+  const isAdmin = session.data?.roles[workspaceId] === 'admin'
 
   const [users, { removeUser, updateUser, resetPassword }] =
     useUsers(workspaceId)
@@ -106,7 +106,7 @@ export default function UsersPage() {
             onRemoveUser={removeUser}
             onChangeRole={onChangeRole}
             onResetPassword={onResetPassword}
-            role={session.data?.roles[workspaceId] ?? UserWorkspaceRole.viewer}
+            role={session.data?.roles[workspaceId] ?? 'viewer'}
           />
         </div>
       </ScrollBar>
diff --git a/docker-compose.dev.yaml b/docker-compose.dev.yaml
index b67dc9d3..bee80ef0 100644
--- a/docker-compose.dev.yaml
+++ b/docker-compose.dev.yaml
@@ -77,6 +77,17 @@ services:
     volumes:
       - ./data/mysql/ecommerce/data:/var/lib/mysql
 
+  haproxy:
+    image: haproxy:3.0
+    volumes:
+      - type: bind
+        source: ./haproxy.cfg
+        target: /usr/local/etc/haproxy/haproxy.cfg
+        volume:
+          nocopy: true
+    ports:
+      - '8080:8080'
+
 volumes:
   postgres_dev_data:
   ecommerce_data:
diff --git a/haproxy.cfg b/haproxy.cfg
new file mode 100644
index 00000000..3ce1dda8
--- /dev/null
+++ b/haproxy.cfg
@@ -0,0 +1,21 @@
+global
+    # No 'log' directive here
+    daemon
+
+defaults
+    mode http
+    timeout connect 5000ms
+    timeout client  50000ms
+    timeout server  50000ms
+    option httplog
+    option http-server-close
+
+frontend main
+    bind *:8080
+    default_backend servers
+
+backend servers
+    balance roundrobin
+    cookie SERVERID insert
+    server app1 host.docker.internal:8081 check cookie app1
+    server app2 host.docker.internal:8082 check cookie app2
diff --git a/packages/database/package.json b/packages/database/package.json
index 87d48195..f4c1165c 100644
--- a/packages/database/package.json
+++ b/packages/database/package.json
@@ -13,6 +13,7 @@
     "@aws-sdk/rds-signer": "^3.465.0",
     "@briefer/types": "*",
     "@prisma/client": "^5.4.2",
+    "pg": "^8.13.1",
     "prisma": "^5.4.2",
     "zod": "^3.23.8"
   },
diff --git a/packages/database/prisma/migrations/20241030205412_introduce_yjs_update_table/migration.sql b/packages/database/prisma/migrations/20241030205412_introduce_yjs_update_table/migration.sql
new file mode 100644
index 00000000..cf8eba06
--- /dev/null
+++ b/packages/database/prisma/migrations/20241030205412_introduce_yjs_update_table/migration.sql
@@ -0,0 +1,23 @@
+-- CreateTable
+CREATE TABLE "YjsUpdate" (
+    "id" UUID NOT NULL DEFAULT gen_random_uuid(),
+    "update" BYTEA NOT NULL,
+    "clock" INTEGER NOT NULL,
+    "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
+    "updatedAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
+    "yjsDocumentId" UUID,
+    "yjsAppDocumentId" UUID,
+    "userYjsAppDocumentYjsAppDocumentId" UUID,
+    "userYjsAppDocumentUserId" UUID,
+
+    CONSTRAINT "YjsUpdate_pkey" PRIMARY KEY ("id")
+);
+
+-- AddForeignKey
+ALTER TABLE "YjsUpdate" ADD CONSTRAINT "YjsUpdate_yjsDocumentId_fkey" FOREIGN KEY ("yjsDocumentId") REFERENCES "YjsDocument"("id") ON DELETE CASCADE ON UPDATE CASCADE;
+
+-- AddForeignKey
+ALTER TABLE "YjsUpdate" ADD CONSTRAINT "YjsUpdate_yjsAppDocumentId_fkey" FOREIGN KEY ("yjsAppDocumentId") REFERENCES "YjsAppDocument"("id") ON DELETE CASCADE ON UPDATE CASCADE;
+
+-- AddForeignKey
+ALTER TABLE "YjsUpdate" ADD CONSTRAINT "YjsUpdate_userYjsAppDocumentYjsAppDocumentId_userYjsAppDoc_fkey" FOREIGN KEY ("userYjsAppDocumentYjsAppDocumentId", "userYjsAppDocumentUserId") REFERENCES "UserYjsAppDocument"("yjsAppDocumentId", "userId") ON DELETE CASCADE ON UPDATE CASCADE;
diff --git a/packages/database/prisma/migrations/20241103212621_add_socket_io_attachement_table/migration.sql b/packages/database/prisma/migrations/20241103212621_add_socket_io_attachement_table/migration.sql
new file mode 100644
index 00000000..9de63ebd
--- /dev/null
+++ b/packages/database/prisma/migrations/20241103212621_add_socket_io_attachement_table/migration.sql
@@ -0,0 +1,9 @@
+-- CreateTable
+CREATE TABLE "SocketIOAttachment" (
+    "id" BIGSERIAL NOT NULL,
+    "created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
+    "payload" BYTEA NOT NULL
+);
+
+-- CreateIndex
+CREATE UNIQUE INDEX "SocketIOAttachment_id_key" ON "SocketIOAttachment"("id");
diff --git a/packages/database/prisma/migrations/20241103215222_fix_socket_io_attachments_table_name/migration.sql b/packages/database/prisma/migrations/20241103215222_fix_socket_io_attachments_table_name/migration.sql
new file mode 100644
index 00000000..49d916c7
--- /dev/null
+++ b/packages/database/prisma/migrations/20241103215222_fix_socket_io_attachments_table_name/migration.sql
@@ -0,0 +1,18 @@
+/*
+  Warnings:
+
+  - You are about to drop the `SocketIOAttachment` table. If the table is not empty, all the data it contains will be lost.
+
+*/
+-- DropTable
+DROP TABLE "SocketIOAttachment";
+
+-- CreateTable
+CREATE TABLE "socket_io_attachments" (
+    "id" BIGSERIAL NOT NULL,
+    "created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
+    "payload" BYTEA NOT NULL
+);
+
+-- CreateIndex
+CREATE UNIQUE INDEX "socket_io_attachments_id_key" ON "socket_io_attachments"("id");
diff --git a/packages/database/prisma/migrations/20241126193409_introduce_lock2_table/migration.sql b/packages/database/prisma/migrations/20241126193409_introduce_lock2_table/migration.sql
new file mode 100644
index 00000000..852f5666
--- /dev/null
+++ b/packages/database/prisma/migrations/20241126193409_introduce_lock2_table/migration.sql
@@ -0,0 +1,10 @@
+-- CreateTable
+CREATE TABLE "Lock2" (
+    "id" BIGSERIAL NOT NULL,
+    "name" TEXT NOT NULL,
+
+    CONSTRAINT "Lock2_pkey" PRIMARY KEY ("id")
+);
+
+-- CreateIndex
+CREATE UNIQUE INDEX "Lock2_name_key" ON "Lock2"("name");
diff --git a/packages/database/prisma/migrations/20241128014003_remove_unused_lock2_table/migration.sql b/packages/database/prisma/migrations/20241128014003_remove_unused_lock2_table/migration.sql
new file mode 100644
index 00000000..b0744d9c
--- /dev/null
+++ b/packages/database/prisma/migrations/20241128014003_remove_unused_lock2_table/migration.sql
@@ -0,0 +1,8 @@
+/*
+  Warnings:
+
+  - You are about to drop the `Lock2` table. If the table is not empty, all the data it contains will be lost.
+
+*/
+-- DropTable
+DROP TABLE "Lock2";
diff --git a/packages/database/prisma/schema.prisma b/packages/database/prisma/schema.prisma
index 961ee19f..2deb6944 100644
--- a/packages/database/prisma/schema.prisma
+++ b/packages/database/prisma/schema.prisma
@@ -49,6 +49,7 @@ model YjsDocument {
   updatedAt  DateTime @default(now()) @updatedAt
 
   document Document @relation(fields: [documentId], references: [id], onDelete: Cascade)
+  yjsUpdates YjsUpdate[]
 }
 
 model YjsAppDocument {
@@ -62,6 +63,7 @@ model YjsAppDocument {
 
   document            Document             @relation(fields: [documentId], references: [id], onDelete: Cascade)
   userYjsAppDocuments UserYjsAppDocument[]
+  yjsUpdates          YjsUpdate[]
 }
 
 model UserYjsAppDocument {
@@ -75,6 +77,7 @@ model UserYjsAppDocument {
 
   yjsAppDocument YjsAppDocument @relation(fields: [yjsAppDocumentId], references: [id], onDelete: Cascade)
   user           User           @relation(fields: [userId], references: [id], onDelete: Cascade)
+  yjsUpdates     YjsUpdate[]
 
   @@id([yjsAppDocumentId, userId])
 }
@@ -589,3 +592,30 @@ model ReusableComponent {
 
   document Document @relation(fields: [documentId], references: [id], onDelete: Cascade)
 }
+
+model YjsUpdate {
+  id         String   @id @default(dbgenerated("gen_random_uuid()")) @db.Uuid
+  update     Bytes
+  clock      Int
+
+  createdAt  DateTime @default(now())
+  updatedAt  DateTime @default(now()) @updatedAt
+
+  yjsDocumentId String?   @db.Uuid
+  yjsDocument   YjsDocument? @relation(fields: [yjsDocumentId], references: [id], onDelete: Cascade)
+
+  yjsAppDocumentId String?   @db.Uuid
+  yjsAppDocument   YjsAppDocument? @relation(fields: [yjsAppDocumentId], references: [id], onDelete: Cascade)
+
+  userYjsAppDocumentYjsAppDocumentId String? @db.Uuid
+  userYjsAppDocumentUserId           String? @db.Uuid
+  userYjsAppDocument UserYjsAppDocument? @relation(fields: [userYjsAppDocumentYjsAppDocumentId, userYjsAppDocumentUserId], references: [yjsAppDocumentId, userId], onDelete: Cascade)
+}
+
+// socket.io pg adapter is case insensitive, so we can't
+// follow our naming convention here
+model socket_io_attachments {
+  id         BigInt    @default(autoincrement()) @unique
+  createdAt  DateTime  @default(now()) @map("created_at")
+  payload    Bytes
+}
diff --git a/packages/database/src/index.ts b/packages/database/src/index.ts
index ca51b3b6..a696bb4c 100644
--- a/packages/database/src/index.ts
+++ b/packages/database/src/index.ts
@@ -1,6 +1,7 @@
 // https://www.prisma.io/docs/guides/other/troubleshooting-orm/help-articles/nextjs-prisma-client-dev-practices#solution
 import { Prisma, PrismaClient } from '@prisma/client'
 import { ITXClientDenyList } from '@prisma/client/runtime/library.js'
+import pg from 'pg'
 
 export type {
   Document,
@@ -9,6 +10,7 @@ export type {
   UserWorkspace,
   YjsAppDocument,
   UserYjsAppDocument,
+  YjsUpdate,
 } from '@prisma/client'
 
 export { UserWorkspaceRole } from '@prisma/client'
@@ -26,9 +28,11 @@ export * from './components.js'
 
 export type PrismaTransaction = Omit<PrismaClient, ITXClientDenyList>
 
+let connectionString: string | null = null
 let singleton: PrismaClient | null = null
-export const init = (datasourceUrl: string) => {
-  singleton = new PrismaClient({ datasourceUrl })
+export const init = (_datasourceUrl: string) => {
+  connectionString = _datasourceUrl
+  singleton = new PrismaClient({ datasourceUrl: connectionString })
 }
 
 export const prisma = () => {
@@ -54,4 +58,112 @@ export async function recoverFromNotFound<A>(
   }
 }
 
+let pgInstance: { pubSubClient: pg.Client; pool: pg.Pool } | null = null
+let subscribers: Record<string, Set<(message?: string) => void>> = {}
+export async function getPGInstance(): Promise<{
+  pubSubClient: pg.Client
+  pool: pg.Pool
+}> {
+  if (!connectionString) {
+    throw new Error(`Access db before calling init()`)
+  }
+
+  if (pgInstance) {
+    return pgInstance
+  }
+
+  const pgPool = new pg.Pool({ connectionString })
+  const pubSubClient = new pg.Client({ connectionString })
+  await pubSubClient.connect()
+
+  pubSubClient.on('notification', (notification) => {
+    const subs = subscribers[notification.channel]
+    if (!subs) {
+      return
+    }
+
+    subs.forEach((sub) => {
+      sub(notification.payload)
+    })
+  })
+
+  pgInstance = { pubSubClient, pool: pgPool }
+
+  return pgInstance
+}
+
+export async function subscribe(
+  channel: string,
+  onNotification: (message?: string) => void
+): Promise<() => Promise<void>> {
+  const { pubSubClient } = await getPGInstance()
+
+  const channelLockId = hashChannel(channel)
+
+  // Acquire the advisory lock for the channel to prevent race conditions
+  // This ensures only one `LISTEN` setup happens at a time for this channel
+  await pubSubClient.query('SELECT pg_advisory_lock($1)', [channelLockId])
+
+  try {
+    const subs = subscribers[channel]
+    if (subs) {
+      subs.add(onNotification)
+    } else {
+      subscribers[channel] = new Set([onNotification])
+
+      try {
+        await pubSubClient.query(`LISTEN ${JSON.stringify(channel)}`)
+      } catch (e) {
+        subscribers[channel].delete(onNotification)
+        throw e
+      }
+    }
+  } finally {
+    // Release the advisory lock so other operations (e.g., another subscribe or unsubscribe) can proceed
+    await pubSubClient.query('SELECT pg_advisory_unlock($1)', [channelLockId])
+  }
+
+  return async () => {
+    // Acquire the advisory lock before making changes during unsubscribe
+    // This prevents race conditions when multiple unsubscribe operations or a subscribe and unsubscribe overlap
+    await pubSubClient.query('SELECT pg_advisory_lock($1)', [channelLockId])
+
+    try {
+      const subs = subscribers[channel]
+      if (!subs) {
+        return
+      }
+
+      subs.delete(onNotification)
+
+      if (subs.size === 0) {
+        // If this was the last subscriber, clean up by removing the channel and issuing UNLISTEN
+        await pubSubClient.query(`UNLISTEN ${JSON.stringify(channel)}`)
+        delete subscribers[channel]
+      }
+    } finally {
+      // Release the advisory lock so other operations can proceed
+      await pubSubClient.query('SELECT pg_advisory_unlock($1)', [channelLockId])
+    }
+  }
+}
+
+// PostgreSQL advisory locks require integers as lock identifiers
+function hashChannel(channel: string): number {
+  let hash = 0
+  for (let i = 0; i < channel.length; i++) {
+    hash = (hash * 31 + channel.charCodeAt(i)) | 0
+  }
+  return Math.abs(hash)
+}
+
+export async function getPGPool(): Promise<pg.Pool> {
+  return (await getPGInstance()).pool
+}
+
+export async function publish(channel: string, message: string): Promise<void> {
+  const { pubSubClient } = await getPGInstance()
+  await pubSubClient.query('SELECT pg_notify($1, $2)', [channel, message])
+}
+
 export default prisma
diff --git a/packages/editor/src/ai-tasks/index.ts b/packages/editor/src/ai-tasks/index.ts
new file mode 100644
index 00000000..836e0da9
--- /dev/null
+++ b/packages/editor/src/ai-tasks/index.ts
@@ -0,0 +1,106 @@
+import * as Y from 'yjs'
+import {
+  AITaskItem,
+  AITaskItemMetadataWithoutNoop,
+  createYAITaskItem,
+  YAITaskItem,
+} from './item.js'
+import { exhaustiveCheck } from '../utils.js'
+
+export * from './item.js'
+
+export type YAITasks = Y.Array<YAITaskItem>
+
+export const AI_TASK_PING_TIMEOUT = 1000 * 5 // 5 seconds
+
+export class AITasks {
+  private readonly observers: Set<() => void> = new Set()
+  private constructor(private readonly tasks: YAITasks) {}
+
+  public enqueue(
+    blockId: string,
+    userId: string | null,
+    metadata: AITaskItemMetadataWithoutNoop
+  ): void {
+    const item = createYAITaskItem(blockId, userId, metadata)
+    this.tasks.push([item])
+  }
+
+  public next(): AITaskItem | null {
+    let pos = 0
+    let current = this.tasks.get(pos)
+    while (current) {
+      const item = AITaskItem.fromYjs(current)
+      const status = item.getStatus()
+      switch (status._tag) {
+        case 'unknown':
+        case 'enqueued':
+          item.setRunning()
+          return item
+        case 'running':
+          if (Date.now() - status.ping > AI_TASK_PING_TIMEOUT) {
+            item.ping()
+            return item
+          }
+          pos++
+          break
+        case 'aborting':
+        case 'completed':
+          this.tasks.delete(pos)
+          break
+        default:
+          exhaustiveCheck(status)
+      }
+
+      current = this.tasks.get(pos)
+    }
+
+    return null
+  }
+
+  public getBlockTasks(
+    blockId: string,
+    tag?: AITaskItemMetadataWithoutNoop['_tag']
+  ): AITaskItem[] {
+    const tasks: AITaskItem[] = []
+    for (const yItem of this.tasks) {
+      const item = AITaskItem.fromYjs(yItem)
+      if (item.getBlockId() === blockId) {
+        if (tag && item.getMetadata()._tag !== tag) {
+          continue
+        }
+        tasks.push(item)
+      }
+    }
+
+    return tasks
+  }
+
+  public observe(cb: () => void): () => void {
+    this.observers.add(cb)
+    if (this.observers.size === 1) {
+      this.tasks.observeDeep(this.onObservation)
+    }
+
+    return () => {
+      this.observers.delete(cb)
+      if (this.observers.size === 0) {
+        this.tasks.unobserveDeep(this.onObservation)
+      }
+    }
+  }
+
+  private onObservation = () => {
+    for (const observer of this.observers) {
+      observer()
+    }
+  }
+
+  public size(): number {
+    return this.tasks.length
+  }
+
+  public static fromYjs(doc: Y.Doc): AITasks {
+    return new AITasks(doc.getArray<YAITaskItem>('aiTasks'))
+  }
+}
diff --git a/packages/editor/src/ai-tasks/item.ts b/packages/editor/src/ai-tasks/item.ts
new file mode 100644
index 00000000..608efc55
--- /dev/null
+++ b/packages/editor/src/ai-tasks/item.ts
@@ -0,0 +1,226 @@
+import * as Y from 'yjs'
+import * as z from 'zod'
+import { exhaustiveCheck, uuidSchema } from '../utils.js'
+
+export const AITaskItemStatus = z.union([
+  z.object({
+    _tag: z.literal('enqueued'),
+  }),
+  z.object({
+    _tag: z.literal('aborting'),
+    ping: z.number().positive(),
+  }),
+  z.object({
+    _tag: z.literal('running'),
+    ping: z.number().positive(),
+  }),
+  z.object({
+    _tag: z.literal('completed'),
+    status: z.union([
+      z.literal('success'),
+      z.literal('error'),
+      z.literal('aborted'),
+    ]),
+  }),
+  z.object({
+    _tag: z.literal('unknown'),
+  }),
+])
+export type AITaskItemStatus = z.infer<typeof AITaskItemStatus>
+
+export type AITaskStatus = AITaskItemStatus['_tag'] | 'idle'
+
+export function isAITaskStatusLoading(
+  status: AITaskStatus
+): status is 'running' | 'aborting' | 'enqueued' {
+  switch (status) {
+    case 'enqueued':
+    case 'running':
+    case 'aborting':
+      return true
+    case 'idle':
+    case 'completed':
+    case 'unknown':
+      return false
+  }
+}
+
+export const AITaskItemEditPythonMetadata = z.object({
+  _tag: z.literal('edit-python'),
+})
+export type AITaskItemEditPythonMetadata = z.infer<
+  typeof AITaskItemEditPythonMetadata
+>
+
+export const AITaskItemFixPythonMetadata = z.object({
+  _tag: z.literal('fix-python'),
+})
+export type AITaskItemFixPythonMetadata = z.infer<
+  typeof AITaskItemFixPythonMetadata
+>
+
+export const AITaskItemEditSQLMetadata = z.object({
+  _tag: z.literal('edit-sql'),
+})
+export type AITaskItemEditSQLMetadata = z.infer<
+  typeof AITaskItemEditSQLMetadata
+>
+
+export const AITaskItemFixSQLMetadata = z.object({
+  _tag: z.literal('fix-sql'),
+})
+export type AITaskItemFixSQLMetadata = z.infer<typeof AITaskItemFixSQLMetadata>
+
+export const AITaskItemNoopMetadata = z.object({
+  _tag: z.literal('noop'),
+})
+export type AITaskItemNoopMetadata = z.infer<typeof AITaskItemNoopMetadata>
+
+export const AITaskItemMetadata = z.union([
+  AITaskItemEditPythonMetadata,
+  AITaskItemFixPythonMetadata,
+  AITaskItemEditSQLMetadata,
+  AITaskItemFixSQLMetadata,
+  AITaskItemNoopMetadata,
+])
+export type AITaskItemMetadata = z.infer<typeof AITaskItemMetadata>
+
+export type AITaskItemMetadataWithoutNoop = Exclude<
+  AITaskItemMetadata,
+  AITaskItemNoopMetadata
+>
+
+export const AITaskItemAttrs = z.object({
+  blockId: uuidSchema,
+  userId: z.nullable(uuidSchema),
+  status: AITaskItemStatus,
+  metadata: AITaskItemMetadata,
+})
+export type YAITaskItemAttrs = z.infer<typeof AITaskItemAttrs>
+
+export type YAITaskItem = Y.XmlElement<YAITaskItemAttrs>
+
+export function createYAITaskItem(
+  blockId: string,
+  userId: string | null,
+  metadata: AITaskItemMetadataWithoutNoop
+): YAITaskItem {
+  const el = new Y.XmlElement<YAITaskItemAttrs>()
+  const attrs: YAITaskItemAttrs = {
+    blockId,
+    userId,
+    status: { _tag: 'enqueued' },
+    metadata,
+  }
+  for (const [key, value] of Object.entries(attrs)) {
+    el.setAttribute(
+      // @ts-ignore
+      key,
+      value
+    )
+  }
+  return el
+}
+
+export class AITaskItem {
+  private constructor(private readonly item: YAITaskItem) {}
+  private statusObservers: Set<(status: AITaskItemStatus) => void> = new Set()
+
+  public getCompleteStatus(): 'success' | 'error' | 'aborted' | null {
+    const status = this.getStatus()
+    switch (status._tag) {
+      case 'completed':
+        return status.status
+      case 'enqueued':
+      case 'running':
+      case 'aborting':
+      case 'unknown':
+        return null
+    }
+  }
+
+  public getMetadata(): AITaskItemMetadata {
+    return this.item.getAttribute('metadata') ?? { _tag: 'noop' }
+  }
+
+  public getBlockId(): string {
+    return this.item.getAttribute('blockId') ?? ''
+  }
+
+  public getUserId(): string | null {
+    return this.item.getAttribute('userId') ?? null
+  }
+
+  public setRunning(): void {
+    this.item.setAttribute('status', { _tag: 'running', ping: Date.now() })
+  }
+
+  public ping(): void {
+    const status = this.getStatus()
+    switch (status._tag) {
+      case 'running':
+        this.item.setAttribute('status', { _tag: 'running', ping: Date.now() })
+        break
+      case 'aborting':
+        this.item.setAttribute('status', { _tag: 'aborting', ping: Date.now() })
+        break
+      case 'enqueued':
+      case 'completed':
+      case 'unknown':
+        break
+      default:
+        exhaustiveCheck(status)
+    }
+  }
+
+  public setAborting(): void {
+    this.item.setAttribute('status', { _tag: 'aborting', ping: Date.now() })
+  }
+
+  public setCompleted(status: 'success' | 'error' | 'aborted'): void {
+    this.item.setAttribute('status', { _tag: 'completed', status })
+  }
+
+  public getStatus(): AITaskItemStatus {
+    const raw = this.item.getAttribute('status')
+    if (!raw) {
+      return { _tag: 'unknown' }
+    }
+
+    const parsed = AITaskItemStatus.safeParse(raw)
+    if (!parsed.success) {
+      return { _tag: 'unknown' }
+    }
+
+    return parsed.data
+  }
+
+  public observeStatus(
+    callback: (status: AITaskItemStatus) => void
+  ): () => void {
+    this.statusObservers.add(callback)
+    if (this.statusObservers.size === 1) {
+      this.item.observeDeep(this.onStatusObservation)
+    }
+
+    return () => {
+      this.statusObservers.delete(callback)
+      if (this.statusObservers.size === 0) {
+        this.item.unobserveDeep(this.onStatusObservation)
+      }
+    }
+  }
+
+  public toJSON() {
+    return this.item.getAttributes()
+  }
+
+  private onStatusObservation = () => {
+    const status = this.getStatus()
+    this.statusObservers.forEach((cb) => cb(status))
+  }
+
+  public static fromYjs(item: YAITaskItem): AITaskItem {
+    return new AITaskItem(item)
+  }
+}
diff --git a/packages/editor/src/blocks/dateInput.ts b/packages/editor/src/blocks/dateInput.ts
index 31ef985b..530e9178 100644
--- a/packages/editor/src/blocks/dateInput.ts
+++ b/packages/editor/src/blocks/dateInput.ts
@@ -6,11 +6,16 @@ import {
   YBlock,
   getAttributeOr,
   getBaseAttributes,
-  ExecStatus,
   duplicateBaseAttributes,
 } from './index.js'
-import { updateYText } from '../index.js'
-import { clone } from 'ramda'
+import {
+  ExecutionQueue,
+  ExecutionStatus,
+  ResultStatus,
+  updateYText,
+  YBlockGroup,
+} from '../index.js'
+import { clone, head } from 'ramda'
 
 export type DateInputValue = {
   year: number
@@ -123,16 +128,6 @@ export function formatDateInputValue(
 
 export type DateInputBlock = BaseBlock<BlockType.DateInput> & {
   label: Y.Text
-  status:
-    | 'idle'
-    | 'run-requested'
-    | 'running'
-    | 'run-all-enqueued'
-    | 'run-all-running'
-    | 'invalid-value'
-    | 'invalid-variable'
-    | 'invalid-variable-and-value'
-    | 'unexpected-error'
   variable: string
   value: {
     year: number
@@ -148,6 +143,12 @@ export type DateInputBlock = BaseBlock<BlockType.DateInput> & {
   dateType: 'date' | 'datetime'
   newValue: Y.Text
   newVariable: Y.Text
+  error:
+    | 'invalid-value'
+    | 'invalid-variable'
+    | 'invalid-variable-and-value'
+    | 'unexpected-error'
+    | null
 }
 
 export const makeDateInputBlock = (
@@ -161,7 +162,6 @@ export const makeDateInputBlock = (
   const value = dateInputValueFromDate(now, 'UTC')
   const attrs: DateInputBlock = {
     id,
-    status: 'idle',
     index: null,
     title: '',
     type: BlockType.DateInput,
@@ -173,6 +173,7 @@ export const makeDateInputBlock = (
     executedAt: null,
     configOpen: true,
     dateType: 'date',
+    error: null,
   }
 
   for (const [key, value] of Object.entries(attrs)) {
@@ -196,7 +197,6 @@ export function duplicateDateInputBlock(
   const nextAttrs: DateInputBlock = {
     ...duplicateBaseAttributes(newId, prevAttributes),
     label: new Y.Text(prevAttributes.label.toString()),
-    status: prevAttributes.status,
     variable: prevAttributes.variable,
     newVariable: new Y.Text(prevAttributes.newVariable.toString()),
     value: clone(prevAttributes.value),
@@ -204,6 +204,7 @@ export function duplicateDateInputBlock(
     executedAt: null,
     configOpen: prevAttributes.configOpen,
     dateType: prevAttributes.dateType,
+    error: null,
   }
 
   const yBlock = new Y.XmlElement<DateInputBlock>('block')
@@ -219,25 +220,17 @@ export function duplicateDateInputBlock(
 }
 
 export function getDateInputBlockExecStatus(
-  block: Y.XmlElement<DateInputBlock>
-): ExecStatus {
-  const status = getAttributeOr(block, 'status', 'idle')
-  switch (status) {
-    case 'idle':
-      return 'idle'
-    case 'run-all-enqueued':
-      return 'enqueued'
-    case 'run-all-running':
-      return 'loading'
-    case 'run-requested':
-    case 'running':
-      return 'loading'
-    case 'invalid-value':
-    case 'invalid-variable':
-    case 'invalid-variable-and-value':
-    case 'unexpected-error':
-      return 'error'
+  block: Y.XmlElement<DateInputBlock>,
+  executionQueue: ExecutionQueue
+): ExecutionStatus {
+  const blockId = getBaseAttributes(block).id
+  const executions = executionQueue.getBlockExecutions(blockId, 'date-input')
+  const execution = head(executions)
+  if (execution) {
+    return execution.item.getStatus()._tag
   }
+
+  return 'completed'
 }
 
 export function isDateInputBlock(
@@ -272,7 +265,6 @@ export function getDateInputAttributes(
   const attrs: DateInputBlock = {
     ...baseAttrs,
     label: getAttributeOr(block, 'label', getLabelFromVariable(variable)),
-    status: getAttributeOr(block, 'status', 'idle'),
     value,
     newValue: getAttributeOr(
       block,
@@ -284,6 +276,7 @@ export function getDateInputAttributes(
     executedAt: getAttributeOr(block, 'executedAt', null),
     configOpen: getAttributeOr(block, 'configOpen', true),
     dateType,
+    error: getAttributeOr(block, 'error', null),
   }
 
   return attrs
@@ -351,8 +344,11 @@ function getAvailableDateInputVariable(
 }
 
 export function requestDateInputRun(
+  executionQueue: ExecutionQueue,
   block: Y.XmlElement<DateInputBlock>,
-  blocks: Y.Map<YBlock>
+  blocks: Y.Map<YBlock>,
+  userId: string | null,
+  environmentStartedAt: string | null
 ): void {
   const operation = () => {
     const attrs = getDateInputAttributes(block, blocks)
@@ -368,21 +364,23 @@ export function requestDateInputRun(
     const invalidVariable = !dfNameRegex.test(nextVariable)
 
     if (invalidValue && invalidVariable) {
-      block.setAttribute('status', 'invalid-variable-and-value')
+      block.setAttribute('error', 'invalid-variable-and-value')
       return
     }
     if (invalidValue) {
-      block.setAttribute('status', 'invalid-value')
+      block.setAttribute('error', 'invalid-value')
       return
     }
     if (invalidVariable) {
-      block.setAttribute('status', 'invalid-variable')
+      block.setAttribute('error', 'invalid-variable')
       return
     }
 
     block.setAttribute('variable', nextVariable)
     block.setAttribute('value', nextValue)
-    block.setAttribute('status', 'run-requested')
+    executionQueue.enqueueBlock(block, userId, environmentStartedAt, {
+      _tag: 'date-input',
+    })
   }
 
   if (block.doc) {
@@ -427,3 +425,19 @@ export function updateDateInputBlockDateType(
     operation()
   }
 }
+
+export function getDateInputBlockResultStatus(
+  block: Y.XmlElement<DateInputBlock>,
+  blocks: Y.Map<YBlock>
+): ResultStatus {
+  const { error, executedAt } = getDateInputAttributes(block, blocks)
+  switch (error) {
+    case 'invalid-variable':
+    case 'invalid-value':
+    case 'invalid-variable-and-value':
+    case 'unexpected-error':
+      return 'error'
+    case null:
+      return executedAt ? 'success' : 'idle'
+  }
+}
diff --git a/packages/editor/src/blocks/dropdownInput.ts b/packages/editor/src/blocks/dropdownInput.ts
index d97fafe6..d1c1f20d 100644
--- a/packages/editor/src/blocks/dropdownInput.ts
+++ b/packages/editor/src/blocks/dropdownInput.ts
@@ -1,4 +1,4 @@
-import { clone, uniq } from 'ramda'
+import { clone, head, uniq } from 'ramda'
 import * as Y from 'yjs'
 import {
   BlockType,
@@ -6,20 +6,18 @@ import {
   YBlock,
   getAttributeOr,
   getBaseAttributes,
-  ExecStatus,
   EditableField,
   duplicateBaseAttributes,
+  ResultStatus,
 } from './index.js'
+import { ExecutionStatus } from '../execution/item.js'
+import { ExecutionQueue } from '../execution/queue.js'
 
 export type DropdownType = 'static' | 'dynamic'
 
 export type DropdownInputBlock = BaseBlock<BlockType.DropdownInput> & {
   label: string
-  value: EditableField<
-    'invalid-value' | 'unexpected-error',
-    'run-all-enqueued' | 'run-all-running',
-    string | null
-  >
+  value: EditableField<'invalid-value' | 'unexpected-error', string | null>
   variable: EditableField<
     'invalid-value' | 'invalid-variable-name' | 'unexpected-error'
   >
@@ -52,7 +50,6 @@ export const makeDropdownInputBlock = (
     value: {
       value: null,
       newValue: null,
-      status: 'idle',
       error: null,
     },
     variable,
@@ -156,7 +153,6 @@ function getDropdownInputValue(
   return getAttributeOr(block, 'value', {
     value: '',
     newValue: '',
-    status: 'idle',
     error: null,
   })
 }
@@ -188,19 +184,20 @@ export function updateDropdownInputValue(
 }
 
 export function getDropdownInputValueExecStatus(
-  block: Y.XmlElement<DropdownInputBlock>
-): ExecStatus {
-  const value = getDropdownInputValue(block)
-  switch (value.status) {
-    case 'idle':
-      return value.error ? 'error' : 'idle'
-    case 'run-all-enqueued':
-      return 'enqueued'
-    case 'save-requested':
-    case 'saving':
-    case 'run-all-running':
-      return 'loading'
+  block: Y.XmlElement<DropdownInputBlock>,
+  executionQueue: ExecutionQueue
+): ExecutionStatus {
+  const blockId = getBaseAttributes(block).id
+  const executions = executionQueue.getBlockExecutions(
+    blockId,
+    'dropdown-input-save-value'
+  )
+  const execution = head(executions)
+  if (execution) {
+    return execution.item.getStatus()._tag
   }
+
+  return 'completed'
 }
 
 function getDropdownInputVariable(
@@ -231,33 +228,41 @@ export function updateDropdownInputVariable(
 
 export function getDropdownInputVariableExecStatus(
   block: Y.XmlElement<DropdownInputBlock>,
-  blocks: Y.Map<YBlock>
-): ExecStatus {
-  const variable = getDropdownInputVariable(block, blocks)
-  switch (variable.status) {
-    case 'idle':
-      return variable.error ? 'error' : 'idle'
-    case 'save-requested':
-    case 'saving':
-      return 'loading'
+  executionQueue: ExecutionQueue
+): ExecutionStatus {
+  const blockId = getBaseAttributes(block).id
+  const executions = executionQueue.getBlockExecutions(
+    blockId,
+    'dropdown-input-rename-variable'
+  )
+
+  const execution = head(executions)
+
+  if (!execution) {
+    return 'completed'
   }
+
+  return execution.item.getStatus()._tag
 }
 
 export function getDropdownInputBlockExecStatus(
   block: Y.XmlElement<DropdownInputBlock>,
-  blocks: Y.Map<YBlock>
-): ExecStatus {
-  const variableStatus = getDropdownInputVariableExecStatus(block, blocks)
+  executionQueue: ExecutionQueue
+): ExecutionStatus {
+  const variableStatus = getDropdownInputVariableExecStatus(
+    block,
+    executionQueue
+  )
 
   switch (variableStatus) {
-    case 'success':
-    case 'idle':
-      return getDropdownInputValueExecStatus(block)
-    case 'loading':
+    case 'running':
+    case 'aborting':
     case 'enqueued':
-      return 'loading'
-    case 'error':
-      return 'error'
+      return variableStatus
+    case 'completed':
+    case 'unknown':
+    case 'idle':
+      return getDropdownInputValueExecStatus(block, executionQueue)
   }
 }
 
@@ -277,50 +282,10 @@ function getAvailableDropdownInputVariable(
   return {
     value: `dropdown_${i}`,
     newValue: `dropdown_${i}`,
-    status: 'idle',
     error: null,
   }
 }
 
-export function dropdownInputRequestSaveVariable(
-  block: Y.XmlElement<DropdownInputBlock>
-): void {
-  const operation = () => {
-    const oldVal = block.getAttribute('variable')
-    if (!oldVal) {
-      return
-    }
-
-    block.setAttribute('variable', { ...oldVal, status: 'save-requested' })
-  }
-
-  if (block.doc) {
-    block.doc.transact(operation)
-  } else {
-    operation()
-  }
-}
-
-export function dropdownInputRequestSaveValue(
-  block: Y.XmlElement<DropdownInputBlock>,
-  status?: 'saving' | 'save-requested'
-): void {
-  const operation = () => {
-    const oldVal = block.getAttribute('value')
-    if (!oldVal) {
-      return
-    }
-
-    block.setAttribute('value', { ...oldVal, status: 'save-requested' })
-  }
-
-  if (block.doc) {
-    block.doc.transact(operation)
-  } else {
-    operation()
-  }
-}
-
 export function dropdownInputToggleConfigOpen(
   block: Y.XmlElement<DropdownInputBlock>
 ): void {
@@ -462,3 +427,17 @@ export function setDropdownColumnName(
 ) {
   block.setAttribute('columnName', name)
 }
+
+export function getDropdownInputBlockResultStatus(
+  block: Y.XmlElement<DropdownInputBlock>,
+  blocks: Y.Map<YBlock>
+): ResultStatus {
+  const variable = getDropdownInputVariable(block, blocks)
+  const value = getDropdownInputValue(block)
+
+  if (variable.error || value.error) {
+    return 'error'
+  }
+
+  return value.error ? 'error' : 'success'
+}
diff --git a/packages/editor/src/blocks/index.ts b/packages/editor/src/blocks/index.ts
index 838be0b3..c3392ba5 100644
--- a/packages/editor/src/blocks/index.ts
+++ b/packages/editor/src/blocks/index.ts
@@ -1,15 +1,10 @@
 import * as dfns from 'date-fns'
 import * as Y from 'yjs'
-import {
-  RichTextBlock,
-  duplicateRichTextBlock,
-  getRichTextBlockExecStatus,
-} from './richText.js'
+import { RichTextBlock, duplicateRichTextBlock } from './richText.js'
 import {
   SQLBlock,
   duplicateSQLBlock,
   getSQLBlockErrorMessage,
-  getSQLBlockExecStatus,
   getSQLBlockExecutedAt,
   getSQLBlockResultStatus,
 } from './sql.js'
@@ -17,7 +12,6 @@ import {
   PythonBlock,
   duplicatePythonBlock,
   getPythonBlockErrorMessage,
-  getPythonBlockExecStatus,
   getPythonBlockExecutedAt,
   getPythonBlockResultStatus,
 } from './python.js'
@@ -25,25 +19,26 @@ import {
   VisualizationBlock,
   duplicateVisualizationBlock,
   getVisualizationBlockErrorMessage,
-  getVisualizationBlockExecStatus,
   getVisualizationBlockExecutedAt,
   getVisualizationBlockResultStatus,
 } from './visualization.js'
 import {
   InputBlock,
   duplicateInputBlock,
-  getInputBlockExecStatus,
   getInputBlockExecutedAt,
-  inputRequestSaveValue,
 } from './input.js'
-import { YBlockGroup, isTextInputBlock, switchBlockType } from '../index.js'
+import {
+  ExecutionStatus,
+  YBlockGroup,
+  getInputBlockResultStatus,
+  switchBlockType,
+} from '../index.js'
 import { FileUploadBlock, duplicateFileUploadBlock } from './fileUpload.js'
 import {
   DropdownInputBlock,
-  dropdownInputRequestSaveValue,
   duplicateDropdownInputBlock,
-  getDropdownInputBlockExecStatus,
   getDropdownInputBlockExecutedAt,
+  getDropdownInputBlockResultStatus,
 } from './dropdownInput.js'
 import { clone } from 'ramda'
 import {
@@ -54,22 +49,19 @@ import {
   WritebackBlock,
   duplicateWritebackBlock,
   getWritebackBlockErrorMessage,
-  getWritebackBlockExecStatus,
   getWritebackBlockExecutedAt,
   getWritebackBlockResultStatus,
 } from './writeback.js'
 import {
   DateInputBlock,
   duplicateDateInputBlock,
-  getDateInputBlockExecStatus,
   getDateInputBlockExecutedAt,
-  requestDateInputRun,
+  getDateInputBlockResultStatus,
 } from './dateInput.js'
 import {
   PivotTableBlock,
   duplicatePivotTableBlock,
   getPivotTableBlockErrorMessage,
-  getPivotTableBlockExecStatus,
   getPivotTableBlockExecutedAt,
   getPivotTableBlockResultStatus,
 } from './pivotTable.js'
@@ -88,7 +80,7 @@ export enum BlockType {
   PivotTable = 'PIVOT_TABLE',
 }
 
-export type ExecStatus = 'enqueued' | 'loading' | 'idle' | 'error' | 'success'
+export type ResultStatus = 'idle' | 'error' | 'success'
 
 export type BaseBlock<T extends BlockType> = {
   id: string
@@ -112,112 +104,19 @@ export type Block =
 
 export type YBlock = Y.XmlElement<Block>
 
-const noop = () => {}
-
-export const requestRun = <B extends YBlock>(
-  block: B,
-  blocks: Y.Map<YBlock>,
-  layout: Y.Array<YBlockGroup>,
-  environmentStartedAt: Date | null,
-  skipDependencyCheck: boolean,
-  customOnRequestRun?: (block: B) => void
-) => {
-  const dependencies = computeDepencyQueue(
-    block,
-    layout,
-    blocks,
-    environmentStartedAt,
-    skipDependencyCheck
-  )
-
-  const queue = dependencies
-  if (!customOnRequestRun) {
-    queue.push(block)
-  }
-  for (const block of queue) {
-    switchBlockType<void>(block, {
-      onPython: (b) => b.setAttribute('status', 'run-requested'),
-      onSQL: (b) => b.setAttribute('status', 'run-requested'),
-      onVisualization: (b) => b.setAttribute('status', 'run-requested'),
-      onWriteback: (b) => b.setAttribute('status', 'run-requested'),
-      onInput: inputRequestSaveValue,
-      onDropdownInput: dropdownInputRequestSaveValue,
-      onDateInput: (b) => requestDateInputRun(b, blocks),
-      onRichText: noop,
-      onFileUpload: noop,
-      onDashboardHeader: noop,
-      onPivotTable: (b) => b.setAttribute('status', 'run-requested'),
-    })
-  }
-
-  if (customOnRequestRun) {
-    customOnRequestRun(block)
-  }
-}
-
-export const requestTrySuggestion = (
-  block: YBlock,
-  blocks: Y.Map<YBlock>,
-  layout: Y.Array<YBlockGroup>,
-  environmentStartedAt: Date | null,
-  skipDependencyCheck = false
-) => {
-  const dependencies = computeDepencyQueue(
-    block,
-    layout,
-    blocks,
-    environmentStartedAt,
-    skipDependencyCheck
-  )
-  const queue = dependencies.concat(block)
-
-  for (const block of queue) {
-    switchBlockType<void>(block, {
-      onPython: (b) => b.setAttribute('status', 'try-suggestion-requested'),
-      onSQL: (b) => b.setAttribute('status', 'try-suggestion-requested'),
-      onVisualization: noop,
-      onInput: inputRequestSaveValue,
-      onDropdownInput: dropdownInputRequestSaveValue,
-      onDateInput: (b) => b.setAttribute('status', 'run-requested'),
-      onRichText: noop,
-      onFileUpload: noop,
-      onDashboardHeader: noop,
-      onWriteback: noop,
-      onPivotTable: noop,
-    })
-  }
-}
-
 export const setTitle = (block: YBlock, title: string) => {
   block.setAttribute('title', title)
 }
 
-export const getExecStatus = (
-  block: YBlock,
-  blocks: Y.Map<YBlock>
-): ExecStatus =>
-  switchBlockType(block, {
-    onPython: getPythonBlockExecStatus,
-    onSQL: getSQLBlockExecStatus,
-    onVisualization: getVisualizationBlockExecStatus,
-    onInput: (block) => getInputBlockExecStatus(block, blocks),
-    onDropdownInput: (block) => getDropdownInputBlockExecStatus(block, blocks),
-    onDateInput: (block) => getDateInputBlockExecStatus(block),
-    onRichText: getRichTextBlockExecStatus,
-    onFileUpload: () => 'idle',
-    onDashboardHeader: () => 'idle',
-    onWriteback: getWritebackBlockExecStatus,
-    onPivotTable: getPivotTableBlockExecStatus,
-  })
-
-export const execStatusIsDisabled = (status: ExecStatus): boolean => {
+export const execStatusIsDisabled = (status: ExecutionStatus): boolean => {
   switch (status) {
+    case 'running':
+    case 'aborting':
     case 'enqueued':
-    case 'loading':
       return true
+    case 'completed':
+    case 'unknown':
     case 'idle':
-    case 'error':
-    case 'success':
       return false
   }
 }
@@ -225,15 +124,16 @@ export const execStatusIsDisabled = (status: ExecStatus): boolean => {
 export const getResultStatus = (
   block: YBlock,
   blocks: Y.Map<YBlock>
-): ExecStatus =>
+): ResultStatus =>
   switchBlockType(block, {
     onPython: getPythonBlockResultStatus,
     onSQL: getSQLBlockResultStatus,
     onVisualization: getVisualizationBlockResultStatus,
-    onInput: (block) => getInputBlockExecStatus(block, blocks),
-    onDropdownInput: (block) => getDropdownInputBlockExecStatus(block, blocks),
-    onDateInput: (block) => getDateInputBlockExecStatus(block),
-    onRichText: getRichTextBlockExecStatus,
+    onInput: (block) => getInputBlockResultStatus(block, blocks),
+    onDropdownInput: (block) =>
+      getDropdownInputBlockResultStatus(block, blocks),
+    onDateInput: (block) => getDateInputBlockResultStatus(block, blocks),
+    onRichText: () => 'idle',
     onFileUpload: () => 'idle',
     onDashboardHeader: () => 'idle',
     onWriteback: getWritebackBlockResultStatus,
@@ -417,7 +317,7 @@ function getExecutedAt(block: YBlock, blocks: Y.Map<YBlock>): Date | null {
 function mustExecute(
   block: YBlock,
   blocks: Y.Map<YBlock>,
-  environmentStartedAt: Date | null,
+  environmentStartedAt: string | null,
   skipDependencyCheck: boolean
 ): boolean {
   if (!isExecutableBlock(block)) {
@@ -452,8 +352,8 @@ export function computeDepencyQueue(
   block: YBlock,
   layout: Y.Array<YBlockGroup>,
   blocks: Y.Map<YBlock>,
-  environmentStartedAt: Date | null,
-  skipDependencyCheck: boolean
+  skipDependencyCheck: boolean,
+  environmentStartedAt: string | null
 ): YBlock[] {
   const flatLayout = layout
     .toArray()
diff --git a/packages/editor/src/blocks/input.ts b/packages/editor/src/blocks/input.ts
index 304399af..eb13a00a 100644
--- a/packages/editor/src/blocks/input.ts
+++ b/packages/editor/src/blocks/input.ts
@@ -5,13 +5,14 @@ import {
   YBlock,
   getAttributeOr,
   getBaseAttributes,
-  ExecStatus,
   duplicateBaseAttributes,
+  ResultStatus,
 } from './index.js'
-import { clone } from 'ramda'
+import { clone, head } from 'ramda'
+import { ExecutionQueue } from '../execution/queue.js'
+import { ExecutionStatus } from '../execution/item.js'
 
-export type EditableField<E, S = never, V = string> = {
-  status: 'idle' | 'save-requested' | 'saving' | S
+export type EditableField<E, V = string> = {
   value: V
   newValue: V
   error: E | null
@@ -19,10 +20,7 @@ export type EditableField<E, S = never, V = string> = {
 
 export type InputBlock = BaseBlock<BlockType.Input> & {
   label: string
-  value: EditableField<
-    'invalid-value' | 'unexpected-error',
-    'run-all-enqueued' | 'run-all-running'
-  >
+  value: EditableField<'invalid-value' | 'unexpected-error'>
   variable: EditableField<
     'invalid-value' | 'invalid-variable-name' | 'unexpected-error'
   >
@@ -52,7 +50,6 @@ export const makeInputBlock = (
     value: {
       value: '',
       newValue: '',
-      status: 'idle',
       error: null,
     },
     variable,
@@ -140,7 +137,6 @@ function getInputValue(block: Y.XmlElement<InputBlock>): InputBlock['value'] {
   return getAttributeOr(block, 'value', {
     value: '',
     newValue: '',
-    status: 'idle',
     error: null,
   })
 }
@@ -158,19 +154,20 @@ export function updateInputValue(
 }
 
 export function getInputValueExecStatus(
-  block: Y.XmlElement<InputBlock>
-): ExecStatus {
-  const value = getInputValue(block)
-  switch (value.status) {
-    case 'idle':
-      return value.error ? 'error' : 'idle'
-    case 'run-all-enqueued':
-      return 'enqueued'
-    case 'save-requested':
-    case 'saving':
-    case 'run-all-running':
-      return 'loading'
+  block: Y.XmlElement<InputBlock>,
+  executionQueue: ExecutionQueue
+): ExecutionStatus {
+  const blockId = getBaseAttributes(block).id
+  const executions = executionQueue.getBlockExecutions(
+    blockId,
+    'text-input-save-value'
+  )
+  const execution = head(executions)
+  if (execution) {
+    return execution.item.getStatus()._tag
   }
+
+  return 'completed'
 }
 
 function getInputVariable(
@@ -201,36 +198,56 @@ export function updateInputVariable(
 
 export function getInputVariableExecStatus(
   block: Y.XmlElement<InputBlock>,
-  blocks: Y.Map<YBlock>
-): ExecStatus {
-  const variable = getInputVariable(block, blocks)
-  switch (variable.status) {
-    case 'idle':
-      return variable.error ? 'error' : 'idle'
-    case 'save-requested':
-    case 'saving':
-      return 'loading'
+  executionQueue: ExecutionQueue
+): ExecutionStatus {
+  const blockId = getBaseAttributes(block).id
+  const executions = executionQueue.getBlockExecutions(
+    blockId,
+    'text-input-rename-variable'
+  )
+
+  const execution = head(executions)
+
+  if (!execution) {
+    return 'completed'
   }
+
+  return execution.item.getStatus()._tag
 }
 
 export function getInputBlockExecStatus(
   block: Y.XmlElement<InputBlock>,
-  blocks: Y.Map<YBlock>
-): ExecStatus {
-  const variableStatus = getInputVariableExecStatus(block, blocks)
-
+  executionQueue: ExecutionQueue
+): ExecutionStatus {
+  const variableStatus = getInputVariableExecStatus(block, executionQueue)
   switch (variableStatus) {
-    case 'success':
+    case 'unknown':
+    case 'completed':
     case 'idle':
-      return getInputValueExecStatus(block)
-    case 'loading':
+      return getInputValueExecStatus(block, executionQueue)
+    case 'running':
+      return 'running'
     case 'enqueued':
-      return 'loading'
-    case 'error':
-      return 'error'
+      return 'enqueued'
+    case 'aborting':
+      return 'aborting'
   }
 }
 
+export function getInputBlockResultStatus(
+  block: Y.XmlElement<InputBlock>,
+  blocks: Y.Map<YBlock>
+): ResultStatus {
+  const variable = getInputVariable(block, blocks)
+  const value = getInputValue(block)
+
+  if (variable.error || value.error) {
+    return 'error'
+  }
+
+  return value.error ? 'error' : 'success'
+}
+
 function getAvailableInputVariable(
   blocks: Y.Map<YBlock>
 ): InputBlock['variable'] {
@@ -247,34 +264,10 @@ function getAvailableInputVariable(
   return {
     value: `input_${i}`,
     newValue: `input_${i}`,
-    status: 'idle',
     error: null,
   }
 }
 
-export function inputRequestSaveValue(
-  block: Y.XmlElement<InputBlock>,
-  status?: 'saving' | 'save-requested'
-): void {
-  const operation = () => {
-    const oldVal = block.getAttribute('value')
-    if (!oldVal) {
-      return
-    }
-
-    block.setAttribute('value', {
-      ...oldVal,
-      status: status ?? 'save-requested',
-    })
-  }
-
-  if (block.doc) {
-    block.doc.transact(operation)
-  } else {
-    operation()
-  }
-}
-
 export function getInputBlockExecutedAt(
   block: Y.XmlElement<InputBlock>,
   blocks: Y.Map<YBlock>
diff --git a/packages/editor/src/blocks/pivotTable.ts b/packages/editor/src/blocks/pivotTable.ts
index ec59a4f6..26cc22b0 100644
--- a/packages/editor/src/blocks/pivotTable.ts
+++ b/packages/editor/src/blocks/pivotTable.ts
@@ -2,20 +2,22 @@ import * as Y from 'yjs'
 import {
   BlockType,
   BaseBlock,
-  ExecStatus,
   getAttributeOr,
   getBaseAttributes,
   duplicateBaseAttributes,
   YBlock,
   EditableField,
+  ResultStatus,
 } from './index.js'
-import { clone } from 'ramda'
+import { clone, head } from 'ramda'
 import {
   AggregateFunction,
   DataFrameColumn,
   PivotTableResult,
   PivotTableSort,
 } from '@briefer/types'
+import { ExecutionStatus } from '../execution/item.js'
+import { ExecutionQueue } from '../execution/queue.js'
 
 export type PivotTableRow = {
   column: DataFrameColumn | null
@@ -29,16 +31,6 @@ export type PivotTableMetric = {
 }
 
 export type PivotTableBlock = BaseBlock<BlockType.PivotTable> & {
-  status:
-    | 'idle'
-    | 'run-requested'
-    | 'running'
-    | 'run-all-enqueued'
-    | 'run-all-running'
-    | 'page-requested'
-    | 'loading-page'
-    | 'abort-requested'
-    | 'aborting'
   dataframeName: string | null
   variable: EditableField<'invalid-variable-name' | 'unexpected-error'>
   rows: PivotTableRow[]
@@ -63,7 +55,6 @@ export const makePivotTableBlock = (
     index: null,
     title: '',
     type: BlockType.PivotTable,
-    status: 'idle',
     variable: getAvailablePivotVariable(blocks),
     dataframeName: null,
     rows: [{ column: null }],
@@ -100,7 +91,6 @@ export function getPivotTableAttributes(
 
   return {
     ...getBaseAttributes(block),
-    status: getAttributeOr(block, 'status', 'idle'),
     dataframeName: getAttributeOr(block, 'dataframeName', null),
     variable,
     rows: getAttributeOr(block, 'rows', []),
@@ -127,7 +117,6 @@ export function duplicatePivotTableBlock(
 
   const nextAttributes: PivotTableBlock = {
     ...duplicateBaseAttributes(newId, prevAttributes),
-    status: prevAttributes.status,
     dataframeName: prevAttributes.dataframeName,
     variable: newVariable
       ? getAvailablePivotVariable(blocks)
@@ -155,30 +144,24 @@ export function duplicatePivotTableBlock(
 }
 
 export function getPivotTableBlockExecStatus(
-  block: Y.XmlElement<PivotTableBlock>
-): ExecStatus {
-  const status = block.getAttribute('status')
-
-  switch (status) {
-    case undefined:
-    case 'idle':
-      return getPivotTableBlockResultStatus(block)
-    case 'run-all-enqueued':
-      return 'enqueued'
-    case 'run-requested':
-    case 'running':
-    case 'run-all-running':
-    case 'abort-requested':
-    case 'aborting':
-    case 'page-requested':
-    case 'loading-page':
-      return 'loading'
+  block: Y.XmlElement<PivotTableBlock>,
+  executionQueue: ExecutionQueue
+): ExecutionStatus {
+  const blockId = getBaseAttributes(block).id
+  const executions = executionQueue
+    .getBlockExecutions(blockId, 'pivot-table')
+    .concat(executionQueue.getBlockExecutions(blockId, 'pivot-table-load-page'))
+  const execution = head(executions)
+  if (execution) {
+    return execution.item.getStatus()._tag
   }
+
+  return 'completed'
 }
 
 export function getPivotTableBlockResultStatus(
   block: Y.XmlElement<PivotTableBlock>
-): ExecStatus {
+): ResultStatus {
   const error = block.getAttribute('error')
   const updatedAt = block.getAttribute('updatedAt')
 
@@ -248,7 +231,6 @@ function getAvailablePivotVariable(
   return {
     value: `pivot_table_${i}`,
     newValue: `pivot_table_${i}`,
-    status: 'idle',
     error: null,
   }
 }
diff --git a/packages/editor/src/blocks/python.ts b/packages/editor/src/blocks/python.ts
index 944fb484..f2b86657 100644
--- a/packages/editor/src/blocks/python.ts
+++ b/packages/editor/src/blocks/python.ts
@@ -4,31 +4,16 @@ import {
   BlockType,
   BaseBlock,
   YBlock,
-  ExecStatus,
   getAttributeOr,
   getBaseAttributes,
   duplicateBaseAttributes,
   duplicateYText,
 } from './index.js'
-import { updateYText } from '../index.js'
+import { ResultStatus, updateYText } from '../index.js'
 import { clone } from 'ramda'
 
 export type PythonBlock = BaseBlock<BlockType.Python> & {
   source: Y.Text
-  status:
-    | 'idle'
-    | 'run-requested'
-    | 'try-suggestion-requested'
-    | 'running'
-    | 'running-suggestion'
-    | 'abort-requested'
-    | 'aborting'
-    | 'run-all-enqueued'
-    | 'run-all-running'
-    | 'edit-with-ai-requested'
-    | 'edit-with-ai-running'
-    | 'fix-with-ai-requested'
-    | 'fix-with-ai-running'
   result: Output[]
   isResultHidden: boolean
   isCodeHidden: boolean
@@ -56,7 +41,6 @@ export const makePythonBlock = (
     title: '',
     type: BlockType.Python,
     source: new Y.Text(options?.source ?? ''),
-    status: 'idle',
     result: [],
     isResultHidden: false,
     isCodeHidden: false,
@@ -83,7 +67,6 @@ export function getPythonAttributes(
   return {
     ...getBaseAttributes(block),
     source: getPythonSource(block),
-    status: getAttributeOr(block, 'status', 'idle'),
     result: getPythonBlockResult(block),
     isResultHidden: getAttributeOr(block, 'isResultHidden', false),
     isCodeHidden: getAttributeOr(block, 'isCodeHidden', false),
@@ -107,7 +90,6 @@ export function duplicatePythonBlock(
   const nextAttributes: PythonBlock = {
     ...duplicateBaseAttributes(newId, prevAttributes),
     source: duplicateYText(prevAttributes.source),
-    status: options?.noState ? 'idle' : prevAttributes.status,
     result: options?.noState ? [] : clone(prevAttributes.result),
     isResultHidden: options?.noState ? false : prevAttributes.isResultHidden,
     isCodeHidden: options?.noState ? false : prevAttributes.isCodeHidden,
@@ -136,34 +118,9 @@ export function duplicatePythonBlock(
   return yBlock
 }
 
-export function getPythonBlockExecStatus(
-  block: Y.XmlElement<PythonBlock>
-): ExecStatus {
-  const status = block.getAttribute('status')
-  switch (status) {
-    case undefined:
-    case 'idle':
-    case 'edit-with-ai-requested':
-    case 'edit-with-ai-running':
-    case 'fix-with-ai-requested':
-    case 'fix-with-ai-running':
-      return getPythonBlockResultStatus(block)
-    case 'run-all-enqueued':
-      return 'enqueued'
-    case 'run-requested':
-    case 'try-suggestion-requested':
-    case 'running':
-    case 'running-suggestion':
-    case 'abort-requested':
-    case 'aborting':
-    case 'run-all-running':
-      return 'loading'
-  }
-}
-
 export function getPythonBlockResultStatus(
   block: Y.XmlElement<PythonBlock>
-): ExecStatus {
+): ResultStatus {
   const lastQueryTime = block.getAttribute('lastQueryTime')
   if (!lastQueryTime) {
     return 'idle'
@@ -192,30 +149,6 @@ export function getPythonAISuggestions(
   return getAttributeOr(block, 'aiSuggestions', null)
 }
 
-export function isPythonBlockAIEditing(
-  block: Y.XmlElement<PythonBlock>
-): boolean {
-  const status = block.getAttribute('status')
-  switch (status) {
-    case 'edit-with-ai-requested':
-    case 'edit-with-ai-running':
-      return true
-    case 'idle':
-    case 'run-requested':
-    case 'try-suggestion-requested':
-    case 'running':
-    case 'running-suggestion':
-    case 'abort-requested':
-    case 'aborting':
-    case 'run-all-enqueued':
-    case 'run-all-running':
-    case 'fix-with-ai-requested':
-    case 'fix-with-ai-running':
-    case undefined:
-      return false
-  }
-}
-
 export function getPythonBlockEditWithAIPrompt(
   block: Y.XmlElement<PythonBlock>
 ): Y.Text {
@@ -263,10 +196,6 @@ export function closePythonEditWithAIPrompt(
   }
 }
 
-export function requestPythonEditWithAI(block: Y.XmlElement<PythonBlock>) {
-  block.setAttribute('status', 'edit-with-ai-requested')
-}
-
 export function updatePythonAISuggestions(
   block: Y.XmlElement<PythonBlock>,
   suggestions: string
@@ -280,34 +209,6 @@ export function updatePythonAISuggestions(
   updateYText(aiSuggestions, suggestions)
 }
 
-export function requestPythonFixWithAI(block: Y.XmlElement<PythonBlock>) {
-  block.setAttribute('status', 'fix-with-ai-requested')
-}
-
-export function isFixingPythonWithAI(
-  block: Y.XmlElement<PythonBlock>
-): boolean {
-  const status = block.getAttribute('status')
-  switch (status) {
-    case 'fix-with-ai-requested':
-    case 'fix-with-ai-running':
-      return true
-    case 'idle':
-    case 'run-requested':
-    case 'try-suggestion-requested':
-    case 'running':
-    case 'running-suggestion':
-    case 'abort-requested':
-    case 'aborting':
-    case 'run-all-enqueued':
-    case 'run-all-running':
-    case 'edit-with-ai-requested':
-    case 'edit-with-ai-running':
-    case undefined:
-      return false
-  }
-}
-
 export function getPythonBlockResult(
   block: Y.XmlElement<PythonBlock>
 ): Output[] {
diff --git a/packages/editor/src/blocks/richText.ts b/packages/editor/src/blocks/richText.ts
index a1b4b0cd..dfa2d342 100644
--- a/packages/editor/src/blocks/richText.ts
+++ b/packages/editor/src/blocks/richText.ts
@@ -3,11 +3,11 @@ import {
   BlockType,
   BaseBlock,
   YBlock,
-  ExecStatus,
   getAttributeOr,
   getBaseAttributes,
   duplicateBaseAttributes,
 } from './index.js'
+import { ExecutionStatus } from '../execution/item.js'
 
 export type RichTextBlock = BaseBlock<BlockType.RichText> & {
   content: Y.XmlFragment
@@ -68,8 +68,8 @@ export function duplicateRichTextBlock(
 
 export function getRichTextBlockExecStatus(
   _block: Y.XmlElement<RichTextBlock>
-): ExecStatus {
-  return 'idle'
+): ExecutionStatus {
+  return 'completed'
 }
 
 function duplicateYXmlFragment(fragment: Y.XmlFragment): Y.XmlFragment {
diff --git a/packages/editor/src/blocks/sql.ts b/packages/editor/src/blocks/sql.ts
index 5e3d2879..5168c4a4 100644
--- a/packages/editor/src/blocks/sql.ts
+++ b/packages/editor/src/blocks/sql.ts
@@ -4,39 +4,22 @@ import {
   BlockType,
   BaseBlock,
   YBlock,
-  ExecStatus,
   getAttributeOr,
   getBaseAttributes,
   duplicateBaseAttributes,
   duplicateYText,
 } from './index.js'
-import { updateYText } from '../index.js'
+import { ResultStatus, updateYText } from '../index.js'
 import { clone } from 'ramda'
 
 export type DataframeName = {
   value: string
   newValue: string
-  status: 'idle' | 'loading' | 'running'
   error?: 'invalid-name' | 'unexpected'
 }
 
 export type SQLBlock = BaseBlock<BlockType.SQL> & {
   source: Y.Text
-  status:
-    | 'idle'
-    | 'run-requested'
-    | 'try-suggestion-requested'
-    | 'running'
-    | 'running-suggestion'
-    | 'abort-requested'
-    | 'aborting'
-    | 'run-all-enqueued'
-    | 'run-all-running'
-    | 'edit-with-ai-requested'
-    | 'edit-with-ai-running'
-    | 'fix-with-ai-requested'
-    | 'fix-with-ai-running'
-  selectedCode: string | null
   dataframeName: DataframeName
   dataSourceId: string | null
   isFileDataSource: boolean
@@ -75,8 +58,6 @@ export const makeSQLBlock = (
     type: BlockType.SQL,
     title: '',
     source: new Y.Text(opts?.source ?? ''),
-    selectedCode: null,
-    status: 'idle',
     dataframeName: getDataframeName(blocks),
     dataSourceId: opts?.dataSourceId ?? null,
     isFileDataSource: opts?.isFileDataSource ?? false,
@@ -108,8 +89,6 @@ export function getSQLAttributes(
   return {
     ...getBaseAttributes(block),
     source: getSQLSource(block),
-    status: getAttributeOr(block, 'status', 'idle'),
-    selectedCode: getAttributeOr(block, 'selectedCode', null),
     dataframeName: getAttributeOr(
       block,
       'dataframeName',
@@ -150,8 +129,6 @@ export function duplicateSQLBlock(
   const nextAttributes: SQLBlock = {
     ...duplicateBaseAttributes(newId, prevAttributes),
     source: duplicateYText(prevAttributes.source),
-    status: options?.noState ? 'idle' : prevAttributes.status,
-    selectedCode: prevAttributes.selectedCode,
     dataframeName: clone(prevAttributes.dataframeName),
     dataSourceId: prevAttributes.dataSourceId
       ? options?.datasourceMap?.get(prevAttributes.dataSourceId) ??
@@ -201,38 +178,12 @@ function getDataframeName(blocks: Y.Map<YBlock>): DataframeName {
   return {
     value: `query_${i}`,
     newValue: `query_${i}`,
-    status: 'idle',
-  }
-}
-
-export function getSQLBlockExecStatus(
-  block: Y.XmlElement<SQLBlock>
-): ExecStatus {
-  const status = block.getAttribute('status')
-  switch (status) {
-    case undefined:
-    case 'idle':
-    case 'edit-with-ai-requested':
-    case 'edit-with-ai-running':
-    case 'fix-with-ai-requested':
-    case 'fix-with-ai-running':
-      return getSQLBlockResultStatus(block)
-    case 'run-all-enqueued':
-      return 'enqueued'
-    case 'run-requested':
-    case 'try-suggestion-requested':
-    case 'running':
-    case 'running-suggestion':
-    case 'abort-requested':
-    case 'aborting':
-    case 'run-all-running':
-      return 'loading'
   }
 }
 
 export function getSQLBlockResultStatus(
   block: Y.XmlElement<SQLBlock>
-): ExecStatus {
+): ResultStatus {
   const lastQueryTime = block.getAttribute('lastQueryTime')
   if (!lastQueryTime) {
     return 'idle'
@@ -263,28 +214,6 @@ export function getSQLAISuggestions(
   return getAttributeOr(block, 'aiSuggestions', null)
 }
 
-export function isSQLBlockAIEditing(block: Y.XmlElement<SQLBlock>): boolean {
-  const status = block.getAttribute('status')
-  switch (status) {
-    case 'edit-with-ai-requested':
-    case 'edit-with-ai-running':
-      return true
-    case 'idle':
-    case 'run-requested':
-    case 'try-suggestion-requested':
-    case 'running':
-    case 'running-suggestion':
-    case 'abort-requested':
-    case 'aborting':
-    case 'run-all-enqueued':
-    case 'run-all-running':
-    case 'fix-with-ai-requested':
-    case 'fix-with-ai-running':
-    case undefined:
-      return false
-  }
-}
-
 export function getSQLBlockEditWithAIPrompt(
   block: Y.XmlElement<SQLBlock>
 ): Y.Text {
@@ -330,10 +259,6 @@ export function closeSQLEditWithAIPrompt(
   }
 }
 
-export function requestSQLEditWithAI(block: Y.XmlElement<SQLBlock>) {
-  block.setAttribute('status', 'edit-with-ai-requested')
-}
-
 export function updateSQLAISuggestions(
   block: Y.XmlElement<SQLBlock>,
   suggestions: string
@@ -347,32 +272,6 @@ export function updateSQLAISuggestions(
   updateYText(aiSuggestions, suggestions)
 }
 
-export function requestSQLFixWithAI(block: Y.XmlElement<SQLBlock>) {
-  block.setAttribute('status', 'fix-with-ai-requested')
-}
-
-export function isFixingSQLWithAI(block: Y.XmlElement<SQLBlock>): boolean {
-  const status = block.getAttribute('status')
-  switch (status) {
-    case 'fix-with-ai-requested':
-    case 'fix-with-ai-running':
-      return true
-    case 'idle':
-    case 'run-requested':
-    case 'try-suggestion-requested':
-    case 'running':
-    case 'running-suggestion':
-    case 'abort-requested':
-    case 'aborting':
-    case 'run-all-enqueued':
-    case 'run-all-running':
-    case 'edit-with-ai-requested':
-    case 'edit-with-ai-running':
-    case undefined:
-      return false
-  }
-}
-
 export function getSQLBlockExecutedAt(
   block: Y.XmlElement<SQLBlock>,
   blocks: Y.Map<YBlock>
diff --git a/packages/editor/src/blocks/visualization.ts b/packages/editor/src/blocks/visualization.ts
index ce5aef50..f60d15f7 100644
--- a/packages/editor/src/blocks/visualization.ts
+++ b/packages/editor/src/blocks/visualization.ts
@@ -3,10 +3,10 @@ import {
   BlockType,
   BaseBlock,
   YBlock,
-  ExecStatus,
   getAttributeOr,
   getBaseAttributes,
   duplicateBaseAttributes,
+  ResultStatus,
 } from './index.js'
 import {
   AggregateFunction,
@@ -22,14 +22,6 @@ import {
 import { clone } from 'ramda'
 
 export type VisualizationBlock = BaseBlock<BlockType.Visualization> & {
-  status:
-    | 'idle'
-    | 'run-requested'
-    | 'running'
-    | 'run-all-enqueued'
-    | 'run-all-running'
-    | 'abort-requested'
-    | 'aborting'
   dataframeName: string | null
   spec: JsonObject | null
   chartType: ChartType
@@ -75,7 +67,6 @@ export const makeVisualizationBlock = (
     index: null,
     title: '',
     type: BlockType.Visualization,
-    status: 'idle',
     dataframeName: null,
     spec: null,
     chartType: 'groupedColumn',
@@ -181,7 +172,6 @@ export function getVisualizationAttributes(
 
   return {
     ...getBaseAttributes(block),
-    status: getAttributeOr(block, 'status', 'idle'),
     dataframeName: getAttributeOr(block, 'dataframeName', null),
     spec: getAttributeOr(block, 'spec', null),
     chartType: getAttributeOr(block, 'chartType', 'groupedColumn'),
@@ -224,7 +214,6 @@ export function duplicateVisualizationBlock(
 
   const nextAttributes: VisualizationBlock = {
     ...duplicateBaseAttributes(newId, prevAttributes),
-    status: prevAttributes.status,
     dataframeName: prevAttributes.dataframeName,
     spec: clone(prevAttributes.spec),
     chartType: prevAttributes.chartType,
@@ -256,29 +245,9 @@ export function duplicateVisualizationBlock(
   return nextBlock
 }
 
-export function getVisualizationBlockExecStatus(
-  block: Y.XmlElement<VisualizationBlock>
-): ExecStatus {
-  const status = block.getAttribute('status')
-
-  switch (status) {
-    case undefined:
-    case 'idle':
-      return getVisualizationBlockResultStatus(block)
-    case 'run-all-enqueued':
-      return 'enqueued'
-    case 'run-requested':
-    case 'running':
-    case 'run-all-running':
-    case 'abort-requested':
-    case 'aborting':
-      return 'loading'
-  }
-}
-
 export function getVisualizationBlockResultStatus(
   block: Y.XmlElement<VisualizationBlock>
-): ExecStatus {
+): ResultStatus {
   const error = block.getAttribute('error')
   const updatedAt = block.getAttribute('updatedAt')
 
diff --git a/packages/editor/src/blocks/writeback.ts b/packages/editor/src/blocks/writeback.ts
index 222e459d..7e0d62d4 100644
--- a/packages/editor/src/blocks/writeback.ts
+++ b/packages/editor/src/blocks/writeback.ts
@@ -6,19 +6,15 @@ import {
   getBaseAttributes,
   duplicateBaseAttributes,
   duplicateYText,
-  ExecStatus,
+  ResultStatus,
+  YBlock,
 } from './index.js'
 import { WriteBackErrorResult, WriteBackResult } from '@briefer/types'
+import { ExecutionStatus } from '../execution/item.js'
+import { ExecutionQueue } from '../execution/queue.js'
+import { head } from 'ramda'
 
 export type WritebackBlock = BaseBlock<BlockType.Writeback> & {
-  status:
-    | 'idle'
-    | 'run-requested'
-    | 'running'
-    | 'abort-requested'
-    | 'aborting'
-    | 'run-all-enqueued'
-    | 'run-all-running'
   dataframeName: string | null
   dataSourceId: string | null
   tableName: Y.Text
@@ -38,7 +34,6 @@ export const makeWritebackBlock = (
     index: null,
     type: BlockType.Writeback,
     title: '',
-    status: 'idle',
     dataframeName: null,
     dataSourceId: null,
     tableName: new Y.Text(''),
@@ -65,7 +60,6 @@ export function getWritebackAttributes(
 ): WritebackBlock {
   return {
     ...getBaseAttributes(block),
-    status: block.getAttribute('status') ?? 'idle',
     dataframeName: getAttributeOr(block, 'dataframeName', null),
     dataSourceId: getAttributeOr(block, 'dataSourceId', null),
     tableName: getAttributeOr(block, 'tableName', new Y.Text('')),
@@ -86,7 +80,6 @@ export function duplicateWritebackBlock(
 
   const nextAttributes: WritebackBlock = {
     ...duplicateBaseAttributes(newId, prevAttributes),
-    status: 'idle',
     dataframeName: prevAttributes.dataframeName,
     dataSourceId: prevAttributes.dataSourceId
       ? options?.datasourceMap?.get(prevAttributes.dataSourceId) ??
@@ -197,36 +190,36 @@ export function getWritebackBlockErrorMessage(
 }
 
 export function getWritebackBlockExecStatus(
-  block: Y.XmlElement<WritebackBlock>
-): ExecStatus {
-  const status = block.getAttribute('status')
-  switch (status) {
-    case undefined:
-    case 'idle':
-      return getWritebackBlockResultStatus(block)
-    case 'run-all-enqueued':
-      return 'enqueued'
-    case 'run-requested':
-    case 'running':
-    case 'abort-requested':
-    case 'aborting':
-    case 'run-all-running':
-      return 'loading'
+  block: Y.XmlElement<WritebackBlock>,
+  executionQueue: ExecutionQueue
+): ExecutionStatus {
+  const blockId = getBaseAttributes(block).id
+  const executions = executionQueue.getBlockExecutions(blockId, 'writeback')
+
+  const execution = head(executions)
+  if (execution) {
+    return execution.item.getStatus()._tag
   }
+
+  return 'completed'
 }
 
 export function getWritebackBlockResultStatus(
   block: Y.XmlElement<WritebackBlock>
-): ExecStatus {
+): ResultStatus {
   const result = block.getAttribute('result')
-  if (result) {
-    switch (result._tag) {
-      case 'success':
-        return 'success'
-      case 'error':
-        return 'error'
-    }
+  switch (result?._tag) {
+    case 'success':
+      return 'success'
+    case 'error':
+      return 'error'
+    case undefined:
+      return 'idle'
   }
+}
 
-  return 'idle'
+export function isWritebackBlock(
+  block: YBlock
+): block is Y.XmlElement<WritebackBlock> {
+  return block.getAttribute('type') === BlockType.Writeback
 }
diff --git a/packages/editor/src/execution/batch.ts b/packages/editor/src/execution/batch.ts
new file mode 100644
index 00000000..1a213968
--- /dev/null
+++ b/packages/editor/src/execution/batch.ts
@@ -0,0 +1,164 @@
+import * as Y from 'yjs'
+import {
+  ExecutionQueueItem,
+  ExecutionStatus,
+  YExecutionQueueItem,
+} from './item.js'
+import { exhaustiveCheck } from '../utils.js'
+
+export type YExecutionQueueBatchAttrs = {
+  version: 1
+  queue: Y.Array<YExecutionQueueItem>
+  isRunAll: boolean
+  scheduleId: string | null
+}
+
+export type YExecutionQueueBatch = Y.XmlElement<YExecutionQueueBatchAttrs>
+
+export function createYExecutionQueueBatch(
+  items: YExecutionQueueItem[],
+  { isRunAll, scheduleId }: { isRunAll: boolean; scheduleId: string | null }
+): YExecutionQueueBatch {
+  const queue = new Y.Array<YExecutionQueueItem>()
+  queue.insert(0, items)
+
+  const attrs: YExecutionQueueBatchAttrs = {
+    version: 1,
+    queue,
+    isRunAll,
+    scheduleId,
+  }
+  const el = new Y.XmlElement<YExecutionQueueBatchAttrs>()
+
+  for (const [key, value] of Object.entries(attrs)) {
+    el.setAttribute(
+      // @ts-ignore
+      key,
+      value
+    )
+  }
+
+  return el
+}
+
+export class ExecutionQueueBatch {
+  private constructor(private readonly batch: YExecutionQueueBatch) {}
+
+  public isRunAll(): boolean {
+    return this.batch.getAttribute('isRunAll') ?? false
+  }
+
+  public getScheduleId(): string | null {
+    return this.batch.getAttribute('scheduleId') ?? null
+  }
+
+  public getCurrent(): ExecutionQueueItem | null {
+    const queue = this.batch.getAttribute('queue')
+    if (!queue) {
+      return null
+    }
+
+    for (const raw of queue) {
+      const item = ExecutionQueueItem.fromYjs(raw)
+      if (item.getCompleteStatus() === null) {
+        return item
+      }
+    }
+
+    return null
+  }
+
+  public *[Symbol.iterator](): Iterator<ExecutionQueueItem> {
+    const yQueue = this.batch.getAttribute('queue') ?? new Y.Array()
+    for (const raw of yQueue) {
+      yield ExecutionQueueItem.fromYjs(raw)
+    }
+  }
+
+  public toJSON() {
+    return {
+      version: 1,
+      queue: (this.batch.getAttribute('queue') ?? new Y.Array()).map((raw) =>
+        ExecutionQueueItem.fromYjs(raw)
+      ),
+      isRunAll: this.batch.getAttribute('isRunAll') ?? false,
+    }
+  }
+
+  public abort() {
+    for (const item of this) {
+      if (item.getStatus()._tag !== 'completed') {
+        item.setAborting()
+      }
+    }
+  }
+
+  public waitForCompletion(): Promise<string | null> {
+    return new Promise((resolve) => {
+      let current = this.getCurrent()
+      const onObservation = () => {
+        if (!current) {
+          this.batch.unobserveDeep(onObservation)
+          resolve(null)
+          return
+        }
+
+        if (current.getCompleteStatus() === 'error') {
+          this.batch.unobserveDeep(onObservation)
+          resolve(current.getBlockId())
+          return
+        }
+
+        current = this.getCurrent()
+        if (!current) {
+          this.batch.unobserveDeep(onObservation)
+          resolve(null)
+          return
+        }
+      }
+      this.batch.observeDeep(onObservation)
+    })
+  }
+
+  public get length(): number {
+    return this.batch.getAttribute('queue')?.length ?? 0
+  }
+
+  public get remaining(): number {
+    let currentPos = 0
+    for (const item of this) {
+      if (item.getCompleteStatus() !== null) {
+        currentPos++
+      } else {
+        break
+      }
+    }
+
+    return this.length - currentPos
+  }
+
+  public get status(): ExecutionStatus {
+    let status: ExecutionStatus = 'unknown'
+    for (const item of this) {
+      const itemStatus = item.getStatus()._tag
+      switch (itemStatus) {
+        case 'completed':
+          status = 'completed'
+          break
+        case 'running':
+        case 'unknown':
+        case 'aborting':
+        case 'enqueued':
+          return itemStatus
+        default:
+          exhaustiveCheck(itemStatus)
+      }
+    }
+
+    return status
+  }
+
+  public static fromYjs(doc: YExecutionQueueBatch): ExecutionQueueBatch {
+    return new ExecutionQueueBatch(doc)
+  }
+}
diff --git a/packages/editor/src/execution/index.ts b/packages/editor/src/execution/index.ts
new file mode 100644
index 00000000..1c2d5869
--- /dev/null
+++ b/packages/editor/src/execution/index.ts
@@ -0,0 +1,3 @@
+export * from './queue.js'
+export * from './batch.js'
+export * from './item.js'
diff --git a/packages/editor/src/execution/item.ts b/packages/editor/src/execution/item.ts
new file mode 100644
index 00000000..188f3ff2
--- /dev/null
+++ b/packages/editor/src/execution/item.ts
@@ -0,0 +1,285 @@
+import * as Y from 'yjs'
+import * as z from 'zod'
+import { uuidSchema } from '../utils.js'
+
+export const ExecutionQueueItemStatus = z.union([
+  z.object({
+    _tag: z.literal('enqueued'),
+  }),
+  z.object({
+    _tag: z.literal('aborting'),
+  }),
+  z.object({
+    _tag: z.literal('running'),
+  }),
+  z.object({
+    _tag: z.literal('completed'),
+    status: z.union([
+      z.literal('success'),
+      z.literal('error'),
+      z.literal('aborted'),
+    ]),
+  }),
+  z.object({
+    _tag: z.literal('unknown'),
+  }),
+])
+export type ExecutionQueueItemStatus = z.infer<typeof ExecutionQueueItemStatus>
+
+export type ExecutionStatus = ExecutionQueueItemStatus['_tag'] | 'idle'
+
+export function isExecutionStatusLoading(
+  status: ExecutionStatus
+): status is 'running' | 'aborting' | 'enqueued' {
+  switch (status) {
+    case 'enqueued':
+    case 'running':
+    case 'aborting':
+      return true
+    case 'idle':
+    case 'completed':
+    case 'unknown':
+      return false
+  }
+}
+
+export const ExecutionQueueItemPythonMetadata = z.object({
+  _tag: z.literal('python'),
+  isSuggestion: z.boolean(),
+})
+export type ExecutionQueueItemPythonMetadata = z.infer<
+  typeof ExecutionQueueItemPythonMetadata
+>
+
+export const ExecutionQueueItemSQLMetadata = z.object({
+  _tag: z.literal('sql'),
+  isSuggestion: z.boolean(),
+  selectedCode: z.string().nullable(),
+})
+export type ExecutionQueueItemSQLMetadata = z.infer<
+  typeof ExecutionQueueItemSQLMetadata
+>
+
+export const ExecutionQueueItemSQLRenameDataframeMetadata = z.object({
+  _tag: z.literal('sql-rename-dataframe'),
+})
+export type ExecutionQueueItemSQLRenameDataframeMetadata = z.infer<
+  typeof ExecutionQueueItemSQLRenameDataframeMetadata
+>
+
+export const ExecutionQueueItemVisualizationMetadata = z.object({
+  _tag: z.literal('visualization'),
+})
+export type ExecutionQueueItemVisualizationMetadata = z.infer<
+  typeof ExecutionQueueItemVisualizationMetadata
+>
+
+export const ExecutionQueueItemTextInputSaveValueMetadata = z.object({
+  _tag: z.literal('text-input-save-value'),
+})
+export type ExecutionQueueItemTextInputSaveValueMetadata = z.infer<
+  typeof ExecutionQueueItemTextInputSaveValueMetadata
+>
+
+export const ExecutionQueueItemTextInputRenameVariableMetadata = z.object({
+  _tag: z.literal('text-input-rename-variable'),
+})
+export type ExecutionQueueItemTextInputRenameVariableMetadata = z.infer<
+  typeof ExecutionQueueItemTextInputRenameVariableMetadata
+>
+
+export const ExecutionQueueItemDropdownInputSaveValueMetadata = z.object({
+  _tag: z.literal('dropdown-input-save-value'),
+})
+export type ExecutionQueueItemDropdownInputSaveValueMetadata = z.infer<
+  typeof ExecutionQueueItemDropdownInputSaveValueMetadata
+>
+
+export const ExecutionQueueItemDropdownInputRenameVariableMetadata = z.object({
+  _tag: z.literal('dropdown-input-rename-variable'),
+})
+export type ExecutionQueueItemDropdownInputRenameVariableMetadata = z.infer<
+  typeof ExecutionQueueItemDropdownInputRenameVariableMetadata
+>
+
+export const ExecutionQueueItemDateInputMetadata = z.object({
+  _tag: z.literal('date-input'),
+})
+export type ExecutionQueueItemDateInputMetadata = z.infer<
+  typeof ExecutionQueueItemDateInputMetadata
+>
+
+export const ExecutionQueueItemPivotTableMetadata = z.object({
+  _tag: z.literal('pivot-table'),
+})
+export type ExecutionQueueItemPivotTableMetadata = z.infer<
+  typeof ExecutionQueueItemPivotTableMetadata
+>
+
+export const ExecutionQueueItemPivotTableLoadPageMetadata = z.object({
+  _tag: z.literal('pivot-table-load-page'),
+})
+export type ExecutionQueueItemPivotTableLoadPageMetadata = z.infer<
+  typeof ExecutionQueueItemPivotTableLoadPageMetadata
+>
+
+export const ExecutionQueueItemWritebackMetadata = z.object({
+  _tag: z.literal('writeback'),
+})
+export type ExecutionQueueItemWritebackMetadata = z.infer<
+  typeof ExecutionQueueItemWritebackMetadata
+>
+
+export const ExecutionQueueItemNoopMetadata = z.object({
+  _tag: z.literal('noop'),
+})
+export type ExecutionQueueItemNoopMetadata = z.infer<
+  typeof ExecutionQueueItemNoopMetadata
+>
+
+export const ExecutionQueueItemMetadata = z.union([
+  ExecutionQueueItemPythonMetadata,
+  ExecutionQueueItemSQLMetadata,
+  ExecutionQueueItemSQLRenameDataframeMetadata,
+  ExecutionQueueItemVisualizationMetadata,
+  ExecutionQueueItemTextInputSaveValueMetadata,
+  ExecutionQueueItemTextInputRenameVariableMetadata,
+  ExecutionQueueItemDateInputMetadata,
+  ExecutionQueueItemDropdownInputSaveValueMetadata,
+  ExecutionQueueItemDropdownInputRenameVariableMetadata,
+  ExecutionQueueItemPivotTableMetadata,
+  ExecutionQueueItemPivotTableLoadPageMetadata,
+  ExecutionQueueItemWritebackMetadata,
+  ExecutionQueueItemNoopMetadata,
+])
+export type ExecutionQueueItemMetadata = z.infer<
+  typeof ExecutionQueueItemMetadata
+>
+
+export type ExecutionQueueItemMetadataWithoutNoop = Exclude<
+  ExecutionQueueItemMetadata,
+  ExecutionQueueItemNoopMetadata
+>
+
+export const ExecutionQueueItemAttrs = z.object({
+  blockId: uuidSchema,
+  userId: z.nullable(uuidSchema),
+  status: ExecutionQueueItemStatus,
+  metadata: ExecutionQueueItemMetadata,
+})
+export type YExecutionQueueItemAttrs = z.infer<typeof ExecutionQueueItemAttrs>
+
+export type YExecutionQueueItem = Y.XmlElement<YExecutionQueueItemAttrs>
+
+export function createYExecutionQueueItem(
+  blockId: string,
+  userId: string | null,
+  metadata: ExecutionQueueItemMetadataWithoutNoop
+): YExecutionQueueItem {
+  const el = new Y.XmlElement<YExecutionQueueItemAttrs>()
+  const attrs: YExecutionQueueItemAttrs = {
+    blockId,
+    userId,
+    status: { _tag: 'enqueued' },
+    metadata,
+  }
+  for (const [key, value] of Object.entries(attrs)) {
+    el.setAttribute(
+      // @ts-ignore
+      key,
+      value
+    )
+  }
+  return el
+}
+
+export type MetadataCallbacks<T> = {
+  python: (metadata: ExecutionQueueItemPythonMetadata) => T
+  sql: (metadata: ExecutionQueueItemSQLMetadata) => T
+}
+
+export class ExecutionQueueItem {
+  private constructor(private readonly item: YExecutionQueueItem) {}
+  private statusObservers: Set<(status: ExecutionQueueItemStatus) => void> =
+    new Set()
+
+  public getCompleteStatus(): 'success' | 'error' | 'aborted' | null {
+    const status = this.getStatus()
+    switch (status._tag) {
+      case 'completed':
+        return status.status
+      case 'enqueued':
+      case 'running':
+      case 'aborting':
+      case 'unknown':
+        return null
+    }
+  }
+
+  public getMetadata(): ExecutionQueueItemMetadata {
+    return this.item.getAttribute('metadata') ?? { _tag: 'noop' }
+  }
+
+  public getBlockId(): string {
+    return this.item.getAttribute('blockId') ?? ''
+  }
+
+  public getUserId(): string | null {
+    return this.item.getAttribute('userId') ?? null
+  }
+
+  public setRunning(): void {
+    this.item.setAttribute('status', { _tag: 'running' })
+  }
+
+  public setAborting(): void {
+    this.item.setAttribute('status', { _tag: 'aborting' })
+  }
+
+  public setCompleted(status: 'success' | 'error' | 'aborted'): void {
+    this.item.setAttribute('status', { _tag: 'completed', status })
+  }
+
+  public getStatus(): ExecutionQueueItemStatus {
+    const raw = this.item.getAttribute('status')
+    if (!raw) {
+      return { _tag: 'unknown' }
+    }
+
+    const parsed = ExecutionQueueItemStatus.safeParse(raw)
+    if (!parsed.success) {
+      return { _tag: 'unknown' }
+    }
+
+    return parsed.data
+  }
+
+  public observeStatus(
+    callback: (status: ExecutionQueueItemStatus) => void
+  ): () => void {
+    this.statusObservers.add(callback)
+    if (this.statusObservers.size === 1) {
+      this.item.observeDeep(this.onStatusObservation)
+    }
+
+    return () => {
+      this.statusObservers.delete(callback)
+      if (this.statusObservers.size === 0) {
+        this.item.unobserveDeep(this.onStatusObservation)
+      }
+    }
+  }
+
+  public toJSON() {
+    return this.item.getAttributes()
+  }
+
+  private onStatusObservation = () => {
+    const status = this.getStatus()
+    this.statusObservers.forEach((cb) => cb(status))
+  }
+
+  public static fromYjs(item: YExecutionQueueItem): ExecutionQueueItem {
+    return new ExecutionQueueItem(item)
+  }
+}
diff --git a/packages/editor/src/execution/queue.ts b/packages/editor/src/execution/queue.ts
new file mode 100644
index 00000000..f3d0a292
--- /dev/null
+++ b/packages/editor/src/execution/queue.ts
@@ -0,0 +1,351 @@
+import * as Y from 'yjs'
+import {
+  createYExecutionQueueBatch,
+  ExecutionQueueBatch,
+  YExecutionQueueBatch,
+} from './batch.js'
+import {
+  createYExecutionQueueItem,
+  ExecutionQueueItem,
+  ExecutionQueueItemMetadataWithoutNoop,
+  YExecutionQueueItem,
+} from './item.js'
+import {
+  computeDepencyQueue,
+  getBaseAttributes,
+  getBlocks,
+  getLayout,
+  getTabsFromBlockGroupId,
+  switchBlockType,
+  YBlock,
+  YBlockGroup,
+} from '../index.js'
+
+export type YExecutionQueue = Y.Array<YExecutionQueueBatch>
+
+export type Execution = {
+  item: ExecutionQueueItem
+  batch: ExecutionQueueBatch
+}
+
+export type ExecutionQueueOptions = {
+  skipDependencyCheck: boolean
+}
+
+const defaultOptions: ExecutionQueueOptions = {
+  skipDependencyCheck: false,
+}
+
+export type RunAllSource =
+  | { _tag: 'user'; userId: string }
+  | { _tag: 'schedule'; scheduleId: string }
+
+export class ExecutionQueue {
+  private readonly queue: YExecutionQueue
+  private readonly blocks: Y.Map<YBlock>
+  private readonly layout: Y.Array<YBlockGroup>
+  private readonly observers: Set<() => void> = new Set()
+  private readonly options: ExecutionQueueOptions
+
+  private constructor(
+    doc: Y.Doc,
+    options: Partial<ExecutionQueueOptions> = {}
+  ) {
+    this.queue = doc.getArray<YExecutionQueueBatch>('executionQueue')
+    this.blocks = getBlocks(doc)
+    this.layout = getLayout(doc)
+    this.options = {
+      ...defaultOptions,
+      ...options,
+    }
+  }
+
+  public getCurrentBatch(): ExecutionQueueBatch | null {
+    const batch = this.queue.get(0)
+    if (!batch) {
+      return null
+    }
+
+    return ExecutionQueueBatch.fromYjs(batch)
+  }
+
+  public advance(): void {
+    this.queue.delete(0)
+  }
+
+  public enqueueBlock(
+    blockId: string | YBlock,
+    userId: string | null,
+    environmentStartedAt: string | null,
+    metadata: ExecutionQueueItemMetadataWithoutNoop
+  ): void {
+    const bId =
+      typeof blockId === 'string' ? blockId : getBaseAttributes(blockId).id
+    const block =
+      typeof blockId === 'string' ? this.blocks.get(blockId) : blockId
+    if (!block) {
+      return
+    }
+
+    const items: YExecutionQueueItem[] = []
+
+    const dependencies = computeDepencyQueue(
+      block,
+      this.layout,
+      this.blocks,
+      this.options.skipDependencyCheck,
+      environmentStartedAt
+    )
+    for (const dep of dependencies) {
+      const metadata = this.getExecutionQueueMetadataForBlock(dep)
+      if (metadata) {
+        const blockId = getBaseAttributes(dep).id
+        items.push(createYExecutionQueueItem(blockId, userId, metadata))
+      }
+    }
+
+    const item = createYExecutionQueueItem(bId, userId, metadata)
+    items.push(item)
+
+    const batch = createYExecutionQueueBatch(items, {
+      isRunAll: false,
+      scheduleId: null,
+    })
+    this.queue.push([batch])
+  }
+
+  public enqueueBlockGroup(
+    yDoc: Y.Doc,
+    blockGroupId: string,
+    userId: string | null
+  ): void {
+    const blocks = getBlocks(yDoc)
+    const tabs = getTabsFromBlockGroupId(getLayout(yDoc), blocks, blockGroupId)
+    const items: YExecutionQueueItem[] = []
+    for (const tab of tabs) {
+      const block = blocks.get(tab.blockId)
+      if (!block) {
+        continue
+      }
+
+      const metadata = this.getExecutionQueueMetadataForBlock(block)
+
+      if (!metadata) {
+        continue
+      }
+
+      items.push(createYExecutionQueueItem(tab.blockId, userId, metadata))
+    }
+
+    const batch = createYExecutionQueueBatch(items, {
+      isRunAll: false,
+      scheduleId: null,
+    })
+    this.queue.push([batch])
+  }
+
+  public enqueueBlockOnwards(
+    blockId: string | YBlock,
+    userId: string | null,
+    environmentStartedAt: string | null,
+    metadata: ExecutionQueueItemMetadataWithoutNoop
+  ): void {
+    const block =
+      typeof blockId === 'string' ? this.blocks.get(blockId) : blockId
+    if (!block) {
+      return
+    }
+
+    const blocksAfter: YExecutionQueueItem[] = []
+    let found = false
+    for (const blockGroup of this.layout) {
+      const tabs = blockGroup.getAttribute('tabs') ?? new Y.Array()
+      for (const tab of tabs) {
+        const tabBlockId = tab.getAttribute('id')
+        if (!tabBlockId) {
+          continue
+        }
+
+        if (tabBlockId === blockId) {
+          found = true
+          continue
+        }
+
+        if (found) {
+          const block = this.blocks.get(tabBlockId)
+          if (block) {
+            const metadata = this.getExecutionQueueMetadataForBlock(block)
+            if (!metadata) {
+              continue
+            }
+
+            blocksAfter.push(
+              createYExecutionQueueItem(tabBlockId, userId, metadata)
+            )
+          }
+        }
+      }
+    }
+
+    const bId =
+      typeof blockId === 'string' ? blockId : getBaseAttributes(blockId).id
+
+    const dependencies = computeDepencyQueue(
+      block,
+      this.layout,
+      this.blocks,
+      this.options.skipDependencyCheck,
+      environmentStartedAt
+    )
+
+    const blocksBefore: YExecutionQueueItem[] = []
+    for (const dep of dependencies) {
+      const metadata = this.getExecutionQueueMetadataForBlock(dep)
+      if (!metadata) {
+        continue
+      }
+
+      const blockId = getBaseAttributes(dep).id
+      blocksBefore.push(createYExecutionQueueItem(blockId, userId, metadata))
+    }
+
+    const item = createYExecutionQueueItem(bId, userId, metadata)
+
+    const batch = createYExecutionQueueBatch(
+      [...blocksBefore, item, ...blocksAfter],
+      {
+        isRunAll: false,
+        scheduleId: null,
+      }
+    )
+    this.queue.push([batch])
+  }
+
+  public enqueueRunAll(
+    layout: Y.Array<YBlockGroup>,
+    blocks: Y.Map<YBlock>,
+    source: RunAllSource
+  ): ExecutionQueueBatch {
+    const items: YExecutionQueueItem[] = []
+    const userId = source._tag === 'user' ? source.userId : null
+
+    layout.forEach((group) => {
+      const tabs = group.getAttribute('tabs')
+      if (!tabs) {
+        return
+      }
+
+      tabs.forEach((tab) => {
+        const blockId = tab.getAttribute('id')
+        if (!blockId) {
+          return
+        }
+
+        const block = blocks.get(blockId)
+        if (!block) {
+          return
+        }
+
+        const metadata = this.getExecutionQueueMetadataForBlock(block)
+        if (metadata) {
+          const item = createYExecutionQueueItem(blockId, userId, metadata)
+          items.push(item)
+        }
+      })
+    })
+
+    const batch = createYExecutionQueueBatch(items, {
+      isRunAll: true,
+      scheduleId: source._tag === 'schedule' ? source.scheduleId : null,
+    })
+    this.queue.push([batch])
+    return ExecutionQueueBatch.fromYjs(batch)
+  }
+
+  public getBlockExecutions(
+    blockId: string,
+    tag?: ExecutionQueueItemMetadataWithoutNoop['_tag']
+  ): Execution[] {
+    const executions: Execution[] = []
+    for (const yBatch of this.queue) {
+      const batch = ExecutionQueueBatch.fromYjs(yBatch)
+      for (const item of batch) {
+        if (
+          item.getBlockId() === blockId &&
+          (tag === undefined || item.getMetadata()._tag === tag)
+        ) {
+          executions.push({ item, batch })
+        }
+      }
+    }
+
+    return executions
+  }
+
+  public observe(cb: () => void): () => void {
+    this.observers.add(cb)
+    if (this.observers.size === 1) {
+      this.queue.observeDeep(this.onObservation)
+    }
+
+    return () => {
+      this.observers.delete(cb)
+      if (this.observers.size === 0) {
+        this.queue.unobserveDeep(this.onObservation)
+      }
+    }
+  }
+
+  public toJSON(): ExecutionQueueBatch[] {
+    return this.queue.map((batch) => ExecutionQueueBatch.fromYjs(batch))
+  }
+
+  public getRunAllBatches(): ExecutionQueueBatch[] {
+    return this.queue
+      .toArray()
+      .filter((batch) => batch.getAttribute('isRunAll'))
+      .map((batch) => ExecutionQueueBatch.fromYjs(batch))
+  }
+
+  private onObservation = () => {
+    for (const observer of this.observers) {
+      observer()
+    }
+  }
+
+  public getExecutionQueueMetadataForBlock(
+    block: YBlock
+  ): ExecutionQueueItemMetadataWithoutNoop | null {
+    return switchBlockType<ExecutionQueueItemMetadataWithoutNoop | null>(
+      block,
+      {
+        onSQL: () => ({
+          _tag: 'sql',
+          isSuggestion: false,
+          selectedCode: null,
+        }),
+        onPython: () => ({ _tag: 'python', isSuggestion: false }),
+        onVisualization: () => ({ _tag: 'visualization' }),
+        onInput: () => ({ _tag: 'text-input-save-value' }),
+        onDateInput: () => ({ _tag: 'date-input' }),
+        onDropdownInput: () => ({ _tag: 'dropdown-input-save-value' }),
+        onWriteback: () => ({ _tag: 'writeback' }),
+        onPivotTable: () => ({ _tag: 'pivot-table' }),
+        onFileUpload: () => null,
+        onRichText: () => null,
+        onDashboardHeader: () => null,
+      }
+    )
+  }
+
+  public get length(): number {
+    return this.queue.length
+  }
+
+  public static fromYjs(
+    doc: Y.Doc,
+    options: Partial<ExecutionQueueOptions> = {}
+  ): ExecutionQueue {
+    return new ExecutionQueue(doc, options)
+  }
+}
diff --git a/packages/editor/src/index.ts b/packages/editor/src/index.ts
index eb57b106..124d91e1 100644
--- a/packages/editor/src/index.ts
+++ b/packages/editor/src/index.ts
@@ -27,10 +27,11 @@ import { PivotTableBlock } from './blocks/pivotTable.js'
 
 export * from './operations/index.js'
 export * from './blocks/index.js'
-export * from './run-all.js'
 export * from './dashboard.js'
 export * from './metadata.js'
 export * from './component.js'
+export * from './execution/index.js'
+export * from './ai-tasks/index.js'
 
 export function getBlocks(doc: Y.Doc) {
   const map = doc.getMap<YBlock>('blocks')
diff --git a/packages/editor/src/operations/blockGroup.ts b/packages/editor/src/operations/blockGroup.ts
index 726309e8..51886ee0 100644
--- a/packages/editor/src/operations/blockGroup.ts
+++ b/packages/editor/src/operations/blockGroup.ts
@@ -1,11 +1,6 @@
 import * as Y from 'yjs'
 import { v4 as uuidv4 } from 'uuid'
-import {
-  BlockType,
-  ExecStatus,
-  YBlock,
-  getExecStatus,
-} from '../blocks/index.js'
+import { BlockType, YBlock } from '../blocks/index.js'
 import { duplicateBlock } from '../blocks/index.js'
 import {
   getBlocks,
@@ -21,7 +16,6 @@ export type TabRef = {
   type: BlockType
   title: string | null
   isCurrent: boolean
-  execStatus: ExecStatus
   isHiddenInPublished: boolean
 }
 
@@ -188,7 +182,6 @@ export const getTabsFromBlockGroup = (
         blockId,
         type,
         title: block.getAttribute('title') ?? null,
-        execStatus: getExecStatus(block, yBlocks),
         isCurrent,
         isHiddenInPublished: tab.getAttribute('isHiddenInPublished') ?? false,
       }
diff --git a/packages/editor/src/run-all.ts b/packages/editor/src/run-all.ts
deleted file mode 100644
index 9153df27..00000000
--- a/packages/editor/src/run-all.ts
+++ /dev/null
@@ -1,69 +0,0 @@
-import * as Y from 'yjs'
-import { getAttributeOr } from './blocks/index.js'
-
-export type RunAll = {
-  type: 'run-all'
-  status:
-    | 'idle'
-    | 'run-requested'
-    | 'running'
-    | 'abort-requested'
-    | 'aborting'
-    | 'schedule-running'
-  remaining: number
-  total: number
-}
-
-export type YRunAll = Y.XmlElement<RunAll>
-
-export function isRunAll(el: Y.XmlElement): boolean {
-  return el.getAttribute('type') === 'run-all'
-}
-
-export function getRunAll(doc: Y.Doc): YRunAll {
-  const el = doc.getXmlElement('runAll') as YRunAll
-
-  const defaultAttributes: RunAll = {
-    type: 'run-all',
-    status: 'idle',
-    remaining: 0,
-    total: 0,
-  }
-
-  for (const [key, value] of Object.entries(defaultAttributes)) {
-    if (!el.hasAttribute(key)) {
-      el.setAttribute(
-        // @ts-ignore
-        key,
-        value
-      )
-    }
-  }
-
-  return el
-}
-
-export function getRunAllAttributes(el: YRunAll): RunAll {
-  return {
-    type: getAttributeOr(el, 'type', 'run-all'),
-    status: getAttributeOr(el, 'status', 'idle'),
-    remaining: getAttributeOr(el, 'remaining', 0),
-    total: getAttributeOr(el, 'total', 0),
-  }
-}
-
-export function isRunAllLoading(el: YRunAll): boolean {
-  const status = getRunAllAttributes(el).status
-
-  switch (status) {
-    case 'idle':
-    case undefined:
-      return false
-    case 'run-requested':
-    case 'running':
-    case 'abort-requested':
-    case 'aborting':
-    case 'schedule-running':
-      return true
-  }
-}
diff --git a/packages/editor/src/utils.ts b/packages/editor/src/utils.ts
new file mode 100644
index 00000000..baa72496
--- /dev/null
+++ b/packages/editor/src/utils.ts
@@ -0,0 +1,8 @@
+import { validate as validateUUID } from 'uuid'
+import * as z from 'zod'
+
+export function exhaustiveCheck(_param: never) {}
+
+export const uuidSchema = z.string().refine((uuid) => validateUUID(uuid), {
+  message: 'Invalid UUID format',
+})
diff --git a/packages/types/src/index.ts b/packages/types/src/index.ts
index 1194c37f..a9ded46b 100644
--- a/packages/types/src/index.ts
+++ b/packages/types/src/index.ts
@@ -1192,3 +1192,5 @@ export type SQLQueryConfiguration = {
     }
   }
 }
+
+export function exhaustiveCheck(_param: never) {}
diff --git a/yarn.lock b/yarn.lock
index bbb4d57c..09232f41 100644
--- a/yarn.lock
+++ b/yarn.lock
@@ -1939,6 +1939,11 @@
   dependencies:
     "@monaco-editor/loader" "^1.4.0"
 
+"@msgpack/msgpack@~2.8.0":
+  version "2.8.0"
+  resolved "https://registry.yarnpkg.com/@msgpack/msgpack/-/msgpack-2.8.0.tgz#4210deb771ee3912964f14a15ddfb5ff877e70b9"
+  integrity sha512-h9u4u/jiIRKbq25PM+zymTyW6bhTzELvOoUd+AvYriWOAKpLGnIamaET3pnHYoI5iYphAHBI4ayx0MehR+VVPQ==
+
 "@next/env@13.5.5":
   version "13.5.5"
   resolved "https://registry.yarnpkg.com/@next/env/-/env-13.5.5.tgz#c26fb9784fe4eae1279c0f2906d925c2297816e9"
@@ -2811,6 +2816,16 @@
   resolved "https://registry.yarnpkg.com/@socket.io/component-emitter/-/component-emitter-3.1.2.tgz#821f8442f4175d8f0467b9daf26e3a18e2d02af2"
   integrity sha512-9BCxFwvbGg/RsZK9tjXd8s4UcwR0MWeFQ1XEKIQVVvAGJyINdrqKMcTRyLoK8Rse1GjzLV9cwjWV1olXRWEXVA==
 
+"@socket.io/postgres-adapter@^0.4.0":
+  version "0.4.0"
+  resolved "https://registry.yarnpkg.com/@socket.io/postgres-adapter/-/postgres-adapter-0.4.0.tgz#21059765822815914877c1413863e4432a77ff1d"
+  integrity sha512-FJQslCIchoT4oMHk0D8HeSi9nhAOE8/snId65zI10ykZsk3MQJnUH45+Jqd75IuQhtxxwrvNxqHmzLJEPw9PnA==
+  dependencies:
+    "@msgpack/msgpack" "~2.8.0"
+    "@types/pg" "^8.6.6"
+    debug "~4.3.4"
+    pg "^8.9.0"
+
 "@swc/helpers@0.5.2":
   version "0.5.2"
   resolved "https://registry.yarnpkg.com/@swc/helpers/-/helpers-0.5.2.tgz#85ea0c76450b61ad7d10a37050289eded783c27d"
@@ -3591,7 +3606,7 @@
   resolved "https://registry.yarnpkg.com/@types/pbf/-/pbf-3.0.5.tgz#a9495a58d8c75be4ffe9a0bd749a307715c07404"
   integrity sha512-j3pOPiEcWZ34R6a6mN07mUkM4o4Lwf6hPNt8eilOeZhTFbxFXmKhvXl9Y28jotFPaI1bpPDJsbCprUoNke6OrA==
 
-"@types/pg@^8.10.7":
+"@types/pg@^8.10.7", "@types/pg@^8.6.6":
   version "8.11.10"
   resolved "https://registry.yarnpkg.com/@types/pg/-/pg-8.11.10.tgz#b8fb2b2b759d452fe3ec182beadd382563b63291"
   integrity sha512-LczQUW4dbOQzsH2RQ5qoeJ6qJPdrcM/DcMLoqWQkMLMsq83J5lAX3LXjdkWdpscFy67JSOWDnh7Ny/sPFykmkg==
@@ -5353,6 +5368,13 @@ cronstrue@^2.44.0:
   resolved "https://registry.yarnpkg.com/cronstrue/-/cronstrue-2.50.0.tgz#eabba0f915f186765258b707b7a3950c663b5573"
   integrity sha512-ULYhWIonJzlScCCQrPUG5uMXzXxSixty4djud9SS37DoNxDdkeRocxzHuAo4ImRBUK+mAuU5X9TSwEDccnnuPg==
 
+cross-env@^7.0.3:
+  version "7.0.3"
+  resolved "https://registry.yarnpkg.com/cross-env/-/cross-env-7.0.3.tgz#865264b29677dc015ba8418918965dd232fc54cf"
+  integrity sha512-+/HKd6EgcQCJGh2PSjZuUitQBQynKor4wrFbRg4DtAgS1aWO+gU52xpH7M9ScGgXSYmAVS9bIJ8EzuaGw0oNAw==
+  dependencies:
+    cross-spawn "^7.0.1"
+
 cross-spawn@^7.0.0, cross-spawn@^7.0.2, cross-spawn@^7.0.3:
   version "7.0.3"
   resolved "https://registry.yarnpkg.com/cross-spawn/-/cross-spawn-7.0.3.tgz#f73a85b9d5d41d045551c177e2882d4ac85728a6"
@@ -5362,6 +5384,15 @@ cross-spawn@^7.0.0, cross-spawn@^7.0.2, cross-spawn@^7.0.3:
     shebang-command "^2.0.0"
     which "^2.0.1"
 
+cross-spawn@^7.0.1:
+  version "7.0.6"
+  resolved "https://registry.yarnpkg.com/cross-spawn/-/cross-spawn-7.0.6.tgz#8a58fe78f00dcd70c370451759dfbfaf03e8ee9f"
+  integrity sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==
+  dependencies:
+    path-key "^3.1.0"
+    shebang-command "^2.0.0"
+    which "^2.0.1"
+
 css-font-size-keywords@^1.0.0:
   version "1.0.0"
   resolved "https://registry.yarnpkg.com/css-font-size-keywords/-/css-font-size-keywords-1.0.0.tgz#854875ace9aca6a8d2ee0d345a44aae9bb6db6cb"
@@ -10657,6 +10688,19 @@ pg@^8.11.3:
   optionalDependencies:
     pg-cloudflare "^1.1.1"
 
+pg@^8.13.1, pg@^8.9.0:
+  version "8.13.1"
+  resolved "https://registry.yarnpkg.com/pg/-/pg-8.13.1.tgz#6498d8b0a87ff76c2df7a32160309d3168c0c080"
+  integrity sha512-OUir1A0rPNZlX//c7ksiu7crsGZTKSOXJPgtNiHGIlC9H0lO+NC6ZDYksSgBYY/thSWhnSRBv8w1lieNNGATNQ==
+  dependencies:
+    pg-connection-string "^2.7.0"
+    pg-pool "^3.7.0"
+    pg-protocol "^1.7.0"
+    pg-types "^2.1.0"
+    pgpass "1.x"
+  optionalDependencies:
+    pg-cloudflare "^1.1.1"
+
 pgpass@1.x:
   version "1.0.5"
   resolved "https://registry.yarnpkg.com/pgpass/-/pgpass-1.0.5.tgz#9b873e4a564bb10fa7a7dbd55312728d422a223d"


Test patch:
diff --git a/apps/api/src/yjs/v2/executors/blocks/input.test.ts b/apps/api/src/yjs/v2/executors/blocks/input.test.ts
deleted file mode 100644
index 3bba1c87..00000000
--- a/apps/api/src/yjs/v2/executors/blocks/input.test.ts
+++ /dev/null
@@ -1,131 +0,0 @@
-import * as Y from 'yjs'
-import PQueue from 'p-queue'
-import { InputEffects, InputExecutor } from './input.js'
-import {
-  YBlock,
-  InputBlock,
-  makeInputBlock,
-  updateInputValue,
-  updateInputVariable,
-} from '@briefer/editor'
-
-describe('InputExecutor', () => {
-  let block: Y.XmlElement<InputBlock>
-  let blocksExecutor: InputExecutor
-  let effects: jest.Mocked<InputEffects>
-  let blocks: Y.Map<YBlock>
-  let queue: PQueue
-  beforeEach(() => {
-    const ydoc = new Y.Doc()
-    blocks = ydoc.getMap<YBlock>('blocks')
-    block = makeInputBlock('blockId', blocks)
-    blocks.set('blockId', block)
-    effects = {
-      setVariable: jest.fn(),
-    }
-
-    queue = new PQueue({ concurrency: 1 })
-    blocksExecutor = new InputExecutor(
-      'workspaceId',
-      'documentId',
-      blocks,
-      queue,
-      effects
-    )
-  })
-
-  describe('.isIdle', () => {
-    it('should return true when the executionQueue is empty', () => {
-      expect(blocksExecutor.isIdle()).toBe(true)
-    })
-
-    it('should return false when the executionQueue is not empty', async () => {
-      queue.add(() => Promise.resolve())
-      expect(blocksExecutor.isIdle()).toBe(false)
-    })
-
-    it('should return false when the executionQueue is empty but one item is still executing', async () => {
-      const queue = new PQueue({ concurrency: 1 })
-      const blocksExecutor = new InputExecutor(
-        'workspaceId',
-        'documentId',
-        new Y.Map(),
-        queue
-      )
-      // never resolve the promise
-      queue.add(() => new Promise(() => {}))
-      // let the item execute
-      // give it a tick to start executing
-      await new Promise((resolve) => setTimeout(resolve, 0))
-      expect(queue.size).toBe(0)
-      expect(blocksExecutor.isIdle()).toBe(false)
-    })
-  })
-
-  describe('saveVariable', () => {
-    it('should set error to invalid-variable-name when newValue is invalid', async () => {
-      updateInputVariable(block, blocks, {
-        status: 'saving',
-        newValue: 'invalid variable',
-      })
-
-      await blocksExecutor.saveVariable(block)
-
-      expect(block.getAttribute('variable')).toHaveProperty(
-        'error',
-        'invalid-variable-name'
-      )
-    })
-
-    it('should update variable in block after perform setVariable effect', async () => {
-      updateInputValue(block, { value: 'value' })
-
-      updateInputVariable(block, blocks, { value: 'old_variable' })
-      updateInputVariable(block, blocks, {
-        status: 'saving',
-        newValue: 'new_variable',
-      })
-
-      effects.setVariable.mockResolvedValue({
-        promise: Promise.resolve(),
-        abort: jest.fn(),
-      })
-      await blocksExecutor.saveVariable(block)
-
-      expect(effects.setVariable).toHaveBeenCalledWith(
-        'workspaceId',
-        'documentId',
-        'new_variable',
-        'value'
-      )
-      const variable = block.getAttribute('variable')
-      expect(variable).toHaveProperty('value', 'new_variable')
-      expect(variable).toHaveProperty('newValue', 'new_variable')
-      expect(variable).toHaveProperty('error', null)
-    })
-  })
-
-  describe('.saveValue', () => {
-    it('should update value in block after perform setVariable effect', async () => {
-      updateInputValue(block, { value: 'old_value' })
-      updateInputValue(block, { status: 'saving', newValue: 'new_value' })
-
-      effects.setVariable.mockResolvedValue({
-        promise: Promise.resolve(),
-        abort: jest.fn(),
-      })
-      await blocksExecutor.saveValue(block)
-
-      expect(effects.setVariable).toHaveBeenCalledWith(
-        'workspaceId',
-        'documentId',
-        'input_1',
-        'new_value'
-      )
-      const value = block.getAttribute('value')
-      expect(value).toHaveProperty('value', 'new_value')
-      expect(value).toHaveProperty('newValue', 'new_value')
-      expect(value).toHaveProperty('error', null)
-    })
-  })
-})
diff --git a/apps/api/src/yjs/v2/executors/blocks/python.test.ts b/apps/api/src/yjs/v2/executors/blocks/python.test.ts
deleted file mode 100644
index c73ebef3..00000000
--- a/apps/api/src/yjs/v2/executors/blocks/python.test.ts
+++ /dev/null
@@ -1,234 +0,0 @@
-import * as Y from 'yjs'
-import PQueue from 'p-queue'
-import { PythonEffects, PythonExecutor } from './python.js'
-import { YBlock, PythonBlock, makePythonBlock } from '@briefer/editor'
-import { DataFrame, Output } from '@briefer/types'
-import { PythonEvents } from '../../../../events/index.js'
-
-describe('PythonExecutor', () => {
-  let ydoc: Y.Doc
-  let block: Y.XmlElement<PythonBlock>
-  let blocksExecutor: PythonExecutor
-  let effects: jest.Mocked<PythonEffects>
-  let events: jest.Mocked<PythonEvents>
-  let dataframes: Y.Map<DataFrame>
-  let blocks: Y.Map<YBlock>
-  let queue: PQueue
-  beforeEach(() => {
-    ydoc = new Y.Doc()
-    dataframes = ydoc.getMap<DataFrame>('dataframes')
-    blocks = ydoc.getMap<YBlock>('blocks')
-    block = makePythonBlock('blockId')
-    blocks.set('blockId', block)
-    block.getAttribute('source')!.insert(0, 'print("3")')
-    effects = {
-      executePython: jest.fn(),
-      listDataFrames: jest.fn(),
-      editWithAI: jest.fn(),
-    }
-    events = {
-      aiUsage: jest.fn(),
-      pythonRun: jest.fn(),
-    }
-
-    queue = new PQueue({ concurrency: 1 })
-    blocksExecutor = new PythonExecutor(
-      'workspaceId',
-      'documentId',
-      dataframes,
-      blocks,
-      queue,
-      effects,
-      events
-    )
-  })
-
-  describe('.isIdle', () => {
-    it('should return true when the executionQueue is empty', () => {
-      expect(blocksExecutor.isIdle()).toBe(true)
-    })
-
-    it('should return false when the executionQueue is not empty', async () => {
-      queue.add(() => Promise.resolve())
-      expect(blocksExecutor.isIdle()).toBe(false)
-    })
-
-    it('should return false when the executionQueue is empty but one item is still executing', async () => {
-      // never resolve the promise
-      queue.add(() => new Promise(() => {}))
-      // let the item execute
-      // give it a tick to start executing
-      await new Promise((resolve) => setTimeout(resolve, 0))
-      expect(queue.size).toBe(0)
-      expect(blocksExecutor.isIdle()).toBe(false)
-    })
-  })
-
-  describe('run', () => {
-    it('should execute python by performing executePython effect', async () => {
-      block.setAttribute('status', 'running')
-      const prevTime = new Date(Date.now() - 1000 * 60)
-      block.setAttribute('lastQueryTime', prevTime.toISOString())
-      const result: Output[] = [{ type: 'stdio', name: 'stdout', text: '3' }]
-      effects.listDataFrames.mockResolvedValue([
-        {
-          name: 'df',
-          columns: [{ name: 'a', type: 'int' }],
-        },
-      ])
-      effects.executePython = jest.fn(
-        async (
-          _workspaceId: string,
-          _sessionId: string,
-          _code: string,
-          onOutputs: (outputs: Output[]) => void,
-          _opts: { killSession?: boolean; storeHistory: boolean }
-        ) => {
-          return {
-            promise: new Promise<void>((resolve) => {
-              onOutputs(result)
-              resolve()
-            }),
-            abort: jest.fn(),
-          }
-        }
-      )
-      await blocksExecutor.run(block, new Y.Transaction(ydoc, {}, true), false)
-
-      expect(effects.listDataFrames).toHaveBeenCalledWith(
-        'workspaceId',
-        'documentId'
-      )
-      expect(effects.executePython).toHaveBeenCalledWith(
-        'workspaceId',
-        'documentId',
-        'print("3")',
-        expect.any(Function),
-        { storeHistory: true }
-      )
-      expect(block.getAttribute('status')).toEqual('idle')
-      expect(block.getAttribute('result')).toEqual(result)
-      expect(
-        new Date(block.getAttribute('lastQueryTime')!).getTime()
-      ).toBeGreaterThan(prevTime.getTime())
-
-      expect(Array.from(dataframes.entries())).toEqual([
-        [
-          'df',
-          {
-            name: 'df',
-            columns: [{ name: 'a', type: 'int' }],
-            blockId: block.getAttribute('id'),
-          },
-        ],
-      ])
-    })
-
-    it('should set error states when running python fails', async () => {
-      block.setAttribute('status', 'running')
-      const prevTime = new Date(Date.now() - 1000 * 60)
-      block.setAttribute('lastQueryTime', prevTime.toISOString())
-      const result: Output[] = [
-        {
-          type: 'error',
-          ename: 'life',
-          evalue: 'is painful',
-          traceback: ['every', 'time'],
-        },
-      ]
-      effects.listDataFrames.mockResolvedValue([
-        {
-          name: 'df',
-          columns: [{ name: 'a', type: 'int' }],
-        },
-      ])
-      effects.executePython = jest.fn(
-        async (
-          _workspaceId: string,
-          _sessionId: string,
-          _code: string,
-          onOutputs: (outputs: Output[]) => void,
-          _opts: { killSession?: boolean; storeHistory: boolean }
-        ) => {
-          return {
-            promise: new Promise<void>((resolve) => {
-              onOutputs(result)
-              resolve()
-            }),
-            abort: jest.fn(),
-          }
-        }
-      )
-      await blocksExecutor.run(block, new Y.Transaction(ydoc, {}, true), false)
-
-      expect(effects.executePython).toHaveBeenCalledWith(
-        'workspaceId',
-        'documentId',
-        'print("3")',
-        expect.any(Function),
-        { storeHistory: true }
-      )
-      expect(block.getAttribute('result')).toEqual(result)
-      expect(
-        new Date(block.getAttribute('lastQueryTime')!).getTime()
-      ).toBeGreaterThan(prevTime.getTime())
-    })
-
-    it('should not crash when execution gets aborted from the queue', async () => {
-      let canResolve = false
-      queue.add(
-        () =>
-          new Promise<void>(async (resolve) => {
-            while (!canResolve) {
-              await new Promise((resolve) => setTimeout(resolve, 0))
-            }
-
-            resolve()
-          })
-      )
-
-      block.setAttribute('status', 'running')
-      const runP = blocksExecutor.run(
-        block,
-        new Y.Transaction(ydoc, {}, true),
-        false
-      )
-
-      // abort
-      await blocksExecutor.abort(block)
-      canResolve = true
-
-      await expect(runP).resolves.toBeUndefined()
-    })
-  })
-
-  describe('abort', () => {
-    it('should do nothing when not running', async () => {
-      await expect(blocksExecutor.abort(block)).resolves.toBeUndefined()
-    })
-
-    it('should abort the execution', async () => {
-      const abort = jest.fn().mockResolvedValue(undefined)
-      effects.executePython.mockResolvedValue({
-        promise: new Promise(async (resolve) => {
-          // only resolve once abort is called
-          while (!abort.mock.calls.length) {
-            await new Promise((resolve) => setTimeout(resolve, 0))
-          }
-          resolve()
-        }),
-        abort,
-      })
-      block.setAttribute('status', 'running')
-      const runningPromise = blocksExecutor.run(
-        block,
-        new Y.Transaction(ydoc, {}, true),
-        false
-      )
-      await blocksExecutor.abort(block)
-      await runningPromise
-      expect(abort).toHaveBeenCalled()
-      expect(block.getAttribute('result')).toEqual([])
-    })
-  })
-})
diff --git a/apps/api/src/yjs/v2/executors/blocks/sql.test.ts b/apps/api/src/yjs/v2/executors/blocks/sql.test.ts
deleted file mode 100644
index c082e7a3..00000000
--- a/apps/api/src/yjs/v2/executors/blocks/sql.test.ts
+++ /dev/null
@@ -1,458 +0,0 @@
-import * as Y from 'yjs'
-import { describe, expect, it } from '@jest/globals'
-import PQueue from 'p-queue'
-import { SQLEffects, SQLExecutor } from './sql.js'
-import {
-  YBlock,
-  SQLBlock,
-  makeSQLBlock,
-  getBaseAttributes,
-} from '@briefer/editor'
-import { DataSource } from '@briefer/database'
-import {
-  DataFrame,
-  RunQueryResult,
-  SQLQueryConfiguration,
-  SuccessRunQueryResult,
-} from '@briefer/types'
-import { SQLEvents } from '../../../../events/index.js'
-import { IJupyterManager } from '../../../../jupyter/index.js'
-
-describe('SQLExecutor', () => {
-  let ydoc: Y.Doc
-  let block: Y.XmlElement<SQLBlock>
-  let blocksExecutor: SQLExecutor
-  let effects: jest.Mocked<SQLEffects>
-  let events: jest.Mocked<SQLEvents>
-  let dataframes: Y.Map<DataFrame>
-  let blocks: Y.Map<YBlock>
-  let jupyterManager: jest.Mocked<IJupyterManager>
-  let queue: PQueue
-  beforeEach(() => {
-    ydoc = new Y.Doc()
-    dataframes = ydoc.getMap<DataFrame>('dataframes')
-    blocks = ydoc.getMap<YBlock>('blocks')
-    block = makeSQLBlock('blockId', blocks, { dataSourceId: 'datasourceId' })
-    blocks.set('blockId', block)
-    block.getAttribute('source')!.insert(0, 'print("3")')
-
-    effects = {
-      makeSQLQuery: jest.fn(),
-      renameDataFrame: jest.fn(),
-      listDataSources: jest.fn(),
-      listDataFrames: jest.fn(),
-      editWithAI: jest.fn(),
-      documentHasRunSQLSelectionEnabled: jest.fn(),
-    }
-    events = {
-      aiUsage: jest.fn(),
-      sqlRun: jest.fn(),
-    }
-    jupyterManager = {
-      start: jest.fn(),
-      stop: jest.fn(),
-      deploy: jest.fn(),
-      restart: jest.fn(),
-      ensureRunning: jest.fn(),
-      isRunning: jest.fn(),
-      fileExists: jest.fn(),
-      listFiles: jest.fn(),
-      getFile: jest.fn(),
-      putFile: jest.fn(),
-      deleteFile: jest.fn(),
-      getServerSettings: jest.fn(),
-      setEnvironmentVariables: jest.fn(),
-    }
-    queue = new PQueue({ concurrency: 1 })
-    blocksExecutor = new SQLExecutor(
-      'workspaceId',
-      'documentId',
-      'dataSourcesEncryptionKey',
-      dataframes,
-      blocks,
-      queue,
-      jupyterManager,
-      effects,
-      events
-    )
-  })
-
-  describe('.isIdle', () => {
-    it('should return true when the executionQueue is empty', () => {
-      expect(blocksExecutor.isIdle()).toBe(true)
-    })
-
-    it('should return false when the executionQueue is not empty', async () => {
-      queue.add(() => Promise.resolve())
-      expect(blocksExecutor.isIdle()).toBe(false)
-    })
-
-    it('should return false when the executionQueue is empty but one item is still executing', async () => {
-      // never resolve the promise
-      queue.add(() => new Promise(() => {}))
-      // let the item execute
-      // give it a tick to start executing
-      await new Promise((resolve) => setTimeout(resolve, 0))
-      expect(queue.size).toBe(0)
-      expect(blocksExecutor.isIdle()).toBe(false)
-    })
-  })
-
-  describe('runQuery', () => {
-    it('should execute sql by performing makeSQLQuery effect', async () => {
-      block.setAttribute('status', 'running')
-      const prevTime = new Date(Date.now() - 1000 * 60)
-      block.setAttribute('lastQueryTime', prevTime.toISOString())
-      // @ts-ignore
-      const datasource: DataSource = { data: { id: 'datasourceId' } }
-      effects.listDataSources.mockResolvedValue([datasource])
-      const result: SuccessRunQueryResult = {
-        type: 'success',
-        columns: [{ name: 'a', type: 'int' }],
-        rows: [],
-        count: 0,
-      }
-      effects.makeSQLQuery.mockImplementationOnce(
-        (
-          _workspaceId: string,
-          _sessionId: string,
-          _queryId: string,
-          _dataframeName: string,
-          _datasource: DataSource | 'duckdb',
-          _encryptionKey: string,
-          _sql: string,
-          onProgress: (result: SuccessRunQueryResult) => void,
-          _configuration: SQLQueryConfiguration | null
-        ) => {
-          onProgress(result)
-          return Promise.resolve([Promise.resolve(result), jest.fn()])
-        }
-      )
-
-      await blocksExecutor.runQuery(
-        block,
-        new Y.Transaction(ydoc, {}, true),
-        false,
-        false
-      )
-
-      expect(effects.makeSQLQuery).toHaveBeenCalledWith(
-        'workspaceId',
-        'documentId',
-        'blockId',
-        'query_1',
-        datasource,
-        'dataSourcesEncryptionKey',
-        'print("3")',
-        expect.any(Function),
-        null
-      )
-      expect(block.getAttribute('result')).toEqual(result)
-      expect(Array.from(dataframes.entries())).toEqual([
-        [
-          'query_1',
-          {
-            id: 'blockId',
-            name: 'query_1',
-            columns: [{ name: 'a', type: 'int' }],
-            updatedAt: expect.any(String),
-            blockId: getBaseAttributes(block).id,
-          },
-        ],
-      ])
-    })
-
-    it('should try AI suggestion for sql when calling run as a suggestion attempt', async () => {
-      block.setAttribute('status', 'running')
-      const prevTime = new Date(Date.now() - 1000 * 60)
-      block.setAttribute('lastQueryTime', prevTime.toISOString())
-      // @ts-ignore
-      const datasource: DataSource = { data: { id: 'datasourceId' } }
-      effects.listDataSources.mockResolvedValue([datasource])
-      const result: SuccessRunQueryResult = {
-        type: 'success',
-        columns: [{ name: 'a', type: 'int' }],
-        rows: [],
-        count: 0,
-      }
-      effects.makeSQLQuery.mockImplementationOnce(
-        (
-          _workspaceId: string,
-          _sessionId: string,
-          _queryId: string,
-          _dataframeName: string,
-          _datasource: DataSource | 'duckdb',
-          _encryptionKey: string,
-          _sql: string,
-          onProgress: (result: SuccessRunQueryResult) => void,
-          _configuration: SQLQueryConfiguration | null
-        ) => {
-          onProgress(result)
-          return Promise.resolve([Promise.resolve(result), jest.fn()])
-        }
-      )
-
-      block.setAttribute('aiSuggestions', new Y.Text('print("1337")'))
-      await blocksExecutor.runQuery(
-        block,
-        new Y.Transaction(ydoc, {}, true),
-        true,
-        false
-      )
-
-      expect(effects.makeSQLQuery).toHaveBeenCalledWith(
-        'workspaceId',
-        'documentId',
-        'blockId',
-        'query_1',
-        datasource,
-        'dataSourcesEncryptionKey',
-        'print("1337")',
-        expect.any(Function),
-        null
-      )
-      expect(block.getAttribute('result')).toEqual(result)
-      expect(Array.from(dataframes.entries())).toEqual([
-        [
-          'query_1',
-          {
-            id: 'blockId',
-            name: 'query_1',
-            columns: [{ name: 'a', type: 'int' }],
-            updatedAt: expect.any(String),
-            blockId: block.getAttribute('id'),
-          },
-        ],
-      ])
-    })
-
-    it('should not crash when execution gets aborted from the queue', async () => {
-      let canResolve = false
-      queue.add(
-        () =>
-          new Promise<void>(async (resolve) => {
-            while (!canResolve) {
-              await new Promise((resolve) => setTimeout(resolve, 0))
-            }
-            resolve()
-          })
-      )
-      const runningPromise = blocksExecutor.runQuery(
-        block,
-        new Y.Transaction(ydoc, {}, true),
-        false,
-        false
-      )
-      await blocksExecutor.abortQuery(block)
-      canResolve = true
-      await expect(runningPromise).resolves.toBeUndefined()
-      expect(block.getAttribute('result')).toEqual({
-        type: 'abort-error',
-        message: 'Query aborted',
-      })
-    })
-  })
-
-  describe('abortQuery', () => {
-    it('should do nothing when not running', async () => {
-      await blocksExecutor.abortQuery(block)
-      expect(effects.makeSQLQuery).not.toHaveBeenCalled()
-    })
-
-    it('should abort the execution', async () => {
-      const abort = jest.fn()
-      effects.listDataSources.mockResolvedValue([
-        // @ts-ignore
-        { data: { id: 'datasourceId' } },
-      ])
-      effects.makeSQLQuery.mockResolvedValue([
-        new Promise(async (resolve) => {
-          // only resolve once abort is called
-          while (abort.mock.calls.length === 0) {
-            await new Promise((resolve) => setTimeout(resolve, 0))
-          }
-
-          resolve({
-            type: 'abort-error',
-            message: 'Query aborted',
-          })
-        }),
-        abort,
-      ])
-      block.setAttribute('status', 'running')
-      const runningPromise = blocksExecutor.runQuery(
-        block,
-
-        new Y.Transaction(ydoc, {}, true),
-        false,
-        false
-      )
-
-      // wait until makeSQLQuery is called
-      while (!effects.makeSQLQuery.mock.calls.length) {
-        await new Promise((resolve) => setTimeout(resolve, 0))
-      }
-
-      await blocksExecutor.abortQuery(block)
-      expect(abort).toHaveBeenCalled()
-      await expect(runningPromise).resolves.toBeUndefined()
-      expect(block.getAttribute('result')).toEqual({
-        type: 'abort-error',
-        message: 'Query aborted',
-      })
-    })
-  })
-
-  describe('renameDataFrame', () => {
-    it('should set error to invalid-name when newValue is invalid', async () => {
-      block.setAttribute('dataframeName', {
-        value: 'query_1',
-        newValue: 'invalid name',
-        status: 'loading',
-      })
-
-      await blocksExecutor.renameDataFrame(block)
-
-      expect(block.getAttribute('dataframeName')).toEqual({
-        value: 'query_1',
-        newValue: 'invalid name',
-        status: 'loading',
-        error: 'invalid-name',
-      })
-    })
-
-    it('should just update to new value when result is not success', async () => {
-      const nonSuccessResults: RunQueryResult[] = [
-        { type: 'abort-error', message: 'Query aborted' },
-        { type: 'syntax-error', message: 'Syntax error' },
-        {
-          type: 'python-error',
-          ename: 'NameError',
-          evalue: 'NameError: name "x" is not defined',
-          traceback: ['line 1', 'line 2'],
-        },
-      ]
-
-      for (const result of nonSuccessResults) {
-        block.setAttribute('dataframeName', {
-          value: 'old_value',
-          newValue: 'new_value',
-          status: 'loading',
-        })
-        block.setAttribute('result', result)
-
-        await blocksExecutor.renameDataFrame(block)
-
-        expect(block.getAttribute('dataframeName')).toEqual({
-          value: 'new_value',
-          newValue: 'new_value',
-          status: 'loading',
-        })
-        expect(effects.makeSQLQuery).not.toHaveBeenCalled()
-        expect(effects.renameDataFrame).not.toHaveBeenCalled()
-        expect(effects.listDataSources).not.toHaveBeenCalled()
-      }
-    })
-
-    it('should just update to new value when query is running', async () => {
-      block.setAttribute('dataframeName', {
-        value: 'old_value',
-        newValue: 'new_value',
-        status: 'loading',
-      })
-      block.setAttribute('result', {
-        type: 'success',
-        columns: [],
-        rows: [],
-        count: 0,
-      })
-      block.setAttribute('status', 'running')
-
-      await blocksExecutor.renameDataFrame(block)
-
-      expect(block.getAttribute('dataframeName')).toEqual({
-        value: 'new_value',
-        newValue: 'new_value',
-        status: 'loading',
-      })
-      expect(effects.makeSQLQuery).not.toHaveBeenCalled()
-      expect(effects.renameDataFrame).not.toHaveBeenCalled()
-      expect(effects.listDataSources).not.toHaveBeenCalled()
-    })
-
-    it('should just update to new value in document when jupyter is not running', async () => {
-      block.setAttribute('dataframeName', {
-        value: 'old_value',
-        newValue: 'new_value',
-        status: 'loading',
-      })
-      block.setAttribute('result', {
-        type: 'success',
-        columns: [],
-        rows: [],
-        count: 0,
-      })
-      jupyterManager.isRunning.mockResolvedValue(false)
-
-      await blocksExecutor.renameDataFrame(block)
-
-      expect(block.getAttribute('dataframeName')).toEqual({
-        value: 'new_value',
-        newValue: 'new_value',
-        status: 'loading',
-      })
-      expect(effects.makeSQLQuery).not.toHaveBeenCalled()
-      expect(effects.renameDataFrame).not.toHaveBeenCalled()
-      expect(jupyterManager.isRunning).toHaveBeenCalledWith('workspaceId')
-      expect(effects.listDataSources).not.toHaveBeenCalled()
-    })
-
-    it('should rename the dataframe in document and jupyter when jupyter is running', async () => {
-      block.setAttribute('dataframeName', {
-        value: 'old_value',
-        newValue: 'new_value',
-        status: 'loading',
-      })
-      block.setAttribute('result', {
-        type: 'success',
-        columns: [],
-        rows: [],
-        count: 0,
-      })
-      jupyterManager.isRunning.mockResolvedValue(true)
-      effects.renameDataFrame.mockResolvedValue()
-      effects.listDataFrames.mockResolvedValue([
-        {
-          name: 'new_value',
-          columns: [{ name: 'a column', type: 'int' }],
-        },
-      ])
-
-      await blocksExecutor.renameDataFrame(block)
-
-      expect(effects.renameDataFrame).toHaveBeenCalledWith(
-        'workspaceId',
-        'documentId',
-        'old_value',
-        'new_value'
-      )
-      expect(block.getAttribute('dataframeName')).toEqual({
-        value: 'new_value',
-        newValue: 'new_value',
-        status: 'loading',
-      })
-      expect(dataframes.get('new_value')).toEqual({
-        name: 'new_value',
-        columns: [{ name: 'a column', type: 'int' }],
-        blockId: getBaseAttributes(block).id,
-      })
-      expect(effects.makeSQLQuery).not.toHaveBeenCalled()
-      expect(jupyterManager.isRunning).toHaveBeenCalledWith('workspaceId')
-      expect(effects.listDataSources).not.toHaveBeenCalled()
-      expect(effects.listDataFrames).toHaveBeenCalledWith(
-        'workspaceId',
-        'documentId'
-      )
-    })
-  })
-})
diff --git a/apps/api/src/yjs/v2/executors/blocks/visualization.test.ts b/apps/api/src/yjs/v2/executors/blocks/visualization.test.ts
deleted file mode 100644
index f265af88..00000000
--- a/apps/api/src/yjs/v2/executors/blocks/visualization.test.ts
+++ /dev/null
@@ -1,177 +0,0 @@
-import * as Y from 'yjs'
-import PQueue from 'p-queue'
-import { VisualizationEffects, VisualizationExecutor } from './visualization.js'
-import {
-  YBlock,
-  VisualizationBlock,
-  makeVisualizationBlock,
-} from '@briefer/editor'
-import { DataFrame } from '@briefer/types'
-import { VisEvents } from '../../../../events/index.js'
-
-describe('VisualizationExecutor', () => {
-  let ydoc: Y.Doc
-  let block: Y.XmlElement<VisualizationBlock>
-  let blocksExecutor: VisualizationExecutor
-  let effects: jest.Mocked<VisualizationEffects>
-  let events: jest.Mocked<VisEvents>
-  let dataframes: Y.Map<DataFrame>
-  let dataframe: DataFrame
-  let queue: PQueue
-
-  beforeEach(() => {
-    ydoc = new Y.Doc()
-    dataframes = ydoc.getMap<DataFrame>('dataframes')
-    const blocks = ydoc.getMap<YBlock>('blocks')
-    block = makeVisualizationBlock('blockId')
-    blocks.set('blockId', block)
-    effects = {
-      createVisualization: jest.fn(),
-    }
-    events = {
-      visUpdate: jest.fn(),
-    }
-    queue = new PQueue({ concurrency: 1 })
-    blocksExecutor = new VisualizationExecutor(
-      'workspaceId',
-      'documentId',
-      dataframes,
-      queue,
-      effects,
-      events
-    )
-
-    block.setAttribute('dataframeName', 'df')
-    block.setAttribute('chartType', 'groupedColumn')
-    block.setAttribute('xAxis', { name: 'a', type: 'string' })
-    block.setAttribute('yAxes', [
-      {
-        series: [
-          {
-            axisName: null,
-            chartType: null,
-            column: { name: 'b', type: 'int' },
-            aggregateFunction: null,
-            colorBy: null,
-          },
-        ],
-      },
-    ])
-    dataframe = {
-      name: 'df',
-      columns: [
-        { name: 'a', type: 'string' },
-        { name: 'b', type: 'int' },
-      ],
-    }
-    dataframes.set('df', dataframe)
-  })
-
-  describe('.isIdle', () => {
-    it('should return true when the executionQueue is empty', () => {
-      expect(blocksExecutor.isIdle()).toBe(true)
-    })
-
-    it('should return false when the executionQueue is not empty', async () => {
-      queue.add(() => Promise.resolve())
-      expect(blocksExecutor.isIdle()).toBe(false)
-    })
-
-    it('should return false when the executionQueue is empty but one item is still executing', async () => {
-      // never resolve the promise
-      queue.add(() => new Promise(() => {}))
-      // let the item execute
-      // give it a tick to start executing
-      await new Promise((resolve) => setTimeout(resolve, 0))
-      expect(queue.size).toBe(0)
-      expect(blocksExecutor.isIdle()).toBe(false)
-    })
-  })
-
-  describe('run', () => {
-    it('should create the visualization by performing the createVisualization effect', async () => {
-      const prevUpdatedAt = new Date(Date.now() - 1000 * 60).toISOString()
-      block.setAttribute('updatedAt', prevUpdatedAt)
-      block.setAttribute('status', 'running')
-      const spec = {
-        a: 'cool',
-        chart: '=]',
-      }
-      effects.createVisualization.mockResolvedValue({
-        promise: Promise.resolve({
-          success: true,
-          spec,
-          filterResults: {},
-        }),
-        abort: jest.fn(),
-      })
-      await blocksExecutor.run(block, new Y.Transaction(ydoc, {}, true))
-
-      expect(effects.createVisualization).toHaveBeenCalledWith(
-        'workspaceId',
-        'documentId',
-        dataframe,
-        'groupedColumn',
-        { name: 'a', type: 'string' },
-        null,
-        null,
-        'ascending',
-        [
-          {
-            series: [
-              {
-                axisName: null,
-                aggregateFunction: null,
-                chartType: null,
-                colorBy: null,
-                column: { name: 'b', type: 'int' },
-              },
-            ],
-          },
-        ],
-        'count',
-        { type: 'auto' },
-        false,
-        null,
-        []
-      )
-      expect(block.getAttribute('spec')).toEqual(spec)
-      expect(block.getAttribute('error')).toEqual(null)
-      expect(
-        new Date(block.getAttribute('updatedAt')!).getTime()
-      ).toBeGreaterThan(new Date(prevUpdatedAt).getTime())
-    })
-
-    it('should set error when createVisualization fails with dataframe-not-found', async () => {
-      block.setAttribute('status', 'running')
-      effects.createVisualization.mockResolvedValue({
-        promise: Promise.resolve({
-          success: false,
-          reason: 'dataframe-not-found',
-          filterResults: {},
-        }),
-        abort: jest.fn(),
-      })
-      await blocksExecutor.run(block, new Y.Transaction(ydoc, {}, true))
-
-      expect(block.getAttribute('error')).toEqual('dataframe-not-found')
-    })
-
-    it('should do nothing when createVisualization fails with aborted', async () => {
-      block.setAttribute('status', 'running')
-
-      const prevAttributes = block.getAttributes()
-      effects.createVisualization.mockResolvedValue({
-        promise: Promise.resolve({
-          success: false,
-          reason: 'aborted',
-          filterResults: {},
-        }),
-        abort: jest.fn(),
-      })
-      await blocksExecutor.run(block, new Y.Transaction(ydoc, {}, true))
-
-      expect(block.getAttributes()).toEqual(prevAttributes)
-    })
-  })
-})
diff --git a/apps/api/src/yjs/v2/executors/index.test.ts b/apps/api/src/yjs/v2/executors/index.test.ts
deleted file mode 100644
index a6510c7d..00000000
--- a/apps/api/src/yjs/v2/executors/index.test.ts
+++ /dev/null
@@ -1,68 +0,0 @@
-import * as Y from 'yjs'
-import { DataFrame } from '@briefer/types'
-import { updateDataframes } from './index.js'
-
-describe('updateDataframes', () => {
-  it('should only update dataframes with new columns', () => {
-    const ydoc = new Y.Doc()
-    const dataframes = ydoc.getMap<DataFrame>()
-    const oldDate = new Date(Date.now() - 1000 * 60).toISOString()
-    dataframes.set('keep', {
-      name: 'keep',
-      columns: [{ name: 'a', type: 'int' }],
-      updatedAt: oldDate,
-    })
-    dataframes.set('remove', {
-      name: 'remove',
-      columns: [{ name: 'a', type: 'int' }],
-      updatedAt: oldDate,
-      blockId: 'notInDocumentAnymore',
-    })
-    dataframes.set('update', {
-      name: 'update',
-      columns: [{ name: 'a', type: 'int' }],
-      updatedAt: oldDate,
-    })
-
-    const newDate = new Date().toISOString()
-    const newDataframes: DataFrame[] = [
-      {
-        name: 'keep',
-        columns: [{ name: 'a', type: 'int' }],
-        updatedAt: newDate,
-      },
-      {
-        name: 'add',
-        columns: [{ name: 'a', type: 'int' }],
-        updatedAt: newDate,
-      },
-      {
-        name: 'update',
-        columns: [{ name: 'b', type: 'int' }],
-        updatedAt: newDate,
-      },
-    ]
-
-    updateDataframes(dataframes, newDataframes, 'blockId', new Set(['blockId']))
-
-    expect(dataframes.get('keep')).toEqual({
-      name: 'keep',
-      columns: [{ name: 'a', type: 'int' }],
-      updatedAt: oldDate,
-    })
-    expect(dataframes.get('remove')).toBeUndefined()
-    expect(dataframes.get('add')).toEqual({
-      name: 'add',
-      columns: [{ name: 'a', type: 'int' }],
-      updatedAt: newDate,
-      blockId: 'blockId',
-    })
-    expect(dataframes.get('update')).toEqual({
-      name: 'update',
-      columns: [{ name: 'b', type: 'int' }],
-      updatedAt: newDate,
-      blockId: 'blockId',
-    })
-    expect(dataframes.size).toBe(3)
-  })
-})
diff --git a/apps/api/src/yjs/v2/observers/blocks/index.test.ts b/apps/api/src/yjs/v2/observers/blocks/index.test.ts
deleted file mode 100644
index ace469c7..00000000
--- a/apps/api/src/yjs/v2/observers/blocks/index.test.ts
+++ /dev/null
@@ -1,147 +0,0 @@
-import * as Y from 'yjs'
-import { YBlock } from '@briefer/editor'
-import { BlocksObserver } from './index.js'
-import { IInputObserver } from './input.js'
-import { IPythonObserver } from './python.js'
-import { ISQLObserver } from './sql.js'
-import { IVisualizationObserver } from './visualization.js'
-import { IFileUploadObserver } from './file-upload.js'
-import { IDropdownInputObserver } from './dropdown-input.js'
-import { IWritebackObserver } from './writeback.js'
-import { IDateInputObserver } from './date-input.js'
-import { IPivotTableObserver } from './pivot-table.js'
-
-describe('BlocksObserver', () => {
-  describe('isIdle', () => {
-    let sqlObserver: jest.Mocked<ISQLObserver>
-    let pythonObserver: jest.Mocked<IPythonObserver>
-    let visualizationObserver: jest.Mocked<IVisualizationObserver>
-    let inputObserver: jest.Mocked<IInputObserver>
-    let dropdownInputObserver: jest.Mocked<IDropdownInputObserver>
-    let dateInputObserver: jest.Mocked<IDateInputObserver>
-    let fileUploadObserver: jest.Mocked<IFileUploadObserver>
-    let writebackObserver: jest.Mocked<IWritebackObserver>
-    let pivotTableObserver: jest.Mocked<IPivotTableObserver>
-    let blocks: Y.Map<YBlock>
-    let blocksObserver: BlocksObserver
-    const doc = new Y.Doc()
-    blocks = doc.getMap<YBlock>('blocks')
-
-    sqlObserver = {
-      isIdle: jest.fn(),
-      handleInitialBlockState: jest.fn(),
-      handleBlockEvent: jest.fn(),
-    }
-    pythonObserver = {
-      isIdle: jest.fn(),
-      handleInitialBlockState: jest.fn(),
-      handleBlockEvent: jest.fn(),
-    }
-    visualizationObserver = {
-      isIdle: jest.fn(),
-      handleInitialBlockState: jest.fn(),
-      handleBlockEvent: jest.fn(),
-    }
-    inputObserver = {
-      isIdle: jest.fn(),
-      handleInitialBlockState: jest.fn(),
-      handleBlockEvent: jest.fn(),
-    }
-    dropdownInputObserver = {
-      isIdle: jest.fn(),
-      handleInitialBlockState: jest.fn(),
-      handleBlockEvent: jest.fn(),
-    }
-    dateInputObserver = {
-      isIdle: jest.fn(),
-      handleInitialBlockState: jest.fn(),
-      handleBlockEvent: jest.fn(),
-    }
-    fileUploadObserver = {
-      isIdle: jest.fn(),
-      handleInitialBlockState: jest.fn(),
-      handleBlockEvent: jest.fn(),
-    }
-    writebackObserver = {
-      isIdle: jest.fn(),
-      handleInitialBlockState: jest.fn(),
-      handleBlockEvent: jest.fn(),
-    }
-    pivotTableObserver = {
-      isIdle: jest.fn(),
-      handleInitialBlockState: jest.fn(),
-      handleBlockEvent: jest.fn(),
-    }
-    blocksObserver = new BlocksObserver(
-      'workspace-id',
-      'document-id',
-      blocks,
-      sqlObserver,
-      pythonObserver,
-      visualizationObserver,
-      inputObserver,
-      dropdownInputObserver,
-      dateInputObserver,
-      fileUploadObserver,
-      writebackObserver,
-      pivotTableObserver,
-      {
-        blockAdd: jest.fn(),
-      }
-    )
-    const mocks = [
-      { name: 'sqlObserver', mock: sqlObserver },
-      { name: 'pythonObserver', mock: pythonObserver },
-      { name: 'visualizationObserver', mock: visualizationObserver },
-      { name: 'inputObserver', mock: inputObserver },
-      { name: 'dropdownInputObserver', mock: dropdownInputObserver },
-      { name: 'fileUploadObserver', mock: fileUploadObserver },
-      { name: 'writebackObserver', mock: writebackObserver },
-      { name: 'dateInputObserver', mock: dateInputObserver },
-      { name: 'pivotTableObserver', mock: pivotTableObserver },
-    ]
-
-    const table = []
-    let row = []
-    for (const sqlIdle of [true, false]) {
-      for (const pythonIdle of [true, false]) {
-        for (const visualizationIdle of [true, false]) {
-          for (const inputIdle of [true, false]) {
-            for (const dropdownInputIdle of [true, false]) {
-              for (const fileUploadIdle of [true, false]) {
-                for (const writebackIdle of [true, false]) {
-                  for (const dateInputIdle of [true, false]) {
-                    for (const pivotTableIdle of [true, false]) {
-                      row = [
-                        sqlIdle,
-                        pythonIdle,
-                        visualizationIdle,
-                        inputIdle,
-                        dropdownInputIdle,
-                        fileUploadIdle,
-                        writebackIdle,
-                        dateInputIdle,
-                        pivotTableIdle,
-                      ]
-                      row.push(row.reduce((acc, curr) => acc && curr, true))
-                      table.push(row)
-                      row = []
-                    }
-                  }
-                }
-              }
-            }
-          }
-        }
-      }
-    }
-
-    for (const row of table) {
-      const testName = mocks.map((m, i) => `${m.name} is ${row[i]}`).join(', ')
-      it(`should return ${row[row.length - 1]} when ${testName}`, () => {
-        mocks.forEach((m, i) => m.mock.isIdle.mockReturnValue(row[i]!))
-        expect(blocksObserver.isIdle()).toEqual(row[row.length - 1])
-      })
-    }
-  })
-})
diff --git a/apps/api/src/yjs/v2/observers/blocks/input.test.ts b/apps/api/src/yjs/v2/observers/blocks/input.test.ts
deleted file mode 100644
index 36864189..00000000
--- a/apps/api/src/yjs/v2/observers/blocks/input.test.ts
+++ /dev/null
@@ -1,269 +0,0 @@
-import * as Y from 'yjs'
-import { InputObserver } from './input.js'
-import {
-  YBlock,
-  InputBlock,
-  makeInputBlock,
-  updateInputValue,
-  updateInputVariable,
-  getInputAttributes,
-} from '@briefer/editor'
-import { clone } from 'ramda'
-import { IInputExecutor } from '../../executors/blocks/input.js'
-
-describe('InputObserver', () => {
-  let executorMock: jest.Mocked<IInputExecutor>
-  beforeEach(() => {
-    executorMock = {
-      isIdle: jest.fn(),
-      saveVariable: jest.fn(),
-      saveValue: jest.fn(),
-      abortSaveValue: jest.fn(),
-    }
-  })
-
-  describe('.isIdle', () => {
-    it('should return true executor returns true', () => {
-      executorMock.isIdle.mockReturnValue(true)
-      const blocksExecutor = new InputObserver(
-        'workspaceId',
-        'documentId',
-        new Y.Map(),
-        executorMock
-      )
-      expect(blocksExecutor.isIdle()).toBe(true)
-    })
-
-    it('should return false when executor returns false', async () => {
-      executorMock.isIdle.mockReturnValue(false)
-      const blocksExecutor = new InputObserver(
-        'workspaceId',
-        'documentId',
-        new Y.Map(),
-        executorMock
-      )
-      expect(blocksExecutor.isIdle()).toBe(false)
-    })
-  })
-
-  describe('.handleInitialBlockState', () => {
-    it('should set value status to idle when value status is not idle', () => {
-      const ydoc = new Y.Doc()
-      const blocks = ydoc.getMap<YBlock>('blocks')
-      const block = makeInputBlock('blockId', blocks)
-      blocks.set('blockId', block)
-      updateInputValue(block, { status: 'save-requested' })
-      const blocksExecutor = new InputObserver(
-        'workspaceId',
-        'documentId',
-        new Y.Map(),
-        executorMock
-      )
-      blocksExecutor.handleInitialBlockState(block)
-      const value = block.getAttribute('value')
-      expect(value?.status).toBe('idle')
-    })
-
-    it('should set variable status to idle when variable status is not idle', () => {
-      const ydoc = new Y.Doc()
-      const blocks = ydoc.getMap<YBlock>('blocks')
-      const block = makeInputBlock('blockId', blocks)
-      blocks.set('blockId', block)
-      updateInputVariable(block, blocks, { status: 'save-requested' })
-      const blocksExecutor = new InputObserver(
-        'workspaceId',
-        'documentId',
-        new Y.Map(),
-        executorMock
-      )
-      blocksExecutor.handleInitialBlockState(block)
-      const variable = block.getAttribute('variable')
-      expect(variable?.status).toBe('idle')
-    })
-  })
-
-  describe('.handleBlockEvent', () => {
-    let block: Y.XmlElement<InputBlock>
-    let blocksExecutor: InputObserver
-    let blocks: Y.Map<YBlock>
-    beforeEach(() => {
-      const ydoc = new Y.Doc()
-      blocks = ydoc.getMap<YBlock>('blocks')
-      block = makeInputBlock('blockId', blocks)
-      blocks.set('blockId', block)
-      blocksExecutor = new InputObserver(
-        'workspaceId',
-        'documentId',
-        blocks,
-        executorMock
-      )
-    })
-
-    it('should do nothing when action is not update', async () => {
-      const previousAttributes = clone(block.getAttributes())
-
-      await Promise.all([
-        blocksExecutor.handleBlockEvent(block, 'add', {}, 'status'),
-        blocksExecutor.handleBlockEvent(block, 'delete', {}, 'status'),
-        blocksExecutor.handleBlockEvent(block, 'batman', {}, 'status'),
-      ])
-
-      expect(block.getAttributes()).toEqual(previousAttributes)
-      expect(executorMock.saveValue).not.toHaveBeenCalled()
-      expect(executorMock.saveVariable).not.toHaveBeenCalled()
-    })
-
-    it('should do nothing when key is not status', async () => {
-      const previousAttributes = clone(block.getAttributes())
-
-      await Promise.all([
-        blocksExecutor.handleBlockEvent(block, 'update', {}, 'result'),
-        blocksExecutor.handleBlockEvent(block, 'update', {}, 'batman'),
-        blocksExecutor.handleBlockEvent(block, 'update', {}, 'robin'),
-      ])
-
-      expect(block.getAttributes()).toEqual(previousAttributes)
-      expect(executorMock.saveValue).not.toHaveBeenCalled()
-      expect(executorMock.saveVariable).not.toHaveBeenCalled()
-    })
-
-    describe('when value key is updated', () => {
-      it('should ignore when status is not changed', async () => {
-        const prev = {
-          ...getInputAttributes(block, blocks).value,
-          status: 'saving',
-        }
-        updateInputValue(block, {
-          status: 'saving',
-          // changing newValue just to simulate a change that should be ignored
-          newValue: 'newValue',
-        })
-        await blocksExecutor.handleBlockEvent(block, 'update', prev, 'variable')
-
-        expect(executorMock.saveVariable).not.toHaveBeenCalled()
-        expect(executorMock.saveValue).not.toHaveBeenCalled()
-      })
-
-      it('should set value status to saving when value status is updated to save-requested', async () => {
-        const prev = {
-          ...getInputAttributes(block, blocks).value,
-          status: 'idle',
-        }
-        updateInputValue(block, { status: 'save-requested' })
-        await blocksExecutor.handleBlockEvent(block, 'update', prev, 'value')
-
-        expect(block.getAttribute('value')?.status).toEqual('saving')
-        expect(executorMock.saveValue).not.toHaveBeenCalled()
-        expect(executorMock.saveVariable).not.toHaveBeenCalled()
-      })
-
-      describe('to saving', () => {
-        it('should call executor to saveValue set status to idle and error to null', async () => {
-          const prev = {
-            ...getInputAttributes(block, blocks).value,
-            status: 'save-requested',
-          }
-          updateInputValue(block, { status: 'saving' })
-          executorMock.saveValue.mockResolvedValue()
-
-          await blocksExecutor.handleBlockEvent(block, 'update', prev, 'value')
-
-          expect(executorMock.saveValue).toHaveBeenCalledWith(block)
-          expect(block.getAttribute('value')).toHaveProperty('status', 'idle')
-          expect(block.getAttribute('value')).toHaveProperty('error', null)
-        })
-
-        it('should set error to unexpected-error when executor fails', async () => {
-          const prev = {
-            ...getInputAttributes(block, blocks).value,
-            status: 'save-requested',
-          }
-          updateInputValue(block, { status: 'saving' })
-
-          executorMock.saveValue.mockRejectedValue(
-            new Error('unexpected-error')
-          )
-
-          await blocksExecutor.handleBlockEvent(block, 'update', prev, 'value')
-
-          expect(executorMock.saveValue).toHaveBeenCalledWith(block)
-          expect(block.getAttribute('value')).toHaveProperty('status', 'idle')
-          expect(block.getAttribute('value')).toHaveProperty(
-            'error',
-            'unexpected-error'
-          )
-        })
-      })
-    })
-
-    describe('when variable key is updated', () => {
-      it('should ignore when status is not changed', async () => {
-        const prev = {
-          ...getInputAttributes(block, blocks).variable,
-          status: 'saving',
-        }
-        updateInputVariable(block, blocks, {
-          status: 'saving',
-          // changing newValue just to simulate a change that should be ignored
-          newValue: 'newValue',
-        })
-        await blocksExecutor.handleBlockEvent(block, 'update', prev, 'variable')
-
-        expect(executorMock.saveVariable).not.toHaveBeenCalled()
-        expect(executorMock.saveValue).not.toHaveBeenCalled()
-      })
-
-      it('should set variable status to saving when variable status is updated to run-requested', async () => {
-        const prev = {
-          ...getInputAttributes(block, blocks).variable,
-          status: 'idle',
-        }
-        updateInputVariable(block, blocks, { status: 'save-requested' })
-        await blocksExecutor.handleBlockEvent(block, 'update', prev, 'variable')
-
-        expect(block.getAttribute('variable')?.status).toEqual('saving')
-        expect(executorMock.saveVariable).not.toHaveBeenCalled()
-        expect(executorMock.saveValue).not.toHaveBeenCalled()
-      })
-
-      it('should call executor to saveVariable set status to idle and error to null', async () => {
-        const prev = {
-          ...getInputAttributes(block, blocks).variable,
-          status: 'save-requested',
-        }
-        updateInputVariable(block, blocks, {
-          status: 'saving',
-        })
-
-        executorMock.saveVariable.mockResolvedValue()
-        await blocksExecutor.handleBlockEvent(block, 'update', prev, 'variable')
-
-        expect(executorMock.saveVariable).toHaveBeenCalledWith(block)
-        expect(block.getAttribute('variable')).toHaveProperty('status', 'idle')
-        expect(block.getAttribute('variable')).toHaveProperty('error', null)
-      })
-
-      it('should set error to unexpected-error when executor fails', async () => {
-        const prev = {
-          ...getInputAttributes(block, blocks).variable,
-          status: 'save-requested',
-        }
-        updateInputVariable(block, blocks, {
-          status: 'saving',
-        })
-
-        executorMock.saveVariable.mockRejectedValue(
-          new Error('unexpected-error')
-        )
-
-        await blocksExecutor.handleBlockEvent(block, 'update', prev, 'variable')
-
-        expect(block.getAttribute('variable')).toHaveProperty('status', 'idle')
-        expect(block.getAttribute('variable')).toHaveProperty(
-          'error',
-          'unexpected-error'
-        )
-      })
-    })
-  })
-})
diff --git a/apps/api/src/yjs/v2/observers/blocks/python.test.ts b/apps/api/src/yjs/v2/observers/blocks/python.test.ts
deleted file mode 100644
index 82bee667..00000000
--- a/apps/api/src/yjs/v2/observers/blocks/python.test.ts
+++ /dev/null
@@ -1,273 +0,0 @@
-import * as Y from 'yjs'
-import { PythonObserver } from './python'
-import { YBlock, PythonBlock, makePythonBlock } from '@briefer/editor'
-import { clone } from 'ramda'
-import { IPythonExecutor } from '../../executors/blocks/python.js'
-
-describe('PythonObserver', () => {
-  let executorMock: jest.Mocked<IPythonExecutor>
-  beforeEach(() => {
-    executorMock = {
-      isIdle: jest.fn(),
-      run: jest.fn(),
-      abort: jest.fn(),
-      editWithAI: jest.fn(),
-      fixWithAI: jest.fn(),
-    }
-  })
-
-  describe('.isIdle', () => {
-    it('should return true executor returns true', () => {
-      executorMock.isIdle.mockReturnValue(true)
-      const blocksExecutor = new PythonObserver(
-        'workspaceId',
-        'documentId',
-        executorMock
-      )
-      expect(blocksExecutor.isIdle()).toBe(true)
-    })
-
-    it('should return false when executor returns false', async () => {
-      executorMock.isIdle.mockReturnValue(false)
-      const blocksExecutor = new PythonObserver(
-        'workspaceId',
-        'documentId',
-        executorMock
-      )
-      expect(blocksExecutor.isIdle()).toBe(false)
-    })
-  })
-
-  describe('.handleInitialBlockState', () => {
-    it('should set status to idle when status is not idle', () => {
-      const ydoc = new Y.Doc()
-      const blocks = ydoc.getMap<YBlock>('blocks')
-      const block = makePythonBlock('blockId')
-      blocks.set('blockId', block)
-      block.setAttribute('status', 'running')
-      const blocksExecutor = new PythonObserver(
-        'workspaceId',
-        'documentId',
-        executorMock
-      )
-      blocksExecutor.handleInitialBlockState(block)
-      expect(block.getAttribute('status')).toEqual('idle')
-    })
-  })
-
-  describe('.handleBlockEvent', () => {
-    let block: Y.XmlElement<PythonBlock>
-    let blocksExecutor: PythonObserver
-    let ydoc: Y.Doc
-    beforeEach(() => {
-      ydoc = new Y.Doc()
-      const blocks = ydoc.getMap<YBlock>('blocks')
-      block = makePythonBlock('blockId')
-      blocks.set('blockId', block)
-      block.getAttribute('source')!.insert(0, 'print("3")')
-      blocksExecutor = new PythonObserver(
-        'workspaceId',
-        'documentId',
-        executorMock
-      )
-    })
-
-    it('should do nothing when action is not update', async () => {
-      const previousAttributes = clone(block.getAttributes())
-
-      await Promise.all([
-        blocksExecutor.handleBlockEvent(
-          block,
-          'add',
-          {},
-          'status',
-          new Y.Transaction(ydoc, {}, true)
-        ),
-        blocksExecutor.handleBlockEvent(
-          block,
-          'delete',
-          {},
-          'status',
-          new Y.Transaction(ydoc, {}, true)
-        ),
-        blocksExecutor.handleBlockEvent(
-          block,
-          'batman',
-          {},
-          'status',
-          new Y.Transaction(ydoc, {}, true)
-        ),
-      ])
-
-      expect(block.getAttributes()).toEqual(previousAttributes)
-      expect(executorMock.run).not.toHaveBeenCalled()
-      expect(executorMock.abort).not.toHaveBeenCalled()
-    })
-
-    it('should do nothing when key is not status', async () => {
-      const previousAttributes = clone(block.getAttributes())
-
-      await Promise.all([
-        blocksExecutor.handleBlockEvent(
-          block,
-          'update',
-          {},
-          'result',
-          new Y.Transaction(ydoc, {}, true)
-        ),
-        blocksExecutor.handleBlockEvent(
-          block,
-          'update',
-          {},
-          'batman',
-          new Y.Transaction(ydoc, {}, true)
-        ),
-        blocksExecutor.handleBlockEvent(
-          block,
-          'update',
-          {},
-          'robin',
-          new Y.Transaction(ydoc, {}, true)
-        ),
-      ])
-
-      expect(block.getAttributes()).toEqual(previousAttributes)
-      expect(executorMock.run).not.toHaveBeenCalled()
-      expect(executorMock.abort).not.toHaveBeenCalled()
-    })
-
-    describe('when status key is updated', () => {
-      it('should set status to running when status is updated to run-requested', async () => {
-        block.setAttribute('status', 'run-requested')
-        await blocksExecutor.handleBlockEvent(
-          block,
-          'update',
-          'idle',
-          'status',
-          new Y.Transaction(ydoc, {}, true)
-        )
-
-        expect(block.getAttribute('status')).toEqual('running')
-        expect(executorMock.run).not.toHaveBeenCalled()
-        expect(executorMock.abort).not.toHaveBeenCalled()
-      })
-
-      it('should set status to aborting when status is updated to abort-requested', async () => {
-        block.setAttribute('status', 'abort-requested')
-        await blocksExecutor.handleBlockEvent(
-          block,
-          'update',
-          'running',
-          'status',
-          new Y.Transaction(ydoc, {}, true)
-        )
-
-        expect(block.getAttribute('status')).toEqual('aborting')
-        expect(executorMock.run).not.toHaveBeenCalled()
-        expect(executorMock.abort).not.toHaveBeenCalled()
-      })
-
-      describe('to running', () => {
-        it('should call executor to run python and update status back to idle', async () => {
-          block.setAttribute('status', 'running')
-          executorMock.run.mockResolvedValue()
-
-          const tr = new Y.Transaction(ydoc, {}, true)
-          await blocksExecutor.handleBlockEvent(
-            block,
-            'update',
-            'idle',
-            'status',
-            tr
-          )
-
-          expect(executorMock.run).toHaveBeenCalledWith(block, tr, false)
-          expect(block.getAttribute('status')).toEqual('idle')
-        })
-
-        it('should update status back to idle when executor fails', async () => {
-          block.setAttribute('status', 'running')
-          executorMock.run.mockRejectedValue(new Error('unexpected-error'))
-
-          await blocksExecutor.handleBlockEvent(
-            block,
-            'update',
-            'idle',
-            'status',
-            new Y.Transaction(ydoc, {}, true)
-          )
-
-          expect(block.getAttribute('status')).toEqual('idle')
-        })
-      })
-
-      describe('to running-suggestion', () => {
-        it('should call executor to run python and update status back to idle', async () => {
-          block.setAttribute('status', 'running-suggestion')
-          executorMock.run.mockResolvedValue()
-
-          const tr = new Y.Transaction(ydoc, {}, true)
-          await blocksExecutor.handleBlockEvent(
-            block,
-            'update',
-            'idle',
-            'status',
-            tr
-          )
-
-          expect(executorMock.run).toHaveBeenCalledWith(block, tr, true)
-          expect(block.getAttribute('status')).toEqual('idle')
-        })
-
-        it('should update status back to idle when executor fails', async () => {
-          block.setAttribute('status', 'running-suggestion')
-          executorMock.run.mockRejectedValue(new Error('unexpected-error'))
-
-          await blocksExecutor.handleBlockEvent(
-            block,
-            'update',
-            'idle',
-            'status',
-            new Y.Transaction(ydoc, {}, true)
-          )
-
-          expect(block.getAttribute('status')).toEqual('idle')
-        })
-      })
-
-      describe('to aborting', () => {
-        it('should call executor to abort python and update status back to idle', async () => {
-          block.setAttribute('status', 'aborting')
-          executorMock.abort.mockResolvedValue()
-
-          const tr = new Y.Transaction(ydoc, {}, true)
-          await blocksExecutor.handleBlockEvent(
-            block,
-            'update',
-            'abort-requested',
-            'status',
-            tr
-          )
-
-          expect(executorMock.abort).toHaveBeenCalledWith(block, tr)
-          expect(block.getAttribute('status')).toEqual('idle')
-        })
-
-        it('should update status back to idle when executor fails', async () => {
-          block.setAttribute('status', 'aborting')
-          executorMock.abort.mockRejectedValue(new Error('unexpected-error'))
-
-          await blocksExecutor.handleBlockEvent(
-            block,
-            'update',
-            'abort-requested',
-            'status',
-            new Y.Transaction(ydoc, {}, true)
-          )
-
-          expect(block.getAttribute('status')).toEqual('idle')
-        })
-      })
-    })
-  })
-})
diff --git a/apps/api/src/yjs/v2/observers/blocks/sql.test.ts b/apps/api/src/yjs/v2/observers/blocks/sql.test.ts
deleted file mode 100644
index 257477e2..00000000
--- a/apps/api/src/yjs/v2/observers/blocks/sql.test.ts
+++ /dev/null
@@ -1,466 +0,0 @@
-import * as Y from 'yjs'
-import { describe, expect, it } from '@jest/globals'
-import { SQLObserver } from './sql.js'
-import { YBlock, SQLBlock, makeSQLBlock } from '@briefer/editor'
-import { clone } from 'ramda'
-import { ISQLExecutor } from '../../executors/blocks/sql.js'
-
-describe.only('SQLObserver', () => {
-  let executorMock: jest.Mocked<ISQLExecutor>
-  beforeEach(() => {
-    executorMock = {
-      isIdle: jest.fn(),
-      runQuery: jest.fn(),
-      abortQuery: jest.fn(),
-      renameDataFrame: jest.fn(),
-      editWithAI: jest.fn(),
-      fixWithAI: jest.fn(),
-    }
-  })
-
-  describe('.isIdle', () => {
-    it('should return true executor returns true', () => {
-      executorMock.isIdle.mockReturnValue(true)
-      const blocksExecutor = new SQLObserver(
-        'workspaceId',
-        'documentId',
-        executorMock
-      )
-      expect(blocksExecutor.isIdle()).toBe(true)
-    })
-
-    it('should return false when executor returns false', async () => {
-      executorMock.isIdle.mockReturnValue(false)
-      const blocksExecutor = new SQLObserver(
-        'workspaceId',
-        'documentId',
-        executorMock
-      )
-      expect(blocksExecutor.isIdle()).toBe(false)
-    })
-  })
-
-  describe('.handleInitialBlockState', () => {
-    let block: Y.XmlElement<SQLBlock>
-    beforeEach(() => {
-      const ydoc = new Y.Doc()
-      const blocks = ydoc.getMap<YBlock>('blocks')
-      block = makeSQLBlock('blockId', blocks, { dataSourceId: 'datasourceId' })
-      blocks.set('blockId', block)
-    })
-
-    it('should set status to idle when status is not idle', () => {
-      block.setAttribute('status', 'running')
-      const blocksExecutor = new SQLObserver(
-        'workspaceId',
-        'documentId',
-        executorMock
-      )
-      blocksExecutor.handleInitialBlockState(block)
-      expect(block.getAttribute('status')).toEqual('idle')
-    })
-
-    it('should set dataframeName status to idle when status is not idle', () => {
-      block.setAttribute('dataframeName', {
-        value: 'query_1',
-        newValue: 'query_1',
-        status: 'loading',
-      })
-      const blocksExecutor = new SQLObserver(
-        'workspaceId',
-        'documentId',
-        executorMock
-      )
-      blocksExecutor.handleInitialBlockState(block)
-      expect(block.getAttribute('dataframeName')).toEqual({
-        value: 'query_1',
-        newValue: 'query_1',
-        status: 'idle',
-      })
-    })
-  })
-
-  describe('.handleBlockEvent', () => {
-    let block: Y.XmlElement<SQLBlock>
-    let blocksExecutor: SQLObserver
-    let ydoc: Y.Doc
-    beforeEach(() => {
-      ydoc = new Y.Doc()
-      const blocks = ydoc.getMap<YBlock>('blocks')
-      block = makeSQLBlock('blockId', blocks, { dataSourceId: 'datasourceId' })
-      blocks.set('blockId', block)
-      block.getAttribute('source')!.insert(0, 'select * from table')
-      blocksExecutor = new SQLObserver(
-        'workspaceId',
-        'documentId',
-        executorMock
-      )
-    })
-
-    it('should do nothing when action is not update', async () => {
-      const previousAttributes = clone(block.getAttributes())
-
-      await Promise.all([
-        blocksExecutor.handleBlockEvent(
-          block,
-          'add',
-          {},
-          'status',
-          new Y.Transaction(ydoc, {}, true)
-        ),
-        blocksExecutor.handleBlockEvent(
-          block,
-          'delete',
-          {},
-          'status',
-          new Y.Transaction(ydoc, {}, true)
-        ),
-        blocksExecutor.handleBlockEvent(
-          block,
-          'batman',
-          {},
-          'status',
-          new Y.Transaction(ydoc, {}, true)
-        ),
-      ])
-
-      expect(block.getAttributes()).toEqual(previousAttributes)
-      expect(executorMock.runQuery).not.toHaveBeenCalled()
-      expect(executorMock.abortQuery).not.toHaveBeenCalled()
-      expect(executorMock.renameDataFrame).not.toHaveBeenCalled()
-    })
-
-    it('should do nothing when key is not status or dataframeName', async () => {
-      const previousAttributes = clone(block.getAttributes())
-
-      await Promise.all([
-        blocksExecutor.handleBlockEvent(
-          block,
-          'update',
-          {},
-          'result',
-          new Y.Transaction(ydoc, {}, true)
-        ),
-        blocksExecutor.handleBlockEvent(
-          block,
-          'update',
-          {},
-          'batman',
-          new Y.Transaction(ydoc, {}, true)
-        ),
-        blocksExecutor.handleBlockEvent(
-          block,
-          'update',
-          {},
-          'robin',
-          new Y.Transaction(ydoc, {}, true)
-        ),
-      ])
-
-      expect(block.getAttributes()).toEqual(previousAttributes)
-      expect(executorMock.runQuery).not.toHaveBeenCalled()
-      expect(executorMock.abortQuery).not.toHaveBeenCalled()
-      expect(executorMock.renameDataFrame).not.toHaveBeenCalled()
-    })
-
-    describe('when status key is updated', () => {
-      it('should set status to running when status is updated to run-requested', async () => {
-        block.setAttribute('status', 'run-requested')
-        await blocksExecutor.handleBlockEvent(
-          block,
-          'update',
-          'idle',
-          'status',
-          new Y.Transaction(ydoc, {}, true)
-        )
-
-        expect(block.getAttribute('status')).toEqual('running')
-        expect(executorMock.runQuery).not.toHaveBeenCalled()
-        expect(executorMock.abortQuery).not.toHaveBeenCalled()
-        expect(executorMock.renameDataFrame).not.toHaveBeenCalled()
-      })
-
-      it('should set status to aborting when status is updated to abort-requested', async () => {
-        block.setAttribute('status', 'abort-requested')
-        await blocksExecutor.handleBlockEvent(
-          block,
-          'update',
-          'idle',
-          'status',
-          new Y.Transaction(ydoc, {}, true)
-        )
-
-        expect(block.getAttribute('status')).toEqual('aborting')
-        expect(executorMock.runQuery).not.toHaveBeenCalled()
-        expect(executorMock.abortQuery).not.toHaveBeenCalled()
-        expect(executorMock.renameDataFrame).not.toHaveBeenCalled()
-      })
-
-      describe('when status key is updated to running', () => {
-        it('should call runQuery from executor and set status back to idle', async () => {
-          block.setAttribute('status', 'running')
-          executorMock.runQuery.mockResolvedValue()
-
-          const tr = new Y.Transaction(ydoc, {}, true)
-          await blocksExecutor.handleBlockEvent(
-            block,
-            'update',
-            'idle',
-            'status',
-            tr
-          )
-
-          expect(executorMock.runQuery).toHaveBeenCalledWith(
-            block,
-            tr,
-            false,
-            false
-          )
-          expect(block.getAttribute('status')).toEqual('idle')
-        })
-
-        it('should set status back to idle when runQuery fails', async () => {
-          block.setAttribute('status', 'running')
-          executorMock.runQuery.mockRejectedValue(new Error('error'))
-
-          const tr = new Y.Transaction(ydoc, {}, true)
-          await blocksExecutor.handleBlockEvent(
-            block,
-            'update',
-            'idle',
-            'status',
-            tr
-          )
-
-          expect(executorMock.runQuery).toHaveBeenCalledWith(
-            block,
-            tr,
-            false,
-            false
-          )
-          expect(block.getAttribute('status')).toEqual('idle')
-        })
-      })
-
-      describe('when status key is updated to running-suggestion', () => {
-        it('should call runQuery from executor and set status back to idle', async () => {
-          block.setAttribute('status', 'running-suggestion')
-          executorMock.runQuery.mockResolvedValue()
-
-          const tr = new Y.Transaction(ydoc, {}, true)
-          await blocksExecutor.handleBlockEvent(
-            block,
-            'update',
-            'idle',
-            'status',
-            tr
-          )
-
-          expect(executorMock.runQuery).toHaveBeenCalledWith(
-            block,
-            tr,
-            true,
-            false
-          )
-          expect(block.getAttribute('status')).toEqual('idle')
-        })
-
-        it('should set status back to idle when runQuery fails', async () => {
-          block.setAttribute('status', 'running-suggestion')
-          executorMock.runQuery.mockRejectedValue(new Error('error'))
-
-          const tr = new Y.Transaction(ydoc, {}, true)
-          await blocksExecutor.handleBlockEvent(
-            block,
-            'update',
-            'idle',
-            'status',
-            tr
-          )
-
-          expect(executorMock.runQuery).toHaveBeenCalledWith(
-            block,
-            tr,
-            true,
-            false
-          )
-          expect(block.getAttribute('status')).toEqual('idle')
-        })
-      })
-    })
-
-    describe('when status key is updated to aborting', () => {
-      it('should call abortQuery from executor and set status back to idle', async () => {
-        block.setAttribute('status', 'aborting')
-        executorMock.abortQuery.mockResolvedValue()
-
-        await blocksExecutor.handleBlockEvent(
-          block,
-          'update',
-          'idle',
-          'status',
-          new Y.Transaction(ydoc, {}, true)
-        )
-
-        expect(block.getAttribute('status')).toEqual('idle')
-      })
-
-      it('should set status back to idle when abortQuery fails', async () => {
-        block.setAttribute('status', 'aborting')
-        executorMock.abortQuery.mockRejectedValue(new Error('error'))
-
-        await blocksExecutor.handleBlockEvent(
-          block,
-          'update',
-          'idle',
-          'status',
-          new Y.Transaction(ydoc, {}, true)
-        )
-
-        expect(block.getAttribute('status')).toEqual('idle')
-      })
-    })
-
-    describe('when dataframeName key is updated', () => {
-      it('should do nothing when dataframeName status is not updated', async () => {
-        const prev: SQLBlock['dataframeName'] = {
-          value: 'query_1',
-          newValue: 'query_1',
-          status: 'running',
-        }
-        block.setAttribute('dataframeName', prev)
-
-        block.setAttribute('dataframeName', {
-          value: 'query_1',
-          // newValue was updated but status kept the same
-          newValue: 'query_2',
-          status: 'running',
-        })
-
-        await blocksExecutor.handleBlockEvent(
-          block,
-          'update',
-          prev,
-          'dataframeName',
-          new Y.Transaction(ydoc, {}, true)
-        )
-
-        expect(executorMock.runQuery).not.toHaveBeenCalled()
-        expect(executorMock.renameDataFrame).not.toHaveBeenCalled()
-        expect(executorMock.abortQuery).not.toHaveBeenCalled()
-      })
-
-      it('should do nothing when dataframeName status is updated to idle', async () => {
-        const prev: SQLBlock['dataframeName'] = {
-          value: 'query_1',
-          newValue: 'query_1',
-          status: 'running',
-        }
-        block.setAttribute('dataframeName', prev)
-        block.setAttribute('dataframeName', {
-          value: 'query_1',
-          newValue: 'query_1',
-          status: 'idle',
-        })
-        await blocksExecutor.handleBlockEvent(
-          block,
-          'update',
-          prev,
-          'dataframeName',
-          new Y.Transaction(ydoc, {}, true)
-        )
-
-        expect(executorMock.runQuery).not.toHaveBeenCalled()
-        expect(executorMock.renameDataFrame).not.toHaveBeenCalled()
-        expect(executorMock.abortQuery).not.toHaveBeenCalled()
-      })
-
-      it('should set dataframeName status to running when updated to loading', async () => {
-        const prev = { ...block.getAttribute('dataframeName'), status: 'idle' }
-        block.setAttribute('dataframeName', {
-          value: 'query_1',
-          newValue: 'query_2',
-          status: 'loading',
-        })
-
-        await blocksExecutor.handleBlockEvent(
-          block,
-          'update',
-          prev,
-          'dataframeName',
-          new Y.Transaction(ydoc, {}, true)
-        )
-
-        expect(block.getAttribute('dataframeName')).toEqual({
-          value: 'query_1',
-          newValue: 'query_2',
-          status: 'running',
-        })
-        expect(executorMock.runQuery).not.toHaveBeenCalled()
-        expect(executorMock.renameDataFrame).not.toHaveBeenCalled()
-        expect(executorMock.abortQuery).not.toHaveBeenCalled()
-      })
-
-      describe('when dataframeName status is running', () => {
-        it('should call renameDataFrame from executor and set status back to idle', async () => {
-          const prev = {
-            ...block.getAttribute('dataframeName'),
-            status: 'idle',
-          }
-          block.setAttribute('dataframeName', {
-            value: 'query_1',
-            newValue: 'query_1',
-            status: 'running',
-          })
-          executorMock.renameDataFrame.mockResolvedValue()
-
-          const tr = new Y.Transaction(ydoc, {}, true)
-          await blocksExecutor.handleBlockEvent(
-            block,
-            'update',
-            prev,
-            'dataframeName',
-            tr
-          )
-
-          expect(executorMock.renameDataFrame).toHaveBeenCalledWith(block)
-          expect(block.getAttribute('dataframeName')).toEqual({
-            value: 'query_1',
-            newValue: 'query_1',
-            status: 'idle',
-          })
-        })
-
-        it('should set status back to idle and error to unexpected when renameDataFrame fails', async () => {
-          const prev = {
-            ...block.getAttribute('dataframeName'),
-            status: 'idle',
-          }
-          block.setAttribute('dataframeName', {
-            value: 'query_1',
-            newValue: 'query_1',
-            status: 'running',
-          })
-          executorMock.renameDataFrame.mockRejectedValue(new Error('error'))
-
-          const tr = new Y.Transaction(ydoc, {}, true)
-          await blocksExecutor.handleBlockEvent(
-            block,
-            'update',
-            prev,
-            'dataframeName',
-            tr
-          )
-
-          expect(executorMock.renameDataFrame).toHaveBeenCalledWith(block)
-          expect(block.getAttribute('dataframeName')).toEqual({
-            value: 'query_1',
-            newValue: 'query_1',
-            status: 'idle',
-            error: 'unexpected',
-          })
-        })
-      })
-    })
-  })
-})
diff --git a/apps/api/src/yjs/v2/observers/blocks/visualization.test.ts b/apps/api/src/yjs/v2/observers/blocks/visualization.test.ts
deleted file mode 100644
index b85dd05e..00000000
--- a/apps/api/src/yjs/v2/observers/blocks/visualization.test.ts
+++ /dev/null
@@ -1,216 +0,0 @@
-import * as Y from 'yjs'
-import { VisualizationObserver } from './visualization.js'
-import {
-  YBlock,
-  VisualizationBlock,
-  makeVisualizationBlock,
-} from '@briefer/editor'
-import { clone } from 'ramda'
-import { IVisualizationExecutor } from '../../executors/blocks/visualization.js'
-
-describe('VisualizationObserver', () => {
-  let executorMock: jest.Mocked<IVisualizationExecutor>
-  beforeEach(() => {
-    executorMock = {
-      isIdle: jest.fn(),
-      run: jest.fn(),
-      abort: jest.fn(),
-    }
-  })
-
-  describe('.isIdle', () => {
-    it('should return true executor returns true', () => {
-      executorMock.isIdle.mockReturnValue(true)
-      const blocksExecutor = new VisualizationObserver(
-        'workspaceId',
-        'documentId',
-        executorMock
-      )
-      expect(blocksExecutor.isIdle()).toBe(true)
-    })
-
-    it('should return false when executor returns false', async () => {
-      executorMock.isIdle.mockReturnValue(false)
-      const blocksExecutor = new VisualizationObserver(
-        'workspaceId',
-        'documentId',
-        executorMock
-      )
-      expect(blocksExecutor.isIdle()).toBe(false)
-    })
-  })
-
-  describe('.handleInitialBlockState', () => {
-    it('should set status to idle when status is not idle', () => {
-      const ydoc = new Y.Doc()
-      const blocks = ydoc.getMap<YBlock>('blocks')
-      const block = makeVisualizationBlock('blockId')
-      blocks.set('blockId', block)
-      block.setAttribute('status', 'running')
-      const blocksExecutor = new VisualizationObserver(
-        'workspaceId',
-        'documentId',
-        executorMock
-      )
-      blocksExecutor.handleInitialBlockState(block)
-      expect(block.getAttribute('status')).toEqual('idle')
-    })
-  })
-
-  describe('.handleBlockEvent', () => {
-    let block: Y.XmlElement<VisualizationBlock>
-    let visualizationObserver: VisualizationObserver
-    let ydoc: Y.Doc
-    beforeEach(() => {
-      ydoc = new Y.Doc()
-      const blocks = ydoc.getMap<YBlock>('blocks')
-      block = makeVisualizationBlock('blockId')
-      blocks.set('blockId', block)
-      visualizationObserver = new VisualizationObserver(
-        'workspaceId',
-        'documentId',
-        executorMock
-      )
-    })
-
-    it('should do nothing when action is not update', async () => {
-      const previousAttributes = clone(block.getAttributes())
-
-      await Promise.all([
-        visualizationObserver.handleBlockEvent(
-          block,
-          'add',
-          {},
-          'status',
-          new Y.Transaction(ydoc, {}, true)
-        ),
-        visualizationObserver.handleBlockEvent(
-          block,
-          'delete',
-          {},
-          'status',
-          new Y.Transaction(ydoc, {}, true)
-        ),
-        visualizationObserver.handleBlockEvent(
-          block,
-          'batman',
-          {},
-          'status',
-          new Y.Transaction(ydoc, {}, true)
-        ),
-      ])
-
-      expect(block.getAttributes()).toEqual(previousAttributes)
-      expect(executorMock.run).not.toHaveBeenCalled()
-    })
-
-    it('should do nothing when key is not status', async () => {
-      const previousAttributes = clone(block.getAttributes())
-
-      await Promise.all([
-        visualizationObserver.handleBlockEvent(
-          block,
-          'update',
-          {},
-          'result',
-          new Y.Transaction(ydoc, {}, true)
-        ),
-        visualizationObserver.handleBlockEvent(
-          block,
-          'update',
-          {},
-          'batman',
-          new Y.Transaction(ydoc, {}, true)
-        ),
-        visualizationObserver.handleBlockEvent(
-          block,
-          'update',
-          {},
-          'robin',
-          new Y.Transaction(ydoc, {}, true)
-        ),
-      ])
-
-      expect(block.getAttributes()).toEqual(previousAttributes)
-      expect(executorMock.run).not.toHaveBeenCalled()
-    })
-
-    describe('when status key is updated', () => {
-      describe('to run-requested', () => {
-        it('should abort current runs and set status to running', async () => {
-          block.setAttribute('status', 'run-requested')
-          executorMock.abort.mockResolvedValue()
-
-          const tr = new Y.Transaction(ydoc, {}, true)
-          await visualizationObserver.handleBlockEvent(
-            block,
-            'update',
-            'idle',
-            'status',
-            tr
-          )
-
-          expect(executorMock.abort).toHaveBeenCalledWith(block)
-          expect(block.getAttribute('status')).toEqual('running')
-        })
-      })
-
-      describe('to running', () => {
-        it('should call executor to run, set status to idle if executor is idle and error to null', async () => {
-          block.setAttribute('status', 'running')
-          executorMock.run.mockResolvedValue()
-          executorMock.isIdle.mockReturnValue(true)
-
-          const tr = new Y.Transaction(ydoc, {}, true)
-          await visualizationObserver.handleBlockEvent(
-            block,
-            'update',
-            'idle',
-            'status',
-            tr
-          )
-
-          expect(block.getAttribute('status')).toEqual('idle')
-          expect(block.getAttribute('error')).toBeNull()
-          expect(executorMock.run).toHaveBeenCalledWith(block, tr)
-        })
-
-        it('should not set status to idle if executor is not idle', async () => {
-          block.setAttribute('status', 'running')
-          executorMock.run.mockResolvedValue()
-          executorMock.isIdle.mockReturnValue(false)
-
-          const tr = new Y.Transaction(ydoc, {}, true)
-          await visualizationObserver.handleBlockEvent(
-            block,
-            'update',
-            'idle',
-            'status',
-            tr
-          )
-
-          expect(block.getAttribute('status')).toEqual('running')
-          expect(block.getAttribute('error')).toBeNull()
-          expect(executorMock.run).toHaveBeenCalledWith(block, tr)
-        })
-
-        it('should set status to idle when executor fails', async () => {
-          block.setAttribute('status', 'running')
-          executorMock.run.mockRejectedValue(new Error('error'))
-
-          const tr = new Y.Transaction(ydoc, {}, true)
-          await visualizationObserver.handleBlockEvent(
-            block,
-            'update',
-            'run-requested',
-            'status',
-            tr
-          )
-
-          expect(block.getAttribute('status')).toEqual('idle')
-          expect(executorMock.run).toHaveBeenCalledWith(block, tr)
-        })
-      })
-    })
-  })
-})
diff --git a/apps/api/src/yjs/v2/observers/index.test.ts b/apps/api/src/yjs/v2/observers/index.test.ts
deleted file mode 100644
index 22f71f3e..00000000
--- a/apps/api/src/yjs/v2/observers/index.test.ts
+++ /dev/null
@@ -1,69 +0,0 @@
-import { MainObserver } from './index.js'
-import { IBlocksObserver } from './blocks/index.js'
-import { IRunAllObserver } from './run-all.js'
-
-describe('MainObserver', () => {
-  let mainObserver: MainObserver
-  let blocksObserver: jest.Mocked<IBlocksObserver>
-  let runAllObserver: jest.Mocked<IRunAllObserver>
-
-  beforeEach(() => {
-    blocksObserver = {
-      start: jest.fn(),
-      stop: jest.fn(),
-      isIdle: jest.fn(),
-    }
-    runAllObserver = {
-      start: jest.fn(),
-      stop: jest.fn(),
-      isIdle: jest.fn(),
-    }
-    mainObserver = new MainObserver(
-      'workspace-id',
-      'document-id',
-      blocksObserver,
-      runAllObserver
-    )
-  })
-
-  describe('isIdle', () => {
-    it('should return true when both blocksObserver and runAllObserver are idle', () => {
-      blocksObserver.isIdle.mockReturnValue(true)
-      runAllObserver.isIdle.mockReturnValue(true)
-
-      expect(mainObserver.isIdle()).toEqual(true)
-      expect(blocksObserver.isIdle).toHaveBeenCalled()
-      expect(runAllObserver.isIdle).toHaveBeenCalled()
-    })
-
-    it('should return false otherwise', () => {
-      blocksObserver.isIdle.mockReturnValue(false)
-      runAllObserver.isIdle.mockReturnValue(true)
-      expect(mainObserver.isIdle()).toEqual(false)
-
-      blocksObserver.isIdle.mockReturnValue(false)
-      runAllObserver.isIdle.mockReturnValue(false)
-      expect(mainObserver.isIdle()).toEqual(false)
-
-      blocksObserver.isIdle.mockReturnValue(false)
-      runAllObserver.isIdle.mockReturnValue(true)
-      expect(mainObserver.isIdle()).toEqual(false)
-    })
-  })
-
-  describe('start', () => {
-    it('should start blocksObserver', () => {
-      mainObserver.start()
-
-      expect(blocksObserver.start).toHaveBeenCalled()
-    })
-  })
-
-  describe('stop', () => {
-    it('should stop blocksObserver', () => {
-      mainObserver.stop()
-
-      expect(blocksObserver.stop).toHaveBeenCalled()
-    })
-  })
-})
diff --git a/packages/editor/src/blocks/index.test.ts b/packages/editor/src/blocks/index.test.ts
index bd6641ef..3d55edeb 100644
--- a/packages/editor/src/blocks/index.test.ts
+++ b/packages/editor/src/blocks/index.test.ts
@@ -26,7 +26,9 @@ describe('computeDepencyQueue', () => {
   })
 
   it('should return a queue that includes all executable blocks that come before the block that are dirty or have not ran since env started or have a previous block that ran after them', () => {
-    const envStartedAt = new Date(Date.now() - 1000 * 60 * 60 * 24) // 1 day ago
+    const envStartedAt = new Date(
+      Date.now() - 1000 * 60 * 60 * 24
+    ).toISOString() // 1 day ago
 
     // ran after env started
     // do not expect this block to be in the queue
@@ -86,8 +88,8 @@ describe('computeDepencyQueue', () => {
       clickedBlock,
       layout,
       blocks,
-      envStartedAt,
-      false
+      false,
+      envStartedAt
     ).map((b) => b.getAttribute('id'))
 
     const expected = [secondBlock].map((b) => b.getAttribute('id'))
diff --git a/packages/editor/src/blocks/input.test.ts b/packages/editor/src/blocks/input.test.ts
index 26134392..47dbb15c 100644
--- a/packages/editor/src/blocks/input.test.ts
+++ b/packages/editor/src/blocks/input.test.ts
@@ -5,12 +5,9 @@ import {
   YBlock,
   getInputAttributes,
   isTextInputBlock,
-  getInputValueExecStatus,
-  getInputVariableExecStatus,
   makeInputBlock,
   updateInputLabel,
   updateInputValue,
-  updateInputVariable,
 } from './index.js'
 
 describe('isYInputBlock', () => {
@@ -46,13 +43,11 @@ describe('makeInputBlock', () => {
     expect(inputBlock.getAttribute('value')).toEqual({
       value: '',
       newValue: '',
-      status: 'idle',
       error: null,
     })
     expect(inputBlock.getAttribute('variable')).toEqual({
       value: 'input_1',
       newValue: 'input_1',
-      status: 'idle',
       error: null,
     })
     expect(inputBlock.getAttribute('inputType')).toBe('text')
@@ -96,107 +91,20 @@ describe('updateInputValue', () => {
     expect(inputBlock.getAttribute('value')).toEqual({
       value: '',
       newValue: '',
-      status: 'idle',
       error: null,
     })
 
     updateInputValue(inputBlock, {
       newValue: 'New value',
-      status: 'save-requested',
     })
     expect(inputBlock.getAttribute('value')).toEqual({
       value: '',
       newValue: 'New value',
-      status: 'save-requested',
       error: null,
     })
   })
 })
 
-describe('getInputValueExecStatus', () => {
-  it('should return false when the status is idle', () => {
-    const ydoc = new Y.Doc()
-    const blocks = ydoc.getMap<YBlock>('blocks')
-    const inputBlock = makeInputBlock('blockId', blocks)
-    blocks.set('blockId', inputBlock)
-
-    updateInputValue(inputBlock, { status: 'idle' })
-    expect(getInputValueExecStatus(inputBlock)).toBe('idle')
-  })
-
-  it('should return true when the status is save-requested', () => {
-    const ydoc = new Y.Doc()
-    const blocks = ydoc.getMap<YBlock>('blocks')
-    const inputBlock = makeInputBlock('blockId', blocks)
-    blocks.set('blockId', inputBlock)
-
-    updateInputValue(inputBlock, { status: 'save-requested' })
-    expect(getInputValueExecStatus(inputBlock)).toBe('loading')
-  })
-
-  it('should return true when the status is saving', () => {
-    const ydoc = new Y.Doc()
-    const blocks = ydoc.getMap<YBlock>('blocks')
-    const inputBlock = makeInputBlock('blockId', blocks)
-    blocks.set('blockId', inputBlock)
-
-    updateInputValue(inputBlock, { status: 'saving' })
-    expect(getInputValueExecStatus(inputBlock)).toBe('loading')
-  })
-})
-
-describe('updateInputVariable', () => {
-  it('should update the variable of an Input block', () => {
-    const ydoc = new Y.Doc()
-    const blocks = ydoc.getMap<YBlock>('blocks')
-    const inputBlock = makeInputBlock('blockId', blocks)
-    blocks.set('blockId', inputBlock)
-
-    updateInputVariable(inputBlock, blocks, {
-      newValue: 'input_2',
-      status: 'save-requested',
-    })
-    expect(inputBlock.getAttribute('variable')).toEqual({
-      value: 'input_1',
-      newValue: 'input_2',
-      status: 'save-requested',
-      error: null,
-    })
-  })
-})
-
-describe('getInputVariableExecStatus', () => {
-  it('should return false when the status is idle', () => {
-    const ydoc = new Y.Doc()
-    const blocks = ydoc.getMap<YBlock>('blocks')
-    const inputBlock = makeInputBlock('blockId', blocks)
-    blocks.set('blockId', inputBlock)
-
-    updateInputVariable(inputBlock, blocks, { status: 'idle' })
-    expect(getInputVariableExecStatus(inputBlock, blocks)).toBe('idle')
-  })
-
-  it('should return true when the status is save-requested', () => {
-    const ydoc = new Y.Doc()
-    const blocks = ydoc.getMap<YBlock>('blocks')
-    const inputBlock = makeInputBlock('blockId', blocks)
-    blocks.set('blockId', inputBlock)
-
-    updateInputVariable(inputBlock, blocks, { status: 'save-requested' })
-    expect(getInputVariableExecStatus(inputBlock, blocks)).toBe('loading')
-  })
-
-  it('should return true when the status is saving', () => {
-    const ydoc = new Y.Doc()
-    const blocks = ydoc.getMap<YBlock>('blocks')
-    const inputBlock = makeInputBlock('blockId', blocks)
-    blocks.set('blockId', inputBlock)
-
-    updateInputVariable(inputBlock, blocks, { status: 'saving' })
-    expect(getInputVariableExecStatus(inputBlock, blocks)).toBe('loading')
-  })
-})
-
 describe('getInputAttributes', () => {
   it('should return the attributes of an Input block', () => {
     const ydoc = new Y.Doc()
@@ -214,13 +122,11 @@ describe('getInputAttributes', () => {
       variable: {
         value: 'input_1',
         newValue: 'input_1',
-        status: 'idle',
         error: null,
       },
       value: {
         value: '',
         newValue: '',
-        status: 'idle',
         error: null,
       },
       inputType: 'text',
diff --git a/packages/editor/src/blocks/python.test.ts b/packages/editor/src/blocks/python.test.ts
index 15eee66e..fc26e1c0 100644
--- a/packages/editor/src/blocks/python.test.ts
+++ b/packages/editor/src/blocks/python.test.ts
@@ -33,6 +33,5 @@ describe('makePythonBlock', () => {
     expect(pythonBlock.getAttribute('type')).toBe(BlockType.Python)
     expect(pythonBlock.getAttribute('id')).toBe('blockId')
     expect(pythonBlock.getAttribute('source')?.toString()).toBe('')
-    expect(pythonBlock.getAttribute('status')).toBe('idle')
   })
 })
diff --git a/packages/editor/src/operations/blockGroup.test.ts b/packages/editor/src/operations/blockGroup.test.ts
index 02c06ed5..4ea8ae4d 100644
--- a/packages/editor/src/operations/blockGroup.test.ts
+++ b/packages/editor/src/operations/blockGroup.test.ts
@@ -86,7 +86,6 @@ describe('getTabsFromBlockGroupId', () => {
         type: BlockType.RichText,
         title: '',
         isCurrent: false,
-        execStatus: 'idle',
         blockGroupId: blockGroup1Id,
         isHiddenInPublished: false,
       },
@@ -95,7 +94,6 @@ describe('getTabsFromBlockGroupId', () => {
         type: BlockType.RichText,
         title: '',
         isCurrent: true,
-        execStatus: 'idle',
         blockGroupId: blockGroup1Id,
         isHiddenInPublished: false,
       },
@@ -104,7 +102,6 @@ describe('getTabsFromBlockGroupId', () => {
         type: BlockType.RichText,
         title: '',
         isCurrent: false,
-        execStatus: 'idle',
         blockGroupId: blockGroup1Id,
         isHiddenInPublished: false,
       },
@@ -146,7 +143,6 @@ describe('ungroupTab', () => {
         type: BlockType.RichText,
         title: '',
         isCurrent: false,
-        execStatus: 'idle',
         blockGroupId: blockGroup1Id,
         isHiddenInPublished: false,
       },
@@ -155,7 +151,6 @@ describe('ungroupTab', () => {
         type: BlockType.RichText,
         title: '',
         isCurrent: true,
-        execStatus: 'idle',
         blockGroupId: blockGroup1Id,
         isHiddenInPublished: false,
       },
@@ -195,7 +190,6 @@ describe('ungroupTab', () => {
         type: BlockType.RichText,
         title: '',
         isCurrent: false,
-        execStatus: 'idle',
         blockGroupId: blockGroup1Id,
         isHiddenInPublished: false,
       },
@@ -204,7 +198,6 @@ describe('ungroupTab', () => {
         type: BlockType.RichText,
         title: '',
         isCurrent: true,
-        execStatus: 'idle',
         blockGroupId: blockGroup1Id,
         isHiddenInPublished: false,
       },
@@ -265,7 +258,6 @@ describe('ungroupTab', () => {
         type: BlockType.RichText,
         title: '',
         isCurrent: false,
-        execStatus: 'idle',
         blockGroupId: blockGroup1Id,
         isHiddenInPublished: false,
       },
@@ -274,7 +266,6 @@ describe('ungroupTab', () => {
         type: BlockType.RichText,
         title: '',
         isCurrent: true,
-        execStatus: 'idle',
         blockGroupId: blockGroup1Id,
         isHiddenInPublished: false,
       },
@@ -315,7 +306,6 @@ describe('ungroupTab', () => {
         type: BlockType.RichText,
         title: '',
         isCurrent: false,
-        execStatus: 'idle',
         blockGroupId: blockGroup1Id,
         isHiddenInPublished: false,
       },
@@ -324,7 +314,6 @@ describe('ungroupTab', () => {
         type: BlockType.RichText,
         title: '',
         isCurrent: true,
-        execStatus: 'idle',
         blockGroupId: blockGroup1Id,
         isHiddenInPublished: false,
       },
@@ -565,7 +554,6 @@ describe('reorderTab', () => {
         type: BlockType.RichText,
         title: '',
         isCurrent: false,
-        execStatus: 'idle',
         blockGroupId: blockGroup1Id,
         isHiddenInPublished: false,
       },
@@ -574,7 +562,6 @@ describe('reorderTab', () => {
         type: BlockType.RichText,
         title: '',
         isCurrent: false,
-        execStatus: 'idle',
         blockGroupId: blockGroup1Id,
         isHiddenInPublished: false,
       },
@@ -583,7 +570,6 @@ describe('reorderTab', () => {
         type: BlockType.RichText,
         title: '',
         isCurrent: true,
-        execStatus: 'idle',
         blockGroupId: blockGroup1Id,
         isHiddenInPublished: false,
       },
@@ -618,7 +604,6 @@ describe('reorderTab', () => {
         type: BlockType.RichText,
         title: '',
         isCurrent: false,
-        execStatus: 'idle',
         blockGroupId: blockGroup1Id,
         isHiddenInPublished: false,
       },
@@ -627,7 +612,6 @@ describe('reorderTab', () => {
         type: BlockType.RichText,
         title: '',
         isCurrent: true,
-        execStatus: 'idle',
         blockGroupId: blockGroup1Id,
         isHiddenInPublished: false,
       },
@@ -636,7 +620,6 @@ describe('reorderTab', () => {
         type: BlockType.RichText,
         title: '',
         isCurrent: false,
-        execStatus: 'idle',
         blockGroupId: blockGroup1Id,
         isHiddenInPublished: false,
       },
@@ -671,7 +654,6 @@ describe('reorderTab', () => {
         type: BlockType.RichText,
         title: '',
         isCurrent: false,
-        execStatus: 'idle',
         blockGroupId: blockGroup1Id,
         isHiddenInPublished: false,
       },
@@ -680,7 +662,6 @@ describe('reorderTab', () => {
         type: BlockType.RichText,
         title: '',
         isCurrent: true,
-        execStatus: 'idle',
         blockGroupId: blockGroup1Id,
         isHiddenInPublished: false,
       },
@@ -689,7 +670,6 @@ describe('reorderTab', () => {
         type: BlockType.RichText,
         title: '',
         isCurrent: false,
-        execStatus: 'idle',
         blockGroupId: blockGroup1Id,
         isHiddenInPublished: false,
       },
@@ -724,7 +704,6 @@ describe('reorderTab', () => {
         type: BlockType.RichText,
         title: '',
         isCurrent: true,
-        execStatus: 'idle',
         blockGroupId: blockGroup1Id,
         isHiddenInPublished: false,
       },
@@ -733,7 +712,6 @@ describe('reorderTab', () => {
         type: BlockType.RichText,
         title: '',
         isCurrent: false,
-        execStatus: 'idle',
         blockGroupId: blockGroup1Id,
         isHiddenInPublished: false,
       },
@@ -742,7 +720,6 @@ describe('reorderTab', () => {
         type: BlockType.RichText,
         title: '',
         isCurrent: false,
-        execStatus: 'idle',
         blockGroupId: blockGroup1Id,
         isHiddenInPublished: false,
       },
@@ -779,7 +756,6 @@ describe('removeBlock', () => {
         type: BlockType.RichText,
         title: '',
         isCurrent: false,
-        execStatus: 'idle',
         blockGroupId: blockGroup1Id,
         isHiddenInPublished: false,
       },
@@ -788,7 +764,6 @@ describe('removeBlock', () => {
         type: BlockType.RichText,
         title: '',
         isCurrent: true,
-        execStatus: 'idle',
         blockGroupId: blockGroup1Id,
         isHiddenInPublished: false,
       },
@@ -826,7 +801,6 @@ describe('removeBlock', () => {
         type: BlockType.RichText,
         title: '',
         isCurrent: true,
-        execStatus: 'idle',
         blockGroupId: blockGroup1Id,
         isHiddenInPublished: false,
       },
@@ -835,7 +809,6 @@ describe('removeBlock', () => {
         type: BlockType.RichText,
         title: '',
         isCurrent: false,
-        execStatus: 'idle',
         blockGroupId: blockGroup1Id,
         isHiddenInPublished: false,
       },
@@ -873,7 +846,6 @@ describe('removeBlock', () => {
         type: BlockType.RichText,
         title: '',
         isCurrent: false,
-        execStatus: 'idle',
         blockGroupId: blockGroup1Id,
         isHiddenInPublished: false,
       },
@@ -882,7 +854,6 @@ describe('removeBlock', () => {
         type: BlockType.RichText,
         title: '',
         isCurrent: true,
-        execStatus: 'idle',
         blockGroupId: blockGroup1Id,
         isHiddenInPublished: false,
       },
diff --git a/packages/editor/src/operations/document.test.ts b/packages/editor/src/operations/document.test.ts
index 7bb78608..6dd26edd 100644
--- a/packages/editor/src/operations/document.test.ts
+++ b/packages/editor/src/operations/document.test.ts
@@ -536,7 +536,6 @@ describe('groupBlocks', () => {
         type: BlockType.RichText,
         title: '',
         isCurrent: true,
-        execStatus: 'idle',
         blockGroupId: blockGroup1Id,
         isHiddenInPublished: false,
       },
@@ -549,7 +548,6 @@ describe('groupBlocks', () => {
         type: BlockType.RichText,
         title: '',
         isCurrent: true,
-        execStatus: 'idle',
         blockGroupId: blockGroup3Id,
         isHiddenInPublished: false,
       },
@@ -558,7 +556,6 @@ describe('groupBlocks', () => {
         type: BlockType.RichText,
         title: '',
         isCurrent: false,
-        execStatus: 'idle',
         blockGroupId: blockGroup3Id,
         isHiddenInPublished: false,
       },
@@ -598,7 +595,6 @@ describe('groupBlocks', () => {
         type: BlockType.RichText,
         title: '',
         isCurrent: false,
-        execStatus: 'idle',
         blockGroupId: blockGroup1Id,
         isHiddenInPublished: false,
       },
@@ -607,7 +603,6 @@ describe('groupBlocks', () => {
         type: BlockType.RichText,
         title: '',
         isCurrent: true,
-        execStatus: 'idle',
         blockGroupId: blockGroup1Id,
         isHiddenInPublished: false,
       },
@@ -619,7 +614,6 @@ describe('groupBlocks', () => {
         type: BlockType.RichText,
         title: '',
         isCurrent: true,
-        execStatus: 'idle',
         blockGroupId: blockGroup3Id,
         isHiddenInPublished: false,
       },
@@ -657,7 +651,6 @@ describe('groupBlocks', () => {
         type: BlockType.RichText,
         title: '',
         isCurrent: true,
-        execStatus: 'idle',
         blockGroupId: blockGroup2Id,
         isHiddenInPublished: false,
       },
@@ -666,7 +659,6 @@ describe('groupBlocks', () => {
         type: BlockType.RichText,
         title: '',
         isCurrent: false,
-        execStatus: 'idle',
         blockGroupId: blockGroup2Id,
         isHiddenInPublished: false,
       },
@@ -678,7 +670,6 @@ describe('groupBlocks', () => {
         type: BlockType.RichText,
         title: '',
         isCurrent: true,
-        execStatus: 'idle',
         blockGroupId: blockGroup3Id,
         isHiddenInPublished: false,
       },
@@ -716,7 +707,6 @@ describe('groupBlocks', () => {
         type: BlockType.RichText,
         title: '',
         isCurrent: false,
-        execStatus: 'idle',
         blockGroupId: blockGroup1Id,
         isHiddenInPublished: false,
       },
@@ -725,7 +715,6 @@ describe('groupBlocks', () => {
         type: BlockType.RichText,
         title: '',
         isCurrent: true,
-        execStatus: 'idle',
         blockGroupId: blockGroup1Id,
         isHiddenInPublished: false,
       },
@@ -737,7 +726,6 @@ describe('groupBlocks', () => {
         type: BlockType.RichText,
         title: '',
         isCurrent: true,
-        execStatus: 'idle',
         blockGroupId: blockGroup3Id,
         isHiddenInPublished: false,
       },
@@ -780,7 +768,6 @@ describe('groupBlocks', () => {
         type: BlockType.RichText,
         title: '',
         isCurrent: true,
-        execStatus: 'idle',
         blockGroupId: blockGroup3Id,
         isHiddenInPublished: false,
       },
@@ -792,7 +779,6 @@ describe('groupBlocks', () => {
         type: BlockType.RichText,
         title: '',
         isCurrent: false,
-        execStatus: 'idle',
         blockGroupId: blockGroup1Id,
         isHiddenInPublished: false,
       },
@@ -801,7 +787,6 @@ describe('groupBlocks', () => {
         type: BlockType.RichText,
         title: '',
         isCurrent: true,
-        execStatus: 'idle',
         blockGroupId: blockGroup1Id,
         isHiddenInPublished: false,
       },
@@ -844,7 +829,6 @@ describe('groupBlocks', () => {
         type: BlockType.RichText,
         title: '',
         isCurrent: true,
-        execStatus: 'idle',
         blockGroupId: blockGroup1Id,
         isHiddenInPublished: false,
       },
@@ -856,7 +840,6 @@ describe('groupBlocks', () => {
         type: BlockType.RichText,
         title: '',
         isCurrent: true,
-        execStatus: 'idle',
         blockGroupId: blockGroup3Id,
         isHiddenInPublished: false,
       },
@@ -865,7 +848,6 @@ describe('groupBlocks', () => {
         type: BlockType.RichText,
         title: '',
         isCurrent: false,
-        execStatus: 'idle',
         blockGroupId: blockGroup3Id,
         isHiddenInPublished: false,
       },
@@ -902,7 +884,6 @@ describe('addGroupedBlock', () => {
         type: BlockType.RichText,
         title: '',
         isCurrent: true,
-        execStatus: 'idle',
         blockGroupId: blockGroup1Id,
         isHiddenInPublished: false,
       },
@@ -911,7 +892,6 @@ describe('addGroupedBlock', () => {
         type: BlockType.Visualization,
         title: '',
         isCurrent: false,
-        execStatus: 'idle',
         blockGroupId: blockGroup1Id,
         isHiddenInPublished: false,
       },
@@ -946,7 +926,6 @@ describe('addGroupedBlock', () => {
         type: BlockType.Visualization,
         title: '',
         isCurrent: false,
-        execStatus: 'idle',
         blockGroupId: blockGroup1Id,
         isHiddenInPublished: false,
       },
@@ -955,7 +934,6 @@ describe('addGroupedBlock', () => {
         type: BlockType.RichText,
         title: '',
         isCurrent: true,
-        execStatus: 'idle',
         blockGroupId: blockGroup1Id,
         isHiddenInPublished: false,
       },
@@ -994,7 +972,6 @@ describe('duplicateBlockGroup', () => {
           type: BlockType.Visualization,
           title: '',
           isCurrent: true,
-          execStatus: 'idle',
           blockGroupId: newBlockGroup1Id,
           isHiddenInPublished: false,
         },
@@ -1009,7 +986,6 @@ describe('duplicateBlockGroup', () => {
           type: BlockType.Visualization,
           title: '',
           isCurrent: true,
-          execStatus: 'idle',
           blockGroupId: newBlockGroup2Id,
           isHiddenInPublished: false,
         },