@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.
@@ -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
+ }