@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,786 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* migration/service.ts — inspect/dryRun/run/verify, backup manifest, journal, write-block on mismatch
|
|
3
|
+
*
|
|
4
|
+
* Implements M3-PR-B migration engine: in-place adoption, byte-preserving backup,
|
|
5
|
+
* SHA-256 verified, parser inventory, malformed JSONL tolerant, missing optional files tolerant,
|
|
6
|
+
* noncanonical/lock warnings, and write-block on verify mismatch.
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
import { createHash } from 'node:crypto'
|
|
10
|
+
import {
|
|
11
|
+
existsSync,
|
|
12
|
+
readFileSync,
|
|
13
|
+
readdirSync,
|
|
14
|
+
statSync,
|
|
15
|
+
mkdirSync,
|
|
16
|
+
writeFileSync,
|
|
17
|
+
appendFileSync,
|
|
18
|
+
copyFileSync,
|
|
19
|
+
rmSync,
|
|
20
|
+
unlinkSync,
|
|
21
|
+
} from 'node:fs'
|
|
22
|
+
import { join, relative, dirname } from 'node:path'
|
|
23
|
+
import {
|
|
24
|
+
resolveMemoryRoot,
|
|
25
|
+
maestroMetaDir,
|
|
26
|
+
schemaPath,
|
|
27
|
+
journalPath,
|
|
28
|
+
backupsDir,
|
|
29
|
+
backupManifestPath,
|
|
30
|
+
backupFilesDirPath,
|
|
31
|
+
} from '../storage/layout.ts'
|
|
32
|
+
import { parseEntries, isCanonical } from '../storage/legacy-format.ts'
|
|
33
|
+
import { parseTodoEntry } from '../storage/legacy-format.ts'
|
|
34
|
+
|
|
35
|
+
function sha256Hex(buf: Buffer): string {
|
|
36
|
+
return createHash('sha256').update(buf).digest('hex')
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
function readBytesAndHash(filePath: string): { bytes: number; sha256: string; raw: Buffer } {
|
|
40
|
+
const raw = readFileSync(filePath)
|
|
41
|
+
return { bytes: raw.length, sha256: sha256Hex(raw), raw }
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
function listDailyFiles(root: string): string[] {
|
|
45
|
+
const dailyRoot = join(root, 'daily')
|
|
46
|
+
try {
|
|
47
|
+
const names = readdirSync(dailyRoot)
|
|
48
|
+
return names.filter((n) => n.endsWith('.md') || n.endsWith('.todo.md')).map((n) => join(dailyRoot, n))
|
|
49
|
+
} catch {
|
|
50
|
+
return []
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
function listProjectFiles(root: string): string[] {
|
|
55
|
+
const projRoot = join(root, 'projects')
|
|
56
|
+
try {
|
|
57
|
+
const hashes = readdirSync(projRoot, { withFileTypes: true }).filter((d) => d.isDirectory()).map((d) => d.name)
|
|
58
|
+
const out: string[] = []
|
|
59
|
+
for (const h of hashes) {
|
|
60
|
+
const dir = join(projRoot, h)
|
|
61
|
+
for (const name of ['MEMORY.md', 'KEY.md', 'KEY-archive.md', 'TODOS.md']) {
|
|
62
|
+
const p = join(dir, name)
|
|
63
|
+
out.push(p) // push even if missing; caller will check exists
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
return out
|
|
67
|
+
} catch {
|
|
68
|
+
return []
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
function collectExpectedPaths(root: string): string[] {
|
|
73
|
+
const expected: string[] = [
|
|
74
|
+
join(root, 'MEMORY.md'),
|
|
75
|
+
join(root, 'USER.md'),
|
|
76
|
+
join(root, 'MEMORY-archive.md'),
|
|
77
|
+
join(root, 'USER-archive.md'),
|
|
78
|
+
join(root, 'SUGGESTIONS.jsonl'),
|
|
79
|
+
join(root, 'TODOS-life.md'),
|
|
80
|
+
join(root, 'TODOS-work.md'),
|
|
81
|
+
join(root, 'TODO-archive.md'),
|
|
82
|
+
]
|
|
83
|
+
expected.push(...listDailyFiles(root))
|
|
84
|
+
expected.push(...listProjectFiles(root))
|
|
85
|
+
// also include any daily files that may exist but we already added; ensure uniqueness
|
|
86
|
+
// Also include any extra files under root for backup completeness: scan all files recursively excluding .maestro-memory
|
|
87
|
+
// But for inspect we want to list expected + known daily/project; extra files will be discovered via full scan for backup
|
|
88
|
+
return expected
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
function scanAllForBackup(root: string): string[] {
|
|
92
|
+
const out: string[] = []
|
|
93
|
+
function walk(dir: string): void {
|
|
94
|
+
let entries: any[]
|
|
95
|
+
try {
|
|
96
|
+
entries = readdirSync(dir, { withFileTypes: true })
|
|
97
|
+
} catch {
|
|
98
|
+
return
|
|
99
|
+
}
|
|
100
|
+
for (const e of entries) {
|
|
101
|
+
const p = join(dir, e.name)
|
|
102
|
+
if (p.includes('.maestro-memory')) continue
|
|
103
|
+
if (e.isDirectory()) {
|
|
104
|
+
// skip .maestro-memory at any depth
|
|
105
|
+
if (e.name === '.maestro-memory') continue
|
|
106
|
+
walk(p)
|
|
107
|
+
} else if (e.isFile()) {
|
|
108
|
+
out.push(p)
|
|
109
|
+
}
|
|
110
|
+
}
|
|
111
|
+
}
|
|
112
|
+
walk(root)
|
|
113
|
+
return out
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
function parseMemoryEntriesSafe(text: string): { entries: string[]; isCanonical: boolean } {
|
|
117
|
+
const entries = parseEntries(text)
|
|
118
|
+
const canon = text.trim() === '' || isCanonical(text)
|
|
119
|
+
return { entries, isCanonical: canon }
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
function parseTodoFileSafe(filePath: string): { count: number; ids: string[]; valid: number; malformed: number } {
|
|
123
|
+
let raw: string
|
|
124
|
+
try {
|
|
125
|
+
raw = readFileSync(filePath, 'utf8')
|
|
126
|
+
} catch {
|
|
127
|
+
return { count: 0, ids: [], valid: 0, malformed: 0 }
|
|
128
|
+
}
|
|
129
|
+
// strip header comment
|
|
130
|
+
const body = raw.replace(/^<!--[\s\S]*?-->\s*/, '').replace(/^\s*§\s*\n?/, '').trim()
|
|
131
|
+
if (body === '') return { count: 0, ids: [], valid: 0, malformed: 0 }
|
|
132
|
+
const parts = body.split('\n§\n').map((s) => s.trim()).filter(Boolean)
|
|
133
|
+
const ids: string[] = []
|
|
134
|
+
let valid = 0
|
|
135
|
+
for (const p of parts) {
|
|
136
|
+
const parsed = parseTodoEntry(p)
|
|
137
|
+
if (parsed && parsed.id) {
|
|
138
|
+
valid += 1
|
|
139
|
+
ids.push(parsed.id)
|
|
140
|
+
}
|
|
141
|
+
}
|
|
142
|
+
return { count: parts.length, ids, valid, malformed: parts.length - valid }
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
function parseQueueSafe(filePath: string): { valid: number; malformed: number; entries: any[] } {
|
|
146
|
+
try {
|
|
147
|
+
const text = readFileSync(filePath, 'utf8')
|
|
148
|
+
const lines = text.split('\n')
|
|
149
|
+
let valid = 0
|
|
150
|
+
let malformed = 0
|
|
151
|
+
const entries: any[] = []
|
|
152
|
+
for (const line of lines) {
|
|
153
|
+
if (line.trim() === '') continue
|
|
154
|
+
try {
|
|
155
|
+
const obj = JSON.parse(line)
|
|
156
|
+
if (obj && typeof obj.target === 'string' && typeof obj.content === 'string') {
|
|
157
|
+
valid += 1
|
|
158
|
+
entries.push(obj)
|
|
159
|
+
} else {
|
|
160
|
+
malformed += 1
|
|
161
|
+
}
|
|
162
|
+
} catch {
|
|
163
|
+
malformed += 1
|
|
164
|
+
}
|
|
165
|
+
}
|
|
166
|
+
return { valid, malformed, entries }
|
|
167
|
+
} catch (e: any) {
|
|
168
|
+
if (e?.code === 'ENOENT') return { valid: 0, malformed: 0, entries: [] }
|
|
169
|
+
throw e
|
|
170
|
+
}
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
export interface FileInfo {
|
|
174
|
+
path: string
|
|
175
|
+
relative: string
|
|
176
|
+
exists: boolean
|
|
177
|
+
bytes: number
|
|
178
|
+
sha256: string | null
|
|
179
|
+
kind: string
|
|
180
|
+
entries?: number
|
|
181
|
+
ids?: string[]
|
|
182
|
+
malformedLines?: number
|
|
183
|
+
valid?: number
|
|
184
|
+
warning?: string | null
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
export interface InspectResult {
|
|
188
|
+
ok: boolean
|
|
189
|
+
root: string
|
|
190
|
+
files: FileInfo[]
|
|
191
|
+
inventory: {
|
|
192
|
+
memoryEntries: number
|
|
193
|
+
todoIds: string[]
|
|
194
|
+
todoIdsCount: number
|
|
195
|
+
todoCount: number
|
|
196
|
+
queueValid: number
|
|
197
|
+
queueMalformed: number
|
|
198
|
+
dailyFiles: number
|
|
199
|
+
projectDirs: number
|
|
200
|
+
} & Record<string, any>
|
|
201
|
+
warnings: string[]
|
|
202
|
+
errors: string[]
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
function kindFor(filePath: string, root: string): string {
|
|
206
|
+
const rel = relative(root, filePath)
|
|
207
|
+
if (rel === 'MEMORY.md' || rel === 'USER.md') return 'memory'
|
|
208
|
+
if (rel.endsWith('-archive.md')) return 'archive'
|
|
209
|
+
if (rel.endsWith('SUGGESTIONS.jsonl')) return 'queue'
|
|
210
|
+
if (rel.startsWith('daily/') && rel.endsWith('.todo.md')) return 'todo-daily'
|
|
211
|
+
if (rel.startsWith('daily/')) return 'daily'
|
|
212
|
+
if (rel.includes('projects/') && rel.endsWith('TODOS.md')) return 'todo-project'
|
|
213
|
+
if (rel.includes('projects/') && rel.endsWith('KEY.md')) return 'key'
|
|
214
|
+
if (rel.includes('projects/') && rel.endsWith('KEY-archive.md')) return 'archive'
|
|
215
|
+
if (rel.includes('projects/') && rel.endsWith('MEMORY.md')) return 'project'
|
|
216
|
+
if (rel.startsWith('TODOS-') || rel === 'TODOS-life.md' || rel === 'TODOS-work.md') return 'todo'
|
|
217
|
+
if (rel === 'TODO-archive.md') return 'archive'
|
|
218
|
+
return 'other'
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
export async function inspect(memoryDir: string | null | undefined = null): Promise<InspectResult> {
|
|
222
|
+
const root = resolveMemoryRoot(memoryDir as any)
|
|
223
|
+
const warnings: string[] = []
|
|
224
|
+
const errors: string[] = []
|
|
225
|
+
const files: FileInfo[] = []
|
|
226
|
+
|
|
227
|
+
const expected = collectExpectedPaths(root)
|
|
228
|
+
// Use set to deduplicate expected
|
|
229
|
+
const seen = new Set<string>()
|
|
230
|
+
const expectedUnique = expected.filter((p) => {
|
|
231
|
+
if (seen.has(p)) return false
|
|
232
|
+
seen.add(p)
|
|
233
|
+
return true
|
|
234
|
+
})
|
|
235
|
+
|
|
236
|
+
let totalMemoryEntries = 0
|
|
237
|
+
let allTodoIds: string[] = []
|
|
238
|
+
let totalTodoCount = 0
|
|
239
|
+
let queueValid = 0
|
|
240
|
+
let queueMalformed = 0
|
|
241
|
+
let dailyFiles = 0
|
|
242
|
+
let projectDirs = 0
|
|
243
|
+
|
|
244
|
+
// Count project dirs
|
|
245
|
+
try {
|
|
246
|
+
const projRoot = join(root, 'projects')
|
|
247
|
+
const hashes = readdirSync(projRoot, { withFileTypes: true }).filter((d) => d.isDirectory())
|
|
248
|
+
projectDirs = hashes.length
|
|
249
|
+
} catch {
|
|
250
|
+
projectDirs = 0
|
|
251
|
+
}
|
|
252
|
+
try {
|
|
253
|
+
dailyFiles = readdirSync(join(root, 'daily')).filter((n) => n.endsWith('.md')).length
|
|
254
|
+
} catch {
|
|
255
|
+
dailyFiles = 0
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
for (const abs of expectedUnique) {
|
|
259
|
+
const rel = relative(root, abs)
|
|
260
|
+
const exists = existsSync(abs)
|
|
261
|
+
if (!exists) {
|
|
262
|
+
files.push({
|
|
263
|
+
path: abs,
|
|
264
|
+
relative: rel,
|
|
265
|
+
exists: false,
|
|
266
|
+
bytes: 0,
|
|
267
|
+
sha256: null,
|
|
268
|
+
kind: kindFor(abs, root),
|
|
269
|
+
warning: null,
|
|
270
|
+
})
|
|
271
|
+
continue
|
|
272
|
+
}
|
|
273
|
+
const { bytes, sha256, raw } = readBytesAndHash(abs)
|
|
274
|
+
const kind = kindFor(abs, root)
|
|
275
|
+
let entriesCount: number | undefined
|
|
276
|
+
let warning: string | null = null
|
|
277
|
+
let malformedLines: number | undefined
|
|
278
|
+
let valid: number | undefined
|
|
279
|
+
let ids: string[] | undefined
|
|
280
|
+
|
|
281
|
+
if (kind === 'memory' || kind === 'project' || kind === 'key' || kind === 'daily' || kind === 'archive') {
|
|
282
|
+
const text = raw.toString('utf8')
|
|
283
|
+
const { entries, isCanonical: canon } = parseMemoryEntriesSafe(text)
|
|
284
|
+
entriesCount = entries.length
|
|
285
|
+
totalMemoryEntries += entriesCount
|
|
286
|
+
if (!canon && text.trim() !== '') {
|
|
287
|
+
warning = `non-canonical content in ${rel} (drift)`
|
|
288
|
+
warnings.push(`non-canonical: ${rel}`)
|
|
289
|
+
}
|
|
290
|
+
} else if (kind === 'todo' || kind === 'todo-project' || kind === 'todo-daily') {
|
|
291
|
+
const info = parseTodoFileSafe(abs)
|
|
292
|
+
entriesCount = info.count
|
|
293
|
+
ids = info.ids
|
|
294
|
+
allTodoIds.push(...info.ids)
|
|
295
|
+
totalTodoCount += info.count
|
|
296
|
+
if (info.malformed > 0) {
|
|
297
|
+
warning = `malformed todo entries in ${rel}: ${info.malformed}`
|
|
298
|
+
warnings.push(`malformed todo: ${rel} (${info.malformed})`)
|
|
299
|
+
}
|
|
300
|
+
} else if (kind === 'queue') {
|
|
301
|
+
const q = parseQueueSafe(abs)
|
|
302
|
+
queueValid = q.valid
|
|
303
|
+
queueMalformed = q.malformed
|
|
304
|
+
entriesCount = q.valid + q.malformed
|
|
305
|
+
malformedLines = q.malformed
|
|
306
|
+
valid = q.valid
|
|
307
|
+
if (q.malformed > 0) {
|
|
308
|
+
warning = `malformed JSONL in ${rel}: ${q.malformed} lines`
|
|
309
|
+
warnings.push(`malformed JSONL: ${rel} (${q.malformed} malformed, ${q.valid} valid)`)
|
|
310
|
+
}
|
|
311
|
+
}
|
|
312
|
+
|
|
313
|
+
files.push({
|
|
314
|
+
path: abs,
|
|
315
|
+
relative: rel,
|
|
316
|
+
exists: true,
|
|
317
|
+
bytes,
|
|
318
|
+
sha256,
|
|
319
|
+
kind,
|
|
320
|
+
entries: entriesCount,
|
|
321
|
+
ids,
|
|
322
|
+
malformedLines,
|
|
323
|
+
valid,
|
|
324
|
+
warning,
|
|
325
|
+
})
|
|
326
|
+
}
|
|
327
|
+
|
|
328
|
+
// Also detect lock files
|
|
329
|
+
const lockPaths: string[] = []
|
|
330
|
+
function findLocks(dir: string): void {
|
|
331
|
+
try {
|
|
332
|
+
const entries = readdirSync(dir, { withFileTypes: true })
|
|
333
|
+
for (const e of entries) {
|
|
334
|
+
const p = join(dir, e.name)
|
|
335
|
+
if (e.isDirectory()) {
|
|
336
|
+
if (e.name === '.maestro-memory') continue
|
|
337
|
+
findLocks(p)
|
|
338
|
+
} else if (e.name === '.maestro.lock') {
|
|
339
|
+
lockPaths.push(p)
|
|
340
|
+
}
|
|
341
|
+
}
|
|
342
|
+
} catch {}
|
|
343
|
+
}
|
|
344
|
+
try {
|
|
345
|
+
findLocks(root)
|
|
346
|
+
} catch {}
|
|
347
|
+
for (const lp of lockPaths) {
|
|
348
|
+
warnings.push(`lock file present: ${relative(root, lp)}`)
|
|
349
|
+
}
|
|
350
|
+
|
|
351
|
+
// For backup completeness, also scan all files not in expected (extra legacy files like advisor/)
|
|
352
|
+
const allFiles = scanAllForBackup(root)
|
|
353
|
+
const expectedSet = new Set(expectedUnique)
|
|
354
|
+
for (const abs of allFiles) {
|
|
355
|
+
if (expectedSet.has(abs)) continue
|
|
356
|
+
const rel = relative(root, abs)
|
|
357
|
+
// already handled daily/project extra? but scan will catch additional files like advisor/
|
|
358
|
+
const { bytes, sha256 } = readBytesAndHash(abs)
|
|
359
|
+
files.push({
|
|
360
|
+
path: abs,
|
|
361
|
+
relative: rel,
|
|
362
|
+
exists: true,
|
|
363
|
+
bytes,
|
|
364
|
+
sha256,
|
|
365
|
+
kind: 'other',
|
|
366
|
+
warning: null,
|
|
367
|
+
})
|
|
368
|
+
}
|
|
369
|
+
|
|
370
|
+
// ok: inspect never blocks on missing optional or malformed; only errors would be unreadable?
|
|
371
|
+
const ok = errors.length === 0
|
|
372
|
+
|
|
373
|
+
return {
|
|
374
|
+
ok,
|
|
375
|
+
root,
|
|
376
|
+
files,
|
|
377
|
+
inventory: {
|
|
378
|
+
memoryEntries: totalMemoryEntries,
|
|
379
|
+
todoIds: allTodoIds,
|
|
380
|
+
todoIdsCount: allTodoIds.length,
|
|
381
|
+
todoCount: totalTodoCount,
|
|
382
|
+
queueValid,
|
|
383
|
+
queueMalformed,
|
|
384
|
+
dailyFiles,
|
|
385
|
+
projectDirs,
|
|
386
|
+
},
|
|
387
|
+
warnings,
|
|
388
|
+
errors,
|
|
389
|
+
}
|
|
390
|
+
}
|
|
391
|
+
|
|
392
|
+
export async function dryRun(memoryDir: string | null | undefined = null): Promise<InspectResult> {
|
|
393
|
+
// dryRun is same as inspect but explicitly read-only; ensure no side effects
|
|
394
|
+
return inspect(memoryDir)
|
|
395
|
+
}
|
|
396
|
+
|
|
397
|
+
function utcRunId(): string {
|
|
398
|
+
const now = new Date()
|
|
399
|
+
const pad = (n: number, len = 2) => String(n).padStart(len, '0')
|
|
400
|
+
const yyyy = now.getUTCFullYear()
|
|
401
|
+
const mm = pad(now.getUTCMonth() + 1)
|
|
402
|
+
const dd = pad(now.getUTCDate())
|
|
403
|
+
const hh = pad(now.getUTCHours())
|
|
404
|
+
const mi = pad(now.getUTCMinutes())
|
|
405
|
+
const ss = pad(now.getUTCSeconds())
|
|
406
|
+
const ms = pad(now.getUTCMilliseconds(), 3)
|
|
407
|
+
return `${yyyy}${mm}${dd}T${hh}${mi}${ss}.${ms}Z`
|
|
408
|
+
}
|
|
409
|
+
|
|
410
|
+
export interface RunResult {
|
|
411
|
+
ok: boolean
|
|
412
|
+
runId: string
|
|
413
|
+
manifestPath: string
|
|
414
|
+
backupFilesDir: string
|
|
415
|
+
warnings: string[]
|
|
416
|
+
errors: string[]
|
|
417
|
+
files: FileInfo[]
|
|
418
|
+
}
|
|
419
|
+
|
|
420
|
+
export async function run(memoryDir: string | null | undefined = null): Promise<RunResult> {
|
|
421
|
+
const root = resolveMemoryRoot(memoryDir as any)
|
|
422
|
+
const insp = await inspect(root)
|
|
423
|
+
if (insp.errors.length > 0) {
|
|
424
|
+
return {
|
|
425
|
+
ok: false,
|
|
426
|
+
runId: '',
|
|
427
|
+
manifestPath: '',
|
|
428
|
+
backupFilesDir: '',
|
|
429
|
+
warnings: insp.warnings,
|
|
430
|
+
errors: insp.errors,
|
|
431
|
+
files: insp.files,
|
|
432
|
+
}
|
|
433
|
+
}
|
|
434
|
+
const runId = utcRunId()
|
|
435
|
+
const manifestPath = backupManifestPath(root, runId)
|
|
436
|
+
const backupFilesDir = backupFilesDirPath(root, runId)
|
|
437
|
+
mkdirSync(backupFilesDir, { recursive: true })
|
|
438
|
+
mkdirSync(dirname(manifestPath), { recursive: true })
|
|
439
|
+
|
|
440
|
+
// byte-preserving copy
|
|
441
|
+
const manifestFiles: any[] = []
|
|
442
|
+
for (const f of insp.files) {
|
|
443
|
+
if (!f.exists) {
|
|
444
|
+
manifestFiles.push({
|
|
445
|
+
relative: f.relative,
|
|
446
|
+
path: f.path,
|
|
447
|
+
exists: false,
|
|
448
|
+
bytes: 0,
|
|
449
|
+
sha256: null,
|
|
450
|
+
kind: f.kind,
|
|
451
|
+
entries: f.entries ?? 0,
|
|
452
|
+
malformedLines: f.malformedLines,
|
|
453
|
+
})
|
|
454
|
+
continue
|
|
455
|
+
}
|
|
456
|
+
const dest = join(backupFilesDir, f.relative)
|
|
457
|
+
mkdirSync(dirname(dest), { recursive: true })
|
|
458
|
+
// byte-preserving copy
|
|
459
|
+
copyFileSync(f.path, dest)
|
|
460
|
+
// verify backup matches
|
|
461
|
+
const backupRaw = readFileSync(dest)
|
|
462
|
+
const backupSha = sha256Hex(backupRaw)
|
|
463
|
+
if (backupSha !== f.sha256) {
|
|
464
|
+
throw new Error(`backup sha mismatch for ${f.relative}`)
|
|
465
|
+
}
|
|
466
|
+
manifestFiles.push({
|
|
467
|
+
relative: f.relative,
|
|
468
|
+
path: f.path,
|
|
469
|
+
exists: true,
|
|
470
|
+
bytes: f.bytes,
|
|
471
|
+
sha256: f.sha256,
|
|
472
|
+
kind: f.kind,
|
|
473
|
+
entries: f.entries,
|
|
474
|
+
ids: f.ids,
|
|
475
|
+
malformedLines: f.malformedLines,
|
|
476
|
+
valid: f.valid,
|
|
477
|
+
})
|
|
478
|
+
}
|
|
479
|
+
|
|
480
|
+
const manifest = {
|
|
481
|
+
version: 1,
|
|
482
|
+
runId,
|
|
483
|
+
createdAt: new Date().toISOString(),
|
|
484
|
+
root,
|
|
485
|
+
files: manifestFiles,
|
|
486
|
+
inventory: insp.inventory,
|
|
487
|
+
warnings: insp.warnings,
|
|
488
|
+
errors: insp.errors,
|
|
489
|
+
backupFilesDir,
|
|
490
|
+
}
|
|
491
|
+
writeFileSync(manifestPath, JSON.stringify(manifest, null, 2), 'utf8')
|
|
492
|
+
|
|
493
|
+
// write schema.json (only after all required data parses — we already did)
|
|
494
|
+
const sPath = schemaPath(root)
|
|
495
|
+
mkdirSync(dirname(sPath), { recursive: true })
|
|
496
|
+
const schema = {
|
|
497
|
+
version: 1,
|
|
498
|
+
runId,
|
|
499
|
+
migratedAt: new Date().toISOString(),
|
|
500
|
+
root,
|
|
501
|
+
inventory: insp.inventory,
|
|
502
|
+
manifest: manifestPath,
|
|
503
|
+
}
|
|
504
|
+
writeFileSync(sPath, JSON.stringify(schema, null, 2), 'utf8')
|
|
505
|
+
|
|
506
|
+
// journal
|
|
507
|
+
const jPath = journalPath(root)
|
|
508
|
+
mkdirSync(dirname(jPath), { recursive: true })
|
|
509
|
+
const journalEntry = {
|
|
510
|
+
runId,
|
|
511
|
+
at: new Date().toISOString(),
|
|
512
|
+
action: 'run',
|
|
513
|
+
ok: true,
|
|
514
|
+
warnings: insp.warnings,
|
|
515
|
+
inventory: insp.inventory,
|
|
516
|
+
manifest: manifestPath,
|
|
517
|
+
}
|
|
518
|
+
appendFileSync(jPath, JSON.stringify(journalEntry) + '\n', 'utf8')
|
|
519
|
+
|
|
520
|
+
// clear any previous write-block on successful run
|
|
521
|
+
const blockPath = join(maestroMetaDir(root), 'write-block.json')
|
|
522
|
+
if (existsSync(blockPath)) {
|
|
523
|
+
try {
|
|
524
|
+
rmSync(blockPath, { force: true })
|
|
525
|
+
} catch {}
|
|
526
|
+
}
|
|
527
|
+
|
|
528
|
+
return {
|
|
529
|
+
ok: true,
|
|
530
|
+
runId,
|
|
531
|
+
manifestPath,
|
|
532
|
+
backupFilesDir,
|
|
533
|
+
warnings: insp.warnings,
|
|
534
|
+
errors: [],
|
|
535
|
+
files: insp.files,
|
|
536
|
+
}
|
|
537
|
+
}
|
|
538
|
+
|
|
539
|
+
export interface VerifyResult {
|
|
540
|
+
ok: boolean
|
|
541
|
+
runId: string
|
|
542
|
+
manifestPath: string
|
|
543
|
+
mismatches: string[]
|
|
544
|
+
warnings: string[]
|
|
545
|
+
}
|
|
546
|
+
|
|
547
|
+
function latestRunId(root: string): string | null {
|
|
548
|
+
const dir = backupsDir(root)
|
|
549
|
+
try {
|
|
550
|
+
const entries = readdirSync(dir, { withFileTypes: true }).filter((d) => d.isDirectory()).map((d) => d.name).sort()
|
|
551
|
+
if (entries.length === 0) return null
|
|
552
|
+
return entries[entries.length - 1]
|
|
553
|
+
} catch {
|
|
554
|
+
return null
|
|
555
|
+
}
|
|
556
|
+
}
|
|
557
|
+
|
|
558
|
+
export async function verify(memoryDir: string | null | undefined = null, runId?: string): Promise<VerifyResult> {
|
|
559
|
+
const root = resolveMemoryRoot(memoryDir as any)
|
|
560
|
+
let targetRunId = runId ?? null
|
|
561
|
+
if (!targetRunId) {
|
|
562
|
+
// try schema.json first
|
|
563
|
+
try {
|
|
564
|
+
const schema = JSON.parse(readFileSync(schemaPath(root), 'utf8'))
|
|
565
|
+
targetRunId = schema.runId ?? null
|
|
566
|
+
} catch {}
|
|
567
|
+
if (!targetRunId) targetRunId = latestRunId(root)
|
|
568
|
+
}
|
|
569
|
+
if (!targetRunId) {
|
|
570
|
+
return { ok: false, runId: '', manifestPath: '', mismatches: ['no manifest found'], warnings: [] }
|
|
571
|
+
}
|
|
572
|
+
const manifestPath = backupManifestPath(root, targetRunId)
|
|
573
|
+
let manifest: any
|
|
574
|
+
try {
|
|
575
|
+
manifest = JSON.parse(readFileSync(manifestPath, 'utf8'))
|
|
576
|
+
} catch (e: any) {
|
|
577
|
+
return { ok: false, runId: targetRunId, manifestPath, mismatches: [`manifest not found: ${e?.message}`], warnings: [] }
|
|
578
|
+
}
|
|
579
|
+
|
|
580
|
+
const mismatches: string[] = []
|
|
581
|
+
const warnings: string[] = []
|
|
582
|
+
|
|
583
|
+
// check each file in manifest
|
|
584
|
+
for (const f of manifest.files) {
|
|
585
|
+
if (!f.exists) {
|
|
586
|
+
// file was missing at backup time; should still be missing
|
|
587
|
+
const curExists = existsSync(f.path)
|
|
588
|
+
if (curExists) {
|
|
589
|
+
mismatches.push(`unexpected file appeared: ${f.relative}`)
|
|
590
|
+
}
|
|
591
|
+
continue
|
|
592
|
+
}
|
|
593
|
+
const curExists = existsSync(f.path)
|
|
594
|
+
if (!curExists) {
|
|
595
|
+
mismatches.push(`missing file: ${f.relative} (expected sha ${f.sha256})`)
|
|
596
|
+
continue
|
|
597
|
+
}
|
|
598
|
+
const cur = readFileSync(f.path)
|
|
599
|
+
const curSha = sha256Hex(cur)
|
|
600
|
+
if (curSha !== f.sha256) {
|
|
601
|
+
mismatches.push(`digest mismatch: ${f.relative} (expected ${f.sha256?.slice(0, 8)}, got ${curSha.slice(0, 8)}, bytes ${f.bytes} -> ${cur.length})`)
|
|
602
|
+
}
|
|
603
|
+
if (cur.length !== f.bytes) {
|
|
604
|
+
mismatches.push(`byte count mismatch: ${f.relative} (${f.bytes} -> ${cur.length})`)
|
|
605
|
+
}
|
|
606
|
+
}
|
|
607
|
+
|
|
608
|
+
// also compare inventory via re-parse (entry counts + todo IDs)
|
|
609
|
+
const currentInspect = await inspect(root)
|
|
610
|
+
const manifestInv = manifest.inventory
|
|
611
|
+
if (manifestInv) {
|
|
612
|
+
if (currentInspect.inventory.memoryEntries !== manifestInv.memoryEntries) {
|
|
613
|
+
mismatches.push(`inventory mismatch: memoryEntries ${manifestInv.memoryEntries} -> ${currentInspect.inventory.memoryEntries}`)
|
|
614
|
+
}
|
|
615
|
+
if (currentInspect.inventory.todoIdsCount !== manifestInv.todoIdsCount) {
|
|
616
|
+
mismatches.push(`inventory mismatch: todoIds ${manifestInv.todoIdsCount} -> ${currentInspect.inventory.todoIdsCount}`)
|
|
617
|
+
}
|
|
618
|
+
if (currentInspect.inventory.queueValid !== manifestInv.queueValid) {
|
|
619
|
+
mismatches.push(`inventory mismatch: queueValid ${manifestInv.queueValid} -> ${currentInspect.inventory.queueValid}`)
|
|
620
|
+
}
|
|
621
|
+
// check todo IDs set equality if counts match but ids differ
|
|
622
|
+
if (manifestInv.todoIds && Array.isArray(manifestInv.todoIds)) {
|
|
623
|
+
const curIdsSorted = [...currentInspect.inventory.todoIds].sort()
|
|
624
|
+
const manIdsSorted = [...manifestInv.todoIds].sort()
|
|
625
|
+
if (curIdsSorted.join(',') !== manIdsSorted.join(',')) {
|
|
626
|
+
// only report if not already reported via count
|
|
627
|
+
if (currentInspect.inventory.todoIdsCount === manifestInv.todoIdsCount) {
|
|
628
|
+
mismatches.push(`todo ID set mismatch: ${manIdsSorted.join(',')} -> ${curIdsSorted.join(',')}`)
|
|
629
|
+
}
|
|
630
|
+
}
|
|
631
|
+
}
|
|
632
|
+
}
|
|
633
|
+
|
|
634
|
+
const ok = mismatches.length === 0
|
|
635
|
+
const blockPath = join(maestroMetaDir(root), 'write-block.json')
|
|
636
|
+
if (!ok) {
|
|
637
|
+
// write-block on mismatch
|
|
638
|
+
mkdirSync(dirname(blockPath), { recursive: true })
|
|
639
|
+
const block = {
|
|
640
|
+
blocked: true,
|
|
641
|
+
runId: targetRunId,
|
|
642
|
+
at: new Date().toISOString(),
|
|
643
|
+
mismatches,
|
|
644
|
+
manifest: manifestPath,
|
|
645
|
+
}
|
|
646
|
+
writeFileSync(blockPath, JSON.stringify(block, null, 2), 'utf8')
|
|
647
|
+
// also append to journal
|
|
648
|
+
try {
|
|
649
|
+
const jPath = journalPath(root)
|
|
650
|
+
mkdirSync(dirname(jPath), { recursive: true })
|
|
651
|
+
appendFileSync(jPath, JSON.stringify({ runId: targetRunId, at: new Date().toISOString(), action: 'verify', ok: false, mismatches }) + '\n', 'utf8')
|
|
652
|
+
} catch {}
|
|
653
|
+
} else {
|
|
654
|
+
// on success, clear block if exists and log verify success
|
|
655
|
+
if (existsSync(blockPath)) {
|
|
656
|
+
try {
|
|
657
|
+
rmSync(blockPath, { force: true })
|
|
658
|
+
} catch {}
|
|
659
|
+
}
|
|
660
|
+
try {
|
|
661
|
+
const jPath = journalPath(root)
|
|
662
|
+
mkdirSync(dirname(jPath), { recursive: true })
|
|
663
|
+
appendFileSync(jPath, JSON.stringify({ runId: targetRunId, at: new Date().toISOString(), action: 'verify', ok: true }) + '\n', 'utf8')
|
|
664
|
+
} catch {}
|
|
665
|
+
}
|
|
666
|
+
|
|
667
|
+
return { ok, runId: targetRunId, manifestPath, mismatches, warnings }
|
|
668
|
+
}
|
|
669
|
+
|
|
670
|
+
export function isWriteBlocked(memoryDir: string | null | undefined = null): boolean {
|
|
671
|
+
const root = resolveMemoryRoot(memoryDir as any)
|
|
672
|
+
const blockPath = join(maestroMetaDir(root), 'write-block.json')
|
|
673
|
+
if (!existsSync(blockPath)) return false
|
|
674
|
+
try {
|
|
675
|
+
const data = JSON.parse(readFileSync(blockPath, 'utf8'))
|
|
676
|
+
return data.blocked === true
|
|
677
|
+
} catch {
|
|
678
|
+
return true
|
|
679
|
+
}
|
|
680
|
+
}
|
|
681
|
+
|
|
682
|
+
export function clearWriteBlock(memoryDir: string | null | undefined = null): void {
|
|
683
|
+
const root = resolveMemoryRoot(memoryDir as any)
|
|
684
|
+
const blockPath = join(maestroMetaDir(root), 'write-block.json')
|
|
685
|
+
if (existsSync(blockPath)) {
|
|
686
|
+
try {
|
|
687
|
+
rmSync(blockPath, { force: true })
|
|
688
|
+
} catch {}
|
|
689
|
+
}
|
|
690
|
+
}
|
|
691
|
+
|
|
692
|
+
export interface RollbackResult {
|
|
693
|
+
ok: boolean
|
|
694
|
+
runId: string
|
|
695
|
+
manifestPath: string
|
|
696
|
+
restored: number
|
|
697
|
+
errors: string[]
|
|
698
|
+
}
|
|
699
|
+
|
|
700
|
+
/**
|
|
701
|
+
* Restore files from a backup manifest byte-identical.
|
|
702
|
+
* Used for M4-PR-A rehearsal: exercise and test rollback against copied schema.
|
|
703
|
+
* - Restores each existing file from backupFilesDir/<relative> to its original path.
|
|
704
|
+
* - Removes files that appeared after backup if they were absent at backup time.
|
|
705
|
+
* - Clears write-block and appends journal entry on success.
|
|
706
|
+
*/
|
|
707
|
+
export async function rollback(
|
|
708
|
+
memoryDir: string | null | undefined = null,
|
|
709
|
+
runId?: string,
|
|
710
|
+
): Promise<RollbackResult> {
|
|
711
|
+
const root = resolveMemoryRoot(memoryDir as any)
|
|
712
|
+
let targetRunId = runId ?? null
|
|
713
|
+
if (!targetRunId) {
|
|
714
|
+
try {
|
|
715
|
+
const schema = JSON.parse(readFileSync(schemaPath(root), 'utf8'))
|
|
716
|
+
targetRunId = schema.runId ?? null
|
|
717
|
+
} catch {}
|
|
718
|
+
if (!targetRunId) targetRunId = latestRunId(root)
|
|
719
|
+
}
|
|
720
|
+
if (!targetRunId) {
|
|
721
|
+
return { ok: false, runId: '', manifestPath: '', restored: 0, errors: ['no manifest found'] }
|
|
722
|
+
}
|
|
723
|
+
const manifestPath = backupManifestPath(root, targetRunId)
|
|
724
|
+
let manifest: any
|
|
725
|
+
try {
|
|
726
|
+
manifest = JSON.parse(readFileSync(manifestPath, 'utf8'))
|
|
727
|
+
} catch (e: any) {
|
|
728
|
+
return { ok: false, runId: targetRunId, manifestPath, restored: 0, errors: [`manifest not found: ${e?.message}`] }
|
|
729
|
+
}
|
|
730
|
+
const backupFilesDir: string = manifest.backupFilesDir ?? backupFilesDirPath(root, targetRunId)
|
|
731
|
+
let restored = 0
|
|
732
|
+
const errors: string[] = []
|
|
733
|
+
for (const f of manifest.files) {
|
|
734
|
+
const dest = f.path as string
|
|
735
|
+
const src = join(backupFilesDir, f.relative as string)
|
|
736
|
+
try {
|
|
737
|
+
if (!f.exists) {
|
|
738
|
+
// file was absent at backup time; remove if it appeared
|
|
739
|
+
if (existsSync(dest)) {
|
|
740
|
+
rmSync(dest, { force: true })
|
|
741
|
+
restored += 1
|
|
742
|
+
}
|
|
743
|
+
continue
|
|
744
|
+
}
|
|
745
|
+
// file existed; restore byte-identical from backup
|
|
746
|
+
if (!existsSync(src)) {
|
|
747
|
+
errors.push(`backup missing for ${f.relative}`)
|
|
748
|
+
continue
|
|
749
|
+
}
|
|
750
|
+
mkdirSync(dirname(dest), { recursive: true })
|
|
751
|
+
copyFileSync(src, dest)
|
|
752
|
+
// verify restored sha matches manifest
|
|
753
|
+
const restoredRaw = readFileSync(dest)
|
|
754
|
+
const restoredSha = sha256Hex(restoredRaw)
|
|
755
|
+
if (restoredSha !== f.sha256) {
|
|
756
|
+
errors.push(`restore sha mismatch for ${f.relative} (expected ${f.sha256?.slice(0, 8)}, got ${restoredSha.slice(0, 8)})`)
|
|
757
|
+
} else {
|
|
758
|
+
restored += 1
|
|
759
|
+
}
|
|
760
|
+
} catch (e: any) {
|
|
761
|
+
errors.push(`restore failed for ${f.relative}: ${e?.message ?? String(e)}`)
|
|
762
|
+
}
|
|
763
|
+
}
|
|
764
|
+
|
|
765
|
+
// clear write-block if any
|
|
766
|
+
const blockPath = join(maestroMetaDir(root), 'write-block.json')
|
|
767
|
+
if (existsSync(blockPath)) {
|
|
768
|
+
try {
|
|
769
|
+
rmSync(blockPath, { force: true })
|
|
770
|
+
} catch {}
|
|
771
|
+
}
|
|
772
|
+
|
|
773
|
+
// journal
|
|
774
|
+
try {
|
|
775
|
+
const jPath = journalPath(root)
|
|
776
|
+
mkdirSync(dirname(jPath), { recursive: true })
|
|
777
|
+
appendFileSync(
|
|
778
|
+
jPath,
|
|
779
|
+
JSON.stringify({ runId: targetRunId, at: new Date().toISOString(), action: 'rollback', ok: errors.length === 0, restored, errors }) +
|
|
780
|
+
'\n',
|
|
781
|
+
'utf8',
|
|
782
|
+
)
|
|
783
|
+
} catch {}
|
|
784
|
+
|
|
785
|
+
return { ok: errors.length === 0, runId: targetRunId, manifestPath, restored, errors }
|
|
786
|
+
}
|