instance.ts 3.5 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117
  1. import { Log } from "@/util/log"
  2. import { Context } from "../util/context"
  3. import { Project } from "./project"
  4. import { State } from "./state"
  5. import { iife } from "@/util/iife"
  6. import { GlobalBus } from "@/bus/global"
  7. import { Filesystem } from "@/util/filesystem"
  8. import { withTimeout } from "@/util/timeout"
  9. interface Context {
  10. directory: string
  11. worktree: string
  12. project: Project.Info
  13. }
  14. const context = Context.create<Context>("instance")
  15. const cache = new Map<string, Promise<Context>>()
  16. const DISPOSE_TIMEOUT_MS = 10_000
  17. const disposal = {
  18. all: undefined as Promise<void> | undefined,
  19. }
  20. export const Instance = {
  21. async provide<R>(input: { directory: string; init?: () => Promise<any>; fn: () => R }): Promise<R> {
  22. let existing = cache.get(input.directory)
  23. if (!existing) {
  24. Log.Default.info("creating instance", { directory: input.directory })
  25. existing = iife(async () => {
  26. const { project, sandbox } = await Project.fromDirectory(input.directory)
  27. const ctx = {
  28. directory: input.directory,
  29. worktree: sandbox,
  30. project,
  31. }
  32. await context.provide(ctx, async () => {
  33. await input.init?.()
  34. })
  35. return ctx
  36. })
  37. cache.set(input.directory, existing)
  38. }
  39. const ctx = await existing
  40. return context.provide(ctx, async () => {
  41. return input.fn()
  42. })
  43. },
  44. get directory() {
  45. return context.use().directory
  46. },
  47. get worktree() {
  48. return context.use().worktree
  49. },
  50. get project() {
  51. return context.use().project
  52. },
  53. /**
  54. * Check if a path is within the project boundary.
  55. * Returns true if path is inside Instance.directory OR Instance.worktree.
  56. * Paths within the worktree but outside the working directory should not trigger external_directory permission.
  57. */
  58. containsPath(filepath: string) {
  59. if (Filesystem.contains(Instance.directory, filepath)) return true
  60. // Non-git projects set worktree to "/" which would match ANY absolute path.
  61. // Skip worktree check in this case to preserve external_directory permissions.
  62. if (Instance.worktree === "/") return false
  63. return Filesystem.contains(Instance.worktree, filepath)
  64. },
  65. state<S>(init: () => S, dispose?: (state: Awaited<S>) => Promise<void>): () => S {
  66. return State.create(() => Instance.directory, init, dispose)
  67. },
  68. async dispose() {
  69. Log.Default.info("disposing instance", { directory: Instance.directory })
  70. await State.dispose(Instance.directory)
  71. cache.delete(Instance.directory)
  72. GlobalBus.emit("event", {
  73. directory: Instance.directory,
  74. payload: {
  75. type: "server.instance.disposed",
  76. properties: {
  77. directory: Instance.directory,
  78. },
  79. },
  80. })
  81. },
  82. async disposeAll() {
  83. if (disposal.all) return disposal.all
  84. disposal.all = iife(async () => {
  85. Log.Default.info("disposing all instances")
  86. const entries = [...cache.entries()]
  87. for (const [key, value] of entries) {
  88. if (cache.get(key) !== value) continue
  89. const ctx = await withTimeout(value, DISPOSE_TIMEOUT_MS).catch((error) => {
  90. Log.Default.warn("instance dispose timed out", { key, error })
  91. return undefined
  92. })
  93. if (!ctx) {
  94. if (cache.get(key) === value) cache.delete(key)
  95. continue
  96. }
  97. if (cache.get(key) !== value) continue
  98. await context.provide(ctx, async () => {
  99. await Instance.dispose()
  100. })
  101. }
  102. }).finally(() => {
  103. disposal.all = undefined
  104. })
  105. return disposal.all
  106. },
  107. }