workspace.ts 4.2 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154
  1. import z from "zod"
  2. import { setTimeout as sleep } from "node:timers/promises"
  3. import { fn } from "@/util/fn"
  4. import { Database, eq } from "@/storage/db"
  5. import { Project } from "@/project/project"
  6. import { BusEvent } from "@/bus/bus-event"
  7. import { GlobalBus } from "@/bus/global"
  8. import { Log } from "@/util/log"
  9. import { ProjectID } from "@/project/schema"
  10. import { WorkspaceTable } from "./workspace.sql"
  11. import { getAdaptor } from "./adaptors"
  12. import { WorkspaceInfo } from "./types"
  13. import { WorkspaceID } from "./schema"
  14. import { parseSSE } from "./sse"
  15. export namespace Workspace {
  16. export const Event = {
  17. Ready: BusEvent.define(
  18. "workspace.ready",
  19. z.object({
  20. name: z.string(),
  21. }),
  22. ),
  23. Failed: BusEvent.define(
  24. "workspace.failed",
  25. z.object({
  26. message: z.string(),
  27. }),
  28. ),
  29. }
  30. export const Info = WorkspaceInfo.meta({
  31. ref: "Workspace",
  32. })
  33. export type Info = z.infer<typeof Info>
  34. function fromRow(row: typeof WorkspaceTable.$inferSelect): Info {
  35. return {
  36. id: row.id,
  37. type: row.type,
  38. branch: row.branch,
  39. name: row.name,
  40. directory: row.directory,
  41. extra: row.extra,
  42. projectID: row.project_id,
  43. }
  44. }
  45. const CreateInput = z.object({
  46. id: WorkspaceID.zod.optional(),
  47. type: Info.shape.type,
  48. branch: Info.shape.branch,
  49. projectID: ProjectID.zod,
  50. extra: Info.shape.extra,
  51. })
  52. export const create = fn(CreateInput, async (input) => {
  53. const id = WorkspaceID.ascending(input.id)
  54. const adaptor = await getAdaptor(input.type)
  55. const config = await adaptor.configure({ ...input, id, name: null, directory: null })
  56. const info: Info = {
  57. id,
  58. type: config.type,
  59. branch: config.branch ?? null,
  60. name: config.name ?? null,
  61. directory: config.directory ?? null,
  62. extra: config.extra ?? null,
  63. projectID: input.projectID,
  64. }
  65. Database.use((db) => {
  66. db.insert(WorkspaceTable)
  67. .values({
  68. id: info.id,
  69. type: info.type,
  70. branch: info.branch,
  71. name: info.name,
  72. directory: info.directory,
  73. extra: info.extra,
  74. project_id: info.projectID,
  75. })
  76. .run()
  77. })
  78. await adaptor.create(config)
  79. return info
  80. })
  81. export function list(project: Project.Info) {
  82. const rows = Database.use((db) =>
  83. db.select().from(WorkspaceTable).where(eq(WorkspaceTable.project_id, project.id)).all(),
  84. )
  85. return rows.map(fromRow).sort((a, b) => a.id.localeCompare(b.id))
  86. }
  87. export const get = fn(WorkspaceID.zod, async (id) => {
  88. const row = Database.use((db) => db.select().from(WorkspaceTable).where(eq(WorkspaceTable.id, id)).get())
  89. if (!row) return
  90. return fromRow(row)
  91. })
  92. export const remove = fn(WorkspaceID.zod, async (id) => {
  93. const row = Database.use((db) => db.select().from(WorkspaceTable).where(eq(WorkspaceTable.id, id)).get())
  94. if (row) {
  95. const info = fromRow(row)
  96. const adaptor = await getAdaptor(row.type)
  97. adaptor.remove(info)
  98. Database.use((db) => db.delete(WorkspaceTable).where(eq(WorkspaceTable.id, id)).run())
  99. return info
  100. }
  101. })
  102. const log = Log.create({ service: "workspace-sync" })
  103. async function workspaceEventLoop(space: Info, stop: AbortSignal) {
  104. while (!stop.aborted) {
  105. const adaptor = await getAdaptor(space.type)
  106. const res = await adaptor.fetch(space, "/event", { method: "GET", signal: stop }).catch(() => undefined)
  107. if (!res || !res.ok || !res.body) {
  108. await sleep(1000)
  109. continue
  110. }
  111. await parseSSE(res.body, stop, (event) => {
  112. GlobalBus.emit("event", {
  113. directory: space.id,
  114. payload: event,
  115. })
  116. })
  117. // Wait 250ms and retry if SSE connection fails
  118. await sleep(250)
  119. }
  120. }
  121. export function startSyncing(project: Project.Info) {
  122. const stop = new AbortController()
  123. const spaces = list(project).filter((space) => space.type !== "worktree")
  124. spaces.forEach((space) => {
  125. void workspaceEventLoop(space, stop.signal).catch((error) => {
  126. log.warn("workspace sync listener failed", {
  127. workspaceID: space.id,
  128. error,
  129. })
  130. })
  131. })
  132. return {
  133. async stop() {
  134. stop.abort()
  135. },
  136. }
  137. }
  138. }