@dsh-fly/dsh-plugin-fly-smart 0.0.0-stage → 0.2.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/LICENSE +21 -0
- package/README.md +122 -2
- package/THIRD_PARTY_NOTICES.md +73 -0
- package/assets/smart-debug/SKILL.md +319 -0
- package/assets/smart-diagnose/SKILL.md +170 -0
- package/assets/smart-parallel/SKILL.md +190 -0
- package/assets/smart-plan/SKILL.md +184 -0
- package/assets/smart-review-receive/SKILL.md +230 -0
- package/assets/smart-skills/SKILL.md +96 -0
- package/assets/smart-tdd/SKILL.md +333 -0
- package/assets/smart-verify-claim/SKILL.md +129 -0
- package/assets/smart-worktree/SKILL.md +182 -0
- package/assets/smart-write-skill/SKILL.md +277 -0
- package/cordis.patch.yml +12 -0
- package/index.js +847 -0
- package/lib/client.js +1569 -0
- package/lib/state.mjs +2389 -0
- package/package.json +67 -4
- package/panel/reader.js +481 -0
- package/panel/routes.mjs +393 -0
- package/tools/index.js +1505 -0
package/panel/routes.mjs
ADDED
|
@@ -0,0 +1,393 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The route mirror, v2 — the one configuration surface both planes share.
|
|
3
|
+
*
|
|
4
|
+
* ## Why a file at all
|
|
5
|
+
*
|
|
6
|
+
* The model pair is edited in the `fly.smart` workbench (host plane) but consumed
|
|
7
|
+
* by the tools (agent plane, `dsh-plugin-fly-smart`). The two planes share no
|
|
8
|
+
* vocabulary, so the interface is a file. Since this page became an *editor* and
|
|
9
|
+
* not just a viewer, the file also has to answer two questions the v1 shape could
|
|
10
|
+
* not:
|
|
11
|
+
*
|
|
12
|
+
* 1. **Which session was this for?** The hand route is per-session by default
|
|
13
|
+
* (`sessions[<id>]`), because the page is opened beside one session and
|
|
14
|
+
* "make it the default for every session" must be a separate, deliberate act.
|
|
15
|
+
* 2. **Who wins?** The scripting plane (`tool-fly-smart` row config) is the
|
|
16
|
+
* hard floor, then this session, then the mirror default.
|
|
17
|
+
*
|
|
18
|
+
* ## v2 schema (frozen; see acceptance.md §2)
|
|
19
|
+
*
|
|
20
|
+
* ```json
|
|
21
|
+
* { "version": 2,
|
|
22
|
+
* "default": { "hand": "openrouter/deepseek/deepseek-chat", "at": "…" },
|
|
23
|
+
* "sessions": { "session-<uuid>": { "hand": "…@high", "at": "…" } },
|
|
24
|
+
* "hand": "…" }
|
|
25
|
+
* ```
|
|
26
|
+
*
|
|
27
|
+
* `hand` at the top level is the **legacy mirror** of `default.hand`: an engine
|
|
28
|
+
* whose `dsh-plugin-fly-smart` predates v2 reads only that key, and it is the only
|
|
29
|
+
* thing standing between an upgraded panel and "no hand model is configured" in
|
|
30
|
+
* every session. The downgrade path exists in exactly that one direction (the old
|
|
31
|
+
* *panel* could never write the mirror at all — `typeof ref === 'string'` is false
|
|
32
|
+
* for a schema ref, so no old panel can damage v2 keys).
|
|
33
|
+
*
|
|
34
|
+
* ## Paths are not derived from the task root
|
|
35
|
+
*
|
|
36
|
+
* v1 put the mirror *beside* the smart root (`dirname(rootDir)/smart-routes.json`).
|
|
37
|
+
* That was fine while the root was one fixed directory; now that the root is the
|
|
38
|
+
* session's workspace (`<workspace>/.dsh-fly/smart`), deriving the mirror from it
|
|
39
|
+
* would give every workspace its own route file — the panel would write A while
|
|
40
|
+
* the agent read B, silently, with every route resolving to nothing. The mirror
|
|
41
|
+
* therefore lives at a **fixed** location (`~/.dsh-fly/smart-routes.json`), and
|
|
42
|
+
* the lock directory follows the mirror so a test override cannot reach back into
|
|
43
|
+
* the real one (`acceptance.md` M1).
|
|
44
|
+
*
|
|
45
|
+
* @module dsh-plugin-fly-smart-panel/routes
|
|
46
|
+
*/
|
|
47
|
+
|
|
48
|
+
import { closeSync, mkdirSync, openSync, readFileSync, renameSync, rmSync, statSync, writeFileSync } from 'node:fs'
|
|
49
|
+
import { randomBytes } from 'node:crypto'
|
|
50
|
+
import { homedir } from 'node:os'
|
|
51
|
+
import { dirname, join, resolve } from 'node:path'
|
|
52
|
+
|
|
53
|
+
/** The mirror file name. The agent half pins the same constant (`state.mjs`). */
|
|
54
|
+
export const ROUTE_MIRROR_FILE = 'smart-routes.json'
|
|
55
|
+
|
|
56
|
+
/** Absolute-path override for the mirror **file** (tests, headless). */
|
|
57
|
+
export const MIRROR_PATH_ENV = 'DSH_FLY_SMART_ROUTES'
|
|
58
|
+
|
|
59
|
+
/** The v2 schema version written by every writer here. */
|
|
60
|
+
export const MIRROR_VERSION = 2
|
|
61
|
+
|
|
62
|
+
/** How long a lock file may sit untouched before it is treated as a corpse. */
|
|
63
|
+
export const LOCK_STALE_MS = 10_000
|
|
64
|
+
|
|
65
|
+
/** Largest POST body accepted on `/fly/smart/routes` (bytes). */
|
|
66
|
+
export const MAX_BODY_BYTES = 4_096
|
|
67
|
+
|
|
68
|
+
/** How many sessions the mirror may hold before new keys are refused. */
|
|
69
|
+
export const MAX_SESSIONS = 200
|
|
70
|
+
|
|
71
|
+
/**
|
|
72
|
+
* Where the mirror lives.
|
|
73
|
+
*
|
|
74
|
+
* Deliberately **not** `dirname(taskRoot)`: the task root is per-workspace now, so
|
|
75
|
+
* that derivation would split the configuration across workspaces.
|
|
76
|
+
*
|
|
77
|
+
* @param env - process environment (injectable for tests).
|
|
78
|
+
* @returns an absolute path.
|
|
79
|
+
*/
|
|
80
|
+
export function resolveMirrorPath(env = process.env) {
|
|
81
|
+
const configured = typeof env?.[MIRROR_PATH_ENV] === 'string' ? env[MIRROR_PATH_ENV].trim() : ''
|
|
82
|
+
if (configured !== '') return resolve(configured)
|
|
83
|
+
return join(homedir(), '.dsh-fly', ROUTE_MIRROR_FILE)
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
/**
|
|
87
|
+
* The lock guarding one mirror. Lives in a `locks/` directory beside the mirror
|
|
88
|
+
* file, so overriding the mirror (tests) also moves the lock out of `~/.dsh-fly`.
|
|
89
|
+
*
|
|
90
|
+
* @param mirrorPath - absolute mirror path.
|
|
91
|
+
* @returns an absolute lock path.
|
|
92
|
+
*/
|
|
93
|
+
export function mirrorLockPath(mirrorPath = resolveMirrorPath()) {
|
|
94
|
+
return join(dirname(mirrorPath), 'locks', `${ROUTE_MIRROR_FILE}.lock`)
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
/**
|
|
98
|
+
* Parse `"provider/model[@effort]"` exactly the way the agent half does
|
|
99
|
+
* (`state.mjs`'s `parseRouteValue`); a cross-package test pins the two together.
|
|
100
|
+
*
|
|
101
|
+
* @param value - candidate string.
|
|
102
|
+
* @returns `{ provider, model, reasoningEffort? }`, or undefined.
|
|
103
|
+
*/
|
|
104
|
+
export function parseRouteString(value) {
|
|
105
|
+
if (typeof value !== 'string') return undefined
|
|
106
|
+
const text = value.trim()
|
|
107
|
+
if (text === '') return undefined
|
|
108
|
+
const cut = text.indexOf('/')
|
|
109
|
+
if (cut <= 0 || cut === text.length - 1) return undefined
|
|
110
|
+
const provider = text.slice(0, cut).trim()
|
|
111
|
+
const rest = text.slice(cut + 1).trim()
|
|
112
|
+
if (provider === '' || rest === '') return undefined
|
|
113
|
+
const at = rest.indexOf('@')
|
|
114
|
+
if (at <= 0) return { provider, model: rest }
|
|
115
|
+
const model = rest.slice(0, at).trim()
|
|
116
|
+
const effort = rest.slice(at + 1).trim()
|
|
117
|
+
return model === '' ? undefined : effort === '' ? { provider, model } : { provider, model, reasoningEffort: effort }
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
/** `provider/model[@effort]` — the text form both planes agree on. */
|
|
121
|
+
export function formatRoute(route) {
|
|
122
|
+
if (route === undefined || route === null) return ''
|
|
123
|
+
const base = `${route.provider}/${route.model}`
|
|
124
|
+
return route.reasoningEffort === undefined ? base : `${base}@${route.reasoningEffort}`
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
/**
|
|
128
|
+
* A session id we are willing to use as a JSON key.
|
|
129
|
+
*
|
|
130
|
+
* Shape is **not** whitelisted: the engine mints `session-<n>` from a counter
|
|
131
|
+
* (`dsh-session` package `lib/index.js:1648`) as well as UUIDs, and CLI users pick
|
|
132
|
+
* their own ids. What is refused is what cannot be a key at all — empty, overlong,
|
|
133
|
+
* separators, `..`, control characters (`acceptance.md` M13).
|
|
134
|
+
*
|
|
135
|
+
* @param value - candidate id.
|
|
136
|
+
* @returns whether the id may be used.
|
|
137
|
+
*/
|
|
138
|
+
export function isValidSessionId(value) {
|
|
139
|
+
if (typeof value !== 'string') return false
|
|
140
|
+
if (value.length === 0 || value.length > 200) return false
|
|
141
|
+
if (value.includes('..') || value.includes('/') || value.includes('\\')) return false
|
|
142
|
+
// eslint-disable-next-line no-control-regex
|
|
143
|
+
if (/[\u0000-\u001f\u007f]/u.test(value)) return false
|
|
144
|
+
return true
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
/**
|
|
148
|
+
* Read the mirror into layers, tolerating every kind of "not there yet".
|
|
149
|
+
*
|
|
150
|
+
* A malformed file reads as "no routes configured", which the agent already
|
|
151
|
+
* reports loudly at call time — the panel must not refuse to render because a
|
|
152
|
+
* hand-edited JSON lost a brace.
|
|
153
|
+
*
|
|
154
|
+
* @param mirrorPath - absolute mirror path.
|
|
155
|
+
* @returns `{ raw, sessions, defaultHand, defaultAt, legacyBrain }`.
|
|
156
|
+
*/
|
|
157
|
+
export function readMirrorDocument(mirrorPath = resolveMirrorPath()) {
|
|
158
|
+
let raw = null
|
|
159
|
+
try {
|
|
160
|
+
raw = JSON.parse(readFileSync(mirrorPath, 'utf8'))
|
|
161
|
+
} catch {
|
|
162
|
+
raw = null
|
|
163
|
+
}
|
|
164
|
+
if (raw === null || typeof raw !== 'object' || Array.isArray(raw)) raw = {}
|
|
165
|
+
|
|
166
|
+
const sessions = {}
|
|
167
|
+
const rawSessions = raw.sessions
|
|
168
|
+
if (rawSessions !== null && typeof rawSessions === 'object' && !Array.isArray(rawSessions)) {
|
|
169
|
+
for (const [id, value] of Object.entries(rawSessions)) {
|
|
170
|
+
if (!isValidSessionId(id)) continue
|
|
171
|
+
sessions[id] = {
|
|
172
|
+
hand: typeof value?.hand === 'string' ? value.hand.trim() : '',
|
|
173
|
+
...typeof value?.review === 'string' && value.review.trim() !== '' ? { review: value.review.trim() } : {},
|
|
174
|
+
at: typeof value?.at === 'string' ? value.at : null,
|
|
175
|
+
}
|
|
176
|
+
}
|
|
177
|
+
}
|
|
178
|
+
// v2 first, legacy top-level as the fallback (acceptance.md §2: 读侧 v2 优先、顶层回落).
|
|
179
|
+
const defaultHand = typeof raw.default?.hand === 'string' && raw.default.hand.trim() !== ''
|
|
180
|
+
? raw.default.hand.trim()
|
|
181
|
+
: typeof raw.hand === 'string' ? raw.hand.trim() : ''
|
|
182
|
+
const defaultReview = typeof raw.default?.review === 'string' ? raw.default.review.trim() : ''
|
|
183
|
+
const defaultReviewAt = typeof raw.default?.reviewAt === 'string' ? raw.default.reviewAt : null
|
|
184
|
+
const defaultAt = typeof raw.default?.at === 'string'
|
|
185
|
+
? raw.default.at
|
|
186
|
+
: typeof raw.at === 'string' ? raw.at : null
|
|
187
|
+
return {
|
|
188
|
+
raw,
|
|
189
|
+
sessions,
|
|
190
|
+
defaultHand,
|
|
191
|
+
defaultReview,
|
|
192
|
+
defaultReviewAt,
|
|
193
|
+
defaultAt,
|
|
194
|
+
legacyBrain: typeof raw.brain === 'string' ? raw.brain.trim() : '',
|
|
195
|
+
}
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
/**
|
|
199
|
+
* Which hand route a given session resolves to, and where it came from.
|
|
200
|
+
* @param doc - {@link readMirrorDocument} result.
|
|
201
|
+
* @param sessionId - session id, or null when the page has none.
|
|
202
|
+
* @returns `{ hand, source, at }` — `source` is `session`/`default`/null.
|
|
203
|
+
*/
|
|
204
|
+
export function resolveHand(doc, sessionId) {
|
|
205
|
+
if (sessionId !== null && isValidSessionId(sessionId)) {
|
|
206
|
+
const row = doc.sessions[sessionId]
|
|
207
|
+
if (row !== undefined && row.hand !== '') return { hand: row.hand, source: 'session', at: row.at }
|
|
208
|
+
}
|
|
209
|
+
if (doc.defaultHand !== '') return { hand: doc.defaultHand, source: 'default', at: doc.defaultAt }
|
|
210
|
+
return { hand: '', source: null, at: null }
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
export function resolveReview(doc, sessionId) {
|
|
214
|
+
if (sessionId !== null && isValidSessionId(sessionId)) {
|
|
215
|
+
const row = doc.sessions[sessionId]
|
|
216
|
+
if (row !== undefined && row.review !== '') return { review: row.review, source: 'session', at: row.at }
|
|
217
|
+
}
|
|
218
|
+
if (doc.defaultReview !== '') return { review: doc.defaultReview, source: 'default', at: doc.defaultReviewAt }
|
|
219
|
+
return { review: '', source: null, at: null }
|
|
220
|
+
}
|
|
221
|
+
/**
|
|
222
|
+
* Compute the next mirror document for one route write, without touching disk.
|
|
223
|
+
*
|
|
224
|
+
* Never deletes a key it was not asked to touch: unknown top-level keys (the
|
|
225
|
+
* legacy `brain` field, a future field written by a newer panel) are carried over
|
|
226
|
+
* verbatim. The only ports that clear anything are an explicit `""` on the layer
|
|
227
|
+
* being written, and the "both layers empty" case, which removes the file.
|
|
228
|
+
*
|
|
229
|
+
* @param doc - {@link readMirrorDocument} result.
|
|
230
|
+
* @param input - `{ scope, sessionId, hand, now }`.
|
|
231
|
+
* @returns `{ next, changed, at }` — `next` is null when the file should be removed.
|
|
232
|
+
*/
|
|
233
|
+
export function planRouteWrite(doc, { role = 'hand', scope, sessionId = null, value = '', now = new Date().toISOString() }) {
|
|
234
|
+
const key = role === 'review' ? 'review' : 'hand'
|
|
235
|
+
const current = scope === 'session' ? (doc.sessions[sessionId]?.[key] ?? '') : key === 'review' ? doc.defaultReview : doc.defaultHand
|
|
236
|
+
if (current === value) return { next: null, changed: false, at: scope === 'session' ? (doc.sessions[sessionId]?.at ?? null) : key === 'review' ? doc.defaultReviewAt : doc.defaultAt }
|
|
237
|
+
const sessions = {}
|
|
238
|
+
for (const [id, row] of Object.entries(doc.sessions)) sessions[id] = { ...row }
|
|
239
|
+
let defaultHand = doc.defaultHand ?? ''
|
|
240
|
+
let defaultReview = doc.defaultReview ?? ''
|
|
241
|
+
let defaultAt = doc.defaultAt
|
|
242
|
+
let defaultReviewAt = doc.defaultReviewAt
|
|
243
|
+
if (scope === 'session') {
|
|
244
|
+
if (value === '') {
|
|
245
|
+
if (sessions[sessionId] !== undefined) delete sessions[sessionId][key]
|
|
246
|
+
if (sessions[sessionId] !== undefined && sessions[sessionId].hand === undefined && sessions[sessionId].review === undefined) delete sessions[sessionId]
|
|
247
|
+
} else sessions[sessionId] = { ...(sessions[sessionId] ?? {}), [key]: value, at: now }
|
|
248
|
+
} else if (key === 'review') {
|
|
249
|
+
defaultReview = value
|
|
250
|
+
defaultReviewAt = value === '' ? null : now
|
|
251
|
+
} else {
|
|
252
|
+
defaultHand = value
|
|
253
|
+
defaultAt = value === '' ? null : now
|
|
254
|
+
}
|
|
255
|
+
const next = { version: MIRROR_VERSION }
|
|
256
|
+
if (defaultHand !== '') { next.default = { hand: defaultHand, at: defaultAt ?? now }; next.hand = defaultHand }
|
|
257
|
+
if (typeof defaultReview === 'string' && defaultReview !== '') next.default = { ...(next.default ?? {}), review: defaultReview, at: defaultReviewAt ?? now }
|
|
258
|
+
if (Object.keys(sessions).length > 0) next.sessions = sessions
|
|
259
|
+
for (const [keyName, val] of Object.entries(doc.raw)) if (!['version', 'default', 'sessions', 'hand', 'at'].includes(keyName)) next[keyName] = val
|
|
260
|
+
return { next: next.default === undefined && next.sessions === undefined ? null : next, changed: true, at: scope === 'session' ? now : key === 'review' ? defaultReviewAt : defaultAt }
|
|
261
|
+
}
|
|
262
|
+
|
|
263
|
+
export const planHandWrite = (doc, input) => planRouteWrite(doc, { ...input, role: 'hand', value: input.hand ?? '' })
|
|
264
|
+
|
|
265
|
+
/**
|
|
266
|
+
* Write bytes so a reader never sees half a document, with an explicit `0600`.
|
|
267
|
+
*
|
|
268
|
+
* The `0600` is not decoration: this file names the model that will run code in
|
|
269
|
+
* the user's sessions, and the previous `writeFileSync` default (0644) would widen
|
|
270
|
+
* it the first time an existing 0600 mirror was rewritten. The temp name carries
|
|
271
|
+
* the pid **and** random bytes — `pid-Date.now` collides when two writes land in
|
|
272
|
+
* the same millisecond, which is exactly what a burst of saves does.
|
|
273
|
+
*
|
|
274
|
+
* @param file - destination path.
|
|
275
|
+
* @param text - full contents.
|
|
276
|
+
* @param mode - file mode (default 0600).
|
|
277
|
+
*/
|
|
278
|
+
export function writeAtomicPrivate(file, text, mode = 0o600) {
|
|
279
|
+
const tmp = `${file}.tmp-${process.pid}-${Date.now()}-${randomBytes(4).toString('hex')}`
|
|
280
|
+
writeFileSync(tmp, text, { encoding: 'utf8', mode })
|
|
281
|
+
try {
|
|
282
|
+
renameSync(tmp, file)
|
|
283
|
+
} catch (error) {
|
|
284
|
+
rmSync(tmp, { force: true })
|
|
285
|
+
throw error
|
|
286
|
+
}
|
|
287
|
+
}
|
|
288
|
+
|
|
289
|
+
/** Raised when the mirror lock is held; the handler maps it to 409. */
|
|
290
|
+
export class LockBusy extends Error {
|
|
291
|
+
constructor(lockPath) {
|
|
292
|
+
super(`smart routes: another writer holds the mirror lock (${lockPath})`)
|
|
293
|
+
this.name = 'LockBusy'
|
|
294
|
+
}
|
|
295
|
+
}
|
|
296
|
+
|
|
297
|
+
/**
|
|
298
|
+
* Take an exclusive lock, synchronously.
|
|
299
|
+
*
|
|
300
|
+
* Synchronous on purpose: the critical section is read-modify-atomic-rename with
|
|
301
|
+
* no awaits, so within one process the lock can never be released by a task that
|
|
302
|
+
* has not finished. What it does cover is a second process (the packaged app plus
|
|
303
|
+
* a dev session) and a crashed writer (stale reclaim).
|
|
304
|
+
*
|
|
305
|
+
* @param lockPath - lock file.
|
|
306
|
+
* @param options - `{ staleMs, now }`.
|
|
307
|
+
* @returns the release function.
|
|
308
|
+
*/
|
|
309
|
+
export function acquireLock(lockPath, { staleMs = LOCK_STALE_MS, now = Date.now() } = {}) {
|
|
310
|
+
for (let attempt = 0; attempt < 2; attempt += 1) {
|
|
311
|
+
try {
|
|
312
|
+
mkdirSync(dirname(lockPath), { recursive: true })
|
|
313
|
+
const fd = openSync(lockPath, 'wx')
|
|
314
|
+
try {
|
|
315
|
+
writeFileSync(fd, `${process.pid} ${new Date(now).toISOString()}\n`, 'utf8')
|
|
316
|
+
} finally {
|
|
317
|
+
closeSync(fd)
|
|
318
|
+
}
|
|
319
|
+
return () => { rmSync(lockPath, { force: true }) }
|
|
320
|
+
} catch (error) {
|
|
321
|
+
if (error?.code !== 'EEXIST') throw error
|
|
322
|
+
// 写入是同步的、通常微秒级,所以"超过 10 秒还没释放"只可能是写者已经死了。
|
|
323
|
+
let age = Number.POSITIVE_INFINITY
|
|
324
|
+
try {
|
|
325
|
+
age = now - statSync(lockPath).mtimeMs
|
|
326
|
+
} catch {
|
|
327
|
+
// 锁在这一刻被别人释放了:直接再抢一次。
|
|
328
|
+
continue
|
|
329
|
+
}
|
|
330
|
+
if (age > staleMs) {
|
|
331
|
+
rmSync(lockPath, { force: true })
|
|
332
|
+
continue
|
|
333
|
+
}
|
|
334
|
+
throw new LockBusy(lockPath)
|
|
335
|
+
}
|
|
336
|
+
}
|
|
337
|
+
throw new LockBusy(lockPath)
|
|
338
|
+
}
|
|
339
|
+
|
|
340
|
+
/**
|
|
341
|
+
* Run `fn` under the mirror lock, always releasing it.
|
|
342
|
+
* @param lockPath - lock file.
|
|
343
|
+
* @param fn - critical section.
|
|
344
|
+
* @returns whatever `fn` returns.
|
|
345
|
+
*/
|
|
346
|
+
export function withMirrorLock(lockPath, fn) {
|
|
347
|
+
const release = acquireLock(lockPath)
|
|
348
|
+
try {
|
|
349
|
+
return fn()
|
|
350
|
+
} finally {
|
|
351
|
+
release()
|
|
352
|
+
}
|
|
353
|
+
}
|
|
354
|
+
|
|
355
|
+
/**
|
|
356
|
+
* One hand write against the real file: lock, read, plan, write (or not).
|
|
357
|
+
*
|
|
358
|
+
* @param mirrorPath - absolute mirror path.
|
|
359
|
+
* @param input - `{ scope, sessionId, hand }`.
|
|
360
|
+
* @returns `{ changed, at, path, doc }`.
|
|
361
|
+
*/
|
|
362
|
+
export function updateMirror(mirrorPath, input) {
|
|
363
|
+
const lockPath = mirrorLockPath(mirrorPath)
|
|
364
|
+
return withMirrorLock(lockPath, () => {
|
|
365
|
+
const doc = readMirrorDocument(mirrorPath)
|
|
366
|
+
const planned = planRouteWrite(doc, { ...input, value: input.role === 'review' ? input.review : input.hand })
|
|
367
|
+
if (!planned.changed) return { changed: false, at: planned.at, path: mirrorPath, doc }
|
|
368
|
+
if (planned.next === null) {
|
|
369
|
+
rmSync(mirrorPath, { force: true })
|
|
370
|
+
return { changed: true, at: null, path: mirrorPath, doc: readMirrorDocument(mirrorPath) }
|
|
371
|
+
}
|
|
372
|
+
mkdirSync(dirname(mirrorPath), { recursive: true })
|
|
373
|
+
writeAtomicPrivate(mirrorPath, `${JSON.stringify(planned.next, null, 2)}\n`)
|
|
374
|
+
return { changed: true, at: planned.at, path: mirrorPath, doc: readMirrorDocument(mirrorPath) }
|
|
375
|
+
})
|
|
376
|
+
}
|
|
377
|
+
|
|
378
|
+
/**
|
|
379
|
+
* The boot-time merge: the scripting plane's row config is the hard floor.
|
|
380
|
+
*
|
|
381
|
+
* Only writes a **non-empty** row value into `default`, and never deletes a key —
|
|
382
|
+
* boot is not allowed to clear a user's choice, it can only assert one.
|
|
383
|
+
*
|
|
384
|
+
* @param mirrorPath - absolute mirror path.
|
|
385
|
+
* @param routes - `{ hand }` as the row spells them.
|
|
386
|
+
* @returns the mirror path when something was written, else null.
|
|
387
|
+
*/
|
|
388
|
+
export function mergeRowRoutes(mirrorPath, routes) {
|
|
389
|
+
const hand = typeof routes?.hand === 'string' ? routes.hand.trim() : ''
|
|
390
|
+
if (hand === '') return null
|
|
391
|
+
const result = updateMirror(mirrorPath, { scope: 'default', sessionId: null, hand })
|
|
392
|
+
return result.path
|
|
393
|
+
}
|