| ... | @@ -63,18 +63,71 @@ export const resolveCodexHomeLayout = Effect.fn("resolveCodexHomeLayout")(functi | ... | @@ -63,18 +63,71 @@ export const resolveCodexHomeLayout = Effect.fn("resolveCodexHomeLayout")(functi |
| 63 | }; | 63 | }; |
| 64 | }); | 64 | }); |
| 65 | | 65 | |
| 66 | export class CodexShadowHomeError extends Schema.TaggedErrorClass<CodexShadowHomeError>()( | 66 | const CodexShadowHomeContext = { |
| 67 | "CodexShadowHomeError", | 67 | sharedHomePath: Schema.String, |
| | 68 | effectiveHomePath: Schema.String, |
| | 69 | }; |
| | 70 | |
| | 71 | export class CodexShadowHomeFileSystemError extends Schema.TaggedErrorClass<CodexShadowHomeFileSystemError>()( |
| | 72 | "CodexShadowHomeFileSystemError", |
| | 73 | { |
| | 74 | ...CodexShadowHomeContext, |
| | 75 | operation: Schema.Literals(["readLink", "makeDirectory", "readDirectory", "remove", "symlink"]), |
| | 76 | path: Schema.String, |
| | 77 | targetPath: Schema.optional(Schema.String), |
| | 78 | entryName: Schema.optional(Schema.String), |
| | 79 | cause: Schema.Defect(), |
| | 80 | }, |
| | 81 | ) { |
| | 82 | override get message(): string { |
| | 83 | const target = this.targetPath === undefined ? "" : ` to '${this.targetPath}'`; |
| | 84 | return `Codex shadow home filesystem operation '${this.operation}' failed for '${this.path}'${target}.`; |
| | 85 | } |
| | 86 | } |
| | 87 | |
| | 88 | export class CodexShadowHomePathConflictError extends Schema.TaggedErrorClass<CodexShadowHomePathConflictError>()( |
| | 89 | "CodexShadowHomePathConflictError", |
| | 90 | CodexShadowHomeContext, |
| | 91 | ) { |
| | 92 | override get message(): string { |
| | 93 | return `Codex shadow home path '${this.effectiveHomePath}' must be different from the shared home path '${this.sharedHomePath}'.`; |
| | 94 | } |
| | 95 | } |
| | 96 | |
| | 97 | export class CodexShadowHomeEntryConflictError extends Schema.TaggedErrorClass<CodexShadowHomeEntryConflictError>()( |
| | 98 | "CodexShadowHomeEntryConflictError", |
| 68 | { | 99 | { |
| 69 | detail: Schema.String, | 100 | ...CodexShadowHomeContext, |
| 70 | cause: Schema.optional(Schema.Unknown), | 101 | entryName: Schema.String, |
| | 102 | linkPath: Schema.String, |
| | 103 | targetPath: Schema.String, |
| 71 | }, | 104 | }, |
| 72 | ) { | 105 | ) { |
| 73 | override get message(): string { | 106 | override get message(): string { |
| 74 | return this.detail; | 107 | return `Cannot create Codex shadow home entry '${this.entryName}' because '${this.linkPath}' already exists and is not a symlink.`; |
| 75 | } | 108 | } |
| 76 | } | 109 | } |
| 77 | const isCodexShadowHomeError = Schema.is(CodexShadowHomeError); | 110 | |
| | 111 | export class CodexShadowHomePrivateEntrySymlinkError extends Schema.TaggedErrorClass<CodexShadowHomePrivateEntrySymlinkError>()( |
| | 112 | "CodexShadowHomePrivateEntrySymlinkError", |
| | 113 | { |
| | 114 | ...CodexShadowHomeContext, |
| | 115 | entryName: Schema.String, |
| | 116 | path: Schema.String, |
| | 117 | }, |
| | 118 | ) { |
| | 119 | override get message(): string { |
| | 120 | return `Codex shadow home private entry '${this.entryName}' at '${this.path}' must be a real file, not a symlink.`; |
| | 121 | } |
| | 122 | } |
| | 123 | |
| | 124 | export const CodexShadowHomeError = Schema.Union([ |
| | 125 | CodexShadowHomeFileSystemError, |
| | 126 | CodexShadowHomePathConflictError, |
| | 127 | CodexShadowHomeEntryConflictError, |
| | 128 | CodexShadowHomePrivateEntrySymlinkError, |
| | 129 | ]); |
| | 130 | export type CodexShadowHomeError = typeof CodexShadowHomeError.Type; |
| 78 | | 131 | |
| 79 | type LinkState = | 132 | type LinkState = |
| 80 | | { | 133 | | { |
| ... | @@ -88,21 +141,6 @@ type LinkState = | ... | @@ -88,21 +141,6 @@ type LinkState = |
| 88 | readonly target: string; | 141 | readonly target: string; |
| 89 | }; | 142 | }; |
| 90 | | 143 | |
| 91 | function toShadowHomeError(cause: unknown): CodexShadowHomeError { | | |
| 92 | return isCodexShadowHomeError(cause) | | |
| 93 | ? cause | | |
| 94 | : new CodexShadowHomeError({ | | |
| 95 | detail: "Failed to materialize Codex shadow home.", | | |
| 96 | cause, | | |
| 97 | }); | | |
| 98 | } | | |
| 99 | | | |
| 100 | function normalizeShadowHomeError<A, E, R>( | | |
| 101 | effect: Effect.Effect<A, E, R>, | | |
| 102 | ): Effect.Effect<A, CodexShadowHomeError, R> { | | |
| 103 | return effect.pipe(Effect.mapError(toShadowHomeError)); | | |
| 104 | } | | |
| 105 | | | |
| 106 | function isNotSymlinkError(error: PlatformError.PlatformError): boolean { | 144 | function isNotSymlinkError(error: PlatformError.PlatformError): boolean { |
| 107 | const cause = error.reason.cause; | 145 | const cause = error.reason.cause; |
| 108 | return ( | 146 | return ( |
| ... | @@ -114,78 +152,151 @@ function isNotSymlinkError(error: PlatformError.PlatformError): boolean { | ... | @@ -114,78 +152,151 @@ function isNotSymlinkError(error: PlatformError.PlatformError): boolean { |
| 114 | ); | 152 | ); |
| 115 | } | 153 | } |
| 116 | | 154 | |
| 117 | const readLinkState = Effect.fn("CodexHomeLayout.readLinkState")(function* ( | 155 | const readLinkState = Effect.fn("CodexHomeLayout.readLinkState")(function* (input: { |
| 118 | fileSystem: FileSystem.FileSystem, | 156 | readonly fileSystem: FileSystem.FileSystem; |
| 119 | linkPath: string, | 157 | readonly sharedHomePath: string; |
| 120 | ): Effect.fn.Return<LinkState, CodexShadowHomeError> { | 158 | readonly effectiveHomePath: string; |
| 121 | return yield* fileSystem.readLink(linkPath).pipe( | 159 | readonly entryName: string; |
| | 160 | readonly linkPath: string; |
| | 161 | }): Effect.fn.Return<LinkState, CodexShadowHomeError> { |
| | 162 | return yield* input.fileSystem.readLink(input.linkPath).pipe( |
| 122 | Effect.map((target): LinkState => ({ _tag: "Symlink", target })), | 163 | Effect.map((target): LinkState => ({ _tag: "Symlink", target })), |
| 123 | Effect.catch((error) => { | 164 | Effect.catchTags({ |
| 124 | if (error.reason._tag === "NotFound") { | 165 | PlatformError: (cause) => { |
| 125 | return Effect.succeed<LinkState>({ _tag: "Missing" }); | 166 | if (cause.reason._tag === "NotFound") { |
| 126 | } | 167 | return Effect.succeed<LinkState>({ _tag: "Missing" }); |
| 127 | if (isNotSymlinkError(error)) { | 168 | } |
| 128 | return Effect.succeed<LinkState>({ _tag: "NotSymlink" }); | 169 | if (isNotSymlinkError(cause)) { |
| 129 | } | 170 | return Effect.succeed<LinkState>({ _tag: "NotSymlink" }); |
| 130 | return Effect.fail(toShadowHomeError(error)); | 171 | } |
| | 172 | return new CodexShadowHomeFileSystemError({ |
| | 173 | sharedHomePath: input.sharedHomePath, |
| | 174 | effectiveHomePath: input.effectiveHomePath, |
| | 175 | operation: "readLink", |
| | 176 | path: input.linkPath, |
| | 177 | entryName: input.entryName, |
| | 178 | cause, |
| | 179 | }); |
| | 180 | }, |
| 131 | }), | 181 | }), |
| 132 | ); | 182 | ); |
| 133 | }); | 183 | }); |
| 134 | | 184 | |
| 135 | const removePrivateSymlink = Effect.fn("CodexHomeLayout.removePrivateSymlink")(function* (input: { | 185 | const removePrivateSymlink = Effect.fn("CodexHomeLayout.removePrivateSymlink")(function* (input: { |
| 136 | readonly fileSystem: FileSystem.FileSystem; | 186 | readonly fileSystem: FileSystem.FileSystem; |
| 137 | readonly shadowPath: string; | 187 | readonly sharedHomePath: string; |
| | 188 | readonly effectiveHomePath: string; |
| 138 | readonly entryName: string; | 189 | readonly entryName: string; |
| 139 | }): Effect.fn.Return<void, CodexShadowHomeError, Path.Path> { | 190 | }): Effect.fn.Return<void, CodexShadowHomeError, Path.Path> { |
| 140 | const path = yield* Path.Path; | 191 | const path = yield* Path.Path; |
| 141 | const privatePath = path.join(input.shadowPath, input.entryName); | 192 | const privatePath = path.join(input.effectiveHomePath, input.entryName); |
| 142 | const state = yield* readLinkState(input.fileSystem, privatePath); | 193 | const state = yield* readLinkState({ |
| | 194 | ...input, |
| | 195 | linkPath: privatePath, |
| | 196 | }); |
| 143 | if (state._tag === "Symlink") { | 197 | if (state._tag === "Symlink") { |
| 144 | yield* normalizeShadowHomeError(input.fileSystem.remove(privatePath)); | 198 | yield* input.fileSystem.remove(privatePath).pipe( |
| | 199 | Effect.catchTags({ |
| | 200 | PlatformError: (cause) => |
| | 201 | new CodexShadowHomeFileSystemError({ |
| | 202 | sharedHomePath: input.sharedHomePath, |
| | 203 | effectiveHomePath: input.effectiveHomePath, |
| | 204 | operation: "remove", |
| | 205 | path: privatePath, |
| | 206 | entryName: input.entryName, |
| | 207 | cause, |
| | 208 | }), |
| | 209 | }), |
| | 210 | ); |
| 145 | } | 211 | } |
| 146 | }); | 212 | }); |
| 147 | | 213 | |
| 148 | const ensureSymlink = Effect.fn("CodexHomeLayout.ensureSymlink")(function* (input: { | 214 | const ensureSymlink = Effect.fn("CodexHomeLayout.ensureSymlink")(function* (input: { |
| 149 | readonly fileSystem: FileSystem.FileSystem; | 215 | readonly fileSystem: FileSystem.FileSystem; |
| 150 | readonly shadowPath: string; | 216 | readonly sharedHomePath: string; |
| 151 | readonly sharedPath: string; | 217 | readonly effectiveHomePath: string; |
| 152 | readonly entryName: string; | 218 | readonly entryName: string; |
| 153 | }): Effect.fn.Return<void, CodexShadowHomeError, Path.Path> { | 219 | }): Effect.fn.Return<void, CodexShadowHomeError, Path.Path> { |
| 154 | const path = yield* Path.Path; | 220 | const path = yield* Path.Path; |
| 155 | const target = path.join(input.sharedPath, input.entryName); | 221 | const target = path.join(input.sharedHomePath, input.entryName); |
| 156 | const link = path.join(input.shadowPath, input.entryName); | 222 | const link = path.join(input.effectiveHomePath, input.entryName); |
| 157 | const state = yield* readLinkState(input.fileSystem, link); | 223 | const state = yield* readLinkState({ |
| | 224 | ...input, |
| | 225 | linkPath: link, |
| | 226 | }); |
| 158 | | 227 | |
| 159 | if (state._tag === "NotSymlink") { | 228 | if (state._tag === "NotSymlink") { |
| 160 | return yield* new CodexShadowHomeError({ | 229 | return yield* new CodexShadowHomeEntryConflictError({ |
| 161 | detail: `Cannot create Codex shadow home because '${link}' already exists and is not a symlink.`, | 230 | sharedHomePath: input.sharedHomePath, |
| | 231 | effectiveHomePath: input.effectiveHomePath, |
| | 232 | entryName: input.entryName, |
| | 233 | linkPath: link, |
| | 234 | targetPath: target, |
| 162 | }); | 235 | }); |
| 163 | } | 236 | } |
| 164 | | 237 | |
| | 238 | const createLink = input.fileSystem.symlink(target, link).pipe( |
| | 239 | Effect.catchTags({ |
| | 240 | PlatformError: (cause) => |
| | 241 | new CodexShadowHomeFileSystemError({ |
| | 242 | sharedHomePath: input.sharedHomePath, |
| | 243 | effectiveHomePath: input.effectiveHomePath, |
| | 244 | operation: "symlink", |
| | 245 | path: link, |
| | 246 | targetPath: target, |
| | 247 | entryName: input.entryName, |
| | 248 | cause, |
| | 249 | }), |
| | 250 | }), |
| | 251 | ); |
| | 252 | |
| 165 | if (state._tag === "Missing") { | 253 | if (state._tag === "Missing") { |
| 166 | return yield* normalizeShadowHomeError(input.fileSystem.symlink(target, link)); | 254 | return yield* createLink; |
| 167 | } | 255 | } |
| 168 | | 256 | |
| 169 | const resolvedExisting = path.resolve(path.dirname(link), state.target); | 257 | const resolvedExisting = path.resolve(path.dirname(link), state.target); |
| 170 | if (resolvedExisting !== target) { | 258 | if (resolvedExisting !== target) { |
| 171 | yield* normalizeShadowHomeError(input.fileSystem.remove(link)); | 259 | yield* input.fileSystem.remove(link).pipe( |
| 172 | yield* normalizeShadowHomeError(input.fileSystem.symlink(target, link)); | 260 | Effect.catchTags({ |
| | 261 | PlatformError: (cause) => |
| | 262 | new CodexShadowHomeFileSystemError({ |
| | 263 | sharedHomePath: input.sharedHomePath, |
| | 264 | effectiveHomePath: input.effectiveHomePath, |
| | 265 | operation: "remove", |
| | 266 | path: link, |
| | 267 | entryName: input.entryName, |
| | 268 | cause, |
| | 269 | }), |
| | 270 | }), |
| | 271 | ); |
| | 272 | yield* createLink; |
| 173 | } | 273 | } |
| 174 | }); | 274 | }); |
| 175 | | 275 | |
| 176 | const ensureShadowAuthIsPrivate = Effect.fn("CodexHomeLayout.ensureShadowAuthIsPrivate")(function* ( | 276 | const ensureShadowAuthIsPrivate = Effect.fn("CodexHomeLayout.ensureShadowAuthIsPrivate")( |
| 177 | fileSystem: FileSystem.FileSystem, | 277 | function* (input: { |
| 178 | shadowPath: string, | 278 | readonly fileSystem: FileSystem.FileSystem; |
| 179 | ): Effect.fn.Return<void, CodexShadowHomeError, Path.Path> { | 279 | readonly sharedHomePath: string; |
| 180 | const path = yield* Path.Path; | 280 | readonly effectiveHomePath: string; |
| 181 | const authPath = path.join(shadowPath, "auth.json"); | 281 | }): Effect.fn.Return<void, CodexShadowHomeError, Path.Path> { |
| 182 | const state = yield* readLinkState(fileSystem, authPath); | 282 | const path = yield* Path.Path; |
| 183 | if (state._tag === "Symlink") { | 283 | const entryName = "auth.json"; |
| 184 | return yield* new CodexShadowHomeError({ | 284 | const authPath = path.join(input.effectiveHomePath, entryName); |
| 185 | detail: `Codex shadow auth file '${authPath}' must be a real file, not a symlink.`, | 285 | const state = yield* readLinkState({ |
| | 286 | ...input, |
| | 287 | entryName, |
| | 288 | linkPath: authPath, |
| 186 | }); | 289 | }); |
| 187 | } | 290 | if (state._tag === "Symlink") { |
| 188 | }); | 291 | return yield* new CodexShadowHomePrivateEntrySymlinkError({ |
| | 292 | sharedHomePath: input.sharedHomePath, |
| | 293 | effectiveHomePath: input.effectiveHomePath, |
| | 294 | entryName, |
| | 295 | path: authPath, |
| | 296 | }); |
| | 297 | } |
| | 298 | }, |
| | 299 | ); |
| 189 | | 300 | |
| 190 | export const materializeCodexShadowHome = Effect.fn("materializeCodexShadowHome")(function* ( | 301 | export const materializeCodexShadowHome = Effect.fn("materializeCodexShadowHome")(function* ( |
| 191 | layout: CodexHomeLayout, | 302 | layout: CodexHomeLayout, |
| ... | @@ -194,31 +305,51 @@ export const materializeCodexShadowHome = Effect.fn("materializeCodexShadowHome" | ... | @@ -194,31 +305,51 @@ export const materializeCodexShadowHome = Effect.fn("materializeCodexShadowHome" |
| 194 | const effectiveHomePath = layout.effectiveHomePath; | 305 | const effectiveHomePath = layout.effectiveHomePath; |
| 195 | if (!effectiveHomePath) return; | 306 | if (!effectiveHomePath) return; |
| 196 | if (layout.sharedHomePath === effectiveHomePath) { | 307 | if (layout.sharedHomePath === effectiveHomePath) { |
| 197 | return yield* new CodexShadowHomeError({ | 308 | return yield* new CodexShadowHomePathConflictError({ |
| 198 | detail: "Codex shadow home path must be different from the shared home path.", | 309 | sharedHomePath: layout.sharedHomePath, |
| | 310 | effectiveHomePath, |
| 199 | }); | 311 | }); |
| 200 | } | 312 | } |
| 201 | | 313 | |
| 202 | const fileSystem = yield* FileSystem.FileSystem; | 314 | const fileSystem = yield* FileSystem.FileSystem; |
| 203 | const path = yield* Path.Path; | 315 | const path = yield* Path.Path; |
| 204 | | 316 | |
| 205 | yield* normalizeShadowHomeError( | 317 | const makeDirectory = (directoryPath: string) => |
| 206 | Effect.all( | 318 | fileSystem.makeDirectory(directoryPath, { recursive: true }).pipe( |
| 207 | [ | 319 | Effect.catchTags({ |
| 208 | fileSystem.makeDirectory(layout.sharedHomePath, { recursive: true }), | 320 | PlatformError: (cause) => |
| 209 | fileSystem.makeDirectory(effectiveHomePath, { recursive: true }), | 321 | new CodexShadowHomeFileSystemError({ |
| 210 | ...KNOWN_SHARED_DIRECTORIES.map((directory) => | 322 | sharedHomePath: layout.sharedHomePath, |
| 211 | fileSystem.makeDirectory(path.join(layout.sharedHomePath, directory), { | 323 | effectiveHomePath, |
| 212 | recursive: true, | 324 | operation: "makeDirectory", |
| | 325 | path: directoryPath, |
| | 326 | cause, |
| 213 | }), | 327 | }), |
| 214 | ), | 328 | }), |
| 215 | ], | 329 | ); |
| 216 | { concurrency: "unbounded" }, | 330 | |
| 217 | ), | 331 | yield* Effect.all( |
| | 332 | [ |
| | 333 | makeDirectory(layout.sharedHomePath), |
| | 334 | makeDirectory(effectiveHomePath), |
| | 335 | ...KNOWN_SHARED_DIRECTORIES.map((directory) => |
| | 336 | makeDirectory(path.join(layout.sharedHomePath, directory)), |
| | 337 | ), |
| | 338 | ], |
| | 339 | { concurrency: "unbounded" }, |
| 218 | ); | 340 | ); |
| 219 | | 341 | |
| 220 | const sharedEntryNames = yield* normalizeShadowHomeError( | 342 | const sharedEntryNames = yield* fileSystem.readDirectory(layout.sharedHomePath).pipe( |
| 221 | fileSystem.readDirectory(layout.sharedHomePath), | 343 | Effect.catchTags({ |
| | 344 | PlatformError: (cause) => |
| | 345 | new CodexShadowHomeFileSystemError({ |
| | 346 | sharedHomePath: layout.sharedHomePath, |
| | 347 | effectiveHomePath, |
| | 348 | operation: "readDirectory", |
| | 349 | path: layout.sharedHomePath, |
| | 350 | cause, |
| | 351 | }), |
| | 352 | }), |
| 222 | ); | 353 | ); |
| 223 | const entries = new Set<string>(KNOWN_SHARED_DIRECTORIES); | 354 | const entries = new Set<string>(KNOWN_SHARED_DIRECTORIES); |
| 224 | for (const entryName of sharedEntryNames) { | 355 | for (const entryName of sharedEntryNames) { |
| ... | @@ -234,7 +365,8 @@ export const materializeCodexShadowHome = Effect.fn("materializeCodexShadowHome" | ... | @@ -234,7 +365,8 @@ export const materializeCodexShadowHome = Effect.fn("materializeCodexShadowHome" |
| 234 | ? Effect.void | 365 | ? Effect.void |
| 235 | : removePrivateSymlink({ | 366 | : removePrivateSymlink({ |
| 236 | fileSystem, | 367 | fileSystem, |
| 237 | shadowPath: effectiveHomePath, | 368 | sharedHomePath: layout.sharedHomePath, |
| | 369 | effectiveHomePath, |
| 238 | entryName, | 370 | entryName, |
| 239 | }), | 371 | }), |
| 240 | { discard: true }, | 372 | { discard: true }, |
| ... | @@ -248,15 +380,19 @@ export const materializeCodexShadowHome = Effect.fn("materializeCodexShadowHome" | ... | @@ -248,15 +380,19 @@ export const materializeCodexShadowHome = Effect.fn("materializeCodexShadowHome" |
| 248 | } | 380 | } |
| 249 | return ensureSymlink({ | 381 | return ensureSymlink({ |
| 250 | fileSystem, | 382 | fileSystem, |
| 251 | shadowPath: effectiveHomePath, | 383 | sharedHomePath: layout.sharedHomePath, |
| 252 | sharedPath: layout.sharedHomePath, | 384 | effectiveHomePath, |
| 253 | entryName, | 385 | entryName, |
| 254 | }); | 386 | }); |
| 255 | }, | 387 | }, |
| 256 | { discard: true }, | 388 | { discard: true }, |
| 257 | ); | 389 | ); |
| 258 | | 390 | |
| 259 | yield* ensureShadowAuthIsPrivate(fileSystem, effectiveHomePath); | 391 | yield* ensureShadowAuthIsPrivate({ |
| | 392 | fileSystem, |
| | 393 | sharedHomePath: layout.sharedHomePath, |
| | 394 | effectiveHomePath, |
| | 395 | }); |
| 260 | }); | 396 | }); |
| 261 | | 397 | |
| 262 | export function codexContinuationIdentity(layout: CodexHomeLayout) { | 398 | export function codexContinuationIdentity(layout: CodexHomeLayout) { |