@ddtcorex/dsh-maestro-memory 1.0.1
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/CHANGELOG.md +96 -0
- package/LICENSE +21 -0
- package/README.md +448 -0
- package/cordis.patch.yml +13 -0
- package/lib/client.js +756 -0
- package/lib/index.d.ts +10 -0
- package/lib/index.d.ts.map +1 -0
- package/lib/index.js +627 -0
- package/lib/index.js.map +1 -0
- package/lib/memory/batch.d.ts +44 -0
- package/lib/memory/batch.d.ts.map +1 -0
- package/lib/memory/batch.js +75 -0
- package/lib/memory/batch.js.map +1 -0
- package/lib/memory/feedback.d.ts +18 -0
- package/lib/memory/feedback.d.ts.map +1 -0
- package/lib/memory/feedback.js +29 -0
- package/lib/memory/feedback.js.map +1 -0
- package/lib/memory/store.d.ts +154 -0
- package/lib/memory/store.d.ts.map +1 -0
- package/lib/memory/store.js +606 -0
- package/lib/memory/store.js.map +1 -0
- package/lib/migration/cli.d.ts +18 -0
- package/lib/migration/cli.d.ts.map +1 -0
- package/lib/migration/cli.js +143 -0
- package/lib/migration/cli.js.map +1 -0
- package/lib/migration/fixture.d.ts +45 -0
- package/lib/migration/fixture.d.ts.map +1 -0
- package/lib/migration/fixture.js +177 -0
- package/lib/migration/fixture.js.map +1 -0
- package/lib/migration/service.d.ts +75 -0
- package/lib/migration/service.d.ts.map +1 -0
- package/lib/migration/service.js +713 -0
- package/lib/migration/service.js.map +1 -0
- package/lib/prompt/snapshot.d.ts +26 -0
- package/lib/prompt/snapshot.d.ts.map +1 -0
- package/lib/prompt/snapshot.js +74 -0
- package/lib/prompt/snapshot.js.map +1 -0
- package/lib/review/queue.d.ts +72 -0
- package/lib/review/queue.d.ts.map +1 -0
- package/lib/review/queue.js +306 -0
- package/lib/review/queue.js.map +1 -0
- package/lib/skills-browser.d.ts +67 -0
- package/lib/skills-browser.d.ts.map +1 -0
- package/lib/skills-browser.js +185 -0
- package/lib/skills-browser.js.map +1 -0
- package/lib/storage/atomic-store.d.ts +211 -0
- package/lib/storage/atomic-store.d.ts.map +1 -0
- package/lib/storage/atomic-store.js +582 -0
- package/lib/storage/atomic-store.js.map +1 -0
- package/lib/storage/layout.d.ts +81 -0
- package/lib/storage/layout.d.ts.map +1 -0
- package/lib/storage/layout.js +162 -0
- package/lib/storage/layout.js.map +1 -0
- package/lib/storage/legacy-format.d.ts +104 -0
- package/lib/storage/legacy-format.d.ts.map +1 -0
- package/lib/storage/legacy-format.js +262 -0
- package/lib/storage/legacy-format.js.map +1 -0
- package/lib/sync/config.d.ts +22 -0
- package/lib/sync/config.d.ts.map +1 -0
- package/lib/sync/config.js +57 -0
- package/lib/sync/config.js.map +1 -0
- package/lib/sync/git.d.ts +97 -0
- package/lib/sync/git.d.ts.map +1 -0
- package/lib/sync/git.js +282 -0
- package/lib/sync/git.js.map +1 -0
- package/lib/sync/index.d.ts +6 -0
- package/lib/sync/index.d.ts.map +1 -0
- package/lib/sync/index.js +6 -0
- package/lib/sync/index.js.map +1 -0
- package/lib/sync/layout.d.ts +15 -0
- package/lib/sync/layout.d.ts.map +1 -0
- package/lib/sync/layout.js +33 -0
- package/lib/sync/layout.js.map +1 -0
- package/lib/sync/merge.d.ts +42 -0
- package/lib/sync/merge.d.ts.map +1 -0
- package/lib/sync/merge.js +159 -0
- package/lib/sync/merge.js.map +1 -0
- package/lib/sync/service.d.ts +70 -0
- package/lib/sync/service.d.ts.map +1 -0
- package/lib/sync/service.js +508 -0
- package/lib/sync/service.js.map +1 -0
- package/lib/todo/store.d.ts +142 -0
- package/lib/todo/store.d.ts.map +1 -0
- package/lib/todo/store.js +452 -0
- package/lib/todo/store.js.map +1 -0
- package/lib/types/client/index.d.ts +7 -0
- package/lib/types/client/index.d.ts.map +1 -0
- package/package.json +63 -0
- package/src/client/index.tsx +994 -0
- package/src/host/index.ts +591 -0
- package/src/host/memory/batch.ts +107 -0
- package/src/host/memory/feedback.ts +36 -0
- package/src/host/memory/store.ts +650 -0
- package/src/host/migration/cli.ts +139 -0
- package/src/host/migration/fixture.ts +218 -0
- package/src/host/migration/service.ts +786 -0
- package/src/host/prompt/snapshot.ts +95 -0
- package/src/host/review/queue.ts +325 -0
- package/src/host/skills-browser.ts +191 -0
- package/src/host/storage/atomic-store.ts +605 -0
- package/src/host/storage/layout.ts +191 -0
- package/src/host/storage/legacy-format.ts +308 -0
- package/src/host/sync/config.ts +67 -0
- package/src/host/sync/git.ts +280 -0
- package/src/host/sync/index.ts +5 -0
- package/src/host/sync/layout.ts +39 -0
- package/src/host/sync/merge.ts +188 -0
- package/src/host/sync/service.ts +494 -0
- package/src/host/todo/store.ts +480 -0
|
@@ -0,0 +1,605 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Atomic file store for Maestro memory — per-directory lock, temp-write/rename,
|
|
3
|
+
* reread validation, backups, duplicate detection.
|
|
4
|
+
*
|
|
5
|
+
* Each mutation:
|
|
6
|
+
* 1. Acquires a same-directory lock file (.maestro.lock) with stale detection
|
|
7
|
+
* (mtime timeout + pid liveness via kill(pid,0)).
|
|
8
|
+
* 2. Validates the on-disk content round-trips through parseEntries/serializeEntries.
|
|
9
|
+
* Non-canonical files are backed up to `<file>.bak.<timestamp>` before the
|
|
10
|
+
* operation is refused (drift guard).
|
|
11
|
+
* 3. Checks for exact duplicates (ID-stripped) so re-adds are no-ops.
|
|
12
|
+
* 4. Writes to a same-directory temp file (`.<uuid>.tmp`, mode 0o600, flag wx),
|
|
13
|
+
* fsyncs the temp, renames atomically over the target, fsyncs the parent
|
|
14
|
+
* directory on POSIX, then rereads and validates the result.
|
|
15
|
+
* 5. Cleans up the temp file on any failure; the previous file is never
|
|
16
|
+
* clobbered on error.
|
|
17
|
+
*
|
|
18
|
+
* Uses only `node:fs` / `node:fs/promises` atomic primitives: open(wx),
|
|
19
|
+
* writeFile, fsync, rename, unlink. No third-party dependencies.
|
|
20
|
+
*
|
|
21
|
+
* @module storage/atomic-store
|
|
22
|
+
*/
|
|
23
|
+
|
|
24
|
+
import { createHash, randomUUID } from 'node:crypto'
|
|
25
|
+
import {
|
|
26
|
+
closeSync,
|
|
27
|
+
existsSync,
|
|
28
|
+
fsyncSync,
|
|
29
|
+
mkdirSync,
|
|
30
|
+
openSync,
|
|
31
|
+
readFileSync,
|
|
32
|
+
renameSync,
|
|
33
|
+
rmSync,
|
|
34
|
+
statSync,
|
|
35
|
+
writeFileSync,
|
|
36
|
+
} from 'node:fs'
|
|
37
|
+
import {
|
|
38
|
+
mkdir,
|
|
39
|
+
open,
|
|
40
|
+
readFile,
|
|
41
|
+
rename,
|
|
42
|
+
rm,
|
|
43
|
+
stat,
|
|
44
|
+
writeFile,
|
|
45
|
+
} from 'node:fs/promises'
|
|
46
|
+
import { dirname, join } from 'node:path'
|
|
47
|
+
|
|
48
|
+
// ---------------------------------------------------------------------------
|
|
49
|
+
// Delimiter + parse/serialize (pure, no I/O)
|
|
50
|
+
// ---------------------------------------------------------------------------
|
|
51
|
+
|
|
52
|
+
/** Entry delimiter, byte-compatible with Hermes MEMORY.md / USER.md. */
|
|
53
|
+
export const ENTRY_DELIMITER = '\n§\n'
|
|
54
|
+
|
|
55
|
+
/**
|
|
56
|
+
* Split raw file text into trimmed, non-empty entries.
|
|
57
|
+
* @param text - raw file content
|
|
58
|
+
* @returns entries
|
|
59
|
+
*/
|
|
60
|
+
export function parseEntries(text: string): string[] {
|
|
61
|
+
return text
|
|
62
|
+
.split(ENTRY_DELIMITER)
|
|
63
|
+
.map((e) => e.trim())
|
|
64
|
+
.filter((e) => e.length > 0)
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
/**
|
|
68
|
+
* Serialize entries into canonical file text.
|
|
69
|
+
* @param entries - entries to serialize
|
|
70
|
+
* @returns canonical file content (entries joined by delimiter + trailing newline)
|
|
71
|
+
*/
|
|
72
|
+
export function serializeEntries(entries: string[]): string {
|
|
73
|
+
return entries.length === 0 ? '' : entries.join(ENTRY_DELIMITER) + '\n'
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
/**
|
|
77
|
+
* Whether raw text is the canonical serialization of its own entries.
|
|
78
|
+
* Blank / whitespace-only text counts as canonical (empty store).
|
|
79
|
+
* @param text - raw file content
|
|
80
|
+
* @returns true when round-tripping preserves the text
|
|
81
|
+
*/
|
|
82
|
+
export function isCanonical(text: string): boolean {
|
|
83
|
+
return text.trim() === '' || serializeEntries(parseEntries(text)) === text
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
// ---------------------------------------------------------------------------
|
|
87
|
+
// Duplicate detection (ID-aware)
|
|
88
|
+
// ---------------------------------------------------------------------------
|
|
89
|
+
|
|
90
|
+
/** Regex for cross-device entry ID prefix (space after colon allowed, case-insensitive). */
|
|
91
|
+
const ENTRY_ID_RE = /^\[id:\s*[0-9a-f]{8}\]\s*/i
|
|
92
|
+
|
|
93
|
+
/**
|
|
94
|
+
* Strip the `[id:xxxxxxxx]` prefix from an entry, if present.
|
|
95
|
+
* @param entry - full entry text
|
|
96
|
+
* @returns text without the ID prefix
|
|
97
|
+
*/
|
|
98
|
+
export function stripEntryId(entry: string): string {
|
|
99
|
+
return String(entry).replace(ENTRY_ID_RE, '')
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
/**
|
|
103
|
+
* Check whether `candidate` already exists in `entries`, comparing after
|
|
104
|
+
* stripping IDs so a re-add with a new random ID is still detected as a
|
|
105
|
+
* duplicate (IDs are random per write, direct includes would never hit).
|
|
106
|
+
* @param entries - existing entries
|
|
107
|
+
* @param candidate - new entry text
|
|
108
|
+
* @returns true when a duplicate exists
|
|
109
|
+
*/
|
|
110
|
+
export function isDuplicate(entries: string[], candidate: string): boolean {
|
|
111
|
+
const needle = stripEntryId(candidate)
|
|
112
|
+
return entries.some((e) => stripEntryId(e) === needle)
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
/**
|
|
116
|
+
* Find the exact index of `exact` in `entries`, ID-immune (strip-and-compare).
|
|
117
|
+
* Returns -1 when not found or when multiple hits make the match ambiguous.
|
|
118
|
+
* @param entries - existing entries
|
|
119
|
+
* @param exact - full entry text to find
|
|
120
|
+
* @returns index or -1
|
|
121
|
+
*/
|
|
122
|
+
export function findExactIndex(entries: string[], exact: string): number {
|
|
123
|
+
const target = stripEntryId(exact)
|
|
124
|
+
let found = -1
|
|
125
|
+
for (let i = 0; i < entries.length; i++) {
|
|
126
|
+
if (stripEntryId(entries[i]) === target) {
|
|
127
|
+
if (found !== -1) return -1 // ambiguous
|
|
128
|
+
found = i
|
|
129
|
+
}
|
|
130
|
+
}
|
|
131
|
+
return found
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
// ---------------------------------------------------------------------------
|
|
135
|
+
// Lock (per-directory)
|
|
136
|
+
// ---------------------------------------------------------------------------
|
|
137
|
+
|
|
138
|
+
/** A lock file older than this is considered abandoned (stale). */
|
|
139
|
+
export const STALE_LOCK_MS = 10_000
|
|
140
|
+
/** How long to keep waiting for the lock before failing. */
|
|
141
|
+
export const LOCK_TIMEOUT_MS = 5_000
|
|
142
|
+
/** Spin interval while waiting for the lock. */
|
|
143
|
+
export const LOCK_RETRY_MS = 25
|
|
144
|
+
|
|
145
|
+
/** Lock file name inside each directory. */
|
|
146
|
+
export const LOCK_FILE = '.maestro.lock'
|
|
147
|
+
|
|
148
|
+
/** Directories whose lock this process currently holds (reentrancy guard). */
|
|
149
|
+
const heldLocks = new Set<string>()
|
|
150
|
+
|
|
151
|
+
/** Lock file content: pid + timestamp for stale detection. */
|
|
152
|
+
function lockJson(): string {
|
|
153
|
+
return JSON.stringify({ pid: process.pid, at: Date.now() })
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
/**
|
|
157
|
+
* Determine whether a lock is stale (exported for tests): mtime timeout or
|
|
158
|
+
* the pid inside the lock file is no longer alive (killed / power loss ->
|
|
159
|
+
* stale immediately without waiting for timeout).
|
|
160
|
+
* @param lockPath - lock file path
|
|
161
|
+
* @returns true when stale
|
|
162
|
+
*/
|
|
163
|
+
export function isStaleLock(lockPath: string): boolean {
|
|
164
|
+
try {
|
|
165
|
+
const info = statSync(lockPath)
|
|
166
|
+
try {
|
|
167
|
+
const owner = JSON.parse(readFileSync(lockPath, 'utf8'))
|
|
168
|
+
if (typeof owner.pid === 'number') {
|
|
169
|
+
try {
|
|
170
|
+
process.kill(owner.pid, 0)
|
|
171
|
+
return false // owner alive -> valid
|
|
172
|
+
} catch {
|
|
173
|
+
return true // owner dead
|
|
174
|
+
}
|
|
175
|
+
}
|
|
176
|
+
} catch {
|
|
177
|
+
// old-format / unparseable -> fall back to mtime
|
|
178
|
+
}
|
|
179
|
+
return Date.now() - info.mtimeMs > STALE_LOCK_MS
|
|
180
|
+
} catch {
|
|
181
|
+
return false
|
|
182
|
+
}
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
/** Async variant of stale check for the async lock path. */
|
|
186
|
+
export async function isStaleLockAsync(lockPath: string): Promise<boolean> {
|
|
187
|
+
try {
|
|
188
|
+
const info = await stat(lockPath)
|
|
189
|
+
try {
|
|
190
|
+
const owner = JSON.parse(await readFile(lockPath, 'utf8'))
|
|
191
|
+
if (typeof owner.pid === 'number') {
|
|
192
|
+
try {
|
|
193
|
+
process.kill(owner.pid, 0)
|
|
194
|
+
return false
|
|
195
|
+
} catch {
|
|
196
|
+
return true
|
|
197
|
+
}
|
|
198
|
+
}
|
|
199
|
+
} catch {
|
|
200
|
+
// fall back to mtime
|
|
201
|
+
}
|
|
202
|
+
return Date.now() - info.mtimeMs > STALE_LOCK_MS
|
|
203
|
+
} catch {
|
|
204
|
+
return false
|
|
205
|
+
}
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
function sleepSync(ms: number): void {
|
|
209
|
+
Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, ms)
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
function sleep(ms: number): Promise<void> {
|
|
213
|
+
return new Promise((resolve) => setTimeout(resolve, ms))
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
/**
|
|
217
|
+
* Acquire the directory lock synchronously, run `fn`, release.
|
|
218
|
+
* Reentrant within this process (outer section remains exclusive vs others).
|
|
219
|
+
* @param dir - directory whose lock to take
|
|
220
|
+
* @param fn - critical section
|
|
221
|
+
* @returns section result
|
|
222
|
+
*/
|
|
223
|
+
export function withLockSync<T>(dir: string, fn: () => T): T {
|
|
224
|
+
if (heldLocks.has(dir)) return fn()
|
|
225
|
+
const lockPath = join(dir, LOCK_FILE)
|
|
226
|
+
mkdirSync(dir, { recursive: true })
|
|
227
|
+
const deadline = Date.now() + LOCK_TIMEOUT_MS
|
|
228
|
+
for (;;) {
|
|
229
|
+
let acquired = false
|
|
230
|
+
try {
|
|
231
|
+
const fd = openSync(lockPath, 'wx')
|
|
232
|
+
try {
|
|
233
|
+
writeFileSync(lockPath, lockJson())
|
|
234
|
+
} finally {
|
|
235
|
+
closeSync(fd)
|
|
236
|
+
}
|
|
237
|
+
acquired = true
|
|
238
|
+
} catch (error: unknown) {
|
|
239
|
+
const code = (error as NodeJS.ErrnoException).code
|
|
240
|
+
if (code !== 'EEXIST') throw error
|
|
241
|
+
}
|
|
242
|
+
if (acquired) break
|
|
243
|
+
if (isStaleLock(lockPath)) rmSync(lockPath, { force: true })
|
|
244
|
+
if (Date.now() >= deadline) throw new Error('atomic-store: timed out waiting for directory lock')
|
|
245
|
+
sleepSync(LOCK_RETRY_MS)
|
|
246
|
+
}
|
|
247
|
+
heldLocks.add(dir)
|
|
248
|
+
try {
|
|
249
|
+
return fn()
|
|
250
|
+
} finally {
|
|
251
|
+
heldLocks.delete(dir)
|
|
252
|
+
rmSync(lockPath, { force: true })
|
|
253
|
+
}
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
/**
|
|
257
|
+
* Acquire the directory lock asynchronously, run `fn`, release.
|
|
258
|
+
* Reentrant within this process.
|
|
259
|
+
* @param dir - directory whose lock to take
|
|
260
|
+
* @param fn - critical section (may be async)
|
|
261
|
+
* @returns section result
|
|
262
|
+
*/
|
|
263
|
+
export async function withLock<T>(dir: string, fn: () => T | Promise<T>): Promise<T> {
|
|
264
|
+
if (heldLocks.has(dir)) return await fn()
|
|
265
|
+
const lockPath = join(dir, LOCK_FILE)
|
|
266
|
+
await mkdir(dir, { recursive: true })
|
|
267
|
+
const deadline = Date.now() + LOCK_TIMEOUT_MS
|
|
268
|
+
for (;;) {
|
|
269
|
+
let acquired = false
|
|
270
|
+
try {
|
|
271
|
+
const handle = await open(lockPath, 'wx', 0o600)
|
|
272
|
+
try {
|
|
273
|
+
await handle.writeFile(lockJson(), 'utf8')
|
|
274
|
+
} finally {
|
|
275
|
+
await handle.close()
|
|
276
|
+
}
|
|
277
|
+
acquired = true
|
|
278
|
+
} catch (error: unknown) {
|
|
279
|
+
const code = (error as NodeJS.ErrnoException).code
|
|
280
|
+
if (code !== 'EEXIST') throw error
|
|
281
|
+
}
|
|
282
|
+
if (acquired) break
|
|
283
|
+
if (await isStaleLockAsync(lockPath)) await rm(lockPath, { force: true })
|
|
284
|
+
if (Date.now() >= deadline) throw new Error('atomic-store: timed out waiting for directory lock')
|
|
285
|
+
await sleep(LOCK_RETRY_MS)
|
|
286
|
+
}
|
|
287
|
+
heldLocks.add(dir)
|
|
288
|
+
try {
|
|
289
|
+
return await fn()
|
|
290
|
+
} finally {
|
|
291
|
+
heldLocks.delete(dir)
|
|
292
|
+
await rm(lockPath, { force: true })
|
|
293
|
+
}
|
|
294
|
+
}
|
|
295
|
+
|
|
296
|
+
// ---------------------------------------------------------------------------
|
|
297
|
+
// Backups
|
|
298
|
+
// ---------------------------------------------------------------------------
|
|
299
|
+
|
|
300
|
+
/**
|
|
301
|
+
* Create a backup of `filePath` at `<file>.bak.<timestamp>`.
|
|
302
|
+
* Returns the backup path, or null when the source does not exist.
|
|
303
|
+
* @param filePath - file to back up
|
|
304
|
+
* @returns backup path or null
|
|
305
|
+
*/
|
|
306
|
+
export function createBackupSync(filePath: string): string | null {
|
|
307
|
+
if (!existsSync(filePath)) return null
|
|
308
|
+
const backupPath = `${filePath}.bak.${Date.now()}`
|
|
309
|
+
const data = readFileSync(filePath)
|
|
310
|
+
writeFileSync(backupPath, data)
|
|
311
|
+
return backupPath
|
|
312
|
+
}
|
|
313
|
+
|
|
314
|
+
/**
|
|
315
|
+
* Async backup.
|
|
316
|
+
* @param filePath - file to back up
|
|
317
|
+
* @returns backup path or null
|
|
318
|
+
*/
|
|
319
|
+
export async function createBackup(filePath: string): Promise<string | null> {
|
|
320
|
+
try {
|
|
321
|
+
const data = await readFile(filePath)
|
|
322
|
+
const backupPath = `${filePath}.bak.${Date.now()}`
|
|
323
|
+
// Create backup atomically; if it exists, bump timestamp
|
|
324
|
+
await writeFile(backupPath, data, { flag: 'wx', mode: 0o600 })
|
|
325
|
+
return backupPath
|
|
326
|
+
} catch (error: unknown) {
|
|
327
|
+
const code = (error as NodeJS.ErrnoException).code
|
|
328
|
+
if (code === 'ENOENT') return null
|
|
329
|
+
throw error
|
|
330
|
+
}
|
|
331
|
+
}
|
|
332
|
+
|
|
333
|
+
// ---------------------------------------------------------------------------
|
|
334
|
+
// Atomic write: temp-write / rename / reread validation
|
|
335
|
+
// ---------------------------------------------------------------------------
|
|
336
|
+
|
|
337
|
+
/** fsync a POSIX directory so a just-renamed entry is crash-durable. */
|
|
338
|
+
/* v8 ignore start -- Windows rejects O_RDONLY directory opens */
|
|
339
|
+
async function fsyncDirectory(dir: string): Promise<void> {
|
|
340
|
+
if (process.platform === 'win32') return
|
|
341
|
+
const handle = await open(dir, 'r')
|
|
342
|
+
try {
|
|
343
|
+
await handle.sync()
|
|
344
|
+
} finally {
|
|
345
|
+
await handle.close()
|
|
346
|
+
}
|
|
347
|
+
}
|
|
348
|
+
|
|
349
|
+
function fsyncDirectorySync(dir: string): void {
|
|
350
|
+
if (process.platform === 'win32') return
|
|
351
|
+
const fd = openSync(dir, 'r')
|
|
352
|
+
try {
|
|
353
|
+
fsyncSync(fd)
|
|
354
|
+
} finally {
|
|
355
|
+
closeSync(fd)
|
|
356
|
+
}
|
|
357
|
+
}
|
|
358
|
+
/* v8 ignore stop */
|
|
359
|
+
|
|
360
|
+
/**
|
|
361
|
+
* Durably replace `filePath` with `content` via same-directory temp file,
|
|
362
|
+
* fsync, atomic rename, directory fsync, and reread validation.
|
|
363
|
+
* @param filePath - absolute target file path
|
|
364
|
+
* @param content - full new file content
|
|
365
|
+
*/
|
|
366
|
+
export async function writeAtomic(filePath: string, content: string): Promise<void> {
|
|
367
|
+
const dir = dirname(filePath)
|
|
368
|
+
await mkdir(dir, { recursive: true })
|
|
369
|
+
const tmp = join(dir, `.${randomUUID()}.tmp`)
|
|
370
|
+
try {
|
|
371
|
+
const handle = await open(tmp, 'wx', 0o600)
|
|
372
|
+
try {
|
|
373
|
+
await handle.writeFile(content, 'utf8')
|
|
374
|
+
await handle.sync()
|
|
375
|
+
} finally {
|
|
376
|
+
await handle.close()
|
|
377
|
+
}
|
|
378
|
+
await rename(tmp, filePath)
|
|
379
|
+
await fsyncDirectory(dir)
|
|
380
|
+
// Reread validation: ensure the file now contains exactly what we wrote.
|
|
381
|
+
const reread = await readFile(filePath, 'utf8')
|
|
382
|
+
if (reread !== content) {
|
|
383
|
+
throw new Error(`atomic-store: reread validation failed for ${filePath} (expected ${content.length} chars, got ${reread.length})`)
|
|
384
|
+
}
|
|
385
|
+
} catch (error) {
|
|
386
|
+
await rm(tmp, { force: true })
|
|
387
|
+
throw error
|
|
388
|
+
}
|
|
389
|
+
}
|
|
390
|
+
|
|
391
|
+
/**
|
|
392
|
+
* Synchronous atomic write (same guarantees, blocking).
|
|
393
|
+
* @param filePath - absolute target file path
|
|
394
|
+
* @param content - full new file content
|
|
395
|
+
*/
|
|
396
|
+
export function writeAtomicSync(filePath: string, content: string): void {
|
|
397
|
+
const dir = dirname(filePath)
|
|
398
|
+
mkdirSync(dir, { recursive: true })
|
|
399
|
+
const tmp = join(dir, `.${randomUUID()}.tmp`)
|
|
400
|
+
try {
|
|
401
|
+
const fd = openSync(tmp, 'wx', 0o600)
|
|
402
|
+
try {
|
|
403
|
+
writeFileSync(tmp, content, 'utf8')
|
|
404
|
+
fsyncSync(fd)
|
|
405
|
+
} finally {
|
|
406
|
+
closeSync(fd)
|
|
407
|
+
}
|
|
408
|
+
renameSync(tmp, filePath)
|
|
409
|
+
fsyncDirectorySync(dir)
|
|
410
|
+
// Reread validation
|
|
411
|
+
const reread = readFileSync(filePath, 'utf8')
|
|
412
|
+
if (reread !== content) {
|
|
413
|
+
throw new Error(`atomic-store: reread validation failed for ${filePath}`)
|
|
414
|
+
}
|
|
415
|
+
} catch (error) {
|
|
416
|
+
rmSync(tmp, { force: true })
|
|
417
|
+
throw error
|
|
418
|
+
}
|
|
419
|
+
}
|
|
420
|
+
|
|
421
|
+
// ---------------------------------------------------------------------------
|
|
422
|
+
// High-level entry helpers (lock + backup + duplicate detection + atomic write)
|
|
423
|
+
// ---------------------------------------------------------------------------
|
|
424
|
+
|
|
425
|
+
/**
|
|
426
|
+
* Read entries from `filePath`. Missing file yields [].
|
|
427
|
+
* @param filePath - file to read
|
|
428
|
+
* @returns parsed entries
|
|
429
|
+
*/
|
|
430
|
+
export function readEntriesSync(filePath: string): string[] {
|
|
431
|
+
try {
|
|
432
|
+
return parseEntries(readFileSync(filePath, 'utf8'))
|
|
433
|
+
} catch (error: unknown) {
|
|
434
|
+
const code = (error as NodeJS.ErrnoException).code
|
|
435
|
+
if (code === 'ENOENT') return []
|
|
436
|
+
throw error
|
|
437
|
+
}
|
|
438
|
+
}
|
|
439
|
+
|
|
440
|
+
/**
|
|
441
|
+
* Async read entries.
|
|
442
|
+
* @param filePath - file to read
|
|
443
|
+
* @returns parsed entries
|
|
444
|
+
*/
|
|
445
|
+
export async function readEntries(filePath: string): Promise<string[]> {
|
|
446
|
+
try {
|
|
447
|
+
return parseEntries(await readFile(filePath, 'utf8'))
|
|
448
|
+
} catch (error: unknown) {
|
|
449
|
+
const code = (error as NodeJS.ErrnoException).code
|
|
450
|
+
if (code === 'ENOENT') return []
|
|
451
|
+
throw error
|
|
452
|
+
}
|
|
453
|
+
}
|
|
454
|
+
|
|
455
|
+
/**
|
|
456
|
+
* Append `entry` to `filePath` atomically, with duplicate detection and
|
|
457
|
+
* drift-guard backup.
|
|
458
|
+
*
|
|
459
|
+
* - Acquires per-directory lock.
|
|
460
|
+
* - Validates existing content is canonical; if not, backs up and throws.
|
|
461
|
+
* - Checks for duplicate (ID-stripped); returns `{duplicate:true}` without writing.
|
|
462
|
+
* - Writes via temp+rename+reread.
|
|
463
|
+
*
|
|
464
|
+
* @param filePath - target file
|
|
465
|
+
* @param entry - entry to append (already trimmed)
|
|
466
|
+
* @returns result
|
|
467
|
+
*/
|
|
468
|
+
export async function appendEntryAtomic(
|
|
469
|
+
filePath: string,
|
|
470
|
+
entry: string,
|
|
471
|
+
): Promise<{ ok: true; duplicate?: boolean; entries: string[] } | { ok: false; error: string; backup?: string }> {
|
|
472
|
+
const dir = dirname(filePath)
|
|
473
|
+
return withLock(dir, async () => {
|
|
474
|
+
// Drift guard: read raw and validate
|
|
475
|
+
let raw = ''
|
|
476
|
+
try {
|
|
477
|
+
raw = await readFile(filePath, 'utf8')
|
|
478
|
+
} catch (error: unknown) {
|
|
479
|
+
const code = (error as NodeJS.ErrnoException).code
|
|
480
|
+
if (code !== 'ENOENT') throw error
|
|
481
|
+
raw = ''
|
|
482
|
+
}
|
|
483
|
+
if (raw !== '' && !isCanonical(raw)) {
|
|
484
|
+
const backup = await createBackup(filePath)
|
|
485
|
+
return { ok: false as const, error: `drift: non-canonical content backed up to ${backup}`, backup: backup ?? undefined }
|
|
486
|
+
}
|
|
487
|
+
const entries = parseEntries(raw)
|
|
488
|
+
if (isDuplicate(entries, entry)) {
|
|
489
|
+
return { ok: true as const, duplicate: true, entries }
|
|
490
|
+
}
|
|
491
|
+
const next = [...entries, entry]
|
|
492
|
+
const content = serializeEntries(next)
|
|
493
|
+
await writeAtomic(filePath, content)
|
|
494
|
+
return { ok: true as const, entries: next }
|
|
495
|
+
})
|
|
496
|
+
}
|
|
497
|
+
|
|
498
|
+
/**
|
|
499
|
+
* Synchronous variant of appendEntryAtomic.
|
|
500
|
+
* @param filePath - target file
|
|
501
|
+
* @param entry - entry to append
|
|
502
|
+
* @returns result
|
|
503
|
+
*/
|
|
504
|
+
export function appendEntryAtomicSync(
|
|
505
|
+
filePath: string,
|
|
506
|
+
entry: string,
|
|
507
|
+
): { ok: true; duplicate?: boolean; entries: string[] } | { ok: false; error: string; backup?: string } {
|
|
508
|
+
const dir = dirname(filePath)
|
|
509
|
+
return withLockSync(dir, () => {
|
|
510
|
+
let raw = ''
|
|
511
|
+
try {
|
|
512
|
+
raw = readFileSync(filePath, 'utf8')
|
|
513
|
+
} catch (error: unknown) {
|
|
514
|
+
const code = (error as NodeJS.ErrnoException).code
|
|
515
|
+
if (code !== 'ENOENT') throw error
|
|
516
|
+
raw = ''
|
|
517
|
+
}
|
|
518
|
+
if (raw !== '' && !isCanonical(raw)) {
|
|
519
|
+
const backup = createBackupSync(filePath)
|
|
520
|
+
return { ok: false, error: `drift: non-canonical content backed up to ${backup}`, backup: backup ?? undefined }
|
|
521
|
+
}
|
|
522
|
+
const entries = parseEntries(raw)
|
|
523
|
+
if (isDuplicate(entries, entry)) {
|
|
524
|
+
return { ok: true, duplicate: true, entries }
|
|
525
|
+
}
|
|
526
|
+
const next = [...entries, entry]
|
|
527
|
+
const content = serializeEntries(next)
|
|
528
|
+
writeAtomicSync(filePath, content)
|
|
529
|
+
return { ok: true, entries: next }
|
|
530
|
+
})
|
|
531
|
+
}
|
|
532
|
+
|
|
533
|
+
/**
|
|
534
|
+
* Replace or remove via atomic write with drift guard and backup.
|
|
535
|
+
* Caller provides the next entries array; this validates, backs up on drift, and writes.
|
|
536
|
+
* @param filePath - target file
|
|
537
|
+
* @param nextEntries - complete new entries array
|
|
538
|
+
* @returns result
|
|
539
|
+
*/
|
|
540
|
+
export async function writeEntriesAtomic(
|
|
541
|
+
filePath: string,
|
|
542
|
+
nextEntries: string[],
|
|
543
|
+
): Promise<{ ok: true; entries: string[] } | { ok: false; error: string; backup?: string }> {
|
|
544
|
+
const dir = dirname(filePath)
|
|
545
|
+
return withLock(dir, async () => {
|
|
546
|
+
let raw = ''
|
|
547
|
+
try {
|
|
548
|
+
raw = await readFile(filePath, 'utf8')
|
|
549
|
+
} catch (error: unknown) {
|
|
550
|
+
const code = (error as NodeJS.ErrnoException).code
|
|
551
|
+
if (code !== 'ENOENT') throw error
|
|
552
|
+
raw = ''
|
|
553
|
+
}
|
|
554
|
+
if (raw !== '' && !isCanonical(raw)) {
|
|
555
|
+
const backup = await createBackup(filePath)
|
|
556
|
+
return { ok: false, error: `drift: non-canonical content backed up to ${backup}`, backup: backup ?? undefined }
|
|
557
|
+
}
|
|
558
|
+
const content = serializeEntries(nextEntries)
|
|
559
|
+
await writeAtomic(filePath, content)
|
|
560
|
+
return { ok: true, entries: nextEntries }
|
|
561
|
+
})
|
|
562
|
+
}
|
|
563
|
+
|
|
564
|
+
/**
|
|
565
|
+
* Synchronous variant.
|
|
566
|
+
* @param filePath - target file
|
|
567
|
+
* @param nextEntries - complete new entries array
|
|
568
|
+
* @returns result
|
|
569
|
+
*/
|
|
570
|
+
export function writeEntriesAtomicSync(
|
|
571
|
+
filePath: string,
|
|
572
|
+
nextEntries: string[],
|
|
573
|
+
): { ok: true; entries: string[] } | { ok: false; error: string; backup?: string } {
|
|
574
|
+
const dir = dirname(filePath)
|
|
575
|
+
return withLockSync(dir, () => {
|
|
576
|
+
let raw = ''
|
|
577
|
+
try {
|
|
578
|
+
raw = readFileSync(filePath, 'utf8')
|
|
579
|
+
} catch (error: unknown) {
|
|
580
|
+
const code = (error as NodeJS.ErrnoException).code
|
|
581
|
+
if (code !== 'ENOENT') throw error
|
|
582
|
+
raw = ''
|
|
583
|
+
}
|
|
584
|
+
if (raw !== '' && !isCanonical(raw)) {
|
|
585
|
+
const backup = createBackupSync(filePath)
|
|
586
|
+
return { ok: false, error: `drift: non-canonical content backed up to ${backup}`, backup: backup ?? undefined }
|
|
587
|
+
}
|
|
588
|
+
const content = serializeEntries(nextEntries)
|
|
589
|
+
writeAtomicSync(filePath, content)
|
|
590
|
+
return { ok: true, entries: nextEntries }
|
|
591
|
+
})
|
|
592
|
+
}
|
|
593
|
+
|
|
594
|
+
// ---------------------------------------------------------------------------
|
|
595
|
+
// Utility: project hash (re-export for layout consumers)
|
|
596
|
+
// ---------------------------------------------------------------------------
|
|
597
|
+
|
|
598
|
+
/**
|
|
599
|
+
* Stable 12-hex project hash for a cwd.
|
|
600
|
+
* @param cwd - working directory
|
|
601
|
+
* @returns 12-char hash
|
|
602
|
+
*/
|
|
603
|
+
export function projectHash(cwd: string): string {
|
|
604
|
+
return createHash('sha1').update(cwd).digest('hex').slice(0, 12)
|
|
605
|
+
}
|