dsh-custom-mode 2.2.5 → 2.3.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/atomic.mjs CHANGED
@@ -14,8 +14,8 @@
14
14
  * random temporary name does not help with *that* collision, only with temp-vs-temp ones.
15
15
  */
16
16
  import { randomBytes } from 'node:crypto'
17
- import { existsSync, readdirSync, renameSync, rmSync, statSync, writeFileSync } from 'node:fs'
18
- import { join } from 'node:path'
17
+ import { existsSync, readFileSync, readdirSync, renameSync, rmSync, statSync, writeFileSync } from 'node:fs'
18
+ import { basename, dirname, join, resolve } from 'node:path'
19
19
 
20
20
  /** Synchronous backoff: these write paths are synchronous, so waiting is simpler than async plumbing. */
21
21
  function sleepSync(ms) {
@@ -63,7 +63,12 @@ export function renameWithRetry(from, to, options = {}) {
63
63
  */
64
64
  export function stageAtomic(file, text) {
65
65
  const temporary = `${file}.tmp-${String(process.pid)}-${randomBytes(4).toString('hex')}`
66
- writeFileSync(temporary, text, 'utf8')
66
+ try {
67
+ writeFileSync(temporary, text, 'utf8')
68
+ } catch (error) {
69
+ try { rmSync(temporary, { force: true }) } catch { /* retain the original failure */ }
70
+ throw error
71
+ }
67
72
  return { file, temporary }
68
73
  }
69
74
 
@@ -108,6 +113,14 @@ export function discardStaged(staged) {
108
113
  * @returns {void}
109
114
  */
110
115
  export function writeAtomicPair(entries) {
116
+ if (entries.length === 0) return
117
+ const directory = dirname(resolve(entries[0][0]))
118
+ const names = entries.map(([file]) => basename(file))
119
+ if (new Set(names).size !== names.length || entries.some(([file]) => dirname(resolve(file)) !== directory || !TRANSACTION_FILES.includes(basename(file)))) {
120
+ throw new Error('Atomic preset transactions require distinct preset files in one directory')
121
+ }
122
+ const recovery = restoreBackups(directory)
123
+ if (recovery.ok !== true) throw new Error(recovery.detail)
111
124
  const stamp = `${String(process.pid)}-${randomBytes(4).toString('hex')}`
112
125
 
113
126
  // ── phase 0: refuse to touch a destination that is a directory. ──
@@ -133,54 +146,41 @@ export function writeAtomicPair(entries) {
133
146
  throw error
134
147
  }
135
148
 
136
- // ── phase 2: move the current targets aside. `null` = the target did not exist. ──
137
- const backups = []
138
- const installed = []
149
+ // A prepared journal is on disk BEFORE any original moves. Recovery rolls the WHOLE transaction
150
+ // back, including files already installed; a committed journal keeps the whole new generation.
151
+ const journal = join(directory, `${TRANSACTION_PREFIX}${stamp}.json`)
152
+ const transaction = {
153
+ version: 1,
154
+ stamp,
155
+ ownerPid: process.pid,
156
+ phase: 'prepared',
157
+ entries: staged.map((one) => ({
158
+ name: basename(one.file),
159
+ temporary: basename(one.temporary),
160
+ backup: `${basename(one.file)}.bak-${stamp}`,
161
+ existed: existsSync(one.file),
162
+ })),
163
+ }
164
+ ACTIVE_TRANSACTIONS.add(directory)
139
165
  try {
140
- for (const one of staged) {
141
- if (existsSync(one.file)) {
142
- const backup = `${one.file}.bak-${stamp}`
143
- renameWithRetry(one.file, backup)
144
- backups.push([one.file, backup])
145
- } else {
146
- backups.push([one.file, null])
147
- }
148
- }
149
- // ── phase 3: install. ──
150
- for (const one of staged) {
151
- commitStaged(one)
152
- installed.push(one)
166
+ writeAtomic(journal, JSON.stringify(transaction) + '\n')
167
+ for (const one of transaction.entries) {
168
+ if (one.existed) renameWithRetry(join(directory, one.name), join(directory, one.backup))
153
169
  }
170
+ for (const one of staged) commitStaged(one)
171
+ writeAtomic(journal, JSON.stringify({ ...transaction, phase: 'committed' }) + '\n')
154
172
  } catch (error) {
155
- // Undo in reverse: remove what we installed, then put the backups back.
156
- for (const one of installed) {
157
- try {
158
- rmSync(one.file, { force: true })
159
- } catch {
160
- /* best effort — the restore below is what matters */
161
- }
162
- }
163
- for (const [file, backup] of backups) {
164
- if (backup === null) continue
165
- try {
166
- renameWithRetry(backup, file)
167
- } catch {
168
- /* best effort; the thrown error still tells the caller nothing is consistent */
169
- }
170
- }
173
+ // If writing the journal itself failed no target moved. Otherwise leave recovery evidence until
174
+ // rollback has completed; a failed restore must not be hidden as "nothing changed".
175
+ const outcome = existsSync(journal) ? recoverTransaction(directory, journal, transaction) : { ok: true }
171
176
  for (const one of staged) discardStaged(one)
177
+ ACTIVE_TRANSACTIONS.delete(directory)
178
+ if (outcome.ok !== true) throw new Error(String(error.message ?? error) + '; recovery required: ' + outcome.detail, { cause: error })
172
179
  throw error
173
180
  }
174
-
175
- // ── success: drop the backups ──
176
- for (const [, backup] of backups) {
177
- if (backup === null) continue
178
- try {
179
- rmSync(backup, { force: true })
180
- } catch {
181
- /* a leftover .bak-… is inert; failing the whole write over it would be worse */
182
- }
183
- }
181
+ // Cleanup failure is recoverable from the committed journal and cannot undo a successful write.
182
+ recoverTransaction(directory, journal, { ...transaction, phase: 'committed' })
183
+ ACTIVE_TRANSACTIONS.delete(directory)
184
184
  }
185
185
 
186
186
  /**
@@ -204,24 +204,124 @@ export function writeAtomicPair(entries) {
204
204
  * @param {string} directory - one assistant directory.
205
205
  * @returns {void}
206
206
  */
207
+ const TRANSACTION_FILES = ['prompt.md', 'agent.cordis.yml', 'preset.yml']
208
+ const TRANSACTION_PREFIX = '.custom-mode-transaction-'
209
+ const ACTIVE_TRANSACTIONS = new Set()
210
+
211
+ function pidFromStamp(stamp) {
212
+ const pid = Number(stamp.split('-')[0])
213
+ return Number.isSafeInteger(pid) && pid > 0 ? pid : null
214
+ }
215
+
216
+ /** A different live or uninspectable process might still be writing. Only ESRCH proves death. */
217
+ function ownerMayBeActive(pid) {
218
+ // This process runs transactions synchronously and ACTIVE_TRANSACTIONS covers its live writes.
219
+ if (pid === process.pid) return false
220
+ try {
221
+ process.kill(pid, 0)
222
+ return true
223
+ } catch (error) {
224
+ return error.code !== 'ESRCH'
225
+ }
226
+ }
227
+
228
+ function activeOwnerResult(pid) {
229
+ return { ok: false, code: 'recoveryRequired', detail: `Preset transaction owner ${pid} is active or cannot be inspected; retry after it finishes` }
230
+ }
231
+
232
+ function validTransaction(transaction, journalName) {
233
+ if (transaction?.version !== 1 || !/^\d+-[a-f0-9]{8}$/.test(transaction.stamp ?? '')) return false
234
+ if (journalName !== `${TRANSACTION_PREFIX}${transaction.stamp}.json`) return false
235
+ const stampedPid = pidFromStamp(transaction.stamp)
236
+ const ownerPid = transaction.ownerPid ?? stampedPid
237
+ if (stampedPid === null || !Number.isSafeInteger(ownerPid) || ownerPid <= 0) return false
238
+ if (transaction.legacy !== true && ownerPid !== stampedPid) return false
239
+ if (!['prepared', 'committed'].includes(transaction.phase) || !Array.isArray(transaction.entries) || transaction.entries.length === 0) return false
240
+ const names = new Set()
241
+ for (const one of transaction.entries) {
242
+ if (one === null || typeof one !== 'object') return false
243
+ if (!TRANSACTION_FILES.includes(one.name) || names.has(one.name) || typeof one.existed !== 'boolean') return false
244
+ if (one.backup !== `${one.name}.bak-${transaction.stamp}`) return false
245
+ if (transaction.legacy === true ? one.temporary !== null : !new RegExp(`^${one.name.replaceAll('.', '\\.')}\\.tmp-\\d+-[a-f0-9]{8}$`).test(one.temporary ?? '')) return false
246
+ names.add(one.name)
247
+ }
248
+ return true
249
+ }
250
+
251
+ function recoverTransaction(directory, journal, transaction) {
252
+ try {
253
+ if (transaction.phase === 'prepared') {
254
+ for (const one of transaction.entries) {
255
+ const file = join(directory, one.name)
256
+ const backup = join(directory, one.backup)
257
+ if (one.existed) {
258
+ if (existsSync(backup)) renameWithRetry(backup, file)
259
+ else if (!existsSync(file)) throw new Error(`Missing original and backup for ${one.name}`)
260
+ } else {
261
+ rmSync(file, { force: true })
262
+ }
263
+ }
264
+ }
265
+ for (const one of transaction.entries) {
266
+ rmSync(join(directory, one.backup), { force: true })
267
+ if (one.temporary !== null) rmSync(join(directory, one.temporary), { force: true })
268
+ }
269
+ rmSync(journal, { force: true })
270
+ return { ok: true }
271
+ } catch (error) {
272
+ return { ok: false, code: 'recoveryRequired', detail: String(error.message ?? error) }
273
+ }
274
+ }
275
+
276
+ /** Recover before ownership discovery: prompt.md itself can be temporarily absent after a crash. */
207
277
  export function restoreBackups(directory) {
208
- const names = ['prompt.md', 'agent.cordis.yml', 'preset.yml']
278
+ if (ACTIVE_TRANSACTIONS.has(resolve(directory))) return { ok: true, active: true }
209
279
  let entries
210
280
  try {
211
281
  entries = readdirSync(directory)
212
- } catch {
213
- return
282
+ } catch (error) {
283
+ return error.code === 'ENOENT' ? { ok: true } : { ok: false, code: 'recoveryRequired', detail: String(error.message ?? error) }
214
284
  }
215
- for (const name of names) {
216
- const file = join(directory, name)
217
- if (existsSync(file)) continue
218
- const backups = entries.filter((entry) => entry.startsWith(name + '.bak-'))
219
- if (backups.length !== 1) continue
285
+ const journals = entries.filter((entry) => entry.startsWith(TRANSACTION_PREFIX) && entry.endsWith('.json'))
286
+ // Shared homes across live processes are not a supported transaction boundary. Never guess which of
287
+ // multiple interrupted generations owns a target, and never seed a replacement over their evidence.
288
+ if (journals.length > 1) return { ok: false, code: 'recoveryRequired', detail: 'Multiple preset transactions require manual recovery' }
289
+ for (const name of journals) {
290
+ let transaction
220
291
  try {
221
- renameSync(join(directory, backups[0]), file)
222
- } catch {
223
- /* 恢复失败就留着备份,下一次读取再试 */
292
+ transaction = JSON.parse(readFileSync(join(directory, name), 'utf8'))
293
+ } catch (error) {
294
+ return { ok: false, code: 'recoveryRequired', detail: 'Unreadable preset transaction: ' + String(error.message ?? error) }
224
295
  }
296
+ if (!validTransaction(transaction, name)) return { ok: false, code: 'recoveryRequired', detail: 'Invalid preset transaction journal' }
297
+ const ownerPid = transaction.ownerPid ?? pidFromStamp(transaction.stamp)
298
+ if (ownerMayBeActive(ownerPid)) return activeOwnerResult(ownerPid)
299
+ const outcome = recoverTransaction(directory, join(directory, name), transaction)
300
+ if (outcome.ok !== true) return outcome
301
+ }
302
+ // Upgrade recovery for v2.2.5 transactions that predate the journal. Restore one whole backup group
303
+ // only when something is missing. Complete targets plus leftover backups denote a completed write.
304
+ try { entries = readdirSync(directory) } catch (error) {
305
+ return { ok: false, code: 'recoveryRequired', detail: String(error.message ?? error) }
306
+ }
307
+ const legacy = entries.map((entry) => ({ entry, match: /^(prompt\.md|agent\.cordis\.yml|preset\.yml)\.bak-(\d+-[a-f0-9]{8})$/.exec(entry) })).filter((one) => one.match !== null)
308
+ if (!legacy.some((one) => !existsSync(join(directory, one.match[1])))) return { ok: true }
309
+ if (new Set(legacy.map((one) => one.match[2])).size !== 1) return { ok: false, code: 'recoveryRequired', detail: 'Ambiguous legacy preset backups require manual recovery' }
310
+ const legacyOwner = pidFromStamp(legacy[0].match[2])
311
+ if (legacyOwner === null) return { ok: false, code: 'recoveryRequired', detail: 'Invalid legacy preset transaction owner' }
312
+ if (ownerMayBeActive(legacyOwner)) return activeOwnerResult(legacyOwner)
313
+ try {
314
+ // Recovery itself can be interrupted. Journal the whole legacy group BEFORE restoring even the
315
+ // first missing target; otherwise that first rename can hide the need to restore the rest.
316
+ const stamp = legacy[0].match[2]
317
+ const journal = join(directory, `${TRANSACTION_PREFIX}${stamp}.json`)
318
+ const transaction = { version: 1, stamp, ownerPid: process.pid, phase: 'prepared', legacy: true, entries: legacy.map((one) => ({
319
+ name: one.match[1], backup: one.entry, temporary: null, existed: true,
320
+ })) }
321
+ writeAtomic(journal, JSON.stringify(transaction) + '\n')
322
+ return recoverTransaction(directory, journal, transaction)
323
+ } catch (error) {
324
+ return { ok: false, code: 'recoveryRequired', detail: String(error.message ?? error) }
225
325
  }
226
326
  }
227
327