octocode-ai-shared 5.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/package.json +42 -0
- package/src/filesystem.ts +236 -0
- package/src/global.ts +84 -0
- package/src/types.d.ts +46 -0
- package/src/util/array.ts +10 -0
- package/src/util/binary.ts +41 -0
- package/src/util/effect-flock.ts +283 -0
- package/src/util/encode.ts +51 -0
- package/src/util/error.ts +60 -0
- package/src/util/flock.ts +358 -0
- package/src/util/fn.ts +11 -0
- package/src/util/glob.ts +34 -0
- package/src/util/hash.ts +7 -0
- package/src/util/identifier.ts +53 -0
- package/src/util/iife.ts +3 -0
- package/src/util/lazy.ts +11 -0
- package/src/util/module.ts +10 -0
- package/src/util/path.ts +37 -0
- package/src/util/retry.ts +42 -0
- package/src/util/slug.ts +74 -0
- package/test/filesystem/filesystem.test.ts +338 -0
- package/test/fixture/effect-flock-worker.ts +63 -0
- package/test/fixture/flock-worker.ts +72 -0
- package/test/global.test.ts +54 -0
- package/test/lib/effect.ts +53 -0
- package/test/util/effect-flock.test.ts +389 -0
- package/test/util/flock.test.ts +426 -0
- package/test/util/identifier.test.ts +30 -0
- package/tsconfig.json +14 -0
|
@@ -0,0 +1,338 @@
|
|
|
1
|
+
import { describe, test, expect } from "bun:test"
|
|
2
|
+
import { Effect, Layer, FileSystem } from "effect"
|
|
3
|
+
import { NodeFileSystem } from "@effect/platform-node"
|
|
4
|
+
import { AppFileSystem } from "octocode-ai-shared/filesystem"
|
|
5
|
+
import { testEffect } from "../lib/effect"
|
|
6
|
+
import path from "path"
|
|
7
|
+
|
|
8
|
+
const live = AppFileSystem.layer.pipe(Layer.provideMerge(NodeFileSystem.layer))
|
|
9
|
+
const { effect: it } = testEffect(live)
|
|
10
|
+
|
|
11
|
+
describe("AppFileSystem", () => {
|
|
12
|
+
describe("isDir", () => {
|
|
13
|
+
it(
|
|
14
|
+
"returns true for directories",
|
|
15
|
+
Effect.gen(function* () {
|
|
16
|
+
const fs = yield* AppFileSystem.Service
|
|
17
|
+
const filesys = yield* FileSystem.FileSystem
|
|
18
|
+
const tmp = yield* filesys.makeTempDirectoryScoped()
|
|
19
|
+
expect(yield* fs.isDir(tmp)).toBe(true)
|
|
20
|
+
}),
|
|
21
|
+
)
|
|
22
|
+
|
|
23
|
+
it(
|
|
24
|
+
"returns false for files",
|
|
25
|
+
Effect.gen(function* () {
|
|
26
|
+
const fs = yield* AppFileSystem.Service
|
|
27
|
+
const filesys = yield* FileSystem.FileSystem
|
|
28
|
+
const tmp = yield* filesys.makeTempDirectoryScoped()
|
|
29
|
+
const file = path.join(tmp, "test.txt")
|
|
30
|
+
yield* filesys.writeFileString(file, "hello")
|
|
31
|
+
expect(yield* fs.isDir(file)).toBe(false)
|
|
32
|
+
}),
|
|
33
|
+
)
|
|
34
|
+
|
|
35
|
+
it(
|
|
36
|
+
"returns false for non-existent paths",
|
|
37
|
+
Effect.gen(function* () {
|
|
38
|
+
const fs = yield* AppFileSystem.Service
|
|
39
|
+
expect(yield* fs.isDir("/tmp/nonexistent-" + Math.random())).toBe(false)
|
|
40
|
+
}),
|
|
41
|
+
)
|
|
42
|
+
})
|
|
43
|
+
|
|
44
|
+
describe("isFile", () => {
|
|
45
|
+
it(
|
|
46
|
+
"returns true for files",
|
|
47
|
+
Effect.gen(function* () {
|
|
48
|
+
const fs = yield* AppFileSystem.Service
|
|
49
|
+
const filesys = yield* FileSystem.FileSystem
|
|
50
|
+
const tmp = yield* filesys.makeTempDirectoryScoped()
|
|
51
|
+
const file = path.join(tmp, "test.txt")
|
|
52
|
+
yield* filesys.writeFileString(file, "hello")
|
|
53
|
+
expect(yield* fs.isFile(file)).toBe(true)
|
|
54
|
+
}),
|
|
55
|
+
)
|
|
56
|
+
|
|
57
|
+
it(
|
|
58
|
+
"returns false for directories",
|
|
59
|
+
Effect.gen(function* () {
|
|
60
|
+
const fs = yield* AppFileSystem.Service
|
|
61
|
+
const filesys = yield* FileSystem.FileSystem
|
|
62
|
+
const tmp = yield* filesys.makeTempDirectoryScoped()
|
|
63
|
+
expect(yield* fs.isFile(tmp)).toBe(false)
|
|
64
|
+
}),
|
|
65
|
+
)
|
|
66
|
+
})
|
|
67
|
+
|
|
68
|
+
describe("readJson / writeJson", () => {
|
|
69
|
+
it(
|
|
70
|
+
"round-trips JSON data",
|
|
71
|
+
Effect.gen(function* () {
|
|
72
|
+
const fs = yield* AppFileSystem.Service
|
|
73
|
+
const filesys = yield* FileSystem.FileSystem
|
|
74
|
+
const tmp = yield* filesys.makeTempDirectoryScoped()
|
|
75
|
+
const file = path.join(tmp, "data.json")
|
|
76
|
+
const data = { name: "test", count: 42, nested: { ok: true } }
|
|
77
|
+
|
|
78
|
+
yield* fs.writeJson(file, data)
|
|
79
|
+
const result = yield* fs.readJson(file)
|
|
80
|
+
|
|
81
|
+
expect(result).toEqual(data)
|
|
82
|
+
}),
|
|
83
|
+
)
|
|
84
|
+
})
|
|
85
|
+
|
|
86
|
+
describe("ensureDir", () => {
|
|
87
|
+
it(
|
|
88
|
+
"creates nested directories",
|
|
89
|
+
Effect.gen(function* () {
|
|
90
|
+
const fs = yield* AppFileSystem.Service
|
|
91
|
+
const filesys = yield* FileSystem.FileSystem
|
|
92
|
+
const tmp = yield* filesys.makeTempDirectoryScoped()
|
|
93
|
+
const nested = path.join(tmp, "a", "b", "c")
|
|
94
|
+
|
|
95
|
+
yield* fs.ensureDir(nested)
|
|
96
|
+
|
|
97
|
+
const info = yield* filesys.stat(nested)
|
|
98
|
+
expect(info.type).toBe("Directory")
|
|
99
|
+
}),
|
|
100
|
+
)
|
|
101
|
+
|
|
102
|
+
it(
|
|
103
|
+
"is idempotent",
|
|
104
|
+
Effect.gen(function* () {
|
|
105
|
+
const fs = yield* AppFileSystem.Service
|
|
106
|
+
const filesys = yield* FileSystem.FileSystem
|
|
107
|
+
const tmp = yield* filesys.makeTempDirectoryScoped()
|
|
108
|
+
const dir = path.join(tmp, "existing")
|
|
109
|
+
yield* filesys.makeDirectory(dir)
|
|
110
|
+
|
|
111
|
+
yield* fs.ensureDir(dir)
|
|
112
|
+
|
|
113
|
+
const info = yield* filesys.stat(dir)
|
|
114
|
+
expect(info.type).toBe("Directory")
|
|
115
|
+
}),
|
|
116
|
+
)
|
|
117
|
+
})
|
|
118
|
+
|
|
119
|
+
describe("writeWithDirs", () => {
|
|
120
|
+
it(
|
|
121
|
+
"creates parent directories if missing",
|
|
122
|
+
Effect.gen(function* () {
|
|
123
|
+
const fs = yield* AppFileSystem.Service
|
|
124
|
+
const filesys = yield* FileSystem.FileSystem
|
|
125
|
+
const tmp = yield* filesys.makeTempDirectoryScoped()
|
|
126
|
+
const file = path.join(tmp, "deep", "nested", "file.txt")
|
|
127
|
+
|
|
128
|
+
yield* fs.writeWithDirs(file, "hello")
|
|
129
|
+
|
|
130
|
+
expect(yield* filesys.readFileString(file)).toBe("hello")
|
|
131
|
+
}),
|
|
132
|
+
)
|
|
133
|
+
|
|
134
|
+
it(
|
|
135
|
+
"writes directly when parent exists",
|
|
136
|
+
Effect.gen(function* () {
|
|
137
|
+
const fs = yield* AppFileSystem.Service
|
|
138
|
+
const filesys = yield* FileSystem.FileSystem
|
|
139
|
+
const tmp = yield* filesys.makeTempDirectoryScoped()
|
|
140
|
+
const file = path.join(tmp, "direct.txt")
|
|
141
|
+
|
|
142
|
+
yield* fs.writeWithDirs(file, "world")
|
|
143
|
+
|
|
144
|
+
expect(yield* filesys.readFileString(file)).toBe("world")
|
|
145
|
+
}),
|
|
146
|
+
)
|
|
147
|
+
|
|
148
|
+
it(
|
|
149
|
+
"writes Uint8Array content",
|
|
150
|
+
Effect.gen(function* () {
|
|
151
|
+
const fs = yield* AppFileSystem.Service
|
|
152
|
+
const filesys = yield* FileSystem.FileSystem
|
|
153
|
+
const tmp = yield* filesys.makeTempDirectoryScoped()
|
|
154
|
+
const file = path.join(tmp, "binary.bin")
|
|
155
|
+
const content = new Uint8Array([0x00, 0x01, 0x02, 0x03])
|
|
156
|
+
|
|
157
|
+
yield* fs.writeWithDirs(file, content)
|
|
158
|
+
|
|
159
|
+
const result = yield* filesys.readFile(file)
|
|
160
|
+
expect(new Uint8Array(result)).toEqual(content)
|
|
161
|
+
}),
|
|
162
|
+
)
|
|
163
|
+
})
|
|
164
|
+
|
|
165
|
+
describe("findUp", () => {
|
|
166
|
+
it(
|
|
167
|
+
"finds target in start directory",
|
|
168
|
+
Effect.gen(function* () {
|
|
169
|
+
const fs = yield* AppFileSystem.Service
|
|
170
|
+
const filesys = yield* FileSystem.FileSystem
|
|
171
|
+
const tmp = yield* filesys.makeTempDirectoryScoped()
|
|
172
|
+
yield* filesys.writeFileString(path.join(tmp, "target.txt"), "found")
|
|
173
|
+
|
|
174
|
+
const result = yield* fs.findUp("target.txt", tmp)
|
|
175
|
+
expect(result).toEqual([path.join(tmp, "target.txt")])
|
|
176
|
+
}),
|
|
177
|
+
)
|
|
178
|
+
|
|
179
|
+
it(
|
|
180
|
+
"finds target in parent directories",
|
|
181
|
+
Effect.gen(function* () {
|
|
182
|
+
const fs = yield* AppFileSystem.Service
|
|
183
|
+
const filesys = yield* FileSystem.FileSystem
|
|
184
|
+
const tmp = yield* filesys.makeTempDirectoryScoped()
|
|
185
|
+
yield* filesys.writeFileString(path.join(tmp, "marker"), "root")
|
|
186
|
+
const child = path.join(tmp, "a", "b")
|
|
187
|
+
yield* filesys.makeDirectory(child, { recursive: true })
|
|
188
|
+
|
|
189
|
+
const result = yield* fs.findUp("marker", child, tmp)
|
|
190
|
+
expect(result).toEqual([path.join(tmp, "marker")])
|
|
191
|
+
}),
|
|
192
|
+
)
|
|
193
|
+
|
|
194
|
+
it(
|
|
195
|
+
"returns empty array when not found",
|
|
196
|
+
Effect.gen(function* () {
|
|
197
|
+
const fs = yield* AppFileSystem.Service
|
|
198
|
+
const filesys = yield* FileSystem.FileSystem
|
|
199
|
+
const tmp = yield* filesys.makeTempDirectoryScoped()
|
|
200
|
+
const result = yield* fs.findUp("nonexistent", tmp, tmp)
|
|
201
|
+
expect(result).toEqual([])
|
|
202
|
+
}),
|
|
203
|
+
)
|
|
204
|
+
})
|
|
205
|
+
|
|
206
|
+
describe("up", () => {
|
|
207
|
+
it(
|
|
208
|
+
"finds multiple targets walking up",
|
|
209
|
+
Effect.gen(function* () {
|
|
210
|
+
const fs = yield* AppFileSystem.Service
|
|
211
|
+
const filesys = yield* FileSystem.FileSystem
|
|
212
|
+
const tmp = yield* filesys.makeTempDirectoryScoped()
|
|
213
|
+
yield* filesys.writeFileString(path.join(tmp, "a.txt"), "a")
|
|
214
|
+
yield* filesys.writeFileString(path.join(tmp, "b.txt"), "b")
|
|
215
|
+
const child = path.join(tmp, "sub")
|
|
216
|
+
yield* filesys.makeDirectory(child)
|
|
217
|
+
yield* filesys.writeFileString(path.join(child, "a.txt"), "a-child")
|
|
218
|
+
|
|
219
|
+
const result = yield* fs.up({ targets: ["a.txt", "b.txt"], start: child, stop: tmp })
|
|
220
|
+
|
|
221
|
+
expect(result).toContain(path.join(child, "a.txt"))
|
|
222
|
+
expect(result).toContain(path.join(tmp, "a.txt"))
|
|
223
|
+
expect(result).toContain(path.join(tmp, "b.txt"))
|
|
224
|
+
}),
|
|
225
|
+
)
|
|
226
|
+
})
|
|
227
|
+
|
|
228
|
+
describe("glob", () => {
|
|
229
|
+
it(
|
|
230
|
+
"finds files matching pattern",
|
|
231
|
+
Effect.gen(function* () {
|
|
232
|
+
const fs = yield* AppFileSystem.Service
|
|
233
|
+
const filesys = yield* FileSystem.FileSystem
|
|
234
|
+
const tmp = yield* filesys.makeTempDirectoryScoped()
|
|
235
|
+
yield* filesys.writeFileString(path.join(tmp, "a.ts"), "a")
|
|
236
|
+
yield* filesys.writeFileString(path.join(tmp, "b.ts"), "b")
|
|
237
|
+
yield* filesys.writeFileString(path.join(tmp, "c.json"), "c")
|
|
238
|
+
|
|
239
|
+
const result = yield* fs.glob("*.ts", { cwd: tmp })
|
|
240
|
+
expect(result.sort()).toEqual(["a.ts", "b.ts"])
|
|
241
|
+
}),
|
|
242
|
+
)
|
|
243
|
+
|
|
244
|
+
it(
|
|
245
|
+
"supports absolute paths",
|
|
246
|
+
Effect.gen(function* () {
|
|
247
|
+
const fs = yield* AppFileSystem.Service
|
|
248
|
+
const filesys = yield* FileSystem.FileSystem
|
|
249
|
+
const tmp = yield* filesys.makeTempDirectoryScoped()
|
|
250
|
+
yield* filesys.writeFileString(path.join(tmp, "file.txt"), "hello")
|
|
251
|
+
|
|
252
|
+
const result = yield* fs.glob("*.txt", { cwd: tmp, absolute: true })
|
|
253
|
+
expect(result).toEqual([path.join(tmp, "file.txt")])
|
|
254
|
+
}),
|
|
255
|
+
)
|
|
256
|
+
})
|
|
257
|
+
|
|
258
|
+
describe("globMatch", () => {
|
|
259
|
+
it(
|
|
260
|
+
"matches patterns",
|
|
261
|
+
Effect.gen(function* () {
|
|
262
|
+
const fs = yield* AppFileSystem.Service
|
|
263
|
+
expect(fs.globMatch("*.ts", "foo.ts")).toBe(true)
|
|
264
|
+
expect(fs.globMatch("*.ts", "foo.json")).toBe(false)
|
|
265
|
+
expect(fs.globMatch("src/**", "src/a/b.ts")).toBe(true)
|
|
266
|
+
}),
|
|
267
|
+
)
|
|
268
|
+
})
|
|
269
|
+
|
|
270
|
+
describe("globUp", () => {
|
|
271
|
+
it(
|
|
272
|
+
"finds files walking up directories",
|
|
273
|
+
Effect.gen(function* () {
|
|
274
|
+
const fs = yield* AppFileSystem.Service
|
|
275
|
+
const filesys = yield* FileSystem.FileSystem
|
|
276
|
+
const tmp = yield* filesys.makeTempDirectoryScoped()
|
|
277
|
+
yield* filesys.writeFileString(path.join(tmp, "root.md"), "root")
|
|
278
|
+
const child = path.join(tmp, "a", "b")
|
|
279
|
+
yield* filesys.makeDirectory(child, { recursive: true })
|
|
280
|
+
yield* filesys.writeFileString(path.join(child, "leaf.md"), "leaf")
|
|
281
|
+
|
|
282
|
+
const result = yield* fs.globUp("*.md", child, tmp)
|
|
283
|
+
expect(result).toContain(path.join(child, "leaf.md"))
|
|
284
|
+
expect(result).toContain(path.join(tmp, "root.md"))
|
|
285
|
+
}),
|
|
286
|
+
)
|
|
287
|
+
})
|
|
288
|
+
|
|
289
|
+
describe("built-in passthrough", () => {
|
|
290
|
+
it(
|
|
291
|
+
"exists works",
|
|
292
|
+
Effect.gen(function* () {
|
|
293
|
+
yield* AppFileSystem.Service
|
|
294
|
+
const filesys = yield* FileSystem.FileSystem
|
|
295
|
+
const tmp = yield* filesys.makeTempDirectoryScoped()
|
|
296
|
+
const file = path.join(tmp, "exists.txt")
|
|
297
|
+
yield* filesys.writeFileString(file, "yes")
|
|
298
|
+
|
|
299
|
+
expect(yield* filesys.exists(file)).toBe(true)
|
|
300
|
+
expect(yield* filesys.exists(file + ".nope")).toBe(false)
|
|
301
|
+
}),
|
|
302
|
+
)
|
|
303
|
+
|
|
304
|
+
it(
|
|
305
|
+
"remove works",
|
|
306
|
+
Effect.gen(function* () {
|
|
307
|
+
yield* AppFileSystem.Service
|
|
308
|
+
const filesys = yield* FileSystem.FileSystem
|
|
309
|
+
const tmp = yield* filesys.makeTempDirectoryScoped()
|
|
310
|
+
const file = path.join(tmp, "delete-me.txt")
|
|
311
|
+
yield* filesys.writeFileString(file, "bye")
|
|
312
|
+
|
|
313
|
+
yield* filesys.remove(file)
|
|
314
|
+
|
|
315
|
+
expect(yield* filesys.exists(file)).toBe(false)
|
|
316
|
+
}),
|
|
317
|
+
)
|
|
318
|
+
})
|
|
319
|
+
|
|
320
|
+
describe("pure helpers", () => {
|
|
321
|
+
test("mimeType returns correct types", () => {
|
|
322
|
+
expect(AppFileSystem.mimeType("file.json")).toBe("application/json")
|
|
323
|
+
expect(AppFileSystem.mimeType("image.png")).toBe("image/png")
|
|
324
|
+
expect(AppFileSystem.mimeType("unknown.qzx")).toBe("application/octet-stream")
|
|
325
|
+
})
|
|
326
|
+
|
|
327
|
+
test("contains checks path containment", () => {
|
|
328
|
+
expect(AppFileSystem.contains("/a/b", "/a/b/c")).toBe(true)
|
|
329
|
+
expect(AppFileSystem.contains("/a/b", "/a/c")).toBe(false)
|
|
330
|
+
})
|
|
331
|
+
|
|
332
|
+
test("overlaps detects overlapping paths", () => {
|
|
333
|
+
expect(AppFileSystem.overlaps("/a/b", "/a/b/c")).toBe(true)
|
|
334
|
+
expect(AppFileSystem.overlaps("/a/b/c", "/a/b")).toBe(true)
|
|
335
|
+
expect(AppFileSystem.overlaps("/a", "/b")).toBe(false)
|
|
336
|
+
})
|
|
337
|
+
})
|
|
338
|
+
})
|
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
import fs from "fs/promises"
|
|
2
|
+
import os from "os"
|
|
3
|
+
import { Effect, Layer } from "effect"
|
|
4
|
+
import { AppFileSystem } from "octocode-ai-shared/filesystem"
|
|
5
|
+
import { EffectFlock } from "octocode-ai-shared/util/effect-flock"
|
|
6
|
+
import { Global } from "octocode-ai-shared/global"
|
|
7
|
+
|
|
8
|
+
type Msg = {
|
|
9
|
+
key: string
|
|
10
|
+
dir: string
|
|
11
|
+
holdMs?: number
|
|
12
|
+
ready?: string
|
|
13
|
+
active?: string
|
|
14
|
+
done?: string
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
function sleep(ms: number) {
|
|
18
|
+
return new Promise<void>((resolve) => setTimeout(resolve, ms))
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
const msg: Msg = JSON.parse(process.argv[2]!)
|
|
22
|
+
|
|
23
|
+
const testGlobal = Layer.succeed(
|
|
24
|
+
Global.Service,
|
|
25
|
+
Global.Service.of({
|
|
26
|
+
home: os.homedir(),
|
|
27
|
+
data: os.tmpdir(),
|
|
28
|
+
cache: os.tmpdir(),
|
|
29
|
+
config: os.tmpdir(),
|
|
30
|
+
state: os.tmpdir(),
|
|
31
|
+
bin: os.tmpdir(),
|
|
32
|
+
log: os.tmpdir(),
|
|
33
|
+
}),
|
|
34
|
+
)
|
|
35
|
+
|
|
36
|
+
const testLayer = EffectFlock.layer.pipe(Layer.provide(testGlobal), Layer.provide(AppFileSystem.defaultLayer))
|
|
37
|
+
|
|
38
|
+
async function job() {
|
|
39
|
+
if (msg.ready) await fs.writeFile(msg.ready, String(process.pid))
|
|
40
|
+
if (msg.active) await fs.writeFile(msg.active, String(process.pid), { flag: "wx" })
|
|
41
|
+
|
|
42
|
+
try {
|
|
43
|
+
if (msg.holdMs && msg.holdMs > 0) await sleep(msg.holdMs)
|
|
44
|
+
if (msg.done) await fs.appendFile(msg.done, "1\n")
|
|
45
|
+
} finally {
|
|
46
|
+
if (msg.active) await fs.rm(msg.active, { force: true })
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
await Effect.runPromise(
|
|
51
|
+
Effect.gen(function* () {
|
|
52
|
+
const flock = yield* EffectFlock.Service
|
|
53
|
+
yield* flock.withLock(
|
|
54
|
+
Effect.promise(() => job()),
|
|
55
|
+
msg.key,
|
|
56
|
+
msg.dir,
|
|
57
|
+
)
|
|
58
|
+
}).pipe(Effect.provide(testLayer)),
|
|
59
|
+
).catch((err) => {
|
|
60
|
+
const text = err instanceof Error ? (err.stack ?? err.message) : String(err)
|
|
61
|
+
process.stderr.write(text)
|
|
62
|
+
process.exit(1)
|
|
63
|
+
})
|
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
import fs from "fs/promises"
|
|
2
|
+
import { Flock } from "octocode-ai-shared/util/flock"
|
|
3
|
+
|
|
4
|
+
type Msg = {
|
|
5
|
+
key: string
|
|
6
|
+
dir: string
|
|
7
|
+
staleMs?: number
|
|
8
|
+
timeoutMs?: number
|
|
9
|
+
baseDelayMs?: number
|
|
10
|
+
maxDelayMs?: number
|
|
11
|
+
holdMs?: number
|
|
12
|
+
ready?: string
|
|
13
|
+
active?: string
|
|
14
|
+
done?: string
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
function sleep(ms: number) {
|
|
18
|
+
return new Promise<void>((resolve) => {
|
|
19
|
+
setTimeout(resolve, ms)
|
|
20
|
+
})
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
function input() {
|
|
24
|
+
const raw = process.argv[2]
|
|
25
|
+
if (!raw) {
|
|
26
|
+
throw new Error("Missing flock worker input")
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
return JSON.parse(raw) as Msg
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
async function job(input: Msg) {
|
|
33
|
+
if (input.ready) {
|
|
34
|
+
await fs.writeFile(input.ready, String(process.pid))
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
if (input.active) {
|
|
38
|
+
await fs.writeFile(input.active, String(process.pid), { flag: "wx" })
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
try {
|
|
42
|
+
if (input.holdMs && input.holdMs > 0) {
|
|
43
|
+
await sleep(input.holdMs)
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
if (input.done) {
|
|
47
|
+
await fs.appendFile(input.done, "1\n")
|
|
48
|
+
}
|
|
49
|
+
} finally {
|
|
50
|
+
if (input.active) {
|
|
51
|
+
await fs.rm(input.active, { force: true })
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
async function main() {
|
|
57
|
+
const msg = input()
|
|
58
|
+
|
|
59
|
+
await Flock.withLock(msg.key, () => job(msg), {
|
|
60
|
+
dir: msg.dir,
|
|
61
|
+
staleMs: msg.staleMs,
|
|
62
|
+
timeoutMs: msg.timeoutMs,
|
|
63
|
+
baseDelayMs: msg.baseDelayMs,
|
|
64
|
+
maxDelayMs: msg.maxDelayMs,
|
|
65
|
+
})
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
await main().catch((err) => {
|
|
69
|
+
const text = err instanceof Error ? (err.stack ?? err.message) : String(err)
|
|
70
|
+
process.stderr.write(text)
|
|
71
|
+
process.exit(1)
|
|
72
|
+
})
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
import { describe, expect, test } from "bun:test"
|
|
2
|
+
import path from "path"
|
|
3
|
+
import { resolveOctocodeHome } from "octocode-ai-shared/global"
|
|
4
|
+
|
|
5
|
+
describe("resolveOctocodeHome", () => {
|
|
6
|
+
test("with OCTOCODE_HOME set, resolves 4 subdirs under root", () => {
|
|
7
|
+
const result = resolveOctocodeHome({
|
|
8
|
+
OCTOCODE_HOME: "/tmp/profile-a",
|
|
9
|
+
})
|
|
10
|
+
expect(result.mode).toBe("octocode_home")
|
|
11
|
+
expect(result.root).toBe("/tmp/profile-a")
|
|
12
|
+
expect(result.config).toBe(path.join("/tmp/profile-a", "config"))
|
|
13
|
+
expect(result.data).toBe(path.join("/tmp/profile-a", "data"))
|
|
14
|
+
expect(result.state).toBe(path.join("/tmp/profile-a", "state"))
|
|
15
|
+
expect(result.cache).toBe(path.join("/tmp/profile-a", "cache"))
|
|
16
|
+
})
|
|
17
|
+
|
|
18
|
+
test("without OCTOCODE_HOME, falls through to xdg mode", () => {
|
|
19
|
+
const result = resolveOctocodeHome({})
|
|
20
|
+
expect(result.mode).toBe("xdg")
|
|
21
|
+
expect(result.root).toBeUndefined()
|
|
22
|
+
// xdg paths end with "/octocode"
|
|
23
|
+
expect(result.config.endsWith(path.join("", "octocode"))).toBe(true)
|
|
24
|
+
expect(result.data.endsWith(path.join("", "octocode"))).toBe(true)
|
|
25
|
+
expect(result.state.endsWith(path.join("", "octocode"))).toBe(true)
|
|
26
|
+
expect(result.cache.endsWith(path.join("", "octocode"))).toBe(true)
|
|
27
|
+
})
|
|
28
|
+
|
|
29
|
+
test("empty OCTOCODE_HOME string is treated as unset (xdg mode)", () => {
|
|
30
|
+
const result = resolveOctocodeHome({ OCTOCODE_HOME: "" })
|
|
31
|
+
expect(result.mode).toBe("xdg")
|
|
32
|
+
})
|
|
33
|
+
|
|
34
|
+
test("relative OCTOCODE_HOME path throws with clear error", () => {
|
|
35
|
+
expect(() => resolveOctocodeHome({ OCTOCODE_HOME: "./foo" })).toThrow(
|
|
36
|
+
/OCTOCODE_HOME must be an absolute path/,
|
|
37
|
+
)
|
|
38
|
+
expect(() => resolveOctocodeHome({ OCTOCODE_HOME: "foo/bar" })).toThrow(
|
|
39
|
+
/OCTOCODE_HOME must be an absolute path/,
|
|
40
|
+
)
|
|
41
|
+
})
|
|
42
|
+
|
|
43
|
+
test("tilde-prefixed OCTOCODE_HOME throws (not treated as absolute)", () => {
|
|
44
|
+
expect(() => resolveOctocodeHome({ OCTOCODE_HOME: "~/profiles/a" })).toThrow(
|
|
45
|
+
/OCTOCODE_HOME must be an absolute path/,
|
|
46
|
+
)
|
|
47
|
+
})
|
|
48
|
+
|
|
49
|
+
test("error message includes the offending value", () => {
|
|
50
|
+
expect(() => resolveOctocodeHome({ OCTOCODE_HOME: "./relative" })).toThrow(
|
|
51
|
+
/\.\/relative/,
|
|
52
|
+
)
|
|
53
|
+
})
|
|
54
|
+
})
|
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
import { test, type TestOptions } from "bun:test"
|
|
2
|
+
import { Cause, Effect, Exit, Layer } from "effect"
|
|
3
|
+
import type * as Scope from "effect/Scope"
|
|
4
|
+
import * as TestClock from "effect/testing/TestClock"
|
|
5
|
+
import * as TestConsole from "effect/testing/TestConsole"
|
|
6
|
+
|
|
7
|
+
type Body<A, E, R> = Effect.Effect<A, E, R> | (() => Effect.Effect<A, E, R>)
|
|
8
|
+
|
|
9
|
+
const body = <A, E, R>(value: Body<A, E, R>) => Effect.suspend(() => (typeof value === "function" ? value() : value))
|
|
10
|
+
|
|
11
|
+
const run = <A, E, R, E2>(value: Body<A, E, R | Scope.Scope>, layer: Layer.Layer<R, E2>) =>
|
|
12
|
+
Effect.gen(function* () {
|
|
13
|
+
const exit = yield* body(value).pipe(Effect.scoped, Effect.provide(layer), Effect.exit)
|
|
14
|
+
if (Exit.isFailure(exit)) {
|
|
15
|
+
for (const err of Cause.prettyErrors(exit.cause)) {
|
|
16
|
+
yield* Effect.logError(err)
|
|
17
|
+
}
|
|
18
|
+
}
|
|
19
|
+
return yield* exit
|
|
20
|
+
}).pipe(Effect.runPromise)
|
|
21
|
+
|
|
22
|
+
const make = <R, E>(testLayer: Layer.Layer<R, E>, liveLayer: Layer.Layer<R, E>) => {
|
|
23
|
+
const effect = <A, E2>(name: string, value: Body<A, E2, R | Scope.Scope>, opts?: number | TestOptions) =>
|
|
24
|
+
test(name, () => run(value, testLayer), opts)
|
|
25
|
+
|
|
26
|
+
effect.only = <A, E2>(name: string, value: Body<A, E2, R | Scope.Scope>, opts?: number | TestOptions) =>
|
|
27
|
+
test.only(name, () => run(value, testLayer), opts)
|
|
28
|
+
|
|
29
|
+
effect.skip = <A, E2>(name: string, value: Body<A, E2, R | Scope.Scope>, opts?: number | TestOptions) =>
|
|
30
|
+
test.skip(name, () => run(value, testLayer), opts)
|
|
31
|
+
|
|
32
|
+
const live = <A, E2>(name: string, value: Body<A, E2, R | Scope.Scope>, opts?: number | TestOptions) =>
|
|
33
|
+
test(name, () => run(value, liveLayer), opts)
|
|
34
|
+
|
|
35
|
+
live.only = <A, E2>(name: string, value: Body<A, E2, R | Scope.Scope>, opts?: number | TestOptions) =>
|
|
36
|
+
test.only(name, () => run(value, liveLayer), opts)
|
|
37
|
+
|
|
38
|
+
live.skip = <A, E2>(name: string, value: Body<A, E2, R | Scope.Scope>, opts?: number | TestOptions) =>
|
|
39
|
+
test.skip(name, () => run(value, liveLayer), opts)
|
|
40
|
+
|
|
41
|
+
return { effect, live }
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
// Test environment with TestClock and TestConsole
|
|
45
|
+
const testEnv = Layer.mergeAll(TestConsole.layer, TestClock.layer())
|
|
46
|
+
|
|
47
|
+
// Live environment - uses real clock, but keeps TestConsole for output capture
|
|
48
|
+
const liveEnv = TestConsole.layer
|
|
49
|
+
|
|
50
|
+
export const it = make(testEnv, liveEnv)
|
|
51
|
+
|
|
52
|
+
export const testEffect = <R, E>(layer: Layer.Layer<R, E>) =>
|
|
53
|
+
make(Layer.provideMerge(layer, testEnv), Layer.provideMerge(layer, liveEnv))
|