@naxodev/apnea 0.2.0 → 0.2.2
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/README.md +18 -1
- package/SECURITY.md +36 -0
- package/briefs/orchestrator.md +4 -3
- package/dist/cli.js +8375 -15093
- package/docs/protocol/artifacts.md +18 -2
- package/docs/protocol/config.md +15 -3
- package/docs/protocol/manual-gate.md +8 -8
- package/docs/protocol/overview.md +17 -4
- package/extension/adapters/commit.ts +5 -1
- package/extension/adapters/dispatch.ts +9 -1
- package/extension/adapters/setup.ts +15 -1
- package/extension/adapters/start.ts +5 -1
- package/extension/adapters/status.ts +17 -2
- package/extension/adapters/wait.ts +6 -1
- package/extension/api.ts +7 -1
- package/extension/cli/main.ts +67 -7
- package/extension/cli/parse.ts +172 -5
- package/extension/domain/paths.ts +2 -11
- package/extension/domain/timeouts.ts +4 -0
- package/extension/domain/types.ts +65 -3
- package/extension/errors.ts +51 -16
- package/extension/operation-hooks.ts +6 -0
- package/extension/registry.ts +29 -15
- package/extension/run-tool.ts +19 -2
- package/extension/schema/config.ts +58 -16
- package/extension/schema/frontmatter.ts +57 -0
- package/extension/schema/state.ts +210 -13
- package/extension/services/app-live.ts +2 -1
- package/extension/services/config.ts +6 -4
- package/extension/services/file-system.ts +346 -75
- package/extension/services/herdr.ts +466 -260
- package/extension/services/operation-lock.ts +452 -0
- package/extension/services/process.ts +477 -0
- package/extension/services/run-store.ts +38 -16
- package/extension/services/vcs.ts +1258 -328
- package/extension/workflows/commit.ts +214 -13
- package/extension/workflows/dispatch.ts +305 -67
- package/extension/workflows/setup.ts +59 -32
- package/extension/workflows/start.ts +6 -4
- package/extension/workflows/status.ts +2 -2
- package/extension/workflows/wait.ts +62 -77
- package/package.json +2 -2
- package/schemas/config.schema.json +5 -1
- package/schemas/state.schema.json +165 -11
|
@@ -0,0 +1,452 @@
|
|
|
1
|
+
import * as crypto from "node:crypto"
|
|
2
|
+
import * as fs from "node:fs"
|
|
3
|
+
import * as os from "node:os"
|
|
4
|
+
import * as path from "node:path"
|
|
5
|
+
import { Effect } from "effect"
|
|
6
|
+
import { ConfigError, OperationLocked } from "../errors.ts"
|
|
7
|
+
|
|
8
|
+
type Owner = {
|
|
9
|
+
readonly pid: number
|
|
10
|
+
readonly token: string
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
const OWNER_FILE = "owner.json"
|
|
14
|
+
const OWNER_LIMIT = 4 * 1024
|
|
15
|
+
|
|
16
|
+
function lockDirectory(): string {
|
|
17
|
+
const identity =
|
|
18
|
+
typeof process.getuid === "function" ? process.getuid() : "user"
|
|
19
|
+
return path.join(os.tmpdir(), `apnea-${identity}`, "operation-locks")
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
function canonicalRepository(root: string): string {
|
|
23
|
+
return fs.realpathSync(root)
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
export function repositoryLockPath(root: string): string {
|
|
27
|
+
const canonical = canonicalRepository(root)
|
|
28
|
+
const key = crypto
|
|
29
|
+
.createHash("sha256")
|
|
30
|
+
.update(`repository\0${canonical}`)
|
|
31
|
+
.digest("hex")
|
|
32
|
+
return path.join(lockDirectory(), `repository-${key}.lock`)
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
export function globalSetupLockPath(accountHome: string): string {
|
|
36
|
+
const canonical = fs.realpathSync(accountHome)
|
|
37
|
+
const key = crypto
|
|
38
|
+
.createHash("sha256")
|
|
39
|
+
.update(`global-setup\0${canonical}`)
|
|
40
|
+
.digest("hex")
|
|
41
|
+
return path.join(lockDirectory(), `global-setup-${key}.lock`)
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
function ensureLockDirectory(directory: string): void {
|
|
45
|
+
for (const component of [path.dirname(directory), directory]) {
|
|
46
|
+
try {
|
|
47
|
+
fs.mkdirSync(component, { mode: 0o700 })
|
|
48
|
+
} catch (error) {
|
|
49
|
+
if ((error as NodeJS.ErrnoException).code !== "EEXIST") throw error
|
|
50
|
+
}
|
|
51
|
+
const stat = fs.lstatSync(component)
|
|
52
|
+
if (stat.isSymbolicLink() || !stat.isDirectory()) {
|
|
53
|
+
throw new ConfigError({
|
|
54
|
+
message: `unsafe Apnea lock directory: ${component}`,
|
|
55
|
+
path: component,
|
|
56
|
+
})
|
|
57
|
+
}
|
|
58
|
+
if (typeof process.getuid === "function" && stat.uid !== process.getuid()) {
|
|
59
|
+
throw new ConfigError({
|
|
60
|
+
message: `Apnea lock directory is not owned by the current user: ${component}`,
|
|
61
|
+
path: component,
|
|
62
|
+
})
|
|
63
|
+
}
|
|
64
|
+
fs.chmodSync(component, 0o700)
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
function isCurrentUser(stat: fs.Stats): boolean {
|
|
69
|
+
return typeof process.getuid !== "function" || stat.uid === process.getuid()
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
function hasPrivateMode(stat: fs.Stats): boolean {
|
|
73
|
+
return process.platform === "win32" || (stat.mode & 0o077) === 0
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
function readOwner(lock: string): Owner | null {
|
|
77
|
+
let descriptor: number | undefined
|
|
78
|
+
try {
|
|
79
|
+
const lockStat = fs.lstatSync(lock)
|
|
80
|
+
if (
|
|
81
|
+
lockStat.isSymbolicLink() ||
|
|
82
|
+
!lockStat.isDirectory() ||
|
|
83
|
+
!isCurrentUser(lockStat) ||
|
|
84
|
+
!hasPrivateMode(lockStat)
|
|
85
|
+
) {
|
|
86
|
+
return null
|
|
87
|
+
}
|
|
88
|
+
const ownerPath = path.join(lock, OWNER_FILE)
|
|
89
|
+
if (fs.lstatSync(ownerPath).isSymbolicLink()) return null
|
|
90
|
+
descriptor = fs.openSync(
|
|
91
|
+
ownerPath,
|
|
92
|
+
process.platform === "win32"
|
|
93
|
+
? "r"
|
|
94
|
+
: fs.constants.O_RDONLY |
|
|
95
|
+
fs.constants.O_NOFOLLOW |
|
|
96
|
+
fs.constants.O_NONBLOCK,
|
|
97
|
+
)
|
|
98
|
+
const ownerStat = fs.fstatSync(descriptor)
|
|
99
|
+
if (
|
|
100
|
+
!ownerStat.isFile() ||
|
|
101
|
+
!isCurrentUser(ownerStat) ||
|
|
102
|
+
!hasPrivateMode(ownerStat) ||
|
|
103
|
+
ownerStat.size <= 0 ||
|
|
104
|
+
ownerStat.size > OWNER_LIMIT
|
|
105
|
+
) {
|
|
106
|
+
return null
|
|
107
|
+
}
|
|
108
|
+
const bytes = Buffer.alloc(ownerStat.size)
|
|
109
|
+
let offset = 0
|
|
110
|
+
while (offset < bytes.length) {
|
|
111
|
+
const read = fs.readSync(
|
|
112
|
+
descriptor,
|
|
113
|
+
bytes,
|
|
114
|
+
offset,
|
|
115
|
+
bytes.length - offset,
|
|
116
|
+
offset,
|
|
117
|
+
)
|
|
118
|
+
if (read === 0) return null
|
|
119
|
+
offset += read
|
|
120
|
+
}
|
|
121
|
+
const value = JSON.parse(bytes.toString("utf8")) as unknown
|
|
122
|
+
if (
|
|
123
|
+
typeof value === "object" &&
|
|
124
|
+
value !== null &&
|
|
125
|
+
"pid" in value &&
|
|
126
|
+
typeof value.pid === "number" &&
|
|
127
|
+
Number.isInteger(value.pid) &&
|
|
128
|
+
value.pid > 0 &&
|
|
129
|
+
"token" in value &&
|
|
130
|
+
typeof value.token === "string" &&
|
|
131
|
+
value.token.length > 0
|
|
132
|
+
) {
|
|
133
|
+
return { pid: value.pid, token: value.token }
|
|
134
|
+
}
|
|
135
|
+
} catch {
|
|
136
|
+
// Malformed ownership is never safe to remove automatically.
|
|
137
|
+
} finally {
|
|
138
|
+
if (descriptor !== undefined) fs.closeSync(descriptor)
|
|
139
|
+
}
|
|
140
|
+
return null
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
function processIsAlive(pid: number): boolean {
|
|
144
|
+
try {
|
|
145
|
+
process.kill(pid, 0)
|
|
146
|
+
return true
|
|
147
|
+
} catch (error) {
|
|
148
|
+
return (error as NodeJS.ErrnoException).code !== "ESRCH"
|
|
149
|
+
}
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
function pathExistsNoFollow(target: string): boolean {
|
|
153
|
+
try {
|
|
154
|
+
fs.lstatSync(target)
|
|
155
|
+
return true
|
|
156
|
+
} catch (error) {
|
|
157
|
+
if ((error as NodeJS.ErrnoException).code === "ENOENT") return false
|
|
158
|
+
throw error
|
|
159
|
+
}
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
function fsyncDirectory(directory: string): void {
|
|
163
|
+
const descriptor = fs.openSync(directory, fs.constants.O_RDONLY)
|
|
164
|
+
try {
|
|
165
|
+
try {
|
|
166
|
+
fs.fsyncSync(descriptor)
|
|
167
|
+
} catch (error) {
|
|
168
|
+
const code = (error as NodeJS.ErrnoException).code
|
|
169
|
+
const unsupported =
|
|
170
|
+
["EINVAL", "ENOTSUP", "EOPNOTSUPP"].includes(code ?? "") ||
|
|
171
|
+
(process.platform === "win32" &&
|
|
172
|
+
["EISDIR", "EPERM"].includes(code ?? ""))
|
|
173
|
+
if (!unsupported) throw error
|
|
174
|
+
}
|
|
175
|
+
} finally {
|
|
176
|
+
fs.closeSync(descriptor)
|
|
177
|
+
}
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
function writeCandidate(directory: string, owner: Owner): void {
|
|
181
|
+
fs.mkdirSync(directory, { mode: 0o700 })
|
|
182
|
+
const ownerPath = path.join(directory, OWNER_FILE)
|
|
183
|
+
const descriptor = fs.openSync(ownerPath, "wx", 0o600)
|
|
184
|
+
try {
|
|
185
|
+
fs.writeFileSync(descriptor, JSON.stringify(owner), "utf8")
|
|
186
|
+
fs.fsyncSync(descriptor)
|
|
187
|
+
} finally {
|
|
188
|
+
fs.closeSync(descriptor)
|
|
189
|
+
}
|
|
190
|
+
fsyncDirectory(directory)
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
// The caller either releases its own live owner or holds the reclamation guard.
|
|
194
|
+
// A live owner cannot be reclaimed. Stale removers must serialize the token
|
|
195
|
+
// check and rename; checking the token after rename cannot undo displacement.
|
|
196
|
+
function moveOwnedToTombstone(lock: string, token: string): string | null {
|
|
197
|
+
if (readOwner(lock)?.token !== token) return null
|
|
198
|
+
const tombstone = `${lock}.tombstone.${crypto.randomUUID()}`
|
|
199
|
+
try {
|
|
200
|
+
fs.renameSync(lock, tombstone)
|
|
201
|
+
} catch (error) {
|
|
202
|
+
if ((error as NodeJS.ErrnoException).code === "ENOENT") return null
|
|
203
|
+
throw error
|
|
204
|
+
}
|
|
205
|
+
if (readOwner(tombstone)?.token !== token) return null
|
|
206
|
+
return tombstone
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
function removeIfOwned(lock: string, token: string): void {
|
|
210
|
+
const tombstone = moveOwnedToTombstone(lock, token)
|
|
211
|
+
if (tombstone !== null) {
|
|
212
|
+
fs.rmSync(tombstone, { recursive: true, force: true })
|
|
213
|
+
}
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
function acquireLock(
|
|
217
|
+
lock: string,
|
|
218
|
+
resource: string,
|
|
219
|
+
reclaim?: { graceMs: number; now: () => number },
|
|
220
|
+
): { lock: string; owner: Owner } {
|
|
221
|
+
ensureLockDirectory(path.dirname(lock))
|
|
222
|
+
|
|
223
|
+
for (let attempt = 0; attempt < 3; attempt++) {
|
|
224
|
+
const owner = { pid: process.pid, token: crypto.randomUUID() }
|
|
225
|
+
const candidate = `${lock}.candidate.${owner.token}`
|
|
226
|
+
let contended = false
|
|
227
|
+
try {
|
|
228
|
+
writeCandidate(candidate, owner)
|
|
229
|
+
if (pathExistsNoFollow(lock)) {
|
|
230
|
+
contended = true
|
|
231
|
+
} else {
|
|
232
|
+
try {
|
|
233
|
+
fs.renameSync(candidate, lock)
|
|
234
|
+
} catch (error) {
|
|
235
|
+
if (
|
|
236
|
+
!["EEXIST", "ENOTEMPTY", "EPERM"].includes(
|
|
237
|
+
(error as NodeJS.ErrnoException).code ?? "",
|
|
238
|
+
)
|
|
239
|
+
) {
|
|
240
|
+
throw error
|
|
241
|
+
}
|
|
242
|
+
contended = true
|
|
243
|
+
}
|
|
244
|
+
}
|
|
245
|
+
} finally {
|
|
246
|
+
fs.rmSync(candidate, { recursive: true, force: true })
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
if (!contended) return { lock, owner }
|
|
250
|
+
|
|
251
|
+
if (!pathExistsNoFollow(lock)) continue
|
|
252
|
+
const existing = readOwner(lock)
|
|
253
|
+
if (existing === null) {
|
|
254
|
+
throw new OperationLocked({
|
|
255
|
+
message:
|
|
256
|
+
`Apnea lock metadata is malformed at ${lock}. ` +
|
|
257
|
+
`Automatic cleanup is disabled. Verify no Apnea process owns it, then Remove this lock directory manually: ${lock}`,
|
|
258
|
+
repository: resource,
|
|
259
|
+
lock_path: lock,
|
|
260
|
+
reason: "malformed",
|
|
261
|
+
pid: 0,
|
|
262
|
+
})
|
|
263
|
+
}
|
|
264
|
+
if (processIsAlive(existing.pid)) {
|
|
265
|
+
throw new OperationLocked({
|
|
266
|
+
message: `another Apnea operation holds ${lock} for ${resource} (pid ${existing.pid})`,
|
|
267
|
+
repository: resource,
|
|
268
|
+
lock_path: lock,
|
|
269
|
+
reason: "live",
|
|
270
|
+
pid: existing.pid,
|
|
271
|
+
})
|
|
272
|
+
}
|
|
273
|
+
if (
|
|
274
|
+
reclaim !== undefined &&
|
|
275
|
+
lockAgeMs(lock, reclaim.now()) >= reclaim.graceMs &&
|
|
276
|
+
removeStaleOwner(lock, existing.token, resource)
|
|
277
|
+
) {
|
|
278
|
+
// Dead owner past the freshness grace: a crashed holder. Reclaiming
|
|
279
|
+
// lets crash-recoverable operations (e.g. a durable commit
|
|
280
|
+
// transaction) resume instead of wedging behind manual cleanup.
|
|
281
|
+
continue
|
|
282
|
+
}
|
|
283
|
+
throw new OperationLocked({
|
|
284
|
+
message:
|
|
285
|
+
`Apnea lock owner pid ${existing.pid} is not live at ${lock}. ` +
|
|
286
|
+
`Automatic stale cleanup is disabled. Verify no Apnea process owns it, then Remove this lock directory manually: ${lock}`,
|
|
287
|
+
repository: resource,
|
|
288
|
+
lock_path: lock,
|
|
289
|
+
reason: "stale",
|
|
290
|
+
pid: existing.pid,
|
|
291
|
+
})
|
|
292
|
+
}
|
|
293
|
+
|
|
294
|
+
throw new OperationLocked({
|
|
295
|
+
message: `Apnea lock changed repeatedly at ${lock}; retry the operation`,
|
|
296
|
+
repository: resource,
|
|
297
|
+
lock_path: lock,
|
|
298
|
+
reason: "raced",
|
|
299
|
+
pid: 0,
|
|
300
|
+
})
|
|
301
|
+
}
|
|
302
|
+
|
|
303
|
+
/** Age of the lock directory in ms against the injected clock. */
|
|
304
|
+
function lockAgeMs(lock: string, nowMs: number): number {
|
|
305
|
+
try {
|
|
306
|
+
return Math.max(0, nowMs - fs.lstatSync(lock).mtimeMs)
|
|
307
|
+
} catch {
|
|
308
|
+
return 0
|
|
309
|
+
}
|
|
310
|
+
}
|
|
311
|
+
|
|
312
|
+
/**
|
|
313
|
+
* Only one stale remover may validate ownership and rename the canonical path.
|
|
314
|
+
* Publication needs no guard: the old nonempty directory excludes candidates
|
|
315
|
+
* until rename, and this remover never renames the canonical path again.
|
|
316
|
+
* A delayed remover must acquire the guard and recheck the token, so it cannot
|
|
317
|
+
* displace a replacement. Live-owner release cannot race a matching stale
|
|
318
|
+
* removal because processIsAlive refuses that owner.
|
|
319
|
+
*
|
|
320
|
+
* The guard is deliberately not reclaimable. A crash here requires manual
|
|
321
|
+
* removal of `${lock}.reclaim` after all Apnea processes using this lock stop.
|
|
322
|
+
* Recursively reclaiming a stale guard would reintroduce the same race.
|
|
323
|
+
*/
|
|
324
|
+
function removeStaleOwner(
|
|
325
|
+
lock: string,
|
|
326
|
+
token: string,
|
|
327
|
+
resource: string,
|
|
328
|
+
): boolean {
|
|
329
|
+
const guard = `${lock}.reclaim`
|
|
330
|
+
try {
|
|
331
|
+
fs.mkdirSync(guard, { mode: 0o700 })
|
|
332
|
+
} catch (error) {
|
|
333
|
+
if ((error as NodeJS.ErrnoException).code !== "EEXIST") throw error
|
|
334
|
+
throw new OperationLocked({
|
|
335
|
+
message:
|
|
336
|
+
`Apnea stale-lock reclamation is guarded at ${guard}. Retry after the other operation completes. ` +
|
|
337
|
+
`If the guard persists, stop all Apnea processes using ${lock}, then remove this guard directory manually: ${guard}`,
|
|
338
|
+
repository: resource,
|
|
339
|
+
lock_path: lock,
|
|
340
|
+
reason: "stale",
|
|
341
|
+
pid: 0,
|
|
342
|
+
})
|
|
343
|
+
}
|
|
344
|
+
try {
|
|
345
|
+
const tombstone = moveOwnedToTombstone(lock, token)
|
|
346
|
+
if (tombstone === null) return false
|
|
347
|
+
fs.rmSync(tombstone, { recursive: true, force: true })
|
|
348
|
+
return true
|
|
349
|
+
} finally {
|
|
350
|
+
fs.rmdirSync(guard)
|
|
351
|
+
}
|
|
352
|
+
}
|
|
353
|
+
|
|
354
|
+
function withLock<A, E, R>(
|
|
355
|
+
lock: string,
|
|
356
|
+
resource: string,
|
|
357
|
+
operation: Effect.Effect<A, E, R>,
|
|
358
|
+
waitForRetry?: Effect.Effect<void>,
|
|
359
|
+
reclaim?: { graceMs: number; now: () => number },
|
|
360
|
+
): Effect.Effect<A, E | OperationLocked | ConfigError, R> {
|
|
361
|
+
const acquireOnce = () =>
|
|
362
|
+
Effect.try({
|
|
363
|
+
try: () => acquireLock(lock, resource, reclaim),
|
|
364
|
+
catch: (error) =>
|
|
365
|
+
error instanceof OperationLocked || error instanceof ConfigError
|
|
366
|
+
? error
|
|
367
|
+
: new ConfigError({
|
|
368
|
+
message: `could not acquire Apnea operation lock: ${error instanceof Error ? error.message : String(error)}`,
|
|
369
|
+
path: resource,
|
|
370
|
+
}),
|
|
371
|
+
})
|
|
372
|
+
const acquireEffect = (): Effect.Effect<
|
|
373
|
+
{ lock: string; owner: Owner },
|
|
374
|
+
OperationLocked | ConfigError
|
|
375
|
+
> =>
|
|
376
|
+
acquireOnce().pipe(
|
|
377
|
+
Effect.catch((error) =>
|
|
378
|
+
error instanceof OperationLocked &&
|
|
379
|
+
error.reason === "live" &&
|
|
380
|
+
waitForRetry
|
|
381
|
+
? waitForRetry.pipe(Effect.andThen(acquireEffect()))
|
|
382
|
+
: Effect.fail(error),
|
|
383
|
+
),
|
|
384
|
+
)
|
|
385
|
+
return Effect.acquireUseRelease(
|
|
386
|
+
acquireEffect(),
|
|
387
|
+
() => operation,
|
|
388
|
+
({ lock: ownedLock, owner }) =>
|
|
389
|
+
Effect.sync(() => removeIfOwned(ownedLock, owner.token)),
|
|
390
|
+
)
|
|
391
|
+
}
|
|
392
|
+
|
|
393
|
+
/** How long a dead owner's lock must sit untouched before reclaim is safe. */
|
|
394
|
+
export const REPOSITORY_LOCK_RECLAIM_GRACE_MS = 60_000
|
|
395
|
+
|
|
396
|
+
export type RepositoryLockOptions = {
|
|
397
|
+
/**
|
|
398
|
+
* Grace period (ms) a stale lock must age past before a dead owner is
|
|
399
|
+
* reclaimed. Mitigates PID reuse: a recycled pid looks alive anyway, and
|
|
400
|
+
* an old lock directory means no live process refreshed it.
|
|
401
|
+
*/
|
|
402
|
+
readonly staleGraceMs?: number
|
|
403
|
+
/** Clock seam for tests; defaults to Date.now. */
|
|
404
|
+
readonly now?: () => number
|
|
405
|
+
}
|
|
406
|
+
|
|
407
|
+
export function withRepositoryLock<A, E, R>(
|
|
408
|
+
root: string,
|
|
409
|
+
operation: Effect.Effect<A, E, R>,
|
|
410
|
+
options: RepositoryLockOptions = {},
|
|
411
|
+
): Effect.Effect<A, E | OperationLocked | ConfigError, R> {
|
|
412
|
+
const repository = canonicalRepository(root)
|
|
413
|
+
return withLock(
|
|
414
|
+
repositoryLockPath(repository),
|
|
415
|
+
repository,
|
|
416
|
+
operation,
|
|
417
|
+
undefined,
|
|
418
|
+
{
|
|
419
|
+
graceMs: options.staleGraceMs ?? REPOSITORY_LOCK_RECLAIM_GRACE_MS,
|
|
420
|
+
now: options.now ?? (() => Date.now()),
|
|
421
|
+
},
|
|
422
|
+
)
|
|
423
|
+
}
|
|
424
|
+
|
|
425
|
+
export function withGlobalSetupLock<A, E, R>(
|
|
426
|
+
accountHome: string,
|
|
427
|
+
operation: Effect.Effect<A, E, R>,
|
|
428
|
+
waitForRetry: Effect.Effect<void> = Effect.sleep(25),
|
|
429
|
+
): Effect.Effect<A, E | OperationLocked | ConfigError, R> {
|
|
430
|
+
const home = fs.realpathSync(accountHome)
|
|
431
|
+
return withLock(
|
|
432
|
+
globalSetupLockPath(home),
|
|
433
|
+
`global setup at ${home}`,
|
|
434
|
+
operation,
|
|
435
|
+
waitForRetry,
|
|
436
|
+
)
|
|
437
|
+
}
|
|
438
|
+
|
|
439
|
+
/** Global setup lock is always outermost; repository lock is optional and inner. */
|
|
440
|
+
export function withSetupLocks<A, E, R>(
|
|
441
|
+
accountHome: string,
|
|
442
|
+
root: string,
|
|
443
|
+
lockRepository: boolean,
|
|
444
|
+
operation: Effect.Effect<A, E, R>,
|
|
445
|
+
waitForRetry?: Effect.Effect<void>,
|
|
446
|
+
): Effect.Effect<A, E | OperationLocked | ConfigError, R> {
|
|
447
|
+
return withGlobalSetupLock(
|
|
448
|
+
accountHome,
|
|
449
|
+
lockRepository ? withRepositoryLock(root, operation) : operation,
|
|
450
|
+
waitForRetry,
|
|
451
|
+
)
|
|
452
|
+
}
|